pax_global_header00006660000000000000000000000064141730365540014522gustar00rootroot0000000000000052 comment=abbbefc7a22653c65a31344144352afc2d3744a7 input-remapper-1.4.0/000077500000000000000000000000001417303655400144745ustar00rootroot00000000000000input-remapper-1.4.0/.coveragerc000066400000000000000000000003561417303655400166210ustar00rootroot00000000000000[run] branch = True source = /usr/lib/python3.10/site-packages/inputremapper concurrency = multiprocessing debug = multiproc omit = # not used currently due to problems /usr/lib/python3.9/site-packages/inputremapper/ipc/socket.py input-remapper-1.4.0/.github/000077500000000000000000000000001417303655400160345ustar00rootroot00000000000000input-remapper-1.4.0/.github/FUNDING.yml000066400000000000000000000012511417303655400176500ustar00rootroot00000000000000# These are supported funding model platforms github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] patreon: # Replace with a single Patreon username open_collective: # Replace with a single Open Collective username ko_fi: sezanzeb tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry liberapay: # Replace with a single Liberapay username issuehunt: # Replace with a single IssueHunt username otechie: # Replace with a single Otechie username custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] input-remapper-1.4.0/.github/ISSUE_TEMPLATE/000077500000000000000000000000001417303655400202175ustar00rootroot00000000000000input-remapper-1.4.0/.github/ISSUE_TEMPLATE/autoloading-not-working.md000066400000000000000000000015451417303655400253300ustar00rootroot00000000000000--- name: Autoloading not working about: "..." title: '' labels: '' assignees: '' --- Please install the newest version from source to see if the problem has already been solved. Share some logs please: 1. `input-remapper-control --version` 2. which linux distro (ubuntu 20.04, manjaro, etc.) 3. `echo $XDG_SESSION_TYPE` 4. which desktop environment (gnome, plasma, xfce4, etc.) 5. `sudo ls -l /proc/1/exe` 6. `cat ~/.config/input-remapper/config.json` 7. `input-remapper-control --command hello` 8. `systemctl status input-remapper -n 50` 9. `sudo pkill -f input-remapper-service && sudo input-remapper-service -d & sleep 2 && input-remapper-control --command autoload`, are your keys mapped now? 10. (while the previous command is still running) `sudo evtest` and search for a device suffixed by "mapped". Select it, does it report any events? Share the output. input-remapper-1.4.0/.github/ISSUE_TEMPLATE/buttons-not-showing-up---can-t-map-a-key-in-the-gui.md000066400000000000000000000010421417303655400320050ustar00rootroot00000000000000--- name: Buttons not showing up / Can't map a key in the GUI about: "..." title: '' labels: '' assignees: '' --- Please install the newest version from source to see if the problem has already been solved. Share some logs please: 1. `input-remapper-control --version` 2. If a button on your device doesn't show up in the GUI, verify that the button is reporting an event via `sudo evtest`. If not, input-remapper won't be able to map that button. 3. If yes, please run `input-remapper-gtk -d`, reproduce the problem and then share the logs. input-remapper-1.4.0/.github/ISSUE_TEMPLATE/key-not-getting-injected.md000066400000000000000000000013461417303655400253550ustar00rootroot00000000000000--- name: Key not getting injected about: "..." title: '' labels: '' assignees: '' --- Please install the newest version from source to see if the problem has already been solved. Share some logs please: 1. `input-remapper-control --version` 2. which linux distro (ubuntu 20.04, manjaro, etc.) 3. `echo $XDG_SESSION_TYPE` 4. which desktop environment (gnome, plasma, xfce4, etc.) 5. `sudo ls -l /proc/1/exe` 6. paste the affected preset .json file from ~/.config/input-remapper/presets 7. `sudo pkill -f input-remapper-service && input-remapper-gtk -d`, start the injection and hit your key. Then share that log. 8. `sudo evtest` would also be interesting while the first command is still running, to see how your mappings are injected. input-remapper-1.4.0/.gitignore000066400000000000000000000034771417303655400164770ustar00rootroot00000000000000inputremapper/commit_hash.py *.glade~ *.glade# .idea *.png~ # 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/ # 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/ input-remapper-1.4.0/.pylintrc000066400000000000000000000006431417303655400163440ustar00rootroot00000000000000[_] max-line-length=88 # black extension-pkg-whitelist=evdev 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 input-remapper-1.4.0/.run/000077500000000000000000000000001417303655400153565ustar00rootroot00000000000000input-remapper-1.4.0/.run/All Tests.run.xml000066400000000000000000000014231417303655400204760ustar00rootroot00000000000000 input-remapper-1.4.0/DEBIAN/000077500000000000000000000000001417303655400154165ustar00rootroot00000000000000input-remapper-1.4.0/DEBIAN/control000066400000000000000000000006721417303655400170260ustar00rootroot00000000000000Package: input-remapper Version: 1.4.0 Architecture: all Maintainer: Sezanzeb Depends: build-essential, libpython3-dev, libdbus-1-dev, python3, python3-setuptools, python3-evdev, python3-pydbus, python3-gi, gettext, python3-cairo, libgtk-3-0, libgtksourceview-4-dev Description: A tool to change the mapping of your input device buttons Replaces: python3-key-mapper, key-mapper Conflicts: python3-key-mapper, key-mapper input-remapper-1.4.0/DEBIAN/copyright000066400000000000000000000000611417303655400173460ustar00rootroot00000000000000Files: * Copyright: 2021 sezanzeb License: GPL-3+input-remapper-1.4.0/DEBIAN/postinst000077500000000000000000000005561417303655400172350ustar00rootroot00000000000000#!/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 systemctl stop key-mapper pkill -f input-remapper-service # might have been started by the gui previously systemctl enable input-remapper systemctl start input-remapper fi input-remapper-1.4.0/LICENSE000066400000000000000000001045151417303655400155070ustar00rootroot00000000000000 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 . input-remapper-1.4.0/README.md000066400000000000000000000035001417303655400157510ustar00rootroot00000000000000

Input Remapper

Formerly Key Mapper

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.

Usage - Macros - Installation - Development - Screenshots - Examples

## Installation ##### Manjaro/Arch ```bash pacaur -S input-remapper-git ``` ##### Ubuntu/Debian Get a .deb file from the [release page](https://github.com/sezanzeb/input-remapper/releases) or install the latest changes via: ```bash sudo apt install git python3-setuptools gettext git clone https://github.com/sezanzeb/input-remapper.git cd input-remapper && ./scripts/build.sh sudo apt install ./dist/input-remapper-1.4.0.deb ``` input-remapper is now part of [Debian Unstable](https://packages.debian.org/sid/key-mapper) ##### pip Dependencies from your distros repo: `gtksourceview4`, `python3-devel` ```bash sudo pip uninstall key-mapper sudo pip install --no-binary :all: git+https://github.com/sezanzeb/input-remapper.git sudo systemctl enable input-remapper sudo systemctl restart input-remapper ``` If it doesn't seem to install, you can also try `sudo python3 setup.py install` ## Screenshots

input-remapper-1.4.0/bin/000077500000000000000000000000001417303655400152445ustar00rootroot00000000000000input-remapper-1.4.0/bin/input-remapper-control000077500000000000000000000220731417303655400216240ustar00rootroot00000000000000#!/usr/bin/python3 # -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings # Copyright (C) 2022 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 grp import sys import argparse import logging import subprocess from inputremapper.logger import logger, update_verbosity, log_info, add_filehandler from inputremapper.migrations import migrate from inputremapper.config import config # import inputremapper modules as late as possible to make sure the correct # log level is applied before anything is logged AUTOLOAD = 'autoload' START = 'start' STOP = 'stop' STOP_ALL = 'stop-all' HELLO = 'hello' # internal stuff that the gui uses START_DAEMON = 'start-daemon' HELPER = 'helper' def run(cmd): """Run and log a command.""" logger.info('Running `%s`...', cmd) code = os.system(cmd) if code != 0: logger.error('Failed. exit code %d', code) def group_exists(name): """Check if a group with that name exists.""" try: grp.getgrnam(name) return True except KeyError: return False COMMANDS = [AUTOLOAD, START, STOP, HELLO, STOP_ALL] INTERNALS = [START_DAEMON, HELPER] def utils(options): """Listing names, tasks that don't require a running daemon.""" if options.list_devices: logger.setLevel(logging.ERROR) from inputremapper.groups import groups for group in groups: print(group.key) if options.key_names: from inputremapper.system_mapping import system_mapping print('\n'.join(system_mapping.list_names())) def communicate(options, daemon): """Commands that require a running daemon""" # import stuff late to make sure the correct log level is applied # before anything is logged from inputremapper.groups import groups from inputremapper.paths import USER def require_group(): if options.device is None: logger.error('--device missing') sys.exit(1) if options.device.startswith('/dev'): group = groups.find(path=options.device) else: group = groups.find(key=options.device) if group is None: logger.error( 'Device "%s" is unknown or not an appropriate input device', options.device ) sys.exit(1) return group if daemon is None: # probably broken tests logger.error('Daemon missing') sys.exit(1) if options.config_dir is not None: path = os.path.abspath(os.path.expanduser(os.path.join( options.config_dir, 'config.json' ))) if not os.path.exists(path): logger.error('"%s" does not exist', path) sys.exit(1) logger.info('Using config from "%s" instead', path) config.load_config(path) if 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(config.path) daemon.set_config_dir(config_dir) migrate() if options.command == AUTOLOAD: # if device was specified, autoload for that one. if None autoload # for all devices. if options.device is None: logger.info('Autoloading all') # timeout is not documented, for more info see # https://github.com/LEW21/pydbus/blob/master/pydbus/proxy_method.py daemon.autoload(timeout=10) else: group = require_group() logger.info('Asking daemon to autoload for %s', options.device) daemon.autoload_single(group.key, timeout=2) if options.command == START: group = require_group() logger.info( 'Starting injection: "%s", "%s"', options.device, options.preset ) daemon.start_injecting(group.key, options.preset) if options.command == STOP: group = require_group() daemon.stop_injecting(group.key) if options.command == STOP_ALL: daemon.stop_all() if options.command == HELLO: response = daemon.hello('hello') logger.info('Daemon answered with "%s"', response) def internals(options): """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 = ' -d' if options.debug else '' if options.command == HELPER: cmd = f'input-remapper-helper{debug}' elif options.command == START_DAEMON: cmd = f'input-remapper-service --hide-info{debug}' else: return # daemonize cmd = f'{cmd} &' os.system(cmd) def systemd_finished(): """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(): return True return False def main(options): if options.debug: update_verbosity(True) add_filehandler('/var/log/input-remapper-control') if options.version: log_info() return logger.debug('Call for "%s"', sys.argv) from inputremapper.paths import USER boot_finished = systemd_finished() is_root = USER == "root" is_autoload = options.command == 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 INTERNALS: internals(options) elif options.command in COMMANDS: from inputremapper.daemon import Daemon daemon = Daemon.connect(fallback=False) communicate(options, daemon) else: logger.error('Unknown command "%s"', options.command) else: utils(options) if options.command: logger.info('Done') if __name__ == '__main__': parser = argparse.ArgumentParser() parser.add_argument( '--command', action='store', dest='command', help=( 'Communicate with the daemon. Available commands are start, ' 'stop, autoload, hello or stop-all' ), 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 mapping', 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 ) main(parser.parse_args(sys.argv[1:])) input-remapper-1.4.0/bin/input-remapper-gtk000077500000000000000000000050371417303655400207320ustar00rootroot00000000000000#!/usr/bin/python3 # -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings # Copyright (C) 2022 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.""" import sys import atexit import gettext import locale from inputremapper.data import get_data_path import os.path from argparse import ArgumentParser import gi gi.require_version('Gtk', '3.0') gi.require_version('GLib', '2.0') gi.require_version('GtkSource', '4') from gi.repository import Gtk 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 # https://github.com/Nuitka/Nuitka/issues/607#issuecomment-650217096 Gtk.init() from inputremapper.logger import logger, update_verbosity, log_info from inputremapper.migrations import migrate if __name__ == '__main__': 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:]) update_verbosity(options.debug) log_info('input-remapper-gtk') logger.debug('Using locale directory: {}'.format(LOCALE_DIR)) # import input-remapper stuff after setting the log verbosity from inputremapper.gui.user_interface import UserInterface from inputremapper.daemon import Daemon from inputremapper.daemon import config migrate() config.load_config() user_interface = UserInterface() def stop(): if isinstance(user_interface.dbus, Daemon): # have fun debugging completely unrelated tests if you remove this user_interface.dbus.stop_all() user_interface.on_close() atexit.register(stop) Gtk.main() input-remapper-1.4.0/bin/input-remapper-helper000077500000000000000000000034301417303655400214170ustar00rootroot00000000000000#!/usr/bin/python3 # -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings # Copyright (C) 2022 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 helper.""" import os import sys import atexit import signal from argparse import ArgumentParser from inputremapper.logger import update_verbosity if __name__ == '__main__': 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:]) update_verbosity(options.debug) # import input-remapper stuff after setting the log verbosity from inputremapper.gui.helper import RootHelper 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) helper = RootHelper() helper.run() input-remapper-1.4.0/bin/input-remapper-service000077500000000000000000000033351417303655400216040ustar00rootroot00000000000000#!/usr/bin/python3 # -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings # Copyright (C) 2022 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 from argparse import ArgumentParser from inputremapper.logger import update_verbosity, log_info, add_filehandler if __name__ == '__main__': 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:]) update_verbosity(options.debug) # import input-remapper stuff after setting the log verbosity from inputremapper.daemon import Daemon add_filehandler() if not options.hide_info: log_info('input-remapper-service') daemon = Daemon() daemon.publish() daemon.run() input-remapper-1.4.0/bin/key-mapper-control000077500000000000000000000023671417303655400207320ustar00rootroot00000000000000#!/usr/bin/python3 # -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings # Copyright (C) 2022 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 . print( "\033[31m" "key-mapper-control is deprecated, please use input-remapper-control instead" "\033[0m" ) from importlib.util import spec_from_loader, module_from_spec from importlib.machinery import SourceFileLoader bin_path = "/bin/input-remapper-control" loader = SourceFileLoader("__main__", bin_path) spec = spec_from_loader("__main__", loader) module = module_from_spec(spec) spec.loader.exec_module(module) input-remapper-1.4.0/bin/key-mapper-gtk000077500000000000000000000023531417303655400200320ustar00rootroot00000000000000#!/usr/bin/python3 # -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings # Copyright (C) 2022 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 . print( "\033[31m" "key-mapper-gtk is deprecated, please use input-remapper-gtk instead" "\033[0m" ) from importlib.util import spec_from_loader, module_from_spec from importlib.machinery import SourceFileLoader bin_path = "/bin/input-remapper-gtk" loader = SourceFileLoader("__main__", bin_path) spec = spec_from_loader("__main__", loader) module = module_from_spec(spec) spec.loader.exec_module(module) input-remapper-1.4.0/bin/key-mapper-service000077500000000000000000000023671417303655400207120ustar00rootroot00000000000000#!/usr/bin/python3 # -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings # Copyright (C) 2022 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 . print( "\033[31m" "key-mapper-service is deprecated, please use input-remapper-service instead" "\033[0m" ) from importlib.util import spec_from_loader, module_from_spec from importlib.machinery import SourceFileLoader bin_path = "/bin/input-remapper-service" loader = SourceFileLoader("__main__", bin_path) spec = spec_from_loader("__main__", loader) module = module_from_spec(spec) spec.loader.exec_module(module) input-remapper-1.4.0/data/000077500000000000000000000000001417303655400154055ustar00rootroot00000000000000input-remapper-1.4.0/data/99-input-remapper.rules000066400000000000000000000005241417303655400216710ustar00rootroot00000000000000# 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 ACTION=="add", SUBSYSTEM=="input", RUN+="/bin/input-remapper-control --command autoload --device $env{DEVNAME}" input-remapper-1.4.0/data/input-remapper-autoload.desktop000066400000000000000000000004531417303655400235600ustar00rootroot00000000000000[Desktop Entry] Type=Application Exec=bash -c "input-remapper-control --command stop-all && input-remapper-control --command autoload" Name=input-remapper-autoload Icon=/usr/share/input-remapper/input-remapper.svg Comment=Starts injecting all presets that are set to automatically load for the user input-remapper-1.4.0/data/input-remapper-large.png000066400000000000000000000170011417303655400221520ustar00rootroot00000000000000‰PNG  IHDRnnÆ[&ûoiCCPicc(‘u‘;KA…?7JÄ)´±HÅB!(ˆ¥ÆÂ&ˆÄFm’u“›Í²› b+ØX¢¯Â ­`« Š bãðÕHXïd ¢³ÌÞ3s.3g@‹›zÁmŽBÁ*9‰éXx!µ¾ÒŒF'hiݵ'ggãü;¾îiRõnXõúߟ£}ÅpuhjÓm§$NŸ(I:Ñ›cí5ĽT&_%;è¿øœodœ.'57Ÿ£ÈÈ r©âÏ2í<î•.=ûhŠTàÏŽÔÚ8âx‡MdºõKî̆"ÔÕÕÓ‚*8\HÕ55ÔÔÔDçZZDYæþ(ªo_Š¥¸þ±”™‘NY”–šJÁÁÁ^CïÀU™ž|(U:ÈΆ¸ß–ËñÌa<Ï“»ëï=I'-[·Ñ›6ÑÉêÓk¤¥¦¤Pvfe‰'“bûõ3ÝŽ¥ µ1#³´-ˆ~ñ“$©ÉR&+ùqOɲ#®œîc¦ã×<¶þÞãs9]´uÇúä³µÔpê”Éák‰ŽæéFde¤¥QHˆ_Ôþ$?’"­¶u" ùqKKåAAô¦”&rêôiz}Å_¨¸¤Äßóíƒ6¦¤$‰£;;3>.Î}(9háÃÉ’ù£Ãàhü†¸eåò-Üøk<h2zÀ‘cÇ迼MgϪŠ^ºédVV&ïÊt ³E¾/gr|ÛÃ)Ò—þ¹íˆûÓ 9üt-ãA? 6à;wÓ[ï¾K.V‡\hà`^?yÀ±3ùÉæ™/˜" àä£óɇ“é7v«Ô,Fm¼Ë2¸Á¿óû+ÔÊ=J¿{íu{±@dd$¯é]ôt3"<Üðð™qù§#‚n}8N:c¸’NAÛ÷b•<œœô1÷—ªÖgEU½¸üjédíÍN",¬C>omm5[ÕÖòØ};¸×Næ'‰w©Î®ÜBóìRŸÙ‚¸Ëe¨¨>ä'Vm…ÀîÿzÉSm]¥E¼jÜ8ºùÆä`ª_]SKE%Å´k÷^:TXh©=»+…óÌLï)³²:ŠÚ d:î”i«ÍŽúڿψc È|fõÿʉÐÌÚõèk>²4Þ>¼O>ñ8O=»xõõ?±°PÙ×ðâùxͤìND&'%‘äpTózÍcË.à )ôIÕЉ´¿q»šíœ=ÛDÿZ»Îò8¯žõ/*Ãu×<ÀU·´¶N%’7Ðí?}nÿ3‹dq`ùƒ\ô w¢Êˆxàèñ„£Rb¢c(::Š¢¢ú %rß¾}ÄŸÓ¯¹Z³*³Ú߿˟øAºíñŸŒî1çÙÐKGä0^'Èiª,¿wÇ{ö~¥9…ÞðL£cö*÷ÀæÆÂúótûã¹TñZÿL»Ö‚˜ÚqrZÆ© ×Jé±ëãÆŒöiL`Y8ú†Á"JiYC7=Ý×ÄA÷ÈÊâï›Y$Ð7=ÿÀË.3Ód²WOšÈ;v ÎÍõ©U† ­›—@Œƒ<›BÎýY«¼!S2´üì^°Flgíÿç_hëXo¸v>EDèò8š½BÃ;rø0|ª:yÒÄ(ýWtê”ÉtÏߣy³fRÞÔ©4lȪ¯o ºúz#¥‘SÎÑ>6P*€.â`OëÓDœMq=è«¢²’¶nß¡:H¨ˆ\w­‘I*E£FŒ ¡,Û:,Ø[0îúö¼¹Šbb¢éÊQ#©¨¸Ä(òòhÄä/yEÞóÐ¥q0‚ò9ÞÊœ;×án 2³„z4ÐJ¿éi4eâUVªÚR–÷™Ó¦)¶Ký‚ë ¬A¼ö/ÑSOõÀ“&â^ª”:-×–&U•¼úÇ?Óß>øÐv»œK81÷°f„AêK›Âª/¨Ã DÃéHËͦvœì¢ç¸BwCr!#‚(èà3¿z–Þ}ÕÔÖm^³\qq©-íXi$ĀӒÔ§šü4ÝtS7²¦úYÀ‹}·•»ëôÕÔxto¹­½6åo¥_=ÿ[vgx“pŒúEr‡PcYy…æÐa–Òã¶½D¡9³=SE\踠OÖfhD̶wß~3Uz”Ýð péóıiiqp­¡U!1ôHp$õó°ÃX\^¡Ž¼[òÍÛeW7ÿSE͉pVÕð{4³ CçRþ¶ífª* Ÿ•Cìwé pj:×ì—þÜý\Æ}¬éGiŒ<7Lã”ûƒÃi^ë)*aú‚ïw|¼oÁ”ã ÛØRðáǰ7›é[ž5×éal¶eÅòC ²}!±0ïýíïTUxyí!ÑÝæžt2Ѽ§·6ˆŸÎœi¤ç–.£áC‡g]À¾HïÕXôlºí‰Á´òYa|ì8øòu 7‚ÙA¹—SûèÁn´Ü:Ô+Hí¦Q’ºzï®ä2»]jF|`û7`~‰‰ L‹$Êëé|[›þò9¡RF\g†~#K€ÎMdÓ Îu£ ÅJ£Ï>_o´)[Ë%Kºú Já2»IS?L#† ¥ë¿=_xš››iÃÆMÂ)X“)“å.¢sò >Ûˆš±fÏÌ##,²»SÙÅ¡£Êþ–EÅÅtüD‘­ã3ÚØ)¦_z Wfäðá´ð®;»†öà›2oö,ºáÚok7/K îÝB·ð SúH½‰à=8ËI&4øêÞ\ùŽøðü¾ú¨L{6óX,«ùÇX&E5ÀñxÝüyų@·ÙŸYTA–»dên¶K¢;}âÿ5ÖsîÌ´oÿªoè ÞzpøÈ!ÓAÿ[Ž•â’RÖóëUõÛ{„”ßßÖ(Ä€H/I©‘\ô£öÆÎ°så! Øm§y—b¡ˆþrÓfåŽ.¹.Ô#|—‘6Ù_³ÆqM¹ÙP¨3B®]ƒÐÛ°ÝÕF³˜íÇÙBe|tV²3éÛü÷Ù­§i+¿Ó‚`fÒô@‹)ëÙÅFw!1×zúúÊßo¸Þ×fz½~ÜNòÎÙZGÃZêéGü÷Ãü›Tsø˜;ÎO­lYY¹j3³ò¦f¸_v•"P^¯gÞOœ0žÎ²VcÍÇŸø¬Ö22¸Ä 4˜JÊJ…-ÌŸ·ôӧј+¯ °ÐP6Ý4ˆx¿í;;\(ÁòÁÜã&JpìÄ :^T¤:DV^…/$@ ))$—Èn˜•7£IûÓ[ÿû.µ³ŽÒ_úøÀ½ YN]@(FH× ¦“Ðe–––“Ÿ –øÎ¸ûBx§}ïÖ[DÀ%, €þëSÁEÂìäy,i¯½þ†jOh7>>úcb“½\)Ïc/knÆ&¥Vl=¸G£ ‹™®Ê¥Ûo¾QSWŠ ÊŠªJFd‰`z`à´ê"ÝüoÑŒiìm§ˆ™ðÜMqqý Z¥  `ñé1]0ÌΞ‘Çæ"ÊAâ±ã:“¿˜Y[ÊædgÑÏÿwZ·a}¶n=?ï{ND¡~kîlB¬@Ї: |Wp%Äxà2o&Œ«Y|Ìè+»!qî_nÞb¸‹+FŽeYA’žÄ1à½Ìç̘!œãž¯¾GšYHLHÇäE3¾w?ðç ‚‰ÉLTQD„vØUx¨uña]nÃ+‹²ÀÕëÁÈUY!Ò,õ*`§|÷úëè;샂#"@ᑣ°ªDá™Á1kÙYY4‚¹˜œ]€p®)“®§€QÀqïVa)Õi8m=¾ýVuD£ñ÷`$4ãÖØY£“Ô*‚}Yv¶xЖ44œ¢–ó­älw2Mp0ÝŠÖqK÷}$³óf"gá©f6nÎgÇ'e•héfÞÁVÎE )0›Í,tV T j øi¤§¥RJr²ß‘†¹AŒ¸qÁ †§¹aãFho€kÆŠ•+-1`8UÀôxA¨ÈˆÔ†Gw‰„ÚÕShý—ug.ô«ï¼CÛwí¢q£GSXxËqœ†oëvK~žø`ïºãvêωx¼-8ƒ¸Ü@Þº3¿@ \?>U²ƒíáÂ#†FwC;\°¥Ž rt&é44¨K±D†ûîþ7Ê蛫¼™µË›z Må®Àt•Ñf½Ë"Ñý c®ôëô!ÆÜÆJY5A¦hæMD:Üÿòî¼õVÊañãï®a/-ß•ž]‚–ÁÀ L‡Nâ`¸ì‚ôzµð>ø|ÅWTQ|IÅ—VPBy9ÚœthÒhÚ;=ð9IOŸ9#·kÏ^ŸåÐq汪 áÒ¡¬˜6k8„ŽŠ.5بÏÅ¢ëX˜eÅ—V DÅž¬!‰Ý”à“{o¥ê¬4Ÿû´Ò,ôðößlîD7†¦M™Â¢‡I­ŠDÛ¤+äó½ó¸kBøÈ‰ç$vvTY%…qBQ£°ÿš ´{–v¸±Ñ¶¬–ƒÛ1V˜:|X¤l„—rkRšÏ1}#;¢'7èò"S„áØåAkÚÚ­ÎD£žÄòNzÁQJ9zB *¦ºŽð›UÀ®ìm€û!¸NoÎnõ°z[̦:-æK\P2C£ë—ÜJ=ÿà3Êݾ׶µŽ/¯ǨÌRé…¾(»µæÂŸy#Äóªx‹+„5pç>‹µ•«qéWmO„­óocÜ€áß>¾n]f>Öé‡ ­`d.%àï¿ÑkK9éÚ´îAZ}Wp@E}ˆ —°[æYîš ä¤kÒ;ü?ôàÕös4”½¨–´ãömH¼Äv“ôS\¤·0v¾7ЏWœ"Á³íMôûο«#н©ÂtâÍíœCo·œ9:ou ØXêÒ“uÃ%÷²¢§«÷ÏÚš„ª*0›9ð‚Cà*qWÀ •}3µüãy$ÿpvOs×ï‡ÚÎÒ¯ß=}!ÈsZD>*8L®Z‡Îy#uœŒ¼E웿Á¥¬Ü½„è\3î»sà¼.ú€ŸSm†:ƒr€£]Ž©DÄ´2'uÇù3„2ÞAÜ ŒÓówS…ȨΆTqS¡OifÍŽ´6éœ üÃ¥ ¨™wÞ?ý–óB}v €HÕ$ü*qS!+ŒÔM®6¯FÀj !h=¼a³çQCÃ!ºãø™ …ÐTäSйS &~±yNjŽñ$r$ ÄázI–u¨Î¡W¬å«R’Šz&‘™êå#h4#h<#j,?¹ž‹Xi=;:¶×]Dõ†éÓ{þf?ïBœ‘Ø ÁÉ·4v&†:§„¸_… ¯V*_õNâØ$ä7AÑÿvüÖ}gÇÜ÷ˆ‡‹\Ù Šôz3ýݳ»}-:gu !6Ä oÐKX ÅWVE¡,àL[¾3—œ­ÎA§^W*ù¯ããø"WÖ qF5(f pÜ(3Å;ÊòNêÇ)æãKQeV÷˜eaöWt2+ŽŒëÀ4àªwŸ]ˆÃí»ím´„_èÇ»Ú0â–¾‘t66†ú6XW•º˜87$ šŒd*ËÍ¡ÊYº#ƒjÌmmOࣻ)ÄD^Ë”cE½…¸š†TqŽ€.ÄáìäãòSþm®îìm*PËê/3ˆkæ,é¨SÍô±6-…êRÙ¬“œwTæÁÔzè¸8öàÏâ ÀjßÀÓXùŒ$u °ÝgÍW&óÑ0Ä•_žCY_)_¯„Ô¥ðnbDáX­å§9ÚL^ãŽåµn\o<M)œ¥ÏéFjŠQ¼O¯ºå÷l7í®Ú q¸çš}[`Ÿ ȨNŒBqì(”³ç µF†‰]„UÃ6`7©äÁì[YÍt‰=Áx€£ eæäèè´{&;ur€9{{¦c²¼zã™6Å0äB¦ý¦v¿‹§âp99{}½e6Û¹Õ…€<·}~žxô ‚#EqLu¸í1¢*NRP[OÕ×ð/¶ŠÝY:¸ÃeJm3DZÞ8ð>™âaDœƒþà=®—“sdÐ"Oúgd2v– â­/vS§_%Õç”ñ;óRïB\«×€d5hé)´8f`RM¸,®,í=‡ÀðËä•|ÔÜi'2ôÚJ>^BXpCqÕäð!Úpècî¶=ªÝ¯yðN¦Q¹ Å£ÇÀÄñG…+ òœD/<"õ0(²d¸Qž/%GÂÝ,éz1òÌ赊éõTïQ^_Ѓb'fj«372!]°ãI¼àør“ÚñcdBXX7âÀ¾gíSÄ¡Œ®¼,“Ö!ì½7´q }Äy,,ê©A<«¼”\Øû32cÅ{Š'ÚÞØD-QÖ,:kôåé´âBšˆûI’ÔÄ´n1—«&¿ÿ‘”í‚n;B…·÷ñý·‹]¥pYˆ<Ó3›lxS3Eן¢3ýûQmj’hCɵ!ˆ£á¤¨ONìê"¡¤œ‘¶JW‡™È‡ûã³k]¸vÖ?¨wá».×øHŠ´š™ŠÏíDš8jÊOvż5ð¢)±Öá,tëIYÌý©î¦N½"väéDõ¸Ïã2åH3#ïé"MÌÁ?.ËI’t/^ÐE8döŸYù¢´TVî/íi@ž7@­•Ç_~¿“êAé5ç±°8jÕò#Ì;™Ó´·V³FÆ@Fr Î~:·“ÃêþÓÈZBÜèÇ®Ýê’¦®ÎøjÚDzûéGéÈXuÛ•7¡4àЖšõÆ_™yéI§!4k15ž «eÿãrÓo–Ó5ï| k£úhWªHþnN;ƒèfµôoq A.§J¢`¯n˜í˜7]È@ +E£†¨ŽÃóˆÒZX03Y–‚fÞ åЂتš®ÝŵXb´øDp LÑC‹_‹~œ$)Ë, • !õX›¢kÒxó‚¹T0iLWWp R :ôô>ÖÓp€‹›É´Ç3F@q8jÅ®`8ߟÎ{\§·FÞN¸BÓ‚a¤Î2ËN“Þ3QÞ¸J‹]05o|úâ–ëèØ•Ã»õµ´J9ÐïÝÄ·žÓ¹Gª¬¼ï ŸÄâr]"úì:.ù$Ðû8ô±}HpàêñôÙ=7‹“Ågè£úZl¶óñ×Í9ÂÚò]­È ÕÓ× ÈYǪìÔTxrä"%À–³ÛFÓŒƒÚ©—PþÆ%¯°Ú-ò>ŽSŽœ0¼F%< ¹ I ví0÷òYó“§eÛèà #®ß+O4Ô/\¢Òõé;óT‘†`òjzó_#.EqhOÉ‘VmÂÐEº¡|Pްˆ+PHó±.42Â5"™`òñ#p6Äù¤÷ÔüéÓ0âИL҉䈃‘S ´Ï…ÕÒp™ŒR«n€öÛµ3iȦÄ©†Um@„ã

Ýj qÌûæóM$Š õÍ;ÄUUɰ˩˜Y“Ùa¾%@‰åvkÀçßÉVÐ-‹µÞ‚〖»¼‚ÝõJ‡^®W%ïóq<ú²Ó܃5…¸ØÔ³ûJûê:Er2ìÁ[v‰ç'§ne{˜¦ ì¬7ƒ8´£t©†ÿ„¥Ám. $FtûbUšæÝ¾ioøº…KÖr%}ïÝ™|]à蘴å†9âh1òÞ\¥X4´ˆm‚yfž‹–ƒå·Â=ªÍÍÔŽC#|ma.ÅVÄE°fÄ ðµ<8y¬È>N»L‹`"˜ëÃo @Å´þÞÅ)ÒûvÙ4âÈÅtNÃÌbe€¥UQ—·óιÓÄs‘ÃNëͨ±ÌÌ×4â‚CùííÖ“¦¹‡ØÐ3DÀàùœ=—AËoTaleî¦i:aAê¿# Im§Ú«rÒ"„;9øøÂF6‚þÀˆ=Í×¹›ÞqÊŸñ™¦…¸2~¿…Ó\ä³T›Æuf×§ß}ÌÙšL‹y¯>Å øÅÞïëbX­ÇøˆÀÝ@Ïrmµïz–v\Ý¢çÒ$gÐÇlíÆ "sÌNfX¶È’œ/·çǯø±êíuKKåTVZ<ÁuîåÇnç.»ÖÅh;§˜&¿Ìù–>ªáØc´13å,!Nì9¶¶ÕÞ¿,)ž"k¥W0f2öÙòj9‰ãñ㟾Ïù03³´¿,ç¦ପæ÷h—Ý[´Œ8»Æ;°°ƒ~ÄÇÍBn3Ó®výÑ/Öwz ¾ü¤ZSÛ5®^Gœ{"|ʼn´¬Œ¦²ªò.þ Ô õ2°5|¼¼Í†äìáÐ|0Zc»`ç9Èß—Ë‘L8¯ãßfóa?Ð;±€ûü1×ßµSãaàC1Tä‚Dœ÷È_®’³qS¡¸ô®ãþ4p´†“ôë¬|äagÜä/È#âNIah{©ÐE8ïµÁýi¸Š ·:ñ»Aâ~ ¾¶7`0­ŒbäFñ¿£-‰§¹L£H‡Ë™UE’ÎŽ‡…¹ÌÅÂÛ‰ãÿe*.Å6³<ºIEND®B`‚input-remapper-1.4.0/data/input-remapper.desktop000066400000000000000000000003171417303655400217510ustar00rootroot00000000000000[Desktop Entry] Type=Application Name=Input Remapper Icon=/usr/share/input-remapper/input-remapper.svg Exec=input-remapper-gtk Terminal=false Categories=Settings Comment=GUI for device specific key mappings input-remapper-1.4.0/data/input-remapper.glade000066400000000000000000001776311417303655400213720ustar00rootroot00000000000000 True False help-about True False 2 dialog-ok True False 2 edit-copy True False 2 edit-delete True False gtk-delete False dialog False vertical 1 top False True expand Delete False True True True delete-icon-1 True True 1 Cancel True True True True True True True 1 True True 0 True False 50 50 32 False True 1 button1 button2 True False input-keyboard True False 2 edit-undo True False 2 window-close True False 2 object-rotate-right 2 9 1 6 True False 2 document-new True False document-save 800 False Input Remapper 1000 450 input-remapper.svg True False vertical True False vertical 18 True False 0 False True 0 True False 18 18 18 0 6 50 True False Device 13 0 False True 0 True False True True 1 Stop Injection True True True Shortcut: ctrl + del Gives your keys back their original function end gtk-redo-icon True False False 2 True True True Help end about-icon True False False 5 False True 1 True False False True 2 False True 0 True False True False vertical True False 18 vertical 6 True False 12 6 True Apply 80 True True True Start injecting. Don't hold down any keys while the injection starts check-icon none True True True 0 Copy 80 True True True Duplicate this preset copy-icon none True True True 1 New 80 True True True Create a new preset new-icon none True True True 2 Delete 80 True True True Delete this preset delete-icon none True True True 4 True True 0 True False 6 50 True False Preset 13 0 False True 0 200 True False True True 1 False True 1 True False 6 True False Rename 13 0 False True 0 True False True True True True 0 True True True Save the entered name 6 save-icon none False True 1 True True 1 False True 2 True False To automatically apply the preset after your login or when it connects. True False Autoload 0 True True 0 True True False True 1 False True 3 False True 0 True False False True 1 True False 18 vertical 6 True False 6 True False Left joystick 0 True True 0 100 True False Mouse Wheel Buttons Joystick False True 1 False True 0 True False 6 True False Right joystick 0 True True 0 100 True False Mouse Wheel Buttons Joystick False True 1 False True 1 True False 6 True False Mouse speed 13 0 False True 0 0 True True mouse_speed_adjustment 1 False True True 1 False True 2 False True 2 True False vertical True False False True 0 True False False 6 6 dialog-warning False True 0 False 6 dialog-error False True 1 True False 7 7 6 6 vertical True True 2 False True 1 False True end 4 False True 0 True False False True 1 250 True False vertical True False 160 True True True False none True False browse False True 0 True False False True 1 True False 18 18 18 18 vertical 18 True False 12 True Change Key True True True Record a button of your device that should be remapped image1 none True False True 0 True False The type of device this mapping is emulating. True True 1 Delete 80 True True True Delete this entry icon-delete-row none True False True end 2 False True 0 True True True True start immediate word 10 10 10 10 True 2 True True True 1 True True 2 True True 0 True True 2 True True 2 False True 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 © 2021 Sezanzeb proxima@sezanzeb.de 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 input-remapper-1.4.0/data/input-remapper.policy000066400000000000000000000014731417303655400216030ustar00rootroot00000000000000 Run Input Remapper as root Authentication is required to discover and read devices. no auth_admin_keep auth_admin_keep /usr/bin/input-remapper-control false input-remapper-1.4.0/data/input-remapper.service000066400000000000000000000005221417303655400217360ustar00rootroot00000000000000[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 input-remapper-1.4.0/data/input-remapper.svg000066400000000000000000000231611417303655400211010ustar00rootroot00000000000000 image/svg+xml input-remapper-1.4.0/data/inputremapper.Control.conf000066400000000000000000000005301417303655400225640ustar00rootroot00000000000000 input-remapper-1.4.0/data/style.css000066400000000000000000000024451417303655400172640ustar00rootroot00000000000000row { padding: 0; } .status_bar frame { /* the status bar is ugly in elementary os otherwise */ border: 0px; } /* adds a bottom border for themes that don't add one in primary-toolbar classes. Interestingly, those that do add a border ignore this separator, which is perfect. */ .top_separator { border: 0px; /* fixes light pixels on each end in arc-dark */ } .table-header, .row-box { padding: 2px; } .changed { background: @selected_bg_color; } list entry { background-color: transparent; border-radius: 4px; border: 0px; box-shadow: none; } list.basic-editor button:not(:focus) { border-color: transparent; background: transparent; box-shadow: none; } list button { border-color: transparent; } .invalid_input { background-color: #ea9697; } .transparent { background: transparent; } .code-editor-text-view > * { border-radius: 2px; } .copyright { font-size: 7pt; } .editor-key-list label { padding: 11px; } .autocompletion label { padding: 11px; } .autocompletion { padding: 0px; box-shadow: none; } .no-border { border: 0px; box-shadow: none; } .code-editor-text-view.multiline { /* extra space between text editor and line numbers */ padding-left: 18px; } /* @theme_bg_color, @theme_fg_color */ input-remapper-1.4.0/inputremapper/000077500000000000000000000000001417303655400173675ustar00rootroot00000000000000input-remapper-1.4.0/inputremapper/__init__.py000066400000000000000000000000001417303655400214660ustar00rootroot00000000000000input-remapper-1.4.0/inputremapper/config.py000066400000000000000000000206211417303655400212070ustar00rootroot00000000000000#!/usr/bin/python3 # -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings # Copyright (C) 2022 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.""" import os import json import copy from inputremapper.paths import CONFIG_PATH, USER, touch from inputremapper.logger import logger, VERSION MOUSE = "mouse" WHEEL = "wheel" BUTTONS = "buttons" NONE = "none" INITIAL_CONFIG = { "version": VERSION, "autoload": {}, "macros": { # some time between keystrokes might be required for them to be # detected properly in software. "keystroke_sleep_ms": 10 }, "gamepad": { "joystick": { # very small movements of the joystick should result in very # small mouse movements. With a non_linearity of 1 it is # impossible/hard to even find a resting position that won't # move the cursor. "non_linearity": 4, "pointer_speed": 80, "left_purpose": NONE, "right_purpose": NONE, "x_scroll_speed": 2, "y_scroll_speed": 0.5, }, }, } class ConfigBase: """Base class for config objects. Loading and saving is optional and handled by classes that derive from this base. """ def __init__(self, fallback=None): """Set up the needed members to turn your object into a config. Parameters ---------- fallback : ConfigBase a configuration that contains fallback default configs, if your object doesn't configure a certain key. """ self._config = {} self.fallback = fallback def _resolve(self, path, func, config=None): """Call func for the given config value. Parameters ---------- path : string or string[] For example 'macros.keystroke_sleep_ms' or ['macros', 'keystroke_sleep_ms'] config : dict The dictionary to search. Defaults to self._config. """ chunks = path.copy() if isinstance(path, list) else path.split(".") if config is None: child = self._config else: child = config while True: chunk = chunks.pop(0) parent = child child = child.get(chunk) if len(chunks) == 0: # child is the value _resolve is looking for return func(parent, child, chunk) # child is another object if child is None: parent[chunk] = {} child = parent[chunk] def remove(self, path): """Remove a config key. Parameters ---------- path : string or string[] For example 'macros.keystroke_sleep_ms' or ['macros', 'keystroke_sleep_ms'] """ def callback(parent, child, chunk): if child is not None: del parent[chunk] self._resolve(path, callback) def set(self, path, value): """Set a config key. Parameters ---------- path : string or string[] For example 'macros.keystroke_sleep_ms' or ['macros', 'keystroke_sleep_ms'] value : any """ logger.info('Changing "%s" to "%s" in %s', path, value, self.__class__.__name__) def callback(parent, child, chunk): parent[chunk] = value self._resolve(path, callback) def get(self, path, log_unknown=True): """Get a config value. If not set, return the default Parameters ---------- path : string or string[] For example 'macros.keystroke_sleep_ms' log_unknown : bool If True, write an error if `path` does not exist in the config """ def callback(parent, child, chunk): return child resolved = self._resolve(path, callback) if resolved is None and self.fallback is not None: resolved = self.fallback._resolve(path, callback) if resolved is None: # don't create new empty stuff in INITIAL_CONFIG with _resolve initial_copy = copy.deepcopy(INITIAL_CONFIG) resolved = self._resolve(path, callback, initial_copy) if resolved is None and log_unknown: logger.error('Unknown config key "%s"', path) # modifications are only allowed via set return copy.deepcopy(resolved) def clear_config(self): """Remove all configurations in memory.""" self._config = {} class GlobalConfig(ConfigBase): """Global default configuration. It can also contain some extra stuff not relevant for presets, like the autoload stuff. If presets have a config key set, it will ignore the default global configuration for that one. If none of the configs have the key set, a hardcoded default value will be used. """ def __init__(self): self.path = os.path.join(CONFIG_PATH, "config.json") super().__init__() def set_autoload_preset(self, group_key, preset): """Set a preset to be automatically applied on start. Parameters ---------- group_key : string the unique identifier of the group. This is used instead of the name to enable autoloading two different presets when two similar devices are connected. preset : string or None if None, don't autoload something for this device. """ if preset is not None: self.set(["autoload", group_key], preset) else: logger.info('Not injecting for "%s" automatically anmore', group_key) self.remove(["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, preset): """Should this preset be loaded automatically?""" return self.get(["autoload", group_key], log_unknown=False) == preset def load_config(self, path=None): """Load the config from the file system. Parameters ---------- path : string or None 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 USER == "root": logger.debug("Skipping config file creation for the root user") return 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") config = GlobalConfig() input-remapper-1.4.0/inputremapper/daemon.py000066400000000000000000000411001417303655400212000ustar00rootroot00000000000000#!/usr/bin/python3 # -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings # Copyright (C) 2022 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/LEW21/pydbus/tree/cc407c8b1d25b7e28a6d661a29f9e661b1c9b964/examples/clientserver # noqa pylint: disable=line-too-long """ import os import sys import json import time import atexit from pydbus import SystemBus import gi gi.require_version("GLib", "2.0") from gi.repository import GLib from inputremapper.logger import logger, is_debug from inputremapper.injection.injector import Injector, UNKNOWN from inputremapper.mapping import Mapping from inputremapper.config import config from inputremapper.system_mapping import system_mapping from inputremapper.groups import groups from inputremapper.paths import get_config_path, USER from inputremapper.injection.macros.macro import macro_variables from inputremapper.injection.global_uinputs import global_uinputs BUS_NAME = "inputremapper.Control" # timeout in seconds, see # https://github.com/LEW21/pydbus/blob/cc407c8b1d25b7e28a6d661a29f9e661b1c9b964/pydbus/proxy.py BUS_TIMEOUT = 10 class AutoloadHistory: """Contains the autoloading history and constraints.""" def __init__(self): """Construct this with an empty history.""" # mapping of device -> (timestamp, preset) self._autoload_history = {} def remember(self, group_key, preset): """Remember when this preset was autoloaded for the device.""" self._autoload_history[group_key] = (time.time(), preset) def forget(self, group_key): """The injection was stopped or started by hand.""" if group_key in self._autoload_history: del self._autoload_history[group_key] def may_autoload(self, group_key, preset): """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 def remove_timeout(func): """Remove timeout to ensure the call works if the daemon is not a proxy.""" # the timeout kwarg is a feature of pydbus. This is needed to make tests work # that create a Daemon by calling its constructor instead of using pydbus. def wrapped(*args, **kwargs): if "timeout" in kwargs: del kwargs["timeout"] return func(*args, **kwargs) return wrapped 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 = f""" """ def __init__(self): """Constructs the daemon.""" logger.debug("Creating daemon") self.injectors = {} self.config_dir = None if USER != "root": self.set_config_dir(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() global_uinputs.prepare() @classmethod def connect(cls, fallback=True): """Get an interface to start and stop injecting keystrokes. Parameters ---------- fallback : bool If true, returns an instance of the daemon instead if it cannot connect """ try: bus = SystemBus() interface = bus.get(BUS_NAME, timeout=BUS_TIMEOUT) logger.info("Connected to the service") except GLib.GError 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 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: interface = bus.get(BUS_NAME, timeout=BUS_TIMEOUT) break except GLib.GError 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(1) if USER != "root": config_path = get_config_path() logger.debug('Telling service about "%s"', config_path) interface.set_config_dir(get_config_path(), timeout=2) return interface def publish(self): """Make the dbus interface available.""" bus = SystemBus() try: bus.publish(BUS_NAME, self) except RuntimeError as error: logger.error("Is the service already running? (%s)", str(error)) sys.exit(1) def run(self): """Start the daemons loop. Blocks until the daemon stops.""" loop = GLib.MainLoop() logger.debug("Running daemon") loop.run() def refresh(self, group_key=None): """Refresh groups if the specified group is unknown. Parameters ---------- group_key : str unique identifier used by the groups object """ now = time.time() if now - 10 > self.refreshed_devices_at: logger.debug("Refreshing because last info is too old") # it may take a little bit of time until devices are visible after # changes 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): """Stop injecting the mapping 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): """Get the injectors state.""" injector = self.injectors.get(group_key) return injector.get_state() if injector else UNKNOWN @remove_timeout def set_config_dir(self, config_dir): """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 : string This path contains config.json, xmodmap.json and the presets directory """ config_path = os.path.join(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 config.load_config(config_path) def _autoload(self, group_key): """Check if autoloading is a good idea, and if so do it. Parameters ---------- group_key : str 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 = config.get(["autoload", group.key], log_unknown=False) 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) @remove_timeout def autoload_single(self, group_key): """Inject the configured autoload preset for the device. If the preset is already being injected, it won't autoload it again. Parameters ---------- group_key : str 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) @remove_timeout def autoload(self): """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(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, preset): """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 : string The unique key of the group preset : string The name of the preset """ 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 = os.path.join( self.config_dir, "presets", group.name, f"{preset}.json" ) mapping = Mapping() try: mapping.load(preset_path) except FileNotFoundError as error: logger.error(str(error)) return False if self.injectors.get(group_key) is not None: self.stop_injecting(group_key) # Path to a dump of the xkb mappings, to provide more human # readable keys in the correct keyboard layout to the service. # The service cannot use `xmodmap -pke` because it's running via # 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) system_mapping.update(xmodmap) # the service now has process wide knowledge of xmodmap # keys of the users session except FileNotFoundError: logger.error('Could not find "%s"', xmodmap_path) try: injector = Injector(group, mapping) 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): """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): """Used for tests.""" logger.info('Received "%s" from client', out) return out input-remapper-1.4.0/inputremapper/data.py000066400000000000000000000055021417303655400206540ustar00rootroot00000000000000#!/usr/bin/python3 # -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings # Copyright (C) 2022 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 sys import os import site import pkg_resources from inputremapper.logger import logger logged = False def get_data_path(filename=""): """Depending on the installation prefix, return the data dir. Since it is a nightmare to get stuff installed with pip across distros this is somewhat complicated. Ubuntu uses /usr/local/share for data_files (setup.py) and manjaro uses /usr/share. """ global logged source = None try: source = pkg_resources.require("input-remapper")[0].location # failed in some ubuntu installations except pkg_resources.DistributionNotFound: pass # depending on where this file is installed to, make sure to use the proper # prefix path for data # https://docs.python.org/3/distutils/setupscript.html?highlight=package_data#installing-additional-files # noqa pylint: disable=line-too-long data = None # python3.8/dist-packages python3.7/site-packages, /usr/share, # /usr/local/share, endless options if source and "-packages" not in source and "python" not in source: # probably installed with -e, running from the cloned git source data = os.path.join(source, "data") if not os.path.exists(data): if not logged: logger.debug('-e, but data missing at "%s"', data) data = None candidates = [ "/usr/share/input-remapper", "/usr/local/share/input-remapper", os.path.join(site.USER_BASE, "share/input-remapper"), ] if data is None: # try any of the options for candidate in candidates: if os.path.exists(candidate): data = candidate break if data is None: logger.error("Could not find the application data") sys.exit(1) if not logged: logger.debug('Found data at "%s"', data) logged = True return os.path.join(data, filename) input-remapper-1.4.0/inputremapper/exceptions.py000066400000000000000000000024161417303655400221250ustar00rootroot00000000000000#!/usr/bin/python3 # -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings # Copyright (C) 2022 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 """ pass class UinputNotAvailable(Error): def __init__(self, name): super().__init__(f"{name} is not defined or unplugged") class EventNotHandled(Error): def __init__(self, event): super().__init__(f"the event {event} can not be handled") input-remapper-1.4.0/inputremapper/groups.py000066400000000000000000000360401417303655400212630ustar00rootroot00000000000000#!/usr/bin/python3 # -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings # Copyright (C) 2022 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. """ import re import multiprocessing import threading import asyncio import json 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.logger import logger from inputremapper.paths import get_preset_path TABLET_KEYS = [ evdev.ecodes.BTN_STYLUS, evdev.ecodes.BTN_TOOL_BRUSH, evdev.ecodes.BTN_TOOL_PEN, evdev.ecodes.BTN_TOOL_RUBBER, ] 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 mapping.""" # 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 not REL_X in capabilities.get(EV_REL, []): return False if not REL_Y in capabilities.get(EV_REL, []): return False # at least the vertical mouse wheel if not REL_WHEEL in capabilities.get(EV_REL, []): return False # and a mouse click button if not BTN_LEFT 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): """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 GRAPHICS_TABLET if _is_touchpad(capabilities): return TOUCHPAD if _is_gamepad(capabilities): return GAMEPAD if _is_mouse(capabilities): return MOUSE if _is_camera(capabilities): return CAMERA if _is_keyboard(capabilities): # very low in the chain to avoid classifying most devices # as keyboard, because there are many with ev_key capabilities return KEYBOARD return UNKNOWN DENYLIST = [".*Yubico.*YubiKey.*", "Eee PC WMI hotkeys"] def is_denylisted(device): """Check if a device should not be used in input-remapper. Parameters ---------- device : InputDevice """ for name in DENYLIST: if re.match(name, str(device.name), re.IGNORECASE): return True return False def get_unique_key(device): """Find a string key that is unique for a single hardware device. All InputDevices in /dev/input that originate from the same physical hardware device should return the same key via this function. Parameters ---------- device : InputDevice """ # Keys that should not be used: # - device.phys is empty sometimes and varies across virtual # subdevices # - device.version varies across subdevices # - device.uniq is empty most of the time, I don't know what this is # supposed to be 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}_" # 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, names, types, key): """Specify a group Parameters ---------- paths : str[] Paths in /dev/input of the grouped devices names : str[] Names of the grouped devices types : str[] Types of the grouped devices key : str 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 = sorted(names, key=len)[0] self.key = key self.paths = paths self.names = names self.types = types def get_preset_path(self, preset=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 get_preset_path(self.name, preset) 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): """Load a serialized representation.""" group = cls(**json.loads(serialized)) return group def __repr__(self): return f"Group({self.key})" 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): """Construct the process. Parameters ---------- pipe : multiprocessing.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 = {} for path in 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 %s: %s", path, str(error)) continue if device.name == "Power Button": continue device_type = classify(device) if device_type == CAMERA: continue # https://www.kernel.org/doc/html/latest/input/event-codes.html capabilities = device.capabilities(absinfo=False) key_capa = capabilities.get(EV_KEY) if key_capa is None and device_type != GAMEPAD: # skip devices that don't provide buttons that can be mapped continue if is_denylisted(device): continue key = get_unique_key(device) if grouped.get(key) is None: grouped[key] = [] logger.debug( 'Found "%s", "%s", "%s", type: %s', key, path, device.name, device_type ) 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) group = _Group( key=key, paths=devs, names=names, types=sorted(list({item[2] for item in group if item[2] != UNKNOWN})), ) result.append(group.dumps()) self.pipe.send(json.dumps(result)) # now that everything is sent via the pipe, the InputDevice # destructors can go on an take ages to complete in the thread # without blocking anything class _Groups: """Contains and manages all groups.""" def __init__(self): self._groups = None def __getattribute__(self, key): """To lazy load group info only when needed. For example, this helps to keep logs of input-remapper-control clear when it doesnt need it the information. """ if key == "_groups" and object.__getattribute__(self, "_groups") is None: object.__setattr__(self, "_groups", {}) object.__getattribute__(self, "refresh")() return object.__getattribute__(self, key) 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 self.loads(pipe[0].recv()) if len(self._groups) == 0: logger.debug("Did not find any input device") else: keys = [f'"{group.key}"' for group in self._groups] logger.info("Found %s", ", ".join(keys)) def filter(self, include_inputremapper=False): """Filter groups.""" result = [] for group in self._groups: name = group.name if not include_inputremapper and name.startswith("input-remapper"): continue result.append(group) return result def set_groups(self, new_groups): """Overwrite all groups.""" self._groups = new_groups def list_group_names(self): """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 __len__(self): return len(self._groups) def __iter__(self): return iter(self._groups) def dumps(self): """Create a deserializable string representation.""" return json.dumps([group.dumps() for group in self._groups]) def loads(self, dump): """Load a serialized representation created via dumps.""" self._groups = [_Group.loads(group) for group in json.loads(dump)] def find(self, name=None, key=None, path=None, include_inputremapper=False): """Find a group that matches the provided parameters. Parameters ---------- name : str "USB Keyboard" Not unique, will return the first group that matches. key : str "USB Keyboard", "USB Keyboard 2", ... path : str "/dev/input/event3" """ for group in self._groups: if not include_inputremapper and group.name.startswith("input-remapper"): continue 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 groups = _Groups() input-remapper-1.4.0/inputremapper/gui/000077500000000000000000000000001417303655400201535ustar00rootroot00000000000000input-remapper-1.4.0/inputremapper/gui/__init__.py000066400000000000000000000000001417303655400222520ustar00rootroot00000000000000input-remapper-1.4.0/inputremapper/gui/custom_mapping.py000066400000000000000000000017051417303655400235550ustar00rootroot00000000000000#!/usr/bin/python3 # -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings # Copyright (C) 2022 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 . """One mapping object for the GUI application.""" from inputremapper.mapping import Mapping custom_mapping = Mapping() input-remapper-1.4.0/inputremapper/gui/editor/000077500000000000000000000000001417303655400214415ustar00rootroot00000000000000input-remapper-1.4.0/inputremapper/gui/editor/__init__.py000066400000000000000000000000001417303655400235400ustar00rootroot00000000000000input-remapper-1.4.0/inputremapper/gui/editor/autocompletion.py000066400000000000000000000325501417303655400250620ustar00rootroot00000000000000#!/usr/bin/python3 # -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings # Copyright (C) 2022 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 gi.repository import Gdk, Gtk, GLib, GObject from evdev.ecodes import EV_KEY from inputremapper.system_mapping import system_mapping from inputremapper.injection.macros.parse import ( FUNCTIONS, get_macro_argument_names, remove_comments, ) from inputremapper.injection.global_uinputs import global_uinputs from inputremapper.logger import logger # no shorthand names FUNCTION_NAMES = [name for name in FUNCTIONS.keys() if len(name) > 1] # no deprecated functions FUNCTION_NAMES.remove("ifeq") def _get_left_text(iter): buffer = iter.get_buffer() result = buffer.get_text(buffer.get_start_iter(), iter, True) result = remove_comments(result) result = result.replace("\n", " ") return result.lower() # regex to search for the beginning of a... PARAMETER = r".*?[(,=+]\s*" FUNCTION_CHAIN = r".*?\)\s*\.\s*" def get_incomplete_function_name(iter): """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) if match is None: return "" return match[1] def get_incomplete_parameter(iter): """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(f"get_incomplete_parameter text: %s match: %s", left_text, match) if match is None: return None return match[1] def propose_symbols(text_iter, codes): """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() return [ (name, name) for name in list(system_mapping.list_names(codes=codes)) if incomplete_name in name.lower() and incomplete_name != name.lower() ] def propose_function_names(text_iter): """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() return [ (name, f"{name}({', '.join(get_macro_argument_names(FUNCTIONS[name]))})") for name in FUNCTION_NAMES if incomplete_name in name.lower() and incomplete_name != name.lower() ] debounces = {} def debounce(func): """Debounce a function call to improve performance.""" def clear_debounce(self, *args): debounces[func.__name__] = None return func(self, *args) def wrapped(self, *args): if debounces.get(func.__name__) is not None: GLib.source_remove(debounces[func.__name__]) timeout = self.debounce_timeout debounces[func.__name__] = GLib.timeout_add( timeout, lambda: clear_debounce(self, *args) ) return wrapped class SuggestionLabel(Gtk.Label): """A label with some extra internal information.""" __gtype_name__ = "SuggestionLabel" def __init__(self, display_name, suggestion): 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" def __init__(self, text_input, target_selector): """Create an autocompletion popover. It will remain hidden until there is something to autocomplete. Parameters ---------- text_input : Gtk.SourceView | Gtk.TextView 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.debounce_timeout = 100 self.text_input = text_input self.target_selector = target_selector self._target_key_capabilities = [] target_selector.connect("changed", self._update_target_key_capabilities) 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) text_input.connect("key-press-event", self.navigate) # add some delay, so that pressing the button in the completion works before # the popover is hidden due to focus-out-event text_input.connect("focus-out-event", self.on_text_input_unfocus) text_input.get_buffer().connect("changed", self.update) self.set_position(Gtk.PositionType.BOTTOM) self.visible = False self.show_all() self.popdown() # hidden by default. this needs to happen after show_all! def on_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): """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): """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 if row: y_offset = row.translate_coordinates(self.list_box, 0, 0)[1] height = self.scrolled_window.get_max_content_height() current_y_scroll = self.scrolled_window.get_vadjustment().get_value() vadjustment = self.scrolled_window.get_vadjustment() if y_offset > current_y_scroll + (height - row_height): vadjustment.set_value(y_offset - (height - row_height)) if y_offset < current_y_scroll: # scroll up because the element is not visible anymore vadjustment.set_value(y_offset) def _get_text_iter_at_cursor(self): """Get Gtk.TextIter at the current text cursor location.""" cursor = self.text_input.get_cursor_locations()[0] return self.text_input.get_iter_at_location(cursor.x, cursor.y)[1] def popup(self): self.visible = True super().popup() def popdown(self): self.visible = False super().popdown() @debounce def update(self, *_): """Find new autocompletion suggestions and display them. Hide if none.""" if not self.text_input.is_focus(): self.popdown() return self.list_box.forall(self.list_box.remove) # move the autocompletion to the text cursor cursor = self.text_input.get_cursor_locations()[0] # convert it to window coords, because the cursor values will be very large # when the TextView is in a scrolled down ScrolledWindow. window_coords = self.text_input.buffer_to_window_coords( Gtk.TextWindowType.TEXT, cursor.x, cursor.y ) cursor.x = window_coords.window_x cursor.y = window_coords.window_y cursor.y += 12 if self.text_input.get_show_line_numbers(): cursor.x += 25 self.set_pointing_to(cursor) text_iter = self._get_text_iter_at_cursor() 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_target_key_capabilities(self, *_): target = self.target_selector.get_active_id() self._target_key_capabilities = global_uinputs.get_uinput( target ).capabilities()[EV_KEY] def _on_suggestion_clicked(self, _, selected_row): """An autocompletion suggestion was selected and should be inserted.""" selected_label = selected_row.get_children()[0] suggestion = selected_label.suggestion buffer = self.text_input.get_buffer() # 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.text_input, 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.text_input, Gtk.DeleteType.CHARS, -len(left) ) # insert the autocompletion Gtk.TextView.do_insert_at_cursor(self.text_input, suggestion) self.emit("suggestion-inserted") GObject.signal_new( "suggestion-inserted", Autocompletion, GObject.SignalFlags.RUN_FIRST, None, [] ) input-remapper-1.4.0/inputremapper/gui/editor/editor.py000066400000000000000000000542501417303655400233070ustar00rootroot00000000000000#!/usr/bin/python3 # -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings # Copyright (C) 2022 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 . """The editor with multiline code input, recording toggle and autocompletion.""" import re from gi.repository import Gtk, GLib, GtkSource, Gdk from inputremapper.gui.editor.autocompletion import Autocompletion from inputremapper.system_mapping import system_mapping from inputremapper.gui.custom_mapping import custom_mapping from inputremapper.key import Key from inputremapper.logger import logger from inputremapper.gui.reader import reader from inputremapper.gui.utils import CTX_KEYCODE, CTX_WARNING, CTX_ERROR from inputremapper.injection.global_uinputs import global_uinputs class SelectionLabel(Gtk.ListBoxRow): """One label per mapping in the preset. This wrapper serves as a storage for the information the inherited label represents. """ __gtype_name__ = "SelectionLabel" def __init__(self): super().__init__() self.key = None self.symbol = "" label = Gtk.Label() # Make the child label widget break lines, important for # long combinations label.set_line_wrap(True) label.set_line_wrap_mode(2) label.set_justify(Gtk.Justification.CENTER) self.label = label self.add(label) self.show_all() def set_key(self, key): """Set the key this button represents Parameters ---------- key : Key """ self.key = key if key: self.label.set_label(key.beautify()) else: self.label.set_label("new entry") def get_key(self): return self.key def set_label(self, label): return self.label.set_label(label) def get_label(self): return self.label.get_label() def __str__(self): return f"SelectionLabel({str(self.key)})" def __repr__(self): return self.__str__() def ensure_everything_saved(func): """Make sure the editor has written its changes to custom_mapping and save.""" def wrapped(self, *args, **kwargs): if self.user_interface.preset_name: self.gather_changes_and_save() return func(self, *args, **kwargs) return wrapped SET_KEY_FIRST = "Set the key first" class Editor: """Maintains the widgets of the editor.""" def __init__(self, user_interface): self.user_interface = user_interface self.autocompletion = None self._setup_target_selector() self._setup_source_view() self._setup_recording_toggle() self.window = self.get("window") self.timeout = GLib.timeout_add(100, self.check_add_new_key) self.active_selection_label = None selection_label_listbox = self.get("selection_label_listbox") selection_label_listbox.connect("row-selected", self.on_mapping_selected) self.device = user_interface.group # keys were not pressed yet self._input_has_arrived = False toggle = self.get_recording_toggle() toggle.connect("focus-out-event", self._reset_keycode_consumption) toggle.connect("focus-out-event", lambda *_: toggle.set_active(False)) toggle.connect("toggled", self._on_recording_toggle_toggle) # Don't leave the input when using arrow keys or tab. wait for the # window to consume the keycode from the reader. I.e. a tab input should # be recorded, instead of causing the recording to stop. toggle.connect("key-press-event", lambda *args: Gdk.EVENT_STOP) text_input = self.get_text_input() text_input.connect("focus-out-event", self.on_text_input_unfocus) delete_button = self.get_delete_button() delete_button.connect("clicked", self._on_delete_button_clicked) target_selector = self.get_target_selector() target_selector.connect("changed", self._on_target_input_changed) @ensure_everything_saved def on_text_input_unfocus(self, *_): """When unfocusing the text it saves. Input Remapper doesn't save the editor on change, because that would cause an incredible amount of logs for every single input. The custom_mapping would need to be changed, which causes two logs, then it has to be saved to disk which is another two log messages. So every time a single character is typed it writes 4 lines. Instead, it will save the preset when it is really needed, i.e. when a button that requires a saved preset is pressed. For this there exists the @ensure_everything_saved decorator. To avoid maybe forgetting to add this decorator somewhere, it will also save when unfocusing the text input. If the scroll wheel is used to interact with gtk widgets it won't unfocus, so this focus-out handler is not the solution to everything as well. One could debounce saving on text-change to avoid those logs, but that just sounds like a huge source of race conditions and is also hard to test. """ pass @ensure_everything_saved def _on_target_input_changed(self, *_): """save when target changed""" pass def clear(self): """Clear all inputs, labels, etc. Reset the state. This is really important to do before loading a different preset. Otherwise the inputs will be read and then saved into the next preset. """ if self.active_selection_label: self.set_key(None) self.set_symbol_input_text("") self.disable_symbol_input() self.set_target_selection("keyboard") # sane default self.disable_target_selector() self._reset_keycode_consumption() selection_label_listbox = self.get("selection_label_listbox") selection_label_listbox.forall(selection_label_listbox.remove) self.add_empty() selection_label_listbox.select_row(selection_label_listbox.get_children()[0]) def _setup_target_selector(self): """Prepare the target selector combobox""" target_store = Gtk.ListStore(str) for uinput in global_uinputs.devices: target_store.append([uinput]) target_input = self.get_target_selector() target_input.set_model(target_store) renderer_text = Gtk.CellRendererText() target_input.pack_start(renderer_text, False) target_input.add_attribute(renderer_text, "text", 0) target_input.set_id_column(0) def _setup_recording_toggle(self): """Prepare the toggle button for recording key inputs.""" toggle = self.get("key_recording_toggle") toggle.connect( "focus-out-event", self._show_change_key, ) toggle.connect( "focus-in-event", self._show_press_key, ) toggle.connect( "clicked", lambda _: ( self._show_press_key() if toggle.get_active() else self._show_change_key() ), ) def _show_press_key(self, *_): """Show user friendly instructions.""" self.get("key_recording_toggle").set_label("Press Key") def _show_change_key(self, *_): """Show user friendly instructions.""" self.get("key_recording_toggle").set_label("Change Key") def _setup_source_view(self): """Prepare the code editor.""" source_view = self.get("code_editor") # without this the wrapping ScrolledWindow acts weird when new lines are added, # not offering enough space to the text editor so the whole thing is suddenly # scrollable by a few pixels. # Found this after making blind guesses with settings in glade, and then # actually looking at the snaphot preview! In glades editor this didn have an # effect. source_view.set_resize_mode(Gtk.ResizeMode.IMMEDIATE) source_view.get_buffer().connect("changed", self.show_line_numbers_if_multiline) # Syntax Highlighting # Thanks to https://github.com/wolfthefallen/py-GtkSourceCompletion-example # language_manager = GtkSource.LanguageManager() # fun fact: without saving LanguageManager into its own variable it doesn't work # python = language_manager.get_language("python") # source_view.get_buffer().set_language(python) # TODO there are some similarities with python, but overall it's quite useless. # commented out until there is proper highlighting for input-remappers syntax. autocompletion = Autocompletion(source_view, self.get_target_selector()) autocompletion.set_relative_to(self.get("code_editor_container")) autocompletion.connect("suggestion-inserted", self.gather_changes_and_save) self.autocompletion = autocompletion def show_line_numbers_if_multiline(self, *_): """Show line numbers if a macro is being edited.""" code_editor = self.get("code_editor") symbol = self.get_symbol_input_text() or "" if "\n" in symbol: code_editor.set_show_line_numbers(True) code_editor.set_monospace(True) code_editor.get_style_context().add_class("multiline") else: code_editor.set_show_line_numbers(False) code_editor.set_monospace(False) code_editor.get_style_context().remove_class("multiline") def get_delete_button(self): return self.get("delete-mapping") def check_add_new_key(self): """If needed, add a new empty mapping to the list for the user to configure.""" selection_label_listbox = self.get("selection_label_listbox") selection_label_listbox = selection_label_listbox.get_children() for selection_label in selection_label_listbox: if selection_label.get_key() is None: # unfinished row found break else: self.add_empty() return True def disable_symbol_input(self): """Display help information and dont allow entering a symbol. Without this, maybe a user enters a symbol or writes a macro, switches presets accidentally before configuring the key and then it's gone. It can only be saved to the preset if a key is configured. This avoids that pitfall. """ text_input = self.get_text_input() text_input.set_sensitive(False) text_input.set_opacity(0.5) if self.get_symbol_input_text() == "": # don't overwrite user input self.set_symbol_input_text(SET_KEY_FIRST) def enable_symbol_input(self): """Don't display help information anymore and allow changing the symbol.""" text_input = self.get_text_input() text_input.set_sensitive(True) text_input.set_opacity(1) buffer = text_input.get_buffer() symbol = buffer.get_text(buffer.get_start_iter(), buffer.get_end_iter(), True) if symbol == SET_KEY_FIRST: # don't overwrite user input self.set_symbol_input_text("") def disable_target_selector(self): """don't allow any selection""" selector = self.get_target_selector() selector.set_sensitive(False) selector.set_opacity(0.5) def enable_target_selector(self): selector = self.get_target_selector() selector.set_sensitive(True) selector.set_opacity(1) @ensure_everything_saved def on_mapping_selected(self, _=None, selection_label=None): """One of the buttons in the left "key" column was clicked. Load the information from that mapping entry into the editor. """ self.active_selection_label = selection_label if selection_label is None: return key = selection_label.key self.set_key(key) if key is None: self.set_symbol_input_text("") self.disable_symbol_input() # default target should fit in most cases self.set_target_selection("keyboard") # symbol input disabled until a key is configured self.disable_target_selector() # symbol input disabled until a key is configured else: if custom_mapping.get_mapping(key): self.set_symbol_input_text(custom_mapping.get_mapping(key)[0]) self.set_target_selection(custom_mapping.get_mapping(key)[1]) self.enable_symbol_input() self.enable_target_selector() self.get("window").set_focus(self.get_text_input()) def add_empty(self): """Add one empty row for a single mapped key.""" selection_label_listbox = self.get("selection_label_listbox") mapping_selection = SelectionLabel() mapping_selection.set_label("new entry") mapping_selection.show_all() selection_label_listbox.insert(mapping_selection, -1) @ensure_everything_saved def load_custom_mapping(self): """Display the entries in custom_mapping.""" self.set_symbol_input_text("") selection_label_listbox = self.get("selection_label_listbox") selection_label_listbox.forall(selection_label_listbox.remove) for key, _ in custom_mapping: selection_label = SelectionLabel() selection_label.set_key(key) selection_label_listbox.insert(selection_label, -1) self.check_add_new_key() # select the first entry selection_labels = selection_label_listbox.get_children() if len(selection_labels) == 0: self.add_empty() selection_labels = selection_label_listbox.get_children() selection_label_listbox.select_row(selection_labels[0]) def get_recording_toggle(self) -> Gtk.ToggleButton: return self.get("key_recording_toggle") def get_text_input(self): return self.get("code_editor") def get_target_selector(self): return self.get("target-selector") def set_key(self, key): """Show what the user is currently pressing in the user interface.""" self.active_selection_label.set_key(key) def get_key(self): """Get the Key object from the left column. Or None if no code is mapped on this row. """ if self.active_selection_label is None: return None return self.active_selection_label.key def set_symbol_input_text(self, symbol): self.get("code_editor").get_buffer().set_text(symbol or "") # move cursor location to the beginning, like any code editor does Gtk.TextView.do_move_cursor( self.get("code_editor"), Gtk.MovementStep.BUFFER_ENDS, -1, False, ) def get_symbol_input_text(self): """Get the assigned symbol from the text input. This might not be stored in custom_mapping yet, and might therefore also not be part of the preset json file yet. If there is no symbol, this returns None. This is important for some other logic down the road in custom_mapping or something. """ buffer = self.get("code_editor").get_buffer() symbol = buffer.get_text(buffer.get_start_iter(), buffer.get_end_iter(), True) if symbol == SET_KEY_FIRST: # not configured yet return "" return symbol def set_target_selection(self, target): selector = self.get_target_selector() selector.set_active_id(target) def get_target_selection(self): return self.get_target_selector().get_active_id() def get(self, name): """Get a widget from the window""" return self.user_interface.builder.get_object(name) def _on_recording_toggle_toggle(self, *_): """Refresh useful usage information.""" if not self.get_recording_toggle().get_active(): return self._reset_keycode_consumption() reader.clear() if not self.user_interface.can_modify_mapping(): # because the device is in grab mode by the daemon and # therefore the original keycode inaccessible logger.info("Cannot change keycodes while injecting") self.user_interface.show_status( CTX_ERROR, 'Use "Stop Injection" to stop before editing' ) self.get_recording_toggle().set_active(False) def _on_delete_button_clicked(self, *_): """Destroy the row and remove it from the config.""" accept = Gtk.ResponseType.ACCEPT if ( len(self.get_symbol_input_text()) > 0 and self._show_confirm_delete() != accept ): return key = self.get_key() if key is not None: custom_mapping.clear(key) # make sure there is no outdated information lying around in memory self.set_key(None) self.load_custom_mapping() def _show_confirm_delete(self): """Blocks until the user decided about an action.""" confirm_delete = self.get("confirm-delete") text = f"Are you sure to delete this mapping?" self.get("confirm-delete-label").set_text(text) confirm_delete.show() response = confirm_delete.run() confirm_delete.hide() return response def gather_changes_and_save(self, *_): """Look into the ui if new changes should be written, and save the preset.""" # correct case symbol = self.get_symbol_input_text() target = self.get_target_selection() if not symbol or not target: return correct_case = system_mapping.correct_case(symbol) if symbol != correct_case: self.get_text_input().get_buffer().set_text(correct_case) # make sure the custom_mapping is up to date key = self.get_key() if correct_case is not None and key is not None and target is not None: custom_mapping.change(key, target, correct_case) # save to disk if required if custom_mapping.has_unsaved_changes(): self.user_interface.save_preset() def is_waiting_for_input(self): """Check if the user is interacting with the ToggleButton for key recording.""" return self.get_recording_toggle().get_active() def consume_newest_keycode(self, key): """To capture events from keyboards, mice and gamepads. Parameters ---------- key : Key or None """ self._switch_focus_if_complete() if key is None: return if not self.is_waiting_for_input(): return if not isinstance(key, Key): raise TypeError("Expected new_key to be a Key object") # keycode is already set by some other row existing = custom_mapping.get_mapping(key) if existing is not None: existing = list(existing) existing[0] = re.sub(r"\s", "", existing[0]) msg = f'"{key.beautify()}" already mapped to "{tuple(existing)}"' logger.info("%s %s", key, msg) self.user_interface.show_status(CTX_KEYCODE, msg) return True if key.is_problematic(): self.user_interface.show_status( CTX_WARNING, "ctrl, alt and shift may not combine properly", "Your system might reinterpret combinations " + "with those after they are injected, and by doing so " + "break them.", ) # the newest_keycode is populated since the ui regularly polls it # in order to display it in the status bar. previous_key = self.get_key() # it might end up being a key combination, wait for more self._input_has_arrived = True # keycode didn't change, do nothing if key == previous_key: logger.debug("%s didn't change", previous_key) return self.set_key(key) symbol = self.get_symbol_input_text() target = self.get_target_selection() # the symbol is empty and therefore the mapping is not complete if not symbol or not target: return # else, the keycode has changed, the symbol is set, all good custom_mapping.change( new_key=key, target=target, symbol=symbol, previous_key=previous_key ) def _switch_focus_if_complete(self): """If keys are released, it will switch to the text_input. States: 1. not doing anything, waiting for the user to start using it 2. user focuses it, no keys pressed 3. user presses keys 4. user releases keys. no keys are pressed, just like in step 2, but this time the focus needs to switch. """ if not self.is_waiting_for_input(): self._reset_keycode_consumption() return all_keys_released = reader.get_unreleased_keys() is None if all_keys_released and self._input_has_arrived and self.get_key(): # A key was pressed and then released. # Switch to the symbol. idle_add this so that the # keycode event won't write into the symbol input as well. window = self.user_interface.window self.enable_symbol_input() self.enable_target_selector() GLib.idle_add(lambda: window.set_focus(self.get_text_input())) if not all_keys_released: # currently the user is using the widget, and certain keys have already # reached it. self._input_has_arrived = True return self._reset_keycode_consumption() def _reset_keycode_consumption(self, *_): self._input_has_arrived = False input-remapper-1.4.0/inputremapper/gui/helper.py000066400000000000000000000150211417303655400220030ustar00rootroot00000000000000#!/usr/bin/python3 # -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings # Copyright (C) 2022 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 provide a key-logger that can be accessed by any user at all times, whereas for the helper to start a password is needed and it stops when the ui closes. """ import sys import select import multiprocessing import subprocess import evdev from evdev.ecodes import EV_KEY, EV_ABS from inputremapper.ipc.pipe import Pipe from inputremapper.logger import logger from inputremapper.groups import groups from inputremapper import utils from inputremapper.user import USER TERMINATE = "terminate" REFRESH_GROUPS = "refresh_groups" def is_helper_running(): """Check if the helper is running.""" try: subprocess.check_output(["pgrep", "-f", "input-remapper-helper"]) except subprocess.CalledProcessError: return False return True class RootHelper: """Client that runs as root and works for the GUI. Sends device information and keycodes to the GUIs socket. Commands are either numbers for generic commands, or strings to start listening on a specific device. """ def __init__(self): """Construct the helper and initialize its sockets.""" self._results = Pipe(f"/tmp/input-remapper-{USER}/results") self._commands = Pipe(f"/tmp/input-remapper-{USER}/commands") self._send_groups() self.group = None self._pipe = multiprocessing.Pipe() def run(self): """Start doing stuff. Blocks.""" while True: self._handle_commands() self._start_reading() def _send_groups(self): """Send the groups to the gui.""" self._results.send({"type": "groups", "message": groups.dumps()}) def _handle_commands(self): """Handle all unread commands.""" # wait for something to do select.select([self._commands], [], []) while self._commands.poll(): cmd = self._commands.recv() logger.debug('Received command "%s"', cmd) if cmd == TERMINATE: logger.debug("Helper terminates") sys.exit(0) if cmd == REFRESH_GROUPS: groups.refresh() self._send_groups() continue group = groups.find(key=cmd) if group is None: groups.refresh() group = groups.find(key=cmd) if group is not None: self.group = group continue logger.error('Received unknown command "%s"', cmd) def _start_reading(self): """Tell the evdev lib to start looking for keycodes. If read is called without prior start_reading, no keycodes will be available. This blocks forever until it discovers a new command on the socket. """ rlist = {} if self.group is None: logger.error("group is None") return virtual_devices = [] # Watch over each one of the potentially multiple devices per # hardware for path in self.group.paths: try: device = evdev.InputDevice(path) except FileNotFoundError: continue if evdev.ecodes.EV_KEY in device.capabilities(): virtual_devices.append(device) if len(virtual_devices) == 0: logger.debug('No interesting device for "%s"', self.group.key) return for device in virtual_devices: rlist[device.fd] = device logger.debug( 'Starting reading keycodes from "%s"', '", "'.join([device.name for device in virtual_devices]), ) rlist[self._commands] = self._commands while True: ready_fds = select.select(rlist, [], []) if len(ready_fds[0]) == 0: # whatever, happens for sockets sometimes. Maybe the socket # is closed and select has nothing to select from? continue for fd in ready_fds[0]: if rlist[fd] == self._commands: # all commands will cause the reader to start over # (possibly for a different device). # _handle_commands will check what is going on return device = rlist[fd] try: event = device.read_one() if event: self._send_event(event, device) except OSError: logger.debug('Device "%s" disappeared', device.path) return def _send_event(self, event, device): """Write the event into the pipe to the main process. Parameters ---------- event : evdev.InputEvent device : evdev.InputDevice """ # value: 1 for down, 0 for up, 2 for hold. if event.type == EV_KEY and event.value == 2: # ignore hold-down events return blacklisted_keys = [evdev.ecodes.BTN_TOOL_DOUBLETAP] if event.type == EV_KEY and event.code in blacklisted_keys: return if event.type == EV_ABS: abs_range = utils.get_abs_range(device, event.code) event.value = utils.classify_action(event, abs_range) else: event.value = utils.classify_action(event) self._results.send( { "type": "event", "message": (event.sec, event.usec, event.type, event.code, event.value), } ) input-remapper-1.4.0/inputremapper/gui/reader.py000066400000000000000000000212121417303655400217650ustar00rootroot00000000000000#!/usr/bin/python3 # -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings # Copyright (C) 2022 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 GUI helper that has root permissions. see gui.helper.helper """ import evdev from evdev.ecodes import EV_REL from inputremapper.logger import logger from inputremapper.key import Key from inputremapper.groups import groups, GAMEPAD from inputremapper.ipc.pipe import Pipe from inputremapper.gui.helper import TERMINATE, REFRESH_GROUPS from inputremapper import utils from inputremapper.gui.custom_mapping import custom_mapping from inputremapper.user import USER DEBOUNCE_TICKS = 3 def will_report_up(ev_type): """Check if this event will ever report a key up (wheels).""" return ev_type != EV_REL class Reader: """Processes events from the helper for the GUI to use. Does not serve any purpose for the injection service. When a button was pressed, the newest keycode can be obtained from this object. GTK has get_key for keyboard keys, but Reader also has knowledge of buttons like the middle-mouse button. """ def __init__(self): self.previous_event = None self.previous_result = None self._unreleased = {} self._debounce_remove = {} self._groups_updated = False self._cleared_at = 0 self.group = None self._results = None self._commands = None self.connect() def connect(self): """Connect to the helper.""" self._results = Pipe(f"/tmp/input-remapper-{USER}/results") self._commands = Pipe(f"/tmp/input-remapper-{USER}/commands") def are_new_groups_available(self): """Check if groups contains new devices. The ui should then update its list. """ outdated = self._groups_updated self._groups_updated = False # assume the ui will react accordingly return outdated def _get_event(self, message): """Return an InputEvent if the message contains one. None otherwise.""" message_type = message["type"] message_body = message["message"] if message_type == "groups": if message_body != groups.dumps(): groups.loads(message_body) logger.debug("Received %d devices", len(groups)) self._groups_updated = True return None if message_type == "event": return evdev.InputEvent(*message_body) logger.error('Received unknown message "%s"', message) return None def read(self): """Get the newest key/combination as Key object. Only reports keys from down-events. On key-down events the pipe returns changed combinations. Release events won't cause that and the reader will return None as in "nothing new to report". So In order to change a combination, one of its keys has to be released and then a different one pressed. Otherwise making combinations wouldn't be possible. Because at some point the keys have to be released, and that shouldn't cause the combination to get trimmed. """ # this is in some ways similar to the keycode_mapper and # joystick_to_mouse, but its much simpler because it doesn't # have to trigger anything, manage any macros and only # reports key-down events. This function is called periodically # by the window. # remember the previous down-event from the pipe in order to # be able to tell if the reader should return the updated combination previous_event = self.previous_event key_down_received = False self._debounce_tick() while self._results.poll(): message = self._results.recv() event = self._get_event(message) if event is None: continue gamepad = GAMEPAD in self.group.types if not utils.should_map_as_btn(event, custom_mapping, gamepad): continue event_tuple = (event.type, event.code, event.value) type_code = (event.type, event.code) if event.value == 0: logger.debug_key(event_tuple, "release") self._release(type_code) continue if self._unreleased.get(type_code) == event_tuple: logger.debug_key(event_tuple, "duplicate key down") self._debounce_start(event_tuple) continue # to keep track of combinations. # "I have got this release event, what was this for?" A release # event for a D-Pad axis might be any direction, hence this maps # from release to input in order to remember it. Since all release # events have value 0, the value is not used in the key. key_down_received = True logger.debug_key(event_tuple, "down") self._unreleased[type_code] = event_tuple self._debounce_start(event_tuple) previous_event = event if not key_down_received: # This prevents writing a subset of the combination into # result after keys were released. In order to control the gui, # they have to be released. return None self.previous_event = previous_event if len(self._unreleased) > 0: result = Key(*self._unreleased.values()) if result == self.previous_result: # don't return the same stuff twice return None self.previous_result = result logger.debug_key(result.keys, "read result") return result return None def start_reading(self, group): """Start reading keycodes for a device.""" logger.debug('Sending start msg to helper for "%s"', group.key) self._commands.send(group.key) self.group = group self.clear() def terminate(self): """Stop reading keycodes for good.""" logger.debug("Sending close msg to helper") self._commands.send(TERMINATE) def refresh_groups(self): """Ask the helper for new device groups.""" self._commands.send(REFRESH_GROUPS) def clear(self): """Next time when reading don't return the previous keycode.""" logger.debug("Clearing reader") while self._results.poll(): # clear the results pipe and handle any non-event messages, # otherwise a 'groups' message might get lost message = self._results.recv() self._get_event(message) self._unreleased = {} self.previous_event = None self.previous_result = None def get_unreleased_keys(self): """Get a Key object of the current keyboard state.""" unreleased = list(self._unreleased.values()) if len(unreleased) == 0: return None return Key(*unreleased) def _release(self, type_code): """Modify the state to recognize the releasing of the key.""" if type_code in self._unreleased: del self._unreleased[type_code] if type_code in self._debounce_remove: del self._debounce_remove[type_code] def _debounce_start(self, event_tuple): """Act like the key was released if no new event arrives in time.""" if not will_report_up(event_tuple[0]): self._debounce_remove[event_tuple[:2]] = DEBOUNCE_TICKS def _debounce_tick(self): """If the counter reaches 0, the key is not considered held down.""" for type_code in list(self._debounce_remove.keys()): if type_code not in self._unreleased: continue # clear wheel events from unreleased after some time if self._debounce_remove[type_code] == 0: logger.debug_key(self._unreleased[type_code], "Considered as released") self._release(type_code) else: self._debounce_remove[type_code] -= 1 reader = Reader() input-remapper-1.4.0/inputremapper/gui/user_interface.py000066400000000000000000000643301417303655400235310ustar00rootroot00000000000000#!/usr/bin/python3 # -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings # Copyright (C) 2022 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.""" import math import os import re import sys from evdev._ecodes import EV_KEY from gi.repository import Gtk, GtkSource, Gdk, GLib, GObject from inputremapper.data import get_data_path from inputremapper.paths import get_config_path from inputremapper.system_mapping import system_mapping from inputremapper.gui.custom_mapping import custom_mapping from inputremapper.gui.utils import HandlerDisabled from inputremapper.presets import ( find_newest_preset, get_presets, delete_preset, rename_preset, get_available_preset_name, ) from inputremapper.logger import logger, COMMIT_HASH, VERSION, EVDEV_VERSION, is_debug from inputremapper.groups import ( groups, GAMEPAD, KEYBOARD, UNKNOWN, GRAPHICS_TABLET, TOUCHPAD, MOUSE, ) from inputremapper.gui.editor.editor import Editor from inputremapper.key import Key from inputremapper.gui.reader import reader from inputremapper.gui.helper import is_helper_running from inputremapper.injection.injector import RUNNING, FAILED, NO_GRAB from inputremapper.daemon import Daemon from inputremapper.config import config from inputremapper.injection.macros.parse import is_this_a_macro, parse from inputremapper.injection.global_uinputs import global_uinputs from inputremapper.gui.utils import ( CTX_ERROR, CTX_MAPPING, CTX_APPLY, CTX_WARNING, gtk_iteration, ) # TODO add to .deb and AUR dependencies # 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 CONTINUE = True GO_BACK = False ICON_NAMES = { GAMEPAD: "input-gaming", MOUSE: "input-mouse", KEYBOARD: "input-keyboard", GRAPHICS_TABLET: "input-tablet", TOUCHPAD: "input-touchpad", UNKNOWN: None, } # sort types that most devices would fall in easily to the right. ICON_PRIORITIES = [GRAPHICS_TABLET, TOUCHPAD, GAMEPAD, MOUSE, KEYBOARD, UNKNOWN] def if_group_selected(func): """Decorate a function to only execute if a device is selected.""" # this should only happen if no device was found at all def wrapped(self, *args, **kwargs): if self.group is None: return True # work with timeout_add return func(self, *args, **kwargs) return wrapped def if_preset_selected(func): """Decorate a function to only execute if a preset is selected.""" # this should only happen if no device was found at all def wrapped(self, *args, **kwargs): if self.preset_name is None or self.group is None: return True # work with timeout_add return func(self, *args, **kwargs) return wrapped def on_close_about(about, _): """Hide the about dialog without destroying it.""" about.hide() return True def ensure_everything_saved(func): """Make sure the editor has written its changes to custom_mapping and save.""" def wrapped(self, *args, **kwargs): if self.preset_name: self.editor.gather_changes_and_save() return func(self, *args, **kwargs) return wrapped class UserInterface: """The key mapper gtk window.""" def __init__(self): self.dbus = None self.start_processes() self.group = None self.preset_name = None global_uinputs.prepare() 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") builder = Gtk.Builder() builder.add_from_file(gladefile) builder.connect_signals(self) self.builder = builder self.editor = Editor(self) # set up the device selection # https://python-gtk-3-tutorial.readthedocs.io/en/latest/treeview.html#the-view combobox = self.get("device_selection") self.device_store = Gtk.ListStore(str, str, str) combobox.set_model(self.device_store) renderer_icon = Gtk.CellRendererPixbuf() renderer_text = Gtk.CellRendererText() renderer_text.set_padding(5, 0) combobox.pack_start(renderer_icon, False) combobox.pack_start(renderer_text, False) combobox.add_attribute(renderer_icon, "icon-name", 1) combobox.add_attribute(renderer_text, "text", 2) combobox.set_id_column(0) self.confirm_delete = builder.get_object("confirm-delete") self.about = builder.get_object("about-dialog") 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 "" ) window = self.get("window") window.show() # hide everything until stuff is populated self.get("vertical-wrapper").set_opacity(0) self.window = window # 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() # this is not set to invisible in glade to give the ui a default # height that doesn't jump when a gamepad is selected self.get("gamepad_separator").hide() self.get("gamepad_config").hide() self.populate_devices() self.timeouts = [] self.setup_timeouts() # now show the proper finished content of the window self.get("vertical-wrapper").set_opacity(1) self.ctrl = False self.unreleased_warn = False self.button_left_warn = False if not is_helper_running(): self.show_status(CTX_ERROR, "The helper did not start") def setup_timeouts(self): """Setup all GLib timeouts.""" self.timeouts = [ GLib.timeout_add(1000 / 30, self.consume_newest_keycode), ] def start_processes(self): """Start helper and daemon via pkexec to run in the background.""" # this function is overwritten in tests self.dbus = Daemon.connect() debug = " -d" if is_debug() else "" cmd = f"pkexec input-remapper-control --command helper {debug}" logger.debug("Running `%s`", cmd) exit_code = os.system(cmd) if exit_code != 0: logger.error("Failed to pkexec the helper, code %d", exit_code) sys.exit() def show_confirm_delete(self): """Blocks until the user decided about an action.""" text = f'Are you sure to delete preset "{self.preset_name}"?' self.get("confirm-delete-label").set_text(text) self.confirm_delete.show() response = self.confirm_delete.run() self.confirm_delete.hide() return response def on_key_press(self, _, event): """To execute shortcuts. This has nothing to do with the keycode reader. """ if self.editor.is_waiting_for_input(): # don't perform shortcuts while keys are being recorded return gdk_keycode = event.get_keyval()[1] if gdk_keycode in [Gdk.KEY_Control_L, Gdk.KEY_Control_R]: self.ctrl = True if self.ctrl: # shortcuts if gdk_keycode == Gdk.KEY_q: self.on_close() if gdk_keycode == Gdk.KEY_r: reader.refresh_groups() if gdk_keycode == Gdk.KEY_Delete: self.on_restore_defaults_clicked() def on_key_release(self, _, event): """To execute shortcuts. This has nothing to do with the keycode reader. """ gdk_keycode = event.get_keyval()[1] if gdk_keycode in [Gdk.KEY_Control_L, Gdk.KEY_Control_R]: self.ctrl = False def initialize_gamepad_config(self): """Set slider and dropdown values when a gamepad is selected.""" if GAMEPAD in self.group.types: self.get("gamepad_separator").show() self.get("gamepad_config").show() else: self.get("gamepad_separator").hide() self.get("gamepad_config").hide() return left_purpose = self.get("left_joystick_purpose") right_purpose = self.get("right_joystick_purpose") speed = self.get("joystick_mouse_speed") with HandlerDisabled(left_purpose, self.on_left_joystick_changed): value = custom_mapping.get("gamepad.joystick.left_purpose") left_purpose.set_active_id(value) with HandlerDisabled(right_purpose, self.on_right_joystick_changed): value = custom_mapping.get("gamepad.joystick.right_purpose") right_purpose.set_active_id(value) with HandlerDisabled(speed, self.on_joystick_mouse_speed_changed): value = custom_mapping.get("gamepad.joystick.pointer_speed") range_value = math.log(value, 2) speed.set_value(range_value) def get(self, name): """Get a widget from the window""" return self.builder.get_object(name) @ensure_everything_saved def on_close(self, *_): """Safely close the application.""" logger.debug("Closing window") self.window.hide() for timeout in self.timeouts: GLib.source_remove(timeout) self.timeouts = [] reader.terminate() Gtk.main_quit() @ensure_everything_saved def select_newest_preset(self): """Find and select the newest preset (and its device).""" group_name, preset = find_newest_preset() if group_name is not None: self.get("device_selection").set_active_id(group_name) if preset is not None: self.get("preset_selection").set_active_id(preset) @ensure_everything_saved def populate_devices(self): """Make the devices selectable.""" device_selection = self.get("device_selection") with HandlerDisabled(device_selection, self.on_select_device): self.device_store.clear() for group in groups.filter(include_inputremapper=False): types = group.types if len(types) > 0: device_type = sorted(types, key=ICON_PRIORITIES.index)[0] icon_name = ICON_NAMES[device_type] else: icon_name = None self.device_store.append([group.key, icon_name, group.key]) self.select_newest_preset() @if_group_selected @ensure_everything_saved def populate_presets(self): """Show the available presets for the selected device. This will destroy unsaved changes in the custom_mapping. """ presets = get_presets(self.group.name) if len(presets) == 0: new_preset = get_available_preset_name(self.group.name) custom_mapping.empty() path = self.group.get_preset_path(new_preset) custom_mapping.save(path) presets = [new_preset] else: logger.debug('"%s" presets: "%s"', self.group.name, '", "'.join(presets)) preset_selection = self.get("preset_selection") with HandlerDisabled(preset_selection, self.on_select_preset): # otherwise the handler is called with None for each preset preset_selection.remove_all() for preset in presets: preset_selection.append(preset, preset) # and select the newest one (on the top). triggers on_select_preset preset_selection.set_active(0) def can_modify_mapping(self, *_) -> bool: """if changing the mapping is possible.""" return self.dbus.get_state(self.group.key) != RUNNING def consume_newest_keycode(self): """To capture events from keyboards, mice and gamepads.""" # the "event" event of Gtk.Window wouldn't trigger on gamepad # events, so it became a GLib timeout to periodically check kernel # events. # letting go of one of the keys of a combination won't just make # it return the leftover key, it will continue to return None because # they have already been read. key = reader.read() if reader.are_new_groups_available(): self.populate_devices() self.editor.consume_newest_keycode(key) return True @if_group_selected def on_restore_defaults_clicked(self, *_): """Stop injecting the mapping.""" self.dbus.stop_injecting(self.group.key) self.show_status(CTX_APPLY, "Applied the system default") GLib.timeout_add(100, self.show_device_mapping_status) def show_status(self, context_id, message, tooltip=None): """Show a status message and set its tooltip. If message is None, it will remove the newest message of the given context_id. """ status_bar = self.get("status_bar") if message is None: status_bar.remove_all(context_id) if context_id in (CTX_ERROR, CTX_MAPPING): self.get("error_status_icon").hide() if context_id == CTX_WARNING: self.get("warning_status_icon").hide() status_bar.set_tooltip_text("") else: if tooltip is None: tooltip = message self.get("error_status_icon").hide() self.get("warning_status_icon").hide() if context_id in (CTX_ERROR, CTX_MAPPING): self.get("error_status_icon").show() if context_id == CTX_WARNING: self.get("warning_status_icon").show() max_length = 45 if len(message) > max_length: message = message[: max_length - 3] + "..." status_bar.push(context_id, message) status_bar.set_tooltip_text(tooltip) def check_macro_syntax(self): """Check if the programmed macros are allright.""" self.show_status(CTX_MAPPING, None) for key, output in custom_mapping: output = output[0] if not is_this_a_macro(output): continue error = parse(output, custom_mapping, return_errors=True) if error is None: continue position = key.beautify() msg = f"Syntax error at {position}, hover for info" self.show_status(CTX_MAPPING, msg, error) @ensure_everything_saved def on_rename_button_clicked(self, _): """Rename the preset based on the contents of the name input.""" new_name = self.get("preset_name_input").get_text() if new_name in ["", self.preset_name]: return new_name = rename_preset(self.group.name, self.preset_name, new_name) # if the old preset was being autoloaded, change the # name there as well is_autoloaded = config.is_autoloaded(self.group.key, self.preset_name) if is_autoloaded: config.set_autoload_preset(self.group.key, new_name) self.get("preset_name_input").set_text("") self.populate_presets() @if_preset_selected def on_delete_preset_clicked(self, *_): """Delete a preset from the file system.""" accept = Gtk.ResponseType.ACCEPT if len(custom_mapping) > 0 and self.show_confirm_delete() != accept: return # avoid having the text of the symbol input leak into the custom_mapping again # via a gazillion hooks, causing the preset to be saved again after deleting. self.editor.clear() delete_preset(self.group.name, self.preset_name) self.populate_presets() @if_preset_selected def on_apply_preset_clicked(self, _): """Apply a preset without saving changes.""" self.save_preset() if custom_mapping.num_saved_keys == 0: logger.error("Cannot apply empty preset file") # also helpful for first time use self.show_status(CTX_ERROR, "You need to add keys and save first") return preset = self.preset_name logger.info('Applying preset "%s" for "%s"', preset, self.group.key) if not self.button_left_warn: if custom_mapping.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 if not self.unreleased_warn: unreleased = reader.get_unreleased_keys() if unreleased is not None and unreleased != Key.btn_left(): # it's super annoying if that happens and may break the user # input in such a way to prevent disabling the mapping logger.error( "Tried to apply a preset while keys were held down: %s", unreleased ) self.show_status( CTX_ERROR, "Please release your pressed keys first", "X11 will think they are held down forever otherwise.\n" "To overwrite this warning, press apply again.", ) self.unreleased_warn = True return self.unreleased_warn = False self.button_left_warn = False self.dbus.set_config_dir(get_config_path()) self.dbus.start_injecting(self.group.key, preset) self.show_status(CTX_APPLY, "Starting injection...") GLib.timeout_add(100, self.show_injection_result) def on_autoload_switch(self, _, active): """Load the preset automatically next time the user logs in.""" key = self.group.key preset = self.preset_name config.set_autoload_preset(key, preset if active else None) # tell the service to refresh its config self.dbus.set_config_dir(get_config_path()) @ensure_everything_saved def on_select_device(self, dropdown): """List all presets, create one if none exist yet.""" if self.group and dropdown.get_active_id() == self.group.key: return group_key = dropdown.get_active_id() if group_key is None: return self.editor.clear() logger.debug('Selecting device "%s"', group_key) self.group = groups.find(key=group_key) self.preset_name = None self.populate_presets() reader.start_reading(groups.find(key=group_key)) self.show_device_mapping_status() def show_injection_result(self): """Show if the injection was successfully started.""" state = self.dbus.get_state(self.group.key) if state == RUNNING: msg = f'Applied preset "{self.preset_name}"' if custom_mapping.get_mapping(Key.btn_left()): msg += ", CTRL + DEL to stop" self.show_status(CTX_APPLY, msg) self.show_device_mapping_status() return False if state == FAILED: self.show_status(CTX_ERROR, f'Failed to apply preset "{self.preset_name}"') return False if state == NO_GRAB: self.show_status( CTX_ERROR, "The device was not grabbed", "Either another application is already grabbing it or " "your preset doesn't contain anything that is sent by the " "device.", ) return False # keep the timeout running return True def show_device_mapping_status(self): """Figure out if this device is currently under inputremappers control.""" group_key = self.group.key state = self.dbus.get_state(group_key) if state == RUNNING: logger.info('Group "%s" is currently mapped', group_key) self.get("apply_system_layout").set_opacity(1) else: self.get("apply_system_layout").set_opacity(0.4) @if_preset_selected def on_copy_preset_clicked(self, *_): """Copy the current preset and select it.""" self.create_preset(copy=True) @if_group_selected def on_create_preset_clicked(self, *_): """Create a new empty preset and select it.""" self.create_preset() @ensure_everything_saved def create_preset(self, copy=False): """Create a new preset and select it.""" name = self.group.name preset = self.preset_name try: if copy: new_preset = get_available_preset_name(name, preset, copy) else: new_preset = get_available_preset_name(name) self.editor.clear() custom_mapping.empty() path = self.group.get_preset_path(new_preset) custom_mapping.save(path) self.get("preset_selection").append(new_preset, new_preset) # triggers on_select_preset self.get("preset_selection").set_active_id(new_preset) if self.get("preset_selection").get_active_id() != new_preset: # for whatever reason I have to use set_active_id twice for this # to work in tests all of the sudden self.get("preset_selection").set_active_id(new_preset) except PermissionError as error: error = str(error) self.show_status(CTX_ERROR, "Permission denied!", error) logger.error(error) @ensure_everything_saved def on_select_preset(self, dropdown): """Show the mappings of the preset.""" # beware in tests that this function won't be called at all if the # active_id stays the same if dropdown.get_active_id() == self.preset_name: return preset = dropdown.get_active_text() if preset is None: return logger.debug('Selecting preset "%s"', preset) self.editor.clear() self.preset_name = preset custom_mapping.load(self.group.get_preset_path(preset)) self.editor.load_custom_mapping() autoload_switch = self.get("preset_autoload_switch") with HandlerDisabled(autoload_switch, self.on_autoload_switch): is_autoloaded = config.is_autoloaded(self.group.key, self.preset_name) autoload_switch.set_active(is_autoloaded) self.get("preset_name_input").set_text("") self.initialize_gamepad_config() custom_mapping.set_has_unsaved_changes(False) def on_left_joystick_changed(self, dropdown): """Set the purpose of the left joystick.""" purpose = dropdown.get_active_id() custom_mapping.set("gamepad.joystick.left_purpose", purpose) self.save_preset() def on_right_joystick_changed(self, dropdown): """Set the purpose of the right joystick.""" purpose = dropdown.get_active_id() custom_mapping.set("gamepad.joystick.right_purpose", purpose) self.save_preset() def on_joystick_mouse_speed_changed(self, gtk_range): """Set how fast the joystick moves the mouse.""" speed = 2 ** gtk_range.get_value() custom_mapping.set("gamepad.joystick.pointer_speed", speed) def save_preset(self, *_): """Write changes in the custom_mapping to disk.""" if not custom_mapping.has_unsaved_changes(): # optimization, and also avoids tons of redundant logs logger.debug("Not saving because mapping did not change") return try: assert self.preset_name is not None path = self.group.get_preset_path(self.preset_name) custom_mapping.save(path) # after saving the config, its modification date will be the # newest, so populate_presets will automatically select the # right one again. self.populate_presets() except PermissionError as error: error = str(error) self.show_status(CTX_ERROR, "Permission denied!", error) logger.error(error) for _, mapping in custom_mapping: if not mapping: continue symbol = mapping[0] target = mapping[1] if is_this_a_macro(symbol): continue code = system_mapping.get(symbol) if ( code is None or code not in global_uinputs.get_uinput(target).capabilities()[EV_KEY] ): trimmed = re.sub(r"\s+", " ", symbol).strip() self.show_status(CTX_MAPPING, f'Unknown mapping "{trimmed}"') break else: # no broken mappings found self.show_status(CTX_MAPPING, None) # checking macros is probably a bit more expensive, do that if # the regular mappings are allright self.check_macro_syntax() def on_about_clicked(self, _): """Show the about/help dialog.""" self.about.show() def on_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() input-remapper-1.4.0/inputremapper/gui/utils.py000066400000000000000000000027471417303655400216770ustar00rootroot00000000000000#!/usr/bin/python3 # -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings # Copyright (C) 2022 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 gi.repository import Gtk # status ctx ids CTX_SAVE = 0 CTX_APPLY = 1 CTX_KEYCODE = 2 CTX_ERROR = 3 CTX_WARNING = 4 CTX_MAPPING = 5 class HandlerDisabled: """Safely modify a widget without causing handlers to be called. Use in a with statement. """ def __init__(self, widget, handler): self.widget = widget self.handler = handler def __enter__(self): self.widget.handler_block_by_func(self.handler) def __exit__(self, *_): self.widget.handler_unblock_by_func(self.handler) def gtk_iteration(): """Iterate while events are pending.""" while Gtk.events_pending(): Gtk.main_iteration() input-remapper-1.4.0/inputremapper/injection/000077500000000000000000000000001417303655400213515ustar00rootroot00000000000000input-remapper-1.4.0/inputremapper/injection/__init__.py000066400000000000000000000021341417303655400234620ustar00rootroot00000000000000#!/usr/bin/python3 # -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings # Copyright (C) 2022 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 . """The injection process. This folder contains all classes that are only relevant for the injection process. There is one process for each hardware device that is being injected for, and one context object per process that is being passed around for all classes to use. """ input-remapper-1.4.0/inputremapper/injection/consumer_control.py000066400000000000000000000103261417303655400253200ustar00rootroot00000000000000#!/usr/bin/python3 # -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings # Copyright (C) 2022 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 evdev from inputremapper.injection.consumers.joystick_to_mouse import JoystickToMouse from inputremapper.injection.consumers.keycode_mapper import KeycodeMapper from inputremapper.logger import logger consumer_classes = [ KeycodeMapper, JoystickToMouse, ] class ConsumerControl: """Reads input events from a single device and distributes them. There is one ConsumerControl object for each source, which tells multiple consumers that a new event is ready so that they can inject all sorts of funny things. Other devnodes may be present for the hardware device, in which case this needs to be created multiple times. """ def __init__(self, context, source, forward_to): """Initialize all consumers Parameters ---------- source : evdev.InputDevice where to read keycodes from forward_to : evdev.UInput where to write keycodes to that were not mapped to anything. Should be an UInput with capabilities that work for all forwarded events, so ideally they should be copied from source. """ self._source = source self._forward_to = forward_to # add all consumers that are enabled for this particular configuration self._consumers = [] for Consumer in consumer_classes: consumer = Consumer(context, source, forward_to) if consumer.is_enabled(): self._consumers.append(consumer) async def run(self): """Start doing things. Can be stopped by stopping the asyncio loop. This loop reads events from a single device only. """ for consumer in self._consumers: # run all of them in parallel asyncio.ensure_future(consumer.run()) logger.debug( "Starting to listen for events from %s, fd %s", self._source.path, self._source.fd, ) async for event in self._source.async_read_loop(): if event.type == evdev.ecodes.EV_KEY and event.value == 2: # button-hold event. Environments (gnome, etc.) create them on # their own for the injection-fake-device if the release event # won't appear, no need to forward or map them. continue handled = False for consumer in self._consumers: # copy so that the consumer doesn't screw this up for # all other future consumers event_copy = evdev.InputEvent( sec=event.sec, usec=event.usec, type=event.type, code=event.code, value=event.value, ) if consumer.is_handled(event_copy): await consumer.notify(event_copy) handled = True if not handled: # forward the rest self._forward_to.write(event.type, event.code, event.value) # this already includes SYN events, so need to syn here again # This happens all the time in tests because the async_read_loop stops when # there is nothing to read anymore. Otherwise tests would block. logger.error('The async_read_loop for "%s" stopped early', self._source.path) input-remapper-1.4.0/inputremapper/injection/consumers/000077500000000000000000000000001417303655400233675ustar00rootroot00000000000000input-remapper-1.4.0/inputremapper/injection/consumers/__init__.py000066400000000000000000000016411417303655400255020ustar00rootroot00000000000000#!/usr/bin/python3 # -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings # Copyright (C) 2022 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 . """Consumers Each consumer can listen for events and then inject something mapped. """ input-remapper-1.4.0/inputremapper/injection/consumers/consumer.py000066400000000000000000000055101417303655400255750ustar00rootroot00000000000000#!/usr/bin/python3 # -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings # Copyright (C) 2022 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 . """Consumer base class. Can be notified of new events so that inheriting classes can map them and inject new events based on them. """ class Consumer: """Can be notified of new events to inject them. Base class.""" def __init__(self, context, source, forward_to=None): """Initialize event consuming functionality. Parameters ---------- context : Context The configuration of the Injector process source : InputDevice Where events used in handle_keycode come from forward_to : evdev.UInput Where to write keycodes to that were not mapped to anything. Should be an UInput with capabilities that work for all forwarded events, so ideally they should be copied from source. """ self.context = context self.forward_to = forward_to self.source = source self.context.update_purposes() def is_enabled(self): """Check if the consumer will have work to do.""" raise NotImplementedError def forward(self, key): """Shorthand to forward an event.""" self.forward_to.write(*key) async def notify(self, event): """A new event is ready. Overwrite this function if the consumer should do something each time a new event arrives. E.g. mapping a single button once clicked. """ raise NotImplementedError def is_handled(self, event): """Check if the consumer will take care of this event. If this returns true, the event will not be forwarded anymore automatically. If you want to forward the event after all you can inject it into `self.forward_to`. """ raise NotImplementedError async def run(self): """Start doing things. Overwrite this function if the consumer should do something continuously even if no new event arrives. e.g. continuously injecting mouse movement events. """ raise NotImplementedError input-remapper-1.4.0/inputremapper/injection/consumers/joystick_to_mouse.py000066400000000000000000000233431417303655400275170ustar00rootroot00000000000000#!/usr/bin/python3 # -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings # Copyright (C) 2022 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 mapping joystick to mouse movements.""" import asyncio import time from evdev.ecodes import ( EV_REL, REL_X, REL_Y, REL_WHEEL, REL_HWHEEL, EV_ABS, ABS_X, ABS_Y, ABS_RX, ABS_RY, ) from inputremapper.logger import logger from inputremapper.config import MOUSE, WHEEL from inputremapper import utils from inputremapper.injection.consumers.consumer import Consumer from inputremapper.groups import classify, GAMEPAD from inputremapper.injection.global_uinputs import global_uinputs # miniscule movements on the joystick should not trigger a mouse wheel event WHEEL_THRESHOLD = 0.15 def abs_max(value_1, value_2): """Get the value with the higher abs value.""" if abs(value_1) > abs(value_2): return value_1 return value_2 class JoystickToMouse(Consumer): """Keeps producing events at 60hz if needed. Maps joysticks to mouse movements. This class does not handle injecting macro stuff over time, that is done by the keycode_mapper. """ def __init__(self, *args, **kwargs): """Construct the event producer without it doing anything yet.""" super().__init__(*args, **kwargs) self._abs_range = None self._set_abs_range_from(self.source) # events only take ints, so a movement of 0.3 needs to add # up to 1.2 to affect the cursor, with 0.2 remaining self.pending_rel = {REL_X: 0, REL_Y: 0, REL_WHEEL: 0, REL_HWHEEL: 0} # the last known position of the joystick self.abs_state = {ABS_X: 0, ABS_Y: 0, ABS_RX: 0, ABS_RY: 0} def is_enabled(self): gamepad = classify(self.source) == GAMEPAD return gamepad and self.context.joystick_as_mouse() def _write(self, ev_type, keycode, value): """Inject.""" # if the mouse won't move even though correct stuff is written here, # the capabilities are probably wrong try: global_uinputs.write((ev_type, keycode, value), "mouse") except OverflowError: # screwed up the calculation of mouse movements logger.error("OverflowError (%s, %s, %s)", ev_type, keycode, value) def accumulate(self, code, input_value): """Since devices can't do float values, stuff has to be accumulated. If pending is 0.6 and input_value is 0.5, return 0.1 and 1. Because it should move 1px, and 0.1px is rememberd for the next value in pending. """ self.pending_rel[code] += input_value output_value = int(self.pending_rel[code]) self.pending_rel[code] -= output_value return output_value def _set_abs_range_from(self, device): """Update the min and max values joysticks will report. This information is needed for abs -> rel mapping. """ if device is None: # I don't think this ever happened logger.error("Expected device to not be None") return abs_range = utils.get_abs_range(device) if abs_range is None: return if abs_range[1] in [0, 1, None]: # max abs_range of joysticks is usually a much higher number return self.set_abs_range(*abs_range) logger.debug('ABS range of "%s": %s', device.name, abs_range) def set_abs_range(self, min_abs, max_abs): """Update the min and max values joysticks will report. This information is needed for abs -> rel mapping. """ self._abs_range = (min_abs, max_abs) # all joysticks in resting position by default center = (self._abs_range[1] + self._abs_range[0]) / 2 self.abs_state = {ABS_X: center, ABS_Y: center, ABS_RX: center, ABS_RY: center} def get_abs_values(self): """Get the raw values for wheel and mouse movement. Returned values center around 0 and are normalized into -1 and 1. If two joysticks have the same purpose, the one that reports higher absolute values takes over the control. """ # center is the value of the resting position center = (self._abs_range[1] + self._abs_range[0]) / 2 # normalizer is the maximum possible value after centering normalizer = (self._abs_range[1] - self._abs_range[0]) / 2 mouse_x = 0 mouse_y = 0 wheel_x = 0 wheel_y = 0 def standardize(value): return (value - center) / normalizer if self.context.left_purpose == MOUSE: mouse_x = abs_max(mouse_x, standardize(self.abs_state[ABS_X])) mouse_y = abs_max(mouse_y, standardize(self.abs_state[ABS_Y])) if self.context.left_purpose == WHEEL: wheel_x = abs_max(wheel_x, standardize(self.abs_state[ABS_X])) wheel_y = abs_max(wheel_y, standardize(self.abs_state[ABS_Y])) if self.context.right_purpose == MOUSE: mouse_x = abs_max(mouse_x, standardize(self.abs_state[ABS_RX])) mouse_y = abs_max(mouse_y, standardize(self.abs_state[ABS_RY])) if self.context.right_purpose == WHEEL: wheel_x = abs_max(wheel_x, standardize(self.abs_state[ABS_RX])) wheel_y = abs_max(wheel_y, standardize(self.abs_state[ABS_RY])) # Some joysticks report from 0 to 255 (EMV101), # others from -32768 to 32767 (X-Box 360 Pad) return mouse_x, mouse_y, wheel_x, wheel_y def is_handled(self, event): """Check if the event is something this will take care of.""" if event.type != EV_ABS or event.code not in utils.JOYSTICK: return False if self._abs_range is None: return False purposes = [MOUSE, WHEEL] left_purpose = self.context.left_purpose right_purpose = self.context.right_purpose if event.code in (ABS_X, ABS_Y) and left_purpose in purposes: return True if event.code in (ABS_RX, ABS_RY) and right_purpose in purposes: return True return False async def notify(self, event): if event.type == EV_ABS and event.code in self.abs_state: self.abs_state[event.code] = event.value async def run(self): """Keep writing mouse movements based on the gamepad stick position. Even if no new input event arrived because the joystick remained at its position, this will keep injecting the mouse movement events. """ abs_range = self._abs_range mapping = self.context.mapping pointer_speed = mapping.get("gamepad.joystick.pointer_speed") non_linearity = mapping.get("gamepad.joystick.non_linearity") x_scroll_speed = mapping.get("gamepad.joystick.x_scroll_speed") y_scroll_speed = mapping.get("gamepad.joystick.y_scroll_speed") max_speed = 2 ** 0.5 # for normalized abs event values if abs_range is not None: logger.info( "Left joystick as %s, right joystick as %s", self.context.left_purpose, self.context.right_purpose, ) start = time.time() while True: # try to do this as close to 60hz as possible time_taken = time.time() - start await asyncio.sleep(max(0.0, (1 / 60) - time_taken)) start = time.time() if abs_range is None: # no ev_abs events will be mapped to ev_rel continue abs_values = self.get_abs_values() if len([val for val in abs_values if not -1 <= val <= 1]) > 0: logger.error("Inconsistent values: %s", abs_values) continue mouse_x, mouse_y, wheel_x, wheel_y = abs_values # mouse movements if abs(mouse_x) > 0 or abs(mouse_y) > 0: if non_linearity != 1: # to make small movements smaller for more precision speed = (mouse_x ** 2 + mouse_y ** 2) ** 0.5 # pythagoras factor = (speed / max_speed) ** non_linearity else: factor = 1 rel_x = mouse_x * factor * pointer_speed rel_y = mouse_y * factor * pointer_speed rel_x = self.accumulate(REL_X, rel_x) rel_y = self.accumulate(REL_Y, rel_y) if rel_x != 0: self._write(EV_REL, REL_X, rel_x) if rel_y != 0: self._write(EV_REL, REL_Y, rel_y) # wheel movements if abs(wheel_x) > 0: change = wheel_x * x_scroll_speed value = self.accumulate(REL_WHEEL, change) if abs(change) > WHEEL_THRESHOLD * x_scroll_speed: self._write(EV_REL, REL_HWHEEL, value) if abs(wheel_y) > 0: change = wheel_y * y_scroll_speed value = self.accumulate(REL_HWHEEL, change) if abs(change) > WHEEL_THRESHOLD * y_scroll_speed: self._write(EV_REL, REL_WHEEL, -value) input-remapper-1.4.0/inputremapper/injection/consumers/keycode_mapper.py000066400000000000000000000521571417303655400267420ustar00rootroot00000000000000#!/usr/bin/python3 # -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings # Copyright (C) 2022 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 . """Inject a keycode based on the mapping.""" import itertools import asyncio import time import evdev from evdev.ecodes import EV_KEY, EV_ABS import inputremapper.exceptions from inputremapper.logger import logger from inputremapper.system_mapping import DISABLE_CODE from inputremapper import utils from inputremapper.injection.consumers.consumer import Consumer from inputremapper.utils import RELEASE from inputremapper.groups import classify, GAMEPAD from inputremapper.injection.global_uinputs import global_uinputs # this state is shared by all KeycodeMappers of this process # maps mouse buttons to macro instances that have been executed. # They may still be running or already be done. Just like unreleased, # this is a mapping of (type, code). The value is not included in the # key, because a key release event with a value of 0 needs to be able # to find the running macro. The downside is that a d-pad cannot # execute two macros at once, one for each direction. # Only sequentially. active_macros = {} # mapping of future release event (type, code) to an Unreleased object, # All key-up events have a value of 0, so it is not added to # the tuple. This is needed in order to release the correct event # mapped on a D-Pad. Each direction on one D-Pad axis reports the # same type and code, but different values. There cannot be both at # the same time, as pressing one side of a D-Pad forces the other # side to go up. If both sides of a D-Pad are mapped to different # event-codes, this data structure helps to figure out which of those # two to release on an event of value 0. Same goes for the Wheel. # The input event is remembered to make sure no duplicate down-events # are written. Since wheels report a lot of "down" events that don't # serve any purpose when mapped to a key, those duplicate down events # should be removed. If the same type and code arrives but with a # different value (direction), there must be a way to check if the # event is actually a duplicate and not a different event. unreleased = {} COMBINATION_INCOMPLETE = 1 # not all keys of the combination are pressed NOT_COMBINED = 2 # this key is not part of a combination def subsets(combination): """Return a list of subsets of the combination. If combination is only one element long it returns an empty list, because it's not a combination and there is no reason to iterate. Includes the complete input as well. Parameters ----------- combination : tuple tuple of 3-tuples, each being int, int, int (type, code, action) """ combination = list(combination) lengths = list(range(2, len(combination) + 1)) lengths.reverse() return list( itertools.chain.from_iterable( itertools.combinations(combination, length) for length in lengths ) ) class Unreleased: """This represents a key that has been pressed but not released yet.""" __slots__ = ( "target", "input_event_tuple", "triggered_key", ) def __init__(self, target, input_event_tuple, triggered_key): """ Parameters ---------- target : 3-tuple int type, int code of what was injected or forwarded and string target_uinput for injected events, None for forwarded events input_event_tuple : 3-tuple int, int, int / type, code, action triggered_key : tuple of 3-tuples What was used to index key_to_code or macros when stuff was triggered. If nothing was triggered and input_event_tuple forwarded, insert None. """ self.target = target self.input_event_tuple = input_event_tuple self.triggered_key = triggered_key if not isinstance(input_event_tuple[0], int) or len(input_event_tuple) != 3: raise ValueError( "Expected input_event_tuple to be a 3-tuple of ints, but " f"got {input_event_tuple}" ) unreleased[input_event_tuple[:2]] = self def is_mapped(self): """If true, the key-down event was written to context.uinput. That means the release event should also be injected into that one. If this returns false, just forward the release event instead. """ # This should end up being equal to context.is_mapped(key) return self.triggered_key is not None def __str__(self): return ( "Unreleased(" f"target{self.target}," f"input{self.input_event_tuple}," f'key{self.triggered_key or "(None)"}' ")" ) def __repr__(self): return self.__str__() def find_by_event(key): """Find an unreleased entry by an event. If such an entry exists, it was created by an event that is exactly like the input parameter (except for the timestamp). That doesn't mean it triggered something, only that it was seen before. """ unreleased_entry = unreleased.get(key[:2]) if unreleased_entry and unreleased_entry.input_event_tuple == key: return unreleased_entry return None def find_by_key(key): """Find an unreleased entry by a combination of keys. If such an entry exist, it was created when a combination of keys (which matches the parameter, can also be of len 1 = single key) ended up triggering something. Parameters ---------- key : tuple of int type, code, action """ unreleased_entry = unreleased.get(key[-1][:2]) if unreleased_entry and unreleased_entry.triggered_key == key: return unreleased_entry return None class KeycodeMapper(Consumer): """Injects keycodes and starts macros. This really is somewhat complicated because it needs to be able to handle combinations (which is actually not that trivial because the order of keys matters). The nature of some events (D-Pads and Wheels) adds to the complexity. Since macros are mapped the same way keys are, this class takes care of both. """ def __init__(self, *args, **kwargs): """Create a keycode mapper for one virtual device. There may be multiple KeycodeMappers for one hardware device. They share some state (unreleased and active_macros) with each other. """ super().__init__(*args, **kwargs) self._abs_range = None if self.context.maps_joystick(): self._abs_range = utils.get_abs_range(self.source) self._gamepad = classify(self.source) == GAMEPAD self.debounces = {} # some type checking, prevents me from forgetting what that stuff # is supposed to be when writing tests. for key in self.context.key_to_code: for sub_key in key: if abs(sub_key[2]) > 1: raise ValueError( f"Expected values to be one of -1, 0 or 1, " f"but got {key}" ) def is_enabled(self): # even if the source does not provide a capability that is used here, it might # be important for notifying macros of new events that run on other sources. return len(self.context.key_to_code) > 0 or len(self.context.macros) > 0 def is_handled(self, event): return utils.should_map_as_btn(event, self.context.mapping, self._gamepad) async def run(self): """Provide a debouncer to inject wheel releases.""" start = time.time() while True: # try to do this as close to 60hz as possible time_taken = time.time() - start await asyncio.sleep(max(0.0, (1 / 60) - time_taken)) start = time.time() for debounce in self.debounces.values(): if debounce[2] == -1: # has already been triggered continue if debounce[2] == 0: debounce[0](*debounce[1]) debounce[2] = -1 else: debounce[2] -= 1 def debounce(self, debounce_id, func, args, ticks): """Debounce a function call. Parameters ---------- debounce_id : hashable If this function is called with the same debounce_id again, the previous debouncing is overwritten, and therefore restarted. func : function args : tuple ticks : int After ticks * 1 / 60 seconds the function will be executed, unless debounce is called again with the same debounce_id """ self.debounces[debounce_id] = [func, args, ticks] async def notify(self, event): """Receive the newest event that should be mapped.""" action = utils.classify_action(event, self._abs_range) for macro, _ in self.context.macros.values(): macro.notify(event, action) will_report_key_up = utils.will_report_key_up(event) if not will_report_key_up: # simulate a key-up event if no down event arrives anymore. # this may release macros, combinations or keycodes. release = evdev.InputEvent(0, 0, event.type, event.code, 0) self.debounce( debounce_id=(event.type, event.code, action), func=self.handle_keycode, args=(release, RELEASE, False), ticks=3, ) async def delayed_handle_keycode(): # give macros a priority of working on their asyncio iterations # first before handle_keycode. This is important for if_single. # If if_single injects a modifier to modify the key that canceled # its sleep, it needs to inject it before handle_keycode injects # anything. This is important for the space cadet shift. # 1. key arrives # 2. stop if_single # 3. make if_single inject `then` # 4. inject key # But I can't just wait for if_single to do its thing because it might # be a macro that sleeps for a few seconds. # This appears to me to be incredibly race-conditiony. For that # reason wait a few more asyncio ticks before continuing. # But a single one also worked. I can't wait for the specific # macro task here because it might block forever. I'll just give # it a few asyncio iterations advance before continuing here. for _ in range(10): # Noticable delays caused by this start at 10000 iterations # Also see the python docs on asyncio.sleep. Sleeping for 0 # seconds just iterates the loop once. await asyncio.sleep(0) self.handle_keycode(event, action) await delayed_handle_keycode() def macro_write(self, target_uinput): def f(ev_type, code, value): """Handler for macros.""" logger.debug( f"Macro sending %s to %s", (ev_type, code, value), target_uinput ) global_uinputs.write((ev_type, code, value), target_uinput) return f def _get_key(self, key): """If the event triggers stuff, get the key for that. This key can be used to index `key_to_code` and `macros` and it might be a combination of keys. Otherwise, for unmapped events, returns the input. The return format is always a tuple of 3-tuples, each 3-tuple being type, code, action (int, int, int) Parameters ---------- key : tuple of int 3-tuple of type, code, action Action should be one of -1, 0 or 1 """ unreleased_entry = find_by_event(key) # The key used to index the mappings `key_to_code` and `macros`. # If the key triggers a combination, the returned key will be that one # instead action = key[2] key = (key,) if unreleased_entry and unreleased_entry.triggered_key is not None: # seen before. If this key triggered a combination, # use the combination that was triggered by this as key. return unreleased_entry.triggered_key if utils.is_key_down(action): # get the key/combination that the key-down would trigger # the triggering key-down has to be the last element in # combination, all others can have any arbitrary order. By # checking all unreleased keys, a + b + c takes priority over # b + c, if both mappings exist. # WARNING! the combination-down triggers, but a single key-up # releases. Do not check if key in macros and such, if it is an # up event. It's going to be False. combination = tuple( value.input_event_tuple for value in unreleased.values() ) if key[0] not in combination: # might be a duplicate-down event combination += key # find any triggered combination. macros and key_to_code contain # every possible equivalent permutation of possible macros. The # last key in the combination needs to remain the newest key # though. for subset in subsets(combination): if subset[-1] != key[0]: # only combinations that are completed and triggered by # the newest input are of interest continue if self.context.is_mapped(subset): key = subset break else: # no subset found, just use the key. all indices are tuples of # tuples, both for combinations and single keys. if len(combination) > 1: logger.debug_key(combination, "unknown combination") return key def handle_keycode(self, event, action, forward=True): """Write mapped keycodes, forward unmapped ones and manage macros. As long as the provided event is mapped it will handle it, it won't check any type, code or capability anymore. Otherwise it forwards it as it is. Parameters ---------- action : int One of PRESS, PRESS_NEGATIVE or RELEASE Just looking at the events value is not enough, because then mapping trigger-values that are between 1 and 255 is not possible. They might skip the 1 when pressed fast enough. event : evdev.InputEvent forward : bool if False, will not forward the event if it didn't trigger any mapping """ assert isinstance(action, int) type_and_code = (event.type, event.code) active_macro = active_macros.get(type_and_code) original_tuple = (event.type, event.code, event.value) key = self._get_key((*type_and_code, action)) is_mapped = self.context.is_mapped(key) """Releasing keys and macros""" if utils.is_key_up(action): if active_macro is not None and active_macro.is_holding(): # Tell the macro for that keycode that the key is released and # let it decide what to do with that information. active_macro.release_trigger() logger.debug_key(key, "releasing macro") if type_and_code in unreleased: # figure out what this release event was for unreleased_entry = unreleased[type_and_code] target_type, target_code, target_uinput = unreleased_entry.target del unreleased[type_and_code] if target_code == DISABLE_CODE: logger.debug_key(key, "releasing disabled key") return if target_code is None: logger.debug_key(key, "releasing key") return if unreleased_entry.is_mapped(): # release what the input is mapped to try: logger.debug_key( key, "releasing (%s, %s)", target_code, target_uinput ) global_uinputs.write( (target_type, target_code, 0), target_uinput ) return except inputremapper.exceptions.Error: logger.debug_key(key, "could not map") pass if forward: # forward the release event logger.debug_key((original_tuple,), "forwarding release") self.forward(original_tuple) else: logger.debug_key(key, "not forwarding release") return if event.type != EV_ABS: # ABS events might be spammed like crazy every time the # position slightly changes logger.debug_key(key, "unexpected key up") # everything that can be released is released now return """Filtering duplicate key downs""" if is_mapped and utils.is_key_down(action): # unmapped keys should not be filtered here, they should just # be forwarded to populate unreleased and then be written. if find_by_key(key) is not None: # this key/combination triggered stuff before. # duplicate key-down. skip this event. Avoid writing millions # of key-down events when a continuous value is reported, for # example for gamepad triggers or mouse-wheel-side buttons logger.debug_key(key, "duplicate key down") return # it would start a macro usually in_macros = key in self.context.macros running = active_macro and active_macro.running if in_macros and running: # for key-down events and running macros, don't do anything. # This avoids spawning a second macro while the first one is # not finished, especially since gamepad-triggers report a ton # of events with a positive value. logger.debug_key(key, "macro already running") self.context.macros[key].press_trigger() return """starting new macros or injecting new keys""" if utils.is_key_down(action): # also enter this for unmapped keys, as they might end up # triggering a combination, so they should be remembered in # unreleased if key in self.context.macros: macro, target_uinput = self.context.macros[key] active_macros[type_and_code] = macro Unreleased((None, None, None), (*type_and_code, action), key) macro.press_trigger() logger.debug_key( key, "maps to macro (%s, %s)", macro.code, target_uinput ) asyncio.ensure_future(macro.run(self.macro_write(target_uinput))) return if key in self.context.key_to_code: target_code, target_uinput = self.context.key_to_code[key] # remember the key that triggered this # (this combination or this single key) Unreleased( (EV_KEY, target_code, target_uinput), (*type_and_code, action), key ) if target_code == DISABLE_CODE: logger.debug_key(key, "disabled") return try: logger.debug_key( key, "maps to (%s, %s)", target_code, target_uinput ) global_uinputs.write((EV_KEY, target_code, 1), target_uinput) return except inputremapper.exceptions.Error: logger.debug_key(key, "could not map") pass if forward: logger.debug_key((original_tuple,), "forwarding") self.forward(original_tuple) else: logger.debug_key(((*type_and_code, action),), "not forwarding") # unhandled events may still be important for triggering # combinations later, so remember them as well. Unreleased((*type_and_code, None), (*type_and_code, action), None) return logger.error("%s unhandled", key) input-remapper-1.4.0/inputremapper/injection/context.py000066400000000000000000000134761417303655400234220ustar00rootroot00000000000000#!/usr/bin/python3 # -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings # Copyright (C) 2022 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 inputremapper.logger import logger from inputremapper.injection.macros.parse import parse, is_this_a_macro from inputremapper.system_mapping import system_mapping from inputremapper.config import NONE, MOUSE, WHEEL, BUTTONS class Context: """Stores injection-process wide information. In some ways this is a wrapper for the mapping 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. Members ------- mapping : Mapping The mapping that is the source of key_to_code and macros, only used to query config values. key_to_code : dict Mapping of ((type, code, value),) to linux-keycode or multiple of those like ((...), (...), ...) for combinations. Combinations need to be present in every possible valid ordering. e.g. shift + alt + a and alt + shift + a. This is needed to query keycodes more efficiently without having to search mapping each time. macros : dict Mapping of ((type, code, value),) to Macro objects. Combinations work similar as in key_to_code """ def __init__(self, mapping): self.mapping = mapping # avoid searching through the mapping at runtime, # might be a bit expensive self.key_to_code = self._map_keys_to_codes() self.macros = self._parse_macros() self.left_purpose = None self.right_purpose = None self.update_purposes() def update_purposes(self): """Read joystick purposes from the configuration. For efficiency, so that the config doesn't have to be read during runtime repeatedly. """ self.left_purpose = self.mapping.get("gamepad.joystick.left_purpose") self.right_purpose = self.mapping.get("gamepad.joystick.right_purpose") def _parse_macros(self): """To quickly get the target macro during operation.""" logger.debug("Parsing macros") macros = {} for key, output in self.mapping: if is_this_a_macro(output[0]): macro = parse(output[0], self) if macro is None: continue for permutation in key.get_permutations(): macros[permutation.keys] = (macro, output[1]) if len(macros) == 0: logger.debug("No macros configured") return macros def _map_keys_to_codes(self): """To quickly get target keycodes during operation. Returns a mapping of one or more 3-tuples to 2-tuples of (int, target_uinput). Examples: ((1, 2, 1),): (3, "keyboard") ((1, 5, 1), (1, 4, 1)): (4, "gamepad") """ key_to_code = {} for key, output in self.mapping: if is_this_a_macro(output[0]): continue target_code = system_mapping.get(output[0]) if target_code is None: logger.error('Don\'t know what "%s" is', output[0]) continue for permutation in key.get_permutations(): if permutation.keys[-1][-1] not in [-1, 1]: logger.error( "Expected values to be -1 or 1 at this point: %s", permutation.keys, ) key_to_code[permutation.keys] = (target_code, output[1]) return key_to_code def is_mapped(self, key): """Check if this key is used for macros or mappings. Parameters ---------- key : tuple of tuple of int One or more 3-tuples of type, code, action, for example ((EV_KEY, KEY_A, 1), (EV_ABS, ABS_X, -1)) or ((EV_KEY, KEY_B, 1),) """ return key in self.macros or key in self.key_to_code def maps_joystick(self): """If at least one of the joysticks will serve a special purpose.""" return (self.left_purpose, self.right_purpose) != (NONE, NONE) def joystick_as_mouse(self): """If at least one joystick maps to an EV_REL capability.""" purposes = (self.left_purpose, self.right_purpose) return MOUSE in purposes or WHEEL in purposes def joystick_as_dpad(self): """If at least one joystick may be mapped to keys.""" purposes = (self.left_purpose, self.right_purpose) return BUTTONS in purposes def writes_keys(self): """Check if anything is being mapped to keys.""" return len(self.macros) > 0 and len(self.key_to_code) > 0 input-remapper-1.4.0/inputremapper/injection/global_uinputs.py000066400000000000000000000100121417303655400247440ustar00rootroot00000000000000#!/usr/bin/python3 # -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings # Copyright (C) 2022 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 evdev import inputremapper.utils import inputremapper.exceptions from inputremapper.logger import logger 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: [ *range(0x00, 0x06), *range(0x10, 0x12), ], # 6-axis and 1 hat switch }, "mouse": { evdev.ecodes.EV_KEY: [*range(0x110, 0x118)], # BTN_LEFT - BTN_TASK evdev.ecodes.EV_REL: [*range(0x00, 0x0A)], # all REL axis }, } class UInput(evdev.UInput): def __init__(self, *args, **kwargs): logger.debug(f"creating UInput device: '{kwargs['name']}'") super().__init__(*args, **kwargs) def can_emit(self, event): """check if an event can be emitted by the uinput Wrong events might be injected if the group mappings are wrong """ # TODO check for event value especially for EV_ABS return event[1] in self.capabilities().get(event[0], []) class FrontendUInput: """Uinput which can not actually send events, for use in the frontend""" def __init__(self, *args, events=None, name="py-evdev-uinput", **kwargs): # see https://python-evdev.readthedocs.io/en/latest/apidoc.html#module-evdev.uinput self.events = events self.name = name logger.debug(f"creating fake UInput device: '{self.name}'") def capabilities(self): return self.events class GlobalUInputs: """Manages all uinputs that are shared between all injection processes.""" def __init__(self): self.devices = {} self._uinput_factory = None def __iter__(self): return iter(uinput for _, uinput in self.devices.items()) def prepare(self): """Generate uinputs. This has to be done in the main process before injections start. """ if inputremapper.utils.is_service(): self._uinput_factory = UInput else: self._uinput_factory = FrontendUInput 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 write(self, event, 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) uinput.write(*event) uinput.syn() def get_uinput(self, name): """UInput with name Or None if there is no uinput with this name. Parameters ---------- name : uniqe name of the uinput device """ if name in self.devices.keys(): return self.devices[name] return None global_uinputs = GlobalUInputs() input-remapper-1.4.0/inputremapper/injection/injector.py000066400000000000000000000304321417303655400235420ustar00rootroot00000000000000#!/usr/bin/python3 # -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings # Copyright (C) 2022 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 mapping.""" import asyncio import time import multiprocessing import evdev from inputremapper.logger import logger from inputremapper.groups import classify, GAMEPAD from inputremapper.injection.context import Context from inputremapper.injection.numlock import set_numlock, is_numlock_on, ensure_numlock from inputremapper.injection.consumer_control import ConsumerControl DEV_NAME = "input-remapper" # messages CLOSE = 0 OK = 1 # states UNKNOWN = -1 STARTING = 2 FAILED = 3 RUNNING = 4 STOPPED = 5 # for both states and messages NO_GRAB = 6 def is_in_capabilities(key, capabilities): """Are this key or one of its sub keys in the capabilities? Parameters ---------- key : Key """ for sub_key in key: if sub_key[1] in capabilities.get(sub_key[0], []): return True return False def get_udev_name(name, suffix): """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 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. """ regrab_timeout = 0.2 def __init__(self, group, mapping): """ Parameters ---------- group : _Group the device group mapping : Mapping """ self.group = group self._state = UNKNOWN # used to interact with the parts of this class that are running within # the new process self._msg_pipe = multiprocessing.Pipe() self.mapping = mapping self.context = None # only needed inside the injection process self._consumer_controls = [] super().__init__(name=group) """Functions to interact with the running process""" def get_state(self): """Get the state of the injection. Can be safely called from the main process. """ # slowly figure out what is going on alive = self.is_alive() if self._state == UNKNOWN and not alive: # didn't start yet return self._state # if it is alive, it is definitely at least starting up if self._state == UNKNOWN and alive: self._state = STARTING # if there is a message available, it might have finished starting up if self._state == STARTING and self._msg_pipe[1].poll(): msg = self._msg_pipe[1].recv() if msg == OK: self._state = RUNNING if msg == NO_GRAB: self._state = NO_GRAB if self._state in [STARTING, RUNNING] and not alive: self._state = FAILED logger.error("Injector was unexpectedly found stopped") return self._state @ensure_numlock def stop_injecting(self): """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(CLOSE) self._state = STOPPED """Process internal stuff""" def _grab_devices(self): """Grab all devices that are needed for the injection.""" sources = [] for path in self.group.paths: source = self._grab_device(path) if source is None: # this path doesn't need to be grabbed for injection, because # it doesn't provide the events needed to execute the mapping continue sources.append(source) return sources def _grab_device(self, path): """Try to grab the device, return None if not needed/possible. Without grab, original events from it would reach the display server even though they are mapped. """ try: device = evdev.InputDevice(path) except (FileNotFoundError, OSError): logger.error('Could not find "%s"', path) return None capabilities = device.capabilities(absinfo=False) needed = False for key, _ in self.context.mapping: if is_in_capabilities(key, capabilities): logger.debug('Grabbing "%s" because of "%s"', path, key) needed = True break gamepad = classify(device) == GAMEPAD if gamepad and self.context.maps_joystick(): logger.debug('Grabbing "%s" because of maps_joystick', path) needed = True if not needed: # skipping reading and checking on events from those devices # may be beneficial for performance. logger.debug("No need to grab %s", path) return None attempts = 0 while True: try: device.grab() logger.debug("Grab %s", path) break except IOError as error: attempts += 1 # it might take a little time until the device is free if # it was previously grabbed. logger.debug("Failed attempts to grab %s: %d", path, attempts) if attempts >= 10: logger.error("Cannot grab %s, it is possibly in use", path) logger.error(str(error)) return None time.sleep(self.regrab_timeout) return device def _copy_capabilities(self, input_device): """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): """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 == CLOSE: logger.debug("Received close signal") # stop the event loop and cause the process to reach its end # cleanly. Using .terminate prevents coverage from working. loop.stop() return def run(self): """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. """ # TODO run all injections in a single process via asyncio # - Make sure that closing asyncio fds won't lag the service # - SharedDict becomes obsolete # - quick_cleanup needs to be able to reliably stop the injection # - I think I want an event listener architecture so that macros, # joystick_to_mouse, keycode_mapper and possibly other modules can get # what they filter for whenever they want, without having to wire # things through multiple other objects all the time # - _new_event_arrived moves to the place where events are emitted. injector? # - active macros and unreleased need to be per injection. it probably # should move into the keycode_mapper class, but that only works if there # is only one keycode_mapper per injection, and not per source. Problem was # that I had to excessively pass around to which device to forward to... # I also need to have information somewhere which source is a gamepad, I # probably don't want to evaluate that from scratch each time `notify` is # called. # - benefit: writing macros that listen for events from other devices logger.info('Starting injecting the mapping 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) # create this within the process after the event loop creation, # so that the macros use the correct loop self.context = Context(self.mapping) # 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() if len(sources) == 0: logger.error("Did not grab any device") self._msg_pipe[0].send(NO_GRAB) return numlock_state = is_numlock_on() coroutines = [] for source in sources: # certain capabilities can have side effects apparently. with an # EV_ABS capability, EV_REL won't move the mouse pointer anymore. # so don't merge all InputDevices into one UInput device. forward_to = evdev.UInput( name=get_udev_name(source.name, "forwarded"), phys=DEV_NAME, events=self._copy_capabilities(source), ) # actually doing things consumer_control = ConsumerControl(self.context, source, forward_to) coroutines.append(consumer_control.run()) self._consumer_controls.append(consumer_control) 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(OK) try: loop.run_until_complete(asyncio.gather(*coroutines)) except RuntimeError: # stopped event loop most likely pass 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: # 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)) input-remapper-1.4.0/inputremapper/injection/macros/000077500000000000000000000000001417303655400226355ustar00rootroot00000000000000input-remapper-1.4.0/inputremapper/injection/macros/__init__.py000066400000000000000000000000001417303655400247340ustar00rootroot00000000000000input-remapper-1.4.0/inputremapper/injection/macros/macro.py000066400000000000000000000533751417303655400243250ustar00rootroot00000000000000#!/usr/bin/python3 # -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings # Copyright (C) 2022 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. To keep it short on the UI, basic functions are one letter long. The outermost macro (in the examples below the one created by 'r', 'r' and 'w') will be started, which triggers a chain reaction to execute all of the configured stuff. Examples -------- r(3, k(a).w(10)): a <10ms> a <10ms> a r(2, k(a).k(KEY_A)).k(b): a - a - b w(1000).m(Shift_L, r(2, k(a))).w(10).k(b): <1s> A A <10ms> b """ import asyncio import copy import re from evdev.ecodes import ecodes, EV_KEY, EV_REL, REL_X, REL_Y, REL_WHEEL, REL_HWHEEL from inputremapper.logger import logger from inputremapper.system_mapping import system_mapping from inputremapper.ipc.shared_dict import SharedDict from inputremapper.utils import PRESS, PRESS_NEGATIVE macro_variables = SharedDict() class Variable: """Can be used as function parameter in the various add_... functions. Parsed from strings like `$foo` in `repeat($foo, k(KEY_A))` Its value is unknown during construction and needs to be set using the `set` macro during runtime. """ def __init__(self, name): self.name = name def resolve(self): """Get the variables value from memory.""" return macro_variables.get(self.name) def __repr__(self): return f'' def _type_check(value, allowed_types, display_name=None, position=None): """Validate a parameter used in a macro. If the value is a Variable, it will be returned and should be resolved during runtime with _resolve. """ if isinstance(value, Variable): # it is a variable and will be read at runtime return value for allowed_type in allowed_types: if allowed_type is None: if value is None: return value continue # try to parse "1" as 1 if possible try: return allowed_type(value) except (TypeError, ValueError): pass if isinstance(value, allowed_type): return value if display_name is not None and position is not None: raise TypeError( f"Expected parameter {position} for {display_name} to be " f"one of {allowed_types}, but got {value}" ) raise TypeError(f"Expected parameter to be one of {allowed_types}, but got {value}") def _type_check_keyname(keyname): """Same as _type_check, but checks if the key-name is valid.""" if isinstance(keyname, Variable): # it is a variable and will be read at runtime return keyname symbol = str(keyname) code = system_mapping.get(symbol) if code is None: raise KeyError(f'Unknown key "{symbol}"') return code def _type_check_variablename(name): """Check if this is a legit variable name. Because they could clash with language features. If the macro is able to 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(name, str) or not re.match(r"^[A-Za-z_][A-Za-z_0-9]*$", name): raise SyntaxError(f'"{name}" is not a legit variable name') def _resolve(argument, allowed_types=None): """If the argument is a variable, figure out its value and cast it. Use this just-in-time when you need the actual value of the variable during runtime. """ if isinstance(argument, Variable): value = argument.resolve() logger.debug('"%s" is "%s"', argument, value) if allowed_types: return _type_check(value, allowed_types) else: return value return argument class Macro: """Supports chaining and preparing actions. Calling functions like keycode on Macro doesn't inject any events yet, it means that once .run is used it will be executed along with all other queued tasks. Those functions need to construct an asyncio coroutine and append it to self.tasks. This makes parameter checking during compile time possible, as long as they are not variables that are resolved durig runtime. Coroutines receive a handler as argument, which is a function that can be used to inject input events into the system. 1. A few parameters of any time are thrown into a macro function like `repeat` 2. `Macro.repeat` will verify the parameter types if possible using `_type_check` (it can't for $variables). This helps debugging macros before the injection starts, but is not mandatory to make things work. 3. `Macro.repeat` - adds a task to self.tasks. This task resolves any variables with `_resolve` and does what the macro is supposed to do once `macro.run` is called. - also adds the child macro to self.child_macros. - adds the used keys to the capabilities 4. `Macro.run` will run all tasks in self.tasks """ def __init__(self, code, context): """Create a macro instance that can be populated with tasks. Parameters ---------- code : string or None The original parsed code, for logging purposes. context : Context, or None for use in frontend """ self.code = code self.context = context # List of coroutines that will be called sequentially. # This is the compiled code self.tasks = [] # can be used to wait for the release of the event 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() self.running = False # all required capabilities, without those of child macros self.capabilities = { EV_KEY: set(), EV_REL: set(), } self.child_macros = [] self.keystroke_sleep_ms = None self._new_event_arrived = asyncio.Event() self._newest_event = None self._newest_action = None def notify(self, event, action): """Tell the macro about the newest event.""" for macro in self.child_macros: macro.notify(event, action) self._newest_event = event self._newest_action = action self._new_event_arrived.set() async def _wait_for_event(self, filter=None): """Wait until a specific event arrives. The parameters can be used to provide a filter. It will block until an event arrives that matches them. Parameters ---------- filter : function Receives the event. Stop waiting if it returns true. """ while True: await self._new_event_arrived.wait() self._new_event_arrived.clear() if filter is not None: if not filter(self._newest_event, self._newest_action): continue break def is_holding(self): """Check if the macro is waiting for a key to be released.""" return not self._trigger_release_event.is_set() def get_capabilities(self): """Get the merged capabilities of the macro and its children.""" capabilities = copy.deepcopy(self.capabilities) for macro in self.child_macros: macro_capabilities = macro.get_capabilities() for ev_type in macro_capabilities: if ev_type not in capabilities: capabilities[ev_type] = set() capabilities[ev_type].update(macro_capabilities[ev_type]) return capabilities async def run(self, handler): """Run the macro. Parameters ---------- handler : function Will receive int type, code and value for an event to write """ if not callable(handler): raise ValueError("handler is not callable") if self.running: logger.error('Tried to run already running macro "%s"', self.code) return # newly arriving events are only interesting if they arrive after the # macro started self._new_event_arrived.clear() self.keystroke_sleep_ms = self.context.mapping.get("macros.keystroke_sleep_ms") self.running = True for task in self.tasks: try: coroutine = task(handler) if asyncio.iscoroutine(coroutine): await coroutine except Exception as e: logger.error(f'Macro "%s" failed: %s', self.code, e) break # done self.running = False def press_trigger(self): """The user pressed the trigger key down.""" if self.is_holding(): logger.error("Already holding") return self._trigger_release_event.clear() self._trigger_press_event.set() for macro in self.child_macros: macro.press_trigger() def release_trigger(self): """The user released the trigger key.""" self._trigger_release_event.set() self._trigger_press_event.clear() for macro in self.child_macros: macro.release_trigger() async def _keycode_pause(self, _=None): """To add a pause between keystrokes.""" await asyncio.sleep(self.keystroke_sleep_ms / 1000) def add_mouse_capabilities(self): """Add all capabilities that are required to recognize the device as mouse.""" self.capabilities[EV_REL].add(REL_X) self.capabilities[EV_REL].add(REL_Y) self.capabilities[EV_REL].add(REL_WHEEL) self.capabilities[EV_REL].add(REL_HWHEEL) def __repr__(self): return f'' """Functions that prepare the macro""" def add_hold(self, macro=None): """Loops the execution until key release.""" _type_check(macro, [Macro, str, None], "h (hold)", 1) if macro is None: self.tasks.append(lambda _: self._trigger_release_event.wait()) return if not isinstance(macro, Macro): # if macro is a key name, hold down the key while the # keyboard key is physically held down code = _type_check_keyname(macro) async def task(handler): resolved_code = _resolve(code, [int]) self.capabilities[EV_KEY].add(resolved_code) handler(EV_KEY, resolved_code, 1) await self._trigger_release_event.wait() handler(EV_KEY, resolved_code, 0) self.capabilities[EV_KEY].add(code) self.tasks.append(task) if isinstance(macro, Macro): # repeat the macro forever while the key is held down async def task(handler): while self.is_holding(): # run the child macro completely to avoid # not-releasing any key await macro.run(handler) self.tasks.append(task) self.child_macros.append(macro) def add_modify(self, modifier, macro): """Do stuff while a modifier is activated. Parameters ---------- modifier : str macro : Macro """ _type_check(macro, [Macro], "m (modify)", 2) modifier = str(modifier) code = system_mapping.get(modifier) if code is None: raise KeyError(f'Unknown modifier "{modifier}"') self.capabilities[EV_KEY].add(code) self.child_macros.append(macro) async def task(handler): resolved_code = _resolve(code, [int]) self.capabilities[EV_KEY].add(resolved_code) await self._keycode_pause() handler(EV_KEY, resolved_code, 1) await self._keycode_pause() await macro.run(handler) await self._keycode_pause() handler(EV_KEY, resolved_code, 0) await self._keycode_pause() self.tasks.append(task) def add_repeat(self, repeats, macro): """Repeat actions. Parameters ---------- repeats : int or Macro macro : Macro """ repeats = _type_check(repeats, [int], "r (repeat)", 1) _type_check(macro, [Macro], "r (repeat)", 2) async def task(handler): for _ in range(_resolve(repeats, [int])): await macro.run(handler) self.tasks.append(task) self.child_macros.append(macro) def add_key(self, symbol): """Write the symbol.""" _type_check_keyname(symbol) symbol = str(symbol) code = system_mapping.get(symbol) self.capabilities[EV_KEY].add(code) async def task(handler): handler(EV_KEY, code, 1) await self._keycode_pause() handler(EV_KEY, code, 0) await self._keycode_pause() self.tasks.append(task) def add_event(self, _type, code, value): """Write any event. Parameters ---------- _type: str or int examples: 2, 'EV_KEY' code : int or int examples: 52, 'KEY_A' value : int """ _type = _type_check(_type, [int, str], "e (event)", 1) code = _type_check(code, [int, str], "e (event)", 2) value = _type_check(value, [int, str], "e (event)", 3) if isinstance(_type, str): _type = ecodes[_type.upper()] if isinstance(code, str): code = ecodes[code.upper()] if _type not in self.capabilities: self.capabilities[_type] = set() if _type == EV_REL: # add all capabilities that are required for the display server # to recognize the device as mouse self.capabilities[EV_REL].add(REL_X) self.capabilities[EV_REL].add(REL_Y) self.capabilities[EV_REL].add(REL_WHEEL) self.capabilities[_type].add(code) self.tasks.append(lambda handler: handler(_type, code, value)) self.tasks.append(self._keycode_pause) def add_mouse(self, direction, speed): """Move the mouse cursor.""" _type_check(direction, [str], "mouse", 1) speed = _type_check(speed, [int], "mouse", 2) code, value = { "up": (REL_Y, -1), "down": (REL_Y, 1), "left": (REL_X, -1), "right": (REL_X, 1), }[direction.lower()] self.add_mouse_capabilities() async def task(handler): resolved_speed = value * _resolve(speed, [int]) while self.is_holding(): handler(EV_REL, code, resolved_speed) await self._keycode_pause() self.tasks.append(task) def add_wheel(self, direction, speed): """Move the scroll wheel.""" _type_check(direction, [str], "wheel", 1) speed = _type_check(speed, [int], "wheel", 2) code, value = { "up": (REL_WHEEL, 1), "down": (REL_WHEEL, -1), "left": (REL_HWHEEL, 1), "right": (REL_HWHEEL, -1), }[direction.lower()] self.add_mouse_capabilities() async def task(handler): resolved_speed = _resolve(speed, [int]) while self.is_holding(): handler(EV_REL, code, value) # scrolling moves much faster than mouse, so this # waits between injections instead to make it slower await asyncio.sleep(1 / resolved_speed) self.tasks.append(task) def add_wait(self, time): """Wait time in milliseconds.""" time = _type_check(time, [int, float], "wait", 1) async def task(_): await asyncio.sleep(_resolve(time, [int, float]) / 1000) self.tasks.append(task) def add_set(self, variable, value): """Set a variable to a certain value.""" _type_check_variablename(variable) async def task(_): # can also copy with set(a, $b) resolved_value = _resolve(value) logger.debug('"%s" set to "%s"', variable, resolved_value) macro_variables[variable] = value self.tasks.append(task) def add_ifeq(self, variable, value, then=None, otherwise=None): """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. """ _type_check(then, [Macro, None], "ifeq", 3) _type_check(otherwise, [Macro, None], "ifeq", 4) async def task(handler): set_value = macro_variables.get(variable) logger.debug('"%s" is "%s"', variable, set_value) if set_value == value: if then is not None: await then.run(handler) elif otherwise is not None: await otherwise.run(handler) if isinstance(then, Macro): self.child_macros.append(then) if isinstance(otherwise, Macro): self.child_macros.append(otherwise) self.tasks.append(task) def add_if_eq(self, value_1, value_2, then=None, _else=None): """Compare two values.""" _type_check(then, [Macro, None], "if_eq", 3) _type_check(_else, [Macro, None], "if_eq", 4) async def task(handler): resolved_value_1 = _resolve(value_1) resolved_value_2 = _resolve(value_2) if resolved_value_1 == resolved_value_2: if then is not None: await then.run(handler) elif _else is not None: await _else.run(handler) if isinstance(then, Macro): self.child_macros.append(then) if isinstance(_else, Macro): self.child_macros.append(_else) self.tasks.append(task) def add_if_tap(self, then=None, _else=None, timeout=300): """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 """ _type_check(then, [Macro, None], "if_tap", 1) _type_check(_else, [Macro, None], "if_tap", 2) timeout = _type_check(timeout, [int, float], "if_tap", 3) if isinstance(then, Macro): self.child_macros.append(then) if isinstance(_else, Macro): self.child_macros.append(_else) async def wait(): """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() async def task(handler): resolved_timeout = _resolve(timeout, [int, float]) / 1000 try: await asyncio.wait_for(wait(), resolved_timeout) if then: await then.run(handler) except asyncio.TimeoutError: if _else: await _else.run(handler) self.tasks.append(task) def add_if_single(self, then, otherwise, timeout=None): """If a key was pressed without combining it.""" _type_check(then, [Macro, None], "if_single", 1) _type_check(otherwise, [Macro, None], "if_single", 2) if isinstance(then, Macro): self.child_macros.append(then) if isinstance(otherwise, Macro): self.child_macros.append(otherwise) async def task(handler): triggering_event = (self._newest_event.type, self._newest_event.code) def event_filter(event, action): """Which event may wake if_single up.""" # release event of the actual key if (event.type, event.code) == triggering_event: return True # press event of another key if action in (PRESS, PRESS_NEGATIVE): return True coroutine = self._wait_for_event(event_filter) resolved_timeout = _resolve(timeout, allowed_types=[int, float, None]) try: if resolved_timeout is not None: await asyncio.wait_for(coroutine, resolved_timeout / 1000) else: await coroutine newest_event = (self._newest_event.type, self._newest_event.code) # if newest_event == triggering_event, then no other key was pressed. # if it is !=, then a new key was pressed in the meantime. new_key_pressed = triggering_event != newest_event if not new_key_pressed: # no timeout and not combined if then: await then.run(handler) return except asyncio.TimeoutError: pass if otherwise: await otherwise.run(handler) self.tasks.append(task) input-remapper-1.4.0/inputremapper/injection/macros/parse.py000066400000000000000000000316251417303655400243300ustar00rootroot00000000000000#!/usr/bin/python3 # -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings # Copyright (C) 2022 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""" import re import traceback import inspect from inputremapper.logger import logger from inputremapper.injection.macros.macro import Macro, Variable def is_this_a_macro(output): """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 FUNCTIONS = { # shorthands for common functions because the space to type is so constrained "m": Macro.add_modify, "r": Macro.add_repeat, "k": Macro.add_key, "e": Macro.add_event, "w": Macro.add_wait, "h": Macro.add_hold, # add proper full function names for all other future macros "modify": Macro.add_modify, "repeat": Macro.add_repeat, "key": Macro.add_key, "event": Macro.add_event, "wait": Macro.add_wait, "hold": Macro.add_hold, "mouse": Macro.add_mouse, "wheel": Macro.add_wheel, "ifeq": Macro.add_ifeq, # kept for compatibility with existing old macros "if_eq": Macro.add_if_eq, "set": Macro.add_set, "if_tap": Macro.add_if_tap, "if_single": Macro.add_if_single, } def use_safe_argument_names(keyword_args): """Certain names cannot be used internally as parameters, Add _ in front of them. For example the macro `if_eq(1, 1, else=k(b))` uses the _else parameter of `def add_if_eq` to work. """ # extend this list with parameter names that cannot be used in python, but should # be used in macro code. built_ins = ["else", "type"] for built_in in built_ins: if keyword_args.get(built_in) is not None: keyword_args[f"_{built_in}"] = keyword_args[built_in] del keyword_args[built_in] def get_macro_argument_names(function): """Certain names, like "else" or "type" cannot be used as parameters in python. Removes the "_" in from of them for displaying them correctly. """ # don't include "self" return [ name[1:] if name.startswith("_") else name for name in inspect.getfullargspec(function).args[1:] ] def get_num_parameters(function): """Get the number of required parameters and the maximum number of parameters.""" fullargspec = inspect.getfullargspec(function) num_args = len(fullargspec.args) - 1 # one is `self` return num_args - len(fullargspec.defaults or ()), num_args def _extract_args(inner): """Extract parameters from the inner contents of a call. This does not parse them. Parameters ---------- inner : string 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 def _count_brackets(macro): """Find where the first opening bracket closes.""" openings = macro.count("(") closings = macro.count(")") if openings != closings: raise SyntaxError(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 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 def _is_number(value): """Check if the value can be turned into a number.""" try: float(value) return True except ValueError: return False def _parse_recurse(code, context, macro_instance=None, depth=0): """Handle a subset of the macro, e.g. one parameter or function call. Not using eval for security reasons. Parameters ---------- code : string Just like parse. A single parameter or the complete macro as string. Comments and redundant whitespace characters are expected to be removed already. context : Context macro_instance : Macro or None A macro instance to add tasks to depth : int For logging porposes """ assert isinstance(code, str) assert isinstance(depth, int) space = " " * depth code = code.strip() if code == "": return None if code.startswith('"'): # a string, don't parse. remove quotes string = code[1:-1] logger.debug("%sstring %s", space, string) return string if code.startswith("$"): # will be resolved during the macros runtime return Variable(code.split("$", 1)[1]) if _is_number(code): if "." in code: code = float(code) else: code = int(code) logger.debug("%snumber %s", space, code) return code # is it another macro? call_match = re.match(r"^(\w+)\(", code) call = call_match[1] if call_match else None if call is not None: if macro_instance is None: # start a new chain macro_instance = Macro(code, context) else: # chain this call to the existing instance assert isinstance(macro_instance, Macro) function = FUNCTIONS.get(call) if function is None: raise Exception(f"Unknown function {call}") # get all the stuff inbetween position = _count_brackets(code) inner = code[code.index("(") + 1 : position - 1] logger.debug("%scalls %s with %s", space, call, inner) # split "3, foo=a(2, k(a).w(10))" into arguments raw_string_args = _extract_args(inner) # parse and sort the params positional_args = [] keyword_args = {} for param in raw_string_args: key, value = _split_keyword_arg(param) parsed = _parse_recurse(value.strip(), context, None, depth + 1) if key is None: if len(keyword_args) > 0: msg = f'Positional argument "{key}" follows keyword argument' raise SyntaxError(msg) positional_args.append(parsed) else: if key in keyword_args: raise SyntaxError(f'The "{key}" argument was specified twice') keyword_args[key] = parsed logger.debug( "%sadd call to %s with %s, %s", space, call, positional_args, keyword_args, ) min_args, max_args = get_num_parameters(function) 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"{call} takes between {min_args} and {max_args}, " f"not {num_provided_args} parameters" ) else: msg = f"{call} takes {min_args}, not {num_provided_args} parameters" raise ValueError(msg) use_safe_argument_names(keyword_args) function(macro_instance, *positional_args, **keyword_args) # is after this another call? Chain it to the macro_instance if len(code) > position and code[position] == ".": chain = code[position + 1 :] logger.debug("%sfollowed by %s", space, chain) _parse_recurse(chain, context, macro_instance, depth) return macro_instance # It is probably either a key name like KEY_A or a variable name as in `set(var,1)`, # both won't contain special characters that can break macro syntax so they don't # have to be wrapped in quotes. logger.debug("%sstring %s", space, code) return code def handle_plus_syntax(macro): """transform a + b + c to m(a, m(b, m(c, h())))""" if "+" not in macro: return macro if "(" in macro or ")" in macro: raise ValueError( f'Mixing "+" and macros is unsupported: "{ macro}"' ) # TODO: MacroParsingError chunks = [chunk.strip() for chunk in macro.split("+")] output = "" depth = 0 for chunk in chunks: if chunk == "": # invalid syntax raise ValueError(f'Invalid syntax for "{macro}"') depth += 1 output += f"m({chunk}," output += "h()" output += depth * ")" logger.debug('Transformed "%s" to "%s"', macro, output) return output 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)] 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 def clean(code): """Remove everything irrelevant for the macro.""" return remove_whitespaces(remove_comments(code), '"') def parse(macro, context=None, return_errors=False): """parse and generate a Macro that can be run as often as you want. If it could not be parsed, possibly due to syntax errors, will log the error and return None. Parameters ---------- macro : string "r(3, k(a).w(10))" "r(2, k(a).k(KEY_A)).k(b)" "w(1000).m(Shift_L, r(2, k(a))).w(10, 20).k(b)" context : Context, or None for use in Frontend return_errors : bool If True, returns errors as a string or None if parsing worked. If False, returns the parsed macro. """ try: macro = handle_plus_syntax(macro) except Exception as error: logger.error('Failed to parse macro "%s": %s', macro, error.__repr__()) # print the traceback in case this is a bug of input-remapper logger.debug("".join(traceback.format_tb(error.__traceback__)).strip()) return f"{error.__class__.__name__}: {str(error)}" if return_errors else None macro = clean(macro) if return_errors: logger.debug("checking the syntax of %s", macro) else: logger.debug("preparing macro %s for later execution", macro) try: macro_object = _parse_recurse(macro, context) return macro_object if not return_errors else None except Exception as error: logger.error('Failed to parse macro "%s": %s', macro, error.__repr__()) # print the traceback in case this is a bug of input-remapper logger.debug("".join(traceback.format_tb(error.__traceback__)).strip()) return f"{error.__class__.__name__}: {str(error)}" if return_errors else None input-remapper-1.4.0/inputremapper/injection/numlock.py000066400000000000000000000045231417303655400233770ustar00rootroot00000000000000#!/usr/bin/python3 # -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings # Copyright (C) 2022 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.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 input-remapper-1.4.0/inputremapper/ipc/000077500000000000000000000000001417303655400201425ustar00rootroot00000000000000input-remapper-1.4.0/inputremapper/ipc/__init__.py000066400000000000000000000020331417303655400222510ustar00rootroot00000000000000#!/usr/bin/python3 # -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings # Copyright (C) 2022 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. """ input-remapper-1.4.0/inputremapper/ipc/pipe.py000066400000000000000000000105061417303655400214530ustar00rootroot00000000000000#!/usr/bin/python3 # -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings # Copyright (C) 2022 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 os import time import json from inputremapper.logger import logger from inputremapper.paths import mkdir, chown class Pipe: """Pipe object.""" def __init__(self, path): """Create a pipe, or open it if it already exists.""" self._path = path self._unread = [] self._created_at = time.time() paths = (f"{path}r", f"{path}w") mkdir(os.path.dirname(path)) if not os.path.exists(paths[0]): logger.debug('Creating new pipe for "%s"', path) # 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/" chown(f"{fds_dir}{self._fds[0]}") 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 pipe for "%s"', path) # 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")) def recv(self): """Read an object from the pipe or None if nothing available. Doesn't transmit pickles, to avoid injection attacks on the privileged helper. Only messages that can be converted to json are allowed. """ if len(self._unread) > 0: return self._unread.pop(0) line = self._handles[0].readline() if len(line) == 0: return None parsed = json.loads(line) if parsed[0] < self._created_at and os.environ.get("UNITTEST"): # important to avoid race conditions between multiple unittests, # for example old terminate messages reaching a new instance of # the helper. logger.debug("Ignoring old message %s", parsed) return None return parsed[1] def send(self, message): """Write an 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() input-remapper-1.4.0/inputremapper/ipc/shared_dict.py000066400000000000000000000073471417303655400230000ustar00rootroot00000000000000#!/usr/bin/python3 # -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings # Copyright (C) 2022 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 multiprocessing import atexit import select from inputremapper.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): """Get a value from the dictionary. If it doesn't exist, returns None. """ return self.__getitem__(key) def is_alive(self, timeout=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, value): self.pipe[1].send(("set", key, value)) def __getitem__(self, key): 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() input-remapper-1.4.0/inputremapper/ipc/socket.py000066400000000000000000000221141417303655400220040ustar00rootroot00000000000000#!/usr/bin/python3 # -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings # Copyright (C) 2022 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 (reader) and Client (helper) instead of Pipe # - 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 select import socket import os import time import json from inputremapper.logger import logger from inputremapper.paths import mkdir, chown # 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 = [] 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 helper # 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 helper. 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 helper. 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): """Send jsonable messages, like numbers, strings or objects.""" 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) 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) input-remapper-1.4.0/inputremapper/key.py000066400000000000000000000221271417303655400205350ustar00rootroot00000000000000#!/usr/bin/python3 # -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings # Copyright (C) 2022 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 . """A button or a key combination.""" import itertools import evdev from evdev import ecodes from inputremapper.system_mapping import system_mapping from inputremapper.logger import logger def verify(key): """Check if the key is an int 3-tuple of type, code, value""" if not isinstance(key, tuple) or len(key) != 3: raise ValueError(f"Expected key to be a 3-tuple, but got {key}") if sum([not isinstance(value, int) for value in key]) != 0: raise ValueError(f"Can only use integers, but got {key}") # having shift in combinations modifies the configured output, # ctrl might not work at all DIFFICULT_COMBINATIONS = [ ecodes.KEY_LEFTSHIFT, ecodes.KEY_RIGHTSHIFT, ecodes.KEY_LEFTCTRL, ecodes.KEY_RIGHTCTRL, ecodes.KEY_LEFTALT, ecodes.KEY_RIGHTALT, ] class Key: """Represents one or more pressed down keys. Can be used in hashmaps/dicts as key """ def __init__(self, *keys): """ Parameters ---------- Takes an arbitrary number of tuples as arguments. Each one should be in the format of 0: type, one of evdev.events, taken from the original source event. Everything will be mapped to EV_KEY. 1: The source keycode, what the mouse would report without any modification. 2. The value. 1 (down), 0 (up) or any other value that the device reports. Gamepads use a continuous space of values for joysticks and triggers. or Key objects, which will flatten all of them into one combination """ if len(keys) == 0: raise ValueError("At least one key is required") if isinstance(keys[0], int): # type, code, value was provided instead of a tuple keys = (keys,) # multiple objects of Key get flattened into one tuple flattened = () for key in keys: if isinstance(key, Key): flattened += key.keys # pylint: disable=no-member else: flattened += (key,) keys = flattened for key in keys: verify(key) self.keys = tuple(keys) self.release = (*self.keys[-1][:2], 0) @classmethod def btn_left(cls): """Construct a Key object representing a left click on a mouse.""" return cls(ecodes.EV_KEY, ecodes.BTN_LEFT, 1) def __iter__(self): return iter(self.keys) def __getitem__(self, item): return self.keys[item] def __len__(self): """Get the number of pressed down kes.""" return len(self.keys) def __str__(self): return f"Key{str(self.keys)}" def __repr__(self): # used in the AssertionError output of tests return self.__str__() def __hash__(self): if len(self.keys) == 1: return hash(self.keys[0]) return hash(self.keys) def __eq__(self, other): if isinstance(other, tuple): if isinstance(other[0], tuple): # a combination ((1, 5, 1), (1, 3, 1)) return self.keys == other # otherwise, self needs to represent a single key as well return len(self.keys) == 1 and self.keys[0] == other if not isinstance(other, Key): return False # compare two instances of Key return self.keys == other.keys def is_problematic(self): """Is this combination going to work properly on all systems?""" if len(self.keys) <= 1: return False for sub_key in self.keys: if sub_key[0] != ecodes.EV_KEY: continue if sub_key[1] in DIFFICULT_COMBINATIONS: return True return False def get_permutations(self): """Get a list of Key objects representing all possible permutations. combining a + b + c should have the same result as b + a + c. Only the last key remains the same in the returned result. """ if len(self.keys) <= 2: return [self] permutations = [] for permutation in itertools.permutations(self.keys[:-1]): permutations.append(Key(*permutation, self.keys[-1])) return permutations def beautify(self): """Get a human readable string representation.""" result = [] for sub_key in self: if isinstance(sub_key[0], tuple): raise Exception("deprecated stuff") ev_type, code, value = sub_key if ev_type not in evdev.ecodes.bytype: logger.error("Unknown key type for %s", sub_key) result.append(str(code)) continue if code not in evdev.ecodes.bytype[ev_type]: logger.error("Unknown key code for %s", sub_key) result.append(str(code)) continue key_name = None # first try to find the name in xmodmap to not display wrong # names due to the keyboard layout if ev_type == evdev.ecodes.EV_KEY: key_name = system_mapping.get_name(code) if key_name is None: # if no result, look in the linux key constants. On a german # keyboard for example z and y are switched, which will therefore # cause the wrong letter to be displayed. key_name = evdev.ecodes.bytype[ev_type][code] if isinstance(key_name, list): key_name = key_name[0] if ev_type != evdev.ecodes.EV_KEY: direction = { # D-Pad (evdev.ecodes.ABS_HAT0X, -1): "Left", (evdev.ecodes.ABS_HAT0X, 1): "Right", (evdev.ecodes.ABS_HAT0Y, -1): "Up", (evdev.ecodes.ABS_HAT0Y, 1): "Down", (evdev.ecodes.ABS_HAT1X, -1): "Left", (evdev.ecodes.ABS_HAT1X, 1): "Right", (evdev.ecodes.ABS_HAT1Y, -1): "Up", (evdev.ecodes.ABS_HAT1Y, 1): "Down", (evdev.ecodes.ABS_HAT2X, -1): "Left", (evdev.ecodes.ABS_HAT2X, 1): "Right", (evdev.ecodes.ABS_HAT2Y, -1): "Up", (evdev.ecodes.ABS_HAT2Y, 1): "Down", # joystick (evdev.ecodes.ABS_X, 1): "Right", (evdev.ecodes.ABS_X, -1): "Left", (evdev.ecodes.ABS_Y, 1): "Down", (evdev.ecodes.ABS_Y, -1): "Up", (evdev.ecodes.ABS_RX, 1): "Right", (evdev.ecodes.ABS_RX, -1): "Left", (evdev.ecodes.ABS_RY, 1): "Down", (evdev.ecodes.ABS_RY, -1): "Up", # wheel (evdev.ecodes.REL_WHEEL, -1): "Down", (evdev.ecodes.REL_WHEEL, 1): "Up", (evdev.ecodes.REL_HWHEEL, -1): "Left", (evdev.ecodes.REL_HWHEEL, 1): "Right", }.get((code, value)) if direction is not None: key_name += f" {direction}" key_name = key_name.replace("ABS_Z", "Trigger Left") key_name = key_name.replace("ABS_RZ", "Trigger Right") key_name = key_name.replace("ABS_HAT0X", "DPad") key_name = key_name.replace("ABS_HAT0Y", "DPad") key_name = key_name.replace("ABS_HAT1X", "DPad 2") key_name = key_name.replace("ABS_HAT1Y", "DPad 2") key_name = key_name.replace("ABS_HAT2X", "DPad 3") key_name = key_name.replace("ABS_HAT2Y", "DPad 3") key_name = key_name.replace("ABS_X", "Joystick") key_name = key_name.replace("ABS_Y", "Joystick") key_name = key_name.replace("ABS_RX", "Joystick 2") key_name = key_name.replace("ABS_RY", "Joystick 2") key_name = key_name.replace("BTN_", "Button ") key_name = key_name.replace("KEY_", "") key_name = key_name.replace("REL_", "") key_name = key_name.replace("HWHEEL", "Wheel") key_name = key_name.replace("WHEEL", "Wheel") key_name = key_name.replace("_", " ") key_name = key_name.replace(" ", " ") result.append(key_name) return " + ".join(result) input-remapper-1.4.0/inputremapper/logger.py000066400000000000000000000217001417303655400212200ustar00rootroot00000000000000#!/usr/bin/python3 # -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings # Copyright (C) 2022 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 os import sys import shutil import time import logging import pkg_resources from datetime import datetime from inputremapper.user import HOME try: from inputremapper.commit_hash import COMMIT_HASH except ImportError: COMMIT_HASH = "" start = time.time() previous_key_debug_log = None def debug_key(self, key, msg, *args): """Log a spam message custom tailored to keycode_mapper. Parameters ---------- key : tuple of int anything that can be string formatted, but usually a tuple of (type, code, value) tuples """ # pylint: disable=protected-access if not self.isEnabledFor(logging.DEBUG): return global previous_key_debug_log msg = msg % args str_key = str(key) str_key = str_key.replace(",)", ")") spacing = " " + "-" * max(0, 30 - len(str_key)) if len(spacing) == 1: spacing = "" msg = f"{str_key}{spacing} {msg}" if msg == previous_key_debug_log: # avoid some super spam from EV_ABS events return previous_key_debug_log = msg self._log(logging.DEBUG, msg, args=None) logging.Logger.debug_key = debug_key LOG_PATH = ( "/var/log/input-remapper" if os.access("/var/log", os.W_OK) else f"{HOME}/.log/input-remapper" ) logger = logging.getLogger("input-remapper") def is_debug(): """True, if the logger is currently in DEBUG or DEBUG mode.""" return logger.level <= logging.DEBUG 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): super().__init__() self.file_color_mapping = {} # 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, g, b): return 16 + b + (6 * g) + (36 * r) def _word_to_color(self, word): """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): """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.""" name = sys.argv[0].split("/")[-1].split("-")[-1] return { "gtk": "GUI", "helper": "GUI-Helper", "service": "Service", "control": "Control", }.get(name, name) def _get_format(self, record): """Generate a message format string.""" debug_mode = is_debug() if record.levelno == logging.INFO and not debug_mode: # if not launched with --debug, then don't print "INFO:" return "%(message)s" if not debug_mode: color = self.level_based_colors[record.levelno] 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): """Overwritten format function.""" # pylint: disable=protected-access self._style._fmt = self._get_format(record) return super().format(record) handler = logging.StreamHandler() handler.setFormatter(ColorfulFormatter()) logger.addHandler(handler) logger.setLevel(logging.INFO) logging.getLogger("asyncio").setLevel(logging.WARNING) VERSION = "" EVDEV_VERSION = None try: VERSION = pkg_resources.require("input-remapper")[0].version EVDEV_VERSION = pkg_resources.require("evdev")[0].version except pkg_resources.DistributionNotFound as error: logger.info("Could not figure out the version") logger.debug(error) def log_info(name="input-remapper"): """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 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(debug): """Set the logging verbosity according to the settings object. Also enable rich tracebacks in debug mode. """ # pylint really doesn't like what I'm doing with rich.traceback here # pylint: disable=broad-except,import-error,import-outside-toplevel if debug: logger.setLevel(logging.DEBUG) try: from rich.traceback import install install(show_locals=True) logger.debug("Using rich.traceback") except Exception as error: # since this is optional, just skip all exceptions if not isinstance(error, ImportError): logger.debug("Cannot use rich.traceback: %s", error) else: logger.setLevel(logging.INFO) def add_filehandler(log_path=LOG_PATH): """Clear the existing logfile and start logging to it.""" try: log_path = os.path.expanduser(log_path) os.makedirs(os.path.dirname(log_path), exist_ok=True) if os.path.isdir(log_path): # used to be a folder < 0.8.0 shutil.rmtree(log_path) if os.path.exists(log_path): # the logfile should not be too long to avoid overflowing the storage with open(log_path, "r") as file: content = file.readlines()[-1000:] with open(log_path, "w") as file: file.truncate(0) file.writelines(content) file_handler = logging.FileHandler(log_path) file_handler.setFormatter(ColorfulFormatter()) logger.addHandler(file_handler) logger.info('Starting logging to "%s"', log_path) except PermissionError: logger.debug('No permission to log to "%s"', log_path) input-remapper-1.4.0/inputremapper/mapping.py000066400000000000000000000223121417303655400213740ustar00rootroot00000000000000#!/usr/bin/python3 # -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings # Copyright (C) 2022 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.""" import os import json import copy from evdev.ecodes import EV_KEY, BTN_LEFT from inputremapper.logger import logger from inputremapper.paths import touch from inputremapper.config import ConfigBase, config from inputremapper.key import Key from inputremapper.injection.macros.parse import clean def split_key(key): """Take a key like "1,2,3" and return a 3-tuple of ints.""" key = key.strip() if key.count(",") != 2: logger.error('Found invalid key: "%s"', key) return None ev_type, code, value = key.split(",") try: key = (int(ev_type), int(code), int(value)) except ValueError: logger.error('Found non-int in: "%s"', key) return None return key class Mapping(ConfigBase): """Contains and manages mappings and config of a single preset.""" def __init__(self): self._mapping = {} # a mapping of Key objects to strings self._changed = False # are there actually any keys set in the mapping file? self.num_saved_keys = 0 super().__init__(fallback=config) def __iter__(self): """Iterate over Key objects and their mappings.""" return iter(self._mapping.items()) def __len__(self): return len(self._mapping) def set(self, *args): """Set a config value. See `ConfigBase.set`.""" self._changed = True return super().set(*args) def remove(self, *args): """Remove a config value. See `ConfigBase.remove`.""" self._changed = True return super().remove(*args) def change(self, new_key, target, symbol, previous_key=None): """Replace the mapping of a keycode with a different one. Parameters ---------- new_key : Key target : string name of target uinput symbol : string A single symbol known to xkb or linux. Examples: KEY_KP1, Shift_L, a, B, BTN_LEFT. previous_key : Key or None the previous key If not set, will not remove any previous mapping. If you recently used (1, 10, 1) for new_key and want to overwrite that with (1, 11, 1), provide (1, 10, 1) here. """ if not isinstance(new_key, Key): raise TypeError(f"Expected {new_key} to be a Key object") if symbol is None or symbol.strip() == "": raise ValueError("Expected `symbol` not to be empty") if target is None or target.strip() == "": raise ValueError("Expected `target` not to be None") target = target.strip() symbol = symbol.strip() output = (symbol, target) if previous_key is None and self._mapping.get(new_key): # the key didn't change previous_key = new_key key_changed = new_key != previous_key if not key_changed and (symbol, target) == self._mapping.get(new_key): # nothing was changed, no need to act return self.clear(new_key) # this also clears all equivalent keys logger.debug('changing %s to "%s"', new_key, clean(symbol)) self._mapping[new_key] = output if key_changed and previous_key is not None: # clear previous mapping of that code, because the line # representing that one will now represent a different one self.clear(previous_key) self._changed = True def has_unsaved_changes(self): """Check if there are unsaved changed.""" return self._changed def set_has_unsaved_changes(self, changed): """Write down if there are unsaved changes, or if they have been saved.""" self._changed = changed def clear(self, key): """Remove a keycode from the mapping. Parameters ---------- key : Key """ if not isinstance(key, Key): raise TypeError(f"Expected key to be a Key object but got {key}") for permutation in key.get_permutations(): if permutation in self._mapping: logger.debug("%s cleared", permutation) del self._mapping[permutation] self._changed = True # there should be only one variation of the permutations # in the mapping actually def empty(self): """Remove all mappings and custom configs without saving.""" self._mapping = {} self._changed = True self.clear_config() def load(self, path): """Load a dumped JSON from home to overwrite the mappings. Parameters path : string Path of the preset file """ logger.info('Loading preset from "%s"', path) if not os.path.exists(path): raise FileNotFoundError(f'Tried to load non-existing preset "{path}"') self.empty() self._changed = False with open(path, "r") as file: preset_dict = json.load(file) if not isinstance(preset_dict.get("mapping"), dict): logger.error( "Expected mapping to be a dict, but was %s. " 'Invalid preset config at "%s"', preset_dict.get("mapping"), path, ) return for key, symbol in preset_dict["mapping"].items(): try: key = Key( *[ split_key(chunk) for chunk in key.split("+") if chunk.strip() != "" ] ) except ValueError as error: logger.error(str(error)) continue if None in key: continue if isinstance(symbol, list): symbol = tuple(symbol) # use a immutable type logger.debug("%s maps to %s", key, symbol) self._mapping[key] = symbol # add any metadata of the mapping for key in preset_dict: if key == "mapping": continue self._config[key] = preset_dict[key] self._changed = False self.num_saved_keys = len(self) def clone(self): """Create a copy of the mapping.""" mapping = Mapping() mapping._mapping = copy.deepcopy(self._mapping) mapping.set_has_unsaved_changes(self._changed) return mapping def save(self, path): """Dump as JSON into home.""" logger.info("Saving preset to %s", path) touch(path) with open(path, "w") as file: if self._config.get("mapping") is not None: logger.error( '"mapping" is reserved and cannot be used as config ' "key: %s", self._config.get("mapping"), ) preset_dict = self._config.copy() # shallow copy # make sure to keep the option to add metadata if ever needed, # so put the mapping into a special key json_ready_mapping = {} # tuple keys are not possible in json, encode them as string for key, value in self._mapping.items(): new_key = "+".join( [",".join([str(value) for value in sub_key]) for sub_key in key] ) json_ready_mapping[new_key] = value preset_dict["mapping"] = json_ready_mapping json.dump(preset_dict, file, indent=4) file.write("\n") self._changed = False self.num_saved_keys = len(self) def get_mapping(self, key): """Read the (symbol, target)-tuple that is mapped to this keycode. Parameters ---------- key : Key """ if not isinstance(key, Key): raise TypeError(f"Expected key to be a Key object but got {key}") for permutation in key.get_permutations(): existing = self._mapping.get(permutation) if existing is not None: return existing return None def dangerously_mapped_btn_left(self): """Return True if this mapping disables BTN_Left.""" if self.get_mapping(Key(EV_KEY, BTN_LEFT, 1)) is not None: values = [value[0].lower() for value in self._mapping.values()] return "btn_left" not in values return False input-remapper-1.4.0/inputremapper/migrations.py000066400000000000000000000163021417303655400221170ustar00rootroot00000000000000#!/usr/bin/python3 # -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings # Copyright (C) 2022 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""" import os import json import copy import shutil import pkg_resources from pathlib import Path from evdev.ecodes import EV_KEY, EV_REL from inputremapper.logger import logger, VERSION from inputremapper.user import HOME from inputremapper.paths import get_preset_path, mkdir, CONFIG_PATH from inputremapper.system_mapping import system_mapping from inputremapper.injection.global_uinputs import global_uinputs from inputremapper.injection.macros.parse import parse, is_this_a_macro def all_presets(): """Get all presets for all groups as list.""" preset_path = Path(get_preset_path()) presets = [] for folder in preset_path.iterdir(): if not folder.is_dir(): continue for preset in folder.iterdir(): if preset.suffix == ".json": presets.append(preset) return presets def config_version(): """Get the version string in config.json as packaging.Version object.""" config_path = os.path.join(CONFIG_PATH, "config.json") if not os.path.exists(config_path): return pkg_resources.parse_version("0.0.0") with open(config_path, "r") as file: config = json.load(file) if "version" in config.keys(): return pkg_resources.parse_version(config["version"]) return pkg_resources.parse_version("0.0.0") def _config_suffix(): """Append the .json suffix to the config file.""" deprecated_path = os.path.join(CONFIG_PATH, "config") config_path = os.path.join(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(): """Migrate the folder structure from < 0.4.0. Move existing presets into the new subfolder "presets" """ new_preset_folder = os.path.join(CONFIG_PATH, "presets") if os.path.exists(get_preset_path()) or not os.path.exists(CONFIG_PATH): return logger.info("Migrating presets from < 0.4.0...") groups = os.listdir(CONFIG_PATH) mkdir(get_preset_path()) for group in groups: path = os.path.join(CONFIG_PATH, group) if os.path.isdir(path): target = path.replace(CONFIG_PATH, new_preset_folder) logger.info('Moving "%s" to "%s"', path, target) os.rename(path, target) logger.info("done") def _mapping_keys(): """Update all preset mappings. Update all keys in mapping to include value e.g.: "1,5"->"1,5,1" """ if not os.path.exists(get_preset_path()): return # don't execute if there are no presets for preset in all_presets(): try: with open(preset, "r") as f: preset_dict = json.load(f) except json.decoder.JSONDecodeError: continue if "mapping" in preset_dict.keys(): mapping = copy.deepcopy(preset_dict["mapping"]) for key in mapping.keys(): if key.count(",") == 1: preset_dict["mapping"][f"{key},1"] = preset_dict["mapping"].pop(key) with open(preset, "w") as file: json.dump(preset_dict, file, indent=4) file.write("\n") def _update_version(): """Write the current version to the config file.""" config_file = os.path.join(CONFIG_PATH, "config.json") if not os.path.exists(config_file): return logger.info("Updating version in config to %s", VERSION) with open(config_file, "r") as file: config = json.load(file) config["version"] = VERSION with open(config_file, "w") as file: json.dump(config, file, indent=4) def _rename_config(): """Rename .config/key-mapper to .config/input-remapper.""" old_config_path = os.path.join(HOME, ".config/key-mapper") if not os.path.exists(CONFIG_PATH) and os.path.exists(old_config_path): logger.info("Moving %s to %s", old_config_path, CONFIG_PATH) shutil.move(old_config_path, CONFIG_PATH) def _find_target(symbol): """try to find a uinput with the required capabilities for the symbol.""" capabilities = {EV_KEY: set(), EV_REL: set()} if is_this_a_macro(symbol): capabilities = parse(symbol).get_capabilities() else: capabilities[EV_KEY] = {system_mapping.get(symbol)} if len(capabilities[EV_REL]) > 0: return "mouse" for name, uinput in 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(): """add the target field to each preset mapping""" if not os.path.exists(get_preset_path()): return # don't execute if there are no presets for preset in all_presets(): try: with open(preset, "r") as f: preset_dict = json.load(f) except json.decoder.JSONDecodeError: logger.info(f"invalid preset{preset}") continue if "mapping" not in preset_dict.keys(): continue changed = False for key, symbol in preset_dict["mapping"].copy().items(): if isinstance(symbol, list): continue target = _find_target(symbol) if target is None: target = "keyboard" symbol = f"{symbol}\n# Broken mapping:\n# No target can handle all specified keycodes" logger.info( "In preset '%s' setting '%s' as target for '%s'", preset, target, symbol ) symbol = [symbol, target] preset_dict["mapping"][key] = symbol changed = True if not changed: continue with open(preset, "w") as file: json.dump(preset_dict, file, indent=4) file.write("\n") def migrate(): """Migrate config files to the current release.""" v = config_version() if v < pkg_resources.parse_version("0.4.0"): _config_suffix() _preset_path() if v < pkg_resources.parse_version("1.2.2"): _mapping_keys() if v < pkg_resources.parse_version("1.3.0"): _rename_config() if v < pkg_resources.parse_version("1.4.0"): global_uinputs.prepare() _add_target() # add new migrations here if v < pkg_resources.parse_version(VERSION): _update_version() input-remapper-1.4.0/inputremapper/paths.py000066400000000000000000000060121417303655400210570ustar00rootroot00000000000000#!/usr/bin/python3 # -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings # Copyright (C) 2022 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 . """Path constants to be used.""" import os import shutil from inputremapper.logger import logger from inputremapper.user import USER, CONFIG_PATH def chown(path): """Set the owner of a path to the user.""" try: shutil.chown(path, user=USER, group=USER) except LookupError: # the users group was unknown in one case for whatever reason shutil.chown(path, user=USER) def touch(path, log=True): """Create an empty file and all its parent dirs, give it to the user.""" if 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) mkdir(os.path.dirname(path), log=False) os.mknod(path) chown(path) 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] mkdir(base, log=False) os.makedirs(path) chown(path) 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) def get_preset_path(group_name=None, preset=None): """Get a path to the stored preset, or to store a preset to.""" presets_base = os.path.join(CONFIG_PATH, "presets") if group_name is None: return presets_base 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. assert 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) def get_config_path(*paths): """Get a path in ~/.config/input-remapper/""" return os.path.join(CONFIG_PATH, *paths) input-remapper-1.4.0/inputremapper/presets.py000066400000000000000000000124151417303655400214310ustar00rootroot00000000000000#!/usr/bin/python3 # -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings # Copyright (C) 2022 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 . """Helperfunctions to find device ids, names, and to load presets.""" import os import time import glob import re from inputremapper.paths import get_preset_path, mkdir from inputremapper.logger import logger from inputremapper.groups import groups def get_available_preset_name(group_name, preset="new preset", copy=False): """Increment the preset name until it is available.""" if group_name is None: # endless loop otherwise raise ValueError("group_name may not be None") preset = preset.strip() if copy and not re.match(r"^.+\scopy( \d+)?$", preset): preset = f"{preset} copy" # find a name that is not already taken if os.path.exists(get_preset_path(group_name, preset)): # if there already is a trailing number, increment it instead of # adding another one match = re.match(r"^(.+) (\d+)$", preset) if match: preset = match[1] i = int(match[2]) + 1 else: i = 2 while os.path.exists(get_preset_path(group_name, f"{preset} {i}")): i += 1 return f"{preset} {i}" return preset def get_presets(group_name): """Get all presets for the device and user, starting with the newest. Parameters ---------- group_name : string """ device_folder = get_preset_path(group_name) mkdir(device_folder) paths = glob.glob(os.path.join(device_folder, "*.json")) presets = [ os.path.splitext(os.path.basename(path))[0] for path in sorted(paths, key=os.path.getmtime) ] # the highest timestamp to the front presets.reverse() return presets def get_any_preset(): """Return the first found tuple of (device, preset).""" group_names = groups.list_group_names() if len(group_names) == 0: return None, None any_device = list(group_names)[0] any_preset = (get_presets(any_device) or [None])[0] return any_device, any_preset def find_newest_preset(group_name=None): """Get a tuple of (device, preset) that was most recently modified in the users home directory. If no device has been configured yet, return an arbitrary device. Parameters ---------- group_name : string If set, will return the newest preset for the device or None """ # sort the oldest files to the front in order to use pop to get the newest if group_name is None: paths = sorted( glob.glob(os.path.join(get_preset_path(), "*/*.json")), key=os.path.getmtime ) else: paths = sorted( glob.glob(os.path.join(get_preset_path(group_name), "*.json")), key=os.path.getmtime, ) if len(paths) == 0: logger.debug("No presets found") return get_any_preset() group_names = groups.list_group_names() newest_path = None while len(paths) > 0: # take the newest path path = paths.pop() preset = os.path.split(path)[1] group_name = os.path.split(os.path.split(path)[0])[1] if group_name in group_names: newest_path = path break if newest_path is None: return get_any_preset() preset = os.path.splitext(preset)[0] logger.debug('The newest preset is "%s", "%s"', group_name, preset) return group_name, preset def delete_preset(group_name, preset): """Delete one of the users presets.""" preset_path = get_preset_path(group_name, preset) if not os.path.exists(preset_path): logger.debug('Cannot remove non existing path "%s"', preset_path) return logger.info('Removing "%s"', preset_path) os.remove(preset_path) device_path = get_preset_path(group_name) if os.path.exists(device_path) and len(os.listdir(device_path)) == 0: logger.debug('Removing empty dir "%s"', device_path) os.rmdir(device_path) def rename_preset(group_name, old_preset_name, new_preset_name): """Rename one of the users presets while avoiding name conflicts.""" if new_preset_name == old_preset_name: return None new_preset_name = get_available_preset_name(group_name, new_preset_name) logger.info('Moving "%s" to "%s"', old_preset_name, new_preset_name) os.rename( get_preset_path(group_name, old_preset_name), get_preset_path(group_name, new_preset_name), ) # set the modification date to now now = time.time() os.utime(get_preset_path(group_name, new_preset_name), (now, now)) return new_preset_name input-remapper-1.4.0/inputremapper/system_mapping.py000066400000000000000000000155421417303655400230070ustar00rootroot00000000000000#!/usr/bin/python3 # -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings # Copyright (C) 2022 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 re import json import subprocess import evdev from inputremapper.logger import logger from inputremapper.paths import get_config_path, touch 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" class SystemMapping: """Stores information about all available keycodes.""" def __init__(self): """Construct the system_mapping.""" self._mapping = None self._xmodmap = None self._case_insensitive_mapping = None def __getattribute__(self, wanted): """To lazy load system_mapping info only when needed. For example, this helps to keep logs of input-remapper-control clear when it doesnt need it the information. """ lazy_loaded_attributes = ["_mapping", "_xmodmap", "_case_insensitive_mapping"] for lazy_loaded_attribute in lazy_loaded_attributes: if wanted != lazy_loaded_attribute: continue if object.__getattribute__(self, lazy_loaded_attribute) is None: object.__setattr__(self, lazy_loaded_attribute, {}) object.__getattribute__(self, "populate")() return object.__getattribute__(self, wanted) def list_names(self, codes=None): """Return a list of all possible names in the mapping, optionally filtered by codes. 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): """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 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. xmodmap_dict = {} try: xmodmap = subprocess.check_output( ["xmodmap", "-pke"], stderr=subprocess.STDOUT ).decode() xmodmap = xmodmap self._xmodmap = re.findall(r"(\d+) = (.+)\n", xmodmap + "\n") xmodmap_dict = self._find_legit_mappings() if len(xmodmap_dict) == 0: logger.info("`xmodmap -pke` did not yield any symbol") except FileNotFoundError: logger.info( "Optional `xmodmap` command not found. This is not critical." ) except subprocess.CalledProcessError as e: logger.error('Call to `xmodmap -pke` failed with "%s"', e) # Clients usually take care of that, don't let the service do funny things. # Write this stuff into the input-remapper config directory, because # the systemd service won't know the user sessions xmodmap. path = get_config_path(XMODMAP_FILENAME) touch(path) with open(path, "w") as file: logger.debug('Writing "%s"', path) json.dump(xmodmap_dict, file, indent=4) for name, code in xmodmap_dict.items(): self._set(name, code) for name, ecode in evdev.ecodes.ecodes.items(): if name.startswith("KEY") or name.startswith("BTN"): self._set(name, ecode) self._set(DISABLE_NAME, DISABLE_CODE) def update(self, mapping): """Update this with new keys. Parameters ---------- mapping : dict 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, code): """Map name to code.""" self._mapping[str(name)] = code self._case_insensitive_mapping[str(name).lower()] = name def get(self, name): """Return the code mapped to the key.""" # the correct casing should be shown when asking the system_mapping # for stuff. indexing case insensitive to support old presets. 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): """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] return None def _find_legit_mappings(self): """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 # this mapping represents the xmodmap output, which stays constant system_mapping = SystemMapping() input-remapper-1.4.0/inputremapper/user.py000066400000000000000000000035231417303655400207220ustar00rootroot00000000000000#!/usr/bin/python3 # -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings # Copyright (C) 2022 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 os import getpass import pwd 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 def get_home(user): """Try to find the user's home directory.""" return pwd.getpwnam(user).pw_dir USER = get_user() HOME = get_home(USER) CONFIG_PATH = os.path.join(HOME, ".config/input-remapper") input-remapper-1.4.0/inputremapper/utils.py000066400000000000000000000151121417303655400211010ustar00rootroot00000000000000#!/usr/bin/python3 # -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings # Copyright (C) 2022 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 math import sys import evdev from evdev.ecodes import ( EV_KEY, EV_ABS, ABS_X, ABS_Y, ABS_RX, ABS_RY, EV_REL, REL_WHEEL, REL_HWHEEL, ) from inputremapper.logger import logger from inputremapper.config import BUTTONS # other events for ABS include buttons JOYSTICK = [ evdev.ecodes.ABS_X, evdev.ecodes.ABS_Y, evdev.ecodes.ABS_RX, evdev.ecodes.ABS_RY, ] # drawing table stylus movements STYLUS = [ (EV_ABS, evdev.ecodes.ABS_DISTANCE), (EV_ABS, evdev.ecodes.ABS_TILT_X), (EV_ABS, evdev.ecodes.ABS_TILT_Y), (EV_KEY, evdev.ecodes.BTN_DIGI), (EV_ABS, evdev.ecodes.ABS_PRESSURE), ] # a third of a quarter circle, so that each quarter is divided in 3 areas: # up, left and up-left. That makes up/down/left/right larger than the # overlapping sections though, maybe it should be 8 equal areas though, idk JOYSTICK_BUTTON_THRESHOLD = math.sin((math.pi / 2) / 3 * 1) PRESS = 1 # D-Pads and joysticks can have a second press event, which moves the knob to the # opposite side, reporting a negative value PRESS_NEGATIVE = -1 RELEASE = 0 def sign(value): """Return -1, 0 or 1 depending on the input value.""" if value > 0: return 1 if value < 0: return -1 return 0 def classify_action(event, abs_range=None): """Fit the event value to one of PRESS, PRESS_NEGATIVE or RELEASE A joystick that is pushed to the very side will probably send a high value, whereas having it close to the middle might send values close to 0 with some noise. A value of 1 is usually noise or from touching the joystick very gently and considered in resting position. """ if event.type == EV_ABS and event.code in JOYSTICK: if abs_range is None: logger.error( "Got %s, but abs_range is %s", (event.type, event.code, event.value), abs_range, ) return event.value # center is the value of the resting position center = (abs_range[1] + abs_range[0]) / 2 # normalizer is the maximum possible value after centering normalizer = (abs_range[1] - abs_range[0]) / 2 threshold = normalizer * JOYSTICK_BUTTON_THRESHOLD triggered = abs(event.value - center) > threshold return sign(event.value - center) if triggered else 0 # non-joystick abs events (triggers) usually start at 0 and go up to 255, # but anything that is > 0 was safe to be treated as pressed so far return sign(event.value) def is_key_down(action): """Is this action a key press.""" return action in [PRESS, PRESS_NEGATIVE] def is_key_up(action): """Is this action a key release.""" return action == RELEASE def is_wheel(event): """Check if this is a wheel event.""" return event.type == EV_REL and event.code in [REL_WHEEL, REL_HWHEEL] def will_report_key_up(event): """Check if the key is expected to report a down event as well.""" return not is_wheel(event) def should_map_as_btn(event, mapping, gamepad): """Does this event describe a button that is or can be mapped. If a new kind of event should be mappable to buttons, this is the place to add it. Especially important for gamepad events, some of the buttons require special rules. Parameters ---------- event : evdev.InputEvent mapping : Mapping gamepad : bool If the device is treated as gamepad """ if (event.type, event.code) in STYLUS: return False is_mousepad = event.type == EV_ABS and 47 <= event.code <= 61 if is_mousepad: return False if event.type == EV_ABS: if event.code == evdev.ecodes.ABS_MISC: # what is that even supposed to be. # the intuos 5 spams those with every event return False if event.code in JOYSTICK: if not gamepad: return False l_purpose = mapping.get("gamepad.joystick.left_purpose") r_purpose = mapping.get("gamepad.joystick.right_purpose") if event.code in [ABS_X, ABS_Y] and l_purpose == BUTTONS: return True if event.code in [ABS_RX, ABS_RY] and r_purpose == BUTTONS: return True else: # for non-joystick buttons just always offer mapping them to # buttons return True if is_wheel(event): return True if event.type == EV_KEY: # usually all EV_KEY events are allright, except for if event.code == evdev.ecodes.BTN_TOUCH: return False return True return False def get_abs_range(device, code=ABS_X): """Figure out the max and min value of EV_ABS events of that device. Like joystick movements or triggers. """ # since input_device.absinfo(EV_ABS).max is too new for (some?) ubuntus, # figure out the max value via the capabilities capabilities = device.capabilities(absinfo=True) if EV_ABS not in capabilities: return None absinfo = [ entry[1] for entry in capabilities[EV_ABS] if ( entry[0] == code and isinstance(entry, tuple) and isinstance(entry[1], evdev.AbsInfo) ) ] if len(absinfo) == 0: logger.warning( 'Failed to get ABS info of "%s" for key %d: %s', device, code, capabilities ) return None absinfo = absinfo[0] return absinfo.min, absinfo.max def get_max_abs(device, code=ABS_X): """Figure out the max value of EV_ABS events of that device. Like joystick movements or triggers. """ abs_range = get_abs_range(device, code) return abs_range and abs_range[1] def is_service(): return sys.argv[0].endswith("input-remapper-service") input-remapper-1.4.0/po/000077500000000000000000000000001417303655400151125ustar00rootroot00000000000000input-remapper-1.4.0/po/input-remapper.pot000066400000000000000000000155061417303655400206150ustar00rootroot00000000000000# 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: 2021-07-03 22:39+0200\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" #: data/input-remapper.glade:1128 msgid "." msgstr "" #: data/input-remapper.glade:1348 msgid "1, 2" msgstr "" #: data/input-remapper.glade:926 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 "" #: data/input-remapper.glade:898 msgid "About" msgstr "" #: data/input-remapper.glade:209 msgid "Apply" msgstr "" #: data/input-remapper.glade:403 msgid "Autoload" msgstr "" #: data/input-remapper.glade:1420 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 "" #: data/input-remapper.glade:482 data/input-remapper.glade:526 msgid "Buttons" msgstr "" #: data/input-remapper.glade:1286 msgid "CTRL + a, CTRL + x" msgstr "" #: data/input-remapper.glade:706 msgid "" "Click on a cell below and hit a key on your device. Click the \"Restore " "Defaults\" button beforehand." msgstr "" #: data/input-remapper.glade:228 msgid "Copy" msgstr "" #: data/input-remapper.glade:265 msgid "Delete" msgstr "" #: data/input-remapper.glade:96 msgid "Device" msgstr "" #: bin/input-remapper-gtk:58 msgid "Displays additional debug information" msgstr "" #: data/input-remapper.glade:214 msgid "Don't hold down any keys while the injection starts." msgstr "" #: data/input-remapper.glade:1213 msgid "Examples" msgstr "" #: data/input-remapper.glade:1604 msgid "Go Back" msgstr "" #: data/input-remapper.glade:483 data/input-remapper.glade:527 msgid "Joystick" msgstr "" #: data/input-remapper.glade:709 msgid "Key" msgstr "" #: data/input-remapper.glade:54 data/input-remapper.glade:1583 #: data/input-remapper.glade:1695 msgid "Input Remapper" msgstr "" #: data/input-remapper.glade:465 msgid "Left joystick" msgstr "" #: data/input-remapper.glade:948 msgid "Macros" msgstr "" #: data/input-remapper.glade:964 msgid "Macros allow multiple characters to be written with a single key-press." msgstr "" #: data/input-remapper.glade:723 msgid "Mapping" msgstr "" #: data/input-remapper.glade:480 data/input-remapper.glade:524 msgid "Mouse" msgstr "" #: data/input-remapper.glade:553 msgid "Mouse speed" msgstr "" #: data/input-remapper.glade:246 msgid "New" msgstr "" #: data/input-remapper.glade:298 msgid "Preset" msgstr "" #: data/input-remapper.glade:337 msgid "Rename" msgstr "" #: data/input-remapper.glade:120 msgid "Stop Injection" msgstr "" #: data/input-remapper.glade:509 msgid "Right joystick" msgstr "" #: data/input-remapper.glade:368 msgid "Save the entered name" msgstr "" #: data/input-remapper.glade:124 msgid "" "Shortcut: ctrl + del\n" "To give your keys back their original mapping." msgstr "" #: data/input-remapper.glade:1556 msgid "Shortcuts" msgstr "" #: data/input-remapper.glade:1458 msgid "" "Shortcuts only work while keys are not being recorded and the gui is in " "focus." msgstr "" #: data/input-remapper.glade:402 msgid "To automatically apply the preset after your login or when it connects." msgstr "" #: data/input-remapper.glade:1439 msgid "Usage" msgstr "" #: data/input-remapper.glade:843 msgid "Version unknown" msgstr "" #: data/input-remapper.glade:481 data/input-remapper.glade:525 msgid "Wheel" msgstr "" #: data/input-remapper.glade:860 msgid "" "You can find more information and report bugs at\n" "https://github.com/" "sezanzeb/input-remapper" msgstr "" #: data/input-remapper.glade:1248 msgid "a, a, a with 500ms pause" msgstr "" #: data/input-remapper.glade:1490 msgid "closes the application" msgstr "" #: data/input-remapper.glade:1478 msgid "ctrl + del" msgstr "" #: data/input-remapper.glade:1502 msgid "ctrl + q" msgstr "" #: data/input-remapper.glade:1514 msgid "ctrl + r" msgstr "" #: data/input-remapper.glade:1044 msgid "e" msgstr "" #: data/input-remapper.glade:1335 msgid "e(EV_REL, REL_X, 10)" msgstr "" #: data/input-remapper.glade:1104 msgid "executes the parameter as long as the key is pressed down" msgstr "" #: data/input-remapper.glade:1140 msgid "executes two actions behind each other" msgstr "" #: data/input-remapper.glade:1116 msgid "h" msgstr "" #: data/input-remapper.glade:1056 msgid "holds a modifier while executing the second parameter" msgstr "" #: data/input-remapper.glade:1020 msgid "k" msgstr "" #: data/input-remapper.glade:1298 msgid "k(1).h(k(2)).k(3)" msgstr "" #: data/input-remapper.glade:1235 msgid "k(1).k(2)" msgstr "" #: data/input-remapper.glade:1398 msgid "keeps scrolling down while held" msgstr "" #: data/input-remapper.glade:1080 msgid "m" msgstr "" #: data/input-remapper.glade:1273 msgid "m(Control_L, k(a).k(x))" msgstr "" #: data/input-remapper.glade:1152 msgid "mouse" msgstr "" #: data/input-remapper.glade:1372 msgid "mouse(right, 4)" msgstr "" #: data/input-remapper.glade:1311 msgid "moves the mouse cursor 10px to the right" msgstr "" #: data/input-remapper.glade:984 msgid "r" msgstr "" #: data/input-remapper.glade:1260 msgid "r(3, k(a).w(500))" msgstr "" #: data/input-remapper.glade:1526 msgid "refreshes the device list" msgstr "" #: data/input-remapper.glade:1092 msgid "repeats the execution of the second parameter" msgstr "" #: data/input-remapper.glade:1188 msgid "same as mouse" msgstr "" #: data/input-remapper.glade:1538 msgid "stops the injection" msgstr "" #: data/input-remapper.glade:1176 msgid "takes direction (up, left, ...) and speed as parameters" msgstr "" #: data/input-remapper.glade:1008 msgid "w" msgstr "" #: data/input-remapper.glade:996 msgid "waits in milliseconds" msgstr "" #: data/input-remapper.glade:1164 msgid "wheel" msgstr "" #: data/input-remapper.glade:1385 msgid "wheel(down, 1)" msgstr "" #: data/input-remapper.glade:1360 msgid "which keeps moving the mouse while pressed" msgstr "" #: data/input-remapper.glade:1323 msgid "writes 1 2 2 ... 2 2 3 while the key is pressed" msgstr "" #: data/input-remapper.glade:1032 msgid "writes a single keystroke" msgstr "" #: data/input-remapper.glade:1068 msgid "writes an event" msgstr "" #: data/input-remapper.glade:880 msgid "" "© 2021 Sezanzeb proxima@sezanzeb.de\n" "This program comes with absolutely no warranty.\n" "See the GNU General " "Public License, version 3 or later for details." msgstr "" input-remapper-1.4.0/po/it_IT.po000066400000000000000000000224541417303655400164710ustar00rootroot00000000000000# 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. # msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" "POT-Creation-Date: 2021-07-03 22:39+0200\n" "PO-Revision-Date: 2021-07-18 19:23+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" #: data/input-remapper.glade:1128 msgid "." msgstr "." #: data/input-remapper.glade:1348 msgid "1, 2" msgstr "1, 2" #: data/input-remapper.glade:926 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." #: data/input-remapper.glade:898 msgid "About" msgstr "Informazioni" #: data/input-remapper.glade:209 msgid "Apply" msgstr "Applica" #: data/input-remapper.glade:403 msgid "Autoload" msgstr "Caricamento automatico" #: data/input-remapper.glade:1420 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" #: data/input-remapper.glade:482 data/input-remapper.glade:526 msgid "Buttons" msgstr "Pulsanti" #: data/input-remapper.glade:1286 msgid "CTRL + a, CTRL + x" msgstr "CTRL + a, CTRL + x" #: data/input-remapper.glade:706 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." #: data/input-remapper.glade:228 msgid "Copy" msgstr "Copia" #: data/input-remapper.glade:265 msgid "Delete" msgstr "Cancella" #: data/input-remapper.glade:96 msgid "Device" msgstr "Dispositivo" #: bin/input-remapper-gtk:58 msgid "Displays additional debug information" msgstr "Visualizza ulteriori informazioni di debug" #: data/input-remapper.glade:214 msgid "Don't hold down any keys while the injection starts." msgstr "Non tenere premuto alcun tasto durante l'avvio dell'iniezione." #: data/input-remapper.glade:1213 msgid "Examples" msgstr "Esempi" #: data/input-remapper.glade:1604 msgid "Go Back" msgstr "Indietro" #: data/input-remapper.glade:483 data/input-remapper.glade:527 msgid "Joystick" msgstr "Joystick" #: data/input-remapper.glade:709 msgid "Key" msgstr "Tasto" #: data/input-remapper.glade:54 data/input-remapper.glade:1583 #: data/input-remapper.glade:1695 msgid "Input Remapper" msgstr "Input Remapper" #: data/input-remapper.glade:465 msgid "Left joystick" msgstr "Joystick sinistro" #: data/input-remapper.glade:948 msgid "Macros" msgstr "Macro" #: data/input-remapper.glade:964 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." #: data/input-remapper.glade:723 msgid "Mapping" msgstr "Mappatura" #: data/input-remapper.glade:480 data/input-remapper.glade:524 msgid "Mouse" msgstr "Mouse" #: data/input-remapper.glade:553 msgid "Mouse speed" msgstr "Velocità mouse" #: data/input-remapper.glade:246 msgid "New" msgstr "Nuovo" #: data/input-remapper.glade:298 msgid "Preset" msgstr "Preimpostato" #: data/input-remapper.glade:337 msgid "Rename" msgstr "Rinomina" #: data/input-remapper.glade:120 msgid "Stop Injection" msgstr "Ripristina impostazioni predefinite" #: data/input-remapper.glade:509 msgid "Right joystick" msgstr "Joystick destro" #: data/input-remapper.glade:368 msgid "Save the entered name" msgstr "Salva il nome inserito" #: data/input-remapper.glade:124 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." #: data/input-remapper.glade:1556 msgid "Shortcuts" msgstr "Scorciatoie" #: data/input-remapper.glade:1458 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:402 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." #: data/input-remapper.glade:1439 msgid "Usage" msgstr "Uso" #: data/input-remapper.glade:843 msgid "Version unknown" msgstr "Versione sconosciuta" #: data/input-remapper.glade:481 data/input-remapper.glade:525 msgid "Wheel" msgstr "" #: data/input-remapper.glade:860 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" #: data/input-remapper.glade:1248 msgid "a, a, a with 500ms pause" msgstr "a, a, a con pausa di 500 ms" #: data/input-remapper.glade:1490 msgid "closes the application" msgstr "chiude l'applicazione" #: data/input-remapper.glade:1478 msgid "ctrl + del" msgstr "ctrl + del" #: data/input-remapper.glade:1502 msgid "ctrl + q" msgstr "ctrl + q" #: data/input-remapper.glade:1514 msgid "ctrl + r" msgstr "ctrl + r" #: data/input-remapper.glade:1044 msgid "e" msgstr "e" #: data/input-remapper.glade:1335 msgid "e(EV_REL, REL_X, 10)" msgstr "e(EV_REL, REL_X, 10)" #: data/input-remapper.glade:1104 msgid "executes the parameter as long as the key is pressed down" msgstr "esegue il parametro fintanto che si tiene premuto il tasto" #: data/input-remapper.glade:1140 msgid "executes two actions behind each other" msgstr "esegue due azioni una dietro l'altra" #: data/input-remapper.glade:1116 msgid "h" msgstr "h" #: data/input-remapper.glade:1056 msgid "holds a modifier while executing the second parameter" msgstr "contiene un modificatore durante l'esecuzione del secondo parametro" #: data/input-remapper.glade:1020 msgid "k" msgstr "k" #: data/input-remapper.glade:1298 msgid "k(1).h(k(2)).k(3)" msgstr "k(1).h(k(2)).k(3)" #: data/input-remapper.glade:1235 msgid "k(1).k(2)" msgstr "k(1).k(2)" #: data/input-remapper.glade:1398 msgid "keeps scrolling down while held" msgstr "continua a scorrere verso il basso mentre si tiene premuto" #: data/input-remapper.glade:1080 msgid "m" msgstr "m" #: data/input-remapper.glade:1273 msgid "m(Control_L, k(a).k(x))" msgstr "m(Control_L, k(a).k(x))" #: data/input-remapper.glade:1152 msgid "mouse" msgstr "mouse" #: data/input-remapper.glade:1372 msgid "mouse(right, 4)" msgstr "mouse(destra, 4)" #: data/input-remapper.glade:1311 msgid "moves the mouse cursor 10px to the right" msgstr "sposta il cursore del mouse di 10 pixel a destra" #: data/input-remapper.glade:984 msgid "r" msgstr "r" #: data/input-remapper.glade:1260 msgid "r(3, k(a).w(500))" msgstr "r(3, k(a).w(500))" #: data/input-remapper.glade:1526 msgid "refreshes the device list" msgstr "aggiorna l'elenco dei dispositivi" #: data/input-remapper.glade:1092 msgid "repeats the execution of the second parameter" msgstr "ripete l'esecuzione del secondo parametro" #: data/input-remapper.glade:1188 msgid "same as mouse" msgstr "come al mouse" #: data/input-remapper.glade:1538 msgid "stops the injection" msgstr "ferma l'iniezione" #: data/input-remapper.glade:1176 msgid "takes direction (up, left, ...) and speed as parameters" msgstr "prende la direzione (su, sinistra, ...) e la velocità come parametri" #: data/input-remapper.glade:1008 msgid "w" msgstr "w" #: data/input-remapper.glade:996 msgid "waits in milliseconds" msgstr "in attesa (in millisecondi)" #: data/input-remapper.glade:1164 msgid "wheel" msgstr "" #: data/input-remapper.glade:1385 msgid "wheel(down, 1)" msgstr "" #: data/input-remapper.glade:1360 msgid "which keeps moving the mouse while pressed" msgstr "che continua a muovere il mouse mentre viene premuto" #: data/input-remapper.glade:1323 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" #: data/input-remapper.glade:1032 msgid "writes a single keystroke" msgstr "scrive una singola sequenza di tasti" #: data/input-remapper.glade:1068 msgid "writes an event" msgstr "scrive un evento" #: data/input-remapper.glade:880 msgid "" "© 2021 Sezanzeb proxima@sezanzeb.de\n" "This program comes with absolutely no warranty.\n" "See the GNU General " "Public License, version 3 or later for details." msgstr "" "© 2021 Sezanzeb proxima@sezanzeb.de\n" "Questo programma non ha assolutamente alcuna garanzia.\n" "Vedi il Generale GNU " "Licenza pubblica, versione 3 o successiva per i dettagli." input-remapper-1.4.0/po/sk_SK.po000066400000000000000000000222571417303655400164740ustar00rootroot00000000000000# Slovak translation of input-remapper. # Copyright (C) 2022. # This file is distributed under the same license as the input-remapper package. # Jose Riha , 2021. # msgid "" msgstr "" "Project-Id-Version: \n" "Report-Msgid-Bugs-To: \n" "POT-Creation-Date: 2021-06-28 21:18+0200\n" "PO-Revision-Date: 2021-06-28 21:22+0200\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" #: data/input-remapper.glade:1128 msgid "." msgstr "." #: data/input-remapper.glade:1348 msgid "1, 2" msgstr "1, 2" #: data/input-remapper.glade:926 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." #: data/input-remapper.glade:898 msgid "About" msgstr "O programe" #: data/input-remapper.glade:209 msgid "Apply" msgstr "PoužiÅ¥" #: data/input-remapper.glade:403 msgid "Autoload" msgstr "Automatické naÄítanie" #: data/input-remapper.glade:1420 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" #: data/input-remapper.glade:482 data/input-remapper.glade:526 msgid "Buttons" msgstr "TlaÄidlá" #: data/input-remapper.glade:1286 msgid "CTRL + a, CTRL + x" msgstr "CTRL + a, CTRL + x" #: data/input-remapper.glade:706 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é\"." #: data/input-remapper.glade:228 msgid "Copy" msgstr "KopírovaÅ¥" #: data/input-remapper.glade:265 msgid "Delete" msgstr "OdstrániÅ¥" #: data/input-remapper.glade:96 msgid "Device" msgstr "Zariadenie" #: data/input-remapper.glade:214 msgid "Don't hold down any keys while the injection starts." msgstr "Nedržte stlaÄené žiadne tlaÄidlá kým prebieha injektáž." #: data/input-remapper.glade:1213 msgid "Examples" msgstr "Príklady" #: data/input-remapper.glade:1604 msgid "Go Back" msgstr "PrejsÅ¥ späť" #: data/input-remapper.glade:483 data/input-remapper.glade:527 msgid "Joystick" msgstr "Joystick" #: data/input-remapper.glade:709 msgid "Key" msgstr "Kláves" #: data/input-remapper.glade:54 data/input-remapper.glade:1583 #: data/input-remapper.glade:1695 msgid "Input Remapper" msgstr "MapovaÄ klávesov" #: data/input-remapper.glade:465 msgid "Left joystick" msgstr "Ľavý joystick" #: data/input-remapper.glade:948 msgid "Macros" msgstr "Makrá" #: data/input-remapper.glade:964 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." #: data/input-remapper.glade:723 msgid "Mapping" msgstr "Mapovanie" #: data/input-remapper.glade:480 data/input-remapper.glade:524 msgid "Mouse" msgstr "MyÅ¡" #: data/input-remapper.glade:553 msgid "Mouse speed" msgstr "CitlivosÅ¥ myÅ¡i" #: data/input-remapper.glade:246 msgid "New" msgstr "Nový" #: data/input-remapper.glade:298 msgid "Preset" msgstr "Prednastavenie" #: data/input-remapper.glade:337 msgid "Rename" msgstr "PremenovaÅ¥" #: data/input-remapper.glade:120 msgid "Stop Injection" msgstr "ObnoviÅ¥ predvolené" #: data/input-remapper.glade:509 msgid "Right joystick" msgstr "Pravý joystick" #: data/input-remapper.glade:368 msgid "Save the entered name" msgstr "UložiÅ¥ zadané meno" #: data/input-remapper.glade:124 msgid "" "Shortcut: ctrl + del\n" "To give your keys back their original mapping." msgstr "" "Skratka: ctrl + del\n" "Pre návrat na pôvodné mapovanie klávesov." #: data/input-remapper.glade:1556 msgid "Shortcuts" msgstr "Skratky" #: data/input-remapper.glade:1458 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:402 msgid "To automatically apply the preset after your login or when it connects." msgstr "" "Na automatické použitie prednastavenia po prihlásení alebo pripojení " "zariadenia." #: data/input-remapper.glade:1439 msgid "Usage" msgstr "Použitie" #: data/input-remapper.glade:843 msgid "Version unknown" msgstr "Neznáma verzia" #: data/input-remapper.glade:481 data/input-remapper.glade:525 msgid "Wheel" msgstr "Koliesko" #: data/input-remapper.glade:860 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" #: data/input-remapper.glade:1248 msgid "a, a, a with 500ms pause" msgstr "a, a, a s 500ms oneskorením" #: data/input-remapper.glade:1490 msgid "closes the application" msgstr "zavrie aplikáciu" #: data/input-remapper.glade:1478 msgid "ctrl + del" msgstr "ctrl + del" #: data/input-remapper.glade:1502 msgid "ctrl + q" msgstr "ctrl + q" #: data/input-remapper.glade:1514 msgid "ctrl + r" msgstr "ctrl + r" #: data/input-remapper.glade:1044 msgid "e" msgstr "e" #: data/input-remapper.glade:1335 msgid "e(EV_REL, REL_X, 10)" msgstr "e(EV_REL, REL_X, 10)" #: data/input-remapper.glade:1104 msgid "executes the parameter as long as the key is pressed down" msgstr "vykoná parameter, kým je kláves stlaÄený" #: data/input-remapper.glade:1140 msgid "executes two actions behind each other" msgstr "vykoná dve akcie za sebou" #: data/input-remapper.glade:1116 msgid "h" msgstr "h" #: data/input-remapper.glade:1056 msgid "holds a modifier while executing the second parameter" msgstr "poÄas vykonávania druhého parametra je stlaÄený modifikátor" #: data/input-remapper.glade:1020 msgid "k" msgstr "k" #: data/input-remapper.glade:1298 msgid "k(1).h(k(2)).k(3)" msgstr "k(1).h(k(2)).k(3)" #: data/input-remapper.glade:1235 msgid "k(1).k(2)" msgstr "k(1).k(2)" #: data/input-remapper.glade:1398 msgid "keeps scrolling down while held" msgstr "kým je držaný aktivuje sa skrolovanie smerom nadol" #: data/input-remapper.glade:1080 msgid "m" msgstr "m" #: data/input-remapper.glade:1273 msgid "m(Control_L, k(a).k(x))" msgstr "m(Control_L, k(a).k(x))" #: data/input-remapper.glade:1152 msgid "mouse" msgstr "mouse" #: data/input-remapper.glade:1372 msgid "mouse(right, 4)" msgstr "mouse(right, 4)" #: data/input-remapper.glade:1311 msgid "moves the mouse cursor 10px to the right" msgstr "posunie kurzor myÅ¡i o 10 pixelov doprava" #: data/input-remapper.glade:984 msgid "r" msgstr "r" #: data/input-remapper.glade:1260 msgid "r(3, k(a).w(500))" msgstr "r(3, k(a).w(500))" #: data/input-remapper.glade:1526 msgid "refreshes the device list" msgstr "aktualizuje zoznam zariadení" #: data/input-remapper.glade:1092 msgid "repeats the execution of the second parameter" msgstr "zopakuje spustenie druhého parametra" #: data/input-remapper.glade:1188 msgid "same as mouse" msgstr "tie isté ako pri myÅ¡i" #: data/input-remapper.glade:1538 msgid "stops the injection" msgstr "zastaví injektáž" #: data/input-remapper.glade:1176 msgid "takes direction (up, left, ...) and speed as parameters" msgstr "prijíma smer (hore, vľavo, ...) a rýchlosÅ¥ ako parametre" #: data/input-remapper.glade:1008 msgid "w" msgstr "w" #: data/input-remapper.glade:996 msgid "waits in milliseconds" msgstr "Äakanie (v milisekundách)" #: data/input-remapper.glade:1164 msgid "wheel" msgstr "wheel" #: data/input-remapper.glade:1385 msgid "wheel(down, 1)" msgstr "wheel(down, 1)" #: data/input-remapper.glade:1360 msgid "which keeps moving the mouse while pressed" msgstr "kým je stlaÄený, kurzor myÅ¡i sa bude posúvaÅ¥" #: data/input-remapper.glade:1323 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ý" #: data/input-remapper.glade:1032 msgid "writes a single keystroke" msgstr "zapíše jedno stlaÄenie klávesu" #: data/input-remapper.glade:1068 msgid "writes an event" msgstr "zapíše udalosÅ¥" #: data/input-remapper.glade:880 msgid "" "© 2021 Sezanzeb proxima@sezanzeb.de\n" "This program comes with absolutely no warranty.\n" "See the GNU General " "Public License, version 3 or later for details." msgstr "" "© 2021 Sezanzeb proxima@sezanzeb.de\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." input-remapper-1.4.0/readme/000077500000000000000000000000001417303655400157315ustar00rootroot00000000000000input-remapper-1.4.0/readme/capabilities.md000066400000000000000000000162001417303655400207030ustar00rootroot00000000000000# 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)) ] } ``` input-remapper-1.4.0/readme/combination.png000066400000000000000000000121061417303655400207410ustar00rootroot00000000000000‰PNG  IHDRd‰”ÔÕŸsBITÛáOàþIDATxœíÝiXSWÂðsor ‘,( ! *"`‹K•‹vÚŠ£m§­ÚºÕ¥UqébŸŽN;ÖŽcí6ë;޶v¬N­í㾋;àJÕŠ¶€!„% $„›ÜäýpÛ”Q¤W&‘ÿïáCrÎÉɹ†ü=ç$‡JùÝCà×Ðþ„‚°A ÂAX€  a‚ ,@„Ÿ×¿Úï®~þE§#ÒöŒ÷÷nÚ />¯REBhš~áÅçü]ÞY«³{áÅç3çÎ*ø±Ðd4y Uª¨+?4hà±£ÙíõD¹9ÇkkkÛ«7(èg'O ë²ní§Çßcb2™S†ÝÛ²$exJËìhn·»};!‚rfqoÊІú†Â¢‘£R‡ öÁû+ìv;!D"‘LþԬ̙éi‰¤¨èò_—.Ñ¢ÒÒ«üŸ™õôäÁçΞ÷çèï\÷¦ -).zïodäßÏEÍxfú¹sç#"ºñ3‹„Ä„YsfNœuÏ®=ùóëùòge΢(ŠòÔÔ)b†YòÆÒOþ½îñqK¥¡~å@°†…6F3g~fö±ƒ¡š/éÖå·CûåëkkëJKJ96ôÞ¡gNŸ‰Oè+—Ë !q}ã†ùáÒ~øަ¨“'O¥¤ü´IžrâÄIþ Ì»zµììwgM&saA‘®¢B­Qóå'Oœ,,(4Ö¿^¿Q" ¹ëî»Ú.÷:ž{übþ¥zsý®»•J…2\)“… I¼qý7Õ†ê«WËvlÛÑñ×Ý)ˆý=€[Ôï®»:’–>êxî‰jC5!D¥RÑ4½tÙ¾H$6ëjkëÊËÊïrOöÑì{ºð}>æ¢íÔ‰Ó?òT*u¹\ÉÉC–.YvwÒ/ïðÄ~‰c­R…JCCCC†áË« 5ü –e« Õ‘‘=Ú.÷ªÒWñ7ø¥hhhhhx8!D¯×óåúŸ€‚5,8¸mËöðððÙ™3ßyë=·ÛMQ”ÇãYö··97Ç·á8Žræt^ròàì£Ùƒ ܺe›_GÝ)ÔÕÕ•—•'ÿfˆÃáÐétF£Ñ[Õ½{÷_zaÿÞ¬Mßnnjjšÿì\o•HôË$WÌ0,˶]îÕjú{<ï†7çâ|¾& $x—!·‡²þËõ2¹ü‘G&„ÔT×BÂdaÆ:#ÿSo®'„äÉKHLÐh5Ý"ºå_¸èßaw'OœJ–2lxʉã'[–ÇÆÅZ-Ö­[¶U”W˜Œ&…Rá­ŠŽŽæo0 Ó£Gw~Ÿ²ò6ëŒEy8‘Q‘¾_à žÝîX·vݘ‡FÇö‰µZ­gNçMzjŸ¸>ááá‰ýùÙ¯Éd.»Zþ‡G¹˜éúÿ— #äÉ‹ëÛ'±_â5ÛÉ|@Ä'ÄGDDL˜ô¤T*õVÝwÿð»“îêÚµëÄÉ,ë?´]Þ«Õzáûü‰“&D©¢´Zíccm߫봂uâuårñ¾=ûgeÎ|sɲÿþçó''Œ~Á³R©´®Î¸éÛM|›3§ÏLš2ñ“¯óïP;›­ñÒÅœN'¿àU|¥øàC/¼øǹf÷üü7—.³ÙÕõœ¹³ùc Û9wvNvî?üØjmÖþYû0!LϘ˜iÓ§Z,–ìc9í= ŒÀ×nä#céU±äŸòh+-"„ÈÝÜkU¬«yvDœ=è2äï¿;)éã5k½ÇÞº\®¦¦¦êêê¯7l¤EtlŸ>„lߺãš3òn–Ëå²Ûí–ËÅ‹— U*U;\ܪvyM;³b&´·«y‘E¯ðpr7·Ð¢u5—Š%¾÷ˆa‘Ô?iÌC£?úpÍf»¾–ŸhÐ4MÙ·wßÙ³ç(Ú×shx="{Äöé]Pðë§iBÇiß×´Z%SéD!Ž]Ø _hÑk9V' Y)ö½ç€[†¨5ê‘£R9ÆŸŠ~ ©Tšž‘Æq\Qaѯv5``ÿiÓ§B$I¿~‰Ç9ìŽ7/¹¦YzFÚˆÔÅb1EÑ›7mÆIëÔ¬´h¹BÍÇ!¤JĬPF[)‘ï=\X$&&dË‘úà©“§kj~É‹ôŒ´‘£R%IÙÕ²W­¹æÄÝV½ûÎß !“§LÊ;wùÊ•Vw.s²s>"‹5õø'Æq.îøñíxE~Õns´€ ‹#‡îÚ¹[©TNzÚÿXá=5;';÷Сö›8´’eY“ÑÄß°X­üíëÙív¾ª¦ºF©TfŒNGX@ðâ÷)øÕ!D˱/5T-W¨ùýN_ÜžÿŸÿ7¿•Éd£ÇüÞ[n·ÛÍ&sGoKÊOÁhµŠOŠå õr…Z' Ñrìk•ï=\XðÇçŸ}‘ž‘Ö»w/ß{[ûñ'7úÜ”"‹¥R©L&Kì—˜:jö, x‰ˆ'ÖÕ\,–ðS ~ÿ¢T,‰u5ûþV¸eˆWIIÉÁ¬ƒÓgLã÷:NzFZzFšÇ㩯¯ÿ.ï,ÿ•0€`ÄêšïSXiÑ[Jm»tNH×.À-@—!h ÂAX€  a‚ ,@„‚°A ÂAX€  a‚ ,@„‚°A ÂAX€  ¸MÂÃÃÿµúŸR©”²èå… ‰ Âû׿-8h€ðrè{ÀÌ›?§¼¼‚ÿƒý4MÏ™—édÙO×}6w^fRÿ$BˆÍfÓUèvlßU^^Î?D&“ÍΜéâ¸U+Wûsèð³–gÍAûbˆÛÙ1“€àžYŒblX—.Ÿÿ÷Kþ³¬ý^ùÓ«ÿ÷ÁJ“ÉüÜóóe²0BˆZ£^ôòý=Rø·­S ­øÈXúzƒNîæø»r7÷Zƒîc±ï=ßÌÂëï¿;)iùû8N¾Äår555555}½aãäÁ±}úä_È0`Àö­; äßwBJ¥ò©©Sâú¦íÛ¶{Ëÿú·Å[6o½ð}~êÈ©©#dr™ÉdÞ¹}çùóß÷ïûÈÖhÔ,ËfËÙ»gÿ¹\¶ðOT«£+u•Ö]UehùD!!!OL?xð=N§3';wÏî½­‚Ý3¡qNÇ"‹~¹RíñþÜÓR±Ä÷žƒ5,’ú'yhôŠþi³Ù®¯åQhš&„ìÛ»òÀƒ÷ßæ!dÒ”‰ #~ç­÷$’ Ÿ¼¦V«ÕŒóûW¯i¨¯éc2š !l3{äБ²²²îÝ»ÏnÞÅ‹—t:BHzFúú¯6댣ÒRç=;wé’eǵ|¢PIè{ïü]&“ÍÊœi6›O?y;¯4p¬’©ø€XØ ÷Ÿ{ºRí{ÏA¹ QkÔÏÌœq<÷DMuÍõµR©ôÑÇþÀq\QaÑíxÉd²¤¤»7mÚRSSSQ¡Û·/ëš!!Š¢lV[}}Cþ…‹•••„òòòóç¿7›ë/_¾R©«ŒŽVñOŸ:}¹è²ÉdÚ²y›T*m¹?Ú¥K—ää!ßlü¦®ÎxõjYNöñäß$ß¶Ë 4üù¦:Qˆ†cµ[%bV(£}?BéÌ"11!ûXΈÔO<]SóK^¤g¤•*‘HÊ®–}¸jÝn÷ã !"¢!D_©çïV ×4(--=ž{âÕ×^ù.ï쬵µu„ø„øŒŒ´¨¨¨Pi¨D"aÄ ß¸¦¦–¿ár¹jkk»GDxû‰ŒŠ¤iú/¯¿Êß‹ÅF£©#¯,¸PíÕQP†Å‘ÃGwíÜ­T*§?=­å¾zNvî¡C‡m,Ëúw„@!åñx¼{®«žÇãÙºe[NvnZú¨W_ûóºOþc¨2Ìv¶mÝÞd·Ïž=³Eg-;¦=ÄÓâ.åñxÞïÜÏ¿ îëž«ó»9~¢…B´ûRC¨º=å2„ÿýûfã·2™lô˜ß{Ëív»ÙdFR“ÑHÓt”*Š¿Ùj³ººº¯7lÜ·7ëþûïëÝ»—ÍjÛ¹s·NWi6™å ¹·™÷á ÃôèѽåÜ¡¶¦–f2šøŸúú†Žºª€·ÀZÅ'År…š_h9vµÊ÷žƒ2,x‡ãóϾHÏHëÝ»—¿Ç­°Zm—.ý0~üØ‘=ÔuËXçiµšäß$GDDt‹è£ih°˜Ìf¹B×7®[·nãÆ •zž’Ø/±k·®}¬©©©å†”Íf;{öܤ)“zÇöV*•ñ ñýú%Þ¦‹ 0"â‰u5‹%üT‚ß¿(Kb];¿ÕƒrâURRr0ëàôÓÞ}çïþ ´â«/¾š&F[[W§«Ðq§ÕjnÔ ð­’©ø€XØ ÷Ÿ{ºRí{Ïþ\†ˆÅ"¥B¡¯Ô;Žæf¶ÑÖxSm´Z ët Õ×?ªÞl¶ZmN§³¦¦F,3 #‰”J¥^_ŲlS“Ýd2…+•Þö6›­Þ\ïr¹hšn£@àãÏ7Õ‰B4«åØ*³BíûêÄ¿3‹ ¡ˆ£¹ùÚtíÖU¡µú¨æŸÛ»97!„¦i†aEÅÇ÷åË)ŠbNoû¦&;C"‘´Ñ QíÕ‘¿? ²‰ÙZ›¦©±)**J§Ó]_ëv·Ú¯çÊ•b·»M<ÿ³™zÃfOîæøeˆNBÑrìK Uü¡ê>öìÏe˲„¢B%mm½Ü¨Mµ¡F§«T( Å ¿pqm?„ˆÅ"'ëüé§µ)ƒÀfkµŠOŠå 5¿Ñrìk•ï=û3,\.—ÕbQ«£% Ã0­~Ϫ6,ËV jF$úõÈt¹\ µFÓ¥K†‡ÉÂd2Ù-7L"â‰u5‹%üT‚ß¿(Kb];¿Õý¼ Ñé*Õê踾}(BY,«ÕzSmŒF“B©TkÔ-?O½‘J]¥J¥êÕ«'-9YÖ`0øÒ q„šײÄJ‹ÞRjÛ¥sjDú¸véîlü N$ a‚ ,@„‚°A ÂAX€  a‚ ,@„‚°A ÂAX€  äÿ ×›²‘9öùIEND®B`‚input-remapper-1.4.0/readme/coverage.svg000066400000000000000000000020171417303655400202450ustar00rootroot00000000000000 coverage coverage 94% 94% input-remapper-1.4.0/readme/development.md000066400000000000000000000260541417303655400206040ustar00rootroot00000000000000# 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. ## Roadmap - [x] show a dropdown to select valid devices - [x] creating presets per device - [x] renaming presets - [x] show a mapping table - [x] make that list extend itself automatically - [x] read keycodes with evdev - [x] inject the mapping - [x] keep the system defaults for unmapped buttons - [x] button to stop mapping and using system defaults - [x] highlight changes and alert before discarding unsaved changes - [x] automatically load presets on login for plugged in devices - [x] make sure it works on wayland - [x] support timed macros, maybe using some sort of syntax - [x] add to the AUR, provide .deb file - [x] basic support for gamepads as keyboard and mouse combi - [x] executing a macro forever while holding down the key using `h` - [x] mapping D-Pad directions as buttons - [x] configure joystick purpose and speed via the GUI - [x] support for non-GUI TTY environments with a command to stop and start - [x] start the daemon in such a way to not require usermod - [x] mapping a combined button press to a key - [x] add "disable" as mapping option - [x] mapping joystick directions as buttons, making it act like a D-Pad - [x] mapping mouse wheel events to buttons - [x] automatically load presets when devices get plugged in after login (udev) - [x] map keys using a `modifier + modifier + ... + key` syntax - [x] inject in an additional device instead to avoid clashing capabilities - [x] don't run any GUI code as root for improved wayland compatibility - [x] advanced multiline editor - [ ] plugin support - [x] getting it into the official debian repo ## Tests ```bash sudo pip install coverage pylint inputremapper --extension-pkg-whitelist=evdev sudo pkill -f input-remapper sudo pip install . && coverage run tests/test.py coverage combine && coverage report -m ``` To read events, `evtest` is very helpful. Add `-d` to `input-remapper-gtk` to get debug output. Single tests can be executed via ```bash python3 tests/test.py test_paths.TestPaths.test_mkdir ``` Don't use your computer during integration tests to avoid interacting with the gui, which might make tests fail. There is also a run configuration for PyCharm called "All Tests" included. ## Writing Tests Tests are in https://github.com/sezanzeb/input-remapper/tree/main/tests https://github.com/sezanzeb/input-remapper/blob/main/tests/test.py patches some modules and runs tests. The tests need patches because every environment that runs them will be different. By using patches they all look the same to the individual tests. Some patches also allow to make some handy assertions, like the `write_history` of `UInput`. Test files are usually named after the module they are in. In the tearDown functions, usually one of `quick_cleanup` or `cleanup` should be called. This avoids making a test fail that comes after your new test, because some state variables might still be modified by yours. ## Releasing ssh/login into a debian/ubuntu environment ```bash ./scripts/build.sh ``` This will generate `input-remapper/deb/input-remapper-1.4.0.deb` ## Badges ```bash sudo pip install git+https://github.com/jongracecox/anybadge ./scripts/badges.sh ``` New badges, if needed, will be created in `readme/` and they just need to be commited. ## Files **gui** - `bin/input-remapper-gtk` the executable that starts the gui. It also sends messages to the service via dbus if certain buttons are clicked. - `bin/input-remapper-helper` provides information to the gui that requires root rights. Is stopped when the gui closes. - `data/input-remapper.policy` configures pkexec. By using auth_admin_keep the user is not asked multiple times for each task that needs elevated rights. This is done instead of granting the whole application root rights because it is [considered problematic](https://wiki.archlinux.org/index.php/Running_GUI_applications_as_root). - `data/input-remapper.desktop` is the entry in the start menu **cli** - `bin/input-remapper-control` is an executable to send messages to the service via dbus. It can be used to start and stop injection without a GUI. The gui also uses it to run the service (if not already running) and helper, because by using one single command for both the polkit rules file remembers not to ask for a password again. **service** - `bin/input-remapper-service` executable that starts listening for commands via dbus and runs the injector when needed. It shouldn't matter how it is started as long as it manages to start without throwing errors. It usually needs root rights. - `data/input-remapper.service` starts input-remapper-service automatically on boot on distros using systemd. - `data/inputremapper.Control.conf` is needed to connect to dbus services started by systemd from other applications. **autoload** - `data/input-remapper-autoload.desktop` executes on login and tells the systemd service to stop injecting (possibly the presets of another user) and to inject the users autoloaded presets instead (if any are configured) - `data/input-remapper.rules` udev rule that sends a message to the service to start injecting for new devices when they are seen for the first time. **Example system startup** 1. systemd loads `input-remapper.service` on boot 2. on login, `input-remapper-autoload.desktop` is executed, which has knowledge of the current user und doesn't run as root 2.1 it sends the users config directory to the service 2.2 it makes the service stop all ongoing injectings 2.3 it tells the service to start loading all of the configured presets 3. a bluetooth device gets connected, so udev runs `input-remapper.rules` which tells the service to start injecting for that device if it has a preset assigned. Works because step 2 told the service about the current users config. Communication to the service always happens via `input-remapper-control` ## Permissions **gui** The gui process starts without root rights. It makes sure the daemon and helper are running via pkexec. **daemon** The daemon exists to keep injections alive beyond the lifetime of the user interface. Runs via root. Communicates via dbus. Either started via systemd or pkexec. **helper** The helper provides information to the user interface like events and devices. Communicates via pipes. It should not exceed the lifetime of the user interface because it exposes all the input events. Starts via pkexec. ## Unsupported Devices Either open up an issue or debug it yourself and make a pull request. You will need to work with the devices capabilities. You can get those using ``` sudo evtest ``` **It tries or doesn't try to map ABS_X/ABS_Y** Is the device a gamepad? Does the GUI show joystick configurations? - if yes, no: adjust `is_gamepad` to loosen up the constraints - if no, yes: adjust `is_gamepad` to tighten up the constraints Try to do it in such a way that other devices won't break. Also see readme/capabilities.md **It won't offer mapping a button** If `sudo evtest` shows an event for the button, try to modify `should_map_as_btn`. If not, the button cannot be mapped. ## How it works It uses evdev. The links below point to the 1.0.0 release, line numbers might have changed in the current main. 1. It grabs a device (e.g. /dev/input/event3), so that the key events won't reach X11/Wayland anymore [source](https://github.com/sezanzeb/input-remapper/blob/1.0.0/inputremapper/injection/injector.py#L197) 2. Reads the events from it (`evtest` can do it, you can also do `cat /dev/input/event3` which yields binary stuff) [source](https://github.com/sezanzeb/input-remapper/blob/1.0.0/inputremapper/injection/injector.py#L443) 3. Looks up the mapping if that event maps to anything [source](https://github.com/sezanzeb/input-remapper/blob/1.0.0/inputremapper/injection/keycode_mapper.py#L434) 4. Injects the output event in a new device that input-remapper created (another new path in /dev/input, device name is suffixed by "mapped") [source](https://github.com/sezanzeb/input-remapper/blob/1.0.0/inputremapper/injection/keycode_mapper.py#L242), [new device](https://github.com/sezanzeb/input-remapper/blob/1.0.0/inputremapper/injection/injector.py#L356) 5. Forwards any events that should not be mapped to anything in another new device (device name is suffixed by "forwarded") [source](https://github.com/sezanzeb/input-remapper/blob/1.0.0/inputremapper/injection/keycode_mapper.py#L247), [new device](https://github.com/sezanzeb/input-remapper/blob/1.0.0/inputremapper/injection/injector.py#L367) This stuff is going on as a daemon in the background ## How combinations are injected Here is an example how combinations are injected: ``` a -> x a + b -> y ``` 1. the `a` button is pressed with your finger, `a 1` arrives via evdev in input-remapper 2. input-remapper maps it to `x 1` and injects it 3. `b` is pressed with your finger, `b 1` arrives via evdev in input-remapper 4. input-remapper sees a triggered combination and maps it to `y 1` and injects it 5. `b` is released, `b 0` arrives at input-remapper 6. input-remapper remembered that it was the trigger for a combination and maps that release to `y 0` and injects it 7. the `a` button is released, `a 0` arrives at input-remapper 8. input-remapper maps that release to `x 0` and injects it ## Multiple sources, single UInput https://github.com/sezanzeb/input-remapper/blob/1.0.0/inputremapper/injection/injector.py This "Injector" process is the only process that injects if input-remapper is used for a single device. Inside `run` of that process there is an iteration of `for source in sources:`, which runs an event loop for each possible source for events. Each event loop has convenient access to the "context" to read some globals. Consider this typical example of device capabilities: - "BrandXY Mouse" -> EV_REL, BTN_LEFT, ... - "BrandXY Mouse" -> KEY_1, KEY_2 There are two devices called "BrandXY Mouse", and they report different events. Input-remapper creates a single uinput to inject all mapped events to. For example - BTN_LEFT -> a - KEY_2 -> b so you end up with a new device with the following capabilities "input-remapper BrandXY Mouse mapped" -> KEY_A, KEY_B while input-remapper reads from multiple InputDevices it injects the mapped letters into a single UInput. ## 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) input-remapper-1.4.0/readme/examples.md000066400000000000000000000136311417303655400200750ustar00rootroot00000000000000# 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 scroll `wheel(down, 10)` `wheel(up, 10)` - Mouse move `mouse(left, 1)` `mouse(right, 1)` `mouse(up, 1)` `mouse(down, 1)` ## Quick Overview of Macros - `k(BTN_LEFT)` a single mouse-click - `k(1).k(2)` 1, 2 - `r(3, k(a).w(500))` a, a, a with 500ms pause - `m(Control_L, k(a).k(x))` CTRL + a, CTRL + x - `k(1).h(k(2)).k(3)` writes 1 2 2 ... 2 2 3 while the key is pressed - `e(EV_REL, REL_X, 10)` moves the mouse cursor 10px to the right - `mouse(right, 4)` which keeps moving the mouse while pressed. Made out of `h(e(...))` internally - `wheel(down, 1)` keeps scrolling down while held - `set(foo, 1)` set ["foo"](https://en.wikipedia.org/wiki/Metasyntactic_variable) to 1 - `ifeq(foo, 1, k(x), k(y))` if "foo" is 1, write x, otherwise y - `h()` does nothing as long as your key is held down - `h(a)` holds down "a" as long as the key is pressed, just like a regular non-macro mapping - `if_tap(k(a), k(b))` writes a if the key is tapped, otherwise b - `if_tap(k(a), k(b), 1000)` writes a if the key is released within a second, otherwise b - `if_single(k(a), k(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(k(a), k(b)), k(c))` "a" if tapped twice, "b" if tapped once and "c" if held down long enough ## 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).h(space).set(foo, 0)` **Mouse** `middle` `if_eq($foo, 1, h(a), h(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** `h(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. `ifeq($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: - `m(Alt_L, k(tab)).w(250).k(KP_Enter).k(key_UP).w(150).m(Alt_L, k(tab))` ## 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. input-remapper-1.4.0/readme/history.md000066400000000000000000000144031417303655400177560ustar00rootroot00000000000000# Why does input-remapper not use xkb configs? **Initial target** You write a symbols file based on your specified mapping, and that's pretty much it. There were two mappings: The first one is in the keycodes file and contains "<10> = 10", which is super redundant but needed for xkb. The second one mapped "<10>" to characters, modifiers, etc. using symbol files in xkb. However, if you had one keyboard layout for your mouse that writes SHIFT keys on keycode 10, and one for your keyboard that is normal and writes 1/! on keycode 10, then you would not be able to write ! by pressing that mouse button and that keyboard button at the same time. This was quite mature, pretty much finished and tested. It still exists in the [first](https://github.com/sezanzeb/input-remapper/tree/first) branch **The second idea** was to write special keycodes known only to input-remapper (256 - 511) into the input device of your mouse in /dev/input, and map those to SHIFT and such, whenever a button is clicked. A mapping would have existed to prevent the original keycode 10 from writing a 1. But this device doesn't have the capabilities set for those keycodes, so it won't use them. At that time I didn't know about capabilities though. **The third idea** is to create a new input device that uses 8 - 255, just like other layouts, and input-remapper always tries to use the same keycodes for SHIFT as already used in the system default. The pipeline is like this: 1. A human thumb presses an extra-button of the device "mouse" 2. input-remapper uses evdev to get the event from "mouse", sees "ahh, it's a 10, I know that one and will now write 50 into my own device". 50 is the keycode for SHIFT on my regular keyboard, so it won't clash anymore with alphanumeric keys and such. 3. X has input-remappers configs for the input-remapper device loaded and checks in it's keycodes config file "50, that would be <50>", then looks into it's symbols config "<50> is mapped to SHIFT", and then it actually presses the SHIFT down to modify all other future buttons. 4. X has another config for "mouse" loaded, which prevents any system default mapping to print the overwritten key "1" into the session. But this is a rather complicated approach. The mapping of 10 -> 50 would have to be stored somewhere as well. It would make the mess of configuration files already needed for xkb even worse. This idea was not considered for long **Fourth idea**: Based on the second idea, instead of using keycodes greater than 255, use unused keycodes starting from 255, going down. For example pressing key 10 triggers input-remapper to write key 253 into the /dev device while mapping key 10 to nothing. This has the same problem, the device capabilities ignore many of those keycodes. 140 works, 145 won't, 150 works. **Fifth idea**: Instead of writing xkb symbol files, just disable all mouse buttons with a single symbol file. Input-remapper listens for key events in /dev and then writes the mapped keycode into a new device in /dev. For example, if 10 should be mapped to Shift_L, xkb configs would disable key 10 and input-remapper would write 50 into /dev, which is Shift_L in the system mapping. This sounds incredibly simple and makes me throw away tons of code. But somehow writing into the new /dev file makes the original keycode not mapped by xbk symbol files, and therefore leak through. In the previous example, it would still write '1', and then after that the other key. By adding a timeout single keys work, but holding down a button that is mapped to shift will (would usually have a keycode of 10, now triggers writing 50) write "!!!!!!!!!". Even though no symbols are loaded for that button. **The Sixth idea** The described problem is because the second device that starts writing an event.value of 2 will take control of what is happening. Following example: (KB = keyboard, example devices) 1. hold a on KB1: `a-1`, `a-2`, `a-2`, `a-2`, ... 2. hold shift on KB2: `shift-2`, `shift-2`, `shift-2`, ... No a-2 on KB1 happening anymore. The xkb symbols of KB2 will be used! So if KB2 maps shift+a to b, it will write b, even though KB1 maps shift+a to c! And if you reverse this, hold shift on KB2 first and then a on KB1, the xkb mapping of KB1 will take effect and write c! In the context of the fifth idea, KB1 would be the mouse, KB2 would be the new /dev device. The KB1 keycode comes first and is then realized as '!' when KB2 comes in and applies a different mapping. Which means in order to prevent "!!!!!!" being written while holding down keycode 10 on the mouse, which is supposed to be shift, the 10 of the input-remapper /dev node has to be mapped to none as well. But that would prevent a key that is mapped to "1", which translates to 10, from working. So instead of using the output from xmodmap to determine the correct keycode, use a custom mapping that starts at 255 and just offsets xmodmap by 255. The correct capabilities need to exist this time. Everything below 255 is disabled. This mapping is applied to input-remappers custom /dev node. However, if you try to map Shift to button 10 of your mouse, and use mouse-shift + keyboard-1, you need to press keyboard-1 again to do anything. I assume this is because: - mouse-10 down - inputremapper says: 50 down - xkb mapping: 10 is none. 50 is shift. - keyboard-10 down (down again? X/Linux ignores that) - keyboard-10 up - keyboard-10 down, "!" written **Seventh, final solution** By grabbing the mouse device (EVIOCGRAB) this won't happen. Since this prevents all the keycodes from doing stuff, no empty xkb symbols file is needed anymore. If 10 is mapped to 'a', it will figure out the keycode for 'a' in the system configuration (via setxkbmap -pke) and write it into a new device that has proper capabilities. So no xkb configurations are needed at all anymore. # How I would have liked it to be This solution would have made the macro thing impossible though setxkbmap -layout ~/.config/input-remapper/mouse -Foo Device3 config looks like: ``` 10 = a A 11 = Shift_L 282 = b # middle mouse ``` done. Without crashing X. Without printing generic useless errors. Without colliding with other devices using the same keycodes. Xkb also can't map 282 afaik. If it was that easy, an app to map keys would have already existed. The current solution supports a config like that in json format. input-remapper-1.4.0/readme/macros.md000066400000000000000000000113761417303655400175470ustar00rootroot00000000000000# 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 `k` 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. 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. > > You don't have to use quotes around the symbol constants. > > Shorthand: `k` > > ```c# > key(symbol: str) > ``` > > Examples: > > ```c# > key(symbol=KEY_A) > key(b).key(space) > ``` ### wait > Waits in milliseconds before continuing the macro > > Shorthand: `w` > > ```c# > wait(time: int) > ``` > > Examples: > > ```c# > wait(time=100) > wait(500) > ``` ### repeat > Repeats the execution of the second parameter a few times > > Shorthand: `r` > > ```c# > repeat(repeats: int, macro: Macro) > ``` > > Examples: > > ```c# > repeat(1, key(KEY_A)) > repeat(repeats=2, key(space)) > ``` ### modify > Holds a modifier while executing the second parameter > > Shorthand: `m` > > ```c# > modify(modifier: str, macro: Macro) > ``` > > Examples: > > ```c# > modify(Control_L, k(a).k(x)) > ``` ### hold > Executes the child macro repeatedly as long as the key is pressed down. > > If a symbol string like KEY_A is provided, it will hold down that symbol as > long as the key is pressed down. > > Shorthand: `h` > > ```c# > hold(macro: Macro | str) > ``` > > Examples: > > ```c# > hold(KEY_A) > hold(key(space)) > ``` ### mouse > Moves the mouse cursor > > ```c# > mouse(direction: str, speed: int) > ``` > > Examples: > > ```c# > mouse(up, 1) > mouse(left, 2) > ``` ### wheel > Injects scroll wheel events > > ```c# > wheel(direction: str, speed: int) > ``` > > Examples: > > ```c# > mouse(up, 10) > mouse(left, 20) > ``` ### event > Writes an event. Examples for `type`, `code` and `value` can be found via the > `sudo evtest` command > > Shorthand: `e` > > ```c# > event(type: str | int, code: str | int, value: int) > ``` > > Examples: > > ```c# > event(EV_KEY, KEY_A, 1) > 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. > > ```c# > set(variable: str, value: str | int) > ``` > > Examples: > > ```c# > set(foo, 1) > set(foo, "qux") > ``` ### if_eq > Compare two values and run different macros depending on the outcome. > > ```c# > if_eq(value_1: str | int, value_2: str | int, then: Macro | None, else: Macro | None) > ``` > > Examples: > > ```c# > 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)) > ``` ### 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 > > ```c# > if_tap(then: Macro | None, else: Macro | None, timeout: int) > ``` > > Examples: > > ```c# > 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. > > ```c# > if_single(then: Macro | None, else: Macro | None, timeout: int | None) > ``` > > Examples: > > ```c# > if_single(key(KEY_A), key(KEY_B)) > if_single(then=key(KEY_A), else=key(KEY_B)) > if_single(key(KEY_A), key(KEY_B), timeout=1000) > ``` ## 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"` input-remapper-1.4.0/readme/plus.png000066400000000000000000000112221417303655400174200ustar00rootroot00000000000000‰PNG  IHDRUUù{wšniCCPicc(‘u‘;KA…¿ïp¨zT½þß÷çðd²–ŽáIÝ0+ÂÓÂÑõŠ¡x[x@/¤2‡ÂS(|¥ôt‹Ÿç[ü®ØLÄgÁ©zjùœþÁzÁ, ûKŪÞ>º‰7[^˜—:$s‹8Âh¤©²J‘ A©eÉìo_¨é›cM<ºü j˜âÈSo@ÔªtÍJ͉ž•¯HMåþ;O+71Þêî Cדm¿@÷4ê¶ýudÛcp=ÂE¹ã_“œ¦>D¯w4ÿø6áì²£¥wá| Œ”™jJ.™Î\^O¡/ ý7лÜʪ½ÎÉ=$6䉮aoFe¿oåÂÙgîñ{çë pHYs  ÒÝ~üÊIDATx^í xTÕÇÿI&! AR$ì‚Ø  a •V´_)Y’°\ж ›m«V[jÑ"R\j‹JXÃ@\ØwA+dK „@XJ dÈÒ{nšI& ÌLx&ÿý¾a™wÞ¹÷ýîÿœ{î÷¼Úvx¨,$@$@†ð6Ä hU 0EÕ@˜tE$@UŽ 0EÕ@˜tE$@UŽ 0EÕ@˜tE$@UŽ 0EÕ@˜tE$@–ÿ^Ì$  ƒXî©×Ø WtC$@$Àé?Ç H€¢j Lº" Š*Ç H€¢j Lº" Š*Ç H€¢j Lº" Š*Ç H€¢j Lº" Š*Ç H€¢j Lº" Š*Ç H€¢j Lº"G^úÃüªGwGf<^… TyQýíÈg3(ÚÖ^^^÷ü<û»U¸[Øt# È™ùî øúùÚ¹mÔ¸>œý"{õ4²º›úÚ±}'ŽùÞmõ±"÷¨ò¢ZYÿ¨~¨U«>ž=Çý4Y£Ç(((@Û¶¡víëÔ¹#®^½êÖ6oܰ ÇŽsk¬Ì½,î­®rk“ÉÏ~Ö¯¿öäääØ*óõõE¿}ѱcäåçcÛÖmX°OxÖ+æ|2×f;hð@X|-ˆ+ñ^å¶šÞÝAà?ßü2>öîIÒÕÉŒ¦CÇpþö°]õ!-CðËî¿À½ÍîÅõë¹X»f-6mÜl³™òR¬?Ûñ@«ðÀ-‘™™‰ùóâûÿGŸÎOLÜ‹õ_®×>ÅþSUGë6­†+Jägÿë#?v\ ÂÐaƒÑ¬y3 _ßk×®áoS§!-í”;б ˜&Rm¬¦rߎ8g3ÎÚaøõð¡¨[·.^}ù5üõÏo 4´ ºü´ d*Ö! >>>6ûÐv¡Ø·wŸ‹iîéŽ$÷·ÐÂ$EDêüù ȱZíšÞR‰ê®»ñòï_Å¢‹0xè -l%‹Ì†ö%íÃÿ ~ý ÆŽmó+vŽŽ—fõÄSÑöC¦þå œ>u 1£l&Ïüö7z<¿0~"fÍ|VÕÞ—&ÿ‚êÁ΢Z£F Œý;x{{á®Úµìp×RÿèÒ‹ã—àòå˸pá¾øbÂÂÛáŠRd¶T‡” ê#P}èþ΃»ŒM«‰LEÃÂÛëÓ;uêˆÄ]‰ð÷¯fçnù²Ø£"I+û’¾Â¥K—poó{ílÖ¬úTÛȱ•+ôj×¾­ÍÆÑñÒíÿRG‰†E<·lÞ† hooo4iÚÛ¶íÀuëuìÿê®e_C}5NY<—€)¦ÿ#:éAþÍÁo0 ª?ö&îA¶|…BÙ’O;~Œ­üÔ‚EfæyýþλбS}nÛvmÕÀݼ¼<Ïí1¶¬b”¨îVhwµò.3ÁU «Ðªu+;"¾­Û´R_Ä®‡ÀÀêøQõêv6ùjܙާ§ŸAЋ£YGÇK_@ŽJAÉñúûûëÿŠïcG£C‡p¤œLAóûš# 0§ÒÒ*Æ€g¹…€)D55õ|4ûcäªØ#¿x‘EbIüR Pinn^Œ}I‹hé²S}À^TÛ\â>± ´m|ºz­[À³÷8tè0†?5L}y†"%%YYWÊ4Bòì5kÞ¥#PÉkÆN™è°¡~*g_RKŸàèxIûÒcôóÏ>Ç¡ƒUšª½Çï¿ûÏrÛí°‘4pSˆêwjº.Ó#)ñ cü„qزi+222~:]§ä[¾h1¡$]‰2NŸ:­-‚ƒƒñí·‡ÜŸ¹—€D~I*_."µPåKKÉvêÜ £G޵[è¼Y+%G\?XEå/9:îˆ@G…™ïÌÒ‘*KÕ `ŠœjIÔÉÉGðõƒÝ_¿õë6@«$We±XôÔ¯žšÚ™ˆ€ƒ*››[5zŽ­¬Ý*ZMM¯eŒ”.(лde? À_§ $§YºHŽ^ö¸J.è°!8wödwAQqtÜ•†VD‹÷Ù-¦ºr>mÝOÀ‘jilK/ÅŸ^²=æ°šò-[²\m¹ŽçÆŒR9²œ8¢„+aå*deeÒ¸ˆ.hÚ´ æÏ[pKþ‚ë#=ýŒS>†?1L?‚z劧ì21-šÒûxû ÀÇ~¥ÊÇÇy¿pÊïUå§þ¾ÈÇuTþ†§Ê¯Á¨PA?òHßž‘=ñÊŸ^FŸÇ{WÐ Oóµkׯ Ÿ×î¹qsñáÿÂÕ«ÙèÑÙ°æ櫪8ž^Þ^(½dÑQ»zß,yÔ§“{î~”_< ‘ORï½{ú°aÝeúH5 À_­hæð‘¾† ™Ûç¨Wï^8yâ$æÍokÄŠå+ì$ -=í°ð0%^zú½zÕäææj»1ãFëÇ>‡´ AhÛP-Ê æ/@ŠzôsTt”èNZDÂ;„cÇŽXºx©>çóµŸkŸíÚµÃë¯ýYwõ¦õT&¥gž¹îŒŒ³åVs_‹ûðÐC?Gã&õu¯_·;¶ï(×¶d´Z:eªãS“D©rñÇüÐÌšq™)x§N#·za¬úwƒÜœôó7¬ËL©¦¥Â:õ!Ê8“a4:r?‰šÚ´i-›·Þ´ò>}ûà'!?Á?ßÿÿ˜9 5BtL”Ý91ƒbpút:Þ™1gΜAŸ>…3˜Åñ‹±uËV$íM ã'hA-*÷ë«m§O—/_†3õT¥Àêê Ãç†îï¿¿’’öaÚo*ñ]‰~ýûBflKé•(“­L½_»!Ò,ÕP_‰è˜ÌT› Ê{³j7rˆÇYÓ‹ª³ hçÙ‚‚êèí>"l7*Õ«WG•]¶d©²Ë@æ¹L,Z¯#Ì’¢²yÓ¹Éñ];wAòŸŽÊ©©Ø°~#Ò•;[#Ÿ•uüÓ5k±ÿ«ý:Ï|ð냸üßËh¢¢Ö;½d©/¢uÛ„U"Ôt‹fª¨UŽUL?ý7 ýÜ^ÎäõêÕ«§™’’jklFF²³³\çÏŸ×ï[­VÛñl5ý¯V­šÃ‹+éÓÙz:uÁ ›ÚŒßí‘nú õ‘ÏD¾Ê^¼poOŸaçIX…„„¨FêÞsTž8PÃRã÷‰QT9Òªs*ªÌÏÏW"QWO¿Ë-7ø|ÜlÓºÚúîúõW ×+±?c“ú•“¼¤Œ{~,Î_¤v¤—ëvðA¨Q£>Syà””<7fÔ­VoŠóeQª(‡*S~)­ŽV©‰`ŠV9ý7Åp1ÿEȾÑCßB×®–¹XI H9£¶0I”Ö°aC›MPPŠìuÕ™’Ÿ___ß›šQ3m©ˆ¤9Ú‡µÇG³?Ɖ'ôK!‘çSµˆŠ Šˆ¥ä½QŠg7·Ê‹¢z«y¾Û$$¬B µ­„³šÒ#²W$F<óÝÉ!îÞµýôE•ƒ­]»¢Fá+•_ÌÌÌtª.œ×«æ²}+00°Üsœ©Çšc…¿¿ó+Ê>jƒ½ŸŸŸíu3a—\pÑn†ò(_,²X%õw{¸›Ý—ŒSLh¤vá¢éõk8®Vù‹¢Ò¢«¬ü7±^3l+§ÿ&@f½¤sgÏá­iGï>áéOËÏÖqìØ1ÌW[¢ŠÊòe+ðhÏ¥r޲càÀþ¯±fõj§‘$í֭݇[cbì½Ø¯~¦Y^qTì èóx$&&âÒÅKëðÁŸB^EEĦý­üÛôÅÍ™{C’7–i¿ìp¸rå wïñø_u9„c€AžÚ>528¤Œ'Ö7ê45 †b^Ý#c*T2´ •êl⤠¨§)J–/¿X§Þg•Z/“€'û©Êv±’¥ð>ªùÈS{Z7|±\ßXEn¤òH÷¾êWkòÕŸî²8GÀô‘ê¾íÃC+¸uO>õ$·[^Y¸`RÕv­ÛUŠïTUP&ÿ*ùXo%¸^^…÷Z¥°:×K¦Uç0Њ*€,yra>¤?ÆÈM`’uSåï/×.Á‚…‹·@nýÇHÕÙ>¤¨:KŠv$`2%£Ô_¤§ý’‡Žˆˆ@ll¬í~ªsæ/c´êBßST]€ES¨êÊæS‹ï§*yT™òoß± “'OÖËû©ºÞã¦_¨r Ï ;ƒ@Q¤*B*û€óÕ«ø™U…9ToµXöÞÁÊ pylint pylint 9.32 9.32 input-remapper-1.4.0/readme/screenshot.png000066400000000000000000001262501417303655400206220ustar00rootroot00000000000000‰PNG  IHDRnàÝt>æriCCPicc(‘u‘=KÃP†ß¶ŠR+uqˆPÅ¡…RAµ]Š”ZÁªKr›´B’†›)®‚‹CÁAtñkðè*¸*‚"ˆ8ùüZ¤Äs›B‹´'Üœ‡÷ž÷pï¹€?­3Ãéðl*)­æ×¤¾wøÄ˜™m-d2itŸGª¦xˆ‰^Ýë:Æ@Aµàë'žewˆç‰Ó[Ž%xx„•äñ q”Ó‰o…®xü&¸èñ—`žË.~ÑS*¶±ÒÆ¬Ä âi∡WXó<â&!Õ\Y¦P¯¹îï©ëÖÏ€À pm¶üešÓÜ7éµ–9Â;ÀåMKS€«]`ôÙ’¹Ü´üš|\ƒy`ø®{³jîãü ÈmÓ݇GÀÕ‡7þÿ¯h lk pHYs  ÒÝ~ü IDATx^ì`”U¶ÇOz#= !Hc4)bgÕ]ÝÕ]ëúÜêêÚuWWW]RAAšôzï%RH ¤÷ÞÞ97L˜$“Ì—0I&“ÿyoÞ“™[÷Ë÷}çžr­ˆÈ®m‡~öVoYO°²¢Vüž”Uþ§î_5|m¸0¾ «šTþ¡¬Œ²‰ÊvRIñ߯^¸låÔÑÁÆ%Ì»u»[["+àhJe%ÅÅEq)Éñ¶%¹#­;†.÷ní7ÈÊʪCIi +u0¥5åò o v…´±¶¡Ò²’¨Ô¤øÃ¶laÏ»’’bÐ0lPÍÚÚÖ›•¸ñ¶ìIÙŠµ8sÆ      p‹ëjnVeT`­#á‰k@@@@@Ìëj¢³Y›ßÈ0"}¬¸! . 0_edËz[' ˜ïÀ120_n¶64Á­õrq¢6¶vj  EEt:7Ÿ¶gfQf1gô×*¬³Ù–{LB@@@@@@LA`@+gZÐÆ‹­*G¦:Ø“|&x´¢…‰©t4;WSw¢³ñq–FàŽîÝéž{g“ƒƒ#åååÒªWÐåððFÅܹ3õéןV®X^c¿S§O§Aƒ‡ªßËÊJ)';‡".]¢-›7RnNN£Ž€€€¢´=éëS«[£(tRæsJÖ¬¼AqÓBe@@ÀÂ\8wŽÒRÓèɧŸ¡/?û”RSR}†¡ÝºiêóZ\¬Rîlmm©uëÖ4aòdºwî\Z²p¡¦ú(  E@Ü#ÅÒ¦‹EË+-#'ëÊ‘iù¥¥ähm­Ê,híEáy”UbÜm’cÜà*ÙX ‰~@@ÀÜ ,xâ :}òµoßžº÷쥆IëVÿL97-\ªÌ‰“Ô> €zr™ÂÂB:þ,mÛ¸‘ Ùw_ä™çž§ÇŽÑÞ°ÝS1j4õèÝ›>ÿø?4cÖ½Ô·õ[^½”õì½·Þ4ˆ§¸¸¨B±LLH >„”æÜ7Ÿììí©ˆû¶â=†D.­\(þzªc¬´ŒøÈ‘<·tú~É"VrµÅChZB@”6y~ìÚ½§Byóæç—©¥—‹s¥&ïòp£µ¬”•°Ò%¶½¬´Mæïô¥7'/1¦¸á7S¯Ú pýÚ5 c'%)™b¯ÆÐ±ÃGØêXifqì¾x`ï^¶„%S+:Û·m¡~°;cyÖ¬†±L‰lÔØ±táÜYÊÏË#kk2t(mÝ´™®ÆDSFFí`­”]N妓X¶¾Ü¿OYîN8A7âã)áÆ :|è€úîôÉòïÚûùUÔÉä¶Ž;ª~ÏÎÊ¢ãGR›6¾JaÔ‰µ•Ñ:~[±ì{žc %''5^´  B`Á#’¡OCv.ÏñcG«û·(o;w…QdTtƒtÙÚîÖ3B:˜áåN¢¼íc…M”¶Iüßs¼=*õÝÆ^[ôš  슧/¹œ¼ÄÑѱÒw‰ì6¨/×ââ”åîé®>SJP‡Žô××^çö9€­z‡ì£í›Ë]=<ÜÉÁщfÌœIÓf̨èÖÑɉÜÜÜ+þ”˜XiHù”œTY’ïÙ%R'¾mÛ² æhj×ÞìX!µá¸…rKŸ-•ÜŒEС¶2e%¥šÆ¡^6  õ#àïïGãÆŒ¢¬´É=ÆeX½ÓR²Ž#DqfK@âÆŒ‰(PúbÇÊŒ’²š×ÈúÈ5¶þ¼bY³âôà#RaAaE,݇¸[Þ` š¾ˆ"¦“â›±wú¿‹U®&qvq¡G‚ÎplÜWŸ}FùùyÔ±#-xüוªháPkÍã/®:Ô³#°pÑÒ&“(o£GPî’²¶gï~“%‰³½çÝ¢¤TÚŸ•MÓÙò&²65ƒøI`¢“D®c\Ê*+{Æ+ €€ù¶õ­„¡-'3)--Q1f"fÏçÔè‹OëJÿ–ø6«*™¶ ±-.*T˜I’±‚ùsb‘ÌŒtVä ” ¥¸6ê ò ê½Tbesrr¦Û¶*¥MÄÓÓ³Z{Æ8H…ÚÊ4Ôøë=qT $PƉ=R9“²ˆ$&‘%º˜7SO÷TέMCiûTnÍb×Èì‘"ùïÓìê¯/'¹ŒÑæP©¥%”h1$™ÇÐ#èÂÙ³äæîA£Ç£cGŽP1gl×É;º÷ CûPnnuéB]»u¥$=7Êô´4êݯyyûp½"¥t9kîôÉ“*¹ÈçŸ|LElI;¸/;žrùA}%R)Œ:¨Ä"ZÚ4Ôgzz*]¦™HŸ^ÞÞ4|ĨjEq ÆÊ4ÄøqÄï  ÐÒ¬Y÷K£Ly{f–:\[—YòßA·b§eï&ÈqÛ3²4 Y%5aB!°,]ù 5IÉ/òø“OÑÏ?ýX§¸îÛG¾mÚÒØßO`õ¦ŒÎŸ=C[6m¬€´sû6ºÛe=ýì³J¹Šçú[9.MÜÖ‰$ ñcËÙSÏüž³?fÒG￯ òÆ_Ös»ÏÑ„I“hãúõ´sûv>D¼€FŒEwO¿‡­}ùt-6–Ο9«©=C…$NOŽ˜>sGµY)Kâº5?Ó#=Q©¸1RØX™†½'ŽŠ  ·E ³¸„²+¤±¸¥ X[˜È °ôbàjê\ÊZùv.ðiãW¿ÀƒÛš*ƒ€4Gr6YìÕ«´íf‚æ8SŒY -eL1´  `^´rV‡kË9m†D,m¢´ÓxÄKrâµB¸Jš×c4  Ð,ˆ "9&sÐR,A@,‹ÀÑì\ go9\»ŸÓækW~\N{¡HÜ›¸Gj±´éSâfY×f     `²8{±¬mìpm­CeÛŽÐ å@@@@@@ ñ ”qŒ[@p¯?bÜŸ>z£’â GË­Š      EŠ[c‘F?      POPÜê Õ@@@@@@ ±@qk,Òè@@@@@êIŠ[=Á¡€€€€€4(nEý€€€€€€@= @q«'8TÆ"Å­±H£¨'(nõ‡j      ÐX ¸5iô     õ$Å­žàP @@@@@‹·Æ"~@@@@@@ ž ¸Õª€€€€€@c€âÖX¤Ñ€€€€€Ô“·z‚C5h,PÜ‹4úz€âVOp¨     EŠ[c‘F?      POPÜê Õ@@@@@@ ±@qk,Òè@@@@@êIŠ[=Á¡€€€€€4(nEý€€€€€€@= ØÖ³ªYÜœ, š ¦   М XYY‘­­ÙÙ;4çi`ì `rPÜLŽ´y6èÒ<ŽQƒ€€X’â"JI¾AE…PÞ,je1™Û%WÉÛ%ˆú    &#`ÃÖ6/_*f p‹7\    fEÀÖÖžÊÊÊÌjL 45(nM½è@@@@@Œ€â†K@@@@@Ìœ’“˜ù™íð8㓟+Ù†x’•‡£fYZ>G¤QÉ5ÎR ÷³]: @@Z"+*£Nvùt¥È©%Ns¶PÜ,`{ Öþ®äxW0Y³âVUìÆRIl&¬ ÒøœÆújœ­K¨›}9[•BqÃõÑl 4[Åí¾¤k׮ў];›-üæ8p›î>ä8+”ÈÖºF£šµ¿9=Þ‡ò~¼@¥S›ã4M>æé÷Ì 7èðÁƒ·ÝöÜùs)þÚ ÚvÛm¡°t¾6…bŸK-=>¨sHåääPüõë ²ä Ý~ƒ ºJ£¡P¯Q#ÈÇÏJŠŠ(-1‰Ní£KG5F÷Fû0¹â6uút4xèÍŽË(77—n\§cGÓ¹3gŒHkðð‹”– ¥@+/S”K›Ã¬.TfkÅÍÉôÄeçt¥œ/Ž“UB~ÝO›1JJŠiÃú ÕÊüö™ßÑžÝ{èÌéÓê77w75z4òÁœ¶”M±W¯Òö­Û©´´”ÆÝ9žúôé£ÊJ&ª¼ü|>&…NŸc®˜²23Ô;‚¼s¸¹»“··EE^ÑÖ`=KyxzQ‡ŽÉÆÆF½?E]á÷‹ mïéiéTÀgã™B Í×”í›bŒum£çÈá4fîÚ·f­üðcrõô  ÜO“yHñ¾pèp]›4yy“+n2Âk±WéÇå?µµ yò¤CB»Ò¬9ó¨c§N´~Í“LâÄÑ£&ih$À1mvS:VSÚ&:ÅÐÝN‘DIDëJƒh«]§[ ²òfÇ.•Å Ï›$æm6ÿ1ååæÑ/k×SAAºIzzz*¥M'ñññô˺õdÅûjÎ.NÔ­GºkÚ]Jiºqã†ÁÉ:³ÒvÿÃR+z{÷„© ;;êL³fÏbÅp+Ú7‚»t®ÖÏÙ:Ô׸"µKOÏ nݺQØ®0uc×—^½{Q:óå  –HÀÙº”]#³•kdUàÀ±ø,¹¬¼Ý(v „;*âÿ®Mºð»l1[`"¯\¡’âb²·w G'ÇŠ# Ü=<czZ*…äS·î=èÂùsüî¢]KNæ—5‰¡ùš²} ³NÍø…”¿¿Eñ;[iI eðæÿ‘-[iFç` ê~‡å*nÅü¢˜ž–¦&Ÿš’LW""(üÂzä±Çù"»@W._R¿Y±20bô8h¹´raÓmAUŒJ଒ìµÚÑ+ŸúZ%Ðò¿Õ +tÖ\—’´+†F“›—G™™YB±¬¸é[Ùj½˜µåšKšÜˆj’.]ºÐ ¾Áê+mº²ÇŽ£!ÇQÇàNtž•u=ñµ·ß>Z²x +“î4iÊd¾¹YÓvÞ•Ù²i³º9—••tÿÔµ;lÄpV`z³²¶])0î¼)‘ts'¬•QàvïÚ¥ÇQcFÓ¤©ShñÂEÆJW‚o¶çΞ㿷ž•·î¬¬ÅÆ^£¬*×ýà!C¨O¿¾´móJNN&??7a•ò’ëæm¬s777º{Ú4Z¿v÷G­\\È—•OסÌñŽnwІu(3+CÍÖÜYôíW Ë€fK@6òFŒYëø÷ïßO÷hð9f²–ÆoëÊ'5uloUF½³éTA+Ê(©þj,ŠO!»Šb••™Yí`ðè¨He@—I}WIQŠÚøúRLt oþæP+WW ì 6ÁSøY«“ÀÀ ÎÛ§,h¢ ±"(J“(]u‘ÀÀ@µzñÂyví³¥NüîÒÞ/€û7¬t´÷÷g÷N/eE”ùµnÓ†½äB釨±ÒéÇãoÍï]W)—•:›lbg°qÆÐ|«ŽUûüë6îº0©KY±°äx¸².!ñnW/\¤3{öÕ¥©+Û¨qš‰ lMðT“7JÙ™ßÊ/º²ƒ‘Á;;¶nQ¦É;ؽíô‰“¼kà¢v1tÒ“•¾S'Wûƒ‘ôÁÜÖÎmÛéô©S”ÄJÂ¥ðpÕ¦±~Œ¬…5\æá 7ýÿ ÏC«“¥Ÿ"'Ѻ”A´¥d"E”õ¯VŽÜíMBcÝêÕäççO¿zâq¾v†ð °UµvÅÿü™g¯>xþYêݯý´â§GrptP±p†¤° PíN‰‚¦“ìŽ)+±*GGEÓ¾½û¨'[Ùä&©Ell¬©/+Iû÷îç›ôEµC&1pY¬˜ŠˆµX,]â+. ¸iúðNžÔÓ*Öì/±~|S–M yôbk×é“'ÕëDþ[¸x—#T¿çΜ£“ÇNÐ`þ^«ÈnœH+›yÛš””D:Qù;”9KÌ¢<œd®{ÃöRk†»„híå@@Ì”€xcìݳ§ÆÑ5–Ò&(a7 ó¬„Å—Ôáýƒ•©®v¹dÃJœ!¹q™=R\•§Œ($vìcLÚ²µJ¼wĽQ\SØÒ&ïÂmÛµ¯T5›-\blÈçfyw¯±R‰rTÑ%ÉÏËWï.I¼1,ïÒ†D6œÛø¶aïµ8ŽyÏbÅ­ ,q\´TYì¤o_V:¯Ç]Wï)yùy”ΞLRN«h]Æ­µoS”3g6Eò;Øú/¾Rú‰9ˆ¶7MTâŽd7BD^„hÆÌ™4mÆŒŠ$I„››»2ÓF\ºÄ/š}Ôî…˜qåB_±ìûj£k…¼€Ç²E¯ªëÇDS³üfø»êÑl¡žÝi°{GŠv.ß5êз¤DPYjåÝ´R®hcBâ*¸ðëoT¬¤$ÿXðøctèÀ¡J™ÅrÃú_ToN|-…°R0ç¾9´êÇ•Ê[?©¼,)}¹CÝàÜÜ]9>î¦ßD-¹òŽ›¸@Ö”Õ©u¶V7T[[;²±-§gmmËñjÚo˜â2*n¥bu ã¤Þ‰“M$2‹¥['2QºD!Õ—ëœujàà7 ß°Dùݶ‡fÜ3]ÍM’£ˆ"(âæÖJíÔÝ9éNš0qBE7ò¸ŠB@@š?+}UË[c*m:Šò¶y¹Ð™òíl¨ƒmžDtTH2Ç´¹r‚‡*ñoòïÖœÈDâÞªŠXœÎ9Í2äÝÚ‡7l{)ëVMÏr‰³fÂÎíP×^¿ßúr¸…¼7輇òÙ«H_D‘±âÿ±w°'Q´Š$Ô ]Ò½CTmÃÞß/ø½"¨S * ªøY¾“wùÈøE©¬Ôeþuw}ÆRŸ:>~í©‡Ùüðîû¼É\=N²>mš¢N£*nmÂu_-kú"+W,¯{TÀA—"'Ù]rƬ{9ÅêÉ ÜÕ˜(Ž™3`¹Ù–Áüôú1HKo£,£ š«déM×IQØt"JZU—ʲ´Ê7}V¥¥%5ZªTƤÒênQl’OgUº—“‡Ä°+Î-¶˜Mûb1‘ÿ/ÊZ{6õ8Ö­Y[m™äZ“.ooƒK(ŠEyÌW¹O»ˆìRé‹-óæ/Ú.ƒ›õ íéI ÚœyóØÍá"-[ºŒýáóÉ/ÀŸæÝ7O[ÛUJ9uFeÛÜÇ» bm“Ì®UÝL­oZòÊ*=ÖÊ’²µ¥nËn¡ú"íÓ¼;Õƒ­æâjÙ@ZõÓÏ ­œÙFΚXÅm¶.»wõ‚€J  F ªòÖJ›þdc‹(¿Ôšºòn:åí<dz‰8r“ÖÖ…È1n67leUûF¥ÄÏËGò.„t U1o†Üý«¼*Tâ¯Þ”ô^ª>gÅkF‰‘ÞUµ. †(†"Ñ‘”Ãîú"Ö% û¨× nÖªÓüÍH1Òqh@W8+y]˜6ÆYÝl°·1¢îü"'&SIÍ.’)æVÒZ¹èõ?º 9’XD.ž`>w¢g¯Þ¼ƒÜà¤nLú±›\UÑÒÏmL«ÅT-»’^Í22¿úq ‘¹I•Ê•°_xYdy²C’ÆÊ•oÛ¶ÕÜ}ÙÚ$7Ôäš|ˆ»·ææVý pý¾D1ÑYz AÜj{÷îmÐí¡_ÿ~TÌÙ£Äÿ['>­++y­y¬¥Ó¦ XåUß±jŸâ&XÄmJò‘ªÒ¦­/9::²ûå^¥´‰xðá¾"®"=y~ÁÁÁJ©ªþ7’¥\8ÚUO;¿v”ÆIJD¹«ª¢&þþUEv9B‹.¤6¼¶:©˜‰×óòñR® úqG…€€XQÞp,›|#¦Í¹$¶°f×Éâ*”¢ÐÅ;Rd±sE†61 µŸUÞ ÇˆÈ{†^‚JÐ% Ó$鈾Hü·ÄÞK,¼Nœoõ/ß99»ð»TiÜ1¨ú»<ÓåÛ½“$]ÿSÂßË¸8öŠ©:ßjí×aþu{c”¿EWN•GeNÒ 7ÉÐ'YüÊÝÇÜ9!Aw•MnoØ.Ž âÔñ,ò2|pÿ^=vûÂËK\žX>…$z:tÿVÀÎ±ÌØ–Ëø«D,UÕw¾¨  `p"sÉy"¿õäl“UEÿ<·¬ÒêÁÆ#Çådç¨lÒâäëÛV=Û²9s£ˆ<Ã%³¤l¾JfHQ„ä}Tžsòß9üÜtãwY©W5Yˆ3{öÈ&¶zÿà†öC—Ì?kIÄV_ÆÒvÇÛI_r¼d ”n×Vn¼i›¦Æ,éý%‰l6Krq”9Ë»‹¡ùêES­ó¯ï²^Žd(¯£Å³!Ç£k»A7¿€@zöÅ?ªÝyÁ‹ã”¦ß/^Ìñ.åÇèD2êååÐN}~÷ô{”…AÒŸž×KA~òØqu ÷é“'ÔER“lÙ¸‘/¤)wÝ­‚GåPÄõkV«âZúi ØÍ½’M1d½ ‘:€›øÌ“bú&õ0}x |§‰ÿà­”ÒV~¥—d³{åæh²®Ai“2ò¾tÉRÉ™¨&q*}Ùu’€ÚKßx`ß­X¹’âRŽÑò§~úñMQ²ɵGË¿_®®1´c¿ñ_?ù›òñ±6•Ïd“ýu±V†Ö@2$-]¼”àÌǨ˜+Ùi’¸Wý´Š³:U>€ûøÑãäãÝš†-Îûa¥¬À]¢°ÝaMg´mûvôУ«›Û×ÔZUäø€b¶@壜y÷-‡¦m[$å‚J‘礉J×ÍdKÕÖ-[8N¯~®’Ò¯(‰¢€ža%¹&9|èÚLÇãqe ¦ÄÇI\ÜEÞDÑÉ~^ñ¼> [ ØÊQ {8¦M¬é"bi” í±Æññ 6œÎ8÷ؤ’”ˆìçä'bÙ“cÆß9Aý=K|à%æ†&WfC' ª{éøÜ< »€Ïr“ø·ª"–'1 H†H‰+.…-Kt©öo‹«‹:_­¨¸Ÿ¹§Uü›(HAå1æòžËG^I²}Iâ÷ •hßÃO½AI2yw6$²),›Í"]ïèFÑœ¦^ëÜUÛ»ÎÉÂd3Ù—ßY탔R&nŸiiåã“1”ð\%[¥-PÄJ«dÈ14ߪíkC¯{]ۗضþü2‰×Øâ×þI…uˆ3¬k_u-oå\àãëo<5N][FùfC 7'‹üƒ´eö+ õ «{ù€Â›Ê[M“,e¥­håErˆ±,789í:ÇÍíå숆#s™77k‡¸ÝÞ]8II?>[6KÅ2ÝÀq·ÛGmõåì3ÉêXžÑ±aÅÐ9k ÛcólÝ©U+šýÜï•Õñ§ÿ‡7ÚÍã]69!®°A,nÍs™0j-¬Â9æé«³T69€¬9öªŠ5­´¨„J"9.mk 9¤™¡YË$”±*«œeÒM¢ & Љ3NÊÛÊù|NDVÃÜ =,]¢†îGÚïHíòØókÉëoš%&(nf¹,æ=(ëDNš±$‚Š|l©¬#ï„ÉorRsz>YEg’mRI­î‘æ=;Œ@@@ %ð°)&q<œïN…¼)+nšsîäìHI·i¾ ¸5ßµkÚ‘óŽ]RQ’¬µÄ´5í Ñ;€€€”·ÈÆvl ö:«ƒ¸32Ó)cÿ!Í—·æ»vyX±lEôŠ.A@@,‘@øÅ[IÀj~ÑGCíV&Ðhç¸<€€€€€€@ý@q«7Ôh ÅœRß ¡ DÍ6WPÜšëÊaÜ    `DiKMN [ÛêgªYàt1%ÐL1nšQYvA9+   MM@,m¢´ÙÙ;4õPÐ?˜(nfµM3˜†>ܲif…^A@@@@ÀrÀUÒrÖ3°PPÜ,ta1-Ë!ÅÍrÖ3°PPÜ,ta1-Ë!ÅÍrÖ3°PPÜ,ta1-Ë!ÅÍrÖ3°PPÜ,ta1-Ë!ÅÍrÖ3°PPÜ,ta1-Ë!ÅÍrÖ3°PPÜ,ta1-Ë!ÅÍrÖ3°PPÜ,ta1-Ë!ÅÍrÖ3°PPÜ,ta1-Ë!ÅÍrÖ3°PPÜ,ta1-Ë!`+SÉLO±œa&      `a”âæÛ6Ц…é€€@Ë&PTX@A/lÙ0û&%³xÙÙ;4éÐ9€X ؘp‚«¤¥¬&æ     `± ¸YìÒbb     –BŠ›¥¬$æ     `± ¸YìÒbb     –BŠ›¥¬$æ     `± ¸YìÒbb     –BŠ›¥¬$æ     `± ¸YìÒbb     –BŠ›¥¬$æ     `± ¸YìÒbb     –BŠ›¥¬$æ     `± ¸YìÒbb     –BŠ›¥¬$æ     `± ¸YìÒbb     –BŠ›¥¬$æ     `± ¸YìÒbb     –BÀÖR&‚y€€˜'ÇGyÒ”ž­ÔàJˈ²óK)6µˆ¶œË¦½—së4èÞŽ4¦« }´5¥¢ž¡ïêÔh ëÏE¿Ú?×%‘§‹ =6Ò“ž]vƒ’²Š+~žÖÛ•¦÷uUß¿;Ç—Úº×üè½÷¿±T•WF^ ˆÉ§%ûÓ)“ÙéKkW[úì¡v´ìp­<šY‡™ (€€@s#Å­¹­Æ  ÍÀå„Bú׿²²"rs²¦±¬|=7Ñ›ndSDb¡æ ìèT­¬¡ï47X‚2ÞÿlK­T39»˜ ŠÊhH°=5Ö“^[›¤~%mþ`wzgc2唪ïmmËìnäëfKÿÝQ¹-ùMx}ÈÊ©—õ÷´£‡†¹Ó³ÌK×®®ó;»»ô=±[+Zu,“ÊX1†€€X&(n–¹®˜€˜Â’2J¼i…J`ÃPtrMìÑJ)6:ÅíãÚÑŽ 9ôóñ[–£™ýÜhDˆ3½°üýnœ½ÃEÍK¾ËdKÔq¶DUýו‚8»¿M`…ÆÃÙšndÓšYª}¼6³ í¹”KZÛѰÎÎêë³× è»R¹íÊ–-}˜Ået-­È ßÏv¤Ñ‡óÛÒ8çNîë·c½hWxŠÍWåx:E®°¸Ô`[ÂK”Z±NÊ|žgÅÍÁÎJ)ˆ"6ÖV4žûYÄ–¸'تÙ?ȉŽFç™Õºc0  ¦#ÅÍt,Ñ€€€bEšÐÍ…rØíO%­"–)[+b¦’«¤¡ïf±Ò6µ—«R¢SЍ[{å‚(©o)o FxÐJ¶T½òS[mè÷¼è!ôÙÎêV0-ãLË-¡/ÃÒè×£=© »1ú¸Úп”[ßnGJØÇT”7;VÖ ¨\qëž°<‘Çós¤I¬Cq»ʨ  æMŠ›y¯F A [;úþ×þj.b5Šc‹Õ«ì6˜ÎŠŽ©EÅR'1a#Ë-Pb½jïaK¢Ðé+n‘ìö¨‹ »ž^¬âîFu)·êÕ$¢þðdù\trÿÿâTüžˆÄíìâLsºÑ«k’*,dõ™§(k~ì*)n•¯äQ6[ét"ŠšôUÄš¬X÷Þ˜Õ†$æM?¾®>}¢€€€y€âfžë‚Q€€EwH‰Ùqu´¦¡ÁÎôÚ=­UÌ–Äs™R¼ÙÊåÄÊaÕv/Ý(T X©DÙkœ¾Hâgûò´šäŠ7Ò&uœì­©ƒ·=eq[½èL\¹›d]D§Š;$ÿ/ýr*‹¾;˜QфĽugrñ¾tõ]øŠgåTbÞ¾×+W—>Q@@À¼ @q3ïõÁè@@À"èÇlÝ`ýC”ª®íìéž¾nôÞ¦äç(nu[ÑtX åé(aƒ•¾’•_Ts,[MýÖã&uîA±lQüáP†²‚b«_]•SrhˇöüùîÖ”ÏquÒ¯N&ö(· ¾5Û·â;±ÎIÌÛòÙ$®•Ë"ÅͲֳb6rS/«(Võ»¤¬åRØÅמ¢’nYóº´µg«TQƒ*5r4ÁpNœòì²xJæql<›MOsR•W$TXù´,°¾rø9Çé½rWk:U®Š2;&T´ å>©WÎÖùêŒ64¸“í¨Û1 ZÆ„2  MKp7-ÿëÝËÓ]SÛZ”;M µÀB;ižu§ŽÚ<Í ¢ 43âžèÝÊF}‚¼íTŠüV¬öéãv™]%á†;' ëQ¿ Gªšê_2SÞÁñrí8^Í‹ÏM©úX›ÖžÌ¢yƒÜh@'Õ§ÄœMíéj’³ÎDq’¸3ýÄ퉋¤d‘üî@ºRÚD–±ÕÍž“±ÔW$sfØ¥NœâMöÜ÷ž‹ð‘9^匓ºÏ9Nôr˜­{“9ö   `yšÅ­s÷H²¶.¥Kg:›Å*¼ôò ÉV”•?­2‹ñT„•<Õ5ˆÖršjqE¬«nû×B .e[ Èš¬lúó×/ÓXÖ7sÿÛn®×‡X¿¾x¤½~>§³¿–^DÿÞ’R‘}ò¶zÇsÝ0>³Wܪ*m¡}.©™Ô¦¼=‚ÒRSiÔèQ´qÃ&¥\@ˆ&MšDnnÕÝuæÌ™S OdT =ððo«!³w°§?¾ü¹{¸Ó†_6RTdɃ-00€²³ní0ƒuýÐCôì ÏÒömÛ¡´i»äPª s¯S¤µ½çàCYVåQY®e¥ôbA —’Öç?žëÕ!›µâVUiÓ ßÆ®fW1b$ý¸âGºŸw”zðËÛéS§+f.æÒCSP§ 4h åÐñc'hå+©° <ˆ]K}”ùÛŸ)""‚~ø~yÅ×nî®ôþïÒ?ùœN]¾aêihnO‹«¤~cÆnôÅEE×eBB"¿œ] Ó'OÓó|ŽNž8YaɬŸ¡Á×VþÑÓ°ÃTµAƒò‹b½ðÜ‹Tói„‚ š ˆ{¤XÚÄšváâ"*)¾eýLO¿DYÙW©{÷Ç•ò&eŽÔÚ¶–ç4`mcM/xˆ èOe¼I|áâEZºø{ÞÌ,·ô¶oߎfÞ;“:p"+''Gºvõ}÷Ý2ºsUý®ž‡ù$(  ¾»x1œ–,þ®¢ [[[š7. <ˆ¬ø{âø ¿x‰FEoýómUçvî9¼ûüKÏÓÁiýÚ_*¸ÔÖfK}Nk¾ [xÁË6öRRHÎO¦·œØ;«ÌŠþTL¥EJ¡Ó*x®W'ÕäÉI‚»ER—^·^üuC¬Ii‹8Lއ֏æýú÷e+-;zœŽ9JcÆŽªVvîýsèÆõôϼA‹¾YDRgîÜÙ•Êi)£«°oï^Vp ^y ¼È€þý)'7Ξ9«õúl´rï¼óÕôÑ= fذa´kGX%¥­j¹)S'Óøñc•âñ÷¿¾J›7n¦ûᅥ ª™osãiª…³âÛ7_-¤1ãÆP§NklV\'GŒN_ýïkúË+£ððpzöùß“»»›RVĤ“Þ}z*ës^=ùú,w[èÚµ åçÐŽÑ´t‘—(Qh\1ÕÚøâQ[ùo.¦£GŽÑa~ñúõãO)¥M¤®}Xú:`~ ÐÐ$¦M$öê¶JJ›®_Qää7ï›ek“–çÔ3f4%Ä'Ðëü>ñÑ¿ÿC4kö=MçæåÑIÞ¼•²¿¼üWJäÍÌÇ[P©ëy÷Í¥T¾O¿ùÆ;ôñGÿ¥öbÑocÆ=Ó©_¿~ôÅÿ¾â÷–7)>þ=ðàý•Ú¨ï=§U+ei;sú,o`WŽÙ¯­Í–úœnèëØRÚÿ—½7Ų‚æ_VD¯°\§´Éwb…»iéÏõ&UÜD9ëÖÿ"…ö¾\Iy«¯Ò¦n¢cGÓáC‡©ˆwß÷ï=@Ý{ôPnSú"¦Wñß–]ù³gÎÑšŸ×ÐHv¯·?h)£+{ˆ-%NÎÎ$±.:ÀÖ¼#[SRbº@èÛ¹Ðõë¾üòËTÓ§¦>œœœØ‚ÆÖØšc'dwn2+nkV¯SVÌDæ»oï~ŽÛISy šÏš˜½ù…é>ZÖΖ7¢¢¢iëæ-ôÈ£“X«Š|7~Â8•G¬eò°_Í×o N%çΟW׻ĊôéÓ›ö„í!vUèØ©“ú®k×PÞ­ g⦿6}ßyä£cÿÖ±j™ë×â©u›ò…1~uåmhsBÊœœÊÿÆ33#kD“‘Qþ›.iIMµ<ïtuåž-^%I‰I$ï »w…UÚxKOKW'òô@E+.ü'?|¯Ç]W/¡wt¥ÌÃG¨[·nJ‘öçÜÞ½{©¿ ùmûÖ–x.®Eº£ÂŒñ«º,u-¯e,â~€I€@3#бã4ÞÔê©FmÍ»þþ‰’“OStÔúê3Ñð¼ÓUºv­òû„¼8²B¤u/—Xp{{{²áç x?ØØØñó°|ó,®Ê†hNv99:©ß¼ø¹* VÕçªl¢†t)'ªÏ}JêÐ&Î që<4_y|èÄX›-â½§™]ãf?\~¿7•´äçz“)n¥¥Õ}Ê[••÷HcJ›TÍÖ6‘?ýùå[-°HbÞÖ®^_aý²á,}±³¿iiÓ;VKý6öíÝGO=ý$9Ø;(?õä¤dŠŒ¨yçÏTo}Ú7ÉšD,q†$Ý>åF-!‰Å2$¢˜)1 F¬;’íO'Æø6'žõYƒÚêÈÃüë¯Ò_y‘޳Ëo)ôêD,ùꋯÙúW©™üüòƒx/Üt—ÌÉÎæÝçLvŠ¥3§Îprš‰lýÜA¾¾mè—i)ȱ#ÑÑÑjºZøés©kùúôÑRÖó†$——¬2GJ"‰i«*Wc6‘³K;Ž3+·ÌIùؘ͇¤åy§«¨‹7Ôk«Vœ¥ñyåJýæo“´+ÊÖÿ¯Ü¥Z'7ãë ææcSÿù)劊Ê7Aoçžqù ­Z¹šc€OÑKÿ÷E°GØî=šÛlÉÏ醼–-¡mID¢ï)s«ÛËœœD,qº„%õkK~®7™âq®ÜmK?Í¿¡4Ó¦«# $%·X+Ž;^Ñ”Ü8_xñ9êËndº´à~þ~•º’0qiÔO¸¡¥Œ~#Î_PŠM/Ž)êÛ¯ 6nJ‘›|Mg´Õ¤œï±ãÇ•+êÖ­Û)‹‚ª’’œ¢t îHW¯–^‹tb½„„¤Jn£ÆøšOclt¿‹k¤!‘ï§=Tþ˺%S6'ë[6nU.“§XéÒIJJ*ǧåS»öméôé[ßW½§sL„ıà¤"§9~á‘p¬× ÞeNQn;-A$a@@€Eâ -üô¹h)/Vh+=·V-uZ{Ì“@JòY¥¸ùŒU‰Hô““È8J$)•Ÿ©k·GÕ°ä¿å»šÄØóNËÜ9Ñ—‹‹ ­^½V)m"’½±.’š–¦B?ä=E6átÒ®­ož †ú,»¹)(Ö¼?,§ùÜÇ›\1*qŠ–ûXs}N×…?ÊÖ€dEM\%EQÑ)r/q’’¿9V>²£.½´ôçz“Ƹ‰òváx××K«Ò& HF792b˦mÊ]L÷‘ÌKÇùåUˆuÒ)¸ŸE6A¹„„t&ɶ{o…Ÿ”ÓRFàräÀýÔ8ÄMR²35¥¤¦e˜¼{q7Íc«Î˯¼¤’È q‡AÔ`v¹TŠÙŽÏš>ãnêÅnyâº7ˆ…qwŽeÿý •ÆcŒ¯¹ñ49L 3[{[Ä=èá[ÉÌr”EˆY¼€ì8î»1Eù´îˇÝù\·ÖœáøîÞ@àucªúšÅgÉwÏ»|Ì@Sй?§›’ úSˆ /lr‹›nRby³¶)á¸,Ò”ˆäv`Ôû¥ß¦–2UÇÚµ«:àh É;ng̦¨[[Ü›~ûUƒ MÑwÕ6´ð5wžâ&¢;Í#) ©L@ˆè6Ý/5ýôpõ€h! ‰°Žª%‹„'$''7ùÍý9Ýä€001³QÜd^’ò¿9J+WWrws¥yógS»kf±›–9JtL,uä`ic}+±ˆ±² ñ{sáÉç÷tæD,ZäJd´–b-ªL<ŸéÒ¾ôVf´Ú&/ç¿@@@ %¤`’yRâ”%éÔÀA8ޝýûƒ› GsyN7 t  DoC&û,`Ù¶]’¢U+V™ Å†iâþ‡žj˜†MÜjsáùУO™ù­ì‘ë–+kbˆÍ ¹?:ÞÊŠÖ †‹!‚€@“ÈÎÉ¡©S'³ hk’£t8®\Î\;߄Ǻ4—çt“,:$`61n 8G4  -ŽbÜZÜ’›Ý„›"ÆÍì `@  `"ã¦5#§‰ºD3      u%WɺCyh&R×<ÛLFŠa‚€€#‹›1Bø@@@@@š˜·&^t     Æ@q3F¿ƒ€€€€€@€âÖÄ €îA@@@@@À(nÆáwhbPÜšxÐ=€€€€€#ÅÍ!ü     MLŠ[/ºc ¸#„ßA@@@@@ ‰ @qkâ@÷      `Œ7c„ð;€€€€€41(nM¼è@@@@@Œ€âfŒ~&&Å­‰݃€€€€€€1PÜŒÂï      ÐÄ ¸5ñ {0FŠ›1Bø@@êI k×PzäчêYÕ@@@à(n¸@@@ ôìݳZF³  -€mK›0æ   `þþðì3täÈ1 ô§¾ýúª_¾t™~X¶‚²³³Õ¿­¬¬èΉwÒˆ‘èU+WŠ‹¥U«VStT4=õ»ßPVf6-]ò*ÛÊÕ•Þ|ëu:rø-Y\ù»þýºr%²”ÚÚ—ÂÆÆøÀƒ÷Óà!ƒT»ýû÷ãqçП^ù‹ª·wÏ>òôö¢;ïO×⮓££EFFÒO?®ª‡«›+½ñÆkôå_Ó™3gÍÑ0B%‹[ƒâEã  õ%0{öLJKK£Þû7ýï³/Éßߟ¦Í¸»¢¹©S§Ð°aChÑÂÅôú?þI—#"èw¿{ŠÜÜ])üÂ% é\Q¶gÏî”–šFݺwS ŸHþ½  ¢¢b ±¶öujãwK¿§'NÒ±£Çè÷Ï<§”6ŒŸ0Ž:ÒçŸ}AËXATʵµME™¾}úPn^?¾¾Q@@À‚ÀâfA‹Yÿ©”ñËK>PYYYý›AMz°¶²&[{{rppäúåJ„èjlmÞ´U¡HLL¤½{÷ÑÀÔ¿ml¬iô˜Q´lÙò kÙúµ¿ÐðáèwïÞtáâEšyï=äÍV­””TêÙ«'íß¿ŸÆg…©cŠŠŒ¢.¡!t9ü•––TÃm¬ý=a{UÚÆXÛÚÚÙÑ×_Ke¥¥ªXVVͺw&ILœNQë7 ?v‚JJÊË@ÌŸ€ ÙÚÙVl˜ÿˆ1B†&Ÿ—o². ¸™ eómH”¶²²RjíÛŽllíšïD0rh¦JŠ‹(-%™ òóÉÁÑ©™ÎÂôþw­R£¹9¹ìR(Ê-‘§—99;ÑüùóhÞ}s*Ê9;9‘‡§Å_§ŒÌL éB™¬üt ¥ ë7’[ízöè¡·.]h×Î]n¬}]¥ÚÆX‘ð‹áJ›”ËÍÍ¥Ó§ÏРA”âæîîN:u¤5«×š,Zl0PÚ -`PÜpPqa!ùø¶…Ò†kšˆ€l˜¸{{SjB7½5(,(¨qE¬oº;.Z´„cÄ*+xùùyª^ø…pv— ¡VŠÄ¢GçΞ£ñluÛ½;ŒÚ´iM/\4؇–ö¥bmc¬ír*.*ªöóAv—|ü‰ÇÈž­¯}ûõ¡”ä¥`Bšnó1F  М Æ­9­Vµ”­m°´5\4  ØÙÚ“ü-B´Håxµ|öhÛÖ—ÒÓÓ+}òó˾ððp îLwtíª¬Y"çΞ§¶íÚÒ€ý• eRR²Áµ´¯e¤eìæhe­íQ{‘M±¼õèÑÝ={ÑQNÎmOð3"P\\L»vì¦ÉS'ÑPNPâíãMíýÚÓÈQ#ÈÝÃ]ô"ǹyyyR÷ÝXq;­¾“Œ”1Ñ14zô(¶¶]¨qFZÚׂ#%5…‚ƒ;)ëžn\5Õ“ãÇSV*;w䬚G´t2  -„\%[ÈBcš  `i6ü²QY¨$¥þ¼ûæRg`¼s•39žRSÍÌÈ¢ñ78ˤEDÜJ÷æÌ9š6ý.ºÀ®ÚÄXûZxîdå²C‡ô¯ü‘23³èÕ¿¿VkµƒÑß^ý+E³r™˜˜¤¥ ”h!¬ü‚ ‚Bí[È|1M²2Ө؀41ø¸ruó4É($K¬+'¸€4/#%çÍmܸ™Âvïi^ƒ¯2Ú¬Œ ²³whÖs¨ëàÊ“ç@@@@GÀTY%cc á*‰ë @@Ì„€dÁtä̘r @@ô ÀU×€€41V­Z‘«k+º—ÏrÛ¿ÿ€ŠÅƒ€€€7\   `F~û»'©o’ø»5?ãì63Z @̆,nf³€€@K%ðî;ï·Ô©cÞ   ˜\q›ÿà|7nŒê¾´´„222éÌ©3´råÏ”›“«qX¦/Ö­û4lØPúêËoLß8Z›BCChàÀ´té20“0¹â&#‹ŒŒ¢/>û’È?0€fÏžE|–ÎÇ~b²×µ¡Þ}ûÔµ ʃ@ ôèÙ£ÎuP@@@@ŒhÅ­¸¨ˆRRRTß×®]§²ÒRzì‰ÇÈÓpŠê—^~vïÜM^|`ê”)“)66ŽÞ÷’4ÈS¦N¡ÑcGr¶+ÅÆÄÒòå?Rä•òów @wO›Ê™¶¡|>¯'**š¾üß×”ŸŸ_kÝG×…ƒî¯]»F«W¯Sßþú7QvV6}ÿýrU·ä¿öÚßèèÑcôýw•¿ûäãOy# ºÚHÊÇpœxc o¿ÞTXPÄìž¡µk¡¢ÂBU^Êìßw@mL?–®]¿Nÿýäsu­Ô46©×§ooš8qùøøP_[1W¯ÒÒÅË(¿ ü:«©î}|ŽÔ ÁTßýúõ¡l¶0ÿí¯ÿÀud¡:vêH]ºt®uvWèŠÞùaŠÓƒ¼ù=ïå—_¢7ßx›ÒÓÓA @à6 4ˆâVuL¥%¥üÂKdmÃÿç¦Lœd+ª‹‹ =ðà}t÷ÝShù?ª"“'ßIýô§%K¾£Ô´t>|(=ù›Çé­·Þ¥Ká—iä¨#éѽ¥q™;ºÝ¡£²²2 éL…|ml#¾gætÚ¸a3ýëýXÉò¦ûæÏ#k®ÿã«*êŒ7†ûO¥/ÙE6禫nmc³±±¥‡~€/ZJ¼aàæêFþJi36¯~XÁ×™½ºÎà*i¢ ÍÌ›éØ©…vébp”¢´É Ð4Di˜ÅY:;tR‰_ö†í£Eß.nšÁXX¯o¿û&­_·öîÙ[ëÌfΜA„Òfaëé4=ÇÍ–•­`~ŸvÏ4:ÃÖ¼¼ò—_[;[úü³/x7ú ݈¿A666Ê’±ò§Utùr¥¦¦ÒêŸ×P +}ýú÷%'GG²¶¶¢¼ü<ÊÊÌTV:¹ ‹«Ûtx-·çظk¬¸m§ÄÄD¶|FÑN_ÈÖ¯òõ°fÅl$­ã›ºXËÒY)ÛðË&*)+¥^½zÐÅðKäÅV0///U¾GîtðÀ!²±²VX‘Î!)‚¯‰“¬Ibb®Ò®]a”””L.„³·‰† Dv|méDþo¿]ª¬³2Vccst´WÊ£lˆUð:[é<¬i^–»Ú˜™!Qì¾yÓ6ÞHºTígQØ6mÚk.hBövvìa“EëÖ®§Ä„Ä&IËìÚ§µõêÝ‹öï=Ð2`Ö ÐÄâ&ˆ~öÅI·B¶šˆ Ür¶FèË…ó” ¥N¼¼½ÈÉÙ‰yä!zð¡*¾wáï<==•Ëå*Npò»§K‘Wh×ö]tòÄI~±/#cu€[‹oò:¯‡¾ˆ5Ë‘•kY/''Gš7o6Í™;«¢˜3ÿîîá¡õÌÌ, éLÇgSNè±qãfjïמºsQ²BXq ÛV+çøëñ•~EÎÚÚ†û÷RJšÈe~©Ö¿ÎŒ->>Ö¯ßH¿zìQ¶öÅðnâ~NÏ}VYÕmñE  SÞdê:Ë”¶x!`ÊfI žŸ5?ÜtÉŸ8y¢YŽqÄÈ*䛯¾¥™÷ΠvíÚ©MÃ/¿øŠä™&"!îœ@­Yï”ý{÷Ñöí;ÕsIä‘GâWÊädpÝ{t£’âRÚ¹s'mãÍU}1ÖNpp'š"˾ûA?X©¿)ÛHHH0Kö4G ¢¸IÓ×_-TJ[ffW·šWâ%.n"_}ñµ²¦éK>[ÙD6²ÕfÏî=4|ÄpšÿÀ|¾‘§þý‰r3V·9.Ž9¹ðf™¡1ŠÅJdé’ï•ŪòZ–[]/±Õ­sp°r_Ì⇔(æçY™7v4íaKª< .²Kembm]Ù`lÇ»«"º‡™üwqqåëLËØ¶oÛA÷¤ÁCѬÙ÷ÐèÑ#è‹/¾Q–8có2ç5ÃØ†€Ny³áMXÚ†3ZK%`ogOwM›¢”LQ6ýýý+<”$öL~-_ö#EEF’o[_ºÿÁû©ˆß«vïÚ]¤k×PúaÙ ~î~Çñçþô‡gŸQŠ—Ä~‹hiÇÝÝ.^¸HëÖ¬£œÜ\êÞ­=üȃ”ÆPW8t /7žùÝT{Z\%eS6š7b! ¦#Ð Š›¼Ô‹U¥."1mâžÖ®}ÛŠ¡úÙÙ9ìž´…ö°_õ{ï¿ÃnvÝèÔ©ÓFëŠÕÅŠÝ1! O@v%&Ì—c D3$§8eÊD.WHgÙ¢%rþüEš?®rWÙdv¬MdgR_üüü”kmZZZÕ´ŒM*ËCkÇŽ]웈þñê_¨k×.tîÜ£ó*¿ÎÔ¹á=Ô™€RÞ6nUõä Z ØÙÛѪŸ~®°°E²‚¦“)wMæwž­V/yWÚÉÖ¶aÇTRÜäû}l‰‰½ËI§häè‘ïSZÚ9~üD¥!‡…í¡¡Ã«p†úÜ×$Á×åKZ1 €€ ¢¸iè·Z±ŒlÛ²îž~·ziç˜%GåNw‚o@bMéÌñr÷$Ê[çÎ9a‰r‰3VWb¬’S’ù4T)¬XÊw†! ë¶k/Mœ ‘Dó ôÑ£'ÈÆºT%¼˜ÿÀ}*vN¶…ß,ªp«¬­®´½uËV:Òß^ý+epr“?½ügÓQDKÕˆ»˜(mãÆ¡ÙsfñZæ«ãN²’%"1n 7È•³ƒ^á5ÖÉY¶jÝÅ;Œá«'|¨ÚI»Í¶k×–&M¹“O{'nû´ B7&µÍºÔš†rfÓ{gÝ£2™&''Ó²eË+\>Ík×î½H/½ôe² è?_ËØpð;€€@ & ÞFú.þ:7½óùØ£¯jõD’òÖU<=Tïò8•Õ[ÄX;.x„ïq¡Ÿ^MI‰ÉTZVÂÇ÷<Á \ýGÃH{Ó0¹â¶lé2££{ï –‘D#[9V>†äÝ·ß«±mcu³ùòÁ{ 'ðÉ'ŸU+$™uÙåGyIÆGùÔ$ï¸$˜ºj@uMõ¥eìÓ/CbhœZÆöñ>­qÌÆæ%VÃO?ýÂ8D”ZdñgFz…rüš.V­¦âíyS’sé21ûsè@RR¹NK;ÃÚ5„¾ùzQ…{£dìööö¦¸*y¤Mñ@‘,͵ÉUÞ¬mß¾=Ö@À„ê¹b )¨']²zVG5 &`Ŧ¢>¦F>öì¶'Öùﶬä4ÙÀÇÜŒ9Œ&N¼“C=|Uâ’1œÄkÚô»*MAÎS½wöLjÓ¦ ‹Ãqi={r.€ò˜7cíȦ¤­ªq‰R6sÖ uN«!‘l‘Ý»wWÙ,%Ä@XW‰_ïÐ)¨Ò=Í…;Æ æJÀä7s(Æ   Ðr8::pxÄ_*&,ŠOß~}ØMÿýåOo öïÛÏqb…v0–$ÁˆÄ¶]‹»Naa•½Y.p6Hžïÿ½ò'k+  ë7Щ“§*樥o¿YLsùŸ¾ñ:g­,¢cGªuC"çìÞ7½öú«J13tÀ©S§h‡JôêÕ‹Ž;Þ,xc `î¬ü‚ ‚BíÍ} _ÃÈÊL£vþå_7yúé§8WŒ:ä–B >.†\ÝûÍÜDŽo1b(½Ë¡†âøÌm¼4ü¼ò£°nWbc aq»]Ѝß$jŠ_k’Á SjÄÒçì⤲T"Ë2.¸}PÜnŸ!Z¨B@¬l[7N8X u'Å­îÌP@@@Ì‚À¢o—˜Å80†'€¬’ ÏØì{°¶²¦D†€4¢âBÎæ†[rÓ­zó&€·ó^ŸF­=¥¥$s©ÂFé€T& {))dÏ‹0D®’¸.8…°#äçSjB•–•‚€@#K›(möü·(n¸j `ÅÊ›“ú@@@@@@Ì,næ·&€€€Àm>b5œüÚû©–bb®ÒÚÕkkÞdƒCÇ &%Åͤ8Ñ€€€€98h ?wÖ¯ý…Š‹‹iâä‰ôÜ ÏÒëÿø'ÅÇß0‡!šl 7âhÑ·‹+Ú+))6YÛh@À|@q3ŸµÀH@@@LDàÃ}T©¥«1±ôÑ'ÿ¦ž½zZœâVTTHqqq&"‡f@Ì•7s]Œ @@@ÀdœœøÈ +ÊÊÊ6Y›æÒPÛ¶¾ôÎ{oQiI‰r ]Ã.¡–fU4Ö4%ДôÑ7€€€@£¸gæt¥Ì9|¤Qúk¬Nbb¢éûï–ѧŸ|Fß/[N®nnôÜóÏ’»»{c ý€4XÜ ´ywSFùTTX@eeeæ=TŒê@@—·µ·'I³oU‡š(  `I¦Ï˜Fݺw§wÞzWÅ»Y’œ9}öÖtbˆ¢®DÑk¯¿JC†¢Í›¶ZÒT1hñ ¸µøK€”ÒVVZBžÞ>dcƒK—„åý¬Œ uN!Ž»°œuÅL@ .fÌœ¡²K¾ÿî””˜T—ªÍ²lvv6¥¦¥RëÖmšåø1hš ÀUW’«»”6\ G@6"\ÝÜ©˜÷! -Àì¹³iøˆ¡ôþÛïÓ Ë$YÓj:;;“—§ed¤W+bÏ$ÿ ÐüÀ¼ÒüÖÌä#.-+…ÒfrªhÐ\ØØÚ’\ã–E`Þü¹4bäpúæ«…dïà@ @6''IKK³þê:sæ,¥¦¤R+×V4ñÎ TRVBû÷¨6ÇAƒѼûæÐò~¤½{öZ LZ (n-e¥1OhA DŽŽŽôÛ§Ÿª4ë°]a´dñwCšãwgκ‡Z¹¸PnN.EEGÓwï~O)¬ÈUG'‰÷%¥äA@š(nÍoÍ0b#^xîÅÁè›o¾Õ<ÏÎ):*šÎŸ?¯¹ ‚˜(næ³ €€™pðñ'¿ ÷“³O[ÎVY¾s]“æSnr<]Ûö=$_3ó™ax –NÀÆÆš:‡ÓçŸýÏÒ§Šù€Å@r‹]ZL @ÀÔü&Ì'·öŒ*mÒ¯(vní;Rûñ÷›zh@êL ¤¤”^xî%º|)¢ÎuQ@À<Àâfë€Q€4Î>íªòí)þ•¾û¿q•þíÒºzf0U @@@À̘\q›ÿà|7nŒšfYi)edeѹ3çh劕”Åg‹@@@ ¹°µs¨óÐëS§Î €€@5ÿ÷#zæw°&WÜ„L¾~õÅ×dÇi¸ÛµkG³æÌ¢Ç~ý}ø¯,& Ð XYÕ}Òõ©S÷^P@@@À 4ˆâVÄ:'&$*t×®]'²¶¢'~ý89Ø;PAaYñ‹Ì”©ShôØ‘äêêJ±ÿßÞ}ÀW]Ýÿg'@{/‰€(ˆ(‚"ËeYÅ]m«?ý«Q\U±Z«­{кd‰Ì²7Ê”½Â^!ƒìñ?ç¸$$¹7÷Þ¼N›‡pïùžñ<—<òÉY»bôÝwcµ}Ûvç™'†=®¥K–©yófêtq'çµ79Ç÷&š<›5j¨~ú©EËŠ0ÇÛîݽW_}õvïÚWƲe+Ô²åYº¨SG¥§Û;Méçi?kЃÔþüvJMNÓ ôø yÃ\TÛüüó@÷@@zdåi IDAT@À ùðS=÷ôóÚ´i“yTQQÕóˆn½í:vì˜^ùšÞõ®sqfÿ}óÞOJNÖ¯¿®Öë¯þSÏ û›>¬ûïÿ£ ñ-·PÌî½ôâHùvŒ®¾æ*ýíùç´eãf½ôÂË;f¬z_ß[çµ;7ï¹â´Í Ç‘&!€ €@%8ÿüö²KÃÌ%ãžLµëÔÖ?^U5jÔðdµÔ…@¥(—·\Q¬5hÐ@7ö¹A+W®r.† Ò•W]¡ÿþ÷ mÙ’s²Ñ„ñ?ª{Ϻ𢎚3{®óÚ.3s6eòOΟš/{af—K;ç ÖñØãZ8aÞßçÏ[ ÇŸ¢àà edd:¯ï03x3gÌrþlgmà§Ù³ÿ—÷Ú5×^mföZhýº Ån[¥þÄÐyP­*Áú¿Kê*2,ç—Q§&{XIbZ–>XrHGNd …$psߛ̪‹T³vM§GÕ‚ Ÿ ²³³+¨UþSm¿~7kñ’%:~üx^§ƒ4ê7ÍJ¦ñæç¹œŸµlêoVH]rI½ûï÷œŸïnô]Öí²|Û·o׿Þ¥ž½z¨¯)ÿå¯èè‘£yùÚž{ŽzøÿÌ/ôÿ­Í›·ø&=A ˜å¸µjÝJïô®‚`¾ì7Éñ?ä,G¬U»–"ªDèž{îÒwÝ‘×̪浚5s¾¹Ú´'&Æ¥ 'O("<"ïµ&Mšèºz«™™‰ UÙOçÌê…änûöp)#ÙÌÒíߟÿ5»ÔÒ¶Ó–l à§Ç’2ôéò#zÐoáÁ® R2²4zÅ‚6?{ºå;ö‡|ûËb»ò&8(XÍš7Õn»ÅlH×ÿæäü’˜T2:uëèüçk䈿YÀ€ýÕ¹ËÅzûíw]~¶‹ÕG|âò|jjŠó÷ysç›g:kÀ€þúèÃ×ìXÞrË-[ºŒ ­Hu2ø«@¹n»vîÒ§ŸŒ6[òˆÒ̾¶´Ô4Ç0ð÷úöð’˜×c³SR’óœSÏ_|dµj:lˆ–™}p¯Œ|UÉIɲÁâ“O uÉž[ç©/fþ>WP¹Åm›¿~è.­l˜ÿÙt !]Ÿ¯<ªû.®£`óK#›2³²õõ/Ç´7.çûMöx^௾¡¬¬œ86 üÃ]ÛûÕ­W×óñ`v ã£ý?­[·^ß›½sgmðtÕÕW©® ºbͪ¥EföqÖ¬9ÎûÕÌÏU/¿ò’þ;úsýò˯y­íxaGÝsïÝzîÙçóΰov¾øb4+™´k¢Îœn¹u .4eŒzëí³gœ’ìÏc{ö¸þ˜÷}Ӵ雯¿Ó“OѹçµÕ†õ¿éò+/WUÓÎñ?üèAMªBÀ»Ê%pK3‡“ø}fë‹ÿ~¥G}H«Y£íÛwèèÑcJIIQÃF ´fÍÚi4kÑLU«VÕ„  Í&û¨´©,ÚVÚ6ð<x¯@zf¶BƒNž,¹ýXªÆ®‰Õ­j9áܸu±Ú|$ç7ƹ)-ƒÀÍ{G”–ù³€ ÚìAfzàO W³âgão¿iÆ´~Ûízõê9AÛòå+ô£ù)7uíz©ú™s¾ûf¬v˜åˆõÔ×íwÞ®t<Íýß\%šëšÖüºF—\ÚÅ%p»ä’ÎZk~VË=.·¼ÖmZi§9AüLÉþ‚ëV3»Ù¡C½m‚¶ÓW;gìÊ«¹fæm ™±û÷þ÷ÕÛÝã¾ÿÁi+ Ê*P.Û©˜öü’%KuߟîÕð_VzZºfNŸ¥oºQ'’’´é·MfCm¸™1‹6ß,VËî]+*9rÌù Qtt´ó ¥^ýzºÆüƒ.mÊ0‡¨”¶m¥mÏ#€€÷ Ä&gª~5×¥‘«÷'©zxÎ^·U{“ò5þxÊÉßø{oÏhþ)`·R4mÚÄéœý¥ñÁ‡”tÊêêu£ÆôsõÒÜyó5mêÏ.]³[K~6ë ÐÙdQ=Ç̶u½ì'p³iá¢ÅzøáÍAqQÎyÕ£"Õ¶í9úàƒò1Õ©SG[6çœSPPºâªËrlpwðàáóØå–öP•SÓlӦܭ5öõ)“¦ªcÇ ôøÐ!fvn¯–,^êOCF_p[ Ü7Û¢o¿£—^îlNýÎüyÒÄIJ2A[ïë®ÕwÞ¡dóMt§™[±â—buààæ”ȱºûÞ»dÍ}Ì|úâ¿_jè“ëùÂ2•¶m¥n €€× l3³ivY¤=”äÔ™·ù;r®)95¥™Ù¹„ÔLÙgªxmhþ-°ÓlÝøó}¨† "n¿ëvs QO³Ç-Cß™“¦ý-=ôЃ Ó^àœšª™k—ìÉ7›½í—Ë÷)³B*7mÞ´Ùœæ«Î—\¬?Ït–C?§ß6lÌGâì:yü¹w×^ítjÊÈL7‡Û~R¥ßC nÅ@"  `(r‘ë½CÈ €€÷ Øë…ì•A-ÏŽVƒúõ•‘™!»ßmöÌ9Zláðçd/¬¶çÜu÷æ¾³ÍÎE¬¦§§é s¤¾ÝsfOÿÞ»gŸæÍ›—Â.—l~;-5—kŸ)­^½Zýoé¯óÏ?_+W®*”3>.Aßš£ýÿdNéÑ£»©s¾“ßÞÝ;ìé“Ûhìk6°´W@ `€ÆM£S›6o PåHˆUÝú +/=÷{Ã÷+²zM¿ïç©L7÷gFšHT”@‚™½ «¨ê+¤Þðˆð ©·,+íÖ½›úÜtƒž{æo…Φ]Öí2uëv©þaÎ%(h\Y¶©¤eÙS+yø±’>Îs”‰@J²ë5A%-4fצ4fÜJªÇs € €€Ÿ„›}µÌ]w×\s•-ZRäH;‹W¥j„ªW¯î\@Bò p+c¯¯!0 P™fIP¯,è¶@¦¹Ÿ1À|ÆI €g¸ûž;un»ó´aÝzsÜ´"©ì,›=}’„žà'uÏY{mMÁ!¡²KZ"«G)Èlà&!à/6h‹?®Pó'!€œYàÔƒC|ÁiÀÀþ…63.>ÞºApK€=nnqùkæl¥¦¤(Ãl\ÎÊÎò×NÒ¯J(`gÚlÐn÷T*ö¸UªáöÊβÇÍ+‡…F!€€‡Øãæapÿ¯.@aáÎ @@¼O€Þ7&´@@p pã € €x¹›—ÍC@@Üø € € €^.@àæåDó@@@7> € € €€— ¸yùÑ<@ð ììlßh(­DŸöÉVÓh@ AUk)¼IG…78WA‘õ©À °bœ¼{¹"šuRª¹.€„ Pr“A[ó3[ΩÂ6€;vô d–MFV¯UòÂy (—Àí‹:š{L‚µrÅ*µjÓJ½.ïá¸ÕçÿŒþÜKaÊÊÌÔ'–—ýºë{ëÊ+/ן¥={öªU«³uûƒ”•¥E&,(ç™$3•þ믫M ù½ÒRR5àtÿýÔ ÏwŠìgö³uèÐA~ð‘:¬Žf6°oÿ›³§¨®ð>x@ ´vñfÛlÐvxî;Î YP•š*‹ËrÓŽl-uí¬[µ6W–º @*³@AA›õ "x«Ì ?ê{¹,•ìuyO-[ºLö"ÊE ë¼víd÷¼•&ÙË,{›ÀíÇ “´jå/:tð.X¤Ù³æèz3[WP*î3Çc;³v¶Ìãqqš?o˜Y¸àà ªg¯š4q²Ö¯Û Ã&p›þó mÛZúÖJãÁ³ pR ("*GfR¬öOzNñk'9ï´Õ½r¨B"땚13éx™”lîž#!€”LàLA[ni¹Á[jj Ë&KFÌS^ Pæ3n 6Pë6­ÍìÕ8§{Û¶n3K™½nÝ4~Ü%îrÍZ5®íÛw¸”±mÛ6Ð]k‚¬`edd¸¼WÜgš4i"»T³Y³¦ U)Ë.± 2DEU5³‡!ÚqZ½11{}vt‰ûà PvÙ¦¨ÓwÒf˜Ë­3(níD3{Ÿ¦´£;ófÚÊ*h+«d›Æg›ÿ‘@Ü(*h;=xcÙ¤ûÆ<áe¸õ4³m6=óì°“=4³evÏÛÄ “•i–?”BCB ±™“ øÙ&++³À%OÅy&²Z5 6DË–,Ó+#_UrR²Zµn¥'ŸêÒ»óÔ”‘î$zÇpÒ *§@¦ ÒCºt>Ì,Ÿ¬}ÙŸutáÇJسŸÖ.,ë -¨J Ù˵K“l»²NpêYi y*§@qƒ¶\–MVÎω¿ôºL7{ˆG×®—jâ“´råÉ empôøÐ¿ª£Ùû¶bÙ %›}d¡¡a.† ÔÏ(ü¾©Ô¾qôÈQ%HRËè³´{wΡ!6µlÙR.0 ,Î3ÍZ4SÕªU5aÂD'h³©nÝ:yåÇše”v&¯©™;uO[C³”’„Þ!~d»Bªçÿ7Y¥ÙEJ?¾Gñë&ÛMe´ÙÞ‡Öm¥ôRnáõZ™ÁmÞI+@p7h#xó‘¥™g(Ó=nöH»^iú´™Ú·g_Þצ›µê—_Õ«WÎlÜŽí;uQ§ ÍÉ>Õ%‰íÚ·S‡Ž\yäèsøH´êׯ§5k8ÙôŸ§ë¦›oÔùÎWÍš5Õù’κâêË5eòÔ;XœgŽ9æÌÖEGç,{¬g껦÷5yåÙÙ¼ùsç›zû83q¶Þ=º«í¹møX!€€—¤Ä¬žWæØ±ãœšüß_Ì>»PsZæ/úæ«ïÔÃZBBŠH1Çè§Þª°ºgؘȶ`T-oÐÖ¹ƒ­¤w¹…Õom.-ñóeÑÊ@|I ´AÛéÁ{Þ|iô+w[7NmÚ¼MáÌ*·½G©ÑTu®~ÒA!omƉ#:8m¤²Rݪ;0´ªê™6Ç.­ŒØ·ž-*sºù¥X¤¹ï’„@E $˜SšCNÛQžm1‹g”˜pÜl{HTVûÐËÓº"Ë 7ã›iŽ÷?yO[iÛc˳Á[`@ ™ È?ùPÚòyÞ{ì„ááUT-²–Ì>•bvmJ+Ó7Ÿê=E¿H?£ØE«V·ÌìyÎ¥«žJÁUë¨N‡ÌÅÞï*+íD±ªµA[î)qíä2ÚŠÕ2!àg‰ Ç”a®!ªS·aÞÅË~ÖEºSNöÀ’:u•SéëMöŽè¸ãGÌ/y|ó2ö2ÝãæMC[@ ò ¤ì]£có?03_Å žÊR(ÌRRÿÚ§^¯èý¯vieÝËëĦJÞÃÞ¶²ʪ¼))IŠªQ‡ ­ò~è9E šƒí÷ ûýÂSPõ¨Z3ð쯧}QŠ6#€€Od$Rò®å PpTc³Âs¿Ÿ «¦ª-»š½v­Í!ÎýqÊÌP@`°‚"ëªJÓ Õ¡¿BªÕQÜʯ•~ìä ¹ek«fî¾$!PQi©©æ>TÏ-ìIˆ3¿Aâ"ûŠoêEÀ[22ÒÙøL³„ÚHh—KÚïÕ"kxk“ lW|ÜÑLÏ}Gõ)‹¾,`ïu;¾ì %¬›¢ˆ&(Ô Y_U¢L@åzIyô3¼Á9²_6ee¦*+)N •z`ƒâ–©Ì¤cåQ-e"€ €À)6h‹ˆˆP‹³Z8¯îܱCÉɾ»‘À7ø­€ 7Ï–ì @*•€=xÆma¡9ç06?ë,mX·Îg <·†Èg‰h8 € €øš€]™´Ù¶‡›Î¾æ«‰ÀÍWGŽv#€ € €@¥ p«4CMG@@@ÀWÜ|uäh7 € €T·J3Ôt@@|U€ÀÍWGŽv#€ € €@¥ p«4CMG@@@ÀWÜ|uäh7 € €T·J3Ôt@@|U€ÀÍWGŽv#€ € €@¥ p«4CMG@@@ÀWÜ|uäh7 € €TàJÓS:Š € àõƒî¤&éõ×ÞÈkkcó÷¡Ã†jê䩚1}¦lž+®è•¯/£þõ¶®ºú*effèQﺼ߽{7Ýrë@=óÔ³JLýLÇcãœçüˆ^øÛ‹ÊÈÈôiKïÛ,•ôíñ£õ € €€ß Ô¨¥!O Ñ’ÅK4yâ—~¦¥¥éÀþ._©i©Ú»wŸæÏ] ûåå¿üŠ^JKK×ìYs µ RžÝ4þûñZ¿nƒ)k¯FüÕ¬YS\pß:Ó1ß póq¢• € €@¥¨V­ª3Ó¶vÍ:ûƒ[}Ÿ0a’š4m¢víÛ9˯»á:}?f¬YBYøŒYƒFõ®Í›6çÕgƒÁíÛw¨åÙ-Ýj™(k–J–µ(å!€ €”JÀOýõQÕ23]g ÚZµn¥÷?rÝÇöЃ(;+K‰ f?ÜOê? ¯V¯^«=1»õë/kŠlSõÈH'OB‚븄øxEU¯^äód@ <ÜÊS—²@@ÜhÖ¼©¦M¦(³TòŽ»é³Óö²ÙwíÜ•o› ÚrÓ̳Ôóòžºîúk5âÅ‘ÅjC@v€“/ ç?y)Ë”›•]¬2È„@y °T²¼d)@@ D[·lÓã&èƒw?ÔÅ/6ûκç+§ =n§fÊÈÈÐò¥Ë´ÿ~g¯ZqR|B¼“Í.Ó<5UŠ4³p Å)‚<”›[¹ÑR0 € €@I²³sfÎìÞ²1ß~§AwܦfÍ›¹]T–™$sg¢lÿþƒJ:‘¤ÖmZçÕeO¤lÞ¢…v˜¶¨H–JV¤>u#€ €*0gö\µŒŽÖƒ= Ã_VrR²“?44T 6pyÖ^`)iÊÊÊÔœ9sÕ¯_ÅÆW|\¼úÜtƒRRR̹Õ%-–ç(·2a¤@@òøâó/õô³ÃtßýÔ{ÿ~ß©¦ÅY-4bäp—*íÜëÖ®/U3&š)CCCô—îWHX¨vlÛ©·Þ¥ôôôR•ËÔV  qÓèÔ¦ÍÛ„–¶ žGðtóçȨ(ïi-©t qq óX¿ìÛ©Zx¬>*BïHMMÖE]äÒЕ+W*öèAŸû~³kS3nÞÿ™£… € €e ¬7Gý³À’fÍœ£ÇÿXµPå#@àV>®”Š € àeö¤ÉGìe­¢9O€S%‹çD.@@@ ÂÜ*ŒžŠ@@@â ¸ω\ € € €@… ¸U=#€ € €Å p+ž¹@@@  p«0z*F@@Š'@àV<'r!€ € €&@àVaôTŒ € €O€À­xNäB@@*L€À­Âè©@@(ž[ñœÈ… € €T˜[…ÑS1 € €”—@@@€RÓÒòŠO1¶¯ùj"póÕ‘£Ý € € pFÀÀ íܱC6`³_»ÌŸík¾š‚}µá´@8U Ñm‚äHúý•ºæüáÈ›½}R‰7Ÿ6 € €•I€À­26}E@@Ÿ póÉa£Ñ € € P™Ü*ÓhÓW@@ðI7Ÿ6 € €•I€À­26}E@@Ÿ póÉa£Ñ € € P™Ü*ÓhÓW@@ðI7Ÿ6 € €•I€À­26}E@@Ÿ póÉa£Ñ € € P™Ü+…Ë IDAT*ÓhÓW@@ðI7Ÿ6 € €•I€À­26}E@@Ÿ póÉa£Ñ € € P™Ü*ÓhÓW@@ðI`Ÿl5F@ðK?õ¨©fµCôüøCyýkZ+D#úÕÓ¸ñš´:A6Ïuí«åëÿË“ëÆ‘ÊÈÊÖß§qyÿªs«êî®5ôð—û•’U¨]™Ú¸­s”ú]T]ÃÆÔ¶Ci~iM§|K€ÀÍ·Æ‹Ö"€ €•J ~õ`=S]M1› ÚrÓVL½=ó˜‹Å‘Ä IÌÔ·6P›aÚt Õy?$(@¸8Jc—Ç´Õ©¤¿^S[©Ù ¨TÒtÖÛX*éí#Dû@@J*P«j^¼¹®æmJÒX3Ûvj²ÕÞØt—¯ÔôlÅK×Ì ‰ºóÒ¨¼ìvv.Í䟺6±HÉs†iþT½>íh‘yÉ€€'Ü<©M] € €Åˆ tfÚVíNÑ‹ë™ÜLß.‹S‹:!º°y¸"BÕß,yüï¢ãÊ4K(‹J ¶$é«%qÊÎ.:oQeñ>e)ÀRɲԤ,@@R „‡è¹>uegܾ0WAéÜFaúöÁ&.oÝþáÙØ,>9KãVÆëöKjhåÎdí<’¦å;’KÝ. @ "Ü*RŸº@@ò ´¬ªñ&ðªY¥ªþÒ³f¾½lö{`Èé{ÜNP›²:QמWMý.ŒÔc¢Œ€Ï ¸ùüÒ@@À¿6š=fv¹â23Kö²9Mò·ýiš±ÞuZî·3õ<=3[vÙãE-"´ëhºÑ›J)À·J9ìt@ð^ÜÃú·LÓèÇu÷²³pî&»M­jß[ܼudh € €€¦­KÔâ­ÉÚ»¶ª†üÑ5,8@k†¸|…™½q$üU€¥’þ:²ô @ðþwLX_ÿïÊZúÇÔœ‹µÏ®ª·ooàÒC{÷/æJþ(иitjÓæmÜŸ{öG ú„ø‰@zZª"£NÞaä'Ý¢>$§Ð0µøÀ¾ºpÈ4ÕGE ໫Þì­ZøTbvmJcÆÍ§†ŒÆ"€ €”T $(@ŸÝרÀǧ®IÔ7KãJZ4Ï!PînåNL € €Þ `Oš¼ëã½ÞÐÚ€€ÛNâ6 € € €ž pó¬7µ!€ € €n ¸¹MÆ € € €€gÜ<ëMm € € €€Ûnn“ñ € € àY7ÏzS € € à¶›Ûd<€ € €xV€ÀͳÞÔ† € €¸-@àæ6 € € €ž pó¬7µ!€ € €n ¸¹MÆ € € €€gÜ<ëMm € € €€Ûnn“ñ € € àY`ÏVGm € €@ùìûöÁò)˜R@/`ÆÍ & € € €… ¸ñù@@@¼\€ÀÍˈæ!€ € €n|@@@/ póò¢y € € €Ÿ@@@ÀËܼ|€h € € @àÆg@@ðr7/ š‡ € €¸ñ@@@¼\€ÀÍˈæ!€ € €n|@@@/ póò¢y € € €Ÿ@@@ÀËܼ|€h € € @àÆg@@ðr`/oÍC@ðrAwR“Æôúkoäµ´±ùûÐaC5uòT͘>S6ÏWôÊדQÿz[W]}•233ôΨw]ÞïÞ½›n¹u žyêY%&ž(T!00H}ûõQïë{ë•×λòòià-Ô¹KgED„këÖ­úò‹¯uèà!'O³æÍÔ¯_Ý*ZÙYÙÚ¶m»Æ~7VûöíwÞÏmû[oŽÒúu\ÚqÓÍ}Ôªu´Þxý-—¼ö/ÙÙÙJ € €e"P­ZUg¦míšu7ö·Êœ0a’š4m¢víÛ9˯»á:@5K(sf¼ K6¨úaÜ3;–?WƒFõ®Í›6ç½iƒE;+Öòì–Y½º3Ó–Ÿ÷~@v€>3³…½L@Ù²åYE5)ßû¿mܨíf f—Kº¸ý, `X*Éç@@ Ô68z쯪–™É:SÐÖªu+½ÿ‘ë>¶‡|Äì+ËRbB‚Ù÷“úè«Õ«×jOÌnýúËšR·«zd¤SFB‚비øxE™íô”³Wî&Í33€I'’òÞ vfäfü<]÷Ü{·^þr±‚ÊSËß·w¿l IB $n%Qã@@fÍ›jÚÔiŠ2K%ï¸k3;uzÚe 9}› ÚrÓ̳ÔÓ,'¼îúk5âÅ‘e"lgÊl:}ë[–©7Ërj²AÛý¹ÏæÖs8É©)wçÜÄ'©C‡fF°w¾¥ E58 0ÀXRT.ÞG `–JòÉ@@(µÀÖ-ۜ劼û¡.î|±ÙWÖ=_™íq;5“=dùÒeÚ¿¿³­,RüïËí2ÎSSõ¨H3 —÷Rpp°þïῨFTu2ûÑì~¶‚’Ý÷é§£Íé•תQ£†&ø+`}ænO¯úð`¯m CÀSœ*é)iêA@@J(@àVB8C@@<%@àæ)iêA@@J(@àVB8C@@<%@àæ)iêA@@J(@àVB8C@@<%@àæ)iêA@@J(@àVB8C@@<%@àæ)iêA@@J(@àVB8C@@<%@àæ)iêA@@J(@àVB8C@@<%@àæ)iêA@@J(@àVB8C@@<%@àæ)iêA@@J(@àVB8C@@<%@àæ)iêA@@J(@àVB8C@@<%@àæ)iêA@@J(@àVB8C@@<%@àæ)iêA@@J(@àVB8C@@<%@àæ)iêA@@J(@àVB8C@@<%@àæ)iêA@@J(@àVB8C@@<%@àæ)iêA@@J(@àVB8C@@<%@àæ)iêA@@J(@àVB8C@@<%쩊¨@@ pAwR“Æôúkoäellþ>tØPM÷æ[ÿÔ‚…‹ôùè/òʨ¡·Þþ—Þ÷}ýúËj·løw\1nãN­ € €@‘uëÖÑàÇkÖÌYNЖ›vîØ©O?íò|ìÑXÅ‹ÕóßSôÙÑÚ¶u›ó¾ ¤úô½Q“&N.2h«U«–þüàýJMIS@@@¾ö 0H§.ôÙ§ŸéxlœSîcƒÑ {Q™ ՓÞPT(Mò“vlßáÔ߬YS%&$Ùß3eèѳ›éÛ1õèÙC?M惧¦Ô´TÝuÏzáÙá².,¹“÷LÎEu$00@C†vúüÑ+))Y6ølP¿ž´å¦íÆç£÷?Vppó~ûöíuÿŸîS›6­M€÷u^¾j‘‘zîù§•t"I?ü0AöPxx¸.¸°ƒþúø#úêóoLÀ7ß®ëÔ`Ùà;·žöçŸo‚¼4u4ß—_æÙµm{Ž)?Ë}›òêqǦ(Þ/{–J–½)%"€ €¥¨a‚Ÿ!O Ñ’ÅK4yâ—òÒÒÒt`ÿ—/ûC÷Þ½û4î Ø//ÿåWôRZZº™ñšSd›În­-›¶èÃ÷?Ì—×6€ÿýxgfkïÞ½ýñT³fM3 t“¿ÏM7ªA£úzuäkšcê³Ï–Í[Là9[«]]dýe°³|ݺuׄñ?Êε3ÁÍéiÙÒåJ7}ìÛÿæ"ëp'‹ª¤JÕjªg‚´‰'iÃú߇•ËWæ›-ÌHO×Ñ£GuÐÌXÚ|ß};Fo¿ù¶z]ÞSçµ;7¯š~nVHp¨þñê?Ù¸={ö8³ß§É“¦êÖAPdµjf\Ö;3¡gµl‘÷ì;há‚… súÜtNÛ¶&¸ß®”””¼×ܱ)Ê€÷Ë^€À­ìM)@(•€]ÞfgÚÖ®Y§qcp«¬ &©IÓ&&ÀiçüÝ ×™ðÇš%”'gzÎT ýÁý‡q̬Lþ6 ׿M›óÞ´Á¢5jyvKçµ®]»ê³ç9ÁHY¥ /êhf킵rÅ*-_¾Â5=ò ÏÌ d¯+z©e˳ ­Ú¼%íCbB‚Ž9ªN:9³_î¤ß6nÔömÛÕå’.yuºè"Í›;Ï%ÈÊ}s¦™‰ QûíÍ’Ô#βնçÚÙ´œÙÖóÚŸëÍ6lPÇ ;æ•yîyç˜%Ÿ®KK=aãŽy]X*É'@ð"=ö×GUËÌd)hkÕº•ÞÿÈuÛC>¢lqÙ aªÙÖ@_­^½V{bv›=LkJÝÃêf¹žM ®{äâãU½º #T=*R111EÖÕÚ,üè“÷ó`ùçììÓ²¥Ë”nf§-X¬'ŸzÂYVh£Ül»fVk†ÙwϽwë¥á/Ÿ1Pu'oaÎEuò½w?Ð_¸ßìÍ¡óhÁ‚Efyéñ¢sÞß·w¿3si“e¬RµŠöïÛWà³ÉÉ)Š;§ºõê9ï¯[¿Açžw®3Kk—Cfed™`{«ì2Ø›úöјoÇ:³¤õë××úõë\ÊtǦX!S™ ¸•)'…!€ €¥hÖ¼©¦™}\vŸØw rf’NO»Ì!§ïq³A[nš9c–zš€çºë¯ÕˆG–®A¿?mgcl:}ë[–©7Ë®¡ß·ÄÚŽ3Ul<ýÙò½ýøãC\^kذl7Ö, ´ÉîÛ;|øPÎ’Íq?æåÍÝ7ñÇIêСƒ™eìoyinfwòå\ìî]»õüs/š%ítÙe—jä+#œ¥’S§L-r<Ì9KZìtÊ l0ËX{öìîÌŽv0{àÖ®]çìw[k‚ø{ÿx·ššý†MÍŒ¬ ðwïr ²Ý±)vÛÈXfneFIA € €@é¶nÙæ,W´'ý=af—¶nÙj–ÉÍw)8wïÕ™jËÈÈÐr3Ke—ÏÙ½he‘ââbì2ÎS÷Eå̲íU²9€ÃžÑ¤I3C¶¼Ð*Ó̉ˆûöäŸA:õà[€ >mzæÙa'Ë3AŠÝó6qÂä|³jö€”O?­'ŸªUfieVvk>/©8y‹r.ÊÕ¢²vÍZçËîY<ä1³—m½ËI•Ѭy3“g§ó–u=aNœlبQÕٙΨ¨ê:|(çdÏ6:AŸ=Mó|³pìwc×;¸m&°m¯ú 8ûíi–¥âØÕwÞ/{ö¸•½)%"€ €%Èþ=ذ{ÇÆ|ûÝq›ìòî&{ð¢[³6ET°ÿA'0³3`¹Éî¡jÞ¢…sz¤M+W­rÖˆ4K'K›ì •]»^*;‹öâ‹#ò¾ÞxýMEFVUG³÷­ dgº¦ÿ4ÃY2™•Yø´•;yKÛŸM7;':Úk K:]ä̈-Y´4/›=ؤg¯îÎI’§§«®¹Ò9˜eÍêœå°vß¡ ö/½ô3‘fÆm}Þ#¿¬úUmÎ9G­Zm–Iºîo;½\OÚ”Ö¶²<ÏŒ[eiú‰ €>'0gö\µŒŽÖƒ= fß–}±)44T Ì2ÂS“½ ¨£ðK`gÃæÌ™«~ýûš;ÉŽ+>.Þœ"yƒ3ûfgm²ËÏ3û«†=ý„sï\Œ¹—Ìž YÛ\kpøÐa³4ow±›Ð¹ËÅÎòËéÓf*5õäɇ¶€U¿üª^½zxß™}Š©»CÇóÕåÒΦ…ï++,oQΡaajÔÄu&,ö豜“6;vtnIˆ3ûÿªèÊ«.wú±ÙU¹)ؾ6 2×ÔªUÓ9<ľòÓ”i.‡ÀLøÁ¸¶?OO {ÜéÛþ}û=…t¼@×ô¾Ú\ðµËUëͬ^Ÿ>7š=7+99ç3cӯ撾ýÌ •æ0›§¨äŽcQeñ~éÜJoH  € €@¹ |ñù—zÚ,¼ïþ?ê½çèÑâ¬1r¸K¹C—[CLÁ͉•¡æ‡~{èFˆ™Û±m§Þzc”spˆMö ’Ã_Ñ}®Wsùu5̬_–™e|ß}5Æ­¦õêÕKóÍeÔ§m¶é?ÏÐÓÏ Ë¼æV`OÐüÌ\UðÜ Ï¸–·(ç-škøK/¸ôëÃ÷>Ò®]»ÌÌVksùæp‘*NPµe˽öÊëŽQn²'`¾úú+9‡Ê˜¥Œ6г—§Û ¾OMv™ãËÃG:pÛ Ðkš /ÙÌöîQÿzG¿mȹ€;7ÙeÌ}n§JcƒçÃGŽ˜™ÈLã’Päx¸ãXdad(µ@@ã¦Ñ©M›· -uI€ à5éf©LdT”×´‡†T>„¸8ó[ý0uüÀ¾æ¾«Š@Àw|ñûEÌ®Mi̸ùîgŽ–#€ €ÅÖ›£þY`þY3çèGsÁ5 ¼W€ÀÍ{dž–!€ €e&`Oš|ôáÁeV!€€g8UÒ³ÞÔ† € €¸-@àæ6 € € €ž pó¬7µ!€ € €n ¸¹MÆ € € €€gÜ<ëMm € € €€Ûnn“ñ € € àY7ÏzS € € à¶›Ûd<€ € €xV€ÀͳÞÔ† € €¸-@àæ6 € € €ž pó¬7µ!€ € €n ¸¹MÆ € € €€gÜ<ëMm € € €€Ûnn“ñ € € àY7ÏzS € € à¶›Ûd<€ € €xV€ÀͳÞÔ† € €¸-@àæ6 € € €ž pó¬7µ!€ € €n ¸¹MÆ € € €€gÜ<ëMm € € €€Ûnn“ñ € € àY7ÏzS € € à¶›Ûd<€ € €xV€ÀͳÞÔ† € €¸-@àæ6 € € €ž pó¬7µ!€ € €n ¸¹MÆ € € €€gÜ<ëMm € € €€Ûnn“ñ € € àY`[]zZªgk¥6@@2tç 5iÜH¯¿öF^™Í߇ª©“§jÆô™²y®¸¢W¾:Gýëm]uõUÊÌÌÐ;£Þuy¿{÷nºåÖzæ©g•˜x¢Ðö©o¿>ê}}o½2âïÚ¹sW^þ   ¼e€:w鬈ˆpmݺU_~ñµ<”—'$$DW_sµ:_ÒIuêÔVVV¶>¬Ù³þ§…ó:í?½¹¿þÆkúùçéš9}–Kk›r^yu¤&NøQS&ÿäò^®Ç[oŽÒúu\Þ»éæ>jÕ:Zo¼þ–ózIòžŽe×­]_h?ì3µk×6VýÕö¼¶ Ull¬6nܤ¯¾øÆ˜dêÁ‡PFZº>ùø³|ãñ¯·ßÔcÐüù \ÚmÿbŸ‹‹×ÚÕk5nÜx%H*³ÏyFÀ ÜBB×&éÔ¥“>ûô3sÊ}lð#záo/*##S¡a¡zrØŠª¥©S~ÒŽí;±èð°  IDATœú›5kªÄ„Ä;÷èÙÍôí˜zô졟¦Ns‚ÁSSª™¼¸ëž;õ³Ãeÿ\Xr'‹êH``€† ìôù£>VRR²lðÙ ~='ð*IÚn,?zÿc…‡‡©‰ñ8°¿jÔª©wÞúwIŠã™ `©dâS5 € PÖ5Lð3ä‰!Z²x‰&OœâR|ZZšì?àòe’½{÷iþÜ0°_^þ˯è¥43³3{Öœ"›xv«hmÙ´E¾ÿa¾¼v&ÎPã¿ïÌlíÝ»W£?þjÖ¬© .¸ÀÉßç¦Õ Q}½:ò5Í1õÙÀgËæ-&ðœ­Õ¿®.²þ‚2ØY¾nݺkÂøQ%BíÚ·Ï—mÙÒåfåYºúö¿¹È:ÜÉ{&ç¢*©Rµšê™ mâÄIÚ°þ7Çaåò•ùf ‹*çÔ÷3ÒÓuôèQgŒ—.^ª±c¾W»víÆÄ;Œ^‘—ÀÍ+†F € €¥¨V­ª3Ó¶vÍ:3KæÜI&LR“¦ML€ÓÎYÎxÝ ×éû1cÍÊ¢gzlPóø fV(6 ׿M›óÞ´Á¢ jyvKçµ®]»ê³ç9FY¥ /êhf킵rÅ*-_¾B½.è€ì}ff {]ÑK-[žUhÕîä-itôÈQuêÔI6à-”•™efE¥À ü3£åQe–€³T’„ € àÛ68z쯪–™É:SÐÖªu+½ÿ‘ë>¶‡|DÙ&â²AÃT³¬ÿ€¾ZmöAí‰Ù­_YSj”ê‘‘N ®{äâãU½º #T=*R111EÖÕºMk}ôÉûù°Àüs½.ï©eK—)ÝÌ8-Z°XO>õ„³ìÐF¹)Øv;Ì¬Ö ³?îž{ïÖKÃ_>c êNÞœ‹êä{ï~ ¿=T«ÌÒʬìÖ|þ^Rqòå\”«=DÅΊٯóÚ«ÁC3{ÞÖ;'ufš¥ŽÁ¡!¬ŒÓ–¶æ‘6h‹sƒ!ù¦{Ü|sÜh5 € à"ý{°a÷Žùö; ºã65kÞÌm%{ð¢+«´ÿA'0³3`¹ÉžÙ¼E çôH›V®Zeö õT¤Y:YÚdO¨ìÚõRÙY´_‘÷õÆëo*2²ª:š½o%;Ó5ý§ΒɬÌÂÜÉ[ÚþlÚ¸Ù9 Ó^`Ó¡Õ¢ys»î³cmÙ¿¿K•¹Aä1sº&A[iG£bŸgÆ­bý©@(s9³çªet´|è0û¶ì¬–M¡¡¡j`–žšìuE…_šÚÙ°9sæª_ÿ¾æN²ãŠ7w‰õ¹égöÍÎÚd—/žwÞ¹öôνs1»cdO…¬m®58|è°l TÜÔ¹ËÅÎòËéÓf*5Õuת_~U¯^=µbÙŠ‹›bêîÐñ|u¹´³igáûÊ Ë[”shX˜5itÚ8Ë9i³cGçà–3;QEW^u¹ÓÍfÕ¦¹sçéò+{éáGÒôŸg:ž 7TãûÛÆÎI”$ÿ póÏq¥W €T* ³'Ë, 4?ì“r¾øüK=m– ÞwÿõÞ¿sôhqV 9Ü…(÷bèòt›hN¬ 5Ëûì¡!fFlǶzëQÎÁ!6ÙƒJF E7ö¹^}Ìå×5jÔ0³~Y:tè¾ûʽýX½zõÒüy òm¶žé?ÏÐÓÏ Ë¼æöÝž ù™¹ªà¹ž-2p+,oQÎ-Ì!!Ã_zÁ…üÃ÷>Ò®]»ÔæœÖæ"ò+ÌUœûó¶lÙ¢×^yÝ1²ÉTòòð‘êoîc»÷¾»UÝÌR?gNÏ\©If(©pû}"(È7C €ÆM£S›6oÊ #€ø€½€;2*Ê:DO|N !.N!¼'*!Þ,3A@T:o>÷i¡ÁxFÀmqǘk"ÂT­z ÏTZFµÄìÚ”æ›áfP  €ø‡@µÈZæ8ûcæ°ýÊ4l@ÓìL[DD5Uô­ -·n|¦@@ÀçìQó‘Õk9_$@À8UÒG•>!€ € €€_ ¸ùÕpÒ@@ðG7Uú„ € €~%@àæWÃIg@@@ÀÜüqTé € €ø•›_ 'A@@ póÇQ¥O € € àWn~5œt@@üQ€ÀÍG•>!€ € €€_ ¸ùÕpÒ@@ðG7Uú„ € €~%@àæWÃIg@@@ÀÜüqTé € €ø•›_ 'A@@ póÇQ¥O € € àWn~5œt@@üQ€ÀÍG•>!€ € €€_ ¸ùÕpÒ@@ðG7Uú„ € €~%@àæWÃIg@@@ÀÜüqTé € €ø•›_ 'A@@T€?v‹>!€ € €~"`b6fÜüd,é € €ø¯€ ܘróßá¥g € € àû̸ùþ Ò@@ðw–JúûÓ?@@ðy7ŸB:€ € €þ.È7bú‡ € €¾,`c¶€&ÍÎNͶ½ÈÎVîÍ_”•sj‰ójÎy}uþd2@@@Ü0Sg®“gæoÎÿs^5o›@˾ðû+¿ÿ÷ÿ|QÕ)a‹<IEND®B`‚input-remapper-1.4.0/readme/screenshot_2.png000066400000000000000000001021261417303655400210370ustar00rootroot00000000000000‰PNG  IHDRn]{ >MriCCPicc(‘u‘=KÃP†ß¶ŠR+uqˆPÅ¡…RAµ]Š”ZÁªKr›´B’†›)®‚‹CÁAtñkðè*¸*‚"ˆ8ùüZ¤Äs›B‹´'Üœ‡÷ž÷pï¹€?­3Ãéðl*)­æ×¤¾wøÄ˜™m-d2itŸGª¦xˆ‰^Ýë:Æ@Aµàë'žewˆç‰Ó[Ž%xx„•äñ q”Ó‰o…®xü&¸èñ—`žË.~ÑS*¶±ÒÆ¬Ä âi∡WXó<â&!Õ\Y¦P¯¹îï©ëÖÏ€À pm¶üešÓÜ7éµ–9Â;ÀåMKS€«]`ôÙ’¹Ü´üš|\ƒy`ø®{³jîãü ÈmÓ݇GÀÕ‡7þÿ¯h lk pHYs  ÒÝ~ü IDATx^ì`”åýÇÙ;d’@dï½A–J.ª¶¶U«Öê_í°¶Zg[ë¬ A‘½÷Þ{C „ì½×ÿ÷{Â…»ä’{/\’»Ë÷צ%wÏû¼Ïóy/÷¾ßç7"r mÓ&ÌÕÁíM"ÇñäͯéY…᯺ßjyÙxc¼       @µ10|£¢‚r‰*¶RYé_¯\‰¾èÕÖÍÉkG`p«2g'çp"'àhJe¥¥% i©‰ÎEeù#"Ûv^6ÐÁÁ¡MYy‹:¸ÒšòòàÜ      @ éäèDåe—ÓS8³‡mÿ¸”••‚€€€€€€€5`‡šh4GGç@qãœ9’Ò›Uœ›5Œ c¸I€µš¯C9ªÀH„Gâ³     ÖG€µšh6GëF       O€…Š‘à#     ÖK ‚œY·Õº“€õ#ë%àëìDã}}¨‡—;µtvQM*)¡“ù…´9;‡²K¹¢¿VcÍæ\1 °þÞž4·e¹9f¦Eº¹’üŒ÷ó¦yÉét(7_ÓéD³ñv0æFà¶nÝè®_Ì$77w*(ȧŸ\LÏŸoT í;t Þ}ûÑ’Å‹j=ï”iÓhà !êýŠŠrÊËÍ£è hÃúµ”Ÿ—רãÅÉ@@@@ m¿ ª3¬Q´!JÕ,Þ Ü´ÐG°3gOŸ¦Œô zâɧèóO>¦ô´´FŸaç®]5ójB¼wÎÎÎLã'M¢_ÌžMóçÍÓt<€€@cðHñ´érÑ Ê+ÈÃÑ03­_sç×äÕG‚é|Aå”™›ä7„J6Ö…Äy@@ÀÚ Ì}üq:qì8µnÝšºõ詆{9†V.[Jy7<\ªÍÑcÔ:"‚zp›ââb:sæmZ»–Š9v_ì©gŸ££‡Ó®Û«¦<|ä(êÞ«}úÁiúŒ_PŸ~ýÔ{Ý{öTÞ³wþù†Q<¥¥%UÂ29)‰xRšuï}äâêJ%|nþ}ø¨Ñ4`à@òòö¢Äk‰´nõjJˆ¿¢ú“ñž:~œÂ#"é¶îÝ©¬´”Ž9B{vî¤)S§RÇNyEt„Ç»yÃúª1·lIã'L¤°ðöLºQRÒuZ³b9]»v­ª_S´°Ò2þƒûP ?1b]¿žH_ñ¹µ”0>°:ŽŽŽ4zÔŠŒ WcËHϤå+W[tœ’Ó¦ùLl óñ¤Y ‰}—’N;ròè³vêwpãZxÓ²ô¬ºÇÁšsÜ`   p“À¤;î¤Û¶Òçÿû˜<=¼hÆìY4nÂZ±tiU#i³eóFúô£) @…]:rØÇªåË4¡\þó%¼*ÊËë •4ÖY9ÃꜜœHdâèqã©gï^ôÓ¢E”•™Aý ¦çÎ¥þõåææª.&L¾ƒ6oÜ@Ûy^‘‘t÷ÌYJ0îÚ¶6±XQ7cÖ,©—éÒŠ꘢¢":ö,­_»F‰Ó “&Ó]Üæã÷ß7‹ƒ)VZÆ?lÄž[&}?ÿ¹Úò!4]4fB@D›Ü?¶mßY%Þüü,>û^}ÞáçK+2²¨ŒE—xØv±h›Ä¯é[OO“ û¸YüR¡C°}×®^¥,pÒRR)þJ>p½N‘KàðŽ»v±',•¢YèlÞ´úöïÏጕU³ÂÄ3%^°‘cÆÐÙÓ§¨° €hð!´qÝzºKYYY´…Z9‡œˆwMgñì}Û·g·òÜ?z”®'&RÒõët`ÿ^õÚ‰c•¯µ «:&›û:røz?7'‡Ž:D-[†(Á¨3-êj£uüÎNδxá÷<Ç8JMMi¼è@…À#Ï!c? yr¹Œ3J}‹xÛºm]¾× §léró!'˜ЂD¼ífÁ&¢m"ÿ{f ¡` qÕ–½&ÛÀ@@@ Š@‡âé[>/qww7x-™ÃõíjB‚Q-ü[(ÁgI‹jÓ–þüÚß¹G‹t¤ý{wÓæõ•!~~-ÈÍ݃¦ß}7M>½ê´îäëÛ¢ê÷”ädƒ!RjŠ¡’×Ü9$Rg!¡¡‚9ŠZµ#¤Nœ·Péés¦²¹Z8ÔÕ¦¢¬\Óøcb¢Õà @@ ~ÂÂ[ÓØÑ#i ‹6ùß±swý:2ó(‘Zü]¯³ô’R’ßôkMVThØ;byåf&4»& yc¦L”¾¹°˜QVÇÍGB#ëcWÙ¸tñbrdáôÀÃPqQqU.݇/¹Î4}!¦³Ò¹wúï‹W®6óôò¢G{œNrnÜŸ|B……Õ¶-Í}ìW‡háPgÍã/­: VGàëo¾k²1‰x5r¸ —”Ű»öX|,É,Ì"ôîwßrNÛžœ\š~#,s9çÕ¹ñ6p%¹GÕX…سøÀÑ!€€€} 1˜X(3)//S9fb’æÊûÔè[PP°Áï’ßæP­Ò–1Z¥%Å*<0™…™I/X8FËÎÊd!W¤B(%´Qÿ§¨°¨ÞðÅËæááI[6mT¢MÌßß¿F¦8Èuµi¨ñ×{â8@ì@öB$bR˜D ”èrÞ,=Ýy7 ¥ïãù4ƒC#§úûªù÷ õ×7i£Å´Tjé m@@š )æ1døp:{êù¶ð£QcÇÒჩ”+6ŠIèämݺÓþ={)??:tìD]ºv¡½0ÊÌŒ êÕ·/ñq%Jt™2Ùkîıcª¸È§~@%¼J¹oÏ.5fåó0öRŒŒ‘mÚ¨Â"Zú4vÎÌÌt~¹B2‘sÒ°á#k45ÅA0Õ¦!ÆoŠ#ÞæFÀÒÕ#kã·9;Gm®­«,ù¯¨›¹ÓrÌÎq“Év›³r4]T•Ô„ @@À¾tá=Ô¤$¿ØcOü†–þô£YpïÛ½›BZ†Ò˜§Ç³¼© 3§NÒ†uk« mݼ‰îôšNO>óŒW‰\B#ç¥É†Û:“b!aì9ûÍSOsõÇlzÿÝw5A^»z÷û,Ÿ8‘Ö®ZE[7oæMÄ‹høÈ‘tç´»ØÛWHWããéÌÉSšú3ÖHòôdKiwÏà¬6åI\¹|)=üèãÍMqƦÚ4Äøë=q  pK²KËh‡BšÚ€[N" k_'s,½¸ÚN.mÂ";µ «_âÁ-M ƒ€Ø"Ù›,þÊÚt£@ˆ-ÎÁcÖÂAKKŒ}€€XþÞžjsm٧͘‰§MDÛa[¼¤&_-F¨¤u]cŒ@l‚€x¡`RcÒ4-mÀ@@À¾Êͧó "›k÷ðô$]Éÿ¤âR:‘Ÿ¯Â#µxÚô©@¸Ù×g³°9\½xYz–É͵µ•ë9c;­°Ð@@@@@Ÿ@ç¸E´/ GŽ[ãÓÇA@@@@@À$Ô¤„bÃTM‚      M­±‰ã|      `&73¡9€€€€€46·Æ&Žó€€€€€€™ ÜÌ†æ      ÐØ Ü›8Î     f€p3šƒ€€€€€@c€pklâ8€€€€€˜IÂÍL`h     M­±‰ã|      `&73¡9€€€€€46·Æ&Žó€€€€€€™ ÜÌ†æ      ÐØ Ü›8Î     f€p3šƒ€€€€€@c€pklâ8€€€€€˜IÂÍL`h     M­±‰ã|      `&73¡9€€€€€46·Æ&Žó€€€€€€™ ÜÌ†æ      ÐØ Ü›8Î     fp6³=šÛ!ü¼;œ¦   `‹ÈÙÙ…\\Ýlqø34·Ck[‡Gu´­c´   vI ¬´„ÒR¯SIqÄ›]^aLª¾*Y_r8@@@@ÀâœØÛB¥,à` 7 @¸áÓ    `Uœ]©¢¢ÂªÆ„Á€@S€pkê+€óƒ€€€€€€ nøˆ€€€€€€€•@q+¿@V;<®øäæCÎýÉÁÏ] ³"£J£3¨ì*W©DxƒÕ^: @@š#ª v.…t©Ä£9Ns¶nvp{ Žá>ä~G{rdáVÝ\ÆFRY|6­Š¦òļÆÎ   5x:–QW×<òt(‡pÃçÃf جp»wÎtõêUÚ¹m«Í·Å;u "÷‰œkuª9†û’Çc½©àdzT~.ݧiñ1O»k:]¿žDöí»å¾gß7›¯^§;vÜr_è@@ì@ˆS1utͧæžÔ¡cGÊËË£Äk×ä’7tÿ 2èjvПzŽNAaaTVRBÉ)t|ûºpèpcœÞä9,.ܦL›F ¹qâ ÊÏϧë×éð¡túäI“ÒÚàüùs”‘Q •—%Ú‰§ÍmF'ªpvàîLTzâ6PÞgGÈ!©°ÖÓO>ÊÊJiͪ55Úüö©ßÑÎí;éä‰ê=ß¾4rÔ(ŠŒŠä9)77—â¯\¡Í7Syy9½}õîÝ[µ•JT……¼L8vœÎŸ;W'w4x µïБ||¼¨¸¸„’YhzŒF£gÏ¢ÝËWÒ’ÿ|@>þ~4~Îý4ñáï³û˜Û¥ÅÛ[\¸É¯Æ_¡ý@ŽŽNäÏ“îØ¹ ͘uµm׎V-_n‘I=tÈ"ý 8§ÍerÛ¢m‚GÝéC”B´²<Š6º´»Ù!‹7©,wÆ"9o3ù© ¿€V¯XEEEEêKÒßß_‰6%&&Òê•«È×Õ<½<¨k÷îtÇÔ;”hº~ýºÑÉz²h»ÿ¡¨ˆ…Þ®;Ô‚€‹‹uèØžfÌœÁÂp‹Gí‹í;u¨qžSf¯ñŠÔÙ,33‹ºvíJ;¶íP_ìúÖ³WOÊd"~a   `<Ë942W…FV·þnœ‹Ï–Ïâíz©%•¹P ÿ».ëÄϲ¥ì‰¹t‰ÊJKÉÕÕÜ=Ü«¶,háç×(33Òé|Q!uíÖΞ9ÍÏ.Ú…Xj*?¬YÈŒÍ×’ý[h˜fuÖ±òùí2?³•—•Q/þܰ‘¦whOQÝn³_áVÊŠ™jòéi©t):šÎŸ=K?úÈÎÒ¥‹Ô{,†M$/o/vÝ&ÒºÕ«)…ßO=M1|܆µ7=1ò þÜ /Ò·_}E£ÆŽ¡„+ñ´qý:Õ—££# >‚ú ¨VCrsrÙã±B·®ó˜uE›qcö¶9pN›~Í‘¡n×è!ﳕTxÑêa§ ”]èBûÜ"ªH9G´ ¢ WrJÑþÅb ³ˆ+?þRܼi ÅÝð€b"R²³²U²•œ”L"TüXàÕ&܆AÎNÎôÍÂE¼‚vsUî‡â–°çmô¸±}ñä“„)ž=}–B[µ¢.]ºPqI1]¼x‘Cv·SII)M˜4‘½mÝÕù;óû"4?ýøuœ~x£|& @Ý{ödŸ7‡.ä+ï˜ü­Ј‘#ù¡|cp¥Ô”TÚ²i%ñ\´Úµ„ŠlE:wâ¿9Î7ÌÃÓi:|ð0‹ÚnÝ :„º÷è¡þEØ>pN견©P ?‹œ8zTýÞªuk æaµèœÄ÷E¹¯ë¬s—Û(Ÿ•½¼¼Õ±e¼Áâìj|¼Á´©+TÙO÷ãIþÜXNN6]‰UÏ'bÒ&£m⫺“ñ·$'žc?‹H$S^^®z_îÛ!¡­*Çïꢄë~ßßÏßè|këßôüë·©¹[êý½ì¸KYÌQg9=ˆ-1æ²¥NsKý4Ú’»¸Ž¯&\á‡èÞUÂmô¸ñÔ³w/úiÑ"öˆdPÿAƒéÁ¹s都GÇQBl㺵U«=ø!7‹= Ò—7}“¾D®]µR @ÿþã¨ô°Ôu ƒ™&àÔ¾†GövMR^N⪒µÚ6 ú8$Ñ^ ¿Ù! :G>–R´‹c£É/( ììêܹ#ųpÓ÷²Õ6zqkËgFÇl`üèhqRÛ‰]*¨—{./ò¦¬²šÆ"|Š9ÄP„UNvvÁc/Ç(‚„Lê‡JŠ(jBq±q¼ø›GÞ>>ÙF-‚§ñ½Vg‘‘Q\·!AyÐD F±Ñ$¢Ë‹ŒŒT‹¡çΞáÐ>gjÇÏ.­Ã"øüÆEGëðpï P^D™_pË–%×™NsŠJIi ‹Î00?w]¡|u26YÄÎb猱ùV«öù›7ns˜˜ÓV¬%$ßíÊÙstrçnsºj°¶š§™œ”ÄÞ5 £”•ùü ++Y¼‚±eãåš¼ÃÛN=Æ«^jCg=Xô?v¤ÆŒ< â¾¶nÚL'ާ ΟW}š:Oƒ‘µ³Ž+üÜ”pÓÿÏù‚Ñ´,åú)f"­LHÊ&PtE¿í¨…«Eh¬\¶ŒÂÂÂé—?ÆŸÁüè]£_‰?ê™§ÕÏïŸ{†zõíM?-þ©VaàææNnîn*Θ«Õ)h:»Îá˜â±¯rìåXÚ½k7õ`/›|Ij1''GêÃ"iÏ®=ü%}N­I\ S1ñ‹§KbÅEÐH˜f¯äÉqZÍ‘ãï%70Œ¿”eCLn=ÙÛuâØ1õoɿ冻—ç}1Z÷ôÉÓtìðQįk5Y+b±YÀ¹­)))¤ •¿C™³ä,ÊÍIæºkÇ.ª`eØ¡SG­§@;+% Ñ»vî¬ut%Údet†EXb™Ï,¦º¸ä“‹8cv)ú"G¤ø¨H$.cÊBÙ[%Ñ;Þ(!iìi“gáÐV­ Íe—8 y¡Yž]%jF¼T"ŽÌ1]á‘‚Bõì’ Ãò,mÌdÁ¹eHKŽ^Kàœ÷nÅì`Ià¦åÊc'çaÑy-ášzN)(, LŽd’vZMëüÍ·Ös[¢ÝèY3)†ŸÁV}ö…Ò'Ö`Úž4-4RÉ;’Õ1yvs÷ éwßMS§O¯:ƒ‰ðõm¡Ü´Ñ.ðƒfoµz!n\ù /^ø}ш·BÀãÙ£WÝLÇBS³ÿnø»úÖlý»Ñ m)Ö³rÕ¨M« Ú›M醫iå| “I¨à¼/¿R¹’RücîcÒþ½û *5J8äšU«ÕÙ<ø³Ô‘EÁ¬{gÑÏ?.QžØú™aò²x¤ôízâuõçÛ‡óãnÄMÔq"^q“ÈÚª:· bïñ õ…êììBNΕô9_Mû¦„ŒJX©xwð R¯ÄÉ&†™ÅÓ­3ˆ.¤úv«N 4àF´é/,¿»vì¤éwMSs“â("Å|}½ÕJÝío§ñÆWF^“PQ€€€íÐ…ÒW÷¼5¦hÓQ”§Í‹ÅžTèâDmœ $££ÊR9§Í‡ ”¸UË“߃¹‰ä½U7ñ8>y‚#dü(08ˆl{*ïVm÷rÉsdÇ‚.ìP×_?߆pº…<7袇 9ªHßDÈ8ð\Ý\ID˜V“‚€ú&©Kºgˆê}¸ºñó?WDµkCQQUoËkòL ?2~•õ1sæoθë3–úÖš¼9Íæ‡·ßåEæšy’õéÓÇ4ªp e×tqµ¬ôÅ–,^T#÷¨ˆ“.ÅŽq¸äô¿àbË© ¸+q—9gΈgäF_FëÓk8%@Ú{YE5B%Ëo„NŠ`Ó™ˆ´ê!•†_$ú¬ÊËËjõT©ŠIå5Ã.³wJ~¢¸ªÒ/¸xH‡èÂbKÙµ/#1ùk­ÙÕßÀZ¹|EË$Ÿ5Yá 4z EXTæ|UÆ´‹É*•¾9ó8o¼£ícpãxckz’ƒ6ëž{8Ìá-\°ãá ),"œî¹÷m}WkuòøIUms7¯‚Š·M*»V3u¼áÉ«0¸­Uv$mëÚKÝ™ÃBõMnÚ'xuª;{Í%Ô²_ÿ~ôóOKZ%³µ\=4¹Zج9«wõ‚€ƒ@@@uñÖ¢M²ñ%nTXîH]x7x;Ãùlbî\À$ر˜"9ÇÍ鯛Þu/TJþ¼üHÝ…Ž:«œ7cáþÕ ø«'%½‡€ê÷Y‰šQf¢€wõ‹jŽÀa(Cyþ¨oâ]’´z âÆQfÍߊ„‘ŽCpD]âªäæ0mŒ?2ó|°·0¢nü '.S)Í.–-îVNÒ”xZùÐëÿè*äHaùð´ç}'zôìÅ+øGŒŽ@Ž-á„É0“«nZÎs Ój6‡V\ʬSXs;†˜üƒve^SY,Ęe°¸ ­ÂÞ&ùÒHO­}ˇ„yk¾¾57×?—§×Ø$¬¶W¯^FÃúöëK¥\=Jâ¿ul(ò‚y¬åœÓ¦KXñªŠXýœ&XÂ}Jñ‘êÖ24„ÜÝÝ9ür—mb~|s¨¯Ih‡Xž_ûö함ªù7’£B8ZUO«°V”ÁEJD\‹‰Àª.Ô$Þ¿ºÉÊᡃé›yó¨%_Û6m£TN€äë¨PPý G…€Øo{9—M~#§Í¹ö°àÐÉÒj ”"èâKÝ)¦Ô³ª c‹˜ÆúÏÍ©LomÄä9C/ Aè’‚iRtDß$ÿ[rï%^gžž7Ï/¯yxzñ³T¹Ya‰¦T_îéòŒíÆÑI’‹®ÿSƯËû"Z¼pâe9ÂûÄÉ ¿¸¤Åû"®ìB~ðMäØbS籦 b­c©HÈ¥Š+ü%qS$­Ë>Ç!ž4„Ã%Åö¦_¢uÙç©Âõæz@IL9¥²×LÿÛLo’’“Ø»Oošv÷]¼×ßa•ü*yÃGŒTEt• ¥Òb‡ŽT8Ÿ\c Q%û‘$$\­êQòÌt¹o\¦WVÃBY<Ø·¿V´»wîQ{¢Ì¾ïÚÏ7 w”@mqH IDATÏb{®¾ØŸ7bܼa3‡*Ü chÅ!»ýô£‹ç/r˜Ÿ/ ájŒ'PŸW±þìJÅFùϦˆ}“/rÉ?6lÏ7O‘~d!CdM¼„âUôãêŒâ-¬¯•••sq‘Ó\Pe4{¬ã)[Ïs¨ëS„Ù!Þ¯n('›oD)ÉÉÁ^¾>}úpÕÖU§ö¹J¥„@J[Ûvmyß»v”~#'ÿÎãû¦/?ËÊqÕ‹…xrd,b«çNihÍ9t©¼ñ³–Blõe,}'q¾œK¶7 ”²ÐíãíË‹¶jÌRÞ_ ˜Èb³W‘ðG™³<»›¯þXD˜j}çÐÇåI…r3=ž 9]ß "ÜÂ""é™ç_P« ò€—À%M¿ÿö[Îw©Ü@gRQ¯  ˆ†séó;§Ý¥< RþôŒ^ òc‡¨ ½O;ª>$µÙ†µkùýbš|Ç*yT6E\µ|™j®å<ÛÖÏQ¶.Žçv%RpïyRJ_¥ /“÷V®4ñ¼ƒm•Ÿô²\¯\K޵ˆ6i#ì æ/ \‰j"—Ò—U'I¨½Àù{wßÌ•++-ç­pêÛ¿/)JU#ù¬$Тï©Ï˜ÎZqÜø¯žøuåøØ ›Î{²ÉæÞº\+c×@*$-øvoÀ=ˆ+8ŽV9W²Ò$pÿüÓÏ\ÕÉpî#‡ŽPP`0 ;Œ×ÃÊYÀ] ÛwTu}„hhëVôà#©/·/9©µºÉö¥ìÃ[ xòê[ ¦M¤ä’*‘ûÄ Jëf³§jㆠœ§W¿PI9¯ˆD 'Y$×föïW‹)cy<>ìÁ”ü8É‹;Ç‹(:ÛÃ×c_Ÿ¹ÎUle«…œÓ&Þt1ñ4JÒö˜ñcy{'.gœÉÛ{¬SEJÄöpññìÉ6ãn¯þž%?ð󃀀@C(¨p¢£E5£t‚nlÐ]Ä{¹Iþ[uÏ“8¤B¤äŒ•–Š`ËQŽ]©ýääëäåã¥öW+)-æ{î •ÿ&)"ª2Ç\žSãyË+)ö¡o)ü¼!©­»‡©'()f"ÏÎÆL…e±Y¬Ëm])–ËÔkÝ€»z׸X˜,&‡ð3K¤k”eö™‘Q9>CÏUªU:óv%,Z¥B¦˜±ùVï_ëüúº›Û¿ä¶ÍyåE’¨±o_û›‘ghî¹ÌmïѾ(($Ütis{F{›!Ÿ—CáQÚ*ûUtö#‡_ð…7Ä[m“,gÑV²ä¹ÅÙWœìÇvóævquD€€€@ÃHˆ»È‹›u§CÜêÙ½¸HI_Þ[K%œ2ÓÈv·zŽºŽ—½Ï¤ªceEdž5cû¬5ìm³woošùìÓÊëøÓ¿ÿË íÖñ,›š”PÜ 7Û¼Lµç9çé‹ST1)‚#9÷ªš7­¼¤ŒÊb8/mc¹eX¡YË$M´q¨0¬2i.Ñ€€€@hÇ'åiåL!"«e®PHCŸGú—èXÝ 8òkþßß°JLnVyY¬{PŽÉ\4c~4•9SE[^ “=Þd§æÌBrˆÍ&甲:Ã#­{v€€€@s àçTJy °Ûñ¢¬„iJ釧;¥pÞÌv @¸ÙîµkÚ‘óŠKJ QŠ‘ªuä´5í qv¨$ a‘ÙìÛµk¯6âÎÊΤLÎý‡Ù.7Û½vyX¼pqœ§°GçÏÝ,ÖPókŒs4ÔØÑ¯!FÛÇ àA@@@@@êG­~Üp€€€€@(å’úH½h ºèÖV @¸Ùê•øA@@@À ˆhKOM"gçš{ªÙát1%ÐL9nšQÙwCÙ+   MM@-Z4o˜}“ÈÉÊ"W·&N öB >î%^»N»wí¦Ã‡˜5è.]:Ó Á雯çWgì5³:5£±þ\ôûäãOÉ××—fÎú½ñú›”ž–^õöرchì¸1ôúëÿ¤^xž‚‚ƒj=ãS¿û=Uç•›KgOŸ¥åËVP.ÿ[ßèÕ¿ý…V¯ZMë×m4c&h   `k ÜlíŠa¼  `ƒbcãhÞ—_“ƒ£ùx{+ñõÈ/¦ÔÔTŠ‹»¢yF=zõ¨ÑÖØkš;¬GCïüo™‘‘AÅÅÅÔ»w/ºïþ{é£>Vï‹H»cêúü³/¨ ¿€>üðcrvª¼õNš<é» kŒ"..޾™÷-9;;Shh(M¿k=üÈCô¯oÆ ¡Œô 6lmX¿‰***ê1#  `  Ülá*aŒ  `ãJKJ(=½Ò •–šF WiØðaÌÂF'Üþü×Whß¾ý´‘ˆÎnŸ8žúõëKo¾ñ6Íyà~%øÄäµÜÜ<:}úL×^~éOäàà@'M ¡C‡o _JII¥Í›6Ó¾½û«úþý3OÑÁƒ‡)22œúôí£^¿xá"ý°pq Ï–>þhIIIF¯ÈÂï~ —ÿü 2ˆöï;@÷ߨ€Î=_5wÝùäË\ŒõUR\¢Æ,–˜xˆïܹ“«««ˆbNNŽ4dÈZºtÍž=‹ºuëF§N²ñO †  P7|6@@•€x‘DP°p¹pñ¢æs·à{rus¥ò²2ƒPIc¯M`Á7jôH%®]½Jí;´gq3Sy¤DPélæÌ»iýú ôÞ;ÿ&oö>ôð4uú$¬>–•M?.ú‘fß3›) ÀŸ>ýäõéÊà˜òÒ2%F…N¸õêÕKý~ìè15¿#‡A¸Ý2it ÖKÂÍz¯ F vC@„Å{ÿ~GÍG¼Fׯ'чÿýˆ²³r,>G8ã'Œ§œvüØqÕ¿x¯BZ¶¤ o7nWâªrÃ’““içÝ пÎ1É\þýþ{mž{ö*OìС#Ô·?š4e"}ÀsÔ ­úLTæÚR…U;zœò9?Pg#FçÁÃTRRªæôìs¿'ÉyÓϯ«Ï9q €€€u€p³Îë‚Q€€]¸råŠÊÙóòò¢^}zÓÓ¿JålIþ›%ÍÏßÜÝÜéòeÃܹ˜ØX%èÄKUZZªNy•C6õ-?/ŸÜÝÝëΕ+ñ5rÜt¢Mtww£ð°0ÊËË£Û¸˜Ê…óÌžžN:9:r^ #mÛºV¬XUÕOhhuèØ–-]®^»s™RSR8×m­\±Úìóáë'áfý×#›' Ÿ³%Þ/kíÛµ¥qãÇÑ—_|Uëü\]Íž»³““:Æjê(//ãpÉJϘXqQ‘Ùýוã&Ýý‹»U^ÚšÕkèÙ?óÄ©N:ñ\~ó»_³×®ˆä¼:>b˜úçžöæøÙ;'9okV¯¥²²›s4{‚8@@À* @¸Yåe¹õA=üÀ]œ_ág²£Ô´ úö»Ê[˜y|ó?x¶ÑtPô¥Ëôà#OjjÛ-È7ôzè<àÖq4›9;rq }OUaa‡QºÌ?$¤¥Áï,HÄ¥oÕ_“"(RئmÅs(¤ÎÚFµ¡ääÔ5²5Aß~}輩ª=nß¾ƒxp½õæÛU^>-X_.ü~=ñÄãtòÄ)%x]8ÔtàÀ´fÍZ>©3oo/zê©ßQO®lyôðQ-§A"€ ¸mèb™3Ôÿšškwš:j†Ú¶‰Ò<ëvmµ <Í¢!Ø OôóóS?­ÃZs‰ü;(**й)0âX”H9}oUˆ£k×®Ô½§aùÿ´ô4jß¾µlL-ü*¿çª¿&Þ¦-›·Ð”)“©{÷îêœýô£QcFr>Û†[&'Â)$$ÄàGòö$Dòþ9÷ÑÊå«”h[½r Tq¡;î˜\ïóžáÊ™¢¹pŠ‹« õïßWñÙ²i+XõsñB4?~’F ^ïsá@ë%`3·ÝbÈѱœ.œì`4ÿøâ(†½(K~úÙ*ÆS}rS×bZÛi髹µqäòÜZÍœ¶Zû´õvµyÙôç¥ß¦±¼oÖþ·m«×½MÛ6ô÷×ÿ¦†_Äá‰ÉIÉœóöMUñy]BüfûxÓŸ¸œ¾ôH¸’ÀùZ+«ÊýK›­[¶S›6mèÿ^z²³sèÕ¿¾fô5ÙÓ¬˜KêÏœ5ƒüüý)--óÁ–Ñ!@·jQQ‘ô§¿¼lÐlÀÝ‹EgFfíܱ«ê=™ÇÂï ßþö‰z…Lê:ZòÓRzåO/Ó´iS•pݳg¯âXݶlÙJÏqx¦ËÚ¶,¸Õù[óñN,êûr‘–näZXÂ[N³·õ‡É–Yløø®°Jt JÀžþVÂ"ÚEDu6?‰ Av.¢í¶¾çÔ‹çu2)Þƒé7_çŠbËiõªµ 2Rkÿ<÷ô#šçý¯ÿ~­¹­‹‹ Ý>áv8¸?1çòò ®Ö–«ÛÛh÷ÎÝšû±‡†{wšW`Ȉ;jö}ÜGcÇŽ¦ÿüë}:}êŒA»iÓ§RÇN\‘ïÿضª9hnú®K¸éøI{)÷žËE!¸€Ä~€>tÀ¼usÿ¶»v»M•¶ÿâóÚó´šâ•pN”O mž÷¦Îiÿr²²ØChúÚ³vqõ¡Žï!OOÃðZÝ9 ’yŸ¾Å,è³5 ÃÔýÎÜï M'm¤FòΞïwÞºY5ŒþÅçiͪ5´qÃͽiH8páüâÎ2¾Ã}½&½ø¸óÅVïqÓm2…ν+«sÕåy9j8‡©¤ÓÈQ#iíšuJ\Àˆ&NœH¾¾¾5PÌš5Ë൘Ëq4ç¡ßÖh'{%½ðâUx’¬ŒK3¹±EFFPnN.ß""~ЖP¨¿¾ò7’Û»YÚƒßÇÏ>ùœ+:‘,Þôèу}ì—Ô¹s'únþ÷ †Sª#Â@š–€xÚt¢­°@6xßJ99±jP>>m(<| yx´äE°Ùtæô<“ž·æv¿ ¢gØS+›ÔC´5ígÙÎ>/ÿÅ8ºÐ;nA”ãP™•åÃE¡ž/âP÷òbÒzÿÇ}½æ§Áª…[uѦ¾“Kí¡Rkøðôãâé~^QêÎo'ŽŸ¨š¹¬íß{€¢ÚEqr÷*äP“#œÄ½äÇ%\]¬²b—–6ú(ÿô—W(::š~àrù¶ð¡wß{›K]j Ô”ÆD›±ñ´mit˜S§ÝI¡­C”°°#]¼ps] œrç1b8ùòj¿xã$§DßgН­ð´ôµ<°ÿ uìÐîš1ý°¸Öî%¼u2çîŒ3‚H|(>.žñ†¿1—bè÷Ï>¥ÂÇæ}ùuå% õwÿõ6ïñ´¾úâækïýûmzçÍwéâÅhKOCs–ô¸ÉIKKJª>—I†wæôY:ÁÕüž{áYµA±Î“Y?cƒ¯«ý#s¢¡Ã‡ªÃÀŠyô‡gŸWùGµ]#Í€Ð@@3 O›xÓΞû†ÊJ «ŽÍ̼@9¹W¨[·Ç”x“¶IIëì[ËýN:;Í}óûq¡ >÷9Zðí÷¼˜Y¹?aëÖ­T•Ñ6\ÈÊÃî^¹Jß}·®ÄUnU¡î‡¼Ÿ„ßö¿±à¹sçiþ·ßUõ!ù¡÷Ü7› È…yè(ç…ž?wFòóÿä"8b·òãÇ‹±Ïýñ9Ú·w­ÒÛJ¢®>›ë}Zó²™7¼èäJËŠé•ÂTú§GgU8ÐËE©Q^¢VÃ}½&©&/NÒ¾k uêyóÁ_7ÄÚD[ôéötöHçZ¯¹Tórqq¦Ã¼ª$sædôê6ûþYtýÚuúÇß^§o¾úFU›={¦A3-mtìÞµ‹E`ÎÁ«,A-Ö¿_?ÊË/ S'Oiý|6Z»·Þz‹jûÑÝŒ fèС´mËÑV½Ýä)“hܸ1JxüõϯÒúµëéþû率¼·¾ÕÅ×ÖxZêÂ9ðÛW_Ì£ÑcGS;.“^›Ièäð‘Ãè‹ÿ}Izé/tþüyzæ¹§©E _%VÄä³^½{(ï³xÐåÙuéÒ‰¤zß%ÎÑ´w“‡(´ƒªšj]üŒñ¨«ý×¼/Ù¡ƒ‡é?xýê±ß(Ñ&fî9ìý:`~ ÐÐ$§M,þÊ&Ѧ;¯9yO,ðFۺƤå~'Ç=Š’“èïü<ñþ¿ÿK‘4cæ]U]çpnãqµPö§ÿLɼ˜ùè£s N}Ͻ³I*¡¾ñú[ôÁûQG±è÷1ý®iÔ·o_úì_ðsËj«‰9ÜoÐG}¿s¤©xÚ¤bé’ söëê³¹Þ§úsl/ýÿË5âY …W”ÐKì׉6yM¼p·bÍý¾Þ¤ÂMÄY×~ç¨s¯‹â­¾¢M}‰ŽEö ^}ß³k/uãŠb6¥oâz•PY•?uò4-ç LGpx¥„ýéLK]Ûýì)ñðô$ÉuÑYöæäÜš²2Ë%BßÊ]ÿØ_|‘jû©íìAcïN||­ÃÕ¹I,Ü–/[©¼˜R|`÷®=œÿ¶•¦Ü1Ió5°5žµyã3Òýh¹vμàpùr,m\¿~ä!ïqu“ׯ«Šâˆ·Lnö²¯TÑ“ˆÓgΨϻäŠI…¾;v’‡*´m×N½&åÊÏóŠ®ìgÕÔòÖ;$?:3õ{}Æ{íj"·¬¼Q˜âg.ocã1÷õ™Ž0$àáQù7žS+š¬¬Ê÷tEKjk¨å~§;V¾³%ª$%9…ä¹aû¶ o™™*âDœó'…kBÙ '!Ý:‹cï›äã']¿®¢wvpmÛ¶QoËÖcÆREzd!X ά]½Î Z¢¾ß9n¼IýïŸ}š¸xOuÑfªO{¹Oãï¨aHxä,ÐtâM/ÿ.‡í®n^úvéÒ%tùFå\µ‘)¾vÏÓȇB—Æ»bùJêÕ«Mf±«¶"‡ð"=üðƒjo(yñkþ|ó½–pM=t¾­ e8¨Ê©‹çÜ^½zª¿ yoóÆæ“x.¡E\¯D™)~Õ/‹¹íës»ø~À$@ÀÊ ´m;•µ*·–päUÿþ^¦ÔÔ{yUÍ‘k¸ßéºzÕðyBžÜYé,<<\}—K.¸láÄ÷A‰~prráûaåâYBµѼÜ<òp÷¨üÎâûª¬ê÷UYDíØ©ò™¨>ßSr\dT­ã:’·>çÁûTćÎLõÙ,ž{¬ü3msÃãç{KYs¾¯7™p+/¯éì«oÕ®¬„GšmrÈ(ö¶‰½üÊ‹7{`/ä¼­X¶ªÊûåTmóVÙG™îéŽÿ©¥þ0wïÚM¿yò rã Z§žš’J1ѵ¯üYêÃ[Ÿ~$L²6Oœ1+à°Où¢–›äb3f•k¾+Þ©ö§3S|m‰g}®A]ÇÈÍüË/çÑ /=OG8ä·œzuæxc›‡/>ûÒ`cay¿°°@5;{#\2/7—WŸ³9<(žNòÞN'NP{[ɆƧ¹Ms±HΉUÓÕÂOŸ‹¹íësŽær0OhH©äåÕZ"‘œ¶êv%nyzµâ<³JÏœ´[otHZîwºu¹ñÆ:òñöæ*Ï©Pê7^“¤_[/ü_eHµÎŠnä×ÌÛ¦þýSÚ•””V5¯Ï÷”}ñý¼d™ÚÄýÿ÷Gþ½}Ûwjþ®lÎ÷é†ü,ÛCßRˆD?F¦+ fp:Y “Fý‚ZÚèwröÌY%lzrNQŸ¾½U²qSš|É×¶G[mâÌÔx9¢BQ7nÜL9,ª[ZjšbЮ}[ºr¥2ñZ¬‡è%%¥„šâkm¬öÃJåë$a;ÍÁ¤`@DDxUá -üô¹hi/^h½°V-Ç4ö˜#4&´ÔSJ¸…GŒQ…Hô‹“È8ʤ(Â¥¥Ô¥ë#jXòoy­63u¿Ó2·H.ôåååEË–­P¢MLª7šcé*õCžSdNg­BC̺/;gÅEAñæ-þaÝ7ç^^äŠS…S´|Ùê}Úþh[?R=R„š„JŠPÓ ¹?r‘’¿¸ß²CËÙšû}½IsÜD¼=Ò¥Öë¤U´IRÑM¶ŒØ°n“ ÓýHå¥#üð* Ä:kÇ›—Þ>a¼ /èØ±Iõ¨ÛwU…ñI;-mô.[ìå Qe&)Õ™šÒÒ3²,~z 7-`¯Î‹/ýQ‘â6m¢¨ß€~r©„ÙÎÏš6ýNêÉayº7…ÂØÛÇpüþƒñ˜âkm<-SC‡ÂÌÙÕ™ XÕº´´”6mØLwògV ”ÈC€xAnjś û©vgNsž¶{öꮪ)ŠI…³ØË—i츱tæÔi g·½&Μ£ÈáÎ-Ù£Øå¶Îê!äñ'Uù ÎW®Àká§?s-íSÓRù{¤½òdÊ5ÐrŒíÑňAÀº ¤¤eq”ÄáúœÛþùtápDÃ=ä x#nñ²Éü».3u¿ÓB#55]Eš´oß^5—ï¦ “&h9´ª, mߺC=§ÈóŠ¡’ûo.8¥3K|çÈÆöRhé‰ßþZ…ãké÷i³.e³i,Ù›Rò?Úѵʻ¦Ëy“Š’íXÐi¸¯×üØ4™ÇM7”ÚÝä€0°0«n2/)ùo‹æíãC-|}èžûfÒ×Ìá0-k´Ø¸xjËÉÒ¦ìrìÍÂ"¦Ú6Äû¶Â3†÷ïéÀ…X´Ø¥˜X-ÍšU›DÞÓ¥uùÍÊhuM^ö€@s$ EÁ¤ò¤ä)KÑ©ûs_Wú÷{ÿi2¶rŸn2@814< Yì3¼eh«–$9D?/þÙ=6L÷?ø›†éØÂ½Ú ÏyÒÄÌoV\9ßT[ C´î^p¿Y͆‹!‚€@“ÈÍË£)S&qh°Úª(‰óÊeϵ3M¸­‹­Ü§›ä‚ᤠЀ¬&Ç­爮A@ Ù@Ž[³»äV7á¦Èq³:€Xˆ€ä¸i­Èi¡S¢s TÒ\bh 6B `zÓåÀØ" ³ ä|;·{G×  ÐüÀãÖü®9f      `c Ülì‚a¸     Í„[ó»æ˜1€€€€€€€p³± †á‚€€€€4?nÍïšcÆ     6FÂÍÆ.†      Ðü@¸5¿kŽƒ€€€€Ø7»`.€€€€€@ó#áÖü®9f      `c Ülì‚a¸     Í„[ó»æ˜1€€€€€€€p³± †á‚€€€€4?nÍïšcÆ     6FÂÍÆ.†      Ðü@¸5¿kŽƒ€€€€Ø7»`.€€€€€@ó#àÜü¦Œƒ€XšÀc#ýiroÕmyQna9ŧ—Іӹ´ëb¾Y§ëáN£»xÑûÓªŽ3öšYšÑX.ú‡ýce ù{9Ñ£#üé™…×)%§´êí©½|hZõúÛ³B(´Eí·×_|OÕye”ÑѸBš¿'“²™¾û8Ó'¶¢…²hÉ¡lM3qqr WgÊ+2ìKÓÁh  `• ܬò²`P  `{.&Ó¿Ö§‘ƒ‘¯‡#añõì„@ºžUJÑÉÅš'4 ­G¶Æ^ÓÜa=Êxÿ»)ÝàÈÔÜR**© Áí=è7cüéµ)ê}i÷ jAo­MUBI^wfá$6³¿/…ø:ÓG[ û’÷„×XœŠÈ ÷w¡‡¶ g˜—®_ÝÉoïæErî ]½éçÃÙTÁÂØ” jçA#;{Ò«RM5Åû  6BÂÍF.†  ÖN ¸¬‚’ox¡’Ø1›ZBº{+a£nÌiE[ÎæÑÒ#7=Gw÷õ¥á=é‹®ÓïÆИۼÔTåµlöDaOTõ×wM Ä™ý|i< ?OGºž]JËæ¨þuöÚÝ-iç…|jìBC;xª—O]-¢ÿmKç¾k÷F•VÐÕŒ£È?Ù’Aÿ¹/”Æò8·ò¹~;&€¶Ï£ãñ…ª}Cg"äŠKËö%¼DÔŠ‰wRæó 77%ÅœhŸçöÄ=Î^Í~Qt(¶ÀÚ? €€@€pk¨è@š;ñ"ïêEyö'BI«‰gÊCüXÓ„J{m‹¶)=}”‹M+¡®­ÝT¢x¤¶ž»)Þæ÷£%ì©zé§$ö:ÑÓãhÎ`?údkM/˜–qfä—Ñç;2èW£ü©%‡1ù8Ñë«+½o·bec*âÍ…ÅZU 7ñî ˽ÑØ\éUÔ…^š3>kj ÑfMWcÐB@š\\\¨¨L{Ô‰©~!ÜLjï—SPH(D[3¸Ö˜¢u“”ž”dÓÂM?gë:ë]Z¹Ò]}|éuµÉ0HsÍY”›±:²¸©/² KÌ_í¬+ÇMÎûÈ0?Šgâû³”l?{ý̧:qèÌó¼rg0r^œWgºWzÿ93¤ê5q8IÎÛ¢Ù$¡•ú&B5-·Ò»)^;¹ºßÍåkmíái³¶+‚ñ€h!`éï.7-Ôí¼M9{Ûài³ó‹ŒéY=gWæ kŸ˜#+r½ì‚ârrgO™¾…ùÞŠDÜÐeUͪ¿–’S¦ÄI§WºœrÓ›×)Ô•½R%5D%9ÉÖøpÊ3 )•DZöT.=ÉEUž_œTååÓr>}qø)çé½tG0¼\)EÌŽî,-K…Oê̇«u¾:½%IÕÈ=Ñæm³ eLh  `½°·õ^Œ @lŠ€„'z;©Ÿ¨@U"¿# «Ýzû¸]äD ákÁEBÄ{Ô7ʪ—ú—Ê”·q¾\+ÎW à}ÓĪ¿&Þ¦Çrèž¾ÔŸÃ園s6¥‡æ½Îê‚+ÂIòÎô$oOB$¥Šäw{3•h[È^7W~OÆR_“Ê™;.äqá”@µÿÚpž‹ð‘9^ናºŸÓ\èå{÷&qî[]&yqØ  ¾WÇ€€u€ÇÍ:¯ F 6G@¼_Ÿ=ÜZ»ËÙ_Í,¡oH«*"¯/b‘Ób´?½¨ ŒaoÙ‚½YjÏ7­â\/éë½{BUa“ßÎO$c¯ýÌ›Qs¿áGA,Ü’³ËTÙü\þÿV­CKWú/QßdîÁœ·'{ª­;™[õ–ä}º5ƒþ45¸^!“ºŽæíÌT\Ü‚ºpnÛ¦3yŠcu[q,›ÞøEˆ•µmYp«óÇñ  ÖGÀ!,¢}QDTgWëFÔXr²3¨UxTc稅@bBùøú[„T‰zhžEúB' PqßÎ%W·úZãw©¸ Û#PX`~ñ*c³Œ;_ŒPIÛ»þ1€€€€€@3#áÖÌ.8¦      `{ ÜlïšaÄ     ÍŒ„[3»à˜.€€€Ü >zÿVDZ õ$`ñª’÷=p;Z §¼¼Œ²²²éäñ“´dÉRÊÏ»õJ_õœ'uív :„¾øü«úvã@À$Î;Ò€ýiÁ‚…&Û¢€€€€h%`qá&'މ¹LŸ}ò9¹»»QxdÍœ9ƒüüéƒÿ|¨u\o׫Oo‹÷‰A :î=º €€€€Xœ@ƒ·Ò’JKKSƒ½zõU”—Ó£?Jn\¸ˆKTÿñÅ?Ðö­Û) (&OžDññ ôîÛïñf£4yÊd5fùøøP|\<-Zô#Å\ŠQ}õØŸîœ:…Z¶lI…tùr,}þ¿/©°°°Îc™û >Tõ1pÐÊÉÉ£?<û¼Åa6—Ÿ|ò7tøðQ £>½{©i_ŒŽ¦Å‹¦¼ÜʽäZŽ?–† L^ÞÞü9¸JË–­¤¸Ø8úÕ¯¥Üœ\úþûEª­·7½öÚ_èСÃôýw†¯}øÁǼ[måŽP/ ôéÛ‹Š‹Jèĉ“´bÅj*).Ví¥ÍžÝ{Õ¢ÁøqcèêµkôчŸÖ969®wŸ^4aÂx ¢"þlÅ]¹B ¾]H…E•Ÿ³Úæuï½³ùóÕ_»oßÞ”Ëæ¿üùoÍåcÑìæÙ¶][êÔ©C󎎾D—¢+¿¿`    p+D¸UPyY9?ð9:ñÿܰ “&*q÷ß÷?¤¼¼<õê´éSiÐAô‹±´´t=f$=óÜÓôç—ÿBNÎÎôø¯£Ï?ýœÎ_¸H~-|)ªm”m¦ŽýzÞ·äêæFåee•¼•O‹Þ±wß=•6nÜBÿa/ª——Íyà^ºóÎÉ´è‡U«I“n§~ýûÑüùßQzF& 6„žøõcôϾMÎ_¤#‡WõÖ½[WÊà6·u½M £ŠŠ êØ¡=Sll|­#¾ëîi´vÍzú×»ï³È ¤{ﻇùøü¹ê˜1cGóùÓés‘Í»ª[רœœœé¡‡æÐ·ß, h^0ðõñ¥ÈÈp%ÚLÍë‡óçÌU}Î*i¡š•wÓ¶]êÜ©“ÑQŠh“€€€X‚@ƒ'qf±ÕžÀ§Þ5•N²7¤@o:ggúô“Ïx5ú]O¼NNNNÊ“±ä§ŸéâÅhJOO§eK—S‹¾¾ýú‡»;9::PAaådg+/Ý®»SÇZú0$Ÿp•…ÛfJNNfÏçeÚ»g/ œˆ×Ñ…ÙZ¹rò–e²([³z•U”SÏžÝéÜù À^°€€Õ¾{÷n´oï~rrp¤6m*7ïбEóç@ò$k³¸¸+´mÛJII¥³gϳˆ[Gƒ‡ $þléLÿ×_/PÞY«“SÝcswwUâQÄ+x½tûöÐ4/|FšË¾~Ý&^HºPcâ"ØÖ­Ûo[óúH`¶ pË~ÿÌS4±a  `Œ@ƒxÜ:vêHŸ|ö‰p+f¯‰„À-bo„¾=sV…Pê, 0€<<=èᇤœSõº¿æïï¯B.æ'¿{ò·s‰¶mÞFÇŽãû 2u,.½å \ãë¡oâÍrgq-&×ËÃÃî¹g&Íš=£ª™'¿ßÂÏO õììêØ©=9’K¸ ÇÚµë©uXkêÆEdDdudá¶cûŽ:žx-Ñà}rŽŽN|þ%ÒÄ.òCµþçÌÔØ“hÕªµôËGao_íÚ¹‡Nž<¥¼€¦ŽµF g*È/0ë84hz "Ü$éË/æ)Ñ–E¥¥5½&¥%¥³—7±/>ûRyÓô­½lbkÙk³sûN6|Ý7ç>û8zÿߪð8SÇ6=jûAñ<2c³•Ø‚ùß+•ᵬ 9¼À^·íÛ«ðÅöl‰0?Ãb~ì˜Q´“=©ÁÁAì™»X'4GGC‡± ߈ÄDd鬴Ôðs¦el›7m¡}{ö©›ñŒ™wѨQÃé³Ï¾Rž8S󲯫ŒÙh! oN¼h Ö\=mô§É=¼~F¦ÜÂrŠO/¡ §si×Eó* ÷Šp§Ñ]¼èý•¹ÒbÆ^Ór}êÓF7—5'réË5ºèæF¯ÝÕ’ÒóÊèñ¯¯Ñ vôä¸òtuTó~S‰+$}&ò­”ÃïŦÓÆ3y´'Ú‰¹m#]è/K+¨ª›~_úïýce ½RùlÏæâêJÏ>û4ùrJņõUnµ•ððpÎîLͰ”õèÕÃR]5Z?={ö¤\3@¢ž` ¶E A„›<Ô‹WÅ“œ6 OkÕ:T™¨ÍrùKw=‡ íܹ‹Þy÷-³ëJÇŸ0y¬x]8Öð$_MrÂBBZ*1fÌ$Oqòä Ü®˜N±GKìÌ™stß}³Uh¬„ʦrd]ÖŠWõ-,,L…ÖfdÔ|ÐÒµÓ26i›—ŸO[¶l£½ûöÓß^ýuéÒ‰NŸ>kr^•Ÿ³@nø ˆ3˜M@‰·µÕq—nS2»;8àbR1ýk}šÊiöõp¤1,¾žH׳J):¹²hÐÖ£F3c¯i髾mdÌ#;{Ò·{2©¤ìæbô7¡›·š“«såbÎþ˜JÎN¦wï ¥JRïéLÇDn?-}œ©o”;ý~|ugñ÷ÙvÃï*sÚšš—ðþï¦tƒf©¹† Y¦ú°Õ÷§L™D!¡!ôúßÿ©î%:“Ô ÉBÜÄIÔ6A"ð$ä~ó¦ÍÞ8 [þìÙ3UÄÆþyÍrÌÌ™wÓúõè½wþMÞ\!ù¡‡ ©Ó串ßý@ß-ø¾ª UõPIÉÙ—ûxµt{Øj=§µ\©KpâäIZºt™Š š~÷tU¼ë×ß´–!b ÐìXpò+W¬¤|~Xž4y"¯dÍQ…HbùúС£ääX® ^Ü7ç^•;'‚mÞWßT…UÖu¬ô½qÃmé/¯þ™²¸¸ÉË/¾Òì.vcNXÂÅD´7šfΚÁײPmïpŒE–˜ä¸%]O"^é¼Ä×Xg§Ø«uÇ“èü¹šª‡Í¶jJ'ßαYÄ}ŸàÏÐ*“Ó¬klŽåŽ4„+›þbÆ]ª’i*çD,\¸¨*äÓÔ¼¶mßEQQ‘ôÇ?>KÙú^ñ…@s$àÂU„Çwõ¢<¡¤Õ>Ú’NnìÉ'—~¨¤±×f°h›ÒÓG‰°Ø´êÚÚM…'J´ôÖs7ÅÛÜá~´äp6½ÄÞ0_'zš=^sûÑ'[ =RúcQ¸•àí, õ…›È3׊(=·öÂI¦æz2¡.°wrd'Oáfì8sÚš:osxßó«}|}(½Cµ™x¾Æó–/+–­ ãÇŽ«fâq á­†&L¼Ý@¸]áÔõë*½éòܱk×nÀ‹s¦LB3¿üòëªksÎiªïú¼/Âôñ_=ª•EogWzóí7ÔÿT£Û¬Ì,ä.BöôïŸTá` MCÀâÂmá‚…&gòÎ[ïm#…F6nؤ~ŒÙÛo¾SkߦŽÍÍÉá³ÿ˜˜&ðᇟÔh$•uÕåMYµ”ŠòS›½eäs°‰+UÊ“s,äÕRù1fÆÆ©elü÷ãZOoj^â5üøcä h¹~hcº¶r£ï®&ææâ@ ìñyuE eæ×_äÔFI‚ÅS7ŸC÷q¨¢˜„(¶ös&túÂ-†Ã—ªôð]c¯—äÝìT··Lr§7q.šô%}ÊqrNr öf’¿×­=¼Æ³Ð ó×v 6§­Ž—ˆØž¨¼:»ÿ *ÿОM—‹ÌŠ©Öiúùû‘;Gô\¾|Å MLl¬t²8¬‹Üð@}ï™®W]ÏŸ;oPËœs6Äõ‘<¿7ßx[u݃«;÷æ=Xçûú½¶œu)vû„Û)‚÷luuqeÁæ¬Â=… @ i௯i¸ã¬ Puƒ¶@_è@àÖ H^ÕnñáÂ!í=¹ˆG0½ÆâMò·,i>NäÁâ°z¿®+A'?]nšxãôM ˆxºÞÜW´¶qIØç öŽI(¦äºuw'>î`lÊs»žV eN[ݘ.Éq³wÑ&s—J‰¹ÓºuÿvÄè%r¾á1â]Ck¼/[ÐT艾â"íÞbýÎJK ?sæœóV>Wµ+B43³2EDÄg O÷»±c$,TrõqŽß{ïþ[q¯Ý3\ô ÐtPE¡éØãÌ  `W$GK¼^ò#‚JÄŽT–¼«oó”0HsÍ™÷õ3&~¸F‘g©°¤vï‹©óŠ×MrÙœYNèæ¥òç¤ÿ[µvÁ®œƒ¦­Xˆ9muãÒå¸Iž›îçVÇl+ÇãðÇá#†‘·Ñ!K»6\tCßÚFµápÈTUäJ«Up[‡jŽkÉsjÛ­´‹à‚,žžž´zÕšªm‚x[€@Ó€pkZþ8;€Ø5 9,×Û¢£ ¸œÜÙS¦oÕCÅ3tC—U5«þZJNå•S§Wƒ¾:…ºRbV ‹+­þ¬ºñK±Û9_O*[J)ÿ[5ñD¶ ráÜ9Ó}™ÓöVÇe/ǯ\±ZU~ö¹ß«ªRq822BU† ˆPÂlËæ-4eÊd®LÝüxÑ~úѨ1#UÕjs,-=Ú·oG-[ó>¥-j=Ô’çÔ?‰+þjÕº•Á»»[S8|øˆÉ­ÒRÓUʃë“‚[ã¸B& @ i T²iùãìõ$P[þZ=»Ãa  á‰Þ•¹_ÞnŽªŠcGVËô*H^ä¾Áí=HöHË.,£>‘îJé{Ÿ$Dqtg/jŹeE%j¿4c¯IeÇ{úR ¹œZ¬Š“LéáCŸr±KY){%_N œH‘•d®\i޹2Ù@œ2AÌfP;OšÈ•6¥ªæi.rbðnF[wöRFTî]©³æ ÂXL¼˜aþ†ïËvÂÓÞMrÚß~ó].t6&³8ócA%yð))É´äÇ¥júÖoR•¥x–Ÿ¿?¥¥¥Ñ2®žxèà!³ðlݲڴiCÿ÷Ò ªèÖ«}­Öã-uNýˆ }ù•ÿ38çW¼îÑ£ÇÌšGõÆRˆåç%Ké~.'å[3Ò3há÷?¨ðI€@Óp‹h_ÕÙpÉ²éÆƒ37œì jn2ÒÃÀ)A ÙHLˆãŠxþáPR\DQͳH_Zô™Á IDAT:©¾és! „«™%´‚ËóëoÀ-åô5ÚŸ÷1s' ç‹á ©O³ ’pDÙ@Lö€{Ž÷ëê¦ ›üv~¢Ñ×ÄowgïÿoïN૬Î<Žÿonr³a߉pD@Q ¸ƒ­Emkkíæ§ÓE*ÚªµV­3íL§Óé´uG”ªPµµÊ¾º€ aI²’’Ü›ÜdÎûF^’›Ü÷æþN›OËÍ{Þ÷9ßsÄ<9ç='Õ>øÛJŠ ÊƒZòa…ÞøèpcÈÖqÛøÍ†"ÇŽæ°v»¼il}gîþf›fµ%Ù$žGwµ´6'±Ž1øõò"­ÛÕ0g=ÓÚ¸Ä:€û"s<À]—uSŠi[¹yî÷ÍÀmMV˜DÕZBº|ëá“Â>ñs§×߀ß,/ÖÚ]•Mþ>þû}wÎܯ+Î×ò쓱e]cíIqÀïÿð;}ÿ®¸' "AÀÅÕæhP”Üœì‰[($#ü$nÞ„ßi"9që4@CB&@âÖzJkçÆ_=þh³7xçÝzmÉk­¿yj’¸…‘[D@(7–JFͰ9uCc<1 ÖÖ˜³]š.«:N ¦6`V$ñÚqljó$Ü+`íù“ÙsÜ ‘!€@Xø)!,ìîzh¬9Ÿ¥´¸HÖŽèx럽2óŽuV@hN€7Æ…âÌ»&ÕÕ*9xÐìþæ|dè@ 4ÖL›•´ùÌ?‹@@€Ä1p IÞí/  € Ð’“0>K%ÃãÎS@@@Ç$nŽ©¸@@‰[xÜy* € € àX€ÄÍ1"€ € €á q ;OE@@ p€cªÎ|a½üþjÕüª¯¯ïÌ ¥mQ&`.ëó)>ÞÚfße­§¹ € ЙHÜ:So¶²-VÒV_Tz÷ y½ ‰V2RÍ…Á`­*ÊÊìs 9îÂ…DH € àX€¥’Ž©:ï…µ€RÓº’´uÞ.ŽÚ–Y¿ˆHí’¦Úš@ÔÐp:ƒ«A:C/Ò¢O Ôw‘¸Eß:©Åuõu$mŒƒN+à•5Æ) ¹555,åÜî#r¢RÀJÚj5!m;ëâBÊÉÍ@@ ÔuÁ:ùƒþPß–û!€%ÀŒ[DuÁ"€ € €@4 0ã½N›@ UñýÕïŠ[””ÑÛìViíTyêR¨VeÑ~åÿí/òå·êym­TòêÛz ê#€ €€K˜qsIG¸_ ß³Ô¥ïàÓ&mVK¬Ä®Kß!ê;å÷7Œ@@Àõ̸¹¾‹Ü"”Ñç¤PŸÖ¿Ég÷.Ëkòçä'×qK{ˆ@"G ä‰Û¬ÛféòË'Ûõuu*«¨ÐÇ[?ÖÂù UqøpäÈ) p‚@l\ü›´¦Î?„  € ÐéBž¸Yb{öìÕSO<­8³ wŸ>}4ã‹3tÇ·îÐþÇï:=( DN,àñœyãZSçÌŸB @èäí’¸Õ˜ ØtùùŸI1Ýù­o*Þ/À/ùAfÚôištÙ¥JMMUnN®^zivïÚm×ùÉœ»µ~Ý 4Pcǵ?Û¶-[ÏͧÃfÏ*}ûöÑ3oÔࡃ•˜˜ ü}ùš7ïíËÙ×x 6ièÐ!3v´jj‚Z³zÞXþ†fÝ:K#Ï!U@«W­ÒË 5vóébëäãæ!€ € €€ :ds’`m­bLòæóÚ×]­&L¼DOýùiÝ߃ÊÎÎÖü/JKëÒHt󗿤’’=öè¿ê÷¿ûƒ  7ÝÐøýʪ*}ðÁ‡úõã¿ÑýsPAa¡î¸ãëMˆ¿xóLåîËÕÃ=ªù/ÎוW]¡¼_;¶m×Ã?D æ/ÐÔéSuÞˆsë9‰Í…ýHH € € ЉÚ5q³’5kfìšk¯ÖæÍï©òH¥¼^¯¦\q¹þõeíØ±ÓNνòª‚æpÍ ÇŒn¤Î13g¯-]¦ƒhçÎZñÎ 2¸ñû‡JiõÊÕöÌÞ¡²2­\±J½Í³bc’C«ì13x{ëïö5k׬S^nžòòòôöÛïØŸ­[»ÞþlР†û:­š†º%Åêg—÷щ“XU­ÏîŸÒWÉí² ÁAt\‚ €Q ]~²È–¥?>ñycbä1_VòôÊË Ë»uï¦Ä¤D}ík_Ñm_¹µÑ4Ù|–žžÞøç¼ÜÜ&ÞGQbBbãgýû÷×´«§j ™‰óù|òš÷éìY=oœjkƒöuŸ}v É=ªÌ,Ýþý'f-µ<“Ø:ã@ M à\ ¤²VOo,Òw.ئ¿ÿª®­Ó3›ŠTt¤Öù ¹@8@»$n9{sôôSϘÄÍ«üøû ˜÷Úþ€JÌç/ê[›—äšÙ®ãKuuUãýŸ_ß\ü©))š=çÇÚ`Þƒ{ìÑÇUUY%+Y¼çÞÙM.?úÌã? ~žÔ5w_§±1ª@ :êU/ùUTÔhîæb}c\†bÍ/¬¬«×_Þ/Q~YÃßwV±êP@@¶ ´ËRɀٜ䀙ÙÊÏÏ×sÏÎÓ´iSíMB¬R\\¢êêjõéÛ[¥¥¥M¾ªªªµgààJNNÖ¢E‹í¤Í*=zd8ªÛÒE¡ˆ­ÍApp­@M°i¶»Ä¯[Jeò5Õ›¯…•j{Qӿǵ$n®íPC@ ‚Ú%q;¾ý[·lÕºuëõoÞ®8ŸµŒ±V{óïºæºkì J¬„ËZöxÙå“Ô5½«#º¢¢óCR½233íë{öê©«¦^å¨nK…"¶6Á @Àµ¥U ˰/î¯Ô²ì2½n¾Þ˯<éû‡ªO®ãÚ €¸V ]–JžØÚ_˜¯‡ù…f˜íû_2ÿÉâ%ª¬¬ÔÔi_Э·Ýª*³Drïî=Ú´é}GPÖ†%ó_\ ¯ÞþY«–JÌ,ÞsÏ>¯Ù÷Üí¨~Kµ5¶6À @Àµ»Ìlšµ,25Þ+Ÿ÷Ø™n+÷4Sr| ˜Ù¹ PV$×¶ˆÀ@@ R<ýdú î‹”€‰3ôå¥êÑ«OèoÌp‰@áÁýJírló£Ö†U:íAÕûÎ, ó*•¾ìáÖ>²Õõj̻ũii­®OEÚ*Pav|Ž3ç·R@Ú.›“h÷¥’m“; €îˆ=ÔtC%'Qµ¦Ž“ûr  €D—‰[tõ7­E6$o]¬¸ÂòÔÛ÷T·³®‰+Ø¡ä-¯¶á‰TE@:ä7°@Î s¸H©kžê M¡  € a̸EX‡. €@äœ}öp}ÍÚH‹‚ €@HÜÚHu@N%0rÔHp@‰K%CÂÙ7‰ñÄ(¬•×Ëpˆìž$úæ‚æìHã”ÈøÁ¿¯7kàÀþ}áh;øÛwÈ:^æðáÃöŸ=®¼êJM¸t¼RRR•—›«—_^¤½{öê»w}[å‡õüsóìkSRSõد~©6ê¹¹M?ûÝoÿK»ví> ¨¥û[Ÿ.Æ[o»EÿtñEö}ÇŒ¹ÐÄ}D?½ï~»Þª•«•Þ½›®¼rŠòó>SBB¼vïÞ­¿.x¹1ŽÔ.©zôчõäOkëÖ"«‰@ äü4rÒÈ»alœOÖ¶ÍÖ¸:“€5¦ËËÉgÆ8%ònºéF•––êßý[ýùOªÿþºöúk2}ú4±ž}f®~ù‹G´cçNÝu×wÕ%-UÙŸnWVÖY׎yžJKJuîyçÚ ŸU†™ïûýíÙ“Ó,NK÷?Z¡¥ç=ÿ½ÿþÚ¼i³þåû?²“¶£eÊ—kð úÓŸÐK/Î×Úµëìä.&ÆÛxÍè .PeU•>ùä“Èë<"F¹S,!'¼Æ'$È_]­C%Ū«¯‹¼1§°fÚ¬¤ÍgÆ8%òöåæéåoÙhÕªÕ7n¬ýg¯7F“&OÔ /¼Ô8[¶tñkºä’ñ5j”>ݶM7μAÝͬVqq‰Fž?RkÖ¬ÑåSLÂ4d°öìÞ£aó´#{»êê‚'áœîþ+W¬²ë´cKâ±qqzúéÿS}]Ãß¹š1óFYïÄMÔ.{¡ÞÛü¾YÁßË‘7z‰½‰[èM#ðŽÅ'$Ú_@À-ùyùMB©t轺h±[ºƒ8@Â,@âæàñ €Í üþSÒÄ|¾ÜñÙgŸ3ïˆ5Mðª«ÎÙËþ4Û,—ÌÒ“Y3Zyyyúø£5Å̺½ûî õìÙCÛ>ÝÖì3œÜߪØRŒ-õkmMÍIß^g–K~óÎ;äóùÌ{}¨¸¨ØN0) €X¼ãÆ8@ˆ8ó¾Zµ¿Z½{÷Ò¡C‡š|UW7$|ÙÙÙÊ<+Sçœ}¶=›e•?úD½ûôÖØqcì%”……EͶÝÉý Õ›eŽžgÿªÝfMkæmĈóÌrÏóµÉlÎBA@਀³›à… €€‹jÍÆ3ï¼ý®¦Nÿ‚þÙlPÒ=£»úöë«K'NPZ×4;Òmæ=·nÝÒuÞˆsMâ¶ÅþÌÚ‘2goŽ&MšhfÛ>=e‹œÜß G±yw83s¨=»w4®SÕ«¯¯×†õ4Æ$•C3‡˜]57:y× €D‰K%£¤£i& ÐÙ^m™=Cem©󗿤*³㾜}f'Çí¦–—UèÀþf—É.Ú¹óØvÿ[·~¬k¯»ZŸš®–ÊéîïÄó&¹¢Û@ð €€[rs²,•tKo €@Ô X»`&˜1­c( €/ÀRIÆ €aHIIQjjŠfš³ÜÖ¬Yk¿‹GA@€Ä1€Q!àMþ£•Ðû\yS{)&1U1ÞŽ]ºUô«¾ª\µU}àSU癕”D…?t.ð½»¾£ž½zÊzÿîÕW8»Í¹W"€Ñ#ÀŒ[ôô5-E j¼IÝ”:ò:% 'y¼Þî꟨2g“…;L’Vj?ß›œ._ÏaJ{«êªËU±e±‚U ߣ ðoÿú@hQ€Ä‚J ¾ïH¥_|»b|ÉÞ.kV­dý\ù ¶Ÿôìºòª1_Gv®P|¯aê6áÛªøx©ª?û¨Ããä € y$n‘×gDŒ§Hèw¾…c–ÍJÖ WüAõÊÓöÿàvüÇoÕcâ]&TŸªrß;m.@@èèt»JþdÎÝšyÓŒvïÕfÜ ûî¿·ÝŸÃ@À™@\ú¥¿3,I›5Óæ4i;Ú+Á+Zñ?JqâºpÖH®B@¨h·Ä­{Fwýù©?éêk¦µ ÷ÜóÎÑ7ïüF«êR ¢LÀœ}ÕuÌ,3{–†—lxÞÑLÛ‰ÁÕލtãÜ@:¯@»,•œ|Ù$mX¿A555Z³j­Î1BÖ;om)óþÇT“„½ºh‰ÞÛü¾ hõª5zûïÿÐt3[×\qZçPé!{Öκ硲2­\±J½Í,\l¬Wž˜MšãÖ§Oo >ÌÌ^-´Ÿ¹kç.³¤±À¼ë6A¯,|µÕ=‘Þ-] Ú½{O“{ìÚµË$t_0IV¬jkk›|ÏiþýûËZª9pàù|>yͽ¬%–^³ÑAZZ²™=ŒÓžž›››¯Ì³2[Ý*"€@è¬y³ߤ­5‡[×.RÙÖÅfö> @ñÞÆ™¶P%m¡jA½ ¾Þü‡‚ €tXâ6É̶Yå§?›sì™f¶Ìzçmñ¢¥ šåÍ_œ¯Å^²3»4ó³M]]°Ù%ONꤦ¤höœkú zìÑÇUUY¥¬aYºçÞÙMâ±–c_jkš&‰ 1Ÿ@Ð$i1q}šo–Ov¿äN¯~RŸ4¼Ok- uÒæMê*ëpí¶+®º#¥m¹u@@ “ „tÆÍÚÄcüøÖâW—hóæcÊZÉÑݳ¤ÑæÝ·M6©Ê¼GæóÅ7¡íÝ»×I‰’Çlrr´«òH¥†fѾ} ›†XeèС:x°°Ù„ÐIƒ*99Y‹-¶“6«ôè‘ÑxÿR³ŒÒšÉ`f㎧­YJIAwÔíV\—“ÿ™L8F5‡òTþÑR™iô'mVë}=²TÓÆÄ-¡g–™ÜåL¢@@W „ô7kHk½Ò›Ëÿ¦Ïò>küÊÞ¶]ï½ÿ&On˜Û³{¯ÆŒ½P©]ºØKGŒ¡Q£G5**.2›dªW¯žêšÞÕNÌÞ|ãM]wý5:ÔùJOO×E_¤Ë¯¼L¯-}½Y\'uŠŠJìÙºÌ̆e=Íó®šzUãý¬Ù¼•ï®4Ͻ֞‰³ž;qâ¥:çÜá®ìP‚B ªs7Ÿ²Ùiç_¯®£gªgˆÞi;ñAIg°£å©‚L8NU¹Ç~Ù}H›@@ eθMž<ÙÞØÃï¯>é©Ö†÷ýtŽz›wà–˜Ù­Ô.·èáGR à7Ûîçêå…¯hü% GXå­7ß6³iCôàC¨Ìl½ÿÓ9?Óëæø€@ F_¾åfu3ï¼™Y¸¿¾´Pë×®?e+OWçàšÿâ}õö¯ØIgIq‰ž{öy;çîÆ{.X°Ð^¡ùíï~˼gç3»e¾¯潤‰fÓ „_ Úl£ï/Ü©øg5Lê9Ío`ŠÈzŸcŸÁÖÚ³Üâ{ 3‡ûZ]?mà € à~O¿™þƒ†·ü‚™ûÛA„ åq](ãÊ{L×áµGŠtpù£ªó>£gÇø’ÕÓÄ\ºîÕ–æžQÝÓ]\c~)–jλ¤ . ³KsÜ ¯E„+ž‹Dº@nNv ¤K%#„ø@ rjåªtÍ“f£æ7@jÏ–Å&g(câ÷d%bN‹umÆ¥ßÓá­KCž´9ë@@ rHÜ"§¯ˆN#P¿E%+ÿdf¾Žt¸U¼Ù¤¤×îSBÏÓ¿ÿj-­ìqÙu$û-Uåñn[‡wD@ ¼]Òº=Ö5ãØöØBFŽ ÔV¨*g£™ýJTlZ?y<÷û©˜ø%oÞµ¦³dÓ:?NÁZybbåMí¡¤*mÔ Å¥d¨ló_TSrl‡ÜP÷`ÙÐ)Þœ}IA \¿ßœ‡ÒWéÃÕž‹„] ¼¬8È;naï@öð&uSbÿ ä3ˆÄ¦öRLRšI¨šEÒ^Ï>zߺ _u•eª­8(ÿOT÷¡‚•%íýXñŽ[»ó€ÓðŽC€õŽ¿ 'wB— X ÒáíoKÖ@ˆ`Ž[CÁH„Ž € €„S€Ä-œú<@@p @âæ‰K@@@p ¸…SŸg#€ € €HÜ q  € € N·pêól@@@À‰›$.A@@Â)@âN}ž € €8 qs€Ä% € € €@8HÜ©ϳ@@@$n¸@@§‰[8õy6 € € à@€ÄÍ— € € €á q §>ÏF@@¸9@â@@@ œ$náÔçÙ € € €€7H\‚ € €„S€Ä-œú<@@p @âæ‰K@@@p X‰›/œðl@@@|̸1B@@@— ¸¹¼ƒ@@ qc € € €. qsy € € @âÆ@@@\.@âæò"<@@@€Ä1€ € €¸\€ÄÍåDx € € €‰c@@p¹‰›Ë;ˆð@@@7Æ € € àr7—wá!€ € €$nŒ@@@Àå$n.ï ÂC@@HÜ € € €€ËHÜ\ÞA„‡ € €¸1@@@— ¸¹¼ƒ@@ qc € € €. qsy € € @âÆ@@@\.@âæò"<@@@€Ä1€ € €¸\€ÄÍåDx € € €‰c@@p¹‰›Ë;ˆð@@@7Æ € € àr7—wá!€ € €$nŒ@@@Àå$n.ï ÂC@@HÜ € € €€ËHÜ\ÞA„‡ € €¸1@@@— ÄZñÕü.“ð@@@è°·8_|ô Ðr@  ð ¹NØ©4 @ ª¬¥’¨ ñ € € €€»¼ãæî":@@@@$n @@@Àå$n.ï ÂC@@HÜ € € €€ËHÜ\ÞA„‡ € €¸1@@@— ¸¹¼ƒ@@ qc € € €. qsy € € @âÆ@@@\.@âæò"<@@@€Ä1€ € €¸\€ÄÍåDx € € €‰c@@p¹‰Ï£«*ZIDAT›Ë;ˆð@@@7Æ € € àr7—wá!€ € €$nŒ@@@Àå$n.ï ÂC@@HÜ € € €€ËHÜ\ÞA„‡ € €¸1@@@— ¸¹¼ƒ@@ qc € € €. qsy € € @âÆ@@@\.@âæò"<@@@€Ä1€ € €¸\€ÄÍåDx € € €‰c@@p¹‰›Ë;ˆð@@@7Æ € € àry\!á!€ € €Ñ,`r6fÜ¢yÐv@@ˆ“¸1å=E € € ¥fÜ¢´çi6 € €DK%#¨³@@¢S€Ä-:ûV#€ € €@ Äð†[õ¡"€ € €@Ô X9›§ÿÀ³üõVÓëëuôÍTWß°k‰ýiÃ7ìÿg.  € € €œ€™:k:yfþdÿ·áSóm“hY|þÉçÿûÿHÏu+r•·ÐIEND®B`‚input-remapper-1.4.0/readme/usage.md000066400000000000000000000223541417303655400173650ustar00rootroot00000000000000# Usage To open the UI to modify the mappings, look into your applications menu and search for 'Input Remapper'. 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`.

Hitting a key on the device that is selected in the large dropdown on the top should display the key on the bottom of the window, and write it into the selected row (as shown in the screenshots). In "Mapping", type the key to which you would like to map this key. More information can be found [below](#key-names-and-macros). Changes are saved automatically. Afterwards press the "Apply" button. To change the mapping, you need to use the "Stop Injection" button, so that the application can read the original keycode. It would otherwise be invisible since the daemon maps it independently of the GUI. ## Troubleshooting 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 Change the key of your mapping (`Change Key` - Button) and hold a few of your device keys down. Releasing them will make your text cursor jump into the mapping column to type in what you want to map it to. Combinations involving Modifiers might not work. Configuring a combination of two keys to output a single key will require you to push down the first key, which of course ends up injecting that first key. Then the second key will trigger the mapping, because the combination is complete. This is not a bug. Otherwise every combination would have to automatically disable all keys that are involved in it. For example a combination of `LEFTSHIFT + a` for `b` would write "B" insetad, because shift will be activated before you hit the "a". Therefore the environment will see shift and a "b", which will then be capitalized. Consider using a different key for the combination than shift. You could use `KP1 + a` and map `KP1` to `disable`. The second option is to release the modifier in your combination by writing the modifier one more time. This will write lowercase "b" characters. To make this work shift has to be injected via key-mappers devices though, which just means it has to be forwarded. So the complete mapping for this would look like: - `Shift L + a` -> `key(Shift_L).hold(b)` - `Shift L` -> `Shift_L` ## 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 themselfes. 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. ## 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.





# Advanced ## Configuration Files If you don't have a graphical user interface, you'll need to edit the configuration files. The default configuration is stored at `~/.config/input-remapper/config.json`, which doesn't include any mappings, but rather other parameters that are interesting for injections. The current default configuration as of 1.2.1 looks like, with an example autoload entry: ```json { "autoload": { "Logitech USB Keyboard": "preset name" }, "macros": { "keystroke_sleep_ms": 10 }, "gamepad": { "joystick": { "non_linearity": 4, "pointer_speed": 80, "left_purpose": "none", "right_purpose": "none", "x_scroll_speed": 2, "y_scroll_speed": 0.5 } } } ``` `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`. Anything that is relevant to presets can be overwritten in them as well. Here is an example configuration for preset "a" for the "gamepad" device: `~/.config/input-remapper/presets/gamepad/a.json` ```json { "macros": { "keystroke_sleep_ms": 100 }, "mapping": { "1,315,1+1,16,-1": "1", "1,307,1": "k(2).k(3)" } } ``` Both need to be valid json files, otherwise the parser refuses to work. This preset maps the EV_KEY down event with code 307 to a macro and sets the time between injected events of macros to 100 ms. Note that a complete keystroke consists of two events: down and up. The other mapping is a key combination, chained using `+`. Other than that, it inherits all configurations from `~/.config/input-remapper/config.json`. If config.json is missing some stuff, it will query the hardcoded default values. The event codes can be read using `evtest`. Available names in the mapping can be listed with `input-remapper-control --symbol-names`. ## 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` | **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). input-remapper-1.4.0/readme/usage_1.png000066400000000000000000000131421417303655400177640ustar00rootroot00000000000000‰PNG  IHDR™jnÔ,ÍniCCPicc(‘u‘;KA…?cDÑH -D·P±0 b©±° b_Íîæ%dã²› ÁV°±,D_…ÿ@[ÁVAAÄÂ_à«‘°Þ1If™½gæ\f΀/’5-×? V.ïÄfÃÚâÒ²ÖúF€^‚ôã×MמŽF#4ß4©zR½ï«;:Iׄ¦6á ÓvòÂS‘¼­xG¸ÛÌè ácáG(|£t£Ì¯ŠÓeþTìÄc3àS=µt 5lfKxXxÀÊÌÊyÔMÉܼÔ^™}¸Ä˜%Œ†A5²ä IÍIfõ}£¾9ÖÅcÊߦˆ#Ž4ñŽˆZ®I©)Ñ“òe)ªÜÿç馯ÇÊÝahyñ¼Ah݃Үçýœx^隟á*Wõ¯KN“_¢ïVµ#nÁÅuU3öárzžlÝÑÿ¤f™¾T ÞÏ¡s ºî }¥œUe³GˆoÊÝÂÁ! Éþàê/Ç÷gð¢êå pHYsaa¨?§išIDATx^í tTE¾Æ¿twº“@˜…ª ;¨€:¾'0‚Ã8¢,ÊGo—áE@`ï1‚ÃcÆm@DG@QQvÐY „„l„$dët'¯êÆî$&!·—Ûçv÷Wç䜛¾µüëWÕýÝúWݪ¾7ý[5H€H€H@ òd–$@$@$  È°# hF€"£ZfL$@$@‘a  ÐŒEF3´Ì˜H€H€"Ã>@$@$ ŠŒfh™1 E†}€H€H@3ÍÐ2c  Š û €f(2š¡eÆ$@$@¦¢+ù¤@$@$@š0µï¨IÆzÍÔj­€ÙlÑ«y´‹H€ŠÝeÕœ¬ è‹EF_íAkH€H  Pdª9Y ЊŒ¾ÚƒÖ @@ ÈTs²2$@$ /}µ­! €"@‘ ¨ædeH€H@_(2újZC$@E€"PÍÉÊ €¾PdôÕ´†H€ŠE& š“•!À 0ëO/"¹Gr`T&ÈkaL옔L ìv;ŒFS0UÙYשO?‰øø¶HMMS>3 ŸõЇ J&¬t-Ù&ÿþ1œ<‘ŠËù—ë¡‘ýæ/Ë– oß>øêËÝšcûæë=ÈÍÍÕ¼ =Žd´g¬ÛÆM‹-"ðæëo¡ººZ·vÒ0߸|¹ƒ‡ jPàà[7-­ªªªÒ2{æíCÁùHïCÀz-jè°;Ñ»Oo,œ¿•••Š™‹ã‡7 „M|öÅ®/±yÓÌyi6vù5>ß¹ËYß=öBBBðÖÿÐki—¾;ò"2ï®Y›Í¦ä ÛyÐàA8rä[$%Ýà̵[÷n¸÷¾ß 11V«_|þ¶lþX¹¿`Ñ|¬Z¹¿÷;tì€ ç/àíUï ób¦êûÖ¿o…=2¯w׮È‘#Ð¥Kg1ÂÉÃ:ñÿ‰ã'•¼"[EâQÑ¥{-?/k×¼‹Ç§<ŽÓ_Dii©˜Ä›8’ñ&M?É«—{F²¿,Cqq±Óêÿ˜ô"#[b^Ê<¼ºl9n¿ã6Üvû­8¸ÿ àŒ'tz÷éE›Ÿ´·+ff\¼ˆ¢¢"¥}A ‹|踘q±^VÖŠ |öéN¤ü)¯ÿýuÜ=ênt‚â~~ðæÌNAffþ8íYq̆Yõýº…M|x"¶~¼3gÌÆÑïâ±É)â'ÃC„)4)sæâ×ÞÂw/ÂÃÃ\©6ãjH€"£!\=fØ¡=þ등û«¯qéR¶ÓÄá6»ù–›ñÎêµÊ“âgÄ—_|…[Ý‚ƒ"©Û B€"•ø×ßp=BÅ—úø±ãz¬"mò€€AüpïÛ·ƒÅÈŤ«lïÞ}ÎuÇççÎ¥ãÈá#.¶Ô“iȸpíÚ·s¦Û'Ò¤žLUFëÖ¾'FÊfô¸±‡êûu«±ç›=øáè1\)¸‚7‚¨¨Vˆj…–-[ˆ þxoízd‹þ,mÚüÑf0©· Ð]æm¢:Ï/¹Gìnῆ=ßìU¾˜2ÄÇÇ+ æÎOqÖ@.ÈÏÏSDç|úyôÐO¸Ív£_ÿ¾øþ_Gn6W™æ¹H`ÿÞuÏH1W\fÅ(vnÊ|ÜØ³V d–Ý“»‹ÑË$ˆ¾&FaaaÊÇ#d_Êq^Kwšìkqq±ªï×5;KŒ„¡¬¬L¹”å…µn­\gfÖ¸áj®kãºXuF×€EF¨zÎrçg;ñч›ÐZ|9ÿsò£XøòŸ!'Y¥ëANþÏiìUvgäj<8$~lú+"#WýóÃô\MÚæ¼¼š‡Š7 @yy9222ÄÃFýcÚcbbðô³Sñé¶ظáeîãS~_¯T£±¾£Dº´¤Ø8Bs÷ëfæ˜7l¬Z²ßÖ]¸b·Õö_00©—Ð]æ%þ’MuUÍ*²µï¬EKáþ’s32äd×*J}1!!„Š B‹%\}"Æt‰@YY¹Xºü&ž›þGE0䌭<üÈD¬^µFùÑh+ÞO›ÇPüîéçÎã×£ïQ|ãuŸH]*˜‘ý‚€|¨KÜåHö×Þj`³CT’º%)}eø]Ã÷ZÝpÛ/nEZZ²2/aÔ¯GŠÅ8q¢fE˜ ÍÝWJ.\‘®ÛqãÇbxp 5…â7¿­&)ãøˆ€)!±“ŠR_Œô_ÉÏEEE…F=6—cž>uÛ·~*Vê<*V”‰%§ÿxŒƒ'Ÿš¢ü`䉧Ä6:ó• äg¹‚‡!° \½Z‚c?WæÝs uk|æôìüìsñ2ïBˆª°sÇNe¡HÝðÉ–­sÿ}Ê(C.]^±üoŠkÖ𻝖ð;o¯\RŸ2w ÄÃ\vßMˆƒ>„ 9^—oáI¡ÉËÎ.(¯’²Z+Ä2J‹Wó –Ì:vêˆg½€gŸzNñÕ3@Sä»-ëßÛ€ï¾ýW£Qš»ï Ùèèh,Z¼O<>•#nO@z)­nçdL&“˜Ìã[¿^jg¯d#'ü¥k㜟‰ã=–¦*ÚÜ}wÉeú%%%wz9W—yh f'ßEh+–©ûåPáòø{ V‘uòcw¸KqÇÉeÌò]®1Ü'ªöã–é™ÀjOMj3íùçЦMlÛº]y¹ŽôD ZÌó<8q‚X–%–R—);ÈeÕ ú  Û9‰'+#]ìKÔÆ«¤8'ãUœÌŒH€®I@·s2l7  ÿ'pM‘™5{z÷®Ý(Ïÿ«Ë ø’€×F2¿;öÞzÛ_ÚβH€H€tNÀk"Ó-‰/?é¼­i øœ€i¼Ø:¢oßÞâ¼¾»ònß¾£ÑSå9÷¹·Ñ¸Ä[à½z÷TþË·lùD¼ ¼K9;¢©4>¯) $ ð9“<~÷¿¢œËðÈïFÁ•+Ø¿ï@CÆŽ£œœØXÜ6þ ñ8|ø[ØйüµÒø¼¦,H€HÀçLïoø………ÊVÞr·Õú7™ˆˆpôïßsÅ6ðÅ­'äÉ=‰êîsÔ\Ÿ×´Nr3 €öL/̘æ,Åd2*»íþ<ÄÆÅ)Z©‰ëHëNí«[SBŒØrœH€H@{¦%ÿ»TŒBêRÕp¿0y$«<HM\‡Éî¤Ñ¾º,H€HÀ—”9™tq ÞµBnn®rûZq¥Iaq5i|YQ–E$@$à{¦±ãîÇúõq¥ ±âüm£ÁˆÔÔ´z–ȳ%ä–Ý׊[ NP”g€/vé•©Iãûê6,Ñ›Œ°^÷#$Ì»G è¡n´ÁsÕå…(ÿaì¹Ü³ÍsšÌ! o¿cxÊÈ‘¿ÂÐaw¢k×.8}æ rrjF.wÜq»"8999ʉvmÅÜLSqešA·Ü„‘£Fˆm¶Kqî\z³iš~µ¨Ð뇖IŒ£7Gˆô8¢Äæ2$Ç[Ð)ÚŒ‚Ò*toknôú†83:ǘQT^…®±ê¯K*ªÑ1:TI£æº¼² ­Cq½(Oíu\+’ÚZP)¼Ÿ±‘Æf¯Û´0¢»¨³<‘9*BýµÜ¢½…Å€ä‹rô­šë°PnlgA¨1fSó×FCz¶ƒL'Èj¯¥-â -ôN SìRsmµW£ˆß*܈r[íu‰µZ½Û"» òÜîæº+ï“ 4B (7Ȭ;ñßòîÅè×1 §²­âÇŸç×ð[RK@ŠT’xØøî|9®nN4$@nðÚÿn”­‹$r)»ÐFÑEkèËùБY`CíL£¾ì£5$à‚^däÙÓÒ5Ã@0›ÄÊJ¢!p›@ЋŒ$ųÛÜîAž}#À˜ÕÓœEF Î(¨Ô4 ðOìþÙn´Z?(2¢-Lt—é§GêÌö 5Íñ;º›ML¸†øÆ<ºDü®ßúÌ`ö Ÿ¡fA:! ﮲õͯ¸‹ð¤Àäç"Tà‹@—ˆ/(ûgìþÙn´Ú}K³ÒðBÞ9´¬³Ý˜¼~^|öY']ÎØ”•‘îr"­ÈŒ‹%\뢔üéñ f¿,„}Ã/›F{@à¬9]­ex&ÿ<–Ewãš<-®ÛÛ*ns9gSd«š·Ýƒ9H—ŸXƒ¹4]wö ö‹`#ð·6‰NQy*ÿ‚²„_ ÌE“ËÛtp‡.Ýe.×ÂÃprön\V­QWÅþ•K£;*¢ÒNˆ‹˜K&3^£yÏÕ@‘¡»ÌÕ>Tñé. ªæfe›$àþ ë•+ˆøÝjŠûûF°“üŽ99š‘ñb43U¸Îê.PË…"#HÑ%¢¶»ÔÆ“çÙ+űÛ6+ªÅÑÛòÿ@ ìت¬ÓµL¹|Á9#Ýf×™t›=QpÁex—‘1”ê*žbÅäWa½šñA@ ÝeìïÁDÀ(¦ù;W–ãG±ŠLŠ‹œƒqÌÑÈ•e¬åpU4\¼Û„d½4«””*» C݈’Kixæß-BhòRhè.Ó¬1c°‹åÊS’±8ºs½I~)4ŸÉ{®¾ªI‘ }IlõÏ Ž€2Š‘BddX½z5Š/ÂäA!¨,½pBCw™º~ÁX$ЊŒ c“GC2¸@@ MíóÌÊ•+a±æ`RÿJT–œÐ¸†QI€~F€"#€Ð]æÎ÷¢V˜åÈfÅŠˆ1—`L÷bØ*JFhØ7ÜéLCµLÅEºãáëmeè.ó¼ H¡yå•W0mÚ4ÜU–™ 0††‰iDƒØèÔý5öž[æYìžñcj0%$vÒ¹Aæ±AfEE™æû—ÉŸ¿J;ÝeÞèUb)ó’%K0sæLØl±+·ŒâMaéJõcôF³2ðˆ€.Ýe&“ ­£cQiµzT95‰¥¼´kmB ‹.Q¨©‚®âÈ„E‹aXß8ÜyF¼G#„ûi±€® UaŒìruY€¾¤‚£€çt{î°šº“ËžWµñªË q6×»xbMN° ¥øa9y©B™§Q{}*ÛŠ¸VFD…¡ö:º¥×µ0в­hÑüud˜±‘&¤çW"¢úZ®Ž2ŠCÙ~Ú´¹ë,±ÒN²õo캽ä‹—ËQ^ÖtױЇƒ¹sç"%%‡Nᜡ'*ìFtŠ1#瘟ÖjtŠÅå;®”ÚÑ5Ö¬ú:_¤OjkFa™9EÍ_Ë:$Ç[pµ¢Jyé¶¹kÉ·g; ʬU8-ÚF¶Ou¹X5Ç@$àá#ÇëÖW$!ðö.ÑVkbbcœ°Œ±ÝÖë~„„µv `°%r¼ˆi«(Áæé½1zôè&DDD`þüùxíãcH«î ƒ1!ÿ1J)?ú>ìy©ÁÖÔ¬/ x…€nG2^©ŠL칩(Ùõ²Š˜Œ" 8Þ“±Ë÷d¦pM(¥¥¥ÊhfÁ‚øï—"#+O9íÔŸ° ¸FÀ¿+]«cë€@QQfÍš…y/ÍBÌu‘N‘Òi4HÀ(2>€ìEbùòåX²xOæÙ‚7ëOz"”î2«U¬xbp‹@=w™Êbcc1uêTL}æyHöƒ8Еë‚UÒc4ðoÉøwûéÞz)0óæÍóÓf"+û²°×_ÌÔ=lH:$”#¶CÀš´páB<ùôtddæÁh4r°-ÍŠ‘@ã8’aÏД@\\.\Ìïê…›¬Fdè*Ó93']ðÚH¦K×ÎèÖ-éš•;sú,NŸ>£+4Æ=B*°î½÷±iÓ¦zlܸ«V­ª÷™".âý Œ{¬™Šü™€×DFBèÚµK“B#ÅEŠ ƒpŒBBÄ.¯®X‰¥}SìM&ÞçU¶Ž©Â‚y³0iÒ¤zBCqñï6§õ$à ¯¹Ë~<{Û¶}Š´´S 쑳}ÛŽbmOŸæ™H7˜AÑh»-›„ØÈ¿P̘ý2Ì–pÜ9t8ÆNx„s0>iBú$àUwY]¡1ü´GG0úlxO­j8y/—&‹Mý!0‰Éý%K_—N41Ùoà„¿§°™žü˜€×EÆ!4R\dàD¿÷¦ÿ\ljF9Bnœa†(BÃ@$œ4ŠKpv&)0rG€šM0ë3à²åàì¬5 h&2Dœ(&ÁÙî¬5 4E€~ ö   ÍPd4CËŒI€H€(2ì$@$@š Èh†–“ PdØH€H€4#@‘Ñ -3& Ð­ÈØl6å}   ÿ% Ë_q)0ù¹5›ý—,-' €)+#]wäF ŒEl²È@$@$à¿L‘­Úø¯õ´œH€H@×té.Ó51G$@$ šEF5*F$ p•EÆUbŒO$@$ šEF5*F$ p•EÆUbŒO$@$ šÀÿüB•ÐcŒä¶IEND®B`‚input-remapper-1.4.0/readme/usage_2.png000066400000000000000000000117351417303655400177730ustar00rootroot00000000000000‰PNG  IHDR™jnÔ,ÍniCCPicc(‘u‘;KA…?cDÑH -D·P±0 b©±° b_Íîæ%dã²› ÁV°±,D_…ÿ@[ÁVAAÄÂ_à«‘°Þ1If™½gæ\f΀/’5-×? V.ïÄfÃÚâÒ²ÖúF€^‚ôã×MמŽF#4ß4©zR½ï«;:Iׄ¦6á ÓvòÂS‘¼­xG¸ÛÌè ácáG(|£t£Ì¯ŠÓeþTìÄc3àS=µt 5lfKxXxÀÊÌÊyÔMÉܼÔ^™}¸Ä˜%Œ†A5²ä IÍIfõ}£¾9ÖÅcÊߦˆ#Ž4ñŽˆZ®I©)Ñ“òe)ªÜÿç馯ÇÊÝahyñ¼Ah݃Үçýœx^隟á*Wõ¯KN“_¢ïVµ#nÁÅuU3öárzžlÝÑÿ¤f™¾T ÞÏ¡s ºî }¥œUe³GˆoÊÝÂÁ! Éþàê/Ç÷gð¢êå pHYsaa¨?§iIDATx^í tUÕ¹Çÿ7¹™'"SBB$2"`ÐÚ·XE¡V*(¢ÏZëÔ"ö©•ÒHyÖ·:Ð‡í¢­hŸˆPIdS@0) `2Bæ·¿“Þ›Ð¹çæœë¹çþ÷Zw­Kξï·7÷ö·÷9Û1lÄ¿5ƒ‰H€H€L dB¬’H€H€4  ÓPdLCËŠI€H€(2$@$@¦ È˜†–“ Pd8H€H€L#@‘1 -+&  Èp ˜F€"cZVL$@$@‘á  0EÆ4´¬˜H€HÀYY^J $@$@$` gÏ„S*¶j¥uuµ ³ªy´‹H€lE€á2[u'! k ÈX«?h ØŠEÆVÝIgH€HÀZ(2ÖêZC$@¶"@‘±UwÒ °ŠŒµúƒÖ €­PdlÕt†H€¬E€"c­þ 5$@$`+[u'! k ÈX«?h ØŠEÆVÝIgHÀ^øùó8h =œ p/‚“SúgƒÆÆF;Ée·¯Oþø $$ôDNN®ö·   ÈßÒÒÓ°wOv@2¡Ó­d,Ìøá#øâHΖž½Œ›ù ~‡aÆbËæ­¦cÛöév›Þ0Ÿg2æ3¶l S¨H¼öêëhnn¶¬4ÌwΞ-èÑ#Û58ê¦Qí„ÇL«šššÌ¬žuû@`ÞÒû°U›3öß1dèüjÞ˨¯¯×Ì Ã=S§`øˆáhPÛ´q32ÿ¾s^œ­›?Å'6ºÝyè‘áp8ðú¢¿ZÕEÚåýÙû5‘ygñR444h5H?5ÙÙûп?w­®€;ïú.’““QWW‡MŸlÂÊÌUÚõ—^ž‡7þï-|O]ïÒ§NžÂ›o¼Â‚B¯/_ö7ìSöH]ï,YŠqãÇ!5µšá”`©ú÷‘Ã_huÅÄÆàa5%¼VZRŠ%‹ßÁ£=Šç~ú<Ο?ï1’g2FÒô“º†*q¹câíX0ªªªÜVßÿŸ÷!&&¿Ìø%^YðG|ó[7ãæoÞ„Ý;÷ }xº;Ÿüè z=Cl~ÒßzÌÌ/(@ee¥Ö¿®$Â"7ùUUW[‹×m@ÆÏ3ðêŸ_ÅwnÿR” ¸Ò´îÃûï}ˆ9³3PXX„§Ÿ™©ŽÙõøzÛÆ¦=0 «W­ÆÏž›ƒâ‘hâ'é¾iSá AÆœ¹Xô—×”ð݉ˆˆp=n3¯‰(2&µbÕɽ“ðƒÍÀÖ-Ÿâôé3n#UØì7~o¿µD»S<þåqlÞ´7޼»wíFÿý”Åhùûöë‹õŸúð¡ÃVt‘6u‚@úáÎÊÚ‰QjæâJ*Û±#Ëý£îúû‰yÈÞ› ±å|‘‹üS§Ð+©—»\–*“óEŽ6»Xºä]5SÅ ëy|½­Û·mÇ硼¬«2?B\\,âºÄ!::JÝ¥áÝ%ËpFg±)óÃÌN`Q£ 0\f4Q‹×7pÐ lTa[¿=Û·íÐþcJJHHÐ6Ì—áö@6H”––h¢s2ï$nH¿A…Ͷ↴a8ðÙAw˜Íâ.Ó<vîØ…Ûï¯fZÈl¸šÅÎ͘‡ë· „TyíÀkÕìeÕØ W3‡ððpíæÃ•ΜþÊý]Âi2Özôèîñõ¶f©™+ÕÔÔh_¥½ð.]´ï……-a¸–ï­yuºÎì& È˜ÕÊUnøx>|ÿïè¢þsNŸñ0~õßÿYd•Ѓ,þÏ{ñ%465º]Ýx’vïÚ£~lÒ4‘‘F¼ÿ¡•ݤm PRÒrS1|D:.\¸€üü|u³qñ1íݺuÃg>‰ukÖcÅò÷´µ=öËZ ¾8P"!-WêèzÛÊ\놗rKÆmÛ+ ­ã·XÔ  —Ò_ªinjÙE¶äí%ˆVá/Y›‘ôÕ™–»Î(~ð†ë#á I{vï,ô&%'᪮W©¸øçþâ2íô‚@Ö2= £%T¶=«] ©}SQUY¥ÝlÈ¢¾lyŽU!¬¶)11ÑýO™átïÞ %jVìJ]÷Äl§rƒÔ6L×£gOŠ28«*Ë|Ô”çÍ8AQ „aažbN]jj.¨­Ë¯aÖOŸÖCÖ`d¶òÀƒÓðÖ‹µžêÙ¹Û<|èˆwÏ;q&Þ¡ÅÆÛÞ‘êj˜™ý‚€ÜTÈw™É.úËëílv‰Jÿýµ±rëmcµðZÛtó-7!77E…§qû„ñjCAŽiÙ&©£ëž€’+ºrÏd,V7N!Î|÷{=)Ê<>"àLL¾ÚGMyÞŒÄËK‹Q[[C¡ñ›îœGÿq kW¯S;uV;ÊÔ–Ó¿¾‰»'OÂO=¦ý`”¨»ÄËW¸ë• ²ÅYvð0Ù›À¹sÕ8ôùamÝ͵ÒÖãcGaÃÇŸ¨‡yWBÔ„ ë7hEÚ¦V®Æ¤ïߥÍ2dëòÂ?þI ͺRG×=%üö›‹![ê3æÎA™º’m÷”ø1Yƒ€ãÖñ÷Xò)<š’3E*¤g(©ººZµ2ÌÐ:¥²”«Sðü ÏbæS³´X= \Ž€<Û²ìÝåØ¿ï³KféèzgÈvíÚ/ÿú%<þ蓜qw¤Ae-»&ãt:ÕbŸú5¨Ÿ ©Fü%´F1§í+q=Çr9G;ºî- Ù¦_]]Mñ Á帻Ì` v¬NžEè©¶©Žýöòø³]¤O~Là¶q·iá8ÙÆ,ÏrMºû.µQe¯{d/Ó)2öêOS¼yæ¿f!>>kV¯Õ®c"+hVë” 4Õ{RèkÏã”§OáááÚzLfæªKµté2LT§"^.ïúupÿý÷bÎ/~¦¶0¯Æ¦›ÑQ™¯Ý{@: Dž€æ†œËþÍõ5:K3; tŽ€#$ýÆ@ÆáùƒþñЀ|A&þ;7йtÌÓqnßb L ‚¯Ùwšè´ûPµkÑ×l‰gÍöÄ¿gÍ1 ø7‡3”cpÎñIƒk´wu2ƒ–qè/‰"ã/=E;I€HÀ Pdü°Óh2 ø ŠŒ¿ôí$ ?$@‘ñÃN£É$@$à/œV5´¡¡5Зý‡¦ŒGƒzÎiáø²i¶E$`!ͨwf%ÅE`ÊJ‹¢Ž `ò ^=»á'3&û¦1¶B$`Y¯ä~Œçóv º±Îm£|6/ ÊY«ÛngQ~žîBfŒLXX„ÙM±þ¸~`*V®ßލÈp ½®/¹€áúöIÂø±£ 74õêüªOwÀºÍ» o‡vŽÀ±ˆ.è[SާOîÆïSF  Ì<µIµU8§»rgLl¼îB,`?®ÿì·Œj?çè‘%ÔÕÕcËŽýÈ+8ƒnñqøÁ´‰8”{¶шV “ÒÜ¢ò““{ЬÞI)Sƒ?$¥ëFeÙ5Ýž° €¥ œ*ü ò‘T^q…gJØã*KÛˆÆ ÅüÞ#ÜB# N‡FaAïákzEF/1æ'ðŠ@¿ÔdÜzËpôèð0W!q§“?A^Áôy¡Ö7ìëmÚ’ ÿz`~ kè«…ÇòòÏàµ%«ð›…KQpºå&kE~׌„Èä“PwOÚ{ÑfO­¦ÈxJŠùH€¼&’”€sÕç±ú“,M\Ê**éu},hÇó³Ýk06“¬Ë™z¡_ª]d".²ãZ£‘e}j*¤ó %D­ÕÇ´†lIàø©"lÙù¦O Ž{o–¬ýؾçs[úêÏN5ªd^ûí\¡y9e”W®Qd¼ÂfßB[w€|˜HÀh™ë¶A>ÿš¦LctS¬ÏB.³PgÐ °ŠŒÝz”þ €…8«*Ë,dN‹)|­Œåº„‘ €Wœ‰ÉW{UÐÌBò‚Ìrõ‚ÌÚÚ¾¿ÌLЬ›,@`æ/^±€4Á,– —ÉSÀ]ºvG}]ë[@ÍÀzI@æ†:8BøâV=̘×XŽH4·yC²±µ_›%EFÜ¡inn2ÞcÖH ÐXsýÆ(¡áƒ„ÀÈ¢^q'㯱ºÔË|_Œ[˜}Ïœ-ú1ó‡25x¢Ó§Â¦bšÝõÊ÷Ôß•êΑ"ch÷°2 ðoÇBÂѧ¡ÏT"¶¹Q›Yê{ªúÛq§Ô§/™¶&£Ï æ& °?D'h¢’ÜX‡Y…ÚÙ¯ò=?8 bu›È™Œnd,@$@ö%PŒßÆöÒD%I‰‹LQpæÇ%B®éM½Ä˜ŸH€Ž€Ãk)2^£cA °×Œ+D&3šD5›™YQtÑfO=·¬È444Àá°¬yžòe> ð+OU¹×`$læ ‰èÈ5½É’¿â"0e¥Å Õëó“ xI X-óË.²cj™ˆ‹¬Á¸Öhdg™\Ó+ŽÁCGËæK%™ÁˆÀ„…EnW]]-bãâ ¯—’ ´'àŒ‰'  0…€Þ™)F°R  { Èس_é X‚EÆÝ@#H€HÀž(2öìWzE$@– @‘±D7Ð °'ÿ¬}ÞŒ“àQIEND®B`‚input-remapper-1.4.0/scripts/000077500000000000000000000000001417303655400161635ustar00rootroot00000000000000input-remapper-1.4.0/scripts/badges.sh000077500000000000000000000013521417303655400177500ustar00rootroot00000000000000#!/usr/bin/env bash # sudo pip install git+https://github.com/jongracecox/anybadge coverage_badge() { coverage run tests/test.py coverage combine rating=$(coverage report | tail -n 1 | ack "\d+%" -o | ack "\d+" -o) rm readme/coverage.svg anybadge -l coverage -v $rating -f readme/coverage.svg coverage coverage report -m echo "coverage badge created" } pylint_badge() { pylint_output=$(pylint inputremapper --extension-pkg-whitelist=evdev) rating=$(echo $pylint_output | grep -Po "rated at .+?/" | grep -Po "\d+.\d+") rm readme/pylint.svg anybadge -l pylint -v $rating -f readme/pylint.svg pylint echo $rating echo "pylint badge created" } pylint_badge & coverage_badge & # wait for all badges to be created wait input-remapper-1.4.0/scripts/build.sh000077500000000000000000000007321417303655400176230ustar00rootroot00000000000000#!/usr/bin/env bash build_deb() { # https://www.devdungeon.com/content/debian-package-tutorial-dpkgdeb # that was really easy actually rm build -r mkdir build/deb -p python3 setup.py install --root=build/deb mv build/deb/usr/local/lib/python3.*/ build/deb/usr/lib/python3/ cp ./DEBIAN build/deb/ -r mkdir dist -p rm dist/input-remapper-1.4.0.deb || true dpkg -b build/deb dist/input-remapper-1.4.0.deb } build_deb & # add more build targets here wait input-remapper-1.4.0/setup.py000066400000000000000000000107421417303655400162120ustar00rootroot00000000000000#!/usr/bin/python3 # -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings # Copyright (C) 2022 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 subprocess from os.path import basename, splitext, join from setuptools import setup from setuptools.command.install import install PO_FILES = "po/*.po" class Install(install): """Add the commit hash and build .mo translations.""" def run(self): try: commit = os.popen("git rev-parse HEAD").read().strip() if re.match(r"^([a-z]|[0-9])+$", commit): # for whatever reason different systems have different paths here build_dir = "" if os.path.exists("build/lib/inputremapper"): build_dir = "build/lib/" with open(f"{build_dir}inputremapper/commit_hash.py", "w+") as f: f.write(f"COMMIT_HASH = '{commit}'\n") except Exception as e: print("Failed to save the commit hash:", e) # generate .mo files make_lang() install.run(self) def get_packages(base="inputremapper"): """Return all modules used in input-remapper. For example 'inputremapper.gui' or 'inputremapper.injection.consumers' """ if not os.path.exists(os.path.join(base, "__init__.py")): # only python modules return [] result = [base.replace("/", ".")] for name in os.listdir(base): if not os.path.isdir(os.path.join(base, name)): continue if name == "__pycache__": continue # find more python submodules in that directory result += get_packages(os.path.join(base, name)) return result def make_lang(): """Build po files into mo/.""" os.makedirs("mo", exist_ok=True) for po_file in glob.glob(PO_FILES): lang = splitext(basename(po_file))[0] os.makedirs(join("mo", lang), exist_ok=True) print(f"generating translation for {lang}") subprocess.run( ["msgfmt", "-o", join("mo", lang, "input-remapper.mo"), str(po_file)], check=True, ) lang_data = [] for po_file in glob.glob(PO_FILES): lang = splitext(basename(po_file))[0] lang_data.append( ( f"/usr/share/input-remapper/lang/{lang}/LC_MESSAGES", [f"mo/{lang}/input-remapper.mo"], ) ) setup( name="input-remapper", version="1.4.0", description="A tool to change the mapping of your input device buttons", author="Sezanzeb", author_email="proxima@sezanzeb.de", url="https://github.com/sezanzeb/input-remapper", license="GPL-3.0", packages=get_packages(), include_package_data=True, data_files=[ # see development.md#files *lang_data, ("/usr/share/input-remapper/", glob.glob("data/*")), ("/usr/share/applications/", ["data/input-remapper.desktop"]), ("/usr/share/polkit-1/actions/", ["data/input-remapper.policy"]), ("/usr/lib/systemd/system", ["data/input-remapper.service"]), ("/etc/dbus-1/system.d/", ["data/inputremapper.Control.conf"]), ("/etc/xdg/autostart/", ["data/input-remapper-autoload.desktop"]), ("/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-helper"]), # those will be deleted at some point: ("/usr/bin/", ["bin/key-mapper-gtk"]), ("/usr/bin/", ["bin/key-mapper-service"]), ("/usr/bin/", ["bin/key-mapper-control"]), ], install_requires=[ "setuptools", "evdev", "pydbus", "pygobject", ], cmdclass={ "install": Install, }, ) input-remapper-1.4.0/tests/000077500000000000000000000000001417303655400156365ustar00rootroot00000000000000input-remapper-1.4.0/tests/__init__.py000066400000000000000000000000611417303655400177440ustar00rootroot00000000000000# make sure patches are loaded import tests.test input-remapper-1.4.0/tests/integration/000077500000000000000000000000001417303655400201615ustar00rootroot00000000000000input-remapper-1.4.0/tests/integration/__init__.py000066400000000000000000000000001417303655400222600ustar00rootroot00000000000000input-remapper-1.4.0/tests/integration/test_daemon.py000066400000000000000000000041031417303655400230330ustar00rootroot00000000000000#!/usr/bin/python3 # -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings # Copyright (C) 2022 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 multiprocessing import unittest import time from gi.repository import Gtk from inputremapper.daemon import Daemon, BUS_NAME from tests.test import is_service_running def gtk_iteration(): """Iterate while events are pending.""" while Gtk.events_pending(): Gtk.main_iteration() class TestDBusDaemon(unittest.TestCase): def setUp(self): 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): # it's a remote dbus object self.assertEqual(self.interface._bus_name, BUS_NAME) self.assertFalse(isinstance(self.interface, Daemon)) self.assertEqual(self.interface.hello("foo"), "foo") if __name__ == "__main__": unittest.main() input-remapper-1.4.0/tests/integration/test_gui.py000066400000000000000000002364751417303655400223770ustar00rootroot00000000000000#!/usr/bin/python3 # -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings # Copyright (C) 2022 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 sys import time import atexit import os import unittest import multiprocessing import evdev from evdev.ecodes import ( EV_KEY, EV_ABS, KEY_LEFTSHIFT, KEY_A, ABS_RX, EV_REL, REL_X, ABS_X, ) import json from unittest.mock import patch from importlib.util import spec_from_loader, module_from_spec from importlib.machinery import SourceFileLoader import gi gi.require_version("Gtk", "3.0") from gi.repository import Gtk, GLib, Gdk, GtkSource from inputremapper.system_mapping import system_mapping, XMODMAP_FILENAME from inputremapper.gui.custom_mapping import custom_mapping from inputremapper.paths import CONFIG_PATH, get_preset_path, get_config_path from inputremapper.config import config, WHEEL, MOUSE, BUTTONS from inputremapper.gui.reader import reader from inputremapper.gui.helper import RootHelper from inputremapper.gui.utils import gtk_iteration from inputremapper.gui.user_interface import UserInterface from inputremapper.gui.editor.editor import SET_KEY_FIRST from inputremapper.injection.injector import RUNNING, FAILED, UNKNOWN from inputremapper.key import Key from inputremapper.daemon import Daemon from inputremapper.groups import groups from tests.test import ( tmp, push_events, new_event, spy, cleanup, uinput_write_history_pipe, MAX_ABS, EVENT_READ_TIMEOUT, send_event_to_reader, MIN_ABS, ) # 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(argv=None) -> UserInterface: """Start input-remapper-gtk with the command line argument array argv.""" bin_path = os.path.join(os.getcwd(), "bin", "input-remapper-gtk") if not argv: argv = ["-d"] with patch( "inputremapper.gui.user_interface.UserInterface.setup_timeouts", lambda *args: None, ): with patch.object(sys, "argv", [""] + [str(arg) for arg in argv]): loader = SourceFileLoader("__main__", bin_path) spec = spec_from_loader("__main__", loader) module = module_from_spec(spec) spec.loader.exec_module(module) gtk_iteration() # otherwise a new handler is added with each call to launch, which # spams tons of garbage when all tests finish atexit.unregister(module.stop) # to avoid triggering any timeouts while the module loads, patch it and # do it afterwards. Because some tests don't want them to be triggered # yet and test the windows initial state. This is only a problem on # slow computers that take long for the window import. module.user_interface.setup_timeouts() return module.user_interface class FakeDeviceDropdown(Gtk.ComboBoxText): def __init__(self, group): if type(group) == str: group = groups.find(key=group) self.group = group def get_active_text(self): return self.group.name def get_active_id(self): return self.group.key def set_active_id(self, key): self.group = groups.find(key=key) class FakePresetDropdown(Gtk.ComboBoxText): def __init__(self, name): self.name = name def get_active_text(self): return self.name def get_active_id(self): return self.name def set_active_id(self, name): self.name = name def clean_up_integration(test): test.user_interface.on_restore_defaults_clicked(None) gtk_iteration() test.user_interface.on_close() test.user_interface.window.destroy() gtk_iteration() cleanup() # do this now, not when all tests are finished test.user_interface.dbus.stop_all() if isinstance(test.user_interface.dbus, Daemon): atexit.unregister(test.user_interface.dbus.stop_all) class GtkKeyEvent: def __init__(self, keyval): self.keyval = keyval def get_keyval(self): return True, self.keyval class TestGroupsFromHelper(unittest.TestCase): def setUp(self): self.injector = None self.grab = evdev.InputDevice.grab # don't try to connect, return an object instance of it instead self.original_connect = Daemon.connect Daemon.connect = Daemon self.original_os_system = os.system def os_system(cmd): # instead of running pkexec, fork instead. This will make # the helper aware of all the test patches if "pkexec input-remapper-control --command helper" in cmd: # the forked process should get the initial groups groups.refresh() multiprocessing.Process(target=RootHelper).start() # the gui an empty dict, because it doesn't know any devices # without the help of the privileged helper groups.set_groups([]) assert len(groups) == 0 return 0 return self.original_os_system(cmd) os.system = os_system self.user_interface = launch() def tearDown(self): clean_up_integration(self) os.system = self.original_os_system Daemon.connect = self.original_connect def test_knows_devices(self): # verify that it is working as expected. The gui doesn't have knowledge # of groups until the root-helper provides them gtk_iteration() self.assertEqual(len(groups), 0) # perform some iterations so that the gui ends up running # consume_newest_keycode, which will make it receive devices. # Restore patch, otherwise gtk complains when disabling handlers for _ in range(10): time.sleep(0.02) gtk_iteration() self.assertIsNotNone(groups.find(key="Foo Device 2")) self.assertIsNotNone(groups.find(name="Bar Device")) self.assertIsNotNone(groups.find(name="gamepad")) self.assertEqual(self.user_interface.group.name, "Foo Device") class PatchedConfirmDelete: def __init__(self, user_interface, response=Gtk.ResponseType.ACCEPT): self.response = response self.user_interface = user_interface self.patch = None def _confirm_delete_run_patch(self): """A patch for the deletion confirmation that briefly shows the dialog.""" confirm_delete = self.user_interface.confirm_delete # the emitted signal causes the dialog to close GLib.timeout_add( 100, lambda: confirm_delete.emit("response", self.response), ) Gtk.MessageDialog.run(confirm_delete) # don't recursively call the patch return self.response def __enter__(self): self.patch = patch.object( self.user_interface.get("confirm-delete"), "run", self._confirm_delete_run_patch, ) self.patch.__enter__() def __exit__(self, *args, **kwargs): self.patch.__exit__(*args, **kwargs) class GuiTestBase: @classmethod def setUpClass(cls): cls.injector = None cls.grab = evdev.InputDevice.grab cls.original_start_processes = UserInterface.start_processes def start_processes(self): """Avoid running pkexec which requires user input, and fork in order to pass the fixtures to the helper and daemon process. """ multiprocessing.Process(target=RootHelper).start() self.dbus = Daemon() UserInterface.start_processes = start_processes def setUp(self): self.user_interface = launch() self.editor = self.user_interface.editor self.toggle = self.editor.get_recording_toggle() self.selection_label_listbox = self.user_interface.get( "selection_label_listbox" ) self.window = self.user_interface.get("window") self.grab_fails = False def grab(_): if self.grab_fails: raise OSError() evdev.InputDevice.grab = grab config._save_config() def tearDown(self): clean_up_integration(self) @classmethod def tearDownClass(cls): UserInterface.start_processes = cls.original_start_processes def set_focus(self, widget): self.user_interface.window.set_focus(widget) # for whatever miraculous reason it suddenly takes 0.005s before gtk does # anything, even for old code. time.sleep(0.02) gtk_iteration() def get_selection_labels(self): 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.editor.get_text_input().get_buffer() return buffer.get_text(buffer.get_start_iter(), buffer.get_end_iter(), True) def add_mapping_via_ui(self, key, symbol, expect_success=True, target=None): """Modify the one empty mapping that always exists. Utility function for other tests. Parameters ---------- key : Key or None expect_success : boolean If the key can be stored in the selection label. False if this change is going to cause a duplicate. target : str the target selection """ self.assertIsNone(reader.get_unreleased_keys()) changed = custom_mapping.has_unsaved_changes() # wait for the window to create a new empty selection_label if needed time.sleep(0.1) gtk_iteration() # the empty selection_label is expected to be the last one selection_label = self.get_selection_labels()[-1] self.selection_label_listbox.select_row(selection_label) self.assertIsNone(selection_label.get_key()) self.assertFalse(self.editor._input_has_arrived) if self.toggle.get_active(): self.assertEqual(self.toggle.get_label(), "Press Key") else: self.assertEqual(self.toggle.get_label(), "Change Key") # the recording toggle connects to focus events self.set_focus(self.toggle) self.toggle.set_active(True) gtk_iteration() gtk_iteration() self.assertIsNone(selection_label.get_key()) self.assertEqual(self.toggle.get_label(), "Press Key") if key: # modifies the keycode in the selection_label not by writing into the input, # but by sending an event. press down all the keys of a combination for sub_key in key: send_event_to_reader(new_event(*sub_key)) # this will be consumed all at once, since no gtk_iteration # is done # make the window consume the keycode self.sleep(len(key)) # holding down self.assertIsNotNone(reader.get_unreleased_keys()) self.assertGreater(len(reader.get_unreleased_keys()), 0) self.assertTrue(self.editor._input_has_arrived) self.assertTrue(self.toggle.get_active()) # release all the keys for sub_key in key: send_event_to_reader(new_event(*sub_key[:2], 0)) # wait for the window to consume the keycode self.sleep(len(key)) # released self.assertIsNone(reader.get_unreleased_keys()) self.assertFalse(self.editor._input_has_arrived) if expect_success: self.assertEqual(self.editor.get_key(), key) # the previously new entry, which has been edited now, is still the # selected one self.assertEqual(self.editor.active_selection_label, selection_label) self.assertEqual( self.editor.active_selection_label.get_label(), key.beautify(), ) self.assertFalse(self.toggle.get_active()) self.assertEqual(len(reader._unreleased), 0) if not expect_success: self.assertIsNone(selection_label.get_key()) self.assertEqual(self.editor.get_symbol_input_text(), "") self.assertFalse(self.editor._input_has_arrived) # it won't switch the focus to the symbol input self.assertTrue(self.toggle.get_active()) self.assertEqual(custom_mapping.has_unsaved_changes(), changed) return selection_label if key is None: self.assertEqual(self.get_unfiltered_symbol_input_text(), SET_KEY_FIRST) self.assertEqual(self.editor.get_symbol_input_text(), "") # set the target selection if target: self.editor.set_target_selection(target) self.assertEqual(self.editor.get_target_selection(), target) else: self.assertEqual(self.editor.get_target_selection(), "keyboard") # set the symbol to make the new selection_label complete self.editor.set_symbol_input_text(symbol) self.assertEqual(self.editor.get_symbol_input_text(), symbol) # unfocus them to trigger some final logic self.set_focus(None) correct_case = system_mapping.correct_case(symbol) self.assertEqual(self.editor.get_symbol_input_text(), correct_case) self.assertFalse(custom_mapping.has_unsaved_changes()) self.set_focus(self.editor.get_text_input()) self.set_focus(None) return selection_label 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() class TestGui(GuiTestBase, unittest.TestCase): """For tests that use the window. Try to modify the configuration only by calling functions of the window. """ def test_can_start(self): self.assertIsNotNone(self.user_interface) self.assertTrue(self.user_interface.window.get_visible()) def test_gui_clean(self): # check that the test is correctly set up so that the user interface is clean selection_labels = self.selection_label_listbox.get_children() self.assertEqual(len(selection_labels), 1) self.assertEqual(self.editor.active_selection_label, selection_labels[0]) self.assertEqual( self.selection_label_listbox.get_selected_row(), selection_labels[0], ) self.assertEqual(len(custom_mapping), 0) self.assertEqual(selection_labels[0].get_label(), "new entry") self.assertEqual(self.editor.get_symbol_input_text(), "") preset_selection = self.user_interface.get("preset_selection") self.assertEqual(preset_selection.get_active_id(), "new preset") self.assertEqual(len(custom_mapping), 0) self.assertEqual(self.editor.get_recording_toggle().get_label(), "Change Key") self.assertEqual(self.get_unfiltered_symbol_input_text(), SET_KEY_FIRST) def test_ctrl_q(self): closed = False def on_close(): nonlocal closed closed = True with patch.object(self.user_interface, "on_close", on_close): self.user_interface.on_key_press( self.user_interface, GtkKeyEvent(Gdk.KEY_Control_L) ) self.user_interface.on_key_press( self.user_interface, GtkKeyEvent(Gdk.KEY_a) ) self.user_interface.on_key_release( self.user_interface, GtkKeyEvent(Gdk.KEY_Control_L) ) self.user_interface.on_key_release( self.user_interface, GtkKeyEvent(Gdk.KEY_a) ) self.user_interface.on_key_press( self.user_interface, GtkKeyEvent(Gdk.KEY_b) ) self.user_interface.on_key_press( self.user_interface, GtkKeyEvent(Gdk.KEY_q) ) self.user_interface.on_key_release( self.user_interface, GtkKeyEvent(Gdk.KEY_q) ) self.user_interface.on_key_release( self.user_interface, GtkKeyEvent(Gdk.KEY_b) ) self.assertFalse(closed) # while keys are being recorded no shortcut should work self.toggle.set_active(True) self.user_interface.on_key_press( self.user_interface, GtkKeyEvent(Gdk.KEY_Control_L) ) self.user_interface.on_key_press( self.user_interface, GtkKeyEvent(Gdk.KEY_q) ) self.assertFalse(closed) self.toggle.set_active(False) self.user_interface.on_key_press( self.user_interface, GtkKeyEvent(Gdk.KEY_Control_L) ) self.user_interface.on_key_press( self.user_interface, GtkKeyEvent(Gdk.KEY_q) ) self.assertTrue(closed) self.user_interface.on_key_release( self.user_interface, GtkKeyEvent(Gdk.KEY_Control_L) ) self.user_interface.on_key_release( self.user_interface, GtkKeyEvent(Gdk.KEY_q) ) def test_ctrl_r(self): with patch.object(reader, "refresh_groups") as reader_get_devices_patch: self.user_interface.on_key_press( self.user_interface, GtkKeyEvent(Gdk.KEY_Control_L) ) self.user_interface.on_key_press( self.user_interface, GtkKeyEvent(Gdk.KEY_r) ) reader_get_devices_patch.assert_called_once() def test_ctrl_del(self): with patch.object(self.user_interface.dbus, "stop_injecting") as stop_injecting: self.user_interface.on_key_press( self.user_interface, GtkKeyEvent(Gdk.KEY_Control_L) ) self.user_interface.on_key_press( self.user_interface, GtkKeyEvent(Gdk.KEY_Delete) ) stop_injecting.assert_called_once() def test_show_device_mapping_status(self): # this function may not return True, otherwise the timeout # runs forever self.assertFalse(self.user_interface.show_device_mapping_status()) def test_autoload(self): with spy(self.user_interface.dbus, "set_config_dir") as set_config_dir: self.user_interface.on_autoload_switch(None, False) set_config_dir.assert_called_once() self.assertFalse( config.is_autoloaded( self.user_interface.group.key, self.user_interface.preset_name ) ) self.user_interface.on_select_device(FakeDeviceDropdown("Foo Device 2")) gtk_iteration() self.assertFalse(self.user_interface.get("preset_autoload_switch").get_active()) # select a preset for the first device self.user_interface.get("preset_autoload_switch").set_active(True) gtk_iteration() self.assertTrue(self.user_interface.get("preset_autoload_switch").get_active()) self.assertEqual(self.user_interface.group.key, "Foo Device 2") self.assertEqual(self.user_interface.group.name, "Foo Device") self.assertTrue( config.is_autoloaded(self.user_interface.group.key, "new preset") ) self.assertFalse(config.is_autoloaded("Bar Device", "new preset")) self.assertListEqual( list(config.iterate_autoload_presets()), [("Foo Device 2", "new preset")] ) # create a new preset, the switch should be correctly off and the # config not changed. self.user_interface.on_create_preset_clicked() gtk_iteration() self.assertEqual(self.user_interface.preset_name, "new preset 2") self.assertFalse(self.user_interface.get("preset_autoload_switch").get_active()) self.assertTrue(config.is_autoloaded("Foo Device 2", "new preset")) self.assertFalse(config.is_autoloaded("Foo Device", "new preset")) self.assertFalse(config.is_autoloaded("Foo Device", "new preset 2")) self.assertFalse(config.is_autoloaded("Foo Device 2", "new preset 2")) # select a preset for the second device self.user_interface.on_select_device(FakeDeviceDropdown("Bar Device")) self.user_interface.get("preset_autoload_switch").set_active(True) gtk_iteration() self.assertTrue(config.is_autoloaded("Foo Device 2", "new preset")) self.assertFalse(config.is_autoloaded("Foo Device", "new preset")) self.assertTrue(config.is_autoloaded("Bar Device", "new preset")) self.assertListEqual( list(config.iterate_autoload_presets()), [("Foo Device 2", "new preset"), ("Bar Device", "new preset")], ) # disable autoloading for the second device self.user_interface.get("preset_autoload_switch").set_active(False) gtk_iteration() self.assertTrue(config.is_autoloaded("Foo Device 2", "new preset")) self.assertFalse(config.is_autoloaded("Foo Device", "new preset")) self.assertFalse(config.is_autoloaded("Bar Device", "new preset")) self.assertListEqual( list(config.iterate_autoload_presets()), [("Foo Device 2", "new preset")], ) def test_select_device(self): # creates a new empty preset when no preset exists for the device self.user_interface.on_select_device(FakeDeviceDropdown("Foo Device")) custom_mapping.change(Key(EV_KEY, 50, 1), "keyboard", "q") custom_mapping.change(Key(EV_KEY, 51, 1), "keyboard", "u") custom_mapping.change(Key(EV_KEY, 52, 1), "keyboard", "x") self.assertEqual(len(custom_mapping), 3) self.user_interface.on_select_device(FakeDeviceDropdown("Bar Device")) self.assertEqual(len(custom_mapping), 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 = get_preset_path("Bar Device", "new preset") self.assertTrue(os.path.exists(path)) with open(path, "r") as file: preset = json.load(file) self.assertEqual(len(preset["mapping"]), 0) def test_permission_error_on_create_preset_clicked(self): def save(_=None): raise PermissionError with patch.object(custom_mapping, "save", save): self.user_interface.on_create_preset_clicked() status = self.get_status_text() self.assertIn("Permission denied", status) def test_show_injection_result_failure(self): def get_state(_=None): return FAILED with patch.object(self.user_interface.dbus, "get_state", get_state): self.user_interface.show_injection_result() text = self.get_status_text() self.assertIn("Failed", text) def test_editor_keycode_to_string(self): # not an integration test, but I have all the selection_label tests here already self.assertEqual(Key(EV_KEY, evdev.ecodes.KEY_A, 1).beautify(), "a") self.assertEqual( Key(EV_ABS, evdev.ecodes.ABS_HAT0X, -1).beautify(), "DPad Left" ) self.assertEqual(Key(EV_ABS, evdev.ecodes.ABS_HAT0Y, -1).beautify(), "DPad Up") self.assertEqual(Key(EV_KEY, evdev.ecodes.BTN_A, 1).beautify(), "Button A") self.assertEqual(Key(EV_KEY, 1234, 1).beautify(), "1234") self.assertEqual( Key(EV_ABS, evdev.ecodes.ABS_X, 1).beautify(), "Joystick Right" ) self.assertEqual( Key(EV_ABS, evdev.ecodes.ABS_RY, 1).beautify(), "Joystick 2 Down" ) self.assertEqual( Key(EV_REL, evdev.ecodes.REL_HWHEEL, 1).beautify(), "Wheel Right" ) self.assertEqual( Key(EV_REL, evdev.ecodes.REL_WHEEL, -1).beautify(), "Wheel Down" ) # combinations self.assertEqual( Key( (EV_KEY, evdev.ecodes.BTN_A, 1), (EV_KEY, evdev.ecodes.BTN_B, 1), (EV_KEY, evdev.ecodes.BTN_C, 1), ).beautify(), "Button A + Button B + Button C", ) def test_editor_simple(self): self.assertEqual(self.toggle.get_label(), "Change Key") self.assertEqual(len(self.selection_label_listbox.get_children()), 1) selection_label = self.selection_label_listbox.get_children()[0] self.set_focus(self.toggle) self.toggle.set_active(True) self.assertEqual(self.toggle.get_label(), "Press Key") self.editor.consume_newest_keycode(None) # nothing happens self.assertIsNone(selection_label.get_key()) self.assertEqual(len(custom_mapping), 0) self.assertEqual(self.toggle.get_label(), "Press Key") self.editor.consume_newest_keycode(Key(EV_KEY, 30, 1)) # no symbol configured yet, so the custom_mapping remains empty self.assertEqual(len(custom_mapping), 0) self.assertEqual(selection_label.get_key(), (EV_KEY, 30, 1)) # this is KEY_A in linux/input-event-codes.h, # but KEY_ is removed from the text for display purposes self.assertEqual(selection_label.get_label(), "a") # providing the same key again (Maybe this could happen for gamepads or # something, idk) doesn't do any harm self.editor.consume_newest_keycode(Key(EV_KEY, 30, 1)) self.assertEqual(len(custom_mapping), 0) # not released yet self.assertEqual(selection_label.get_key(), (EV_KEY, 30, 1)) time.sleep(0.11) # new empty entry was added gtk_iteration() self.assertEqual( len(self.selection_label_listbox.get_children()), 2, ) self.set_focus(self.editor.get_text_input()) self.editor.set_symbol_input_text("Shift_L") self.set_focus(None) self.assertEqual(len(custom_mapping), 1) time.sleep(0.1) gtk_iteration() self.assertEqual( len(self.selection_label_listbox.get_children()), 2, ) self.assertEqual( custom_mapping.get_mapping(Key(EV_KEY, 30, 1)), ("Shift_L", "keyboard") ) self.assertEqual(self.editor.get_target_selection(), "keyboard") self.assertEqual(self.editor.get_symbol_input_text(), "Shift_L") self.assertEqual(selection_label.get_key(), (EV_KEY, 30, 1)) self.editor.set_target_selection("mouse") time.sleep(0.1) gtk_iteration() self.assertEqual( len(self.selection_label_listbox.get_children()), 2, ) self.assertEqual( custom_mapping.get_mapping(Key(EV_KEY, 30, 1)), ("Shift_L", "mouse") ) self.assertEqual(self.editor.get_target_selection(), "mouse") self.assertEqual(self.editor.get_symbol_input_text(), "Shift_L") self.assertEqual(selection_label.get_key(), (EV_KEY, 30, 1)) def test_editor_not_focused(self): # focus anything that is not the selection_label, # no keycode should be inserted into it self.set_focus(self.user_interface.get("preset_name_input")) send_event_to_reader(new_event(1, 61, 1)) self.user_interface.consume_newest_keycode() selection_labels = self.get_selection_labels() self.assertEqual(len(selection_labels), 1) selection_label = selection_labels[0] # the empty selection_label has this key not set self.assertIsNone(selection_label.get_key()) # focus the text input instead self.set_focus(self.editor.get_text_input()) send_event_to_reader(new_event(1, 61, 1)) self.user_interface.consume_newest_keycode() # still nothing set self.assertIsNone(selection_label.get_key()) def test_show_status(self): self.user_interface.show_status(0, "a" * 100) text = self.get_status_text() self.assertIn("...", text) self.user_interface.show_status(0, "b") text = self.get_status_text() self.assertNotIn("...", text) def test_clears_unreleased_on_focus_change(self): ev_1 = Key(EV_KEY, 41, 1) # focus self.set_focus(self.toggle) send_event_to_reader(new_event(*ev_1.keys[0])) reader.read() self.assertEqual(reader.get_unreleased_keys(), ev_1) # unfocus # doesn't call reader.clear. Otherwise the super key cannot be mapped, # because the start menu that opens up would unfocus the user interface self.set_focus(None) self.assertEqual(reader.get_unreleased_keys(), ev_1) # focus the toggle after selecting a different selection_label. # It resets the reader self.editor.add_empty() self.selection_label_listbox.select_row( self.selection_label_listbox.get_children()[-1] ) self.set_focus(self.toggle) self.toggle.set_active(True) self.assertEqual(reader.get_unreleased_keys(), None) def test_editor(self): """Comprehensive test for the editor.""" system_mapping.clear() system_mapping._set("Foo_BAR", 41) system_mapping._set("B", 42) system_mapping._set("c", 43) system_mapping._set("d", 44) # how many selection_labels there should be in the end num_selection_labels_target = 3 ev_1 = Key(EV_KEY, 10, 1) ev_2 = Key(EV_ABS, evdev.ecodes.ABS_HAT0X, -1) """edit""" # add two selection_labels by modifiying the one empty selection_label that # exists. Insert lowercase, it should be corrected to uppercase as stored # in system_mapping self.add_mapping_via_ui(ev_1, "foo_bar", target="mouse") self.add_mapping_via_ui(ev_2, "k(b).k(c)") # one empty selection_label added automatically again time.sleep(0.1) gtk_iteration() self.assertEqual(len(self.get_selection_labels()), num_selection_labels_target) self.assertEqual(custom_mapping.get_mapping(ev_1), ("Foo_BAR", "mouse")) self.assertEqual(custom_mapping.get_mapping(ev_2), ("k(b).k(c)", "keyboard")) """edit first selection_label""" self.selection_label_listbox.select_row( self.selection_label_listbox.get_children()[0] ) self.assertEqual(self.editor.get_key(), ev_1) self.set_focus(self.editor.get_text_input()) self.editor.set_symbol_input_text("c") self.set_focus(None) # after unfocusing, it stores the mapping. So loading it again will retain # the mapping that was used preset_name = self.user_interface.preset_name preset_path = self.user_interface.group.get_preset_path(preset_name) custom_mapping.load(preset_path) self.assertEqual(custom_mapping.get_mapping(ev_1), ("c", "mouse")) self.assertEqual(custom_mapping.get_mapping(ev_2), ("k(b).k(c)", "keyboard")) """add duplicate""" # try to add a duplicate keycode, it should be ignored self.add_mapping_via_ui(ev_2, "d", expect_success=False) self.assertEqual(custom_mapping.get_mapping(ev_2), ("k(b).k(c)", "keyboard")) # and the number of selection_labels shouldn't change self.assertEqual(len(self.get_selection_labels()), num_selection_labels_target) def test_hat0x(self): # it should be possible to add all of them ev_1 = Key(EV_ABS, evdev.ecodes.ABS_HAT0X, -1) ev_2 = Key(EV_ABS, evdev.ecodes.ABS_HAT0X, 1) ev_3 = Key(EV_ABS, evdev.ecodes.ABS_HAT0Y, -1) ev_4 = Key(EV_ABS, evdev.ecodes.ABS_HAT0Y, 1) self.add_mapping_via_ui(ev_1, "a") self.add_mapping_via_ui(ev_2, "b") self.add_mapping_via_ui(ev_3, "c") self.add_mapping_via_ui(ev_4, "d") self.assertEqual(custom_mapping.get_mapping(ev_1), ("a", "keyboard")) self.assertEqual(custom_mapping.get_mapping(ev_2), ("b", "keyboard")) self.assertEqual(custom_mapping.get_mapping(ev_3), ("c", "keyboard")) self.assertEqual(custom_mapping.get_mapping(ev_4), ("d", "keyboard")) # and trying to add them as duplicate selection_labels will be ignored for each # of them self.add_mapping_via_ui(ev_1, "e", expect_success=False) self.add_mapping_via_ui(ev_2, "f", expect_success=False) self.add_mapping_via_ui(ev_3, "g", expect_success=False) self.add_mapping_via_ui(ev_4, "h", expect_success=False) self.assertEqual(custom_mapping.get_mapping(ev_1), ("a", "keyboard")) self.assertEqual(custom_mapping.get_mapping(ev_2), ("b", "keyboard")) self.assertEqual(custom_mapping.get_mapping(ev_3), ("c", "keyboard")) self.assertEqual(custom_mapping.get_mapping(ev_4), ("d", "keyboard")) def test_combination(self): # it should be possible to write a key combination ev_1 = Key(EV_KEY, evdev.ecodes.KEY_A, 1) ev_2 = Key(EV_ABS, evdev.ecodes.ABS_HAT0X, 1) ev_3 = Key(EV_KEY, evdev.ecodes.KEY_C, 1) ev_4 = Key(EV_ABS, evdev.ecodes.ABS_HAT0X, -1) combination_1 = Key(ev_1, ev_2, ev_3) combination_2 = Key(ev_2, ev_1, ev_3) # same as 1, but different D-Pad direction combination_3 = Key(ev_1, ev_4, ev_3) combination_4 = Key(ev_4, ev_1, ev_3) # same as 1, but the last key is different combination_5 = Key(ev_1, ev_3, ev_2) combination_6 = Key(ev_3, ev_1, ev_2) self.add_mapping_via_ui(combination_1, "a") self.assertEqual(custom_mapping.get_mapping(combination_1), ("a", "keyboard")) self.assertEqual(custom_mapping.get_mapping(combination_2), ("a", "keyboard")) self.assertIsNone(custom_mapping.get_mapping(combination_3)) self.assertIsNone(custom_mapping.get_mapping(combination_4)) self.assertIsNone(custom_mapping.get_mapping(combination_5)) self.assertIsNone(custom_mapping.get_mapping(combination_6)) # it won't write the same combination again, even if the # first two events are in a different order self.add_mapping_via_ui(combination_2, "b", expect_success=False) self.assertEqual(custom_mapping.get_mapping(combination_1), ("a", "keyboard")) self.assertEqual(custom_mapping.get_mapping(combination_2), ("a", "keyboard")) self.assertIsNone(custom_mapping.get_mapping(combination_3)) self.assertIsNone(custom_mapping.get_mapping(combination_4)) self.assertIsNone(custom_mapping.get_mapping(combination_5)) self.assertIsNone(custom_mapping.get_mapping(combination_6)) self.add_mapping_via_ui(combination_3, "c") self.assertEqual(custom_mapping.get_mapping(combination_1), ("a", "keyboard")) self.assertEqual(custom_mapping.get_mapping(combination_2), ("a", "keyboard")) self.assertEqual(custom_mapping.get_mapping(combination_3), ("c", "keyboard")) self.assertEqual(custom_mapping.get_mapping(combination_4), ("c", "keyboard")) self.assertIsNone(custom_mapping.get_mapping(combination_5)) self.assertIsNone(custom_mapping.get_mapping(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. self.add_mapping_via_ui(combination_4, "d", expect_success=False) self.assertEqual(custom_mapping.get_mapping(combination_1), ("a", "keyboard")) self.assertEqual(custom_mapping.get_mapping(combination_2), ("a", "keyboard")) self.assertEqual(custom_mapping.get_mapping(combination_3), ("c", "keyboard")) self.assertEqual(custom_mapping.get_mapping(combination_4), ("c", "keyboard")) self.assertIsNone(custom_mapping.get_mapping(combination_5)) self.assertIsNone(custom_mapping.get_mapping(combination_6)) self.add_mapping_via_ui(combination_5, "e") self.assertEqual(custom_mapping.get_mapping(combination_1), ("a", "keyboard")) self.assertEqual(custom_mapping.get_mapping(combination_2), ("a", "keyboard")) self.assertEqual(custom_mapping.get_mapping(combination_3), ("c", "keyboard")) self.assertEqual(custom_mapping.get_mapping(combination_4), ("c", "keyboard")) self.assertEqual(custom_mapping.get_mapping(combination_5), ("e", "keyboard")) self.assertEqual(custom_mapping.get_mapping(combination_6), ("e", "keyboard")) 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_remove_selection_label(self): """Comprehensive test for selection_labels 2.""" def remove( selection_label, code, symbol, num_selection_labels_after, target="keyboard" ): """Remove a selection_label by clicking the delete button. Parameters ---------- selection_label : SelectionLabel code : int or None keycode of the mapping that is associated with this selection_label symbol : string ouptut of the mapping that is associated with this selection_label num_selection_labels_after : int after deleting, how many selection_labels are expected to still be there target : selected target in target_selector """ self.selection_label_listbox.select_row(selection_label) if code is not None and symbol is not None: self.assertEqual( custom_mapping.get_mapping(Key(EV_KEY, code, 1)), (symbol, target) ) if symbol is not None: self.assertEqual(self.editor.get_symbol_input_text(), symbol) self.assertEqual(self.editor.get_target_selection(), target) if code is None: self.assertIsNone(selection_label.get_key()) else: self.assertEqual(selection_label.get_key(), Key(EV_KEY, code, 1)) with PatchedConfirmDelete(self.user_interface): self.editor._on_delete_button_clicked() time.sleep(0.2) gtk_iteration() # if a reference to the selection_label is held somewhere and it is # accidentally used again, make sure to not provide any outdated # information that is supposed to be deleted self.assertIsNone(selection_label.get_key()) if code is not None: self.assertIsNone(custom_mapping.get_mapping(Key(EV_KEY, code, 1))) self.assertEqual( len(self.get_selection_labels()), num_selection_labels_after, ) # sleeps are added to be able to visually follow and debug the test. Add two # selection_labels by modifiying the one empty selection_label that exists selection_label_1 = self.add_mapping_via_ui(Key(EV_KEY, 10, 1), "a") selection_label_2 = self.add_mapping_via_ui(Key(EV_KEY, 11, 1), "b") # no empty selection_label added because one is unfinished time.sleep(0.2) gtk_iteration() self.assertEqual(len(self.get_selection_labels()), 3) self.assertEqual( custom_mapping.get_mapping(Key(EV_KEY, 11, 1)), ("b", "keyboard") ) remove(selection_label_1, 10, "a", 2) remove(selection_label_2, 11, "b", 1) # there is no empty selection_label at the moment, so after removing that one, # which is the only selection_label, one empty selection_label will be there. # So the number of selection_labels won't change. remove(self.selection_label_listbox.get_children()[-1], None, None, 1) def test_problematic_combination(self): combination = Key((EV_KEY, KEY_LEFTSHIFT, 1), (EV_KEY, 82, 1)) self.add_mapping_via_ui(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): self.assertEqual(self.user_interface.group.name, "Foo Device") self.assertFalse(config.is_autoloaded("Foo Device", "new preset")) custom_mapping.change(Key(EV_KEY, 14, 1), "keyboard", "a", None) self.assertEqual(self.user_interface.preset_name, "new preset") self.user_interface.save_preset() self.assertEqual( custom_mapping.get_mapping(Key(EV_KEY, 14, 1)), ("a", "keyboard") ) config.set_autoload_preset("Foo Device", "new preset") self.assertTrue(config.is_autoloaded("Foo Device", "new preset")) custom_mapping.change(Key(EV_KEY, 14, 1), "keyboard", "b", None) self.user_interface.get("preset_name_input").set_text("asdf") self.user_interface.save_preset() self.user_interface.on_rename_button_clicked(None) self.assertEqual(self.user_interface.preset_name, "asdf") preset_path = f"{CONFIG_PATH}/presets/Foo Device/asdf.json" self.assertTrue(os.path.exists(preset_path)) self.assertEqual( custom_mapping.get_mapping(Key(EV_KEY, 14, 1)), ("b", "keyboard") ) # after renaming the preset it is still set to autoload self.assertTrue(config.is_autoloaded("Foo Device", "asdf")) # ALSO IN THE ACTUAL CONFIG FILE! config.load_config() self.assertTrue(config.is_autoloaded("Foo Device", "asdf")) error_icon = self.user_interface.get("error_status_icon") self.assertFalse(error_icon.get_visible()) # otherwise save won't do anything custom_mapping.change(Key(EV_KEY, 14, 1), "keyboard", "c", None) self.assertTrue(custom_mapping.has_unsaved_changes()) def save(_): raise PermissionError with patch.object(custom_mapping, "save", save): self.user_interface.save_preset() status = self.get_status_text() self.assertIn("Permission denied", status) with PatchedConfirmDelete(self.user_interface): self.user_interface.on_delete_preset_clicked(None) self.assertFalse(os.path.exists(preset_path)) def test_rename_create_switch(self): # after renaming a preset and saving it, new presets # start with "new preset" again custom_mapping.change(Key(EV_KEY, 14, 1), "keyboard", "a", None) self.user_interface.get("preset_name_input").set_text("asdf") self.user_interface.save_preset() self.user_interface.on_rename_button_clicked(None) self.assertEqual(len(custom_mapping), 1) self.assertEqual(self.user_interface.preset_name, "asdf") self.user_interface.on_create_preset_clicked() self.assertEqual(self.user_interface.preset_name, "new preset") self.assertEqual(len(self.selection_label_listbox.get_children()), 1) self.assertEqual(len(custom_mapping), 0) self.user_interface.save_preset() # symbol and code in the gui won't be carried over after selecting a preset self.editor.set_key(Key(EV_KEY, 15, 1)) self.editor.set_symbol_input_text("b") # selecting the first preset again loads the saved mapping, and saves # the current changes in the gui self.user_interface.on_select_preset(FakePresetDropdown("asdf")) self.assertEqual( custom_mapping.get_mapping(Key(EV_KEY, 14, 1)), ("a", "keyboard") ) self.assertEqual(len(custom_mapping), 1) self.assertEqual(len(self.selection_label_listbox.get_children()), 2) config.set_autoload_preset("Foo Device", "new preset") # renaming a preset to an existing name appends a number self.user_interface.on_select_preset(FakePresetDropdown("new preset")) self.user_interface.get("preset_name_input").set_text("asdf") self.user_interface.on_rename_button_clicked(None) self.assertEqual(self.user_interface.preset_name, "asdf 2") # and that added number is correctly used in the autoload # configuration as well self.assertTrue(config.is_autoloaded("Foo Device", "asdf 2")) self.assertEqual( custom_mapping.get_mapping(Key(EV_KEY, 15, 1)), ("b", "keyboard") ) self.assertEqual(len(custom_mapping), 1) self.assertEqual(len(self.selection_label_listbox.get_children()), 2) self.assertEqual(self.user_interface.get("preset_name_input").get_text(), "") # renaming the current preset to itself doesn't append a number and # it doesn't do anything on the file system def _raise(*_): # should not get called raise AssertionError with patch.object(os, "rename", _raise): self.user_interface.get("preset_name_input").set_text("asdf 2") self.user_interface.on_rename_button_clicked(None) self.assertEqual(self.user_interface.preset_name, "asdf 2") self.user_interface.get("preset_name_input").set_text("") self.user_interface.on_rename_button_clicked(None) self.assertEqual(self.user_interface.preset_name, "asdf 2") def test_avoids_redundant_saves(self): custom_mapping.change(Key(EV_KEY, 14, 1), "keyboard", "abcd", None) custom_mapping.set_has_unsaved_changes(False) self.user_interface.save_preset() with open(get_preset_path("Foo Device", "new preset")) as f: content = f.read() self.assertNotIn("abcd", content) custom_mapping.set_has_unsaved_changes(True) self.user_interface.save_preset() with open(get_preset_path("Foo Device", "new preset")) as f: content = f.read() self.assertIn("abcd", content) def test_check_for_unknown_symbols(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") custom_mapping.change(Key(EV_KEY, 71, 1), "keyboard", "qux", None) custom_mapping.change(Key(EV_KEY, 72, 1), "keyboard", "foo", None) self.user_interface.save_preset() tooltip = status.get_tooltip_text().lower() self.assertIn("qux", tooltip) self.assertTrue(error_icon.get_visible()) self.assertFalse(warning_icon.get_visible()) # it will still save it though with open(get_preset_path("Foo Device", "new preset")) as f: content = f.read() self.assertIn("qux", content) self.assertIn("foo", content) custom_mapping.change(Key(EV_KEY, 71, 1), "keyboard", "a", None) self.user_interface.save_preset() tooltip = status.get_tooltip_text().lower() self.assertIn("foo", tooltip) self.assertTrue(error_icon.get_visible()) self.assertFalse(warning_icon.get_visible()) custom_mapping.change(Key(EV_KEY, 72, 1), "keyboard", "b", None) self.user_interface.save_preset() tooltip = status.get_tooltip_text() self.assertIsNone(tooltip) self.assertFalse(error_icon.get_visible()) self.assertFalse(warning_icon.get_visible()) def test_check_macro_syntax(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") custom_mapping.change(Key(EV_KEY, 9, 1), "keyboard", "k(1))", None) self.user_interface.save_preset() tooltip = status.get_tooltip_text().lower() self.assertIn("brackets", tooltip) self.assertTrue(error_icon.get_visible()) self.assertFalse(warning_icon.get_visible()) custom_mapping.change(Key(EV_KEY, 9, 1), "keyboard", "k(1)", None) self.user_interface.save_preset() 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( custom_mapping.get_mapping(Key(EV_KEY, 9, 1)), ("k(1)", "keyboard") ) def test_select_device_and_preset(self): foo_device_path = f"{CONFIG_PATH}/presets/Foo Device" key_10 = Key(EV_KEY, 10, 1) key_11 = Key(EV_KEY, 11, 1) # created on start because the first device is selected and some empty # preset prepared. self.assertTrue(os.path.exists(f"{foo_device_path}/new preset.json")) self.assertEqual(self.user_interface.group.name, "Foo Device") self.assertEqual(self.user_interface.preset_name, "new preset") # change it to check if the gui loads presets correctly later self.editor.set_key(key_10) self.editor.set_symbol_input_text("a") # create another one self.user_interface.on_create_preset_clicked() gtk_iteration() self.assertTrue(os.path.exists(f"{foo_device_path}/new preset.json")) self.assertTrue(os.path.exists(f"{foo_device_path}/new preset 2.json")) self.assertEqual(self.user_interface.preset_name, "new preset 2") self.assertEqual(len(custom_mapping), 0) # this should not be loaded when "new preset" is selected, because it belongs # to "new preset 2": self.editor.set_key(key_11) self.editor.set_symbol_input_text("a") # select the first one again self.user_interface.on_select_preset(FakePresetDropdown("new preset")) gtk_iteration() self.assertEqual(self.user_interface.preset_name, "new preset") self.assertEqual(len(custom_mapping), 1) self.assertEqual(custom_mapping.get_mapping(key_10), ("a", "keyboard")) self.assertListEqual( sorted(os.listdir(f"{foo_device_path}")), sorted(["new preset.json", "new preset 2.json"]), ) """now try to change the name""" self.user_interface.get("preset_name_input").set_text("abc 123") gtk_iteration() self.assertEqual(self.user_interface.preset_name, "new preset") self.assertFalse(os.path.exists(f"{foo_device_path}/abc 123.json")) # putting new information into the editor does not lead to some weird # problems. when doing the rename everything will be saved and then moved # to the new path self.editor.set_key(Key(EV_KEY, 10, 1)) self.editor.set_symbol_input_text("1") self.assertEqual(self.user_interface.preset_name, "new preset") self.user_interface.on_rename_button_clicked(None) self.assertEqual(self.user_interface.preset_name, "abc 123") gtk_iteration() self.assertEqual(self.user_interface.preset_name, "abc 123") self.assertTrue(os.path.exists(f"{foo_device_path}/abc 123.json")) self.assertListEqual( sorted(os.listdir(os.path.join(CONFIG_PATH, "presets"))), sorted(["Foo Device"]), ) self.assertListEqual( sorted(os.listdir(f"{foo_device_path}")), sorted(["abc 123.json", "new preset 2.json"]), ) def test_copy_preset(self): selection_labels = self.selection_label_listbox self.add_mapping_via_ui(Key(EV_KEY, 81, 1), "a") time.sleep(0.1) gtk_iteration() self.user_interface.save_preset() # 2 selection_labels: the changed selection_label and an empty selection_label self.assertEqual(len(selection_labels.get_children()), 2) # should be cleared when creating a new preset custom_mapping.set("a.b", 3) self.assertEqual(custom_mapping.get("a.b"), 3) self.user_interface.on_create_preset_clicked() # the preset should be empty, only one empty selection_label present self.assertEqual(len(selection_labels.get_children()), 1) self.assertIsNone(custom_mapping.get("a.b")) # add one new selection_label again and a setting self.add_mapping_via_ui(Key(EV_KEY, 81, 1), "b") time.sleep(0.1) gtk_iteration() self.user_interface.save_preset() self.assertEqual(len(selection_labels.get_children()), 2) custom_mapping.set(["foo", "bar"], 2) # this time it should be copied self.user_interface.on_copy_preset_clicked() self.assertEqual(self.user_interface.preset_name, "new preset 2 copy") self.assertEqual(len(selection_labels.get_children()), 2) self.assertEqual(self.editor.get_symbol_input_text(), "b") self.assertEqual(custom_mapping.get(["foo", "bar"]), 2) # make another copy self.user_interface.on_copy_preset_clicked() self.assertEqual(self.user_interface.preset_name, "new preset 2 copy 2") self.assertEqual(len(selection_labels.get_children()), 2) self.assertEqual(self.editor.get_symbol_input_text(), "b") self.assertEqual(len(custom_mapping), 1) self.assertEqual(custom_mapping.get("foo.bar"), 2) def test_gamepad_config(self): # set some stuff in the beginning, otherwise gtk fails to # do handler_unblock_by_func, which makes no sense at all. # but it ONLY fails on right_joystick_purpose for some reason, # unblocking the left one works just fine. I should open a bug report # on gtk or something probably. self.user_interface.get("left_joystick_purpose").set_active_id(BUTTONS) self.user_interface.get("right_joystick_purpose").set_active_id(BUTTONS) self.user_interface.get("joystick_mouse_speed").set_value(1) custom_mapping.set_has_unsaved_changes(False) # select a device that is not a gamepad self.user_interface.on_select_device(FakeDeviceDropdown("Foo Device")) self.assertFalse(self.user_interface.get("gamepad_config").is_visible()) self.assertFalse(custom_mapping.has_unsaved_changes()) # select a gamepad self.user_interface.on_select_device(FakeDeviceDropdown("gamepad")) self.assertTrue(self.user_interface.get("gamepad_config").is_visible()) self.assertFalse(custom_mapping.has_unsaved_changes()) # set stuff gtk_iteration() self.user_interface.get("left_joystick_purpose").set_active_id(WHEEL) self.user_interface.get("right_joystick_purpose").set_active_id(WHEEL) joystick_mouse_speed = 5 self.user_interface.get("joystick_mouse_speed").set_value(joystick_mouse_speed) # it should be stored in custom_mapping, which overwrites the # global config config.set("gamepad.joystick.left_purpose", MOUSE) config.set("gamepad.joystick.right_purpose", MOUSE) config.set("gamepad.joystick.pointer_speed", 50) self.assertTrue(custom_mapping.has_unsaved_changes()) left_purpose = custom_mapping.get("gamepad.joystick.left_purpose") right_purpose = custom_mapping.get("gamepad.joystick.right_purpose") pointer_speed = custom_mapping.get("gamepad.joystick.pointer_speed") self.assertEqual(left_purpose, WHEEL) self.assertEqual(right_purpose, WHEEL) self.assertEqual(pointer_speed, 2 ** joystick_mouse_speed) # select a device that is not a gamepad again self.user_interface.on_select_device(FakeDeviceDropdown("Foo Device")) self.assertFalse(self.user_interface.get("gamepad_config").is_visible()) self.assertFalse(custom_mapping.has_unsaved_changes()) def test_wont_start(self): error_icon = self.user_interface.get("error_status_icon") preset_name = "foo preset" group_name = "Bar Device" self.user_interface.preset_name = preset_name self.user_interface.group = groups.find(name=group_name) # empty custom_mapping.empty() self.user_interface.save_preset() self.user_interface.on_apply_preset_clicked(None) text = self.get_status_text() self.assertIn("add keys", text) self.assertTrue(error_icon.get_visible()) self.assertNotEqual( self.user_interface.dbus.get_state(self.user_interface.group.key), RUNNING ) # not empty, but keys are held down custom_mapping.change(Key(EV_KEY, KEY_A, 1), "keyboard", "a") self.user_interface.save_preset() send_event_to_reader(new_event(EV_KEY, KEY_A, 1)) reader.read() self.assertEqual(len(reader._unreleased), 1) self.assertFalse(self.user_interface.unreleased_warn) self.user_interface.on_apply_preset_clicked(None) text = self.get_status_text() self.assertIn("release", text) self.assertTrue(error_icon.get_visible()) self.assertNotEqual( self.user_interface.dbus.get_state(self.user_interface.group.key), RUNNING ) self.assertTrue(self.user_interface.unreleased_warn) self.assertEqual( self.user_interface.get("apply_system_layout").get_opacity(), 0.4 ) # device grabbing fails 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 for i in range(2): # just pressing apply again will overwrite the previous error self.grab_fails = True self.user_interface.on_apply_preset_clicked(None) self.assertFalse(self.user_interface.unreleased_warn) 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("not grabbed", text) self.assertTrue(error_icon.get_visible()) self.assertNotEqual( self.user_interface.dbus.get_state(self.user_interface.group.key), RUNNING, ) # for the second try, release the key. that should also work send_event_to_reader(new_event(EV_KEY, KEY_A, 0)) reader.read() self.assertEqual(len(reader._unreleased), 0) # this time work properly self.grab_fails = False custom_mapping.save(get_preset_path(group_name, preset_name)) self.user_interface.on_apply_preset_clicked(None) 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.user_interface.dbus.get_state(self.user_interface.group.key), RUNNING ) # because this test managed to reproduce some minor bug: self.assertNotIn("mapping", custom_mapping._config) def test_wont_start_2(self): preset_name = "foo preset" group_name = "Bar Device" self.user_interface.preset_name = preset_name self.user_interface.group = groups.find(name=group_name) 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 # btn_left mapped custom_mapping.change(Key.btn_left(), "keyboard", "a") self.user_interface.save_preset() # and key held down send_event_to_reader(new_event(EV_KEY, KEY_A, 1)) reader.read() self.assertEqual(len(reader._unreleased), 1) self.assertFalse(self.user_interface.unreleased_warn) # first apply, shows btn_left warning self.user_interface.on_apply_preset_clicked(None) text = self.get_status_text() self.assertIn("click", text) self.assertEqual( self.user_interface.dbus.get_state(self.user_interface.group.key), UNKNOWN ) # second apply, shows unreleased warning self.user_interface.on_apply_preset_clicked(None) text = self.get_status_text() self.assertIn("release", text) self.assertEqual( self.user_interface.dbus.get_state(self.user_interface.group.key), UNKNOWN ) # third apply, overwrites both warnings self.user_interface.on_apply_preset_clicked(None) wait() self.assertEqual( self.user_interface.dbus.get_state(self.user_interface.group.key), 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_can_modify_mapping(self): preset_name = "foo preset" group_name = "Bar Device" self.user_interface.preset_name = preset_name self.user_interface.group = groups.find(name=group_name) self.assertNotEqual( self.user_interface.dbus.get_state(self.user_interface.group.key), RUNNING ) self.user_interface.can_modify_mapping() text = self.get_status_text() self.assertNotIn("Stop Injection", text) custom_mapping.change(Key(EV_KEY, KEY_A, 1), "keyboard", "b") custom_mapping.save(get_preset_path(group_name, preset_name)) self.user_interface.on_apply_preset_clicked(None) # 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.user_interface.dbus.get_state(self.user_interface.group.key), RUNNING ) # the mapping cannot be changed anymore self.assertFalse(self.user_interface.can_modify_mapping()) # the toggle button should reset itself shortly self.user_interface.editor.get_recording_toggle().set_active(True) for _ in range(10): time.sleep(0.1) gtk_iteration() if not self.user_interface.editor.get_recording_toggle().get_active(): break self.assertFalse(self.user_interface.editor.get_recording_toggle().get_active()) text = self.get_status_text() self.assertIn("Stop Injection", text) def test_start_injecting(self): keycode_from = 9 keycode_to = 200 self.add_mapping_via_ui(Key(EV_KEY, keycode_from, 1), "a") system_mapping.clear() system_mapping._set("a", keycode_to) push_events( "Foo Device 2", [ new_event(evdev.events.EV_KEY, keycode_from, 1), new_event(evdev.events.EV_KEY, keycode_from, 0), ], ) # injecting for group.key will look at paths containing group.name custom_mapping.save(get_preset_path("Foo Device", "foo preset")) # use only the manipulated system_mapping if os.path.exists(os.path.join(tmp, XMODMAP_FILENAME)): os.remove(os.path.join(tmp, XMODMAP_FILENAME)) # select the second Foo device self.user_interface.group = groups.find(key="Foo Device 2") with spy(self.user_interface.dbus, "set_config_dir") as spy1: self.user_interface.preset_name = "foo preset" with spy(self.user_interface.dbus, "start_injecting") as spy2: self.user_interface.on_apply_preset_clicked(None) # correctly uses group.key, not group.name spy2.assert_called_once_with("Foo Device 2", "foo preset") spy1.assert_called_once_with(get_config_path()) # the integration tests will cause the injection to be started as # processes, as intended. Luckily, recv will block until the events # are handled and pushed. # Note, that appending events to pending_events won't work anymore # from here on because the injector processes memory cannot be # modified from here. event = uinput_write_history_pipe[0].recv() self.assertEqual(event.type, evdev.events.EV_KEY) self.assertEqual(event.code, keycode_to) self.assertEqual(event.value, 1) event = uinput_write_history_pipe[0].recv() self.assertEqual(event.type, evdev.events.EV_KEY) self.assertEqual(event.code, keycode_to) self.assertEqual(event.value, 0) # the input-remapper device will not be shown groups.refresh() self.user_interface.populate_devices() for entry in self.user_interface.device_store: # whichever attribute contains "input-remapper" self.assertNotIn("input-remapper", "".join(entry)) def test_gamepad_purpose_mouse_and_button(self): self.user_interface.on_select_device(FakeDeviceDropdown("gamepad")) self.user_interface.get("right_joystick_purpose").set_active_id(MOUSE) self.user_interface.get("left_joystick_purpose").set_active_id(BUTTONS) self.user_interface.get("joystick_mouse_speed").set_value(6) gtk_iteration() speed = custom_mapping.get("gamepad.joystick.pointer_speed") custom_mapping.set("gamepad.joystick.non_linearity", 1) self.assertEqual(speed, 2 ** 6) # don't consume the events in the reader, they are used to test # the injection reader.terminate() time.sleep(0.1) push_events( "gamepad", [new_event(EV_ABS, ABS_RX, MIN_ABS), new_event(EV_ABS, ABS_X, MAX_ABS)] * 100, ) custom_mapping.change(Key(EV_ABS, ABS_X, 1), "keyboard", "a") self.user_interface.save_preset() gtk_iteration() self.user_interface.on_apply_preset_clicked(None) time.sleep(0.3) history = [] while uinput_write_history_pipe[0].poll(): history.append(uinput_write_history_pipe[0].recv().t) count_mouse = history.count((EV_REL, REL_X, -speed)) count_button = history.count((EV_KEY, KEY_A, 1)) self.assertGreater(count_mouse, 1) self.assertEqual(count_button, 1) self.assertEqual(count_button + count_mouse, len(history)) self.assertIn("gamepad", self.user_interface.dbus.injectors) def test_stop_injecting(self): keycode_from = 16 keycode_to = 90 self.add_mapping_via_ui(Key(EV_KEY, keycode_from, 1), "t") system_mapping.clear() system_mapping._set("t", keycode_to) # not all of those events should be processed, since that takes some # time due to time.sleep in the fakes and the injection is stopped. push_events("Bar Device", [new_event(1, keycode_from, 1)] * 100) custom_mapping.save(get_preset_path("Bar Device", "foo preset")) self.user_interface.group = groups.find(name="Bar Device") self.user_interface.preset_name = "foo preset" self.user_interface.on_apply_preset_clicked(None) pipe = uinput_write_history_pipe[0] # block until the first event is available, indicating that # the injector is ready write_history = [pipe.recv()] # stop self.user_interface.on_restore_defaults_clicked(None) # try to receive a few of the events time.sleep(0.2) while pipe.poll(): write_history.append(pipe.recv()) len_before = len(write_history) self.assertLess(len(write_history), 50) # since the injector should not be running anymore, no more events # should be received after waiting even more time time.sleep(0.2) while pipe.poll(): write_history.append(pipe.recv()) self.assertEqual(len(write_history), len_before) def test_delete_preset(self): self.editor.set_key(Key(EV_KEY, 71, 1)) self.editor.set_symbol_input_text("a") self.user_interface.get("preset_name_input").set_text("asdf") self.user_interface.on_rename_button_clicked(None) gtk_iteration() self.assertEqual(self.user_interface.preset_name, "asdf") self.assertEqual(len(custom_mapping), 1) self.user_interface.save_preset() self.assertTrue(os.path.exists(get_preset_path("Foo Device", "asdf"))) with PatchedConfirmDelete(self.user_interface, Gtk.ResponseType.CANCEL): self.user_interface.on_delete_preset_clicked(None) self.assertTrue(os.path.exists(get_preset_path("Foo Device", "asdf"))) self.assertEqual(self.user_interface.preset_name, "asdf") self.assertEqual(self.user_interface.group.name, "Foo Device") with PatchedConfirmDelete(self.user_interface): self.user_interface.on_delete_preset_clicked(None) self.assertFalse(os.path.exists(get_preset_path("Foo Device", "asdf"))) self.assertEqual(self.user_interface.preset_name, "new preset") self.assertEqual(self.user_interface.group.name, "Foo Device") def test_populate_devices(self): preset_selection = self.user_interface.get("preset_selection") # create two presets self.user_interface.get("preset_name_input").set_text("preset 1") self.user_interface.on_rename_button_clicked(None) self.assertEqual(preset_selection.get_active_id(), "preset 1") # to make sure the next preset has a slightly higher timestamp time.sleep(0.1) self.user_interface.on_create_preset_clicked() self.user_interface.get("preset_name_input").set_text("preset 2") self.user_interface.on_rename_button_clicked(None) self.assertEqual(preset_selection.get_active_id(), "preset 2") # select the older one preset_selection.set_active_id("preset 1") self.assertEqual(self.user_interface.preset_name, "preset 1") # add a device that doesn't exist to the dropdown unknown_key = "key-1234" self.user_interface.device_store.insert(0, [unknown_key, None, "foo"]) self.user_interface.populate_devices() # the newest preset should be selected self.assertEqual(self.user_interface.preset_name, "preset 2") # the list contains correct entries # and the non-existing entry should be removed entries = [tuple(entry) for entry in self.user_interface.device_store] keys = [entry[0] for entry in self.user_interface.device_store] self.assertNotIn(unknown_key, keys) self.assertIn("Foo Device", keys) self.assertIn(("Foo Device", "input-keyboard", "Foo Device"), entries) self.assertIn(("Foo Device 2", "input-mouse", "Foo Device 2"), entries) self.assertIn(("Bar Device", "input-keyboard", "Bar Device"), entries) self.assertIn(("gamepad", "input-gaming", "gamepad"), entries) # it won't crash due to "list index out of range" # when `types` is an empty list. Won't show an icon groups.find(key="Foo Device 2").types = [] self.user_interface.populate_devices() self.assertIn( ("Foo Device 2", None, "Foo Device 2"), [tuple(entry) for entry in self.user_interface.device_store], ) 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 # 1. create a preset self.user_interface.on_select_device(FakeDeviceDropdown("Foo Device 2")) self.user_interface.on_create_preset_clicked() self.add_mapping_via_ui(Key(3, 2, 1), "qux") self.user_interface.get("preset_name_input").set_text("asdf") self.user_interface.on_rename_button_clicked(None) self.user_interface.save_preset() self.assertIn("asdf.json", os.listdir(get_preset_path("Foo Device"))) # 2. switch to the different device, there should be no preset named asdf self.user_interface.on_select_device(FakeDeviceDropdown("Bar Device")) self.assertEqual(self.user_interface.preset_name, "new preset") self.assertNotIn("asdf.json", os.listdir(get_preset_path("Bar Device"))) self.assertEqual(self.editor.get_symbol_input_text(), "") # 3. switch to the device with the same name as the first one self.user_interface.on_select_device(FakeDeviceDropdown("Foo Device")) # the newest preset is asdf, it should be automatically selected self.assertEqual(self.user_interface.preset_name, "asdf") self.assertEqual(self.editor.get_symbol_input_text(), "qux") def test_delete_last_preset(self): with PatchedConfirmDelete(self.user_interface): # add some rows for code in range(3): self.add_mapping_via_ui(Key(1, code, 1), "qux") self.user_interface.on_delete_preset_clicked() # the ui should be clear now self.test_gui_clean() device_path = f"{CONFIG_PATH}/presets/{self.user_interface.group.key}" self.assertTrue(os.path.exists(f"{device_path}/new preset.json")) self.user_interface.on_delete_preset_clicked() # deleting an empty preset als doesn't do weird stuff self.test_gui_clean() device_path = f"{CONFIG_PATH}/presets/{self.user_interface.group.key}" self.assertTrue(os.path.exists(f"{device_path}/new preset.json")) def test_enable_disable_symbol_input(self): self.editor.disable_symbol_input() self.assertEqual(self.get_unfiltered_symbol_input_text(), SET_KEY_FIRST) self.assertFalse(self.editor.get_text_input().get_sensitive()) self.editor.enable_symbol_input() self.assertEqual(self.get_unfiltered_symbol_input_text(), "") self.assertTrue(self.editor.get_text_input().get_sensitive()) # it wouldn't clear user input, if for whatever reason (a bug?) there is user # input in there when enable_symbol_input is called. self.editor.set_symbol_input_text("foo") self.editor.enable_symbol_input() self.assertEqual(self.get_unfiltered_symbol_input_text(), "foo") class TestAutocompletion(GuiTestBase, unittest.TestCase): def press_key(self, keyval): event = Gdk.EventKey() event.keyval = keyval self.editor.autocompletion.navigate(None, event) def test_autocomplete_key(self): self.add_mapping_via_ui(Key(1, 99, 1), "") source_view = self.editor.get_text_input() self.set_focus(source_view) complete_key_name = "Test_Foo_Bar" system_mapping.clear() system_mapping._set(complete_key_name, 1) # it can autocomplete a key inbetween other things incomplete = "qux_1\n + + qux_2" Gtk.TextView.do_insert_at_cursor(source_view, incomplete) Gtk.TextView.do_move_cursor( source_view, Gtk.MovementStep.VISUAL_POSITIONS, -8, False, ) Gtk.TextView.do_insert_at_cursor(source_view, "foo") time.sleep(0.11) gtk_iteration() autocompletion = self.editor.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.editor.get_symbol_input_text().strip() 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(source_view, " + foo ") time.sleep(0.11) gtk_iteration() self.assertFalse(autocompletion.visible) def test_autocomplete_function(self): self.add_mapping_via_ui(Key(1, 99, 1), "") source_view = self.editor.get_text_input() self.set_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.editor.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.editor.get_symbol_input_text().strip() self.assertEqual(modified_symbol, "key(KEY_A).\nrepeat") def test_close_autocompletion(self): self.add_mapping_via_ui(Key(1, 99, 1), "") source_view = self.editor.get_text_input() self.set_focus(source_view) Gtk.TextView.do_insert_at_cursor(source_view, "KEY_") time.sleep(0.11) gtk_iteration() autocompletion = self.editor.autocompletion self.assertTrue(autocompletion.visible) self.press_key(Gdk.KEY_Down) self.press_key(Gdk.KEY_Escape) self.assertFalse(autocompletion.visible) symbol = self.editor.get_symbol_input_text().strip() self.assertEqual(symbol, "KEY_") def test_writing_still_works(self): self.add_mapping_via_ui(Key(1, 99, 1), "") source_view = self.editor.get_text_input() self.set_focus(source_view) Gtk.TextView.do_insert_at_cursor(source_view, "KEY_") autocompletion = self.editor.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.add_mapping_via_ui(Key(1, 99, 1), "") source_view = self.editor.get_text_input() self.set_focus(source_view) Gtk.TextView.do_insert_at_cursor(source_view, "KEY_") autocompletion = self.editor.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() input-remapper-1.4.0/tests/test.py000066400000000000000000000512611417303655400171740ustar00rootroot00000000000000#!/usr/bin/python3 # -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings # Copyright (C) 2022 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 . """Sets up inputremapper for the tests and runs them.""" import os import sys import tempfile # the working directory should be the project root assert not os.getcwd().endswith("tests") assert not os.getcwd().endswith("unit") assert not os.getcwd().endswith("integration") # make sure the "tests" module visible sys.path.append(os.getcwd()) if __name__ == "__main__": # import this file to itself to make sure is not run twice and all global variables end up in sys.modules # https://stackoverflow.com/questions/13181559/importing-modules-main-vs-import-as-module import tests.test tests.test.main() import shutil import time import copy import unittest import subprocess import multiprocessing import asyncio import psutil import logging from pickle import UnpicklingError from unittest.mock import patch import evdev import gi gi.require_version("Gtk", "3.0") gi.require_version("GLib", "2.0") gi.require_version("GtkSource", "4") from tests.xmodmap import xmodmap os.environ["UNITTEST"] = "1" 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 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 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 if is_service_running(): # let tests control daemon existance raise Exception("Expected the service not to be running already.") # 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 helper starts receiving previously pushed events after a # call to start_reading START_READING_DELAY = 0.05 # for joysticks MIN_ABS = -(2 ** 15) MAX_ABS = 2 ** 15 # 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 uinput_write_history = [] # for tests that makes the injector create its processes uinput_write_history_pipe = multiprocessing.Pipe() pending_events = {} 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 # 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] fixtures = { "/dev/input/event1": { "capabilities": { evdev.ecodes.EV_KEY: [evdev.ecodes.KEY_A], }, "phys": "usb-0000:03:00.0-0/input1", "info": info_foo, "name": "Foo Device", }, # 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": { "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, evdev.ecodes.REL_HWHEEL, ], }, "phys": f"{phys_foo}/input2", "info": info_foo, "name": "Foo Device foo", "group_key": "Foo Device 2", # expected key }, "/dev/input/event10": { "capabilities": {evdev.ecodes.EV_KEY: keyboard_keys}, "phys": f"{phys_foo}/input3", "info": info_foo, "name": "Foo Device", "group_key": "Foo Device 2", }, "/dev/input/event13": { "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", }, "/dev/input/event14": { "capabilities": {evdev.ecodes.EV_SYN: []}, "phys": f"{phys_foo}/input0", "info": info_foo, "name": "Foo Device qux", "group_key": "Foo Device 2", }, # Bar Device "/dev/input/event20": { "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", }, "/dev/input/event30": { "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.EV_KEY: [evdev.ecodes.BTN_A], }, "phys": "", # this is empty sometimes "info": evdev.device.DeviceInfo(3, 1, 3, 1), "name": "gamepad", }, # device that is completely ignored "/dev/input/event31": { "capabilities": {evdev.ecodes.EV_SYN: []}, "phys": "usb-0000:03:00.0-4/input1", "info": evdev.device.DeviceInfo(4, 1, 4, 1), "name": "Power Button", }, # input-remapper devices are not displayed in the ui, some instance # of input-remapper started injecting apparently. "/dev/input/event40": { "capabilities": {evdev.ecodes.EV_KEY: keyboard_keys}, "phys": "input-remapper/input1", "info": evdev.device.DeviceInfo(5, 1, 5, 1), "name": "input-remapper Bar Device", }, # denylisted "/dev/input/event51": { "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", }, } def setup_pipe(group_key): """Create a pipe that can be used to send events to the helper, which in turn will be sent to the reader """ if pending_events.get(group_key) is None: pending_events[group_key] = multiprocessing.Pipe() # make sure those pipes exist before any process (the helper) gets forked, # so that events can be pushed after the fork. for fixture in fixtures.values(): if "group_key" in fixture: setup_pipe(fixture["group_key"]) def get_events(): """Get all events written by the injector.""" return uinput_write_history def push_event(group_key, event): """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 ---------- group_key : string For example 'Foo Device' event : InputEvent """ setup_pipe(group_key) pending_events[group_key][0].send(event) def push_events(group_key, events): """Push multiple events""" for event in events: push_event(group_key, event) def new_event(type, code, value, timestamp=None, offset=0): """Create a new input_event.""" if timestamp is None: timestamp = time.time() + offset sec = int(timestamp) usec = timestamp % 1 * 1000000 event = evdev.InputEvent(sec, usec, type, code, value) return event def patch_paths(): from inputremapper import paths paths.CONFIG_PATH = tmp 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 path not in fixtures: raise FileNotFoundError() self.path = path fixture = fixtures.get(path, {}) self.phys = fixture.get("phys", "unset") self.info = fixture.get("info", evdev.device.DeviceInfo(None, None, None, None)) self.name = fixture.get("name", "unset") # this property exists only for test purposes and is not part of # the original evdev.InputDevice class self.group_key = fixture.get("group_key", self.name) # ensure a pipe exists to make this object act like # it is reading events from a device setup_pipe(self.group_key) self.fd = pending_events[self.group_key][1].fileno() def push_events(self, events): push_events(self.group_key, events) def fileno(self): """Compatibility to select.select.""" return self.fd def log(self, key, msg): logger.info(f'%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): if pending_events.get(self.group_key) is None: self.log("no events to read", self.group_key) return # consume all of them while pending_events[self.group_key][1].poll(): result = pending_events[self.group_key][1].recv() self.log(result, "async_read_loop") yield result await asyncio.sleep(0.01) # doesn't loop endlessly in order to run tests for the injector in # the main process 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.group_key][1].poll(): event = pending_events[self.group_key][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.group_key][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 pending_events.get(self.group_key) is None: return None if len(pending_events[self.group_key]) == 0: return None time.sleep(EVENT_READ_TIMEOUT) try: event = pending_events[self.group_key][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(fixtures[self.path]["capabilities"]) if absinfo and evdev.ecodes.EV_ABS in result: absinfo_obj = evdev.AbsInfo( value=None, min=MIN_ABS, fuzz=None, flat=None, resolution=None, max=MAX_ABS, ) result[evdev.ecodes.EV_ABS] = [ (stuff, absinfo_obj) for stuff in result[evdev.ecodes.EV_ABS] ] return result uinputs = {} class UInput: def __init__(self, events=None, name="unnamed", *args, **kwargs): self.fd = 0 self.write_count = 0 self.device = InputDevice("justdoit") self.name = name self.events = events self.write_history = [] global uinputs uinputs[name] = self def capabilities(self, *args, **kwargs): return self.events def write(self, type, code, value): self.write_count += 1 event = new_event(type, code, value) uinput_write_history.append(event) uinput_write_history_pipe[1].send(event) self.write_history.append(event) logger.info("%s written", (type, code, value)) def syn(self): pass class InputEvent(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 InputEvent(self.sec, self.usec, self.type, self.code, self.value) def patch_evdev(): def list_devices(): return fixtures.keys() evdev.list_devices = list_devices evdev.InputDevice = InputDevice evdev.UInput = UInput evdev.InputEvent = InputEvent def patch_events(): # improve logging of stuff 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) os.system = system 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) subprocess.check_output = check_output 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() # quickly fake some stuff before any other file gets a chance to import # the original versions patch_paths() patch_evdev() patch_events() patch_os_system() patch_check_output() from inputremapper.logger import update_verbosity update_verbosity(True) from inputremapper.injection.injector import Injector from inputremapper.config import config from inputremapper.gui.reader import reader from inputremapper.groups import groups from inputremapper.system_mapping import system_mapping from inputremapper.gui.custom_mapping import custom_mapping from inputremapper.paths import get_config_path from inputremapper.injection.macros.macro import macro_variables from inputremapper.injection.consumers.keycode_mapper import active_macros, unreleased from inputremapper.injection.global_uinputs import global_uinputs # no need for a high number in tests Injector.regrab_timeout = 0.05 _fixture_copy = copy.deepcopy(fixtures) environ_copy = copy.deepcopy(os.environ) def send_event_to_reader(event): """Act like the helper and send input events to the reader.""" reader._results._unread.append( { "type": "event", "message": (event.sec, event.usec, event.type, event.code, event.value), } ) def quick_cleanup(log=True): """Reset the applications state.""" if log: print("quick cleanup") 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] = None setup_pipe(device) try: reader.terminate() except (BrokenPipeError, OSError): pass 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) config.path = os.path.join(get_config_path(), "config.json") config.clear_config() config._save_config() system_mapping.populate() custom_mapping.empty() custom_mapping.clear_config() custom_mapping.set_has_unsaved_changes(False) 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] for path in list(fixtures.keys()): if path not in _fixture_copy: del fixtures[path] for path in list(_fixture_copy.keys()): fixtures[path] = copy.deepcopy(_fixture_copy[path]) os.environ.update(environ_copy) for device in list(os.environ.keys()): if device not in environ_copy: del os.environ[device] reader.clear() for _, pipe in pending_events.values(): assert not pipe.poll() assert macro_variables.is_alive(1) for uinput in global_uinputs.devices.values(): uinput.write_count = 0 uinput.write_history = [] def cleanup(): """Reset the applications state. Using this is slower, usually quick_cleanup() is sufficient. """ print("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() with patch.object(sys, "argv", ["input-remapper-service"]): global_uinputs.prepare() def spy(obj, name): """Convenient wrapper for patch.object(..., ..., wraps=...).""" return patch.object(obj, name, wraps=obj.__getattribute__(name)) cleanup() def main(): modules = sys.argv[1:] # discoverer is really convenient, but it can't find a specific test # in all of the available tests like unittest.main() does..., # so provide both options. if len(modules) > 0: # for example # `tests/test.py integration.test_gui.TestGui.test_can_start` # or `tests/test.py integration.test_gui integration.test_daemon` testsuite = unittest.defaultTestLoader.loadTestsFromNames(modules) else: # run all tests by default testsuite = unittest.defaultTestLoader.discover(".", pattern="test_*.py") # add a newline to each "qux (foo.bar)..." output before each test, # because the first log will be on the same line otherwise original_start_test = unittest.TextTestResult.startTest def start_test(self, test): original_start_test(self, test) print() unittest.TextTestResult.startTest = start_test result = unittest.TextTestRunner(verbosity=2).run(testsuite) sys.exit(not result.wasSuccessful()) input-remapper-1.4.0/tests/unit/000077500000000000000000000000001417303655400166155ustar00rootroot00000000000000input-remapper-1.4.0/tests/unit/__init__.py000066400000000000000000000000001417303655400207140ustar00rootroot00000000000000input-remapper-1.4.0/tests/unit/test_config.py000066400000000000000000000116201417303655400214730ustar00rootroot00000000000000#!/usr/bin/python3 # -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings # Copyright (C) 2022 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.config import config from inputremapper.paths import touch from tests.test import quick_cleanup, tmp class TestConfig(unittest.TestCase): def tearDown(self): quick_cleanup() self.assertEqual(len(config.iterate_autoload_presets()), 0) def test_get_default(self): config._config = {} self.assertEqual(config.get("gamepad.joystick.non_linearity"), 4) config.set("gamepad.joystick.non_linearity", 3) self.assertEqual(config.get("gamepad.joystick.non_linearity"), 3) def test_basic(self): self.assertEqual(config.get("a"), None) config.set("a", 1) self.assertEqual(config.get("a"), 1) config.remove("a") config.set("a.b", 2) self.assertEqual(config.get("a.b"), 2) self.assertEqual(config._config["a"]["b"], 2) config.remove("a.b") config.set("a.b.c", 3) self.assertEqual(config.get("a.b.c"), 3) self.assertEqual(config._config["a"]["b"]["c"], 3) def test_autoload(self): self.assertEqual(len(config.iterate_autoload_presets()), 0) self.assertFalse(config.is_autoloaded("d1", "a")) self.assertFalse(config.is_autoloaded("d2.foo", "b")) self.assertEqual(config.get(["autoload", "d1"]), None) self.assertEqual(config.get(["autoload", "d2.foo"]), None) config.set_autoload_preset("d1", "a") self.assertEqual(len(config.iterate_autoload_presets()), 1) self.assertTrue(config.is_autoloaded("d1", "a")) self.assertFalse(config.is_autoloaded("d2.foo", "b")) config.set_autoload_preset("d2.foo", "b") self.assertEqual(len(config.iterate_autoload_presets()), 2) self.assertTrue(config.is_autoloaded("d1", "a")) self.assertTrue(config.is_autoloaded("d2.foo", "b")) self.assertEqual(config.get(["autoload", "d1"]), "a") self.assertEqual(config.get("autoload.d1"), "a") self.assertEqual(config.get(["autoload", "d2.foo"]), "b") config.set_autoload_preset("d2.foo", "c") self.assertEqual(len(config.iterate_autoload_presets()), 2) self.assertTrue(config.is_autoloaded("d1", "a")) self.assertFalse(config.is_autoloaded("d2.foo", "b")) self.assertTrue(config.is_autoloaded("d2.foo", "c")) self.assertEqual(config._config["autoload"]["d2.foo"], "c") self.assertListEqual( list(config.iterate_autoload_presets()), [("d1", "a"), ("d2.foo", "c")], ) config.set_autoload_preset("d2.foo", None) self.assertTrue(config.is_autoloaded("d1", "a")) self.assertFalse(config.is_autoloaded("d2.foo", "b")) self.assertFalse(config.is_autoloaded("d2.foo", "c")) self.assertListEqual(list(config.iterate_autoload_presets()), [("d1", "a")]) self.assertEqual(config.get(["autoload", "d1"]), "a") def test_initial(self): # when loading for the first time, create a config file with # the default values os.remove(config.path) self.assertFalse(os.path.exists(config.path)) config.load_config() self.assertTrue(os.path.exists(config.path)) with open(config.path, "r") as file: contents = file.read() self.assertIn('"keystroke_sleep_ms": 10', contents) def test_save_load(self): self.assertEqual(len(config.iterate_autoload_presets()), 0) config.load_config() self.assertEqual(len(config.iterate_autoload_presets()), 0) config.set_autoload_preset("d1", "a") config.set_autoload_preset("d2.foo", "b") config.load_config() self.assertListEqual( list(config.iterate_autoload_presets()), [("d1", "a"), ("d2.foo", "b")], ) config_2 = os.path.join(tmp, "config_2.json") touch(config_2) with open(config_2, "w") as f: f.write('{"a":"b"}') config.load_config(config_2) self.assertEqual(config.get("a"), "b") self.assertEqual(config.get(["a"]), "b") if __name__ == "__main__": unittest.main() input-remapper-1.4.0/tests/unit/test_consumer_control.py000066400000000000000000000202571417303655400236270ustar00rootroot00000000000000#!/usr/bin/python3 # -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings # Copyright (C) 2022 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 asyncio import evdev from evdev.ecodes import EV_KEY, EV_ABS, ABS_Y, EV_REL from inputremapper.injection.consumers.keycode_mapper import active_macros from inputremapper.config import BUTTONS, MOUSE, WHEEL from inputremapper.injection.context import Context from inputremapper.mapping import Mapping from inputremapper.key import Key from inputremapper.injection.consumer_control import ConsumerControl, consumer_classes from inputremapper.injection.consumers.consumer import Consumer from inputremapper.injection.consumers.keycode_mapper import KeycodeMapper from inputremapper.system_mapping import system_mapping from inputremapper.injection.global_uinputs import global_uinputs from tests.test import new_event, quick_cleanup class ExampleConsumer(Consumer): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) def is_enabled(self): return True async def notify(self, event): pass def is_handled(self, event): pass async def run(self): pass class TestConsumerControl(unittest.IsolatedAsyncioTestCase): def setUp(self): consumer_classes.append(ExampleConsumer) self.gamepad_source = evdev.InputDevice("/dev/input/event30") self.mapping = Mapping() def tearDown(self): quick_cleanup() consumer_classes.remove(ExampleConsumer) def setup(self, source, mapping): """Set a a ConsumerControl up for the test and run it in the background.""" forward_to = evdev.UInput() context = Context(mapping) context.uinput = evdev.UInput() consumer_control = ConsumerControl(context, source, forward_to) for consumer in consumer_control._consumers: consumer._abs_range = (-10, 10) asyncio.ensure_future(consumer_control.run()) return context, consumer_control async def test_no_keycode_mapper_needed(self): self.mapping.change(Key(EV_KEY, 1, 1), "keyboard", "b") _, consumer_control = self.setup(self.gamepad_source, self.mapping) consumer_types = [type(consumer) for consumer in consumer_control._consumers] self.assertIn(KeycodeMapper, consumer_types) self.mapping.empty() _, consumer_control = self.setup(self.gamepad_source, self.mapping) consumer_types = [type(consumer) for consumer in consumer_control._consumers] self.assertNotIn(KeycodeMapper, consumer_types) self.mapping.change(Key(EV_KEY, 1, 1), "keyboard", "k(a)") _, consumer_control = self.setup(self.gamepad_source, self.mapping) consumer_types = [type(consumer) for consumer in consumer_control._consumers] self.assertIn(KeycodeMapper, consumer_types) async def test_if_single_joystick_then(self): # Integration test style for if_single. # won't care about the event, because the purpose is not set to BUTTON code_a = system_mapping.get("a") code_shift = system_mapping.get("KEY_LEFTSHIFT") trigger = 1 self.mapping.change( Key(EV_KEY, trigger, 1), "keyboard", "if_single(k(a), k(KEY_LEFTSHIFT))" ) self.mapping.change(Key(EV_ABS, ABS_Y, 1), "keyboard", "b") self.mapping.set("gamepad.joystick.left_purpose", MOUSE) self.mapping.set("gamepad.joystick.right_purpose", WHEEL) context, _ = self.setup(self.gamepad_source, self.mapping) self.gamepad_source.push_events( [ new_event(EV_KEY, trigger, 1), # start the macro new_event(EV_ABS, ABS_Y, 10), # ignored new_event(EV_KEY, 2, 2), # ignored new_event(EV_KEY, 2, 0), # ignored new_event(EV_REL, 1, 1), # ignored new_event( EV_KEY, trigger, 0 ), # stop it, the only way to trigger `then` ] ) await asyncio.sleep(0.1) self.assertFalse(active_macros[(EV_KEY, 1)].running) history = [a.t for a in 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) async def test_if_single_joystick_else(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 code_b = system_mapping.get("b") code_shift = system_mapping.get("KEY_LEFTSHIFT") trigger = 1 self.mapping.change( Key(EV_KEY, trigger, 1), "keyboard", "if_single(k(a), k(KEY_LEFTSHIFT))" ) self.mapping.change(Key(EV_ABS, ABS_Y, 1), "keyboard", "b") self.mapping.set("gamepad.joystick.left_purpose", BUTTONS) self.mapping.set("gamepad.joystick.right_purpose", BUTTONS) context, _ = self.setup(self.gamepad_source, self.mapping) self.gamepad_source.push_events( [ new_event(EV_KEY, trigger, 1), # start the macro new_event(EV_ABS, ABS_Y, 10), # not ignored, stops it ] ) await asyncio.sleep(0.1) self.assertFalse(active_macros[(EV_KEY, 1)].running) history = [a.t for a in 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_shift, 1), (EV_KEY, code_b, 1), # would be capitalized now (EV_KEY, code_shift, 0), ], ) async def test_if_single_joystick_under_threshold(self): """triggers then because the joystick events value is too low.""" code_a = system_mapping.get("a") trigger = 1 self.mapping.change( Key(EV_KEY, trigger, 1), "keyboard", "if_single(k(a), k(KEY_LEFTSHIFT))" ) self.mapping.change(Key(EV_ABS, ABS_Y, 1), "keyboard", "b") self.mapping.set("gamepad.joystick.left_purpose", BUTTONS) self.mapping.set("gamepad.joystick.right_purpose", BUTTONS) context, _ = self.setup(self.gamepad_source, self.mapping) self.gamepad_source.push_events( [ new_event(EV_KEY, trigger, 1), # start the macro new_event(EV_ABS, ABS_Y, 1), # ignored because value too low new_event(EV_KEY, trigger, 0), # stop, only way to trigger `then` ] ) await asyncio.sleep(0.1) self.assertFalse(active_macros[(EV_KEY, 1)].running) history = [a.t for a in 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), ], ) input-remapper-1.4.0/tests/unit/test_context.py000066400000000000000000000120301417303655400217060ustar00rootroot00000000000000#!/usr/bin/python3 # -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings # Copyright (C) 2022 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.context import Context from inputremapper.mapping import Mapping from inputremapper.key import Key from inputremapper.config import NONE, MOUSE, WHEEL, BUTTONS from inputremapper.system_mapping import system_mapping from tests.test import quick_cleanup class TestContext(unittest.TestCase): @classmethod def setUpClass(cls): quick_cleanup() def setUp(self): self.mapping = Mapping() self.mapping.set("gamepad.joystick.left_purpose", WHEEL) self.mapping.set("gamepad.joystick.right_purpose", WHEEL) self.mapping.change(Key(1, 31, 1), "keyboard", "k(a)") self.mapping.change(Key(1, 32, 1), "keyboard", "b") self.mapping.change(Key((1, 33, 1), (1, 34, 1), (1, 35, 1)), "keyboard", "c") self.context = Context(self.mapping) def test_update_purposes(self): self.mapping.set("gamepad.joystick.left_purpose", BUTTONS) self.mapping.set("gamepad.joystick.right_purpose", MOUSE) self.context.update_purposes() self.assertEqual(self.context.left_purpose, BUTTONS) self.assertEqual(self.context.right_purpose, MOUSE) def test_parse_macros(self): self.assertEqual(len(self.context.macros), 1) self.assertEqual(self.context.macros[((1, 31, 1),)][1], "keyboard") self.assertEqual(self.context.macros[((1, 31, 1),)][0].code, "k(a)") def test_map_keys_to_codes(self): b = system_mapping.get("b") c = system_mapping.get("c") self.assertEqual(len(self.context.key_to_code), 3) self.assertEqual(self.context.key_to_code[((1, 32, 1),)], (b, "keyboard")) self.assertEqual( self.context.key_to_code[(1, 33, 1), (1, 34, 1), (1, 35, 1)], (c, "keyboard"), ) self.assertEqual( self.context.key_to_code[(1, 34, 1), (1, 33, 1), (1, 35, 1)], (c, "keyboard"), ) def test_is_mapped(self): self.assertTrue(self.context.is_mapped(((1, 32, 1),))) self.assertTrue(self.context.is_mapped(((1, 33, 1), (1, 34, 1), (1, 35, 1)))) self.assertTrue(self.context.is_mapped(((1, 34, 1), (1, 33, 1), (1, 35, 1)))) self.assertFalse(self.context.is_mapped(((1, 34, 1), (1, 35, 1), (1, 33, 1)))) self.assertFalse(self.context.is_mapped(((1, 36, 1),))) def test_maps_joystick(self): self.assertTrue(self.context.maps_joystick()) self.mapping.set("gamepad.joystick.left_purpose", NONE) self.mapping.set("gamepad.joystick.right_purpose", NONE) self.context.update_purposes() self.assertFalse(self.context.maps_joystick()) def test_joystick_as_dpad(self): self.assertTrue(self.context.maps_joystick()) self.mapping.set("gamepad.joystick.left_purpose", WHEEL) self.mapping.set("gamepad.joystick.right_purpose", MOUSE) self.context.update_purposes() self.assertFalse(self.context.joystick_as_dpad()) self.mapping.set("gamepad.joystick.left_purpose", BUTTONS) self.mapping.set("gamepad.joystick.right_purpose", NONE) self.context.update_purposes() self.assertTrue(self.context.joystick_as_dpad()) self.mapping.set("gamepad.joystick.left_purpose", MOUSE) self.mapping.set("gamepad.joystick.right_purpose", BUTTONS) self.context.update_purposes() self.assertTrue(self.context.joystick_as_dpad()) def test_joystick_as_mouse(self): self.assertTrue(self.context.maps_joystick()) self.mapping.set("gamepad.joystick.right_purpose", MOUSE) self.context.update_purposes() self.assertTrue(self.context.joystick_as_mouse()) self.mapping.set("gamepad.joystick.left_purpose", NONE) self.mapping.set("gamepad.joystick.right_purpose", NONE) self.context.update_purposes() self.assertFalse(self.context.joystick_as_mouse()) self.mapping.set("gamepad.joystick.right_purpose", BUTTONS) self.context.update_purposes() self.assertFalse(self.context.joystick_as_mouse()) def test_writes_keys(self): self.assertTrue(self.context.writes_keys()) self.assertFalse(Context(Mapping()).writes_keys()) if __name__ == "__main__": unittest.main() input-remapper-1.4.0/tests/unit/test_control.py000066400000000000000000000271271417303655400217170ustar00rootroot00000000000000#!/usr/bin/python3 # -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings # Copyright (C) 2022 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 os import time import unittest from unittest import mock import collections from importlib.util import spec_from_loader, module_from_spec from importlib.machinery import SourceFileLoader from inputremapper.gui.custom_mapping import custom_mapping from inputremapper.config import config from inputremapper.daemon import Daemon from inputremapper.mapping import Mapping from inputremapper.paths import get_preset_path from inputremapper.groups import groups from tests.test import quick_cleanup, tmp def import_control(): """Import the core function of the input-remapper-control command.""" custom_mapping.empty() bin_path = os.path.join(os.getcwd(), "bin", "input-remapper-control") loader = SourceFileLoader("__not_main_idk__", bin_path) spec = spec_from_loader("__not_main_idk__", loader) module = module_from_spec(spec) spec.loader.exec_module(module) return module.communicate, module.utils, module.internals communicate, utils, internals = import_control() options = collections.namedtuple( "options", ["command", "config_dir", "preset", "device", "list_devices", "key_names", "debug"], ) class TestControl(unittest.TestCase): def tearDown(self): quick_cleanup() 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 = [ get_preset_path(groups_[0].name, presets[0]), get_preset_path(groups_[1].name, presets[1]), get_preset_path(groups_[1].name, presets[2]), ] Mapping().save(paths[0]) Mapping().save(paths[1]) Mapping().save(paths[2]) 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, preset): print(f'\033[90mstart_injecting "{device}" "{preset}"\033[0m') start_history.append((device, preset)) daemon.injectors[device] = Injector() daemon.start_injecting = start_injecting config.set_autoload_preset(groups_[0].key, presets[0]) config.set_autoload_preset(groups_[1].key, presets[1]) communicate(options("autoload", None, None, None, False, False, False), daemon) 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 communicate(options("autoload", None, None, None, False, False, False), daemon) 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 communicate( options("stop", None, None, groups_[0].key, False, False, False), daemon ) 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]) ) communicate(options("autoload", None, None, None, False, False, False), daemon) 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 communicate(options("stop-all", None, None, None, False, False, False), daemon) 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) config.set_autoload_preset(groups_[1].key, presets[2]) communicate( options("autoload", None, None, groups_[1].key, False, False, False), daemon ) 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 communicate( options("autoload", None, None, groups_[1].key, False, False, False), daemon ) 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]) ) 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"), ] Mapping().save(paths[0]) Mapping().save(paths[1]) daemon = Daemon() start_history = [] daemon.start_injecting = lambda *args: start_history.append(args) config.path = os.path.join(config_dir, "config.json") config.load_config() config.set_autoload_preset(device_names[0], presets[0]) config.set_autoload_preset(device_names[1], presets[1]) communicate( options("autoload", config_dir, None, None, False, False, False), daemon, ) 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() 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) communicate( options("start", None, preset, group.paths[0], False, False, False), daemon, ) self.assertEqual(len(start_history), 1) self.assertEqual(start_history[0], (group.key, preset)) communicate( options("stop", None, None, group.paths[1], False, False, False), daemon, ) 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,)) communicate(options("stop-all", None, None, None, False, False, False), daemon) self.assertEqual(len(stop_all_history), 1) self.assertEqual(stop_all_history[0], ()) def test_config_not_found(self): key = "Foo Device 2" path = "~/a/preset.json" config_dir = "/foo/bar" daemon = Daemon() start_history = [] stop_history = [] daemon.start_injecting = lambda *args: start_history.append(args) daemon.stop_injecting = lambda *args: stop_history.append(args) options_1 = options("start", config_dir, path, key, False, False, False) self.assertRaises(SystemExit, lambda: communicate(options_1, daemon)) options_2 = options("stop", config_dir, None, key, False, False, False) self.assertRaises(SystemExit, lambda: communicate(options_2, daemon)) def test_autoload_config_dir(self): daemon = Daemon() path = os.path.join(tmp, "foo") os.makedirs(path) with open(os.path.join(path, "config.json"), "w") as file: file.write('{"foo":"bar"}') self.assertIsNone(config.get("foo")) daemon.set_config_dir(path) # since daemon and this test share the same memory, the config # object that this test can access will be modified self.assertEqual(config.get("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(config.get("foo"), "bar") daemon.set_config_dir(os.path.join(tmp, "qux")) self.assertEqual(config.get("foo"), "bar") def test_internals(self): with mock.patch("os.system") as os_system_patch: internals(options("helper", None, None, None, False, False, False)) os_system_patch.assert_called_once() self.assertIn("input-remapper-helper", os_system_patch.call_args.args[0]) self.assertNotIn("-d", os_system_patch.call_args.args[0]) with mock.patch("os.system") as os_system_patch: internals(options("start-daemon", None, None, None, False, False, 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() input-remapper-1.4.0/tests/unit/test_daemon.py000066400000000000000000000406601417303655400214770ustar00rootroot00000000000000#!/usr/bin/python3 # -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings # Copyright (C) 2022 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 import time import subprocess import json import evdev from evdev.ecodes import EV_KEY, EV_ABS, KEY_B, KEY_A from gi.repository import Gtk from pydbus import SystemBus from inputremapper.system_mapping import system_mapping from inputremapper.gui.custom_mapping import custom_mapping from inputremapper.config import config from inputremapper.groups import groups from inputremapper.paths import get_config_path, mkdir, get_preset_path from inputremapper.key import Key from inputremapper.mapping import Mapping from inputremapper.injection.injector import STARTING, RUNNING, STOPPED, UNKNOWN from inputremapper.daemon import Daemon from tests.test import ( cleanup, uinput_write_history_pipe, new_event, push_events, is_service_running, fixtures, tmp, ) def gtk_iteration(): """Iterate while events are pending.""" while Gtk.events_pending(): Gtk.main_iteration() check_output = subprocess.check_output os_system = os.system dbus_get = type(SystemBus()).get class TestDaemon(unittest.TestCase): new_fixture_path = "/dev/input/event9876" def setUp(self): self.grab = evdev.InputDevice.grab self.daemon = None mkdir(get_config_path()) config._save_config() 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 evdev.InputDevice.grab = self.grab subprocess.check_output = check_output os.system = os_system type(SystemBus()).get = dbus_get cleanup() def test_connect(self): os_system_history = [] os.system = os_system_history.append 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) self.assertEqual(len(os_system_history), 1) 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, *args, **kwargs): nonlocal set_config_dir_callcount set_config_dir_callcount += 1 type(SystemBus()).get = lambda *args, **kwargs: 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(get_config_path("xmodmap.json")): os.remove(get_config_path("xmodmap.json")) ev_1 = (EV_KEY, 9) ev_2 = (EV_ABS, 12) group = groups.find(name="Bar Device") # unrelated group that shouldn't be affected at all group2 = groups.find(name="gamepad") custom_mapping.change(Key(*ev_1, 1), "keyboard", "a") custom_mapping.change(Key(*ev_2, -1), "keyboard", "b") preset = "foo" custom_mapping.save(group.get_preset_path(preset)) config.set_autoload_preset(group.key, preset) """injection 1""" # should forward the event unchanged push_events(group.key, [new_event(EV_KEY, 13, 1)]) self.daemon = Daemon() self.assertFalse(uinput_write_history_pipe[0].poll()) self.daemon.start_injecting(group.key, preset) self.assertEqual(self.daemon.get_state(group.key), STARTING) self.assertEqual(self.daemon.get_state(group2.key), UNKNOWN) event = uinput_write_history_pipe[0].recv() self.assertEqual(self.daemon.get_state(group.key), RUNNING) self.assertEqual(event.type, EV_KEY) self.assertEqual(event.code, 13) self.assertEqual(event.value, 1) self.daemon.stop_injecting(group.key) self.assertEqual(self.daemon.get_state(group.key), STOPPED) time.sleep(0.1) 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""" # -1234 will be classified as -1 by the injector push_events(group.key, [new_event(*ev_2, -1234)]) self.daemon.start_injecting(group.key, preset) 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_config_dir(self): config.set("foo", "bar") self.assertEqual(config.get("foo"), "bar") # freshly loads the config and therefore removes the previosly added key. # This is important so that if the service is started via sudo or pkexec # it knows where to look for configuration files. self.daemon = Daemon() self.assertEqual(self.daemon.config_dir, get_config_path()) self.assertIsNone(config.get("foo")) def test_refresh_on_start(self): if os.path.exists(get_config_path("xmodmap.json")): os.remove(get_config_path("xmodmap.json")) ev = (EV_KEY, 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) custom_mapping.change(Key(*ev, 1), "keyboard", "a") system_mapping.clear() system_mapping._set("a", KEY_A) # make the daemon load the file instead with open(get_config_path("xmodmap.json"), "w") as file: json.dump(system_mapping._mapping, file, indent=4) system_mapping.clear() preset = "foo" custom_mapping.save(get_preset_path(group_name, preset)) config.set_autoload_preset(group_key, preset) push_events(group_key, [new_event(*ev, 1)]) self.daemon = Daemon() # make sure the devices are populated groups.refresh() # the daemon is supposed to find this device by calling refresh fixtures[self.new_fixture_path] = { "capabilities": {evdev.ecodes.EV_KEY: [ev[1]]}, "phys": "9876 phys", "info": evdev.device.DeviceInfo(4, 5, 6, 7), "name": group_name, } self.daemon.start_injecting(group_key, preset) # 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.t, (EV_KEY, KEY_A, 1)) self.daemon.stop_injecting(group_key) self.assertEqual(self.daemon.get_state(group_key), STOPPED) def test_refresh_for_unknown_key(self): device = "9876 name" # this test only makes sense if this device is unknown yet self.assertIsNone(groups.find(name=device)) self.daemon = Daemon() # make sure the devices are populated groups.refresh() self.daemon.refresh() fixtures[self.new_fixture_path] = { "capabilities": {evdev.ecodes.EV_KEY: [evdev.ecodes.KEY_A]}, "phys": "9876 phys", "info": evdev.device.DeviceInfo(4, 5, 6, 7), "name": device, } 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)) def test_xmodmap_file(self): from_keycode = evdev.ecodes.KEY_A target = "keyboard" to_name = "qux" to_keycode = 100 event = (EV_KEY, from_keycode, 1) name = "Bar Device" preset = "foo" group = groups.find(name=name) config_dir = os.path.join(tmp, "foo") path = os.path.join(config_dir, "presets", name, f"{preset}.json") custom_mapping.change(Key(event), target, to_name) custom_mapping.save(path) system_mapping.clear() push_events(group.key, [new_event(*event)]) # an existing config file is needed otherwise set_config_dir refuses # to use the directory config_path = os.path.join(config_dir, "config.json") config.path = config_path config._save_config() xmodmap_path = os.path.join(config_dir, "xmodmap.json") with open(xmodmap_path, "w") as file: file.write(f'{{"{to_name}":{to_keycode}}}') self.daemon = Daemon() self.daemon.set_config_dir(config_dir) self.daemon.start_injecting(group.key, preset) 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 = groups.find(key="Foo Device 2") preset = "preset8" daemon = Daemon() self.daemon = daemon mapping = Mapping() mapping.change(Key(3, 2, 1), "keyboard", "a") mapping.save(group.get_preset_path(preset)) # start daemon.start_injecting(group.key, preset) # 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)) # 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(), STOPPED) daemon.start_injecting(group.key, preset) self.assertNotIn(group.key, daemon.autoload_history._autoload_history) self.assertTrue(daemon.autoload_history.may_autoload(group.key, preset)) self.assertIn(group.key, daemon.injectors) self.assertEqual(previous_injector.get_state(), STOPPED) # a different injetor is now running self.assertNotEqual(previous_injector, daemon.injectors[group.key]) self.assertNotEqual(daemon.injectors[group.key].get_state(), 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(), 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(), 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)) # stop daemon.stop_injecting(group.key) self.assertNotIn(group.key, daemon.autoload_history._autoload_history) self.assertEqual(daemon.injectors[group.key].get_state(), STOPPED) self.assertTrue(daemon.autoload_history.may_autoload(group.key, preset)) def test_autoload(self): preset = "preset7" group = groups.find(key="Foo Device 2") daemon = Daemon() self.daemon = daemon mapping = Mapping() mapping.change(Key(3, 2, 1), "keyboard", "a") mapping.save(group.get_preset_path(preset)) # 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)) config.set_autoload_preset(group.key, preset) 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 ) self.assertFalse(daemon.autoload_history.may_autoload(group.key, preset)) injector = daemon.injectors[group.key] self.assertEqual(len_before + 1, len_after) # calling duplicate _autoload does nothing self.daemon._autoload(group.key) self.assertEqual( daemon.autoload_history._autoload_history[group.key][1], preset ) self.assertEqual(injector, daemon.injectors[group.key]) self.assertFalse(daemon.autoload_history.may_autoload(group.key, preset)) # explicit start_injecting clears the autoload history self.daemon.start_injecting(group.key, preset) self.assertTrue(daemon.autoload_history.may_autoload(group.key, preset)) # 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() history = self.daemon.autoload_history._autoload_history # existing device preset = "preset7" group = groups.find(key="Foo Device 2") mapping = Mapping() mapping.change(Key(3, 2, 1), "keyboard", "a") mapping.save(group.get_preset_path(preset)) config.set_autoload_preset(group.key, preset) # ignored, won't cause problems: config.set_autoload_preset("non-existant-key", "foo") self.daemon.autoload() self.assertEqual(len(history), 1) self.assertEqual(history[group.key][1], preset) def test_autoload_3(self): # based on a bug preset = "preset7" group = groups.find(key="Foo Device 2") mapping = Mapping() mapping.change(Key(3, 2, 1), "keyboard", "a") mapping.save(group.get_preset_path(preset)) config.set_autoload_preset(group.key, preset) self.daemon = Daemon() 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) self.assertEqual(self.daemon.get_state(group.key), STARTING) self.assertIsNotNone(groups.find(key="Foo Device 2")) if __name__ == "__main__": unittest.main() input-remapper-1.4.0/tests/unit/test_data.py000066400000000000000000000032371417303655400211440ustar00rootroot00000000000000#!/usr/bin/python3 # -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings # Copyright (C) 2022 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 os import pkg_resources from inputremapper.data import get_data_path class TestData(unittest.TestCase): def test_data_editable(self): path = os.getcwd() pkg_resources.require("input-remapper")[0].location = path self.assertEqual(get_data_path(), path + "/data/") self.assertEqual(get_data_path("a"), path + "/data/a") def test_data_usr(self): path = "/usr/some/where/python3.8/dist-packages/" pkg_resources.require("input-remapper")[0].location = path self.assertTrue(get_data_path().startswith("/usr/")) self.assertTrue(get_data_path().endswith("input-remapper/")) self.assertTrue(get_data_path("a").startswith("/usr/")) self.assertTrue(get_data_path("a").endswith("input-remapper/a")) if __name__ == "__main__": unittest.main() input-remapper-1.4.0/tests/unit/test_dev_utils.py000066400000000000000000000176141417303655400222350ustar00rootroot00000000000000#!/usr/bin/python3 # -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings # Copyright (C) 2022 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 import ecodes from evdev.ecodes import ( EV_KEY, EV_ABS, ABS_HAT0X, KEY_A, EV_REL, REL_X, REL_WHEEL, REL_HWHEEL, ) from inputremapper.config import config, BUTTONS from inputremapper.mapping import Mapping from inputremapper import utils from tests.test import new_event, InputDevice, MAX_ABS, MIN_ABS class TestDevUtils(unittest.TestCase): def test_max_abs(self): self.assertEqual( utils.get_abs_range(InputDevice("/dev/input/event30"))[1], MAX_ABS ) self.assertIsNone(utils.get_abs_range(InputDevice("/dev/input/event10"))) def test_will_report_key_up(self): self.assertFalse(utils.will_report_key_up(new_event(EV_REL, REL_WHEEL, 1))) self.assertFalse(utils.will_report_key_up(new_event(EV_REL, REL_HWHEEL, -1))) self.assertTrue(utils.will_report_key_up(new_event(EV_KEY, KEY_A, 1))) self.assertTrue(utils.will_report_key_up(new_event(EV_ABS, ABS_HAT0X, -1))) def test_is_wheel(self): self.assertTrue(utils.is_wheel(new_event(EV_REL, REL_WHEEL, 1))) self.assertTrue(utils.is_wheel(new_event(EV_REL, REL_HWHEEL, -1))) self.assertFalse(utils.is_wheel(new_event(EV_KEY, KEY_A, 1))) self.assertFalse(utils.is_wheel(new_event(EV_ABS, ABS_HAT0X, -1))) def test_should_map_as_btn(self): mapping = Mapping() def do(gamepad, event): return utils.should_map_as_btn(event, mapping, gamepad) """D-Pad""" self.assertTrue(do(1, new_event(EV_ABS, ABS_HAT0X, 1))) self.assertTrue(do(0, new_event(EV_ABS, ABS_HAT0X, -1))) """Mouse movements""" self.assertTrue(do(1, new_event(EV_REL, REL_WHEEL, 1))) self.assertTrue(do(0, new_event(EV_REL, REL_WHEEL, -1))) self.assertTrue(do(1, new_event(EV_REL, REL_HWHEEL, 1))) self.assertTrue(do(0, new_event(EV_REL, REL_HWHEEL, -1))) self.assertFalse(do(1, new_event(EV_REL, REL_X, -1))) """regular keys and buttons""" self.assertTrue(do(1, new_event(EV_KEY, KEY_A, 1))) self.assertTrue(do(0, new_event(EV_KEY, KEY_A, 1))) self.assertTrue(do(1, new_event(EV_ABS, ABS_HAT0X, -1))) self.assertTrue(do(0, new_event(EV_ABS, ABS_HAT0X, -1))) """mousepad events""" self.assertFalse(do(1, new_event(EV_ABS, ecodes.ABS_MT_SLOT, 1))) self.assertFalse(do(0, new_event(EV_ABS, ecodes.ABS_MT_SLOT, 1))) self.assertFalse(do(1, new_event(EV_ABS, ecodes.ABS_MT_TOOL_Y, 1))) self.assertFalse(do(0, new_event(EV_ABS, ecodes.ABS_MT_TOOL_Y, 1))) self.assertFalse(do(1, new_event(EV_ABS, ecodes.ABS_MT_POSITION_X, 1))) self.assertFalse(do(0, new_event(EV_ABS, ecodes.ABS_MT_POSITION_X, 1))) self.assertFalse(do(1, new_event(EV_KEY, ecodes.BTN_TOUCH, 1))) self.assertFalse(do(0, new_event(EV_KEY, ecodes.BTN_TOUCH, 1))) """stylus movements""" self.assertFalse(do(0, new_event(EV_KEY, ecodes.BTN_DIGI, 1))) self.assertFalse(do(1, new_event(EV_KEY, ecodes.BTN_DIGI, 1))) self.assertFalse(do(0, new_event(EV_ABS, ecodes.ABS_TILT_X, 1))) self.assertFalse(do(1, new_event(EV_ABS, ecodes.ABS_TILT_X, 1))) self.assertFalse(do(0, new_event(EV_ABS, ecodes.ABS_TILT_Y, 1))) self.assertFalse(do(1, new_event(EV_ABS, ecodes.ABS_TILT_Y, 1))) self.assertFalse(do(0, new_event(EV_ABS, ecodes.ABS_DISTANCE, 1))) self.assertFalse(do(1, new_event(EV_ABS, ecodes.ABS_DISTANCE, 1))) self.assertFalse(do(0, new_event(EV_ABS, ecodes.ABS_PRESSURE, 1))) self.assertFalse(do(1, new_event(EV_ABS, ecodes.ABS_PRESSURE, 1))) """joysticks""" # without a purpose of BUTTONS it won't map any button, even for # gamepads self.assertFalse(do(0, new_event(EV_ABS, ecodes.ABS_RX, 1234))) self.assertFalse(do(1, new_event(EV_ABS, ecodes.ABS_RX, 1234))) self.assertFalse(do(0, new_event(EV_ABS, ecodes.ABS_Y, -1))) self.assertFalse(do(1, new_event(EV_ABS, ecodes.ABS_Y, -1))) self.assertFalse(do(0, new_event(EV_ABS, ecodes.ABS_RY, -1))) self.assertFalse(do(1, new_event(EV_ABS, ecodes.ABS_RY, -1))) mapping.set("gamepad.joystick.right_purpose", BUTTONS) config.set("gamepad.joystick.left_purpose", BUTTONS) # but only for gamepads self.assertFalse(do(0, new_event(EV_ABS, ecodes.ABS_Y, -1))) self.assertTrue(do(1, new_event(EV_ABS, ecodes.ABS_Y, -1))) self.assertFalse(do(0, new_event(EV_ABS, ecodes.ABS_RY, -1))) self.assertTrue(do(1, new_event(EV_ABS, ecodes.ABS_RY, -1))) """weird events""" self.assertFalse(do(0, new_event(EV_ABS, ecodes.ABS_MISC, -1))) self.assertFalse(do(1, new_event(EV_ABS, ecodes.ABS_MISC, -1))) def test_classify_action(self): """""" """0 to MAX_ABS""" def do(event): return utils.classify_action(event, (0, MAX_ABS)) event = new_event(EV_ABS, ecodes.ABS_RX, MAX_ABS) self.assertEqual(do(event), 1) event = new_event(EV_ABS, ecodes.ABS_Y, MAX_ABS) self.assertEqual(do(event), 1) event = new_event(EV_ABS, ecodes.ABS_Y, 0) self.assertEqual(do(event), -1) event = new_event(EV_ABS, ecodes.ABS_X, MAX_ABS // 4) self.assertEqual(do(event), -1) event = new_event(EV_ABS, ecodes.ABS_X, MAX_ABS // 2) self.assertEqual(do(event), 0) """MIN_ABS to MAX_ABS""" def do2(event): return utils.classify_action(event, (MIN_ABS, MAX_ABS)) event = new_event(EV_ABS, ecodes.ABS_RX, MAX_ABS) self.assertEqual(do2(event), 1) event = new_event(EV_ABS, ecodes.ABS_Y, MIN_ABS) self.assertEqual(do2(event), -1) event = new_event(EV_ABS, ecodes.ABS_X, MIN_ABS // 4) self.assertEqual(do2(event), 0) event = new_event(EV_ABS, ecodes.ABS_RX, MAX_ABS) self.assertEqual(do2(event), 1) event = new_event(EV_ABS, ecodes.ABS_Y, MAX_ABS) self.assertEqual(do2(event), 1) event = new_event(EV_ABS, ecodes.ABS_X, MAX_ABS // 4) self.assertEqual(do2(event), 0) """None""" # it just forwards the value event = new_event(EV_ABS, ecodes.ABS_RX, MAX_ABS) self.assertEqual(utils.classify_action(event, None), MAX_ABS) """Not a joystick""" event = new_event(EV_ABS, ecodes.ABS_Z, 1234) self.assertEqual(do(event), 1) self.assertEqual(do2(event), 1) event = new_event(EV_ABS, ecodes.ABS_Z, 0) self.assertEqual(do(event), 0) self.assertEqual(do2(event), 0) event = new_event(EV_ABS, ecodes.ABS_Z, -1234) self.assertEqual(do(event), -1) self.assertEqual(do2(event), -1) event = new_event(EV_KEY, ecodes.KEY_A, 1) self.assertEqual(do(event), 1) self.assertEqual(do2(event), 1) event = new_event(EV_ABS, ecodes.ABS_HAT0X, 0) self.assertEqual(do(event), 0) self.assertEqual(do2(event), 0) event = new_event(EV_ABS, ecodes.ABS_HAT0X, -1) self.assertEqual(do(event), -1) self.assertEqual(do2(event), -1) input-remapper-1.4.0/tests/unit/test_event_producer.py000066400000000000000000000201501417303655400232500ustar00rootroot00000000000000#!/usr/bin/python3 # -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings # Copyright (C) 2022 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 asyncio from evdev.ecodes import ( EV_REL, REL_X, REL_Y, REL_WHEEL, REL_HWHEEL, EV_ABS, ABS_X, ABS_Y, ABS_RX, ABS_RY, ) from inputremapper.config import config from inputremapper.mapping import Mapping from inputremapper.injection.context import Context from inputremapper.injection.consumers.joystick_to_mouse import ( JoystickToMouse, MOUSE, WHEEL, ) from tests.test import ( InputDevice, UInput, MAX_ABS, clear_write_history, uinput_write_history, quick_cleanup, new_event, MIN_ABS, ) abs_state = [0, 0, 0, 0] class TestJoystickToMouse(unittest.IsolatedAsyncioTestCase): def setUp(self): loop = asyncio.new_event_loop() asyncio.set_event_loop(loop) self.mapping = Mapping() self.context = Context(self.mapping) uinput = UInput() self.context.uinput = uinput source = InputDevice("/dev/input/event30") self.joystick_to_mouse = JoystickToMouse(self.context, source) config.set("gamepad.joystick.x_scroll_speed", 1) config.set("gamepad.joystick.y_scroll_speed", 1) def tearDown(self): quick_cleanup() def assertClose(self, a, b, within): """a has to be within b - b * within, b + b * within.""" self.assertLess(a - abs(a) * within, b) self.assertGreater(a + abs(a) * within, b) async def test_assertClose(self): self.assertClose(5, 5, 0.1) self.assertClose(5, 5, 1) self.assertClose(6, 5, 0.2) self.assertClose(4, 5, 0.3) self.assertRaises(AssertionError, lambda: self.assertClose(6, 5, 0.1)) self.assertRaises(AssertionError, lambda: self.assertClose(4, 5, 0.1)) self.assertClose(-5, -5, 0.1) self.assertClose(-5, -5, 1) self.assertClose(-6, -5, 0.2) self.assertClose(-4, -5, 0.3) self.assertRaises(AssertionError, lambda: self.assertClose(-6, -5, 0.1)) self.assertRaises(AssertionError, lambda: self.assertClose(-4, -5, 0.1)) async def do(self, a, b, c, d, expectation): """Present fake values to the loop and observe the outcome. Depending on the configuration, the cursor or wheel should move. """ clear_write_history() self.joystick_to_mouse.context.update_purposes() await self.joystick_to_mouse.notify(new_event(EV_ABS, ABS_X, a)) await self.joystick_to_mouse.notify(new_event(EV_ABS, ABS_Y, b)) await self.joystick_to_mouse.notify(new_event(EV_ABS, ABS_RX, c)) await self.joystick_to_mouse.notify(new_event(EV_ABS, ABS_RY, d)) # sleep long enough to test if multiple events are written await asyncio.sleep(5 / 60) history = [h.t for h in uinput_write_history] self.assertGreater(len(history), 1) self.assertIn(expectation, history) for history_entry in history: self.assertEqual(history_entry[:2], expectation[:2]) # if the injected cursor movement is 19 or 20 doesn't really matter self.assertClose(history_entry[2], expectation[2], 0.1) async def test_joystick_purpose_1(self): asyncio.ensure_future(self.joystick_to_mouse.run()) speed = 20 self.mapping.set("gamepad.joystick.non_linearity", 1) self.mapping.set("gamepad.joystick.pointer_speed", speed) self.mapping.set("gamepad.joystick.left_purpose", MOUSE) self.mapping.set("gamepad.joystick.right_purpose", WHEEL) min_abs = 0 # if `rest` is not exactly `max_abs / 2` decimal places might add up # and cause higher or lower values to be written after a few events, # which might be difficult to test. max_abs = 256 rest = 128 # resting position of the cursor self.joystick_to_mouse.set_abs_range(min_abs, max_abs) await self.do(max_abs, rest, rest, rest, (EV_REL, REL_X, speed)) await self.do(min_abs, rest, rest, rest, (EV_REL, REL_X, -speed)) await self.do(rest, max_abs, rest, rest, (EV_REL, REL_Y, speed)) await self.do(rest, min_abs, rest, rest, (EV_REL, REL_Y, -speed)) # vertical wheel event values are negative await self.do(rest, rest, max_abs, rest, (EV_REL, REL_HWHEEL, 1)) await self.do(rest, rest, min_abs, rest, (EV_REL, REL_HWHEEL, -1)) await self.do(rest, rest, rest, max_abs, (EV_REL, REL_WHEEL, -1)) await self.do(rest, rest, rest, min_abs, (EV_REL, REL_WHEEL, 1)) async def test_joystick_purpose_2(self): asyncio.ensure_future(self.joystick_to_mouse.run()) speed = 30 config.set("gamepad.joystick.non_linearity", 1) config.set("gamepad.joystick.pointer_speed", speed) config.set("gamepad.joystick.left_purpose", WHEEL) config.set("gamepad.joystick.right_purpose", MOUSE) config.set("gamepad.joystick.x_scroll_speed", 1) config.set("gamepad.joystick.y_scroll_speed", 2) # vertical wheel event values are negative await self.do(MAX_ABS, 0, 0, 0, (EV_REL, REL_HWHEEL, 1)) await self.do(MIN_ABS, 0, 0, 0, (EV_REL, REL_HWHEEL, -1)) await self.do(0, MAX_ABS, 0, 0, (EV_REL, REL_WHEEL, -2)) await self.do(0, MIN_ABS, 0, 0, (EV_REL, REL_WHEEL, 2)) await self.do(0, 0, MAX_ABS, 0, (EV_REL, REL_X, speed)) await self.do(0, 0, MIN_ABS, 0, (EV_REL, REL_X, -speed)) await self.do(0, 0, 0, MAX_ABS, (EV_REL, REL_Y, speed)) await self.do(0, 0, 0, MIN_ABS, (EV_REL, REL_Y, -speed)) async def test_joystick_purpose_3(self): asyncio.ensure_future(self.joystick_to_mouse.run()) speed = 40 self.mapping.set("gamepad.joystick.non_linearity", 1) config.set("gamepad.joystick.pointer_speed", speed) self.mapping.set("gamepad.joystick.left_purpose", MOUSE) config.set("gamepad.joystick.right_purpose", MOUSE) await self.do(MAX_ABS, 0, 0, 0, (EV_REL, REL_X, speed)) await self.do(MIN_ABS, 0, 0, 0, (EV_REL, REL_X, -speed)) await self.do(0, MAX_ABS, 0, 0, (EV_REL, REL_Y, speed)) await self.do(0, MIN_ABS, 0, 0, (EV_REL, REL_Y, -speed)) await self.do(0, 0, MAX_ABS, 0, (EV_REL, REL_X, speed)) await self.do(0, 0, MIN_ABS, 0, (EV_REL, REL_X, -speed)) await self.do(0, 0, 0, MAX_ABS, (EV_REL, REL_Y, speed)) await self.do(0, 0, 0, MIN_ABS, (EV_REL, REL_Y, -speed)) async def test_joystick_purpose_4(self): asyncio.ensure_future(self.joystick_to_mouse.run()) config.set("gamepad.joystick.left_purpose", WHEEL) config.set("gamepad.joystick.right_purpose", WHEEL) self.mapping.set("gamepad.joystick.x_scroll_speed", 2) self.mapping.set("gamepad.joystick.y_scroll_speed", 3) await self.do(MAX_ABS, 0, 0, 0, (EV_REL, REL_HWHEEL, 2)) await self.do(MIN_ABS, 0, 0, 0, (EV_REL, REL_HWHEEL, -2)) await self.do(0, MAX_ABS, 0, 0, (EV_REL, REL_WHEEL, -3)) await self.do(0, MIN_ABS, 0, 0, (EV_REL, REL_WHEEL, 3)) # vertical wheel event values are negative await self.do(0, 0, MAX_ABS, 0, (EV_REL, REL_HWHEEL, 2)) await self.do(0, 0, MIN_ABS, 0, (EV_REL, REL_HWHEEL, -2)) await self.do(0, 0, 0, MAX_ABS, (EV_REL, REL_WHEEL, -3)) await self.do(0, 0, 0, MIN_ABS, (EV_REL, REL_WHEEL, 3)) if __name__ == "__main__": unittest.main() input-remapper-1.4.0/tests/unit/test_global_uinputs.py000066400000000000000000000054251417303655400232630ustar00rootroot00000000000000#!/usr/bin/python3 # -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings # Copyright (C) 2022 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 sys import unittest import evdev from unittest.mock import patch from evdev.ecodes import ( EV_KEY, EV_ABS, KEY_A, ABS_X, ) from tests.test import cleanup from inputremapper.injection.global_uinputs import ( global_uinputs, FrontendUInput, UInput, GlobalUInputs, ) from inputremapper.exceptions import EventNotHandled, UinputNotAvailable 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) class TestGlobalUinputs(unittest.TestCase): def setUp(self) -> None: cleanup() def test_iter(self): 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 """ ev_1 = (EV_KEY, KEY_A, 1) ev_2 = (EV_ABS, ABS_X, 10) keyboard = global_uinputs.get_uinput("keyboard") global_uinputs.write(ev_1, "keyboard") self.assertEqual(keyboard.write_count, 1) with self.assertRaises(EventNotHandled): global_uinputs.write(ev_2, "keyboard") with self.assertRaises(UinputNotAvailable): global_uinputs.write(ev_1, "foo") def test_creates_frontend_uinputs(self): frontend_uinputs = GlobalUInputs() with patch.object(sys, "argv", ["foo"]): frontend_uinputs.prepare() uinput = frontend_uinputs.get_uinput("keyboard") self.assertIsInstance(uinput, FrontendUInput) input-remapper-1.4.0/tests/unit/test_groups.py000066400000000000000000000225201417303655400215460ustar00rootroot00000000000000#!/usr/bin/python3 # -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings # Copyright (C) 2022 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 import json import evdev from evdev.ecodes import EV_KEY, KEY_A from inputremapper.paths import CONFIG_PATH from inputremapper.groups import ( _FindGroups, groups, classify, GAMEPAD, MOUSE, UNKNOWN, GRAPHICS_TABLET, TOUCHPAD, KEYBOARD, _Group, ) from tests.test import quick_cleanup, fixtures class FakePipe: groups = None def send(self, groups): self.groups = groups class TestGroups(unittest.TestCase): def tearDown(self): quick_cleanup() def test_group(self): group = _Group( paths=["/dev/a", "/dev/b", "/dev/c"], names=["name_bar", "name_a", "name_foo"], types=[MOUSE, KEYBOARD, UNKNOWN], key="key", ) self.assertEqual(group.name, "name_a") self.assertEqual(group.key, "key") self.assertEqual( group.get_preset_path("preset1234"), os.path.join(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 self.assertEqual( groups.dumps(), json.dumps( [ json.dumps( { "paths": [ "/dev/input/event1", ], "names": ["Foo Device"], "types": [KEYBOARD], "key": "Foo Device", } ), json.dumps( { "paths": [ "/dev/input/event11", "/dev/input/event10", "/dev/input/event13", ], "names": ["Foo Device foo", "Foo Device", "Foo Device"], "types": [KEYBOARD, MOUSE], "key": "Foo Device 2", } ), json.dumps( { "paths": ["/dev/input/event20"], "names": ["Bar Device"], "types": [KEYBOARD], "key": "Bar Device", } ), json.dumps( { "paths": ["/dev/input/event30"], "names": ["gamepad"], "types": [GAMEPAD], "key": "gamepad", } ), json.dumps( { "paths": ["/dev/input/event40"], "names": ["input-remapper Bar Device"], "types": [KEYBOARD], "key": "input-remapper Bar Device", } ), ] ), ) groups2 = json.dumps( [group.dumps() for group in groups.filter(include_inputremapper=True)] ) self.assertEqual(pipe.groups, groups2) def test_list_group_names(self): self.assertListEqual( groups.list_group_names(), [ "Foo Device", "Foo Device", "Bar Device", "gamepad", ], ) def test_filter(self): # by default no input-remapper devices are present filtered = groups.filter() keys = [group.key for group in filtered] self.assertIn("Foo Device 2", keys) self.assertNotIn("input-remapper Bar Device", keys) def test_skip_camera(self): fixtures["/foo/bar"] = { "name": "camera", "phys": "abcd1", "info": evdev.DeviceInfo(1, 2, 3, 4), "capabilities": {evdev.ecodes.EV_KEY: [evdev.ecodes.KEY_CAMERA]}, } groups.refresh() self.assertIsNone(groups.find(name="camera")) self.assertIsNotNone(groups.find(name="gamepad")) def test_device_with_only_ev_abs(self): # could be anything, a lot of devices have ABS_X capabilities, # so it is not treated as gamepad joystick and since it also # doesn't have key capabilities, there is nothing to map. fixtures["/foo/bar"] = { "name": "qux", "phys": "abcd2", "info": evdev.DeviceInfo(1, 2, 3, 4), "capabilities": {evdev.ecodes.EV_ABS: [evdev.ecodes.ABS_X]}, } groups.refresh() self.assertIsNotNone(groups.find(name="gamepad")) self.assertIsNone(groups.find(name="qux")) # verify this test even works at all fixtures["/foo/bar"]["capabilities"][EV_KEY] = [KEY_A] groups.refresh() self.assertIsNotNone(groups.find(name="qux")) def test_duplicate_device(self): fixtures["/dev/input/event20"]["name"] = "Foo Device" 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/event20", 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], } ) ), 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], } ) ), MOUSE, ) """keyboard""" self.assertEqual(classify(FakeDevice({EV_KEY: [evdev.ecodes.KEY_A]})), KEYBOARD) """touchpads""" self.assertEqual( classify( FakeDevice( { EV_KEY: [evdev.ecodes.KEY_A], EV_ABS: [evdev.ecodes.ABS_MT_POSITION_X], } ) ), TOUCHPAD, ) """graphics tablets""" self.assertEqual( classify( FakeDevice( { EV_ABS: [evdev.ecodes.ABS_X, evdev.ecodes.ABS_Y], EV_KEY: [evdev.ecodes.BTN_STYLUS], } ) ), GRAPHICS_TABLET, ) """weird combos""" self.assertEqual( classify( FakeDevice( { EV_ABS: [evdev.ecodes.ABS_X, evdev.ecodes.ABS_Y], EV_KEY: [evdev.ecodes.KEY_1], } ) ), UNKNOWN, ) self.assertEqual( classify( FakeDevice({EV_ABS: [evdev.ecodes.ABS_X], EV_KEY: [evdev.ecodes.BTN_A]}) ), UNKNOWN, ) self.assertEqual(classify(FakeDevice({EV_KEY: [evdev.ecodes.BTN_A]})), UNKNOWN) self.assertEqual(classify(FakeDevice({EV_ABS: [evdev.ecodes.ABS_X]})), UNKNOWN) if __name__ == "__main__": unittest.main() input-remapper-1.4.0/tests/unit/test_injector.py000066400000000000000000001025561417303655400220540ustar00rootroot00000000000000#!/usr/bin/python3 # -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings # Copyright (C) 2022 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 import mock import time import copy import evdev from evdev.ecodes import ( EV_REL, EV_KEY, EV_ABS, ABS_HAT0X, BTN_LEFT, KEY_A, REL_X, REL_Y, REL_WHEEL, REL_HWHEEL, BTN_A, ABS_X, ABS_Y, ABS_Z, ABS_RZ, ABS_VOLUME, KEY_B, KEY_C, ) from inputremapper.injection.consumers.joystick_to_mouse import JoystickToMouse from inputremapper.injection.injector import ( Injector, is_in_capabilities, STARTING, RUNNING, STOPPED, NO_GRAB, UNKNOWN, get_udev_name, ) from inputremapper.injection.numlock import is_numlock_on, set_numlock, ensure_numlock from inputremapper.system_mapping import system_mapping, DISABLE_CODE, DISABLE_NAME from inputremapper.gui.custom_mapping import custom_mapping from inputremapper.mapping import Mapping from inputremapper.config import config, NONE, MOUSE, WHEEL, BUTTONS from inputremapper.key import Key from inputremapper.injection.macros.parse import parse from inputremapper.injection.context import Context from inputremapper.groups import groups, classify, GAMEPAD from tests.test import ( new_event, push_events, fixtures, EVENT_READ_TIMEOUT, uinput_write_history_pipe, MAX_ABS, quick_cleanup, read_write_history_pipe, InputDevice, uinputs, keyboard_keys, MIN_ABS, ) 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) class TestInjector(unittest.IsolatedAsyncioTestCase): new_gamepad_path = "/dev/input/event100" @classmethod def setUpClass(cls): cls.injector = None cls.grab = evdev.InputDevice.grab quick_cleanup() def setUp(self): self.failed = 0 self.make_it_fail = 2 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: self.injector.stop_injecting() self.assertEqual(self.injector.get_state(), STOPPED) self.injector = None evdev.InputDevice.grab = self.grab quick_cleanup() def find_joystick_to_mouse(self): # this object became somewhat a pain to retreive return [ consumer for consumer in self.injector._consumer_controls[0]._consumers if isinstance(consumer, JoystickToMouse) ][0] def test_grab(self): # path is from the fixtures path = "/dev/input/event10" custom_mapping.change(Key(EV_KEY, 10, 1), "keyboard", "a") self.injector = Injector(groups.find(key="Foo Device 2"), custom_mapping) # this test needs to pass around all other constraints of # _grab_device self.injector.context = Context(custom_mapping) device = self.injector._grab_device(path) gamepad = classify(device) == GAMEPAD self.assertFalse(gamepad) self.assertEqual(self.failed, 2) # success on the third try self.assertEqual(device.name, fixtures[path]["name"]) def test_fail_grab(self): self.make_it_fail = 999 custom_mapping.change(Key(EV_KEY, 10, 1), "keyboard", "a") self.injector = Injector(groups.find(key="Foo Device 2"), custom_mapping) path = "/dev/input/event10" self.injector.context = Context(custom_mapping) device = self.injector._grab_device(path) self.assertIsNone(device) self.assertGreaterEqual(self.failed, 1) self.assertEqual(self.injector.get_state(), UNKNOWN) self.injector.start() self.assertEqual(self.injector.get_state(), 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(), NO_GRAB) def test_grab_device_1(self): custom_mapping.change(Key(EV_ABS, ABS_HAT0X, 1), "keyboard", "a") self.injector = Injector(groups.find(name="gamepad"), custom_mapping) self.injector.context = Context(custom_mapping) _grab_device = self.injector._grab_device # doesn't have the required capability self.assertIsNone(_grab_device("/dev/input/event10")) # according to the fixtures, /dev/input/event30 can do ABS_HAT0X self.assertIsNotNone(_grab_device("/dev/input/event30")) # this doesn't exist self.assertIsNone(_grab_device("/dev/input/event1234")) def test_gamepad_purpose_none(self): # forward abs joystick events custom_mapping.set("gamepad.joystick.left_purpose", NONE) config.set("gamepad.joystick.right_purpose", NONE) self.injector = Injector(groups.find(name="gamepad"), custom_mapping) self.injector.context = Context(custom_mapping) path = "/dev/input/event30" device = self.injector._grab_device(path) self.assertIsNone(device) # no capability is used, so it won't grab custom_mapping.change(Key(EV_KEY, BTN_A, 1), "keyboard", "a") device = self.injector._grab_device(path) self.assertIsNotNone(device) gamepad = classify(device) == GAMEPAD self.assertTrue(gamepad) def test_gamepad_purpose_none_2(self): # forward abs joystick events for the left joystick only custom_mapping.set("gamepad.joystick.left_purpose", NONE) config.set("gamepad.joystick.right_purpose", MOUSE) self.injector = Injector(groups.find(name="gamepad"), custom_mapping) self.injector.context = Context(custom_mapping) path = "/dev/input/event30" device = self.injector._grab_device(path) # the right joystick maps as mouse, so it is grabbed # even with an empty mapping self.assertIsNotNone(device) gamepad = classify(device) == GAMEPAD self.assertTrue(gamepad) custom_mapping.change(Key(EV_KEY, BTN_A, 1), "keyboard", "a") device = self.injector._grab_device(path) gamepad = classify(device) == GAMEPAD self.assertIsNotNone(device) self.assertTrue(gamepad) def test_skip_unused_device(self): # skips a device because its capabilities are not used in the mapping custom_mapping.change(Key(EV_KEY, 10, 1), "keyboard", "a") self.injector = Injector(groups.find(key="Foo Device 2"), custom_mapping) self.injector.context = Context(custom_mapping) path = "/dev/input/event11" device = self.injector._grab_device(path) self.assertIsNone(device) self.assertEqual(self.failed, 0) def test_skip_unknown_device(self): custom_mapping.change(Key(EV_KEY, 10, 1), "keyboard", "a") # skips a device because its capabilities are not used in the mapping self.injector = Injector(groups.find(key="Foo Device 2"), custom_mapping) self.injector.context = Context(custom_mapping) path = "/dev/input/event11" device = self.injector._grab_device(path) # skips the device alltogether, so no grab attempts fail self.assertEqual(self.failed, 0) self.assertIsNone(device) 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()) def test_gamepad_to_mouse(self): # maps gamepad joystick events to mouse events config.set("gamepad.joystick.non_linearity", 1) pointer_speed = 80 config.set("gamepad.joystick.pointer_speed", pointer_speed) config.set("gamepad.joystick.left_purpose", MOUSE) # they need to sum up before something is written divisor = 10 x = MAX_ABS / pointer_speed / divisor y = MAX_ABS / pointer_speed / divisor push_events( "gamepad", [ new_event(EV_ABS, ABS_X, x), new_event(EV_ABS, ABS_Y, y), new_event(EV_ABS, ABS_X, -x), new_event(EV_ABS, ABS_Y, -y), ], ) self.injector = Injector(groups.find(name="gamepad"), custom_mapping) self.injector.start() # wait for the injector to start sending, at most 1s uinput_write_history_pipe[0].poll(1) # wait a bit more for it to sum up sleep = 0.5 time.sleep(sleep) # convert the write history to some easier to manage list history = read_write_history_pipe() if history[0][0] == EV_ABS: raise AssertionError( "The injector probably just forwarded them unchanged" # possibly in addition to writing mouse events ) # movement is written at 60hz and it takes `divisor` steps to # move 1px. take it times 2 for both x and y events. self.assertGreater(len(history), 60 * sleep * 0.9 * 2 / divisor) self.assertLess(len(history), 60 * sleep * 1.1 * 2 / divisor) # those may be in arbitrary order count_x = history.count((EV_REL, REL_X, -1)) count_y = history.count((EV_REL, REL_Y, -1)) self.assertGreater(count_x, 1) self.assertGreater(count_y, 1) # only those two types of events were written self.assertEqual(len(history), count_x + count_y) def test_gamepad_forward_joysticks(self): push_events( "gamepad", [ # should forward them unmodified new_event(EV_ABS, ABS_X, 10), new_event(EV_ABS, ABS_Y, 20), new_event(EV_ABS, ABS_X, -30), new_event(EV_ABS, ABS_Y, -40), new_event(EV_KEY, BTN_A, 1), new_event(EV_KEY, BTN_A, 0), ] * 2, ) custom_mapping.set("gamepad.joystick.left_purpose", NONE) custom_mapping.set("gamepad.joystick.right_purpose", NONE) # BTN_A -> 77 custom_mapping.change(Key((1, BTN_A, 1)), "keyboard", "b") system_mapping._set("b", 77) self.injector = Injector(groups.find(name="gamepad"), custom_mapping) self.injector.start() # wait for the injector to start sending, at most 1s uinput_write_history_pipe[0].poll(1) time.sleep(0.2) # convert the write history to some easier to manage list history = read_write_history_pipe() self.assertEqual(history.count((EV_ABS, ABS_X, 10)), 2) self.assertEqual(history.count((EV_ABS, ABS_Y, 20)), 2) self.assertEqual(history.count((EV_ABS, ABS_X, -30)), 2) self.assertEqual(history.count((EV_ABS, ABS_Y, -40)), 2) self.assertEqual(history.count((EV_KEY, 77, 1)), 2) self.assertEqual(history.count((EV_KEY, 77, 0)), 2) def test_gamepad_trigger(self): # map one of the triggers to BTN_NORTH, while the other one # should be forwarded unchanged value = MAX_ABS // 2 push_events( "gamepad", [ new_event(EV_ABS, ABS_Z, value), new_event(EV_ABS, ABS_RZ, value), ], ) # ABS_Z -> 77 # ABS_RZ is not mapped custom_mapping.change(Key((EV_ABS, ABS_Z, 1)), "keyboard", "b") system_mapping._set("b", 77) self.injector = Injector(groups.find(name="gamepad"), custom_mapping) self.injector.start() # wait for the injector to start sending, at most 1s uinput_write_history_pipe[0].poll(1) time.sleep(0.2) # convert the write history to some easier to manage list history = read_write_history_pipe() self.assertEqual(history.count((EV_KEY, 77, 1)), 1) self.assertEqual(history.count((EV_ABS, ABS_RZ, value)), 1) @mock.patch("evdev.InputDevice.ungrab") def test_gamepad_to_mouse_joystick_to_mouse(self, ungrab_patch): custom_mapping.set("gamepad.joystick.left_purpose", MOUSE) custom_mapping.set("gamepad.joystick.right_purpose", NONE) self.injector = Injector(groups.find(name="gamepad"), custom_mapping) # the stop message will be available in the pipe right away, # so run won't block and just stop. all the stuff # will be initialized though, so that stuff can be tested self.injector.stop_injecting() # the context serves no purpose in the main process (which runs the # tests). The context is only accessible in the newly created process. self.assertIsNone(self.injector.context) # not in a process because this doesn't call start, so the # joystick_to_mouse state can be checked self.injector.run() joystick_to_mouse = self.find_joystick_to_mouse() self.assertEqual(joystick_to_mouse._abs_range[0], MIN_ABS) self.assertEqual(joystick_to_mouse._abs_range[1], MAX_ABS) self.assertEqual( self.injector.context.mapping.get("gamepad.joystick.left_purpose"), MOUSE ) self.assertEqual(ungrab_patch.call_count, 1) def test_device1_not_a_gamepad(self): custom_mapping.set("gamepad.joystick.left_purpose", MOUSE) custom_mapping.set("gamepad.joystick.right_purpose", WHEEL) self.injector = Injector(groups.find(key="Foo Device 2"), custom_mapping) self.injector.stop_injecting() self.injector.run() # not a gamepad, so nothing should happen self.assertEqual(len(self.injector._consumer_controls), 0) def test_get_udev_name(self): self.injector = Injector(groups.find(key="Foo Device 2"), custom_mapping) 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", ) @mock.patch("evdev.InputDevice.ungrab") def test_capabilities_and_uinput_presence(self, ungrab_patch): custom_mapping.change(Key(EV_KEY, KEY_A, 1), "keyboard", "c") custom_mapping.change(Key(EV_REL, REL_HWHEEL, 1), "keyboard", "k(b)") self.injector = Injector(groups.find(key="Foo Device 2"), custom_mapping) self.injector.stop_injecting() self.injector.run() self.assertEqual( self.injector.context.mapping.get_mapping(Key(EV_KEY, KEY_A, 1)), ("c", "keyboard"), ) self.assertEqual( self.injector.context.key_to_code[((EV_KEY, KEY_A, 1),)], (KEY_C, "keyboard"), ) self.assertEqual( self.injector.context.mapping.get_mapping(Key(EV_REL, REL_HWHEEL, 1)), ("k(b)", "keyboard"), ) self.assertEqual( self.injector.context.macros[((EV_REL, REL_HWHEEL, 1),)][0].code, "k(b)" ) self.assertListEqual( sorted(uinputs.keys()), sorted( [ # reading and preventing original events from reaching the # display server "input-remapper Foo Device foo forwarded", "input-remapper Foo Device forwarded", ] ), ) forwarded_foo = uinputs.get("input-remapper Foo Device foo forwarded") forwarded = uinputs.get("input-remapper Foo Device forwarded") 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(ungrab_patch.call_count, 2) def test_injector(self): # the tests in test_keycode_mapper.py test this stuff in detail numlock_before = is_numlock_on() combination = Key((EV_KEY, 8, 1), (EV_KEY, 9, 1)) custom_mapping.change(combination, "keyboard", "k(KEY_Q).k(w)") custom_mapping.change(Key(EV_ABS, ABS_HAT0X, -1), "keyboard", "a") # one mapping that is unknown in the system_mapping on purpose input_b = 10 custom_mapping.change(Key(EV_KEY, input_b, 1), "keyboard", "b") # stuff the custom_mapping outputs (except for the unknown b) system_mapping.clear() code_a = 100 code_q = 101 code_w = 102 system_mapping._set("a", code_a) system_mapping._set("key_q", code_q) system_mapping._set("w", code_w) push_events( "Bar Device", [ # should execute a macro... new_event(EV_KEY, 8, 1), new_event(EV_KEY, 9, 1), # ...now new_event(EV_KEY, 8, 0), new_event(EV_KEY, 9, 0), # gamepad stuff. trigger a combination new_event(EV_ABS, ABS_HAT0X, -1), new_event(EV_ABS, ABS_HAT0X, 0), # just pass those over without modifying new_event(EV_KEY, 10, 1), new_event(EV_KEY, 10, 0), new_event(3124, 3564, 6542), ], ) self.injector = Injector(groups.find(name="Bar Device"), custom_mapping) self.assertEqual(self.injector.get_state(), UNKNOWN) self.injector.start() self.assertEqual(self.injector.get_state(), STARTING) uinput_write_history_pipe[0].poll(timeout=1) self.assertEqual(self.injector.get_state(), RUNNING) time.sleep(EVENT_READ_TIMEOUT * 10) # 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 (+1 for release) # 4 events for the macro # 2 for mapped keys # 3 for forwarded events self.assertEqual(len(history), 11) # 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. self.assertIn((EV_KEY, 8, 1), history) self.assertIn((EV_KEY, code_q, 1), history) 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_q_1] index_q_0 = history.index((EV_KEY, code_q, 0)) del history[index_q_0] index_w_1 = history.index((EV_KEY, code_w, 1)) del history[index_w_1] index_w_0 = history.index((EV_KEY, code_w, 0)) del history[index_w_0] # the rest should be in order. # first the incomplete combination key that wasn't mapped to anything # and just forwarded. The input event that triggered the macro # won't appear here. self.assertEqual(history[0], (EV_KEY, 8, 1)) self.assertEqual(history[1], (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[2], (EV_KEY, code_a, 1)) self.assertEqual(history[3], (EV_KEY, code_a, 0)) self.assertEqual(history[4], (EV_KEY, input_b, 1)) self.assertEqual(history[5], (EV_KEY, input_b, 0)) self.assertEqual(history[6], (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(), RUNNING) def test_any_funky_event_as_button(self): # as long as should_map_as_btn says it should be a button, # it will be. EV_TYPE = 4531 CODE_1 = 754 CODE_2 = 4139 w_down = (EV_TYPE, CODE_1, -1) w_up = (EV_TYPE, CODE_1, 0) d_down = (EV_TYPE, CODE_2, 1) d_up = (EV_TYPE, CODE_2, 0) custom_mapping.change(Key(*w_down[:2], -1), "keyboard", "w") custom_mapping.change(Key(*d_down[:2], 1), "keyboard", "k(d)") system_mapping.clear() code_w = 71 code_d = 74 system_mapping._set("w", code_w) system_mapping._set("d", code_d) def do_stuff(): if self.injector is not None: # discard the previous injector self.injector.stop_injecting() time.sleep(0.1) while uinput_write_history_pipe[0].poll(): uinput_write_history_pipe[0].recv() push_events( "gamepad", [ new_event(*w_down), new_event(*d_down), new_event(*w_up), new_event(*d_up), ], ) self.injector = Injector(groups.find(name="gamepad"), custom_mapping) # the injector will otherwise skip the device because # the capabilities don't contain EV_TYPE input = InputDevice("/dev/input/event30") self.injector._grab_device = lambda *args: input self.injector.start() uinput_write_history_pipe[0].poll(timeout=1) time.sleep(EVENT_READ_TIMEOUT * 10) return read_write_history_pipe() """no""" history = do_stuff() self.assertEqual(history.count((EV_KEY, code_w, 1)), 0) self.assertEqual(history.count((EV_KEY, code_d, 1)), 0) self.assertEqual(history.count((EV_KEY, code_w, 0)), 0) self.assertEqual(history.count((EV_KEY, code_d, 0)), 0) """yes""" with mock.patch("inputremapper.utils.should_map_as_btn", lambda *_: True): history = do_stuff() 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_w, 0)), 1) self.assertEqual(history.count((EV_KEY, code_d, 0)), 1) def test_wheel(self): # wheel release events are made up with a debouncer # 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) custom_mapping.change(Key(*hw_right), "keyboard", "k(b)") custom_mapping.change(Key(*w_up), "keyboard", "c") system_mapping.clear() code_b = 91 code_c = 92 system_mapping._set("b", code_b) system_mapping._set("c", code_c) group_key = "Foo Device 2" push_events( group_key, [new_event(*w_up)] * 10 + [new_event(*hw_right), new_event(*w_up)] * 5 + [new_event(*hw_left)], ) group = groups.find(key=group_key) self.injector = Injector(group, custom_mapping) device = InputDevice("/dev/input/event11") # make sure this test uses a device that has the needed capabilities # for the injector to grab it self.assertIn(EV_REL, device.capabilities()) self.assertIn(REL_WHEEL, device.capabilities()[EV_REL]) self.assertIn(REL_HWHEEL, device.capabilities()[EV_REL]) self.assertIn(device.path, group.paths) self.injector.start() # wait for the first injected key down event uinput_write_history_pipe[0].poll(timeout=1) self.assertTrue(uinput_write_history_pipe[0].poll()) event = uinput_write_history_pipe[0].recv() self.assertEqual(event.t, (EV_KEY, code_c, 1)) # in 5 more read-loop ticks, nothing new should have happened. # add a bit of a head-start of one EVENT_READ_TIMEOUT to avoid race-conditions # in tests self.assertFalse( uinput_write_history_pipe[0].poll(timeout=EVENT_READ_TIMEOUT * 6) ) # 5 more and it should be within the second phase in which # the horizontal wheel is used. add some tolerance self.assertAlmostEqual( wait_for_uinput_write(), EVENT_READ_TIMEOUT * 5, delta=EVENT_READ_TIMEOUT ) event = uinput_write_history_pipe[0].recv() self.assertEqual(event.t, (EV_KEY, code_b, 1)) time.sleep(EVENT_READ_TIMEOUT * 10 + 5 / 60) # after 21 read-loop ticks all events should be consumed, wait for # at least 3 (lets use 5 so that the test passes even if it lags) # ticks so that the debouncers are triggered. # Key-up events for both wheel events should be written now that no # new key-down event arrived. events = read_write_history_pipe() self.assertEqual(events.count((EV_KEY, code_b, 0)), 1) self.assertEqual(events.count((EV_KEY, code_c, 0)), 1) self.assertEqual(events.count(hw_left), 1) # the unmapped wheel # the unmapped wheel won't get a debounced release command, it's # forwarded as is self.assertNotIn((EV_REL, REL_HWHEEL, 0), events) self.assertEqual(len(events), 3) def test_store_permutations_for_macros(self): mapping = Mapping() ev_1 = (EV_KEY, 41, 1) ev_2 = (EV_KEY, 42, 1) ev_3 = (EV_KEY, 43, 1) # a combination mapping.change(Key(ev_1, ev_2, ev_3), "keyboard", "k(a)") self.injector = Injector(groups.find(key="Foo Device 2"), mapping) history = [] class Stop(Exception): pass def _copy_capabilities(*args): history.append(args) # avoid going into any mainloop raise Stop() with mock.patch.object(self.injector, "_copy_capabilities", _copy_capabilities): try: self.injector.run() except Stop: pass # one call self.assertEqual(len(history), 1) # first argument of the first call macros = self.injector.context.macros self.assertEqual(len(macros), 2) self.assertEqual(macros[(ev_1, ev_2, ev_3)][0].code, "k(a)") self.assertEqual(macros[(ev_2, ev_1, ev_3)][0].code, "k(a)") def test_key_to_code(self): mapping = Mapping() ev_1 = (EV_KEY, 41, 1) ev_2 = (EV_KEY, 42, 1) ev_3 = (EV_KEY, 43, 1) ev_4 = (EV_KEY, 44, 1) mapping.change(Key(ev_1), "keyboard", "a") # a combination mapping.change(Key(ev_2, ev_3, ev_4), "keyboard", "b") self.assertEqual(mapping.get_mapping(Key(ev_2, ev_3, ev_4)), ("b", "keyboard")) system_mapping.clear() system_mapping._set("a", 51) system_mapping._set("b", 52) injector = Injector(groups.find(key="Foo Device 2"), mapping) injector.context = Context(mapping) self.assertEqual(injector.context.key_to_code.get((ev_1,)), (51, "keyboard")) # permutations to make matching combinations easier self.assertEqual( injector.context.key_to_code.get((ev_2, ev_3, ev_4)), (52, "keyboard") ) self.assertEqual( injector.context.key_to_code.get((ev_3, ev_2, ev_4)), (52, "keyboard") ) self.assertEqual(len(injector.context.key_to_code), 3) def test_is_in_capabilities(self): key = Key(1, 2, 1) capabilities = {1: [9, 2, 5]} self.assertTrue(is_in_capabilities(key, capabilities)) key = Key((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 = Key((1, 2, 1), (1, 5, 1)) capabilities = {1: [9, 2, 5]} self.assertTrue(is_in_capabilities(key, capabilities)) class TestModifyCapabilities(unittest.TestCase): @classmethod def setUpClass(cls): quick_cleanup() def setUp(self): 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 mapping = Mapping() mapping.change(Key(EV_KEY, 80, 1), "keyboard", "a") mapping.change(Key(EV_KEY, 81, 1), "keyboard", DISABLE_NAME) macro_code = "r(2, m(sHiFt_l, r(2, k(1).k(2))))" macro = parse(macro_code, mapping) mapping.change(Key(EV_KEY, 60, 111), "keyboard", macro_code) # going to be ignored, because EV_REL cannot be mapped, that's # mouse movements. mapping.change(Key(EV_REL, 1234, 3), "keyboard", "b") self.a = system_mapping.get("a") self.shift_l = system_mapping.get("ShIfT_L") self.one = system_mapping.get(1) self.two = system_mapping.get("2") self.left = system_mapping.get("BtN_lEfT") self.fake_device = FakeDevice() self.mapping = mapping 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 tearDown(self): quick_cleanup() def test_copy_capabilities(self): self.mapping.change(Key(EV_KEY, 60, 1), "keyboard", self.macro.code) # 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(None, self.mapping) 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() input-remapper-1.4.0/tests/unit/test_ipc.py000066400000000000000000000122101417303655400207750ustar00rootroot00000000000000#!/usr/bin/python3 # -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings # Copyright (C) 2022 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 select import time import os from inputremapper.ipc.pipe import Pipe from inputremapper.ipc.shared_dict import SharedDict from inputremapper.ipc.socket import Server, Client, Base from tests.test import quick_cleanup, tmp class TestSharedDict(unittest.TestCase): def setUp(self): self.shared_dict = SharedDict() self.shared_dict.start() time.sleep(0.02) def tearDown(self): quick_cleanup() 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) 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)) class TestPipe(unittest.TestCase): 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) if __name__ == "__main__": unittest.main() input-remapper-1.4.0/tests/unit/test_key.py000066400000000000000000000106641417303655400210250ustar00rootroot00000000000000#!/usr/bin/python3 # -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings # Copyright (C) 2022 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 KEY_LEFTSHIFT, KEY_RIGHTALT, KEY_LEFTCTRL from inputremapper.key import Key class TestKey(unittest.TestCase): def test_key(self): # its very similar to regular tuples, but with some extra stuff key_1 = Key((1, 3, 1), (1, 5, 1)) self.assertEqual(str(key_1), "Key((1, 3, 1), (1, 5, 1))") self.assertEqual(len(key_1), 2) self.assertEqual(key_1[0], (1, 3, 1)) self.assertEqual(key_1[1], (1, 5, 1)) self.assertEqual(hash(key_1), hash(((1, 3, 1), (1, 5, 1)))) key_2 = Key((1, 3, 1)) self.assertEqual(str(key_2), "Key((1, 3, 1),)") self.assertEqual(len(key_2), 1) self.assertNotEqual(key_2, key_1) self.assertNotEqual(hash(key_2), hash(key_1)) key_3 = Key(1, 3, 1) self.assertEqual(str(key_3), "Key((1, 3, 1),)") self.assertEqual(len(key_3), 1) self.assertEqual(key_3, key_2) self.assertEqual(key_3, (1, 3, 1)) self.assertEqual(hash(key_3), hash(key_2)) self.assertEqual(hash(key_3), hash((1, 3, 1))) key_4 = Key(key_3) self.assertEqual(str(key_4), "Key((1, 3, 1),)") self.assertEqual(len(key_4), 1) self.assertEqual(key_4, key_3) self.assertEqual(hash(key_4), hash(key_3)) key_5 = Key(key_4, key_4, (1, 7, 1)) self.assertEqual(str(key_5), "Key((1, 3, 1), (1, 3, 1), (1, 7, 1))") self.assertEqual(len(key_5), 3) self.assertNotEqual(key_5, key_4) self.assertNotEqual(hash(key_5), hash(key_4)) self.assertEqual(key_5, ((1, 3, 1), (1, 3, 1), (1, 7, 1))) self.assertEqual(hash(key_5), hash(((1, 3, 1), (1, 3, 1), (1, 7, 1)))) def test_get_permutations(self): key_1 = Key((1, 3, 1)) self.assertEqual(len(key_1.get_permutations()), 1) self.assertEqual(key_1.get_permutations()[0], key_1) key_2 = Key((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 = Key((1, 3, 1), (1, 5, 1), (1, 7, 1)) self.assertEqual(len(key_3.get_permutations()), 2) self.assertEqual( key_3.get_permutations()[0], Key((1, 3, 1), (1, 5, 1), (1, 7, 1)) ) self.assertEqual(key_3.get_permutations()[1], ((1, 5, 1), (1, 3, 1), (1, 7, 1))) def test_is_problematic(self): key_1 = Key((1, KEY_LEFTSHIFT, 1), (1, 5, 1)) self.assertTrue(key_1.is_problematic()) key_2 = Key((1, KEY_RIGHTALT, 1), (1, 5, 1)) self.assertTrue(key_2.is_problematic()) key_3 = Key((1, 3, 1), (1, KEY_LEFTCTRL, 1)) self.assertTrue(key_3.is_problematic()) key_4 = Key(1, 3, 1) self.assertFalse(key_4.is_problematic()) key_5 = Key((1, 3, 1), (1, 5, 1)) self.assertFalse(key_5.is_problematic()) def test_raises(self): self.assertRaises(ValueError, lambda: Key(1)) self.assertRaises(ValueError, lambda: Key(None)) self.assertRaises(ValueError, lambda: Key([1])) self.assertRaises(ValueError, lambda: Key((1,))) self.assertRaises(ValueError, lambda: Key((1, 2))) self.assertRaises(ValueError, lambda: Key(("1", "2", "3"))) self.assertRaises(ValueError, lambda: Key("1")) self.assertRaises(ValueError, lambda: Key("(1,2,3)")) self.assertRaises(ValueError, lambda: Key((1, 2, 3), (1, 2, "3"))) self.assertRaises(ValueError, lambda: Key((1, 2, 3), (1, 2, 3), None)) # those don't raise errors Key((1, 2, 3), (1, 2, 3)) Key((1, 2, 3)) if __name__ == "__main__": unittest.main() input-remapper-1.4.0/tests/unit/test_keycode_mapper.py000066400000000000000000001463051417303655400232260ustar00rootroot00000000000000#!/usr/bin/python3 # -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings # Copyright (C) 2022 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 asyncio import time from evdev.ecodes import ( EV_KEY, EV_ABS, KEY_A, KEY_B, KEY_C, BTN_TL, ABS_HAT0X, ABS_HAT0Y, ABS_HAT1X, ABS_HAT1Y, ABS_Y, ) from inputremapper.injection.consumers.keycode_mapper import ( active_macros, KeycodeMapper, unreleased, subsets, ) from inputremapper.system_mapping import system_mapping from inputremapper.injection.macros.parse import parse from inputremapper.injection.context import Context from inputremapper.utils import RELEASE, PRESS from inputremapper.config import config, BUTTONS from inputremapper.mapping import Mapping from inputremapper.system_mapping import DISABLE_CODE from inputremapper.injection.global_uinputs import global_uinputs from tests.test import ( new_event, UInput, uinput_write_history, quick_cleanup, InputDevice, MAX_ABS, MIN_ABS, ) 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: raise Exception("Timeout") def calculate_event_number(holdtime, before, after): """ Parameters ---------- holdtime : int in ms, how long was the key held down before : int how many extra k() calls are executed before h() after : int how many extra k() calls are executed after h() """ keystroke_sleep = config.get("macros.keystroke_sleep_ms", 10) # down and up: two sleeps per k # one initial k(a): events = before * 2 holdtime -= keystroke_sleep * 2 # hold events events += (holdtime / (keystroke_sleep * 2)) * 2 # one trailing k(c) events += after * 2 return events class TestKeycodeMapper(unittest.IsolatedAsyncioTestCase): def setUp(self): self.mapping = Mapping() self.context = Context(self.mapping) self.source = InputDevice("/dev/input/event11") self.history = [] def tearDown(self): # make sure all macros are stopped by tests self.history = [] for macro in active_macros.values(): if macro.is_holding(): macro.release_trigger() asyncio.get_event_loop().run_until_complete(asyncio.sleep(0.01)) self.assertFalse(macro.is_holding()) self.assertFalse(macro.running) quick_cleanup() def setup_keycode_mapper(self, keycodes, macro_mapping): """Setup a default keycode mapper than can be used for most tests.""" system_mapping.clear() for key, code in keycodes.items(): system_mapping._set(key, code) # parse requires an intact system_mapping! self.context.macros = { key: (parse(code, self.context), "keyboard") for key, code in macro_mapping.items() } uinput = UInput() self.context.uinput = uinput keycode_mapper = KeycodeMapper(self.context, self.source, UInput()) keycode_mapper.macro_write = self.create_handler return keycode_mapper def create_handler(self, _): # to reduce the likelihood of race conditions of macros that for some reason # are still running after the test, make sure they don't access the history # of the next test. history = self.history return lambda *args: history.append(args) async def test_subsets(self): a = subsets(((1,), (2,), (3,))) self.assertIn(((1,), (2,)), a) self.assertIn(((2,), (3,)), a) self.assertIn(((1,), (3,)), a) self.assertIn(((1,), (2,), (3,)), a) self.assertEqual(len(a), 4) b = subsets(((1,), (2,))) self.assertIn(((1,), (2,)), b) self.assertEqual(len(b), 1) c = subsets(((1,),)) self.assertEqual(len(c), 0) async def test_d_pad(self): ev_1 = (EV_ABS, ABS_HAT0X, 1) ev_2 = (EV_ABS, ABS_HAT0X, -1) ev_3 = (EV_ABS, ABS_HAT0X, 0) ev_4 = (EV_ABS, ABS_HAT0Y, 1) ev_5 = (EV_ABS, ABS_HAT0Y, -1) ev_6 = (EV_ABS, ABS_HAT0Y, 0) uinput = UInput() self.context.uinput = uinput self.context.key_to_code = { (ev_1,): (51, "keyboard"), (ev_2,): (52, "keyboard"), (ev_4,): (54, "keyboard"), (ev_5,): (55, "keyboard"), } keycode_mapper = KeycodeMapper(self.context, self.source, uinput) # a bunch of d-pad key down events at once await keycode_mapper.notify(new_event(*ev_1)) await keycode_mapper.notify(new_event(*ev_4)) self.assertEqual(len(unreleased), 2) self.assertEqual( unreleased.get(ev_1[:2]).target, (EV_KEY, *self.context.key_to_code[(ev_1,)]), ) self.assertEqual(unreleased.get(ev_1[:2]).input_event_tuple, ev_1) self.assertEqual( unreleased.get(ev_1[:2]).triggered_key, (ev_1,) ) # as seen in key_to_code self.assertEqual( unreleased.get(ev_4[:2]).target, (EV_KEY, *self.context.key_to_code[(ev_4,)]), ev_4, ) self.assertEqual(unreleased.get(ev_4[:2]).input_event_tuple, ev_4) self.assertEqual(unreleased.get(ev_4[:2]).triggered_key, (ev_4,)) # release all of them await keycode_mapper.notify(new_event(*ev_3)) await keycode_mapper.notify(new_event(*ev_6)) self.assertEqual(len(unreleased), 0) # repeat with other values await keycode_mapper.notify(new_event(*ev_2)) await keycode_mapper.notify(new_event(*ev_5)) self.assertEqual(len(unreleased), 2) self.assertEqual( unreleased.get(ev_2[:2]).target, (EV_KEY, *self.context.key_to_code[(ev_2,)]), ) self.assertEqual(unreleased.get(ev_2[:2]).input_event_tuple, ev_2) self.assertEqual( unreleased.get(ev_5[:2]).target, (EV_KEY, *self.context.key_to_code[(ev_5,)]), ) self.assertEqual(unreleased.get(ev_5[:2]).input_event_tuple, ev_5) # release all of them again await keycode_mapper.notify(new_event(*ev_3)) await keycode_mapper.notify(new_event(*ev_6)) self.assertEqual(len(unreleased), 0) self.assertEqual(len(uinput_write_history), 8) self.assertEqual(uinput_write_history[0].t, (EV_KEY, 51, 1)) self.assertEqual(uinput_write_history[1].t, (EV_KEY, 54, 1)) self.assertEqual(uinput_write_history[2].t, (EV_KEY, 51, 0)) self.assertEqual(uinput_write_history[3].t, (EV_KEY, 54, 0)) self.assertEqual(uinput_write_history[4].t, (EV_KEY, 52, 1)) self.assertEqual(uinput_write_history[5].t, (EV_KEY, 55, 1)) self.assertEqual(uinput_write_history[6].t, (EV_KEY, 52, 0)) self.assertEqual(uinput_write_history[7].t, (EV_KEY, 55, 0)) async def test_not_forward(self): down = (EV_KEY, 91, 1) up = (EV_KEY, 91, 0) uinput = global_uinputs.devices["keyboard"] keycode_mapper = KeycodeMapper(self.context, self.source, uinput) keycode_mapper.handle_keycode(new_event(*down), PRESS, forward=False) self.assertEqual(unreleased[(EV_KEY, 91)].input_event_tuple, down) self.assertEqual(unreleased[(EV_KEY, 91)].target, (*down[:2], None)) self.assertEqual(len(unreleased), 1) self.assertEqual(uinput.write_count, 0) keycode_mapper.handle_keycode(new_event(*up), RELEASE, forward=False) self.assertEqual(len(unreleased), 0) self.assertEqual(uinput.write_count, 0) async def test_release_joystick_button(self): # with the left joystick mapped as button, it will release the mapped # key when it goes back to close to its resting position ev_1 = (3, 0, MAX_ABS // 10) # release ev_3 = (3, 0, MIN_ABS) # press uinput = UInput() _key_to_code = {((3, 0, -1),): (73, "keyboard")} self.mapping.set("gamepad.joystick.left_purpose", BUTTONS) # something with gamepad capabilities source = InputDevice("/dev/input/event30") self.context.uinput = uinput self.context.key_to_code = _key_to_code keycode_mapper = KeycodeMapper(self.context, source, uinput) await keycode_mapper.notify(new_event(*ev_3)) await keycode_mapper.notify(new_event(*ev_1)) # array of 3-tuples self.history = [a.t for a in uinput_write_history] self.assertIn((EV_KEY, 73, 1), self.history) self.assertEqual(self.history.count((EV_KEY, 73, 1)), 1) self.assertIn((EV_KEY, 73, 0), self.history) self.assertEqual(self.history.count((EV_KEY, 73, 0)), 1) async def test_dont_filter_unmapped(self): # if an event is not used at all, it should be written but not # furthermore modified. For example wheel events # keep reporting events of the same value without a release inbetween, # they should be forwarded. down = (EV_KEY, 91, 1) up = (EV_KEY, 91, 0) uinput = global_uinputs.devices["keyboard"] forward_to = UInput() keycode_mapper = KeycodeMapper(self.context, self.source, forward_to) for _ in range(10): # don't filter duplicate events if not mapped await keycode_mapper.notify(new_event(*down)) self.assertEqual(unreleased[(EV_KEY, 91)].input_event_tuple, down) self.assertEqual(unreleased[(EV_KEY, 91)].target, (*down[:2], None)) self.assertEqual(len(unreleased), 1) self.assertEqual(forward_to.write_count, 10) self.assertEqual(uinput.write_count, 0) await keycode_mapper.notify(new_event(*up)) self.assertEqual(len(unreleased), 0) self.assertEqual(forward_to.write_count, 11) self.assertEqual(uinput.write_count, 0) async def test_filter_combi_mapped_duplicate_down(self): # the opposite of the other test, but don't map the key directly # but rather as the trigger for a combination down_1 = (EV_KEY, 91, 1) down_2 = (EV_KEY, 92, 1) up_1 = (EV_KEY, 91, 0) up_2 = (EV_KEY, 92, 0) # forwarded and mapped event will end up at the same place forward = global_uinputs.devices["keyboard"] output = 71 key_to_code = {(down_1, down_2): (71, "keyboard")} self.context.key_to_code = key_to_code keycode_mapper = KeycodeMapper(self.context, self.source, forward) await keycode_mapper.notify(new_event(*down_1)) for _ in range(10): await keycode_mapper.notify(new_event(*down_2)) # all duplicate down events should have been ignored self.assertEqual(len(unreleased), 2) self.assertEqual(forward.write_count, 2) self.assertEqual(uinput_write_history[0].t, down_1) self.assertEqual(uinput_write_history[1].t, (EV_KEY, output, 1)) await keycode_mapper.notify(new_event(*up_1)) await keycode_mapper.notify(new_event(*up_2)) self.assertEqual(len(unreleased), 0) self.assertEqual(forward.write_count, 4) self.assertEqual(uinput_write_history[2].t, up_1) self.assertEqual(uinput_write_history[3].t, (EV_KEY, output, 0)) async def test_d_pad_combination(self): ev_1 = (EV_ABS, ABS_HAT0X, 1) ev_2 = (EV_ABS, ABS_HAT0Y, -1) ev_3 = (EV_ABS, ABS_HAT0X, 0) ev_4 = (EV_ABS, ABS_HAT0Y, 0) _key_to_code = { (ev_1, ev_2): (51, "keyboard"), (ev_2,): (52, "keyboard"), } uinput = UInput() self.context.uinput = uinput self.context.key_to_code = _key_to_code keycode_mapper = KeycodeMapper(self.context, self.source, uinput) # a bunch of d-pad key down events at once await keycode_mapper.notify(new_event(*ev_1)) await keycode_mapper.notify(new_event(*ev_2)) # (what_will_be_released, what_caused_the_key_down) self.assertEqual(unreleased.get(ev_1[:2]).target, (EV_ABS, ABS_HAT0X, None)) self.assertEqual(unreleased.get(ev_1[:2]).input_event_tuple, ev_1) self.assertEqual(unreleased.get(ev_2[:2]).target, (EV_KEY, 51, "keyboard")) self.assertEqual(unreleased.get(ev_2[:2]).input_event_tuple, ev_2) self.assertEqual(len(unreleased), 2) # ev_1 is unmapped and the other is the triggered combination self.assertEqual(len(uinput_write_history), 2) self.assertEqual(uinput_write_history[0].t, ev_1) self.assertEqual(uinput_write_history[1].t, (EV_KEY, 51, 1)) # release all of them await keycode_mapper.notify(new_event(*ev_3)) await keycode_mapper.notify(new_event(*ev_4)) self.assertEqual(len(unreleased), 0) self.assertEqual(len(uinput_write_history), 4) self.assertEqual(uinput_write_history[2].t, ev_3) self.assertEqual(uinput_write_history[3].t, (EV_KEY, 51, 0)) async def test_notify(self): code_2 = 2 # this also makes sure that the keycode_mapper doesn't get confused # when input and output codes are the same (because it at some point # screwed it up because of that) _key_to_code = { ((EV_KEY, 1, 1),): (101, "keyboard"), ((EV_KEY, code_2, 1),): (code_2, "keyboard"), } uinput_mapped = global_uinputs.devices["keyboard"] uinput_forwarded = UInput() self.context.key_to_code = _key_to_code keycode_mapper = KeycodeMapper(self.context, self.source, uinput_forwarded) await keycode_mapper.notify(new_event(EV_KEY, 1, 1)) await keycode_mapper.notify(new_event(EV_KEY, 3, 1)) await keycode_mapper.notify(new_event(EV_KEY, code_2, 1)) await keycode_mapper.notify(new_event(EV_KEY, code_2, 0)) self.assertEqual(len(uinput_write_history), 4) self.assertEqual(uinput_mapped.write_history[0].t, (EV_KEY, 101, 1)) self.assertEqual(uinput_mapped.write_history[1].t, (EV_KEY, code_2, 1)) self.assertEqual(uinput_mapped.write_history[2].t, (EV_KEY, code_2, 0)) self.assertEqual(uinput_forwarded.write_history[0].t, (EV_KEY, 3, 1)) async def test_combination_keycode(self): combination = ((EV_KEY, 1, 1), (EV_KEY, 2, 1)) _key_to_code = {combination: (101, "keyboard")} uinput = UInput() self.context.uinput = uinput self.context.key_to_code = _key_to_code keycode_mapper = KeycodeMapper(self.context, self.source, uinput) await keycode_mapper.notify(new_event(*combination[0])) await keycode_mapper.notify(new_event(*combination[1])) self.assertEqual(len(uinput_write_history), 2) # the first event is written and then the triggered combination self.assertEqual(uinput_write_history[0].t, (EV_KEY, 1, 1)) self.assertEqual(uinput_write_history[1].t, (EV_KEY, 101, 1)) # release them await keycode_mapper.notify(new_event(*combination[0][:2], 0)) await keycode_mapper.notify(new_event(*combination[1][:2], 0)) # the first key writes its release event. The second key is hidden # behind the executed combination. The result of the combination is # also released, because it acts like a key. self.assertEqual(len(uinput_write_history), 4) self.assertEqual(uinput_write_history[2].t, (EV_KEY, 1, 0)) self.assertEqual(uinput_write_history[3].t, (EV_KEY, 101, 0)) # press them in the wrong order (the wrong key at the end, the order # of all other keys won't matter). no combination should be triggered await keycode_mapper.notify(new_event(*combination[1])) await keycode_mapper.notify(new_event(*combination[0])) self.assertEqual(len(uinput_write_history), 6) self.assertEqual(uinput_write_history[4].t, (EV_KEY, 2, 1)) self.assertEqual(uinput_write_history[5].t, (EV_KEY, 1, 1)) async def test_combination_keycode_2(self): combination_1 = ( (EV_KEY, 1, 1), (EV_ABS, ABS_Y, MIN_ABS), (EV_KEY, 3, 1), (EV_KEY, 4, 1), ) combination_2 = ( # should not be triggered, combination_1 should be prioritized # when all of its keys are down (EV_KEY, 2, 1), (EV_KEY, 3, 1), (EV_KEY, 4, 1), ) down_5 = (EV_KEY, 5, 1) up_5 = (EV_KEY, 5, 0) up_4 = (EV_KEY, 4, 0) def sign_value(key): return key[0], key[1], key[2] / abs(key[2]) _key_to_code = { # key_to_code is supposed to only contain values classified into PRESS, # PRESS_NEGATIVE and RELEASE tuple([sign_value(a) for a in combination_1]): (101, "keyboard"), combination_2: (102, "keyboard"), (down_5,): (103, "keyboard"), } uinput = UInput() source = InputDevice("/dev/input/event30") # ABS_Y is part of the combination, which only works if the joystick # is configured as D-Pad self.mapping.set("gamepad.joystick.left_purpose", BUTTONS) self.context.uinput = uinput self.context.key_to_code = _key_to_code keycode_mapper = KeycodeMapper(self.context, source, uinput) self.assertIsNotNone(keycode_mapper._abs_range) # 10 and 11: insert some more arbitrary key-down events, # they should not break the combinations await keycode_mapper.notify(new_event(EV_KEY, 10, 1)) await keycode_mapper.notify(new_event(*combination_1[0])) await keycode_mapper.notify(new_event(*combination_1[1])) await keycode_mapper.notify(new_event(*combination_1[2])) await keycode_mapper.notify(new_event(EV_KEY, 11, 1)) await keycode_mapper.notify(new_event(*combination_1[3])) # combination_1 should have been triggered now self.assertEqual(len(uinput_write_history), 6) # the first events are written and then the triggered combination, # while the triggering event is the only one that is omitted self.assertEqual(uinput_write_history[1].t, combination_1[0]) self.assertEqual(uinput_write_history[2].t, combination_1[1]) self.assertEqual(uinput_write_history[3].t, combination_1[2]) self.assertEqual(uinput_write_history[5].t, (EV_KEY, 101, 1)) # while the combination is down, another unrelated key can be used await keycode_mapper.notify(new_event(*down_5)) # the keycode_mapper searches for subsets of the current held-down # keys to activate combinations, down_5 should not trigger them # again. self.assertEqual(len(uinput_write_history), 7) self.assertEqual(uinput_write_history[6].t, (EV_KEY, 103, 1)) # release the combination by releasing the last key, and release # the unrelated key await keycode_mapper.notify(new_event(*up_4)) await keycode_mapper.notify(new_event(*up_5)) self.assertEqual(len(uinput_write_history), 9) self.assertEqual(uinput_write_history[7].t, (EV_KEY, 101, 0)) self.assertEqual(uinput_write_history[8].t, (EV_KEY, 103, 0)) async def test_macro_writes_to_global_uinput(self): macro_mapping = { ((EV_KEY, 1, 1),): (parse("k(a)", self.context), "keyboard"), } self.context.macros = macro_mapping forward_to = UInput() keycode_mapper = KeycodeMapper(self.context, self.source, forward_to) await keycode_mapper.notify(new_event(EV_KEY, 1, 1)) sleeptime = config.get("macros.keystroke_sleep_ms", 10) * 12 await asyncio.sleep(sleeptime / 1000 + 0.1) self.assertEqual( global_uinputs.devices["keyboard"].write_count, 2 ) # down and up self.assertEqual(forward_to.write_count, 0) await keycode_mapper.notify(new_event(EV_KEY, 2, 1)) self.assertEqual(forward_to.write_count, 1) async def test_notify_macro(self): code_a, code_b = 100, 101 keycode_mapper = self.setup_keycode_mapper( {"a": code_a, "b": code_b}, { ((EV_KEY, 1, 1),): "k(a)", ((EV_KEY, 2, 1),): "r(5, k(b))", }, ) await keycode_mapper.notify(new_event(EV_KEY, 1, 1)) await keycode_mapper.notify(new_event(EV_KEY, 2, 1)) sleeptime = config.get("macros.keystroke_sleep_ms", 10) * 12 # let the mainloop run for some time so that the macro does its stuff await asyncio.sleep(sleeptime / 1000 + 0.1) # 6 keycodes written, with down and up events self.assertEqual(len(self.history), 12) self.assertIn((EV_KEY, code_a, 1), self.history) self.assertIn((EV_KEY, code_a, 0), self.history) self.assertIn((EV_KEY, code_b, 1), self.history) self.assertIn((EV_KEY, code_b, 0), self.history) # releasing stuff self.assertIn((EV_KEY, 1), unreleased) self.assertIn((EV_KEY, 2), unreleased) await keycode_mapper.notify(new_event(EV_KEY, 1, 0)) await keycode_mapper.notify(new_event(EV_KEY, 2, 0)) self.assertNotIn((EV_KEY, 1), unreleased) self.assertNotIn((EV_KEY, 2), unreleased) await asyncio.sleep(0.1) self.assertEqual(len(self.history), 12) async def test_if_single(self): code_a, code_b = 100, 101 keycode_mapper = self.setup_keycode_mapper( {"a": code_a, "b": code_b}, {((EV_KEY, 1, 1),): "if_single(k(a), k(b))"} ) """triggers then""" await keycode_mapper.notify(new_event(EV_KEY, 1, 1)) # start the macro await asyncio.sleep(0.05) self.assertTrue(active_macros[(EV_KEY, 1)].running) await keycode_mapper.notify(new_event(EV_KEY, 1, 0)) await asyncio.sleep(0.05) self.assertFalse(active_macros[(EV_KEY, 1)].running) self.assertIn((EV_KEY, code_a, 1), self.history) self.assertIn((EV_KEY, code_a, 0), self.history) self.assertNotIn((EV_KEY, code_b, 1), self.history) self.assertNotIn((EV_KEY, code_b, 0), self.history) """triggers else""" self.history.clear() await keycode_mapper.notify(new_event(EV_KEY, 1, 1)) # start the macro await asyncio.sleep(0.05) self.assertTrue(active_macros[(EV_KEY, 1)].running) await keycode_mapper.notify(new_event(EV_KEY, 2, 1)) await asyncio.sleep(0.05) self.assertFalse(active_macros[(EV_KEY, 1)].running) self.assertNotIn((EV_KEY, code_a, 1), self.history) self.assertNotIn((EV_KEY, code_a, 0), self.history) self.assertIn((EV_KEY, code_b, 1), self.history) self.assertIn((EV_KEY, code_b, 0), self.history) async def test_hold(self): code_a, code_b, code_c = 100, 101, 102 keycode_mapper = self.setup_keycode_mapper( {"a": code_a, "b": code_b, "c": code_c}, {((EV_KEY, 1, 1),): "k(a).h(k(b)).k(c)"}, ) """start macro""" await keycode_mapper.notify(new_event(EV_KEY, 1, 1)) # let the mainloop run for some time so that the macro does its stuff sleeptime = 500 keystroke_sleep = config.get("macros.keystroke_sleep_ms", 10) await asyncio.sleep(sleeptime / 1000) self.assertTrue(active_macros[(EV_KEY, 1)].is_holding()) self.assertTrue(active_macros[(EV_KEY, 1)].running) """stop macro""" await keycode_mapper.notify(new_event(EV_KEY, 1, 0)) await asyncio.sleep(keystroke_sleep * 10 / 1000) events = calculate_event_number(sleeptime, 1, 1) self.assertGreater(len(self.history), events * 0.9) self.assertLess(len(self.history), events * 1.1) self.assertIn((EV_KEY, code_a, 1), self.history) self.assertIn((EV_KEY, code_a, 0), self.history) self.assertIn((EV_KEY, code_b, 1), self.history) self.assertIn((EV_KEY, code_b, 0), self.history) self.assertIn((EV_KEY, code_c, 1), self.history) self.assertIn((EV_KEY, code_c, 0), self.history) self.assertGreater(self.history.count((EV_KEY, code_b, 1)), 1) self.assertGreater(self.history.count((EV_KEY, code_b, 0)), 1) # it's stopped and won't write stuff anymore count_before = len(self.history) await asyncio.sleep(0.2) count_after = len(self.history) self.assertEqual(count_before, count_after) self.assertFalse(active_macros[(EV_KEY, 1)].is_holding()) self.assertFalse(active_macros[(EV_KEY, 1)].running) async def test_hold_2(self): # test irregular input patterns code_a, code_b, code_c, code_d = 100, 101, 102, 103 keycode_mapper = self.setup_keycode_mapper( {"a": code_a, "b": code_b, "c": code_c, "d": code_d}, { ((EV_KEY, 1, 1),): "h(k(b))", ((EV_KEY, 2, 1),): "k(c).r(1, r(1, r(1, h(k(a))))).k(d)", ((EV_KEY, 3, 1),): "h(k(b))", }, ) """start macro 2""" await keycode_mapper.notify(new_event(EV_KEY, 2, 1)) await asyncio.sleep(0.1) # starting code_c written self.assertEqual(self.history.count((EV_KEY, code_c, 1)), 1) self.assertEqual(self.history.count((EV_KEY, code_c, 0)), 1) # spam garbage events for _ in range(5): await keycode_mapper.notify(new_event(EV_KEY, 1, 1)) await keycode_mapper.notify(new_event(EV_KEY, 3, 1)) await asyncio.sleep(0.05) self.assertTrue(active_macros[(EV_KEY, 1)].is_holding()) self.assertTrue(active_macros[(EV_KEY, 1)].running) self.assertTrue(active_macros[(EV_KEY, 2)].is_holding()) self.assertTrue(active_macros[(EV_KEY, 2)].running) self.assertTrue(active_macros[(EV_KEY, 3)].is_holding()) self.assertTrue(active_macros[(EV_KEY, 3)].running) # there should only be one code_c in the events, because no key # up event was ever done so the hold just continued self.assertEqual(self.history.count((EV_KEY, code_c, 1)), 1) self.assertEqual(self.history.count((EV_KEY, code_c, 0)), 1) # without an key up event on 2, it won't write code_d self.assertNotIn((code_d, 1), self.history) self.assertNotIn((code_d, 0), self.history) # stop macro 2 await keycode_mapper.notify(new_event(EV_KEY, 2, 0)) await asyncio.sleep(0.1) # it stopped and didn't restart, so the count stays at 1 self.assertEqual(self.history.count((EV_KEY, code_c, 1)), 1) self.assertEqual(self.history.count((EV_KEY, code_c, 0)), 1) # and the trailing d was written self.assertEqual(self.history.count((EV_KEY, code_d, 1)), 1) self.assertEqual(self.history.count((EV_KEY, code_d, 0)), 1) # it's stopped and won't write stuff anymore count_before = self.history.count((EV_KEY, code_a, 1)) self.assertGreater(count_before, 1) await asyncio.sleep(0.1) count_after = self.history.count((EV_KEY, code_a, 1)) self.assertEqual(count_before, count_after) """restart macro 2""" self.history.clear() await keycode_mapper.notify(new_event(EV_KEY, 2, 1)) await asyncio.sleep(0.1) self.assertEqual(self.history.count((EV_KEY, code_c, 1)), 1) self.assertEqual(self.history.count((EV_KEY, code_c, 0)), 1) # spam garbage events again, this time key-up events on all other # macros for _ in range(5): await keycode_mapper.notify(new_event(EV_KEY, 1, 0)) await keycode_mapper.notify(new_event(EV_KEY, 3, 0)) await asyncio.sleep(0.05) self.assertFalse(active_macros[(EV_KEY, 1)].is_holding()) self.assertFalse(active_macros[(EV_KEY, 1)].running) self.assertTrue(active_macros[(EV_KEY, 2)].is_holding()) self.assertTrue(active_macros[(EV_KEY, 2)].running) self.assertFalse(active_macros[(EV_KEY, 3)].is_holding()) self.assertFalse(active_macros[(EV_KEY, 3)].running) # stop macro 2 await keycode_mapper.notify(new_event(EV_KEY, 2, 0)) await asyncio.sleep(0.1) # was started only once self.assertEqual(self.history.count((EV_KEY, code_c, 1)), 1) self.assertEqual(self.history.count((EV_KEY, code_c, 0)), 1) # and the trailing d was also written only once self.assertEqual(self.history.count((EV_KEY, code_d, 1)), 1) self.assertEqual(self.history.count((EV_KEY, code_d, 0)), 1) # stop all macros await keycode_mapper.notify(new_event(EV_KEY, 1, 0)) await keycode_mapper.notify(new_event(EV_KEY, 3, 0)) await asyncio.sleep(0.1) # it's stopped and won't write stuff anymore count_before = len(self.history) await asyncio.sleep(0.1) count_after = len(self.history) self.assertEqual(count_before, count_after) self.assertFalse(active_macros[(EV_KEY, 1)].is_holding()) self.assertFalse(active_macros[(EV_KEY, 1)].running) self.assertFalse(active_macros[(EV_KEY, 2)].is_holding()) self.assertFalse(active_macros[(EV_KEY, 2)].running) self.assertFalse(active_macros[(EV_KEY, 3)].is_holding()) self.assertFalse(active_macros[(EV_KEY, 3)].running) async def test_hold_3(self): # test irregular input patterns code_a, code_b, code_c = 100, 101, 102 keycode_mapper = self.setup_keycode_mapper( {"a": code_a, "b": code_b, "c": code_c}, {((EV_KEY, 1, 1),): "k(a).h(k(b)).k(c)"}, ) await keycode_mapper.notify(new_event(EV_KEY, 1, 1)) await asyncio.sleep(0.1) for _ in range(5): self.assertTrue(active_macros[(EV_KEY, 1)].is_holding()) self.assertTrue(active_macros[(EV_KEY, 1)].running) await keycode_mapper.notify(new_event(EV_KEY, 1, 1)) await asyncio.sleep(0.05) # duplicate key down events don't do anything self.assertEqual(self.history.count((EV_KEY, code_a, 1)), 1) self.assertEqual(self.history.count((EV_KEY, code_a, 0)), 1) self.assertEqual(self.history.count((EV_KEY, code_c, 1)), 0) self.assertEqual(self.history.count((EV_KEY, code_c, 0)), 0) # stop await keycode_mapper.notify(new_event(EV_KEY, 1, 0)) await asyncio.sleep(0.1) self.assertEqual(self.history.count((EV_KEY, code_a, 1)), 1) self.assertEqual(self.history.count((EV_KEY, code_a, 0)), 1) self.assertEqual(self.history.count((EV_KEY, code_c, 1)), 1) self.assertEqual(self.history.count((EV_KEY, code_c, 0)), 1) self.assertFalse(active_macros[(EV_KEY, 1)].is_holding()) self.assertFalse(active_macros[(EV_KEY, 1)].running) # it's stopped and won't write stuff anymore count_before = len(self.history) await asyncio.sleep(0.1) count_after = len(self.history) self.assertEqual(count_before, count_after) async def test_hold_two(self): # holding two macros at the same time, # the first one is triggered by a combination key_0 = (EV_KEY, 10) key_1 = (EV_KEY, 11) key_2 = (EV_ABS, ABS_HAT0X) down_0 = (*key_0, 1) down_1 = (*key_1, 1) down_2 = (*key_2, -1) up_0 = (*key_0, 0) up_1 = (*key_1, 0) up_2 = (*key_2, 0) code_1, code_2, code_3, code_a, code_b, code_c = 100, 101, 102, 103, 104, 105 keycode_mapper = self.setup_keycode_mapper( {1: code_1, 2: code_2, 3: code_3, "a": code_a, "b": code_b, "c": code_c}, { (down_0, down_1): "k(1).h(k(2)).k(3)", (down_2,): "k(a).h(k(b)).k(c)", }, ) # key up won't do anything await keycode_mapper.notify(new_event(*up_0)) await keycode_mapper.notify(new_event(*up_1)) await keycode_mapper.notify(new_event(*up_2)) await asyncio.sleep(0.1) self.assertEqual(len(active_macros), 0) """start macros""" uinput_2 = UInput() self.context.uinput = uinput_2 keycode_mapper = KeycodeMapper(self.context, self.source, uinput_2) keycode_mapper.macro_write = self.create_handler await keycode_mapper.notify(new_event(*down_0)) self.assertEqual(uinput_2.write_count, 1) await keycode_mapper.notify(new_event(*down_1)) await keycode_mapper.notify(new_event(*down_2)) self.assertEqual(uinput_2.write_count, 1) # let the mainloop run for some time so that the macro does its stuff sleeptime = 500 keystroke_sleep = config.get("macros.keystroke_sleep_ms", 10) await asyncio.sleep(sleeptime / 1000) # test that two macros are really running at the same time self.assertEqual(len(active_macros), 2) self.assertTrue(active_macros[key_1].is_holding()) self.assertTrue(active_macros[key_1].running) self.assertTrue(active_macros[key_2].is_holding()) self.assertTrue(active_macros[key_2].running) self.assertIn(down_0[:2], unreleased) self.assertIn(down_1[:2], unreleased) self.assertIn(down_2[:2], unreleased) """stop macros""" keycode_mapper = KeycodeMapper(self.context, self.source, None) # releasing the last key of a combination releases the whole macro await keycode_mapper.notify(new_event(*up_1)) await keycode_mapper.notify(new_event(*up_2)) self.assertIn(down_0[:2], unreleased) self.assertNotIn(down_1[:2], unreleased) self.assertNotIn(down_2[:2], unreleased) await asyncio.sleep(keystroke_sleep * 10 / 1000) self.assertFalse(active_macros[key_1].is_holding()) self.assertFalse(active_macros[key_1].running) self.assertFalse(active_macros[key_2].is_holding()) self.assertFalse(active_macros[key_2].running) events = calculate_event_number(sleeptime, 1, 1) * 2 self.assertGreater(len(self.history), events * 0.9) self.assertLess(len(self.history), events * 1.1) self.assertIn((EV_KEY, code_a, 1), self.history) self.assertIn((EV_KEY, code_a, 0), self.history) self.assertIn((EV_KEY, code_b, 1), self.history) self.assertIn((EV_KEY, code_b, 0), self.history) self.assertIn((EV_KEY, code_c, 1), self.history) self.assertIn((EV_KEY, code_c, 0), self.history) self.assertIn((EV_KEY, code_1, 1), self.history) self.assertIn((EV_KEY, code_1, 0), self.history) self.assertIn((EV_KEY, code_2, 1), self.history) self.assertIn((EV_KEY, code_2, 0), self.history) self.assertIn((EV_KEY, code_3, 1), self.history) self.assertIn((EV_KEY, code_3, 0), self.history) self.assertGreater(self.history.count((EV_KEY, code_b, 1)), 1) self.assertGreater(self.history.count((EV_KEY, code_b, 0)), 1) self.assertGreater(self.history.count((EV_KEY, code_2, 1)), 1) self.assertGreater(self.history.count((EV_KEY, code_2, 0)), 1) # it's stopped and won't write stuff anymore count_before = len(self.history) await asyncio.sleep(0.2) count_after = len(self.history) self.assertEqual(count_before, count_after) async def test_filter_trigger_spam(self): # test_filter_duplicates trigger = (EV_KEY, BTN_TL) _key_to_code = { ((*trigger, 1),): (51, "keyboard"), ((*trigger, -1),): (52, "keyboard"), } uinput = UInput() self.context.uinput = uinput self.context.key_to_code = _key_to_code keycode_mapper = KeycodeMapper(self.context, self.source, uinput) """positive""" for _ in range(1, 20): await keycode_mapper.notify(new_event(*trigger, 1)) self.assertIn(trigger, unreleased) await keycode_mapper.notify(new_event(*trigger, 0)) self.assertNotIn(trigger, unreleased) self.assertEqual(len(uinput_write_history), 2) """negative""" for _ in range(1, 20): await keycode_mapper.notify(new_event(*trigger, -1)) self.assertIn(trigger, unreleased) await keycode_mapper.notify(new_event(*trigger, 0)) self.assertNotIn(trigger, unreleased) self.assertEqual(len(uinput_write_history), 4) self.assertEqual(uinput_write_history[0].t, (EV_KEY, 51, 1)) self.assertEqual(uinput_write_history[1].t, (EV_KEY, 51, 0)) self.assertEqual(uinput_write_history[2].t, (EV_KEY, 52, 1)) self.assertEqual(uinput_write_history[3].t, (EV_KEY, 52, 0)) 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. key = (EV_KEY, KEY_A) ev_1 = (*key, 1) ev_2 = (*key, 2) ev_3 = (*key, 0) _key_to_code = { ((*key, 1),): (21, "keyboard"), } uinput = UInput() self.context.uinput = uinput self.context.key_to_code = _key_to_code keycode_mapper = KeycodeMapper(self.context, self.source, uinput) await keycode_mapper.notify(new_event(*ev_1)) for _ in range(10): await keycode_mapper.notify(new_event(*ev_2)) self.assertIn(key, unreleased) await keycode_mapper.notify(new_event(*ev_3)) self.assertNotIn(key, unreleased) self.assertEqual(len(uinput_write_history), 2) self.assertEqual(uinput_write_history[0].t, (EV_KEY, 21, 1)) self.assertEqual(uinput_write_history[1].t, (EV_KEY, 21, 0)) async def test_ignore_disabled(self): ev_1 = (EV_ABS, ABS_HAT0Y, 1) ev_2 = (EV_ABS, ABS_HAT0Y, 0) ev_3 = (EV_ABS, ABS_HAT0X, 1) # disabled ev_4 = (EV_ABS, ABS_HAT0X, 0) ev_5 = (EV_KEY, KEY_A, 1) ev_6 = (EV_KEY, KEY_A, 0) combi_1 = (ev_5, ev_3) combi_2 = (ev_3, ev_5) _key_to_code = { (ev_1,): (61, "keyboard"), (ev_3,): (DISABLE_CODE, "keyboard"), combi_1: (62, "keyboard"), combi_2: (63, "keyboard"), } forward_to = UInput() self.context.key_to_code = _key_to_code keycode_mapper = KeycodeMapper(self.context, self.source, forward_to) def expect_writecounts(uinput_count, forwarded_count): self.assertEqual( global_uinputs.devices["keyboard"].write_count, uinput_count ) self.assertEqual(forward_to.write_count, forwarded_count) """single keys""" # down await keycode_mapper.notify(new_event(*ev_1)) await keycode_mapper.notify(new_event(*ev_3)) self.assertIn(ev_1[:2], unreleased) self.assertIn(ev_3[:2], unreleased) expect_writecounts(1, 0) # up await keycode_mapper.notify(new_event(*ev_2)) await keycode_mapper.notify(new_event(*ev_4)) expect_writecounts(2, 0) self.assertNotIn(ev_1[:2], unreleased) self.assertNotIn(ev_3[:2], unreleased) self.assertEqual(len(uinput_write_history), 2) self.assertEqual(uinput_write_history[0].t, (EV_KEY, 61, 1)) self.assertEqual(uinput_write_history[1].t, (EV_KEY, 61, 0)) """a combination that ends in a disabled key""" # ev_5 should be forwarded and the combination triggered await keycode_mapper.notify(new_event(*combi_1[0])) # ev_5 await keycode_mapper.notify(new_event(*combi_1[1])) # ev_3 expect_writecounts(3, 1) self.assertEqual(len(uinput_write_history), 4) self.assertEqual(uinput_write_history[2].t, (EV_KEY, KEY_A, 1)) self.assertEqual(uinput_write_history[3].t, (EV_KEY, 62, 1)) self.assertIn(combi_1[0][:2], unreleased) self.assertIn(combi_1[1][:2], unreleased) # since this event did not trigger anything, key is None self.assertEqual(unreleased[combi_1[0][:2]].triggered_key, None) # that one triggered something from _key_to_code, so the key is that self.assertEqual(unreleased[combi_1[1][:2]].triggered_key, combi_1) # release the last key of the combi first, it should # release what the combination maps to event = new_event(combi_1[1][0], combi_1[1][1], 0) await keycode_mapper.notify(event) expect_writecounts(4, 1) self.assertEqual(len(uinput_write_history), 5) self.assertEqual(uinput_write_history[-1].t, (EV_KEY, 62, 0)) self.assertIn(combi_1[0][:2], unreleased) self.assertNotIn(combi_1[1][:2], unreleased) event = new_event(combi_1[0][0], combi_1[0][1], 0) await keycode_mapper.notify(event) expect_writecounts(4, 2) self.assertEqual(len(uinput_write_history), 6) self.assertEqual(uinput_write_history[-1].t, (EV_KEY, KEY_A, 0)) self.assertNotIn(combi_1[0][:2], unreleased) self.assertNotIn(combi_1[1][:2], unreleased) """a combination that starts with a disabled key""" # only the combination should get triggered await keycode_mapper.notify(new_event(*combi_2[0])) await keycode_mapper.notify(new_event(*combi_2[1])) expect_writecounts(5, 2) self.assertEqual(len(uinput_write_history), 7) self.assertEqual(uinput_write_history[-1].t, (EV_KEY, 63, 1)) # release the last key of the combi first, it should # release what the combination maps to event = new_event(combi_2[1][0], combi_2[1][1], 0) await keycode_mapper.notify(event) self.assertEqual(len(uinput_write_history), 8) self.assertEqual(uinput_write_history[-1].t, (EV_KEY, 63, 0)) expect_writecounts(6, 2) # the first key of combi_2 is disabled, so it won't write another # key-up event event = new_event(combi_2[0][0], combi_2[0][1], 0) await keycode_mapper.notify(event) self.assertEqual(len(uinput_write_history), 8) expect_writecounts(6, 2) 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_HAT1X, 1) down_2 = (EV_ABS, ABS_HAT1Y, -1) up_1 = (EV_ABS, ABS_HAT1X, 0) up_2 = (EV_ABS, ABS_HAT1Y, 0) keycode_mapper = self.setup_keycode_mapper({"a": 92}, {(down_1,): "h(k(a))"}) _key_to_code = {(down_1, down_2): (91, "keyboard")} self.context.key_to_code = _key_to_code # macro starts await keycode_mapper.notify(new_event(*down_1)) await asyncio.sleep(0.05) self.assertEqual(len(uinput_write_history), 0) self.assertGreater(len(self.history), 1) self.assertIn(down_1[:2], unreleased) self.assertIn((EV_KEY, 92, 1), self.history) # combination triggered await keycode_mapper.notify(new_event(*down_2)) self.assertIn(down_1[:2], unreleased) self.assertIn(down_2[:2], unreleased) self.assertEqual(uinput_write_history[0].t, (EV_KEY, 91, 1)) len_a = len(self.history) await asyncio.sleep(0.05) len_b = len(self.history) # still running self.assertGreater(len_b, len_a) # release await keycode_mapper.notify(new_event(*up_1)) self.assertNotIn(down_1[:2], unreleased) self.assertIn(down_2[:2], unreleased) await asyncio.sleep(0.05) len_c = len(self.history) await asyncio.sleep(0.05) len_d = len(self.history) # not running anymore self.assertEqual(len_c, len_d) await keycode_mapper.notify(new_event(*up_2)) self.assertEqual(uinput_write_history[1].t, (EV_KEY, 91, 0)) self.assertEqual(len(uinput_write_history), 2) self.assertNotIn(down_1[:2], unreleased) self.assertNotIn(down_2[:2], unreleased) 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 scroll = (2, 8, -1) scroll_release = (2, 8, 0) btn_down = (1, 276, 1) btn_up = (1, 276, 0) combination = ((1, 276, 1), (2, 8, -1)) system_mapping.clear() system_mapping._set("a", 30) k2c = {combination: (30, "keyboard")} uinput = UInput() self.context.uinput = uinput self.context.key_to_code = k2c keycode_mapper = KeycodeMapper(self.context, self.source, uinput) await keycode_mapper.notify(new_event(*btn_down)) # "forwarding" self.assertEqual(uinput_write_history[0].t, btn_down) await keycode_mapper.notify(new_event(*scroll)) # "maps to 30" self.assertEqual(uinput_write_history[1].t, (1, 30, 1)) for _ in range(5): # keep scrolling # "duplicate key down" await keycode_mapper.notify(new_event(*scroll)) # nothing new since all of them were duplicate key downs self.assertEqual(len(uinput_write_history), 2) await keycode_mapper.notify(new_event(*btn_up)) # "forwarding release" self.assertEqual(uinput_write_history[2].t, btn_up) # one more scroll event. since the combination is still not released, # it should be ignored as duplicate key-down self.assertEqual(len(uinput_write_history), 3) # "forwarding" (should be "duplicate key down") await keycode_mapper.notify(new_event(*scroll)) self.assertEqual(len(uinput_write_history), 3) # the failure to release the mapped key # forward=False is what the debouncer uses, because a # "scroll release" doesn't actually exist so it is not actually # written if it doesn't release any mapping keycode_mapper.handle_keycode( new_event(*scroll_release), RELEASE, forward=False ) # 30 should be released self.assertEqual(uinput_write_history[3].t, (1, 30, 0)) self.assertEqual(len(uinput_write_history), 4) async def test_debounce_1(self): tick_time = 1 / 60 self.history = [] keycode_mapper = KeycodeMapper(self.context, self.source) keycode_mapper.debounce(1234, self.history.append, (1,), 10) asyncio.ensure_future(keycode_mapper.run()) # run alongside the test await asyncio.sleep(6 * tick_time) self.assertEqual(len(self.history), 0) await asyncio.sleep(6 * tick_time) self.assertEqual(len(self.history), 1) # won't get called a second time await asyncio.sleep(12 * tick_time) self.assertEqual(len(self.history), 1) self.assertEqual(self.history[0], 1) async def test_debounce_2(self): tick_time = 1 / 60 self.history = [] keycode_mapper = KeycodeMapper(self.context, self.source) keycode_mapper.debounce(1234, self.history.append, ("first",), 10) asyncio.ensure_future(keycode_mapper.run()) # run alongside the test await asyncio.sleep(6 * tick_time) self.assertEqual(len(self.history), 0) # replaces keycode_mapper.debounce(1234, self.history.append, ("second",), 20) await asyncio.sleep(5 * tick_time) self.assertEqual(len(self.history), 0) await asyncio.sleep(17 * tick_time) self.assertEqual(len(self.history), 1) self.assertEqual(self.history[0], "second") # won't get called a second time await asyncio.sleep(22 * tick_time) self.assertEqual(len(self.history), 1) self.assertEqual(self.history[0], "second") async def test_debounce_3(self): tick_time = 1 / 60 self.history = [] keycode_mapper = KeycodeMapper(self.context, self.source) keycode_mapper.debounce(1234, self.history.append, (1,), 10) keycode_mapper.debounce(5678, self.history.append, (2,), 20) asyncio.ensure_future(keycode_mapper.run()) # run alongside the test await asyncio.sleep(12 * tick_time) self.assertEqual(len(self.history), 1) await asyncio.sleep(12 * tick_time) self.assertEqual(len(self.history), 2) await asyncio.sleep(22 * tick_time) self.assertEqual(len(self.history), 2) self.assertEqual(self.history[0], 1) self.assertEqual(self.history[1], 2) async def test_can_not_map(self): """inject events to wrong or invalid uinput""" ev_1 = (EV_KEY, KEY_A, 1) ev_2 = (EV_KEY, KEY_B, 1) ev_3 = (EV_KEY, KEY_C, 1) ev_4 = (EV_KEY, KEY_A, 0) ev_5 = (EV_KEY, KEY_B, 0) ev_6 = (EV_KEY, KEY_C, 0) self.context.key_to_code = { (ev_1,): (51, "foo"), # invalid (ev_2,): (BTN_TL, "keyboard"), # invalid (ev_3,): (KEY_A, "keyboard"), # valid } keyboard = global_uinputs.get_uinput("keyboard") forward = UInput() keycode_mapper = KeycodeMapper(self.context, self.source, forward) # send key-down await keycode_mapper.notify(new_event(*ev_1)) await keycode_mapper.notify(new_event(*ev_2)) await keycode_mapper.notify(new_event(*ev_3)) self.assertEqual(len(unreleased), 3) # send key-up await keycode_mapper.notify(new_event(*ev_4)) await keycode_mapper.notify(new_event(*ev_5)) await keycode_mapper.notify(new_event(*ev_6)) # all key down and key up events get forwarded self.assertEqual(forward.write_count, 4) self.assertEqual(keyboard.write_count, 2) forward_history = [event.t for event in forward.write_history] self.assertIn(ev_1, forward_history) self.assertIn(ev_2, forward_history) self.assertIn(ev_4, forward_history) self.assertIn(ev_5, forward_history) self.assertNotIn(ev_3, forward_history) self.assertNotIn(ev_6, forward_history) keyboard_history = [event.t for event in keyboard.write_history] self.assertIn((EV_KEY, KEY_A, 1), keyboard_history) self.assertIn((EV_KEY, KEY_A, 0), keyboard_history) if __name__ == "__main__": unittest.main() input-remapper-1.4.0/tests/unit/test_logger.py000066400000000000000000000113441417303655400215100ustar00rootroot00000000000000#!/usr/bin/python3 # -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings # Copyright (C) 2022 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 shutil import unittest import logging from inputremapper.logger import logger, add_filehandler, update_verbosity, log_info from inputremapper.paths import remove from tests.test import tmp class TestLogger(unittest.TestCase): def tearDown(self): 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") remove(path) def test_key_debug(self): path = os.path.join(tmp, "logger-test") add_filehandler(path) logger.debug_key(((1, 2, 1),), "foo %s bar", 1234) logger.debug_key(((1, 200, -1), (1, 5, 1)), "foo %s", (1, 2)) with open(path, "r") as f: content = f.read().lower() self.assertIn("((1, 2, 1)) ------------------- foo 1234 bar", content) self.assertIn("((1, 200, -1), (1, 5, 1)) ----- foo (1, 2)", content) def test_log_info(self): update_verbosity(debug=False) path = os.path.join(tmp, "logger-test") add_filehandler(path) 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) self.assertTrue(os.path.exists(new_path)) def test_clears_log(self): path = os.path.join(tmp, "logger-test") os.makedirs(os.path.dirname(path), exist_ok=True) os.mknod(path) with open(path, "w") as f: f.write("aaaa\n" * 2000 + "end") add_filehandler(os.path.join(tmp, "logger-test")) with open(path, "r") as f: # it only keeps the newest information content = f.readlines() self.assertLess(len(content), 1100) # whatever the logging module decides to log into that file self.assertNotIn("aaaa", content[-1]) def test_debug(self): path = os.path.join(tmp, "logger-test") add_filehandler(path) 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") update_verbosity(debug=False) add_filehandler(path) 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() input-remapper-1.4.0/tests/unit/test_macros.py000066400000000000000000001506331417303655400215220ustar00rootroot00000000000000#!/usr/bin/python3 # -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings # Copyright (C) 2022 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 re import asyncio import multiprocessing from unittest import mock from evdev.ecodes import ( EV_REL, EV_KEY, REL_Y, REL_X, REL_WHEEL, REL_HWHEEL, KEY_A, KEY_B, KEY_C, KEY_E, ) from inputremapper.injection.macros.macro import ( Macro, _type_check, macro_variables, _type_check_variablename, _resolve, Variable, ) from inputremapper.injection.macros.parse import ( parse, _extract_args, is_this_a_macro, _parse_recurse, handle_plus_syntax, _count_brackets, _split_keyword_arg, remove_whitespaces, remove_comments, ) from inputremapper.injection.context import Context from inputremapper.config import config from inputremapper.mapping import Mapping from inputremapper.system_mapping import system_mapping from inputremapper.utils import PRESS, RELEASE from tests.test import quick_cleanup, new_event class MacroTestBase(unittest.IsolatedAsyncioTestCase): def setUp(self): self.result = [] 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.context = Context(Mapping()) def tearDown(self): self.result = [] self.context.mapping.clear_config() quick_cleanup() def handler(self, ev_type, code, value): """Where macros should write codes to.""" print(f"\033[90mmacro wrote{(ev_type, code, value)}\033[0m") self.result.append((ev_type, code, value)) class TestMacros(MacroTestBase): async def test_named_parameter(self): result = [] def patch(_, a, b, c, d=400): result.append((a, b, c, d)) functions = {"k": patch} with mock.patch("inputremapper.injection.macros.parse.FUNCTIONS", functions): await parse("k(1, d=4, b=2, c=3)", self.context).run(self.handler) await parse("k(1, b=2, c=3)", self.context).run(self.handler) self.assertListEqual(result, [(1, 2, 3, 4), (1, 2, 3, 400)]) def testremove_whitespaces(self): self.assertEqual(remove_whitespaces('foo"bar"foo'), 'foo"bar"foo') self.assertEqual(remove_whitespaces('foo" bar"foo'), 'foo" bar"foo') self.assertEqual(remove_whitespaces('foo" bar"fo" "o'), 'foo" bar"fo" "o') self.assertEqual(remove_whitespaces(' fo o"\nba r "f\noo'), 'foo"\nba r "foo') self.assertEqual(remove_whitespaces(' a " b " c " '), 'a" b "c" ') self.assertEqual(remove_whitespaces('"""""""""'), '"""""""""') self.assertEqual(remove_whitespaces('""""""""'), '""""""""') self.assertEqual(remove_whitespaces(" "), "") self.assertEqual(remove_whitespaces(' " '), '" ') self.assertEqual(remove_whitespaces(' " " '), '" "') self.assertEqual(remove_whitespaces("a# ##b", delimiter="##"), "a###b") self.assertEqual(remove_whitespaces("a###b", delimiter="##"), "a###b") self.assertEqual(remove_whitespaces("a## #b", delimiter="##"), "a## #b") self.assertEqual(remove_whitespaces("a## ##b", delimiter="##"), "a## ##b") def testremove_comments(self): self.assertEqual(remove_comments("a#b"), "a") self.assertEqual(remove_comments('"a#b"'), '"a#b"') self.assertEqual(remove_comments('a"#"#b'), 'a"#"') self.assertEqual(remove_comments('a"#""#"#b'), 'a"#""#"') self.assertEqual(remove_comments('#a"#""#"#b'), "") self.assertEqual( re.sub( r"\s", "", remove_comments( """ # a b # c d """ ), ), "bd", ) async def test_count_brackets(self): self.assertEqual(_count_brackets(""), 0) self.assertEqual(_count_brackets("()"), 2) self.assertEqual(_count_brackets("a()"), 3) self.assertEqual(_count_brackets("a(b)"), 4) self.assertEqual(_count_brackets("a(b())"), 6) self.assertEqual(_count_brackets("a(b(c))"), 7) self.assertEqual(_count_brackets("a(b(c))d"), 7) self.assertEqual(_count_brackets("a(b(c))d()"), 7) def test_resolve(self): self.assertEqual(_resolve("a"), "a") self.assertEqual(_resolve(1), 1) self.assertEqual(_resolve(None), None) # $ is part of a custom string here self.assertEqual(_resolve('"$a"'), '"$a"') self.assertEqual(_resolve("'$a'"), "'$a'") # variables are expected to be of the Variable type here, not a $string self.assertEqual(_resolve("$a"), "$a") variable = Variable("a") self.assertEqual(_resolve(variable), None) macro_variables["a"] = 1 self.assertEqual(_resolve(variable), 1) def test_type_check(self): # allows params that can be cast to the target type self.assertEqual(_type_check(1, [str, None], "foo", 0), "1") self.assertEqual(_type_check("1", [int, None], "foo", 1), 1) self.assertEqual(_type_check(1.2, [str], "foo", 2), "1.2") self.assertRaises(TypeError, lambda: _type_check("1.2", [int], "foo", 3)) self.assertRaises(TypeError, lambda: _type_check("a", [None], "foo", 0)) self.assertRaises(TypeError, lambda: _type_check("a", [int], "foo", 1)) self.assertRaises(TypeError, lambda: _type_check("a", [int, float], "foo", 2)) self.assertRaises(TypeError, lambda: _type_check("a", [int, None], "foo", 3)) self.assertEqual(_type_check("a", [int, float, None, str], "foo", 4), "a") # variables are expected to be of the Variable type here, not a $string self.assertRaises(TypeError, lambda: _type_check("$a", [int], "foo", 4)) variable = Variable("a") self.assertEqual(_type_check(variable, [int], "foo", 4), variable) self.assertRaises(TypeError, lambda: _type_check("a", [Macro], "foo", 0)) self.assertRaises(TypeError, lambda: _type_check(1, [Macro], "foo", 0)) self.assertEqual(_type_check("1", [Macro, int], "foo", 4), 1) def test_type_check_variablename(self): self.assertRaises(SyntaxError, lambda: _type_check_variablename("1a")) self.assertRaises(SyntaxError, lambda: _type_check_variablename("$a")) self.assertRaises(SyntaxError, lambda: _type_check_variablename("a()")) self.assertRaises(SyntaxError, lambda: _type_check_variablename("1")) self.assertRaises(SyntaxError, lambda: _type_check_variablename("+")) self.assertRaises(SyntaxError, lambda: _type_check_variablename("-")) self.assertRaises(SyntaxError, lambda: _type_check_variablename("*")) self.assertRaises(SyntaxError, lambda: _type_check_variablename("a,b")) self.assertRaises(SyntaxError, lambda: _type_check_variablename("a,b")) self.assertRaises(SyntaxError, lambda: _type_check_variablename("#")) self.assertRaises(SyntaxError, lambda: _type_check_variablename(1)) self.assertRaises(SyntaxError, lambda: _type_check_variablename(None)) self.assertRaises(SyntaxError, lambda: _type_check_variablename([])) self.assertRaises(SyntaxError, lambda: _type_check_variablename(())) # doesn't raise _type_check_variablename("a") _type_check_variablename("_a") _type_check_variablename("_A") _type_check_variablename("A") _type_check_variablename("Abcd") _type_check_variablename("Abcd_") _type_check_variablename("Abcd_1234") _type_check_variablename("Abcd1234_") def test_split_keyword_arg(self): self.assertTupleEqual(_split_keyword_arg("_A=b"), ("_A", "b")) self.assertTupleEqual(_split_keyword_arg("a_=1"), ("a_", "1")) self.assertTupleEqual(_split_keyword_arg("a=r(2, KEY_A)"), ("a", "r(2, KEY_A)")) self.assertTupleEqual(_split_keyword_arg('a="=,#+."'), ("a", '"=,#+."')) async def test_is_this_a_macro(self): self.assertTrue(is_this_a_macro("k(1)")) self.assertTrue(is_this_a_macro("k(1).k(2)")) self.assertTrue(is_this_a_macro("r(1, k(1).k(2))")) self.assertFalse(is_this_a_macro("1")) self.assertFalse(is_this_a_macro("key_kp1")) self.assertFalse(is_this_a_macro("btn_left")) self.assertFalse(is_this_a_macro("minus")) self.assertFalse(is_this_a_macro("k")) self.assertFalse(is_this_a_macro(1)) self.assertFalse(is_this_a_macro(None)) self.assertTrue(is_this_a_macro("a+b")) self.assertTrue(is_this_a_macro("a+b+c")) self.assertTrue(is_this_a_macro("a + b")) self.assertTrue(is_this_a_macro("a + b + c")) async def test_handle_plus_syntax(self): self.assertEqual(handle_plus_syntax("a + b"), "m(a,m(b,h()))") self.assertEqual(handle_plus_syntax("a + b + c"), "m(a,m(b,m(c,h())))") self.assertEqual(handle_plus_syntax(" a+b+c "), "m(a,m(b,m(c,h())))") # invalid strings = ["+", "a+", "+b", "k(a + b)"] for s in strings: with self.assertRaises(ValueError): print(f"testing '{s}'") handle_plus_syntax(s) self.assertEqual(handle_plus_syntax("a"), "a") self.assertEqual(handle_plus_syntax("k(a)"), "k(a)") self.assertEqual(handle_plus_syntax(""), "") async def test_run_plus_syntax(self): macro = parse("a + b + c + d", self.context) self.assertSetEqual( macro.get_capabilities()[EV_KEY], { system_mapping.get("a"), system_mapping.get("b"), system_mapping.get("c"), system_mapping.get("d"), }, ) macro.press_trigger() asyncio.ensure_future(macro.run(self.handler)) await asyncio.sleep(0.2) self.assertTrue(macro.is_holding()) # starting from the left, presses each one down self.assertEqual(self.result[0], (EV_KEY, system_mapping.get("a"), 1)) self.assertEqual(self.result[1], (EV_KEY, system_mapping.get("b"), 1)) self.assertEqual(self.result[2], (EV_KEY, system_mapping.get("c"), 1)) self.assertEqual(self.result[3], (EV_KEY, system_mapping.get("d"), 1)) # and then releases starting with the previously pressed key macro.release_trigger() await asyncio.sleep(0.2) self.assertFalse(macro.is_holding()) self.assertEqual(self.result[4], (EV_KEY, system_mapping.get("d"), 0)) self.assertEqual(self.result[5], (EV_KEY, system_mapping.get("c"), 0)) self.assertEqual(self.result[6], (EV_KEY, system_mapping.get("b"), 0)) self.assertEqual(self.result[7], (EV_KEY, system_mapping.get("a"), 0)) async def test_extract_params(self): # splits strings, doesn't try to understand their meaning yet def expect(raw, expectation): self.assertListEqual(_extract_args(raw), expectation) expect("a", ["a"]) expect("a,b", ["a", "b"]) expect("a,b,c", ["a", "b", "c"]) expect("k(a)", ["k(a)"]) expect("k(a).k(b), k(a)", ["k(a).k(b)", "k(a)"]) expect("k(a), k(a).k(b)", ["k(a)", "k(a).k(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("r(1, k(a))", ["r(1, k(a))"]) expect("r(1, k(a)), r(1, k(b))", ["r(1, k(a))", "r(1, k(b))"]) expect( "r(1, k(a)), r(1, k(b)), r(1, k(c))", ["r(1, k(a))", "r(1, k(b))", "r(1, k(c))"], ) # will be parsed as None expect("", [""]) expect(",", ["", ""]) expect(",,", ["", "", ""]) async def test_parse_params(self): self.assertEqual(_parse_recurse("", self.context), None) # strings. If it is wrapped in quotes, don't parse the contents self.assertEqual(_parse_recurse('"foo"', self.context), "foo") self.assertEqual(_parse_recurse('"\tf o o\n"', self.context), "\tf o o\n") self.assertEqual(_parse_recurse('"foo(a,b)"', self.context), "foo(a,b)") self.assertEqual(_parse_recurse('",,,()"', self.context), ",,,()") # 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(_parse_recurse("foo", self.context), "foo") self.assertEqual(_parse_recurse("5", self.context), 5) self.assertEqual(_parse_recurse("5.2", self.context), 5.2) self.assertIsInstance(_parse_recurse("$foo", self.context), Variable) self.assertEqual(_parse_recurse("$foo", self.context).name, "foo") async def test_fails(self): self.assertIsNone(parse("r(1, a)", self.context)) self.assertIsNone(parse("r(a, k(b))", self.context)) self.assertIsNone(parse("m(a, b)", self.context)) async def test_0(self): macro = parse("k(1)", self.context) one_code = system_mapping.get("1") self.assertSetEqual(macro.get_capabilities()[EV_KEY], {one_code}) self.assertSetEqual(macro.get_capabilities()[EV_REL], set()) await macro.run(self.handler) self.assertListEqual( self.result, [(EV_KEY, one_code, 1), (EV_KEY, one_code, 0)] ) self.assertEqual(len(macro.child_macros), 0) async def test_1(self): macro = parse('k(1).k("KEY_A").k(3)', self.context) self.assertSetEqual( macro.get_capabilities()[EV_KEY], {system_mapping.get("1"), system_mapping.get("a"), system_mapping.get("3")}, ) await macro.run(self.handler) self.assertListEqual( self.result, [ (EV_KEY, system_mapping.get("1"), 1), (EV_KEY, system_mapping.get("1"), 0), (EV_KEY, system_mapping.get("a"), 1), (EV_KEY, system_mapping.get("a"), 0), (EV_KEY, system_mapping.get("3"), 1), (EV_KEY, system_mapping.get("3"), 0), ], ) self.assertEqual(len(macro.child_macros), 0) async def test_return_errors(self): error = parse("k(1).h(k(a)).k(3)", self.context, True) self.assertIsNone(error) error = parse("k(1))", self.context, True) self.assertIn("bracket", error) error = parse("key((1)", self.context, True) self.assertIn("bracket", error) error = parse("k((1).k)", self.context, True) self.assertIsNotNone(error) error = parse("k()", self.context, True) self.assertIsNotNone(error) error = parse("key(1)", self.context, True) self.assertIsNone(error) error = parse("k(1, 1)", self.context, True) self.assertIsNotNone(error) error = parse("key($a)", self.context, True) self.assertIsNone(error) error = parse("h(1, 1)", self.context, True) self.assertIsNotNone(error) error = parse("h(hold(h(1, 1)))", self.context, True) self.assertIsNotNone(error) error = parse("r(1)", self.context, True) self.assertIsNotNone(error) error = parse("repeat(a, k(1))", self.context, True) self.assertIsNotNone(error) error = parse("repeat($a, k(1))", self.context, True) self.assertIsNone(error) error = parse("r(1, 1)", self.context, True) self.assertIsNotNone(error) error = parse("r(k(1), 1)", self.context, True) self.assertIsNotNone(error) error = parse("r(1, macro=k(1))", self.context, True) self.assertIsNone(error) error = parse("r(a=1, b=k(1))", self.context, True) self.assertIsNotNone(error) error = parse("r(repeats=1, macro=k(1), a=2)", self.context, True) self.assertIsNotNone(error) error = parse("r(repeats=1, macro=k(1), repeats=2)", self.context, True) self.assertIsNotNone(error) error = parse("modify(asdf, k(a))", self.context, True) self.assertIsNotNone(error) error = parse("if_tap(, k(a), 1000)", self.context, True) self.assertIsNone(error) error = parse("if_tap(, k(a), timeout=1000)", self.context, True) self.assertIsNone(error) error = parse("if_tap(, k(a), $timeout)", self.context, True) self.assertIsNone(error) error = parse("if_tap(, k(a), timeout=$t)", self.context, True) self.assertIsNone(error) error = parse("if_tap(, key(a))", self.context, True) self.assertIsNone(error) error = parse("if_tap(k(a),)", self.context, True) self.assertIsNone(error) error = parse("if_tap(k(a), b)", self.context, True) self.assertIsNotNone(error) error = parse("if_single(k(a),)", self.context, True) self.assertIsNone(error) error = parse("if_single(1,)", self.context, True) self.assertIsNotNone(error) error = parse("if_single(,1)", self.context, True) self.assertIsNotNone(error) error = parse("mouse(up, 3)", self.context, True) self.assertIsNone(error) error = parse("mouse(up, speed=$a)", self.context, True) self.assertIsNone(error) error = parse("mouse(3, up)", self.context, True) self.assertIsNotNone(error) error = parse("wheel(left, 3)", self.context, True) self.assertIsNone(error) error = parse("wheel(3, left)", self.context, True) self.assertIsNotNone(error) error = parse("w(2)", self.context, True) self.assertIsNone(error) error = parse("wait(a)", self.context, True) self.assertIsNotNone(error) error = parse("ifeq(a, 2, k(a),)", self.context, True) self.assertIsNone(error) error = parse("ifeq(a, 2, , k(a))", self.context, True) self.assertIsNone(error) error = parse("ifeq(a, 2, 1,)", self.context, True) self.assertIsNotNone(error) error = parse("ifeq(a, 2, , 2)", self.context, True) self.assertIsNotNone(error) error = parse("if_eq(2, $a, k(a),)", self.context, True) self.assertIsNone(error) error = parse("if_eq(2, $a, , else=k(a))", self.context, True) self.assertIsNone(error) error = parse("if_eq(2, $a, 1,)", self.context, True) self.assertIsNotNone(error) error = parse("if_eq(2, $a, , 2)", self.context, True) self.assertIsNotNone(error) error = parse("foo(a)", self.context, True) self.assertIn("unknown", error.lower()) self.assertIn("foo", error) error = parse("set($a, 1)", self.context, True) self.assertIsNotNone(error) error = parse("set(1, 2)", self.context, True) self.assertIsNotNone(error) error = parse("set(+, 2)", self.context, True) self.assertIsNotNone(error) error = parse("set(a(), 2)", self.context, True) self.assertIsNotNone(error) error = parse("set('b,c', 2)", self.context, True) self.assertIsNotNone(error) error = parse('set("b,c", 2)', self.context, True) self.assertIsNotNone(error) error = parse("set(A, 2)", self.context, True) self.assertIsNone(error) async def test_hold(self): # repeats k(a) as long as the key is held down macro = parse("k(1).h(k(a)).k(3)", self.context) self.assertSetEqual( macro.get_capabilities()[EV_KEY], {system_mapping.get("1"), system_mapping.get("a"), system_mapping.get("3")}, ) """down""" macro.press_trigger() await (asyncio.sleep(0.05)) self.assertTrue(macro.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.is_holding()) self.assertGreater(len(self.result), 2) """up""" macro.release_trigger() await (asyncio.sleep(0.05)) self.assertFalse(macro.is_holding()) self.assertEqual(self.result[0], (EV_KEY, system_mapping.get("1"), 1)) self.assertEqual(self.result[-1], (EV_KEY, system_mapping.get("3"), 0)) code_a = system_mapping.get("a") self.assertGreater(self.result.count((EV_KEY, code_a, 1)), 2) self.assertEqual(len(macro.child_macros), 1) async def test_dont_hold(self): macro = parse("k(1).h(k(a)).k(3)", self.context) self.assertSetEqual( macro.get_capabilities()[EV_KEY], {system_mapping.get("1"), system_mapping.get("a"), system_mapping.get("3")}, ) asyncio.ensure_future(macro.run(self.handler)) await asyncio.sleep(0.2) self.assertFalse(macro.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, system_mapping.get("1"), 1)) self.assertEqual(self.result[-1], (EV_KEY, system_mapping.get("3"), 0)) self.assertEqual(len(macro.child_macros), 1) async def test_just_hold(self): macro = parse("k(1).h().k(3)", self.context) self.assertSetEqual( macro.get_capabilities()[EV_KEY], {system_mapping.get("1"), system_mapping.get("3")}, ) """down""" macro.press_trigger() asyncio.ensure_future(macro.run(self.handler)) await (asyncio.sleep(0.1)) self.assertTrue(macro.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.is_holding()) self.assertEqual(len(self.result), 4) self.assertEqual(self.result[0], (EV_KEY, system_mapping.get("1"), 1)) self.assertEqual(self.result[-1], (EV_KEY, system_mapping.get("3"), 0)) self.assertEqual(len(macro.child_macros), 0) async def test_dont_just_hold(self): macro = parse("k(1).h().k(3)", self.context) self.assertSetEqual( macro.get_capabilities()[EV_KEY], {system_mapping.get("1"), system_mapping.get("3")}, ) asyncio.ensure_future(macro.run(self.handler)) await (asyncio.sleep(0.1)) self.assertFalse(macro.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, system_mapping.get("1"), 1)) self.assertEqual(self.result[-1], (EV_KEY, system_mapping.get("3"), 0)) self.assertEqual(len(macro.child_macros), 0) async def test_hold_down(self): # writes down and waits for the up event until the key is released macro = parse("h(a)", self.context) self.assertSetEqual( macro.get_capabilities()[EV_KEY], { system_mapping.get("a"), }, ) self.assertEqual(len(macro.child_macros), 0) """down""" macro.press_trigger() await (asyncio.sleep(0.05)) self.assertTrue(macro.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.is_holding()) self.assertEqual(len(self.result), 1) self.assertEqual(self.result[0], (EV_KEY, system_mapping.get("a"), 1)) """up""" macro.release_trigger() await (asyncio.sleep(0.05)) self.assertFalse(macro.is_holding()) self.assertEqual(len(self.result), 2) self.assertEqual(self.result[0], (EV_KEY, system_mapping.get("a"), 1)) self.assertEqual(self.result[1], (EV_KEY, system_mapping.get("a"), 0)) async def test_2(self): start = time.time() repeats = 20 macro = parse(f"r({repeats}, k(k)).r(1, k(k))", self.context) k_code = system_mapping.get("k") self.assertSetEqual(macro.get_capabilities()[EV_KEY], {k_code}) await macro.run(self.handler) keystroke_sleep = self.context.mapping.get("macros.keystroke_sleep_ms") sleep_time = 2 * repeats * keystroke_sleep / 1000 self.assertGreater(time.time() - start, sleep_time * 0.9) self.assertLess(time.time() - start, sleep_time * 1.2) self.assertListEqual( self.result, [(EV_KEY, k_code, 1), (EV_KEY, k_code, 0)] * (repeats + 1) ) self.assertEqual(len(macro.child_macros), 2) self.assertEqual(len(macro.child_macros[0].child_macros), 0) async def test_3(self): start = time.time() macro = parse("r(3, k(m).w(100))", self.context) m_code = system_mapping.get("m") self.assertSetEqual(macro.get_capabilities()[EV_KEY], {m_code}) await macro.run(self.handler) keystroke_time = 6 * self.context.mapping.get("macros.keystroke_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(len(macro.child_macros), 1) self.assertEqual(len(macro.child_macros[0].child_macros), 0) async def test_4(self): macro = parse(" r(2,\nk(\nr ).k(minus\n )).k(m) ", self.context) r = system_mapping.get("r") minus = system_mapping.get("minus") m = system_mapping.get("m") self.assertSetEqual(macro.get_capabilities()[EV_KEY], {r, minus, 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(len(macro.child_macros), 1) self.assertEqual(len(macro.child_macros[0].child_macros), 0) async def test_5(self): start = time.time() macro = parse("w(200).r(2,m(w,\nr(2,\tk(BtN_LeFt))).w(10).k(k))", self.context) self.assertEqual(len(macro.child_macros), 1) self.assertEqual(len(macro.child_macros[0].child_macros), 1) w = system_mapping.get("w") left = system_mapping.get("bTn_lEfT") k = system_mapping.get("k") self.assertSetEqual(macro.get_capabilities()[EV_KEY], {w, left, k}) await macro.run(self.handler) num_pauses = 8 + 6 + 4 keystroke_time = num_pauses * self.context.mapping.get( "macros.keystroke_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_6(self): # does nothing without .run macro = parse("k(a).r(3, k(b))", self.context) self.assertIsInstance(macro, Macro) self.assertListEqual(self.result, []) async def test_keystroke_sleep_config(self): # global config as fallback config.set("macros.keystroke_sleep_ms", 100) start = time.time() macro = parse("k(a).k(b)", self.context) await macro.run(self.handler) delta = time.time() - start # is currently over 400, k(b) adds another sleep afterwards # that doesn't do anything self.assertGreater(delta, 0.300) # now set the value in the mapping, which is prioritized self.context.mapping.set("macros.keystroke_sleep_ms", 50) start = time.time() macro = parse("k(a).k(b)", self.context) await macro.run(self.handler) delta = time.time() - start self.assertGreater(delta, 0.150) self.assertLess(delta, 0.300) 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 = system_mapping.get("a") b = system_mapping.get("b") c = system_mapping.get("c") macro = parse("k(a).m(b, h()).k(c)", self.context) asyncio.ensure_future(macro.run(self.handler)) self.assertFalse(macro.is_holding()) asyncio.ensure_future(macro.run(self.handler)) # ignored self.assertFalse(macro.is_holding()) macro.press_trigger() await asyncio.sleep(0.2) self.assertTrue(macro.is_holding()) asyncio.ensure_future(macro.run(self.handler)) # ignored self.assertTrue(macro.is_holding()) macro.release_trigger() await asyncio.sleep(0.2) self.assertFalse(macro.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.is_holding()) macro.release_trigger() await asyncio.sleep(0.2) self.assertFalse(macro.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) async def test_mouse(self): wheel_speed = 100 macro_1 = parse("mouse(up, 4)", self.context) macro_2 = parse(f"wheel(left, {wheel_speed})", self.context) macro_1.press_trigger() macro_2.press_trigger() asyncio.ensure_future(macro_1.run(self.handler)) asyncio.ensure_future(macro_2.run(self.handler)) sleep = 0.1 await (asyncio.sleep(sleep)) self.assertTrue(macro_1.is_holding()) self.assertTrue(macro_2.is_holding()) macro_1.release_trigger() macro_2.release_trigger() self.assertIn((EV_REL, REL_Y, -4), self.result) expected_wheel_event_count = sleep / (1 / wheel_speed) actual_wheel_event_count = self.result.count((EV_REL, REL_HWHEEL, 1)) # this seems to have a tendency of injecting less wheel events, # especially if the sleep is short self.assertGreater(actual_wheel_event_count, expected_wheel_event_count * 0.8) self.assertLess(actual_wheel_event_count, expected_wheel_event_count * 1.1) self.assertIn(REL_WHEEL, macro_1.get_capabilities()[EV_REL]) self.assertIn(REL_Y, macro_1.get_capabilities()[EV_REL]) self.assertIn(REL_X, macro_1.get_capabilities()[EV_REL]) self.assertIn(REL_WHEEL, macro_2.get_capabilities()[EV_REL]) self.assertIn(REL_Y, macro_2.get_capabilities()[EV_REL]) self.assertIn(REL_X, macro_2.get_capabilities()[EV_REL]) async def test_event_1(self): macro = parse("e(EV_KEY, KEY_A, 1)", self.context) a_code = system_mapping.get("a") self.assertSetEqual(macro.get_capabilities()[EV_KEY], {a_code}) self.assertSetEqual(macro.get_capabilities()[EV_REL], set()) await macro.run(self.handler) self.assertListEqual(self.result, [(EV_KEY, a_code, 1)]) self.assertEqual(len(macro.child_macros), 0) async def test_event_2(self): macro = parse("r(1, event(type=5421, code=324, value=154))", self.context) code = 324 self.assertSetEqual(macro.get_capabilities()[5421], {324}) self.assertSetEqual(macro.get_capabilities()[EV_REL], set()) self.assertSetEqual(macro.get_capabilities()[EV_KEY], set()) await macro.run(self.handler) self.assertListEqual(self.result, [(5421, code, 154)]) self.assertEqual(len(macro.child_macros), 1) async def test__wait_for_event(self): macro = parse("h(a)", self.context) try: # should timeout, no event known await asyncio.wait_for(macro._wait_for_event(), 0.1) raise AssertionError("Expected asyncio.TimeoutError") except asyncio.TimeoutError: pass # should not timeout because a new event arrived macro.notify(new_event(EV_KEY, 1, 1), PRESS) await asyncio.wait_for(macro._wait_for_event(), 0.1) try: # should timeout, because the previous event doesn't match the filter await asyncio.wait_for( macro._wait_for_event(lambda e, a: e.value == 3), 0.1 ) raise AssertionError("Expected asyncio.TimeoutError") except asyncio.TimeoutError: pass # should not timeout because a new event arrived macro.notify(new_event(EV_KEY, 1, 3), RELEASE) await asyncio.wait_for(macro._wait_for_event(), 0.1) try: # should timeout, because the previous event doesn't match the filter await asyncio.wait_for(macro._wait_for_event(lambda _, a: a == PRESS), 0.1) raise AssertionError("Expected asyncio.TimeoutError") except asyncio.TimeoutError: pass async def test_macro_breaks(self): # the first parameter for `repeat` requires an integer, not "foo", # which makes `repeat` throw macro = parse('set(a, "foo").r($a, k(KEY_A)).k(KEY_B)', self.context) await macro.run(self.handler) # .run() it will not throw because r() breaks, and it will properly set # it to stopped self.assertFalse(macro.running) # k(KEY_B) is not executed, the macro stops self.assertListEqual(self.result, []) async def test_set(self): await parse('set(a, "foo")', self.context).run(self.handler) self.assertEqual(macro_variables.get("a"), "foo") await parse('set( \t"b" \n, "1")', self.context).run(self.handler) self.assertEqual(macro_variables.get("b"), "1") await parse("set(a, 1)", self.context).run(self.handler) self.assertEqual(macro_variables.get("a"), 1) await parse("set(a, )", self.context).run(self.handler) self.assertEqual(macro_variables.get("a"), None) 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 = '# r(1,k(KEY_D)).set(a,"#b")' macro = 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, ) 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), ], ) class TestIfEq(MacroTestBase): async def test_ifeq_runs(self): # deprecated ifeq function, but kept for compatibility reasons macro = parse("set(foo, 2).ifeq(foo, 2, k(a), k(b))", self.context) code_a = system_mapping.get("a") code_b = system_mapping.get("b") self.assertSetEqual(macro.get_capabilities()[EV_KEY], {code_a, code_b}) self.assertSetEqual(macro.get_capabilities()[EV_REL], set()) await macro.run(self.handler) self.assertListEqual(self.result, [(EV_KEY, code_a, 1), (EV_KEY, code_a, 0)]) self.assertEqual(len(macro.child_macros), 2) async def test_ifeq_none(self): # first param none macro = parse("set(foo, 2).ifeq(foo, 2, , k(b))", self.context) self.assertEqual(len(macro.child_macros), 1) code_b = system_mapping.get("b") self.assertSetEqual(macro.get_capabilities()[EV_KEY], {code_b}) self.assertSetEqual(macro.get_capabilities()[EV_REL], set()) await macro.run(self.handler) self.assertListEqual(self.result, []) # second param none macro = parse("set(foo, 2).ifeq(foo, 2, k(a),)", self.context) self.assertEqual(len(macro.child_macros), 1) code_a = system_mapping.get("a") self.assertSetEqual(macro.get_capabilities()[EV_KEY], {code_a}) self.assertSetEqual(macro.get_capabilities()[EV_REL], set()) 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 = parse("ifeq(qux, 2, k(a), k(b))", self.context) code_a = system_mapping.get("a") code_b = system_mapping.get("b") self.assertSetEqual(macro.get_capabilities()[EV_KEY], {code_a, code_b}) self.assertSetEqual(macro.get_capabilities()[EV_REL], set()) await macro.run(self.handler) self.assertListEqual(self.result, [(EV_KEY, code_b, 1), (EV_KEY, code_b, 0)]) self.assertEqual(len(macro.child_macros), 2) async def test_if_eq(self): """new version of ifeq""" code_a = system_mapping.get("a") code_b = system_mapping.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): # cleanup macro_variables._clear() self.assertIsNone(macro_variables.get("a")) self.result.clear() # test macro = parse(macro, self.context) await macro.run(self.handler) self.assertListEqual(self.result, expected) await test("if_eq(1, 1, k(a), k(b))", a_press) await test("if_eq(1, 2, k(a), k(b))", b_press) await test("if_eq(value_1=1, value_2=1, then=k(a), else=k(b))", a_press) await test('set(a, "foo").if_eq($a, "foo", k(a), k(b))', a_press) await test('set(a, "foo").if_eq("foo", $a, k(a), k(b))', a_press) await test('set(a, "foo").if_eq("foo", $a, , k(b))', []) await test('set(a, "qux").if_eq("foo", $a, k(a), k(b))', b_press) await test('set(a, "qux").if_eq($a, "foo", k(a), k(b))', b_press) await test('set(a, "qux").if_eq($a, "foo", k(a), )', []) await test('set(a, "x").set(b, "y").if_eq($b, $a, k(a), k(b))', b_press) await test('set(a, "x").set(b, "y").if_eq($b, $a, k(a), )', []) await test('set(a, "x").set(b, "x").if_eq($b, $a, k(a), k(b))', a_press) await test('set(a, "x").set(b, "x").if_eq($b, $a, , k(b))', []) await test("if_eq($q, $w, k(a), else=k(b))", a_press) # both None await test("set(q, 1).if_eq($q, $w, k(a), else=k(b))", b_press) await test("set(q, 1).set(w, 1).if_eq($q, $w, k(a), else=k(b))", a_press) await test('set(q, " a b ").if_eq($q, " a b ", k(a), k(b))', a_press) await test('if_eq("\t", "\n", k(a), k(b))', b_press) # treats values in quotes as strings, not as code await test('set(q, "$a").if_eq($q, "$a", k(a), k(b))', a_press) await test('set(q, "a,b").if_eq("a,b", $q, k(a), k(b))', a_press) await test('set(q, "c(1, 2)").if_eq("c(1, 2)", $q, k(a), k(b))', a_press) await test('set(q, "c(1, 2)").if_eq("c(1, 2)", "$q", k(a), k(b))', b_press) await test('if_eq("value_1=1", 1, k(a), k(b))', b_press) # won't compare strings and int, be similar to python await test('set(a, "1").if_eq($a, 1, k(a), k(b))', b_press) await test('set(a, 1).if_eq($a, "1", k(a), k(b))', b_press) async def test_if_eq_runs_multiprocessed(self): """ifeq on variables that have been set in other processes works.""" macro = parse("if_eq($foo, 3, k(a), k(b))", self.context) code_a = system_mapping.get("a") code_b = system_mapping.get("b") self.assertSetEqual(macro.get_capabilities()[EV_KEY], {code_a, code_b}) self.assertSetEqual(macro.get_capabilities()[EV_REL], set()) self.assertEqual(len(macro.child_macros), 2) def set_foo(value): # will write foo = 2 into the shared dictionary of macros macro_2 = parse(f"set(foo, {value})", self.context) 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), ], ) class TestIfSingle(MacroTestBase): async def test_if_single(self): macro = parse("if_single(k(x), k(y))", self.context) self.assertEqual(len(macro.child_macros), 2) a = system_mapping.get("a") x = system_mapping.get("x") y = system_mapping.get("y") self.assertSetEqual(macro.get_capabilities()[EV_KEY], {x, y}) macro.notify(new_event(EV_KEY, a, 1), PRESS) asyncio.ensure_future(macro.run(self.handler)) await asyncio.sleep(0.1) macro.notify(new_event(EV_KEY, a, 0), RELEASE) # 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 = parse("if_single(k(x), k(y), timeout=100000)", self.context) self.assertEqual(len(macro.child_macros), 2) a = system_mapping.get("a") b = system_mapping.get("b") x = system_mapping.get("x") y = system_mapping.get("y") self.assertSetEqual(macro.get_capabilities()[EV_KEY], {x, y}) macro.notify(new_event(EV_KEY, a, 1), PRESS) asyncio.ensure_future(macro.run(self.handler)) 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 macro.notify(new_event(EV_KEY, b, 0), RELEASE) await asyncio.sleep(0.05) self.assertListEqual(self.result, []) # pressing an actual key triggers if_single await asyncio.sleep(0.05) macro.notify(new_event(EV_KEY, a, 1), PRESS) 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 = parse("r(1, if_single(k(x), k(y)))", self.context) self.assertEqual(len(macro.child_macros), 1) self.assertEqual(len(macro.child_macros[0].child_macros), 2) a = system_mapping.get("a") b = system_mapping.get("b") x = system_mapping.get("x") y = system_mapping.get("y") self.assertSetEqual(macro.get_capabilities()[EV_KEY], {x, y}) macro.notify(new_event(EV_KEY, a, 1), PRESS) asyncio.ensure_future(macro.run(self.handler)) await asyncio.sleep(0.1) macro.notify(new_event(EV_KEY, b, 1), PRESS) 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 = parse("if_single(k(x),)", self.context) self.assertEqual(len(macro.child_macros), 1) a = system_mapping.get("a") b = system_mapping.get("b") x = system_mapping.get("x") self.assertSetEqual(macro.get_capabilities()[EV_KEY], {x}) macro.notify(new_event(EV_KEY, a, 1), PRESS) asyncio.ensure_future(macro.run(self.handler)) await asyncio.sleep(0.1) macro.notify(new_event(EV_KEY, b, 1), PRESS) await asyncio.sleep(0.1) self.assertListEqual(self.result, []) self.assertFalse(macro.running) async def test_if_single_times_out(self): macro = parse("set(t, 300).if_single(k(x), k(y), timeout=$t)", self.context) self.assertEqual(len(macro.child_macros), 2) a = system_mapping.get("a") y = system_mapping.get("y") macro.notify(new_event(EV_KEY, a, 1), PRESS) asyncio.ensure_future(macro.run(self.handler)) # 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) class TestIfTap(MacroTestBase): async def test_if_tap(self): macro = parse("if_tap(k(x), k(y), 100)", self.context) self.assertEqual(len(macro.child_macros), 2) x = system_mapping.get("x") y = system_mapping.get("y") self.assertSetEqual(macro.get_capabilities()[EV_KEY], {x, 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 = parse("if_tap(k(a), k(b), 100)", self.context) 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 = parse("if_tap(if_tap(k(a), k(b), 100), k(c), 100)", self.context) self.assertEqual(len(macro.child_macros), 2) self.assertEqual(len(macro.child_macros[0].child_macros), 2) self.assertSetEqual(macro.get_capabilities()[EV_KEY], {KEY_A, KEY_B, KEY_C}) 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 = parse("if_tap(, k(y), 100)", self.context) self.assertEqual(len(macro.child_macros), 1) y = system_mapping.get("y") self.assertSetEqual(macro.get_capabilities()[EV_KEY], {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 = parse("if_tap(k(y), , 50)", self.context) self.assertEqual(len(macro.child_macros), 1) y = system_mapping.get("y") self.assertSetEqual(macro.get_capabilities()[EV_KEY], {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 = parse("if_tap(k(x), k(y), 50)", self.context) self.assertEqual(len(macro.child_macros), 2) x = system_mapping.get("x") y = system_mapping.get("y") self.assertSetEqual(macro.get_capabilities()[EV_KEY], {x, 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 = parse("if_tap(k(x), k(y), timeout=50)", self.context) self.assertEqual(len(macro.child_macros), 2) x = system_mapping.get("x") y = system_mapping.get("y") self.assertSetEqual(macro.get_capabilities()[EV_KEY], {x, 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) if __name__ == "__main__": unittest.main() input-remapper-1.4.0/tests/unit/test_mapping.py000066400000000000000000000347431417303655400216740ustar00rootroot00000000000000#!/usr/bin/python3 # -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings # Copyright (C) 2022 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 import json from unittest.mock import patch from evdev.ecodes import EV_KEY, EV_ABS, KEY_A from inputremapper.mapping import Mapping, split_key from inputremapper.system_mapping import SystemMapping, XMODMAP_FILENAME from inputremapper.config import config from inputremapper.paths import get_preset_path from inputremapper.key import Key from tests.test import tmp, quick_cleanup class TestSystemMapping(unittest.TestCase): def tearDown(self): quick_cleanup() def test_split_key(self): self.assertEqual(split_key("1,2,3"), (1, 2, 3)) self.assertIsNone(split_key("1,2"), (1, 2, 1)) self.assertIsNone(split_key("1")) self.assertIsNone(split_key("1,a,2")) self.assertIsNone(split_key("1,a")) def test_update(self): system_mapping = SystemMapping() system_mapping.update({"foo1": 101, "bar1": 102}) system_mapping.update({"foo2": 201, "bar2": 202}) self.assertEqual(system_mapping.get("foo1"), 101) self.assertEqual(system_mapping.get("bar2"), 202) def test_xmodmap_file(self): system_mapping = SystemMapping() path = os.path.join(tmp, XMODMAP_FILENAME) os.remove(path) system_mapping.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_correct_case(self): system_mapping = SystemMapping() system_mapping.clear() system_mapping._set("A", 31) system_mapping._set("a", 32) system_mapping._set("abcd_B", 33) self.assertEqual(system_mapping.correct_case("a"), "a") self.assertEqual(system_mapping.correct_case("A"), "A") self.assertEqual(system_mapping.correct_case("ABCD_b"), "abcd_B") # unknown stuff is returned as is self.assertEqual(system_mapping.correct_case("FOo"), "FOo") self.assertEqual(system_mapping.get("A"), 31) self.assertEqual(system_mapping.get("a"), 32) self.assertEqual(system_mapping.get("ABCD_b"), 33) self.assertEqual(system_mapping.get("abcd_B"), 33) def test_system_mapping(self): system_mapping = SystemMapping() system_mapping.populate() self.assertGreater(len(system_mapping._mapping), 100) # this is case-insensitive self.assertEqual(system_mapping.get("1"), 2) self.assertEqual(system_mapping.get("KeY_1"), 2) self.assertEqual(system_mapping.get("AlT_L"), 56) self.assertEqual(system_mapping.get("KEy_LEFtALT"), 56) self.assertEqual(system_mapping.get("kEY_LeFTSHIFT"), 42) self.assertEqual(system_mapping.get("ShiFt_L"), 42) self.assertEqual(system_mapping.get("BTN_left"), 272) self.assertIsNotNone(system_mapping.get("KEY_KP4")) self.assertEqual(system_mapping.get("KP_Left"), system_mapping.get("KEY_KP4")) # this only lists the correct casing, # includes linux constants and xmodmap symbols names = system_mapping.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 = system_mapping._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(system_mapping.get("disable"), -1) class TestMapping(unittest.TestCase): def setUp(self): self.mapping = Mapping() self.assertFalse(self.mapping.has_unsaved_changes()) def tearDown(self): quick_cleanup() def test_config(self): self.mapping.save(get_preset_path("foo", "bar2")) self.assertEqual(self.mapping.get("a"), None) self.assertFalse(self.mapping.has_unsaved_changes()) self.mapping.set("a", 1) self.assertEqual(self.mapping.get("a"), 1) self.assertTrue(self.mapping.has_unsaved_changes()) self.mapping.remove("a") self.mapping.set("a.b", 2) self.assertEqual(self.mapping.get("a.b"), 2) self.assertEqual(self.mapping._config["a"]["b"], 2) self.mapping.remove("a.b") self.mapping.set("a.b.c", 3) self.assertEqual(self.mapping.get("a.b.c"), 3) self.assertEqual(self.mapping._config["a"]["b"]["c"], 3) # setting mapping.whatever does not overwrite the mapping # after saving. It should be ignored. self.mapping.change(Key(EV_KEY, 81, 1), "keyboard", " a ") self.mapping.set("mapping.a", 2) self.assertEqual(self.mapping.num_saved_keys, 0) self.mapping.save(get_preset_path("foo", "bar")) self.assertEqual(self.mapping.num_saved_keys, len(self.mapping)) self.assertFalse(self.mapping.has_unsaved_changes()) self.mapping.load(get_preset_path("foo", "bar")) self.assertEqual( self.mapping.get_mapping(Key(EV_KEY, 81, 1)), ("a", "keyboard") ) self.assertIsNone(self.mapping.get("mapping.a")) self.assertFalse(self.mapping.has_unsaved_changes()) # loading a different preset also removes the configs from memory self.mapping.remove("a") self.assertTrue(self.mapping.has_unsaved_changes()) self.mapping.set("a.b.c", 6) self.mapping.load(get_preset_path("foo", "bar2")) self.assertIsNone(self.mapping.get("a.b.c")) def test_fallback(self): config.set("d.e.f", 5) self.assertEqual(self.mapping.get("d.e.f"), 5) self.mapping.set("d.e.f", 3) self.assertEqual(self.mapping.get("d.e.f"), 3) def test_clone(self): ev_1 = Key(EV_KEY, 1, 1) ev_2 = Key(EV_KEY, 2, 0) mapping1 = Mapping() mapping1.change(ev_1, "keyboard", " a") mapping2 = mapping1.clone() mapping1.change(ev_2, "keyboard", "b ") self.assertEqual(mapping1.get_mapping(ev_1), ("a", "keyboard")) self.assertEqual(mapping1.get_mapping(ev_2), ("b", "keyboard")) self.assertEqual(mapping2.get_mapping(ev_1), ("a", "keyboard")) self.assertIsNone(mapping2.get_mapping(ev_2)) self.assertIsNone(mapping2.get_mapping(Key(EV_KEY, 2, 3))) self.assertIsNone(mapping2.get_mapping(Key(EV_KEY, 1, 3))) def test_save_load(self): one = Key(EV_KEY, 10, 1) two = Key(EV_KEY, 11, 1) three = Key(EV_KEY, 12, 1) self.mapping.change(one, "keyboard", "1") self.mapping.change(two, "keyboard", "2") self.mapping.change(Key(two, three), "keyboard", "3") self.mapping._config["foo"] = "bar" self.mapping.save(get_preset_path("Foo Device", "test")) path = os.path.join(tmp, "presets", "Foo Device", "test.json") self.assertTrue(os.path.exists(path)) loaded = Mapping() self.assertEqual(len(loaded), 0) loaded.load(get_preset_path("Foo Device", "test")) self.assertEqual(len(loaded), 3) self.assertEqual(loaded.get_mapping(one), ("1", "keyboard")) self.assertEqual(loaded.get_mapping(two), ("2", "keyboard")) self.assertEqual(loaded.get_mapping(Key(two, three)), ("3", "keyboard")) self.assertEqual(loaded._config["foo"], "bar") def test_change(self): # the reader would not report values like 111 or 222, only 1 or -1. # the mapping just does what it is told, so it accepts them. ev_1 = Key(EV_KEY, 1, 111) ev_2 = Key(EV_KEY, 1, 222) ev_3 = Key(EV_KEY, 2, 111) ev_4 = Key(EV_ABS, 1, 111) # 1 is not assigned yet, ignore it self.mapping.change(ev_1, "keyboard", "a", ev_2) self.assertTrue(self.mapping.has_unsaved_changes()) self.assertIsNone(self.mapping.get_mapping(ev_2)) self.assertEqual(self.mapping.get_mapping(ev_1), ("a", "keyboard")) self.assertEqual(len(self.mapping), 1) # change ev_1 to ev_3 and change a to b self.mapping.change(ev_3, "keyboard", "b", ev_1) self.assertIsNone(self.mapping.get_mapping(ev_1)) self.assertEqual(self.mapping.get_mapping(ev_3), ("b", "keyboard")) self.assertEqual(len(self.mapping), 1) # add 4 self.mapping.change(ev_4, "keyboard", "c", None) self.assertEqual(self.mapping.get_mapping(ev_3), ("b", "keyboard")) self.assertEqual(self.mapping.get_mapping(ev_4), ("c", "keyboard")) self.assertEqual(len(self.mapping), 2) # change the mapping of 4 to d self.mapping.change(ev_4, "keyboard", "d", None) self.assertEqual(self.mapping.get_mapping(ev_4), ("d", "keyboard")) self.assertEqual(len(self.mapping), 2) # this also works in the same way self.mapping.change(ev_4, "keyboard", "e", ev_4) self.assertEqual(self.mapping.get_mapping(ev_4), ("e", "keyboard")) self.assertEqual(len(self.mapping), 2) self.assertEqual(self.mapping.num_saved_keys, 0) def test_rejects_empty(self): key = Key(EV_KEY, 1, 111) self.assertEqual(len(self.mapping), 0) self.assertRaises( ValueError, lambda: self.mapping.change(key, "keyboard", " \n ") ) self.assertRaises(ValueError, lambda: self.mapping.change(key, " \n ", "b")) self.assertEqual(len(self.mapping), 0) def test_avoids_redundant_changes(self): # to avoid logs that don't add any value def clear(*_): # should not be called raise AssertionError key = Key(EV_KEY, 987, 1) target = "keyboard" symbol = "foo" self.mapping.change(key, target, symbol) with patch.object(self.mapping, "clear", clear): self.mapping.change(key, target, symbol) self.mapping.change(key, target, symbol, previous_key=key) def test_combinations(self): ev_1 = Key(EV_KEY, 1, 111) ev_2 = Key(EV_KEY, 1, 222) ev_3 = Key(EV_KEY, 2, 111) ev_4 = Key(EV_ABS, 1, 111) combi_1 = Key(ev_1, ev_2, ev_3) combi_2 = Key(ev_2, ev_1, ev_3) combi_3 = Key(ev_1, ev_2, ev_4) self.mapping.change(combi_1, "keyboard", "a") self.assertEqual(self.mapping.get_mapping(combi_1), ("a", "keyboard")) self.assertEqual(self.mapping.get_mapping(combi_2), ("a", "keyboard")) # since combi_1 and combi_2 are equivalent, a changes to b self.mapping.change(combi_2, "keyboard", "b") self.assertEqual(self.mapping.get_mapping(combi_1), ("b", "keyboard")) self.assertEqual(self.mapping.get_mapping(combi_2), ("b", "keyboard")) self.mapping.change(combi_3, "keyboard", "c") self.assertEqual(self.mapping.get_mapping(combi_1), ("b", "keyboard")) self.assertEqual(self.mapping.get_mapping(combi_2), ("b", "keyboard")) self.assertEqual(self.mapping.get_mapping(combi_3), ("c", "keyboard")) self.mapping.change(combi_3, "keyboard", "c", combi_1) self.assertIsNone(self.mapping.get_mapping(combi_1)) self.assertIsNone(self.mapping.get_mapping(combi_2)) self.assertEqual(self.mapping.get_mapping(combi_3), ("c", "keyboard")) def test_clear(self): # does nothing ev_1 = Key(EV_KEY, 40, 1) ev_2 = Key(EV_KEY, 30, 1) ev_3 = Key(EV_KEY, 20, 1) ev_4 = Key(EV_KEY, 10, 1) self.mapping.clear(ev_1) self.assertFalse(self.mapping.has_unsaved_changes()) self.assertEqual(len(self.mapping), 0) self.mapping._mapping[ev_1] = "b" self.assertEqual(len(self.mapping), 1) self.mapping.clear(ev_1) self.assertEqual(len(self.mapping), 0) self.assertTrue(self.mapping.has_unsaved_changes()) self.mapping.change(ev_4, "keyboard", "KEY_KP1", None) self.assertTrue(self.mapping.has_unsaved_changes()) self.mapping.change(ev_3, "keyboard", "KEY_KP2", None) self.mapping.change(ev_2, "keyboard", "KEY_KP3", None) self.assertEqual(len(self.mapping), 3) self.mapping.clear(ev_3) self.assertEqual(len(self.mapping), 2) self.assertEqual(self.mapping.get_mapping(ev_4), ("KEY_KP1", "keyboard")) self.assertIsNone(self.mapping.get_mapping(ev_3)) self.assertEqual(self.mapping.get_mapping(ev_2), ("KEY_KP3", "keyboard")) def test_empty(self): self.mapping.change(Key(EV_KEY, 10, 1), "keyboard", "1") self.mapping.change(Key(EV_KEY, 11, 1), "keyboard", "2") self.mapping.change(Key(EV_KEY, 12, 1), "keyboard", "3") self.assertEqual(len(self.mapping), 3) self.mapping.empty() self.assertEqual(len(self.mapping), 0) def test_dangerously_mapped_btn_left(self): self.mapping.change(Key.btn_left(), "keyboard", "1") self.assertTrue(self.mapping.dangerously_mapped_btn_left()) self.mapping.change(Key(EV_KEY, 41, 1), "keyboard", "2") self.assertTrue(self.mapping.dangerously_mapped_btn_left()) self.mapping.change(Key(EV_KEY, 42, 1), "gamepad", "btn_left") self.assertFalse(self.mapping.dangerously_mapped_btn_left()) self.mapping.change(Key(EV_KEY, 42, 1), "gamepad", "BTN_Left") self.assertFalse(self.mapping.dangerously_mapped_btn_left()) self.mapping.change(Key(EV_KEY, 42, 1), "keyboard", "3") self.assertTrue(self.mapping.dangerously_mapped_btn_left()) if __name__ == "__main__": unittest.main() input-remapper-1.4.0/tests/unit/test_migrations.py000066400000000000000000000171751417303655400224150ustar00rootroot00000000000000# # input-remapper is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # input-remapper is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General 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 import shutil import json from evdev.ecodes import EV_KEY, EV_ABS, ABS_HAT0X from inputremapper.migrations import migrate, config_version from inputremapper.mapping import Mapping from inputremapper.config import config from inputremapper.paths import touch, CONFIG_PATH, mkdir, get_preset_path from inputremapper.key import Key from inputremapper.user import HOME from inputremapper.logger import VERSION from tests.test import quick_cleanup, tmp class TestMigrations(unittest.TestCase): def tearDown(self): quick_cleanup() self.assertEqual(len(config.iterate_autoload_presets()), 0) def test_migrate_suffix(self): old = os.path.join(CONFIG_PATH, "config") new = os.path.join(CONFIG_PATH, "config.json") try: os.remove(new) except FileNotFoundError: pass touch(old) with open(old, "w") as f: f.write("{}") migrate() self.assertTrue(os.path.exists(new)) self.assertFalse(os.path.exists(old)) def test_rename_config(self): old = os.path.join(HOME, ".config", "key-mapper") new = CONFIG_PATH # we are not destroying our actual config files with this test self.assertTrue(new.startswith(tmp)) try: shutil.rmtree(new) except FileNotFoundError: pass old_config_json = os.path.join(old, "config.json") touch(old_config_json) with open(old_config_json, "w") as f: f.write('{"foo":"bar"}') 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") self.assertIn("version", moved_config) def test_wont_migrate_suffix(self): old = os.path.join(CONFIG_PATH, "config") new = os.path.join(CONFIG_PATH, "config.json") touch(new) with open(new, "w") as f: f.write("{}") touch(old) with open(old, "w") as f: f.write("{}") migrate() self.assertTrue(os.path.exists(new)) self.assertTrue(os.path.exists(old)) def test_migrate_preset(self): if os.path.exists(tmp): shutil.rmtree(tmp) p1 = os.path.join(tmp, "foo1", "bar1.json") p2 = os.path.join(tmp, "foo2", "bar2.json") touch(p1) touch(p2) with open(p1, "w") as f: f.write("{}") with open(p2, "w") as f: f.write("{}") migrate() self.assertFalse(os.path.exists(os.path.join(tmp, "foo1", "bar1.json"))) self.assertFalse(os.path.exists(os.path.join(tmp, "foo2", "bar2.json"))) self.assertTrue( os.path.exists(os.path.join(tmp, "presets", "foo1", "bar1.json")) ) self.assertTrue( os.path.exists(os.path.join(tmp, "presets", "foo2", "bar2.json")) ) def test_wont_migrate_preset(self): if os.path.exists(tmp): shutil.rmtree(tmp) p1 = os.path.join(tmp, "foo1", "bar1.json") p2 = os.path.join(tmp, "foo2", "bar2.json") touch(p1) touch(p2) with open(p1, "w") as f: f.write("{}") with open(p2, "w") as f: f.write("{}") # already migrated mkdir(os.path.join(tmp, "presets")) migrate() self.assertTrue(os.path.exists(os.path.join(tmp, "foo1", "bar1.json"))) self.assertTrue(os.path.exists(os.path.join(tmp, "foo2", "bar2.json"))) self.assertFalse( os.path.exists(os.path.join(tmp, "presets", "foo1", "bar1.json")) ) self.assertFalse( os.path.exists(os.path.join(tmp, "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 {(type, code, value): (symbol, "keyboard")} """ path = os.path.join(tmp, "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_KEY}, 4, 1": ("a", "foo"), f"{EV_ABS},{ABS_HAT0X},-1": "b", f"{EV_ABS},1,1+{EV_ABS},2,-1+{EV_ABS},3,1": "c", # ignored because broken f"3,1,1,2": "e", f"3": "e", f",,+3,1,2": "g", f"": "h", } }, file, ) migrate() loaded = Mapping() self.assertEqual(loaded.num_saved_keys, 0) loaded.load(get_preset_path("Foo Device", "test")) self.assertEqual(len(loaded), 6) self.assertEqual(loaded.num_saved_keys, 6) self.assertEqual(loaded.get_mapping(Key(EV_KEY, 1, 1)), ("a", "keyboard")) self.assertEqual(loaded.get_mapping(Key(EV_KEY, 2, 1)), ("BTN_B", "gamepad")) self.assertEqual( loaded.get_mapping(Key(EV_KEY, 3, 1)), ( "BTN_1\n# Broken mapping:\n# No target can handle all specified keycodes", "keyboard", ), ) self.assertEqual(loaded.get_mapping(Key(EV_KEY, 4, 1)), ("a", "foo")) self.assertEqual( loaded.get_mapping(Key(EV_ABS, ABS_HAT0X, -1)), ("b", "keyboard") ) self.assertEqual( loaded.get_mapping(Key((EV_ABS, 1, 1), (EV_ABS, 2, -1), Key(EV_ABS, 3, 1))), ("c", "keyboard"), ) def test_add_version(self): path = os.path.join(CONFIG_PATH, "config.json") os.makedirs(os.path.dirname(path), exist_ok=True) with open(path, "w") as file: file.write("{}") migrate() self.assertEqual(VERSION, config_version().public) def test_update_version(self): path = os.path.join(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) migrate() self.assertEqual(VERSION, config_version().public) def test_config_version(self): path = os.path.join(CONFIG_PATH, "config.json") with open(path, "w") as file: file.write("{}") self.assertEqual("0.0.0", config_version().public) try: os.remove(path) except FileNotFoundError: pass self.assertEqual("0.0.0", config_version().public) if __name__ == "__main__": unittest.main() input-remapper-1.4.0/tests/unit/test_paths.py000066400000000000000000000043641417303655400213540ustar00rootroot00000000000000#!/usr/bin/python3 # -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings # Copyright (C) 2022 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 import tempfile from inputremapper.paths import touch, mkdir, get_preset_path, get_config_path from tests.test import quick_cleanup, tmp def _raise(error): raise error class TestPaths(unittest.TestCase): def tearDown(self): quick_cleanup() def test_touch(self): with tempfile.TemporaryDirectory() as local_tmp: path_abcde = os.path.join(local_tmp, "a/b/c/d/e") touch(path_abcde) self.assertTrue(os.path.exists(path_abcde)) self.assertTrue(os.path.isfile(path_abcde)) self.assertRaises( ValueError, lambda: 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") mkdir(path_bcde) self.assertTrue(os.path.exists(path_bcde)) self.assertTrue(os.path.isdir(path_bcde)) def test_get_preset_path(self): self.assertEqual(get_preset_path(), os.path.join(tmp, "presets")) self.assertEqual(get_preset_path("a"), os.path.join(tmp, "presets/a")) self.assertEqual( get_preset_path("a", "b"), os.path.join(tmp, "presets/a/b.json") ) def test_get_config_path(self): self.assertEqual(get_config_path(), tmp) self.assertEqual(get_config_path("a", "b"), os.path.join(tmp, "a/b")) input-remapper-1.4.0/tests/unit/test_presets.py000066400000000000000000000201521417303655400217130ustar00rootroot00000000000000#!/usr/bin/python3 # -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings # Copyright (C) 2022 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 import shutil import time from inputremapper.presets import ( find_newest_preset, rename_preset, get_any_preset, delete_preset, get_available_preset_name, get_presets, ) from inputremapper.paths import CONFIG_PATH, get_preset_path, touch, mkdir from inputremapper.gui.custom_mapping import custom_mapping from tests.test import tmp def create_preset(group_name, name="new preset"): name = get_available_preset_name(group_name, name) custom_mapping.empty() custom_mapping.save(get_preset_path(group_name, name)) PRESETS = os.path.join(CONFIG_PATH, "presets") class TestPresets(unittest.TestCase): def test_get_available_preset_name(self): # no filename conflict self.assertEqual(get_available_preset_name("_", "qux 2"), "qux 2") touch(get_preset_path("_", "qux 5")) self.assertEqual(get_available_preset_name("_", "qux 5"), "qux 6") touch(get_preset_path("_", "qux")) self.assertEqual(get_available_preset_name("_", "qux"), "qux 2") touch(get_preset_path("_", "qux1")) self.assertEqual(get_available_preset_name("_", "qux1"), "qux1 2") touch(get_preset_path("_", "qux 2 3")) self.assertEqual(get_available_preset_name("_", "qux 2 3"), "qux 2 4") touch(get_preset_path("_", "qux 5")) self.assertEqual(get_available_preset_name("_", "qux 5", True), "qux 5 copy") touch(get_preset_path("_", "qux 5 copy")) self.assertEqual(get_available_preset_name("_", "qux 5", True), "qux 5 copy 2") touch(get_preset_path("_", "qux 5 copy 2")) self.assertEqual(get_available_preset_name("_", "qux 5", True), "qux 5 copy 3") touch(get_preset_path("_", "qux 5copy")) self.assertEqual( get_available_preset_name("_", "qux 5copy", True), "qux 5copy copy" ) touch(get_preset_path("_", "qux 5copy 2")) self.assertEqual( get_available_preset_name("_", "qux 5copy 2", True), "qux 5copy 2 copy" ) touch(get_preset_path("_", "qux 5copy 2 copy")) self.assertEqual( get_available_preset_name("_", "qux 5copy 2 copy", True), "qux 5copy 2 copy 2", ) class TestCreatePreset(unittest.TestCase): def tearDown(self): if os.path.exists(tmp): shutil.rmtree(tmp) def test_create_preset_1(self): self.assertEqual(get_any_preset(), ("Foo Device", None)) create_preset("Foo Device") self.assertEqual(get_any_preset(), ("Foo Device", "new preset")) self.assertTrue(os.path.exists(f"{PRESETS}/Foo Device/new preset.json")) def test_create_preset_2(self): create_preset("Foo Device") create_preset("Foo Device") self.assertTrue(os.path.exists(f"{PRESETS}/Foo Device/new preset.json")) self.assertTrue(os.path.exists(f"{PRESETS}/Foo Device/new preset 2.json")) def test_create_preset_3(self): create_preset("Foo Device", "pre set") create_preset("Foo Device", "pre set") create_preset("Foo Device", "pre set") self.assertTrue(os.path.exists(f"{PRESETS}/Foo Device/pre set.json")) self.assertTrue(os.path.exists(f"{PRESETS}/Foo Device/pre set 2.json")) self.assertTrue(os.path.exists(f"{PRESETS}/Foo Device/pre set 3.json")) class TestDeletePreset(unittest.TestCase): def tearDown(self): if os.path.exists(tmp): shutil.rmtree(tmp) def test_delete_preset(self): create_preset("Foo Device") create_preset("Foo Device") self.assertTrue(os.path.exists(f"{PRESETS}/Foo Device/new preset.json")) delete_preset("Foo Device", "new preset") self.assertFalse(os.path.exists(f"{PRESETS}/Foo Device/new preset.json")) self.assertTrue(os.path.exists(f"{PRESETS}/Foo Device")) delete_preset("Foo Device", "new preset 2") self.assertFalse(os.path.exists(f"{PRESETS}/Foo Device/new preset.json")) self.assertFalse(os.path.exists(f"{PRESETS}/Foo Device/new preset 2.json")) # if no preset in the directory, remove the directory self.assertFalse(os.path.exists(f"{PRESETS}/Foo Device")) class TestRenamePreset(unittest.TestCase): def tearDown(self): if os.path.exists(tmp): shutil.rmtree(tmp) def test_rename_preset(self): create_preset("Foo Device", "preset 1") create_preset("Foo Device", "preset 2") create_preset("Foo Device", "foobar") rename_preset("Foo Device", "preset 1", "foobar") rename_preset("Foo Device", "preset 2", "foobar") self.assertFalse(os.path.exists(f"{PRESETS}/Foo Device/preset 1.json")) self.assertTrue(os.path.exists(f"{PRESETS}/Foo Device/foobar.json")) self.assertTrue(os.path.exists(f"{PRESETS}/Foo Device/foobar 2.json")) self.assertTrue(os.path.exists(f"{PRESETS}/Foo Device/foobar 3.json")) class TestFindPresets(unittest.TestCase): def tearDown(self): if os.path.exists(tmp): shutil.rmtree(tmp) def test_get_presets(self): os.makedirs(os.path.join(PRESETS, "1234")) os.mknod(os.path.join(PRESETS, "1234", "picture.png")) self.assertEqual(len(get_presets("1234")), 0) os.mknod(os.path.join(PRESETS, "1234", "foo bar 1.json")) time.sleep(0.01) os.mknod(os.path.join(PRESETS, "1234", "foo bar 2.json")) # the newest to the front self.assertListEqual(get_presets("1234"), ["foo bar 2", "foo bar 1"]) def test_find_newest_preset_1(self): create_preset("Foo Device", "preset 1") time.sleep(0.01) create_preset("Bar Device", "preset 2") # not a preset, ignore time.sleep(0.01) path = os.path.join(PRESETS, "Bar Device", "picture.png") os.mknod(path) self.assertEqual(find_newest_preset(), ("Bar Device", "preset 2")) def test_find_newest_preset_2(self): os.makedirs(f"{PRESETS}/Foo Device") time.sleep(0.01) os.makedirs(f"{PRESETS}/device_2") # takes the first one that the test-fake returns self.assertEqual(find_newest_preset(), ("Foo Device", None)) def test_find_newest_preset_3(self): os.makedirs(f"{PRESETS}/Foo Device") self.assertEqual(find_newest_preset(), ("Foo Device", None)) def test_find_newest_preset_4(self): create_preset("Foo Device", "preset 1") self.assertEqual(find_newest_preset(), ("Foo Device", "preset 1")) def test_find_newest_preset_5(self): create_preset("Foo Device", "preset 1") time.sleep(0.01) create_preset("unknown device 3", "preset 3") self.assertEqual(find_newest_preset(), ("Foo Device", "preset 1")) def test_find_newest_preset_6(self): # takes the first one that the test-fake returns self.assertEqual(find_newest_preset(), ("Foo Device", None)) def test_find_newest_preset_7(self): self.assertEqual(find_newest_preset("Foo Device"), ("Foo Device", None)) def test_find_newest_preset_8(self): create_preset("Foo Device", "preset 1") time.sleep(0.01) create_preset("Foo Device", "preset 3") time.sleep(0.01) create_preset("Bar Device", "preset 2") self.assertEqual(find_newest_preset("Foo Device"), ("Foo Device", "preset 3")) if __name__ == "__main__": unittest.main() input-remapper-1.4.0/tests/unit/test_reader.py000066400000000000000000000510511417303655400214720ustar00rootroot00000000000000#!/usr/bin/python3 # -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings # Copyright (C) 2022 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 import mock import time import multiprocessing from evdev.ecodes import ( EV_KEY, EV_ABS, ABS_HAT0X, KEY_COMMA, BTN_TOOL_DOUBLETAP, ABS_Z, ABS_Y, KEY_A, EV_REL, REL_WHEEL, REL_X, ABS_X, ABS_RZ, ) from inputremapper.gui.reader import reader, will_report_up from inputremapper.gui.custom_mapping import custom_mapping from inputremapper.config import BUTTONS, MOUSE from inputremapper.key import Key from inputremapper.gui.helper import RootHelper from inputremapper.groups import groups from tests.test import ( new_event, push_events, send_event_to_reader, EVENT_READ_TIMEOUT, START_READING_DELAY, quick_cleanup, MAX_ABS, ) CODE_1 = 100 CODE_2 = 101 CODE_3 = 102 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 class TestReader(unittest.TestCase): def setUp(self): self.helper = None def tearDown(self): quick_cleanup() if self.helper is not None: self.helper.join() groups.refresh() def create_helper(self): # this will cause pending events to be copied over to the helper # process def start_helper(): helper = RootHelper() helper.run() self.helper = multiprocessing.Process(target=start_helper) self.helper.start() time.sleep(0.1) def test_will_report_up(self): self.assertFalse(will_report_up(EV_REL)) self.assertTrue(will_report_up(EV_ABS)) self.assertTrue(will_report_up(EV_KEY)) def test_reading_1(self): # a single event push_events("Foo Device 2", [new_event(EV_ABS, ABS_HAT0X, 1)]) push_events( "Foo Device 2", [new_event(EV_ABS, REL_X, 1)] ) # mouse movements are ignored self.create_helper() reader.start_reading(groups.find(key="Foo Device 2")) time.sleep(0.2) self.assertEqual(reader.read(), (EV_ABS, ABS_HAT0X, 1)) self.assertEqual(reader.read(), None) self.assertEqual(len(reader._unreleased), 1) def test_reading_wheel(self): # will be treated as released automatically at some point self.create_helper() reader.start_reading(groups.find(key="Foo Device 2")) send_event_to_reader(new_event(EV_REL, REL_WHEEL, 0)) self.assertIsNone(reader.read()) send_event_to_reader(new_event(EV_REL, REL_WHEEL, 1)) result = reader.read() self.assertIsInstance(result, Key) self.assertEqual(result, (EV_REL, REL_WHEEL, 1)) self.assertEqual(result, ((EV_REL, REL_WHEEL, 1),)) self.assertNotEqual(result, ((EV_REL, REL_WHEEL, 1), (1, 1, 1))) self.assertEqual(result.keys, ((EV_REL, REL_WHEEL, 1),)) # it won't return the same event twice self.assertEqual(reader.read(), None) # but it is still remembered unreleased self.assertEqual(len(reader._unreleased), 1) self.assertEqual(reader.get_unreleased_keys(), (EV_REL, REL_WHEEL, 1)) self.assertIsInstance(reader.get_unreleased_keys(), Key) # as long as new wheel events arrive, it is considered unreleased for _ in range(10): send_event_to_reader(new_event(EV_REL, REL_WHEEL, 1)) self.assertEqual(reader.read(), None) self.assertEqual(len(reader._unreleased), 1) # read a few more times, at some point it is treated as unreleased for _ in range(4): self.assertEqual(reader.read(), None) self.assertEqual(len(reader._unreleased), 0) self.assertIsNone(reader.get_unreleased_keys()) """combinations""" send_event_to_reader(new_event(EV_REL, REL_WHEEL, 1, 1000)) send_event_to_reader(new_event(EV_KEY, KEY_COMMA, 1, 1001)) combi_1 = ((EV_REL, REL_WHEEL, 1), (EV_KEY, KEY_COMMA, 1)) combi_2 = ((EV_KEY, KEY_COMMA, 1), (EV_KEY, KEY_A, 1)) read = reader.read() self.assertEqual(read, combi_1) self.assertEqual(reader.read(), None) self.assertEqual(len(reader._unreleased), 2) self.assertEqual(reader.get_unreleased_keys(), combi_1) # don't send new wheel down events, it should get released again i = 0 while len(reader._unreleased) == 2: read = reader.read() if i == 100: raise AssertionError("Did not release the wheel") i += 1 # and only the comma remains. However, a changed combination is # only returned when a new key is pressed. Only then the pressed # down keys are collected in a new Key object. self.assertEqual(read, None) self.assertEqual(reader.read(), None) self.assertEqual(len(reader._unreleased), 1) self.assertEqual(reader.get_unreleased_keys(), combi_1[1]) # press down a new key, now it will return a different combination send_event_to_reader(new_event(EV_KEY, KEY_A, 1, 1002)) self.assertEqual(reader.read(), combi_2) self.assertEqual(len(reader._unreleased), 2) # release all of them send_event_to_reader(new_event(EV_KEY, KEY_COMMA, 0)) send_event_to_reader(new_event(EV_KEY, KEY_A, 0)) self.assertEqual(reader.read(), None) self.assertEqual(len(reader._unreleased), 0) self.assertEqual(reader.get_unreleased_keys(), None) def test_change_wheel_direction(self): # not just wheel, anything that suddenly reports a different value. # as long as type and code are equal its the same key, so there is no # way both directions can be held down. self.assertEqual(reader.read(), None) self.create_helper() self.assertEqual(reader.read(), None) reader.start_reading(groups.find(key="Foo Device 2")) self.assertEqual(reader.read(), None) send_event_to_reader(new_event(EV_REL, REL_WHEEL, 1)) self.assertEqual(reader.read(), (EV_REL, REL_WHEEL, 1)) self.assertEqual(len(reader._unreleased), 1) self.assertEqual(reader.read(), None) send_event_to_reader(new_event(EV_REL, REL_WHEEL, -1)) self.assertEqual(reader.read(), (EV_REL, REL_WHEEL, -1)) # notice that this is no combination of two sides, the previous # entry in unreleased has to get overwritten. So there is still only # one element in it. self.assertEqual(len(reader._unreleased), 1) self.assertEqual(reader.read(), None) def test_change_device(self): push_events( "Foo Device 2", [ new_event(EV_KEY, 1, 1), ] * 100, ) push_events( "Bar Device", [ new_event(EV_KEY, 2, 1), ] * 100, ) self.create_helper() reader.start_reading(groups.find(key="Foo Device 2")) time.sleep(0.1) self.assertEqual(reader.read(), Key(EV_KEY, 1, 1)) reader.start_reading(groups.find(name="Bar Device")) # it's plausible that right after sending the new read command more # events from the old device might still appear. Give the helper # some time to handle the new command. time.sleep(0.1) reader.clear() time.sleep(0.1) self.assertEqual(reader.read(), Key(EV_KEY, 2, 1)) def test_reading_2(self): # a combination of events push_events( "Foo Device 2", [ new_event(EV_KEY, CODE_1, 1, 10000.1234), new_event(EV_KEY, CODE_3, 1, 10001.1234), new_event(EV_ABS, ABS_HAT0X, -1, 10002.1234), ], ) pipe = multiprocessing.Pipe() def refresh(): # from within the helper process notify this test that # refresh was called as expected pipe[1].send("refreshed") with mock.patch.object(groups, "refresh", refresh): self.create_helper() reader.start_reading(groups.find(key="Foo Device 2")) # sending anything arbitrary does not stop the helper reader._commands.send(856794) time.sleep(0.2) # but it makes it look for new devices because maybe its list of # groups is not up-to-date self.assertTrue(pipe[0].poll()) self.assertEqual(pipe[0].recv(), "refreshed") self.assertEqual( reader.read(), ((EV_KEY, CODE_1, 1), (EV_KEY, CODE_3, 1), (EV_ABS, ABS_HAT0X, -1)), ) self.assertEqual(reader.read(), None) self.assertEqual(len(reader._unreleased), 3) def test_reading_3(self): self.create_helper() # a combination of events via Socket with reads inbetween reader.start_reading(groups.find(name="gamepad")) send_event_to_reader(new_event(EV_KEY, CODE_1, 1, 1001)) self.assertEqual(reader.read(), ((EV_KEY, CODE_1, 1))) custom_mapping.set("gamepad.joystick.left_purpose", BUTTONS) send_event_to_reader(new_event(EV_ABS, ABS_Y, 1, 1002)) self.assertEqual(reader.read(), ((EV_KEY, CODE_1, 1), (EV_ABS, ABS_Y, 1))) send_event_to_reader(new_event(EV_ABS, ABS_HAT0X, -1, 1003)) self.assertEqual( reader.read(), ((EV_KEY, CODE_1, 1), (EV_ABS, ABS_Y, 1), (EV_ABS, ABS_HAT0X, -1)), ) # adding duplicate down events won't report a different combination. # import for triggers, as they keep reporting more down-events before # they are released send_event_to_reader(new_event(EV_ABS, ABS_Y, 1, 1005)) self.assertEqual(reader.read(), None) send_event_to_reader(new_event(EV_ABS, ABS_HAT0X, -1, 1006)) self.assertEqual(reader.read(), None) send_event_to_reader(new_event(EV_KEY, CODE_1, 0, 1004)) read = reader.read() self.assertEqual(read, None) send_event_to_reader(new_event(EV_ABS, ABS_Y, 0, 1007)) self.assertEqual(reader.read(), None) send_event_to_reader(new_event(EV_KEY, ABS_HAT0X, 0, 1008)) self.assertEqual(reader.read(), None) def test_reads_joysticks(self): # if their purpose is "buttons" custom_mapping.set("gamepad.joystick.left_purpose", BUTTONS) push_events( "gamepad", [ new_event(EV_ABS, ABS_Y, MAX_ABS), # the value of that one is interpreted as release, because # it is too small new_event(EV_ABS, ABS_X, MAX_ABS // 10), ], ) self.create_helper() reader.start_reading(groups.find(name="gamepad")) time.sleep(0.2) self.assertEqual(reader.read(), (EV_ABS, ABS_Y, 1)) self.assertEqual(reader.read(), None) self.assertEqual(len(reader._unreleased), 1) reader._unreleased = {} custom_mapping.set("gamepad.joystick.left_purpose", MOUSE) push_events("gamepad", [new_event(EV_ABS, ABS_Y, MAX_ABS)]) self.create_helper() reader.start_reading(groups.find(name="gamepad")) time.sleep(0.1) self.assertEqual(reader.read(), None) self.assertEqual(len(reader._unreleased), 0) def test_combine_triggers(self): reader.start_reading(groups.find(key="Foo Device 2")) i = 0 def next_timestamp(): nonlocal i i += 1 return time.time() + i # based on an observed bug send_event_to_reader(new_event(3, 1, 0, next_timestamp())) send_event_to_reader(new_event(3, 0, 0, next_timestamp())) send_event_to_reader(new_event(3, 2, 1, next_timestamp())) self.assertEqual(reader.read(), (EV_ABS, ABS_Z, 1)) send_event_to_reader(new_event(3, 0, 0, next_timestamp())) send_event_to_reader(new_event(3, 5, 1, next_timestamp())) self.assertEqual(reader.read(), ((EV_ABS, ABS_Z, 1), (EV_ABS, ABS_RZ, 1))) send_event_to_reader(new_event(3, 5, 0, next_timestamp())) send_event_to_reader(new_event(3, 0, 0, next_timestamp())) send_event_to_reader(new_event(3, 1, 0, next_timestamp())) self.assertEqual(reader.read(), None) send_event_to_reader(new_event(3, 2, 1, next_timestamp())) send_event_to_reader(new_event(3, 1, 0, next_timestamp())) send_event_to_reader(new_event(3, 0, 0, next_timestamp())) # due to not properly handling the duplicate down event it cleared # the combination and returned it. Instead it should report None # and by doing that keep the previous combination. self.assertEqual(reader.read(), None) def test_blacklisted_events(self): push_events( "Foo Device 2", [ new_event(EV_KEY, BTN_TOOL_DOUBLETAP, 1), new_event(EV_KEY, CODE_2, 1), new_event(EV_KEY, BTN_TOOL_DOUBLETAP, 1), ], ) self.create_helper() reader.start_reading(groups.find(key="Foo Device 2")) time.sleep(0.1) self.assertEqual(reader.read(), (EV_KEY, CODE_2, 1)) self.assertEqual(reader.read(), None) self.assertEqual(len(reader._unreleased), 1) def test_ignore_value_2(self): # this is not a combination, because (EV_KEY CODE_3, 2) is ignored push_events( "Foo Device 2", [new_event(EV_ABS, ABS_HAT0X, 1), new_event(EV_KEY, CODE_3, 2)], ) self.create_helper() reader.start_reading(groups.find(key="Foo Device 2")) time.sleep(0.2) self.assertEqual(reader.read(), (EV_ABS, ABS_HAT0X, 1)) self.assertEqual(reader.read(), None) self.assertEqual(len(reader._unreleased), 1) def test_reading_ignore_up(self): push_events( "Foo Device 2", [ 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_helper() reader.start_reading(groups.find(key="Foo Device 2")) time.sleep(0.1) self.assertEqual(reader.read(), (EV_KEY, CODE_2, 1)) self.assertEqual(reader.read(), None) self.assertEqual(len(reader._unreleased), 1) def test_reading_ignore_duplicate_down(self): send_event_to_reader(new_event(EV_ABS, ABS_Z, 1, 10)) self.assertEqual(reader.read(), (EV_ABS, ABS_Z, 1)) self.assertEqual(reader.read(), None) # duplicate send_event_to_reader(new_event(EV_ABS, ABS_Z, 1, 10)) self.assertEqual(reader.read(), None) self.assertEqual(len(reader._unreleased), 1) self.assertEqual(len(reader.get_unreleased_keys()), 1) self.assertIsInstance(reader.get_unreleased_keys(), Key) # release send_event_to_reader(new_event(EV_ABS, ABS_Z, 0, 10)) self.assertEqual(reader.read(), None) self.assertEqual(len(reader._unreleased), 0) self.assertIsNone(reader.get_unreleased_keys()) def test_wrong_device(self): push_events( "Foo Device 2", [ new_event(EV_KEY, CODE_1, 1), new_event(EV_KEY, CODE_2, 1), new_event(EV_KEY, CODE_3, 1), ], ) self.create_helper() reader.start_reading(groups.find(name="Bar Device")) time.sleep(EVENT_READ_TIMEOUT * 5) self.assertEqual(reader.read(), None) self.assertEqual(len(reader._unreleased), 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. push_events( "input-remapper Bar Device", [ new_event(EV_KEY, CODE_1, 1), new_event(EV_KEY, CODE_2, 1), new_event(EV_KEY, CODE_3, 1), ], ) self.create_helper() reader.start_reading(groups.find(name="Bar Device")) time.sleep(EVENT_READ_TIMEOUT * 5) self.assertEqual(reader.read(), None) self.assertEqual(len(reader._unreleased), 0) def test_clear(self): push_events( "Foo Device 2", [ new_event(EV_KEY, CODE_1, 1), new_event(EV_KEY, CODE_2, 1), new_event(EV_KEY, CODE_3, 1), ] * 15, ) self.create_helper() reader.start_reading(groups.find(key="Foo Device 2")) time.sleep(START_READING_DELAY + EVENT_READ_TIMEOUT * 3) reader.read() self.assertEqual(len(reader._unreleased), 3) self.assertIsNotNone(reader.previous_event) self.assertIsNotNone(reader.previous_result) # make the helper send more events to the reader time.sleep(EVENT_READ_TIMEOUT * 2) self.assertTrue(reader._results.poll()) reader.clear() self.assertFalse(reader._results.poll()) self.assertEqual(reader.read(), None) self.assertEqual(len(reader._unreleased), 0) self.assertIsNone(reader.get_unreleased_keys()) self.assertIsNone(reader.previous_event) self.assertIsNone(reader.previous_result) self.tearDown() def test_switch_device(self): push_events("Bar Device", [new_event(EV_KEY, CODE_1, 1)]) push_events("Foo Device 2", [new_event(EV_KEY, CODE_3, 1)]) self.create_helper() reader.start_reading(groups.find(name="Bar Device")) self.assertFalse(reader._results.poll()) self.assertEqual(reader.group.name, "Bar Device") time.sleep(EVENT_READ_TIMEOUT * 5) self.assertTrue(reader._results.poll()) reader.start_reading(groups.find(key="Foo Device 2")) self.assertEqual(reader.group.name, "Foo Device") self.assertFalse(reader._results.poll()) # pipe resets time.sleep(EVENT_READ_TIMEOUT * 5) self.assertTrue(reader._results.poll()) self.assertEqual(reader.read(), (EV_KEY, CODE_3, 1)) self.assertEqual(reader.read(), None) self.assertEqual(len(reader._unreleased), 1) def test_terminate(self): self.create_helper() reader.start_reading(groups.find(key="Foo Device 2")) push_events("Foo Device 2", [new_event(EV_KEY, CODE_3, 1)]) time.sleep(START_READING_DELAY + EVENT_READ_TIMEOUT) self.assertTrue(reader._results.poll()) reader.terminate() reader.clear() time.sleep(EVENT_READ_TIMEOUT) # no new events arrive after terminating push_events("Foo Device 2", [new_event(EV_KEY, CODE_3, 1)]) time.sleep(EVENT_READ_TIMEOUT * 3) self.assertFalse(reader._results.poll()) def test_are_new_groups_available(self): self.create_helper() groups.set_groups({}) # read stuff from the helper, which includes the devices self.assertFalse(reader.are_new_groups_available()) reader.read() self.assertTrue(reader.are_new_groups_available()) # a bit weird, but it assumes the gui handled that and returns # false afterwards self.assertFalse(reader.are_new_groups_available()) # send the same devices again reader._get_event({"type": "groups", "message": groups.dumps()}) self.assertFalse(reader.are_new_groups_available()) # send changed devices message = groups.dumps() message = message.replace("Foo Device", "foo_device") reader._get_event({"type": "groups", "message": message}) self.assertTrue(reader.are_new_groups_available()) self.assertFalse(reader.are_new_groups_available()) if __name__ == "__main__": unittest.main() input-remapper-1.4.0/tests/unit/test_test.py000066400000000000000000000103051417303655400212040ustar00rootroot00000000000000#!/usr/bin/python3 # -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings # Copyright (C) 2022 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 import time import multiprocessing import evdev from evdev.ecodes import EV_ABS, EV_KEY from inputremapper.groups import groups from inputremapper.gui.reader import reader from inputremapper.gui.helper import RootHelper from tests.test import ( InputDevice, quick_cleanup, cleanup, fixtures, new_event, push_events, EVENT_READ_TIMEOUT, START_READING_DELAY, ) class TestTest(unittest.TestCase): def test_stubs(self): self.assertIsNotNone(groups.find(key="Foo Device 2")) def tearDown(self): quick_cleanup() 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[1] = [1234] del fixtures["/dev/input/event11"] cleanup() self.assertIsNone(fixtures.get(1)) 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 helper and reader. Using push_events after the helper is already forked should work, as well as using push_event twice """ def create_helper(): # this will cause pending events to be copied over to the helper # process def start_helper(): helper = RootHelper() helper.run() self.helper = multiprocessing.Process(target=start_helper) self.helper.start() time.sleep(0.1) def wait_for_results(): # wait for the helper to send stuff for _ in range(10): time.sleep(EVENT_READ_TIMEOUT) if reader._results.poll(): break event = new_event(EV_KEY, 102, 1) create_helper() reader.start_reading(groups.find(key="Foo Device 2")) time.sleep(START_READING_DELAY) push_events("Foo Device 2", [event]) wait_for_results() self.assertTrue(reader._results.poll()) reader.clear() self.assertFalse(reader._results.poll()) # can push more events to the helper that is inside a separate # process, which end up being sent to the reader push_events("Foo Device 2", [event]) wait_for_results() self.assertTrue(reader._results.poll()) if __name__ == "__main__": unittest.main() input-remapper-1.4.0/tests/unit/test_user.py000066400000000000000000000041531417303655400212070ustar00rootroot00000000000000#!/usr/bin/python3 # -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings # Copyright (C) 2022 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 get_user, get_home from tests.test import quick_cleanup def _raise(error): raise error class TestUser(unittest.TestCase): def tearDown(self): quick_cleanup() def test_get_user(self): with mock.patch("os.getlogin", lambda: "foo"): self.assertEqual(get_user(), "foo") with mock.patch("os.getlogin", lambda: "root"): self.assertEqual(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(get_user(), "qux") os.environ["USER"] = "root" del os.environ["SUDO_USER"] os.environ["PKEXEC_UID"] = "1000" self.assertNotEqual(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(get_home("foo"), "/custom/home/foo") input-remapper-1.4.0/tests/xmodmap.py000066400000000000000000000306011417303655400176550ustar00rootroot00000000000000xmodmap = ( 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" )