Compare commits
7 Commits
1d790a78fb
...
6c6684f759
| Author | SHA1 | Date | |
|---|---|---|---|
| 6c6684f759 | |||
| 0b560f69e0 | |||
| 3ebfd278af | |||
| af10ec8bd1 | |||
| 53fe0cc369 | |||
| 9ef2c1439c | |||
| 0ab77d2a64 |
17
.coveragerc
Normal file
17
.coveragerc
Normal 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
142
.gitignore
vendored
Normal 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
3
.mypy.ini
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
[mypy]
|
||||||
|
plugins = pydantic.mypy
|
||||||
|
ignore_missing_imports = True
|
||||||
17
.pylintrc
Normal file
17
.pylintrc
Normal 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
21
.reviewdog.yml
Normal 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
9
DEBIAN/control
Normal 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
3
DEBIAN/copyright
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
Files: *
|
||||||
|
Copyright: 2025 Sezanzeb
|
||||||
|
License: GPL-3+
|
||||||
21
DEBIAN/postinst
Executable file
21
DEBIAN/postinst
Executable 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
674
LICENSE
Normal 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
30
bin/input-remapper-control
Executable 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())
|
||||||
28
bin/input-remapper-focus-service
Executable file
28
bin/input-remapper-focus-service
Executable 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
26
bin/input-remapper-gtk
Executable 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()
|
||||||
28
bin/input-remapper-reader-service
Executable file
28
bin/input-remapper-reader-service
Executable 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
26
bin/input-remapper-service
Executable 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()
|
||||||
8
data/69-input-remapper-forwarded.rules
Normal file
8
data/69-input-remapper-forwarded.rules
Normal 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"
|
||||||
10
data/99-input-remapper.rules
Normal file
10
data/99-input-remapper.rules
Normal 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}"
|
||||||
6
data/input-remapper-autoload.desktop
Normal file
6
data/input-remapper-autoload.desktop
Normal 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
|
||||||
16
data/input-remapper-focus.service
Normal file
16
data/input-remapper-focus.service
Normal 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
|
||||||
8
data/input-remapper-gtk.desktop
Normal file
8
data/input-remapper-gtk.desktop
Normal 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
|
||||||
BIN
data/input-remapper-large.png
Normal file
BIN
data/input-remapper-large.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 12 KiB |
1991
data/input-remapper.glade
Normal file
1991
data/input-remapper.glade
Normal file
File diff suppressed because it is too large
Load Diff
21
data/input-remapper.policy
Normal file
21
data/input-remapper.policy
Normal 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>
|
||||||
14
data/input-remapper.service
Normal file
14
data/input-remapper.service
Normal 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
216
data/input-remapper.svg
Normal 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 |
9
data/inputremapper.Control.conf
Normal file
9
data/inputremapper.Control.conf
Normal 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>
|
||||||
44
data/io.github.sezanzeb.input_remapper.metainfo.xml
Normal file
44
data/io.github.sezanzeb.input_remapper.metainfo.xml
Normal 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
50
data/style.css
Normal 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
|
||||||
|
*/
|
||||||
0
inputremapper/__init__.py
Normal file
0
inputremapper/__init__.py
Normal file
0
inputremapper/bin/__init__.py
Normal file
0
inputremapper/bin/__init__.py
Normal file
429
inputremapper/bin/input_remapper_control.py
Executable file
429
inputremapper/bin/input_remapper_control.py
Executable file
@ -0,0 +1,429 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# input-remapper - GUI for device specific keyboard mappings
|
||||||
|
# Copyright (C) 2025 sezanzeb <b8x45ygc9@mozmail.com>
|
||||||
|
#
|
||||||
|
# This file is part of input-remapper.
|
||||||
|
#
|
||||||
|
# input-remapper is free software: you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU General Public License as published by
|
||||||
|
# the Free Software Foundation, either version 3 of the License, or
|
||||||
|
# (at your option) any later version.
|
||||||
|
#
|
||||||
|
# input-remapper is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU General Public License
|
||||||
|
# along with input-remapper. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
"""Control the dbus service from the command line."""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from enum import Enum
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import gi
|
||||||
|
|
||||||
|
gi.require_version("GLib", "2.0")
|
||||||
|
from gi.repository import GLib
|
||||||
|
|
||||||
|
from inputremapper.configs.global_config import GlobalConfig
|
||||||
|
from inputremapper.configs.migrations import Migrations
|
||||||
|
from inputremapper.injection.global_uinputs import GlobalUInputs, FrontendUInput
|
||||||
|
from inputremapper.logging.logger import logger
|
||||||
|
from inputremapper.user import UserUtils
|
||||||
|
|
||||||
|
|
||||||
|
class Commands(Enum):
|
||||||
|
AUTOLOAD = "autoload"
|
||||||
|
START = "start"
|
||||||
|
STOP = "stop"
|
||||||
|
STOP_ALL = "stop-all"
|
||||||
|
HELLO = "hello"
|
||||||
|
QUIT = "quit"
|
||||||
|
|
||||||
|
|
||||||
|
class Internals(Enum):
|
||||||
|
# internal stuff that the gui uses
|
||||||
|
START_DAEMON = "start-daemon"
|
||||||
|
START_READER_SERVICE = "start-reader-service"
|
||||||
|
|
||||||
|
|
||||||
|
class Options:
|
||||||
|
command: str
|
||||||
|
config_dir: str
|
||||||
|
preset: str
|
||||||
|
device: str
|
||||||
|
list_devices: bool
|
||||||
|
key_names: str
|
||||||
|
debug: bool
|
||||||
|
version: str
|
||||||
|
|
||||||
|
|
||||||
|
class InputRemapperControlBin:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
global_config: GlobalConfig,
|
||||||
|
migrations: Migrations,
|
||||||
|
):
|
||||||
|
self.global_config = global_config
|
||||||
|
self.migrations = migrations
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def main(options: Options) -> None:
|
||||||
|
global_config = GlobalConfig()
|
||||||
|
global_uinputs = GlobalUInputs(FrontendUInput)
|
||||||
|
migrations = Migrations(global_uinputs)
|
||||||
|
input_remapper_control = InputRemapperControlBin(
|
||||||
|
global_config,
|
||||||
|
migrations,
|
||||||
|
)
|
||||||
|
|
||||||
|
if options.debug:
|
||||||
|
logger.update_verbosity(True)
|
||||||
|
|
||||||
|
if options.version:
|
||||||
|
logger.log_info()
|
||||||
|
return
|
||||||
|
|
||||||
|
logger.debug('Call for "%s"', sys.argv)
|
||||||
|
|
||||||
|
boot_finished_ = input_remapper_control.boot_finished()
|
||||||
|
is_root = UserUtils.user == "root"
|
||||||
|
is_autoload = options.command == Commands.AUTOLOAD
|
||||||
|
config_dir_set = options.config_dir is not None
|
||||||
|
if is_autoload and not boot_finished_ and is_root and not config_dir_set:
|
||||||
|
# this is probably happening during boot time and got
|
||||||
|
# triggered by udev. There is no need to try to inject anything if the
|
||||||
|
# service doesn't know where to look for a config file. This avoids a lot
|
||||||
|
# of confusing service logs. And also avoids potential for problems when
|
||||||
|
# input-remapper-control stresses about evdev, dbus and multiprocessing already
|
||||||
|
# while the system hasn't even booted completely.
|
||||||
|
logger.warning("Skipping autoload command without a logged in user")
|
||||||
|
return
|
||||||
|
|
||||||
|
if options.command is not None:
|
||||||
|
if options.command in [command.value for command in Internals]:
|
||||||
|
input_remapper_control.internals(options.command, options.debug)
|
||||||
|
elif options.command in [command.value for command in Commands]:
|
||||||
|
from inputremapper.daemon import Daemon
|
||||||
|
|
||||||
|
daemon = Daemon.connect(fallback=False)
|
||||||
|
|
||||||
|
input_remapper_control.set_daemon(daemon)
|
||||||
|
|
||||||
|
input_remapper_control.communicate(
|
||||||
|
options.command,
|
||||||
|
options.device,
|
||||||
|
options.config_dir,
|
||||||
|
options.preset,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logger.error('Unknown command "%s"', options.command)
|
||||||
|
else:
|
||||||
|
if options.list_devices:
|
||||||
|
input_remapper_control.list_devices()
|
||||||
|
|
||||||
|
if options.key_names:
|
||||||
|
input_remapper_control.list_key_names()
|
||||||
|
|
||||||
|
if options.command:
|
||||||
|
logger.info("Done")
|
||||||
|
|
||||||
|
def list_devices(self):
|
||||||
|
logger.setLevel(logging.ERROR)
|
||||||
|
from inputremapper.groups import groups
|
||||||
|
|
||||||
|
for group in groups.get_groups():
|
||||||
|
print(group.key)
|
||||||
|
|
||||||
|
def list_key_names(self):
|
||||||
|
from inputremapper.configs.keyboard_layout import keyboard_layout
|
||||||
|
|
||||||
|
print("\n".join(keyboard_layout.list_names()))
|
||||||
|
|
||||||
|
def communicate(
|
||||||
|
self,
|
||||||
|
command: str,
|
||||||
|
device: str,
|
||||||
|
config_dir: Optional[str],
|
||||||
|
preset: str,
|
||||||
|
) -> None:
|
||||||
|
"""Commands that require a running daemon."""
|
||||||
|
if self.daemon is None:
|
||||||
|
# probably broken tests
|
||||||
|
logger.error("Daemon missing")
|
||||||
|
sys.exit(5)
|
||||||
|
|
||||||
|
if config_dir is not None:
|
||||||
|
self._load_config(config_dir)
|
||||||
|
|
||||||
|
self.ensure_migrated()
|
||||||
|
|
||||||
|
if command == Commands.AUTOLOAD.value:
|
||||||
|
self._autoload(device)
|
||||||
|
|
||||||
|
if command == Commands.START.value:
|
||||||
|
self._start(device, preset)
|
||||||
|
|
||||||
|
if command == Commands.STOP.value:
|
||||||
|
self._stop(device)
|
||||||
|
|
||||||
|
if command == Commands.STOP_ALL.value:
|
||||||
|
self.daemon.stop_all()
|
||||||
|
|
||||||
|
if command == Commands.HELLO.value:
|
||||||
|
self._hello()
|
||||||
|
|
||||||
|
if command == Commands.QUIT.value:
|
||||||
|
self._quit()
|
||||||
|
|
||||||
|
def _hello(self):
|
||||||
|
response = self.daemon.hello("hello")
|
||||||
|
logger.info('Daemon answered with "%s"', response)
|
||||||
|
|
||||||
|
def _load_config(self, config_dir: str) -> None:
|
||||||
|
path = os.path.abspath(
|
||||||
|
os.path.expanduser(os.path.join(config_dir, "config.json"))
|
||||||
|
)
|
||||||
|
if not os.path.exists(path):
|
||||||
|
logger.error('"%s" does not exist', path)
|
||||||
|
sys.exit(6)
|
||||||
|
|
||||||
|
logger.info('Using config from "%s" instead', path)
|
||||||
|
self.global_config.load_config(path)
|
||||||
|
|
||||||
|
def ensure_migrated(self) -> None:
|
||||||
|
# import stuff late to make sure the correct log level is applied
|
||||||
|
# before anything is logged
|
||||||
|
# TODO since imports shouldn't run any code, this is fixed by moving towards DI
|
||||||
|
from inputremapper.user import UserUtils
|
||||||
|
|
||||||
|
if UserUtils.user != "root":
|
||||||
|
# Might be triggered by udev, so skip the root user.
|
||||||
|
# This will also refresh the config of the daemon if the user changed
|
||||||
|
# it in the meantime.
|
||||||
|
# config_dir is either the cli arg or the default path in home
|
||||||
|
config_dir = os.path.dirname(self.global_config.path)
|
||||||
|
self.daemon.set_config_dir(config_dir)
|
||||||
|
self.migrations.migrate()
|
||||||
|
|
||||||
|
def _stop(self, device: str) -> None:
|
||||||
|
group = self._load_group(device)
|
||||||
|
self.daemon.stop_injecting(group.key)
|
||||||
|
|
||||||
|
def _quit(self) -> None:
|
||||||
|
try:
|
||||||
|
self.daemon.quit()
|
||||||
|
except GLib.GError as error:
|
||||||
|
if "NoReply" in str(error):
|
||||||
|
# The daemon is expected to terminate, so there won't be a reply.
|
||||||
|
return
|
||||||
|
|
||||||
|
raise
|
||||||
|
|
||||||
|
def _start(self, device: str, preset: str) -> None:
|
||||||
|
group = self._load_group(device)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
'Starting injection: "%s", "%s"',
|
||||||
|
device,
|
||||||
|
preset,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.daemon.start_injecting(group.key, preset)
|
||||||
|
|
||||||
|
def _load_group(self, device: str):
|
||||||
|
"""Load groups, check if a group exists for the given device and return it.
|
||||||
|
Crash if it can't be found.
|
||||||
|
|
||||||
|
device can be either a path like "/dev/input/event1" or a group key like
|
||||||
|
"USB Optical Mouse",
|
||||||
|
"""
|
||||||
|
|
||||||
|
# import stuff late to make sure the correct log level is applied
|
||||||
|
# before anything is logged
|
||||||
|
# TODO since imports shouldn't run any code, this is fixed by moving towards DI
|
||||||
|
from inputremapper.groups import groups
|
||||||
|
|
||||||
|
if device is None:
|
||||||
|
logger.error("--device missing")
|
||||||
|
sys.exit(3)
|
||||||
|
|
||||||
|
# groups.find would also refresh it, but explicit is better than implicit
|
||||||
|
groups.refresh()
|
||||||
|
|
||||||
|
if device.startswith("/dev"):
|
||||||
|
group = groups.find(path=device)
|
||||||
|
else:
|
||||||
|
group = groups.find(key=device)
|
||||||
|
|
||||||
|
if group is None:
|
||||||
|
logger.error(
|
||||||
|
'Device "%s" is unknown, not an appropriate input device or you are '
|
||||||
|
"missing permissions",
|
||||||
|
device,
|
||||||
|
)
|
||||||
|
sys.exit(4)
|
||||||
|
|
||||||
|
return group
|
||||||
|
|
||||||
|
def _autoload(self, device: str) -> None:
|
||||||
|
# if device was specified, autoload for that one. if None autoload
|
||||||
|
# for all devices.
|
||||||
|
if device is None:
|
||||||
|
logger.info("Autoloading all")
|
||||||
|
self.daemon.autoload(timeout=10000)
|
||||||
|
else:
|
||||||
|
group = self._load_group(device)
|
||||||
|
logger.info(
|
||||||
|
'Asking daemon to autoload for device "%s", group "%s"',
|
||||||
|
device,
|
||||||
|
group.key,
|
||||||
|
)
|
||||||
|
self.daemon.autoload_single(group.key, timeout=2000)
|
||||||
|
|
||||||
|
def internals(self, command: str, debug: bool) -> None:
|
||||||
|
"""Methods that are needed to get the gui to work and that require root.
|
||||||
|
|
||||||
|
input-remapper-control should be started with sudo or pkexec for this.
|
||||||
|
"""
|
||||||
|
debug_flag = " -d" if debug else ""
|
||||||
|
|
||||||
|
if command == Internals.START_READER_SERVICE.value:
|
||||||
|
cmd = f"input-remapper-reader-service{debug_flag}"
|
||||||
|
elif command == Internals.START_DAEMON.value:
|
||||||
|
cmd = f"input-remapper-service --hide-info{debug_flag}"
|
||||||
|
else:
|
||||||
|
return
|
||||||
|
|
||||||
|
# daemonize
|
||||||
|
cmd = f"{cmd} &"
|
||||||
|
logger.debug(f"Running `{cmd}`")
|
||||||
|
os.system(cmd)
|
||||||
|
|
||||||
|
def _num_logged_in_users(self) -> int:
|
||||||
|
"""Check how many users are logged in."""
|
||||||
|
who = subprocess.run(["who"], stdout=subprocess.PIPE).stdout.decode()
|
||||||
|
return len([user for user in who.split("\n") if user.strip() != ""])
|
||||||
|
|
||||||
|
def _is_systemd_finished(self) -> bool:
|
||||||
|
"""Check if systemd finished booting."""
|
||||||
|
try:
|
||||||
|
systemd_analyze = subprocess.run(
|
||||||
|
["systemd-analyze"], stdout=subprocess.PIPE
|
||||||
|
)
|
||||||
|
except FileNotFoundError:
|
||||||
|
# probably not systemd, lets assume true to not block input-remapper for good
|
||||||
|
# on certain installations
|
||||||
|
return True
|
||||||
|
|
||||||
|
if "finished" in systemd_analyze.stdout.decode():
|
||||||
|
# it writes into stderr otherwise or something
|
||||||
|
return True
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
def boot_finished(self) -> bool:
|
||||||
|
"""Check if booting is completed."""
|
||||||
|
# Get as much information as needed to really safely determine if booting up is
|
||||||
|
# complete.
|
||||||
|
# - `who` returns an empty list on some system for security purposes
|
||||||
|
# - something might be broken and might make systemd_analyze fail:
|
||||||
|
# Bootup is not yet finished
|
||||||
|
# (org.freedesktop.systemd1.Manager.FinishTimestampMonotonic=0).
|
||||||
|
# Please try again later.
|
||||||
|
# Hint: Use 'systemctl list-jobs' to see active jobs
|
||||||
|
if self._is_systemd_finished():
|
||||||
|
logger.debug("System is booted")
|
||||||
|
return True
|
||||||
|
|
||||||
|
if self._num_logged_in_users() > 0:
|
||||||
|
logger.debug("User(s) logged in")
|
||||||
|
return True
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
def set_daemon(self, daemon):
|
||||||
|
# TODO DI?
|
||||||
|
self.daemon = daemon
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def parse_args() -> Options:
|
||||||
|
parser = argparse.ArgumentParser()
|
||||||
|
parser.add_argument(
|
||||||
|
"--command",
|
||||||
|
action="store",
|
||||||
|
dest="command",
|
||||||
|
help=(
|
||||||
|
"Communicate with the daemon. Available commands are "
|
||||||
|
f"{', '.join([command.value for command in Commands])}"
|
||||||
|
),
|
||||||
|
default=None,
|
||||||
|
metavar="NAME",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--config-dir",
|
||||||
|
action="store",
|
||||||
|
dest="config_dir",
|
||||||
|
help=(
|
||||||
|
"path to the config directory containing config.json, "
|
||||||
|
"xmodmap.json and the presets folder. "
|
||||||
|
"defaults to ~/.config/input-remapper/"
|
||||||
|
),
|
||||||
|
default=None,
|
||||||
|
metavar="PATH",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--preset",
|
||||||
|
action="store",
|
||||||
|
dest="preset",
|
||||||
|
help="The filename of the preset without the .json extension.",
|
||||||
|
default=None,
|
||||||
|
metavar="NAME",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--device",
|
||||||
|
action="store",
|
||||||
|
dest="device",
|
||||||
|
help="One of the device keys from --list-devices",
|
||||||
|
default=None,
|
||||||
|
metavar="NAME",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--list-devices",
|
||||||
|
action="store_true",
|
||||||
|
dest="list_devices",
|
||||||
|
help="List available device keys and exit",
|
||||||
|
default=False,
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--symbol-names",
|
||||||
|
action="store_true",
|
||||||
|
dest="key_names",
|
||||||
|
help="Print all available names for the preset",
|
||||||
|
default=False,
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"-d",
|
||||||
|
"--debug",
|
||||||
|
action="store_true",
|
||||||
|
dest="debug",
|
||||||
|
help="Displays additional debug information",
|
||||||
|
default=False,
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"-v",
|
||||||
|
"--version",
|
||||||
|
action="store_true",
|
||||||
|
dest="version",
|
||||||
|
help="Print the version and exit",
|
||||||
|
default=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
return parser.parse_args(sys.argv[1:]) # type: ignore
|
||||||
100
inputremapper/bin/input_remapper_focus_service.py
Normal file
100
inputremapper/bin/input_remapper_focus_service.py
Normal 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()
|
||||||
166
inputremapper/bin/input_remapper_gtk.py
Executable file
166
inputremapper/bin/input_remapper_gtk.py
Executable 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()
|
||||||
80
inputremapper/bin/input_remapper_reader_service.py
Executable file
80
inputremapper/bin/input_remapper_reader_service.py
Executable file
@ -0,0 +1,80 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# input-remapper - GUI for device specific keyboard mappings
|
||||||
|
# Copyright (C) 2025 sezanzeb <b8x45ygc9@mozmail.com>
|
||||||
|
#
|
||||||
|
# This file is part of input-remapper.
|
||||||
|
#
|
||||||
|
# input-remapper is free software: you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU General Public License as published by
|
||||||
|
# the Free Software Foundation, either version 3 of the License, or
|
||||||
|
# (at your option) any later version.
|
||||||
|
#
|
||||||
|
# input-remapper is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU General Public License
|
||||||
|
# along with input-remapper. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
"""Starts the root reader-service."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import atexit
|
||||||
|
import os
|
||||||
|
import signal
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
from argparse import ArgumentParser
|
||||||
|
|
||||||
|
from inputremapper.bin.process_utils import ProcessUtils
|
||||||
|
from inputremapper.groups import _Groups
|
||||||
|
from inputremapper.gui.reader_service import ReaderService
|
||||||
|
from inputremapper.injection.global_uinputs import GlobalUInputs, FrontendUInput
|
||||||
|
from inputremapper.logging.logger import logger
|
||||||
|
|
||||||
|
|
||||||
|
class InputRemapperReaderServiceBin:
|
||||||
|
@staticmethod
|
||||||
|
def main() -> None:
|
||||||
|
parser = ArgumentParser()
|
||||||
|
parser.add_argument(
|
||||||
|
"-d",
|
||||||
|
"--debug",
|
||||||
|
action="store_true",
|
||||||
|
dest="debug",
|
||||||
|
help="Displays additional debug information",
|
||||||
|
default=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
options = parser.parse_args(sys.argv[1:])
|
||||||
|
|
||||||
|
logger.update_verbosity(options.debug)
|
||||||
|
|
||||||
|
if ProcessUtils.count_python_processes("input-remapper-reader-service") >= 2:
|
||||||
|
logger.warning(
|
||||||
|
"Another input-remapper-reader-service process is already running. "
|
||||||
|
"This can cause problems while recording keys"
|
||||||
|
)
|
||||||
|
|
||||||
|
if os.getuid() != 0:
|
||||||
|
logger.warning("The reader-service usually needs elevated privileges")
|
||||||
|
|
||||||
|
if not ReaderService.pipes_exist():
|
||||||
|
logger.info("Waiting for pipes to be created by the GUI")
|
||||||
|
while not ReaderService.pipes_exist():
|
||||||
|
time.sleep(0.5)
|
||||||
|
logger.debug("Waiting...")
|
||||||
|
|
||||||
|
def on_exit():
|
||||||
|
"""Don't remain idle and alive when the GUI exits via ctrl+c."""
|
||||||
|
# makes no sense to me, but after the keyboard interrupt it is still
|
||||||
|
# waiting for an event to complete (`S` in `ps ax`), even when using
|
||||||
|
# sys.exit
|
||||||
|
os.kill(os.getpid(), signal.SIGKILL)
|
||||||
|
|
||||||
|
atexit.register(on_exit)
|
||||||
|
groups = _Groups()
|
||||||
|
global_uinputs = GlobalUInputs(FrontendUInput)
|
||||||
|
reader_service = ReaderService(groups, global_uinputs)
|
||||||
|
asyncio.run(reader_service.run())
|
||||||
71
inputremapper/bin/input_remapper_service.py
Executable file
71
inputremapper/bin/input_remapper_service.py
Executable file
@ -0,0 +1,71 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# input-remapper - GUI for device specific keyboard mappings
|
||||||
|
# Copyright (C) 2025 sezanzeb <b8x45ygc9@mozmail.com>
|
||||||
|
#
|
||||||
|
# This file is part of input-remapper.
|
||||||
|
#
|
||||||
|
# input-remapper is free software: you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU General Public License as published by
|
||||||
|
# the Free Software Foundation, either version 3 of the License, or
|
||||||
|
# (at your option) any later version.
|
||||||
|
#
|
||||||
|
# input-remapper is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU General Public License
|
||||||
|
# along with input-remapper. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
"""Starts injecting keycodes based on the configuration."""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import multiprocessing
|
||||||
|
from argparse import ArgumentParser
|
||||||
|
|
||||||
|
from inputremapper.configs.global_config import GlobalConfig
|
||||||
|
from inputremapper.injection.global_uinputs import GlobalUInputs, UInput
|
||||||
|
from inputremapper.injection.mapping_handlers.mapping_parser import MappingParser
|
||||||
|
from inputremapper.logging.logger import logger
|
||||||
|
|
||||||
|
|
||||||
|
class InputRemapperServiceBin:
|
||||||
|
@staticmethod
|
||||||
|
def main() -> None:
|
||||||
|
parser = ArgumentParser()
|
||||||
|
parser.add_argument(
|
||||||
|
"-d",
|
||||||
|
"--debug",
|
||||||
|
action="store_true",
|
||||||
|
dest="debug",
|
||||||
|
help="Displays additional debug information",
|
||||||
|
default=False,
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--hide-info",
|
||||||
|
action="store_true",
|
||||||
|
dest="hide_info",
|
||||||
|
help="Don't display version information",
|
||||||
|
default=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
options = parser.parse_args(sys.argv[1:])
|
||||||
|
|
||||||
|
# Python 3.14 compatibility
|
||||||
|
multiprocessing.set_start_method("fork")
|
||||||
|
|
||||||
|
logger.update_verbosity(options.debug)
|
||||||
|
|
||||||
|
# import input-remapper stuff after setting the log verbosity
|
||||||
|
from inputremapper.daemon import Daemon
|
||||||
|
|
||||||
|
if not options.hide_info:
|
||||||
|
logger.log_info("input-remapper-service")
|
||||||
|
|
||||||
|
global_config = GlobalConfig()
|
||||||
|
global_uinputs = GlobalUInputs(UInput)
|
||||||
|
mapping_parser = MappingParser(global_uinputs)
|
||||||
|
|
||||||
|
daemon = Daemon(global_config, global_uinputs, mapping_parser)
|
||||||
|
daemon.publish()
|
||||||
|
daemon.run()
|
||||||
39
inputremapper/bin/process_utils.py
Normal file
39
inputremapper/bin/process_utils.py
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# input-remapper - GUI for device specific keyboard mappings
|
||||||
|
# Copyright (C) 2025 sezanzeb <b8x45ygc9@mozmail.com>
|
||||||
|
#
|
||||||
|
# This file is part of input-remapper.
|
||||||
|
#
|
||||||
|
# input-remapper is free software: you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU General Public License as published by
|
||||||
|
# the Free Software Foundation, either version 3 of the License, or
|
||||||
|
# (at your option) any later version.
|
||||||
|
#
|
||||||
|
# input-remapper is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU General Public License
|
||||||
|
# along with input-remapper. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
import psutil
|
||||||
|
|
||||||
|
|
||||||
|
class ProcessUtils:
|
||||||
|
@staticmethod
|
||||||
|
def count_python_processes(name: str) -> int:
|
||||||
|
# This is somewhat complicated, because there might also be a "sudo <name>"
|
||||||
|
# process.
|
||||||
|
count = 0
|
||||||
|
pids = psutil.pids()
|
||||||
|
for pid in pids:
|
||||||
|
try:
|
||||||
|
process = psutil.Process(pid)
|
||||||
|
cmdline = process.cmdline()
|
||||||
|
if len(cmdline) >= 2 and "python" in cmdline[0] and name in cmdline[1]:
|
||||||
|
count += 1
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return count
|
||||||
0
inputremapper/configs/__init__.py
Normal file
0
inputremapper/configs/__init__.py
Normal file
108
inputremapper/configs/app_binding.py
Normal file
108
inputremapper/configs/app_binding.py
Normal file
@ -0,0 +1,108 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# input-remapper - GUI for device specific keyboard mappings
|
||||||
|
# Copyright (C) 2025 sezanzeb <b8x45ygc9@mozmail.com>
|
||||||
|
#
|
||||||
|
# This file is part of input-remapper.
|
||||||
|
#
|
||||||
|
# input-remapper is free software: you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU General Public License as published by
|
||||||
|
# the Free Software Foundation, either version 3 of the License, or
|
||||||
|
# (at your option) any later version.
|
||||||
|
#
|
||||||
|
# input-remapper is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU General Public License
|
||||||
|
# along with input-remapper. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
"""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)
|
||||||
33
inputremapper/configs/data.py
Normal file
33
inputremapper/configs/data.py
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# input-remapper - GUI for device specific keyboard mappings
|
||||||
|
# Copyright (C) 2025 sezanzeb <b8x45ygc9@mozmail.com>
|
||||||
|
#
|
||||||
|
# This file is part of input-remapper.
|
||||||
|
#
|
||||||
|
# input-remapper is free software: you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU General Public License as published by
|
||||||
|
# the Free Software Foundation, either version 3 of the License, or
|
||||||
|
# (at your option) any later version.
|
||||||
|
#
|
||||||
|
# input-remapper is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU General Public License
|
||||||
|
# along with input-remapper. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
|
||||||
|
"""Get stuff from /usr/share/input-remapper, depending on the prefix."""
|
||||||
|
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
from inputremapper.installation_info import DATA_DIR
|
||||||
|
|
||||||
|
|
||||||
|
def get_data_path(filename=""):
|
||||||
|
"""Depending on the installation prefix, return the data dir."""
|
||||||
|
# This was more complicated in the past. This wrapper is kept for now, but feel
|
||||||
|
# free to remove it at a later point.
|
||||||
|
return os.path.join(DATA_DIR, filename)
|
||||||
171
inputremapper/configs/global_config.py
Normal file
171
inputremapper/configs/global_config.py
Normal 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 = {}
|
||||||
485
inputremapper/configs/input_config.py
Normal file
485
inputremapper/configs/input_config.py
Normal file
@ -0,0 +1,485 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# input-remapper - GUI for device specific keyboard mappings
|
||||||
|
# Copyright (C) 2025 sezanzeb <b8x45ygc9@mozmail.com>
|
||||||
|
#
|
||||||
|
# This file is part of input-remapper.
|
||||||
|
#
|
||||||
|
# input-remapper is free software: you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU General Public License as published by
|
||||||
|
# the Free Software Foundation, either version 3 of the License, or
|
||||||
|
# (at your option) any later version.
|
||||||
|
#
|
||||||
|
# input-remapper is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU General Public License
|
||||||
|
# along with input-remapper. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import itertools
|
||||||
|
from typing import Tuple, Iterable, Union, List, Dict, Optional, Hashable
|
||||||
|
|
||||||
|
from evdev import ecodes
|
||||||
|
from inputremapper.configs.paths import PathUtils
|
||||||
|
from inputremapper.input_event import InputEvent
|
||||||
|
|
||||||
|
try:
|
||||||
|
from pydantic.v1 import BaseModel, root_validator, validator
|
||||||
|
except ImportError:
|
||||||
|
from pydantic import BaseModel, root_validator, validator
|
||||||
|
|
||||||
|
from inputremapper.configs.keyboard_layout import keyboard_layout
|
||||||
|
from inputremapper.gui.messages.message_types import MessageType
|
||||||
|
from inputremapper.logging.logger import logger
|
||||||
|
from inputremapper.utils import get_evdev_constant_name, DeviceHash
|
||||||
|
|
||||||
|
# having shift in combinations modifies the configured output,
|
||||||
|
# ctrl might not work at all
|
||||||
|
DIFFICULT_COMBINATIONS = [
|
||||||
|
ecodes.KEY_LEFTSHIFT,
|
||||||
|
ecodes.KEY_RIGHTSHIFT,
|
||||||
|
ecodes.KEY_LEFTCTRL,
|
||||||
|
ecodes.KEY_RIGHTCTRL,
|
||||||
|
ecodes.KEY_LEFTALT,
|
||||||
|
ecodes.KEY_RIGHTALT,
|
||||||
|
]
|
||||||
|
|
||||||
|
EMPTY_TYPE = 99
|
||||||
|
|
||||||
|
# "Button 11" (10 + 0x110 "BTN_LEFT") is the highest mouse button simulatable
|
||||||
|
# at time of writing using Piper 0.8 and a Logi G502X.
|
||||||
|
# Please update if a way to simulate higher button-presses is found.
|
||||||
|
MAX_BTN_MOUSE_ECODE = 0x11A
|
||||||
|
|
||||||
|
# Beware, this is without sign! Movement to the left needs this to be negative
|
||||||
|
DEFAULT_ABS_ANALOG_THRESHOLD_MAGNITUDE = 30
|
||||||
|
|
||||||
|
|
||||||
|
class InputConfig(BaseModel):
|
||||||
|
"""Describes a single input within a combination, to configure mappings."""
|
||||||
|
|
||||||
|
message_type = MessageType.selected_event
|
||||||
|
|
||||||
|
type: int
|
||||||
|
code: int
|
||||||
|
|
||||||
|
# origin_hash is a hash to identify a specific /dev/input/eventXX device.
|
||||||
|
# This solves a number of bugs when multiple devices have overlapping capabilities.
|
||||||
|
# see utils.get_device_hash for the exact hashing function
|
||||||
|
origin_hash: Optional[DeviceHash] = None
|
||||||
|
|
||||||
|
# At which point is an analog input treated as "pressed". In percent (-99 to 99)
|
||||||
|
# of the delta between a resting joystick and a maxed-out joystick.
|
||||||
|
# Should be None if no analog input is configured.
|
||||||
|
analog_threshold: Optional[int] = None
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"InputConfig {get_evdev_constant_name(self.type, self.code)}"
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return (
|
||||||
|
f"<InputConfig {self.type_and_code} "
|
||||||
|
f"{get_evdev_constant_name(*self.type_and_code)}, "
|
||||||
|
f"{self.analog_threshold}, "
|
||||||
|
f"{self.origin_hash}, "
|
||||||
|
f"at {hex(id(self))}>"
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def input_match_hash(self) -> Hashable:
|
||||||
|
"""a Hashable object which is intended to match the InputConfig with a
|
||||||
|
InputEvent.
|
||||||
|
|
||||||
|
InputConfig itself is hashable, but can not be used to match InputEvent's
|
||||||
|
because its hash includes the analog_threshold
|
||||||
|
"""
|
||||||
|
return self.type, self.code, self.origin_hash
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_empty(self) -> bool:
|
||||||
|
return self.type == EMPTY_TYPE
|
||||||
|
|
||||||
|
@property
|
||||||
|
def defines_analog_input(self) -> bool:
|
||||||
|
"""Whether this defines an analog input."""
|
||||||
|
return not self.analog_threshold and self.type != ecodes.EV_KEY
|
||||||
|
|
||||||
|
@property
|
||||||
|
def type_and_code(self) -> Tuple[int, int]:
|
||||||
|
"""Event type, code."""
|
||||||
|
return self.type, self.code
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def btn_left(cls):
|
||||||
|
return cls(type=ecodes.EV_KEY, code=ecodes.BTN_LEFT)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_input_event(cls, event: InputEvent) -> InputConfig:
|
||||||
|
"""create an input confing from the given InputEvent, uses the value as
|
||||||
|
analog threshold"""
|
||||||
|
return cls(
|
||||||
|
type=event.type,
|
||||||
|
code=event.code,
|
||||||
|
origin_hash=event.origin_hash,
|
||||||
|
analog_threshold=event.value,
|
||||||
|
)
|
||||||
|
|
||||||
|
def description(self, exclude_threshold=False, exclude_direction=False) -> str:
|
||||||
|
"""Get a human-readable description of the event."""
|
||||||
|
return (
|
||||||
|
f"{self._get_name()} "
|
||||||
|
f"{self._get_direction() if not exclude_direction else ''} "
|
||||||
|
f"{self._get_threshold_value() if not exclude_threshold else ''}".strip()
|
||||||
|
)
|
||||||
|
|
||||||
|
def _get_mouse_button_name(self) -> Optional[str]:
|
||||||
|
"""Get a human-readable description of a mouse-button. Only the first 7
|
||||||
|
mouse buttons are in evdev and they often have misleading names there
|
||||||
|
(eg it calls buttons 6 & 7 forward/back but usually that's buttons 5 & 4).
|
||||||
|
Returns None if not a mouse button."""
|
||||||
|
|
||||||
|
if self.type == ecodes.EV_KEY:
|
||||||
|
if ecodes.BTN_MOUSE <= self.code <= ecodes.BTN_MIDDLE:
|
||||||
|
# button is left/right/middle button
|
||||||
|
key_name: str = get_evdev_constant_name(self.type, self.code)
|
||||||
|
return key_name.replace(
|
||||||
|
"BTN_", "Mouse Button "
|
||||||
|
) # eg "Mouse Button LEFT"
|
||||||
|
elif ecodes.BTN_MIDDLE < self.code <= MAX_BTN_MOUSE_ECODE:
|
||||||
|
# button is a higher-number mouse button like side-buttons.
|
||||||
|
# This calculation assumes left mouse button is button 1, so side buttons start at 4.
|
||||||
|
button_number: int = self.code - ecodes.BTN_MOUSE + 1
|
||||||
|
return f"Mouse Button {button_number}" # eg "Mouse Button 7"
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _get_name(self) -> Optional[str]:
|
||||||
|
"""Human-readable name (e.g. KEY_A) of the specified input event."""
|
||||||
|
|
||||||
|
# prevent logging warnings for new/empty configs
|
||||||
|
if self.is_empty:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# must check if it's a mouse button *before* ecodes
|
||||||
|
# because not all mouse buttons are in ecodes.
|
||||||
|
mouse_button_name: Optional[str] = self._get_mouse_button_name()
|
||||||
|
if mouse_button_name is not None:
|
||||||
|
return mouse_button_name
|
||||||
|
|
||||||
|
if self.type not in ecodes.bytype:
|
||||||
|
logger.warning("Unknown type for %s", self)
|
||||||
|
return f"unknown {self.type, self.code}"
|
||||||
|
|
||||||
|
if self.code not in ecodes.bytype[self.type]:
|
||||||
|
logger.warning("Unknown code for %s", self)
|
||||||
|
return f"unknown {self.type, self.code}"
|
||||||
|
|
||||||
|
key_name = None
|
||||||
|
|
||||||
|
# first try to find the name in xmodmap to not display wrong
|
||||||
|
# names due to the keyboard layout
|
||||||
|
if self.type == ecodes.EV_KEY:
|
||||||
|
key_name = keyboard_layout.get_name(self.code)
|
||||||
|
|
||||||
|
if key_name is None:
|
||||||
|
# if no result, look in the linux combination constants. On a german
|
||||||
|
# keyboard for example z and y are switched, which will therefore
|
||||||
|
# cause the wrong letter to be displayed.
|
||||||
|
key_name = get_evdev_constant_name(self.type, self.code)
|
||||||
|
|
||||||
|
key_name = key_name.replace("ABS_Z", "Trigger Left")
|
||||||
|
key_name = key_name.replace("ABS_RZ", "Trigger Right")
|
||||||
|
|
||||||
|
key_name = key_name.replace("ABS_HAT0X", "DPad-X")
|
||||||
|
key_name = key_name.replace("ABS_HAT0Y", "DPad-Y")
|
||||||
|
key_name = key_name.replace("ABS_HAT1X", "DPad-2-X")
|
||||||
|
key_name = key_name.replace("ABS_HAT1Y", "DPad-2-Y")
|
||||||
|
key_name = key_name.replace("ABS_HAT2X", "DPad-3-X")
|
||||||
|
key_name = key_name.replace("ABS_HAT2Y", "DPad-3-Y")
|
||||||
|
|
||||||
|
key_name = key_name.replace("ABS_X", "Joystick-X")
|
||||||
|
key_name = key_name.replace("ABS_Y", "Joystick-Y")
|
||||||
|
key_name = key_name.replace("ABS_RX", "Joystick-RX")
|
||||||
|
key_name = key_name.replace("ABS_RY", "Joystick-RY")
|
||||||
|
|
||||||
|
key_name = key_name.replace("BTN_", "Button ")
|
||||||
|
key_name = key_name.replace("KEY_", "")
|
||||||
|
|
||||||
|
key_name = key_name.replace("REL_", "")
|
||||||
|
key_name = key_name.replace("HWHEEL", "Wheel")
|
||||||
|
key_name = key_name.replace("WHEEL", "Wheel")
|
||||||
|
|
||||||
|
key_name = key_name.replace("_", " ")
|
||||||
|
key_name = key_name.replace(" ", " ")
|
||||||
|
return key_name
|
||||||
|
|
||||||
|
def _get_direction(self) -> str:
|
||||||
|
"""human-readable direction description for the analog_threshold"""
|
||||||
|
if self.type == ecodes.EV_KEY or self.defines_analog_input:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
assert self.analog_threshold
|
||||||
|
threshold_direction = self.analog_threshold // abs(self.analog_threshold)
|
||||||
|
return {
|
||||||
|
# D-Pad
|
||||||
|
(ecodes.ABS_HAT0X, -1): "Left",
|
||||||
|
(ecodes.ABS_HAT0X, 1): "Right",
|
||||||
|
(ecodes.ABS_HAT0Y, -1): "Up",
|
||||||
|
(ecodes.ABS_HAT0Y, 1): "Down",
|
||||||
|
(ecodes.ABS_HAT1X, -1): "Left",
|
||||||
|
(ecodes.ABS_HAT1X, 1): "Right",
|
||||||
|
(ecodes.ABS_HAT1Y, -1): "Up",
|
||||||
|
(ecodes.ABS_HAT1Y, 1): "Down",
|
||||||
|
(ecodes.ABS_HAT2X, -1): "Left",
|
||||||
|
(ecodes.ABS_HAT2X, 1): "Right",
|
||||||
|
(ecodes.ABS_HAT2Y, -1): "Up",
|
||||||
|
(ecodes.ABS_HAT2Y, 1): "Down",
|
||||||
|
# joystick
|
||||||
|
(ecodes.ABS_X, 1): "Right",
|
||||||
|
(ecodes.ABS_X, -1): "Left",
|
||||||
|
(ecodes.ABS_Y, 1): "Down",
|
||||||
|
(ecodes.ABS_Y, -1): "Up",
|
||||||
|
(ecodes.ABS_RX, 1): "Right",
|
||||||
|
(ecodes.ABS_RX, -1): "Left",
|
||||||
|
(ecodes.ABS_RY, 1): "Down",
|
||||||
|
(ecodes.ABS_RY, -1): "Up",
|
||||||
|
# wheel
|
||||||
|
(ecodes.REL_WHEEL, -1): "Down",
|
||||||
|
(ecodes.REL_WHEEL, 1): "Up",
|
||||||
|
(ecodes.REL_HWHEEL, -1): "Left",
|
||||||
|
(ecodes.REL_HWHEEL, 1): "Right",
|
||||||
|
}.get((self.code, threshold_direction)) or (
|
||||||
|
"+" if threshold_direction > 0 else "-"
|
||||||
|
)
|
||||||
|
|
||||||
|
def _get_threshold_value(self) -> str:
|
||||||
|
"""human-readable value of the analog_threshold e.g. '20%'"""
|
||||||
|
if self.analog_threshold is None:
|
||||||
|
return ""
|
||||||
|
return {
|
||||||
|
ecodes.EV_REL: f"{abs(self.analog_threshold)}",
|
||||||
|
ecodes.EV_ABS: f"{abs(self.analog_threshold)}%",
|
||||||
|
}.get(self.type) or ""
|
||||||
|
|
||||||
|
def modify(
|
||||||
|
self,
|
||||||
|
type_: Optional[int] = None,
|
||||||
|
code: Optional[int] = None,
|
||||||
|
origin_hash: Optional[str] = None,
|
||||||
|
analog_threshold: Optional[int] = None,
|
||||||
|
) -> InputConfig:
|
||||||
|
"""Return a new modified event."""
|
||||||
|
return InputConfig(
|
||||||
|
type=type_ if type_ is not None else self.type,
|
||||||
|
code=code if code is not None else self.code,
|
||||||
|
origin_hash=origin_hash if origin_hash is not None else self.origin_hash,
|
||||||
|
analog_threshold=(
|
||||||
|
analog_threshold
|
||||||
|
if analog_threshold is not None
|
||||||
|
else self.analog_threshold
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
def __hash__(self):
|
||||||
|
return hash((self.type, self.code, self.origin_hash, self.analog_threshold))
|
||||||
|
|
||||||
|
@validator("analog_threshold")
|
||||||
|
def _ensure_analog_threshold_is_none(cls, analog_threshold):
|
||||||
|
"""ensure the analog threshold is None, not zero."""
|
||||||
|
if analog_threshold == 0 or analog_threshold is None:
|
||||||
|
# the sign of the threshold defines the direction the joystick has to move.
|
||||||
|
# 0 is invalid, it has no sign. And for triggers, what does a threshold of
|
||||||
|
# 0 mean? Would it always be active?
|
||||||
|
return None
|
||||||
|
|
||||||
|
return analog_threshold
|
||||||
|
|
||||||
|
@root_validator
|
||||||
|
def _remove_analog_threshold_for_key_input(cls, values):
|
||||||
|
"""remove the analog threshold if the type is a EV_KEY"""
|
||||||
|
type_ = values.get("type")
|
||||||
|
if type_ == ecodes.EV_KEY:
|
||||||
|
values["analog_threshold"] = None
|
||||||
|
return values
|
||||||
|
|
||||||
|
@root_validator(pre=True)
|
||||||
|
def validate_origin_hash(cls, values):
|
||||||
|
origin_hash = values.get("origin_hash")
|
||||||
|
if origin_hash is None:
|
||||||
|
# For new presets, origin_hash should be set. For old ones, it can
|
||||||
|
# be still missing. A lot of tests didn't set an origin_hash.
|
||||||
|
if values.get("type") != EMPTY_TYPE:
|
||||||
|
logger.warning("No origin_hash set for %s", values)
|
||||||
|
|
||||||
|
return values
|
||||||
|
|
||||||
|
values["origin_hash"] = origin_hash.lower()
|
||||||
|
return values
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
allow_mutation = False
|
||||||
|
underscore_attrs_are_private = True
|
||||||
|
|
||||||
|
|
||||||
|
InputCombinationInit = Union[
|
||||||
|
Iterable[Dict[str, Union[str, int]]],
|
||||||
|
Iterable[InputConfig],
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class InputCombination(Tuple[InputConfig, ...]):
|
||||||
|
"""One or more InputConfigs used to trigger a mapping."""
|
||||||
|
|
||||||
|
# tuple is immutable, therefore we need to override __new__()
|
||||||
|
# https://jfine-python-classes.readthedocs.io/en/latest/subclass-tuple.html
|
||||||
|
def __new__(cls, configs: InputCombinationInit) -> InputCombination:
|
||||||
|
"""Create a new InputCombination.
|
||||||
|
|
||||||
|
Examples
|
||||||
|
--------
|
||||||
|
InputCombination([InputConfig, ...])
|
||||||
|
InputCombination([{type: ..., code: ..., value: ...}, ...])
|
||||||
|
"""
|
||||||
|
if not isinstance(configs, Iterable):
|
||||||
|
raise TypeError("InputCombination requires a list of InputConfigs.")
|
||||||
|
|
||||||
|
if isinstance(configs, InputConfig):
|
||||||
|
# wrap the argument in square brackets
|
||||||
|
raise TypeError("InputCombination requires a list of InputConfigs.")
|
||||||
|
|
||||||
|
validated_configs = []
|
||||||
|
for config in configs:
|
||||||
|
if isinstance(configs, InputEvent):
|
||||||
|
raise TypeError("InputCombinations require InputConfigs, not Events.")
|
||||||
|
|
||||||
|
if isinstance(config, InputConfig):
|
||||||
|
validated_configs.append(config)
|
||||||
|
elif isinstance(config, dict):
|
||||||
|
validated_configs.append(InputConfig(**config))
|
||||||
|
else:
|
||||||
|
raise TypeError(f'Can\'t handle "{config}"')
|
||||||
|
|
||||||
|
if len(validated_configs) == 0:
|
||||||
|
raise ValueError(f"failed to create InputCombination with {configs = }")
|
||||||
|
|
||||||
|
# mypy bug: https://github.com/python/mypy/issues/8957
|
||||||
|
# https://github.com/python/mypy/issues/8541
|
||||||
|
return super().__new__(cls, validated_configs) # type: ignore
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f'Combination ({" + ".join(str(event) for event in self)})'
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
combination = ", ".join(repr(event) for event in self)
|
||||||
|
return f"<InputCombination ({combination}) at {hex(id(self))}>"
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def __get_validators__(cls):
|
||||||
|
"""Used by pydantic to create InputCombination objects."""
|
||||||
|
yield cls.validate
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def validate(cls, init_arg) -> InputCombination:
|
||||||
|
"""The only valid option is from_config"""
|
||||||
|
if isinstance(init_arg, InputCombination):
|
||||||
|
return init_arg
|
||||||
|
return cls(init_arg)
|
||||||
|
|
||||||
|
def to_config(self) -> Tuple[Dict[str, int], ...]:
|
||||||
|
"""Turn the object into a tuple of dicts."""
|
||||||
|
return tuple(input_config.dict(exclude_defaults=True) for input_config in self)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def empty_combination(cls) -> InputCombination:
|
||||||
|
"""A combination that has default invalid (to evdev) values.
|
||||||
|
|
||||||
|
Useful for the UI to indicate that this combination is not set
|
||||||
|
"""
|
||||||
|
return cls([{"type": EMPTY_TYPE, "code": 99, "analog_threshold": 99}])
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_tuples(cls, *tuples):
|
||||||
|
"""Construct an InputCombination from (type, code, analog_threshold) tuples."""
|
||||||
|
dicts = []
|
||||||
|
for tuple_ in tuples:
|
||||||
|
if len(tuple_) == 3:
|
||||||
|
dicts.append(
|
||||||
|
{
|
||||||
|
"type": tuple_[0],
|
||||||
|
"code": tuple_[1],
|
||||||
|
"analog_threshold": tuple_[2],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
elif len(tuple_) == 2:
|
||||||
|
dicts.append(
|
||||||
|
{
|
||||||
|
"type": tuple_[0],
|
||||||
|
"code": tuple_[1],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
raise TypeError
|
||||||
|
|
||||||
|
return cls(dicts)
|
||||||
|
|
||||||
|
def is_problematic(self) -> bool:
|
||||||
|
"""Is this combination going to work properly on all systems?"""
|
||||||
|
if len(self) <= 1:
|
||||||
|
return False
|
||||||
|
|
||||||
|
for input_config in self:
|
||||||
|
if input_config.type != ecodes.EV_KEY:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if input_config.code in DIFFICULT_COMBINATIONS:
|
||||||
|
return True
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
@property
|
||||||
|
def defines_analog_input(self) -> bool:
|
||||||
|
"""Check if there is any analog input in self."""
|
||||||
|
return True in tuple(i.defines_analog_input for i in self)
|
||||||
|
|
||||||
|
def find_analog_input_config(
|
||||||
|
self, type_: Optional[int] = None
|
||||||
|
) -> Optional[InputConfig]:
|
||||||
|
"""Return the first event that defines an analog input."""
|
||||||
|
for input_config in self:
|
||||||
|
if input_config.defines_analog_input and (
|
||||||
|
type_ is None or input_config.type == type_
|
||||||
|
):
|
||||||
|
return input_config
|
||||||
|
return None
|
||||||
|
|
||||||
|
def get_permutations(self) -> List[InputCombination]:
|
||||||
|
"""Get a list of EventCombinations representing all possible permutations.
|
||||||
|
|
||||||
|
combining a + b + c should have the same result as b + a + c.
|
||||||
|
Only the last combination remains the same in the returned result.
|
||||||
|
"""
|
||||||
|
if len(self) <= 2:
|
||||||
|
return [self]
|
||||||
|
|
||||||
|
if len(self) > 6:
|
||||||
|
logger.warning(
|
||||||
|
"Your input combination has a length of %d. Long combinations might "
|
||||||
|
'freeze the process. Edit the configuration files in "%s" to fix it.',
|
||||||
|
len(self),
|
||||||
|
PathUtils.get_config_path(),
|
||||||
|
)
|
||||||
|
|
||||||
|
permutations = []
|
||||||
|
for permutation in itertools.permutations(self[:-1]):
|
||||||
|
permutations.append(InputCombination((*permutation, self[-1])))
|
||||||
|
|
||||||
|
return permutations
|
||||||
|
|
||||||
|
def beautify(self) -> str:
|
||||||
|
"""Get a human-readable string representation."""
|
||||||
|
if self == InputCombination.empty_combination():
|
||||||
|
return "empty_combination"
|
||||||
|
return " + ".join(event.description(exclude_threshold=True) for event in self)
|
||||||
221
inputremapper/configs/keyboard_layout.py
Normal file
221
inputremapper/configs/keyboard_layout.py
Normal file
@ -0,0 +1,221 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# input-remapper - GUI for device specific keyboard mappings
|
||||||
|
# Copyright (C) 2025 sezanzeb <b8x45ygc9@mozmail.com>
|
||||||
|
#
|
||||||
|
# This file is part of input-remapper.
|
||||||
|
#
|
||||||
|
# input-remapper is free software: you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU General Public License as published by
|
||||||
|
# the Free Software Foundation, either version 3 of the License, or
|
||||||
|
# (at your option) any later version.
|
||||||
|
#
|
||||||
|
# input-remapper is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU General Public License
|
||||||
|
# along with input-remapper. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
"""Make the systems/environments mapping of keys and codes accessible."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
from typing import Optional, List, Iterable, Tuple
|
||||||
|
|
||||||
|
import evdev
|
||||||
|
|
||||||
|
from inputremapper.configs.paths import PathUtils
|
||||||
|
from inputremapper.logging.logger import logger
|
||||||
|
from inputremapper.utils import is_service
|
||||||
|
|
||||||
|
DISABLE_NAME = "disable"
|
||||||
|
|
||||||
|
DISABLE_CODE = -1
|
||||||
|
|
||||||
|
# xkb uses keycodes that are 8 higher than those from evdev
|
||||||
|
XKB_KEYCODE_OFFSET = 8
|
||||||
|
|
||||||
|
XMODMAP_FILENAME = "xmodmap.json"
|
||||||
|
|
||||||
|
LAZY_LOAD = None
|
||||||
|
|
||||||
|
|
||||||
|
class KeyboardLayout:
|
||||||
|
"""Stores information about all available keycodes."""
|
||||||
|
|
||||||
|
_mapping: Optional[dict] = LAZY_LOAD
|
||||||
|
_xmodmap: Optional[List[Tuple[str, str]]] = LAZY_LOAD
|
||||||
|
_case_insensitive_mapping: Optional[dict] = LAZY_LOAD
|
||||||
|
|
||||||
|
def __getattribute__(self, wanted: str):
|
||||||
|
"""To lazy load keyboard_layout info only when needed.
|
||||||
|
|
||||||
|
For example, this helps to keep logs of input-remapper-control clear when it
|
||||||
|
doesn't need it the information.
|
||||||
|
"""
|
||||||
|
lazy_loaded_attributes = ["_mapping", "_xmodmap", "_case_insensitive_mapping"]
|
||||||
|
for lazy_loaded_attribute in lazy_loaded_attributes:
|
||||||
|
if wanted != lazy_loaded_attribute:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if object.__getattribute__(self, lazy_loaded_attribute) is LAZY_LOAD:
|
||||||
|
# initialize _mapping and such with an empty dict, for populate
|
||||||
|
# to write into
|
||||||
|
object.__setattr__(self, lazy_loaded_attribute, {})
|
||||||
|
object.__getattribute__(self, "populate")()
|
||||||
|
|
||||||
|
return object.__getattribute__(self, wanted)
|
||||||
|
|
||||||
|
def list_names(self, codes: Optional[Iterable[int]] = None) -> List[str]:
|
||||||
|
"""Get all possible names in the mapping, optionally filtered by codes.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
codes: list of event codes
|
||||||
|
"""
|
||||||
|
if not codes:
|
||||||
|
return self._mapping.keys()
|
||||||
|
|
||||||
|
return [name for name, code in self._mapping.items() if code in codes]
|
||||||
|
|
||||||
|
def correct_case(self, symbol: str):
|
||||||
|
"""Return the correct casing for a symbol."""
|
||||||
|
if symbol in self._mapping:
|
||||||
|
return symbol
|
||||||
|
# only if not e.g. both "a" and "A" are in the mapping
|
||||||
|
return self._case_insensitive_mapping.get(symbol.lower(), symbol)
|
||||||
|
|
||||||
|
def _use_xmodmap_symbols(self):
|
||||||
|
"""Look up xmodmap -pke, write xmodmap.json, and get the symbols."""
|
||||||
|
try:
|
||||||
|
xmodmap = subprocess.check_output(
|
||||||
|
["xmodmap", "-pke"],
|
||||||
|
stderr=subprocess.STDOUT,
|
||||||
|
).decode()
|
||||||
|
except FileNotFoundError:
|
||||||
|
logger.info("Optional `xmodmap` command not found. This is not critical.")
|
||||||
|
return
|
||||||
|
except subprocess.CalledProcessError as e:
|
||||||
|
logger.error('Call to `xmodmap -pke` failed with "%s"', e)
|
||||||
|
return
|
||||||
|
|
||||||
|
self._xmodmap = re.findall(r"(\d+) = (.+)\n", xmodmap + "\n")
|
||||||
|
xmodmap_dict = self._find_legit_mappings()
|
||||||
|
if len(xmodmap_dict) == 0:
|
||||||
|
logger.info("`xmodmap -pke` did not yield any symbol")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Write this stuff into the input-remapper config directory, because
|
||||||
|
# the systemd service won't know the user sessions xmodmap.
|
||||||
|
path = PathUtils.get_config_path(XMODMAP_FILENAME)
|
||||||
|
PathUtils.touch(path)
|
||||||
|
with open(path, "w") as file:
|
||||||
|
logger.debug('Writing "%s"', path)
|
||||||
|
json.dump(xmodmap_dict, file, indent=4)
|
||||||
|
|
||||||
|
for name, code in xmodmap_dict.items():
|
||||||
|
self._set(name, code)
|
||||||
|
|
||||||
|
def _use_linux_evdev_symbols(self):
|
||||||
|
"""Look up the evdev constant names and use them."""
|
||||||
|
for name, ecode in evdev.ecodes.ecodes.items():
|
||||||
|
if name.startswith("KEY") or name.startswith("BTN"):
|
||||||
|
self._set(name, ecode)
|
||||||
|
|
||||||
|
def populate(self):
|
||||||
|
"""Get a mapping of all available names to their keycodes."""
|
||||||
|
logger.debug("Gathering available keycodes")
|
||||||
|
self.clear()
|
||||||
|
|
||||||
|
if not is_service():
|
||||||
|
# xmodmap is only available from within the login session.
|
||||||
|
# The service that runs via systemd can't use this.
|
||||||
|
self._use_xmodmap_symbols()
|
||||||
|
|
||||||
|
self._use_linux_evdev_symbols()
|
||||||
|
|
||||||
|
self._set(DISABLE_NAME, DISABLE_CODE)
|
||||||
|
|
||||||
|
def update(self, mapping: dict):
|
||||||
|
"""Update this with new keys.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
mapping
|
||||||
|
maps from name to code. Make sure your keys are lowercase.
|
||||||
|
"""
|
||||||
|
len_before = len(self._mapping)
|
||||||
|
for name, code in mapping.items():
|
||||||
|
self._set(name, code)
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
"Updated keycodes with %d new ones", len(self._mapping) - len_before
|
||||||
|
)
|
||||||
|
|
||||||
|
def _set(self, name: str, code: int):
|
||||||
|
"""Map name to code."""
|
||||||
|
self._mapping[str(name)] = code
|
||||||
|
self._case_insensitive_mapping[str(name).lower()] = name
|
||||||
|
|
||||||
|
def get(self, name: str) -> Optional[int]:
|
||||||
|
"""Return the code mapped to the key."""
|
||||||
|
# the correct casing should be shown when asking the keyboard_layout
|
||||||
|
# for stuff. indexing case insensitive to support old presets.
|
||||||
|
if name not in self._mapping:
|
||||||
|
# only if not e.g. both "a" and "A" are in the mapping
|
||||||
|
name = self._case_insensitive_mapping.get(str(name).lower())
|
||||||
|
|
||||||
|
return self._mapping.get(name)
|
||||||
|
|
||||||
|
def clear(self):
|
||||||
|
"""Remove all mapped keys. Only needed for tests."""
|
||||||
|
keys = list(self._mapping.keys())
|
||||||
|
for key in keys:
|
||||||
|
del self._mapping[key]
|
||||||
|
|
||||||
|
def get_name(self, code: int):
|
||||||
|
"""Get the first matching name for the code."""
|
||||||
|
for entry in self._xmodmap:
|
||||||
|
if int(entry[0]) - XKB_KEYCODE_OFFSET == code:
|
||||||
|
return entry[1].split()[0]
|
||||||
|
|
||||||
|
# Fall back to the linux constants
|
||||||
|
# This is especially important for BTN_LEFT and such
|
||||||
|
btn_name = evdev.ecodes.BTN.get(code, None)
|
||||||
|
if btn_name is not None:
|
||||||
|
if type(btn_name) in [list, tuple]:
|
||||||
|
# python-evdev >= 1.8.0 uses tuples
|
||||||
|
return btn_name[0]
|
||||||
|
|
||||||
|
return btn_name
|
||||||
|
|
||||||
|
key_name = evdev.ecodes.KEY.get(code, None)
|
||||||
|
if key_name is not None:
|
||||||
|
if type(key_name) in [list, tuple]:
|
||||||
|
# python-evdev >= 1.8.0 uses tuples
|
||||||
|
return key_name[0]
|
||||||
|
|
||||||
|
return key_name
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _find_legit_mappings(self) -> dict:
|
||||||
|
"""From the parsed xmodmap list find usable symbols and their codes."""
|
||||||
|
xmodmap_dict = {}
|
||||||
|
for keycode, names in self._xmodmap:
|
||||||
|
# there might be multiple, like:
|
||||||
|
# keycode 64 = Alt_L Meta_L Alt_L Meta_L
|
||||||
|
# keycode 204 = NoSymbol Alt_L NoSymbol Alt_L
|
||||||
|
# Alt_L should map to code 64. Writing code 204 only works
|
||||||
|
# if a modifier is applied at the same time. So take the first
|
||||||
|
# one.
|
||||||
|
name = names.split()[0]
|
||||||
|
xmodmap_dict[name] = int(keycode) - XKB_KEYCODE_OFFSET
|
||||||
|
|
||||||
|
return xmodmap_dict
|
||||||
|
|
||||||
|
|
||||||
|
# TODO DI
|
||||||
|
# this mapping represents the xmodmap output, which stays constant
|
||||||
|
keyboard_layout = KeyboardLayout()
|
||||||
525
inputremapper/configs/mapping.py
Normal file
525
inputremapper/configs/mapping.py
Normal file
@ -0,0 +1,525 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# input-remapper - GUI for device specific keyboard mappings
|
||||||
|
# Copyright (C) 2025 sezanzeb <b8x45ygc9@mozmail.com>
|
||||||
|
#
|
||||||
|
# This file is part of input-remapper.
|
||||||
|
#
|
||||||
|
# input-remapper is free software: you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU General Public License as published by
|
||||||
|
# the Free Software Foundation, either version 3 of the License, or
|
||||||
|
# (at your option) any later version.
|
||||||
|
#
|
||||||
|
# input-remapper is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU General Public License
|
||||||
|
# along with input-remapper. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import enum
|
||||||
|
from collections import namedtuple
|
||||||
|
from typing import Optional, Callable, Tuple, TypeVar, Union, Any, Dict
|
||||||
|
|
||||||
|
from evdev.ecodes import (
|
||||||
|
EV_KEY,
|
||||||
|
EV_ABS,
|
||||||
|
EV_REL,
|
||||||
|
REL_WHEEL,
|
||||||
|
REL_HWHEEL,
|
||||||
|
REL_HWHEEL_HI_RES,
|
||||||
|
REL_WHEEL_HI_RES,
|
||||||
|
)
|
||||||
|
from packaging import version
|
||||||
|
|
||||||
|
from inputremapper.logging.logger import logger
|
||||||
|
|
||||||
|
try:
|
||||||
|
from pydantic.v1 import (
|
||||||
|
BaseModel,
|
||||||
|
PositiveInt,
|
||||||
|
confloat,
|
||||||
|
conint,
|
||||||
|
root_validator,
|
||||||
|
validator,
|
||||||
|
ValidationError,
|
||||||
|
PositiveFloat,
|
||||||
|
VERSION,
|
||||||
|
BaseConfig,
|
||||||
|
)
|
||||||
|
except ImportError:
|
||||||
|
from pydantic import (
|
||||||
|
BaseModel,
|
||||||
|
PositiveInt,
|
||||||
|
confloat,
|
||||||
|
conint,
|
||||||
|
root_validator,
|
||||||
|
validator,
|
||||||
|
ValidationError,
|
||||||
|
PositiveFloat,
|
||||||
|
VERSION,
|
||||||
|
BaseConfig,
|
||||||
|
)
|
||||||
|
|
||||||
|
from inputremapper.configs.input_config import InputCombination
|
||||||
|
from inputremapper.configs.keyboard_layout import keyboard_layout, DISABLE_NAME
|
||||||
|
from inputremapper.configs.validation_errors import (
|
||||||
|
OutputSymbolUnknownError,
|
||||||
|
SymbolNotAvailableInTargetError,
|
||||||
|
OnlyOneAnalogInputError,
|
||||||
|
TriggerPointInRangeError,
|
||||||
|
OutputSymbolVariantError,
|
||||||
|
MacroButTypeOrCodeSetError,
|
||||||
|
SymbolAndCodeMismatchError,
|
||||||
|
WrongMappingTypeForKeyError,
|
||||||
|
MissingOutputAxisError,
|
||||||
|
MissingMacroOrKeyError,
|
||||||
|
)
|
||||||
|
from inputremapper.gui.gettext import _
|
||||||
|
from inputremapper.gui.messages.message_types import MessageType
|
||||||
|
from inputremapper.injection.global_uinputs import GlobalUInputs
|
||||||
|
from inputremapper.injection.macros.parse import Parser
|
||||||
|
from inputremapper.utils import get_evdev_constant_name
|
||||||
|
|
||||||
|
# TODO: remove pydantic VERSION check as soon as we no longer support
|
||||||
|
# Ubuntu 20.04 and with it the ancient pydantic 1.2
|
||||||
|
|
||||||
|
needs_workaround = version.parse(str(VERSION)) < version.parse("1.7.1")
|
||||||
|
|
||||||
|
|
||||||
|
EMPTY_MAPPING_NAME: str = _("Empty Mapping")
|
||||||
|
|
||||||
|
# If `1` is the default speed for EV_REL, how much does this value needs to be scaled
|
||||||
|
# up to get reasonable speeds for various EV_REL events?
|
||||||
|
# Mouse injection rates vary wildly, and so do the values.
|
||||||
|
REL_XY_SCALING: float = 60
|
||||||
|
WHEEL_SCALING: float = 1
|
||||||
|
# WHEEL_HI_RES always generates events with 120 times higher values than WHEEL
|
||||||
|
# https://www.kernel.org/doc/html/latest/input/event-codes.html?highlight=wheel_hi_res#ev-rel
|
||||||
|
WHEEL_HI_RES_SCALING: float = 120
|
||||||
|
# Those values are assuming a rate of 60hz
|
||||||
|
DEFAULT_REL_RATE: float = 60
|
||||||
|
|
||||||
|
|
||||||
|
class KnownUinput(str, enum.Enum):
|
||||||
|
"""The default targets."""
|
||||||
|
|
||||||
|
KEYBOARD = "keyboard"
|
||||||
|
MOUSE = "mouse"
|
||||||
|
GAMEPAD = "gamepad"
|
||||||
|
KEYBOARD_MOUSE = "keyboard + mouse"
|
||||||
|
|
||||||
|
|
||||||
|
class MappingType(str, enum.Enum):
|
||||||
|
"""What kind of output the mapping produces."""
|
||||||
|
|
||||||
|
KEY_MACRO = "key_macro"
|
||||||
|
ANALOG = "analog"
|
||||||
|
|
||||||
|
|
||||||
|
CombinationChangedCallback = Optional[
|
||||||
|
Callable[[InputCombination, InputCombination], None]
|
||||||
|
]
|
||||||
|
MappingModel = TypeVar("MappingModel", bound="UIMapping")
|
||||||
|
|
||||||
|
|
||||||
|
class Cfg(BaseConfig):
|
||||||
|
validate_assignment = True
|
||||||
|
use_enum_values = True
|
||||||
|
underscore_attrs_are_private = True
|
||||||
|
json_encoders = {InputCombination: lambda v: v.json_key()}
|
||||||
|
|
||||||
|
|
||||||
|
class ImmutableCfg(Cfg):
|
||||||
|
allow_mutation = False
|
||||||
|
|
||||||
|
|
||||||
|
class UIMapping(BaseModel):
|
||||||
|
"""Holds all the data for mapping an input action to an output action.
|
||||||
|
|
||||||
|
The Preset contains multiple UIMappings.
|
||||||
|
|
||||||
|
This mapping does not validate the structure of the mapping or macros, only basic
|
||||||
|
values. It is meant to be used in the GUI where invalid mappings are expected.
|
||||||
|
"""
|
||||||
|
|
||||||
|
if needs_workaround:
|
||||||
|
__slots__ = ("_combination_changed",)
|
||||||
|
|
||||||
|
# Required attributes
|
||||||
|
# The InputEvent or InputEvent combination which is mapped
|
||||||
|
input_combination: InputCombination = InputCombination.empty_combination()
|
||||||
|
# The UInput to which the mapped event will be sent
|
||||||
|
target_uinput: Optional[Union[str, KnownUinput]] = None
|
||||||
|
|
||||||
|
# Either `output_symbol` or `output_type` and `output_code` is required
|
||||||
|
# Only set if output is "Key or Macro":
|
||||||
|
output_symbol: Optional[str] = None # The symbol or macro string if applicable
|
||||||
|
# "Analog Axis" or if preset edited manually to inject a code instead of a symbol:
|
||||||
|
output_type: Optional[int] = None # The event type of the mapped event
|
||||||
|
output_code: Optional[int] = None # The event code of the mapped event
|
||||||
|
|
||||||
|
name: Optional[str] = None
|
||||||
|
mapping_type: Optional[MappingType] = None
|
||||||
|
|
||||||
|
# if release events will be sent to the forwarded device as soon as a combination
|
||||||
|
# triggers see also #229
|
||||||
|
release_combination_keys: bool = True
|
||||||
|
|
||||||
|
# macro settings
|
||||||
|
macro_key_sleep_ms: conint(ge=0) = 0 # type: ignore
|
||||||
|
|
||||||
|
# Optional attributes for mapping Axis to Axis
|
||||||
|
# The deadzone of the input axis
|
||||||
|
deadzone: confloat(ge=0, le=1) = 0.1 # type: ignore
|
||||||
|
gain: float = 1.0 # The scale factor for the transformation
|
||||||
|
# The expo factor for the transformation
|
||||||
|
expo: confloat(ge=-1, le=1) = 0 # type: ignore
|
||||||
|
|
||||||
|
# when mapping to relative axis
|
||||||
|
# The frequency [Hz] at which EV_REL events get generated
|
||||||
|
rel_rate: PositiveInt = 60
|
||||||
|
|
||||||
|
# when mapping from a relative axis:
|
||||||
|
# the relative value at which a EV_REL axis is considered at its maximum. Moving
|
||||||
|
# a mouse at 2x the regular speed would be considered max by default.
|
||||||
|
rel_to_abs_input_cutoff: PositiveInt = 2
|
||||||
|
|
||||||
|
# the time until a relative axis is considered stationary if no new events arrive
|
||||||
|
release_timeout: PositiveFloat = 0.05
|
||||||
|
# don't release immediately when a relative axis drops below the speed threshold
|
||||||
|
# instead wait until it dropped for loger than release_timeout below the threshold
|
||||||
|
force_release_timeout: bool = False
|
||||||
|
|
||||||
|
# callback which gets called if the input_combination is updated
|
||||||
|
if not needs_workaround:
|
||||||
|
_combination_changed: Optional[CombinationChangedCallback] = None
|
||||||
|
|
||||||
|
# use type: ignore, looks like a mypy bug related to:
|
||||||
|
# https://github.com/samuelcolvin/pydantic/issues/2949
|
||||||
|
def __init__(self, **kwargs): # type: ignore
|
||||||
|
super().__init__(**kwargs)
|
||||||
|
if needs_workaround:
|
||||||
|
object.__setattr__(self, "_combination_changed", None)
|
||||||
|
|
||||||
|
def __setattr__(self, key: str, value: Any):
|
||||||
|
"""Call the combination changed callback
|
||||||
|
if we are about to update the input_combination
|
||||||
|
"""
|
||||||
|
if key != "input_combination" or self._combination_changed is None:
|
||||||
|
if key == "_combination_changed" and needs_workaround:
|
||||||
|
object.__setattr__(self, "_combination_changed", value)
|
||||||
|
return
|
||||||
|
super().__setattr__(key, value)
|
||||||
|
return
|
||||||
|
|
||||||
|
# the new combination is not yet validated
|
||||||
|
try:
|
||||||
|
new_combi = InputCombination.validate(value)
|
||||||
|
except (ValueError, TypeError) as exception:
|
||||||
|
raise ValidationError(
|
||||||
|
f"failed to Validate {value} as InputCombination", UIMapping
|
||||||
|
) from exception
|
||||||
|
|
||||||
|
if new_combi == self.input_combination:
|
||||||
|
return
|
||||||
|
|
||||||
|
# raises a keyError if the combination or a permutation is already mapped
|
||||||
|
self._combination_changed(new_combi, self.input_combination)
|
||||||
|
super().__setattr__("input_combination", new_combi)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return str(
|
||||||
|
self.dict(
|
||||||
|
exclude_defaults=True, include={"input_combination", "target_uinput"}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if needs_workaround:
|
||||||
|
# https://github.com/samuelcolvin/pydantic/issues/1383
|
||||||
|
def copy(self: MappingModel, *args, **kwargs) -> MappingModel:
|
||||||
|
kwargs["deep"] = True
|
||||||
|
copy = super().copy(*args, **kwargs)
|
||||||
|
object.__setattr__(copy, "_combination_changed", self._combination_changed)
|
||||||
|
return copy
|
||||||
|
|
||||||
|
def format_name(self) -> str:
|
||||||
|
"""Get the custom-name or a readable representation of the combination."""
|
||||||
|
if self.name:
|
||||||
|
return self.name
|
||||||
|
|
||||||
|
if (
|
||||||
|
self.input_combination == InputCombination.empty_combination()
|
||||||
|
or self.input_combination is None
|
||||||
|
):
|
||||||
|
return EMPTY_MAPPING_NAME
|
||||||
|
|
||||||
|
return self.input_combination.beautify()
|
||||||
|
|
||||||
|
def has_input_defined(self) -> bool:
|
||||||
|
"""Whether this mapping defines an event-input."""
|
||||||
|
return self.input_combination != InputCombination.empty_combination()
|
||||||
|
|
||||||
|
def is_axis_mapping(self) -> bool:
|
||||||
|
"""Whether this mapping specifies an output axis."""
|
||||||
|
return self.output_type in [EV_ABS, EV_REL]
|
||||||
|
|
||||||
|
def is_wheel_output(self) -> bool:
|
||||||
|
"""Check if this maps to wheel output."""
|
||||||
|
return self.output_code in (
|
||||||
|
REL_WHEEL,
|
||||||
|
REL_HWHEEL,
|
||||||
|
)
|
||||||
|
|
||||||
|
def is_high_res_wheel_output(self) -> bool:
|
||||||
|
"""Check if this maps to high-res wheel output."""
|
||||||
|
return self.output_code in (
|
||||||
|
REL_WHEEL_HI_RES,
|
||||||
|
REL_HWHEEL_HI_RES,
|
||||||
|
)
|
||||||
|
|
||||||
|
def is_analog_output(self):
|
||||||
|
return self.mapping_type == MappingType.ANALOG
|
||||||
|
|
||||||
|
def set_combination_changed_callback(self, callback: CombinationChangedCallback):
|
||||||
|
self._combination_changed = callback
|
||||||
|
|
||||||
|
def remove_combination_changed_callback(self):
|
||||||
|
self._combination_changed = None
|
||||||
|
|
||||||
|
def get_output_type_code(self) -> Optional[Tuple[int, int]]:
|
||||||
|
"""Returns the output_type and output_code if set,
|
||||||
|
otherwise looks the output_symbol up in the keyboard_layout
|
||||||
|
return None for unknown symbols and macros
|
||||||
|
"""
|
||||||
|
if self.output_code is not None and self.output_type is not None:
|
||||||
|
return self.output_type, self.output_code
|
||||||
|
|
||||||
|
if self.output_symbol and not Parser.is_this_a_macro(self.output_symbol):
|
||||||
|
return EV_KEY, keyboard_layout.get(self.output_symbol)
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
def get_output_name_constant(self) -> str:
|
||||||
|
"""Get the evdev name costant for the output."""
|
||||||
|
return get_evdev_constant_name(self.output_type, self.output_code)
|
||||||
|
|
||||||
|
def is_valid(self) -> bool:
|
||||||
|
"""If the mapping is valid."""
|
||||||
|
return not self.get_error()
|
||||||
|
|
||||||
|
def get_error(self) -> Optional[ValidationError]:
|
||||||
|
"""The validation error or None."""
|
||||||
|
try:
|
||||||
|
Mapping(**self.dict())
|
||||||
|
except ValidationError as exception:
|
||||||
|
return exception
|
||||||
|
return None
|
||||||
|
|
||||||
|
def get_bus_message(self) -> MappingData:
|
||||||
|
"""Return an immutable copy for use in the message broker."""
|
||||||
|
return MappingData(**self.dict())
|
||||||
|
|
||||||
|
@root_validator
|
||||||
|
def validate_mapping_type(cls, values):
|
||||||
|
"""Overrides the mapping type if the output mapping type is obvious."""
|
||||||
|
output_type = values.get("output_type")
|
||||||
|
output_code = values.get("output_code")
|
||||||
|
output_symbol = values.get("output_symbol")
|
||||||
|
|
||||||
|
if output_type is not None and output_symbol is not None:
|
||||||
|
# This is currently only possible when someone edits the preset file by
|
||||||
|
# hand. A key-output mapping without an output_symbol, but type and code
|
||||||
|
# instead, is valid as well.
|
||||||
|
logger.debug("Both output_type and output_symbol are set")
|
||||||
|
|
||||||
|
if output_type != EV_KEY and output_code is not None and not output_symbol:
|
||||||
|
values["mapping_type"] = MappingType.ANALOG.value
|
||||||
|
|
||||||
|
if output_type is None and output_code is None and output_symbol:
|
||||||
|
values["mapping_type"] = MappingType.KEY_MACRO.value
|
||||||
|
|
||||||
|
if output_type == EV_KEY:
|
||||||
|
values["mapping_type"] = MappingType.KEY_MACRO.value
|
||||||
|
|
||||||
|
return values
|
||||||
|
|
||||||
|
Config = Cfg
|
||||||
|
|
||||||
|
|
||||||
|
class Mapping(UIMapping):
|
||||||
|
"""Holds all the data for mapping an input action to an output action.
|
||||||
|
|
||||||
|
This implements the missing validations from UIMapping.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Override Required attributes to enforce they are set
|
||||||
|
input_combination: InputCombination
|
||||||
|
target_uinput: KnownUinput
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_combination(
|
||||||
|
cls,
|
||||||
|
input_combination=None,
|
||||||
|
target_uinput="keyboard",
|
||||||
|
output_symbol="a",
|
||||||
|
):
|
||||||
|
"""Convenient function to get a valid mapping."""
|
||||||
|
if not input_combination:
|
||||||
|
input_combination = [{"type": 99, "code": 99, "analog_threshold": 99}]
|
||||||
|
|
||||||
|
return cls(
|
||||||
|
input_combination=input_combination,
|
||||||
|
target_uinput=target_uinput,
|
||||||
|
output_symbol=output_symbol,
|
||||||
|
)
|
||||||
|
|
||||||
|
def is_valid(self) -> bool:
|
||||||
|
"""If the mapping is valid."""
|
||||||
|
return True
|
||||||
|
|
||||||
|
@root_validator(pre=True)
|
||||||
|
def validate_symbol(cls, values):
|
||||||
|
"""Parse a macro to check for syntax errors."""
|
||||||
|
symbol = values.get("output_symbol")
|
||||||
|
|
||||||
|
if symbol == "":
|
||||||
|
values["output_symbol"] = None
|
||||||
|
return values
|
||||||
|
|
||||||
|
if symbol is None:
|
||||||
|
return values
|
||||||
|
|
||||||
|
symbol = symbol.strip()
|
||||||
|
values["output_symbol"] = symbol
|
||||||
|
|
||||||
|
if symbol == DISABLE_NAME:
|
||||||
|
return values
|
||||||
|
|
||||||
|
if Parser.is_this_a_macro(symbol):
|
||||||
|
mapping_mock = namedtuple("Mapping", values.keys())(**values)
|
||||||
|
# raises MacroError
|
||||||
|
Parser.parse(symbol, mapping=mapping_mock, verbose=False)
|
||||||
|
return values
|
||||||
|
|
||||||
|
code = keyboard_layout.get(symbol)
|
||||||
|
if code is None:
|
||||||
|
raise OutputSymbolUnknownError(symbol)
|
||||||
|
|
||||||
|
target = values.get("target_uinput")
|
||||||
|
if target is not None and not GlobalUInputs.can_default_uinput_emit(
|
||||||
|
target, EV_KEY, code
|
||||||
|
):
|
||||||
|
raise SymbolNotAvailableInTargetError(symbol, target)
|
||||||
|
|
||||||
|
return values
|
||||||
|
|
||||||
|
@validator("input_combination")
|
||||||
|
def only_one_analog_input(cls, combination) -> InputCombination:
|
||||||
|
"""Check that the input_combination specifies a maximum of one
|
||||||
|
analog to analog mapping
|
||||||
|
"""
|
||||||
|
analog_events = [event for event in combination if event.defines_analog_input]
|
||||||
|
if len(analog_events) > 1:
|
||||||
|
raise OnlyOneAnalogInputError(analog_events)
|
||||||
|
|
||||||
|
return combination
|
||||||
|
|
||||||
|
@validator("input_combination")
|
||||||
|
def trigger_point_in_range(cls, combination: InputCombination) -> InputCombination:
|
||||||
|
"""Check if the trigger point for mapping analog axis to buttons is valid."""
|
||||||
|
for input_config in combination:
|
||||||
|
if (
|
||||||
|
input_config.type == EV_ABS
|
||||||
|
and input_config.analog_threshold
|
||||||
|
and abs(input_config.analog_threshold) >= 100
|
||||||
|
):
|
||||||
|
raise TriggerPointInRangeError(input_config)
|
||||||
|
return combination
|
||||||
|
|
||||||
|
@root_validator
|
||||||
|
def validate_output_symbol_variant(cls, values):
|
||||||
|
"""Validate that either type and code or symbol are set for key output."""
|
||||||
|
o_symbol = values.get("output_symbol")
|
||||||
|
o_type = values.get("output_type")
|
||||||
|
o_code = values.get("output_code")
|
||||||
|
if o_symbol is None and (o_type is None or o_code is None):
|
||||||
|
raise OutputSymbolVariantError()
|
||||||
|
return values
|
||||||
|
|
||||||
|
@root_validator
|
||||||
|
def validate_output_integrity(cls, values):
|
||||||
|
"""Validate the output key configuration."""
|
||||||
|
symbol = values.get("output_symbol")
|
||||||
|
type_ = values.get("output_type")
|
||||||
|
code = values.get("output_code")
|
||||||
|
if symbol is None:
|
||||||
|
# If symbol is "", then validate_symbol changes it to None
|
||||||
|
# type and code can be anything
|
||||||
|
return values
|
||||||
|
|
||||||
|
if type_ is None and code is None:
|
||||||
|
# we have a symbol: no type and code is fine
|
||||||
|
return values
|
||||||
|
|
||||||
|
if Parser.is_this_a_macro(symbol):
|
||||||
|
# disallow output type and code for macros
|
||||||
|
if type_ is not None or code is not None:
|
||||||
|
raise MacroButTypeOrCodeSetError()
|
||||||
|
|
||||||
|
if code is not None and code != keyboard_layout.get(symbol) or type_ != EV_KEY:
|
||||||
|
raise SymbolAndCodeMismatchError(symbol, code)
|
||||||
|
return values
|
||||||
|
|
||||||
|
@root_validator
|
||||||
|
def output_matches_input(cls, values: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
"""Validate that an output type is an axis if we have an input axis.
|
||||||
|
And vice versa."""
|
||||||
|
assert isinstance(values.get("input_combination"), InputCombination)
|
||||||
|
combination: InputCombination = values["input_combination"]
|
||||||
|
|
||||||
|
analog_input_config = combination.find_analog_input_config()
|
||||||
|
defines_analog_input = analog_input_config is not None
|
||||||
|
output_type = values.get("output_type")
|
||||||
|
output_code = values.get("output_code")
|
||||||
|
mapping_type = values.get("mapping_type")
|
||||||
|
output_symbol = values.get("output_symbol")
|
||||||
|
output_key_set = output_symbol or (output_type == EV_KEY and output_code)
|
||||||
|
|
||||||
|
if mapping_type is None:
|
||||||
|
# Empty mapping most likely
|
||||||
|
return values
|
||||||
|
|
||||||
|
if not defines_analog_input and mapping_type != MappingType.KEY_MACRO.value:
|
||||||
|
raise WrongMappingTypeForKeyError()
|
||||||
|
|
||||||
|
if not defines_analog_input and not output_key_set:
|
||||||
|
raise MissingMacroOrKeyError()
|
||||||
|
|
||||||
|
if (
|
||||||
|
defines_analog_input
|
||||||
|
and output_type not in (EV_ABS, EV_REL)
|
||||||
|
and output_symbol != DISABLE_NAME
|
||||||
|
):
|
||||||
|
raise MissingOutputAxisError(analog_input_config, output_type)
|
||||||
|
|
||||||
|
return values
|
||||||
|
|
||||||
|
|
||||||
|
class MappingData(UIMapping):
|
||||||
|
"""Like UIMapping, but can be sent over the message broker."""
|
||||||
|
|
||||||
|
Config = ImmutableCfg
|
||||||
|
message_type = MessageType.mapping # allow this to be sent over the MessageBroker
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return str(self.dict(exclude_defaults=True))
|
||||||
|
|
||||||
|
def dict(self, *args, **kwargs):
|
||||||
|
"""Will not include the message_type."""
|
||||||
|
dict_ = super().dict(*args, **kwargs)
|
||||||
|
if "message_type" in dict_:
|
||||||
|
del dict_["message_type"]
|
||||||
|
return dict_
|
||||||
548
inputremapper/configs/migrations.py
Normal file
548
inputremapper/configs/migrations.py
Normal file
@ -0,0 +1,548 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# input-remapper - GUI for device specific keyboard mappings
|
||||||
|
# Copyright (C) 2025 sezanzeb <b8x45ygc9@mozmail.com>
|
||||||
|
#
|
||||||
|
# This file is part of input-remapper.
|
||||||
|
#
|
||||||
|
# input-remapper is free software: you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU General Public License as published by
|
||||||
|
# the Free Software Foundation, either version 3 of the License, or
|
||||||
|
# (at your option) any later version.
|
||||||
|
#
|
||||||
|
# input-remapper is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU General Public License
|
||||||
|
# along with input-remapper. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
"""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
|
||||||
157
inputremapper/configs/paths.py
Normal file
157
inputremapper/configs/paths.py
Normal file
@ -0,0 +1,157 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# input-remapper - GUI for device specific keyboard mappings
|
||||||
|
# Copyright (C) 2025 sezanzeb <b8x45ygc9@mozmail.com>
|
||||||
|
#
|
||||||
|
# This file is part of input-remapper.
|
||||||
|
#
|
||||||
|
# input-remapper is free software: you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU General Public License as published by
|
||||||
|
# the Free Software Foundation, either version 3 of the License, or
|
||||||
|
# (at your option) any later version.
|
||||||
|
#
|
||||||
|
# input-remapper is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU General Public License
|
||||||
|
# along with input-remapper. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
# TODO: convert everything to use pathlib.Path
|
||||||
|
|
||||||
|
"""Path constants to be used."""
|
||||||
|
|
||||||
|
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
from typing import List, Union, Optional
|
||||||
|
|
||||||
|
from inputremapper.logging.logger import logger
|
||||||
|
from inputremapper.user import UserUtils
|
||||||
|
|
||||||
|
|
||||||
|
# TODO maybe this could become, idk, ConfigService and PresetService
|
||||||
|
class PathUtils:
|
||||||
|
@staticmethod
|
||||||
|
def config_path() -> str:
|
||||||
|
# TODO when proper DI is being done, construct PathUtils and configure it
|
||||||
|
# in the constructor. Then there is no need to recompute the config_path
|
||||||
|
# each time. Tests might have overwritten UserUtils.home.
|
||||||
|
xdg_config_home = os.getenv(
|
||||||
|
"XDG_CONFIG_HOME", os.path.join(UserUtils.home, ".config")
|
||||||
|
)
|
||||||
|
return os.path.join(xdg_config_home, "input-remapper-2")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def chown(path):
|
||||||
|
"""Set the owner of a path to the user."""
|
||||||
|
try:
|
||||||
|
logger.debug('Chown "%s", "%s"', path, UserUtils.user)
|
||||||
|
shutil.chown(path, user=UserUtils.user, group=UserUtils.user)
|
||||||
|
except LookupError:
|
||||||
|
# the users group was unknown in one case for whatever reason
|
||||||
|
shutil.chown(path, user=UserUtils.user)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def touch(path: Union[str, os.PathLike], log=True):
|
||||||
|
"""Create an empty file and all its parent dirs, give it to the user."""
|
||||||
|
if str(path).endswith("/"):
|
||||||
|
raise ValueError(f"Expected path to not end with a slash: {path}")
|
||||||
|
|
||||||
|
if os.path.exists(path):
|
||||||
|
return
|
||||||
|
|
||||||
|
if log:
|
||||||
|
logger.info('Creating file "%s"', path)
|
||||||
|
|
||||||
|
PathUtils.mkdir(os.path.dirname(path), log=False)
|
||||||
|
|
||||||
|
os.mknod(path)
|
||||||
|
PathUtils.chown(path)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def mkdir(path, log=True):
|
||||||
|
"""Create a folder, give it to the user."""
|
||||||
|
if path == "" or path is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
if os.path.exists(path):
|
||||||
|
return
|
||||||
|
|
||||||
|
if log:
|
||||||
|
logger.info('Creating dir "%s"', path)
|
||||||
|
|
||||||
|
# give all newly created folders to the user.
|
||||||
|
# e.g. if .config/input-remapper/mouse/ is created the latter two
|
||||||
|
base = os.path.split(path)[0]
|
||||||
|
PathUtils.mkdir(base, log=False)
|
||||||
|
|
||||||
|
os.makedirs(path)
|
||||||
|
PathUtils.chown(path)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def split_all(path: Union[os.PathLike, str]) -> List[str]:
|
||||||
|
"""Split the path into its segments."""
|
||||||
|
parts = []
|
||||||
|
while True:
|
||||||
|
path, tail = os.path.split(path)
|
||||||
|
parts.append(tail)
|
||||||
|
if path == os.path.sep:
|
||||||
|
# we arrived at the root '/'
|
||||||
|
parts.append(path)
|
||||||
|
break
|
||||||
|
if not path:
|
||||||
|
# arrived at start of relative path
|
||||||
|
break
|
||||||
|
|
||||||
|
parts.reverse()
|
||||||
|
return parts
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def remove(path):
|
||||||
|
"""Remove whatever is at the path."""
|
||||||
|
if not os.path.exists(path):
|
||||||
|
return
|
||||||
|
|
||||||
|
if os.path.isdir(path):
|
||||||
|
shutil.rmtree(path)
|
||||||
|
else:
|
||||||
|
os.remove(path)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def sanitize_path_component(group_name: str) -> str:
|
||||||
|
"""Replace characters listed in
|
||||||
|
https://en.wikipedia.org/wiki/Filename#Reserved_characters_and_words
|
||||||
|
with an underscore.
|
||||||
|
"""
|
||||||
|
for character in '/\\?%*:|"<>':
|
||||||
|
if character in group_name:
|
||||||
|
group_name = group_name.replace(character, "_")
|
||||||
|
return group_name
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_preset_path(group_name: Optional[str] = None, preset: Optional[str] = None):
|
||||||
|
"""Get a path to the stored preset, or to store a preset to."""
|
||||||
|
presets_base = os.path.join(PathUtils.config_path(), "presets")
|
||||||
|
|
||||||
|
if group_name is None:
|
||||||
|
return presets_base
|
||||||
|
|
||||||
|
group_name = PathUtils.sanitize_path_component(group_name)
|
||||||
|
|
||||||
|
if preset is not None:
|
||||||
|
# the extension of the preset should not be shown in the ui.
|
||||||
|
# if a .json extension arrives this place, it has not been
|
||||||
|
# stripped away properly prior to this.
|
||||||
|
if not preset.endswith(".json"):
|
||||||
|
preset = f"{preset}.json"
|
||||||
|
|
||||||
|
if preset is None:
|
||||||
|
return os.path.join(presets_base, group_name)
|
||||||
|
|
||||||
|
return os.path.join(presets_base, group_name, preset)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_config_path(*paths) -> str:
|
||||||
|
"""Get a path in ~/.config/input-remapper/."""
|
||||||
|
return os.path.join(PathUtils.config_path(), *paths)
|
||||||
325
inputremapper/configs/preset.py
Normal file
325
inputremapper/configs/preset.py
Normal file
@ -0,0 +1,325 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# input-remapper - GUI for device specific keyboard mappings
|
||||||
|
# Copyright (C) 2025 sezanzeb <b8x45ygc9@mozmail.com>
|
||||||
|
#
|
||||||
|
# This file is part of input-remapper.
|
||||||
|
#
|
||||||
|
# input-remapper is free software: you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU General Public License as published by
|
||||||
|
# the Free Software Foundation, either version 3 of the License, or
|
||||||
|
# (at your option) any later version.
|
||||||
|
#
|
||||||
|
# input-remapper is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU General Public License
|
||||||
|
# along with input-remapper. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
"""Contains and manages mappings."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from typing import (
|
||||||
|
Tuple,
|
||||||
|
Dict,
|
||||||
|
List,
|
||||||
|
Optional,
|
||||||
|
Iterator,
|
||||||
|
Type,
|
||||||
|
TypeVar,
|
||||||
|
Generic,
|
||||||
|
overload,
|
||||||
|
)
|
||||||
|
|
||||||
|
from evdev import ecodes
|
||||||
|
|
||||||
|
from inputremapper.configs.input_config import InputCombination, InputConfig
|
||||||
|
from inputremapper.configs.mapping import Mapping, UIMapping
|
||||||
|
from inputremapper.configs.paths import PathUtils
|
||||||
|
from inputremapper.logging.logger import logger
|
||||||
|
|
||||||
|
MappingModel = TypeVar("MappingModel", bound=UIMapping)
|
||||||
|
|
||||||
|
|
||||||
|
class Preset(Generic[MappingModel]):
|
||||||
|
"""Contains and manages mappings of a single preset."""
|
||||||
|
|
||||||
|
# workaround for typing: https://github.com/python/mypy/issues/4236
|
||||||
|
@overload
|
||||||
|
def __init__(self: Preset[Mapping], path: Optional[os.PathLike] = None): ...
|
||||||
|
|
||||||
|
@overload
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
path: Optional[os.PathLike] = None,
|
||||||
|
mapping_factory: Type[MappingModel] = ...,
|
||||||
|
): ...
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
path: Optional[os.PathLike] = None,
|
||||||
|
mapping_factory=Mapping,
|
||||||
|
) -> None:
|
||||||
|
self._mappings: Dict[InputCombination, MappingModel] = {}
|
||||||
|
# a copy of mappings for keeping track of changes
|
||||||
|
self._saved_mappings: Dict[InputCombination, MappingModel] = {}
|
||||||
|
self._path: Optional[os.PathLike] = path
|
||||||
|
|
||||||
|
# the mapping class which is used by load()
|
||||||
|
self._mapping_factory: Type[MappingModel] = mapping_factory
|
||||||
|
|
||||||
|
def __iter__(self) -> Iterator[MappingModel]:
|
||||||
|
"""Iterate over Mapping objects."""
|
||||||
|
return iter(self._mappings.copy().values())
|
||||||
|
|
||||||
|
def __len__(self) -> int:
|
||||||
|
return len(self._mappings)
|
||||||
|
|
||||||
|
def __bool__(self):
|
||||||
|
# otherwise __len__ will be used which results in False for a preset
|
||||||
|
# without mappings
|
||||||
|
return True
|
||||||
|
|
||||||
|
def has_unsaved_changes(self) -> bool:
|
||||||
|
"""Check if there are unsaved changed."""
|
||||||
|
return self._mappings != self._saved_mappings
|
||||||
|
|
||||||
|
def remove(self, combination: InputCombination) -> None:
|
||||||
|
"""Remove a mapping from the preset by providing the InputCombination."""
|
||||||
|
|
||||||
|
if not isinstance(combination, InputCombination):
|
||||||
|
raise TypeError(
|
||||||
|
f"combination must by of type InputCombination, got {type(combination)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
for permutation in combination.get_permutations():
|
||||||
|
if permutation in self._mappings.keys():
|
||||||
|
combination = permutation
|
||||||
|
break
|
||||||
|
try:
|
||||||
|
mapping = self._mappings.pop(combination)
|
||||||
|
mapping.remove_combination_changed_callback()
|
||||||
|
except KeyError:
|
||||||
|
logger.debug(
|
||||||
|
"unable to remove non-existing mapping with combination = %s",
|
||||||
|
combination,
|
||||||
|
)
|
||||||
|
pass
|
||||||
|
|
||||||
|
def add(self, mapping: MappingModel) -> None:
|
||||||
|
"""Add a mapping to the preset."""
|
||||||
|
for permutation in mapping.input_combination.get_permutations():
|
||||||
|
if permutation in self._mappings:
|
||||||
|
raise KeyError(
|
||||||
|
"A mapping with this input_combination: "
|
||||||
|
f"{permutation} already exists",
|
||||||
|
)
|
||||||
|
|
||||||
|
mapping.set_combination_changed_callback(self._combination_changed_callback)
|
||||||
|
self._mappings[mapping.input_combination] = mapping
|
||||||
|
|
||||||
|
def empty(self) -> None:
|
||||||
|
"""Remove all mappings and custom configs without saving.
|
||||||
|
note: self.has_unsaved_changes() will report True
|
||||||
|
"""
|
||||||
|
for mapping in self._mappings.values():
|
||||||
|
mapping.remove_combination_changed_callback()
|
||||||
|
self._mappings = {}
|
||||||
|
|
||||||
|
def clear(self) -> None:
|
||||||
|
"""Remove all mappings and also self.path."""
|
||||||
|
self.empty()
|
||||||
|
self._saved_mappings = {}
|
||||||
|
self.path = None
|
||||||
|
|
||||||
|
def load(self) -> None:
|
||||||
|
"""Load from the mapping from the disc, clears all existing mappings."""
|
||||||
|
logger.info('Loading preset from "%s"', self.path)
|
||||||
|
|
||||||
|
if not self.path or not os.path.exists(self.path):
|
||||||
|
raise FileNotFoundError(f'Tried to load non-existing preset "{self.path}"')
|
||||||
|
|
||||||
|
self._saved_mappings = self._get_mappings_from_disc()
|
||||||
|
self.empty()
|
||||||
|
for mapping in self._saved_mappings.values():
|
||||||
|
# use the public add method to make sure
|
||||||
|
# the _combination_changed_callback is attached
|
||||||
|
self.add(mapping.copy())
|
||||||
|
|
||||||
|
def _is_mapped_multiple_times(self, input_combination: InputCombination) -> bool:
|
||||||
|
"""Check if the event combination maps to multiple mappings."""
|
||||||
|
all_input_combinations = {mapping.input_combination for mapping in self}
|
||||||
|
permutations = set(input_combination.get_permutations())
|
||||||
|
union = permutations & all_input_combinations
|
||||||
|
# if there are more than one matches, then there is a duplicate
|
||||||
|
return len(union) > 1
|
||||||
|
|
||||||
|
def _has_valid_input_combination(self, mapping: UIMapping) -> bool:
|
||||||
|
"""Check if the mapping has a valid input event combination."""
|
||||||
|
is_a_combination = isinstance(mapping.input_combination, InputCombination)
|
||||||
|
is_empty = mapping.input_combination == InputCombination.empty_combination()
|
||||||
|
return is_a_combination and not is_empty
|
||||||
|
|
||||||
|
def save(self) -> None:
|
||||||
|
"""Dump as JSON to self.path."""
|
||||||
|
|
||||||
|
if not self.path:
|
||||||
|
logger.debug("unable to save preset without a path set Preset.path first")
|
||||||
|
return
|
||||||
|
|
||||||
|
PathUtils.touch(self.path)
|
||||||
|
if not self.has_unsaved_changes():
|
||||||
|
logger.debug("Not saving unchanged preset")
|
||||||
|
return
|
||||||
|
|
||||||
|
logger.info("Saving preset to %s", self.path)
|
||||||
|
|
||||||
|
preset_list = []
|
||||||
|
saved_mappings = {}
|
||||||
|
for mapping in self:
|
||||||
|
if not mapping.is_valid():
|
||||||
|
if not self._has_valid_input_combination(mapping):
|
||||||
|
# we save invalid mappings except for those with an invalid
|
||||||
|
# input_combination
|
||||||
|
logger.debug("Skipping invalid mapping %s", mapping)
|
||||||
|
continue
|
||||||
|
|
||||||
|
if self._is_mapped_multiple_times(mapping.input_combination):
|
||||||
|
# todo: is this ever executed? it should not be possible to
|
||||||
|
# reach this
|
||||||
|
logger.debug(
|
||||||
|
"skipping mapping with duplicate event combination %s",
|
||||||
|
mapping,
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
mapping_dict = mapping.dict(exclude_defaults=True)
|
||||||
|
mapping_dict["input_combination"] = mapping.input_combination.to_config()
|
||||||
|
combination = mapping.input_combination
|
||||||
|
preset_list.append(mapping_dict)
|
||||||
|
|
||||||
|
saved_mappings[combination] = mapping.copy()
|
||||||
|
saved_mappings[combination].remove_combination_changed_callback()
|
||||||
|
|
||||||
|
with open(self.path, "w") as file:
|
||||||
|
json.dump(preset_list, file, indent=4)
|
||||||
|
file.write("\n")
|
||||||
|
|
||||||
|
self._saved_mappings = saved_mappings
|
||||||
|
|
||||||
|
def is_valid(self) -> bool:
|
||||||
|
return False not in [mapping.is_valid() for mapping in self]
|
||||||
|
|
||||||
|
def get_mapping(
|
||||||
|
self, combination: Optional[InputCombination]
|
||||||
|
) -> Optional[MappingModel]:
|
||||||
|
"""Return the Mapping that is mapped to this InputCombination."""
|
||||||
|
if not combination:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if not isinstance(combination, InputCombination):
|
||||||
|
raise TypeError(
|
||||||
|
f"combination must by of type InputCombination, got {type(combination)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
for permutation in combination.get_permutations():
|
||||||
|
existing = self._mappings.get(permutation)
|
||||||
|
if existing is not None:
|
||||||
|
return existing
|
||||||
|
return None
|
||||||
|
|
||||||
|
def dangerously_mapped_btn_left(self) -> bool:
|
||||||
|
"""Return True if this mapping disables BTN_Left."""
|
||||||
|
if (ecodes.EV_KEY, ecodes.BTN_LEFT) not in [
|
||||||
|
m.input_combination[0].type_and_code for m in self
|
||||||
|
]:
|
||||||
|
return False
|
||||||
|
|
||||||
|
values: List[str | Tuple[int, int] | None] = []
|
||||||
|
for mapping in self:
|
||||||
|
if mapping.output_symbol is None:
|
||||||
|
continue
|
||||||
|
values.append(mapping.output_symbol.lower())
|
||||||
|
values.append(mapping.get_output_type_code())
|
||||||
|
|
||||||
|
return (
|
||||||
|
"btn_left" not in values
|
||||||
|
or InputConfig.btn_left().type_and_code not in values
|
||||||
|
)
|
||||||
|
|
||||||
|
def _combination_changed_callback(
|
||||||
|
self, new: InputCombination, old: InputCombination
|
||||||
|
) -> None:
|
||||||
|
for permutation in new.get_permutations():
|
||||||
|
if permutation in self._mappings.keys() and permutation != old:
|
||||||
|
raise KeyError("combination already exists in the preset")
|
||||||
|
self._mappings[new] = self._mappings.pop(old)
|
||||||
|
|
||||||
|
def _update_saved_mappings(self) -> None:
|
||||||
|
if self.path is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
if not os.path.exists(self.path):
|
||||||
|
self._saved_mappings = {}
|
||||||
|
return
|
||||||
|
self._saved_mappings = self._get_mappings_from_disc()
|
||||||
|
|
||||||
|
def _get_mappings_from_disc(self) -> Dict[InputCombination, MappingModel]:
|
||||||
|
mappings: Dict[InputCombination, MappingModel] = {}
|
||||||
|
if not self.path:
|
||||||
|
logger.debug("unable to read preset without a path set Preset.path first")
|
||||||
|
return mappings
|
||||||
|
|
||||||
|
if os.stat(self.path).st_size == 0:
|
||||||
|
logger.debug("got empty file")
|
||||||
|
return mappings
|
||||||
|
|
||||||
|
with open(self.path, "r") as file:
|
||||||
|
try:
|
||||||
|
preset_list = json.load(file)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
logger.error("unable to decode json file: %s", self.path)
|
||||||
|
return mappings
|
||||||
|
|
||||||
|
for mapping_dict in preset_list:
|
||||||
|
if not isinstance(mapping_dict, dict):
|
||||||
|
logger.error(
|
||||||
|
"Expected mapping to be a dict: %s %s",
|
||||||
|
type(mapping_dict),
|
||||||
|
mapping_dict,
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
mapping = self._mapping_factory(**mapping_dict)
|
||||||
|
except Exception as error:
|
||||||
|
logger.error(
|
||||||
|
"failed to Validate mapping for %s: %s",
|
||||||
|
mapping_dict.get("input_combination"),
|
||||||
|
error,
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
mappings[mapping.input_combination] = mapping
|
||||||
|
return mappings
|
||||||
|
|
||||||
|
@property
|
||||||
|
def path(self) -> Optional[os.PathLike]:
|
||||||
|
return self._path
|
||||||
|
|
||||||
|
@path.setter
|
||||||
|
def path(self, path: Optional[os.PathLike]):
|
||||||
|
if path != self.path:
|
||||||
|
self._path = path
|
||||||
|
self._update_saved_mappings()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def name(self) -> Optional[str]:
|
||||||
|
"""The name of the preset."""
|
||||||
|
if self.path:
|
||||||
|
return os.path.basename(self.path).split(".")[0]
|
||||||
|
return None
|
||||||
147
inputremapper/configs/validation_errors.py
Normal file
147
inputremapper/configs/validation_errors.py
Normal 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
548
inputremapper/daemon.py
Normal file
@ -0,0 +1,548 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# input-remapper - GUI for device specific keyboard mappings
|
||||||
|
# Copyright (C) 2025 sezanzeb <b8x45ygc9@mozmail.com>
|
||||||
|
#
|
||||||
|
# This file is part of input-remapper.
|
||||||
|
#
|
||||||
|
# input-remapper is free software: you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU General Public License as published by
|
||||||
|
# the Free Software Foundation, either version 3 of the License, or
|
||||||
|
# (at your option) any later version.
|
||||||
|
#
|
||||||
|
# input-remapper is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU General Public License
|
||||||
|
# along with input-remapper. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
|
||||||
|
"""Starts injecting keycodes based on the configuration.
|
||||||
|
|
||||||
|
https://github.com/dasbus-project/dasbus/tree/master/examples/03_helloworld
|
||||||
|
"""
|
||||||
|
|
||||||
|
import atexit
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
from pathlib import PurePath
|
||||||
|
from typing import Dict, Optional, Protocol
|
||||||
|
|
||||||
|
import gi
|
||||||
|
from dasbus.error import DBusError
|
||||||
|
from dasbus.connection import SystemMessageBus
|
||||||
|
from dasbus.identifier import DBusServiceIdentifier
|
||||||
|
from dasbus.loop import EventLoop
|
||||||
|
|
||||||
|
from inputremapper.configs.global_config import GlobalConfig
|
||||||
|
from inputremapper.configs.keyboard_layout import keyboard_layout
|
||||||
|
from inputremapper.configs.paths import PathUtils
|
||||||
|
from inputremapper.configs.preset import Preset
|
||||||
|
from inputremapper.groups import groups
|
||||||
|
from inputremapper.injection.global_uinputs import GlobalUInputs
|
||||||
|
from inputremapper.injection.injector import Injector, InjectorState
|
||||||
|
from inputremapper.injection.macros.macro import macro_variables
|
||||||
|
from inputremapper.injection.mapping_handlers.mapping_parser import MappingParser
|
||||||
|
from inputremapper.logging.logger import logger
|
||||||
|
from inputremapper.user import UserUtils
|
||||||
|
|
||||||
|
gi.require_version("GLib", "2.0")
|
||||||
|
|
||||||
|
|
||||||
|
SYSTEM_BUS = SystemMessageBus()
|
||||||
|
|
||||||
|
DAEMON = DBusServiceIdentifier(
|
||||||
|
namespace=("inputremapper", "Control"),
|
||||||
|
message_bus=SYSTEM_BUS,
|
||||||
|
)
|
||||||
|
|
||||||
|
# timeout in milliseconds
|
||||||
|
BUS_TIMEOUT = 10000
|
||||||
|
|
||||||
|
|
||||||
|
class AutoloadHistory:
|
||||||
|
"""Contains the autoloading history and constraints."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
"""Construct this with an empty history."""
|
||||||
|
# preset of device -> (timestamp, preset)
|
||||||
|
self._autoload_history = {}
|
||||||
|
|
||||||
|
def remember(self, group_key: str, preset: str):
|
||||||
|
"""Remember when this preset was autoloaded for the device."""
|
||||||
|
self._autoload_history[group_key] = (time.time(), preset)
|
||||||
|
|
||||||
|
def forget(self, group_key: str):
|
||||||
|
"""The injection was stopped or started by hand."""
|
||||||
|
if group_key in self._autoload_history:
|
||||||
|
del self._autoload_history[group_key]
|
||||||
|
|
||||||
|
def may_autoload(self, group_key: str, preset: str):
|
||||||
|
"""Check if this autoload would be redundant.
|
||||||
|
|
||||||
|
This is needed because udev triggers multiple times per hardware
|
||||||
|
device, and because it should be possible to stop the injection
|
||||||
|
by unplugging the device if the preset goes wrong or if input-remapper
|
||||||
|
has some bug that prevents the computer from being controlled.
|
||||||
|
|
||||||
|
For that unplug and reconnect the device twice within a 15 seconds
|
||||||
|
timeframe which will then not ask for autoloading again. Wait 3
|
||||||
|
seconds between replugging.
|
||||||
|
"""
|
||||||
|
if group_key not in self._autoload_history:
|
||||||
|
return True
|
||||||
|
|
||||||
|
if self._autoload_history[group_key][1] != preset:
|
||||||
|
return True
|
||||||
|
|
||||||
|
# bluetooth devices go to standby mode after some time. After a
|
||||||
|
# certain time of being disconnected it should be legit to autoload
|
||||||
|
# again. It takes 2.5 seconds for me when quickly replugging my usb
|
||||||
|
# mouse until the daemon is asked to autoload again. Redundant calls
|
||||||
|
# by udev to autoload for the device seem to happen within 0.2
|
||||||
|
# seconds in my case.
|
||||||
|
now = time.time()
|
||||||
|
threshold = 15 # seconds
|
||||||
|
if self._autoload_history[group_key][0] < now - threshold:
|
||||||
|
return True
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
class DaemonProxy(Protocol): # pragma: no cover
|
||||||
|
"""The interface provided over the dbus."""
|
||||||
|
|
||||||
|
def stop_injecting(self, group_key: str) -> None: ...
|
||||||
|
|
||||||
|
def get_state(self, group_key: str) -> InjectorState: ...
|
||||||
|
|
||||||
|
def start_injecting(self, group_key: str, preset: str) -> bool: ...
|
||||||
|
|
||||||
|
def stop_all(self) -> None: ...
|
||||||
|
|
||||||
|
def set_config_dir(self, config_dir: str) -> None: ...
|
||||||
|
|
||||||
|
def autoload(self) -> None: ...
|
||||||
|
|
||||||
|
def autoload_single(self, group_key: str) -> None: ...
|
||||||
|
|
||||||
|
def hello(self, out: str) -> str: ...
|
||||||
|
|
||||||
|
def quit(self) -> None: ...
|
||||||
|
|
||||||
|
|
||||||
|
class Daemon:
|
||||||
|
"""Starts injecting keycodes based on the configuration.
|
||||||
|
|
||||||
|
Can be talked to either over dbus or by instantiating it.
|
||||||
|
|
||||||
|
The Daemon may not have any knowledge about the logged in user, so it
|
||||||
|
can't read any config files. It has to be told what to do and will
|
||||||
|
continue to do so afterwards, but it can't decide to start injecting
|
||||||
|
on its own.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# https://dbus.freedesktop.org/doc/dbus-specification.html#type-system
|
||||||
|
__dbus_xml__ = f"""
|
||||||
|
<node>
|
||||||
|
<interface name='{DAEMON.interface_name}'>
|
||||||
|
<method name='stop_injecting'>
|
||||||
|
<arg type='s' name='group_key' direction='in'/>
|
||||||
|
</method>
|
||||||
|
<method name='get_state'>
|
||||||
|
<arg type='s' name='group_key' direction='in'/>
|
||||||
|
<arg type='s' name='response' direction='out'/>
|
||||||
|
</method>
|
||||||
|
<method name='start_injecting'>
|
||||||
|
<arg type='s' name='group_key' direction='in'/>
|
||||||
|
<arg type='s' name='preset' direction='in'/>
|
||||||
|
<arg type='b' name='response' direction='out'/>
|
||||||
|
</method>
|
||||||
|
<method name='stop_all'>
|
||||||
|
</method>
|
||||||
|
<method name='set_config_dir'>
|
||||||
|
<arg type='s' name='config_dir' direction='in'/>
|
||||||
|
</method>
|
||||||
|
<method name='autoload'>
|
||||||
|
</method>
|
||||||
|
<method name='autoload_single'>
|
||||||
|
<arg type='s' name='group_key' direction='in'/>
|
||||||
|
</method>
|
||||||
|
<method name='hello'>
|
||||||
|
<arg type='s' name='out' direction='in'/>
|
||||||
|
<arg type='s' name='response' direction='out'/>
|
||||||
|
</method>
|
||||||
|
<method name='quit'>
|
||||||
|
</method>
|
||||||
|
</interface>
|
||||||
|
</node>
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
global_config: GlobalConfig,
|
||||||
|
global_uinputs: GlobalUInputs,
|
||||||
|
mapping_parser: MappingParser,
|
||||||
|
) -> None:
|
||||||
|
"""Constructs the daemon."""
|
||||||
|
logger.debug("Creating daemon")
|
||||||
|
|
||||||
|
self.global_config = global_config
|
||||||
|
self.global_uinputs = global_uinputs
|
||||||
|
self.mapping_parser = mapping_parser
|
||||||
|
|
||||||
|
self.injectors: Dict[str, Injector] = {}
|
||||||
|
|
||||||
|
self.config_dir = None
|
||||||
|
|
||||||
|
if UserUtils.user != "root":
|
||||||
|
# If started via sudo, UserUtils.user is the actual user, and the config
|
||||||
|
# path is valid. If running as a systemd service, it is root, and the path
|
||||||
|
# invalid.
|
||||||
|
self.set_config_dir(PathUtils.get_config_path())
|
||||||
|
|
||||||
|
# check privileges
|
||||||
|
if os.getuid() != 0:
|
||||||
|
logger.warning("The service usually needs elevated privileges")
|
||||||
|
|
||||||
|
self.autoload_history = AutoloadHistory()
|
||||||
|
self.refreshed_devices_at = 0
|
||||||
|
|
||||||
|
atexit.register(self.stop_all)
|
||||||
|
|
||||||
|
# initialize stuff that is needed alongside the daemon process
|
||||||
|
macro_variables.start()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def connect(cls, fallback: bool = True) -> Optional[DaemonProxy]:
|
||||||
|
"""Get a proxy to start and stop injecting keystrokes.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
fallback
|
||||||
|
If true, starts the daemon via pkexec if it cannot connect.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
proxy = DAEMON.get_proxy()
|
||||||
|
# Calling proxy.Introspect to check if the proxy is running
|
||||||
|
# https://dbus.freedesktop.org/doc/dbus-tutorial.html#introspection
|
||||||
|
proxy.Introspect(timeout=BUS_TIMEOUT)
|
||||||
|
logger.info("Connected to the service")
|
||||||
|
except DBusError as error:
|
||||||
|
if not fallback:
|
||||||
|
logger.error("Service not running? %s", error)
|
||||||
|
return None
|
||||||
|
|
||||||
|
logger.info("Starting the service")
|
||||||
|
# Blocks until pkexec is done asking for the password.
|
||||||
|
# Runs via input-remapper-control so that auth_admin_keep works
|
||||||
|
# for all pkexec calls of the gui
|
||||||
|
debug = " -d" if logger.is_debug() else ""
|
||||||
|
cmd = f"pkexec input-remapper-control --command start-daemon {debug}"
|
||||||
|
|
||||||
|
# using pkexec will also cause the service to continue running in
|
||||||
|
# the background after the gui has been closed, which will keep
|
||||||
|
# the injections ongoing
|
||||||
|
|
||||||
|
logger.debug("Running `%s`", cmd)
|
||||||
|
os.system(cmd)
|
||||||
|
time.sleep(0.2)
|
||||||
|
|
||||||
|
# try a few times if the service was just started
|
||||||
|
for attempt in range(3):
|
||||||
|
try:
|
||||||
|
proxy = DAEMON.get_proxy()
|
||||||
|
proxy.Introspect(timeout=BUS_TIMEOUT)
|
||||||
|
break
|
||||||
|
except DBusError as error:
|
||||||
|
logger.debug("Attempt %d to reach the service failed:", attempt + 1)
|
||||||
|
logger.debug('"%s"', error)
|
||||||
|
time.sleep(0.2)
|
||||||
|
else:
|
||||||
|
logger.error("Failed to connect to the service")
|
||||||
|
sys.exit(8)
|
||||||
|
|
||||||
|
if UserUtils.user != "root":
|
||||||
|
config_path = PathUtils.get_config_path()
|
||||||
|
logger.debug('Telling service about "%s"', config_path)
|
||||||
|
proxy.set_config_dir(PathUtils.get_config_path(), timeout=2000)
|
||||||
|
|
||||||
|
return proxy
|
||||||
|
|
||||||
|
def publish(self) -> None:
|
||||||
|
"""Make the dbus interface available."""
|
||||||
|
try:
|
||||||
|
SYSTEM_BUS.publish_object(DAEMON.object_path, self)
|
||||||
|
SYSTEM_BUS.register_service(DAEMON.service_name)
|
||||||
|
except ConnectionError as error:
|
||||||
|
logger.error("Is the service already running? (%s)", str(error))
|
||||||
|
sys.exit(9)
|
||||||
|
|
||||||
|
def run(self) -> None:
|
||||||
|
"""Start the daemons loop. Blocks until the daemon stops."""
|
||||||
|
loop = EventLoop()
|
||||||
|
logger.debug("Running daemon")
|
||||||
|
loop.run()
|
||||||
|
|
||||||
|
def refresh(self, group_key: Optional[str] = None) -> None:
|
||||||
|
"""Refresh groups if the specified group is unknown.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
group_key
|
||||||
|
unique identifier used by the groups object
|
||||||
|
"""
|
||||||
|
now = time.time()
|
||||||
|
if now - 10 > self.refreshed_devices_at:
|
||||||
|
logger.debug("Refreshing because last info is too old")
|
||||||
|
# it may take a bit of time until devices are visible after changes
|
||||||
|
time.sleep(0.1)
|
||||||
|
groups.refresh()
|
||||||
|
self.refreshed_devices_at = now
|
||||||
|
return
|
||||||
|
|
||||||
|
if not groups.find(key=group_key):
|
||||||
|
logger.debug('Refreshing because "%s" is unknown', group_key)
|
||||||
|
time.sleep(0.1)
|
||||||
|
groups.refresh()
|
||||||
|
self.refreshed_devices_at = now
|
||||||
|
|
||||||
|
def stop_injecting(self, group_key: str) -> None:
|
||||||
|
"""Stop injecting the preset mappings for a single device."""
|
||||||
|
if self.injectors.get(group_key) is None:
|
||||||
|
logger.debug(
|
||||||
|
'Tried to stop injector, but none is running for group "%s"',
|
||||||
|
group_key,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
self.injectors[group_key].stop_injecting()
|
||||||
|
self.autoload_history.forget(group_key)
|
||||||
|
|
||||||
|
def get_state(self, group_key: str) -> InjectorState:
|
||||||
|
"""Get the injectors state."""
|
||||||
|
injector = self.injectors.get(group_key)
|
||||||
|
return injector.get_state() if injector else InjectorState.UNKNOWN
|
||||||
|
|
||||||
|
def set_config_dir(self, config_dir: str) -> None:
|
||||||
|
"""All future operations will use this config dir.
|
||||||
|
|
||||||
|
Existing injections (possibly of the previous user) will be kept
|
||||||
|
alive, call stop_all to stop them.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
config_dir
|
||||||
|
This path contains config.json, xmodmap.json and the
|
||||||
|
presets directory
|
||||||
|
"""
|
||||||
|
config_path = PurePath(config_dir, "config.json")
|
||||||
|
if not os.path.exists(config_path):
|
||||||
|
logger.error('"%s" does not exist', config_path)
|
||||||
|
return
|
||||||
|
|
||||||
|
self.config_dir = config_dir
|
||||||
|
self.global_config.load_config(str(config_path))
|
||||||
|
|
||||||
|
def _autoload(self, group_key: str) -> None:
|
||||||
|
"""Check if autoloading is a good idea, and if so do it.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
group_key
|
||||||
|
unique identifier used by the groups object
|
||||||
|
"""
|
||||||
|
self.refresh(group_key)
|
||||||
|
|
||||||
|
group = groups.find(key=group_key)
|
||||||
|
if group is None:
|
||||||
|
# even after groups.refresh, the device is unknown, so it's
|
||||||
|
# either not relevant for input-remapper, or not connected yet
|
||||||
|
return
|
||||||
|
|
||||||
|
preset = self.global_config.get_autoload_preset(group.key)
|
||||||
|
|
||||||
|
if preset is None:
|
||||||
|
# no autoloading is configured for this device
|
||||||
|
return
|
||||||
|
|
||||||
|
if not isinstance(preset, str):
|
||||||
|
# maybe another dict or something, who knows. Broken config
|
||||||
|
logger.error("Expected a string for autoload, but got %s", preset)
|
||||||
|
return
|
||||||
|
|
||||||
|
logger.info('Autoloading for "%s"', group.key)
|
||||||
|
|
||||||
|
if not self.autoload_history.may_autoload(group.key, preset):
|
||||||
|
logger.info(
|
||||||
|
'Not autoloading the same preset "%s" again for group "%s"',
|
||||||
|
preset,
|
||||||
|
group.key,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
self.start_injecting(group.key, preset)
|
||||||
|
self.autoload_history.remember(group.key, preset)
|
||||||
|
|
||||||
|
def autoload_single(self, group_key: str) -> None:
|
||||||
|
"""Inject the configured autoload preset for the device.
|
||||||
|
|
||||||
|
If the preset is already being injected, it won't autoload it again.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
group_key
|
||||||
|
unique identifier used by the groups object
|
||||||
|
"""
|
||||||
|
# avoid some confusing logs and filter obviously invalid requests
|
||||||
|
if group_key.startswith("input-remapper"):
|
||||||
|
return
|
||||||
|
|
||||||
|
logger.info('Request to autoload for "%s"', group_key)
|
||||||
|
|
||||||
|
if self.config_dir is None:
|
||||||
|
logger.error(
|
||||||
|
'Request to autoload "%s" before a user told the service about their '
|
||||||
|
"session using set_config_dir",
|
||||||
|
group_key,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
self._autoload(group_key)
|
||||||
|
|
||||||
|
def autoload(self) -> None:
|
||||||
|
"""Load all autoloaded presets for the current config_dir.
|
||||||
|
|
||||||
|
If the preset is already being injected, it won't autoload it again.
|
||||||
|
"""
|
||||||
|
if self.config_dir is None:
|
||||||
|
logger.error(
|
||||||
|
"Request to autoload all before a user told the service about their "
|
||||||
|
"session using set_config_dir",
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
autoload_presets = list(self.global_config.iterate_autoload_presets())
|
||||||
|
|
||||||
|
logger.info("Autoloading for all devices")
|
||||||
|
|
||||||
|
if len(autoload_presets) == 0:
|
||||||
|
logger.error("No presets configured to autoload")
|
||||||
|
return
|
||||||
|
|
||||||
|
for group_key, _ in autoload_presets:
|
||||||
|
self._autoload(group_key)
|
||||||
|
|
||||||
|
def start_injecting(self, group_key: str, preset_name: str) -> bool:
|
||||||
|
"""Start injecting the preset for the device.
|
||||||
|
|
||||||
|
Returns True on success. If an injection is already ongoing for
|
||||||
|
the specified device it will stop it automatically first.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
group_key
|
||||||
|
The unique key of the group
|
||||||
|
preset_name
|
||||||
|
The name of the preset
|
||||||
|
"""
|
||||||
|
logger.info('Request to start injecting for "%s"', group_key)
|
||||||
|
|
||||||
|
self.refresh(group_key)
|
||||||
|
|
||||||
|
if self.config_dir is None:
|
||||||
|
logger.error(
|
||||||
|
"Request to start an injectoin before a user told the service about "
|
||||||
|
"their session using set_config_dir",
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
group = groups.find(key=group_key)
|
||||||
|
|
||||||
|
if group is None:
|
||||||
|
logger.error('Could not find group "%s"', group_key)
|
||||||
|
return False
|
||||||
|
|
||||||
|
preset_path = PurePath(
|
||||||
|
self.config_dir,
|
||||||
|
"presets",
|
||||||
|
PathUtils.sanitize_path_component(group.name),
|
||||||
|
f"{preset_name}.json",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Path to a dump of the xkb mappings, to provide more human
|
||||||
|
# readable keys in the correct keyboard layout to the service.
|
||||||
|
# The service cannot use `xmodmap -pke` because it's running via
|
||||||
|
# systemd.
|
||||||
|
xmodmap_path = os.path.join(self.config_dir, "xmodmap.json")
|
||||||
|
try:
|
||||||
|
with open(xmodmap_path, "r") as file:
|
||||||
|
# do this for each injection to make sure it is up to
|
||||||
|
# date when the system layout changes.
|
||||||
|
xmodmap = json.load(file)
|
||||||
|
logger.debug('Using keycodes from "%s"', xmodmap_path)
|
||||||
|
|
||||||
|
# this creates the keyboard_layout._xmodmap, which we need to do now
|
||||||
|
# otherwise it might be created later which will override the changes
|
||||||
|
# we do here.
|
||||||
|
# Do we really need to lazyload in the keyboard_layout?
|
||||||
|
# this kind of bug is stupid to track down
|
||||||
|
keyboard_layout.get_name(0)
|
||||||
|
keyboard_layout.update(xmodmap)
|
||||||
|
# the service now has process wide knowledge of xmodmap
|
||||||
|
# keys of the users session
|
||||||
|
except FileNotFoundError:
|
||||||
|
logger.error('Could not find "%s"', xmodmap_path)
|
||||||
|
|
||||||
|
preset = Preset(preset_path)
|
||||||
|
|
||||||
|
try:
|
||||||
|
preset.load()
|
||||||
|
except FileNotFoundError as error:
|
||||||
|
logger.error(str(error))
|
||||||
|
return False
|
||||||
|
|
||||||
|
for mapping in preset:
|
||||||
|
# only create those uinputs that are required to avoid
|
||||||
|
# confusing the system. Seems to be especially important with
|
||||||
|
# gamepads, because some apps treat the first gamepad they found
|
||||||
|
# as the only gamepad they'll ever care about.
|
||||||
|
self.global_uinputs.prepare_single(mapping.target_uinput)
|
||||||
|
|
||||||
|
if self.injectors.get(group_key) is not None:
|
||||||
|
self.stop_injecting(group_key)
|
||||||
|
|
||||||
|
try:
|
||||||
|
injector = Injector(
|
||||||
|
group,
|
||||||
|
preset,
|
||||||
|
self.mapping_parser,
|
||||||
|
)
|
||||||
|
injector.start()
|
||||||
|
self.injectors[group.key] = injector
|
||||||
|
except OSError:
|
||||||
|
# I think this will never happen, probably leftover from
|
||||||
|
# some earlier version
|
||||||
|
return False
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
def stop_all(self) -> None:
|
||||||
|
"""Stop all injections."""
|
||||||
|
logger.info("Stopping all injections")
|
||||||
|
for group_key in list(self.injectors.keys()):
|
||||||
|
self.stop_injecting(group_key)
|
||||||
|
|
||||||
|
def hello(self, out: str) -> str:
|
||||||
|
"""Used for tests."""
|
||||||
|
logger.info('Received "%s" from client', out)
|
||||||
|
return out
|
||||||
|
|
||||||
|
def quit(self) -> None:
|
||||||
|
"""Stop the process."""
|
||||||
|
# Beware, that stop_all will also be called via atexit.register(self.stop_all)
|
||||||
|
logger.info("Got command to stop the daemon process")
|
||||||
|
sys.exit(0)
|
||||||
65
inputremapper/exceptions.py
Normal file
65
inputremapper/exceptions.py
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# input-remapper - GUI for device specific keyboard mappings
|
||||||
|
# Copyright (C) 2025 sezanzeb <b8x45ygc9@mozmail.com>
|
||||||
|
#
|
||||||
|
# This file is part of input-remapper.
|
||||||
|
#
|
||||||
|
# input-remapper is free software: you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU General Public License as published by
|
||||||
|
# the Free Software Foundation, either version 3 of the License, or
|
||||||
|
# (at your option) any later version.
|
||||||
|
#
|
||||||
|
# input-remapper is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU General Public License
|
||||||
|
# along with input-remapper. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
|
||||||
|
"""Exceptions specific to inputremapper."""
|
||||||
|
|
||||||
|
|
||||||
|
class Error(Exception):
|
||||||
|
"""Base class for exceptions in inputremapper.
|
||||||
|
|
||||||
|
We can catch all inputremapper exceptions with this.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class UinputNotAvailable(Error):
|
||||||
|
"""If an expected UInput is not found (anymore)."""
|
||||||
|
|
||||||
|
def __init__(self, name: str):
|
||||||
|
super().__init__(f"{name} is not defined or unplugged")
|
||||||
|
|
||||||
|
|
||||||
|
class EventNotHandled(Error):
|
||||||
|
"""For example mapping to BTN_LEFT on a keyboard target."""
|
||||||
|
|
||||||
|
def __init__(self, event):
|
||||||
|
super().__init__(f"Event {event} can not be handled by the configured target")
|
||||||
|
|
||||||
|
|
||||||
|
class MappingParsingError(Error):
|
||||||
|
"""Anything that goes wrong during the creation of handlers from the mapping."""
|
||||||
|
|
||||||
|
def __init__(self, msg: str, *, mapping=None, mapping_handler=None):
|
||||||
|
self.mapping_handler = mapping_handler
|
||||||
|
self.mapping = mapping
|
||||||
|
super().__init__(msg)
|
||||||
|
|
||||||
|
|
||||||
|
class InputEventCreationError(Error):
|
||||||
|
"""An input-event failed to be created due to broken factory/constructor calls."""
|
||||||
|
|
||||||
|
def __init__(self, msg: str):
|
||||||
|
super().__init__(msg)
|
||||||
|
|
||||||
|
|
||||||
|
class DataManagementError(Error):
|
||||||
|
"""Any error that happens in the DataManager."""
|
||||||
|
|
||||||
|
def __init__(self, msg: str):
|
||||||
|
super().__init__(msg)
|
||||||
20
inputremapper/focus/__init__.py
Normal file
20
inputremapper/focus/__init__.py
Normal 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)."""
|
||||||
20
inputremapper/focus/backends/__init__.py
Normal file
20
inputremapper/focus/backends/__init__.py
Normal 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."""
|
||||||
174
inputremapper/focus/backends/hyprland.py
Normal file
174
inputremapper/focus/backends/hyprland.py
Normal file
@ -0,0 +1,174 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# input-remapper - GUI for device specific keyboard mappings
|
||||||
|
# Copyright (C) 2025 sezanzeb <b8x45ygc9@mozmail.com>
|
||||||
|
#
|
||||||
|
# This file is part of input-remapper.
|
||||||
|
#
|
||||||
|
# input-remapper is free software: you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU General Public License as published by
|
||||||
|
# the Free Software Foundation, either version 3 of the License, or
|
||||||
|
# (at your option) any later version.
|
||||||
|
#
|
||||||
|
# input-remapper is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU General Public License
|
||||||
|
# along with input-remapper. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
"""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,
|
||||||
|
)
|
||||||
55
inputremapper/focus/backends/null.py
Normal file
55
inputremapper/focus/backends/null.py
Normal 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
|
||||||
219
inputremapper/focus/backends/sway.py
Normal file
219
inputremapper/focus/backends/sway.py
Normal 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)
|
||||||
220
inputremapper/focus/backends/wlr_foreign_toplevel.py
Normal file
220
inputremapper/focus/backends/wlr_foreign_toplevel.py
Normal 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
|
||||||
181
inputremapper/focus/backends/xorg.py
Normal file
181
inputremapper/focus/backends/xorg.py
Normal 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
|
||||||
88
inputremapper/focus/focus_backend.py
Normal file
88
inputremapper/focus/focus_backend.py
Normal 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
|
||||||
278
inputremapper/focus/focus_service.py
Normal file
278
inputremapper/focus/focus_service.py
Normal 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]
|
||||||
83
inputremapper/focus/focus_watcher.py
Normal file
83
inputremapper/focus/focus_watcher.py
Normal file
@ -0,0 +1,83 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# input-remapper - GUI for device specific keyboard mappings
|
||||||
|
# Copyright (C) 2025 sezanzeb <b8x45ygc9@mozmail.com>
|
||||||
|
#
|
||||||
|
# This file is part of input-remapper.
|
||||||
|
#
|
||||||
|
# input-remapper is free software: you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU General Public License as published by
|
||||||
|
# the Free Software Foundation, either version 3 of the License, or
|
||||||
|
# (at your option) any later version.
|
||||||
|
#
|
||||||
|
# input-remapper is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU General Public License
|
||||||
|
# along with input-remapper. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
"""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
567
inputremapper/groups.py
Normal file
@ -0,0 +1,567 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# input-remapper - GUI for device specific keyboard mappings
|
||||||
|
# Copyright (C) 2025 sezanzeb <b8x45ygc9@mozmail.com>
|
||||||
|
#
|
||||||
|
# This file is part of input-remapper.
|
||||||
|
#
|
||||||
|
# input-remapper is free software: you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU General Public License as published by
|
||||||
|
# the Free Software Foundation, either version 3 of the License, or
|
||||||
|
# (at your option) any later version.
|
||||||
|
#
|
||||||
|
# input-remapper is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU General Public License
|
||||||
|
# along with input-remapper. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
"""Find, classify and group devices.
|
||||||
|
|
||||||
|
Because usually connected devices pop up multiple times in /dev/input,
|
||||||
|
in order to provide multiple types of input devices (e.g. a keyboard and a
|
||||||
|
graphics-tablet at the same time)
|
||||||
|
|
||||||
|
Those groups are what is being displayed in the device dropdown, and
|
||||||
|
events are being read from all of the paths of an individual group in the gui
|
||||||
|
and the injector.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import enum
|
||||||
|
import json
|
||||||
|
import multiprocessing
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import threading
|
||||||
|
from typing import List, Optional
|
||||||
|
|
||||||
|
import evdev
|
||||||
|
from evdev.ecodes import (
|
||||||
|
EV_KEY,
|
||||||
|
EV_ABS,
|
||||||
|
KEY_CAMERA,
|
||||||
|
EV_REL,
|
||||||
|
BTN_STYLUS,
|
||||||
|
ABS_MT_POSITION_X,
|
||||||
|
REL_X,
|
||||||
|
KEY_A,
|
||||||
|
BTN_LEFT,
|
||||||
|
REL_Y,
|
||||||
|
REL_WHEEL,
|
||||||
|
)
|
||||||
|
|
||||||
|
from inputremapper.configs.paths import PathUtils
|
||||||
|
from inputremapper.logging.logger import logger
|
||||||
|
from inputremapper.utils import get_device_hash
|
||||||
|
|
||||||
|
TABLET_KEYS = [
|
||||||
|
evdev.ecodes.BTN_STYLUS,
|
||||||
|
evdev.ecodes.BTN_TOOL_BRUSH,
|
||||||
|
evdev.ecodes.BTN_TOOL_PEN,
|
||||||
|
evdev.ecodes.BTN_TOOL_RUBBER,
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class DeviceType(str, enum.Enum):
|
||||||
|
GAMEPAD = "gamepad"
|
||||||
|
KEYBOARD = "keyboard"
|
||||||
|
MOUSE = "mouse"
|
||||||
|
TOUCHPAD = "touchpad"
|
||||||
|
GRAPHICS_TABLET = "graphics-tablet"
|
||||||
|
CAMERA = "camera"
|
||||||
|
UNKNOWN = "unknown"
|
||||||
|
|
||||||
|
|
||||||
|
if not hasattr(evdev.InputDevice, "path"):
|
||||||
|
# for evdev < 1.0.0 patch the path property
|
||||||
|
@property
|
||||||
|
def path(device):
|
||||||
|
return device.fn
|
||||||
|
|
||||||
|
evdev.InputDevice.path = path
|
||||||
|
|
||||||
|
|
||||||
|
def _is_gamepad(capabilities):
|
||||||
|
"""Check if joystick movements are available for preset."""
|
||||||
|
# A few buttons that indicate a gamepad
|
||||||
|
buttons = {
|
||||||
|
evdev.ecodes.BTN_BASE,
|
||||||
|
evdev.ecodes.BTN_A,
|
||||||
|
evdev.ecodes.BTN_THUMB,
|
||||||
|
evdev.ecodes.BTN_TOP,
|
||||||
|
evdev.ecodes.BTN_DPAD_DOWN,
|
||||||
|
evdev.ecodes.BTN_GAMEPAD,
|
||||||
|
}
|
||||||
|
if not buttons.intersection(capabilities.get(EV_KEY, [])):
|
||||||
|
# no button is in the key capabilities
|
||||||
|
return False
|
||||||
|
|
||||||
|
# joysticks
|
||||||
|
abs_capabilities = capabilities.get(EV_ABS, [])
|
||||||
|
if evdev.ecodes.ABS_X not in abs_capabilities:
|
||||||
|
return False
|
||||||
|
if evdev.ecodes.ABS_Y not in abs_capabilities:
|
||||||
|
return False
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def _is_mouse(capabilities):
|
||||||
|
"""Check if the capabilities represent those of a mouse."""
|
||||||
|
# Based on observation, those capabilities need to be present to get an
|
||||||
|
# UInput recognized as mouse
|
||||||
|
|
||||||
|
# mouse movements
|
||||||
|
if REL_X not in capabilities.get(EV_REL, []):
|
||||||
|
return False
|
||||||
|
if REL_Y not in capabilities.get(EV_REL, []):
|
||||||
|
return False
|
||||||
|
|
||||||
|
# at least the vertical mouse wheel
|
||||||
|
if REL_WHEEL not in capabilities.get(EV_REL, []):
|
||||||
|
return False
|
||||||
|
|
||||||
|
# and a mouse click button
|
||||||
|
if BTN_LEFT not in capabilities.get(EV_KEY, []):
|
||||||
|
return False
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def _is_graphics_tablet(capabilities):
|
||||||
|
"""Check if the capabilities represent those of a graphics tablet."""
|
||||||
|
if BTN_STYLUS in capabilities.get(EV_KEY, []):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _is_touchpad(capabilities):
|
||||||
|
"""Check if the capabilities represent those of a touchpad."""
|
||||||
|
if ABS_MT_POSITION_X in capabilities.get(EV_ABS, []):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _is_keyboard(capabilities):
|
||||||
|
"""Check if the capabilities represent those of a keyboard."""
|
||||||
|
if KEY_A in capabilities.get(EV_KEY, []):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _is_camera(capabilities):
|
||||||
|
"""Check if the capabilities represent those of a camera."""
|
||||||
|
key_capa = capabilities.get(EV_KEY)
|
||||||
|
return key_capa and len(key_capa) == 1 and key_capa[0] == KEY_CAMERA
|
||||||
|
|
||||||
|
|
||||||
|
def classify(device) -> DeviceType:
|
||||||
|
"""Figure out what kind of device this is.
|
||||||
|
|
||||||
|
Use this instead of functions like _is_keyboard to avoid getting false
|
||||||
|
positives.
|
||||||
|
"""
|
||||||
|
capabilities = device.capabilities(absinfo=False)
|
||||||
|
|
||||||
|
if _is_graphics_tablet(capabilities):
|
||||||
|
# check this before is_gamepad to avoid classifying abs_x
|
||||||
|
# as joysticks when they are actually stylus positions
|
||||||
|
return DeviceType.GRAPHICS_TABLET
|
||||||
|
|
||||||
|
if _is_touchpad(capabilities):
|
||||||
|
return DeviceType.TOUCHPAD
|
||||||
|
|
||||||
|
if _is_gamepad(capabilities):
|
||||||
|
return DeviceType.GAMEPAD
|
||||||
|
|
||||||
|
if _is_mouse(capabilities):
|
||||||
|
return DeviceType.MOUSE
|
||||||
|
|
||||||
|
if _is_camera(capabilities):
|
||||||
|
return DeviceType.CAMERA
|
||||||
|
|
||||||
|
if _is_keyboard(capabilities):
|
||||||
|
# very low in the chain to avoid classifying most devices
|
||||||
|
# as keyboard, because there are many with ev_key capabilities
|
||||||
|
return DeviceType.KEYBOARD
|
||||||
|
|
||||||
|
return DeviceType.UNKNOWN
|
||||||
|
|
||||||
|
|
||||||
|
DENYLIST = [".*Yubico.*YubiKey.*", "Eee PC WMI hotkeys"]
|
||||||
|
|
||||||
|
|
||||||
|
def is_inputremapper_device(device: evdev.InputDevice) -> bool:
|
||||||
|
"""Return whether the device was created by input-remapper."""
|
||||||
|
name = str(device.name or "")
|
||||||
|
phys = str(device.phys or "")
|
||||||
|
return name.startswith("input-remapper") or phys.startswith("input-remapper")
|
||||||
|
|
||||||
|
|
||||||
|
def is_denylisted(device: evdev.InputDevice):
|
||||||
|
"""Check if a device should not be used in input-remapper.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
device
|
||||||
|
"""
|
||||||
|
for name in DENYLIST:
|
||||||
|
if re.match(name, str(device.name), re.IGNORECASE):
|
||||||
|
return True
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def get_unique_key(device: evdev.InputDevice):
|
||||||
|
"""Find a string key that is unique for a single hardware device.
|
||||||
|
|
||||||
|
All InputDevices in /dev/input that originate from the same physical
|
||||||
|
hardware device should return the same key via this function.
|
||||||
|
"""
|
||||||
|
# Keys that should not be used:
|
||||||
|
# - device.phys is empty sometimes and varies across virtual
|
||||||
|
# subdevices
|
||||||
|
# - device.version varies across subdevices
|
||||||
|
return (
|
||||||
|
# device.info bustype, vendor and product are unique for
|
||||||
|
# a product, but multiple similar device models would be grouped
|
||||||
|
# in the same group
|
||||||
|
f"{device.info.bustype}_"
|
||||||
|
f"{device.info.vendor}_"
|
||||||
|
f"{device.info.product}_"
|
||||||
|
# device.uniq is empty most of the time. It seems to be the only way to
|
||||||
|
# distinguish multiple connected bluetooth gamepads
|
||||||
|
f"{device.uniq}_"
|
||||||
|
# deivce.phys if "/input..." is removed from it, because the first
|
||||||
|
# chunk seems to be unique per hardware (if it's not completely empty)
|
||||||
|
f'{device.phys.split("/")[0] or "-"}'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class _Group:
|
||||||
|
"""Groups multiple devnodes together.
|
||||||
|
|
||||||
|
For example, name could be "Logitech USB Keyboard", devices
|
||||||
|
might contain "Logitech USB Keyboard System Control" and "Logitech USB
|
||||||
|
Keyboard". paths is a list of files in /dev/input that belong to the
|
||||||
|
devices.
|
||||||
|
|
||||||
|
They are grouped by usb port.
|
||||||
|
|
||||||
|
Members
|
||||||
|
-------
|
||||||
|
name : str
|
||||||
|
A human readable name, generated from .names, that should always
|
||||||
|
look the same for a device model. It is used to generate the
|
||||||
|
presets folder structure
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
paths: List[os.PathLike],
|
||||||
|
names: List[str],
|
||||||
|
types: List[DeviceType | str],
|
||||||
|
key: str,
|
||||||
|
):
|
||||||
|
"""Specify a group
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
paths
|
||||||
|
Paths in /dev/input of the grouped devices
|
||||||
|
names
|
||||||
|
Names of the grouped devices
|
||||||
|
types
|
||||||
|
Types of the grouped devices
|
||||||
|
key
|
||||||
|
Unique identifier of the group.
|
||||||
|
|
||||||
|
It should be human readable and if possible equal to group.name.
|
||||||
|
To avoid multiple groups having the same key, a number starting
|
||||||
|
with 2 followed by a whitespace should be added to it:
|
||||||
|
"key", "key 2", "key 3", ...
|
||||||
|
|
||||||
|
This is important for the autoloading configuration. If the key
|
||||||
|
changed over reboots, then autoloading would break.
|
||||||
|
"""
|
||||||
|
# There might be multiple groups with the same name here when two
|
||||||
|
# similar devices are connected to the computer.
|
||||||
|
self.name: str = sorted(names, key=len)[0]
|
||||||
|
|
||||||
|
self.key = key
|
||||||
|
|
||||||
|
self.paths = paths
|
||||||
|
self.names = names
|
||||||
|
self.types = [DeviceType(type_) for type_ in types]
|
||||||
|
|
||||||
|
def get_preset_path(self, preset: Optional[str] = None):
|
||||||
|
"""Get a path to the stored preset, or to store a preset to.
|
||||||
|
|
||||||
|
This path is unique per device-model, not per group. Groups
|
||||||
|
of the same model share the same preset paths.
|
||||||
|
"""
|
||||||
|
return PathUtils.get_preset_path(self.name, preset)
|
||||||
|
|
||||||
|
def get_devices(self) -> List[evdev.InputDevice]:
|
||||||
|
devices: List[evdev.InputDevice] = []
|
||||||
|
for path in self.paths:
|
||||||
|
try:
|
||||||
|
devices.append(evdev.InputDevice(path))
|
||||||
|
except (FileNotFoundError, OSError):
|
||||||
|
logger.error('Could not find "%s"', path)
|
||||||
|
continue
|
||||||
|
return devices
|
||||||
|
|
||||||
|
def dumps(self):
|
||||||
|
"""Return a string representing this object."""
|
||||||
|
return json.dumps(
|
||||||
|
dict(paths=self.paths, names=self.names, types=self.types, key=self.key),
|
||||||
|
)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def loads(cls, serialized: str):
|
||||||
|
"""Load a serialized representation."""
|
||||||
|
group = cls(**json.loads(serialized))
|
||||||
|
return group
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f"<Group ({self.key}) at {hex(id(self))}>"
|
||||||
|
|
||||||
|
|
||||||
|
class _FindGroups(threading.Thread):
|
||||||
|
"""Thread to get the devices that can be worked with.
|
||||||
|
|
||||||
|
Since InputDevice destructors take quite some time, do this
|
||||||
|
asynchronously so that they can take as much time as they want without
|
||||||
|
slowing down the initialization.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, pipe: multiprocessing.Pipe):
|
||||||
|
"""Construct the process.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
pipe
|
||||||
|
used to communicate the result
|
||||||
|
"""
|
||||||
|
self.pipe = pipe
|
||||||
|
super().__init__()
|
||||||
|
|
||||||
|
def run(self):
|
||||||
|
"""Do what get_groups describes."""
|
||||||
|
# evdev needs asyncio to work
|
||||||
|
loop = asyncio.new_event_loop()
|
||||||
|
asyncio.set_event_loop(loop)
|
||||||
|
|
||||||
|
logger.debug("Discovering device paths")
|
||||||
|
|
||||||
|
# group them together by usb device because there could be stuff like
|
||||||
|
# "Logitech USB Keyboard" and "Logitech USB Keyboard Consumer Control"
|
||||||
|
grouped = {}
|
||||||
|
|
||||||
|
# Sorting the paths is extremely important if two devices of the same make
|
||||||
|
# (two identical mice for example) are connected. Without sorting, the order
|
||||||
|
# depends on the order of plugging devices in. This causes the most recently
|
||||||
|
# plugged in device to get the first group.
|
||||||
|
# Without sorting:
|
||||||
|
# - mouse1 /input1 plugged in. group.key: "Mouse". Autoloads as configured.
|
||||||
|
# - mouse2 /input2 plugged in. group.key: "Mouse". mouse1 gets a different
|
||||||
|
# group.key: "Mouse 2", even though an injection is running there. Bug.
|
||||||
|
# - I believe this is what causes input-remapper to stop the injection for
|
||||||
|
# mouse1 and then start it for mouse2 instead. So plugging in a second
|
||||||
|
# device breaks autoloading. Sorting fixes it.
|
||||||
|
# With sorting, mouse1 always gets group.key "Mouse", and mouse2 always
|
||||||
|
# "Mouse 2" (Unless only mouse2 is plugged in, then it gets "Mouse")
|
||||||
|
for path in sorted(evdev.list_devices()):
|
||||||
|
try:
|
||||||
|
device = evdev.InputDevice(path)
|
||||||
|
except Exception as error:
|
||||||
|
# Observed exceptions in journalctl:
|
||||||
|
# - "SystemError: <built-in function ioctl_EVIOCGVERSION> returned NULL
|
||||||
|
# without setting an error"
|
||||||
|
# - "FileNotFoundError: [Errno 2] No such file or directory:
|
||||||
|
# '/dev/input/event12'"
|
||||||
|
logger.error(
|
||||||
|
'Failed to access path "%s": %s %s',
|
||||||
|
path,
|
||||||
|
error.__class__.__name__,
|
||||||
|
str(error),
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
if device.name == "Power Button":
|
||||||
|
continue
|
||||||
|
|
||||||
|
if is_inputremapper_device(device):
|
||||||
|
logger.debug('Skipping input-remapper device "%s"', device.name)
|
||||||
|
continue
|
||||||
|
|
||||||
|
device_type = classify(device)
|
||||||
|
|
||||||
|
if device_type == DeviceType.CAMERA:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# https://www.kernel.org/doc/html/latest/input/event-codes.html
|
||||||
|
capabilities = device.capabilities(absinfo=False)
|
||||||
|
|
||||||
|
key_capa = capabilities.get(EV_KEY)
|
||||||
|
abs_capa = capabilities.get(EV_ABS)
|
||||||
|
rel_capa = capabilities.get(EV_REL)
|
||||||
|
|
||||||
|
if key_capa is None and abs_capa is None and rel_capa is None:
|
||||||
|
# skip devices that don't provide buttons or axes that can be mapped
|
||||||
|
logger.debug('"%s" has no useful capabilities', device.name)
|
||||||
|
continue
|
||||||
|
|
||||||
|
if is_denylisted(device):
|
||||||
|
logger.debug('"%s" is denylisted', device.name)
|
||||||
|
continue
|
||||||
|
|
||||||
|
key = get_unique_key(device)
|
||||||
|
if grouped.get(key) is None:
|
||||||
|
grouped[key] = []
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
'Found %s "%s" at "%s", hash "%s", key "%s"',
|
||||||
|
device_type.value,
|
||||||
|
device.name,
|
||||||
|
path,
|
||||||
|
get_device_hash(device),
|
||||||
|
key,
|
||||||
|
)
|
||||||
|
|
||||||
|
grouped[key].append((device.name, path, device_type))
|
||||||
|
|
||||||
|
# now write down all the paths of that group
|
||||||
|
result = []
|
||||||
|
used_keys = set()
|
||||||
|
for group in grouped.values():
|
||||||
|
names = [entry[0] for entry in group]
|
||||||
|
devs = [entry[1] for entry in group]
|
||||||
|
|
||||||
|
# generate a human readable key
|
||||||
|
shortest_name = sorted(names, key=len)[0]
|
||||||
|
key = shortest_name
|
||||||
|
i = 2
|
||||||
|
while key in used_keys:
|
||||||
|
key = f"{shortest_name} {i}"
|
||||||
|
i += 1
|
||||||
|
used_keys.add(key)
|
||||||
|
|
||||||
|
logger.debug('Creating group with key "%s", paths "%s"', key, devs)
|
||||||
|
group = _Group(
|
||||||
|
key=key,
|
||||||
|
paths=devs,
|
||||||
|
names=names,
|
||||||
|
types=sorted(
|
||||||
|
list({item[2] for item in group if item[2] != DeviceType.UNKNOWN})
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
result.append(group.dumps())
|
||||||
|
|
||||||
|
self.pipe.send(json.dumps(result))
|
||||||
|
loop.close() # avoid resource allocation warnings
|
||||||
|
# now that everything is sent via the pipe, the InputDevice
|
||||||
|
# destructors can go on and take ages to complete in the thread
|
||||||
|
# without blocking anything
|
||||||
|
|
||||||
|
|
||||||
|
class _Groups:
|
||||||
|
"""Contains and manages all groups."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self._groups: List[_Group] = None
|
||||||
|
|
||||||
|
def refresh(self):
|
||||||
|
"""Look for devices and group them together.
|
||||||
|
|
||||||
|
Since this needs to do some stuff with /dev and spawn processes the
|
||||||
|
result is cached. Use refresh_groups if you need up to date
|
||||||
|
devices.
|
||||||
|
"""
|
||||||
|
pipe = multiprocessing.Pipe()
|
||||||
|
_FindGroups(pipe[1]).start()
|
||||||
|
# block until groups are available
|
||||||
|
message = pipe[0].recv()
|
||||||
|
self.loads(message)
|
||||||
|
|
||||||
|
if len(self._groups) == 0:
|
||||||
|
logger.error(
|
||||||
|
"Did not find any input device, possibly due to missing permissions"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
keys = [f'"{group.key}"' for group in self._groups]
|
||||||
|
logger.info("Found %s", ", ".join(keys))
|
||||||
|
|
||||||
|
def get_groups(self) -> List[_Group]:
|
||||||
|
"""Load groups and return them."""
|
||||||
|
if self._groups is None:
|
||||||
|
# To lazy load group info only when needed.
|
||||||
|
# For example, this helps to keep logs of input-remapper-control clear when
|
||||||
|
# it doesn't need it the information.
|
||||||
|
self.refresh()
|
||||||
|
|
||||||
|
return list(self._groups)
|
||||||
|
|
||||||
|
def set_groups(self, new_groups: List[_Group]):
|
||||||
|
"""Overwrite all groups."""
|
||||||
|
logger.debug("Overwriting groups with %s", new_groups)
|
||||||
|
self._groups = new_groups
|
||||||
|
|
||||||
|
def list_group_names(self) -> List[str]:
|
||||||
|
"""Return a list of all 'name' properties of the groups."""
|
||||||
|
return [
|
||||||
|
group.name
|
||||||
|
for group in self._groups
|
||||||
|
if not group.name.startswith("input-remapper")
|
||||||
|
]
|
||||||
|
|
||||||
|
def dumps(self):
|
||||||
|
"""Create a deserializable string representation."""
|
||||||
|
groups = self.get_groups()
|
||||||
|
return json.dumps([group.dumps() for group in groups])
|
||||||
|
|
||||||
|
def loads(self, dump: str):
|
||||||
|
"""Load a serialized representation created via dumps."""
|
||||||
|
self._groups = [_Group.loads(group) for group in json.loads(dump)]
|
||||||
|
|
||||||
|
def find(
|
||||||
|
self,
|
||||||
|
name: Optional[str] = None,
|
||||||
|
key: Optional[str] = None,
|
||||||
|
path: Optional[str] = None,
|
||||||
|
) -> Optional[_Group]:
|
||||||
|
"""Find a group that matches the provided parameters.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
name
|
||||||
|
"USB Keyboard"
|
||||||
|
Not unique, will return the first group that matches.
|
||||||
|
key
|
||||||
|
"USB Keyboard", "USB Keyboard 2", ...
|
||||||
|
path
|
||||||
|
"/dev/input/event3"
|
||||||
|
"""
|
||||||
|
for group in self.get_groups():
|
||||||
|
if name and group.name != name:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if key and group.key != key:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if path and path not in group.paths:
|
||||||
|
continue
|
||||||
|
|
||||||
|
return group
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# TODO global objects are bad practice
|
||||||
|
groups = _Groups()
|
||||||
6
inputremapper/gui/__init__.py
Normal file
6
inputremapper/gui/__init__.py
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
import gi
|
||||||
|
|
||||||
|
gi.require_version("Gdk", "3.0")
|
||||||
|
gi.require_version("Gtk", "3.0")
|
||||||
|
gi.require_version("GLib", "2.0")
|
||||||
|
gi.require_version("GtkSource", "4")
|
||||||
457
inputremapper/gui/autocompletion.py
Normal file
457
inputremapper/gui/autocompletion.py
Normal file
@ -0,0 +1,457 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# input-remapper - GUI for device specific keyboard mappings
|
||||||
|
# Copyright (C) 2025 sezanzeb <b8x45ygc9@mozmail.com>
|
||||||
|
#
|
||||||
|
# This file is part of input-remapper.
|
||||||
|
#
|
||||||
|
# input-remapper is free software: you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU General Public License as published by
|
||||||
|
# the Free Software Foundation, either version 3 of the License, or
|
||||||
|
# (at your option) any later version.
|
||||||
|
#
|
||||||
|
# input-remapper is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU General Public License
|
||||||
|
# along with input-remapper. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
|
||||||
|
"""Autocompletion for the editor."""
|
||||||
|
|
||||||
|
|
||||||
|
import re
|
||||||
|
from typing import Dict, Optional, List, Tuple
|
||||||
|
|
||||||
|
from evdev.ecodes import EV_KEY
|
||||||
|
from gi.repository import Gdk, Gtk, GLib, GObject
|
||||||
|
|
||||||
|
from inputremapper.configs.keyboard_layout import keyboard_layout, DISABLE_NAME
|
||||||
|
from inputremapper.configs.mapping import MappingData
|
||||||
|
from inputremapper.gui.components.editor import CodeEditor
|
||||||
|
from inputremapper.gui.controller import Controller
|
||||||
|
from inputremapper.gui.messages.message_broker import MessageBroker, MessageType
|
||||||
|
from inputremapper.gui.messages.message_data import UInputsData
|
||||||
|
from inputremapper.gui.utils import debounce
|
||||||
|
from inputremapper.injection.macros.parse import Parser
|
||||||
|
from inputremapper.logging.logger import logger
|
||||||
|
|
||||||
|
# no deprecated shorthand function-names
|
||||||
|
FUNCTION_NAMES = [name for name in Parser.TASK_CLASSES.keys() if len(name) > 1]
|
||||||
|
# no deprecated functions
|
||||||
|
FUNCTION_NAMES.remove("ifeq")
|
||||||
|
|
||||||
|
Capabilities = Dict[int, List]
|
||||||
|
|
||||||
|
|
||||||
|
def _get_left_text(iter_: Gtk.TextIter) -> str:
|
||||||
|
buffer = iter_.get_buffer()
|
||||||
|
result = buffer.get_text(buffer.get_start_iter(), iter_, True)
|
||||||
|
result = Parser.remove_comments(result)
|
||||||
|
result = result.replace("\n", " ")
|
||||||
|
return result.lower()
|
||||||
|
|
||||||
|
|
||||||
|
# regex to search for the beginning of a...
|
||||||
|
PARAMETER = r".*?[(,=+]\s*"
|
||||||
|
FUNCTION_CHAIN = r".*?\)\s*\.\s*"
|
||||||
|
|
||||||
|
|
||||||
|
def get_incomplete_function_name(iter_: Gtk.TextIter) -> str:
|
||||||
|
"""Get the word that is written left to the TextIter."""
|
||||||
|
left_text = _get_left_text(iter_)
|
||||||
|
|
||||||
|
# match foo in:
|
||||||
|
# bar().foo
|
||||||
|
# bar()\n.foo
|
||||||
|
# bar().\nfoo
|
||||||
|
# bar(\nfoo
|
||||||
|
# bar(\nqux=foo
|
||||||
|
# bar(KEY_A,\nfoo
|
||||||
|
# foo
|
||||||
|
match = re.match(rf"(?:{FUNCTION_CHAIN}|{PARAMETER}|^)(\w+)$", left_text)
|
||||||
|
logger.debug('get_incomplete_function_name text: "%s" match: %s', left_text, match)
|
||||||
|
|
||||||
|
if match is None:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
return match[1]
|
||||||
|
|
||||||
|
|
||||||
|
def get_incomplete_parameter(iter_: Gtk.TextIter) -> Optional[str]:
|
||||||
|
"""Get the parameter that is written left to the TextIter."""
|
||||||
|
left_text = _get_left_text(iter_)
|
||||||
|
|
||||||
|
# match foo in:
|
||||||
|
# bar(foo
|
||||||
|
# bar(a=foo
|
||||||
|
# bar(qux, foo
|
||||||
|
# foo
|
||||||
|
# bar + foo
|
||||||
|
match = re.match(rf"(?:{PARAMETER}|^)(\w+)$", left_text)
|
||||||
|
logger.debug('get_incomplete_parameter text: "%s" match: %s', left_text, match)
|
||||||
|
|
||||||
|
if match is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return match[1]
|
||||||
|
|
||||||
|
|
||||||
|
def propose_symbols(text_iter: Gtk.TextIter, codes: List[int]) -> List[Tuple[str, str]]:
|
||||||
|
"""Find key names that match the input at the cursor and are mapped to the codes."""
|
||||||
|
incomplete_name = get_incomplete_parameter(text_iter)
|
||||||
|
|
||||||
|
if incomplete_name is None or len(incomplete_name) <= 1:
|
||||||
|
return []
|
||||||
|
|
||||||
|
incomplete_name = incomplete_name.lower()
|
||||||
|
|
||||||
|
names = list(keyboard_layout.list_names(codes=codes)) + [DISABLE_NAME]
|
||||||
|
|
||||||
|
return [
|
||||||
|
(name, name)
|
||||||
|
for name in names
|
||||||
|
if incomplete_name in name.lower() and incomplete_name != name.lower()
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def propose_function_names(text_iter: Gtk.TextIter) -> List[Tuple[str, str]]:
|
||||||
|
"""Find function names that match the input at the cursor."""
|
||||||
|
incomplete_name = get_incomplete_function_name(text_iter)
|
||||||
|
|
||||||
|
if incomplete_name is None or len(incomplete_name) <= 1:
|
||||||
|
return []
|
||||||
|
|
||||||
|
incomplete_name = incomplete_name.lower()
|
||||||
|
|
||||||
|
# A list of
|
||||||
|
# - ("key", "key(symbol)")
|
||||||
|
# - ("repeat", "repeat(repeats, macro)")
|
||||||
|
# etc.
|
||||||
|
function_names: List[Tuple[str, str]] = []
|
||||||
|
|
||||||
|
for name in FUNCTION_NAMES:
|
||||||
|
if incomplete_name in name.lower() and incomplete_name != name.lower():
|
||||||
|
task_class = Parser.TASK_CLASSES[name]
|
||||||
|
argument_names = task_class.get_macro_argument_names()
|
||||||
|
function_names.append((name, f"{name}({', '.join(argument_names)})"))
|
||||||
|
|
||||||
|
return function_names
|
||||||
|
|
||||||
|
|
||||||
|
class SuggestionLabel(Gtk.Label):
|
||||||
|
"""A label with some extra internal information."""
|
||||||
|
|
||||||
|
__gtype_name__ = "SuggestionLabel"
|
||||||
|
|
||||||
|
def __init__(self, display_name: str, suggestion: str):
|
||||||
|
super().__init__(label=display_name)
|
||||||
|
self.suggestion = suggestion
|
||||||
|
|
||||||
|
|
||||||
|
class Autocompletion(Gtk.Popover):
|
||||||
|
"""Provide keyboard-controllable beautiful autocompletions.
|
||||||
|
|
||||||
|
The one provided via source_view.get_completion() is not very appealing
|
||||||
|
"""
|
||||||
|
|
||||||
|
__gtype_name__ = "Autocompletion"
|
||||||
|
_target_uinput: Optional[str] = None
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
message_broker: MessageBroker,
|
||||||
|
controller: Controller,
|
||||||
|
code_editor: CodeEditor,
|
||||||
|
):
|
||||||
|
"""Create an autocompletion popover.
|
||||||
|
|
||||||
|
It will remain hidden until there is something to autocomplete.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
code_editor
|
||||||
|
The widget that contains the text that should be autocompleted
|
||||||
|
"""
|
||||||
|
super().__init__(
|
||||||
|
# Don't switch the focus to the popover when it shows
|
||||||
|
modal=False,
|
||||||
|
# Always show the popover below the cursor, don't move it to a different
|
||||||
|
# position based on the location within the window
|
||||||
|
constrain_to=Gtk.PopoverConstraint.NONE,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.code_editor = code_editor
|
||||||
|
self.controller = controller
|
||||||
|
self.message_broker = message_broker
|
||||||
|
self._uinputs: Optional[Dict[str, Capabilities]] = None
|
||||||
|
self._target_key_capabilities: List[int] = []
|
||||||
|
|
||||||
|
self.scrolled_window = Gtk.ScrolledWindow(
|
||||||
|
min_content_width=200,
|
||||||
|
max_content_height=200,
|
||||||
|
propagate_natural_width=True,
|
||||||
|
propagate_natural_height=True,
|
||||||
|
)
|
||||||
|
self.list_box = Gtk.ListBox()
|
||||||
|
self.list_box.get_style_context().add_class("transparent")
|
||||||
|
self.scrolled_window.add(self.list_box)
|
||||||
|
|
||||||
|
# row-activated is on-click,
|
||||||
|
# row-selected is when scrolling through it
|
||||||
|
self.list_box.connect(
|
||||||
|
"row-activated",
|
||||||
|
self._on_suggestion_clicked,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.add(self.scrolled_window)
|
||||||
|
|
||||||
|
self.get_style_context().add_class("autocompletion")
|
||||||
|
|
||||||
|
self.set_position(Gtk.PositionType.BOTTOM)
|
||||||
|
|
||||||
|
self.code_editor.gui.connect("key-press-event", self.navigate)
|
||||||
|
|
||||||
|
# add some delay, so that pressing the button in the completion works before
|
||||||
|
# the popover is hidden due to focus-out-event
|
||||||
|
self.code_editor.gui.connect("focus-out-event", self.on_gtk_text_input_unfocus)
|
||||||
|
|
||||||
|
self.code_editor.gui.get_buffer().connect("changed", self.update)
|
||||||
|
|
||||||
|
self.set_position(Gtk.PositionType.BOTTOM)
|
||||||
|
|
||||||
|
self.visible = False
|
||||||
|
|
||||||
|
self.attach_to_events()
|
||||||
|
self.show_all()
|
||||||
|
self.popdown() # hidden by default. this needs to happen after show_all!
|
||||||
|
|
||||||
|
def attach_to_events(self):
|
||||||
|
self.message_broker.subscribe(MessageType.mapping, self._on_mapping_changed)
|
||||||
|
self.message_broker.subscribe(MessageType.uinputs, self._on_uinputs_changed)
|
||||||
|
|
||||||
|
def on_gtk_text_input_unfocus(self, *_):
|
||||||
|
"""The code editor was unfocused."""
|
||||||
|
GLib.timeout_add(100, self.popdown)
|
||||||
|
# "(input-remapper-gtk:97611): Gtk-WARNING **: 16:33:56.464: GtkTextView -
|
||||||
|
# did not receive focus-out-event. If you connect a handler to this signal,
|
||||||
|
# it must return FALSE so the text view gets the event as well"
|
||||||
|
return False
|
||||||
|
|
||||||
|
def navigate(self, _, event: Gdk.EventKey):
|
||||||
|
"""Using the keyboard to select an autocompletion suggestion."""
|
||||||
|
if not self.visible:
|
||||||
|
return
|
||||||
|
|
||||||
|
if event.keyval == Gdk.KEY_Escape:
|
||||||
|
self.popdown()
|
||||||
|
return
|
||||||
|
|
||||||
|
selected_row = self.list_box.get_selected_row()
|
||||||
|
|
||||||
|
if event.keyval not in [Gdk.KEY_Down, Gdk.KEY_Up, Gdk.KEY_Return]:
|
||||||
|
# not one of the keys that controls the autocompletion. Deselect
|
||||||
|
# the row but keep it open
|
||||||
|
self.list_box.select_row(None)
|
||||||
|
return
|
||||||
|
|
||||||
|
if event.keyval == Gdk.KEY_Return:
|
||||||
|
if selected_row is None:
|
||||||
|
# nothing selected, forward the event to the text editor
|
||||||
|
return
|
||||||
|
|
||||||
|
# a row is selected and should be used for autocompletion
|
||||||
|
self.list_box.emit("row-activated", selected_row)
|
||||||
|
return Gdk.EVENT_STOP
|
||||||
|
|
||||||
|
num_rows = len(self.list_box.get_children())
|
||||||
|
|
||||||
|
if selected_row is None:
|
||||||
|
# select the first row
|
||||||
|
if event.keyval == Gdk.KEY_Down:
|
||||||
|
new_selected_row = self.list_box.get_row_at_index(0)
|
||||||
|
|
||||||
|
if event.keyval == Gdk.KEY_Up:
|
||||||
|
new_selected_row = self.list_box.get_row_at_index(num_rows - 1)
|
||||||
|
else:
|
||||||
|
# select the next row
|
||||||
|
selected_index = selected_row.get_index()
|
||||||
|
new_index = selected_index
|
||||||
|
|
||||||
|
if event.keyval == Gdk.KEY_Down:
|
||||||
|
new_index += 1
|
||||||
|
|
||||||
|
if event.keyval == Gdk.KEY_Up:
|
||||||
|
new_index -= 1
|
||||||
|
|
||||||
|
if new_index < 0:
|
||||||
|
new_index = num_rows - 1
|
||||||
|
|
||||||
|
if new_index > num_rows - 1:
|
||||||
|
new_index = 0
|
||||||
|
|
||||||
|
new_selected_row = self.list_box.get_row_at_index(new_index)
|
||||||
|
|
||||||
|
self.list_box.select_row(new_selected_row)
|
||||||
|
|
||||||
|
self._scroll_to_row(new_selected_row)
|
||||||
|
|
||||||
|
# don't change editor contents
|
||||||
|
return Gdk.EVENT_STOP
|
||||||
|
|
||||||
|
def _scroll_to_row(self, row: Gtk.ListBoxRow):
|
||||||
|
"""Scroll up or down so that the row is visible."""
|
||||||
|
# unfortunately, it seems that without focusing the row it won't happen
|
||||||
|
# automatically (or whatever the reason for this is, just a wild guess)
|
||||||
|
# (the focus should not leave the code editor, so that continuing
|
||||||
|
# to write code is possible), so here is a custom solution.
|
||||||
|
row_height = row.get_allocation().height
|
||||||
|
|
||||||
|
list_box_height = self.list_box.get_allocated_height()
|
||||||
|
|
||||||
|
if row:
|
||||||
|
# get coordinate relative to the list_box,
|
||||||
|
# measured from the top of the selected row to the top of the list_box
|
||||||
|
row_y_position = row.translate_coordinates(self.list_box, 0, 0)[1]
|
||||||
|
|
||||||
|
# Depending on the theme, the y_offset will be > 0, even though it
|
||||||
|
# is the uppermost element, due to margins/paddings.
|
||||||
|
if row_y_position < row_height:
|
||||||
|
row_y_position = 0
|
||||||
|
|
||||||
|
# if the selected row sits lower than the second to last row,
|
||||||
|
# then scroll all the way down. otherwise it will only scroll down
|
||||||
|
# to the bottom edge of the selected-row, which might not actually be the
|
||||||
|
# bottom of the list-box due to paddings.
|
||||||
|
if row_y_position > list_box_height - row_height * 1.5:
|
||||||
|
# using a value that is too high doesn't hurt here.
|
||||||
|
row_y_position = list_box_height
|
||||||
|
|
||||||
|
# the visible height of the scrolled_window. not the content.
|
||||||
|
height = self.scrolled_window.get_max_content_height()
|
||||||
|
|
||||||
|
current_y_scroll = self.scrolled_window.get_vadjustment().get_value()
|
||||||
|
|
||||||
|
vadjustment = self.scrolled_window.get_vadjustment()
|
||||||
|
|
||||||
|
# for the selected row to still be visible, its y_offset has to be
|
||||||
|
# at height - row_height. If the y_offset is higher than that, then
|
||||||
|
# the autocompletion needs to scroll down to make it visible again.
|
||||||
|
if row_y_position > current_y_scroll + (height - row_height):
|
||||||
|
value = row_y_position - (height - row_height)
|
||||||
|
vadjustment.set_value(value)
|
||||||
|
|
||||||
|
if row_y_position < current_y_scroll:
|
||||||
|
# the selected element is not visiable, so we need to scroll up.
|
||||||
|
vadjustment.set_value(row_y_position)
|
||||||
|
|
||||||
|
def _get_text_iter_at_cursor(self):
|
||||||
|
"""Get Gtk.TextIter at the current text cursor location."""
|
||||||
|
cursor = self.code_editor.gui.get_cursor_locations()[0]
|
||||||
|
return self.code_editor.gui.get_iter_at_location(cursor.x, cursor.y)[1]
|
||||||
|
|
||||||
|
def popup(self):
|
||||||
|
self.visible = True
|
||||||
|
super().popup()
|
||||||
|
|
||||||
|
def popdown(self):
|
||||||
|
self.visible = False
|
||||||
|
super().popdown()
|
||||||
|
|
||||||
|
@debounce(100)
|
||||||
|
def update(self, *_):
|
||||||
|
"""Find new autocompletion suggestions and display them. Hide if none."""
|
||||||
|
if len(self._target_key_capabilities) == 0:
|
||||||
|
logger.error("No target capabilities available")
|
||||||
|
return
|
||||||
|
|
||||||
|
if not self.code_editor.gui.is_focus():
|
||||||
|
self.popdown()
|
||||||
|
return
|
||||||
|
|
||||||
|
self.list_box.forall(self.list_box.remove)
|
||||||
|
|
||||||
|
# move the autocompletion to the text cursor
|
||||||
|
cursor = self.code_editor.gui.get_cursor_locations()[0]
|
||||||
|
# convert it to window coords, because the cursor values will be very large
|
||||||
|
# when the TextView is in a scrolled down ScrolledWindow.
|
||||||
|
window_coords = self.code_editor.gui.buffer_to_window_coords(
|
||||||
|
Gtk.TextWindowType.TEXT, cursor.x, cursor.y
|
||||||
|
)
|
||||||
|
cursor.x = window_coords.window_x
|
||||||
|
cursor.y = window_coords.window_y
|
||||||
|
cursor.y += 12
|
||||||
|
|
||||||
|
if self.code_editor.gui.get_show_line_numbers():
|
||||||
|
cursor.x += 48
|
||||||
|
|
||||||
|
self.set_pointing_to(cursor)
|
||||||
|
|
||||||
|
text_iter = self._get_text_iter_at_cursor()
|
||||||
|
# get a list of (evdev/xmodmap symbol-name, display-name)
|
||||||
|
suggested_names = propose_function_names(text_iter)
|
||||||
|
suggested_names += propose_symbols(text_iter, self._target_key_capabilities)
|
||||||
|
|
||||||
|
if len(suggested_names) == 0:
|
||||||
|
self.popdown()
|
||||||
|
return
|
||||||
|
|
||||||
|
self.popup() # ffs was this hard to find
|
||||||
|
|
||||||
|
# add visible autocompletion entries
|
||||||
|
for suggestion, display_name in suggested_names:
|
||||||
|
label = SuggestionLabel(display_name, suggestion)
|
||||||
|
self.list_box.insert(label, -1)
|
||||||
|
label.show_all()
|
||||||
|
|
||||||
|
def _update_capabilities(self):
|
||||||
|
if self._target_uinput and self._uinputs:
|
||||||
|
self._target_key_capabilities = self._uinputs[self._target_uinput][EV_KEY]
|
||||||
|
|
||||||
|
def _on_mapping_changed(self, mapping: MappingData):
|
||||||
|
self._target_uinput = mapping.target_uinput
|
||||||
|
self._update_capabilities()
|
||||||
|
|
||||||
|
def _on_uinputs_changed(self, data: UInputsData):
|
||||||
|
self._uinputs = data.uinputs
|
||||||
|
self._update_capabilities()
|
||||||
|
|
||||||
|
def _on_suggestion_clicked(self, _, selected_row):
|
||||||
|
"""An autocompletion suggestion was selected and should be inserted."""
|
||||||
|
selected_label = selected_row.get_children()[0]
|
||||||
|
suggestion = selected_label.suggestion
|
||||||
|
buffer = self.code_editor.gui.get_buffer()
|
||||||
|
|
||||||
|
# make sure to replace the complete unfinished word. Look to the right and
|
||||||
|
# remove whatever there is
|
||||||
|
cursor_iter = self._get_text_iter_at_cursor()
|
||||||
|
right = buffer.get_text(cursor_iter, buffer.get_end_iter(), True)
|
||||||
|
match = re.match(r"^(\w+)", right)
|
||||||
|
right = match[1] if match else ""
|
||||||
|
Gtk.TextView.do_delete_from_cursor(
|
||||||
|
self.code_editor.gui, Gtk.DeleteType.CHARS, len(right)
|
||||||
|
)
|
||||||
|
|
||||||
|
# do the same to the left
|
||||||
|
cursor_iter = self._get_text_iter_at_cursor()
|
||||||
|
left = buffer.get_text(buffer.get_start_iter(), cursor_iter, True)
|
||||||
|
match = re.match(r".*?(\w+)$", re.sub("\n", " ", left))
|
||||||
|
left = match[1] if match else ""
|
||||||
|
Gtk.TextView.do_delete_from_cursor(
|
||||||
|
self.code_editor.gui, Gtk.DeleteType.CHARS, -len(left)
|
||||||
|
)
|
||||||
|
|
||||||
|
# insert the autocompletion
|
||||||
|
Gtk.TextView.do_insert_at_cursor(self.code_editor.gui, suggestion)
|
||||||
|
|
||||||
|
self.emit("suggestion-inserted")
|
||||||
|
|
||||||
|
|
||||||
|
GObject.signal_new(
|
||||||
|
"suggestion-inserted",
|
||||||
|
Autocompletion,
|
||||||
|
GObject.SignalFlags.RUN_FIRST,
|
||||||
|
None,
|
||||||
|
[],
|
||||||
|
)
|
||||||
0
inputremapper/gui/components/__init__.py
Normal file
0
inputremapper/gui/components/__init__.py
Normal file
539
inputremapper/gui/components/app_bindings.py
Normal file
539
inputremapper/gui/components/app_bindings.py
Normal 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
|
||||||
174
inputremapper/gui/components/common.py
Normal file
174
inputremapper/gui/components/common.py
Normal file
@ -0,0 +1,174 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# input-remapper - GUI for device specific keyboard mappings
|
||||||
|
# Copyright (C) 2025 sezanzeb <b8x45ygc9@mozmail.com>
|
||||||
|
#
|
||||||
|
# This file is part of input-remapper.
|
||||||
|
#
|
||||||
|
# input-remapper is free software: you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU General Public License as published by
|
||||||
|
# the Free Software Foundation, either version 3 of the License, or
|
||||||
|
# (at your option) any later version.
|
||||||
|
#
|
||||||
|
# input-remapper is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU General Public License
|
||||||
|
# along with input-remapper. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
|
||||||
|
"""Components used in multiple places."""
|
||||||
|
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|
||||||
|
from gi.repository import Gtk
|
||||||
|
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from inputremapper.configs.mapping import MappingData
|
||||||
|
|
||||||
|
from inputremapper.gui.controller import Controller
|
||||||
|
from inputremapper.gui.messages.message_broker import (
|
||||||
|
MessageBroker,
|
||||||
|
MessageType,
|
||||||
|
)
|
||||||
|
from inputremapper.gui.messages.message_data import GroupData, PresetData
|
||||||
|
from inputremapper.gui.utils import HandlerDisabled
|
||||||
|
|
||||||
|
|
||||||
|
class FlowBoxEntry(Gtk.ToggleButton):
|
||||||
|
"""A device that can be selected in the GUI.
|
||||||
|
|
||||||
|
For example a keyboard or a mouse.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__gtype_name__ = "FlowBoxEntry"
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
message_broker: MessageBroker,
|
||||||
|
controller: Controller,
|
||||||
|
name: str,
|
||||||
|
icon_name: Optional[str] = None,
|
||||||
|
):
|
||||||
|
super().__init__()
|
||||||
|
self.icon_name = icon_name
|
||||||
|
self.message_broker = message_broker
|
||||||
|
self._controller = controller
|
||||||
|
|
||||||
|
box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
|
||||||
|
|
||||||
|
if icon_name:
|
||||||
|
icon = Gtk.Image.new_from_icon_name(icon_name, Gtk.IconSize.DIALOG)
|
||||||
|
box.add(icon)
|
||||||
|
|
||||||
|
label = Gtk.Label()
|
||||||
|
label.set_label(name)
|
||||||
|
self.name = name
|
||||||
|
|
||||||
|
# wrap very long names properly
|
||||||
|
label.set_line_wrap(True)
|
||||||
|
label.set_line_wrap_mode(2)
|
||||||
|
# this affeects how many device entries fit next to each other
|
||||||
|
label.set_width_chars(28)
|
||||||
|
label.set_max_width_chars(28)
|
||||||
|
|
||||||
|
box.add(label)
|
||||||
|
|
||||||
|
box.set_margin_top(18)
|
||||||
|
box.set_margin_bottom(18)
|
||||||
|
box.set_homogeneous(True)
|
||||||
|
box.set_spacing(12)
|
||||||
|
|
||||||
|
# self.set_relief(Gtk.ReliefStyle.NONE)
|
||||||
|
|
||||||
|
self.add(box)
|
||||||
|
|
||||||
|
self.show_all()
|
||||||
|
|
||||||
|
self.connect("toggled", self._on_gtk_toggle)
|
||||||
|
|
||||||
|
def _on_gtk_toggle(self):
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
def show_active(self, active):
|
||||||
|
"""Show the active state without triggering anything."""
|
||||||
|
with HandlerDisabled(self, self._on_gtk_toggle):
|
||||||
|
self.set_active(active)
|
||||||
|
|
||||||
|
|
||||||
|
class FlowBoxWrapper:
|
||||||
|
"""A wrapper for a flowbox that contains FlowBoxEntry widgets."""
|
||||||
|
|
||||||
|
def __init__(self, flowbox: Gtk.FlowBox):
|
||||||
|
self._gui = flowbox
|
||||||
|
|
||||||
|
def show_active_entry(self, name: Optional[str]):
|
||||||
|
"""Activate the togglebutton that matches the name."""
|
||||||
|
for child in self._gui.get_children():
|
||||||
|
flow_box_entry: FlowBoxEntry = child.get_children()[0]
|
||||||
|
flow_box_entry.show_active(flow_box_entry.name == name)
|
||||||
|
|
||||||
|
|
||||||
|
class Breadcrumbs:
|
||||||
|
"""Writes a breadcrumbs string into a given label."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
message_broker: MessageBroker,
|
||||||
|
label: Gtk.Label,
|
||||||
|
show_device_group: bool = False,
|
||||||
|
show_preset: bool = False,
|
||||||
|
show_mapping: bool = False,
|
||||||
|
):
|
||||||
|
self._message_broker = message_broker
|
||||||
|
self._gui = label
|
||||||
|
self._connect_message_listener()
|
||||||
|
|
||||||
|
self.show_device_group = show_device_group
|
||||||
|
self.show_preset = show_preset
|
||||||
|
self.show_mapping = show_mapping
|
||||||
|
|
||||||
|
self._group_key: str = ""
|
||||||
|
self._preset_name: str = ""
|
||||||
|
self._mapping_name: str = ""
|
||||||
|
|
||||||
|
label.set_max_width_chars(50)
|
||||||
|
label.set_line_wrap(True)
|
||||||
|
label.set_line_wrap_mode(2)
|
||||||
|
|
||||||
|
self._render()
|
||||||
|
|
||||||
|
def _connect_message_listener(self):
|
||||||
|
self._message_broker.subscribe(MessageType.group, self._on_group_changed)
|
||||||
|
self._message_broker.subscribe(MessageType.preset, self._on_preset_changed)
|
||||||
|
self._message_broker.subscribe(MessageType.mapping, self._on_mapping_changed)
|
||||||
|
|
||||||
|
def _on_preset_changed(self, data: PresetData):
|
||||||
|
self._preset_name = data.name or ""
|
||||||
|
self._render()
|
||||||
|
|
||||||
|
def _on_group_changed(self, data: GroupData):
|
||||||
|
self._group_key = data.group_key
|
||||||
|
self._render()
|
||||||
|
|
||||||
|
def _on_mapping_changed(self, mapping_data: MappingData):
|
||||||
|
self._mapping_name = mapping_data.format_name()
|
||||||
|
self._render()
|
||||||
|
|
||||||
|
def _render(self):
|
||||||
|
label = []
|
||||||
|
|
||||||
|
if self.show_device_group:
|
||||||
|
label.append(self._group_key or "?")
|
||||||
|
|
||||||
|
if self.show_preset:
|
||||||
|
label.append(self._preset_name or "?")
|
||||||
|
|
||||||
|
if self.show_mapping:
|
||||||
|
label.append(self._mapping_name or "?")
|
||||||
|
|
||||||
|
self._gui.set_label(" / ".join(label))
|
||||||
115
inputremapper/gui/components/device_groups.py
Normal file
115
inputremapper/gui/components/device_groups.py
Normal file
@ -0,0 +1,115 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# input-remapper - GUI for device specific keyboard mappings
|
||||||
|
# Copyright (C) 2025 sezanzeb <b8x45ygc9@mozmail.com>
|
||||||
|
#
|
||||||
|
# This file is part of input-remapper.
|
||||||
|
#
|
||||||
|
# input-remapper is free software: you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU General Public License as published by
|
||||||
|
# the Free Software Foundation, either version 3 of the License, or
|
||||||
|
# (at your option) any later version.
|
||||||
|
#
|
||||||
|
# input-remapper is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU General Public License
|
||||||
|
# along with input-remapper. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from gi.repository import Gtk
|
||||||
|
|
||||||
|
from inputremapper.gui.components.common import FlowBoxEntry, FlowBoxWrapper
|
||||||
|
from inputremapper.gui.components.editor import ICON_PRIORITIES, ICON_NAMES
|
||||||
|
from inputremapper.gui.components.main import Stack
|
||||||
|
from inputremapper.gui.controller import Controller
|
||||||
|
from inputremapper.gui.messages.message_broker import (
|
||||||
|
MessageBroker,
|
||||||
|
MessageType,
|
||||||
|
)
|
||||||
|
from inputremapper.gui.messages.message_data import (
|
||||||
|
GroupsData,
|
||||||
|
GroupData,
|
||||||
|
DoStackSwitch,
|
||||||
|
)
|
||||||
|
from inputremapper.logging.logger import logger
|
||||||
|
|
||||||
|
|
||||||
|
class DeviceGroupEntry(FlowBoxEntry):
|
||||||
|
"""A device that can be selected in the GUI.
|
||||||
|
|
||||||
|
For example a keyboard or a mouse.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__gtype_name__ = "DeviceGroupEntry"
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
message_broker: MessageBroker,
|
||||||
|
controller: Controller,
|
||||||
|
icon_name: Optional[str],
|
||||||
|
group_key: str,
|
||||||
|
):
|
||||||
|
super().__init__(
|
||||||
|
message_broker=message_broker,
|
||||||
|
controller=controller,
|
||||||
|
icon_name=icon_name,
|
||||||
|
name=group_key,
|
||||||
|
)
|
||||||
|
self.group_key = group_key
|
||||||
|
|
||||||
|
def _on_gtk_toggle(self, *_, **__):
|
||||||
|
logger.debug('Selecting device "%s"', self.group_key)
|
||||||
|
self._controller.load_group(self.group_key)
|
||||||
|
self.message_broker.publish(DoStackSwitch(Stack.presets_page))
|
||||||
|
|
||||||
|
|
||||||
|
class DeviceGroupSelection(FlowBoxWrapper):
|
||||||
|
"""A wrapper for the container with our groups.
|
||||||
|
|
||||||
|
A group is a collection of devices.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
message_broker: MessageBroker,
|
||||||
|
controller: Controller,
|
||||||
|
flowbox: Gtk.FlowBox,
|
||||||
|
):
|
||||||
|
super().__init__(flowbox)
|
||||||
|
|
||||||
|
self._message_broker = message_broker
|
||||||
|
self._controller = controller
|
||||||
|
self._gui = flowbox
|
||||||
|
|
||||||
|
self._message_broker.subscribe(MessageType.groups, self._on_groups_changed)
|
||||||
|
self._message_broker.subscribe(MessageType.group, self._on_group_changed)
|
||||||
|
|
||||||
|
def _on_groups_changed(self, data: GroupsData):
|
||||||
|
self._gui.foreach(self._gui.remove)
|
||||||
|
|
||||||
|
for group_key, types in data.groups.items():
|
||||||
|
if len(types) > 0:
|
||||||
|
device_type = sorted(types, key=ICON_PRIORITIES.index)[0]
|
||||||
|
icon_name = ICON_NAMES[device_type]
|
||||||
|
else:
|
||||||
|
icon_name = None
|
||||||
|
|
||||||
|
logger.debug("adding %s to device selection", group_key)
|
||||||
|
device_group_entry = DeviceGroupEntry(
|
||||||
|
self._message_broker,
|
||||||
|
self._controller,
|
||||||
|
icon_name,
|
||||||
|
group_key,
|
||||||
|
)
|
||||||
|
self._gui.insert(device_group_entry, -1)
|
||||||
|
|
||||||
|
if self._controller.data_manager.active_group:
|
||||||
|
self.show_active_entry(self._controller.data_manager.active_group.key)
|
||||||
|
|
||||||
|
def _on_group_changed(self, data: GroupData):
|
||||||
|
self.show_active_entry(data.group_key)
|
||||||
1211
inputremapper/gui/components/editor.py
Normal file
1211
inputremapper/gui/components/editor.py
Normal file
File diff suppressed because it is too large
Load Diff
134
inputremapper/gui/components/main.py
Normal file
134
inputremapper/gui/components/main.py
Normal file
@ -0,0 +1,134 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# input-remapper - GUI for device specific keyboard mappings
|
||||||
|
# Copyright (C) 2025 sezanzeb <b8x45ygc9@mozmail.com>
|
||||||
|
#
|
||||||
|
# This file is part of input-remapper.
|
||||||
|
#
|
||||||
|
# input-remapper is free software: you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU General Public License as published by
|
||||||
|
# the Free Software Foundation, either version 3 of the License, or
|
||||||
|
# (at your option) any later version.
|
||||||
|
#
|
||||||
|
# input-remapper is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU General Public License
|
||||||
|
# along with input-remapper. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
|
||||||
|
"""Components that wrap everything."""
|
||||||
|
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from gi.repository import Gtk, Pango
|
||||||
|
|
||||||
|
from inputremapper.gui.controller import Controller
|
||||||
|
from inputremapper.gui.messages.message_broker import (
|
||||||
|
MessageBroker,
|
||||||
|
MessageType,
|
||||||
|
)
|
||||||
|
from inputremapper.gui.messages.message_data import StatusData, DoStackSwitch
|
||||||
|
from inputremapper.gui.utils import CTX_ERROR, CTX_MAPPING, CTX_WARNING
|
||||||
|
|
||||||
|
|
||||||
|
class Stack:
|
||||||
|
"""Wraps the Stack, which contains the main menu pages."""
|
||||||
|
|
||||||
|
devices_page = 0
|
||||||
|
presets_page = 1
|
||||||
|
editor_page = 2
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
message_broker: MessageBroker,
|
||||||
|
controller: Controller,
|
||||||
|
stack: Gtk.Stack,
|
||||||
|
):
|
||||||
|
self._message_broker = message_broker
|
||||||
|
self._controller = controller
|
||||||
|
self._gui = stack
|
||||||
|
|
||||||
|
self._message_broker.subscribe(
|
||||||
|
MessageType.do_stack_switch, self._do_stack_switch
|
||||||
|
)
|
||||||
|
|
||||||
|
def _do_stack_switch(self, msg: DoStackSwitch):
|
||||||
|
self._gui.set_visible_child(self._gui.get_children()[msg.page_index])
|
||||||
|
|
||||||
|
|
||||||
|
class StatusBar:
|
||||||
|
"""The status bar on the bottom of the main window."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
message_broker: MessageBroker,
|
||||||
|
controller: Controller,
|
||||||
|
status_bar: Gtk.Statusbar,
|
||||||
|
error_icon: Gtk.Image,
|
||||||
|
warning_icon: Gtk.Image,
|
||||||
|
):
|
||||||
|
self._message_broker = message_broker
|
||||||
|
self._controller = controller
|
||||||
|
self._gui = status_bar
|
||||||
|
self._error_icon = error_icon
|
||||||
|
self._warning_icon = warning_icon
|
||||||
|
|
||||||
|
label = self._gui.get_message_area().get_children()[0]
|
||||||
|
label.set_ellipsize(Pango.EllipsizeMode.END)
|
||||||
|
label.set_selectable(True)
|
||||||
|
|
||||||
|
self._message_broker.subscribe(MessageType.status_msg, self._on_status_update)
|
||||||
|
|
||||||
|
# keep track if there is an error or warning in the stack of statusbar
|
||||||
|
# unfortunately this is not exposed over the api
|
||||||
|
self._error = False
|
||||||
|
self._warning = False
|
||||||
|
|
||||||
|
def _on_status_update(self, data: StatusData):
|
||||||
|
"""Show a status message and set its tooltip.
|
||||||
|
|
||||||
|
If message is None, it will remove the newest message of the
|
||||||
|
given context_id.
|
||||||
|
"""
|
||||||
|
context_id = data.ctx_id
|
||||||
|
message = data.msg
|
||||||
|
tooltip = data.tooltip
|
||||||
|
status_bar = self._gui
|
||||||
|
|
||||||
|
if message is None:
|
||||||
|
status_bar.remove_all(context_id)
|
||||||
|
|
||||||
|
if context_id in (CTX_ERROR, CTX_MAPPING):
|
||||||
|
self._error_icon.hide()
|
||||||
|
self._error = False
|
||||||
|
if self._warning:
|
||||||
|
self._warning_icon.show()
|
||||||
|
|
||||||
|
if context_id == CTX_WARNING:
|
||||||
|
self._warning_icon.hide()
|
||||||
|
self._warning = False
|
||||||
|
if self._error:
|
||||||
|
self._error_icon.show()
|
||||||
|
|
||||||
|
status_bar.set_tooltip_text("")
|
||||||
|
return
|
||||||
|
|
||||||
|
if tooltip is None:
|
||||||
|
tooltip = message
|
||||||
|
|
||||||
|
self._error_icon.hide()
|
||||||
|
self._warning_icon.hide()
|
||||||
|
|
||||||
|
if context_id in (CTX_ERROR, CTX_MAPPING):
|
||||||
|
self._error_icon.show()
|
||||||
|
self._error = True
|
||||||
|
|
||||||
|
if context_id == CTX_WARNING:
|
||||||
|
self._warning_icon.show()
|
||||||
|
self._warning = True
|
||||||
|
|
||||||
|
status_bar.push(context_id, message)
|
||||||
|
status_bar.set_tooltip_text(tooltip)
|
||||||
3
inputremapper/gui/components/output_type_names.py
Normal file
3
inputremapper/gui/components/output_type_names.py
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
class OutputTypeNames:
|
||||||
|
analog_axis = "Analog Axis"
|
||||||
|
key_or_macro = "Key or Macro"
|
||||||
106
inputremapper/gui/components/presets.py
Normal file
106
inputremapper/gui/components/presets.py
Normal file
@ -0,0 +1,106 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# input-remapper - GUI for device specific keyboard mappings
|
||||||
|
# Copyright (C) 2025 sezanzeb <b8x45ygc9@mozmail.com>
|
||||||
|
#
|
||||||
|
# This file is part of input-remapper.
|
||||||
|
#
|
||||||
|
# input-remapper is free software: you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU General Public License as published by
|
||||||
|
# the Free Software Foundation, either version 3 of the License, or
|
||||||
|
# (at your option) any later version.
|
||||||
|
#
|
||||||
|
# input-remapper is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU General Public License
|
||||||
|
# along with input-remapper. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
|
||||||
|
"""All components that are visible on the page that shows all the presets."""
|
||||||
|
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from gi.repository import Gtk
|
||||||
|
|
||||||
|
from inputremapper.gui.components.common import FlowBoxEntry, FlowBoxWrapper
|
||||||
|
from inputremapper.gui.components.main import Stack
|
||||||
|
from inputremapper.gui.controller import Controller
|
||||||
|
from inputremapper.gui.messages.message_broker import (
|
||||||
|
MessageBroker,
|
||||||
|
MessageType,
|
||||||
|
)
|
||||||
|
from inputremapper.gui.messages.message_data import (
|
||||||
|
GroupData,
|
||||||
|
PresetData,
|
||||||
|
DoStackSwitch,
|
||||||
|
)
|
||||||
|
from inputremapper.logging.logger import logger
|
||||||
|
|
||||||
|
|
||||||
|
class PresetEntry(FlowBoxEntry):
|
||||||
|
"""A preset that can be selected in the GUI."""
|
||||||
|
|
||||||
|
__gtype_name__ = "PresetEntry"
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
message_broker: MessageBroker,
|
||||||
|
controller: Controller,
|
||||||
|
preset_name: str,
|
||||||
|
):
|
||||||
|
super().__init__(
|
||||||
|
message_broker=message_broker, controller=controller, name=preset_name
|
||||||
|
)
|
||||||
|
self.preset_name = preset_name
|
||||||
|
|
||||||
|
def _on_gtk_toggle(self, *_, **__):
|
||||||
|
logger.debug('Selecting preset "%s"', self.preset_name)
|
||||||
|
self._controller.load_preset(self.preset_name)
|
||||||
|
self.message_broker.publish(DoStackSwitch(Stack.editor_page))
|
||||||
|
|
||||||
|
|
||||||
|
class PresetSelection(FlowBoxWrapper):
|
||||||
|
"""A wrapper for the container with our presets.
|
||||||
|
|
||||||
|
Selectes the active_preset.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
message_broker: MessageBroker,
|
||||||
|
controller: Controller,
|
||||||
|
flowbox: Gtk.FlowBox,
|
||||||
|
):
|
||||||
|
super().__init__(flowbox)
|
||||||
|
|
||||||
|
self._message_broker = message_broker
|
||||||
|
self._controller = controller
|
||||||
|
self._gui = flowbox
|
||||||
|
self._connect_message_listener()
|
||||||
|
|
||||||
|
def _connect_message_listener(self):
|
||||||
|
self._message_broker.subscribe(MessageType.group, self._on_group_changed)
|
||||||
|
self._message_broker.subscribe(MessageType.preset, self._on_preset_changed)
|
||||||
|
|
||||||
|
def _on_group_changed(self, data: GroupData):
|
||||||
|
self._gui.foreach(self._gui.remove)
|
||||||
|
for preset_name in data.presets:
|
||||||
|
preset_entry = PresetEntry(
|
||||||
|
self._message_broker,
|
||||||
|
self._controller,
|
||||||
|
preset_name,
|
||||||
|
)
|
||||||
|
self._gui.insert(preset_entry, -1)
|
||||||
|
|
||||||
|
def _on_preset_changed(self, data: PresetData):
|
||||||
|
self.show_active_entry(data.name)
|
||||||
|
|
||||||
|
def set_active_preset(self, preset_name: str):
|
||||||
|
"""Change the currently selected preset."""
|
||||||
|
# TODO might only be needed in tests
|
||||||
|
for child in self._gui.get_children():
|
||||||
|
preset_entry: PresetEntry = child.get_children()[0]
|
||||||
|
preset_entry.set_active(preset_entry.preset_name == preset_name)
|
||||||
920
inputremapper/gui/controller.py
Normal file
920
inputremapper/gui/controller.py
Normal 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
|
||||||
697
inputremapper/gui/data_manager.py
Normal file
697
inputremapper/gui/data_manager.py
Normal 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)
|
||||||
145
inputremapper/gui/focus_service_client.py
Normal file
145
inputremapper/gui/focus_service_client.py
Normal 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
|
||||||
125
inputremapper/gui/forward_to_ui_handler.py
Normal file
125
inputremapper/gui/forward_to_ui_handler.py
Normal file
@ -0,0 +1,125 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# input-remapper - GUI for device specific keyboard mappings
|
||||||
|
# Copyright (C) 2023 sezanzeb <proxima@hip70890b.de>
|
||||||
|
#
|
||||||
|
# This file is part of input-remapper.
|
||||||
|
#
|
||||||
|
# input-remapper is free software: you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU General Public License as published by
|
||||||
|
# the Free Software Foundation, either version 3 of the License, or
|
||||||
|
# (at your option) any later version.
|
||||||
|
#
|
||||||
|
# input-remapper is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU General Public License
|
||||||
|
# along with input-remapper. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
"""Process that sends stuff to the GUI.
|
||||||
|
|
||||||
|
It should be started via input-remapper-control and pkexec.
|
||||||
|
|
||||||
|
GUIs should not run as root
|
||||||
|
https://wiki.archlinux.org/index.php/Running_GUI_applications_as_root
|
||||||
|
|
||||||
|
The service shouldn't do that even though it has root rights, because that
|
||||||
|
would enable key-loggers to just ask input-remapper for all user-input.
|
||||||
|
|
||||||
|
Instead, the ReaderService is used, which will be stopped when the gui closes.
|
||||||
|
|
||||||
|
Whereas for the reader-service to start a password is needed and it stops whe
|
||||||
|
the ui closes.
|
||||||
|
|
||||||
|
This uses the backend injection.event_reader and mapping_handlers to process all the
|
||||||
|
different input-events into simple on/off events and sends them to the gui.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|
||||||
|
import evdev
|
||||||
|
from evdev.ecodes import EV_ABS
|
||||||
|
|
||||||
|
from inputremapper.configs.input_config import (
|
||||||
|
DEFAULT_ABS_ANALOG_THRESHOLD_MAGNITUDE,
|
||||||
|
)
|
||||||
|
from inputremapper.input_event import InputEvent
|
||||||
|
from inputremapper.ipc.pipe import Pipe
|
||||||
|
from inputremapper.logging.logger import logger
|
||||||
|
from inputremapper.injection.mapping_handlers.mapping_handler import MappingHandler
|
||||||
|
from inputremapper.injection.mapping_handlers.abs_util import calculate_trigger_point
|
||||||
|
|
||||||
|
# received by the reader-service
|
||||||
|
CMD_TERMINATE = "terminate"
|
||||||
|
CMD_STOP_READING = "stop-reading"
|
||||||
|
CMD_REFRESH_GROUPS = "refresh_groups"
|
||||||
|
|
||||||
|
# sent by the reader-service to the reader
|
||||||
|
MSG_GROUPS = "groups"
|
||||||
|
MSG_EVENT = "event"
|
||||||
|
MSG_STATUS = "status"
|
||||||
|
|
||||||
|
|
||||||
|
class ForwardToUIHandler(MappingHandler):
|
||||||
|
"""Implements the MappingHandler protocol. Sends all events into the pipe."""
|
||||||
|
|
||||||
|
def __init__(self, pipe: Pipe):
|
||||||
|
self.pipe = pipe
|
||||||
|
self._last_event = InputEvent.from_tuple((99, 99, 99))
|
||||||
|
|
||||||
|
def notify(
|
||||||
|
self,
|
||||||
|
event: InputEvent,
|
||||||
|
source: evdev.InputDevice,
|
||||||
|
suppress: bool = False,
|
||||||
|
) -> bool:
|
||||||
|
"""Filter duplicates and send into the pipe."""
|
||||||
|
if event == self._last_event:
|
||||||
|
return True
|
||||||
|
|
||||||
|
# These defaults work with EV_KEY and EV_REL
|
||||||
|
pressed = False if event.value == 0 else True
|
||||||
|
direction = 1 if event.value >= 0 else -1
|
||||||
|
|
||||||
|
# Because joysticks aren't as precise, they wiggle and their value might not be
|
||||||
|
# centered around 0, they need special treatment
|
||||||
|
if event.type == EV_ABS:
|
||||||
|
threshold, mid_point = calculate_trigger_point(
|
||||||
|
event,
|
||||||
|
DEFAULT_ABS_ANALOG_THRESHOLD_MAGNITUDE,
|
||||||
|
source,
|
||||||
|
)
|
||||||
|
|
||||||
|
# If within 30% (into each direction) of the mid_point, count as released
|
||||||
|
# A large threshold makes it significantly easier to not accidentally
|
||||||
|
# record both ABS_X and ABS_Y.
|
||||||
|
if abs(event.value - mid_point) < threshold:
|
||||||
|
pressed = False
|
||||||
|
|
||||||
|
if event.value < mid_point:
|
||||||
|
direction = -1
|
||||||
|
|
||||||
|
self._last_event = event
|
||||||
|
|
||||||
|
logger.debug("Sending %s to frontend", event)
|
||||||
|
self.pipe.send(
|
||||||
|
{
|
||||||
|
"type": MSG_EVENT,
|
||||||
|
"message": {
|
||||||
|
"sec": event.sec,
|
||||||
|
"usec": event.usec,
|
||||||
|
"type": event.type,
|
||||||
|
"code": event.code,
|
||||||
|
"value": event.value,
|
||||||
|
"pressed": pressed,
|
||||||
|
"direction": direction,
|
||||||
|
"origin_hash": event.origin_hash,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
|
||||||
|
def reset(self):
|
||||||
|
pass
|
||||||
33
inputremapper/gui/gettext.py
Normal file
33
inputremapper/gui/gettext.py
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# input-remapper - GUI for device specific keyboard mappings
|
||||||
|
# Copyright (C) 2025 sezanzeb <b8x45ygc9@mozmail.com>
|
||||||
|
#
|
||||||
|
# This file is part of input-remapper.
|
||||||
|
#
|
||||||
|
# input-remapper is free software: you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU General Public License as published by
|
||||||
|
# the Free Software Foundation, either version 3 of the License, or
|
||||||
|
# (at your option) any later version.
|
||||||
|
#
|
||||||
|
# input-remapper is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU General Public License
|
||||||
|
# along with input-remapper. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
import gettext
|
||||||
|
import locale
|
||||||
|
import os.path
|
||||||
|
|
||||||
|
from inputremapper.configs.data import get_data_path
|
||||||
|
|
||||||
|
APP_NAME = "input-remapper"
|
||||||
|
LOCALE_DIR = os.path.join(get_data_path(), "lang")
|
||||||
|
|
||||||
|
locale.bindtextdomain(APP_NAME, LOCALE_DIR)
|
||||||
|
locale.textdomain(APP_NAME)
|
||||||
|
|
||||||
|
translate = gettext.translation(APP_NAME, LOCALE_DIR, fallback=True)
|
||||||
|
_ = translate.gettext
|
||||||
0
inputremapper/gui/messages/__init__.py
Normal file
0
inputremapper/gui/messages/__init__.py
Normal file
120
inputremapper/gui/messages/message_broker.py
Normal file
120
inputremapper/gui/messages/message_broker.py
Normal file
@ -0,0 +1,120 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# input-remapper - GUI for device specific keyboard mappings
|
||||||
|
# Copyright (C) 2025 sezanzeb <b8x45ygc9@mozmail.com>
|
||||||
|
#
|
||||||
|
# This file is part of input-remapper.
|
||||||
|
#
|
||||||
|
# input-remapper is free software: you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU General Public License as published by
|
||||||
|
# the Free Software Foundation, either version 3 of the License, or
|
||||||
|
# (at your option) any later version.
|
||||||
|
#
|
||||||
|
# input-remapper is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU General Public License
|
||||||
|
# along with input-remapper. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
import os.path
|
||||||
|
import re
|
||||||
|
import traceback
|
||||||
|
from collections import defaultdict, deque
|
||||||
|
from typing import (
|
||||||
|
Callable,
|
||||||
|
Dict,
|
||||||
|
Set,
|
||||||
|
Protocol,
|
||||||
|
Tuple,
|
||||||
|
Deque,
|
||||||
|
Any,
|
||||||
|
)
|
||||||
|
|
||||||
|
from inputremapper.gui.messages.message_types import MessageType
|
||||||
|
from inputremapper.logging.logger import logger
|
||||||
|
|
||||||
|
|
||||||
|
class Message(Protocol):
|
||||||
|
"""The protocol any message must follow to be sent with the MessageBroker."""
|
||||||
|
|
||||||
|
@property
|
||||||
|
def message_type(self) -> MessageType: ...
|
||||||
|
|
||||||
|
|
||||||
|
# useful type aliases
|
||||||
|
MessageListener = Callable[[Any], None]
|
||||||
|
|
||||||
|
|
||||||
|
class MessageBroker:
|
||||||
|
shorten_path = re.compile(r"inputremapper/")
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self._listeners: Dict[MessageType, Set[MessageListener]] = defaultdict(set)
|
||||||
|
self._messages: Deque[Tuple[Message, str, int]] = deque()
|
||||||
|
self._publishing = False
|
||||||
|
|
||||||
|
def publish(self, data: Message):
|
||||||
|
"""Schedule a massage to be sent.
|
||||||
|
The message will be sent after all currently pending messages are sent."""
|
||||||
|
self._messages.append((data, *self.get_caller()))
|
||||||
|
self._publish_all()
|
||||||
|
|
||||||
|
def signal(self, signal: MessageType):
|
||||||
|
"""Send a signal without any data payload."""
|
||||||
|
# This is different from calling self.publish because self.get_caller()
|
||||||
|
# looks back at the current stack 3 frames
|
||||||
|
self._messages.append((Signal(signal), *self.get_caller()))
|
||||||
|
self._publish_all()
|
||||||
|
|
||||||
|
def _publish(self, data: Message, file: str, line: int):
|
||||||
|
logger.debug(
|
||||||
|
"from %s:%d: Signal=%s: %s", file, line, data.message_type.name, data
|
||||||
|
)
|
||||||
|
for listener in self._listeners[data.message_type].copy():
|
||||||
|
listener(data)
|
||||||
|
|
||||||
|
def _publish_all(self):
|
||||||
|
"""Send all scheduled messages in order."""
|
||||||
|
if self._publishing:
|
||||||
|
# don't run this twice, so we not mess up the order
|
||||||
|
return
|
||||||
|
|
||||||
|
self._publishing = True
|
||||||
|
try:
|
||||||
|
while self._messages:
|
||||||
|
self._publish(*self._messages.popleft())
|
||||||
|
finally:
|
||||||
|
self._publishing = False
|
||||||
|
|
||||||
|
def subscribe(self, massage_type: MessageType, listener: MessageListener):
|
||||||
|
"""Attach a listener to an event."""
|
||||||
|
logger.debug("adding new Listener for %s: %s", massage_type, listener)
|
||||||
|
self._listeners[massage_type].add(listener)
|
||||||
|
return self
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_caller(position: int = 3) -> Tuple[str, int]:
|
||||||
|
"""Extract a file and line from current stack and format for logging."""
|
||||||
|
tb = traceback.extract_stack(limit=position)[0]
|
||||||
|
return os.path.basename(tb.filename), tb.lineno or 0
|
||||||
|
|
||||||
|
def unsubscribe(self, listener: MessageListener) -> None:
|
||||||
|
for listeners in self._listeners.values():
|
||||||
|
try:
|
||||||
|
listeners.remove(listener)
|
||||||
|
except KeyError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class Signal:
|
||||||
|
"""Send a Message without any associated data over the MassageBus."""
|
||||||
|
|
||||||
|
def __init__(self, message_type: MessageType):
|
||||||
|
self.message_type: MessageType = message_type
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"Signal: {self.message_type}"
|
||||||
|
|
||||||
|
def __eq__(self, other: Any):
|
||||||
|
return type(self) is type(other) and self.message_type == other.message_type
|
||||||
155
inputremapper/gui/messages/message_data.py
Normal file
155
inputremapper/gui/messages/message_data.py
Normal 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 = ""
|
||||||
64
inputremapper/gui/messages/message_types.py
Normal file
64
inputremapper/gui/messages/message_types.py
Normal 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"
|
||||||
306
inputremapper/gui/reader_client.py
Normal file
306
inputremapper/gui/reader_client.py
Normal file
@ -0,0 +1,306 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# input-remapper - GUI for device specific keyboard mappings
|
||||||
|
# Copyright (C) 2025 sezanzeb <b8x45ygc9@mozmail.com>
|
||||||
|
#
|
||||||
|
# This file is part of input-remapper.
|
||||||
|
#
|
||||||
|
# input-remapper is free software: you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU General Public License as published by
|
||||||
|
# the Free Software Foundation, either version 3 of the License, or
|
||||||
|
# (at your option) any later version.
|
||||||
|
#
|
||||||
|
# input-remapper is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU General Public License
|
||||||
|
# along with input-remapper. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
|
||||||
|
"""Talking to the ReaderService that has root permissions.
|
||||||
|
|
||||||
|
see gui.reader_service.ReaderService
|
||||||
|
"""
|
||||||
|
|
||||||
|
import time
|
||||||
|
from typing import Optional, List, Generator, Dict, Set
|
||||||
|
|
||||||
|
import evdev
|
||||||
|
from gi.repository import GLib
|
||||||
|
|
||||||
|
from inputremapper.configs.input_config import (
|
||||||
|
InputCombination,
|
||||||
|
DEFAULT_ABS_ANALOG_THRESHOLD_MAGNITUDE,
|
||||||
|
)
|
||||||
|
from inputremapper.groups import _Groups, _Group
|
||||||
|
from inputremapper.gui.gettext import _
|
||||||
|
from inputremapper.gui.messages.message_broker import MessageBroker
|
||||||
|
from inputremapper.gui.messages.message_data import (
|
||||||
|
GroupsData,
|
||||||
|
CombinationRecorded,
|
||||||
|
StatusData,
|
||||||
|
)
|
||||||
|
from inputremapper.gui.messages.message_types import MessageType
|
||||||
|
from inputremapper.gui.reader_service import (
|
||||||
|
MSG_EVENT,
|
||||||
|
MSG_GROUPS,
|
||||||
|
CMD_TERMINATE,
|
||||||
|
CMD_REFRESH_GROUPS,
|
||||||
|
CMD_STOP_READING,
|
||||||
|
ReaderService,
|
||||||
|
)
|
||||||
|
from inputremapper.gui.utils import CTX_ERROR
|
||||||
|
from inputremapper.input_event import InputEvent
|
||||||
|
from inputremapper.ipc.pipe import Pipe
|
||||||
|
from inputremapper.logging.logger import logger
|
||||||
|
|
||||||
|
BLACKLISTED_EVENTS = [(1, evdev.ecodes.BTN_TOOL_DOUBLETAP)]
|
||||||
|
RecordingGenerator = Generator[None, InputEvent, None]
|
||||||
|
|
||||||
|
|
||||||
|
class ReaderClient:
|
||||||
|
"""Processes events from the reader-service for the GUI to use.
|
||||||
|
|
||||||
|
Does not serve any purpose for the injection service.
|
||||||
|
|
||||||
|
When a button was pressed, the newest keycode can be obtained from this object.
|
||||||
|
GTK has get_key for keyboard keys, but Reader also has knowledge of buttons like
|
||||||
|
the middle-mouse button.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# how long to wait for the reader-service at most
|
||||||
|
_timeout: int = 5
|
||||||
|
|
||||||
|
def __init__(self, message_broker: MessageBroker, groups: _Groups):
|
||||||
|
self.groups = groups
|
||||||
|
self.message_broker = message_broker
|
||||||
|
|
||||||
|
self.group: Optional[_Group] = None
|
||||||
|
|
||||||
|
self._recording_generator: Optional[RecordingGenerator] = None
|
||||||
|
self._results_pipe, self._commands_pipe = self.connect()
|
||||||
|
|
||||||
|
self.attach_to_events()
|
||||||
|
|
||||||
|
self._read_timeout = GLib.timeout_add(30, self._read)
|
||||||
|
|
||||||
|
def ensure_reader_service_running(self):
|
||||||
|
if ReaderService.is_running():
|
||||||
|
return
|
||||||
|
|
||||||
|
logger.info("ReaderService not running anymore, restarting")
|
||||||
|
ReaderService.pkexec_reader_service()
|
||||||
|
|
||||||
|
# wait until the ReaderService is up
|
||||||
|
|
||||||
|
# wait no more than:
|
||||||
|
polling_period = 0.01
|
||||||
|
# this will make the gui non-responsive for 0.4s or something. The pkexec
|
||||||
|
# password prompt will appear, so the user understands that the lag has to
|
||||||
|
# be connected to the authentication. I would actually prefer the frozen gui
|
||||||
|
# over a reactive one here, because the short lag shows that stuff is going on
|
||||||
|
# behind the scenes.
|
||||||
|
for __ in range(int(self._timeout / polling_period)):
|
||||||
|
if self._results_pipe.poll():
|
||||||
|
logger.info("ReaderService started")
|
||||||
|
break
|
||||||
|
|
||||||
|
time.sleep(polling_period)
|
||||||
|
else:
|
||||||
|
msg = "The reader-service did not start"
|
||||||
|
logger.error(msg)
|
||||||
|
self.message_broker.publish(StatusData(CTX_ERROR, _(msg)))
|
||||||
|
|
||||||
|
def _send_command(self, command: str):
|
||||||
|
"""Send a command to the ReaderService."""
|
||||||
|
if command not in [CMD_TERMINATE, CMD_STOP_READING]:
|
||||||
|
self.ensure_reader_service_running()
|
||||||
|
|
||||||
|
logger.debug('Sending "%s" to ReaderService', command)
|
||||||
|
self._commands_pipe.send(command)
|
||||||
|
|
||||||
|
def connect(self):
|
||||||
|
"""Connect to the reader-service."""
|
||||||
|
results_pipe = Pipe(ReaderService.get_pipe_paths()[0])
|
||||||
|
commands_pipe = Pipe(ReaderService.get_pipe_paths()[1])
|
||||||
|
return results_pipe, commands_pipe
|
||||||
|
|
||||||
|
def attach_to_events(self):
|
||||||
|
"""Connect listeners to event_reader."""
|
||||||
|
self.message_broker.subscribe(
|
||||||
|
MessageType.terminate,
|
||||||
|
lambda _: self.terminate(),
|
||||||
|
)
|
||||||
|
|
||||||
|
def _read(self):
|
||||||
|
"""Read the messages from the reader-service and handle them."""
|
||||||
|
while self._results_pipe.poll():
|
||||||
|
message = self._results_pipe.recv()
|
||||||
|
|
||||||
|
logger.debug("received %s", message)
|
||||||
|
|
||||||
|
message_type = message["type"]
|
||||||
|
message_body = message["message"]
|
||||||
|
|
||||||
|
if message_type == MSG_GROUPS:
|
||||||
|
self._update_groups(message_body)
|
||||||
|
|
||||||
|
if message_type == MSG_EVENT:
|
||||||
|
# update the generator
|
||||||
|
try:
|
||||||
|
if self._recording_generator is not None:
|
||||||
|
self._recording_generator.send(InputEvent(**message_body))
|
||||||
|
else:
|
||||||
|
# the ReaderService should only send events while the gui
|
||||||
|
# is recording, so this is unexpected.
|
||||||
|
logger.error("Got event, but recorder is not running.")
|
||||||
|
except StopIteration:
|
||||||
|
# the _recording_generator returned
|
||||||
|
logger.debug("Recorder finished.")
|
||||||
|
self.stop_recorder()
|
||||||
|
break
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
def start_recorder(self) -> None:
|
||||||
|
"""Record user input."""
|
||||||
|
if self.group is None:
|
||||||
|
logger.error("No group set")
|
||||||
|
return
|
||||||
|
|
||||||
|
logger.debug("Starting recorder.")
|
||||||
|
self._send_command(self.group.key)
|
||||||
|
|
||||||
|
self._recording_generator = self._recorder()
|
||||||
|
next(self._recording_generator)
|
||||||
|
|
||||||
|
self.message_broker.signal(MessageType.recording_started)
|
||||||
|
|
||||||
|
def stop_recorder(self) -> None:
|
||||||
|
"""Stop recording the input.
|
||||||
|
|
||||||
|
Will send recording_finished signals.
|
||||||
|
"""
|
||||||
|
logger.debug("Stopping recorder.")
|
||||||
|
self._send_command(CMD_STOP_READING)
|
||||||
|
|
||||||
|
if self._recording_generator:
|
||||||
|
self._recording_generator.close()
|
||||||
|
self._recording_generator = None
|
||||||
|
else:
|
||||||
|
# this would be unexpected. but this is not critical enough to
|
||||||
|
# show to the user without debug logs
|
||||||
|
logger.debug("No recording generator existed")
|
||||||
|
|
||||||
|
self.message_broker.signal(MessageType.recording_finished)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _input_event_to_config(event: InputEvent):
|
||||||
|
# This used to default to event.value, which was broken for joysticks because
|
||||||
|
# it resulted in a very low value (I tihnk 1). Which I think was because we
|
||||||
|
# overwrote the event.value with 1 in the handlers.
|
||||||
|
|
||||||
|
# For joysticks the default uses DEFAULT_ABS_ANALOG_THRESHOLD_MAGNITUDE
|
||||||
|
# in percent now. This value would break moues movements, because mice don't
|
||||||
|
# have a maximum value that we could use for percent calculations.
|
||||||
|
|
||||||
|
# So for EV_REL we just use 1 or -1 to keep it working the same way it used to
|
||||||
|
# work.
|
||||||
|
analog_threshold = event.direction
|
||||||
|
|
||||||
|
if event.type == evdev.ecodes.EV_ABS:
|
||||||
|
analog_threshold = event.direction * DEFAULT_ABS_ANALOG_THRESHOLD_MAGNITUDE
|
||||||
|
|
||||||
|
return {
|
||||||
|
"type": event.type,
|
||||||
|
"code": event.code,
|
||||||
|
"analog_threshold": analog_threshold,
|
||||||
|
"origin_hash": event.origin_hash,
|
||||||
|
}
|
||||||
|
|
||||||
|
def _recorder(self) -> RecordingGenerator:
|
||||||
|
"""Generator which receives InputEvents.
|
||||||
|
|
||||||
|
It accumulates them into EventCombinations and sends those on the
|
||||||
|
message_broker. It will stop once all keys or inputs are released.
|
||||||
|
"""
|
||||||
|
active: Set = set()
|
||||||
|
accumulator: List[InputEvent] = []
|
||||||
|
while True:
|
||||||
|
event: InputEvent = yield
|
||||||
|
if event.type_and_code in BLACKLISTED_EVENTS:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if not event.is_pressed():
|
||||||
|
try:
|
||||||
|
active.remove(event.input_match_hash)
|
||||||
|
except KeyError:
|
||||||
|
# we haven't seen this before probably a key got released which
|
||||||
|
# was pressed before we started recording. ignore it.
|
||||||
|
continue
|
||||||
|
|
||||||
|
if not active:
|
||||||
|
# all previously recorded events are released
|
||||||
|
return
|
||||||
|
continue
|
||||||
|
|
||||||
|
active.add(event.input_match_hash)
|
||||||
|
accu_input_hashes = [e.input_match_hash for e in accumulator]
|
||||||
|
if event.input_match_hash in accu_input_hashes and event not in accumulator:
|
||||||
|
# the value has changed but the event is already in the accumulator
|
||||||
|
# update the event
|
||||||
|
i = accu_input_hashes.index(event.input_match_hash)
|
||||||
|
accumulator[i] = event
|
||||||
|
self.message_broker.publish(
|
||||||
|
CombinationRecorded(
|
||||||
|
InputCombination(map(self._input_event_to_config, accumulator))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if event not in accumulator:
|
||||||
|
accumulator.append(event)
|
||||||
|
self.message_broker.publish(
|
||||||
|
CombinationRecorded(
|
||||||
|
InputCombination(map(self._input_event_to_config, accumulator))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
def set_group(self, group: Optional[_Group]):
|
||||||
|
"""Set the group for which input events should be read later."""
|
||||||
|
# TODO load the active_group from the controller instead?
|
||||||
|
self.group = group
|
||||||
|
|
||||||
|
def terminate(self):
|
||||||
|
"""Stop reading keycodes for good."""
|
||||||
|
self._send_command(CMD_TERMINATE)
|
||||||
|
|
||||||
|
self.stop_recorder()
|
||||||
|
|
||||||
|
if self._read_timeout is not None:
|
||||||
|
GLib.source_remove(self._read_timeout)
|
||||||
|
self._read_timeout = None
|
||||||
|
|
||||||
|
while self._results_pipe.poll():
|
||||||
|
self._results_pipe.recv()
|
||||||
|
|
||||||
|
def refresh_groups(self):
|
||||||
|
"""Ask the ReaderService for new device groups."""
|
||||||
|
self._send_command(CMD_REFRESH_GROUPS)
|
||||||
|
|
||||||
|
def publish_groups(self):
|
||||||
|
"""Announce all known groups."""
|
||||||
|
groups: Dict[str, List[str]] = {
|
||||||
|
group.key: group.types or [] for group in self.groups.get_groups()
|
||||||
|
}
|
||||||
|
self.message_broker.publish(GroupsData(groups))
|
||||||
|
|
||||||
|
def _update_groups(self, dump: str):
|
||||||
|
if dump != self.groups.dumps():
|
||||||
|
self.groups.loads(dump)
|
||||||
|
logger.debug("Received %d devices", len(self.groups.get_groups()))
|
||||||
|
self._groups_updated = True
|
||||||
|
|
||||||
|
# send this even if the groups did not change, as the user expects the ui
|
||||||
|
# to respond in some form
|
||||||
|
self.publish_groups()
|
||||||
420
inputremapper/gui/reader_service.py
Normal file
420
inputremapper/gui/reader_service.py
Normal file
@ -0,0 +1,420 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# input-remapper - GUI for device specific keyboard mappings
|
||||||
|
# Copyright (C) 2023 sezanzeb <proxima@hip70890b.de>
|
||||||
|
#
|
||||||
|
# This file is part of input-remapper.
|
||||||
|
#
|
||||||
|
# input-remapper is free software: you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU General Public License as published by
|
||||||
|
# the Free Software Foundation, either version 3 of the License, or
|
||||||
|
# (at your option) any later version.
|
||||||
|
#
|
||||||
|
# input-remapper is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU General Public License
|
||||||
|
# along with input-remapper. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
"""Process that sends stuff to the GUI.
|
||||||
|
|
||||||
|
It should be started via input-remapper-control and pkexec.
|
||||||
|
|
||||||
|
GUIs should not run as root
|
||||||
|
https://wiki.archlinux.org/index.php/Running_GUI_applications_as_root
|
||||||
|
|
||||||
|
The service shouldn't do that even though it has root rights, because that
|
||||||
|
would enable key-loggers to just ask input-remapper for all user-input.
|
||||||
|
|
||||||
|
Instead, the ReaderService is used, which will be stopped when the gui closes.
|
||||||
|
|
||||||
|
Whereas for the reader-service to start a password is needed and it stops whe
|
||||||
|
the ui closes.
|
||||||
|
|
||||||
|
This uses the backend injection.event_reader and mapping_handlers to process all the
|
||||||
|
different input-events into simple on/off events and sends them to the gui.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import multiprocessing
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
from collections import defaultdict
|
||||||
|
from typing import Set, List, Tuple
|
||||||
|
|
||||||
|
import evdev
|
||||||
|
from evdev.ecodes import EV_KEY, EV_ABS, EV_REL, REL_HWHEEL, REL_WHEEL
|
||||||
|
|
||||||
|
from inputremapper.configs.input_config import InputCombination, InputConfig
|
||||||
|
from inputremapper.configs.mapping import Mapping, KnownUinput
|
||||||
|
from inputremapper.groups import _Groups, _Group
|
||||||
|
from inputremapper.injection.event_reader import EventReader
|
||||||
|
from inputremapper.injection.global_uinputs import GlobalUInputs
|
||||||
|
from inputremapper.injection.mapping_handlers.abs_to_btn_handler import AbsToBtnHandler
|
||||||
|
from inputremapper.injection.mapping_handlers.mapping_handler import (
|
||||||
|
NotifyCallback,
|
||||||
|
MappingHandler,
|
||||||
|
)
|
||||||
|
from inputremapper.injection.mapping_handlers.rel_to_btn_handler import RelToBtnHandler
|
||||||
|
from inputremapper.input_event import InputEvent
|
||||||
|
from inputremapper.ipc.pipe import Pipe
|
||||||
|
from inputremapper.logging.logger import logger
|
||||||
|
from inputremapper.user import UserUtils
|
||||||
|
from inputremapper.utils import get_device_hash
|
||||||
|
from inputremapper.gui.forward_to_ui_handler import ForwardToUIHandler
|
||||||
|
|
||||||
|
# received by the reader-service
|
||||||
|
CMD_TERMINATE = "terminate"
|
||||||
|
CMD_STOP_READING = "stop-reading"
|
||||||
|
CMD_REFRESH_GROUPS = "refresh_groups"
|
||||||
|
|
||||||
|
# sent by the reader-service to the reader
|
||||||
|
MSG_GROUPS = "groups"
|
||||||
|
MSG_EVENT = "event"
|
||||||
|
MSG_STATUS = "status"
|
||||||
|
|
||||||
|
RELEASE_TIMEOUT = 0.3
|
||||||
|
|
||||||
|
|
||||||
|
class ReaderService:
|
||||||
|
"""Service that only reads events and is supposed to run as root.
|
||||||
|
|
||||||
|
Sends device information and keycodes to the GUI.
|
||||||
|
|
||||||
|
Commands are either numbers for generic commands,
|
||||||
|
or strings to start listening on a specific device.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# the speed threshold at which relative axis are considered moving
|
||||||
|
# and will be sent as "pressed" to the frontend.
|
||||||
|
# We want to allow some mouse movement before we record it as an input
|
||||||
|
rel_xy_speed = defaultdict(lambda: 3)
|
||||||
|
# wheel events usually don't produce values higher than 1
|
||||||
|
rel_xy_speed[REL_WHEEL] = 1
|
||||||
|
rel_xy_speed[REL_HWHEEL] = 1
|
||||||
|
|
||||||
|
# Polkit won't ask for another password if the pid stays the same or something, and
|
||||||
|
# if the previous request was no more than 5 minutes ago. see
|
||||||
|
# https://unix.stackexchange.com/a/458260.
|
||||||
|
# If the user does something after 6 minutes they will get a prompt already if the
|
||||||
|
# reader timed out already, which sounds annoying. Instead, I'd rather have the
|
||||||
|
# password prompt appear at most every 15 minutes.
|
||||||
|
_maximum_lifetime: int = 60 * 15
|
||||||
|
_timeout_tolerance: int = 60
|
||||||
|
|
||||||
|
def __init__(self, groups: _Groups, global_uinputs: GlobalUInputs) -> None:
|
||||||
|
"""Construct the reader-service and initialize its communication pipes."""
|
||||||
|
self._start_time = time.time()
|
||||||
|
self.groups = groups
|
||||||
|
self.global_uinputs = global_uinputs
|
||||||
|
self._results_pipe = Pipe(self.get_pipe_paths()[0])
|
||||||
|
self._commands_pipe = Pipe(self.get_pipe_paths()[1])
|
||||||
|
self._pipe = multiprocessing.Pipe()
|
||||||
|
|
||||||
|
self._tasks: Set[asyncio.Task] = set()
|
||||||
|
self._stop_event = asyncio.Event()
|
||||||
|
|
||||||
|
self._results_pipe.send({"type": MSG_STATUS, "message": "ready"})
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_pipe_paths() -> Tuple[str, str]:
|
||||||
|
"""Get the path where the pipe can be found."""
|
||||||
|
return (
|
||||||
|
f"/tmp/input-remapper-{UserUtils.home}/reader-results",
|
||||||
|
f"/tmp/input-remapper-{UserUtils.home}/reader-commands",
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def pipes_exist() -> bool:
|
||||||
|
# Just checking for one of the 4 files (results, commands both read and write)
|
||||||
|
# should be enough I guess.
|
||||||
|
path = f"{ReaderService.get_pipe_paths()[0]}r"
|
||||||
|
# Use os.path.exists, not lexists or islink, because broken links are bad.
|
||||||
|
# New pipes and symlinks need to be made.
|
||||||
|
return os.path.exists(path)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def is_running() -> bool:
|
||||||
|
"""Check if the reader-service is running."""
|
||||||
|
try:
|
||||||
|
subprocess.check_output(["pgrep", "-f", "input-remapper-reader-service"])
|
||||||
|
except subprocess.CalledProcessError:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def pkexec_reader_service() -> None:
|
||||||
|
"""Start reader-service via pkexec to run in the background."""
|
||||||
|
debug = " -d" if logger.level <= logging.DEBUG else ""
|
||||||
|
cmd = f"pkexec input-remapper-control --command start-reader-service{debug}"
|
||||||
|
|
||||||
|
logger.debug("Running `%s`", cmd)
|
||||||
|
exit_code = os.system(cmd)
|
||||||
|
|
||||||
|
if exit_code != 0:
|
||||||
|
raise Exception(f"Failed to pkexec the reader-service, code {exit_code}")
|
||||||
|
|
||||||
|
async def run(self) -> None:
|
||||||
|
"""Start doing stuff."""
|
||||||
|
# the reader will check for new commands later, once it is running
|
||||||
|
# it keeps running for one device or another.
|
||||||
|
logger.debug("Discovering initial groups")
|
||||||
|
self.groups.refresh()
|
||||||
|
self._send_groups()
|
||||||
|
await asyncio.gather(
|
||||||
|
self._read_commands(),
|
||||||
|
self._timeout(),
|
||||||
|
self._stop_if_pipes_broken(),
|
||||||
|
)
|
||||||
|
|
||||||
|
def _send_groups(self) -> None:
|
||||||
|
"""Send the groups to the gui."""
|
||||||
|
logger.debug("Sending groups")
|
||||||
|
self._results_pipe.send({"type": MSG_GROUPS, "message": self.groups.dumps()})
|
||||||
|
|
||||||
|
async def _timeout(self) -> None:
|
||||||
|
"""Stop automatically after some time."""
|
||||||
|
# Prevents a permanent hole for key-loggers to exist, in case the gui crashes.
|
||||||
|
# If the ReaderService stops even though the gui needs it, it needs to restart
|
||||||
|
# it. This makes it also more comfortable to have debug mode running during
|
||||||
|
# development, because it won't keep writing inputs containing passwords and
|
||||||
|
# such to the terminal forever.
|
||||||
|
|
||||||
|
await asyncio.sleep(self._maximum_lifetime)
|
||||||
|
|
||||||
|
# if it is currently reading, wait a bit longer for the gui to complete
|
||||||
|
# what it is doing.
|
||||||
|
if self._is_reading():
|
||||||
|
logger.debug("Waiting a bit longer for the gui to finish reading")
|
||||||
|
|
||||||
|
for _ in range(self._timeout_tolerance):
|
||||||
|
if not self._is_reading():
|
||||||
|
# once reading completes, it should terminate right away
|
||||||
|
break
|
||||||
|
|
||||||
|
await asyncio.sleep(1)
|
||||||
|
|
||||||
|
logger.debug("Maximum life-span reached, terminating")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
async def _read_commands(self) -> None:
|
||||||
|
"""Handle all unread commands.
|
||||||
|
this will run until it receives CMD_TERMINATE
|
||||||
|
"""
|
||||||
|
logger.debug("Waiting for commands")
|
||||||
|
async for cmd in self._commands_pipe:
|
||||||
|
logger.debug('Received command "%s"', cmd)
|
||||||
|
|
||||||
|
if cmd == CMD_TERMINATE:
|
||||||
|
await self._stop_reading()
|
||||||
|
logger.debug("Terminating")
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
if cmd == CMD_REFRESH_GROUPS:
|
||||||
|
self.groups.refresh()
|
||||||
|
self._send_groups()
|
||||||
|
continue
|
||||||
|
|
||||||
|
if cmd == CMD_STOP_READING:
|
||||||
|
await self._stop_reading()
|
||||||
|
continue
|
||||||
|
|
||||||
|
group = self.groups.find(key=cmd)
|
||||||
|
if group is None:
|
||||||
|
# this will block for a bit maybe we want to do this async?
|
||||||
|
self.groups.refresh()
|
||||||
|
group = self.groups.find(key=cmd)
|
||||||
|
|
||||||
|
if group is not None:
|
||||||
|
await self._stop_reading()
|
||||||
|
self._start_reading(group)
|
||||||
|
continue
|
||||||
|
|
||||||
|
logger.error('Received unknown command "%s"', cmd)
|
||||||
|
|
||||||
|
async def _stop_if_pipes_broken(self) -> None:
|
||||||
|
# The GUI probably exited, and failed to tell the reader-service to stop.
|
||||||
|
# Pipes are owned by the GUI process, because the non-privileged GUI process
|
||||||
|
# needs to be able to read them. Therefore, they are gone.
|
||||||
|
while True:
|
||||||
|
await asyncio.sleep(1)
|
||||||
|
if not self.pipes_exist():
|
||||||
|
await self._stop_reading()
|
||||||
|
logger.debug("Pipes broken, exiting")
|
||||||
|
sys.exit(13)
|
||||||
|
|
||||||
|
def _is_reading(self) -> bool:
|
||||||
|
"""Check if the ReaderService is currently sending events to the GUI."""
|
||||||
|
return len(self._tasks) > 0
|
||||||
|
|
||||||
|
def _start_reading(self, group: _Group) -> None:
|
||||||
|
"""Find all devices of that group, filter interesting ones and send the events
|
||||||
|
to the gui."""
|
||||||
|
sources = []
|
||||||
|
for path in group.paths:
|
||||||
|
try:
|
||||||
|
device = evdev.InputDevice(path)
|
||||||
|
except (FileNotFoundError, OSError):
|
||||||
|
logger.error('Could not find "%s"', path)
|
||||||
|
return None
|
||||||
|
|
||||||
|
capabilities = device.capabilities(absinfo=False)
|
||||||
|
if (
|
||||||
|
EV_KEY in capabilities
|
||||||
|
or EV_ABS in capabilities
|
||||||
|
or EV_REL in capabilities
|
||||||
|
):
|
||||||
|
sources.append(device)
|
||||||
|
|
||||||
|
context = self._create_event_pipeline(sources)
|
||||||
|
# create the event reader and start it
|
||||||
|
for device in sources:
|
||||||
|
reader = EventReader(context, device, self._stop_event)
|
||||||
|
self._tasks.add(asyncio.create_task(reader.run()))
|
||||||
|
|
||||||
|
async def _stop_reading(self) -> None:
|
||||||
|
"""Stop the running event_reader."""
|
||||||
|
self._stop_event.set()
|
||||||
|
if self._tasks:
|
||||||
|
await asyncio.gather(*self._tasks)
|
||||||
|
self._tasks = set()
|
||||||
|
self._stop_event.clear()
|
||||||
|
|
||||||
|
def _create_event_pipeline(self, sources: List[evdev.InputDevice]) -> ContextDummy:
|
||||||
|
"""Create a custom event pipeline for each event code in the capabilities.
|
||||||
|
|
||||||
|
Instead of sending the events to an uinput they will be sent to the frontend.
|
||||||
|
"""
|
||||||
|
context_dummy = ContextDummy()
|
||||||
|
# create a context for each source
|
||||||
|
for device in sources:
|
||||||
|
device_hash = get_device_hash(device)
|
||||||
|
capabilities = device.capabilities(absinfo=False)
|
||||||
|
|
||||||
|
for ev_code in capabilities.get(EV_KEY) or ():
|
||||||
|
input_config = InputConfig(
|
||||||
|
type=EV_KEY,
|
||||||
|
code=ev_code,
|
||||||
|
origin_hash=device_hash,
|
||||||
|
)
|
||||||
|
context_dummy.add_handler(
|
||||||
|
input_config,
|
||||||
|
ForwardToUIHandler(self._results_pipe),
|
||||||
|
)
|
||||||
|
|
||||||
|
for ev_code in capabilities.get(EV_ABS) or ():
|
||||||
|
# positive direction
|
||||||
|
input_config = InputConfig(
|
||||||
|
type=EV_ABS,
|
||||||
|
code=ev_code,
|
||||||
|
analog_threshold=30,
|
||||||
|
origin_hash=device_hash,
|
||||||
|
)
|
||||||
|
mapping = Mapping(
|
||||||
|
input_combination=InputCombination([input_config]),
|
||||||
|
target_uinput=KnownUinput.KEYBOARD,
|
||||||
|
output_symbol="KEY_A",
|
||||||
|
)
|
||||||
|
handler: MappingHandler = AbsToBtnHandler(
|
||||||
|
InputCombination([input_config]),
|
||||||
|
mapping,
|
||||||
|
self.global_uinputs,
|
||||||
|
)
|
||||||
|
handler.set_sub_handler(ForwardToUIHandler(self._results_pipe))
|
||||||
|
context_dummy.add_handler(input_config, handler)
|
||||||
|
|
||||||
|
# negative direction
|
||||||
|
input_config = input_config.modify(analog_threshold=-30)
|
||||||
|
mapping = Mapping(
|
||||||
|
input_combination=InputCombination([input_config]),
|
||||||
|
target_uinput=KnownUinput.KEYBOARD,
|
||||||
|
output_symbol="KEY_A",
|
||||||
|
)
|
||||||
|
handler = AbsToBtnHandler(
|
||||||
|
InputCombination([input_config]),
|
||||||
|
mapping,
|
||||||
|
self.global_uinputs,
|
||||||
|
)
|
||||||
|
handler.set_sub_handler(ForwardToUIHandler(self._results_pipe))
|
||||||
|
context_dummy.add_handler(input_config, handler)
|
||||||
|
|
||||||
|
for ev_code in capabilities.get(EV_REL) or ():
|
||||||
|
# positive direction
|
||||||
|
input_config = InputConfig(
|
||||||
|
type=EV_REL,
|
||||||
|
code=ev_code,
|
||||||
|
analog_threshold=self.rel_xy_speed[ev_code],
|
||||||
|
origin_hash=device_hash,
|
||||||
|
)
|
||||||
|
mapping = Mapping(
|
||||||
|
input_combination=InputCombination([input_config]),
|
||||||
|
target_uinput=KnownUinput.KEYBOARD,
|
||||||
|
output_symbol="KEY_A",
|
||||||
|
release_timeout=RELEASE_TIMEOUT,
|
||||||
|
force_release_timeout=True,
|
||||||
|
)
|
||||||
|
handler = RelToBtnHandler(
|
||||||
|
InputCombination([input_config]),
|
||||||
|
mapping,
|
||||||
|
self.global_uinputs,
|
||||||
|
)
|
||||||
|
handler.set_sub_handler(ForwardToUIHandler(self._results_pipe))
|
||||||
|
context_dummy.add_handler(input_config, handler)
|
||||||
|
|
||||||
|
# negative direction
|
||||||
|
input_config = input_config.modify(
|
||||||
|
analog_threshold=-self.rel_xy_speed[ev_code]
|
||||||
|
)
|
||||||
|
mapping = Mapping(
|
||||||
|
input_combination=InputCombination([input_config]),
|
||||||
|
target_uinput=KnownUinput.KEYBOARD,
|
||||||
|
output_symbol="KEY_A",
|
||||||
|
release_timeout=RELEASE_TIMEOUT,
|
||||||
|
force_release_timeout=True,
|
||||||
|
)
|
||||||
|
handler = RelToBtnHandler(
|
||||||
|
InputCombination([input_config]),
|
||||||
|
mapping,
|
||||||
|
self.global_uinputs,
|
||||||
|
)
|
||||||
|
handler.set_sub_handler(ForwardToUIHandler(self._results_pipe))
|
||||||
|
context_dummy.add_handler(input_config, handler)
|
||||||
|
|
||||||
|
return context_dummy
|
||||||
|
|
||||||
|
|
||||||
|
class ForwardDummy(evdev.UInput):
|
||||||
|
# You may add more attributes of evdev.UInput here for compatibility
|
||||||
|
name: str = "forward-dummy"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def write(*_):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class ContextDummy:
|
||||||
|
"""Used for the reader so that no events are actually written to any uinput."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.listeners = set()
|
||||||
|
self._notify_callbacks = defaultdict(list)
|
||||||
|
self.forward_dummy = ForwardDummy()
|
||||||
|
|
||||||
|
def add_handler(self, input_config: InputConfig, handler: MappingHandler):
|
||||||
|
self._notify_callbacks[input_config.input_match_hash].append(handler.notify)
|
||||||
|
|
||||||
|
def get_notify_callbacks(self, input_event: InputEvent) -> List[NotifyCallback]:
|
||||||
|
return self._notify_callbacks[input_event.input_match_hash]
|
||||||
|
|
||||||
|
def reset(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def get_forward_uinput(self, origin_hash) -> evdev.UInput:
|
||||||
|
"""Don't actually write anything."""
|
||||||
|
return self.forward_dummy
|
||||||
428
inputremapper/gui/user_interface.py
Normal file
428
inputremapper/gui/user_interface.py
Normal 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
272
inputremapper/gui/utils.py
Normal file
@ -0,0 +1,272 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# input-remapper - GUI for device specific keyboard mappings
|
||||||
|
# Copyright (C) 2025 sezanzeb <b8x45ygc9@mozmail.com>
|
||||||
|
#
|
||||||
|
# This file is part of input-remapper.
|
||||||
|
#
|
||||||
|
# input-remapper is free software: you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU General Public License as published by
|
||||||
|
# the Free Software Foundation, either version 3 of the License, or
|
||||||
|
# (at your option) any later version.
|
||||||
|
#
|
||||||
|
# input-remapper is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU General Public License
|
||||||
|
# along with input-remapper. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import time
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import List, Callable, Dict, Optional
|
||||||
|
|
||||||
|
from gi.repository import Gtk, GLib, Gdk
|
||||||
|
|
||||||
|
from inputremapper.logging.logger import logger
|
||||||
|
|
||||||
|
# status ctx ids
|
||||||
|
|
||||||
|
CTX_SAVE = 0
|
||||||
|
CTX_APPLY = 1
|
||||||
|
CTX_KEYCODE = 2
|
||||||
|
CTX_ERROR = 3
|
||||||
|
CTX_WARNING = 4
|
||||||
|
CTX_MAPPING = 5
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass()
|
||||||
|
class DebounceInfo:
|
||||||
|
# constant after register:
|
||||||
|
function: Optional[Callable]
|
||||||
|
other: object
|
||||||
|
key: int
|
||||||
|
|
||||||
|
# can change when called again:
|
||||||
|
args: list
|
||||||
|
kwargs: dict
|
||||||
|
glib_timeout: Optional[int]
|
||||||
|
|
||||||
|
|
||||||
|
class DebounceManager:
|
||||||
|
"""Stops all debounced functions if needed."""
|
||||||
|
|
||||||
|
debounce_infos: Dict[int, DebounceInfo] = {}
|
||||||
|
|
||||||
|
def _register(self, other, function):
|
||||||
|
debounce_info = DebounceInfo(
|
||||||
|
function=function,
|
||||||
|
glib_timeout=None,
|
||||||
|
other=other,
|
||||||
|
args=[],
|
||||||
|
kwargs={},
|
||||||
|
key=self._get_key(other, function),
|
||||||
|
)
|
||||||
|
key = self._get_key(other, function)
|
||||||
|
self.debounce_infos[key] = debounce_info
|
||||||
|
return debounce_info
|
||||||
|
|
||||||
|
def get(self, other: object, function: Callable) -> Optional[DebounceInfo]:
|
||||||
|
"""Find the debounce_info that matches the given callable."""
|
||||||
|
key = self._get_key(other, function)
|
||||||
|
return self.debounce_infos.get(key)
|
||||||
|
|
||||||
|
def _get_key(self, other, function):
|
||||||
|
return f"{id(other)},{function.__name__}"
|
||||||
|
|
||||||
|
def debounce(self, other, function, timeout_ms, *args, **kwargs):
|
||||||
|
"""Call this function with the given args later."""
|
||||||
|
debounce_info = self.get(other, function)
|
||||||
|
if debounce_info is None:
|
||||||
|
debounce_info = self._register(other, function)
|
||||||
|
|
||||||
|
debounce_info.args = args
|
||||||
|
debounce_info.kwargs = kwargs
|
||||||
|
|
||||||
|
glib_timeout = debounce_info.glib_timeout
|
||||||
|
if glib_timeout is not None:
|
||||||
|
GLib.source_remove(glib_timeout)
|
||||||
|
|
||||||
|
def run():
|
||||||
|
self.stop(other, function)
|
||||||
|
return function(other, *args, **kwargs)
|
||||||
|
|
||||||
|
debounce_info.glib_timeout = GLib.timeout_add(
|
||||||
|
timeout_ms,
|
||||||
|
lambda: run(),
|
||||||
|
)
|
||||||
|
|
||||||
|
def stop(self, other: object, function: Callable):
|
||||||
|
"""Stop the current debounce timeout of this function and don't call it.
|
||||||
|
|
||||||
|
New calls to that function will be debounced again.
|
||||||
|
"""
|
||||||
|
debounce_info = self.get(other, function)
|
||||||
|
if debounce_info is None:
|
||||||
|
logger.debug("Tried to stop function that is not currently scheduled")
|
||||||
|
return
|
||||||
|
|
||||||
|
if debounce_info.glib_timeout is not None:
|
||||||
|
GLib.source_remove(debounce_info.glib_timeout)
|
||||||
|
debounce_info.glib_timeout = None
|
||||||
|
|
||||||
|
def stop_all(self):
|
||||||
|
"""No debounced function should be called anymore after this.
|
||||||
|
|
||||||
|
New calls to that function will be debounced again.
|
||||||
|
"""
|
||||||
|
for debounce_info in self.debounce_infos.values():
|
||||||
|
self.stop(debounce_info.other, debounce_info.function)
|
||||||
|
|
||||||
|
def run_all_now(self):
|
||||||
|
"""Don't wait any longer."""
|
||||||
|
for debounce_info in self.debounce_infos.values():
|
||||||
|
if debounce_info.glib_timeout is None:
|
||||||
|
# nothing is currently waiting for this function to be called
|
||||||
|
continue
|
||||||
|
|
||||||
|
self.stop(debounce_info.other, debounce_info.function)
|
||||||
|
try:
|
||||||
|
logger.warning(
|
||||||
|
'Running "%s" now without waiting',
|
||||||
|
debounce_info.function.__name__,
|
||||||
|
)
|
||||||
|
debounce_info.function(
|
||||||
|
debounce_info.other,
|
||||||
|
*debounce_info.args,
|
||||||
|
**debounce_info.kwargs,
|
||||||
|
)
|
||||||
|
except Exception as exception:
|
||||||
|
# if individual functions fails, continue calling the others.
|
||||||
|
# also, don't raise this because there is nowhere this exception
|
||||||
|
# could be caught in a useful way
|
||||||
|
logger.error(exception)
|
||||||
|
|
||||||
|
|
||||||
|
def debounce(timeout):
|
||||||
|
"""Debounce a method call to improve performance.
|
||||||
|
|
||||||
|
Calling this with a millisecond value creates the decorator, so use something like
|
||||||
|
|
||||||
|
@debounce(50)
|
||||||
|
def function(self):
|
||||||
|
...
|
||||||
|
|
||||||
|
In tests, run_all_now can be used to avoid waiting to speed them up.
|
||||||
|
"""
|
||||||
|
# the outside `debounce` function is needed to obtain the millisecond value
|
||||||
|
|
||||||
|
def decorator(function):
|
||||||
|
# the regular decorator.
|
||||||
|
# @decorator
|
||||||
|
# def foo():
|
||||||
|
# ...
|
||||||
|
def wrapped(self, *args, **kwargs):
|
||||||
|
# this is the function that will actually be called
|
||||||
|
debounce_manager.debounce(self, function, timeout, *args, **kwargs)
|
||||||
|
|
||||||
|
wrapped.__name__ = function.__name__
|
||||||
|
|
||||||
|
return wrapped
|
||||||
|
|
||||||
|
return decorator
|
||||||
|
|
||||||
|
|
||||||
|
debounce_manager = DebounceManager()
|
||||||
|
|
||||||
|
|
||||||
|
class HandlerDisabled:
|
||||||
|
"""Safely modify a widget without causing handlers to be called.
|
||||||
|
|
||||||
|
Use in a `with` statement.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, widget: Gtk.Widget, handler: Callable):
|
||||||
|
self.widget = widget
|
||||||
|
self.handler = handler
|
||||||
|
|
||||||
|
def __enter__(self):
|
||||||
|
try:
|
||||||
|
self.widget.handler_block_by_func(self.handler)
|
||||||
|
except TypeError as error:
|
||||||
|
# if nothing is connected to the given signal, it is not critical
|
||||||
|
# at all
|
||||||
|
logger.warning('HandlerDisabled entry failed: "%s"', error)
|
||||||
|
|
||||||
|
def __exit__(self, *_):
|
||||||
|
try:
|
||||||
|
self.widget.handler_unblock_by_func(self.handler)
|
||||||
|
except TypeError as error:
|
||||||
|
logger.warning('HandlerDisabled exit failed: "%s"', error)
|
||||||
|
|
||||||
|
|
||||||
|
def gtk_iteration(iterations=0):
|
||||||
|
"""Iterate while events are pending."""
|
||||||
|
while Gtk.events_pending():
|
||||||
|
Gtk.main_iteration()
|
||||||
|
for _ in range(iterations):
|
||||||
|
time.sleep(0.002)
|
||||||
|
while Gtk.events_pending():
|
||||||
|
Gtk.main_iteration()
|
||||||
|
|
||||||
|
|
||||||
|
class Colors:
|
||||||
|
"""Looks up colors from the GTK theme.
|
||||||
|
|
||||||
|
Defaults to libadwaita-light theme colors if the lookup fails.
|
||||||
|
"""
|
||||||
|
|
||||||
|
fallback_accent = Gdk.RGBA(0.21, 0.52, 0.89, 1)
|
||||||
|
fallback_background = Gdk.RGBA(0.98, 0.98, 0.98, 1)
|
||||||
|
fallback_base = Gdk.RGBA(1, 1, 1, 1)
|
||||||
|
fallback_border = Gdk.RGBA(0.87, 0.87, 0.87, 1)
|
||||||
|
fallback_font = Gdk.RGBA(0.20, 0.20, 0.20, 1)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_color(names: List[str], fallback: Gdk.RGBA) -> Gdk.RGBA:
|
||||||
|
"""Get theme colors. Provide multiple names for fallback purposes."""
|
||||||
|
for name in names:
|
||||||
|
found, color = Gtk.StyleContext().lookup_color(name)
|
||||||
|
if found:
|
||||||
|
return color
|
||||||
|
|
||||||
|
return fallback
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_accent_color() -> Gdk.RGBA:
|
||||||
|
"""Look up the accent color from the current theme."""
|
||||||
|
return Colors.get_color(
|
||||||
|
["accent_bg_color", "theme_selected_bg_color"],
|
||||||
|
Colors.fallback_accent,
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_background_color() -> Gdk.RGBA:
|
||||||
|
"""Look up the background-color from the current theme."""
|
||||||
|
return Colors.get_color(
|
||||||
|
["theme_bg_color"],
|
||||||
|
Colors.fallback_background,
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_base_color() -> Gdk.RGBA:
|
||||||
|
"""Look up the base-color from the current theme."""
|
||||||
|
return Colors.get_color(
|
||||||
|
["theme_base_color"],
|
||||||
|
Colors.fallback_base,
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_border_color() -> Gdk.RGBA:
|
||||||
|
"""Look up the border from the current theme."""
|
||||||
|
return Colors.get_color(["borders"], Colors.fallback_border)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_font_color() -> Gdk.RGBA:
|
||||||
|
"""Look up the border from the current theme."""
|
||||||
|
return Colors.get_color(
|
||||||
|
["theme_fg_color"],
|
||||||
|
Colors.fallback_font,
|
||||||
|
)
|
||||||
0
inputremapper/injection/__init__.py
Normal file
0
inputremapper/injection/__init__.py
Normal file
132
inputremapper/injection/context.py
Normal file
132
inputremapper/injection/context.py
Normal file
@ -0,0 +1,132 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# input-remapper - GUI for device specific keyboard mappings
|
||||||
|
# Copyright (C) 2025 sezanzeb <b8x45ygc9@mozmail.com>
|
||||||
|
#
|
||||||
|
# This file is part of input-remapper.
|
||||||
|
#
|
||||||
|
# input-remapper is free software: you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU General Public License as published by
|
||||||
|
# the Free Software Foundation, either version 3 of the License, or
|
||||||
|
# (at your option) any later version.
|
||||||
|
#
|
||||||
|
# input-remapper is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU General Public License
|
||||||
|
# along with input-remapper. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
|
||||||
|
"""Stores injection-process wide information."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections import defaultdict
|
||||||
|
from typing import List, Dict, Set, Hashable
|
||||||
|
|
||||||
|
import evdev
|
||||||
|
|
||||||
|
from inputremapper.configs.preset import Preset
|
||||||
|
from inputremapper.injection.mapping_handlers.mapping_handler import (
|
||||||
|
EventListener,
|
||||||
|
NotifyCallback,
|
||||||
|
)
|
||||||
|
from inputremapper.injection.mapping_handlers.mapping_parser import (
|
||||||
|
MappingParser,
|
||||||
|
EventPipelines,
|
||||||
|
)
|
||||||
|
from inputremapper.input_event import InputEvent
|
||||||
|
from inputremapper.logging.logger import logger
|
||||||
|
from inputremapper.utils import DeviceHash
|
||||||
|
|
||||||
|
|
||||||
|
class Context:
|
||||||
|
"""Stores injection-process wide information.
|
||||||
|
|
||||||
|
In some ways this is a wrapper for the preset that derives some
|
||||||
|
information that is specifically important to the injection.
|
||||||
|
|
||||||
|
The information in the context does not change during the injection.
|
||||||
|
|
||||||
|
One Context exists for each injection process, which is shared
|
||||||
|
with all coroutines and used objects.
|
||||||
|
|
||||||
|
Benefits of the context:
|
||||||
|
- less redundant passing around of parameters
|
||||||
|
- easier to add new process wide information without having to adjust
|
||||||
|
all function calls in unittests
|
||||||
|
- makes the injection class shorter and more specific to a certain task,
|
||||||
|
which is actually spinning up the injection.
|
||||||
|
|
||||||
|
Note, that for the reader_service a ContextDummy is used.
|
||||||
|
|
||||||
|
Members
|
||||||
|
-------
|
||||||
|
preset : Preset
|
||||||
|
The preset holds all Mappings for the injection process
|
||||||
|
listeners : Set[EventListener]
|
||||||
|
A set of callbacks which receive all events
|
||||||
|
callbacks : Dict[Tuple[int, int], List[NotifyCallback]]
|
||||||
|
All entry points to the event pipeline sorted by InputEvent.type_and_code
|
||||||
|
"""
|
||||||
|
|
||||||
|
listeners: Set[EventListener]
|
||||||
|
_notify_callbacks: Dict[Hashable, List[NotifyCallback]]
|
||||||
|
_handlers: EventPipelines
|
||||||
|
_forward_devices: Dict[DeviceHash, evdev.UInput]
|
||||||
|
_source_devices: Dict[DeviceHash, evdev.InputDevice]
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
preset: Preset,
|
||||||
|
source_devices: Dict[DeviceHash, evdev.InputDevice],
|
||||||
|
forward_devices: Dict[DeviceHash, evdev.UInput],
|
||||||
|
mapping_parser: MappingParser,
|
||||||
|
) -> None:
|
||||||
|
if len(forward_devices) == 0:
|
||||||
|
logger.warning("forward_devices not set")
|
||||||
|
|
||||||
|
if len(source_devices) == 0:
|
||||||
|
logger.warning("source_devices not set")
|
||||||
|
|
||||||
|
self.listeners = set()
|
||||||
|
self._source_devices = source_devices
|
||||||
|
self._forward_devices = forward_devices
|
||||||
|
self._notify_callbacks = defaultdict(list)
|
||||||
|
self._handlers = mapping_parser.parse_mappings(preset, self)
|
||||||
|
|
||||||
|
self._create_callbacks()
|
||||||
|
|
||||||
|
def reset(self) -> None:
|
||||||
|
"""Call the reset method for each handler in the context."""
|
||||||
|
for handlers in self._handlers.values():
|
||||||
|
for handler in handlers:
|
||||||
|
handler.reset()
|
||||||
|
|
||||||
|
def _create_callbacks(self) -> None:
|
||||||
|
"""Add the notify method from all _handlers to self.callbacks."""
|
||||||
|
for input_config, handler_list in self._handlers.items():
|
||||||
|
input_match_hash = input_config.input_match_hash
|
||||||
|
logger.debug("Adding NotifyCallback for %s", input_match_hash)
|
||||||
|
self._notify_callbacks[input_match_hash].extend(
|
||||||
|
handler.notify for handler in handler_list
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_notify_callbacks(self, input_event: InputEvent) -> List[NotifyCallback]:
|
||||||
|
input_match_hash = input_event.input_match_hash
|
||||||
|
return self._notify_callbacks[input_match_hash]
|
||||||
|
|
||||||
|
def get_forward_uinput(self, origin_hash: DeviceHash) -> evdev.UInput:
|
||||||
|
"""Get the "forward" uinput events from the given origin should go into."""
|
||||||
|
return self._forward_devices[origin_hash]
|
||||||
|
|
||||||
|
def get_source(self, key: DeviceHash) -> evdev.InputDevice:
|
||||||
|
return self._source_devices[key]
|
||||||
|
|
||||||
|
def get_leds(self) -> Set[int]:
|
||||||
|
"""Get a set of LED_* ecodes that are currently on."""
|
||||||
|
leds = set()
|
||||||
|
for device in self._source_devices.values():
|
||||||
|
leds.update(device.leds())
|
||||||
|
return leds
|
||||||
205
inputremapper/injection/event_reader.py
Normal file
205
inputremapper/injection/event_reader.py
Normal file
@ -0,0 +1,205 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# input-remapper - GUI for device specific keyboard mappings
|
||||||
|
# Copyright (C) 2025 sezanzeb <b8x45ygc9@mozmail.com>
|
||||||
|
#
|
||||||
|
# This file is part of input-remapper.
|
||||||
|
#
|
||||||
|
# input-remapper is free software: you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU General Public License as published by
|
||||||
|
# the Free Software Foundation, either version 3 of the License, or
|
||||||
|
# (at your option) any later version.
|
||||||
|
#
|
||||||
|
# input-remapper is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU General Public License
|
||||||
|
# along with input-remapper. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
|
||||||
|
"""Because multiple calls to async_read_loop won't work."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import os
|
||||||
|
import traceback
|
||||||
|
from typing import AsyncIterator, Protocol, Set, List
|
||||||
|
|
||||||
|
import evdev
|
||||||
|
|
||||||
|
from inputremapper.injection.mapping_handlers.mapping_handler import (
|
||||||
|
EventListener,
|
||||||
|
NotifyCallback,
|
||||||
|
)
|
||||||
|
from inputremapper.input_event import InputEvent
|
||||||
|
from inputremapper.logging.logger import logger
|
||||||
|
from inputremapper.utils import get_device_hash, DeviceHash
|
||||||
|
|
||||||
|
|
||||||
|
class Context(Protocol):
|
||||||
|
listeners: Set[EventListener]
|
||||||
|
|
||||||
|
def reset(self): ...
|
||||||
|
|
||||||
|
def get_notify_callbacks(self, input_event: InputEvent) -> List[NotifyCallback]: ...
|
||||||
|
|
||||||
|
def get_forward_uinput(self, origin_hash: DeviceHash) -> evdev.UInput: ...
|
||||||
|
|
||||||
|
|
||||||
|
class EventReader:
|
||||||
|
"""Reads input events from a single device and distributes them.
|
||||||
|
|
||||||
|
There is one EventReader object for each source, which tells multiple
|
||||||
|
mapping_handlers that a new event is ready so that they can inject all sorts of
|
||||||
|
funny things.
|
||||||
|
|
||||||
|
Other devnodes may be present for the hardware device, in which case this
|
||||||
|
needs to be created multiple times.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
context: Context,
|
||||||
|
source: evdev.InputDevice,
|
||||||
|
stop_event: asyncio.Event,
|
||||||
|
) -> None:
|
||||||
|
"""Initialize all mapping_handlers
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
source
|
||||||
|
where to read keycodes from
|
||||||
|
"""
|
||||||
|
self._device_hash = get_device_hash(source)
|
||||||
|
self._source = source
|
||||||
|
self.context = context
|
||||||
|
self.stop_event = stop_event
|
||||||
|
|
||||||
|
def stop(self):
|
||||||
|
"""Stop the reader."""
|
||||||
|
self.stop_event.set()
|
||||||
|
|
||||||
|
async def read_loop(self) -> AsyncIterator[evdev.InputEvent]:
|
||||||
|
stop_task = asyncio.Task(self.stop_event.wait())
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
events_ready = asyncio.Event()
|
||||||
|
loop.add_reader(self._source.fileno(), events_ready.set)
|
||||||
|
|
||||||
|
while True:
|
||||||
|
_, pending = await asyncio.wait(
|
||||||
|
{stop_task, asyncio.Task(events_ready.wait())},
|
||||||
|
return_when=asyncio.FIRST_COMPLETED,
|
||||||
|
)
|
||||||
|
|
||||||
|
fd_broken = os.stat(self._source.fileno()).st_nlink == 0
|
||||||
|
if fd_broken:
|
||||||
|
# happens when the device is unplugged while reading, causing 100% cpu
|
||||||
|
# usage because events_ready.set is called repeatedly forever,
|
||||||
|
# while read_loop will hang at self._source.read_one().
|
||||||
|
logger.error("fd broke, was the device unplugged?")
|
||||||
|
|
||||||
|
if stop_task.done() or fd_broken:
|
||||||
|
for task in pending:
|
||||||
|
task.cancel()
|
||||||
|
loop.remove_reader(self._source.fileno())
|
||||||
|
logger.debug("read loop stopped")
|
||||||
|
return
|
||||||
|
|
||||||
|
events_ready.clear()
|
||||||
|
while event := self._source.read_one():
|
||||||
|
yield event
|
||||||
|
|
||||||
|
def send_to_handlers(self, event: InputEvent) -> bool:
|
||||||
|
"""Send the event to the NotifyCallbacks.
|
||||||
|
|
||||||
|
Return if anyone took care of the event.
|
||||||
|
"""
|
||||||
|
if event.type == evdev.ecodes.EV_MSC:
|
||||||
|
return False
|
||||||
|
|
||||||
|
if event.type == evdev.ecodes.EV_SYN:
|
||||||
|
return False
|
||||||
|
|
||||||
|
handled = False
|
||||||
|
notify_callbacks = self.context.get_notify_callbacks(event)
|
||||||
|
|
||||||
|
if notify_callbacks:
|
||||||
|
for notify_callback in notify_callbacks:
|
||||||
|
handled = notify_callback(event, source=self._source) | handled
|
||||||
|
|
||||||
|
return handled
|
||||||
|
|
||||||
|
async def send_to_listeners(self, event: InputEvent) -> None:
|
||||||
|
"""Send the event to listeners."""
|
||||||
|
if event.type == evdev.ecodes.EV_MSC:
|
||||||
|
return
|
||||||
|
|
||||||
|
if event.type == evdev.ecodes.EV_SYN:
|
||||||
|
return
|
||||||
|
|
||||||
|
for listener in self.context.listeners.copy():
|
||||||
|
# use a copy, since the listeners might remove themselves from the set
|
||||||
|
|
||||||
|
await listener(event)
|
||||||
|
|
||||||
|
# Running macros have priority, give them a head-start for processing the
|
||||||
|
# event. If if_single injects a modifier, this modifier should be active
|
||||||
|
# before the next handler injects an "a" or something, so that it is
|
||||||
|
# possible to capitalize it via if_single.
|
||||||
|
# 1. Event from keyboard arrives (e.g. an "a")
|
||||||
|
# 2. the listener for if_single is called
|
||||||
|
# 3. if_single decides runs then (e.g. injects shift_L)
|
||||||
|
# 4. The original event is forwarded (or whatever it is supposed to do)
|
||||||
|
# 5. Capitalized "A" is injected.
|
||||||
|
# So make sure to call the listeners before notifying the handlers.
|
||||||
|
for _ in range(5):
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
|
||||||
|
def forward(self, event: InputEvent) -> None:
|
||||||
|
"""Forward an event, which injects it unmodified."""
|
||||||
|
forward_to = self.context.get_forward_uinput(self._device_hash)
|
||||||
|
logger.write(event, forward_to)
|
||||||
|
forward_to.write(*event.event_tuple)
|
||||||
|
|
||||||
|
async def handle(self, event: InputEvent) -> None:
|
||||||
|
if event.type == evdev.ecodes.EV_KEY and event.value == 2:
|
||||||
|
# button-hold event. Environments (gnome, etc.) create them on
|
||||||
|
# their own for the injection-fake-device if the release event
|
||||||
|
# won't appear, no need to forward or map them.
|
||||||
|
return
|
||||||
|
|
||||||
|
await self.send_to_listeners(event)
|
||||||
|
|
||||||
|
handled = self.send_to_handlers(event)
|
||||||
|
|
||||||
|
if not handled:
|
||||||
|
# no handler took care of it, forward it
|
||||||
|
self.forward(event)
|
||||||
|
|
||||||
|
async def run(self):
|
||||||
|
"""Start doing things.
|
||||||
|
|
||||||
|
Can be stopped by stopping the asyncio loop or by setting the stop_event.
|
||||||
|
This loop reads events from a single device only.
|
||||||
|
"""
|
||||||
|
logger.debug(
|
||||||
|
"Starting to listen for events from %s, fd %s",
|
||||||
|
self._source.path,
|
||||||
|
self._source.fd,
|
||||||
|
)
|
||||||
|
|
||||||
|
async for event in self.read_loop():
|
||||||
|
try:
|
||||||
|
# Fire and forget, so that handlers and listeners can take their time,
|
||||||
|
# if they want to wait for something special to happen.
|
||||||
|
asyncio.ensure_future(
|
||||||
|
self.handle(
|
||||||
|
InputEvent.from_event(event, origin_hash=self._device_hash)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Handling event %s failed with %s", event, type(e))
|
||||||
|
traceback.print_exception(e)
|
||||||
|
|
||||||
|
self.context.reset()
|
||||||
|
logger.info("read loop for %s stopped", self._source.path)
|
||||||
192
inputremapper/injection/global_uinputs.py
Normal file
192
inputremapper/injection/global_uinputs.py
Normal file
@ -0,0 +1,192 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# input-remapper - GUI for device specific keyboard mappings
|
||||||
|
# Copyright (C) 2025 sezanzeb <b8x45ygc9@mozmail.com>
|
||||||
|
#
|
||||||
|
# This file is part of input-remapper.
|
||||||
|
#
|
||||||
|
# input-remapper is free software: you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU General Public License as published by
|
||||||
|
# the Free Software Foundation, either version 3 of the License, or
|
||||||
|
# (at your option) any later version.
|
||||||
|
#
|
||||||
|
# input-remapper is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU General Public License
|
||||||
|
# along with input-remapper. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
from typing import Dict, Union, Tuple, Optional, List, Type
|
||||||
|
|
||||||
|
import evdev
|
||||||
|
|
||||||
|
import inputremapper.exceptions
|
||||||
|
import inputremapper.utils
|
||||||
|
from inputremapper.logging.logger import logger
|
||||||
|
|
||||||
|
MIN_ABS = -(2**15) # -32768
|
||||||
|
MAX_ABS = 2**15 # 32768
|
||||||
|
DEV_NAME = "input-remapper"
|
||||||
|
DEFAULT_UINPUTS = {
|
||||||
|
# for event codes see linux/input-event-codes.h
|
||||||
|
"keyboard": {
|
||||||
|
evdev.ecodes.EV_KEY: list(evdev.ecodes.KEY.keys() & evdev.ecodes.keys.keys())
|
||||||
|
},
|
||||||
|
"gamepad": {
|
||||||
|
evdev.ecodes.EV_KEY: [*range(0x130, 0x13F)], # BTN_SOUTH - BTN_THUMBR
|
||||||
|
evdev.ecodes.EV_ABS: [
|
||||||
|
*(
|
||||||
|
(i, evdev.AbsInfo(0, MIN_ABS, MAX_ABS, 0, 0, 0))
|
||||||
|
for i in range(0x00, 0x06)
|
||||||
|
),
|
||||||
|
*((i, evdev.AbsInfo(0, -1, 1, 0, 0, 0)) for i in range(0x10, 0x12)),
|
||||||
|
], # 6-axis and 1 hat switch
|
||||||
|
},
|
||||||
|
"mouse": {
|
||||||
|
evdev.ecodes.EV_KEY: [*range(0x110, 0x118)], # BTN_LEFT - BTN_TASK
|
||||||
|
evdev.ecodes.EV_REL: [*range(0x00, 0x0D)], # all REL axis
|
||||||
|
},
|
||||||
|
}
|
||||||
|
DEFAULT_UINPUTS["keyboard + mouse"] = {
|
||||||
|
evdev.ecodes.EV_KEY: [
|
||||||
|
*DEFAULT_UINPUTS["keyboard"][evdev.ecodes.EV_KEY],
|
||||||
|
*DEFAULT_UINPUTS["mouse"][evdev.ecodes.EV_KEY],
|
||||||
|
],
|
||||||
|
evdev.ecodes.EV_REL: [
|
||||||
|
*DEFAULT_UINPUTS["mouse"][evdev.ecodes.EV_REL],
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class UInput(evdev.UInput):
|
||||||
|
_capabilities_cache: Optional[Dict] = None
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
name = kwargs["name"]
|
||||||
|
logger.debug('creating UInput device: "%s"', name)
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
|
def can_emit(self, event: Tuple[int, int, int]):
|
||||||
|
"""Check if an event can be emitted by the UIinput.
|
||||||
|
|
||||||
|
Wrong events might be injected if the group mappings are wrong,
|
||||||
|
"""
|
||||||
|
# this will never change, so we cache it since evdev runs an expensive loop to
|
||||||
|
# gather the capabilities. (can_emit is called regularly)
|
||||||
|
if self._capabilities_cache is None:
|
||||||
|
self._capabilities_cache = self.capabilities(absinfo=False)
|
||||||
|
|
||||||
|
return event[1] in self._capabilities_cache.get(event[0], [])
|
||||||
|
|
||||||
|
|
||||||
|
class FrontendUInput:
|
||||||
|
"""Uinput which can not actually send events, for use in the frontend."""
|
||||||
|
|
||||||
|
def __init__(self, *_, events=None, name="py-evdev-uinput", **__):
|
||||||
|
# see https://python-evdev.readthedocs.io/en/latest/apidoc.html#module-evdev.uinput # noqa pylint: disable=line-too-long
|
||||||
|
self.events = events
|
||||||
|
self.name = name
|
||||||
|
|
||||||
|
logger.debug('creating fake UInput device: "%s"', self.name)
|
||||||
|
|
||||||
|
def capabilities(self):
|
||||||
|
return self.events
|
||||||
|
|
||||||
|
|
||||||
|
class GlobalUInputs:
|
||||||
|
"""Manages all UInputs that are shared between all injection processes."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
uinput_factory: Union[Type[UInput], Type[FrontendUInput]],
|
||||||
|
):
|
||||||
|
self.devices: Dict[str, Union[UInput, FrontendUInput]] = {}
|
||||||
|
self._uinput_factory = uinput_factory
|
||||||
|
|
||||||
|
def __iter__(self):
|
||||||
|
return iter(uinput for _, uinput in self.devices.items())
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def can_default_uinput_emit(target: str, type_: int, code: int) -> bool:
|
||||||
|
"""Check if the uinput with the target name is capable of the event."""
|
||||||
|
capabilities = DEFAULT_UINPUTS.get(target, {}).get(type_)
|
||||||
|
return capabilities is not None and code in capabilities
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def find_fitting_default_uinputs(type_: int, code: int) -> List[str]:
|
||||||
|
"""Find the names of default uinputs that are able to emit this event."""
|
||||||
|
return [
|
||||||
|
uinput
|
||||||
|
for uinput in DEFAULT_UINPUTS
|
||||||
|
if code in DEFAULT_UINPUTS[uinput].get(type_, [])
|
||||||
|
]
|
||||||
|
|
||||||
|
def reset(self):
|
||||||
|
self.devices = {}
|
||||||
|
self.prepare_all()
|
||||||
|
|
||||||
|
def prepare_all(self):
|
||||||
|
"""Generate UInputs."""
|
||||||
|
for name, events in DEFAULT_UINPUTS.items():
|
||||||
|
if name in self.devices.keys():
|
||||||
|
continue
|
||||||
|
|
||||||
|
self.devices[name] = self._uinput_factory(
|
||||||
|
name=f"{DEV_NAME} {name}",
|
||||||
|
phys=DEV_NAME,
|
||||||
|
events=events,
|
||||||
|
)
|
||||||
|
|
||||||
|
def prepare_single(self, name: str):
|
||||||
|
"""Generate a single uinput.
|
||||||
|
|
||||||
|
This has to be done in the main process before injections that use it start.
|
||||||
|
"""
|
||||||
|
if name not in DEFAULT_UINPUTS:
|
||||||
|
raise KeyError("Could not find a matching uinput to generate.")
|
||||||
|
|
||||||
|
if name in self.devices:
|
||||||
|
logger.debug('Target "%s" already exists', name)
|
||||||
|
return
|
||||||
|
|
||||||
|
self.devices[name] = self._uinput_factory(
|
||||||
|
name=f"{DEV_NAME} {name}",
|
||||||
|
phys=DEV_NAME,
|
||||||
|
events=DEFAULT_UINPUTS[name],
|
||||||
|
)
|
||||||
|
|
||||||
|
def write(self, event: Tuple[int, int, int], target_uinput):
|
||||||
|
"""Write event to target uinput."""
|
||||||
|
uinput = self.get_uinput(target_uinput)
|
||||||
|
if not uinput:
|
||||||
|
raise inputremapper.exceptions.UinputNotAvailable(target_uinput)
|
||||||
|
|
||||||
|
if not uinput.can_emit(event):
|
||||||
|
raise inputremapper.exceptions.EventNotHandled(event)
|
||||||
|
|
||||||
|
# Was bool once due to a bug during development
|
||||||
|
assert not isinstance(event[2], bool) and isinstance(event[2], int)
|
||||||
|
|
||||||
|
logger.write(event, uinput)
|
||||||
|
uinput.write(*event)
|
||||||
|
uinput.syn()
|
||||||
|
|
||||||
|
def get_uinput(self, name: str) -> Optional[evdev.UInput]:
|
||||||
|
"""UInput with name
|
||||||
|
|
||||||
|
Or None if there is no uinput with this name.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
name
|
||||||
|
uniqe name of the uinput device
|
||||||
|
"""
|
||||||
|
if name not in self.devices:
|
||||||
|
logger.error(
|
||||||
|
f'UInput "{name}" is unknown. '
|
||||||
|
+ f"Available: {list(self.devices.keys())}"
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
return self.devices.get(name)
|
||||||
511
inputremapper/injection/injector.py
Normal file
511
inputremapper/injection/injector.py
Normal file
@ -0,0 +1,511 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# input-remapper - GUI for device specific keyboard mappings
|
||||||
|
# Copyright (C) 2025 sezanzeb <b8x45ygc9@mozmail.com>
|
||||||
|
#
|
||||||
|
# This file is part of input-remapper.
|
||||||
|
#
|
||||||
|
# input-remapper is free software: you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU General Public License as published by
|
||||||
|
# the Free Software Foundation, either version 3 of the License, or
|
||||||
|
# (at your option) any later version.
|
||||||
|
#
|
||||||
|
# input-remapper is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU General Public License
|
||||||
|
# along with input-remapper. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
|
||||||
|
"""Keeps injecting keycodes in the background based on the preset."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import enum
|
||||||
|
import multiprocessing
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
from collections import defaultdict
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from multiprocessing.connection import Connection
|
||||||
|
from typing import Dict, List, Optional, Tuple, Union
|
||||||
|
|
||||||
|
import evdev
|
||||||
|
|
||||||
|
from inputremapper.configs.input_config import InputCombination, InputConfig
|
||||||
|
from inputremapper.configs.preset import Preset
|
||||||
|
from inputremapper.groups import (
|
||||||
|
_Group,
|
||||||
|
classify,
|
||||||
|
DeviceType,
|
||||||
|
)
|
||||||
|
from inputremapper.gui.messages.message_broker import MessageType
|
||||||
|
from inputremapper.injection.context import Context
|
||||||
|
from inputremapper.injection.event_reader import EventReader
|
||||||
|
from inputremapper.injection.mapping_handlers.mapping_parser import MappingParser
|
||||||
|
from inputremapper.injection.numlock import set_numlock, is_numlock_on, ensure_numlock
|
||||||
|
from inputremapper.logging.logger import logger
|
||||||
|
from inputremapper.utils import get_device_hash, DeviceHash
|
||||||
|
|
||||||
|
CapabilitiesDict = Dict[int, List[int]]
|
||||||
|
|
||||||
|
DEV_NAME = "input-remapper"
|
||||||
|
|
||||||
|
|
||||||
|
# messages sent to the injector process
|
||||||
|
class InjectorCommand(str, enum.Enum):
|
||||||
|
CLOSE = "CLOSE"
|
||||||
|
|
||||||
|
|
||||||
|
# messages the injector process reports back to the service
|
||||||
|
class InjectorState(str, enum.Enum):
|
||||||
|
UNKNOWN = "UNKNOWN"
|
||||||
|
STARTING = "STARTING"
|
||||||
|
ERROR = "FAILED"
|
||||||
|
RUNNING = "RUNNING"
|
||||||
|
STOPPED = "STOPPED"
|
||||||
|
NO_GRAB = "NO_GRAB"
|
||||||
|
UPGRADE_EVDEV = "UPGRADE_EVDEV"
|
||||||
|
|
||||||
|
|
||||||
|
def is_in_capabilities(
|
||||||
|
combination: InputCombination, capabilities: CapabilitiesDict
|
||||||
|
) -> bool:
|
||||||
|
"""Are this combination or one of its sub keys in the capabilities?"""
|
||||||
|
for event in combination:
|
||||||
|
if event.code in capabilities.get(event.type, []):
|
||||||
|
return True
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def get_udev_name(name: str, suffix: str) -> str:
|
||||||
|
"""Make sure the generated name is not longer than 80 chars."""
|
||||||
|
max_len = 80 # based on error messages
|
||||||
|
remaining_len = max_len - len(DEV_NAME) - len(suffix) - 2
|
||||||
|
middle = name[:remaining_len]
|
||||||
|
name = f"{DEV_NAME} {middle} {suffix}"
|
||||||
|
return name
|
||||||
|
|
||||||
|
|
||||||
|
def get_forward_name(name: str) -> str:
|
||||||
|
"""Keep forwarded uinput names within evdev's 80 character limit."""
|
||||||
|
return name[:80]
|
||||||
|
|
||||||
|
|
||||||
|
def get_forward_phys(source: evdev.InputDevice) -> str:
|
||||||
|
"""Use a stable phys marker for forwarded devices.
|
||||||
|
|
||||||
|
The original phys path must not be reused because it makes the forwarded
|
||||||
|
device look like the hardware device to our autoload rule. However, using a
|
||||||
|
dedicated input-remapper phys marker still allows us to identify and ignore
|
||||||
|
the forwarded device elsewhere.
|
||||||
|
"""
|
||||||
|
if source.phys:
|
||||||
|
return f"{DEV_NAME}/{source.phys}"
|
||||||
|
|
||||||
|
return DEV_NAME
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class InjectorStateMessage:
|
||||||
|
message_type = MessageType.injector_state
|
||||||
|
state: Union[InjectorState]
|
||||||
|
|
||||||
|
def active(self) -> bool:
|
||||||
|
return self.state in [InjectorState.RUNNING, InjectorState.STARTING]
|
||||||
|
|
||||||
|
def inactive(self) -> bool:
|
||||||
|
return self.state in [InjectorState.STOPPED, InjectorState.NO_GRAB]
|
||||||
|
|
||||||
|
|
||||||
|
class Injector(multiprocessing.Process):
|
||||||
|
"""Initializes, starts and stops injections.
|
||||||
|
|
||||||
|
Is a process to make it non-blocking for the rest of the code and to
|
||||||
|
make running multiple injector easier. There is one process per
|
||||||
|
hardware-device that is being mapped.
|
||||||
|
"""
|
||||||
|
|
||||||
|
group: _Group
|
||||||
|
preset: Preset
|
||||||
|
context: Optional[Context]
|
||||||
|
_devices: List[evdev.InputDevice]
|
||||||
|
_state: InjectorState
|
||||||
|
_msg_pipe: Tuple[Connection, Connection]
|
||||||
|
_event_readers: List[EventReader]
|
||||||
|
_stop_event: asyncio.Event
|
||||||
|
|
||||||
|
regrab_timeout = 0.2
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
group: _Group,
|
||||||
|
preset: Preset,
|
||||||
|
mapping_parser: MappingParser,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
group
|
||||||
|
the device group
|
||||||
|
"""
|
||||||
|
self.group = group
|
||||||
|
self.mapping_parser = mapping_parser
|
||||||
|
self._state = InjectorState.UNKNOWN
|
||||||
|
|
||||||
|
# used to interact with the parts of this class that are running within
|
||||||
|
# the new process
|
||||||
|
self._msg_pipe = multiprocessing.Pipe()
|
||||||
|
|
||||||
|
self.preset = preset
|
||||||
|
self.context = None # only needed inside the injection process
|
||||||
|
|
||||||
|
self._event_readers = []
|
||||||
|
|
||||||
|
super().__init__(name=group.key)
|
||||||
|
|
||||||
|
"""Functions to interact with the running process."""
|
||||||
|
|
||||||
|
def get_state(self) -> InjectorState:
|
||||||
|
"""Get the state of the injection.
|
||||||
|
|
||||||
|
Can be safely called from the main process.
|
||||||
|
"""
|
||||||
|
# before we try to we try to guess anything lets check if there is a message
|
||||||
|
state = self._state
|
||||||
|
while self._msg_pipe[1].poll():
|
||||||
|
state = self._msg_pipe[1].recv()
|
||||||
|
|
||||||
|
# figure out what is going on step by step
|
||||||
|
alive = self.is_alive()
|
||||||
|
|
||||||
|
# if `self.start()` has been called
|
||||||
|
started = state != InjectorState.UNKNOWN or alive
|
||||||
|
|
||||||
|
if started:
|
||||||
|
if state == InjectorState.UNKNOWN and alive:
|
||||||
|
# if it is alive, it is definitely at least starting up.
|
||||||
|
state = InjectorState.STARTING
|
||||||
|
|
||||||
|
if state in (InjectorState.STARTING, InjectorState.RUNNING) and not alive:
|
||||||
|
# we thought it is running (maybe it was when get_state was previously),
|
||||||
|
# but the process is not alive. It probably crashed
|
||||||
|
state = InjectorState.ERROR
|
||||||
|
logger.error("Injector was unexpectedly found stopped")
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
'Injector state of "%s", "%s": %s',
|
||||||
|
self.group.key,
|
||||||
|
self.preset.name,
|
||||||
|
state,
|
||||||
|
)
|
||||||
|
self._state = state
|
||||||
|
return self._state
|
||||||
|
|
||||||
|
@ensure_numlock
|
||||||
|
def stop_injecting(self) -> None:
|
||||||
|
"""Stop injecting keycodes.
|
||||||
|
|
||||||
|
Can be safely called from the main procss.
|
||||||
|
"""
|
||||||
|
logger.info('Stopping injecting keycodes for group "%s"', self.group.key)
|
||||||
|
self._msg_pipe[1].send(InjectorCommand.CLOSE)
|
||||||
|
|
||||||
|
"""Process internal stuff."""
|
||||||
|
|
||||||
|
def _find_input_device(
|
||||||
|
self, input_config: InputConfig
|
||||||
|
) -> Optional[evdev.InputDevice]:
|
||||||
|
"""find the InputDevice specified by the InputConfig
|
||||||
|
|
||||||
|
ensures the devices supports the type and code specified by the InputConfig"""
|
||||||
|
devices_by_hash = {get_device_hash(device): device for device in self._devices}
|
||||||
|
|
||||||
|
# mypy thinks None is the wrong type for dict.get()
|
||||||
|
if device := devices_by_hash.get(input_config.origin_hash): # type: ignore
|
||||||
|
if input_config.code in device.capabilities(absinfo=False).get(
|
||||||
|
input_config.type, []
|
||||||
|
):
|
||||||
|
return device
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _find_input_device_fallback(
|
||||||
|
self, input_config: InputConfig
|
||||||
|
) -> Optional[evdev.InputDevice]:
|
||||||
|
"""find the InputDevice specified by the InputConfig fallback logic"""
|
||||||
|
ranking = [
|
||||||
|
DeviceType.KEYBOARD,
|
||||||
|
DeviceType.GAMEPAD,
|
||||||
|
DeviceType.MOUSE,
|
||||||
|
DeviceType.TOUCHPAD,
|
||||||
|
DeviceType.GRAPHICS_TABLET,
|
||||||
|
DeviceType.CAMERA,
|
||||||
|
DeviceType.UNKNOWN,
|
||||||
|
]
|
||||||
|
candidates: List[evdev.InputDevice] = [
|
||||||
|
device
|
||||||
|
for device in self._devices
|
||||||
|
if input_config.code
|
||||||
|
in device.capabilities(absinfo=False).get(input_config.type, [])
|
||||||
|
]
|
||||||
|
|
||||||
|
if len(candidates) > 1:
|
||||||
|
# there is more than on input device which can be used for this
|
||||||
|
# event we choose only one determined by the ranking
|
||||||
|
return sorted(candidates, key=lambda d: ranking.index(classify(d)))[0]
|
||||||
|
if len(candidates) == 1:
|
||||||
|
return candidates.pop()
|
||||||
|
|
||||||
|
logger.error(f"Could not find input for {input_config}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _grab_devices(self) -> Dict[DeviceHash, evdev.InputDevice]:
|
||||||
|
"""Grab all InputDevices that match a mappings' origin_hash."""
|
||||||
|
# use a dict because the InputDevice is not directly hashable
|
||||||
|
needed_devices = {}
|
||||||
|
input_configs = set()
|
||||||
|
|
||||||
|
# find all unique input_config's
|
||||||
|
for mapping in self.preset:
|
||||||
|
for input_config in mapping.input_combination:
|
||||||
|
input_configs.add(input_config)
|
||||||
|
|
||||||
|
# find all unique input_device's
|
||||||
|
for input_config in input_configs:
|
||||||
|
if not (device := self._find_input_device(input_config)):
|
||||||
|
# there is no point in trying the fallback because
|
||||||
|
# self._update_preset already did that.
|
||||||
|
continue
|
||||||
|
needed_devices[device.path] = device
|
||||||
|
|
||||||
|
grabbed_devices = {}
|
||||||
|
for device in needed_devices.values():
|
||||||
|
if device := self._grab_device(device):
|
||||||
|
grabbed_devices[get_device_hash(device)] = device
|
||||||
|
|
||||||
|
return grabbed_devices
|
||||||
|
|
||||||
|
def _update_preset(self):
|
||||||
|
"""Update all InputConfigs in the preset to include correct origin_hash
|
||||||
|
information."""
|
||||||
|
mappings_by_input = defaultdict(list)
|
||||||
|
for mapping in self.preset:
|
||||||
|
for input_config in mapping.input_combination:
|
||||||
|
mappings_by_input[input_config].append(mapping)
|
||||||
|
|
||||||
|
for input_config in mappings_by_input:
|
||||||
|
if self._find_input_device(input_config):
|
||||||
|
continue
|
||||||
|
|
||||||
|
if not (device := self._find_input_device_fallback(input_config)):
|
||||||
|
# fallback failed, this mapping will be ignored
|
||||||
|
continue
|
||||||
|
|
||||||
|
for mapping in mappings_by_input[input_config]:
|
||||||
|
combination: List[InputConfig] = list(mapping.input_combination)
|
||||||
|
device_hash = get_device_hash(device)
|
||||||
|
idx = combination.index(input_config)
|
||||||
|
combination[idx] = combination[idx].modify(origin_hash=device_hash)
|
||||||
|
mapping.input_combination = combination
|
||||||
|
|
||||||
|
def _grab_device(self, device: evdev.InputDevice) -> Optional[evdev.InputDevice]:
|
||||||
|
"""Try to grab the device, return None if not possible.
|
||||||
|
|
||||||
|
Without grab, original events from it would reach the display server
|
||||||
|
even though they are mapped.
|
||||||
|
"""
|
||||||
|
error = None
|
||||||
|
for attempt in range(10):
|
||||||
|
try:
|
||||||
|
device.grab()
|
||||||
|
logger.debug("Grab %s", device.path)
|
||||||
|
return device
|
||||||
|
except IOError as err:
|
||||||
|
# it might take a little time until the device is free if
|
||||||
|
# it was previously grabbed.
|
||||||
|
error = err
|
||||||
|
logger.debug("Failed attempts to grab %s: %d", device.path, attempt + 1)
|
||||||
|
time.sleep(self.regrab_timeout)
|
||||||
|
|
||||||
|
logger.error("Cannot grab %s, it is possibly in use", device.path)
|
||||||
|
logger.error(str(error))
|
||||||
|
return None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _copy_capabilities(input_device: evdev.InputDevice) -> CapabilitiesDict:
|
||||||
|
"""Copy capabilities for a new device."""
|
||||||
|
ecodes = evdev.ecodes
|
||||||
|
|
||||||
|
# copy the capabilities because the uinput is going
|
||||||
|
# to act like the device.
|
||||||
|
capabilities = input_device.capabilities(absinfo=True)
|
||||||
|
|
||||||
|
# just like what python-evdev does in from_device
|
||||||
|
if ecodes.EV_SYN in capabilities:
|
||||||
|
del capabilities[ecodes.EV_SYN]
|
||||||
|
if ecodes.EV_FF in capabilities:
|
||||||
|
del capabilities[ecodes.EV_FF]
|
||||||
|
|
||||||
|
if ecodes.ABS_VOLUME in capabilities.get(ecodes.EV_ABS, []):
|
||||||
|
# For some reason an ABS_VOLUME capability likes to appear
|
||||||
|
# for some users. It prevents mice from moving around and
|
||||||
|
# keyboards from writing symbols
|
||||||
|
capabilities[ecodes.EV_ABS].remove(ecodes.ABS_VOLUME)
|
||||||
|
|
||||||
|
return capabilities
|
||||||
|
|
||||||
|
async def _msg_listener(self) -> None:
|
||||||
|
"""Wait for messages from the main process to do special stuff."""
|
||||||
|
loop = asyncio.get_event_loop()
|
||||||
|
while True:
|
||||||
|
frame_available = asyncio.Event()
|
||||||
|
loop.add_reader(self._msg_pipe[0].fileno(), frame_available.set)
|
||||||
|
await frame_available.wait()
|
||||||
|
frame_available.clear()
|
||||||
|
msg = self._msg_pipe[0].recv()
|
||||||
|
|
||||||
|
if msg == InjectorCommand.CLOSE:
|
||||||
|
await self._close()
|
||||||
|
return
|
||||||
|
|
||||||
|
async def _close(self):
|
||||||
|
logger.debug("Received close signal")
|
||||||
|
self._stop_event.set()
|
||||||
|
# give the event pipeline some time to reset devices
|
||||||
|
# before shutting the loop down
|
||||||
|
await asyncio.sleep(0.1)
|
||||||
|
|
||||||
|
# stop the event loop and cause the process to reach its end
|
||||||
|
# cleanly. Using .terminate prevents coverage from working.
|
||||||
|
loop = asyncio.get_event_loop()
|
||||||
|
loop.stop()
|
||||||
|
|
||||||
|
self._msg_pipe[0].send(InjectorState.STOPPED)
|
||||||
|
|
||||||
|
def _create_forwarding_device(self, source: evdev.InputDevice) -> evdev.UInput:
|
||||||
|
# copy as much information as possible, because libinput uses the extra
|
||||||
|
# information to enable certain features like "Disable touchpad while
|
||||||
|
# typing"
|
||||||
|
try:
|
||||||
|
forward_to = evdev.UInput(
|
||||||
|
# Keep the original name as far as possible so system hwdb rules
|
||||||
|
# can still match the virtual device and restore properties such
|
||||||
|
# as MOUSE_DPI.
|
||||||
|
name=get_forward_name(source.name),
|
||||||
|
events=self._copy_capabilities(source),
|
||||||
|
# Reusing source.phys causes our autoload rule to treat the
|
||||||
|
# forwarded device as hardware. Prefix it so it stays
|
||||||
|
# distinguishable while still carrying some source identity.
|
||||||
|
phys=get_forward_phys(source),
|
||||||
|
vendor=source.info.vendor,
|
||||||
|
product=source.info.product,
|
||||||
|
version=source.info.version,
|
||||||
|
bustype=source.info.bustype,
|
||||||
|
input_props=source.input_props(),
|
||||||
|
)
|
||||||
|
except TypeError as e:
|
||||||
|
if "input_props" in str(e):
|
||||||
|
# UInput constructor doesn't support input_props and
|
||||||
|
# source.input_props doesn't exist with old python-evdev versions.
|
||||||
|
logger.error("Please upgrade your python-evdev version. Exiting")
|
||||||
|
self._msg_pipe[0].send(InjectorState.UPGRADE_EVDEV)
|
||||||
|
sys.exit(12)
|
||||||
|
|
||||||
|
raise e
|
||||||
|
return forward_to
|
||||||
|
|
||||||
|
def run(self) -> None:
|
||||||
|
"""The injection worker that keeps injecting until terminated.
|
||||||
|
|
||||||
|
Stuff is non-blocking by using asyncio in order to do multiple things
|
||||||
|
somewhat concurrently.
|
||||||
|
|
||||||
|
Use this function as starting point in a process. It creates
|
||||||
|
the loops needed to read and map events and keeps running them.
|
||||||
|
"""
|
||||||
|
logger.info('Starting injecting the preset for "%s"', self.group.key)
|
||||||
|
|
||||||
|
# create a new event loop, because somehow running an infinite loop
|
||||||
|
# that sleeps on iterations (joystick_to_mouse) in one process causes
|
||||||
|
# another injection process to screw up reading from the grabbed
|
||||||
|
# device.
|
||||||
|
loop = asyncio.new_event_loop()
|
||||||
|
asyncio.set_event_loop(loop)
|
||||||
|
|
||||||
|
self._devices = self.group.get_devices()
|
||||||
|
|
||||||
|
# InputConfigs may not contain the origin_hash information, this will try to
|
||||||
|
# make a good guess if the origin_hash information is missing or invalid.
|
||||||
|
self._update_preset()
|
||||||
|
|
||||||
|
# grab devices as early as possible. If events appear that won't get
|
||||||
|
# released anymore before the grab they appear to be held down forever
|
||||||
|
sources = self._grab_devices()
|
||||||
|
forward_devices = {}
|
||||||
|
for device_hash, device in sources.items():
|
||||||
|
forward_devices[device_hash] = self._create_forwarding_device(device)
|
||||||
|
|
||||||
|
# create this within the process after the event loop creation,
|
||||||
|
# so that the macros use the correct loop
|
||||||
|
self.context = Context(
|
||||||
|
self.preset,
|
||||||
|
sources,
|
||||||
|
forward_devices,
|
||||||
|
self.mapping_parser,
|
||||||
|
)
|
||||||
|
self._stop_event = asyncio.Event()
|
||||||
|
|
||||||
|
if len(sources) == 0:
|
||||||
|
# maybe the preset was empty or something
|
||||||
|
logger.error("Did not grab any device")
|
||||||
|
self._msg_pipe[0].send(InjectorState.NO_GRAB)
|
||||||
|
return
|
||||||
|
|
||||||
|
numlock_state = is_numlock_on()
|
||||||
|
coroutines = []
|
||||||
|
|
||||||
|
for device_hash in sources:
|
||||||
|
# actually doing things
|
||||||
|
event_reader = EventReader(
|
||||||
|
self.context,
|
||||||
|
sources[device_hash],
|
||||||
|
self._stop_event,
|
||||||
|
)
|
||||||
|
coroutines.append(event_reader.run())
|
||||||
|
self._event_readers.append(event_reader)
|
||||||
|
|
||||||
|
coroutines.append(self._msg_listener())
|
||||||
|
|
||||||
|
# set the numlock state to what it was before injecting, because
|
||||||
|
# grabbing devices screws this up
|
||||||
|
set_numlock(numlock_state)
|
||||||
|
|
||||||
|
self._msg_pipe[0].send(InjectorState.RUNNING)
|
||||||
|
|
||||||
|
try:
|
||||||
|
loop.run_until_complete(asyncio.gather(*coroutines))
|
||||||
|
except RuntimeError as error:
|
||||||
|
# the loop might have been stopped via a `CLOSE` message,
|
||||||
|
# which causes the error message below. This is expected behavior
|
||||||
|
if str(error) != "Event loop stopped before Future completed.":
|
||||||
|
raise error
|
||||||
|
except OSError as error:
|
||||||
|
logger.error("Failed to run injector coroutines: %s", str(error))
|
||||||
|
|
||||||
|
if len(coroutines) > 0:
|
||||||
|
# expected when stop_injecting is called,
|
||||||
|
# during normal operation as well as tests this point is not
|
||||||
|
# reached otherwise.
|
||||||
|
logger.debug("Injector coroutines ended")
|
||||||
|
|
||||||
|
for source in sources.values():
|
||||||
|
# ungrab at the end to make the next injection process not fail
|
||||||
|
# its grabs
|
||||||
|
try:
|
||||||
|
source.ungrab()
|
||||||
|
except OSError as error:
|
||||||
|
# it might have disappeared
|
||||||
|
logger.debug("OSError for ungrab on %s: %s", source.path, str(error))
|
||||||
0
inputremapper/injection/macros/__init__.py
Normal file
0
inputremapper/injection/macros/__init__.py
Normal file
320
inputremapper/injection/macros/argument.py
Normal file
320
inputremapper/injection/macros/argument.py
Normal file
@ -0,0 +1,320 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# input-remapper - GUI for device specific keyboard mappings
|
||||||
|
# Copyright (C) 2025 sezanzeb <b8x45ygc9@mozmail.com>
|
||||||
|
#
|
||||||
|
# This file is part of input-remapper.
|
||||||
|
#
|
||||||
|
# input-remapper is free software: you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU General Public License as published by
|
||||||
|
# the Free Software Foundation, either version 3 of the License, or
|
||||||
|
# (at your option) any later version.
|
||||||
|
#
|
||||||
|
# input-remapper is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU General Public License
|
||||||
|
# along with input-remapper. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from enum import Enum
|
||||||
|
from typing import Optional, Any, Union, List, Literal, Type, TYPE_CHECKING
|
||||||
|
|
||||||
|
from evdev._ecodes import EV_KEY
|
||||||
|
|
||||||
|
from inputremapper.configs.keyboard_layout import keyboard_layout
|
||||||
|
from inputremapper.configs.validation_errors import (
|
||||||
|
MacroError,
|
||||||
|
SymbolNotAvailableInTargetError,
|
||||||
|
)
|
||||||
|
from inputremapper.injection.global_uinputs import GlobalUInputs
|
||||||
|
from inputremapper.injection.macros.macro import Macro
|
||||||
|
from inputremapper.injection.macros.variable import Variable
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from inputremapper.injection.macros.raw_value import RawValue
|
||||||
|
from inputremapper.configs.mapping import Mapping
|
||||||
|
|
||||||
|
|
||||||
|
class ArgumentFlags(Enum):
|
||||||
|
# No default value is set, and the user has to provide one when using the macro
|
||||||
|
required = "required"
|
||||||
|
|
||||||
|
# If used, acts like foo(*bar)
|
||||||
|
spread = "spread"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ArgumentConfig:
|
||||||
|
"""Definition what kind of arguments a task may take."""
|
||||||
|
|
||||||
|
position: Union[int, Literal[ArgumentFlags.spread]]
|
||||||
|
name: str
|
||||||
|
types: List[Optional[Type]]
|
||||||
|
is_symbol: bool = False
|
||||||
|
default: Any = ArgumentFlags.required
|
||||||
|
|
||||||
|
# If True, then the value (which should be a string), is the name of a non-constant
|
||||||
|
# variable. Tasks that overwrite their value need this, like `set`. The specified
|
||||||
|
# types are those that the current value of that variable may have. For `set` this
|
||||||
|
# doesn't matter, but something like `add` requires them to be numbers.
|
||||||
|
is_variable_name: bool = False
|
||||||
|
|
||||||
|
def is_required(self) -> bool:
|
||||||
|
return self.default == ArgumentFlags.required
|
||||||
|
|
||||||
|
def is_spread(self):
|
||||||
|
"""Does this Argument store all remaining Variables of a Task as a list?"""
|
||||||
|
return self.position == ArgumentFlags.spread
|
||||||
|
|
||||||
|
|
||||||
|
class Argument(ArgumentConfig):
|
||||||
|
"""Validation of variables and access to their value for Tasks during runtime."""
|
||||||
|
|
||||||
|
_variable: Optional[Variable] = None
|
||||||
|
|
||||||
|
# If the position is set to ArgumentFlags.spread, then _variables will be filled
|
||||||
|
# with all remaining positional arguments that were passed to a task.
|
||||||
|
_variables: List[Variable]
|
||||||
|
|
||||||
|
_mapping: Optional[Mapping] = None
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
argument_config: ArgumentConfig,
|
||||||
|
mapping: Mapping,
|
||||||
|
) -> None:
|
||||||
|
# If a default of None is specified, but None is not an allowed type, then
|
||||||
|
# input-remapper has a bug here. Add "None" to your ArgumentConfig.types
|
||||||
|
assert not (
|
||||||
|
argument_config.default is None and None not in argument_config.types
|
||||||
|
)
|
||||||
|
|
||||||
|
self.position = argument_config.position
|
||||||
|
self.name = argument_config.name
|
||||||
|
self.types = argument_config.types
|
||||||
|
self.is_symbol = argument_config.is_symbol
|
||||||
|
self.default = argument_config.default
|
||||||
|
self.is_variable_name = argument_config.is_variable_name
|
||||||
|
|
||||||
|
self._mapping = mapping
|
||||||
|
self._variables = []
|
||||||
|
|
||||||
|
def initialize_variables(self, raw_values: List[RawValue]) -> None:
|
||||||
|
"""If the macro is supposed to contain multiple variables, set them.
|
||||||
|
Should be done during parsing."""
|
||||||
|
assert len(self._variables) == 0
|
||||||
|
assert self._variable is None
|
||||||
|
assert self.is_spread()
|
||||||
|
|
||||||
|
for raw_value in raw_values:
|
||||||
|
variable = self._parse_raw_value(raw_value)
|
||||||
|
self._variables.append(variable)
|
||||||
|
|
||||||
|
def initialize_variable(self, raw_value: RawValue) -> None:
|
||||||
|
"""Set the Arguments Variable. Done during parsing."""
|
||||||
|
assert len(self._variables) == 0
|
||||||
|
assert self._variable is None
|
||||||
|
assert not self.is_spread()
|
||||||
|
|
||||||
|
variable = self._parse_raw_value(raw_value)
|
||||||
|
self._variable = variable
|
||||||
|
|
||||||
|
def initialize_default(self) -> None:
|
||||||
|
"""Set the Arguments to its default value. Done during parsing."""
|
||||||
|
assert len(self._variables) == 0
|
||||||
|
assert self._variable is None
|
||||||
|
assert not self.is_spread()
|
||||||
|
|
||||||
|
variable = Variable(value=self.default, const=True)
|
||||||
|
self._variable = variable
|
||||||
|
|
||||||
|
def get_value(self) -> Any:
|
||||||
|
"""To ask for the current value of the variable during runtime."""
|
||||||
|
assert not self.is_spread(), f"Use .{self.get_values.__name__}()"
|
||||||
|
# If a user passed None as value, it should be a Variable(None, const=True) here.
|
||||||
|
# If not, a test or input-remapper is broken.
|
||||||
|
assert self._variable is not None
|
||||||
|
|
||||||
|
value = self._variable.get_value()
|
||||||
|
|
||||||
|
if not self._variable.const:
|
||||||
|
# Dynamic value. Hasn't been validated yet
|
||||||
|
value = self._validate_dynamic_value(self._variable)
|
||||||
|
|
||||||
|
return value
|
||||||
|
|
||||||
|
def get_values(self) -> List[Any]:
|
||||||
|
"""To ask for the current values of the variables during runtime."""
|
||||||
|
assert self.is_spread(), f"Use .{self.get_value.__name__}()"
|
||||||
|
|
||||||
|
values = []
|
||||||
|
for variable in self._variables:
|
||||||
|
if not variable.const:
|
||||||
|
values.append(self._validate_dynamic_value(variable))
|
||||||
|
else:
|
||||||
|
values.append(variable.get_value())
|
||||||
|
|
||||||
|
return values
|
||||||
|
|
||||||
|
def get_variable_name(self) -> str:
|
||||||
|
"""If the variable is not const, return its name."""
|
||||||
|
assert self._variable is not None
|
||||||
|
return self._variable.get_name()
|
||||||
|
|
||||||
|
def contains_macro(self) -> bool:
|
||||||
|
"""Does the underlying Variable contain another child-macro?"""
|
||||||
|
assert self._variable is not None
|
||||||
|
return isinstance(self._variable.get_value(), Macro)
|
||||||
|
|
||||||
|
def set_value(self, value: Any) -> Any:
|
||||||
|
"""To set the value of the underlying Variable during runtime.
|
||||||
|
Fails for constants."""
|
||||||
|
assert self._variable is not None
|
||||||
|
if self._variable.const:
|
||||||
|
raise Exception("Can't set value of a constant")
|
||||||
|
|
||||||
|
self._variable.set_value(value)
|
||||||
|
|
||||||
|
def assert_is_symbol(self, symbol: str) -> None:
|
||||||
|
"""Checks if the key/symbol-name is valid. Like "KEY_A" or "escape".
|
||||||
|
|
||||||
|
Using `is_symbol` on the ArgumentConfig is prefered, which causes it to
|
||||||
|
automatically do this for you. But some macros may be a bit more flexible,
|
||||||
|
and there we want to assert this ourselves only in certain cases."""
|
||||||
|
symbol = str(symbol)
|
||||||
|
code = keyboard_layout.get(symbol)
|
||||||
|
|
||||||
|
if code is None:
|
||||||
|
raise MacroError(msg=f'Unknown key "{symbol}"')
|
||||||
|
|
||||||
|
if self._mapping is not None:
|
||||||
|
target = self._mapping.target_uinput
|
||||||
|
if target is not None and not GlobalUInputs.can_default_uinput_emit(
|
||||||
|
target, EV_KEY, code
|
||||||
|
):
|
||||||
|
raise SymbolNotAvailableInTargetError(symbol, target)
|
||||||
|
|
||||||
|
def _parse_raw_value(self, raw_value: RawValue) -> Variable:
|
||||||
|
"""Validate and parse."""
|
||||||
|
value = raw_value.value
|
||||||
|
|
||||||
|
# The order of steps below matters.
|
||||||
|
|
||||||
|
if isinstance(value, Macro):
|
||||||
|
return Variable(value=value, const=True)
|
||||||
|
|
||||||
|
if self.is_variable_name:
|
||||||
|
# Treat this as a non-constant variable,
|
||||||
|
# even without a `$` in front of its name
|
||||||
|
if value.startswith('"'):
|
||||||
|
# Remove quotes from the string
|
||||||
|
value = value[1:-1]
|
||||||
|
return Variable(value=value, const=False)
|
||||||
|
|
||||||
|
if value.startswith("$"):
|
||||||
|
# Will be resolved during the macros runtime
|
||||||
|
return Variable(value=value[1:], const=False)
|
||||||
|
|
||||||
|
if self.is_symbol:
|
||||||
|
if value.startswith('"'):
|
||||||
|
value = value[1:-1]
|
||||||
|
self.assert_is_symbol(value)
|
||||||
|
return Variable(value=value, const=True)
|
||||||
|
|
||||||
|
if (value == "" or value == "None") and None in self.types:
|
||||||
|
# I think "" is the deprecated alternative to "None"
|
||||||
|
return Variable(value=None, const=True)
|
||||||
|
|
||||||
|
if value.startswith('"') and str in self.types:
|
||||||
|
# Something with explicit quotes should never be parsed as a number.
|
||||||
|
# Treat it as a string no matter the content.
|
||||||
|
value = value[1:-1]
|
||||||
|
return Variable(value=value, const=True)
|
||||||
|
|
||||||
|
if float in self.types and "." in value:
|
||||||
|
try:
|
||||||
|
return Variable(value=float(value), const=True)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
if int in self.types:
|
||||||
|
try:
|
||||||
|
return Variable(value=int(value), const=True)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
if not value.startswith('"') and ("(" in value or ")" in value):
|
||||||
|
# Looks like something that should have been a macro. It is not explicitly
|
||||||
|
# wrapped in quotes. Most likely an error. If it was a valid macro, the
|
||||||
|
# parser would have parsed it as such.
|
||||||
|
raise MacroError(
|
||||||
|
msg=f"A broken macro was passed as parameter to {self.name}"
|
||||||
|
)
|
||||||
|
|
||||||
|
if str in self.types:
|
||||||
|
# Treat as a string. Something like KEY_A in key(KEY_A)
|
||||||
|
return Variable(value=value, const=True)
|
||||||
|
|
||||||
|
raise self._type_error_factory(value)
|
||||||
|
|
||||||
|
def _validate_dynamic_value(self, variable: Variable) -> Any:
|
||||||
|
"""To make sure the value of a non-const variable, asked for at runtime, is
|
||||||
|
fitting for the given ArgumentConfig."""
|
||||||
|
# Most of the stuff has already been taken care of when, for example,
|
||||||
|
# the "1" of set(foo, 1), or the '"bar"' or set(foo, "bar") was parsed the
|
||||||
|
# first time. In the first case we get a number 1, and in the second a string
|
||||||
|
# `bar` without quotes
|
||||||
|
assert not variable.const
|
||||||
|
value = variable.get_value()
|
||||||
|
|
||||||
|
if self.is_symbol:
|
||||||
|
# value might be int `1`, which is a valid symbol for `key(1)`
|
||||||
|
value = str(value)
|
||||||
|
self.assert_is_symbol(value)
|
||||||
|
return value
|
||||||
|
|
||||||
|
if None in self.types and value is None:
|
||||||
|
return value
|
||||||
|
|
||||||
|
if type(value) in self.types:
|
||||||
|
return value
|
||||||
|
|
||||||
|
if type(value) not in self.types and str in self.types:
|
||||||
|
# `set` cannot make predictions where the variable will be used. Make sure
|
||||||
|
# the type is compatible, and turn numbers back into strings if need be.
|
||||||
|
return str(value)
|
||||||
|
|
||||||
|
# If the value is "1", we don't attempt to parse it as a number. This being a
|
||||||
|
# string means that something like `set(foo, "1")` was used, which enforces a
|
||||||
|
# string datatype. Otherwise, `set` would have already turned it into an int.
|
||||||
|
|
||||||
|
raise self._type_error_factory(value)
|
||||||
|
|
||||||
|
def _is_numeric_string(self, value: str) -> bool:
|
||||||
|
"""Check if the value can be turned into a number."""
|
||||||
|
try:
|
||||||
|
float(value)
|
||||||
|
return True
|
||||||
|
except ValueError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _type_error_factory(self, value: Any) -> MacroError:
|
||||||
|
formatted_types: List[str] = []
|
||||||
|
|
||||||
|
for type_ in self.types:
|
||||||
|
if type_ is None:
|
||||||
|
formatted_types.append("None")
|
||||||
|
else:
|
||||||
|
formatted_types.append(type_.__name__)
|
||||||
|
|
||||||
|
return MacroError(
|
||||||
|
msg=(
|
||||||
|
f'Expected "{self.name}" to be one of {formatted_types}, but got '
|
||||||
|
f'{type(value).__name__} "{value}"'
|
||||||
|
)
|
||||||
|
)
|
||||||
126
inputremapper/injection/macros/macro.py
Normal file
126
inputremapper/injection/macros/macro.py
Normal file
@ -0,0 +1,126 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# input-remapper - GUI for device specific keyboard mappings
|
||||||
|
# Copyright (C) 2025 sezanzeb <b8x45ygc9@mozmail.com>
|
||||||
|
#
|
||||||
|
# This file is part of input-remapper.
|
||||||
|
#
|
||||||
|
# input-remapper is free software: you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU General Public License as published by
|
||||||
|
# the Free Software Foundation, either version 3 of the License, or
|
||||||
|
# (at your option) any later version.
|
||||||
|
#
|
||||||
|
# input-remapper is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU General Public License
|
||||||
|
# along with input-remapper. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
"""Executes more complex patterns of keystrokes."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from typing import List, Callable, Optional, TYPE_CHECKING
|
||||||
|
|
||||||
|
from inputremapper.ipc.shared_dict import SharedDict
|
||||||
|
from inputremapper.logging.logger import logger
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from inputremapper.injection.macros.task import Task
|
||||||
|
from inputremapper.injection.context import Context
|
||||||
|
from inputremapper.configs.mapping import Mapping
|
||||||
|
|
||||||
|
InjectEventCallback = Callable[[int, int, int], None]
|
||||||
|
|
||||||
|
macro_variables = SharedDict()
|
||||||
|
|
||||||
|
|
||||||
|
class Macro:
|
||||||
|
"""Chains tasks (like `modify` or `repeat`).
|
||||||
|
|
||||||
|
Tasks may have child_macros. Running a Macro runs Tasks, which in turn may run
|
||||||
|
their child_macros based on certain conditions (depending on the Task).
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
code: Optional[str],
|
||||||
|
context: Optional[Context] = None,
|
||||||
|
mapping: Optional[Mapping] = None,
|
||||||
|
):
|
||||||
|
"""Create a macro instance that can be populated with tasks.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
code
|
||||||
|
The original parsed code, for logging purposes.
|
||||||
|
context : Context
|
||||||
|
mapping : UIMapping
|
||||||
|
"""
|
||||||
|
self.code = code
|
||||||
|
self.context = context
|
||||||
|
self.mapping = mapping
|
||||||
|
|
||||||
|
# List of coroutines that will be called sequentially.
|
||||||
|
# This is the compiled code
|
||||||
|
self.tasks: List[Task] = []
|
||||||
|
|
||||||
|
self.running = False
|
||||||
|
|
||||||
|
self.keystroke_sleep_ms = None
|
||||||
|
|
||||||
|
async def run(self, callback: InjectEventCallback):
|
||||||
|
"""Run the macro.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
callback
|
||||||
|
Will receive int type, code and value for an event to write
|
||||||
|
"""
|
||||||
|
if not callable(callback):
|
||||||
|
raise ValueError("handler is not callable")
|
||||||
|
|
||||||
|
if self.running:
|
||||||
|
logger.error('Tried to run already running macro "%s"', self.code)
|
||||||
|
return
|
||||||
|
|
||||||
|
self.keystroke_sleep_ms = self.mapping.macro_key_sleep_ms
|
||||||
|
|
||||||
|
self.running = True
|
||||||
|
|
||||||
|
try:
|
||||||
|
for task in self.tasks:
|
||||||
|
coroutine = task.run(callback)
|
||||||
|
if asyncio.iscoroutine(coroutine):
|
||||||
|
await coroutine
|
||||||
|
except Exception:
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
# done
|
||||||
|
self.running = False
|
||||||
|
|
||||||
|
def press_trigger(self):
|
||||||
|
"""The user pressed the trigger key down."""
|
||||||
|
for task in self.tasks:
|
||||||
|
task.press_trigger()
|
||||||
|
|
||||||
|
def release_trigger(self):
|
||||||
|
"""The user released the trigger key."""
|
||||||
|
for task in self.tasks:
|
||||||
|
task.release_trigger()
|
||||||
|
|
||||||
|
async def _keycode_pause(self, _=None):
|
||||||
|
"""To add a pause between keystrokes.
|
||||||
|
|
||||||
|
This was needed at some point because it appeared that injecting keys too
|
||||||
|
fast will prevent them from working. It probably depends on the environment.
|
||||||
|
"""
|
||||||
|
await asyncio.sleep(self.keystroke_sleep_ms / 1000)
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f'<Macro "{self.code}" at {hex(id(self))}>'
|
||||||
|
|
||||||
|
def add_task(self, task):
|
||||||
|
self.tasks.append(task)
|
||||||
476
inputremapper/injection/macros/parse.py
Normal file
476
inputremapper/injection/macros/parse.py
Normal file
@ -0,0 +1,476 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# input-remapper - GUI for device specific keyboard mappings
|
||||||
|
# Copyright (C) 2025 sezanzeb <b8x45ygc9@mozmail.com>
|
||||||
|
#
|
||||||
|
# This file is part of input-remapper.
|
||||||
|
#
|
||||||
|
# input-remapper is free software: you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU General Public License as published by
|
||||||
|
# the Free Software Foundation, either version 3 of the License, or
|
||||||
|
# (at your option) any later version.
|
||||||
|
#
|
||||||
|
# input-remapper is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU General Public License
|
||||||
|
# along with input-remapper. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
|
||||||
|
"""Parse macro code"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from typing import Optional, Any, Type, TYPE_CHECKING, Dict, List
|
||||||
|
|
||||||
|
from inputremapper.configs.validation_errors import MacroError
|
||||||
|
from inputremapper.injection.macros.macro import Macro
|
||||||
|
from inputremapper.injection.macros.raw_value import RawValue
|
||||||
|
from inputremapper.injection.macros.task import Task
|
||||||
|
from inputremapper.injection.macros.tasks.add import AddTask
|
||||||
|
from inputremapper.injection.macros.tasks.event import EventTask
|
||||||
|
from inputremapper.injection.macros.tasks.hold import HoldTask
|
||||||
|
from inputremapper.injection.macros.tasks.hold_keys import HoldKeysTask
|
||||||
|
from inputremapper.injection.macros.tasks.if_eq import IfEqTask
|
||||||
|
from inputremapper.injection.macros.tasks.if_led import IfNumlockTask, IfCapslockTask
|
||||||
|
from inputremapper.injection.macros.tasks.if_single import IfSingleTask
|
||||||
|
from inputremapper.injection.macros.tasks.if_tap import IfTapTask
|
||||||
|
from inputremapper.injection.macros.tasks.ifeq import DeprecatedIfEqTask
|
||||||
|
from inputremapper.injection.macros.tasks.key import KeyTask
|
||||||
|
from inputremapper.injection.macros.tasks.key_down import KeyDownTask
|
||||||
|
from inputremapper.injection.macros.tasks.key_up import KeyUpTask
|
||||||
|
from inputremapper.injection.macros.tasks.mod_tap import ModTapTask
|
||||||
|
from inputremapper.injection.macros.tasks.modify import ModifyTask
|
||||||
|
from inputremapper.injection.macros.tasks.mouse import MouseTask
|
||||||
|
from inputremapper.injection.macros.tasks.parallel import ParallelTask
|
||||||
|
from inputremapper.injection.macros.tasks.mouse_xy import MouseXYTask
|
||||||
|
from inputremapper.injection.macros.tasks.repeat import RepeatTask
|
||||||
|
from inputremapper.injection.macros.tasks.set import SetTask
|
||||||
|
from inputremapper.injection.macros.tasks.toggle import ToggleTask
|
||||||
|
from inputremapper.injection.macros.tasks.wait import WaitTask
|
||||||
|
from inputremapper.injection.macros.tasks.wheel import WheelTask
|
||||||
|
from inputremapper.logging.logger import logger
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from inputremapper.injection.context import Context
|
||||||
|
from inputremapper.configs.mapping import Mapping
|
||||||
|
|
||||||
|
|
||||||
|
class Parser:
|
||||||
|
TASK_CLASSES: dict[str, type[Task]] = {
|
||||||
|
"modify": ModifyTask,
|
||||||
|
"repeat": RepeatTask,
|
||||||
|
"toggle": ToggleTask,
|
||||||
|
"key": KeyTask,
|
||||||
|
"key_down": KeyDownTask,
|
||||||
|
"key_up": KeyUpTask,
|
||||||
|
"event": EventTask,
|
||||||
|
"wait": WaitTask,
|
||||||
|
"hold": HoldTask,
|
||||||
|
"hold_keys": HoldKeysTask,
|
||||||
|
"mouse": MouseTask,
|
||||||
|
"mouse_xy": MouseXYTask,
|
||||||
|
"wheel": WheelTask,
|
||||||
|
"if_eq": IfEqTask,
|
||||||
|
"if_numlock": IfNumlockTask,
|
||||||
|
"if_capslock": IfCapslockTask,
|
||||||
|
"set": SetTask,
|
||||||
|
"if_tap": IfTapTask,
|
||||||
|
"if_single": IfSingleTask,
|
||||||
|
"add": AddTask,
|
||||||
|
"mod_tap": ModTapTask,
|
||||||
|
"parallel": ParallelTask,
|
||||||
|
# Those are only kept for backwards compatibility with old macros. The space for
|
||||||
|
# writing macro was very constrained in the past, so shorthands were introduced:
|
||||||
|
"m": ModifyTask,
|
||||||
|
"r": RepeatTask,
|
||||||
|
"k": KeyTask,
|
||||||
|
"e": EventTask,
|
||||||
|
"w": WaitTask,
|
||||||
|
"h": HoldTask,
|
||||||
|
# It was not possible to adjust ifeq to support variables without breaking old
|
||||||
|
# macros, so this function is deprecated and if_eq introduced. Kept for backwards
|
||||||
|
# compatibility:
|
||||||
|
"ifeq": DeprecatedIfEqTask,
|
||||||
|
}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def is_this_a_macro(output: Any):
|
||||||
|
"""Figure out if this is a macro."""
|
||||||
|
if not isinstance(output, str):
|
||||||
|
return False
|
||||||
|
|
||||||
|
if "+" in output.strip():
|
||||||
|
# for example "a + b"
|
||||||
|
return True
|
||||||
|
|
||||||
|
return "(" in output and ")" in output and len(output) >= 4
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _extract_args(inner: str):
|
||||||
|
"""Extract parameters from the inner contents of a call.
|
||||||
|
|
||||||
|
This does not parse them.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
inner
|
||||||
|
for example '1, r, r(2, k(a))' should result in ['1', 'r', 'r(2, k(a))']
|
||||||
|
"""
|
||||||
|
inner = inner.strip()
|
||||||
|
brackets = 0
|
||||||
|
params = []
|
||||||
|
start = 0
|
||||||
|
string = False
|
||||||
|
for position, char in enumerate(inner):
|
||||||
|
# ignore anything between string quotes
|
||||||
|
if char == '"':
|
||||||
|
string = not string
|
||||||
|
if string:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# ignore commas inside child macros
|
||||||
|
if char == "(":
|
||||||
|
brackets += 1
|
||||||
|
if char == ")":
|
||||||
|
brackets -= 1
|
||||||
|
if char == "," and brackets == 0:
|
||||||
|
# , potentially starts another parameter, but only if
|
||||||
|
# the current brackets are all closed.
|
||||||
|
params.append(inner[start:position].strip())
|
||||||
|
# skip the comma
|
||||||
|
start = position + 1
|
||||||
|
|
||||||
|
# one last parameter
|
||||||
|
params.append(inner[start:].strip())
|
||||||
|
|
||||||
|
return params
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _count_brackets(macro):
|
||||||
|
"""Find where the first opening bracket closes."""
|
||||||
|
openings = macro.count("(")
|
||||||
|
closings = macro.count(")")
|
||||||
|
if openings != closings:
|
||||||
|
raise MacroError(
|
||||||
|
macro, f"Found {openings} opening and {closings} closing brackets"
|
||||||
|
)
|
||||||
|
|
||||||
|
brackets = 0
|
||||||
|
position = 0
|
||||||
|
for char in macro:
|
||||||
|
position += 1
|
||||||
|
if char == "(":
|
||||||
|
brackets += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
if char == ")":
|
||||||
|
brackets -= 1
|
||||||
|
if brackets == 0:
|
||||||
|
# the closing bracket of the call
|
||||||
|
break
|
||||||
|
|
||||||
|
return position
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _split_keyword_arg(param):
|
||||||
|
"""Split "foo=bar" into "foo" and "bar".
|
||||||
|
|
||||||
|
If not a keyward param, return None and the param.
|
||||||
|
"""
|
||||||
|
if re.match(r"[a-zA-Z_][a-zA-Z_\d]*=.+", param):
|
||||||
|
split = param.split("=", 1)
|
||||||
|
return split[0], split[1]
|
||||||
|
|
||||||
|
return None, param
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _validate_keyword_argument_names(
|
||||||
|
keyword_args: Dict[str, Any],
|
||||||
|
task_class: Type[Task],
|
||||||
|
) -> None:
|
||||||
|
for keyword_arg in keyword_args:
|
||||||
|
for argument in task_class.argument_configs:
|
||||||
|
if argument.name == keyword_arg:
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
raise MacroError(msg=f"Unknown keyword argument {keyword_arg}")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _parse_recurse(
|
||||||
|
code: str,
|
||||||
|
context: Optional[Context],
|
||||||
|
mapping: Mapping,
|
||||||
|
verbose: bool,
|
||||||
|
macro_instance: Optional[Macro] = None,
|
||||||
|
depth: int = 0,
|
||||||
|
) -> RawValue:
|
||||||
|
"""Handle a subset of the macro, e.g. one parameter or function call.
|
||||||
|
|
||||||
|
Not using eval for security reasons.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
code
|
||||||
|
Just like parse. A single parameter or the complete macro as string.
|
||||||
|
Comments and redundant whitespace characters are expected to be removed already.
|
||||||
|
Example:
|
||||||
|
- "parallel(key(a),key(b).key($foo))"
|
||||||
|
- "key(a)"
|
||||||
|
- "a"
|
||||||
|
- "key(b).key($foo)"
|
||||||
|
- "b"
|
||||||
|
- "key($foo)"
|
||||||
|
- "$foo"
|
||||||
|
context : Context
|
||||||
|
macro_instance
|
||||||
|
A macro instance to add tasks to. This is the output of the parser, and is
|
||||||
|
organized like a tree.
|
||||||
|
depth
|
||||||
|
For logging porposes
|
||||||
|
"""
|
||||||
|
assert isinstance(code, str)
|
||||||
|
assert isinstance(depth, int)
|
||||||
|
|
||||||
|
def debug(*args, **kwargs):
|
||||||
|
if verbose:
|
||||||
|
logger.debug(*args, **kwargs)
|
||||||
|
|
||||||
|
space = " " * depth
|
||||||
|
|
||||||
|
code = code.strip()
|
||||||
|
|
||||||
|
# is it another macro?
|
||||||
|
task_call_match = re.match(r"^(\w+)\(", code)
|
||||||
|
task_name = task_call_match[1] if task_call_match else None
|
||||||
|
|
||||||
|
if task_name is None:
|
||||||
|
# It is probably either a key name like KEY_A or a variable name as in `set(var,1)`,
|
||||||
|
# both won't contain special characters that can break macro syntax so they don't
|
||||||
|
# have to be wrapped in quotes. The argument configuration of the tasks will
|
||||||
|
# detemrine how to parse it.
|
||||||
|
debug("%svalue %s", space, code)
|
||||||
|
return RawValue(value=code)
|
||||||
|
|
||||||
|
if macro_instance is None:
|
||||||
|
# start a new chain
|
||||||
|
macro_instance = Macro(code, context, mapping)
|
||||||
|
else:
|
||||||
|
# chain this call to the existing instance
|
||||||
|
assert isinstance(macro_instance, Macro)
|
||||||
|
|
||||||
|
task_class = Parser.TASK_CLASSES.get(task_name)
|
||||||
|
if task_class is None:
|
||||||
|
raise MacroError(code, f"Unknown function {task_name}")
|
||||||
|
|
||||||
|
# get all the stuff inbetween
|
||||||
|
closing_bracket_position = Parser._count_brackets(code) - 1
|
||||||
|
inner = code[code.index("(") + 1 : closing_bracket_position]
|
||||||
|
debug("%scalls %s with %s", space, task_name, inner)
|
||||||
|
|
||||||
|
# split "3, foo=a(2, k(a).w(10))" into arguments
|
||||||
|
raw_string_args = Parser._extract_args(inner)
|
||||||
|
|
||||||
|
# parse and sort the params
|
||||||
|
positional_args: List[RawValue] = []
|
||||||
|
keyword_args: Dict[str, RawValue] = {}
|
||||||
|
for param in raw_string_args:
|
||||||
|
key, value = Parser._split_keyword_arg(param)
|
||||||
|
parsed = Parser._parse_recurse(
|
||||||
|
value.strip(),
|
||||||
|
context,
|
||||||
|
mapping,
|
||||||
|
verbose,
|
||||||
|
None,
|
||||||
|
depth + 1,
|
||||||
|
)
|
||||||
|
if key is None:
|
||||||
|
if len(keyword_args) > 0:
|
||||||
|
msg = f'Positional argument "{key}" follows keyword argument'
|
||||||
|
raise MacroError(code, msg)
|
||||||
|
positional_args.append(parsed)
|
||||||
|
else:
|
||||||
|
if key in keyword_args:
|
||||||
|
raise MacroError(code, f'The "{key}" argument was specified twice')
|
||||||
|
keyword_args[key] = parsed
|
||||||
|
|
||||||
|
debug(
|
||||||
|
"%sadd call to %s with %s, %s",
|
||||||
|
space,
|
||||||
|
task_name,
|
||||||
|
positional_args,
|
||||||
|
keyword_args,
|
||||||
|
)
|
||||||
|
|
||||||
|
Parser._validate_keyword_argument_names(
|
||||||
|
keyword_args,
|
||||||
|
task_class,
|
||||||
|
)
|
||||||
|
Parser._validate_num_args(
|
||||||
|
code,
|
||||||
|
task_name,
|
||||||
|
task_class,
|
||||||
|
raw_string_args,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
task = task_class(
|
||||||
|
positional_args,
|
||||||
|
keyword_args,
|
||||||
|
context,
|
||||||
|
mapping,
|
||||||
|
)
|
||||||
|
macro_instance.add_task(task)
|
||||||
|
except TypeError as exception:
|
||||||
|
raise MacroError(msg=str(exception)) from exception
|
||||||
|
|
||||||
|
# is after this another call? Chain it to the macro_instance
|
||||||
|
more_code_exists = len(code) > closing_bracket_position + 1
|
||||||
|
if more_code_exists:
|
||||||
|
next_char = code[closing_bracket_position + 1]
|
||||||
|
statement_closed = next_char == "."
|
||||||
|
|
||||||
|
if statement_closed:
|
||||||
|
# skip over the ")."
|
||||||
|
chain = code[closing_bracket_position + 2 :]
|
||||||
|
debug("%sfollowed by %s", space, chain)
|
||||||
|
Parser._parse_recurse(
|
||||||
|
chain,
|
||||||
|
context,
|
||||||
|
mapping,
|
||||||
|
verbose,
|
||||||
|
macro_instance,
|
||||||
|
depth,
|
||||||
|
)
|
||||||
|
elif re.match(r"[a-zA-Z_]", next_char):
|
||||||
|
# something like foo()bar
|
||||||
|
raise MacroError(
|
||||||
|
code,
|
||||||
|
f'Expected a "." to follow after '
|
||||||
|
f"{code[:closing_bracket_position + 1]}",
|
||||||
|
)
|
||||||
|
|
||||||
|
return RawValue(value=macro_instance)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _validate_num_args(
|
||||||
|
code: str,
|
||||||
|
task_name: str,
|
||||||
|
task_class: Type[Task],
|
||||||
|
raw_string_args: List[str],
|
||||||
|
) -> None:
|
||||||
|
min_args, max_args = task_class.get_num_parameters()
|
||||||
|
num_provided_args = len(raw_string_args)
|
||||||
|
if num_provided_args < min_args or num_provided_args > max_args:
|
||||||
|
if min_args != max_args:
|
||||||
|
msg = (
|
||||||
|
f"{task_name} takes between {min_args} and {max_args}, "
|
||||||
|
f"not {num_provided_args} parameters"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
msg = (
|
||||||
|
f"{task_name} takes {min_args}, not {num_provided_args} parameters"
|
||||||
|
)
|
||||||
|
|
||||||
|
raise MacroError(code, msg)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def handle_plus_syntax(macro):
|
||||||
|
"""Transform a + b + c to hold_keys(a,b,c)."""
|
||||||
|
if "+" not in macro:
|
||||||
|
return macro
|
||||||
|
|
||||||
|
if "(" in macro or ")" in macro:
|
||||||
|
raise MacroError(macro, f'Mixing "+" and macros is unsupported: "{ macro}"')
|
||||||
|
|
||||||
|
chunks = [chunk.strip() for chunk in macro.split("+")]
|
||||||
|
|
||||||
|
if "" in chunks:
|
||||||
|
raise MacroError(f'Invalid syntax for "{macro}"')
|
||||||
|
|
||||||
|
output = f"hold_keys({','.join(chunks)})"
|
||||||
|
|
||||||
|
logger.debug('Transformed "%s" to "%s"', macro, output)
|
||||||
|
return output
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def remove_whitespaces(macro, delimiter='"'):
|
||||||
|
"""Remove whitespaces, tabs, newlines and such outside of string quotes."""
|
||||||
|
result = ""
|
||||||
|
for i, chunk in enumerate(macro.split(delimiter)):
|
||||||
|
# every second chunk is inside string quotes
|
||||||
|
if i % 2 == 0:
|
||||||
|
result += re.sub(r"\s", "", chunk)
|
||||||
|
else:
|
||||||
|
result += chunk
|
||||||
|
result += delimiter
|
||||||
|
|
||||||
|
# one extra delimiter was added
|
||||||
|
return result[: -len(delimiter)]
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def remove_comments(macro):
|
||||||
|
"""Remove comments from the macro and return the resulting code."""
|
||||||
|
# keep hashtags inside quotes intact
|
||||||
|
result = ""
|
||||||
|
|
||||||
|
for i, line in enumerate(macro.split("\n")):
|
||||||
|
for j, chunk in enumerate(line.split('"')):
|
||||||
|
if j > 0:
|
||||||
|
# add back the string quote
|
||||||
|
chunk = f'"{chunk}'
|
||||||
|
|
||||||
|
# every second chunk is inside string quotes
|
||||||
|
if j % 2 == 0 and "#" in chunk:
|
||||||
|
# everything from now on is a comment and can be ignored
|
||||||
|
result += chunk.split("#")[0]
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
result += chunk
|
||||||
|
|
||||||
|
if i < macro.count("\n"):
|
||||||
|
result += "\n"
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def clean(code):
|
||||||
|
"""Remove everything irrelevant for the macro."""
|
||||||
|
return Parser.remove_whitespaces(
|
||||||
|
Parser.remove_comments(code),
|
||||||
|
'"',
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def parse(macro: str, context=None, mapping=None, verbose: bool = True) -> Macro:
|
||||||
|
"""Parse and generate a Macro that can be run as often as you want.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
macro
|
||||||
|
"repeat(3, key(a).wait(10))"
|
||||||
|
"repeat(2, key(a).key(KEY_A)).key(b)"
|
||||||
|
"wait(1000).modify(Shift_L, repeat(2, k(a))).wait(10, 20).key(b)"
|
||||||
|
context : Context, or None for use in Frontend
|
||||||
|
mapping
|
||||||
|
the mapping for the macro, or None for use in Frontend
|
||||||
|
verbose
|
||||||
|
log the parsing True by default
|
||||||
|
"""
|
||||||
|
# TODO pass mapping in frontend and do the target check for keys?
|
||||||
|
logger.debug("parsing macro %s", macro.replace("\n", ""))
|
||||||
|
macro = Parser.clean(macro)
|
||||||
|
macro = Parser.handle_plus_syntax(macro)
|
||||||
|
|
||||||
|
macro_obj = Parser._parse_recurse(
|
||||||
|
macro,
|
||||||
|
context,
|
||||||
|
mapping,
|
||||||
|
verbose,
|
||||||
|
).value
|
||||||
|
if not isinstance(macro_obj, Macro):
|
||||||
|
raise MacroError(macro, "The provided code was not a macro")
|
||||||
|
|
||||||
|
return macro_obj
|
||||||
35
inputremapper/injection/macros/raw_value.py
Normal file
35
inputremapper/injection/macros/raw_value.py
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# input-remapper - GUI for device specific keyboard mappings
|
||||||
|
# Copyright (C) 2025 sezanzeb <b8x45ygc9@mozmail.com>
|
||||||
|
#
|
||||||
|
# This file is part of input-remapper.
|
||||||
|
#
|
||||||
|
# input-remapper is free software: you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU General Public License as published by
|
||||||
|
# the Free Software Foundation, either version 3 of the License, or
|
||||||
|
# (at your option) any later version.
|
||||||
|
#
|
||||||
|
# input-remapper is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU General Public License
|
||||||
|
# along with input-remapper. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Union
|
||||||
|
|
||||||
|
from inputremapper.injection.macros.macro import Macro
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class RawValue:
|
||||||
|
"""Store a value exactly as it is in the macro-code. Values still have to be
|
||||||
|
validated according to the ArgumentConfig of the Tasks.
|
||||||
|
|
||||||
|
Child-macros are passed as Macro objects though.
|
||||||
|
"""
|
||||||
|
|
||||||
|
value: Union[str, Macro]
|
||||||
248
inputremapper/injection/macros/task.py
Normal file
248
inputremapper/injection/macros/task.py
Normal file
@ -0,0 +1,248 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# input-remapper - GUI for device specific keyboard mappings
|
||||||
|
# Copyright (C) 2025 sezanzeb <b8x45ygc9@mozmail.com>
|
||||||
|
#
|
||||||
|
# This file is part of input-remapper.
|
||||||
|
#
|
||||||
|
# input-remapper is free software: you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU General Public License as published by
|
||||||
|
# the Free Software Foundation, either version 3 of the License, or
|
||||||
|
# (at your option) any later version.
|
||||||
|
#
|
||||||
|
# input-remapper is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU General Public License
|
||||||
|
# along with input-remapper. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from itertools import chain
|
||||||
|
from typing import List, Dict, TYPE_CHECKING, Optional, Tuple, Union
|
||||||
|
|
||||||
|
from inputremapper.configs.validation_errors import MacroError
|
||||||
|
from inputremapper.injection.macros.argument import (
|
||||||
|
Argument,
|
||||||
|
ArgumentConfig,
|
||||||
|
ArgumentFlags,
|
||||||
|
)
|
||||||
|
from inputremapper.injection.macros.macro import Macro, InjectEventCallback
|
||||||
|
from inputremapper.logging.logger import logger
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from inputremapper.injection.mapping_handlers.mapping_handler import EventListener
|
||||||
|
from inputremapper.injection.macros.raw_value import RawValue
|
||||||
|
from inputremapper.injection.context import Context
|
||||||
|
from inputremapper.configs.mapping import Mapping
|
||||||
|
|
||||||
|
|
||||||
|
class Task:
|
||||||
|
"""Base Class for functions like `if_eq` or `key`
|
||||||
|
|
||||||
|
A macro like `key(a).key(b)` will contain two instances of this class.
|
||||||
|
"""
|
||||||
|
|
||||||
|
argument_configs: List[ArgumentConfig]
|
||||||
|
arguments: Dict[str, Argument]
|
||||||
|
mapping: Mapping
|
||||||
|
|
||||||
|
# The context is None during frontend-parsing/validation I believe
|
||||||
|
context: Optional[Context]
|
||||||
|
|
||||||
|
child_macros: List[Macro]
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
positional_args: List[RawValue],
|
||||||
|
keyword_args: Dict[str, RawValue],
|
||||||
|
context: Optional[Context],
|
||||||
|
mapping: Mapping,
|
||||||
|
) -> None:
|
||||||
|
self.context = context
|
||||||
|
self.mapping = mapping
|
||||||
|
self.child_macros = []
|
||||||
|
|
||||||
|
self._validate_argument_configs()
|
||||||
|
|
||||||
|
self.arguments = {
|
||||||
|
argument_config.name: Argument(argument_config, mapping)
|
||||||
|
for argument_config in self.argument_configs
|
||||||
|
}
|
||||||
|
|
||||||
|
self._setup_asyncio_events()
|
||||||
|
|
||||||
|
for argument in self.arguments.values():
|
||||||
|
self._initialize_argument(argument, keyword_args, positional_args)
|
||||||
|
|
||||||
|
self._initialize_spread_arg(positional_args)
|
||||||
|
|
||||||
|
for raw_value in chain(keyword_args.values(), positional_args):
|
||||||
|
if isinstance(raw_value.value, Macro):
|
||||||
|
self.child_macros.append(raw_value.value)
|
||||||
|
|
||||||
|
async def run(self, callback: InjectEventCallback) -> None:
|
||||||
|
"""Macro logic goes here.
|
||||||
|
|
||||||
|
Call the callback with the type, code and value that should be injected.
|
||||||
|
"""
|
||||||
|
raise NotImplementedError()
|
||||||
|
|
||||||
|
def add_event_listener(self, listener: EventListener) -> None:
|
||||||
|
"""Listeners get each event from the source device.
|
||||||
|
|
||||||
|
After all listeners are done, the event will go into the mapping handlers.
|
||||||
|
|
||||||
|
Make sure to remove your event_listener once you are done.
|
||||||
|
"""
|
||||||
|
# The context will be there when the macro is parsed by the service
|
||||||
|
assert self.context is not None
|
||||||
|
self.context.listeners.add(listener)
|
||||||
|
|
||||||
|
def remove_event_listener(self, listener: EventListener) -> None:
|
||||||
|
assert self.context is not None
|
||||||
|
self.context.listeners.remove(listener)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_macro_argument_names(cls):
|
||||||
|
return [argument_config.name for argument_config in cls.argument_configs]
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_num_parameters(cls) -> Tuple[int, Union[int, float]]:
|
||||||
|
"""Get the number of required parameters and the maximum number of parameters."""
|
||||||
|
min_num_args = 0
|
||||||
|
argument_configs = cls.argument_configs
|
||||||
|
max_num_args: Union[int, float] = len(argument_configs)
|
||||||
|
for argument_config in argument_configs:
|
||||||
|
if argument_config.position == ArgumentFlags.spread:
|
||||||
|
# 0 or more
|
||||||
|
max_num_args = float("inf")
|
||||||
|
continue
|
||||||
|
|
||||||
|
if argument_config.is_required():
|
||||||
|
min_num_args += 1
|
||||||
|
|
||||||
|
return min_num_args, max_num_args
|
||||||
|
|
||||||
|
def get_argument(self, argument_name) -> Argument:
|
||||||
|
return self.arguments[argument_name]
|
||||||
|
|
||||||
|
def press_trigger(self) -> None:
|
||||||
|
"""The user pressed the trigger key down."""
|
||||||
|
for macro in self.child_macros:
|
||||||
|
macro.press_trigger()
|
||||||
|
|
||||||
|
if self.is_holding():
|
||||||
|
logger.error("Already holding")
|
||||||
|
return
|
||||||
|
|
||||||
|
self._trigger_release_event.clear()
|
||||||
|
self._trigger_press_event.set()
|
||||||
|
|
||||||
|
def release_trigger(self) -> None:
|
||||||
|
"""The user released the trigger key."""
|
||||||
|
if not self.is_holding():
|
||||||
|
return
|
||||||
|
|
||||||
|
self._trigger_release_event.set()
|
||||||
|
self._trigger_press_event.clear()
|
||||||
|
|
||||||
|
for macro in self.child_macros:
|
||||||
|
macro.release_trigger()
|
||||||
|
|
||||||
|
def is_holding(self) -> bool:
|
||||||
|
"""Check if the macro is waiting for a key to be released."""
|
||||||
|
return not self._trigger_release_event.is_set()
|
||||||
|
|
||||||
|
async def keycode_pause(self, _=None) -> None:
|
||||||
|
"""To add a pause between keystrokes.
|
||||||
|
|
||||||
|
This was needed at some point because it appeared that injecting keys too
|
||||||
|
fast will prevent them from working. It probably depends on the environment.
|
||||||
|
"""
|
||||||
|
await asyncio.sleep(self.mapping.macro_key_sleep_ms / 1000)
|
||||||
|
|
||||||
|
def _initialize_spread_arg(
|
||||||
|
self,
|
||||||
|
positional_args: List[RawValue],
|
||||||
|
) -> None:
|
||||||
|
"""Put all positional arguments that aren't used into the spread argument."""
|
||||||
|
spread_argument: Optional[Argument] = None
|
||||||
|
for argument in self.arguments.values():
|
||||||
|
if argument.position == ArgumentFlags.spread:
|
||||||
|
spread_argument = argument
|
||||||
|
break
|
||||||
|
|
||||||
|
if spread_argument is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
remaining_positional_args = [*positional_args]
|
||||||
|
|
||||||
|
for argument in self.arguments.values():
|
||||||
|
if argument.position != ArgumentFlags.spread and argument.position < len(
|
||||||
|
remaining_positional_args
|
||||||
|
):
|
||||||
|
del remaining_positional_args[argument.position]
|
||||||
|
|
||||||
|
spread_argument.initialize_variables(remaining_positional_args)
|
||||||
|
|
||||||
|
def _find_argument_by_position(self, position: int) -> Optional[Argument]:
|
||||||
|
for argument in self.arguments.values():
|
||||||
|
if argument.position == position:
|
||||||
|
return argument
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _setup_asyncio_events(self) -> None:
|
||||||
|
# Can be used to wait for the press and release of the input event/key, that is
|
||||||
|
# configured as the trigger of the macro, via asyncio.
|
||||||
|
self._trigger_release_event = asyncio.Event()
|
||||||
|
self._trigger_press_event = asyncio.Event()
|
||||||
|
# released by default
|
||||||
|
self._trigger_release_event.set()
|
||||||
|
self._trigger_press_event.clear()
|
||||||
|
|
||||||
|
def _initialize_argument(
|
||||||
|
self,
|
||||||
|
argument: Argument,
|
||||||
|
keyword_args: Dict[str, RawValue],
|
||||||
|
positional_args: List[RawValue],
|
||||||
|
) -> None:
|
||||||
|
if argument.position == ArgumentFlags.spread:
|
||||||
|
# Will get all the remaining positional arguments afterward.
|
||||||
|
return
|
||||||
|
|
||||||
|
for name, value in keyword_args.items():
|
||||||
|
if argument.name == name:
|
||||||
|
argument.initialize_variable(value)
|
||||||
|
return
|
||||||
|
|
||||||
|
if argument.position < len(positional_args):
|
||||||
|
argument.initialize_variable(positional_args[argument.position])
|
||||||
|
return
|
||||||
|
|
||||||
|
if not argument.is_required():
|
||||||
|
argument.initialize_default()
|
||||||
|
return
|
||||||
|
|
||||||
|
# This shouldn't be possible, the parser should have ensured things are valid
|
||||||
|
# already.
|
||||||
|
raise MacroError(f"Could not initialize argument {argument.name}")
|
||||||
|
|
||||||
|
def _validate_argument_configs(self):
|
||||||
|
# Might help during development
|
||||||
|
positions = set()
|
||||||
|
names = set()
|
||||||
|
for argument_config in self.argument_configs:
|
||||||
|
position = argument_config.position
|
||||||
|
if position in positions:
|
||||||
|
raise MacroError(f"Duplicate position {positions} in ArgumentConfig")
|
||||||
|
positions.add(position)
|
||||||
|
|
||||||
|
name = argument_config.name
|
||||||
|
if name in names:
|
||||||
|
raise MacroError(f"Duplicate name {name} in ArgumentConfig")
|
||||||
|
names.add(name)
|
||||||
0
inputremapper/injection/macros/tasks/__init__.py
Normal file
0
inputremapper/injection/macros/tasks/__init__.py
Normal file
72
inputremapper/injection/macros/tasks/add.py
Normal file
72
inputremapper/injection/macros/tasks/add.py
Normal file
@ -0,0 +1,72 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# input-remapper - GUI for device specific keyboard mappings
|
||||||
|
# Copyright (C) 2025 sezanzeb <b8x45ygc9@mozmail.com>
|
||||||
|
#
|
||||||
|
# This file is part of input-remapper.
|
||||||
|
#
|
||||||
|
# input-remapper is free software: you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU General Public License as published by
|
||||||
|
# the Free Software Foundation, either version 3 of the License, or
|
||||||
|
# (at your option) any later version.
|
||||||
|
#
|
||||||
|
# input-remapper is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU General Public License
|
||||||
|
# along with input-remapper. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from inputremapper.configs.validation_errors import MacroError
|
||||||
|
from inputremapper.injection.macros.argument import ArgumentConfig
|
||||||
|
from inputremapper.injection.macros.task import Task
|
||||||
|
from inputremapper.logging.logger import logger
|
||||||
|
|
||||||
|
|
||||||
|
class AddTask(Task):
|
||||||
|
"""Add a number to a variable."""
|
||||||
|
|
||||||
|
argument_configs = [
|
||||||
|
ArgumentConfig(
|
||||||
|
name="variable",
|
||||||
|
position=0,
|
||||||
|
types=[int, float, None],
|
||||||
|
is_variable_name=True,
|
||||||
|
),
|
||||||
|
ArgumentConfig(
|
||||||
|
name="value",
|
||||||
|
position=1,
|
||||||
|
types=[int, float],
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
async def run(self, callback) -> None:
|
||||||
|
argument = self.get_argument("variable")
|
||||||
|
try:
|
||||||
|
current = argument.get_value()
|
||||||
|
except MacroError:
|
||||||
|
return
|
||||||
|
|
||||||
|
if current is None:
|
||||||
|
logger.debug(
|
||||||
|
'"%s" initialized with 0',
|
||||||
|
self.arguments["variable"].get_variable_name(),
|
||||||
|
)
|
||||||
|
argument.set_value(0)
|
||||||
|
current = 0
|
||||||
|
|
||||||
|
addend = self.get_argument("value").get_value()
|
||||||
|
|
||||||
|
if not isinstance(current, (int, float)):
|
||||||
|
logger.error(
|
||||||
|
'Expected variable "%s" to contain a number, but got "%s"',
|
||||||
|
argument.get_value(),
|
||||||
|
current,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
logger.debug("%s += %s", current, addend)
|
||||||
|
argument.set_value(current + addend)
|
||||||
64
inputremapper/injection/macros/tasks/event.py
Normal file
64
inputremapper/injection/macros/tasks/event.py
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# input-remapper - GUI for device specific keyboard mappings
|
||||||
|
# Copyright (C) 2025 sezanzeb <b8x45ygc9@mozmail.com>
|
||||||
|
#
|
||||||
|
# This file is part of input-remapper.
|
||||||
|
#
|
||||||
|
# input-remapper is free software: you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU General Public License as published by
|
||||||
|
# the Free Software Foundation, either version 3 of the License, or
|
||||||
|
# (at your option) any later version.
|
||||||
|
#
|
||||||
|
# input-remapper is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU General Public License
|
||||||
|
# along with input-remapper. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from evdev.ecodes import ecodes
|
||||||
|
|
||||||
|
from inputremapper.injection.macros.argument import ArgumentConfig
|
||||||
|
from inputremapper.injection.macros.task import Task
|
||||||
|
|
||||||
|
|
||||||
|
class EventTask(Task):
|
||||||
|
"""Write any event.
|
||||||
|
|
||||||
|
For example event(EV_KEY, KEY_A, 1)
|
||||||
|
"""
|
||||||
|
|
||||||
|
argument_configs = [
|
||||||
|
ArgumentConfig(
|
||||||
|
name="type",
|
||||||
|
position=0,
|
||||||
|
types=[str, int],
|
||||||
|
),
|
||||||
|
ArgumentConfig(
|
||||||
|
name="code",
|
||||||
|
position=1,
|
||||||
|
types=[str, int],
|
||||||
|
),
|
||||||
|
ArgumentConfig(
|
||||||
|
name="value",
|
||||||
|
position=2,
|
||||||
|
types=[int],
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
async def run(self, callback) -> None:
|
||||||
|
type_ = self.get_argument("type").get_value()
|
||||||
|
code = self.get_argument("code").get_value()
|
||||||
|
value = self.get_argument("value").get_value()
|
||||||
|
|
||||||
|
if isinstance(type_, str):
|
||||||
|
type_ = ecodes[type_.upper()]
|
||||||
|
if isinstance(code, str):
|
||||||
|
code = ecodes[code.upper()]
|
||||||
|
|
||||||
|
callback(type_, code, value)
|
||||||
|
await self.keycode_pause()
|
||||||
71
inputremapper/injection/macros/tasks/hold.py
Normal file
71
inputremapper/injection/macros/tasks/hold.py
Normal file
@ -0,0 +1,71 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# input-remapper - GUI for device specific keyboard mappings
|
||||||
|
# Copyright (C) 2025 sezanzeb <b8x45ygc9@mozmail.com>
|
||||||
|
#
|
||||||
|
# This file is part of input-remapper.
|
||||||
|
#
|
||||||
|
# input-remapper is free software: you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU General Public License as published by
|
||||||
|
# the Free Software Foundation, either version 3 of the License, or
|
||||||
|
# (at your option) any later version.
|
||||||
|
#
|
||||||
|
# input-remapper is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU General Public License
|
||||||
|
# along with input-remapper. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
from evdev.ecodes import EV_KEY
|
||||||
|
|
||||||
|
from inputremapper.configs.keyboard_layout import keyboard_layout
|
||||||
|
from inputremapper.injection.macros.argument import ArgumentConfig
|
||||||
|
from inputremapper.injection.macros.macro import Macro
|
||||||
|
from inputremapper.injection.macros.task import Task
|
||||||
|
|
||||||
|
|
||||||
|
class HoldTask(Task):
|
||||||
|
"""Loop the macro until the trigger-key is released."""
|
||||||
|
|
||||||
|
argument_configs = [
|
||||||
|
ArgumentConfig(
|
||||||
|
name="macro",
|
||||||
|
position=0,
|
||||||
|
types=[Macro, str, None],
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
async def run(self, callback) -> None:
|
||||||
|
macro = self.get_argument("macro").get_value()
|
||||||
|
|
||||||
|
if macro is None:
|
||||||
|
await self._trigger_release_event.wait()
|
||||||
|
return
|
||||||
|
|
||||||
|
if isinstance(macro, str):
|
||||||
|
# if macro is a key name, hold down the key while the
|
||||||
|
# keyboard key is physically held down
|
||||||
|
symbol = macro
|
||||||
|
self.get_argument("macro").assert_is_symbol(symbol)
|
||||||
|
|
||||||
|
code = keyboard_layout.get(symbol)
|
||||||
|
callback(EV_KEY, code, 1)
|
||||||
|
await self._trigger_release_event.wait()
|
||||||
|
callback(EV_KEY, code, 0)
|
||||||
|
|
||||||
|
if isinstance(macro, Macro):
|
||||||
|
# repeat the macro forever while the key is held down
|
||||||
|
while self.is_holding():
|
||||||
|
# run the child macro completely to avoid
|
||||||
|
# not-releasing any key
|
||||||
|
await macro.run(callback)
|
||||||
|
# prevent input-remapper from freezing up and making the system hard
|
||||||
|
# to control, by adding 1ms of asyncio.sleep during which the event
|
||||||
|
# loop can do other things
|
||||||
|
await asyncio.sleep(1 / 1000)
|
||||||
55
inputremapper/injection/macros/tasks/hold_keys.py
Normal file
55
inputremapper/injection/macros/tasks/hold_keys.py
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# input-remapper - GUI for device specific keyboard mappings
|
||||||
|
# Copyright (C) 2025 sezanzeb <b8x45ygc9@mozmail.com>
|
||||||
|
#
|
||||||
|
# This file is part of input-remapper.
|
||||||
|
#
|
||||||
|
# input-remapper is free software: you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU General Public License as published by
|
||||||
|
# the Free Software Foundation, either version 3 of the License, or
|
||||||
|
# (at your option) any later version.
|
||||||
|
#
|
||||||
|
# input-remapper is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU General Public License
|
||||||
|
# along with input-remapper. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from evdev.ecodes import EV_KEY
|
||||||
|
|
||||||
|
from inputremapper.configs.keyboard_layout import keyboard_layout
|
||||||
|
from inputremapper.injection.macros.argument import ArgumentConfig, ArgumentFlags
|
||||||
|
from inputremapper.injection.macros.task import Task
|
||||||
|
|
||||||
|
|
||||||
|
class HoldKeysTask(Task):
|
||||||
|
"""Hold down multiple keys, equivalent to `a + b + c + ...`."""
|
||||||
|
|
||||||
|
argument_configs = [
|
||||||
|
ArgumentConfig(
|
||||||
|
name="*symbols",
|
||||||
|
position=ArgumentFlags.spread,
|
||||||
|
types=[str],
|
||||||
|
is_symbol=True,
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
async def run(self, callback) -> None:
|
||||||
|
symbols = self.get_argument("*symbols").get_values()
|
||||||
|
|
||||||
|
codes = [keyboard_layout.get(symbol) for symbol in symbols]
|
||||||
|
|
||||||
|
for code in codes:
|
||||||
|
callback(EV_KEY, code, 1)
|
||||||
|
await self.keycode_pause()
|
||||||
|
|
||||||
|
await self._trigger_release_event.wait()
|
||||||
|
|
||||||
|
for code in codes[::-1]:
|
||||||
|
callback(EV_KEY, code, 0)
|
||||||
|
await self.keycode_pause()
|
||||||
66
inputremapper/injection/macros/tasks/if_eq.py
Normal file
66
inputremapper/injection/macros/tasks/if_eq.py
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# input-remapper - GUI for device specific keyboard mappings
|
||||||
|
# Copyright (C) 2025 sezanzeb <b8x45ygc9@mozmail.com>
|
||||||
|
#
|
||||||
|
# This file is part of input-remapper.
|
||||||
|
#
|
||||||
|
# input-remapper is free software: you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU General Public License as published by
|
||||||
|
# the Free Software Foundation, either version 3 of the License, or
|
||||||
|
# (at your option) any later version.
|
||||||
|
#
|
||||||
|
# input-remapper is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU General Public License
|
||||||
|
# along with input-remapper. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from inputremapper.injection.macros.argument import ArgumentConfig
|
||||||
|
from inputremapper.injection.macros.macro import Macro
|
||||||
|
from inputremapper.injection.macros.task import Task
|
||||||
|
|
||||||
|
|
||||||
|
class IfEqTask(Task):
|
||||||
|
"""Compare two values."""
|
||||||
|
|
||||||
|
argument_configs = [
|
||||||
|
ArgumentConfig(
|
||||||
|
name="value_1",
|
||||||
|
position=0,
|
||||||
|
types=[str, int, float],
|
||||||
|
),
|
||||||
|
ArgumentConfig(
|
||||||
|
name="value_2",
|
||||||
|
position=1,
|
||||||
|
types=[str, int, float],
|
||||||
|
),
|
||||||
|
ArgumentConfig(
|
||||||
|
name="then",
|
||||||
|
position=2,
|
||||||
|
types=[Macro, None],
|
||||||
|
default=None,
|
||||||
|
),
|
||||||
|
ArgumentConfig(
|
||||||
|
name="else",
|
||||||
|
position=3,
|
||||||
|
types=[Macro, None],
|
||||||
|
default=None,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
async def run(self, callback) -> None:
|
||||||
|
value_1 = self.get_argument("value_1").get_value()
|
||||||
|
value_2 = self.get_argument("value_2").get_value()
|
||||||
|
then = self.get_argument("then").get_value()
|
||||||
|
else_ = self.get_argument("else").get_value()
|
||||||
|
|
||||||
|
if value_1 == value_2:
|
||||||
|
if then is not None:
|
||||||
|
await then.run(callback)
|
||||||
|
elif else_ is not None:
|
||||||
|
await else_.run(callback)
|
||||||
71
inputremapper/injection/macros/tasks/if_led.py
Normal file
71
inputremapper/injection/macros/tasks/if_led.py
Normal file
@ -0,0 +1,71 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# input-remapper - GUI for device specific keyboard mappings
|
||||||
|
# Copyright (C) 2025 sezanzeb <b8x45ygc9@mozmail.com>
|
||||||
|
#
|
||||||
|
# This file is part of input-remapper.
|
||||||
|
#
|
||||||
|
# input-remapper is free software: you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU General Public License as published by
|
||||||
|
# the Free Software Foundation, either version 3 of the License, or
|
||||||
|
# (at your option) any later version.
|
||||||
|
#
|
||||||
|
# input-remapper is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU General Public License
|
||||||
|
# along with input-remapper. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from evdev.ecodes import (
|
||||||
|
LED_NUML,
|
||||||
|
LED_CAPSL,
|
||||||
|
)
|
||||||
|
|
||||||
|
from inputremapper.injection.macros.argument import ArgumentConfig
|
||||||
|
from inputremapper.injection.macros.macro import Macro
|
||||||
|
from inputremapper.injection.macros.task import Task
|
||||||
|
|
||||||
|
|
||||||
|
class IfLedTask(Task):
|
||||||
|
argument_configs = [
|
||||||
|
ArgumentConfig(
|
||||||
|
name="then",
|
||||||
|
position=0,
|
||||||
|
types=[Macro, None],
|
||||||
|
default=None,
|
||||||
|
),
|
||||||
|
ArgumentConfig(
|
||||||
|
name="else",
|
||||||
|
position=1,
|
||||||
|
types=[Macro, None],
|
||||||
|
default=None,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
led_code = None
|
||||||
|
|
||||||
|
async def run(self, callback) -> None:
|
||||||
|
then = self.get_argument("then").get_value()
|
||||||
|
else_ = self.get_argument("else").get_value()
|
||||||
|
|
||||||
|
# self.context is only None when the frontend is parsing the macro
|
||||||
|
assert self.context is not None
|
||||||
|
led_on = self.led_code in self.context.get_leds()
|
||||||
|
|
||||||
|
if led_on:
|
||||||
|
if then is not None:
|
||||||
|
await then.run(callback)
|
||||||
|
elif else_ is not None:
|
||||||
|
await else_.run(callback)
|
||||||
|
|
||||||
|
|
||||||
|
class IfNumlockTask(IfLedTask):
|
||||||
|
led_code = LED_NUML
|
||||||
|
|
||||||
|
|
||||||
|
class IfCapslockTask(IfLedTask):
|
||||||
|
led_code = LED_CAPSL
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user