Compare commits

..

7 Commits

Author SHA1 Message Date
6c6684f759 release(2.4.0): intégration des features app-binding et focus Wayland
Merge de develop vers main pour la release 2.4.0 :
- liaison des presets aux applications (app-binding)
- détection correcte du focus/unfocus sur les backends Wayland

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 00:54:28 +02:00
0b560f69e0 chore(release): passage en version 2.4.0
Bump de version pour la release intégrant la liaison presets↔applications
et la détection du focus/unfocus sur les backends Wayland.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 00:54:23 +02:00
3ebfd278af merge(focus): correction unfocus backends Wayland dans develop
Intègre la détection d'unfocus corrigée (FocusService app_id vide, Sway
new/title, WlrForeignToplevel fermeture du toplevel courant).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 00:42:19 +02:00
af10ec8bd1 fix(focus): détecter correctement l'unfocus sur les backends Wayland
Le suivi du focus laissait les injections app-controlled actives lors d'une
perte de focus réelle (fermeture de la fenêtre courante, retour à un bureau
vide). On corrige la détection sur toute la chaîne :

- FocusService: un app_id vide est désormais traité comme un unfocus réel
  (et non un focus transitoire ignoré), ce qui stoppe les injections liées
  à l'application.
- Sway: on ignore les events 'new' (pas un changement de focus) et on relit
  le focus courant sur un event 'title' non focused.
- WlrForeignToplevelBackend: suit _current_handle, le clear sur stop() et
  émet un FocusEvent vide quand le toplevel actuellement focus se ferme.

Tests: test_focus_service + test_focus_watcher (67) OK; lot feature
historique (295) OK.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 00:42:15 +02:00
53fe0cc369 merge(app-binding): intégration de la liaison presets/applications dans develop
Intègre la feature preset-app-binding. Tests unitaires/backend/frontend verts (288 tests OK). Test système GUI non validé pour raison d'environnement (SIGSEGV GTK headless, xvfb absent, session Wayland) — pas d'échec de logique.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 23:55:43 +02:00
9ef2c1439c feat(app-binding): lier les presets aux applications
Ajoute la configuration des associations application/preset, le service de suivi du focus, l'intégration GUI et les fichiers d'installation nécessaires.

Ajoute les tests unitaires ciblés des bindings, du service de focus, des migrations et des composants GUI associés.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 23:52:34 +02:00
0ab77d2a64 chore(repo): baseline du fork input-remapper (upstream v2.2.1)
Le dépôt ne contenait que README.md ; ajout de l'intégralité du code
fonctionnel du fork comme socle stable de la branche de release.
L'état runtime d'orchestration (.ideai/) est exclu du suivi.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 00:47:12 +02:00
278 changed files with 56328 additions and 0 deletions

17
.coveragerc Normal file
View File

@ -0,0 +1,17 @@
[run]
branch = True
source = ./inputremapper
concurrency = multiprocessing
debug = multiproc
omit =
# not used currently due to problems
./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.*\):

142
.gitignore vendored Normal file
View File

@ -0,0 +1,142 @@
mo
*.glade~
*.glade#
.idea
*.png~*
*.orig
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
# C extensions
*.so
# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
/lib/
lib64/
parts/
sdist/
var/
wheels/
pip-wheel-metadata/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# PyInstaller
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py,cover
.hypothesis/
.pytest_cache/
# pyreverse graphs
*.dot
# Translations
*.mo
# Django stuff:
*.log
local_settings.py
db.sqlite3
db.sqlite3-journal
# Flask stuff:
instance/
.webassets-cache
# Scrapy stuff:
.scrapy
# Sphinx documentation
docs/_build/
# PyBuilder
target/
# Jupyter Notebook
.ipynb_checkpoints
# IPython
profile_default/
ipython_config.py
# pyenv
.python-version
# pipenv
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
# However, in case of collaboration, if having platform-specific dependencies or dependencies
# having no cross-platform support, pipenv may install dependencies that don't work, or not
# install all needed dependencies.
#Pipfile.lock
# PEP 582; used by e.g. github.com/David-OConnor/pyflow
__pypackages__/
# Celery stuff
celerybeat-schedule
celerybeat.pid
# SageMath parsed files
*.sage.py
# Environments
.env
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
# Spyder project settings
.spyderproject
.spyproject
# Rope project settings
.ropeproject
# mkdocs documentation
/site
# mypy
.mypy_cache/
.dmypy.json
dmypy.json
# Pyre type checker
.pyre/
# IdeA orchestration runtime state
.ideai/

3
.mypy.ini Normal file
View File

@ -0,0 +1,3 @@
[mypy]
plugins = pydantic.mypy
ignore_missing_imports = True

17
.pylintrc Normal file
View File

@ -0,0 +1,17 @@
[_]
max-line-length=88 # black
extension-pkg-whitelist=evdev, pydantic
load-plugins=pylint_pydantic
disable=
# that is the standard way to import GTK afaik
wrong-import-position,
# using """ for comments highlights them in green for me and makes it
# a great way to separate stuff into multiple sections
pointless-string-statement
# https://github.com/psf/black/blob/main/docs/compatible_configs/pylint/pylintrc
C0330, C0326

21
.reviewdog.yml Normal file
View File

@ -0,0 +1,21 @@
---
runner:
mypy:
name: mypy
cmd: mypy --show-column-numbers inputremapper tests install --ignore-missing-imports
errorformat:
- "%f:%l:%c: %m"
pylint:
name: pylint
cmd: pylint inputremapper tests --extension-pkg-whitelist=evdev
errorformat:
- "%f:%l:%c: %t%n: %m"
flake8:
cmd: flake8 inputremapper tests
format: flake8
black:
cmd: black --diff --quiet --check ./inputremapper ./tests
format: black

9
DEBIAN/control Normal file
View File

@ -0,0 +1,9 @@
Package: input-remapper
Version: 2.4.0
Architecture: all
Maintainer: Sezanzeb <b8x45ygc9@mozmail.com>
Depends: build-essential, libpython3-dev, libdbus-1-dev, python3, python3-evdev, python3-dasbus, python3-gi, gettext, python3-cairo, libgtk-3-0, libgtksourceview-4-dev, python3-pydantic, python3-packaging, python3-psutil, python3-xlib
Recommends: python3-pywayland
Description: A tool to change the mapping of your input device buttons
Replaces: python3-key-mapper, key-mapper, input-remapper-gtk, input-remapper-daemon, python3-inputremapper
Conflicts: python3-key-mapper, key-mapper, input-remapper-gtk, input-remapper-daemon, python3-inputremapper

3
DEBIAN/copyright Normal file
View File

@ -0,0 +1,3 @@
Files: *
Copyright: 2025 Sezanzeb
License: GPL-3+

21
DEBIAN/postinst Executable file
View File

@ -0,0 +1,21 @@
#!/bin/bash
if [ -d "/run/systemd/system/" ]; then
# old name, those lines should at some point be removed from postinst
pkill -f key-mapper-service
systemctl disable key-mapper 2> /dev/null || true
systemctl stop key-mapper 2> /dev/null || true
# The ubuntu package creates those two symlinks that break when installing the .deb
# built from source. Either ubuntus packages need to be uninstalled with --purge first,
# or those files need to be unlinked manually.
unlink /etc/systemd/system/input-remapper.service || true
unlink /etc/systemd/system/default.target.wants/input-remapper-daemon.service || true
pkill -f input-remapper-service # might have been started by the gui previously
systemctl enable input-remapper
systemctl start input-remapper
# The focus-service is a per-user service. Enable it globally so it starts in
# each user's graphical session (takes effect on next login).
systemctl --global enable input-remapper-focus.service 2> /dev/null || true
fi

674
LICENSE Normal file
View File

@ -0,0 +1,674 @@
GNU GENERAL PUBLIC LICENSE
Version 3, 29 June 2007
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The GNU General Public License is a free, copyleft license for
software and other kinds of works.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
the GNU General Public License is intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free
software for all its users. We, the Free Software Foundation, use the
GNU General Public License for most of our software; it applies also to
any other work released this way by its authors. You can apply it to
your programs, too.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.
To protect your rights, we need to prevent others from denying you
these rights or asking you to surrender the rights. Therefore, you have
certain responsibilities if you distribute copies of the software, or if
you modify it: responsibilities to respect the freedom of others.
For example, if you distribute copies of such a program, whether
gratis or for a fee, you must pass on to the recipients the same
freedoms that you received. You must make sure that they, too, receive
or can get the source code. And you must show them these terms so they
know their rights.
Developers that use the GNU GPL protect your rights with two steps:
(1) assert copyright on the software, and (2) offer you this License
giving you legal permission to copy, distribute and/or modify it.
For the developers' and authors' protection, the GPL clearly explains
that there is no warranty for this free software. For both users' and
authors' sake, the GPL requires that modified versions be marked as
changed, so that their problems will not be attributed erroneously to
authors of previous versions.
Some devices are designed to deny users access to install or run
modified versions of the software inside them, although the manufacturer
can do so. This is fundamentally incompatible with the aim of
protecting users' freedom to change the software. The systematic
pattern of such abuse occurs in the area of products for individuals to
use, which is precisely where it is most unacceptable. Therefore, we
have designed this version of the GPL to prohibit the practice for those
products. If such problems arise substantially in other domains, we
stand ready to extend this provision to those domains in future versions
of the GPL, as needed to protect the freedom of users.
Finally, every program is threatened constantly by software patents.
States should not allow patents to restrict development and use of
software on general-purpose computers, but in those that do, we wish to
avoid the special danger that patents applied to a free program could
make it effectively proprietary. To prevent this, the GPL assures that
patents cannot be used to render the program non-free.
The precise terms and conditions for copying, distribution and
modification follow.
TERMS AND CONDITIONS
0. Definitions.
"This License" refers to version 3 of the GNU General Public License.
"Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.
"The Program" refers to any copyrightable work licensed under this
License. Each licensee is addressed as "you". "Licensees" and
"recipients" may be individuals or organizations.
To "modify" a work means to copy from or adapt all or part of the work
in a fashion requiring copyright permission, other than the making of an
exact copy. The resulting work is called a "modified version" of the
earlier work or a work "based on" the earlier work.
A "covered work" means either the unmodified Program or a work based
on the Program.
To "propagate" a work means to do anything with it that, without
permission, would make you directly or secondarily liable for
infringement under applicable copyright law, except executing it on a
computer or modifying a private copy. Propagation includes copying,
distribution (with or without modification), making available to the
public, and in some countries other activities as well.
To "convey" a work means any kind of propagation that enables other
parties to make or receive copies. Mere interaction with a user through
a computer network, with no transfer of a copy, is not conveying.
An interactive user interface displays "Appropriate Legal Notices"
to the extent that it includes a convenient and prominently visible
feature that (1) displays an appropriate copyright notice, and (2)
tells the user that there is no warranty for the work (except to the
extent that warranties are provided), that licensees may convey the
work under this License, and how to view a copy of this License. If
the interface presents a list of user commands or options, such as a
menu, a prominent item in the list meets this criterion.
1. Source Code.
The "source code" for a work means the preferred form of the work
for making modifications to it. "Object code" means any non-source
form of a work.
A "Standard Interface" means an interface that either is an official
standard defined by a recognized standards body, or, in the case of
interfaces specified for a particular programming language, one that
is widely used among developers working in that language.
The "System Libraries" of an executable work include anything, other
than the work as a whole, that (a) is included in the normal form of
packaging a Major Component, but which is not part of that Major
Component, and (b) serves only to enable use of the work with that
Major Component, or to implement a Standard Interface for which an
implementation is available to the public in source code form. A
"Major Component", in this context, means a major essential component
(kernel, window system, and so on) of the specific operating system
(if any) on which the executable work runs, or a compiler used to
produce the work, or an object code interpreter used to run it.
The "Corresponding Source" for a work in object code form means all
the source code needed to generate, install, and (for an executable
work) run the object code and to modify the work, including scripts to
control those activities. However, it does not include the work's
System Libraries, or general-purpose tools or generally available free
programs which are used unmodified in performing those activities but
which are not part of the work. For example, Corresponding Source
includes interface definition files associated with source files for
the work, and the source code for shared libraries and dynamically
linked subprograms that the work is specifically designed to require,
such as by intimate data communication or control flow between those
subprograms and other parts of the work.
The Corresponding Source need not include anything that users
can regenerate automatically from other parts of the Corresponding
Source.
The Corresponding Source for a work in source code form is that
same work.
2. Basic Permissions.
All rights granted under this License are granted for the term of
copyright on the Program, and are irrevocable provided the stated
conditions are met. This License explicitly affirms your unlimited
permission to run the unmodified Program. The output from running a
covered work is covered by this License only if the output, given its
content, constitutes a covered work. This License acknowledges your
rights of fair use or other equivalent, as provided by copyright law.
You may make, run and propagate covered works that you do not
convey, without conditions so long as your license otherwise remains
in force. You may convey covered works to others for the sole purpose
of having them make modifications exclusively for you, or provide you
with facilities for running those works, provided that you comply with
the terms of this License in conveying all material for which you do
not control copyright. Those thus making or running the covered works
for you must do so exclusively on your behalf, under your direction
and control, on terms that prohibit them from making any copies of
your copyrighted material outside their relationship with you.
Conveying under any other circumstances is permitted solely under
the conditions stated below. Sublicensing is not allowed; section 10
makes it unnecessary.
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
No covered work shall be deemed part of an effective technological
measure under any applicable law fulfilling obligations under article
11 of the WIPO copyright treaty adopted on 20 December 1996, or
similar laws prohibiting or restricting circumvention of such
measures.
When you convey a covered work, you waive any legal power to forbid
circumvention of technological measures to the extent such circumvention
is effected by exercising rights under this License with respect to
the covered work, and you disclaim any intention to limit operation or
modification of the work as a means of enforcing, against the work's
users, your or third parties' legal rights to forbid circumvention of
technological measures.
4. Conveying Verbatim Copies.
You may convey verbatim copies of the Program's source code as you
receive it, in any medium, provided that you conspicuously and
appropriately publish on each copy an appropriate copyright notice;
keep intact all notices stating that this License and any
non-permissive terms added in accord with section 7 apply to the code;
keep intact all notices of the absence of any warranty; and give all
recipients a copy of this License along with the Program.
You may charge any price or no price for each copy that you convey,
and you may offer support or warranty protection for a fee.
5. Conveying Modified Source Versions.
You may convey a work based on the Program, or the modifications to
produce it from the Program, in the form of source code under the
terms of section 4, provided that you also meet all of these conditions:
a) The work must carry prominent notices stating that you modified
it, and giving a relevant date.
b) The work must carry prominent notices stating that it is
released under this License and any conditions added under section
7. This requirement modifies the requirement in section 4 to
"keep intact all notices".
c) You must license the entire work, as a whole, under this
License to anyone who comes into possession of a copy. This
License will therefore apply, along with any applicable section 7
additional terms, to the whole of the work, and all its parts,
regardless of how they are packaged. This License gives no
permission to license the work in any other way, but it does not
invalidate such permission if you have separately received it.
d) If the work has interactive user interfaces, each must display
Appropriate Legal Notices; however, if the Program has interactive
interfaces that do not display Appropriate Legal Notices, your
work need not make them do so.
A compilation of a covered work with other separate and independent
works, which are not by their nature extensions of the covered work,
and which are not combined with it such as to form a larger program,
in or on a volume of a storage or distribution medium, is called an
"aggregate" if the compilation and its resulting copyright are not
used to limit the access or legal rights of the compilation's users
beyond what the individual works permit. Inclusion of a covered work
in an aggregate does not cause this License to apply to the other
parts of the aggregate.
6. Conveying Non-Source Forms.
You may convey a covered work in object code form under the terms
of sections 4 and 5, provided that you also convey the
machine-readable Corresponding Source under the terms of this License,
in one of these ways:
a) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by the
Corresponding Source fixed on a durable physical medium
customarily used for software interchange.
b) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by a
written offer, valid for at least three years and valid for as
long as you offer spare parts or customer support for that product
model, to give anyone who possesses the object code either (1) a
copy of the Corresponding Source for all the software in the
product that is covered by this License, on a durable physical
medium customarily used for software interchange, for a price no
more than your reasonable cost of physically performing this
conveying of source, or (2) access to copy the
Corresponding Source from a network server at no charge.
c) Convey individual copies of the object code with a copy of the
written offer to provide the Corresponding Source. This
alternative is allowed only occasionally and noncommercially, and
only if you received the object code with such an offer, in accord
with subsection 6b.
d) Convey the object code by offering access from a designated
place (gratis or for a charge), and offer equivalent access to the
Corresponding Source in the same way through the same place at no
further charge. You need not require recipients to copy the
Corresponding Source along with the object code. If the place to
copy the object code is a network server, the Corresponding Source
may be on a different server (operated by you or a third party)
that supports equivalent copying facilities, provided you maintain
clear directions next to the object code saying where to find the
Corresponding Source. Regardless of what server hosts the
Corresponding Source, you remain obligated to ensure that it is
available for as long as needed to satisfy these requirements.
e) Convey the object code using peer-to-peer transmission, provided
you inform other peers where the object code and Corresponding
Source of the work are being offered to the general public at no
charge under subsection 6d.
A separable portion of the object code, whose source code is excluded
from the Corresponding Source as a System Library, need not be
included in conveying the object code work.
A "User Product" is either (1) a "consumer product", which means any
tangible personal property which is normally used for personal, family,
or household purposes, or (2) anything designed or sold for incorporation
into a dwelling. In determining whether a product is a consumer product,
doubtful cases shall be resolved in favor of coverage. For a particular
product received by a particular user, "normally used" refers to a
typical or common use of that class of product, regardless of the status
of the particular user or of the way in which the particular user
actually uses, or expects or is expected to use, the product. A product
is a consumer product regardless of whether the product has substantial
commercial, industrial or non-consumer uses, unless such uses represent
the only significant mode of use of the product.
"Installation Information" for a User Product means any methods,
procedures, authorization keys, or other information required to install
and execute modified versions of a covered work in that User Product from
a modified version of its Corresponding Source. The information must
suffice to ensure that the continued functioning of the modified object
code is in no case prevented or interfered with solely because
modification has been made.
If you convey an object code work under this section in, or with, or
specifically for use in, a User Product, and the conveying occurs as
part of a transaction in which the right of possession and use of the
User Product is transferred to the recipient in perpetuity or for a
fixed term (regardless of how the transaction is characterized), the
Corresponding Source conveyed under this section must be accompanied
by the Installation Information. But this requirement does not apply
if neither you nor any third party retains the ability to install
modified object code on the User Product (for example, the work has
been installed in ROM).
The requirement to provide Installation Information does not include a
requirement to continue to provide support service, warranty, or updates
for a work that has been modified or installed by the recipient, or for
the User Product in which it has been modified or installed. Access to a
network may be denied when the modification itself materially and
adversely affects the operation of the network or violates the rules and
protocols for communication across the network.
Corresponding Source conveyed, and Installation Information provided,
in accord with this section must be in a format that is publicly
documented (and with an implementation available to the public in
source code form), and must require no special password or key for
unpacking, reading or copying.
7. Additional Terms.
"Additional permissions" are terms that supplement the terms of this
License by making exceptions from one or more of its conditions.
Additional permissions that are applicable to the entire Program shall
be treated as though they were included in this License, to the extent
that they are valid under applicable law. If additional permissions
apply only to part of the Program, that part may be used separately
under those permissions, but the entire Program remains governed by
this License without regard to the additional permissions.
When you convey a copy of a covered work, you may at your option
remove any additional permissions from that copy, or from any part of
it. (Additional permissions may be written to require their own
removal in certain cases when you modify the work.) You may place
additional permissions on material, added by you to a covered work,
for which you have or can give appropriate copyright permission.
Notwithstanding any other provision of this License, for material you
add to a covered work, you may (if authorized by the copyright holders of
that material) supplement the terms of this License with terms:
a) Disclaiming warranty or limiting liability differently from the
terms of sections 15 and 16 of this License; or
b) Requiring preservation of specified reasonable legal notices or
author attributions in that material or in the Appropriate Legal
Notices displayed by works containing it; or
c) Prohibiting misrepresentation of the origin of that material, or
requiring that modified versions of such material be marked in
reasonable ways as different from the original version; or
d) Limiting the use for publicity purposes of names of licensors or
authors of the material; or
e) Declining to grant rights under trademark law for use of some
trade names, trademarks, or service marks; or
f) Requiring indemnification of licensors and authors of that
material by anyone who conveys the material (or modified versions of
it) with contractual assumptions of liability to the recipient, for
any liability that these contractual assumptions directly impose on
those licensors and authors.
All other non-permissive additional terms are considered "further
restrictions" within the meaning of section 10. If the Program as you
received it, or any part of it, contains a notice stating that it is
governed by this License along with a term that is a further
restriction, you may remove that term. If a license document contains
a further restriction but permits relicensing or conveying under this
License, you may add to a covered work material governed by the terms
of that license document, provided that the further restriction does
not survive such relicensing or conveying.
If you add terms to a covered work in accord with this section, you
must place, in the relevant source files, a statement of the
additional terms that apply to those files, or a notice indicating
where to find the applicable terms.
Additional terms, permissive or non-permissive, may be stated in the
form of a separately written license, or stated as exceptions;
the above requirements apply either way.
8. Termination.
You may not propagate or modify a covered work except as expressly
provided under this License. Any attempt otherwise to propagate or
modify it is void, and will automatically terminate your rights under
this License (including any patent licenses granted under the third
paragraph of section 11).
However, if you cease all violation of this License, then your
license from a particular copyright holder is reinstated (a)
provisionally, unless and until the copyright holder explicitly and
finally terminates your license, and (b) permanently, if the copyright
holder fails to notify you of the violation by some reasonable means
prior to 60 days after the cessation.
Moreover, your license from a particular copyright holder is
reinstated permanently if the copyright holder notifies you of the
violation by some reasonable means, this is the first time you have
received notice of violation of this License (for any work) from that
copyright holder, and you cure the violation prior to 30 days after
your receipt of the notice.
Termination of your rights under this section does not terminate the
licenses of parties who have received copies or rights from you under
this License. If your rights have been terminated and not permanently
reinstated, you do not qualify to receive new licenses for the same
material under section 10.
9. Acceptance Not Required for Having Copies.
You are not required to accept this License in order to receive or
run a copy of the Program. Ancillary propagation of a covered work
occurring solely as a consequence of using peer-to-peer transmission
to receive a copy likewise does not require acceptance. However,
nothing other than this License grants you permission to propagate or
modify any covered work. These actions infringe copyright if you do
not accept this License. Therefore, by modifying or propagating a
covered work, you indicate your acceptance of this License to do so.
10. Automatic Licensing of Downstream Recipients.
Each time you convey a covered work, the recipient automatically
receives a license from the original licensors, to run, modify and
propagate that work, subject to this License. You are not responsible
for enforcing compliance by third parties with this License.
An "entity transaction" is a transaction transferring control of an
organization, or substantially all assets of one, or subdividing an
organization, or merging organizations. If propagation of a covered
work results from an entity transaction, each party to that
transaction who receives a copy of the work also receives whatever
licenses to the work the party's predecessor in interest had or could
give under the previous paragraph, plus a right to possession of the
Corresponding Source of the work from the predecessor in interest, if
the predecessor has it or can get it with reasonable efforts.
You may not impose any further restrictions on the exercise of the
rights granted or affirmed under this License. For example, you may
not impose a license fee, royalty, or other charge for exercise of
rights granted under this License, and you may not initiate litigation
(including a cross-claim or counterclaim in a lawsuit) alleging that
any patent claim is infringed by making, using, selling, offering for
sale, or importing the Program or any portion of it.
11. Patents.
A "contributor" is a copyright holder who authorizes use under this
License of the Program or a work on which the Program is based. The
work thus licensed is called the contributor's "contributor version".
A contributor's "essential patent claims" are all patent claims
owned or controlled by the contributor, whether already acquired or
hereafter acquired, that would be infringed by some manner, permitted
by this License, of making, using, or selling its contributor version,
but do not include claims that would be infringed only as a
consequence of further modification of the contributor version. For
purposes of this definition, "control" includes the right to grant
patent sublicenses in a manner consistent with the requirements of
this License.
Each contributor grants you a non-exclusive, worldwide, royalty-free
patent license under the contributor's essential patent claims, to
make, use, sell, offer for sale, import and otherwise run, modify and
propagate the contents of its contributor version.
In the following three paragraphs, a "patent license" is any express
agreement or commitment, however denominated, not to enforce a patent
(such as an express permission to practice a patent or covenant not to
sue for patent infringement). To "grant" such a patent license to a
party means to make such an agreement or commitment not to enforce a
patent against the party.
If you convey a covered work, knowingly relying on a patent license,
and the Corresponding Source of the work is not available for anyone
to copy, free of charge and under the terms of this License, through a
publicly available network server or other readily accessible means,
then you must either (1) cause the Corresponding Source to be so
available, or (2) arrange to deprive yourself of the benefit of the
patent license for this particular work, or (3) arrange, in a manner
consistent with the requirements of this License, to extend the patent
license to downstream recipients. "Knowingly relying" means you have
actual knowledge that, but for the patent license, your conveying the
covered work in a country, or your recipient's use of the covered work
in a country, would infringe one or more identifiable patents in that
country that you have reason to believe are valid.
If, pursuant to or in connection with a single transaction or
arrangement, you convey, or propagate by procuring conveyance of, a
covered work, and grant a patent license to some of the parties
receiving the covered work authorizing them to use, propagate, modify
or convey a specific copy of the covered work, then the patent license
you grant is automatically extended to all recipients of the covered
work and works based on it.
A patent license is "discriminatory" if it does not include within
the scope of its coverage, prohibits the exercise of, or is
conditioned on the non-exercise of one or more of the rights that are
specifically granted under this License. You may not convey a covered
work if you are a party to an arrangement with a third party that is
in the business of distributing software, under which you make payment
to the third party based on the extent of your activity of conveying
the work, and under which the third party grants, to any of the
parties who would receive the covered work from you, a discriminatory
patent license (a) in connection with copies of the covered work
conveyed by you (or copies made from those copies), or (b) primarily
for and in connection with specific products or compilations that
contain the covered work, unless you entered into that arrangement,
or that patent license was granted, prior to 28 March 2007.
Nothing in this License shall be construed as excluding or limiting
any implied license or other defenses to infringement that may
otherwise be available to you under applicable patent law.
12. No Surrender of Others' Freedom.
If conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot convey a
covered work so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you may
not convey it at all. For example, if you agree to terms that obligate you
to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program.
13. Use with the GNU Affero General Public License.
Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
under version 3 of the GNU Affero General Public License into a single
combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work,
but the special requirements of the GNU Affero General Public License,
section 13, concerning interaction through a network will apply to the
combination as such.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of
the GNU General Public License from time to time. Such new versions will
be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the
Program specifies that a certain numbered version of the GNU General
Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software
Foundation. If the Program does not specify a version number of the
GNU General Public License, you may choose any version ever published
by the Free Software Foundation.
If the Program specifies that a proxy can decide which future
versions of the GNU General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you
to choose that version for the Program.
Later license versions may give you additional or different
permissions. However, no additional obligations are imposed on any
author or copyright holder as a result of your choosing to follow a
later version.
15. Disclaimer of Warranty.
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
16. Limitation of Liability.
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
SUCH DAMAGES.
17. Interpretation of Sections 15 and 16.
If the disclaimer of warranty and limitation of liability provided
above cannot be given local legal effect according to their terms,
reviewing courts shall apply local law that most closely approximates
an absolute waiver of all civil liability in connection with the
Program, unless a warranty or assumption of liability accompanies a
copy of the Program in return for a fee.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
state the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If the program does terminal interaction, make it output a short
notice like this when it starts in an interactive mode:
<program> Copyright (C) <year> <name of author>
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
This is free software, and you are welcome to redistribute it
under certain conditions; type `show c' for details.
The hypothetical commands `show w' and `show c' should show the appropriate
parts of the General Public License. Of course, your program's commands
might be different; for a GUI interface, you would use an "about box".
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU GPL, see
<https://www.gnu.org/licenses/>.
The GNU General Public License does not permit incorporating your program
into proprietary programs. If your program is a subroutine library, you
may consider it more useful to permit linking proprietary applications with
the library. If this is what you want to do, use the GNU Lesser General
Public License instead of this License. But first, please read
<https://www.gnu.org/licenses/why-not-lgpl.html>.

30
bin/input-remapper-control Executable file
View File

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

View File

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

26
bin/input-remapper-gtk Executable file
View File

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

View File

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

26
bin/input-remapper-service Executable file
View File

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

View File

@ -0,0 +1,8 @@
# helpful commands:
# udevadm monitor --property
# udevadm info --query=all --name=/dev/input/event3
#
# Forwarded input-remapper devices are virtual, so systemd's persistent-input
# rules do not assign ID_BUS to them. Without ID_BUS, 70-mouse.rules will not
# query hwdb and properties such as MOUSE_DPI get lost, changing pointer feel.
ACTION=="add", SUBSYSTEM=="input", KERNEL=="event*", ATTRS{phys}=="input-remapper/*", ENV{ID_BUS}=="", ENV{ID_BUS}="usb"

View File

@ -0,0 +1,10 @@
# helpful commands:
# udevadm monitor --property
# udevadm info --query=all --name=/dev/input/event3
# to test changes:
# sudo udevadm control --log-priority=debug
# sudo udevadm control --reload-rules
# journalctl -f
# to get available variables:
# udevadm monitor --environment --udev --subsystem input
ACTION=="add", SUBSYSTEM=="input", KERNEL=="event*", ENV{ID_PATH}!="platform-sound", ATTRS{phys}!="input-remapper*", RUN+="/bin/input-remapper-control --command autoload --device $env{DEVNAME}"

View File

@ -0,0 +1,6 @@
[Desktop Entry]
Type=Application
Exec=bash -c "input-remapper-control --command stop-all && input-remapper-control --command autoload"
Name=input-remapper-autoload
Icon=input-remapper
Comment=Starts injecting all presets that are set to automatically load for the user

View File

@ -0,0 +1,16 @@
[Unit]
Description=Input Remapper focus-driven preset binding (per-user service)
Documentation=https://github.com/sezanzeb/input-remapper
# Runs in the graphical user session, talks to the root daemon over the system bus.
After=graphical-session.target
PartOf=graphical-session.target
[Service]
Type=dbus
BusName=inputremapper.Focus
ExecStart=/usr/bin/input-remapper-focus-service
Restart=on-failure
RestartSec=2
[Install]
WantedBy=graphical-session.target

View File

@ -0,0 +1,8 @@
[Desktop Entry]
Type=Application
Name=Input Remapper
Icon=input-remapper
Exec=input-remapper-gtk
Terminal=false
Categories=Settings
Comment=GUI for device specific key mappings

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

1991
data/input-remapper.glade Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,21 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE policyconfig PUBLIC
"-//freedesktop//DTD PolicyKit Policy Configuration 1.0//EN"
"http://www.freedesktop.org/standards/PolicyKit/1/policyconfig.dtd"
>
<policyconfig>
<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="ua">Потрібна автентифікація для виявлення та читання пристроїв.</message>
<message xml:lang="ru">Требуется аутентификация для обнаружения и чтения устройств.</message>
<defaults>
<allow_any>no</allow_any>
<allow_inactive>auth_admin_keep</allow_inactive>
<allow_active>auth_admin_keep</allow_active>
</defaults>
<annotate key="org.freedesktop.policykit.exec.path">/usr/bin/input-remapper-control</annotate>
<annotate key="org.freedesktop.policykit.exec.allow_gui">false</annotate>
</action>
</policyconfig>

View File

@ -0,0 +1,14 @@
[Unit]
Description=Service to inject keycodes without the GUI application
# dbus is required for ipc between gui and input-remapper-control
Requires=dbus.service
After=dbus.service
[Service]
Type=dbus
BusName=inputremapper.Control
ExecStart=/usr/bin/input-remapper-service
[Install]
WantedBy=default.target
Alias=input-remapper.service

216
data/input-remapper.svg Normal file
View File

@ -0,0 +1,216 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
width="143.21349mm"
height="143.21349mm"
viewBox="0 0 143.21348 143.21348"
version="1.1"
id="svg2873"
inkscape:version="1.2.2 (b0a8486541, 2022-12-01)"
sodipodi:docname="input-remapper.svg"
inkscape:export-filename="../data/input-remapper-large.png"
inkscape:export-xdpi="23.219999"
inkscape:export-ydpi="23.219999"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:xlink="http://www.w3.org/1999/xlink"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:dc="http://purl.org/dc/elements/1.1/">
<defs
id="defs2867">
<linearGradient
inkscape:collect="always"
id="linearGradient2144">
<stop
style="stop-color:#ee628b;stop-opacity:1;"
offset="0"
id="stop2140" />
<stop
style="stop-color:#c34eb3;stop-opacity:1;"
offset="0.56871039"
id="stop2148" />
<stop
style="stop-color:#934de2;stop-opacity:1;"
offset="1"
id="stop2142" />
</linearGradient>
<linearGradient
inkscape:collect="always"
id="linearGradient2085">
<stop
style="stop-color:#005a76;stop-opacity:1;"
offset="0"
id="stop2081" />
<stop
style="stop-color:#a00062;stop-opacity:1;"
offset="1"
id="stop2083" />
</linearGradient>
<linearGradient
inkscape:collect="always"
id="linearGradient2021">
<stop
style="stop-color:#38a4c4;stop-opacity:1;"
offset="0"
id="stop2017" />
<stop
style="stop-color:#cbe8ff;stop-opacity:1;"
offset="1"
id="stop2019" />
</linearGradient>
<filter
style="color-interpolation-filters:sRGB"
inkscape:label="Drop Shadow"
id="filter1980"
x="-0.019841487"
y="-0.018018769"
width="1.039683"
height="1.0435454">
<feFlood
flood-opacity="0.25098"
flood-color="rgb(0,0,0)"
result="flood"
id="feFlood1970" />
<feComposite
in="flood"
in2="SourceGraphic"
operator="in"
result="composite1"
id="feComposite1972" />
<feGaussianBlur
in="composite1"
stdDeviation="1"
result="blur"
id="feGaussianBlur1974" />
<feOffset
dx="0"
dy="1"
result="offset"
id="feOffset1976" />
<feComposite
in="SourceGraphic"
in2="offset"
operator="over"
result="composite2"
id="feComposite1978" />
</filter>
<linearGradient
inkscape:collect="always"
xlink:href="#linearGradient2021"
id="linearGradient2023"
x1="226.8387"
y1="35.980072"
x2="285.77728"
y2="166.38808"
gradientUnits="userSpaceOnUse"
gradientTransform="matrix(0.95542849,0,0,0.95542849,11.339536,4.6386034)" />
<linearGradient
inkscape:collect="always"
xlink:href="#linearGradient2085"
id="linearGradient2089"
x1="271.02744"
y1="35.842381"
x2="270.98306"
y2="119.29856"
gradientUnits="userSpaceOnUse"
gradientTransform="matrix(1.054711,-0.31934424,0.31934424,1.054711,-45.053843,141.95867)" />
<linearGradient
inkscape:collect="always"
xlink:href="#linearGradient2144"
id="linearGradient2146"
x1="267.28836"
y1="166.43533"
x2="220.4397"
y2="99.792412"
gradientUnits="userSpaceOnUse"
gradientTransform="rotate(17.765376,441.5393,128.22537)" />
</defs>
<sodipodi:namedview
id="base"
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1.0"
inkscape:pageopacity="0.0"
inkscape:pageshadow="2"
inkscape:zoom="0.93381215"
inkscape:cx="17.669507"
inkscape:cy="229.70359"
inkscape:document-units="mm"
inkscape:current-layer="g1932"
inkscape:document-rotation="0"
showgrid="false"
fit-margin-top="0"
fit-margin-left="0"
fit-margin-right="0"
fit-margin-bottom="0"
inkscape:window-width="1920"
inkscape:window-height="1113"
inkscape:window-x="0"
inkscape:window-y="0"
inkscape:window-maximized="1"
inkscape:showpageshadow="0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1" />
<metadata
id="metadata2870">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
</cc:Work>
</rdf:RDF>
</metadata>
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1"
transform="translate(-184.44892,-29.512886)">
<path
style="fill:url(#linearGradient2023);fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.538446;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
id="path3680"
sodipodi:type="arc"
sodipodi:cx="256.05566"
sodipodi:cy="101.11963"
sodipodi:rx="66.890587"
sodipodi:ry="66.890587"
sodipodi:start="0.11227764"
sodipodi:end="0.10993049"
sodipodi:arc-type="arc"
d="m 322.52507,108.61418 a 66.890587,66.890587 0 0 1 -73.92495,58.97924 66.890587,66.890587 0 0 1 -59.02261,-73.890327 66.890587,66.890587 0 0 1 73.85568,-59.065963 66.890587,66.890587 0 0 1 59.10929,73.82101"
sodipodi:open="true" />
<g
id="g1932"
inkscape:label="key n pad"
transform="matrix(1.0680039,0,0,1.0680039,-17.494668,-6.729483)">
<ellipse
style="fill:#ee628b;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.933566;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
id="path3702"
cx="229.45534"
cy="202.79051"
rx="8.1979532"
ry="8.2254095"
transform="rotate(-16.845205)" />
<path
id="rect3031-2-3-7"
style="fill:url(#linearGradient2089);fill-opacity:1;stroke:none;stroke-width:1.56982;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
d="m 219.42531,102.03763 c -16.41828,1.26473 -28.7079,15.60645 -27.4432,32.02471 1.26471,16.41826 15.60583,28.70798 32.0241,27.44326 10.44047,-0.80424 19.2105,-6.89726 23.88824,-15.44872 l 19.06877,-1.46889 c 5.93223,7.73412 15.53139,12.41214 25.97181,11.60791 16.41829,-1.26472 28.70797,-15.60583 27.44326,-32.02409 -1.2697,-16.48291 -15.60589,-28.708541 -32.02415,-27.443825 -0.14429,0.01062 -0.2873,0.02618 -0.43092,0.03916 l -68.06607,5.243185 c -0.14396,0.009 -0.2876,0.0162 -0.43188,0.0268 z m -1.40054,12.38288 4.2224,-0.32524 c 0.95846,-0.0738 1.86674,0.37857 2.41795,0.98872 0.55122,0.61013 0.83585,1.36517 0.89688,2.15727 l 2.05325,6.8304 7.06426,0.98289 c 0.78686,0.10947 1.57951,0.10265 2.26687,0.5539 0.68736,0.45124 1.2757,1.27934 1.34954,2.23783 l 0.32525,4.22242 c 0.0738,0.95852 -0.38075,1.86693 -0.9909,2.41812 -0.61013,0.55122 -1.363,0.83568 -2.15512,0.8967 l -6.83042,2.05315 -0.98305,7.0621 c -0.10953,0.7869 -0.10465,1.58185 -0.55588,2.26921 -0.45127,0.68735 -1.27936,1.27571 -2.23786,1.34954 l -4.22241,0.32525 c -0.95849,0.0738 -1.86693,-0.38074 -2.41812,-0.99089 -0.55121,-0.61011 -0.83368,-1.36532 -0.8947,-2.15745 l -2.05305,-6.82823 -7.0643,-0.98288 c -0.78687,-0.10949 -1.58179,-0.10464 -2.26917,-0.55591 -0.68736,-0.45125 -1.27354,-1.27952 -1.34737,-2.238 l -0.32535,-4.22241 c -0.0738,-0.95847 0.37858,-1.86673 0.98873,-2.41795 0.61013,-0.55122 1.36532,-0.83369 2.15744,-0.8947 l 6.83042,-2.05315 0.98289,-7.06425 c 0.10949,-0.7869 0.10248,-1.58168 0.55374,-2.26905 0.45126,-0.68735 1.27952,-1.27353 2.238,-1.34736 z m 71.51733,-6.63523 c 3.57095,-0.27499 6.6965,2.4034 6.97158,5.97433 0.275,3.57098 -2.40339,6.69651 -5.97434,6.97158 -3.57094,0.27499 -6.69651,-2.40336 -6.97159,-5.97434 -0.27509,-3.57093 2.40339,-6.69649 5.97435,-6.97157 z m -11.21343,13.23551 c 3.57086,-0.27508 6.69644,2.4028 6.97152,5.97374 0.2751,3.57097 -2.40338,6.69652 -5.97435,6.97159 -3.57092,0.27508 -6.6965,-2.40339 -6.97157,-5.97434 -0.27499,-3.57094 2.4035,-6.69592 5.9744,-6.97099 z m 24.32165,-1.87354 c 3.57085,-0.27507 6.69647,2.40282 6.97153,5.97376 0.2751,3.57097 -2.4034,6.69652 -5.97435,6.97159 -3.57094,0.27508 -6.69651,-2.40339 -6.97156,-5.97437 -0.27499,-3.57093 2.4035,-6.69591 5.97438,-6.97098 z m -11.1607,13.02367 c 3.57086,-0.27509 6.69647,2.4028 6.97154,5.97375 0.27499,3.57092 -2.40339,6.69648 -5.97434,6.97157 -3.57094,0.27508 -6.69651,-2.4034 -6.97157,-5.97433 -0.27519,-3.57094 2.40352,-6.69594 5.97437,-6.97099 z"
sodipodi:nodetypes="sssccsssccssssscsssssscsssssscsssssscssssssssssssssssssssssscs" />
<ellipse
style="fill:#005a76;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.893247;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
id="path3704"
cx="263.43878"
cy="-31.419773"
rx="10.779231"
ry="9.3496962"
transform="rotate(17.765376)" />
<path
id="path1873-2-8"
style="color:#000000;font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:medium;line-height:normal;font-family:sans-serif;font-variant-ligatures:normal;font-variant-position:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-alternates:normal;font-variant-east-asian:normal;font-feature-settings:normal;font-variation-settings:normal;text-indent:0;text-align:start;text-decoration:none;text-decoration-line:none;text-decoration-style:solid;text-decoration-color:#000000;letter-spacing:normal;word-spacing:normal;text-transform:none;writing-mode:lr-tb;direction:ltr;text-orientation:mixed;dominant-baseline:auto;baseline-shift:baseline;text-anchor:start;white-space:normal;shape-padding:0;shape-margin:0;inline-size:0;clip-rule:nonzero;display:inline;overflow:visible;visibility:visible;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;vector-effect:none;fill:url(#linearGradient2146);fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:1.12845;stroke-linecap:butt;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto;enable-background:accumulate;stop-color:#000000"
d="m 250.24887,34.121016 c -1.78889,-0.166794 -3.37456,1.148415 -3.54133,2.937328 l -2.20385,23.639024 c -0.16679,1.788903 1.14846,3.373947 2.93737,3.540722 l 23.639,2.203862 c 1.78889,0.16679 3.37392,-1.147888 3.54067,-2.936775 l 2.20389,-23.639027 c 0.16678,-1.788901 -1.14785,-3.374509 -2.93673,-3.54129 z m 11.18772,10.12449 c 0.54697,0.03864 1.03084,0.368609 1.26468,0.864662 l 4.53089,9.597533 c 0.49976,1.060328 -0.34199,2.261945 -1.50919,2.153959 L 254.96396,55.858622 C 253.7969,55.749 253.1912,54.412392 253.87832,53.46272 l 6.22753,-8.594536 c 0.30643,-0.423425 0.80932,-0.658595 1.33074,-0.622678 z m -48.75272,20.725987 c -1.7889,-0.166803 -3.3745,1.147838 -3.54126,2.936722 l -2.20387,23.639038 c -0.16678,1.788869 1.14844,3.37453 2.93732,3.541308 l 23.63902,2.203846 c 1.78889,0.1668 3.37399,-1.148464 3.54074,-2.93737 l 2.20386,-23.639013 c 0.1668,-1.788891 -1.14789,-3.3739 -2.9368,-3.540675 z m 34.38991,3.206163 c -1.78892,-0.166797 -3.37451,1.147838 -3.54125,2.936725 l -2.2039,23.639023 c -0.16678,1.788875 1.14843,3.37454 2.93734,3.54132 l 23.639,2.203846 c 1.78889,0.16679 3.37397,-1.148472 3.54074,-2.937378 l 2.20386,-23.639021 c 0.16678,-1.788885 -1.14789,-3.373881 -2.9368,-3.540665 z m 34.0955,3.178716 c -1.78892,-0.166797 -3.37391,1.147881 -3.54068,2.936781 l -2.20387,23.639005 c -0.16678,1.788904 1.14784,3.374482 2.93675,3.541262 l 23.63901,2.20386 c 1.78888,0.16679 3.37455,-1.14842 3.54133,-2.93732 l 2.20385,-23.638995 c 0.16679,-1.788894 -1.14848,-3.373966 -2.93736,-3.540732 z m 7.70879,8.962597 c 0.26806,0.02529 0.52396,0.121462 0.74242,0.278823 l 8.59458,6.227542 c 0.93167,0.676009 0.7986,2.103276 -0.24197,2.595406 l -9.59763,4.531472 c -1.0611,0.502539 -2.2655,-0.341044 -2.15623,-1.510018 l 1.00279,-10.756092 c 0.078,-0.83556 0.82082,-1.448455 1.65606,-1.36718 z m -61.32169,-5.7164 c 0.84649,0.06143 1.47748,0.806339 1.39865,1.651396 l -1.00279,10.756082 c -0.10959,1.167096 -1.44621,1.772782 -2.39587,1.085652 l -8.59456,-6.227568 c -0.93168,-0.676021 -0.79867,-2.10269 0.24192,-2.594823 l 9.59762,-4.531471 c 0.23567,-0.110722 0.49545,-0.158938 0.75503,-0.139277 z m 25.36167,3.200826 10.75899,1.003051 c 1.16703,0.109708 1.77276,1.446198 1.08562,2.395864 l -6.22768,8.596293 c -0.67604,0.931665 -2.10326,0.798629 -2.5954,-0.241955 l -4.53154,-9.597049 c -0.50252,-1.061064 0.34102,-2.2655 1.51001,-2.156204 z" />
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 13 KiB

View File

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE busconfig PUBLIC "-//freedesktop//DTD D-BUS Bus Configuration 1.0//EN"
"http://www.freedesktop.org/standards/dbus/1.0/busconfig.dtd">
<busconfig>
<policy context="default">
<allow own="inputremapper.Control"/>
<allow send_destination="inputremapper.Control"/>
</policy>
</busconfig>

View File

@ -0,0 +1,44 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- Copyright [year] [name] -->
<component type="desktop-application">
<id>io.github.sezanzeb.input_remapper</id>
<name>Input Remapper</name>
<summary>An easy to use tool to change the mapping of your input device buttons</summary>
<metadata_license>CC0-1.0</metadata_license>
<project_license>GPL-3.0-or-later</project_license>
<supports>
<control>pointing</control>
<control>keyboard</control>
<control>gamepad</control>
</supports>
<description>
<p>
An easy to use tool to change the mapping of your input device buttons. Supports mice, keyboards, gamepads, X11, Wayland, combined buttons and programmable macros. Allows mapping non-keyboard events (click, joystick, wheel) to keys of keyboard devices.
</p>
</description>
<launchable type="desktop-id">input-remapper-gtk.desktop</launchable>
<screenshots>
<screenshot type="default">
<caption>Defining a new mapping</caption>
<image>https://raw.githubusercontent.com/sezanzeb/input-remapper/main/readme/screenshot.png</image>
</screenshot>
<screenshot>
<image>https://raw.githubusercontent.com/sezanzeb/input-remapper/main/readme/screenshot_2.png</image>
</screenshot>
</screenshots>
<url type="homepage">https://github.com/sezanzeb/input-remapper</url>
<content_rating type="oars-1.1" />
<releases>
<release version="1.5.0" date="2022-06-22" />
</releases>
</component>

50
data/style.css Normal file
View File

@ -0,0 +1,50 @@
.status_bar frame {
/* the status bar is ugly in elementary os otherwise */
border: 0px;
}
.transparent {
background: transparent;
}
.copyright {
font-size: 7pt;
}
.autocompletion label {
padding: 11px;
}
.autocompletion {
padding: 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;
}
.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;
}
.opaque-text text {
/* found by roaming through /usr/share/themes,
and some experimentation in the gnome inspector */
color: alpha(currentColor, 0.5);
}
/*
@theme_bg_color
@theme_selected_bg_color
@theme_base_color
*/

View File

View File

View File

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

View File

@ -0,0 +1,100 @@
# -*- coding: utf-8 -*-
# input-remapper - GUI for device specific keyboard mappings
# Copyright (C) 2025 sezanzeb <b8x45ygc9@mozmail.com>
#
# This file is part of input-remapper.
#
# input-remapper is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# input-remapper is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with input-remapper. If not, see <https://www.gnu.org/licenses/>.
"""Starts the user-level focus-service for focus-driven preset binding."""
import os
import sys
import time
from argparse import ArgumentParser
from typing import Optional
from inputremapper.configs.global_config import GlobalConfig
from inputremapper.daemon import Daemon, DaemonProxy
from inputremapper.logging.logger import logger
class InputRemapperFocusServiceBin:
@staticmethod
def _connect_daemon(retries: int = 10, delay: float = 1.0) -> Optional[DaemonProxy]:
"""Connect to the root daemon over the system bus, without pkexec.
The focus-service runs as a background user service and must never
trigger a polkit prompt, so it connects with fallback=False and simply
retries until the daemon (started separately, e.g. via its systemd
unit) becomes available.
"""
for attempt in range(retries):
daemon = Daemon.connect(fallback=False)
if daemon is not None:
return daemon
logger.info(
"Daemon not available yet (attempt %d/%d), retrying in %.1fs",
attempt + 1,
retries,
delay,
)
time.sleep(delay)
return None
@staticmethod
def main() -> None:
parser = ArgumentParser()
parser.add_argument(
"-d",
"--debug",
action="store_true",
dest="debug",
help="Displays additional debug information",
default=False,
)
parser.add_argument(
"--hide-info",
action="store_true",
dest="hide_info",
help="Don't display version information",
default=False,
)
options = parser.parse_args(sys.argv[1:])
logger.update_verbosity(options.debug)
if not options.hide_info:
logger.log_info("input-remapper-focus-service")
if os.getuid() == 0:
logger.warning(
"The focus-service is meant to run as the logged-in user, not root"
)
# Imported here so log verbosity is configured first.
from inputremapper.focus.focus_service import FocusService
from inputremapper.focus.focus_watcher import select_backend
global_config = GlobalConfig()
backend = select_backend()
daemon = InputRemapperFocusServiceBin._connect_daemon()
if daemon is None:
logger.error("Could not connect to the input-remapper daemon, exiting")
sys.exit(1)
focus_service = FocusService(global_config, backend, daemon)
focus_service.run()

View File

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

View File

@ -0,0 +1,80 @@
# -*- coding: utf-8 -*-
# input-remapper - GUI for device specific keyboard mappings
# Copyright (C) 2025 sezanzeb <b8x45ygc9@mozmail.com>
#
# This file is part of input-remapper.
#
# input-remapper is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# input-remapper is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with input-remapper. If not, see <https://www.gnu.org/licenses/>.
"""Starts the root reader-service."""
import asyncio
import atexit
import os
import signal
import sys
import time
from argparse import ArgumentParser
from inputremapper.bin.process_utils import ProcessUtils
from inputremapper.groups import _Groups
from inputremapper.gui.reader_service import ReaderService
from inputremapper.injection.global_uinputs import GlobalUInputs, FrontendUInput
from inputremapper.logging.logger import logger
class InputRemapperReaderServiceBin:
@staticmethod
def main() -> None:
parser = ArgumentParser()
parser.add_argument(
"-d",
"--debug",
action="store_true",
dest="debug",
help="Displays additional debug information",
default=False,
)
options = parser.parse_args(sys.argv[1:])
logger.update_verbosity(options.debug)
if ProcessUtils.count_python_processes("input-remapper-reader-service") >= 2:
logger.warning(
"Another input-remapper-reader-service process is already running. "
"This can cause problems while recording keys"
)
if os.getuid() != 0:
logger.warning("The reader-service usually needs elevated privileges")
if not ReaderService.pipes_exist():
logger.info("Waiting for pipes to be created by the GUI")
while not ReaderService.pipes_exist():
time.sleep(0.5)
logger.debug("Waiting...")
def on_exit():
"""Don't remain idle and alive when the GUI exits via ctrl+c."""
# makes no sense to me, but after the keyboard interrupt it is still
# waiting for an event to complete (`S` in `ps ax`), even when using
# sys.exit
os.kill(os.getpid(), signal.SIGKILL)
atexit.register(on_exit)
groups = _Groups()
global_uinputs = GlobalUInputs(FrontendUInput)
reader_service = ReaderService(groups, global_uinputs)
asyncio.run(reader_service.run())

View File

@ -0,0 +1,71 @@
# -*- coding: utf-8 -*-
# input-remapper - GUI for device specific keyboard mappings
# Copyright (C) 2025 sezanzeb <b8x45ygc9@mozmail.com>
#
# This file is part of input-remapper.
#
# input-remapper is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# input-remapper is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with input-remapper. If not, see <https://www.gnu.org/licenses/>.
"""Starts injecting keycodes based on the configuration."""
import sys
import multiprocessing
from argparse import ArgumentParser
from inputremapper.configs.global_config import GlobalConfig
from inputremapper.injection.global_uinputs import GlobalUInputs, UInput
from inputremapper.injection.mapping_handlers.mapping_parser import MappingParser
from inputremapper.logging.logger import logger
class InputRemapperServiceBin:
@staticmethod
def main() -> None:
parser = ArgumentParser()
parser.add_argument(
"-d",
"--debug",
action="store_true",
dest="debug",
help="Displays additional debug information",
default=False,
)
parser.add_argument(
"--hide-info",
action="store_true",
dest="hide_info",
help="Don't display version information",
default=False,
)
options = parser.parse_args(sys.argv[1:])
# Python 3.14 compatibility
multiprocessing.set_start_method("fork")
logger.update_verbosity(options.debug)
# import input-remapper stuff after setting the log verbosity
from inputremapper.daemon import Daemon
if not options.hide_info:
logger.log_info("input-remapper-service")
global_config = GlobalConfig()
global_uinputs = GlobalUInputs(UInput)
mapping_parser = MappingParser(global_uinputs)
daemon = Daemon(global_config, global_uinputs, mapping_parser)
daemon.publish()
daemon.run()

View File

@ -0,0 +1,39 @@
# -*- coding: utf-8 -*-
# input-remapper - GUI for device specific keyboard mappings
# Copyright (C) 2025 sezanzeb <b8x45ygc9@mozmail.com>
#
# This file is part of input-remapper.
#
# input-remapper is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# input-remapper is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with input-remapper. If not, see <https://www.gnu.org/licenses/>.
import psutil
class ProcessUtils:
@staticmethod
def count_python_processes(name: str) -> int:
# This is somewhat complicated, because there might also be a "sudo <name>"
# process.
count = 0
pids = psutil.pids()
for pid in pids:
try:
process = psutil.Process(pid)
cmdline = process.cmdline()
if len(cmdline) >= 2 and "python" in cmdline[0] and name in cmdline[1]:
count += 1
except Exception:
pass
return count

View File

View File

@ -0,0 +1,108 @@
# -*- coding: utf-8 -*-
# input-remapper - GUI for device specific keyboard mappings
# Copyright (C) 2025 sezanzeb <b8x45ygc9@mozmail.com>
#
# This file is part of input-remapper.
#
# input-remapper is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# input-remapper is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with input-remapper. If not, see <https://www.gnu.org/licenses/>.
"""Models describing which presets should be applied for which application.
These models are persisted as part of the global config (config.json) and are
consumed by the input-remapper-focus-service to decide which presets to inject
depending on the currently focused window.
"""
from __future__ import annotations
import enum
import re
from typing import List, TYPE_CHECKING
try:
from pydantic.v1 import BaseModel, Field, root_validator, validator
except ImportError:
from pydantic import ( # type: ignore[assignment, no-redef]
BaseModel,
Field,
root_validator,
validator,
)
from inputremapper.configs.validation_errors import (
EmptyAppIdError,
InvalidTitleRegexError,
)
if TYPE_CHECKING:
from inputremapper.focus.focus_watcher import FocusEvent
class MatchType(str, enum.Enum):
"""How an AppBinding decides whether it applies to a focused window."""
wm_class = "wm_class"
title_regex = "title_regex"
def normalize_app_id(app_id: str) -> str:
"""Normalize a window identifier for case-insensitive comparison."""
return app_id.strip().lower()
class BoundPreset(BaseModel):
"""A (device, preset) pair that should be injected for an application."""
group_key: str
preset: str
@validator("group_key", "preset")
def _not_empty(cls, value: str) -> str: # noqa: N805
if not value or not value.strip():
raise ValueError("group_key and preset must not be empty")
return value
class AppBinding(BaseModel):
"""Binds a focused application to a list of presets to inject."""
app_id: str
match: MatchType = MatchType.wm_class
presets: List[BoundPreset] = Field(default_factory=list)
@validator("app_id")
def _app_id_not_empty(cls, value: str) -> str: # noqa: N805
if not value or not value.strip():
raise EmptyAppIdError()
return value
@root_validator(skip_on_failure=True)
def _valid_regex(cls, values: dict) -> dict: # noqa: N805
if values.get("match") == MatchType.title_regex:
app_id = values.get("app_id", "")
try:
re.compile(app_id)
except re.error as error:
raise InvalidTitleRegexError(app_id, str(error))
return values
def matches(self, event: "FocusEvent") -> bool:
"""Whether this binding applies to the given focus event."""
if self.match == MatchType.title_regex:
try:
return re.search(self.app_id, event.title) is not None
except re.error:
return False
return normalize_app_id(self.app_id) == normalize_app_id(event.app_id)

View File

@ -0,0 +1,33 @@
# -*- coding: utf-8 -*-
# input-remapper - GUI for device specific keyboard mappings
# Copyright (C) 2025 sezanzeb <b8x45ygc9@mozmail.com>
#
# This file is part of input-remapper.
#
# input-remapper is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# input-remapper is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with input-remapper. If not, see <https://www.gnu.org/licenses/>.
"""Get stuff from /usr/share/input-remapper, depending on the prefix."""
import os
from inputremapper.installation_info import DATA_DIR
def get_data_path(filename=""):
"""Depending on the installation prefix, return the data dir."""
# This was more complicated in the past. This wrapper is kept for now, but feel
# free to remove it at a later point.
return os.path.join(DATA_DIR, filename)

View File

@ -0,0 +1,171 @@
# -*- coding: utf-8 -*-
# input-remapper - GUI for device specific keyboard mappings
# Copyright (C) 2025 sezanzeb <b8x45ygc9@mozmail.com>
#
# This file is part of input-remapper.
#
# input-remapper is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# input-remapper is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with input-remapper. If not, see <https://www.gnu.org/licenses/>.
"""Store which presets should be enabled for which device on login."""
from __future__ import annotations
import copy
import json
import os
from typing import List, Optional
from inputremapper.configs.app_binding import AppBinding
from inputremapper.configs.paths import PathUtils
from inputremapper.logging.logger import logger, VERSION
from inputremapper.user import UserUtils
MOUSE = "mouse"
WHEEL = "wheel"
BUTTONS = "buttons"
NONE = "none"
INITIAL_CONFIG = {
"version": VERSION,
"autoload": {},
"app_binding_enabled": False,
"app_bindings": [],
}
class GlobalConfig:
"""Configures stuff like autoloading in ~/.config/input-remapper-2/config.json."""
def __init__(self):
self.path = os.path.join(PathUtils.config_path(), "config.json")
self._config = copy.deepcopy(INITIAL_CONFIG)
def get_dir(self) -> str:
"""The folder containing this config."""
return os.path.split(self.path)[0]
def get_autoload_preset(self, group_key: str) -> Optional[str]:
# modifications are only allowed via the setter, because it needs to write
# the config file too. Therefore return a copy to prevent inconsistencies.
return copy.deepcopy(self._config["autoload"].get(group_key))
def set_autoload_preset(self, group_key: str, preset: Optional[str]):
"""Set a preset to be automatically applied on start.
Parameters
----------
group_key
the unique identifier of the group. This is used instead of the
name to enable autoloading two different presets when two similar
devices are connected.
preset
if None, don't autoload something for this device.
"""
if preset is not None:
self._config["autoload"][group_key] = preset
else:
logger.info('Not injecting for "%s" automatically anmore', group_key)
del self._config["autoload"][group_key]
self._save_config()
def iterate_autoload_presets(self):
"""Get tuples of (device, preset)."""
return self._config.get("autoload", {}).items()
def is_autoloaded(self, group_key: Optional[str], preset: Optional[str]):
"""Should this preset be loaded automatically?"""
if group_key is None or preset is None:
raise ValueError("Expected group_key and preset to not be None")
return self._config.get("autoload", {}).get(group_key) == preset
def get_app_binding_enabled(self) -> bool:
"""Whether focus-driven preset binding is enabled."""
return bool(self._config.get("app_binding_enabled", False))
def set_app_binding_enabled(self, enabled: bool) -> None:
"""Enable or disable focus-driven preset binding."""
self._config["app_binding_enabled"] = bool(enabled)
self._save_config()
def get_app_bindings(self) -> List[AppBinding]:
"""Get the configured application bindings as model objects."""
bindings = []
for raw in self._config.get("app_bindings", []):
try:
bindings.append(AppBinding(**raw))
except (TypeError, ValueError) as error:
logger.error("Ignoring invalid app_binding %s: %s", raw, str(error))
return bindings
def set_app_bindings(self, bindings: List[AppBinding]) -> None:
"""Persist the given application bindings."""
self._config["app_bindings"] = [
json.loads(binding.json()) for binding in bindings
]
self._save_config()
def load_config(self, path: Optional[str] = None):
"""Load the config from the file system.
Parameters
----------
path
If set, will change the path to load from and save to.
"""
if path is not None:
if not os.path.exists(path):
logger.error('Config at "%s" not found', path)
return
self.path = path
self._clear_config()
if not os.path.exists(self.path):
# treated like an empty config
logger.debug('Config "%s" doesn\'t exist yet', self.path)
self._clear_config()
self._config = copy.deepcopy(INITIAL_CONFIG)
self._save_config()
return
with open(self.path, "r") as file:
try:
self._config.update(json.load(file))
logger.info('Loaded config from "%s"', self.path)
except json.decoder.JSONDecodeError as error:
logger.error(
'Failed to parse config "%s": %s. Using defaults',
self.path,
str(error),
)
# uses the default configuration when the config object
# is empty automatically
def _save_config(self):
"""Save the config to the file system."""
if UserUtils.user == "root":
logger.debug("Skipping config file creation for the root user")
return
PathUtils.touch(self.path)
with open(self.path, "w") as file:
json.dump(self._config, file, indent=4)
logger.info("Saved config to %s", self.path)
file.write("\n")
def _clear_config(self):
"""Remove all configurations in memory."""
self._config = {}

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,157 @@
# -*- coding: utf-8 -*-
# input-remapper - GUI for device specific keyboard mappings
# Copyright (C) 2025 sezanzeb <b8x45ygc9@mozmail.com>
#
# This file is part of input-remapper.
#
# input-remapper is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# input-remapper is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with input-remapper. If not, see <https://www.gnu.org/licenses/>.
# TODO: convert everything to use pathlib.Path
"""Path constants to be used."""
import os
import shutil
from typing import List, Union, Optional
from inputremapper.logging.logger import logger
from inputremapper.user import UserUtils
# TODO maybe this could become, idk, ConfigService and PresetService
class PathUtils:
@staticmethod
def config_path() -> str:
# TODO when proper DI is being done, construct PathUtils and configure it
# in the constructor. Then there is no need to recompute the config_path
# each time. Tests might have overwritten UserUtils.home.
xdg_config_home = os.getenv(
"XDG_CONFIG_HOME", os.path.join(UserUtils.home, ".config")
)
return os.path.join(xdg_config_home, "input-remapper-2")
@staticmethod
def chown(path):
"""Set the owner of a path to the user."""
try:
logger.debug('Chown "%s", "%s"', path, UserUtils.user)
shutil.chown(path, user=UserUtils.user, group=UserUtils.user)
except LookupError:
# the users group was unknown in one case for whatever reason
shutil.chown(path, user=UserUtils.user)
@staticmethod
def touch(path: Union[str, os.PathLike], log=True):
"""Create an empty file and all its parent dirs, give it to the user."""
if str(path).endswith("/"):
raise ValueError(f"Expected path to not end with a slash: {path}")
if os.path.exists(path):
return
if log:
logger.info('Creating file "%s"', path)
PathUtils.mkdir(os.path.dirname(path), log=False)
os.mknod(path)
PathUtils.chown(path)
@staticmethod
def mkdir(path, log=True):
"""Create a folder, give it to the user."""
if path == "" or path is None:
return
if os.path.exists(path):
return
if log:
logger.info('Creating dir "%s"', path)
# give all newly created folders to the user.
# e.g. if .config/input-remapper/mouse/ is created the latter two
base = os.path.split(path)[0]
PathUtils.mkdir(base, log=False)
os.makedirs(path)
PathUtils.chown(path)
@staticmethod
def split_all(path: Union[os.PathLike, str]) -> List[str]:
"""Split the path into its segments."""
parts = []
while True:
path, tail = os.path.split(path)
parts.append(tail)
if path == os.path.sep:
# we arrived at the root '/'
parts.append(path)
break
if not path:
# arrived at start of relative path
break
parts.reverse()
return parts
@staticmethod
def remove(path):
"""Remove whatever is at the path."""
if not os.path.exists(path):
return
if os.path.isdir(path):
shutil.rmtree(path)
else:
os.remove(path)
@staticmethod
def sanitize_path_component(group_name: str) -> str:
"""Replace characters listed in
https://en.wikipedia.org/wiki/Filename#Reserved_characters_and_words
with an underscore.
"""
for character in '/\\?%*:|"<>':
if character in group_name:
group_name = group_name.replace(character, "_")
return group_name
@staticmethod
def get_preset_path(group_name: Optional[str] = None, preset: Optional[str] = None):
"""Get a path to the stored preset, or to store a preset to."""
presets_base = os.path.join(PathUtils.config_path(), "presets")
if group_name is None:
return presets_base
group_name = PathUtils.sanitize_path_component(group_name)
if preset is not None:
# the extension of the preset should not be shown in the ui.
# if a .json extension arrives this place, it has not been
# stripped away properly prior to this.
if not preset.endswith(".json"):
preset = f"{preset}.json"
if preset is None:
return os.path.join(presets_base, group_name)
return os.path.join(presets_base, group_name, preset)
@staticmethod
def get_config_path(*paths) -> str:
"""Get a path in ~/.config/input-remapper/."""
return os.path.join(PathUtils.config_path(), *paths)

View File

@ -0,0 +1,325 @@
# -*- coding: utf-8 -*-
# input-remapper - GUI for device specific keyboard mappings
# Copyright (C) 2025 sezanzeb <b8x45ygc9@mozmail.com>
#
# This file is part of input-remapper.
#
# input-remapper is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# input-remapper is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with input-remapper. If not, see <https://www.gnu.org/licenses/>.
"""Contains and manages mappings."""
from __future__ import annotations
import json
import os
from typing import (
Tuple,
Dict,
List,
Optional,
Iterator,
Type,
TypeVar,
Generic,
overload,
)
from evdev import ecodes
from inputremapper.configs.input_config import InputCombination, InputConfig
from inputremapper.configs.mapping import Mapping, UIMapping
from inputremapper.configs.paths import PathUtils
from inputremapper.logging.logger import logger
MappingModel = TypeVar("MappingModel", bound=UIMapping)
class Preset(Generic[MappingModel]):
"""Contains and manages mappings of a single preset."""
# workaround for typing: https://github.com/python/mypy/issues/4236
@overload
def __init__(self: Preset[Mapping], path: Optional[os.PathLike] = None): ...
@overload
def __init__(
self,
path: Optional[os.PathLike] = None,
mapping_factory: Type[MappingModel] = ...,
): ...
def __init__(
self,
path: Optional[os.PathLike] = None,
mapping_factory=Mapping,
) -> None:
self._mappings: Dict[InputCombination, MappingModel] = {}
# a copy of mappings for keeping track of changes
self._saved_mappings: Dict[InputCombination, MappingModel] = {}
self._path: Optional[os.PathLike] = path
# the mapping class which is used by load()
self._mapping_factory: Type[MappingModel] = mapping_factory
def __iter__(self) -> Iterator[MappingModel]:
"""Iterate over Mapping objects."""
return iter(self._mappings.copy().values())
def __len__(self) -> int:
return len(self._mappings)
def __bool__(self):
# otherwise __len__ will be used which results in False for a preset
# without mappings
return True
def has_unsaved_changes(self) -> bool:
"""Check if there are unsaved changed."""
return self._mappings != self._saved_mappings
def remove(self, combination: InputCombination) -> None:
"""Remove a mapping from the preset by providing the InputCombination."""
if not isinstance(combination, InputCombination):
raise TypeError(
f"combination must by of type InputCombination, got {type(combination)}"
)
for permutation in combination.get_permutations():
if permutation in self._mappings.keys():
combination = permutation
break
try:
mapping = self._mappings.pop(combination)
mapping.remove_combination_changed_callback()
except KeyError:
logger.debug(
"unable to remove non-existing mapping with combination = %s",
combination,
)
pass
def add(self, mapping: MappingModel) -> None:
"""Add a mapping to the preset."""
for permutation in mapping.input_combination.get_permutations():
if permutation in self._mappings:
raise KeyError(
"A mapping with this input_combination: "
f"{permutation} already exists",
)
mapping.set_combination_changed_callback(self._combination_changed_callback)
self._mappings[mapping.input_combination] = mapping
def empty(self) -> None:
"""Remove all mappings and custom configs without saving.
note: self.has_unsaved_changes() will report True
"""
for mapping in self._mappings.values():
mapping.remove_combination_changed_callback()
self._mappings = {}
def clear(self) -> None:
"""Remove all mappings and also self.path."""
self.empty()
self._saved_mappings = {}
self.path = None
def load(self) -> None:
"""Load from the mapping from the disc, clears all existing mappings."""
logger.info('Loading preset from "%s"', self.path)
if not self.path or not os.path.exists(self.path):
raise FileNotFoundError(f'Tried to load non-existing preset "{self.path}"')
self._saved_mappings = self._get_mappings_from_disc()
self.empty()
for mapping in self._saved_mappings.values():
# use the public add method to make sure
# the _combination_changed_callback is attached
self.add(mapping.copy())
def _is_mapped_multiple_times(self, input_combination: InputCombination) -> bool:
"""Check if the event combination maps to multiple mappings."""
all_input_combinations = {mapping.input_combination for mapping in self}
permutations = set(input_combination.get_permutations())
union = permutations & all_input_combinations
# if there are more than one matches, then there is a duplicate
return len(union) > 1
def _has_valid_input_combination(self, mapping: UIMapping) -> bool:
"""Check if the mapping has a valid input event combination."""
is_a_combination = isinstance(mapping.input_combination, InputCombination)
is_empty = mapping.input_combination == InputCombination.empty_combination()
return is_a_combination and not is_empty
def save(self) -> None:
"""Dump as JSON to self.path."""
if not self.path:
logger.debug("unable to save preset without a path set Preset.path first")
return
PathUtils.touch(self.path)
if not self.has_unsaved_changes():
logger.debug("Not saving unchanged preset")
return
logger.info("Saving preset to %s", self.path)
preset_list = []
saved_mappings = {}
for mapping in self:
if not mapping.is_valid():
if not self._has_valid_input_combination(mapping):
# we save invalid mappings except for those with an invalid
# input_combination
logger.debug("Skipping invalid mapping %s", mapping)
continue
if self._is_mapped_multiple_times(mapping.input_combination):
# todo: is this ever executed? it should not be possible to
# reach this
logger.debug(
"skipping mapping with duplicate event combination %s",
mapping,
)
continue
mapping_dict = mapping.dict(exclude_defaults=True)
mapping_dict["input_combination"] = mapping.input_combination.to_config()
combination = mapping.input_combination
preset_list.append(mapping_dict)
saved_mappings[combination] = mapping.copy()
saved_mappings[combination].remove_combination_changed_callback()
with open(self.path, "w") as file:
json.dump(preset_list, file, indent=4)
file.write("\n")
self._saved_mappings = saved_mappings
def is_valid(self) -> bool:
return False not in [mapping.is_valid() for mapping in self]
def get_mapping(
self, combination: Optional[InputCombination]
) -> Optional[MappingModel]:
"""Return the Mapping that is mapped to this InputCombination."""
if not combination:
return None
if not isinstance(combination, InputCombination):
raise TypeError(
f"combination must by of type InputCombination, got {type(combination)}"
)
for permutation in combination.get_permutations():
existing = self._mappings.get(permutation)
if existing is not None:
return existing
return None
def dangerously_mapped_btn_left(self) -> bool:
"""Return True if this mapping disables BTN_Left."""
if (ecodes.EV_KEY, ecodes.BTN_LEFT) not in [
m.input_combination[0].type_and_code for m in self
]:
return False
values: List[str | Tuple[int, int] | None] = []
for mapping in self:
if mapping.output_symbol is None:
continue
values.append(mapping.output_symbol.lower())
values.append(mapping.get_output_type_code())
return (
"btn_left" not in values
or InputConfig.btn_left().type_and_code not in values
)
def _combination_changed_callback(
self, new: InputCombination, old: InputCombination
) -> None:
for permutation in new.get_permutations():
if permutation in self._mappings.keys() and permutation != old:
raise KeyError("combination already exists in the preset")
self._mappings[new] = self._mappings.pop(old)
def _update_saved_mappings(self) -> None:
if self.path is None:
return
if not os.path.exists(self.path):
self._saved_mappings = {}
return
self._saved_mappings = self._get_mappings_from_disc()
def _get_mappings_from_disc(self) -> Dict[InputCombination, MappingModel]:
mappings: Dict[InputCombination, MappingModel] = {}
if not self.path:
logger.debug("unable to read preset without a path set Preset.path first")
return mappings
if os.stat(self.path).st_size == 0:
logger.debug("got empty file")
return mappings
with open(self.path, "r") as file:
try:
preset_list = json.load(file)
except json.JSONDecodeError:
logger.error("unable to decode json file: %s", self.path)
return mappings
for mapping_dict in preset_list:
if not isinstance(mapping_dict, dict):
logger.error(
"Expected mapping to be a dict: %s %s",
type(mapping_dict),
mapping_dict,
)
continue
try:
mapping = self._mapping_factory(**mapping_dict)
except Exception as error:
logger.error(
"failed to Validate mapping for %s: %s",
mapping_dict.get("input_combination"),
error,
)
continue
mappings[mapping.input_combination] = mapping
return mappings
@property
def path(self) -> Optional[os.PathLike]:
return self._path
@path.setter
def path(self, path: Optional[os.PathLike]):
if path != self.path:
self._path = path
self._update_saved_mappings()
@property
def name(self) -> Optional[str]:
"""The name of the preset."""
if self.path:
return os.path.basename(self.path).split(".")[0]
return None

View File

@ -0,0 +1,147 @@
# -*- coding: utf-8 -*-
# input-remapper - GUI for device specific keyboard mappings
# Copyright (C) 2025 sezanzeb <b8x45ygc9@mozmail.com>
#
# This file is part of input-remapper.
#
# input-remapper is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# input-remapper is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with input-remapper. If not, see <https://www.gnu.org/licenses/>.
"""Exceptions that are thrown when configurations are incorrect."""
# can't merge this with exceptions.py, because I want error constructors here to
# be intelligent to avoid redundant code, and they need imports, which would cause
# circular imports.
# pydantic only catches ValueError, TypeError, and AssertionError
from __future__ import annotations
from typing import Optional
from evdev.ecodes import EV_KEY
from inputremapper.configs.keyboard_layout import keyboard_layout
from inputremapper.injection.global_uinputs import GlobalUInputs
class OutputSymbolVariantError(ValueError):
def __init__(self):
super().__init__(
"Missing Argument: Mapping must either contain "
"`output_symbol` or `output_type` and `output_code`"
)
class TriggerPointInRangeError(ValueError):
def __init__(self, input_config):
super().__init__(
f"{input_config = } maps an absolute axis to a button, but the "
"trigger point (event.analog_threshold) is not between -100[%] "
"and 100[%]"
)
class OnlyOneAnalogInputError(ValueError):
def __init__(self, analog_events):
super().__init__(
f"Cannot map a combination of multiple analog inputs: {analog_events}"
"add trigger points (event.value != 0) to map as a button"
)
class SymbolNotAvailableInTargetError(ValueError):
def __init__(self, symbol, target):
code = keyboard_layout.get(symbol)
fitting_targets = GlobalUInputs.find_fitting_default_uinputs(EV_KEY, code)
fitting_targets_string = '", "'.join(fitting_targets)
super().__init__(
f'The output_symbol "{symbol}" is not available for the "{target}" '
+ f'target. Try "{fitting_targets_string}".'
)
class OutputSymbolUnknownError(ValueError):
def __init__(self, symbol: str):
super().__init__(
f'The output_symbol "{symbol}" is not a macro and not a valid '
+ "keycode-name"
)
class MacroButTypeOrCodeSetError(ValueError):
def __init__(self):
super().__init__(
"output_symbol is a macro: output_type " "and output_code must be None"
)
class SymbolAndCodeMismatchError(ValueError):
def __init__(self, symbol, code):
super().__init__(
"output_symbol and output_code mismatch: "
f"output macro is {symbol} -> {keyboard_layout.get(symbol)} "
f"but output_code is {code} -> {keyboard_layout.get_name(code)} "
)
class WrongMappingTypeForKeyError(ValueError):
def __init__(self):
super().__init__("Wrong mapping_type for key input")
class MissingMacroOrKeyError(ValueError):
def __init__(self):
super().__init__("Missing macro or key")
class MissingOutputAxisError(ValueError):
def __init__(self, analog_input_config, output_type):
super().__init__(
"Missing output axis: "
f'"{analog_input_config}" is used as analog input, '
f"but the {output_type = } is not an axis "
)
class EmptyAppIdError(ValueError):
def __init__(self):
super().__init__("app_id must not be empty")
class InvalidTitleRegexError(ValueError):
def __init__(self, pattern: str, reason: str):
super().__init__(f'"{pattern}" is not a valid title regex: {reason}')
class MacroError(ValueError):
"""Macro syntax errors."""
def __init__(self, symbol: Optional[str] = None, msg="Error while parsing a macro"):
self.symbol = symbol
super().__init__(msg)
def pydantify(error: type):
"""Generate a string as it would appear IN pydantic error types.
This does not include the base class name, which is transformed to snake case in
pydantic. Example pydantic error type: "value_error.foobar" for FooBarError.
"""
# See https://github.com/pydantic/pydantic/discussions/5112
lower_classname = error.__name__.lower()
if lower_classname.endswith("error"):
return lower_classname[: -len("error")]
return lower_classname

548
inputremapper/daemon.py Normal file
View File

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

View File

@ -0,0 +1,65 @@
# -*- coding: utf-8 -*-
# input-remapper - GUI for device specific keyboard mappings
# Copyright (C) 2025 sezanzeb <b8x45ygc9@mozmail.com>
#
# This file is part of input-remapper.
#
# input-remapper is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# input-remapper is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with input-remapper. If not, see <https://www.gnu.org/licenses/>.
"""Exceptions specific to inputremapper."""
class Error(Exception):
"""Base class for exceptions in inputremapper.
We can catch all inputremapper exceptions with this.
"""
class UinputNotAvailable(Error):
"""If an expected UInput is not found (anymore)."""
def __init__(self, name: str):
super().__init__(f"{name} is not defined or unplugged")
class EventNotHandled(Error):
"""For example mapping to BTN_LEFT on a keyboard target."""
def __init__(self, event):
super().__init__(f"Event {event} can not be handled by the configured target")
class MappingParsingError(Error):
"""Anything that goes wrong during the creation of handlers from the mapping."""
def __init__(self, msg: str, *, mapping=None, mapping_handler=None):
self.mapping_handler = mapping_handler
self.mapping = mapping
super().__init__(msg)
class InputEventCreationError(Error):
"""An input-event failed to be created due to broken factory/constructor calls."""
def __init__(self, msg: str):
super().__init__(msg)
class DataManagementError(Error):
"""Any error that happens in the DataManager."""
def __init__(self, msg: str):
super().__init__(msg)

View File

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

View File

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

View File

@ -0,0 +1,174 @@
# -*- coding: utf-8 -*-
# input-remapper - GUI for device specific keyboard mappings
# Copyright (C) 2025 sezanzeb <b8x45ygc9@mozmail.com>
#
# This file is part of input-remapper.
#
# input-remapper is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# input-remapper is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with input-remapper. If not, see <https://www.gnu.org/licenses/>.
"""Focus backend for Hyprland (socket2 event stream + socket1 queries)."""
from __future__ import annotations
import json
import os
import socket
from typing import Optional
import gi
from inputremapper.focus.focus_backend import FocusBackend, FocusEvent, OnFocus
from inputremapper.logging.logger import logger
gi.require_version("GLib", "2.0")
from gi.repository import GLib # noqa: E402
def _runtime_dirs(signature: str) -> list[str]:
"""Candidate hypr runtime directories, newest layout first."""
candidates = []
xdg_runtime = os.environ.get("XDG_RUNTIME_DIR")
if xdg_runtime:
candidates.append(os.path.join(xdg_runtime, "hypr", signature))
# legacy location for older Hyprland versions
candidates.append(os.path.join("/tmp", "hypr", signature))
return candidates
class HyprlandFocusBackend(FocusBackend):
"""Reads ``activewindow`` events from the Hyprland socket2."""
name = "hyprland"
def __init__(self) -> None:
self._socket: Optional[socket.socket] = None
self._watch_id: Optional[int] = None
self._buffer = b""
self._on_focus: Optional[OnFocus] = None
@staticmethod
def _signature() -> Optional[str]:
return os.environ.get("HYPRLAND_INSTANCE_SIGNATURE")
@classmethod
def _socket_path(cls, name: str) -> Optional[str]:
signature = cls._signature()
if not signature:
return None
for directory in _runtime_dirs(signature):
path = os.path.join(directory, name)
if os.path.exists(path):
return path
return None
@staticmethod
def is_available() -> bool:
return HyprlandFocusBackend._socket_path(".socket2.sock") is not None
def start(self, on_focus: OnFocus) -> None:
path = self._socket_path(".socket2.sock")
if not path:
raise RuntimeError("Hyprland socket2 not found")
self._on_focus = on_focus
self._socket = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
self._socket.connect(path)
self._socket.setblocking(False)
self._watch_id = GLib.io_add_watch(
self._socket.fileno(),
GLib.IO_IN | GLib.IO_HUP | GLib.IO_ERR,
self._on_readable,
)
logger.debug("Hyprland focus backend listening on %s", path)
def _on_readable(self, _fd: int, condition: int) -> bool:
if condition & (GLib.IO_HUP | GLib.IO_ERR):
logger.error("Hyprland socket2 closed")
return False
assert self._socket is not None
try:
data = self._socket.recv(65536)
except BlockingIOError:
return True
except OSError as error:
logger.error("Hyprland socket2 read failed: %s", error)
return False
if not data:
return False
self._buffer += data
while b"\n" in self._buffer:
line, self._buffer = self._buffer.split(b"\n", 1)
self._handle_line(line.decode(errors="replace"))
return True
def _handle_line(self, line: str) -> None:
# Lines look like "activewindow>>CLASS,TITLE"
name, separator, data = line.partition(">>")
if not separator or name != "activewindow":
return
app_id, _, title = data.partition(",")
event = FocusEvent(app_id=app_id, title=title, backend=self.name)
if self._on_focus is not None:
self._on_focus(event)
def stop(self) -> None:
if self._watch_id is not None:
GLib.source_remove(self._watch_id)
self._watch_id = None
if self._socket is not None:
self._socket.close()
self._socket = None
self._buffer = b""
def get_current(self) -> Optional[FocusEvent]:
path = self._socket_path(".socket.sock")
if not path:
return None
try:
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as sock:
sock.connect(path)
sock.sendall(b"j/activewindow")
chunks = []
while True:
chunk = sock.recv(65536)
if not chunk:
break
chunks.append(chunk)
except OSError as error:
logger.error("Failed to query Hyprland active window: %s", error)
return None
raw = b"".join(chunks).decode(errors="replace").strip()
if not raw:
return None
try:
data = json.loads(raw)
except json.JSONDecodeError:
return None
if not data:
return None
return FocusEvent(
app_id=data.get("class", "") or "",
title=data.get("title", "") or "",
backend=self.name,
)

View File

@ -0,0 +1,55 @@
# -*- coding: utf-8 -*-
# input-remapper - GUI for device specific keyboard mappings
# Copyright (C) 2025 sezanzeb <b8x45ygc9@mozmail.com>
#
# This file is part of input-remapper.
#
# input-remapper is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# input-remapper is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with input-remapper. If not, see <https://www.gnu.org/licenses/>.
"""A fallback backend used when no focus detection is possible.
This is selected for example on GNOME-Wayland, where the focused window cannot
be queried without a shell extension. The feature is effectively inactive, but
the focus-service still runs and reports a clear status.
"""
from __future__ import annotations
from typing import Optional
from inputremapper.focus.focus_backend import FocusBackend, FocusEvent, OnFocus
from inputremapper.logging.logger import logger
class NullBackend(FocusBackend):
"""Does nothing. Always available as the last-resort fallback."""
name = "null"
@staticmethod
def is_available() -> bool:
return True
def start(self, on_focus: OnFocus) -> None:
logger.warning(
"No supported focus detection is available for this session "
"(GNOME-Wayland is not supported). Focus-driven preset binding is "
"inactive."
)
def stop(self) -> None:
pass
def get_current(self) -> Optional[FocusEvent]:
return None

View File

@ -0,0 +1,219 @@
# -*- coding: utf-8 -*-
# input-remapper - GUI for device specific keyboard mappings
# Copyright (C) 2025 sezanzeb <b8x45ygc9@mozmail.com>
#
# This file is part of input-remapper.
#
# input-remapper is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# input-remapper is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with input-remapper. If not, see <https://www.gnu.org/licenses/>.
"""Focus backend for Sway (and other i3-IPC compatible compositors)."""
from __future__ import annotations
import json
import os
import socket
import struct
from typing import Optional
import gi
from inputremapper.focus.focus_backend import FocusBackend, FocusEvent, OnFocus
from inputremapper.logging.logger import logger
gi.require_version("GLib", "2.0")
from gi.repository import GLib # noqa: E402
# https://i3wm.org/docs/ipc.html
_MAGIC = b"i3-ipc"
_HEADER = struct.Struct("=6sII") # magic, length, type (native byte order)
_MSG_SUBSCRIBE = 2
_MSG_GET_TREE = 4
_EVENT_MASK = 0x80000000
def _pack(message_type: int, payload: bytes = b"") -> bytes:
return _HEADER.pack(_MAGIC, len(payload), message_type) + payload
def _recv_exactly(sock: socket.socket, length: int) -> bytes:
chunks = []
received = 0
while received < length:
chunk = sock.recv(length - received)
if not chunk:
raise ConnectionError("Socket closed while reading")
chunks.append(chunk)
received += len(chunk)
return b"".join(chunks)
def _recv_message(sock: socket.socket) -> tuple[int, bytes]:
header = _recv_exactly(sock, _HEADER.size)
_, length, message_type = _HEADER.unpack(header)
payload = _recv_exactly(sock, length) if length else b""
return message_type, payload
def _container_to_event(container: dict) -> Optional[FocusEvent]:
if not container:
return None
app_id = container.get("app_id")
if not app_id:
# XWayland windows expose their class via window_properties instead.
window_properties = container.get("window_properties") or {}
app_id = window_properties.get("class")
title = container.get("name") or ""
return FocusEvent(app_id=app_id or "", title=title, backend=SwayFocusBackend.name)
def _find_focused(node: dict) -> Optional[dict]:
if node.get("focused"):
return node
for child in node.get("nodes", []) + node.get("floating_nodes", []):
found = _find_focused(child)
if found is not None:
return found
return None
class SwayFocusBackend(FocusBackend):
"""Subscribes to window events over the i3-IPC socket ($SWAYSOCK)."""
name = "sway"
def __init__(self) -> None:
self._socket: Optional[socket.socket] = None
self._watch_id: Optional[int] = None
self._buffer = b""
self._on_focus: Optional[OnFocus] = None
@staticmethod
def _socket_path() -> Optional[str]:
return os.environ.get("SWAYSOCK") or os.environ.get("I3SOCK")
@staticmethod
def is_available() -> bool:
path = SwayFocusBackend._socket_path()
return path is not None and os.path.exists(path)
def start(self, on_focus: OnFocus) -> None:
path = self._socket_path()
if not path:
raise RuntimeError("SWAYSOCK is not set")
self._on_focus = on_focus
self._socket = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
self._socket.connect(path)
self._socket.sendall(_pack(_MSG_SUBSCRIBE, json.dumps(["window"]).encode()))
self._socket.setblocking(False)
self._watch_id = GLib.io_add_watch(
self._socket.fileno(),
GLib.IO_IN | GLib.IO_HUP | GLib.IO_ERR,
self._on_readable,
)
logger.debug("Sway focus backend listening on %s", path)
def _on_readable(self, _fd: int, condition: int) -> bool:
if condition & (GLib.IO_HUP | GLib.IO_ERR):
logger.error("Sway IPC socket closed")
return False
assert self._socket is not None
try:
data = self._socket.recv(65536)
except BlockingIOError:
return True
except OSError as error:
logger.error("Sway IPC read failed: %s", error)
return False
if not data:
return False
self._buffer += data
self._consume_buffer()
return True
def _consume_buffer(self) -> None:
while len(self._buffer) >= _HEADER.size:
_, length, message_type = _HEADER.unpack(self._buffer[: _HEADER.size])
total = _HEADER.size + length
if len(self._buffer) < total:
break
payload = self._buffer[_HEADER.size : total]
self._buffer = self._buffer[total:]
if message_type & _EVENT_MASK:
self._handle_event(payload)
def _handle_event(self, payload: bytes) -> None:
try:
data = json.loads(payload.decode())
except (json.JSONDecodeError, UnicodeDecodeError):
return
change = data.get("change")
if change not in ("focus", "title"):
return
if change == "title" and not (data.get("container") or {}).get("focused"):
event = self.get_current()
else:
event = _container_to_event(data.get("container") or {})
if event is not None and self._on_focus is not None:
self._on_focus(event)
def stop(self) -> None:
if self._watch_id is not None:
GLib.source_remove(self._watch_id)
self._watch_id = None
if self._socket is not None:
self._socket.close()
self._socket = None
self._buffer = b""
def get_current(self) -> Optional[FocusEvent]:
path = self._socket_path()
if not path:
return None
try:
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as sock:
sock.connect(path)
sock.sendall(_pack(_MSG_GET_TREE))
while True:
message_type, payload = _recv_message(sock)
if message_type == _MSG_GET_TREE:
break
except OSError as error:
logger.error("Failed to query Sway tree: %s", error)
return None
try:
tree = json.loads(payload.decode())
except (json.JSONDecodeError, UnicodeDecodeError):
return None
focused = _find_focused(tree)
if focused is None:
return None
return _container_to_event(focused)

View File

@ -0,0 +1,220 @@
# -*- coding: utf-8 -*-
# input-remapper - GUI for device specific keyboard mappings
# Copyright (C) 2025 sezanzeb <b8x45ygc9@mozmail.com>
#
# This file is part of input-remapper.
#
# input-remapper is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# input-remapper is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with input-remapper. If not, see <https://www.gnu.org/licenses/>.
"""Focus backend for wlroots based compositors and KDE/KWin (Wayland).
Uses the ``zwlr_foreign_toplevel_manager_v1`` protocol to learn which toplevel
window is currently "activated" (focused). pywayland is an optional dependency:
if it (or the protocol bindings) are missing, the backend reports itself as
unavailable and the registry falls back to the next backend.
"""
from __future__ import annotations
import os
from typing import Any, Dict, Optional
import gi
from inputremapper.focus.focus_backend import FocusBackend, FocusEvent, OnFocus
from inputremapper.logging.logger import logger
gi.require_version("GLib", "2.0")
from gi.repository import GLib # noqa: E402
# zwlr_foreign_toplevel_handle_v1 state value for "activated".
_STATE_ACTIVATED = 2
_MANAGER_INTERFACE = "zwlr_foreign_toplevel_manager_v1"
def _import_protocol() -> Optional[Any]:
"""Import the zwlr_foreign_toplevel manager class, or None if unavailable."""
try:
from pywayland.protocol.wlr_foreign_toplevel_management_unstable_v1 import ( # noqa: E501
ZwlrForeignToplevelManagerV1,
)
except (ImportError, ModuleNotFoundError):
return None
return ZwlrForeignToplevelManagerV1
class WlrForeignToplevelBackend(FocusBackend):
"""Tracks the activated toplevel via zwlr_foreign_toplevel_manager_v1."""
name = "wlr-foreign-toplevel"
def __init__(self) -> None:
self._display: Any = None
self._registry: Any = None
self._manager: Any = None
self._watch_id: Optional[int] = None
self._on_focus: Optional[OnFocus] = None
# handle -> {"app_id": str, "title": str, "activated": bool}
self._toplevels: Dict[Any, Dict[str, Any]] = {}
self._current: Optional[FocusEvent] = None
self._current_handle: Optional[Any] = None
@staticmethod
def is_available() -> bool:
if not os.environ.get("WAYLAND_DISPLAY"):
return False
if _import_protocol() is None:
logger.debug("pywayland or wlr-foreign-toplevel bindings not available")
return False
# Verify the compositor actually exports the manager global.
try:
from pywayland.client import Display
except (ImportError, ModuleNotFoundError):
return False
display = None
try:
display = Display()
display.connect()
found = {"value": False}
registry = display.get_registry()
def _on_global(_registry, _name, interface, _version):
if interface == _MANAGER_INTERFACE:
found["value"] = True
registry.dispatcher["global"] = _on_global
display.roundtrip()
return found["value"]
except Exception as error: # pragma: no cover - depends on environment
logger.debug("wlr-foreign-toplevel probe failed: %s", error)
return False
finally:
if display is not None:
try:
display.disconnect()
except Exception: # pragma: no cover - defensive
pass
def start(self, on_focus: OnFocus) -> None:
from pywayland.client import Display
manager_class = _import_protocol()
if manager_class is None:
raise RuntimeError("wlr-foreign-toplevel bindings are not available")
self._on_focus = on_focus
self._display = Display()
self._display.connect()
self._registry = self._display.get_registry()
self._registry.dispatcher["global"] = self._make_global_handler(manager_class)
self._display.roundtrip()
self._display.flush()
self._watch_id = GLib.io_add_watch(
self._display.get_fd(),
GLib.IO_IN | GLib.IO_HUP | GLib.IO_ERR,
self._on_readable,
)
logger.debug("wlr-foreign-toplevel focus backend started")
def _make_global_handler(self, manager_class: Any):
def _on_global(registry, name, interface, version):
if interface != _MANAGER_INTERFACE:
return
self._manager = registry.bind(name, manager_class, version)
self._manager.dispatcher["toplevel"] = self._on_toplevel
return _on_global
def _on_toplevel(self, _manager, handle) -> None:
self._toplevels[handle] = {"app_id": "", "title": "", "activated": False}
handle.dispatcher["app_id"] = self._on_app_id
handle.dispatcher["title"] = self._on_title
handle.dispatcher["state"] = self._on_state
handle.dispatcher["done"] = self._on_done
handle.dispatcher["closed"] = self._on_closed
def _on_app_id(self, handle, app_id) -> None:
if handle in self._toplevels:
self._toplevels[handle]["app_id"] = app_id or ""
def _on_title(self, handle, title) -> None:
if handle in self._toplevels:
self._toplevels[handle]["title"] = title or ""
def _on_state(self, handle, states) -> None:
if handle not in self._toplevels:
return
# `states` is a wl_array of 32-bit ints.
values = list(states) if states is not None else []
self._toplevels[handle]["activated"] = _STATE_ACTIVATED in values
def _on_done(self, handle) -> None:
info = self._toplevels.get(handle)
if info is None or not info["activated"]:
return
event = FocusEvent(
app_id=info["app_id"], title=info["title"], backend=self.name
)
self._current_handle = handle
self._current = event
if self._on_focus is not None:
self._on_focus(event)
def _on_closed(self, handle) -> None:
self._toplevels.pop(handle, None)
if handle != self._current_handle:
return
self._current_handle = None
self._current = FocusEvent(app_id="", title="", backend=self.name)
if self._on_focus is not None:
self._on_focus(self._current)
def _on_readable(self, _fd: int, condition: int) -> bool:
if condition & (GLib.IO_HUP | GLib.IO_ERR):
logger.error("Wayland connection lost")
return False
try:
self._display.dispatch(block=False)
self._display.flush()
except Exception as error: # pragma: no cover - depends on environment
logger.error("Error dispatching Wayland events: %s", error)
return False
return True
def get_current(self) -> Optional[FocusEvent]:
return self._current
def stop(self) -> None:
if self._watch_id is not None:
GLib.source_remove(self._watch_id)
self._watch_id = None
if self._display is not None:
try:
self._display.disconnect()
except Exception: # pragma: no cover - defensive
pass
self._display = None
self._manager = None
self._registry = None
self._toplevels.clear()
self._current_handle = None
self._current = None

View File

@ -0,0 +1,181 @@
# -*- coding: utf-8 -*-
# input-remapper - GUI for device specific keyboard mappings
# Copyright (C) 2025 sezanzeb <b8x45ygc9@mozmail.com>
#
# This file is part of input-remapper.
#
# input-remapper is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# input-remapper is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with input-remapper. If not, see <https://www.gnu.org/licenses/>.
"""Focus backend for X11 / XWayland using python-xlib.
The root window is watched for ``_NET_ACTIVE_WINDOW`` property changes. The
X connection's file descriptor is integrated into the GLib main loop so no
extra thread is needed.
"""
from __future__ import annotations
import os
from typing import Any, Optional
import gi
from inputremapper.focus.focus_backend import FocusBackend, FocusEvent, OnFocus
from inputremapper.logging.logger import logger
gi.require_version("GLib", "2.0")
from gi.repository import GLib # noqa: E402
class XorgFocusBackend(FocusBackend):
"""Detects focus changes via _NET_ACTIVE_WINDOW on the X root window."""
name = "xorg"
def __init__(self) -> None:
self._display: Any = None
self._root: Any = None
self._watch_id: Optional[int] = None
self._on_focus: Optional[OnFocus] = None
self._net_active_window: Any = None
self._net_wm_name: Any = None
self._utf8_string: Any = None
@staticmethod
def is_available() -> bool:
if not os.environ.get("DISPLAY"):
return False
try:
from Xlib import display as xdisplay
except ImportError:
logger.debug("python-xlib is not installed")
return False
try:
connection = xdisplay.Display()
connection.close()
except Exception as error: # pragma: no cover - depends on environment
logger.debug("Could not open X display: %s", error)
return False
return True
def start(self, on_focus: OnFocus) -> None:
from Xlib import X, display as xdisplay
self._on_focus = on_focus
self._display = xdisplay.Display()
self._root = self._display.screen().root
self._net_active_window = self._display.intern_atom("_NET_ACTIVE_WINDOW")
self._net_wm_name = self._display.intern_atom("_NET_WM_NAME")
self._utf8_string = self._display.intern_atom("UTF8_STRING")
self._root.change_attributes(event_mask=X.PropertyChangeMask)
self._display.flush()
self._watch_id = GLib.io_add_watch(
self._display.fileno(),
GLib.IO_IN | GLib.IO_HUP | GLib.IO_ERR,
self._on_readable,
)
logger.debug("Xorg focus backend watching root window")
def _on_readable(self, _fd: int, condition: int) -> bool:
from Xlib import X
if condition & (GLib.IO_HUP | GLib.IO_ERR):
logger.error("X connection lost")
return False
try:
pending = self._display.pending_events()
for _ in range(pending):
event = self._display.next_event()
if (
event.type == X.PropertyNotify
and event.atom == self._net_active_window
):
self._emit_current()
except Exception as error: # pragma: no cover - depends on environment
logger.error("Error while reading X events: %s", error)
return False
return True
def _emit_current(self) -> None:
event = self.get_current()
if event is not None and self._on_focus is not None:
self._on_focus(event)
def _active_window(self) -> Any:
prop = self._root.get_full_property(self._net_active_window, 0)
if prop is None or not prop.value:
return None
window_id = prop.value[0]
if not window_id:
return None
return self._display.create_resource_object("window", window_id)
def _read_title(self, window: Any) -> str:
from Xlib import X
try:
prop = window.get_full_property(self._net_wm_name, self._utf8_string)
if prop and prop.value:
value = prop.value
if isinstance(value, bytes):
return value.decode(errors="replace")
return str(value)
prop = window.get_full_property(X.XA_WM_NAME, X.AnyPropertyType)
if prop and prop.value:
value = prop.value
if isinstance(value, bytes):
return value.decode(errors="replace")
return str(value)
except Exception: # pragma: no cover - depends on environment
pass
return ""
def get_current(self) -> Optional[FocusEvent]:
if self._display is None:
return None
from Xlib.error import XError
try:
window = self._active_window()
if window is None:
return FocusEvent(app_id="", title="", backend=self.name)
wm_class = window.get_wm_class()
# get_wm_class returns (instance, class); the class is the canonical id
app_id = wm_class[1] if wm_class else ""
title = self._read_title(window)
except XError as error:
logger.debug("X error while reading active window: %s", error)
return None
return FocusEvent(app_id=app_id or "", title=title, backend=self.name)
def stop(self) -> None:
if self._watch_id is not None:
GLib.source_remove(self._watch_id)
self._watch_id = None
if self._display is not None:
try:
self._display.close()
except Exception: # pragma: no cover - defensive
pass
self._display = None
self._root = None

View File

@ -0,0 +1,88 @@
# -*- coding: utf-8 -*-
# input-remapper - GUI for device specific keyboard mappings
# Copyright (C) 2025 sezanzeb <b8x45ygc9@mozmail.com>
#
# This file is part of input-remapper.
#
# input-remapper is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# input-remapper is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with input-remapper. If not, see <https://www.gnu.org/licenses/>.
"""The base class and event for all focus backends.
This lives in its own module to avoid a circular import between the backend
registry (``focus_watcher``) and the concrete backends.
"""
from __future__ import annotations
from abc import ABC, abstractmethod
from dataclasses import dataclass
from typing import Callable, Optional
@dataclass(frozen=True)
class FocusEvent:
"""A normalized description of the currently focused window.
Attributes
----------
app_id
The canonical application identifier (WM_CLASS on X11, app_id on
Wayland). This is the value bindings are matched against. May be empty
for transient focus changes (menus, popups, no focus).
title
The human readable window title (_NET_WM_NAME / title).
backend
The name of the backend that produced this event.
"""
app_id: str
title: str
backend: str
# Type alias for the focus callback passed to FocusBackend.start.
OnFocus = Callable[[FocusEvent], None]
class FocusBackend(ABC):
"""Detects the focused window for a specific compositor / display server.
Backends integrate into the GLib main loop (e.g. via ``GLib.io_add_watch``
on a file descriptor) and call the ``on_focus`` callback whenever the
focused window changes.
"""
# A short, human readable identifier, also used as FocusEvent.backend.
name: str = "base"
@staticmethod
@abstractmethod
def is_available() -> bool:
"""Whether this backend can run in the current session."""
raise NotImplementedError
@abstractmethod
def start(self, on_focus: OnFocus) -> None:
"""Begin watching for focus changes, invoking on_focus on each change."""
raise NotImplementedError
@abstractmethod
def stop(self) -> None:
"""Stop watching and release all resources."""
raise NotImplementedError
@abstractmethod
def get_current(self) -> Optional[FocusEvent]:
"""Return the currently focused window, or None if unavailable."""
raise NotImplementedError

View File

@ -0,0 +1,278 @@
# -*- coding: utf-8 -*-
# input-remapper - GUI for device specific keyboard mappings
# Copyright (C) 2025 sezanzeb <b8x45ygc9@mozmail.com>
#
# This file is part of input-remapper.
#
# input-remapper is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# input-remapper is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with input-remapper. If not, see <https://www.gnu.org/licenses/>.
"""User-level service that applies presets based on the focused application.
This process runs as the logged-in user (never root, no pkexec). It reads the
user config, detects the focused window through a ``FocusBackend`` and drives
the existing root daemon over the system D-Bus. It exposes a small interface on
the session bus (``inputremapper.Focus``) for the GUI.
"""
from __future__ import annotations
import json
from typing import Dict, List, Optional
import gi
from dasbus.connection import SessionMessageBus
from dasbus.identifier import DBusServiceIdentifier
from dasbus.loop import EventLoop
from dasbus.signal import Signal
from inputremapper.configs.app_binding import AppBinding
from inputremapper.configs.global_config import GlobalConfig
from inputremapper.daemon import DaemonProxy
from inputremapper.focus.focus_backend import FocusBackend, FocusEvent
from inputremapper.logging.logger import logger
gi.require_version("GLib", "2.0")
from gi.repository import GLib # noqa: E402
SESSION_BUS = SessionMessageBus()
FOCUS = DBusServiceIdentifier(
namespace=("inputremapper", "Focus"),
message_bus=SESSION_BUS,
)
# How long to wait for the focus to settle before reconciling, in milliseconds.
# This collapses rapid alt-tabbing into a single reconciliation.
DEFAULT_DEBOUNCE_MS = 150
class FocusService:
"""Reconciles injected presets with the currently focused application.
The service only ever stops injections that it started itself (tracked in
``_app_controlled``). Presets applied manually (e.g. from the GUI) are left
untouched until another *bound* application takes focus and reclaims that
device, which implements the "manual apply is a temporary override"
behavior.
"""
__dbus_xml__ = f"""
<node>
<interface name='{FOCUS.interface_name}'>
<method name='get_focused_app'>
<arg type='s' name='response' direction='out'/>
</method>
<method name='reload'>
</method>
<method name='set_enabled'>
<arg type='b' name='enabled' direction='in'/>
</method>
<method name='get_status'>
<arg type='s' name='response' direction='out'/>
</method>
<signal name='focus_changed'>
<arg type='s' name='app'/>
</signal>
</interface>
</node>
"""
def __init__(
self,
global_config: GlobalConfig,
backend: FocusBackend,
daemon: DaemonProxy,
debounce_ms: int = DEFAULT_DEBOUNCE_MS,
) -> None:
self.global_config = global_config
self.backend = backend
self.daemon = daemon
self._debounce_ms = debounce_ms
# The signal attribute that dasbus connects to the bus.
self.focus_changed = Signal()
self._bindings: List[AppBinding] = []
self._enabled = False
# group_key -> preset currently injected because of a binding
self._app_controlled: Dict[str, str] = {}
self._current_event: Optional[FocusEvent] = None
self._pending_event: Optional[FocusEvent] = None
self._debounce_id: Optional[int] = None
self._load_config()
# -- lifecycle ---------------------------------------------------------
def _load_config(self) -> None:
self.global_config.load_config()
self._enabled = self.global_config.get_app_binding_enabled()
self._bindings = self.global_config.get_app_bindings()
logger.info(
"Loaded %d app binding(s), enabled=%s",
len(self._bindings),
self._enabled,
)
def publish(self) -> None:
"""Make the session-bus interface available to the GUI."""
try:
SESSION_BUS.publish_object(FOCUS.object_path, self)
SESSION_BUS.register_service(FOCUS.service_name)
except ConnectionError as error:
logger.error("Is the focus-service already running? (%s)", str(error))
raise RuntimeError("Failed to publish focus-service on D-Bus") from error
def start(self) -> None:
"""Publish the interface and start watching for focus changes."""
self.publish()
self.backend.start(self._on_focus)
self._current_event = self.backend.get_current()
if self._current_event is not None and self._enabled:
self._reconcile(self._current_event)
logger.info("Focus-service started with backend '%s'", self.backend.name)
def run(self) -> None:
"""Start the service and block on the GLib main loop."""
self.start()
loop = EventLoop()
loop.run()
def stop(self) -> None:
"""Stop the backend and any injections this service controls."""
if self._debounce_id is not None:
GLib.source_remove(self._debounce_id)
self._debounce_id = None
self.backend.stop()
for group_key in list(self._app_controlled):
self.daemon.stop_injecting(group_key)
self._app_controlled.clear()
# -- focus handling ----------------------------------------------------
def _on_focus(self, event: FocusEvent) -> None:
"""Called by the backend on every focus change (debounced here)."""
self._pending_event = event
if self._debounce_id is not None:
GLib.source_remove(self._debounce_id)
self._debounce_id = GLib.timeout_add(self._debounce_ms, self._flush)
def _flush(self) -> bool:
self._debounce_id = None
event = self._pending_event
if event is None:
return False
changed = event != self._current_event
self._current_event = event
if changed:
self._emit_focus_changed(event)
if self._enabled:
self._reconcile(event)
return False # GLib: do not repeat
def _compute_desired(self, event: FocusEvent) -> Dict[str, str]:
"""Map of group_key -> preset that should be injected for this event."""
desired: Dict[str, str] = {}
for binding in self._bindings:
if binding.matches(event):
for bound in binding.presets:
desired[bound.group_key] = bound.preset
return desired
def _reconcile(self, event: FocusEvent) -> None:
desired = self._compute_desired(event)
# Stop app-controlled injections that are no longer wanted. Manually
# applied presets are not in _app_controlled, so they are never stopped
# here.
for group_key in list(self._app_controlled):
if group_key not in desired:
logger.info("Focus reconcile: stopping '%s'", group_key)
self.daemon.stop_injecting(group_key)
del self._app_controlled[group_key]
# Start or replace the wanted injections.
for group_key, preset in desired.items():
if self._app_controlled.get(group_key) == preset:
continue
logger.info(
"Focus reconcile: injecting '%s' on '%s' for app '%s'",
preset,
group_key,
event.app_id,
)
if self.daemon.start_injecting(group_key, preset):
self._app_controlled[group_key] = preset
else:
logger.error(
"Failed to start injecting '%s' on '%s'", preset, group_key
)
def _emit_focus_changed(self, event: FocusEvent) -> None:
self.focus_changed(self._event_to_json(event))
@staticmethod
def _event_to_json(event: Optional[FocusEvent]) -> str:
if event is None:
return json.dumps({"app_id": "", "title": ""})
return json.dumps({"app_id": event.app_id, "title": event.title})
# -- D-Bus interface ---------------------------------------------------
def get_focused_app(self) -> str:
"""Return json {app_id, title} for the currently focused window."""
return self._event_to_json(self._current_event)
def reload(self) -> None:
"""Re-read the config from disk and reconcile the current focus."""
logger.info("Reloading focus-service config")
self._load_config()
if self._current_event is not None and self._enabled:
self._reconcile(self._current_event)
elif not self._enabled:
self._stop_app_controlled()
def set_enabled(self, enabled: bool) -> None:
"""Toggle focus-driven binding at runtime (does not write the config)."""
logger.info("Setting focus-binding enabled=%s", enabled)
self._enabled = bool(enabled)
if self._enabled:
if self._current_event is not None:
self._reconcile(self._current_event)
else:
self._stop_app_controlled()
def get_status(self) -> str:
"""Return json describing the service state for the GUI."""
return json.dumps(
{
"backend": self.backend.name,
"enabled": self._enabled,
"focused": {
"app_id": self._current_event.app_id if self._current_event else "",
"title": self._current_event.title if self._current_event else "",
},
"app_controlled": dict(self._app_controlled),
}
)
def _stop_app_controlled(self) -> None:
for group_key in list(self._app_controlled):
self.daemon.stop_injecting(group_key)
del self._app_controlled[group_key]

View File

@ -0,0 +1,83 @@
# -*- coding: utf-8 -*-
# input-remapper - GUI for device specific keyboard mappings
# Copyright (C) 2025 sezanzeb <b8x45ygc9@mozmail.com>
#
# This file is part of input-remapper.
#
# input-remapper is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# input-remapper is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with input-remapper. If not, see <https://www.gnu.org/licenses/>.
"""Abstractions to detect the currently focused window across compositors.
Focus detection is implemented as a registry of backends. To add support for a
new compositor, implement a new ``FocusBackend`` and append its class to
``focus_backend_classes`` (ordered by priority). ``select_backend`` returns the
first backend that reports itself as available, so the most specific backend
for the current session wins.
"""
from __future__ import annotations
from typing import List, Type
from inputremapper.focus.backends.hyprland import HyprlandFocusBackend
from inputremapper.focus.backends.null import NullBackend
from inputremapper.focus.backends.sway import SwayFocusBackend
from inputremapper.focus.backends.wlr_foreign_toplevel import (
WlrForeignToplevelBackend,
)
from inputremapper.focus.backends.xorg import XorgFocusBackend
from inputremapper.focus.focus_backend import FocusBackend, FocusEvent
from inputremapper.logging.logger import logger
# Re-exported for convenience / backwards compatible imports.
__all__ = [
"FocusEvent",
"FocusBackend",
"focus_backend_classes",
"select_backend",
]
# Ordered by priority. The first one that reports is_available() wins.
# SWAYSOCK -> HYPRLAND_INSTANCE_SIGNATURE -> wlr-foreign-toplevel -> DISPLAY -> Null
focus_backend_classes: List[Type[FocusBackend]] = [
SwayFocusBackend,
HyprlandFocusBackend,
WlrForeignToplevelBackend,
XorgFocusBackend,
NullBackend,
]
def select_backend() -> FocusBackend:
"""Instantiate the first available focus backend by priority."""
for backend_class in focus_backend_classes:
try:
available = backend_class.is_available()
except Exception as error: # pragma: no cover - defensive
logger.debug(
"Backend %s failed its availability check: %s",
backend_class.__name__,
error,
)
available = False
if available:
logger.info("Using focus backend %s", backend_class.name)
return backend_class()
# NullBackend.is_available() always returns True, so this is unreachable
# as long as it stays in the registry.
logger.warning("No focus backend available, falling back to NullBackend")
return NullBackend()

567
inputremapper/groups.py Normal file
View File

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

View File

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

View File

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

View File

View File

@ -0,0 +1,539 @@
# -*- coding: utf-8 -*-
# input-remapper - GUI for device specific keyboard mappings
# Copyright (C) 2025 sezanzeb <b8x45ygc9@mozmail.com>
#
# This file is part of input-remapper.
#
# input-remapper is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# input-remapper is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with input-remapper. If not, see <https://www.gnu.org/licenses/>.
"""Components for the focus-driven "Application Bindings" page.
This page lets the user bind presets to applications. When the feature is
enabled, the user-level focus-service watches the focused window and applies the
bound presets automatically. The whole page is built dynamically because the
list of bindings (and the presets within each binding) is fully variable.
Everything is driven through the MessageBroker: the editor rebuilds itself from
``AppBindingsData`` and persists changes through the Controller, which republishes
the new state. Self-originated saves are guarded so the editor does not rebuild
(and lose focus) while the user is typing.
"""
from __future__ import annotations
import contextlib
from typing import Callable, List, Optional, Tuple
from gi.repository import Gtk
from inputremapper.configs.app_binding import AppBinding, BoundPreset, MatchType
from inputremapper.gui.controller import Controller
from inputremapper.gui.gettext import _
from inputremapper.gui.messages.message_broker import (
MessageBroker,
MessageType,
)
from inputremapper.gui.messages.message_data import (
AppBindingsData,
FocusAppData,
GroupsData,
)
from inputremapper.gui.utils import CTX_MAPPING, HandlerDisabled, debounce
from inputremapper.logging.logger import logger
# human readable labels for the match types
MATCH_TYPE_LABELS = {
MatchType.wm_class: _("Window class"),
MatchType.title_regex: _("Title (regex)"),
}
def _maybe_blocked(widget: Gtk.Widget, handler: Callable, block: bool):
"""Block ``handler`` while modifying ``widget`` only when ``block`` is True.
Used so the initial population of a freshly built combo (before its handlers
are connected) does not log spurious HandlerDisabled warnings.
"""
if block:
return HandlerDisabled(widget, handler)
return contextlib.nullcontext()
class AppBindingsEditor:
"""The whole "Application Bindings" page.
Owns a global enable switch, a status label (e.g. for unsupported
environments) and a dynamic list of binding rows.
"""
def __init__(
self,
message_broker: MessageBroker,
controller: Controller,
enabled_switch: Gtk.Switch,
status_label: Gtk.Label,
listbox: Gtk.ListBox,
add_button: Gtk.Button,
):
self._message_broker = message_broker
self._controller = controller
self._enabled_switch = enabled_switch
self._status_label = status_label
self._listbox = listbox
self._add_button = add_button
# available device groups (group keys), updated from the "groups" message
self._groups: Tuple[str, ...] = ()
# whether focus detection is usable (service reachable + backend present)
self._detection_available = False
# the binding row currently waiting for a detected app, if any
self._detecting_row: Optional[_BindingRow] = None
# guards rebuilds caused by our own saves
self._editing = False
self._rows: List[_BindingRow] = []
self._enabled_switch.connect("state-set", self._on_enabled_toggled)
self._add_button.connect("clicked", self._on_add_binding_clicked)
self._message_broker.subscribe(MessageType.app_bindings, self._on_app_bindings)
self._message_broker.subscribe(MessageType.groups, self._on_groups)
self._message_broker.subscribe(MessageType.focused_app, self._on_focused_app)
# -- message listeners -------------------------------------------------
def _on_groups(self, data: GroupsData):
self._groups = tuple(data.groups.keys())
# refresh the device selectors of every existing row
for row in self._rows:
row.refresh_groups(self._groups)
def _on_app_bindings(self, data: AppBindingsData):
with HandlerDisabled(self._enabled_switch, self._on_enabled_toggled):
self._enabled_switch.set_active(data.enabled)
self._detection_available = data.supported
self._update_status_label(data)
self._update_detect_sensitivity()
if self._editing:
# this is the echo of our own save; do not rebuild and steal focus
return
self._rebuild(data.bindings)
def _on_focused_app(self, data: FocusAppData):
if self._detecting_row is None:
return
if not data.app_id:
# transient focus, keep waiting
return
row = self._detecting_row
self._stop_detection()
row.set_app_id(data.app_id)
title = data.title or data.app_id
self._controller.show_status(
CTX_MAPPING, _('Detected application "%s"') % title
)
self.save()
# -- gtk handlers ------------------------------------------------------
def _on_enabled_toggled(self, _switch, state: bool):
self._controller.set_app_binding_enabled(state)
return False # let GTK update the switch visual state
def _on_add_binding_clicked(self, *_args):
row = self._build_row(AppBinding(app_id=_("new application")))
self._rows.append(row)
self._listbox.insert(row.widget, -1)
self._listbox.show_all()
self.save()
# -- detection ---------------------------------------------------------
def request_detection(self, row: "_BindingRow"):
"""Begin (or restart) focus detection for the given binding row."""
if self._detecting_row is row:
# toggling off
self._stop_detection()
return
# only one row can detect at a time
if self._detecting_row is not None:
self._detecting_row.set_detecting(False)
self._detecting_row = row
row.set_detecting(True)
self._controller.start_app_detection()
self._controller.show_status(
CTX_MAPPING, _("Switch to the application you want to bind…")
)
def _stop_detection(self):
if self._detecting_row is not None:
self._detecting_row.set_detecting(False)
self._detecting_row = None
self._controller.stop_app_detection()
# -- persistence -------------------------------------------------------
def save(self):
"""Collect every binding from the UI and persist it if all are valid."""
try:
bindings = self.to_model()
except ValueError as error:
self._controller.show_status(CTX_MAPPING, str(error))
return
self._editing = True
try:
self._controller.update_app_bindings(bindings)
finally:
self._editing = False
def to_model(self) -> List[AppBinding]:
"""Return all bindings, or raise if any visible row is invalid."""
bindings: List[AppBinding] = []
first_error: Optional[ValueError] = None
for row in self._rows:
try:
model = row.to_model()
bindings.append(model)
row.set_error("")
except ValueError as error:
row.set_error(str(error))
if first_error is None:
first_error = error
if first_error is not None:
raise first_error
return bindings
# -- helpers -----------------------------------------------------------
def get_presets_for_group(self, group_key: str) -> Tuple[str, ...]:
"""Proxy used by binding rows to populate their preset selectors."""
return self._controller.get_presets_for_group(group_key)
@property
def groups(self) -> Tuple[str, ...]:
return self._groups
@property
def detection_available(self) -> bool:
return self._detection_available
def _rebuild(self, bindings: Tuple[AppBinding, ...]):
self._stop_detection()
for child in self._listbox.get_children():
self._listbox.remove(child)
self._rows = []
for binding in bindings:
row = self._build_row(binding)
self._rows.append(row)
self._listbox.insert(row.widget, -1)
self._listbox.show_all()
def _build_row(self, binding: AppBinding) -> "_BindingRow":
return _BindingRow(self, binding)
def remove_row(self, row: "_BindingRow"):
"""Remove a binding row from the list and persist."""
if row is self._detecting_row:
self._stop_detection()
if row in self._rows:
self._rows.remove(row)
self._listbox.remove(row.widget)
self.save()
def _update_detect_sensitivity(self):
for row in self._rows:
row.set_detect_sensitive(self._detection_available)
def _update_status_label(self, data: AppBindingsData):
if not data.enabled:
self._status_label.set_text(
_("Enable application bindings to apply presets based on focus.")
)
return
if not data.reachable:
self._status_label.set_text(_("Starting the focus-detection service…"))
return
if not data.supported:
self._status_label.set_text(
_(
"Focus detection is not supported on your environment "
"(e.g. GNOME on Wayland)."
)
)
return
self._status_label.set_text(
_("Focus detection is active (backend: %s).") % data.backend
)
class _PresetRow:
"""A single (device, preset) selector inside a binding."""
def __init__(self, binding_row: "_BindingRow", bound: Optional[BoundPreset]):
self._binding_row = binding_row
self._editor = binding_row.editor
self.widget = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6)
self.widget.set_margin_start(12)
self._device_combo = Gtk.ComboBoxText()
self._device_combo.set_hexpand(True)
self._preset_combo = Gtk.ComboBoxText()
self._preset_combo.set_hexpand(True)
remove_button = Gtk.Button.new_from_icon_name(
"edit-delete", Gtk.IconSize.BUTTON
)
remove_button.set_tooltip_text(_("Remove this preset"))
self.widget.pack_start(self._device_combo, True, True, 0)
self.widget.pack_start(self._preset_combo, True, True, 0)
self.widget.pack_start(remove_button, False, False, 0)
self._selected_group = bound.group_key if bound else ""
self._selected_preset = bound.preset if bound else ""
# populate before connecting, so the initial selection does not trigger a save
self._populate_devices(block=False)
self._populate_presets(block=False)
self._device_combo.connect("changed", self._on_device_changed)
self._preset_combo.connect("changed", self._on_preset_changed)
remove_button.connect("clicked", self._on_remove_clicked)
def _populate_devices(self, block: bool = True):
with _maybe_blocked(self._device_combo, self._on_device_changed, block):
self._device_combo.remove_all()
group_keys = list(self._editor.groups)
# keep a stored group even if the device is not currently plugged in
if self._selected_group and self._selected_group not in group_keys:
group_keys.append(self._selected_group)
for group_key in group_keys:
self._device_combo.append(group_key, group_key)
if self._selected_group:
self._device_combo.set_active_id(self._selected_group)
def _populate_presets(self, block: bool = True):
with _maybe_blocked(self._preset_combo, self._on_preset_changed, block):
self._preset_combo.remove_all()
presets: Tuple[str, ...] = ()
if self._selected_group:
presets = self._editor.get_presets_for_group(self._selected_group)
preset_names = list(presets)
if self._selected_preset and self._selected_preset not in preset_names:
preset_names.append(self._selected_preset)
for preset_name in preset_names:
self._preset_combo.append(preset_name, preset_name)
if self._selected_preset:
self._preset_combo.set_active_id(self._selected_preset)
def refresh_groups(self):
self._populate_devices()
def _on_device_changed(self, *_):
self._selected_group = self._device_combo.get_active_id() or ""
# changing the device invalidates the chosen preset
self._selected_preset = ""
self._populate_presets()
self._binding_row.editor.save()
def _on_preset_changed(self, *_):
self._selected_preset = self._preset_combo.get_active_id() or ""
self._binding_row.editor.save()
def _on_remove_clicked(self, *_):
self._binding_row.remove_preset(self)
def to_model(self) -> BoundPreset:
if not self._selected_group or not self._selected_preset:
raise ValueError(_("Select both a device and a preset."))
return BoundPreset(group_key=self._selected_group, preset=self._selected_preset)
class _BindingRow:
"""A single application binding (app_id + match type + bound presets)."""
def __init__(self, editor: AppBindingsEditor, binding: AppBinding):
self.editor = editor
self._preset_rows: List[_PresetRow] = []
self.widget = Gtk.ListBoxRow()
self.widget.set_selectable(False)
self.widget.set_activatable(False)
frame = Gtk.Frame()
frame.set_margin_start(12)
frame.set_margin_end(12)
frame.set_margin_top(6)
frame.set_margin_bottom(6)
self.widget.add(frame)
outer = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=6)
outer.set_margin_start(12)
outer.set_margin_end(12)
outer.set_margin_top(12)
outer.set_margin_bottom(12)
frame.add(outer)
# header row: app_id entry + match selector + detect + remove
header = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6)
outer.pack_start(header, False, False, 0)
self._app_id_entry = Gtk.Entry()
self._app_id_entry.set_hexpand(True)
self._app_id_entry.set_placeholder_text(
_("application identifier or title pattern")
)
self._app_id_entry.set_text(binding.app_id)
header.pack_start(self._app_id_entry, True, True, 0)
self._match_combo = Gtk.ComboBoxText()
for match_type in MatchType:
self._match_combo.append(match_type.value, MATCH_TYPE_LABELS[match_type])
self._match_combo.set_active_id(binding.match.value)
header.pack_start(self._match_combo, False, False, 0)
self._detect_button = Gtk.Button.new_with_label(_("Detect"))
self._detect_button.set_tooltip_text(
_("Detect the focused application automatically")
)
header.pack_start(self._detect_button, False, False, 0)
remove_button = Gtk.Button.new_from_icon_name(
"edit-delete", Gtk.IconSize.BUTTON
)
remove_button.set_tooltip_text(_("Remove this binding"))
header.pack_start(remove_button, False, False, 0)
# presets container
self._presets_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=6)
outer.pack_start(self._presets_box, False, False, 0)
add_preset_button = Gtk.Button.new_with_label(_("Add preset"))
add_preset_button.set_halign(Gtk.Align.START)
outer.pack_start(add_preset_button, False, False, 0)
self._error_label = Gtk.Label()
self._error_label.set_halign(Gtk.Align.START)
self._error_label.set_line_wrap(True)
self._error_label.set_no_show_all(True)
outer.pack_start(self._error_label, False, False, 0)
for bound in binding.presets:
self._add_preset_row(bound)
# signals
self._app_id_entry.connect("changed", self._on_app_id_changed)
self._match_combo.connect("changed", self._on_match_changed)
self._detect_button.connect("clicked", self._on_detect_clicked)
remove_button.connect("clicked", self._on_remove_clicked)
add_preset_button.connect("clicked", self._on_add_preset_clicked)
self.set_detect_sensitive(editor.detection_available)
# -- preset rows -------------------------------------------------------
def _add_preset_row(self, bound: Optional[BoundPreset]):
preset_row = _PresetRow(self, bound)
self._preset_rows.append(preset_row)
self._presets_box.pack_start(preset_row.widget, False, False, 0)
return preset_row
def remove_preset(self, preset_row: _PresetRow):
if preset_row in self._preset_rows:
self._preset_rows.remove(preset_row)
self._presets_box.remove(preset_row.widget)
self.editor.save()
def refresh_groups(self, _groups: Tuple[str, ...]):
for preset_row in self._preset_rows:
preset_row.refresh_groups()
# -- detection ---------------------------------------------------------
def set_detecting(self, detecting: bool):
self._detect_button.set_label(_("Cancel") if detecting else _("Detect"))
def set_detect_sensitive(self, sensitive: bool):
self._detect_button.set_sensitive(sensitive)
def set_app_id(self, app_id: str):
with HandlerDisabled(self._app_id_entry, self._on_app_id_changed):
self._app_id_entry.set_text(app_id)
def set_error(self, error: str):
self._error_label.set_text(error)
if error:
self._error_label.show()
else:
self._error_label.hide()
# -- gtk handlers ------------------------------------------------------
@debounce(500)
def _on_app_id_changed(self, *_):
self.editor.save()
def _on_match_changed(self, *_):
self.editor.save()
def _on_detect_clicked(self, *_):
self.editor.request_detection(self)
def _on_remove_clicked(self, *_):
self.editor.remove_row(self)
def _on_add_preset_clicked(self, *_):
self._add_preset_row(None)
self._presets_box.show_all()
self.editor.save()
# -- model -------------------------------------------------------------
def to_model(self) -> AppBinding:
app_id = self._app_id_entry.get_text().strip()
if not app_id:
raise ValueError(_("Application identifier must not be empty."))
match_value = self._match_combo.get_active_id() or MatchType.wm_class.value
presets = []
for preset_row in self._preset_rows:
presets.append(preset_row.to_model())
try:
return AppBinding(
app_id=app_id,
match=MatchType(match_value),
presets=presets,
)
except ValueError as error:
# e.g. an invalid title regex; keep the row so the user can fix it
logger.debug("Invalid app binding: %s", error)
raise ValueError(str(error)) from error

View File

@ -0,0 +1,174 @@
# -*- coding: utf-8 -*-
# input-remapper - GUI for device specific keyboard mappings
# Copyright (C) 2025 sezanzeb <b8x45ygc9@mozmail.com>
#
# This file is part of input-remapper.
#
# input-remapper is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# input-remapper is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with input-remapper. If not, see <https://www.gnu.org/licenses/>.
"""Components used in multiple places."""
from __future__ import annotations
from gi.repository import Gtk
from typing import Optional
from inputremapper.configs.mapping import MappingData
from inputremapper.gui.controller import Controller
from inputremapper.gui.messages.message_broker import (
MessageBroker,
MessageType,
)
from inputremapper.gui.messages.message_data import GroupData, PresetData
from inputremapper.gui.utils import HandlerDisabled
class FlowBoxEntry(Gtk.ToggleButton):
"""A device that can be selected in the GUI.
For example a keyboard or a mouse.
"""
__gtype_name__ = "FlowBoxEntry"
def __init__(
self,
message_broker: MessageBroker,
controller: Controller,
name: str,
icon_name: Optional[str] = None,
):
super().__init__()
self.icon_name = icon_name
self.message_broker = message_broker
self._controller = controller
box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
if icon_name:
icon = Gtk.Image.new_from_icon_name(icon_name, Gtk.IconSize.DIALOG)
box.add(icon)
label = Gtk.Label()
label.set_label(name)
self.name = name
# wrap very long names properly
label.set_line_wrap(True)
label.set_line_wrap_mode(2)
# this affeects how many device entries fit next to each other
label.set_width_chars(28)
label.set_max_width_chars(28)
box.add(label)
box.set_margin_top(18)
box.set_margin_bottom(18)
box.set_homogeneous(True)
box.set_spacing(12)
# self.set_relief(Gtk.ReliefStyle.NONE)
self.add(box)
self.show_all()
self.connect("toggled", self._on_gtk_toggle)
def _on_gtk_toggle(self):
raise NotImplementedError
def show_active(self, active):
"""Show the active state without triggering anything."""
with HandlerDisabled(self, self._on_gtk_toggle):
self.set_active(active)
class FlowBoxWrapper:
"""A wrapper for a flowbox that contains FlowBoxEntry widgets."""
def __init__(self, flowbox: Gtk.FlowBox):
self._gui = flowbox
def show_active_entry(self, name: Optional[str]):
"""Activate the togglebutton that matches the name."""
for child in self._gui.get_children():
flow_box_entry: FlowBoxEntry = child.get_children()[0]
flow_box_entry.show_active(flow_box_entry.name == name)
class Breadcrumbs:
"""Writes a breadcrumbs string into a given label."""
def __init__(
self,
message_broker: MessageBroker,
label: Gtk.Label,
show_device_group: bool = False,
show_preset: bool = False,
show_mapping: bool = False,
):
self._message_broker = message_broker
self._gui = label
self._connect_message_listener()
self.show_device_group = show_device_group
self.show_preset = show_preset
self.show_mapping = show_mapping
self._group_key: str = ""
self._preset_name: str = ""
self._mapping_name: str = ""
label.set_max_width_chars(50)
label.set_line_wrap(True)
label.set_line_wrap_mode(2)
self._render()
def _connect_message_listener(self):
self._message_broker.subscribe(MessageType.group, self._on_group_changed)
self._message_broker.subscribe(MessageType.preset, self._on_preset_changed)
self._message_broker.subscribe(MessageType.mapping, self._on_mapping_changed)
def _on_preset_changed(self, data: PresetData):
self._preset_name = data.name or ""
self._render()
def _on_group_changed(self, data: GroupData):
self._group_key = data.group_key
self._render()
def _on_mapping_changed(self, mapping_data: MappingData):
self._mapping_name = mapping_data.format_name()
self._render()
def _render(self):
label = []
if self.show_device_group:
label.append(self._group_key or "?")
if self.show_preset:
label.append(self._preset_name or "?")
if self.show_mapping:
label.append(self._mapping_name or "?")
self._gui.set_label(" / ".join(label))

View File

@ -0,0 +1,115 @@
# -*- coding: utf-8 -*-
# input-remapper - GUI for device specific keyboard mappings
# Copyright (C) 2025 sezanzeb <b8x45ygc9@mozmail.com>
#
# This file is part of input-remapper.
#
# input-remapper is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# input-remapper is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with input-remapper. If not, see <https://www.gnu.org/licenses/>.
from __future__ import annotations
from typing import Optional
from gi.repository import Gtk
from inputremapper.gui.components.common import FlowBoxEntry, FlowBoxWrapper
from inputremapper.gui.components.editor import ICON_PRIORITIES, ICON_NAMES
from inputremapper.gui.components.main import Stack
from inputremapper.gui.controller import Controller
from inputremapper.gui.messages.message_broker import (
MessageBroker,
MessageType,
)
from inputremapper.gui.messages.message_data import (
GroupsData,
GroupData,
DoStackSwitch,
)
from inputremapper.logging.logger import logger
class DeviceGroupEntry(FlowBoxEntry):
"""A device that can be selected in the GUI.
For example a keyboard or a mouse.
"""
__gtype_name__ = "DeviceGroupEntry"
def __init__(
self,
message_broker: MessageBroker,
controller: Controller,
icon_name: Optional[str],
group_key: str,
):
super().__init__(
message_broker=message_broker,
controller=controller,
icon_name=icon_name,
name=group_key,
)
self.group_key = group_key
def _on_gtk_toggle(self, *_, **__):
logger.debug('Selecting device "%s"', self.group_key)
self._controller.load_group(self.group_key)
self.message_broker.publish(DoStackSwitch(Stack.presets_page))
class DeviceGroupSelection(FlowBoxWrapper):
"""A wrapper for the container with our groups.
A group is a collection of devices.
"""
def __init__(
self,
message_broker: MessageBroker,
controller: Controller,
flowbox: Gtk.FlowBox,
):
super().__init__(flowbox)
self._message_broker = message_broker
self._controller = controller
self._gui = flowbox
self._message_broker.subscribe(MessageType.groups, self._on_groups_changed)
self._message_broker.subscribe(MessageType.group, self._on_group_changed)
def _on_groups_changed(self, data: GroupsData):
self._gui.foreach(self._gui.remove)
for group_key, types in data.groups.items():
if len(types) > 0:
device_type = sorted(types, key=ICON_PRIORITIES.index)[0]
icon_name = ICON_NAMES[device_type]
else:
icon_name = None
logger.debug("adding %s to device selection", group_key)
device_group_entry = DeviceGroupEntry(
self._message_broker,
self._controller,
icon_name,
group_key,
)
self._gui.insert(device_group_entry, -1)
if self._controller.data_manager.active_group:
self.show_active_entry(self._controller.data_manager.active_group.key)
def _on_group_changed(self, data: GroupData):
self.show_active_entry(data.group_key)

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,134 @@
# -*- coding: utf-8 -*-
# input-remapper - GUI for device specific keyboard mappings
# Copyright (C) 2025 sezanzeb <b8x45ygc9@mozmail.com>
#
# This file is part of input-remapper.
#
# input-remapper is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# input-remapper is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with input-remapper. If not, see <https://www.gnu.org/licenses/>.
"""Components that wrap everything."""
from __future__ import annotations
from gi.repository import Gtk, Pango
from inputremapper.gui.controller import Controller
from inputremapper.gui.messages.message_broker import (
MessageBroker,
MessageType,
)
from inputremapper.gui.messages.message_data import StatusData, DoStackSwitch
from inputremapper.gui.utils import CTX_ERROR, CTX_MAPPING, CTX_WARNING
class Stack:
"""Wraps the Stack, which contains the main menu pages."""
devices_page = 0
presets_page = 1
editor_page = 2
def __init__(
self,
message_broker: MessageBroker,
controller: Controller,
stack: Gtk.Stack,
):
self._message_broker = message_broker
self._controller = controller
self._gui = stack
self._message_broker.subscribe(
MessageType.do_stack_switch, self._do_stack_switch
)
def _do_stack_switch(self, msg: DoStackSwitch):
self._gui.set_visible_child(self._gui.get_children()[msg.page_index])
class StatusBar:
"""The status bar on the bottom of the main window."""
def __init__(
self,
message_broker: MessageBroker,
controller: Controller,
status_bar: Gtk.Statusbar,
error_icon: Gtk.Image,
warning_icon: Gtk.Image,
):
self._message_broker = message_broker
self._controller = controller
self._gui = status_bar
self._error_icon = error_icon
self._warning_icon = warning_icon
label = self._gui.get_message_area().get_children()[0]
label.set_ellipsize(Pango.EllipsizeMode.END)
label.set_selectable(True)
self._message_broker.subscribe(MessageType.status_msg, self._on_status_update)
# keep track if there is an error or warning in the stack of statusbar
# unfortunately this is not exposed over the api
self._error = False
self._warning = False
def _on_status_update(self, data: StatusData):
"""Show a status message and set its tooltip.
If message is None, it will remove the newest message of the
given context_id.
"""
context_id = data.ctx_id
message = data.msg
tooltip = data.tooltip
status_bar = self._gui
if message is None:
status_bar.remove_all(context_id)
if context_id in (CTX_ERROR, CTX_MAPPING):
self._error_icon.hide()
self._error = False
if self._warning:
self._warning_icon.show()
if context_id == CTX_WARNING:
self._warning_icon.hide()
self._warning = False
if self._error:
self._error_icon.show()
status_bar.set_tooltip_text("")
return
if tooltip is None:
tooltip = message
self._error_icon.hide()
self._warning_icon.hide()
if context_id in (CTX_ERROR, CTX_MAPPING):
self._error_icon.show()
self._error = True
if context_id == CTX_WARNING:
self._warning_icon.show()
self._warning = True
status_bar.push(context_id, message)
status_bar.set_tooltip_text(tooltip)

View File

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

View File

@ -0,0 +1,106 @@
# -*- coding: utf-8 -*-
# input-remapper - GUI for device specific keyboard mappings
# Copyright (C) 2025 sezanzeb <b8x45ygc9@mozmail.com>
#
# This file is part of input-remapper.
#
# input-remapper is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# input-remapper is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with input-remapper. If not, see <https://www.gnu.org/licenses/>.
"""All components that are visible on the page that shows all the presets."""
from __future__ import annotations
from gi.repository import Gtk
from inputremapper.gui.components.common import FlowBoxEntry, FlowBoxWrapper
from inputremapper.gui.components.main import Stack
from inputremapper.gui.controller import Controller
from inputremapper.gui.messages.message_broker import (
MessageBroker,
MessageType,
)
from inputremapper.gui.messages.message_data import (
GroupData,
PresetData,
DoStackSwitch,
)
from inputremapper.logging.logger import logger
class PresetEntry(FlowBoxEntry):
"""A preset that can be selected in the GUI."""
__gtype_name__ = "PresetEntry"
def __init__(
self,
message_broker: MessageBroker,
controller: Controller,
preset_name: str,
):
super().__init__(
message_broker=message_broker, controller=controller, name=preset_name
)
self.preset_name = preset_name
def _on_gtk_toggle(self, *_, **__):
logger.debug('Selecting preset "%s"', self.preset_name)
self._controller.load_preset(self.preset_name)
self.message_broker.publish(DoStackSwitch(Stack.editor_page))
class PresetSelection(FlowBoxWrapper):
"""A wrapper for the container with our presets.
Selectes the active_preset.
"""
def __init__(
self,
message_broker: MessageBroker,
controller: Controller,
flowbox: Gtk.FlowBox,
):
super().__init__(flowbox)
self._message_broker = message_broker
self._controller = controller
self._gui = flowbox
self._connect_message_listener()
def _connect_message_listener(self):
self._message_broker.subscribe(MessageType.group, self._on_group_changed)
self._message_broker.subscribe(MessageType.preset, self._on_preset_changed)
def _on_group_changed(self, data: GroupData):
self._gui.foreach(self._gui.remove)
for preset_name in data.presets:
preset_entry = PresetEntry(
self._message_broker,
self._controller,
preset_name,
)
self._gui.insert(preset_entry, -1)
def _on_preset_changed(self, data: PresetData):
self.show_active_entry(data.name)
def set_active_preset(self, preset_name: str):
"""Change the currently selected preset."""
# TODO might only be needed in tests
for child in self._gui.get_children():
preset_entry: PresetEntry = child.get_children()[0]
preset_entry.set_active(preset_entry.preset_name == preset_name)

View File

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

View File

@ -0,0 +1,697 @@
# -*- coding: utf-8 -*-
# input-remapper - GUI for device specific keyboard mappings
# Copyright (C) 2025 sezanzeb <b8x45ygc9@mozmail.com>
#
# This file is part of input-remapper.
#
# input-remapper is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# input-remapper is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with input-remapper. If not, see <https://www.gnu.org/licenses/>.
import glob
import os
import re
import time
from typing import Optional, List, Tuple, Set, Dict
from gi.repository import GLib
from inputremapper.configs.app_binding import AppBinding
from inputremapper.configs.global_config import GlobalConfig
from inputremapper.configs.input_config import InputCombination, InputConfig
from inputremapper.configs.mapping import UIMapping, MappingData
from inputremapper.configs.paths import PathUtils
from inputremapper.configs.preset import Preset
from inputremapper.configs.keyboard_layout import KeyboardLayout
from inputremapper.daemon import DaemonProxy
from inputremapper.exceptions import DataManagementError
from inputremapper.groups import _Group
from inputremapper.gui.gettext import _
from inputremapper.gui.messages.message_broker import (
MessageBroker,
)
from inputremapper.gui.messages.message_data import (
UInputsData,
GroupData,
PresetData,
CombinationUpdate,
AppBindingsData,
FocusAppData,
)
from inputremapper.gui.focus_service_client import FocusServiceClient
from inputremapper.gui.reader_client import ReaderClient
from inputremapper.injection.global_uinputs import GlobalUInputs
from inputremapper.injection.injector import (
InjectorState,
InjectorStateMessage,
)
from inputremapper.logging.logger import logger
DEFAULT_PRESET_NAME = _("new preset")
# useful type aliases
Name = str
GroupKey = str
class DataManager:
"""DataManager provides an interface to create and modify configurations as well
as modify the state of the Service.
Any state changes will be announced via the MessageBroker.
"""
def __init__(
self,
message_broker: MessageBroker,
config: GlobalConfig,
reader_client: ReaderClient,
daemon: DaemonProxy,
uinputs: GlobalUInputs,
keyboard_layout: KeyboardLayout,
focus_service: Optional[FocusServiceClient] = None,
):
self.message_broker = message_broker
self._reader_client = reader_client
self._daemon = daemon
self._uinputs = uinputs
self._keyboard_layout = keyboard_layout
self._focus_service = focus_service or FocusServiceClient()
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()))
def publish_app_bindings(self):
"""Publish the "app_bindings" message with the current service state."""
status = self._focus_service.get_status()
reachable = status is not None
backend = status.get("backend") if status else None
self.message_broker.publish(
AppBindingsData(
enabled=self._config.get_app_binding_enabled(),
bindings=tuple(self._config.get_app_bindings()),
backend=backend,
supported=reachable and backend is not None,
reachable=reachable,
)
)
def get_app_bindings(self) -> List[AppBinding]:
"""Get the configured application bindings."""
return self._config.get_app_bindings()
def set_app_bindings(self, bindings: List[AppBinding]):
"""Persist the application bindings and reconcile the focus-service.
Will send the "app_bindings" message on the MessageBroker.
"""
self._config.set_app_bindings(bindings)
self._focus_service.reload()
self.publish_app_bindings()
def get_app_binding_enabled(self) -> bool:
"""Whether focus-driven preset binding is enabled."""
return self._config.get_app_binding_enabled()
def set_app_binding_enabled(self, enabled: bool):
"""Enable or disable focus-driven preset binding.
Writes the config and toggles the running service. Will send the
"app_bindings" message on the MessageBroker.
"""
self._config.set_app_binding_enabled(enabled)
self._focus_service.set_enabled(enabled)
self.publish_app_bindings()
def start_app_detection(self):
"""Listen for focus changes to auto-fill an app_id.
Will send "focused_app" messages as the focus changes.
"""
self._focus_service.connect_focus_changed(self._on_focus_changed)
def stop_app_detection(self):
"""Stop listening for focus changes."""
self._focus_service.disconnect_focus_changed()
def _on_focus_changed(self, app: Dict[str, str]):
self.message_broker.publish(
FocusAppData(
app_id=app.get("app_id", ""),
title=app.get("title", ""),
)
)
def get_presets_for_group(self, group_key: str) -> Tuple[Name, ...]:
"""Get all preset names for an arbitrary group, sorted by age.
Unlike get_preset_names this does not depend on the active group, so it
can be used to populate the application-binding preset selectors.
"""
group = self._reader_client.groups.find(key=group_key)
if not group:
return tuple()
device_folder = PathUtils.get_preset_path(group.name)
PathUtils.mkdir(device_folder)
paths = glob.glob(os.path.join(glob.escape(device_folder), "*.json"))
presets = [
os.path.splitext(os.path.basename(path))[0]
for path in sorted(paths, key=os.path.getmtime)
]
presets.reverse()
return tuple(presets)
@property
def active_group(self) -> Optional[_Group]:
"""The currently loaded group."""
return self._reader_client.group
@property
def active_preset(self) -> Optional[Preset[UIMapping]]:
"""The currently loaded preset."""
return self._active_preset
@property
def active_mapping(self) -> Optional[UIMapping]:
"""The currently loaded mapping."""
return self._active_mapping
@property
def active_input_config(self) -> Optional[InputConfig]:
"""The currently loaded event."""
return self._active_input_config
def get_group_keys(self) -> Tuple[GroupKey, ...]:
"""Get all group keys (plugged devices)."""
return tuple(group.key for group in self._reader_client.groups.get_groups())
def get_preset_names(self) -> Tuple[Name, ...]:
"""Get all preset names for active_group and current user sorted by age."""
if not self.active_group:
raise DataManagementError("Cannot find presets: Group is not set")
device_folder = PathUtils.get_preset_path(self.active_group.name)
PathUtils.mkdir(device_folder)
paths = glob.glob(os.path.join(glob.escape(device_folder), "*.json"))
presets = [
os.path.splitext(os.path.basename(path))[0]
for path in sorted(paths, key=os.path.getmtime)
]
# the highest timestamp to the front
presets.reverse()
return tuple(presets)
def get_mappings(self) -> Optional[List[MappingData]]:
"""All mappings from the active_preset."""
if not self._active_preset:
return None
return [mapping.get_bus_message() for mapping in self._active_preset]
def get_autoload(self) -> bool:
"""The autoload status of the active_preset."""
if not self.active_preset or not self.active_group:
return False
return self._config.is_autoloaded(
self.active_group.key, self.active_preset.name
)
def set_autoload(self, status: bool):
"""Set the autoload status of the active_preset.
Will send "preset" message on the MessageBroker.
"""
if not self.active_preset or not self.active_group:
raise DataManagementError("Cannot set autoload status: Preset is not set")
if status:
self._config.set_autoload_preset(
self.active_group.key, self.active_preset.name
)
elif self.get_autoload():
self._config.set_autoload_preset(self.active_group.key, None)
self.publish_preset()
def get_newest_group_key(self) -> GroupKey:
"""group_key of the group with the most recently modified preset."""
paths = []
pattern = os.path.join(
glob.escape(PathUtils.get_preset_path()),
"*/*.json",
)
for path in glob.glob(pattern):
if self._reader_client.groups.find(key=PathUtils.split_all(path)[-2]):
paths.append((path, os.path.getmtime(path)))
if not paths:
raise FileNotFoundError()
path, _ = max(paths, key=lambda x: x[1])
return PathUtils.split_all(path)[-2]
def get_newest_preset_name(self) -> Name:
"""Preset name of the most recently modified preset in the active group."""
if not self.active_group:
raise DataManagementError("Cannot find newest preset: Group is not set")
pattern = os.path.join(
glob.escape(PathUtils.get_preset_path(self.active_group.name)),
"*.json",
)
paths = [(path, os.path.getmtime(path)) for path in glob.glob(pattern)]
if not paths:
raise FileNotFoundError()
path, _ = max(paths, key=lambda x: x[1])
return os.path.split(path)[-1].split(".")[0]
def get_available_preset_name(self, name=DEFAULT_PRESET_NAME) -> Name:
"""The first available preset in the active group."""
if not self.active_group:
raise DataManagementError("Unable find preset name. Group is not set")
name = name.strip()
# find a name that is not already taken
if os.path.exists(PathUtils.get_preset_path(self.active_group.name, name)):
# if there already is a trailing number, increment it instead of
# adding another one
match = re.match(r"^(.+) (\d+)$", name)
if match:
name = match[1]
i = int(match[2]) + 1
else:
i = 2
while os.path.exists(
PathUtils.get_preset_path(self.active_group.name, f"{name} {i}")
):
i += 1
return f"{name} {i}"
return name
def load_group(self, group_key: str):
"""Load a group. will publish "groups" and "injector_state" messages.
This will render the active_mapping and active_preset invalid.
"""
if group_key not in self.get_group_keys():
raise DataManagementError("Unable to load non existing group")
logger.info('Loading group "%s"', group_key)
self._active_input_config = None
self._active_mapping = None
self._active_preset = None
group = self._reader_client.groups.find(key=group_key)
self._reader_client.set_group(group)
self.publish_group()
self.publish_injector_state()
def load_preset(self, name: str):
"""Load a preset. Will send "preset" message on the MessageBroker.
This will render the active_mapping invalid.
"""
if not self.active_group:
raise DataManagementError("Unable to load preset. Group is not set")
logger.info('Loading preset "%s"', name)
preset_path = PathUtils.get_preset_path(self.active_group.name, name)
preset = Preset(preset_path, mapping_factory=UIMapping)
preset.load()
self._active_input_config = None
self._active_mapping = None
self._active_preset = preset
self.publish_preset()
def load_mapping(self, combination: InputCombination):
"""Load a mapping. Will send "mapping" message on the MessageBroker."""
if not self._active_preset:
raise DataManagementError("Unable to load mapping. Preset is not set")
mapping = self._active_preset.get_mapping(combination)
if not mapping:
msg = (
f"the mapping with {combination = } does not "
f"exist in the {self._active_preset.path}"
)
logger.error(msg)
raise KeyError(msg)
self._active_input_config = None
self._active_mapping = mapping
self.publish_mapping()
def load_input_config(self, input_config: InputConfig):
"""Load a InputConfig from the combination in the active mapping.
Will send "event" message on the MessageBroker,
"""
if not self.active_mapping:
raise DataManagementError("Unable to load event. Mapping is not set")
if input_config not in self.active_mapping.input_combination:
raise ValueError(
f"{input_config} is not member of active_mapping.input_combination: "
f"{self.active_mapping.input_combination}"
)
self._active_input_config = input_config
self.publish_event()
def rename_preset(self, new_name: str):
"""Rename the current preset and move the correct file.
Will send "group" and then "preset" message on the MessageBroker
"""
if not self.active_preset or not self.active_group:
raise DataManagementError("Unable rename preset: Preset is not set")
if self.active_preset.path == PathUtils.get_preset_path(
self.active_group.name, new_name
):
return
old_path = self.active_preset.path
assert old_path is not None
old_name = os.path.basename(old_path).split(".")[0]
new_path = PathUtils.get_preset_path(self.active_group.name, new_name)
if os.path.exists(new_path):
raise ValueError(
f"cannot rename {old_name} to " f"{new_name}, preset already exists"
)
logger.info('Moving "%s" to "%s"', old_path, new_path)
os.rename(old_path, new_path)
now = time.time()
os.utime(new_path, (now, now))
if self._config.is_autoloaded(self.active_group.key, old_name):
self._config.set_autoload_preset(self.active_group.key, new_name)
self.active_preset.path = PathUtils.get_preset_path(
self.active_group.name, new_name
)
self.publish_group()
self.publish_preset()
def copy_preset(self, name: str):
"""Copy the current preset to the given name.
Will send "group" and "preset" message to the MessageBroker and load the copy
"""
# todo: Do we want to load the copy here? or is this up to the controller?
if not self.active_preset or not self.active_group:
raise DataManagementError("Unable to copy preset: Preset is not set")
if self.active_preset.path == PathUtils.get_preset_path(
self.active_group.name, name
):
return
if name in self.get_preset_names():
raise ValueError(f"a preset with the name {name} already exits")
new_path = PathUtils.get_preset_path(self.active_group.name, name)
logger.info('Copy "%s" to "%s"', self.active_preset.path, new_path)
self.active_preset.path = new_path
self.save()
self.publish_group()
self.publish_preset()
def create_preset(self, name: str):
"""Create empty preset in the active_group.
Will send "group" message to the MessageBroker
"""
if not self.active_group:
raise DataManagementError("Unable to add preset. Group is not set")
path = PathUtils.get_preset_path(self.active_group.name, name)
if os.path.exists(path):
raise DataManagementError("Unable to add preset. Preset exists")
Preset(path).save()
self.publish_group()
def delete_preset(self):
"""Delete the active preset.
Will send "group" message to the MessageBroker
this will invalidate the active mapping,
"""
preset_path = self._active_preset.path
logger.info('Removing "%s"', preset_path)
os.remove(preset_path)
self._active_mapping = None
self._active_preset = None
self.publish_group()
def update_mapping(self, **kwargs):
"""Update the active mapping with the given keywords and values.
Will send "mapping" message to the MessageBroker. In case of a new
input_combination. This will first send a "combination_update" message.
"""
if not self._active_mapping:
raise DataManagementError("Cannot modify Mapping: Mapping is not set")
if symbol := kwargs.get("output_symbol"):
kwargs["output_symbol"] = self._keyboard_layout.correct_case(symbol)
combination = self.active_mapping.input_combination
for key, value in kwargs.items():
setattr(self._active_mapping, key, value)
if (
"input_combination" in kwargs
and combination != self.active_mapping.input_combination
):
self._active_input_config = None
self.message_broker.publish(
CombinationUpdate(combination, self._active_mapping.input_combination)
)
if "mapping_type" in kwargs:
# mapping_type must be the last update because it is automatically updated
# by a validation function
self._active_mapping.mapping_type = kwargs["mapping_type"]
self.publish_mapping()
def update_input_config(self, new_input_config: InputConfig):
"""Update the active input configuration.
Will send "combination_update", "mapping" and "event" messages to the
MessageBroker (in that order)
"""
if not self.active_mapping or not self.active_input_config:
raise DataManagementError("Cannot modify event: Event is not set")
combination = list(self.active_mapping.input_combination)
combination[combination.index(self.active_input_config)] = new_input_config
self.update_mapping(input_combination=InputCombination(combination))
self._active_input_config = new_input_config
self.publish_event()
def create_mapping(self):
"""Create empty mapping in the active preset.
Will send "preset" message to the MessageBroker
"""
if not self._active_preset:
raise DataManagementError("Cannot create mapping: Preset is not set")
self._active_preset.add(UIMapping())
self.publish_preset()
def delete_mapping(self):
"""Delete the active mapping.
Will send "preset" message to the MessageBroker
"""
if not self._active_mapping:
raise DataManagementError(
"cannot delete active mapping: active mapping is not set"
)
self._active_preset.remove(self._active_mapping.input_combination)
self._active_mapping = None
self.publish_preset()
def save(self):
"""Save the active preset."""
if self._active_preset:
self._active_preset.save()
def refresh_groups(self):
"""Refresh the groups (plugged devices).
Should send "groups" message to MessageBroker this will not happen immediately
because the system might take a bit until the groups are available
"""
self._reader_client.refresh_groups()
def start_combination_recording(self):
"""Record user input.
Will send "combination_recorded" messages as new input arrives.
Will eventually send a "recording_finished" message.
"""
self._reader_client.start_recorder()
def stop_combination_recording(self):
"""Stop recording user input.
Will send a recording_finished signal if a recording is running.
"""
self._reader_client.stop_recorder()
def stop_injecting(self) -> None:
"""Stop injecting for the active group.
Will send "injector_state" message once the injector has stopped."""
if not self.active_group:
raise DataManagementError("Cannot stop injection: Group is not set")
self._daemon.stop_injecting(self.active_group.key)
self.do_when_injector_state(
{InjectorState.STOPPED}, self.publish_injector_state
)
def start_injecting(self) -> bool:
"""Start injecting the active preset for the active group.
returns if the startup was successfully initialized.
Will send "injector_state" message once the startup is complete.
"""
if not self.active_preset or not self.active_group:
raise DataManagementError("Cannot start injection: Preset is not set")
self._daemon.set_config_dir(self._config.get_dir())
assert self.active_preset.name is not None
if self._daemon.start_injecting(self.active_group.key, self.active_preset.name):
self.do_when_injector_state(
{
InjectorState.RUNNING,
InjectorState.ERROR,
InjectorState.NO_GRAB,
InjectorState.UPGRADE_EVDEV,
},
self.publish_injector_state,
)
return True
return False
def get_state(self) -> InjectorState:
"""The state of the injector."""
if not self.active_group:
raise DataManagementError("Cannot read state: Group is not set")
return self._daemon.get_state(self.active_group.key)
def refresh_service_config_path(self):
"""Tell the service to refresh its config path."""
self._daemon.set_config_dir(self._config.get_dir())
def do_when_injector_state(self, states: Set[InjectorState], callback):
"""Run callback once the injector state is one of states."""
start = time.time()
def do():
if time.time() - start > 3:
# something went wrong, there should have been a state long ago.
# the timeout prevents tons of GLib.timeouts to run forever, especially
# after spamming the "Stop" button.
logger.error("Timed out while waiting for injector state %s", states)
return False
if self.get_state() in states:
callback()
return False
return True
GLib.timeout_add(100, do)

View File

@ -0,0 +1,145 @@
# -*- coding: utf-8 -*-
# input-remapper - GUI for device specific keyboard mappings
# Copyright (C) 2025 sezanzeb <b8x45ygc9@mozmail.com>
#
# This file is part of input-remapper.
#
# input-remapper is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# input-remapper is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with input-remapper. If not, see <https://www.gnu.org/licenses/>.
"""GUI-side client for the user-level focus-service (``inputremapper.Focus``).
The focus-service exposes a small interface on the session bus. This client
wraps the dasbus proxy and stays defensive: when the service is not running (the
feature might be disabled, or the environment might not support focus detection)
every call degrades gracefully instead of raising, so the GUI keeps working.
"""
from __future__ import annotations
import json
import subprocess
from typing import Any, Callable, Dict, Optional
from dasbus.connection import SessionMessageBus
from inputremapper.bin.process_utils import ProcessUtils
from inputremapper.logging.logger import logger
FOCUS_SERVICE_NAME = "inputremapper.Focus"
FOCUS_OBJECT_PATH = "/inputremapper/Focus"
FOCUS_SERVICE_EXECUTABLE = "input-remapper-focus-service"
def ensure_focus_service_running() -> None:
"""Launch the user-level focus-service if it is not already running.
Mirrors how the GUI starts the reader-service, but the focus-service runs as
the logged-in user (no pkexec / no root).
"""
if ProcessUtils.count_python_processes(FOCUS_SERVICE_EXECUTABLE) >= 1:
logger.info("Found an input-remapper-focus-service to already be running")
return
try:
# start_new_session detaches the service from the GUI process group so it
# keeps running and is not torn down together with the GUI.
subprocess.Popen( # pylint: disable=consider-using-with
[FOCUS_SERVICE_EXECUTABLE],
start_new_session=True,
)
logger.info("Started the input-remapper-focus-service")
except Exception as error: # pylint: disable=broad-except
logger.error("Failed to start the focus-service: %s", error)
class FocusServiceClient:
"""Thin, defensive wrapper around the focus-service session-bus interface."""
def __init__(self, bus: Optional[SessionMessageBus] = None) -> None:
self._bus = bus or SessionMessageBus()
self._proxy: Any = None
self._focus_changed_handler: Optional[Callable[[str], None]] = None
def _get_proxy(self) -> Any:
if self._proxy is None:
self._proxy = self._bus.get_proxy(FOCUS_SERVICE_NAME, FOCUS_OBJECT_PATH)
return self._proxy
def _reset(self) -> None:
"""Drop the cached proxy so the next call reconnects."""
self._proxy = None
self._focus_changed_handler = None
def get_status(self) -> Optional[Dict[str, Any]]:
"""Return the service status as a dict, or None if it is unreachable."""
try:
return json.loads(self._get_proxy().get_status())
except Exception as error: # pylint: disable=broad-except
logger.debug("focus-service get_status failed: %s", error)
self._reset()
return None
def get_focused_app(self) -> Dict[str, str]:
"""Return the currently focused window as {"app_id", "title"}."""
try:
return json.loads(self._get_proxy().get_focused_app())
except Exception as error: # pylint: disable=broad-except
logger.debug("focus-service get_focused_app failed: %s", error)
self._reset()
return {"app_id": "", "title": ""}
def reload(self) -> None:
"""Ask the service to re-read the config from disk and reconcile."""
try:
self._get_proxy().reload()
except Exception as error: # pylint: disable=broad-except
logger.debug("focus-service reload failed: %s", error)
self._reset()
def set_enabled(self, enabled: bool) -> None:
"""Toggle focus-driven binding at runtime (does not write the config)."""
try:
self._get_proxy().set_enabled(enabled)
except Exception as error: # pylint: disable=broad-except
logger.debug("focus-service set_enabled failed: %s", error)
self._reset()
def connect_focus_changed(self, callback: Callable[[Dict[str, str]], None]) -> bool:
"""Subscribe to the focus_changed signal. Returns whether it succeeded."""
def handler(app_json: str) -> None:
try:
callback(json.loads(app_json))
except (ValueError, TypeError) as error:
logger.error("Invalid focus_changed payload %r: %s", app_json, error)
try:
self._get_proxy().focus_changed.connect(handler)
self._focus_changed_handler = handler
return True
except Exception as error: # pylint: disable=broad-except
logger.debug("focus-service focus_changed.connect failed: %s", error)
self._reset()
return False
def disconnect_focus_changed(self) -> None:
"""Unsubscribe from the focus_changed signal."""
if self._focus_changed_handler is None:
return
try:
self._get_proxy().focus_changed.disconnect(self._focus_changed_handler)
except Exception as error: # pylint: disable=broad-except
logger.debug("focus-service focus_changed.disconnect failed: %s", error)
finally:
self._focus_changed_handler = None

View File

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

View File

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

View File

View File

@ -0,0 +1,120 @@
# -*- coding: utf-8 -*-
# input-remapper - GUI for device specific keyboard mappings
# Copyright (C) 2025 sezanzeb <b8x45ygc9@mozmail.com>
#
# This file is part of input-remapper.
#
# input-remapper is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# input-remapper is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with input-remapper. If not, see <https://www.gnu.org/licenses/>.
import os.path
import re
import traceback
from collections import defaultdict, deque
from typing import (
Callable,
Dict,
Set,
Protocol,
Tuple,
Deque,
Any,
)
from inputremapper.gui.messages.message_types import MessageType
from inputremapper.logging.logger import logger
class Message(Protocol):
"""The protocol any message must follow to be sent with the MessageBroker."""
@property
def message_type(self) -> MessageType: ...
# useful type aliases
MessageListener = Callable[[Any], None]
class MessageBroker:
shorten_path = re.compile(r"inputremapper/")
def __init__(self):
self._listeners: Dict[MessageType, Set[MessageListener]] = defaultdict(set)
self._messages: Deque[Tuple[Message, str, int]] = deque()
self._publishing = False
def publish(self, data: Message):
"""Schedule a massage to be sent.
The message will be sent after all currently pending messages are sent."""
self._messages.append((data, *self.get_caller()))
self._publish_all()
def signal(self, signal: MessageType):
"""Send a signal without any data payload."""
# This is different from calling self.publish because self.get_caller()
# looks back at the current stack 3 frames
self._messages.append((Signal(signal), *self.get_caller()))
self._publish_all()
def _publish(self, data: Message, file: str, line: int):
logger.debug(
"from %s:%d: Signal=%s: %s", file, line, data.message_type.name, data
)
for listener in self._listeners[data.message_type].copy():
listener(data)
def _publish_all(self):
"""Send all scheduled messages in order."""
if self._publishing:
# don't run this twice, so we not mess up the order
return
self._publishing = True
try:
while self._messages:
self._publish(*self._messages.popleft())
finally:
self._publishing = False
def subscribe(self, massage_type: MessageType, listener: MessageListener):
"""Attach a listener to an event."""
logger.debug("adding new Listener for %s: %s", massage_type, listener)
self._listeners[massage_type].add(listener)
return self
@staticmethod
def get_caller(position: int = 3) -> Tuple[str, int]:
"""Extract a file and line from current stack and format for logging."""
tb = traceback.extract_stack(limit=position)[0]
return os.path.basename(tb.filename), tb.lineno or 0
def unsubscribe(self, listener: MessageListener) -> None:
for listeners in self._listeners.values():
try:
listeners.remove(listener)
except KeyError:
pass
class Signal:
"""Send a Message without any associated data over the MassageBus."""
def __init__(self, message_type: MessageType):
self.message_type: MessageType = message_type
def __str__(self):
return f"Signal: {self.message_type}"
def __eq__(self, other: Any):
return type(self) is type(other) and self.message_type == other.message_type

View File

@ -0,0 +1,155 @@
# -*- coding: utf-8 -*-
# input-remapper - GUI for device specific keyboard mappings
# Copyright (C) 2025 sezanzeb <b8x45ygc9@mozmail.com>
#
# This file is part of input-remapper.
#
# input-remapper is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# input-remapper is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with input-remapper. If not, see <https://www.gnu.org/licenses/>.
import re
from dataclasses import dataclass
from typing import Dict, Tuple, Optional, Callable
from inputremapper.configs.app_binding import AppBinding
from inputremapper.configs.input_config import InputCombination
from inputremapper.configs.mapping import MappingData
from inputremapper.gui.messages.message_types import (
MessageType,
Name,
Capabilities,
Key,
DeviceTypes,
)
@dataclass(frozen=True)
class UInputsData:
message_type = MessageType.uinputs
uinputs: Dict[Name, Capabilities]
def __str__(self):
string = f"{self.__class__.__name__}(uinputs={self.uinputs})"
# find all sequences of comma+space separated numbers, and shorten them
# to the first and last number
all_matches = list(re.finditer(r"(\d+, )+", string))
all_matches.reverse()
for match in all_matches:
start = match.start()
end = match.end()
start += string[start:].find(",") + 2
if start == end:
continue
string = f"{string[:start]}... {string[end:]}"
return string
@dataclass(frozen=True)
class GroupsData:
"""Message containing all available groups and their device types."""
message_type = MessageType.groups
groups: Dict[Key, DeviceTypes]
@dataclass(frozen=True)
class GroupData:
"""Message with the active group and available presets for the group."""
message_type = MessageType.group
group_key: str
presets: Tuple[str, ...]
@dataclass(frozen=True)
class PresetData:
"""Message with the active preset name and mapping names/combinations."""
message_type = MessageType.preset
name: Optional[Name]
mappings: Optional[Tuple[MappingData, ...]]
autoload: bool = False
@dataclass(frozen=True)
class StatusData:
"""Message with the strings and id for the status bar."""
message_type = MessageType.status_msg
ctx_id: int
msg: Optional[str] = None
tooltip: Optional[str] = None
@dataclass(frozen=True)
class CombinationRecorded:
"""Message with the latest recoded combination."""
message_type = MessageType.combination_recorded
combination: "InputCombination"
@dataclass(frozen=True)
class CombinationUpdate:
"""Message with the old and new combination (hash for a mapping) when it changed."""
message_type = MessageType.combination_update
old_combination: "InputCombination"
new_combination: "InputCombination"
@dataclass(frozen=True)
class UserConfirmRequest:
"""Message for requesting a user response (confirm/cancel) from the gui."""
message_type = MessageType.user_confirm_request
msg: str
respond: Callable[[bool], None] = lambda _: None
@dataclass(frozen=True)
class DoStackSwitch:
"""Command the stack to switch to a different page."""
message_type = MessageType.do_stack_switch
page_index: int
@dataclass(frozen=True)
class AppBindingsData:
"""Message with the current focus-driven application bindings and service state.
``backend`` is the name of the focus backend reported by the focus-service
(e.g. "xorg", "sway", "hyprland", "wlr-foreign-toplevel"). ``supported`` is
False when the service is reachable but reports no usable backend (e.g. on
GNOME-Wayland). ``reachable`` is False when the focus-service is not running
yet, in which case the backend state is simply unknown.
"""
message_type = MessageType.app_bindings
enabled: bool
bindings: Tuple[AppBinding, ...]
backend: Optional[str] = None
supported: bool = False
reachable: bool = False
@dataclass(frozen=True)
class FocusAppData:
"""Message with the latest focused application, emitted during detection."""
message_type = MessageType.focused_app
app_id: str
title: str = ""

View File

@ -0,0 +1,64 @@
# -*- coding: utf-8 -*-
# input-remapper - GUI for device specific keyboard mappings
# Copyright (C) 2025 sezanzeb <b8x45ygc9@mozmail.com>
#
# This file is part of input-remapper.
#
# input-remapper is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# input-remapper is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with input-remapper. If not, see <https://www.gnu.org/licenses/>.
from enum import Enum
from typing import Dict, List
from inputremapper.groups import DeviceType
# useful type aliases
Capabilities = Dict[int, List]
Name = str
Key = str
DeviceTypes = List[DeviceType]
class MessageType(Enum):
reset_gui = "reset_gui"
terminate = "terminate"
init = "init"
uinputs = "uinputs"
groups = "groups"
group = "group"
preset = "preset"
mapping = "mapping"
selected_event = "selected_event"
combination_recorded = "combination_recorded"
# only the reader_client should send those messages:
recording_started = "recording_started"
recording_finished = "recording_finished"
combination_update = "combination_update"
status_msg = "status_msg"
injector_state = "injector_state"
gui_focus_request = "gui_focus_request"
user_confirm_request = "user_confirm_request"
do_stack_switch = "do_stack_switch"
# focus-driven preset binding (application bindings)
app_bindings = "app_bindings"
focused_app = "focused_app"
# for unit tests:
test1 = "test1"
test2 = "test2"

View File

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

View File

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

View File

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

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

@ -0,0 +1,272 @@
# -*- coding: utf-8 -*-
# input-remapper - GUI for device specific keyboard mappings
# Copyright (C) 2025 sezanzeb <b8x45ygc9@mozmail.com>
#
# This file is part of input-remapper.
#
# input-remapper is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# input-remapper is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with input-remapper. If not, see <https://www.gnu.org/licenses/>.
from __future__ import annotations
import time
from dataclasses import dataclass
from typing import List, Callable, Dict, Optional
from gi.repository import Gtk, GLib, Gdk
from inputremapper.logging.logger import logger
# status ctx ids
CTX_SAVE = 0
CTX_APPLY = 1
CTX_KEYCODE = 2
CTX_ERROR = 3
CTX_WARNING = 4
CTX_MAPPING = 5
@dataclass()
class DebounceInfo:
# constant after register:
function: Optional[Callable]
other: object
key: int
# can change when called again:
args: list
kwargs: dict
glib_timeout: Optional[int]
class DebounceManager:
"""Stops all debounced functions if needed."""
debounce_infos: Dict[int, DebounceInfo] = {}
def _register(self, other, function):
debounce_info = DebounceInfo(
function=function,
glib_timeout=None,
other=other,
args=[],
kwargs={},
key=self._get_key(other, function),
)
key = self._get_key(other, function)
self.debounce_infos[key] = debounce_info
return debounce_info
def get(self, other: object, function: Callable) -> Optional[DebounceInfo]:
"""Find the debounce_info that matches the given callable."""
key = self._get_key(other, function)
return self.debounce_infos.get(key)
def _get_key(self, other, function):
return f"{id(other)},{function.__name__}"
def debounce(self, other, function, timeout_ms, *args, **kwargs):
"""Call this function with the given args later."""
debounce_info = self.get(other, function)
if debounce_info is None:
debounce_info = self._register(other, function)
debounce_info.args = args
debounce_info.kwargs = kwargs
glib_timeout = debounce_info.glib_timeout
if glib_timeout is not None:
GLib.source_remove(glib_timeout)
def run():
self.stop(other, function)
return function(other, *args, **kwargs)
debounce_info.glib_timeout = GLib.timeout_add(
timeout_ms,
lambda: run(),
)
def stop(self, other: object, function: Callable):
"""Stop the current debounce timeout of this function and don't call it.
New calls to that function will be debounced again.
"""
debounce_info = self.get(other, function)
if debounce_info is None:
logger.debug("Tried to stop function that is not currently scheduled")
return
if debounce_info.glib_timeout is not None:
GLib.source_remove(debounce_info.glib_timeout)
debounce_info.glib_timeout = None
def stop_all(self):
"""No debounced function should be called anymore after this.
New calls to that function will be debounced again.
"""
for debounce_info in self.debounce_infos.values():
self.stop(debounce_info.other, debounce_info.function)
def run_all_now(self):
"""Don't wait any longer."""
for debounce_info in self.debounce_infos.values():
if debounce_info.glib_timeout is None:
# nothing is currently waiting for this function to be called
continue
self.stop(debounce_info.other, debounce_info.function)
try:
logger.warning(
'Running "%s" now without waiting',
debounce_info.function.__name__,
)
debounce_info.function(
debounce_info.other,
*debounce_info.args,
**debounce_info.kwargs,
)
except Exception as exception:
# if individual functions fails, continue calling the others.
# also, don't raise this because there is nowhere this exception
# could be caught in a useful way
logger.error(exception)
def debounce(timeout):
"""Debounce a method call to improve performance.
Calling this with a millisecond value creates the decorator, so use something like
@debounce(50)
def function(self):
...
In tests, run_all_now can be used to avoid waiting to speed them up.
"""
# the outside `debounce` function is needed to obtain the millisecond value
def decorator(function):
# the regular decorator.
# @decorator
# def foo():
# ...
def wrapped(self, *args, **kwargs):
# this is the function that will actually be called
debounce_manager.debounce(self, function, timeout, *args, **kwargs)
wrapped.__name__ = function.__name__
return wrapped
return decorator
debounce_manager = DebounceManager()
class HandlerDisabled:
"""Safely modify a widget without causing handlers to be called.
Use in a `with` statement.
"""
def __init__(self, widget: Gtk.Widget, handler: Callable):
self.widget = widget
self.handler = handler
def __enter__(self):
try:
self.widget.handler_block_by_func(self.handler)
except TypeError as error:
# if nothing is connected to the given signal, it is not critical
# at all
logger.warning('HandlerDisabled entry failed: "%s"', error)
def __exit__(self, *_):
try:
self.widget.handler_unblock_by_func(self.handler)
except TypeError as error:
logger.warning('HandlerDisabled exit failed: "%s"', error)
def gtk_iteration(iterations=0):
"""Iterate while events are pending."""
while Gtk.events_pending():
Gtk.main_iteration()
for _ in range(iterations):
time.sleep(0.002)
while Gtk.events_pending():
Gtk.main_iteration()
class Colors:
"""Looks up colors from the GTK theme.
Defaults to libadwaita-light theme colors if the lookup fails.
"""
fallback_accent = Gdk.RGBA(0.21, 0.52, 0.89, 1)
fallback_background = Gdk.RGBA(0.98, 0.98, 0.98, 1)
fallback_base = Gdk.RGBA(1, 1, 1, 1)
fallback_border = Gdk.RGBA(0.87, 0.87, 0.87, 1)
fallback_font = Gdk.RGBA(0.20, 0.20, 0.20, 1)
@staticmethod
def get_color(names: List[str], fallback: Gdk.RGBA) -> Gdk.RGBA:
"""Get theme colors. Provide multiple names for fallback purposes."""
for name in names:
found, color = Gtk.StyleContext().lookup_color(name)
if found:
return color
return fallback
@staticmethod
def get_accent_color() -> Gdk.RGBA:
"""Look up the accent color from the current theme."""
return Colors.get_color(
["accent_bg_color", "theme_selected_bg_color"],
Colors.fallback_accent,
)
@staticmethod
def get_background_color() -> Gdk.RGBA:
"""Look up the background-color from the current theme."""
return Colors.get_color(
["theme_bg_color"],
Colors.fallback_background,
)
@staticmethod
def get_base_color() -> Gdk.RGBA:
"""Look up the base-color from the current theme."""
return Colors.get_color(
["theme_base_color"],
Colors.fallback_base,
)
@staticmethod
def get_border_color() -> Gdk.RGBA:
"""Look up the border from the current theme."""
return Colors.get_color(["borders"], Colors.fallback_border)
@staticmethod
def get_font_color() -> Gdk.RGBA:
"""Look up the border from the current theme."""
return Colors.get_color(
["theme_fg_color"],
Colors.fallback_font,
)

View File

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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