diff --git a/.coveragerc b/.coveragerc new file mode 100644 index 0000000..2c1bd00 --- /dev/null +++ b/.coveragerc @@ -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.*\): diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..866472a --- /dev/null +++ b/.gitignore @@ -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/ diff --git a/.mypy.ini b/.mypy.ini new file mode 100644 index 0000000..a2e213d --- /dev/null +++ b/.mypy.ini @@ -0,0 +1,3 @@ +[mypy] +plugins = pydantic.mypy +ignore_missing_imports = True diff --git a/.pylintrc b/.pylintrc new file mode 100644 index 0000000..e9b01fe --- /dev/null +++ b/.pylintrc @@ -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 diff --git a/.reviewdog.yml b/.reviewdog.yml new file mode 100644 index 0000000..97aad1f --- /dev/null +++ b/.reviewdog.yml @@ -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 diff --git a/DEBIAN/control b/DEBIAN/control new file mode 100644 index 0000000..485a90e --- /dev/null +++ b/DEBIAN/control @@ -0,0 +1,8 @@ +Package: input-remapper +Version: 2.2.1 +Architecture: all +Maintainer: Sezanzeb +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 +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 diff --git a/DEBIAN/copyright b/DEBIAN/copyright new file mode 100644 index 0000000..f9556e4 --- /dev/null +++ b/DEBIAN/copyright @@ -0,0 +1,3 @@ +Files: * +Copyright: 2025 Sezanzeb +License: GPL-3+ diff --git a/DEBIAN/postinst b/DEBIAN/postinst new file mode 100755 index 0000000..cfaa22c --- /dev/null +++ b/DEBIAN/postinst @@ -0,0 +1,17 @@ +#!/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 +fi diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..f288702 --- /dev/null +++ b/LICENSE @@ -0,0 +1,674 @@ + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + 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. + + + Copyright (C) + + 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 . + +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: + + Copyright (C) + 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 +. + + 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 +. diff --git a/bin/input-remapper-control b/bin/input-remapper-control new file mode 100755 index 0000000..f2f5f0e --- /dev/null +++ b/bin/input-remapper-control @@ -0,0 +1,30 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + +"""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()) diff --git a/bin/input-remapper-gtk b/bin/input-remapper-gtk new file mode 100755 index 0000000..726a71d --- /dev/null +++ b/bin/input-remapper-gtk @@ -0,0 +1,26 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + +"""Starts the user interface.""" + +from inputremapper.bin.input_remapper_gtk import InputRemapperGtkBin + +if __name__ == "__main__": + InputRemapperGtkBin.main() diff --git a/bin/input-remapper-reader-service b/bin/input-remapper-reader-service new file mode 100755 index 0000000..6f5a067 --- /dev/null +++ b/bin/input-remapper-reader-service @@ -0,0 +1,28 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + +"""Starts the root reader-service.""" + +from inputremapper.bin.input_remapper_reader_service import ( + InputRemapperReaderServiceBin, +) + +if __name__ == "__main__": + InputRemapperReaderServiceBin.main() diff --git a/bin/input-remapper-service b/bin/input-remapper-service new file mode 100755 index 0000000..9309600 --- /dev/null +++ b/bin/input-remapper-service @@ -0,0 +1,26 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + +"""Starts injecting keycodes based on the configuration.""" + +from inputremapper.bin.input_remapper_service import InputRemapperServiceBin + +if __name__ == "__main__": + InputRemapperServiceBin.main() diff --git a/data/69-input-remapper-forwarded.rules b/data/69-input-remapper-forwarded.rules new file mode 100644 index 0000000..101284c --- /dev/null +++ b/data/69-input-remapper-forwarded.rules @@ -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" diff --git a/data/99-input-remapper.rules b/data/99-input-remapper.rules new file mode 100644 index 0000000..087abef --- /dev/null +++ b/data/99-input-remapper.rules @@ -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}" diff --git a/data/input-remapper-autoload.desktop b/data/input-remapper-autoload.desktop new file mode 100644 index 0000000..a647840 --- /dev/null +++ b/data/input-remapper-autoload.desktop @@ -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 diff --git a/data/input-remapper-gtk.desktop b/data/input-remapper-gtk.desktop new file mode 100644 index 0000000..71785a3 --- /dev/null +++ b/data/input-remapper-gtk.desktop @@ -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 diff --git a/data/input-remapper-large.png b/data/input-remapper-large.png new file mode 100644 index 0000000..3bc3e7f Binary files /dev/null and b/data/input-remapper-large.png differ diff --git a/data/input-remapper.glade b/data/input-remapper.glade new file mode 100644 index 0000000..df11529 --- /dev/null +++ b/data/input-remapper.glade @@ -0,0 +1,1865 @@ + + + + + + + True + False + help-about + + + True + False + 2 + 2 + media-playback-start + + + True + False + 2 + 2 + edit-copy + + + 1 + 0.01 + 0.01 + + + True + False + 2 + 2 + edit-delete + + + -1 + 1 + 0.05 + 0.05 + + + -2 + 2 + 0.10 + 0.10 + + + True + False + 2 + 2 + media-playback-stop + + + True + False + 2 + 2 + media-playback-stop + + + True + False + 2 + 2 + edit-delete + + + True + False + edit + + + True + False + media-record + + + True + False + list-add + + + True + False + list-remove + + + True + False + 2 + 2 + document-new + + + True + False + document-save + + + 1000 + False + 450 + input-remapper.svg + + + + True + False + vertical + + + True + False + crossfade + + + True + True + + + True + False + none + + + True + False + start + 18 + 18 + 18 + 18 + True + 18 + 18 + 99 + none + + + + + + + Devices + Devices + + + + + True + False + vertical + + + True + False + 18 + 18 + Device Name + + + + + + False + True + 0 + + + + + True + False + center + 18 + 18 + 27 + 6 + True + + + New + True + True + True + Create a new preset + new-icon + True + + + False + True + 0 + + + + + Stop + True + True + True + Stops the Injection for the selected device, +gives your keys their original function back +Shortcut: ctrl + del + gtk-redo-icon + True + + + False + True + 1 + + + + + False + True + 1 + + + + + True + False + + + False + True + 2 + + + + + True + True + + + True + False + none + + + True + False + start + 18 + 18 + 18 + 18 + True + 18 + 18 + 99 + none + + + + + + + True + True + 3 + + + + + Presets + Presets + 1 + + + + + True + False + vertical + + + True + False + 18 + 18 + Preset Name + + + + + + False + True + 0 + + + + + + True + False + center + 106 + 112 + 27 + 6 + 12 + + + 463 + True + False + 6 + True + + + Apply + True + True + True + Start injecting. Don't hold down any keys while the injection starts + check-icon1 + True + + + False + True + 0 + + + + + Stop + True + True + True + Stops the Injection for the selected device, +gives your keys their original function back +Shortcut: ctrl + del + gtk-redo-icon1 + True + + + False + True + 1 + + + + + Copy + True + True + True + Duplicate this preset + copy-icon1 + True + + + False + True + 2 + + + + + Delete + True + True + True + Delete this preset + delete-icon1 + True + + + False + True + 3 + + + + + 1 + 0 + + + + + 100 + True + False + 0.5 + Rename + 13 + 1 + + + 0 + 1 + + + + + True + False + 0.5 + 6 + 7 + Autoload + 1 + + + 0 + 2 + + + + + True + True + Activate this to load the preset next time the device connects, or when the user logs in + start + center + + + 1 + 2 + + + + + True + False + 6 + + + True + True + + + True + True + 0 + + + + + True + True + True + Save the entered name + start + save-icon1 + + + False + True + 1 + + + + + 1 + 1 + + + + + + + + False + True + 1 + + + + + True + False + + + False + True + 5 + + + + + True + False + True + + + True + False + + + 200 + True + False + 18 + vertical + + + True + False + 18 + Input + + + + + + False + True + 0 + + + + + True + False + center + 18 + + + True + False + no input configured + True + + + False + True + 0 + + + + + False + 0.5 + (recording ...) + + + + + + False + True + 1 + + + + + False + False + 1 + + + + + 463 + True + False + center + 18 + 18 + 18 + 6 + True + + + Add + True + True + True + image3 + True + + + False + True + 1 + + + + + Record + True + True + True + Record a button of your device that should be remapped + image2 + True + + + False + True + 1 + + + + + Advanced + True + True + True + image1 + + + False + True + 2 + + + + + Delete + True + True + True + Delete this entry + icon-delete-row + True + + + + False + True + 4 + + + + + False + True + 2 + + + + + True + False + + + False + True + 3 + + + + + True + True + + + True + False + none + + + True + False + + + + + + + + True + True + 4 + + + + + True + True + 0 + + + + + True + False + + + False + True + 1 + + + + + False + True + 0 + + + + + True + False + 18 + 18 + 18 + 18 + vertical + + + True + False + 18 + Output + + + + + + False + True + 0 + + + + + True + False + 12 + + + 100 + True + False + 0.5 + Type + 1 + + + False + True + 0 + + + + + True + False + True + expand + + + Key or Macro + True + True + True + + + True + True + 0 + + + + + Analog Axis + True + True + True + + + True + True + end + 1 + + + + + True + True + 1 + + + + + False + True + 1 + + + + + True + False + The type of device this mapping is emulating. + 6 + 12 + + + 100 + True + False + 0.5 + Target + 1 + + + False + True + 0 + + + + + True + False + + + True + True + 1 + + + + + False + True + 2 + + + + + True + False + 6 + + + True + False + vertical + 6 + + + True + False + Available output axes are affected by the Target setting. + 12 + + + 100 + True + False + 0.5 + Output axis + 1 + + + False + True + 0 + + + + + True + False + + + True + True + 1 + + + + + False + True + 0 + + + + + True + False + + + True + False + vertical + True + + + True + False + 12 + + + True + True + deadzone-adjustment + 2 + 2 + + + True + True + end + 0 + + + + + 100 + True + False + 0.5 + Deadzone + 1 + + + False + True + 1 + + + + + False + True + 0 + + + + + True + False + 12 + + + True + True + gain-adjustment + 2 + 2 + + + True + True + end + 0 + + + + + 100 + True + False + 0.5 + Gain + 1 + + + False + True + 1 + + + + + True + True + 1 + + + + + True + False + 12 + + + True + True + expo-adjustment + 2 + 2 + + + True + True + end + 0 + + + + + 100 + True + False + 0.5 + Expo + 1 + + + False + True + 1 + + + + + True + True + 2 + + + + + True + True + 0 + + + + + True + False + 6 + vertical + + + 150 + 150 + True + False + center + center + 12 + 12 + 12 + 12 + + + False + True + 0 + + + + + + False + True + 1 + + + + + False + True + 1 + + + + + True + False + The Speed at which the Input is considered at maximum. +Only relevant when mapping relative inputs (e.g. mouse) to absolute outputs (e.g. gamepad) + 12 + + + 100 + True + False + 0.5 + Input cutoff + 1 + + + False + True + 0 + + + + + True + True + + + True + True + end + 1 + + + + + False + True + 4 + + + + + Analog Axis + Analog Axis + + + + + True + False + vertical + + + True + True + + + True + True + What should be written. For example KEY_A + start + immediate + word + 10 + 10 + 10 + 10 + True + 2 + True + + + + + + True + True + 0 + + + + + True + False + You can copy this text into the output + 0.5 + 12 + True + end + + + False + True + end + 1 + + + + + + Key or Macro + Key or Macro + 1 + + + + + False + False + 3 + + + + + False + True + 1 + + + + + True + True + 6 + + + + + Editor + Editor + 2 + + + + + True + True + 0 + + + + + True + False + + + False + True + 1 + + + + + True + False + 18 + + + False + 6 + 9 + dialog-warning + + + False + True + 0 + + + + + False + 6 + 9 + dialog-error + + + False + True + 1 + + + + + True + False + 0.5 + 7 + 7 + 7 + 7 + 6 + 6 + vertical + + + + True + True + 2 + + + + + False + True + 3 + + + + + + + True + False + Input Remapper + False + True + + + True + False + True + main_stack + + + + + True + True + True + Help + end + center + about-icon + True + + + + end + 1 + + + + + + + False + True + center-on-parent + input-remapper.svg + dialog + True + window + window + + + + True + False + + + True + False + center + 18 + 18 + vertical + 18 + + + True + False + input-remapper-large.png + + + False + True + 0 + + + + + True + False + Version unknown + center + + + False + True + 1 + + + + + True + True + 6 + 6 + 6 + 6 + You can find more information and report bugs at +<a href="https://github.com/sezanzeb/input-remapper">https://github.com/sezanzeb/input-remapper</a> + True + center + + + False + True + 2 + + + + + True + True + 0.5 + 6 + 6 + 6 + 6 + © 2025 Sezanzeb b8x45ygc9@mozmail.com +This program comes with absolutely no warranty. +See the <a href="https://www.gnu.org/licenses/gpl-3.0.html">GNU General Public License, version 3 or later</a> for details. + True + center + + + + False + True + 3 + + + + + About + About + + + + + 500 + 300 + True + True + + + True + False + + + True + False + 5 + 5 + 5 + 5 + 6 + vertical + 6 + + + True + False + See <a href="https://github.com/sezanzeb/input-remapper/blob/HEAD/readme/usage.md">usage.md</a> online on github for comprehensive information. + +A "key + key + ... + key" syntax can be used to trigger key combinations. For example "Control_L + a". + +Writing "disable" as a mapping disables a key. + +Macros allow multiple characters to be written with a single key-press. Information about programming them is available online on github. See <a href="https://github.com/sezanzeb/input-remapper/blob/HEAD/readme/macros.md">macros.md</a> and <a href="https://github.com/sezanzeb/input-remapper/blob/HEAD/readme/examples.md">examples.md</a> + True + True + 0 + + + False + True + 0 + + + + + + + + + Usage + Usage + 1 + + + + + True + False + 5 + 5 + 5 + 5 + 6 + vertical + 6 + + + True + False + Shortcuts only work while keys are not being recorded and the gui is in focus. + True + 0 + + + False + True + 0 + + + + + + True + False + 18 + + + True + False + ctrl + del + 0 + + + 0 + 0 + + + + + True + False + closes the application + 0 + + + 1 + 1 + + + + + True + False + ctrl + q + 0 + + + 0 + 1 + + + + + True + False + ctrl + r + 0 + + + 0 + 2 + + + + + True + False + refreshes the device list + 0 + + + 1 + 2 + + + + + True + False + stops the injection + 0 + + + 1 + 0 + + + + + False + False + 3 + + + + + Shortcuts + Shortcuts + 2 + + + + + + + True + False + True + + + True + False + stack1 + + + + + + + + + + 200 + 200 + False + Advanced + False + True + center-on-parent + True + window + window + + + True + False + + + 220 + True + True + + + True + False + none + + + True + False + browse + + + + + + + False + True + 0 + + + + + True + False + + + False + True + 1 + + + + + + True + False + start + 18 + 18 + 6 + 12 + True + + + True + False + 0.5 + 18 + Release timeout + 1 + + + 0 + 2 + + + + + True + True + 18 + 5 + 3 + + + 1 + 2 + + + + + True + False + Release all inputs which are part of the combination before the mapping is injected + 0.5 + 18 + Release input + 1 + + + 0 + 1 + + + + + True + True + start + center + True + + + 1 + 1 + + + + + True + False + General + + + 0 + 0 + 2 + + + + + True + False + center + 18 + 18 + + + 0 + 3 + 2 + + + + + True + False + Event Specific + + + 0 + 4 + 2 + + + + + True + False + 0.5 + 18 + Remove this input + 1 + + + 0 + 5 + + + + + True + True + True + start + image4 + + + 1 + 5 + + + + + True + False + Map this input to an Analog Axis. Only possible for analog inputs and mice. + 0.5 + 18 + True + Use as analog + 1 + + + 0 + 6 + + + + + True + True + start + center + + + 1 + 6 + + + + + True + False + 0.5 + 18 + Trigger threshold + 1 + + + 0 + 7 + + + + + True + True + 18 + 5 + number + 1 + True + True + + + 1 + 7 + + + + + False + True + 2 + + + + + + diff --git a/data/input-remapper.policy b/data/input-remapper.policy new file mode 100644 index 0000000..439e9f2 --- /dev/null +++ b/data/input-remapper.policy @@ -0,0 +1,21 @@ + + + + + Run Input Remapper as root + Authentication is required to discover and read devices. + Vyžaduje sa prihlásenie na objavenie a prístup k zariadeniam. + Потрібна автентифікація для виявлення та читання пристроїв. + Требуется аутентификация для обнаружения и чтения устройств. + + no + auth_admin_keep + auth_admin_keep + + /usr/bin/input-remapper-control + false + + diff --git a/data/input-remapper.service b/data/input-remapper.service new file mode 100644 index 0000000..1b02132 --- /dev/null +++ b/data/input-remapper.service @@ -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 diff --git a/data/input-remapper.svg b/data/input-remapper.svg new file mode 100644 index 0000000..c3e38f6 --- /dev/null +++ b/data/input-remapper.svg @@ -0,0 +1,216 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + diff --git a/data/inputremapper.Control.conf b/data/inputremapper.Control.conf new file mode 100644 index 0000000..901d088 --- /dev/null +++ b/data/inputremapper.Control.conf @@ -0,0 +1,9 @@ + + + + + + + + diff --git a/data/io.github.sezanzeb.input_remapper.metainfo.xml b/data/io.github.sezanzeb.input_remapper.metainfo.xml new file mode 100644 index 0000000..bdd5330 --- /dev/null +++ b/data/io.github.sezanzeb.input_remapper.metainfo.xml @@ -0,0 +1,44 @@ + + + + io.github.sezanzeb.input_remapper + + Input Remapper + An easy to use tool to change the mapping of your input device buttons + + CC0-1.0 + GPL-3.0-or-later + + + pointing + keyboard + gamepad + + + +

+ 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. +

+
+ + input-remapper-gtk.desktop + + + + Defining a new mapping + https://raw.githubusercontent.com/sezanzeb/input-remapper/main/readme/screenshot.png + + + https://raw.githubusercontent.com/sezanzeb/input-remapper/main/readme/screenshot_2.png + + + + https://github.com/sezanzeb/input-remapper + + + + + + + +
diff --git a/data/style.css b/data/style.css new file mode 100644 index 0000000..cd716ce --- /dev/null +++ b/data/style.css @@ -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 +*/ diff --git a/inputremapper/__init__.py b/inputremapper/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/inputremapper/bin/__init__.py b/inputremapper/bin/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/inputremapper/bin/input_remapper_control.py b/inputremapper/bin/input_remapper_control.py new file mode 100755 index 0000000..74629cb --- /dev/null +++ b/inputremapper/bin/input_remapper_control.py @@ -0,0 +1,429 @@ +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + +"""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 diff --git a/inputremapper/bin/input_remapper_gtk.py b/inputremapper/bin/input_remapper_gtk.py new file mode 100755 index 0000000..a602d0f --- /dev/null +++ b/inputremapper/bin/input_remapper_gtk.py @@ -0,0 +1,152 @@ +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + +"""Starts the user interface.""" + +from __future__ import annotations + +import atexit +import sys +from argparse import ArgumentParser +from typing import Tuple + +import gi + +from inputremapper.bin.process_utils import ProcessUtils + +gi.require_version("Gtk", "3.0") +gi.require_version("GLib", "2.0") +gi.require_version("GtkSource", "4") +from gi.repository import Gtk + +# https://github.com/Nuitka/Nuitka/issues/607#issuecomment-650217096 +Gtk.init() + +from inputremapper.gui.gettext import _, LOCALE_DIR +from inputremapper.gui.reader_service import ReaderService +from inputremapper.daemon import DaemonProxy, Daemon +from inputremapper.logging.logger import logger +from inputremapper.gui.messages.message_broker import MessageBroker, MessageType +from inputremapper.configs.keyboard_layout import keyboard_layout +from inputremapper.gui.data_manager import DataManager +from inputremapper.gui.user_interface import UserInterface +from inputremapper.gui.controller import Controller +from inputremapper.injection.global_uinputs import GlobalUInputs, FrontendUInput +from inputremapper.groups import _Groups +from inputremapper.gui.reader_client import ReaderClient +from inputremapper.configs.global_config import GlobalConfig +from inputremapper.configs.migrations import Migrations + + +class InputRemapperGtkBin: + @staticmethod + def main() -> Tuple[ + UserInterface, + Controller, + DataManager, + MessageBroker, + DaemonProxy, + GlobalConfig, + ]: + parser = ArgumentParser() + parser.add_argument( + "-d", + "--debug", + action="store_true", + dest="debug", + help=_("Displays additional debug information"), + default=False, + ) + + options = parser.parse_args(sys.argv[1:]) + logger.update_verbosity(options.debug) + logger.log_info("input-remapper-gtk") + logger.debug("Using locale directory: {}".format(LOCALE_DIR)) + + global_uinputs = GlobalUInputs(FrontendUInput) + + migrations = Migrations(global_uinputs) + migrations.migrate() + + message_broker = MessageBroker() + + global_config = GlobalConfig() + + # Create the ReaderClient before we start the reader-service, otherwise the + # privileged service creates and owns those pipes, and then they cannot be accessed + # by the user. + reader_client = ReaderClient(message_broker, _Groups()) + + if ProcessUtils.count_python_processes("input-remapper-gtk") >= 2: + logger.warning( + "Another input-remapper GUI is already running. " + "This can cause problems while recording keys" + ) + + InputRemapperGtkBin.start_reader_service() + + daemon = Daemon.connect() + + data_manager = DataManager( + message_broker, + global_config, + reader_client, + daemon, + global_uinputs, + keyboard_layout, + ) + controller = Controller(message_broker, data_manager) + user_interface = UserInterface(message_broker, controller) + controller.set_gui(user_interface) + + message_broker.signal(MessageType.init) + + atexit.register(lambda: InputRemapperGtkBin.stop(daemon, controller)) + + Gtk.main() + + # For tests: + return ( + user_interface, + controller, + data_manager, + message_broker, + daemon, + global_config, + ) + + @staticmethod + def start_reader_service(): + if ProcessUtils.count_python_processes("input-remapper-reader-service") >= 1: + logger.info("Found an input-remapper-reader-service to already be running") + return + + try: + ReaderService.pkexec_reader_service() + except Exception as e: + logger.error(e) + sys.exit(11) + + @staticmethod + def stop(daemon, controller): + if isinstance(daemon, Daemon): + # have fun debugging completely unrelated tests if you remove this + daemon.stop_all() + + controller.close() diff --git a/inputremapper/bin/input_remapper_reader_service.py b/inputremapper/bin/input_remapper_reader_service.py new file mode 100755 index 0000000..b0a298c --- /dev/null +++ b/inputremapper/bin/input_remapper_reader_service.py @@ -0,0 +1,80 @@ +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + +"""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()) diff --git a/inputremapper/bin/input_remapper_service.py b/inputremapper/bin/input_remapper_service.py new file mode 100755 index 0000000..586f51b --- /dev/null +++ b/inputremapper/bin/input_remapper_service.py @@ -0,0 +1,71 @@ +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + +"""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() diff --git a/inputremapper/bin/process_utils.py b/inputremapper/bin/process_utils.py new file mode 100644 index 0000000..a0ac0db --- /dev/null +++ b/inputremapper/bin/process_utils.py @@ -0,0 +1,39 @@ +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + +import psutil + + +class ProcessUtils: + @staticmethod + def count_python_processes(name: str) -> int: + # This is somewhat complicated, because there might also be a "sudo " + # 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 diff --git a/inputremapper/configs/__init__.py b/inputremapper/configs/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/inputremapper/configs/data.py b/inputremapper/configs/data.py new file mode 100644 index 0000000..5d7e345 --- /dev/null +++ b/inputremapper/configs/data.py @@ -0,0 +1,33 @@ +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + + +"""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) diff --git a/inputremapper/configs/global_config.py b/inputremapper/configs/global_config.py new file mode 100644 index 0000000..6964134 --- /dev/null +++ b/inputremapper/configs/global_config.py @@ -0,0 +1,142 @@ +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . +"""Store which presets should be enabled for which device on login.""" + +from __future__ import annotations + +import copy +import json +import os +from typing import Optional + +from inputremapper.configs.paths import PathUtils +from inputremapper.logging.logger import logger, VERSION +from inputremapper.user import UserUtils + +MOUSE = "mouse" +WHEEL = "wheel" +BUTTONS = "buttons" +NONE = "none" + +INITIAL_CONFIG = { + "version": VERSION, + "autoload": {}, +} + + +class GlobalConfig: + """Configures stuff like autoloading in ~/.config/input-remapper-2/config.json.""" + + def __init__(self): + self.path = os.path.join(PathUtils.config_path(), "config.json") + self._config = copy.deepcopy(INITIAL_CONFIG) + + def get_dir(self) -> str: + """The folder containing this config.""" + return os.path.split(self.path)[0] + + def get_autoload_preset(self, group_key: str) -> Optional[str]: + # modifications are only allowed via the setter, because it needs to write + # the config file too. Therefore return a copy to prevent inconsistencies. + return copy.deepcopy(self._config["autoload"].get(group_key)) + + def set_autoload_preset(self, group_key: str, preset: Optional[str]): + """Set a preset to be automatically applied on start. + + Parameters + ---------- + group_key + the unique identifier of the group. This is used instead of the + name to enable autoloading two different presets when two similar + devices are connected. + preset + if None, don't autoload something for this device. + """ + if preset is not None: + self._config["autoload"][group_key] = preset + else: + logger.info('Not injecting for "%s" automatically anmore', group_key) + del self._config["autoload"][group_key] + + self._save_config() + + def iterate_autoload_presets(self): + """Get tuples of (device, preset).""" + return self._config.get("autoload", {}).items() + + def is_autoloaded(self, group_key: Optional[str], preset: Optional[str]): + """Should this preset be loaded automatically?""" + if group_key is None or preset is None: + raise ValueError("Expected group_key and preset to not be None") + + return self._config.get("autoload", {}).get(group_key) == preset + + def load_config(self, path: Optional[str] = None): + """Load the config from the file system. + Parameters + ---------- + path + If set, will change the path to load from and save to. + """ + if path is not None: + if not os.path.exists(path): + logger.error('Config at "%s" not found', path) + return + + self.path = path + + self._clear_config() + + if not os.path.exists(self.path): + # treated like an empty config + logger.debug('Config "%s" doesn\'t exist yet', self.path) + self._clear_config() + self._config = copy.deepcopy(INITIAL_CONFIG) + self._save_config() + return + + with open(self.path, "r") as file: + try: + self._config.update(json.load(file)) + logger.info('Loaded config from "%s"', self.path) + except json.decoder.JSONDecodeError as error: + logger.error( + 'Failed to parse config "%s": %s. Using defaults', + self.path, + str(error), + ) + # uses the default configuration when the config object + # is empty automatically + + def _save_config(self): + """Save the config to the file system.""" + if UserUtils.user == "root": + logger.debug("Skipping config file creation for the root user") + return + + PathUtils.touch(self.path) + + with open(self.path, "w") as file: + json.dump(self._config, file, indent=4) + logger.info("Saved config to %s", self.path) + file.write("\n") + + def _clear_config(self): + """Remove all configurations in memory.""" + self._config = {} diff --git a/inputremapper/configs/input_config.py b/inputremapper/configs/input_config.py new file mode 100644 index 0000000..a99f810 --- /dev/null +++ b/inputremapper/configs/input_config.py @@ -0,0 +1,485 @@ +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + +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"" + ) + + @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"" + + @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) diff --git a/inputremapper/configs/keyboard_layout.py b/inputremapper/configs/keyboard_layout.py new file mode 100644 index 0000000..6fd0d1a --- /dev/null +++ b/inputremapper/configs/keyboard_layout.py @@ -0,0 +1,221 @@ +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . +"""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() diff --git a/inputremapper/configs/mapping.py b/inputremapper/configs/mapping.py new file mode 100644 index 0000000..c57f955 --- /dev/null +++ b/inputremapper/configs/mapping.py @@ -0,0 +1,525 @@ +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + +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_ diff --git a/inputremapper/configs/migrations.py b/inputremapper/configs/migrations.py new file mode 100644 index 0000000..c533136 --- /dev/null +++ b/inputremapper/configs/migrations.py @@ -0,0 +1,516 @@ +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + +"""Migration functions. + +Only write changes to disk, if there actually are changes. Otherwise, file-modification +dates are destroyed. +""" +from __future__ import annotations + +import copy +import json +import os +import re +import shutil +from pathlib import Path +from typing import Iterator, Tuple, Dict, List, Optional, TypedDict + +from evdev.ecodes import ( + EV_KEY, + EV_ABS, + EV_REL, + ABS_X, + ABS_Y, + ABS_RX, + ABS_RY, + REL_X, + REL_Y, + REL_WHEEL_HI_RES, + REL_HWHEEL_HI_RES, +) +from packaging import version + +from inputremapper.configs.input_config import InputCombination, InputConfig +from inputremapper.configs.keyboard_layout import keyboard_layout +from inputremapper.configs.mapping import Mapping, UIMapping +from inputremapper.configs.paths import PathUtils +from inputremapper.configs.preset import Preset +from inputremapper.injection.global_uinputs import GlobalUInputs +from inputremapper.injection.macros.parse import Parser +from inputremapper.logging.logger import logger, VERSION +from inputremapper.user import UserUtils + + +class Config(TypedDict): + input_combination: Optional[InputCombination] + target_uinput: str + output_type: int + output_code: Optional[int] + + +class Migrations: + def __init__(self, global_uinputs: GlobalUInputs): + self.global_uinputs = global_uinputs + + def migrate(self): + """Migrate config files to the current release.""" + + self._rename_to_input_remapper() + + self._copy_to_v2() + + v = self.config_version() + + if v < version.parse("0.4.0"): + self._config_suffix() + self._preset_path() + + if v < version.parse("1.2.2"): + self._mapping_keys() + + if v < version.parse("1.4.0"): + self.global_uinputs.prepare_all() + self._add_target() + + if v < version.parse("1.4.1"): + self._otherwise_to_else() + + if v < version.parse("1.5.0"): + self._remove_logs() + + if v < version.parse("1.6.0-beta"): + self._convert_to_individual_mappings() + + # add new migrations here + + if v < version.parse(VERSION): + self._update_version() + + def all_presets( + self, + ) -> Iterator[Tuple[os.PathLike, Dict | List]]: + """Get all presets for all groups as list.""" + if not os.path.exists(PathUtils.get_preset_path()): + return + + preset_path = Path(PathUtils.get_preset_path()) + for folder in preset_path.iterdir(): + if not folder.is_dir(): + continue + + for preset in folder.iterdir(): + if preset.suffix != ".json": + continue + + try: + with open(preset, "r") as f: + preset_structure = json.load(f) + yield preset, preset_structure + except json.decoder.JSONDecodeError: + logger.warning('Invalid json format in preset "%s"', preset) + continue + + def config_version(self): + """Get the version string in config.json as packaging.Version object.""" + config_path = os.path.join(PathUtils.config_path(), "config.json") + + if not os.path.exists(config_path): + return version.parse("0.0.0") + + with open(config_path, "r") as file: + config = json.load(file) + + if "version" in config.keys(): + return version.parse(config["version"]) + + return version.parse("0.0.0") + + def _config_suffix(self): + """Append the .json suffix to the config file.""" + deprecated_path = os.path.join(PathUtils.config_path(), "config") + config_path = os.path.join(PathUtils.config_path(), "config.json") + if os.path.exists(deprecated_path) and not os.path.exists(config_path): + logger.info('Moving "%s" to "%s"', deprecated_path, config_path) + os.rename(deprecated_path, config_path) + + def _preset_path(self): + """Migrate the folder structure from < 0.4.0. + + Move existing presets into the new subfolder 'presets' + """ + new_preset_folder = os.path.join(PathUtils.config_path(), "presets") + if os.path.exists(PathUtils.get_preset_path()) or not os.path.exists( + PathUtils.config_path() + ): + return + + logger.info("Migrating presets from < 0.4.0...") + group_config_files = os.listdir(PathUtils.config_path()) + PathUtils.mkdir(PathUtils.get_preset_path()) + for group in group_config_files: + path = os.path.join(PathUtils.config_path(), group) + if os.path.isdir(path): + target = path.replace(PathUtils.config_path(), new_preset_folder) + logger.info('Moving "%s" to "%s"', path, target) + os.rename(path, target) + + logger.info("done") + + def _mapping_keys(self): + """Update all preset mappings. + + Update all keys in preset to include value e.g.: '1,5'->'1,5,1' + """ + for preset, preset_structure in self.all_presets(): + if isinstance(preset_structure, list): + continue # the preset must be at least 1.6-beta version + + changes = 0 + if "mapping" in preset_structure.keys(): + mapping = copy.deepcopy(preset_structure["mapping"]) + for key in mapping.keys(): + if key.count(",") == 1: + preset_structure["mapping"][f"{key},1"] = preset_structure[ + "mapping" + ].pop(key) + changes += 1 + + if changes: + with open(preset, "w") as file: + logger.info('Updating mapping keys of "%s"', preset) + json.dump(preset_structure, file, indent=4) + file.write("\n") + + def _update_version(self): + """Write the current version to the config file.""" + config_file = os.path.join(PathUtils.config_path(), "config.json") + if not os.path.exists(config_file): + return + + with open(config_file, "r") as file: + config = json.load(file) + + config["version"] = VERSION + with open(config_file, "w") as file: + logger.info('Updating version in config to "%s"', VERSION) + json.dump(config, file, indent=4) + + def _rename_to_input_remapper(self): + """Rename .config/key-mapper to .config/input-remapper.""" + old_config_path = os.path.join(UserUtils.home, ".config/key-mapper") + if not os.path.exists(PathUtils.config_path()) and os.path.exists( + old_config_path + ): + logger.info("Moving %s to %s", old_config_path, PathUtils.config_path()) + shutil.move(old_config_path, PathUtils.config_path()) + + def _find_target(self, symbol): + """Try to find a uinput with the required capabilities for the symbol.""" + capabilities = {EV_KEY: set(), EV_REL: set()} + + if Parser.is_this_a_macro(symbol): + # deprecated mechanic, cannot figure this out anymore + # capabilities = parse(symbol).get_capabilities() + return None + + capabilities[EV_KEY] = {keyboard_layout.get(symbol)} + + if len(capabilities[EV_REL]) > 0: + return "mouse" + + for name, uinput in self.global_uinputs.devices.items(): + if capabilities[EV_KEY].issubset(uinput.capabilities()[EV_KEY]): + return name + + logger.info('could not find a suitable target UInput for "%s"', symbol) + return None + + def _add_target(self): + """Add the target field to each preset mapping.""" + for preset, preset_structure in self.all_presets(): + if isinstance(preset_structure, list): + continue + + if "mapping" not in preset_structure.keys(): + continue + + changed = False + for key, symbol in preset_structure["mapping"].copy().items(): + if isinstance(symbol, list): + continue + + target = self._find_target(symbol) + if target is None: + target = "keyboard" + symbol = ( + f"{symbol}\n" + "# Broken mapping:\n" + "# No target can handle all specified keycodes" + ) + + logger.info( + 'Changing target of mapping for "%s" in preset "%s" to "%s"', + key, + preset, + target, + ) + symbol = [symbol, target] + preset_structure["mapping"][key] = symbol + changed = True + + if not changed: + continue + + with open(preset, "w") as file: + logger.info('Adding targets for "%s"', preset) + json.dump(preset_structure, file, indent=4) + file.write("\n") + + def _otherwise_to_else(self): + """Conditional macros should use an "else" parameter instead of "otherwise".""" + for preset, preset_structure in self.all_presets(): + if isinstance(preset_structure, list): + continue + + if "mapping" not in preset_structure.keys(): + continue + + changed = False + for key, symbol in preset_structure["mapping"].copy().items(): + if not Parser.is_this_a_macro(symbol[0]): + continue + + symbol_before = symbol[0] + symbol[0] = re.sub(r"otherwise\s*=\s*", "else=", symbol[0]) + + if symbol_before == symbol[0]: + continue + + changed = changed or symbol_before != symbol[0] + + logger.info( + 'Changing mapping for "%s" in preset "%s" to "%s"', + key, + preset, + symbol[0], + ) + + preset_structure["mapping"][key] = symbol + + if not changed: + continue + + with open(preset, "w") as file: + logger.info('Changing otherwise to else for "%s"', preset) + json.dump(preset_structure, file, indent=4) + file.write("\n") + + def _input_combination_from_string( + self, combination_string: str + ) -> InputCombination: + configs = [] + for event_str in combination_string.split("+"): + type_, code, analog_threshold = event_str.split(",") + configs.append( + { + "type": int(type_), + "code": int(code), + "analog_threshold": int(analog_threshold), + } + ) + + return InputCombination(configs) + + def _convert_to_individual_mappings( + self, + ) -> None: + """Convert preset.json + from {key: [symbol, target]} + to [{input_combination: ..., output_symbol: symbol, ...}] + """ + + for old_preset_path, old_preset in self.all_presets(): + if isinstance(old_preset, list): + continue + + migrated_preset = Preset(old_preset_path, UIMapping) + if "mapping" in old_preset.keys(): + for combination, symbol_target in old_preset["mapping"].items(): + logger.info( + 'migrating from "%s: %s" to mapping dict', + combination, + symbol_target, + ) + try: + combination = self._input_combination_from_string(combination) + except ValueError: + logger.error( + "unable to migrate mapping with invalid combination %s", + combination, + ) + continue + + mapping = UIMapping( + input_combination=combination, + target_uinput=symbol_target[1], + output_symbol=symbol_target[0], + ) + migrated_preset.add(mapping) + + if ( + "gamepad" in old_preset.keys() + and "joystick" in old_preset["gamepad"].keys() + ): + joystick_dict = old_preset["gamepad"]["joystick"] + left_purpose = joystick_dict.get("left_purpose") + right_purpose = joystick_dict.get("right_purpose") + # TODO if pointer_speed is migrated, why is it in my config? + pointer_speed = joystick_dict.get("pointer_speed") + if pointer_speed: + pointer_speed /= 100 + # non_linearity = joystick_dict.get("non_linearity") # Todo + x_scroll_speed = joystick_dict.get("x_scroll_speed") + y_scroll_speed = joystick_dict.get("y_scroll_speed") + + cfg: Config = { + "input_combination": None, + "target_uinput": "mouse", + "output_type": EV_REL, + "output_code": None, + } + + if left_purpose == "mouse": + x_config = cfg.copy() + y_config = cfg.copy() + x_config["input_combination"] = InputCombination( + [InputConfig(type=EV_ABS, code=ABS_X)] + ) + y_config["input_combination"] = InputCombination( + [InputConfig(type=EV_ABS, code=ABS_Y)] + ) + x_config["output_code"] = REL_X + y_config["output_code"] = REL_Y + mapping_x = Mapping(**x_config) + mapping_y = Mapping(**y_config) + if pointer_speed: + mapping_x.gain = pointer_speed + mapping_y.gain = pointer_speed + migrated_preset.add(mapping_x) + migrated_preset.add(mapping_y) + + if right_purpose == "mouse": + x_config = cfg.copy() + y_config = cfg.copy() + x_config["input_combination"] = InputCombination( + [InputConfig(type=EV_ABS, code=ABS_RX)] + ) + y_config["input_combination"] = InputCombination( + [InputConfig(type=EV_ABS, code=ABS_RY)] + ) + x_config["output_code"] = REL_X + y_config["output_code"] = REL_Y + mapping_x = Mapping(**x_config) + mapping_y = Mapping(**y_config) + if pointer_speed: + mapping_x.gain = pointer_speed + mapping_y.gain = pointer_speed + migrated_preset.add(mapping_x) + migrated_preset.add(mapping_y) + + if left_purpose == "wheel": + x_config = cfg.copy() + y_config = cfg.copy() + x_config["input_combination"] = InputCombination( + [InputConfig(type=EV_ABS, code=ABS_X)] + ) + y_config["input_combination"] = InputCombination( + [InputConfig(type=EV_ABS, code=ABS_Y)] + ) + x_config["output_code"] = REL_HWHEEL_HI_RES + y_config["output_code"] = REL_WHEEL_HI_RES + mapping_x = Mapping(**x_config) + mapping_y = Mapping(**y_config) + if x_scroll_speed: + mapping_x.gain = x_scroll_speed + if y_scroll_speed: + mapping_y.gain = y_scroll_speed + migrated_preset.add(mapping_x) + migrated_preset.add(mapping_y) + + if right_purpose == "wheel": + x_config = cfg.copy() + y_config = cfg.copy() + x_config["input_combination"] = InputCombination( + [InputConfig(type=EV_ABS, code=ABS_RX)] + ) + y_config["input_combination"] = InputCombination( + [InputConfig(type=EV_ABS, code=ABS_RY)] + ) + x_config["output_code"] = REL_HWHEEL_HI_RES + y_config["output_code"] = REL_WHEEL_HI_RES + mapping_x = Mapping(**x_config) + mapping_y = Mapping(**y_config) + if x_scroll_speed: + mapping_x.gain = x_scroll_speed + if y_scroll_speed: + mapping_y.gain = y_scroll_speed + migrated_preset.add(mapping_x) + migrated_preset.add(mapping_y) + + migrated_preset.save() + + def _copy_to_v2(self): + """Move the beta config to the v2 path, or copy the v1 config to the v2 path.""" + # TODO test + if os.path.exists(PathUtils.config_path()): + # don't copy to already existing folder + # users should delete the input-remapper-2 folder if they need to + return + + # prioritize the v1 configs over beta configs + old_path = os.path.join(UserUtils.home, ".config/input-remapper") + if os.path.exists(os.path.join(old_path, "config.json")): + # no beta path, only old presets exist. COPY to v2 path, which will then be + # migrated by the various self. + logger.debug("copying all from %s to %s", old_path, PathUtils.config_path()) + shutil.copytree(old_path, PathUtils.config_path()) + return + + # if v1 configs don't exist, try to find beta configs. + beta_path = os.path.join( + UserUtils.home, ".config/input-remapper/beta_1.6.0-beta" + ) + if os.path.exists(beta_path): + # There has never been a different version than "1.6.0-beta" in beta, so we + # only need to check for that exact directory + # already migrated, possibly new presets in them, move to v2 path + logger.debug("moving %s to %s", beta_path, PathUtils.config_path()) + shutil.move(beta_path, PathUtils.config_path()) + + def _remove_logs(self): + """We will try to rely on journalctl for this in the future.""" + try: + PathUtils.remove(f"{UserUtils.home}/.log/input-remapper") + PathUtils.remove("/var/log/input-remapper") + PathUtils.remove("/var/log/input-remapper-control") + except Exception as error: + logger.debug("Failed to remove deprecated logfiles: %s", str(error)) + # this migration is not important. Continue + pass diff --git a/inputremapper/configs/paths.py b/inputremapper/configs/paths.py new file mode 100644 index 0000000..db961cc --- /dev/null +++ b/inputremapper/configs/paths.py @@ -0,0 +1,157 @@ +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + +# 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) diff --git a/inputremapper/configs/preset.py b/inputremapper/configs/preset.py new file mode 100644 index 0000000..5122cba --- /dev/null +++ b/inputremapper/configs/preset.py @@ -0,0 +1,325 @@ +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + +"""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 diff --git a/inputremapper/configs/validation_errors.py b/inputremapper/configs/validation_errors.py new file mode 100644 index 0000000..af9d213 --- /dev/null +++ b/inputremapper/configs/validation_errors.py @@ -0,0 +1,137 @@ +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + +"""Exceptions that are thrown when configurations are incorrect.""" + +# can't merge this with exceptions.py, because I want error constructors here to +# be intelligent to avoid redundant code, and they need imports, which would cause +# circular imports. + +# pydantic only catches ValueError, TypeError, and AssertionError + +from __future__ import annotations + +from typing import Optional + +from evdev.ecodes import EV_KEY + +from inputremapper.configs.keyboard_layout import keyboard_layout +from inputremapper.injection.global_uinputs import GlobalUInputs + + +class OutputSymbolVariantError(ValueError): + def __init__(self): + super().__init__( + "Missing Argument: Mapping must either contain " + "`output_symbol` or `output_type` and `output_code`" + ) + + +class TriggerPointInRangeError(ValueError): + def __init__(self, input_config): + super().__init__( + f"{input_config = } maps an absolute axis to a button, but the " + "trigger point (event.analog_threshold) is not between -100[%] " + "and 100[%]" + ) + + +class OnlyOneAnalogInputError(ValueError): + def __init__(self, analog_events): + super().__init__( + f"Cannot map a combination of multiple analog inputs: {analog_events}" + "add trigger points (event.value != 0) to map as a button" + ) + + +class SymbolNotAvailableInTargetError(ValueError): + def __init__(self, symbol, target): + code = keyboard_layout.get(symbol) + + fitting_targets = GlobalUInputs.find_fitting_default_uinputs(EV_KEY, code) + fitting_targets_string = '", "'.join(fitting_targets) + + super().__init__( + f'The output_symbol "{symbol}" is not available for the "{target}" ' + + f'target. Try "{fitting_targets_string}".' + ) + + +class OutputSymbolUnknownError(ValueError): + def __init__(self, symbol: str): + super().__init__( + f'The output_symbol "{symbol}" is not a macro and not a valid ' + + "keycode-name" + ) + + +class MacroButTypeOrCodeSetError(ValueError): + def __init__(self): + super().__init__( + "output_symbol is a macro: output_type " "and output_code must be None" + ) + + +class SymbolAndCodeMismatchError(ValueError): + def __init__(self, symbol, code): + super().__init__( + "output_symbol and output_code mismatch: " + f"output macro is {symbol} -> {keyboard_layout.get(symbol)} " + f"but output_code is {code} -> {keyboard_layout.get_name(code)} " + ) + + +class WrongMappingTypeForKeyError(ValueError): + def __init__(self): + super().__init__("Wrong mapping_type for key input") + + +class MissingMacroOrKeyError(ValueError): + def __init__(self): + super().__init__("Missing macro or key") + + +class MissingOutputAxisError(ValueError): + def __init__(self, analog_input_config, output_type): + super().__init__( + "Missing output axis: " + f'"{analog_input_config}" is used as analog input, ' + f"but the {output_type = } is not an axis " + ) + + +class MacroError(ValueError): + """Macro syntax errors.""" + + def __init__(self, symbol: Optional[str] = None, msg="Error while parsing a macro"): + self.symbol = symbol + super().__init__(msg) + + +def pydantify(error: type): + """Generate a string as it would appear IN pydantic error types. + + This does not include the base class name, which is transformed to snake case in + pydantic. Example pydantic error type: "value_error.foobar" for FooBarError. + """ + # See https://github.com/pydantic/pydantic/discussions/5112 + lower_classname = error.__name__.lower() + if lower_classname.endswith("error"): + return lower_classname[: -len("error")] + return lower_classname diff --git a/inputremapper/daemon.py b/inputremapper/daemon.py new file mode 100644 index 0000000..c229241 --- /dev/null +++ b/inputremapper/daemon.py @@ -0,0 +1,548 @@ +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + + +"""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""" + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + """ + + 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) diff --git a/inputremapper/exceptions.py b/inputremapper/exceptions.py new file mode 100644 index 0000000..a53e085 --- /dev/null +++ b/inputremapper/exceptions.py @@ -0,0 +1,65 @@ +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + + +"""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) diff --git a/inputremapper/groups.py b/inputremapper/groups.py new file mode 100644 index 0000000..13674b5 --- /dev/null +++ b/inputremapper/groups.py @@ -0,0 +1,567 @@ +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + +"""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"" + + +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: 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() diff --git a/inputremapper/gui/__init__.py b/inputremapper/gui/__init__.py new file mode 100644 index 0000000..ee13662 --- /dev/null +++ b/inputremapper/gui/__init__.py @@ -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") diff --git a/inputremapper/gui/autocompletion.py b/inputremapper/gui/autocompletion.py new file mode 100644 index 0000000..9ceeb6f --- /dev/null +++ b/inputremapper/gui/autocompletion.py @@ -0,0 +1,457 @@ +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + + +"""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, + [], +) diff --git a/inputremapper/gui/components/__init__.py b/inputremapper/gui/components/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/inputremapper/gui/components/common.py b/inputremapper/gui/components/common.py new file mode 100644 index 0000000..fd4892e --- /dev/null +++ b/inputremapper/gui/components/common.py @@ -0,0 +1,174 @@ +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + + +"""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)) diff --git a/inputremapper/gui/components/device_groups.py b/inputremapper/gui/components/device_groups.py new file mode 100644 index 0000000..7627db7 --- /dev/null +++ b/inputremapper/gui/components/device_groups.py @@ -0,0 +1,115 @@ +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + +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) diff --git a/inputremapper/gui/components/editor.py b/inputremapper/gui/components/editor.py new file mode 100644 index 0000000..6b64d2e --- /dev/null +++ b/inputremapper/gui/components/editor.py @@ -0,0 +1,1211 @@ +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + + +"""All components that control a single preset.""" + + +from __future__ import annotations + +from collections import defaultdict +from typing import List, Optional, Dict, Union, Callable, Literal, Set + +import cairo +from evdev.ecodes import ( + EV_KEY, + EV_ABS, + EV_REL, + BTN_LEFT, + BTN_MIDDLE, + BTN_RIGHT, + BTN_EXTRA, + BTN_SIDE, +) +from gi.repository import Gtk, GtkSource, Gdk + +from inputremapper.configs.input_config import InputCombination, InputConfig +from inputremapper.configs.keyboard_layout import keyboard_layout, XKB_KEYCODE_OFFSET +from inputremapper.configs.mapping import MappingData, MappingType +from inputremapper.groups import DeviceType +from inputremapper.gui.components.output_type_names import OutputTypeNames +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 ( + UInputsData, + PresetData, + CombinationUpdate, +) +from inputremapper.gui.utils import HandlerDisabled, Colors +from inputremapper.injection.mapping_handlers.axis_transform import Transformation +from inputremapper.input_event import InputEvent +from inputremapper.utils import get_evdev_constant_name + +Capabilities = Dict[int, List] + +SET_KEY_FIRST = _("Record the input first") + +ICON_NAMES = { + DeviceType.GAMEPAD: "input-gaming", + DeviceType.MOUSE: "input-mouse", + DeviceType.KEYBOARD: "input-keyboard", + DeviceType.GRAPHICS_TABLET: "input-tablet", + DeviceType.TOUCHPAD: "input-touchpad", + DeviceType.UNKNOWN: None, +} + +# sort types that most devices would fall in easily to the right. +ICON_PRIORITIES = [ + DeviceType.GRAPHICS_TABLET, + DeviceType.TOUCHPAD, + DeviceType.GAMEPAD, + DeviceType.MOUSE, + DeviceType.KEYBOARD, + DeviceType.UNKNOWN, +] + + +class TargetSelection: + """The dropdown menu to select the targe_uinput of the active_mapping, + + For example "keyboard" or "gamepad". + """ + + _mapping: Optional[MappingData] = None + + def __init__( + self, + message_broker: MessageBroker, + controller: Controller, + combobox: Gtk.ComboBox, + ): + self._message_broker = message_broker + self._controller = controller + self._gui = combobox + + self._message_broker.subscribe(MessageType.uinputs, self._on_uinputs_changed) + self._message_broker.subscribe(MessageType.mapping, self._on_mapping_loaded) + self._gui.connect("changed", self._on_gtk_target_selected) + + def _select_current_target(self): + """Select the currently configured target.""" + if self._mapping is not None: + with HandlerDisabled(self._gui, self._on_gtk_target_selected): + self._gui.set_active_id(self._mapping.target_uinput) + + def _on_uinputs_changed(self, data: UInputsData): + target_store = Gtk.ListStore(str) + for uinput in data.uinputs.keys(): + target_store.append([uinput]) + + self._gui.set_model(target_store) + renderer_text = Gtk.CellRendererText() + self._gui.pack_start(renderer_text, False) + self._gui.add_attribute(renderer_text, "text", 0) + self._gui.set_id_column(0) + + self._select_current_target() + + def _on_mapping_loaded(self, mapping: MappingData): + self._mapping = mapping + self._select_current_target() + + def _on_gtk_target_selected(self, *_): + target = self._gui.get_active_id() + self._controller.update_mapping(target_uinput=target) + + +class MappingListBox: + """The listbox showing all available mapping in the active_preset.""" + + def __init__( + self, + message_broker: MessageBroker, + controller: Controller, + listbox: Gtk.ListBox, + ): + self._message_broker = message_broker + self._controller = controller + self._gui = listbox + self._gui.set_sort_func(self._sort_func) + + self._message_broker.subscribe(MessageType.preset, self._on_preset_changed) + self._message_broker.subscribe(MessageType.mapping, self._on_mapping_changed) + self._gui.connect("row-selected", self._on_gtk_mapping_selected) + + @staticmethod + def _sort_func(row1: MappingSelectionLabel, row2: MappingSelectionLabel) -> int: + """Sort alphanumerical by name.""" + if row1.combination == InputCombination.empty_combination(): + return 1 + if row2.combination == InputCombination.empty_combination(): + return 0 + + return 0 if row1.name < row2.name else 1 + + def _on_preset_changed(self, data: PresetData): + selection_labels = self._gui.get_children() + for selection_label in selection_labels: + selection_label.cleanup() + self._gui.remove(selection_label) + + if not data.mappings: + return + + for mapping in data.mappings: + selection_label = MappingSelectionLabel( + self._message_broker, + self._controller, + mapping.format_name(), + mapping.input_combination, + ) + self._gui.insert(selection_label, -1) + self._gui.invalidate_sort() + + def _on_mapping_changed(self, mapping: MappingData): + with HandlerDisabled(self._gui, self._on_gtk_mapping_selected): + combination = mapping.input_combination + + for row in self._gui.get_children(): + if row.combination == combination: + self._gui.select_row(row) + + def _on_gtk_mapping_selected(self, _, row: Optional[MappingSelectionLabel]): + if not row: + return + self._controller.load_mapping(row.combination) + + +class MappingSelectionLabel(Gtk.ListBoxRow): + """The ListBoxRow representing a mapping inside the MappingListBox.""" + + __gtype_name__ = "MappingSelectionLabel" + + def __init__( + self, + message_broker: MessageBroker, + controller: Controller, + name: Optional[str], + combination: InputCombination, + ): + super().__init__() + self._message_broker = message_broker + self._controller = controller + + if not name: + name = combination.beautify() + + self.name = name + self.combination = combination + # add hotkey handler + self.connect("key-press-event", self._on_key_press) + + # Make the child label widget break lines, important for + # long combinations + self.label = Gtk.Label() + self.label.set_line_wrap(True) + self.label.set_line_wrap_mode(Gtk.WrapMode.WORD) + self.label.set_justify(Gtk.Justification.CENTER) + # set the name or combination.beautify as label + self.label.set_label(self.name) + + self.label.set_margin_top(11) + self.label.set_margin_bottom(11) + + # button to edit the name of the mapping + self.edit_btn = Gtk.Button() + self.edit_btn.set_relief(Gtk.ReliefStyle.NONE) + self.edit_btn.set_image( + Gtk.Image.new_from_icon_name(Gtk.STOCK_EDIT, Gtk.IconSize.MENU) + ) + self.edit_btn.set_tooltip_text(_("Change Mapping Name") + " (F2)") + self.edit_btn.set_margin_top(4) + self.edit_btn.set_margin_bottom(4) + self.edit_btn.connect("clicked", self._set_edit_mode) + + self.name_input = Gtk.Entry() + self.name_input.set_text(self.name) + self.name_input.set_halign(Gtk.Align.FILL) + self.name_input.set_margin_top(4) + self.name_input.set_margin_bottom(4) + self.name_input.connect("activate", self._on_gtk_rename_finished) + self.name_input.connect("key-press-event", self._on_gtk_rename_abort) + + self._box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL) + self._box.set_center_widget(self.label) + self._box.add(self.edit_btn) + self._box.set_child_packing(self.edit_btn, False, False, 4, Gtk.PackType.END) + self._box.add(self.name_input) + self._box.set_child_packing(self.name_input, True, True, 4, Gtk.PackType.START) + + self.add(self._box) + self.show_all() + self._message_broker.subscribe(MessageType.mapping, self._on_mapping_changed) + self._message_broker.subscribe( + MessageType.combination_update, self._on_combination_update + ) + + self.edit_btn.hide() + self.name_input.hide() + + def __repr__(self): + return f"" + + def _set_not_selected(self): + self.edit_btn.hide() + self.name_input.hide() + self.label.show() + + def _set_selected(self): + self.label.set_label(self.name) + self.edit_btn.show() + self.name_input.hide() + self.label.show() + + def _set_edit_mode(self, *_): + self.name_input.set_text(self.name) + self.label.hide() + self.name_input.show() + self._controller.set_focus(self.name_input) + + def _on_key_press(self, widget, event): + """ "Mapping Selection Row Label key handler for hotkeys""" + if self.label.is_visible(): + # hotkeys are only meaningful when not already in edit-mapping-name-mode + if event.keyval == Gdk.KEY_F2: + self._set_edit_mode() + elif event.keyval == Gdk.KEY_Delete: + self._controller.delete_mapping() + + def _on_mapping_changed(self, mapping: MappingData): + if mapping.input_combination != self.combination: + self._set_not_selected() + return + self.name = mapping.format_name() + self._set_selected() + self.get_parent().invalidate_sort() + + def _on_combination_update(self, data: CombinationUpdate): + if data.old_combination == self.combination and self.is_selected(): + self.combination = data.new_combination + + def _on_gtk_rename_finished(self, *_): + name = self.name_input.get_text() + if name.lower().strip() == self.combination.beautify().lower(): + name = "" + self.name = name + self._set_selected() + self._controller.update_mapping(name=name) + + def _on_gtk_rename_abort(self, _, key_event: Gdk.EventKey): + if key_event.keyval == Gdk.KEY_Escape: + self._set_selected() + + def cleanup(self) -> None: + """Clean up message listeners. Execute before removing from gui!""" + self._message_broker.unsubscribe(self._on_mapping_changed) + self._message_broker.unsubscribe(self._on_combination_update) + + +class GdkEventRecorder: + """Records events delivered by GDK, similar to the ReaderService/ReaderClient.""" + + _combination: List[int] + _pressed: Set[int] + + __gtype_name__ = "GdkEventRecorder" + + def __init__(self, window: Gtk.Window, gui: Gtk.Label): + super().__init__() + self._combination = [] + self._pressed = set() + self._gui = gui + window.connect("event", self._on_gtk_event) + + def _get_button_code(self, event: Gdk.Event): + """Get the evdev code for the given event.""" + return { + Gdk.BUTTON_MIDDLE: BTN_MIDDLE, + Gdk.BUTTON_PRIMARY: BTN_LEFT, + Gdk.BUTTON_SECONDARY: BTN_RIGHT, + 9: BTN_EXTRA, + 8: BTN_SIDE, + }.get(event.get_button().button) + + def _reset(self, event: Gdk.Event): + """If a new combination is being typed, start from scratch.""" + gdk_event_type: int = event.type + + is_press = gdk_event_type in [ + Gdk.EventType.KEY_PRESS, + Gdk.EventType.BUTTON_PRESS, + ] + + if len(self._pressed) == 0 and is_press: + self._combination = [] + + def _press(self, event: Gdk.Event): + """Remember pressed keys, write down combinations.""" + gdk_event_type: int = event.type + + if gdk_event_type == Gdk.EventType.KEY_PRESS: + code = event.hardware_keycode - XKB_KEYCODE_OFFSET + if code not in self._combination: + self._combination.append(code) + + self._pressed.add(code) + + if gdk_event_type == Gdk.EventType.BUTTON_PRESS: + code = self._get_button_code(event) + if code not in self._combination: + self._combination.append(code) + + self._pressed.add(code) + + def _release(self, event: Gdk.Event): + """Clear pressed keys if this is a release event.""" + if event.type in [Gdk.EventType.KEY_RELEASE, Gdk.EventType.BUTTON_RELEASE]: + self._pressed = set() + + def _display(self, event): + """Show the recorded combination in the gui.""" + is_press = event.type in [ + Gdk.EventType.KEY_PRESS, + Gdk.EventType.BUTTON_PRESS, + ] + + if is_press and len(self._combination) > 0: + names = [ + keyboard_layout.get_name(code) + for code in self._combination + if code is not None and keyboard_layout.get_name(code) is not None + ] + self._gui.set_text(" + ".join(names)) + + def _on_gtk_event(self, _, event: Gdk.Event): + """For all sorts of input events that gtk cares about.""" + self._reset(event) + self._release(event) + self._press(event) + self._display(event) + + +class CodeEditor: + """The editor used to edit the output_symbol of the active_mapping.""" + + placeholder: str = _("Enter your output here") + + def __init__( + self, + message_broker: MessageBroker, + controller: Controller, + editor: GtkSource.View, + ): + self._message_broker = message_broker + self._controller = controller + self.gui = editor + + # without this the wrapping ScrolledWindow acts weird when new lines are added, + # not offering enough space to the text editor so the whole thing is suddenly + # scrollable by a few pixels. + # Found this after making blind guesses with settings in glade, and then + # actually looking at the snapshot preview! In glades editor this didn't have an + # effect. + self.gui.set_resize_mode(Gtk.ResizeMode.IMMEDIATE) + + # Syntax Highlighting + # TODO there are some similarities with python, but overall it's quite useless. + # commented out until there is proper highlighting for input-remappers syntax. + # Thanks to https://github.com/wolfthefallen/py-GtkSourceCompletion-example + # language_manager = GtkSource.LanguageManager() + # fun fact: without saving LanguageManager into its own variable it doesn't work + # python = language_manager.get_language("python") + # source_view.get_buffer().set_language(python) + + self._update_placeholder() + + self.gui.get_buffer().connect("changed", self._on_gtk_changed) + self.gui.connect("focus-in-event", self._update_placeholder) + self.gui.connect("focus-out-event", self._update_placeholder) + self._connect_message_listener() + + def _update_placeholder(self, *_): + buffer = self.gui.get_buffer() + code = buffer.get_text(buffer.get_start_iter(), buffer.get_end_iter(), True) + + # test for incorrect states and fix them, without causing side effects + with HandlerDisabled(buffer, self._on_gtk_changed): + if self.gui.has_focus() and code == self.placeholder: + # hide the placeholder + buffer.set_text("") + self.gui.get_style_context().remove_class("opaque-text") + elif code == "": + # show the placeholder instead + buffer.set_text(self.placeholder) + self.gui.get_style_context().add_class("opaque-text") + elif code != "": + # something is written, ensure the opacity is correct + self.gui.get_style_context().remove_class("opaque-text") + + def _shows_placeholder(self): + buffer = self.gui.get_buffer() + code = buffer.get_text(buffer.get_start_iter(), buffer.get_end_iter(), True) + return code == self.placeholder + + @property + def code(self) -> str: + """Get the user-defined macro code string.""" + if self._shows_placeholder(): + return "" + + buffer = self.gui.get_buffer() + return buffer.get_text(buffer.get_start_iter(), buffer.get_end_iter(), True) + + @code.setter + def code(self, code: str) -> None: + """Set the text without triggering any events.""" + buffer = self.gui.get_buffer() + with HandlerDisabled(buffer, self._on_gtk_changed): + buffer.set_text(code) + self._update_placeholder() + self.gui.do_move_cursor(self.gui, Gtk.MovementStep.BUFFER_ENDS, -1, False) + + def _connect_message_listener(self): + self._message_broker.subscribe( + MessageType.mapping, + self._on_mapping_loaded, + ) + self._message_broker.subscribe( + MessageType.recording_finished, + self._on_recording_finished, + ) + + def _toggle_line_numbers(self): + """Show line numbers if multiline, otherwise remove them.""" + if "\n" in self.code: + self.gui.set_show_line_numbers(True) + # adds a bit of space between numbers and text: + self.gui.set_show_line_marks(True) + self.gui.set_monospace(True) + self.gui.get_style_context().add_class("multiline") + else: + self.gui.set_show_line_numbers(False) + self.gui.set_show_line_marks(False) + self.gui.set_monospace(False) + self.gui.get_style_context().remove_class("multiline") + + def _on_gtk_changed(self, *_): + if self._shows_placeholder(): + return + + self._controller.update_mapping(output_symbol=self.code) + + def _on_mapping_loaded(self, mapping: MappingData): + code = SET_KEY_FIRST + if not self._controller.is_empty_mapping(): + code = mapping.output_symbol or "" + + if self.code.strip().lower() != code.strip().lower(): + self.code = code + + self._toggle_line_numbers() + + def _on_recording_finished(self, _): + self._controller.set_focus(self.gui) + + +class RequireActiveMapping: + """Disable the widget if no mapping is selected.""" + + def __init__( + self, + message_broker: MessageBroker, + widget: Gtk.ToggleButton, + require_recorded_input: bool, + ): + self._widget = widget + self._default_tooltip = self._widget.get_tooltip_text() + self._require_recorded_input = require_recorded_input + + self._active_preset: Optional[PresetData] = None + self._active_mapping: Optional[MappingData] = None + + message_broker.subscribe(MessageType.preset, self._on_preset) + message_broker.subscribe(MessageType.mapping, self._on_mapping) + + def _on_preset(self, preset_data: PresetData): + self._active_preset = preset_data + self._check() + + def _on_mapping(self, mapping_data: MappingData): + self._active_mapping = mapping_data + self._check() + + def _check(self, *__): + if not self._active_preset or len(self._active_preset.mappings) == 0: + self._disable() + self._widget.set_tooltip_text(_("Add a mapping first")) + return + + if ( + self._require_recorded_input + and self._active_mapping + and not self._active_mapping.has_input_defined() + ): + self._disable() + self._widget.set_tooltip_text(_("Record input first")) + return + + self._enable() + self._widget.set_tooltip_text(self._default_tooltip) + + def _enable(self): + self._widget.set_sensitive(True) + self._widget.set_opacity(1) + + def _disable(self): + self._widget.set_sensitive(False) + self._widget.set_opacity(0.5) + + +class RecordingToggle: + """The toggle that starts input recording for the active_mapping.""" + + def __init__( + self, + message_broker: MessageBroker, + controller: Controller, + toggle: Gtk.ToggleButton, + ): + self._message_broker = message_broker + self._controller = controller + self._gui = toggle + + toggle.connect("toggled", self._on_gtk_toggle) + # Don't leave the input when using arrow keys or tab. wait for the + # window to consume the keycode from the reader. I.e. a tab input should + # be recorded, instead of causing the recording to stop. + toggle.connect("key-press-event", lambda *args: Gdk.EVENT_STOP) + self._message_broker.subscribe( + MessageType.recording_finished, + self._on_recording_finished, + ) + + RequireActiveMapping( + message_broker, + toggle, + require_recorded_input=False, + ) + + def _on_gtk_toggle(self, *__): + if self._gui.get_active(): + self._controller.start_key_recording() + else: + self._controller.stop_key_recording() + + def _on_recording_finished(self, __): + with HandlerDisabled(self._gui, self._on_gtk_toggle): + self._gui.set_active(False) + + +class RecordingStatus: + """Displays if keys are being recorded for a mapping.""" + + def __init__( + self, + message_broker: MessageBroker, + label: Gtk.Label, + ): + self._gui = label + + message_broker.subscribe( + MessageType.recording_started, + self._on_recording_started, + ) + + message_broker.subscribe( + MessageType.recording_finished, + self._on_recording_finished, + ) + + def _on_recording_started(self, _): + self._gui.set_visible(True) + + def _on_recording_finished(self, _): + self._gui.set_visible(False) + + +class AutoloadSwitch: + """The switch used to toggle the autoload state of the active_preset.""" + + def __init__( + self, message_broker: MessageBroker, controller: Controller, switch: Gtk.Switch + ): + self._message_broker = message_broker + self._controller = controller + self._gui = switch + + self._gui.connect("state-set", self._on_gtk_toggle) + self._message_broker.subscribe(MessageType.preset, self._on_preset_changed) + + def _on_preset_changed(self, data: PresetData): + with HandlerDisabled(self._gui, self._on_gtk_toggle): + self._gui.set_active(data.autoload) + + def _on_gtk_toggle(self, *_): + self._controller.set_autoload(self._gui.get_active()) + + +class ReleaseCombinationSwitch: + """The switch used to set the active_mapping.release_combination_keys parameter.""" + + def __init__( + self, message_broker: MessageBroker, controller: Controller, switch: Gtk.Switch + ): + self._message_broker = message_broker + self._controller = controller + self._gui = switch + + self._gui.connect("state-set", self._on_gtk_toggle) + self._message_broker.subscribe(MessageType.mapping, self._on_mapping_changed) + + def _on_mapping_changed(self, data: MappingData): + with HandlerDisabled(self._gui, self._on_gtk_toggle): + self._gui.set_active(data.release_combination_keys) + + def _on_gtk_toggle(self, *_): + self._controller.update_mapping(release_combination_keys=self._gui.get_active()) + + +class InputConfigEntry(Gtk.ListBoxRow): + """The ListBoxRow representing a single input config inside the CombinationListBox.""" + + __gtype_name__ = "InputConfigEntry" + + def __init__(self, event: InputConfig, controller: Controller): + super().__init__() + + self.input_event = event + self._controller = controller + + hbox = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=4) + hbox.set_margin_start(12) + + label = Gtk.Label() + label.set_label(event.description()) + hbox.pack_start(label, False, False, 0) + + up_btn = Gtk.Button() + up_btn.set_halign(Gtk.Align.END) + up_btn.set_relief(Gtk.ReliefStyle.NONE) + up_btn.get_style_context().add_class("no-v-padding") + up_img = Gtk.Image.new_from_icon_name("go-up", Gtk.IconSize.BUTTON) + up_btn.add(up_img) + + down_btn = Gtk.Button() + down_btn.set_halign(Gtk.Align.END) + down_btn.set_relief(Gtk.ReliefStyle.NONE) + down_btn.get_style_context().add_class("no-v-padding") + down_img = Gtk.Image.new_from_icon_name("go-down", Gtk.IconSize.BUTTON) + down_btn.add(down_img) + + vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) + vbox.pack_start(up_btn, False, True, 0) + vbox.pack_end(down_btn, False, True, 0) + hbox.pack_end(vbox, False, False, 0) + + up_btn.connect( + "clicked", + lambda *_: self._controller.move_input_config_in_combination( + self.input_event, "up" + ), + ) + down_btn.connect( + "clicked", + lambda *_: self._controller.move_input_config_in_combination( + self.input_event, "down" + ), + ) + self.add(hbox) + self.show_all() + + # only used in testing + self._up_btn = up_btn + self._down_btn = down_btn + + +class CombinationListbox: + """The ListBox with all the events inside active_mapping.input_combination.""" + + def __init__( + self, + message_broker: MessageBroker, + controller: Controller, + listbox: Gtk.ListBox, + ): + self._message_broker = message_broker + self._controller = controller + self._gui = listbox + self._combination: Optional[InputCombination] = None + + self._message_broker.subscribe( + MessageType.mapping, + self._on_mapping_changed, + ) + self._message_broker.subscribe( + MessageType.selected_event, + self._on_event_changed, + ) + + self._gui.connect("row-selected", self._on_gtk_row_selected) + + def _select_row(self, event: InputEvent): + for row in self._gui.get_children(): + if row.input_event == event: + self._gui.select_row(row) + + def _on_mapping_changed(self, mapping: MappingData): + if self._combination == mapping.input_combination: + return + + event_entries = self._gui.get_children() + for event_entry in event_entries: + self._gui.remove(event_entry) + + if self._controller.is_empty_mapping(): + self._combination = None + else: + self._combination = mapping.input_combination + for event in self._combination: + self._gui.insert(InputConfigEntry(event, self._controller), -1) + + def _on_event_changed(self, event: InputEvent): + with HandlerDisabled(self._gui, self._on_gtk_row_selected): + self._select_row(event) + + def _on_gtk_row_selected(self, *_): + for row in self._gui.get_children(): + if row.is_selected(): + self._controller.load_input_config(row.input_event) + break + + +class AnalogInputSwitch: + """The switch that marks the active_input_config as analog input.""" + + def __init__( + self, + message_broker: MessageBroker, + controller: Controller, + gui: Gtk.Switch, + ): + self._message_broker = message_broker + self._controller = controller + self._gui = gui + self._input_config: Optional[InputConfig] = None + + self._gui.connect("state-set", self._on_gtk_toggle) + self._message_broker.subscribe(MessageType.selected_event, self._on_event) + + def _on_event(self, input_cfg: InputConfig): + with HandlerDisabled(self._gui, self._on_gtk_toggle): + self._gui.set_active(input_cfg.defines_analog_input) + self._input_config = input_cfg + + if input_cfg.type == EV_KEY: + self._gui.set_sensitive(False) + self._gui.set_opacity(0.5) + else: + self._gui.set_sensitive(True) + self._gui.set_opacity(1) + + def _on_gtk_toggle(self, *_): + self._controller.set_event_as_analog(self._gui.get_active()) + + +class TriggerThresholdInput: + """The number selection used to set the speed or position threshold of the + active_input_config when it is an ABS or REL event used as a key.""" + + def __init__( + self, + message_broker: MessageBroker, + controller: Controller, + gui: Gtk.SpinButton, + ): + self._message_broker = message_broker + self._controller = controller + self._gui = gui + self._input_config: Optional[InputConfig] = None + + self._gui.set_increments(1, 1) + self._gui.connect("value-changed", self._on_gtk_changed) + self._message_broker.subscribe(MessageType.selected_event, self._on_event) + + def _on_event(self, input_config: InputConfig): + if input_config.type == EV_KEY: + self._gui.set_sensitive(False) + self._gui.set_opacity(0.5) + elif input_config.type == EV_ABS: + self._gui.set_sensitive(True) + self._gui.set_opacity(1) + self._gui.set_range(-99, 99) + else: + self._gui.set_sensitive(True) + self._gui.set_opacity(1) + self._gui.set_range(-999, 999) + + with HandlerDisabled(self._gui, self._on_gtk_changed): + self._gui.set_value(input_config.analog_threshold or 0) + self._input_config = input_config + + def _on_gtk_changed(self, *_): + self._controller.update_input_config( + self._input_config.modify(analog_threshold=int(self._gui.get_value())) + ) + + +class ReleaseTimeoutInput: + """The number selector used to set the active_mapping.release_timeout parameter.""" + + def __init__( + self, + message_broker: MessageBroker, + controller: Controller, + gui: Gtk.SpinButton, + ): + self._message_broker = message_broker + self._controller = controller + self._gui = gui + + self._gui.set_increments(0.01, 0.01) + self._gui.set_range(0, 2) + self._gui.connect("value-changed", self._on_gtk_changed) + self._message_broker.subscribe(MessageType.mapping, self._on_mapping_message) + + def _on_mapping_message(self, mapping: MappingData): + if EV_REL in [event.type for event in mapping.input_combination]: + self._gui.set_sensitive(True) + self._gui.set_opacity(1) + else: + self._gui.set_sensitive(False) + self._gui.set_opacity(0.5) + + with HandlerDisabled(self._gui, self._on_gtk_changed): + self._gui.set_value(mapping.release_timeout) + + def _on_gtk_changed(self, *_): + self._controller.update_mapping(release_timeout=self._gui.get_value()) + + +class RelativeInputCutoffInput: + """The number selector to set active_mapping.rel_to_abs_input_cutoff.""" + + def __init__( + self, + message_broker: MessageBroker, + controller: Controller, + gui: Gtk.SpinButton, + ): + self._message_broker = message_broker + self._controller = controller + self._gui = gui + + self._gui.set_increments(1, 1) + self._gui.set_range(1, 1000) + self._gui.connect("value-changed", self._on_gtk_changed) + self._message_broker.subscribe(MessageType.mapping, self._on_mapping_message) + + def _on_mapping_message(self, mapping: MappingData): + if ( + EV_REL in [event.type for event in mapping.input_combination] + and mapping.output_type == EV_ABS + ): + self._gui.set_sensitive(True) + self._gui.set_opacity(1) + else: + self._gui.set_sensitive(False) + self._gui.set_opacity(0.5) + + with HandlerDisabled(self._gui, self._on_gtk_changed): + self._gui.set_value(mapping.rel_to_abs_input_cutoff) + + def _on_gtk_changed(self, *_): + self._controller.update_mapping(rel_xy_cutoff=self._gui.get_value()) + + +class OutputAxisSelector: + """The dropdown menu used to select the output axis if the active_mapping is a + mapping targeting an analog axis + + modifies the active_mapping.output_code and active_mapping.output_type parameters + """ + + def __init__( + self, + message_broker: MessageBroker, + controller: Controller, + gui: Gtk.ComboBox, + ): + self._message_broker = message_broker + self._controller = controller + self._gui = gui + self._uinputs: Dict[str, Capabilities] = {} + self.model = Gtk.ListStore(str, str) + + self._current_target: Optional[str] = None + + self._gui.set_model(self.model) + renderer_text = Gtk.CellRendererText() + self._gui.pack_start(renderer_text, False) + self._gui.add_attribute(renderer_text, "text", 1) + self._gui.set_id_column(0) + + self._gui.connect("changed", self._on_gtk_select_axis) + self._message_broker.subscribe(MessageType.mapping, self._on_mapping_message) + self._message_broker.subscribe(MessageType.uinputs, self._on_uinputs_message) + + def _set_model(self, target: Optional[str]): + if target == self._current_target: + return + + self.model.clear() + self.model.append(["None, None", _("No Axis")]) + + if target is not None: + capabilities = self._uinputs.get(target) or defaultdict(list) + types_codes = [ + (EV_ABS, code) for code, absinfo in capabilities.get(EV_ABS) or () + ] + types_codes.extend( + (EV_REL, code) for code in capabilities.get(EV_REL) or () + ) + for type_, code in types_codes: + key_name = get_evdev_constant_name(type_, code) + if isinstance(key_name, list): + key_name = key_name[0] + self.model.append([f"{type_}, {code}", key_name]) + + self._current_target = target + + def _on_mapping_message(self, mapping: MappingData): + with HandlerDisabled(self._gui, self._on_gtk_select_axis): + self._set_model(mapping.target_uinput) + self._gui.set_active_id(f"{mapping.output_type}, {mapping.output_code}") + + def _on_uinputs_message(self, uinputs: UInputsData): + self._uinputs = uinputs.uinputs + + def _on_gtk_select_axis(self, *_): + if self._gui.get_active_id() == "None, None": + type_code = (None, None) + else: + type_code = tuple(int(i) for i in self._gui.get_active_id().split(",")) + self._controller.update_mapping( + output_type=type_code[0], output_code=type_code[1] + ) + + +class KeyAxisStackSwitcher: + """The controls used to switch between the gui to modify a key-mapping or + an analog-axis mapping.""" + + def __init__( + self, + message_broker: MessageBroker, + controller: Controller, + stack: Gtk.Stack, + key_macro_toggle: Gtk.ToggleButton, + analog_toggle: Gtk.ToggleButton, + ): + self._message_broker = message_broker + self._controller = controller + self._stack = stack + self._key_macro_toggle = key_macro_toggle + self._analog_toggle = analog_toggle + + self._key_macro_toggle.connect("toggled", self._on_gtk_toggle) + self._analog_toggle.connect("toggled", self._on_gtk_toggle) + self._message_broker.subscribe(MessageType.mapping, self._on_mapping_message) + + def _set_active(self, mapping_type: Literal["key_macro", "analog"]): + if mapping_type == MappingType.ANALOG.value: + self._stack.set_visible_child_name(OutputTypeNames.analog_axis) + active = self._analog_toggle + inactive = self._key_macro_toggle + else: + self._stack.set_visible_child_name(OutputTypeNames.key_or_macro) + active = self._key_macro_toggle + inactive = self._analog_toggle + + with HandlerDisabled(active, self._on_gtk_toggle): + active.set_active(True) + with HandlerDisabled(inactive, self._on_gtk_toggle): + inactive.set_active(False) + + def _on_mapping_message(self, mapping: MappingData): + # fist check the actual mapping + if mapping.mapping_type == MappingType.ANALOG.value: + self._set_active(MappingType.ANALOG.value) + + if mapping.mapping_type == MappingType.KEY_MACRO.value: + self._set_active(MappingType.KEY_MACRO.value) + + def _on_gtk_toggle(self, btn: Gtk.ToggleButton): + # get_active returns the new toggle state already + was_active = not btn.get_active() + + if was_active: + # cannot deactivate manually + with HandlerDisabled(btn, self._on_gtk_toggle): + btn.set_active(True) + return + + if btn is self._key_macro_toggle: + self._controller.update_mapping(mapping_type=MappingType.KEY_MACRO.value) + else: + self._controller.update_mapping(mapping_type=MappingType.ANALOG.value) + + +class TransformationDrawArea: + """The graph which shows the relation between input- and output-axis.""" + + def __init__( + self, + message_broker: MessageBroker, + controller: Controller, + gui: Gtk.DrawingArea, + ): + self._message_broker = message_broker + self._controller = controller + self._gui = gui + + self._transformation: Callable[[Union[float, int]], float] = lambda x: x + + self._gui.connect("draw", self._on_gtk_draw) + self._message_broker.subscribe(MessageType.mapping, self._on_mapping_message) + + def _on_mapping_message(self, mapping: MappingData): + self._transformation = Transformation( + 100, -100, mapping.deadzone, mapping.gain, mapping.expo + ) + self._gui.queue_draw() + + def _on_gtk_draw(self, _, context: cairo.Context): + points = [ + (x / 200 + 0.5, -0.5 * self._transformation(x) + 0.5) + # leave some space left and right for the lineCap to be visible + for x in range(-97, 97) + ] + width = self._gui.get_allocated_width() + height = self._gui.get_allocated_height() + b = min((width, height)) + scaled_points = [(x * b, y * b) for x, y in points] + + # x arrow + context.move_to(0 * b, 0.5 * b) + context.line_to(1 * b, 0.5 * b) + context.line_to(0.96 * b, 0.52 * b) + context.move_to(1 * b, 0.5 * b) + context.line_to(0.96 * b, 0.48 * b) + + # y arrow + context.move_to(0.5 * b, 1 * b) + context.line_to(0.5 * b, 0) + context.line_to(0.48 * b, 0.04 * b) + context.move_to(0.5 * b, 0) + context.line_to(0.52 * b, 0.04 * b) + + context.set_line_width(2) + arrow_color = Gdk.RGBA(0.5, 0.5, 0.5, 0.2) + context.set_source_rgba( + arrow_color.red, + arrow_color.green, + arrow_color.blue, + arrow_color.alpha, + ) + context.stroke() + + # graph + context.move_to(*scaled_points[0]) + for scaled_point in scaled_points[1:]: + # Ploting point + context.line_to(*scaled_point) + + line_color = Colors.get_accent_color() + context.set_line_width(3) + context.set_line_cap(cairo.LineCap.ROUND) + # the default gtk adwaita highlight color: + context.set_source_rgba( + line_color.red, + line_color.green, + line_color.blue, + line_color.alpha, + ) + context.stroke() + + +class Sliders: + """The different sliders to modify the gain, deadzone and expo parameters of the + active_mapping.""" + + def __init__( + self, + message_broker: MessageBroker, + controller: Controller, + gain: Gtk.Range, + deadzone: Gtk.Range, + expo: Gtk.Range, + ): + self._message_broker = message_broker + self._controller = controller + self._gain = gain + self._deadzone = deadzone + self._expo = expo + + self._gain.set_range(-2, 2) + self._deadzone.set_range(0, 0.9) + self._expo.set_range(-1, 1) + + self._gain.connect("value-changed", self._on_gtk_gain_changed) + self._expo.connect("value-changed", self._on_gtk_expo_changed) + self._deadzone.connect("value-changed", self._on_gtk_deadzone_changed) + self._message_broker.subscribe(MessageType.mapping, self._on_mapping_message) + + def _on_mapping_message(self, mapping: MappingData): + with HandlerDisabled(self._gain, self._on_gtk_gain_changed): + self._gain.set_value(mapping.gain) + + with HandlerDisabled(self._expo, self._on_gtk_expo_changed): + self._expo.set_value(mapping.expo) + + with HandlerDisabled(self._deadzone, self._on_gtk_deadzone_changed): + self._deadzone.set_value(mapping.deadzone) + + def _on_gtk_gain_changed(self, *_): + self._controller.update_mapping(gain=self._gain.get_value()) + + def _on_gtk_deadzone_changed(self, *_): + self._controller.update_mapping(deadzone=self._deadzone.get_value()) + + def _on_gtk_expo_changed(self, *_): + self._controller.update_mapping(expo=self._expo.get_value()) diff --git a/inputremapper/gui/components/main.py b/inputremapper/gui/components/main.py new file mode 100644 index 0000000..3ada3d8 --- /dev/null +++ b/inputremapper/gui/components/main.py @@ -0,0 +1,134 @@ +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + + +"""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) diff --git a/inputremapper/gui/components/output_type_names.py b/inputremapper/gui/components/output_type_names.py new file mode 100644 index 0000000..1407692 --- /dev/null +++ b/inputremapper/gui/components/output_type_names.py @@ -0,0 +1,3 @@ +class OutputTypeNames: + analog_axis = "Analog Axis" + key_or_macro = "Key or Macro" diff --git a/inputremapper/gui/components/presets.py b/inputremapper/gui/components/presets.py new file mode 100644 index 0000000..b0d6146 --- /dev/null +++ b/inputremapper/gui/components/presets.py @@ -0,0 +1,106 @@ +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + + +"""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) diff --git a/inputremapper/gui/controller.py b/inputremapper/gui/controller.py new file mode 100644 index 0000000..93e051d --- /dev/null +++ b/inputremapper/gui/controller.py @@ -0,0 +1,876 @@ +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + +from __future__ import annotations # needed for the TYPE_CHECKING import + +import re +from functools import partial +from typing import ( + TYPE_CHECKING, + Optional, + Union, + Literal, + Sequence, + Dict, + Callable, + List, + Any, + Tuple, +) + +from evdev.ecodes import EV_KEY, EV_REL, EV_ABS +from gi.repository import Gtk + +from inputremapper.configs.input_config import InputCombination, InputConfig +from inputremapper.configs.mapping import ( + MappingData, + UIMapping, + MappingType, +) +from inputremapper.configs.paths import PathUtils +from inputremapper.configs.validation_errors import ( + pydantify, + MissingMacroOrKeyError, + MacroButTypeOrCodeSetError, + SymbolAndCodeMismatchError, + MissingOutputAxisError, + WrongMappingTypeForKeyError, + OutputSymbolVariantError, +) +from inputremapper.exceptions import DataManagementError +from inputremapper.gui.components.output_type_names import OutputTypeNames +from inputremapper.gui.data_manager import DataManager, DEFAULT_PRESET_NAME +from inputremapper.gui.gettext import _ +from inputremapper.gui.messages.message_broker import ( + MessageBroker, + MessageType, +) +from inputremapper.gui.messages.message_data import ( + PresetData, + StatusData, + CombinationRecorded, + UserConfirmRequest, + DoStackSwitch, +) +from inputremapper.gui.utils import CTX_APPLY, CTX_ERROR, CTX_WARNING, CTX_MAPPING +from inputremapper.injection.injector import ( + InjectorState, + InjectorStateMessage, +) +from inputremapper.logging.logger import logger + +if TYPE_CHECKING: + # avoids gtk import error in tests + from inputremapper.gui.user_interface import UserInterface + + +MAPPING_DEFAULTS = {"target_uinput": "keyboard"} + + +class Controller: + """Implements the behaviour of the gui.""" + + def __init__( + self, + message_broker: MessageBroker, + data_manager: DataManager, + ) -> None: + self.message_broker = message_broker + self.data_manager = data_manager + self.gui: Optional[UserInterface] = None + + self.button_left_warn = False + self._attach_to_events() + + def set_gui(self, gui: UserInterface): + """Let the Controller know about the user interface singleton..""" + self.gui = gui + + def _attach_to_events(self) -> None: + self.message_broker.subscribe(MessageType.groups, self._on_groups_changed) + self.message_broker.subscribe(MessageType.preset, self._on_preset_changed) + self.message_broker.subscribe(MessageType.init, self._on_init) + self.message_broker.subscribe( + MessageType.preset, self._publish_mapping_errors_as_status_msg + ) + self.message_broker.subscribe( + MessageType.mapping, self._publish_mapping_errors_as_status_msg + ) + + def _on_init(self, __): + """Initialize the gui and the data_manager.""" + # make sure we get a groups_changed event when everything is ready + # this might not be necessary if the reader-service takes longer to provide the + # initial groups + self.data_manager.publish_groups() + self.data_manager.publish_uinputs() + + def _on_groups_changed(self, _): + """Load the newest group as soon as everyone got notified + about the updated groups.""" + + if self.data_manager.active_group is not None: + # don't jump to a different group and preset suddenly, if the user + # is already looking at one + logger.debug("A group is already active") + return + + group_key = self.get_a_group() + if group_key is None: + logger.debug("Could not find a group") + return + + self.load_group(group_key) + + def _on_preset_changed(self, data: PresetData): + """Load a mapping as soon as everyone got notified about the new preset.""" + if data.mappings: + mappings = list(data.mappings) + mappings.sort( + key=lambda mapping: ( + mapping.format_name() or mapping.input_combination.beautify() + ) + ) + combination = mappings[0].input_combination + self.load_mapping(combination) + self.load_input_config(combination[0]) + else: + # send an empty mapping to make sure the ui is reset to default values + self.message_broker.publish(MappingData(**MAPPING_DEFAULTS)) + + def _on_combination_recorded(self, data: CombinationRecorded): + combination = self._auto_use_as_analog(data.combination) + self.update_combination(combination) + + def _format_status_bar_validation_errors(self) -> Optional[Tuple[str, str]]: + if not self.data_manager.active_preset: + return None + + if self.data_manager.active_preset.is_valid(): + self.message_broker.publish(StatusData(CTX_MAPPING)) + return None + + mappings = list(self.data_manager.active_preset) + + # Move the selected (active) mapping to the front, so that it is checked first. + active_mapping = self.data_manager.active_mapping + if active_mapping is not None: + mappings.remove(active_mapping) + mappings.insert(0, active_mapping) + + for mapping in mappings: + if not mapping.has_input_defined(): + # Empty mapping, nothing recorded yet so nothing can be configured, + # therefore there isn't anything to validate. + continue + + position = mapping.format_name() + error_strings = self._get_ui_error_strings(mapping) + + if len(error_strings) == 0: + continue + + if len(error_strings) > 1: + msg = _('%d Mapping errors at "%s", hover for info') % ( + len(error_strings), + position, + ) + tooltip = "– " + "\n– ".join(error_strings) + else: + msg = f'"{position}": {error_strings[0]}' + tooltip = error_strings[0] + + return msg.replace("\n", " "), tooltip + + return None + + def _publish_mapping_errors_as_status_msg(self, *__) -> None: + """Send mapping ValidationErrors to the MessageBroker.""" + validation_result = self._format_status_bar_validation_errors() + + if validation_result is None: + return + + self.show_status( + CTX_MAPPING, + validation_result[0], + validation_result[1], + ) + + @staticmethod + def format_error_message(mapping, error_type, error_message: str) -> str: + """Check all the different error messages which are not useful for the user.""" + # There is no more elegant way of comparing error_type with the base class. + # https://github.com/pydantic/pydantic/discussions/5112 + if ( + pydantify(MacroButTypeOrCodeSetError) in error_type + or pydantify(SymbolAndCodeMismatchError) in error_type + ) and mapping.input_combination.defines_analog_input: + return _( + "Remove the macro or key from the macro input field " + "when specifying an analog output" + ) + + if ( + pydantify(MacroButTypeOrCodeSetError) in error_type + or pydantify(SymbolAndCodeMismatchError) in error_type + ) and not mapping.input_combination.defines_analog_input: + return _( + "Remove the Analog Output Axis when specifying a macro or key output" + ) + + if pydantify(MissingOutputAxisError) in error_type: + error_message = _( + "The input specifies an analog axis, but no output axis is selected." + ) + if mapping.output_symbol is not None: + event = [ + event + for event in mapping.input_combination + if event.defines_analog_input + ][0] + error_message += _( + "\nIf you mean to create a key or macro mapping " + "go to the advanced input configuration" + ' and set a "Trigger Threshold" for ' + f'"{event.description()}"' + ) + return error_message + + if pydantify(WrongMappingTypeForKeyError) in error_type: + error_message = _( + "The input specifies a key, but the output type is not " + f'"{OutputTypeNames.key_or_macro}".' + ) + + if mapping.output_type in (EV_ABS, EV_REL): + error_message += _( + "\nIf you mean to create an analog axis mapping go to the " + 'advanced input configuration and set an input to "Use as Analog".' + ) + + return error_message + + if pydantify(MissingMacroOrKeyError) in error_type: + return _("Missing macro or key") + + return error_message + + @staticmethod + def _get_ui_error_strings(mapping: UIMapping) -> List[str]: + """Get a human readable error message from a mapping error.""" + validation_error = mapping.get_error() + + if validation_error is None: + return [] + + formatted_errors = [] + + for error in validation_error.errors(): + if pydantify(OutputSymbolVariantError) in error["type"]: + # this is rather internal, when this error appears in the gui, there is + # also always another more readable error at the same time that explains + # this problem. + continue + + error_string = f'"{mapping.format_name()}": ' + error_message = error["msg"] + error_location = error["loc"][0] + if error_location != "__root__": + error_string += f"{error_location}: " + + # check all the different error messages which are not useful for the user + formatted_errors.append( + Controller.format_error_message( + mapping, + error["type"], + error_message, + ) + ) + + return formatted_errors + + def get_a_preset(self) -> str: + """Attempts to get the newest preset in the current group + creates a new preset if that fails.""" + try: + return self.data_manager.get_newest_preset_name() + except FileNotFoundError: + pass + self.data_manager.create_preset(self.data_manager.get_available_preset_name()) + return self.data_manager.get_newest_preset_name() + + def get_a_group(self) -> Optional[str]: + """Attempts to get the group with the newest preset + returns any if that fails.""" + try: + return self.data_manager.get_newest_group_key() + except FileNotFoundError: + pass + + keys = self.data_manager.get_group_keys() + return keys[0] if keys else None + + def copy_preset(self): + """Create a copy of the active preset and name it `preset_name copy`.""" + name = self.data_manager.active_preset.name + match = re.search(r" copy *\d*$", name) + if match: + name = name[: match.start()] + + self.data_manager.copy_preset( + self.data_manager.get_available_preset_name(f"{name} copy") + ) + self.message_broker.publish(DoStackSwitch(1)) + + def _auto_use_as_analog(self, combination: InputCombination) -> InputCombination: + """If output is analog, set the first fitting input to analog.""" + if self.data_manager.active_mapping is None: + return combination + + if not self.data_manager.active_mapping.is_analog_output(): + return combination + + if combination.find_analog_input_config(): + # something is already set to do that + return combination + + for i, input_config in enumerate(combination): + # find the first analog input and set it to "use as analog" + if input_config.type in (EV_ABS, EV_REL): + logger.info("Using %s as analog input", input_config) + + # combinations and input_configs are immutable, a new combination + # is created to fit the needs instead + combination_list = list(combination) + combination_list[i] = input_config.modify(analog_threshold=0) + new_combination = InputCombination(combination_list) + return new_combination + + return combination + + def update_combination(self, combination: InputCombination): + """Update the input_combination of the active mapping.""" + combination = self._auto_use_as_analog(combination) + + try: + self.data_manager.update_mapping(input_combination=combination) + self.save() + except KeyError: + self.show_status( + CTX_MAPPING, + f'"{combination.beautify()}" already mapped to something else', + ) + return + + if combination.is_problematic(): + self.show_status( + CTX_WARNING, + _("ctrl, alt and shift may not combine properly"), + _( + "Your system might reinterpret combinations with those after they " + + "are injected, and by doing so break them. Play around with the " + + 'advanced "Release Input" toggle.' + ), + ) + + def move_input_config_in_combination( + self, + input_config: InputConfig, + direction: Union[Literal["up"], Literal["down"]], + ): + """Move the active_input_config up or down in the input_combination of the + active_mapping.""" + if ( + not self.data_manager.active_mapping + or len(self.data_manager.active_mapping.input_combination) == 1 + ): + return + combination: Sequence[InputConfig] = ( + self.data_manager.active_mapping.input_combination + ) + + i = combination.index(input_config) + if ( + i + 1 == len(combination) + and direction == "down" + or i == 0 + and direction == "up" + ): + return + + if direction == "up": + combination = ( + list(combination[: i - 1]) + + [input_config] + + [combination[i - 1]] + + list(combination[i + 1 :]) + ) + elif direction == "down": + combination = ( + list(combination[:i]) + + [combination[i + 1]] + + [input_config] + + list(combination[i + 2 :]) + ) + else: + raise ValueError(f"unknown direction: {direction}") + self.update_combination(InputCombination(combination)) + self.load_input_config(input_config) + + def load_input_config(self, input_config: InputConfig): + """Load an InputConfig form the active mapping input combination.""" + self.data_manager.load_input_config(input_config) + + def update_input_config(self, new_input_config: InputConfig): + """Modify the active input configuration.""" + try: + self.data_manager.update_input_config(new_input_config) + except KeyError: + # we need to synchronize the gui + self.data_manager.publish_mapping() + self.data_manager.publish_event() + + def remove_event(self): + """Remove the active InputEvent from the active mapping event combination.""" + if ( + not self.data_manager.active_mapping + or not self.data_manager.active_input_config + ): + return + + combination = list(self.data_manager.active_mapping.input_combination) + combination.remove(self.data_manager.active_input_config) + try: + self.data_manager.update_mapping( + input_combination=InputCombination(combination) + ) + self.load_input_config(combination[0]) + self.save() + except (KeyError, ValueError): + # we need to synchronize the gui + self.data_manager.publish_mapping() + self.data_manager.publish_event() + + def set_event_as_analog(self, analog: bool): + """Use the active event as an analog input.""" + assert self.data_manager.active_input_config is not None + event = self.data_manager.active_input_config + + if event.type != EV_KEY: + if analog: + try: + self.data_manager.update_input_config( + event.modify(analog_threshold=0) + ) + self.save() + return + except KeyError: + pass + else: + try_values = {EV_REL: [1, -1], EV_ABS: [10, -10]} + for value in try_values[event.type]: + try: + self.data_manager.update_input_config( + event.modify(analog_threshold=value) + ) + self.save() + return + except KeyError: + pass + + # didn't update successfully + # we need to synchronize the gui + self.data_manager.publish_mapping() + self.data_manager.publish_event() + + def load_groups(self): + """Refresh the groups.""" + self.data_manager.refresh_groups() + + def load_group(self, group_key: str): + """Load the group and then a preset of that group.""" + self.data_manager.load_group(group_key) + self.load_preset(self.get_a_preset()) + + def load_preset(self, name: str): + """Load the preset.""" + self.data_manager.load_preset(name) + # self.load_mapping(...) # not needed because we have on_preset_changed() + + def rename_preset(self, new_name: str): + """Rename the active_preset.""" + if ( + not self.data_manager.active_preset + or not new_name + or new_name == self.data_manager.active_preset.name + ): + return + + new_name = PathUtils.sanitize_path_component(new_name) + new_name = self.data_manager.get_available_preset_name(new_name) + self.data_manager.rename_preset(new_name) + + def add_preset(self, name: str = DEFAULT_PRESET_NAME): + """Create a new preset called `new preset n`, add it to the active_group.""" + name = self.data_manager.get_available_preset_name(name) + try: + self.data_manager.create_preset(name) + self.data_manager.load_preset(name) + except PermissionError as e: + self.show_status(CTX_ERROR, _("Permission denied!"), str(e)) + + def delete_preset(self): + """Delete the active_preset from the disc.""" + + def f(answer: bool): + if answer: + self.data_manager.delete_preset() + self.data_manager.load_preset(self.get_a_preset()) + self.message_broker.publish(DoStackSwitch(1)) + + if not self.data_manager.active_preset: + return + msg = ( + _('Are you sure you want to delete the preset "%s"?') + % self.data_manager.active_preset.name + ) + self.message_broker.publish(UserConfirmRequest(msg, f)) + + def load_mapping(self, input_combination: InputCombination): + """Load the mapping with the given input_combination form the active_preset.""" + self.data_manager.load_mapping(input_combination) + self.load_input_config(input_combination[0]) + + def update_mapping(self, **changes): + """Update the active_mapping with the given keywords and values.""" + if "mapping_type" in changes.keys(): + if not (changes := self._change_mapping_type(changes)): + # we need to synchronize the gui + self.data_manager.publish_mapping() + self.data_manager.publish_event() + return + + self.data_manager.update_mapping(**changes) + self.save() + + def create_mapping(self): + """Create a new empty mapping in the active_preset.""" + try: + self.data_manager.create_mapping() + except KeyError: + # there is already an empty mapping + return + + self.data_manager.load_mapping(combination=InputCombination.empty_combination()) + self.data_manager.update_mapping(**MAPPING_DEFAULTS) + + def delete_mapping(self): + """Remove the active_mapping form the active_preset.""" + + def get_answer(answer: bool): + if answer: + self.data_manager.delete_mapping() + self.save() + + if not self.data_manager.active_mapping: + return + self.message_broker.publish( + UserConfirmRequest( + _("Are you sure you want to delete this mapping?"), + get_answer, + ) + ) + + def set_autoload(self, autoload: bool): + """Set the autoload state for the active_preset and active_group.""" + self.data_manager.set_autoload(autoload) + self.data_manager.refresh_service_config_path() + + def save(self): + """Save all data to the disc.""" + try: + self.data_manager.save() + except PermissionError as e: + self.show_status(CTX_ERROR, _("Permission denied!"), str(e)) + + def start_key_recording(self): + """Record the input of the active_group + + Updates the active_mapping.input_combination with the recorded events. + """ + state = self.data_manager.get_state() + if state == InjectorState.RUNNING or state == InjectorState.STARTING: + self.data_manager.stop_combination_recording() + self.message_broker.signal(MessageType.recording_finished) + self.show_status(CTX_ERROR, _('Use "Stop" to stop before editing')) + return + + logger.debug("Recording Keys") + + def on_recording_finished(_): + self.message_broker.unsubscribe(on_recording_finished) + self.message_broker.unsubscribe(self._on_combination_recorded) + self.gui.connect_shortcuts() + + self.gui.disconnect_shortcuts() + self.message_broker.subscribe( + MessageType.combination_recorded, + self._on_combination_recorded, + ) + self.message_broker.subscribe( + MessageType.recording_finished, on_recording_finished + ) + self.data_manager.start_combination_recording() + + def stop_key_recording(self): + """Stop recording the input.""" + logger.debug("Stopping Recording Keys") + self.data_manager.stop_combination_recording() + + def start_injecting(self): + """Inject the active_preset for the active_group.""" + if len(self.data_manager.active_preset) == 0: + logger.error(_("Cannot apply empty preset file")) + # also helpful for first time use + self.show_status(CTX_ERROR, _("You need to add mappings first")) + return + + if not self.button_left_warn: + if self.data_manager.active_preset.dangerously_mapped_btn_left(): + self.show_status( + CTX_ERROR, + "This would disable your click button", + "Map a button to BTN_LEFT to avoid this.\n" + "To overwrite this warning, press apply again.", + ) + self.button_left_warn = True + return + + # todo: warn about unreleased keys + self.button_left_warn = False + self.message_broker.subscribe( + MessageType.injector_state, + self.show_injector_result, + ) + self.show_status(CTX_APPLY, _("Starting injection...")) + if not self.data_manager.start_injecting(): + self.message_broker.unsubscribe(self.show_injector_result) + self.show_status( + CTX_APPLY, + _('Failed to apply preset "%s"') % self.data_manager.active_preset.name, + ) + + def show_injector_result(self, msg: InjectorStateMessage) -> None: + """Show if the injection was successfully started.""" + self.message_broker.unsubscribe(self.show_injector_result) + state = msg.state + + def running() -> None: + assert self.data_manager.active_preset is not None + msg = _('Applied preset "%s"') % self.data_manager.active_preset.name + if self.data_manager.active_preset.dangerously_mapped_btn_left(): + msg += _(", CTRL + DEL to stop") + self.show_status(CTX_APPLY, msg) + logger.info( + 'Group "%s" is currently mapped', self.data_manager.active_group.key + ) + + def no_grab() -> None: + assert self.data_manager.active_preset is not None + msg = ( + _('Failed to apply preset "%s"') % self.data_manager.active_preset.name + ) + tooltip = ( + "Maybe your preset doesn't contain anything that is sent by the " + "device or another device is already grabbing it" + ) + + # InjectorState.NO_GRAB also happens when all mappings have validation + # errors. In that case, we can show something more useful. + validation_result = self._format_status_bar_validation_errors() + if validation_result is not None: + msg = f"{msg}. {validation_result[0]}" + tooltip = validation_result[1] + + self.show_status(CTX_ERROR, msg, tooltip) + + assert self.data_manager.active_preset # make mypy happy + state_calls: Dict[InjectorState, Callable] = { + InjectorState.RUNNING: running, + InjectorState.ERROR: partial( + self.show_status, + CTX_ERROR, + _('Error applying preset "%s"') % self.data_manager.active_preset.name, + ), + InjectorState.NO_GRAB: no_grab, + InjectorState.UPGRADE_EVDEV: partial( + self.show_status, + CTX_ERROR, + "Upgrade python-evdev", + "Your python-evdev version is too old.", + ), + } + + if state in state_calls: + state_calls[state]() + + def stop_injecting(self): + """Stop injecting any preset for the active_group.""" + + def show_result(msg: InjectorStateMessage): + self.message_broker.unsubscribe(show_result) + + if not msg.inactive(): + # some speculation: there might be unexpected additional status messages + # with a different state, or the status is wrong because something in + # the long pipeline of status messages is broken. + logger.error( + "Expected the injection to eventually stop, but got state %s", + msg.state, + ) + return + + self.show_status(CTX_APPLY, _("Stopped the injection")) + + try: + self.message_broker.subscribe(MessageType.injector_state, show_result) + self.data_manager.stop_injecting() + except DataManagementError: + self.message_broker.unsubscribe(show_result) + + def show_status( + self, + ctx_id: int, + msg: Optional[str] = None, + tooltip: Optional[str] = None, + ): + """Send a status message to the ui to show it in the status-bar.""" + self.message_broker.publish(StatusData(ctx_id, msg, tooltip)) + + def is_empty_mapping(self) -> bool: + """Check if the active_mapping is empty.""" + return ( + self.data_manager.active_mapping == UIMapping(**MAPPING_DEFAULTS) + or self.data_manager.active_mapping is None + ) + + def refresh_groups(self): + """Reload the connected devices and send them as a groups message. + + Runs asynchronously. + """ + self.data_manager.refresh_groups() + + def close(self): + """Safely close the application.""" + logger.debug("Closing Application") + self.save() + self.message_broker.signal(MessageType.terminate) + logger.debug("Quitting") + Gtk.main_quit() + + def set_focus(self, component): + """Focus the given component.""" + self.gui.window.set_focus(component) + + def _change_mapping_type(self, changes: Dict[str, Any]): + """Query the user to update the mapping in order to change the mapping type.""" + mapping = self.data_manager.active_mapping + + if mapping is None: + return changes + + if changes["mapping_type"] == mapping.mapping_type: + return changes + + if changes["mapping_type"] == MappingType.ANALOG.value: + msg = _("You are about to change the mapping to analog.") + if mapping.output_symbol: + msg += _('\nThis will remove "{}" ' "from the text input!").format( + mapping.output_symbol + ) + + if not [ + input_config + for input_config in mapping.input_combination + if input_config.defines_analog_input + ]: + # there is no analog input configured, let's try to autoconfigure it + inputs: List[InputConfig] = list(mapping.input_combination) + for i, input_config in enumerate(inputs): + if input_config.type in [EV_ABS, EV_REL]: + inputs[i] = input_config.modify(analog_threshold=0) + changes["input_combination"] = InputCombination(inputs) + msg += _( + '\nThe input "{}" will be used as analog input.' + ).format(input_config.description()) + break + else: + # not possible to autoconfigure inform the user + msg += _("\nYou need to record an analog input.") + + elif not mapping.output_symbol: + return changes + + answer = None + + def get_answer(answer_: bool): + nonlocal answer + answer = answer_ + + self.message_broker.publish(UserConfirmRequest(msg, get_answer)) + if answer: + changes["output_symbol"] = None + return changes + else: + return None + + if changes["mapping_type"] == MappingType.KEY_MACRO.value: + try: + analog_input = tuple( + filter(lambda i: i.defines_analog_input, mapping.input_combination) + )[0] + except IndexError: + changes["output_type"] = None + changes["output_code"] = None + return changes + + answer = None + + def get_answer(answer_: bool): + nonlocal answer + answer = answer_ + + self.message_broker.publish( + UserConfirmRequest( + f"You are about to change the mapping to a Key or Macro mapping!\n" + f"Go to the advanced input configuration and set a " + f'"Trigger Threshold" for "{analog_input.description()}".', + get_answer, + ) + ) + if answer: + changes["output_type"] = None + changes["output_code"] = None + return changes + else: + return None + + return changes diff --git a/inputremapper/gui/data_manager.py b/inputremapper/gui/data_manager.py new file mode 100644 index 0000000..b077928 --- /dev/null +++ b/inputremapper/gui/data_manager.py @@ -0,0 +1,609 @@ +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + +import glob +import os +import re +import time +from typing import Optional, List, Tuple, Set + +from gi.repository import GLib + +from inputremapper.configs.global_config import GlobalConfig +from inputremapper.configs.input_config import InputCombination, InputConfig +from inputremapper.configs.mapping import UIMapping, MappingData +from inputremapper.configs.paths import PathUtils +from inputremapper.configs.preset import Preset +from inputremapper.configs.keyboard_layout import KeyboardLayout +from inputremapper.daemon import DaemonProxy +from inputremapper.exceptions import DataManagementError +from inputremapper.groups import _Group +from inputremapper.gui.gettext import _ +from inputremapper.gui.messages.message_broker import ( + MessageBroker, +) +from inputremapper.gui.messages.message_data import ( + UInputsData, + GroupData, + PresetData, + CombinationUpdate, +) +from inputremapper.gui.reader_client import ReaderClient +from inputremapper.injection.global_uinputs import GlobalUInputs +from inputremapper.injection.injector import ( + InjectorState, + InjectorStateMessage, +) +from inputremapper.logging.logger import logger + +DEFAULT_PRESET_NAME = _("new preset") + +# useful type aliases +Name = str +GroupKey = str + + +class DataManager: + """DataManager provides an interface to create and modify configurations as well + as modify the state of the Service. + + Any state changes will be announced via the MessageBroker. + """ + + def __init__( + self, + message_broker: MessageBroker, + config: GlobalConfig, + reader_client: ReaderClient, + daemon: DaemonProxy, + uinputs: GlobalUInputs, + keyboard_layout: KeyboardLayout, + ): + self.message_broker = message_broker + self._reader_client = reader_client + self._daemon = daemon + self._uinputs = uinputs + self._keyboard_layout = keyboard_layout + uinputs.prepare_all() + + self._config = config + self._config.load_config() + + self._active_preset: Optional[Preset[UIMapping]] = None + self._active_mapping: Optional[UIMapping] = None + self._active_input_config: Optional[InputConfig] = None + + def publish_group(self): + """Send active group to the MessageBroker. + + This is internally called whenever the group changes. + It is usually not necessary to call this explicitly from + outside DataManager. + """ + self.message_broker.publish( + GroupData(self.active_group.key, self.get_preset_names()) + ) + + def publish_preset(self): + """Send active preset to the MessageBroker. + + This is internally called whenever the preset changes. + It is usually not necessary to call this explicitly from + outside DataManager. + """ + self.message_broker.publish( + PresetData( + self.active_preset.name, self.get_mappings(), self.get_autoload() + ) + ) + + def publish_mapping(self): + """Send active mapping to the MessageBroker + + This is internally called whenever the mapping changes. + It is usually not necessary to call this explicitly from + outside DataManager. + """ + if self.active_mapping: + self.message_broker.publish(self.active_mapping.get_bus_message()) + + def publish_event(self): + """Send active event to the MessageBroker. + + This is internally called whenever the event changes. + It is usually not necessary to call this explicitly from + outside DataManager + """ + if self.active_input_config: + assert self.active_input_config in self.active_mapping.input_combination + self.message_broker.publish(self.active_input_config) + + def publish_uinputs(self): + """Send the "uinputs" message on the MessageBroker.""" + self.message_broker.publish( + UInputsData( + { + name: uinput.capabilities() + for name, uinput in self._uinputs.devices.items() + } + ) + ) + + def publish_groups(self): + """Publish the "groups" message on the MessageBroker.""" + self._reader_client.publish_groups() + + def publish_injector_state(self): + """Publish the "injector_state" message for the active_group.""" + if not self.active_group: + return + + self.message_broker.publish(InjectorStateMessage(self.get_state())) + + @property + def active_group(self) -> Optional[_Group]: + """The currently loaded group.""" + return self._reader_client.group + + @property + def active_preset(self) -> Optional[Preset[UIMapping]]: + """The currently loaded preset.""" + return self._active_preset + + @property + def active_mapping(self) -> Optional[UIMapping]: + """The currently loaded mapping.""" + return self._active_mapping + + @property + def active_input_config(self) -> Optional[InputConfig]: + """The currently loaded event.""" + return self._active_input_config + + def get_group_keys(self) -> Tuple[GroupKey, ...]: + """Get all group keys (plugged devices).""" + return tuple(group.key for group in self._reader_client.groups.get_groups()) + + def get_preset_names(self) -> Tuple[Name, ...]: + """Get all preset names for active_group and current user sorted by age.""" + if not self.active_group: + raise DataManagementError("Cannot find presets: Group is not set") + device_folder = PathUtils.get_preset_path(self.active_group.name) + PathUtils.mkdir(device_folder) + + paths = glob.glob(os.path.join(glob.escape(device_folder), "*.json")) + presets = [ + os.path.splitext(os.path.basename(path))[0] + for path in sorted(paths, key=os.path.getmtime) + ] + # the highest timestamp to the front + presets.reverse() + return tuple(presets) + + def get_mappings(self) -> Optional[List[MappingData]]: + """All mappings from the active_preset.""" + if not self._active_preset: + return None + + return [mapping.get_bus_message() for mapping in self._active_preset] + + def get_autoload(self) -> bool: + """The autoload status of the active_preset.""" + if not self.active_preset or not self.active_group: + return False + return self._config.is_autoloaded( + self.active_group.key, self.active_preset.name + ) + + def set_autoload(self, status: bool): + """Set the autoload status of the active_preset. + + Will send "preset" message on the MessageBroker. + """ + if not self.active_preset or not self.active_group: + raise DataManagementError("Cannot set autoload status: Preset is not set") + + if status: + self._config.set_autoload_preset( + self.active_group.key, self.active_preset.name + ) + elif self.get_autoload(): + self._config.set_autoload_preset(self.active_group.key, None) + + self.publish_preset() + + def get_newest_group_key(self) -> GroupKey: + """group_key of the group with the most recently modified preset.""" + paths = [] + pattern = os.path.join( + glob.escape(PathUtils.get_preset_path()), + "*/*.json", + ) + for path in glob.glob(pattern): + if self._reader_client.groups.find(key=PathUtils.split_all(path)[-2]): + paths.append((path, os.path.getmtime(path))) + + if not paths: + raise FileNotFoundError() + + path, _ = max(paths, key=lambda x: x[1]) + return PathUtils.split_all(path)[-2] + + def get_newest_preset_name(self) -> Name: + """Preset name of the most recently modified preset in the active group.""" + if not self.active_group: + raise DataManagementError("Cannot find newest preset: Group is not set") + + pattern = os.path.join( + glob.escape(PathUtils.get_preset_path(self.active_group.name)), + "*.json", + ) + paths = [(path, os.path.getmtime(path)) for path in glob.glob(pattern)] + if not paths: + raise FileNotFoundError() + + path, _ = max(paths, key=lambda x: x[1]) + return os.path.split(path)[-1].split(".")[0] + + def get_available_preset_name(self, name=DEFAULT_PRESET_NAME) -> Name: + """The first available preset in the active group.""" + if not self.active_group: + raise DataManagementError("Unable find preset name. Group is not set") + + name = name.strip() + + # find a name that is not already taken + if os.path.exists(PathUtils.get_preset_path(self.active_group.name, name)): + # if there already is a trailing number, increment it instead of + # adding another one + match = re.match(r"^(.+) (\d+)$", name) + if match: + name = match[1] + i = int(match[2]) + 1 + else: + i = 2 + + while os.path.exists( + PathUtils.get_preset_path(self.active_group.name, f"{name} {i}") + ): + i += 1 + + return f"{name} {i}" + + return name + + def load_group(self, group_key: str): + """Load a group. will publish "groups" and "injector_state" messages. + + This will render the active_mapping and active_preset invalid. + """ + if group_key not in self.get_group_keys(): + raise DataManagementError("Unable to load non existing group") + + logger.info('Loading group "%s"', group_key) + + self._active_input_config = None + self._active_mapping = None + self._active_preset = None + group = self._reader_client.groups.find(key=group_key) + self._reader_client.set_group(group) + self.publish_group() + self.publish_injector_state() + + def load_preset(self, name: str): + """Load a preset. Will send "preset" message on the MessageBroker. + + This will render the active_mapping invalid. + """ + if not self.active_group: + raise DataManagementError("Unable to load preset. Group is not set") + + logger.info('Loading preset "%s"', name) + + preset_path = PathUtils.get_preset_path(self.active_group.name, name) + preset = Preset(preset_path, mapping_factory=UIMapping) + preset.load() + self._active_input_config = None + self._active_mapping = None + self._active_preset = preset + self.publish_preset() + + def load_mapping(self, combination: InputCombination): + """Load a mapping. Will send "mapping" message on the MessageBroker.""" + if not self._active_preset: + raise DataManagementError("Unable to load mapping. Preset is not set") + + mapping = self._active_preset.get_mapping(combination) + if not mapping: + msg = ( + f"the mapping with {combination = } does not " + f"exist in the {self._active_preset.path}" + ) + logger.error(msg) + raise KeyError(msg) + + self._active_input_config = None + self._active_mapping = mapping + self.publish_mapping() + + def load_input_config(self, input_config: InputConfig): + """Load a InputConfig from the combination in the active mapping. + + Will send "event" message on the MessageBroker, + """ + if not self.active_mapping: + raise DataManagementError("Unable to load event. Mapping is not set") + if input_config not in self.active_mapping.input_combination: + raise ValueError( + f"{input_config} is not member of active_mapping.input_combination: " + f"{self.active_mapping.input_combination}" + ) + self._active_input_config = input_config + self.publish_event() + + def rename_preset(self, new_name: str): + """Rename the current preset and move the correct file. + + Will send "group" and then "preset" message on the MessageBroker + """ + if not self.active_preset or not self.active_group: + raise DataManagementError("Unable rename preset: Preset is not set") + + if self.active_preset.path == PathUtils.get_preset_path( + self.active_group.name, new_name + ): + return + + old_path = self.active_preset.path + assert old_path is not None + old_name = os.path.basename(old_path).split(".")[0] + new_path = PathUtils.get_preset_path(self.active_group.name, new_name) + if os.path.exists(new_path): + raise ValueError( + f"cannot rename {old_name} to " f"{new_name}, preset already exists" + ) + + logger.info('Moving "%s" to "%s"', old_path, new_path) + os.rename(old_path, new_path) + now = time.time() + os.utime(new_path, (now, now)) + + if self._config.is_autoloaded(self.active_group.key, old_name): + self._config.set_autoload_preset(self.active_group.key, new_name) + + self.active_preset.path = PathUtils.get_preset_path( + self.active_group.name, new_name + ) + self.publish_group() + self.publish_preset() + + def copy_preset(self, name: str): + """Copy the current preset to the given name. + + Will send "group" and "preset" message to the MessageBroker and load the copy + """ + # todo: Do we want to load the copy here? or is this up to the controller? + if not self.active_preset or not self.active_group: + raise DataManagementError("Unable to copy preset: Preset is not set") + + if self.active_preset.path == PathUtils.get_preset_path( + self.active_group.name, name + ): + return + + if name in self.get_preset_names(): + raise ValueError(f"a preset with the name {name} already exits") + + new_path = PathUtils.get_preset_path(self.active_group.name, name) + logger.info('Copy "%s" to "%s"', self.active_preset.path, new_path) + self.active_preset.path = new_path + self.save() + self.publish_group() + self.publish_preset() + + def create_preset(self, name: str): + """Create empty preset in the active_group. + + Will send "group" message to the MessageBroker + """ + if not self.active_group: + raise DataManagementError("Unable to add preset. Group is not set") + + path = PathUtils.get_preset_path(self.active_group.name, name) + if os.path.exists(path): + raise DataManagementError("Unable to add preset. Preset exists") + + Preset(path).save() + self.publish_group() + + def delete_preset(self): + """Delete the active preset. + + Will send "group" message to the MessageBroker + this will invalidate the active mapping, + """ + preset_path = self._active_preset.path + logger.info('Removing "%s"', preset_path) + os.remove(preset_path) + self._active_mapping = None + self._active_preset = None + self.publish_group() + + def update_mapping(self, **kwargs): + """Update the active mapping with the given keywords and values. + + Will send "mapping" message to the MessageBroker. In case of a new + input_combination. This will first send a "combination_update" message. + """ + if not self._active_mapping: + raise DataManagementError("Cannot modify Mapping: Mapping is not set") + + if symbol := kwargs.get("output_symbol"): + kwargs["output_symbol"] = self._keyboard_layout.correct_case(symbol) + + combination = self.active_mapping.input_combination + for key, value in kwargs.items(): + setattr(self._active_mapping, key, value) + + if ( + "input_combination" in kwargs + and combination != self.active_mapping.input_combination + ): + self._active_input_config = None + self.message_broker.publish( + CombinationUpdate(combination, self._active_mapping.input_combination) + ) + + if "mapping_type" in kwargs: + # mapping_type must be the last update because it is automatically updated + # by a validation function + self._active_mapping.mapping_type = kwargs["mapping_type"] + + self.publish_mapping() + + def update_input_config(self, new_input_config: InputConfig): + """Update the active input configuration. + + Will send "combination_update", "mapping" and "event" messages to the + MessageBroker (in that order) + """ + if not self.active_mapping or not self.active_input_config: + raise DataManagementError("Cannot modify event: Event is not set") + + combination = list(self.active_mapping.input_combination) + combination[combination.index(self.active_input_config)] = new_input_config + self.update_mapping(input_combination=InputCombination(combination)) + self._active_input_config = new_input_config + self.publish_event() + + def create_mapping(self): + """Create empty mapping in the active preset. + + Will send "preset" message to the MessageBroker + """ + if not self._active_preset: + raise DataManagementError("Cannot create mapping: Preset is not set") + self._active_preset.add(UIMapping()) + self.publish_preset() + + def delete_mapping(self): + """Delete the active mapping. + + Will send "preset" message to the MessageBroker + """ + if not self._active_mapping: + raise DataManagementError( + "cannot delete active mapping: active mapping is not set" + ) + + self._active_preset.remove(self._active_mapping.input_combination) + self._active_mapping = None + self.publish_preset() + + def save(self): + """Save the active preset.""" + if self._active_preset: + self._active_preset.save() + + def refresh_groups(self): + """Refresh the groups (plugged devices). + + Should send "groups" message to MessageBroker this will not happen immediately + because the system might take a bit until the groups are available + """ + self._reader_client.refresh_groups() + + def start_combination_recording(self): + """Record user input. + + Will send "combination_recorded" messages as new input arrives. + Will eventually send a "recording_finished" message. + """ + self._reader_client.start_recorder() + + def stop_combination_recording(self): + """Stop recording user input. + + Will send a recording_finished signal if a recording is running. + """ + self._reader_client.stop_recorder() + + def stop_injecting(self) -> None: + """Stop injecting for the active group. + + Will send "injector_state" message once the injector has stopped.""" + if not self.active_group: + raise DataManagementError("Cannot stop injection: Group is not set") + self._daemon.stop_injecting(self.active_group.key) + self.do_when_injector_state( + {InjectorState.STOPPED}, self.publish_injector_state + ) + + def start_injecting(self) -> bool: + """Start injecting the active preset for the active group. + + returns if the startup was successfully initialized. + Will send "injector_state" message once the startup is complete. + """ + if not self.active_preset or not self.active_group: + raise DataManagementError("Cannot start injection: Preset is not set") + + self._daemon.set_config_dir(self._config.get_dir()) + assert self.active_preset.name is not None + if self._daemon.start_injecting(self.active_group.key, self.active_preset.name): + self.do_when_injector_state( + { + InjectorState.RUNNING, + InjectorState.ERROR, + InjectorState.NO_GRAB, + InjectorState.UPGRADE_EVDEV, + }, + self.publish_injector_state, + ) + return True + return False + + def get_state(self) -> InjectorState: + """The state of the injector.""" + if not self.active_group: + raise DataManagementError("Cannot read state: Group is not set") + return self._daemon.get_state(self.active_group.key) + + def refresh_service_config_path(self): + """Tell the service to refresh its config path.""" + self._daemon.set_config_dir(self._config.get_dir()) + + def do_when_injector_state(self, states: Set[InjectorState], callback): + """Run callback once the injector state is one of states.""" + start = time.time() + + def do(): + if time.time() - start > 3: + # something went wrong, there should have been a state long ago. + # the timeout prevents tons of GLib.timeouts to run forever, especially + # after spamming the "Stop" button. + logger.error("Timed out while waiting for injector state %s", states) + return False + + if self.get_state() in states: + callback() + return False + return True + + GLib.timeout_add(100, do) diff --git a/inputremapper/gui/forward_to_ui_handler.py b/inputremapper/gui/forward_to_ui_handler.py new file mode 100644 index 0000000..e046445 --- /dev/null +++ b/inputremapper/gui/forward_to_ui_handler.py @@ -0,0 +1,125 @@ +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2023 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + +"""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 diff --git a/inputremapper/gui/gettext.py b/inputremapper/gui/gettext.py new file mode 100644 index 0000000..891aadf --- /dev/null +++ b/inputremapper/gui/gettext.py @@ -0,0 +1,33 @@ +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + +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 diff --git a/inputremapper/gui/messages/__init__.py b/inputremapper/gui/messages/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/inputremapper/gui/messages/message_broker.py b/inputremapper/gui/messages/message_broker.py new file mode 100644 index 0000000..62c9fcd --- /dev/null +++ b/inputremapper/gui/messages/message_broker.py @@ -0,0 +1,120 @@ +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + +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 diff --git a/inputremapper/gui/messages/message_data.py b/inputremapper/gui/messages/message_data.py new file mode 100644 index 0000000..94ca174 --- /dev/null +++ b/inputremapper/gui/messages/message_data.py @@ -0,0 +1,126 @@ +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + +import re +from dataclasses import dataclass +from typing import Dict, Tuple, Optional, Callable + +from inputremapper.configs.input_config import InputCombination +from inputremapper.configs.mapping import MappingData +from inputremapper.gui.messages.message_types import ( + MessageType, + Name, + Capabilities, + Key, + DeviceTypes, +) + + +@dataclass(frozen=True) +class UInputsData: + message_type = MessageType.uinputs + uinputs: Dict[Name, Capabilities] + + def __str__(self): + string = f"{self.__class__.__name__}(uinputs={self.uinputs})" + + # find all sequences of comma+space separated numbers, and shorten them + # to the first and last number + all_matches = list(re.finditer(r"(\d+, )+", string)) + all_matches.reverse() + for match in all_matches: + start = match.start() + end = match.end() + start += string[start:].find(",") + 2 + if start == end: + continue + string = f"{string[:start]}... {string[end:]}" + + return string + + +@dataclass(frozen=True) +class GroupsData: + """Message containing all available groups and their device types.""" + + message_type = MessageType.groups + groups: Dict[Key, DeviceTypes] + + +@dataclass(frozen=True) +class GroupData: + """Message with the active group and available presets for the group.""" + + message_type = MessageType.group + group_key: str + presets: Tuple[str, ...] + + +@dataclass(frozen=True) +class PresetData: + """Message with the active preset name and mapping names/combinations.""" + + message_type = MessageType.preset + name: Optional[Name] + mappings: Optional[Tuple[MappingData, ...]] + autoload: bool = False + + +@dataclass(frozen=True) +class StatusData: + """Message with the strings and id for the status bar.""" + + message_type = MessageType.status_msg + ctx_id: int + msg: Optional[str] = None + tooltip: Optional[str] = None + + +@dataclass(frozen=True) +class CombinationRecorded: + """Message with the latest recoded combination.""" + + message_type = MessageType.combination_recorded + combination: "InputCombination" + + +@dataclass(frozen=True) +class CombinationUpdate: + """Message with the old and new combination (hash for a mapping) when it changed.""" + + message_type = MessageType.combination_update + old_combination: "InputCombination" + new_combination: "InputCombination" + + +@dataclass(frozen=True) +class UserConfirmRequest: + """Message for requesting a user response (confirm/cancel) from the gui.""" + + message_type = MessageType.user_confirm_request + msg: str + respond: Callable[[bool], None] = lambda _: None + + +@dataclass(frozen=True) +class DoStackSwitch: + """Command the stack to switch to a different page.""" + + message_type = MessageType.do_stack_switch + page_index: int diff --git a/inputremapper/gui/messages/message_types.py b/inputremapper/gui/messages/message_types.py new file mode 100644 index 0000000..c11f30b --- /dev/null +++ b/inputremapper/gui/messages/message_types.py @@ -0,0 +1,60 @@ +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + +from enum import Enum +from typing import Dict, List + +from inputremapper.groups import DeviceType + +# useful type aliases +Capabilities = Dict[int, List] +Name = str +Key = str +DeviceTypes = List[DeviceType] + + +class MessageType(Enum): + reset_gui = "reset_gui" + terminate = "terminate" + init = "init" + + uinputs = "uinputs" + groups = "groups" + group = "group" + preset = "preset" + mapping = "mapping" + selected_event = "selected_event" + combination_recorded = "combination_recorded" + + # only the reader_client should send those messages: + recording_started = "recording_started" + recording_finished = "recording_finished" + + combination_update = "combination_update" + status_msg = "status_msg" + injector_state = "injector_state" + + gui_focus_request = "gui_focus_request" + user_confirm_request = "user_confirm_request" + + do_stack_switch = "do_stack_switch" + + # for unit tests: + test1 = "test1" + test2 = "test2" diff --git a/inputremapper/gui/reader_client.py b/inputremapper/gui/reader_client.py new file mode 100644 index 0000000..99ef8f1 --- /dev/null +++ b/inputremapper/gui/reader_client.py @@ -0,0 +1,306 @@ +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + + +"""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() diff --git a/inputremapper/gui/reader_service.py b/inputremapper/gui/reader_service.py new file mode 100644 index 0000000..4e2c87a --- /dev/null +++ b/inputremapper/gui/reader_service.py @@ -0,0 +1,420 @@ +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2023 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + +"""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 diff --git a/inputremapper/gui/user_interface.py b/inputremapper/gui/user_interface.py new file mode 100644 index 0000000..09043de --- /dev/null +++ b/inputremapper/gui/user_interface.py @@ -0,0 +1,417 @@ +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + + +"""User Interface.""" +from typing import Dict, Callable + +from gi.repository import Gtk, GtkSource, Gdk, GObject + +from inputremapper.configs.data import get_data_path +from inputremapper.configs.input_config import InputCombination +from inputremapper.configs.mapping import MappingData +from inputremapper.gui.autocompletion import Autocompletion +from inputremapper.gui.components.common import Breadcrumbs +from inputremapper.gui.components.device_groups import DeviceGroupSelection +from inputremapper.gui.components.editor import ( + MappingListBox, + TargetSelection, + CodeEditor, + RecordingToggle, + RecordingStatus, + AutoloadSwitch, + ReleaseCombinationSwitch, + CombinationListbox, + AnalogInputSwitch, + TriggerThresholdInput, + OutputAxisSelector, + ReleaseTimeoutInput, + TransformationDrawArea, + Sliders, + RelativeInputCutoffInput, + KeyAxisStackSwitcher, + RequireActiveMapping, + GdkEventRecorder, +) +from inputremapper.gui.components.main import Stack, StatusBar +from inputremapper.gui.components.presets import PresetSelection +from inputremapper.gui.controller import Controller +from inputremapper.gui.gettext import _ +from inputremapper.gui.messages.message_broker import ( + MessageBroker, + MessageType, +) +from inputremapper.gui.messages.message_data import UserConfirmRequest +from inputremapper.gui.utils import ( + gtk_iteration, +) +from inputremapper.injection.injector import InjectorStateMessage +from inputremapper.logging.logger import logger, COMMIT_HASH, VERSION, EVDEV_VERSION + +# https://cjenkins.wordpress.com/2012/05/08/use-gtksourceview-widget-in-glade/ +GObject.type_register(GtkSource.View) +# GtkSource.View() also works: +# https://stackoverflow.com/questions/60126579/gtk-builder-error-quark-invalid-object-type-webkitwebview + + +def on_close_about(about, _): + """Hide the about dialog without destroying it.""" + about.hide() + return True + + +class UserInterface: + """The input-remapper gtk window.""" + + def __init__( + self, + message_broker: MessageBroker, + controller: Controller, + ): + self.message_broker = message_broker + self.controller = controller + + # all shortcuts executed when ctrl+... + self.shortcuts: Dict[int, Callable] = { + Gdk.KEY_q: self.controller.close, + Gdk.KEY_r: self.controller.refresh_groups, + Gdk.KEY_Delete: self.controller.stop_injecting, + Gdk.KEY_n: self.controller.add_preset, + } + + # stores the ids for all the listeners attached to the gui + self.gtk_listeners: Dict[Callable, int] = {} + + self.message_broker.subscribe(MessageType.terminate, lambda _: self.close()) + + self.builder = Gtk.Builder() + self._build_ui() + self.window: Gtk.Window = self.get("window") + self.about: Gtk.Window = self.get("about-dialog") + self.combination_editor: Gtk.Dialog = self.get("combination-editor") + + self._create_dialogs() + self._create_components() + self._connect_gtk_signals() + self._connect_message_listener() + + self.window.show() + # hide everything until stuff is populated + self.get("vertical-wrapper").set_opacity(0) + # if any of the next steps take a bit to complete, have the window + # already visible (without content) to make it look more responsive. + gtk_iteration() + + # now show the proper finished content of the window + self.get("vertical-wrapper").set_opacity(1) + + def _build_ui(self): + """Build the window from stylesheet and gladefile.""" + css_provider = Gtk.CssProvider() + + with open(get_data_path("style.css"), "r") as file: + css_provider.load_from_data(bytes(file.read(), encoding="UTF-8")) + + Gtk.StyleContext.add_provider_for_screen( + Gdk.Screen.get_default(), + css_provider, + Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION, + ) + + gladefile = get_data_path("input-remapper.glade") + self.builder.add_from_file(gladefile) + self.builder.connect_signals(self) + + def _create_components(self): + """Setup all objects which manage individual components of the ui.""" + message_broker = self.message_broker + controller = self.controller + DeviceGroupSelection(message_broker, controller, self.get("device_selection")) + PresetSelection(message_broker, controller, self.get("preset_selection")) + MappingListBox(message_broker, controller, self.get("selection_label_listbox")) + TargetSelection(message_broker, controller, self.get("target-selector")) + + Breadcrumbs( + message_broker, + self.get("selected_device_name"), + show_device_group=True, + ) + Breadcrumbs( + message_broker, + self.get("selected_preset_name"), + show_device_group=True, + show_preset=True, + ) + + Stack(message_broker, controller, self.get("main_stack")) + RecordingToggle(message_broker, controller, self.get("key_recording_toggle")) + StatusBar( + message_broker, + controller, + self.get("status_bar"), + self.get("error_status_icon"), + self.get("warning_status_icon"), + ) + RecordingStatus(message_broker, self.get("recording_status")) + AutoloadSwitch(message_broker, controller, self.get("preset_autoload_switch")) + ReleaseCombinationSwitch( + message_broker, controller, self.get("release-combination-switch") + ) + CombinationListbox(message_broker, controller, self.get("combination-listbox")) + AnalogInputSwitch(message_broker, controller, self.get("analog-input-switch")) + TriggerThresholdInput( + message_broker, controller, self.get("trigger-threshold-spin-btn") + ) + RelativeInputCutoffInput( + message_broker, controller, self.get("input-cutoff-spin-btn") + ) + OutputAxisSelector(message_broker, controller, self.get("output-axis-selector")) + KeyAxisStackSwitcher( + message_broker, + controller, + self.get("editor-stack"), + self.get("key_macro_toggle_btn"), + self.get("analog_toggle_btn"), + ) + ReleaseTimeoutInput( + message_broker, controller, self.get("release-timeout-spin-button") + ) + TransformationDrawArea( + message_broker, controller, self.get("transformation-draw-area") + ) + Sliders( + message_broker, + controller, + self.get("gain-scale"), + self.get("deadzone-scale"), + self.get("expo-scale"), + ) + + GdkEventRecorder(self.window, self.get("gdk-event-recorder-label")) + + RequireActiveMapping( + message_broker, + self.get("edit-combination-btn"), + require_recorded_input=True, + ) + RequireActiveMapping( + message_broker, + self.get("output"), + require_recorded_input=True, + ) + RequireActiveMapping( + message_broker, + self.get("delete-mapping"), + require_recorded_input=False, + ) + + # code editor and autocompletion + code_editor = CodeEditor(message_broker, controller, self.get("code_editor")) + autocompletion = Autocompletion(message_broker, controller, code_editor) + autocompletion.set_relative_to(self.get("code_editor_container")) + self.autocompletion = autocompletion # only for testing + + def _create_dialogs(self): + """Setup different dialogs, such as the about page.""" + self.about.connect("delete-event", on_close_about) + # set_position needs to be done once initially, otherwise the + # dialog is not centered when it is opened for the first time + self.about.set_position(Gtk.WindowPosition.CENTER_ON_PARENT) + self.get("version-label").set_text( + f"input-remapper {VERSION} {COMMIT_HASH[:7]}" + f"\npython-evdev {EVDEV_VERSION}" + if EVDEV_VERSION + else "" + ) + + def _connect_gtk_signals(self): + self.get("delete_preset").connect( + "clicked", lambda *_: self.controller.delete_preset() + ) + self.get("copy_preset").connect( + "clicked", lambda *_: self.controller.copy_preset() + ) + self.get("create_preset").connect( + "clicked", lambda *_: self.controller.add_preset() + ) + self.get("apply_preset").connect( + "clicked", lambda *_: self.controller.start_injecting() + ) + self.get("stop_injection_preset_page").connect( + "clicked", lambda *_: self.controller.stop_injecting() + ) + self.get("stop_injection_editor_page").connect( + "clicked", lambda *_: self.controller.stop_injecting() + ) + self.get("rename-button").connect("clicked", self.on_gtk_rename_clicked) + self.get("preset_name_input").connect( + "key-release-event", self.on_gtk_preset_name_input_return + ) + self.get("create_mapping_button").connect( + "clicked", lambda *_: self.controller.create_mapping() + ) + self.get("delete-mapping").connect( + "clicked", lambda *_: self.controller.delete_mapping() + ) + self.combination_editor.connect( + # it only takes self as argument, but delete-events provides more + # probably a gtk bug + "delete-event", + lambda dialog, *_: Gtk.Widget.hide_on_delete(dialog), + ) + self.get("edit-combination-btn").connect( + "clicked", lambda *_: self.combination_editor.show() + ) + self.get("remove-event-btn").connect( + "clicked", lambda *_: self.controller.remove_event() + ) + self.connect_shortcuts() + + def _connect_message_listener(self): + self.message_broker.subscribe( + MessageType.mapping, self.update_combination_label + ) + self.message_broker.subscribe( + MessageType.injector_state, self.on_injector_state_msg + ) + self.message_broker.subscribe( + MessageType.user_confirm_request, self._on_user_confirm_request + ) + + def _create_dialog(self, primary: str, secondary: str) -> Gtk.MessageDialog: + """Create a message dialog with cancel and confirm buttons.""" + message_dialog = Gtk.MessageDialog( + self.window, + Gtk.DialogFlags.MODAL | Gtk.DialogFlags.DESTROY_WITH_PARENT, + Gtk.MessageType.QUESTION, + Gtk.ButtonsType.NONE, + primary, + ) + + if secondary: + message_dialog.format_secondary_text(secondary) + + message_dialog.add_button("Cancel", Gtk.ResponseType.CANCEL) + + confirm_button = message_dialog.add_button("Confirm", Gtk.ResponseType.ACCEPT) + confirm_button.get_style_context().add_class(Gtk.STYLE_CLASS_DESTRUCTIVE_ACTION) + + return message_dialog + + def _on_user_confirm_request(self, msg: UserConfirmRequest): + # if the message contains a line-break, use the first chunk for the primary + # message, and the rest for the secondary message. + chunks = msg.msg.split("\n") + primary = chunks[0] + secondary = " ".join(chunks[1:]) + + message_dialog = self._create_dialog(primary, secondary) + + response = message_dialog.run() + msg.respond(response == Gtk.ResponseType.ACCEPT) + + message_dialog.hide() + + def on_injector_state_msg(self, msg: InjectorStateMessage): + """Update the ui to reflect the status of the injector.""" + stop_injection_preset_page: Gtk.Button = self.get("stop_injection_preset_page") + stop_injection_editor_page: Gtk.Button = self.get("stop_injection_editor_page") + recording_toggle: Gtk.ToggleButton = self.get("key_recording_toggle") + + if msg.active(): + stop_injection_preset_page.set_opacity(1) + stop_injection_editor_page.set_opacity(1) + stop_injection_preset_page.set_sensitive(True) + stop_injection_editor_page.set_sensitive(True) + recording_toggle.set_opacity(0.5) + else: + stop_injection_preset_page.set_opacity(0.5) + stop_injection_editor_page.set_opacity(0.5) + stop_injection_preset_page.set_sensitive(True) + stop_injection_editor_page.set_sensitive(True) + recording_toggle.set_opacity(1) + + def disconnect_shortcuts(self): + """Stop listening for shortcuts. + + e.g. when recording key combinations + """ + try: + self.window.disconnect(self.gtk_listeners.pop(self.on_gtk_shortcut)) + except KeyError: + logger.debug("key listeners seem to be not connected") + + def connect_shortcuts(self): + """Start listening for shortcuts.""" + if not self.gtk_listeners.get(self.on_gtk_shortcut): + self.gtk_listeners[self.on_gtk_shortcut] = self.window.connect( + "key-press-event", self.on_gtk_shortcut + ) + + def get(self, name: str): + """Get a widget from the window.""" + return self.builder.get_object(name) + + def close(self): + """Close the window.""" + logger.debug("Closing window") + self.window.hide() + + def update_combination_label(self, mapping: MappingData): + """Listens for mapping and updates the combination label.""" + label: Gtk.Label = self.get("combination-label") + if mapping.input_combination.beautify() == label.get_label(): + return + if mapping.input_combination == InputCombination.empty_combination(): + label.set_opacity(0.5) + label.set_label(_("no input configured")) + return + + label.set_opacity(1) + label.set_label(mapping.input_combination.beautify()) + + def on_gtk_shortcut(self, _, event: Gdk.EventKey): + """Execute shortcuts.""" + if event.state & Gdk.ModifierType.CONTROL_MASK: + try: + self.shortcuts[event.keyval]() + except KeyError: + pass + + def on_gtk_close(self, *_): + self.controller.close() + + def on_gtk_about_clicked(self, _): + """Show the about/help dialog.""" + self.about.show() + + def on_gtk_about_key_press(self, _, event): + """Hide the about/help dialog.""" + gdk_keycode = event.get_keyval()[1] + if gdk_keycode == Gdk.KEY_Escape: + self.about.hide() + + def on_gtk_rename_clicked(self, *_): + name = self.get("preset_name_input").get_text() + self.controller.rename_preset(name) + self.get("preset_name_input").set_text("") + + def on_gtk_preset_name_input_return(self, _, event: Gdk.EventKey): + if event.keyval == Gdk.KEY_Return: + self.on_gtk_rename_clicked() diff --git a/inputremapper/gui/utils.py b/inputremapper/gui/utils.py new file mode 100644 index 0000000..d84e3cb --- /dev/null +++ b/inputremapper/gui/utils.py @@ -0,0 +1,272 @@ +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + +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, + ) diff --git a/inputremapper/injection/__init__.py b/inputremapper/injection/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/inputremapper/injection/context.py b/inputremapper/injection/context.py new file mode 100644 index 0000000..ea40e02 --- /dev/null +++ b/inputremapper/injection/context.py @@ -0,0 +1,132 @@ +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + + +"""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 diff --git a/inputremapper/injection/event_reader.py b/inputremapper/injection/event_reader.py new file mode 100644 index 0000000..56ec821 --- /dev/null +++ b/inputremapper/injection/event_reader.py @@ -0,0 +1,205 @@ +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + + +"""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) diff --git a/inputremapper/injection/global_uinputs.py b/inputremapper/injection/global_uinputs.py new file mode 100644 index 0000000..2849700 --- /dev/null +++ b/inputremapper/injection/global_uinputs.py @@ -0,0 +1,192 @@ +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + +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) diff --git a/inputremapper/injection/injector.py b/inputremapper/injection/injector.py new file mode 100644 index 0000000..fe7b41e --- /dev/null +++ b/inputremapper/injection/injector.py @@ -0,0 +1,511 @@ +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + + +"""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)) diff --git a/inputremapper/injection/macros/__init__.py b/inputremapper/injection/macros/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/inputremapper/injection/macros/argument.py b/inputremapper/injection/macros/argument.py new file mode 100644 index 0000000..d01c13c --- /dev/null +++ b/inputremapper/injection/macros/argument.py @@ -0,0 +1,320 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + +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}"' + ) + ) diff --git a/inputremapper/injection/macros/macro.py b/inputremapper/injection/macros/macro.py new file mode 100644 index 0000000..1ef5cf1 --- /dev/null +++ b/inputremapper/injection/macros/macro.py @@ -0,0 +1,126 @@ +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + +"""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'' + + def add_task(self, task): + self.tasks.append(task) diff --git a/inputremapper/injection/macros/parse.py b/inputremapper/injection/macros/parse.py new file mode 100644 index 0000000..60ae91d --- /dev/null +++ b/inputremapper/injection/macros/parse.py @@ -0,0 +1,476 @@ +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + + +"""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 diff --git a/inputremapper/injection/macros/raw_value.py b/inputremapper/injection/macros/raw_value.py new file mode 100644 index 0000000..44209df --- /dev/null +++ b/inputremapper/injection/macros/raw_value.py @@ -0,0 +1,35 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + +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] diff --git a/inputremapper/injection/macros/task.py b/inputremapper/injection/macros/task.py new file mode 100644 index 0000000..986cce5 --- /dev/null +++ b/inputremapper/injection/macros/task.py @@ -0,0 +1,248 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + +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) diff --git a/inputremapper/injection/macros/tasks/__init__.py b/inputremapper/injection/macros/tasks/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/inputremapper/injection/macros/tasks/add.py b/inputremapper/injection/macros/tasks/add.py new file mode 100644 index 0000000..5da9040 --- /dev/null +++ b/inputremapper/injection/macros/tasks/add.py @@ -0,0 +1,72 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + +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) diff --git a/inputremapper/injection/macros/tasks/event.py b/inputremapper/injection/macros/tasks/event.py new file mode 100644 index 0000000..ab31014 --- /dev/null +++ b/inputremapper/injection/macros/tasks/event.py @@ -0,0 +1,64 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + +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() diff --git a/inputremapper/injection/macros/tasks/hold.py b/inputremapper/injection/macros/tasks/hold.py new file mode 100644 index 0000000..9de6418 --- /dev/null +++ b/inputremapper/injection/macros/tasks/hold.py @@ -0,0 +1,71 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + +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) diff --git a/inputremapper/injection/macros/tasks/hold_keys.py b/inputremapper/injection/macros/tasks/hold_keys.py new file mode 100644 index 0000000..9cddf0d --- /dev/null +++ b/inputremapper/injection/macros/tasks/hold_keys.py @@ -0,0 +1,55 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + +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() diff --git a/inputremapper/injection/macros/tasks/if_eq.py b/inputremapper/injection/macros/tasks/if_eq.py new file mode 100644 index 0000000..5e6ad69 --- /dev/null +++ b/inputremapper/injection/macros/tasks/if_eq.py @@ -0,0 +1,66 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + +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) diff --git a/inputremapper/injection/macros/tasks/if_led.py b/inputremapper/injection/macros/tasks/if_led.py new file mode 100644 index 0000000..740b744 --- /dev/null +++ b/inputremapper/injection/macros/tasks/if_led.py @@ -0,0 +1,71 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + +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 diff --git a/inputremapper/injection/macros/tasks/if_single.py b/inputremapper/injection/macros/tasks/if_single.py new file mode 100644 index 0000000..c731541 --- /dev/null +++ b/inputremapper/injection/macros/tasks/if_single.py @@ -0,0 +1,96 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + +from __future__ import annotations + +import asyncio + +from evdev.ecodes import EV_KEY + +from inputremapper.injection.macros.argument import ArgumentConfig +from inputremapper.injection.macros.macro import Macro +from inputremapper.injection.macros.task import Task +from inputremapper.input_event import InputEvent + + +class IfSingleTask(Task): + """If a key was pressed without combining it.""" + + argument_configs = [ + ArgumentConfig( + name="then", + position=0, + types=[Macro, None], + ), + ArgumentConfig( + name="else", + position=1, + types=[Macro, None], + ), + ArgumentConfig( + name="timeout", + position=2, + types=[int, float, None], + default=None, + ), + ] + + async def run(self, callback) -> None: + assert self.context is not None + another_key_pressed_event = asyncio.Event() + then = self.get_argument("then").get_value() + else_ = self.get_argument("else").get_value() + + async def listener(event: InputEvent) -> None: + if event.type != EV_KEY: + # Ignore anything that is not a key + return + + if event.is_pressed(): + # Another key was pressed + another_key_pressed_event.set() + return + + self.add_event_listener(listener) + + timeout = self.get_argument("timeout").get_value() + + # Wait for anything of importance to happen, that would determine the + # outcome of the if_single macro. + await asyncio.wait( + [ + asyncio.Task(another_key_pressed_event.wait()), + asyncio.Task(self._trigger_release_event.wait()), + ], + timeout=timeout / 1000 if timeout else None, + return_when=asyncio.FIRST_COMPLETED, + ) + + self.remove_event_listener(listener) + + if not self.is_holding(): + if then: + await then.run(callback) + else: + # If the trigger has not been released, then `await asyncio.wait` above + # could only have finished waiting due to a timeout, or because another + # key was pressed. + if else_: + await else_.run(callback) diff --git a/inputremapper/injection/macros/tasks/if_tap.py b/inputremapper/injection/macros/tasks/if_tap.py new file mode 100644 index 0000000..9c08c76 --- /dev/null +++ b/inputremapper/injection/macros/tasks/if_tap.py @@ -0,0 +1,79 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + +from __future__ import annotations + +import asyncio + +from inputremapper.injection.macros.argument import ArgumentConfig +from inputremapper.injection.macros.macro import Macro +from inputremapper.injection.macros.task import Task + + +class IfTapTask(Task): + """If a key was pressed quickly. + + macro key pressed -> if_tap starts -> key released -> then + + macro key pressed -> released (does other stuff in the meantime) + -> if_tap starts -> pressed -> released -> then + """ + + argument_configs = [ + ArgumentConfig( + name="then", + position=0, + types=[Macro, None], + default=None, + ), + ArgumentConfig( + name="else", + position=1, + types=[Macro, None], + default=None, + ), + ArgumentConfig( + name="timeout", + position=2, + types=[int, float], + default=300, + ), + ] + + async def run(self, callback) -> None: + then = self.get_argument("then").get_value() + else_ = self.get_argument("else").get_value() + timeout = self.get_argument("timeout").get_value() / 1000 + + try: + await asyncio.wait_for(self._wait(), timeout) + if then: + await then.run(callback) + except asyncio.TimeoutError: + if else_: + await else_.run(callback) + + async def _wait(self): + """Wait for a release, or if nothing pressed yet, a press and release.""" + if self.is_holding(): + await self._trigger_release_event.wait() + else: + await self._trigger_press_event.wait() + await self._trigger_release_event.wait() diff --git a/inputremapper/injection/macros/tasks/ifeq.py b/inputremapper/injection/macros/tasks/ifeq.py new file mode 100644 index 0000000..6e164af --- /dev/null +++ b/inputremapper/injection/macros/tasks/ifeq.py @@ -0,0 +1,72 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + +from __future__ import annotations + +from inputremapper.injection.macros.argument import ArgumentConfig +from inputremapper.injection.macros.macro import Macro +from inputremapper.injection.macros.task import Task + + +class DeprecatedIfEqTask(Task): + """Old version of if_eq, kept for compatibility reasons. + + This can't support a comparison like ifeq("foo", $blub) with blub containing + "foo" without breaking old functionality, because "foo" is treated as a + variable name. + """ + + argument_configs = [ + ArgumentConfig( + name="variable", + position=0, + types=[str, float, int, None], + is_variable_name=True, + ), + ArgumentConfig( + name="value", + position=1, + types=[str, float, int, None], + ), + ArgumentConfig( + name="then", + position=2, + types=[Macro, None], + ), + ArgumentConfig( + name="else", + position=3, + types=[Macro, None], + ), + ] + + async def run(self, callback) -> None: + actual_value = self.get_argument("variable").get_value() + value = self.get_argument("value").get_value() + then = self.get_argument("then").get_value() + else_ = self.get_argument("else").get_value() + + # The old ifeq function became somewhat incompatible with the new macro code. + # I need to compare them as strings to keep this working. + if str(actual_value) == str(value): + if then is not None: + await then.run(callback) + elif else_ is not None: + await else_.run(callback) diff --git a/inputremapper/injection/macros/tasks/key.py b/inputremapper/injection/macros/tasks/key.py new file mode 100644 index 0000000..e04cfb8 --- /dev/null +++ b/inputremapper/injection/macros/tasks/key.py @@ -0,0 +1,49 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + +from __future__ import annotations + +from evdev.ecodes import EV_KEY + +from inputremapper.configs.keyboard_layout import keyboard_layout +from inputremapper.injection.macros.argument import ArgumentConfig +from inputremapper.injection.macros.task import Task + + +class KeyTask(Task): + """Write the symbol.""" + + argument_configs = [ + ArgumentConfig( + name="symbol", + position=0, + types=[str], + is_symbol=True, + ) + ] + + async def run(self, callback) -> None: + symbol = self.get_argument("symbol").get_value() + code = keyboard_layout.get(symbol) + + callback(EV_KEY, code, 1) + await self.keycode_pause() + callback(EV_KEY, code, 0) + await self.keycode_pause() diff --git a/inputremapper/injection/macros/tasks/key_down.py b/inputremapper/injection/macros/tasks/key_down.py new file mode 100644 index 0000000..4efd9e9 --- /dev/null +++ b/inputremapper/injection/macros/tasks/key_down.py @@ -0,0 +1,45 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + +from __future__ import annotations + +from evdev.ecodes import EV_KEY + +from inputremapper.configs.keyboard_layout import keyboard_layout +from inputremapper.injection.macros.argument import ArgumentConfig +from inputremapper.injection.macros.task import Task + + +class KeyDownTask(Task): + """Press the symbol.""" + + argument_configs = [ + ArgumentConfig( + name="symbol", + position=0, + types=[str], + is_symbol=True, + ) + ] + + async def run(self, callback) -> None: + symbol = self.get_argument("symbol").get_value() + code = keyboard_layout.get(symbol) + callback(EV_KEY, code, 1) diff --git a/inputremapper/injection/macros/tasks/key_up.py b/inputremapper/injection/macros/tasks/key_up.py new file mode 100644 index 0000000..ea11419 --- /dev/null +++ b/inputremapper/injection/macros/tasks/key_up.py @@ -0,0 +1,45 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + +from __future__ import annotations + +from evdev.ecodes import EV_KEY + +from inputremapper.configs.keyboard_layout import keyboard_layout +from inputremapper.injection.macros.argument import ArgumentConfig +from inputremapper.injection.macros.task import Task + + +class KeyUpTask(Task): + """Release the symbol.""" + + argument_configs = [ + ArgumentConfig( + name="symbol", + position=0, + types=[str], + is_symbol=True, + ) + ] + + async def run(self, callback) -> None: + symbol = self.get_argument("symbol").get_value() + code = keyboard_layout.get(symbol) + callback(EV_KEY, code, 0) diff --git a/inputremapper/injection/macros/tasks/mod_tap.py b/inputremapper/injection/macros/tasks/mod_tap.py new file mode 100644 index 0000000..5b7a0a5 --- /dev/null +++ b/inputremapper/injection/macros/tasks/mod_tap.py @@ -0,0 +1,140 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + +from __future__ import annotations + +import asyncio +from collections import deque +from typing import Deque + +from evdev.ecodes import EV_KEY + +from inputremapper.configs.keyboard_layout import keyboard_layout +from inputremapper.injection.macros.argument import ArgumentConfig +from inputremapper.injection.macros.task import Task +from inputremapper.input_event import InputEvent +from inputremapper.logging.logger import logger + + +class ModTapTask(Task): + """If pressed long enough in combination with other keys, it turns into a modifier. + + Can be used to make home-row-modifiers. + + Works similar to the default of + https://github.com/qmk/qmk_firmware/blob/78a0adfbb4d2c4e12f93f2a62ded0020d406243e/docs/tap_hold.md#comparison-comparison + """ + + argument_configs = [ + ArgumentConfig( + name="default", + position=0, + types=[str], + is_symbol=True, + ), + ArgumentConfig( + name="modifier", + position=1, + types=[str], + is_symbol=True, + ), + ArgumentConfig( + name="tapping_term", + position=2, + types=[int, float], + default=200, + ), + ] + + async def run(self, callback) -> None: + tapping_term = self.get_argument("tapping_term").get_value() / 1000 + jamming_asyncio_events: Deque[asyncio.Event] = deque() + + async def listener(event: InputEvent) -> None: + trigger = self.mapping.input_combination[-1] + if event.type_and_code == trigger.type_and_code: + # We don't block the event that would set _trigger_release_event. + return + + if event.type != EV_KEY: + return + + asyncio_event = asyncio.Event() + jamming_asyncio_events.append(asyncio_event) + # Make the EventReader wait until the mod_tap macro allows it to continue + # processing the event. Because we want to wait until mod_tap injected the + # modifier. + await asyncio_event.wait() + + self.add_event_listener(listener) + + timeout = asyncio.Task(asyncio.sleep(tapping_term)) + await asyncio.wait( + [asyncio.Task(self._trigger_release_event.wait()), timeout], + return_when=asyncio.FIRST_COMPLETED, + ) + has_timed_out = timeout.done() + + if has_timed_out: + # The timeout happened before the trigger got released. + # We therefore modify stuff. + symbol = self.get_argument("modifier").get_value() + logger.debug("Modifying with %s", symbol) + else: + # The trigger got released before the timeout. + # We therefore do not modify stuff. + symbol = self.get_argument("default").get_value() + logger.debug("Writing default %s", symbol) + + code = keyboard_layout.get(symbol) + callback(EV_KEY, code, 1) + await self.keycode_pause() + + # Now that we know if the key was pressed with the intention of modifying other + # keys, we can let the jammed keys go on their journey through the handlers. + # Those other handlers may map them to other keys and stuff. + while len(jamming_asyncio_events) > 0: + asyncio_event = jamming_asyncio_events.popleft() + asyncio_event.set() + await self.keycode_pause() + await self.throttle() + # While we are emptying the queue, more events might still arrive and add + # to the queue. + + # We remove this as late as possible, because if more keys are pressed while + # jamming_asyncio_events is still being taken care of, they should wait until + # all is done. This ensures the order of all events that are pressed, until + # mod_tap is completely finished. + self.remove_event_listener(listener) + + # Keep the modifier pressed until the input/trigger is released + await self._trigger_release_event.wait() + callback(EV_KEY, code, 0) + + await self.keycode_pause() + + async def throttle(self) -> None: + # In case the keycode_pause ist set to 0ms, we need to give the event handlers + # a chance to inject the withheld events, before we go on. This ensures the + # correct order of injections. Since we are using asyncio, something like + # `callback(EV_KEY, code, 0)` might be faster than the event handlers, even if + # it is the last step of the macro. + if self.mapping.macro_key_sleep_ms == 0: + await asyncio.sleep(0.01) diff --git a/inputremapper/injection/macros/tasks/modify.py b/inputremapper/injection/macros/tasks/modify.py new file mode 100644 index 0000000..7d5f939 --- /dev/null +++ b/inputremapper/injection/macros/tasks/modify.py @@ -0,0 +1,57 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + +from __future__ import annotations + +from evdev.ecodes import EV_KEY + +from inputremapper.configs.keyboard_layout import keyboard_layout +from inputremapper.injection.macros.argument import ArgumentConfig +from inputremapper.injection.macros.macro import Macro +from inputremapper.injection.macros.task import Task + + +class ModifyTask(Task): + """Do stuff while a modifier is activated.""" + + argument_configs = [ + ArgumentConfig( + name="modifier", + position=0, + types=[str], + is_symbol=True, + ), + ArgumentConfig( + name="macro", + position=1, + types=[Macro], + ), + ] + + async def run(self, callback) -> None: + modifier = self.get_argument("modifier").get_value() + code = keyboard_layout.get(modifier) + macro = self.get_argument("macro").get_value() + + callback(EV_KEY, code, 1) + await self.keycode_pause() + await macro.run(callback) + callback(EV_KEY, code, 0) + await self.keycode_pause() diff --git a/inputremapper/injection/macros/tasks/mouse.py b/inputremapper/injection/macros/tasks/mouse.py new file mode 100644 index 0000000..801f850 --- /dev/null +++ b/inputremapper/injection/macros/tasks/mouse.py @@ -0,0 +1,69 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + +from __future__ import annotations + +from evdev._ecodes import REL_Y, REL_X + +from inputremapper.injection.macros.argument import ArgumentConfig +from inputremapper.injection.macros.macro import InjectEventCallback +from inputremapper.injection.macros.tasks.mouse_xy import MouseXYTask + + +class MouseTask(MouseXYTask): + """Move the mouse cursor.""" + + argument_configs = [ + ArgumentConfig( + name="direction", + position=0, + types=[str], + ), + ArgumentConfig( + name="speed", + position=1, + types=[int, float], + ), + ArgumentConfig( + name="acceleration", + position=2, + types=[int, float], + default=1, + ), + ] + + async def run(self, callback: InjectEventCallback) -> None: + direction = self.get_argument("direction").get_value() + speed = self.get_argument("speed").get_value() + acceleration = self.get_argument("acceleration").get_value() + + code, direction = { + "up": (REL_Y, -1), + "down": (REL_Y, 1), + "left": (REL_X, -1), + "right": (REL_X, 1), + }[direction.lower()] + + await self.axis( + code, + direction * speed, + acceleration, + callback, + ) diff --git a/inputremapper/injection/macros/tasks/mouse_xy.py b/inputremapper/injection/macros/tasks/mouse_xy.py new file mode 100644 index 0000000..a499efa --- /dev/null +++ b/inputremapper/injection/macros/tasks/mouse_xy.py @@ -0,0 +1,99 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + +from __future__ import annotations + +import asyncio +from typing import Union + +from evdev._ecodes import REL_Y, REL_X +from evdev.ecodes import EV_REL + +from inputremapper.injection.macros.argument import ArgumentConfig +from inputremapper.injection.macros.macro import InjectEventCallback +from inputremapper.injection.macros.task import Task +from inputremapper.injection.macros.tasks.util import precise_iteration_frequency + + +class MouseXYTask(Task): + """Move the mouse cursor.""" + + argument_configs = [ + ArgumentConfig( + name="x", + position=0, + types=[int, float], + default=0, + ), + ArgumentConfig( + name="y", + position=1, + types=[int, float], + default=0, + ), + ArgumentConfig( + name="acceleration", + position=2, + types=[int, float], + default=1, + ), + ] + + async def run(self, callback: InjectEventCallback) -> None: + x = self.get_argument("x").get_value() + y = self.get_argument("y").get_value() + acceleration = self.get_argument("acceleration").get_value() + await asyncio.gather( + self.axis(REL_X, x, acceleration, callback), + self.axis(REL_Y, y, acceleration, callback), + ) + + async def axis( + self, + code: int, + speed: Union[int, float], + fractional_acceleration: Union[int, float], + callback: InjectEventCallback, + ) -> None: + acceleration = speed * fractional_acceleration + direction = -1 if speed < 0 else 1 + current_speed = 0.0 + displacement_accumulator = 0.0 + displacement = 0 + if acceleration <= 0: + displacement = int(speed) + + async for _ in precise_iteration_frequency(self.mapping.rel_rate): + if not self.is_holding(): + return + + # Cursors can only move by integers. To get smooth acceleration for + # small acceleration values, the cursor needs to move by a pixel every + # few iterations. This can be achieved by remembering the decimal + # places that were cast away, and using them for the next iteration. + if acceleration: + current_speed += acceleration + current_speed = direction * min(abs(current_speed), abs(speed)) + displacement_accumulator += current_speed + displacement = int(displacement_accumulator) + displacement_accumulator -= displacement + + if displacement != 0: + callback(EV_REL, code, displacement) diff --git a/inputremapper/injection/macros/tasks/parallel.py b/inputremapper/injection/macros/tasks/parallel.py new file mode 100644 index 0000000..d875474 --- /dev/null +++ b/inputremapper/injection/macros/tasks/parallel.py @@ -0,0 +1,45 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + +from __future__ import annotations + +import asyncio +from typing import List + +from inputremapper.injection.macros.argument import ArgumentConfig, ArgumentFlags +from inputremapper.injection.macros.macro import Macro, InjectEventCallback +from inputremapper.injection.macros.task import Task + + +class ParallelTask(Task): + """Run all provided macros in parallel.""" + + argument_configs = [ + ArgumentConfig( + name="*macros", + position=ArgumentFlags.spread, + types=[Macro], + ), + ] + + async def run(self, callback: InjectEventCallback) -> None: + macros: List[Macro] = self.get_argument("*macros").get_values() + coroutines = [macro.run(callback) for macro in macros] + await asyncio.gather(*coroutines) diff --git a/inputremapper/injection/macros/tasks/repeat.py b/inputremapper/injection/macros/tasks/repeat.py new file mode 100644 index 0000000..f031a1b --- /dev/null +++ b/inputremapper/injection/macros/tasks/repeat.py @@ -0,0 +1,49 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + +from __future__ import annotations + +from inputremapper.injection.macros.argument import ArgumentConfig +from inputremapper.injection.macros.macro import Macro +from inputremapper.injection.macros.task import Task + + +class RepeatTask(Task): + """Repeat macros.""" + + argument_configs = [ + ArgumentConfig( + name="repeats", + position=0, + types=[int], + ), + ArgumentConfig( + name="macro", + position=1, + types=[Macro], + ), + ] + + async def run(self, callback) -> None: + repeats = self.get_argument("repeats").get_value() + macro = self.get_argument("macro").get_value() + + for _ in range(repeats): + await macro.run(callback) diff --git a/inputremapper/injection/macros/tasks/set.py b/inputremapper/injection/macros/tasks/set.py new file mode 100644 index 0000000..382b3aa --- /dev/null +++ b/inputremapper/injection/macros/tasks/set.py @@ -0,0 +1,49 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + +from __future__ import annotations + +from inputremapper.injection.macros.argument import ArgumentConfig +from inputremapper.injection.macros.macro import macro_variables +from inputremapper.injection.macros.task import Task + + +class SetTask(Task): + """Set a variable to a certain value.""" + + argument_configs = [ + ArgumentConfig( + name="variable", + position=0, + types=[str, float, int, None], + is_variable_name=True, + ), + ArgumentConfig( + name="value", + position=1, + types=[str, float, int, None], + default=None, + ), + ] + + async def run(self, callback) -> None: + value = self.get_argument("value").get_value() + assert macro_variables.is_alive() + self.arguments["variable"].set_value(value) diff --git a/inputremapper/injection/macros/tasks/toggle.py b/inputremapper/injection/macros/tasks/toggle.py new file mode 100644 index 0000000..8f21f4a --- /dev/null +++ b/inputremapper/injection/macros/tasks/toggle.py @@ -0,0 +1,56 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + +from __future__ import annotations + +import asyncio + +from inputremapper.injection.macros.argument import ArgumentConfig +from inputremapper.injection.macros.macro import Macro +from inputremapper.injection.macros.task import Task + + +class ToggleTask(Task): + """Toggle macros.""" + + argument_configs = [ + ArgumentConfig( + name="macro", + position=0, + types=[Macro], + ), + ] + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.trigger_count = 0 + + def press_trigger(self) -> None: + Task.press_trigger(self) + self.trigger_count += 1 + + async def run(self, callback) -> None: + macro = self.get_argument("macro").get_value() + + while self.trigger_count <= 1: + await macro.run(callback) + await asyncio.sleep(1 / 1000) + + self.trigger_count = 0 diff --git a/inputremapper/injection/macros/tasks/util.py b/inputremapper/injection/macros/tasks/util.py new file mode 100644 index 0000000..4061bfa --- /dev/null +++ b/inputremapper/injection/macros/tasks/util.py @@ -0,0 +1,45 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + +from __future__ import annotations + +import asyncio +import time +from typing import AsyncIterator + + +async def precise_iteration_frequency(frequency: float) -> AsyncIterator[None]: + """A generator to iterate over in a fixed frequency. + + asyncio.sleep might end up sleeping too long, for whatever reason. Maybe there are + other async function calls that take longer than expected in the background. + """ + sleep = 1 / frequency + corrected_sleep = sleep + error = 0.0 + + while True: + start = time.time() + + yield + + corrected_sleep -= error + await asyncio.sleep(corrected_sleep) + error = (time.time() - start) - sleep diff --git a/inputremapper/injection/macros/tasks/wait.py b/inputremapper/injection/macros/tasks/wait.py new file mode 100644 index 0000000..b51507a --- /dev/null +++ b/inputremapper/injection/macros/tasks/wait.py @@ -0,0 +1,54 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + +from __future__ import annotations + +import asyncio +import random + +from inputremapper.injection.macros.argument import ArgumentConfig +from inputremapper.injection.macros.task import Task + + +class WaitTask(Task): + """Wait time in milliseconds.""" + + argument_configs = [ + ArgumentConfig( + name="time", + position=0, + types=[float, int], + ), + ArgumentConfig( + name="max_time", + position=1, + types=[float, int, None], + default=None, + ), + ] + + async def run(self, callback) -> None: + time = self.get_argument("time").get_value() + max_time = self.get_argument("max_time").get_value() + + if max_time is not None and max_time > time: + time = random.uniform(time, max_time) + + await asyncio.sleep(time / 1000) diff --git a/inputremapper/injection/macros/tasks/wheel.py b/inputremapper/injection/macros/tasks/wheel.py new file mode 100644 index 0000000..f56bd9d --- /dev/null +++ b/inputremapper/injection/macros/tasks/wheel.py @@ -0,0 +1,76 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + +from __future__ import annotations + +import math + +from evdev.ecodes import ( + EV_REL, + REL_WHEEL_HI_RES, + REL_HWHEEL_HI_RES, + REL_WHEEL, + REL_HWHEEL, +) + +from inputremapper.injection.macros.argument import ArgumentConfig +from inputremapper.injection.macros.task import Task +from inputremapper.injection.macros.tasks.util import precise_iteration_frequency + + +class WheelTask(Task): + """Move the scroll wheel.""" + + argument_configs = [ + ArgumentConfig( + name="direction", + position=0, + types=[str], + ), + ArgumentConfig( + name="speed", + position=1, + types=[int, float], + ), + ] + + async def run(self, callback) -> None: + direction = self.get_argument("direction").get_value() + + # 120, see https://www.kernel.org/doc/html/latest/input/event-codes.html#ev-rel + code, value = { + "up": ([REL_WHEEL, REL_WHEEL_HI_RES], [1 / 120, 1]), + "down": ([REL_WHEEL, REL_WHEEL_HI_RES], [-1 / 120, -1]), + "left": ([REL_HWHEEL, REL_HWHEEL_HI_RES], [1 / 120, 1]), + "right": ([REL_HWHEEL, REL_HWHEEL_HI_RES], [-1 / 120, -1]), + }[direction.lower()] + + speed = self.get_argument("speed").get_value() + remainder = [0.0, 0.0] + + async for _ in precise_iteration_frequency(self.mapping.rel_rate): + if not self.is_holding(): + return + + for i in range(0, 2): + float_value = value[i] * speed + remainder[i] + remainder[i] = math.fmod(float_value, 1) + if abs(float_value) >= 1: + callback(EV_REL, code[i], int(float_value)) diff --git a/inputremapper/injection/macros/variable.py b/inputremapper/injection/macros/variable.py new file mode 100644 index 0000000..a4dffd4 --- /dev/null +++ b/inputremapper/injection/macros/variable.py @@ -0,0 +1,90 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + +import re +from typing import Any + +from inputremapper.configs.validation_errors import MacroError +from inputremapper.injection.macros.macro import macro_variables + + +class Variable: + """Something that the user passed into a macro function as parameter. + + The value should already be parsed and validated, if const=True, according to the + argument_configs of a given Task. + + Examples: + - const string "KEY_A" in `key(KEY_A)` + - non-const string "foo" in `repeat($foo, key(KEY_A))` (`value` is the name here) + - const Macro `key(a)` in `repeat(1, key(a))` + - const int 1 in `repeat(1, key(a)) + """ + + def __init__(self, value: Any, const: bool) -> None: + if not const and not isinstance(value, str): + raise MacroError(f"Variables require a string name, not {value}") + + self.value = value + self.const = const + + if not const: + self.validate_variable_name() + + def get_name(self) -> str: + """If the variable is not const, return its name.""" + assert not self.const + assert isinstance(self.value, str) + return self.value + + def get_value(self) -> Any: + """Get the variables value from the common variable storage process.""" + if self.const: + return self.value + + return macro_variables.get(self.value) + + def set_value(self, value: Any) -> None: + """Set the variables value across all macros.""" + assert not self.const + macro_variables[self.value] = value + + def validate_variable_name(self) -> None: + """Check if this is a legit variable name. + + Because they could clash with language features. If the macro can be + parsed at all due to a problematic choice of a variable name. + + Allowed examples: "foo", "Foo1234_", "_foo_1234" + Not allowed: "1_foo", "foo=blub", "$foo", "foo,1234", "foo()" + """ + if not isinstance(self.value, str) or not re.match( + r"^[A-Za-z_][A-Za-z_0-9]*$", self.value + ): + raise MacroError(msg=f'"{self.value}" is not a legit variable name') + + def __repr__(self) -> str: + return f'' + + def __eq__(self, other) -> bool: + if not isinstance(other, Variable): + return False + + return self.const == other.const and self.value == other.value diff --git a/inputremapper/injection/mapping_handlers/__init__.py b/inputremapper/injection/mapping_handlers/__init__.py new file mode 100644 index 0000000..0006fb4 --- /dev/null +++ b/inputremapper/injection/mapping_handlers/__init__.py @@ -0,0 +1,18 @@ +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . diff --git a/inputremapper/injection/mapping_handlers/abs_to_abs_handler.py b/inputremapper/injection/mapping_handlers/abs_to_abs_handler.py new file mode 100644 index 0000000..836db6c --- /dev/null +++ b/inputremapper/injection/mapping_handlers/abs_to_abs_handler.py @@ -0,0 +1,152 @@ +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + +from typing import Tuple, Optional, Dict, List + +import evdev +from evdev.ecodes import EV_ABS + +from inputremapper import exceptions +from inputremapper.configs.input_config import InputCombination, InputConfig +from inputremapper.configs.mapping import Mapping +from inputremapper.injection.global_uinputs import GlobalUInputs +from inputremapper.injection.mapping_handlers.axis_transform import Transformation +from inputremapper.injection.mapping_handlers.mapping_handler import ( + HandlerEnums, + MappingHandler, +) +from inputremapper.input_event import InputEvent, EventActions +from inputremapper.logging.logger import logger +from inputremapper.utils import get_evdev_constant_name + + +class AbsToAbsHandler(MappingHandler): + """Handler which transforms EV_ABS to EV_ABS events.""" + + _map_axis: InputConfig # the InputConfig for the axis we map + _output_axis: Tuple[int, int] # the (type, code) of the output axis + _transform: Optional[Transformation] + _target_absinfo: evdev.AbsInfo + + def __init__( + self, + combination: InputCombination, + mapping: Mapping, + global_uinputs: GlobalUInputs, + **_, + ) -> None: + super().__init__(combination, mapping, global_uinputs) + + # find the input event we are supposed to map. If the input combination is + # BTN_A + ABS_X + BTN_B, then use the value of ABS_X for the transformation + assert (map_axis := combination.find_analog_input_config(type_=EV_ABS)) + self._map_axis = map_axis + + assert mapping.output_code is not None + assert mapping.output_type == EV_ABS + self._output_axis = (mapping.output_type, mapping.output_code) + + target_uinput = global_uinputs.get_uinput(mapping.target_uinput) + assert target_uinput is not None + abs_capabilities = target_uinput.capabilities(absinfo=True)[EV_ABS] + self._target_absinfo = dict(abs_capabilities)[mapping.output_code] + + self._transform = None + + def __str__(self): + name = get_evdev_constant_name(*self._map_axis.type_and_code) + return ( + f'AbsToAbsHandler for "{name}" {self._map_axis} ' + f"maps {self._map_axis} to: {self.mapping.get_output_name_constant()} " + f"{self.mapping.get_output_type_code()} at " + f"{self.mapping.target_uinput}" + ) + + def __repr__(self): + return f"<{str(self)} at {hex(id(self))}>" + + def get_children(self) -> List[MappingHandler]: + return [] + + def notify( + self, + event: InputEvent, + source: evdev.InputDevice, + suppress: bool = False, + ) -> bool: + if event.input_match_hash != self._map_axis.input_match_hash: + return False + + if EventActions.recenter in event.actions: + self._write(self._scale_to_target(0)) + return True + + if not self._transform: + absinfo = dict(source.capabilities(absinfo=True)[EV_ABS])[event.code] # type: ignore + self._transform = Transformation( + max_=absinfo.max, + min_=absinfo.min, + deadzone=self.mapping.deadzone, + gain=self.mapping.gain, + expo=self.mapping.expo, + ) + + try: + self._write(self._scale_to_target(self._transform(event.value))) + return True + except (exceptions.UinputNotAvailable, exceptions.EventNotHandled): + return False + + def reset(self) -> None: + self._write(self._scale_to_target(0)) + + def _scale_to_target(self, x: float) -> int: + """Scales an x value between -1 and 1 to an integer between + target_absinfo.min and target_absinfo.max + + input values above 1 or below -1 are clamped to the extreme values + """ + factor = (self._target_absinfo.max - self._target_absinfo.min) / 2 + offset = self._target_absinfo.min + factor + y = factor * x + offset + if y > offset: + return int(min(self._target_absinfo.max, y)) + else: + return int(max(self._target_absinfo.min, y)) + + def _write(self, value: int): + """Inject.""" + try: + self.global_uinputs.write( + (*self._output_axis, value), self.mapping.target_uinput + ) + except OverflowError: + # screwed up the calculation of the event value + logger.error("OverflowError (%s, %s, %s)", *self._output_axis, value) + + def needs_wrapping(self) -> bool: + return len(self.input_configs) > 1 + + def set_sub_handler(self, handler: MappingHandler) -> None: + assert False # cannot have a sub-handler + + def wrap_with(self) -> Dict[InputCombination, HandlerEnums]: + if self.needs_wrapping(): + return {InputCombination(self.input_configs): HandlerEnums.axisswitch} + return {} diff --git a/inputremapper/injection/mapping_handlers/abs_to_btn_handler.py b/inputremapper/injection/mapping_handlers/abs_to_btn_handler.py new file mode 100644 index 0000000..b826d3a --- /dev/null +++ b/inputremapper/injection/mapping_handlers/abs_to_btn_handler.py @@ -0,0 +1,151 @@ +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + +from typing import List + +import evdev + +from inputremapper.configs.input_config import InputCombination, InputConfig +from inputremapper.configs.mapping import Mapping +from inputremapper.injection.global_uinputs import GlobalUInputs +from inputremapper.injection.mapping_handlers.abs_util import calculate_trigger_point +from inputremapper.injection.mapping_handlers.mapping_handler import ( + MappingHandler, +) +from inputremapper.input_event import InputEvent, EventActions +from inputremapper.utils import get_evdev_constant_name + + +class AbsToBtnHandler(MappingHandler): + """Handler which transforms an EV_ABS to a button event.""" + + _input_config: InputConfig + _configured_direction_was_pressed: bool + _sub_handler: MappingHandler + + def __init__( + self, + combination: InputCombination, + mapping: Mapping, + global_uinputs: GlobalUInputs, + **_, + ) -> None: + super().__init__(combination, mapping, global_uinputs) + + self._configured_direction_was_pressed = False + self._input_config = combination[0] + assert self._input_config.analog_threshold + assert len(combination) == 1 + + def __str__(self): + name = get_evdev_constant_name(*self._input_config.type_and_code) + return f'AbsToBtnHandler for "{name}" ' f"{self._input_config.type_and_code}" + + def __repr__(self): + return f"<{str(self)} at {hex(id(self))}>" + + def get_children(self) -> List[MappingHandler]: + return [self._sub_handler] + + def notify( + self, + event: InputEvent, + source: evdev.InputDevice, + suppress: bool = False, + ) -> bool: + if event.input_match_hash != self._input_config.input_match_hash: + return False + + analog_threshold = self._input_config.analog_threshold + assert analog_threshold is not None + + threshold, mid_point = calculate_trigger_point( + event, + analog_threshold, + source, + ) + + value = event.value + + direction = 1 if value > mid_point else -1 + + want_positive = analog_threshold > 0 + want_negative = analog_threshold < 0 + + # For dpads, the threshold is 1, but so is the max value. So <= and >= it is. + # If this is dumb, change the threhsold to be a float. + pressed = value >= threshold if want_positive else value <= threshold + + '''print(f"""abs_to_btn + {pressed=} + {value=} + {want_positive=} + {want_negative=} + {direction=} + {threshold=} + {mid_point=} + {analog_threshold=} + {self._configured_direction_was_pressed=}""" + )''' + + # dpad-right to a: + # dpad moves right: a down + # dpad returns: a up + # dpad goes left: dpad -1 + # dpad returns: dpad 0 + # There are two "dpad returns" cases that have different outcomes + + # joystick-right to a: + # joystick moves to +1234: ignore (If the architecture could do it, forward 0) + # joystick moves over threshold: a down + # joystick returns below threshold: a up + # joystick moves -1234: forward -1234 + # joystick goes to 0: forward 0 + # (In many cases it won't exactly return to 0, but to +1 or something, because + # they aren't 100% precise. But the positive direction is mapped, so turn + # this into 0. Unfortunately there is currently no way to do this in our + # architecture.) + + if not self._configured_direction_was_pressed: + # these needs to be <= and >= mid point, to forward the dpad release for + # the unmapped direction + if want_positive and value <= mid_point: + return False + if want_negative and value >= mid_point: + return False + + # if it was pressed, then we first need to deal with releasing the sub-handler. + + self._configured_direction_was_pressed = pressed + + event = event.modify( + pressed=pressed, + direction=direction, + actions=(EventActions.as_key,), + ) + + return self._sub_handler.notify( + event, + source=source, + suppress=suppress, + ) + + def reset(self) -> None: + self._configured_direction_was_pressed = False + self._sub_handler.reset() diff --git a/inputremapper/injection/mapping_handlers/abs_to_rel_handler.py b/inputremapper/injection/mapping_handlers/abs_to_rel_handler.py new file mode 100644 index 0000000..88a2d7e --- /dev/null +++ b/inputremapper/injection/mapping_handlers/abs_to_rel_handler.py @@ -0,0 +1,245 @@ +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + +import asyncio +import math +import time +from functools import partial +from typing import Dict, Tuple, Optional, List + +import evdev +from evdev.ecodes import ( + EV_REL, + EV_ABS, + REL_WHEEL, + REL_HWHEEL, + REL_WHEEL_HI_RES, + REL_HWHEEL_HI_RES, +) + +from inputremapper.configs.input_config import InputCombination, InputConfig +from inputremapper.configs.mapping import ( + Mapping, + REL_XY_SCALING, + WHEEL_SCALING, + WHEEL_HI_RES_SCALING, + DEFAULT_REL_RATE, +) +from inputremapper.injection.global_uinputs import GlobalUInputs +from inputremapper.injection.mapping_handlers.axis_transform import Transformation +from inputremapper.injection.mapping_handlers.mapping_handler import ( + HandlerEnums, + MappingHandler, +) +from inputremapper.input_event import InputEvent, EventActions +from inputremapper.logging.logger import logger +from inputremapper.utils import get_evdev_constant_name + + +class AbsToRelHandler(MappingHandler): + """Handler which transforms an EV_ABS to EV_REL events.""" + + _map_axis: InputConfig # the InputConfig for the axis we map + _value: float # the current output value + _running: bool # if the run method is active + _stop: bool # if the run loop should return + _transform: Optional[Transformation] + + def __init__( + self, + combination: InputCombination, + mapping: Mapping, + global_uinputs: GlobalUInputs, + **_, + ) -> None: + super().__init__(combination, mapping, global_uinputs) + + # find the input event we are supposed to map + assert (map_axis := combination.find_analog_input_config(type_=EV_ABS)) + self._map_axis = map_axis + + self._value = 0 + self._running = False + self._stop = True + self._transform = None + + # bind the correct run method + if self.mapping.output_code in ( + REL_WHEEL, + REL_HWHEEL, + REL_WHEEL_HI_RES, + REL_HWHEEL_HI_RES, + ): + if self.mapping.output_code in (REL_WHEEL, REL_WHEEL_HI_RES): + codes = (REL_WHEEL, REL_WHEEL_HI_RES) + else: + codes = (REL_HWHEEL, REL_HWHEEL_HI_RES) + + self._run = partial(self._run_wheel_output, codes=codes) + + else: + self._run = partial(self._run_normal_output) + + def __str__(self): + name = get_evdev_constant_name(*self._map_axis.type_and_code) + return ( + f'AbsToRelHandler for "{name}" {self._map_axis}: ' + f"maps to {self.mapping.get_output_name_constant()} " + f"{self.mapping.get_output_type_code()} at " + f"{self.mapping.target_uinput}" + ) + + def __repr__(self): + return f"<{str(self)} at {hex(id(self))}>" + + def get_children(self) -> List[MappingHandler]: + return [] + + def notify( + self, + event: InputEvent, + source: evdev.InputDevice, + suppress: bool = False, + ) -> bool: + if event.input_match_hash != self._map_axis.input_match_hash: + return False + + if EventActions.recenter in event.actions: + self._stop = True + return True + + if not self._transform: + absinfo = { + entry[0]: entry[1] # type: ignore + for entry in source.capabilities(absinfo=True)[EV_ABS] + } + self._transform = Transformation( + max_=absinfo[event.code].max, + min_=absinfo[event.code].min, + deadzone=self.mapping.deadzone, + gain=self.mapping.gain, + expo=self.mapping.expo, + ) + + transformed = self._transform(event.value) + + self._value = transformed + + if transformed == 0: + self._stop = True + return True + + if not self._running: + asyncio.ensure_future(self._run()) + return True + + def reset(self) -> None: + self._stop = True + + def _write(self, type_, keycode, value): + """Inject.""" + # if the mouse won't move even though correct stuff is written here, + # the capabilities are probably wrong + if value == 0: + # rel 0 does not make sense. We don't need to tell linux that the mouse + # should not be moved this time. + return + + try: + self.global_uinputs.write( + (type_, keycode, value), + self.mapping.target_uinput, + ) + except OverflowError: + # screwed up the calculation of mouse movements + logger.error("OverflowError (%s, %s, %s)", type_, keycode, value) + + def needs_wrapping(self) -> bool: + return len(self.input_configs) > 1 + + def set_sub_handler(self, handler: MappingHandler) -> None: + assert False # cannot have a sub-handler + + def wrap_with(self) -> Dict[InputCombination, HandlerEnums]: + if self.needs_wrapping(): + return {InputCombination(self.input_configs): HandlerEnums.axisswitch} + return {} + + def _calculate_output(self, value, weight, remainder): + # self._value is between 0 and 1, scale up with weight + scaled = value * weight + remainder + # float_value % 1 will result in wrong calculations for negative values + remainder = math.fmod(scaled, 1) + return int(scaled), remainder + + async def _run_normal_output(self) -> None: + """Start injecting events.""" + self._running = True + self._stop = False + remainder = 0.0 + start = time.time() + + # if the rate is configured to be slower than the default, increase the value, so + # that the overall speed stays the same. + rate_compensation = DEFAULT_REL_RATE / self.mapping.rel_rate + weight = REL_XY_SCALING * rate_compensation + + while not self._stop: + value, remainder = self._calculate_output( + self._value, + weight, + remainder, + ) + + self._write(EV_REL, self.mapping.output_code, value) + + time_taken = time.time() - start + sleep = max(0.0, (1 / self.mapping.rel_rate) - time_taken) + await asyncio.sleep(sleep) + start = time.time() + + self._running = False + + async def _run_wheel_output(self, codes: Tuple[int, int]) -> None: + """Start injecting wheel events. + + made to inject both REL_WHEEL and REL_WHEEL_HI_RES events, because otherwise + wheel output doesn't work for some people. See issue #354 + """ + weights = (WHEEL_SCALING, WHEEL_HI_RES_SCALING) + + self._running = True + self._stop = False + remainder = [0.0, 0.0] + start = time.time() + while not self._stop: + for i in range(len(codes)): + value, remainder[i] = self._calculate_output( + self._value, + weights[i], + remainder[i], + ) + + self._write(EV_REL, codes[i], value) + + time_taken = time.time() - start + await asyncio.sleep(max(0.0, (1 / self.mapping.rel_rate) - time_taken)) + start = time.time() + + self._running = False diff --git a/inputremapper/injection/mapping_handlers/abs_util.py b/inputremapper/injection/mapping_handlers/abs_util.py new file mode 100644 index 0000000..7b674fd --- /dev/null +++ b/inputremapper/injection/mapping_handlers/abs_util.py @@ -0,0 +1,72 @@ +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + +from typing import Tuple + +import evdev +from evdev.ecodes import EV_ABS, ABS_GAS, ABS_BRAKE, ABS_Z, ABS_RZ + +from inputremapper.input_event import InputEvent + + +# TODO: potentially cache this function { cache_key_from_args: previous_result } +def calculate_trigger_point( + event: InputEvent, + analog_threshold: int, + source: evdev.InputDevice, +) -> Tuple[float, float]: + """Calculate the threshold and resting-point of the axis. + + If an EV_ABS events value suprasses the threshold, it should be considered pressed. + + The threshold is the offset from the resting-point/middle in both directions. + + The resting point might be the middle value for a joystick: 0, *128*, 256 or + -128, *0*, 128. Or it might be the minimum value of the shoulder triggers: *0* 256. + """ + absinfo = dict(source.capabilities(absinfo=True)[EV_ABS]) # type: ignore + abs_min = absinfo[event.code].min + abs_max = absinfo[event.code].max + + assert analog_threshold + if abs_min == -1 and abs_max == 1: + # this is a hat switch + # return +-1 + return ( + analog_threshold // abs(analog_threshold), + 0, + ) + + if event.code in [ABS_GAS, ABS_BRAKE, ABS_Z, ABS_RZ]: + threshold = abs_max * analog_threshold / 100 + # For the L/R triggers, there is only one direction, and the resting + # position is the same as the min_abs. + middle = abs_min + return threshold, middle + + half_range = (abs_max - abs_min) / 2 + middle = half_range + abs_min + trigger_offset = half_range * analog_threshold / 100 + # Examples for threshold of +50: + # -128 to 128. half_range is 128. middle is 0. trigger_offset is 64 (and above) + # 0 to 128. half_range is 64. middle is 64. trigger_offset is 96 (and above) + + # threshold, middle + threshold = middle + trigger_offset + return threshold, middle diff --git a/inputremapper/injection/mapping_handlers/axis_switch_handler.py b/inputremapper/injection/mapping_handlers/axis_switch_handler.py new file mode 100644 index 0000000..a2ac18f --- /dev/null +++ b/inputremapper/injection/mapping_handlers/axis_switch_handler.py @@ -0,0 +1,190 @@ +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + +from typing import Dict, Tuple, Hashable, List + +import evdev + +from inputremapper.configs.input_config import InputCombination +from inputremapper.configs.input_config import InputConfig +from inputremapper.configs.mapping import Mapping +from inputremapper.injection.global_uinputs import GlobalUInputs +from inputremapper.injection.mapping_handlers.mapping_handler import ( + HandlerEnums, + MappingHandler, + ContextProtocol, +) +from inputremapper.input_event import InputEvent, EventActions +from inputremapper.logging.logger import logger +from inputremapper.utils import get_device_hash + + +class AxisSwitchHandler(MappingHandler): + """Enables or disables an axis. + + This is used when a combination involving an analog input (rel or abs) is mapped + to another analog output (rel or abs). I think. + + Generally, if multiple events are mapped to something in a combination, all of + them need to be triggered in order to map to the output. + + If an analog input is combined with a key input, then the same thing should happen. + The key needs to be pressed and the joystick needs to be moved in order to generate + output. + """ + + _map_axis: InputConfig # the InputConfig for the axis we switch on or off + _trigger_keys: Tuple[Hashable, ...] # all events that can switch the axis + _active: bool # whether the axis is on or off + _last_value: int # the value of the last axis event that arrived + _axis_source: evdev.InputDevice # the cached source of the axis input events + _forward_device: evdev.UInput # the cached forward uinput + _sub_handler: MappingHandler + + def __init__( + self, + combination: InputCombination, + mapping: Mapping, + context: ContextProtocol, + global_uinputs: GlobalUInputs, + **_, + ): + super().__init__(combination, mapping, global_uinputs) + trigger_keys = tuple( + event.input_match_hash + for event in combination + if not event.defines_analog_input + ) + assert len(trigger_keys) >= 1 + assert (map_axis := combination.find_analog_input_config()) + self._map_axis = map_axis + self._trigger_keys = trigger_keys + self._active = False + + self._last_value = 0 + self._axis_source = None + self._forward_device = None + + self.context = context + + def __str__(self): + return f"AxisSwitchHandler for {self._map_axis.type_and_code}" + + def __repr__(self): + return f"<{str(self)} at {hex(id(self))}>" + + def get_children(self) -> List[MappingHandler]: + return [self._sub_handler] + + def _handle_key_input(self, event: InputEvent): + """If a key is pressed, allow mapping analog events in subhandlers. + + Analog events (e.g. ABS_X, REL_Y) that have gone through Handlers that + transform them to buttons also count as keys. + """ + key_is_pressed = event.is_pressed() + if self._active == key_is_pressed: + # nothing changed + return False + + self._active = key_is_pressed + + if self._axis_source is None: + return True + + if not key_is_pressed: + # recenter the axis + logger.debug("Stopping axis for %s", self.mapping.input_combination) + event = InputEvent( + 0, + 0, + *self._map_axis.type_and_code, + 0, + actions=(EventActions.recenter,), + origin_hash=self._map_axis.origin_hash, + ) + self._sub_handler.notify(event, self._axis_source) + return True + + if self._map_axis.type == evdev.ecodes.EV_ABS: + # send the last cached value so that the abs axis + # is at the correct position + logger.debug("Starting axis for %s", self.mapping.input_combination) + event = InputEvent( + 0, + 0, + *self._map_axis.type_and_code, + self._last_value, + origin_hash=self._map_axis.origin_hash, + ) + self._sub_handler.notify(event, self._axis_source) + return True + + return True + + def _should_map(self, event: InputEvent): + return ( + event.input_match_hash in self._trigger_keys + or event.input_match_hash == self._map_axis.input_match_hash + ) + + def notify( + self, + event: InputEvent, + source: evdev.InputDevice, + suppress: bool = False, + ) -> bool: + if not self._should_map(event): + return False + + if event.is_key_event: + # A key or an analog even that is being treated as a key (on/off) due to + # previous handlers. + return self._handle_key_input(event) + + # do some caching so that we can generate the + # recenter event and an initial abs event + if self._axis_source is None: + self._axis_source = source + + if self._forward_device is None: + device_hash = get_device_hash(source) + self._forward_device = self.context.get_forward_uinput(device_hash) + + # always cache the value + self._last_value = event.value + + if self._active: + return self._sub_handler.notify(event, source, suppress) + + return False + + def reset(self) -> None: + self._last_value = 0 + self._active = False + self._sub_handler.reset() + + def needs_wrapping(self) -> bool: + return True + + def wrap_with(self) -> Dict[InputCombination, HandlerEnums]: + combination = [ + config for config in self.input_configs if not config.defines_analog_input + ] + return {InputCombination(combination): HandlerEnums.combination} diff --git a/inputremapper/injection/mapping_handlers/axis_transform.py b/inputremapper/injection/mapping_handlers/axis_transform.py new file mode 100644 index 0000000..fb5f8e3 --- /dev/null +++ b/inputremapper/injection/mapping_handlers/axis_transform.py @@ -0,0 +1,140 @@ +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + +import math +from typing import Dict, Union + + +class Transformation: + """Callable that returns the axis transformation at x.""" + + def __init__( + self, + # if input values are > max_, the return value will be > 1 + max_: Union[int, float], + min_: Union[int, float], + deadzone: float, + gain: float = 1, + expo: float = 0, + ) -> None: + self._max = max_ + self._min = min_ + self._deadzone = deadzone + self._gain = gain + self._expo = expo + self._cache: Dict[float, float] = {} + + def __call__(self, /, x: Union[int, float]) -> float: + if x not in self._cache: + y = ( + self._calc_qubic(self._flatten_deadzone(self._normalize(x))) + * self._gain + ) + self._cache[x] = y + + return self._cache[x] + + def set_range(self, min_, max_): + # TODO docstring + if min_ != self._min or max_ != self._max: + self._cache = {} + + self._min = min_ + self._max = max_ + + def _normalize(self, x: Union[int, float]) -> float: + """Move and scale x to be between -1 and 1 + return: x + """ + if self._min == -1 and self._max == 1: + return x + + half_range = (self._max - self._min) / 2 + middle = half_range + self._min + return (x - middle) / half_range + + def _flatten_deadzone(self, x: float) -> float: + """ + y ^ y ^ + | | + 1 | / 1 | / + | / | / + | / ==> | --- + | / | / + -1 | / -1 | / + |------------> |------------> + -1 1 x -1 1 x + """ + if abs(x) <= self._deadzone: + return 0 + + return (x - self._deadzone * x / abs(x)) / (1 - self._deadzone) + + def _calc_qubic(self, x: float) -> float: + """Transforms an x value by applying a qubic function + + k = 0 : will yield no transformation f(x) = x + 1 > k > 0 : will yield low sensitivity for low x values + and high sensitivity for high x values + -1 < k < 0 : will yield high sensitivity for low x values + and low sensitivity for high x values + + see also: https://www.geogebra.org/calculator/mkdqueky + + Mathematical definition: + f(x,d) = d * x + (1 - d) * x ** 3 | d = 1 - k | k ∈ [0,1] + the function is designed such that if follows these constraints: + f'(0, d) = d and f(1, d) = 1 and f(-x,d) = -f(x,d) + + for k ∈ [-1,0) the above function is mirrored at y = x + and d = 1 + k + """ + k = self._expo + + if k == 0 or x == 0: + return x + + if 0 < k <= 1: + d = 1 - k + return d * x + (1 - d) * x**3 + + if -1 <= k < 0: + # calculate return value with the real inverse solution + # of y = b * x + a * x ** 3 + # LaTeX for better readability: + # + # y=\frac{{{\left( \sqrt{27 {{x}^{2}}+\frac{4 {{b}^{3}}}{a}} + # +{{3}^{\frac{3}{2}}} x\right) }^{\frac{1}{3}}}} + # {{{2}^{\frac{1}{3}}} \sqrt{3} {{a}^{\frac{1}{3}}}} + # -\frac{{{2}^{\frac{1}{3}}} b} + # {\sqrt{3} {{a}^{\frac{2}{3}}} + # {{\left( \sqrt{27 {{x}^{2}}+\frac{4 {{b}^{3}}}{a}} + # +{{3}^{\frac{3}{2}}} x\right) }^{\frac{1}{3}}}} + sign = x / abs(x) + x = math.fabs(x) + d = 1 + k + a = 1 - d + b = d + c = (math.sqrt(27 * x**2 + (4 * b**3) / a) + 3 ** (3 / 2) * x) ** (1 / 3) + y = c / (2 ** (1 / 3) * math.sqrt(3) * a ** (1 / 3)) - ( + 2 ** (1 / 3) * b + ) / (math.sqrt(3) * a ** (2 / 3) * c) + return y * sign + + raise ValueError("k must be between -1 and 1") diff --git a/inputremapper/injection/mapping_handlers/combination_handler.py b/inputremapper/injection/mapping_handlers/combination_handler.py new file mode 100644 index 0000000..fe21de4 --- /dev/null +++ b/inputremapper/injection/mapping_handlers/combination_handler.py @@ -0,0 +1,275 @@ +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + +from __future__ import annotations # needed for the TYPE_CHECKING import + +from typing import TYPE_CHECKING, Dict, Hashable, Tuple, List + +import evdev +from evdev.ecodes import EV_ABS, EV_REL + +from inputremapper.configs.input_config import InputCombination +from inputremapper.configs.mapping import Mapping +from inputremapper.injection.global_uinputs import GlobalUInputs +from inputremapper.injection.mapping_handlers.mapping_handler import ( + MappingHandler, + HandlerEnums, +) +from inputremapper.input_event import InputEvent +from inputremapper.logging.logger import logger + +if TYPE_CHECKING: + from inputremapper.injection.context import Context + + +class CombinationHandler(MappingHandler): + """Keeps track of a combination and notifies a sub handler.""" + + # map of InputEvent.input_match_hash -> bool , keep track of the combination state + _pressed_keys: Dict[Hashable, bool] + # the last update we sent to a sub-handler. If this is true, the output key is + # still being held down. + _output_previously_active: bool + _sub_handler: MappingHandler + _handled_input_hashes: list[Hashable] + _requires_a_release: Dict[Tuple[int, int], bool] + + def __init__( + self, + combination: InputCombination, + mapping: Mapping, + context: Context, + global_uinputs: GlobalUInputs, + **_, + ) -> None: + logger.debug(str(mapping)) + super().__init__(combination, mapping, global_uinputs) + self._pressed_keys = {} + self._output_previously_active = False + self._context = context + self._requires_a_release = {} + + # prepare a key map for all events with non-zero value + for input_config in combination: + assert not input_config.defines_analog_input + self._pressed_keys[input_config.input_match_hash] = False + + self._handled_input_hashes = [ + input_config.input_match_hash for input_config in combination + ] + + assert len(self._pressed_keys) > 0 # no combination handler without a key + + def __str__(self): + return ( + f'CombinationHandler for "{str(self.mapping.input_combination)}" ' + f"{tuple(t for t in self._pressed_keys.keys())}" + ) + + def __repr__(self): + description = ( + f'CombinationHandler for "{repr(self.mapping.input_combination)}" ' + f"{tuple(t for t in self._pressed_keys.keys())}" + ) + return f"<{description} at {hex(id(self))}>" + + def get_children(self) -> List[MappingHandler]: + return [self._sub_handler] + + def notify( + self, + event: InputEvent, + source: evdev.InputDevice, + suppress: bool = False, + ) -> bool: + if event.input_match_hash not in self._handled_input_hashes: + # we are not responsible for the event + return False + + # update the state + # The value of non-key input should have been changed to either 0 or 1 at this + # point by other handlers. + is_pressed = event.is_pressed() + self._pressed_keys[event.input_match_hash] = is_pressed + # maybe this changes the activation status (triggered/not-triggered) + changed = self._is_activated() != self._output_previously_active + + if changed: + if is_pressed: + return self._handle_freshly_activated(suppress, event, source) + else: + return self._handle_freshly_deactivated(event, source) + else: + if is_pressed: + return self._handle_no_change_press(event) + else: + return self._handle_no_change_release(event) + + def _handle_no_change_press(self, event: InputEvent) -> bool: + """A key was pressed, but this doesn't change the combinations activation state. + Can only happen if either the combination wasn't already active, or a duplicate + key-down event arrived (EV_ABS?) + """ + # self._output_previously_active is negated, because if the output is active, a + # key-down event triggered it, which then did not get forwarded, therefore + # it doesn't require a release. + self._require_release_later(not self._output_previously_active, event) + # output is active: consume the event + # output inactive: forward the event + return self._output_previously_active + + def _handle_no_change_release(self, event: InputEvent) -> bool: + """One of the combinations keys was released, but it didn't untrigger the + combination yet.""" + # Negate: `False` means that the event-reader will forward the release. + return not self._should_release_event(event) + + def _handle_freshly_activated( + self, + suppress: bool, + event: InputEvent, + source: evdev.InputDevice, + ) -> bool: + """The combination was deactivated, but is activated now.""" + if suppress: + return False + + # Send key up events to the forwarded uinput if configured to do so. + self._forward_release() + + logger.debug( + "Sending %s to sub-handler %s", + repr(event), + repr(self._sub_handler), + ) + self._output_previously_active = event.is_pressed() + sub_handler_result = self._sub_handler.notify(event, source, suppress) + + # Only if the sub-handler return False, we need a release-event later. + # If it handled the event, the user never sees this key-down event. + self._require_release_later(not sub_handler_result, event) + return sub_handler_result + + def _handle_freshly_deactivated( + self, + event: InputEvent, + source: evdev.InputDevice, + ) -> bool: + """The combination was activated, but is deactivated now.""" + # We ignore the `suppress` argument for release events. Otherwise, we + # might end up with stuck keys (test_event_pipeline.test_combination). + # In the case of output axis, this will enable us to activate multiple + # axis with the same button. + + logger.debug( + "Sending %s to sub-handler %s", + repr(event), + repr(self._sub_handler), + ) + self._output_previously_active = event.is_pressed() + self._sub_handler.notify(event, source, suppress=False) + + # Negate: `False` means that the event-reader will forward the release. + return not self._should_release_event(event) + + def _should_release_event(self, event: InputEvent) -> bool: + """Check if the key-up event should be forwarded by the event-reader. + + After this, the release event needs to be injected by someone, otherwise the + dictionary was modified erroneously. If there is no entry, we assume that there + was no key-down event to release. Maybe a duplicate event arrived. + """ + # Ensure that all injected key-down events will get their release event + # injected eventually. + # If a key-up event arrives that will inactivate the combination, but + # for which previously a key-down event was injected (because it was + # an earlier key in the combination chain), then we need to ensure that its + # release is injected as well. So we get two release events in that case: + # one for the key, and one for the output. + assert event.is_pressed() == 0, f"expected {event.is_pressed()} to be 0" + return self._requires_a_release.pop(event.type_and_code, False) + + def _require_release_later(self, require: bool, event: InputEvent) -> None: + """Remember if this key-down event will need a release event later on.""" + assert event.is_pressed() == 1 + self._requires_a_release[event.type_and_code] = require + + def reset(self) -> None: + self._sub_handler.reset() + for key in self._pressed_keys: + self._pressed_keys[key] = False + self._requires_a_release = {} + self._output_previously_active = False + + def _is_activated(self) -> bool: + """Return if all keys in the keymap are set to True.""" + return False not in self._pressed_keys.values() + + def _forward_release(self) -> None: + """Forward a button release for all keys if this is a combination. + + This might cause duplicate key-up events but those are ignored by evdev anyway + """ + if len(self._pressed_keys) == 1 or not self.mapping.release_combination_keys: + return + + keys_to_release = filter( + lambda cfg: self._pressed_keys.get(cfg.input_match_hash), + self.mapping.input_combination, + ) + + logger.debug("Forwarding release for %s", self.mapping.input_combination) + + for input_config in keys_to_release: + if not self._requires_a_release.get(input_config.type_and_code): + continue + + origin_hash = input_config.origin_hash + if origin_hash is None: + logger.error( + f"Can't forward due to missing origin_hash in {repr(input_config)}" + ) + continue + + forward_to = self._context.get_forward_uinput(origin_hash) + logger.write(input_config, forward_to) + forward_to.write(*input_config.type_and_code, 0) + forward_to.syn() + + # We are done with this key, forget about it + del self._requires_a_release[input_config.type_and_code] + + def needs_ranking(self) -> bool: + return bool(self.input_configs) + + def rank_by(self) -> InputCombination: + return InputCombination( + [event for event in self.input_configs if not event.defines_analog_input] + ) + + def wrap_with(self) -> Dict[InputCombination, HandlerEnums]: + return_dict = {} + for config in self.input_configs: + if config.type == EV_ABS and not config.defines_analog_input: + return_dict[InputCombination([config])] = HandlerEnums.abs2btn + + if config.type == EV_REL and not config.defines_analog_input: + return_dict[InputCombination([config])] = HandlerEnums.rel2btn + + return return_dict diff --git a/inputremapper/injection/mapping_handlers/hierarchy_handler.py b/inputremapper/injection/mapping_handlers/hierarchy_handler.py new file mode 100644 index 0000000..a53f22a --- /dev/null +++ b/inputremapper/injection/mapping_handlers/hierarchy_handler.py @@ -0,0 +1,108 @@ +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + +from typing import List, Dict + +import evdev +from evdev.ecodes import EV_ABS, EV_REL + +from inputremapper.configs.input_config import InputCombination, InputConfig +from inputremapper.injection.global_uinputs import GlobalUInputs +from inputremapper.injection.mapping_handlers.mapping_handler import ( + MappingHandler, + HandlerEnums, +) +from inputremapper.input_event import InputEvent + + +class HierarchyHandler(MappingHandler): + """Handler consisting of an ordered list of MappingHandler + + only the first handler which successfully handles the event will execute it, + all other handlers will be notified, but suppressed + """ + + _input_config: InputConfig + + def __init__( + self, + handlers: List[MappingHandler], + input_config: InputConfig, + global_uinputs: GlobalUInputs, + ) -> None: + self.handlers = handlers + self._input_config = input_config + combination = InputCombination([input_config]) + # use the mapping from the first child TODO: find a better solution + mapping = handlers[0].mapping + super().__init__(combination, mapping, global_uinputs) + + def __str__(self): + return f"HierarchyHandler for {self._input_config}" + + def __repr__(self): + return f"<{str(self)} at {hex(id(self))}>" + + def get_children(self) -> List[MappingHandler]: + return self.handlers + + def notify( + self, + event: InputEvent, + source: evdev.InputDevice, + suppress: bool = False, + ) -> bool: + if event.input_match_hash != self._input_config.input_match_hash: + return False + + handled = False + for handler in self.handlers: + if handled: + # To allow an arbitrary number of output axes to be activated at the + # same time, we don't suppress them. + handler.notify( + event, + source, + suppress=not handler.mapping.input_combination.defines_analog_input, + ) + continue + + handled = handler.notify(event, source) + + return handled + + def reset(self) -> None: + for sub_handler in self.handlers: + sub_handler.reset() + + def wrap_with(self) -> Dict[InputCombination, HandlerEnums]: + if ( + self._input_config.type == EV_ABS + and not self._input_config.defines_analog_input + ): + return {InputCombination([self._input_config]): HandlerEnums.abs2btn} + if ( + self._input_config.type == EV_REL + and not self._input_config.defines_analog_input + ): + return {InputCombination([self._input_config]): HandlerEnums.rel2btn} + return {} + + def set_sub_handler(self, handler: MappingHandler) -> None: + assert False diff --git a/inputremapper/injection/mapping_handlers/key_handler.py b/inputremapper/injection/mapping_handlers/key_handler.py new file mode 100644 index 0000000..fa64436 --- /dev/null +++ b/inputremapper/injection/mapping_handlers/key_handler.py @@ -0,0 +1,90 @@ +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + +from typing import Tuple, Dict, List + +from inputremapper import exceptions +from inputremapper.configs.input_config import InputCombination +from inputremapper.configs.mapping import Mapping +from inputremapper.exceptions import MappingParsingError +from inputremapper.injection.global_uinputs import GlobalUInputs +from inputremapper.injection.mapping_handlers.mapping_handler import ( + HandlerEnums, + MappingHandler, +) +from inputremapper.input_event import InputEvent +from inputremapper.logging.logger import logger +from inputremapper.utils import get_evdev_constant_name + + +class KeyHandler(MappingHandler): + """Injects the target key if notified.""" + + _active: bool + _maps_to: Tuple[int, int] + + def __init__( + self, + combination: InputCombination, + mapping: Mapping, + global_uinputs: GlobalUInputs, + **_, + ): + super().__init__(combination, mapping, global_uinputs) + maps_to = mapping.get_output_type_code() + if not maps_to: + raise MappingParsingError( + "Unable to create key handler from mapping", mapping=mapping + ) + + self._maps_to = maps_to + self._active = False + + def __str__(self): + name = get_evdev_constant_name(*self._maps_to) + return f"KeyHandler to {name} {self._maps_to} on {self.mapping.target_uinput}" + + def __repr__(self): + return f"<{str(self)} at {hex(id(self))}>" + + def get_children(self) -> List[MappingHandler]: + return [] + + def notify(self, event: InputEvent, *_, **__) -> bool: + """Inject the correct value to the target uinput.""" + event_tuple = (*self._maps_to, 1 if event.is_pressed() else 0) + try: + self.global_uinputs.write(event_tuple, self.mapping.target_uinput) + self._active = bool(event.is_pressed()) + return True + except exceptions.Error: + return False + + def reset(self) -> None: + logger.debug("resetting key_handler") + if self._active: + event_tuple = (*self._maps_to, 0) + self.global_uinputs.write(event_tuple, self.mapping.target_uinput) + self._active = False + + def needs_wrapping(self) -> bool: + return True + + def wrap_with(self) -> Dict[InputCombination, HandlerEnums]: + return {InputCombination(self.input_configs): HandlerEnums.combination} diff --git a/inputremapper/injection/mapping_handlers/macro_handler.py b/inputremapper/injection/mapping_handlers/macro_handler.py new file mode 100644 index 0000000..38ed4f3 --- /dev/null +++ b/inputremapper/injection/mapping_handlers/macro_handler.py @@ -0,0 +1,121 @@ +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + +import asyncio +import traceback +from typing import Dict, Callable, Tuple, List + +from inputremapper.configs.input_config import InputCombination +from inputremapper.configs.mapping import Mapping +from inputremapper.injection.global_uinputs import GlobalUInputs +from inputremapper.injection.macros.macro import Macro +from inputremapper.injection.macros.parse import Parser +from inputremapper.injection.mapping_handlers.mapping_handler import ( + ContextProtocol, + HandlerEnums, + MappingHandler, +) +from inputremapper.input_event import InputEvent +from inputremapper.logging.logger import logger + + +class MacroHandler(MappingHandler): + """Runs the target macro if notified.""" + + # TODO: replace this by the macro itself + _macro: Macro + _active: bool + + def __init__( + self, + combination: InputCombination, + mapping: Mapping, + global_uinputs: GlobalUInputs, + *, + context: ContextProtocol, + ): + super().__init__(combination, mapping, global_uinputs) + self._pressed_keys: Dict[Tuple[int, int], int] = {} + self._active = False + assert self.mapping.output_symbol is not None + self._macro = Parser.parse(self.mapping.output_symbol, context, mapping) + + def __str__(self): + return f"MacroHandler maps to {self._macro} on {self.mapping.target_uinput}" + + def __repr__(self): + return f"<{str(self)} at {hex(id(self))}>" + + def get_children(self) -> List[MappingHandler]: + return [] + + async def run_macro(self, handler: Callable): + """Run the macro with the provided function.""" + try: + await self._macro.run(handler) + except Exception as exception: + logger.error('Macro "%s" failed with %s', self._macro.code, type(exception)) + traceback.print_exc() + + def notify(self, event: InputEvent, *_, **__) -> bool: + if event.is_pressed(): + self._active = True + self._macro.press_trigger() + if self._macro.running: + return True + + def handler(type_, code, value) -> None: + """Handler for macros.""" + self._remember_pressed_keys((type_, code, value)) + + self.global_uinputs.write( + (type_, code, value), + self.mapping.target_uinput, + ) + + asyncio.ensure_future(self.run_macro(handler)) + return True + else: + self._active = False + self._macro.release_trigger() + + return True + + def reset(self) -> None: + self._active = False + + # To avoid a key hanging forever. Can be pretty annoying, especially if it is + # a modifier that makes you unable to interact with your system. + for (type, code), value in self._pressed_keys.items(): + if value == 1: + logger.debug("Releasing key %s", (type, code, value)) + self.global_uinputs.write( + (type, code, 0), + self.mapping.target_uinput, + ) + + def needs_wrapping(self) -> bool: + return True + + def wrap_with(self) -> Dict[InputCombination, HandlerEnums]: + return {InputCombination(self.input_configs): HandlerEnums.combination} + + def _remember_pressed_keys(self, event: Tuple[int, int, int]) -> None: + type, code, value = event + self._pressed_keys[(type, code)] = value diff --git a/inputremapper/injection/mapping_handlers/mapping_handler.py b/inputremapper/injection/mapping_handlers/mapping_handler.py new file mode 100644 index 0000000..e9d5306 --- /dev/null +++ b/inputremapper/injection/mapping_handlers/mapping_handler.py @@ -0,0 +1,213 @@ +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . +"""Provides protocols for mapping handlers + +*** The architecture behind mapping handlers *** + +Handling an InputEvent is done in 3 steps: + 1. Input Event Handling + A MappingHandler that does Input event handling receives Input Events directly + from the EventReader. + To do so it must implement the MappingHandler protocol. + An MappingHandler may handle multiple events (InputEvent.type_and_code) + + 2. Event Transformation + The event gets transformed as described by the mapping. + e.g.: combining multiple events to a single one + transforming EV_ABS to EV_REL + macros + ... + Multiple transformations may get chained + + 3. Event Injection + The transformed event gets injected to a global_uinput + +MappingHandlers can implement one or more of these steps. + +Overview of implemented handlers and the steps they implement: + +Step 1: + - HierarchyHandler + +Step 1 and 2: + - CombinationHandler + - AbsToBtnHandler + - RelToBtnHandler + +Step 1, 2 and 3: + - AbsToRelHandler + - NullHandler + +Step 2 and 3: + - KeyHandler + - MacroHandler +""" +from __future__ import annotations + +import enum +from typing import Dict, Protocol, Set, Optional, List + +import evdev + +from inputremapper.configs.input_config import InputCombination, InputConfig +from inputremapper.configs.mapping import Mapping +from inputremapper.exceptions import MappingParsingError +from inputremapper.injection.global_uinputs import GlobalUInputs +from inputremapper.input_event import InputEvent +from inputremapper.logging.logger import logger + + +class EventListener(Protocol): + async def __call__(self, event: evdev.InputEvent) -> None: ... + + +class ContextProtocol(Protocol): + """The parts from context needed for handlers.""" + + listeners: Set[EventListener] + + def get_forward_uinput(self, origin_hash) -> evdev.UInput: + pass + + +class NotifyCallback(Protocol): + """Type signature of MappingHandler.notify + + return True if the event was actually taken care of + """ + + def __call__( + self, + event: InputEvent, + source: evdev.InputDevice, + suppress: bool = False, + ) -> bool: ... + + +class HandlerEnums(enum.Enum): + # converting to btn + abs2btn = enum.auto() + rel2btn = enum.auto() + + macro = enum.auto() + key = enum.auto() + + # converting to "analog" + btn2rel = enum.auto() + rel2rel = enum.auto() + abs2rel = enum.auto() + + btn2abs = enum.auto() + rel2abs = enum.auto() + abs2abs = enum.auto() + + # special handlers + combination = enum.auto() + hierarchy = enum.auto() + axisswitch = enum.auto() + disable = enum.auto() + + +class MappingHandler: + mapping: Mapping + # all input events this handler cares about + # should always be a subset of mapping.input_combination + input_configs: List[InputConfig] + _sub_handler: Optional[MappingHandler] + + # https://bugs.python.org/issue44807 + def __init__( + self, + combination: InputCombination, + mapping: Mapping, + global_uinputs: GlobalUInputs, + **_, + ) -> None: + """Initialize the handler + + Parameters + ---------- + combination + the combination from sub_handler.wrap_with() + mapping + """ + self.mapping = mapping + self.input_configs = list(combination) + self._sub_handler = None + self.global_uinputs = global_uinputs + + def notify( + self, + event: InputEvent, + source: evdev.InputDevice, + suppress: bool = False, + ) -> bool: + """Notify this handler about an incoming event. + + Parameters + ---------- + event + The newest event that came from `source`, and that should be mapped to + something else + source + Where `event` comes from + """ + raise NotImplementedError + + def reset(self) -> None: + """Reset the state of the handler e.g. release any buttons.""" + raise NotImplementedError + + def needs_wrapping(self) -> bool: + """If this handler needs to be wrapped in another MappingHandler.""" + return len(self.wrap_with()) > 0 + + def needs_ranking(self) -> bool: + """If this handler needs ranking and wrapping with a HierarchyHandler.""" + return False + + def rank_by(self) -> Optional[InputCombination]: + """The combination for which this handler needs ranking.""" + + def wrap_with(self) -> Dict[InputCombination, HandlerEnums]: + """A dict of InputCombination -> HandlerEnums. + + for each InputCombination this handler should be wrapped + with the given MappingHandler. + """ + return {} + + def set_sub_handler(self, handler: MappingHandler) -> None: + """Give this handler a sub_handler.""" + self._sub_handler = handler + + def occlude_input_event(self, input_config: InputConfig) -> None: + """Remove the config from self.input_configs.""" + if not self.input_configs: + logger.debug_mapping_handler(self) + raise MappingParsingError( + "Cannot remove a non existing config", mapping_handler=self + ) + + # should be called for each event a wrapping-handler + # has in its input_configs InputCombination + self.input_configs.remove(input_config) + + def get_children(self) -> List[MappingHandler]: + return [] diff --git a/inputremapper/injection/mapping_handlers/mapping_parser.py b/inputremapper/injection/mapping_handlers/mapping_parser.py new file mode 100644 index 0000000..59a4501 --- /dev/null +++ b/inputremapper/injection/mapping_handlers/mapping_parser.py @@ -0,0 +1,360 @@ +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + +"""Functions to assemble the mapping handler tree.""" + +from collections import defaultdict +from typing import Dict, List, Type, Optional, Set, Iterable, Sized, Tuple, Sequence + +from evdev.ecodes import EV_KEY, EV_ABS, EV_REL + +from inputremapper.configs.input_config import InputCombination, InputConfig +from inputremapper.configs.keyboard_layout import DISABLE_CODE, DISABLE_NAME +from inputremapper.configs.mapping import Mapping +from inputremapper.configs.preset import Preset +from inputremapper.exceptions import MappingParsingError +from inputremapper.injection.global_uinputs import GlobalUInputs +from inputremapper.injection.macros.parse import Parser +from inputremapper.injection.mapping_handlers.abs_to_abs_handler import AbsToAbsHandler +from inputremapper.injection.mapping_handlers.abs_to_btn_handler import AbsToBtnHandler +from inputremapper.injection.mapping_handlers.abs_to_rel_handler import AbsToRelHandler +from inputremapper.injection.mapping_handlers.axis_switch_handler import ( + AxisSwitchHandler, +) +from inputremapper.injection.mapping_handlers.combination_handler import ( + CombinationHandler, +) +from inputremapper.injection.mapping_handlers.hierarchy_handler import HierarchyHandler +from inputremapper.injection.mapping_handlers.key_handler import KeyHandler +from inputremapper.injection.mapping_handlers.macro_handler import MacroHandler +from inputremapper.injection.mapping_handlers.mapping_handler import ( + HandlerEnums, + ContextProtocol, + MappingHandler, +) +from inputremapper.injection.mapping_handlers.null_handler import NullHandler +from inputremapper.injection.mapping_handlers.rel_to_abs_handler import RelToAbsHandler +from inputremapper.injection.mapping_handlers.rel_to_btn_handler import RelToBtnHandler +from inputremapper.injection.mapping_handlers.rel_to_rel_handler import RelToRelHandler +from inputremapper.logging.logger import logger +from inputremapper.utils import get_evdev_constant_name + +EventPipelines = Dict[InputConfig, Set[MappingHandler]] + +mapping_handler_classes: Dict[HandlerEnums, Optional[Type[MappingHandler]]] = { + # all available mapping_handlers + HandlerEnums.abs2btn: AbsToBtnHandler, + HandlerEnums.rel2btn: RelToBtnHandler, + HandlerEnums.macro: MacroHandler, + HandlerEnums.key: KeyHandler, + HandlerEnums.btn2rel: None, # can be a macro + HandlerEnums.rel2rel: RelToRelHandler, + HandlerEnums.abs2rel: AbsToRelHandler, + HandlerEnums.btn2abs: None, # can be a macro + HandlerEnums.rel2abs: RelToAbsHandler, + HandlerEnums.abs2abs: AbsToAbsHandler, + HandlerEnums.combination: CombinationHandler, + HandlerEnums.hierarchy: HierarchyHandler, + HandlerEnums.axisswitch: AxisSwitchHandler, + HandlerEnums.disable: NullHandler, +} + + +class MappingParser: + def __init__( + self, + global_uinputs: GlobalUInputs, + ) -> None: + self.global_uinputs = global_uinputs + + def parse_mappings( + self, + preset: Preset, + context: ContextProtocol, + ) -> EventPipelines: + """Create a dict with a list of MappingHandler for each InputEvent.""" + handlers = [] + for mapping in preset: + # start with the last handler in the chain, each mapping only has one output, + # but may have multiple inputs, therefore the last handler is a good starting + # point to assemble the pipeline + handler_enum = self._get_output_handler(mapping) + constructor = mapping_handler_classes[handler_enum] + if not constructor: + logger.warning( + "a mapping handler '%s' for %s is not implemented", + handler_enum, + mapping.format_name(), + ) + continue + + output_handler = constructor( + mapping.input_combination, + mapping, + context=context, + global_uinputs=self.global_uinputs, + ) + + # layer other handlers on top until the outer handler needs ranking or can + # directly handle a input event + handlers.extend(self._create_event_pipeline(output_handler, context)) + + # figure out which handlers need ranking and wrap them with hierarchy_handlers + need_ranking = defaultdict(set) + for handler in handlers.copy(): + if handler.needs_ranking(): + combination = handler.rank_by() + if not combination: + raise MappingParsingError( + f"{type(handler).__name__} claims to need ranking but does not " + f"return a combination to rank by", + mapping_handler=handler, + ) + + need_ranking[combination].add(handler) + handlers.remove(handler) + + # the HierarchyHandler's might not be the starting point of the event pipeline, + # layer other handlers on top again. + ranked_handlers = self._create_hierarchy_handlers(need_ranking) + for handler in ranked_handlers: + handlers.extend( + self._create_event_pipeline(handler, context, ignore_ranking=True) + ) + + # group all handlers by the input events they take care of. One handler might end + # up in multiple groups if it takes care of multiple InputEvents + event_pipelines: EventPipelines = defaultdict(set) + for handler in handlers: + assert handler.input_configs + for input_config in handler.input_configs: + logger.debug( + "event-pipeline with entry point: %s %s", + get_evdev_constant_name(*input_config.type_and_code), + input_config.input_match_hash, + ) + logger.debug_mapping_handler(handler) + event_pipelines[input_config].add(handler) + + return event_pipelines + + def _create_event_pipeline( + self, + handler: MappingHandler, + context: ContextProtocol, + ignore_ranking=False, + ) -> List[MappingHandler]: + """Recursively wrap a handler with other handlers until the + outer handler needs ranking or is finished wrapping. + """ + if not handler.needs_wrapping() or ( + handler.needs_ranking() and not ignore_ranking + ): + return [handler] + + handlers = [] + for combination, handler_enum in handler.wrap_with().items(): + constructor = mapping_handler_classes[handler_enum] + if not constructor: + raise NotImplementedError( + f"mapping handler {handler_enum} is not implemented" + ) + + super_handler = constructor( + combination, + handler.mapping, + context=context, + global_uinputs=self.global_uinputs, + ) + super_handler.set_sub_handler(handler) + for event in combination: + # the handler now has a super_handler which takes care about the events. + # so we need to hide them on the handler + handler.occlude_input_event(event) + + handlers.extend(self._create_event_pipeline(super_handler, context)) + + if handler.input_configs: + # the handler was only partially wrapped, + # we need to return it as a toplevel handler + handlers.append(handler) + + return handlers + + def _get_output_handler(self, mapping: Mapping) -> HandlerEnums: + """Determine the correct output handler. + + this is used as a starting point for the mapping parser + """ + if mapping.output_code == DISABLE_CODE or mapping.output_symbol == DISABLE_NAME: + return HandlerEnums.disable + + if mapping.output_symbol: + if Parser.is_this_a_macro(mapping.output_symbol): + return HandlerEnums.macro + + return HandlerEnums.key + + if mapping.output_type == EV_KEY: + return HandlerEnums.key + + input_event = self._maps_axis(mapping.input_combination) + if not input_event: + raise MappingParsingError( + f"This {mapping = } does not map to an axis, key or macro", + mapping=Mapping, + ) + + if mapping.output_type == EV_REL: + if input_event.type == EV_KEY: + return HandlerEnums.btn2rel + if input_event.type == EV_REL: + return HandlerEnums.rel2rel + if input_event.type == EV_ABS: + return HandlerEnums.abs2rel + + if mapping.output_type == EV_ABS: + if input_event.type == EV_KEY: + return HandlerEnums.btn2abs + if input_event.type == EV_REL: + return HandlerEnums.rel2abs + if input_event.type == EV_ABS: + return HandlerEnums.abs2abs + + raise MappingParsingError( + f"the output of {mapping = } is unknown", mapping=Mapping + ) + + def _maps_axis(self, combination: InputCombination) -> Optional[InputConfig]: + """Whether this InputCombination contains an InputEvent that is treated as + an axis and not a binary (key or button) event. + """ + for event in combination: + if event.defines_analog_input: + return event + return None + + def _create_hierarchy_handlers( + self, + handlers: Dict[InputCombination, Set[MappingHandler]], + ) -> Set[MappingHandler]: + """Sort handlers by input events and create Hierarchy handlers.""" + sorted_handlers = set() + all_combinations = handlers.keys() + events = set() + + # gather all InputEvents from all handlers + for combination in all_combinations: + for event in combination: + events.add(event) + + # create a ranking for each event + for event in events: + # find all combinations (from handlers) which contain the event + combinations_with_event = [ + combination for combination in all_combinations if event in combination + ] + + if len(combinations_with_event) == 1: + # there was only one handler containing that event return it as is + sorted_handlers.update(handlers[combinations_with_event[0]]) + continue + + # there are multiple handler with the same event. + # rank them and create the HierarchyHandler + sorted_combinations = self._order_combinations( + combinations_with_event, + event, + ) + sub_handlers: List[MappingHandler] = [] + for combination in sorted_combinations: + sub_handlers.extend(handlers[combination]) + + sorted_handlers.add( + HierarchyHandler( + sub_handlers, + event, + self.global_uinputs, + ) + ) + for handler in sub_handlers: + # the handler now has a HierarchyHandler which takes care about this event. + # so we hide need to hide it on the handler + handler.occlude_input_event(event) + + return sorted_handlers + + def _order_combinations( + self, + combinations: List[InputCombination], + common_config: InputConfig, + ) -> List[InputCombination]: + """Reorder the keys according to some rules. + + such that a combination a+b+c is in front of a+b which is in front of b + for a+b+c vs. b+d+e: a+b+c would be in front of b+d+e, because the common key b + has the higher index in the a+b+c (1), than in the b+c+d (0) list + in this example b would be the common key + as for combinations like a+b+c and e+d+c with the common key c: ¯\\_(ツ)_/¯ + + Parameters + ---------- + combinations + the list which needs ordering + common_config + the InputConfig all InputCombination's in combinations have in common + """ + combinations.sort(key=len) + + for start, end in self._ranges_with_constant_length(combinations.copy()): + sub_list = combinations[start:end] + sub_list.sort(key=lambda x: x.index(common_config)) + combinations[start:end] = sub_list + + combinations.reverse() + return combinations + + def _ranges_with_constant_length( + self, + x: Sequence[Sized], + ) -> Iterable[Tuple[int, int]]: + """Get all ranges of x for which the elements have constant length + + Parameters + ---------- + x: Sequence[Sized] + l must be ordered by increasing length of elements + """ + start_idx = 0 + last_len = 0 + for idx, y in enumerate(x): + if len(y) > last_len and idx - start_idx > 1: + yield start_idx, idx + + if len(y) == last_len and idx + 1 == len(x): + yield start_idx, idx + 1 + + if len(y) > last_len: + start_idx = idx + + if len(y) < last_len: + raise MappingParsingError( + "ranges_with_constant_length was called with an unordered list" + ) + last_len = len(y) diff --git a/inputremapper/injection/mapping_handlers/null_handler.py b/inputremapper/injection/mapping_handlers/null_handler.py new file mode 100644 index 0000000..3d12b6d --- /dev/null +++ b/inputremapper/injection/mapping_handlers/null_handler.py @@ -0,0 +1,62 @@ +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + +from typing import Dict, List + +import evdev + +from inputremapper.configs.input_config import InputCombination +from inputremapper.injection.mapping_handlers.mapping_handler import ( + HandlerEnums, + MappingHandler, +) +from inputremapper.input_event import InputEvent + + +class NullHandler(MappingHandler): + """Handler which consumes the event and does nothing.""" + + def __str__(self): + return f"NullHandler for {self.mapping.input_combination}<{id(self)}>" + + def get_children(self) -> List[MappingHandler]: + return [] + + def needs_wrapping(self) -> bool: + return False in [ + input_.defines_analog_input for input_ in self.mapping.input_combination + ] + + def wrap_with(self) -> Dict[InputCombination, HandlerEnums]: + if not self.mapping.input_combination.defines_analog_input: + return {self.mapping.input_combination: HandlerEnums.combination} + + assert len(self.mapping.input_combination) > 1, "nees_wrapping ensures this!" + return {self.mapping.input_combination: HandlerEnums.axisswitch} + + def notify( + self, + event: InputEvent, + source: evdev.InputDevice, + suppress: bool = False, + ) -> bool: + return True + + def reset(self) -> None: + pass diff --git a/inputremapper/injection/mapping_handlers/rel_to_abs_handler.py b/inputremapper/injection/mapping_handlers/rel_to_abs_handler.py new file mode 100644 index 0000000..b6d92a4 --- /dev/null +++ b/inputremapper/injection/mapping_handlers/rel_to_abs_handler.py @@ -0,0 +1,248 @@ +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + +import asyncio +from typing import Tuple, Dict, Optional, List + +import evdev +from evdev.ecodes import ( + EV_ABS, + EV_REL, + REL_WHEEL, + REL_HWHEEL, + REL_HWHEEL_HI_RES, + REL_WHEEL_HI_RES, +) + +from inputremapper import exceptions +from inputremapper.configs.input_config import InputCombination, InputConfig +from inputremapper.configs.mapping import ( + Mapping, + WHEEL_SCALING, + WHEEL_HI_RES_SCALING, + REL_XY_SCALING, + DEFAULT_REL_RATE, +) +from inputremapper.injection.global_uinputs import GlobalUInputs +from inputremapper.injection.mapping_handlers.axis_transform import Transformation +from inputremapper.injection.mapping_handlers.mapping_handler import ( + HandlerEnums, + MappingHandler, +) +from inputremapper.input_event import InputEvent, EventActions +from inputremapper.logging.logger import logger + + +class RelToAbsHandler(MappingHandler): + """Handler which transforms EV_REL to EV_ABS events. + + High EV_REL input results in high EV_ABS output. + If no new EV_REL events are seen, the EV_ABS output is set to 0 after + release_timeout. + """ + + _map_axis: InputConfig # InputConfig for the relative movement we map + _output_axis: Tuple[int, int] # the (type, code) of the output axis + _transform: Transformation + _target_absinfo: evdev.AbsInfo + + # infinite loop which centers the output when input stops + _recenter_loop: Optional[asyncio.Task] + _moving: asyncio.Event # event to notify the _recenter_loop + + _previous_event: Optional[InputEvent] + _observed_rate: float # input events per second + + def __init__( + self, + combination: InputCombination, + mapping: Mapping, + global_uinputs: GlobalUInputs, + **_, + ) -> None: + super().__init__(combination, mapping, global_uinputs) + + # find the input event we are supposed to map. If the input combination is + # BTN_A + REL_X + BTN_B, then use the value of REL_X for the transformation + assert (map_axis := combination.find_analog_input_config(type_=EV_REL)) + self._map_axis = map_axis + + assert mapping.output_code is not None + assert mapping.output_type == EV_ABS + self._output_axis = (mapping.output_type, mapping.output_code) + + target_uinput = global_uinputs.get_uinput(mapping.target_uinput) + assert target_uinput is not None + abs_capabilities = target_uinput.capabilities(absinfo=True)[EV_ABS] + self._target_absinfo = dict(abs_capabilities)[mapping.output_code] + + max_ = self._get_default_cutoff() + self._transform = Transformation( + min_=-max(1, int(max_)), + max_=max(1, int(max_)), + deadzone=mapping.deadzone, + gain=mapping.gain, + expo=mapping.expo, + ) + self._moving = asyncio.Event() + self._recenter_loop = None + + self._previous_event = None + self._observed_rate = DEFAULT_REL_RATE + + def __str__(self): + return ( + f"RelToAbsHandler for {self._map_axis} " + f"maps to: {self.mapping.get_output_name_constant()} " + f"{self.mapping.get_output_type_code()} at " + f"{self.mapping.target_uinput}" + ) + + def __repr__(self): + return f"<{str(self)} at {hex(id(self))}>" + + def get_children(self) -> List[MappingHandler]: + return [] + + def _observe_rate(self, event: InputEvent): + """Watch incoming events and remember how many events appear per second.""" + if self._previous_event is not None: + delta_time = event.timestamp() - self._previous_event.timestamp() + if delta_time == 0: + logger.error("Observed two events with the same timestamp") + return + + rate = 1 / delta_time + # mice seem to have a constant rate. wheel events are jaggy and the + # rate depends on how fast it is turned. + if rate > self._observed_rate: + logger.debug("Updating rate to %s", rate) + self._observed_rate = rate + self._calculate_cutoff() + + self._previous_event = event + + def _get_default_cutoff(self): + """Get the cutoff value assuming the default input rate.""" + if self._map_axis.code in [REL_WHEEL, REL_HWHEEL]: + return self.mapping.rel_to_abs_input_cutoff * WHEEL_SCALING + + if self._map_axis.code in [REL_WHEEL_HI_RES, REL_HWHEEL_HI_RES]: + return self.mapping.rel_to_abs_input_cutoff * WHEEL_HI_RES_SCALING + + return self.mapping.rel_to_abs_input_cutoff * REL_XY_SCALING + + def _calculate_cutoff(self): + """Correct the default cutoff with the observed input rate, and set it.""" + # Mice that have very high input rates report low values at the same time. + # If the rate is high, use a lower cutoff-value. If the rate is low, use a + # higher cutoff-value. + cutoff = self._get_default_cutoff() + cutoff *= DEFAULT_REL_RATE / self._observed_rate + + self._transform.set_range(-max(1, int(cutoff)), max(1, int(cutoff))) + + def notify( + self, + event: InputEvent, + source: evdev.InputDevice, + suppress: bool = False, + ) -> bool: + self._observe_rate(event) + + if event.input_match_hash != self._map_axis.input_match_hash: + return False + + if EventActions.recenter in event.actions: + if self._recenter_loop: + self._recenter_loop.cancel() + self._recenter() + return True + + if not self._recenter_loop or self._recenter_loop.cancelled(): + self._recenter_loop = asyncio.create_task(self._create_recenter_loop()) + + self._moving.set() # notify the _recenter_loop + try: + self._write(self._scale_to_target(self._transform(event.value))) + return True + except (exceptions.UinputNotAvailable, exceptions.EventNotHandled): + return False + + def reset(self) -> None: + if self._recenter_loop: + self._recenter_loop.cancel() + self._recenter() + + def _recenter(self) -> None: + """Recenter the output.""" + self._write(self._scale_to_target(0)) + + async def _create_recenter_loop(self) -> None: + """Coroutine which waits for the input to start moving, + then waits until the input stops moving, centers the output and repeat. + + Runs forever. + """ + while True: + await self._moving.wait() # input moving started + while ( + await asyncio.wait( + (asyncio.create_task(self._moving.wait()),), + timeout=self.mapping.release_timeout, + ) + )[0]: + self._moving.clear() # still moving + self._recenter() # input moving stopped + + def _scale_to_target(self, x: float) -> int: + """Scales a x value between -1 and 1 to an integer between + target_absinfo.min and target_absinfo.max + + input values above 1 or below -1 are clamped to the extreme values + """ + factor = (self._target_absinfo.max - self._target_absinfo.min) / 2 + offset = self._target_absinfo.min + factor + y = factor * x + offset + if y > offset: + return int(min(self._target_absinfo.max, y)) + else: + return int(max(self._target_absinfo.min, y)) + + def _write(self, value: int) -> None: + """Inject.""" + try: + self.global_uinputs.write( + (*self._output_axis, value), + self.mapping.target_uinput, + ) + except OverflowError: + # screwed up the calculation of the event value + logger.error("OverflowError (%s, %s, %s)", *self._output_axis, value) + + def needs_wrapping(self) -> bool: + return len(self.input_configs) > 1 + + def set_sub_handler(self, handler: MappingHandler) -> None: + assert False # cannot have a sub-handler + + def wrap_with(self) -> Dict[InputCombination, HandlerEnums]: + if self.needs_wrapping(): + return {InputCombination(self.input_configs): HandlerEnums.axisswitch} + return {} diff --git a/inputremapper/injection/mapping_handlers/rel_to_btn_handler.py b/inputremapper/injection/mapping_handlers/rel_to_btn_handler.py new file mode 100644 index 0000000..7be6ba0 --- /dev/null +++ b/inputremapper/injection/mapping_handlers/rel_to_btn_handler.py @@ -0,0 +1,148 @@ +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + +import asyncio +import time +from typing import List + +import evdev +from evdev.ecodes import EV_REL + +from inputremapper.configs.input_config import InputCombination, InputConfig +from inputremapper.configs.mapping import Mapping +from inputremapper.injection.global_uinputs import GlobalUInputs +from inputremapper.injection.mapping_handlers.mapping_handler import ( + MappingHandler, +) +from inputremapper.input_event import InputEvent, EventActions +from inputremapper.logging.logger import logger + + +class RelToBtnHandler(MappingHandler): + """Handler which transforms an EV_REL to a button event + and sends that to a sub_handler + + adheres to the MappingHandler protocol + """ + + _active: bool + _input_config: InputConfig + _last_activation: float + _sub_handler: MappingHandler + + def __init__( + self, + combination: InputCombination, + mapping: Mapping, + global_uinputs: GlobalUInputs, + **_, + ) -> None: + super().__init__(combination, mapping, global_uinputs) + + self._active = False + self._input_config = combination[0] + self._last_activation = time.time() + self._abort_release = False + assert self._input_config.analog_threshold != 0 + assert len(combination) == 1 + + def __str__(self): + return f'RelToBtnHandler for "{self._input_config}"' + + def __repr__(self): + return f"<{str(self)} at {hex(id(self))}>" + + def get_children(self) -> List[MappingHandler]: + return [self._sub_handler] + + async def _stage_release( + self, + source: evdev.InputDevice, + suppress: bool, + ): + while time.time() < self._last_activation + self.mapping.release_timeout: + await asyncio.sleep(1 / self.mapping.rel_rate) + + if self._abort_release: + self._abort_release = False + return + + event = InputEvent( + 0, + 0, + *self._input_config.type_and_code, + value=0, + actions=(EventActions.as_key,), + origin_hash=self._input_config.origin_hash, + ) + logger.debug("Sending %s to sub_handler", event) + self._sub_handler.notify(event, source, suppress) + self._active = False + + def notify( + self, + event: InputEvent, + source: evdev.InputDevice, + suppress: bool = False, + ) -> bool: + assert event.type == EV_REL + if event.input_match_hash != self._input_config.input_match_hash: + return False + + assert (threshold := self._input_config.analog_threshold) + value = event.value + if (value < threshold > 0) or (value > threshold < 0): + if self._active: + # the axis is below the threshold and the stage_release + # function is running + if self.mapping.force_release_timeout: + # consume the event + return True + event = event.modify(pressed=False, actions=(EventActions.as_key,)) + logger.debug("Sending %s to sub_handler", event) + self._abort_release = True + else: + # don't consume the event. + # We could return True to consume events + return False + else: + # the axis is above the threshold + if not self._active: + asyncio.ensure_future(self._stage_release(source, suppress)) + if value >= threshold > 0: + direction = 1 + else: + direction = -1 + self._last_activation = time.time() + event = event.modify( + pressed=True, + direction=direction, + actions=(EventActions.as_key,), + ) + + self._active = event.is_pressed() + # logger.debug("Sending %s to sub_handler", event) + return self._sub_handler.notify(event, source=source, suppress=suppress) + + def reset(self) -> None: + if self._active: + self._abort_release = True + + self._active = False + self._sub_handler.reset() diff --git a/inputremapper/injection/mapping_handlers/rel_to_rel_handler.py b/inputremapper/injection/mapping_handlers/rel_to_rel_handler.py new file mode 100644 index 0000000..f61bd1a --- /dev/null +++ b/inputremapper/injection/mapping_handlers/rel_to_rel_handler.py @@ -0,0 +1,277 @@ +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + +import math +from typing import Dict, List + +import evdev +from evdev.ecodes import ( + EV_REL, + REL_WHEEL, + REL_HWHEEL, + REL_WHEEL_HI_RES, + REL_HWHEEL_HI_RES, +) + +from inputremapper import exceptions +from inputremapper.configs.input_config import InputCombination, InputConfig +from inputremapper.configs.mapping import ( + Mapping, + REL_XY_SCALING, + WHEEL_SCALING, + WHEEL_HI_RES_SCALING, +) +from inputremapper.injection.global_uinputs import GlobalUInputs +from inputremapper.injection.mapping_handlers.axis_transform import Transformation +from inputremapper.injection.mapping_handlers.mapping_handler import ( + HandlerEnums, + MappingHandler, +) +from inputremapper.input_event import InputEvent +from inputremapper.logging.logger import logger + + +def is_wheel(event) -> bool: + return event.type == EV_REL and event.code in (REL_WHEEL, REL_HWHEEL) + + +def is_high_res_wheel(event) -> bool: + return event.type == EV_REL and event.code in (REL_WHEEL_HI_RES, REL_HWHEEL_HI_RES) + + +class Remainder: + _scale: float + _remainder: float + + def __init__(self, scale: float): + self._scale = scale + self._remainder = 0 + + def input(self, value: float) -> int: + # if the mouse moves very slow, it might not move at all because of the + # int-conversion (which is required when writing). store the remainder + # (the decimal places) and add it up, until the mouse moves a little. + scaled = value * self._scale + self._remainder + self._remainder = math.fmod(scaled, 1) + + return int(scaled) + + +class RelToRelHandler(MappingHandler): + """Handler which transforms EV_REL to EV_REL events.""" + + _input_config: InputConfig # the relative movement we map + + _max_observed_input: float + + _transform: Transformation + + _remainder: Remainder + _wheel_remainder: Remainder + _wheel_hi_res_remainder: Remainder + + def __init__( + self, + combination: InputCombination, + mapping: Mapping, + global_uinputs: GlobalUInputs, + **_, + ) -> None: + super().__init__(combination, mapping, global_uinputs) + + assert self.mapping.output_code is not None + + # find the input event we are supposed to map. If the input combination is + # BTN_A + REL_X + BTN_B, then use the value of REL_X for the transformation + input_config = combination.find_analog_input_config(type_=EV_REL) + assert input_config is not None + self._input_config = input_config + + self._max_observed_input = 1 + + self._remainder = Remainder(REL_XY_SCALING) + self._wheel_remainder = Remainder(WHEEL_SCALING) + self._wheel_hi_res_remainder = Remainder(WHEEL_HI_RES_SCALING) + + self._transform = Transformation( + max_=1, + min_=-1, + deadzone=self.mapping.deadzone, + gain=self.mapping.gain, + expo=self.mapping.expo, + ) + + def __str__(self): + return ( + f"RelToRelHandler for {self._input_config} " + f"maps to: {self.mapping.output_code} at {self.mapping.target_uinput}" + ) + + def __repr__(self): + return f"<{str(self)} at {hex(id(self))}>" + + def get_children(self) -> List[MappingHandler]: + return [] + + def _should_map(self, event: InputEvent): + """Check if this input event is relevant for this handler.""" + return event.input_match_hash == self._input_config.input_match_hash + + def notify( + self, + event: InputEvent, + source: evdev.InputDevice, + suppress: bool = False, + ) -> bool: + if not self._should_map(event): + return False + """ + There was the idea to define speed as "movemnt per second". + There are deprecated mapping variables in this explanation. + + rel2rel example: + - input every 0.1s (`input_rate` of 10 events/s), value of 200 + - input speed is 2000, because in 1 second a value of 2000 acumulates + - `input_rel_speed` is a const defined as 4000 px/s, how fast mice usually move + - `transformed = Transformation(input.value, max=input_rel_speed / input_rate)` + - get 0.5 because the expo is 0 + - `abs_to_rel_speed` is 5000 + - inject 2500 therefore per second, making it a bit faster + - divide 2500 by the rate of 10 to inject a value of 250 each time input occurs + + ``` + output_value = Transformation( + input.value, + max=input_rel_speed / input_rate + ) * abs_to_rel_speed / input_rate + ``` + + The input_rel_speed could be used here instead of abs_to_rel_speed, because the + gain already controls the speed. In that case it would be a 1:1 ratio of + input-to-output value if the gain is 1. + + for wheel and wheel_hi_res, different input speed constants must be set. + + abs2rel needs a base value for the output, so `abs_to_rel_speed` is still + required. + `abs_to_rel_speed / rel_rate * transform(input.value, max=absinfo.max)` + is the output value. Both abs_to_rel_speed and the transformation-gain control + speed. + + if abs_to_rel_speed controls speed in the abs2rel output, it should also do so + in other handlers that have EV_REL output. + + unfortunately input_rate needs to be determined during runtime, which screws + the overall speed up when slowly moving the input device in the beginning, + because slow input is thought to be the regular input. + + --- + + transforming from rate based to rate based speed values won't work well. + + better to use fractional speed values. + REL_X of 40 = REL_WHEEL of 1 = REL_WHEE_HI_RES of 1/120 + + this is why abs_to_rel_speed does not affect the rel_to_rel handler. + + The expo calculation will be wrong in the beginning, because it is based on + the highest observed value. The overall gain will be fine though. + """ + + input_value = float(event.value) + + # scale down now, the remainder calculation scales up by the same factor later + # depending on what kind of event this becomes. + if event.is_wheel_event: + input_value /= WHEEL_SCALING + elif event.is_wheel_hi_res_event: + input_value /= WHEEL_HI_RES_SCALING + else: + # even though the input rate is unknown we can apply REL_XY_SCALING, which + # is based on 60hz or something, because the un-scaling also uses values + # based on 60hz. So the rate cancels out + input_value /= REL_XY_SCALING + + if abs(input_value) > self._max_observed_input: + self._max_observed_input = abs(input_value) + + # If _max_observed_input is wrong when the injection starts and the correct + # value learned during runtime, results can be weird at the beginning. + # If expo and deadzone are not set, then it is linear and doesn't matter. + transformed = self._transform(input_value / self._max_observed_input) + transformed *= self._max_observed_input + + is_wheel_output = self.mapping.is_wheel_output() + is_hi_res_wheel_output = self.mapping.is_high_res_wheel_output() + + horizontal = self.mapping.output_code in ( + REL_HWHEEL_HI_RES, + REL_HWHEEL, + ) + + try: + if is_wheel_output or is_hi_res_wheel_output: + # inject both kinds of wheels, otherwise wheels don't work for some + # people. See issue #354 + self._write( + REL_HWHEEL if horizontal else REL_WHEEL, + self._wheel_remainder.input(transformed), + ) + self._write( + REL_HWHEEL_HI_RES if horizontal else REL_WHEEL_HI_RES, + self._wheel_hi_res_remainder.input(transformed), + ) + else: + self._write( + self.mapping.output_code, + self._remainder.input(transformed), + ) + + return True + except OverflowError: + # screwed up the calculation of the event value + logger.error("OverflowError while handling %s", event) + return True + except (exceptions.UinputNotAvailable, exceptions.EventNotHandled): + return False + + def reset(self) -> None: + pass + + def _write(self, code: int, value: int): + if value == 0: + # rel 0 does not make sense. We don't need to tell linux that the mouse + # should not be moved this time. + return + + self.global_uinputs.write( + (EV_REL, code, value), + self.mapping.target_uinput, + ) + + def needs_wrapping(self) -> bool: + return len(self.input_configs) > 1 + + def set_sub_handler(self, handler: MappingHandler) -> None: + assert False # cannot have a sub-handler + + def wrap_with(self) -> Dict[InputCombination, HandlerEnums]: + if self.needs_wrapping(): + return {InputCombination(self.input_configs): HandlerEnums.axisswitch} + return {} diff --git a/inputremapper/injection/numlock.py b/inputremapper/injection/numlock.py new file mode 100644 index 0000000..c425744 --- /dev/null +++ b/inputremapper/injection/numlock.py @@ -0,0 +1,83 @@ +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + + +"""Functions to handle numlocks. + +For unknown reasons the numlock status can change when starting injections, +which is why these functions exist. +""" + + +import re +import subprocess + +from inputremapper.logging.logger import logger + + +def is_numlock_on(): + """Get the current state of the numlock.""" + try: + xset_q = subprocess.check_output( + ["xset", "q"], + stderr=subprocess.STDOUT, + ).decode() + num_lock_status = re.search(r"Num Lock:\s+(.+?)\s", xset_q) + + if num_lock_status is not None: + return num_lock_status[1] == "on" + + return False + except (FileNotFoundError, subprocess.CalledProcessError): + # tty + return None + + +def set_numlock(state): + """Set the numlock to a given state of True or False.""" + if state is None: + return + + value = {True: "on", False: "off"}[state] + + try: + subprocess.check_output(["numlockx", value]) + except subprocess.CalledProcessError: + # might be in a tty + pass + except FileNotFoundError: + # doesn't seem to be installed everywhere + logger.debug("numlockx not found") + + +def ensure_numlock(func): + """Decorator to reset the numlock to its initial state afterwards.""" + + def wrapped(*args, **kwargs): + # for some reason, grabbing a device can modify the num lock state. + # remember it and apply back later + numlock_before = is_numlock_on() + + result = func(*args, **kwargs) + + set_numlock(numlock_before) + + return result + + return wrapped diff --git a/inputremapper/input_event.py b/inputremapper/input_event.py new file mode 100644 index 0000000..0f3f886 --- /dev/null +++ b/inputremapper/input_event.py @@ -0,0 +1,264 @@ +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + +from __future__ import annotations + +import enum +from dataclasses import dataclass +from typing import Tuple, Optional, Hashable, Literal + +import evdev +from evdev import ecodes + +from inputremapper.utils import get_evdev_constant_name, DeviceHash + + +class EventActions(enum.Enum): + """Additional information an InputEvent can send through the event pipeline.""" + + as_key = enum.auto() # treat this event as a key event + recenter = enum.auto() # recenter the axis when receiving this + none = enum.auto() + + +# Todo: add slots=True as soon as python 3.10 is in common distros +@dataclass(frozen=True) +class InputEvent: + """Events that are generated during runtime. + + Is a drop-in replacement for evdev.InputEvent + """ + + sec: int + usec: int + type: int + code: int + value: int + + # Our own custom attributes + # (They need types for the dataclass to allow them in the constructor) + pressed: bool | None = None # haven't figured out yet, depends on threshold config + direction: int = 1 # -1 for joystick left, +1 for joystick right and buttons + actions: Tuple[EventActions, ...] = () + origin_hash: Optional[DeviceHash] = None + + def __eq__(self, other: InputEvent | evdev.InputEvent | Tuple[int, int, int]): + # useful in tests + if isinstance(other, InputEvent) or isinstance(other, evdev.InputEvent): + return self.event_tuple == (other.type, other.code, other.value) + if isinstance(other, tuple): + return self.event_tuple == other + raise TypeError(f"cannot compare {type(other)} with InputEvent") + + def is_pressed(self) -> bool: + """Get if the event is representing a pressed button, joystick, trigger, + a scrolled wheel, a moved mouse, etc. + + It might depend on stuff like the analog_threshold. + """ + if self.pressed is not None: + return self.pressed + + # As long as we haven't checked it using the analog thresholds and such, + # assume that anything != 0 means it is pressed.' + + if self.value == 0: + return False + + return True + + @staticmethod + def validate_event(event): + """Test if the event is valid.""" + if not isinstance(event.type, int): + raise TypeError(f"Expected type to be an int, but got {event.type}") + + if not isinstance(event.code, int): + raise TypeError(f"Expected code to be an int, but got {event.code}") + + if not isinstance(event.value, int): + # this happened to me because I screwed stuff up + raise TypeError(f"Expected value to be an int, but got {event.value}") + + return event + + @property + def input_match_hash(self) -> Hashable: + """a Hashable object which is intended to match the InputEvent with a + InputConfig. + """ + return self.type, self.code, self.origin_hash + + @classmethod + def from_event( + cls, + event: evdev.InputEvent, + origin_hash: Optional[DeviceHash] = None, + ) -> InputEvent: + """Create a InputEvent from another InputEvent or evdev.InputEvent.""" + try: + return cls( + event.sec, + event.usec, + event.type, + event.code, + event.value, + origin_hash=origin_hash, + ) + except AttributeError as exception: + raise TypeError( + f"Failed to create InputEvent from {event = }" + ) from exception + + @classmethod + def from_tuple( + cls, + event_tuple: Tuple[int, int, int], + origin_hash: Optional[DeviceHash] = None, + ) -> InputEvent: + """Create a InputEvent from a (type, code, value) tuple.""" + # use this as rarely as possible. Construct objects early on and pass them + # around instead of passing around integers + if len(event_tuple) != 3: + raise TypeError( + f"failed to create InputEvent {event_tuple = } must have length 3" + ) + + return cls.validate_event( + cls( + 0, + 0, + int(event_tuple[0]), + int(event_tuple[1]), + int(event_tuple[2]), + origin_hash=origin_hash, + ) + ) + + @classmethod + def abs(cls, code: int, value: int, origin_hash: Optional[DeviceHash] = None): + """Create an abs event, like joystick movements.""" + return cls.validate_event( + cls( + 0, + 0, + ecodes.EV_ABS, + code, + value, + origin_hash=origin_hash, + ) + ) + + @classmethod + def rel(cls, code: int, value: int, origin_hash: Optional[str] = None): + """Create a rel event, like mouse movements.""" + return cls.validate_event( + cls( + 0, + 0, + ecodes.EV_REL, + code, + value, + origin_hash=origin_hash, + ) + ) + + @classmethod + def key(cls, code: int, value: Literal[0, 1], origin_hash: Optional[str] = None): + """Create a key event, like keyboard keys or gamepad buttons. + + A value of 1 means "press", a value of 0 means "release". + """ + return cls.validate_event( + cls( + 0, + 0, + ecodes.EV_KEY, + code, + value, + origin_hash=origin_hash, + ) + ) + + @property + def type_and_code(self) -> Tuple[int, int]: + """Event type, code.""" + return self.type, self.code + + @property + def event_tuple(self) -> Tuple[int, int, int]: + """Event type, code, value.""" + return self.type, self.code, self.value + + @property + def is_key_event(self) -> bool: + """Whether this is interpreted as a key event.""" + return self.type == evdev.ecodes.EV_KEY or EventActions.as_key in self.actions + + @property + def is_wheel_event(self) -> bool: + """Whether this is interpreted as a key event.""" + return self.type == evdev.ecodes.EV_REL and self.code in [ + ecodes.REL_WHEEL, + ecodes.REL_HWHEEL, + ] + + @property + def is_wheel_hi_res_event(self) -> bool: + """Whether this is interpreted as a key event.""" + return self.type == evdev.ecodes.EV_REL and self.code in [ + ecodes.REL_WHEEL_HI_RES, + ecodes.REL_HWHEEL_HI_RES, + ] + + def __str__(self): + name = get_evdev_constant_name(self.type, self.code) + return f"InputEvent for {self.event_tuple} {name}" + + def __repr__(self): + return f"<{str(self)} at {hex(id(self))}>" + + def timestamp(self): + """Return the unix timestamp of when the event was seen.""" + return self.sec + self.usec / 1000000 + + def modify( + self, + sec: Optional[int] = None, + usec: Optional[int] = None, + type_: Optional[int] = None, + code: Optional[int] = None, + value: Optional[int] = None, + pressed: Optional[bool] = None, + direction: Optional[int] = None, + actions: Optional[Tuple[EventActions, ...]] = None, + origin_hash: Optional[str] = None, + ) -> InputEvent: + """Return a new modified event.""" + return InputEvent( + sec if sec is not None else self.sec, + usec if usec is not None else self.usec, + type_ if type_ is not None else self.type, + code if code is not None else self.code, + value if value is not None else self.value, + pressed if pressed is not None else self.is_pressed(), + direction if direction is not None else self.direction, + actions if actions is not None else self.actions, + origin_hash=origin_hash if origin_hash is not None else self.origin_hash, # type: ignore + ) diff --git a/inputremapper/installation_info.py b/inputremapper/installation_info.py new file mode 100644 index 0000000..339e394 --- /dev/null +++ b/inputremapper/installation_info.py @@ -0,0 +1,30 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + +"""Information about the input-remapper installation. + +These defaults might be overwritten by package maintainers. +""" + +COMMIT_HASH = "unknown" +VERSION = "2.2.1" +# depending on where this file is installed to, make sure to use the proper +# prefix path for data. +DATA_DIR = "/usr/share/input-remapper" diff --git a/inputremapper/ipc/__init__.py b/inputremapper/ipc/__init__.py new file mode 100644 index 0000000..be84d2d --- /dev/null +++ b/inputremapper/ipc/__init__.py @@ -0,0 +1,24 @@ +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + +"""Since I'm not forking, I can't use multiprocessing.Pipe. + +Processes that need privileges are spawned with pkexec, which connect to +known pipe paths to communicate with the non-privileged parent process. +""" diff --git a/inputremapper/ipc/pipe.py b/inputremapper/ipc/pipe.py new file mode 100644 index 0000000..520dd02 --- /dev/null +++ b/inputremapper/ipc/pipe.py @@ -0,0 +1,183 @@ +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + + +"""Named bidirectional non-blocking pipes. + +>>> p1 = Pipe('foo') +>>> p2 = Pipe('foo') + +>>> p1.send(1) +>>> p2.poll() +>>> p2.recv() + +>>> p2.send(2) +>>> p1.poll() +>>> p1.recv() + +Beware that pipes read any available messages, +even those written by themselves. +""" + +import asyncio +import json +import os +import time +from typing import Optional, AsyncIterator, Union + +from inputremapper.configs.paths import PathUtils +from inputremapper.logging.logger import logger + + +class Pipe: + """Pipe object. + + This is not for secure communication. If pipes already exist, they will be used, + but existing pipes might have open permissions! Only use this for stuff that + non-privileged users would be allowed to read. + """ + + def __init__(self, path): + """Create a pipe, or open it if it already exists.""" + self._path = path + self._unread = [] + self._created_at = time.time() + + self._transport: Optional[asyncio.ReadTransport] = None + self._async_iterator: Optional[AsyncIterator] = None + + paths = (f"{path}r", f"{path}w") + + PathUtils.mkdir(os.path.dirname(path)) + + if not os.path.exists(paths[0]): + logger.debug("Creating new pipes %s", paths) + # The fd the link points to is closed, or none ever existed + # If there is a link, remove it. + if os.path.islink(paths[0]): + os.remove(paths[0]) + if os.path.islink(paths[1]): + os.remove(paths[1]) + + self._fds = os.pipe() + fds_dir = f"/proc/{os.getpid()}/fd/" + PathUtils.chown(f"{fds_dir}{self._fds[0]}") + PathUtils.chown(f"{fds_dir}{self._fds[1]}") + + # to make it accessible by path constants, create symlinks + os.symlink(f"{fds_dir}{self._fds[0]}", paths[0]) + os.symlink(f"{fds_dir}{self._fds[1]}", paths[1]) + else: + logger.debug("Using existing pipes %s", paths) + + # thanks to os.O_NONBLOCK, readline will return b'' when there + # is nothing to read + self._fds = ( + os.open(paths[0], os.O_RDONLY | os.O_NONBLOCK), + os.open(paths[1], os.O_WRONLY | os.O_NONBLOCK), + ) + + self._handles = (open(self._fds[0], "r"), open(self._fds[1], "w")) + + # clear the pipe of any contents, to avoid leftover messages from breaking + # the reader-client or reader-service + while self.poll(): + leftover = self.recv() + logger.debug('Cleared leftover message "%s"', leftover) + + def __del__(self): + if self._transport: + logger.debug("closing transport") + self._transport.close() + for file in self._handles: + file.close() + + def recv(self): + """Read an object from the pipe or None if nothing available. + + Doesn't transmit pickles, to avoid injection attacks on the + privileged reader-service. Only messages that can be converted to json + are allowed. + """ + if len(self._unread) > 0: + return self._unread.pop(0) + + line = self._handles[0].readline() + if len(line) == 0: + return None + + return self._get_msg(line) + + def _get_msg(self, line: str): + parsed = json.loads(line) + if parsed[0] < self._created_at and os.environ.get("UNITTEST"): + # important to avoid race conditions between multiple unittests, + # for example old terminate messages reaching a new instance of + # the reader-service. + logger.debug("Ignoring old message %s", parsed) + return None + + return parsed[1] + + def send(self, message: Union[str, int, float, dict, list, tuple]): + """Write a serializable object to the pipe.""" + dump = json.dumps((time.time(), message)) + # there aren't any newlines supposed to be, + # but if there are it breaks readline(). + self._handles[1].write(dump.replace("\n", "")) + self._handles[1].write("\n") + self._handles[1].flush() + + def poll(self): + """Check if there is anything that can be read.""" + if len(self._unread) > 0: + return True + + # using select.select apparently won't mark the pipe as ready + # anymore when there are multiple lines to read but only a single + # line is retreived. Using read instead. + msg = self.recv() + if msg is not None: + self._unread.append(msg) + + return len(self._unread) > 0 + + def fileno(self): + """Compatibility to select.select.""" + return self._handles[0].fileno() + + def __aiter__(self): + return self + + async def __anext__(self): + if not self._async_iterator: + loop = asyncio.get_running_loop() + reader = asyncio.StreamReader() + + self._transport, _ = await loop.connect_read_pipe( + lambda: asyncio.StreamReaderProtocol(reader), self._handles[0] + ) + self._async_iterator = reader.__aiter__() + + return self._get_msg(await self._async_iterator.__anext__()) + + async def recv_async(self): + """Read the next line with async. Do not use this when using + the async for loop.""" + return await self.__aiter__().__anext__() diff --git a/inputremapper/ipc/shared_dict.py b/inputremapper/ipc/shared_dict.py new file mode 100644 index 0000000..cf34655 --- /dev/null +++ b/inputremapper/ipc/shared_dict.py @@ -0,0 +1,122 @@ +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + + +"""Share a dictionary across processes.""" + + +import atexit +import multiprocessing +import select +from typing import Optional, Any + +from inputremapper.logging.logger import logger + + +class SharedDict: + """Share a dictionary across processes.""" + + # because unittests terminate all child processes in cleanup I can't use + # multiprocessing.Manager + def __init__(self): + """Create a shared dictionary.""" + super().__init__() + + # To avoid blocking forever if something goes wrong. The maximum + # observed time communication takes was 0.001 for me on a slow pc + self._timeout = 0.02 + + self.pipe = multiprocessing.Pipe() + self.process = None + atexit.register(self._stop) + + def start(self): + """Ensure the process to manage the dictionary is running.""" + if self.process is not None and self.process.is_alive(): + logger.debug("SharedDict process already running") + return + + # if the manager has already been running in the past but stopped + # for some reason, the dictionary contents are lost. + logger.debug("Starting SharedDict process") + self.process = multiprocessing.Process(target=self.manage) + self.process.start() + + def manage(self): + """Manage the dictionary, handle read and write requests.""" + logger.debug("SharedDict process started") + shared_dict = {} + while True: + message = self.pipe[0].recv() + logger.debug("SharedDict got %s", message) + + if message[0] == "stop": + return + + if message[0] == "set": + shared_dict[message[1]] = message[2] + + if message[0] == "clear": + shared_dict.clear() + + if message[0] == "get": + self.pipe[0].send(shared_dict.get(message[1])) + + if message[0] == "ping": + self.pipe[0].send("pong") + + def _stop(self): + """Stop the managing process.""" + self.pipe[1].send(("stop",)) + + def _clear(self): + """Clears the memory.""" + self.pipe[1].send(("clear",)) + + def get(self, key: str): + """Get a value from the dictionary. + + If it doesn't exist, returns None. + """ + return self[key] + + def is_alive(self, timeout: Optional[int] = None): + """Check if the manager process is running.""" + self.pipe[1].send(("ping",)) + select.select([self.pipe[1]], [], [], timeout or self._timeout) + if self.pipe[1].poll(): + return self.pipe[1].recv() == "pong" + + return False + + def __setitem__(self, key: str, value: Any): + self.pipe[1].send(("set", key, value)) + + def __getitem__(self, key: str): + self.pipe[1].send(("get", key)) + + select.select([self.pipe[1]], [], [], self._timeout) + if self.pipe[1].poll(): + return self.pipe[1].recv() + + logger.error("select.select timed out") + return None + + def __del__(self): + self._stop() diff --git a/inputremapper/ipc/socket.py b/inputremapper/ipc/socket.py new file mode 100644 index 0000000..7142459 --- /dev/null +++ b/inputremapper/ipc/socket.py @@ -0,0 +1,303 @@ +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + + +"""Non-blocking abstraction of unix domain sockets. + +>>> server = Server('foo') +>>> client = Client('foo') + +>>> server.send(1) +>>> client.poll() +>>> client.recv() + +>>> client.send(2) +>>> server.poll() +>>> server.recv() + +I seems harder to sniff on a socket than using pipes for other non-root +processes, but it doesn't guarantee security. As long as the GUI is open +and not running as root user, it is most likely possible to somehow log +keycodes by looking into the memory of the gui process (just like with most +other applications because they end up receiving keyboard input as well). +It still appears to be a bit overkill to use a socket considering pipes +are much easier to handle. +""" + + +# Issues: +# - Tests don't pass with Server and Client instead of Pipe for reader-client +# and service communication or something +# - Had one case of a test that was blocking forever, seems very rare. +# - Hard to debug, generally very problematic compared to Pipes +# The tool works fine, it's just the tests. BrokenPipe errors reported +# by _Server all the time. + + +import json +import os +import select +import socket +import time +from typing import Union + +from inputremapper.configs.paths import PathUtils +from inputremapper.logging.logger import logger + +# something funny that most likely won't appear in messages. +# also add some ones so that 01 in the payload won't offset +# a match by 2 bits +END = b"\x55\x55\xff\x55" # should be 01010101 01010101 11111111 01010101 + +ENCODING = "utf8" + + +# reusing existing objects makes tests easier, no headaches about closing +# and reopening anymore. The ui also only runs only one instance of each all +# the time. +existing_servers = {} +existing_clients = {} + + +class Base: + """Abstract base class for Socket and Client.""" + + def __init__(self, path): + self._path = path + self._unread = [] + self.unsent = [] + PathUtils.mkdir(os.path.dirname(path)) + self.connection = None + self.socket = None + self._created_at = 0 + self.reset() + + def reset(self): + """Ignore older messages than now.""" + # ensure it is connected + self.connect() + self._created_at = time.time() + + def connect(self): + """Returns True if connected, and if not attempts to connect.""" + raise NotImplementedError + + def fileno(self): + """For compatibility with select.select.""" + raise NotImplementedError + + def reconnect(self): + """Try to make a new connection.""" + raise NotImplementedError + + def _receive_new_messages(self): + if not self.connect(): + logger.debug("Not connected") + return + + messages = b"" + attempts = 0 + while True: + try: + chunk = self.connection.recvmsg(4096)[0] + messages += chunk + + if len(chunk) == 0: + # select keeps telling me the socket has messages + # ready to be received, and I keep getting empty + # buffers. Happened during a test that ran two reader-service + # processes without stopping the first one. + attempts += 1 + if attempts == 2 or not self.reconnect(): + return + + except (socket.timeout, BlockingIOError): + break + + split = messages.split(END) + for message in split: + if len(message) > 0: + parsed = json.loads(message.decode(ENCODING)) + if parsed[0] < self._created_at: + # important to avoid race conditions between multiple + # unittests, for example old terminate messages reaching + # a new instance of the reader-service. + logger.debug("Ignoring old message %s", parsed) + continue + + self._unread.append(parsed[1]) + + def recv(self): + """Get the next message or None if nothing to read. + + Doesn't transmit pickles, to avoid injection attacks on the + privileged reader-service. Only messages that can be converted to json + are allowed. + """ + self._receive_new_messages() + + if len(self._unread) == 0: + return None + + return self._unread.pop(0) + + def poll(self): + """Check if a message to read is available.""" + if len(self._unread) > 0: + return True + + self._receive_new_messages() + return len(self._unread) > 0 + + def send(self, message: Union[str, int, float, dict, list, tuple]): + """Send json-serializable messages.""" + dump = bytes(json.dumps((time.time(), message)), ENCODING) + self.unsent.append(dump) + + if not self.connect(): + logger.debug("Not connected") + return + + def send_all(): + while len(self.unsent) > 0: + unsent = self.unsent[0] + self.connection.sendall(unsent + END) + # sending worked, remove message + self.unsent.pop(0) + + # attempt sending twice in case it fails + try: + send_all() + except BrokenPipeError: + if not self.reconnect(): + logger.error( + '%s: The other side of "%s" disappeared', + type(self).__name__, + self._path, + ) + return + + try: + send_all() + except BrokenPipeError as error: + logger.error( + '%s: Failed to send via "%s": %s', + type(self).__name__, + self._path, + error, + ) + + +class _Client(Base): + """A socket that can be written to and read from.""" + + def connect(self): + if self.socket is not None: + return True + + try: + _socket = socket.socket(socket.AF_UNIX) + _socket.connect(self._path) + logger.debug('Connected to socket: "%s"', self._path) + _socket.setblocking(False) + except Exception as error: + logger.debug('Failed to connect to "%s": "%s"', self._path, error) + return False + + self.socket = _socket + self.connection = _socket + existing_clients[self._path] = self + return True + + def fileno(self): + """For compatibility with select.select.""" + self.connect() + return self.socket.fileno() + + def reconnect(self): + self.connection = None + self.socket = None + return self.connect() + + +def Client(path): + if path in existing_clients: + # ensure it is running, might have been closed + existing_clients[path].reset() + return existing_clients[path] + + return _Client(path) + + +class _Server(Base): + """A socket that can be written to and read from. + + It accepts one connection at a time, and drops old connections if + a new one is in sight. + """ + + def connect(self): + if self.socket is None: + if os.path.exists(self._path): + # leftover from the previous execution + os.remove(self._path) + + _socket = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + _socket.bind(self._path) + _socket.listen(1) + PathUtils.chown(self._path) + logger.debug('Created socket: "%s"', self._path) + self.socket = _socket + self.socket.setblocking(False) + existing_servers[self._path] = self + + incoming = len(select.select([self.socket], [], [], 0)[0]) != 0 + if not incoming and self.connection is None: + # no existing connection, no client attempting to connect + return False + + if not incoming and self.connection is not None: + # old connection + return True + + if incoming: + logger.debug('Incoming connection: "%s"', self._path) + connection = self.socket.accept()[0] + self.connection = connection + self.connection.setblocking(False) + + return True + + def fileno(self): + """For compatibility with select.select.""" + self.connect() + return self.connection.fileno() + + def reconnect(self): + self.connection = None + return self.connect() + + +def Server(path): + if path in existing_servers: + # ensure it is running, might have been closed + existing_servers[path].reset() + return existing_servers[path] + + return _Server(path) diff --git a/inputremapper/logging/__init__.py b/inputremapper/logging/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/inputremapper/logging/formatter.py b/inputremapper/logging/formatter.py new file mode 100644 index 0000000..3a7ab95 --- /dev/null +++ b/inputremapper/logging/formatter.py @@ -0,0 +1,143 @@ +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + +"""Logging setup for input-remapper.""" + +import logging +import os +import sys +from datetime import datetime +from typing import Dict + + +class ColorfulFormatter(logging.Formatter): + """Overwritten Formatter to print nicer logs. + + It colors all logs from the same filename in the same color to visually group them + together. It also adds process name, process id, file, line-number and time. + + If debug mode is not active, it will not do any of this. + """ + + def __init__(self, debug_mode: bool = False): + super().__init__() + + self.debug_mode = debug_mode + self.file_color_mapping: Dict[str, int] = {} + + # see https://en.wikipedia.org/wiki/ANSI_escape_code#8-bit + self.allowed_colors = [] + for r in range(0, 6): + for g in range(0, 6): + for b in range(0, 6): + # https://stackoverflow.com/a/596243 + brightness = 0.2126 * r + 0.7152 * g + 0.0722 * b + if brightness < 1: + # prefer light colors, because most people have a dark + # terminal background + continue + + if g + b <= 1: + # red makes it look like it's an error + continue + + if abs(g - b) < 2 and abs(b - r) < 2 and abs(r - g) < 2: + # no colors that are too grey + continue + + self.allowed_colors.append(self._get_ansi_code(r, g, b)) + + self.level_based_colors = { + logging.WARNING: 11, + logging.ERROR: 9, + logging.FATAL: 9, + } + + def _get_ansi_code(self, r: int, g: int, b: int) -> int: + return 16 + b + (6 * g) + (36 * r) + + def _word_to_color(self, word: str) -> int: + """Convert a word to a 8bit ansi color code.""" + digit_sum = sum([ord(char) for char in word]) + index = digit_sum % len(self.allowed_colors) + return self.allowed_colors[index] + + def _allocate_debug_log_color(self, record: logging.LogRecord): + """Get the color that represents the source file of the log.""" + if self.file_color_mapping.get(record.filename) is not None: + return self.file_color_mapping[record.filename] + + color = self._word_to_color(record.filename) + + if self.file_color_mapping.get(record.filename) is None: + # calculate the color for each file only once + self.file_color_mapping[record.filename] = color + + return color + + def _get_process_name(self): + """Generate a beaitiful to read name for this process.""" + process_path = sys.argv[0] + process_name = process_path.split("/")[-1] + + if "input-remapper-" in process_name: + process_name = process_name.replace("input-remapper-", "") + + if process_name == "gtk": + process_name = "GUI" + + return process_name + + def _get_format(self, record: logging.LogRecord): + """Generate a message format string.""" + if record.levelno == logging.INFO and not self.debug_mode: + # if not launched with --debug, then don't print "INFO:" + return "%(message)s" + + if not self.debug_mode: + color = self.level_based_colors.get(record.levelno, 9) + return f"\033[38;5;{color}m%(levelname)s\033[0m: %(message)s" + + color = self._allocate_debug_log_color(record) + if record.levelno in [logging.ERROR, logging.WARNING, logging.FATAL]: + # underline + style = f"\033[4;38;5;{color}m" + else: + style = f"\033[38;5;{color}m" + + process_color = self._word_to_color(f"{os.getpid()}{sys.argv[0]}") + + return ( # noqa + f'{datetime.now().strftime("%H:%M:%S.%f")} ' + f"\033[38;5;{process_color}m" # color + f"{os.getpid()} " + f"{self._get_process_name()} " + "\033[0m" # end style + f"{style}" + f"%(levelname)s " + f"%(filename)s:%(lineno)d: " + "%(message)s" + "\033[0m" # end style + ).replace(" ", " ") + + def format(self, record: logging.LogRecord): + """Overwritten format function.""" + # pylint: disable=protected-access + self._style._fmt = self._get_format(record) + return super().format(record) diff --git a/inputremapper/logging/logger.py b/inputremapper/logging/logger.py new file mode 100644 index 0000000..43993f9 --- /dev/null +++ b/inputremapper/logging/logger.py @@ -0,0 +1,172 @@ +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + +"""Logging setup for input-remapper.""" + +from __future__ import annotations # needed for the TYPE_CHECKING import + +import logging +import time +from typing import cast, Type, TYPE_CHECKING, List, Tuple +from evdev.ecodes import EV_ABS, EV_KEY, EV_REL, ABS_HAT0X, ABS_HAT0Y + +from inputremapper.input_event import InputEvent +from inputremapper.logging.formatter import ColorfulFormatter +from inputremapper.installation_info import VERSION, COMMIT_HASH + +if TYPE_CHECKING: + from inputremapper.injection.mapping_handler import MappingHandler + + +start = time.time() + + +class Logger(logging.Logger): + previous_abs_rel_log_time = 0.0 + previous_write_debug_log = None + analog_log_threshold = 0.1 # s + + def debug_mapping_handler(self, mapping_handler: MappingHandler) -> None: + """Parse the structure of a mapping_handler and log it.""" + if not self.isEnabledFor(logging.DEBUG): + return + + lines_and_indent = self._build_mapping_handler_description_tree(mapping_handler) + for line in lines_and_indent: + indent = " " + msg = indent * line[1] + line[0] + self._log(logging.DEBUG, msg, args=()) + + def write(self, key, uinput) -> None: + """Log that an event is being written + + Parameters + ---------- + key + anything that can be string formatted, but usually a tuple of + (type, code, value) tuples + """ + # pylint: disable=protected-access + if not self.isEnabledFor(logging.DEBUG): + return + + if isinstance(key, InputEvent): + if key.type not in [EV_ABS, EV_REL, EV_KEY]: + return + + # Avoid spaming the terminal with tons of high-resolution logs + now = time.time() + if key.type in [EV_ABS, EV_REL] and key.code not in [ABS_HAT0X, ABS_HAT0Y]: + if now - self.previous_abs_rel_log_time < self.analog_log_threshold: + return + + self.previous_abs_rel_log_time = now + + str_key = repr(key) + str_key = str_key.replace(",)", ")") + + msg = f'Writing {str_key} to "{uinput.name}"' + + if msg == self.previous_write_debug_log: + # avoid some spam + return + + self.previous_write_debug_log = msg + + self._log(logging.DEBUG, msg, args=(), stacklevel=2) + + def _build_mapping_handler_description_tree( + self, + mapping_handler: MappingHandler, + indent=0, + ) -> List[Tuple[str, int]]: + lines_and_indent = [ + (str(mapping_handler), indent), + ] + + mapping_handlers = mapping_handler.get_children() + for sub_handler in mapping_handlers: + sub_list = self._build_mapping_handler_description_tree( + sub_handler, indent + 1 + ) + lines_and_indent.extend(sub_list) + + return lines_and_indent + + def is_debug(self) -> bool: + """True, if the logger is currently in DEBUG mode.""" + return self.level <= logging.DEBUG + + def log_info(self, name: str = "input-remapper") -> None: + """Log version and name to the console.""" + logger.info( + "%s %s %s https://github.com/sezanzeb/input-remapper", + name, + VERSION, + COMMIT_HASH, + ) + + if EVDEV_VERSION: + logger.info("python-evdev %s", EVDEV_VERSION) + + if self.is_debug(): + logger.warning( + "Debug level will log all your keystrokes! Do not post this " + "output in the internet if you typed in sensitive or private " + "information with your device!" + ) + + def update_verbosity(self, debug: bool) -> None: + """Set the logging verbosity according to the settings object.""" + if debug: + self.setLevel(logging.DEBUG) + else: + self.setLevel(logging.INFO) + + for handler in self.handlers: + handler.setFormatter(ColorfulFormatter(debug)) + + @classmethod + def bootstrap_logger(cls: Type[Logger]) -> Logger: + # https://github.com/python/typeshed/issues/1801 + logging.setLoggerClass(cls) + logger = cast(Logger, logging.getLogger("input-remapper")) + + handler = logging.StreamHandler() + handler.setFormatter(ColorfulFormatter(False)) + logger.addHandler(handler) + logger.setLevel(logging.INFO) + logging.getLogger("asyncio").setLevel(logging.WARNING) + return logger + + +logger = Logger.bootstrap_logger() + + +EVDEV_VERSION = None +try: + from importlib.metadata import version + + EVDEV_VERSION = version("evdev") +except Exception as error: + logger.info("Could not figure out the evdev version") + logger.debug(error) + +# check if the version is something like 1.5.0-beta or 1.5.0-beta.5 +IS_BETA = "beta" in VERSION diff --git a/inputremapper/user.py b/inputremapper/user.py new file mode 100644 index 0000000..5a86210 --- /dev/null +++ b/inputremapper/user.py @@ -0,0 +1,71 @@ +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + + +"""Figure out the user.""" + + +import getpass +import os +import pwd + + +class UserUtils: + @staticmethod + def get_user(): + """Try to find the user who called sudo/pkexec.""" + try: + return os.getlogin() + except OSError: + # failed in some ubuntu installations and in systemd services + pass + + try: + user = os.environ["USER"] + except KeyError: + # possibly the systemd service. no sudo was used + return getpass.getuser() + + if user == "root": + try: + return os.environ["SUDO_USER"] + except KeyError: + # no sudo was used + pass + + try: + pkexec_uid = int(os.environ["PKEXEC_UID"]) + return pwd.getpwuid(pkexec_uid).pw_name + except KeyError: + # no pkexec was used or the uid is unknown + pass + + return user + + @staticmethod + def get_home(user): + """Try to find the user's home directory.""" + return pwd.getpwnam(user).pw_dir + + # An odd construct, but it can't really be helped because this was done as an + # afterthought, after I learned about proper object-oriented software architecture. + # TODO Eventually, UserUtils should be constructed and injected as a UserService, + # which then initializes stuff. + user = get_user() + home = get_home(user) diff --git a/inputremapper/utils.py b/inputremapper/utils.py new file mode 100644 index 0000000..8a10f13 --- /dev/null +++ b/inputremapper/utils.py @@ -0,0 +1,64 @@ +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + + +"""Utility functions.""" + +import sys +from hashlib import md5 +from typing import Optional, NewType + +import evdev + +DeviceHash = NewType("DeviceHash", str) + + +def is_service() -> bool: + return sys.argv[0].endswith("input-remapper-service") + + +def get_device_hash(device: evdev.InputDevice) -> DeviceHash: + """get a unique hash for the given device.""" + # The builtin hash() function can not be used because it is randomly + # seeded at python startup. + # A non-cryptographic hash would be faster but there is none in the standard lib + # This hash needs to stay the same across reboots, and even stay the same when + # moving the config to a new computer. + s = str(device.capabilities(absinfo=False)) + device.name + return DeviceHash(md5(s.encode()).hexdigest().lower()) + + +def get_evdev_constant_name(type_: Optional[int], code: Optional[int], *_) -> str: + """Handy function to get the evdev constant name for display purposes. + + Returns "unknown" for unknown events. + """ + # using this function is more readable than + # type_, code = event.type_and_code + # name = evdev.ecodes.bytype[type_][code] + name = evdev.ecodes.bytype.get(type_, {}).get(code) + + if type(name) in [list, tuple]: + # python-evdev >= 1.8.0 uses tuples + name = name[0] + + if name is None: + return "unknown" + + return name diff --git a/install/__init__.py b/install/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/install/__main__.py b/install/__main__.py new file mode 100644 index 0000000..902414a --- /dev/null +++ b/install/__main__.py @@ -0,0 +1,116 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + +"""Build input-remapper including its translations and system-files. + +pip, in many cases, fails to install data files, which need to go into system paths, +and instead puts them (despite them being absolute paths) into +/usr/lib/python3/inputremapper/usr/share/... + +python3 setup.py install is deprecated + +meson fails to install the module into a path that can actually be imported, and +its python features require one to specify each individual file of the module. + +So instead, input-remapper uses a custom python solution. Hopefulls this works well +enough to prevent all ModuleNotFoundErrors in the future. +""" + +import shutil +import os +import sys +from enum import Enum + +from install.check_dependencies import check_dependencies +from install.data_files import build_data_files +from install.module import build_input_remapper_module +from install.language import make_lang + +import argparse + + +class Components(str, Enum): + data_files = "data_files" + python_module = "python_module" + + +def parse_args(): + parser = argparse.ArgumentParser(description="Tool to install input-remapper with.") + parser.add_argument( + "--root", + type=str, + help=( + "Where to install input-remapper to. For example ./build to prepare a " + "package archive or for debugging, or / to install it to the system." + ), + required=True, + ) + parser.add_argument( + "--components", + type=str, nargs='+', + help=( + f'A list of components to install. Default: --components ' + f'{Components.python_module.value} {Components.data_files.value}' + ), + default=[Components.python_module, Components.data_files], + metavar="COMPONENT" + ) + args = parser.parse_args() + return args + + +def ask(msg) -> bool: + answer = "" + while answer not in ["y", "n"]: + answer = input(f"{msg} [y/n] ").lower() + return answer == "y" + + +def print_headline(message: str) -> None: + print(f"\033[7m{message}\033[0m") + + +def main() -> None: + args = parse_args() + + if os.path.exists(args.root) and not args.root.startswith("/"): + delete = ask(f"{args.root} already exists. Delete?") + if delete: + shutil.rmtree(args.root) + else: + sys.exit(3) + + for component in args.components: + if component not in [Components.data_files, Components.python_module]: + raise ValueError(f"Unknown component {component}") + + if Components.data_files in args.components: + print_headline(f'Installing component "{Components.data_files.value}"') + build_data_files(args.root) + make_lang(args.root) + + if Components.python_module in args.components: + print_headline(f'Installing component "{Components.python_module.value}"') + check_dependencies() + build_input_remapper_module(args.root) + + +if __name__ == "__main__": + main() diff --git a/install/check_dependencies.py b/install/check_dependencies.py new file mode 100644 index 0000000..83f8ae4 --- /dev/null +++ b/install/check_dependencies.py @@ -0,0 +1,43 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + + +def check_dependencies() -> None: + print("Checking dependencies") + try: + import gi + + gi.require_version("Gdk", "3.0") + gi.require_version("GLib", "2.0") + gi.require_version("Gst", "1.0") + gi.require_version("Gtk", "3.0") + gi.require_version("GtkSource", "4") + from gi.repository import GObject, Gtk, Gst, Gdk, GLib, Pango, Gio, GtkSource + import evdev + import psutil + import dasbus + import pygobject + import pydantic + + print("All required Python modules found") + except ImportError as e: + print(f"\033[93mMissing Python module: {e}\033[0m") + except Exception as e: + print(f"\033[93mException while checking dependencies: {e}\033[0m") diff --git a/install/data_files.py b/install/data_files.py new file mode 100644 index 0000000..d4bf331 --- /dev/null +++ b/install/data_files.py @@ -0,0 +1,66 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + +"""Copy input-remappers system-files around.""" + +import glob +import os +import shutil + + +DATA_DIR = "usr/share/input-remapper" + + +def get_data_files() -> list[tuple[str, list[str]]]: + return [ + # see development.md#files + (DATA_DIR, glob.glob("data/*")), + ("usr/share/applications/", ["data/input-remapper-gtk.desktop"]), + ( + "usr/share/metainfo/", + ["data/io.github.sezanzeb.input_remapper.metainfo.xml"], + ), + ("usr/share/icons/hicolor/scalable/apps/", ["data/input-remapper.svg"]), + ("usr/share/polkit-1/actions/", ["data/input-remapper.policy"]), + ("usr/lib/systemd/system", ["data/input-remapper.service"]), + # Fun fact: At some point during development and testing on arch, I ended up + # with an empty inputremapper.Control.conf file, causing dbus to fail to start, + # which rendered the whole operating system unusable. + ("usr/share/dbus-1/system.d/", ["data/inputremapper.Control.conf"]), + ("etc/xdg/autostart/", ["data/input-remapper-autoload.desktop"]), + ("usr/lib/udev/rules.d", ["data/69-input-remapper-forwarded.rules"]), + ("usr/lib/udev/rules.d", ["data/99-input-remapper.rules"]), + ("usr/bin/", ["bin/input-remapper-gtk"]), + ("usr/bin/", ["bin/input-remapper-service"]), + ("usr/bin/", ["bin/input-remapper-control"]), + ("usr/bin/", ["bin/input-remapper-reader-service"]), + ] + + +def build_data_files(root: str) -> None: + for target_dir, files in get_data_files(): + # We specify the root via argv instead. Argparse would ignore the first + # arguments with a leading slash. + assert not target_dir.startswith("/") + for file_ in files: + destination_dir = os.path.join(root, target_dir) + print("Copying", file_, "to", destination_dir) + os.makedirs(destination_dir, exist_ok=True) + shutil.copy(file_, os.path.join(destination_dir, os.path.basename(file_))) diff --git a/install/language.py b/install/language.py new file mode 100644 index 0000000..93dbac8 --- /dev/null +++ b/install/language.py @@ -0,0 +1,54 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + +"""Build language files and copy them to the target.""" + +import glob +import os +import subprocess +from os.path import basename, splitext, join, dirname + + +def make_lang(root: str) -> None: + """Build po files as mo files into the expected directory.""" + os.makedirs("mo", exist_ok=True) + for po_file in glob.glob("po/*.po"): + lang = splitext(basename(po_file))[0] + target = join( + root, + "usr", + "share", + "input-remapper", + "lang", + lang, + "LC_MESSAGES", + "input-remapper.mo", + ) + os.makedirs(dirname(target), exist_ok=True) + print(f"Generating translation {target}") + subprocess.run( + [ + "msgfmt", + "-o", + target, + str(po_file), + ], + check=True, + ) diff --git a/install/module.py b/install/module.py new file mode 100644 index 0000000..a1db5c6 --- /dev/null +++ b/install/module.py @@ -0,0 +1,187 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + +"""Figure out where the python module needs to be placed in order to reliable import it. + +Dealing with varying sys.paths is frustrating. The sys.paths in /usr are not mirrored +in /usr/local consistently. Meson uses /usr/local/python3, which ubuntus python3 does +not import from. /usr/local is ignored by python within udev. Arch does not import +from /usr/local. When in doubt, do not install into /usr/local. I do not want to deal +with ModuleNotFoundErrors. I don't care if it should be in /usr/local by convention. +I don't know how much this varies across ubuntu versions and other debian based +distributions. I want the .deb to install reliably. + +sys.path samples: +endeavouros user: ['', '/usr/lib/python313.zip', '/usr/lib/python3.13', '/usr/lib/python3.13/lib-dynload', '/usr/lib/python3.13/site-packages'] +endeavouros root: ['', '/usr/lib/python313.zip', '/usr/lib/python3.13', '/usr/lib/python3.13/lib-dynload', '/usr/lib/python3.13/site-packages'] +endeavouros udev: ['/usr/bin', '/lib/python313.zip', '/lib/python3.13', '/lib/python3.13/lib-dynload', '/lib/python3.13/site-packages'] +ubuntu 25.04 user: ['', '/usr/lib/python313.zip', '/usr/lib/python3.13', '/usr/lib/python3.13/lib-dynload', '/home/user/.local/lib/python3.13/site-packages', '/usr/local/lib/python3.13/dist-packages', '/usr/lib/python3/dist-packages'] +ubuntu 25.04 root: ['', '/usr/lib/python313.zip', '/usr/lib/python3.13', '/usr/lib/python3.13/lib-dynload', '/usr/local/lib/python3.13/dist-packages', '/usr/lib/python3/dist-packages'] +ubuntu 25.04 udev: ['/usr/bin', '/lib/python313.zip', '/lib/python3.13', '/lib/python3.13/lib-dynload', '/lib/python3/dist-packages'] +""" + +import sys +import os +import subprocess +import shutil +import re +import tomllib + +from install.data_files import DATA_DIR + + +def _key(path) -> int: + favorability = 0 + + # Good + if re.match(r".*/lib/python3/.+-packages", path): + # Independent of the installed python version, so it probably just continues + # to be importable after a python version upgrade. + favorability = 5 + elif re.match(r".*/lib/python3.+?/.+-packages", path): + favorability = 4 + + # Not meant for python code, but it probably works flawlessly + elif re.match(r".*/lib/python3.+?/lib-dynload", path): + favorability = 3 + + # pip refuses to uninstall from standard-library directories once installed there + elif re.match(r".*/lib/python3", path): + favorability = 2 + elif re.match(r".*/lib/python3.+?", path): + favorability = 1 + + # Check the prefix + # (beware, udev imports from /lib, which I think is equivalent to /usr/lib, so + # don't require /usr as a prefix) + if path.startswith("/usr/local"): + # udev does not import from /usr/local + favorability -= 5 + elif path.startswith("/home"): + # Something like /home/user/.local/lib/python3.13/site-packages + # The python package needs to be installed system-wide + favorability -= 50 + + if not os.path.exists(path): + # If it doesn't exist yet, pip will apparently create it + favorability -= 2 + elif not os.path.isdir(path): + # If it exists but is not a dir, do not use it + favorability -= 99 + + return -favorability + + +def _get_packages_dir() -> str: + """Where to install the input-remapper module to. + + For example "/usr/lib/python3.13 + """ + packages_dirs = sorted(sys.path, key=_key) + packages_dir = packages_dirs[0] + print(f'Picked "{packages_dir}" from {packages_dirs}') + return packages_dir + + +def _get_commit_hash() -> str: + git_call = subprocess.check_output(["git", "rev-parse", "HEAD"]) + commit = git_call.decode().strip() + return commit + + +def _set_variables(target: str) -> None: + path = os.path.join(target, "inputremapper", "installation_info.py") + assert os.path.exists(path) + + with open(path, "r") as f: + contents = f.read() + + with open("pyproject.toml", "rb") as f: + version = tomllib.load(f)["project"]["version"] + + values = { + "COMMIT_HASH": _get_commit_hash(), + "VERSION": version, + "DATA_DIR": f"/{DATA_DIR}", + } + + print("Setting", values, "in", path) + + with open(path, "w") as f: + for variable_name, value in values.items(): + contents = re.sub( + rf"{variable_name}\s*=.+", + f"{variable_name} = '{value}'", + contents, + ) + + f.write(contents) + + +def build_input_remapper_module(root: str) -> None: + # I'd use --prefix and --root, but + # `pip install . --root ./build --prefix usr` + # makes it end up in ./build/usr/local + + # if root is not /, then input-remapper is built into some other directory for the + # purpose of merging it into / later. So regardless of root, we use the same + # package_dir. + package_dir = _get_packages_dir() + if package_dir.startswith("/"): + package_dir = package_dir[1:] + + target = os.path.join(root, package_dir) + + # Make sure we don't install input-remapper twice on that system into two different + # paths, which commonly causes unexpected behavior. Also, we need to specifically + # tell pip to replace an existing installation, or uninstall it beforehand. + if root == "/": + print("Uninstalling existing input-remapper installation") + os.system("pip uninstall input-remapper --break-system-packages -y") + # If root is not "/", it is probably part of a packaging script for a distro, + # which will take care of uninstalling the old python-package for us. + + command = [ + sys.executable, + "-m", + "pip", + "install", + ".", + "--target", + target, + "--no-deps", + ] + + print("Running", " ".join(command)) + + # Fix the stdout ordering in github workflows + sys.stdout.flush() + + subprocess.check_call(command) + + # pip puts its own leftovers into ./build that we don't need. + # This only happens, when root is set to "build". + if "build" in root: + if os.path.exists("./build/lib/"): + shutil.rmtree("./build/lib/") + if os.path.exists("./build/bdist.linux-x86_64/"): + shutil.rmtree("./build/bdist.linux-x86_64/") + + _set_variables(target) diff --git a/install/uninstall.py b/install/uninstall.py new file mode 100644 index 0000000..ff38f4b --- /dev/null +++ b/install/uninstall.py @@ -0,0 +1,77 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + +"""Remove a system-wide input-remapper installation.""" + +import os +import sys +import shutil +import subprocess + +from install.data_files import get_data_files + + +def uninstall() -> None: + # remove data files + for directory, files in get_data_files(): + for file_ in files: + filename = os.path.basename(file_) + path = os.path.join("/", directory, filename) + + # Removing files from system directories is risky. Low propability, very + # high damage. To avoid accidentally removing system directories due to + # a bug, assert that this is an input-remapper path. + # If this happens, urgently create a new issue on github! + assert "input" in path and "remapper" in path + + try: + os.unlink(path) + print("Removed", path) + except FileNotFoundError: + print(path, "not found") + + # language files are not in data_files + data_path = "/usr/share/input-remapper/" + try: + shutil.rmtree(data_path) + print("Removed", data_path) + except FileNotFoundError: + print(data_path, "not found") + + # remove pip module + command = [ + sys.executable, + "-m", + "pip", + "uninstall", + "input-remapper", + "--break-system-packages", + ] + print("Running", " ".join(command)) + # Fix the stdout ordering in github workflows + sys.stdout.flush() + subprocess.check_call(command) + + os.system("sudo systemctl stop input-remapper") + os.system("sudo systemctl daemon-reload") + + +if __name__ == "__main__": + uninstall() diff --git a/po/fr.po b/po/fr.po new file mode 120000 index 0000000..65af04b --- /dev/null +++ b/po/fr.po @@ -0,0 +1 @@ +fr_FR.po \ No newline at end of file diff --git a/po/fr_FR.po b/po/fr_FR.po new file mode 100644 index 0000000..31cf593 --- /dev/null +++ b/po/fr_FR.po @@ -0,0 +1,572 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +msgid "" +msgstr "" +"Project-Id-Version: \n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2023-05-16 08:30-0300\n" +"PO-Revision-Date: 2023-02-03 09:09+0100\n" +"Last-Translator: \n" +"Language-Team: \n" +"Language: fr_FR\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n > 1);\n" +"X-Generator: Poedit 3.2.2\n" + +#: inputremapper/gui/controller.py:222 +msgid "" +"\n" +"If you mean to create a key or macro mapping go to the advanced input " +"configuration and set a \"Trigger Threshold\" for " +msgstr "" + +#: inputremapper/gui/controller.py:239 +msgid "" +"\n" +"If you mean to create an analog axis mapping go to the advanced input " +"configuration and set an input to \"Use as Analog\"." +msgstr "" + +#: inputremapper/gui/controller.py:779 +msgid "" +"\n" +"The input \"{}\" will be used as analog input." +msgstr "" + +#: inputremapper/gui/controller.py:763 +msgid "" +"\n" +"This will remove \"{}\" from the text input!" +msgstr "" + +#: inputremapper/gui/controller.py:784 +msgid "" +"\n" +"You need to record an analog input." +msgstr "" + +#: data/input-remapper.glade:544 +msgid " (recording ...)" +msgstr "" + +#: inputremapper/gui/controller.py:174 +#, fuzzy, python-format +msgid "%d Mapping errors at \"%s\", hover for info" +msgstr "Erreur de syntaxe à %s, survoller pour plus d'informations" + +#: inputremapper/gui/controller.py:661 +msgid ", CTRL + DEL to stop" +msgstr ", CTRL + SUPPR pour arrêter" + +#: data/input-remapper.glade:1421 +msgid "About" +msgstr "À propos" + +#: data/input-remapper.glade:422 +msgid "" +"Activate this to load the preset next time the device connects, or when the " +"user logs in" +msgstr "" + +#: data/input-remapper.glade:575 +msgid "Add" +msgstr "" + +#: inputremapper/gui/components/editor.py:554 +msgid "Add a mapping first" +msgstr "" + +#: data/input-remapper.glade:606 data/input-remapper.glade:1618 +msgid "Advanced" +msgstr "" + +#: data/input-remapper.glade:773 data/input-remapper.glade:1118 +msgid "Analog Axis" +msgstr "" + +#: inputremapper/gui/controller.py:657 +#, python-format +msgid "Applied preset %s" +msgstr "Préréglage %s appliqué" + +#: data/input-remapper.glade:318 +msgid "Apply" +msgstr "Appliquer" + +#: inputremapper/gui/controller.py:522 +#, fuzzy, python-format +msgid "Are you sure you want to delete the preset \"%s\"?" +msgstr "Êtes-vous sûr de vouloir supprimer le préréglage %s ?" + +#: inputremapper/gui/controller.py:567 +#, fuzzy +msgid "Are you sure you want to delete this mapping?" +msgstr "Êtes-vous sûr de vouloir supprimer ce mapping ?" + +#: data/input-remapper.glade:410 +msgid "Autoload" +msgstr "Charger automatiquement" + +#: data/input-remapper.glade:854 +msgid "Available output axes are affected by the Target setting." +msgstr "" + +#: inputremapper/gui/controller.py:621 +msgid "Cannot apply empty preset file" +msgstr "Impossible d'appliquer un fichier de préréglage vide" + +#: inputremapper/gui/components/editor.py:238 +msgid "Change Mapping Name" +msgstr "" + +#: data/input-remapper.glade:352 +msgid "Copy" +msgstr "Copier" + +#: data/input-remapper.glade:189 +msgid "Create a new preset" +msgstr "Créer un nouveau préréglage" + +#: data/input-remapper.glade:925 +msgid "Deadzone" +msgstr "" + +#: data/input-remapper.glade:368 data/input-remapper.glade:620 +msgid "Delete" +msgstr "Supprimer" + +#: data/input-remapper.glade:624 +msgid "Delete this entry" +msgstr "Supprimer ce mapping" + +#: data/input-remapper.glade:372 +msgid "Delete this preset" +msgstr "Supprimer ce préréglage" + +#: data/input-remapper.glade:162 +#, fuzzy +msgid "Device Name" +msgstr "Périphérique" + +#: data/input-remapper.glade:148 +#, fuzzy +msgid "Devices" +msgstr "Périphérique" + +#: data/input-remapper.glade:356 +msgid "Duplicate this preset" +msgstr "Dupliquer ce préréglage" + +#: data/input-remapper.glade:1208 +msgid "Editor" +msgstr "" + +#: inputremapper/configs/mapping.py:77 +msgid "Empty Mapping" +msgstr "" + +#: inputremapper/gui/components/editor.py:404 +msgid "Enter your output here" +msgstr "" + +#: data/input-remapper.glade:1762 +msgid "Event Specific" +msgstr "" + +#: data/input-remapper.glade:1009 +msgid "Expo" +msgstr "" + +#: inputremapper/gui/controller.py:648 inputremapper/gui/controller.py:673 +#, python-format +msgid "Failed to apply preset %s" +msgstr "Échec de l'application du préréglage %s" + +#: data/input-remapper.glade:967 +msgid "Gain" +msgstr "" + +#: data/input-remapper.glade:1736 +msgid "General" +msgstr "" + +#: data/input-remapper.glade:1312 +msgid "Help" +msgstr "Aide" + +#: data/input-remapper.glade:510 +msgid "Input" +msgstr "" + +#: data/input-remapper.glade:1296 +msgid "Input Remapper" +msgstr "Input Remapper" + +#: data/input-remapper.glade:1087 +msgid "Input cutoff" +msgstr "" + +#: data/input-remapper.glade:760 data/input-remapper.glade:1180 +msgid "Key or Macro" +msgstr "" + +#: data/input-remapper.glade:1801 +msgid "Map this input to an Analog Axis" +msgstr "" + +#: data/input-remapper.glade:185 +msgid "New" +msgstr "Nouveau" + +#: inputremapper/gui/components/editor.py:980 +msgid "No Axis" +msgstr "" + +#: data/input-remapper.glade:721 +msgid "Output" +msgstr "" + +#: data/input-remapper.glade:862 +msgid "Output axis" +msgstr "" + +#: inputremapper/gui/controller.py:508 inputremapper/gui/controller.py:582 +msgid "Permission denied!" +msgstr "Autorisation refusée !" + +#: data/input-remapper.glade:287 +#, fuzzy +msgid "Preset Name" +msgstr "Préréglage" + +#: data/input-remapper.glade:272 +#, fuzzy +msgid "Presets" +msgstr "Préréglage" + +#: data/input-remapper.glade:590 +msgid "Record" +msgstr "" + +#: data/input-remapper.glade:594 +msgid "Record a button of your device that should be remapped" +msgstr "Enregistrer un bouton de votre périphérique qui devrait être remappé" + +#: inputremapper/gui/components/editor.py:563 +msgid "Record input first" +msgstr "" + +#: inputremapper/gui/components/editor.py:65 +#, fuzzy +msgid "Record the input first" +msgstr "Set the key first" + +#: data/input-remapper.glade:1708 +msgid "" +"Release all inputs which are part of the combination before the mapping is " +"injected" +msgstr "" + +#: data/input-remapper.glade:1711 +msgid "Release input" +msgstr "" + +#: data/input-remapper.glade:1683 +msgid "Release timeout" +msgstr "" + +#: inputremapper/gui/controller.py:208 +msgid "Remove the Analog Output Axis when specifying a macro or key output" +msgstr "" + +#: inputremapper/gui/controller.py:199 +msgid "" +"Remove the macro or key from the macro input field when specifying an analog " +"output" +msgstr "" + +#: data/input-remapper.glade:1776 +msgid "Remove this input" +msgstr "" + +#: data/input-remapper.glade:394 +msgid "Rename" +msgstr "Renommer" + +#: data/input-remapper.glade:452 +msgid "Save the entered name" +msgstr "Enregistrer le nom saisi" + +#: data/input-remapper.glade:1449 +msgid "" +"See usage.md online on github for comprehensive information.\n" +"\n" +"A \"key + key + ... + key\" syntax can be used to trigger key combinations. " +"For example \"Control_L + a\".\n" +"\n" +"Writing \"disable\" as a mapping disables a key.\n" +"\n" +"Macros allow multiple characters to be written with a single key-press. " +"Information about programming them is available online on github. See macros.md and examples.md" +msgstr "" +"Voir usage.md en ligne sur github pour des informations " +"complètes.\n" +"La syntaxe \"touche + touche + ... + touche\" peut être utilisée pour " +"déclencher des combinaisons de touches.\n" +"Par exemple \"Control_L + a\".\n" +"Écrire \"disable\" comme mapping désactive une touche.\n" +"Les macros permettent d'écrire plusieurs caractères avec un seul appui de " +"touche. Des informations sur leur programmation sont disponibles en ligne " +"sur github. See macros.md et examples.md" + +#: data/input-remapper.glade:1590 +msgid "Shortcuts" +msgstr "Raccourcis" + +#: data/input-remapper.glade:1492 +msgid "" +"Shortcuts only work while keys are not being recorded and the gui is in " +"focus." +msgstr "" +"Les raccourcis fonctionnent uniquement lorsque l'enregistrement des touches " +"n'est pas en cours et l'interface graphique a le focus." + +#: data/input-remapper.glade:322 +msgid "Start injecting. Don't hold down any keys while the injection starts" +msgstr "" +"Démarrer l'injection. Ne maintenir aucune touche pendant que l'injection " +"démarre" + +#: inputremapper/gui/controller.py:643 +msgid "Starting injection..." +msgstr "Injection en cours..." + +#: data/input-remapper.glade:201 data/input-remapper.glade:334 +msgid "Stop" +msgstr "" + +#: inputremapper/gui/controller.py:710 +#, fuzzy +msgid "Stopped the injection" +msgstr "arrête l'injection" + +#: data/input-remapper.glade:205 data/input-remapper.glade:338 +msgid "" +"Stops the Injection for the selected device,\n" +"gives your keys their original function back\n" +"Shortcut: ctrl + del" +msgstr "" + +#: data/input-remapper.glade:812 +msgid "Target" +msgstr "" + +#: data/input-remapper.glade:1078 +msgid "" +"The Speed at which the Input is considered at maximum.\n" +"Only relevant when mapping relative inputs (e.g. mouse) to absolute outputs " +"(e.g. gamepad)" +msgstr "" + +#: inputremapper/gui/controller.py:234 +msgid "" +"The input specifies a key or macro input, but no macro or key is programmed." +msgstr "" + +#: inputremapper/gui/controller.py:213 +msgid "The input specifies an analog axis, but no output axis is selected." +msgstr "" + +#: data/input-remapper.glade:803 +msgid "The type of device this mapping is emulating." +msgstr "Le type de périphérique émulé par ce mapping." + +#: data/input-remapper.glade:1831 +msgid "Trigger threshold" +msgstr "" + +#: data/input-remapper.glade:743 +msgid "Type" +msgstr "" + +#: data/input-remapper.glade:1473 +msgid "Usage" +msgstr "Utilisation" + +#: inputremapper/gui/controller.py:593 +#, fuzzy +msgid "Use \"Stop\" to stop before editing" +msgstr "Utiliser \"Arrêter l'injection\" pour arrêter avant l'édition" + +#: data/input-remapper.glade:1805 +msgid "Use as analog" +msgstr "" + +#: data/input-remapper.glade:1366 +msgid "Version unknown" +msgstr "Version inconnue" + +#: data/input-remapper.glade:1134 +msgid "What should be written. For example KEY_A" +msgstr "" + +#: inputremapper/gui/controller.py:761 +msgid "You are about to change the mapping to analog." +msgstr "" + +#: data/input-remapper.glade:1161 +msgid "You can copy this text into the output" +msgstr "" + +#: data/input-remapper.glade:1383 +msgid "" +"You can find more information and report bugs at\n" +"https://github.com/" +"sezanzeb/input-remapper" +msgstr "" +"Vous pouvez trouver plus d'informations ou signaler un bug sur\n" +"https://github.com/" +"sezanzeb/input-remapper" + +#: inputremapper/gui/controller.py:623 +#, fuzzy +msgid "You need to add mappings first" +msgstr "Vous devez d'abord ajouter des touches et sauvegarder" + +#: inputremapper/gui/controller.py:358 +#, fuzzy +msgid "" +"Your system might reinterpret combinations with those after they are " +"injected, and by doing so break them." +msgstr "avec celles-ci après leur injection, et ce faisant " + +#: data/input-remapper.glade:1524 +msgid "closes the application" +msgstr "ferme l'application" + +#: data/input-remapper.glade:1512 +msgid "ctrl + del" +msgstr "ctrl + del" + +#: data/input-remapper.glade:1536 +msgid "ctrl + q" +msgstr "ctrl + q" + +#: data/input-remapper.glade:1548 +msgid "ctrl + r" +msgstr "ctrl + r" + +#: inputremapper/gui/controller.py:356 +msgid "ctrl, alt and shift may not combine properly" +msgstr "ctrl, alt et shift peuvent ne pas se combiner correctement" + +#: inputremapper/gui/data_manager.py:56 +#, fuzzy +msgid "new preset" +msgstr "Créer un nouveau préréglage" + +#: data/input-remapper.glade:531 inputremapper/gui/user_interface.py:385 +msgid "no input configured" +msgstr "" + +#: data/input-remapper.glade:1560 +msgid "refreshes the device list" +msgstr "rafraîchit la liste des périphériques" + +#: data/input-remapper.glade:1572 +msgid "stops the injection" +msgstr "arrête l'injection" + +#: data/input-remapper.glade:1403 +#, fuzzy +msgid "" +"© 2025 Sezanzeb b8x45ygc9@mozmail.com\n" +"This program comes with absolutely no warranty.\n" +"See the GNU General " +"Public License, version 3 or later for details." +msgstr "" +"© 2021 Sezanzeb b8x45ygc9@mozmail.com\n" +"Ce programme est fourni sans aucune garantie.\n" +"Voir la Licence GNU " +"General Public, version 3 ou ultérieure pour plus de détails." + +#, python-format +#~ msgid "\"%s\" already mapped to \"%s\"" +#~ msgstr "\\\"%s\\\" déjà mappé sur \\\"%s\\\"" + +#~ msgid "Applied the system default" +#~ msgstr "Réglage système restauré" + +#~ msgid "Buttons" +#~ msgstr "Boutons" + +#~ msgid "Cancel" +#~ msgstr "Annuler" + +#~ msgid "Change Key" +#~ msgstr "Changer la touche" + +#~ msgid "Joystick" +#~ msgstr "Joystick" + +#~ msgid "Left joystick" +#~ msgstr "Joystick gauche" + +#~ msgid "Mouse" +#~ msgstr "Souris" + +#~ msgid "Mouse speed" +#~ msgstr "Vitesse de la souris" + +#~ msgid "Press Key" +#~ msgstr "Appuyer sur une touche" + +#~ msgid "Right joystick" +#~ msgstr "Joystick droite" + +#~ msgid "" +#~ "Shortcut: ctrl + del\n" +#~ "Gives your keys back their original function" +#~ msgstr "" +#~ "Raccourci : ctrl + del\n" +#~ "Réinitialise vos touches à leur fonctionnalité d'origine" + +#~ msgid "Stop Injection" +#~ msgstr "Arrêter l'injection" + +#~ msgid "The helper did not start" +#~ msgstr "Le helper n'a pas démarré" + +#~ msgid "" +#~ "To automatically apply the preset after your login or when it connects." +#~ msgstr "" +#~ "Pour appliquer automatiquement le préréglage après votre login ou à la " +#~ "connexion." + +#, python-format +#~ msgid "Unknown mapping %s" +#~ msgstr "Mapping inconnu %s" + +#~ msgid "Wheel" +#~ msgstr "Roulette" + +#~ msgid "Your system might reinterpret combinations " +#~ msgstr "Votre système peut réinterpréter les combinaisons " + +#~ msgid "break them." +#~ msgstr "les casser." + +#~ msgid "new entry" +#~ msgstr "nouveau mapping" diff --git a/po/input-remapper.pot b/po/input-remapper.pot new file mode 100644 index 0000000..4d5909e --- /dev/null +++ b/po/input-remapper.pot @@ -0,0 +1,467 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2023-05-16 08:30-0300\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" + +#: inputremapper/gui/controller.py:222 +msgid "" +"\n" +"If you mean to create a key or macro mapping go to the advanced input " +"configuration and set a \"Trigger Threshold\" for " +msgstr "" + +#: inputremapper/gui/controller.py:239 +msgid "" +"\n" +"If you mean to create an analog axis mapping go to the advanced input " +"configuration and set an input to \"Use as Analog\"." +msgstr "" + +#: inputremapper/gui/controller.py:779 +msgid "" +"\n" +"The input \"{}\" will be used as analog input." +msgstr "" + +#: inputremapper/gui/controller.py:763 +msgid "" +"\n" +"This will remove \"{}\" from the text input!" +msgstr "" + +#: inputremapper/gui/controller.py:784 +msgid "" +"\n" +"You need to record an analog input." +msgstr "" + +#: data/input-remapper.glade:544 +msgid " (recording ...)" +msgstr "" + +#: inputremapper/gui/controller.py:174 +#, python-format +msgid "%d Mapping errors at \"%s\", hover for info" +msgstr "" + +#: inputremapper/gui/controller.py:661 +msgid ", CTRL + DEL to stop" +msgstr "" + +#: data/input-remapper.glade:1421 +msgid "About" +msgstr "" + +#: data/input-remapper.glade:422 +msgid "" +"Activate this to load the preset next time the device connects, or when the " +"user logs in" +msgstr "" + +#: data/input-remapper.glade:575 +msgid "Add" +msgstr "" + +#: inputremapper/gui/components/editor.py:554 +msgid "Add a mapping first" +msgstr "" + +#: data/input-remapper.glade:606 data/input-remapper.glade:1618 +msgid "Advanced" +msgstr "" + +#: data/input-remapper.glade:773 data/input-remapper.glade:1118 +msgid "Analog Axis" +msgstr "" + +#: inputremapper/gui/controller.py:657 +#, python-format +msgid "Applied preset %s" +msgstr "" + +#: data/input-remapper.glade:318 +msgid "Apply" +msgstr "" + +#: inputremapper/gui/controller.py:522 +#, python-format +msgid "Are you sure you want to delete the preset \"%s\"?" +msgstr "" + +#: inputremapper/gui/controller.py:567 +msgid "Are you sure you want to delete this mapping?" +msgstr "" + +#: data/input-remapper.glade:410 +msgid "Autoload" +msgstr "" + +#: data/input-remapper.glade:854 +msgid "Available output axes are affected by the Target setting." +msgstr "" + +#: inputremapper/gui/controller.py:621 +msgid "Cannot apply empty preset file" +msgstr "" + +#: inputremapper/gui/components/editor.py:238 +msgid "Change Mapping Name" +msgstr "" + +#: data/input-remapper.glade:352 +msgid "Copy" +msgstr "" + +#: data/input-remapper.glade:189 +msgid "Create a new preset" +msgstr "" + +#: data/input-remapper.glade:925 +msgid "Deadzone" +msgstr "" + +#: data/input-remapper.glade:368 data/input-remapper.glade:620 +msgid "Delete" +msgstr "" + +#: data/input-remapper.glade:624 +msgid "Delete this entry" +msgstr "" + +#: data/input-remapper.glade:372 +msgid "Delete this preset" +msgstr "" + +#: data/input-remapper.glade:162 +msgid "Device Name" +msgstr "" + +#: data/input-remapper.glade:148 +msgid "Devices" +msgstr "" + +#: data/input-remapper.glade:356 +msgid "Duplicate this preset" +msgstr "" + +#: data/input-remapper.glade:1208 +msgid "Editor" +msgstr "" + +#: inputremapper/configs/mapping.py:77 +msgid "Empty Mapping" +msgstr "" + +#: inputremapper/gui/components/editor.py:404 +msgid "Enter your output here" +msgstr "" + +#: data/input-remapper.glade:1762 +msgid "Event Specific" +msgstr "" + +#: data/input-remapper.glade:1009 +msgid "Expo" +msgstr "" + +#: inputremapper/gui/controller.py:648 inputremapper/gui/controller.py:673 +#, python-format +msgid "Failed to apply preset %s" +msgstr "" + +#: data/input-remapper.glade:967 +msgid "Gain" +msgstr "" + +#: data/input-remapper.glade:1736 +msgid "General" +msgstr "" + +#: data/input-remapper.glade:1312 +msgid "Help" +msgstr "" + +#: data/input-remapper.glade:510 +msgid "Input" +msgstr "" + +#: data/input-remapper.glade:1296 +msgid "Input Remapper" +msgstr "" + +#: data/input-remapper.glade:1087 +msgid "Input cutoff" +msgstr "" + +#: data/input-remapper.glade:760 data/input-remapper.glade:1180 +msgid "Key or Macro" +msgstr "" + +#: data/input-remapper.glade:1801 +msgid "Map this input to an Analog Axis" +msgstr "" + +#: data/input-remapper.glade:185 +msgid "New" +msgstr "" + +#: inputremapper/gui/components/editor.py:980 +msgid "No Axis" +msgstr "" + +#: data/input-remapper.glade:721 +msgid "Output" +msgstr "" + +#: data/input-remapper.glade:862 +msgid "Output axis" +msgstr "" + +#: inputremapper/gui/controller.py:508 inputremapper/gui/controller.py:582 +msgid "Permission denied!" +msgstr "" + +#: data/input-remapper.glade:287 +msgid "Preset Name" +msgstr "" + +#: data/input-remapper.glade:272 +msgid "Presets" +msgstr "" + +#: data/input-remapper.glade:590 +msgid "Record" +msgstr "" + +#: data/input-remapper.glade:594 +msgid "Record a button of your device that should be remapped" +msgstr "" + +#: inputremapper/gui/components/editor.py:563 +msgid "Record input first" +msgstr "" + +#: inputremapper/gui/components/editor.py:65 +msgid "Record the input first" +msgstr "" + +#: data/input-remapper.glade:1708 +msgid "" +"Release all inputs which are part of the combination before the mapping is " +"injected" +msgstr "" + +#: data/input-remapper.glade:1711 +msgid "Release input" +msgstr "" + +#: data/input-remapper.glade:1683 +msgid "Release timeout" +msgstr "" + +#: inputremapper/gui/controller.py:208 +msgid "Remove the Analog Output Axis when specifying a macro or key output" +msgstr "" + +#: inputremapper/gui/controller.py:199 +msgid "" +"Remove the macro or key from the macro input field when specifying an analog " +"output" +msgstr "" + +#: data/input-remapper.glade:1776 +msgid "Remove this input" +msgstr "" + +#: data/input-remapper.glade:394 +msgid "Rename" +msgstr "" + +#: data/input-remapper.glade:452 +msgid "Save the entered name" +msgstr "" + +#: data/input-remapper.glade:1449 +msgid "" +"See usage.md online on github for comprehensive information.\n" +"\n" +"A \"key + key + ... + key\" syntax can be used to trigger key combinations. " +"For example \"Control_L + a\".\n" +"\n" +"Writing \"disable\" as a mapping disables a key.\n" +"\n" +"Macros allow multiple characters to be written with a single key-press. " +"Information about programming them is available online on github. See macros.md and examples.md" +msgstr "" + +#: data/input-remapper.glade:1590 +msgid "Shortcuts" +msgstr "" + +#: data/input-remapper.glade:1492 +msgid "" +"Shortcuts only work while keys are not being recorded and the gui is in " +"focus." +msgstr "" + +#: data/input-remapper.glade:322 +msgid "Start injecting. Don't hold down any keys while the injection starts" +msgstr "" + +#: inputremapper/gui/controller.py:643 +msgid "Starting injection..." +msgstr "" + +#: data/input-remapper.glade:201 data/input-remapper.glade:334 +msgid "Stop" +msgstr "" + +#: inputremapper/gui/controller.py:710 +msgid "Stopped the injection" +msgstr "" + +#: data/input-remapper.glade:205 data/input-remapper.glade:338 +msgid "" +"Stops the Injection for the selected device,\n" +"gives your keys their original function back\n" +"Shortcut: ctrl + del" +msgstr "" + +#: data/input-remapper.glade:812 +msgid "Target" +msgstr "" + +#: data/input-remapper.glade:1078 +msgid "" +"The Speed at which the Input is considered at maximum.\n" +"Only relevant when mapping relative inputs (e.g. mouse) to absolute outputs " +"(e.g. gamepad)" +msgstr "" + +#: inputremapper/gui/controller.py:234 +msgid "" +"The input specifies a key or macro input, but no macro or key is programmed." +msgstr "" + +#: inputremapper/gui/controller.py:213 +msgid "The input specifies an analog axis, but no output axis is selected." +msgstr "" + +#: data/input-remapper.glade:803 +msgid "The type of device this mapping is emulating." +msgstr "" + +#: data/input-remapper.glade:1831 +msgid "Trigger threshold" +msgstr "" + +#: data/input-remapper.glade:743 +msgid "Type" +msgstr "" + +#: data/input-remapper.glade:1473 +msgid "Usage" +msgstr "" + +#: inputremapper/gui/controller.py:593 +msgid "Use \"Stop\" to stop before editing" +msgstr "" + +#: data/input-remapper.glade:1805 +msgid "Use as analog" +msgstr "" + +#: data/input-remapper.glade:1366 +msgid "Version unknown" +msgstr "" + +#: data/input-remapper.glade:1134 +msgid "What should be written. For example KEY_A" +msgstr "" + +#: inputremapper/gui/controller.py:761 +msgid "You are about to change the mapping to analog." +msgstr "" + +#: data/input-remapper.glade:1161 +msgid "You can copy this text into the output" +msgstr "" + +#: data/input-remapper.glade:1383 +msgid "" +"You can find more information and report bugs at\n" +"https://github.com/" +"sezanzeb/input-remapper" +msgstr "" + +#: inputremapper/gui/controller.py:623 +msgid "You need to add mappings first" +msgstr "" + +#: inputremapper/gui/controller.py:358 +msgid "" +"Your system might reinterpret combinations with those after they are " +"injected, and by doing so break them." +msgstr "" + +#: data/input-remapper.glade:1524 +msgid "closes the application" +msgstr "" + +#: data/input-remapper.glade:1512 +msgid "ctrl + del" +msgstr "" + +#: data/input-remapper.glade:1536 +msgid "ctrl + q" +msgstr "" + +#: data/input-remapper.glade:1548 +msgid "ctrl + r" +msgstr "" + +#: inputremapper/gui/controller.py:356 +msgid "ctrl, alt and shift may not combine properly" +msgstr "" + +#: inputremapper/gui/data_manager.py:56 +msgid "new preset" +msgstr "" + +#: data/input-remapper.glade:531 inputremapper/gui/user_interface.py:385 +msgid "no input configured" +msgstr "" + +#: data/input-remapper.glade:1560 +msgid "refreshes the device list" +msgstr "" + +#: data/input-remapper.glade:1572 +msgid "stops the injection" +msgstr "" + +#: data/input-remapper.glade:1403 +msgid "" +"© 2025 Sezanzeb b8x45ygc9@mozmail.com\n" +"This program comes with absolutely no warranty.\n" +"See the GNU General " +"Public License, version 3 or later for details." +msgstr "" diff --git a/po/it.po b/po/it.po new file mode 120000 index 0000000..015154a --- /dev/null +++ b/po/it.po @@ -0,0 +1 @@ +./it_IT.po \ No newline at end of file diff --git a/po/it_IT.po b/po/it_IT.po new file mode 100644 index 0000000..3d6dfad --- /dev/null +++ b/po/it_IT.po @@ -0,0 +1,684 @@ +# ITALIAN TRANSLATION FOR INPUT-REMAPPER. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# ALBANO BATTISTELLA , 2021,2023. +# +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2023-05-16 08:30-0300\n" +"PO-Revision-Date: 2023-11-02 20:00+0200\n" +"Last-Translator: Albano Battistella \n" +"Language-Team: ITALIAN \n" +"Language: it\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" + +#: inputremapper/gui/controller.py:222 +msgid "" +"\n" +"If you mean to create a key or macro mapping go to the advanced input " +"configuration and set a \"Trigger Threshold\" for " +msgstr "" +"\n" +"Se intendi creare una mappatura di tasti o macro vai all'input avanzato " +"e impostare una \"Soglia di attivazione\" per " + +#: inputremapper/gui/controller.py:239 +msgid "" +"\n" +"If you mean to create an analog axis mapping go to the advanced input " +"configuration and set an input to \"Use as Analog\"." +msgstr "" +"\n" +"Se intendi creare una mappatura degli assi analogici vai all'input avanzato " +"configurazione e impostare un ingresso su \"Utilizza come analogico\"." + +#: inputremapper/gui/controller.py:779 +msgid "" +"\n" +"The input \"{}\" will be used as analog input." +msgstr "" +"\n" +"L'ingresso \"{}\" verrà utilizzato come ingresso analogico." + +#: inputremapper/gui/controller.py:763 +msgid "" +"\n" +"This will remove \"{}\" from the text input!" +msgstr "" +"\n" +"Questo rimuoverà \"{}\" dal testo inserito!" + +#: inputremapper/gui/controller.py:784 +msgid "" +"\n" +"You need to record an analog input." +msgstr "" +"\n" +"Devi registrare un ingresso analogico." + +#: data/input-remapper.glade:544 +msgid " (recording ...)" +msgstr " (registrazione ...)" + +#: inputremapper/gui/controller.py:174 +#, python-format +msgid "%d Mapping errors at \"%s\", hover for info" +msgstr "%d Errori di mappatura in \"%s\", passa il mouse per informazioni" + +#: inputremapper/gui/controller.py:661 +msgid ", CTRL + DEL to stop" +msgstr ",CTRL + CANC per interrompere" + +#: data/input-remapper.glade:1421 +msgid "About" +msgstr "Informazioni" + +#: data/input-remapper.glade:422 +msgid "" +"Activate this to load the preset next time the device connects, or when the " +"user logs in" +msgstr "" +"Attivarlo per caricare la preimpostazione la prossima volta che il dispositivo si connette o quando " +"l'utente accede" + +#: data/input-remapper.glade:575 +msgid "Add" +msgstr "Aggiungi" + +#: inputremapper/gui/components/editor.py:554 +msgid "Add a mapping first" +msgstr "Aggiungi prima una mappatura" + +#: data/input-remapper.glade:606 data/input-remapper.glade:1618 +msgid "Advanced" +msgstr "Avanzato" + +#: data/input-remapper.glade:773 data/input-remapper.glade:1118 +msgid "Analog Axis" +msgstr "Asse analogico" + +#: inputremapper/gui/controller.py:657 +#, python-format +msgid "Applied preset %s" +msgstr "Preimpostazione applicata %s" + +#: data/input-remapper.glade:318 +msgid "Apply" +msgstr "Applica" + +#: inputremapper/gui/controller.py:522 +#, python-format +msgid "Are you sure you want to delete the preset \"%s\"?" +msgstr "Sei sicuro di voler eliminare la preimpostazione \"%s\"?" + +#: inputremapper/gui/controller.py:567 +msgid "Are you sure you want to delete this mapping?" +msgstr "Sei sicuro di voler eliminare questa mappatura?" + +#: data/input-remapper.glade:410 +msgid "Autoload" +msgstr "Caricamento automatico" + +#: data/input-remapper.glade:854 +msgid "Available output axes are affected by the Target setting." +msgstr "Gli assi di uscita disponibili sono influenzati dall'impostazione Target." + +#: inputremapper/gui/controller.py:621 +msgid "Cannot apply empty preset file" +msgstr "Impossibile applicare un file preimpostato vuoto" + +#: inputremapper/gui/components/editor.py:238 +msgid "Change Mapping Name" +msgstr "Modifica nome mappatura" + +#: data/input-remapper.glade:352 +msgid "Copy" +msgstr "Copia" + +#: data/input-remapper.glade:189 +msgid "Create a new preset" +msgstr "Crea una nuova preimpostazione" + +#: data/input-remapper.glade:925 +msgid "Deadzone" +msgstr "Zona morta" + +#: data/input-remapper.glade:368 data/input-remapper.glade:620 +msgid "Delete" +msgstr "Cancella" + +#: data/input-remapper.glade:624 +msgid "Delete this entry" +msgstr "Elimina questa voce" + +#: data/input-remapper.glade:372 +msgid "Delete this preset" +msgstr "Elimina questa preimpostazione" + +#: data/input-remapper.glade:162 +msgid "Device Name" +msgstr "Nome dispositivo" + +#: data/input-remapper.glade:148 +msgid "Devices" +msgstr "Dispositivi" + +#: data/input-remapper.glade:356 +msgid "Duplicate this preset" +msgstr "Duplica questa preimpostazione" + +#: data/input-remapper.glade:1208 +msgid "Editor" +msgstr "" + +#: inputremapper/configs/mapping.py:77 +msgid "Empty Mapping" +msgstr "Mappatura vuota" + +#: inputremapper/gui/components/editor.py:404 +msgid "Enter your output here" +msgstr "Inserisci il tuo output qui" + +#: data/input-remapper.glade:1762 +msgid "Event Specific" +msgstr "Evento specifico" + +#: data/input-remapper.glade:1009 +msgid "Expo" +msgstr "" + +#: inputremapper/gui/controller.py:648 inputremapper/gui/controller.py:673 +#, python-format +msgid "Failed to apply preset %s" +msgstr "Impossibile applicare la preimpostazione %s" + +#: data/input-remapper.glade:967 +msgid "Gain" +msgstr "Guadagno" + +#: data/input-remapper.glade:1736 +msgid "General" +msgstr "Generale" + +#: data/input-remapper.glade:1312 +msgid "Help" +msgstr "Aiuto" + +#: data/input-remapper.glade:510 +msgid "Input" +msgstr "Input" + +#: data/input-remapper.glade:1296 +msgid "Input Remapper" +msgstr "Input Remapper" + +#: data/input-remapper.glade:1087 +msgid "Input cutoff" +msgstr "" + +#: data/input-remapper.glade:760 data/input-remapper.glade:1180 +msgid "Key or Macro" +msgstr "Tasto o macro" + +#: data/input-remapper.glade:1801 +msgid "Map this input to an Analog Axis" +msgstr "Mappa questo ingresso su un asse analogico" + +#: data/input-remapper.glade:185 +msgid "New" +msgstr "Nuovo" + +#: inputremapper/gui/components/editor.py:980 +msgid "No Axis" +msgstr "Nessun asse" + +#: data/input-remapper.glade:721 +msgid "Output" +msgstr "Output" + +#: data/input-remapper.glade:862 +msgid "Output axis" +msgstr "Asse di uscita" + +#: inputremapper/gui/controller.py:508 inputremapper/gui/controller.py:582 +msgid "Permission denied!" +msgstr "Permesso negato!" + +#: data/input-remapper.glade:287 +msgid "Preset Name" +msgstr "Nome preimpostazione" + +#: data/input-remapper.glade:272 +msgid "Presets" +msgstr "Preimpostazioni" + +#: data/input-remapper.glade:590 +msgid "Record" +msgstr "Registra" + +#: data/input-remapper.glade:594 +msgid "Record a button of your device that should be remapped" +msgstr "Registra un pulsante del tuo dispositivo che dovrebbe essere rimappato" + +#: inputremapper/gui/components/editor.py:563 +msgid "Record input first" +msgstr "Registrare prima l'input" + +#: inputremapper/gui/components/editor.py:65 +msgid "Record the input first" +msgstr "Registrare prima l'input" + +#: data/input-remapper.glade:1708 +msgid "" +"Release all inputs which are part of the combination before the mapping is " +"injected" +msgstr "" +"Rilascia tutti gli input che fanno parte della combinazione prima che la mappatura sia " +"iniettata" + +#: data/input-remapper.glade:1711 +msgid "Release input" +msgstr "Rilascia l'input" + +#: data/input-remapper.glade:1683 +msgid "Release timeout" +msgstr "Rilascia l'input" + +#: inputremapper/gui/controller.py:208 +msgid "Remove the Analog Output Axis when specifying a macro or key output" +msgstr "Rimuovere l'asse di uscita analogica quando si specifica una macro o un tasto d'uscita" + +#: inputremapper/gui/controller.py:199 +msgid "" +"Remove the macro or key from the macro input field when specifying an analog " +"output" +msgstr "" +"Rimuovere la macro o il tasto dal campo di immissione macro quando si specifica un output " +"analogico" + +#: data/input-remapper.glade:1776 +msgid "Remove this input" +msgstr "Rimuovi questo input" + +#: data/input-remapper.glade:394 +msgid "Rename" +msgstr "Rinomina" + +#: data/input-remapper.glade:452 +msgid "Save the entered name" +msgstr "Salva il nome inserito" + +#: data/input-remapper.glade:1449 +msgid "" +"See usage.md online on github for comprehensive information.\n" +"\n" +"A \"key + key + ... + key\" syntax can be used to trigger key combinations. " +"For example \"Control_L + a\".\n" +"\n" +"Writing \"disable\" as a mapping disables a key.\n" +"\n" +"Macros allow multiple characters to be written with a single key-press. " +"Information about programming them is available online on github. See macros.md and examples.md" +msgstr "" +"Vedi usage.md online su github per informazioni complete.\n" +"\n" +"Una sintassi \"tasto + tasto + ... + tasto\" può essere utilizzata per attivare combinazioni di tasti. " +"Ad esempio \"Control_L + a\".\n" +"\n" +"Scrivere \"disabilita\" come mappatura disabilitata di un tasto.\n" +"\n" +"Le macro consentono di scrivere più caratteri premendo un solo tasto. " +"Le informazioni su come programmarli sono disponibili online su github. Vedi macros.md e examples.md" + +#: data/input-remapper.glade:1590 +msgid "Shortcuts" +msgstr "Scorciatoie" + +#: data/input-remapper.glade:1492 +msgid "" +"Shortcuts only work while keys are not being recorded and the gui is in " +"focus." +msgstr "" +"Le scorciatoie funzionano solo mentre i tasti non vengono registrati e la " +"GUI è in messa a fuoco." + +#: data/input-remapper.glade:322 +msgid "Start injecting. Don't hold down any keys while the injection starts" +msgstr "Inizia a iniettare. Non tenere premuto alcun tasto durante l'avvio dell'iniezione" + +#: inputremapper/gui/controller.py:643 +msgid "Starting injection..." +msgstr "Avvio dell'iniezione..." + +#: data/input-remapper.glade:201 data/input-remapper.glade:334 +msgid "Stop" +msgstr "Ferma" + +#: inputremapper/gui/controller.py:710 +msgid "Stopped the injection" +msgstr "Fermata l'iniezione" + +#: data/input-remapper.glade:205 data/input-remapper.glade:338 +msgid "" +"Stops the Injection for the selected device,\n" +"gives your keys their original function back\n" +"Shortcut: ctrl + del" +msgstr"" +"Interrompe l'iniezione per il dispositivo selezionato,\n" +"ridona ai tuoi tasti la loro funzione originale\n" +"Scorciatoia: ctrl + canc" + +#: data/input-remapper.glade:812 +msgid "Target" +msgstr "" + +#: data/input-remapper.glade:1078 +msgid "" +"The Speed at which the Input is considered at maximum.\n" +"Only relevant when mapping relative inputs (e.g. mouse) to absolute outputs " +"(e.g. gamepad)" +msgstr "" +"La velocità alla quale l'input è considerato massimo.\n" +"Rilevante solo quando si mappano input relativi (ad esempio mouse) su output assoluti" +"(ad esempio gamepad)" + +#: inputremapper/gui/controller.py:234 +msgid "" +"The input specifies a key or macro input, but no macro or key is programmed." +msgstr "" +"L'ingresso specifico ad un tasto o un ingresso macro, ma non è programmata alcuna macro o tasto." + +#: inputremapper/gui/controller.py:213 +msgid "The input specifies an analog axis, but no output axis is selected." +msgstr "L'ingresso specifica un asse analogico, ma nessun asse di uscita è selezionato." + +#: data/input-remapper.glade:803 +msgid "The type of device this mapping is emulating." +msgstr "Il tipo di dispositivo che questa mappatura sta emulando." + +#: data/input-remapper.glade:1831 +msgid "Trigger threshold" +msgstr "Soglia di attivazione" + +#: data/input-remapper.glade:743 +msgid "Type" +msgstr "Tipo" + +#: data/input-remapper.glade:1473 +msgid "Usage" +msgstr "Uso" + +#: inputremapper/gui/controller.py:593 +msgid "Use \"Stop\" to stop before editing" +msgstr "Utilizza \"Ferma\" per interrompere prima della modifica" + +#: data/input-remapper.glade:1805 +msgid "Use as analog" +msgstr "Usa come analogico" + +#: data/input-remapper.glade:1366 +msgid "Version unknown" +msgstr "Versione sconosciuta" + +#: data/input-remapper.glade:1134 +msgid "What should be written. For example KEY_A" +msgstr "Cosa dovrebbe essere scritto. Ad esempio KEY_A" + +#: inputremapper/gui/controller.py:761 +msgid "You are about to change the mapping to analog." +msgstr "Stai per cambiare la mappatura in analogica." + +#: data/input-remapper.glade:1161 +msgid "You can copy this text into the output" +msgstr "Puoi copiare questo testo nell'output" + +#: data/input-remapper.glade:1383 +msgid "" +"You can find more information and report bugs at\n" +"https://github.com/" +"sezanzeb/input-remapper" +msgstr "" +"Puoi trovare maggiori informazioni e segnalare bug su\n" +"https://github.com/" +"sezanzeb/input-remapper" + +#: inputremapper/gui/controller.py:623 +msgid "You need to add mappings first" +msgstr "È necessario prima aggiungere le mappature" + +#: inputremapper/gui/controller.py:358 +msgid "" +"Your system might reinterpret combinations with those after they are " +"injected, and by doing so break them." +msgstr "" +"Il tuo sistema potrebbe reinterpretare le combinazioni con quelle successive " +"iniettati, e così facendo li romperete." + +#: data/input-remapper.glade:1524 +msgid "closes the application" +msgstr "chiude l'applicazione" + +#: data/input-remapper.glade:1512 +msgid "ctrl + del" +msgstr "ctrl + del" + +#: data/input-remapper.glade:1536 +msgid "ctrl + q" +msgstr "ctrl + q" + +#: data/input-remapper.glade:1548 +msgid "ctrl + r" +msgstr "ctrl + r" + +#: inputremapper/gui/controller.py:356 +msgid "ctrl, alt and shift may not combine properly" +msgstr "ctrl, alt e shift potrebbero non combinarsi correttamente" + +#: inputremapper/gui/data_manager.py:56 +msgid "new preset" +msgstr "Nuova preinpostazione" + +#: data/input-remapper.glade:531 inputremapper/gui/user_interface.py:385 +msgid "no input configured" +msgstr "nessun imput configurato" + +#: data/input-remapper.glade:1560 +msgid "refreshes the device list" +msgstr "aggiorna l'elenco dei dispositivi" + +#: data/input-remapper.glade:1572 +msgid "stops the injection" +msgstr "ferma l'iniezione" + +#: data/input-remapper.glade:1403 +msgid "" +"© 2025 Sezanzeb b8x45ygc9@mozmail.com\n" +"This program comes with absolutely no warranty.\n" +"See the GNU General " +"Public License, version 3 or later for details." +msgstr "" +"© 2025 Sezanzeb b8x45ygc9@mozmail.com\n" +"Questo programma non ha assolutamente alcuna garanzia.\n" +"Vedi il Generale GNU " +"Licenza pubblica, versione 3 o successiva per i dettagli." + +#~ msgid "." +#~ msgstr "." + +#~ msgid "1, 2" +#~ msgstr "1, 2" + +#~ msgid "" +#~ "A \"key + key + ... + key\" syntax can be used to trigger key " +#~ "combinations. For example \"control_l + a\".\n" +#~ "\n" +#~ "\"disable\" disables a key." +#~ msgstr "" +#~ "Sintassi\"tasti + tasti + ... + tasti\"puoi usare per entrare nella " +#~ "combinazioni di tasti. Ad esempio \"control_l + a\".\n" +#~ "\n" +#~ "\"disabilita\"disabilitato la mappatura dei tasti." + +#~ msgid "" +#~ "Between calls to k, key down and key up events, macros will sleep for " +#~ "10ms by default, which can be configured in ~/.config/input-remapper/" +#~ "config" +#~ msgstr "" +#~ "Tra chiamate a k, battitura ed eventi rilasciati,le macro dormiranno per " +#~ "10 ms per impostazione predefinita, che può essere configurato in ~/." +#~ "config/input-remapper/config" + +#~ msgid "Buttons" +#~ msgstr "Pulsanti" + +#~ msgid "CTRL + a, CTRL + x" +#~ msgstr "CTRL + a, CTRL + x" + +#~ msgid "" +#~ "Click on a cell below and hit a key on your device. Click the \"Restore " +#~ "Defaults\" button beforehand." +#~ msgstr "" +#~ "Fai clic su una cella in basso e premi un tasto sul dispositivo. Fai clic " +#~ "su \"Ripristina Pulsante \"Predefiniti\" in anticipo." + +#~ msgid "Displays additional debug information" +#~ msgstr "Visualizza ulteriori informazioni di debug" + +#~ msgid "Examples" +#~ msgstr "Esempi" + +#~ msgid "Go Back" +#~ msgstr "Indietro" + +#~ msgid "Joystick" +#~ msgstr "Joystick" + +#~ msgid "Key" +#~ msgstr "Tasto" + +#~ msgid "Left joystick" +#~ msgstr "Joystick sinistro" + +#~ msgid "Macros" +#~ msgstr "Macro" + +#~ msgid "" +#~ "Macros allow multiple characters to be written with a single key-press." +#~ msgstr "" +#~ "Le macro consentono di scrivere più caratteri premendo un solo tasto." + +#~ msgid "Mouse" +#~ msgstr "Mouse" + +#~ msgid "Mouse speed" +#~ msgstr "Velocità mouse" + +#~ msgid "Right joystick" +#~ msgstr "Joystick destro" + +#~ msgid "" +#~ "Shortcut: ctrl + del\n" +#~ "To give your keys back their original mapping." +#~ msgstr "" +#~ "Scorciatoia: ctrl + del\n" +#~ "Per restituire alle tue chiavi la loro mappatura originale." + +#~ msgid "" +#~ "To automatically apply the preset after your login or when it connects." +#~ msgstr "" +#~ "Per applicare automaticamente il predefinito dopo il login o quando si " +#~ "connette." + +#~ msgid "a, a, a with 500ms pause" +#~ msgstr "a, a, a con pausa di 500 ms" + +#~ msgid "e" +#~ msgstr "e" + +#~ msgid "e(EV_REL, REL_X, 10)" +#~ msgstr "e(EV_REL, REL_X, 10)" + +#~ msgid "executes the parameter as long as the key is pressed down" +#~ msgstr "esegue il parametro fintanto che si tiene premuto il tasto" + +#~ msgid "executes two actions behind each other" +#~ msgstr "esegue due azioni una dietro l'altra" + +#~ msgid "h" +#~ msgstr "h" + +#~ msgid "holds a modifier while executing the second parameter" +#~ msgstr "contiene un modificatore durante l'esecuzione del secondo parametro" + +#~ msgid "k" +#~ msgstr "k" + +#~ msgid "k(1).h(k(2)).k(3)" +#~ msgstr "k(1).h(k(2)).k(3)" + +#~ msgid "k(1).k(2)" +#~ msgstr "k(1).k(2)" + +#~ msgid "keeps scrolling down while held" +#~ msgstr "continua a scorrere verso il basso mentre si tiene premuto" + +#~ msgid "m" +#~ msgstr "m" + +#~ msgid "m(Control_L, k(a).k(x))" +#~ msgstr "m(Control_L, k(a).k(x))" + +#~ msgid "mouse" +#~ msgstr "mouse" + +#~ msgid "mouse(right, 4)" +#~ msgstr "mouse(destra, 4)" + +#~ msgid "moves the mouse cursor 10px to the right" +#~ msgstr "sposta il cursore del mouse di 10 pixel a destra" + +#~ msgid "r" +#~ msgstr "r" + +#~ msgid "r(3, k(a).w(500))" +#~ msgstr "r(3, k(a).w(500))" + +#~ msgid "repeats the execution of the second parameter" +#~ msgstr "ripete l'esecuzione del secondo parametro" + +#~ msgid "same as mouse" +#~ msgstr "come al mouse" + +#~ msgid "takes direction (up, left, ...) and speed as parameters" +#~ msgstr "" +#~ "prende la direzione (su, sinistra, ...) e la velocità come parametri" + +#~ msgid "w" +#~ msgstr "w" + +#~ msgid "waits in milliseconds" +#~ msgstr "in attesa (in millisecondi)" + +#~ msgid "which keeps moving the mouse while pressed" +#~ msgstr "che continua a muovere il mouse mentre viene premuto" + +#~ msgid "writes 1 2 2 ... 2 2 3 while the key is pressed" +#~ msgstr "scrive 1 2 2 ... 2 2 3 mentre il tasto è premuto" + +#~ msgid "writes a single keystroke" +#~ msgstr "scrive una singola sequenza di tasti" + +#~ msgid "writes an event" +#~ msgstr "scrive un evento" diff --git a/po/pt.po b/po/pt.po new file mode 120000 index 0000000..c733dd6 --- /dev/null +++ b/po/pt.po @@ -0,0 +1 @@ +pt_BR.po \ No newline at end of file diff --git a/po/pt_BR.po b/po/pt_BR.po new file mode 100644 index 0000000..f08ab96 --- /dev/null +++ b/po/pt_BR.po @@ -0,0 +1,525 @@ +# Portuguese translations for input-mapper package +# Traduções em português brasileiro para o pacote input-mapper. +# Copyright (C) 2023 THE input-mapper'S COPYRIGHT HOLDER +# This file is distributed under the same license as the input-mapper package. +# Rafael Fontenelle , 2023. +# +msgid "" +msgstr "" +"Project-Id-Version: input-mapper\n" +"Report-Msgid-Bugs-To: https://github.com/sezanzeb/input-remapper/issues\n" +"POT-Creation-Date: 2023-05-16 08:30-0300\n" +"PO-Revision-Date: 2023-05-16 08:56-0300\n" +"Last-Translator: Rafael Fontenelle \n" +"Language-Team: Brazilian Portuguese\n" +"Language: pt_BR\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n > 1)\n" +"X-Generator: Gtranslator 42.0\n" + +#: inputremapper/gui/controller.py:222 +msgid "" +"\n" +"If you mean to create a key or macro mapping go to the advanced input " +"configuration and set a \"Trigger Threshold\" for " +msgstr "" +"\n" +"Se você pretende criar um mapeamento de macro ou tecla, vá para a " +"configuração avançada de entrada e defina um \"Limiar de acionamento\"" + +#: inputremapper/gui/controller.py:239 +msgid "" +"\n" +"If you mean to create an analog axis mapping go to the advanced input " +"configuration and set an input to \"Use as Analog\"." +msgstr "" +"\n" +"Se você deseja criar um mapeamento de eixo analógico, vá para a configuração " +"avançada de entrada e defina uma entrada para \"Usar como analógico\"." + +#: inputremapper/gui/controller.py:779 +msgid "" +"\n" +"The input \"{}\" will be used as analog input." +msgstr "" +"\n" +"A entrada \"{}\" será usada como entrada analógica." + +#: inputremapper/gui/controller.py:763 +msgid "" +"\n" +"This will remove \"{}\" from the text input!" +msgstr "" +"\n" +"Isso removerá \"{}\" da entrada de texto!" + +#: inputremapper/gui/controller.py:784 +msgid "" +"\n" +"You need to record an analog input." +msgstr "" +"\n" +"Você precisa gravar uma entrada analógica." + +#: data/input-remapper.glade:544 +msgid " (recording ...)" +msgstr " (gravando ...)" + +#: inputremapper/gui/controller.py:174 +#, python-format +msgid "%d Mapping errors at \"%s\", hover for info" +msgstr "%d erros de mapeamento em \"%s\", passe o mouse para obter informações" + +#: inputremapper/gui/controller.py:661 +msgid ", CTRL + DEL to stop" +msgstr ", CTRL + DEL para interromper" + +#: data/input-remapper.glade:1421 +msgid "About" +msgstr "Sobre" + +#: data/input-remapper.glade:422 +msgid "" +"Activate this to load the preset next time the device connects, or when the " +"user logs in" +msgstr "" +"Ative para carregar a predefinição na próxima vez que o dispositivo se " +"conectar ou quando o usuário fizer login" + +#: data/input-remapper.glade:575 +msgid "Add" +msgstr "Adicionar" + +#: inputremapper/gui/components/editor.py:554 +msgid "Add a mapping first" +msgstr "Adicione um mapeamento primeiro" + +#: data/input-remapper.glade:606 data/input-remapper.glade:1618 +msgid "Advanced" +msgstr "Avançado" + +#: data/input-remapper.glade:773 data/input-remapper.glade:1118 +msgid "Analog Axis" +msgstr "Eixo analógico" + +#: inputremapper/gui/controller.py:657 +#, python-format +msgid "Applied preset %s" +msgstr "Aplicada a predefinição %s" + +#: data/input-remapper.glade:318 +msgid "Apply" +msgstr "Aplicar" + +#: inputremapper/gui/controller.py:522 +#, python-format +msgid "Are you sure you want to delete the preset \"%s\"?" +msgstr "Tem certeza que deseja excluir a predefinição \"%s\"?" + +#: inputremapper/gui/controller.py:567 +msgid "Are you sure you want to delete this mapping?" +msgstr "Tem certeza que deseja excluir este mapeamento?" + +#: data/input-remapper.glade:410 +msgid "Autoload" +msgstr "Autocarregamento" + +#: data/input-remapper.glade:854 +msgid "Available output axes are affected by the Target setting." +msgstr "Os eixos de saída disponíveis são afetados pela configuração de Alvo." + +#: inputremapper/gui/controller.py:621 +msgid "Cannot apply empty preset file" +msgstr "Não é possível aplicar um arquivo de predefinição vazio" + +#: inputremapper/gui/components/editor.py:238 +msgid "Change Mapping Name" +msgstr "Altera o nome do mapeamento" + +#: data/input-remapper.glade:352 +msgid "Copy" +msgstr "Copiar" + +#: data/input-remapper.glade:189 +msgid "Create a new preset" +msgstr "Criar uma nova predefinição" + +#: data/input-remapper.glade:925 +msgid "Deadzone" +msgstr "Zona morta" + +#: data/input-remapper.glade:368 data/input-remapper.glade:620 +msgid "Delete" +msgstr "Excluir" + +#: data/input-remapper.glade:624 +msgid "Delete this entry" +msgstr "Excluir esta entrada" + +#: data/input-remapper.glade:372 +msgid "Delete this preset" +msgstr "Exclui esta predefinição" + +#: data/input-remapper.glade:162 +msgid "Device Name" +msgstr "Nome do dispositivo" + +#: data/input-remapper.glade:148 +msgid "Devices" +msgstr "Dispositivos" + +#: data/input-remapper.glade:356 +msgid "Duplicate this preset" +msgstr "Duplica esta predefinição" + +#: data/input-remapper.glade:1208 +msgid "Editor" +msgstr "Editor" + +#: inputremapper/configs/mapping.py:77 +msgid "Empty Mapping" +msgstr "Mapeamento vazio" + +#: inputremapper/gui/components/editor.py:404 +msgid "Enter your output here" +msgstr "Insira sua saída aqui" + +#: data/input-remapper.glade:1762 +msgid "Event Specific" +msgstr "Específico de evento" + +#: data/input-remapper.glade:1009 +msgid "Expo" +msgstr "Expo" + +#: inputremapper/gui/controller.py:648 inputremapper/gui/controller.py:673 +#, python-format +msgid "Failed to apply preset %s" +msgstr "Falha ao aplicar a predefinição %s" + +#: data/input-remapper.glade:967 +msgid "Gain" +msgstr "Ganho" + +#: data/input-remapper.glade:1736 +msgid "General" +msgstr "Geral" + +#: data/input-remapper.glade:1312 +msgid "Help" +msgstr "Ajuda" + +#: data/input-remapper.glade:510 +msgid "Input" +msgstr "Entrada" + +#: data/input-remapper.glade:1296 +msgid "Input Remapper" +msgstr "Remapeador de entrada" + +#: data/input-remapper.glade:1087 +msgid "Input cutoff" +msgstr "Corte da entrada" + +#: data/input-remapper.glade:760 data/input-remapper.glade:1180 +msgid "Key or Macro" +msgstr "Chave ou macro" + +#: data/input-remapper.glade:1801 +msgid "Map this input to an Analog Axis" +msgstr "Mapear esta entrada para um eixo analógico" + +# Nova predefinição -- Rafael +#: data/input-remapper.glade:185 +msgid "New" +msgstr "Nova" + +#: inputremapper/gui/components/editor.py:980 +msgid "No Axis" +msgstr "Nenhum eixo" + +#: data/input-remapper.glade:721 +msgid "Output" +msgstr "Saída" + +#: data/input-remapper.glade:862 +msgid "Output axis" +msgstr "Eixo de saída" + +#: inputremapper/gui/controller.py:508 inputremapper/gui/controller.py:582 +msgid "Permission denied!" +msgstr "Permissão negada!" + +#: data/input-remapper.glade:287 +msgid "Preset Name" +msgstr "Nome da predefinição" + +#: data/input-remapper.glade:272 +msgid "Presets" +msgstr "Predefinições" + +#: data/input-remapper.glade:590 +msgid "Record" +msgstr "Gravar" + +#: data/input-remapper.glade:594 +msgid "Record a button of your device that should be remapped" +msgstr "Grava um botão de seu dispositivo que deve ser remapeado" + +#: inputremapper/gui/components/editor.py:563 +msgid "Record input first" +msgstr "Gravar primeira entrada" + +#: inputremapper/gui/components/editor.py:65 +msgid "Record the input first" +msgstr "Grava a primeira entrada" + +#: data/input-remapper.glade:1708 +msgid "" +"Release all inputs which are part of the combination before the mapping is " +"injected" +msgstr "" +"Libere todas as entradas que fazem parte da combinação antes que o " +"mapeamento seja injetado" + +#: data/input-remapper.glade:1711 +msgid "Release input" +msgstr "Liberar entrada" + +#: data/input-remapper.glade:1683 +msgid "Release timeout" +msgstr "Tempo limite para liberar" + +#: inputremapper/gui/controller.py:208 +msgid "Remove the Analog Output Axis when specifying a macro or key output" +msgstr "" +"Remove o eixo de saída analógica ao especificar uma saída de macro ou tecla" + +#: inputremapper/gui/controller.py:199 +msgid "" +"Remove the macro or key from the macro input field when specifying an analog " +"output" +msgstr "" +"Remova a macro ou chave do campo de entrada de macro ao especificar uma " +"saída analógica" + +#: data/input-remapper.glade:1776 +msgid "Remove this input" +msgstr "Remove esta entrada" + +#: data/input-remapper.glade:394 +msgid "Rename" +msgstr "Renomear" + +#: data/input-remapper.glade:452 +msgid "Save the entered name" +msgstr "Salva o nome inserido" + +#: data/input-remapper.glade:1449 +msgid "" +"See usage.md online on github for comprehensive information.\n" +"\n" +"A \"key + key + ... + key\" syntax can be used to trigger key combinations. " +"For example \"Control_L + a\".\n" +"\n" +"Writing \"disable\" as a mapping disables a key.\n" +"\n" +"Macros allow multiple characters to be written with a single key-press. " +"Information about programming them is available online on github. See macros.md and examples.md" +msgstr "" +"Consulte usage.md online no github para obter informações " +"abrangentes.\n" +"\n" +"Uma sintaxe \"tecla + tecla + ... + tecla\" pode ser usada para acionar " +"combinações de teclas. Por exemplo, \"Control_L + a\".\n" +"\n" +"Escrever \"disable\" como mapeamento desativa uma tecla.\n" +"\n" +"As macros permitem que vários caracteres sejam escritos com um único " +"pressionamento de tecla. Informações sobre como programá-los estão " +"disponíveis online no github. Consulte macros.md e examples.md" + +#: data/input-remapper.glade:1590 +msgid "Shortcuts" +msgstr "Atalhos" + +#: data/input-remapper.glade:1492 +msgid "" +"Shortcuts only work while keys are not being recorded and the gui is in " +"focus." +msgstr "" +"Os atalhos funcionam apenas enquanto as teclas não estão sendo gravadas e a " +"interface gráfica está em foco." + +#: data/input-remapper.glade:322 +msgid "Start injecting. Don't hold down any keys while the injection starts" +msgstr "Começa a injeção. Não segure nenhuma tecla enquanto a injeção começa" + +#: inputremapper/gui/controller.py:643 +msgid "Starting injection..." +msgstr "Iniciando a injeção..." + +#: data/input-remapper.glade:201 data/input-remapper.glade:334 +msgid "Stop" +msgstr "Parar" + +#: inputremapper/gui/controller.py:710 +msgid "Stopped the injection" +msgstr "Parou a injeção" + +#: data/input-remapper.glade:205 data/input-remapper.glade:338 +msgid "" +"Stops the Injection for the selected device,\n" +"gives your keys their original function back\n" +"Shortcut: ctrl + del" +msgstr "" +"Interrompe a injeção para o dispositivo selecionado,\n" +"devolve às suas teclas a função original\n" +"Atalho: ctrl + del" + +#: data/input-remapper.glade:812 +msgid "Target" +msgstr "Alvo" + +#: data/input-remapper.glade:1078 +msgid "" +"The Speed at which the Input is considered at maximum.\n" +"Only relevant when mapping relative inputs (e.g. mouse) to absolute outputs " +"(e.g. gamepad)" +msgstr "" +"A velocidade na qual a entrada é considerada no máximo.\n" +"Relevante apenas ao mapear entradas relativas (por exemplo, mouse) para " +"saídas absolutas (por exemplo, gamepad)" + +#: inputremapper/gui/controller.py:234 +msgid "" +"The input specifies a key or macro input, but no macro or key is programmed." +msgstr "" +"A entrada especifica uma entrada de tecla ou macro, mas nenhuma macro ou " +"chave está programada." + +#: inputremapper/gui/controller.py:213 +msgid "The input specifies an analog axis, but no output axis is selected." +msgstr "" +"A entrada especifica um eixo analógico, mas nenhum eixo de saída está " +"selecionado." + +#: data/input-remapper.glade:803 +msgid "The type of device this mapping is emulating." +msgstr "O tipo de dispositivo que este mapeamento está emulando." + +#: data/input-remapper.glade:1831 +msgid "Trigger threshold" +msgstr "Limiar de acionamento" + +#: data/input-remapper.glade:743 +msgid "Type" +msgstr "Tipo" + +#: data/input-remapper.glade:1473 +msgid "Usage" +msgstr "Uso" + +#: inputremapper/gui/controller.py:593 +msgid "Use \"Stop\" to stop before editing" +msgstr "Use \"Parar\" para parar antes de editar" + +#: data/input-remapper.glade:1805 +msgid "Use as analog" +msgstr "Usa como analógico" + +#: data/input-remapper.glade:1366 +msgid "Version unknown" +msgstr "Versão desconhecida" + +#: data/input-remapper.glade:1134 +msgid "What should be written. For example KEY_A" +msgstr "O que deve ser escrito. Por exemplo, KEY_A" + +#: inputremapper/gui/controller.py:761 +msgid "You are about to change the mapping to analog." +msgstr "Você está prestes a alterar o mapeamento para analógico." + +#: data/input-remapper.glade:1161 +msgid "You can copy this text into the output" +msgstr "Você pode copiar este texto na saída" + +#: data/input-remapper.glade:1383 +msgid "" +"You can find more information and report bugs at\n" +"https://github.com/" +"sezanzeb/input-remapper" +msgstr "" +"Você pode encontrar mais informações e relatar bugs em\n" +"https://github.com/" +"sezanzeb/input-remapper" + +#: inputremapper/gui/controller.py:623 +msgid "You need to add mappings first" +msgstr "Você precisa adicionar mapeamentos primeiro" + +#: inputremapper/gui/controller.py:358 +msgid "" +"Your system might reinterpret combinations with those after they are " +"injected, and by doing so break them." +msgstr "" +"Seu sistema pode reinterpretar combinações com aquelas após serem injetadas " +"e, ao fazê-lo, quebrá-las." + +#: data/input-remapper.glade:1524 +msgid "closes the application" +msgstr "fecha o aplicativo" + +#: data/input-remapper.glade:1512 +msgid "ctrl + del" +msgstr "ctrl + del" + +#: data/input-remapper.glade:1536 +msgid "ctrl + q" +msgstr "ctrl + q" + +#: data/input-remapper.glade:1548 +msgid "ctrl + r" +msgstr "ctrl + r" + +#: inputremapper/gui/controller.py:356 +msgid "ctrl, alt and shift may not combine properly" +msgstr "ctrl, alt e shift podem não combinar adequadamente" + +#: inputremapper/gui/data_manager.py:56 +msgid "new preset" +msgstr "nova predefinição" + +#: data/input-remapper.glade:531 inputremapper/gui/user_interface.py:385 +msgid "no input configured" +msgstr "nenhuma entrada configurada" + +#: data/input-remapper.glade:1560 +msgid "refreshes the device list" +msgstr "atualiza a lista de dispositivos" + +#: data/input-remapper.glade:1572 +msgid "stops the injection" +msgstr "para a injeção" + +#: data/input-remapper.glade:1403 +msgid "" +"© 2025 Sezanzeb b8x45ygc9@mozmail.com\n" +"This program comes with absolutely no warranty.\n" +"See the GNU General " +"Public License, version 3 or later for details." +msgstr "" +"© 2025 Sezanzeb b8x45ygc9@mozmail.com\n" +"Este programa vem com absolutamente nenhuma garantia.\n" +"Veja a Licença Pública " +"Geral GNU, versão 3 ou posterior para detalhes." diff --git a/po/ru.po b/po/ru.po new file mode 120000 index 0000000..b5d03f7 --- /dev/null +++ b/po/ru.po @@ -0,0 +1 @@ +./ru_RU.po \ No newline at end of file diff --git a/po/ru_RU.po b/po/ru_RU.po new file mode 100644 index 0000000..626b641 --- /dev/null +++ b/po/ru_RU.po @@ -0,0 +1,504 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +msgid "" +msgstr "" +"Project-Id-Version: \n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2023-05-16 08:30-0300\n" +"PO-Revision-Date: 2023-01-11 21:48+0100\n" +"Last-Translator: Sviatoslav Vorona \n" +"Language-Team: \n" +"Language: ru_RU\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"X-Generator: Poedit 3.0\n" +"Plural-Forms: nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && " +"n%10<=4 && (n%100<12 || n%100>14) ? 1 : 2);\n" + +#: inputremapper/gui/controller.py:222 +msgid "" +"\n" +"If you mean to create a key or macro mapping go to the advanced input " +"configuration and set a \"Trigger Threshold\" for " +msgstr "" + +#: inputremapper/gui/controller.py:239 +msgid "" +"\n" +"If you mean to create an analog axis mapping go to the advanced input " +"configuration and set an input to \"Use as Analog\"." +msgstr "" + +#: inputremapper/gui/controller.py:779 +msgid "" +"\n" +"The input \"{}\" will be used as analog input." +msgstr "" + +#: inputremapper/gui/controller.py:763 +msgid "" +"\n" +"This will remove \"{}\" from the text input!" +msgstr "" + +#: inputremapper/gui/controller.py:784 +msgid "" +"\n" +"You need to record an analog input." +msgstr "" + +#: data/input-remapper.glade:544 +msgid " (recording ...)" +msgstr " (запись ...)" + +#: inputremapper/gui/controller.py:174 +#, python-format +msgid "%d Mapping errors at \"%s\", hover for info" +msgstr "" + +#: inputremapper/gui/controller.py:661 +msgid ", CTRL + DEL to stop" +msgstr "" + +#: data/input-remapper.glade:1421 +msgid "About" +msgstr "О программе" + +#: data/input-remapper.glade:422 +msgid "" +"Activate this to load the preset next time the device connects, or when the " +"user logs in" +msgstr "" + +#: data/input-remapper.glade:575 +msgid "Add" +msgstr "Добавить" + +#: inputremapper/gui/components/editor.py:554 +msgid "Add a mapping first" +msgstr "" + +#: data/input-remapper.glade:606 data/input-remapper.glade:1618 +msgid "Advanced" +msgstr "Дополнительные" + +#: data/input-remapper.glade:773 data/input-remapper.glade:1118 +msgid "Analog Axis" +msgstr "Аналоговая Ось" + +#: inputremapper/gui/controller.py:657 +#, python-format +msgid "Applied preset %s" +msgstr "" + +#: data/input-remapper.glade:318 +msgid "Apply" +msgstr "Применить" + +#: inputremapper/gui/controller.py:522 +#, python-format +msgid "Are you sure you want to delete the preset \"%s\"?" +msgstr "" + +#: inputremapper/gui/controller.py:567 +msgid "Are you sure you want to delete this mapping?" +msgstr "" + +#: data/input-remapper.glade:410 +msgid "Autoload" +msgstr "Автозагрузка" + +#: data/input-remapper.glade:854 +msgid "Available output axes are affected by the Target setting." +msgstr "Доступные оси вывода затрагиваются настройками Цели." + +#: inputremapper/gui/controller.py:621 +msgid "Cannot apply empty preset file" +msgstr "" + +#: inputremapper/gui/components/editor.py:238 +msgid "Change Mapping Name" +msgstr "" + +#: data/input-remapper.glade:352 +msgid "Copy" +msgstr "Копировать" + +#: data/input-remapper.glade:189 +msgid "Create a new preset" +msgstr "Создать новую предустановку" + +#: data/input-remapper.glade:925 +msgid "Deadzone" +msgstr "Мёртвая зона" + +#: data/input-remapper.glade:368 data/input-remapper.glade:620 +msgid "Delete" +msgstr "Удалить" + +#: data/input-remapper.glade:624 +msgid "Delete this entry" +msgstr "Удалить эту запись" + +#: data/input-remapper.glade:372 +msgid "Delete this preset" +msgstr "Удалить эту предустановку" + +#: data/input-remapper.glade:162 +msgid "Device Name" +msgstr "Имя устройства" + +#: data/input-remapper.glade:148 +msgid "Devices" +msgstr "Устройства" + +#: data/input-remapper.glade:356 +msgid "Duplicate this preset" +msgstr "Скопировать эту предустановку" + +#: data/input-remapper.glade:1208 +msgid "Editor" +msgstr "Редактор" + +#: inputremapper/configs/mapping.py:77 +msgid "Empty Mapping" +msgstr "" + +#: inputremapper/gui/components/editor.py:404 +msgid "Enter your output here" +msgstr "" + +#: data/input-remapper.glade:1762 +msgid "Event Specific" +msgstr "Специфичные для событий" + +#: data/input-remapper.glade:1009 +msgid "Expo" +msgstr "Expo" + +#: inputremapper/gui/controller.py:648 inputremapper/gui/controller.py:673 +#, python-format +msgid "Failed to apply preset %s" +msgstr "" + +#: data/input-remapper.glade:967 +msgid "Gain" +msgstr "Усиление" + +#: data/input-remapper.glade:1736 +msgid "General" +msgstr "Общие" + +#: data/input-remapper.glade:1312 +msgid "Help" +msgstr "Помощь" + +#: data/input-remapper.glade:510 +msgid "Input" +msgstr "Ввод" + +#: data/input-remapper.glade:1296 +msgid "Input Remapper" +msgstr "Input Remapper" + +#: data/input-remapper.glade:1087 +msgid "Input cutoff" +msgstr "Входная отсечка" + +#: data/input-remapper.glade:760 data/input-remapper.glade:1180 +msgid "Key or Macro" +msgstr "Клавиша или Макрос" + +#: data/input-remapper.glade:1801 +msgid "Map this input to an Analog Axis" +msgstr "" + +#: data/input-remapper.glade:185 +msgid "New" +msgstr "Новая" + +#: inputremapper/gui/components/editor.py:980 +#, fuzzy +msgid "No Axis" +msgstr "Аналоговая Ось" + +#: data/input-remapper.glade:721 +msgid "Output" +msgstr "Вывод" + +#: data/input-remapper.glade:862 +msgid "Output axis" +msgstr "Ось вывода" + +#: inputremapper/gui/controller.py:508 inputremapper/gui/controller.py:582 +msgid "Permission denied!" +msgstr "" + +#: data/input-remapper.glade:287 +msgid "Preset Name" +msgstr "Имя предустановки" + +#: data/input-remapper.glade:272 +msgid "Presets" +msgstr "Предустановки" + +#: data/input-remapper.glade:590 +msgid "Record" +msgstr "Запись" + +#: data/input-remapper.glade:594 +msgid "Record a button of your device that should be remapped" +msgstr "Записать клавишу вашего устройства которая должна быть привязана" + +#: inputremapper/gui/components/editor.py:563 +msgid "Record input first" +msgstr "" + +#: inputremapper/gui/components/editor.py:65 +#, fuzzy +msgid "Record the input first" +msgstr "Удалить этот ввод" + +#: data/input-remapper.glade:1708 +msgid "" +"Release all inputs which are part of the combination before the mapping is " +"injected" +msgstr "" + +#: data/input-remapper.glade:1711 +msgid "Release input" +msgstr "Освободить" + +#: data/input-remapper.glade:1683 +msgid "Release timeout" +msgstr "Таймаут освобождения" + +#: inputremapper/gui/controller.py:208 +msgid "Remove the Analog Output Axis when specifying a macro or key output" +msgstr "" + +#: inputremapper/gui/controller.py:199 +msgid "" +"Remove the macro or key from the macro input field when specifying an analog " +"output" +msgstr "" + +#: data/input-remapper.glade:1776 +msgid "Remove this input" +msgstr "Удалить этот ввод" + +#: data/input-remapper.glade:394 +msgid "Rename" +msgstr "Переименовать" + +#: data/input-remapper.glade:452 +msgid "Save the entered name" +msgstr "Сохранить введённое имя" + +#: data/input-remapper.glade:1449 +msgid "" +"See usage.md online on github for comprehensive information.\n" +"\n" +"A \"key + key + ... + key\" syntax can be used to trigger key combinations. " +"For example \"Control_L + a\".\n" +"\n" +"Writing \"disable\" as a mapping disables a key.\n" +"\n" +"Macros allow multiple characters to be written with a single key-press. " +"Information about programming them is available online on github. See macros.md and examples.md" +msgstr "" +"Смотрите usage.md в сети на github для исчерпывающей " +"информации.\n" +"\n" +"A \"key + key + ... + key\" синтаксис может быть использован чтобы вызвать " +"комбинацию клавиш. Например \"Control_L + a\".\n" +"\n" +"Написание \"disable\" как привязки отключает клавишу.\n" +"\n" +"Макросы позволяют написать множество символов нажатием одной клавиши. " +"Информация об их программировании доступна в сети на github. Смотрите macros.md and examples.md" + +#: data/input-remapper.glade:1590 +msgid "Shortcuts" +msgstr "Комбинации" + +#: data/input-remapper.glade:1492 +msgid "" +"Shortcuts only work while keys are not being recorded and the gui is in " +"focus." +msgstr "" +"Комбинации работают только пока клавиши не записываются и интерфейс " +"приложения в фокусе." + +#: data/input-remapper.glade:322 +msgid "Start injecting. Don't hold down any keys while the injection starts" +msgstr "Начать ввод. Не зажимайте никакие клавиши пока ввод запускается" + +#: inputremapper/gui/controller.py:643 +#, fuzzy +msgid "Starting injection..." +msgstr "останавливает ввод" + +#: data/input-remapper.glade:201 data/input-remapper.glade:334 +msgid "Stop" +msgstr "Остановить" + +#: inputremapper/gui/controller.py:710 +#, fuzzy +msgid "Stopped the injection" +msgstr "останавливает ввод" + +#: data/input-remapper.glade:205 data/input-remapper.glade:338 +msgid "" +"Stops the Injection for the selected device,\n" +"gives your keys their original function back\n" +"Shortcut: ctrl + del" +msgstr "" +"Останавливает Ввод для выбранного устройства,\n" +"возвращает функции ваших клавиш назад\n" +"Комбинация: ctrl + del" + +#: data/input-remapper.glade:812 +msgid "Target" +msgstr "Цель" + +#: data/input-remapper.glade:1078 +#, fuzzy +msgid "" +"The Speed at which the Input is considered at maximum.\n" +"Only relevant when mapping relative inputs (e.g. mouse) to absolute outputs " +"(e.g. gamepad)" +msgstr "" +"Скорость при которой Ввод считается максимальным.\n" +"Актуально только при эмуляции относительных устройств ввода (например, мыши) " +"как абсолютных устройств вывода (например, геймпада)." + +#: inputremapper/gui/controller.py:234 +msgid "" +"The input specifies a key or macro input, but no macro or key is programmed." +msgstr "" + +#: inputremapper/gui/controller.py:213 +msgid "The input specifies an analog axis, but no output axis is selected." +msgstr "" + +#: data/input-remapper.glade:803 +msgid "The type of device this mapping is emulating." +msgstr "Тип устройства которое эмулируется этой привязкой." + +#: data/input-remapper.glade:1831 +msgid "Trigger threshold" +msgstr "Порог срабатывания" + +#: data/input-remapper.glade:743 +msgid "Type" +msgstr "Тип" + +#: data/input-remapper.glade:1473 +msgid "Usage" +msgstr "Использование" + +#: inputremapper/gui/controller.py:593 +msgid "Use \"Stop\" to stop before editing" +msgstr "" + +#: data/input-remapper.glade:1805 +msgid "Use as analog" +msgstr "Использовать как аналоговое" + +#: data/input-remapper.glade:1366 +msgid "Version unknown" +msgstr "Неизвестная версия" + +#: data/input-remapper.glade:1134 +msgid "What should be written. For example KEY_A" +msgstr "Что должно быть написано. Например KEY_A" + +#: inputremapper/gui/controller.py:761 +msgid "You are about to change the mapping to analog." +msgstr "" + +#: data/input-remapper.glade:1161 +msgid "You can copy this text into the output" +msgstr "" + +#: data/input-remapper.glade:1383 +msgid "" +"You can find more information and report bugs at\n" +"https://github.com/" +"sezanzeb/input-remapper" +msgstr "" +"Вы можете найти больше информации и сообщить о проблемах по адресу\n" +"https://github.com/" +"sezanzeb/input-remapper" + +#: inputremapper/gui/controller.py:623 +msgid "You need to add mappings first" +msgstr "" + +#: inputremapper/gui/controller.py:358 +msgid "" +"Your system might reinterpret combinations with those after they are " +"injected, and by doing so break them." +msgstr "" + +#: data/input-remapper.glade:1524 +msgid "closes the application" +msgstr "закрывает приложение" + +#: data/input-remapper.glade:1512 +msgid "ctrl + del" +msgstr "ctrl + del" + +#: data/input-remapper.glade:1536 +msgid "ctrl + q" +msgstr "ctrl + q" + +#: data/input-remapper.glade:1548 +msgid "ctrl + r" +msgstr "ctrl + r" + +#: inputremapper/gui/controller.py:356 +msgid "ctrl, alt and shift may not combine properly" +msgstr "" + +#: inputremapper/gui/data_manager.py:56 +#, fuzzy +msgid "new preset" +msgstr "Создать новую предустановку" + +#: data/input-remapper.glade:531 inputremapper/gui/user_interface.py:385 +msgid "no input configured" +msgstr "ввод не сконфигурирован" + +#: data/input-remapper.glade:1560 +msgid "refreshes the device list" +msgstr "обновляет список устройств" + +#: data/input-remapper.glade:1572 +msgid "stops the injection" +msgstr "останавливает ввод" + +#: data/input-remapper.glade:1403 +msgid "" +"© 2025 Sezanzeb b8x45ygc9@mozmail.com\n" +"This program comes with absolutely no warranty.\n" +"See the GNU General " +"Public License, version 3 or later for details." +msgstr "" +"© 2025 Sezanzeb b8x45ygc9@mozmail.com\n" +"Программа распространяется без какой либо гарантии.\n" +"Смотрите подробности по ссылке GNU General Public License, версия 3 или новее." diff --git a/po/sk.po b/po/sk.po new file mode 120000 index 0000000..e48f8ad --- /dev/null +++ b/po/sk.po @@ -0,0 +1 @@ +./sk_SK.po \ No newline at end of file diff --git a/po/sk_SK.po b/po/sk_SK.po new file mode 100644 index 0000000..7274106 --- /dev/null +++ b/po/sk_SK.po @@ -0,0 +1,724 @@ +# Slovak translation of input-remapper. +# Copyright (C) 2023. +# This file is distributed under the same license as the input-remapper package. +# Jose Riha , 2021, 2025. +# +msgid "" +msgstr "" +"Project-Id-Version: \n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2023-05-16 08:30-0300\n" +"PO-Revision-Date: 2025-03-28 23:47+0100\n" +"Last-Translator: Jose Riha \n" +"Language-Team: \n" +"Language: sk_SK\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"X-Generator: Poedit 3.0\n" +"Plural-Forms: nplurals=3; plural=(n==1 ? 0 : n>=2 && n<=4 ? 1 : 2);\n" + +#: inputremapper/gui/controller.py:222 +msgid "" +"\n" +"If you mean to create a key or macro mapping go to the advanced input " +"configuration and set a \"Trigger Threshold\" for " +msgstr "" +"\n" +"Ak chcete vytvoriť mapovanie klávesu alebo makra, prejdite do pokročilej " +"konfigurácie vstupu a nastavte „Prah aktivácie“ pre " + +#: inputremapper/gui/controller.py:239 +msgid "" +"\n" +"If you mean to create an analog axis mapping go to the advanced input " +"configuration and set an input to \"Use as Analog\"." +msgstr "" +"\n" +"Ak chcete vytvoriť mapovanie analógovej osi, prejdite do pokročilej " +"konfigurácie vstupu a nastavte vstup na „Použiť ako analógový“." + +#: inputremapper/gui/controller.py:779 +msgid "" +"\n" +"The input \"{}\" will be used as analog input." +msgstr "" +"\n" +"Vstup „{}“ sa použije ako analógový vstup." + +#: inputremapper/gui/controller.py:763 +msgid "" +"\n" +"This will remove \"{}\" from the text input!" +msgstr "" +"\n" +"Toto odstráni „{}“ z textového vstupu!" + +#: inputremapper/gui/controller.py:784 +msgid "" +"\n" +"You need to record an analog input." +msgstr "" +"\n" +"Analógový vstup musíte nahrať." + +#: data/input-remapper.glade:544 +msgid " (recording ...)" +msgstr " (nahrávam…)" + +#: inputremapper/gui/controller.py:174 +#, python-format +msgid "%d Mapping errors at \"%s\", hover for info" +msgstr "%d chýb mapovania na „%s“, prejdite myšou pre viac informácií" + +#: inputremapper/gui/controller.py:661 +msgid ", CTRL + DEL to stop" +msgstr ", zastavíte stlačením CTRL + DEL" + +#: data/input-remapper.glade:1421 +msgid "About" +msgstr "O programe" + +#: data/input-remapper.glade:422 +msgid "" +"Activate this to load the preset next time the device connects, or when the " +"user logs in" +msgstr "" +"Aktivujte túto možnosť pre načítanie prednastavenia pri ďalšom pripojení " +"zariadenia alebo pri prihlásení používateľa" + +#: data/input-remapper.glade:575 +msgid "Add" +msgstr "Pridať" + +#: inputremapper/gui/components/editor.py:554 +msgid "Add a mapping first" +msgstr "Pridajte najprv mapovanie" + +#: data/input-remapper.glade:606 data/input-remapper.glade:1618 +msgid "Advanced" +msgstr "Pokročilé" + +#: data/input-remapper.glade:773 data/input-remapper.glade:1118 +msgid "Analog Axis" +msgstr "Analógová os" + +#: inputremapper/gui/controller.py:657 +#, python-format +msgid "Applied preset %s" +msgstr "Použité prednastavenie %s" + +#: data/input-remapper.glade:318 +msgid "Apply" +msgstr "Použiť" + +#: inputremapper/gui/controller.py:522 +#, python-format +msgid "Are you sure you want to delete the preset \"%s\"?" +msgstr "Naozaj chcete odstrániť prednastavenie „%s“?" + +#: inputremapper/gui/controller.py:567 +msgid "Are you sure you want to delete this mapping?" +msgstr "Naozaj chcete odstrániť toto mapovanie?" + +#: data/input-remapper.glade:410 +msgid "Autoload" +msgstr "Automaticky načítať" + +#: data/input-remapper.glade:854 +msgid "Available output axes are affected by the Target setting." +msgstr "Dostupné osy výstupu sú ovplyvnené nastavením cieľa." + +#: inputremapper/gui/controller.py:621 +msgid "Cannot apply empty preset file" +msgstr "Nemôžete použiť prázdny súbor s prednastavením" + +#: inputremapper/gui/components/editor.py:238 +msgid "Change Mapping Name" +msgstr "Zmeniť názov mapovania" + +#: data/input-remapper.glade:352 +msgid "Copy" +msgstr "Kopírovať" + +#: data/input-remapper.glade:189 +msgid "Create a new preset" +msgstr "Vytvoriť nové prednastavenie" + +#: data/input-remapper.glade:925 +msgid "Deadzone" +msgstr "Mŕtva zóna" + +#: data/input-remapper.glade:368 data/input-remapper.glade:620 +msgid "Delete" +msgstr "Odstrániť" + +#: data/input-remapper.glade:624 +msgid "Delete this entry" +msgstr "Odstrániť túto položku" + +#: data/input-remapper.glade:372 +msgid "Delete this preset" +msgstr "Odstrániť toto prednastavenie" + +#: data/input-remapper.glade:162 +msgid "Device Name" +msgstr "Názov zariadenia" + +#: data/input-remapper.glade:148 +msgid "Devices" +msgstr "Zariadenia" + +#: data/input-remapper.glade:356 +msgid "Duplicate this preset" +msgstr "Duplikovať toto prednastavenie" + +#: data/input-remapper.glade:1208 +msgid "Editor" +msgstr "Editor" + +#: inputremapper/configs/mapping.py:77 +msgid "Empty Mapping" +msgstr "Prázdne mapovanie" + +#: inputremapper/gui/components/editor.py:404 +msgid "Enter your output here" +msgstr "Sem zadajte vstup" + +#: data/input-remapper.glade:1762 +msgid "Event Specific" +msgstr "Špecifické pre udalosť" + +#: data/input-remapper.glade:1009 +msgid "Expo" +msgstr "Exponent" + +#: inputremapper/gui/controller.py:648 inputremapper/gui/controller.py:673 +#, python-format +msgid "Failed to apply preset %s" +msgstr "Nepodarilo sa načítať prednastavenie %s" + +#: data/input-remapper.glade:967 +msgid "Gain" +msgstr "Zisk" + +#: data/input-remapper.glade:1736 +msgid "General" +msgstr "Všeobecné" + +#: data/input-remapper.glade:1312 +msgid "Help" +msgstr "Pomocník" + +#: data/input-remapper.glade:510 +msgid "Input" +msgstr "Vstup" + +#: data/input-remapper.glade:1296 +msgid "Input Remapper" +msgstr "Mapovač vstupu" + +#: data/input-remapper.glade:1087 +msgid "Input cutoff" +msgstr "Orezanie vstupu" + +#: data/input-remapper.glade:760 data/input-remapper.glade:1180 +msgid "Key or Macro" +msgstr "Kláves alebo makro" + +#: data/input-remapper.glade:1801 +msgid "Map this input to an Analog Axis" +msgstr "Mapovať tento vstup na analógovú os" + +#: data/input-remapper.glade:185 +msgid "New" +msgstr "Nové" + +#: inputremapper/gui/components/editor.py:980 +msgid "No Axis" +msgstr "Žiadna os" + +#: data/input-remapper.glade:721 +msgid "Output" +msgstr "Výstup" + +#: data/input-remapper.glade:862 +msgid "Output axis" +msgstr "Výstupná os" + +#: inputremapper/gui/controller.py:508 inputremapper/gui/controller.py:582 +msgid "Permission denied!" +msgstr "Odopretý prístup!" + +#: data/input-remapper.glade:287 +msgid "Preset Name" +msgstr "Názov prednastavenia" + +#: data/input-remapper.glade:272 +msgid "Presets" +msgstr "Prednastavenia" + +#: data/input-remapper.glade:590 +msgid "Record" +msgstr "Nahrať" + +#: data/input-remapper.glade:594 +msgid "Record a button of your device that should be remapped" +msgstr "Zaznamenajte tlačidlo zariadenia, ktoré sa má premapovať" + +#: inputremapper/gui/components/editor.py:563 +msgid "Record input first" +msgstr "Najprv nahrajte vstup" + +#: inputremapper/gui/components/editor.py:65 +msgid "Record the input first" +msgstr "Najprv nahrajte kláves" + +#: data/input-remapper.glade:1708 +msgid "" +"Release all inputs which are part of the combination before the mapping is " +"injected" +msgstr "" +"Uvoľniť všetky vstupy, ktoré sú súčasťou kombinácie pred tým, ako sa spustí " +"injektáž" + +#: data/input-remapper.glade:1711 +msgid "Release input" +msgstr "Uvoľniť vstup" + +#: data/input-remapper.glade:1683 +msgid "Release timeout" +msgstr "Časový limit uvoľnenia" + +#: inputremapper/gui/controller.py:208 +msgid "Remove the Analog Output Axis when specifying a macro or key output" +msgstr "Odstráňte výstupnú analógovú os pri definovaní výstupného makra alebo klávesu" + +#: inputremapper/gui/controller.py:199 +msgid "" +"Remove the macro or key from the macro input field when specifying an analog " +"output" +msgstr "" +"Odstráňte makro alebo kláves z poľa vstupu makra, ak definujete analógový výstup" + +#: data/input-remapper.glade:1776 +msgid "Remove this input" +msgstr "Odstrániť tento vstup" + +#: data/input-remapper.glade:394 +msgid "Rename" +msgstr "Premenovať" + +#: data/input-remapper.glade:452 +msgid "Save the entered name" +msgstr "Uložiť pod týmto názvom" + +#: data/input-remapper.glade:1449 +msgid "" +"See usage.md online on github for comprehensive information.\n" +"\n" +"A \"key + key + ... + key\" syntax can be used to trigger key combinations. " +"For example \"Control_L + a\".\n" +"\n" +"Writing \"disable\" as a mapping disables a key.\n" +"\n" +"Macros allow multiple characters to be written with a single key-press. " +"Information about programming them is available online on github. See macros.md and examples.md" +msgstr "" +"Vyčerpávajúce informácie nájdete na online stránke usage.md na " +"githube.\n" +"\n" +"Syntax \"key + key + ... + key\" môžete použit na aktivovanie kombinácie " +"klávesov. Napríklad \"Control_L + a\".\n" +"\n" +"Ak použijete \"disable\" ako mapovanie, mapovanie klávesu deaktivujete.\n" +"\n" +"Makrá vám umožnia aktivovať viacero znakov pomocou stlačenia jedného " +"klávesu. Informácie o možnostiach programovania nájdete online na githube. " +"Pozrite macros.md a examples.md" + +#: data/input-remapper.glade:1590 +msgid "Shortcuts" +msgstr "Skratky" + +#: data/input-remapper.glade:1492 +msgid "" +"Shortcuts only work while keys are not being recorded and the gui is in " +"focus." +msgstr "" +"Skratky fungujú iba vtedy, ak sa tlačidlá nenahrávaju a okno programu je " +"aktívne." + +#: data/input-remapper.glade:322 +msgid "Start injecting. Don't hold down any keys while the injection starts" +msgstr "" +"Spustiť injektáž. Uistite sa, že pri spúšťaní injektáže nie sú stlačené " +"žiadne tlačidlá" + +#: inputremapper/gui/controller.py:643 +msgid "Starting injection..." +msgstr "Spúšťam injektáž…" + +#: data/input-remapper.glade:201 data/input-remapper.glade:334 +msgid "Stop" +msgstr "Zastaviť injektáž" + +#: inputremapper/gui/controller.py:710 +msgid "Stopped the injection" +msgstr "Injektáž bola zastavená" + +#: data/input-remapper.glade:205 data/input-remapper.glade:338 +msgid "" +"Stops the Injection for the selected device,\n" +"gives your keys their original function back\n" +"Shortcut: ctrl + del" +msgstr "" +"Zastaví injektáž pre vybrané zariadenie,\n" +"vráti klávesom ich pôvodnú funkciu\n" +"Klávesová skratka: ctrl + del" + +#: data/input-remapper.glade:812 +msgid "Target" +msgstr "Cieľ" + +#: data/input-remapper.glade:1078 +msgid "" +"The Speed at which the Input is considered at maximum.\n" +"Only relevant when mapping relative inputs (e.g. mouse) to absolute outputs " +"(e.g. gamepad)" +msgstr "" +"Rýchlosť, pri ktorej sa vstup považuje byť na maxime.\n" +"Má význam iba pri mapovaní relatívnych vstup (napr. myši) na absolútne výstupy " +"(napr. gamepad)" + +#: inputremapper/gui/controller.py:234 +msgid "" +"The input specifies a key or macro input, but no macro or key is programmed." +msgstr "" +"Vstup definuje vstup ako kláves alebo makro, ale žiaden kláves ani makro nie " +"je naprogramované." + +#: inputremapper/gui/controller.py:213 +msgid "The input specifies an analog axis, but no output axis is selected." +msgstr "Vstup definuje analógovú os, ale nevybrali ste výstupnú os." + +#: data/input-remapper.glade:803 +msgid "The type of device this mapping is emulating." +msgstr "Typ zariadenia, ktoré toto mapovanie emuluje." + +#: data/input-remapper.glade:1831 +msgid "Trigger threshold" +msgstr "Prah aktivácie" + +#: data/input-remapper.glade:743 +msgid "Type" +msgstr "Typ" + +#: data/input-remapper.glade:1473 +msgid "Usage" +msgstr "Použitie" + +#: inputremapper/gui/controller.py:593 +msgid "Use \"Stop\" to stop before editing" +msgstr "Pred editovaním použite „Zastaviť injektáž“" + +#: data/input-remapper.glade:1805 +msgid "Use as analog" +msgstr "Použiť ako analógový" + +#: data/input-remapper.glade:1366 +msgid "Version unknown" +msgstr "Neznáma verzia" + +#: data/input-remapper.glade:1134 +msgid "What should be written. For example KEY_A" +msgstr "Čo by sa malo napísať. Napríklad KEY_A" + +#: inputremapper/gui/controller.py:761 +msgid "You are about to change the mapping to analog." +msgstr "Chystáte sa zmeniť mapovanie na analógové." + +#: data/input-remapper.glade:1161 +msgid "You can copy this text into the output" +msgstr "Tento text môžete skopírovať do výstupu" + +#: data/input-remapper.glade:1383 +msgid "" +"You can find more information and report bugs at\n" +"https://github.com/" +"sezanzeb/input-remapper" +msgstr "" +"Viac informácií a hlásenia chýb nájdete na\n" +"https://github.com/" +"sezanzeb/input-remapper" + +#: inputremapper/gui/controller.py:623 +msgid "You need to add mappings first" +msgstr "Najprv musíte pridať mapovania" + +#: inputremapper/gui/controller.py:358 +msgid "" +"Your system might reinterpret combinations with those after they are " +"injected, and by doing so break them." +msgstr "" +"Váš systém by mohol opätovne interpretovať kombinácie po tom, čo dôjde " +"k ich injektáži, čo by mohlo mať nežiadúci efekt." + +#: data/input-remapper.glade:1524 +msgid "closes the application" +msgstr "zavrie aplikáciu" + +#: data/input-remapper.glade:1512 +msgid "ctrl + del" +msgstr "ctrl + del" + +#: data/input-remapper.glade:1536 +msgid "ctrl + q" +msgstr "ctrl + q" + +#: data/input-remapper.glade:1548 +msgid "ctrl + r" +msgstr "ctrl + r" + +#: inputremapper/gui/controller.py:356 +msgid "ctrl, alt and shift may not combine properly" +msgstr "ctrl, alt a shift nemusia v kombinácii fungovať správne" + +#: inputremapper/gui/data_manager.py:56 +msgid "new preset" +msgstr "nové prednastavenie" + +#: data/input-remapper.glade:531 inputremapper/gui/user_interface.py:385 +msgid "no input configured" +msgstr "nie je nastavený žiaden vstup" + +#: data/input-remapper.glade:1560 +msgid "refreshes the device list" +msgstr "aktualizuje zoznam zariadení" + +#: data/input-remapper.glade:1572 +msgid "stops the injection" +msgstr "zastaví injektáž" + +#: data/input-remapper.glade:1403 +msgid "" +"© 2025 Sezanzeb b8x45ygc9@mozmail.com\n" +"This program comes with absolutely no warranty.\n" +"See the GNU General " +"Public License, version 3 or later for details." +msgstr "" +"© 2025 Sezanzeb b8x45ygc9@mozmail.com\n" +"Tento program je poskytovaný bez akýchkoľvek záruk.\n" +"Podrobnosti nájdete v podmienkach licencie GNU General Public License, version 3 alebo novšej." + +#, python-format +#~ msgid "\"%s\" already mapped to \"%s\"" +#~ msgstr "\"%s\" je už namapované na \"%s\"" + +#~ msgid "Applied the system default" +#~ msgstr "Použité systémové nastavenia" + +#~ msgid "Buttons" +#~ msgstr "Tlačidlá" + +#~ msgid "Cancel" +#~ msgstr "Zrušiť" + +#~ msgid "Change Key" +#~ msgstr "Zmeniť kláves" + +#~ msgid "Joystick" +#~ msgstr "Joystick" + +#~ msgid "Left joystick" +#~ msgstr "Ľavý joystick" + +#~ msgid "Mouse" +#~ msgstr "Myš" + +#~ msgid "Mouse speed" +#~ msgstr "Citlivosť myši" + +#~ msgid "Right joystick" +#~ msgstr "Pravý joystick" + +#~ msgid "" +#~ "Shortcut: ctrl + del\n" +#~ "Gives your keys back their original function" +#~ msgstr "" +#~ "Skratka: ctrl + del\n" +#~ "Vráti vašim klávesom pôvodnú funkciu" + +#~ msgid "The helper did not start" +#~ msgstr "Pomocná aplikácia sa nespustila" + +#~ msgid "" +#~ "To automatically apply the preset after your login or when it connects." +#~ msgstr "" +#~ "Na automatické načítanie prednastavenia po prihlásení alebo pripojení " +#~ "zariadenia." + +#, python-format +#~ msgid "Unknown mapping %s" +#~ msgstr "Neznáme mapovanie %s" + +#~ msgid "Wheel" +#~ msgstr "Koliesko" + +#~ msgid "Your system might reinterpret combinations " +#~ msgstr "Váš systém by mohol zle interpretovať " + +#~ msgid "break them." +#~ msgstr "ich rozbiť." + +#~ msgid "new entry" +#~ msgstr "nová položka" + +#~ msgid "." +#~ msgstr "." + +#~ msgid "1, 2" +#~ msgstr "1, 2" + +#~ msgid "" +#~ "A \"key + key + ... + key\" syntax can be used to trigger key " +#~ "combinations. For example \"control_l + a\".\n" +#~ "\n" +#~ "\"disable\" disables a key." +#~ msgstr "" +#~ "Syntax \"kláves + kláves + ... + kláves\" môžete použiť na zadávanie " +#~ "kombinácie klávesov. Napríklad \"control_l + a\".\n" +#~ "\n" +#~ "\"disable\" deaktivuje mapovanie klávesu." + +#~ msgid "" +#~ "Between calls to k, key down and key up events, macros will sleep for " +#~ "10ms by default, which can be configured in ~/.config/input-remapper/" +#~ "config" +#~ msgstr "" +#~ "Medzi volaniami k, udalosťami stlačeného a uvoľneného klávesu štandardne " +#~ "čakajú makrá 10 ms. Toto nastavenie môžete zmeniť v ~/.config/input-" +#~ "remapper/config" + +#~ msgid "CTRL + a, CTRL + x" +#~ msgstr "CTRL + a, CTRL + x" + +#~ msgid "" +#~ "Click on a cell below and hit a key on your device. Click the \"Restore " +#~ "Defaults\" button beforehand." +#~ msgstr "" +#~ "Kliknite do poľa nižšie a stlačte tlačidlo na vašom zariadení. Predtým " +#~ "kliknite na tlačidlo \"Obnoviť predvolené\"." + +#~ msgid "Examples" +#~ msgstr "Príklady" + +#~ msgid "Go Back" +#~ msgstr "Prejsť späť" + +#~ msgid "Key" +#~ msgstr "Kláves" + +#~ msgid "Macros" +#~ msgstr "Makrá" + +#~ msgid "" +#~ "Macros allow multiple characters to be written with a single key-press." +#~ msgstr "" +#~ "Makrá vám umožnia zapísať po stlačení jedného klávesu viacero znakov." + +#~ msgid "a, a, a with 500ms pause" +#~ msgstr "a, a, a s 500ms oneskorením" + +#~ msgid "e" +#~ msgstr "e" + +#~ msgid "e(EV_REL, REL_X, 10)" +#~ msgstr "e(EV_REL, REL_X, 10)" + +#~ msgid "executes the parameter as long as the key is pressed down" +#~ msgstr "vykoná parameter, kým je kláves stlačený" + +#~ msgid "executes two actions behind each other" +#~ msgstr "vykoná dve akcie za sebou" + +#~ msgid "h" +#~ msgstr "h" + +#~ msgid "holds a modifier while executing the second parameter" +#~ msgstr "počas vykonávania druhého parametra je stlačený modifikátor" + +#~ msgid "k" +#~ msgstr "k" + +#~ msgid "k(1).h(k(2)).k(3)" +#~ msgstr "k(1).h(k(2)).k(3)" + +#~ msgid "k(1).k(2)" +#~ msgstr "k(1).k(2)" + +#~ msgid "keeps scrolling down while held" +#~ msgstr "kým je držaný aktivuje sa skrolovanie smerom nadol" + +#~ msgid "m" +#~ msgstr "m" + +#~ msgid "m(Control_L, k(a).k(x))" +#~ msgstr "m(Control_L, k(a).k(x))" + +#~ msgid "mouse" +#~ msgstr "mouse" + +#~ msgid "mouse(right, 4)" +#~ msgstr "mouse(right, 4)" + +#~ msgid "moves the mouse cursor 10px to the right" +#~ msgstr "posunie kurzor myši o 10 pixelov doprava" + +#~ msgid "r" +#~ msgstr "r" + +#~ msgid "r(3, k(a).w(500))" +#~ msgstr "r(3, k(a).w(500))" + +#~ msgid "repeats the execution of the second parameter" +#~ msgstr "zopakuje spustenie druhého parametra" + +#~ msgid "same as mouse" +#~ msgstr "tie isté ako pri myši" + +#~ msgid "takes direction (up, left, ...) and speed as parameters" +#~ msgstr "prijíma smer (hore, vľavo, ...) a rýchlosť ako parametre" + +#~ msgid "w" +#~ msgstr "w" + +#~ msgid "waits in milliseconds" +#~ msgstr "čakanie (v milisekundách)" + +#~ msgid "wheel" +#~ msgstr "wheel" + +#~ msgid "wheel(down, 1)" +#~ msgstr "wheel(down, 1)" + +#~ msgid "which keeps moving the mouse while pressed" +#~ msgstr "kým je stlačený, kurzor myši sa bude posúvať" + +#~ msgid "writes 1 2 2 ... 2 2 3 while the key is pressed" +#~ msgstr "zapíše 1 2 2 ... 2 2 3, kým je kláves stlačený" + +#~ msgid "writes a single keystroke" +#~ msgstr "zapíše jedno stlačenie klávesu" + +#~ msgid "writes an event" +#~ msgstr "zapíše udalosť" diff --git a/po/uk.po b/po/uk.po new file mode 120000 index 0000000..e07f33f --- /dev/null +++ b/po/uk.po @@ -0,0 +1 @@ +uk_UA.po \ No newline at end of file diff --git a/po/uk_UA.po b/po/uk_UA.po new file mode 100644 index 0000000..1ec98d1 --- /dev/null +++ b/po/uk_UA.po @@ -0,0 +1,577 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +msgid "" +msgstr "" +"Project-Id-Version: \n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2023-05-16 08:30-0300\n" +"PO-Revision-Date: 2025-08-14 22:20+0200\n" +"Last-Translator: Vsevolod «Damglador» Stopchanskyi " +"\n" +"Language-Team: coffebar, Vsevolod «Damglador» Stopchanskyi\n" +"Language: uk_UA\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && " +"n%10<=4 && (n%100<12 || n%100>14) ? 1 : 2);\n" +"X-Generator: Poedit 3.6\n" + +#: inputremapper/gui/controller.py:222 +msgid "" +"\n" +"If you mean to create a key or macro mapping go to the advanced input " +"configuration and set a \"Trigger Threshold\" for " +msgstr "" + +#: inputremapper/gui/controller.py:239 +msgid "" +"\n" +"If you mean to create an analog axis mapping go to the advanced input " +"configuration and set an input to \"Use as Analog\"." +msgstr "" + +#: inputremapper/gui/controller.py:779 +msgid "" +"\n" +"The input \"{}\" will be used as analog input." +msgstr "" +"\n" +"Уведення «{}» використовуватиметься як аналогове." + +#: inputremapper/gui/controller.py:763 +msgid "" +"\n" +"This will remove \"{}\" from the text input!" +msgstr "" +"\n" +"Прибере «{}» з текстового введення." + +#: inputremapper/gui/controller.py:784 +msgid "" +"\n" +"You need to record an analog input." +msgstr "" +"\n" +"Необхідно записати аналогове введення." + +#: data/input-remapper.glade:544 +msgid " (recording ...)" +msgstr " (Записуємо...)" + +#: inputremapper/gui/controller.py:174 +#, python-format +msgid "%d Mapping errors at \"%s\", hover for info" +msgstr "%d синтаксичних помилок в %s, наведіть курсор для подробиць" + +#: inputremapper/gui/controller.py:661 +msgid ", CTRL + DEL to stop" +msgstr ", CTRL + DEL щоб зупинити" + +#: data/input-remapper.glade:1421 +msgid "About" +msgstr "Про програму" + +#: data/input-remapper.glade:422 +msgid "" +"Activate this to load the preset next time the device connects, or when the " +"user logs in" +msgstr "" + +#: data/input-remapper.glade:575 +msgid "Add" +msgstr "Додати" + +#: inputremapper/gui/components/editor.py:554 +msgid "Add a mapping first" +msgstr "Спочатку додайте прив'язку" + +#: data/input-remapper.glade:606 data/input-remapper.glade:1618 +msgid "Advanced" +msgstr "Розширені" + +#: data/input-remapper.glade:773 data/input-remapper.glade:1118 +msgid "Analog Axis" +msgstr "Аналоговий" + +#: inputremapper/gui/controller.py:657 +#, python-format +msgid "Applied preset %s" +msgstr "Застосовано пресет %s" + +#: data/input-remapper.glade:318 +msgid "Apply" +msgstr "Застосувати" + +#: inputremapper/gui/controller.py:522 +#, python-format +msgid "Are you sure you want to delete the preset \"%s\"?" +msgstr "Ви впевнені що хочете видалити пресет %s?" + +#: inputremapper/gui/controller.py:567 +msgid "Are you sure you want to delete this mapping?" +msgstr "Ви впевнені, що хочете видалити цю прив'язку?" + +#: data/input-remapper.glade:410 +msgid "Autoload" +msgstr "Автозавантаження" + +#: data/input-remapper.glade:854 +msgid "Available output axes are affected by the Target setting." +msgstr "Доступні вихідні осі залежать від параметра Ціль." + +#: inputremapper/gui/controller.py:621 +msgid "Cannot apply empty preset file" +msgstr "Неможливо застосувати порожній файл попереднього налаштування" + +#: inputremapper/gui/components/editor.py:238 +msgid "Change Mapping Name" +msgstr "Змінити назву прив'язки" + +#: data/input-remapper.glade:352 +msgid "Copy" +msgstr "Копіювати" + +#: data/input-remapper.glade:189 +msgid "Create a new preset" +msgstr "Стваорити новий пресет" + +#: data/input-remapper.glade:925 +msgid "Deadzone" +msgstr "Мертва зона" + +#: data/input-remapper.glade:368 data/input-remapper.glade:620 +msgid "Delete" +msgstr "Видалити" + +#: data/input-remapper.glade:624 +msgid "Delete this entry" +msgstr "Видалити запис" + +#: data/input-remapper.glade:372 +msgid "Delete this preset" +msgstr "Видалити пресет" + +#: data/input-remapper.glade:162 +msgid "Device Name" +msgstr "Назва пристрою" + +#: data/input-remapper.glade:148 +msgid "Devices" +msgstr "Пристрої" + +#: data/input-remapper.glade:356 +msgid "Duplicate this preset" +msgstr "Копіювати пресет" + +#: data/input-remapper.glade:1208 +msgid "Editor" +msgstr "Редактор" + +#: inputremapper/configs/mapping.py:77 +msgid "Empty Mapping" +msgstr "Порожня прив'язка" + +#: inputremapper/gui/components/editor.py:404 +msgid "Enter your output here" +msgstr "" + +#: data/input-remapper.glade:1762 +msgid "Event Specific" +msgstr "Залежно від події" + +#: data/input-remapper.glade:1009 +msgid "Expo" +msgstr "Експо" + +#: inputremapper/gui/controller.py:648 inputremapper/gui/controller.py:673 +#, python-format +msgid "Failed to apply preset %s" +msgstr "Не вдалося застосувати пресет %s" + +#: data/input-remapper.glade:967 +msgid "Gain" +msgstr "Посилення" + +#: data/input-remapper.glade:1736 +msgid "General" +msgstr "Загалні" + +#: data/input-remapper.glade:1312 +msgid "Help" +msgstr "Допомога" + +#: data/input-remapper.glade:510 +msgid "Input" +msgstr "Уведення" + +#: data/input-remapper.glade:1296 +msgid "Input Remapper" +msgstr "Input Remapper" + +#: data/input-remapper.glade:1087 +msgid "Input cutoff" +msgstr "Відсічення введення" + +#: data/input-remapper.glade:760 data/input-remapper.glade:1180 +msgid "Key or Macro" +msgstr "Клавіша або макрос" + +#: data/input-remapper.glade:1801 +msgid "Map this input to an Analog Axis" +msgstr "Призначити це введення на аналогову вісь" + +#: data/input-remapper.glade:185 +msgid "New" +msgstr "Нове" + +#: inputremapper/gui/components/editor.py:980 +#, fuzzy +msgid "No Axis" +msgstr "Аналоговий" + +#: data/input-remapper.glade:721 +msgid "Output" +msgstr "Виведення" + +#: data/input-remapper.glade:862 +msgid "Output axis" +msgstr "Вихідна вісь" + +#: inputremapper/gui/controller.py:508 inputremapper/gui/controller.py:582 +msgid "Permission denied!" +msgstr "Доступ відсутній!" + +#: data/input-remapper.glade:287 +msgid "Preset Name" +msgstr "Назва пресету" + +#: data/input-remapper.glade:272 +msgid "Presets" +msgstr "Пресети" + +#: data/input-remapper.glade:590 +msgid "Record" +msgstr "Запис" + +#: data/input-remapper.glade:594 +msgid "Record a button of your device that should be remapped" +msgstr "Записати клавішу яку потрібно перепризначити" + +#: inputremapper/gui/components/editor.py:563 +msgid "Record input first" +msgstr "" + +#: inputremapper/gui/components/editor.py:65 +#, fuzzy +msgid "Record the input first" +msgstr "Спочатку задайте клавішу" + +#: data/input-remapper.glade:1708 +msgid "" +"Release all inputs which are part of the combination before the mapping is " +"injected" +msgstr "" + +#: data/input-remapper.glade:1711 +msgid "Release input" +msgstr "Звільнити введення" + +#: data/input-remapper.glade:1683 +msgid "Release timeout" +msgstr "Таймаут звільнення" + +#: inputremapper/gui/controller.py:208 +msgid "Remove the Analog Output Axis when specifying a macro or key output" +msgstr "" + +#: inputremapper/gui/controller.py:199 +msgid "" +"Remove the macro or key from the macro input field when specifying an analog " +"output" +msgstr "" + +#: data/input-remapper.glade:1776 +msgid "Remove this input" +msgstr "Видалити це введення" + +#: data/input-remapper.glade:394 +msgid "Rename" +msgstr "Перейменувати" + +#: data/input-remapper.glade:452 +msgid "Save the entered name" +msgstr "Зберігти вказану назву" + +#: data/input-remapper.glade:1449 +msgid "" +"See usage.md online on github for comprehensive information.\n" +"\n" +"A \"key + key + ... + key\" syntax can be used to trigger key combinations. " +"For example \"Control_L + a\".\n" +"\n" +"Writing \"disable\" as a mapping disables a key.\n" +"\n" +"Macros allow multiple characters to be written with a single key-press. " +"Information about programming them is available online on github. See macros.md and examples.md" +msgstr "" +"Дивіться usage.md в Інтернеті на github для вичерпної " +"інформації.\n" +"\n" +"Ви можете використовувати синтаксис \"key + key + ... + key\" щоб викликати " +"комбінацію клавіш. Приклад: \"Control_L + a\".\n" +"\n" +"Слово «disable» в полі прив'язки відключає клавішу.\n" +"\n" +"Макроси дозволяють вводити декілька символів одним натисканням клавіші. " +"Інформація про їх програмування доступна в Інтернеті на github. Дивіться macros.md та examples.md" + +#: data/input-remapper.glade:1590 +msgid "Shortcuts" +msgstr "Комбінації" + +#: data/input-remapper.glade:1492 +msgid "" +"Shortcuts only work while keys are not being recorded and the gui is in " +"focus." +msgstr "" +"Комбінації працюють лише тоді, коли клавіші не записуються, а графічний " +"інтерфейс у фокусі." + +#: data/input-remapper.glade:322 +msgid "Start injecting. Don't hold down any keys while the injection starts" +msgstr "" +"Запуск перехоплення. Не натискайте нічого поки перехоплення запускається" + +#: inputremapper/gui/controller.py:643 +msgid "Starting injection..." +msgstr "Запуск перехоплення..." + +#: data/input-remapper.glade:201 data/input-remapper.glade:334 +msgid "Stop" +msgstr "Зупинити" + +#: inputremapper/gui/controller.py:710 +msgid "Stopped the injection" +msgstr "Зупиняє перехоплення" + +#: data/input-remapper.glade:205 data/input-remapper.glade:338 +msgid "" +"Stops the Injection for the selected device,\n" +"gives your keys their original function back\n" +"Shortcut: ctrl + del" +msgstr "" +"Зупиняє ін’єкцію для вибраного пристрою,\n" +"повертає вашим клавішам їх початкову функцію\n" +"Комбінація клавіш: ctrl + del" + +#: data/input-remapper.glade:812 +msgid "Target" +msgstr "Ціль" + +#: data/input-remapper.glade:1078 +msgid "" +"The Speed at which the Input is considered at maximum.\n" +"Only relevant when mapping relative inputs (e.g. mouse) to absolute outputs " +"(e.g. gamepad)" +msgstr "" +"Швидкість, з якою введення вважається максимальним.\n" +"Доречно лише під час зіставлення відносних входів (наприклад, миші) з " +"абсолютними виходами (наприклад, контролер)" + +#: inputremapper/gui/controller.py:234 +msgid "" +"The input specifies a key or macro input, but no macro or key is programmed." +msgstr "" + +#: inputremapper/gui/controller.py:213 +msgid "The input specifies an analog axis, but no output axis is selected." +msgstr "" + +#: data/input-remapper.glade:803 +msgid "The type of device this mapping is emulating." +msgstr "Тип пристрою, який емулює цей мапінг." + +#: data/input-remapper.glade:1831 +msgid "Trigger threshold" +msgstr "Поріг спрацьовування" + +#: data/input-remapper.glade:743 +msgid "Type" +msgstr "Тип" + +#: data/input-remapper.glade:1473 +msgid "Usage" +msgstr "Використання" + +#: inputremapper/gui/controller.py:593 +msgid "Use \"Stop\" to stop before editing" +msgstr "Натисніть «Зупинити» перед початком редагування" + +#: data/input-remapper.glade:1805 +msgid "Use as analog" +msgstr "Використовувати як аналоговий" + +#: data/input-remapper.glade:1366 +msgid "Version unknown" +msgstr "Невідома версія" + +#: data/input-remapper.glade:1134 +msgid "What should be written. For example KEY_A" +msgstr "Що треба написати. Наприклад KEY_A" + +#: inputremapper/gui/controller.py:761 +msgid "You are about to change the mapping to analog." +msgstr "" + +#: data/input-remapper.glade:1161 +msgid "You can copy this text into the output" +msgstr "Ви можете скопіювати цей текст у вивід" + +#: data/input-remapper.glade:1383 +msgid "" +"You can find more information and report bugs at\n" +"https://github.com/" +"sezanzeb/input-remapper" +msgstr "" +"Ви можете знайти більше інформації та повідомити про помилки на\n" +"https://github.com/" +"sezanzeb/input-remapper" + +#: inputremapper/gui/controller.py:623 +msgid "You need to add mappings first" +msgstr "Необхідно спочатку додати прив'язки" + +#: inputremapper/gui/controller.py:358 +msgid "" +"Your system might reinterpret combinations with those after they are " +"injected, and by doing so break them." +msgstr "" + +#: data/input-remapper.glade:1524 +msgid "closes the application" +msgstr "закриває програму" + +#: data/input-remapper.glade:1512 +msgid "ctrl + del" +msgstr "" + +#: data/input-remapper.glade:1536 +msgid "ctrl + q" +msgstr "" + +#: data/input-remapper.glade:1548 +msgid "ctrl + r" +msgstr "" + +#: inputremapper/gui/controller.py:356 +msgid "ctrl, alt and shift may not combine properly" +msgstr "ctrl, alt та shift можуть не поєднуватися належним чином" + +#: inputremapper/gui/data_manager.py:56 +msgid "new preset" +msgstr "Створити новий пресет" + +#: data/input-remapper.glade:531 inputremapper/gui/user_interface.py:385 +msgid "no input configured" +msgstr "уведення не налаштовано" + +#: data/input-remapper.glade:1560 +msgid "refreshes the device list" +msgstr "оновлює список пристроїв" + +#: data/input-remapper.glade:1572 +msgid "stops the injection" +msgstr "зупиняє перехоплення" + +#: data/input-remapper.glade:1403 +msgid "" +"© 2025 Sezanzeb b8x45ygc9@mozmail.com\n" +"This program comes with absolutely no warranty.\n" +"See the GNU General " +"Public License, version 3 or later for details." +msgstr "" +"© 2025 Sezanzeb b8x45ygc9@mozmail.com\n" +"Ця програма постачається без жодних гарантій.\n" +"Дивіться GNU General " +"Public License, версії 3 або новіше щоб дізнатись більше." + +#, python-format +#~ msgid "\"%s\" already mapped to \"%s\"" +#~ msgstr "\"%s\" вже призначено до \"%s\"" + +#~ msgid "Applied the system default" +#~ msgstr "Застосовано за замовченням" + +#~ msgid "Buttons" +#~ msgstr "Кнопки" + +#~ msgid "Cancel" +#~ msgstr "Скасувати" + +#~ msgid "Change Key" +#~ msgstr "Змінити клавішу" + +#~ msgid "Joystick" +#~ msgstr "Джойстик" + +#~ msgid "Left joystick" +#~ msgstr "Лівий джойстик" + +#~ msgid "Mouse" +#~ msgstr "Миша" + +#~ msgid "Mouse speed" +#~ msgstr "Швидкість миши" + +#~ msgid "Press Key" +#~ msgstr "Натисніть клавішу" + +#~ msgid "Right joystick" +#~ msgstr "Правий джойстик" + +#~ msgid "" +#~ "Shortcut: ctrl + del\n" +#~ "Gives your keys back their original function" +#~ msgstr "" +#~ "Комбінація: ctrl + del\n" +#~ "Повертає вашим клавішам їх початкову функцію" + +#~ msgid "The helper did not start" +#~ msgstr "Помічник не запущено" + +#~ msgid "" +#~ "To automatically apply the preset after your login or when it connects." +#~ msgstr "" +#~ "Для автоматичного застосування попереднього налаштування після вашого " +#~ "входу або під час підключення." + +#, python-format +#~ msgid "Unknown mapping %s" +#~ msgstr "Невідомий мапінг %s" + +#~ msgid "Wheel" +#~ msgstr "Колесо" + +#~ msgid "Your system might reinterpret combinations " +#~ msgstr "Ваша система може повторно інтерпретувати комбінації " + +#~ msgid "break them." +#~ msgstr "порушуючи їх." + +#~ msgid "new entry" +#~ msgstr "новий запис" + +#~ msgid "Stop Injection" +#~ msgstr "Зупинити перехоплення" diff --git a/po/zh.po b/po/zh.po new file mode 120000 index 0000000..347ce65 --- /dev/null +++ b/po/zh.po @@ -0,0 +1 @@ +./zh_CN.po \ No newline at end of file diff --git a/po/zh_CN.po b/po/zh_CN.po new file mode 100644 index 0000000..3bea57c --- /dev/null +++ b/po/zh_CN.po @@ -0,0 +1,560 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +msgid "" +msgstr "" +"Project-Id-Version: \n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2023-05-16 08:30-0300\n" +"PO-Revision-Date: 2022-04-09 16:04+0800\n" +"Last-Translator: \n" +"Language-Team: \n" +"Language: zh_CN\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=1; plural=0;\n" +"X-Generator: Poedit 3.0.1\n" + +#: inputremapper/gui/controller.py:222 +msgid "" +"\n" +"If you mean to create a key or macro mapping go to the advanced input " +"configuration and set a \"Trigger Threshold\" for " +msgstr "" + +#: inputremapper/gui/controller.py:239 +msgid "" +"\n" +"If you mean to create an analog axis mapping go to the advanced input " +"configuration and set an input to \"Use as Analog\"." +msgstr "" + +#: inputremapper/gui/controller.py:779 +msgid "" +"\n" +"The input \"{}\" will be used as analog input." +msgstr "" + +#: inputremapper/gui/controller.py:763 +msgid "" +"\n" +"This will remove \"{}\" from the text input!" +msgstr "" + +#: inputremapper/gui/controller.py:784 +msgid "" +"\n" +"You need to record an analog input." +msgstr "" + +#: data/input-remapper.glade:544 +msgid " (recording ...)" +msgstr "" + +#: inputremapper/gui/controller.py:174 +#, fuzzy, python-format +msgid "%d Mapping errors at \"%s\", hover for info" +msgstr "%s有语法错误,请悬停以获取信息" + +#: inputremapper/gui/controller.py:661 +msgid ", CTRL + DEL to stop" +msgstr ", CTRL + DEL 停止" + +#: data/input-remapper.glade:1421 +msgid "About" +msgstr "关于" + +#: data/input-remapper.glade:422 +msgid "" +"Activate this to load the preset next time the device connects, or when the " +"user logs in" +msgstr "" + +#: data/input-remapper.glade:575 +msgid "Add" +msgstr "" + +#: inputremapper/gui/components/editor.py:554 +msgid "Add a mapping first" +msgstr "" + +#: data/input-remapper.glade:606 data/input-remapper.glade:1618 +msgid "Advanced" +msgstr "" + +#: data/input-remapper.glade:773 data/input-remapper.glade:1118 +msgid "Analog Axis" +msgstr "" + +#: inputremapper/gui/controller.py:657 +#, python-format +msgid "Applied preset %s" +msgstr "应用预设%s" + +#: data/input-remapper.glade:318 +msgid "Apply" +msgstr "应用" + +#: inputremapper/gui/controller.py:522 +#, fuzzy, python-format +msgid "Are you sure you want to delete the preset \"%s\"?" +msgstr "确定要删除预设%s吗?" + +#: inputremapper/gui/controller.py:567 +#, fuzzy +msgid "Are you sure you want to delete this mapping?" +msgstr "确定要删除此映射吗?" + +#: data/input-remapper.glade:410 +msgid "Autoload" +msgstr "自动加载" + +#: data/input-remapper.glade:854 +msgid "Available output axes are affected by the Target setting." +msgstr "" + +#: inputremapper/gui/controller.py:621 +msgid "Cannot apply empty preset file" +msgstr "无法应用空的预设文件" + +#: inputremapper/gui/components/editor.py:238 +msgid "Change Mapping Name" +msgstr "" + +#: data/input-remapper.glade:352 +msgid "Copy" +msgstr "复制" + +#: data/input-remapper.glade:189 +msgid "Create a new preset" +msgstr "创建新预设" + +#: data/input-remapper.glade:925 +msgid "Deadzone" +msgstr "" + +#: data/input-remapper.glade:368 data/input-remapper.glade:620 +msgid "Delete" +msgstr "删除" + +#: data/input-remapper.glade:624 +msgid "Delete this entry" +msgstr "删除此条目" + +#: data/input-remapper.glade:372 +msgid "Delete this preset" +msgstr "删除此预设" + +#: data/input-remapper.glade:162 +#, fuzzy +msgid "Device Name" +msgstr "设备" + +#: data/input-remapper.glade:148 +#, fuzzy +msgid "Devices" +msgstr "设备" + +#: data/input-remapper.glade:356 +msgid "Duplicate this preset" +msgstr "复制此预设" + +#: data/input-remapper.glade:1208 +msgid "Editor" +msgstr "" + +#: inputremapper/configs/mapping.py:77 +msgid "Empty Mapping" +msgstr "" + +#: inputremapper/gui/components/editor.py:404 +msgid "Enter your output here" +msgstr "" + +#: data/input-remapper.glade:1762 +msgid "Event Specific" +msgstr "" + +#: data/input-remapper.glade:1009 +msgid "Expo" +msgstr "" + +#: inputremapper/gui/controller.py:648 inputremapper/gui/controller.py:673 +#, python-format +msgid "Failed to apply preset %s" +msgstr "应用预设%s失败" + +#: data/input-remapper.glade:967 +msgid "Gain" +msgstr "" + +#: data/input-remapper.glade:1736 +msgid "General" +msgstr "" + +#: data/input-remapper.glade:1312 +msgid "Help" +msgstr "帮助" + +#: data/input-remapper.glade:510 +msgid "Input" +msgstr "" + +#: data/input-remapper.glade:1296 +msgid "Input Remapper" +msgstr "Input Remapper" + +#: data/input-remapper.glade:1087 +msgid "Input cutoff" +msgstr "" + +#: data/input-remapper.glade:760 data/input-remapper.glade:1180 +msgid "Key or Macro" +msgstr "" + +#: data/input-remapper.glade:1801 +msgid "Map this input to an Analog Axis" +msgstr "" + +#: data/input-remapper.glade:185 +msgid "New" +msgstr "新建" + +#: inputremapper/gui/components/editor.py:980 +msgid "No Axis" +msgstr "" + +#: data/input-remapper.glade:721 +msgid "Output" +msgstr "" + +#: data/input-remapper.glade:862 +msgid "Output axis" +msgstr "" + +#: inputremapper/gui/controller.py:508 inputremapper/gui/controller.py:582 +msgid "Permission denied!" +msgstr "权限不足!" + +#: data/input-remapper.glade:287 +#, fuzzy +msgid "Preset Name" +msgstr "预设" + +#: data/input-remapper.glade:272 +#, fuzzy +msgid "Presets" +msgstr "预设" + +#: data/input-remapper.glade:590 +msgid "Record" +msgstr "" + +#: data/input-remapper.glade:594 +msgid "Record a button of your device that should be remapped" +msgstr "记录你设备上应重新映射的按钮" + +#: inputremapper/gui/components/editor.py:563 +msgid "Record input first" +msgstr "" + +#: inputremapper/gui/components/editor.py:65 +#, fuzzy +msgid "Record the input first" +msgstr "先设置按键" + +#: data/input-remapper.glade:1708 +msgid "" +"Release all inputs which are part of the combination before the mapping is " +"injected" +msgstr "" + +#: data/input-remapper.glade:1711 +msgid "Release input" +msgstr "" + +#: data/input-remapper.glade:1683 +msgid "Release timeout" +msgstr "" + +#: inputremapper/gui/controller.py:208 +msgid "Remove the Analog Output Axis when specifying a macro or key output" +msgstr "" + +#: inputremapper/gui/controller.py:199 +msgid "" +"Remove the macro or key from the macro input field when specifying an analog " +"output" +msgstr "" + +#: data/input-remapper.glade:1776 +msgid "Remove this input" +msgstr "" + +#: data/input-remapper.glade:394 +msgid "Rename" +msgstr "重命名" + +#: data/input-remapper.glade:452 +msgid "Save the entered name" +msgstr "以输入的名字保存" + +#: data/input-remapper.glade:1449 +msgid "" +"See usage.md online on github for comprehensive information.\n" +"\n" +"A \"key + key + ... + key\" syntax can be used to trigger key combinations. " +"For example \"Control_L + a\".\n" +"\n" +"Writing \"disable\" as a mapping disables a key.\n" +"\n" +"Macros allow multiple characters to be written with a single key-press. " +"Information about programming them is available online on github. See macros.md and examples.md" +msgstr "" +"更全面的信息,参见 github 在线文档 usage.md。\n" +"\n" +"“键 + 键 + …… + 键”语言可用于触发组合键。例如“Control_L + a” 。\n" +"\n" +"输入“disable”作为映射值来禁用此按键。\n" +"\n" +"“宏”可以做到按一次键写入多个字符。有关编程的信息可以在 github 上在线获得。 查" +"阅 macros.mdexamples.md" + +#: data/input-remapper.glade:1590 +msgid "Shortcuts" +msgstr "快捷键" + +#: data/input-remapper.glade:1492 +msgid "" +"Shortcuts only work while keys are not being recorded and the gui is in " +"focus." +msgstr "快捷键仅在按键未被录制且应用界面处于焦点时有效。" + +#: data/input-remapper.glade:322 +msgid "Start injecting. Don't hold down any keys while the injection starts" +msgstr "启动注入。注入启动时不要按任何键" + +#: inputremapper/gui/controller.py:643 +msgid "Starting injection..." +msgstr "注入启动中……" + +#: data/input-remapper.glade:201 data/input-remapper.glade:334 +msgid "Stop" +msgstr "停止注入" + +#: inputremapper/gui/controller.py:710 +#, fuzzy +msgid "Stopped the injection" +msgstr "停止此注入" + +#: data/input-remapper.glade:205 data/input-remapper.glade:338 +msgid "" +"Stops the Injection for the selected device,\n" +"gives your keys their original function back\n" +"Shortcut: ctrl + del" +msgstr "" + +#: data/input-remapper.glade:812 +msgid "Target" +msgstr "" + +#: data/input-remapper.glade:1078 +msgid "" +"The Speed at which the Input is considered at maximum.\n" +"Only relevant when mapping relative inputs (e.g. mouse) to absolute outputs " +"(e.g. gamepad)" +msgstr "" + +#: inputremapper/gui/controller.py:234 +msgid "" +"The input specifies a key or macro input, but no macro or key is programmed." +msgstr "" + +#: inputremapper/gui/controller.py:213 +msgid "The input specifies an analog axis, but no output axis is selected." +msgstr "" + +#: data/input-remapper.glade:803 +msgid "The type of device this mapping is emulating." +msgstr "该映射正在模拟的设备类型。" + +#: data/input-remapper.glade:1831 +msgid "Trigger threshold" +msgstr "" + +#: data/input-remapper.glade:743 +msgid "Type" +msgstr "" + +#: data/input-remapper.glade:1473 +msgid "Usage" +msgstr "用法" + +#: inputremapper/gui/controller.py:593 +msgid "Use \"Stop\" to stop before editing" +msgstr "使用“停止注入”停止后再编辑" + +#: data/input-remapper.glade:1805 +msgid "Use as analog" +msgstr "" + +#: data/input-remapper.glade:1366 +msgid "Version unknown" +msgstr "版本未知" + +#: data/input-remapper.glade:1134 +msgid "What should be written. For example KEY_A" +msgstr "" + +#: inputremapper/gui/controller.py:761 +msgid "You are about to change the mapping to analog." +msgstr "" + +#: data/input-remapper.glade:1161 +msgid "You can copy this text into the output" +msgstr "" + +#: data/input-remapper.glade:1383 +msgid "" +"You can find more information and report bugs at\n" +"https://github.com/" +"sezanzeb/input-remapper" +msgstr "" +"你可以在此找到更多信息并报告 bug\n" +"https://github.com/" +"sezanzeb/input-remapper" + +#: inputremapper/gui/controller.py:623 +#, fuzzy +msgid "You need to add mappings first" +msgstr "你需要先添加键并保存" + +#: inputremapper/gui/controller.py:358 +#, fuzzy +msgid "" +"Your system might reinterpret combinations with those after they are " +"injected, and by doing so break them." +msgstr "系统可能会重新解释此组合 " + +#: data/input-remapper.glade:1524 +msgid "closes the application" +msgstr "关闭应用" + +#: data/input-remapper.glade:1512 +msgid "ctrl + del" +msgstr "ctrl + del" + +#: data/input-remapper.glade:1536 +msgid "ctrl + q" +msgstr "ctrl + q" + +#: data/input-remapper.glade:1548 +msgid "ctrl + r" +msgstr "ctrl + r" + +#: inputremapper/gui/controller.py:356 +msgid "ctrl, alt and shift may not combine properly" +msgstr "ctrl、alt 和 shift 键可能无法正确组合" + +#: inputremapper/gui/data_manager.py:56 +#, fuzzy +msgid "new preset" +msgstr "创建新预设" + +#: data/input-remapper.glade:531 inputremapper/gui/user_interface.py:385 +msgid "no input configured" +msgstr "" + +#: data/input-remapper.glade:1560 +msgid "refreshes the device list" +msgstr "刷新设备列表" + +#: data/input-remapper.glade:1572 +msgid "stops the injection" +msgstr "停止此注入" + +#: data/input-remapper.glade:1403 +msgid "" +"© 2025 Sezanzeb b8x45ygc9@mozmail.com\n" +"This program comes with absolutely no warranty.\n" +"See the GNU General " +"Public License, version 3 or later for details." +msgstr "" +"© 2025 Sezanzeb b8x45ygc9@mozmail.com\n" +"此程序毫无担保。\n" +"详情参见 GNU General " +"Public License, version 3 or later。" + +#, python-format +#~ msgid "\"%s\" already mapped to \"%s\"" +#~ msgstr "“%s”已映射到“%s”" + +#~ msgid "Applied the system default" +#~ msgstr "系统默认设置已应用" + +#~ msgid "Buttons" +#~ msgstr "按键" + +#~ msgid "Cancel" +#~ msgstr "取消" + +#~ msgid "Change Key" +#~ msgstr "修改键位" + +#~ msgid "Joystick" +#~ msgstr "摇杆" + +#~ msgid "Left joystick" +#~ msgstr "左摇杆" + +#~ msgid "Mouse" +#~ msgstr "鼠标" + +#~ msgid "Mouse speed" +#~ msgstr "鼠标速度" + +#~ msgid "Press Key" +#~ msgstr "按按键" + +#~ msgid "Right joystick" +#~ msgstr "右摇杆" + +#~ msgid "" +#~ "Shortcut: ctrl + del\n" +#~ "Gives your keys back their original function" +#~ msgstr "" +#~ "快捷键: ctrl + del\n" +#~ "一键恢复到按键的原有功能" + +#~ msgid "The helper did not start" +#~ msgstr "辅助程序未启动" + +#~ msgid "" +#~ "To automatically apply the preset after your login or when it connects." +#~ msgstr "用于在登录后或连接时自动应用该预设。" + +#, python-format +#~ msgid "Unknown mapping %s" +#~ msgstr "未知映射 %s" + +#~ msgid "Wheel" +#~ msgstr "滚轮" + +#~ msgid "Your system might reinterpret combinations " +#~ msgstr "在这些组合键位被注入后 " + +#~ msgid "break them." +#~ msgstr "从而破坏这些组合键。" + +#~ msgid "new entry" +#~ msgstr "新条目" diff --git a/po/zh_TW.po b/po/zh_TW.po new file mode 100644 index 0000000..5f63646 --- /dev/null +++ b/po/zh_TW.po @@ -0,0 +1,505 @@ +# Traditional Chinese (Taiwan) translation for input-remapper. +# Copyright (C) 2026 THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the input-remapper package. +# Peter Dave Hello , 2026. +# +msgid "" +msgstr "" +"Project-Id-Version: input-remapper\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2023-05-16 08:30-0300\n" +"PO-Revision-Date: 2026-05-28 02:54+0800\n" +"Last-Translator: Peter Dave Hello \n" +"Language-Team: \n" +"Language: zh_TW\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=1; plural=0;\n" + +#: inputremapper/gui/controller.py:222 +msgid "" +"\n" +"If you mean to create a key or macro mapping go to the advanced input " +"configuration and set a \"Trigger Threshold\" for " +msgstr "" +"\n" +"如果想建立按鍵或巨集對應,請前往進階輸入設定,並將「觸發臨界值」設定給 " + +#: inputremapper/gui/controller.py:239 +msgid "" +"\n" +"If you mean to create an analog axis mapping go to the advanced input " +"configuration and set an input to \"Use as Analog\"." +msgstr "" +"\n" +"若要建立類比軸對應,請前往進階輸入設定,並將輸入設為「以類比方式使用」。" + +#: inputremapper/gui/controller.py:779 +msgid "" +"\n" +"The input \"{}\" will be used as analog input." +msgstr "" +"\n" +"系統會將輸入「{}」當作類比輸入使用。" + +#: inputremapper/gui/controller.py:763 +msgid "" +"\n" +"This will remove \"{}\" from the text input!" +msgstr "" +"\n" +"這會將「{}」從文字輸入中移除!" + +#: inputremapper/gui/controller.py:784 +msgid "" +"\n" +"You need to record an analog input." +msgstr "" +"\n" +"請錄製一個類比輸入。" + +#: data/input-remapper.glade:544 +msgid " (recording ...)" +msgstr "(錄製中……)" + +#: inputremapper/gui/controller.py:174 +#, python-format +msgid "%d Mapping errors at \"%s\", hover for info" +msgstr "%d 個對應錯誤位於「%s」,將游標停在上方檢視資訊" + +#: inputremapper/gui/controller.py:661 +msgid ", CTRL + DEL to stop" +msgstr ",按 CTRL + DEL 停止" + +#: data/input-remapper.glade:1421 +msgid "About" +msgstr "關於" + +#: data/input-remapper.glade:422 +msgid "" +"Activate this to load the preset next time the device connects, or when the " +"user logs in" +msgstr "" +"啟用此選項後,裝置下次連線時或使用者登入時將自動載入預設組合" + +#: data/input-remapper.glade:575 +msgid "Add" +msgstr "新增" + +#: inputremapper/gui/components/editor.py:554 +msgid "Add a mapping first" +msgstr "請先新增一個對應" + +#: data/input-remapper.glade:606 data/input-remapper.glade:1618 +msgid "Advanced" +msgstr "進階" + +#: data/input-remapper.glade:773 data/input-remapper.glade:1118 +msgid "Analog Axis" +msgstr "類比軸" + +#: inputremapper/gui/controller.py:657 +#, python-format +msgid "Applied preset %s" +msgstr "已套用預設組合 %s" + +#: data/input-remapper.glade:318 +msgid "Apply" +msgstr "套用" + +#: inputremapper/gui/controller.py:522 +#, python-format +msgid "Are you sure you want to delete the preset \"%s\"?" +msgstr "確定要刪除預設組合「%s」嗎?" + +#: inputremapper/gui/controller.py:567 +msgid "Are you sure you want to delete this mapping?" +msgstr "確定要刪除此對應嗎?" + +#: data/input-remapper.glade:410 +msgid "Autoload" +msgstr "自動載入" + +#: data/input-remapper.glade:854 +msgid "Available output axes are affected by the Target setting." +msgstr "目標設定會影響可用的輸出軸。" + +#: inputremapper/gui/controller.py:621 +msgid "Cannot apply empty preset file" +msgstr "無法套用空白的預設組合檔案" + +#: inputremapper/gui/components/editor.py:238 +msgid "Change Mapping Name" +msgstr "變更對應名稱" + +#: data/input-remapper.glade:352 +msgid "Copy" +msgstr "複製" + +#: data/input-remapper.glade:189 +msgid "Create a new preset" +msgstr "建立新的預設組合" + +#: data/input-remapper.glade:925 +msgid "Deadzone" +msgstr "死區" + +#: data/input-remapper.glade:368 data/input-remapper.glade:620 +msgid "Delete" +msgstr "刪除" + +#: data/input-remapper.glade:624 +msgid "Delete this entry" +msgstr "刪除此項" + +#: data/input-remapper.glade:372 +msgid "Delete this preset" +msgstr "刪除此預設組合" + +#: data/input-remapper.glade:162 +msgid "Device Name" +msgstr "裝置名稱" + +#: data/input-remapper.glade:148 +msgid "Devices" +msgstr "裝置" + +#: data/input-remapper.glade:356 +msgid "Duplicate this preset" +msgstr "複製此預設組合" + +#: data/input-remapper.glade:1208 +msgid "Editor" +msgstr "編輯器" + +#: inputremapper/configs/mapping.py:77 +msgid "Empty Mapping" +msgstr "空白對應" + +#: inputremapper/gui/components/editor.py:404 +msgid "Enter your output here" +msgstr "請在此輸入輸出內容" + +#: data/input-remapper.glade:1762 +msgid "Event Specific" +msgstr "事件專屬" + +#: data/input-remapper.glade:1009 +msgid "Expo" +msgstr "指數曲線" + +#: inputremapper/gui/controller.py:648 inputremapper/gui/controller.py:673 +#, python-format +msgid "Failed to apply preset %s" +msgstr "套用預設組合 %s 失敗" + +#: data/input-remapper.glade:967 +msgid "Gain" +msgstr "增益" + +#: data/input-remapper.glade:1736 +msgid "General" +msgstr "一般" + +#: data/input-remapper.glade:1312 +msgid "Help" +msgstr "說明" + +#: data/input-remapper.glade:510 +msgid "Input" +msgstr "輸入" + +#: data/input-remapper.glade:1296 +msgid "Input Remapper" +msgstr "Input Remapper" + +#: data/input-remapper.glade:1087 +msgid "Input cutoff" +msgstr "輸入截止值" + +#: data/input-remapper.glade:760 data/input-remapper.glade:1180 +msgid "Key or Macro" +msgstr "按鍵或巨集" + +#: data/input-remapper.glade:1801 +msgid "Map this input to an Analog Axis" +msgstr "將此輸入對應至類比軸" + +#: data/input-remapper.glade:185 +msgid "New" +msgstr "新增" + +#: inputremapper/gui/components/editor.py:980 +msgid "No Axis" +msgstr "無軸" + +#: data/input-remapper.glade:721 +msgid "Output" +msgstr "輸出" + +#: data/input-remapper.glade:862 +msgid "Output axis" +msgstr "輸出軸" + +#: inputremapper/gui/controller.py:508 inputremapper/gui/controller.py:582 +msgid "Permission denied!" +msgstr "權限不足!" + +#: data/input-remapper.glade:287 +msgid "Preset Name" +msgstr "預設組合名稱" + +#: data/input-remapper.glade:272 +msgid "Presets" +msgstr "預設組合" + +#: data/input-remapper.glade:590 +msgid "Record" +msgstr "錄製" + +#: data/input-remapper.glade:594 +msgid "Record a button of your device that should be remapped" +msgstr "錄製裝置上要重新對應的按鈕" + +#: inputremapper/gui/components/editor.py:563 +msgid "Record input first" +msgstr "請先錄製輸入" + +#: inputremapper/gui/components/editor.py:65 +msgid "Record the input first" +msgstr "請先錄製輸入" + +#: data/input-remapper.glade:1708 +msgid "" +"Release all inputs which are part of the combination before the mapping is " +"injected" +msgstr "" +"在注入對應前,先釋放屬於該組合的所有輸入" + +#: data/input-remapper.glade:1711 +msgid "Release input" +msgstr "釋放輸入" + +#: data/input-remapper.glade:1683 +msgid "Release timeout" +msgstr "釋放逾時" + +#: inputremapper/gui/controller.py:208 +msgid "Remove the Analog Output Axis when specifying a macro or key output" +msgstr "指定巨集或按鍵輸出時,請移除類比輸出軸" + +#: inputremapper/gui/controller.py:199 +msgid "" +"Remove the macro or key from the macro input field when specifying an analog " +"output" +msgstr "" +"指定類比輸出時,請從巨集輸入欄位中移除巨集或按鍵" + +#: data/input-remapper.glade:1776 +msgid "Remove this input" +msgstr "移除此輸入" + +#: data/input-remapper.glade:394 +msgid "Rename" +msgstr "重新命名" + +#: data/input-remapper.glade:452 +msgid "Save the entered name" +msgstr "儲存輸入的名稱" + +#: data/input-remapper.glade:1449 +msgid "" +"See usage.md online on github for comprehensive information.\n" +"\n" +"A \"key + key + ... + key\" syntax can be used to trigger key combinations. " +"For example \"Control_L + a\".\n" +"\n" +"Writing \"disable\" as a mapping disables a key.\n" +"\n" +"Macros allow multiple characters to be written with a single key-press. " +"Information about programming them is available online on github. See macros.md and examples.md" +msgstr "" +"完整資訊請參閱 GitHub 上的線上文件 usage.md。\n" +"\n" +"可使用「按鍵 + 按鍵 + … + 按鍵」語法觸發組合鍵,例如「Control_L + a」。\n" +"\n" +"將對應值設為「disable」可停用該按鍵。\n" +"\n" +"巨集可讓單次按鍵輸入多個字元。巨集編寫相關資訊請參閱 GitHub 上的線上文件:" +"macros.mdexamples.md" + +#: data/input-remapper.glade:1590 +msgid "Shortcuts" +msgstr "快速鍵" + +#: data/input-remapper.glade:1492 +msgid "" +"Shortcuts only work while keys are not being recorded and the gui is in " +"focus." +msgstr "快速鍵僅在未錄製按鍵且應用程式視窗為焦點時才有作用。" + +#: data/input-remapper.glade:322 +msgid "Start injecting. Don't hold down any keys while the injection starts" +msgstr "開始注入。注入啟動時請勿按住任何按鍵" + +#: inputremapper/gui/controller.py:643 +msgid "Starting injection..." +msgstr "正在啟動注入……" + +#: data/input-remapper.glade:201 data/input-remapper.glade:334 +msgid "Stop" +msgstr "停止" + +#: inputremapper/gui/controller.py:710 +msgid "Stopped the injection" +msgstr "已停止注入" + +#: data/input-remapper.glade:205 data/input-remapper.glade:338 +msgid "" +"Stops the Injection for the selected device,\n" +"gives your keys their original function back\n" +"Shortcut: ctrl + del" +msgstr "" +"停止所選裝置的注入,\n" +"還原按鍵原有功能\n" +"快速鍵:ctrl + del" + +#: data/input-remapper.glade:812 +msgid "Target" +msgstr "目標" + +#: data/input-remapper.glade:1078 +msgid "" +"The Speed at which the Input is considered at maximum.\n" +"Only relevant when mapping relative inputs (e.g. mouse) to absolute outputs " +"(e.g. gamepad)" +msgstr "" +"系統將輸入視為最大值時的速度。\n" +"僅在將相對輸入(例如滑鼠)對應至絕對輸出(例如遊戲手把)時適用" + +#: inputremapper/gui/controller.py:234 +msgid "" +"The input specifies a key or macro input, but no macro or key is programmed." +msgstr "" +"輸入指定為按鍵或巨集輸入,但尚未設定任何巨集或按鍵。" + +#: inputremapper/gui/controller.py:213 +msgid "The input specifies an analog axis, but no output axis is selected." +msgstr "輸入指定為類比軸,但尚未選擇輸出軸。" + +#: data/input-remapper.glade:803 +msgid "The type of device this mapping is emulating." +msgstr "此對應所仿效的裝置類型。" + +#: data/input-remapper.glade:1831 +msgid "Trigger threshold" +msgstr "觸發臨界值" + +#: data/input-remapper.glade:743 +msgid "Type" +msgstr "類型" + +#: data/input-remapper.glade:1473 +msgid "Usage" +msgstr "使用方式" + +#: inputremapper/gui/controller.py:593 +msgid "Use \"Stop\" to stop before editing" +msgstr "請先按 [停止] 停止注入再進行編輯" + +#: data/input-remapper.glade:1805 +msgid "Use as analog" +msgstr "以類比方式使用" + +#: data/input-remapper.glade:1366 +msgid "Version unknown" +msgstr "版本未知" + +#: data/input-remapper.glade:1134 +msgid "What should be written. For example KEY_A" +msgstr "要輸出的內容,例如 KEY_A" + +#: inputremapper/gui/controller.py:761 +msgid "You are about to change the mapping to analog." +msgstr "即將把對應變更為類比模式。" + +#: data/input-remapper.glade:1161 +msgid "You can copy this text into the output" +msgstr "可將此文字複製到輸出欄位" + +#: data/input-remapper.glade:1383 +msgid "" +"You can find more information and report bugs at\n" +"https://github.com/" +"sezanzeb/input-remapper" +msgstr "" +"可在此找到更多資訊及回報錯誤\n" +"https://github.com/" +"sezanzeb/input-remapper" + +#: inputremapper/gui/controller.py:623 +msgid "You need to add mappings first" +msgstr "請先新增對應" + +#: inputremapper/gui/controller.py:358 +msgid "" +"Your system might reinterpret combinations with those after they are " +"injected, and by doing so break them." +msgstr "" +"系統可能會在注入後重新解讀含有這些鍵的組合,導致組合鍵無法正常運作。" + +#: data/input-remapper.glade:1524 +msgid "closes the application" +msgstr "關閉應用程式" + +#: data/input-remapper.glade:1512 +msgid "ctrl + del" +msgstr "ctrl + del" + +#: data/input-remapper.glade:1536 +msgid "ctrl + q" +msgstr "ctrl + q" + +#: data/input-remapper.glade:1548 +msgid "ctrl + r" +msgstr "ctrl + r" + +#: inputremapper/gui/controller.py:356 +msgid "ctrl, alt and shift may not combine properly" +msgstr "ctrl、alt 和 shift 鍵可能無法正確組合" + +#: inputremapper/gui/data_manager.py:56 +msgid "new preset" +msgstr "新預設組合" + +#: data/input-remapper.glade:531 inputremapper/gui/user_interface.py:385 +msgid "no input configured" +msgstr "尚未設定輸入" + +#: data/input-remapper.glade:1560 +msgid "refreshes the device list" +msgstr "重新整理裝置清單" + +#: data/input-remapper.glade:1572 +msgid "stops the injection" +msgstr "停止注入" + +#: data/input-remapper.glade:1403 +msgid "" +"© 2025 Sezanzeb b8x45ygc9@mozmail.com\n" +"This program comes with absolutely no warranty.\n" +"See the GNU General " +"Public License, version 3 or later for details." +msgstr "" +"© 2025 Sezanzeb b8x45ygc9@mozmail.com\n" +"本程式不附帶任何擔保。\n" +"詳細資訊請參閱 GNU General " +"Public License 第 3 版或更新版本。" diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..5baedad --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,32 @@ +[project] +name = "input-remapper" +version = "2.2.1" +description = "A tool to change the mapping of your input device buttons" +authors = [ + { name = "Sezanzeb", email = "b8x45ygc9@mozmail.com" }, +] +license = { file = "LICENSE" } +readme = "README.md" +requires-python = ">=3.10" +dependencies = [ + "evdev", + "psutil", + "dasbus", + "pycairo", + "PyGObject", + "pydantic", + "packaging", +] + +[tool.setuptools.packages.find] +where = ["."] +include = ["inputremapper*"] + +[project.urls] +Homepage = "https://github.com/sezanzeb/input-remapper" + +[tool.ruff.lint] +# Because of gi.require_version we get this a lot +ignore = ["E402"] +# Lots of unused imports in +exclude = ["install/check_dependencies.py"] diff --git a/readme/architecture.png b/readme/architecture.png new file mode 100644 index 0000000..15f8146 Binary files /dev/null and b/readme/architecture.png differ diff --git a/readme/capabilities.md b/readme/capabilities.md new file mode 100644 index 0000000..3d3207a --- /dev/null +++ b/readme/capabilities.md @@ -0,0 +1,176 @@ +# Capabilities + +A list of example capabilities for reference. + +- [Gamepads](#Gamepads) +- [Graphics Tablets](#Graphics-tablets) +- [Touchpads](#Touchpads) + +Feel free to extend this list with more devices that are not keyboards +and not mice. + +```bash +sudo python3 +``` + +```py +import evdev +evdev.InputDevice('/dev/input/event12').capabilities(verbose=True) +``` + +## Gamepads + +#### Microsoft X-Box 360 pad + +```py +{ + ('EV_SYN', 0): [('SYN_REPORT', 0), ('SYN_CONFIG', 1), ('SYN_DROPPED', 3), ('?', 21)], + ('EV_KEY', 1): [ + (['BTN_A', 'BTN_GAMEPAD', 'BTN_SOUTH'], 304), + (['BTN_B', 'BTN_EAST'], 305), (['BTN_NORTH', 'BTN_X'], 307), + (['BTN_WEST', 'BTN_Y'], 308), ('BTN_TL', 310), ('BTN_TR', 311), + ('BTN_SELECT', 314), ('BTN_START', 315), ('BTN_MODE', 316), + ('BTN_THUMBL', 317), ('BTN_THUMBR', 318) + ], + ('EV_ABS', 3): [ + (('ABS_X', 0), AbsInfo(value=1476, min=-32768, max=32767, fuzz=16, flat=128, resolution=0)), + (('ABS_Y', 1), AbsInfo(value=366, min=-32768, max=32767, fuzz=16, flat=128, resolution=0)), + (('ABS_Z', 2), AbsInfo(value=0, min=0, max=255, fuzz=0, flat=0, resolution=0)), + (('ABS_RX', 3), AbsInfo(value=-2950, min=-32768, max=32767, fuzz=16, flat=128, resolution=0)), + (('ABS_RY', 4), AbsInfo(value=1973, min=-32768, max=32767, fuzz=16, flat=128, resolution=0)), + (('ABS_RZ', 5), AbsInfo(value=0, min=0, max=255, fuzz=0, flat=0, resolution=0)), + (('ABS_HAT0X', 16), AbsInfo(value=0, min=-1, max=1, fuzz=0, flat=0, resolution=0)), + (('ABS_HAT0Y', 17), AbsInfo(value=0, min=-1, max=1, fuzz=0, flat=0, resolution=0)) + ], + ('EV_FF', 21): [ + (['FF_EFFECT_MIN', 'FF_RUMBLE'], 80), ('FF_PERIODIC', 81), + (['FF_SQUARE', 'FF_WAVEFORM_MIN'], 88), ('FF_TRIANGLE', 89), + ('FF_SINE', 90), (['FF_GAIN', 'FF_MAX_EFFECTS'], 96) + ] +} +``` + +## Graphics tablets + +#### Wacom Intuos 5 M + +Pen + +```py +{ + ('EV_SYN', 0): [ + ('SYN_REPORT', 0), ('SYN_CONFIG', 1), + ('SYN_MT_REPORT', 2), ('SYN_DROPPED', 3), ('?', 4) + ], + ('EV_KEY', 1): [ + (['BTN_LEFT', 'BTN_MOUSE'], 272), ('BTN_RIGHT', 273), ('BTN_MIDDLE', 274), + ('BTN_SIDE', 275), ('BTN_EXTRA', 276), (['BTN_DIGI', 'BTN_TOOL_PEN'], 320), + ('BTN_TOOL_RUBBER', 321), ('BTN_TOOL_BRUSH', 322), ('BTN_TOOL_PENCIL', 323), + ('BTN_TOOL_AIRBRUSH', 324), ('BTN_TOOL_MOUSE', 326), ('BTN_TOOL_LENS', 327), + ('BTN_TOUCH', 330), ('BTN_STYLUS', 331), ('BTN_STYLUS2', 332) + ], + ('EV_REL', 2): [('REL_WHEEL', 8)], + ('EV_ABS', 3): [ + (('ABS_X', 0), AbsInfo(value=0, min=0, max=44704, fuzz=4, flat=0, resolution=200)), + (('ABS_Y', 1), AbsInfo(value=0, min=0, max=27940, fuzz=4, flat=0, resolution=200)), + (('ABS_Z', 2), AbsInfo(value=0, min=-900, max=899, fuzz=0, flat=0, resolution=287)), + (('ABS_RZ', 5), AbsInfo(value=0, min=-900, max=899, fuzz=0, flat=0, resolution=287)), + (('ABS_THROTTLE', 6), AbsInfo(value=0, min=-1023, max=1023, fuzz=0, flat=0, resolution=0)), + (('ABS_WHEEL', 8), AbsInfo(value=0, min=0, max=1023, fuzz=0, flat=0, resolution=0)), + (('ABS_PRESSURE', 24), AbsInfo(value=0, min=0, max=2047, fuzz=0, flat=0, resolution=0)), + (('ABS_DISTANCE', 25), AbsInfo(value=0, min=0, max=63, fuzz=1, flat=0, resolution=0)), + (('ABS_TILT_X', 26), AbsInfo(value=0, min=-64, max=63, fuzz=1, flat=0, resolution=57)), + (('ABS_TILT_Y', 27), AbsInfo(value=0, min=-64, max=63, fuzz=1, flat=0, resolution=57)), + (('ABS_MISC', 40), AbsInfo(value=0, min=0, max=0, fuzz=0, flat=0, resolution=0)) + ], + ('EV_MSC', 4): [('MSC_SERIAL', 0)] +} +``` + +Pad + +```py +{ + ('EV_SYN', 0): [('SYN_REPORT', 0), ('SYN_CONFIG', 1), ('SYN_DROPPED', 3)], + ('EV_KEY', 1): [ + (['BTN_0', 'BTN_MISC'], 256), ('BTN_1', 257), ('BTN_2', 258), + ('BTN_3', 259), ('BTN_4', 260), ('BTN_5', 261), ('BTN_6', 262), + ('BTN_7', 263), ('BTN_8', 264), ('BTN_STYLUS', 331)], + ('EV_ABS', 3): [ + (('ABS_X', 0), AbsInfo(value=0, min=0, max=1, fuzz=0, flat=0, resolution=0)), + (('ABS_Y', 1), AbsInfo(value=0, min=0, max=1, fuzz=0, flat=0, resolution=0)), + (('ABS_WHEEL', 8), AbsInfo(value=0, min=0, max=71, fuzz=0, flat=0, resolution=0)), + (('ABS_MISC', 40), AbsInfo(value=0, min=0, max=0, fuzz=0, flat=0, resolution=0)) + ] +} +``` + +#### 10 inch PenTablet + +```py +{ + ('EV_SYN', 0): [('SYN_REPORT', 0), ('SYN_CONFIG', 1), ('SYN_DROPPED', 3), ('?', 4)], + ('EV_KEY', 1): [(['BTN_DIGI', 'BTN_TOOL_PEN'], 320), ('BTN_TOUCH', 330), ('BTN_STYLUS', 331)], + ('EV_ABS', 3): [ + (('ABS_X', 0), AbsInfo(value=41927, min=0, max=50794, fuzz=0, flat=0, resolution=200)), + (('ABS_Y', 1), AbsInfo(value=11518, min=0, max=30474, fuzz=0, flat=0, resolution=200)), + (('ABS_PRESSURE', 24), AbsInfo(value=0, min=0, max=8191, fuzz=0, flat=0, resolution=0)), + (('ABS_TILT_X', 26), AbsInfo(value=0, min=-127, max=127, fuzz=0, flat=0, resolution=0)), + (('ABS_TILT_Y', 27), AbsInfo(value=0, min=-127, max=127, fuzz=0, flat=0, resolution=0)) + ], + ('EV_MSC', 4): [('MSC_SCAN', 4)] +} +``` + +10 inch PenTablet Mouse + +```py +{ + ('EV_SYN', 0): [ + ('SYN_REPORT', 0), ('SYN_CONFIG', 1), ('SYN_MT_REPORT', 2), + ('SYN_DROPPED', 3), ('?', 4) + ], + ('EV_KEY', 1): [ + (['BTN_LEFT', 'BTN_MOUSE'], 272), ('BTN_RIGHT', 273), + ('BTN_MIDDLE', 274), ('BTN_SIDE', 275), ('BTN_EXTRA', 276), + ('BTN_TOUCH', 330) + ], + ('EV_REL', 2): [ + ('REL_X', 0), ('REL_Y', 1), ('REL_HWHEEL', 6), ('REL_WHEEL', 8), + ('REL_WHEEL_HI_RES', 11), ('REL_HWHEEL_HI_RES', 12) + ], + ('EV_ABS', 3): [ + (('ABS_X', 0), AbsInfo(value=0, min=0, max=32767, fuzz=0, flat=0, resolution=0)), + (('ABS_Y', 1), AbsInfo(value=0, min=0, max=32767, fuzz=0, flat=0, resolution=0)), + (('ABS_PRESSURE', 24), AbsInfo(value=0, min=0, max=2047, fuzz=0, flat=0, resolution=0)) + ], + ('EV_MSC', 4): [('MSC_SCAN', 4)] +} +``` + +## Touchpads + +#### ThinkPad E590 SynPS/2 Synaptics TouchPad + +```py +{ + ('EV_SYN', 0): [('SYN_REPORT', 0), ('SYN_CONFIG', 1), ('SYN_DROPPED', 3)], + ('EV_KEY', 1): [ + (['BTN_LEFT', 'BTN_MOUSE'], 272), ('BTN_TOOL_FINGER', 325), + ('BTN_TOOL_QUINTTAP', 328), ('BTN_TOUCH', 330), + ('BTN_TOOL_DOUBLETAP', 333), ('BTN_TOOL_TRIPLETAP', 334), + ('BTN_TOOL_QUADTAP', 335) + ], + ('EV_ABS', 3): [ + (('ABS_X', 0), AbsInfo(value=3111, min=1266, max=5678, fuzz=0, flat=0, resolution=0)), + (('ABS_Y', 1), AbsInfo(value=2120, min=1162, max=4694, fuzz=0, flat=0, resolution=0)), + (('ABS_PRESSURE', 24), AbsInfo(value=0, min=0, max=255, fuzz=0, flat=0, resolution=0)), + (('ABS_TOOL_WIDTH', 28), AbsInfo(value=0, min=0, max=15, fuzz=0, flat=0, resolution=0)), + (('ABS_MT_SLOT', 47), AbsInfo(value=0, min=0, max=1, fuzz=0, flat=0, resolution=0)), + (('ABS_MT_POSITION_X', 53), AbsInfo(value=0, min=1266, max=5678, fuzz=0, flat=0, resolution=0)), + (('ABS_MT_POSITION_Y', 54), AbsInfo(value=0, min=1162, max=4694, fuzz=0, flat=0, resolution=0)), + (('ABS_MT_TRACKING_ID', 57), AbsInfo(value=0, min=0, max=65535, fuzz=0, flat=0, resolution=0)), + (('ABS_MT_PRESSURE', 58), AbsInfo(value=0, min=0, max=255, fuzz=0, flat=0, resolution=0)) + ] +} +``` diff --git a/readme/coverage.svg b/readme/coverage.svg new file mode 100644 index 0000000..4421b8a --- /dev/null +++ b/readme/coverage.svg @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + coverage + coverage + + + 88% + 88% + + diff --git a/readme/development.md b/readme/development.md new file mode 100644 index 0000000..d3ecb3b --- /dev/null +++ b/readme/development.md @@ -0,0 +1,156 @@ +Development +=========== + +Contributions are very welcome, I will gladly review and discuss any merge requests. +If you have questions about the code and architecture, feel free to +[open an issue](https://github.com/sezanzeb/input-remapper/issues). +This file should give an overview about some internals of input-remapper. + +All pull requests will at some point require unittests (see below for more info). +The code coverage may only be improved, not decreased. It also has to be mostly +compliant with pylint. + +Running +------- + +To quickly restart input-remapper without pkexec prompts, you can use + +```bash +sudo pkill -f input-remapper && sudo input-remapper-reader-service -d & sudo input-remapper-service -d & input-remapper-gtk -d +``` + +Linting +------- + +```bash +mypy inputremapper # find typing issues +black . # auto-format all code in-place +pip install pylint-pydantic --user # https://github.com/fcfangcc/pylint-pydantic +pylint inputremapper # get a code quality rating from pylint +``` + +Pylint gives lots of great advice on how to write better python code and even detects +errors. Mypy checks for typing errors. Use black to format it. + +Automated tests +--------------- + +You should be able to use your IDEs built in python unittest features to run tests. +But you can also run them from your console: + +```bash +pip install psutil # https://github.com/giampaolo/psutil +sudo pkill -f input-remapper +python3 -m unittest discover -s ./tests/ +python3 -m unittest tests/unit/test_daemon.py +python3 -m unittest tests.unit.test_ipc.TestPipe -k "test_pipe" -f +# See `python -m unittest -h` for more. +``` + +Don't use your computer during integration tests to avoid interacting with the gui, +which might make tests fail. + +To read events for manual testing, `evtest` is very helpful. +Add `-d` to `input-remapper-gtk` to get debug output. + +Writing Tests +------------- + +Tests are in https://github.com/sezanzeb/input-remapper/tree/main/tests + +Make sure to use the `@test_setup` decorator. Look for other tests that did something +vaguely similar for inspiration. For example, copy one of the macro test files +and modify it if you write a new macro. + +If you have difficulty running your tests locally, github will run them for you when +you create a new pull request. + +Scripts +------- + +To automate some of the development tasks, you can use the +[setup.sh](/scripts/setup.sh) script. The script avoids using `pip` for installation. +Instead, it uses either your local `python3` in your virtual env, or using +`/usr/bin/python3` explicitly. For more information run + +``` +scripts/setup.sh help +``` + +Advice +------ + +Do not use GTKs `foreach` methods, because when the function fails it just freezes up +completely. Use `get_children()` and iterate over it with regular python `for` loops. +Use `gtk_iteration()` in tests when interacting with GTK methods to trigger events to +be emitted. + +Do not do `from evdev import list_devices; list_devices()`, and instead do +`import evdev; evdev.list_devices()`. The first variant cannot be easily patched in +tests (there are ways, but as far as I can tell it has to be configured individually +for each source-file/module). The second option allows for patches to be defiend in +one central places. Importing `KEY_*`, `BTN_*`, etc. constants via `from evdev` is +fine. + +Releasing +--------- + +ssh/login into a debian/ubuntu environment + +```bash +scripts/build-deb.sh +``` + +This will generate `dist/input-remapper-2.2.1.deb` + +Badges +------ + +```bash +# https://github.com/nedbat/coveragepy https://github.com/giampaolo/psutil +pip install coverage anybadge pylint psutil +sudo pkill -f input-remapper +# Make sure input-remapper is uninstalled, then install it editable (without sudo +# should be fine), so that the path for the coverage collection is correct. +# Use `find /usr/ -iname "*inputremapper*"` to check if it is uninstalled. +pip install -e . +./scripts/badges.sh +``` + +New badges, if needed, will be created in `readme/` and they just need to be commited. + +Translations +------------ + +To regenerate the `po/input-remapper.pot` file, run + +```bash +xgettext -k --keyword=translatable --sort-output -o po/input-remapper.pot data/input-remapper.glade +xgettext --keyword=_ -L Python --sort-output -jo po/input-remapper.pot inputremapper/configs/mapping.py inputremapper/gui/*.py inputremapper/gui/components/*.py +``` + +This is the template file that you can copy to fill in the translations. Also create a +corresponding symlink, like `ln -s it_IT.po it.po`, because some environments expect +different names, apparently. See https://github.com/sezanzeb/input-remapper/tree/main/po +for examples. + +Architecture +------------ + +There is a miro board describing input-remappers architecture: + +https://miro.com/app/board/uXjVPLa8ilM=/?share_link_id=272180986764 + +![architecture.png](./architecture.png) + +Resources +--------- + +- [Guidelines for device capabilities](https://www.kernel.org/doc/Documentation/input/event-codes.txt) +- [PyGObject API Reference](https://lazka.github.io/pgi-docs/) +- [python-evdev](https://python-evdev.readthedocs.io/en/stable/) +- [Python Unix Domain Sockets](https://pymotw.com/2/socket/uds.html) +- [GNOME HIG](https://developer.gnome.org/hig/stable/) +- [GtkSource Example](https://github.com/wolfthefallen/py-GtkSourceCompletion-example) +- [linux/input-event-codes.h](https://github.com/torvalds/linux/blob/master/include/uapi/linux/input-event-codes.h) +- [Screenshot Guidelines](https://www.freedesktop.org/software/appstream/docs/chap-Quickstart.html) diff --git a/readme/examples.md b/readme/examples.md new file mode 100644 index 0000000..3edea37 --- /dev/null +++ b/readme/examples.md @@ -0,0 +1,190 @@ +# Examples + +Examples for particular devices and/or use cases: + +## Event Names + +- Alphanumeric `a` to `z` and `0` to `9` +- Modifiers `Alt_L` `Control_L` `Control_R` `Shift_L` `Shift_R` +- Mouse buttons `BTN_LEFT` `BTN_RIGHT` `BTN_MIDDLE` `BTN_SIDE` ... +- Multimedia keys `KEY_NEXTSONG` `KEY_PLAYPAUSE` `XF86AudioMicMute` ... + +Mouse movements have to be performed by macros. See below. + +## Short Macro Examples + +- `key(BTN_LEFT)` a single mouse-click +- `key(1).key(2)` 1, 2 +- `wheel(down, 10)` `wheel(up, 10)` Scroll while the input is pressed. +- `mouse(left, 5)` `mouse(right, 2)` `mouse(up, 1)` `mouse(down, 3)` Move the cursor while the input is pressed. +- `repeat(3, key(a).w(500))` a, a, a with 500ms pause +- `modify(Control_L, key(a).key(x))` CTRL + a, CTRL + x +- `key(1).hold(key(2)).key(3)` writes 1 2 2 ... 2 2 3 while the key is pressed +- `event(EV_REL, REL_X, 10)` moves the mouse cursor 10px to the right +- `mouse(right, 4)` which keeps moving the mouse while pressed +- `wheel(down, 1)` keeps scrolling down while held +- `set(foo, 1)` set ["foo"](https://en.wikipedia.org/wiki/Metasyntactic_variable) to 1 +- `if_eq($foo, 1, key(x), key(y))` if "foo" is 1, write x, otherwise y +- `hold()` does nothing as long as your key is held down +- `hold_keys(a)` holds down "a" as long as the key is pressed, just like a regular non-macro mapping +- `if_tap(key(a), key(b))` writes a if the key is tapped, otherwise b +- `if_tap(key(a), key(b), 1000)` writes a if the key is released within a second, otherwise b +- `if_single(key(a), key(b))` writes b if another key is pressed, or a if the key is released + and no other key was pressed in the meantime. +- `if_tap(if_tap(key(a), key(b)), key(c))` "a" if tapped twice, "b" if tapped once and "c" if + held down long enough +- `key_up(a).wait(1000).key_down(a)` keeps a pressed for one second +- `hold_keys(Control_L, a)` holds down those two keys +- `key(BTN_LEFT).wait(100).key(BTN_LEFT)` a double-click + +## Double Tap + +``` +if_tap( + if_tap( + key(a), + key(c) + ), + key(b) +) +``` + +- press twice: a +- press and hold: b +- press and release: c + +## Combinations Spanning Multiple Devices + +For regular combinations on only single devices it is not required to +configure macros. See [readme/usage.md](usage.md#combinations). + +**Keyboard** `space` `set(foo, 1).hold_keys(space).set(foo, 0)` + +**Mouse** `middle` `if_eq($foo, 1, hold_keys(a), hold_keys(BTN_MIDDLE))` + +Apply both presets. If you press space on your keyboard, it will write a +space exactly like it used to. If you hold down space and press the middle +button of your mouse, it will write "a" instead. If you just press the +middle button of your mouse it behaves like a regular middle mouse button. + +**Explanation** + +`hold_keys(space)` makes your key work exactly like if it was mapped to "space". +It will inject a key-down event if you press it, does nothing as long you +hold your key down, and injects a key-up event after releasing. +`set(foo, 1).set(foo, 0)` sets "foo" to 1 and then sets "foo" to 0. +`set` and `if_eq` work on shared memory, so all injections will see your +variables. Combine both to get a key that works like a normal key, but that also +works as a modifier for other keys of other devices. `if_eq($foo, 1, ..., ...)` +runs the first param if foo is 1, or the second one if foo is not 1. + + +## Scroll and Click on a Keyboard + +Seldom used PrintScreen, ScrollLock and Pause keys on keyboards with TKL (ten key +less) layout are easily accessible by the right hand thanks to the missing +numeric block, so they can be mapped to mouse scroll and click events: + +- Print: `wheel(up, 1)` +- Pause: `wheel(down, 1)` +- Scroll Lock: `BTN_LEFT` +- Menu: `BTN_RIGHT` +- F12: `KEY_LEFTCTRL + w` + +In contrast to libinput's `ScrollMethod` `button` which requires the scroll +button to belong to the same (mouse) device, clicking and scrolling events mapped +to a keyboard key can fully cooperate with events from a real mouse, e.g. +drag'n'drop by holding a (mapped) keyboard key and moving the cursor by mouse. + +Mapping the scrolling to a keyboard key is also useful for trackballs without +a scroll ring. + +In contrast to a real scroll wheel, holding a key which has mouse wheel event +mapped produces linear auto-repeat, without any acceleration. Using a PageDown +key for fast scrolling requires only a small adjustment of the right hand +position. + +## Scroll on a 3-Button Mouse + +Cheap 3-button mouse without a scroll wheel can scroll using the middle button: + +- Button MIDDLE: `wheel(down, 1)` + +## Click on Lower Buttons of Trackball + +Trackball with 4 buttons (e.g. Kensington Wireless Expert Mouse) with lower 2 +buttons by default assigned to middle and side button can be remapped to provide +left and right click on both the upper and lower pairs of buttons to avoid +readjusting a hand after moving the cursor down: + +- Button MIDDLE: BTN_LEFT +- Button SIDE: BTN_RIGHT + +## Scroll on Foot Pedals + +While Kinesis Savant Elite 2 foot pedals can be programmed to emit key press or +mouse click events, they cannot emit scroll events themselves. Using the pedals +for scrolling while standing at a standing desk is possible thanks to remapping: + +- Button LEFT: `wheel(up, 1)` +- Button RIGHT: `wheel(down, 1)` + +## Gamepads + +Joystick movements will be translated to mouse movements, while the second +joystick acts as a mouse wheel. You can swap this in the user interface. +All buttons, triggers and D-Pads can be mapped to keycodes and macros. + +The D-Pad can be mapped to W, A, S, D for example, to run around in games, +while the joystick turns the view (depending on the game). + +Tested with the XBOX 360 Gamepad. On Ubuntu, gamepads worked better in +Wayland than with X11. + +## Sequence of Keys with Modifiers + +Alt+TAB, Enter, Alt+TAB: + +``` +modify(Alt_L, key(tab)).wait(250). +key(KP_Enter).key(key_UP).wait(150). +modify(Alt_L, key(tab)) +``` + +## Home Row Mods + +See https://precondition.github.io/home-row-mods#home-row-mods-order + +- a: `mod_tap(a, Super_L)` +- s: `mod_tap(s, Alt_L)` +- d: `mod_tap(d, Shift_L)` +- f: `mod_tap(f, Control_L)` + +## Emitting Unavailable Symbols + +For example Japanese letters without overwriting any existing key +of your system-layout. Only works in X11. + +``` +xmodmap -pke > keyboard_layout +mousepad keyboard_layout & +``` + +Find a code that is not mapped to anything, for example `keycode 93 = `, +and map it like `keycode 93 = kana_YA`. See [this gist](https://gist.github.com/sezanzeb/e29bae637b8a799ccf2490b8537487df) +for available symbols. + +``` +xmodmap keyboard_layout +input-remapper-gtk +``` + +"kana_YA" should be in the dropdown of available symbols now. Map it +to a key and press apply. Now run + +``` +xmodmap keyboard_layout +``` + +again for the injection to use that xmodmap as well. It should be possible +to write "ヤ" now when pressing the key. diff --git a/readme/macros.md b/readme/macros.md new file mode 100644 index 0000000..4ed488b --- /dev/null +++ b/readme/macros.md @@ -0,0 +1,431 @@ +# Macros + +input-remapper comes with an optional custom macro language with support for cross-device +variables, conditions and named parameters. + +Syntax errors are shown in the UI on save. Each `key` function adds a short delay of 10ms +between key-down, key-up and at the end. See [usage.md](usage.md#configuration-files) +for more info. + +Macros are written into the same text field, that would usually contain the output symbol. + +Bear in mind that anti-cheat software might detect macros in games. + +### key + +> Acts like a pressed key. All names that are available in regular mappings can be used +> here. +> +> ```ts +> key(symbol: str) +> ``` +> +> Examples: +> +> ```ts +> key(symbol=KEY_A) +> key(b).key(space) +> ``` + +### key_down and key_up + +> Inject the press/down/1 and release/up/0 events individually with those macros. +> +> ```ts +> key_down(symbol: str) +> key_up(symbol: str) +> ``` +> +> Examples: +> +> ```ts +> key_down(KEY_A) +> key_up(KEY_B) +> ``` + +### wait + +> Waits in milliseconds before continuing the macro. If the max_time argument is +> provided, it will randomize the time between time and max_time. +> +> ```ts +> wait(time: int, max_time: int | None) +> ``` +> +> Examples: +> +> ```ts +> wait(time=100) +> wait(500) +> wait(10, 1000) +> ``` + +### repeat + +> Repeats the execution of the second parameter a few times +> +> ```ts +> repeat(repeats: int, macro: Macro) +> ``` +> +> Examples: +> +> ```ts +> repeat(1, key(KEY_A)) +> repeat(repeats=2, key(space)) +> ``` + +### toggle + +> Repeats the execution of the parameter until activated again +> +> ```ts +> toggle(macro: Macro) +> ``` +> +> Examples: +> +> ```ts +> toggle(key(KEY_A)) +> ``` + +### modify + +> Holds a modifier while executing the second parameter +> +> ```ts +> modify(modifier: str, macro: Macro) +> ``` +> +> Examples: +> +> ```ts +> modify(Control_L, key(a).key(x)) +> ``` + +### mod_tap + +> If an input is held down long enough, then it turns into a modifier for all keys +> that came and come afterwards. +> +> You can use this to create home row mods for example. +> +> Behaves similar to the Mod-Tap feature of QMK. +> +> ```ts +> mod_tap( +> default: str, +> modifier: str, +> tapping_term: int +> ) +> ``` +> +> Examples: +> +> ```ts +> mod_tap(a, Shift_L) +> mod_tap(s, Control_L, 300) +> ``` + +### hold_keys + +> Holds down all the provided symbols like a combination, and releases them when the +> actual key on your keyboard is released. +> +> An arbitrary number of symbols can be provided. +> +> When provided with a single key, it will behave just like a regular keyboard key. +> +> ```ts +> hold_keys(*symbols: str) +> ``` +> +> Examples: +> +> ```ts +> hold_keys(KEY_B) +> hold_keys(KEY_LEFTCTRL, KEY_A) +> hold_keys(Control_L, Alt_L, Delete) +> set(foo, KEY_A).hold_keys($foo) +> ``` + +### hold + +> Runs the child macro repeatedly as long as the input is pressed. +> +> ```ts +> hold(macro: Macro) +> ``` +> +> Examples: +> +> ```ts +> hold(key(space)) +> ``` + +### mouse + +> Moves the mouse cursor +> +> If the fractional `acceleration` value is provided then the cursor will accelerate +> from zero to a maximum speed of `speed`. +> +> ```ts +> mouse( +> direction: up | down | left | right, +> speed: int, +> acceleration: int | float +> ) +> ``` +> +> Examples: +> +> ```ts +> mouse(up, 1) +> mouse(left, 2) +> mouse(down, 10, 0.05) +> ``` + +### mouse_xy + +> Moves the mouse cursor in both x and y direction. +> +> If the fractional `acceleration` value is provided then the cursor will accelerate +> from zero to the maximum specified x and y speeds. +> +> ```ts +> mouse( +> x: int | float, +> y: int | float, +> acceleration: int | float +> ) +> ``` +> +> Examples: +> +> ```ts +> mouse_xy(x=10, y=20) +> mouse_xy(-5, -1, 0.01) +> mouse_xy(x=10, acceleration=0.05) +> ``` + +### wheel + +> Injects scroll wheel events +> +> ```ts +> wheel( +> direction: up | down | left | right, +> speed: int +> ) +> ``` +> +> Examples: +> +> ```ts +> mouse(up, 10) +> mouse(left, 20) +> ``` + +### event + +> Writes an event. Examples for `type`, `code` and `value` can be found via the +> `sudo evtest` command. Also check out [input-event-codes.h](https://github.com/torvalds/linux/blob/master/include/uapi/linux/input-event-codes.h). +> `EV_KEY` for keys, `EV_REL` for mouse movements, `EV_ABS` for gamepad events among +> others. +> +> ```ts +> event( +> type: str | int, +> code: str | int, +> value: int +> ) +> ``` +> +> Examples: +> +> ```ts +> event(EV_KEY, KEY_A, 1) +> event(EV_REL, REL_X, -10) +> event(2, 8, 1) +> ``` + +### set + +> Set a variable to a value. This variable and its value is available in all injection +> processes. +> +> Variables can be used in function arguments by adding a `$` in front of their name: +> `repeat($foo, key(KEY_A))` +> +> Their values are available for other injections/devices as well, so you can make them +> interact with each other. In other words, using `set` on a keyboard and `if_eq` with +> the previously used variable name on a mouse will work. +> +> ```ts +> set(variable: str, value: str | int) +> ``` +> +> Examples: +> +> ```ts +> set(foo, 1) +> set(foo, "qux") +> ``` + +### add + +> Adds a number fo a variable. +> +> ```ts +> add(variable: str, value: int) +> ``` +> +> Examples: +> +> ```ts +> set(a, 1).add(a, 2).if_eq($a, 3, key(x), key(y)) +> ``` + +### if_eq + +> Compare two values and run different macros depending on the outcome. +> +> ```ts +> if_eq( +> value_1: str | int, +> value_2: str | int, +> then: Macro | None, +> else: Macro | None +> ) +> ``` +> +> Examples: +> +> ```ts +> set(a, 1).if_eq($a, 1, key(KEY_A), key(KEY_B)) +> set(a, 1).set(b, 1).if_eq($a, $b, else=key(KEY_B).key(KEY_C)) +> set(a, "foo").if_eq("foo", $a, key(KEY_A)) +> set(a, 1).if_eq($a, 1, None, key(KEY_B)) +> ``` + +### if_capslock + +> Run the first macro if your capslock is on, otherwise the second. +> +> ```ts +> if_capslock( +> then: Macro | None, +> else: Macro | None +> ) +> ``` +> +> Examples: +> +> ```ts +> if_capslock( +> then=hold_keys(KEY_3), +> else=hold_keys(KEY_4) +> ) +> ``` + +### if_numlock + +> Run the first macro if your numlock is on, otherwise the second. +> +> ```ts +> if_numlock( +> then: Macro | None, +> else: Macro | None +> ) +> ``` +> +> Examples: +> +> ```ts +> if_numlock(hold_keys(KEY_3), hold_keys(KEY_4)) +> ``` + +### if_tap + +> If the key is tapped quickly, run the `then` macro, otherwise the +> second. The third param is the optional time in milliseconds and defaults to +> 300ms +> +> ```ts +> if_tap( +> then: Macro | None, +> else: Macro | None, +> timeout: int +> ) +> ``` +> +> Examples: +> +> ```ts +> if_tap(key(KEY_A), key(KEY_B), timeout=500) +> if_tap(then=key(KEY_A), else=key(KEY_B)) +> ``` + +### if_single + +> If the key that is mapped to the macro is pressed and released, run the `then` macro. +> +> If another key is pressed while the triggering key is held down, run the `else` macro. +> +> If a timeout number is provided, the macro will run `else` if no event arrives for +> more than the configured number in milliseconds. +> +> ```ts +> if_single( +> then: Macro | None, +> else: Macro | None, +> timeout: int | None +> ) +> ``` +> +> Examples: +> +> ```ts +> if_single(key(KEY_A), key(KEY_B)) +> if_single(None, key(KEY_B)) +> if_single(then=key(KEY_A), else=key(KEY_B)) +> if_single(key(KEY_A), key(KEY_B), timeout=1000) +> ``` + +### parallel + +> Run all provided macros in parallel. +> +> ```ts +> parallel(*macros: Macro) +> ``` +> +> Examples: +> +> ```ts +> parallel( +> mouse(up, 10), +> hold_keys(a), +> wheel(down, 10) +> ) +> ``` + +## Syntax + +Multiple functions are chained using `.`. + +Unlike other programming languages, `qux(bar())` would not run `bar` and then +`qux`. Instead, `cux` can decide to run `bar` during runtime depending on various +other factors. Like `repeat` is running its parameter multiple times. + +Whitespaces, newlines and tabs don't have any meaning and are removed when the macro +gets compiled, unless you wrap your strings in "quotes". + +Similar to python, arguments can be either positional or keyword arguments. +`key(symbol=KEY_A)` is the same as `key(KEY_A)`. + +Using `$` resolves a variable during runtime. For example `set(a, $1)` and +`if_eq($a, 1, key(KEY_A), key(KEY_B))`. + +Comments can be written with '#', like `key(KEY_A) # write an "a"` diff --git a/readme/plus.png b/readme/plus.png new file mode 100644 index 0000000..bc0ca48 Binary files /dev/null and b/readme/plus.png differ diff --git a/readme/pylint.svg b/readme/pylint.svg new file mode 100644 index 0000000..48c01e8 --- /dev/null +++ b/readme/pylint.svg @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + pylint + pylint + + + 9.21 + 9.21 + + diff --git a/readme/screenshot.png b/readme/screenshot.png new file mode 100644 index 0000000..a8a75da Binary files /dev/null and b/readme/screenshot.png differ diff --git a/readme/screenshot_2.png b/readme/screenshot_2.png new file mode 100644 index 0000000..0bed2c0 Binary files /dev/null and b/readme/screenshot_2.png differ diff --git a/readme/usage.md b/readme/usage.md new file mode 100644 index 0000000..1ac492d --- /dev/null +++ b/readme/usage.md @@ -0,0 +1,355 @@ +# Usage + +Look into your applications menu and search for **Input Remapper** to open the UI. +You should be prompted for your sudo password as special permissions are needed to read +events from `/dev/input/` files. You can also start it via `input-remapper-gtk`. + +First, select your device (like your keyboard) on the first page, then create a new +preset on the second page, and add a mapping. Then you can already edit your inputs, +as shown in the screenshots below. + +

+ + +

+ +In the "Output" textbox on the right, type the key to which you would like to map this input. +More information about the possible mappings can be found in +[examples.md](./examples.md) and [below](#key-names). You can also write your macro +into the "Output" textbox. If you hit enter, it will switch to a multiline-editor with +line-numbers. + +Changes are saved automatically. Press the "Apply" button to activate (inject) the +mapping you created. + +If you later want to modify the Input of your mapping you need to use the +"Stop" button, so that the application can read your original input. +It would otherwise be invisible since the daemon maps it independently of the GUI. + +## Troubleshooting + +If your key is hanging due to a macro, unplug your device, and then plug it back in. +This should reset the key. + +If stuff doesn't work, check the output of `input-remapper-gtk -d` and feel free +to [open up an issue here](https://github.com/sezanzeb/input-remapper/issues/new). +Make sure to not post any debug logs that were generated while you entered +private information with your device. Debug logs are quite verbose. + +If input-remapper or your presets prevents your input device from working +at all due to autoload, please try to unplug and plug it in twice. +No injection should be running anymore. + +## Combinations + +You can use combinations of different inputs to trigger a mapping: While you record +the input (`Record` - Button) press multiple keys and/or move axis at once. +The mapping will be triggered as soon as all the recorded inputs are pressed. + +If you use an axis an input you can modify the threshold at which the mapping is +activated in the advanced input configuration, which can be opened by clicking +on the `Advanced` button. + +A mapping with an input combination is only injected once all combination keys +are pressed. This means all the input keys you press before the combination is complete +will be injected unmodified. In some cases this can be desirable, in others not. + +*Option 1*: In the advanced input configuration there is the `Release Input` toggle. +This will release all inputs which are part of the combination before the mapping is +injected. Consider a mapping `Shift+1 -> a` this will inject a lowercase `a` if the +toggle is on and an uppercase `A` if it is off. The exact behaviour if the toggle is off +is dependent on keys (are modifiers involved?), the order in which they are pressed and +on your environment (X11/Wayland). By default the toggle is on. + +*Option 2*: Disable the keys that are part of the combination individually. So with +a mapping of `Super+1 -> a`, you could additionally map `Super` to `disable`. Now +`Super` won't do anything anymore, and therefore pressing the combination won't have +any side effects anymore. + +## Writing Combinations + +You can write `Control_L + a` as mapping, which will inject those two +keycodes into your system on a single key press. An arbitrary number of +names can be chained using ` + `. + +

+ +

+ +## UI Shortcuts + +- `ctrl` + `del` stops the injection (only works while the gui is in focus) +- `ctrl` + `q` closes the application +- `ctrl` + `r` refreshes the device list + +## Key Names + +Check the autocompletion of the GUI for possible values. You can also +obtain a complete list of possiblities using `input-remapper-control --symbol-names`. + +Input-remapper only recognizes symbol names, but not the symbols themselves. So for +example, input-remapper might (depending on the system layout) know what a `minus` is, but +it doesn't know `-`. + +Key names that start with `KEY_` are keyboard layout independent constants that might +not result in the expected output. For example using `KEY_Y` would result in "z" +if the layout of the environment is set to german. Using `y` on the other hand would +correctly result in "y" to be written. + +It is also possible to map a key to `disable` to stop it from doing anything. + +## Limitations + +**If your fingers can't type it on your keyboard, input-remapper can't inject it.** + +The available symbols depend on the environments keyboard layout, and only those that +don't require a combination to be pressed can be used without workarounds (so most +special characters need some extra steps to use them). Furthermore, if your configured +keyboard layout doesn't support the special character at all (not even via a +combination), then it also won't be possible for input-remapper to map that character at +all. + +For example, mapping a key to an exclamation mark is not possible if the keyboard +layout is set to german. However, it is possible to mimic the combination that would +be required to write it, by writing `Shift_L + 1` into the mapping. + +This is because input-remapper creates a new virtual keyboard and injects numeric keycodes, +and it won't be able to inject anything a usb keyboard wouldn't been able to. This has +the benefit of being compatible to all display servers, but means the environment will +ultimately decide which character to write. + +## Analog Axis + +It is possible to map analog inputs to analog outputs. E.g. use a gamepad as a mouse. +For this you need to create a mapping and record the input axis. Then click on +`Advanced` and select `Use as Analog`. Make sure to select a target +which supports analog axis and switch to the `Analog Axis` tab. +There you can select an output axis and use the different sliders to configure the +sensitivity, non-linearity and other parameters as you like. + +It is also possible to use an analog output with an input combination. +This will result in the analog axis to be only injected if the combination is pressed + +## Wheels + +When mapping wheels, you need to be aware that there are both `WHEEL` and `WHEEL_HI_RES` +events. This can cause your wheel to scroll, despite being mapped to something. +By fiddling around with the advanced settings when editing one of your inputs, you can +map the "Hi Res" inputs to `disable`. + +# External tools + +Repositories listed here are made by input-remappers users. Feel free to extend. Beware, +that I can't review their code, so use them at your own risk (just like everything). + +- input-remapper-xautopresets: https://github.com/DreadPirateLynx/input-remapper-xautopresets + +# Advanced + +## Configuration Files + +If you don't have a graphical user interface, you'll need to edit the +configuration files. All configuration files need to be valid json files, otherwise the +parser refuses to work. + +The default configuration is stored at `~/.config/input-remapper-2/config.json`, +which doesn't include any mappings, but rather other parameters that +are interesting for injections. The config might look something like this: + +```json +{ + "version": "2.2.1", + "autoload": { + "USB Optical Mouse": "preset name" + } +} +``` + +`preset name` refers to `~/.config/input-remapper/presets/device name/preset name.json`. +The device name can be found with `sudo input-remapper-control --list-devices`. + +### Preset + +The preset files are a collection of mappings. +Here is an example configuration for preset "a" for the "gamepad" device: +`~/.config/input-remapper-2/presets/gamepad/a.json` + +```json +[ + { + "input_combination": [ + {"type": 1, "code": 307} + ], + "target_uinput": "keyboard", + "output_symbol": "key(2).key(3)", + "macro_key_sleep_ms": 100 + }, + { + "input_combination": [ + {"type": 1, "code": 315, "origin_hash": "07f543a6d19f00769e7300c2b1033b7a"}, + {"type": 3, "code": 1, "analog_threshold": 10} + ], + "target_uinput": "keyboard", + "output_symbol": "1" + }, + { + "input_combination": [ + {"type": 3, "code": 1} + ], + "target_uinput": "mouse", + "output_type": 2, + "output_code": 1, + "gain": 0.5 + } +] +``` + +This preset consists of three mappings. + + * The first maps the key event with code 307 to a macro and sets the time between + injected events of macros to 100 ms. The macro injects its events to the virtual keyboard. + * The second mapping is a combination of a key event with the code 315 and a + analog input of the axis 1 (y-Axis). + * The third maps the y-Axis of a joystick to the y-Axis on the virtual mouse. + +### Mapping + +As shown above, the mapping is part of the preset. It consists of the input-combination, +which is a list of input-configurations and the mapping parameters. + +``` +{ + "input_combination": [ + , + + ] + : , + : +} +``` + +#### Input Combination and Configuration + +The input-combination is a list of one or more input configurations. To trigger a +mapping, all input configurations must trigger. + +A input configuration is a dictionary with some or all of the following parameters: + +| Parameter | Default | Type | Description | +|------------------|---------|------------------------|---------------------------------------------------------------------| +| type | - | int | Input Event Type | +| code | - | int | Input Evnet Code | +| origin_hash | None | hex (string formatted) | A unique identifier for the device which emits the described event. | +| analog_threshold | None | int | The threshold above which a input axis triggers the mapping. | + +##### type, code + +The `type` and `code` parameters are always needed. Use the program `evtest` to find +Available types and codes. See also the [evdev documentation](https://www.kernel.org/doc/html/latest/input/event-codes.html#input-event-codes) + +##### origin_hash + +The origin_hash is an internally computed hash. It is used associate the input with a +specific `/dev/input/eventXX` device. This is useful when a single pyhsical device +creates multiple `/dev/input/eventXX` devices wihth similar capabilities. +See also: [Issue#435](https://github.com/sezanzeb/input-remapper/issues/435) + +##### analog_threshold + +Setting the `analog_threshold` to zero or omitting it means that the input will be +mapped to an axis. There can only be one axis input with a threshold of 0 in a mapping. +If the `type` is 1 (EV_KEY) the `analog_threshold` has no effect. + +The `analog_threshold` is needend when the input is a analog axis which should be +treated as a key input. If the event type is `3 (EV_ABS)` (as in: map a joystick axis to +a key or macro) the threshold can be between `-100 [%]` and `100 [%]`. The mapping will +be triggered once the joystick reaches the position described by the value. + +If the event type is `2 (EV_REL)` (as in: map a relative axis (e.g. mouse wheel) to a +key or macro) the threshold can be anything. The mapping will be triggered once the +speed and direction of the axis is higher than described by the threshold. + +#### Mapping Parameters + +The following table contains all possible parameters and their default values: + +| Parameter | Default | Type | Description | +|--------------------------|---------|-----------------|-------------------------------------------------------------------------------------------------------------------------| +| input_combination | | list | see [above](#input-combination-and-configuration) | +| target_uinput | | string | The UInput to which the mapped event will be sent | +| output_symbol | | string | The symbol or macro string if applicable | +| output_type | | int | The event type of the mapped event | +| output_code | | int | The event code of the mapped event | +| release_combination_keys | true | bool | If release events will be sent to the forwarded device as soon as a combination triggers see also #229 | +| **Macro settings** | | | | +| macro_key_sleep_ms | 0 | positive int | | +| **Axis settings** | | | | +| deadzone | 0.1 | float ∈ (0, 1) | The deadzone of the input axis | +| gain | 1.0 | float | Scale factor when mapping an axis to an axis | +| expo | 0 | float ∈ (-1, 1) | Non liniarity factor see also [GeoGebra](https://www.geogebra.org/calculator/mkdqueky) | +| **EV_REL output** | | | | +| rel_rate | 60 | positive int | The frequency `[Hz]` at which `EV_REL` events get generated (also effects mouse macro) | +| **EV_REL as input** | | | | +| rel_to_abs_input_cutoff | 2 | positive float | The value relative to a predefined base-speed, at which `EV_REL` input (cursor and wheel) is considered at its maximum. | +| release_timeout | 0.05 | positive float | The time `[s]` until a relative axis is considered stationary if no new events arrive | + + +## CLI + +**input-remapper-control** + +`--command` requires the service to be running. You can start it via +`systemctl start input-remapper` or `sudo input-remapper-service` if it isn't already +running (or without sudo if your user has the appropriate permissions). + +Examples: + +| Description | Command | +|---------------------------------------------------------------------------------------------------------|-------------------------------------------------------------------------------------------| +| Load all configured presets for all devices | `input-remapper-control --command autoload` | +| If you are running as root user, provide information about the whereabouts of the input-remapper config | `input-remapper-control --command autoload --config-dir "~/.config/input-remapper/"` | +| List available device names for the `--device` parameter | `sudo input-remapper-control --list-devices` | +| Stop injecting | `input-remapper-control --command stop --device "Razer Razer Naga Trinity"` | +| Load `~/.config/input-remapper/presets/Razer Razer Naga Trinity/a.json` | `input-remapper-control --command start --device "Razer Razer Naga Trinity" --preset "a"` | +| Loads the configured preset for whatever device is using this /dev path | `/bin/input-remapper-control --command autoload --device /dev/input/event5` | +| Make the input-remapper-service process exit | `/bin/input-remapper-control --command quit` | + +**systemctl** + +Stopping the service will stop all ongoing injections + +```bash +sudo systemctl stop input-remapper +sudo systemctl start input-remapper +systemctl status input-remapper +``` + +## Testing your Installation + +The following commands can be used to make sure it works: + +```bash +sudo input-remapper-service & +input-remapper-control --command hello +``` + +should print `Daemon answered with "hello"`. And + +```bash +sudo input-remapper-control --list-devices +``` + +should print `Found "...", ...`. If anything looks wrong, feel free to [create +an issue](https://github.com/sezanzeb/input-remapper/issues/new). + +## Migrating beta configs to version 2 + +By default, Input Remapper will not migrate configurations from the beta. +If you want to use those you will need to copy them manually. + +```bash +rm ~/.config/input-remapper-2 -r +cp ~/.config/input-remapper/beta_1.6.0-beta ~/.config/input-remapper-2 -r +``` + +Then start input-remapper diff --git a/readme/usage_1.png b/readme/usage_1.png new file mode 100644 index 0000000..cceb4e8 Binary files /dev/null and b/readme/usage_1.png differ diff --git a/readme/usage_2.png b/readme/usage_2.png new file mode 100644 index 0000000..4a0605e Binary files /dev/null and b/readme/usage_2.png differ diff --git a/scripts/badges.sh b/scripts/badges.sh new file mode 100755 index 0000000..7c960ea --- /dev/null +++ b/scripts/badges.sh @@ -0,0 +1,32 @@ +#!/usr/bin/env bash + +# pip install git+https://github.com/jongracecox/anybadge + +coverage_badge() { + python3 -m coverage erase + python3 -m coverage run -m unittest discover -s ./tests/ + python3 -m coverage combine + rating=$(python3 -m coverage report | tail -n 1 | ack "\d+%" -o | ack "\d+" -o) + echo "coverage rating: $rating" + rm readme/coverage.svg + python3 -m anybadge -l coverage -v $rating -f readme/coverage.svg coverage + + python3 -m coverage report -m + echo "coverage badge created" +} + +pylint_badge() { + pylint_output=$(python3 -m pylint inputremapper --extension-pkg-whitelist=evdev) + rating=$(echo $pylint_output | grep -Po "rated at .+?/" | grep -Po "\d+.\d+") + rm readme/pylint.svg + python3 -m anybadge -l pylint -v $rating -f readme/pylint.svg pylint + + echo "pylint rating: $rating" + echo "pylint badge created" +} + +pylint_badge & +coverage_badge & + +# wait for all badges to be created +wait diff --git a/scripts/build-deb.sh b/scripts/build-deb.sh new file mode 100755 index 0000000..6ff94f4 --- /dev/null +++ b/scripts/build-deb.sh @@ -0,0 +1,17 @@ +#!/usr/bin/env bash + +build_deb() { + # https://www.devdungeon.com/content/debian-package-tutorial-dpkgdeb + # that was really easy actually + rm build -r + mkdir dist | true + python3 -m install --root build/deb + + find ./build -name "*.pyc" -delete + find ./build -empty -delete + + cp ./DEBIAN build/deb -r + dpkg-deb -Z gzip -b build/deb dist/input-remapper-2.2.1.deb +} + +build_deb diff --git a/scripts/ci-install-deps.sh b/scripts/ci-install-deps.sh new file mode 100755 index 0000000..b134b72 --- /dev/null +++ b/scripts/ci-install-deps.sh @@ -0,0 +1,15 @@ +#!/usr/bin/env bash +# Called from multiple CI pipelines in .github/workflows +set -xeuo pipefail + +sudo apt update + +# native deps +# gettext required to generate translations, others are python deps +sudo apt-get install -y gettext python3-evdev python3-dasbus python3-pydantic python3-gi gir1.2-gtk-3.0 gir1.2-gtksource-4 + +# ensure pip and setuptools/wheel up to date so can install all pip modules +sudo apt-get install python3-pip python3-wheel + +# install test deps which aren't in pyproject.toml +python -m pip install psutil pylint-pydantic diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/__main__.py b/tests/__main__.py new file mode 100644 index 0000000..fba0404 --- /dev/null +++ b/tests/__main__.py @@ -0,0 +1,4 @@ +import unittest + +if __name__ == "__main__": + unittest.main() diff --git a/tests/lib/__init__.py b/tests/lib/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/lib/cleanup.py b/tests/lib/cleanup.py new file mode 100644 index 0000000..1edd6af --- /dev/null +++ b/tests/lib/cleanup.py @@ -0,0 +1,172 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + +from __future__ import annotations + +import asyncio +import copy +import os +import shutil +import time +from pickle import UnpicklingError + +import psutil + +from tests.lib.constants import EVENT_READ_TIMEOUT +from tests.lib.fixtures import fixtures +from tests.lib.logger import logger +from tests.lib.patches import uinputs +from tests.lib.pipes import ( + uinput_write_history_pipe, + uinput_write_history, + pending_events, + setup_pipe, +) +from tests.lib.tmp import tmp + +# TODO on it. You don't need a framework for this by the way: +# don't import anything from input_remapper gloablly here, because some files execute +# code when imported, which can screw up patches. I wish we had a dependency injection +# framework that patches together the dependencies during runtime... + + +environ_copy = copy.deepcopy(os.environ) + + +def join_children(): + """Wait for child processes to exit. Stop them if it takes too long.""" + this = psutil.Process(os.getpid()) + + i = 0 + time.sleep(EVENT_READ_TIMEOUT) + children = this.children(recursive=True) + while len([c for c in children if c.status() != "zombie"]) > 0: + for child in children: + if i > 10: + child.kill() + logger.info("Killed pid %s because it didn't finish in time", child.pid) + + children = this.children(recursive=True) + time.sleep(EVENT_READ_TIMEOUT) + i += 1 + + +def clear_write_history(): + """Empty the history in preparation for the next test.""" + while len(uinput_write_history) > 0: + uinput_write_history.pop() + while uinput_write_history_pipe[0].poll(): + uinput_write_history_pipe[0].recv() + + +def quick_cleanup(log=True): + """Reset the applications state.""" + # TODO no: + # Reminder: before patches are applied in test.py, no inputremapper module + # may be imported. So tests.lib imports them just-in-time in functions instead. + from inputremapper.injection.macros.macro import macro_variables + from inputremapper.configs.keyboard_layout import keyboard_layout + from inputremapper.gui.utils import debounce_manager + + if log: + logger.info("Quick cleanup...") + + debounce_manager.stop_all() + + for device in list(pending_events.keys()): + try: + while pending_events[device][1].poll(): + pending_events[device][1].recv() + except (UnpicklingError, EOFError): + pass + + # setup new pipes for the next test + pending_events[device][1].close() + pending_events[device][0].close() + del pending_events[device] + setup_pipe(device) + + try: + if asyncio.get_event_loop().is_running(): + for task in asyncio.all_tasks(): + task.cancel() + except RuntimeError: + # happens when the event loop disappears for magical reasons + # create a fresh event loop + asyncio.set_event_loop(asyncio.new_event_loop()) + + if macro_variables.process is not None and not macro_variables.process.is_alive(): + # nothing should stop the process during runtime, if it has been started by + # the injector once + raise AssertionError("the SharedDict manager is not running anymore") + + if macro_variables.process is not None: + macro_variables._stop() + + join_children() + + macro_variables.start() + + if os.path.exists(tmp): + shutil.rmtree(tmp) + + keyboard_layout.populate() + + clear_write_history() + + for name in list(uinputs.keys()): + del uinputs[name] + + # for device in list(active_macros.keys()): + # del active_macros[device] + # for device in list(unreleased.keys()): + # del unreleased[device] + fixtures.reset() + os.environ.update(environ_copy) + for device in list(os.environ.keys()): + if device not in environ_copy: + del os.environ[device] + + for _, pipe in pending_events.values(): + assert not pipe.poll() + + assert macro_variables.is_alive(1) + + if log: + logger.info("Quick cleanup done") + + +def cleanup(): + """Reset the applications state. + + Using this is slower, usually quick_cleanup() is sufficient. + """ + from inputremapper.groups import groups + + logger.info("Cleanup...") + + os.system("pkill -f input-remapper-service") + os.system("pkill -f input-remapper-control") + time.sleep(0.05) + + quick_cleanup(log=False) + groups.refresh() + + logger.info("Cleanup done") diff --git a/tests/lib/constants.py b/tests/lib/constants.py new file mode 100644 index 0000000..e970cfc --- /dev/null +++ b/tests/lib/constants.py @@ -0,0 +1,28 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + +# give tests some time to test stuff while the process +# is still running +EVENT_READ_TIMEOUT = 0.01 + +# based on experience how much time passes at most until +# the reader-service starts receiving previously pushed events after a +# call to start_reading +START_READING_DELAY = 0.05 diff --git a/tests/lib/fixture_pipes.py b/tests/lib/fixture_pipes.py new file mode 100644 index 0000000..359aa13 --- /dev/null +++ b/tests/lib/fixture_pipes.py @@ -0,0 +1,36 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + +from __future__ import annotations + +from tests.lib.fixtures import fixtures +from tests.lib.pipes import setup_pipe, close_pipe + + +def create_fixture_pipes(): + # make sure those pipes exist before any process (the reader-service) gets forked, + # so that events can be pushed after the fork. + for _fixture in fixtures: + setup_pipe(_fixture) + + +def remove_fixture_pipes(): + for _fixture in fixtures: + close_pipe(_fixture) diff --git a/tests/lib/fixtures.py b/tests/lib/fixtures.py new file mode 100644 index 0000000..19bc9aa --- /dev/null +++ b/tests/lib/fixtures.py @@ -0,0 +1,420 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + +from __future__ import annotations + +import dataclasses +import json +import time +from hashlib import md5 +from typing import Dict, Optional + +import evdev + +from inputremapper.configs.input_config import InputCombination +from inputremapper.configs.mapping import Mapping +from inputremapper.configs.paths import PathUtils +from inputremapper.configs.preset import Preset +from tests.lib.logger import logger + +# input-remapper is only interested in devices that have EV_KEY, add some +# random other stuff to test that they are ignored. +phys_foo = "usb-0000:03:00.0-1/input2" +info_foo = evdev.device.DeviceInfo(1, 1, 1, 1) + +keyboard_keys = sorted(evdev.ecodes.keys.keys())[:255] + + +@dataclasses.dataclass(frozen=True) +class Fixture: + path: str + capabilities: Dict = dataclasses.field(default_factory=dict) + name: str = "unset" + info: evdev.device.DeviceInfo = evdev.device.DeviceInfo(None, None, None, None) + phys: str = "unset" + group_key: Optional[str] = None + + # for joysticks and such + min_abs: int = -(2**15) + max_abs: int = 2**15 + + # uniq is typically empty + uniq: str = "" + + def __hash__(self): + return hash(self.path) + + def get_device_hash(self): + s = str(self.capabilities) + self.name + device_hash = md5(s.encode()).hexdigest() + logger.info( + 'Hash for fixture "%s" "%s": "%s"', + self.path, + self.name, + device_hash, + ) + return device_hash + + +class _Fixtures: + """contains all predefined Fixtures. + Can be extended with new Fixtures during runtime""" + + dev_input_event1 = Fixture( + capabilities={ + evdev.ecodes.EV_KEY: [evdev.ecodes.KEY_A], + }, + phys="usb-0000:03:00.0-0/input1", + info=info_foo, + name="Foo Device", + path="/dev/input/event1", + ) + # Another "Foo Device", which will get an incremented key. + # If possible write tests using this one, because name != key here and + # that would be important to test as well. Otherwise, the tests can't + # see if the groups correct attribute is used in functions and paths. + dev_input_event11 = Fixture( + capabilities={ + evdev.ecodes.EV_KEY: [ + evdev.ecodes.BTN_LEFT, + evdev.ecodes.BTN_TOOL_DOUBLETAP, + ], + evdev.ecodes.EV_REL: [ + evdev.ecodes.REL_X, + evdev.ecodes.REL_Y, + evdev.ecodes.REL_WHEEL, + evdev.ecodes.REL_HWHEEL, + ], + }, + phys=f"{phys_foo}/input2", + info=info_foo, + name="Foo Device foo", + group_key="Foo Device 2", # expected key + path="/dev/input/event11", + ) + dev_input_event10 = Fixture( + capabilities={evdev.ecodes.EV_KEY: keyboard_keys}, + phys=f"{phys_foo}/input3", + info=info_foo, + name="Foo Device", + group_key="Foo Device 2", + path="/dev/input/event10", + ) + dev_input_event13 = Fixture( + capabilities={evdev.ecodes.EV_KEY: [], evdev.ecodes.EV_SYN: []}, + phys=f"{phys_foo}/input1", + info=info_foo, + name="Foo Device", + group_key="Foo Device 2", + path="/dev/input/event13", + ) + dev_input_event14 = Fixture( + capabilities={evdev.ecodes.EV_SYN: []}, + phys=f"{phys_foo}/input0", + info=info_foo, + name="Foo Device qux", + group_key="Foo Device 2", + path="/dev/input/event14", + ) + dev_input_event15 = Fixture( + capabilities={ + evdev.ecodes.EV_SYN: [], + evdev.ecodes.EV_ABS: [ + evdev.ecodes.ABS_X, + evdev.ecodes.ABS_Y, + evdev.ecodes.ABS_RX, + evdev.ecodes.ABS_RY, + evdev.ecodes.ABS_Z, + evdev.ecodes.ABS_RZ, + evdev.ecodes.ABS_HAT0X, + evdev.ecodes.ABS_HAT0Y, + ], + evdev.ecodes.EV_KEY: [evdev.ecodes.BTN_A], + }, + phys=f"{phys_foo}/input4", + info=info_foo, + name="Foo Device bar", + group_key="Foo Device 2", + path="/dev/input/event15", + ) + # Bar Device + dev_input_event20 = Fixture( + capabilities={evdev.ecodes.EV_KEY: keyboard_keys}, + phys="usb-0000:03:00.0-2/input1", + info=evdev.device.DeviceInfo(2, 1, 2, 1), + name="Bar Device", + path="/dev/input/event20", + ) + dev_input_event30 = Fixture( + capabilities={ + evdev.ecodes.EV_SYN: [], + evdev.ecodes.EV_ABS: [ + evdev.ecodes.ABS_X, + evdev.ecodes.ABS_Y, + evdev.ecodes.ABS_RX, + evdev.ecodes.ABS_RY, + evdev.ecodes.ABS_Z, + evdev.ecodes.ABS_RZ, + evdev.ecodes.ABS_HAT0X, + evdev.ecodes.ABS_HAT0Y, + ], + evdev.ecodes.EV_KEY: [ + evdev.ecodes.BTN_A, + evdev.ecodes.BTN_B, + evdev.ecodes.BTN_X, + evdev.ecodes.BTN_Y, + ], + }, + phys="", # this is empty sometimes + info=evdev.device.DeviceInfo(3, 1, 3, 1), + name="gamepad", + path="/dev/input/event30", + ) + # device that is completely ignored + dev_input_event31 = Fixture( + capabilities={evdev.ecodes.EV_SYN: []}, + phys="usb-0000:03:00.0-4/input1", + info=evdev.device.DeviceInfo(4, 1, 4, 1), + name="Power Button", + path="/dev/input/event31", + ) + # input-remapper devices are not displayed in the ui, some instance + # of input-remapper started injecting, apparently. + dev_input_event40 = Fixture( + capabilities={evdev.ecodes.EV_KEY: keyboard_keys}, + phys="input-remapper/input1", + info=evdev.device.DeviceInfo(5, 1, 5, 1), + name="input-remapper Bar Device", + path="/dev/input/event40", + ) + # denylisted + dev_input_event51 = Fixture( + capabilities={evdev.ecodes.EV_KEY: keyboard_keys}, + phys="usb-0000:03:00.0-5/input1", + info=evdev.device.DeviceInfo(6, 1, 6, 1), + name="YuBiCofooYuBiKeYbar", + path="/dev/input/event51", + ) + # name requires sanitation + dev_input_event52 = Fixture( + capabilities={evdev.ecodes.EV_KEY: keyboard_keys}, + phys="usb-0000:03:00.0-3/input1", + info=evdev.device.DeviceInfo(2, 1, 2, 1), + name="Qux/[Device]?", + path="/dev/input/event52", + ) + dev_input_event32 = Fixture( + capabilities={ + evdev.ecodes.EV_SYN: [], + evdev.ecodes.EV_ABS: [ + evdev.ecodes.ABS_X, + evdev.ecodes.ABS_Y, + evdev.ecodes.ABS_RX, + evdev.ecodes.ABS_RY, + evdev.ecodes.ABS_Z, + evdev.ecodes.ABS_RZ, + evdev.ecodes.ABS_HAT0X, + evdev.ecodes.ABS_HAT0Y, + ], + evdev.ecodes.EV_KEY: [ + evdev.ecodes.BTN_A, + evdev.ecodes.BTN_B, + evdev.ecodes.BTN_X, + evdev.ecodes.BTN_Y, + ], + }, + phys="", # this is empty sometimes + info=evdev.device.DeviceInfo(3, 1, 3, 1), + name="gamepad abs 0 to 256", + path="/dev/input/event32", + min_abs=0, + max_abs=256, + ) + + def __init__(self): + self.reset() + + def reset(self) -> None: + self._iter = [ + self.dev_input_event1, + self.dev_input_event11, + self.dev_input_event10, + self.dev_input_event13, + self.dev_input_event14, + self.dev_input_event15, + self.dev_input_event20, + self.dev_input_event30, + self.dev_input_event31, + self.dev_input_event40, + self.dev_input_event51, + self.dev_input_event52, + self.dev_input_event32, + ] + + def get_fixture(self, path: str) -> Fixture: + """get a Fixture by it's unique /dev/input/eventX path""" + for fixture in self._iter: + if fixture.path == path: + return fixture + + raise KeyError(f"Could not find fixture with path {path}") + + def add_fixture(self, value: Fixture | dict) -> None: + if isinstance(value, Fixture): + value = value.__dict__ + + key = value["path"] + if isinstance(value, Fixture): + self._iter.append(value) + elif isinstance(value, dict): + self._iter.append(Fixture(**value)) + + def remove_fixture(self, path: str) -> None: + index = 0 + for i, fixture in enumerate(self._iter): + if fixture.path == path: + index = i + + del self._iter[index] + + def __iter__(self): + return iter(self._iter) + + def get_paths(self): + """Get a list of all available device paths.""" + paths = [] + for fixture in self._iter: + paths.append(fixture.path) + + return paths + + def get(self, item) -> Optional[Fixture]: + try: + return self.get_fixture(item) + except KeyError: + return None + + @property + def foo_device_1_1(self): + return self.get_fixture("/dev/input/event1") + + @property + def foo_device_2_mouse(self): + return self.get_fixture("/dev/input/event11") + + @property + def foo_device_2_keyboard(self): + return self.get_fixture("/dev/input/event10") + + @property + def foo_device_2_13(self): + return self.get_fixture("/dev/input/event13") + + @property + def foo_device_2_qux(self): + return self.get_fixture("/dev/input/event14") + + @property + def foo_device_2_gamepad(self): + return self.get_fixture("/dev/input/event15") + + @property + def bar_device(self): + return self.get_fixture("/dev/input/event20") + + @property + def gamepad(self): + return self.get_fixture("/dev/input/event30") + + @property + def power_button(self): + return self.get_fixture("/dev/input/event31") + + @property + def input_remapper_bar_device(self): + return self.get_fixture("/dev/input/event40") + + @property + def YuBiCofooYuBiKeYbar(self): + return self.get_fixture("/dev/input/event51") + + @property + def QuxSlashDeviceQuestionmark(self): + return self.get_fixture("/dev/input/event52") + + @property + def gamepad_abs_0_to_256(self): + return self.get_fixture("/dev/input/event32") + + +fixtures = _Fixtures() + + +def new_event(type, code, value, timestamp): + """Create a new InputEvent. + + Handy because of the annoying sec and usec arguments of the regular + evdev.InputEvent constructor. + + Prefer using `InputEvent.key()`, `InputEvent.abs()`, `InputEvent.rel()` or just + `InputEvent(0, 0, 1234, 2345, 3456)`. + """ + from inputremapper.input_event import InputEvent + + if timestamp is None: + timestamp = time.time() + + sec = int(timestamp) + usec = timestamp % 1 * 1000000 + event = InputEvent(sec, usec, type, code, value) + return event + + +def prepare_presets(): + """prepare a few presets for use in tests + "Foo Device 2/preset3" is the newest and "Foo Device 2/preset2" is set to autoload + """ + preset1 = Preset(PathUtils.get_preset_path("Foo Device", "preset1")) + preset1.add( + Mapping.from_combination( + InputCombination.from_tuples((1, 1)), + output_symbol="b", + ) + ) + preset1.add(Mapping.from_combination(InputCombination.from_tuples((1, 2)))) + preset1.save() + + time.sleep(0.1) + preset2 = Preset(PathUtils.get_preset_path("Foo Device", "preset2")) + preset2.add(Mapping.from_combination(InputCombination.from_tuples((1, 3)))) + preset2.add(Mapping.from_combination(InputCombination.from_tuples((1, 4)))) + preset2.save() + + # make sure the timestamp of preset 3 is the newest, + # so that it will be automatically loaded by the GUI + time.sleep(0.1) + preset3 = Preset(PathUtils.get_preset_path("Foo Device", "preset3")) + preset3.add(Mapping.from_combination(InputCombination.from_tuples((1, 5)))) + preset3.save() + + with open(PathUtils.get_config_path("config.json"), "w") as file: + json.dump({"autoload": {"Foo Device 2": "preset2"}}, file, indent=4) + + return preset1, preset2, preset3 diff --git a/tests/lib/is_service_running.py b/tests/lib/is_service_running.py new file mode 100644 index 0000000..e969e46 --- /dev/null +++ b/tests/lib/is_service_running.py @@ -0,0 +1,32 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + +from __future__ import annotations + +import subprocess + + +def is_service_running(): + """Check if the daemon is running.""" + try: + subprocess.check_output(["pgrep", "-f", "input-remapper-service"]) + return True + except subprocess.CalledProcessError: + return False diff --git a/tests/lib/logger.py b/tests/lib/logger.py new file mode 100644 index 0000000..9e542a4 --- /dev/null +++ b/tests/lib/logger.py @@ -0,0 +1,54 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + +from __future__ import annotations + +import sys +import traceback +import tracemalloc +import warnings +import logging + + +tracemalloc.start() + +logger = logging.getLogger("input-remapper-test") +handler = logging.StreamHandler() +handler.setFormatter(logging.Formatter("\033[90mTest: %(message)s\033[0m")) +logger.addHandler(handler) +logger.setLevel(logging.INFO) + + +def update_inputremapper_verbosity(): + from inputremapper.logging.logger import logger + + logger.update_verbosity(True) + + +def warn_with_traceback(message, category, filename, lineno, file=None, line=None): + log = file if hasattr(file, "write") else sys.stderr + traceback.print_stack(file=log) + log.write(warnings.formatwarning(message, category, filename, lineno, line)) + + +def patch_warnings(): + # show traceback + warnings.showwarning = warn_with_traceback + warnings.simplefilter("always") diff --git a/tests/lib/patches.py b/tests/lib/patches.py new file mode 100644 index 0000000..55fd990 --- /dev/null +++ b/tests/lib/patches.py @@ -0,0 +1,413 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + +from __future__ import annotations + +import asyncio +import copy +import os +import subprocess +import time +from pickle import UnpicklingError +from unittest.mock import patch +import atexit + +import evdev + +from inputremapper.utils import get_evdev_constant_name +from tests.lib.constants import EVENT_READ_TIMEOUT +from tests.lib.fixtures import Fixture, fixtures, new_event +from tests.lib.pipes import ( + setup_pipe, + push_events, + uinput_write_history, + uinput_write_history_pipe, + pending_events, +) +from tests.lib.xmodmap import xmodmap +from tests.lib.tmp import tmp +from tests.lib.logger import logger + + +def patch_paths(): + from inputremapper.user import UserUtils + + return [ + patch.object(UserUtils, "home", tmp), + patch.dict(os.environ, {"XDG_CONFIG_HOME": os.path.join(tmp, ".config")}), + ] + + +class InputDevice: + # expose as existing attribute, otherwise the patch for + # evdev < 1.0.0 will crash the test + path = None + + def __init__(self, path): + if path != "justdoit" and not fixtures.get(path): + # beware that fixtures keys and the path attribute of a fixture can + # theoretically be different. I don't know if this is the case right now + logger.error( + 'path "%s" was not found in fixtures. available: %s', + path, + list(fixtures.get_paths()), + ) + raise FileNotFoundError() + if path == "justdoit": + self._fixture = Fixture(path="justdoit") + else: + self._fixture = fixtures.get_fixture(path) + + self.path = path + self.phys = self._fixture.phys + self.info = self._fixture.info + self.name = self._fixture.name + self.uniq = self._fixture.uniq + + # this property exists only for test purposes and is not part of + # the original evdev.InputDevice class + self.group_key = self._fixture.group_key or self._fixture.name + + # ensure a pipe exists to make this object act like + # it is reading events from a device + setup_pipe(self._fixture) + + self.fd = pending_events[self._fixture][1].fileno() + + def push_events(self, events): + push_events(self._fixture, events) + + def fileno(self): + """Compatibility to select.select.""" + return self.fd + + def log(self, key, msg): + logger.info('%s "%s" "%s" %s', msg, self.name, self.path, key) + + def absinfo(self, *args): + raise Exception("Ubuntus version of evdev doesn't support .absinfo") + + def grab(self): + logger.info("grab %s %s", self.name, self.path) + + def ungrab(self): + logger.info("ungrab %s %s", self.name, self.path) + + async def async_read_loop(self): + logger.info("starting read loop for %s", self.path) + new_frame = asyncio.Event() + asyncio.get_running_loop().add_reader(self.fd, new_frame.set) + while True: + await new_frame.wait() + new_frame.clear() + if not pending_events[self._fixture][1].poll(): + # todo: why? why do we need this? + # sometimes this happens, as if a other process calls recv on + # the pipe + continue + + event = pending_events[self._fixture][1].recv() + logger.info("got %s at %s", event, self.path) + yield event + + def read(self): + # the patched fake InputDevice objects read anything pending from + # that group. + # To be realistic it would have to check if the provided + # element is in its capabilities. + if self.group_key not in pending_events: + self.log("no events to read", self.group_key) + return + + # consume all of them + while pending_events[self._fixture][1].poll(): + event = pending_events[self._fixture][1].recv() + self.log(event, "read") + yield event + time.sleep(EVENT_READ_TIMEOUT) + + def read_loop(self): + """Endless loop that yields events.""" + while True: + event = pending_events[self._fixture][1].recv() + if event is not None: + self.log(event, "read_loop") + yield event + time.sleep(EVENT_READ_TIMEOUT) + + def read_one(self): + """Read one event or none if nothing available.""" + if not pending_events.get(self._fixture): + return None + + if not pending_events[self._fixture][1].poll(): + return None + + try: + event = pending_events[self._fixture][1].recv() + except (UnpicklingError, EOFError): + # failed in tests sometimes + return None + + self.log(event, "read_one") + return event + + def capabilities(self, absinfo=True, verbose=False): + result = copy.deepcopy(self._fixture.capabilities) + + if absinfo and evdev.ecodes.EV_ABS in result: + absinfo_obj = evdev.AbsInfo( + value=None, + min=self._fixture.min_abs, + fuzz=None, + flat=None, + resolution=None, + max=self._fixture.max_abs, + ) + + ev_abs = [] + for ev_code in result[evdev.ecodes.EV_ABS]: + if ev_code in range(0x10, 0x18): # ABS_HAT0X - ABS_HAT3Y + absinfo_obj = evdev.AbsInfo( + value=None, + min=-1, + fuzz=None, + flat=None, + resolution=None, + max=1, + ) + ev_abs.append((ev_code, absinfo_obj)) + + result[evdev.ecodes.EV_ABS] = ev_abs + + return result + + def input_props(self): + return [] + + def leds(self): + return [] + + +uinputs = {} + + +class UInputMock: + def __init__( + self, events=None, name="unnamed", phys="py-evdev-uinput", *args, **kwargs + ): + self.fd = 0 + self.write_count = 0 + self.device = InputDevice("justdoit") + self.name = name + self.events = events + self.phys = phys + self.write_history = [] + + global uinputs + uinputs[name] = self + + def capabilities(self, verbose=False, absinfo=True): + if absinfo or 3 not in self.events: + return self.events + else: + events = self.events.copy() + events[3] = [code for code, _ in self.events[3]] + return events + + def write(self, type, code, value): + self.write_count += 1 + event = new_event(type, code, value, time.time()) + uinput_write_history.append(event) + uinput_write_history_pipe[1].send(event) + self.write_history.append(event) + logger.info( + '%s %s written to "%s"', + (type, code, value), + get_evdev_constant_name(type, code), + self.name, + ) + + def syn(self): + pass + + +def patch_evdev(): + def list_devices(): + return [fixture_.path for fixture_ in fixtures] + + class PatchedInputEvent(evdev.InputEvent): + def __init__(self, sec, usec, type, code, value): + self.t = (type, code, value) + super().__init__(sec, usec, type, code, value) + + def copy(self): + return PatchedInputEvent( + self.sec, + self.usec, + self.type, + self.code, + self.value, + ) + + return [ + patch.object(evdev, "list_devices", list_devices), + patch.object(evdev, "InputDevice", InputDevice), + patch.object(evdev.UInput, "capabilities", UInputMock.capabilities), + patch.object(evdev.UInput, "write", UInputMock.write), + patch.object(evdev.UInput, "syn", UInputMock.syn), + patch.object(evdev.UInput, "__init__", UInputMock.__init__), + patch.object(evdev, "InputEvent", PatchedInputEvent), + ] + + +def patch_events(): + # improve logging of stuff + return patch.object( + evdev.InputEvent, + "__str__", + lambda self: (f"InputEvent{(self.type, self.code, self.value)}"), + ) + + +def patch_os_system(): + """Avoid running pkexec.""" + original_system = os.system + + def system(command): + if "pkexec" in command: + # because it + # - will open a window for user input + # - has no knowledge of the fixtures and patches + raise Exception("Write patches to avoid running pkexec stuff") + return original_system(command) + + return patch.object(os, "system", system) + + +def patch_atexit_register(): + """Avoid adding tons of redundant atexit handlers that we don't need anyway. + Otherwise we get lots of logs at the end of gui tests that bury the test result. + """ + return patch.object(atexit, "register") + + +def patch_check_output(): + """Xmodmap -pke should always return a fixed set of symbols. + + On some installations the `xmodmap` command might be missig completely, + which would break the tests. + """ + original_check_output = subprocess.check_output + + def check_output(command, *args, **kwargs): + if "xmodmap" in command and "-pke" in command: + return xmodmap + return original_check_output(command, *args, **kwargs) + + return patch.object(subprocess, "check_output", check_output) + + +def patch_regrab_timeout(): + # no need for a high number in tests + from inputremapper.injection.injector import Injector + + return patch.object(Injector, "regrab_timeout", 0.05) + + +def is_running_patch(): + logger.info("is_running is patched to always return True") + return True + + +def patch_is_running(): + from inputremapper.gui.reader_service import ReaderService + + return patch.object(ReaderService, "is_running", is_running_patch) + + +def patch_enable_all_logs(): + from inputremapper.logging.logger import Logger + + return patch.object(Logger, "analog_log_threshold", 0) + + +class FakeDaemonProxy: + def __init__(self): + self.calls = { + "stop_injecting": [], + "get_state": [], + "start_injecting": [], + "stop_all": 0, + "set_config_dir": [], + "autoload": 0, + "autoload_single": [], + "hello": [], + "quit": 0, + } + + def stop_injecting(self, group_key: str) -> None: + self.calls["stop_injecting"].append(group_key) + + def get_state(self, group_key: str): + from inputremapper.injection.injector import InjectorState + + self.calls["get_state"].append(group_key) + return InjectorState.STOPPED + + def start_injecting(self, group_key: str, preset: str) -> bool: + self.calls["start_injecting"].append((group_key, preset)) + return True + + def stop_all(self) -> None: + self.calls["stop_all"] += 1 + + def set_config_dir(self, config_dir: str) -> None: + self.calls["set_config_dir"].append(config_dir) + + def autoload(self) -> None: + self.calls["autoload"] += 1 + + def autoload_single(self, group_key: str) -> None: + self.calls["autoload_single"].append(group_key) + + def hello(self, out: str) -> str: + self.calls["hello"].append(out) + return out + + def quit(self): + self.calls["quit"] += 1 + + +def create_patches(): + return [ + # Sketchy, they only work because the whole modules are imported, instead of + # importing `check_output` and `system` from the module. + *patch_evdev(), + patch_os_system(), + patch_atexit_register(), + patch_check_output(), + # Those are comfortably wrapped in a class, and are therefore easy to patch + *patch_paths(), + patch_regrab_timeout(), + patch_is_running(), + patch_events(), + patch_enable_all_logs(), + ] diff --git a/tests/lib/pipes.py b/tests/lib/pipes.py new file mode 100644 index 0000000..4bf2189 --- /dev/null +++ b/tests/lib/pipes.py @@ -0,0 +1,96 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + +"""Reading events from fixtures, making fixtures act like they are sending events.""" + +from __future__ import annotations + +import multiprocessing +from multiprocessing.connection import Connection +from typing import Dict, Tuple + +from tests.lib.fixtures import Fixture +from tests.lib.logger import logger + +uinput_write_history = [] +# for tests that makes the injector create its processes +uinput_write_history_pipe = multiprocessing.Pipe() +pending_events: Dict[Fixture, Tuple[Connection, Connection]] = {} + + +def read_write_history_pipe(): + """Convert the write history from the pipe to some easier to manage list.""" + history = [] + while uinput_write_history_pipe[0].poll(): + event = uinput_write_history_pipe[0].recv() + history.append((event.type, event.code, event.value)) + return history + + +def setup_pipe(fixture: Fixture): + """Create a pipe that can be used to send events to the reader-service, + which in turn will be sent to the reader-client + """ + if pending_events.get(fixture) is None: + pending_events[fixture] = multiprocessing.Pipe() + + +def close_pipe(fixture: Fixture): + if fixture in pending_events: + pipe1, pipe2 = pending_events[fixture] + pipe1.close() + pipe2.close() + del pending_events[fixture] + + +def get_events(): + """Get all events written by the injector.""" + return uinput_write_history + + +def push_event(fixture: Fixture, event, force: bool = False): + """Make a device act like it is reading events from evdev. + + push_event is like hitting a key on a keyboard for stuff that reads from + evdev.InputDevice (which is patched in test.py to work that way) + + Parameters + ---------- + fixture + For example 'Foo Device' + event + The InputEvent to send + force + don't check if the event is in fixture.capabilities + """ + setup_pipe(fixture) + if not force and ( + not fixture.capabilities.get(event.type) + or event.code not in fixture.capabilities[event.type] + ): + raise AssertionError(f"Fixture {fixture.path} cannot send {event}") + logger.info("Simulating %s for %s", event, fixture.path) + pending_events[fixture][0].send(event) + + +def push_events(fixture: Fixture, events, force=False): + """Push multiple events.""" + for event in events: + push_event(fixture, event, force) diff --git a/tests/lib/spy.py b/tests/lib/spy.py new file mode 100644 index 0000000..002983a --- /dev/null +++ b/tests/lib/spy.py @@ -0,0 +1,26 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + +from unittest.mock import patch + + +def spy(obj, name): + """Convenient wrapper for patch.object(..., ..., wraps=...).""" + return patch.object(obj, name, wraps=obj.__getattribute__(name)) diff --git a/tests/lib/test_setup.py b/tests/lib/test_setup.py new file mode 100644 index 0000000..a6b5032 --- /dev/null +++ b/tests/lib/test_setup.py @@ -0,0 +1,109 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + +from __future__ import annotations + +import os +import tracemalloc + +from tests.lib.cleanup import cleanup, quick_cleanup +from tests.lib.fixture_pipes import create_fixture_pipes, remove_fixture_pipes +from tests.lib.is_service_running import is_service_running +from tests.lib.logger import update_inputremapper_verbosity, logger +from tests.lib.patches import create_patches + + +def test_setup(cls): + """A class decorator to + - apply the patches to all tests + - check if the deamon is already running + - create pipes to send events to the reader service + - reset stuff automatically + """ + original_setUp = cls.setUp + original_tearDown = cls.tearDown + original_setUpClass = cls.setUpClass + original_tearDownClass = cls.tearDownClass + + tracemalloc.start() + os.environ["UNITTEST"] = "1" + update_inputremapper_verbosity() + + patches = create_patches() + + def resetPatches(): + # In case some patches carry a state (I don't remember, idk), stop and start + # them from scratch + for patch in patches: + patch.stop() + + for patch in patches: + patch.start() + + def setUpClass(): + logger.info("setUpClass %s", cls) + + if is_service_running(): + # let tests control daemon existance + raise Exception("Expected the service not to be running already.") + + create_fixture_pipes() + + # I don't know. Somehow tearDownClass is called before the test, so lets + # make sure the patches are started already when the class is set up, so that + # an unpatched `prepare_all` doesn't take ages to finish, and doesn't do funky + # stuff with the real evdev. + resetPatches() + + original_setUpClass() + + def tearDownClass(): + logger.info("tearDownClass %s", cls) + original_tearDownClass() + + remove_fixture_pipes() + + # Do the more thorough cleanup only after all tests of classes, because it + # slows tests down. If this is required after each test, call it in your + # tearDown method. + cleanup() + + def setUp(self): + logger.info("setUp %s", cls) + + resetPatches() + + original_setUp(self) + + def tearDown(self): + logger.info("tearDown %s", cls) + + original_tearDown(self) + + quick_cleanup() + + resetPatches() + + cls.setUp = setUp + cls.tearDown = tearDown + cls.setUpClass = setUpClass + cls.tearDownClass = tearDownClass + + return cls diff --git a/tests/lib/tmp.py b/tests/lib/tmp.py new file mode 100644 index 0000000..c8a828f --- /dev/null +++ b/tests/lib/tmp.py @@ -0,0 +1,30 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + +from __future__ import annotations + +import tempfile +from tests.lib.logger import logger + +# When it gets garbage collected it cleans up the temporary directory so it needs to +# stay reachable while the tests are ran. +temporary_directory = tempfile.TemporaryDirectory(prefix="input-remapper-test") +tmp = temporary_directory.name +logger.info('tmp at "%s"', tmp) diff --git a/tests/lib/xmodmap.py b/tests/lib/xmodmap.py new file mode 100644 index 0000000..1c3349c --- /dev/null +++ b/tests/lib/xmodmap.py @@ -0,0 +1,128 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + +xmodmap = ( + b"keycode 8 =\nkeycode 9 = Escape NoSymbol Escape\nkeycode 10 = 1 exclam 1 exclam onesuperior exclamdown ones" + b"uperior\nkeycode 11 = 2 quotedbl 2 quotedbl twosuperior oneeighth twosuperior\nkeycode 12 = 3 section 3 sectio" + b"n threesuperior sterling threesuperior\nkeycode 13 = 4 dollar 4 dollar onequarter currency onequarter\nkeycode " + b" 14 = 5 percent 5 percent onehalf threeeighths onehalf\nkeycode 15 = 6 ampersand 6 ampersand notsign fiveeighth" + b"s notsign\nkeycode 16 = 7 slash 7 slash braceleft seveneighths braceleft\nkeycode 17 = 8 parenleft 8 parenleft" + b" bracketleft trademark bracketleft\nkeycode 18 = 9 parenright 9 parenright bracketright plusminus bracketright" + b"\nkeycode 19 = 0 equal 0 equal braceright degree braceright\nkeycode 20 = ssharp question ssharp question back" + b"slash questiondown U1E9E\nkeycode 21 = dead_acute dead_grave dead_acute dead_grave dead_cedilla dead_ogonek dea" + b"d_cedilla\nkeycode 22 = BackSpace BackSpace BackSpace BackSpace\nkeycode 23 = Tab ISO_Left_Tab Tab ISO_Left_Ta" + b"b\nkeycode 24 = q Q q Q at Greek_OMEGA at\nkeycode 25 = w W w W lstroke Lstroke lstroke\nkeycode 26 = e E e E" + b" EuroSign EuroSign EuroSign\nkeycode 27 = r R r R paragraph registered paragraph\nkeycode 28 = t T t T tslash " + b"Tslash tslash\nkeycode 29 = z Z z Z leftarrow yen leftarrow\nkeycode 30 = u U u U downarrow uparrow downarrow" + b"\nkeycode 31 = i I i I rightarrow idotless rightarrow\nkeycode 32 = o O o O oslash Oslash oslash\nkeycode 33 " + b"= p P p P thorn THORN thorn\nkeycode 34 = udiaeresis Udiaeresis udiaeresis Udiaeresis dead_diaeresis dead_above" + b"ring dead_diaeresis\nkeycode 35 = plus asterisk plus asterisk asciitilde macron asciitilde\nkeycode 36 = Retur" + b"n NoSymbol Return\nkeycode 37 = Control_L NoSymbol Control_L\nkeycode 38 = a A a A ae AE ae\nkeycode 39 = s S" + b" s S U017F U1E9E U017F\nkeycode 40 = d D d D eth ETH eth\nkeycode 41 = f F f F dstroke ordfeminine dstroke\nke" + b"ycode 42 = g G g G eng ENG eng\nkeycode 43 = h H h H hstroke Hstroke hstroke\nkeycode 44 = j J j J dead_below" + b"dot dead_abovedot dead_belowdot\nkeycode 45 = k K k K kra ampersand kra\nkeycode 46 = l L l L lstroke Lstroke " + b"lstroke\nkeycode 47 = odiaeresis Odiaeresis odiaeresis Odiaeresis dead_doubleacute dead_belowdot dead_doubleacu" + b"te\nkeycode 48 = adiaeresis Adiaeresis adiaeresis Adiaeresis dead_circumflex dead_caron dead_circumflex\nkeycod" + b"e 49 = dead_circumflex degree dead_circumflex degree U2032 U2033 U2032\nkeycode 50 = Shift_L NoSymbol Shift_L" + b"\nkeycode 51 = numbersign apostrophe numbersign apostrophe rightsinglequotemark dead_breve rightsinglequotemark" + b"\nkeycode 52 = y Y y Y guillemotright U203A guillemotright\nkeycode 53 = x X x X guillemotleft U2039 guillemot" + b"left\nkeycode 54 = c C c C cent copyright cent\nkeycode 55 = v V v V doublelowquotemark singlelowquotemark dou" + b"blelowquotemark\nkeycode 56 = b B b B leftdoublequotemark leftsinglequotemark leftdoublequotemark\nkeycode 57 " + b"= n N n N rightdoublequotemark rightsinglequotemark rightdoublequotemark\nkeycode 58 = m M m M mu masculine mu" + b"\nkeycode 59 = comma semicolon comma semicolon periodcentered multiply periodcentered\nkeycode 60 = period col" + b"on period colon U2026 division U2026\nkeycode 61 = minus underscore minus underscore endash emdash endash\nkeyc" + b"ode 62 = Shift_R NoSymbol Shift_R\nkeycode 63 = KP_Multiply KP_Multiply KP_Multiply KP_Multiply KP_Multiply KP" + b"_Multiply XF86ClearGrab\nkeycode 64 = Alt_L Meta_L Alt_L Meta_L\nkeycode 65 = space NoSymbol space\nkeycode 6" + b"6 = Caps_Lock NoSymbol Caps_Lock\nkeycode 67 = F1 F1 F1 F1 F1 F1 XF86Switch_VT_1\nkeycode 68 = F2 F2 F2 F2 F2 " + b"F2 XF86Switch_VT_2\nkeycode 69 = F3 F3 F3 F3 F3 F3 XF86Switch_VT_3\nkeycode 70 = F4 F4 F4 F4 F4 F4 XF86Switch_" + b"VT_4\nkeycode 71 = F5 F5 F5 F5 F5 F5 XF86Switch_VT_5\nkeycode 72 = F6 F6 F6 F6 F6 F6 XF86Switch_VT_6\nkeycode " + b" 73 = F7 F7 F7 F7 F7 F7 XF86Switch_VT_7\nkeycode 74 = F8 F8 F8 F8 F8 F8 XF86Switch_VT_8\nkeycode 75 = F9 F9 F9" + b" F9 F9 F9 XF86Switch_VT_9\nkeycode 76 = F10 F10 F10 F10 F10 F10 XF86Switch_VT_10\nkeycode 77 = Num_Lock NoSymb" + b"ol Num_Lock\nkeycode 78 = Scroll_Lock NoSymbol Scroll_Lock\nkeycode 79 = KP_Home KP_7 KP_Home KP_7\nkeycode 8" + b"0 = KP_Up KP_8 KP_Up KP_8\nkeycode 81 = KP_Prior KP_9 KP_Prior KP_9\nkeycode 82 = KP_Subtract KP_Subtract KP_S" + b"ubtract KP_Subtract KP_Subtract KP_Subtract XF86Prev_VMode\nkeycode 83 = KP_Left KP_4 KP_Left KP_4\nkeycode 84" + b" = KP_Begin KP_5 KP_Begin KP_5\nkeycode 85 = KP_Right KP_6 KP_Right KP_6\nkeycode 86 = KP_Add KP_Add KP_Add KP" + b"_Add KP_Add KP_Add XF86Next_VMode\nkeycode 87 = KP_End KP_1 KP_End KP_1\nkeycode 88 = KP_Down KP_2 KP_Down KP_" + b"2\nkeycode 89 = KP_Next KP_3 KP_Next KP_3\nkeycode 90 = KP_Insert KP_0 KP_Insert KP_0\nkeycode 91 = KP_Delete" + b" KP_Separator KP_Delete KP_Separator\nkeycode 92 = ISO_Level3_Shift NoSymbol ISO_Level3_Shift\nkeycode 93 =\nk" + b"eycode 94 = less greater less greater bar dead_belowmacron bar\nkeycode 95 = F11 F11 F11 F11 F11 F11 XF86Switc" + b"h_VT_11\nkeycode 96 = F12 F12 F12 F12 F12 F12 XF86Switch_VT_12\nkeycode 97 =\nkeycode 98 = Katakana NoSymbol " + b"Katakana\nkeycode 99 = Hiragana NoSymbol Hiragana\nkeycode 100 = Henkan_Mode NoSymbol Henkan_Mode\nkeycode 101 " + b"= Hiragana_Katakana NoSymbol Hiragana_Katakana\nkeycode 102 = Muhenkan NoSymbol Muhenkan\nkeycode 103 =\nkeycode" + b" 104 = KP_Enter NoSymbol KP_Enter\nkeycode 105 = Control_R NoSymbol Control_R\nkeycode 106 = KP_Divide KP_Divide" + b" KP_Divide KP_Divide KP_Divide KP_Divide XF86Ungrab\nkeycode 107 = Print Sys_Req Print Sys_Req\nkeycode 108 = IS" + b"O_Level3_Shift NoSymbol ISO_Level3_Shift\nkeycode 109 = Linefeed NoSymbol Linefeed\nkeycode 110 = Home NoSymbol " + b"Home\nkeycode 111 = Up NoSymbol Up\nkeycode 112 = Prior NoSymbol Prior\nkeycode 113 = Left NoSymbol Left\nkeycod" + b"e 114 = Right NoSymbol Right\nkeycode 115 = End NoSymbol End\nkeycode 116 = Down NoSymbol Down\nkeycode 117 = Ne" + b"xt NoSymbol Next\nkeycode 118 = Insert NoSymbol Insert\nkeycode 119 = Delete NoSymbol Delete\nkeycode 120 =\nkey" + b"code 121 = XF86AudioMute NoSymbol XF86AudioMute\nkeycode 122 = XF86AudioLowerVolume NoSymbol XF86AudioLowerVolum" + b"e\nkeycode 123 = XF86AudioRaiseVolume NoSymbol XF86AudioRaiseVolume\nkeycode 124 = XF86PowerOff NoSymbol XF86Pow" + b"erOff\nkeycode 125 = KP_Equal NoSymbol KP_Equal\nkeycode 126 = plusminus NoSymbol plusminus\nkeycode 127 = Pause" + b" Break Pause Break\nkeycode 128 = XF86LaunchA NoSymbol XF86LaunchA\nkeycode 129 = KP_Decimal KP_Decimal KP_Decim" + b"al KP_Decimal\nkeycode 130 = Hangul NoSymbol Hangul\nkeycode 131 = Hangul_Hanja NoSymbol Hangul_Hanja\nkeycode 1" + b"32 =\nkeycode 133 = Super_L NoSymbol Super_L\nkeycode 134 = Super_R NoSymbol Super_R\nkeycode 135 = Menu NoSymbo" + b"l Menu\nkeycode 136 = Cancel NoSymbol Cancel\nkeycode 137 = Redo NoSymbol Redo\nkeycode 138 = SunProps NoSymbol " + b"SunProps\nkeycode 139 = Undo NoSymbol Undo\nkeycode 140 = SunFront NoSymbol SunFront\nkeycode 141 = XF86Copy NoS" + b"ymbol XF86Copy\nkeycode 142 = XF86Open NoSymbol XF86Open\nkeycode 143 = XF86Paste NoSymbol XF86Paste\nkeycode 14" + b"4 = Find NoSymbol Find\nkeycode 145 = XF86Cut NoSymbol XF86Cut\nkeycode 146 = Help NoSymbol Help\nkeycode 147 = " + b"XF86MenuKB NoSymbol XF86MenuKB\nkeycode 148 = XF86Calculator NoSymbol XF86Calculator\nkeycode 149 =\nkeycode 150" + b" = XF86Sleep NoSymbol XF86Sleep\nkeycode 151 = XF86WakeUp NoSymbol XF86WakeUp\nkeycode 152 = XF86Explorer NoSymb" + b"ol XF86Explorer\nkeycode 153 = XF86Send NoSymbol XF86Send\nkeycode 154 =\nkeycode 155 = XF86Xfer NoSymbol XF86Xf" + b"er\nkeycode 156 = XF86Launch1 NoSymbol XF86Launch1\nkeycode 157 = XF86Launch2 NoSymbol XF86Launch2\nkeycode 158 " + b"= XF86WWW NoSymbol XF86WWW\nkeycode 159 = XF86DOS NoSymbol XF86DOS\nkeycode 160 = XF86ScreenSaver NoSymbol XF86S" + b"creenSaver\nkeycode 161 = XF86RotateWindows NoSymbol XF86RotateWindows\nkeycode 162 = XF86TaskPane NoSymbol XF86" + b"TaskPane\nkeycode 163 = XF86Mail NoSymbol XF86Mail\nkeycode 164 = XF86Favorites NoSymbol XF86Favorites\nkeycode " + b"165 = XF86MyComputer NoSymbol XF86MyComputer\nkeycode 166 = XF86Back NoSymbol XF86Back\nkeycode 167 = XF86Forwar" + b"d NoSymbol XF86Forward\nkeycode 168 =\nkeycode 169 = XF86Eject NoSymbol XF86Eject\nkeycode 170 = XF86Eject XF86E" + b"ject XF86Eject XF86Eject\nkeycode 171 = XF86AudioNext NoSymbol XF86AudioNext\nkeycode 172 = XF86AudioPlay XF86Au" + b"dioPause XF86AudioPlay XF86AudioPause\nkeycode 173 = XF86AudioPrev NoSymbol XF86AudioPrev\nkeycode 174 = XF86Aud" + b"ioStop XF86Eject XF86AudioStop XF86Eject\nkeycode 175 = XF86AudioRecord NoSymbol XF86AudioRecord\nkeycode 176 = " + b"XF86AudioRewind NoSymbol XF86AudioRewind\nkeycode 177 = XF86Phone NoSymbol XF86Phone\nkeycode 178 =\nkeycode 179" + b" = XF86Tools NoSymbol XF86Tools\nkeycode 180 = XF86HomePage NoSymbol XF86HomePage\nkeycode 181 = XF86Reload NoSy" + b"mbol XF86Reload\nkeycode 182 = XF86Close NoSymbol XF86Close\nkeycode 183 =\nkeycode 184 =\nkeycode 185 = XF86Scr" + b"ollUp NoSymbol XF86ScrollUp\nkeycode 186 = XF86ScrollDown NoSymbol XF86ScrollDown\nkeycode 187 = parenleft NoSym" + b"bol parenleft\nkeycode 188 = parenright NoSymbol parenright\nkeycode 189 = XF86New NoSymbol XF86New\nkeycode 190" + b" = Redo NoSymbol Redo\nkeycode 191 = XF86Tools NoSymbol XF86Tools\nkeycode 192 = XF86Launch5 NoSymbol XF86Launch" + b"5\nkeycode 193 = XF86Launch6 NoSymbol XF86Launch6\nkeycode 194 = XF86Launch7 NoSymbol XF86Launch7\nkeycode 195 =" + b" XF86Launch8 NoSymbol XF86Launch8\nkeycode 196 = XF86Launch9 NoSymbol XF86Launch9\nkeycode 197 =\nkeycode 198 = " + b"XF86AudioMicMute NoSymbol XF86AudioMicMute\nkeycode 199 = XF86TouchpadToggle NoSymbol XF86TouchpadToggle\nkeycod" + b"e 200 = XF86TouchpadOn NoSymbol XF86TouchpadOn\nkeycode 201 = XF86TouchpadOff NoSymbol XF86TouchpadOff\nkeycode " + b"202 =\nkeycode 203 = Mode_switch NoSymbol Mode_switch\nkeycode 204 = NoSymbol Alt_L NoSymbol Alt_L\nkeycode 205 " + b"= NoSymbol Meta_L NoSymbol Meta_L\nkeycode 206 = NoSymbol Super_L NoSymbol Super_L\nkeycode 207 = NoSymbol Hyper" + b"_L NoSymbol Hyper_L\nkeycode 208 = XF86AudioPlay NoSymbol XF86AudioPlay\nkeycode 209 = XF86AudioPause NoSymbol X" + b"F86AudioPause\nkeycode 210 = XF86Launch3 NoSymbol XF86Launch3\nkeycode 211 = XF86Launch4 NoSymbol XF86Launch4\nk" + b"eycode 212 = XF86LaunchB NoSymbol XF86LaunchB\nkeycode 213 = XF86Suspend NoSymbol XF86Suspend\nkeycode 214 = XF8" + b"6Close NoSymbol XF86Close\nkeycode 215 = XF86AudioPlay NoSymbol XF86AudioPlay\nkeycode 216 = XF86AudioForward No" + b"Symbol XF86AudioForward\nkeycode 217 =\nkeycode 218 = Print NoSymbol Print\nkeycode 219 =\nkeycode 220 = XF86Web" + b"Cam NoSymbol XF86WebCam\nkeycode 221 = XF86AudioPreset NoSymbol XF86AudioPreset\nkeycode 222 =\nkeycode 223 = XF" + b"86Mail NoSymbol XF86Mail\nkeycode 224 = XF86Messenger NoSymbol XF86Messenger\nkeycode 225 = XF86Search NoSymbol " + b"XF86Search\nkeycode 226 = XF86Go NoSymbol XF86Go\nkeycode 227 = XF86Finance NoSymbol XF86Finance\nkeycode 228 = " + b"XF86Game NoSymbol XF86Game\nkeycode 229 = XF86Shop NoSymbol XF86Shop\nkeycode 230 =\nkeycode 231 = Cancel NoSymb" + b"ol Cancel\nkeycode 232 = XF86MonBrightnessDown NoSymbol XF86MonBrightnessDown\nkeycode 233 = XF86MonBrightnessUp" + b" NoSymbol XF86MonBrightnessUp\nkeycode 234 = XF86AudioMedia NoSymbol XF86AudioMedia\nkeycode 235 = XF86Display N" + b"oSymbol XF86Display\nkeycode 236 = XF86KbdLightOnOff NoSymbol XF86KbdLightOnOff\nkeycode 237 = XF86KbdBrightness" + b"Down NoSymbol XF86KbdBrightnessDown\nkeycode 238 = XF86KbdBrightnessUp NoSymbol XF86KbdBrightnessUp\nkeycode 239" + b" = XF86Send NoSymbol XF86Send\nkeycode 240 = XF86Reply NoSymbol XF86Reply\nkeycode 241 = XF86MailForward NoSymbo" + b"l XF86MailForward\nkeycode 242 = XF86Save NoSymbol XF86Save\nkeycode 243 = XF86Documents NoSymbol XF86Documents" + b"\nkeycode 244 = XF86Battery NoSymbol XF86Battery\nkeycode 245 = XF86Bluetooth NoSymbol XF86Bluetooth\nkeycode 24" + b"6 = XF86WLAN NoSymbol XF86WLAN\nkeycode 247 =\nkeycode 248 =\nkeycode 249 =\nkeycode 250 =\nkeycode 251 = XF86Mo" + b"nBrightnessCycle NoSymbol XF86MonBrightnessCycle\nkeycode 252 =\nkeycode 253 =\nkeycode 254 = XF86WWAN NoSymbol " + b"XF86WWAN\nkeycode 255 = XF86RFKill NoSymbol XF86RFKill\n" +) diff --git a/tests/system/__init__.py b/tests/system/__init__.py new file mode 100644 index 0000000..71782b9 --- /dev/null +++ b/tests/system/__init__.py @@ -0,0 +1,8 @@ +"""Tests that require linux system components to be running, that might not be.""" + +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") diff --git a/tests/system/gui/__init__.py b/tests/system/gui/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/system/gui/gui_test_base.py b/tests/system/gui/gui_test_base.py new file mode 100644 index 0000000..fde2600 --- /dev/null +++ b/tests/system/gui/gui_test_base.py @@ -0,0 +1,383 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + +import asyncio +import atexit +import multiprocessing +import os +import time +import unittest +from contextlib import contextmanager +from typing import Tuple, List, Optional +from unittest.mock import patch + +import evdev +import gi +import sys + +from inputremapper.injection.global_uinputs import GlobalUInputs, FrontendUInput, UInput +from inputremapper.injection.mapping_handlers.mapping_parser import MappingParser +from tests.lib.cleanup import cleanup +from tests.lib.constants import EVENT_READ_TIMEOUT +from tests.lib.fixtures import prepare_presets +from tests.lib.logger import logger + +gi.require_version("Gdk", "3.0") +gi.require_version("Gtk", "3.0") +gi.require_version("GtkSource", "4") +gi.require_version("GLib", "2.0") +from gi.repository import Gtk, GLib, GtkSource + +from inputremapper.configs.mapping import Mapping +from inputremapper.configs.global_config import GlobalConfig +from inputremapper.groups import _Groups +from inputremapper.gui.data_manager import DataManager +from inputremapper.gui.messages.message_broker import ( + MessageBroker, +) +from inputremapper.gui.components.editor import ( + MappingSelectionLabel, +) +from inputremapper.gui.controller import Controller +from inputremapper.gui.reader_service import ReaderService +from inputremapper.gui.utils import gtk_iteration +from inputremapper.gui.user_interface import UserInterface +from inputremapper.configs.input_config import InputCombination, InputConfig +from inputremapper.daemon import Daemon, DaemonProxy +from inputremapper.bin.input_remapper_gtk import InputRemapperGtkBin + + +# iterate a few times when Gtk.main() is called, but don't block +# there and just continue to the tests while the UI becomes +# unresponsive +Gtk.main = gtk_iteration + +# doesn't do much except avoid some Gtk assertion error, whatever: +Gtk.main_quit = lambda: None + + +def launch() -> Tuple[ + UserInterface, + Controller, + DataManager, + MessageBroker, + DaemonProxy, + GlobalConfig, +]: + """Start input-remapper-gtk.""" + with patch.object(sys, "argv", ["/usr/bin/input-remapper-gtk", "-d"]): + return_ = InputRemapperGtkBin.main() + + gtk_iteration() + # otherwise a new handler is added with each call to launch, which + # spams tons of garbage when all tests finish + atexit.unregister(InputRemapperGtkBin.stop) + return return_ + + +def start_reader_service(): + def process(): + global_uinputs = GlobalUInputs(FrontendUInput) + reader_service = ReaderService(_Groups(), global_uinputs) + loop = asyncio.new_event_loop() + loop.run_until_complete(reader_service.run()) + + multiprocessing.Process(target=process).start() + + +def os_system_patch(cmd, original_os_system=os.system): + # instead of running pkexec, fork instead. This will make + # the reader-service aware of all the test patches + if "pkexec input-remapper-control --command start-reader-service" in cmd: + logger.info("pkexec-patch starting ReaderService process") + start_reader_service() + return 0 + + return original_os_system(cmd) + + +@contextmanager +def patch_services(): + """Don't connect to the dbus and don't use pkexec to start the reader-service""" + + def bootstrap_daemon(): + # The daemon gets fresh instances of everything, because as far as I remember + # it runs in a separate process. + global_config = GlobalConfig() + global_uinputs = GlobalUInputs(UInput) + mapping_parser = MappingParser(global_uinputs) + + return Daemon( + global_config, + global_uinputs, + mapping_parser, + ) + + with ( + patch.object( + os, + "system", + os_system_patch, + ), + patch.object(Daemon, "connect", bootstrap_daemon), + ): + yield + + +def clean_up_gui_test(test): + logger.info("clean_up_gui_test") + test.controller.stop_injecting() + gtk_iteration() + test.user_interface.on_gtk_close() + test.user_interface.window.destroy() + gtk_iteration() + cleanup() + + # do this now, not when all tests are finished + test.daemon.stop_all() + if isinstance(test.daemon, Daemon): + atexit.unregister(test.daemon.stop_all) + + +class GtkKeyEvent: + def __init__(self, keyval): + self.keyval = keyval + + def get_keyval(self): + return True, self.keyval + + +@contextmanager +def patch_confirm_delete( + user_interface: UserInterface, + response=Gtk.ResponseType.ACCEPT, +): + original_create_dialog = user_interface._create_dialog + + def _create_dialog_patch(*args, **kwargs): + """A patch for the deletion confirmation that briefly shows the dialog.""" + confirm_cancel_dialog = original_create_dialog(*args, **kwargs) + + # the emitted signal causes the dialog to close + GLib.timeout_add( + 100, + lambda: confirm_cancel_dialog.emit("response", response), + ) + + # don't recursively call the patch + Gtk.MessageDialog.run(confirm_cancel_dialog) + + confirm_cancel_dialog.run = lambda: response + + return confirm_cancel_dialog + + with patch.object( + user_interface, + "_create_dialog", + _create_dialog_patch, + ): + # Tests are run during `yield` + yield + + +class GuiTestBase(unittest.TestCase): + def setUp(self): + prepare_presets() + with patch_services(): + ( + self.user_interface, + self.controller, + self.data_manager, + self.message_broker, + self.daemon, + self.global_config, + ) = launch() + + get = self.user_interface.get + self.device_selection: Gtk.FlowBox = get("device_selection") + self.preset_selection: Gtk.ComboBoxText = get("preset_selection") + self.selection_label_listbox: Gtk.ListBox = get("selection_label_listbox") + self.target_selection: Gtk.ComboBox = get("target-selector") + self.recording_toggle: Gtk.ToggleButton = get("key_recording_toggle") + self.recording_status: Gtk.ToggleButton = get("recording_status") + self.status_bar: Gtk.Statusbar = get("status_bar") + self.autoload_toggle: Gtk.Switch = get("preset_autoload_switch") + self.code_editor: GtkSource.View = get("code_editor") + self.output_box: GtkSource.View = get("output") + + self.delete_preset_btn: Gtk.Button = get("delete_preset") + self.copy_preset_btn: Gtk.Button = get("copy_preset") + self.create_preset_btn: Gtk.Button = get("create_preset") + self.start_injector_btn: Gtk.Button = get("apply_preset") + self.stop_injector_btn: Gtk.Button = get("stop_injection_preset_page") + self.rename_btn: Gtk.Button = get("rename-button") + self.rename_input: Gtk.Entry = get("preset_name_input") + self.create_mapping_btn: Gtk.Button = get("create_mapping_button") + self.delete_mapping_btn: Gtk.Button = get("delete-mapping") + + self._test_initial_state() + + self.grab_fails = False + + def grab(_): + if self.grab_fails: + raise OSError() + + evdev.InputDevice.grab = grab + + self.global_config._save_config() + + self.throttle(20) + + self.assertIsNotNone(self.data_manager.active_group) + self.assertIsNotNone(self.data_manager.active_preset) + + def tearDown(self): + clean_up_gui_test(self) + + # this is important, otherwise it keeps breaking things in the background + self.assertIsNone(self.data_manager._reader_client._read_timeout) + + self.throttle(20) + + def get_code_input(self): + buffer = self.code_editor.get_buffer() + return buffer.get_text( + buffer.get_start_iter(), + buffer.get_end_iter(), + True, + ) + + def _test_initial_state(self): + # make sure each test deals with the same initial state + self.assertEqual(self.controller.data_manager, self.data_manager) + self.assertEqual(self.data_manager.active_group.key, "Foo Device") + # if the modification-date from `prepare_presets` is not destroyed, preset3 + # should be selected as the newest one + self.assertEqual(self.data_manager.active_preset.name, "preset3") + self.assertEqual(self.data_manager.active_mapping.target_uinput, "keyboard") + self.assertEqual(self.target_selection.get_active_id(), "keyboard") + self.assertEqual( + self.data_manager.active_mapping.input_combination, + InputCombination([InputConfig(type=1, code=5)]), + ) + self.assertEqual( + self.data_manager.active_input_config, InputConfig(type=1, code=5) + ) + self.assertGreater( + len(self.user_interface.autocompletion._target_key_capabilities), 0 + ) + + def _callTestMethod(self, method): + """Retry all tests if they fail. + + GUI tests suddenly started to lag a lot and fail randomly, and even + though that improved drastically, sometimes they still do. + """ + attempts = 0 + while True: + attempts += 1 + try: + method() + break + except Exception as e: + if attempts == 2: + raise e + + # try again + print("Test failed, trying again...") + self.tearDown() + self.setUp() + + def throttle(self, time_=10): + """Give GTK some time in ms to process everything.""" + # tests suddenly started to freeze my computer up completely and tests started + # to fail. By using this (and by optimizing some redundant calls in the gui) it + # worked again. EDIT: Might have been caused by my broken/bloated ssd. I'll + # keep it in some places, since it did make the tests more reliable after all. + for _ in range(time_ // 2): + gtk_iteration() + time.sleep(0.002) + + def set_focus(self, widget): + logger.info("Focusing %s", widget) + + self.user_interface.window.set_focus(widget) + + self.throttle(20) + + def focus_source_view(self): + # despite the focus and gtk_iterations, gtk never runs the event handlers for + # the focus-in-event (_update_placeholder), which would clear the placeholder + # text. Remove it manually, it can't be helped. Fun fact: when the + # window gets destroyed, gtk runs the handler 10 times for good measure. + # Lost one hour of my life on GTK again. It's gone! Forever! Use qt next time. + source_view = self.code_editor + self.set_focus(source_view) + self.code_editor.get_buffer().set_text("") + return source_view + + def get_selection_labels(self) -> List[MappingSelectionLabel]: + return self.selection_label_listbox.get_children() + + def get_status_text(self): + status_bar = self.user_interface.get("status_bar") + return status_bar.get_message_area().get_children()[0].get_label() + + def get_unfiltered_symbol_input_text(self): + buffer = self.code_editor.get_buffer() + return buffer.get_text(buffer.get_start_iter(), buffer.get_end_iter(), True) + + def select_mapping(self, i: int): + """Select one of the mappings of a preset. + + Parameters + ---------- + i + if -1, will select the last row, + 0 will select the uppermost row. + 1 will select the second row, and so on + """ + selection_label = self.get_selection_labels()[i] + self.selection_label_listbox.select_row(selection_label) + logger.info( + 'Selecting mapping %s "%s"', + selection_label.combination, + selection_label.name, + ) + gtk_iteration() + return selection_label + + def add_mapping(self, mapping: Optional[Mapping] = None): + self.controller.create_mapping() + self.controller.load_mapping(InputCombination.empty_combination()) + gtk_iteration() + if mapping: + self.controller.update_mapping(**mapping.dict(exclude_defaults=True)) + gtk_iteration() + + def sleep(self, num_events): + for _ in range(num_events * 2): + time.sleep(EVENT_READ_TIMEOUT) + gtk_iteration() + + time.sleep(1 / 30) # one window iteration + + gtk_iteration() diff --git a/tests/system/gui/test_autocompletion.py b/tests/system/gui/test_autocompletion.py new file mode 100644 index 0000000..dbef04e --- /dev/null +++ b/tests/system/gui/test_autocompletion.py @@ -0,0 +1,259 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + +import time +import unittest + +import gi + +from inputremapper.gui.autocompletion import ( + get_incomplete_parameter, + get_incomplete_function_name, +) + +gi.require_version("Gdk", "3.0") +gi.require_version("Gtk", "3.0") +gi.require_version("GtkSource", "4") +gi.require_version("GLib", "2.0") +from gi.repository import Gtk, Gdk + +from inputremapper.configs.keyboard_layout import keyboard_layout +from inputremapper.gui.utils import gtk_iteration + +from tests.lib.test_setup import test_setup +from tests.system.gui.gui_test_base import GuiTestBase + + +@test_setup +class TestAutocompletion(GuiTestBase): + def press_key(self, keyval): + event = Gdk.EventKey() + event.keyval = keyval + self.user_interface.autocompletion.navigate(None, event) + + def get_suggestions(self, autocompletion): + return [ + row.get_children()[0].get_text() + for row in autocompletion.list_box.get_children() + ] + + def test_get_incomplete_parameter(self): + def test(text, expected): + text_view = Gtk.TextView() + Gtk.TextView.do_insert_at_cursor(text_view, text) + text_iter = text_view.get_iter_at_location(0, 0)[1] + text_iter.set_offset(len(text)) + self.assertEqual(get_incomplete_parameter(text_iter), expected) + + test("bar(foo", "foo") + test("bar(a=foo", "foo") + test("bar(qux, foo", "foo") + test("foo", "foo") + test("bar + foo", "foo") + + def test_get_incomplete_function_name(self): + def test(text, expected): + text_view = Gtk.TextView() + Gtk.TextView.do_insert_at_cursor(text_view, text) + text_iter = text_view.get_iter_at_location(0, 0)[1] + text_iter.set_offset(len(text)) + self.assertEqual(get_incomplete_function_name(text_iter), expected) + + test("bar().foo", "foo") + test("bar()\n.foo", "foo") + test("bar().\nfoo", "foo") + test("bar(\nfoo", "foo") + test("bar(\nqux=foo", "foo") + test("bar(KEY_A,\nfoo", "foo") + test("foo", "foo") + + def test_autocomplete_names(self): + autocompletion = self.user_interface.autocompletion + + def setup(text): + self.set_focus(self.code_editor) + self.code_editor.get_buffer().set_text("") + Gtk.TextView.do_insert_at_cursor(self.code_editor, text) + self.throttle(200) + text_iter = self.code_editor.get_iter_at_location(0, 0)[1] + text_iter.set_offset(len(text)) + + setup("disa") + self.assertNotIn("KEY_A", self.get_suggestions(autocompletion)) + self.assertIn("disable", self.get_suggestions(autocompletion)) + + setup(" + _A") + self.assertIn("KEY_A", self.get_suggestions(autocompletion)) + self.assertNotIn("disable", self.get_suggestions(autocompletion)) + + def test_autocomplete_key(self): + self.controller.update_mapping(output_symbol="") + gtk_iteration() + + self.set_focus(self.code_editor) + self.code_editor.get_buffer().set_text("") + + complete_key_name = "Test_Foo_Bar" + + keyboard_layout.clear() + keyboard_layout._set(complete_key_name, 1) + keyboard_layout._set("KEY_A", 30) # we need this for the UIMapping to work + + # it can autocomplete a combination inbetween other things + incomplete = "qux_1\n + + qux_2" + Gtk.TextView.do_insert_at_cursor(self.code_editor, incomplete) + Gtk.TextView.do_move_cursor( + self.code_editor, + Gtk.MovementStep.VISUAL_POSITIONS, + -8, + False, + ) + + Gtk.TextView.do_insert_at_cursor(self.code_editor, "foo") + self.throttle(200) + gtk_iteration() + + autocompletion = self.user_interface.autocompletion + self.assertTrue(autocompletion.visible) + + self.press_key(Gdk.KEY_Down) + self.press_key(Gdk.KEY_Return) + self.throttle(200) + gtk_iteration() + + # the first suggestion should have been selected + + modified_symbol = self.get_code_input() + self.assertEqual(modified_symbol, f"qux_1\n + {complete_key_name} + qux_2") + + # try again, but a whitespace completes the word and so no autocompletion + # should be shown + Gtk.TextView.do_insert_at_cursor(self.code_editor, " + foo ") + + time.sleep(0.11) + gtk_iteration() + + self.assertFalse(autocompletion.visible) + + def test_autocomplete_function(self): + self.controller.update_mapping(output_symbol="") + gtk_iteration() + + source_view = self.focus_source_view() + + incomplete = "key(KEY_A).\nepea" + Gtk.TextView.do_insert_at_cursor(source_view, incomplete) + + time.sleep(0.11) + gtk_iteration() + + autocompletion = self.user_interface.autocompletion + self.assertTrue(autocompletion.visible) + + self.press_key(Gdk.KEY_Down) + self.press_key(Gdk.KEY_Return) + + # the first suggestion should have been selected + modified_symbol = self.get_code_input() + self.assertEqual(modified_symbol, "key(KEY_A).\nrepeat") + + def test_close_autocompletion(self): + self.controller.update_mapping(output_symbol="") + gtk_iteration() + + source_view = self.focus_source_view() + + Gtk.TextView.do_insert_at_cursor(source_view, "KEY_") + + time.sleep(0.11) + gtk_iteration() + + autocompletion = self.user_interface.autocompletion + self.assertTrue(autocompletion.visible) + + self.press_key(Gdk.KEY_Down) + self.press_key(Gdk.KEY_Escape) + + self.assertFalse(autocompletion.visible) + + symbol = self.get_code_input() + self.assertEqual(symbol, "KEY_") + + def test_writing_still_works(self): + self.controller.update_mapping(output_symbol="") + gtk_iteration() + source_view = self.focus_source_view() + + Gtk.TextView.do_insert_at_cursor(source_view, "KEY_") + + autocompletion = self.user_interface.autocompletion + + time.sleep(0.11) + gtk_iteration() + self.assertTrue(autocompletion.visible) + + # writing still works while an entry is selected + self.press_key(Gdk.KEY_Down) + + Gtk.TextView.do_insert_at_cursor(source_view, "A") + + time.sleep(0.11) + gtk_iteration() + self.assertTrue(autocompletion.visible) + + Gtk.TextView.do_insert_at_cursor(source_view, "1234foobar") + + time.sleep(0.11) + gtk_iteration() + # no key matches this completion, so it closes again + self.assertFalse(autocompletion.visible) + + def test_cycling(self): + self.controller.update_mapping(output_symbol="") + gtk_iteration() + source_view = self.focus_source_view() + + Gtk.TextView.do_insert_at_cursor(source_view, "KEY_") + + autocompletion = self.user_interface.autocompletion + + time.sleep(0.11) + gtk_iteration() + self.assertTrue(autocompletion.visible) + + self.assertEqual( + autocompletion.scrolled_window.get_vadjustment().get_value(), 0 + ) + + # cycle to the end of the list because there is no element higher than index 0 + self.press_key(Gdk.KEY_Up) + self.assertGreater( + autocompletion.scrolled_window.get_vadjustment().get_value(), 0 + ) + + # go back to the start, because it can't go down further + self.press_key(Gdk.KEY_Down) + self.assertEqual( + autocompletion.scrolled_window.get_vadjustment().get_value(), 0 + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/system/gui/test_colors.py b/tests/system/gui/test_colors.py new file mode 100644 index 0000000..f591297 --- /dev/null +++ b/tests/system/gui/test_colors.py @@ -0,0 +1,89 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + +import unittest + +import gi + + +gi.require_version("Gdk", "3.0") +gi.require_version("Gtk", "3.0") +gi.require_version("GtkSource", "4") +gi.require_version("GLib", "2.0") +from gi.repository import Gdk + +from inputremapper.gui.utils import Colors + +from tests.lib.test_setup import test_setup +from tests.system.gui.gui_test_base import GuiTestBase + + +@test_setup +class TestColors(GuiTestBase): + # requires a running ui, otherwise fails with segmentation faults + def test_get_color_falls_back(self): + fallback = Gdk.RGBA(0, 0.5, 1, 0.8) + + color = Colors.get_color(["doesnt_exist_1234"], fallback) + + self.assertIsInstance(color, Gdk.RGBA) + self.assertAlmostEqual(color.red, fallback.red, delta=0.01) + self.assertAlmostEqual(color.green, fallback.green, delta=0.01) + self.assertAlmostEqual(color.blue, fallback.blue, delta=0.01) + self.assertAlmostEqual(color.alpha, fallback.alpha, delta=0.01) + + def test_get_color_works(self): + fallback = Gdk.RGBA(1, 0, 1, 0.1) + + color = Colors.get_color( + ["accent_bg_color", "theme_selected_bg_color"], fallback + ) + + self.assertIsInstance(color, Gdk.RGBA) + self.assertNotAlmostEqual(color.red, fallback.red, delta=0.01) + self.assertNotAlmostEqual(color.green, fallback.blue, delta=0.01) + self.assertNotAlmostEqual(color.blue, fallback.green, delta=0.01) + self.assertNotAlmostEqual(color.alpha, fallback.alpha, delta=0.01) + + def _test_color_wont_fallback(self, get_color, fallback): + color = get_color() + self.assertIsInstance(color, Gdk.RGBA) + if ( + (abs(color.green - fallback.green) < 0.01) + and (abs(color.red - fallback.red) < 0.01) + and (abs(color.blue - fallback.blue) < 0.01) + and (abs(color.alpha - fallback.alpha) < 0.01) + ): + raise AssertionError( + f"Color {color.to_string()} is similar to {fallback.toString()}" + ) + + def test_get_colors(self): + self._test_color_wont_fallback(Colors.get_accent_color, Colors.fallback_accent) + self._test_color_wont_fallback(Colors.get_border_color, Colors.fallback_border) + self._test_color_wont_fallback( + Colors.get_background_color, Colors.fallback_background + ) + self._test_color_wont_fallback(Colors.get_base_color, Colors.fallback_base) + self._test_color_wont_fallback(Colors.get_font_color, Colors.fallback_font) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/system/gui/test_components.py b/tests/system/gui/test_components.py new file mode 100644 index 0000000..f606698 --- /dev/null +++ b/tests/system/gui/test_components.py @@ -0,0 +1,1925 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + +import time +import unittest +from typing import Optional, Tuple, Union +from unittest.mock import MagicMock, call + +import evdev +import gi +from evdev.ecodes import KEY_A, KEY_B, KEY_C + +gi.require_version("Gdk", "3.0") +gi.require_version("Gtk", "3.0") +gi.require_version("GLib", "2.0") +gi.require_version("GtkSource", "4") +from gi.repository import Gtk, GLib, GtkSource, Gdk + +from tests.lib.spy import spy +from tests.lib.logger import logger + +from inputremapper.gui.controller import Controller +from inputremapper.configs.keyboard_layout import XKB_KEYCODE_OFFSET +from inputremapper.gui.utils import CTX_ERROR, CTX_WARNING, gtk_iteration +from inputremapper.gui.messages.message_broker import ( + MessageBroker, + MessageType, +) +from inputremapper.gui.messages.message_data import ( + UInputsData, + GroupsData, + GroupData, + PresetData, + StatusData, + CombinationUpdate, + DoStackSwitch, +) +from inputremapper.groups import DeviceType +from inputremapper.gui.components.editor import ( + TargetSelection, + MappingListBox, + MappingSelectionLabel, + CodeEditor, + RecordingToggle, + AutoloadSwitch, + ReleaseCombinationSwitch, + CombinationListbox, + InputConfigEntry, + AnalogInputSwitch, + TriggerThresholdInput, + ReleaseTimeoutInput, + OutputAxisSelector, + KeyAxisStackSwitcher, + Sliders, + TransformationDrawArea, + RelativeInputCutoffInput, + RecordingStatus, + RequireActiveMapping, + GdkEventRecorder, +) +from inputremapper.gui.components.main import Stack, StatusBar +from inputremapper.gui.components.common import FlowBoxEntry, Breadcrumbs +from inputremapper.gui.components.presets import PresetSelection +from inputremapper.gui.components.device_groups import ( + DeviceGroupEntry, + DeviceGroupSelection, +) +from inputremapper.configs.mapping import MappingData +from inputremapper.configs.input_config import InputCombination, InputConfig +from tests.lib.test_setup import test_setup + + +class ComponentBaseTest(unittest.TestCase): + """Test a gui component.""" + + def setUp(self) -> None: + self.message_broker = MessageBroker() + self.controller_mock: Controller = MagicMock() + + def destroy_all_member_widgets(self): + # destroy all Gtk Widgets that are stored in self + # TODO why is this necessary? + for attribute in dir(self): + stuff = getattr(self, attribute, None) + if isinstance(stuff, Gtk.Widget): + logger.info('destroying member "%s" %s', attribute, stuff) + GLib.timeout_add(0, stuff.destroy) + setattr(self, attribute, None) + + def tearDown(self) -> None: + super().tearDown() + self.message_broker.signal(MessageType.terminate) + + # Shut down the gui properly + self.destroy_all_member_widgets() + GLib.timeout_add(0, Gtk.main_quit) + + # Gtk.main() will start the Gtk event loop and process all pending events. + # So the gui will do whatever is queued up this ensures that the next tests + # starts without pending events. + Gtk.main() + + +class FlowBoxTestUtils: + """Methods to test the FlowBoxes that contain presets and devices. + + Those are only used in tests, so I moved them here instead. + """ + + @staticmethod + def set_active(flow_box: Gtk.FlowBox, name: str): + """Change the currently selected group.""" + for child in flow_box.get_children(): + flow_box_entry: FlowBoxEntry = child.get_children()[0] + flow_box_entry.set_active(flow_box_entry.name == name) + + @staticmethod + def get_active_entry(flow_box: Gtk.FlowBox) -> Union[DeviceGroupEntry, None]: + """Find the currently selected DeviceGroupEntry.""" + children = flow_box.get_children() + + if len(children) == 0: + return None + + for child in children: + flow_box_entry: FlowBoxEntry = child.get_children()[0] + + if flow_box_entry.get_active(): + return flow_box_entry + + raise AssertionError("Expected one entry to be selected.") + + @staticmethod + def get_child_names(flow_box: Gtk.FlowBox): + names = [] + for child in flow_box.get_children(): + flow_box_entry: FlowBoxEntry = child.get_children()[0] + names.append(flow_box_entry.name) + + return names + + @staticmethod + def get_child_icons(flow_box: Gtk.FlowBox): + icon_names = [] + for child in flow_box.get_children(): + flow_box_entry: FlowBoxEntry = child.get_children()[0] + icon_names.append(flow_box_entry.icon_name) + + return icon_names + + +@test_setup +class TestDeviceGroupSelection(ComponentBaseTest): + def setUp(self) -> None: + super().setUp() + self.gui = Gtk.FlowBox() + self.selection = DeviceGroupSelection( + self.message_broker, + self.controller_mock, + self.gui, + ) + self.message_broker.publish( + GroupsData( + { + "foo": [DeviceType.GAMEPAD, DeviceType.KEYBOARD], + "bar": [], + "baz": [DeviceType.GRAPHICS_TABLET], + } + ) + ) + + def get_displayed_group_keys_and_icons(self): + """Get a list of all group_keys and icons of the displayed groups.""" + group_keys = [] + icons = [] + for child in self.gui.get_children(): + device_group_entry = child.get_children()[0] + group_keys.append(device_group_entry.group_key) + icons.append(device_group_entry.icon_name) + + return group_keys, icons + + def test_populates_devices(self): + # tests that all devices sent via the broker end up in the gui + group_keys, icons = self.get_displayed_group_keys_and_icons() + self.assertEqual(group_keys, ["foo", "bar", "baz"]) + self.assertEqual(icons, ["input-gaming", None, "input-tablet"]) + + self.message_broker.publish( + GroupsData( + { + "kuu": [DeviceType.KEYBOARD], + "qux": [DeviceType.GAMEPAD], + } + ) + ) + + group_keys, icons = self.get_displayed_group_keys_and_icons() + self.assertEqual(group_keys, ["kuu", "qux"]) + self.assertEqual(icons, ["input-keyboard", "input-gaming"]) + + def test_selects_correct_device(self): + self.message_broker.publish(GroupData("bar", ())) + self.assertEqual(FlowBoxTestUtils.get_active_entry(self.gui).group_key, "bar") + self.message_broker.publish(GroupData("baz", ())) + self.assertEqual(FlowBoxTestUtils.get_active_entry(self.gui).group_key, "baz") + + def test_loads_group(self): + FlowBoxTestUtils.set_active(self.gui, "bar") + self.controller_mock.load_group.assert_called_once_with("bar") + + def test_avoids_infinite_recursion(self): + self.message_broker.publish(GroupData("bar", ())) + self.controller_mock.load_group.assert_not_called() + + +@test_setup +class TestTargetSelection(ComponentBaseTest): + def setUp(self) -> None: + super().setUp() + self.gui = Gtk.ComboBox() + self.selection = TargetSelection( + self.message_broker, self.controller_mock, self.gui + ) + self.message_broker.publish( + UInputsData( + { + "foo": {}, + "bar": {}, + "baz": {}, + } + ) + ) + + def test_populates_devices(self): + names = [row[0] for row in self.gui.get_model()] + self.assertEqual(names, ["foo", "bar", "baz"]) + + self.message_broker.publish( + UInputsData( + { + "kuu": {}, + "qux": {}, + } + ) + ) + names = [row[0] for row in self.gui.get_model()] + self.assertEqual(names, ["kuu", "qux"]) + + def test_updates_mapping(self): + self.gui.set_active_id("baz") + self.controller_mock.update_mapping.assert_called_once_with(target_uinput="baz") + + def test_selects_correct_target(self): + self.message_broker.publish(MappingData(target_uinput="baz")) + self.assertEqual(self.gui.get_active_id(), "baz") + self.message_broker.publish(MappingData(target_uinput="bar")) + self.assertEqual(self.gui.get_active_id(), "bar") + + def test_avoids_infinite_recursion(self): + self.message_broker.publish(MappingData(target_uinput="baz")) + self.controller_mock.update_mapping.assert_not_called() + + +@test_setup +class TestPresetSelection(ComponentBaseTest): + def setUp(self) -> None: + super().setUp() + self.gui = Gtk.FlowBox() + self.selection = PresetSelection( + self.message_broker, self.controller_mock, self.gui + ) + self.message_broker.publish(GroupData("foo", ("preset1", "preset2"))) + + def test_populates_presets(self): + names = FlowBoxTestUtils.get_child_names(self.gui) + self.assertEqual(names, ["preset1", "preset2"]) + + self.message_broker.publish(GroupData("foo", ("preset3", "preset4"))) + names = FlowBoxTestUtils.get_child_names(self.gui) + self.assertEqual(names, ["preset3", "preset4"]) + + def test_selects_preset(self): + self.message_broker.publish( + PresetData( + "preset2", + ( + MappingData( + name="m1", + input_combination=InputCombination( + [InputConfig(type=1, code=2)] + ), + ), + ), + ) + ) + self.assertEqual(FlowBoxTestUtils.get_active_entry(self.gui).name, "preset2") + + self.message_broker.publish( + PresetData( + "preset1", + ( + MappingData( + name="m1", + input_combination=InputCombination( + [InputConfig(type=1, code=2)] + ), + ), + ), + ) + ) + self.assertEqual(FlowBoxTestUtils.get_active_entry(self.gui).name, "preset1") + + def test_avoids_infinite_recursion(self): + self.message_broker.publish( + PresetData( + "preset2", + ( + MappingData( + name="m1", + input_combination=InputCombination( + [InputConfig(type=1, code=2)] + ), + ), + ), + ) + ) + self.controller_mock.load_preset.assert_not_called() + + def test_loads_preset(self): + FlowBoxTestUtils.set_active(self.gui, "preset2") + self.controller_mock.load_preset.assert_called_once_with("preset2") + + +@test_setup +class TestMappingListbox(ComponentBaseTest): + def setUp(self) -> None: + super().setUp() + self.gui = Gtk.ListBox() + self.listbox = MappingListBox( + self.message_broker, self.controller_mock, self.gui + ) + + self.message_broker.publish( + PresetData( + "preset1", + ( + MappingData( + name="mapping1", + input_combination=InputCombination( + [InputConfig(type=1, code=KEY_C)] + ), + ), + MappingData( + name="", + input_combination=InputCombination( + [ + InputConfig(type=1, code=KEY_A), + InputConfig(type=1, code=KEY_B), + ] + ), + ), + MappingData( + name="mapping2", + input_combination=InputCombination( + [InputConfig(type=1, code=KEY_B)] + ), + ), + ), + ) + ) + + def get_selected_row(self) -> MappingSelectionLabel: + for label in self.gui.get_children(): + if label.is_selected(): + return label + + raise Exception("Expected one MappingSelectionLabel to be selected") + + def select_row(self, combination: InputCombination): + def select(label_: MappingSelectionLabel): + if label_.combination == combination: + self.gui.select_row(label_) + + for label in self.gui.get_children(): + select(label) + + def test_populates_listbox(self): + labels = {row.name for row in self.gui.get_children()} + self.assertEqual(labels, {"mapping1", "mapping2", "a + b"}) + + def test_alphanumerically_sorted(self): + labels = [row.name for row in self.gui.get_children()] + self.assertEqual(labels, ["a + b", "mapping1", "mapping2"]) + + def test_activates_correct_row(self): + self.message_broker.publish( + MappingData( + name="mapping1", + input_combination=InputCombination([InputConfig(type=1, code=KEY_C)]), + ) + ) + selected = self.get_selected_row() + self.assertEqual(selected.name, "mapping1") + self.assertEqual( + selected.combination, + InputCombination([InputConfig(type=1, code=KEY_C)]), + ) + + def test_loads_mapping(self): + self.select_row(InputCombination([InputConfig(type=1, code=KEY_B)])) + self.controller_mock.load_mapping.assert_called_once_with( + InputCombination([InputConfig(type=1, code=KEY_B)]) + ) + + def test_avoids_infinite_recursion(self): + self.message_broker.publish( + MappingData( + name="mapping1", + input_combination=InputCombination([InputConfig(type=1, code=KEY_C)]), + ) + ) + self.controller_mock.load_mapping.assert_not_called() + + def test_sorts_empty_mapping_to_bottom(self): + self.message_broker.publish( + PresetData( + "preset1", + ( + MappingData( + name="qux", + input_combination=InputCombination( + [InputConfig(type=1, code=KEY_C)] + ), + ), + MappingData( + name="foo", + input_combination=InputCombination.empty_combination(), + ), + MappingData( + name="bar", + input_combination=InputCombination( + [InputConfig(type=1, code=KEY_B)] + ), + ), + ), + ) + ) + bottom_row: MappingSelectionLabel = self.gui.get_row_at_index(2) + self.assertEqual(bottom_row.combination, InputCombination.empty_combination()) + self.message_broker.publish( + PresetData( + "preset1", + ( + MappingData( + name="foo", + input_combination=InputCombination.empty_combination(), + ), + MappingData( + name="qux", + input_combination=InputCombination( + [InputConfig(type=1, code=KEY_C)] + ), + ), + MappingData( + name="bar", + input_combination=InputCombination( + [InputConfig(type=1, code=KEY_B)] + ), + ), + ), + ) + ) + bottom_row: MappingSelectionLabel = self.gui.get_row_at_index(2) + self.assertEqual(bottom_row.combination, InputCombination.empty_combination()) + + +@test_setup +class TestMappingSelectionLabel(ComponentBaseTest): + def setUp(self) -> None: + super().setUp() + self.gui = Gtk.ListBox() + self.mapping_selection_label = MappingSelectionLabel( + self.message_broker, + self.controller_mock, + "", + InputCombination( + [ + InputConfig(type=1, code=KEY_A), + InputConfig(type=1, code=KEY_B), + ] + ), + ) + self.gui.insert(self.mapping_selection_label, -1) + + def assert_edit_mode(self): + self.assertTrue(self.mapping_selection_label.name_input.get_visible()) + self.assertFalse(self.mapping_selection_label.label.get_visible()) + + def assert_selected(self): + self.assertTrue(self.mapping_selection_label.label.get_visible()) + self.assertFalse(self.mapping_selection_label.name_input.get_visible()) + + def test_repr(self): + self.mapping_selection_label.name = "name" + self.assertIn("name", repr(self.mapping_selection_label)) + self.assertIn("KEY_A", repr(self.mapping_selection_label)) + self.assertIn("KEY_B", repr(self.mapping_selection_label)) + + def test_shows_combination_without_name(self): + self.assertEqual(self.mapping_selection_label.label.get_label(), "a + b") + + def test_shows_name_when_given(self): + self.gui = MappingSelectionLabel( + self.message_broker, + self.controller_mock, + "foo", + InputCombination( + ( + InputConfig(type=1, code=KEY_A), + InputConfig(type=1, code=KEY_B), + ) + ), + ) + self.assertEqual(self.gui.label.get_label(), "foo") + + def test_updates_combination_when_selected(self): + self.gui.select_row(self.mapping_selection_label) + self.assertEqual( + self.mapping_selection_label.combination, + InputCombination( + ( + InputConfig(type=1, code=KEY_A), + InputConfig(type=1, code=KEY_B), + ) + ), + ) + self.message_broker.publish( + CombinationUpdate( + InputCombination( + ( + InputConfig(type=1, code=KEY_A), + InputConfig(type=1, code=KEY_B), + ) + ), + InputCombination([InputConfig(type=1, code=KEY_A)]), + ) + ) + self.assertEqual( + self.mapping_selection_label.combination, + InputCombination([InputConfig(type=1, code=KEY_A)]), + ) + + def test_doesnt_update_combination_when_not_selected(self): + self.assertEqual( + self.mapping_selection_label.combination, + InputCombination( + ( + InputConfig(type=1, code=KEY_A), + InputConfig(type=1, code=KEY_B), + ) + ), + ) + self.message_broker.publish( + CombinationUpdate( + InputCombination( + ( + InputConfig(type=1, code=KEY_A), + InputConfig(type=1, code=KEY_B), + ) + ), + InputCombination([InputConfig(type=1, code=KEY_A)]), + ) + ) + self.assertEqual( + self.mapping_selection_label.combination, + InputCombination( + ( + InputConfig(type=1, code=KEY_A), + InputConfig(type=1, code=KEY_B), + ) + ), + ) + + def test_updates_name_when_mapping_changed_and_combination_matches(self): + self.message_broker.publish( + MappingData( + input_combination=InputCombination( + ( + InputConfig(type=1, code=KEY_A), + InputConfig(type=1, code=KEY_B), + ) + ), + name="foo", + ) + ) + self.assertEqual(self.mapping_selection_label.label.get_label(), "foo") + + def test_ignores_mapping_when_combination_does_not_match(self): + self.message_broker.publish( + MappingData( + input_combination=InputCombination( + ( + InputConfig(type=1, code=KEY_A), + InputConfig(type=1, code=KEY_C), + ) + ), + name="foo", + ) + ) + self.assertEqual(self.mapping_selection_label.label.get_label(), "a + b") + + def test_edit_button_visibility(self): + # start off invisible + self.assertFalse(self.mapping_selection_label.edit_btn.get_visible()) + + # load the mapping associated with the ListBoxRow + self.message_broker.publish( + MappingData( + input_combination=InputCombination( + ( + InputConfig(type=1, code=KEY_A), + InputConfig(type=1, code=KEY_B), + ) + ), + ) + ) + self.assertTrue(self.mapping_selection_label.edit_btn.get_visible()) + + # load a different row + self.message_broker.publish( + MappingData( + input_combination=InputCombination( + ( + InputConfig(type=1, code=KEY_A), + InputConfig(type=1, code=KEY_C), + ) + ), + ) + ) + self.assertFalse(self.mapping_selection_label.edit_btn.get_visible()) + + def test_enter_edit_mode_focuses_name_input(self): + self.message_broker.publish( + MappingData( + input_combination=InputCombination( + ( + InputConfig(type=1, code=KEY_A), + InputConfig(type=1, code=KEY_B), + ) + ), + ) + ) + self.mapping_selection_label.edit_btn.clicked() + self.controller_mock.set_focus.assert_called_once_with( + self.mapping_selection_label.name_input + ) + + def test_enter_edit_mode_updates_visibility(self): + self.message_broker.publish( + MappingData( + input_combination=InputCombination( + ( + InputConfig(type=1, code=KEY_A), + InputConfig(type=1, code=KEY_B), + ) + ), + ) + ) + self.assert_selected() + self.mapping_selection_label.edit_btn.clicked() + self.assert_edit_mode() + self.mapping_selection_label.name_input.activate() # aka hit the return key + self.assert_selected() + + def test_leaves_edit_mode_on_esc(self): + self.message_broker.publish( + MappingData( + input_combination=InputCombination( + ( + InputConfig(type=1, code=KEY_A), + InputConfig(type=1, code=KEY_B), + ) + ), + ) + ) + self.mapping_selection_label.edit_btn.clicked() + self.assert_edit_mode() + self.mapping_selection_label.name_input.set_text("foo") + + event = Gdk.Event() + event.key.keyval = Gdk.KEY_Escape + # send the "key-press-event" + self.mapping_selection_label._on_gtk_rename_abort(None, event.key) + self.assert_selected() + self.assertEqual(self.mapping_selection_label.label.get_text(), "a + b") + self.controller_mock.update_mapping.assert_not_called() + + def test_update_name(self): + self.message_broker.publish( + MappingData( + input_combination=InputCombination( + ( + InputConfig(type=1, code=KEY_A), + InputConfig(type=1, code=KEY_B), + ) + ), + ) + ) + self.mapping_selection_label.edit_btn.clicked() + + self.mapping_selection_label.name_input.set_text("foo") + self.mapping_selection_label.name_input.activate() + self.controller_mock.update_mapping.assert_called_once_with(name="foo") + + def test_name_input_contains_combination_when_name_not_set(self): + self.message_broker.publish( + MappingData( + input_combination=InputCombination( + ( + InputConfig(type=1, code=KEY_A), + InputConfig(type=1, code=KEY_B), + ) + ), + ) + ) + self.mapping_selection_label.edit_btn.clicked() + self.assertEqual(self.mapping_selection_label.name_input.get_text(), "a + b") + + def test_name_input_contains_name(self): + self.message_broker.publish( + MappingData( + input_combination=InputCombination( + ( + InputConfig(type=1, code=KEY_A), + InputConfig(type=1, code=KEY_B), + ) + ), + name="foo", + ) + ) + self.mapping_selection_label.edit_btn.clicked() + self.assertEqual(self.mapping_selection_label.name_input.get_text(), "foo") + + def test_removes_name_when_name_matches_combination(self): + self.message_broker.publish( + MappingData( + input_combination=InputCombination( + ( + InputConfig(type=1, code=KEY_A), + InputConfig(type=1, code=KEY_B), + ) + ), + name="foo", + ) + ) + self.mapping_selection_label.edit_btn.clicked() + self.mapping_selection_label.name_input.set_text("a + b") + self.mapping_selection_label.name_input.activate() + self.controller_mock.update_mapping.assert_called_once_with(name="") + + +@test_setup +class TestGdkEventRecorder(ComponentBaseTest): + def _emit_key(self, window, code, type_): + event = Gdk.Event() + event.type = type_ + event.hardware_keycode = code + XKB_KEYCODE_OFFSET + window.emit("event", event) + gtk_iteration() + + def _emit_button(self, window, button, type_): + event = Gdk.Event() + event.type = type_ + event.button = button + window.emit("event", event) + gtk_iteration() + + def test_records_combinations(self): + label = Gtk.Label() + window = Gtk.Window() + GdkEventRecorder(window, label) + + self._emit_key(window, KEY_A, Gdk.EventType.KEY_PRESS) + self._emit_key(window, KEY_B, Gdk.EventType.KEY_PRESS) + self.assertEqual(label.get_text(), "a + b") + + self._emit_key(window, KEY_A, Gdk.EventType.KEY_RELEASE) + self._emit_key(window, KEY_B, Gdk.EventType.KEY_RELEASE) + self.assertEqual(label.get_text(), "a + b") + + self._emit_key(window, KEY_C, Gdk.EventType.KEY_PRESS) + self.assertEqual(label.get_text(), "c") + + # buttons + self._emit_button(window, Gdk.BUTTON_PRIMARY, Gdk.EventType.BUTTON_PRESS) + self._emit_button(window, Gdk.BUTTON_SECONDARY, Gdk.EventType.BUTTON_PRESS) + self._emit_button(window, Gdk.BUTTON_MIDDLE, Gdk.EventType.BUTTON_PRESS) + # no constants seem to exist, but this is the value that was observed during + # usage: + self._emit_button(window, 8, Gdk.EventType.BUTTON_PRESS) + self._emit_button(window, 9, Gdk.EventType.BUTTON_PRESS) + self.assertEqual( + label.get_text(), + "c + BTN_LEFT + BTN_RIGHT + BTN_MIDDLE + BTN_SIDE + BTN_EXTRA", + ) + + # releasing anything resets the combination + self._emit_button(window, 9, Gdk.EventType.BUTTON_RELEASE) + self._emit_key(window, KEY_A, Gdk.EventType.KEY_PRESS) + self.assertEqual(label.get_text(), "a") + + +@test_setup +class TestCodeEditor(ComponentBaseTest): + def setUp(self) -> None: + super().setUp() + self.gui = GtkSource.View() + self.editor = CodeEditor(self.message_broker, self.controller_mock, self.gui) + # TODO why is mocking this to False needed? + self.controller_mock.is_empty_mapping.return_value = False + + def get_text(self) -> str: + buffer = self.gui.get_buffer() + return buffer.get_text(buffer.get_start_iter(), buffer.get_end_iter(), True) + + def test_shows_output_symbol(self): + self.message_broker.publish(MappingData(output_symbol="foo")) + self.assertEqual(self.get_text(), "foo") + + def test_shows_record_input_first_message_when_mapping_is_empty(self): + self.controller_mock.is_empty_mapping.return_value = True + self.message_broker.publish(MappingData(output_symbol="foo")) + self.assertEqual(self.get_text(), "Record the input first") + + def test_active_when_mapping_is_not_empty(self): + self.message_broker.publish(MappingData(output_symbol="foo")) + self.assertTrue(self.gui.get_sensitive()) + self.assertEqual(self.gui.get_opacity(), 1) + + def test_expands_to_multiline(self): + self.message_broker.publish(MappingData(output_symbol="foo\nbar")) + self.assertIn("multiline", self.gui.get_style_context().list_classes()) + + def test_shows_line_numbers_when_multiline(self): + self.message_broker.publish(MappingData(output_symbol="foo\nbar")) + self.assertTrue(self.gui.get_show_line_numbers()) + + def test_no_multiline_when_macro_not_multiline(self): + self.message_broker.publish(MappingData(output_symbol="foo")) + self.assertNotIn("multiline", self.gui.get_style_context().list_classes()) + + def test_no_line_numbers_macro_not_multiline(self): + self.message_broker.publish(MappingData(output_symbol="foo")) + self.assertFalse(self.gui.get_show_line_numbers()) + + def test_shows_placeholder_when_mapping_has_no_output_symbol(self): + self.message_broker.publish(MappingData()) + self.assertEqual(self.get_text(), self.editor.placeholder) + + # there are no side-effects because the placeholder is inserted: + self.controller_mock.update_mapping.assert_not_called() + + def test_updates_mapping(self): + self.message_broker.publish(MappingData()) + buffer = self.gui.get_buffer() + self.controller_mock.update_mapping.assert_not_called() + buffer.set_text("foo") + call_args_list = self.controller_mock.update_mapping.call_args_list + # this test emits 2 events for whatever reason, the first one with an empty + # symbol. this doesn't actually seem to happen when using it. + self.assertEqual(call_args_list[-1], call(output_symbol="foo")) + + def test_avoids_infinite_recursion_when_loading_mapping(self): + self.message_broker.publish(MappingData(output_symbol="foo")) + self.controller_mock.update_mapping.assert_not_called() + + def test_gets_focus_when_input_recording_finises(self): + self.message_broker.signal(MessageType.recording_finished) + self.controller_mock.set_focus.assert_called_once_with(self.gui) + + def test_placeholder(self): + self.assertEqual(self.get_text(), self.editor.placeholder) + + window = Gtk.Window() + window.add(self.gui) + window.show_all() + + def focus(): + self.gui.grab_focus() + # Do as many iterations as needed to make it work. + gtk_iteration(15) + + def unfocus(): + window.set_focus(None) + gtk_iteration(15) + + # clears the input when we enter the editor widget + focus() + self.assertEqual(self.get_text(), "") + self.assertNotIn("opaque-text", self.gui.get_style_context().list_classes()) + + # adds the placeholder back when we leave it + unfocus() + self.assertEqual(self.get_text(), self.editor.placeholder) + self.assertIn("opaque-text", self.gui.get_style_context().list_classes()) + + # if we enter text and then leave, it won't show the placeholder + focus() + self.assertEqual(self.get_text(), "") + buffer = self.gui.get_buffer() + buffer.set_text("foo") + self.assertNotIn("opaque-text", self.gui.get_style_context().list_classes()) + unfocus() + self.assertEqual(self.get_text(), "foo") + self.assertNotIn("opaque-text", self.gui.get_style_context().list_classes()) + + +@test_setup +class TestRecordingToggle(ComponentBaseTest): + def setUp(self) -> None: + super().setUp() + + self.toggle_button = Gtk.ToggleButton() + self.recording_toggle = RecordingToggle( + self.message_broker, + self.controller_mock, + self.toggle_button, + ) + + self.label = Gtk.Label() + self.recording_status = RecordingStatus(self.message_broker, self.label) + + def assert_not_recording(self): + self.assertFalse(self.label.get_visible()) + self.assertFalse(self.toggle_button.get_active()) + + def test_starts_recording(self): + self.toggle_button.set_active(True) + self.controller_mock.start_key_recording.assert_called_once() + + def test_stops_recording_when_clicked(self): + self.toggle_button.set_active(True) + self.toggle_button.set_active(False) + self.controller_mock.stop_key_recording.assert_called_once() + + def test_not_recording_initially(self): + self.assert_not_recording() + + def test_shows_recording_when_message_sent(self): + self.assertFalse(self.label.get_visible()) + self.message_broker.signal(MessageType.recording_started) + self.assertTrue(self.label.get_visible()) + + def test_shows_not_recording_after_toggle(self): + self.toggle_button.set_active(True) + self.toggle_button.set_active(False) + self.assert_not_recording() + + def test_shows_not_recording_when_recording_finished(self): + self.toggle_button.set_active(True) + self.message_broker.signal(MessageType.recording_finished) + self.assert_not_recording() + + +@test_setup +class TestStatusBar(ComponentBaseTest): + def setUp(self) -> None: + super().setUp() + self.gui = Gtk.Statusbar() + self.err_icon = Gtk.Image() + self.warn_icon = Gtk.Image() + self.statusbar = StatusBar( + self.message_broker, + self.controller_mock, + self.gui, + self.err_icon, + self.warn_icon, + ) + self.message_broker.signal(MessageType.init) + + def assert_empty(self): + self.assertFalse(self.err_icon.get_visible()) + self.assertFalse(self.warn_icon.get_visible()) + self.assertEqual(self.get_text(), "") + self.assertIsNone(self.get_tooltip()) + + def assert_error_status(self): + self.assertTrue(self.err_icon.get_visible()) + self.assertFalse(self.warn_icon.get_visible()) + + def assert_warning_status(self): + self.assertFalse(self.err_icon.get_visible()) + self.assertTrue(self.warn_icon.get_visible()) + + def get_text(self) -> str: + return self.gui.get_message_area().get_children()[0].get_text() + + def get_tooltip(self) -> Optional[str]: + return self.gui.get_tooltip_text() + + def test_starts_empty(self): + self.assert_empty() + + def test_shows_error_status(self): + self.message_broker.publish(StatusData(CTX_ERROR, "msg", "tooltip")) + self.assertEqual(self.get_text(), "msg") + self.assertEqual(self.get_tooltip(), "tooltip") + self.assert_error_status() + + def test_shows_warning_status(self): + self.message_broker.publish(StatusData(CTX_WARNING, "msg", "tooltip")) + self.assertEqual(self.get_text(), "msg") + self.assertEqual(self.get_tooltip(), "tooltip") + self.assert_warning_status() + + def test_shows_newest_message(self): + self.message_broker.publish(StatusData(CTX_ERROR, "msg", "tooltip")) + self.message_broker.publish(StatusData(CTX_WARNING, "msg2", "tooltip2")) + self.assertEqual(self.get_text(), "msg2") + self.assertEqual(self.get_tooltip(), "tooltip2") + self.assert_warning_status() + + def test_data_without_message_removes_messages(self): + self.message_broker.publish(StatusData(CTX_WARNING, "msg", "tooltip")) + self.message_broker.publish(StatusData(CTX_WARNING, "msg2", "tooltip2")) + self.message_broker.publish(StatusData(CTX_WARNING)) + self.assert_empty() + + def test_restores_message_from_not_removed_ctx_id(self): + self.message_broker.publish(StatusData(CTX_ERROR, "msg", "tooltip")) + self.message_broker.publish(StatusData(CTX_WARNING, "msg2", "tooltip2")) + self.message_broker.publish(StatusData(CTX_WARNING)) + self.assertEqual(self.get_text(), "msg") + self.assert_error_status() + + # works also the other way round + self.message_broker.publish(StatusData(CTX_ERROR)) + self.message_broker.publish(StatusData(CTX_WARNING, "msg", "tooltip")) + self.message_broker.publish(StatusData(CTX_ERROR, "msg2", "tooltip2")) + self.message_broker.publish(StatusData(CTX_ERROR)) + self.assertEqual(self.get_text(), "msg") + self.assert_warning_status() + + def test_sets_msg_as_tooltip_if_tooltip_is_none(self): + self.message_broker.publish(StatusData(CTX_ERROR, "msg")) + self.assertEqual(self.get_tooltip(), "msg") + + +@test_setup +class TestAutoloadSwitch(ComponentBaseTest): + def setUp(self) -> None: + super().setUp() + self.gui = Gtk.Switch() + self.switch = AutoloadSwitch( + self.message_broker, self.controller_mock, self.gui + ) + + def test_sets_autoload(self): + self.gui.set_active(True) + self.controller_mock.set_autoload.assert_called_once_with(True) + self.controller_mock.reset_mock() + self.gui.set_active(False) + self.controller_mock.set_autoload.assert_called_once_with(False) + + def test_updates_state(self): + self.message_broker.publish(PresetData(None, None, autoload=True)) + self.assertTrue(self.gui.get_active()) + self.message_broker.publish(PresetData(None, None, autoload=False)) + self.assertFalse(self.gui.get_active()) + + def test_avoids_infinite_recursion(self): + self.message_broker.publish(PresetData(None, None, autoload=True)) + self.message_broker.publish(PresetData(None, None, autoload=False)) + self.controller_mock.set_autoload.assert_not_called() + + +@test_setup +class TestReleaseCombinationSwitch(ComponentBaseTest): + def setUp(self) -> None: + super().setUp() + self.gui = Gtk.Switch() + self.switch = ReleaseCombinationSwitch( + self.message_broker, self.controller_mock, self.gui + ) + + def test_updates_mapping(self): + self.gui.set_active(True) + self.controller_mock.update_mapping.assert_called_once_with( + release_combination_keys=True + ) + self.controller_mock.reset_mock() + self.gui.set_active(False) + self.controller_mock.update_mapping.assert_called_once_with( + release_combination_keys=False + ) + + def test_updates_state(self): + self.message_broker.publish(MappingData(release_combination_keys=True)) + self.assertTrue(self.gui.get_active()) + self.message_broker.publish(MappingData(release_combination_keys=False)) + self.assertFalse(self.gui.get_active()) + + def test_avoids_infinite_recursion(self): + self.message_broker.publish(MappingData(release_combination_keys=True)) + self.message_broker.publish(MappingData(release_combination_keys=False)) + self.controller_mock.update_mapping.assert_not_called() + + +@test_setup +class TestEventEntry(ComponentBaseTest): + def setUp(self) -> None: + super().setUp() + self.gui = InputConfigEntry( + InputConfig(type=3, code=0, analog_threshold=1), self.controller_mock + ) + + def test_move_event(self): + self.gui._up_btn.clicked() + self.controller_mock.move_input_config_in_combination.assert_called_once_with( + InputConfig(type=3, code=0, analog_threshold=1), "up" + ) + self.controller_mock.reset_mock() + + self.gui._down_btn.clicked() + self.controller_mock.move_input_config_in_combination.assert_called_once_with( + InputConfig(type=3, code=0, analog_threshold=1), "down" + ) + + +@test_setup +class TestCombinationListbox(ComponentBaseTest): + def setUp(self) -> None: + super().setUp() + self.gui = Gtk.ListBox() + self.listbox = CombinationListbox( + self.message_broker, self.controller_mock, self.gui + ) + self.controller_mock.is_empty_mapping.return_value = False + combination = InputCombination( + ( + InputConfig(type=1, code=1), + InputConfig(type=3, code=0, analog_threshold=1), + InputConfig(type=1, code=2), + ) + ) + self.message_broker.publish( + MappingData( + input_combination=combination.to_config(), target_uinput="keyboard" + ) + ) + + def get_selected_row(self) -> InputConfigEntry: + for entry in self.gui.get_children(): + if entry.is_selected(): + return entry + + raise Exception("Expected one InputConfigEntry to be selected") + + def select_row(self, input_cfg: InputConfig): + for entry in self.gui.get_children(): + if entry.input_event == input_cfg: + self.gui.select_row(entry) + + def test_loads_selected_row(self): + self.select_row(InputConfig(type=1, code=2)) + self.controller_mock.load_input_config.assert_called_once_with( + InputConfig(type=1, code=2) + ) + + def test_does_not_create_rows_when_mapping_is_empty(self): + self.controller_mock.is_empty_mapping.return_value = True + combination = InputCombination( + ( + InputConfig(type=1, code=1), + InputConfig(type=3, code=0, analog_threshold=1), + ) + ) + self.message_broker.publish(MappingData(input_combination=combination)) + self.assertEqual(len(self.gui.get_children()), 0) + + def test_selects_row_when_selected_event_message_arrives(self): + self.message_broker.publish(InputConfig(type=3, code=0, analog_threshold=1)) + self.assertEqual( + self.get_selected_row().input_event, + InputConfig(type=3, code=0, analog_threshold=1), + ) + + def test_avoids_infinite_recursion(self): + self.message_broker.publish(InputConfig(type=3, code=0, analog_threshold=1)) + self.controller_mock.load_event.assert_not_called() + + +@test_setup +class TestAnalogInputSwitch(ComponentBaseTest): + def setUp(self) -> None: + super().setUp() + self.gui = Gtk.Switch() + self.switch = AnalogInputSwitch( + self.message_broker, self.controller_mock, self.gui + ) + + def test_updates_event_as_analog(self): + self.gui.set_active(True) + self.controller_mock.set_event_as_analog.assert_called_once_with(True) + self.controller_mock.reset_mock() + self.gui.set_active(False) + self.controller_mock.set_event_as_analog.assert_called_once_with(False) + + def test_updates_state(self): + self.message_broker.publish(InputConfig(type=3, code=0)) + self.assertTrue(self.gui.get_active()) + self.message_broker.publish(InputConfig(type=3, code=0, analog_threshold=10)) + self.assertFalse(self.gui.get_active()) + + def test_avoids_infinite_recursion(self): + self.message_broker.publish(InputConfig(type=3, code=0)) + self.message_broker.publish(InputConfig(type=3, code=0, analog_threshold=-10)) + self.controller_mock.set_event_as_analog.assert_not_called() + + def test_disables_switch_when_key_event(self): + self.message_broker.publish(InputConfig(type=1, code=1)) + self.assertLess(self.gui.get_opacity(), 0.6) + self.assertFalse(self.gui.get_sensitive()) + + def test_enables_switch_when_axis_event(self): + self.message_broker.publish(InputConfig(type=1, code=1)) + self.message_broker.publish(InputConfig(type=3, code=0, analog_threshold=10)) + self.assertEqual(self.gui.get_opacity(), 1) + self.assertTrue(self.gui.get_sensitive()) + + self.message_broker.publish(InputConfig(type=1, code=1)) + self.message_broker.publish(InputConfig(type=2, code=0, analog_threshold=10)) + self.assertEqual(self.gui.get_opacity(), 1) + self.assertTrue(self.gui.get_sensitive()) + + +@test_setup +class TestTriggerThresholdInput(ComponentBaseTest): + def setUp(self) -> None: + super().setUp() + self.gui = Gtk.SpinButton() + self.input = TriggerThresholdInput( + self.message_broker, self.controller_mock, self.gui + ) + self.message_broker.publish(InputConfig(type=3, code=0, analog_threshold=-10)) + + def assert_abs_event_config(self): + self.assertEqual(self.gui.get_range(), (-99, 99)) + self.assertTrue(self.gui.get_sensitive()) + self.assertEqual(self.gui.get_opacity(), 1) + + def assert_rel_event_config(self): + self.assertEqual(self.gui.get_range(), (-999, 999)) + self.assertTrue(self.gui.get_sensitive()) + self.assertEqual(self.gui.get_opacity(), 1) + + def assert_key_event_config(self): + self.assertFalse(self.gui.get_sensitive()) + self.assertLess(self.gui.get_opacity(), 0.6) + + def test_updates_event(self): + self.gui.set_value(15) + self.controller_mock.update_input_config.assert_called_once_with( + InputConfig(type=3, code=0, analog_threshold=15) + ) + + def test_sets_value_on_selected_event_message(self): + self.message_broker.publish(InputConfig(type=3, code=0, analog_threshold=10)) + self.assertEqual(self.gui.get_value(), 10) + + def test_avoids_infinite_recursion(self): + self.message_broker.publish(InputConfig(type=3, code=0, analog_threshold=10)) + self.controller_mock.update_input_config.assert_not_called() + + def test_updates_configuration_according_to_selected_event(self): + self.assert_abs_event_config() + self.message_broker.publish(InputConfig(type=2, code=0, analog_threshold=-10)) + self.assert_rel_event_config() + self.message_broker.publish(InputConfig(type=1, code=1)) + self.assert_key_event_config() + + +@test_setup +class TestReleaseTimeoutInput(ComponentBaseTest): + def setUp(self) -> None: + super().setUp() + self.gui = Gtk.SpinButton() + self.input = ReleaseTimeoutInput( + self.message_broker, self.controller_mock, self.gui + ) + self.message_broker.publish( + MappingData( + input_combination=InputCombination( + [InputConfig(type=2, code=0, analog_threshold=1)] + ), + target_uinput="keyboard", + ) + ) + + def test_updates_timeout_on_mapping_message(self): + self.message_broker.publish( + MappingData( + input_combination=InputCombination( + [InputConfig(type=2, code=0, analog_threshold=1)] + ), + release_timeout=1, + ) + ) + self.assertEqual(self.gui.get_value(), 1) + + def test_updates_mapping(self): + self.gui.set_value(0.5) + self.controller_mock.update_mapping.assert_called_once_with(release_timeout=0.5) + + def test_avoids_infinite_recursion(self): + self.message_broker.publish( + MappingData( + input_combination=InputCombination( + [InputConfig(type=2, code=0, analog_threshold=1)] + ), + release_timeout=1, + ) + ) + self.controller_mock.update_mapping.assert_not_called() + + def test_disables_input_based_on_input_combination(self): + self.message_broker.publish( + MappingData( + input_combination=InputCombination( + ( + InputConfig(type=2, code=0, analog_threshold=1), + InputConfig(type=1, code=1), + ) + ) + ) + ) + self.assertTrue(self.gui.get_sensitive()) + self.assertEqual(self.gui.get_opacity(), 1) + + self.message_broker.publish( + MappingData( + input_combination=InputCombination( + ( + InputConfig(type=1, code=1), + InputConfig(type=1, code=2), + ) + ) + ) + ) + self.assertFalse(self.gui.get_sensitive()) + self.assertLess(self.gui.get_opacity(), 0.6) + + self.message_broker.publish( + MappingData( + input_combination=InputCombination( + ( + InputConfig(type=2, code=0, analog_threshold=1), + InputConfig(type=1, code=1), + ) + ) + ) + ) + self.message_broker.publish( + MappingData( + input_combination=InputCombination( + ( + InputConfig(type=3, code=0, analog_threshold=1), + InputConfig( + type=1, + code=2, + ), + ) + ) + ) + ) + self.assertFalse(self.gui.get_sensitive()) + self.assertLess(self.gui.get_opacity(), 0.6) + + +@test_setup +class TestOutputAxisSelector(ComponentBaseTest): + def setUp(self) -> None: + super().setUp() + self.gui = Gtk.ComboBox() + self.selection = OutputAxisSelector( + self.message_broker, self.controller_mock, self.gui + ) + absinfo = evdev.AbsInfo(0, -10, 10, 0, 0, 0) + self.message_broker.publish( + UInputsData( + { + "mouse": {1: [1, 2, 3, 4], 2: [0, 1, 2, 3]}, + "keyboard": {1: [1, 2, 3, 4]}, + "gamepad": { + 2: [0, 1, 2, 3], + 3: [(0, absinfo), (1, absinfo), (2, absinfo), (3, absinfo)], + }, + } + ) + ) + self.message_broker.publish( + MappingData( + target_uinput="mouse", + input_combination=InputCombination([InputConfig(type=1, code=1)]), + ) + ) + + def set_active_selection(self, selection: Tuple): + self.gui.set_active_id(f"{selection[0]}, {selection[1]}") + + def get_active_selection(self) -> Tuple[int, int]: + return tuple(int(i) for i in self.gui.get_active_id().split(",")) # type: ignore + + def test_updates_mapping(self): + self.set_active_selection((2, 0)) + self.controller_mock.update_mapping.assert_called_once_with( + output_type=2, output_code=0 + ) + + def test_updates_mapping_with_none(self): + self.set_active_selection((2, 0)) + self.controller_mock.reset_mock() + self.set_active_selection((None, None)) + self.controller_mock.update_mapping.assert_called_once_with( + output_type=None, output_code=None + ) + + def test_selects_correct_entry(self): + self.assertEqual(self.gui.get_active_id(), "None, None") + self.message_broker.publish( + MappingData(target_uinput="mouse", output_type=2, output_code=3) + ) + self.assertEqual(self.get_active_selection(), (2, 3)) + + def test_avoids_infinite_recursion(self): + self.message_broker.publish( + MappingData(target_uinput="mouse", output_type=2, output_code=3) + ) + self.controller_mock.update_mapping.assert_not_called() + + def test_updates_dropdown_model(self): + self.assertEqual(len(self.gui.get_model()), 5) + self.message_broker.publish(MappingData(target_uinput="keyboard")) + self.assertEqual(len(self.gui.get_model()), 1) + self.message_broker.publish(MappingData(target_uinput="gamepad")) + self.assertEqual(len(self.gui.get_model()), 9) + + +@test_setup +class TestKeyAxisStackSwitcher(ComponentBaseTest): + def setUp(self) -> None: + super().setUp() + self.gui = Gtk.Box() + self.gtk_stack = Gtk.Stack() + self.analog_toggle = Gtk.ToggleButton() + self.key_toggle = Gtk.ToggleButton() + + self.gui.add(self.gtk_stack) + self.gui.add(self.analog_toggle) + self.gui.add(self.key_toggle) + self.gtk_stack.add_named(Gtk.Box(), "Analog Axis") + self.gtk_stack.add_named(Gtk.Box(), "Key or Macro") + + self.stack = KeyAxisStackSwitcher( + self.message_broker, + self.controller_mock, + self.gtk_stack, + self.key_toggle, + self.analog_toggle, + ) + + self.gui.show_all() + self.gtk_stack.set_visible_child_name("Key or Macro") + + def assert_key_macro_active(self): + self.assertEqual(self.gtk_stack.get_visible_child_name(), "Key or Macro") + self.assertTrue(self.key_toggle.get_active()) + self.assertFalse(self.analog_toggle.get_active()) + + def assert_analog_active(self): + self.assertEqual(self.gtk_stack.get_visible_child_name(), "Analog Axis") + self.assertFalse(self.key_toggle.get_active()) + self.assertTrue(self.analog_toggle.get_active()) + + def test_switches_to_axis(self): + self.message_broker.publish(MappingData(mapping_type="analog")) + self.assert_analog_active() + + def test_switches_to_key_macro(self): + self.message_broker.publish(MappingData(mapping_type="analog")) + self.message_broker.publish(MappingData(mapping_type="key_macro")) + self.assert_key_macro_active() + + def test_updates_mapping_type(self): + self.key_toggle.set_active(True) + self.controller_mock.update_mapping.assert_called_once_with( + mapping_type="key_macro" + ) + self.controller_mock.update_mapping.reset_mock() + + self.analog_toggle.set_active(True) + self.controller_mock.update_mapping.assert_called_once_with( + mapping_type="analog" + ) + + def test_avoids_infinite_recursion(self): + self.message_broker.publish(MappingData(mapping_type="analog")) + self.message_broker.publish(MappingData(mapping_type="key_macro")) + self.controller_mock.update_mapping.assert_not_called() + + +@test_setup +class TestTransformationDrawArea(ComponentBaseTest): + def setUp(self) -> None: + super().setUp() + self.gui = Gtk.Window() + self.draw_area = Gtk.DrawingArea() + self.gui.add(self.draw_area) + self.transform_draw_area = TransformationDrawArea( + self.message_broker, + self.controller_mock, + self.draw_area, + ) + + def test_draws_transform(self): + with spy(self.transform_draw_area, "_transformation") as mock: + # show the window, it takes some time and iterations until it pops up + self.gui.show_all() + for _ in range(5): + gtk_iteration() + time.sleep(0.01) + + mock.assert_called() + + def test_updates_transform_when_mapping_updates(self): + old_tf = self.transform_draw_area._transformation + self.message_broker.publish(MappingData(gain=2)) + self.assertIsNot(old_tf, self.transform_draw_area._transformation) + + def test_redraws_when_mapping_updates(self): + self.gui.show_all() + gtk_iteration(20) + mock = MagicMock() + self.draw_area.connect("draw", mock) + self.message_broker.publish(MappingData(gain=2)) + gtk_iteration(20) + mock.assert_called() + + +@test_setup +class TestSliders(ComponentBaseTest): + def setUp(self) -> None: + super().setUp() + self.gui = Gtk.Box() + self.gain = Gtk.Scale() + self.deadzone = Gtk.Scale() + self.expo = Gtk.Scale() + + # add everything to a box: it will be cleand up properly + self.gui.add(self.gain) + self.gui.add(self.deadzone) + self.gui.add(self.expo) + + self.sliders = Sliders( + self.message_broker, + self.controller_mock, + self.gain, + self.deadzone, + self.expo, + ) + self.message_broker.publish( + MappingData( + input_combination=InputCombination([InputConfig(type=3, code=0)]), + target_uinput="mouse", + ) + ) + + @staticmethod + def get_range(range: Gtk.Range) -> Tuple[int, int]: + """the Gtk.Range, has no get_range method. this is a workaround""" + v = range.get_value() + range.set_value(-(2**16)) + min_ = range.get_value() + range.set_value(2**16) + max_ = range.get_value() + range.set_value(v) + return min_, max_ + + def test_slider_ranges(self): + self.assertEqual(self.get_range(self.gain), (-2, 2)) + self.assertEqual(self.get_range(self.deadzone), (0, 0.9)) + self.assertEqual(self.get_range(self.expo), (-1, 1)) + + def test_updates_value(self): + self.message_broker.publish( + MappingData( + gain=0.5, + deadzone=0.6, + expo=0.3, + ) + ) + self.assertEqual(self.gain.get_value(), 0.5) + self.assertEqual(self.expo.get_value(), 0.3) + self.assertEqual(self.deadzone.get_value(), 0.6) + + def test_gain_updates_mapping(self): + self.gain.set_value(0.5) + self.controller_mock.update_mapping.assert_called_once_with(gain=0.5) + + def test_expo_updates_mapping(self): + self.expo.set_value(0.5) + self.controller_mock.update_mapping.assert_called_once_with(expo=0.5) + + def test_deadzone_updates_mapping(self): + self.deadzone.set_value(0.5) + self.controller_mock.update_mapping.assert_called_once_with(deadzone=0.5) + + def test_avoids_recursion(self): + self.message_broker.publish(MappingData(gain=0.5)) + self.controller_mock.update_mapping.assert_not_called() + self.message_broker.publish(MappingData(expo=0.5)) + self.controller_mock.update_mapping.assert_not_called() + self.message_broker.publish(MappingData(deadzone=0.5)) + self.controller_mock.update_mapping.assert_not_called() + + +@test_setup +class TestRelativeInputCutoffInput(ComponentBaseTest): + def setUp(self) -> None: + super().setUp() + self.gui = Gtk.SpinButton() + self.input = RelativeInputCutoffInput( + self.message_broker, self.controller_mock, self.gui + ) + self.message_broker.publish( + MappingData( + target_uinput="mouse", + input_combination=InputCombination([InputConfig(type=2, code=0)]), + rel_to_abs_input_cutoff=1, + output_type=3, + output_code=0, + ) + ) + + def assert_active(self): + self.assertTrue(self.gui.get_sensitive()) + self.assertEqual(self.gui.get_opacity(), 1) + + def assert_inactive(self): + self.assertFalse(self.gui.get_sensitive()) + self.assertLess(self.gui.get_opacity(), 0.6) + + def test_avoids_infinite_recursion(self): + self.message_broker.publish( + MappingData( + target_uinput="mouse", + input_combination=InputCombination([InputConfig(type=2, code=0)]), + rel_to_abs_input_cutoff=3, + output_type=3, + output_code=0, + ) + ) + self.controller_mock.update_mapping.assert_not_called() + + def test_updates_value(self): + rel_to_abs_input_cutoff = 3 + self.message_broker.publish( + MappingData( + target_uinput="mouse", + input_combination=InputCombination([InputConfig(type=2, code=0)]), + rel_to_abs_input_cutoff=rel_to_abs_input_cutoff, + output_type=3, + output_code=0, + ) + ) + self.assertEqual(self.gui.get_value(), rel_to_abs_input_cutoff) + + def test_updates_mapping(self): + self.gui.set_value(300) + self.controller_mock.update_mapping.assert_called_once_with(rel_xy_cutoff=300) + + def test_disables_input_when_no_rel_axis_input(self): + self.assert_active() + self.message_broker.publish( + MappingData( + target_uinput="mouse", + input_combination=InputCombination([InputConfig(type=3, code=0)]), + output_type=3, + output_code=0, + ) + ) + self.assert_inactive() + + def test_disables_input_when_no_abs_axis_output(self): + self.assert_active() + self.message_broker.publish( + MappingData( + target_uinput="mouse", + input_combination=InputCombination([InputConfig(type=2, code=0)]), + rel_to_abs_input_cutoff=3, + output_type=2, + output_code=0, + ) + ) + self.assert_inactive() + + def test_enables_input(self): + self.message_broker.publish( + MappingData( + target_uinput="mouse", + input_combination=InputCombination([InputConfig(type=3, code=0)]), + output_type=3, + output_code=0, + ) + ) + self.assert_inactive() + self.message_broker.publish( + MappingData( + target_uinput="mouse", + input_combination=InputCombination([InputConfig(type=2, code=0)]), + rel_to_abs_input_cutoff=1, + output_type=3, + output_code=0, + ) + ) + self.assert_active() + + +@test_setup +class TestRequireActiveMapping(ComponentBaseTest): + def test_no_reqorded_input_required(self): + self.box = Gtk.Box() + RequireActiveMapping( + self.message_broker, + self.box, + require_recorded_input=False, + ) + combination = InputCombination([InputConfig(type=1, code=KEY_A)]) + + self.message_broker.publish(MappingData()) + self.assert_inactive(self.box) + + self.message_broker.publish(PresetData(name="preset", mappings=())) + self.assert_inactive(self.box) + + # a mapping is available, that is all the widget needs to be activated. one + # mapping is always selected, so there is no need to check the mapping message + self.message_broker.publish(PresetData(name="preset", mappings=(combination,))) + self.assert_active(self.box) + + self.message_broker.publish(MappingData(input_combination=combination)) + self.assert_active(self.box) + + self.message_broker.publish(MappingData()) + self.assert_active(self.box) + + def test_recorded_input_required(self): + self.box = Gtk.Box() + RequireActiveMapping( + self.message_broker, + self.box, + require_recorded_input=True, + ) + combination = InputCombination([InputConfig(type=1, code=KEY_A)]) + + self.message_broker.publish(MappingData()) + self.assert_inactive(self.box) + + self.message_broker.publish(PresetData(name="preset", mappings=())) + self.assert_inactive(self.box) + + self.message_broker.publish(PresetData(name="preset", mappings=(combination,))) + self.assert_inactive(self.box) + + # the widget will be enabled once a mapping with recorded input is selected + self.message_broker.publish(MappingData(input_combination=combination)) + self.assert_active(self.box) + + # this mapping doesn't have input recorded, so the box is disabled + self.message_broker.publish(MappingData()) + self.assert_inactive(self.box) + + def assert_inactive(self, widget: Gtk.Widget): + self.assertFalse(widget.get_sensitive()) + self.assertLess(widget.get_opacity(), 0.6) + self.assertGreater(widget.get_opacity(), 0.4) + + def assert_active(self, widget: Gtk.Widget): + self.assertTrue(widget.get_sensitive()) + self.assertEqual(widget.get_opacity(), 1) + + +@test_setup +class TestStack(ComponentBaseTest): + def test_switches_pages(self): + self.stack = Gtk.Stack() + self.stack.add_named(Gtk.Label(), "Devices") + self.stack.add_named(Gtk.Label(), "Presets") + self.stack.add_named(Gtk.Label(), "Editor") + self.stack.show_all() + stack_wrapper = Stack(self.message_broker, self.controller_mock, self.stack) + + self.message_broker.publish(DoStackSwitch(Stack.devices_page)) + self.assertEqual(self.stack.get_visible_child_name(), "Devices") + + self.message_broker.publish(DoStackSwitch(Stack.presets_page)) + self.assertEqual(self.stack.get_visible_child_name(), "Presets") + + self.message_broker.publish(DoStackSwitch(Stack.editor_page)) + self.assertEqual(self.stack.get_visible_child_name(), "Editor") + + +@test_setup +class TestBreadcrumbs(ComponentBaseTest): + def test_breadcrumbs(self): + self.label_1 = Gtk.Label() + self.label_2 = Gtk.Label() + self.label_3 = Gtk.Label() + self.label_4 = Gtk.Label() + self.label_5 = Gtk.Label() + + Breadcrumbs( + self.message_broker, + self.label_1, + show_device_group=False, + show_preset=False, + show_mapping=False, + ) + Breadcrumbs( + self.message_broker, + self.label_2, + show_device_group=True, + show_preset=False, + show_mapping=False, + ) + Breadcrumbs( + self.message_broker, + self.label_3, + show_device_group=True, + show_preset=True, + show_mapping=False, + ) + Breadcrumbs( + self.message_broker, + self.label_4, + show_device_group=True, + show_preset=True, + show_mapping=True, + ) + Breadcrumbs( + self.message_broker, + self.label_5, + show_device_group=False, + show_preset=False, + show_mapping=True, + ) + + self.assertEqual(self.label_1.get_text(), "") + self.assertEqual(self.label_2.get_text(), "?") + self.assertEqual(self.label_3.get_text(), "? / ?") + self.assertEqual(self.label_4.get_text(), "? / ? / ?") + self.assertEqual(self.label_5.get_text(), "?") + + self.message_broker.publish(PresetData("preset", None)) + + self.assertEqual(self.label_1.get_text(), "") + self.assertEqual(self.label_2.get_text(), "?") + self.assertEqual(self.label_3.get_text(), "? / preset") + self.assertEqual(self.label_4.get_text(), "? / preset / ?") + self.assertEqual(self.label_5.get_text(), "?") + + self.message_broker.publish(GroupData("group", ())) + + self.assertEqual(self.label_1.get_text(), "") + self.assertEqual(self.label_2.get_text(), "group") + self.assertEqual(self.label_3.get_text(), "group / preset") + self.assertEqual(self.label_4.get_text(), "group / preset / ?") + self.assertEqual(self.label_5.get_text(), "?") + + self.message_broker.publish(MappingData()) + + self.assertEqual(self.label_1.get_text(), "") + self.assertEqual(self.label_2.get_text(), "group") + self.assertEqual(self.label_3.get_text(), "group / preset") + self.assertEqual(self.label_4.get_text(), "group / preset / Empty Mapping") + self.assertEqual(self.label_5.get_text(), "Empty Mapping") + + self.message_broker.publish(MappingData(name="mapping")) + self.assertEqual(self.label_4.get_text(), "group / preset / mapping") + self.assertEqual(self.label_5.get_text(), "mapping") + + combination = InputCombination( + ( + InputConfig(type=1, code=KEY_A), + InputConfig(type=1, code=KEY_B), + ) + ) + self.message_broker.publish(MappingData(input_combination=combination)) + self.assertEqual(self.label_4.get_text(), "group / preset / a + b") + self.assertEqual(self.label_5.get_text(), "a + b") + + combination = InputCombination([InputConfig(type=1, code=KEY_A)]) + self.message_broker.publish( + MappingData(name="qux", input_combination=combination) + ) + self.assertEqual(self.label_4.get_text(), "group / preset / qux") + self.assertEqual(self.label_5.get_text(), "qux") diff --git a/tests/system/gui/test_debounce.py b/tests/system/gui/test_debounce.py new file mode 100644 index 0000000..0f71568 --- /dev/null +++ b/tests/system/gui/test_debounce.py @@ -0,0 +1,149 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + +import time +import unittest + +import gi + + +gi.require_version("Gdk", "3.0") +gi.require_version("Gtk", "3.0") +gi.require_version("GtkSource", "4") +gi.require_version("GLib", "2.0") + +from inputremapper.gui.utils import gtk_iteration, debounce, debounce_manager + +from tests.lib.test_setup import test_setup + + +@test_setup +class TestDebounce(unittest.TestCase): + def test_debounce(self): + calls = 0 + + class A: + @debounce(20) + def foo(self): + nonlocal calls + calls += 1 + + # two methods with the same name don't confuse debounce + class B: + @debounce(20) + def foo(self): + nonlocal calls + calls += 1 + + a = A() + b = B() + + self.assertEqual(calls, 0) + + a.foo() + gtk_iteration() + self.assertEqual(calls, 0) + + b.foo() + gtk_iteration() + self.assertEqual(calls, 0) + + time.sleep(0.021) + gtk_iteration() + self.assertEqual(calls, 2) + + a.foo() + b.foo() + a.foo() + b.foo() + gtk_iteration() + self.assertEqual(calls, 2) + + time.sleep(0.021) + gtk_iteration() + self.assertEqual(calls, 4) + + def test_run_all_now(self): + calls = 0 + + class A: + @debounce(20) + def foo(self): + nonlocal calls + calls += 1 + + a = A() + a.foo() + gtk_iteration() + self.assertEqual(calls, 0) + + debounce_manager.run_all_now() + self.assertEqual(calls, 1) + + # waiting for some time will not call it again + time.sleep(0.021) + gtk_iteration() + self.assertEqual(calls, 1) + + def test_stop_all(self): + calls = 0 + + class A: + @debounce(20) + def foo(self): + nonlocal calls + calls += 1 + + a = A() + a.foo() + gtk_iteration() + self.assertEqual(calls, 0) + + debounce_manager.stop_all() + + # waiting for some time will not call it + time.sleep(0.021) + gtk_iteration() + self.assertEqual(calls, 0) + + def test_stop(self): + calls = 0 + + class A: + @debounce(20) + def foo(self): + nonlocal calls + calls += 1 + + a = A() + a.foo() + gtk_iteration() + self.assertEqual(calls, 0) + + debounce_manager.stop(a, a.foo) + + # waiting for some time will not call it + time.sleep(0.021) + gtk_iteration() + self.assertEqual(calls, 0) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/system/gui/test_groups.py b/tests/system/gui/test_groups.py new file mode 100644 index 0000000..3082dfd --- /dev/null +++ b/tests/system/gui/test_groups.py @@ -0,0 +1,137 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + +import os +import time +import unittest +from unittest.mock import patch, MagicMock + +import gi + +from inputremapper.injection.global_uinputs import GlobalUInputs, UInput +from inputremapper.injection.mapping_handlers.mapping_parser import MappingParser + +gi.require_version("Gdk", "3.0") +gi.require_version("Gtk", "3.0") +gi.require_version("GtkSource", "4") +gi.require_version("GLib", "2.0") + +from inputremapper.configs.global_config import GlobalConfig +from inputremapper.gui.utils import gtk_iteration +from inputremapper.daemon import Daemon + +from tests.lib.test_setup import test_setup +from tests.system.gui.gui_test_base import ( + launch, + start_reader_service, + clean_up_gui_test, +) + + +@test_setup +class TestGroupsFromReaderService(unittest.TestCase): + def patch_os_system(self): + def os_system(cmd, original_os_system=os.system): + # instead of running pkexec, fork instead. This will make + # the reader-service aware of all the test patches + if "pkexec input-remapper-control --command start-reader-service" in cmd: + # don't start the reader-service just log that it was. + self.reader_service_started() + return 0 + + return original_os_system(cmd) + + self.os_system_patch = patch.object( + os, + "system", + os_system, + ) + + # this is already part of the test. we need a bit of patching and hacking + # because we want to discover the groups as early a possible, to reduce startup + # time for the application + self.os_system_patch.start() + + def bootstrap_daemon(self): + # The daemon gets fresh instances of everything, because as far as I remember + # it runs in a separate process. + global_config = GlobalConfig() + global_uinputs = GlobalUInputs(UInput) + mapping_parser = MappingParser(global_uinputs) + + return Daemon( + global_config, + global_uinputs, + mapping_parser, + ) + + def patch_daemon(self): + # don't try to connect, return an object instance of it instead + self.daemon_connect_patch = patch.object( + Daemon, + "connect", + lambda: self.bootstrap_daemon(), + ) + self.daemon_connect_patch.start() + + def setUp(self): + self.reader_service_started = MagicMock() + self.patch_os_system() + self.patch_daemon() + + ( + self.user_interface, + self.controller, + self.data_manager, + self.message_broker, + self.daemon, + self.global_config, + ) = launch() + + def tearDown(self): + clean_up_gui_test(self) + self.os_system_patch.stop() + self.daemon_connect_patch.stop() + + def test_knows_devices(self): + # verify that it is working as expected. The gui doesn't have knowledge + # of groups until the root-reader-service provides them + self.data_manager._reader_client.groups.set_groups([]) + gtk_iteration() + self.reader_service_started.assert_called() + self.assertEqual(len(self.data_manager.get_group_keys()), 0) + + # start the reader-service delayed + start_reader_service() + # perform some iterations so that the reader ends up reading from the pipes + # which will make it receive devices. + for _ in range(10): + time.sleep(0.02) + gtk_iteration() + + self.assertIn("Foo Device 2", self.data_manager.get_group_keys()) + self.assertIn("Foo Device 2", self.data_manager.get_group_keys()) + self.assertIn("Bar Device", self.data_manager.get_group_keys()) + self.assertIn("gamepad", self.data_manager.get_group_keys()) + self.assertEqual(self.data_manager.active_group.name, "Foo Device") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/system/gui/test_gui.py b/tests/system/gui/test_gui.py new file mode 100644 index 0000000..0389d1a --- /dev/null +++ b/tests/system/gui/test_gui.py @@ -0,0 +1,1619 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + +import os +import time +import unittest +from typing import Tuple, Iterable +from unittest.mock import patch, MagicMock, call + +import evdev +import gi +from evdev.ecodes import ( + EV_KEY, + EV_ABS, + KEY_LEFTSHIFT, + KEY_A, + KEY_Q, + EV_REL, +) + +from inputremapper.input_event import InputEvent +from tests.system.gui.test_components import FlowBoxTestUtils +from tests.lib.fixtures import fixtures +from tests.lib.logger import logger +from tests.lib.pipes import push_event, push_events, uinput_write_history_pipe +from tests.lib.spy import spy + +gi.require_version("Gdk", "3.0") +gi.require_version("Gtk", "3.0") +gi.require_version("GtkSource", "4") +gi.require_version("GLib", "2.0") +from gi.repository import Gtk + +from inputremapper.configs.mapping import Mapping +from inputremapper.configs.paths import PathUtils +from inputremapper.gui.messages.message_broker import ( + MessageType, +) +from inputremapper.gui.messages.message_data import StatusData, CombinationRecorded +from inputremapper.gui.components.editor import ( + MappingSelectionLabel, + SET_KEY_FIRST, + CodeEditor, +) +from inputremapper.gui.components.device_groups import DeviceGroupEntry +from inputremapper.gui.utils import gtk_iteration +from inputremapper.injection.injector import InjectorState +from inputremapper.configs.input_config import InputCombination, InputConfig + +from tests.lib.test_setup import test_setup +from tests.system.gui.gui_test_base import GuiTestBase, patch_confirm_delete + + +@test_setup +class TestGui(GuiTestBase): + """For tests that use the window. + + It is intentional that there is no access to the Components. + Try to modify the configuration only by calling functions of the window. + For example by simulating clicks on buttons. Get the widget to interact with + by going through the windows children. (See click_on_group for inspiration) + """ + + def click_on_group(self, group_key: str): + for child in self.device_selection.get_children(): + device_group_entry = child.get_children()[0] + + if device_group_entry.group_key == group_key: + device_group_entry.set_active(True) + + def test_can_start(self): + self.assertIsNotNone(self.user_interface) + self.assertTrue(self.user_interface.window.get_visible()) + + def assert_gui_clean(self): + selection_labels = self.selection_label_listbox.get_children() + self.assertEqual(len(selection_labels), 0) + self.assertEqual(len(self.data_manager.active_preset), 0) + self.assertEqual( + FlowBoxTestUtils.get_active_entry(self.preset_selection).name, "new preset" + ) + self.assertEqual(self.recording_toggle.get_label(), "Record") + self.assertEqual(self.get_unfiltered_symbol_input_text(), SET_KEY_FIRST) + + def test_initial_state(self): + self.assertEqual(self.data_manager.active_group.key, "Foo Device") + self.assertEqual( + FlowBoxTestUtils.get_active_entry(self.device_selection).name, "Foo Device" + ) + self.assertEqual(self.data_manager.active_preset.name, "preset3") + self.assertEqual( + FlowBoxTestUtils.get_active_entry(self.preset_selection).name, "preset3" + ) + self.assertFalse(self.data_manager.get_autoload()) + self.assertFalse(self.autoload_toggle.get_active()) + self.assertEqual( + self.selection_label_listbox.get_selected_row().combination, + InputCombination([InputConfig(type=1, code=5)]), + ) + self.assertEqual( + self.data_manager.active_mapping.input_combination, + InputCombination( + [ + InputConfig(type=1, code=5), + ] + ), + ) + self.assertEqual(self.selection_label_listbox.get_selected_row().name, "4") + self.assertIsNone(self.data_manager.active_mapping.name) + self.assertTrue(self.data_manager.active_mapping.is_valid()) + self.assertTrue(self.data_manager.active_preset.is_valid()) + # todo + + def test_set_autoload_refreshes_service_config(self): + self.assertFalse(self.data_manager.get_autoload()) + with spy(self.daemon, "set_config_dir") as set_config_dir: + self.autoload_toggle.set_active(True) + gtk_iteration() + set_config_dir.assert_called_once() + self.assertTrue(self.data_manager.get_autoload()) + + def test_autoload_sets_correctly(self): + self.assertFalse(self.data_manager.get_autoload()) + self.assertFalse(self.autoload_toggle.get_active()) + + self.autoload_toggle.set_active(True) + gtk_iteration() + self.assertTrue(self.data_manager.get_autoload()) + self.assertTrue(self.autoload_toggle.get_active()) + + self.autoload_toggle.set_active(False) + gtk_iteration() + self.assertFalse(self.data_manager.get_autoload()) + self.assertFalse(self.autoload_toggle.get_active()) + + def test_autoload_is_set_when_changing_preset(self): + self.assertFalse(self.data_manager.get_autoload()) + self.assertFalse(self.autoload_toggle.get_active()) + + self.click_on_group("Foo Device 2") + FlowBoxTestUtils.set_active(self.preset_selection, "preset2") + + gtk_iteration() + self.assertTrue(self.data_manager.get_autoload()) + self.assertTrue(self.autoload_toggle.get_active()) + + def test_only_one_autoload_per_group(self): + self.assertFalse(self.data_manager.get_autoload()) + self.assertFalse(self.autoload_toggle.get_active()) + + self.click_on_group("Foo Device 2") + FlowBoxTestUtils.set_active(self.preset_selection, "preset2") + gtk_iteration() + self.assertTrue(self.data_manager.get_autoload()) + self.assertTrue(self.autoload_toggle.get_active()) + + FlowBoxTestUtils.set_active(self.preset_selection, "preset3") + gtk_iteration() + self.autoload_toggle.set_active(True) + gtk_iteration() + FlowBoxTestUtils.set_active(self.preset_selection, "preset2") + gtk_iteration() + self.assertFalse(self.data_manager.get_autoload()) + self.assertFalse(self.autoload_toggle.get_active()) + + def test_each_device_can_have_autoload(self): + self.autoload_toggle.set_active(True) + gtk_iteration() + self.assertTrue(self.data_manager.get_autoload()) + self.assertTrue(self.autoload_toggle.get_active()) + + self.click_on_group("Foo Device 2") + gtk_iteration() + self.autoload_toggle.set_active(True) + gtk_iteration() + self.assertTrue(self.data_manager.get_autoload()) + self.assertTrue(self.autoload_toggle.get_active()) + + self.click_on_group("Foo Device") + gtk_iteration() + self.assertTrue(self.data_manager.get_autoload()) + self.assertTrue(self.autoload_toggle.get_active()) + + def test_select_device_without_preset(self): + # creates a new empty preset when no preset exists for the device + self.click_on_group("Bar Device") + self.assertEqual( + FlowBoxTestUtils.get_active_entry(self.preset_selection).name, "new preset" + ) + self.assertEqual(len(self.data_manager.active_preset), 0) + + # it creates the file for that right away. It may have been possible + # to write it such that it doesn't (its empty anyway), but it does, + # so use that to test it in more detail. + path = PathUtils.get_preset_path("Bar Device", "new preset") + self.assertTrue(os.path.exists(path)) + with open(path, "r") as file: + self.assertEqual(file.read(), "") + + def test_recording_toggle_labels(self): + self.assertFalse(self.recording_status.get_visible()) + + self.recording_toggle.set_active(True) + gtk_iteration() + self.assertTrue(self.recording_status.get_visible()) + + self.recording_toggle.set_active(False) + gtk_iteration() + self.assertFalse(self.recording_status.get_visible()) + + def test_recording_label_updates_on_recording_finished(self): + self.assertFalse(self.recording_status.get_visible()) + + self.recording_toggle.set_active(True) + gtk_iteration() + self.assertTrue(self.recording_status.get_visible()) + + self.message_broker.signal(MessageType.recording_finished) + gtk_iteration() + self.assertFalse(self.recording_status.get_visible()) + self.assertFalse(self.recording_toggle.get_active()) + + def test_events_from_reader_service_arrive(self): + # load a device with more capabilities + self.controller.load_group("Foo Device 2") + gtk_iteration() + mock1 = MagicMock() + mock2 = MagicMock() + mock3 = MagicMock() + self.message_broker.subscribe(MessageType.combination_recorded, mock1) + self.message_broker.subscribe(MessageType.recording_finished, mock2) + self.message_broker.subscribe(MessageType.recording_started, mock3) + self.recording_toggle.set_active(True) + mock3.assert_called_once() + gtk_iteration() + + push_events( + fixtures.foo_device_2_keyboard, + [ + InputEvent(0, 0, 1, 30, 1), + InputEvent(0, 0, 1, 31, 1), + ], + ) + self.throttle(60) + origin = fixtures.foo_device_2_keyboard.get_device_hash() + mock1.assert_has_calls( + ( + call( + CombinationRecorded( + InputCombination( + [InputConfig(type=1, code=30, origin_hash=origin)] + ) + ) + ), + call( + CombinationRecorded( + InputCombination( + [ + InputConfig(type=1, code=30, origin_hash=origin), + InputConfig(type=1, code=31, origin_hash=origin), + ] + ) + ) + ), + ), + any_order=False, + ) + self.assertEqual(mock1.call_count, 2) + mock2.assert_not_called() + + push_events(fixtures.foo_device_2_keyboard, [InputEvent(0, 0, 1, 31, 0)]) + self.throttle(60) + self.assertEqual(mock1.call_count, 2) + mock2.assert_not_called() + + push_events(fixtures.foo_device_2_keyboard, [InputEvent(0, 0, 1, 30, 0)]) + self.throttle(60) + self.assertEqual(mock1.call_count, 2) + mock2.assert_called_once() + + self.assertFalse(self.recording_toggle.get_active()) + mock3.assert_called_once() + + def test_cannot_create_duplicate_input_combination(self): + # load a device with more capabilities + self.controller.load_group("Foo Device 2") + gtk_iteration() + + # update the combination of the active mapping + self.controller.start_key_recording() + push_events( + fixtures.foo_device_2_keyboard, + [InputEvent(0, 0, 1, 30, 1), InputEvent(0, 0, 1, 30, 0)], + ) + self.throttle(60) + + # if this fails with : this is the initial + # mapping or something, so it was never overwritten. + origin = fixtures.foo_device_2_keyboard.get_device_hash() + self.assertEqual( + self.data_manager.active_mapping.input_combination, + InputCombination([InputConfig(type=1, code=30, origin_hash=origin)]), + ) + + # create a new mapping + self.controller.create_mapping() + gtk_iteration() + self.assertEqual( + self.data_manager.active_mapping.input_combination, + InputCombination.empty_combination(), + ) + + # try to record the same combination + self.controller.start_key_recording() + push_events( + fixtures.foo_device_2_keyboard, + [InputEvent(0, 0, 1, 30, 1), InputEvent(0, 0, 1, 30, 0)], + ) + self.throttle(60) + # should still be the empty mapping + self.assertEqual( + self.data_manager.active_mapping.input_combination, + InputCombination.empty_combination(), + ) + + # try to record a different combination + self.controller.start_key_recording() + push_events(fixtures.foo_device_2_keyboard, [InputEvent(0, 0, 1, 30, 1)]) + self.throttle(60) + # nothing changed yet, as we got the duplicate combination + self.assertEqual( + self.data_manager.active_mapping.input_combination, + InputCombination.empty_combination(), + ) + push_events(fixtures.foo_device_2_keyboard, [InputEvent(0, 0, 1, 31, 1)]) + self.throttle(60) + # now the combination is different + self.assertEqual( + self.data_manager.active_mapping.input_combination, + InputCombination( + [ + InputConfig(type=1, code=30, origin_hash=origin), + InputConfig(type=1, code=31, origin_hash=origin), + ] + ), + ) + + # let's make the combination even longer + push_events(fixtures.foo_device_2_keyboard, [InputEvent(0, 0, 1, 32, 1)]) + self.throttle(60) + self.assertEqual( + self.data_manager.active_mapping.input_combination, + InputCombination( + [ + InputConfig(type=1, code=30, origin_hash=origin), + InputConfig(type=1, code=31, origin_hash=origin), + InputConfig(type=1, code=32, origin_hash=origin), + ] + ), + ) + + # make sure we stop recording by releasing all keys + push_events( + fixtures.foo_device_2_keyboard, + [ + InputEvent(0, 0, 1, 31, 0), + InputEvent(0, 0, 1, 30, 0), + InputEvent(0, 0, 1, 32, 0), + ], + ) + self.throttle(60) + + # sending a combination update now should not do anything + self.message_broker.publish( + CombinationRecorded(InputCombination([InputConfig(type=1, code=35)])) + ) + gtk_iteration() + self.assertEqual( + self.data_manager.active_mapping.input_combination, + InputCombination( + [ + InputConfig(type=1, code=30, origin_hash=origin), + InputConfig(type=1, code=31, origin_hash=origin), + InputConfig(type=1, code=32, origin_hash=origin), + ] + ), + ) + + def test_create_simple_mapping(self): + self.click_on_group("Foo Device 2") + # 1. create a mapping + self.create_mapping_btn.clicked() + gtk_iteration() + + self.assertEqual( + self.selection_label_listbox.get_selected_row().combination, + InputCombination.empty_combination(), + ) + self.assertEqual( + self.data_manager.active_mapping.input_combination, + InputCombination.empty_combination(), + ) + self.assertEqual( + self.selection_label_listbox.get_selected_row().name, "Empty Mapping" + ) + self.assertIsNone(self.data_manager.active_mapping.name) + + # there are now 2 mappings + self.assertEqual(len(self.selection_label_listbox.get_children()), 2) + self.assertEqual(len(self.data_manager.active_preset), 2) + + # 2. record a combination for that mapping + self.recording_toggle.set_active(True) + gtk_iteration() + push_events(fixtures.foo_device_2_keyboard, [InputEvent(0, 0, 1, 30, 1)]) + self.throttle(60) + push_events(fixtures.foo_device_2_keyboard, [InputEvent(0, 0, 1, 30, 0)]) + self.throttle(60) + + # check the input_combination + origin = fixtures.foo_device_2_keyboard.get_device_hash() + self.assertEqual( + self.selection_label_listbox.get_selected_row().combination, + InputCombination([InputConfig(type=1, code=30, origin_hash=origin)]), + ) + self.assertEqual( + self.data_manager.active_mapping.input_combination, + InputCombination([InputConfig(type=1, code=30, origin_hash=origin)]), + ) + self.assertEqual(self.selection_label_listbox.get_selected_row().name, "a") + self.assertIsNone(self.data_manager.active_mapping.name) + + # 3. set the output symbol + self.code_editor.get_buffer().set_text("Shift_L") + gtk_iteration() + + # the mapping and preset should be valid by now + self.assertTrue(self.data_manager.active_mapping.is_valid()) + self.assertTrue(self.data_manager.active_preset.is_valid()) + + self.assertEqual( + self.data_manager.active_mapping, + Mapping( + input_combination=InputCombination( + [InputConfig(type=1, code=30, origin_hash=origin)] + ), + output_symbol="Shift_L", + target_uinput="keyboard", + ), + ) + self.assertEqual(self.target_selection.get_active_id(), "keyboard") + buffer = self.code_editor.get_buffer() + self.assertEqual( + buffer.get_text(buffer.get_start_iter(), buffer.get_end_iter(), True), + "Shift_L", + ) + self.assertEqual( + self.selection_label_listbox.get_selected_row().combination, + InputCombination([InputConfig(type=1, code=30, origin_hash=origin)]), + ) + + # 4. update target + self.target_selection.set_active_id("keyboard + mouse") + gtk_iteration() + self.assertEqual( + self.data_manager.active_mapping, + Mapping( + input_combination=InputCombination( + [InputConfig(type=1, code=30, origin_hash=origin)] + ), + output_symbol="Shift_L", + target_uinput="keyboard + mouse", + ), + ) + + def test_show_status(self): + self.message_broker.publish(StatusData(0, "a")) + text = self.get_status_text() + self.assertEqual("a", text) + + def test_hat_switch(self): + # load a device with more capabilities + self.controller.load_group("Foo Device 2") + gtk_iteration() + + # it should be possible to add all of them + ev_1 = InputEvent.abs(evdev.ecodes.ABS_HAT0X, -1) + ev_2 = InputEvent.abs(evdev.ecodes.ABS_HAT0X, 1) + ev_3 = InputEvent.abs(evdev.ecodes.ABS_HAT0Y, -1) + ev_4 = InputEvent.abs(evdev.ecodes.ABS_HAT0Y, 1) + + def add_mapping(event, symbol) -> InputCombination: + """adds mapping and returns the expected input combination""" + self.controller.create_mapping() + gtk_iteration() + self.controller.start_key_recording() + push_events(fixtures.foo_device_2_gamepad, [event, event.modify(value=0)]) + self.throttle(60) + gtk_iteration() + self.code_editor.get_buffer().set_text(symbol) + gtk_iteration() + return InputCombination( + [ + InputConfig.from_input_event(event).modify( + origin_hash=fixtures.foo_device_2_gamepad.get_device_hash() + ) + ] + ) + + config_1 = add_mapping(ev_1, "a") + config_2 = add_mapping(ev_2, "b") + config_3 = add_mapping(ev_3, "c") + config_4 = add_mapping(ev_4, "d") + + self.assertEqual( + self.data_manager.active_preset.get_mapping( + InputCombination(config_1) + ).output_symbol, + "a", + ) + self.assertEqual( + self.data_manager.active_preset.get_mapping( + InputCombination(config_2) + ).output_symbol, + "b", + ) + self.assertEqual( + self.data_manager.active_preset.get_mapping( + InputCombination(config_3) + ).output_symbol, + "c", + ) + self.assertEqual( + self.data_manager.active_preset.get_mapping( + InputCombination(config_4) + ).output_symbol, + "d", + ) + + def test_combination(self): + # if this test freezes, try waiting a few minutes and then look for + # stack traces in the console + + # load a device with more capabilities + self.controller.load_group("Foo Device 2") + gtk_iteration() + + # it should be possible to write a combination + ev_1 = InputEvent.key( + evdev.ecodes.KEY_A, + 1, + origin_hash=fixtures.foo_device_2_keyboard.get_device_hash(), + ) + ev_2 = InputEvent.abs( + evdev.ecodes.ABS_HAT0X, + 1, + origin_hash=fixtures.foo_device_2_gamepad.get_device_hash(), + ) + ev_3 = InputEvent.key( + evdev.ecodes.KEY_C, + 1, + origin_hash=fixtures.foo_device_2_keyboard.get_device_hash(), + ) + ev_4 = InputEvent.abs( + evdev.ecodes.ABS_HAT0X, + -1, + origin_hash=fixtures.foo_device_2_gamepad.get_device_hash(), + ) + combination_1 = (ev_1, ev_2, ev_3) + combination_2 = (ev_2, ev_1, ev_3) + + # same as 1, but different D-Pad direction + combination_3 = (ev_1, ev_4, ev_3) + combination_4 = (ev_4, ev_1, ev_3) + + # same as 1, but the last combination is different + combination_5 = (ev_1, ev_3, ev_2) + combination_6 = (ev_3, ev_1, ev_2) + + def get_combination(combi: Iterable[InputEvent]) -> InputCombination: + """Create an InputCombination from a list of events. + + Ensures the origin_hash is set correctly. + """ + configs = [] + for event in combi: + config = InputConfig.from_input_event(event) + configs.append(config) + return InputCombination(configs) + + def add_mapping(combi: Iterable[InputEvent], symbol): + logger.info("add_mapping %s", combi) + self.controller.create_mapping() + gtk_iteration() + self.controller.start_key_recording() + for event in combi: + if event.type == EV_KEY: + push_event(fixtures.foo_device_2_keyboard, event) + if event.type == EV_ABS: + push_event(fixtures.foo_device_2_gamepad, event) + if event.type == EV_REL: + push_event(fixtures.foo_device_2_mouse, event) + + # avoid race condition if we switch fixture in push_event. The order + # of events needs to be correct. + self.throttle(20) + + for event in combi: + if event.type == EV_KEY: + push_event(fixtures.foo_device_2_keyboard, event.modify(value=0)) + if event.type == EV_ABS: + push_event(fixtures.foo_device_2_gamepad, event.modify(value=0)) + if event.type == EV_REL: + pass + + self.throttle(60) + gtk_iteration() + self.code_editor.get_buffer().set_text(symbol) + gtk_iteration() + + add_mapping(combination_1, "a") + + self.assertEqual( + self.data_manager.active_preset.get_mapping( + get_combination(combination_1) + ).output_symbol, + "a", + ) + self.assertEqual( + self.data_manager.active_preset.get_mapping( + get_combination(combination_2) + ).output_symbol, + "a", + ) + self.assertIsNone( + self.data_manager.active_preset.get_mapping(get_combination(combination_3)) + ) + self.assertIsNone( + self.data_manager.active_preset.get_mapping(get_combination(combination_4)) + ) + self.assertIsNone( + self.data_manager.active_preset.get_mapping(get_combination(combination_5)) + ) + self.assertIsNone( + self.data_manager.active_preset.get_mapping(get_combination(combination_6)) + ) + + # it won't write the same combination again, even if the + # first two events are in a different order + add_mapping(combination_2, "b") + self.assertEqual( + self.data_manager.active_preset.get_mapping( + get_combination(combination_1) + ).output_symbol, + "a", + ) + self.assertEqual( + self.data_manager.active_preset.get_mapping( + get_combination(combination_2) + ).output_symbol, + "a", + ) + self.assertIsNone( + self.data_manager.active_preset.get_mapping(get_combination(combination_3)) + ) + self.assertIsNone( + self.data_manager.active_preset.get_mapping(get_combination(combination_4)) + ) + self.assertIsNone( + self.data_manager.active_preset.get_mapping(get_combination(combination_5)) + ) + self.assertIsNone( + self.data_manager.active_preset.get_mapping(get_combination(combination_6)) + ) + + add_mapping(combination_3, "c") + self.assertEqual( + self.data_manager.active_preset.get_mapping( + get_combination(combination_1) + ).output_symbol, + "a", + ) + self.assertEqual( + self.data_manager.active_preset.get_mapping( + get_combination(combination_2) + ).output_symbol, + "a", + ) + self.assertEqual( + self.data_manager.active_preset.get_mapping( + get_combination(combination_3) + ).output_symbol, + "c", + ) + self.assertEqual( + self.data_manager.active_preset.get_mapping( + get_combination(combination_4) + ).output_symbol, + "c", + ) + self.assertIsNone( + self.data_manager.active_preset.get_mapping(get_combination(combination_5)) + ) + self.assertIsNone( + self.data_manager.active_preset.get_mapping(get_combination(combination_6)) + ) + + # same as with combination_2, the existing combination_3 blocks + # combination_4 because they have the same keys and end in the + # same key. + add_mapping(combination_4, "d") + self.assertEqual( + self.data_manager.active_preset.get_mapping( + get_combination(combination_1) + ).output_symbol, + "a", + ) + self.assertEqual( + self.data_manager.active_preset.get_mapping( + get_combination(combination_2) + ).output_symbol, + "a", + ) + self.assertEqual( + self.data_manager.active_preset.get_mapping( + get_combination(combination_3) + ).output_symbol, + "c", + ) + self.assertEqual( + self.data_manager.active_preset.get_mapping( + get_combination(combination_4) + ).output_symbol, + "c", + ) + self.assertIsNone( + self.data_manager.active_preset.get_mapping(get_combination(combination_5)) + ) + self.assertIsNone( + self.data_manager.active_preset.get_mapping(get_combination(combination_6)) + ) + + add_mapping(combination_5, "e") + self.assertEqual( + self.data_manager.active_preset.get_mapping( + get_combination(combination_1) + ).output_symbol, + "a", + ) + self.assertEqual( + self.data_manager.active_preset.get_mapping( + get_combination(combination_2) + ).output_symbol, + "a", + ) + self.assertEqual( + self.data_manager.active_preset.get_mapping( + get_combination(combination_3) + ).output_symbol, + "c", + ) + self.assertEqual( + self.data_manager.active_preset.get_mapping( + get_combination(combination_4) + ).output_symbol, + "c", + ) + self.assertEqual( + self.data_manager.active_preset.get_mapping( + get_combination(combination_5) + ).output_symbol, + "e", + ) + self.assertEqual( + self.data_manager.active_preset.get_mapping( + get_combination(combination_6) + ).output_symbol, + "e", + ) + + error_icon = self.user_interface.get("error_status_icon") + warning_icon = self.user_interface.get("warning_status_icon") + + self.assertFalse(error_icon.get_visible()) + self.assertFalse(warning_icon.get_visible()) + + def test_only_one_empty_mapping_possible(self): + self.assertEqual( + self.selection_label_listbox.get_selected_row().combination, + InputCombination([InputConfig(type=1, code=5)]), + ) + self.assertEqual(len(self.selection_label_listbox.get_children()), 1) + self.assertEqual(len(self.data_manager.active_preset), 1) + + self.create_mapping_btn.clicked() + gtk_iteration() + self.assertEqual( + self.selection_label_listbox.get_selected_row().combination, + InputCombination.empty_combination(), + ) + self.assertEqual(len(self.selection_label_listbox.get_children()), 2) + self.assertEqual(len(self.data_manager.active_preset), 2) + + self.create_mapping_btn.clicked() + gtk_iteration() + self.assertEqual(len(self.selection_label_listbox.get_children()), 2) + self.assertEqual(len(self.data_manager.active_preset), 2) + + def test_selection_labels_sort_alphabetically(self): + self.controller.load_preset("preset1") + # contains two mappings (1,1,1 -> b) and (1,2,1 -> a) + gtk_iteration() + # we expect (1,2,1 -> a) to be selected because "1" < "Escape" + self.assertEqual(self.data_manager.active_mapping.output_symbol, "a") + self.assertIs( + self.selection_label_listbox.get_row_at_index(0), + self.selection_label_listbox.get_selected_row(), + ) + + self.recording_toggle.set_active(True) + gtk_iteration() + self.message_broker.publish( + CombinationRecorded( + InputCombination([InputConfig(type=EV_KEY, code=KEY_Q)]) + ) + ) + gtk_iteration() + self.message_broker.signal(MessageType.recording_finished) + gtk_iteration() + # the combination and the order changed "Escape" < "q" + self.assertEqual(self.data_manager.active_mapping.output_symbol, "a") + self.assertIs( + self.selection_label_listbox.get_row_at_index(1), + self.selection_label_listbox.get_selected_row(), + ) + + def test_selection_labels_sort_empty_mapping_to_the_bottom(self): + # make sure we have a mapping which would sort to the bottom only + # considering alphanumeric sorting: "q" > "Empty Mapping" + self.controller.load_preset("preset1") + gtk_iteration() + self.recording_toggle.set_active(True) + gtk_iteration() + self.message_broker.publish( + CombinationRecorded( + InputCombination([InputConfig(type=EV_KEY, code=KEY_Q)]) + ) + ) + gtk_iteration() + self.message_broker.signal(MessageType.recording_finished) + gtk_iteration() + + self.controller.create_mapping() + gtk_iteration() + row: MappingSelectionLabel = self.selection_label_listbox.get_selected_row() + self.assertEqual(row.combination, InputCombination.empty_combination()) + self.assertEqual(row.label.get_text(), "Empty Mapping") + self.assertIs(self.selection_label_listbox.get_row_at_index(2), row) + + def test_select_mapping(self): + self.controller.load_preset("preset1") + # contains two mappings (1,1,1 -> b) and (1,2,1 -> a) + gtk_iteration() + # we expect (1,2,1 -> a) to be selected because "1" < "Escape" + self.assertEqual(self.data_manager.active_mapping.output_symbol, "a") + + # select the second entry in the listbox + row = self.selection_label_listbox.get_row_at_index(1) + self.selection_label_listbox.select_row(row) + gtk_iteration() + self.assertEqual(self.data_manager.active_mapping.output_symbol, "b") + + def test_selection_label_uses_name_if_available(self): + self.controller.load_preset("preset1") + gtk_iteration() + row: MappingSelectionLabel = self.selection_label_listbox.get_selected_row() + self.assertEqual(row.label.get_text(), "1") + self.assertIs(row, self.selection_label_listbox.get_row_at_index(0)) + + self.controller.update_mapping(name="foo") + gtk_iteration() + self.assertEqual(row.label.get_text(), "foo") + self.assertIs(row, self.selection_label_listbox.get_row_at_index(1)) + + # Empty Mapping still sorts to the bottom + self.controller.create_mapping() + gtk_iteration() + row = self.selection_label_listbox.get_selected_row() + self.assertEqual(row.combination, InputCombination.empty_combination()) + self.assertEqual(row.label.get_text(), "Empty Mapping") + self.assertIs(self.selection_label_listbox.get_row_at_index(2), row) + + def test_fake_empty_mapping_does_not_sort_to_bottom(self): + """If someone chooses to name a mapping "Empty Mapping" + it is not sorted to the bottom""" + self.controller.load_preset("preset1") + gtk_iteration() + + self.controller.update_mapping(name="Empty Mapping") + self.throttle(20) # sorting seems to take a bit + + # "Empty Mapping" < "Escape" so we still expect this to be the first row + row = self.selection_label_listbox.get_selected_row() + self.assertIs(row, self.selection_label_listbox.get_row_at_index(0)) + + # now create a real empty mapping + self.controller.create_mapping() + self.throttle(20) + + # for some reason we no longer can use assertIs maybe a gtk bug? + # self.assertIs(row, self.selection_label_listbox.get_row_at_index(0)) + + # we expect the fake empty mapping in row 0 and the real one in row 2 + self.selection_label_listbox.select_row( + self.selection_label_listbox.get_row_at_index(0) + ) + gtk_iteration() + self.assertEqual(self.data_manager.active_mapping.name, "Empty Mapping") + self.assertEqual(self.data_manager.active_mapping.output_symbol, "a") + + self.selection_label_listbox.select_row( + self.selection_label_listbox.get_row_at_index(2) + ) + self.assertIsNone(self.data_manager.active_mapping.name) + self.assertEqual( + self.data_manager.active_mapping.input_combination, + InputCombination.empty_combination(), + ) + + def test_remove_mapping(self): + self.controller.load_preset("preset1") + gtk_iteration() + self.assertEqual(len(self.data_manager.active_preset), 2) + self.assertEqual(len(self.selection_label_listbox.get_children()), 2) + + with patch_confirm_delete(self.user_interface): + self.delete_mapping_btn.clicked() + gtk_iteration() + + self.assertEqual(len(self.data_manager.active_preset), 1) + self.assertEqual(len(self.selection_label_listbox.get_children()), 1) + + def test_problematic_combination(self): + # load a device with more capabilities + self.controller.load_group("Foo Device 2") + gtk_iteration() + + def add_mapping(combi: Iterable[Tuple[int, int, int]], symbol): + combi = [InputEvent(0, 0, *t) for t in combi] + self.controller.create_mapping() + gtk_iteration() + self.controller.start_key_recording() + push_events(fixtures.foo_device_2_keyboard, combi) + push_events( + fixtures.foo_device_2_keyboard, + [event.modify(value=0) for event in combi], + ) + self.throttle(60) + gtk_iteration() + self.code_editor.get_buffer().set_text(symbol) + gtk_iteration() + + combination = [(EV_KEY, KEY_LEFTSHIFT, 1), (EV_KEY, 82, 1)] + + add_mapping(combination, "b") + text = self.get_status_text() + self.assertIn("shift", text) + + error_icon = self.user_interface.get("error_status_icon") + warning_icon = self.user_interface.get("warning_status_icon") + + self.assertFalse(error_icon.get_visible()) + self.assertTrue(warning_icon.get_visible()) + + def test_rename_and_save(self): + # only a basic test, TestController and TestDataManager go more in detail + self.rename_input.set_text("foo") + self.rename_btn.clicked() + gtk_iteration() + + preset_path = f"{PathUtils.config_path()}/presets/Foo Device/foo.json" + self.assertTrue(os.path.exists(preset_path)) + error_icon = self.user_interface.get("error_status_icon") + self.assertFalse(error_icon.get_visible()) + + def save(): + raise PermissionError + + with patch.object(self.data_manager.active_preset, "save", save): + self.code_editor.get_buffer().set_text("f") + gtk_iteration() + status = self.get_status_text() + self.assertIn("Permission denied", status) + + with patch_confirm_delete(self.user_interface): + self.delete_preset_btn.clicked() + gtk_iteration() + self.assertFalse(os.path.exists(preset_path)) + + def test_check_for_unknown_symbols(self): + first_input = InputCombination([InputConfig(type=1, code=1)]) + second_input = InputCombination([InputConfig(type=1, code=2)]) + status = self.user_interface.get("status_bar") + error_icon = self.user_interface.get("error_status_icon") + warning_icon = self.user_interface.get("warning_status_icon") + + self.controller.load_preset("preset1") + self.throttle(20) + + # Switch to the first mapping, and change it + self.controller.load_mapping(first_input) + gtk_iteration() + self.controller.update_mapping(output_symbol="foo") + gtk_iteration() + + # Switch to the second mapping, and change it + self.controller.load_mapping(second_input) + gtk_iteration() + self.controller.update_mapping(output_symbol="qux") + gtk_iteration() + + # The tooltip should show the error of the currently selected mapping + tooltip = status.get_tooltip_text().lower() + self.assertIn("qux", tooltip) + self.assertTrue(error_icon.get_visible()) + self.assertFalse(warning_icon.get_visible()) + + # So switching to the other mapping changes the tooltip + self.controller.load_mapping(first_input) + gtk_iteration() + tooltip = status.get_tooltip_text().lower() + self.assertIn("foo", tooltip) + + # It will still save it though + with open(PathUtils.get_preset_path("Foo Device", "preset1")) as f: + content = f.read() + self.assertIn("qux", content) + self.assertIn("foo", content) + + # Fix the current active mapping. + # It should show the error of the other mapping now. + self.controller.update_mapping(output_symbol="a") + gtk_iteration() + tooltip = status.get_tooltip_text().lower() + self.assertIn("qux", tooltip) + self.assertTrue(error_icon.get_visible()) + self.assertFalse(warning_icon.get_visible()) + + # Fix the other mapping as well. No tooltip should be shown afterward. + self.controller.load_mapping(second_input) + gtk_iteration() + self.controller.update_mapping(output_symbol="b") + gtk_iteration() + tooltip = status.get_tooltip_text() + self.assertIsNone(tooltip) + self.assertFalse(error_icon.get_visible()) + self.assertFalse(warning_icon.get_visible()) + + def test_no_validation_tooltip_for_empty_mappings(self): + self.controller.load_preset("preset1") + self.throttle(20) + + status = self.user_interface.get("status_bar") + self.assertIsNone(status.get_tooltip_text()) + + self.controller.create_mapping() + gtk_iteration() + self.assertTrue(self.controller.is_empty_mapping()) + self.assertIsNone(status.get_tooltip_text()) + + def test_check_macro_syntax(self): + status = self.status_bar + error_icon = self.user_interface.get("error_status_icon") + warning_icon = self.user_interface.get("warning_status_icon") + + self.code_editor.get_buffer().set_text("k(1))") + tooltip = status.get_tooltip_text().lower() + self.assertIn("brackets", tooltip) + self.assertTrue(error_icon.get_visible()) + self.assertFalse(warning_icon.get_visible()) + + self.code_editor.get_buffer().set_text("k(1)") + tooltip = (status.get_tooltip_text() or "").lower() + self.assertNotIn("brackets", tooltip) + self.assertFalse(error_icon.get_visible()) + self.assertFalse(warning_icon.get_visible()) + + self.assertEqual( + self.data_manager.active_mapping.output_symbol, + "k(1)", + ) + + def test_check_on_typing(self): + status = self.user_interface.get("status_bar") + error_icon = self.user_interface.get("error_status_icon") + warning_icon = self.user_interface.get("warning_status_icon") + + tooltip = status.get_tooltip_text() + # nothing wrong yet + self.assertIsNone(tooltip) + + # now change the mapping by typing into the field + buffer = self.code_editor.get_buffer() + buffer.set_text("sdfgkj()") + gtk_iteration() + + # the mapping is validated + tooltip = status.get_tooltip_text() + self.assertIn("Unknown function sdfgkj", tooltip) + self.assertTrue(error_icon.get_visible()) + self.assertFalse(warning_icon.get_visible()) + + self.assertEqual(self.data_manager.active_mapping.output_symbol, "sdfgkj()") + + def test_select_device(self): + # simple test to make sure we can switch between devices + # more detailed tests in TestController and TestDataManager + self.click_on_group("Bar Device") + gtk_iteration() + + entries = {*FlowBoxTestUtils.get_child_names(self.preset_selection)} + self.assertEqual(entries, {"new preset"}) + + self.click_on_group("Foo Device") + gtk_iteration() + + entries = {*FlowBoxTestUtils.get_child_names(self.preset_selection)} + self.assertEqual(entries, {"preset1", "preset2", "preset3"}) + + # make sure a preset and mapping was loaded + self.assertIsNotNone(self.data_manager.active_preset) + self.assertEqual( + self.data_manager.active_preset.name, + FlowBoxTestUtils.get_active_entry(self.preset_selection).name, + ) + self.assertIsNotNone(self.data_manager.active_mapping) + self.assertEqual( + self.data_manager.active_mapping.input_combination, + self.selection_label_listbox.get_selected_row().combination, + ) + + def test_select_preset(self): + # simple test to make sure we can switch between presets + # more detailed tests in TestController and TestDataManager + self.click_on_group("Foo Device 2") + gtk_iteration() + FlowBoxTestUtils.set_active(self.preset_selection, "preset1") + gtk_iteration() + + mappings = { + row.combination for row in self.selection_label_listbox.get_children() + } + self.assertEqual( + mappings, + { + InputCombination([InputConfig(type=1, code=1)]), + InputCombination([InputConfig(type=1, code=2)]), + }, + ) + self.assertFalse(self.autoload_toggle.get_active()) + + FlowBoxTestUtils.set_active(self.preset_selection, "preset2") + gtk_iteration() + + mappings = { + row.combination for row in self.selection_label_listbox.get_children() + } + self.assertEqual( + mappings, + { + InputCombination([InputConfig(type=1, code=3)]), + InputCombination([InputConfig(type=1, code=4)]), + }, + ) + self.assertTrue(self.autoload_toggle.get_active()) + + def test_copy_preset(self): + # simple tests to ensure it works + # more detailed tests in TestController and TestDataManager + + # check the initial state + entries = {*FlowBoxTestUtils.get_child_names(self.preset_selection)} + self.assertEqual(entries, {"preset1", "preset2", "preset3"}) + self.assertEqual( + FlowBoxTestUtils.get_active_entry(self.preset_selection).name, "preset3" + ) + + self.copy_preset_btn.clicked() + gtk_iteration() + entries = {*FlowBoxTestUtils.get_child_names(self.preset_selection)} + self.assertEqual(entries, {"preset1", "preset2", "preset3", "preset3 copy"}) + self.assertEqual( + FlowBoxTestUtils.get_active_entry(self.preset_selection).name, + "preset3 copy", + ) + + self.copy_preset_btn.clicked() + gtk_iteration() + + entries = {*FlowBoxTestUtils.get_child_names(self.preset_selection)} + self.assertEqual( + entries, {"preset1", "preset2", "preset3", "preset3 copy", "preset3 copy 2"} + ) + + def test_wont_start(self): + def wait(): + """Wait for the injector process to finish doing stuff.""" + for _ in range(10): + time.sleep(0.1) + gtk_iteration() + if "Starting" not in self.get_status_text(): + return + + error_icon = self.user_interface.get("error_status_icon") + self.controller.load_group("Bar Device") + + # empty + self.start_injector_btn.clicked() + gtk_iteration() + wait() + text = self.get_status_text() + self.assertIn("add mappings", text) + self.assertTrue(error_icon.get_visible()) + self.assertNotEqual(self.daemon.get_state("Bar Device"), InjectorState.RUNNING) + + # device grabbing fails + self.controller.load_group("Foo Device 2") + gtk_iteration() + + for i in range(2): + # just pressing apply again will overwrite the previous error + self.grab_fails = True + self.start_injector_btn.clicked() + gtk_iteration() + + text = self.get_status_text() + # it takes a little bit of time + self.assertIn("Starting injection", text) + self.assertFalse(error_icon.get_visible()) + wait() + text = self.get_status_text() + self.assertIn("Failed to apply preset", text) + self.assertTrue(error_icon.get_visible()) + self.assertNotEqual( + self.daemon.get_state("Foo Device 2"), InjectorState.RUNNING + ) + + # this time work properly + + self.grab_fails = False + self.start_injector_btn.clicked() + gtk_iteration() + text = self.get_status_text() + self.assertIn("Starting injection", text) + self.assertFalse(error_icon.get_visible()) + wait() + text = self.get_status_text() + self.assertIn("Applied", text) + text = self.get_status_text() + self.assertNotIn("CTRL + DEL", text) # only shown if btn_left mapped + self.assertFalse(error_icon.get_visible()) + self.assertEqual(self.daemon.get_state("Foo Device 2"), InjectorState.RUNNING) + + def test_start_with_btn_left(self): + self.controller.load_group("Foo Device 2") + gtk_iteration() + + self.controller.create_mapping() + gtk_iteration() + self.controller.update_mapping( + input_combination=InputCombination([InputConfig.btn_left()]), + output_symbol="a", + ) + gtk_iteration() + + def wait(): + """Wait for the injector process to finish doing stuff.""" + for _ in range(10): + time.sleep(0.1) + gtk_iteration() + if "Starting" not in self.get_status_text(): + return + + # first apply, shows btn_left warning + self.start_injector_btn.clicked() + gtk_iteration() + text = self.get_status_text() + self.assertIn("click", text) + self.assertEqual(self.daemon.get_state("Foo Device 2"), InjectorState.UNKNOWN) + + # second apply, overwrites + self.start_injector_btn.clicked() + gtk_iteration() + wait() + self.assertEqual(self.daemon.get_state("Foo Device 2"), InjectorState.RUNNING) + text = self.get_status_text() + # because btn_left is mapped, shows help on how to stop + # injecting via the keyboard + self.assertIn("CTRL + DEL", text) + + def test_cannot_record_keys(self): + self.controller.load_group("Foo Device 2") + self.assertNotEqual(self.data_manager.get_state(), InjectorState.RUNNING) + self.assertNotIn("Stop", self.get_status_text()) + + self.recording_toggle.set_active(True) + gtk_iteration() + self.assertTrue(self.recording_toggle.get_active()) + self.controller.stop_key_recording() + gtk_iteration() + self.assertFalse(self.recording_toggle.get_active()) + + self.start_injector_btn.clicked() + gtk_iteration() + # wait for the injector to start + for _ in range(10): + time.sleep(0.1) + gtk_iteration() + if "Starting" not in self.get_status_text(): + break + + self.assertEqual(self.data_manager.get_state(), InjectorState.RUNNING) + + # the toggle button should reset itself shortly + self.recording_toggle.set_active(True) + gtk_iteration() + self.assertFalse(self.recording_toggle.get_active()) + text = self.get_status_text() + self.assertIn("Stop", text) + + def test_start_injecting(self): + self.controller.load_group("Foo Device 2") + + with spy(self.daemon, "set_config_dir") as spy1: + with spy(self.daemon, "start_injecting") as spy2: + self.start_injector_btn.clicked() + gtk_iteration() + # correctly uses group.key, not group.name + spy2.assert_called_once_with("Foo Device 2", "preset3") + + spy1.assert_called_once_with(PathUtils.get_config_path()) + + for _ in range(10): + time.sleep(0.1) + gtk_iteration() + if self.data_manager.get_state() == InjectorState.RUNNING: + break + + # fail here so we don't block forever + self.assertEqual(self.data_manager.get_state(), InjectorState.RUNNING) + + # this is a stupid workaround for the bad test fixtures + # by switching the group we make sure that the reader-service no longer + # listens for events on "Foo Device 2" otherwise we would have two processes + # (reader-service and injector) reading the same pipe which can block this test + # indefinitely + self.controller.load_group("Foo Device") + gtk_iteration() + + push_events( + fixtures.foo_device_2_keyboard, + [ + InputEvent.key(5, 1), + InputEvent.key(5, 0), + ], + ) + + event = uinput_write_history_pipe[0].recv() + self.assertEqual(event.type, evdev.events.EV_KEY) + self.assertEqual(event.code, KEY_A) + self.assertEqual(event.value, 1) + + event = uinput_write_history_pipe[0].recv() + self.assertEqual(event.type, evdev.events.EV_KEY) + self.assertEqual(event.code, KEY_A) + self.assertEqual(event.value, 0) + + # the input-remapper device will not be shown + self.controller.refresh_groups() + gtk_iteration() + for child in self.device_selection.get_children(): + device_group_entry = child.get_children()[0] + self.assertNotIn("input-remapper", device_group_entry.name) + + def test_stop_injecting(self): + self.controller.load_group("Foo Device 2") + self.start_injector_btn.clicked() + gtk_iteration() + + for _ in range(10): + time.sleep(0.1) + gtk_iteration() + if self.data_manager.get_state() == InjectorState.RUNNING: + break + + # fail here so we don't block forever + self.assertEqual(self.data_manager.get_state(), InjectorState.RUNNING) + + # stupid fixture workaround + self.controller.load_group("Foo Device") + gtk_iteration() + + pipe = uinput_write_history_pipe[0] + self.assertFalse(pipe.poll()) + + push_events( + fixtures.foo_device_2_keyboard, + [ + InputEvent.key(5, 1), + InputEvent.key(5, 0), + ], + ) + + time.sleep(0.2) + self.assertTrue(pipe.poll()) + while pipe.poll(): + pipe.recv() + + self.controller.load_group("Foo Device 2") + self.controller.stop_injecting() + gtk_iteration() + + for _ in range(10): + time.sleep(0.1) + gtk_iteration() + if self.data_manager.get_state() == InjectorState.STOPPED: + break + self.assertEqual(self.data_manager.get_state(), InjectorState.STOPPED) + + push_events( + fixtures.foo_device_2_keyboard, + [ + InputEvent.key(5, 1), + InputEvent.key(5, 0), + ], + ) + time.sleep(0.2) + self.assertFalse(pipe.poll()) + + def test_delete_preset(self): + # as per test_initial_state we already have preset3 loaded + self.assertTrue( + os.path.exists(PathUtils.get_preset_path("Foo Device", "preset3")) + ) + + with patch_confirm_delete(self.user_interface, Gtk.ResponseType.CANCEL): + self.delete_preset_btn.clicked() + gtk_iteration() + self.assertTrue( + os.path.exists(PathUtils.get_preset_path("Foo Device", "preset3")) + ) + self.assertEqual(self.data_manager.active_preset.name, "preset3") + self.assertEqual(self.data_manager.active_group.name, "Foo Device") + + with patch_confirm_delete(self.user_interface): + self.delete_preset_btn.clicked() + gtk_iteration() + self.assertFalse( + os.path.exists(PathUtils.get_preset_path("Foo Device", "preset3")) + ) + self.assertEqual(self.data_manager.active_preset.name, "preset2") + self.assertEqual(self.data_manager.active_group.name, "Foo Device") + + def test_refresh_groups(self): + # sanity check: preset3 should be the newest + self.assertEqual( + FlowBoxTestUtils.get_active_entry(self.preset_selection).name, "preset3" + ) + + # select the older one + FlowBoxTestUtils.set_active(self.preset_selection, "preset1") + gtk_iteration() + self.assertEqual(self.data_manager.active_preset.name, "preset1") + + # add a device that doesn't exist to the dropdown + unknown_key = "key-1234" + self.device_selection.insert( + DeviceGroupEntry(self.message_broker, self.controller, None, unknown_key), + 0, + # 0, [unknown_key, None, "foo"] + ) + + self.controller.refresh_groups() + gtk_iteration() + self.throttle(200) + # the gui should not jump to a different preset suddenly + self.assertEqual(self.data_manager.active_preset.name, "preset1") + + # just to verify that the mtime still tells us that preset3 is the newest one + self.assertEqual(self.controller.get_a_preset(), "preset3") + + # the list contains correct entries + # and the non-existing entry should be removed + names = FlowBoxTestUtils.get_child_names(self.device_selection) + icons = FlowBoxTestUtils.get_child_icons(self.device_selection) + self.assertNotIn(unknown_key, names) + + self.assertIn("Foo Device", names) + self.assertIn("Foo Device 2", names) + self.assertIn("Bar Device", names) + self.assertIn("gamepad", names) + + self.assertIn("input-keyboard", icons) + self.assertIn("input-gaming", icons) + self.assertIn("input-keyboard", icons) + self.assertIn("input-gaming", icons) + + # it won't crash due to "list index out of range" + # when `types` is an empty list. Won't show an icon + self.data_manager._reader_client.groups.find(key="Foo Device 2").types = [] + self.data_manager._reader_client.publish_groups() + gtk_iteration() + self.assertIn( + "Foo Device 2", + FlowBoxTestUtils.get_child_names(self.device_selection), + ) + + def test_shared_presets(self): + # devices with the same name (but different key because the key is + # unique) share the same presets. + # Those devices would usually be of the same model of keyboard for example + # Todo: move this to unit tests, there is no point in having the ui around + self.controller.load_group("Foo Device") + presets1 = self.data_manager.get_preset_names() + self.controller.load_group("Foo Device 2") + gtk_iteration() + presets2 = self.data_manager.get_preset_names() + self.controller.load_group("Bar Device") + gtk_iteration() + presets3 = self.data_manager.get_preset_names() + + self.assertEqual(presets1, presets2) + self.assertNotEqual(presets1, presets3) + + def test_delete_last_preset(self): + with patch_confirm_delete(self.user_interface): + # as per test_initial_state we already have preset3 loaded + self.assertEqual(self.data_manager.active_preset.name, "preset3") + + self.delete_preset_btn.clicked() + gtk_iteration() + # the next newest preset should be loaded + self.assertEqual(self.data_manager.active_preset.name, "preset2") + self.delete_preset_btn.clicked() + gtk_iteration() + self.delete_preset_btn.clicked() + # the ui should be clean + self.assert_gui_clean() + device_path = f"{PathUtils.config_path()}/presets/{self.data_manager.active_group.name}" + self.assertTrue(os.path.exists(f"{device_path}/new preset.json")) + + self.delete_preset_btn.clicked() + gtk_iteration() + # deleting an empty preset als doesn't do weird stuff + self.assert_gui_clean() + device_path = f"{PathUtils.config_path()}/presets/{self.data_manager.active_group.name}" + self.assertTrue(os.path.exists(f"{device_path}/new preset.json")) + + def test_enable_disable_output(self): + # load a group without any presets + self.controller.load_group("Bar Device") + + # should be disabled by default since no key is recorded yet + self.assertEqual(self.get_unfiltered_symbol_input_text(), SET_KEY_FIRST) + self.assertFalse(self.output_box.get_sensitive()) + + # create a mapping + self.controller.create_mapping() + gtk_iteration() + + # should still be disabled + self.assertEqual(self.get_unfiltered_symbol_input_text(), SET_KEY_FIRST) + self.assertFalse(self.output_box.get_sensitive()) + + # enable it by sending a combination + self.controller.start_key_recording() + gtk_iteration() + push_events( + fixtures.bar_device, + [ + InputEvent(0, 0, 1, 30, 1), + InputEvent(0, 0, 1, 30, 0), + ], + ) + self.throttle(100) # give time for the input to arrive + + self.assertEqual( + self.get_unfiltered_symbol_input_text(), CodeEditor.placeholder + ) + self.assertTrue(self.output_box.get_sensitive()) + + # disable it by deleting the mapping + with patch_confirm_delete(self.user_interface): + self.delete_mapping_btn.clicked() + gtk_iteration() + + self.assertEqual(self.get_unfiltered_symbol_input_text(), SET_KEY_FIRST) + self.assertFalse(self.output_box.get_sensitive()) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/system/gui/test_user_interface.py b/tests/system/gui/test_user_interface.py new file mode 100644 index 0000000..37365d1 --- /dev/null +++ b/tests/system/gui/test_user_interface.py @@ -0,0 +1,113 @@ +import unittest +from unittest.mock import MagicMock + +import gi +from evdev.ecodes import EV_KEY, KEY_A + +gi.require_version("Gdk", "3.0") +gi.require_version("Gtk", "3.0") +gi.require_version("GLib", "2.0") +gi.require_version("GtkSource", "4") +from gi.repository import Gtk, Gdk, GLib + +from inputremapper.gui.utils import gtk_iteration +from inputremapper.gui.messages.message_broker import MessageBroker, MessageType +from inputremapper.gui.user_interface import UserInterface +from inputremapper.configs.mapping import MappingData +from inputremapper.configs.input_config import InputCombination, InputConfig +from tests.lib.test_setup import test_setup + + +@test_setup +class TestUserInterface(unittest.TestCase): + def setUp(self) -> None: + self.message_broker = MessageBroker() + self.controller_mock = MagicMock() + self.user_interface = UserInterface(self.message_broker, self.controller_mock) + + def tearDown(self) -> None: + super().tearDown() + self.message_broker.signal(MessageType.terminate) + GLib.timeout_add(0, self.user_interface.window.destroy) + GLib.timeout_add(0, Gtk.main_quit) + Gtk.main() + + def test_shortcut(self): + mock = MagicMock() + self.user_interface.shortcuts[Gdk.KEY_x] = mock + + event = Gdk.Event() + event.key.keyval = Gdk.KEY_x + event.key.state = Gdk.ModifierType.SHIFT_MASK + self.user_interface.window.emit("key-press-event", event) + gtk_iteration() + mock.assert_not_called() + + event.key.state = Gdk.ModifierType.CONTROL_MASK + self.user_interface.window.emit("key-press-event", event) + gtk_iteration() + mock.assert_called_once() + + mock.reset_mock() + event.key.keyval = Gdk.KEY_y + self.user_interface.window.emit("key-press-event", event) + gtk_iteration() + mock.assert_not_called() + + def test_connected_shortcuts(self): + should_be_connected = {Gdk.KEY_q, Gdk.KEY_r, Gdk.KEY_Delete, Gdk.KEY_n} + connected = set(self.user_interface.shortcuts.keys()) + self.assertEqual(connected, should_be_connected) + + self.assertIs( + self.user_interface.shortcuts[Gdk.KEY_q], self.controller_mock.close + ) + self.assertIs( + self.user_interface.shortcuts[Gdk.KEY_r], + self.controller_mock.refresh_groups, + ) + self.assertIs( + self.user_interface.shortcuts[Gdk.KEY_Delete], + self.controller_mock.stop_injecting, + ) + + def test_connect_disconnect_shortcuts(self): + mock = MagicMock() + self.user_interface.shortcuts[Gdk.KEY_x] = mock + + event = Gdk.Event() + event.key.keyval = Gdk.KEY_x + event.key.state = Gdk.ModifierType.CONTROL_MASK + self.user_interface.disconnect_shortcuts() + self.user_interface.window.emit("key-press-event", event) + gtk_iteration() + mock.assert_not_called() + + self.user_interface.connect_shortcuts() + gtk_iteration() + self.user_interface.window.emit("key-press-event", event) + gtk_iteration() + mock.assert_called_once() + + def test_combination_label_shows_combination(self): + self.message_broker.publish( + MappingData( + input_combination=InputCombination( + [InputConfig(type=EV_KEY, code=KEY_A)] + ), + name="foo", + ) + ) + gtk_iteration() + label: Gtk.Label = self.user_interface.get("combination-label") + self.assertEqual(label.get_text(), "a") + self.assertEqual(label.get_opacity(), 1) + + def test_combination_label_shows_text_when_empty_mapping(self): + self.message_broker.publish(MappingData()) + gtk_iteration() + label: Gtk.Label = self.user_interface.get("combination-label") + self.assertEqual(label.get_text(), "no input configured") + + # 0.5 != 0.501960..., for whatever reason this number is all screwed up + self.assertAlmostEqual(label.get_opacity(), 0.5, delta=0.1) diff --git a/tests/system/test_dbus.py b/tests/system/test_dbus.py new file mode 100644 index 0000000..81dadc8 --- /dev/null +++ b/tests/system/test_dbus.py @@ -0,0 +1,85 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + +import multiprocessing +import os +import time +import unittest +from xml.etree import ElementTree as ET + +import gi + +from tests.lib.test_setup import is_service_running + +gi.require_version("Gtk", "3.0") +from gi.repository import Gtk + +from inputremapper.daemon import Daemon, DAEMON +from tests.lib.test_setup import test_setup + + +def gtk_iteration(): + """Iterate while events are pending.""" + while Gtk.events_pending(): + Gtk.main_iteration() + + +@test_setup +class TestDBusDaemon(unittest.TestCase): + def setUp(self): + # You need to install input-remapper into your system in order for this test + # to work. + self.process = multiprocessing.Process( + target=os.system, args=("input-remapper-service -d",) + ) + self.process.start() + time.sleep(1) + + # should not use pkexec, but rather connect to the previously + # spawned process + self.interface = Daemon.connect() + + def tearDown(self): + self.interface.stop_all() + os.system("pkill -f input-remapper-service") + + for _ in range(10): + time.sleep(0.1) + if not is_service_running(): + break + + self.assertFalse(is_service_running()) + + def test_can_connect(self): + # Seriously what does this test even do? It checks if the correct interface + # name is set, apparently, which does not sound like "can_connect" at all. + + # it's a remote dbus object + introspection_xml = self.interface.Introspect() + root = ET.fromstring(introspection_xml) + interfaces = [node.get("name") for node in root.findall(".//interface")] + + self.assertIn(DAEMON.interface_name, interfaces) + self.assertFalse(isinstance(self.interface, Daemon)) + self.assertEqual(self.interface.hello("foo"), "foo") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/system/test_numlockx.py b/tests/system/test_numlockx.py new file mode 100644 index 0000000..59f9cd5 --- /dev/null +++ b/tests/system/test_numlockx.py @@ -0,0 +1,56 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + + +import unittest + +from inputremapper.injection.numlock import is_numlock_on, set_numlock, ensure_numlock +from tests.lib.test_setup import test_setup + + +@test_setup +class TestNumlock(unittest.TestCase): + def test_numlock(self): + before = is_numlock_on() + + set_numlock(not before) # should change + self.assertEqual(not before, is_numlock_on()) + + @ensure_numlock + def wrapped_1(): + set_numlock(not is_numlock_on()) + + @ensure_numlock + def wrapped_2(): + pass + + # should not change + wrapped_1() + self.assertEqual(not before, is_numlock_on()) + wrapped_2() + self.assertEqual(not before, is_numlock_on()) + + # toggle one more time to restore the previous configuration + set_numlock(before) + self.assertEqual(before, is_numlock_on()) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/__init__.py b/tests/unit/__init__.py new file mode 100644 index 0000000..8a6637a --- /dev/null +++ b/tests/unit/__init__.py @@ -0,0 +1 @@ +"""Tests that don't require a complete linux desktop.""" diff --git a/tests/unit/test_context.py b/tests/unit/test_context.py new file mode 100644 index 0000000..db01145 --- /dev/null +++ b/tests/unit/test_context.py @@ -0,0 +1,142 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + +import unittest +from unittest.mock import patch + +from evdev.ecodes import ( + EV_REL, + EV_ABS, + ABS_X, + ABS_Y, + REL_WHEEL_HI_RES, + REL_HWHEEL_HI_RES, +) + +from inputremapper.configs.input_config import InputCombination +from inputremapper.configs.mapping import Mapping +from inputremapper.configs.preset import Preset +from inputremapper.injection.context import Context +from inputremapper.injection.global_uinputs import GlobalUInputs, UInput +from inputremapper.injection.mapping_handlers.macro_handler import MacroHandler +from inputremapper.injection.mapping_handlers.mapping_parser import MappingParser +from inputremapper.input_event import InputEvent +from tests.lib.test_setup import test_setup + + +@test_setup +class TestContext(unittest.TestCase): + def test_callbacks(self): + global_uinputs = GlobalUInputs(UInput) + mapping_parser = MappingParser(global_uinputs) + + preset = Preset() + cfg = { + "input_combination": InputCombination.from_tuples((EV_ABS, ABS_X)), + "target_uinput": "mouse", + "output_type": EV_REL, + "output_code": REL_HWHEEL_HI_RES, + } + preset.add(Mapping(**cfg)) # abs x -> wheel + cfg["input_combination"] = InputCombination.from_tuples((EV_ABS, ABS_Y)) + cfg["output_code"] = REL_WHEEL_HI_RES + preset.add(Mapping(**cfg)) # abs y -> wheel + + preset.add( + Mapping.from_combination( + InputCombination.from_tuples((1, 31)), "keyboard", "key(a)" + ) + ) + preset.add( + Mapping.from_combination( + InputCombination.from_tuples((1, 32)), "keyboard", "b" + ) + ) + + # overlapping combination for (1, 32, 1) + preset.add( + Mapping.from_combination( + InputCombination.from_tuples((1, 32), (1, 33), (1, 34)), + "keyboard", + "c", + ) + ) + + # map abs x to key "b" + preset.add( + Mapping.from_combination( + InputCombination.from_tuples((EV_ABS, ABS_X, 20)), + "keyboard", + "d", + ), + ) + + context = Context(preset, {}, {}, mapping_parser) + + expected_num_callbacks = { + # ABS_X -> "d" and ABS_X -> wheel have the same type and code + InputEvent.abs(ABS_X, 1): 2, + InputEvent.abs(ABS_Y, 1): 1, + InputEvent.key(31, 1): 1, + # even though we have 2 mappings with this type and code, we only expect + # one callback because they both map to keys. We don't want to trigger two + # mappings with the same key press + InputEvent.key(32, 1): 1, + InputEvent.key(33, 1): 1, + InputEvent.key(34, 1): 1, + } + + self.assertEqual( + set([event.input_match_hash for event in expected_num_callbacks.keys()]), + set(context._notify_callbacks.keys()), + ) + for input_event, num_callbacks in expected_num_callbacks.items(): + self.assertEqual( + num_callbacks, + len(context.get_notify_callbacks(input_event)), + ) + + # 7 unique input events in the preset + self.assertEqual(7, len(context._handlers)) + + def test_reset(self): + global_uinputs = GlobalUInputs(UInput) + mapping_parser = MappingParser(global_uinputs) + + preset = Preset() + preset.add( + Mapping.from_combination( + InputCombination.from_tuples((1, 31)), + "keyboard", + "key(a)", + ) + ) + + context = Context(preset, {}, {}, mapping_parser) + + self.assertEqual(1, len(context._handlers)) + + with patch.object(MacroHandler, "reset") as reset_mock: + context.reset() + reset_mock.assert_called_once() + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/test_control.py b/tests/unit/test_control.py new file mode 100644 index 0000000..f6442c5 --- /dev/null +++ b/tests/unit/test_control.py @@ -0,0 +1,407 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + +"""Testing the input-remapper-control command""" + +import collections +import os +import time +import unittest +from unittest.mock import patch, MagicMock + +from inputremapper.bin.input_remapper_control import InputRemapperControlBin +from inputremapper.configs.global_config import GlobalConfig +from inputremapper.configs.migrations import Migrations +from inputremapper.configs.paths import PathUtils +from inputremapper.configs.preset import Preset +from inputremapper.daemon import Daemon +from inputremapper.groups import groups +from inputremapper.injection.global_uinputs import GlobalUInputs, FrontendUInput +from inputremapper.injection.mapping_handlers.mapping_parser import MappingParser +from tests.lib.test_setup import test_setup +from tests.lib.tmp import tmp + +options = collections.namedtuple( + "options", + ["command", "config_dir", "preset", "device", "list_devices", "key_names", "debug"], +) + + +def remove_timeout_from_calls(method: str): + # Remove the timeout argument, which is used by dasbus but not actually + # passed to the Daemon class methods. Since we create the Daemon object directly + # for this test it would raise an exception otherwise, for example: + # TypeError: Daemon.autoload_single() got an unexpected keyword argument 'timeout' + def decorator(func): + def wrapped(*args, **kwargs): + original_method = getattr(Daemon, method) + with patch.object( + Daemon, + method, + lambda *args, timeout=0, **kwargs: original_method(*args, **kwargs), + ): + return func(*args, **kwargs) + + return wrapped + + return decorator + + +@test_setup +class TestControl(unittest.TestCase): + def setUp(self): + self.global_config = GlobalConfig() + self.global_uinputs = GlobalUInputs(FrontendUInput) + self.migrations = Migrations(self.global_uinputs) + self.mapping_parser = MappingParser(self.global_uinputs) + self.input_remapper_control = InputRemapperControlBin( + self.global_config, self.migrations + ) + + @remove_timeout_from_calls("autoload") + @remove_timeout_from_calls("autoload_single") + def test_autoload(self): + device_keys = ["Foo Device 2", "Bar Device"] + groups_ = [groups.find(key=key) for key in device_keys] + presets = ["bar0", "bar", "bar2"] + paths = [ + PathUtils.get_preset_path(groups_[0].name, presets[0]), + PathUtils.get_preset_path(groups_[1].name, presets[1]), + PathUtils.get_preset_path(groups_[1].name, presets[2]), + ] + + Preset(paths[0]).save() + Preset(paths[1]).save() + Preset(paths[2]).save() + + daemon = Daemon(self.global_config, self.global_uinputs, self.mapping_parser) + + self.input_remapper_control.set_daemon(daemon) + + start_history = [] + stop_counter = 0 + + # using an actual injector is not within the scope of this test + class Injector: + def stop_injecting(self, *args, **kwargs): + nonlocal stop_counter + stop_counter += 1 + + def start_injecting(device: str, preset: str): + print(f'\033[90mstart_injecting "{device}" "{preset}"\033[0m') + start_history.append((device, preset)) + daemon.injectors[device] = Injector() + + patch.object(daemon, "start_injecting", start_injecting).start() + + self.global_config.set_autoload_preset(groups_[0].key, presets[0]) + self.global_config.set_autoload_preset(groups_[1].key, presets[1]) + + self.input_remapper_control.communicate( + command="autoload", + config_dir=None, + preset=None, + device=None, + ) + + self.assertEqual(len(start_history), 2) + self.assertEqual(start_history[0], (groups_[0].key, presets[0])) + self.assertEqual(start_history[1], (groups_[1].key, presets[1])) + self.assertIn(groups_[0].key, daemon.injectors) + self.assertIn(groups_[1].key, daemon.injectors) + self.assertFalse( + daemon.autoload_history.may_autoload(groups_[0].key, presets[0]) + ) + self.assertFalse( + daemon.autoload_history.may_autoload(groups_[1].key, presets[1]) + ) + + # calling autoload again doesn't load redundantly + self.input_remapper_control.communicate( + command="autoload", + config_dir=None, + preset=None, + device=None, + ) + self.assertEqual(len(start_history), 2) + self.assertEqual(stop_counter, 0) + self.assertFalse( + daemon.autoload_history.may_autoload(groups_[0].key, presets[0]) + ) + self.assertFalse( + daemon.autoload_history.may_autoload(groups_[1].key, presets[1]) + ) + + # unless the injection in question ist stopped + self.input_remapper_control.communicate( + command="stop", + config_dir=None, + preset=None, + device=groups_[0].key, + ) + self.assertEqual(stop_counter, 1) + self.assertTrue( + daemon.autoload_history.may_autoload(groups_[0].key, presets[0]) + ) + self.assertFalse( + daemon.autoload_history.may_autoload(groups_[1].key, presets[1]) + ) + self.input_remapper_control.communicate( + command="autoload", + config_dir=None, + preset=None, + device=None, + ) + self.assertEqual(len(start_history), 3) + self.assertEqual(start_history[2], (groups_[0].key, presets[0])) + self.assertFalse( + daemon.autoload_history.may_autoload(groups_[0].key, presets[0]) + ) + self.assertFalse( + daemon.autoload_history.may_autoload(groups_[1].key, presets[1]) + ) + + # if a device name is passed, will only start injecting for that one + self.input_remapper_control.communicate( + command="stop-all", + config_dir=None, + preset=None, + device=None, + ) + self.assertTrue( + daemon.autoload_history.may_autoload(groups_[0].key, presets[0]) + ) + self.assertTrue( + daemon.autoload_history.may_autoload(groups_[1].key, presets[1]) + ) + self.assertEqual(stop_counter, 3) + self.global_config.set_autoload_preset(groups_[1].key, presets[2]) + self.input_remapper_control.communicate( + command="autoload", + config_dir=None, + preset=None, + device=groups_[1].key, + ) + self.assertEqual(len(start_history), 4) + self.assertEqual(start_history[3], (groups_[1].key, presets[2])) + self.assertTrue( + daemon.autoload_history.may_autoload(groups_[0].key, presets[0]) + ) + self.assertFalse( + daemon.autoload_history.may_autoload(groups_[1].key, presets[2]) + ) + + # autoloading for the same device again redundantly will not autoload + # again + self.input_remapper_control.communicate( + command="autoload", + config_dir=None, + preset=None, + device=groups_[1].key, + ) + self.assertEqual(len(start_history), 4) + self.assertEqual(stop_counter, 3) + self.assertFalse( + daemon.autoload_history.may_autoload(groups_[1].key, presets[2]) + ) + + # any other arbitrary preset may be autoloaded + self.assertTrue(daemon.autoload_history.may_autoload(groups_[1].key, "quuuux")) + + # after 15 seconds it may be autoloaded again + daemon.autoload_history._autoload_history[groups_[1].key] = ( + time.time() - 16, + presets[2], + ) + self.assertTrue( + daemon.autoload_history.may_autoload(groups_[1].key, presets[2]) + ) + + @remove_timeout_from_calls("autoload") + def test_autoload_other_path(self): + device_names = ["Foo Device", "Bar Device"] + groups_ = [groups.find(name=name) for name in device_names] + presets = ["bar123", "bar2"] + config_dir = os.path.join(tmp, "qux", "quux") + paths = [ + os.path.join(config_dir, "presets", device_names[0], presets[0] + ".json"), + os.path.join(config_dir, "presets", device_names[1], presets[1] + ".json"), + ] + + Preset(paths[0]).save() + Preset(paths[1]).save() + + daemon = Daemon(self.global_config, self.global_uinputs, self.mapping_parser) + self.input_remapper_control.set_daemon(daemon) + + start_history = [] + daemon.start_injecting = lambda *args: start_history.append(args) + + self.global_config.path = os.path.join(config_dir, "config.json") + self.global_config.load_config() + self.global_config.set_autoload_preset(device_names[0], presets[0]) + self.global_config.set_autoload_preset(device_names[1], presets[1]) + + self.input_remapper_control.communicate( + command="autoload", + config_dir=config_dir, + preset=None, + device=None, + ) + + self.assertEqual(len(start_history), 2) + self.assertEqual(start_history[0], (groups_[0].key, presets[0])) + self.assertEqual(start_history[1], (groups_[1].key, presets[1])) + + def test_start_stop(self): + group = groups.find(key="Foo Device 2") + preset = "preset9" + + daemon = Daemon(self.global_config, self.global_uinputs, self.mapping_parser) + self.input_remapper_control.set_daemon(daemon) + + start_history = [] + stop_history = [] + stop_all_history = [] + daemon.start_injecting = lambda *args: start_history.append(args) + daemon.stop_injecting = lambda *args: stop_history.append(args) + daemon.stop_all = lambda *args: stop_all_history.append(args) + + self.input_remapper_control.communicate( + command="start", + config_dir=None, + preset=preset, + device=group.paths[0], + ) + self.assertEqual(len(start_history), 1) + self.assertEqual(start_history[0], (group.key, preset)) + + self.input_remapper_control.communicate( + command="stop", + config_dir=None, + preset=None, + device=group.paths[1], + ) + self.assertEqual(len(stop_history), 1) + # provided any of the groups paths as --device argument, figures out + # the correct group.key to use here + self.assertEqual(stop_history[0], (group.key,)) + + self.input_remapper_control.communicate( + command="stop-all", + config_dir=None, + preset=None, + device=None, + ) + self.assertEqual(len(stop_all_history), 1) + self.assertEqual(stop_all_history[0], ()) + + @patch.object(Daemon, "quit") + def test_quit(self, quit_mock: MagicMock) -> None: + group = groups.find(key="Foo Device 2") + assert group is not None + preset = "preset9" + + daemon = Daemon(self.global_config, self.global_uinputs, self.mapping_parser) + self.input_remapper_control.set_daemon(daemon) + + self.input_remapper_control.communicate( + command="quit", + config_dir=None, + preset=preset, + device=group.paths[0], + ) + + quit_mock.assert_called_once() + + def test_config_not_found(self): + key = "Foo Device 2" + path = "~/a/preset.json" + config_dir = "/foo/bar" + + daemon = Daemon(self.global_config, self.global_uinputs, self.mapping_parser) + self.input_remapper_control.set_daemon(daemon) + + start_history = [] + stop_history = [] + daemon.start_injecting = lambda *args: start_history.append(args) + daemon.stop_injecting = lambda *args: stop_history.append(args) + + self.assertRaises( + SystemExit, + lambda: self.input_remapper_control.communicate( + command="start", + config_dir=config_dir, + preset=path, + device=key, + ), + ) + + self.assertRaises( + SystemExit, + lambda: self.input_remapper_control.communicate( + command="stop", + config_dir=config_dir, + preset=None, + device=key, + ), + ) + + def test_autoload_config_dir(self): + daemon = Daemon(self.global_config, self.global_uinputs, self.mapping_parser) + + path = os.path.join(tmp, "foo") + os.makedirs(path) + with open(os.path.join(path, "config.json"), "w") as file: + file.write('{"autoload":{"foo": "bar"}}') + + self.assertIsNone(self.global_config.get_autoload_preset("foo")) + daemon.set_config_dir(path) + # since daemon and this test share the same memory, the global_config + # object that this test can access will be modified + self.assertEqual(self.global_config.get_autoload_preset("foo"), "bar") + + # passing a path that doesn't exist or a path that doesn't contain + # a config.json file won't do anything + os.makedirs(os.path.join(tmp, "bar")) + daemon.set_config_dir(os.path.join(tmp, "bar")) + self.assertEqual(self.global_config.get_autoload_preset("foo"), "bar") + daemon.set_config_dir(os.path.join(tmp, "qux")) + self.assertEqual(self.global_config.get_autoload_preset("foo"), "bar") + + def test_internals_reader(self): + with patch.object(os, "system") as os_system_patch: + self.input_remapper_control.internals("start-reader-service", False) + os_system_patch.assert_called_once() + self.assertIn( + "input-remapper-reader-service", os_system_patch.call_args.args[0] + ) + self.assertNotIn("-d", os_system_patch.call_args.args[0]) + + def test_internals_daemon(self): + with patch.object(os, "system") as os_system_patch: + self.input_remapper_control.internals("start-daemon", True) + os_system_patch.assert_called_once() + self.assertIn("input-remapper-service", os_system_patch.call_args.args[0]) + self.assertIn("-d", os_system_patch.call_args.args[0]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/test_controller.py b/tests/unit/test_controller.py new file mode 100644 index 0000000..c328833 --- /dev/null +++ b/tests/unit/test_controller.py @@ -0,0 +1,1627 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + +import os.path +import unittest +from typing import List +from unittest.mock import patch, MagicMock, call + +import gi +from evdev.ecodes import EV_ABS, ABS_X, ABS_Y, ABS_RX + +from inputremapper.configs.keyboard_layout import keyboard_layout +from inputremapper.injection.injector import InjectorState + +gi.require_version("Gtk", "3.0") +from gi.repository import Gtk + +from inputremapper.configs.input_config import InputCombination, InputConfig +from inputremapper.groups import _Groups +from inputremapper.gui.messages.message_broker import ( + MessageBroker, + MessageType, +) +from inputremapper.gui.messages.message_data import ( + GroupsData, + GroupData, + PresetData, + StatusData, + CombinationRecorded, + CombinationUpdate, + UserConfirmRequest, +) +from inputremapper.gui.reader_client import ReaderClient +from inputremapper.gui.utils import CTX_ERROR, CTX_APPLY, gtk_iteration +from inputremapper.gui.gettext import _ +from inputremapper.injection.global_uinputs import GlobalUInputs, FrontendUInput +from inputremapper.configs.mapping import UIMapping, MappingData, Mapping +from tests.lib.spy import spy +from tests.lib.patches import FakeDaemonProxy +from tests.lib.fixtures import fixtures, prepare_presets +from inputremapper.configs.global_config import GlobalConfig +from inputremapper.gui.controller import Controller, MAPPING_DEFAULTS +from inputremapper.gui.data_manager import DataManager, DEFAULT_PRESET_NAME +from inputremapper.configs.paths import PathUtils +from inputremapper.configs.preset import Preset +from tests.lib.test_setup import test_setup + + +@test_setup +class TestController(unittest.TestCase): + def setUp(self) -> None: + super().setUp() + self.message_broker = MessageBroker() + uinputs = GlobalUInputs(FrontendUInput) + uinputs.prepare_all() + self.data_manager = DataManager( + self.message_broker, + GlobalConfig(), + ReaderClient(self.message_broker, _Groups()), + FakeDaemonProxy(), + uinputs, + keyboard_layout, + ) + self.user_interface = MagicMock() + self.controller = Controller(self.message_broker, self.data_manager) + self.controller.set_gui(self.user_interface) + + def test_should_get_newest_group(self): + """get_a_group should the newest group.""" + with patch.object( + self.data_manager, "get_newest_group_key", MagicMock(return_value="foo") + ): + self.assertEqual(self.controller.get_a_group(), "foo") + + def test_should_get_any_group(self): + """get_a_group should return a valid group.""" + with patch.object( + self.data_manager, + "get_newest_group_key", + MagicMock(side_effect=FileNotFoundError), + ): + fixture_keys = [fixture.group_key or fixture.name for fixture in fixtures] + self.assertIn(self.controller.get_a_group(), fixture_keys) + + def test_should_get_newest_preset(self): + """get_a_group should the newest group.""" + with patch.object( + self.data_manager, "get_newest_preset_name", MagicMock(return_value="bar") + ): + self.data_manager.load_group("Foo Device") + self.assertEqual(self.controller.get_a_preset(), "bar") + + def test_should_get_any_preset(self): + """get_a_preset should return a new preset if none exist.""" + self.data_manager.load_group("Foo Device") + # the default name + self.assertEqual(self.controller.get_a_preset(), "new preset") + + def test_on_init_should_provide_uinputs(self): + calls = [] + + def f(data): + calls.append(data) + + self.message_broker.subscribe(MessageType.uinputs, f) + self.message_broker.signal(MessageType.init) + self.assertEqual( + ["keyboard", "gamepad", "mouse", "keyboard + mouse"], + list(calls[-1].uinputs.keys()), + ) + + def test_on_init_should_provide_groups(self): + calls: List[GroupsData] = [] + + def f(groups): + calls.append(groups) + + self.message_broker.subscribe(MessageType.groups, f) + self.message_broker.signal(MessageType.init) + self.assertEqual( + ["Foo Device", "Foo Device 2", "Bar Device", "gamepad", "Qux/[Device]?"], + list(calls[-1].groups.keys()), + ) + + def test_on_init_should_provide_a_group(self): + calls: List[GroupData] = [] + + def f(data): + calls.append(data) + + self.message_broker.subscribe(MessageType.group, f) + self.message_broker.signal(MessageType.init) + self.assertGreaterEqual(len(calls), 1) + + def test_on_init_should_provide_a_preset(self): + calls: List[PresetData] = [] + + def f(data): + calls.append(data) + + self.message_broker.subscribe(MessageType.preset, f) + self.message_broker.signal(MessageType.init) + self.assertGreaterEqual(len(calls), 1) + + def test_on_init_should_provide_a_mapping(self): + """Only if there is one.""" + prepare_presets() + calls: List[MappingData] = [] + + def f(data): + calls.append(data) + + self.message_broker.subscribe(MessageType.mapping, f) + self.message_broker.signal(MessageType.init) + self.assertTrue(calls[-1].is_valid()) + + def test_on_init_should_provide_a_default_mapping(self): + """If there is no real preset available""" + calls: List[MappingData] = [] + + def f(data): + calls.append(data) + + self.message_broker.subscribe(MessageType.mapping, f) + self.message_broker.signal(MessageType.init) + for m in calls: + self.assertEqual(m, UIMapping(**MAPPING_DEFAULTS)) + + def test_on_load_group_should_provide_preset(self): + with patch.object(self.data_manager, "load_preset") as mock: + self.controller.load_group("Foo Device") + mock.assert_called_once() + + def test_on_load_group_should_provide_mapping(self): + """If there is one""" + prepare_presets() + calls: List[MappingData] = [] + + def f(data): + calls.append(data) + + self.message_broker.subscribe(MessageType.mapping, f) + self.controller.load_group(group_key="Foo Device 2") + self.assertTrue(calls[-1].is_valid()) + + def test_on_load_group_should_provide_default_mapping(self): + """If there is none.""" + calls: List[MappingData] = [] + + def f(data): + calls.append(data) + + self.message_broker.subscribe(MessageType.mapping, f) + + self.controller.load_group(group_key="Foo Device") + for m in calls: + self.assertEqual(m, UIMapping(**MAPPING_DEFAULTS)) + + def test_on_load_preset_should_provide_mapping(self): + """If there is one.""" + prepare_presets() + self.data_manager.load_group("Foo Device 2") + calls: List[MappingData] = [] + + def f(data): + calls.append(data) + + self.message_broker.subscribe(MessageType.mapping, f) + self.controller.load_preset(name="preset2") + self.assertTrue(calls[-1].is_valid()) + + def test_on_load_preset_should_provide_default_mapping(self): + """If there is none.""" + Preset(PathUtils.get_preset_path("Foo Device", "bar")).save() + self.data_manager.load_group("Foo Device 2") + calls: List[MappingData] = [] + + def f(data): + calls.append(data) + + self.message_broker.subscribe(MessageType.mapping, f) + self.controller.load_preset(name="bar") + for m in calls: + self.assertEqual(m, UIMapping(**MAPPING_DEFAULTS)) + + def test_on_delete_preset_asks_for_confirmation(self): + prepare_presets() + self.message_broker.signal(MessageType.init) + mock = MagicMock() + self.message_broker.subscribe(MessageType.user_confirm_request, mock) + self.controller.delete_preset() + mock.assert_called_once() + + def test_deletes_preset_when_confirmed(self): + prepare_presets() + self.assertTrue( + os.path.isfile(PathUtils.get_preset_path("Foo Device", "preset2")) + ) + + self.data_manager.load_group("Foo Device 2") + self.data_manager.load_preset("preset2") + self.message_broker.subscribe( + MessageType.user_confirm_request, lambda msg: msg.respond(True) + ) + self.controller.delete_preset() + self.assertFalse( + os.path.exists(PathUtils.get_preset_path("Foo Device", "preset2")) + ) + + def test_does_not_delete_preset_when_not_confirmed(self): + prepare_presets() + self.assertTrue( + os.path.isfile(PathUtils.get_preset_path("Foo Device", "preset2")) + ) + self.data_manager.load_group("Foo Device 2") + self.data_manager.load_preset("preset2") + self.user_interface.confirm_delete.configure_mock( + return_value=Gtk.ResponseType.CANCEL + ) + self.controller.delete_preset() + self.assertTrue( + os.path.isfile(PathUtils.get_preset_path("Foo Device", "preset2")) + ) + + def test_copy_preset(self): + prepare_presets() + self.assertTrue( + os.path.isfile(PathUtils.get_preset_path("Foo Device", "preset2")) + ) + self.data_manager.load_group("Foo Device 2") + self.data_manager.load_preset("preset2") + self.controller.copy_preset() + + self.assertTrue( + os.path.exists(PathUtils.get_preset_path("Foo Device", "preset2")) + ) + self.assertTrue( + os.path.exists(PathUtils.get_preset_path("Foo Device", "preset2 copy")) + ) + + def test_copy_preset_should_add_number(self): + prepare_presets() + self.assertTrue( + os.path.isfile(PathUtils.get_preset_path("Foo Device", "preset2")) + ) + + self.data_manager.load_group("Foo Device 2") + self.data_manager.load_preset("preset2") + self.controller.copy_preset() # creates "preset2 copy" + self.data_manager.load_preset("preset2") + self.controller.copy_preset() # creates "preset2 copy 2" + + self.assertTrue( + os.path.exists(PathUtils.get_preset_path("Foo Device", "preset2")) + ) + self.assertTrue( + os.path.exists(PathUtils.get_preset_path("Foo Device", "preset2 copy")) + ) + self.assertTrue( + os.path.exists(PathUtils.get_preset_path("Foo Device", "preset2 copy 2")) + ) + + def test_copy_preset_should_increment_existing_number(self): + prepare_presets() + self.assertTrue( + os.path.isfile(PathUtils.get_preset_path("Foo Device", "preset2")) + ) + + self.data_manager.load_group("Foo Device 2") + self.data_manager.load_preset("preset2") + self.controller.copy_preset() # creates "preset2 copy" + self.data_manager.load_preset("preset2") + self.controller.copy_preset() # creates "preset2 copy 2" + self.data_manager.load_preset("preset2") + self.controller.copy_preset() # creates "preset2 copy 3" + + self.assertTrue( + os.path.exists(PathUtils.get_preset_path("Foo Device", "preset2")) + ) + self.assertTrue( + os.path.exists(PathUtils.get_preset_path("Foo Device", "preset2 copy")) + ) + self.assertTrue( + os.path.exists(PathUtils.get_preset_path("Foo Device", "preset2 copy 2")) + ) + self.assertTrue( + os.path.exists(PathUtils.get_preset_path("Foo Device", "preset2 copy 3")) + ) + + def test_copy_preset_should_not_append_copy_twice(self): + prepare_presets() + self.assertTrue( + os.path.isfile(PathUtils.get_preset_path("Foo Device", "preset2")) + ) + + self.data_manager.load_group("Foo Device 2") + self.data_manager.load_preset("preset2") + self.controller.copy_preset() # creates "preset2 copy" + self.controller.copy_preset() # creates "preset2 copy 2" not "preset2 copy copy" + + self.assertTrue( + os.path.exists(PathUtils.get_preset_path("Foo Device", "preset2")) + ) + self.assertTrue( + os.path.exists(PathUtils.get_preset_path("Foo Device", "preset2 copy")) + ) + self.assertTrue( + os.path.exists(PathUtils.get_preset_path("Foo Device", "preset2 copy 2")) + ) + + def test_copy_preset_should_not_append_copy_to_copy_with_number(self): + prepare_presets() + self.assertTrue( + os.path.isfile(PathUtils.get_preset_path("Foo Device", "preset2")) + ) + + self.data_manager.load_group("Foo Device 2") + self.data_manager.load_preset("preset2") + self.controller.copy_preset() # creates "preset2 copy" + self.data_manager.load_preset("preset2") + self.controller.copy_preset() # creates "preset2 copy 2" + self.controller.copy_preset() # creates "preset2 copy 3" not "preset2 copy 2 copy" + + self.assertTrue( + os.path.exists(PathUtils.get_preset_path("Foo Device", "preset2")) + ) + self.assertTrue( + os.path.exists(PathUtils.get_preset_path("Foo Device", "preset2 copy")) + ) + self.assertTrue( + os.path.exists(PathUtils.get_preset_path("Foo Device", "preset2 copy 2")) + ) + self.assertTrue( + os.path.exists(PathUtils.get_preset_path("Foo Device", "preset2 copy 3")) + ) + + def test_rename_preset(self): + prepare_presets() + self.assertTrue( + os.path.isfile(PathUtils.get_preset_path("Foo Device", "preset2")) + ) + self.assertFalse(os.path.exists(PathUtils.get_preset_path("Foo Device", "foo"))) + + self.data_manager.load_group("Foo Device 2") + self.data_manager.load_preset("preset2") + self.controller.rename_preset(new_name="foo") + + self.assertFalse( + os.path.exists(PathUtils.get_preset_path("Foo Device", "preset2")) + ) + self.assertTrue(os.path.exists(PathUtils.get_preset_path("Foo Device", "foo"))) + + def test_rename_preset_sanitized(self): + Preset(PathUtils.get_preset_path("Qux/[Device]?", "bla")).save() + + self.assertTrue( + os.path.isfile(PathUtils.get_preset_path("Qux/[Device]?", "bla")) + ) + self.assertFalse( + os.path.exists(PathUtils.get_preset_path("Qux/[Device]?", "blubb")) + ) + + self.data_manager.load_group("Qux/[Device]?") + self.data_manager.load_preset("bla") + self.controller.rename_preset(new_name="foo:/bar") + + # all functions expect the true name, which is also shown to the user, but on + # the file system it always uses sanitized names. + self.assertTrue( + os.path.exists(PathUtils.get_preset_path("Qux/[Device]?", "foo__bar")) + ) + + # since the name is never stored in an un-sanitized way, this can't work + self.assertFalse( + os.path.exists(PathUtils.get_preset_path("Qux/[Device]?", "foo:/bar")) + ) + + path = os.path.join( + PathUtils.config_path(), "presets", "Qux_[Device]_", "foo__bar.json" + ) + self.assertTrue(os.path.exists(path)) + + # using the sanitized name in function calls works as well + self.assertTrue( + os.path.isfile(PathUtils.get_preset_path("Qux_[Device]_", "foo__bar")) + ) + + def test_rename_preset_should_pick_available_name(self): + prepare_presets() + self.assertTrue( + os.path.exists(PathUtils.get_preset_path("Foo Device", "preset2")) + ) + self.assertTrue( + os.path.exists(PathUtils.get_preset_path("Foo Device", "preset3")) + ) + self.assertFalse( + os.path.exists(PathUtils.get_preset_path("Foo Device", "preset3 2")) + ) + + self.data_manager.load_group("Foo Device") + self.data_manager.load_preset("preset2") + self.controller.rename_preset(new_name="preset3") + + self.assertFalse( + os.path.exists(PathUtils.get_preset_path("Foo Device", "preset2")) + ) + self.assertTrue( + os.path.exists(PathUtils.get_preset_path("Foo Device", "preset3")) + ) + self.assertTrue( + os.path.exists(PathUtils.get_preset_path("Foo Device", "preset3 2")) + ) + + def test_rename_preset_should_not_rename_to_empty_name(self): + prepare_presets() + self.assertTrue( + os.path.exists(PathUtils.get_preset_path("Foo Device", "preset2")) + ) + + self.data_manager.load_group("Foo Device") + self.data_manager.load_preset("preset2") + self.controller.rename_preset(new_name="") + + self.assertTrue( + os.path.exists(PathUtils.get_preset_path("Foo Device", "preset2")) + ) + + def test_rename_preset_should_not_update_same_name(self): + """When the new name is the same as the current name.""" + prepare_presets() + self.assertTrue( + os.path.exists(PathUtils.get_preset_path("Foo Device", "preset2")) + ) + + self.data_manager.load_group("Foo Device 2") + self.data_manager.load_preset("preset2") + self.controller.rename_preset(new_name="preset2") + + self.assertTrue( + os.path.exists(PathUtils.get_preset_path("Foo Device", "preset2")) + ) + self.assertFalse( + os.path.exists(PathUtils.get_preset_path("Foo Device", "preset2 2")) + ) + + def test_on_add_preset_uses_default_name(self): + self.assertFalse( + os.path.exists(PathUtils.get_preset_path("Foo Device", DEFAULT_PRESET_NAME)) + ) + + self.data_manager.load_group("Foo Device 2") + + self.controller.add_preset() + self.assertTrue( + os.path.exists(PathUtils.get_preset_path("Foo Device", "new preset")) + ) + + def test_on_add_preset_uses_provided_name(self): + self.assertFalse(os.path.exists(PathUtils.get_preset_path("Foo Device", "foo"))) + + self.data_manager.load_group("Foo Device 2") + + self.controller.add_preset(name="foo") + self.assertTrue(os.path.exists(PathUtils.get_preset_path("Foo Device", "foo"))) + + def test_on_add_preset_shows_permission_error_status(self): + self.data_manager.load_group("Foo Device 2") + + msg = None + + def f(data): + nonlocal msg + msg = data + + self.message_broker.subscribe(MessageType.status_msg, f) + mock = MagicMock(side_effect=PermissionError) + with patch("inputremapper.configs.preset.Preset.save", mock): + self.controller.add_preset("foo") + + mock.assert_called() + self.assertIsNotNone(msg) + self.assertIn("Permission denied", msg.msg) + + def test_on_update_mapping(self): + """Update_mapping should call data_manager.update_mapping. + + This ensures mapping_changed is emitted. + """ + prepare_presets() + self.data_manager.load_group("Foo Device 2") + self.data_manager.load_preset("preset2") + self.data_manager.load_mapping( + combination=InputCombination([InputConfig(type=1, code=4)]) + ) + + with patch.object(self.data_manager, "update_mapping") as mock: + self.controller.update_mapping( + name="foo", + output_symbol="f", + release_timeout=0.3, + ) + mock.assert_called_once() + + def test_create_mapping_will_load_the_created_mapping(self): + prepare_presets() + self.data_manager.load_group("Foo Device 2") + self.data_manager.load_preset("preset2") + + calls: List[MappingData] = [] + + def f(data): + calls.append(data) + + self.message_broker.subscribe(MessageType.mapping, f) + self.controller.create_mapping() + + self.assertEqual(calls[-1], UIMapping(**MAPPING_DEFAULTS)) + + def test_create_mapping_should_not_create_multiple_empty_mappings(self): + prepare_presets() + self.data_manager.load_group("Foo Device 2") + self.data_manager.load_preset("preset2") + self.controller.create_mapping() # create a first empty mapping + + calls = [] + + def f(data): + calls.append(data) + + self.message_broker.subscribe(MessageType.mapping, f) + self.message_broker.subscribe(MessageType.preset, f) + + self.controller.create_mapping() # try to create a second one + self.assertEqual(len(calls), 0) + + def test_delete_mapping_asks_for_confirmation(self): + prepare_presets() + self.message_broker.signal(MessageType.init) + mock = MagicMock() + self.message_broker.subscribe(MessageType.user_confirm_request, mock) + self.controller.delete_mapping() + mock.assert_called_once() + + def test_deletes_mapping_when_confirmed(self): + prepare_presets() + self.assertTrue( + os.path.isfile(PathUtils.get_preset_path("Foo Device", "preset2")) + ) + + self.data_manager.load_group("Foo Device 2") + self.data_manager.load_preset("preset2") + self.data_manager.load_mapping(InputCombination([InputConfig(type=1, code=3)])) + self.message_broker.subscribe( + MessageType.user_confirm_request, lambda msg: msg.respond(True) + ) + self.controller.delete_mapping() + self.controller.save() + + preset = Preset(PathUtils.get_preset_path("Foo Device", "preset2")) + preset.load() + self.assertIsNone( + preset.get_mapping(InputCombination([InputConfig(type=1, code=3)])) + ) + + def test_does_not_delete_mapping_when_not_confirmed(self): + prepare_presets() + self.assertTrue( + os.path.isfile(PathUtils.get_preset_path("Foo Device", "preset2")) + ) + + self.data_manager.load_group("Foo Device 2") + self.data_manager.load_preset("preset2") + self.data_manager.load_mapping(InputCombination([InputConfig(type=1, code=3)])) + self.user_interface.confirm_delete.configure_mock( + return_value=Gtk.ResponseType.CANCEL + ) + + self.controller.delete_mapping() + self.controller.save() + + preset = Preset(PathUtils.get_preset_path("Foo Device", "preset2")) + preset.load() + self.assertIsNotNone( + preset.get_mapping(InputCombination([InputConfig(type=1, code=3)])) + ) + + def test_should_update_combination(self): + """When combination is free.""" + prepare_presets() + self.data_manager.load_group("Foo Device 2") + self.data_manager.load_preset("preset2") + self.data_manager.load_mapping(InputCombination([InputConfig(type=1, code=3)])) + + calls: List[CombinationUpdate] = [] + + def f(data): + calls.append(data) + + self.message_broker.subscribe(MessageType.combination_update, f) + self.controller.update_combination( + InputCombination([InputConfig(type=1, code=10)]) + ) + self.assertEqual( + calls[0], + CombinationUpdate( + InputCombination([InputConfig(type=1, code=3)]), + InputCombination([InputConfig(type=1, code=10)]), + ), + ) + + def test_should_not_update_combination(self): + """When combination is already used.""" + prepare_presets() + self.data_manager.load_group("Foo Device 2") + self.data_manager.load_preset("preset2") + self.data_manager.load_mapping(InputCombination([InputConfig(type=1, code=3)])) + + calls: List[CombinationUpdate] = [] + + def f(data): + calls.append(data) + + self.message_broker.subscribe(MessageType.combination_update, f) + self.controller.update_combination( + InputCombination([InputConfig(type=1, code=4)]) + ) + self.assertEqual(len(calls), 0) + + def test_sets_input_to_analog(self): + prepare_presets() + + input_config = InputConfig(type=EV_ABS, code=ABS_RX) + + self.data_manager.load_group("Foo Device 2") + self.data_manager.load_preset("preset2") + self.data_manager.active_preset.add( + Mapping( + input_combination=InputCombination([input_config]), + output_type=EV_ABS, + output_code=ABS_X, + target_uinput="gamepad", + ) + ) + self.data_manager.load_mapping(InputCombination([input_config])) + + self.controller.start_key_recording() + self.message_broker.publish( + CombinationRecorded( + InputCombination( + [ + InputConfig( + type=EV_ABS, + code=ABS_Y, + analog_threshold=50, + ), + InputConfig( + type=EV_ABS, + code=ABS_RX, + analog_threshold=60, + ), + ] + ) + ) + ) + + # the analog_threshold is removed automatically, otherwise the mapping doesn't + # make sense because only analog inputs can map to analog outputs. + # This is indicated by is_analog_output being true. + self.assertTrue(self.controller.data_manager.active_mapping.is_analog_output()) + + # only the first input is modified + active_mapping = self.controller.data_manager.active_mapping + self.assertEqual(active_mapping.input_combination[0].analog_threshold, None) + self.assertEqual(active_mapping.input_combination[1].analog_threshold, 60) + + def test_key_recording_disables_gui_shortcuts(self): + self.message_broker.signal(MessageType.init) + self.user_interface.disconnect_shortcuts.assert_not_called() + self.controller.start_key_recording() + self.user_interface.disconnect_shortcuts.assert_called_once() + + def test_key_recording_enables_gui_shortcuts_when_finished(self): + self.message_broker.signal(MessageType.init) + self.controller.start_key_recording() + + self.user_interface.connect_shortcuts.assert_not_called() + self.message_broker.signal(MessageType.recording_finished) + self.user_interface.connect_shortcuts.assert_called_once() + + def test_key_recording_enables_gui_shortcuts_when_stopped(self): + self.message_broker.signal(MessageType.init) + self.controller.start_key_recording() + + self.user_interface.connect_shortcuts.assert_not_called() + self.controller.stop_key_recording() + self.user_interface.connect_shortcuts.assert_called_once() + + def test_recording_messages(self): + mock1 = MagicMock() + mock2 = MagicMock() + self.message_broker.subscribe(MessageType.recording_started, mock1) + self.message_broker.subscribe(MessageType.recording_finished, mock2) + + self.message_broker.signal(MessageType.init) + self.controller.start_key_recording() + + mock1.assert_called_once() + mock2.assert_not_called() + + self.controller.stop_key_recording() + + mock1.assert_called_once() + mock2.assert_called_once() + + def test_key_recording_updates_mapping_combination(self): + prepare_presets() + self.data_manager.load_group("Foo Device 2") + self.data_manager.load_preset("preset2") + self.data_manager.load_mapping(InputCombination([InputConfig(type=1, code=3)])) + + calls: List[CombinationUpdate] = [] + + def f(data): + calls.append(data) + + self.message_broker.subscribe(MessageType.combination_update, f) + + self.controller.start_key_recording() + self.message_broker.publish( + CombinationRecorded(InputCombination([InputConfig(type=1, code=10)])) + ) + self.assertEqual( + calls[0], + CombinationUpdate( + InputCombination([InputConfig(type=1, code=3)]), + InputCombination([InputConfig(type=1, code=10)]), + ), + ) + self.message_broker.publish( + CombinationRecorded( + InputCombination(InputCombination.from_tuples((1, 10), (1, 3))) + ) + ) + self.assertEqual( + calls[1], + CombinationUpdate( + InputCombination([InputConfig(type=1, code=10)]), + InputCombination(InputCombination.from_tuples((1, 10), (1, 3))), + ), + ) + + def test_no_key_recording_when_not_started(self): + prepare_presets() + self.data_manager.load_group("Foo Device 2") + self.data_manager.load_preset("preset2") + self.data_manager.load_mapping(InputCombination([InputConfig(type=1, code=3)])) + + calls: List[CombinationUpdate] = [] + + def f(data): + calls.append(data) + + self.message_broker.subscribe(MessageType.combination_update, f) + + self.message_broker.publish( + CombinationRecorded(InputCombination([InputConfig(type=1, code=10)])) + ) + self.assertEqual(len(calls), 0) + + def test_key_recording_stops_when_finished(self): + prepare_presets() + self.data_manager.load_group("Foo Device 2") + self.data_manager.load_preset("preset2") + self.data_manager.load_mapping(InputCombination([InputConfig(type=1, code=3)])) + + calls: List[CombinationUpdate] = [] + + def f(data): + calls.append(data) + + self.message_broker.subscribe(MessageType.combination_update, f) + + self.controller.start_key_recording() + self.message_broker.publish( + CombinationRecorded(InputCombination([InputConfig(type=1, code=10)])) + ) + self.message_broker.signal(MessageType.recording_finished) + self.message_broker.publish( + CombinationRecorded( + InputCombination(InputCombination.from_tuples((1, 10), (1, 3))) + ) + ) + + self.assertEqual(len(calls), 1) # only the first was processed + + def test_key_recording_stops_when_stopped(self): + prepare_presets() + self.data_manager.load_group("Foo Device 2") + self.data_manager.load_preset("preset2") + self.data_manager.load_mapping(InputCombination([InputConfig(type=1, code=3)])) + + calls: List[CombinationUpdate] = [] + + def f(data): + calls.append(data) + + self.message_broker.subscribe(MessageType.combination_update, f) + + self.controller.start_key_recording() + self.message_broker.publish( + CombinationRecorded(InputCombination([InputConfig(type=1, code=10)])) + ) + self.controller.stop_key_recording() + self.message_broker.publish( + CombinationRecorded( + InputCombination(InputCombination.from_tuples((1, 10), (1, 3))) + ) + ) + + self.assertEqual(len(calls), 1) # only the first was processed + + def test_start_injecting_shows_status_when_preset_empty(self): + self.data_manager.load_group("Foo Device 2") + self.data_manager.create_preset("foo") + self.data_manager.load_preset("foo") + calls: List[StatusData] = [] + + def f(data): + calls.append(data) + + self.message_broker.subscribe(MessageType.status_msg, f) + + def f2(): + raise AssertionError("Injection started unexpectedly") + + self.data_manager.start_injecting = f2 + self.controller.start_injecting() + + self.assertEqual( + calls[-1], StatusData(CTX_ERROR, _("You need to add mappings first")) + ) + + def test_start_injecting_warns_about_btn_left(self): + self.data_manager.load_group("Foo Device 2") + self.data_manager.create_preset("foo") + self.data_manager.load_preset("foo") + self.data_manager.create_mapping() + self.data_manager.update_mapping( + input_combination=InputCombination([InputConfig.btn_left()]), + target_uinput="keyboard", + output_symbol="a", + ) + calls: List[StatusData] = [] + + def f(data): + calls.append(data) + + self.message_broker.subscribe(MessageType.status_msg, f) + + def f2(): + raise AssertionError("Injection started unexpectedly") + + self.data_manager.start_injecting = f2 + self.controller.start_injecting() + + self.assertEqual(calls[-1].ctx_id, CTX_ERROR) + self.assertIn("BTN_LEFT", calls[-1].tooltip) + + def test_start_injecting_starts_with_btn_left_on_second_try(self): + self.data_manager.load_group("Foo Device 2") + self.data_manager.create_preset("foo") + self.data_manager.load_preset("foo") + self.data_manager.create_mapping() + self.data_manager.update_mapping( + input_combination=InputCombination([InputConfig.btn_left()]), + target_uinput="keyboard", + output_symbol="a", + ) + + with patch.object(self.data_manager, "start_injecting") as mock: + self.controller.start_injecting() + mock.assert_not_called() + self.controller.start_injecting() + mock.assert_called_once() + + def test_start_injecting_starts_with_btn_left_when_mapped_to_other_button(self): + self.data_manager.load_group("Foo Device 2") + self.data_manager.create_preset("foo") + self.data_manager.load_preset("foo") + self.data_manager.create_mapping() + self.data_manager.update_mapping( + input_combination=InputCombination([InputConfig.btn_left()]), + target_uinput="keyboard", + output_symbol="a", + ) + self.data_manager.create_mapping() + self.data_manager.load_mapping(InputCombination.empty_combination()) + self.data_manager.update_mapping( + input_combination=InputCombination([InputConfig(type=1, code=5)]), + target_uinput="mouse", + output_symbol="BTN_LEFT", + ) + + mock = MagicMock(return_value=True) + self.data_manager.start_injecting = mock + self.controller.start_injecting() + mock.assert_called() + + def test_start_injecting_shows_status(self): + prepare_presets() + self.data_manager.load_group("Foo Device 2") + self.data_manager.load_preset("preset2") + calls: List[StatusData] = [] + + def f(data): + calls.append(data) + + self.message_broker.subscribe(MessageType.status_msg, f) + mock = MagicMock(return_value=True) + self.data_manager.start_injecting = mock + self.controller.start_injecting() + + mock.assert_called() + self.assertEqual(calls[0], StatusData(CTX_APPLY, _("Starting injection..."))) + + def test_start_injecting_shows_failure_status(self): + prepare_presets() + self.data_manager.load_group("Foo Device 2") + self.data_manager.load_preset("preset2") + calls: List[StatusData] = [] + + def f(data): + calls.append(data) + + self.message_broker.subscribe(MessageType.status_msg, f) + mock = MagicMock(return_value=False) + self.data_manager.start_injecting = mock + self.controller.start_injecting() + + mock.assert_called() + self.assertEqual( + calls[-1], + StatusData( + CTX_APPLY, + _('Failed to apply preset "%s"') % self.data_manager.active_preset.name, + ), + ) + + def test_start_injecting_adds_listener_to_update_injector_status(self): + prepare_presets() + self.data_manager.load_group("Foo Device 2") + self.data_manager.load_preset("preset2") + + with patch.object(self.message_broker, "subscribe") as mock: + self.controller.start_injecting() + mock.assert_called_once_with( + MessageType.injector_state, self.controller.show_injector_result + ) + + def test_stop_injecting_shows_status(self): + prepare_presets() + self.data_manager.load_group("Foo Device 2") + self.data_manager.load_preset("preset2") + calls: List[StatusData] = [] + + def f(data): + calls.append(data) + + self.message_broker.subscribe(MessageType.status_msg, f) + mock = MagicMock(return_value=InjectorState.STOPPED) + self.data_manager.get_state = mock + self.controller.stop_injecting() + gtk_iteration(50) + + mock.assert_called() + self.assertEqual(calls[-1], StatusData(CTX_APPLY, _("Stopped the injection"))) + + def test_show_injection_result(self): + prepare_presets() + self.data_manager.load_group("Foo Device 2") + self.data_manager.load_preset("preset2") + + mock = MagicMock(return_value=InjectorState.RUNNING) + self.data_manager.get_state = mock + calls: List[StatusData] = [] + + def f(data): + calls.append(data) + + self.message_broker.subscribe(MessageType.status_msg, f) + + self.controller.start_injecting() + gtk_iteration(50) + self.assertEqual(calls[-1].msg, _('Applied preset "%s"') % "preset2") + + mock.return_value = InjectorState.ERROR + self.controller.start_injecting() + gtk_iteration(50) + self.assertEqual(calls[-1].msg, _('Error applying preset "%s"') % "preset2") + + mock.return_value = InjectorState.NO_GRAB + self.controller.start_injecting() + gtk_iteration(50) + self.assertEqual(calls[-1].msg, _('Failed to apply preset "%s"') % "preset2") + + mock.return_value = InjectorState.UPGRADE_EVDEV + self.controller.start_injecting() + gtk_iteration(50) + self.assertEqual(calls[-1].msg, "Upgrade python-evdev") + + def test_close(self): + mock_save = MagicMock() + listener = MagicMock() + self.message_broker.subscribe(MessageType.terminate, listener) + self.data_manager.save = mock_save + + self.controller.close() + mock_save.assert_called() + listener.assert_called() + + def test_set_autoload_refreshes_service_config(self): + prepare_presets() + self.data_manager.load_group("Foo Device 2") + self.data_manager.load_preset("preset2") + + with patch.object(self.data_manager, "refresh_service_config_path") as mock: + self.controller.set_autoload(True) + mock.assert_called() + + def test_move_event_up(self): + prepare_presets() + self.data_manager.load_group("Foo Device 2") + self.data_manager.load_preset("preset2") + self.data_manager.load_mapping( + InputCombination(InputCombination.from_tuples((1, 3))) + ) + self.data_manager.update_mapping( + input_combination=InputCombination( + InputCombination.from_tuples((1, 1), (1, 2), (1, 3)) + ) + ) + + self.controller.move_input_config_in_combination( + InputConfig(type=1, code=2), "up" + ) + self.assertEqual( + self.data_manager.active_mapping.input_combination, + InputCombination(InputCombination.from_tuples((1, 2), (1, 1), (1, 3))), + ) + # now nothing changes + self.controller.move_input_config_in_combination( + InputConfig(type=1, code=2), "up" + ) + self.assertEqual( + self.data_manager.active_mapping.input_combination, + InputCombination(InputCombination.from_tuples((1, 2), (1, 1), (1, 3))), + ) + + def test_move_event_down(self): + prepare_presets() + self.data_manager.load_group("Foo Device 2") + self.data_manager.load_preset("preset2") + self.data_manager.load_mapping( + InputCombination(InputCombination.from_tuples((1, 3))) + ) + self.data_manager.update_mapping( + input_combination=InputCombination( + InputCombination.from_tuples((1, 1), (1, 2), (1, 3)) + ) + ) + + self.controller.move_input_config_in_combination( + InputConfig(type=1, code=2), "down" + ) + self.assertEqual( + self.data_manager.active_mapping.input_combination, + InputCombination(InputCombination.from_tuples((1, 1), (1, 3), (1, 2))), + ) + # now nothing changes + self.controller.move_input_config_in_combination( + InputConfig(type=1, code=2), "down" + ) + self.assertEqual( + self.data_manager.active_mapping.input_combination, + InputCombination(InputCombination.from_tuples((1, 1), (1, 3), (1, 2))), + ) + + def test_move_event_in_combination_of_len_1(self): + prepare_presets() + self.data_manager.load_group("Foo Device 2") + self.data_manager.load_preset("preset2") + self.data_manager.load_mapping( + InputCombination(InputCombination.from_tuples((1, 3))) + ) + self.controller.move_input_config_in_combination( + InputConfig(type=1, code=3), "down" + ) + self.assertEqual( + self.data_manager.active_mapping.input_combination, + InputCombination(InputCombination.from_tuples((1, 3))), + ) + + def test_move_event_loads_it_again(self): + prepare_presets() + self.data_manager.load_group("Foo Device 2") + self.data_manager.load_preset("preset2") + self.data_manager.load_mapping( + InputCombination(InputCombination.from_tuples((1, 3))) + ) + self.data_manager.update_mapping( + input_combination=InputCombination( + InputCombination.from_tuples((1, 1), (1, 2), (1, 3)) + ) + ) + mock = MagicMock() + self.message_broker.subscribe(MessageType.selected_event, mock) + self.controller.move_input_config_in_combination( + InputConfig(type=1, code=2), "down" + ) + mock.assert_called_once_with(InputConfig(type=1, code=2)) + + def test_update_event(self): + prepare_presets() + self.data_manager.load_group("Foo Device 2") + self.data_manager.load_preset("preset2") + self.data_manager.load_mapping( + InputCombination(InputCombination.from_tuples((1, 3))) + ) + self.data_manager.load_input_config(InputConfig(type=1, code=3)) + mock = MagicMock() + self.message_broker.subscribe(MessageType.selected_event, mock) + self.controller.update_input_config(InputConfig(type=1, code=10)) + mock.assert_called_once_with(InputConfig(type=1, code=10)) + + def test_update_event_reloads_mapping_and_event_when_update_fails(self): + prepare_presets() + self.data_manager.load_group("Foo Device 2") + self.data_manager.load_preset("preset2") + self.data_manager.load_mapping( + InputCombination(InputCombination.from_tuples((1, 3))) + ) + self.data_manager.load_input_config(InputConfig(type=1, code=3)) + mock = MagicMock() + self.message_broker.subscribe(MessageType.selected_event, mock) + self.message_broker.subscribe(MessageType.mapping, mock) + calls = [ + call(self.data_manager.active_mapping.get_bus_message()), + call(InputConfig(type=1, code=3)), + ] + self.controller.update_input_config( + InputConfig(type=1, code=4) + ) # already exists + mock.assert_has_calls(calls, any_order=False) + + def test_remove_event_does_nothing_when_mapping_not_loaded(self): + with spy(self.data_manager, "update_mapping") as mock: + self.controller.remove_event() + mock.assert_not_called() + + def test_remove_event_removes_active_event(self): + prepare_presets() + self.data_manager.load_group("Foo Device 2") + self.data_manager.load_preset("preset2") + self.data_manager.load_mapping( + InputCombination(InputCombination.from_tuples((1, 3))) + ) + self.data_manager.update_mapping( + input_combination=InputCombination.from_tuples((1, 3), (1, 4)) + ) + self.assertEqual( + self.data_manager.active_mapping.input_combination, + InputCombination(InputCombination.from_tuples((1, 3), (1, 4))), + ) + self.data_manager.load_input_config(InputConfig(type=1, code=4)) + + self.controller.remove_event() + self.assertEqual( + self.data_manager.active_mapping.input_combination, + InputCombination(InputCombination.from_tuples((1, 3))), + ) + + def test_remove_event_loads_a_event(self): + prepare_presets() + self.data_manager.load_group("Foo Device 2") + self.data_manager.load_preset("preset2") + self.data_manager.load_mapping( + InputCombination(InputCombination.from_tuples((1, 3))) + ) + self.data_manager.update_mapping( + input_combination=InputCombination.from_tuples((1, 3), (1, 4)) + ) + self.assertEqual( + self.data_manager.active_mapping.input_combination, + InputCombination(InputCombination.from_tuples((1, 3), (1, 4))), + ) + self.data_manager.load_input_config(InputConfig(type=1, code=4)) + + mock = MagicMock() + self.message_broker.subscribe(MessageType.selected_event, mock) + self.controller.remove_event() + mock.assert_called_once_with(InputConfig(type=1, code=3)) + + def test_remove_event_reloads_mapping_and_event_when_update_fails(self): + prepare_presets() + self.data_manager.load_group("Foo Device 2") + self.data_manager.load_preset("preset2") + self.data_manager.load_mapping( + InputCombination(InputCombination.from_tuples((1, 3))) + ) + self.data_manager.update_mapping( + input_combination=InputCombination.from_tuples((1, 3), (1, 4)) + ) + self.data_manager.load_input_config(InputConfig(type=1, code=3)) + + # removing "1,3,1" will throw a key error because a mapping with combination + # "1,4,1" already exists in preset + mock = MagicMock() + self.message_broker.subscribe(MessageType.selected_event, mock) + self.message_broker.subscribe(MessageType.mapping, mock) + calls = [ + call(self.data_manager.active_mapping.get_bus_message()), + call(InputConfig(type=1, code=3)), + ] + self.controller.remove_event() + mock.assert_has_calls(calls, any_order=False) + + def test_set_event_as_analog_saves(self): + prepare_presets() + self.data_manager.load_group("Foo Device 2") + self.data_manager.load_preset("preset2") + self.data_manager.update_mapping( + input_combination=InputCombination.from_tuples((3, 0, 10)) + ) + self.data_manager.load_mapping( + InputCombination(InputCombination.from_tuples((3, 0, 10))) + ) + self.data_manager.load_input_config( + InputConfig(type=3, code=0, analog_threshold=10) + ) + + with patch.object(self.data_manager, "save") as mock: + self.controller.set_event_as_analog(False) + mock.assert_called_once() + + with patch.object(self.data_manager, "save") as mock: + self.controller.set_event_as_analog(True) + mock.assert_called_once() + + def test_set_event_as_analog_sets_input_to_analog(self): + prepare_presets() + self.data_manager.load_group("Foo Device 2") + self.data_manager.load_preset("preset2") + self.data_manager.load_mapping( + InputCombination(InputCombination.from_tuples((1, 3))) + ) + self.data_manager.update_mapping( + input_combination=InputCombination.from_tuples((3, 0, 10)) + ) + self.data_manager.load_input_config( + InputConfig(type=3, code=0, analog_threshold=10) + ) + + self.controller.set_event_as_analog(True) + self.assertEqual( + self.data_manager.active_mapping.input_combination, + InputCombination(InputCombination.from_tuples((3, 0))), + ) + + def test_set_event_as_analog_adds_rel_threshold(self): + prepare_presets() + self.data_manager.load_group("Foo Device 2") + self.data_manager.load_preset("preset2") + self.data_manager.load_mapping( + InputCombination(InputCombination.from_tuples((1, 3))) + ) + self.data_manager.update_mapping( + input_combination=InputCombination.from_tuples((2, 0)) + ) + self.data_manager.load_input_config(InputConfig(type=2, code=0)) + + self.controller.set_event_as_analog(False) + combinations = [ + InputCombination(InputCombination.from_tuples((2, 0, 1))), + InputCombination(InputCombination.from_tuples((2, 0, -1))), + ] + self.assertIn(self.data_manager.active_mapping.input_combination, combinations) + + def test_set_event_as_analog_adds_abs_threshold(self): + prepare_presets() + self.data_manager.load_group("Foo Device 2") + self.data_manager.load_preset("preset2") + self.data_manager.load_mapping( + InputCombination(InputCombination.from_tuples((1, 3))) + ) + self.data_manager.update_mapping( + input_combination=InputCombination.from_tuples((3, 0)) + ) + self.data_manager.load_input_config(InputConfig(type=3, code=0)) + + self.controller.set_event_as_analog(False) + combinations = [ + InputCombination(InputCombination.from_tuples((3, 0, 10))), + InputCombination(InputCombination.from_tuples((3, 0, -10))), + ] + self.assertIn(self.data_manager.active_mapping.input_combination, combinations) + + def test_set_event_as_analog_reloads_mapping_and_event_when_key_event(self): + prepare_presets() + self.data_manager.load_group("Foo Device 2") + self.data_manager.load_preset("preset2") + self.data_manager.load_mapping( + InputCombination(InputCombination.from_tuples((1, 3))) + ) + self.data_manager.load_input_config(InputConfig(type=1, code=3)) + + mock = MagicMock() + self.message_broker.subscribe(MessageType.selected_event, mock) + self.message_broker.subscribe(MessageType.mapping, mock) + calls = [ + call(self.data_manager.active_mapping.get_bus_message()), + call(InputConfig(type=1, code=3)), + ] + self.controller.set_event_as_analog(True) + mock.assert_has_calls(calls, any_order=False) + + def test_set_event_as_analog_reloads_when_setting_to_analog_fails(self): + prepare_presets() + self.data_manager.load_group("Foo Device 2") + self.data_manager.load_preset("preset2") + self.data_manager.load_mapping( + InputCombination(InputCombination.from_tuples((1, 3))) + ) + self.data_manager.update_mapping( + input_combination=InputCombination.from_tuples((3, 0, 10)) + ) + self.data_manager.load_input_config( + InputConfig(type=3, code=0, analog_threshold=10) + ) + + mock = MagicMock() + self.message_broker.subscribe(MessageType.selected_event, mock) + self.message_broker.subscribe(MessageType.mapping, mock) + calls = [ + call(self.data_manager.active_mapping.get_bus_message()), + call(InputConfig(type=3, code=0, analog_threshold=10)), + ] + with patch.object(self.data_manager, "update_mapping", side_effect=KeyError): + self.controller.set_event_as_analog(True) + mock.assert_has_calls(calls, any_order=False) + + def test_set_event_as_analog_reloads_when_setting_to_key_fails(self): + prepare_presets() + self.data_manager.load_group("Foo Device 2") + self.data_manager.load_preset("preset2") + self.data_manager.load_mapping( + InputCombination(InputCombination.from_tuples((1, 3))) + ) + self.data_manager.update_mapping( + input_combination=InputCombination.from_tuples((3, 0)) + ) + self.data_manager.load_input_config(InputConfig(type=3, code=0)) + + mock = MagicMock() + self.message_broker.subscribe(MessageType.selected_event, mock) + self.message_broker.subscribe(MessageType.mapping, mock) + calls = [ + call(self.data_manager.active_mapping.get_bus_message()), + call(InputConfig(type=3, code=0)), + ] + with patch.object(self.data_manager, "update_mapping", side_effect=KeyError): + self.controller.set_event_as_analog(False) + mock.assert_has_calls(calls, any_order=False) + + def test_update_mapping_type_will_ask_user_when_output_symbol_is_set(self): + prepare_presets() + self.data_manager.load_group("Foo Device 2") + self.data_manager.load_preset("preset2") + self.data_manager.load_mapping( + InputCombination(InputCombination.from_tuples((1, 3))) + ) + request: UserConfirmRequest = None + + def f(r: UserConfirmRequest): + nonlocal request + request = r + + self.message_broker.subscribe(MessageType.user_confirm_request, f) + self.controller.update_mapping(mapping_type="analog") + self.assertIn('This will remove "a" from the text input', request.msg) + + def test_update_mapping_type_will_notify_user_to_recorde_analog_input(self): + prepare_presets() + self.data_manager.load_group("Foo Device 2") + self.data_manager.load_preset("preset2") + self.data_manager.load_mapping( + InputCombination(InputCombination.from_tuples((1, 3))) + ) + self.data_manager.update_mapping(output_symbol=None) + request: UserConfirmRequest = None + + def f(r: UserConfirmRequest): + nonlocal request + request = r + + self.message_broker.subscribe(MessageType.user_confirm_request, f) + self.controller.update_mapping(mapping_type="analog") + self.assertIn("You need to record an analog input.", request.msg) + + def test_update_mapping_type_will_tell_user_which_input_is_used_as_analog(self): + prepare_presets() + self.data_manager.load_group("Foo Device 2") + self.data_manager.load_preset("preset2") + self.data_manager.load_mapping( + InputCombination(InputCombination.from_tuples((1, 3))) + ) + self.data_manager.update_mapping( + input_combination=InputCombination.from_tuples((1, 3), (2, 1, 1)), + output_symbol=None, + ) + request: UserConfirmRequest = None + + def f(r: UserConfirmRequest): + nonlocal request + request = r + + self.message_broker.subscribe(MessageType.user_confirm_request, f) + self.controller.update_mapping(mapping_type="analog") + self.assertIn('The input "Y Down 1" will be used as analog input.', request.msg) + + def test_update_mapping_type_will_will_autoconfigure_the_input(self): + prepare_presets() + self.data_manager.load_group("Foo Device 2") + self.data_manager.load_preset("preset2") + self.data_manager.load_mapping( + InputCombination(InputCombination.from_tuples((1, 3))) + ) + self.data_manager.update_mapping( + input_combination=InputCombination.from_tuples((1, 3), (2, 1, 1)), + output_symbol=None, + ) + + self.message_broker.subscribe( + MessageType.user_confirm_request, lambda r: r.respond(True) + ) + with patch.object(self.data_manager, "update_mapping") as mock: + self.controller.update_mapping(mapping_type="analog") + mock.assert_called_once_with( + mapping_type="analog", + output_symbol=None, + input_combination=InputCombination( + InputCombination.from_tuples((1, 3), (2, 1)) + ), + ) + + def test_update_mapping_type_will_abort_when_user_denys(self): + prepare_presets() + self.data_manager.load_group("Foo Device 2") + self.data_manager.load_preset("preset2") + self.data_manager.load_mapping( + InputCombination(InputCombination.from_tuples((1, 3))) + ) + + self.message_broker.subscribe( + MessageType.user_confirm_request, lambda r: r.respond(False) + ) + with patch.object(self.data_manager, "update_mapping") as mock: + self.controller.update_mapping(mapping_type="analog") + mock.assert_not_called() + + self.data_manager.update_mapping( + input_combination=InputCombination.from_tuples((1, 3), (2, 1)), + output_symbol=None, + mapping_type="analog", + ) + with patch.object(self.data_manager, "update_mapping") as mock: + self.controller.update_mapping(mapping_type="key_macro") + mock.assert_not_called() + + def test_update_mapping_type_will_delete_output_symbol_when_user_confirms(self): + prepare_presets() + self.data_manager.load_group("Foo Device 2") + self.data_manager.load_preset("preset2") + self.data_manager.load_mapping( + InputCombination(InputCombination.from_tuples((1, 3))) + ) + + self.message_broker.subscribe( + MessageType.user_confirm_request, lambda r: r.respond(True) + ) + with patch.object(self.data_manager, "update_mapping") as mock: + self.controller.update_mapping(mapping_type="analog") + mock.assert_called_once_with(mapping_type="analog", output_symbol=None) + + def test_update_mapping_will_ask_user_to_set_trigger_threshold(self): + prepare_presets() + self.data_manager.load_group("Foo Device 2") + self.data_manager.load_preset("preset2") + self.data_manager.load_mapping( + InputCombination(InputCombination.from_tuples((1, 3))) + ) + self.data_manager.update_mapping( + input_combination=InputCombination.from_tuples((1, 3), (2, 1)), + output_symbol=None, + mapping_type="analog", + ) + request: UserConfirmRequest = None + + def f(r: UserConfirmRequest): + nonlocal request + request = r + + self.message_broker.subscribe(MessageType.user_confirm_request, f) + self.controller.update_mapping(mapping_type="key_macro") + self.assertIn('and set a "Trigger Threshold" for "Y".', request.msg) + + def test_update_mapping_update_to_analog_without_asking(self): + prepare_presets() + self.data_manager.load_group("Foo Device 2") + self.data_manager.load_preset("preset2") + self.data_manager.load_mapping( + InputCombination(InputCombination.from_tuples((1, 3))) + ) + self.data_manager.update_mapping( + input_combination=InputCombination.from_tuples((1, 3), (2, 1)), + output_symbol=None, + ) + mock = MagicMock() + self.message_broker.subscribe(MessageType.user_confirm_request, mock) + self.controller.update_mapping(mapping_type="analog") + mock.assert_not_called() + + def test_update_mapping_update_to_key_macro_without_asking(self): + prepare_presets() + self.data_manager.load_group("Foo Device 2") + self.data_manager.load_preset("preset2") + self.data_manager.load_mapping( + InputCombination(InputCombination.from_tuples((1, 3))) + ) + self.data_manager.update_mapping( + input_combination=InputCombination.from_tuples((1, 3), (2, 1, 1)), + mapping_type="analog", + output_symbol=None, + ) + mock = MagicMock() + self.message_broker.subscribe(MessageType.user_confirm_request, mock) + self.controller.update_mapping(mapping_type="key_macro") + mock.assert_not_called() + + def test_update_mapping_will_remove_output_type_and_code(self): + prepare_presets() + self.data_manager.load_group("Foo Device 2") + self.data_manager.load_preset("preset2") + self.data_manager.load_mapping( + InputCombination(InputCombination.from_tuples((1, 3))) + ) + self.data_manager.update_mapping( + input_combination=InputCombination.from_tuples((1, 3), (2, 1)), + output_symbol=None, + mapping_type="analog", + ) + self.message_broker.subscribe( + MessageType.user_confirm_request, lambda r: r.respond(True) + ) + with patch.object(self.data_manager, "update_mapping") as mock: + self.controller.update_mapping(mapping_type="key_macro") + mock.assert_called_once_with( + mapping_type="key_macro", + output_type=None, + output_code=None, + ) diff --git a/tests/unit/test_daemon.py b/tests/unit/test_daemon.py new file mode 100644 index 0000000..eb230da --- /dev/null +++ b/tests/unit/test_daemon.py @@ -0,0 +1,577 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + +import json +import os +import time +import unittest +from unittest.mock import patch, MagicMock + +import evdev +from evdev._ecodes import EV_ABS +from evdev.ecodes import EV_KEY, KEY_B, KEY_A, ABS_X, BTN_A, BTN_B + +from inputremapper.configs.global_config import GlobalConfig +from inputremapper.configs.input_config import InputCombination, InputConfig +from inputremapper.configs.mapping import Mapping +from inputremapper.configs.paths import PathUtils +from inputremapper.configs.preset import Preset +from inputremapper.configs.keyboard_layout import keyboard_layout +from inputremapper.daemon import Daemon, DAEMON +from inputremapper.groups import groups +from inputremapper.injection.global_uinputs import GlobalUInputs, UInput +from inputremapper.injection.injector import InjectorState +from inputremapper.injection.mapping_handlers.mapping_parser import MappingParser +from inputremapper.input_event import InputEvent +from tests.lib.cleanup import cleanup +from tests.lib.fixtures import Fixture +from tests.lib.fixtures import fixtures +from tests.lib.logger import logger +from tests.lib.pipes import push_events, uinput_write_history_pipe +from tests.lib.test_setup import test_setup, is_service_running +from tests.lib.tmp import tmp + + +@test_setup +class TestDaemon(unittest.TestCase): + new_fixture_path = "/dev/input/event9876" + + def setUp(self): + self.daemon = None + self.global_config = GlobalConfig() + self.global_uinputs = GlobalUInputs(UInput) + PathUtils.mkdir(PathUtils.get_config_path()) + self.global_config._save_config() + self.mapping_parser = MappingParser(self.global_uinputs) + + # the daemon should be able to create them on demand: + self.global_uinputs.devices = {} + self.global_uinputs.is_service = True + + def tearDown(self): + # avoid race conditions with other tests, daemon may run processes + if self.daemon is not None: + self.daemon.stop_all() + self.daemon = None + + cleanup() + + @patch.object(os, "system") + def test_connect(self, os_system_mock: MagicMock): + self.assertFalse(is_service_running()) + + # no daemon runs, should try to run it via pkexec instead. + # It fails due to the patch on os.system and therefore exits the process + self.assertRaises(SystemExit, Daemon.connect) + os_system_mock.assert_called_once() + self.assertIsNone(Daemon.connect(False)) + + # make the connect command work this time by acting like a connection is + # available: + + set_config_dir_callcount = 0 + + class FakeConnection: + def set_config_dir(self, *_, **__): + nonlocal set_config_dir_callcount + set_config_dir_callcount += 1 + + def Introspect(self, timeout=0): + return "" + + with patch.object(DAEMON, "get_proxy") as get_mock: + get_mock.return_value = FakeConnection() + self.assertIsInstance(Daemon.connect(), FakeConnection) + self.assertEqual(set_config_dir_callcount, 1) + + self.assertIsInstance(Daemon.connect(False), FakeConnection) + self.assertEqual(set_config_dir_callcount, 2) + + def test_daemon(self): + # remove the existing system mapping to force our own into it + if os.path.exists(PathUtils.get_config_path("xmodmap.json")): + os.remove(PathUtils.get_config_path("xmodmap.json")) + + preset_name = "foo" + + group = groups.find(name="gamepad") + + # unrelated group that shouldn't be affected at all + group2 = groups.find(name="Bar Device") + + preset = Preset(group.get_preset_path(preset_name)) + preset.add( + Mapping.from_combination( + input_combination=InputCombination( + [InputConfig(type=EV_KEY, code=BTN_A)] + ), + target_uinput="keyboard", + output_symbol="a", + ) + ) + preset.add( + Mapping.from_combination( + input_combination=InputCombination( + [InputConfig(type=EV_ABS, code=ABS_X, analog_threshold=-1)] + ), + target_uinput="keyboard", + output_symbol="b", + ) + ) + preset.save() + self.global_config.set_autoload_preset(group.key, preset_name) + + """Injection 1""" + + # should forward the event unchanged + push_events( + fixtures.gamepad, + [InputEvent.key(BTN_B, 1, fixtures.gamepad.get_device_hash())], + ) + + self.daemon = Daemon( + self.global_config, + self.global_uinputs, + self.mapping_parser, + ) + + self.assertFalse(uinput_write_history_pipe[0].poll()) + + # has been cleanedUp in setUp + self.assertNotIn("keyboard", self.global_uinputs.devices) + + logger.info(f"start injector for {group.key}") + self.daemon.start_injecting(group.key, preset_name) + + # created on demand + self.assertIn("keyboard", self.global_uinputs.devices) + self.assertNotIn("gamepad", self.global_uinputs.devices) + + self.assertEqual(self.daemon.get_state(group.key), InjectorState.STARTING) + self.assertEqual(self.daemon.get_state(group2.key), InjectorState.UNKNOWN) + + event = uinput_write_history_pipe[0].recv() + self.assertEqual(self.daemon.get_state(group.key), InjectorState.RUNNING) + self.assertEqual(event.type, EV_KEY) + self.assertEqual(event.code, BTN_B) + self.assertEqual(event.value, 1) + + logger.info(f"stopping injector for {group.key}") + self.daemon.stop_injecting(group.key) + time.sleep(0.2) + self.assertEqual(self.daemon.get_state(group.key), InjectorState.STOPPED) + + try: + self.assertFalse(uinput_write_history_pipe[0].poll()) + except AssertionError: + print("Unexpected", uinput_write_history_pipe[0].recv()) + # possibly a duplicate write! + raise + + """Injection 2""" + logger.info(f"start injector for {group.key}") + self.daemon.start_injecting(group.key, preset_name) + + time.sleep(0.1) + # -1234 will be classified as -1 by the injector + push_events( + fixtures.gamepad, + [InputEvent.abs(ABS_X, -1234, fixtures.gamepad.get_device_hash())], + ) + time.sleep(0.1) + + self.assertTrue(uinput_write_history_pipe[0].poll()) + + # the written key is a key-down event, not the original + # event value of -1234 + event = uinput_write_history_pipe[0].recv() + + self.assertEqual(event.type, EV_KEY) + self.assertEqual(event.code, KEY_B) + self.assertEqual(event.value, 1) + + def test_refresh_on_start(self): + if os.path.exists(PathUtils.get_config_path("xmodmap.json")): + os.remove(PathUtils.get_config_path("xmodmap.json")) + + preset_name = "foo" + key_code = 9 + group_name = "9876 name" + + # expected key of the group + group_key = group_name + + group = groups.find(name=group_name) + # this test only makes sense if this device is unknown yet + self.assertIsNone(group) + + keyboard_layout.clear() + keyboard_layout._set("a", KEY_A) + + preset = Preset(PathUtils.get_preset_path(group_name, preset_name)) + preset.add( + Mapping.from_combination( + InputCombination([InputConfig(type=EV_KEY, code=key_code)]), + "keyboard", + "a", + ) + ) + + # make the daemon load the file instead + with open(PathUtils.get_config_path("xmodmap.json"), "w") as file: + json.dump(keyboard_layout._mapping, file, indent=4) + keyboard_layout.clear() + + preset.save() + self.global_config.set_autoload_preset(group_key, preset_name) + self.daemon = Daemon( + self.global_config, + self.global_uinputs, + self.mapping_parser, + ) + + # make sure the devices are populated + groups.refresh() + + # the daemon is supposed to find this device by calling refresh + fixture = Fixture( + capabilities={evdev.ecodes.EV_KEY: [key_code]}, + phys="9876 phys", + info=evdev.device.DeviceInfo(4, 5, 6, 7), + name=group_name, + path=self.new_fixture_path, + ) + fixtures.add_fixture(fixture) + push_events(fixture, [InputEvent.key(key_code, 1, fixture.get_device_hash())]) + self.daemon.start_injecting(group_key, preset_name) + + # test if the injector called groups.refresh successfully + group = groups.find(key=group_key) + self.assertEqual(group.name, group_name) + self.assertEqual(group.key, group_key) + + time.sleep(0.1) + self.assertTrue(uinput_write_history_pipe[0].poll()) + + event = uinput_write_history_pipe[0].recv() + self.assertEqual(event, (EV_KEY, KEY_A, 1)) + + self.daemon.stop_injecting(group_key) + time.sleep(0.2) + self.assertEqual(self.daemon.get_state(group_key), InjectorState.STOPPED) + + def test_refresh_for_unknown_key(self): + device_9876 = "9876 name" + # this test only makes sense if this device is unknown yet + self.assertIsNone(groups.find(name=device_9876)) + + self.daemon = Daemon( + self.global_config, + self.global_uinputs, + self.mapping_parser, + ) + + # make sure the devices are populated + groups.refresh() + + self.daemon.refresh() + + fixtures.add_fixture( + Fixture( + capabilities={evdev.ecodes.EV_KEY: [evdev.ecodes.KEY_A]}, + phys="9876 phys", + info=evdev.device.DeviceInfo(4, 5, 6, 7), + name=device_9876, + path=self.new_fixture_path, + ) + ) + + self.daemon._autoload("25v7j9q4vtj") + # this is unknown, so the daemon will scan the devices again + + # test if the injector called groups.refresh successfully + self.assertIsNotNone(groups.find(name=device_9876)) + + def test_xmodmap_file(self): + """Create a custom xmodmap file, expect the daemon to read keycodes from it.""" + from_keycode = evdev.ecodes.KEY_A + target = "keyboard" + to_name = "q" + to_keycode = 100 + + name = "Bar Device" + preset_name = "foo" + group = groups.find(name=name) + + config_dir = os.path.join(tmp, "foo") + + path = os.path.join(config_dir, "presets", name, f"{preset_name}.json") + + preset = Preset(path) + preset.add( + Mapping.from_combination( + InputCombination([InputConfig(type=EV_KEY, code=from_keycode)]), + target, + to_name, + ) + ) + preset.save() + + keyboard_layout.clear() + + push_events( + fixtures.bar_device, + [ + InputEvent.key( + from_keycode, + 1, + origin_hash=fixtures.bar_device.get_device_hash(), + ) + ], + ) + + # an existing config file is needed otherwise set_config_dir refuses + # to use the directory + config_path = os.path.join(config_dir, "config.json") + self.global_config.path = config_path + self.global_config._save_config() + + # finally, create the xmodmap file + xmodmap_path = os.path.join(config_dir, "xmodmap.json") + with open(xmodmap_path, "w") as file: + file.write(f'{{"{to_name}":{to_keycode}}}') + + # test setup complete + + self.daemon = Daemon( + self.global_config, + self.global_uinputs, + self.mapping_parser, + ) + self.daemon.set_config_dir(config_dir) + + self.daemon.start_injecting(group.key, preset_name) + + time.sleep(0.1) + self.assertTrue(uinput_write_history_pipe[0].poll()) + + event = uinput_write_history_pipe[0].recv() + self.assertEqual(event.type, EV_KEY) + self.assertEqual(event.code, to_keycode) + self.assertEqual(event.value, 1) + + def test_start_stop(self): + group_key = "Qux/[Device]?" + group = groups.find(key=group_key) + preset_name = "preset8" + + daemon = Daemon( + self.global_config, + self.global_uinputs, + self.mapping_parser, + ) + self.daemon = daemon + + pereset = Preset(group.get_preset_path(preset_name)) + pereset.add( + Mapping.from_combination( + InputCombination([InputConfig(type=EV_KEY, code=KEY_A)]), + "keyboard", + "a", + ) + ) + pereset.save() + + # start + daemon.start_injecting(group_key, preset_name) + # explicit start, not autoload, so the history stays empty + self.assertNotIn(group_key, daemon.autoload_history._autoload_history) + self.assertTrue(daemon.autoload_history.may_autoload(group_key, preset_name)) + # path got translated to the device name + self.assertIn(group_key, daemon.injectors) + + # start again + previous_injector = daemon.injectors[group_key] + self.assertNotEqual(previous_injector.get_state(), InjectorState.STOPPED) + daemon.start_injecting(group_key, preset_name) + self.assertNotIn(group_key, daemon.autoload_history._autoload_history) + self.assertTrue(daemon.autoload_history.may_autoload(group_key, preset_name)) + self.assertIn(group_key, daemon.injectors) + time.sleep(0.2) + self.assertEqual(previous_injector.get_state(), InjectorState.STOPPED) + # a different injetor is now running + self.assertNotEqual(previous_injector, daemon.injectors[group_key]) + self.assertNotEqual( + daemon.injectors[group_key].get_state(), InjectorState.STOPPED + ) + + # trying to inject a non existing preset keeps the previous inejction + # alive + injector = daemon.injectors[group_key] + daemon.start_injecting(group_key, "qux") + self.assertEqual(injector, daemon.injectors[group_key]) + self.assertNotEqual( + daemon.injectors[group_key].get_state(), InjectorState.STOPPED + ) + + # trying to start injecting for an unknown device also just does + # nothing + daemon.start_injecting("quux", "qux") + self.assertNotEqual( + daemon.injectors[group_key].get_state(), InjectorState.STOPPED + ) + + # after all that stuff autoload_history is still unharmed + self.assertNotIn(group_key, daemon.autoload_history._autoload_history) + self.assertTrue(daemon.autoload_history.may_autoload(group_key, preset_name)) + + # stop + daemon.stop_injecting(group_key) + time.sleep(0.2) + self.assertNotIn(group_key, daemon.autoload_history._autoload_history) + self.assertEqual(daemon.injectors[group_key].get_state(), InjectorState.STOPPED) + self.assertTrue(daemon.autoload_history.may_autoload(group_key, preset_name)) + + def test_autoload(self): + preset_name = "preset7" + group_key = "Qux/[Device]?" + group = groups.find(key=group_key) + + daemon = Daemon(self.global_config, self.global_uinputs, self.mapping_parser) + self.daemon = daemon + + preset = Preset(group.get_preset_path(preset_name)) + preset.add( + Mapping.from_combination( + InputCombination([InputConfig(type=EV_KEY, code=KEY_A)]), + "keyboard", + "a", + ) + ) + preset.save() + + # no autoloading is configured yet + self.daemon._autoload(group_key) + self.assertNotIn(group_key, daemon.autoload_history._autoload_history) + self.assertTrue(daemon.autoload_history.may_autoload(group_key, preset_name)) + + self.global_config.set_autoload_preset(group_key, preset_name) + len_before = len(self.daemon.autoload_history._autoload_history) + # now autoloading is configured, so it will autoload + self.daemon._autoload(group_key) + len_after = len(self.daemon.autoload_history._autoload_history) + self.assertEqual( + daemon.autoload_history._autoload_history[group_key][1], + preset_name, + ) + self.assertFalse(daemon.autoload_history.may_autoload(group_key, preset_name)) + injector = daemon.injectors[group_key] + self.assertEqual(len_before + 1, len_after) + + # calling duplicate get_autoload does nothing + self.daemon._autoload(group_key) + self.assertEqual( + daemon.autoload_history._autoload_history[group_key][1], + preset_name, + ) + self.assertEqual(injector, daemon.injectors[group_key]) + self.assertFalse(daemon.autoload_history.may_autoload(group_key, preset_name)) + + # explicit start_injecting clears the autoload history + self.daemon.start_injecting(group_key, preset_name) + self.assertTrue(daemon.autoload_history.may_autoload(group_key, preset_name)) + + # calling autoload for (yet) unknown devices does nothing + len_before = len(self.daemon.autoload_history._autoload_history) + self.daemon._autoload("unknown-key-1234") + len_after = len(self.daemon.autoload_history._autoload_history) + self.assertEqual(len_before, len_after) + + # autoloading input-remapper devices does nothing + len_before = len(self.daemon.autoload_history._autoload_history) + self.daemon.autoload_single("Bar Device") + len_after = len(self.daemon.autoload_history._autoload_history) + self.assertEqual(len_before, len_after) + + def test_autoload_2(self): + self.daemon = Daemon( + self.global_config, + self.global_uinputs, + self.mapping_parser, + ) + history = self.daemon.autoload_history._autoload_history + + # existing device + preset_name = "preset7" + group = groups.find(key="Foo Device 2") + preset = Preset(group.get_preset_path(preset_name)) + preset.add( + Mapping.from_combination( + InputCombination([InputConfig(type=3, code=2, analog_threshold=1)]), + "keyboard", + "a", + ) + ) + preset.save() + self.global_config.set_autoload_preset(group.key, preset_name) + + # ignored, won't cause problems: + self.global_config.set_autoload_preset("non-existant-key", "foo") + + self.daemon.autoload() + self.assertEqual(len(history), 1) + self.assertEqual(history[group.key][1], preset_name) + + def test_autoload_3(self): + # based on a bug + preset_name = "preset7" + group = groups.find(key="Foo Device 2") + + preset = Preset(group.get_preset_path(preset_name)) + preset.add( + Mapping.from_combination( + InputCombination([InputConfig(type=3, code=2, analog_threshold=1)]), + "keyboard", + "a", + ) + ) + preset.save() + + self.global_config.set_autoload_preset(group.key, preset_name) + + self.daemon = Daemon( + self.global_config, + self.global_uinputs, + self.mapping_parser, + ) + groups.set_groups([]) # caused the bug + self.assertIsNone(groups.find(key="Foo Device 2")) + self.daemon.autoload() + + # it should try to refresh the groups because all the + # group_keys are unknown at the moment + history = self.daemon.autoload_history._autoload_history + self.assertEqual(history[group.key][1], preset_name) + self.assertEqual(self.daemon.get_state(group.key), InjectorState.STARTING) + self.assertIsNotNone(groups.find(key="Foo Device 2")) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/test_data_manager.py b/tests/unit/test_data_manager.py new file mode 100644 index 0000000..fefbcde --- /dev/null +++ b/tests/unit/test_data_manager.py @@ -0,0 +1,971 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + +import os +import time +import unittest +from itertools import permutations +from typing import List +from unittest.mock import MagicMock, call + +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 keyboard_layout +from inputremapper.exceptions import DataManagementError +from inputremapper.groups import _Groups +from inputremapper.gui.data_manager import DataManager, DEFAULT_PRESET_NAME +from inputremapper.gui.messages.message_broker import ( + MessageBroker, + MessageType, +) +from inputremapper.gui.messages.message_data import ( + GroupData, + CombinationUpdate, +) +from inputremapper.gui.reader_client import ReaderClient +from inputremapper.injection.global_uinputs import GlobalUInputs, FrontendUInput +from tests.lib.fixtures import prepare_presets +from tests.lib.patches import FakeDaemonProxy +from tests.lib.test_setup import test_setup + + +class Listener: + def __init__(self): + self.calls: List = [] + + def __call__(self, data): + self.calls.append(data) + + +@test_setup +class TestDataManager(unittest.TestCase): + def setUp(self) -> None: + self.message_broker = MessageBroker() + self.reader = ReaderClient(self.message_broker, _Groups()) + self.uinputs = GlobalUInputs(FrontendUInput) + self.uinputs.prepare_all() + self.global_config = GlobalConfig() + self.data_manager = DataManager( + self.message_broker, + self.global_config, + self.reader, + FakeDaemonProxy(), + self.uinputs, + keyboard_layout, + ) + + def test_load_group_provides_presets(self): + """we should get all preset of a group, when loading it""" + prepare_presets() + response: List[GroupData] = [] + + def listener(data: GroupData): + response.append(data) + + self.message_broker.subscribe(MessageType.group, listener) + self.data_manager.load_group("Foo Device 2") + + for preset_name in response[0].presets: + self.assertIn( + preset_name, + ( + "preset1", + "preset2", + "preset3", + ), + ) + + self.assertEqual(response[0].group_key, "Foo Device 2") + + def test_load_group_without_presets_provides_none(self): + """We should get no presets when loading a group without presets.""" + response: List[GroupData] = [] + + def listener(data: GroupData): + response.append(data) + + self.message_broker.subscribe(MessageType.group, listener) + + self.data_manager.load_group(group_key="Foo Device 2") + self.assertEqual(len(response[0].presets), 0) + + def test_load_non_existing_group(self): + """we should not be able to load an unknown group""" + with self.assertRaises(DataManagementError): + self.data_manager.load_group(group_key="Some Unknown Device") + + def test_cannot_load_preset_without_group(self): + """Loading a preset without a loaded group raises a DataManagementError.""" + prepare_presets() + self.assertRaises( + DataManagementError, + self.data_manager.load_preset, + name="preset1", + ) + + def test_load_preset(self): + """loading an existing preset should be possible""" + prepare_presets() + + self.data_manager.load_group(group_key="Foo Device") + listener = Listener() + self.message_broker.subscribe(MessageType.preset, listener) + self.data_manager.load_preset(name="preset1") + mappings = listener.calls[0].mappings + preset_name = listener.calls[0].name + + expected_preset = Preset(PathUtils.get_preset_path("Foo Device", "preset1")) + expected_preset.load() + expected_mappings = list(expected_preset) + + self.assertEqual(preset_name, "preset1") + for mapping in expected_mappings: + self.assertIn(mapping, mappings) + + def test_cannot_load_non_existing_preset(self): + """Loading a non-existing preset should raise a KeyError.""" + prepare_presets() + + self.data_manager.load_group(group_key="Foo Device") + self.assertRaises( + FileNotFoundError, + self.data_manager.load_preset, + name="unknownPreset", + ) + + def test_save_preset(self): + """Modified preses should be saved to the disc.""" + prepare_presets() + # make sure the correct preset is loaded + self.data_manager.load_group(group_key="Foo Device") + self.data_manager.load_preset(name="preset1") + listener = Listener() + self.message_broker.subscribe(MessageType.mapping, listener) + self.data_manager.load_mapping( + combination=InputCombination([InputConfig(type=1, code=1)]) + ) + + mapping: MappingData = listener.calls[0] + control_preset = Preset(PathUtils.get_preset_path("Foo Device", "preset1")) + control_preset.load() + self.assertEqual( + control_preset.get_mapping( + InputCombination([InputConfig(type=1, code=1)]) + ).output_symbol, + mapping.output_symbol, + ) + + # change the mapping provided with the mapping_changed event and save + self.data_manager.update_mapping(output_symbol="key(a)") + self.data_manager.save() + + # reload the control_preset + control_preset.empty() + control_preset.load() + self.assertEqual( + control_preset.get_mapping( + InputCombination([InputConfig(type=1, code=1)]) + ).output_symbol, + "key(a)", + ) + + def test_copy_preset(self): + prepare_presets() + self.data_manager.load_group(group_key="Foo Device 2") + self.data_manager.load_preset(name="preset2") + listener = Listener() + self.message_broker.subscribe(MessageType.group, listener) + self.message_broker.subscribe(MessageType.preset, listener) + + self.data_manager.copy_preset("foo") + + # we expect the first data to be group data and the second + # one a preset data of the new copy + presets_in_group = [preset for preset in listener.calls[0].presets] + self.assertIn("preset2", presets_in_group) + self.assertIn("foo", presets_in_group) + self.assertEqual(listener.calls[1].name, "foo") + + # this should pass without error: + self.data_manager.load_preset("preset2") + self.data_manager.copy_preset("preset2") + + def test_cannot_copy_preset(self): + prepare_presets() + + self.assertRaises( + DataManagementError, + self.data_manager.copy_preset, + "foo", + ) + self.data_manager.load_group("Foo Device 2") + self.assertRaises( + DataManagementError, + self.data_manager.copy_preset, + "foo", + ) + + def test_copy_preset_to_existing_name_raises_error(self): + prepare_presets() + self.data_manager.load_group(group_key="Foo Device 2") + self.data_manager.load_preset(name="preset2") + + self.assertRaises( + ValueError, + self.data_manager.copy_preset, + "preset3", + ) + + def test_rename_preset(self): + """should be able to rename a preset""" + prepare_presets() + self.data_manager.load_group(group_key="Foo Device 2") + self.data_manager.load_preset(name="preset2") + listener = Listener() + self.message_broker.subscribe(MessageType.group, listener) + self.message_broker.subscribe(MessageType.preset, listener) + + self.data_manager.rename_preset(new_name="new preset") + + # we expect the first data to be group data and the second + # one a preset data + presets_in_group = [preset for preset in listener.calls[0].presets] + self.assertNotIn("preset2", presets_in_group) + self.assertIn("new preset", presets_in_group) + self.assertEqual(listener.calls[1].name, "new preset") + + # this should pass without error: + self.data_manager.load_preset(name="new preset") + self.data_manager.rename_preset(new_name="new preset") + + def test_rename_preset_sets_autoload_correct(self): + """when renaming a preset the autoload status should still be set correctly""" + prepare_presets() + self.data_manager.load_group(group_key="Foo Device 2") + listener = Listener() + self.message_broker.subscribe(MessageType.preset, listener) + self.data_manager.load_preset(name="preset2") # sends PresetData + # sends PresetData with updated name, e. e. should be equal + self.data_manager.rename_preset(new_name="foo") + self.assertEqual(listener.calls[0].autoload, listener.calls[1].autoload) + + def test_cannot_rename_preset(self): + """rename preset should raise a DataManagementError if a preset + with the new name already exists in the current group""" + prepare_presets() + self.data_manager.load_group(group_key="Foo Device 2") + self.data_manager.load_preset(name="preset2") + + self.assertRaises( + ValueError, + self.data_manager.rename_preset, + new_name="preset3", + ) + + def test_cannot_rename_preset_without_preset(self): + prepare_presets() + + self.assertRaises( + DataManagementError, + self.data_manager.rename_preset, + new_name="foo", + ) + self.data_manager.load_group(group_key="Foo Device 2") + self.assertRaises( + DataManagementError, + self.data_manager.rename_preset, + new_name="foo", + ) + + def test_add_preset(self): + """should be able to add a preset""" + prepare_presets() + self.data_manager.load_group(group_key="Foo Device 2") + listener = Listener() + self.message_broker.subscribe(MessageType.group, listener) + + # should emit group_changed + self.data_manager.create_preset(name="new preset") + + presets_in_group = [preset for preset in listener.calls[0].presets] + self.assertIn("preset2", presets_in_group) + self.assertIn("preset3", presets_in_group) + self.assertIn("new preset", presets_in_group) + + def test_cannot_add_preset(self): + """adding a preset with the same name as an already existing + preset (of the current group) should raise a DataManagementError""" + prepare_presets() + self.data_manager.load_group(group_key="Foo Device 2") + self.data_manager.load_preset(name="preset2") + + self.assertRaises( + DataManagementError, + self.data_manager.create_preset, + name="preset3", + ) + + def test_cannot_add_preset_without_group(self): + self.assertRaises( + DataManagementError, + self.data_manager.create_preset, + name="foo", + ) + + def test_delete_preset(self): + """should be able to delete the current preset""" + prepare_presets() + self.data_manager.load_group(group_key="Foo Device 2") + self.data_manager.load_preset(name="preset2") + listener = Listener() + self.message_broker.subscribe(MessageType.group, listener) + self.message_broker.subscribe(MessageType.preset, listener) + self.message_broker.subscribe(MessageType.mapping, listener) + + # should emit only group_changed + self.data_manager.delete_preset() + + presets_in_group = [preset for preset in listener.calls[0].presets] + self.assertEqual(len(presets_in_group), 2) + self.assertNotIn("preset2", presets_in_group) + self.assertEqual(len(listener.calls), 1) + + def test_delete_preset_sanitized(self): + """should be able to delete the current preset""" + Preset(PathUtils.get_preset_path("Qux/[Device]?", "bla")).save() + Preset(PathUtils.get_preset_path("Qux/[Device]?", "foo")).save() + self.assertTrue( + os.path.exists(PathUtils.get_preset_path("Qux/[Device]?", "bla")) + ) + + self.data_manager.load_group(group_key="Qux/[Device]?") + self.data_manager.load_preset(name="bla") + listener = Listener() + self.message_broker.subscribe(MessageType.group, listener) + self.message_broker.subscribe(MessageType.preset, listener) + self.message_broker.subscribe(MessageType.mapping, listener) + + # should emit only group_changed + self.data_manager.delete_preset() + + presets_in_group = [preset for preset in listener.calls[0].presets] + self.assertEqual(len(presets_in_group), 1) + self.assertNotIn("bla", presets_in_group) + self.assertIn("foo", presets_in_group) + self.assertEqual(len(listener.calls), 1) + + self.assertFalse( + os.path.exists(PathUtils.get_preset_path("Qux/[Device]?", "bla")) + ) + + def test_load_mapping(self): + """should be able to load a mapping""" + preset, _, _ = prepare_presets() + expected_mapping = preset.get_mapping( + InputCombination([InputConfig(type=1, code=1)]) + ) + + self.data_manager.load_group(group_key="Foo Device") + self.data_manager.load_preset(name="preset1") + listener = Listener() + self.message_broker.subscribe(MessageType.mapping, listener) + self.data_manager.load_mapping( + combination=InputCombination([InputConfig(type=1, code=1)]) + ) + mapping = listener.calls[0] + + self.assertEqual(mapping, expected_mapping) + + def test_cannot_load_non_existing_mapping(self): + """loading a mapping tha is not present in the preset should raise a KeyError""" + prepare_presets() + + self.data_manager.load_group(group_key="Foo Device 2") + self.data_manager.load_preset(name="preset2") + self.assertRaises( + KeyError, + self.data_manager.load_mapping, + combination=InputCombination([InputConfig(type=1, code=1)]), + ) + + def test_cannot_load_mapping_without_preset(self): + """loading a mapping if no preset is loaded + should raise an DataManagementError""" + prepare_presets() + + self.assertRaises( + DataManagementError, + self.data_manager.load_mapping, + combination=InputCombination([InputConfig(type=1, code=1)]), + ) + self.data_manager.load_group("Foo Device") + self.assertRaises( + DataManagementError, + self.data_manager.load_mapping, + combination=InputCombination([InputConfig(type=1, code=1)]), + ) + + def test_load_event(self): + prepare_presets() + mock = MagicMock() + self.message_broker.subscribe(MessageType.selected_event, mock) + self.data_manager.load_group("Foo Device") + self.data_manager.load_preset("preset1") + self.data_manager.load_mapping(InputCombination([InputConfig(type=1, code=1)])) + self.data_manager.load_input_config(InputConfig(type=1, code=1)) + mock.assert_called_once_with(InputConfig(type=1, code=1)) + self.assertEqual( + self.data_manager.active_input_config, InputConfig(type=1, code=1) + ) + + def test_cannot_load_event_when_mapping_not_set(self): + prepare_presets() + self.data_manager.load_group("Foo Device") + self.data_manager.load_preset("preset1") + with self.assertRaises(DataManagementError): + self.data_manager.load_input_config(InputConfig(type=1, code=1)) + + def test_cannot_load_event_when_not_in_mapping_combination(self): + prepare_presets() + self.data_manager.load_group("Foo Device") + self.data_manager.load_preset("preset1") + self.data_manager.load_mapping(InputCombination([InputConfig(type=1, code=1)])) + with self.assertRaises(ValueError): + self.data_manager.load_input_config(InputConfig(type=1, code=5)) + + def test_update_event(self): + prepare_presets() + self.data_manager.load_group("Foo Device") + self.data_manager.load_preset("preset1") + self.data_manager.load_mapping(InputCombination([InputConfig(type=1, code=1)])) + self.data_manager.load_input_config(InputConfig(type=1, code=1)) + self.data_manager.update_input_config(InputConfig(type=1, code=5)) + self.assertEqual( + self.data_manager.active_input_config, InputConfig(type=1, code=5) + ) + + def test_update_event_sends_messages(self): + prepare_presets() + self.data_manager.load_group("Foo Device") + self.data_manager.load_preset("preset1") + self.data_manager.load_mapping(InputCombination([InputConfig(type=1, code=1)])) + self.data_manager.load_input_config(InputConfig(type=1, code=1)) + + mock = MagicMock() + self.message_broker.subscribe(MessageType.selected_event, mock) + self.message_broker.subscribe(MessageType.combination_update, mock) + self.message_broker.subscribe(MessageType.mapping, mock) + self.data_manager.update_input_config(InputConfig(type=1, code=5)) + expected = [ + call( + CombinationUpdate( + InputCombination([InputConfig(type=1, code=1)]), + InputCombination([InputConfig(type=1, code=5)]), + ) + ), + call(self.data_manager.active_mapping.get_bus_message()), + call(InputConfig(type=1, code=5)), + ] + mock.assert_has_calls(expected, any_order=False) + + def test_cannot_update_event_when_resulting_combination_exists(self): + prepare_presets() + self.data_manager.load_group("Foo Device") + self.data_manager.load_preset("preset1") + self.data_manager.load_mapping(InputCombination([InputConfig(type=1, code=1)])) + self.data_manager.load_input_config(InputConfig(type=1, code=1)) + with self.assertRaises(KeyError): + self.data_manager.update_input_config(InputConfig(type=1, code=2)) + + def test_cannot_update_event_when_not_loaded(self): + prepare_presets() + self.data_manager.load_group("Foo Device") + self.data_manager.load_preset("preset1") + self.data_manager.load_mapping(InputCombination([InputConfig(type=1, code=1)])) + with self.assertRaises(DataManagementError): + self.data_manager.update_input_config(InputConfig(type=1, code=2)) + + def test_update_mapping_emits_mapping_changed(self): + """update mapping should emit a mapping_changed event""" + prepare_presets() + self.data_manager.load_group(group_key="Foo Device 2") + self.data_manager.load_preset(name="preset2") + self.data_manager.load_mapping( + combination=InputCombination([InputConfig(type=1, code=4)]) + ) + + listener = Listener() + self.message_broker.subscribe(MessageType.mapping, listener) + self.data_manager.update_mapping( + name="foo", + output_symbol="f", + release_timeout=0.3, + ) + + response = listener.calls[0] + self.assertEqual(response.name, "foo") + self.assertEqual(response.output_symbol, "f") + self.assertEqual(response.release_timeout, 0.3) + + def test_updated_mapping_can_be_saved(self): + """make sure that updated changes can be saved""" + prepare_presets() + self.data_manager.load_group(group_key="Foo Device 2") + self.data_manager.load_preset(name="preset2") + self.data_manager.load_mapping( + combination=InputCombination([InputConfig(type=1, code=4)]) + ) + + self.data_manager.update_mapping( + name="foo", + output_symbol="f", + release_timeout=0.3, + ) + self.data_manager.save() + + preset = Preset(PathUtils.get_preset_path("Foo Device", "preset2"), UIMapping) + preset.load() + mapping = preset.get_mapping(InputCombination([InputConfig(type=1, code=4)])) + self.assertEqual(mapping.format_name(), "foo") + self.assertEqual(mapping.output_symbol, "f") + self.assertEqual(mapping.release_timeout, 0.3) + + def test_updated_mapping_saves_invalid_mapping(self): + """make sure that updated changes can be saved even if they are not valid""" + prepare_presets() + self.data_manager.load_group(group_key="Foo Device 2") + self.data_manager.load_preset(name="preset2") + self.data_manager.load_mapping( + combination=InputCombination([InputConfig(type=1, code=4)]) + ) + + self.data_manager.update_mapping( + output_symbol="bar", # not a macro and not a valid symbol + ) + self.data_manager.save() + + preset = Preset(PathUtils.get_preset_path("Foo Device", "preset2"), UIMapping) + preset.load() + mapping = preset.get_mapping(InputCombination([InputConfig(type=1, code=4)])) + self.assertIsNotNone(mapping.get_error()) + self.assertEqual(mapping.output_symbol, "bar") + + def test_update_mapping_combination_sends_massage(self): + prepare_presets() + + self.data_manager.load_group(group_key="Foo Device 2") + self.data_manager.load_preset(name="preset2") + self.data_manager.load_mapping( + combination=InputCombination([InputConfig(type=1, code=4)]) + ) + listener = Listener() + self.message_broker.subscribe(MessageType.mapping, listener) + self.message_broker.subscribe(MessageType.combination_update, listener) + + # we expect a message for combination update first, and then for mapping + self.data_manager.update_mapping( + input_combination=InputCombination( + InputCombination.from_tuples((1, 5), (1, 6)) + ) + ) + self.assertEqual(listener.calls[0].message_type, MessageType.combination_update) + self.assertEqual( + listener.calls[0].old_combination, + InputCombination([InputConfig(type=1, code=4)]), + ) + self.assertEqual( + listener.calls[0].new_combination, + InputCombination(InputCombination.from_tuples((1, 5), (1, 6))), + ) + self.assertEqual(listener.calls[1].message_type, MessageType.mapping) + self.assertEqual( + listener.calls[1].input_combination, + InputCombination(InputCombination.from_tuples((1, 5), (1, 6))), + ) + + def test_cannot_update_mapping_combination(self): + """updating a mapping with an already existing combination + should raise a KeyError""" + prepare_presets() + self.data_manager.load_group(group_key="Foo Device 2") + self.data_manager.load_preset(name="preset2") + self.data_manager.load_mapping( + combination=InputCombination([InputConfig(type=1, code=4)]) + ) + + self.assertRaises( + KeyError, + self.data_manager.update_mapping, + input_combination=InputCombination([InputConfig(type=1, code=3)]), + ) + + def test_cannot_update_mapping(self): + """updating a mapping should not be possible if the mapping was not loaded""" + prepare_presets() + self.assertRaises( + DataManagementError, + self.data_manager.update_mapping, + name="foo", + ) + self.data_manager.load_group(group_key="Foo Device 2") + self.assertRaises( + DataManagementError, + self.data_manager.update_mapping, + name="foo", + ) + self.data_manager.load_preset("preset2") + self.assertRaises( + DataManagementError, + self.data_manager.update_mapping, + name="foo", + ) + + def test_create_mapping(self): + """should be able to add a mapping to the current preset""" + prepare_presets() + + self.data_manager.load_group(group_key="Foo Device 2") + self.data_manager.load_preset(name="preset2") + listener = Listener() + self.message_broker.subscribe(MessageType.mapping, listener) + self.message_broker.subscribe(MessageType.preset, listener) + self.data_manager.create_mapping() # emits preset_changed + + self.data_manager.load_mapping(combination=InputCombination.empty_combination()) + + self.assertEqual(listener.calls[0].name, "preset2") + self.assertEqual(len(listener.calls[0].mappings), 3) + self.assertEqual(listener.calls[1], UIMapping()) + + def test_cannot_create_mapping_without_preset(self): + """adding a mapping if not preset is loaded + should raise an DataManagementError""" + prepare_presets() + + self.assertRaises(DataManagementError, self.data_manager.create_mapping) + self.data_manager.load_group(group_key="Foo Device 2") + self.assertRaises(DataManagementError, self.data_manager.create_mapping) + + def test_delete_mapping(self): + """should be able to delete a mapping""" + prepare_presets() + + old_preset = Preset(PathUtils.get_preset_path("Foo Device", "preset2")) + old_preset.load() + + self.data_manager.load_group(group_key="Foo Device 2") + self.data_manager.load_preset(name="preset2") + self.data_manager.load_mapping( + combination=InputCombination([InputConfig(type=1, code=3)]) + ) + + listener = Listener() + self.message_broker.subscribe(MessageType.preset, listener) + self.message_broker.subscribe(MessageType.mapping, listener) + + self.data_manager.delete_mapping() # emits preset + self.data_manager.save() + + deleted_mapping = old_preset.get_mapping( + InputCombination([InputConfig(type=1, code=3)]) + ) + mappings = listener.calls[0].mappings + preset_name = listener.calls[0].name + expected_preset = Preset(PathUtils.get_preset_path("Foo Device", "preset2")) + expected_preset.load() + expected_mappings = list(expected_preset) + + self.assertEqual(preset_name, "preset2") + for mapping in expected_mappings: + self.assertIn(mapping, mappings) + + self.assertNotIn(deleted_mapping, mappings) + + def test_cannot_delete_mapping(self): + """deleting a mapping should not be possible if the mapping was not loaded""" + prepare_presets() + self.assertRaises(DataManagementError, self.data_manager.delete_mapping) + self.data_manager.load_group(group_key="Foo Device 2") + self.assertRaises(DataManagementError, self.data_manager.delete_mapping) + self.data_manager.load_preset(name="preset2") + self.assertRaises(DataManagementError, self.data_manager.delete_mapping) + + def test_set_autoload(self): + """should be able to set the autoload status""" + prepare_presets() + self.data_manager.load_group(group_key="Foo Device") + + listener = Listener() + self.message_broker.subscribe(MessageType.preset, listener) + self.data_manager.load_preset(name="preset1") # sends updated preset data + self.data_manager.set_autoload(True) # sends updated preset data + self.data_manager.set_autoload(False) # sends updated preset data + + self.assertFalse(listener.calls[0].autoload) + self.assertTrue(listener.calls[1].autoload) + self.assertFalse(listener.calls[2].autoload) + + def test_each_device_can_have_autoload(self): + prepare_presets() + self.data_manager.load_group("Foo Device 2") + self.data_manager.load_preset("preset1") + self.data_manager.set_autoload(True) + + # switch to another device + self.data_manager.load_group("Foo Device") + self.data_manager.load_preset("preset1") + self.data_manager.set_autoload(True) + + # now check that both are set to autoload + self.data_manager.load_group("Foo Device 2") + self.data_manager.load_preset("preset1") + self.assertTrue(self.data_manager.get_autoload()) + + self.data_manager.load_group("Foo Device") + self.data_manager.load_preset("preset1") + self.assertTrue(self.data_manager.get_autoload()) + + def test_cannot_set_autoload_without_preset(self): + prepare_presets() + self.assertRaises( + DataManagementError, + self.data_manager.set_autoload, + True, + ) + self.data_manager.load_group(group_key="Foo Device 2") + self.assertRaises( + DataManagementError, + self.data_manager.set_autoload, + True, + ) + + def test_finds_newest_group(self): + Preset(PathUtils.get_preset_path("Foo Device", "preset 1")).save() + time.sleep(0.01) + Preset(PathUtils.get_preset_path("Bar Device", "preset 2")).save() + self.assertEqual(self.data_manager.get_newest_group_key(), "Bar Device") + + def test_finds_newest_preset(self): + Preset(PathUtils.get_preset_path("Foo Device", "preset 1")).save() + time.sleep(0.01) + Preset(PathUtils.get_preset_path("Foo Device", "preset 2")).save() + self.data_manager.load_group("Foo Device") + self.assertEqual(self.data_manager.get_newest_preset_name(), "preset 2") + + def test_newest_group_ignores_unknown_filetypes(self): + Preset(PathUtils.get_preset_path("Foo Device", "preset 1")).save() + time.sleep(0.01) + Preset(PathUtils.get_preset_path("Bar Device", "preset 2")).save() + + # not a preset, ignore + time.sleep(0.01) + path = os.path.join(PathUtils.get_preset_path("Foo Device"), "picture.png") + os.mknod(path) + + self.assertEqual(self.data_manager.get_newest_group_key(), "Bar Device") + + def test_newest_preset_ignores_unknown_filetypes(self): + Preset(PathUtils.get_preset_path("Bar Device", "preset 1")).save() + time.sleep(0.01) + Preset(PathUtils.get_preset_path("Bar Device", "preset 2")).save() + time.sleep(0.01) + Preset(PathUtils.get_preset_path("Bar Device", "preset 3")).save() + + # not a preset, ignore + time.sleep(0.01) + path = os.path.join(PathUtils.get_preset_path("Bar Device"), "picture.png") + os.mknod(path) + + self.data_manager.load_group("Bar Device") + + self.assertEqual(self.data_manager.get_newest_preset_name(), "preset 3") + + def test_newest_group_ignores_unknown_groups(self): + Preset(PathUtils.get_preset_path("Bar Device", "preset 1")).save() + time.sleep(0.01) + + # not a known group + Preset(PathUtils.get_preset_path("unknown_group", "preset 2")).save() + + self.assertEqual(self.data_manager.get_newest_group_key(), "Bar Device") + + def test_newest_group_and_preset_raises_file_not_found(self): + """should raise file not found error when all preset folders are empty""" + self.assertRaises(FileNotFoundError, self.data_manager.get_newest_group_key) + os.makedirs(PathUtils.get_preset_path("Bar Device")) + self.assertRaises(FileNotFoundError, self.data_manager.get_newest_group_key) + self.data_manager.load_group("Bar Device") + self.assertRaises(FileNotFoundError, self.data_manager.get_newest_preset_name) + + def test_newest_preset_raises_data_management_error(self): + """should raise data management error without an active group""" + self.assertRaises(DataManagementError, self.data_manager.get_newest_preset_name) + + def test_newest_preset_only_searches_active_group(self): + Preset(PathUtils.get_preset_path("Foo Device", "preset 1")).save() + time.sleep(0.01) + Preset(PathUtils.get_preset_path("Foo Device", "preset 3")).save() + time.sleep(0.01) + Preset(PathUtils.get_preset_path("Bar Device", "preset 2")).save() + + self.data_manager.load_group("Foo Device") + self.assertEqual(self.data_manager.get_newest_preset_name(), "preset 3") + + def test_available_preset_name_default(self): + self.data_manager.load_group("Foo Device") + self.assertEqual( + self.data_manager.get_available_preset_name(), DEFAULT_PRESET_NAME + ) + + def test_available_preset_name_adds_number_to_default(self): + Preset(PathUtils.get_preset_path("Foo Device", DEFAULT_PRESET_NAME)).save() + self.data_manager.load_group("Foo Device") + self.assertEqual( + self.data_manager.get_available_preset_name(), f"{DEFAULT_PRESET_NAME} 2" + ) + + def test_available_preset_name_returns_provided_name(self): + self.data_manager.load_group("Foo Device") + self.assertEqual(self.data_manager.get_available_preset_name("bar"), "bar") + + def test_available_preset_name__adds_number_to_provided_name(self): + Preset(PathUtils.get_preset_path("Foo Device", "bar")).save() + self.data_manager.load_group("Foo Device") + self.assertEqual(self.data_manager.get_available_preset_name("bar"), "bar 2") + + def test_available_preset_name_raises_data_management_error(self): + """should raise DataManagementError when group is not set""" + self.assertRaises( + DataManagementError, self.data_manager.get_available_preset_name + ) + + def test_get_preset_names(self): + self.data_manager.load_group("Qux/[Device]?") + Preset(PathUtils.get_preset_path("Qux/[Device]?", "new preset")).save() + # get_preset_names uses glob, the special characters in the device name + # don't break it. + self.assertEqual(self.data_manager.get_preset_names(), ("new preset",)) + + def test_available_preset_name_sanitized(self): + self.data_manager.load_group("Qux/[Device]?") + self.assertEqual( + self.data_manager.get_available_preset_name(), DEFAULT_PRESET_NAME + ) + + Preset(PathUtils.get_preset_path("Qux/[Device]?", DEFAULT_PRESET_NAME)).save() + self.assertEqual( + self.data_manager.get_available_preset_name(), f"{DEFAULT_PRESET_NAME} 2" + ) + + Preset(PathUtils.get_preset_path("Qux/[Device]?", "foo")).save() + self.assertEqual(self.data_manager.get_available_preset_name("foo"), "foo 2") + + def test_available_preset_name_increments_default(self): + Preset(PathUtils.get_preset_path("Foo Device", DEFAULT_PRESET_NAME)).save() + Preset( + PathUtils.get_preset_path("Foo Device", f"{DEFAULT_PRESET_NAME} 2") + ).save() + Preset( + PathUtils.get_preset_path("Foo Device", f"{DEFAULT_PRESET_NAME} 3") + ).save() + self.data_manager.load_group("Foo Device") + self.assertEqual( + self.data_manager.get_available_preset_name(), f"{DEFAULT_PRESET_NAME} 4" + ) + + def test_available_preset_name_increments_provided_name(self): + Preset(PathUtils.get_preset_path("Foo Device", "foo")).save() + Preset(PathUtils.get_preset_path("Foo Device", "foo 1")).save() + Preset(PathUtils.get_preset_path("Foo Device", "foo 2")).save() + self.data_manager.load_group("Foo Device") + self.assertEqual(self.data_manager.get_available_preset_name("foo 1"), "foo 3") + + def test_should_publish_groups(self): + listener = Listener() + self.message_broker.subscribe(MessageType.groups, listener) + + self.data_manager.publish_groups() + data = listener.calls[0] + + # we expect a list of tuples with the group key and their device types + self.assertEqual( + data.groups, + { + "Foo Device": ["keyboard"], + "Foo Device 2": ["gamepad", "keyboard", "mouse"], + "Bar Device": ["keyboard"], + "gamepad": ["gamepad"], + "Qux/[Device]?": ["keyboard"], + }, + ) + + def test_should_load_group(self): + prepare_presets() + listener = Listener() + self.message_broker.subscribe(MessageType.group, listener) + + self.data_manager.load_group("Foo Device 2") + + self.assertEqual(self.data_manager.active_group.key, "Foo Device 2") + data = ( + GroupData("Foo Device 2", (p1, p2, p3)) + for p1, p2, p3 in permutations(("preset3", "preset2", "preset1")) + ) + self.assertIn(listener.calls[0], data) + + def test_should_start_reading_active_group(self): + def f(*_): + raise AssertionError() + + self.reader.set_group = f + self.assertRaises(AssertionError, self.data_manager.load_group, "Foo Device") + + def test_should_send_uinputs(self): + listener = Listener() + self.message_broker.subscribe(MessageType.uinputs, listener) + + self.data_manager.publish_uinputs() + data = listener.calls[0] + + # we expect a list of tuples with the group key and their device types + self.assertEqual( + data.uinputs, + { + "gamepad": self.uinputs.get_uinput("gamepad").capabilities(), + "keyboard": self.uinputs.get_uinput("keyboard").capabilities(), + "mouse": self.uinputs.get_uinput("mouse").capabilities(), + "keyboard + mouse": self.uinputs.get_uinput( + "keyboard + mouse" + ).capabilities(), + }, + ) + + def test_cannot_stop_injecting_without_group(self): + self.assertRaises(DataManagementError, self.data_manager.stop_injecting) + + def test_cannot_start_injecting_without_preset(self): + self.data_manager.load_group("Foo Device") + self.assertRaises(DataManagementError, self.data_manager.start_injecting) + + def test_cannot_get_injector_state_without_group(self): + self.assertRaises(DataManagementError, self.data_manager.get_state) diff --git a/tests/unit/test_event_pipeline/__init__.py b/tests/unit/test_event_pipeline/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/unit/test_event_pipeline/event_pipeline_test_base.py b/tests/unit/test_event_pipeline/event_pipeline_test_base.py new file mode 100644 index 0000000..7ff8998 --- /dev/null +++ b/tests/unit/test_event_pipeline/event_pipeline_test_base.py @@ -0,0 +1,86 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + +import asyncio +import unittest +from typing import Iterable + +import evdev + +from inputremapper.configs.preset import Preset +from inputremapper.injection.context import Context +from inputremapper.injection.event_reader import EventReader +from inputremapper.injection.global_uinputs import GlobalUInputs, UInput +from inputremapper.injection.mapping_handlers.mapping_parser import MappingParser +from inputremapper.input_event import InputEvent +from tests.lib.cleanup import cleanup +from tests.lib.logger import logger +from tests.lib.fixtures import Fixture + + +class EventPipelineTestBase(unittest.IsolatedAsyncioTestCase): + """Test the event pipeline form event_reader to UInput.""" + + def setUp(self): + self.global_uinputs = GlobalUInputs(UInput) + self.global_uinputs.prepare_all() + self.mapping_parser = MappingParser(self.global_uinputs) + self.global_uinputs.is_service = True + self.global_uinputs.prepare_all() + self.forward_uinput = evdev.UInput(name="test-forward-uinput") + self.stop_event = asyncio.Event() + + def tearDown(self) -> None: + cleanup() + + async def asyncTearDown(self) -> None: + logger.info("setting stop_event for the reader") + self.stop_event.set() + await asyncio.sleep(0.5) + + @staticmethod + async def send_events(events: Iterable[InputEvent], event_reader: EventReader): + for event in events: + logger.info("sending into event_pipeline: %s", event) + await event_reader.handle(event) + + def create_event_reader( + self, + preset: Preset, + source: Fixture, + ) -> EventReader: + """Create and start an EventReader.""" + context = Context( + preset, + source_devices={}, + forward_devices={source.get_device_hash(): self.forward_uinput}, + mapping_parser=self.mapping_parser, + ) + reader = EventReader( + context, + evdev.InputDevice(source.path), + self.stop_event, + ) + asyncio.ensure_future(reader.run()) + return reader + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/test_event_pipeline/test_abs_to_abs.py b/tests/unit/test_event_pipeline/test_abs_to_abs.py new file mode 100644 index 0000000..74f1a26 --- /dev/null +++ b/tests/unit/test_event_pipeline/test_abs_to_abs.py @@ -0,0 +1,145 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + +import asyncio +import unittest + +from evdev.ecodes import ( + EV_ABS, + ABS_X, + ABS_Y, +) + +from inputremapper.configs.mapping import ( + Mapping, +) +from inputremapper.configs.preset import Preset +from inputremapper.configs.input_config import InputCombination, InputConfig +from inputremapper.input_event import InputEvent +from tests.lib.fixtures import fixtures +from tests.lib.test_setup import test_setup +from tests.unit.test_event_pipeline.event_pipeline_test_base import ( + EventPipelineTestBase, +) + + +@test_setup +class TestAbsToAbs(EventPipelineTestBase): + async def test_abs_to_abs(self): + gain = 0.5 + # left x to mouse x + input_config = InputConfig(type=EV_ABS, code=ABS_X) + mapping_config = { + "input_combination": InputCombination([input_config]).to_config(), + "target_uinput": "gamepad", + "output_type": EV_ABS, + "output_code": ABS_X, + "gain": gain, + "deadzone": 0, + } + mapping_1 = Mapping(**mapping_config) + preset = Preset() + preset.add(mapping_1) + input_config = InputConfig(type=EV_ABS, code=ABS_Y) + mapping_config["input_combination"] = InputCombination( + [input_config] + ).to_config() + mapping_config["output_code"] = ABS_Y + mapping_2 = Mapping(**mapping_config) + preset.add(mapping_2) + + x = fixtures.gamepad.max_abs + y = fixtures.gamepad.max_abs + + event_reader = self.create_event_reader(preset, fixtures.gamepad) + + await self.send_events( + [ + InputEvent.abs(ABS_X, -x), + InputEvent.abs(ABS_Y, y), + ], + event_reader, + ) + + await asyncio.sleep(0.2) + + history = self.global_uinputs.get_uinput("gamepad").write_history + self.assertEqual( + history, + [ + InputEvent.from_tuple((3, 0, fixtures.gamepad.min_abs / 2)), + InputEvent.from_tuple((3, 1, fixtures.gamepad.max_abs / 2)), + ], + ) + + async def test_abs_to_abs_with_input_switch(self): + gain = 0.5 + input_combination = InputCombination( + ( + InputConfig(type=EV_ABS, code=0), + InputConfig(type=EV_ABS, code=1, analog_threshold=10), + ) + ) + # left x to mouse x + mapping_config = { + "input_combination": input_combination.to_config(), + "target_uinput": "gamepad", + "output_type": EV_ABS, + "output_code": ABS_X, + "gain": gain, + "deadzone": 0, + } + mapping_1 = Mapping(**mapping_config) + preset = Preset() + preset.add(mapping_1) + + x = fixtures.gamepad.max_abs + y = fixtures.gamepad.max_abs + + event_reader = self.create_event_reader(preset, fixtures.gamepad) + + await self.send_events( + [ + InputEvent.abs(ABS_X, -x // 5), # will not map + InputEvent.abs(ABS_X, -x), # will map later + # switch axis on sends initial position (previous event) + InputEvent.abs(ABS_Y, y), + InputEvent.abs(ABS_X, x), # normally mapped + InputEvent.abs(ABS_Y, y // 15), # off, re-centers axis + InputEvent.abs(ABS_X, -x // 5), # will not map + ], + event_reader, + ) + + await asyncio.sleep(0.2) + + history = self.global_uinputs.get_uinput("gamepad").write_history + self.assertEqual( + history, + [ + InputEvent.from_tuple((3, 0, fixtures.gamepad.min_abs / 2)), + InputEvent.from_tuple((3, 0, fixtures.gamepad.max_abs / 2)), + InputEvent.from_tuple((3, 0, 0)), + ], + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/test_event_pipeline/test_abs_to_btn.py b/tests/unit/test_event_pipeline/test_abs_to_btn.py new file mode 100644 index 0000000..5a72887 --- /dev/null +++ b/tests/unit/test_event_pipeline/test_abs_to_btn.py @@ -0,0 +1,225 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + +import unittest + +from evdev.ecodes import EV_KEY, EV_ABS, ABS_X, ABS_Z + +from inputremapper.configs.mapping import ( + Mapping, +) +from inputremapper.configs.preset import Preset +from inputremapper.configs.keyboard_layout import keyboard_layout +from inputremapper.configs.input_config import InputCombination, InputConfig +from inputremapper.input_event import InputEvent +from tests.lib.logger import logger +from tests.lib.fixtures import fixtures +from tests.lib.test_setup import test_setup +from tests.unit.test_event_pipeline.event_pipeline_test_base import ( + EventPipelineTestBase, +) + + +@test_setup +class TestAbsToBtn(EventPipelineTestBase): + async def test_abs_trigger_threshold_simple(self): + # at 30% map to a + mapping_1 = Mapping.from_combination( + InputCombination( + [InputConfig(type=EV_ABS, code=ABS_X, analog_threshold=30)] + ), + output_symbol="a", + ) + preset = Preset() + preset.add(mapping_1) + a_code = keyboard_layout.get("a") + event_reader = self.create_event_reader(preset, fixtures.gamepad) + + # 50%, trigger a + await self.send_events( + [InputEvent.abs(ABS_X, fixtures.gamepad.max_abs // 2)], + event_reader, + ) + + keyboard_history = self.global_uinputs.get_uinput("keyboard").write_history + self.assertEqual(len(keyboard_history), 1) + self.assertEqual(keyboard_history[0], (EV_KEY, a_code, 1)) + self.assertNotIn((EV_KEY, a_code, 0), keyboard_history) + + async def test_abs_z(self): + # Shoulder triggers (ABS_Z, ABS_RZ, ABS_GAS, ABS_BRAKE). Their center point + # is equal to the fully released point. They only have one direction. + + # Triggers go from 0 to whatever value, so use the appropriate fixture. + # Yes this test setup is stupid, it should have different absinfos depending + # on the event. + fixture = fixtures.gamepad_abs_0_to_256 + + # at 30% map to a + mapping_1 = Mapping.from_combination( + InputCombination( + [InputConfig(type=EV_ABS, code=ABS_Z, analog_threshold=30)] + ), + output_symbol="a", + ) + + # This mapping is impossible. There is no negative direction. + mapping_2 = Mapping.from_combination( + InputCombination( + [InputConfig(type=EV_ABS, code=ABS_Z, analog_threshold=-30)] + ), + output_symbol="b", + ) + + preset = Preset() + preset.add(mapping_1) + preset.add(mapping_2) + a_code = keyboard_layout.get("a") + b_code = keyboard_layout.get("b") + event_reader = self.create_event_reader(preset, fixture) + max_abs = fixture.max_abs + + # 50%, trigger a + await self.send_events( + [InputEvent.abs(ABS_Z, max_abs // 2)], + event_reader, + ) + + keyboard_history = self.global_uinputs.get_uinput("keyboard").write_history + self.assertEqual(keyboard_history, [(EV_KEY, a_code, 1)]) + + # Lets slowly move it all the way into the other direction + assert fixture.min_abs == 0 # just for clarification here + await self.send_events( + [ + InputEvent.abs(ABS_Z, max_abs // 3), + InputEvent.abs(ABS_Z, max_abs // 5), + InputEvent.abs(ABS_Z, max_abs // 10), + InputEvent.abs(ABS_Z, 0), + ], + event_reader, + ) + + # The negative mapping (mapping_2, to b) was not triggered. It just released + # the a. + self.assertEqual( + keyboard_history, + [ + (EV_KEY, a_code, 1), + (EV_KEY, a_code, 0), + ], + ) + + async def test_abs_trigger_threshold(self): + """Test that different activation points for abs_to_btn work correctly.""" + forwarded_history = self.forward_uinput.write_history + + # at 30% map to a + mapping_1 = Mapping.from_combination( + InputCombination( + [InputConfig(type=EV_ABS, code=ABS_X, analog_threshold=30)] + ), + output_symbol="a", + ) + # at 70% map to b + mapping_2 = Mapping.from_combination( + InputCombination( + [InputConfig(type=EV_ABS, code=ABS_X, analog_threshold=70)] + ), + output_symbol="b", + ) + preset = Preset() + preset.add(mapping_1) + preset.add(mapping_2) + + a = keyboard_layout.get("a") + b = keyboard_layout.get("b") + + event_reader = self.create_event_reader(preset, fixtures.gamepad) + + logger.info("do nothing, then trigger a") + await self.send_events( + [ + # -10%, do nothing + InputEvent.abs(ABS_X, fixtures.gamepad.min_abs // 10), + # 0%, do noting + InputEvent.abs(ABS_X, 0), + # 10%, do nothing + InputEvent.abs(ABS_X, fixtures.gamepad.max_abs // 10), + # 50%, trigger a + InputEvent.abs(ABS_X, fixtures.gamepad.max_abs // 2), + ], + event_reader, + ) + keyboard_history = self.global_uinputs.get_uinput("keyboard").write_history + + self.assertEqual(keyboard_history.count((EV_KEY, a, 1)), 1) + self.assertNotIn((EV_KEY, a, 0), keyboard_history) + self.assertNotIn((EV_KEY, b, 1), keyboard_history) + # the negative movements are not mapped, so the one event at -10% and its + # release should be forwarded instead + self.assertEqual( + forwarded_history, + [ + (EV_ABS, ABS_X, fixtures.gamepad.min_abs // 10), + (EV_ABS, ABS_X, 0), + ], + ) + + logger.info("trigger b, then release b") + await self.send_events( + [ + # 80%, trigger b + InputEvent.abs(ABS_X, int(fixtures.gamepad.max_abs * 0.8)), + # 50%, release b + InputEvent.abs(ABS_X, fixtures.gamepad.max_abs // 2), + ], + event_reader, + ) + keyboard_history = self.global_uinputs.get_uinput("keyboard").write_history + self.assertEqual( + keyboard_history, + [ + (EV_KEY, a, 1), + (EV_KEY, b, 1), + (EV_KEY, b, 0), + ], + ) + + # 0% release a + await event_reader.handle(InputEvent.abs(ABS_X, 0)) + keyboard_history = self.global_uinputs.get_uinput("keyboard").write_history + self.assertEqual( + keyboard_history, + [ + (EV_KEY, a, 1), + (EV_KEY, b, 1), + (EV_KEY, b, 0), + (EV_KEY, a, 0), + ], + ) + + # This didn't change. ABS_X of 0 should not be forwarded, because the joystick + # came from the mapped direction to 0. Instead, it maps to release a. + self.assertEqual(len(forwarded_history), 2) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/test_event_pipeline/test_abs_to_rel.py b/tests/unit/test_event_pipeline/test_abs_to_rel.py new file mode 100644 index 0000000..b6ebb98 --- /dev/null +++ b/tests/unit/test_event_pipeline/test_abs_to_rel.py @@ -0,0 +1,202 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + +import asyncio +import unittest + +from evdev.ecodes import ( + EV_ABS, + EV_REL, + ABS_X, + ABS_Y, + REL_X, + REL_Y, + REL_HWHEEL, + REL_WHEEL, + REL_WHEEL_HI_RES, + REL_HWHEEL_HI_RES, +) + +from inputremapper.configs.mapping import ( + Mapping, + REL_XY_SCALING, + DEFAULT_REL_RATE, +) +from inputremapper.configs.preset import Preset +from inputremapper.configs.input_config import InputCombination, InputConfig +from inputremapper.input_event import InputEvent +from tests.lib.fixtures import fixtures +from tests.lib.test_setup import test_setup +from tests.unit.test_event_pipeline.event_pipeline_test_base import ( + EventPipelineTestBase, +) + + +@test_setup +class TestAbsToRel(EventPipelineTestBase): + async def test_abs_to_rel(self): + """Map gamepad EV_ABS events to EV_REL events.""" + + rel_rate = 60 # rate [Hz] at which events are produced + gain = 0.5 # halve the speed of the rel axis + # left x to mouse x + input_config = InputConfig(type=EV_ABS, code=ABS_X) + mapping_config = { + "input_combination": InputCombination([input_config]).to_config(), + "target_uinput": "mouse", + "output_type": EV_REL, + "output_code": REL_X, + "rel_rate": rel_rate, + "gain": gain, + "deadzone": 0, + } + mapping_1 = Mapping(**mapping_config) + preset = Preset() + preset.add(mapping_1) + # left y to mouse y + input_config = InputConfig(type=EV_ABS, code=ABS_Y) + mapping_config["input_combination"] = InputCombination( + [input_config] + ).to_config() + mapping_config["output_code"] = REL_Y + mapping_2 = Mapping(**mapping_config) + preset.add(mapping_2) + + # set input axis to 100% in order to move + # (gain * REL_XY_SCALING) pixel per event + x = fixtures.gamepad.max_abs + y = fixtures.gamepad.max_abs + + event_reader = self.create_event_reader(preset, fixtures.gamepad) + + await self.send_events( + [ + InputEvent.abs(ABS_X, -x), + InputEvent.abs(ABS_Y, -y), + ], + event_reader, + ) + # wait a bit more for it to sum up + sleep = 0.5 + await asyncio.sleep(sleep) + # stop it + await self.send_events( + [ + InputEvent.abs(ABS_X, 0), + InputEvent.abs(ABS_Y, 0), + ], + event_reader, + ) + + mouse_history = self.global_uinputs.get_uinput("mouse").write_history + + if mouse_history[0].type == EV_ABS: + raise AssertionError( + "The injector probably just forwarded them unchanged" + # possibly in addition to writing mouse events + ) + + # This varies quite a lot depending on the machines performance. + # Face it, python is a bad choice for this. + self.assertAlmostEqual(len(mouse_history), rel_rate * sleep * 2, delta=10) + + # those may be in arbitrary order + expected_value = -gain * REL_XY_SCALING * (rel_rate / DEFAULT_REL_RATE) + count_x = mouse_history.count((EV_REL, REL_X, expected_value)) + count_y = mouse_history.count((EV_REL, REL_Y, expected_value)) + self.assertGreater(count_x, 1) + self.assertGreater(count_y, 1) + # only those two types of events were written + self.assertEqual(len(mouse_history), count_x + count_y) + + async def test_abs_to_wheel_hi_res_quirk(self): + """When mapping to wheel events we always expect to see both, + REL_WHEEL and REL_WHEEL_HI_RES events with an accumulative value ratio of 1/120 + """ + rel_rate = 60 # rate [Hz] at which events are produced + gain = 1 + # left x to mouse x + input_config = InputConfig(type=EV_ABS, code=ABS_X) + mapping_config = { + "input_combination": InputCombination([input_config]).to_config(), + "target_uinput": "mouse", + "output_type": EV_REL, + "output_code": REL_WHEEL, + "rel_rate": rel_rate, + "gain": gain, + "deadzone": 0, + } + mapping_1 = Mapping(**mapping_config) + + preset = Preset() + preset.add(mapping_1) + # left y to mouse y + input_config = InputConfig(type=EV_ABS, code=ABS_Y) + mapping_config["input_combination"] = InputCombination( + [input_config] + ).to_config() + mapping_config["output_code"] = REL_HWHEEL_HI_RES + mapping_2 = Mapping(**mapping_config) + preset.add(mapping_2) + + # set input axis to 100% in order to move + # speed*gain*rate=1*0.5*60 pixel per second + x = fixtures.gamepad.max_abs + y = fixtures.gamepad.max_abs + + event_reader = self.create_event_reader(preset, fixtures.gamepad) + + await self.send_events( + [ + InputEvent.abs(ABS_X, x), + InputEvent.abs(ABS_Y, -y), + ], + event_reader, + ) + # wait a bit more for it to sum up + sleep = 0.8 + await asyncio.sleep(sleep) + # stop it + await self.send_events( + [ + InputEvent.abs(ABS_X, 0), + InputEvent.abs(ABS_Y, 0), + ], + event_reader, + ) + m_history = self.global_uinputs.get_uinput("mouse").write_history + + rel_wheel = sum([event.value for event in m_history if event.code == REL_WHEEL]) + rel_wheel_hi_res = sum( + [event.value for event in m_history if event.code == REL_WHEEL_HI_RES] + ) + rel_hwheel = sum( + [event.value for event in m_history if event.code == REL_HWHEEL] + ) + rel_hwheel_hi_res = sum( + [event.value for event in m_history if event.code == REL_HWHEEL_HI_RES] + ) + + self.assertAlmostEqual(rel_wheel, rel_wheel_hi_res / 120, places=0) + self.assertAlmostEqual(rel_hwheel, rel_hwheel_hi_res / 120, places=0) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/test_event_pipeline/test_axis_transformation.py b/tests/unit/test_event_pipeline/test_axis_transformation.py new file mode 100644 index 0000000..51b2269 --- /dev/null +++ b/tests/unit/test_event_pipeline/test_axis_transformation.py @@ -0,0 +1,209 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + +import dataclasses +import functools +import itertools +import unittest +from typing import Iterable, List + +from inputremapper.injection.mapping_handlers.axis_transform import Transformation +from tests.lib.test_setup import test_setup + + +@test_setup +class TestAxisTransformation(unittest.TestCase): + @dataclasses.dataclass + class InitArgs: + max_: int + min_: int + deadzone: float + gain: float + expo: float + + def values(self): + return self.__dict__.values() + + def get_init_args( + self, + max_=(255, 1000, 2**15), + min_=(50, 0, -255), + deadzone=(0, 0.5), + gain=(0.5, 1, 2), + expo=(-0.9, 0, 0.3), + ) -> Iterable[InitArgs]: + for args in itertools.product(max_, min_, deadzone, gain, expo): + yield self.InitArgs(*args) + + @staticmethod + def scale_to_range(min_, max_, x=(-1, -0.2, 0, 0.6, 1)) -> List[float]: + """Scale values between -1 and 1 up, such that they are between min and max.""" + half_range = (max_ - min_) / 2 + return [float_x * half_range + min_ + half_range for float_x in x] + + def test_scale_to_range(self): + """Make sure scale_to_range will actually return the min and max values + (avoid "off by one" errors)""" + max_ = (255, 1000, 2**15) + min_ = (50, 0, -255) + + for x1, x2 in itertools.product(min_, max_): + scaled = self.scale_to_range(x1, x2, (-1, 1)) + self.assertEqual(scaled, [x1, x2]) + + def test_expo_symmetry(self): + """Test that the transformation is symmetric for expo parameter + x = f(g(x)), if f._expo == - g._expo + + with the following constraints: + min = -1, max = 1 + gain = 1 + deadzone = 0 + + we can remove the constraints for min, max and gain, + by scaling the values appropriately after each transformation + """ + + for init_args in self.get_init_args(deadzone=(0,)): + f = Transformation(*init_args.values()) + init_args.expo = -init_args.expo + g = Transformation(*init_args.values()) + + scale = functools.partial( + self.scale_to_range, + init_args.min_, + init_args.max_, + ) + for x in scale(): + y1 = g(x) + y1 = y1 / init_args.gain # remove the gain + y1 = scale((y1,))[0] # remove the min/max constraint + + y2 = f(y1) + y2 = y2 / init_args.gain # remove the gain + y2 = scale((y2,))[0] # remove the min/max constraint + self.assertAlmostEqual(x, y2, msg=f"test expo symmetry for {init_args}") + + def test_origin_symmetry(self): + """Test that the transformation is symmetric to the origin_hash + f(x) = - f(-x) + within the constraints: min = -max + """ + + for init_args in self.get_init_args(): + init_args.min_ = -init_args.max_ + f = Transformation(*init_args.values()) + for x in self.scale_to_range(init_args.min_, init_args.max_): + self.assertAlmostEqual( + f(x), + -f(-x), + msg=f"test origin_hash symmetry at {x=} for {init_args}", + ) + + def test_gain(self): + """Test that f(max) = gain and f(min) = -gain.""" + for init_args in self.get_init_args(): + f = Transformation(*init_args.values()) + self.assertAlmostEqual( + f(init_args.max_), + init_args.gain, + msg=f"test gain for {init_args}", + ) + self.assertAlmostEqual( + f(init_args.min_), + -init_args.gain, + msg=f"test gain for {init_args}", + ) + + def test_deadzone(self): + """Test the Transfomation returns exactly 0 in the range of the deadzone.""" + + for init_args in self.get_init_args(deadzone=(0.1, 0.2, 0.9)): + f = Transformation(*init_args.values()) + for x in self.scale_to_range( + init_args.min_, + init_args.max_, + x=( + init_args.deadzone * 0.999, + -init_args.deadzone * 0.999, + 0.3 * init_args.deadzone, + 0, + ), + ): + self.assertEqual(f(x), 0, msg=f"test deadzone at {x=} for {init_args}") + + def test_continuity_near_deadzone(self): + """Test that the Transfomation is continues (no sudden jump) next to the + deadzone""" + + for init_args in self.get_init_args(deadzone=(0.1, 0.2, 0.9)): + f = Transformation(*init_args.values()) + scale = functools.partial( + self.scale_to_range, + init_args.min_, + init_args.max_, + ) + x = ( + init_args.deadzone * 1.00001, + init_args.deadzone * 1.001, + -init_args.deadzone * 1.00001, + -init_args.deadzone * 1.001, + ) + scaled_x = scale(x=x) + + p1 = (x[0], f(scaled_x[0])) # first point right of deadzone + p2 = (x[1], f(scaled_x[1])) # second point right of deadzone + + # calculate a linear function y = m * x + b from p1 and p2 + m = (p1[1] - p2[1]) / (p1[0] - p2[0]) + b = p1[1] - m * p1[0] + + # the zero intersection of that function must be close to the + # edge of the deadzone + self.assertAlmostEqual( + -b / m, + init_args.deadzone, + places=5, + msg=f"test continuity at {init_args.deadzone} for {init_args}", + ) + + # same thing on the other side + p1 = (x[2], f(scaled_x[2])) + p2 = (x[3], f(scaled_x[3])) + m = (p1[1] - p2[1]) / (p1[0] - p2[0]) + b = p1[1] - m * p1[0] + self.assertAlmostEqual( + -b / m, + -init_args.deadzone, + places=5, + msg=f"test continuity at {- init_args.deadzone} for {init_args}", + ) + + def test_expo_out_of_range(self): + f = Transformation(deadzone=0.1, min_=-20, max_=5, expo=1.3) + self.assertRaises(ValueError, f, 0) + f = Transformation(deadzone=0.1, min_=-20, max_=5, expo=-1.3) + self.assertRaises(ValueError, f, 0) + + def test_returns_one_for_range_between_minus_and_plus_one(self): + for init_args in self.get_init_args(max_=(1,), min_=(-1,), gain=(1,)): + f = Transformation(*init_args.values()) + self.assertEqual(f(1), 1) + self.assertEqual(f(-1), -1) diff --git a/tests/unit/test_event_pipeline/test_combination.py b/tests/unit/test_event_pipeline/test_combination.py new file mode 100644 index 0000000..837ccc5 --- /dev/null +++ b/tests/unit/test_event_pipeline/test_combination.py @@ -0,0 +1,1497 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + +import asyncio +import unittest + +from evdev.ecodes import ( + EV_KEY, + EV_ABS, + EV_REL, + ABS_X, + ABS_Y, + REL_X, + REL_Y, + BTN_A, + ABS_HAT0X, + BTN_LEFT, + BTN_B, + KEY_A, + ABS_HAT0Y, + KEY_B, + KEY_C, + KEY_D, + BTN_TL, + KEY_1, +) + +from inputremapper.configs.mapping import ( + Mapping, + REL_XY_SCALING, +) +from inputremapper.configs.preset import Preset +from inputremapper.configs.keyboard_layout import keyboard_layout +from inputremapper.configs.input_config import InputCombination, InputConfig +from inputremapper.input_event import InputEvent +from tests.lib.logger import logger +from tests.lib.fixtures import fixtures +from tests.lib.pipes import uinput_write_history +from tests.lib.test_setup import test_setup +from tests.unit.test_event_pipeline.event_pipeline_test_base import ( + EventPipelineTestBase, +) + + +@test_setup +class TestCombination(EventPipelineTestBase): + + # ----------------- + # | Test Template | + # ----------------- + + async def test_releases_then_triggers(self): + origin = fixtures.foo_device_2_keyboard + origin_hash = origin.get_device_hash() + + input_combination = InputCombination( + [ + InputConfig( + type=EV_KEY, + code=KEY_A, + origin_hash=origin_hash, + ), + InputConfig( + type=EV_KEY, + code=KEY_B, + origin_hash=origin_hash, + ), + ] + ) + + mapping = Mapping( + input_combination=input_combination.to_config(), + target_uinput="keyboard", + output_symbol="1", + release_combination_keys=True, + ) + + preset = Preset() + preset.add(mapping) + + event_reader = self.create_event_reader(preset, origin) + + await self.send_events( + [ + InputEvent.key(KEY_A, 1, origin_hash), + InputEvent.key(KEY_B, 1, origin_hash), + ], + event_reader, + ) + + # Other tests check forwarded_history and keyboard_history individually, + # so here is one that looks at the order in uinput_write_history + self.assertListEqual( + uinput_write_history, + [ + (EV_KEY, KEY_A, 1), + (EV_KEY, KEY_A, 0), + (EV_KEY, KEY_1, 1), + ], + ) + + # -------------------------- + # | More complicated tests | + # -------------------------- + + async def test_combine_hat_right_and_forward_hat_left(self): + origin = fixtures.gamepad + origin_hash = origin.get_device_hash() + + input_combination = InputCombination( + [ + InputConfig( + type=EV_ABS, + code=ABS_HAT0X, + origin_hash=origin_hash, + analog_threshold=30, + ), + InputConfig( + type=EV_KEY, + code=BTN_B, + origin_hash=origin_hash, + ), + ] + ) + + mapping = Mapping( + input_combination=input_combination.to_config(), + target_uinput="keyboard", + output_symbol="1", + release_combination_keys=True, + ) + + preset = Preset() + preset.add(mapping) + + event_reader = self.create_event_reader(preset, origin) + + # 1. dpad-Left works + # ------------------ + + await self.send_events( + [ + InputEvent.abs(ABS_HAT0X, -1, origin_hash), + InputEvent.abs(ABS_HAT0X, 0, origin_hash), + ], + event_reader, + ) + + # This used to be empty due to a bug + self.assertListEqual( + uinput_write_history, + [ + (EV_ABS, ABS_HAT0X, -1), + (EV_ABS, ABS_HAT0X, 0), + ], + ) + + # 2. Pressing dpad-right without combining it should forward it + # ------------------ + + await self.send_events( + [ + InputEvent.abs(ABS_HAT0X, 1, origin_hash), + InputEvent.abs(ABS_HAT0X, 0, origin_hash), + ], + event_reader, + ) + + self.assertListEqual( + uinput_write_history, + [ + # 1 + (EV_ABS, ABS_HAT0X, -1), + (EV_ABS, ABS_HAT0X, 0), + # 2 + (EV_ABS, ABS_HAT0X, 1), + (EV_ABS, ABS_HAT0X, 0), + ], + ) + + # 3. The combintaion can be triggered + # ------------------ + + await self.send_events( + [ + InputEvent.abs(ABS_HAT0X, 1, origin_hash), + InputEvent.key(BTN_B, 1, origin_hash), + InputEvent.key(BTN_B, 0, origin_hash), + InputEvent.abs(ABS_HAT0X, 0, origin_hash), + ], + event_reader, + ) + + self.assertListEqual( + uinput_write_history, + [ + # 1 + (EV_ABS, ABS_HAT0X, -1), + (EV_ABS, ABS_HAT0X, 0), + # 2 + (EV_ABS, ABS_HAT0X, 1), + (EV_ABS, ABS_HAT0X, 0), + # 3 + (EV_ABS, ABS_HAT0X, 1), + (EV_ABS, ABS_HAT0X, 0), + (EV_KEY, KEY_1, 1), + (EV_KEY, KEY_1, 0), + ], + ) + + async def test_abs_combination_0_to_256(self): + b = keyboard_layout.get("b") + origin = fixtures.gamepad_abs_0_to_256 + origin_hash = origin.get_device_hash() + mapping_1 = Mapping.from_combination( + InputCombination( + [ + InputConfig( + type=EV_ABS, + code=ABS_X, + # This value is relative in percent from half_range + # Positive: should trigger from 192 to 256 + # Negative: it would be from 0 to 64 + analog_threshold=50, + origin_hash=origin_hash, + ) + ] + ), + output_symbol="b", + ) + assert mapping_1.release_combination_keys + preset = Preset() + preset.add(mapping_1) + event_reader = self.create_event_reader(preset, origin) + + # don't trigger anything + await self.send_events( + [ + # Because the abs range is from 0 to 256, 128 is the centerpoint and + # releases both directions. + InputEvent.abs(ABS_X, 128, origin_hash), + # Wrong direction, should not trigger anything + InputEvent.abs(ABS_X, 0, origin_hash), + ], + event_reader, + ) + keyboard_history = self.global_uinputs.get_uinput("keyboard").write_history + self.assertEqual(len(keyboard_history), 0) + + # trigger b + await self.send_events( + [InputEvent.abs(ABS_X, 256, origin_hash)], + event_reader, + ) + self.assertEqual(keyboard_history.count((EV_KEY, b, 1)), 1) + self.assertEqual(len(keyboard_history), 1) + + # release b + await self.send_events( + [InputEvent.abs(ABS_X, 128, origin_hash)], + event_reader, + ) + keyboard_history = self.global_uinputs.get_uinput("keyboard").write_history + self.assertEqual(keyboard_history.count((EV_KEY, b, 0)), 1) + self.assertEqual(len(keyboard_history), 2) + + async def test_abs_combination_0_to_256_negative(self): + b = keyboard_layout.get("b") + origin = fixtures.gamepad_abs_0_to_256 + origin_hash = origin.get_device_hash() + mapping_1 = Mapping.from_combination( + InputCombination( + [ + InputConfig( + type=EV_ABS, + code=ABS_X, + # This value is relative in percent from half_range + # Positive: should trigger from 192 to 256 + # Negative: it would be from 0 to 64 + analog_threshold=-50, + origin_hash=origin_hash, + ) + ] + ), + output_symbol="b", + ) + assert mapping_1.release_combination_keys + preset = Preset() + preset.add(mapping_1) + event_reader = self.create_event_reader(preset, origin) + + logger.info("don't trigger anything") + await self.send_events( + [ + # Because the abs range is from 0 to 256, 128 is the centerpoint and + # releases both directions. + InputEvent.abs(ABS_X, 128, origin_hash), + # Wrong direction, should not trigger anything + InputEvent.abs(ABS_X, 256, origin_hash), + ], + event_reader, + ) + keyboard_history = self.global_uinputs.get_uinput("keyboard").write_history + self.assertEqual(len(keyboard_history), 0) + + logger.info("trigger b") + await self.send_events( + [InputEvent.abs(ABS_X, 0, origin_hash)], + event_reader, + ) + self.assertEqual(keyboard_history.count((EV_KEY, b, 1)), 1) + self.assertEqual(len(keyboard_history), 1) + + logger.info("release b") + await self.send_events( + [InputEvent.abs(ABS_X, 128, origin_hash)], + event_reader, + ) + keyboard_history = self.global_uinputs.get_uinput("keyboard").write_history + self.assertEqual(keyboard_history.count((EV_KEY, b, 0)), 1) + self.assertEqual(len(keyboard_history), 2) + + async def test_any_event_as_button(self): + """As long as there is an event handler and a mapping we should be able + to map anything to a button""" + + # value needs to be higher than 10% below center of axis (absinfo) + w_down = (EV_ABS, ABS_Y, -12345) + w_up = (EV_ABS, ABS_Y, 0) + + s_down = (EV_ABS, ABS_Y, 12345) + s_up = (EV_ABS, ABS_Y, 0) + + d_down = (EV_REL, REL_X, 100) + d_up = (EV_REL, REL_X, 0) + + a_down = (EV_REL, REL_X, -100) + a_up = (EV_REL, REL_X, 0) + + b_down = (EV_ABS, ABS_HAT0X, 1) + b_up = (EV_ABS, ABS_HAT0X, 0) + + c_down = (EV_ABS, ABS_HAT0X, -1) + c_up = (EV_ABS, ABS_HAT0X, 0) + + # first change the system mapping because Mapping will validate against it + keyboard_layout.clear() + code_w = 71 + code_b = 72 + code_c = 73 + code_d = 74 + code_a = 75 + code_s = 76 + keyboard_layout._set("w", code_w) + keyboard_layout._set("d", code_d) + keyboard_layout._set("a", code_a) + keyboard_layout._set("s", code_s) + keyboard_layout._set("b", code_b) + keyboard_layout._set("c", code_c) + + preset = Preset() + preset.add( + Mapping.from_combination( + InputCombination(InputCombination.from_tuples(b_down)), "keyboard", "b" + ) + ) + preset.add( + Mapping.from_combination( + InputCombination(InputCombination.from_tuples(c_down)), "keyboard", "c" + ) + ) + preset.add( + Mapping.from_combination( + InputCombination(InputCombination.from_tuples((*w_down[:2], -10))), + "keyboard", + "w", + ) + ) + preset.add( + Mapping.from_combination( + InputCombination(InputCombination.from_tuples((*d_down[:2], 10))), + "keyboard", + "k(d)", + ) + ) + preset.add( + Mapping.from_combination( + InputCombination(InputCombination.from_tuples((*s_down[:2], 10))), + "keyboard", + "s", + ) + ) + preset.add( + Mapping.from_combination( + InputCombination(InputCombination.from_tuples((*a_down[:2], -10))), + "keyboard", + "a", + ) + ) + + # gamepad fixture + event_reader = self.create_event_reader(preset, fixtures.gamepad) + + await self.send_events( + [ + InputEvent.from_tuple(b_down), + InputEvent.from_tuple(c_down), + InputEvent.from_tuple(w_down), + InputEvent.from_tuple(d_down), + InputEvent.from_tuple(s_down), + InputEvent.from_tuple(a_down), + InputEvent.from_tuple(b_up), + InputEvent.from_tuple(c_up), + InputEvent.from_tuple(w_up), + InputEvent.from_tuple(d_up), + InputEvent.from_tuple(s_up), + InputEvent.from_tuple(a_up), + ], + event_reader, + ) + # wait a bit for the rel_to_btn handler to send the key up + await asyncio.sleep(0.1) + + history = self.global_uinputs.get_uinput("keyboard").write_history + + self.assertEqual(history.count((EV_KEY, code_b, 1)), 1) + self.assertEqual(history.count((EV_KEY, code_c, 1)), 1) + self.assertEqual(history.count((EV_KEY, code_w, 1)), 1) + self.assertEqual(history.count((EV_KEY, code_d, 1)), 1) + self.assertEqual(history.count((EV_KEY, code_a, 1)), 1) + self.assertEqual(history.count((EV_KEY, code_s, 1)), 1) + self.assertEqual(history.count((EV_KEY, code_b, 0)), 1) + self.assertEqual(history.count((EV_KEY, code_c, 0)), 1) + self.assertEqual(history.count((EV_KEY, code_w, 0)), 1) + self.assertEqual(history.count((EV_KEY, code_d, 0)), 1) + self.assertEqual(history.count((EV_KEY, code_a, 0)), 1) + self.assertEqual(history.count((EV_KEY, code_s, 0)), 1) + + async def test_reset_releases_keys(self): + """Make sure that macros and keys are releases when the stop event is set.""" + preset = Preset() + input_cfg = InputCombination([InputConfig(type=EV_KEY, code=1)]).to_config() + preset.add( + Mapping.from_combination( + input_combination=input_cfg, output_symbol="hold(a)" + ) + ) + + input_cfg = InputCombination([InputConfig(type=EV_KEY, code=2)]).to_config() + preset.add( + Mapping.from_combination(input_combination=input_cfg, output_symbol="b") + ) + + input_cfg = InputCombination([InputConfig(type=EV_KEY, code=3)]).to_config() + preset.add( + Mapping.from_combination( + input_combination=input_cfg, output_symbol="modify(c,hold(d))" + ), + ) + event_reader = self.create_event_reader(preset, fixtures.foo_device_2_keyboard) + + a = keyboard_layout.get("a") + b = keyboard_layout.get("b") + c = keyboard_layout.get("c") + d = keyboard_layout.get("d") + + await self.send_events( + [ + InputEvent.key(1, 1), + InputEvent.key(2, 1), + InputEvent.key(3, 1), + ], + event_reader, + ) + await asyncio.sleep(0.1) + + forwarded_history = self.forward_uinput.write_history + keyboard_history = self.global_uinputs.get_uinput("keyboard").write_history + + self.assertEqual(len(forwarded_history), 0) + # a down, b down, c down, d down + self.assertEqual(len(keyboard_history), 4) + + event_reader.context.reset() + await asyncio.sleep(0.1) + + forwarded_history = self.forward_uinput.write_history + keyboard_history = self.global_uinputs.get_uinput("keyboard").write_history + + self.assertEqual(len(forwarded_history), 0) + # all a, b, c, d down+up + self.assertEqual(len(keyboard_history), 8) + keyboard_history = keyboard_history[-4:] + self.assertIn((EV_KEY, a, 0), keyboard_history) + self.assertIn((EV_KEY, b, 0), keyboard_history) + self.assertIn((EV_KEY, c, 0), keyboard_history) + self.assertIn((EV_KEY, d, 0), keyboard_history) + + async def test_forward_abs(self): + """Test if EV_ABS events are forwarded when other events of the same input are not.""" + preset = Preset() + # BTN_A -> 77 + keyboard_layout._set("b", 77) + preset.add( + Mapping.from_combination( + InputCombination([InputConfig(type=EV_KEY, code=BTN_A)]), + "keyboard", + "b", + ) + ) + event_reader = self.create_event_reader(preset, fixtures.gamepad) + + # should forward them unmodified + await self.send_events( + [ + InputEvent.abs(ABS_X, 10), + InputEvent.abs(ABS_Y, 20), + InputEvent.abs(ABS_X, -30), + InputEvent.abs(ABS_Y, -40), + # send them to keyboard 77 + InputEvent.key(BTN_A, 1), + InputEvent.key(BTN_A, 0), + ], + event_reader, + ) + + history = self.forward_uinput.write_history + keyboard_history = self.global_uinputs.get_uinput("keyboard").write_history + + self.assertEqual(history.count((EV_ABS, ABS_X, 10)), 1) + self.assertEqual(history.count((EV_ABS, ABS_Y, 20)), 1) + self.assertEqual(history.count((EV_ABS, ABS_X, -30)), 1) + self.assertEqual(history.count((EV_ABS, ABS_Y, -40)), 1) + self.assertEqual(keyboard_history.count((EV_KEY, 77, 1)), 1) + self.assertEqual(keyboard_history.count((EV_KEY, 77, 0)), 1) + + async def test_forward_rel(self): + """Test if EV_REL events are forwarded when other events of the same input are not.""" + preset = Preset() + # BTN_A -> 77 + keyboard_layout._set("b", 77) + preset.add( + Mapping.from_combination( + InputCombination([InputConfig(type=EV_KEY, code=BTN_LEFT)]), + "keyboard", + "b", + ) + ) + event_reader = self.create_event_reader(preset, fixtures.gamepad) + + # should forward them unmodified + await self.send_events( + [ + InputEvent.rel(REL_X, 10), + InputEvent.rel(REL_Y, 20), + InputEvent.rel(REL_X, -30), + InputEvent.rel(REL_Y, -40), + # send them to keyboard 77 + InputEvent.key(BTN_LEFT, 1), + InputEvent.key(BTN_LEFT, 0), + ], + event_reader, + ) + await asyncio.sleep(0.1) + + history = self.forward_uinput.write_history + keyboard_history = self.global_uinputs.get_uinput("keyboard").write_history + + self.assertEqual(history.count((EV_REL, REL_X, 10)), 1) + self.assertEqual(history.count((EV_REL, REL_Y, 20)), 1) + self.assertEqual(history.count((EV_REL, REL_X, -30)), 1) + self.assertEqual(history.count((EV_REL, REL_Y, -40)), 1) + self.assertEqual(keyboard_history.count((EV_KEY, 77, 1)), 1) + self.assertEqual(keyboard_history.count((EV_KEY, 77, 0)), 1) + + async def test_combination(self): + """Test if combinations map to keys properly.""" + a = keyboard_layout.get("a") + b = keyboard_layout.get("b") + c = keyboard_layout.get("c") + + origin = fixtures.gamepad + origin_hash = origin.get_device_hash() + + mapping_1 = Mapping.from_combination( + InputCombination( + [ + InputConfig( + type=EV_ABS, + code=ABS_X, + analog_threshold=1, + origin_hash=origin_hash, + ) + ] + ), + output_symbol="a", + ) + + mapping_2 = Mapping.from_combination( + InputCombination( + [ + InputConfig( + type=EV_ABS, + code=ABS_X, + analog_threshold=1, + origin_hash=origin_hash, + ), + InputConfig(type=EV_KEY, code=BTN_A, origin_hash=origin_hash), + ] + ), + output_symbol="b", + ) + + mapping_3 = Mapping.from_combination( + InputCombination( + [ + InputConfig( + type=EV_ABS, + code=ABS_X, + analog_threshold=1, + origin_hash=origin_hash, + ), + InputConfig(type=EV_KEY, code=BTN_A, origin_hash=origin_hash), + InputConfig(type=EV_KEY, code=BTN_B, origin_hash=origin_hash), + ] + ), + output_symbol="c", + ) + + assert mapping_1.release_combination_keys + assert mapping_2.release_combination_keys + assert mapping_3.release_combination_keys + + preset = Preset() + preset.add(mapping_1) + preset.add(mapping_2) + preset.add(mapping_3) + + event_reader = self.create_event_reader(preset, origin) + + # send_events awaits the event_reader to do its thing + await self.send_events( + [ + # forwarded + InputEvent.key(BTN_A, 1, origin_hash), + # triggers b, releases BTN_A, ABS_X + InputEvent.abs(ABS_X, 1234, origin_hash), + # triggers c, releases BTN_A, ABS_X, BTN_B + InputEvent.key(BTN_B, 1, origin_hash), + ], + event_reader, + ) + + keyboard_history = self.global_uinputs.get_uinput("keyboard").write_history + + forwarded_history = self.forward_uinput.write_history + + # I don't remember the specifics. I guess if there is a combination ongoing, + # it shouldn't trigger ABS_X -> a? + self.assertNotIn((EV_KEY, a, 1), keyboard_history) + + # c and b should have been written, because the input from send_events + # should trigger the combination + self.assertEqual(keyboard_history.count((EV_KEY, c, 1)), 1) + self.assertEqual(keyboard_history.count((EV_KEY, b, 1)), 1) + + self.assertEqual(forwarded_history.count((EV_KEY, BTN_A, 1)), 1) + self.assertIn((EV_KEY, BTN_A, 0), forwarded_history) + self.assertNotIn((EV_ABS, ABS_X, 1234), forwarded_history) + self.assertNotIn((EV_KEY, BTN_B, 1), forwarded_history) + + # release b and c) + await self.send_events( + [InputEvent.abs(ABS_X, 0, origin_hash)], + event_reader, + ) + + keyboard_history = self.global_uinputs.get_uinput("keyboard").write_history + + self.assertNotIn((EV_KEY, a, 1), keyboard_history) + self.assertNotIn((EV_KEY, a, 0), keyboard_history) + self.assertEqual(keyboard_history.count((EV_KEY, c, 0)), 1) + self.assertEqual(keyboard_history.count((EV_KEY, b, 0)), 1) + + async def test_combination_manual_release_in_press_order(self): + """Test if combinations map to keys properly.""" + # release_combination_keys is off + # press 5, then 6, then release 5, then release 6 + in_1 = keyboard_layout.get("7") + in_2 = keyboard_layout.get("8") + out = keyboard_layout.get("a") + + origin = fixtures.foo_device_2_keyboard + origin_hash = origin.get_device_hash() + + input_combination = InputCombination( + [ + InputConfig( + type=EV_KEY, + code=in_1, + origin_hash=origin_hash, + ), + InputConfig( + type=EV_KEY, + code=in_2, + origin_hash=origin_hash, + ), + ] + ) + + mapping = Mapping( + input_combination=input_combination.to_config(), + target_uinput="keyboard", + output_symbol="a", + release_combination_keys=False, + ) + + assert not mapping.release_combination_keys + + preset = Preset() + preset.add(mapping) + + event_reader = self.create_event_reader(preset, origin) + + keyboard_history = self.global_uinputs.get_uinput("keyboard").write_history + forwarded_history = self.forward_uinput.write_history + + # press the first key of the combination + await self.send_events([InputEvent.key(in_1, 1, origin_hash)], event_reader) + self.assertListEqual(forwarded_history, [(EV_KEY, in_1, 1)]) + + # then the second, it should trigger the combination + await self.send_events([InputEvent.key(in_2, 1, origin_hash)], event_reader) + self.assertListEqual(forwarded_history, [(EV_KEY, in_1, 1)]) + self.assertListEqual(keyboard_history, [(EV_KEY, out, 1)]) + + # release the first key. A key-down event was injected for it previously, so + # now we find a key-up event here as well. + await self.send_events([InputEvent.key(in_1, 0, origin_hash)], event_reader) + self.assertListEqual(forwarded_history, [(EV_KEY, in_1, 1), (EV_KEY, in_1, 0)]) + self.assertListEqual(keyboard_history, [(EV_KEY, out, 1), (EV_KEY, out, 0)]) + + # release the second key. No key-down event was injected, so we don't have a + # key-up event here either. + await self.send_events([InputEvent.key(in_2, 0, origin_hash)], event_reader) + self.assertListEqual(forwarded_history, [(EV_KEY, in_1, 1), (EV_KEY, in_1, 0)]) + self.assertListEqual(keyboard_history, [(EV_KEY, out, 1), (EV_KEY, out, 0)]) + + async def test_suppressed_doesnt_forward_releases(self): + origin = fixtures.foo_device_2_keyboard + origin_hash = origin.get_device_hash() + + input_combination_1 = InputCombination( + [ + InputConfig( + type=EV_KEY, + code=KEY_A, + origin_hash=origin_hash, + ), + InputConfig( + type=EV_KEY, + code=KEY_B, + origin_hash=origin_hash, + ), + InputConfig( + type=EV_KEY, + code=KEY_C, + origin_hash=origin_hash, + ), + ] + ) + + mapping_1 = Mapping( + input_combination=input_combination_1.to_config(), + target_uinput="keyboard", + output_symbol="1", + release_combination_keys=False, + ) + + input_combination_2 = InputCombination( + [ + InputConfig( + type=EV_KEY, + code=KEY_A, + origin_hash=origin_hash, + ), + InputConfig( + type=EV_KEY, + code=KEY_C, + origin_hash=origin_hash, + ), + ] + ) + + mapping_2 = Mapping( + input_combination=input_combination_2.to_config(), + target_uinput="keyboard", + output_symbol="2", + release_combination_keys=True, + ) + + preset = Preset() + preset.add(mapping_1) + preset.add(mapping_2) + + event_reader = self.create_event_reader(preset, origin) + # Trigger mapping_1, mapping_2 should be suppressed + await self.send_events( + [ + InputEvent.key(KEY_A, 1, origin_hash), + InputEvent.key(KEY_B, 1, origin_hash), + InputEvent.key(KEY_C, 1, origin_hash), + ], + event_reader, + ) + + keyboard_history = self.global_uinputs.get_uinput("keyboard").write_history + forwarded_history = self.forward_uinput.write_history + # There used to be an incorrect KEY_A release, caused by + # `release_combination_keys` on the suppressed mapping. + self.assertListEqual( + forwarded_history, + [ + (EV_KEY, KEY_A, 1), + (EV_KEY, KEY_B, 1), + ], + ) + self.assertListEqual( + keyboard_history, + [ + (EV_KEY, KEY_1, 1), + ], + ) + + async def test_no_stuck_key(self): + origin = fixtures.foo_device_2_keyboard + origin_hash = origin.get_device_hash() + + input_combination_1 = InputCombination( + [ + InputConfig( + type=EV_KEY, + code=KEY_A, + origin_hash=origin_hash, + ), + InputConfig( + type=EV_KEY, + code=KEY_B, + origin_hash=origin_hash, + ), + InputConfig( + type=EV_KEY, + code=KEY_D, + origin_hash=origin_hash, + ), + ] + ) + + mapping_1 = Mapping( + input_combination=input_combination_1.to_config(), + target_uinput="keyboard", + output_symbol="1", + release_combination_keys=False, + ) + + input_combination_2 = InputCombination( + [ + InputConfig( + type=EV_KEY, + code=KEY_C, + origin_hash=origin_hash, + ), + InputConfig( + type=EV_KEY, + code=KEY_D, + origin_hash=origin_hash, + ), + ] + ) + + mapping_2 = Mapping( + input_combination=input_combination_2.to_config(), + target_uinput="keyboard", + output_symbol="2", + release_combination_keys=False, + ) + + preset = Preset() + preset.add(mapping_1) + preset.add(mapping_2) + + event_reader = self.create_event_reader(preset, origin) + # Trigger mapping_1, mapping_2 should be suppressed + await self.send_events( + [ + InputEvent.key(KEY_A, 1, origin_hash), + InputEvent.key(KEY_B, 1, origin_hash), + InputEvent.key(KEY_C, 1, origin_hash), + InputEvent.key(KEY_D, 1, origin_hash), + # Release c -> mapping_2 transitions from suppressed to passive. + # The release of c should be forwarded. + InputEvent.key(KEY_C, 0, origin_hash), + ], + event_reader, + ) + + keyboard_history = self.global_uinputs.get_uinput("keyboard").write_history + forwarded_history = self.forward_uinput.write_history + self.assertListEqual( + forwarded_history, + [ + (EV_KEY, KEY_A, 1), + (EV_KEY, KEY_B, 1), + (EV_KEY, KEY_C, 1), + (EV_KEY, KEY_C, 0), + ], + ) + self.assertListEqual( + keyboard_history, + [ + (EV_KEY, KEY_1, 1), + ], + ) + + async def test_ignore_hold(self): + # hold as in event-value 2, not in macro-hold. + # linux will generate events with value 2 after input-remapper injected + # the key-press, so input-remapper doesn't need to forward them. That + # would cause duplicate events of those values otherwise. + ev_1 = InputEvent.key(KEY_A, 1) + ev_2 = InputEvent.key(KEY_A, 2) + ev_3 = InputEvent.key(KEY_A, 0) + + preset = Preset() + preset.add( + Mapping.from_combination( + input_combination=InputCombination( + [InputConfig(type=EV_KEY, code=KEY_A)] + ), + output_symbol="a", + ) + ) + a = keyboard_layout.get("a") + + event_reader = self.create_event_reader(preset, fixtures.gamepad) + await self.send_events( + [ev_1, ev_2, ev_3], + event_reader, + ) + + keyboard_history = self.global_uinputs.get_uinput("keyboard").write_history + forwarded_history = self.forward_uinput.write_history + self.assertEqual(len(keyboard_history), 2) + self.assertEqual(len(forwarded_history), 0) + self.assertNotIn((EV_KEY, a, 2), keyboard_history) + + async def test_ignore_disabled(self): + origin = fixtures.gamepad + origin_hash = origin.get_device_hash() + + ev_1 = InputEvent.abs(ABS_HAT0Y, 1, origin_hash) + ev_2 = InputEvent.abs(ABS_HAT0Y, 0, origin_hash) + + ev_3 = InputEvent.abs(ABS_HAT0X, 1, origin_hash) # disabled + ev_4 = InputEvent.abs(ABS_HAT0X, 0, origin_hash) + + ev_5 = InputEvent.key(KEY_A, 1, origin_hash) + ev_6 = InputEvent.key(KEY_A, 0, origin_hash) + + combi_1 = (ev_5, ev_3) + combi_2 = (ev_3, ev_5) + + preset = Preset() + preset.add( + Mapping.from_combination( + input_combination=InputCombination( + [ + InputConfig.from_input_event(ev_1), + ] + ), + output_symbol="a", + ) + ) + preset.add( + Mapping.from_combination( + input_combination=InputCombination( + [ + InputConfig.from_input_event(ev_3), + ] + ), + output_symbol="disable", + ) + ) + preset.add( + Mapping.from_combination( + input_combination=InputCombination( + ( + InputConfig.from_input_event(combi_1[0]), + InputConfig.from_input_event(combi_1[1]), + ) + ), + output_symbol="b", + ) + ) + preset.add( + Mapping.from_combination( + input_combination=InputCombination( + ( + InputConfig.from_input_event(combi_2[0]), + InputConfig.from_input_event(combi_2[1]), + ) + ), + output_symbol="c", + ) + ) + + a = keyboard_layout.get("a") + b = keyboard_layout.get("b") + c = keyboard_layout.get("c") + + event_reader = self.create_event_reader(preset, origin) + + """Single keys""" + await self.send_events( + [ + ev_1, # press a + ev_3, # disabled + ev_2, # release a + ev_4, # disabled + ], + event_reader, + ) + keyboard_history = self.global_uinputs.get_uinput("keyboard").write_history + forwarded_history = self.forward_uinput.write_history + self.assertIn((EV_KEY, a, 1), keyboard_history) + self.assertIn((EV_KEY, a, 0), keyboard_history) + self.assertEqual(len(keyboard_history), 2) + self.assertEqual(len(forwarded_history), 0) + + """A combination that ends in a disabled key""" + # ev_5 should be forwarded and the combination triggered + await self.send_events(combi_1, event_reader) + keyboard_history = self.global_uinputs.get_uinput("keyboard").write_history + forwarded_history = self.forward_uinput.write_history + self.assertIn((EV_KEY, b, 1), keyboard_history) + self.assertEqual(len(keyboard_history), 3) + self.assertEqual(forwarded_history.count(ev_3), 0) + self.assertEqual(forwarded_history.count(ev_5), 1) + self.assertTrue(forwarded_history.count(ev_6) >= 1) + + # release what the combination maps to + await self.send_events([ev_4, ev_6], event_reader) + keyboard_history = self.global_uinputs.get_uinput("keyboard").write_history + forwarded_history = self.forward_uinput.write_history + self.assertIn((EV_KEY, b, 0), keyboard_history) + self.assertEqual(len(keyboard_history), 4) + self.assertEqual(forwarded_history.count(ev_3), 0) + self.assertTrue(forwarded_history.count(ev_6) >= 1) + + """A combination that starts with a disabled key""" + # only the combination should get triggered + await self.send_events(combi_2, event_reader) + keyboard_history = self.global_uinputs.get_uinput("keyboard").write_history + forwarded_history = self.forward_uinput.write_history + self.assertIn((EV_KEY, c, 1), keyboard_history) + self.assertEqual(len(keyboard_history), 5) + self.assertEqual(forwarded_history.count(ev_3), 0) + self.assertEqual(forwarded_history.count(ev_5), 1) + self.assertTrue(forwarded_history.count(ev_6) >= 1) + + # release what the combination maps to + await self.send_events([ev_4, ev_6], event_reader) + keyboard_history = self.global_uinputs.get_uinput("keyboard").write_history + forwarded_history = self.forward_uinput.write_history + for event in keyboard_history: + print(event.event_tuple) + self.assertIn((EV_KEY, c, 0), keyboard_history) + self.assertEqual(len(keyboard_history), 6) + self.assertEqual(forwarded_history.count(ev_3), 0) + self.assertTrue(forwarded_history.count(ev_6) >= 1) + + async def test_combination_keycode_macro_mix(self): + """Ev_1 triggers macro, ev_1 + ev_2 triggers key while the macro is + still running""" + + down_1 = (EV_ABS, ABS_HAT0X, 1) + down_2 = (EV_ABS, ABS_HAT0Y, -1) + up_1 = (EV_ABS, ABS_HAT0X, 0) + up_2 = (EV_ABS, ABS_HAT0Y, 0) + + a = keyboard_layout.get("a") + b = keyboard_layout.get("b") + + preset = Preset() + preset.add( + Mapping.from_combination( + InputCombination(InputCombination.from_tuples(down_1)), + output_symbol="h(k(a))", + ) + ) + preset.add( + Mapping.from_combination( + InputCombination(InputCombination.from_tuples(down_1, down_2)), + output_symbol="b", + ) + ) + + event_reader = self.create_event_reader(preset, fixtures.gamepad) + # macro starts + await self.send_events([InputEvent.from_tuple(down_1)], event_reader) + await asyncio.sleep(0.05) + keyboard_history = self.global_uinputs.get_uinput("keyboard").write_history + forwarded_history = self.forward_uinput.write_history + self.assertEqual(len(forwarded_history), 0) + self.assertGreater(len(keyboard_history), 1) + self.assertNotIn((EV_KEY, b, 1), keyboard_history) + self.assertIn((EV_KEY, a, 1), keyboard_history) + self.assertIn((EV_KEY, a, 0), keyboard_history) + + # combination triggered + await self.send_events([InputEvent.from_tuple(down_2)], event_reader) + await asyncio.sleep(0) + keyboard_history = self.global_uinputs.get_uinput("keyboard").write_history + self.assertIn((EV_KEY, b, 1), keyboard_history) + + len_a = len(self.global_uinputs.get_uinput("keyboard").write_history) + await asyncio.sleep(0.05) + len_b = len(self.global_uinputs.get_uinput("keyboard").write_history) + # still running + self.assertGreater(len_b, len_a) + + # release + await self.send_events([InputEvent.from_tuple(up_1)], event_reader) + keyboard_history = self.global_uinputs.get_uinput("keyboard").write_history + self.assertEqual(keyboard_history[-1], (EV_KEY, b, 0)) + await asyncio.sleep(0.05) + len_c = len(self.global_uinputs.get_uinput("keyboard").write_history) + await asyncio.sleep(0.05) + len_d = len(self.global_uinputs.get_uinput("keyboard").write_history) + # not running anymore + self.assertEqual(len_c, len_d) + + await self.send_events([InputEvent.from_tuple(up_2)], event_reader) + await asyncio.sleep(0.05) + len_e = len(self.global_uinputs.get_uinput("keyboard").write_history) + self.assertEqual(len_e, len_d) + + async def test_wheel_combination_release_failure(self): + # test based on a bug that once occurred + # 1 | 22.6698, ((1, 276, 1)) -------------- forwarding + # 2 | 22.9904, ((1, 276, 1), (2, 8, -1)) -- maps to 30 + # 3 | 23.0103, ((1, 276, 1), (2, 8, -1)) -- duplicate key down + # 4 | ... 34 more duplicate key downs (scrolling) + # 5 | 23.7104, ((1, 276, 1), (2, 8, -1)) -- duplicate key down + # 6 | 23.7283, ((1, 276, 0)) -------------- forwarding release + # 7 | 23.7303, ((2, 8, -1)) --------------- forwarding + # 8 | 23.7865, ((2, 8, 0)) ---------------- not forwarding release + # line 7 should have been "duplicate key down" as well + # line 8 should have released 30, instead it was never released + # + # Note: the test was modified for the new Event pipeline: + # line 6 now releases the combination + # line 7 get forwarded + # line 8 get forwarded + + scroll = InputEvent.from_tuple((2, 8, -1)) + scroll_release = InputEvent.from_tuple((2, 8, 0)) + btn_down = InputEvent.key(276, 1) + btn_up = InputEvent.key(276, 0) + combination = InputCombination( + InputCombination.from_tuples((1, 276, 1), (2, 8, -1)) + ) + + keyboard_layout.clear() + keyboard_layout._set("a", 30) + a = 30 + + m = Mapping.from_combination(combination, output_symbol="a") + m.release_timeout = 0.1 # a higher release timeout to give time for assertions + + preset = Preset() + preset.add(m) + + event_reader = self.create_event_reader(preset, fixtures.foo_device_2_mouse) + + await self.send_events([btn_down], event_reader) + forwarded_history = self.forward_uinput.write_history + self.assertEqual(forwarded_history[0], btn_down) + + await self.send_events([scroll], event_reader) + # "maps to 30" + keyboard_history = self.global_uinputs.get_uinput("keyboard").write_history + self.assertEqual(keyboard_history[0], (EV_KEY, a, 1)) + + await self.send_events([scroll] * 5, event_reader) + + # nothing new since all of them were duplicate key downs + keyboard_history = self.global_uinputs.get_uinput("keyboard").write_history + self.assertEqual(len(keyboard_history), 1) + + await self.send_events([btn_up], event_reader) + # releasing the combination + keyboard_history = self.global_uinputs.get_uinput("keyboard").write_history + self.assertEqual(keyboard_history[1], (EV_KEY, a, 0)) + + # more scroll events + # it should be ignored as duplicate key-down + await self.send_events([scroll] * 5, event_reader) + forwarded_history = self.forward_uinput.write_history + self.assertEqual(forwarded_history.count(scroll), 5) + + await self.send_events([scroll_release], event_reader) + forwarded_history = self.forward_uinput.write_history + self.assertEqual(forwarded_history[-1], scroll_release) + + async def test_can_not_map(self): + """Inject events to wrong or invalid uinput.""" + ev_1 = InputEvent.key(KEY_A, 1) + ev_2 = InputEvent.key(KEY_B, 1) + ev_3 = InputEvent.key(KEY_C, 1) + + ev_4 = InputEvent.key(KEY_A, 0) + ev_5 = InputEvent.key(KEY_B, 0) + ev_6 = InputEvent.key(KEY_C, 0) + + mapping_1 = Mapping( + input_combination=InputCombination([InputConfig.from_input_event(ev_2)]), + target_uinput="keyboard", + output_type=EV_KEY, + output_code=BTN_TL, + ) + mapping_2 = Mapping( + input_combination=InputCombination([InputConfig.from_input_event(ev_3)]), + target_uinput="keyboard", + output_type=EV_KEY, + output_code=KEY_A, + ) + + preset = Preset() + preset.add(mapping_1) + preset.add(mapping_2) + + event_reader = self.create_event_reader(preset, fixtures.foo_device_2_mouse) + # send key-down and up + await self.send_events( + [ + ev_1, + ev_2, + ev_3, + ev_4, + ev_5, + ev_6, + ], + event_reader, + ) + + keyboard_history = self.global_uinputs.get_uinput("keyboard").write_history + forwarded_history = self.forward_uinput.write_history + + self.assertEqual(len(forwarded_history), 4) + self.assertEqual(len(keyboard_history), 2) + self.assertIn(ev_1, forwarded_history) + self.assertIn(ev_2, forwarded_history) + self.assertIn(ev_4, forwarded_history) + self.assertIn(ev_5, forwarded_history) + self.assertNotIn(ev_3, forwarded_history) + self.assertNotIn(ev_6, forwarded_history) + + self.assertIn((EV_KEY, KEY_A, 1), keyboard_history) + self.assertIn((EV_KEY, KEY_A, 0), keyboard_history) + + async def test_axis_switch(self): + """Test a mapping for an axis that can be switched on or off.""" + + rel_rate = 60 # rate [Hz] at which events are produced + gain = 0.5 # halve the speed of the rel axis + preset = Preset() + mouse = self.global_uinputs.get_uinput("mouse") + forward_history = self.forward_uinput.write_history + mouse_history = mouse.write_history + + # ABS_X to REL_Y if ABS_Y is above 10% + combination = InputCombination( + [ + # Apparently, setting the threshold to None declares this as a fully + # analog input. The ABS_X movement will probably be mapped to x mouse + # movement. + InputConfig(type=EV_ABS, code=ABS_X, analog_threshold=None), + InputConfig(type=EV_ABS, code=ABS_Y, analog_threshold=10), + ] + ) + + cfg = { + "input_combination": combination.to_config(), + "target_uinput": "mouse", + "output_type": EV_REL, + "output_code": REL_X, + "rel_rate": rel_rate, + "gain": gain, + "deadzone": 0, + } + m1 = Mapping(**cfg) + preset.add(m1) + + event_reader = self.create_event_reader(preset, fixtures.gamepad) + + max_abs = fixtures.gamepad.max_abs + + logger.info("set ABS_X input to 100%% (%s)", max_abs) + await event_reader.handle(InputEvent.abs(ABS_X, max_abs)) + + # wait a bit more for nothing to sum up, because ABS_Y is still 0. + await asyncio.sleep(0.2) + self.assertEqual( + # There is one forwarded event from maxing out abs_x + InputEvent.from_event(forward_history[0]), + (EV_ABS, ABS_X, max_abs), + ) + self.assertEqual(len(mouse_history), 0) + self.assertEqual(len(forward_history), 1) + + logger.info("slowly move ABS_Y above 10%% (> %s)", max_abs * 0.10) + await self.send_events( + ( + InputEvent.abs(ABS_Y, int(max_abs * 0.05)), + InputEvent.abs(ABS_Y, int(max_abs * 0.11)), + InputEvent.abs(ABS_Y, int(max_abs * 0.5)), + ), + event_reader, + ) + + # wait a bit more for it to sum up + sleep = 0.5 + await asyncio.sleep(sleep) + + self.assertAlmostEqual(len(mouse_history), rel_rate * sleep, delta=3) + self.assertEqual(len(forward_history), 1) + + logger.info("send some more x events") + await self.send_events( + ( + InputEvent.abs(ABS_X, max_abs), + InputEvent.abs(ABS_X, int(max_abs * 0.9)), + ), + event_reader, + ) + + # stop it + await event_reader.handle(InputEvent.abs(ABS_Y, int(max_abs * 0.05))) + + await asyncio.sleep(0.2) # wait a bit more for nothing to sum up + if mouse_history[0].type == EV_ABS: + raise AssertionError( + "The injector probably just forwarded them unchanged" + # possibly in addition to writing mouse events + ) + + # does not contain anything else + expected_rel_event = (EV_REL, REL_X, int(gain * REL_XY_SCALING)) + count_x = mouse_history.count(expected_rel_event) + self.assertEqual(len(mouse_history), count_x) + + self.assertAlmostEqual(len(mouse_history), rel_rate * sleep, delta=3) + + async def test_key_axis_combination_to_disable(self): + combination = InputCombination( + [ + InputConfig(type=EV_ABS, code=ABS_X), + InputConfig(type=EV_ABS, code=ABS_Y, analog_threshold=5), + ] + ) + preset = Preset() + forward_history = self.forward_uinput.write_history + + mapping = Mapping( + input_combination=combination, + output_symbol="disable", + target_uinput="keyboard", + ) + preset.add(mapping) + + event_reader = self.create_event_reader(preset, fixtures.gamepad) + + max_abs = fixtures.gamepad.max_abs + await self.send_events( + [ + InputEvent.from_tuple((EV_ABS, ABS_X, 10)), # forwarded + InputEvent.from_tuple((EV_ABS, ABS_Y, int(0.1 * max_abs))), + InputEvent.from_tuple((EV_ABS, ABS_X, 20)), # disabled + InputEvent.from_tuple((EV_ABS, ABS_Y, int(0.02 * max_abs))), + InputEvent.from_tuple((EV_ABS, ABS_X, 30)), # forwarded + ], + event_reader, + ) + self.assertEqual( + forward_history, + [ + InputEvent.from_tuple((EV_ABS, ABS_X, 10)), + InputEvent.from_tuple((EV_ABS, ABS_X, 30)), + ], + ) + + async def test_multiple_axis_in_hierarchy_handler(self): + preset = Preset() + + # Add two mappings that map EV_REL to EV_ABS. We want to test that they don't + # suppress each other when they are part of a hierarchy handler. So having at + # least two of them is important for this test. + cutoff = 2 + for in_, out in [(REL_X, ABS_X), (REL_Y, ABS_Y)]: + input_combination = InputCombination( + [ + InputConfig(type=EV_KEY, code=KEY_A), + InputConfig(type=EV_REL, code=in_), + ] + ) + mapping = Mapping( + input_combination=input_combination.to_config(), + target_uinput="gamepad", + output_type=EV_ABS, + output_code=out, + gain=0.5, + rel_to_abs_input_cutoff=cutoff, + release_timeout=0.1, + deadzone=0, + ) + preset.add(mapping) + + # Add a non-analog mapping, to make sure a HierarchyHandler exists + key_input = InputCombination( + ( + InputConfig(type=EV_KEY, code=KEY_A), + InputConfig(type=EV_KEY, code=KEY_B), + ) + ) + key_mapping = Mapping( + input_combination=key_input.to_config(), + target_uinput="keyboard", + output_type=EV_KEY, + output_code=KEY_C, + ) + preset.add(key_mapping) + + event_reader = self.create_event_reader(preset, fixtures.gamepad) + + # Trigger all of them at the same time + value = int(REL_XY_SCALING * cutoff) + await asyncio.sleep(0.1) + await self.send_events( + [ + InputEvent(0, 0, EV_KEY, KEY_A, 1), + InputEvent(0, 0, EV_REL, REL_X, value), + InputEvent(0, 0, EV_REL, REL_Y, value), + InputEvent(0, 0, EV_KEY, KEY_B, 1), + InputEvent(0, 0, EV_KEY, KEY_B, 0), + ], + event_reader, + ) + + await asyncio.sleep(0.4) + + # We expect all of it to be present. No mapping was suppressed. + # So we can trigger combinations that inject keys while a joystick is being + # simulated in multiple directions. + max_abs = fixtures.gamepad.max_abs + self.assertEqual( + uinput_write_history, + [ + InputEvent.from_tuple((EV_ABS, ABS_X, max_abs / 2)), + InputEvent.from_tuple((EV_ABS, ABS_Y, max_abs / 2)), + InputEvent.from_tuple((EV_KEY, KEY_C, 1)), + InputEvent.from_tuple((EV_KEY, KEY_C, 0)), + InputEvent.from_tuple((EV_ABS, ABS_X, 0)), + InputEvent.from_tuple((EV_ABS, ABS_Y, 0)), + ], + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/test_event_pipeline/test_mapping_handlers.py b/tests/unit/test_event_pipeline/test_mapping_handlers.py new file mode 100644 index 0000000..9661f3b --- /dev/null +++ b/tests/unit/test_event_pipeline/test_mapping_handlers.py @@ -0,0 +1,597 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + + +"""See TestEventPipeline for more tests.""" + +import asyncio +import unittest +from unittest.mock import MagicMock + +import evdev +from evdev.ecodes import ( + EV_KEY, + EV_ABS, + EV_REL, + ABS_X, + REL_X, + BTN_LEFT, + BTN_RIGHT, + KEY_A, + REL_Y, + REL_WHEEL, +) + +from inputremapper.configs.input_config import InputCombination, InputConfig +from inputremapper.configs.mapping import Mapping, DEFAULT_REL_RATE, KnownUinput +from inputremapper.injection.global_uinputs import GlobalUInputs, UInput +from inputremapper.injection.mapping_handlers.abs_to_abs_handler import AbsToAbsHandler +from inputremapper.injection.mapping_handlers.abs_to_btn_handler import AbsToBtnHandler +from inputremapper.injection.mapping_handlers.abs_to_rel_handler import AbsToRelHandler +from inputremapper.injection.mapping_handlers.axis_switch_handler import ( + AxisSwitchHandler, +) +from inputremapper.injection.mapping_handlers.combination_handler import ( + CombinationHandler, +) +from inputremapper.injection.mapping_handlers.hierarchy_handler import HierarchyHandler +from inputremapper.injection.mapping_handlers.key_handler import KeyHandler +from inputremapper.injection.mapping_handlers.macro_handler import MacroHandler +from inputremapper.injection.mapping_handlers.mapping_handler import ( + MappingHandler, +) +from inputremapper.injection.mapping_handlers.rel_to_abs_handler import RelToAbsHandler +from inputremapper.injection.mapping_handlers.rel_to_btn_handler import RelToBtnHandler +from inputremapper.injection.mapping_handlers.rel_to_rel_handler import RelToRelHandler +from inputremapper.input_event import InputEvent, EventActions +from tests.lib.cleanup import cleanup +from tests.lib.fixtures import fixtures +from tests.lib.patches import InputDevice +from tests.lib.test_setup import test_setup + + +class BaseTests: + """implements test that should pass on most mapping handlers + in special cases override specific tests. + """ + + handler: MappingHandler + + def setUp(self): + raise NotImplementedError + + def tearDown(self) -> None: + cleanup() + + def test_reset(self): + mock = MagicMock() + self.handler.set_sub_handler(mock) + self.handler.reset() + mock.reset.assert_called() + + +@test_setup +class TestAxisSwitchHandler(BaseTests, unittest.IsolatedAsyncioTestCase): + def setUp(self): + input_combination = InputCombination( + ( + InputConfig(type=2, code=5), + InputConfig(type=1, code=3), + ) + ) + self.global_uinputs = GlobalUInputs(UInput) + self.global_uinputs.prepare_all() + self.handler = AxisSwitchHandler( + input_combination, + Mapping( + input_combination=input_combination.to_config(), + target_uinput="mouse", + output_type=2, + output_code=1, + ), + MagicMock(), + self.global_uinputs, + ) + + +@test_setup +class TestAbsToBtnHandler(BaseTests, unittest.IsolatedAsyncioTestCase): + def setUp(self): + input_combination = InputCombination( + [InputConfig(type=3, code=5, analog_threshold=10)] + ) + self.global_uinputs = GlobalUInputs(UInput) + self.global_uinputs.prepare_all() + self.handler = AbsToBtnHandler( + input_combination, + Mapping( + input_combination=input_combination.to_config(), + target_uinput="mouse", + output_symbol="BTN_LEFT", + ), + global_uinputs=self.global_uinputs, + ) + + +@test_setup +class TestAbsToAbsHandler(BaseTests, unittest.IsolatedAsyncioTestCase): + def setUp(self): + input_combination = InputCombination([InputConfig(type=EV_ABS, code=ABS_X)]) + self.global_uinputs = GlobalUInputs(UInput) + self.global_uinputs.prepare_all() + self.handler = AbsToAbsHandler( + input_combination, + Mapping( + input_combination=input_combination.to_config(), + target_uinput="gamepad", + output_type=EV_ABS, + output_code=ABS_X, + ), + global_uinputs=self.global_uinputs, + ) + + async def test_reset(self): + self.handler.notify( + InputEvent(0, 0, EV_ABS, ABS_X, fixtures.foo_device_2_gamepad.max_abs), + source=InputDevice("/dev/input/event15"), + ) + self.handler.reset() + history = self.global_uinputs.get_uinput("gamepad").write_history + self.assertEqual( + history, + [ + InputEvent.from_tuple((3, 0, fixtures.foo_device_2_gamepad.max_abs)), + InputEvent.from_tuple((3, 0, 0)), + ], + ) + + +@test_setup +class TestRelToAbsHandler(BaseTests, unittest.IsolatedAsyncioTestCase): + def setUp(self): + input_combination = InputCombination([InputConfig(type=EV_REL, code=REL_X)]) + self.global_uinputs = GlobalUInputs(UInput) + self.global_uinputs.prepare_all() + self.handler = RelToAbsHandler( + input_combination, + Mapping( + input_combination=input_combination.to_config(), + target_uinput="gamepad", + output_type=EV_ABS, + output_code=ABS_X, + ), + self.global_uinputs, + ) + + async def test_reset(self): + self.handler.notify( + InputEvent(0, 0, EV_REL, REL_X, 123), + source=InputDevice("/dev/input/event15"), + ) + self.handler.reset() + history = self.global_uinputs.get_uinput("gamepad").write_history + self.assertEqual(len(history), 2) + + # something large, doesn't matter + self.assertGreater(history[0].value, fixtures.foo_device_2_gamepad.max_abs / 10) + + # 0, because of the reset + self.assertEqual(history[1].value, 0) + + async def test_rate_changes(self): + expected_rate = 100 + + # delta in usec + delta = 1000000 / expected_rate + + self.handler.notify( + InputEvent(0, delta, EV_REL, REL_X, 100), + source=InputDevice("/dev/input/event15"), + ) + + self.handler.notify( + InputEvent(0, delta * 2, EV_REL, REL_X, 100), + source=InputDevice("/dev/input/event15"), + ) + + self.assertEqual(self.handler._observed_rate, expected_rate) + + async def test_rate_stays(self): + # if two timestamps are equal, the rate stays at its previous value, + # in this case the default + + self.handler.notify( + InputEvent(0, 50, EV_REL, REL_X, 100), + source=InputDevice("/dev/input/event15"), + ) + + self.handler.notify( + InputEvent(0, 50, EV_REL, REL_X, 100), + source=InputDevice("/dev/input/event15"), + ) + + self.assertEqual(self.handler._observed_rate, DEFAULT_REL_RATE) + + +@test_setup +class TestAbsToRelHandler(BaseTests, unittest.IsolatedAsyncioTestCase): + def setUp(self): + input_combination = InputCombination([InputConfig(type=EV_ABS, code=ABS_X)]) + self.global_uinputs = GlobalUInputs(UInput) + self.global_uinputs.prepare_all() + self.handler = AbsToRelHandler( + input_combination, + Mapping( + input_combination=input_combination.to_config(), + target_uinput="mouse", + output_type=EV_REL, + output_code=REL_X, + ), + self.global_uinputs, + ) + + async def test_reset(self): + self.handler.notify( + InputEvent(0, 0, EV_ABS, ABS_X, fixtures.foo_device_2_gamepad.max_abs), + source=InputDevice("/dev/input/event15"), + ) + await asyncio.sleep(0.2) + self.handler.reset() + await asyncio.sleep(0.05) + + count = self.global_uinputs.get_uinput("mouse").write_count + self.assertGreater(count, 6) # count should be 60*0.2 = 12 + await asyncio.sleep(0.2) + self.assertEqual(count, self.global_uinputs.get_uinput("mouse").write_count) + + +@test_setup +class TestCombinationHandler(BaseTests, unittest.IsolatedAsyncioTestCase): + handler: CombinationHandler + + def setUp(self): + mouse = fixtures.foo_device_2_mouse + self.mouse_hash = mouse.get_device_hash() + + keyboard = fixtures.foo_device_2_keyboard + self.keyboard_hash = keyboard.get_device_hash() + + gamepad = fixtures.gamepad + self.gamepad_hash = gamepad.get_device_hash() + + input_combination = InputCombination( + ( + InputConfig( + type=EV_REL, + code=5, + analog_threshold=10, + origin_hash=self.mouse_hash, + ), + InputConfig( + type=EV_KEY, + code=3, + origin_hash=self.keyboard_hash, + ), + InputConfig( + type=EV_KEY, + code=4, + origin_hash=self.gamepad_hash, + ), + ) + ) + + self.input_combination = input_combination + + self.context_mock = MagicMock() + + self.global_uinputs = GlobalUInputs(UInput) + self.global_uinputs.prepare_all() + self.handler = CombinationHandler( + input_combination, + Mapping( + input_combination=input_combination.to_config(), + target_uinput="mouse", + output_symbol="BTN_LEFT", + release_combination_keys=True, + ), + self.context_mock, + global_uinputs=self.global_uinputs, + ) + + sub_handler_mock = MagicMock(MappingHandler) + self.handler.set_sub_handler(sub_handler_mock) + + # insert our own test-uinput to see what is being written to it + self.uinputs = { + self.mouse_hash: evdev.UInput(), + self.keyboard_hash: evdev.UInput(), + self.gamepad_hash: evdev.UInput(), + } + self.context_mock.get_forward_uinput = lambda origin_hash: self.uinputs[ + origin_hash + ] + + def test_forward_correctly(self): + # In the past, if a mapping has inputs from two different sub devices, it + # always failed to send the release events to the correct one. + # Nowadays, self._context.get_forward_uinput(origin_hash) is used to + # release them correctly. + # 1. trigger the combination + self.handler.notify( + InputEvent.rel( + code=self.input_combination[0].code, + value=1, + origin_hash=self.input_combination[0].origin_hash, + ), + source=fixtures.foo_device_2_mouse, + ) + self.handler.notify( + InputEvent.key( + code=self.input_combination[1].code, + value=1, + origin_hash=self.input_combination[1].origin_hash, + ), + source=fixtures.foo_device_2_keyboard, + ) + self.handler.notify( + InputEvent.key( + code=self.input_combination[2].code, + value=1, + origin_hash=self.input_combination[2].origin_hash, + ), + source=fixtures.gamepad, + ) + + # 2. expect release events to be written to the correct devices, as indicated + # by the origin_hash of the InputConfigs + self.assertListEqual( + self.uinputs[self.mouse_hash].write_history, + [InputEvent.rel(self.input_combination[0].code, 0)], + ) + self.assertListEqual( + self.uinputs[self.keyboard_hash].write_history, + [InputEvent.key(self.input_combination[1].code, 0)], + ) + + # We do not expect a release event for this, because there was no key-down + # event when the combination triggered. + # self.assertListEqual( + # self.uinputs[self.gamepad_hash].write_history, + # [InputEvent.key(self.input_combination[2].code, 0)], + # ) + + def test_no_forwards(self): + # if a combination is not triggered, nothing is released + # 1. inject any two events + self.handler.notify( + InputEvent.rel( + code=self.input_combination[0].code, + value=1, + origin_hash=self.input_combination[0].origin_hash, + ), + source=fixtures.foo_device_2_mouse, + ) + self.handler.notify( + InputEvent.key( + code=self.input_combination[1].code, + value=1, + origin_hash=self.input_combination[1].origin_hash, + ), + source=fixtures.foo_device_2_keyboard, + ) + + # 2. expect no release events to be written + self.assertListEqual(self.uinputs[self.mouse_hash].write_history, []) + self.assertListEqual(self.uinputs[self.keyboard_hash].write_history, []) + + +@test_setup +class TestHierarchyHandler(BaseTests, unittest.IsolatedAsyncioTestCase): + def setUp(self): + self.mock1 = MagicMock() + self.mock2 = MagicMock() + self.mock3 = MagicMock() + self.global_uinputs = GlobalUInputs(UInput) + self.global_uinputs.prepare_all() + self.handler = HierarchyHandler( + [self.mock1, self.mock2, self.mock3], + InputConfig(type=EV_KEY, code=KEY_A), + self.global_uinputs, + ) + + def test_reset(self): + self.handler.reset() + self.mock1.reset.assert_called() + self.mock2.reset.assert_called() + self.mock3.reset.assert_called() + + +@test_setup +class TestKeyHandler(BaseTests, unittest.IsolatedAsyncioTestCase): + def setUp(self): + input_combination = InputCombination( + ( + InputConfig(type=2, code=0, analog_threshold=10), + InputConfig(type=1, code=3), + ) + ) + self.global_uinputs = GlobalUInputs(UInput) + self.global_uinputs.prepare_all() + self.handler = KeyHandler( + input_combination, + Mapping( + input_combination=input_combination.to_config(), + target_uinput="mouse", + output_symbol="BTN_LEFT", + ), + self.global_uinputs, + ) + + def test_reset(self): + self.handler.notify( + InputEvent(0, 0, EV_REL, REL_X, 1, actions=(EventActions.as_key,)), + source=InputDevice("/dev/input/event11"), + ) + history = self.global_uinputs.get_uinput("mouse").write_history + self.assertEqual(history[0], InputEvent.key(BTN_LEFT, 1)) + self.assertEqual(len(history), 1) + + self.handler.reset() + history = self.global_uinputs.get_uinput("mouse").write_history + self.assertEqual(history[1], InputEvent.key(BTN_LEFT, 0)) + self.assertEqual(len(history), 2) + + +@test_setup +class TestMacroHandler(BaseTests, unittest.IsolatedAsyncioTestCase): + def setUp(self): + self.input_combination = InputCombination( + ( + InputConfig(type=2, code=0, analog_threshold=10), + InputConfig(type=1, code=3), + ) + ) + self.context_mock = MagicMock() + self.global_uinputs = GlobalUInputs(UInput) + self.global_uinputs.prepare_all() + self.set_handler(KnownUinput.KEYBOARD, "key(a)") + + def set_handler(self, target_uinput: KnownUinput, macro: str): + self.handler = MacroHandler( + self.input_combination, + Mapping( + input_combination=self.input_combination.to_config(), + target_uinput=target_uinput, + output_symbol=macro, + ), + context=self.context_mock, + global_uinputs=self.global_uinputs, + ) + + async def test_reset(self): + self.set_handler(KnownUinput.MOUSE, "hold_keys(BTN_LEFT, BTN_RIGHT)") + + self.handler.notify( + InputEvent(0, 0, EV_REL, REL_X, 1, actions=(EventActions.as_key,)), + source=InputDevice("/dev/input/event11"), + ) + + await asyncio.sleep(0.1) + history = self.global_uinputs.get_uinput(KnownUinput.MOUSE).write_history + self.assertIn(InputEvent.key(BTN_LEFT, 1), history) + self.assertIn(InputEvent.key(BTN_RIGHT, 1), history) + self.assertEqual(len(history), 2) + + self.handler.reset() + await asyncio.sleep(0.1) + history = self.global_uinputs.get_uinput(KnownUinput.MOUSE).write_history + self.assertIn(InputEvent.key(BTN_LEFT, 0), history[-2:]) + self.assertIn(InputEvent.key(BTN_RIGHT, 0), history[-2:]) + self.assertEqual(len(history), 4) + + async def test_reset_output(self): + self.set_handler(KnownUinput.KEYBOARD, "key_down(a)") + + history = self.global_uinputs.get_uinput(KnownUinput.KEYBOARD).write_history + + self.handler.reset() + await asyncio.sleep(0.1) + self.assertEqual(len(history), 0) + + self.handler.notify( + InputEvent(0, 0, EV_REL, REL_X, 1, actions=(EventActions.as_key,)), + source=InputDevice("/dev/input/event11"), + ) + await asyncio.sleep(0.1) + self.assertEqual(len(history), 1) + self.assertIn(InputEvent.key(KEY_A, 1), history) + + self.handler.reset() + await asyncio.sleep(0.1) + self.assertEqual(len(history), 2) + self.assertIn(InputEvent.key(KEY_A, 0), history) + + +@test_setup +class TestRelToBtnHandler(BaseTests, unittest.IsolatedAsyncioTestCase): + def setUp(self): + input_combination = InputCombination( + [InputConfig(type=2, code=0, analog_threshold=10)] + ) + self.global_uinputs = GlobalUInputs(UInput) + self.global_uinputs.prepare_all() + self.handler = RelToBtnHandler( + input_combination, + Mapping( + input_combination=input_combination.to_config(), + target_uinput="mouse", + output_symbol="BTN_LEFT", + ), + self.global_uinputs, + ) + + +@test_setup +class TestRelToRelHanlder(BaseTests, unittest.IsolatedAsyncioTestCase): + handler: RelToRelHandler + + def setUp(self): + input_combination = InputCombination([InputConfig(type=EV_REL, code=REL_X)]) + self.global_uinputs = GlobalUInputs(UInput) + self.global_uinputs.prepare_all() + self.handler = RelToRelHandler( + input_combination, + Mapping( + input_combination=input_combination.to_config(), + output_type=EV_REL, + output_code=REL_Y, + output_value=20, + target_uinput="mouse", + ), + self.global_uinputs, + ) + + def test_should_map(self): + self.assertTrue( + self.handler._should_map( + InputEvent( + 0, + 0, + EV_REL, + REL_X, + 0, + ) + ) + ) + self.assertFalse( + self.handler._should_map( + InputEvent( + 0, + 0, + EV_REL, + REL_WHEEL, + 1, + ) + ) + ) + + def test_reset(self): + # nothing special has to happen here + pass diff --git a/tests/unit/test_event_pipeline/test_rel_to_abs.py b/tests/unit/test_event_pipeline/test_rel_to_abs.py new file mode 100644 index 0000000..cffe0af --- /dev/null +++ b/tests/unit/test_event_pipeline/test_rel_to_abs.py @@ -0,0 +1,239 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + +import asyncio +import unittest + +from evdev.ecodes import ( + EV_KEY, + EV_ABS, + EV_REL, + ABS_X, + ABS_Y, + REL_X, + REL_Y, + KEY_A, +) + +from inputremapper.configs.mapping import ( + Mapping, + REL_XY_SCALING, + DEFAULT_REL_RATE, +) +from inputremapper.configs.preset import Preset +from inputremapper.configs.input_config import InputCombination, InputConfig +from inputremapper.input_event import InputEvent +from tests.lib.fixtures import fixtures +from tests.lib.test_setup import test_setup +from tests.unit.test_event_pipeline.event_pipeline_test_base import ( + EventPipelineTestBase, +) + + +@test_setup +class TestRelToAbs(EventPipelineTestBase): + def setUp(self): + self.timestamp = 0 + super().setUp() + + def next_usec_time(self): + self.timestamp += 1000000 / DEFAULT_REL_RATE + return self.timestamp + + async def test_rel_to_abs(self): + # first mapping + # left mouse x to abs x + gain = 0.5 + cutoff = 2 + input_combination = InputCombination([InputConfig(type=EV_REL, code=REL_X)]) + mapping_config = { + "input_combination": input_combination.to_config(), + "target_uinput": "gamepad", + "output_type": EV_ABS, + "output_code": ABS_X, + "gain": gain, + "rel_to_abs_input_cutoff": cutoff, + "release_timeout": 0.5, + "deadzone": 0, + } + mapping_1 = Mapping(**mapping_config) + preset = Preset() + preset.add(mapping_1) + + # second mapping + input_combination = InputCombination([InputConfig(type=EV_REL, code=REL_Y)]) + mapping_config["input_combination"] = input_combination.to_config() + mapping_config["output_code"] = ABS_Y + mapping_2 = Mapping(**mapping_config) + preset.add(mapping_2) + + event_reader = self.create_event_reader(preset, fixtures.gamepad) + + next_time = self.next_usec_time() + await self.send_events( + [ + InputEvent(0, next_time, EV_REL, REL_X, -int(REL_XY_SCALING * cutoff)), + InputEvent(0, next_time, EV_REL, REL_Y, int(REL_XY_SCALING * cutoff)), + ], + event_reader, + ) + + await asyncio.sleep(0.1) + + history = self.global_uinputs.get_uinput("gamepad").write_history + self.assertEqual( + history, + [ + InputEvent.from_tuple((3, 0, fixtures.gamepad.min_abs / 2)), + InputEvent.from_tuple((3, 1, fixtures.gamepad.max_abs / 2)), + ], + ) + + # send more events, then wait until the release timeout + next_time = self.next_usec_time() + await self.send_events( + [ + InputEvent(0, next_time, EV_REL, REL_X, -int(REL_XY_SCALING)), + InputEvent(0, next_time, EV_REL, REL_Y, int(REL_XY_SCALING)), + ], + event_reader, + ) + await asyncio.sleep(0.7) + history = self.global_uinputs.get_uinput("gamepad").write_history + self.assertEqual( + history, + [ + InputEvent.from_tuple((3, 0, fixtures.gamepad.min_abs / 2)), + InputEvent.from_tuple((3, 1, fixtures.gamepad.max_abs / 2)), + InputEvent.from_tuple((3, 0, fixtures.gamepad.min_abs / 4)), + InputEvent.from_tuple((3, 1, fixtures.gamepad.max_abs / 4)), + InputEvent.from_tuple((3, 0, 0)), + InputEvent.from_tuple((3, 1, 0)), + ], + ) + + async def test_rel_to_abs_reset_multiple(self): + # Recenters correctly when triggering the mapping a second time. + # Could only be reproduced if a key input is part of the combination, that is + # released and pressed again. + + # left mouse x to abs x + gain = 0.5 + cutoff = 2 + input_combination = InputCombination( + [ + InputConfig(type=EV_KEY, code=KEY_A), + InputConfig(type=EV_REL, code=REL_X), + ] + ) + mapping_config = { + "input_combination": input_combination.to_config(), + "target_uinput": "gamepad", + "output_type": EV_ABS, + "output_code": ABS_X, + "gain": gain, + "rel_to_abs_input_cutoff": cutoff, + "release_timeout": 0.1, + "deadzone": 0, + } + mapping_1 = Mapping(**mapping_config) + preset = Preset() + preset.add(mapping_1) + + event_reader = self.create_event_reader(preset, fixtures.gamepad) + + for _ in range(3): + next_time = self.next_usec_time() + value = int(REL_XY_SCALING * cutoff) + await event_reader.handle(InputEvent(0, next_time, EV_KEY, KEY_A, 1)) + await event_reader.handle(InputEvent(0, next_time, EV_REL, REL_X, value)) + await asyncio.sleep(0.2) + + history = self.global_uinputs.get_uinput("gamepad").write_history + self.assertIn( + InputEvent.from_tuple((3, 0, 0)), + history, + ) + + await event_reader.handle(InputEvent(0, next_time, EV_KEY, KEY_A, 0)) + await asyncio.sleep(0.05) + + self.global_uinputs.get_uinput("gamepad").write_history = [] + + async def test_rel_to_abs_with_input_switch(self): + # use 0 everywhere, because that will cause the handler to not update the rate, + # and we are able to test things without worrying about that at all + timestamp = 0 + + gain = 0.5 + cutoff = 1 + input_combination = InputCombination( + ( + InputConfig(type=EV_REL, code=REL_X), + InputConfig(type=EV_REL, code=REL_Y, analog_threshold=10), + ) + ) + # left mouse x to x + mapping_config = { + "input_combination": input_combination.to_config(), + "target_uinput": "gamepad", + "output_type": EV_ABS, + "output_code": ABS_X, + "gain": gain, + "rel_to_abs_input_cutoff": cutoff, + "deadzone": 0, + } + mapping_1 = Mapping(**mapping_config) + preset = Preset() + preset.add(mapping_1) + + event_reader = self.create_event_reader(preset, fixtures.gamepad) + + # if the cutoff is higher, the test sends higher values to overcome the cutoff + await self.send_events( + [ + # will not map + InputEvent(0, timestamp, EV_REL, REL_X, -REL_XY_SCALING / 4 * cutoff), + # switch axis on + InputEvent(0, timestamp, EV_REL, REL_Y, REL_XY_SCALING / 5 * cutoff), + # normally mapped + InputEvent(0, timestamp, EV_REL, REL_X, REL_XY_SCALING * cutoff), + # off, re-centers axis + InputEvent(0, timestamp, EV_REL, REL_Y, REL_XY_SCALING / 20 * cutoff), + # will not map + InputEvent(0, timestamp, EV_REL, REL_X, REL_XY_SCALING / 2 * cutoff), + ], + event_reader, + ) + + await asyncio.sleep(0.2) + + history = self.global_uinputs.get_uinput("gamepad").write_history + self.assertEqual( + history, + [ + InputEvent.from_tuple((3, 0, fixtures.gamepad.max_abs / 2)), + InputEvent.from_tuple((3, 0, 0)), + ], + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/test_event_pipeline/test_rel_to_btn.py b/tests/unit/test_event_pipeline/test_rel_to_btn.py new file mode 100644 index 0000000..0d7aa85 --- /dev/null +++ b/tests/unit/test_event_pipeline/test_rel_to_btn.py @@ -0,0 +1,180 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + +import asyncio +import unittest + +from evdev.ecodes import ( + EV_KEY, + EV_REL, + REL_X, + REL_HWHEEL, + REL_WHEEL, +) + +from inputremapper.configs.mapping import ( + Mapping, +) +from inputremapper.configs.preset import Preset +from inputremapper.configs.keyboard_layout import keyboard_layout +from inputremapper.configs.input_config import InputCombination, InputConfig +from inputremapper.input_event import InputEvent +from tests.lib.fixtures import fixtures +from tests.lib.test_setup import test_setup +from tests.unit.test_event_pipeline.event_pipeline_test_base import ( + EventPipelineTestBase, +) + + +@test_setup +class TestRelToBtn(EventPipelineTestBase): + async def test_rel_to_btn(self): + """Rel axis mapped to buttons are automatically released if no new rel event arrives.""" + + # map those two to stuff + w_up = (EV_REL, REL_WHEEL, -1) + hw_right = (EV_REL, REL_HWHEEL, 1) + + # should be forwarded and present in the capabilities + hw_left = (EV_REL, REL_HWHEEL, -1) + + keyboard_layout.clear() + code_b = 91 + code_c = 92 + keyboard_layout._set("b", code_b) + keyboard_layout._set("c", code_c) + + # set a high release timeout to make sure the tests pass + release_timeout = 0.2 + mapping_1 = Mapping.from_combination( + InputCombination(InputCombination.from_tuples(hw_right)), "keyboard", "k(b)" + ) + mapping_2 = Mapping.from_combination( + InputCombination(InputCombination.from_tuples(w_up)), "keyboard", "c" + ) + mapping_1.release_timeout = release_timeout + mapping_2.release_timeout = release_timeout + + preset = Preset() + preset.add(mapping_1) + preset.add(mapping_2) + + event_reader = self.create_event_reader(preset, fixtures.foo_device_2_mouse) + + await self.send_events( + [InputEvent.from_tuple(hw_right), InputEvent.from_tuple(w_up)] * 5, + event_reader, + ) + # wait less than the release timeout and send more events + await asyncio.sleep(release_timeout / 5) + await self.send_events( + [InputEvent.from_tuple(hw_right), InputEvent.from_tuple(w_up)] * 5 + + [InputEvent.from_tuple(hw_left)] + * 3, # one event will release hw_right, the others are forwarded + event_reader, + ) + # wait more than the release_timeout to make sure all handlers finish + await asyncio.sleep(release_timeout * 1.2) + + keyboard_history = self.global_uinputs.get_uinput("keyboard").write_history + forwarded_history = self.forward_uinput.write_history + self.assertEqual(keyboard_history.count((EV_KEY, code_b, 1)), 1) + self.assertEqual(keyboard_history.count((EV_KEY, code_c, 1)), 1) + self.assertEqual(keyboard_history.count((EV_KEY, code_b, 0)), 1) + self.assertEqual(keyboard_history.count((EV_KEY, code_c, 0)), 1) + # the unmapped wheel direction + self.assertEqual(forwarded_history.count(hw_left), 2) + + # the unmapped wheel won't get a debounced release command, it's + # forwarded as is + self.assertNotIn((EV_REL, REL_HWHEEL, 0), forwarded_history) + + async def test_rel_trigger_threshold(self): + """Test that different activation points for rel_to_btn work correctly.""" + + # at 5 map to a + mapping_1 = Mapping.from_combination( + InputCombination( + [InputConfig(type=EV_REL, code=REL_X, analog_threshold=5)] + ), + output_symbol="a", + ) + # at 15 map to b + mapping_2 = Mapping.from_combination( + InputCombination( + [InputConfig(type=EV_REL, code=REL_X, analog_threshold=15)] + ), + output_symbol="b", + ) + release_timeout = 0.2 # give some time to do assertions before the release + mapping_1.release_timeout = release_timeout + mapping_2.release_timeout = release_timeout + preset = Preset() + preset.add(mapping_1) + preset.add(mapping_2) + + a = keyboard_layout.get("a") + b = keyboard_layout.get("b") + + event_reader = self.create_event_reader(preset, fixtures.foo_device_2_mouse) + + await self.send_events( + [ + InputEvent.rel(REL_X, -5), # forward + InputEvent.rel(REL_X, 0), # forward + InputEvent.rel(REL_X, 3), # forward + InputEvent.rel(REL_X, 10), # trigger a + ], + event_reader, + ) + await asyncio.sleep(release_timeout * 1.5) # release a + keyboard_history = self.global_uinputs.get_uinput("keyboard").write_history + + self.assertEqual(keyboard_history, [(EV_KEY, a, 1), (EV_KEY, a, 0)]) + self.assertEqual(keyboard_history.count((EV_KEY, a, 1)), 1) + self.assertEqual(keyboard_history.count((EV_KEY, a, 0)), 1) + self.assertNotIn((EV_KEY, b, 1), keyboard_history) + + await self.send_events( + [ + InputEvent.rel(REL_X, 10), # trigger a + InputEvent.rel(REL_X, 20), # trigger b + InputEvent.rel(REL_X, 10), # release b + ], + event_reader, + ) + keyboard_history = self.global_uinputs.get_uinput("keyboard").write_history + self.assertEqual(keyboard_history.count((EV_KEY, a, 1)), 2) + self.assertEqual(keyboard_history.count((EV_KEY, b, 1)), 1) + self.assertEqual(keyboard_history.count((EV_KEY, b, 0)), 1) + self.assertEqual(keyboard_history.count((EV_KEY, a, 0)), 1) + + await asyncio.sleep(release_timeout * 1.5) # release a + keyboard_history = self.global_uinputs.get_uinput("keyboard").write_history + forwarded_history = self.forward_uinput.write_history + self.assertEqual(keyboard_history.count((EV_KEY, a, 0)), 2) + self.assertEqual( + forwarded_history, + [(EV_REL, REL_X, -5), (EV_REL, REL_X, 0), (EV_REL, REL_X, 3)], + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/test_event_pipeline/test_rel_to_rel.py b/tests/unit/test_event_pipeline/test_rel_to_rel.py new file mode 100644 index 0000000..18cfb40 --- /dev/null +++ b/tests/unit/test_event_pipeline/test_rel_to_rel.py @@ -0,0 +1,219 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + +import unittest + +from evdev.ecodes import ( + EV_REL, + REL_X, + REL_Y, + REL_HWHEEL, + REL_WHEEL, + REL_WHEEL_HI_RES, + REL_HWHEEL_HI_RES, +) + +from inputremapper.configs.mapping import ( + Mapping, + REL_XY_SCALING, + WHEEL_SCALING, + WHEEL_HI_RES_SCALING, +) +from inputremapper.configs.preset import Preset +from inputremapper.configs.input_config import InputCombination, InputConfig +from inputremapper.input_event import InputEvent +from tests.lib.fixtures import fixtures +from tests.lib.test_setup import test_setup +from tests.unit.test_event_pipeline.event_pipeline_test_base import ( + EventPipelineTestBase, +) + + +@test_setup +class TestRelToRel(EventPipelineTestBase): + async def _test(self, input_code, input_value, output_code, output_value, gain=1): + preset = Preset() + + input_config = InputConfig(type=EV_REL, code=input_code) + mapping = Mapping( + input_combination=InputCombination([input_config]).to_config(), + target_uinput="mouse", + output_type=EV_REL, + output_code=output_code, + deadzone=0, + gain=gain, + ) + preset.add(mapping) + + event_reader = self.create_event_reader(preset, fixtures.gamepad) + + await self.send_events( + [InputEvent(0, 0, EV_REL, input_code, input_value)], + event_reader, + ) + + history = self.global_uinputs.get_uinput("mouse").write_history + + self.assertEqual(len(history), 1) + self.assertEqual( + history[0], + InputEvent(0, 0, EV_REL, output_code, output_value), + ) + + async def test_wheel_to_y(self): + await self._test( + input_code=REL_WHEEL, + input_value=2 * WHEEL_SCALING, + output_code=REL_Y, + output_value=2 * REL_XY_SCALING, + ) + + async def test_hi_res_wheel_to_y(self): + await self._test( + input_code=REL_WHEEL_HI_RES, + input_value=3 * WHEEL_HI_RES_SCALING, + output_code=REL_Y, + output_value=3 * REL_XY_SCALING, + ) + + async def test_x_to_hwheel(self): + # injects both hi_res and regular wheel events at the same time + + input_code = REL_X + input_value = 100 + output_code = REL_HWHEEL + gain = 2 + + output_value = int(input_value / REL_XY_SCALING * WHEEL_SCALING * gain) + output_value_hi_res = int( + input_value / REL_XY_SCALING * WHEEL_HI_RES_SCALING * gain + ) + + preset = Preset() + + input_config = InputConfig(type=EV_REL, code=input_code) + mapping = Mapping( + input_combination=InputCombination([input_config]).to_config(), + target_uinput="mouse", + output_type=EV_REL, + output_code=output_code, + deadzone=0, + gain=gain, + ) + preset.add(mapping) + + event_reader = self.create_event_reader(preset, fixtures.gamepad) + + await self.send_events( + [InputEvent(0, 0, EV_REL, input_code, input_value)], + event_reader, + ) + + history = self.global_uinputs.get_uinput("mouse").write_history + # injects both REL_WHEEL and REL_WHEEL_HI_RES events + self.assertEqual(len(history), 2) + self.assertEqual( + history[0], + InputEvent( + 0, + 0, + EV_REL, + REL_HWHEEL, + output_value, + ), + ) + + self.assertEqual( + history[1], + InputEvent( + 0, + 0, + EV_REL, + REL_HWHEEL_HI_RES, + output_value_hi_res, + ), + ) + + async def test_remainder(self): + preset = Preset() + history = self.global_uinputs.get_uinput("mouse").write_history + + # REL_WHEEL_HI_RES to REL_Y + input_config = InputConfig(type=EV_REL, code=REL_WHEEL_HI_RES) + gain = 0.01 + mapping = Mapping( + input_combination=InputCombination([input_config]).to_config(), + target_uinput="mouse", + output_type=EV_REL, + output_code=REL_Y, + deadzone=0, + gain=gain, + ) + preset.add(mapping) + + event_reader = self.create_event_reader(preset, fixtures.gamepad) + + events_until_one_rel_y_written = int( + WHEEL_HI_RES_SCALING / REL_XY_SCALING / gain + ) + # due to the low gain and low input value, it needs to be sent many times + # until one REL_Y event is written + await self.send_events( + [InputEvent(0, 0, EV_REL, REL_WHEEL_HI_RES, 1)] + * (events_until_one_rel_y_written - 1), + event_reader, + ) + self.assertEqual(len(history), 0) + + # write the final event that causes the input to accumulate to 1 + # plus one extra event because of floating-point math + await self.send_events( + [InputEvent(0, 0, EV_REL, REL_WHEEL_HI_RES, 1)], + event_reader, + ) + self.assertEqual(len(history), 1) + self.assertEqual( + history[0], + InputEvent(0, 0, EV_REL, REL_Y, 1), + ) + + # repeat it one more time to see if the remainder is reset correctly + await self.send_events( + [InputEvent(0, 0, EV_REL, REL_WHEEL_HI_RES, 1)] + * (events_until_one_rel_y_written - 1), + event_reader, + ) + self.assertEqual(len(history), 1) + + # the event that causes the second REL_Y to be written + # this should never need the one extra if the remainder is reset correctly + await self.send_events( + [InputEvent(0, 0, EV_REL, REL_WHEEL_HI_RES, 1)], + event_reader, + ) + self.assertEqual(len(history), 2) + self.assertEqual( + history[1], + InputEvent(0, 0, EV_REL, REL_Y, 1), + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/test_event_reader.py b/tests/unit/test_event_reader.py new file mode 100644 index 0000000..16c575c --- /dev/null +++ b/tests/unit/test_event_reader.py @@ -0,0 +1,300 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + +import asyncio +import os +import unittest +from unittest.mock import MagicMock, patch + +import evdev +from evdev.ecodes import ( + EV_KEY, + EV_ABS, + BTN_A, + ABS_X, + ABS_Y, + ABS_RX, + ABS_RY, + EV_REL, + REL_X, + REL_Y, + REL_HWHEEL_HI_RES, + REL_WHEEL_HI_RES, +) + +from inputremapper.configs.input_config import InputCombination, InputConfig +from inputremapper.configs.keyboard_layout import keyboard_layout +from inputremapper.configs.mapping import Mapping +from inputremapper.configs.preset import Preset +from inputremapper.injection.context import Context +from inputremapper.injection.event_reader import EventReader +from inputremapper.injection.global_uinputs import GlobalUInputs, UInput +from inputremapper.injection.mapping_handlers.mapping_parser import MappingParser +from inputremapper.input_event import InputEvent +from inputremapper.utils import get_device_hash +from tests.lib.fixtures import fixtures +from tests.lib.test_setup import test_setup + + +@test_setup +class TestEventReader(unittest.IsolatedAsyncioTestCase): + def setUp(self): + self.gamepad_source = evdev.InputDevice(fixtures.gamepad.path) + self.source_hash = fixtures.gamepad.get_device_hash() + self.global_uinputs = GlobalUInputs(UInput) + self.mapping_parser = MappingParser(self.global_uinputs) + self.stop_event = asyncio.Event() + self.preset = Preset() + self.forward_uinput = evdev.UInput(name="test-forward-uinput") + + self.global_uinputs.is_service = True + self.global_uinputs.prepare_all() + + async def setup(self, mapping): + """Set a EventReader up for the test and run it in the background.""" + context = Context( + mapping, + {}, + {self.source_hash: self.forward_uinput}, + self.mapping_parser, + ) + context.uinput = evdev.UInput() + event_reader = EventReader( + context, + self.gamepad_source, + self.stop_event, + ) + asyncio.ensure_future(event_reader.run()) + await asyncio.sleep(0.1) + return context, event_reader + + async def test_if_single_joystick_then(self): + # TODO: Move this somewhere more sensible + # Integration test style for if_single. + # won't care about the event, because the purpose is not set to BUTTON + code_a = keyboard_layout.get("a") + code_shift = keyboard_layout.get("KEY_LEFTSHIFT") + trigger = evdev.ecodes.BTN_A + + self.preset.add( + Mapping.from_combination( + InputCombination( + [ + InputConfig( + type=EV_KEY, + code=trigger, + origin_hash=fixtures.gamepad.get_device_hash(), + ) + ] + ), + "keyboard", + "if_single(key(a), key(KEY_LEFTSHIFT))", + ) + ) + self.preset.add( + Mapping.from_combination( + InputCombination( + [ + InputConfig( + type=EV_ABS, + code=ABS_Y, + analog_threshold=1, + origin_hash=fixtures.gamepad.get_device_hash(), + ) + ] + ), + "keyboard", + "b", + ), + ) + + # left x to mouse x + config = { + "input_combination": [ + InputConfig( + type=EV_ABS, + code=ABS_X, + origin_hash=fixtures.gamepad.get_device_hash(), + ) + ], + "target_uinput": "mouse", + "output_type": EV_REL, + "output_code": REL_X, + } + self.preset.add(Mapping(**config)) + + # left y to mouse y + config["input_combination"] = [ + InputConfig( + type=EV_ABS, + code=ABS_Y, + origin_hash=fixtures.gamepad.get_device_hash(), + ) + ] + config["output_code"] = REL_Y + self.preset.add(Mapping(**config)) + + # right x to wheel x + config["input_combination"] = [ + InputConfig( + type=EV_ABS, + code=ABS_RX, + origin_hash=fixtures.gamepad.get_device_hash(), + ) + ] + config["output_code"] = REL_HWHEEL_HI_RES + self.preset.add(Mapping(**config)) + + # right y to wheel y + config["input_combination"] = [ + InputConfig( + type=EV_ABS, + code=ABS_RY, + origin_hash=fixtures.gamepad.get_device_hash(), + ) + ] + config["output_code"] = REL_WHEEL_HI_RES + self.preset.add(Mapping(**config)) + + context, _ = await self.setup(self.preset) + + gamepad_hash = get_device_hash(self.gamepad_source) + self.gamepad_source.push_events( + [ + InputEvent.key(evdev.ecodes.BTN_Y, 0, gamepad_hash), # start the macro + InputEvent.key(trigger, 1, gamepad_hash), # start the macro + InputEvent.abs(ABS_Y, 10, gamepad_hash), # ignored + InputEvent.key(evdev.ecodes.BTN_B, 2, gamepad_hash), # ignored + InputEvent.key(evdev.ecodes.BTN_B, 0, gamepad_hash), # ignored + # release the trigger, which runs `then` of if_single + InputEvent.key(trigger, 0, gamepad_hash), + ] + ) + + await asyncio.sleep(0.1) + self.stop_event.set() # stop the reader + + history = self.global_uinputs.get_uinput("keyboard").write_history + self.assertIn((EV_KEY, code_a, 1), history) + self.assertIn((EV_KEY, code_a, 0), history) + self.assertNotIn((EV_KEY, code_shift, 1), history) + self.assertNotIn((EV_KEY, code_shift, 0), history) + + # after if_single takes an action, the listener should have been removed + self.assertSetEqual(context.listeners, set()) + + async def test_if_single_joystick_under_threshold(self): + """Triggers then because the joystick events value is too low.""" + # TODO: Move this somewhere more sensible + code_a = keyboard_layout.get("a") + trigger = evdev.ecodes.BTN_A + self.preset.add( + Mapping.from_combination( + InputCombination( + [ + InputConfig( + type=EV_KEY, + code=trigger, + origin_hash=fixtures.gamepad.get_device_hash(), + ) + ] + ), + "keyboard", + "if_single(k(a), k(KEY_LEFTSHIFT))", + ) + ) + self.preset.add( + Mapping.from_combination( + InputCombination( + [ + InputConfig( + type=EV_ABS, + code=ABS_Y, + analog_threshold=1, + origin_hash=fixtures.gamepad.get_device_hash(), + ) + ] + ), + "keyboard", + "b", + ), + ) + + # self.preset.set("gamepad.joystick.left_purpose", BUTTONS) + # self.preset.set("gamepad.joystick.right_purpose", BUTTONS) + context, _ = await self.setup(self.preset) + + self.gamepad_source.push_events( + [ + InputEvent.key(trigger, 1), # start the macro + InputEvent.abs(ABS_Y, 1), # ignored because value too low + InputEvent.key(trigger, 0), # stop, only way to trigger `then` + ] + ) + await asyncio.sleep(0.1) + self.assertEqual(len(context.listeners), 0) + history = self.global_uinputs.get_uinput("keyboard").write_history + + # the key that triggered if_single should be injected after + # if_single had a chance to inject keys (if the macro is fast enough), + # so that if_single can inject a modifier to e.g. capitalize the + # triggering key. This is important for the space cadet shift + self.assertListEqual( + history, + [ + (EV_KEY, code_a, 1), + (EV_KEY, code_a, 0), + ], + ) + + @patch.object(Context, "reset") + async def test_reset_handlers_on_stop(self, reset_mock: MagicMock) -> None: + await self.setup(self.preset) + + self.stop_event.set() + await asyncio.sleep(0.1) + + reset_mock.assert_called_once() + + @patch.object(Context, "reset") + @patch.object(os, "stat") + async def test_reset_handlers_after_unplugging( + self, + stat_mock: MagicMock, + reset_mock: MagicMock, + ) -> None: + await self.setup(self.preset) + + self.gamepad_source.push_events([InputEvent.key(BTN_A, 1)]) + await asyncio.sleep(0.1) + reset_mock.assert_not_called() + + # unplug the device + stat_mock().st_nlink = 0 + + # It seems that once a device is unplugged, asyncio stops waiting for new input + # from _source.fileno() or something. I didn't manage to replicate this + # behavior in tests using the fd of pending_events[fixtures.gamepad][0].fileno() + # and/or pending_events[fixtures.gamepad][1].fileno(). So instead, I push + # another event, so that the EventReader makes another iteration. + self.gamepad_source.push_events([InputEvent.key(BTN_A, 1)]) + await asyncio.sleep(0.1) + + reset_mock.assert_called_once() diff --git a/tests/unit/test_global_config.py b/tests/unit/test_global_config.py new file mode 100644 index 0000000..719c185 --- /dev/null +++ b/tests/unit/test_global_config.py @@ -0,0 +1,106 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + + +import os +import unittest + +from inputremapper.configs.global_config import GlobalConfig +from tests.lib.test_setup import test_setup + + +@test_setup +class TestGlobalConfig(unittest.TestCase): + def test_autoload(self): + global_config = GlobalConfig() + self.assertEqual(len(global_config.iterate_autoload_presets()), 0) + self.assertFalse(global_config.is_autoloaded("d1", "a")) + self.assertFalse(global_config.is_autoloaded("d2", "b")) + self.assertEqual(global_config.get_autoload_preset("d1"), None) + self.assertEqual(global_config.get_autoload_preset("d2"), None) + + global_config.set_autoload_preset("d1", "a") + self.assertEqual(len(global_config.iterate_autoload_presets()), 1) + self.assertTrue(global_config.is_autoloaded("d1", "a")) + self.assertFalse(global_config.is_autoloaded("d2", "b")) + + global_config.set_autoload_preset("d2", "b") + self.assertEqual(len(global_config.iterate_autoload_presets()), 2) + self.assertTrue(global_config.is_autoloaded("d1", "a")) + self.assertTrue(global_config.is_autoloaded("d2", "b")) + self.assertEqual(global_config.get_autoload_preset("d1"), "a") + self.assertEqual(global_config.get_autoload_preset("d2"), "b") + + global_config.set_autoload_preset("d2", "c") + self.assertEqual(len(global_config.iterate_autoload_presets()), 2) + self.assertTrue(global_config.is_autoloaded("d1", "a")) + self.assertFalse(global_config.is_autoloaded("d2", "b")) + self.assertTrue(global_config.is_autoloaded("d2", "c")) + self.assertEqual(global_config._config["autoload"]["d2"], "c") + self.assertListEqual( + list(global_config.iterate_autoload_presets()), + [("d1", "a"), ("d2", "c")], + ) + + global_config.set_autoload_preset("d2", None) + self.assertTrue(global_config.is_autoloaded("d1", "a")) + self.assertFalse(global_config.is_autoloaded("d2", "b")) + self.assertFalse(global_config.is_autoloaded("d2", "c")) + self.assertListEqual( + list(global_config.iterate_autoload_presets()), + [("d1", "a")], + ) + self.assertEqual(global_config.get_autoload_preset("d1"), "a") + + self.assertRaises(ValueError, global_config.is_autoloaded, "d1", None) + self.assertRaises(ValueError, global_config.is_autoloaded, None, "a") + + def test_initial(self): + global_config = GlobalConfig() + # when loading for the first time, create a config file with + # the default values + self.assertFalse(os.path.exists(global_config.path)) + global_config.load_config() + self.assertTrue(os.path.exists(global_config.path)) + + with open(global_config.path, "r") as file: + contents = file.read() + self.assertIn('"autoload": {}', contents) + + def test_save_load(self): + global_config = GlobalConfig() + self.assertEqual(len(global_config.iterate_autoload_presets()), 0) + + global_config.load_config() + self.assertEqual(len(global_config.iterate_autoload_presets()), 0) + + global_config.set_autoload_preset("d1", "a") + global_config.set_autoload_preset("d2", "b") + + global_config.load_config() + + self.assertListEqual( + list(global_config.iterate_autoload_presets()), + [("d1", "a"), ("d2", "b")], + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/test_global_uinputs.py b/tests/unit/test_global_uinputs.py new file mode 100644 index 0000000..8ecd800 --- /dev/null +++ b/tests/unit/test_global_uinputs.py @@ -0,0 +1,100 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + +import unittest + +import evdev +from evdev.ecodes import ( + KEY_A, + ABS_X, +) + +from inputremapper.exceptions import EventNotHandled, UinputNotAvailable +from inputremapper.injection.global_uinputs import ( + FrontendUInput, + GlobalUInputs, + UInput, +) +from inputremapper.input_event import InputEvent +from tests.lib.cleanup import cleanup +from tests.lib.test_setup import test_setup + + +@test_setup +class TestFrontendUinput(unittest.TestCase): + def setUp(self) -> None: + cleanup() + + def test_init(self): + name = "foo" + capabilities = {1: [1, 2, 3], 2: [4, 5, 6]} + uinput_defaults = FrontendUInput() + uinput_custom = FrontendUInput(name=name, events=capabilities) + + self.assertEqual(uinput_defaults.name, "py-evdev-uinput") + self.assertIsNone(uinput_defaults.capabilities()) + + self.assertEqual(uinput_custom.name, name) + self.assertEqual(uinput_custom.capabilities(), capabilities) + + +@test_setup +class TestGlobalUInputs(unittest.TestCase): + def setUp(self) -> None: + cleanup() + + def test_iter(self): + global_uinputs = GlobalUInputs(FrontendUInput) + for uinput in global_uinputs: + self.assertIsInstance(uinput, evdev.UInput) + + def test_write(self): + """Test write and write failure + + implicitly tests get_uinput and UInput.can_emit + """ + global_uinputs = GlobalUInputs(UInput) + global_uinputs.prepare_all() + + ev_1 = InputEvent.key(KEY_A, 1) + ev_2 = InputEvent.abs(ABS_X, 10) + + keyboard = global_uinputs.get_uinput("keyboard") + + global_uinputs.write(ev_1.event_tuple, "keyboard") + self.assertEqual(keyboard.write_count, 1) + + with self.assertRaises(EventNotHandled): + global_uinputs.write(ev_2.event_tuple, "keyboard") + + with self.assertRaises(UinputNotAvailable): + global_uinputs.write(ev_1.event_tuple, "foo") + + def test_creates_frontend_uinputs(self): + frontend_uinputs = GlobalUInputs(FrontendUInput) + frontend_uinputs.prepare_all() + uinput = frontend_uinputs.get_uinput("keyboard") + self.assertIsInstance(uinput, FrontendUInput) + + def test_creates_backend_service_uinputs(self): + frontend_uinputs = GlobalUInputs(UInput) + frontend_uinputs.prepare_all() + uinput = frontend_uinputs.get_uinput("keyboard") + self.assertIsInstance(uinput, UInput) diff --git a/tests/unit/test_groups.py b/tests/unit/test_groups.py new file mode 100644 index 0000000..01963ba --- /dev/null +++ b/tests/unit/test_groups.py @@ -0,0 +1,417 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + +import json +import os +import unittest + +import evdev + +from inputremapper.configs.paths import PathUtils +from inputremapper.groups import ( + _FindGroups, + groups, + classify, + DeviceType, + _Group, + is_inputremapper_device, +) +from tests.lib.fixtures import fixtures, keyboard_keys, Fixture +from tests.lib.test_setup import test_setup + + +class FakePipe: + groups = None + + def send(self, groups): + self.groups = groups + + +@test_setup +class TestGroups(unittest.TestCase): + def test_group(self): + group = _Group( + paths=["/dev/a", "/dev/b", "/dev/c"], + names=["name_bar", "name_a", "name_foo"], + types=[DeviceType.MOUSE, DeviceType.KEYBOARD, DeviceType.UNKNOWN], + key="key", + ) + self.assertEqual(group.name, "name_a") + self.assertEqual(group.key, "key") + self.assertEqual( + group.get_preset_path("preset1234"), + os.path.join( + PathUtils.config_path(), + "presets", + group.name, + "preset1234.json", + ), + ) + + def test_find_groups(self): + pipe = FakePipe() + _FindGroups(pipe).run() + self.assertIsInstance(pipe.groups, str) + + groups.loads(pipe.groups) + self.maxDiff = None + dump1 = groups.dumps() + dump2 = json.dumps( + [ + json.dumps( + { + "paths": [ + "/dev/input/event1", + ], + "names": ["Foo Device"], + "types": [DeviceType.KEYBOARD], + "key": "Foo Device", + } + ), + json.dumps( + { + "paths": [ + "/dev/input/event10", + "/dev/input/event11", + "/dev/input/event13", + "/dev/input/event15", + ], + "names": [ + "Foo Device", + "Foo Device foo", + "Foo Device", + "Foo Device bar", + ], + "types": [ + DeviceType.GAMEPAD, + DeviceType.KEYBOARD, + DeviceType.MOUSE, + ], + "key": "Foo Device 2", + } + ), + json.dumps( + { + "paths": ["/dev/input/event20"], + "names": ["Bar Device"], + "types": [DeviceType.KEYBOARD], + "key": "Bar Device", + } + ), + json.dumps( + { + "paths": [ + "/dev/input/event30", + "/dev/input/event32", + ], + "names": [ + "gamepad", + "gamepad abs 0 to 256", + ], + "types": [DeviceType.GAMEPAD], + "key": "gamepad", + } + ), + json.dumps( + { + "paths": ["/dev/input/event52"], + "names": ["Qux/[Device]?"], + "types": [DeviceType.KEYBOARD], + "key": "Qux/[Device]?", + } + ), + ] + ) + + self.assertEqual(dump1, dump2) + + groups2 = json.dumps([group.dumps() for group in groups.get_groups()]) + self.assertEqual(pipe.groups, groups2) + + def test_list_group_names(self): + self.assertListEqual( + groups.list_group_names(), + [ + "Foo Device", + "Foo Device", + "Bar Device", + "gamepad", + "Qux/[Device]?", + ], + ) + + def test_get_groups(self): + # by default no input-remapper devices are present + filtered = groups.get_groups() + keys = [group.key for group in filtered] + self.assertIn("Foo Device 2", keys) + self.assertNotIn("input-remapper Bar Device", keys) + + def test_skip_inputremapper_phys_devices(self): + fixtures.add_fixture( + { + "name": "Logitech G Pro", + "phys": "input-remapper/usb-0000:0f:00.3-4.2/input2:1", + "info": evdev.DeviceInfo(3, 0x046D, 0x4079, 0x0111), + "capabilities": { + evdev.ecodes.EV_KEY: [evdev.ecodes.BTN_LEFT], + evdev.ecodes.EV_REL: [ + evdev.ecodes.REL_X, + evdev.ecodes.REL_Y, + evdev.ecodes.REL_WHEEL, + ], + }, + "path": "/foo/bar", + } + ) + + groups.refresh() + self.assertIsNone(groups.find(path="/foo/bar")) + + def test_is_inputremapper_device(self): + device = evdev.InputDevice("/dev/input/event40") + self.assertTrue(is_inputremapper_device(device)) + + def test_skip_camera(self): + fixtures.add_fixture( + { + "name": "camera", + "phys": "abcd1", + "info": evdev.DeviceInfo(1, 2, 3, 4), + "capabilities": {evdev.ecodes.EV_KEY: [evdev.ecodes.KEY_CAMERA]}, + "path": "/foo/bar", + } + ) + + groups.refresh() + self.assertIsNone(groups.find(name="camera")) + self.assertIsNotNone(groups.find(name="gamepad")) + + def test_device_with_only_ev_abs(self): + # As Input Mapper can now map axes to buttons, + # a single EV_ABS device is valid for mapping. + fixtures.add_fixture( + { + "name": "qux", + "phys": "abcd2", + "info": evdev.DeviceInfo(1, 2, 3, 4), + "capabilities": {evdev.ecodes.EV_ABS: [evdev.ecodes.ABS_X]}, + "path": "/foo/bar", + } + ) + + groups.refresh() + self.assertIsNotNone(groups.find(name="gamepad")) + self.assertIsNotNone(groups.find(name="qux")) + + def test_device_with_no_capabilities(self): + fixtures.add_fixture( + { + "name": "nulcap", + "phys": "abcd3", + "info": evdev.DeviceInfo(1, 2, 3, 4), + "capabilities": {}, + "path": "/foo/bar", + } + ) + + groups.refresh() + self.assertIsNotNone(groups.find(name="gamepad")) + self.assertIsNone(groups.find(name="nulcap")) + + def test_duplicate_device(self): + fixtures.add_fixture( + { + "capabilities": {evdev.ecodes.EV_KEY: keyboard_keys}, + "phys": "usb-0000:03:00.0-3/input1", + "info": evdev.device.DeviceInfo(2, 1, 2, 1), + "name": "Foo Device", + "path": "/dev/input/event100", + } + ) + + groups.refresh() + group1 = groups.find(key="Foo Device") + group2 = groups.find(key="Foo Device 2") + group3 = groups.find(key="Foo Device 3") + self.assertIn("/dev/input/event1", group1.paths) + self.assertIn("/dev/input/event10", group2.paths) + self.assertIn("/dev/input/event100", group3.paths) + self.assertEqual(group1.key, "Foo Device") + self.assertEqual(group2.key, "Foo Device 2") + self.assertEqual(group3.key, "Foo Device 3") + self.assertEqual(group1.name, "Foo Device") + self.assertEqual(group2.name, "Foo Device") + self.assertEqual(group3.name, "Foo Device") + + # Bug reproduction: Unplugging a device and plugging it back in makes it appear + # earlier in the detected devices, giving it the first group, causing it to + # steal other devices injections on autoload. + + # Make sure the first fixture is earlier in the list than the third fixture. + # This is the starting situation, everything is still fine. + paths = fixtures.get_paths() + self.assertLess( + paths.index("/dev/input/event1"), + paths.index("/dev/input/event100"), + ) + + # Remove the first group, and add it back in + backup = fixtures.get_fixture("/dev/input/event1") + fixtures.remove_fixture("/dev/input/event1") + fixtures.add_fixture(backup) + # Now the order should be messed up (This is acceptable here, as long as the + # groups end up in the correct original order. This check only exists to make + # sure the test would still be able to reproduce the bug. The order being + # messed up is a prerequisite to reproduce the bug) + paths = fixtures.get_paths() + self.assertGreater( + paths.index("/dev/input/event1"), + paths.index("/dev/input/event100"), + ) + + # refreshing groups should still end up giving each device the same group as + # before. + groups.refresh() + group1 = groups.find(key="Foo Device") + group2 = groups.find(key="Foo Device 2") + group3 = groups.find(key="Foo Device 3") + self.assertIn("/dev/input/event1", group1.paths) + self.assertIn("/dev/input/event10", group2.paths) + self.assertIn("/dev/input/event100", group3.paths) + self.assertEqual(group1.key, "Foo Device") + self.assertEqual(group2.key, "Foo Device 2") + self.assertEqual(group3.key, "Foo Device 3") + self.assertEqual(group1.name, "Foo Device") + self.assertEqual(group2.name, "Foo Device") + self.assertEqual(group3.name, "Foo Device") + + def test_classify(self): + # properly detects if the device is a gamepad + EV_ABS = evdev.ecodes.EV_ABS + EV_KEY = evdev.ecodes.EV_KEY + EV_REL = evdev.ecodes.EV_REL + + class FakeDevice: + def __init__(self, capabilities): + self.c = capabilities + + def capabilities(self, absinfo): + assert not absinfo + return self.c + + """Gamepads""" + + self.assertEqual( + classify( + FakeDevice( + { + EV_ABS: [evdev.ecodes.ABS_X, evdev.ecodes.ABS_Y], + EV_KEY: [evdev.ecodes.BTN_A], + } + ) + ), + DeviceType.GAMEPAD, + ) + + """Mice""" + + self.assertEqual( + classify( + FakeDevice( + { + EV_REL: [ + evdev.ecodes.REL_X, + evdev.ecodes.REL_Y, + evdev.ecodes.REL_WHEEL, + ], + EV_KEY: [evdev.ecodes.BTN_LEFT], + } + ) + ), + DeviceType.MOUSE, + ) + + """Keyboard""" + + self.assertEqual( + classify(FakeDevice({EV_KEY: [evdev.ecodes.KEY_A]})), DeviceType.KEYBOARD + ) + + """Touchpads""" + + self.assertEqual( + classify( + FakeDevice( + { + EV_KEY: [evdev.ecodes.KEY_A], + EV_ABS: [evdev.ecodes.ABS_MT_POSITION_X], + } + ) + ), + DeviceType.TOUCHPAD, + ) + + """Graphics tablets""" + + self.assertEqual( + classify( + FakeDevice( + { + EV_ABS: [evdev.ecodes.ABS_X, evdev.ecodes.ABS_Y], + EV_KEY: [evdev.ecodes.BTN_STYLUS], + } + ) + ), + DeviceType.GRAPHICS_TABLET, + ) + + """Weird combos""" + + self.assertEqual( + classify( + FakeDevice( + { + EV_ABS: [evdev.ecodes.ABS_X, evdev.ecodes.ABS_Y], + EV_KEY: [evdev.ecodes.KEY_1], + } + ) + ), + DeviceType.UNKNOWN, + ) + + self.assertEqual( + classify( + FakeDevice({EV_ABS: [evdev.ecodes.ABS_X], EV_KEY: [evdev.ecodes.BTN_A]}) + ), + DeviceType.UNKNOWN, + ) + + self.assertEqual( + classify(FakeDevice({EV_KEY: [evdev.ecodes.BTN_A]})), DeviceType.UNKNOWN + ) + + self.assertEqual( + classify(FakeDevice({EV_ABS: [evdev.ecodes.ABS_X]})), DeviceType.UNKNOWN + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/test_injector.py b/tests/unit/test_injector.py new file mode 100644 index 0000000..af41b35 --- /dev/null +++ b/tests/unit/test_injector.py @@ -0,0 +1,711 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . +from inputremapper.injection.global_uinputs import GlobalUInputs, UInput +from inputremapper.injection.macros.parse import Parser +from inputremapper.injection.mapping_handlers.mapping_parser import MappingParser + +try: + from pydantic.v1 import ValidationError +except ImportError: + from pydantic import ValidationError + +import time +import unittest +from unittest import mock + +import evdev +from evdev.ecodes import ( + EV_REL, + EV_KEY, + EV_ABS, + ABS_HAT0X, + KEY_A, + REL_HWHEEL, + BTN_A, + ABS_X, + ABS_VOLUME, +) + +from inputremapper.configs.input_config import InputCombination, InputConfig +from inputremapper.configs.mapping import Mapping +from inputremapper.configs.preset import Preset +from inputremapper.configs.keyboard_layout import ( + keyboard_layout, + DISABLE_CODE, + DISABLE_NAME, +) +from inputremapper.groups import groups, classify, DeviceType +from inputremapper.injection.context import Context +from inputremapper.injection.injector import ( + Injector, + is_in_capabilities, + InjectorState, + get_udev_name, + get_forward_name, + get_forward_phys, +) +from inputremapper.injection.numlock import is_numlock_on +from inputremapper.input_event import InputEvent + +from tests.lib.constants import EVENT_READ_TIMEOUT +from tests.lib.fixtures import fixtures +from tests.lib.fixtures import keyboard_keys +from tests.lib.patches import uinputs +from tests.lib.pipes import read_write_history_pipe, push_events +from tests.lib.pipes import uinput_write_history_pipe +from tests.lib.test_setup import test_setup + + +def wait_for_uinput_write(): + start = time.time() + if not uinput_write_history_pipe[0].poll(timeout=10): + raise AssertionError("No event written within 10 seconds") + return float(time.time() - start) + + +@test_setup +class TestInjector(unittest.IsolatedAsyncioTestCase): + new_gamepad_path = "/dev/input/event100" + + @classmethod + def setUpClass(cls): + cls.injector = None + cls.grab = evdev.InputDevice.grab + + def setUp(self): + self.failed = 0 + self.make_it_fail = 2 + self.global_uinputs = GlobalUInputs(UInput) + self.global_uinputs.prepare_all() + self.mapping_parser = MappingParser(self.global_uinputs) + + def grab_fail_twice(_): + if self.failed < self.make_it_fail: + self.failed += 1 + raise OSError() + + evdev.InputDevice.grab = grab_fail_twice + + def tearDown(self): + if self.injector is not None and self.injector.is_alive(): + self.injector.stop_injecting() + time.sleep(0.2) + self.assertIn( + self.injector.get_state(), + (InjectorState.STOPPED, InjectorState.ERROR, InjectorState.NO_GRAB), + ) + self.injector = None + evdev.InputDevice.grab = self.grab + + def initialize_injector(self, group, preset: Preset): + self.injector = Injector(group, preset, self.mapping_parser) + self.injector._devices = self.injector.group.get_devices() + self.injector._update_preset() + + def test_grab(self): + # path is from the fixtures + path = "/dev/input/event10" + preset = Preset() + preset.add( + Mapping.from_combination( + InputCombination([InputConfig(type=EV_KEY, code=10)]), + "keyboard", + "a", + ) + ) + + self.injector = Injector( + groups.find(key="Foo Device 2"), + preset, + self.mapping_parser, + ) + # this test needs to pass around all other constraints of + # _grab_device + self.injector.context = Context(preset, {}, {}, self.mapping_parser) + device = self.injector._grab_device(evdev.InputDevice(path)) + gamepad = classify(device) == DeviceType.GAMEPAD + self.assertFalse(gamepad) + self.assertEqual(self.failed, 2) + # success on the third try + self.assertEqual(device.name, fixtures.get_fixture(path).name) + + def test_fail_grab(self): + self.make_it_fail = 999 + preset = Preset() + preset.add( + Mapping.from_combination( + InputCombination([InputConfig(type=EV_KEY, code=10)]), + "keyboard", + "a", + ) + ) + + self.injector = Injector( + groups.find(key="Foo Device 2"), + preset, + self.mapping_parser, + ) + path = "/dev/input/event10" + self.injector.context = Context(preset, {}, {}, self.mapping_parser) + device = self.injector._grab_device(evdev.InputDevice(path)) + self.assertIsNone(device) + self.assertGreaterEqual(self.failed, 1) + + self.assertEqual(self.injector.get_state(), InjectorState.UNKNOWN) + self.injector.start() + self.assertEqual(self.injector.get_state(), InjectorState.STARTING) + # since none can be grabbed, the process will terminate. But that + # actually takes quite some time. + time.sleep(self.injector.regrab_timeout * 12) + self.assertFalse(self.injector.is_alive()) + self.assertEqual(self.injector.get_state(), InjectorState.NO_GRAB) + + def test_grab_device_1(self): + device_hash = fixtures.gamepad.get_device_hash() + + preset = Preset() + preset.add( + Mapping.from_combination( + InputCombination( + [ + InputConfig( + type=EV_ABS, + code=ABS_HAT0X, + analog_threshold=1, + origin_hash=device_hash, + ) + ] + ), + "keyboard", + "a", + ), + ) + self.initialize_injector(groups.find(name="gamepad"), preset) + self.injector.context = Context(preset, {}, {}, self.mapping_parser) + self.injector.group.paths = [ + "/dev/input/event10", + "/dev/input/event30", + "/dev/input/event1234", + ] + + grabbed = self.injector._grab_devices() + self.assertEqual(len(grabbed), 1) + self.assertEqual(grabbed[device_hash].path, "/dev/input/event30") + + def test_forward_gamepad_events(self): + device_hash = fixtures.gamepad.get_device_hash() + + # forward abs joystick events + preset = Preset() + preset.add( + Mapping.from_combination( + input_combination=InputCombination( + [InputConfig(type=EV_KEY, code=BTN_A, origin_hash=device_hash)] + ), + target_uinput="keyboard", + output_symbol="a", + ), + ) + + self.initialize_injector(groups.find(name="gamepad"), preset) + self.injector.context = Context(preset, {}, {}, self.mapping_parser) + + path = "/dev/input/event30" + devices = self.injector._grab_devices() + self.assertEqual(len(devices), 1) + self.assertEqual(devices[device_hash].path, path) + gamepad = classify(devices[device_hash]) == DeviceType.GAMEPAD + self.assertTrue(gamepad) + + def test_skip_unused_device(self): + # skips a device because its capabilities are not used in the preset + preset = Preset() + preset.add( + Mapping.from_combination( + InputCombination([InputConfig(type=EV_KEY, code=10)]), + "keyboard", + "a", + ) + ) + self.initialize_injector(groups.find(key="Foo Device 2"), preset) + self.injector.context = Context(preset, {}, {}, self.mapping_parser) + + # grabs only one device even though the group has 4 devices + devices = self.injector._grab_devices() + self.assertEqual(len(devices), 1) + self.assertEqual(self.failed, 2) + + def test_skip_unknown_device(self): + preset = Preset() + preset.add( + Mapping.from_combination( + InputCombination([InputConfig(type=EV_KEY, code=1234)]), + "keyboard", + "a", + ) + ) + + # skips a device because its capabilities are not used in the preset + self.initialize_injector(groups.find(key="Foo Device 2"), preset) + self.injector.context = Context(preset, {}, {}, self.mapping_parser) + devices = self.injector._grab_devices() + + # skips the device alltogether, so no grab attempts fail + self.assertEqual(self.failed, 0) + self.assertEqual(devices, {}) + + def test_get_udev_name(self): + self.injector = Injector( + groups.find(key="Foo Device 2"), + Preset(), + self.mapping_parser, + ) + suffix = "mapped" + prefix = "input-remapper" + expected = f'{prefix} {"a" * (80 - len(suffix) - len(prefix) - 2)} {suffix}' + self.assertEqual(len(expected), 80) + self.assertEqual(get_udev_name("a" * 100, suffix), expected) + + self.injector.device = "abcd" + self.assertEqual( + get_udev_name("abcd", "forwarded"), + "input-remapper abcd forwarded", + ) + + def test_get_forward_phys(self): + path = "/dev/input/event11" + device = evdev.InputDevice(path) + self.assertEqual( + get_forward_phys(device), + "input-remapper/usb-0000:03:00.0-1/input2/input2", + ) + + def test_get_forward_name(self): + self.assertEqual(get_forward_name("a" * 100), "a" * 80) + self.assertEqual(get_forward_name("abcd"), "abcd") + + @mock.patch("evdev.InputDevice.ungrab") + def test_capabilities_and_uinput_presence(self, ungrab_patch): + preset = Preset() + m1 = Mapping.from_combination( + InputCombination( + [ + InputConfig( + type=EV_KEY, + code=KEY_A, + origin_hash=fixtures.foo_device_2_keyboard.get_device_hash(), + ) + ] + ), + "keyboard", + "c", + ) + m2 = Mapping.from_combination( + InputCombination( + [ + InputConfig( + type=EV_REL, + code=REL_HWHEEL, + analog_threshold=1, + origin_hash=fixtures.foo_device_2_mouse.get_device_hash(), + ) + ] + ), + "keyboard", + "key(b)", + ) + preset.add(m1) + preset.add(m2) + self.injector = Injector( + groups.find(key="Foo Device 2"), + preset, + self.mapping_parser, + ) + self.injector.stop_injecting() + self.injector.run() + + self.assertEqual( + self.injector.preset.get_mapping( + InputCombination( + [ + InputConfig( + type=EV_KEY, + code=KEY_A, + origin_hash=fixtures.foo_device_2_keyboard.get_device_hash(), + ) + ] + ) + ), + m1, + ) + self.assertEqual( + self.injector.preset.get_mapping( + InputCombination( + [ + InputConfig( + type=EV_REL, + code=REL_HWHEEL, + analog_threshold=1, + origin_hash=fixtures.foo_device_2_mouse.get_device_hash(), + ) + ] + ) + ), + m2, + ) + + # reading and preventing original events from reaching the + # display server + forwarded_foo = uinputs.get("Foo Device foo") + forwarded = uinputs.get("Foo Device") + self.assertIsNotNone(forwarded_foo) + self.assertIsNotNone(forwarded) + + # copies capabilities for all other forwarded devices + self.assertIn(EV_REL, forwarded_foo.capabilities()) + self.assertIn(EV_KEY, forwarded.capabilities()) + self.assertEqual(sorted(forwarded.capabilities()[EV_KEY]), keyboard_keys) + self.assertEqual( + forwarded_foo.phys, "input-remapper/usb-0000:03:00.0-1/input2/input2" + ) + self.assertEqual( + forwarded.phys, "input-remapper/usb-0000:03:00.0-1/input2/input3" + ) + + self.assertEqual(ungrab_patch.call_count, 2) + + def test_injector(self): + numlock_before = is_numlock_on() + + # stuff the preset outputs + keyboard_layout.clear() + code_a = 100 + code_q = 101 + code_w = 102 + keyboard_layout._set("a", code_a) + keyboard_layout._set("key_q", code_q) + keyboard_layout._set("w", code_w) + + preset = Preset() + preset.add( + Mapping.from_combination( + InputCombination( + [ + InputConfig( + type=EV_KEY, + code=8, + origin_hash=fixtures.foo_device_2_keyboard.get_device_hash(), + ), + InputConfig( + type=EV_KEY, + code=9, + origin_hash=fixtures.foo_device_2_keyboard.get_device_hash(), + ), + ] + ), + "keyboard", + "k(KEY_Q).k(w)", + ) + ) + preset.add( + Mapping.from_combination( + InputCombination( + [ + InputConfig( + type=EV_ABS, + code=ABS_HAT0X, + analog_threshold=-1, + origin_hash=fixtures.foo_device_2_gamepad.get_device_hash(), + ) + ] + ), + "keyboard", + "a", + ) + ) + # one mapping that is unknown in the keyboard_layout on purpose + input_b = 10 + with self.assertRaises(ValidationError): + preset.add( + Mapping.from_combination( + InputCombination( + [ + InputConfig( + type=EV_KEY, + code=input_b, + origin_hash=fixtures.foo_device_2_keyboard.get_device_hash(), + ) + ] + ), + "keyboard", + "b", + ) + ) + + self.injector = Injector( + groups.find(key="Foo Device 2"), + preset, + self.mapping_parser, + ) + self.assertEqual(self.injector.get_state(), InjectorState.UNKNOWN) + self.injector.start() + self.assertEqual(self.injector.get_state(), InjectorState.STARTING) + + uinput_write_history_pipe[0].poll(timeout=1) + self.assertEqual(self.injector.get_state(), InjectorState.RUNNING) + time.sleep(EVENT_READ_TIMEOUT * 10) + + push_events( + fixtures.foo_device_2_keyboard, + [ + # should execute a macro... + InputEvent.key(8, 1), # forwarded + InputEvent.key(9, 1), # triggers macro, not forwarding + # macro runs now and injects a few more keys + InputEvent.key(8, 0), # releases macro (needs to be forwarded as well) + InputEvent.key(9, 0), # not forwarded, just like the down-event + ], + ) + + time.sleep(0.1) # give a chance that everything arrives in order + push_events( + fixtures.foo_device_2_gamepad, + [ + # gamepad stuff. trigger a combination + InputEvent.abs(ABS_HAT0X, -1), + InputEvent.abs(ABS_HAT0X, 0), + ], + ) + + time.sleep(0.1) + push_events( + fixtures.foo_device_2_keyboard, + [ + # just pass those over without modifying + InputEvent.key(10, 1), + InputEvent.key(10, 0), + InputEvent(0, 0, 3124, 3564, 6542), + ], + force=True, + ) + + # the injector needs time to process this + time.sleep(0.1) + + # sending anything arbitrary does not stop the process + # (is_alive checked later after some time) + self.injector._msg_pipe[1].send(1234) + + # convert the write history to some easier to manage list + history = read_write_history_pipe() + + # 1 event before the combination was triggered + # 4 events for the macro + # 1 event for releasing the previous key-down event + # 2 for mapped keys + # 3 for forwarded events + self.assertEqual(len(history), 11) + + # the first bit is ordered properly + self.assertEqual(history[0], (EV_KEY, 8, 1)) # forwarded + del history[0] + + # since the macro takes a little bit of time to execute, its + # keystrokes are all over the place. + # just check if they are there and if so, remove them from the list. + # the macro itself + self.assertIn((EV_KEY, code_q, 1), history) + self.assertIn((EV_KEY, code_q, 0), history) + self.assertIn((EV_KEY, code_w, 1), history) + self.assertIn((EV_KEY, code_w, 0), history) + index_q_1 = history.index((EV_KEY, code_q, 1)) + index_q_0 = history.index((EV_KEY, code_q, 0)) + index_w_1 = history.index((EV_KEY, code_w, 1)) + index_w_0 = history.index((EV_KEY, code_w, 0)) + self.assertGreater(index_q_0, index_q_1) + self.assertGreater(index_w_1, index_q_0) + self.assertGreater(index_w_0, index_w_1) + del history[index_w_0] + del history[index_w_1] + del history[index_q_0] + del history[index_q_1] + + # The rest should be in order now. + # First the released combination key which did not release the macro. + # The combination key which released the macro won't appear here, because + # it also didn't have a key-down event and therefore doesn't need to be + # released itself. + self.assertEqual(history[0], (EV_KEY, 8, 0)) + # value should be 1, even if the input event was -1. + # Injected keycodes should always be either 0 or 1 + self.assertEqual(history[1], (EV_KEY, code_a, 1)) + self.assertEqual(history[2], (EV_KEY, code_a, 0)) + self.assertEqual(history[3], (EV_KEY, input_b, 1)) + self.assertEqual(history[4], (EV_KEY, input_b, 0)) + self.assertEqual(history[5], (3124, 3564, 6542)) + + time.sleep(0.1) + self.assertTrue(self.injector.is_alive()) + + numlock_after = is_numlock_on() + self.assertEqual(numlock_before, numlock_after) + self.assertEqual(self.injector.get_state(), InjectorState.RUNNING) + + def test_is_in_capabilities(self): + key = InputCombination(InputCombination.from_tuples((1, 2, 1))) + capabilities = {1: [9, 2, 5]} + self.assertTrue(is_in_capabilities(key, capabilities)) + + key = InputCombination(InputCombination.from_tuples((1, 2, 1), (1, 3, 1))) + capabilities = {1: [9, 2, 5]} + # only one of the codes of the combination is required. + # The goal is to make combinations= across those sub-devices possible, + # that make up one hardware device + self.assertTrue(is_in_capabilities(key, capabilities)) + + key = InputCombination(InputCombination.from_tuples((1, 2, 1), (1, 5, 1))) + capabilities = {1: [9, 2, 5]} + self.assertTrue(is_in_capabilities(key, capabilities)) + + +@test_setup +class TestModifyCapabilities(unittest.TestCase): + def setUp(self): + self.global_uinputs = GlobalUInputs(UInput) + self.global_uinputs.prepare_all() + self.mapping_parser = MappingParser(self.global_uinputs) + + class FakeDevice: + def __init__(self): + self._capabilities = { + evdev.ecodes.EV_SYN: [1, 2, 3], + evdev.ecodes.EV_FF: [1, 2, 3], + EV_ABS: [ + ( + 1, + evdev.AbsInfo( + value=None, + min=None, + max=1234, + fuzz=None, + flat=None, + resolution=None, + ), + ), + ( + 2, + evdev.AbsInfo( + value=None, + min=50, + max=2345, + fuzz=None, + flat=None, + resolution=None, + ), + ), + 3, + ], + } + + def capabilities(self, absinfo=False): + assert absinfo is True + return self._capabilities + + preset = Preset() + preset.add( + Mapping.from_combination( + InputCombination([InputConfig(type=EV_KEY, code=80)]), + "keyboard", + "a", + ) + ) + preset.add( + Mapping.from_combination( + InputCombination([InputConfig(type=EV_KEY, code=81)]), + "keyboard", + DISABLE_NAME, + ), + ) + + macro_code = "r(2, m(sHiFt_l, r(2, k(1).k(2))))" + macro = Parser.parse(macro_code, preset) + + preset.add( + Mapping.from_combination( + InputCombination([InputConfig(type=EV_KEY, code=60)]), + "keyboard", + macro_code, + ), + ) + + # going to be ignored, because EV_REL cannot be mapped, that's + # mouse movements. + preset.add( + Mapping.from_combination( + InputCombination( + [InputConfig(type=EV_REL, code=1234, analog_threshold=3)] + ), + "keyboard", + "b", + ), + ) + + self.a = keyboard_layout.get("a") + self.shift_l = keyboard_layout.get("ShIfT_L") + self.one = keyboard_layout.get(1) + self.two = keyboard_layout.get("2") + self.left = keyboard_layout.get("BtN_lEfT") + self.fake_device = FakeDevice() + self.preset = preset + self.macro = macro + + def check_keys(self, capabilities): + """No matter the configuration, EV_KEY will be mapped to EV_KEY.""" + self.assertIn(EV_KEY, capabilities) + keys = capabilities[EV_KEY] + self.assertIn(self.a, keys) + self.assertIn(self.one, keys) + self.assertIn(self.two, keys) + self.assertIn(self.shift_l, keys) + self.assertNotIn(DISABLE_CODE, keys) + + def test_copy_capabilities(self): + # I don't know what ABS_VOLUME is, for now I would like to just always + # remove it until somebody complains, since its presence broke stuff + self.injector = Injector(mock.Mock(), self.preset, self.mapping_parser) + self.fake_device._capabilities = { + EV_ABS: [ABS_VOLUME, (ABS_X, evdev.AbsInfo(0, 0, 500, 0, 0, 0))], + EV_KEY: [1, 2, 3], + EV_REL: [11, 12, 13], + evdev.ecodes.EV_SYN: [1], + evdev.ecodes.EV_FF: [2], + } + + capabilities = self.injector._copy_capabilities(self.fake_device) + self.assertNotIn(ABS_VOLUME, capabilities[EV_ABS]) + self.assertNotIn(evdev.ecodes.EV_SYN, capabilities) + self.assertNotIn(evdev.ecodes.EV_FF, capabilities) + self.assertListEqual(capabilities[EV_KEY], [1, 2, 3]) + self.assertListEqual(capabilities[EV_REL], [11, 12, 13]) + self.assertEqual(capabilities[EV_ABS][0][1].max, 500) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/test_input_config.py b/tests/unit/test_input_config.py new file mode 100644 index 0000000..141ffd9 --- /dev/null +++ b/tests/unit/test_input_config.py @@ -0,0 +1,559 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + + +import unittest + +from evdev.ecodes import ( + EV_KEY, + EV_ABS, + EV_REL, + BTN_C, + BTN_B, + BTN_A, + BTN_LEFT, + BTN_MIDDLE, + BTN_RIGHT, + BTN_SIDE, + BTN_EXTRA, + BTN_FORWARD, + BTN_BACK, + BTN_TASK, + REL_X, + REL_Y, + REL_WHEEL, + REL_HWHEEL, + ABS_RY, + ABS_X, + ABS_HAT0Y, + ABS_HAT0X, + KEY_A, + KEY_LEFTSHIFT, + KEY_RIGHTALT, + KEY_LEFTCTRL, +) + +from inputremapper.configs.input_config import InputCombination, InputConfig +from tests.lib.test_setup import test_setup + + +@test_setup +class TestInputConfig(unittest.TestCase): + def test_input_config(self): + test_cases = [ + # basic test, nothing fancy here + { + "input": { + "type": EV_KEY, + "code": KEY_A, + "origin_hash": "foo", + }, + "properties": { + "type": EV_KEY, + "code": KEY_A, + "origin_hash": "foo", + "input_match_hash": (EV_KEY, KEY_A, "foo"), + "defines_analog_input": False, + "type_and_code": (EV_KEY, KEY_A), + }, + "methods": [ + { + "name": "description", + "args": (), + "kwargs": {}, + "return": "a", + }, + { + "name": "__hash__", + "args": (), + "kwargs": {}, + "return": hash((EV_KEY, KEY_A, "foo", None)), + }, + ], + }, + # removes analog_threshold + { + "input": { + "type": EV_KEY, + "code": KEY_A, + "origin_hash": "foo", + "analog_threshold": 10, + }, + "properties": { + "type": EV_KEY, + "code": KEY_A, + "origin_hash": "foo", + "analog_threshold": None, + "input_match_hash": (EV_KEY, KEY_A, "foo"), + "defines_analog_input": False, + "type_and_code": (EV_KEY, KEY_A), + }, + "methods": [ + { + "name": "description", + "args": (), + "kwargs": {}, + "return": "a", + }, + { + "name": "__hash__", + "args": (), + "kwargs": {}, + "return": hash((EV_KEY, KEY_A, "foo", None)), + }, + ], + }, + # abs to btn + { + "input": { + "type": EV_ABS, + "code": ABS_X, + "origin_hash": "foo", + "analog_threshold": 10, + }, + "properties": { + "type": EV_ABS, + "code": ABS_X, + "origin_hash": "foo", + "analog_threshold": 10, + "input_match_hash": (EV_ABS, ABS_X, "foo"), + "defines_analog_input": False, + "type_and_code": (EV_ABS, ABS_X), + }, + "methods": [ + { + "name": "description", + "args": (), + "kwargs": {}, + "return": "Joystick-X Right 10%", + }, + { + "name": "description", + "args": (), + "kwargs": {"exclude_threshold": True}, + "return": "Joystick-X Right", + }, + { + "name": "description", + "args": (), + "kwargs": { + "exclude_threshold": True, + "exclude_direction": True, + }, + "return": "Joystick-X", + }, + { + "name": "__hash__", + "args": (), + "kwargs": {}, + "return": hash((EV_ABS, ABS_X, "foo", 10)), + }, + ], + }, + # abs to btn with d-pad + { + "input": { + "type": EV_ABS, + "code": ABS_HAT0Y, + "origin_hash": "foo", + "analog_threshold": 10, + }, + "properties": { + "type": EV_ABS, + "code": ABS_HAT0Y, + "origin_hash": "foo", + "analog_threshold": 10, + "input_match_hash": (EV_ABS, ABS_HAT0Y, "foo"), + "defines_analog_input": False, + "type_and_code": (EV_ABS, ABS_HAT0Y), + }, + "methods": [ + { + "name": "description", + "args": (), + "kwargs": {}, + "return": "DPad-Y Down 10%", + }, + { + "name": "__hash__", + "args": (), + "kwargs": {}, + "return": hash((EV_ABS, ABS_HAT0Y, "foo", 10)), + }, + ], + }, + # rel to btn + { + "input": { + "type": EV_REL, + "code": REL_Y, + "origin_hash": "foo", + "analog_threshold": 10, + }, + "properties": { + "type": EV_REL, + "code": REL_Y, + "origin_hash": "foo", + "analog_threshold": 10, + "input_match_hash": (EV_REL, REL_Y, "foo"), + "defines_analog_input": False, + "type_and_code": (EV_REL, REL_Y), + }, + "methods": [ + { + "name": "description", + "args": (), + "kwargs": {}, + "return": "Y Down 10", + }, + { + "name": "__hash__", + "args": (), + "kwargs": {}, + "return": hash((EV_REL, REL_Y, "foo", 10)), + }, + ], + }, + # abs as axis + { + "input": { + "type": EV_ABS, + "code": ABS_X, + "origin_hash": "foo", + "analog_threshold": 0, + }, + "properties": { + "type": EV_ABS, + "code": ABS_X, + "origin_hash": "foo", + "analog_threshold": None, + "input_match_hash": (EV_ABS, ABS_X, "foo"), + "defines_analog_input": True, + "type_and_code": (EV_ABS, ABS_X), + }, + "methods": [ + { + "name": "description", + "args": (), + "kwargs": {}, + "return": "Joystick-X", + }, + { + "name": "description", + "args": (), + "kwargs": { + "exclude_threshold": True, + "exclude_direction": True, + }, + "return": "Joystick-X", + }, + { + "name": "__hash__", + "args": (), + "kwargs": {}, + "return": hash((EV_ABS, ABS_X, "foo", None)), + }, + ], + }, + # rel as axis + { + "input": { + "type": EV_REL, + "code": REL_WHEEL, + "origin_hash": "foo", + }, + "properties": { + "type": EV_REL, + "code": REL_WHEEL, + "origin_hash": "foo", + "analog_threshold": None, + "input_match_hash": (EV_REL, REL_WHEEL, "foo"), + "defines_analog_input": True, + "type_and_code": (EV_REL, REL_WHEEL), + }, + "methods": [ + { + "name": "description", + "args": (), + "kwargs": {}, + "return": "Wheel", + }, + { + "name": "__hash__", + "args": (), + "kwargs": {}, + "return": hash((EV_REL, REL_WHEEL, "foo", None)), + }, + ], + }, + ] + for test_case in test_cases: + input_config = InputConfig(**test_case["input"]) + for property_, value in test_case["properties"].items(): + self.assertEqual( + value, + getattr(input_config, property_), + f"property mismatch for input: {test_case['input']} " + f"property: {property_} expected value: {value}", + ) + for method in test_case["methods"]: + self.assertEqual( + method["return"], + getattr(input_config, method["name"])( + *method["args"], **method["kwargs"] + ), + f"wrong method return for input: {test_case['input']} " + f"method: {method}", + ) + + def test_is_immutable(self): + input_config = InputConfig(type=1, code=2) + with self.assertRaises(TypeError): + input_config.origin_hash = "foo" + + +@test_setup +class TestInputCombination(unittest.TestCase): + def test_eq(self): + a = InputCombination( + [ + InputConfig(type=EV_REL, code=REL_X, value=1, origin_hash="1234"), + InputConfig(type=EV_KEY, code=KEY_A, value=1, origin_hash="abcd"), + ] + ) + b = InputCombination( + [ + InputConfig(type=EV_REL, code=REL_X, value=1, origin_hash="1234"), + InputConfig(type=EV_KEY, code=KEY_A, value=1, origin_hash="abcd"), + ] + ) + self.assertEqual(a, b) + + def test_not_eq(self): + a = InputCombination( + [ + InputConfig(type=EV_REL, code=REL_X, value=1, origin_hash="2345"), + InputConfig(type=EV_KEY, code=KEY_A, value=1, origin_hash="bcde"), + ] + ) + b = InputCombination( + [ + InputConfig(type=EV_REL, code=REL_X, value=1, origin_hash="1234"), + InputConfig(type=EV_KEY, code=KEY_A, value=1, origin_hash="abcd"), + ] + ) + self.assertNotEqual(a, b) + + def test_can_be_used_as_dict_key(self): + dict_ = { + InputCombination( + [ + InputConfig(type=EV_REL, code=REL_X, value=1, origin_hash="1234"), + InputConfig(type=EV_KEY, code=KEY_A, value=1, origin_hash="abcd"), + ] + ): "foo" + } + key = InputCombination( + [ + InputConfig(type=EV_REL, code=REL_X, value=1, origin_hash="1234"), + InputConfig(type=EV_KEY, code=KEY_A, value=1, origin_hash="abcd"), + ] + ) + self.assertEqual(dict_.get(key), "foo") + + def test_get_permutations(self): + key_1 = InputCombination(InputCombination.from_tuples((1, 3, 1))) + self.assertEqual(len(key_1.get_permutations()), 1) + self.assertEqual(key_1.get_permutations()[0], key_1) + + key_2 = InputCombination(InputCombination.from_tuples((1, 3, 1), (1, 5, 1))) + self.assertEqual(len(key_2.get_permutations()), 1) + self.assertEqual(key_2.get_permutations()[0], key_2) + + key_3 = InputCombination( + InputCombination.from_tuples((1, 3, 1), (1, 5, 1), (1, 7, 1)) + ) + self.assertEqual(len(key_3.get_permutations()), 2) + self.assertEqual( + key_3.get_permutations()[0], + InputCombination( + InputCombination.from_tuples((1, 3, 1), (1, 5, 1), (1, 7, 1)) + ), + ) + self.assertEqual( + key_3.get_permutations()[1], + InputCombination( + InputCombination.from_tuples((1, 5, 1), (1, 3, 1), (1, 7, 1)) + ), + ) + + def test_is_problematic(self): + key_1 = InputCombination( + InputCombination.from_tuples((1, KEY_LEFTSHIFT, 1), (1, 5, 1)) + ) + self.assertTrue(key_1.is_problematic()) + + key_2 = InputCombination( + InputCombination.from_tuples((1, KEY_RIGHTALT, 1), (1, 5, 1)) + ) + self.assertTrue(key_2.is_problematic()) + + key_3 = InputCombination( + InputCombination.from_tuples((1, 3, 1), (1, KEY_LEFTCTRL, 1)) + ) + self.assertTrue(key_3.is_problematic()) + + key_4 = InputCombination(InputCombination.from_tuples((1, 3, 1))) + self.assertFalse(key_4.is_problematic()) + + key_5 = InputCombination(InputCombination.from_tuples((1, 3, 1), (1, 5, 1))) + self.assertFalse(key_5.is_problematic()) + + def test_init(self): + self.assertRaises(TypeError, lambda: InputCombination(1)) + self.assertRaises(TypeError, lambda: InputCombination(None)) + self.assertRaises(TypeError, lambda: InputCombination([1])) + self.assertRaises(TypeError, lambda: InputCombination((1,))) + self.assertRaises(TypeError, lambda: InputCombination((1, 2))) + self.assertRaises(TypeError, lambda: InputCombination("1")) + self.assertRaises(TypeError, lambda: InputCombination("(1,2,3)")) + self.assertRaises( + TypeError, + lambda: InputCombination(((1, 2, 3), (1, 2, 3), None)), + ) + + # those don't raise errors + InputCombination(({"type": 1, "code": 2}, {"type": 1, "code": 1})) + InputCombination(({"type": 1, "code": 2},)) + InputCombination(({"type": "1", "code": "2"},)) + InputCombination([InputConfig(type=1, code=2, analog_threshold=3)]) + InputCombination( + ( + {"type": 1, "code": 2}, + {"type": "1", "code": "2"}, + InputConfig(type=1, code=2), + ) + ) + + def test_to_config(self): + c1 = InputCombination([InputConfig(type=1, code=2, analog_threshold=3)]) + c2 = InputCombination( + ( + InputConfig(type=1, code=2, analog_threshold=3), + InputConfig(type=4, code=5, analog_threshold=6), + ) + ) + # analog_threshold is removed for key events + self.assertEqual(c1.to_config(), ({"type": 1, "code": 2},)) + self.assertEqual( + c2.to_config(), + ({"type": 1, "code": 2}, {"type": 4, "code": 5, "analog_threshold": 6}), + ) + + def test_beautify(self): + # not an integration test, but I have all the selection_label tests here already + self.assert_beautify_single(EV_KEY, KEY_A, 1, "a") + self.assert_beautify_single(EV_KEY, KEY_A, 1, "a") + self.assert_beautify_single(EV_ABS, ABS_HAT0Y, -1, "DPad-Y Up") + self.assert_beautify_single(EV_KEY, BTN_A, 1, "Button A") + self.assert_beautify_single(EV_KEY, 1234, 1, "unknown (1, 1234)") + self.assert_beautify_single(EV_ABS, ABS_HAT0X, -1, "DPad-X Left") + self.assert_beautify_single(EV_ABS, ABS_HAT0Y, -1, "DPad-Y Up") + self.assert_beautify_single(EV_KEY, BTN_A, 1, "Button A") + self.assert_beautify_single(EV_ABS, ABS_X, 1, "Joystick-X Right") + self.assert_beautify_single(EV_ABS, ABS_RY, 1, "Joystick-RY Down") + self.assert_beautify_single(EV_REL, REL_HWHEEL, 1, "Wheel Right") + self.assert_beautify_single(EV_REL, REL_WHEEL, -1, "Wheel Down") + + # region "mouse buttons" + self.assert_beautify_single(EV_KEY, BTN_LEFT, 1, "Mouse Button LEFT") + self.assert_beautify_single(EV_KEY, BTN_MIDDLE, 1, "Mouse Button MIDDLE") + self.assert_beautify_single(EV_KEY, BTN_RIGHT, 1, "Mouse Button RIGHT") + self.assert_beautify_single(EV_KEY, BTN_SIDE, 1, "Mouse Button 4") + self.assert_beautify_single(EV_KEY, BTN_EXTRA, 1, "Mouse Button 5") + self.assert_beautify_single(EV_KEY, BTN_FORWARD, 1, "Mouse Button 6") + self.assert_beautify_single(EV_KEY, BTN_BACK, 1, "Mouse Button 7") + self.assert_beautify_single(EV_KEY, BTN_TASK, 1, "Mouse Button 8") + + # Mouse buttons 9+ do not have an evdev name, and so must be expressed + # as an offset from BTN_LEFT AKA "Button 1". Subtract 1 for base index + # so that `btn_mouse_base + 1` would be "Button 1". + btn_mouse_base: int = BTN_LEFT - 1 + + self.assert_beautify_single(EV_KEY, btn_mouse_base + 9, 1, "Mouse Button 9") + self.assert_beautify_single(EV_KEY, btn_mouse_base + 10, 1, "Mouse Button 10") + # endregion "mouse buttons" + + # combinations + self.assertEqual( + InputCombination( + InputCombination.from_tuples( + (EV_KEY, BTN_A, 1), + (EV_KEY, BTN_B, 1), + (EV_KEY, BTN_C, 1), + ), + ).beautify(), + "Button A + Button B + Button C", + ) + + def test_find_analog_input_config(self): + analog_input = InputConfig(type=EV_REL, code=REL_X) + + combination = InputCombination( + ( + InputConfig(type=EV_KEY, code=BTN_MIDDLE), + InputConfig(type=EV_REL, code=REL_Y, analog_threshold=1), + analog_input, + ) + ) + self.assertIsNone(combination.find_analog_input_config(type_=EV_ABS)) + self.assertEqual( + combination.find_analog_input_config(type_=EV_REL), analog_input + ) + self.assertEqual(combination.find_analog_input_config(), analog_input) + + combination = InputCombination( + ( + InputConfig(type=EV_REL, code=REL_X, analog_threshold=1), + InputConfig(type=EV_KEY, code=BTN_MIDDLE), + ) + ) + self.assertIsNone(combination.find_analog_input_config(type_=EV_ABS)) + self.assertIsNone(combination.find_analog_input_config(type_=EV_REL)) + self.assertIsNone(combination.find_analog_input_config()) + + # region helper methods + def assert_beautify_single(self, type_, code, direction, expected_beautified_name): + """ + Assert that the beautified name of the event tuple `(type_, code, + direction)` matches the `expected_beautified_name`. + """ + self.assertEqual( + InputCombination( + InputCombination.from_tuples((type_, code, direction)) + ).beautify(), + expected_beautified_name, + ) + + # endregion + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/test_input_event.py b/tests/unit/test_input_event.py new file mode 100644 index 0000000..5da1264 --- /dev/null +++ b/tests/unit/test_input_event.py @@ -0,0 +1,132 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + +import unittest + +import evdev +from dataclasses import FrozenInstanceError +from inputremapper.input_event import InputEvent +from tests.lib.test_setup import test_setup + + +@test_setup +class TestInputEvent(unittest.TestCase): + def test_from_event(self): + e1 = InputEvent.from_event(evdev.InputEvent(1, 2, 3, 4, 5)) + e2 = InputEvent.from_event(e1) + + self.assertEqual(e1, e2) + self.assertEqual(e1.sec, 1) + self.assertEqual(e1.usec, 2) + self.assertEqual(e1.type, 3) + self.assertEqual(e1.code, 4) + self.assertEqual(e1.value, 5) + + self.assertEqual(e1.sec, e2.sec) + self.assertEqual(e1.usec, e2.usec) + self.assertEqual(e1.type, e2.type) + self.assertEqual(e1.code, e2.code) + self.assertEqual(e1.value, e2.value) + + self.assertRaises(TypeError, InputEvent.from_event, "1,2,3") + + def test_from_event_tuple(self): + t1 = (1, 2, 3) + t2 = (1, "2", 3) + t3 = (1, 2, 3, 4, 5) + t4 = (1, "b", 3) + + e1 = InputEvent.from_tuple(t1) + e2 = InputEvent.from_tuple(t2) + self.assertEqual(e1, e2) + self.assertEqual(e1.sec, 0) + self.assertEqual(e1.usec, 0) + self.assertEqual(e1.type, 1) + self.assertEqual(e1.code, 2) + self.assertEqual(e1.value, 3) + + def test_properties(self): + e1 = InputEvent.from_tuple((evdev.ecodes.EV_KEY, evdev.ecodes.BTN_LEFT, 1)) + self.assertEqual( + e1.event_tuple, + (evdev.ecodes.EV_KEY, evdev.ecodes.BTN_LEFT, 1), + ) + self.assertEqual(e1.type_and_code, (evdev.ecodes.EV_KEY, evdev.ecodes.BTN_LEFT)) + + with self.assertRaises( + FrozenInstanceError + ): # would be TypeError on a slotted class + e1.event_tuple = (1, 2, 3) + + with self.assertRaises( + FrozenInstanceError + ): # would be TypeError on a slotted class + e1.type_and_code = (1, 2) + + with self.assertRaises(FrozenInstanceError): + e1.value = 5 + + def test_modify(self): + e1 = InputEvent(1, 2, 3, 4, 5) + e2 = e1.modify(value=6) + e3 = e1.modify(sec=0, usec=0, type_=0, code=0, value=0) + + self.assertNotEqual(e1, e2) + self.assertEqual(e1.sec, e2.sec) + self.assertEqual(e1.usec, e2.usec) + self.assertEqual(e1.type, e2.type) + self.assertEqual(e1.code, e2.code) + self.assertNotEqual(e1.value, e2.value) + self.assertEqual(e3.sec, 0) + self.assertEqual(e3.usec, 0) + self.assertEqual(e3.type, 0) + self.assertEqual(e3.code, 0) + self.assertEqual(e3.value, 0) + + def test_is_wheel_event(self): + input_event_x = InputEvent( + 0, + 0, + evdev.ecodes.EV_REL, + evdev.ecodes.REL_X, + 1, + ) + self.assertFalse(input_event_x.is_wheel_event) + self.assertFalse(input_event_x.is_wheel_hi_res_event) + + input_event_wheel = InputEvent( + 0, + 0, + evdev.ecodes.EV_REL, + evdev.ecodes.REL_WHEEL, + 1, + ) + self.assertTrue(input_event_wheel.is_wheel_event) + self.assertFalse(input_event_wheel.is_wheel_hi_res_event) + + input_event_wheel_hi_res = InputEvent( + 0, + 0, + evdev.ecodes.EV_REL, + evdev.ecodes.REL_WHEEL_HI_RES, + 1, + ) + self.assertFalse(input_event_wheel_hi_res.is_wheel_event) + self.assertTrue(input_event_wheel_hi_res.is_wheel_hi_res_event) diff --git a/tests/unit/test_ipc.py b/tests/unit/test_ipc.py new file mode 100644 index 0000000..16e6ec1 --- /dev/null +++ b/tests/unit/test_ipc.py @@ -0,0 +1,208 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + +import asyncio +import multiprocessing +import os +import select +import time +import unittest + +from inputremapper.ipc.pipe import Pipe +from inputremapper.ipc.shared_dict import SharedDict +from inputremapper.ipc.socket import Server, Client, Base +from tests.lib.test_setup import test_setup +from tests.lib.tmp import tmp + + +@test_setup +class TestSharedDict(unittest.TestCase): + def setUp(self): + self.shared_dict = SharedDict() + self.shared_dict.start() + time.sleep(0.02) + + def test_returns_none(self): + self.assertIsNone(self.shared_dict.get("a")) + self.assertIsNone(self.shared_dict["a"]) + + def test_set_get(self): + self.shared_dict["a"] = 3 + self.assertEqual(self.shared_dict.get("a"), 3) + self.assertEqual(self.shared_dict["a"], 3) + + +@test_setup +class TestSocket(unittest.TestCase): + def test_socket(self): + def test(s1, s2): + self.assertEqual(s2.recv(), None) + + s1.send(1) + self.assertTrue(s2.poll()) + self.assertEqual(s2.recv(), 1) + self.assertFalse(s2.poll()) + self.assertEqual(s2.recv(), None) + + s1.send(2) + self.assertTrue(s2.poll()) + s1.send(3) + self.assertTrue(s2.poll()) + self.assertEqual(s2.recv(), 2) + self.assertTrue(s2.poll()) + self.assertEqual(s2.recv(), 3) + self.assertFalse(s2.poll()) + self.assertEqual(s2.recv(), None) + + server = Server(os.path.join(tmp, "socket1")) + client = Client(os.path.join(tmp, "socket1")) + test(server, client) + + client = Client(os.path.join(tmp, "socket2")) + server = Server(os.path.join(tmp, "socket2")) + test(client, server) + + def test_not_connected_1(self): + # client discards old message, because it might have had a purpose + # for a different client and not for the current one + server = Server(os.path.join(tmp, "socket3")) + server.send(1) + + client = Client(os.path.join(tmp, "socket3")) + server.send(2) + + self.assertTrue(client.poll()) + self.assertEqual(client.recv(), 2) + self.assertFalse(client.poll()) + self.assertEqual(client.recv(), None) + + def test_not_connected_2(self): + client = Client(os.path.join(tmp, "socket4")) + client.send(1) + + server = Server(os.path.join(tmp, "socket4")) + client.send(2) + + self.assertTrue(server.poll()) + self.assertEqual(server.recv(), 2) + self.assertFalse(server.poll()) + self.assertEqual(server.recv(), None) + + def test_select(self): + """Is compatible to select.select.""" + server = Server(os.path.join(tmp, "socket6")) + client = Client(os.path.join(tmp, "socket6")) + + server.send(1) + ready = select.select([client], [], [], 0)[0][0] + self.assertEqual(ready, client) + + client.send(2) + ready = select.select([server], [], [], 0)[0][0] + self.assertEqual(ready, server) + + def test_base_abstract(self): + self.assertRaises(NotImplementedError, lambda: Base("foo")) + self.assertRaises(NotImplementedError, lambda: Base.connect(None)) + self.assertRaises(NotImplementedError, lambda: Base.reconnect(None)) + self.assertRaises(NotImplementedError, lambda: Base.fileno(None)) + + +@test_setup +class TestPipe(unittest.IsolatedAsyncioTestCase): + def test_pipe_single(self): + p1 = Pipe(os.path.join(tmp, "pipe")) + self.assertEqual(p1.recv(), None) + + p1.send(1) + self.assertTrue(p1.poll()) + self.assertEqual(p1.recv(), 1) + self.assertFalse(p1.poll()) + self.assertEqual(p1.recv(), None) + + p1.send(2) + self.assertTrue(p1.poll()) + p1.send(3) + self.assertTrue(p1.poll()) + self.assertEqual(p1.recv(), 2) + self.assertTrue(p1.poll()) + self.assertEqual(p1.recv(), 3) + self.assertFalse(p1.poll()) + self.assertEqual(p1.recv(), None) + + def test_pipe_duo(self): + p1 = Pipe(os.path.join(tmp, "pipe")) + p2 = Pipe(os.path.join(tmp, "pipe")) + self.assertEqual(p2.recv(), None) + + p1.send(1) + self.assertEqual(p2.recv(), 1) + self.assertEqual(p2.recv(), None) + + p1.send(2) + p1.send(3) + self.assertEqual(p2.recv(), 2) + self.assertEqual(p2.recv(), 3) + self.assertEqual(p2.recv(), None) + + async def test_async_for_loop(self): + p1 = Pipe(os.path.join(tmp, "pipe")) + iterator = p1.__aiter__() + p1.send(1) + + self.assertEqual(await iterator.__anext__(), 1) + + read_task = asyncio.Task(iterator.__anext__()) + timeout_task = asyncio.Task(asyncio.sleep(1)) + + done, pending = await asyncio.wait( + (read_task, timeout_task), return_when=asyncio.FIRST_COMPLETED + ) + self.assertIn(timeout_task, done) + self.assertIn(read_task, pending) + read_task.cancel() + + async def test_async_for_loop_duo(self): + def writer(): + p = Pipe(os.path.join(tmp, "pipe")) + for i in range(3): + p.send(i) + time.sleep(0.5) + for i in range(3): + p.send(i) + time.sleep(0.1) + p.send("stop now") + + p1 = Pipe(os.path.join(tmp, "pipe")) + + w_process = multiprocessing.Process(target=writer) + w_process.start() + + messages = [] + async for msg in p1: + messages.append(msg) + if msg == "stop now": + break + + self.assertEqual(messages, [0, 1, 2, 0, 1, 2, "stop now"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/test_logger.py b/tests/unit/test_logger.py new file mode 100644 index 0000000..52066ce --- /dev/null +++ b/tests/unit/test_logger.py @@ -0,0 +1,151 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + +from tests.lib.tmp import tmp + +import logging +import os +import shutil +import unittest + +import evdev + +from inputremapper.configs.paths import PathUtils +from inputremapper.logging.logger import ( + logger, + ColorfulFormatter, +) +from tests.lib.test_setup import test_setup + + +def add_filehandler(log_path: str, debug: bool) -> None: + """Start logging to a file.""" + log_path = os.path.expanduser(log_path) + os.makedirs(os.path.dirname(log_path), exist_ok=True) + file_handler = logging.FileHandler(log_path) + file_handler.setFormatter(ColorfulFormatter(debug)) + logger.addHandler(file_handler) + logger.info('Starting logging to "%s"', log_path) + + +@test_setup +class TestLogger(unittest.TestCase): + def tearDown(self): + logger.update_verbosity(debug=True) + + # remove the file handler + logger.handlers = [ + handler + for handler in logger.handlers + if not isinstance(logger.handlers, logging.FileHandler) + ] + path = os.path.join(tmp, "logger-test") + PathUtils.remove(path) + + def test_write(self): + uinput = evdev.UInput(name="foo") + path = os.path.join(tmp, "logger-test") + add_filehandler(path, False) + logger.write((evdev.ecodes.EV_KEY, evdev.ecodes.KEY_A, 1), uinput) + with open(path, "r") as f: + content = f.read() + self.assertIn( + 'Writing (1, 30, 1) to "foo"', + content, + ) + + def test_log_info(self): + logger.update_verbosity(debug=False) + path = os.path.join(tmp, "logger-test") + add_filehandler(path, False) + logger.log_info() + with open(path, "r") as f: + content = f.read().lower() + self.assertIn("input-remapper", content) + + def test_makes_path(self): + path = os.path.join(tmp, "logger-test") + if os.path.exists(path): + shutil.rmtree(path) + + new_path = os.path.join(tmp, "logger-test", "a", "b", "c") + add_filehandler(new_path, False) + self.assertTrue(os.path.exists(new_path)) + + def test_debug(self): + path = os.path.join(tmp, "logger-test") + logger.update_verbosity(True) + add_filehandler(path, True) + logger.error("abc") + logger.warning("foo") + logger.info("123") + logger.debug("456") + logger.debug("789") + with open(path, "r") as f: + content = f.read().lower() + self.assertIn("logger.py", content) + + self.assertIn("error", content) + self.assertIn("abc", content) + + self.assertIn("warn", content) + self.assertIn("foo", content) + + self.assertIn("info", content) + self.assertIn("123", content) + + self.assertIn("debug", content) + self.assertIn("456", content) + + self.assertIn("debug", content) + self.assertIn("789", content) + + def test_default(self): + path = os.path.join(tmp, "logger-test") + logger.update_verbosity(debug=False) + add_filehandler(path, False) + logger.error("abc") + logger.warning("foo") + logger.info("123") + logger.debug("456") + logger.debug("789") + with open(path, "r") as f: + content = f.read().lower() + self.assertNotIn("logger.py", content) + self.assertNotIn("line", content) + + self.assertIn("error", content) + self.assertIn("abc", content) + + self.assertIn("warn", content) + self.assertIn("foo", content) + + self.assertNotIn("info", content) + self.assertIn("123", content) + + self.assertNotIn("debug", content) + self.assertNotIn("456", content) + + self.assertNotIn("debug", content) + self.assertNotIn("789", content) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/test_macros/__init__.py b/tests/unit/test_macros/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/unit/test_macros/macro_test_base.py b/tests/unit/test_macros/macro_test_base.py new file mode 100644 index 0000000..82ffbd7 --- /dev/null +++ b/tests/unit/test_macros/macro_test_base.py @@ -0,0 +1,120 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + + +import asyncio +import unittest + +from inputremapper.configs.preset import Preset +from inputremapper.configs.validation_errors import MacroError +from inputremapper.injection.context import Context +from inputremapper.injection.global_uinputs import GlobalUInputs, UInput +from inputremapper.injection.macros.macro import Macro, macro_variables +from inputremapper.injection.macros.parse import Parser +from inputremapper.injection.mapping_handlers.mapping_parser import MappingParser +from tests.lib.fixtures import fixtures +from tests.lib.logger import logger +from tests.lib.patches import InputDevice + + +class MacroTestBase(unittest.IsolatedAsyncioTestCase): + @classmethod + def setUpClass(cls): + macro_variables.start() + + def setUp(self): + self.result = [] + self.global_uinputs = GlobalUInputs(UInput) + self.mapping_parser = MappingParser(self.global_uinputs) + + try: + self.loop = asyncio.get_event_loop() + except RuntimeError: + # suddenly "There is no current event loop in thread 'MainThread'" + # errors started to appear + self.loop = asyncio.new_event_loop() + asyncio.set_event_loop(self.loop) + + self.source_device = InputDevice(fixtures.bar_device.path) + + self.context = Context( + Preset(), + source_devices={fixtures.bar_device.get_device_hash(): self.source_device}, + forward_devices={}, + mapping_parser=self.mapping_parser, + ) + + def tearDown(self): + self.result = [] + + def handler(self, type_: int, code: int, value: int): + """Where macros should write codes to.""" + logger.info(f"macro wrote{(type_, code, value)}") + self.result.append((type_, code, value)) + + async def trigger_sequence(self, macro: Macro, event): + for listener in self.context.listeners: + asyncio.ensure_future(listener(event)) + # this still might cause race conditions and the test to fail + await asyncio.sleep(0) + + macro.press_trigger() + if macro.running: + return + asyncio.ensure_future(macro.run(self.handler)) + + async def release_sequence(self, macro: Macro, event): + for listener in self.context.listeners: + asyncio.ensure_future(listener(event)) + # this still might cause race conditions and the test to fail + await asyncio.sleep(0) + + macro.release_trigger() + + def count_child_macros(self, macro) -> int: + count = 0 + for task in macro.tasks: + count += len(task.child_macros) + for child_macro in task.child_macros: + count += self.count_child_macros(child_macro) + return count + + def count_tasks(self, macro) -> int: + count = len(macro.tasks) + for task in macro.tasks: + for child_macro in task.child_macros: + count += self.count_tasks(child_macro) + return count + + def expect_string_in_error(self, string: str, macro: str): + with self.assertRaises(MacroError) as cm: + Parser.parse(macro, self.context) + error = str(cm.exception) + self.assertIn(string, error) + + +class DummyMapping: + macro_key_sleep_ms = 10 + rel_rate = 60 + target_uinput = "keyboard + mouse" + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/test_macros/test_add.py b/tests/unit/test_macros/test_add.py new file mode 100644 index 0000000..2f31c17 --- /dev/null +++ b/tests/unit/test_macros/test_add.py @@ -0,0 +1,71 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + + +import unittest + +from inputremapper.configs.validation_errors import MacroError +from inputremapper.injection.macros.macro import macro_variables +from inputremapper.injection.macros.parse import Parser +from tests.lib.test_setup import test_setup +from tests.unit.test_macros.macro_test_base import DummyMapping, MacroTestBase + + +@test_setup +class TestAdd(MacroTestBase): + async def test_add(self): + await Parser.parse("set(a, 1).add(a, 1)", self.context, DummyMapping).run( + self.handler + ) + self.assertEqual(macro_variables.get("a"), 2) + + await Parser.parse("set(b, 1).add(b, -1)", self.context, DummyMapping).run( + self.handler + ) + self.assertEqual(macro_variables.get("b"), 0) + + await Parser.parse("set(c, -1).add(c, 500)", self.context, DummyMapping).run( + self.handler + ) + self.assertEqual(macro_variables.get("c"), 499) + + await Parser.parse("add(d, 500)", self.context, DummyMapping).run(self.handler) + self.assertEqual(macro_variables.get("d"), 500) + + async def test_add_invalid(self): + # For invalid input it should do nothing (except to log to the console) + await Parser.parse('set(e, "foo").add(e, 1)', self.context, DummyMapping).run( + self.handler + ) + self.assertEqual(macro_variables.get("e"), "foo") + + await Parser.parse('set(e, "2").add(e, 3)', self.context, DummyMapping).run( + self.handler + ) + self.assertEqual(macro_variables.get("e"), "2") + + async def test_raises_error(self): + Parser.parse("add(a, 1)", self.context) # no error + self.assertRaises(MacroError, Parser.parse, "add(a, b)", self.context) + self.assertRaises(MacroError, Parser.parse, 'add(a, "1")', self.context) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/test_macros/test_argument.py b/tests/unit/test_macros/test_argument.py new file mode 100644 index 0000000..aef5733 --- /dev/null +++ b/tests/unit/test_macros/test_argument.py @@ -0,0 +1,187 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + + +import unittest + +from inputremapper.configs.validation_errors import ( + MacroError, +) +from inputremapper.injection.macros.argument import Argument, ArgumentConfig +from inputremapper.injection.macros.macro import Macro, macro_variables +from inputremapper.injection.macros.raw_value import RawValue +from inputremapper.injection.macros.variable import Variable +from tests.lib.test_setup import test_setup +from tests.unit.test_macros.macro_test_base import DummyMapping, MacroTestBase + + +@test_setup +class TestArgument(MacroTestBase): + def test_resolve(self): + self.assertEqual(Variable("a", const=True).get_value(), "a") + self.assertEqual(Variable(1, const=True).get_value(), 1) + self.assertEqual(Variable(None, const=True).get_value(), None) + + # $ is part of a custom string here + self.assertEqual(Variable('"$a"', const=True).get_value(), '"$a"') + self.assertEqual(Variable("'$a'", const=True).get_value(), "'$a'") + self.assertEqual(Variable("$a", const=True).get_value(), "$a") + + variable = Variable("a", const=False) + self.assertEqual(variable.get_value(), None) + macro_variables["a"] = 1 + self.assertEqual(variable.get_value(), 1) + + def test_type_check(self): + def test(value, types, name, position): + argument = Argument( + ArgumentConfig( + types=types, + name=name, + position=position, + ), + DummyMapping(), + ) + argument.initialize_variable(RawValue(value=value)) + return argument.get_value() + + def test_variable(variable, types, name, position): + argument = Argument( + ArgumentConfig( + types=types, + name=name, + position=position, + ), + DummyMapping(), + ) + argument._variable = variable + return argument.get_value() + + # allows params that can be cast to the target type + self.assertEqual(test("1", [str, None], "foo", 0), "1") + self.assertEqual(test("1.2", [str], "foo", 2), "1.2") + + self.assertRaises( + MacroError, + lambda: test("1.2", [int], "foo", 3), + ) + self.assertRaises(MacroError, lambda: test("a", [None], "foo", 0)) + self.assertRaises(MacroError, lambda: test("a", [int], "foo", 1)) + self.assertRaises( + MacroError, + lambda: test("a", [int, float], "foo", 2), + ) + self.assertRaises( + MacroError, + lambda: test("a", [int, None], "foo", 3), + ) + self.assertEqual(test("a", [int, float, None, str], "foo", 4), "a") + + # variables are expected to be of the Variable type here, not a $string + self.assertRaises( + MacroError, + lambda: test("$a", [int], "foo", 4), + ) + + # We don't cast values that were explicitly set as strings back into numbers. + variable = Variable("a", const=False) + variable.set_value("5") + self.assertRaises( + MacroError, + lambda: test_variable(variable, [int], "foo", 4), + ) + + self.assertRaises( + MacroError, + lambda: test("a", [Macro], "foo", 0), + ) + self.assertRaises(MacroError, lambda: test("1", [Macro], "foo", 0)) + + def test_validate_variable_name(self): + self.assertRaises( + MacroError, + lambda: Variable("1a", const=False).validate_variable_name(), + ) + self.assertRaises( + MacroError, + lambda: Variable("$a", const=False).validate_variable_name(), + ) + self.assertRaises( + MacroError, + lambda: Variable("a()", const=False).validate_variable_name(), + ) + self.assertRaises( + MacroError, + lambda: Variable("1", const=False).validate_variable_name(), + ) + self.assertRaises( + MacroError, + lambda: Variable("+", const=False).validate_variable_name(), + ) + self.assertRaises( + MacroError, + lambda: Variable("-", const=False).validate_variable_name(), + ) + self.assertRaises( + MacroError, + lambda: Variable("*", const=False).validate_variable_name(), + ) + self.assertRaises( + MacroError, + lambda: Variable("a,b", const=False).validate_variable_name(), + ) + self.assertRaises( + MacroError, + lambda: Variable("a,b", const=False).validate_variable_name(), + ) + self.assertRaises( + MacroError, + lambda: Variable("#", const=False).validate_variable_name(), + ) + self.assertRaises( + MacroError, + lambda: Variable(1, const=False).validate_variable_name(), + ) + self.assertRaises( + MacroError, + lambda: Variable(None, const=False).validate_variable_name(), + ) + self.assertRaises( + MacroError, + lambda: Variable([], const=False).validate_variable_name(), + ) + self.assertRaises( + MacroError, + lambda: Variable((), const=False).validate_variable_name(), + ) + + # doesn't raise + Variable("a", const=False).validate_variable_name() + Variable("_a", const=False).validate_variable_name() + Variable("_A", const=False).validate_variable_name() + Variable("A", const=False).validate_variable_name() + Variable("Abcd", const=False).validate_variable_name() + Variable("Abcd_", const=False).validate_variable_name() + Variable("Abcd_1234", const=False).validate_variable_name() + Variable("Abcd1234_", const=False).validate_variable_name() + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/test_macros/test_dynamic_types.py b/tests/unit/test_macros/test_dynamic_types.py new file mode 100644 index 0000000..ecd7acd --- /dev/null +++ b/tests/unit/test_macros/test_dynamic_types.py @@ -0,0 +1,154 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + + +import unittest + +from inputremapper.configs.validation_errors import ( + MacroError, +) +from inputremapper.injection.macros.argument import ArgumentConfig +from inputremapper.injection.macros.macro import macro_variables +from inputremapper.injection.macros.parse import Parser +from inputremapper.injection.macros.raw_value import RawValue +from inputremapper.injection.macros.task import Task +from tests.unit.test_macros.macro_test_base import DummyMapping, MacroTestBase + + +class TestDynamicTypes(MacroTestBase): + # "Dynamic" meaning const=False + async def test_set_type_int(self): + await Parser.parse( + "set(a, 1)", + self.context, + DummyMapping, + True, + ).run(lambda *_, **__: None) + self.assertEqual(macro_variables.get("a"), 1) + # assertEqual(1.0, 1) passes, so check for the type to be sure: + self.assertIsInstance(macro_variables.get("a"), int) + + async def test_set_type_float(self): + await Parser.parse( + "set(a, 2.2)", + self.context, + DummyMapping, + True, + ).run(lambda *_, **__: None) + self.assertEqual(macro_variables.get("a"), 2.2) + + async def test_set_type_str(self): + await Parser.parse( + 'set(a, "3")', + self.context, + DummyMapping, + True, + ).run(lambda *_, **__: None) + self.assertEqual(macro_variables.get("a"), "3") + + def make_test_task(self, types): + # Make a new test task, with a different types array each time. + class TestTask(Task): + argument_configs = [ + ArgumentConfig( + name="testvalue", + position=0, + types=types, + ) + ] + + return TestTask( + [RawValue("$a")], + {}, + self.context, + DummyMapping, + ) + + async def test_dynamic_int_parsing(self): + # set(a, 4) was used. Could be meant as an integer, or as a string + # (just like how key(KEY_A) doesn't require string quotes to be a string) + macro_variables["a"] = 4 + + test_task = self.make_test_task([str, int]) + self.assertEqual(test_task.get_argument("testvalue").get_value(), 4) + + test_task = self.make_test_task([int]) + self.assertEqual(test_task.get_argument("testvalue").get_value(), 4) + + # Now that ints are not allowed, it will be used as a string + test_task = self.make_test_task([str]) + self.assertEqual(test_task.get_argument("testvalue").get_value(), "4") + + async def test_dynamic_float_parsing(self): + # set(a, 5.5) was used. + macro_variables["a"] = 5.5 + + test_task = self.make_test_task([str, float]) + self.assertEqual(test_task.get_argument("testvalue").get_value(), 5.5) + + test_task = self.make_test_task([float]) + self.assertEqual(test_task.get_argument("testvalue").get_value(), 5.5) + + test_task = self.make_test_task([str]) + self.assertEqual(test_task.get_argument("testvalue").get_value(), "5.5") + + async def test_no_float_allowed(self): + # set(a, 6.6) was used. + macro_variables["a"] = 6.6 + + test_task = self.make_test_task([str, int]) + self.assertEqual(test_task.get_argument("testvalue").get_value(), "6.6") + + test_task = self.make_test_task([int]) + self.assertRaises( + MacroError, + lambda: test_task.get_argument("testvalue").get_value(), + ) + + async def test_force_string_float(self): + # set(a, "7.7") was used. Since quotes are explicitly added, the variable is + # not intended to be used as a float. + macro_variables["a"] = "7.7" + + test_task = self.make_test_task([str, float]) + self.assertEqual(test_task.get_argument("testvalue").get_value(), "7.7") + + test_task = self.make_test_task([float]) + self.assertRaises( + MacroError, + lambda: test_task.get_argument("testvalue").get_value(), + ) + + async def test_force_string_int(self): + # set(a, "8") was used. + macro_variables["a"] = "8" + + test_task = self.make_test_task([int, str]) + self.assertEqual(test_task.get_argument("testvalue").get_value(), "8") + + test_task = self.make_test_task([int]) + self.assertRaises( + MacroError, + lambda: test_task.get_argument("testvalue").get_value(), + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/test_macros/test_event.py b/tests/unit/test_macros/test_event.py new file mode 100644 index 0000000..4084a94 --- /dev/null +++ b/tests/unit/test_macros/test_event.py @@ -0,0 +1,67 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + + +import unittest + +from evdev.ecodes import ( + EV_REL, + EV_KEY, + REL_X, +) + +from inputremapper.configs.keyboard_layout import keyboard_layout +from inputremapper.injection.macros.parse import Parser +from tests.lib.test_setup import test_setup +from tests.unit.test_macros.macro_test_base import MacroTestBase, DummyMapping + + +@test_setup +class TestEvent(MacroTestBase): + async def test_event_1(self): + macro = Parser.parse("e(EV_KEY, KEY_A, 1)", self.context, DummyMapping) + a_code = keyboard_layout.get("a") + + await macro.run(self.handler) + self.assertListEqual(self.result, [(EV_KEY, a_code, 1)]) + self.assertEqual(self.count_child_macros(macro), 0) + + async def test_event_2(self): + macro = Parser.parse( + "repeat(1, event(type=5421, code=324, value=154))", + self.context, + DummyMapping, + ) + code = 324 + + await macro.run(self.handler) + self.assertListEqual(self.result, [(5421, code, 154)]) + self.assertEqual(self.count_child_macros(macro), 1) + + async def test_event_mouse(self): + macro = Parser.parse("e(EV_REL, REL_X, 10)", self.context, DummyMapping) + + await macro.run(self.handler) + self.assertListEqual(self.result, [(EV_REL, REL_X, 10)]) + self.assertEqual(self.count_child_macros(macro), 0) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/test_macros/test_hold.py b/tests/unit/test_macros/test_hold.py new file mode 100644 index 0000000..37e8aaf --- /dev/null +++ b/tests/unit/test_macros/test_hold.py @@ -0,0 +1,191 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + + +import asyncio +import unittest + +from evdev.ecodes import EV_KEY + +from inputremapper.configs.keyboard_layout import keyboard_layout +from inputremapper.configs.validation_errors import MacroError +from inputremapper.injection.macros.parse import Parser +from tests.lib.test_setup import test_setup +from tests.unit.test_macros.macro_test_base import MacroTestBase, DummyMapping + + +@test_setup +class TestHold(MacroTestBase): + async def test_hold(self): + # repeats key(a) as long as the key is held down + macro = Parser.parse("key(1).hold(key(a)).key(3)", self.context, DummyMapping) + + """down""" + + macro.press_trigger() + await asyncio.sleep(0.05) + self.assertTrue(macro.tasks[1].is_holding()) + + macro.press_trigger() # redundantly calling doesn't break anything + asyncio.ensure_future(macro.run(self.handler)) + await asyncio.sleep(0.2) + self.assertTrue(macro.tasks[1].is_holding()) + self.assertGreater(len(self.result), 2) + + """up""" + + macro.release_trigger() + await asyncio.sleep(0.05) + self.assertFalse(macro.tasks[1].is_holding()) + + self.assertEqual(self.result[0], (EV_KEY, keyboard_layout.get("1"), 1)) + self.assertEqual(self.result[-1], (EV_KEY, keyboard_layout.get("3"), 0)) + + code_a = keyboard_layout.get("a") + self.assertGreater(self.result.count((EV_KEY, code_a, 1)), 2) + + self.assertEqual(self.count_child_macros(macro), 1) + self.assertEqual(self.count_tasks(macro), 4) + + async def test_hold_failing_child(self): + # if a child macro fails, hold will not try to run it again. + # The exception is properly propagated through both `hold`s and the macro + # stops. If the code is broken, this test might enter an infinite loop. + macro = Parser.parse("hold(hold(key(a)))", self.context, DummyMapping) + + class MyException(Exception): + pass + + def f(*_): + raise MyException("foo") + + macro.press_trigger() + with self.assertRaises(MyException): + await macro.run(f) + + await asyncio.sleep(0.1) + self.assertFalse(macro.running) + + async def test_dont_hold(self): + macro = Parser.parse("key(1).hold(key(a)).key(3)", self.context, DummyMapping) + + asyncio.ensure_future(macro.run(self.handler)) + await asyncio.sleep(0.2) + self.assertFalse(macro.tasks[1].is_holding()) + # press_trigger was never called, so the macro completes right away + # and the child macro of hold is never called. + self.assertEqual(len(self.result), 4) + + self.assertEqual(self.result[0], (EV_KEY, keyboard_layout.get("1"), 1)) + self.assertEqual(self.result[-1], (EV_KEY, keyboard_layout.get("3"), 0)) + + self.assertEqual(self.count_child_macros(macro), 1) + self.assertEqual(self.count_tasks(macro), 4) + + async def test_just_hold(self): + macro = Parser.parse("key(1).hold().key(3)", self.context, DummyMapping) + + """down""" + + macro.press_trigger() + asyncio.ensure_future(macro.run(self.handler)) + await asyncio.sleep(0.1) + self.assertTrue(macro.tasks[1].is_holding()) + self.assertEqual(len(self.result), 2) + await asyncio.sleep(0.1) + # doesn't do fancy stuff, is blocking until the release + self.assertEqual(len(self.result), 2) + + """up""" + + macro.release_trigger() + await asyncio.sleep(0.05) + self.assertFalse(macro.tasks[1].is_holding()) + self.assertEqual(len(self.result), 4) + + self.assertEqual(self.result[0], (EV_KEY, keyboard_layout.get("1"), 1)) + self.assertEqual(self.result[-1], (EV_KEY, keyboard_layout.get("3"), 0)) + + self.assertEqual(self.count_child_macros(macro), 0) + self.assertEqual(self.count_tasks(macro), 3) + + async def test_dont_just_hold(self): + macro = Parser.parse("key(1).hold().key(3)", self.context, DummyMapping) + + asyncio.ensure_future(macro.run(self.handler)) + await asyncio.sleep(0.1) + self.assertFalse(macro.tasks[1].is_holding()) + # since press_trigger was never called it just does the macro + # completely + self.assertEqual(len(self.result), 4) + + self.assertEqual(self.result[0], (EV_KEY, keyboard_layout.get("1"), 1)) + self.assertEqual(self.result[-1], (EV_KEY, keyboard_layout.get("3"), 0)) + + self.assertEqual(self.count_child_macros(macro), 0) + + async def test_hold_down(self): + # writes down and waits for the up event until the key is released + macro = Parser.parse("hold(a)", self.context, DummyMapping) + self.assertEqual(self.count_child_macros(macro), 0) + + """down""" + + macro.press_trigger() + await asyncio.sleep(0.05) + self.assertTrue(macro.tasks[0].is_holding()) + + asyncio.ensure_future(macro.run(self.handler)) + macro.press_trigger() # redundantly calling doesn't break anything + await asyncio.sleep(0.2) + self.assertTrue(macro.tasks[0].is_holding()) + self.assertEqual(len(self.result), 1) + self.assertEqual(self.result[0], (EV_KEY, keyboard_layout.get("a"), 1)) + + """up""" + + macro.release_trigger() + await asyncio.sleep(0.05) + self.assertFalse(macro.tasks[0].is_holding()) + + self.assertEqual(len(self.result), 2) + self.assertEqual(self.result[0], (EV_KEY, keyboard_layout.get("a"), 1)) + self.assertEqual(self.result[1], (EV_KEY, keyboard_layout.get("a"), 0)) + + async def test_hold_variable(self): + code_a = keyboard_layout.get("a") + macro = Parser.parse("set(foo, a).hold($foo)", self.context, DummyMapping) + await macro.run(self.handler) + self.assertListEqual( + self.result, + [ + (EV_KEY, code_a, 1), + (EV_KEY, code_a, 0), + ], + ) + + async def test_raises_error(self): + self.assertRaises(MacroError, Parser.parse, "h(1, 1)", self.context) + self.assertRaises(MacroError, Parser.parse, "h(hold(h(1, 1)))", self.context) + self.assertRaises(MacroError, Parser.parse, "hold(key(a)key(b))", self.context) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/test_macros/test_hold_keys.py b/tests/unit/test_macros/test_hold_keys.py new file mode 100644 index 0000000..18ca0e4 --- /dev/null +++ b/tests/unit/test_macros/test_hold_keys.py @@ -0,0 +1,130 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + + +import asyncio +import unittest + +from evdev.ecodes import EV_KEY + +from inputremapper.configs.keyboard_layout import keyboard_layout +from inputremapper.configs.validation_errors import MacroError +from inputremapper.injection.macros.parse import Parser +from tests.lib.test_setup import test_setup +from tests.unit.test_macros.macro_test_base import MacroTestBase, DummyMapping + + +@test_setup +class TestHoldKeys(MacroTestBase): + async def test_hold_keys(self): + macro = Parser.parse( + "set(foo, b).hold_keys(a, $foo, c)", self.context, DummyMapping + ) + # press first + macro.press_trigger() + # then run, just like how it is going to happen during runtime + asyncio.ensure_future(macro.run(self.handler)) + + code_a = keyboard_layout.get("a") + code_b = keyboard_layout.get("b") + code_c = keyboard_layout.get("c") + + await asyncio.sleep(0.2) + self.assertListEqual( + self.result, + [ + (EV_KEY, code_a, 1), + (EV_KEY, code_b, 1), + (EV_KEY, code_c, 1), + ], + ) + + macro.release_trigger() + + await asyncio.sleep(0.2) + self.assertListEqual( + self.result, + [ + (EV_KEY, code_a, 1), + (EV_KEY, code_b, 1), + (EV_KEY, code_c, 1), + (EV_KEY, code_c, 0), + (EV_KEY, code_b, 0), + (EV_KEY, code_a, 0), + ], + ) + + async def test_hold_keys_broken(self): + # Won't run any of the keys when one of them is invalid + macro = Parser.parse( + "set(foo, broken).hold_keys(a, $foo, c)", self.context, DummyMapping + ) + # press first + macro.press_trigger() + # then run, just like how it is going to happen during runtime + asyncio.ensure_future(macro.run(self.handler)) + await asyncio.sleep(0.2) + self.assertListEqual(self.result, []) + macro.release_trigger() + await asyncio.sleep(0.2) + self.assertListEqual(self.result, []) + + async def test_aldjfakl(self): + repeats = 5 + + macro = Parser.parse( + f"repeat({repeats}, key(k))", + self.context, + DummyMapping, + ) + + self.assertEqual(self.count_child_macros(macro), 1) + + async def test_run_plus_syntax(self): + macro = Parser.parse("a + b + c + d", self.context, DummyMapping) + + macro.press_trigger() + asyncio.ensure_future(macro.run(self.handler)) + await asyncio.sleep(0.2) + self.assertTrue(macro.tasks[0].is_holding()) + + # starting from the left, presses each one down + self.assertEqual(self.result[0], (EV_KEY, keyboard_layout.get("a"), 1)) + self.assertEqual(self.result[1], (EV_KEY, keyboard_layout.get("b"), 1)) + self.assertEqual(self.result[2], (EV_KEY, keyboard_layout.get("c"), 1)) + self.assertEqual(self.result[3], (EV_KEY, keyboard_layout.get("d"), 1)) + + # and then releases starting with the previously pressed key + macro.release_trigger() + await asyncio.sleep(0.2) + self.assertFalse(macro.tasks[0].is_holding()) + self.assertEqual(self.result[4], (EV_KEY, keyboard_layout.get("d"), 0)) + self.assertEqual(self.result[5], (EV_KEY, keyboard_layout.get("c"), 0)) + self.assertEqual(self.result[6], (EV_KEY, keyboard_layout.get("b"), 0)) + self.assertEqual(self.result[7], (EV_KEY, keyboard_layout.get("a"), 0)) + + async def test_raises_error(self): + self.assertRaises( + MacroError, Parser.parse, "hold_keys(a, broken, b)", self.context + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/test_macros/test_if_eq.py b/tests/unit/test_macros/test_if_eq.py new file mode 100644 index 0000000..bd8b036 --- /dev/null +++ b/tests/unit/test_macros/test_if_eq.py @@ -0,0 +1,215 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + + +import asyncio +import multiprocessing +import unittest + +from evdev.ecodes import ( + EV_KEY, +) + +from inputremapper.configs.keyboard_layout import keyboard_layout +from inputremapper.configs.validation_errors import MacroError +from inputremapper.injection.macros.macro import macro_variables +from inputremapper.injection.macros.parse import Parser +from tests.lib.logger import logger +from tests.lib.test_setup import test_setup +from tests.unit.test_macros.macro_test_base import DummyMapping, MacroTestBase + + +@test_setup +class TestIfEq(MacroTestBase): + async def test_if_eq(self): + """new version of ifeq""" + code_a = keyboard_layout.get("a") + code_b = keyboard_layout.get("b") + a_press = [(EV_KEY, code_a, 1), (EV_KEY, code_a, 0)] + b_press = [(EV_KEY, code_b, 1), (EV_KEY, code_b, 0)] + + async def test(macro, expected): + """Run the macro and compare the injections with an expectation.""" + logger.info("Testing %s", macro) + # cleanup + macro_variables._clear() + self.assertIsNone(macro_variables.get("a")) + self.result.clear() + + # test + macro = Parser.parse(macro, self.context, DummyMapping) + await macro.run(self.handler) + self.assertListEqual(self.result, expected) + + await test("if_eq(1, 1, key(a), key(b))", a_press) + await test("if_eq(1, 2, key(a), key(b))", b_press) + await test("if_eq(value_1=1, value_2=1, then=key(a), else=key(b))", a_press) + await test('set(a, "foo").if_eq($a, "foo", key(a), key(b))', a_press) + await test('set(a, "foo").if_eq("foo", $a, key(a), key(b))', a_press) + await test('set(a, "foo").if_eq("foo", $a, , key(b))', []) + await test('set(a, "foo").if_eq("foo", $a, None, key(b))', []) + await test('set(a, "qux").if_eq("foo", $a, key(a), key(b))', b_press) + await test('set(a, "qux").if_eq($a, "foo", key(a), key(b))', b_press) + await test('set(a, "qux").if_eq($a, "foo", key(a), )', []) + await test('set(a, "x").set(b, "y").if_eq($b, $a, key(a), key(b))', b_press) + await test('set(a, "x").set(b, "y").if_eq($b, $a, key(a), )', []) + await test('set(a, "x").set(b, "y").if_eq($b, $a, key(a), None)', []) + await test('set(a, "x").set(b, "y").if_eq($b, $a, key(a), else=None)', []) + await test('set(a, "x").set(b, "x").if_eq($b, $a, key(a), key(b))', a_press) + await test('set(a, "x").set(b, "x").if_eq($b, $a, , key(b))', []) + await test("if_eq($q, $w, key(a), else=key(b))", a_press) # both None + await test("set(q, 1).if_eq($q, $w, key(a), else=key(b))", b_press) + await test("set(q, 1).set(w, 1).if_eq($q, $w, key(a), else=key(b))", a_press) + await test('set(q, " a b ").if_eq($q, " a b ", key(a), key(b))', a_press) + await test('if_eq("\t", "\n", key(a), key(b))', b_press) + + # treats values in quotes as strings, not as code + await test('set(q, "$a").if_eq($q, "$a", key(a), key(b))', a_press) + await test('set(q, "a,b").if_eq("a,b", $q, key(a), key(b))', a_press) + await test('set(q, "c(1, 2)").if_eq("c(1, 2)", $q, key(a), key(b))', a_press) + await test('set(q, "c(1, 2)").if_eq("c(1, 2)", "$q", key(a), key(b))', b_press) + await test('if_eq("value_1=1", 1, key(a), key(b))', b_press) + + # won't compare strings and int, be similar to python + await test('set(a, "1").if_eq($a, 1, key(a), key(b))', b_press) + await test('set(a, 1).if_eq($a, "1", key(a), key(b))', b_press) + + async def test_if_eq_runs_multiprocessed(self): + """ifeq on variables that have been set in other processes works.""" + macro = Parser.parse( + "if_eq($foo, 3, key(a), key(b))", self.context, DummyMapping + ) + code_a = keyboard_layout.get("a") + code_b = keyboard_layout.get("b") + + self.assertEqual(self.count_child_macros(macro), 2) + + def set_foo(value): + # will write foo = 2 into the shared dictionary of macros + macro_2 = Parser.parse(f"set(foo, {value})", self.context, DummyMapping) + loop = asyncio.new_event_loop() + loop.run_until_complete(macro_2.run(lambda: None)) + + """foo is not 3""" + + process = multiprocessing.Process(target=set_foo, args=(2,)) + process.start() + process.join() + await macro.run(self.handler) + self.assertListEqual(self.result, [(EV_KEY, code_b, 1), (EV_KEY, code_b, 0)]) + + """foo is 3""" + + process = multiprocessing.Process(target=set_foo, args=(3,)) + process.start() + process.join() + await macro.run(self.handler) + self.assertListEqual( + self.result, + [ + (EV_KEY, code_b, 1), + (EV_KEY, code_b, 0), + (EV_KEY, code_a, 1), + (EV_KEY, code_a, 0), + ], + ) + + async def test_raises_error(self): + Parser.parse("if_eq(2, $a, k(a),)", self.context) # no error + Parser.parse("if_eq(2, $a, , else=k(a))", self.context) # no error + self.assertRaises(MacroError, Parser.parse, "if_eq(2, $a, 1,)", self.context) + self.assertRaises(MacroError, Parser.parse, "if_eq(2, $a, , 2)", self.context) + self.expect_string_in_error("blub", "if_eq(2, $a, key(a), blub=a)") + + +class TestIfEqDeprecated(MacroTestBase): + async def test_ifeq_runs(self): + # deprecated ifeq function, but kept for compatibility reasons + macro = Parser.parse( + "set(foo, 2).ifeq(foo, 2, key(a), key(b))", + self.context, + DummyMapping, + ) + code_a = keyboard_layout.get("a") + code_b = keyboard_layout.get("b") + + await macro.run(self.handler) + self.assertListEqual(self.result, [(EV_KEY, code_a, 1), (EV_KEY, code_a, 0)]) + self.assertEqual(self.count_child_macros(macro), 2) + + async def test_ifeq_none(self): + code_a = keyboard_layout.get("a") + + # first param None + macro = Parser.parse( + "set(foo, 2).ifeq(foo, 2, None, key(b))", self.context, DummyMapping + ) + self.assertEqual(self.count_child_macros(macro), 1) + await macro.run(self.handler) + self.assertListEqual(self.result, []) + + # second param None + self.result = [] + macro = Parser.parse( + "set(foo, 2).ifeq(foo, 2, key(a), None)", self.context, DummyMapping + ) + self.assertEqual(self.count_child_macros(macro), 1) + await macro.run(self.handler) + self.assertListEqual(self.result, [(EV_KEY, code_a, 1), (EV_KEY, code_a, 0)]) + + """Old syntax, use None instead""" + + # first param "" + self.result = [] + macro = Parser.parse( + "set(foo, 2).ifeq(foo, 2, , key(b))", self.context, DummyMapping + ) + self.assertEqual(self.count_child_macros(macro), 1) + await macro.run(self.handler) + self.assertListEqual(self.result, []) + + # second param "" + self.result = [] + macro = Parser.parse( + "set(foo, 2).ifeq(foo, 2, key(a), )", self.context, DummyMapping + ) + self.assertEqual(self.count_child_macros(macro), 1) + await macro.run(self.handler) + self.assertListEqual(self.result, [(EV_KEY, code_a, 1), (EV_KEY, code_a, 0)]) + + async def test_ifeq_unknown_key(self): + macro = Parser.parse("ifeq(qux, 2, key(a), key(b))", self.context, DummyMapping) + code_a = keyboard_layout.get("a") + code_b = keyboard_layout.get("b") + + await macro.run(self.handler) + self.assertListEqual(self.result, [(EV_KEY, code_b, 1), (EV_KEY, code_b, 0)]) + self.assertEqual(self.count_child_macros(macro), 2) + + async def test_raises_error(self): + Parser.parse("ifeq(a, 2, k(a),)", self.context) # no error + Parser.parse("ifeq(a, 2, , k(a))", self.context) # no error + Parser.parse("ifeq(a, 2, None, k(a))", self.context) # no error + self.assertRaises(MacroError, Parser.parse, "ifeq(a, 2, 1,)", self.context) + self.assertRaises(MacroError, Parser.parse, "ifeq(a, 2, , 2)", self.context) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/test_macros/test_if_single.py b/tests/unit/test_macros/test_if_single.py new file mode 100644 index 0000000..f439e2f --- /dev/null +++ b/tests/unit/test_macros/test_if_single.py @@ -0,0 +1,192 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + + +import asyncio +import unittest + +from evdev.ecodes import ( + EV_KEY, + ABS_Y, +) + +from inputremapper.configs.keyboard_layout import keyboard_layout +from inputremapper.configs.validation_errors import MacroError +from inputremapper.injection.macros.parse import Parser +from inputremapper.input_event import InputEvent +from tests.lib.test_setup import test_setup +from tests.unit.test_macros.macro_test_base import DummyMapping, MacroTestBase + + +@test_setup +class TestIfSingle(MacroTestBase): + async def test_if_single(self): + macro = Parser.parse("if_single(key(x), key(y))", self.context, DummyMapping) + self.assertEqual(self.count_child_macros(macro), 2) + + a = keyboard_layout.get("a") + x = keyboard_layout.get("x") + + await self.trigger_sequence(macro, InputEvent.key(a, 1)) + await asyncio.sleep(0.1) + await self.release_sequence(macro, InputEvent.key(a, 0)) + # the key that triggered the macro is released + await asyncio.sleep(0.1) + + self.assertListEqual(self.result, [(EV_KEY, x, 1), (EV_KEY, x, 0)]) + self.assertFalse(macro.running) + + async def test_if_single_ignores_releases(self): + # the timeout won't break the macro, everything happens well within that + # timeframe. + macro = Parser.parse( + "if_single(key(x), else=key(y), timeout=100000)", + self.context, + DummyMapping, + ) + self.assertEqual(self.count_child_macros(macro), 2) + + a = keyboard_layout.get("a") + b = keyboard_layout.get("b") + + x = keyboard_layout.get("x") + y = keyboard_layout.get("y") + + # pressing the macro key + await self.trigger_sequence(macro, InputEvent.key(a, 1)) + await asyncio.sleep(0.05) + + # if_single only looks out for newly pressed keys, + # it doesn't care if keys were released that have been + # pressed before if_single. This was decided because it is a lot + # less tricky and more fluently to use if you type fast + for listener in self.context.listeners: + asyncio.ensure_future(listener(InputEvent.key(b, 0))) + await asyncio.sleep(0.05) + self.assertListEqual(self.result, []) + + # releasing the actual key triggers if_single + await asyncio.sleep(0.05) + await self.release_sequence(macro, InputEvent.key(a, 0)) + await asyncio.sleep(0.05) + self.assertListEqual(self.result, [(EV_KEY, x, 1), (EV_KEY, x, 0)]) + self.assertFalse(macro.running) + + async def test_if_not_single(self): + # Will run the `else` macro if another key is pressed. + # Also works if if_single is a child macro, i.e. the event is passed to it + # from the outside macro correctly. + macro = Parser.parse( + "repeat(1, if_single(then=key(x), else=key(y)))", + self.context, + DummyMapping, + ) + self.assertEqual(self.count_child_macros(macro), 3) + self.assertEqual(self.count_tasks(macro), 4) + + a = keyboard_layout.get("a") + b = keyboard_layout.get("b") + + x = keyboard_layout.get("x") + y = keyboard_layout.get("y") + + # press the trigger key + await self.trigger_sequence(macro, InputEvent.key(a, 1)) + await asyncio.sleep(0.1) + # press another key + for listener in self.context.listeners: + asyncio.ensure_future(listener(InputEvent.key(b, 1))) + await asyncio.sleep(0.1) + + self.assertListEqual(self.result, [(EV_KEY, y, 1), (EV_KEY, y, 0)]) + self.assertFalse(macro.running) + + async def test_if_not_single_none(self): + macro = Parser.parse("if_single(key(x),)", self.context, DummyMapping) + self.assertEqual(self.count_child_macros(macro), 1) + + a = keyboard_layout.get("a") + b = keyboard_layout.get("b") + + x = keyboard_layout.get("x") + + # press trigger key + await self.trigger_sequence(macro, InputEvent.key(a, 1)) + await asyncio.sleep(0.1) + # press another key + for listener in self.context.listeners: + asyncio.ensure_future(listener(InputEvent.key(b, 1))) + await asyncio.sleep(0.1) + + self.assertListEqual(self.result, []) + self.assertFalse(macro.running) + + async def test_if_single_times_out(self): + macro = Parser.parse( + "set(t, 300).if_single(key(x), key(y), timeout=$t)", + self.context, + DummyMapping, + ) + self.assertEqual(self.count_child_macros(macro), 2) + + a = keyboard_layout.get("a") + y = keyboard_layout.get("y") + + await self.trigger_sequence(macro, InputEvent.key(a, 1)) + + # no timeout yet + await asyncio.sleep(0.2) + self.assertListEqual(self.result, []) + self.assertTrue(macro.running) + + # times out now + await asyncio.sleep(0.2) + self.assertListEqual(self.result, [(EV_KEY, y, 1), (EV_KEY, y, 0)]) + self.assertFalse(macro.running) + + async def test_if_single_ignores_joystick(self): + """Triggers else + delayed_handle_keycode.""" + # Integration test style for if_single. + # If a joystick that is mapped to a button is moved, if_single stops + macro = Parser.parse( + "if_single(k(a), k(KEY_LEFTSHIFT))", self.context, DummyMapping + ) + code_shift = keyboard_layout.get("KEY_LEFTSHIFT") + code_a = keyboard_layout.get("a") + trigger = 1 + + await self.trigger_sequence(macro, InputEvent.key(trigger, 1)) + await asyncio.sleep(0.1) + for listener in self.context.listeners: + asyncio.ensure_future(listener(InputEvent.abs(ABS_Y, 10))) + await asyncio.sleep(0.1) + await self.release_sequence(macro, InputEvent.key(trigger, 0)) + await asyncio.sleep(0.1) + self.assertFalse(macro.running) + self.assertListEqual(self.result, [(EV_KEY, code_a, 1), (EV_KEY, code_a, 0)]) + + async def test_raises_error(self): + Parser.parse("if_single(k(a),)", self.context) # no error + self.assertRaises(MacroError, Parser.parse, "if_single(1,)", self.context) + self.assertRaises(MacroError, Parser.parse, "if_single(,1)", self.context) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/test_macros/test_if_tap.py b/tests/unit/test_macros/test_if_tap.py new file mode 100644 index 0000000..92330ab --- /dev/null +++ b/tests/unit/test_macros/test_if_tap.py @@ -0,0 +1,190 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + + +import asyncio +import unittest + +from evdev.ecodes import ( + EV_KEY, + KEY_A, + KEY_B, +) + +from inputremapper.configs.keyboard_layout import keyboard_layout +from inputremapper.configs.validation_errors import MacroError +from inputremapper.injection.macros.parse import Parser +from tests.lib.test_setup import test_setup +from tests.unit.test_macros.macro_test_base import DummyMapping, MacroTestBase + + +@test_setup +class TestIfTap(MacroTestBase): + async def test_if_tap(self): + macro = Parser.parse("if_tap(key(x), key(y), 100)", self.context, DummyMapping) + self.assertEqual(self.count_child_macros(macro), 2) + + x = keyboard_layout.get("x") + y = keyboard_layout.get("y") + + # this is the regular routine of how a macro is started. the tigger is pressed + # already when the macro runs, and released during if_tap within the timeout. + macro.press_trigger() + asyncio.ensure_future(macro.run(self.handler)) + await asyncio.sleep(0.05) + macro.release_trigger() + await asyncio.sleep(0.05) + + self.assertListEqual(self.result, [(EV_KEY, x, 1), (EV_KEY, x, 0)]) + self.assertFalse(macro.running) + + async def test_if_tap_2(self): + # when the press arrives shortly after run. + # a tap will happen within the timeout even if the tigger is not pressed when + # it does into if_tap + macro = Parser.parse("if_tap(key(a), key(b), 100)", self.context, DummyMapping) + asyncio.ensure_future(macro.run(self.handler)) + + await asyncio.sleep(0.01) + macro.press_trigger() + await asyncio.sleep(0.01) + macro.release_trigger() + await asyncio.sleep(0.2) + self.assertListEqual(self.result, [(EV_KEY, KEY_A, 1), (EV_KEY, KEY_A, 0)]) + self.assertFalse(macro.running) + self.result.clear() + + async def test_if_double_tap(self): + macro = Parser.parse( + "if_tap(if_tap(key(a), key(b), 100), key(c), 100)", + self.context, + DummyMapping, + ) + self.assertEqual(self.count_child_macros(macro), 4) + self.assertEqual(self.count_tasks(macro), 5) + + asyncio.ensure_future(macro.run(self.handler)) + + # first tap + macro.press_trigger() + await asyncio.sleep(0.05) + macro.release_trigger() + + # second tap + await asyncio.sleep(0.04) + macro.press_trigger() + await asyncio.sleep(0.04) + macro.release_trigger() + + await asyncio.sleep(0.05) + self.assertListEqual(self.result, [(EV_KEY, KEY_A, 1), (EV_KEY, KEY_A, 0)]) + self.assertFalse(macro.running) + self.result.clear() + + """If the second tap takes too long, runs else there""" + + asyncio.ensure_future(macro.run(self.handler)) + + # first tap + macro.press_trigger() + await asyncio.sleep(0.05) + macro.release_trigger() + + # second tap + await asyncio.sleep(0.06) + macro.press_trigger() + await asyncio.sleep(0.06) + macro.release_trigger() + + await asyncio.sleep(0.05) + self.assertListEqual(self.result, [(EV_KEY, KEY_B, 1), (EV_KEY, KEY_B, 0)]) + self.assertFalse(macro.running) + self.result.clear() + + async def test_if_tap_none(self): + # first param none + macro = Parser.parse("if_tap(, key(y), 100)", self.context, DummyMapping) + self.assertEqual(self.count_child_macros(macro), 1) + y = keyboard_layout.get("y") + macro.press_trigger() + asyncio.ensure_future(macro.run(self.handler)) + await asyncio.sleep(0.05) + macro.release_trigger() + await asyncio.sleep(0.05) + self.assertListEqual(self.result, []) + + # second param none + macro = Parser.parse("if_tap(key(y), , 50)", self.context, DummyMapping) + self.assertEqual(self.count_child_macros(macro), 1) + y = keyboard_layout.get("y") + macro.press_trigger() + asyncio.ensure_future(macro.run(self.handler)) + await asyncio.sleep(0.1) + macro.release_trigger() + await asyncio.sleep(0.05) + self.assertListEqual(self.result, []) + + self.assertFalse(macro.running) + + async def test_if_not_tap(self): + macro = Parser.parse("if_tap(key(x), key(y), 50)", self.context, DummyMapping) + self.assertEqual(self.count_child_macros(macro), 2) + + y = keyboard_layout.get("y") + + macro.press_trigger() + asyncio.ensure_future(macro.run(self.handler)) + await asyncio.sleep(0.1) + macro.release_trigger() + await asyncio.sleep(0.05) + + self.assertListEqual(self.result, [(EV_KEY, y, 1), (EV_KEY, y, 0)]) + self.assertFalse(macro.running) + + async def test_if_not_tap_named(self): + macro = Parser.parse( + "if_tap(key(x), key(y), timeout=50)", self.context, DummyMapping + ) + self.assertEqual(self.count_child_macros(macro), 2) + + x = keyboard_layout.get("x") + y = keyboard_layout.get("y") + + macro.press_trigger() + asyncio.ensure_future(macro.run(self.handler)) + await asyncio.sleep(0.1) + macro.release_trigger() + await asyncio.sleep(0.05) + + self.assertListEqual(self.result, [(EV_KEY, y, 1), (EV_KEY, y, 0)]) + self.assertFalse(macro.running) + + async def test_raises_error(self): + Parser.parse("if_tap(, k(a), 1000)", self.context) # no error + Parser.parse("if_tap(, k(a), timeout=1000)", self.context) # no error + Parser.parse("if_tap(, k(a), $timeout)", self.context) # no error + Parser.parse("if_tap(, k(a), timeout=$t)", self.context) # no error + Parser.parse("if_tap(, key(a))", self.context) # no error + Parser.parse("if_tap(k(a),)", self.context) # no error + self.assertRaises(MacroError, Parser.parse, "if_tap(k(a), b)", self.context) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/test_macros/test_key.py b/tests/unit/test_macros/test_key.py new file mode 100644 index 0000000..8504d08 --- /dev/null +++ b/tests/unit/test_macros/test_key.py @@ -0,0 +1,123 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + + +import unittest + +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.macros.parse import Parser +from tests.lib.test_setup import test_setup +from tests.unit.test_macros.macro_test_base import DummyMapping, MacroTestBase + + +@test_setup +class TestKey(MacroTestBase): + async def test_1(self): + macro = Parser.parse("key(1)", self.context, DummyMapping, True) + one_code = keyboard_layout.get("1") + + await macro.run(self.handler) + self.assertListEqual( + self.result, + [(EV_KEY, one_code, 1), (EV_KEY, one_code, 0)], + ) + self.assertEqual(self.count_child_macros(macro), 0) + + async def test_named_parameter(self): + macro = Parser.parse("key(symbol=1)", self.context, DummyMapping, True) + one_code = keyboard_layout.get("1") + + await macro.run(self.handler) + self.assertListEqual( + self.result, + [(EV_KEY, one_code, 1), (EV_KEY, one_code, 0)], + ) + self.assertEqual(self.count_child_macros(macro), 0) + + async def test_2(self): + macro = Parser.parse('key(1).key("KEY_A").key(3)', self.context, DummyMapping) + + await macro.run(self.handler) + self.assertListEqual( + self.result, + [ + (EV_KEY, keyboard_layout.get("1"), 1), + (EV_KEY, keyboard_layout.get("1"), 0), + (EV_KEY, keyboard_layout.get("a"), 1), + (EV_KEY, keyboard_layout.get("a"), 0), + (EV_KEY, keyboard_layout.get("3"), 1), + (EV_KEY, keyboard_layout.get("3"), 0), + ], + ) + self.assertEqual(self.count_child_macros(macro), 0) + + async def test_key_down_up(self): + code_a = keyboard_layout.get("a") + code_b = keyboard_layout.get("b") + macro = Parser.parse( + "set(foo, b).key_down($foo).key_up($foo).key_up(a).key_down(a)", + self.context, + DummyMapping, + ) + await macro.run(self.handler) + self.assertListEqual( + self.result, + [ + (EV_KEY, code_b, 1), + (EV_KEY, code_b, 0), + (EV_KEY, code_a, 0), + (EV_KEY, code_a, 1), + ], + ) + + async def test_raises_error(self): + Parser.parse("k(1).h(k(a)).k(3)", self.context) # No error + self.expect_string_in_error("bracket", "key((1)") + self.expect_string_in_error("bracket", "k(1))") + self.assertRaises(MacroError, Parser.parse, "k((1).k)", self.context) + self.assertRaises(MacroError, Parser.parse, "key(foo=a)", self.context) + self.assertRaises( + MacroError, Parser.parse, "key(symbol=a, foo=b)", self.context + ) + self.assertRaises(MacroError, Parser.parse, "k()", self.context) + self.assertRaises(MacroError, Parser.parse, "key(invalidkey)", self.context) + self.assertRaises(MacroError, Parser.parse, 'key("invalidkey")', self.context) + Parser.parse("key(1)", self.context) # no error + self.assertRaises(MacroError, Parser.parse, "k(1, 1)", self.context) + Parser.parse("key($a)", self.context) # no error + self.assertRaises(MacroError, Parser.parse, "key(a)key(b)", self.context) + # wrong target for BTN_A + self.assertRaises( + SymbolNotAvailableInTargetError, + Parser.parse, + "key(BTN_A)", + self.context, + DummyMapping, + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/test_macros/test_leds.py b/tests/unit/test_macros/test_leds.py new file mode 100644 index 0000000..ec99edd --- /dev/null +++ b/tests/unit/test_macros/test_leds.py @@ -0,0 +1,124 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + + +import unittest +from unittest.mock import patch + +from evdev.ecodes import ( + EV_KEY, + KEY_1, + KEY_2, + LED_CAPSL, + LED_NUML, +) + +from inputremapper.injection.macros.parse import Parser +from tests.lib.test_setup import test_setup +from tests.unit.test_macros.macro_test_base import DummyMapping, MacroTestBase + + +@test_setup +class TestLeds(MacroTestBase): + async def test_if_capslock(self): + macro = Parser.parse( + "if_capslock(key(KEY_1), key(KEY_2))", + self.context, + DummyMapping, + True, + ) + self.assertEqual(self.count_child_macros(macro), 2) + + with patch.object(self.source_device, "leds", side_effect=lambda: [LED_NUML]): + await macro.run(self.handler) + self.assertListEqual(self.result, [(EV_KEY, KEY_2, 1), (EV_KEY, KEY_2, 0)]) + + with patch.object(self.source_device, "leds", side_effect=lambda: [LED_CAPSL]): + self.result = [] + await macro.run(self.handler) + self.assertListEqual(self.result, [(EV_KEY, KEY_1, 1), (EV_KEY, KEY_1, 0)]) + + async def test_if_numlock(self): + macro = Parser.parse( + "if_numlock(key(KEY_1), key(KEY_2))", + self.context, + DummyMapping, + True, + ) + self.assertEqual(self.count_child_macros(macro), 2) + + with patch.object(self.source_device, "leds", side_effect=lambda: [LED_NUML]): + await macro.run(self.handler) + self.assertListEqual(self.result, [(EV_KEY, KEY_1, 1), (EV_KEY, KEY_1, 0)]) + + with patch.object(self.source_device, "leds", side_effect=lambda: [LED_CAPSL]): + self.result = [] + await macro.run(self.handler) + self.assertListEqual(self.result, [(EV_KEY, KEY_2, 1), (EV_KEY, KEY_2, 0)]) + + async def test_if_numlock_no_else(self): + macro = Parser.parse( + "if_numlock(key(KEY_1))", + self.context, + DummyMapping, + True, + ) + self.assertEqual(self.count_child_macros(macro), 1) + + with patch.object(self.source_device, "leds", side_effect=lambda: [LED_CAPSL]): + await macro.run(self.handler) + self.assertListEqual(self.result, []) + + with patch.object(self.source_device, "leds", side_effect=lambda: [LED_NUML]): + self.result = [] + await macro.run(self.handler) + self.assertListEqual(self.result, [(EV_KEY, KEY_1, 1), (EV_KEY, KEY_1, 0)]) + + async def test_if_capslock_no_then(self): + macro = Parser.parse( + "if_capslock(None, key(KEY_1))", + self.context, + DummyMapping, + True, + ) + self.assertEqual(self.count_child_macros(macro), 1) + + with patch.object(self.source_device, "leds", side_effect=lambda: [LED_CAPSL]): + await macro.run(self.handler) + self.assertListEqual(self.result, []) + + with patch.object(self.source_device, "leds", side_effect=lambda: [LED_NUML]): + self.result = [] + await macro.run(self.handler) + self.assertListEqual(self.result, [(EV_KEY, KEY_1, 1), (EV_KEY, KEY_1, 0)]) + + async def test_raises_error(self): + Parser.parse("if_capslock(else=key(KEY_A))", self.context) # no error + Parser.parse("if_capslock(key(KEY_A), None)", self.context) # no error + Parser.parse("if_capslock(key(KEY_A))", self.context) # no error + Parser.parse("if_capslock(then=key(KEY_A))", self.context) # no error + Parser.parse("if_numlock(else=key(KEY_A))", self.context) # no error + Parser.parse("if_numlock(key(KEY_A), None)", self.context) # no error + Parser.parse("if_numlock(key(KEY_A))", self.context) # no error + Parser.parse("if_numlock(then=key(KEY_A))", self.context) # no error + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/test_macros/test_macros.py b/tests/unit/test_macros/test_macros.py new file mode 100644 index 0000000..d7a98f8 --- /dev/null +++ b/tests/unit/test_macros/test_macros.py @@ -0,0 +1,167 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + + +import asyncio +import time +import unittest + +from evdev.ecodes import ( + EV_KEY, +) + +from inputremapper.configs.keyboard_layout import keyboard_layout +from inputremapper.injection.macros.macro import Macro +from inputremapper.injection.macros.parse import Parser +from tests.lib.test_setup import test_setup +from tests.unit.test_macros.macro_test_base import MacroTestBase, DummyMapping + + +@test_setup +class TestMacros(MacroTestBase): + async def test_newlines(self): + macro = Parser.parse( + " repeat(2,\nkey(\nr ).key(minus\n )).key(m) ", + self.context, + DummyMapping, + ) + + r = keyboard_layout.get("r") + minus = keyboard_layout.get("minus") + m = keyboard_layout.get("m") + + await macro.run(self.handler) + self.assertListEqual( + self.result, + [ + (EV_KEY, r, 1), + (EV_KEY, r, 0), + (EV_KEY, minus, 1), + (EV_KEY, minus, 0), + (EV_KEY, r, 1), + (EV_KEY, r, 0), + (EV_KEY, minus, 1), + (EV_KEY, minus, 0), + (EV_KEY, m, 1), + (EV_KEY, m, 0), + ], + ) + self.assertEqual(self.count_child_macros(macro), 1) + self.assertEqual(self.count_tasks(macro), 4) + + async def test_various(self): + start = time.time() + macro = Parser.parse( + "w(200).repeat(2,modify(w,\nrepeat(2,\tkey(BtN_LeFt))).w(10).key(k))", + self.context, + DummyMapping, + ) + + self.assertEqual(self.count_child_macros(macro), 3) + self.assertEqual(self.count_tasks(macro), 7) + + w = keyboard_layout.get("w") + left = keyboard_layout.get("bTn_lEfT") + k = keyboard_layout.get("k") + + await macro.run(self.handler) + + num_pauses = 8 + 6 + 4 + keystroke_time = num_pauses * DummyMapping.macro_key_sleep_ms + wait_time = 220 + total_time = (keystroke_time + wait_time) / 1000 + + self.assertLess(time.time() - start, total_time * 1.2) + self.assertGreater(time.time() - start, total_time * 0.9) + expected = [(EV_KEY, w, 1)] + expected += [(EV_KEY, left, 1), (EV_KEY, left, 0)] * 2 + expected += [(EV_KEY, w, 0)] + expected += [(EV_KEY, k, 1), (EV_KEY, k, 0)] + expected *= 2 + self.assertListEqual(self.result, expected) + + async def test_not_run(self): + # does nothing without .run + macro = Parser.parse("key(a).repeat(3, key(b))", self.context) + self.assertIsInstance(macro, Macro) + self.assertListEqual(self.result, []) + + async def test_duplicate_run(self): + # it won't restart the macro, because that may screw up the + # internal state (in particular the _trigger_release_event). + # I actually don't know at all what kind of bugs that might produce, + # lets just avoid it. It might cause it to be held down forever. + a = keyboard_layout.get("a") + b = keyboard_layout.get("b") + c = keyboard_layout.get("c") + + macro = Parser.parse( + "key(a).modify(b, hold()).key(c)", self.context, DummyMapping + ) + asyncio.ensure_future(macro.run(self.handler)) + self.assertFalse(macro.tasks[1].child_macros[0].tasks[0].is_holding()) + + asyncio.ensure_future(macro.run(self.handler)) # ignored + self.assertFalse(macro.tasks[1].child_macros[0].tasks[0].is_holding()) + + macro.press_trigger() + await asyncio.sleep(0.2) + self.assertTrue(macro.tasks[1].child_macros[0].tasks[0].is_holding()) + + asyncio.ensure_future(macro.run(self.handler)) # ignored + self.assertTrue(macro.tasks[1].child_macros[0].tasks[0].is_holding()) + + macro.release_trigger() + await asyncio.sleep(0.2) + self.assertFalse(macro.tasks[1].child_macros[0].tasks[0].is_holding()) + + expected = [ + (EV_KEY, a, 1), + (EV_KEY, a, 0), + (EV_KEY, b, 1), + (EV_KEY, b, 0), + (EV_KEY, c, 1), + (EV_KEY, c, 0), + ] + self.assertListEqual(self.result, expected) + + """not ignored, since previous run is over""" + + asyncio.ensure_future(macro.run(self.handler)) + macro.press_trigger() + await asyncio.sleep(0.2) + self.assertTrue(macro.tasks[1].child_macros[0].tasks[0].is_holding()) + macro.release_trigger() + await asyncio.sleep(0.2) + self.assertFalse(macro.tasks[1].child_macros[0].tasks[0].is_holding()) + + expected = [ + (EV_KEY, a, 1), + (EV_KEY, a, 0), + (EV_KEY, b, 1), + (EV_KEY, b, 0), + (EV_KEY, c, 1), + (EV_KEY, c, 0), + ] * 2 + self.assertListEqual(self.result, expected) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/test_macros/test_mod_tap.py b/tests/unit/test_macros/test_mod_tap.py new file mode 100644 index 0000000..43b00f3 --- /dev/null +++ b/tests/unit/test_macros/test_mod_tap.py @@ -0,0 +1,385 @@ +import asyncio +import time +import unittest +from unittest.mock import patch + +import evdev +from evdev.ecodes import KEY_A, EV_KEY, KEY_B, KEY_LEFTSHIFT, KEY_C + +from inputremapper.configs.input_config import InputConfig +from inputremapper.configs.mapping import Mapping +from inputremapper.configs.preset import Preset +from inputremapper.injection.context import Context +from inputremapper.injection.event_reader import EventReader +from inputremapper.injection.global_uinputs import GlobalUInputs, UInput +from inputremapper.injection.macros.parse import Parser +from inputremapper.injection.mapping_handlers.mapping_parser import MappingParser +from inputremapper.input_event import InputEvent +from tests.lib.fixtures import fixtures +from tests.lib.patches import InputDevice +from tests.lib.pipes import uinput_write_history +from tests.lib.test_setup import test_setup +from tests.unit.test_macros.macro_test_base import MacroTestBase, DummyMapping + + +@test_setup +class TestModTapIntegration(unittest.IsolatedAsyncioTestCase): + # Testcases are from https://github.com/qmk/qmk_firmware/blob/78a0adfbb4d2c4e12f93f2a62ded0020d406243e/docs/tap_hold.md#nested-tap-abba-nested-tap + # This test-setup is a bit more involved, because I needed to modify the + # EventReader as well for this to work. + + def setUp(self): + self.origin_hash = fixtures.bar_device.get_device_hash() + self.forward_uinput = evdev.UInput(name="test-forward-uinput") + self.source_device = InputDevice(fixtures.bar_device.path) + self.stop_event = asyncio.Event() + self.global_uinputs = GlobalUInputs(UInput) + self.global_uinputs.prepare_all() + self.target_uinput = self.global_uinputs.get_uinput("keyboard") + self.mapping_parser = MappingParser(self.global_uinputs) + + self.mapping = Mapping.from_combination( + input_combination=[ + InputConfig( + type=EV_KEY, + code=KEY_A, + origin_hash=self.origin_hash, + ) + ], + output_symbol="mod_tap(a, Shift_L)", + ) + + self.preset = Preset() + self.preset.add(self.mapping) + + self.bootstrap_event_reader() + + self.loop = asyncio.new_event_loop() + asyncio.set_event_loop(self.loop) + + def bootstrap_event_reader(self): + self.context = Context( + self.preset, + source_devices={self.origin_hash: self.source_device}, + forward_devices={self.origin_hash: self.forward_uinput}, + mapping_parser=self.mapping_parser, + ) + + self.event_reader = EventReader( + self.context, + self.source_device, + self.stop_event, + ) + + def write(self, type_, code, value): + self.target_uinput.write_event(InputEvent.from_tuple((type_, code, value))) + + async def input(self, type_, code, value): + asyncio.ensure_future( + self.event_reader.handle( + InputEvent.from_tuple( + ( + type_, + code, + value, + ), + origin_hash=self.origin_hash, + ) + ) + ) + # Make the main_loop iterate a bit for the event_reader to do its thing. + await asyncio.sleep(0) + + async def test_distinct_taps_1(self): + await self.input(EV_KEY, KEY_A, 1) + await asyncio.sleep(0.190) + await self.input(EV_KEY, KEY_A, 0) + await asyncio.sleep(0.020) # exceeds tapping_term here + await self.input(EV_KEY, KEY_B, 1) + await asyncio.sleep(0.020) + await self.input(EV_KEY, KEY_B, 0) + + self.assertEqual( + uinput_write_history, + [ + InputEvent.from_tuple((EV_KEY, KEY_A, 1)), + InputEvent.from_tuple((EV_KEY, KEY_A, 0)), + InputEvent.from_tuple((EV_KEY, KEY_B, 1)), + InputEvent.from_tuple((EV_KEY, KEY_B, 0)), + ], + ) + self.assertEqual( + self.target_uinput.write_history, + [ + InputEvent.from_tuple((EV_KEY, KEY_A, 1)), + InputEvent.from_tuple((EV_KEY, KEY_A, 0)), + ], + ) + self.assertEqual( + self.forward_uinput.write_history, + [ + InputEvent.from_tuple((EV_KEY, KEY_B, 1)), + InputEvent.from_tuple((EV_KEY, KEY_B, 0)), + ], + ) + + async def test_distinct_taps_2(self): + await self.input(EV_KEY, KEY_A, 1) + await asyncio.sleep(0.220) # exceeds tapping_term here + await self.input(EV_KEY, KEY_A, 0) + await asyncio.sleep(0.020) + await self.input(EV_KEY, KEY_B, 1) + await asyncio.sleep(0.020) + await self.input(EV_KEY, KEY_B, 0) + + self.assertEqual( + uinput_write_history, + [ + InputEvent.from_tuple((EV_KEY, KEY_LEFTSHIFT, 1)), + InputEvent.from_tuple((EV_KEY, KEY_LEFTSHIFT, 0)), + InputEvent.from_tuple((EV_KEY, KEY_B, 1)), + InputEvent.from_tuple((EV_KEY, KEY_B, 0)), + ], + ) + self.assertEqual( + self.target_uinput.write_history, + [ + InputEvent.from_tuple((EV_KEY, KEY_LEFTSHIFT, 1)), + InputEvent.from_tuple((EV_KEY, KEY_LEFTSHIFT, 0)), + ], + ) + self.assertEqual( + self.forward_uinput.write_history, + [ + InputEvent.from_tuple((EV_KEY, KEY_B, 1)), + InputEvent.from_tuple((EV_KEY, KEY_B, 0)), + ], + ) + + async def test_nested_tap_1(self): + await self.input(EV_KEY, KEY_A, 1) + await asyncio.sleep(0.100) + self.assertEqual(uinput_write_history, []) + + await self.input(EV_KEY, KEY_B, 1) + await asyncio.sleep(0.020) + self.assertEqual(uinput_write_history, []) + + await self.input(EV_KEY, KEY_B, 0) + await asyncio.sleep(0.050) + self.assertEqual(uinput_write_history, []) + + await self.input(EV_KEY, KEY_A, 0) + + # everything happened within the tapping_term, so the modifier is not activated. + # "ab" should be written, in the exact order of the input. + await asyncio.sleep(0.040) + self.assertEqual( + uinput_write_history, + [ + InputEvent.from_tuple((EV_KEY, KEY_A, 1)), + InputEvent.from_tuple((EV_KEY, KEY_B, 1)), + InputEvent.from_tuple((EV_KEY, KEY_B, 0)), + InputEvent.from_tuple((EV_KEY, KEY_A, 0)), + ], + ) + + async def test_nested_tap_2(self): + await self.input(EV_KEY, KEY_A, 1) + await asyncio.sleep(0.100) + await self.input(EV_KEY, KEY_B, 1) + await asyncio.sleep(0.020) + await self.input(EV_KEY, KEY_B, 0) + await asyncio.sleep(0.100) # exceeds tapping_term here + await self.input(EV_KEY, KEY_A, 0) + + await asyncio.sleep(0.020) + self.assertEqual( + uinput_write_history, + [ + InputEvent.from_tuple((EV_KEY, KEY_LEFTSHIFT, 1)), + InputEvent.from_tuple((EV_KEY, KEY_B, 1)), + InputEvent.from_tuple((EV_KEY, KEY_B, 0)), + InputEvent.from_tuple((EV_KEY, KEY_LEFTSHIFT, 0)), + ], + ) + + async def test_nested_tap_3(self): + await self.input(EV_KEY, KEY_A, 1) + await asyncio.sleep(0.220) # exceeds tapping_term here + await self.input(EV_KEY, KEY_B, 1) + await asyncio.sleep(0.020) + await self.input(EV_KEY, KEY_B, 0) + await asyncio.sleep(0.020) + await self.input(EV_KEY, KEY_A, 0) + + await asyncio.sleep(0.020) + self.assertEqual( + uinput_write_history, + [ + InputEvent.from_tuple((EV_KEY, KEY_LEFTSHIFT, 1)), + InputEvent.from_tuple((EV_KEY, KEY_B, 1)), + InputEvent.from_tuple((EV_KEY, KEY_B, 0)), + InputEvent.from_tuple((EV_KEY, KEY_LEFTSHIFT, 0)), + ], + ) + + async def test_rolling_keys_1(self): + await self.input(EV_KEY, KEY_A, 1) + await asyncio.sleep(0.100) + await self.input(EV_KEY, KEY_B, 1) + await asyncio.sleep(0.020) + await self.input(EV_KEY, KEY_A, 0) + await asyncio.sleep(0.020) + await self.input(EV_KEY, KEY_B, 0) + + # everything happened within the tapping_term, so the modifier is not activated. + # "ab" should be written, in the exact order of the input. + await asyncio.sleep(0.100) + self.assertEqual( + uinput_write_history, + [ + InputEvent.from_tuple((EV_KEY, KEY_A, 1)), + InputEvent.from_tuple((EV_KEY, KEY_B, 1)), + InputEvent.from_tuple((EV_KEY, KEY_A, 0)), + InputEvent.from_tuple((EV_KEY, KEY_B, 0)), + ], + ) + + async def test_rolling_keys_2(self): + await self.input(EV_KEY, KEY_A, 1) + await asyncio.sleep(0.100) + await self.input(EV_KEY, KEY_B, 1) + await asyncio.sleep(0.100) # exceeds tapping_term here + await self.input(EV_KEY, KEY_A, 0) + await asyncio.sleep(0.020) + await self.input(EV_KEY, KEY_B, 0) + + await asyncio.sleep(0.020) + self.assertEqual( + uinput_write_history, + [ + InputEvent.from_tuple((EV_KEY, KEY_LEFTSHIFT, 1)), + InputEvent.from_tuple((EV_KEY, KEY_B, 1)), + InputEvent.from_tuple((EV_KEY, KEY_LEFTSHIFT, 0)), + InputEvent.from_tuple((EV_KEY, KEY_B, 0)), + ], + ) + + async def test_many_keys_correct_order_without_sleep(self): + self.mapping.macro_key_sleep_ms = 0 + await self.many_keys_correct_order() + + async def test_many_keys_correct_order_with_sleep(self): + self.mapping.macro_key_sleep_ms = 20 + await self.many_keys_correct_order() + + async def many_keys_correct_order(self): + await self.input(EV_KEY, KEY_A, 1) + + # Send many events to the listener. It has to make all of them wait. + for i in range(30): + await self.input(EV_KEY, i, 1) + + # exceed tapping_term. mod_tap will inject the modifier and replay all the + # previous events. + await asyncio.sleep(0.201) + + # mod_tap is busy replaying events. While it does that, inject this + await self.input(EV_KEY, 100, 1) + + start = time.time() + timeout = 2 + while len(uinput_write_history) < 32 and (time.time() - start) < timeout: + # Wait for it to complete + await asyncio.sleep(0.1) + + self.assertEqual(len(uinput_write_history), 32) + + # Expect it to cleanly handle all events before injecting 100. Expect + # everything to be in the correct order. + self.assertEqual( + uinput_write_history, + [ + InputEvent.from_tuple((EV_KEY, KEY_LEFTSHIFT, 1)), + *[InputEvent.from_tuple((EV_KEY, i, 1)) for i in range(30)], + InputEvent.from_tuple((EV_KEY, 100, 1)), + ], + ) + + async def test_mapped_second_key(self): + # Map b to c. + # While mod_tap is waiting for the timeout to happen, press b. + # We expect c to be written, because b goes through the handlers and + # gets mapped. + # The event_reader has to wait for listeners to complete for mod_tap to work, so + # that it hands them over to the other handlers when the time comes. + # That means however, that the event_readers loop blocks. Therefore, it was turned + # into a fire-and-forget kind of thing. When an event arrives, it just schedules + # asyncio to do that stuff later, and continues reading. + + self.preset.add( + Mapping.from_combination( + input_combination=[ + InputConfig( + type=EV_KEY, + code=KEY_B, + origin_hash=self.origin_hash, + ) + ], + output_symbol="c", + ), + ) + + self.bootstrap_event_reader() + + async def async_generator(): + events = [ + InputEvent(0, 0, EV_KEY, KEY_A, 1), + InputEvent(0, 0, EV_KEY, KEY_B, 1), + InputEvent(0, 0, EV_KEY, KEY_A, 0), + InputEvent(0, 0, EV_KEY, KEY_B, 0), + ] + for event in events: + yield event + # Wait a bit. During runtime, events don't come in that quickly + # and the mod_tap macro needs some loop iterations until it adds + # the listener to the context. + await asyncio.sleep(0.010) + + with patch.object(self.event_reader, "read_loop", async_generator): + await self.event_reader.run() + + await asyncio.sleep(0.020) + self.assertIn(InputEvent(0, 0, EV_KEY, KEY_C, 1), uinput_write_history) + self.assertIn(InputEvent(0, 0, EV_KEY, KEY_C, 0), uinput_write_history) + self.assertIn(InputEvent(0, 0, EV_KEY, KEY_A, 1), uinput_write_history) + self.assertIn(InputEvent(0, 0, EV_KEY, KEY_A, 0), uinput_write_history) + self.assertNotIn(InputEvent(0, 0, EV_KEY, KEY_B, 1), uinput_write_history) + self.assertNotIn(InputEvent(0, 0, EV_KEY, KEY_B, 0), uinput_write_history) + + +@test_setup +class TestModTapUnit(MacroTestBase): + async def wait_for_timeout(self, macro): + macro = Parser.parse(macro, self.context, DummyMapping, True) + + start = time.time() + # Awaiting macro.run will cause it to wait for the tapping_term. + # When it injects the modifier, release the trigger. + macro.press_trigger() + await macro.run(lambda *_, **__: macro.release_trigger()) + return time.time() - start + + async def test_tapping_term_configuration_default(self): + time_ = await self.wait_for_timeout("mod_tap(a, b)") + # + 2 times 10ms of keycode_pause + self.assertAlmostEqual(time_, 0.22, delta=0.02) + + async def test_tapping_term_configuration_100(self): + time_ = await self.wait_for_timeout("mod_tap(a, b, 100)") + self.assertAlmostEqual(time_, 0.12, delta=0.02) + + async def test_tapping_term_configuration_100_kwarg(self): + time_ = await self.wait_for_timeout("mod_tap(a, b, tapping_term=100)") + self.assertAlmostEqual(time_, 0.12, delta=0.02) diff --git a/tests/unit/test_macros/test_modify.py b/tests/unit/test_macros/test_modify.py new file mode 100644 index 0000000..b42f97c --- /dev/null +++ b/tests/unit/test_macros/test_modify.py @@ -0,0 +1,62 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + + +import unittest + +from evdev.ecodes import EV_KEY + +from inputremapper.configs.keyboard_layout import keyboard_layout +from inputremapper.configs.validation_errors import MacroError +from inputremapper.injection.macros.parse import Parser +from tests.lib.test_setup import test_setup +from tests.unit.test_macros.macro_test_base import MacroTestBase, DummyMapping + + +@test_setup +class TestModify(MacroTestBase): + async def test_modify(self): + code_a = keyboard_layout.get("a") + code_b = keyboard_layout.get("b") + code_c = keyboard_layout.get("c") + macro = Parser.parse( + "set(foo, b).modify($foo, modify(a, key(c)))", + self.context, + DummyMapping, + ) + await macro.run(self.handler) + self.assertListEqual( + self.result, + [ + (EV_KEY, code_b, 1), + (EV_KEY, code_a, 1), + (EV_KEY, code_c, 1), + (EV_KEY, code_c, 0), + (EV_KEY, code_a, 0), + (EV_KEY, code_b, 0), + ], + ) + + async def test_raises_error(self): + self.assertRaises(MacroError, Parser.parse, "modify(asdf, k(a))", self.context) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/test_macros/test_mouse.py b/tests/unit/test_macros/test_mouse.py new file mode 100644 index 0000000..7136965 --- /dev/null +++ b/tests/unit/test_macros/test_mouse.py @@ -0,0 +1,246 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + +import asyncio +import unittest + +from evdev._ecodes import ( + REL_Y, + EV_REL, + REL_HWHEEL, + REL_HWHEEL_HI_RES, + REL_X, + REL_WHEEL, + REL_WHEEL_HI_RES, + KEY_A, + EV_KEY, +) + +from inputremapper.configs.validation_errors import MacroError +from inputremapper.injection.macros.parse import Parser +from tests.lib.test_setup import test_setup +from tests.unit.test_macros.macro_test_base import DummyMapping, MacroTestBase + + +@test_setup +class TestMouse(MacroTestBase): + async def test_mouse_acceleration(self): + # There is a tiny float-rounding error that can break the test, therefore I use + # 0.09001 to make it more robust. + await self._run_macro("mouse(up, 10, 0.09001)", 0.1) + self.assertEqual( + [ + (EV_REL, REL_Y, -2), + (EV_REL, REL_Y, -3), + (EV_REL, REL_Y, -4), + (EV_REL, REL_Y, -4), + (EV_REL, REL_Y, -5), + ], + self.result, + ) + + async def test_rate(self): + # It should move 200 times per second by 1px, for 0.2 seconds. + rel_rate = 200 + time = 0.2 + speed = 1 + expected_movement = time * rel_rate * speed + + await self._run_macro(f"mouse(down, {speed})", time, rel_rate) + total_movement = sum(event[2] for event in self.result) + self.assertAlmostEqual(float(total_movement), expected_movement, delta=1) + + async def test_slow_movement(self): + await self._run_macro("mouse(down, 0.1)", 0.2, 200) + total_movement = sum(event[2] for event in self.result) + self.assertAlmostEqual(total_movement, 4, delta=1) + + async def test_mouse_xy_acceleration_1(self): + await self._run_macro("mouse_xy(2, -10, 0.09001)", 0.1) + self.assertEqual( + [ + (EV_REL, REL_Y, -2), + (EV_REL, REL_Y, -3), + (EV_REL, REL_Y, -4), + (EV_REL, REL_Y, -4), + (EV_REL, REL_Y, -5), + ], + self._get_y_movement(), + ) + self.assertEqual( + [ + (EV_REL, REL_X, 1), + (EV_REL, REL_X, 1), + (EV_REL, REL_X, 1), + ], + self._get_x_movement(), + ) + + async def test_mouse_xy_acceleration_2(self): + await self._run_macro("mouse_xy(10, -2, 0.09001)", 0.1) + self.assertEqual( + [ + (EV_REL, REL_Y, -1), + (EV_REL, REL_Y, -1), + (EV_REL, REL_Y, -1), + ], + self._get_y_movement(), + ) + self.assertEqual( + [ + (EV_REL, REL_X, 2), + (EV_REL, REL_X, 3), + (EV_REL, REL_X, 4), + (EV_REL, REL_X, 4), + (EV_REL, REL_X, 5), + ], + self._get_x_movement(), + ) + + async def test_mouse_xy_only_x(self): + await self._run_macro("mouse_xy(x=10, acceleration=1)", 0.1) + self.assertEqual([], self._get_y_movement()) + self.assertEqual( + [ + (EV_REL, REL_X, 10), + (EV_REL, REL_X, 10), + (EV_REL, REL_X, 10), + (EV_REL, REL_X, 10), + (EV_REL, REL_X, 10), + (EV_REL, REL_X, 10), + ], + self._get_x_movement(), + ) + + async def test_mouse_xy_only_y(self): + await self._run_macro("mouse_xy(y=10)", 0.1) + self.assertEqual([], self._get_x_movement()) + self.assertEqual( + [ + (EV_REL, REL_Y, 10), + (EV_REL, REL_Y, 10), + (EV_REL, REL_Y, 10), + (EV_REL, REL_Y, 10), + (EV_REL, REL_Y, 10), + (EV_REL, REL_Y, 10), + ], + self._get_y_movement(), + ) + + async def test_wheel_left(self): + wheel_speed = 60 + sleep = 0.1 + await self._run_macro(f"wheel(left, {wheel_speed})", sleep) + + self.assertIn((EV_REL, REL_HWHEEL, 1), self.result) + self.assertIn((EV_REL, REL_HWHEEL_HI_RES, 60), self.result) + + expected_num_hires_events = sleep * DummyMapping.rel_rate + expected_num_wheel_events = int(expected_num_hires_events / 120 * wheel_speed) + actual_num_wheel_events = self.result.count((EV_REL, REL_HWHEEL, 1)) + actual_num_hires_events = self.result.count( + ( + EV_REL, + REL_HWHEEL_HI_RES, + wheel_speed, + ) + ) + + self.assertGreater( + actual_num_wheel_events, + expected_num_wheel_events * 0.9, + ) + self.assertLess( + actual_num_wheel_events, + expected_num_wheel_events * 1.1, + ) + self.assertGreater( + actual_num_hires_events, + expected_num_hires_events * 0.9, + ) + self.assertLess( + actual_num_hires_events, + expected_num_hires_events * 1.1, + ) + + async def test_wheel_up(self): + await self._run_macro("wheel(up, 60)", 0.1) + self.assertIn((EV_REL, REL_WHEEL, 1), self.result) + self.assertIn((EV_REL, REL_WHEEL_HI_RES, 60), self.result) + + async def test_wheel_down(self): + await self._run_macro("wheel(down, 60)", 0.1) + self.assertIn((EV_REL, REL_WHEEL, -1), self.result) + self.assertIn((EV_REL, REL_WHEEL_HI_RES, -60), self.result) + + async def test_wheel_right(self): + await self._run_macro("wheel(right, 60)", 0.1) + self.assertIn((EV_REL, REL_HWHEEL, -1), self.result) + self.assertIn((EV_REL, REL_HWHEEL_HI_RES, -60), self.result) + + async def test_mouse_releases(self): + await self._run_macro("mouse(down, 1).key(a)", 0.1) + self.assertEqual(self.result[-2:], [(EV_KEY, KEY_A, 1), (EV_KEY, KEY_A, 0)]) + + async def test_mouse_xy_releases(self): + await self._run_macro("mouse_xy(1, 1, 1).key(a)", 0.1) + self.assertEqual(self.result[-2:], [(EV_KEY, KEY_A, 1), (EV_KEY, KEY_A, 0)]) + + async def test_wheel_releases(self): + await self._run_macro("wheel(down, 1).key(a)", 0.1) + self.assertEqual(self.result[-2:], [(EV_KEY, KEY_A, 1), (EV_KEY, KEY_A, 0)]) + + async def test_raises_error(self): + Parser.parse("mouse(up, 3)", self.context) # no error + Parser.parse("mouse(up, speed=$a)", self.context) # no error + self.assertRaises(MacroError, Parser.parse, "mouse(3, up)", self.context) + Parser.parse("wheel(left, 3)", self.context) # no error + self.assertRaises(MacroError, Parser.parse, "wheel(3, left)", self.context) + + def _get_x_movement(self): + return [event for event in self.result if event[1] == REL_X] + + def _get_y_movement(self): + return [event for event in self.result if event[1] == REL_Y] + + async def _run_macro( + self, + code: str, + time: float, + rel_rate: int = DummyMapping.rel_rate, + ): + dummy_mapping = DummyMapping() + dummy_mapping.rel_rate = rel_rate + macro = Parser.parse( + code, + self.context, + dummy_mapping, + ) + macro.press_trigger() + asyncio.ensure_future(macro.run(self.handler)) + + await asyncio.sleep(time) + self.assertTrue(macro.tasks[0].is_holding()) + macro.release_trigger() + await asyncio.sleep(0.05) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/test_macros/test_parallel.py b/tests/unit/test_macros/test_parallel.py new file mode 100644 index 0000000..c80f2dc --- /dev/null +++ b/tests/unit/test_macros/test_parallel.py @@ -0,0 +1,114 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + +import asyncio +import unittest + +from evdev.ecodes import ( + EV_KEY, + KEY_A, + KEY_B, + KEY_C, + KEY_D, +) + +from inputremapper.injection.macros.parse import Parser +from tests.lib.test_setup import test_setup +from tests.unit.test_macros.macro_test_base import DummyMapping, MacroTestBase + + +@test_setup +class TestParallel(MacroTestBase): + async def test_1_child_macro(self): + macro = Parser.parse( + "parallel(key(a))", + self.context, + DummyMapping(), + True, + ) + self.assertEqual(len(macro.tasks[0].child_macros), 1) + await macro.run(self.handler) + self.assertEqual(self.result, [(EV_KEY, KEY_A, 1), (EV_KEY, KEY_A, 0)]) + + async def test_4_child_macros(self): + macro = Parser.parse( + "parallel(key(a), key(b), key(c), key(d))", + self.context, + DummyMapping(), + True, + ) + self.assertEqual(len(macro.tasks[0].child_macros), 4) + await macro.run(self.handler) + self.assertIn((EV_KEY, KEY_A, 0), self.result) + self.assertIn((EV_KEY, KEY_B, 0), self.result) + self.assertIn((EV_KEY, KEY_C, 0), self.result) + self.assertIn((EV_KEY, KEY_D, 0), self.result) + + async def test_one_wait_takes_longer(self): + mapping = DummyMapping() + mapping.macro_key_sleep_ms = 0 + macro = Parser.parse( + "parallel(wait(100), wait(10).key(b)).key(c)", + self.context, + mapping, + True, + ) + + asyncio.ensure_future(macro.run(self.handler)) + await asyncio.sleep(0.06) + # The wait(10).key(b) macro is already done, but KEY_C is not yet injected + self.assertEqual(len(self.result), 2) + self.assertIn((EV_KEY, KEY_B, 1), self.result) + self.assertIn((EV_KEY, KEY_B, 0), self.result) + + # Both need to complete for it to continue to key(c) + await asyncio.sleep(0.06) + self.assertEqual(len(self.result), 4) + self.assertIn((EV_KEY, KEY_C, 1), self.result) + self.assertIn((EV_KEY, KEY_C, 0), self.result) + + async def test_parallel_hold(self): + mapping = DummyMapping() + mapping.macro_key_sleep_ms = 0 + macro = Parser.parse( + "parallel(hold_keys(a), hold_keys(b)).key(c)", + self.context, + mapping, + True, + ) + + macro.press_trigger() + asyncio.ensure_future(macro.run(self.handler)) + await asyncio.sleep(0.05) + self.assertIn((EV_KEY, KEY_A, 1), self.result) + self.assertIn((EV_KEY, KEY_B, 1), self.result) + self.assertEqual(len(self.result), 2) + + macro.release_trigger() + await asyncio.sleep(0.05) + self.assertIn((EV_KEY, KEY_A, 0), self.result) + self.assertIn((EV_KEY, KEY_B, 0), self.result) + self.assertIn((EV_KEY, KEY_C, 1), self.result) + self.assertIn((EV_KEY, KEY_C, 0), self.result) + self.assertEqual(len(self.result), 6) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/test_macros/test_parsing.py b/tests/unit/test_macros/test_parsing.py new file mode 100644 index 0000000..c76ddf7 --- /dev/null +++ b/tests/unit/test_macros/test_parsing.py @@ -0,0 +1,346 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + + +import re +import unittest + +from evdev.ecodes import EV_KEY, KEY_A, KEY_B, KEY_C, KEY_E + +from inputremapper.configs.validation_errors import ( + MacroError, +) +from inputremapper.injection.macros.argument import Argument, ArgumentConfig +from inputremapper.injection.macros.parse import Parser +from inputremapper.injection.macros.raw_value import RawValue +from inputremapper.injection.macros.tasks.hold_keys import HoldKeysTask +from inputremapper.injection.macros.tasks.if_tap import IfTapTask +from inputremapper.injection.macros.tasks.key import KeyTask +from inputremapper.injection.macros.variable import Variable +from tests.lib.logger import logger +from tests.lib.test_setup import test_setup +from tests.unit.test_macros.macro_test_base import DummyMapping, MacroTestBase + + +@test_setup +class TestParsing(MacroTestBase): + def test_get_macro_argument_names(self): + self.assertEqual( + IfTapTask.get_macro_argument_names(), + ["then", "else", "timeout"], + ) + + self.assertEqual( + HoldKeysTask.get_macro_argument_names(), + ["*symbols"], + ) + + def test_get_num_parameters(self): + self.assertEqual(IfTapTask.get_num_parameters(), (0, 3)) + self.assertEqual(KeyTask.get_num_parameters(), (1, 1)) + self.assertEqual(HoldKeysTask.get_num_parameters(), (0, float("inf"))) + + def test_remove_whitespaces(self): + self.assertEqual(Parser.remove_whitespaces('foo"bar"foo'), 'foo"bar"foo') + self.assertEqual(Parser.remove_whitespaces('foo" bar"foo'), 'foo" bar"foo') + self.assertEqual( + Parser.remove_whitespaces('foo" bar"fo" "o'), 'foo" bar"fo" "o' + ) + self.assertEqual( + Parser.remove_whitespaces(' fo o"\nba r "f\noo'), 'foo"\nba r "foo' + ) + self.assertEqual(Parser.remove_whitespaces(' a " b " c " '), 'a" b "c" ') + + self.assertEqual(Parser.remove_whitespaces('"""""""""'), '"""""""""') + self.assertEqual(Parser.remove_whitespaces('""""""""'), '""""""""') + + self.assertEqual(Parser.remove_whitespaces(" "), "") + self.assertEqual(Parser.remove_whitespaces(' " '), '" ') + self.assertEqual(Parser.remove_whitespaces(' " " '), '" "') + + self.assertEqual(Parser.remove_whitespaces("a# ##b", delimiter="##"), "a###b") + self.assertEqual(Parser.remove_whitespaces("a###b", delimiter="##"), "a###b") + self.assertEqual(Parser.remove_whitespaces("a## #b", delimiter="##"), "a## #b") + self.assertEqual( + Parser.remove_whitespaces("a## ##b", delimiter="##"), "a## ##b" + ) + + def test_remove_comments(self): + self.assertEqual(Parser.remove_comments("a#b"), "a") + self.assertEqual(Parser.remove_comments('"a#b"'), '"a#b"') + self.assertEqual(Parser.remove_comments('a"#"#b'), 'a"#"') + self.assertEqual(Parser.remove_comments('a"#""#"#b'), 'a"#""#"') + self.assertEqual(Parser.remove_comments('#a"#""#"#b'), "") + + self.assertEqual( + re.sub( + r"\s", + "", + Parser.remove_comments( + """ + # a + b + # c + d + """ + ), + ), + "bd", + ) + + async def test_count_brackets(self): + self.assertEqual(Parser._count_brackets(""), 0) + self.assertEqual(Parser._count_brackets("()"), 2) + self.assertEqual(Parser._count_brackets("a()"), 3) + self.assertEqual(Parser._count_brackets("a(b)"), 4) + self.assertEqual(Parser._count_brackets("a(b())"), 6) + self.assertEqual(Parser._count_brackets("a(b(c))"), 7) + self.assertEqual(Parser._count_brackets("a(b(c))d"), 7) + self.assertEqual(Parser._count_brackets("a(b(c))d()"), 7) + + def test_split_keyword_arg(self): + self.assertTupleEqual(Parser._split_keyword_arg("_A=b"), ("_A", "b")) + self.assertTupleEqual(Parser._split_keyword_arg("a_=1"), ("a_", "1")) + self.assertTupleEqual( + Parser._split_keyword_arg("a=repeat(2, KEY_A)"), + ("a", "repeat(2, KEY_A)"), + ) + self.assertTupleEqual(Parser._split_keyword_arg('a="=,#+."'), ("a", '"=,#+."')) + + def test_is_this_a_macro(self): + self.assertTrue(Parser.is_this_a_macro("key(1)")) + self.assertTrue(Parser.is_this_a_macro("key(1).key(2)")) + self.assertTrue(Parser.is_this_a_macro("repeat(1, key(1).key(2))")) + + self.assertFalse(Parser.is_this_a_macro("1")) + self.assertFalse(Parser.is_this_a_macro("key_kp1")) + self.assertFalse(Parser.is_this_a_macro("btn_left")) + self.assertFalse(Parser.is_this_a_macro("minus")) + self.assertFalse(Parser.is_this_a_macro("k")) + self.assertFalse(Parser.is_this_a_macro(1)) + self.assertFalse(Parser.is_this_a_macro(None)) + + self.assertTrue(Parser.is_this_a_macro("a+b")) + self.assertTrue(Parser.is_this_a_macro("a+b+c")) + self.assertTrue(Parser.is_this_a_macro("a + b")) + self.assertTrue(Parser.is_this_a_macro("a + b + c")) + + def test_handle_plus_syntax(self): + self.assertEqual(Parser.handle_plus_syntax("a + b"), "hold_keys(a,b)") + self.assertEqual(Parser.handle_plus_syntax("a + b + c"), "hold_keys(a,b,c)") + self.assertEqual(Parser.handle_plus_syntax(" a+b+c "), "hold_keys(a,b,c)") + + # invalid. The last one with `key` should not have been a parameter + # of this function to begin with. + strings = ["+", "a+", "+b", "a\n+\n+\nb", "key(a + b)"] + for string in strings: + with self.assertRaises(MacroError): + logger.info('testing "%s"', string) + Parser.handle_plus_syntax(string) + + self.assertEqual(Parser.handle_plus_syntax("a"), "a") + self.assertEqual(Parser.handle_plus_syntax("key(a)"), "key(a)") + self.assertEqual(Parser.handle_plus_syntax(""), "") + + def test_parse_plus_syntax(self): + macro = Parser.parse("a + b") + self.assertEqual(macro.code, "hold_keys(a,b)") + + # this is not erroneously recognized as "plus" syntax + macro = Parser.parse("key(a) # a + b") + self.assertEqual(macro.code, "key(a)") + + async def test_extract_params(self): + # splits strings, doesn't try to understand their meaning yet + def expect(raw, expectation): + self.assertListEqual(Parser._extract_args(raw), expectation) + + expect("a", ["a"]) + expect("a,b", ["a", "b"]) + expect("a,b,c", ["a", "b", "c"]) + + expect("key(a)", ["key(a)"]) + expect("key(a).key(b), key(a)", ["key(a).key(b)", "key(a)"]) + expect("key(a), key(a).key(b)", ["key(a)", "key(a).key(b)"]) + + expect( + 'a("foo(1,2,3)", ",,,,,, "), , ""', + ['a("foo(1,2,3)", ",,,,,, ")', "", '""'], + ) + + expect( + ",1, ,b,x(,a(),).y().z(),,", + ["", "1", "", "b", "x(,a(),).y().z()", "", ""], + ) + + expect("repeat(1, key(a))", ["repeat(1, key(a))"]) + expect( + "repeat(1, key(a)), repeat(1, key(b))", + ["repeat(1, key(a))", "repeat(1, key(b))"], + ) + expect( + "repeat(1, key(a)), repeat(1, key(b)), repeat(1, key(c))", + ["repeat(1, key(a))", "repeat(1, key(b))", "repeat(1, key(c))"], + ) + + # will be parsed as None + expect("", [""]) + expect(",", ["", ""]) + expect(",,", ["", "", ""]) + + async def test_parse_params(self): + def test(value, types): + argument = Argument( + ArgumentConfig(position=0, name="test", types=types), + DummyMapping, + ) + argument.initialize_variable(RawValue(value=value)) + return argument._variable + + self.assertEqual( + test("", [None, int, float]), + Variable(None, const=True), + ) + + # strings. If it is wrapped in quotes, don't parse the contents + self.assertEqual( + test('"foo"', [str]), + Variable("foo", const=True), + ) + self.assertEqual( + test('"\tf o o\n"', [str]), + Variable("\tf o o\n", const=True), + ) + self.assertEqual( + test('"foo(a,b)"', [str]), + Variable("foo(a,b)", const=True), + ) + self.assertEqual( + test('",,,()"', [str]), + Variable(",,,()", const=True), + ) + + # strings without quotes only work as long as there is no function call or + # anything. This is only really acceptable for constants like KEY_A and for + # variable names, which are not allowed to contain special characters that may + # have a meaning in the macro syntax. + self.assertEqual( + test("foo", [str]), + Variable("foo", const=True), + ) + + self.assertEqual( + test("", [str, None]), + Variable(None, const=True), + ) + self.assertEqual( + test("", [str]), + Variable("", const=True), + ) + self.assertEqual( + test("", [None]), + Variable(None, const=True), + ) + self.assertEqual( + test("None", [None]), + Variable(None, const=True), + ) + self.assertEqual( + test('"None"', [str]), + Variable("None", const=True), + ) + + self.assertEqual( + test("5", [int]), + Variable(5, const=True), + ) + self.assertEqual( + test("5", [float, int]), + Variable(5, const=True), + ) + self.assertEqual( + test("5.2", [int, float]), + Variable(5.2, const=True), + ) + self.assertIsInstance( + test("$foo", [str]), + Variable, + ) + self.assertEqual( + test("$foo", [str]), + Variable("foo", const=False), + ) + + async def test_string_not_a_macro(self): + # passing a string parameter. This is not a macro, even though + # it might look like it without the string quotes. Everything with + # explicit quotes around it has to be treated as a string. + self.assertRaises(MacroError, Parser.parse, '"modify(a, b)"', self.context) + + async def test_multiline_macro_and_comments(self): + # the parser is not confused by the code in the comments and can use hashtags + # in strings in the actual code + comment = '# repeat(1,key(KEY_D)).set(a,"#b")' + macro = Parser.parse( + f""" + {comment} + key(KEY_A).{comment} + key(KEY_B). {comment} + repeat({comment} + 1, {comment} + key(KEY_C){comment} + ). {comment} + {comment} + set(a, "#").{comment} + if_eq($a, "#", key(KEY_E), key(KEY_F)) {comment} + {comment} + """, + self.context, + DummyMapping, + ) + await macro.run(self.handler) + self.assertListEqual( + self.result, + [ + (EV_KEY, KEY_A, 1), + (EV_KEY, KEY_A, 0), + (EV_KEY, KEY_B, 1), + (EV_KEY, KEY_B, 0), + (EV_KEY, KEY_C, 1), + (EV_KEY, KEY_C, 0), + (EV_KEY, KEY_E, 1), + (EV_KEY, KEY_E, 0), + ], + ) + + async def test_child_macro_count(self): + # It correctly keeps track of child-macros for both positional and keyword-args + macro = Parser.parse( + "hold(macro=hold(hold())).repeat(1, macro=repeat(1, hold()))", + self.context, + DummyMapping, + True, + ) + self.assertEqual(self.count_child_macros(macro), 4) + self.assertEqual(self.count_tasks(macro), 6) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/test_macros/test_repeat.py b/tests/unit/test_macros/test_repeat.py new file mode 100644 index 0000000..e410413 --- /dev/null +++ b/tests/unit/test_macros/test_repeat.py @@ -0,0 +1,142 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . +import time +import unittest + +from evdev.ecodes import EV_KEY + +from inputremapper.configs.keyboard_layout import keyboard_layout +from inputremapper.configs.validation_errors import MacroError +from inputremapper.injection.macros.parse import Parser +from tests.lib.test_setup import test_setup +from tests.unit.test_macros.macro_test_base import MacroTestBase, DummyMapping + + +@test_setup +class TestRepeat(MacroTestBase): + async def test_1(self): + start = time.time() + repeats = 20 + + macro = Parser.parse( + f"repeat({repeats}, key(k)).repeat(1, key(k))", + self.context, + DummyMapping, + ) + k_code = keyboard_layout.get("k") + + await macro.run(self.handler) + keystroke_sleep = DummyMapping.macro_key_sleep_ms + sleep_time = 2 * repeats * keystroke_sleep / 1000 + self.assertGreater(time.time() - start, sleep_time * 0.9) + + # This test is rather lax, timing in github actions is slow + self.assertLess(time.time() - start, sleep_time * 1.4) + + self.assertListEqual( + self.result, + [(EV_KEY, k_code, 1), (EV_KEY, k_code, 0)] * (repeats + 1), + ) + + self.assertEqual(self.count_child_macros(macro), 2) + + self.assertEqual(len(macro.tasks[0].child_macros), 1) + self.assertEqual(len(macro.tasks[0].child_macros[0].tasks), 1) + self.assertEqual(len(macro.tasks[0].child_macros[0].tasks[0].child_macros), 0) + + self.assertEqual(len(macro.tasks[1].child_macros), 1) + self.assertEqual(len(macro.tasks[1].child_macros[0].tasks), 1) + self.assertEqual(len(macro.tasks[1].child_macros[0].tasks[0].child_macros), 0) + + async def test_2(self): + start = time.time() + macro = Parser.parse("repeat(3, key(m).w(100))", self.context, DummyMapping) + m_code = keyboard_layout.get("m") + await macro.run(self.handler) + + keystroke_time = 6 * DummyMapping.macro_key_sleep_ms + total_time = keystroke_time + 300 + total_time /= 1000 + + self.assertGreater(time.time() - start, total_time * 0.9) + self.assertLess(time.time() - start, total_time * 1.2) + self.assertListEqual( + self.result, + [ + (EV_KEY, m_code, 1), + (EV_KEY, m_code, 0), + (EV_KEY, m_code, 1), + (EV_KEY, m_code, 0), + (EV_KEY, m_code, 1), + (EV_KEY, m_code, 0), + ], + ) + self.assertEqual(self.count_child_macros(macro), 1) + self.assertEqual(len(macro.tasks), 1) + self.assertEqual(len(macro.tasks[0].child_macros), 1) + self.assertEqual(len(macro.tasks[0].child_macros[0].tasks), 2) + self.assertEqual(len(macro.tasks[0].child_macros[0].tasks[0].child_macros), 0) + self.assertEqual(len(macro.tasks[0].child_macros[0].tasks[1].child_macros), 0) + + async def test_not_an_int(self): + # the first parameter for `repeat` requires an integer, not "foo", + # which makes `repeat` throw + macro = Parser.parse( + 'set(a, "foo").repeat($a, key(KEY_A)).key(KEY_B)', + self.context, + DummyMapping, + ) + + try: + await macro.run(self.handler) + except MacroError as e: + self.assertIn("foo", str(e)) + + self.assertFalse(macro.running) + + # key(KEY_B) is not executed, the macro stops + self.assertListEqual(self.result, []) + + async def test_raises_error(self): + self.assertRaises(MacroError, Parser.parse, "r(1)", self.context) + self.assertRaises(MacroError, Parser.parse, "repeat(a, k(1))", self.context) + Parser.parse("repeat($a, k(1))", self.context) # no error + Parser.parse("repeat(2, k(1))", self.context) # no error + self.assertRaises(MacroError, Parser.parse, 'repeat("2", k(1))', self.context) + self.assertRaises(MacroError, Parser.parse, "r(1, 1)", self.context) + self.assertRaises(MacroError, Parser.parse, "r(k(1), 1)", self.context) + Parser.parse("r(1, macro=k(1))", self.context) # no error + self.assertRaises(MacroError, Parser.parse, "r(a=1, b=k(1))", self.context) + self.assertRaises( + MacroError, + Parser.parse, + "r(repeats=1, macro=k(1), a=2)", + self.context, + ) + self.assertRaises( + MacroError, + Parser.parse, + "r(repeats=1, macro=k(1), repeats=2)", + self.context, + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/test_macros/test_set.py b/tests/unit/test_macros/test_set.py new file mode 100644 index 0000000..f37401a --- /dev/null +++ b/tests/unit/test_macros/test_set.py @@ -0,0 +1,101 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + + +import unittest + +from evdev.ecodes import EV_KEY + +from inputremapper.configs.keyboard_layout import keyboard_layout +from inputremapper.configs.validation_errors import MacroError +from inputremapper.injection.macros.macro import macro_variables +from inputremapper.injection.macros.parse import Parser +from tests.lib.test_setup import test_setup +from tests.unit.test_macros.macro_test_base import MacroTestBase, DummyMapping + + +@test_setup +class TestSet(MacroTestBase): + async def test_set_key(self): + code_b = keyboard_layout.get("b") + macro = Parser.parse("set(foo, b).key($foo)", self.context, DummyMapping) + await macro.run(self.handler) + self.assertListEqual( + self.result, + [ + (EV_KEY, code_b, 1), + (EV_KEY, code_b, 0), + ], + ) + + async def test_int_is_explicit_string(self): + await Parser.parse( + 'set( \t"b" \n, "1")', + self.context, + DummyMapping, + ).run(self.handler) + self.assertEqual(macro_variables.get("b"), "1") + + async def test_int_is_int(self): + await Parser.parse( + "set(a, 1)", + self.context, + DummyMapping, + ).run(self.handler) + self.assertEqual(macro_variables.get("a"), 1) + + async def test_none(self): + await Parser.parse( + "set(a, )", + self.context, + DummyMapping, + ).run(self.handler) + self.assertEqual(macro_variables.get("a"), None) + + async def test_set_case_sensitive_1(self): + await Parser.parse( + 'set(a, "foo")', + self.context, + DummyMapping, + ).run(self.handler) + self.assertEqual(macro_variables.get("a"), "foo") + self.assertEqual(macro_variables.get("A"), None) + + async def test_set_case_sensitive_2(self): + await Parser.parse( + 'set(A, "foo")', + self.context, + DummyMapping, + ).run(self.handler) + self.assertEqual(macro_variables.get("A"), "foo") + self.assertEqual(macro_variables.get("a"), None) + + async def test_raises_error(self): + self.assertRaises(MacroError, Parser.parse, "set($a, 1)", self.context) + self.assertRaises(MacroError, Parser.parse, "set(1, 2)", self.context) + self.assertRaises(MacroError, Parser.parse, "set(+, 2)", self.context) + self.assertRaises(MacroError, Parser.parse, "set(a(), 2)", self.context) + self.assertRaises(MacroError, Parser.parse, "set('b,c', 2)", self.context) + self.assertRaises(MacroError, Parser.parse, 'set("b,c", 2)', self.context) + Parser.parse("set(A, 2)", self.context) # no error + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/test_macros/test_toggle.py b/tests/unit/test_macros/test_toggle.py new file mode 100644 index 0000000..bfa01db --- /dev/null +++ b/tests/unit/test_macros/test_toggle.py @@ -0,0 +1,69 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + + +import asyncio +import unittest + +from evdev.ecodes import EV_KEY + +from inputremapper.configs.keyboard_layout import keyboard_layout +from inputremapper.injection.macros.parse import Parser +from tests.lib.test_setup import test_setup +from tests.unit.test_macros.macro_test_base import MacroTestBase, DummyMapping + + +@test_setup +class TestToggle(MacroTestBase): + async def test_toggle(self): + # repeats key(a) as long as macro is toggled + macro = Parser.parse("toggle(key(a))", self.context, DummyMapping) + code_a = keyboard_layout.get("a") + + self.assertEqual(self.count_child_macros(macro), 1) + self.assertEqual(self.count_tasks(macro), 2) + + # Start + macro.press_trigger() + macro.release_trigger() + asyncio.ensure_future(macro.run(self.handler)) + + await asyncio.sleep(0.1) + count_1 = self.result.count((EV_KEY, code_a, 1)) + self.assertGreater(count_1, 2) + + # Keeps writing more + await asyncio.sleep(0.1) + count_2 = self.result.count((EV_KEY, code_a, 1)) + self.assertGreater(count_2, count_1) + + # Stop + macro.press_trigger() + macro.release_trigger() + + count_3 = self.result.count((EV_KEY, code_a, 1)) + await asyncio.sleep(0.1) + count_4 = self.result.count((EV_KEY, code_a, 1)) + # ensure that the macro has stopped + self.assertEqual(count_3, count_4) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/test_macros/test_wait.py b/tests/unit/test_macros/test_wait.py new file mode 100644 index 0000000..c7a54c4 --- /dev/null +++ b/tests/unit/test_macros/test_wait.py @@ -0,0 +1,89 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + + +import time +import unittest + +from inputremapper.configs.validation_errors import MacroError +from inputremapper.injection.macros.macro import Macro +from inputremapper.injection.macros.parse import Parser +from tests.lib.test_setup import test_setup +from tests.unit.test_macros.macro_test_base import DummyMapping, MacroTestBase + + +@test_setup +class TestWait(MacroTestBase): + async def assert_time_randomized( + self, + macro: Macro, + min_: float, + max_: float, + ): + for _ in range(100): + start = time.time() + await macro.run(self.handler) + time_taken = time.time() - start + + # Any of the runs should be within the defined range, to prove that they + # are indeed random. + if min_ < time_taken < max_: + return + + raise AssertionError("`wait` was not randomized") + + async def test_wait_1_core(self): + mapping = DummyMapping() + mapping.macro_key_sleep_ms = 0 + macro = Parser.parse("repeat(5, wait(50))", self.context, mapping, True) + + start = time.time() + await macro.run(self.handler) + time_per_iteration = (time.time() - start) / 5 + + self.assertLess(abs(time_per_iteration - 0.05), 0.005) + + async def test_wait_2_ranged(self): + mapping = DummyMapping() + mapping.macro_key_sleep_ms = 0 + macro = Parser.parse("wait(1, 100)", self.context, mapping, True) + await self.assert_time_randomized(macro, 0.02, 0.08) + + async def test_wait_3_ranged_single_get(self): + mapping = DummyMapping() + mapping.macro_key_sleep_ms = 0 + macro = Parser.parse("set(a, 100).wait(1, $a)", self.context, mapping, True) + await self.assert_time_randomized(macro, 0.02, 0.08) + + async def test_wait_4_ranged_double_get(self): + mapping = DummyMapping() + mapping.macro_key_sleep_ms = 0 + macro = Parser.parse( + "set(a, 1).set(b, 100).wait($a, $b)", self.context, mapping, True + ) + await self.assert_time_randomized(macro, 0.02, 0.08) + + async def test_raises_error(self): + Parser.parse("w(2)", self.context) # no error + self.assertRaises(MacroError, Parser.parse, "wait(a)", self.context) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/test_mapping.py b/tests/unit/test_mapping.py new file mode 100644 index 0000000..fb53f45 --- /dev/null +++ b/tests/unit/test_mapping.py @@ -0,0 +1,503 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + +import unittest +from functools import partial + +from evdev.ecodes import ( + EV_REL, + REL_X, + EV_KEY, + REL_Y, + REL_WHEEL, + REL_WHEEL_HI_RES, + KEY_1, + KEY_ESC, +) + +try: + from pydantic.v1 import ValidationError +except ImportError: + from pydantic import ValidationError + +from inputremapper.configs.mapping import Mapping, UIMapping, MappingType +from inputremapper.configs.keyboard_layout import keyboard_layout, DISABLE_NAME +from inputremapper.configs.input_config import InputCombination, InputConfig +from inputremapper.gui.messages.message_broker import MessageType +from tests.lib.test_setup import test_setup + + +@test_setup +class TestMapping(unittest.IsolatedAsyncioTestCase): + def test_init(self): + """Test init and that defaults are set.""" + cfg = { + "input_combination": [{"type": 1, "code": 2}], + "target_uinput": "keyboard", + "output_symbol": "a", + } + m = Mapping(**cfg) + self.assertEqual( + m.input_combination, InputCombination([InputConfig(type=1, code=2)]) + ) + self.assertEqual(m.target_uinput, "keyboard") + self.assertEqual(m.output_symbol, "a") + + self.assertIsNone(m.output_code) + self.assertIsNone(m.output_type) + + self.assertEqual(m.macro_key_sleep_ms, 0) + self.assertEqual(m.deadzone, 0.1) + self.assertEqual(m.gain, 1) + self.assertEqual(m.expo, 0) + self.assertEqual(m.rel_rate, 60) + self.assertEqual(m.rel_to_abs_input_cutoff, 2) + self.assertEqual(m.release_timeout, 0.05) + + def test_is_wheel_output(self): + mapping = Mapping( + input_combination=InputCombination([InputConfig(type=EV_REL, code=REL_X)]), + target_uinput="keyboard", + output_type=EV_REL, + output_code=REL_Y, + ) + self.assertFalse(mapping.is_wheel_output()) + self.assertFalse(mapping.is_high_res_wheel_output()) + + mapping = Mapping( + input_combination=InputCombination([InputConfig(type=EV_REL, code=REL_X)]), + target_uinput="keyboard", + output_type=EV_REL, + output_code=REL_WHEEL, + ) + self.assertTrue(mapping.is_wheel_output()) + self.assertFalse(mapping.is_high_res_wheel_output()) + + mapping = Mapping( + input_combination=InputCombination([InputConfig(type=EV_REL, code=REL_X)]), + target_uinput="keyboard", + output_type=EV_REL, + output_code=REL_WHEEL_HI_RES, + ) + self.assertFalse(mapping.is_wheel_output()) + self.assertTrue(mapping.is_high_res_wheel_output()) + + def test_get_output_type_code(self): + cfg = { + "input_combination": [{"type": 1, "code": 2}], + "target_uinput": "keyboard", + "output_symbol": "a", + } + m = Mapping(**cfg) + a = keyboard_layout.get("a") + self.assertEqual(m.get_output_type_code(), (EV_KEY, a)) + + m.output_symbol = "key(a)" + self.assertIsNone(m.get_output_type_code()) + + cfg = { + "input_combination": [{"type": 1, "code": 2}, {"type": 3, "code": 1}], + "target_uinput": "keyboard", + "output_type": 2, + "output_code": 3, + } + m = Mapping(**cfg) + self.assertEqual(m.get_output_type_code(), (2, 3)) + + def test_strips_output_symbol(self): + cfg = { + "input_combination": [{"type": 1, "code": 2}], + "target_uinput": "keyboard", + "output_symbol": "\t a \n", + } + m = Mapping(**cfg) + a = keyboard_layout.get("a") + self.assertEqual(m.get_output_type_code(), (EV_KEY, a)) + + def test_combination_changed_callback(self): + cfg = { + "input_combination": [{"type": 1, "code": 1}], + "target_uinput": "keyboard", + "output_symbol": "a", + } + m = Mapping(**cfg) + arguments = [] + + def callback(*args): + arguments.append(tuple(args)) + + m.set_combination_changed_callback(callback) + m.input_combination = [{"type": 1, "code": 2}] + m.input_combination = [{"type": 1, "code": 3}] + + # make sure a copy works as expected and keeps the callback + m2 = m.copy() + m2.input_combination = [{"type": 1, "code": 4}] + m2.remove_combination_changed_callback() + m.remove_combination_changed_callback() + m.input_combination = [{"type": 1, "code": 5}] + m2.input_combination = [{"type": 1, "code": 6}] + self.assertEqual( + arguments, + [ + ( + InputCombination([{"type": 1, "code": 2}]), + InputCombination([{"type": 1, "code": 1}]), + ), + ( + InputCombination([{"type": 1, "code": 3}]), + InputCombination([{"type": 1, "code": 2}]), + ), + ( + InputCombination([{"type": 1, "code": 4}]), + InputCombination([{"type": 1, "code": 3}]), + ), + ], + ) + m.remove_combination_changed_callback() + + def test_init_fails(self): + """Test that the init fails with invalid data.""" + test = partial(self.assertRaises, ValidationError, Mapping) + cfg = { + "input_combination": [{"type": 1, "code": 2}], + "target_uinput": "keyboard", + "output_symbol": "a", + } + Mapping(**cfg) + + # missing output symbol + del cfg["output_symbol"] + test(**cfg) + cfg["output_code"] = KEY_ESC + test(**cfg) + cfg["output_type"] = EV_KEY + Mapping(**cfg) + + # matching type, code and symbol. This cannot be done via the ui, and requires + # manual editing of the preset file. + a = keyboard_layout.get("a") + cfg["output_code"] = a + cfg["output_symbol"] = "a" + cfg["output_type"] = EV_KEY + Mapping(**cfg) + + # macro + cfg["output_symbol"] = "key(a)" + test(**cfg) + cfg["output_symbol"] = "a" + Mapping(**cfg) + + # mismatching type, code and symbol + cfg["output_symbol"] = "b" + test(**cfg) + del cfg["output_type"] + del cfg["output_code"] + Mapping(**cfg) # no error + + # empty symbol string without type and code + cfg["output_symbol"] = "" + test(**cfg) + cfg["output_symbol"] = "a" + + # missing target + del cfg["target_uinput"] + test(**cfg) + + # unknown target + cfg["target_uinput"] = "foo" + test(**cfg) + cfg["target_uinput"] = "keyboard" + Mapping(**cfg) + + # missing input_combination + del cfg["input_combination"] + test(**cfg) + cfg["input_combination"] = [{"type": 1, "code": 2}] + Mapping(**cfg) + + # no macro and not a known symbol + cfg["output_symbol"] = "qux" + test(**cfg) + cfg["output_symbol"] = "key(a)" + Mapping(**cfg) + + # invalid macro + cfg["output_symbol"] = "key('a')" + test(**cfg) + cfg["output_symbol"] = "a" + Mapping(**cfg) + + # map axis but no output type and code given + cfg["input_combination"] = [{"type": 3, "code": 0}] + test(**cfg) + # output symbol=disable is allowed + cfg["output_symbol"] = DISABLE_NAME + Mapping(**cfg) + del cfg["output_symbol"] + cfg["output_code"] = 1 + cfg["output_type"] = 3 + Mapping(**cfg) + + # empty symbol string is allowed when type and code is set + cfg["output_symbol"] = "" + Mapping(**cfg) + del cfg["output_symbol"] + + # multiple axis as axis in event combination + cfg["input_combination"] = [{"type": 3, "code": 0}, {"type": 3, "code": 1}] + test(**cfg) + cfg["input_combination"] = [{"type": 3, "code": 0}] + Mapping(**cfg) + + del cfg["output_type"] + del cfg["output_code"] + cfg["input_combination"] = [{"type": 1, "code": 2}] + cfg["output_symbol"] = "a" + Mapping(**cfg) + + # map EV_ABS as key with trigger point out of range + cfg["input_combination"] = [{"type": 3, "code": 0, "analog_threshold": 100}] + test(**cfg) + cfg["input_combination"] = [{"type": 3, "code": 0, "analog_threshold": 99}] + Mapping(**cfg) + cfg["input_combination"] = [{"type": 3, "code": 0, "analog_threshold": -100}] + test(**cfg) + cfg["input_combination"] = [{"type": 3, "code": 0, "analog_threshold": -99}] + Mapping(**cfg) + + cfg["input_combination"] = [{"type": 1, "code": 2}] + Mapping(**cfg) + + # deadzone out of range + test(**cfg, deadzone=1.01) + test(**cfg, deadzone=-0.01) + Mapping(**cfg, deadzone=1) + Mapping(**cfg, deadzone=0) + + # expo out of range + test(**cfg, expo=1.01) + test(**cfg, expo=-1.01) + Mapping(**cfg, expo=1) + Mapping(**cfg, expo=-1) + + # negative rate + test(**cfg, rel_rate=-1) + test(**cfg, rel_rate=0) + + Mapping(**cfg, rel_rate=1) + Mapping(**cfg, rel_rate=200) + + # negative rel_to_abs_input_cutoff + test(**cfg, rel_to_abs_input_cutoff=-1) + test(**cfg, rel_to_abs_input_cutoff=0) + Mapping(**cfg, rel_to_abs_input_cutoff=1) + Mapping(**cfg, rel_to_abs_input_cutoff=3) + + # negative release timeout + test(**cfg, release_timeout=-0.1) + test(**cfg, release_timeout=0) + Mapping(**cfg, release_timeout=0.05) + Mapping(**cfg, release_timeout=0.3) + + # analog output but no analog input + cfg = { + "input_combination": [{"type": 3, "code": 1, "analog_threshold": -1}], + "target_uinput": "gamepad", + "output_type": 3, + "output_code": 1, + } + test(**cfg) + cfg["input_combination"] = [{"type": 2, "code": 1, "analog_threshold": -1}] + test(**cfg) + cfg["output_type"] = 2 + test(**cfg) + cfg["input_combination"] = [{"type": 3, "code": 1, "analog_threshold": -1}] + test(**cfg) + + def test_automatically_detects_mapping_type(self): + cfg = { + "input_combination": [{"type": 1, "code": 2}], + "target_uinput": "keyboard", + "output_symbol": "a", + } + self.assertEqual(Mapping(**cfg).mapping_type, MappingType.KEY_MACRO.value) + + cfg = { + "input_combination": [{"type": 1, "code": 2}], + "target_uinput": "keyboard", + "output_type": EV_KEY, + "output_code": KEY_ESC, + } + self.assertEqual(Mapping(**cfg).mapping_type, MappingType.KEY_MACRO.value) + + cfg = { + "input_combination": [{"type": EV_REL, "code": REL_X}], + "target_uinput": "keyboard", + "output_type": EV_REL, + "output_code": REL_X, + } + self.assertEqual(Mapping(**cfg).mapping_type, MappingType.ANALOG.value) + + def test_revalidate_at_assignment(self): + cfg = { + "input_combination": [{"type": 1, "code": 1}], + "target_uinput": "keyboard", + "output_symbol": "a", + } + m = Mapping(**cfg) + test = partial(self.assertRaises, ValidationError, m.__setattr__) + + # invalid input event + test("input_combination", "1,2,3,4") + + # unknown target + test("target_uinput", "foo") + + # invalid macro + test("output_symbol", "key()") + + # we could do a lot more tests here but since pydantic uses the same validation + # code as for the initialization we only need to make sure that the + # assignment validation is active + + def test_set_invalid_combination_with_callback(self): + cfg = { + "input_combination": [{"type": 1, "code": 1}], + "target_uinput": "keyboard", + "output_symbol": "a", + } + m = Mapping(**cfg) + m.set_combination_changed_callback(lambda *args: None) + self.assertRaises(ValidationError, m.__setattr__, "input_combination", "1,2") + m.input_combination = [{"type": 1, "code": 2}] + m.input_combination = [{"type": 1, "code": 2}] + + def test_is_valid(self): + cfg = { + "input_combination": [{"type": 1, "code": 1}], + "target_uinput": "keyboard", + "output_symbol": "a", + } + m = Mapping(**cfg) + self.assertTrue(m.is_valid()) + + def test_wrong_target(self): + mapping = Mapping( + input_combination=[{"type": EV_KEY, "code": KEY_1}], + target_uinput="keyboard", + output_symbol="a", + ) + mapping.set_combination_changed_callback(lambda *args: None) + self.assertRaisesRegex( + ValidationError, + # the error should mention + # - the symbol + # - the current incorrect target + # - the target that works for this symbol + ".*BTN_A.*keyboard.*gamepad", + mapping.__setattr__, + "output_symbol", + "BTN_A", + ) + + def test_wrong_target_for_macro(self): + mapping = Mapping( + input_combination=[{"type": EV_KEY, "code": KEY_1}], + target_uinput="keyboard", + output_symbol="key(a)", + ) + mapping.set_combination_changed_callback(lambda *args: None) + self.assertRaisesRegex( + ValidationError, + # the error should mention + # - the symbol + # - the current incorrect target + # - the target that works for this symbol + ".*BTN_A.*keyboard.*gamepad", + mapping.__setattr__, + "output_symbol", + "key(BTN_A)", + ) + + +@test_setup +class TestUIMapping(unittest.IsolatedAsyncioTestCase): + def test_init(self): + """Should be able to initialize without throwing errors.""" + UIMapping() + + def test_is_valid(self): + """Should be invalid at first and become valid once all data is provided.""" + mapping = UIMapping() + self.assertFalse(mapping.is_valid()) + + mapping.input_combination = [{"type": EV_KEY, "code": KEY_1}] + mapping.output_symbol = "a" + self.assertFalse(mapping.is_valid()) + mapping.target_uinput = "keyboard" + self.assertTrue(mapping.is_valid()) + + def test_updates_validation_error(self): + mapping = UIMapping() + self.assertGreaterEqual(len(mapping.get_error().errors()), 2) + mapping.input_combination = [{"type": EV_KEY, "code": KEY_1}] + mapping.output_symbol = "a" + self.assertIn( + "1 validation error for Mapping\ntarget_uinput", + str(mapping.get_error()), + ) + mapping.target_uinput = "keyboard" + self.assertTrue(mapping.is_valid()) + self.assertIsNone(mapping.get_error()) + + def test_copy_returns_ui_mapping(self): + """Copy should also be a UIMapping with all the invalid data.""" + mapping = UIMapping() + mapping_2 = mapping.copy() + self.assertIsInstance(mapping_2, UIMapping) + self.assertEqual( + mapping_2.input_combination, InputCombination.empty_combination() + ) + self.assertIsNone(mapping_2.output_symbol) + + def test_get_bus_massage(self): + mapping = UIMapping() + mapping_2 = mapping.get_bus_message() + self.assertEqual(mapping_2.message_type, MessageType.mapping) + + with self.assertRaises(TypeError): + # the massage should be immutable + mapping_2.output_symbol = "a" + self.assertIsNone(mapping_2.output_symbol) + + # the original should be not immutable + mapping.output_symbol = "a" + self.assertEqual(mapping.output_symbol, "a") + + def test_has_input_defined(self): + mapping = UIMapping() + self.assertFalse(mapping.has_input_defined()) + mapping.input_combination = InputCombination([InputConfig(type=EV_KEY, code=1)]) + self.assertTrue(mapping.has_input_defined()) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/test_message_broker.py b/tests/unit/test_message_broker.py new file mode 100644 index 0000000..f1ebf1c --- /dev/null +++ b/tests/unit/test_message_broker.py @@ -0,0 +1,109 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + +import unittest +from dataclasses import dataclass + +from inputremapper.gui.messages.message_broker import MessageBroker, MessageType, Signal +from tests.lib.test_setup import test_setup + + +class Listener: + def __init__(self): + self.calls = [] + + def __call__(self, data): + self.calls.append(data) + + +@dataclass +class Message: + message_type: MessageType + msg: str + + +@test_setup +class TestMessageBroker(unittest.TestCase): + def test_calls_listeners(self): + """The correct Listeners get called""" + message_broker = MessageBroker() + listener = Listener() + message_broker.subscribe(MessageType.test1, listener) + message_broker.publish(Message(MessageType.test1, "foo")) + message_broker.publish(Message(MessageType.test2, "bar")) + self.assertEqual(listener.calls[0], Message(MessageType.test1, "foo")) + + def test_unsubscribe(self): + message_broker = MessageBroker() + listener = Listener() + message_broker.subscribe(MessageType.test1, listener) + message_broker.publish(Message(MessageType.test1, "a")) + message_broker.unsubscribe(listener) + message_broker.publish(Message(MessageType.test1, "b")) + self.assertEqual(len(listener.calls), 1) + self.assertEqual(listener.calls[0], Message(MessageType.test1, "a")) + + def test_unsubscribe_unknown_listener(self): + """nothing happens if we unsubscribe an unknown listener""" + message_broker = MessageBroker() + listener1 = Listener() + listener2 = Listener() + message_broker.subscribe(MessageType.test1, listener1) + message_broker.unsubscribe(listener2) + message_broker.publish(Message(MessageType.test1, "a")) + self.assertEqual(listener1.calls[0], Message(MessageType.test1, "a")) + + def test_preserves_order(self): + message_broker = MessageBroker() + calls = [] + + def listener1(_): + message_broker.publish(Message(MessageType.test2, "f")) + calls.append(1) + + def listener2(_): + message_broker.publish(Message(MessageType.test2, "f")) + calls.append(2) + + def listener3(_): + message_broker.publish(Message(MessageType.test2, "f")) + calls.append(3) + + def listener4(_): + calls.append(4) + + message_broker.subscribe(MessageType.test1, listener1) + message_broker.subscribe(MessageType.test1, listener2) + message_broker.subscribe(MessageType.test1, listener3) + message_broker.subscribe(MessageType.test2, listener4) + message_broker.publish(Message(MessageType.test1, "")) + + first = calls[:3] + first.sort() + self.assertEqual([1, 2, 3], first) + self.assertEqual([4, 4, 4], calls[3:]) + + +@test_setup +class TestSignal(unittest.TestCase): + def test_eq(self): + self.assertEqual(Signal(MessageType.uinputs), Signal(MessageType.uinputs)) + self.assertNotEqual(Signal(MessageType.uinputs), Signal(MessageType.groups)) + self.assertNotEqual(Signal(MessageType.uinputs), "Signal: MessageType.uinputs") diff --git a/tests/unit/test_migrations.py b/tests/unit/test_migrations.py new file mode 100644 index 0000000..8bc6f11 --- /dev/null +++ b/tests/unit/test_migrations.py @@ -0,0 +1,726 @@ +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + +import json +import os +import shutil +import unittest +from packaging import version + +from evdev.ecodes import ( + EV_KEY, + EV_ABS, + ABS_HAT0X, + ABS_X, + ABS_Y, + ABS_RX, + ABS_RY, + EV_REL, + REL_X, + REL_Y, + REL_WHEEL_HI_RES, + REL_HWHEEL_HI_RES, +) + +from inputremapper.configs.input_config import InputCombination, InputConfig +from inputremapper.configs.mapping import UIMapping +from inputremapper.configs.migrations import Migrations +from inputremapper.configs.paths import PathUtils +from inputremapper.configs.preset import Preset +from inputremapper.injection.global_uinputs import GlobalUInputs, UInput +from inputremapper.logging.logger import VERSION +from inputremapper.user import UserUtils +from tests.lib.test_setup import test_setup +from tests.lib.tmp import tmp + + +@test_setup +class TestMigrations(unittest.TestCase): + def setUp(self): + # some extra care to ensure those tests are not destroying actual presets + self.assertTrue(UserUtils.home.startswith("/tmp")) + self.assertTrue(PathUtils.config_path().startswith("/tmp")) + self.assertTrue(PathUtils.get_preset_path().startswith("/tmp")) + self.assertTrue(PathUtils.get_preset_path("foo", "bar").startswith("/tmp")) + self.assertTrue(PathUtils.get_config_path().startswith("/tmp")) + self.assertTrue(PathUtils.get_config_path("foo").startswith("/tmp")) + + self.v1_dir = os.path.join(UserUtils.home, ".config", "input-remapper") + self.beta_dir = os.path.join( + UserUtils.home, ".config", "input-remapper", "beta_1.6.0-beta" + ) + + global_uinputs = GlobalUInputs(UInput) + global_uinputs.prepare_all() + self.migrations = Migrations(global_uinputs) + + def test_migrate_suffix(self): + old = os.path.join(PathUtils.config_path(), "config") + new = os.path.join(PathUtils.config_path(), "config.json") + + try: + os.remove(new) + except FileNotFoundError: + pass + + PathUtils.touch(old) + with open(old, "w") as f: + f.write("{}") + + self.migrations.migrate() + self.assertTrue(os.path.exists(new)) + self.assertFalse(os.path.exists(old)) + + def test_rename_config(self): + old = os.path.join(UserUtils.home, ".config", "key-mapper") + new = PathUtils.config_path() + + # we are not destroying our actual config files with this test + self.assertTrue(new.startswith(tmp), f'Expected "{new}" to start with "{tmp}"') + + try: + shutil.rmtree(new) + except FileNotFoundError: + pass + + old_config_json = os.path.join(old, "config.json") + PathUtils.touch(old_config_json) + with open(old_config_json, "w") as f: + f.write('{"foo":"bar"}') + + self.migrations.migrate() + + self.assertTrue(os.path.exists(new)) + self.assertFalse(os.path.exists(old)) + + new_config_json = os.path.join(new, "config.json") + with open(new_config_json, "r") as f: + moved_config = json.loads(f.read()) + self.assertEqual(moved_config["foo"], "bar") + + def test_wont_migrate_suffix(self): + old = os.path.join(PathUtils.config_path(), "config") + new = os.path.join(PathUtils.config_path(), "config.json") + + PathUtils.touch(new) + with open(new, "w") as f: + f.write("{}") + + PathUtils.touch(old) + with open(old, "w") as f: + f.write("{}") + + self.migrations.migrate() + self.assertTrue(os.path.exists(new)) + self.assertTrue(os.path.exists(old)) + + def test_migrate_preset(self): + if os.path.exists(PathUtils.config_path()): + shutil.rmtree(PathUtils.config_path()) + + p1 = os.path.join(PathUtils.config_path(), "foo1", "bar1.json") + p2 = os.path.join(PathUtils.config_path(), "foo2", "bar2.json") + PathUtils.touch(p1) + PathUtils.touch(p2) + + with open(p1, "w") as f: + f.write("{}") + + with open(p2, "w") as f: + f.write("{}") + + self.migrations.migrate() + + self.assertFalse( + os.path.exists(os.path.join(PathUtils.config_path(), "foo1", "bar1.json")) + ) + self.assertFalse( + os.path.exists(os.path.join(PathUtils.config_path(), "foo2", "bar2.json")) + ) + + self.assertTrue( + os.path.exists( + os.path.join(PathUtils.config_path(), "presets", "foo1", "bar1.json") + ), + ) + self.assertTrue( + os.path.exists( + os.path.join(PathUtils.config_path(), "presets", "foo2", "bar2.json") + ), + ) + + def test_wont_migrate_preset(self): + if os.path.exists(PathUtils.config_path()): + shutil.rmtree(PathUtils.config_path()) + + p1 = os.path.join(PathUtils.config_path(), "foo1", "bar1.json") + p2 = os.path.join(PathUtils.config_path(), "foo2", "bar2.json") + PathUtils.touch(p1) + PathUtils.touch(p2) + + with open(p1, "w") as f: + f.write("{}") + + with open(p2, "w") as f: + f.write("{}") + + # already migrated + PathUtils.mkdir(os.path.join(PathUtils.config_path(), "presets")) + + self.migrations.migrate() + + self.assertTrue( + os.path.exists(os.path.join(PathUtils.config_path(), "foo1", "bar1.json")) + ) + self.assertTrue( + os.path.exists(os.path.join(PathUtils.config_path(), "foo2", "bar2.json")) + ) + + self.assertFalse( + os.path.exists( + os.path.join(PathUtils.config_path(), "presets", "foo1", "bar1.json") + ), + ) + self.assertFalse( + os.path.exists( + os.path.join(PathUtils.config_path(), "presets", "foo2", "bar2.json") + ), + ) + + def test_migrate_mappings(self): + """Test if mappings are migrated correctly + + mappings like + {(type, code): symbol} or {(type, code, value): symbol} should migrate + to {InputCombination: {target: target, symbol: symbol, ...}} + """ + path = os.path.join( + PathUtils.config_path(), "presets", "Foo Device", "test.json" + ) + os.makedirs(os.path.dirname(path), exist_ok=True) + with open(path, "w") as file: + json.dump( + { + "mapping": { + f"{EV_KEY},1": "a", + f"{EV_KEY}, 2, 1": "BTN_B", # can be mapped to "gamepad" + f"{EV_KEY}, 3, 1": "BTN_1", # can not be mapped + f"{EV_ABS},{ABS_HAT0X},-1": "b", + f"{EV_ABS},1,1+{EV_ABS},2,-1+{EV_ABS},3,1": "c", + f"{EV_KEY}, 4, 1": ("d", "keyboard"), + f"{EV_KEY}, 5, 1": ("e", "foo"), # unknown target + f"{EV_KEY}, 6, 1": ("key(a, b)", "keyboard"), # broken macro + # ignored because broken + "3,1,1,2": "e", + "3": "e", + ",,+3,1,2": "g", + "": "h", + } + }, + file, + ) + self.migrations.migrate() + # use UIMapping to also load invalid mappings + preset = Preset(PathUtils.get_preset_path("Foo Device", "test"), UIMapping) + preset.load() + + self.assertEqual( + preset.get_mapping(InputCombination([InputConfig(type=EV_KEY, code=1)])), + UIMapping( + input_combination=InputCombination([InputConfig(type=EV_KEY, code=1)]), + target_uinput="keyboard", + output_symbol="a", + ), + ) + self.assertEqual( + preset.get_mapping(InputCombination([InputConfig(type=EV_KEY, code=2)])), + UIMapping( + input_combination=InputCombination([InputConfig(type=EV_KEY, code=2)]), + target_uinput="gamepad", + output_symbol="BTN_B", + ), + ) + self.assertEqual( + preset.get_mapping(InputCombination([InputConfig(type=EV_KEY, code=3)])), + UIMapping( + input_combination=InputCombination([InputConfig(type=EV_KEY, code=3)]), + target_uinput="keyboard", + output_symbol="BTN_1\n# Broken mapping:\n# No target can handle all specified keycodes", + ), + ) + self.assertEqual( + preset.get_mapping(InputCombination([InputConfig(type=EV_KEY, code=4)])), + UIMapping( + input_combination=InputCombination([InputConfig(type=EV_KEY, code=4)]), + target_uinput="keyboard", + output_symbol="d", + ), + ) + self.assertEqual( + preset.get_mapping( + InputCombination( + [InputConfig(type=EV_ABS, code=ABS_HAT0X, analog_threshold=-1)] + ) + ), + UIMapping( + input_combination=InputCombination( + [InputConfig(type=EV_ABS, code=ABS_HAT0X, analog_threshold=-1)] + ), + target_uinput="keyboard", + output_symbol="b", + ), + ) + self.assertEqual( + preset.get_mapping( + InputCombination( + InputCombination.from_tuples( + (EV_ABS, 1, 1), (EV_ABS, 2, -1), (EV_ABS, 3, 1) + ) + ), + ), + UIMapping( + input_combination=InputCombination( + InputCombination.from_tuples( + (EV_ABS, 1, 1), (EV_ABS, 2, -1), (EV_ABS, 3, 1) + ), + ), + target_uinput="keyboard", + output_symbol="c", + ), + ) + self.assertEqual( + preset.get_mapping(InputCombination([InputConfig(type=EV_KEY, code=5)])), + UIMapping( + input_combination=InputCombination([InputConfig(type=EV_KEY, code=5)]), + target_uinput="foo", + output_symbol="e", + ), + ) + self.assertEqual( + preset.get_mapping(InputCombination([InputConfig(type=EV_KEY, code=6)])), + UIMapping( + input_combination=InputCombination([InputConfig(type=EV_KEY, code=6)]), + target_uinput="keyboard", + output_symbol="key(a, b)", + ), + ) + + self.assertEqual(8, len(preset)) + + def test_migrate_otherwise(self): + path = os.path.join( + PathUtils.config_path(), "presets", "Foo Device", "test.json" + ) + os.makedirs(os.path.dirname(path), exist_ok=True) + with open(path, "w") as file: + json.dump( + { + "mapping": { + f"{EV_KEY},1,1": ("otherwise + otherwise", "keyboard"), + f"{EV_KEY},2,1": ("bar($otherwise)", "keyboard"), + f"{EV_KEY},3,1": ("foo(otherwise=qux)", "keyboard"), + f"{EV_KEY},4,1": ("qux(otherwise).bar(otherwise = 1)", "foo"), + f"{EV_KEY},5,1": ("foo(otherwise1=2qux)", "keyboard"), + } + }, + file, + ) + + self.migrations.migrate() + + preset = Preset(PathUtils.get_preset_path("Foo Device", "test"), UIMapping) + preset.load() + + self.assertEqual( + preset.get_mapping(InputCombination([InputConfig(type=EV_KEY, code=1)])), + UIMapping( + input_combination=InputCombination([InputConfig(type=EV_KEY, code=1)]), + target_uinput="keyboard", + output_symbol="otherwise + otherwise", + ), + ) + self.assertEqual( + preset.get_mapping(InputCombination([InputConfig(type=EV_KEY, code=2)])), + UIMapping( + input_combination=InputCombination([InputConfig(type=EV_KEY, code=2)]), + target_uinput="keyboard", + output_symbol="bar($otherwise)", + ), + ) + self.assertEqual( + preset.get_mapping(InputCombination([InputConfig(type=EV_KEY, code=3)])), + UIMapping( + input_combination=InputCombination([InputConfig(type=EV_KEY, code=3)]), + target_uinput="keyboard", + output_symbol="foo(else=qux)", + ), + ) + self.assertEqual( + preset.get_mapping(InputCombination([InputConfig(type=EV_KEY, code=4)])), + UIMapping( + input_combination=InputCombination([InputConfig(type=EV_KEY, code=4)]), + target_uinput="foo", + output_symbol="qux(otherwise).bar(else=1)", + ), + ) + self.assertEqual( + preset.get_mapping(InputCombination([InputConfig(type=EV_KEY, code=5)])), + UIMapping( + input_combination=InputCombination([InputConfig(type=EV_KEY, code=5)]), + target_uinput="keyboard", + output_symbol="foo(otherwise1=2qux)", + ), + ) + + def test_add_version(self): + path = os.path.join(PathUtils.config_path(), "config.json") + os.makedirs(os.path.dirname(path), exist_ok=True) + with open(path, "w") as file: + file.write("{}") + + self.migrations.migrate() + self.assertEqual( + version.parse(VERSION), + self.migrations.config_version(), + ) + + def test_update_version(self): + path = os.path.join(PathUtils.config_path(), "config.json") + os.makedirs(os.path.dirname(path), exist_ok=True) + with open(path, "w") as file: + json.dump({"version": "0.1.0"}, file) + + self.migrations.migrate() + self.assertEqual( + version.parse(VERSION), + self.migrations.config_version(), + ) + + def test_config_version(self): + path = os.path.join(PathUtils.config_path(), "config.json") + with open(path, "w") as file: + file.write("{}") + + self.assertEqual("0.0.0", self.migrations.config_version().public) + + try: + os.remove(path) + except FileNotFoundError: + pass + + self.assertEqual("0.0.0", self.migrations.config_version().public) + + def test_migrate_left_and_right_purpose(self): + path = os.path.join( + PathUtils.config_path(), "presets", "Foo Device", "test.json" + ) + os.makedirs(os.path.dirname(path), exist_ok=True) + with open(path, "w") as file: + json.dump( + { + "gamepad": { + "joystick": { + "left_purpose": "mouse", + "right_purpose": "wheel", + "pointer_speed": 50, + "x_scroll_speed": 10, + "y_scroll_speed": 20, + } + } + }, + file, + ) + self.migrations.migrate() + + preset = Preset(PathUtils.get_preset_path("Foo Device", "test"), UIMapping) + preset.load() + # 2 mappings for mouse + # 2 mappings for wheel + self.assertEqual(len(preset), 4) + self.assertEqual( + preset.get_mapping( + InputCombination([InputConfig(type=EV_ABS, code=ABS_X)]) + ), + UIMapping( + input_combination=InputCombination( + [InputConfig(type=EV_ABS, code=ABS_X)] + ), + target_uinput="mouse", + output_type=EV_REL, + output_code=REL_X, + gain=50 / 100, + ), + ) + self.assertEqual( + preset.get_mapping( + InputCombination([InputConfig(type=EV_ABS, code=ABS_Y)]) + ), + UIMapping( + input_combination=InputCombination( + [InputConfig(type=EV_ABS, code=ABS_Y)] + ), + target_uinput="mouse", + output_type=EV_REL, + output_code=REL_Y, + gain=50 / 100, + ), + ) + self.assertEqual( + preset.get_mapping( + InputCombination([InputConfig(type=EV_ABS, code=ABS_RX)]) + ), + UIMapping( + input_combination=InputCombination( + [InputConfig(type=EV_ABS, code=ABS_RX)] + ), + target_uinput="mouse", + output_type=EV_REL, + output_code=REL_HWHEEL_HI_RES, + gain=10, + ), + ) + self.assertEqual( + preset.get_mapping( + InputCombination([InputConfig(type=EV_ABS, code=ABS_RY)]) + ), + UIMapping( + input_combination=InputCombination( + [InputConfig(type=EV_ABS, code=ABS_RY)] + ), + target_uinput="mouse", + output_type=EV_REL, + output_code=REL_WHEEL_HI_RES, + gain=20, + ), + ) + + def test_migrate_left_and_right_purpose2(self): + # same as above, but left and right is swapped + + path = os.path.join( + PathUtils.config_path(), "presets", "Foo Device", "test.json" + ) + os.makedirs(os.path.dirname(path), exist_ok=True) + with open(path, "w") as file: + json.dump( + { + "gamepad": { + "joystick": { + "right_purpose": "mouse", + "left_purpose": "wheel", + "pointer_speed": 50, + "x_scroll_speed": 10, + "y_scroll_speed": 20, + } + } + }, + file, + ) + self.migrations.migrate() + + preset = Preset(PathUtils.get_preset_path("Foo Device", "test"), UIMapping) + preset.load() + # 2 mappings for mouse + # 2 mappings for wheel + self.assertEqual(len(preset), 4) + self.assertEqual( + preset.get_mapping( + InputCombination([InputConfig(type=EV_ABS, code=ABS_RX)]) + ), + UIMapping( + input_combination=InputCombination( + [InputConfig(type=EV_ABS, code=ABS_RX)] + ), + target_uinput="mouse", + output_type=EV_REL, + output_code=REL_X, + gain=50 / 100, + ), + ) + self.assertEqual( + preset.get_mapping( + InputCombination([InputConfig(type=EV_ABS, code=ABS_RY)]) + ), + UIMapping( + input_combination=InputCombination( + [InputConfig(type=EV_ABS, code=ABS_RY)] + ), + target_uinput="mouse", + output_type=EV_REL, + output_code=REL_Y, + gain=50 / 100, + ), + ) + self.assertEqual( + preset.get_mapping( + InputCombination([InputConfig(type=EV_ABS, code=ABS_X)]) + ), + UIMapping( + input_combination=InputCombination( + [InputConfig(type=EV_ABS, code=ABS_X)] + ), + target_uinput="mouse", + output_type=EV_REL, + output_code=REL_HWHEEL_HI_RES, + gain=10, + ), + ) + self.assertEqual( + preset.get_mapping( + InputCombination([InputConfig(type=EV_ABS, code=ABS_Y)]) + ), + UIMapping( + input_combination=InputCombination( + [InputConfig(type=EV_ABS, code=ABS_Y)] + ), + target_uinput="mouse", + output_type=EV_REL, + output_code=REL_WHEEL_HI_RES, + gain=20, + ), + ) + + def _create_v1_setup(self): + """Create all files needed to mimic an outdated v1 configuration.""" + device_name = "device_name" + + PathUtils.mkdir(os.path.join(self.v1_dir, "presets", device_name)) + v1_config = {"autoload": {device_name: "foo"}, "version": "1.0"} + with open(os.path.join(self.v1_dir, "config.json"), "w") as file: + json.dump(v1_config, file) + # insert something outdated that will be migrated, to ensure the files are + # first copied and then migrated. + with open( + os.path.join(self.v1_dir, "presets", device_name, "foo.json"), "w" + ) as file: + json.dump({"mapping": {f"{EV_KEY},1": "a"}}, file) + + def _create_beta_setup(self): + """Create all files needed to mimic a beta configuration.""" + device_name = "device_name" + + # same here, but a different contents to tell the difference + PathUtils.mkdir(os.path.join(self.beta_dir, "presets", device_name)) + beta_config = {"autoload": {device_name: "bar"}, "version": "1.6"} + with open(os.path.join(self.beta_dir, "config.json"), "w") as file: + json.dump(beta_config, file) + with open( + os.path.join(self.beta_dir, "presets", device_name, "bar.json"), "w" + ) as file: + json.dump( + [ + { + "input_combination": [ + {"type": EV_KEY, "code": 1}, + ], + "target_uinput": "keyboard", + "output_symbol": "b", + "mapping_type": "key_macro", + } + ], + file, + ) + + def test_prioritize_v1_over_beta_configs(self): + # if both v1 and beta presets and config exist, migrate v1 + PathUtils.remove(PathUtils.get_config_path()) + + device_name = "device_name" + self._create_v1_setup() + self._create_beta_setup() + + self.assertFalse(os.path.exists(PathUtils.get_preset_path(device_name, "foo"))) + self.assertFalse(os.path.exists(PathUtils.get_config_path("config.json"))) + + self.migrations.migrate() + + self.assertTrue(os.path.exists(PathUtils.get_preset_path(device_name, "foo"))) + self.assertTrue(os.path.exists(PathUtils.get_config_path("config.json"))) + self.assertFalse(os.path.exists(PathUtils.get_preset_path(device_name, "bar"))) + + # expect all original files to still exist + self.assertTrue(os.path.join(self.v1_dir, "config.json")) + self.assertTrue(os.path.join(self.v1_dir, "presets", "foo.json")) + self.assertTrue(os.path.join(self.beta_dir, "config.json")) + self.assertTrue(os.path.join(self.beta_dir, "presets", "bar.json")) + + # v1 configs should be in the v2 dir now, and migrated + with open(PathUtils.get_config_path("config.json"), "r") as f: + config_json = json.load(f) + self.assertDictEqual( + config_json, {"autoload": {device_name: "foo"}, "version": VERSION} + ) + with open(PathUtils.get_preset_path(device_name, "foo.json"), "r") as f: + os.system(f'cat { PathUtils.get_preset_path(device_name, "foo.json") }') + preset_foo_json = json.load(f) + self.assertEqual( + preset_foo_json, + [ + { + "input_combination": [ + {"type": EV_KEY, "code": 1}, + ], + "target_uinput": "keyboard", + "output_symbol": "a", + "mapping_type": "key_macro", + } + ], + ) + + def test_copy_over_beta_configs(self): + # same as test_prioritize_v1_over_beta_configs, but only create the beta + # directory without any v1 presets. + PathUtils.remove(PathUtils.get_config_path()) + + device_name = "device_name" + self._create_beta_setup() + + self.assertFalse(os.path.exists(PathUtils.get_preset_path(device_name, "bar"))) + self.assertFalse(os.path.exists(PathUtils.get_config_path("config.json"))) + + self.migrations.migrate() + + self.assertTrue(os.path.exists(PathUtils.get_preset_path(device_name, "bar"))) + self.assertTrue(os.path.exists(PathUtils.get_config_path("config.json"))) + + # expect all original files to still exist + self.assertTrue(os.path.join(self.beta_dir, "config.json")) + self.assertTrue(os.path.join(self.beta_dir, "presets", "bar.json")) + + # beta configs should be in the v2 dir now + with open(PathUtils.get_config_path("config.json"), "r") as f: + config_json = json.load(f) + self.assertDictEqual( + config_json, {"autoload": {device_name: "bar"}, "version": VERSION} + ) + with open(PathUtils.get_preset_path(device_name, "bar.json"), "r") as f: + os.system(f'cat { PathUtils.get_preset_path(device_name, "bar.json") }') + preset_foo_json = json.load(f) + self.assertEqual( + preset_foo_json, + [ + { + "input_combination": [ + {"type": EV_KEY, "code": 1}, + ], + "target_uinput": "keyboard", + "output_symbol": "b", + "mapping_type": "key_macro", + } + ], + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/test_paths.py b/tests/unit/test_paths.py new file mode 100644 index 0000000..49b898d --- /dev/null +++ b/tests/unit/test_paths.py @@ -0,0 +1,73 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + + +import os +import tempfile +import unittest + +from inputremapper.configs.paths import PathUtils +from tests.lib.test_setup import test_setup +from tests.lib.tmp import tmp + + +def _raise(error): + raise error + + +@test_setup +class TestPaths(unittest.TestCase): + def test_touch(self): + with tempfile.TemporaryDirectory() as local_tmp: + path_abcde = os.path.join(local_tmp, "a/b/c/d/e") + PathUtils.touch(path_abcde) + self.assertTrue(os.path.exists(path_abcde)) + self.assertTrue(os.path.isfile(path_abcde)) + self.assertRaises( + ValueError, + lambda: PathUtils.touch(os.path.join(local_tmp, "a/b/c/d/f/")), + ) + + def test_mkdir(self): + with tempfile.TemporaryDirectory() as local_tmp: + path_bcde = os.path.join(local_tmp, "b/c/d/e") + PathUtils.mkdir(path_bcde) + self.assertTrue(os.path.exists(path_bcde)) + self.assertTrue(os.path.isdir(path_bcde)) + + def test_get_preset_path(self): + self.assertTrue( + PathUtils.get_preset_path().startswith(PathUtils.get_config_path()) + ) + self.assertTrue(PathUtils.get_preset_path().endswith("presets")) + self.assertTrue(PathUtils.get_preset_path("a").endswith("presets/a")) + self.assertTrue( + PathUtils.get_preset_path("a", "b").endswith("presets/a/b.json") + ) + + def test_get_config_path(self): + # might end with /beta_XXX + self.assertTrue( + PathUtils.get_config_path().startswith(f"{tmp}/.config/input-remapper") + ) + self.assertTrue(PathUtils.get_config_path("a", "b").endswith("a/b")) + + def test_split_all(self): + self.assertListEqual(PathUtils.split_all("a/b/c/d"), ["a", "b", "c", "d"]) diff --git a/tests/unit/test_preset.py b/tests/unit/test_preset.py new file mode 100644 index 0000000..e3db558 --- /dev/null +++ b/tests/unit/test_preset.py @@ -0,0 +1,486 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + +import os +import unittest +from unittest.mock import patch + +from evdev.ecodes import EV_KEY, EV_ABS + +from inputremapper.configs.input_config import InputCombination, InputConfig +from inputremapper.configs.mapping import Mapping +from inputremapper.configs.mapping import UIMapping +from inputremapper.configs.paths import PathUtils +from inputremapper.configs.preset import Preset +from tests.lib.test_setup import test_setup + + +@test_setup +class TestPreset(unittest.TestCase): + def setUp(self): + self.preset = Preset(PathUtils.get_preset_path("foo", "bar2")) + self.assertFalse(self.preset.has_unsaved_changes()) + + def test_is_mapped_multiple_times(self): + combination = InputCombination( + InputCombination.from_tuples((1, 1, 1), (2, 2, 2), (3, 3, 3), (4, 4, 4)) + ) + permutations = combination.get_permutations() + self.assertEqual(len(permutations), 6) + + self.preset._mappings[permutations[0]] = Mapping( + input_combination=permutations[0], + target_uinput="keyboard", + output_symbol="a", + ) + self.assertFalse(self.preset._is_mapped_multiple_times(permutations[2])) + + self.preset._mappings[permutations[1]] = Mapping( + input_combination=permutations[1], + target_uinput="keyboard", + output_symbol="a", + ) + self.assertTrue(self.preset._is_mapped_multiple_times(permutations[2])) + + def test_has_unsaved_changes(self): + self.preset.path = PathUtils.get_preset_path("foo", "bar2") + self.preset.add(Mapping.from_combination()) + self.assertTrue(self.preset.has_unsaved_changes()) + self.preset.save() + self.assertFalse(self.preset.has_unsaved_changes()) + + self.preset.empty() + self.assertEqual(len(self.preset), 0) + # empty preset but non-empty file + self.assertTrue(self.preset.has_unsaved_changes()) + + # load again from the disc + self.preset.load() + self.assertEqual( + self.preset.get_mapping(InputCombination.empty_combination()), + Mapping.from_combination(), + ) + self.assertFalse(self.preset.has_unsaved_changes()) + + # change the path to a non exiting file + self.preset.path = PathUtils.get_preset_path("bar", "foo") + # the preset has a mapping, the file has not + self.assertTrue(self.preset.has_unsaved_changes()) + + # change back to the original path + self.preset.path = PathUtils.get_preset_path("foo", "bar2") + # no difference between file and memory + self.assertFalse(self.preset.has_unsaved_changes()) + + # modify the mapping + mapping = self.preset.get_mapping(InputCombination.empty_combination()) + mapping.gain = 0.5 + self.assertTrue(self.preset.has_unsaved_changes()) + self.preset.load() + + self.preset.path = PathUtils.get_preset_path("bar", "foo") + self.preset.remove(Mapping.from_combination().input_combination) + # empty preset and empty file + self.assertFalse(self.preset.has_unsaved_changes()) + + self.preset.path = PathUtils.get_preset_path("foo", "bar2") + # empty preset, but non-empty file + self.assertTrue(self.preset.has_unsaved_changes()) + self.preset.load() + self.assertEqual(len(self.preset), 1) + self.assertFalse(self.preset.has_unsaved_changes()) + + # delete the preset from the system: + self.preset.empty() + self.preset.save() + self.preset.load() + self.assertFalse(self.preset.has_unsaved_changes()) + self.assertEqual(len(self.preset), 0) + + def test_save_load(self): + one = InputConfig(type=EV_KEY, code=10) + two = InputConfig(type=EV_KEY, code=11) + three = InputConfig(type=EV_KEY, code=12) + + self.preset.add( + Mapping.from_combination(InputCombination([one]), "keyboard", "1") + ) + self.preset.add( + Mapping.from_combination(InputCombination([two]), "keyboard", "2") + ) + self.preset.add( + Mapping.from_combination(InputCombination((two, three)), "keyboard", "3"), + ) + self.preset.path = PathUtils.get_preset_path("Foo Device", "test") + self.preset.save() + + path = os.path.join( + PathUtils.config_path(), "presets", "Foo Device", "test.json" + ) + self.assertTrue(os.path.exists(path)) + + loaded = Preset(PathUtils.get_preset_path("Foo Device", "test")) + self.assertEqual(len(loaded), 0) + loaded.load() + + self.assertEqual(len(loaded), 3) + self.assertRaises(TypeError, loaded.get_mapping, one) + self.assertEqual( + loaded.get_mapping(InputCombination([one])), + Mapping.from_combination(InputCombination([one]), "keyboard", "1"), + ) + self.assertEqual( + loaded.get_mapping(InputCombination([two])), + Mapping.from_combination(InputCombination([two]), "keyboard", "2"), + ) + self.assertEqual( + loaded.get_mapping(InputCombination([two, three])), + Mapping.from_combination(InputCombination([two, three]), "keyboard", "3"), + ) + + # load missing file + preset = Preset(PathUtils.get_config_path("missing_file.json")) + self.assertRaises(FileNotFoundError, preset.load) + + def test_modify_mapping(self): + ev_1 = InputCombination([InputConfig(type=EV_KEY, code=1)]) + ev_3 = InputCombination([InputConfig(type=EV_KEY, code=2)]) + # only values between -99 and 99 are allowed as mapping for EV_ABS or EV_REL + ev_4 = InputCombination([InputConfig(type=EV_ABS, code=1, analog_threshold=99)]) + + # add the first mapping + self.preset.add(Mapping.from_combination(ev_1, "keyboard", "a")) + self.assertTrue(self.preset.has_unsaved_changes()) + self.assertEqual(len(self.preset), 1) + + # change ev_1 to ev_3 and change a to b + mapping = self.preset.get_mapping(ev_1) + mapping.input_combination = ev_3 + mapping.output_symbol = "b" + self.assertIsNone(self.preset.get_mapping(ev_1)) + self.assertEqual( + self.preset.get_mapping(ev_3), + Mapping.from_combination(ev_3, "keyboard", "b"), + ) + self.assertEqual(len(self.preset), 1) + + # add 4 + self.preset.add(Mapping.from_combination(ev_4, "keyboard", "c")) + self.assertEqual( + self.preset.get_mapping(ev_3), + Mapping.from_combination(ev_3, "keyboard", "b"), + ) + self.assertEqual( + self.preset.get_mapping(ev_4), + Mapping.from_combination(ev_4, "keyboard", "c"), + ) + self.assertEqual(len(self.preset), 2) + + # change the preset of 4 to d + mapping = self.preset.get_mapping(ev_4) + mapping.output_symbol = "d" + self.assertEqual( + self.preset.get_mapping(ev_4), + Mapping.from_combination(ev_4, "keyboard", "d"), + ) + self.assertEqual(len(self.preset), 2) + + # try to change combination of 4 to 3 + mapping = self.preset.get_mapping(ev_4) + with self.assertRaises(KeyError): + mapping.input_combination = ev_3 + + self.assertEqual( + self.preset.get_mapping(ev_3), + Mapping.from_combination(ev_3, "keyboard", "b"), + ) + self.assertEqual( + self.preset.get_mapping(ev_4), + Mapping.from_combination(ev_4, "keyboard", "d"), + ) + self.assertEqual(len(self.preset), 2) + + def test_avoids_redundant_saves(self): + with patch.object(self.preset, "has_unsaved_changes", lambda: False): + self.preset.path = PathUtils.get_preset_path("foo", "bar2") + self.preset.add(Mapping.from_combination()) + self.preset.save() + + with open(PathUtils.get_preset_path("foo", "bar2"), "r") as f: + content = f.read() + + self.assertFalse(content) + + def test_combinations(self): + ev_1 = InputConfig(type=EV_KEY, code=1, analog_threshold=111) + ev_2 = InputConfig(type=EV_KEY, code=1, analog_threshold=222) + ev_3 = InputConfig(type=EV_KEY, code=2, analog_threshold=111) + ev_4 = InputConfig(type=EV_ABS, code=1, analog_threshold=99) + combi_1 = InputCombination((ev_1, ev_2, ev_3)) + combi_2 = InputCombination((ev_2, ev_1, ev_3)) + combi_3 = InputCombination((ev_1, ev_2, ev_4)) + + self.preset.add(Mapping.from_combination(combi_1, "keyboard", "a")) + self.assertEqual( + self.preset.get_mapping(combi_1), + Mapping.from_combination(combi_1, "keyboard", "a"), + ) + self.assertEqual( + self.preset.get_mapping(combi_2), + Mapping.from_combination(combi_1, "keyboard", "a"), + ) + # since combi_1 and combi_2 are equivalent, this raises a KeyError + self.assertRaises( + KeyError, + self.preset.add, + Mapping.from_combination(combi_2, "keyboard", "b"), + ) + self.assertEqual( + self.preset.get_mapping(combi_1), + Mapping.from_combination(combi_1, "keyboard", "a"), + ) + self.assertEqual( + self.preset.get_mapping(combi_2), + Mapping.from_combination(combi_1, "keyboard", "a"), + ) + + self.preset.add(Mapping.from_combination(combi_3, "keyboard", "c")) + self.assertEqual( + self.preset.get_mapping(combi_1), + Mapping.from_combination(combi_1, "keyboard", "a"), + ) + self.assertEqual( + self.preset.get_mapping(combi_2), + Mapping.from_combination(combi_1, "keyboard", "a"), + ) + self.assertEqual( + self.preset.get_mapping(combi_3), + Mapping.from_combination(combi_3, "keyboard", "c"), + ) + + mapping = self.preset.get_mapping(combi_1) + mapping.output_symbol = "c" + with self.assertRaises(KeyError): + mapping.input_combination = combi_3 + + self.assertEqual( + self.preset.get_mapping(combi_1), + Mapping.from_combination(combi_1, "keyboard", "c"), + ) + self.assertEqual( + self.preset.get_mapping(combi_2), + Mapping.from_combination(combi_1, "keyboard", "c"), + ) + self.assertEqual( + self.preset.get_mapping(combi_3), + Mapping.from_combination(combi_3, "keyboard", "c"), + ) + + def test_remove(self): + # does nothing + ev_1 = InputCombination([InputConfig(type=EV_KEY, code=40)]) + ev_2 = InputCombination([InputConfig(type=EV_KEY, code=30)]) + ev_3 = InputCombination([InputConfig(type=EV_KEY, code=20)]) + ev_4 = InputCombination([InputConfig(type=EV_KEY, code=10)]) + + self.assertRaises(TypeError, self.preset.remove, (EV_KEY, 10, 1)) + self.preset.remove(ev_1) + self.assertFalse(self.preset.has_unsaved_changes()) + self.assertEqual(len(self.preset), 0) + + self.preset.add(Mapping.from_combination(input_combination=ev_1)) + self.assertEqual(len(self.preset), 1) + self.preset.remove(ev_1) + self.assertEqual(len(self.preset), 0) + + self.preset.add(Mapping.from_combination(ev_4, "keyboard", "KEY_KP1")) + self.assertTrue(self.preset.has_unsaved_changes()) + self.preset.add(Mapping.from_combination(ev_3, "keyboard", "KEY_KP2")) + self.preset.add(Mapping.from_combination(ev_2, "keyboard", "KEY_KP3")) + self.assertEqual(len(self.preset), 3) + self.preset.remove(ev_3) + self.assertEqual(len(self.preset), 2) + self.assertEqual( + self.preset.get_mapping(ev_4), + Mapping.from_combination(ev_4, "keyboard", "KEY_KP1"), + ) + self.assertIsNone(self.preset.get_mapping(ev_3)) + self.assertEqual( + self.preset.get_mapping(ev_2), + Mapping.from_combination(ev_2, "keyboard", "KEY_KP3"), + ) + + def test_empty(self): + self.preset.add( + Mapping.from_combination( + InputCombination([InputConfig(type=EV_KEY, code=10)]), + "keyboard", + "1", + ), + ) + self.preset.add( + Mapping.from_combination( + InputCombination([InputConfig(type=EV_KEY, code=11)]), + "keyboard", + "2", + ), + ) + self.preset.add( + Mapping.from_combination( + InputCombination([InputConfig(type=EV_KEY, code=12)]), + "keyboard", + "3", + ), + ) + self.assertEqual(len(self.preset), 3) + self.preset.path = PathUtils.get_config_path("test.json") + self.preset.save() + self.assertFalse(self.preset.has_unsaved_changes()) + + self.preset.empty() + self.assertEqual(self.preset.path, PathUtils.get_config_path("test.json")) + self.assertTrue(self.preset.has_unsaved_changes()) + self.assertEqual(len(self.preset), 0) + + def test_clear(self): + self.preset.add( + Mapping.from_combination( + InputCombination([InputConfig(type=EV_KEY, code=10)]), + "keyboard", + "1", + ), + ) + self.preset.add( + Mapping.from_combination( + InputCombination([InputConfig(type=EV_KEY, code=11)]), + "keyboard", + "2", + ), + ) + self.preset.add( + Mapping.from_combination( + InputCombination([InputConfig(type=EV_KEY, code=12)]), + "keyboard", + "3", + ), + ) + self.assertEqual(len(self.preset), 3) + self.preset.path = PathUtils.get_config_path("test.json") + self.preset.save() + self.assertFalse(self.preset.has_unsaved_changes()) + + self.preset.clear() + self.assertFalse(self.preset.has_unsaved_changes()) + self.assertIsNone(self.preset.path) + self.assertEqual(len(self.preset), 0) + + def test_dangerously_mapped_btn_left(self): + # btn left is mapped + self.preset.add( + Mapping.from_combination( + InputCombination([InputConfig.btn_left()]), + "keyboard", + "1", + ) + ) + self.assertTrue(self.preset.dangerously_mapped_btn_left()) + self.preset.add( + Mapping.from_combination( + InputCombination([InputConfig(type=EV_KEY, code=41)]), + "keyboard", + "2", + ) + ) + self.assertTrue(self.preset.dangerously_mapped_btn_left()) + + # another mapping maps to btn_left + self.preset.add( + Mapping.from_combination( + InputCombination([InputConfig(type=EV_KEY, code=42)]), + "mouse", + "btn_left", + ) + ) + self.assertFalse(self.preset.dangerously_mapped_btn_left()) + + mapping = self.preset.get_mapping( + InputCombination([InputConfig(type=EV_KEY, code=42)]) + ) + mapping.output_symbol = "BTN_Left" + self.assertFalse(self.preset.dangerously_mapped_btn_left()) + + mapping.target_uinput = "keyboard + mouse" + mapping.output_symbol = "3" + self.assertTrue(self.preset.dangerously_mapped_btn_left()) + + # btn_left is not mapped + self.preset.remove(InputCombination([InputConfig.btn_left()])) + self.assertFalse(self.preset.dangerously_mapped_btn_left()) + + def test_save_load_with_invalid_mappings(self): + ui_preset = Preset( + PathUtils.get_config_path("test.json"), mapping_factory=UIMapping + ) + + ui_preset.add(UIMapping()) + self.assertFalse(ui_preset.is_valid()) + + # make the mapping valid + m = ui_preset.get_mapping(InputCombination.empty_combination()) + m.output_symbol = "a" + m.target_uinput = "keyboard" + self.assertTrue(ui_preset.is_valid()) + + m2 = UIMapping( + input_combination=InputCombination([InputConfig(type=1, code=2)]) + ) + ui_preset.add(m2) + self.assertFalse(ui_preset.is_valid()) + ui_preset.save() + + # only the valid preset is loaded + preset = Preset(PathUtils.get_config_path("test.json")) + preset.load() + self.assertEqual(len(preset), 1) + + a = preset.get_mapping(m.input_combination).dict() + b = m.dict() + a.pop("mapping_type") + b.pop("mapping_type") + self.assertEqual(a, b) + # self.assertEqual(preset.get_mapping(m.input_combination), m) + + # both presets load + ui_preset.clear() + ui_preset.path = PathUtils.get_config_path("test.json") + ui_preset.load() + self.assertEqual(len(ui_preset), 2) + + a = ui_preset.get_mapping(m.input_combination).dict() + b = m.dict() + a.pop("mapping_type") + b.pop("mapping_type") + self.assertEqual(a, b) + # self.assertEqual(ui_preset.get_mapping(m.input_combination), m) + self.assertEqual(ui_preset.get_mapping(m2.input_combination), m2) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/test_reader.py b/tests/unit/test_reader.py new file mode 100644 index 0000000..b099990 --- /dev/null +++ b/tests/unit/test_reader.py @@ -0,0 +1,1052 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + +import asyncio +import json +import multiprocessing +import os +import time +import unittest +from typing import List, Optional +from unittest import mock +from unittest.mock import patch, MagicMock + +from evdev.ecodes import ( + EV_KEY, + EV_ABS, + ABS_HAT0X, + KEY_COMMA, + BTN_TOOL_DOUBLETAP, + KEY_A, + REL_WHEEL, + REL_X, + ABS_X, + REL_HWHEEL, + BTN_LEFT, + EV_REL, +) + +from inputremapper.configs.input_config import ( + InputCombination, + InputConfig, + DEFAULT_ABS_ANALOG_THRESHOLD_MAGNITUDE, +) +from inputremapper.groups import _Groups, DeviceType +from inputremapper.gui.messages.message_broker import ( + MessageBroker, + Signal, +) +from inputremapper.gui.messages.message_data import CombinationRecorded +from inputremapper.gui.messages.message_types import MessageType +from inputremapper.gui.reader_client import ReaderClient +from inputremapper.gui.reader_service import ( + ReaderService, + ContextDummy, + RELEASE_TIMEOUT, +) +from inputremapper.injection.global_uinputs import GlobalUInputs, UInput, FrontendUInput +from inputremapper.input_event import InputEvent +from tests.lib.constants import EVENT_READ_TIMEOUT, START_READING_DELAY +from tests.lib.fixtures import fixtures +from tests.lib.fixtures import new_event +from tests.lib.pipes import push_event, push_events +from tests.lib.spy import spy +from tests.lib.test_setup import test_setup + +CODE_1 = 100 +CODE_2 = 101 +CODE_3 = 102 + + +class Listener: + def __init__(self): + self.calls: List = [] + + def __call__(self, data): + self.calls.append(data) + + +def wait(func, timeout=1.0): + """Wait for func to return True.""" + iterations = 0 + sleepytime = 0.1 + while not func(): + time.sleep(sleepytime) + iterations += 1 + if iterations * sleepytime > timeout: + break + + +@test_setup +class TestReaderAsyncio(unittest.IsolatedAsyncioTestCase): + def setUp(self): + self.reader_service = None + self.groups = _Groups() + self.message_broker = MessageBroker() + self.reader_client = ReaderClient(self.message_broker, self.groups) + + def tearDown(self): + try: + self.reader_client.terminate() + except (BrokenPipeError, OSError): + pass + + async def create_reader_service(self, groups: Optional[_Groups] = None): + # this will cause pending events to be copied over to the reader-service + # process + if not groups: + groups = self.groups + + global_uinputs = GlobalUInputs(UInput) + assert groups is not None + self.reader_service = ReaderService(groups, global_uinputs) + asyncio.ensure_future(self.reader_service.run()) + + async def test_should_forward_to_dummy(self): + # It forwards to a ForwardDummy, because the gui process + # 1. can't inject and + # 2. is not even supposed to inject anything + # thanks to not using multiprocessing as opposed to the other tests, we can + # access this stuff + context = None + original_create_event_pipeline = ReaderService._create_event_pipeline + + def remember_context(*args, **kwargs): + nonlocal context + context = original_create_event_pipeline(*args, **kwargs) + return context + + with mock.patch.object( + ReaderService, + "_create_event_pipeline", + remember_context, + ): + await self.create_reader_service() + + listener = Listener() + self.message_broker.subscribe(MessageType.combination_recorded, listener) + + self.reader_client.set_group(self.groups.find(key="Foo Device 2")) + self.reader_client.start_recorder() + + await asyncio.sleep(0.1) + self.assertIsInstance(context, ContextDummy) + + with spy( + context.forward_dummy, + "write", + ) as write_spy: + events = [InputEvent.rel(REL_X, -1)] + push_events(fixtures.foo_device_2_mouse, events) + await asyncio.sleep(0.1) + self.reader_client._read() + self.assertEqual(0, len(listener.calls)) + + # we want `write` to be called on the forward_dummy, because we want + # those events to just disappear. + self.assertEqual(write_spy.call_count, len(events)) + self.assertEqual([call[0] for call in write_spy.call_args_list], events) + + +@test_setup +class TestReaderMultiprocessing(unittest.TestCase): + def setUp(self): + self.reader_service_process = None + self.groups = _Groups() + self.message_broker = MessageBroker() + self.global_uinputs = GlobalUInputs(UInput) + self.reader_client = ReaderClient(self.message_broker, self.groups) + + def tearDown(self): + try: + self.reader_client.terminate() + except (BrokenPipeError, OSError): + pass + + if self.reader_service_process is not None: + self.reader_service_process.join(timeout=1) + if self.reader_service_process.is_alive(): + self.reader_service_process.terminate() + + def create_reader_service(self, groups: Optional[_Groups] = None): + # this will cause pending events to be copied over to the reader-service + # process + if not groups: + groups = self.groups + + def start_reader_service(): + # this is a new process, so create a new event loop, and all dependencies + # from scratch, or something + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + global_uinputs = GlobalUInputs(FrontendUInput) + global_uinputs.reset() + reader_service = ReaderService(groups, global_uinputs) + loop.run_until_complete(reader_service.run()) + + self.reader_service_process = multiprocessing.Process( + target=start_reader_service + ) + self.reader_service_process.start() + time.sleep(0.1) + + def test_reading(self): + l1 = Listener() + l2 = Listener() + self.message_broker.subscribe(MessageType.combination_recorded, l1) + self.message_broker.subscribe(MessageType.recording_finished, l2) + self.create_reader_service() + self.reader_client.set_group(self.groups.find(key="Foo Device 2")) + self.reader_client.start_recorder() + + push_events(fixtures.foo_device_2_gamepad, [InputEvent.abs(ABS_HAT0X, 1)]) + # we need to sleep because we have two different fixtures, + # which will lead to race conditions + time.sleep(0.1) + + # relative axis events should be released automagically after 0.3s + push_events(fixtures.foo_device_2_mouse, [InputEvent.rel(REL_X, 5)]) + time.sleep(0.1) + # read all pending events. Having a glib mainloop would be better, + # as it would call read automatically periodically + self.reader_client._read() + + expected = [ + CombinationRecorded( + InputCombination( + [ + InputConfig( + type=EV_ABS, + code=16, + analog_threshold=DEFAULT_ABS_ANALOG_THRESHOLD_MAGNITUDE, + origin_hash=fixtures.foo_device_2_gamepad.get_device_hash(), + ) + ] + ) + ), + CombinationRecorded( + InputCombination( + [ + InputConfig( + type=EV_ABS, + code=16, + analog_threshold=DEFAULT_ABS_ANALOG_THRESHOLD_MAGNITUDE, + origin_hash=fixtures.foo_device_2_gamepad.get_device_hash(), + ), + InputConfig( + type=EV_REL, + code=0, + analog_threshold=1, + origin_hash=fixtures.foo_device_2_mouse.get_device_hash(), + ), + ] + ) + ), + ] + + self.assertEqual( + expected, + l1.calls, + ) + + # release the hat switch should emit the recording finished event + # as both the hat and relative axis are released by now + push_events(fixtures.foo_device_2_gamepad, [InputEvent.abs(ABS_HAT0X, 0)]) + time.sleep(RELEASE_TIMEOUT + 0.1) + self.reader_client._read() + self.assertEqual([Signal(MessageType.recording_finished)], l2.calls) + + def test_should_release_relative_axis(self): + l1 = Listener() + l2 = Listener() + self.message_broker.subscribe(MessageType.combination_recorded, l1) + self.message_broker.subscribe(MessageType.recording_finished, l2) + self.create_reader_service() + self.reader_client.set_group(self.groups.find(key="Foo Device 2")) + self.reader_client.start_recorder() + + push_events(fixtures.foo_device_2_mouse, [InputEvent.rel(REL_X, -5)]) + time.sleep(0.1) + self.reader_client._read() + + self.assertEqual( + [ + CombinationRecorded( + InputCombination( + [ + InputConfig( + type=EV_REL, + code=0, + analog_threshold=-1, + origin_hash=fixtures.foo_device_2_mouse.get_device_hash(), + ) + ] + ) + ) + ], + l1.calls, + ) + self.assertEqual([], l2.calls) # no stop recording yet + + time.sleep(RELEASE_TIMEOUT + 0.1) + self.reader_client._read() + self.assertEqual([Signal(MessageType.recording_finished)], l2.calls) + + def test_should_not_trigger_at_low_speed_for_rel_axis(self): + l1 = Listener() + self.message_broker.subscribe(MessageType.combination_recorded, l1) + self.create_reader_service() + self.reader_client.set_group(self.groups.find(key="Foo Device 2")) + self.reader_client.start_recorder() + + push_events(fixtures.foo_device_2_mouse, [InputEvent.rel(REL_X, -1)]) + time.sleep(0.1) + self.reader_client._read() + self.assertEqual(0, len(l1.calls)) + + def test_should_trigger_wheel_at_low_speed(self): + l1 = Listener() + self.message_broker.subscribe(MessageType.combination_recorded, l1) + self.create_reader_service() + self.reader_client.set_group(self.groups.find(key="Foo Device 2")) + self.reader_client.start_recorder() + + push_events( + fixtures.foo_device_2_mouse, + [InputEvent.rel(REL_WHEEL, -1), InputEvent.rel(REL_HWHEEL, 1)], + ) + time.sleep(0.1) + self.reader_client._read() + + self.assertEqual( + [ + CombinationRecorded( + InputCombination( + [ + InputConfig( + type=EV_REL, + code=8, + analog_threshold=-1, + origin_hash=fixtures.foo_device_2_mouse.get_device_hash(), + ) + ] + ) + ), + CombinationRecorded( + InputCombination( + [ + InputConfig( + type=EV_REL, + code=8, + analog_threshold=-1, + origin_hash=fixtures.foo_device_2_mouse.get_device_hash(), + ), + InputConfig( + type=EV_REL, + code=6, + analog_threshold=1, + origin_hash=fixtures.foo_device_2_mouse.get_device_hash(), + ), + ] + ) + ), + ], + l1.calls, + ) + + def test_wont_emit_the_same_combination_twice(self): + l1 = Listener() + self.message_broker.subscribe(MessageType.combination_recorded, l1) + self.create_reader_service() + self.reader_client.set_group(self.groups.find(key="Foo Device 2")) + self.reader_client.start_recorder() + + push_events(fixtures.foo_device_2_keyboard, [InputEvent.key(KEY_A, 1)]) + time.sleep(0.1) + self.reader_client._read() + # the duplicate event should be ignored + push_events(fixtures.foo_device_2_keyboard, [InputEvent.key(KEY_A, 1)]) + time.sleep(0.1) + self.reader_client._read() + + self.assertEqual( + [ + CombinationRecorded( + InputCombination( + [ + InputConfig( + type=EV_KEY, + code=30, + origin_hash=fixtures.foo_device_2_keyboard.get_device_hash(), + ) + ] + ) + ) + ], + l1.calls, + ) + + def test_should_read_absolut_axis(self): + l1 = Listener() + l2 = Listener() + self.message_broker.subscribe(MessageType.combination_recorded, l1) + self.message_broker.subscribe(MessageType.recording_finished, l2) + self.create_reader_service() + self.reader_client.set_group(self.groups.find(key="Foo Device 2")) + self.reader_client.start_recorder() + + # over 30% should trigger + push_events( + fixtures.foo_device_2_gamepad, + [InputEvent.abs(ABS_X, int(fixtures.foo_device_2_gamepad.max_abs * 0.4))], + ) + time.sleep(0.1) + self.reader_client._read() + self.assertEqual( + [ + CombinationRecorded( + InputCombination( + [ + InputConfig( + type=EV_ABS, + code=0, + analog_threshold=DEFAULT_ABS_ANALOG_THRESHOLD_MAGNITUDE, + origin_hash=fixtures.foo_device_2_gamepad.get_device_hash(), + ) + ] + ) + ) + ], + l1.calls, + ) + self.assertEqual([], l2.calls) # no stop recording yet + + # less the 30% should release + push_events( + fixtures.foo_device_2_gamepad, + [InputEvent.abs(ABS_X, int(fixtures.foo_device_2_gamepad.max_abs * 0.2))], + ) + time.sleep(0.1) + self.reader_client._read() + self.assertEqual( + [ + CombinationRecorded( + InputCombination( + [ + InputConfig( + type=EV_ABS, + code=0, + analog_threshold=DEFAULT_ABS_ANALOG_THRESHOLD_MAGNITUDE, + origin_hash=fixtures.foo_device_2_gamepad.get_device_hash(), + ) + ] + ) + ) + ], + l1.calls, + ) + self.assertEqual([Signal(MessageType.recording_finished)], l2.calls) + + def test_should_change_direction(self): + l1 = Listener() + self.message_broker.subscribe(MessageType.combination_recorded, l1) + self.create_reader_service() + self.reader_client.set_group(self.groups.find(key="Foo Device 2")) + self.reader_client.start_recorder() + + push_event(fixtures.foo_device_2_keyboard, InputEvent.key(KEY_A, 1)) + time.sleep(0.1) + push_event( + fixtures.foo_device_2_gamepad, + InputEvent.abs(ABS_X, int(fixtures.foo_device_2_gamepad.max_abs * 0.4)), + ) + time.sleep(0.1) + push_event(fixtures.foo_device_2_keyboard, InputEvent.key(KEY_COMMA, 1)) + time.sleep(0.1) + push_events( + fixtures.foo_device_2_gamepad, + [ + InputEvent.abs(ABS_X, int(fixtures.foo_device_2_gamepad.max_abs * 0.1)), + InputEvent.abs(ABS_X, int(fixtures.foo_device_2_gamepad.min_abs * 0.4)), + ], + ) + time.sleep(0.1) + self.reader_client._read() + self.assertEqual( + [ + CombinationRecorded( + InputCombination( + [ + InputConfig( + type=EV_KEY, + code=KEY_A, + origin_hash=fixtures.foo_device_2_keyboard.get_device_hash(), + ) + ] + ) + ), + CombinationRecorded( + InputCombination( + [ + InputConfig( + type=EV_KEY, + code=KEY_A, + origin_hash=fixtures.foo_device_2_keyboard.get_device_hash(), + ), + InputConfig( + type=EV_ABS, + code=ABS_X, + analog_threshold=DEFAULT_ABS_ANALOG_THRESHOLD_MAGNITUDE, + origin_hash=fixtures.foo_device_2_gamepad.get_device_hash(), + ), + ] + ) + ), + CombinationRecorded( + InputCombination( + [ + InputConfig( + type=EV_KEY, + code=KEY_A, + origin_hash=fixtures.foo_device_2_keyboard.get_device_hash(), + ), + InputConfig( + type=EV_ABS, + code=ABS_X, + analog_threshold=DEFAULT_ABS_ANALOG_THRESHOLD_MAGNITUDE, + origin_hash=fixtures.foo_device_2_gamepad.get_device_hash(), + ), + InputConfig( + type=EV_KEY, + code=KEY_COMMA, + origin_hash=fixtures.foo_device_2_keyboard.get_device_hash(), + ), + ] + ) + ), + CombinationRecorded( + InputCombination( + [ + InputConfig( + type=EV_KEY, + code=KEY_A, + origin_hash=fixtures.foo_device_2_keyboard.get_device_hash(), + ), + InputConfig( + type=EV_ABS, + code=ABS_X, + analog_threshold=-DEFAULT_ABS_ANALOG_THRESHOLD_MAGNITUDE, + origin_hash=fixtures.foo_device_2_gamepad.get_device_hash(), + ), + InputConfig( + type=EV_KEY, + code=KEY_COMMA, + origin_hash=fixtures.foo_device_2_keyboard.get_device_hash(), + ), + ] + ) + ), + ], + l1.calls, + ) + + def test_change_device(self): + l1 = Listener() + self.message_broker.subscribe(MessageType.combination_recorded, l1) + + push_events( + fixtures.foo_device_2_keyboard, + [ + InputEvent.key(1, 1), + ] + * 10, + ) + + push_events( + fixtures.bar_device, + [ + InputEvent.key(2, 1), + InputEvent.key(2, 0), + ] + * 3, + ) + + self.create_reader_service() + self.reader_client.set_group(self.groups.find(key="Foo Device 2")) + self.reader_client.start_recorder() + time.sleep(0.1) + self.reader_client._read() + self.assertEqual( + l1.calls[0].combination, + InputCombination( + [ + InputConfig( + type=EV_KEY, + code=1, + origin_hash=fixtures.foo_device_2_keyboard.get_device_hash(), + ) + ] + ), + ) + + self.reader_client.set_group(self.groups.find(name="Bar Device")) + time.sleep(0.1) + self.reader_client._read() + + # we did not get the event from the "Bar Device" because the group change + # stopped the recording + self.assertEqual(len(l1.calls), 1) + + self.reader_client.start_recorder() + push_events(fixtures.bar_device, [InputEvent.key(2, 1)]) + time.sleep(0.1) + self.reader_client._read() + self.assertEqual( + l1.calls[1].combination, + InputCombination( + [ + InputConfig( + type=EV_KEY, + code=2, + origin_hash=fixtures.bar_device.get_device_hash(), + ) + ] + ), + ) + + def test_reading_2(self): + l1 = Listener() + self.message_broker.subscribe(MessageType.combination_recorded, l1) + # a combination of events + push_events( + fixtures.foo_device_2_keyboard, + [ + new_event(EV_KEY, CODE_1, 1, 10000.1234), + new_event(EV_KEY, CODE_3, 1, 10001.1234), + ], + ) + + pipe = multiprocessing.Pipe() + + groups = _Groups() + + def refresh(): + # from within the reader-service process notify this test that + # refresh was called as expected + pipe[1].send("refreshed") + # call original method + return _Groups.refresh(groups) + + groups.refresh = refresh + self.create_reader_service(groups) + + self.reader_client.set_group(self.groups.find(key="Foo Device 2")) + self.reader_client.start_recorder() + + # sending anything arbitrary does not stop the reader-service + self.reader_client._commands_pipe.send(856794) + time.sleep(0.2) + push_events( + fixtures.foo_device_2_gamepad, + [new_event(EV_ABS, ABS_HAT0X, -1, 10002.1234)], + ) + time.sleep(0.1) + # but it makes it look for new devices because maybe its list of + # self.groups is not up-to-date + self.assertTrue(pipe[0].poll()) + self.assertEqual(pipe[0].recv(), "refreshed") + + self.reader_client._read() + + expected = InputCombination( + [ + InputConfig( + type=EV_KEY, + code=CODE_1, + origin_hash=fixtures.foo_device_2_keyboard.get_device_hash(), + ), + InputConfig( + type=EV_KEY, + code=CODE_3, + origin_hash=fixtures.foo_device_2_keyboard.get_device_hash(), + ), + InputConfig( + type=EV_ABS, + code=ABS_HAT0X, + analog_threshold=-DEFAULT_ABS_ANALOG_THRESHOLD_MAGNITUDE, + origin_hash=fixtures.foo_device_2_gamepad.get_device_hash(), + ), + ] + ) + + self.assertEqual( + l1.calls[-1].combination, + expected, + ) + + def test_blacklisted_events(self): + l1 = Listener() + self.message_broker.subscribe(MessageType.combination_recorded, l1) + + push_events( + fixtures.foo_device_2_mouse, + [ + InputEvent.key(BTN_TOOL_DOUBLETAP, 1), + InputEvent.key(BTN_LEFT, 1), + InputEvent.key(BTN_TOOL_DOUBLETAP, 1), + ], + force=True, + ) + self.create_reader_service() + self.reader_client.set_group(self.groups.find(key="Foo Device 2")) + self.reader_client.start_recorder() + time.sleep(0.1) + self.reader_client._read() + self.assertEqual( + l1.calls[-1].combination, + InputCombination( + [ + InputConfig( + type=EV_KEY, + code=BTN_LEFT, + origin_hash=fixtures.foo_device_2_mouse.get_device_hash(), + ) + ] + ), + ) + + def test_ignore_value_2(self): + l1 = Listener() + self.message_broker.subscribe(MessageType.combination_recorded, l1) + # this is not a combination, because (EV_KEY CODE_3, 2) is ignored + push_events( + fixtures.foo_device_2_gamepad, + [InputEvent.abs(ABS_HAT0X, 1), InputEvent.key(CODE_3, 2)], + force=True, + ) + self.create_reader_service() + self.reader_client.set_group(self.groups.find(key="Foo Device 2")) + self.reader_client.start_recorder() + time.sleep(0.2) + self.reader_client._read() + self.assertEqual( + l1.calls[-1].combination, + InputCombination( + [ + InputConfig( + type=EV_ABS, + code=ABS_HAT0X, + analog_threshold=DEFAULT_ABS_ANALOG_THRESHOLD_MAGNITUDE, + origin_hash=fixtures.foo_device_2_gamepad.get_device_hash(), + ) + ] + ), + ) + + def test_reading_ignore_up(self): + l1 = Listener() + self.message_broker.subscribe(MessageType.combination_recorded, l1) + push_events( + fixtures.foo_device_2_keyboard, + [ + new_event(EV_KEY, CODE_1, 0, 10), + new_event(EV_KEY, CODE_2, 1, 11), + new_event(EV_KEY, CODE_3, 0, 12), + ], + ) + self.create_reader_service() + self.reader_client.set_group(self.groups.find(key="Foo Device 2")) + self.reader_client.start_recorder() + time.sleep(0.1) + self.reader_client._read() + self.assertEqual( + l1.calls[-1].combination, + InputCombination( + [ + InputConfig( + type=EV_KEY, + code=CODE_2, + origin_hash=fixtures.foo_device_2_keyboard.get_device_hash(), + ) + ] + ), + ) + + def test_wrong_device(self): + l1 = Listener() + self.message_broker.subscribe(MessageType.combination_recorded, l1) + + push_events( + fixtures.foo_device_2_keyboard, + [ + InputEvent.key(CODE_1, 1), + InputEvent.key(CODE_2, 1), + InputEvent.key(CODE_3, 1), + ], + ) + self.create_reader_service() + self.reader_client.set_group(self.groups.find(name="Bar Device")) + self.reader_client.start_recorder() + time.sleep(EVENT_READ_TIMEOUT * 5) + self.reader_client._read() + self.assertEqual(len(l1.calls), 0) + + def test_inputremapper_devices(self): + # Don't read from inputremapper devices, their keycodes are not + # representative for the original key. As long as this is not + # intentionally programmed it won't even do that. But it was at some + # point. + l1 = Listener() + self.message_broker.subscribe(MessageType.combination_recorded, l1) + push_events( + fixtures.input_remapper_bar_device, + [ + InputEvent.key(CODE_1, 1), + InputEvent.key(CODE_2, 1), + InputEvent.key(CODE_3, 1), + ], + ) + self.create_reader_service() + self.reader_client.set_group(self.groups.find(name="Bar Device")) + self.reader_client.start_recorder() + time.sleep(EVENT_READ_TIMEOUT * 5) + self.reader_client._read() + self.assertEqual(len(l1.calls), 0) + + def test_terminate(self): + self.create_reader_service() + self.reader_client.set_group(self.groups.find(key="Foo Device 2")) + + push_events(fixtures.foo_device_2_keyboard, [InputEvent.key(CODE_3, 1)]) + time.sleep(START_READING_DELAY + EVENT_READ_TIMEOUT) + self.assertTrue(self.reader_client._results_pipe.poll()) + + self.reader_client.terminate() + time.sleep(EVENT_READ_TIMEOUT) + self.assertFalse(self.reader_client._results_pipe.poll()) + + # no new events arrive after terminating + push_events(fixtures.foo_device_2_keyboard, [InputEvent.key(CODE_3, 1)]) + time.sleep(EVENT_READ_TIMEOUT * 3) + self.assertFalse(self.reader_client._results_pipe.poll()) + + def test_are_new_groups_available(self): + l1 = Listener() + self.message_broker.subscribe(MessageType.groups, l1) + self.create_reader_service() + self.reader_client.groups.set_groups([]) + + time.sleep(0.1) # let the reader-service send the groups + # read stuff from the reader-service, which includes the devices + self.assertEqual("[]", self.reader_client.groups.dumps()) + self.reader_client._read() + + self.assertEqual( + self.reader_client.groups.dumps(), + json.dumps( + [ + json.dumps( + { + "paths": [ + "/dev/input/event1", + ], + "names": ["Foo Device"], + "types": [DeviceType.KEYBOARD], + "key": "Foo Device", + } + ), + json.dumps( + { + "paths": [ + "/dev/input/event10", + "/dev/input/event11", + "/dev/input/event13", + "/dev/input/event15", + ], + "names": [ + "Foo Device", + "Foo Device foo", + "Foo Device", + "Foo Device bar", + ], + "types": [ + DeviceType.GAMEPAD, + DeviceType.KEYBOARD, + DeviceType.MOUSE, + ], + "key": "Foo Device 2", + } + ), + json.dumps( + { + "paths": ["/dev/input/event20"], + "names": ["Bar Device"], + "types": [DeviceType.KEYBOARD], + "key": "Bar Device", + } + ), + json.dumps( + { + "paths": [ + "/dev/input/event30", + "/dev/input/event32", + ], + "names": [ + "gamepad", + "gamepad abs 0 to 256", + ], + "types": [DeviceType.GAMEPAD], + "key": "gamepad", + } + ), + json.dumps( + { + "paths": ["/dev/input/event52"], + "names": ["Qux/[Device]?"], + "types": [DeviceType.KEYBOARD], + "key": "Qux/[Device]?", + } + ), + ] + ), + ) + + self.assertEqual(len(l1.calls), 1) # ensure we got the event + + def test_starts_the_service(self): + # if ReaderClient can't see the ReaderService, a new ReaderService should + # be started via pkexec + with patch.object(ReaderService, "is_running", lambda: False): + os_system_mock = MagicMock(return_value=0) + with patch.object(os, "system", os_system_mock): + # the status message enables the reader-client to see, that the + # reader-service has started + self.reader_client._results_pipe.send( + {"type": "status", "message": "ready"} + ) + self.reader_client._send_command("foo") + os_system_mock.assert_called_once_with( + "pkexec input-remapper-control --command start-reader-service -d" + ) + + def test_wont_start_the_service(self): + # already running, no call to os.system + with patch.object(ReaderService, "is_running", lambda: True): + mock = MagicMock(return_value=0) + with patch.object(os, "system", mock): + self.reader_client._send_command("foo") + mock.assert_not_called() + + def test_reader_service_wont_start(self): + # test for the "The reader-service did not start" message + + expected_msg = "The reader-service did not start" + subscribe_mock = MagicMock() + self.message_broker.subscribe(MessageType.status_msg, subscribe_mock) + + with patch.object(ReaderClient, "_timeout", 1): + with patch.object(ReaderService, "is_running", lambda: False): + os_system_mock = MagicMock(return_value=0) + with patch.object(os, "system", os_system_mock): + self.reader_client._send_command("foo") + # no message is sent into _results_pipe, so the reader-client will + # think the reader-service didn't manage to start + os_system_mock.assert_called_once_with( + "pkexec input-remapper-control " + "--command start-reader-service -d" + ) + + subscribe_mock.assert_called_once() + status = subscribe_mock.call_args[0][0] + self.assertEqual(status.msg, expected_msg) + + def test_reader_service_times_out(self): + # after some time the reader-service just stops, to avoid leaving a hole + # that exposes user-input forever + with patch.object(ReaderService, "_maximum_lifetime", 1): + self.create_reader_service() + self.assertTrue(self.reader_service_process.is_alive()) + time.sleep(0.5) + self.assertTrue(self.reader_service_process.is_alive()) + time.sleep(1) + self.assertFalse(self.reader_service_process.is_alive()) + + def test_reader_service_waits_for_client_to_finish(self): + # if the client is currently reading, it waits a bit longer until the + # client finishes reading + with patch.object(ReaderService, "_maximum_lifetime", 1): + self.create_reader_service() + self.assertTrue(self.reader_service_process.is_alive()) + + self.reader_client.set_group(self.groups.find(key="Foo Device 2")) + self.reader_client.start_recorder() + + time.sleep(2) + # still alive, without start_recorder it should have already exited + self.assertTrue(self.reader_service_process.is_alive()) + + self.reader_client.stop_recorder() + + time.sleep(1) + self.assertFalse(self.reader_service_process.is_alive()) + + def test_reader_service_wont_wait_forever(self): + # if the client is reading forever, stop it after another timeout + with patch.object(ReaderService, "_maximum_lifetime", 1): + with patch.object(ReaderService, "_timeout_tolerance", 1): + self.create_reader_service() + self.assertTrue(self.reader_service_process.is_alive()) + + self.reader_client.set_group(self.groups.find(key="Foo Device 2")) + self.reader_client.start_recorder() + + time.sleep(1.5) + # still alive, without start_recorder it should have already exited + self.assertTrue(self.reader_service_process.is_alive()) + + time.sleep(1) + # now it stopped, even though the reader is still reading + self.assertFalse(self.reader_service_process.is_alive()) + + def test_reader_service_stops_with_broken_pipes(self): + with patch.object(ReaderService, "pipes_exist", side_effect=lambda: False): + self.create_reader_service() + self.assertTrue(self.reader_service_process.is_alive()) + + time.sleep(0.5) + # still alive + self.assertTrue(self.reader_service_process.is_alive()) + + time.sleep(1) + # now it stopped + self.assertFalse(self.reader_service_process.is_alive()) + + # It will not stop if pipes are ok + with patch.object(ReaderService, "pipes_exist", side_effect=lambda: True): + self.create_reader_service() + self.assertTrue(self.reader_service_process.is_alive()) + + time.sleep(1.5) + # still alive + self.assertTrue(self.reader_service_process.is_alive()) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/test_system_mapping.py b/tests/unit/test_system_mapping.py new file mode 100644 index 0000000..6d7cfab --- /dev/null +++ b/tests/unit/test_system_mapping.py @@ -0,0 +1,165 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + +import json +import os +import subprocess +import unittest +from unittest.mock import patch + +from evdev.ecodes import BTN_LEFT, KEY_A + +from inputremapper.configs.paths import PathUtils +from inputremapper.configs.keyboard_layout import KeyboardLayout, XMODMAP_FILENAME +from tests.lib.test_setup import test_setup + + +@test_setup +class TestSystemMapping(unittest.TestCase): + def test_update(self): + keyboard_layout = KeyboardLayout() + keyboard_layout.update({"foo1": 101, "bar1": 102}) + keyboard_layout.update({"foo2": 201, "bar2": 202}) + self.assertEqual(keyboard_layout.get("foo1"), 101) + self.assertEqual(keyboard_layout.get("bar2"), 202) + + def test_xmodmap_file(self): + keyboard_layout = KeyboardLayout() + path = os.path.join(PathUtils.config_path(), XMODMAP_FILENAME) + os.remove(path) + + keyboard_layout.populate() + self.assertTrue(os.path.exists(path)) + with open(path, "r") as file: + content = json.load(file) + self.assertEqual(content["a"], KEY_A) + # only xmodmap stuff should be present + self.assertNotIn("key_a", content) + self.assertNotIn("KEY_A", content) + self.assertNotIn("disable", content) + + def test_empty_xmodmap(self): + # if xmodmap returns nothing, don't write the file + empty_xmodmap = "" + + class SubprocessMock: + def decode(self): + return empty_xmodmap + + def check_output(*args, **kwargs): + return SubprocessMock() + + with patch.object(subprocess, "check_output", check_output): + keyboard_layout = KeyboardLayout() + path = os.path.join(PathUtils.config_path(), XMODMAP_FILENAME) + os.remove(path) + + keyboard_layout.populate() + self.assertFalse(os.path.exists(path)) + + def test_xmodmap_command_missing(self): + # if xmodmap is not installed, don't write the file + def check_output(*args, **kwargs): + raise FileNotFoundError + + with patch.object(subprocess, "check_output", check_output): + keyboard_layout = KeyboardLayout() + path = os.path.join(PathUtils.config_path(), XMODMAP_FILENAME) + os.remove(path) + + keyboard_layout.populate() + self.assertFalse(os.path.exists(path)) + + def test_correct_case(self): + keyboard_layout = KeyboardLayout() + keyboard_layout.clear() + keyboard_layout._set("A", 31) + keyboard_layout._set("a", 32) + keyboard_layout._set("abcd_B", 33) + + self.assertEqual(keyboard_layout.correct_case("a"), "a") + self.assertEqual(keyboard_layout.correct_case("A"), "A") + self.assertEqual(keyboard_layout.correct_case("ABCD_b"), "abcd_B") + # unknown stuff is returned as is + self.assertEqual(keyboard_layout.correct_case("FOo"), "FOo") + + self.assertEqual(keyboard_layout.get("A"), 31) + self.assertEqual(keyboard_layout.get("a"), 32) + self.assertEqual(keyboard_layout.get("ABCD_b"), 33) + self.assertEqual(keyboard_layout.get("abcd_B"), 33) + + def test_keyboard_layout(self): + keyboard_layout = KeyboardLayout() + keyboard_layout.populate() + self.assertGreater(len(keyboard_layout._mapping), 100) + + # this is case-insensitive + self.assertEqual(keyboard_layout.get("1"), 2) + self.assertEqual(keyboard_layout.get("KeY_1"), 2) + + self.assertEqual(keyboard_layout.get("AlT_L"), 56) + self.assertEqual(keyboard_layout.get("KEy_LEFtALT"), 56) + + self.assertEqual(keyboard_layout.get("kEY_LeFTSHIFT"), 42) + self.assertEqual(keyboard_layout.get("ShiFt_L"), 42) + + self.assertEqual(keyboard_layout.get("BTN_left"), 272) + + self.assertIsNotNone(keyboard_layout.get("KEY_KP4")) + self.assertEqual(keyboard_layout.get("KP_Left"), keyboard_layout.get("KEY_KP4")) + + # this only lists the correct casing, + # includes linux constants and xmodmap symbols + names = keyboard_layout.list_names() + self.assertIn("2", names) + self.assertIn("c", names) + self.assertIn("KEY_3", names) + self.assertNotIn("key_3", names) + self.assertIn("KP_Down", names) + self.assertNotIn("kp_down", names) + names = keyboard_layout._mapping.keys() + self.assertIn("F4", names) + self.assertNotIn("f4", names) + self.assertIn("BTN_RIGHT", names) + self.assertNotIn("btn_right", names) + self.assertIn("KEY_KP7", names) + self.assertIn("KP_Home", names) + self.assertNotIn("kp_home", names) + + self.assertEqual(keyboard_layout.get("disable"), -1) + + def test_get_name_no_xmodmap(self): + # if xmodmap is not installed, uses the linux constant names + keyboard_layout = KeyboardLayout() + + def check_output(*args, **kwargs): + raise FileNotFoundError + + with patch.object(subprocess, "check_output", check_output): + keyboard_layout.populate() + self.assertEqual(keyboard_layout.get_name(KEY_A), "KEY_A") + + # testing for BTN_LEFT is especially important, because + # `evdev.ecodes.BTN.get(code)` returns an array of ['BTN_LEFT', 'BTN_MOUSE'] + self.assertEqual(keyboard_layout.get_name(BTN_LEFT), "BTN_LEFT") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/test_test.py b/tests/unit/test_test.py new file mode 100644 index 0000000..81b7f81 --- /dev/null +++ b/tests/unit/test_test.py @@ -0,0 +1,145 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + +import asyncio +import multiprocessing +import os +import time +import unittest + +import evdev +from evdev.ecodes import EV_ABS, EV_KEY + +from inputremapper.groups import groups, _Groups +from inputremapper.gui.messages.message_broker import MessageBroker +from inputremapper.gui.reader_client import ReaderClient +from inputremapper.gui.reader_service import ReaderService +from inputremapper.injection.global_uinputs import UInput, GlobalUInputs +from inputremapper.input_event import InputEvent +from inputremapper.utils import get_device_hash +from tests.lib.cleanup import cleanup +from tests.lib.constants import EVENT_READ_TIMEOUT, START_READING_DELAY +from tests.lib.fixtures import fixtures +from tests.lib.logger import logger +from tests.lib.patches import InputDevice +from tests.lib.pipes import push_events +from tests.lib.test_setup import test_setup + + +@test_setup +class TestTest(unittest.TestCase): + def test_stubs(self): + self.assertIsNotNone(groups.find(key="Foo Device 2")) + + def test_fake_capabilities(self): + device = InputDevice("/dev/input/event30") + capabilities = device.capabilities(absinfo=False) + self.assertIsInstance(capabilities, dict) + self.assertIsInstance(capabilities[EV_ABS], list) + self.assertIsInstance(capabilities[EV_ABS][0], int) + + capabilities = device.capabilities() + self.assertIsInstance(capabilities, dict) + self.assertIsInstance(capabilities[EV_ABS], list) + self.assertIsInstance(capabilities[EV_ABS][0], tuple) + self.assertIsInstance(capabilities[EV_ABS][0][0], int) + self.assertIsInstance(capabilities[EV_ABS][0][1], evdev.AbsInfo) + self.assertIsInstance(capabilities[EV_ABS][0][1].max, int) + self.assertIsInstance(capabilities, dict) + self.assertIsInstance(capabilities[EV_KEY], list) + self.assertIsInstance(capabilities[EV_KEY][0], int) + + def test_restore_fixtures(self): + fixtures.add_fixture({"name": "bla", "path": "/bar/dev"}) + cleanup() + self.assertIsNone(fixtures.get("/bar/dev")) + self.assertIsNotNone(fixtures.get("/dev/input/event11")) + + def test_restore_os_environ(self): + os.environ["foo"] = "bar" + del os.environ["USER"] + environ = os.environ + cleanup() + self.assertIn("USER", environ) + self.assertNotIn("foo", environ) + + def test_push_events(self): + """Test that push_event works properly between reader service and client. + + Using push_events after the reader-service is already started should work, + as well as using push_event twice + """ + reader_client = ReaderClient(MessageBroker(), groups) + + def create_reader_service(): + # this will cause pending events to be copied over to the reader-service + # process + def start_reader_service(): + # Create dependencies from scratch, because the reader-service runs + # in a different process + global_uinputs = GlobalUInputs(UInput) + reader_service = ReaderService(_Groups(), global_uinputs) + loop = asyncio.new_event_loop() + loop.run_until_complete(reader_service.run()) + + self.reader_service = multiprocessing.Process(target=start_reader_service) + self.reader_service.start() + time.sleep(0.1) + + def wait_for_results(): + # wait for the reader-service to send stuff + for _ in range(10): + time.sleep(EVENT_READ_TIMEOUT) + if reader_client._results_pipe.poll(): + break + + create_reader_service() + reader_client.set_group(groups.find(key="Foo Device 2")) + reader_client.start_recorder() + time.sleep(START_READING_DELAY) + + event = InputEvent.key(102, 1) + push_events(fixtures.foo_device_2_keyboard, [event]) + wait_for_results() + self.assertTrue(reader_client._results_pipe.poll()) + + reader_client._read() + self.assertFalse(reader_client._results_pipe.poll()) + + # can push more events to the reader-service that is inside a separate + # process, which end up being sent to the reader + event = InputEvent.key(102, 0) + logger.info("push_events") + push_events(fixtures.foo_device_2_keyboard, [event]) + wait_for_results() + logger.info("assert") + self.assertTrue(reader_client._results_pipe.poll()) + + reader_client.terminate() + + def test_device_hash_from_fixture_is_correct(self): + for fixture in fixtures: + self.assertEqual( + fixture.get_device_hash(), get_device_hash(InputDevice(fixture.path)) + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/test_user.py b/tests/unit/test_user.py new file mode 100644 index 0000000..e1c71a0 --- /dev/null +++ b/tests/unit/test_user.py @@ -0,0 +1,62 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + + +import os +import unittest +from unittest import mock + +from inputremapper.user import UserUtils +from tests.lib.test_setup import test_setup + + +def _raise(error): + raise error + + +@test_setup +class TestUser(unittest.TestCase): + def test_get_user(self): + with mock.patch("os.getlogin", lambda: "foo"): + self.assertEqual(UserUtils.get_user(), "foo") + + with mock.patch("os.getlogin", lambda: "root"): + self.assertEqual(UserUtils.get_user(), "root") + + property_mock = mock.Mock() + property_mock.configure_mock(pw_name="quix") + with ( + mock.patch("os.getlogin", lambda: _raise(OSError())), + mock.patch("pwd.getpwuid", return_value=property_mock), + ): + os.environ["USER"] = "root" + os.environ["SUDO_USER"] = "qux" + self.assertEqual(UserUtils.get_user(), "qux") + + os.environ["USER"] = "root" + del os.environ["SUDO_USER"] + os.environ["PKEXEC_UID"] = "1000" + self.assertNotEqual(UserUtils.get_user(), "root") + + def test_get_home(self): + property_mock = mock.Mock() + property_mock.configure_mock(pw_dir="/custom/home/foo") + with mock.patch("pwd.getpwnam", return_value=property_mock): + self.assertEqual(UserUtils.get_home("foo"), "/custom/home/foo") diff --git a/tests/unit/test_util.py b/tests/unit/test_util.py new file mode 100644 index 0000000..f753db6 --- /dev/null +++ b/tests/unit/test_util.py @@ -0,0 +1,43 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + + +import unittest + +from evdev._ecodes import EV_ABS, ABS_X, BTN_WEST, BTN_Y, EV_KEY, KEY_A + +from inputremapper.utils import get_evdev_constant_name +from tests.lib.test_setup import test_setup + + +@test_setup +class TestUtil(unittest.TestCase): + def test_get_evdev_constant_name(self): + # BTN_WEST and BTN_Y both are code 308. I don't care which one is chosen + # in the return value, but it should return one of them without crashing. + self.assertEqual(get_evdev_constant_name(EV_KEY, BTN_Y), "BTN_WEST") + self.assertEqual(get_evdev_constant_name(EV_KEY, BTN_WEST), "BTN_WEST") + + self.assertEqual(get_evdev_constant_name(123, KEY_A), "unknown") + self.assertEqual(get_evdev_constant_name(EV_KEY, 9999), "unknown") + + self.assertEqual(get_evdev_constant_name(EV_KEY, KEY_A), "KEY_A") + + self.assertEqual(get_evdev_constant_name(EV_ABS, ABS_X), "ABS_X")