pax_global_header00006660000000000000000000000064147543346520014527gustar00rootroot0000000000000052 comment=3b174be31aa5caf440b9079d2fe8dbe909be1049 input-remapper-2.1.1/000077500000000000000000000000001475433465200145005ustar00rootroot00000000000000input-remapper-2.1.1/.coveragerc000066400000000000000000000005721475433465200166250ustar00rootroot00000000000000[run] branch = True source = ./inputremapper concurrency = multiprocessing debug = multiproc omit = # not used currently due to problems ./inputremapper/ipc/socket.py [report] exclude_lines = pragma: no cover # Don't complain about abstract methods, they aren't run: @(abc\.)?abstractmethod # Don't cover Protocol classes class .*\(.*Protocol.*\): input-remapper-2.1.1/.github/000077500000000000000000000000001475433465200160405ustar00rootroot00000000000000input-remapper-2.1.1/.github/ISSUE_TEMPLATE/000077500000000000000000000000001475433465200202235ustar00rootroot00000000000000input-remapper-2.1.1/.github/ISSUE_TEMPLATE/autoloading-not-working.md000066400000000000000000000020141475433465200253240ustar00rootroot00000000000000--- name: Autoloading not working about: "..." title: '' labels: '' assignees: '' --- Please install the newest version from source to see if the problem has already been solved. **System Information and logs** 1. `input-remapper-control --version` 2. which linux distro (ubuntu 20.04, manjaro, etc.) 3. which desktop environment (gnome, plasma, xfce4, etc.) 4. `sudo ls -l /proc/1/exe` to check if you are using systemd 5. `cat ~/.config/input-remapper-2/config.json` to see if the "autoload" config is written correctly 6. `systemctl status input-remapper -n 50` the service has to be running 7. `journalctl -b | grep input-remapper` **Testing the setup** 1. `input-remapper-control --command hello` 2. `sudo pkill -f input-remapper-service && sudo input-remapper-service -d & sleep 2 && input-remapper-control --command autoload`, are your keys mapped now? 3. `sudo udevadm control --log-priority=debug && sudo udevadm control --reload-rules && journalctl -f | grep input-remapper`, now plug in the device that should autoload input-remapper-2.1.1/.github/ISSUE_TEMPLATE/buttons-not-showing-up---can-t-map-a-key-in-the-gui.md000066400000000000000000000010421475433465200320110ustar00rootroot00000000000000--- 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-2.1.1/.github/ISSUE_TEMPLATE/key-not-getting-injected.md000066400000000000000000000013751475433465200253630ustar00rootroot00000000000000--- 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-2/presets 7. `sudo pkill -f input-remapper-service && input-remapper-gtk -d`, apply the preset and hit your key. Then share that log. 8. `sudo evtest` while the previous command is running, to see how events are injected. Devices starting with `input-remapper ...` are of interest. input-remapper-2.1.1/.github/ISSUE_TEMPLATE/something-else-is-not-working.md000066400000000000000000000003721475433465200263570ustar00rootroot00000000000000--- name: Something else is not working about: "..." title: '' labels: '' assignees: '' --- To help people understand your problems, run `sudo pkill -f input-remapper && input-remapper-gtk -d`, reproduce the problem and then share the output here. input-remapper-2.1.1/.github/workflows/000077500000000000000000000000001475433465200200755ustar00rootroot00000000000000input-remapper-2.1.1/.github/workflows/lint.yml000066400000000000000000000013711475433465200215700ustar00rootroot00000000000000name: Lint on: [push, pull_request] jobs: black: runs-on: ubuntu-latest if: github.event_name == 'push' || github.event.pull_request.head.repo.full_name != github.repository strategy: matrix: python-version: ["3.11"] steps: - uses: actions/checkout@v3 - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v4 with: python-version: ${{ matrix.python-version }} cache: 'pip' cache-dependency-path: setup.py - name: Install dependencies run: | scripts/ci-install-deps.sh pip install black - name: Analysing the code with black --check --diff run: | black --version black --check --diff ./inputremapper ./tests input-remapper-2.1.1/.github/workflows/reviewdog.yml000066400000000000000000000026671475433465200226260ustar00rootroot00000000000000--- name: reviewdog # run reviewdog for PR only because "github-check" option is failing :( # https://github.com/reviewdog/reviewdog/issues/924 on: [pull_request] jobs: reviewdog_python: name: reviewdog - Python lint runs-on: ubuntu-latest strategy: matrix: python-version: ["3.11"] steps: - uses: actions/checkout@v3 - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v4 with: python-version: ${{ matrix.python-version }} cache: 'pip' cache-dependency-path: setup.py - uses: reviewdog/action-setup@master with: reviewdog_version: latest - name: Install dependencies shell: bash run: | scripts/ci-install-deps.sh pip install flake8 pylint mypy black types-setuptools - name: Set env for PR if: github.event_name == 'pull_request' shell: bash run: echo "REWIEVDOG_REPORTER=github-pr-review" >> $GITHUB_ENV - name: Set env for push if: github.event_name != 'pull_request' shell: bash run: echo "REWIEVDOG_REPORTER=github-check" >> $GITHUB_ENV - name: Run reviewdog shell: bash env: REVIEWDOG_GITHUB_API_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | reviewdog -list reviewdog -tee -runners=mypy,black -reporter=${{ env.REWIEVDOG_REPORTER }} -fail-on-error=false input-remapper-2.1.1/.github/workflows/test.yml000066400000000000000000000025221475433465200216000ustar00rootroot00000000000000name: Test on: [push, pull_request] jobs: build: continue-on-error: true runs-on: ubuntu-latest if: github.event_name == 'push' || github.event.pull_request.head.repo.full_name != github.repository strategy: matrix: python-version: ["3.8", "3.12"] # min and max supported versions? steps: - uses: actions/checkout@v3 - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v4 with: python-version: ${{ matrix.python-version }} # broken "Error: Cache folder path is retrieved for pip but doesn't exist on disk: /home/runner/.cache/pip" # cache: 'pip' # cache-dependency-path: setup.py - name: Install dependencies run: | # Install deps as root since we will run tests as root sudo scripts/ci-install-deps.sh sudo pip install . - name: Run tests run: | # FIXME: Had some permissions issues, currently worked around by running tests as root mkdir test_tmp export TMPDIR="$(realpath test_tmp)" export DATA_DIR="/home/runner/work/input-remapper/input-remapper/data/" # try this if input-remappers data cannot be found, and set DATA_DIR to a matching directory # find / -type f -name "input-remapper.glade" sudo -E python -m unittest discover tests/unit input-remapper-2.1.1/.gitignore000066400000000000000000000035451475433465200164770ustar00rootroot00000000000000inputremapper/commit_hash.py mo *.glade~ *.glade# .idea *.png~* *.orig # Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] *$py.class # C extensions *.so # Distribution / packaging .Python build/ develop-eggs/ dist/ downloads/ eggs/ .eggs/ /lib/ lib64/ parts/ sdist/ var/ wheels/ pip-wheel-metadata/ share/python-wheels/ *.egg-info/ .installed.cfg *.egg MANIFEST # PyInstaller # Usually these files are written by a python script from a template # before PyInstaller builds the exe, so as to inject date/other infos into it. *.manifest *.spec # Installer logs pip-log.txt pip-delete-this-directory.txt # Unit test / coverage reports htmlcov/ .tox/ .nox/ .coverage .coverage.* .cache nosetests.xml coverage.xml *.cover *.py,cover .hypothesis/ .pytest_cache/ # pyreverse graphs *.dot # Translations *.mo # Django stuff: *.log local_settings.py db.sqlite3 db.sqlite3-journal # Flask stuff: instance/ .webassets-cache # Scrapy stuff: .scrapy # Sphinx documentation docs/_build/ # PyBuilder target/ # Jupyter Notebook .ipynb_checkpoints # IPython profile_default/ ipython_config.py # pyenv .python-version # pipenv # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. # However, in case of collaboration, if having platform-specific dependencies or dependencies # having no cross-platform support, pipenv may install dependencies that don't work, or not # install all needed dependencies. #Pipfile.lock # PEP 582; used by e.g. github.com/David-OConnor/pyflow __pypackages__/ # Celery stuff celerybeat-schedule celerybeat.pid # SageMath parsed files *.sage.py # Environments .env .venv env/ venv/ ENV/ env.bak/ venv.bak/ # Spyder project settings .spyderproject .spyproject # Rope project settings .ropeproject # mkdocs documentation /site # mypy .mypy_cache/ .dmypy.json dmypy.json # Pyre type checker .pyre/ input-remapper-2.1.1/.mypy.ini000066400000000000000000000001601475433465200162520ustar00rootroot00000000000000 [mypy] plugins = pydantic.mypy # ignore the missing evdev stubs [mypy-evdev.*] ignore_missing_imports = True input-remapper-2.1.1/.pylintrc000066400000000000000000000007121475433465200163450ustar00rootroot00000000000000[_] max-line-length=88 # black extension-pkg-whitelist=evdev, pydantic load-plugins=pylint_pydantic disable= # that is the standard way to import GTK afaik wrong-import-position, # using """ for comments highlights them in green for me and makes it # a great way to separate stuff into multiple sections pointless-string-statement # https://github.com/psf/black/blob/main/docs/compatible_configs/pylint/pylintrc C0330, C0326 input-remapper-2.1.1/.reviewdog.yml000066400000000000000000000007061475433465200172770ustar00rootroot00000000000000--- runner: mypy: name: mypy cmd: mypy --show-column-numbers inputremapper tests --ignore-missing-imports errorformat: - "%f:%l:%c: %m" pylint: name: pylint cmd: pylint inputremapper tests --extension-pkg-whitelist=evdev errorformat: - "%f:%l:%c: %t%n: %m" flake8: cmd: flake8 inputremapper tests format: flake8 black: cmd: black --diff --quiet --check ./inputremapper ./tests format: black input-remapper-2.1.1/DEBIAN/000077500000000000000000000000001475433465200154225ustar00rootroot00000000000000input-remapper-2.1.1/DEBIAN/control000066400000000000000000000011631475433465200170260ustar00rootroot00000000000000Package: input-remapper Version: 2.1.1 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, python3-pydantic, python3-packaging, python3-psutil Description: A tool to change the mapping of your input device buttons Replaces: python3-key-mapper, key-mapper, input-remapper-gtk, input-remapper-daemon, python3-inputremapper Conflicts: python3-key-mapper, key-mapper, input-remapper-gtk, input-remapper-daemon, python3-inputremapper input-remapper-2.1.1/DEBIAN/copyright000066400000000000000000000000611475433465200173520ustar00rootroot00000000000000Files: * Copyright: 2023 Sezanzeb License: GPL-3+input-remapper-2.1.1/DEBIAN/postinst000077500000000000000000000014161475433465200172350ustar00rootroot00000000000000#!/bin/bash if [ -d "/run/systemd/system/" ]; then # old name, those lines should at some point be removed from postinst pkill -f key-mapper-service systemctl disable key-mapper 2> /dev/null || true systemctl stop key-mapper 2> /dev/null || true # The ubuntu package creates those two symlinks that break when installing the .deb # built from source. Either ubuntus packages need to be uninstalled with --purge first, # or those files need to be unlinked manually. unlink /etc/systemd/system/input-remapper.service || true unlink /etc/systemd/system/default.target.wants/input-remapper-daemon.service || true pkill -f input-remapper-service # might have been started by the gui previously systemctl enable input-remapper systemctl start input-remapper fi input-remapper-2.1.1/LICENSE000066400000000000000000001045151475433465200155130ustar00rootroot00000000000000 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-2.1.1/README.md000066400000000000000000000052051475433465200157610ustar00rootroot00000000000000

Input Remapper

An easy to use tool for Linux to change the behaviour of your input devices.
Supports X11, Wayland, combinations, programmable macros, joysticks, wheels,
triggers, keys, mouse-movements and more. Maps any input to any other input.

Usage - Macros - Installation - Development - Examples

 


## Installation ### Ubuntu/Debian Either download an installable .deb file from the [latest release](https://github.com/sezanzeb/input-remapper/releases): ```bash wget https://github.com/sezanzeb/input-remapper/releases/download/2.1.1/input-remapper-2.1.1.deb sudo apt install -f ./input-remapper-2.1.1.deb ``` Or install the very 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 purge input-remapper input-remapper-daemon input-remapper-gtk python3-inputremapper sudo apt install -f ./dist/input-remapper-2.1.1.deb ``` Input Remapper is also available in the repositories of [Debian](https://tracker.debian.org/pkg/input-remapper) and [Ubuntu](https://packages.ubuntu.com/oracular/input-remapper) via ```bash sudo apt install input-remapper ``` Input Remapper ≥ 2.0 requires at least Ubuntu 22.04.
### Fedora ```bash sudo dnf install input-remapper sudo systemctl enable --now input-remapper ```
### Arch ```bash yay -S input-remapper-git sudo systemctl enable --now input-remapper ```
### Other Distros Figure out the packages providing those dependencies in your distro, and install them: `python3-evdev` ≥1.3.0, `gtksourceview4`, `python3-devel`, `python3-pydantic`, `python3-pydbus`, `python3-psutil` You can also use pip to install some of them. Python packages need to be installed globally for the service to be able to import them. Don't use `--user`. Conda and such may also cause problems due to changed python paths and versions. ```bash sudo pip install evdev pydantic pydbus PyGObject setuptools ``` ```bash git clone https://github.com/sezanzeb/input-remapper.git cd input-remapper sudo python3 setup.py install sudo systemctl enable --now input-remapper ``` input-remapper-2.1.1/bin/000077500000000000000000000000001475433465200152505ustar00rootroot00000000000000input-remapper-2.1.1/bin/input-remapper-control000077500000000000000000000032701475433465200216260ustar00rootroot00000000000000#!/usr/bin/env python3 # -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings # Copyright (C) 2025 sezanzeb # # This file is part of input-remapper. # # input-remapper is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # input-remapper is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with input-remapper. If not, see . """Control the dbus service from the command line.""" import os import sys def fix_import_path(): # Installations via `sudo python3 setup.py install` install into /usr/local/lib # instead of /usr/local. sys.path is missing /usr/local/lib when udev is running # its rules. try: import inputremapper except ModuleNotFoundError: python_folder = f"python{sys.version_info.major}.{sys.version_info.minor}" usr_local_lib = f"/usr/local/lib/{python_folder}/dist-packages" if not os.path.exists(usr_local_lib): return print(f'Appending "{usr_local_lib}" to sys.path') sys.path.append(usr_local_lib) fix_import_path() from inputremapper.bin.input_remapper_control import InputRemapperControlBin if __name__ == "__main__": InputRemapperControlBin.main(InputRemapperControlBin.parse_args()) input-remapper-2.1.1/bin/input-remapper-gtk000077500000000000000000000017611475433465200207360ustar00rootroot00000000000000#!/usr/bin/env python3 # -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings # Copyright (C) 2025 sezanzeb # # This file is part of input-remapper. # # input-remapper is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # input-remapper is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with input-remapper. If not, see . """Starts the user interface.""" from inputremapper.bin.input_remapper_gtk import InputRemapperGtkBin if __name__ == "__main__": InputRemapperGtkBin.main() input-remapper-2.1.1/bin/input-remapper-reader-service000077500000000000000000000020361475433465200230450ustar00rootroot00000000000000#!/usr/bin/env python3 # -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings # Copyright (C) 2025 sezanzeb # # This file is part of input-remapper. # # input-remapper is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # input-remapper is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with input-remapper. If not, see . """Starts the root reader-service.""" from inputremapper.bin.input_remapper_reader_service import ( InputRemapperReaderServiceBin, ) if __name__ == "__main__": InputRemapperReaderServiceBin.main() input-remapper-2.1.1/bin/input-remapper-service000077500000000000000000000020301475433465200215770ustar00rootroot00000000000000#!/usr/bin/env python3 # -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings # Copyright (C) 2025 sezanzeb # # This file is part of input-remapper. # # input-remapper is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # input-remapper is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with input-remapper. If not, see . """Starts injecting keycodes based on the configuration.""" from inputremapper.bin.input_remapper_service import InputRemapperServiceBin if __name__ == "__main__": InputRemapperServiceBin.main() input-remapper-2.1.1/data/000077500000000000000000000000001475433465200154115ustar00rootroot00000000000000input-remapper-2.1.1/data/99-input-remapper.rules000066400000000000000000000007151475433465200216770ustar00rootroot00000000000000# helpful commands: # udevadm monitor --property # udevadm info --query=all --name=/dev/input/event3 # to test changes: # sudo udevadm control --log-priority=debug # sudo udevadm control --reload-rules # journalctl -f # to get available variables: # udevadm monitor --environment --udev --subsystem input ACTION=="add", SUBSYSTEM=="input", ENV{ID_PATH}!="platform-sound", RUN+="/bin/input-remapper-control --command autoload --device $env{DEVNAME}" input-remapper-2.1.1/data/input-remapper-autoload.desktop000066400000000000000000000004151475433465200235620ustar00rootroot00000000000000[Desktop Entry] Type=Application Exec=bash -c "input-remapper-control --command stop-all && input-remapper-control --command autoload" Name=input-remapper-autoload Icon=input-remapper Comment=Starts injecting all presets that are set to automatically load for the user input-remapper-2.1.1/data/input-remapper-gtk.desktop000066400000000000000000000002611475433465200225360ustar00rootroot00000000000000[Desktop Entry] Type=Application Name=Input Remapper Icon=input-remapper Exec=input-remapper-gtk Terminal=false Categories=Settings Comment=GUI for device specific key mappings input-remapper-2.1.1/data/input-remapper-large.png000066400000000000000000000170011475433465200221560ustar00rootroot00000000000000PNG  IHDRnn[&oiCCPicc(u;KA?7J)HB!(&FmuͲ b+X`  bHXd ޏ3s.3g@zmB*9Xx!ҌF'hiݵ'gg;iRnXߟ}puhjm$N(I:ћc5ĽT&_%;od.'57 r2<.=hTώ8xMdK̆"Ӂ*8\H55DZZDY(o_NYJ^CU|(U:ߖa<ϓ=I'-[6kPvfe'b3ݎ 1#-~$R&+qOɲ#c.}(9hɒhe-k<h2zc荿Mg^dVV&t E/gr|)җ 9t-A?6;w[K.V\h`^?y3杙/" ɇ7v,Fm2+=J{u{@dd$]t3"omm5[};N'wήBRقe>'VmzSm]Ej8`_]SKE%Ŵk^:TXh=+L): d:i͎ڿψc |fk>4>O>8O=x?PxͤND&'%pTzc. )IЉq=DZ8/*uED8BÏ;r0|:y(WttϝߣyfRԩ4loz#S>6P*.`ODMq=諢nߡ:H\wI*EF,:,[0bbQ#(h/yEХq09;n2z4Ji4eUVRӦ)K A/SO&^:-ז&U?>vK81fAKª/ DHͦvBwCr!#(3z}m^\qq-Xi$ĀӒÔ4tS7Y}xto6o_=[vgxpErPcYya㶽D9=SE\踠OfhDw~3UzpıiiqpU!1Hp$X\^[eW7SE͉pV{4 CRf* Cw pj:\}Gi<7Li^)*aw|o Rǰ7[5aleC }!0TUxy!ݐt26Μi.Cg]HXlYa|8u 7ASn:+HQz2]jF|`7`~ L$|[9RF\g~#KMd uJ>_o)[%K J2IS?L# =_xiM)X).s >ۈf##,SšEtD3)_zWf;2o,ok7/K B SH=8I&4\L{6X,X&E5xyų@ٟYTAdnK;}5soo zp!A[RU{(ĀH/I\ΰs! mybrf..#|6_qMP3B]۰FBe|tV3٭i+ӂ`f@)Fw!1zofz~NZGZGTs;OlYYj3f_v"P^gO0βVcǟ22 4JJ-̟ӧј+P64x;;\(&Jp :^T:DV^/$@ ))$n7I[._ YN]@(FH e θBx}[D%, SEy,ijOh7>>cb\)c/kn&Vl=G ʥoQSWʊJFd`z`"oьimMqq Z`1]0Ξ"A:Y[dgwZa}n=?{ND~klB@Ї: |Wp%x2o&Y|+!q_nb+FeYA1̘!㞯GYHLHE3¾w? LTQDvUxua]n+ȍUY!,*`|;샂#"@ᑣ°DᏙ1kYY4]p)QqVa)i8m=VuD`$4ÖY*}YvxЖ44lw2Mp0݊qK}$f"gf6ng'ehfVE )0́,tV T j iRJrߑAq aFhokƊ+-1`8UxAԁGwShug.CwqGSXxqovK~`vωx-8@޺3@ \?>U#FwC;\ rt&44KD7蛫˛z M坮tf" c!JY5AhMD:Vaa/-ߕ] LN`z>|WTQ|IŗVPBy9ڜthh;=9IO9#k^q汪 ҡ6k8.5بŢXeŗV DŞ!{o4,lD7M¢IDۤ+kBȉ$vvTY%qBQ {vѶ1V:|XlrkRϝ1}#;¢'7"SAkڭDNzQJ9zB *Um!Nonz[:-K\P2CJ=3ݾ׶/ǨR(Ÿy#x+5p>qWmOoc܀>n]f> `d.%сkK9ڴAZ}Wp@E} [Y k;?s4mHvS\0v7W"Mο#tCo9:ou Xғu%ښ*09C*qW}3y$pvOs=}!sZD>*8LZy#uE웿ܽ\3s.Sm:r]DĴ2'u32A wSȨΆTqSOif͎6 å w?B}v H$*qS!+M6FÀj !h=aQC! TSйS &~yNj$r$ zIuΡWRz&#h4#h<#j,?Xi=;:]D{f?B ɷ4v&:_ V*_N$7Av}gÇ\٠z3ݳ}-:gu !6oKXWVE,L[3A^W*"W qF5(fp(3;N)KQeVeaWt2+4w]m_ǻ0▾t666XW87$d*͡ʁY#jmmO)D^˔cETq.Smm*P/3k,S6-RwTÔz8 jX$u gW&0ĕ_CY_)_ԥnbDX9L^n\o<M)FjQOl7 q}[` ȨNBq( F]U6`7[Yt=x e{&;ur9{{cz60Bvp99{}e6۹Յ<}~x #EqLu1*NRP[O/Y:eJm3DZ8>aD=sd"Ogd2v /vS_%;RB\׀d5h)8f`RM,,=|i'2J>^BXpCq!pc=ݯyNQ ţG+ D/<"0(dQ/%G,z1èTQ^_Ѓb'fj372!]IrcdBXX7gSġ,!7q}y,,A<\32c{'D-Q,:kéBIĴn1&n;B]pY<3lxS3Eן3QmjhCɵ!ᤨON"JWk]v?w.HD8jOvż5),tIYN"vD2H3#"M?.It/^E8döYTV/i@7@_~A5籰8j#;ӴVF@Fr ~:ZBǮjDzGXuە74Ж_yI!4k15 ero5| khWHnN;foqA.J`n7]@+EZX03YfЂتŵXbDp LC_~$), !XkxT0iLWWp R :>pɴ3F@q8jŮ`8ߟ{\§FNBӂa2N3Q޸J]05o|ؕûJ9ķӹG r]":.$8}Hp=7gZl9] YTxr"%–FӌکP%->S0F%< I v0Ye #+O4/\;T`jz_#.EqhOɑVmE|P+PH.425"`#p60ИLS υpRn۵3iȦUm@

j qM$ ;UUɰ˩Ya%@vkV-ނJ^W%q<܃5ԳJ:Er2[v'ne{78tm. $FtbUݾioKr%}ݙ|]9h1\X4myf=ԎC#|ma.VEf <8y>NL`"o @Ŵ)v4tNbeUQιsNͨ4C֓3D=AoTalei:aA# Imګr";9F6=׹qʟ񙦅2~\Tufק}ٚLy> bX@rmzv\ݢ$gl "sNfXȒ/ǯuKKTVZc13,!N9޿,)"kW0f2j9㟾Ϗ03,ପh[8;~Bn3Ӯv/wz ZS5^G{"|ʼn.  25|͆|0Zc`9ߗˑL8fa?;1ߵSaC1TD_qS4p|ag܁/#NIah{E8i :A~ 7`0bF񿣐-LH˙UEΎۉe*.6<IENDB`input-remapper-2.1.1/data/input-remapper.glade000066400000000000000000003075531475433465200213740ustar00rootroot00000000000000 True False help-about True False 2 2 media-playback-start True False 2 2 edit-copy 1 0.01 0.01 True False 2 2 edit-delete -1 1 0.05 0.05 -2 2 0.10 0.10 True False 2 2 media-playback-stop True False 2 2 media-playback-stop True False 2 2 edit-delete True False edit True False media-record True False list-add True False list-remove True False 2 2 document-new True False document-save 1000 False 450 input-remapper.svg True False vertical True False crossfade True True True False none True False start 18 18 18 18 True 18 18 99 none Devices Devices True False vertical True False 18 18 Device Name False True 0 True False center 18 18 27 6 True New True True True Create a new preset new-icon True False True 0 Stop True True True Stops the Injection for the selected device, gives your keys their original function back Shortcut: ctrl + del gtk-redo-icon True False True 1 False True 1 True False False True 2 True True True False none True False start 18 18 18 18 True 18 18 99 none True True 3 Presets Presets 1 True False vertical True False 18 18 Preset Name False True 0 True False center 106 112 27 6 12 463 True False 6 True Apply True True True Start injecting. Don't hold down any keys while the injection starts check-icon1 True False True 0 Stop True True True Stops the Injection for the selected device, gives your keys their original function back Shortcut: ctrl + del gtk-redo-icon1 True False True 1 Copy True True True Duplicate this preset copy-icon1 True False True 2 Delete True True True Delete this preset delete-icon1 True False True 3 1 0 100 True False 0.5 Rename 13 1 0 1 True False 0.5 6 7 Autoload 1 0 2 True True Activate this to load the preset next time the device connects, or when the user logs in start center 1 2 True False 6 True True True True 0 True True True Save the entered name start save-icon1 False True 1 1 1 False True 1 True False False True 5 True False True True False 200 True False 18 vertical True False 18 Input False True 0 True False center 18 True False no input configured True False True 0 False 0.5 (recording ...) False True 1 False False 1 463 True False center 18 18 18 6 True Add True True True image3 True False True 1 Record True True True Record a button of your device that should be remapped image2 True False True 1 Advanced True True True image1 False True 2 Delete True True True Delete this entry icon-delete-row True False True 4 False True 2 True False False True 3 True True True False none True False True True 4 True True 0 True False False True 1 False True 0 True False 18 18 18 18 vertical True False 18 Output False True 0 True False 12 100 True False 0.5 Type 1 False True 0 True False True expand Key or Macro True True True True True 0 Analog Axis True True True True True end 1 True True 1 False True 1 True False The type of device this mapping is emulating. 6 12 100 True False 0.5 Target 1 False True 0 True False True True 1 False True 2 True False 6 True False vertical 6 True False Available output axes are affected by the Target setting. 12 100 True False 0.5 Output axis 1 False True 0 True False True True 1 False True 0 True False True False vertical True True False 12 True True deadzone-adjustment 2 2 True True end 0 100 True False 0.5 Deadzone 1 False True 1 False True 0 True False 12 True True gain-adjustment 2 2 True True end 0 100 True False 0.5 Gain 1 False True 1 True True 1 True False 12 True True expo-adjustment 2 2 True True end 0 100 True False 0.5 Expo 1 False True 1 True True 2 True True 0 True False 6 vertical 150 150 True False center center 12 12 12 12 False True 0 False True 1 False True 1 True False The Speed at which the Input is considered at maximum. Only relevant when mapping relative inputs (e.g. mouse) to absolute outputs (e.g. gamepad) 12 100 True False 0.5 Input cutoff 1 False True 0 True True True True end 1 False True 4 Analog Axis Analog Axis True False vertical True True True True What should be written. For example KEY_A start immediate word 10 10 10 10 True 2 True True True 0 True False You can copy this text into the output 0.5 12 True end False True end 1 Key or Macro Key or Macro 1 False False 3 False True 1 True True 6 Editor Editor 2 True True 0 True False False True 1 True False 18 False 6 9 dialog-warning False True 0 False 6 9 dialog-error False True 1 True False 0.5 7 7 7 7 6 6 vertical True True 2 False True 3 True False Input Remapper False True True False True main_stack True True True Help end center about-icon True end 1 False True center-on-parent input-remapper.svg dialog True window window True False True False center 18 18 vertical 18 True False input-remapper-large.png False True 0 True False Version unknown center False True 1 True True 6 6 6 6 You can find more information and report bugs at <a href="https://github.com/sezanzeb/input-remapper">https://github.com/sezanzeb/input-remapper</a> True center False True 2 True True 0.5 6 6 6 6 © 2023 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 200 200 False Advanced False True center-on-parent True window window True False 220 True True True False none True False browse False True 0 True False False True 1 True False start 18 18 6 12 True True False 0.5 18 Release timeout 1 0 2 True True 18 5 3 1 2 True False Release all inputs which are part of the combination before the mapping is injected 0.5 18 Release input 1 0 1 True True start center True 1 1 True False General 0 0 2 True False center 18 18 0 3 2 True False Event Specific 0 4 2 True False 0.5 18 Remove this input 1 0 5 True True True start image4 1 5 True False Map this input to an Analog Axis. Only possible for analog inputs and mice. 0.5 18 True Use as analog 1 0 6 True True start center 1 6 True False 0.5 18 Trigger threshold 1 0 7 True True 18 5 number 1 True True 1 7 False True 2 input-remapper-2.1.1/data/input-remapper.policy000066400000000000000000000023311475433465200216010ustar00rootroot00000000000000 Run Input Remapper as root Authentication is required to discover and read devices. Vyžaduje sa prihlásenie na objavenie a prístup k zariadeniam. Потрібна автентифікація для виявлення та читання пристроїв. Требуется аутентификация для обнаружения и чтения устройств. no auth_admin_keep auth_admin_keep /usr/bin/input-remapper-control false input-remapper-2.1.1/data/input-remapper.service000066400000000000000000000005221475433465200217420ustar00rootroot00000000000000[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-2.1.1/data/input-remapper.svg000066400000000000000000000231611475433465200211050ustar00rootroot00000000000000 image/svg+xml input-remapper-2.1.1/data/inputremapper.Control.conf000066400000000000000000000005301475433465200225700ustar00rootroot00000000000000 input-remapper-2.1.1/data/io.github.sezanzeb.input_remapper.metainfo.xml000066400000000000000000000027171475433465200265040ustar00rootroot00000000000000 io.github.sezanzeb.input_remapper Input Remapper

An easy to use tool to change the mapping of your input device buttons CC0-1.0 GPL-3.0-or-later pointing keyboard gamepad

An easy to use tool to change the mapping of your input device buttons. Supports mice, keyboards, gamepads, X11, Wayland, combined buttons and programmable macros. Allows mapping non-keyboard events (click, joystick, wheel) to keys of keyboard devices.

input-remapper.desktop Defining a new mapping https://raw.githubusercontent.com/sezanzeb/input-remapper/main/readme/screenshot.png https://raw.githubusercontent.com/sezanzeb/input-remapper/main/readme/screenshot_2.png https://github.com/sezanzeb/input-remapper input-remapper-2.1.1/data/style.css000066400000000000000000000016501475433465200172650ustar00rootroot00000000000000.status_bar frame { /* the status bar is ugly in elementary os otherwise */ border: 0px; } .transparent { background: transparent; } .copyright { font-size: 7pt; } .autocompletion label { padding: 11px; } .autocompletion { padding: 0px; box-shadow: none; } .no-v-padding { padding-top: 0; padding-bottom: 0; } .transformation-draw-area { border: 1px solid @borders; border-radius: 6px; background: @theme_base_color; } .multiline > *:first-child { /* source view suddenly started showing a white background behind line-numbers */ /* solution found by furiously trying css rules out in the gtk inspector */ background: @theme_bg_color; } .opaque-text text { /* found by roaming through /usr/share/themes, and some experimentation in the gnome inspector */ color: alpha(currentColor, 0.5); } /* @theme_bg_color @theme_selected_bg_color @theme_base_color */ input-remapper-2.1.1/inputremapper/000077500000000000000000000000001475433465200173735ustar00rootroot00000000000000input-remapper-2.1.1/inputremapper/__init__.py000066400000000000000000000000001475433465200214720ustar00rootroot00000000000000input-remapper-2.1.1/inputremapper/bin/000077500000000000000000000000001475433465200201435ustar00rootroot00000000000000input-remapper-2.1.1/inputremapper/bin/__init__.py000066400000000000000000000000001475433465200222420ustar00rootroot00000000000000input-remapper-2.1.1/inputremapper/bin/input_remapper_control.py000077500000000000000000000324751475433465200253250ustar00rootroot00000000000000# -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings # Copyright (C) 2025 sezanzeb # # This file is part of input-remapper. # # input-remapper is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # input-remapper is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with input-remapper. If not, see . """Control the dbus service from the command line.""" import argparse import logging import os import subprocess import sys from enum import Enum from typing import Optional import gi gi.require_version("GLib", "2.0") from gi.repository import GLib from inputremapper.configs.global_config import GlobalConfig from inputremapper.configs.migrations import Migrations from inputremapper.injection.global_uinputs import GlobalUInputs, FrontendUInput from inputremapper.logging.logger import logger from inputremapper.user import UserUtils class Commands(Enum): AUTOLOAD = "autoload" START = "start" STOP = "stop" STOP_ALL = "stop-all" HELLO = "hello" QUIT = "quit" class Internals(Enum): # internal stuff that the gui uses START_DAEMON = "start-daemon" START_READER_SERVICE = "start-reader-service" class Options: command: str config_dir: str preset: str device: str list_devices: bool key_names: str debug: bool version: str class InputRemapperControlBin: def __init__( self, global_config: GlobalConfig, migrations: Migrations, ): self.global_config = global_config self.migrations = migrations @staticmethod def main(options: Options) -> None: global_config = GlobalConfig() global_uinputs = GlobalUInputs(FrontendUInput) migrations = Migrations(global_uinputs) input_remapper_control = InputRemapperControlBin( global_config, migrations, ) if options.debug: logger.update_verbosity(True) if options.version: logger.log_info() return logger.debug('Call for "%s"', sys.argv) boot_finished_ = input_remapper_control.boot_finished() is_root = UserUtils.user == "root" is_autoload = options.command == Commands.AUTOLOAD config_dir_set = options.config_dir is not None if is_autoload and not boot_finished_ and is_root and not config_dir_set: # this is probably happening during boot time and got # triggered by udev. There is no need to try to inject anything if the # service doesn't know where to look for a config file. This avoids a lot # of confusing service logs. And also avoids potential for problems when # input-remapper-control stresses about evdev, dbus and multiprocessing already # while the system hasn't even booted completely. logger.warning("Skipping autoload command without a logged in user") return if options.command is not None: if options.command in [command.value for command in Internals]: input_remapper_control.internals(options.command, options.debug) elif options.command in [command.value for command in Commands]: from inputremapper.daemon import Daemon daemon = Daemon.connect(fallback=False) input_remapper_control.set_daemon(daemon) input_remapper_control.communicate( options.command, options.device, options.config_dir, options.preset, ) else: logger.error('Unknown command "%s"', options.command) else: if options.list_devices: input_remapper_control.list_devices() if options.key_names: input_remapper_control.list_key_names() if options.command: logger.info("Done") def list_devices(self): logger.setLevel(logging.ERROR) from inputremapper.groups import groups for group in groups: print(group.key) def list_key_names(self): from inputremapper.configs.keyboard_layout import keyboard_layout print("\n".join(keyboard_layout.list_names())) def communicate( self, command: str, device: str, config_dir: Optional[str], preset: str, ) -> None: """Commands that require a running daemon.""" if self.daemon is None: # probably broken tests logger.error("Daemon missing") sys.exit(5) if config_dir is not None: self._load_config(config_dir) self.ensure_migrated() if command == Commands.AUTOLOAD.value: self._autoload(device) if command == Commands.START.value: self._start(device, preset) if command == Commands.STOP.value: self._stop(device) if command == Commands.STOP_ALL.value: self.daemon.stop_all() if command == Commands.HELLO.value: self._hello() if command == Commands.QUIT.value: self._quit() def _hello(self): response = self.daemon.hello("hello") logger.info('Daemon answered with "%s"', response) def _load_config(self, config_dir: str) -> None: path = os.path.abspath( os.path.expanduser(os.path.join(config_dir, "config.json")) ) if not os.path.exists(path): logger.error('"%s" does not exist', path) sys.exit(6) logger.info('Using config from "%s" instead', path) self.global_config.load_config(path) def ensure_migrated(self) -> None: # import stuff late to make sure the correct log level is applied # before anything is logged # TODO since imports shouldn't run any code, this is fixed by moving towards DI from inputremapper.user import UserUtils if UserUtils.user != "root": # Might be triggered by udev, so skip the root user. # This will also refresh the config of the daemon if the user changed # it in the meantime. # config_dir is either the cli arg or the default path in home config_dir = os.path.dirname(self.global_config.path) self.daemon.set_config_dir(config_dir) self.migrations.migrate() def _stop(self, device: str) -> None: group = self._require_group(device) self.daemon.stop_injecting(group.key) def _quit(self) -> None: try: self.daemon.quit() except GLib.GError as error: if "NoReply" in str(error): # The daemon is expected to terminate, so there won't be a reply. return raise def _start(self, device: str, preset: str) -> None: group = self._require_group(device) logger.info( 'Starting injection: "%s", "%s"', device, preset, ) self.daemon.start_injecting(group.key, preset) def _require_group(self, device: str): # import stuff late to make sure the correct log level is applied # before anything is logged # TODO since imports shouldn't run any code, this is fixed by moving towards DI from inputremapper.groups import groups if device is None: logger.error("--device missing") sys.exit(3) if device.startswith("/dev"): group = groups.find(path=device) else: group = groups.find(key=device) if group is None: logger.error( 'Device "%s" is unknown or not an appropriate input device', device, ) sys.exit(4) return group def _autoload(self, device: str) -> None: # if device was specified, autoload for that one. if None autoload # for all devices. if device is None: logger.info("Autoloading all") # timeout is not documented, for more info see # https://github.com/LEW21/pydbus/blob/master/pydbus/proxy_method.py self.daemon.autoload(timeout=10) else: group = self._require_group(device) logger.info("Asking daemon to autoload for %s", device) self.daemon.autoload_single(group.key, timeout=2) def internals(self, command: str, debug: True) -> None: """Methods that are needed to get the gui to work and that require root. input-remapper-control should be started with sudo or pkexec for this. """ debug = " -d" if debug else "" if command == Internals.START_READER_SERVICE.value: cmd = f"input-remapper-reader-service{debug}" elif command == Internals.START_DAEMON.value: cmd = f"input-remapper-service --hide-info{debug}" else: return # daemonize cmd = f"{cmd} &" logger.debug(f"Running `{cmd}`") os.system(cmd) def _num_logged_in_users(self) -> int: """Check how many users are logged in.""" who = subprocess.run(["who"], stdout=subprocess.PIPE).stdout.decode() return len([user for user in who.split("\n") if user.strip() != ""]) def _is_systemd_finished(self) -> bool: """Check if systemd finished booting.""" try: systemd_analyze = subprocess.run( ["systemd-analyze"], stdout=subprocess.PIPE ) except FileNotFoundError: # probably not systemd, lets assume true to not block input-remapper for good # on certain installations return True if "finished" in systemd_analyze.stdout.decode(): # it writes into stderr otherwise or something return True return False def boot_finished(self) -> bool: """Check if booting is completed.""" # Get as much information as needed to really safely determine if booting up is # complete. # - `who` returns an empty list on some system for security purposes # - something might be broken and might make systemd_analyze fail: # Bootup is not yet finished # (org.freedesktop.systemd1.Manager.FinishTimestampMonotonic=0). # Please try again later. # Hint: Use 'systemctl list-jobs' to see active jobs if self._is_systemd_finished(): logger.debug("System is booted") return True if self._num_logged_in_users() > 0: logger.debug("User(s) logged in") return True return False def set_daemon(self, daemon): # TODO DI? self.daemon = daemon @staticmethod def parse_args() -> Options: parser = argparse.ArgumentParser() parser.add_argument( "--command", action="store", dest="command", help=( "Communicate with the daemon. Available commands are " f"{', '.join([command.value for command in Commands])}" ), default=None, metavar="NAME", ) parser.add_argument( "--config-dir", action="store", dest="config_dir", help=( "path to the config directory containing config.json, " "xmodmap.json and the presets folder. " "defaults to ~/.config/input-remapper/" ), default=None, metavar="PATH", ) parser.add_argument( "--preset", action="store", dest="preset", help="The filename of the preset without the .json extension.", default=None, metavar="NAME", ) parser.add_argument( "--device", action="store", dest="device", help="One of the device keys from --list-devices", default=None, metavar="NAME", ) parser.add_argument( "--list-devices", action="store_true", dest="list_devices", help="List available device keys and exit", default=False, ) parser.add_argument( "--symbol-names", action="store_true", dest="key_names", help="Print all available names for the preset", default=False, ) parser.add_argument( "-d", "--debug", action="store_true", dest="debug", help="Displays additional debug information", default=False, ) parser.add_argument( "-v", "--version", action="store_true", dest="version", help="Print the version and exit", default=False, ) return parser.parse_args(sys.argv[1:]) # type: ignore input-remapper-2.1.1/inputremapper/bin/input_remapper_gtk.py000077500000000000000000000115061475433465200244220ustar00rootroot00000000000000# -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings # Copyright (C) 2025 sezanzeb # # This file is part of input-remapper. # # input-remapper is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # input-remapper is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with input-remapper. If not, see . """Starts the user interface.""" from __future__ import annotations import atexit import sys from argparse import ArgumentParser from typing import Tuple import gi from inputremapper.bin.process_utils import ProcessUtils gi.require_version("Gtk", "3.0") gi.require_version("GLib", "2.0") gi.require_version("GtkSource", "4") from gi.repository import Gtk # https://github.com/Nuitka/Nuitka/issues/607#issuecomment-650217096 Gtk.init() from inputremapper.gui.gettext import _, LOCALE_DIR from inputremapper.gui.reader_service import ReaderService from inputremapper.daemon import DaemonProxy, Daemon from inputremapper.logging.logger import logger from inputremapper.gui.messages.message_broker import MessageBroker, MessageType from inputremapper.configs.keyboard_layout import keyboard_layout from inputremapper.gui.data_manager import DataManager from inputremapper.gui.user_interface import UserInterface from inputremapper.gui.controller import Controller from inputremapper.injection.global_uinputs import GlobalUInputs, FrontendUInput from inputremapper.groups import _Groups from inputremapper.gui.reader_client import ReaderClient from inputremapper.configs.global_config import GlobalConfig from inputremapper.configs.migrations import Migrations class InputRemapperGtkBin: @staticmethod def main() -> Tuple[ UserInterface, Controller, DataManager, MessageBroker, DaemonProxy, GlobalConfig, ]: parser = ArgumentParser() parser.add_argument( "-d", "--debug", action="store_true", dest="debug", help=_("Displays additional debug information"), default=False, ) options = parser.parse_args(sys.argv[1:]) logger.update_verbosity(options.debug) logger.log_info("input-remapper-gtk") logger.debug("Using locale directory: {}".format(LOCALE_DIR)) global_uinputs = GlobalUInputs(FrontendUInput) migrations = Migrations(global_uinputs) migrations.migrate() message_broker = MessageBroker() global_config = GlobalConfig() # Create the ReaderClient before we start the reader-service, otherwise the # privileged service creates and owns those pipes, and then they cannot be accessed # by the user. reader_client = ReaderClient(message_broker, _Groups()) if ProcessUtils.count_python_processes("input-remapper-gtk") >= 2: logger.warning( "Another input-remapper GUI is already running. " "This can cause problems while recording keys" ) InputRemapperGtkBin.start_reader_service() daemon = Daemon.connect() data_manager = DataManager( message_broker, global_config, reader_client, daemon, global_uinputs, keyboard_layout, ) controller = Controller(message_broker, data_manager) user_interface = UserInterface(message_broker, controller) controller.set_gui(user_interface) message_broker.signal(MessageType.init) atexit.register(lambda: InputRemapperGtkBin.stop(daemon, controller)) Gtk.main() # For tests: return ( user_interface, controller, data_manager, message_broker, daemon, global_config, ) @staticmethod def start_reader_service(): if ProcessUtils.count_python_processes("input-remapper-reader-service") >= 1: logger.info("Found an input-remapper-reader-service to already be running") return try: ReaderService.pkexec_reader_service() except Exception as e: logger.error(e) sys.exit(11) @staticmethod def stop(daemon, controller): if isinstance(daemon, Daemon): # have fun debugging completely unrelated tests if you remove this daemon.stop_all() controller.close() input-remapper-2.1.1/inputremapper/bin/input_remapper_reader_service.py000077500000000000000000000055071475433465200266230ustar00rootroot00000000000000# -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings # Copyright (C) 2025 sezanzeb # # This file is part of input-remapper. # # input-remapper is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # input-remapper is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with input-remapper. If not, see . """Starts the root reader-service.""" import asyncio import atexit import os import signal import sys import time from argparse import ArgumentParser from inputremapper.bin.process_utils import ProcessUtils from inputremapper.groups import _Groups from inputremapper.gui.reader_service import ReaderService from inputremapper.injection.global_uinputs import GlobalUInputs, FrontendUInput from inputremapper.logging.logger import logger class InputRemapperReaderServiceBin: @staticmethod def main() -> None: parser = ArgumentParser() parser.add_argument( "-d", "--debug", action="store_true", dest="debug", help="Displays additional debug information", default=False, ) options = parser.parse_args(sys.argv[1:]) logger.update_verbosity(options.debug) if ProcessUtils.count_python_processes("input-remapper-reader-service") >= 2: logger.warning( "Another input-remapper-reader-service process is already running. " "This can cause problems while recording keys" ) if os.getuid() != 0: logger.warning("The reader-service usually needs elevated privileges") if not ReaderService.pipes_exist(): logger.info("Waiting for pipes to be created by the GUI") while not ReaderService.pipes_exist(): time.sleep(0.5) logger.debug("Waiting...") def on_exit(): """Don't remain idle and alive when the GUI exits via ctrl+c.""" # makes no sense to me, but after the keyboard interrupt it is still # waiting for an event to complete (`S` in `ps ax`), even when using # sys.exit os.kill(os.getpid(), signal.SIGKILL) atexit.register(on_exit) groups = _Groups() global_uinputs = GlobalUInputs(FrontendUInput) reader_service = ReaderService(groups, global_uinputs) asyncio.run(reader_service.run()) input-remapper-2.1.1/inputremapper/bin/input_remapper_service.py000077500000000000000000000044031475433465200252730ustar00rootroot00000000000000# -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings # Copyright (C) 2025 sezanzeb # # This file is part of input-remapper. # # input-remapper is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # input-remapper is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with input-remapper. If not, see . """Starts injecting keycodes based on the configuration.""" import sys from argparse import ArgumentParser from inputremapper.configs.global_config import GlobalConfig from inputremapper.injection.global_uinputs import GlobalUInputs, UInput from inputremapper.injection.mapping_handlers.mapping_parser import MappingParser from inputremapper.logging.logger import logger class InputRemapperServiceBin: @staticmethod def main() -> None: parser = ArgumentParser() parser.add_argument( "-d", "--debug", action="store_true", dest="debug", help="Displays additional debug information", default=False, ) parser.add_argument( "--hide-info", action="store_true", dest="hide_info", help="Don't display version information", default=False, ) options = parser.parse_args(sys.argv[1:]) logger.update_verbosity(options.debug) # import input-remapper stuff after setting the log verbosity from inputremapper.daemon import Daemon if not options.hide_info: logger.log_info("input-remapper-service") global_config = GlobalConfig() global_uinputs = GlobalUInputs(UInput) mapping_parser = MappingParser(global_uinputs) daemon = Daemon(global_config, global_uinputs, mapping_parser) daemon.publish() daemon.run() input-remapper-2.1.1/inputremapper/bin/process_utils.py000066400000000000000000000025721475433465200234210ustar00rootroot00000000000000# -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings # Copyright (C) 2025 sezanzeb # # This file is part of input-remapper. # # input-remapper is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # input-remapper is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with input-remapper. If not, see . import psutil class ProcessUtils: @staticmethod def count_python_processes(name: str) -> int: # This is somewhat complicated, because there might also be a "sudo " # process. count = 0 pids = psutil.pids() for pid in pids: try: process = psutil.Process(pid) cmdline = process.cmdline() if len(cmdline) >= 2 and "python" in cmdline[0] and name in cmdline[1]: count += 1 except Exception: pass return count input-remapper-2.1.1/inputremapper/configs/000077500000000000000000000000001475433465200210235ustar00rootroot00000000000000input-remapper-2.1.1/inputremapper/configs/__init__.py000066400000000000000000000000001475433465200231220ustar00rootroot00000000000000input-remapper-2.1.1/inputremapper/configs/base_config.py000066400000000000000000000110131475433465200236300ustar00rootroot00000000000000# -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings # Copyright (C) 2025 sezanzeb # # This file is part of input-remapper. # # input-remapper is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # input-remapper is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with input-remapper. If not, see . from __future__ import annotations import copy from typing import Union, List, Optional, Callable, Any from inputremapper.logging.logger import logger, VERSION NONE = "none" INITIAL_CONFIG = { "version": VERSION, "autoload": {}, } 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: Optional[ConfigBase] = 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: Union[str, List[str]], func: Callable, config: Optional[dict] = None, ): """Call func for the given config value. Parameters ---------- path For example 'macros.keystroke_sleep_ms' or ['macros', 'keystroke_sleep_ms'] config 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: Union[str, List[str]]): """Remove a config key. Parameters ---------- path 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: Union[str, List[str]], value: Any): """Set a config key. Parameters ---------- path For example 'macros.keystroke_sleep_ms' or ['macros', 'keystroke_sleep_ms'] """ 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: Union[str, List[str]], log_unknown: bool = True): """Get a config value. If not set, return the default Parameters ---------- path For example 'macros.keystroke_sleep_ms' log_unknown 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 = {} input-remapper-2.1.1/inputremapper/configs/data.py000066400000000000000000000072001475433465200223050ustar00rootroot00000000000000# -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings # Copyright (C) 2025 sezanzeb # # This file is part of input-remapper. # # input-remapper is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # input-remapper is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with input-remapper. If not, see . """Get stuff from /usr/share/input-remapper, depending on the prefix.""" import os import site import sys import pkg_resources from inputremapper.logging.logger import logger logged = False def _try_standard_locations(): # https://specifications.freedesktop.org/basedir-spec/basedir-spec-latest.html # ensure at least /usr/local/share/ and /usr/share/ are tried xdg_data_dirs = set( os.environ.get("XDG_DATA_DIRS", "").split(":") + [ "/usr/local/share/", "/usr/share/", os.path.join(site.USER_BASE, "share/"), ] ) for xdg_data_dir in xdg_data_dirs: candidate = os.path.join(xdg_data_dir, "input-remapper") if os.path.exists(candidate): return candidate return None def _try_python_package_location(): """Look for the data dir at the packages installation location.""" source = None try: source = pkg_resources.require("input-remapper")[0].location # failed in some ubuntu installations except Exception: logger.debug("failed to figure out package location") pass 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 return data def _try_env_data_dir(): """Check if input-remappers data can be found at DATA_DIR.""" data_dir = os.environ.get("DATA_DIR", None) if data_dir is None: return None if os.path.exists(data_dir): return data_dir else: logger.error(f'"{ data_dir }" does not exist') return None def get_data_path(filename=""): """Depending on the installation prefix, return the data dir. Since it is a nightmare to get stuff installed with pip across distros this is somewhat complicated. Ubuntu uses /usr/local/share for data_files (setup.py) and manjaro uses /usr/share. """ global logged # depending on where this file is installed to, make sure to use the proper # prefix path for data # https://docs.python.org/3/distutils/setupscript.html?highlight=package_data#installing-additional-files # noqa pylint: disable=line-too-long data = ( _try_env_data_dir() or _try_python_package_location() or _try_standard_locations() ) if data is None: logger.error("Could not find the application data") sys.exit(10) if not logged: logger.debug('Found data at "%s"', data) logged = True return os.path.join(data, filename) input-remapper-2.1.1/inputremapper/configs/global_config.py000066400000000000000000000113001475433465200241550ustar00rootroot00000000000000# -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings # Copyright (C) 2025 sezanzeb # # This file is part of input-remapper. # # input-remapper is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # input-remapper is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with input-remapper. If not, see . """Store which presets should be enabled for which device on login.""" import copy import json import os from typing import Optional from inputremapper.configs.base_config import ConfigBase, INITIAL_CONFIG from inputremapper.configs.paths import PathUtils from inputremapper.logging.logger import logger from inputremapper.user import UserUtils MOUSE = "mouse" WHEEL = "wheel" BUTTONS = "buttons" NONE = "none" class GlobalConfig(ConfigBase): """Global default configuration, from which all presets inherit. 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(PathUtils.config_path(), "config.json") super().__init__() def get_dir(self) -> str: """The folder containing this config.""" return os.path.split(self.path)[0] def set_autoload_preset(self, group_key: str, preset: Optional[str]): """Set a preset to be automatically applied on start. Parameters ---------- group_key the unique identifier of the group. This is used instead of the name to enable autoloading two different presets when two similar devices are connected. preset if None, don't autoload something for this device. """ if preset is not None: self.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: Optional[str], preset: Optional[str]): """Should this preset be loaded automatically?""" if group_key is None or preset is None: raise ValueError("Expected group_key and preset to not be None") return self.get(["autoload", group_key], log_unknown=False) == preset def load_config(self, path: Optional[str] = None): """Load the config from the file system. Parameters ---------- path If set, will change the path to load from and save to. """ if path is not None: if not os.path.exists(path): logger.error('Config at "%s" not found', path) return self.path = path self.clear_config() if not os.path.exists(self.path): # treated like an empty config logger.debug('Config "%s" doesn\'t exist yet', self.path) self.clear_config() self._config = copy.deepcopy(INITIAL_CONFIG) self._save_config() return with open(self.path, "r") as file: try: self._config.update(json.load(file)) logger.info('Loaded config from "%s"', self.path) except json.decoder.JSONDecodeError as error: logger.error( 'Failed to parse config "%s": %s. Using defaults', self.path, str(error), ) # uses the default configuration when the config object # is empty automatically def _save_config(self): """Save the config to the file system.""" if UserUtils.user == "root": logger.debug("Skipping config file creation for the root user") return PathUtils.touch(self.path) with open(self.path, "w") as file: json.dump(self._config, file, indent=4) logger.info("Saved config to %s", self.path) file.write("\n") input-remapper-2.1.1/inputremapper/configs/input_config.py000066400000000000000000000371521475433465200240710ustar00rootroot00000000000000# -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings # Copyright (C) 2025 sezanzeb # # This file is part of input-remapper. # # input-remapper is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # input-remapper is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with input-remapper. If not, see . from __future__ import annotations import itertools from typing import Tuple, Iterable, Union, List, Dict, Optional, Hashable from evdev import ecodes from inputremapper.configs.paths import PathUtils from inputremapper.input_event import InputEvent try: from pydantic.v1 import BaseModel, root_validator, validator except ImportError: from pydantic import BaseModel, root_validator, validator from inputremapper.configs.keyboard_layout import keyboard_layout from inputremapper.gui.messages.message_types import MessageType from inputremapper.logging.logger import logger from inputremapper.utils import get_evdev_constant_name, DeviceHash # having shift in combinations modifies the configured output, # ctrl might not work at all DIFFICULT_COMBINATIONS = [ ecodes.KEY_LEFTSHIFT, ecodes.KEY_RIGHTSHIFT, ecodes.KEY_LEFTCTRL, ecodes.KEY_RIGHTCTRL, ecodes.KEY_LEFTALT, ecodes.KEY_RIGHTALT, ] EMPTY_TYPE = 99 class InputConfig(BaseModel): """Describes a single input within a combination, to configure mappings.""" message_type = MessageType.selected_event type: int code: int # origin_hash is a hash to identify a specific /dev/input/eventXX device. # This solves a number of bugs when multiple devices have overlapping capabilities. # see utils.get_device_hash for the exact hashing function origin_hash: Optional[DeviceHash] = None # At which point is an analog input treated as "pressed" analog_threshold: Optional[int] = None def __str__(self): return f"InputConfig {get_evdev_constant_name(self.type, self.code)}" def __repr__(self): return ( f"" ) @property def input_match_hash(self) -> Hashable: """a Hashable object which is intended to match the InputConfig with a InputEvent. InputConfig itself is hashable, but can not be used to match InputEvent's because its hash includes the analog_threshold """ return self.type, self.code, self.origin_hash @property def is_empty(self) -> bool: return self.type == EMPTY_TYPE @property def defines_analog_input(self) -> bool: """Whether this defines an analog input.""" return not self.analog_threshold and self.type != ecodes.EV_KEY @property def type_and_code(self) -> Tuple[int, int]: """Event type, code.""" return self.type, self.code @classmethod def btn_left(cls): return cls(type=ecodes.EV_KEY, code=ecodes.BTN_LEFT) @classmethod def from_input_event(cls, event: InputEvent) -> InputConfig: """create an input confing from the given InputEvent, uses the value as analog threshold""" return cls( type=event.type, code=event.code, origin_hash=event.origin_hash, analog_threshold=event.value, ) def description(self, exclude_threshold=False, exclude_direction=False) -> str: """Get a human-readable description of the event.""" return ( f"{self._get_name()} " f"{self._get_direction() if not exclude_direction else ''} " f"{self._get_threshold_value() if not exclude_threshold else ''}".strip() ) def _get_name(self) -> Optional[str]: """Human-readable name (e.g. KEY_A) of the specified input event.""" if self.type not in ecodes.bytype: logger.warning("Unknown type for %s", self) return f"unknown {self.type, self.code}" if self.code not in ecodes.bytype[self.type]: logger.warning("Unknown code for %s", self) return f"unknown {self.type, self.code}" key_name = None # first try to find the name in xmodmap to not display wrong # names due to the keyboard layout if self.type == ecodes.EV_KEY: key_name = keyboard_layout.get_name(self.code) if key_name is None: # if no result, look in the linux combination constants. On a german # keyboard for example z and y are switched, which will therefore # cause the wrong letter to be displayed. key_name = get_evdev_constant_name(self.type, self.code) key_name = key_name.replace("ABS_Z", "Trigger Left") key_name = key_name.replace("ABS_RZ", "Trigger Right") key_name = key_name.replace("ABS_HAT0X", "DPad-X") key_name = key_name.replace("ABS_HAT0Y", "DPad-Y") key_name = key_name.replace("ABS_HAT1X", "DPad-2-X") key_name = key_name.replace("ABS_HAT1Y", "DPad-2-Y") key_name = key_name.replace("ABS_HAT2X", "DPad-3-X") key_name = key_name.replace("ABS_HAT2Y", "DPad-3-Y") key_name = key_name.replace("ABS_X", "Joystick-X") key_name = key_name.replace("ABS_Y", "Joystick-Y") key_name = key_name.replace("ABS_RX", "Joystick-RX") key_name = key_name.replace("ABS_RY", "Joystick-RY") key_name = key_name.replace("BTN_", "Button ") key_name = key_name.replace("KEY_", "") key_name = key_name.replace("REL_", "") key_name = key_name.replace("HWHEEL", "Wheel") key_name = key_name.replace("WHEEL", "Wheel") key_name = key_name.replace("_", " ") key_name = key_name.replace(" ", " ") return key_name def _get_direction(self) -> str: """human-readable direction description for the analog_threshold""" if self.type == ecodes.EV_KEY or self.defines_analog_input: return "" assert self.analog_threshold threshold_direction = self.analog_threshold // abs(self.analog_threshold) return { # D-Pad (ecodes.ABS_HAT0X, -1): "Left", (ecodes.ABS_HAT0X, 1): "Right", (ecodes.ABS_HAT0Y, -1): "Up", (ecodes.ABS_HAT0Y, 1): "Down", (ecodes.ABS_HAT1X, -1): "Left", (ecodes.ABS_HAT1X, 1): "Right", (ecodes.ABS_HAT1Y, -1): "Up", (ecodes.ABS_HAT1Y, 1): "Down", (ecodes.ABS_HAT2X, -1): "Left", (ecodes.ABS_HAT2X, 1): "Right", (ecodes.ABS_HAT2Y, -1): "Up", (ecodes.ABS_HAT2Y, 1): "Down", # joystick (ecodes.ABS_X, 1): "Right", (ecodes.ABS_X, -1): "Left", (ecodes.ABS_Y, 1): "Down", (ecodes.ABS_Y, -1): "Up", (ecodes.ABS_RX, 1): "Right", (ecodes.ABS_RX, -1): "Left", (ecodes.ABS_RY, 1): "Down", (ecodes.ABS_RY, -1): "Up", # wheel (ecodes.REL_WHEEL, -1): "Down", (ecodes.REL_WHEEL, 1): "Up", (ecodes.REL_HWHEEL, -1): "Left", (ecodes.REL_HWHEEL, 1): "Right", }.get((self.code, threshold_direction)) or ( "+" if threshold_direction > 0 else "-" ) def _get_threshold_value(self) -> str: """human-readable value of the analog_threshold e.g. '20%'""" if self.analog_threshold is None: return "" return { ecodes.EV_REL: f"{abs(self.analog_threshold)}", ecodes.EV_ABS: f"{abs(self.analog_threshold)}%", }.get(self.type) or "" def modify( self, type_: Optional[int] = None, code: Optional[int] = None, origin_hash: Optional[str] = None, analog_threshold: Optional[int] = None, ) -> InputConfig: """Return a new modified event.""" return InputConfig( type=type_ if type_ is not None else self.type, code=code if code is not None else self.code, origin_hash=origin_hash if origin_hash is not None else self.origin_hash, analog_threshold=( analog_threshold if analog_threshold is not None else self.analog_threshold ), ) def __hash__(self): return hash((self.type, self.code, self.origin_hash, self.analog_threshold)) @validator("analog_threshold") def _ensure_analog_threshold_is_none(cls, analog_threshold): """ensure the analog threshold is none, not zero.""" if analog_threshold == 0 or analog_threshold is None: return None return analog_threshold @root_validator def _remove_analog_threshold_for_key_input(cls, values): """remove the analog threshold if the type is a EV_KEY""" type_ = values.get("type") if type_ == ecodes.EV_KEY: values["analog_threshold"] = None return values @root_validator(pre=True) def validate_origin_hash(cls, values): origin_hash = values.get("origin_hash") if origin_hash is None: # For new presets, origin_hash should be set. For old ones, it can # be still missing. A lot of tests didn't set an origin_hash. if values.get("type") != EMPTY_TYPE: logger.warning("No origin_hash set for %s", values) return values values["origin_hash"] = origin_hash.lower() return values class Config: allow_mutation = False underscore_attrs_are_private = True InputCombinationInit = Union[ Iterable[Dict[str, Union[str, int]]], Iterable[InputConfig], ] class InputCombination(Tuple[InputConfig, ...]): """One or more InputConfigs used to trigger a mapping.""" # tuple is immutable, therefore we need to override __new__() # https://jfine-python-classes.readthedocs.io/en/latest/subclass-tuple.html def __new__(cls, configs: InputCombinationInit) -> InputCombination: """Create a new InputCombination. Examples -------- InputCombination([InputConfig, ...]) InputCombination([{type: ..., code: ..., value: ...}, ...]) """ if not isinstance(configs, Iterable): raise TypeError("InputCombination requires a list of InputConfigs.") if isinstance(configs, InputConfig): # wrap the argument in square brackets raise TypeError("InputCombination requires a list of InputConfigs.") validated_configs = [] for config in configs: if isinstance(configs, InputEvent): raise TypeError("InputCombinations require InputConfigs, not Events.") if isinstance(config, InputConfig): validated_configs.append(config) elif isinstance(config, dict): validated_configs.append(InputConfig(**config)) else: raise TypeError(f'Can\'t handle "{config}"') if len(validated_configs) == 0: raise ValueError(f"failed to create InputCombination with {configs = }") # mypy bug: https://github.com/python/mypy/issues/8957 # https://github.com/python/mypy/issues/8541 return super().__new__(cls, validated_configs) # type: ignore def __str__(self): return f'Combination ({" + ".join(str(event) for event in self)})' def __repr__(self): combination = ", ".join(repr(event) for event in self) return f"" @classmethod def __get_validators__(cls): """Used by pydantic to create InputCombination objects.""" yield cls.validate @classmethod def validate(cls, init_arg) -> InputCombination: """The only valid option is from_config""" if isinstance(init_arg, InputCombination): return init_arg return cls(init_arg) def to_config(self) -> Tuple[Dict[str, int], ...]: """Turn the object into a tuple of dicts.""" return tuple(input_config.dict(exclude_defaults=True) for input_config in self) @classmethod def empty_combination(cls) -> InputCombination: """A combination that has default invalid (to evdev) values. Useful for the UI to indicate that this combination is not set """ return cls([{"type": EMPTY_TYPE, "code": 99, "analog_threshold": 99}]) @classmethod def from_tuples(cls, *tuples): """Construct an InputCombination from (type, code, analog_threshold) tuples.""" dicts = [] for tuple_ in tuples: if len(tuple_) == 3: dicts.append( { "type": tuple_[0], "code": tuple_[1], "analog_threshold": tuple_[2], } ) elif len(tuple_) == 2: dicts.append( { "type": tuple_[0], "code": tuple_[1], } ) else: raise TypeError return cls(dicts) def is_problematic(self) -> bool: """Is this combination going to work properly on all systems?""" if len(self) <= 1: return False for input_config in self: if input_config.type != ecodes.EV_KEY: continue if input_config.code in DIFFICULT_COMBINATIONS: return True return False @property def defines_analog_input(self) -> bool: """Check if there is any analog input in self.""" return True in tuple(i.defines_analog_input for i in self) def find_analog_input_config( self, type_: Optional[int] = None ) -> Optional[InputConfig]: """Return the first event that defines an analog input.""" for input_config in self: if input_config.defines_analog_input and ( type_ is None or input_config.type == type_ ): return input_config return None def get_permutations(self) -> List[InputCombination]: """Get a list of EventCombinations representing all possible permutations. combining a + b + c should have the same result as b + a + c. Only the last combination remains the same in the returned result. """ if len(self) <= 2: return [self] if len(self) > 6: logger.warning( "Your input combination has a length of %d. Long combinations might " 'freeze the process. Edit the configuration files in "%s" to fix it.', len(self), PathUtils.get_config_path(), ) permutations = [] for permutation in itertools.permutations(self[:-1]): permutations.append(InputCombination((*permutation, self[-1]))) return permutations def beautify(self) -> str: """Get a human-readable string representation.""" if self == InputCombination.empty_combination(): return "empty_combination" return " + ".join(event.description(exclude_threshold=True) for event in self) input-remapper-2.1.1/inputremapper/configs/keyboard_layout.py000066400000000000000000000174371475433465200246060ustar00rootroot00000000000000# -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings # Copyright (C) 2025 sezanzeb # # This file is part of input-remapper. # # input-remapper is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # input-remapper is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with input-remapper. If not, see . """Make the systems/environments mapping of keys and codes accessible.""" import json import re import subprocess from typing import Optional, List, Iterable, Tuple import evdev from inputremapper.configs.paths import PathUtils from inputremapper.logging.logger import logger from inputremapper.utils import is_service DISABLE_NAME = "disable" DISABLE_CODE = -1 # xkb uses keycodes that are 8 higher than those from evdev XKB_KEYCODE_OFFSET = 8 XMODMAP_FILENAME = "xmodmap.json" LAZY_LOAD = None class KeyboardLayout: """Stores information about all available keycodes.""" _mapping: Optional[dict] = LAZY_LOAD _xmodmap: Optional[List[Tuple[str, str]]] = LAZY_LOAD _case_insensitive_mapping: Optional[dict] = LAZY_LOAD def __getattribute__(self, wanted: str): """To lazy load keyboard_layout info only when needed. For example, this helps to keep logs of input-remapper-control clear when it doesn't need it the information. """ lazy_loaded_attributes = ["_mapping", "_xmodmap", "_case_insensitive_mapping"] for lazy_loaded_attribute in lazy_loaded_attributes: if wanted != lazy_loaded_attribute: continue if object.__getattribute__(self, lazy_loaded_attribute) is LAZY_LOAD: # initialize _mapping and such with an empty dict, for populate # to write into object.__setattr__(self, lazy_loaded_attribute, {}) object.__getattribute__(self, "populate")() return object.__getattribute__(self, wanted) def list_names(self, codes: Optional[Iterable[int]] = None) -> List[str]: """Get all possible names in the mapping, optionally filtered by codes. Parameters ---------- codes: list of event codes """ if not codes: return self._mapping.keys() return [name for name, code in self._mapping.items() if code in codes] def correct_case(self, symbol: str): """Return the correct casing for a symbol.""" if symbol in self._mapping: return symbol # only if not e.g. both "a" and "A" are in the mapping return self._case_insensitive_mapping.get(symbol.lower(), symbol) def _use_xmodmap_symbols(self): """Look up xmodmap -pke, write xmodmap.json, and get the symbols.""" try: xmodmap = subprocess.check_output( ["xmodmap", "-pke"], stderr=subprocess.STDOUT, ).decode() except FileNotFoundError: logger.info("Optional `xmodmap` command not found. This is not critical.") return except subprocess.CalledProcessError as e: logger.error('Call to `xmodmap -pke` failed with "%s"', e) return self._xmodmap = re.findall(r"(\d+) = (.+)\n", xmodmap + "\n") xmodmap_dict = self._find_legit_mappings() if len(xmodmap_dict) == 0: logger.info("`xmodmap -pke` did not yield any symbol") return # Write this stuff into the input-remapper config directory, because # the systemd service won't know the user sessions xmodmap. path = PathUtils.get_config_path(XMODMAP_FILENAME) PathUtils.touch(path) with open(path, "w") as file: logger.debug('Writing "%s"', path) json.dump(xmodmap_dict, file, indent=4) for name, code in xmodmap_dict.items(): self._set(name, code) def _use_linux_evdev_symbols(self): """Look up the evdev constant names and use them.""" for name, ecode in evdev.ecodes.ecodes.items(): if name.startswith("KEY") or name.startswith("BTN"): self._set(name, ecode) def populate(self): """Get a mapping of all available names to their keycodes.""" logger.debug("Gathering available keycodes") self.clear() if not is_service(): # xmodmap is only available from within the login session. # The service that runs via systemd can't use this. self._use_xmodmap_symbols() self._use_linux_evdev_symbols() self._set(DISABLE_NAME, DISABLE_CODE) def update(self, mapping: dict): """Update this with new keys. Parameters ---------- mapping maps from name to code. Make sure your keys are lowercase. """ len_before = len(self._mapping) for name, code in mapping.items(): self._set(name, code) logger.debug( "Updated keycodes with %d new ones", len(self._mapping) - len_before ) def _set(self, name: str, code: int): """Map name to code.""" self._mapping[str(name)] = code self._case_insensitive_mapping[str(name).lower()] = name def get(self, name: str) -> Optional[int]: """Return the code mapped to the key.""" # the correct casing should be shown when asking the keyboard_layout # for stuff. indexing case insensitive to support old presets. if name not in self._mapping: # only if not e.g. both "a" and "A" are in the mapping name = self._case_insensitive_mapping.get(str(name).lower()) return self._mapping.get(name) def clear(self): """Remove all mapped keys. Only needed for tests.""" keys = list(self._mapping.keys()) for key in keys: del self._mapping[key] def get_name(self, code: int): """Get the first matching name for the code.""" for entry in self._xmodmap: if int(entry[0]) - XKB_KEYCODE_OFFSET == code: return entry[1].split()[0] # Fall back to the linux constants # This is especially important for BTN_LEFT and such btn_name = evdev.ecodes.BTN.get(code, None) if btn_name is not None: if type(btn_name) in [list, tuple]: # python-evdev >= 1.8.0 uses tuples return btn_name[0] return btn_name key_name = evdev.ecodes.KEY.get(code, None) if key_name is not None: if type(key_name) in [list, tuple]: # python-evdev >= 1.8.0 uses tuples return key_name[0] return key_name return None def _find_legit_mappings(self) -> dict: """From the parsed xmodmap list find usable symbols and their codes.""" xmodmap_dict = {} for keycode, names in self._xmodmap: # there might be multiple, like: # keycode 64 = Alt_L Meta_L Alt_L Meta_L # keycode 204 = NoSymbol Alt_L NoSymbol Alt_L # Alt_L should map to code 64. Writing code 204 only works # if a modifier is applied at the same time. So take the first # one. name = names.split()[0] xmodmap_dict[name] = int(keycode) - XKB_KEYCODE_OFFSET return xmodmap_dict # TODO DI # this mapping represents the xmodmap output, which stays constant keyboard_layout = KeyboardLayout() input-remapper-2.1.1/inputremapper/configs/mapping.py000066400000000000000000000437661475433465200230500ustar00rootroot00000000000000# -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings # Copyright (C) 2025 sezanzeb # # This file is part of input-remapper. # # input-remapper is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # input-remapper is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with input-remapper. If not, see . from __future__ import annotations import enum from collections import namedtuple from typing import Optional, Callable, Tuple, TypeVar, Union, Any, Dict from evdev.ecodes import ( EV_KEY, EV_ABS, EV_REL, REL_WHEEL, REL_HWHEEL, REL_HWHEEL_HI_RES, REL_WHEEL_HI_RES, ) from packaging import version from inputremapper.logging.logger import logger try: from pydantic.v1 import ( BaseModel, PositiveInt, confloat, conint, root_validator, validator, ValidationError, PositiveFloat, VERSION, BaseConfig, ) except ImportError: from pydantic import ( BaseModel, PositiveInt, confloat, conint, root_validator, validator, ValidationError, PositiveFloat, VERSION, BaseConfig, ) from inputremapper.configs.input_config import InputCombination from inputremapper.configs.keyboard_layout import keyboard_layout, DISABLE_NAME from inputremapper.configs.validation_errors import ( OutputSymbolUnknownError, SymbolNotAvailableInTargetError, OnlyOneAnalogInputError, TriggerPointInRangeError, OutputSymbolVariantError, MacroButTypeOrCodeSetError, SymbolAndCodeMismatchError, WrongMappingTypeForKeyError, MissingOutputAxisError, MissingMacroOrKeyError, ) from inputremapper.gui.gettext import _ from inputremapper.gui.messages.message_types import MessageType from inputremapper.injection.global_uinputs import GlobalUInputs from inputremapper.injection.macros.parse import Parser from inputremapper.utils import get_evdev_constant_name # TODO: remove pydantic VERSION check as soon as we no longer support # Ubuntu 20.04 and with it the ancient pydantic 1.2 needs_workaround = version.parse(str(VERSION)) < version.parse("1.7.1") EMPTY_MAPPING_NAME: str = _("Empty Mapping") # If `1` is the default speed for EV_REL, how much does this value needs to be scaled # up to get reasonable speeds for various EV_REL events? # Mouse injection rates vary wildly, and so do the values. REL_XY_SCALING: float = 60 WHEEL_SCALING: float = 1 # WHEEL_HI_RES always generates events with 120 times higher values than WHEEL # https://www.kernel.org/doc/html/latest/input/event-codes.html?highlight=wheel_hi_res#ev-rel WHEEL_HI_RES_SCALING: float = 120 # Those values are assuming a rate of 60hz DEFAULT_REL_RATE: float = 60 class KnownUinput(str, enum.Enum): """The default targets.""" KEYBOARD = "keyboard" MOUSE = "mouse" GAMEPAD = "gamepad" KEYBOARD_MOUSE = "keyboard + mouse" class MappingType(str, enum.Enum): """What kind of output the mapping produces.""" KEY_MACRO = "key_macro" ANALOG = "analog" CombinationChangedCallback = Optional[ Callable[[InputCombination, InputCombination], None] ] MappingModel = TypeVar("MappingModel", bound="UIMapping") class Cfg(BaseConfig): validate_assignment = True use_enum_values = True underscore_attrs_are_private = True json_encoders = {InputCombination: lambda v: v.json_key()} class ImmutableCfg(Cfg): allow_mutation = False class UIMapping(BaseModel): """Holds all the data for mapping an input action to an output action. The Preset contains multiple UIMappings. This mapping does not validate the structure of the mapping or macros, only basic values. It is meant to be used in the GUI where invalid mappings are expected. """ if needs_workaround: __slots__ = ("_combination_changed",) # Required attributes # The InputEvent or InputEvent combination which is mapped input_combination: InputCombination = InputCombination.empty_combination() # The UInput to which the mapped event will be sent target_uinput: Optional[Union[str, KnownUinput]] = None # Either `output_symbol` or `output_type` and `output_code` is required # Only set if output is "Key or Macro": output_symbol: Optional[str] = None # The symbol or macro string if applicable # "Analog Axis" or if preset edited manually to inject a code instead of a symbol: output_type: Optional[int] = None # The event type of the mapped event output_code: Optional[int] = None # The event code of the mapped event name: Optional[str] = None mapping_type: Optional[MappingType] = None # if release events will be sent to the forwarded device as soon as a combination # triggers see also #229 release_combination_keys: bool = True # macro settings macro_key_sleep_ms: conint(ge=0) = 0 # type: ignore # Optional attributes for mapping Axis to Axis # The deadzone of the input axis deadzone: confloat(ge=0, le=1) = 0.1 # type: ignore gain: float = 1.0 # The scale factor for the transformation # The expo factor for the transformation expo: confloat(ge=-1, le=1) = 0 # type: ignore # when mapping to relative axis # The frequency [Hz] at which EV_REL events get generated rel_rate: PositiveInt = 60 # when mapping from a relative axis: # the relative value at which a EV_REL axis is considered at its maximum. Moving # a mouse at 2x the regular speed would be considered max by default. rel_to_abs_input_cutoff: PositiveInt = 2 # the time until a relative axis is considered stationary if no new events arrive release_timeout: PositiveFloat = 0.05 # don't release immediately when a relative axis drops below the speed threshold # instead wait until it dropped for loger than release_timeout below the threshold force_release_timeout: bool = False # callback which gets called if the input_combination is updated if not needs_workaround: _combination_changed: Optional[CombinationChangedCallback] = None # use type: ignore, looks like a mypy bug related to: # https://github.com/samuelcolvin/pydantic/issues/2949 def __init__(self, **kwargs): # type: ignore super().__init__(**kwargs) if needs_workaround: object.__setattr__(self, "_combination_changed", None) def __setattr__(self, key: str, value: Any): """Call the combination changed callback if we are about to update the input_combination """ if key != "input_combination" or self._combination_changed is None: if key == "_combination_changed" and needs_workaround: object.__setattr__(self, "_combination_changed", value) return super().__setattr__(key, value) return # the new combination is not yet validated try: new_combi = InputCombination.validate(value) except (ValueError, TypeError) as exception: raise ValidationError( f"failed to Validate {value} as InputCombination", UIMapping ) from exception if new_combi == self.input_combination: return # raises a keyError if the combination or a permutation is already mapped self._combination_changed(new_combi, self.input_combination) super().__setattr__("input_combination", new_combi) def __str__(self): return str( self.dict( exclude_defaults=True, include={"input_combination", "target_uinput"} ) ) if needs_workaround: # https://github.com/samuelcolvin/pydantic/issues/1383 def copy(self: MappingModel, *args, **kwargs) -> MappingModel: kwargs["deep"] = True copy = super().copy(*args, **kwargs) object.__setattr__(copy, "_combination_changed", self._combination_changed) return copy def format_name(self) -> str: """Get the custom-name or a readable representation of the combination.""" if self.name: return self.name if ( self.input_combination == InputCombination.empty_combination() or self.input_combination is None ): return EMPTY_MAPPING_NAME return self.input_combination.beautify() def has_input_defined(self) -> bool: """Whether this mapping defines an event-input.""" return self.input_combination != InputCombination.empty_combination() def is_axis_mapping(self) -> bool: """Whether this mapping specifies an output axis.""" return self.output_type in [EV_ABS, EV_REL] def is_wheel_output(self) -> bool: """Check if this maps to wheel output.""" return self.output_code in ( REL_WHEEL, REL_HWHEEL, ) def is_high_res_wheel_output(self) -> bool: """Check if this maps to high-res wheel output.""" return self.output_code in ( REL_WHEEL_HI_RES, REL_HWHEEL_HI_RES, ) def is_analog_output(self): return self.mapping_type == MappingType.ANALOG def set_combination_changed_callback(self, callback: CombinationChangedCallback): self._combination_changed = callback def remove_combination_changed_callback(self): self._combination_changed = None def get_output_type_code(self) -> Optional[Tuple[int, int]]: """Returns the output_type and output_code if set, otherwise looks the output_symbol up in the keyboard_layout return None for unknown symbols and macros """ if self.output_code is not None and self.output_type is not None: return self.output_type, self.output_code if self.output_symbol and not Parser.is_this_a_macro(self.output_symbol): return EV_KEY, keyboard_layout.get(self.output_symbol) return None def get_output_name_constant(self) -> str: """Get the evdev name costant for the output.""" return get_evdev_constant_name(self.output_type, self.output_code) def is_valid(self) -> bool: """If the mapping is valid.""" return not self.get_error() def get_error(self) -> Optional[ValidationError]: """The validation error or None.""" try: Mapping(**self.dict()) except ValidationError as exception: return exception return None def get_bus_message(self) -> MappingData: """Return an immutable copy for use in the message broker.""" return MappingData(**self.dict()) @root_validator def validate_mapping_type(cls, values): """Overrides the mapping type if the output mapping type is obvious.""" output_type = values.get("output_type") output_code = values.get("output_code") output_symbol = values.get("output_symbol") if output_type is not None and output_symbol is not None: # This is currently only possible when someone edits the preset file by # hand. A key-output mapping without an output_symbol, but type and code # instead, is valid as well. logger.debug("Both output_type and output_symbol are set") if output_type != EV_KEY and output_code is not None and not output_symbol: values["mapping_type"] = MappingType.ANALOG.value if output_type is None and output_code is None and output_symbol: values["mapping_type"] = MappingType.KEY_MACRO.value if output_type == EV_KEY: values["mapping_type"] = MappingType.KEY_MACRO.value return values Config = Cfg class Mapping(UIMapping): """Holds all the data for mapping an input action to an output action. This implements the missing validations from UIMapping. """ # Override Required attributes to enforce they are set input_combination: InputCombination target_uinput: KnownUinput @classmethod def from_combination( cls, input_combination=None, target_uinput="keyboard", output_symbol="a", ): """Convenient function to get a valid mapping.""" if not input_combination: input_combination = [{"type": 99, "code": 99, "analog_threshold": 99}] return cls( input_combination=input_combination, target_uinput=target_uinput, output_symbol=output_symbol, ) def is_valid(self) -> bool: """If the mapping is valid.""" return True @root_validator(pre=True) def validate_symbol(cls, values): """Parse a macro to check for syntax errors.""" symbol = values.get("output_symbol") if symbol == "": values["output_symbol"] = None return values if symbol is None: return values symbol = symbol.strip() values["output_symbol"] = symbol if symbol == DISABLE_NAME: return values if Parser.is_this_a_macro(symbol): mapping_mock = namedtuple("Mapping", values.keys())(**values) # raises MacroError Parser.parse(symbol, mapping=mapping_mock, verbose=False) return values code = keyboard_layout.get(symbol) if code is None: raise OutputSymbolUnknownError(symbol) target = values.get("target_uinput") if target is not None and not GlobalUInputs.can_default_uinput_emit( target, EV_KEY, code ): raise SymbolNotAvailableInTargetError(symbol, target) return values @validator("input_combination") def only_one_analog_input(cls, combination) -> InputCombination: """Check that the input_combination specifies a maximum of one analog to analog mapping """ analog_events = [event for event in combination if event.defines_analog_input] if len(analog_events) > 1: raise OnlyOneAnalogInputError(analog_events) return combination @validator("input_combination") def trigger_point_in_range(cls, combination: InputCombination) -> InputCombination: """Check if the trigger point for mapping analog axis to buttons is valid.""" for input_config in combination: if ( input_config.type == EV_ABS and input_config.analog_threshold and abs(input_config.analog_threshold) >= 100 ): raise TriggerPointInRangeError(input_config) return combination @root_validator def validate_output_symbol_variant(cls, values): """Validate that either type and code or symbol are set for key output.""" o_symbol = values.get("output_symbol") o_type = values.get("output_type") o_code = values.get("output_code") if o_symbol is None and (o_type is None or o_code is None): raise OutputSymbolVariantError() return values @root_validator def validate_output_integrity(cls, values): """Validate the output key configuration.""" symbol = values.get("output_symbol") type_ = values.get("output_type") code = values.get("output_code") if symbol is None: # If symbol is "", then validate_symbol changes it to None # type and code can be anything return values if type_ is None and code is None: # we have a symbol: no type and code is fine return values if Parser.is_this_a_macro(symbol): # disallow output type and code for macros if type_ is not None or code is not None: raise MacroButTypeOrCodeSetError() if code is not None and code != keyboard_layout.get(symbol) or type_ != EV_KEY: raise SymbolAndCodeMismatchError(symbol, code) return values @root_validator def output_matches_input(cls, values: Dict[str, Any]) -> Dict[str, Any]: """Validate that an output type is an axis if we have an input axis. And vice versa.""" assert isinstance(values.get("input_combination"), InputCombination) combination: InputCombination = values["input_combination"] analog_input_config = combination.find_analog_input_config() defines_analog_input = analog_input_config is not None output_type = values.get("output_type") output_code = values.get("output_code") mapping_type = values.get("mapping_type") output_symbol = values.get("output_symbol") output_key_set = output_symbol or (output_type == EV_KEY and output_code) if mapping_type is None: # Empty mapping most likely return values if not defines_analog_input and mapping_type != MappingType.KEY_MACRO.value: raise WrongMappingTypeForKeyError() if not defines_analog_input and not output_key_set: raise MissingMacroOrKeyError() if ( defines_analog_input and output_type not in (EV_ABS, EV_REL) and output_symbol != DISABLE_NAME ): raise MissingOutputAxisError(analog_input_config, output_type) return values class MappingData(UIMapping): """Like UIMapping, but can be sent over the message broker.""" Config = ImmutableCfg message_type = MessageType.mapping # allow this to be sent over the MessageBroker def __str__(self): return str(self.dict(exclude_defaults=True)) def dict(self, *args, **kwargs): """Will not include the message_type.""" dict_ = super().dict(*args, **kwargs) if "message_type" in dict_: del dict_["message_type"] return dict_ input-remapper-2.1.1/inputremapper/configs/migrations.py000066400000000000000000000464221475433465200235610ustar00rootroot00000000000000# -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings # Copyright (C) 2025 sezanzeb # # This file is part of input-remapper. # # input-remapper is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # input-remapper is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with input-remapper. If not, see . """Migration functions. Only write changes to disk, if there actually are changes. Otherwise, file-modification dates are destroyed. """ from __future__ import annotations import copy import json import os import re import shutil from pathlib import Path from typing import Iterator, Tuple, Dict, List, Optional, TypedDict from evdev.ecodes import ( EV_KEY, EV_ABS, EV_REL, ABS_X, ABS_Y, ABS_RX, ABS_RY, REL_X, REL_Y, REL_WHEEL_HI_RES, REL_HWHEEL_HI_RES, ) from packaging import version from inputremapper.configs.input_config import InputCombination, InputConfig from inputremapper.configs.keyboard_layout import keyboard_layout from inputremapper.configs.mapping import Mapping, UIMapping from inputremapper.configs.paths import PathUtils from inputremapper.configs.preset import Preset from inputremapper.injection.global_uinputs import GlobalUInputs from inputremapper.injection.macros.parse import Parser from inputremapper.logging.logger import logger, VERSION from inputremapper.user import UserUtils class Config(TypedDict): input_combination: Optional[InputCombination] target_uinput: str output_type: int output_code: Optional[int] class Migrations: def __init__(self, global_uinputs: GlobalUInputs): self.global_uinputs = global_uinputs def migrate(self): """Migrate config files to the current release.""" self._rename_to_input_remapper() self._copy_to_v2() v = self.config_version() if v < version.parse("0.4.0"): self._config_suffix() self._preset_path() if v < version.parse("1.2.2"): self._mapping_keys() if v < version.parse("1.4.0"): self.global_uinputs.prepare_all() self._add_target() if v < version.parse("1.4.1"): self._otherwise_to_else() if v < version.parse("1.5.0"): self._remove_logs() if v < version.parse("1.6.0-beta"): self._convert_to_individual_mappings() # add new migrations here if v < version.parse(VERSION): self._update_version() def all_presets( self, ) -> Iterator[Tuple[os.PathLike, Dict | List]]: """Get all presets for all groups as list.""" if not os.path.exists(PathUtils.get_preset_path()): return preset_path = Path(PathUtils.get_preset_path()) for folder in preset_path.iterdir(): if not folder.is_dir(): continue for preset in folder.iterdir(): if preset.suffix != ".json": continue try: with open(preset, "r") as f: preset_structure = json.load(f) yield preset, preset_structure except json.decoder.JSONDecodeError: logger.warning('Invalid json format in preset "%s"', preset) continue def config_version(self): """Get the version string in config.json as packaging.Version object.""" config_path = os.path.join(PathUtils.config_path(), "config.json") if not os.path.exists(config_path): return version.parse("0.0.0") with open(config_path, "r") as file: config = json.load(file) if "version" in config.keys(): return version.parse(config["version"]) return version.parse("0.0.0") def _config_suffix(self): """Append the .json suffix to the config file.""" deprecated_path = os.path.join(PathUtils.config_path(), "config") config_path = os.path.join(PathUtils.config_path(), "config.json") if os.path.exists(deprecated_path) and not os.path.exists(config_path): logger.info('Moving "%s" to "%s"', deprecated_path, config_path) os.rename(deprecated_path, config_path) def _preset_path(self): """Migrate the folder structure from < 0.4.0. Move existing presets into the new subfolder 'presets' """ new_preset_folder = os.path.join(PathUtils.config_path(), "presets") if os.path.exists(PathUtils.get_preset_path()) or not os.path.exists( PathUtils.config_path() ): return logger.info("Migrating presets from < 0.4.0...") groups = os.listdir(PathUtils.config_path()) PathUtils.mkdir(PathUtils.get_preset_path()) for group in groups: path = os.path.join(PathUtils.config_path(), group) if os.path.isdir(path): target = path.replace(PathUtils.config_path(), new_preset_folder) logger.info('Moving "%s" to "%s"', path, target) os.rename(path, target) logger.info("done") def _mapping_keys(self): """Update all preset mappings. Update all keys in preset to include value e.g.: '1,5'->'1,5,1' """ for preset, preset_structure in self.all_presets(): if isinstance(preset_structure, list): continue # the preset must be at least 1.6-beta version changes = 0 if "mapping" in preset_structure.keys(): mapping = copy.deepcopy(preset_structure["mapping"]) for key in mapping.keys(): if key.count(",") == 1: preset_structure["mapping"][f"{key},1"] = preset_structure[ "mapping" ].pop(key) changes += 1 if changes: with open(preset, "w") as file: logger.info('Updating mapping keys of "%s"', preset) json.dump(preset_structure, file, indent=4) file.write("\n") def _update_version(self): """Write the current version to the config file.""" config_file = os.path.join(PathUtils.config_path(), "config.json") if not os.path.exists(config_file): return with open(config_file, "r") as file: config = json.load(file) config["version"] = VERSION with open(config_file, "w") as file: logger.info('Updating version in config to "%s"', VERSION) json.dump(config, file, indent=4) def _rename_to_input_remapper(self): """Rename .config/key-mapper to .config/input-remapper.""" old_config_path = os.path.join(UserUtils.home, ".config/key-mapper") if not os.path.exists(PathUtils.config_path()) and os.path.exists( old_config_path ): logger.info("Moving %s to %s", old_config_path, PathUtils.config_path()) shutil.move(old_config_path, PathUtils.config_path()) def _find_target(self, symbol): """Try to find a uinput with the required capabilities for the symbol.""" capabilities = {EV_KEY: set(), EV_REL: set()} if Parser.is_this_a_macro(symbol): # deprecated mechanic, cannot figure this out anymore # capabilities = parse(symbol).get_capabilities() return None capabilities[EV_KEY] = {keyboard_layout.get(symbol)} if len(capabilities[EV_REL]) > 0: return "mouse" for name, uinput in self.global_uinputs.devices.items(): if capabilities[EV_KEY].issubset(uinput.capabilities()[EV_KEY]): return name logger.info('could not find a suitable target UInput for "%s"', symbol) return None def _add_target(self): """Add the target field to each preset mapping.""" for preset, preset_structure in self.all_presets(): if isinstance(preset_structure, list): continue if "mapping" not in preset_structure.keys(): continue changed = False for key, symbol in preset_structure["mapping"].copy().items(): if isinstance(symbol, list): continue target = self._find_target(symbol) if target is None: target = "keyboard" symbol = ( f"{symbol}\n" "# Broken mapping:\n" "# No target can handle all specified keycodes" ) logger.info( 'Changing target of mapping for "%s" in preset "%s" to "%s"', key, preset, target, ) symbol = [symbol, target] preset_structure["mapping"][key] = symbol changed = True if not changed: continue with open(preset, "w") as file: logger.info('Adding targets for "%s"', preset) json.dump(preset_structure, file, indent=4) file.write("\n") def _otherwise_to_else(self): """Conditional macros should use an "else" parameter instead of "otherwise".""" for preset, preset_structure in self.all_presets(): if isinstance(preset_structure, list): continue if "mapping" not in preset_structure.keys(): continue changed = False for key, symbol in preset_structure["mapping"].copy().items(): if not Parser.is_this_a_macro(symbol[0]): continue symbol_before = symbol[0] symbol[0] = re.sub(r"otherwise\s*=\s*", "else=", symbol[0]) if symbol_before == symbol[0]: continue changed = changed or symbol_before != symbol[0] logger.info( 'Changing mapping for "%s" in preset "%s" to "%s"', key, preset, symbol[0], ) preset_structure["mapping"][key] = symbol if not changed: continue with open(preset, "w") as file: logger.info('Changing otherwise to else for "%s"', preset) json.dump(preset_structure, file, indent=4) file.write("\n") def _input_combination_from_string( self, combination_string: str ) -> InputCombination: configs = [] for event_str in combination_string.split("+"): type_, code, analog_threshold = event_str.split(",") configs.append( { "type": int(type_), "code": int(code), "analog_threshold": int(analog_threshold), } ) return InputCombination(configs) def _convert_to_individual_mappings( self, ) -> None: """Convert preset.json from {key: [symbol, target]} to [{input_combination: ..., output_symbol: symbol, ...}] """ for old_preset_path, old_preset in self.all_presets(): if isinstance(old_preset, list): continue migrated_preset = Preset(old_preset_path, UIMapping) if "mapping" in old_preset.keys(): for combination, symbol_target in old_preset["mapping"].items(): logger.info( 'migrating from "%s: %s" to mapping dict', combination, symbol_target, ) try: combination = self._input_combination_from_string(combination) except ValueError: logger.error( "unable to migrate mapping with invalid combination %s", combination, ) continue mapping = UIMapping( input_combination=combination, target_uinput=symbol_target[1], output_symbol=symbol_target[0], ) migrated_preset.add(mapping) if ( "gamepad" in old_preset.keys() and "joystick" in old_preset["gamepad"].keys() ): joystick_dict = old_preset["gamepad"]["joystick"] left_purpose = joystick_dict.get("left_purpose") right_purpose = joystick_dict.get("right_purpose") # TODO if pointer_speed is migrated, why is it in my config? pointer_speed = joystick_dict.get("pointer_speed") if pointer_speed: pointer_speed /= 100 non_linearity = joystick_dict.get("non_linearity") # Todo x_scroll_speed = joystick_dict.get("x_scroll_speed") y_scroll_speed = joystick_dict.get("y_scroll_speed") cfg: Config = { "input_combination": None, "target_uinput": "mouse", "output_type": EV_REL, "output_code": None, } if left_purpose == "mouse": x_config = cfg.copy() y_config = cfg.copy() x_config["input_combination"] = InputCombination( [InputConfig(type=EV_ABS, code=ABS_X)] ) y_config["input_combination"] = InputCombination( [InputConfig(type=EV_ABS, code=ABS_Y)] ) x_config["output_code"] = REL_X y_config["output_code"] = REL_Y mapping_x = Mapping(**x_config) mapping_y = Mapping(**y_config) if pointer_speed: mapping_x.gain = pointer_speed mapping_y.gain = pointer_speed migrated_preset.add(mapping_x) migrated_preset.add(mapping_y) if right_purpose == "mouse": x_config = cfg.copy() y_config = cfg.copy() x_config["input_combination"] = InputCombination( [InputConfig(type=EV_ABS, code=ABS_RX)] ) y_config["input_combination"] = InputCombination( [InputConfig(type=EV_ABS, code=ABS_RY)] ) x_config["output_code"] = REL_X y_config["output_code"] = REL_Y mapping_x = Mapping(**x_config) mapping_y = Mapping(**y_config) if pointer_speed: mapping_x.gain = pointer_speed mapping_y.gain = pointer_speed migrated_preset.add(mapping_x) migrated_preset.add(mapping_y) if left_purpose == "wheel": x_config = cfg.copy() y_config = cfg.copy() x_config["input_combination"] = InputCombination( [InputConfig(type=EV_ABS, code=ABS_X)] ) y_config["input_combination"] = InputCombination( [InputConfig(type=EV_ABS, code=ABS_Y)] ) x_config["output_code"] = REL_HWHEEL_HI_RES y_config["output_code"] = REL_WHEEL_HI_RES mapping_x = Mapping(**x_config) mapping_y = Mapping(**y_config) if x_scroll_speed: mapping_x.gain = x_scroll_speed if y_scroll_speed: mapping_y.gain = y_scroll_speed migrated_preset.add(mapping_x) migrated_preset.add(mapping_y) if right_purpose == "wheel": x_config = cfg.copy() y_config = cfg.copy() x_config["input_combination"] = InputCombination( [InputConfig(type=EV_ABS, code=ABS_RX)] ) y_config["input_combination"] = InputCombination( [InputConfig(type=EV_ABS, code=ABS_RY)] ) x_config["output_code"] = REL_HWHEEL_HI_RES y_config["output_code"] = REL_WHEEL_HI_RES mapping_x = Mapping(**x_config) mapping_y = Mapping(**y_config) if x_scroll_speed: mapping_x.gain = x_scroll_speed if y_scroll_speed: mapping_y.gain = y_scroll_speed migrated_preset.add(mapping_x) migrated_preset.add(mapping_y) migrated_preset.save() def _copy_to_v2(self): """Move the beta config to the v2 path, or copy the v1 config to the v2 path.""" # TODO test if os.path.exists(PathUtils.config_path()): # don't copy to already existing folder # users should delete the input-remapper-2 folder if they need to return # prioritize the v1 configs over beta configs old_path = os.path.join(UserUtils.home, ".config/input-remapper") if os.path.exists(os.path.join(old_path, "config.json")): # no beta path, only old presets exist. COPY to v2 path, which will then be # migrated by the various self. logger.debug("copying all from %s to %s", old_path, PathUtils.config_path()) shutil.copytree(old_path, PathUtils.config_path()) return # if v1 configs don't exist, try to find beta configs. beta_path = os.path.join( UserUtils.home, ".config/input-remapper/beta_1.6.0-beta" ) if os.path.exists(beta_path): # There has never been a different version than "1.6.0-beta" in beta, so we # only need to check for that exact directory # already migrated, possibly new presets in them, move to v2 path logger.debug("moving %s to %s", beta_path, PathUtils.config_path()) shutil.move(beta_path, PathUtils.config_path()) def _remove_logs(self): """We will try to rely on journalctl for this in the future.""" try: PathUtils.remove(f"{UserUtils.home}/.log/input-remapper") PathUtils.remove("/var/log/input-remapper") PathUtils.remove("/var/log/input-remapper-control") except Exception as error: logger.debug("Failed to remove deprecated logfiles: %s", str(error)) # this migration is not important. Continue pass input-remapper-2.1.1/inputremapper/configs/paths.py000066400000000000000000000121071475433465200225150ustar00rootroot00000000000000# -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings # Copyright (C) 2025 sezanzeb # # This file is part of input-remapper. # # input-remapper is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # input-remapper is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with input-remapper. If not, see . # TODO: convert everything to use pathlib.Path """Path constants to be used.""" import os import shutil from typing import List, Union, Optional from inputremapper.logging.logger import logger from inputremapper.user import UserUtils # TODO maybe this could become, idk, ConfigService and PresetService class PathUtils: rel_path = ".config/input-remapper-2" @staticmethod def config_path() -> str: # TODO when proper DI is being done, construct PathUtils and configure it in # the constructor. Then there is no need to recompute the config_path # each time. Tests might have overwritten UserUtils.home. return os.path.join(UserUtils.home, PathUtils.rel_path) @staticmethod def chown(path): """Set the owner of a path to the user.""" try: logger.debug('Chown "%s", "%s"', path, UserUtils.user) shutil.chown(path, user=UserUtils.user, group=UserUtils.user) except LookupError: # the users group was unknown in one case for whatever reason shutil.chown(path, user=UserUtils.user) @staticmethod def touch(path: Union[str, os.PathLike], log=True): """Create an empty file and all its parent dirs, give it to the user.""" if str(path).endswith("/"): raise ValueError(f"Expected path to not end with a slash: {path}") if os.path.exists(path): return if log: logger.info('Creating file "%s"', path) PathUtils.mkdir(os.path.dirname(path), log=False) os.mknod(path) PathUtils.chown(path) @staticmethod def mkdir(path, log=True): """Create a folder, give it to the user.""" if path == "" or path is None: return if os.path.exists(path): return if log: logger.info('Creating dir "%s"', path) # give all newly created folders to the user. # e.g. if .config/input-remapper/mouse/ is created the latter two base = os.path.split(path)[0] PathUtils.mkdir(base, log=False) os.makedirs(path) PathUtils.chown(path) @staticmethod def split_all(path: Union[os.PathLike, str]) -> List[str]: """Split the path into its segments.""" parts = [] while True: path, tail = os.path.split(path) parts.append(tail) if path == os.path.sep: # we arrived at the root '/' parts.append(path) break if not path: # arrived at start of relative path break parts.reverse() return parts @staticmethod def remove(path): """Remove whatever is at the path.""" if not os.path.exists(path): return if os.path.isdir(path): shutil.rmtree(path) else: os.remove(path) @staticmethod def sanitize_path_component(group_name: str) -> str: """Replace characters listed in https://en.wikipedia.org/wiki/Filename#Reserved_characters_and_words with an underscore. """ for character in '/\\?%*:|"<>': if character in group_name: group_name = group_name.replace(character, "_") return group_name @staticmethod def get_preset_path(group_name: Optional[str] = None, preset: Optional[str] = None): """Get a path to the stored preset, or to store a preset to.""" presets_base = os.path.join(PathUtils.config_path(), "presets") if group_name is None: return presets_base group_name = PathUtils.sanitize_path_component(group_name) if preset is not None: # the extension of the preset should not be shown in the ui. # if a .json extension arrives this place, it has not been # stripped away properly prior to this. if not preset.endswith(".json"): preset = f"{preset}.json" if preset is None: return os.path.join(presets_base, group_name) return os.path.join(presets_base, group_name, preset) @staticmethod def get_config_path(*paths) -> str: """Get a path in ~/.config/input-remapper/.""" return os.path.join(PathUtils.config_path(), *paths) input-remapper-2.1.1/inputremapper/configs/preset.py000066400000000000000000000267661475433465200227200ustar00rootroot00000000000000# -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings # Copyright (C) 2025 sezanzeb # # This file is part of input-remapper. # # input-remapper is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # input-remapper is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with input-remapper. If not, see . """Contains and manages mappings.""" from __future__ import annotations import json import os from typing import ( Tuple, Dict, List, Optional, Iterator, Type, TypeVar, Generic, overload, ) from evdev import ecodes try: from pydantic.v1 import ValidationError except ImportError: from pydantic import ValidationError from inputremapper.configs.input_config import InputCombination, InputConfig from inputremapper.configs.mapping import Mapping, UIMapping from inputremapper.configs.paths import PathUtils from inputremapper.logging.logger import logger MappingModel = TypeVar("MappingModel", bound=UIMapping) class Preset(Generic[MappingModel]): """Contains and manages mappings of a single preset.""" # workaround for typing: https://github.com/python/mypy/issues/4236 @overload def __init__(self: Preset[Mapping], path: Optional[os.PathLike] = None): ... @overload def __init__( self, path: Optional[os.PathLike] = None, mapping_factory: Type[MappingModel] = ..., ): ... def __init__( self, path: Optional[os.PathLike] = None, mapping_factory=Mapping, ) -> None: self._mappings: Dict[InputCombination, MappingModel] = {} # a copy of mappings for keeping track of changes self._saved_mappings: Dict[InputCombination, MappingModel] = {} self._path: Optional[os.PathLike] = path # the mapping class which is used by load() self._mapping_factory: Type[MappingModel] = mapping_factory def __iter__(self) -> Iterator[MappingModel]: """Iterate over Mapping objects.""" return iter(self._mappings.copy().values()) def __len__(self) -> int: return len(self._mappings) def __bool__(self): # otherwise __len__ will be used which results in False for a preset # without mappings return True def has_unsaved_changes(self) -> bool: """Check if there are unsaved changed.""" return self._mappings != self._saved_mappings def remove(self, combination: InputCombination) -> None: """Remove a mapping from the preset by providing the InputCombination.""" if not isinstance(combination, InputCombination): raise TypeError( f"combination must by of type InputCombination, got {type(combination)}" ) for permutation in combination.get_permutations(): if permutation in self._mappings.keys(): combination = permutation break try: mapping = self._mappings.pop(combination) mapping.remove_combination_changed_callback() except KeyError: logger.debug( "unable to remove non-existing mapping with combination = %s", combination, ) pass def add(self, mapping: MappingModel) -> None: """Add a mapping to the preset.""" for permutation in mapping.input_combination.get_permutations(): if permutation in self._mappings: raise KeyError( "A mapping with this input_combination: " f"{permutation} already exists", ) mapping.set_combination_changed_callback(self._combination_changed_callback) self._mappings[mapping.input_combination] = mapping def empty(self) -> None: """Remove all mappings and custom configs without saving. note: self.has_unsaved_changes() will report True """ for mapping in self._mappings.values(): mapping.remove_combination_changed_callback() self._mappings = {} def clear(self) -> None: """Remove all mappings and also self.path.""" self.empty() self._saved_mappings = {} self.path = None def load(self) -> None: """Load from the mapping from the disc, clears all existing mappings.""" logger.info('Loading preset from "%s"', self.path) if not self.path or not os.path.exists(self.path): raise FileNotFoundError(f'Tried to load non-existing preset "{self.path}"') self._saved_mappings = self._get_mappings_from_disc() self.empty() for mapping in self._saved_mappings.values(): # use the public add method to make sure # the _combination_changed_callback is attached self.add(mapping.copy()) def _is_mapped_multiple_times(self, input_combination: InputCombination) -> bool: """Check if the event combination maps to multiple mappings.""" all_input_combinations = {mapping.input_combination for mapping in self} permutations = set(input_combination.get_permutations()) union = permutations & all_input_combinations # if there are more than one matches, then there is a duplicate return len(union) > 1 def _has_valid_input_combination(self, mapping: UIMapping) -> bool: """Check if the mapping has a valid input event combination.""" is_a_combination = isinstance(mapping.input_combination, InputCombination) is_empty = mapping.input_combination == InputCombination.empty_combination() return is_a_combination and not is_empty def save(self) -> None: """Dump as JSON to self.path.""" if not self.path: logger.debug("unable to save preset without a path set Preset.path first") return PathUtils.touch(self.path) if not self.has_unsaved_changes(): logger.debug("Not saving unchanged preset") return logger.info("Saving preset to %s", self.path) preset_list = [] saved_mappings = {} for mapping in self: if not mapping.is_valid(): if not self._has_valid_input_combination(mapping): # we save invalid mappings except for those with an invalid # input_combination logger.debug("Skipping invalid mapping %s", mapping) continue if self._is_mapped_multiple_times(mapping.input_combination): # todo: is this ever executed? it should not be possible to # reach this logger.debug( "skipping mapping with duplicate event combination %s", mapping, ) continue mapping_dict = mapping.dict(exclude_defaults=True) mapping_dict["input_combination"] = mapping.input_combination.to_config() combination = mapping.input_combination preset_list.append(mapping_dict) saved_mappings[combination] = mapping.copy() saved_mappings[combination].remove_combination_changed_callback() with open(self.path, "w") as file: json.dump(preset_list, file, indent=4) file.write("\n") self._saved_mappings = saved_mappings def is_valid(self) -> bool: return False not in [mapping.is_valid() for mapping in self] def get_mapping( self, combination: Optional[InputCombination] ) -> Optional[MappingModel]: """Return the Mapping that is mapped to this InputCombination.""" if not combination: return None if not isinstance(combination, InputCombination): raise TypeError( f"combination must by of type InputCombination, got {type(combination)}" ) for permutation in combination.get_permutations(): existing = self._mappings.get(permutation) if existing is not None: return existing return None def dangerously_mapped_btn_left(self) -> bool: """Return True if this mapping disables BTN_Left.""" if (ecodes.EV_KEY, ecodes.BTN_LEFT) not in [ m.input_combination[0].type_and_code for m in self ]: return False values: List[str | Tuple[int, int] | None] = [] for mapping in self: if mapping.output_symbol is None: continue values.append(mapping.output_symbol.lower()) values.append(mapping.get_output_type_code()) return ( "btn_left" not in values or InputConfig.btn_left().type_and_code not in values ) def _combination_changed_callback( self, new: InputCombination, old: InputCombination ) -> None: for permutation in new.get_permutations(): if permutation in self._mappings.keys() and permutation != old: raise KeyError("combination already exists in the preset") self._mappings[new] = self._mappings.pop(old) def _update_saved_mappings(self) -> None: if self.path is None: return if not os.path.exists(self.path): self._saved_mappings = {} return self._saved_mappings = self._get_mappings_from_disc() def _get_mappings_from_disc(self) -> Dict[InputCombination, MappingModel]: mappings: Dict[InputCombination, MappingModel] = {} if not self.path: logger.debug("unable to read preset without a path set Preset.path first") return mappings if os.stat(self.path).st_size == 0: logger.debug("got empty file") return mappings with open(self.path, "r") as file: try: preset_list = json.load(file) except json.JSONDecodeError: logger.error("unable to decode json file: %s", self.path) return mappings for mapping_dict in preset_list: if not isinstance(mapping_dict, dict): logger.error( "Expected mapping to be a dict: %s %s", type(mapping_dict), mapping_dict, ) continue try: mapping = self._mapping_factory(**mapping_dict) except Exception as error: logger.error( "failed to Validate mapping for %s: %s", mapping_dict.get("input_combination"), error, ) continue mappings[mapping.input_combination] = mapping return mappings @property def path(self) -> Optional[os.PathLike]: return self._path @path.setter def path(self, path: Optional[os.PathLike]): if path != self.path: self._path = path self._update_saved_mappings() @property def name(self) -> Optional[str]: """The name of the preset.""" if self.path: return os.path.basename(self.path).split(".")[0] return None input-remapper-2.1.1/inputremapper/configs/validation_errors.py000066400000000000000000000110401475433465200251170ustar00rootroot00000000000000# -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings # Copyright (C) 2025 sezanzeb # # This file is part of input-remapper. # # input-remapper is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # input-remapper is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with input-remapper. If not, see . """Exceptions that are thrown when configurations are incorrect.""" # can't merge this with exceptions.py, because I want error constructors here to # be intelligent to avoid redundant code, and they need imports, which would cause # circular imports. # pydantic only catches ValueError, TypeError, and AssertionError from __future__ import annotations from typing import Optional from evdev.ecodes import EV_KEY from inputremapper.configs.keyboard_layout import keyboard_layout from inputremapper.injection.global_uinputs import GlobalUInputs class OutputSymbolVariantError(ValueError): def __init__(self): super().__init__( "Missing Argument: Mapping must either contain " "`output_symbol` or `output_type` and `output_code`" ) class TriggerPointInRangeError(ValueError): def __init__(self, input_config): super().__init__( f"{input_config = } maps an absolute axis to a button, but the " "trigger point (event.analog_threshold) is not between -100[%] " "and 100[%]" ) class OnlyOneAnalogInputError(ValueError): def __init__(self, analog_events): super().__init__( f"Cannot map a combination of multiple analog inputs: {analog_events}" "add trigger points (event.value != 0) to map as a button" ) class SymbolNotAvailableInTargetError(ValueError): def __init__(self, symbol, target): code = keyboard_layout.get(symbol) fitting_targets = GlobalUInputs.find_fitting_default_uinputs(EV_KEY, code) fitting_targets_string = '", "'.join(fitting_targets) super().__init__( f'The output_symbol "{symbol}" is not available for the "{target}" ' + f'target. Try "{fitting_targets_string}".' ) class OutputSymbolUnknownError(ValueError): def __init__(self, symbol: str): super().__init__( f'The output_symbol "{symbol}" is not a macro and not a valid ' + "keycode-name" ) class MacroButTypeOrCodeSetError(ValueError): def __init__(self): super().__init__( "output_symbol is a macro: output_type " "and output_code must be None" ) class SymbolAndCodeMismatchError(ValueError): def __init__(self, symbol, code): super().__init__( "output_symbol and output_code mismatch: " f"output macro is {symbol} -> {keyboard_layout.get(symbol)} " f"but output_code is {code} -> {keyboard_layout.get_name(code)} " ) class WrongMappingTypeForKeyError(ValueError): def __init__(self): super().__init__(f"Wrong mapping_type for key input") class MissingMacroOrKeyError(ValueError): def __init__(self): super().__init__("Missing macro or key") class MissingOutputAxisError(ValueError): def __init__(self, analog_input_config, output_type): super().__init__( "Missing output axis: " f'"{analog_input_config}" is used as analog input, ' f"but the {output_type = } is not an axis " ) class MacroError(ValueError): """Macro syntax errors.""" def __init__(self, symbol: Optional[str] = None, msg="Error while parsing a macro"): self.symbol = symbol super().__init__(msg) def pydantify(error: type): """Generate a string as it would appear IN pydantic error types. This does not include the base class name, which is transformed to snake case in pydantic. Example pydantic error type: "value_error.foobar" for FooBarError. """ # See https://github.com/pydantic/pydantic/discussions/5112 lower_classname = error.__name__.lower() if lower_classname.endswith("error"): return lower_classname[: -len("error")] return lower_classname input-remapper-2.1.1/inputremapper/daemon.py000066400000000000000000000457471475433465200212310ustar00rootroot00000000000000# -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings # Copyright (C) 2025 sezanzeb # # This file is part of input-remapper. # # input-remapper is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # input-remapper is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with input-remapper. If not, see . """Starts injecting keycodes based on the configuration. https://github.com/LEW21/pydbus/tree/cc407c8b1d25b7e28a6d661a29f9e661b1c9b964/examples/clientserver # noqa pylint: disable=line-too-long """ import atexit import json import os import sys import time from pathlib import PurePath from typing import Protocol, Dict, Optional import gi from pydbus import SystemBus from inputremapper.injection.mapping_handlers.mapping_parser import MappingParser gi.require_version("GLib", "2.0") from gi.repository import GLib from inputremapper.logging.logger import logger from inputremapper.injection.injector import Injector, InjectorState from inputremapper.configs.preset import Preset from inputremapper.configs.global_config import GlobalConfig from inputremapper.configs.keyboard_layout import keyboard_layout from inputremapper.groups import groups from inputremapper.configs.paths import PathUtils from inputremapper.user import UserUtils from inputremapper.injection.macros.macro import macro_variables from inputremapper.injection.global_uinputs import GlobalUInputs 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.""" # preset of device -> (timestamp, preset) self._autoload_history = {} def remember(self, group_key: str, preset: str): """Remember when this preset was autoloaded for the device.""" self._autoload_history[group_key] = (time.time(), preset) def forget(self, group_key: str): """The injection was stopped or started by hand.""" if group_key in self._autoload_history: del self._autoload_history[group_key] def may_autoload(self, group_key: str, preset: str): """Check if this autoload would be redundant. This is needed because udev triggers multiple times per hardware device, and because it should be possible to stop the injection by unplugging the device if the preset goes wrong or if input-remapper has some bug that prevents the computer from being controlled. For that unplug and reconnect the device twice within a 15 seconds timeframe which will then not ask for autoloading again. Wait 3 seconds between replugging. """ if group_key not in self._autoload_history: return True if self._autoload_history[group_key][1] != preset: return True # bluetooth devices go to standby mode after some time. After a # certain time of being disconnected it should be legit to autoload # again. It takes 2.5 seconds for me when quickly replugging my usb # mouse until the daemon is asked to autoload again. Redundant calls # by udev to autoload for the device seem to happen within 0.2 # seconds in my case. now = time.time() threshold = 15 # seconds if self._autoload_history[group_key][0] < now - threshold: return True return False 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 DaemonProxy(Protocol): # pragma: no cover """The interface provided over the dbus.""" def stop_injecting(self, group_key: str) -> None: ... def get_state(self, group_key: str) -> InjectorState: ... def start_injecting(self, group_key: str, preset: str) -> bool: ... def stop_all(self) -> None: ... def set_config_dir(self, config_dir: str) -> None: ... def autoload(self) -> None: ... def autoload_single(self, group_key: str) -> None: ... def hello(self, out: str) -> str: ... def quit(self) -> None: ... class Daemon: """Starts injecting keycodes based on the configuration. Can be talked to either over dbus or by instantiating it. The Daemon may not have any knowledge about the logged in user, so it can't read any config files. It has to be told what to do and will continue to do so afterwards, but it can't decide to start injecting on its own. """ # https://dbus.freedesktop.org/doc/dbus-specification.html#type-system dbus = f""" """ def __init__( self, global_config: GlobalConfig, global_uinputs: GlobalUInputs, mapping_parser: MappingParser, ): """Constructs the daemon.""" logger.debug("Creating daemon") self.global_config = global_config self.global_uinputs = global_uinputs self.mapping_parser = mapping_parser self.injectors: Dict[str, Injector] = {} self.config_dir = None if UserUtils.home != "root": self.set_config_dir(PathUtils.get_config_path()) # check privileges if os.getuid() != 0: logger.warning("The service usually needs elevated privileges") self.autoload_history = AutoloadHistory() self.refreshed_devices_at = 0 atexit.register(self.stop_all) # initialize stuff that is needed alongside the daemon process macro_variables.start() @classmethod def connect(cls, fallback: bool = True) -> DaemonProxy: """Get an interface to start and stop injecting keystrokes. Parameters ---------- fallback If true, starts the daemon via pkexec if it cannot connect. """ bus = SystemBus() try: 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 logger.is_debug() else "" cmd = f"pkexec input-remapper-control --command start-daemon {debug}" # using pkexec will also cause the service to continue running in # the background after the gui has been closed, which will keep # the injections ongoing logger.debug("Running `%s`", cmd) os.system(cmd) time.sleep(0.2) # try a few times if the service was just started for attempt in range(3): try: 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(8) if UserUtils.home != "root": config_path = PathUtils.get_config_path() logger.debug('Telling service about "%s"', config_path) interface.set_config_dir(PathUtils.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(9) 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: Optional[str] = None): """Refresh groups if the specified group is unknown. Parameters ---------- group_key unique identifier used by the groups object """ now = time.time() if now - 10 > self.refreshed_devices_at: logger.debug("Refreshing because last info is too old") # it may take a bit of time until devices are visible after changes time.sleep(0.1) groups.refresh() self.refreshed_devices_at = now return if not groups.find(key=group_key): logger.debug('Refreshing because "%s" is unknown', group_key) time.sleep(0.1) groups.refresh() self.refreshed_devices_at = now def stop_injecting(self, group_key: str): """Stop injecting the preset mappings for a single device.""" if self.injectors.get(group_key) is None: logger.debug( 'Tried to stop injector, but none is running for group "%s"', group_key, ) return self.injectors[group_key].stop_injecting() self.autoload_history.forget(group_key) def get_state(self, group_key: str) -> InjectorState: """Get the injectors state.""" injector = self.injectors.get(group_key) return injector.get_state() if injector else InjectorState.UNKNOWN @remove_timeout def set_config_dir(self, config_dir: str): """All future operations will use this config dir. Existing injections (possibly of the previous user) will be kept alive, call stop_all to stop them. Parameters ---------- config_dir This path contains config.json, xmodmap.json and the presets directory """ config_path = PurePath(config_dir, "config.json") if not os.path.exists(config_path): logger.error('"%s" does not exist', config_path) return self.config_dir = config_dir self.global_config.load_config(str(config_path)) def _autoload(self, group_key: str): """Check if autoloading is a good idea, and if so do it. Parameters ---------- group_key unique identifier used by the groups object """ self.refresh(group_key) group = groups.find(key=group_key) if group is None: # even after groups.refresh, the device is unknown, so it's # either not relevant for input-remapper, or not connected yet return preset = self.global_config.get(["autoload", 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: str): """Inject the configured autoload preset for the device. If the preset is already being injected, it won't autoload it again. Parameters ---------- group_key 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(self.global_config.iterate_autoload_presets()) logger.info("Autoloading for all devices") if len(autoload_presets) == 0: logger.error("No presets configured to autoload") return for group_key, _ in autoload_presets: self._autoload(group_key) def start_injecting(self, group_key: str, preset_name: str) -> bool: """Start injecting the preset for the device. Returns True on success. If an injection is already ongoing for the specified device it will stop it automatically first. Parameters ---------- group_key The unique key of the group preset_name The name of the preset """ logger.info('Request to start injecting for "%s"', group_key) self.refresh(group_key) if self.config_dir is None: logger.error( "Request to start an injectoin before a user told the service about " "their session using set_config_dir", ) return False group = groups.find(key=group_key) if group is None: logger.error('Could not find group "%s"', group_key) return False preset_path = PurePath( self.config_dir, "presets", PathUtils.sanitize_path_component(group.name), f"{preset_name}.json", ) # Path to a dump of the xkb mappings, to provide more human # readable keys in the correct keyboard layout to the service. # The service cannot use `xmodmap -pke` because it's running via # systemd. xmodmap_path = os.path.join(self.config_dir, "xmodmap.json") try: with open(xmodmap_path, "r") as file: # do this for each injection to make sure it is up to # date when the system layout changes. xmodmap = json.load(file) logger.debug('Using keycodes from "%s"', xmodmap_path) # this creates the keyboard_layout._xmodmap, which we need to do now # otherwise it might be created later which will override the changes # we do here. # Do we really need to lazyload in the keyboard_layout? # this kind of bug is stupid to track down keyboard_layout.get_name(0) keyboard_layout.update(xmodmap) # the service now has process wide knowledge of xmodmap # keys of the users session except FileNotFoundError: logger.error('Could not find "%s"', xmodmap_path) preset = Preset(preset_path) try: preset.load() except FileNotFoundError as error: logger.error(str(error)) return False for mapping in preset: # only create those uinputs that are required to avoid # confusing the system. Seems to be especially important with # gamepads, because some apps treat the first gamepad they found # as the only gamepad they'll ever care about. self.global_uinputs.prepare_single(mapping.target_uinput) if self.injectors.get(group_key) is not None: self.stop_injecting(group_key) try: injector = Injector( group, preset, self.mapping_parser, ) injector.start() self.injectors[group.key] = injector except OSError: # I think this will never happen, probably leftover from # some earlier version return False return True def stop_all(self): """Stop all injections.""" logger.info("Stopping all injections") for group_key in list(self.injectors.keys()): self.stop_injecting(group_key) def hello(self, out: str): """Used for tests.""" logger.info('Received "%s" from client', out) return out def quit(self): """Stop the process.""" # Beware, that stop_all will also be called via atexit.register(self.stop_all) logger.info("Got command to stop the daemon process") sys.exit(0) input-remapper-2.1.1/inputremapper/exceptions.py000066400000000000000000000040231475433465200221250ustar00rootroot00000000000000# -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings # Copyright (C) 2025 sezanzeb # # This file is part of input-remapper. # # input-remapper is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # input-remapper is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with input-remapper. If not, see . """Exceptions specific to inputremapper.""" class Error(Exception): """Base class for exceptions in inputremapper. We can catch all inputremapper exceptions with this. """ class UinputNotAvailable(Error): """If an expected UInput is not found (anymore).""" def __init__(self, name: str): super().__init__(f"{name} is not defined or unplugged") class EventNotHandled(Error): """For example mapping to BTN_LEFT on a keyboard target.""" def __init__(self, event): super().__init__(f"Event {event} can not be handled by the configured target") class MappingParsingError(Error): """Anything that goes wrong during the creation of handlers from the mapping.""" def __init__(self, msg: str, *, mapping=None, mapping_handler=None): self.mapping_handler = mapping_handler self.mapping = mapping super().__init__(msg) class InputEventCreationError(Error): """An input-event failed to be created due to broken factory/constructor calls.""" def __init__(self, msg: str): super().__init__(msg) class DataManagementError(Error): """Any error that happens in the DataManager.""" def __init__(self, msg: str): super().__init__(msg) input-remapper-2.1.1/inputremapper/groups.py000066400000000000000000000414151475433465200212710ustar00rootroot00000000000000# -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings # Copyright (C) 2025 sezanzeb # # This file is part of input-remapper. # # input-remapper is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # input-remapper is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with input-remapper. If not, see . """Find, classify and group devices. Because usually connected devices pop up multiple times in /dev/input, in order to provide multiple types of input devices (e.g. a keyboard and a graphics-tablet at the same time) Those groups are what is being displayed in the device dropdown, and events are being read from all of the paths of an individual group in the gui and the injector. """ from __future__ import annotations import asyncio import enum import json import multiprocessing import os import re import threading from typing import List, Optional import evdev from evdev.ecodes import ( EV_KEY, EV_ABS, KEY_CAMERA, EV_REL, BTN_STYLUS, ABS_MT_POSITION_X, REL_X, KEY_A, BTN_LEFT, REL_Y, REL_WHEEL, ) from inputremapper.configs.paths import PathUtils from inputremapper.logging.logger import logger from inputremapper.utils import get_device_hash TABLET_KEYS = [ evdev.ecodes.BTN_STYLUS, evdev.ecodes.BTN_TOOL_BRUSH, evdev.ecodes.BTN_TOOL_PEN, evdev.ecodes.BTN_TOOL_RUBBER, ] class DeviceType(str, enum.Enum): GAMEPAD = "gamepad" KEYBOARD = "keyboard" MOUSE = "mouse" TOUCHPAD = "touchpad" GRAPHICS_TABLET = "graphics-tablet" CAMERA = "camera" UNKNOWN = "unknown" if not hasattr(evdev.InputDevice, "path"): # for evdev < 1.0.0 patch the path property @property def path(device): return device.fn evdev.InputDevice.path = path def _is_gamepad(capabilities): """Check if joystick movements are available for preset.""" # A few buttons that indicate a gamepad buttons = { evdev.ecodes.BTN_BASE, evdev.ecodes.BTN_A, evdev.ecodes.BTN_THUMB, evdev.ecodes.BTN_TOP, evdev.ecodes.BTN_DPAD_DOWN, evdev.ecodes.BTN_GAMEPAD, } if not buttons.intersection(capabilities.get(EV_KEY, [])): # no button is in the key capabilities return False # joysticks abs_capabilities = capabilities.get(EV_ABS, []) if evdev.ecodes.ABS_X not in abs_capabilities: return False if evdev.ecodes.ABS_Y not in abs_capabilities: return False return True def _is_mouse(capabilities): """Check if the capabilities represent those of a mouse.""" # Based on observation, those capabilities need to be present to get an # UInput recognized as mouse # mouse movements if 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) -> DeviceType: """Figure out what kind of device this is. Use this instead of functions like _is_keyboard to avoid getting false positives. """ capabilities = device.capabilities(absinfo=False) if _is_graphics_tablet(capabilities): # check this before is_gamepad to avoid classifying abs_x # as joysticks when they are actually stylus positions return DeviceType.GRAPHICS_TABLET if _is_touchpad(capabilities): return DeviceType.TOUCHPAD if _is_gamepad(capabilities): return DeviceType.GAMEPAD if _is_mouse(capabilities): return DeviceType.MOUSE if _is_camera(capabilities): return DeviceType.CAMERA if _is_keyboard(capabilities): # very low in the chain to avoid classifying most devices # as keyboard, because there are many with ev_key capabilities return DeviceType.KEYBOARD return DeviceType.UNKNOWN DENYLIST = [".*Yubico.*YubiKey.*", "Eee PC WMI hotkeys"] def is_denylisted(device: evdev.InputDevice): """Check if a device should not be used in input-remapper. Parameters ---------- device """ for name in DENYLIST: if re.match(name, str(device.name), re.IGNORECASE): return True return False def get_unique_key(device: evdev.InputDevice): """Find a string key that is unique for a single hardware device. All InputDevices in /dev/input that originate from the same physical hardware device should return the same key via this function. """ # Keys that should not be used: # - device.phys is empty sometimes and varies across virtual # subdevices # - device.version varies across subdevices return ( # device.info bustype, vendor and product are unique for # a product, but multiple similar device models would be grouped # in the same group f"{device.info.bustype}_" f"{device.info.vendor}_" f"{device.info.product}_" # device.uniq is empty most of the time. It seems to be the only way to # distinguish multiple connected bluetooth gamepads f"{device.uniq}_" # deivce.phys if "/input..." is removed from it, because the first # chunk seems to be unique per hardware (if it's not completely empty) f'{device.phys.split("/")[0] or "-"}' ) class _Group: """Groups multiple devnodes together. For example, name could be "Logitech USB Keyboard", devices might contain "Logitech USB Keyboard System Control" and "Logitech USB Keyboard". paths is a list of files in /dev/input that belong to the devices. They are grouped by usb port. Members ------- name : str A human readable name, generated from .names, that should always look the same for a device model. It is used to generate the presets folder structure """ def __init__( self, paths: List[os.PathLike], names: List[str], types: List[DeviceType | str], key: str, ): """Specify a group Parameters ---------- paths Paths in /dev/input of the grouped devices names Names of the grouped devices types Types of the grouped devices key Unique identifier of the group. It should be human readable and if possible equal to group.name. To avoid multiple groups having the same key, a number starting with 2 followed by a whitespace should be added to it: "key", "key 2", "key 3", ... This is important for the autoloading configuration. If the key changed over reboots, then autoloading would break. """ # There might be multiple groups with the same name here when two # similar devices are connected to the computer. self.name: str = sorted(names, key=len)[0] self.key = key self.paths = paths self.names = names self.types = [DeviceType(type_) for type_ in types] def get_preset_path(self, preset: Optional[str] = None): """Get a path to the stored preset, or to store a preset to. This path is unique per device-model, not per group. Groups of the same model share the same preset paths. """ return PathUtils.get_preset_path(self.name, preset) def get_devices(self) -> List[evdev.InputDevice]: devices: List[evdev.InputDevice] = [] for path in self.paths: try: devices.append(evdev.InputDevice(path)) except (FileNotFoundError, OSError): logger.error('Could not find "%s"', path) continue return devices def dumps(self): """Return a string representing this object.""" return json.dumps( dict(paths=self.paths, names=self.names, types=self.types, key=self.key), ) @classmethod def loads(cls, serialized: str): """Load a serialized representation.""" group = cls(**json.loads(serialized)) return group def __repr__(self): return f"" class _FindGroups(threading.Thread): """Thread to get the devices that can be worked with. Since InputDevice destructors take quite some time, do this asynchronously so that they can take as much time as they want without slowing down the initialization. """ def __init__(self, pipe: multiprocessing.Pipe): """Construct the process. Parameters ---------- pipe used to communicate the result """ self.pipe = pipe super().__init__() def run(self): """Do what get_groups describes.""" # evdev needs asyncio to work loop = asyncio.new_event_loop() asyncio.set_event_loop(loop) logger.debug("Discovering device paths") # group them together by usb device because there could be stuff like # "Logitech USB Keyboard" and "Logitech USB Keyboard Consumer Control" grouped = {} 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 path "%s": %s %s', path, error.__class__.__name__, str(error), ) continue if device.name == "Power Button": continue device_type = classify(device) if device_type == DeviceType.CAMERA: continue # https://www.kernel.org/doc/html/latest/input/event-codes.html capabilities = device.capabilities(absinfo=False) key_capa = capabilities.get(EV_KEY) abs_capa = capabilities.get(EV_ABS) rel_capa = capabilities.get(EV_REL) if key_capa is None and abs_capa is None and rel_capa is None: # skip devices that don't provide buttons or axes that can be mapped logger.debug('"%s" has no useful capabilities', device.name) continue if is_denylisted(device): logger.debug('"%s" is denylisted', device.name) continue key = get_unique_key(device) if grouped.get(key) is None: grouped[key] = [] logger.debug( 'Found %s "%s" at "%s", hash "%s", key "%s"', device_type.value, device.name, path, get_device_hash(device), key, ) grouped[key].append((device.name, path, device_type)) # now write down all the paths of that group result = [] used_keys = set() for group in grouped.values(): names = [entry[0] for entry in group] devs = [entry[1] for entry in group] # generate a human readable key shortest_name = sorted(names, key=len)[0] key = shortest_name i = 2 while key in used_keys: key = f"{shortest_name} {i}" i += 1 used_keys.add(key) group = _Group( key=key, paths=devs, names=names, types=sorted( list({item[2] for item in group if item[2] != DeviceType.UNKNOWN}) ), ) result.append(group.dumps()) self.pipe.send(json.dumps(result)) loop.close() # avoid resource allocation warnings # now that everything is sent via the pipe, the InputDevice # destructors can go on and take ages to complete in the thread # without blocking anything class _Groups: """Contains and manages all groups.""" def __init__(self): self._groups: List[_Group] = None def __getattribute__(self, key: str): """To lazy load group info only when needed. For example, this helps to keep logs of input-remapper-control clear when it doesn't 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.error("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: bool = False) -> List[_Group]: """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: List[_Group]): """Overwrite all groups.""" logger.debug("Overwriting groups with %s", new_groups) self._groups = new_groups def list_group_names(self) -> List[str]: """Return a list of all 'name' properties of the groups.""" return [ group.name for group in self._groups if not group.name.startswith("input-remapper") ] def __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: str): """Load a serialized representation created via dumps.""" self._groups = [_Group.loads(group) for group in json.loads(dump)] def find( self, name: Optional[str] = None, key: Optional[str] = None, path: Optional[str] = None, include_inputremapper: bool = False, ) -> Optional[_Group]: """Find a group that matches the provided parameters. Parameters ---------- name "USB Keyboard" Not unique, will return the first group that matches. key "USB Keyboard", "USB Keyboard 2", ... path "/dev/input/event3" """ for group in self._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 # TODO global objects are bad practice groups = _Groups() input-remapper-2.1.1/inputremapper/gui/000077500000000000000000000000001475433465200201575ustar00rootroot00000000000000input-remapper-2.1.1/inputremapper/gui/__init__.py000066400000000000000000000002241475433465200222660ustar00rootroot00000000000000import gi gi.require_version("Gdk", "3.0") gi.require_version("Gtk", "3.0") gi.require_version("GLib", "2.0") gi.require_version("GtkSource", "4") input-remapper-2.1.1/inputremapper/gui/autocompletion.py000066400000000000000000000376011475433465200236020ustar00rootroot00000000000000# -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings # Copyright (C) 2025 sezanzeb # # This file is part of input-remapper. # # input-remapper is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # input-remapper is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with input-remapper. If not, see . """Autocompletion for the editor.""" import re from typing import Dict, Optional, List, Tuple from evdev.ecodes import EV_KEY from gi.repository import Gdk, Gtk, GLib, GObject from inputremapper.configs.keyboard_layout import keyboard_layout, DISABLE_NAME from inputremapper.configs.mapping import MappingData from inputremapper.gui.components.editor import CodeEditor from inputremapper.gui.controller import Controller from inputremapper.gui.messages.message_broker import MessageBroker, MessageType from inputremapper.gui.messages.message_data import UInputsData from inputremapper.gui.utils import debounce from inputremapper.injection.macros.parse import Parser from inputremapper.logging.logger import logger # no deprecated shorthand function-names FUNCTION_NAMES = [name for name in Parser.TASK_CLASSES.keys() if len(name) > 1] # no deprecated functions FUNCTION_NAMES.remove("ifeq") Capabilities = Dict[int, List] def _get_left_text(iter_: Gtk.TextIter) -> str: buffer = iter_.get_buffer() result = buffer.get_text(buffer.get_start_iter(), iter_, True) result = Parser.remove_comments(result) result = result.replace("\n", " ") return result.lower() # regex to search for the beginning of a... PARAMETER = r".*?[(,=+]\s*" FUNCTION_CHAIN = r".*?\)\s*\.\s*" def get_incomplete_function_name(iter_: Gtk.TextIter) -> str: """Get the word that is written left to the TextIter.""" left_text = _get_left_text(iter_) # match foo in: # bar().foo # bar()\n.foo # bar().\nfoo # bar(\nfoo # bar(\nqux=foo # bar(KEY_A,\nfoo # foo match = re.match(rf"(?:{FUNCTION_CHAIN}|{PARAMETER}|^)(\w+)$", left_text) logger.debug('get_incomplete_function_name text: "%s" match: %s', left_text, match) if match is None: return "" return match[1] def get_incomplete_parameter(iter_: Gtk.TextIter) -> Optional[str]: """Get the parameter that is written left to the TextIter.""" left_text = _get_left_text(iter_) # match foo in: # bar(foo # bar(a=foo # bar(qux, foo # foo # bar + foo match = re.match(rf"(?:{PARAMETER}|^)(\w+)$", left_text) logger.debug('get_incomplete_parameter text: "%s" match: %s', left_text, match) if match is None: return None return match[1] def propose_symbols(text_iter: Gtk.TextIter, codes: List[int]) -> List[Tuple[str, str]]: """Find key names that match the input at the cursor and are mapped to the codes.""" incomplete_name = get_incomplete_parameter(text_iter) if incomplete_name is None or len(incomplete_name) <= 1: return [] incomplete_name = incomplete_name.lower() names = list(keyboard_layout.list_names(codes=codes)) + [DISABLE_NAME] return [ (name, name) for name in names if incomplete_name in name.lower() and incomplete_name != name.lower() ] def propose_function_names(text_iter: Gtk.TextIter) -> List[Tuple[str, str]]: """Find function names that match the input at the cursor.""" incomplete_name = get_incomplete_function_name(text_iter) if incomplete_name is None or len(incomplete_name) <= 1: return [] incomplete_name = incomplete_name.lower() # A list of # - ("key", "key(symbol)") # - ("repeat", "repeat(repeats, macro)") # etc. function_names: List[Tuple[str, str]] = [] for name in FUNCTION_NAMES: if incomplete_name in name.lower() and incomplete_name != name.lower(): task_class = Parser.TASK_CLASSES[name] argument_names = task_class.get_macro_argument_names() function_names.append((name, f"{name}({', '.join(argument_names)})")) return function_names class SuggestionLabel(Gtk.Label): """A label with some extra internal information.""" __gtype_name__ = "SuggestionLabel" def __init__(self, display_name: str, suggestion: str): super().__init__(label=display_name) self.suggestion = suggestion class Autocompletion(Gtk.Popover): """Provide keyboard-controllable beautiful autocompletions. The one provided via source_view.get_completion() is not very appealing """ __gtype_name__ = "Autocompletion" _target_uinput: Optional[str] = None def __init__( self, message_broker: MessageBroker, controller: Controller, code_editor: CodeEditor, ): """Create an autocompletion popover. It will remain hidden until there is something to autocomplete. Parameters ---------- code_editor The widget that contains the text that should be autocompleted """ super().__init__( # Don't switch the focus to the popover when it shows modal=False, # Always show the popover below the cursor, don't move it to a different # position based on the location within the window constrain_to=Gtk.PopoverConstraint.NONE, ) self.code_editor = code_editor self.controller = controller self.message_broker = message_broker self._uinputs: Optional[Dict[str, Capabilities]] = None self._target_key_capabilities: List[int] = [] self.scrolled_window = Gtk.ScrolledWindow( min_content_width=200, max_content_height=200, propagate_natural_width=True, propagate_natural_height=True, ) self.list_box = Gtk.ListBox() self.list_box.get_style_context().add_class("transparent") self.scrolled_window.add(self.list_box) # row-activated is on-click, # row-selected is when scrolling through it self.list_box.connect( "row-activated", self._on_suggestion_clicked, ) self.add(self.scrolled_window) self.get_style_context().add_class("autocompletion") self.set_position(Gtk.PositionType.BOTTOM) self.code_editor.gui.connect("key-press-event", self.navigate) # add some delay, so that pressing the button in the completion works before # the popover is hidden due to focus-out-event self.code_editor.gui.connect("focus-out-event", self.on_gtk_text_input_unfocus) self.code_editor.gui.get_buffer().connect("changed", self.update) self.set_position(Gtk.PositionType.BOTTOM) self.visible = False self.attach_to_events() self.show_all() self.popdown() # hidden by default. this needs to happen after show_all! def attach_to_events(self): self.message_broker.subscribe(MessageType.mapping, self._on_mapping_changed) self.message_broker.subscribe(MessageType.uinputs, self._on_uinputs_changed) def on_gtk_text_input_unfocus(self, *_): """The code editor was unfocused.""" GLib.timeout_add(100, self.popdown) # "(input-remapper-gtk:97611): Gtk-WARNING **: 16:33:56.464: GtkTextView - # did not receive focus-out-event. If you connect a handler to this signal, # it must return FALSE so the text view gets the event as well" return False def navigate(self, _, event: Gdk.EventKey): """Using the keyboard to select an autocompletion suggestion.""" if not self.visible: return if event.keyval == Gdk.KEY_Escape: self.popdown() return selected_row = self.list_box.get_selected_row() if event.keyval not in [Gdk.KEY_Down, Gdk.KEY_Up, Gdk.KEY_Return]: # not one of the keys that controls the autocompletion. Deselect # the row but keep it open self.list_box.select_row(None) return if event.keyval == Gdk.KEY_Return: if selected_row is None: # nothing selected, forward the event to the text editor return # a row is selected and should be used for autocompletion self.list_box.emit("row-activated", selected_row) return Gdk.EVENT_STOP num_rows = len(self.list_box.get_children()) if selected_row is None: # select the first row if event.keyval == Gdk.KEY_Down: new_selected_row = self.list_box.get_row_at_index(0) if event.keyval == Gdk.KEY_Up: new_selected_row = self.list_box.get_row_at_index(num_rows - 1) else: # select the next row selected_index = selected_row.get_index() new_index = selected_index if event.keyval == Gdk.KEY_Down: new_index += 1 if event.keyval == Gdk.KEY_Up: new_index -= 1 if new_index < 0: new_index = num_rows - 1 if new_index > num_rows - 1: new_index = 0 new_selected_row = self.list_box.get_row_at_index(new_index) self.list_box.select_row(new_selected_row) self._scroll_to_row(new_selected_row) # don't change editor contents return Gdk.EVENT_STOP def _scroll_to_row(self, row: Gtk.ListBoxRow): """Scroll up or down so that the row is visible.""" # unfortunately, it seems that without focusing the row it won't happen # automatically (or whatever the reason for this is, just a wild guess) # (the focus should not leave the code editor, so that continuing # to write code is possible), so here is a custom solution. row_height = row.get_allocation().height list_box_height = self.list_box.get_allocated_height() if row: # get coordinate relative to the list_box, # measured from the top of the selected row to the top of the list_box row_y_position = row.translate_coordinates(self.list_box, 0, 0)[1] # Depending on the theme, the y_offset will be > 0, even though it # is the uppermost element, due to margins/paddings. if row_y_position < row_height: row_y_position = 0 # if the selected row sits lower than the second to last row, # then scroll all the way down. otherwise it will only scroll down # to the bottom edge of the selected-row, which might not actually be the # bottom of the list-box due to paddings. if row_y_position > list_box_height - row_height * 1.5: # using a value that is too high doesn't hurt here. row_y_position = list_box_height # the visible height of the scrolled_window. not the content. height = self.scrolled_window.get_max_content_height() current_y_scroll = self.scrolled_window.get_vadjustment().get_value() vadjustment = self.scrolled_window.get_vadjustment() # for the selected row to still be visible, its y_offset has to be # at height - row_height. If the y_offset is higher than that, then # the autocompletion needs to scroll down to make it visible again. if row_y_position > current_y_scroll + (height - row_height): value = row_y_position - (height - row_height) vadjustment.set_value(value) if row_y_position < current_y_scroll: # the selected element is not visiable, so we need to scroll up. vadjustment.set_value(row_y_position) def _get_text_iter_at_cursor(self): """Get Gtk.TextIter at the current text cursor location.""" cursor = self.code_editor.gui.get_cursor_locations()[0] return self.code_editor.gui.get_iter_at_location(cursor.x, cursor.y)[1] def popup(self): self.visible = True super().popup() def popdown(self): self.visible = False super().popdown() @debounce(100) def update(self, *_): """Find new autocompletion suggestions and display them. Hide if none.""" if len(self._target_key_capabilities) == 0: logger.error("No target capabilities available") return if not self.code_editor.gui.is_focus(): self.popdown() return self.list_box.forall(self.list_box.remove) # move the autocompletion to the text cursor cursor = self.code_editor.gui.get_cursor_locations()[0] # convert it to window coords, because the cursor values will be very large # when the TextView is in a scrolled down ScrolledWindow. window_coords = self.code_editor.gui.buffer_to_window_coords( Gtk.TextWindowType.TEXT, cursor.x, cursor.y ) cursor.x = window_coords.window_x cursor.y = window_coords.window_y cursor.y += 12 if self.code_editor.gui.get_show_line_numbers(): cursor.x += 48 self.set_pointing_to(cursor) text_iter = self._get_text_iter_at_cursor() # get a list of (evdev/xmodmap symbol-name, display-name) suggested_names = propose_function_names(text_iter) suggested_names += propose_symbols(text_iter, self._target_key_capabilities) if len(suggested_names) == 0: self.popdown() return self.popup() # ffs was this hard to find # add visible autocompletion entries for suggestion, display_name in suggested_names: label = SuggestionLabel(display_name, suggestion) self.list_box.insert(label, -1) label.show_all() def _update_capabilities(self): if self._target_uinput and self._uinputs: self._target_key_capabilities = self._uinputs[self._target_uinput][EV_KEY] def _on_mapping_changed(self, mapping: MappingData): self._target_uinput = mapping.target_uinput self._update_capabilities() def _on_uinputs_changed(self, data: UInputsData): self._uinputs = data.uinputs self._update_capabilities() def _on_suggestion_clicked(self, _, selected_row): """An autocompletion suggestion was selected and should be inserted.""" selected_label = selected_row.get_children()[0] suggestion = selected_label.suggestion buffer = self.code_editor.gui.get_buffer() # make sure to replace the complete unfinished word. Look to the right and # remove whatever there is cursor_iter = self._get_text_iter_at_cursor() right = buffer.get_text(cursor_iter, buffer.get_end_iter(), True) match = re.match(r"^(\w+)", right) right = match[1] if match else "" Gtk.TextView.do_delete_from_cursor( self.code_editor.gui, Gtk.DeleteType.CHARS, len(right) ) # do the same to the left cursor_iter = self._get_text_iter_at_cursor() left = buffer.get_text(buffer.get_start_iter(), cursor_iter, True) match = re.match(r".*?(\w+)$", re.sub("\n", " ", left)) left = match[1] if match else "" Gtk.TextView.do_delete_from_cursor( self.code_editor.gui, Gtk.DeleteType.CHARS, -len(left) ) # insert the autocompletion Gtk.TextView.do_insert_at_cursor(self.code_editor.gui, suggestion) self.emit("suggestion-inserted") GObject.signal_new( "suggestion-inserted", Autocompletion, GObject.SignalFlags.RUN_FIRST, None, [], ) input-remapper-2.1.1/inputremapper/gui/components/000077500000000000000000000000001475433465200223445ustar00rootroot00000000000000input-remapper-2.1.1/inputremapper/gui/components/__init__.py000066400000000000000000000000001475433465200244430ustar00rootroot00000000000000input-remapper-2.1.1/inputremapper/gui/components/common.py000066400000000000000000000120331475433465200242050ustar00rootroot00000000000000# -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings # Copyright (C) 2025 sezanzeb # # This file is part of input-remapper. # # input-remapper is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # input-remapper is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with input-remapper. If not, see . """Components used in multiple places.""" from __future__ import annotations import gi from gi.repository import Gtk from typing import Optional from inputremapper.configs.mapping import MappingData from inputremapper.gui.controller import Controller from inputremapper.gui.messages.message_broker import ( MessageBroker, MessageType, ) from inputremapper.gui.messages.message_data import GroupData, PresetData from inputremapper.gui.utils import HandlerDisabled class FlowBoxEntry(Gtk.ToggleButton): """A device that can be selected in the GUI. For example a keyboard or a mouse. """ __gtype_name__ = "FlowBoxEntry" def __init__( self, message_broker: MessageBroker, controller: Controller, name: str, icon_name: Optional[str] = None, ): super().__init__() self.icon_name = icon_name self.message_broker = message_broker self._controller = controller box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) if icon_name: icon = Gtk.Image.new_from_icon_name(icon_name, Gtk.IconSize.DIALOG) box.add(icon) label = Gtk.Label() label.set_label(name) self.name = name # wrap very long names properly label.set_line_wrap(True) label.set_line_wrap_mode(2) # this affeects how many device entries fit next to each other label.set_width_chars(28) label.set_max_width_chars(28) box.add(label) box.set_margin_top(18) box.set_margin_bottom(18) box.set_homogeneous(True) box.set_spacing(12) # self.set_relief(Gtk.ReliefStyle.NONE) self.add(box) self.show_all() self.connect("toggled", self._on_gtk_toggle) def _on_gtk_toggle(self): raise NotImplementedError def show_active(self, active): """Show the active state without triggering anything.""" with HandlerDisabled(self, self._on_gtk_toggle): self.set_active(active) class FlowBoxWrapper: """A wrapper for a flowbox that contains FlowBoxEntry widgets.""" def __init__(self, flowbox: Gtk.FlowBox): self._gui = flowbox def show_active_entry(self, name: Optional[str]): """Activate the togglebutton that matches the name.""" for child in self._gui.get_children(): flow_box_entry: FlowBoxEntry = child.get_children()[0] flow_box_entry.show_active(flow_box_entry.name == name) class Breadcrumbs: """Writes a breadcrumbs string into a given label.""" def __init__( self, message_broker: MessageBroker, label: Gtk.Label, show_device_group: bool = False, show_preset: bool = False, show_mapping: bool = False, ): self._message_broker = message_broker self._gui = label self._connect_message_listener() self.show_device_group = show_device_group self.show_preset = show_preset self.show_mapping = show_mapping self._group_key: str = "" self._preset_name: str = "" self._mapping_name: str = "" label.set_max_width_chars(50) label.set_line_wrap(True) label.set_line_wrap_mode(2) self._render() def _connect_message_listener(self): self._message_broker.subscribe(MessageType.group, self._on_group_changed) self._message_broker.subscribe(MessageType.preset, self._on_preset_changed) self._message_broker.subscribe(MessageType.mapping, self._on_mapping_changed) def _on_preset_changed(self, data: PresetData): self._preset_name = data.name or "" self._render() def _on_group_changed(self, data: GroupData): self._group_key = data.group_key self._render() def _on_mapping_changed(self, mapping_data: MappingData): self._mapping_name = mapping_data.format_name() self._render() def _render(self): label = [] if self.show_device_group: label.append(self._group_key or "?") if self.show_preset: label.append(self._preset_name or "?") if self.show_mapping: label.append(self._mapping_name or "?") self._gui.set_label(" / ".join(label)) input-remapper-2.1.1/inputremapper/gui/components/device_groups.py000066400000000000000000000072101475433465200255540ustar00rootroot00000000000000# -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings # Copyright (C) 2025 sezanzeb # # This file is part of input-remapper. # # input-remapper is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # input-remapper is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with input-remapper. If not, see . from __future__ import annotations from typing import Optional from gi.repository import Gtk from inputremapper.gui.components.common import FlowBoxEntry, FlowBoxWrapper from inputremapper.gui.components.editor import ICON_PRIORITIES, ICON_NAMES from inputremapper.gui.components.main import Stack from inputremapper.gui.controller import Controller from inputremapper.gui.messages.message_broker import ( MessageBroker, MessageType, ) from inputremapper.gui.messages.message_data import ( GroupsData, GroupData, DoStackSwitch, ) from inputremapper.logging.logger import logger class DeviceGroupEntry(FlowBoxEntry): """A device that can be selected in the GUI. For example a keyboard or a mouse. """ __gtype_name__ = "DeviceGroupEntry" def __init__( self, message_broker: MessageBroker, controller: Controller, icon_name: Optional[str], group_key: str, ): super().__init__( message_broker=message_broker, controller=controller, icon_name=icon_name, name=group_key, ) self.group_key = group_key def _on_gtk_toggle(self, *_, **__): logger.debug('Selecting device "%s"', self.group_key) self._controller.load_group(self.group_key) self.message_broker.publish(DoStackSwitch(Stack.presets_page)) class DeviceGroupSelection(FlowBoxWrapper): """A wrapper for the container with our groups. A group is a collection of devices. """ def __init__( self, message_broker: MessageBroker, controller: Controller, flowbox: Gtk.FlowBox, ): super().__init__(flowbox) self._message_broker = message_broker self._controller = controller self._gui = flowbox self._message_broker.subscribe(MessageType.groups, self._on_groups_changed) self._message_broker.subscribe(MessageType.group, self._on_group_changed) def _on_groups_changed(self, data: GroupsData): self._gui.foreach(self._gui.remove) for group_key, types in data.groups.items(): if len(types) > 0: device_type = sorted(types, key=ICON_PRIORITIES.index)[0] icon_name = ICON_NAMES[device_type] else: icon_name = None logger.debug("adding %s to device selection", group_key) device_group_entry = DeviceGroupEntry( self._message_broker, self._controller, icon_name, group_key, ) self._gui.insert(device_group_entry, -1) if self._controller.data_manager.active_group: self.show_active_entry(self._controller.data_manager.active_group.key) def _on_group_changed(self, data: GroupData): self.show_active_entry(data.group_key) input-remapper-2.1.1/inputremapper/gui/components/editor.py000066400000000000000000001210471475433465200242110ustar00rootroot00000000000000# -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings # Copyright (C) 2025 sezanzeb # # This file is part of input-remapper. # # input-remapper is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # input-remapper is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with input-remapper. If not, see . """All components that control a single preset.""" from __future__ import annotations from collections import defaultdict from typing import List, Optional, Dict, Union, Callable, Literal, Set import cairo from evdev.ecodes import ( EV_KEY, EV_ABS, EV_REL, BTN_LEFT, BTN_MIDDLE, BTN_RIGHT, BTN_EXTRA, BTN_SIDE, ) from gi.repository import Gtk, GtkSource, Gdk from inputremapper.configs.input_config import InputCombination, InputConfig from inputremapper.configs.keyboard_layout import keyboard_layout, XKB_KEYCODE_OFFSET from inputremapper.configs.mapping import MappingData, MappingType from inputremapper.groups import DeviceType from inputremapper.gui.components.output_type_names import OutputTypeNames from inputremapper.gui.controller import Controller from inputremapper.gui.gettext import _ from inputremapper.gui.messages.message_broker import ( MessageBroker, MessageType, ) from inputremapper.gui.messages.message_data import ( UInputsData, PresetData, CombinationUpdate, ) from inputremapper.gui.utils import HandlerDisabled, Colors from inputremapper.injection.mapping_handlers.axis_transform import Transformation from inputremapper.input_event import InputEvent from inputremapper.utils import get_evdev_constant_name Capabilities = Dict[int, List] SET_KEY_FIRST = _("Record the input first") ICON_NAMES = { DeviceType.GAMEPAD: "input-gaming", DeviceType.MOUSE: "input-mouse", DeviceType.KEYBOARD: "input-keyboard", DeviceType.GRAPHICS_TABLET: "input-tablet", DeviceType.TOUCHPAD: "input-touchpad", DeviceType.UNKNOWN: None, } # sort types that most devices would fall in easily to the right. ICON_PRIORITIES = [ DeviceType.GRAPHICS_TABLET, DeviceType.TOUCHPAD, DeviceType.GAMEPAD, DeviceType.MOUSE, DeviceType.KEYBOARD, DeviceType.UNKNOWN, ] class TargetSelection: """The dropdown menu to select the targe_uinput of the active_mapping, For example "keyboard" or "gamepad". """ _mapping: Optional[MappingData] = None def __init__( self, message_broker: MessageBroker, controller: Controller, combobox: Gtk.ComboBox, ): self._message_broker = message_broker self._controller = controller self._gui = combobox self._message_broker.subscribe(MessageType.uinputs, self._on_uinputs_changed) self._message_broker.subscribe(MessageType.mapping, self._on_mapping_loaded) self._gui.connect("changed", self._on_gtk_target_selected) def _select_current_target(self): """Select the currently configured target.""" if self._mapping is not None: with HandlerDisabled(self._gui, self._on_gtk_target_selected): self._gui.set_active_id(self._mapping.target_uinput) def _on_uinputs_changed(self, data: UInputsData): target_store = Gtk.ListStore(str) for uinput in data.uinputs.keys(): target_store.append([uinput]) self._gui.set_model(target_store) renderer_text = Gtk.CellRendererText() self._gui.pack_start(renderer_text, False) self._gui.add_attribute(renderer_text, "text", 0) self._gui.set_id_column(0) self._select_current_target() def _on_mapping_loaded(self, mapping: MappingData): self._mapping = mapping self._select_current_target() def _on_gtk_target_selected(self, *_): target = self._gui.get_active_id() self._controller.update_mapping(target_uinput=target) class MappingListBox: """The listbox showing all available mapping in the active_preset.""" def __init__( self, message_broker: MessageBroker, controller: Controller, listbox: Gtk.ListBox, ): self._message_broker = message_broker self._controller = controller self._gui = listbox self._gui.set_sort_func(self._sort_func) self._message_broker.subscribe(MessageType.preset, self._on_preset_changed) self._message_broker.subscribe(MessageType.mapping, self._on_mapping_changed) self._gui.connect("row-selected", self._on_gtk_mapping_selected) @staticmethod def _sort_func(row1: MappingSelectionLabel, row2: MappingSelectionLabel) -> int: """Sort alphanumerical by name.""" if row1.combination == InputCombination.empty_combination(): return 1 if row2.combination == InputCombination.empty_combination(): return 0 return 0 if row1.name < row2.name else 1 def _on_preset_changed(self, data: PresetData): selection_labels = self._gui.get_children() for selection_label in selection_labels: selection_label.cleanup() self._gui.remove(selection_label) if not data.mappings: return for mapping in data.mappings: selection_label = MappingSelectionLabel( self._message_broker, self._controller, mapping.format_name(), mapping.input_combination, ) self._gui.insert(selection_label, -1) self._gui.invalidate_sort() def _on_mapping_changed(self, mapping: MappingData): with HandlerDisabled(self._gui, self._on_gtk_mapping_selected): combination = mapping.input_combination for row in self._gui.get_children(): if row.combination == combination: self._gui.select_row(row) def _on_gtk_mapping_selected(self, _, row: Optional[MappingSelectionLabel]): if not row: return self._controller.load_mapping(row.combination) class MappingSelectionLabel(Gtk.ListBoxRow): """The ListBoxRow representing a mapping inside the MappingListBox.""" __gtype_name__ = "MappingSelectionLabel" def __init__( self, message_broker: MessageBroker, controller: Controller, name: Optional[str], combination: InputCombination, ): super().__init__() self._message_broker = message_broker self._controller = controller if not name: name = combination.beautify() self.name = name self.combination = combination # Make the child label widget break lines, important for # long combinations self.label = Gtk.Label() self.label.set_line_wrap(True) self.label.set_line_wrap_mode(Gtk.WrapMode.WORD) self.label.set_justify(Gtk.Justification.CENTER) # set the name or combination.beautify as label self.label.set_label(self.name) self.label.set_margin_top(11) self.label.set_margin_bottom(11) # button to edit the name of the mapping self.edit_btn = Gtk.Button() self.edit_btn.set_relief(Gtk.ReliefStyle.NONE) self.edit_btn.set_image( Gtk.Image.new_from_icon_name(Gtk.STOCK_EDIT, Gtk.IconSize.MENU) ) self.edit_btn.set_tooltip_text(_("Change Mapping Name")) self.edit_btn.set_margin_top(4) self.edit_btn.set_margin_bottom(4) self.edit_btn.connect("clicked", self._set_edit_mode) self.name_input = Gtk.Entry() self.name_input.set_text(self.name) self.name_input.set_halign(Gtk.Align.FILL) self.name_input.set_margin_top(4) self.name_input.set_margin_bottom(4) self.name_input.connect("activate", self._on_gtk_rename_finished) self.name_input.connect("key-press-event", self._on_gtk_rename_abort) self._box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL) self._box.set_center_widget(self.label) self._box.add(self.edit_btn) self._box.set_child_packing(self.edit_btn, False, False, 4, Gtk.PackType.END) self._box.add(self.name_input) self._box.set_child_packing(self.name_input, True, True, 4, Gtk.PackType.START) self.add(self._box) self.show_all() self._message_broker.subscribe(MessageType.mapping, self._on_mapping_changed) self._message_broker.subscribe( MessageType.combination_update, self._on_combination_update ) self.edit_btn.hide() self.name_input.hide() def __repr__(self): return f"" def _set_not_selected(self): self.edit_btn.hide() self.name_input.hide() self.label.show() def _set_selected(self): self.label.set_label(self.name) self.edit_btn.show() self.name_input.hide() self.label.show() def _set_edit_mode(self, *_): self.name_input.set_text(self.name) self.label.hide() self.name_input.show() self._controller.set_focus(self.name_input) def _on_mapping_changed(self, mapping: MappingData): if mapping.input_combination != self.combination: self._set_not_selected() return self.name = mapping.format_name() self._set_selected() self.get_parent().invalidate_sort() def _on_combination_update(self, data: CombinationUpdate): if data.old_combination == self.combination and self.is_selected(): self.combination = data.new_combination def _on_gtk_rename_finished(self, *_): name = self.name_input.get_text() if name.lower().strip() == self.combination.beautify().lower(): name = "" self.name = name self._set_selected() self._controller.update_mapping(name=name) def _on_gtk_rename_abort(self, _, key_event: Gdk.EventKey): if key_event.keyval == Gdk.KEY_Escape: self._set_selected() def cleanup(self) -> None: """Clean up message listeners. Execute before removing from gui!""" self._message_broker.unsubscribe(self._on_mapping_changed) self._message_broker.unsubscribe(self._on_combination_update) class GdkEventRecorder: """Records events delivered by GDK, similar to the ReaderService/ReaderClient.""" _combination: List[int] _pressed: Set[int] __gtype_name__ = "GdkEventRecorder" def __init__(self, window: Gtk.Window, gui: Gtk.Label): super().__init__() self._combination = [] self._pressed = set() self._gui = gui window.connect("event", self._on_gtk_event) def _get_button_code(self, event: Gdk.Event): """Get the evdev code for the given event.""" return { Gdk.BUTTON_MIDDLE: BTN_MIDDLE, Gdk.BUTTON_PRIMARY: BTN_LEFT, Gdk.BUTTON_SECONDARY: BTN_RIGHT, 9: BTN_EXTRA, 8: BTN_SIDE, }.get(event.get_button().button) def _reset(self, event: Gdk.Event): """If a new combination is being typed, start from scratch.""" gdk_event_type: int = event.type is_press = gdk_event_type in [ Gdk.EventType.KEY_PRESS, Gdk.EventType.BUTTON_PRESS, ] if len(self._pressed) == 0 and is_press: self._combination = [] def _press(self, event: Gdk.Event): """Remember pressed keys, write down combinations.""" gdk_event_type: int = event.type if gdk_event_type == Gdk.EventType.KEY_PRESS: code = event.hardware_keycode - XKB_KEYCODE_OFFSET if code not in self._combination: self._combination.append(code) self._pressed.add(code) if gdk_event_type == Gdk.EventType.BUTTON_PRESS: code = self._get_button_code(event) if code not in self._combination: self._combination.append(code) self._pressed.add(code) def _release(self, event: Gdk.Event): """Clear pressed keys if this is a release event.""" if event.type in [Gdk.EventType.KEY_RELEASE, Gdk.EventType.BUTTON_RELEASE]: self._pressed = set() def _display(self, event): """Show the recorded combination in the gui.""" is_press = event.type in [ Gdk.EventType.KEY_PRESS, Gdk.EventType.BUTTON_PRESS, ] if is_press and len(self._combination) > 0: names = [ keyboard_layout.get_name(code) for code in self._combination if code is not None and keyboard_layout.get_name(code) is not None ] self._gui.set_text(" + ".join(names)) def _on_gtk_event(self, _, event: Gdk.Event): """For all sorts of input events that gtk cares about.""" self._reset(event) self._release(event) self._press(event) self._display(event) class CodeEditor: """The editor used to edit the output_symbol of the active_mapping.""" placeholder: str = _("Enter your output here") def __init__( self, message_broker: MessageBroker, controller: Controller, editor: GtkSource.View, ): self._message_broker = message_broker self._controller = controller self.gui = editor # without this the wrapping ScrolledWindow acts weird when new lines are added, # not offering enough space to the text editor so the whole thing is suddenly # scrollable by a few pixels. # Found this after making blind guesses with settings in glade, and then # actually looking at the snapshot preview! In glades editor this didn't have an # effect. self.gui.set_resize_mode(Gtk.ResizeMode.IMMEDIATE) # Syntax Highlighting # TODO there are some similarities with python, but overall it's quite useless. # commented out until there is proper highlighting for input-remappers syntax. # Thanks to https://github.com/wolfthefallen/py-GtkSourceCompletion-example # language_manager = GtkSource.LanguageManager() # fun fact: without saving LanguageManager into its own variable it doesn't work # python = language_manager.get_language("python") # source_view.get_buffer().set_language(python) self._update_placeholder() self.gui.get_buffer().connect("changed", self._on_gtk_changed) self.gui.connect("focus-in-event", self._update_placeholder) self.gui.connect("focus-out-event", self._update_placeholder) self._connect_message_listener() def _update_placeholder(self, *_): buffer = self.gui.get_buffer() code = buffer.get_text(buffer.get_start_iter(), buffer.get_end_iter(), True) # test for incorrect states and fix them, without causing side effects with HandlerDisabled(buffer, self._on_gtk_changed): if self.gui.has_focus() and code == self.placeholder: # hide the placeholder buffer.set_text("") self.gui.get_style_context().remove_class("opaque-text") elif code == "": # show the placeholder instead buffer.set_text(self.placeholder) self.gui.get_style_context().add_class("opaque-text") elif code != "": # something is written, ensure the opacity is correct self.gui.get_style_context().remove_class("opaque-text") def _shows_placeholder(self): buffer = self.gui.get_buffer() code = buffer.get_text(buffer.get_start_iter(), buffer.get_end_iter(), True) return code == self.placeholder @property def code(self) -> str: """Get the user-defined macro code string.""" if self._shows_placeholder(): return "" buffer = self.gui.get_buffer() return buffer.get_text(buffer.get_start_iter(), buffer.get_end_iter(), True) @code.setter def code(self, code: str) -> None: """Set the text without triggering any events.""" buffer = self.gui.get_buffer() with HandlerDisabled(buffer, self._on_gtk_changed): buffer.set_text(code) self._update_placeholder() self.gui.do_move_cursor(self.gui, Gtk.MovementStep.BUFFER_ENDS, -1, False) def _connect_message_listener(self): self._message_broker.subscribe( MessageType.mapping, self._on_mapping_loaded, ) self._message_broker.subscribe( MessageType.recording_finished, self._on_recording_finished, ) def _toggle_line_numbers(self): """Show line numbers if multiline, otherwise remove them.""" if "\n" in self.code: self.gui.set_show_line_numbers(True) # adds a bit of space between numbers and text: self.gui.set_show_line_marks(True) self.gui.set_monospace(True) self.gui.get_style_context().add_class("multiline") else: self.gui.set_show_line_numbers(False) self.gui.set_show_line_marks(False) self.gui.set_monospace(False) self.gui.get_style_context().remove_class("multiline") def _on_gtk_changed(self, *_): if self._shows_placeholder(): return self._controller.update_mapping(output_symbol=self.code) def _on_mapping_loaded(self, mapping: MappingData): code = SET_KEY_FIRST if not self._controller.is_empty_mapping(): code = mapping.output_symbol or "" if self.code.strip().lower() != code.strip().lower(): self.code = code self._toggle_line_numbers() def _on_recording_finished(self, _): self._controller.set_focus(self.gui) class RequireActiveMapping: """Disable the widget if no mapping is selected.""" def __init__( self, message_broker: MessageBroker, widget: Gtk.ToggleButton, require_recorded_input: bool, ): self._widget = widget self._default_tooltip = self._widget.get_tooltip_text() self._require_recorded_input = require_recorded_input self._active_preset: Optional[PresetData] = None self._active_mapping: Optional[MappingData] = None message_broker.subscribe(MessageType.preset, self._on_preset) message_broker.subscribe(MessageType.mapping, self._on_mapping) def _on_preset(self, preset_data: PresetData): self._active_preset = preset_data self._check() def _on_mapping(self, mapping_data: MappingData): self._active_mapping = mapping_data self._check() def _check(self, *__): if not self._active_preset or len(self._active_preset.mappings) == 0: self._disable() self._widget.set_tooltip_text(_("Add a mapping first")) return if ( self._require_recorded_input and self._active_mapping and not self._active_mapping.has_input_defined() ): self._disable() self._widget.set_tooltip_text(_("Record input first")) return self._enable() self._widget.set_tooltip_text(self._default_tooltip) def _enable(self): self._widget.set_sensitive(True) self._widget.set_opacity(1) def _disable(self): self._widget.set_sensitive(False) self._widget.set_opacity(0.5) class RecordingToggle: """The toggle that starts input recording for the active_mapping.""" def __init__( self, message_broker: MessageBroker, controller: Controller, toggle: Gtk.ToggleButton, ): self._message_broker = message_broker self._controller = controller self._gui = toggle toggle.connect("toggled", self._on_gtk_toggle) # Don't leave the input when using arrow keys or tab. wait for the # window to consume the keycode from the reader. I.e. a tab input should # be recorded, instead of causing the recording to stop. toggle.connect("key-press-event", lambda *args: Gdk.EVENT_STOP) self._message_broker.subscribe( MessageType.recording_finished, self._on_recording_finished, ) RequireActiveMapping( message_broker, toggle, require_recorded_input=False, ) def _on_gtk_toggle(self, *__): if self._gui.get_active(): self._controller.start_key_recording() else: self._controller.stop_key_recording() def _on_recording_finished(self, __): with HandlerDisabled(self._gui, self._on_gtk_toggle): self._gui.set_active(False) class RecordingStatus: """Displays if keys are being recorded for a mapping.""" def __init__( self, message_broker: MessageBroker, label: Gtk.Label, ): self._gui = label message_broker.subscribe( MessageType.recording_started, self._on_recording_started, ) message_broker.subscribe( MessageType.recording_finished, self._on_recording_finished, ) def _on_recording_started(self, _): self._gui.set_visible(True) def _on_recording_finished(self, _): self._gui.set_visible(False) class AutoloadSwitch: """The switch used to toggle the autoload state of the active_preset.""" def __init__( self, message_broker: MessageBroker, controller: Controller, switch: Gtk.Switch ): self._message_broker = message_broker self._controller = controller self._gui = switch self._gui.connect("state-set", self._on_gtk_toggle) self._message_broker.subscribe(MessageType.preset, self._on_preset_changed) def _on_preset_changed(self, data: PresetData): with HandlerDisabled(self._gui, self._on_gtk_toggle): self._gui.set_active(data.autoload) def _on_gtk_toggle(self, *_): self._controller.set_autoload(self._gui.get_active()) class ReleaseCombinationSwitch: """The switch used to set the active_mapping.release_combination_keys parameter.""" def __init__( self, message_broker: MessageBroker, controller: Controller, switch: Gtk.Switch ): self._message_broker = message_broker self._controller = controller self._gui = switch self._gui.connect("state-set", self._on_gtk_toggle) self._message_broker.subscribe(MessageType.mapping, self._on_mapping_changed) def _on_mapping_changed(self, data: MappingData): with HandlerDisabled(self._gui, self._on_gtk_toggle): self._gui.set_active(data.release_combination_keys) def _on_gtk_toggle(self, *_): self._controller.update_mapping(release_combination_keys=self._gui.get_active()) class InputConfigEntry(Gtk.ListBoxRow): """The ListBoxRow representing a single input config inside the CombinationListBox.""" __gtype_name__ = "InputConfigEntry" def __init__(self, event: InputConfig, controller: Controller): super().__init__() self.input_event = event self._controller = controller hbox = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=4) hbox.set_margin_start(12) label = Gtk.Label() label.set_label(event.description()) hbox.pack_start(label, False, False, 0) up_btn = Gtk.Button() up_btn.set_halign(Gtk.Align.END) up_btn.set_relief(Gtk.ReliefStyle.NONE) up_btn.get_style_context().add_class("no-v-padding") up_img = Gtk.Image.new_from_icon_name("go-up", Gtk.IconSize.BUTTON) up_btn.add(up_img) down_btn = Gtk.Button() down_btn.set_halign(Gtk.Align.END) down_btn.set_relief(Gtk.ReliefStyle.NONE) down_btn.get_style_context().add_class("no-v-padding") down_img = Gtk.Image.new_from_icon_name("go-down", Gtk.IconSize.BUTTON) down_btn.add(down_img) vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) vbox.pack_start(up_btn, False, True, 0) vbox.pack_end(down_btn, False, True, 0) hbox.pack_end(vbox, False, False, 0) up_btn.connect( "clicked", lambda *_: self._controller.move_input_config_in_combination( self.input_event, "up" ), ) down_btn.connect( "clicked", lambda *_: self._controller.move_input_config_in_combination( self.input_event, "down" ), ) self.add(hbox) self.show_all() # only used in testing self._up_btn = up_btn self._down_btn = down_btn class CombinationListbox: """The ListBox with all the events inside active_mapping.input_combination.""" def __init__( self, message_broker: MessageBroker, controller: Controller, listbox: Gtk.ListBox, ): self._message_broker = message_broker self._controller = controller self._gui = listbox self._combination: Optional[InputCombination] = None self._message_broker.subscribe( MessageType.mapping, self._on_mapping_changed, ) self._message_broker.subscribe( MessageType.selected_event, self._on_event_changed, ) self._gui.connect("row-selected", self._on_gtk_row_selected) def _select_row(self, event: InputEvent): for row in self._gui.get_children(): if row.input_event == event: self._gui.select_row(row) def _on_mapping_changed(self, mapping: MappingData): if self._combination == mapping.input_combination: return event_entries = self._gui.get_children() for event_entry in event_entries: self._gui.remove(event_entry) if self._controller.is_empty_mapping(): self._combination = None else: self._combination = mapping.input_combination for event in self._combination: self._gui.insert(InputConfigEntry(event, self._controller), -1) def _on_event_changed(self, event: InputEvent): with HandlerDisabled(self._gui, self._on_gtk_row_selected): self._select_row(event) def _on_gtk_row_selected(self, *_): for row in self._gui.get_children(): if row.is_selected(): self._controller.load_input_config(row.input_event) break class AnalogInputSwitch: """The switch that marks the active_input_config as analog input.""" def __init__( self, message_broker: MessageBroker, controller: Controller, gui: Gtk.Switch, ): self._message_broker = message_broker self._controller = controller self._gui = gui self._input_config: Optional[InputConfig] = None self._gui.connect("state-set", self._on_gtk_toggle) self._message_broker.subscribe(MessageType.selected_event, self._on_event) def _on_event(self, input_cfg: InputConfig): with HandlerDisabled(self._gui, self._on_gtk_toggle): self._gui.set_active(input_cfg.defines_analog_input) self._input_config = input_cfg if input_cfg.type == EV_KEY: self._gui.set_sensitive(False) self._gui.set_opacity(0.5) else: self._gui.set_sensitive(True) self._gui.set_opacity(1) def _on_gtk_toggle(self, *_): self._controller.set_event_as_analog(self._gui.get_active()) class TriggerThresholdInput: """The number selection used to set the speed or position threshold of the active_input_config when it is an ABS or REL event used as a key.""" def __init__( self, message_broker: MessageBroker, controller: Controller, gui: Gtk.SpinButton, ): self._message_broker = message_broker self._controller = controller self._gui = gui self._input_config: Optional[InputConfig] = None self._gui.set_increments(1, 1) self._gui.connect("value-changed", self._on_gtk_changed) self._message_broker.subscribe(MessageType.selected_event, self._on_event) def _on_event(self, input_config: InputConfig): if input_config.type == EV_KEY: self._gui.set_sensitive(False) self._gui.set_opacity(0.5) elif input_config.type == EV_ABS: self._gui.set_sensitive(True) self._gui.set_opacity(1) self._gui.set_range(-99, 99) else: self._gui.set_sensitive(True) self._gui.set_opacity(1) self._gui.set_range(-999, 999) with HandlerDisabled(self._gui, self._on_gtk_changed): self._gui.set_value(input_config.analog_threshold or 0) self._input_config = input_config def _on_gtk_changed(self, *_): self._controller.update_input_config( self._input_config.modify(analog_threshold=int(self._gui.get_value())) ) class ReleaseTimeoutInput: """The number selector used to set the active_mapping.release_timeout parameter.""" def __init__( self, message_broker: MessageBroker, controller: Controller, gui: Gtk.SpinButton, ): self._message_broker = message_broker self._controller = controller self._gui = gui self._gui.set_increments(0.01, 0.01) self._gui.set_range(0, 2) self._gui.connect("value-changed", self._on_gtk_changed) self._message_broker.subscribe(MessageType.mapping, self._on_mapping_message) def _on_mapping_message(self, mapping: MappingData): if EV_REL in [event.type for event in mapping.input_combination]: self._gui.set_sensitive(True) self._gui.set_opacity(1) else: self._gui.set_sensitive(False) self._gui.set_opacity(0.5) with HandlerDisabled(self._gui, self._on_gtk_changed): self._gui.set_value(mapping.release_timeout) def _on_gtk_changed(self, *_): self._controller.update_mapping(release_timeout=self._gui.get_value()) class RelativeInputCutoffInput: """The number selector to set active_mapping.rel_to_abs_input_cutoff.""" def __init__( self, message_broker: MessageBroker, controller: Controller, gui: Gtk.SpinButton, ): self._message_broker = message_broker self._controller = controller self._gui = gui self._gui.set_increments(1, 1) self._gui.set_range(1, 1000) self._gui.connect("value-changed", self._on_gtk_changed) self._message_broker.subscribe(MessageType.mapping, self._on_mapping_message) def _on_mapping_message(self, mapping: MappingData): if ( EV_REL in [event.type for event in mapping.input_combination] and mapping.output_type == EV_ABS ): self._gui.set_sensitive(True) self._gui.set_opacity(1) else: self._gui.set_sensitive(False) self._gui.set_opacity(0.5) with HandlerDisabled(self._gui, self._on_gtk_changed): self._gui.set_value(mapping.rel_to_abs_input_cutoff) def _on_gtk_changed(self, *_): self._controller.update_mapping(rel_xy_cutoff=self._gui.get_value()) class OutputAxisSelector: """The dropdown menu used to select the output axis if the active_mapping is a mapping targeting an analog axis modifies the active_mapping.output_code and active_mapping.output_type parameters """ def __init__( self, message_broker: MessageBroker, controller: Controller, gui: Gtk.ComboBox, ): self._message_broker = message_broker self._controller = controller self._gui = gui self._uinputs: Dict[str, Capabilities] = {} self.model = Gtk.ListStore(str, str) self._current_target: Optional[str] = None self._gui.set_model(self.model) renderer_text = Gtk.CellRendererText() self._gui.pack_start(renderer_text, False) self._gui.add_attribute(renderer_text, "text", 1) self._gui.set_id_column(0) self._gui.connect("changed", self._on_gtk_select_axis) self._message_broker.subscribe(MessageType.mapping, self._on_mapping_message) self._message_broker.subscribe(MessageType.uinputs, self._on_uinputs_message) def _set_model(self, target: Optional[str]): if target == self._current_target: return self.model.clear() self.model.append(["None, None", _("No Axis")]) if target is not None: capabilities = self._uinputs.get(target) or defaultdict(list) types_codes = [ (EV_ABS, code) for code, absinfo in capabilities.get(EV_ABS) or () ] types_codes.extend( (EV_REL, code) for code in capabilities.get(EV_REL) or () ) for type_, code in types_codes: key_name = get_evdev_constant_name(type_, code) if isinstance(key_name, list): key_name = key_name[0] self.model.append([f"{type_}, {code}", key_name]) self._current_target = target def _on_mapping_message(self, mapping: MappingData): with HandlerDisabled(self._gui, self._on_gtk_select_axis): self._set_model(mapping.target_uinput) self._gui.set_active_id(f"{mapping.output_type}, {mapping.output_code}") def _on_uinputs_message(self, uinputs: UInputsData): self._uinputs = uinputs.uinputs def _on_gtk_select_axis(self, *_): if self._gui.get_active_id() == "None, None": type_code = (None, None) else: type_code = tuple(int(i) for i in self._gui.get_active_id().split(",")) self._controller.update_mapping( output_type=type_code[0], output_code=type_code[1] ) class KeyAxisStackSwitcher: """The controls used to switch between the gui to modify a key-mapping or an analog-axis mapping.""" def __init__( self, message_broker: MessageBroker, controller: Controller, stack: Gtk.Stack, key_macro_toggle: Gtk.ToggleButton, analog_toggle: Gtk.ToggleButton, ): self._message_broker = message_broker self._controller = controller self._stack = stack self._key_macro_toggle = key_macro_toggle self._analog_toggle = analog_toggle self._key_macro_toggle.connect("toggled", self._on_gtk_toggle) self._analog_toggle.connect("toggled", self._on_gtk_toggle) self._message_broker.subscribe(MessageType.mapping, self._on_mapping_message) def _set_active(self, mapping_type: Literal["key_macro", "analog"]): if mapping_type == MappingType.ANALOG.value: self._stack.set_visible_child_name(OutputTypeNames.analog_axis) active = self._analog_toggle inactive = self._key_macro_toggle else: self._stack.set_visible_child_name(OutputTypeNames.key_or_macro) active = self._key_macro_toggle inactive = self._analog_toggle with HandlerDisabled(active, self._on_gtk_toggle): active.set_active(True) with HandlerDisabled(inactive, self._on_gtk_toggle): inactive.set_active(False) def _on_mapping_message(self, mapping: MappingData): # fist check the actual mapping if mapping.mapping_type == MappingType.ANALOG.value: self._set_active(MappingType.ANALOG.value) if mapping.mapping_type == MappingType.KEY_MACRO.value: self._set_active(MappingType.KEY_MACRO.value) def _on_gtk_toggle(self, btn: Gtk.ToggleButton): # get_active returns the new toggle state already was_active = not btn.get_active() if was_active: # cannot deactivate manually with HandlerDisabled(btn, self._on_gtk_toggle): btn.set_active(True) return if btn is self._key_macro_toggle: self._controller.update_mapping(mapping_type=MappingType.KEY_MACRO.value) else: self._controller.update_mapping(mapping_type=MappingType.ANALOG.value) class TransformationDrawArea: """The graph which shows the relation between input- and output-axis.""" def __init__( self, message_broker: MessageBroker, controller: Controller, gui: Gtk.DrawingArea, ): self._message_broker = message_broker self._controller = controller self._gui = gui self._transformation: Callable[[Union[float, int]], float] = lambda x: x self._gui.connect("draw", self._on_gtk_draw) self._message_broker.subscribe(MessageType.mapping, self._on_mapping_message) def _on_mapping_message(self, mapping: MappingData): self._transformation = Transformation( 100, -100, mapping.deadzone, mapping.gain, mapping.expo ) self._gui.queue_draw() def _on_gtk_draw(self, _, context: cairo.Context): points = [ (x / 200 + 0.5, -0.5 * self._transformation(x) + 0.5) # leave some space left and right for the lineCap to be visible for x in range(-97, 97) ] width = self._gui.get_allocated_width() height = self._gui.get_allocated_height() b = min((width, height)) scaled_points = [(x * b, y * b) for x, y in points] # x arrow context.move_to(0 * b, 0.5 * b) context.line_to(1 * b, 0.5 * b) context.line_to(0.96 * b, 0.52 * b) context.move_to(1 * b, 0.5 * b) context.line_to(0.96 * b, 0.48 * b) # y arrow context.move_to(0.5 * b, 1 * b) context.line_to(0.5 * b, 0) context.line_to(0.48 * b, 0.04 * b) context.move_to(0.5 * b, 0) context.line_to(0.52 * b, 0.04 * b) context.set_line_width(2) arrow_color = Gdk.RGBA(0.5, 0.5, 0.5, 0.2) context.set_source_rgba( arrow_color.red, arrow_color.green, arrow_color.blue, arrow_color.alpha, ) context.stroke() # graph context.move_to(*scaled_points[0]) for scaled_point in scaled_points[1:]: # Ploting point context.line_to(*scaled_point) line_color = Colors.get_accent_color() context.set_line_width(3) context.set_line_cap(cairo.LineCap.ROUND) # the default gtk adwaita highlight color: context.set_source_rgba( line_color.red, line_color.green, line_color.blue, line_color.alpha, ) context.stroke() class Sliders: """The different sliders to modify the gain, deadzone and expo parameters of the active_mapping.""" def __init__( self, message_broker: MessageBroker, controller: Controller, gain: Gtk.Range, deadzone: Gtk.Range, expo: Gtk.Range, ): self._message_broker = message_broker self._controller = controller self._gain = gain self._deadzone = deadzone self._expo = expo self._gain.set_range(-2, 2) self._deadzone.set_range(0, 0.9) self._expo.set_range(-1, 1) self._gain.connect("value-changed", self._on_gtk_gain_changed) self._expo.connect("value-changed", self._on_gtk_expo_changed) self._deadzone.connect("value-changed", self._on_gtk_deadzone_changed) self._message_broker.subscribe(MessageType.mapping, self._on_mapping_message) def _on_mapping_message(self, mapping: MappingData): with HandlerDisabled(self._gain, self._on_gtk_gain_changed): self._gain.set_value(mapping.gain) with HandlerDisabled(self._expo, self._on_gtk_expo_changed): self._expo.set_value(mapping.expo) with HandlerDisabled(self._deadzone, self._on_gtk_deadzone_changed): self._deadzone.set_value(mapping.deadzone) def _on_gtk_gain_changed(self, *_): self._controller.update_mapping(gain=self._gain.get_value()) def _on_gtk_deadzone_changed(self, *_): self._controller.update_mapping(deadzone=self._deadzone.get_value()) def _on_gtk_expo_changed(self, *_): self._controller.update_mapping(expo=self._expo.get_value()) input-remapper-2.1.1/inputremapper/gui/components/main.py000066400000000000000000000100271475433465200236420ustar00rootroot00000000000000# -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings # Copyright (C) 2025 sezanzeb # # This file is part of input-remapper. # # input-remapper is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # input-remapper is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with input-remapper. If not, see . """Components that wrap everything.""" from __future__ import annotations from gi.repository import Gtk, Pango from inputremapper.gui.controller import Controller from inputremapper.gui.messages.message_broker import ( MessageBroker, MessageType, ) from inputremapper.gui.messages.message_data import StatusData, DoStackSwitch from inputremapper.gui.utils import CTX_ERROR, CTX_MAPPING, CTX_WARNING class Stack: """Wraps the Stack, which contains the main menu pages.""" devices_page = 0 presets_page = 1 editor_page = 2 def __init__( self, message_broker: MessageBroker, controller: Controller, stack: Gtk.Stack, ): self._message_broker = message_broker self._controller = controller self._gui = stack self._message_broker.subscribe( MessageType.do_stack_switch, self._do_stack_switch ) def _do_stack_switch(self, msg: DoStackSwitch): self._gui.set_visible_child(self._gui.get_children()[msg.page_index]) class StatusBar: """The status bar on the bottom of the main window.""" def __init__( self, message_broker: MessageBroker, controller: Controller, status_bar: Gtk.Statusbar, error_icon: Gtk.Image, warning_icon: Gtk.Image, ): self._message_broker = message_broker self._controller = controller self._gui = status_bar self._error_icon = error_icon self._warning_icon = warning_icon label = self._gui.get_message_area().get_children()[0] label.set_ellipsize(Pango.EllipsizeMode.END) label.set_selectable(True) self._message_broker.subscribe(MessageType.status_msg, self._on_status_update) # keep track if there is an error or warning in the stack of statusbar # unfortunately this is not exposed over the api self._error = False self._warning = False def _on_status_update(self, data: StatusData): """Show a status message and set its tooltip. If message is None, it will remove the newest message of the given context_id. """ context_id = data.ctx_id message = data.msg tooltip = data.tooltip status_bar = self._gui if message is None: status_bar.remove_all(context_id) if context_id in (CTX_ERROR, CTX_MAPPING): self._error_icon.hide() self._error = False if self._warning: self._warning_icon.show() if context_id == CTX_WARNING: self._warning_icon.hide() self._warning = False if self._error: self._error_icon.show() status_bar.set_tooltip_text("") return if tooltip is None: tooltip = message self._error_icon.hide() self._warning_icon.hide() if context_id in (CTX_ERROR, CTX_MAPPING): self._error_icon.show() self._error = True if context_id == CTX_WARNING: self._warning_icon.show() self._warning = True status_bar.push(context_id, message) status_bar.set_tooltip_text(tooltip) input-remapper-2.1.1/inputremapper/gui/components/output_type_names.py000066400000000000000000000001311475433465200264750ustar00rootroot00000000000000class OutputTypeNames: analog_axis = "Analog Axis" key_or_macro = "Key or Macro" input-remapper-2.1.1/inputremapper/gui/components/presets.py000066400000000000000000000066431475433465200244140ustar00rootroot00000000000000# -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings # Copyright (C) 2025 sezanzeb # # This file is part of input-remapper. # # input-remapper is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # input-remapper is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with input-remapper. If not, see . """All components that are visible on the page that shows all the presets.""" from __future__ import annotations from gi.repository import Gtk from inputremapper.gui.components.common import FlowBoxEntry, FlowBoxWrapper from inputremapper.gui.components.main import Stack from inputremapper.gui.controller import Controller from inputremapper.gui.messages.message_broker import ( MessageBroker, MessageType, ) from inputremapper.gui.messages.message_data import ( GroupData, PresetData, DoStackSwitch, ) from inputremapper.logging.logger import logger class PresetEntry(FlowBoxEntry): """A preset that can be selected in the GUI.""" __gtype_name__ = "PresetEntry" def __init__( self, message_broker: MessageBroker, controller: Controller, preset_name: str, ): super().__init__( message_broker=message_broker, controller=controller, name=preset_name ) self.preset_name = preset_name def _on_gtk_toggle(self, *_, **__): logger.debug('Selecting preset "%s"', self.preset_name) self._controller.load_preset(self.preset_name) self.message_broker.publish(DoStackSwitch(Stack.editor_page)) class PresetSelection(FlowBoxWrapper): """A wrapper for the container with our presets. Selectes the active_preset. """ def __init__( self, message_broker: MessageBroker, controller: Controller, flowbox: Gtk.FlowBox, ): super().__init__(flowbox) self._message_broker = message_broker self._controller = controller self._gui = flowbox self._connect_message_listener() def _connect_message_listener(self): self._message_broker.subscribe(MessageType.group, self._on_group_changed) self._message_broker.subscribe(MessageType.preset, self._on_preset_changed) def _on_group_changed(self, data: GroupData): self._gui.foreach(self._gui.remove) for preset_name in data.presets: preset_entry = PresetEntry( self._message_broker, self._controller, preset_name, ) self._gui.insert(preset_entry, -1) def _on_preset_changed(self, data: PresetData): self.show_active_entry(data.name) def set_active_preset(self, preset_name: str): """Change the currently selected preset.""" # TODO might only be needed in tests for child in self._gui.get_children(): preset_entry: PresetEntry = child.get_children()[0] preset_entry.set_active(preset_entry.preset_name == preset_name) input-remapper-2.1.1/inputremapper/gui/controller.py000066400000000000000000000774451475433465200227350ustar00rootroot00000000000000# -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings # Copyright (C) 2025 sezanzeb # # This file is part of input-remapper. # # input-remapper is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # input-remapper is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with input-remapper. If not, see . from __future__ import annotations # needed for the TYPE_CHECKING import import re from functools import partial from typing import ( TYPE_CHECKING, Optional, Union, Literal, Sequence, Dict, Callable, List, Any, Tuple, ) from evdev.ecodes import EV_KEY, EV_REL, EV_ABS from gi.repository import Gtk from inputremapper.configs.input_config import InputCombination, InputConfig from inputremapper.configs.mapping import ( MappingData, UIMapping, MappingType, ) from inputremapper.configs.paths import PathUtils from inputremapper.configs.validation_errors import ( pydantify, MissingMacroOrKeyError, MacroButTypeOrCodeSetError, SymbolAndCodeMismatchError, MissingOutputAxisError, WrongMappingTypeForKeyError, OutputSymbolVariantError, ) from inputremapper.exceptions import DataManagementError from inputremapper.gui.components.output_type_names import OutputTypeNames from inputremapper.gui.data_manager import DataManager, DEFAULT_PRESET_NAME from inputremapper.gui.gettext import _ from inputremapper.gui.messages.message_broker import ( MessageBroker, MessageType, ) from inputremapper.gui.messages.message_data import ( PresetData, StatusData, CombinationRecorded, UserConfirmRequest, DoStackSwitch, ) from inputremapper.gui.utils import CTX_APPLY, CTX_ERROR, CTX_WARNING, CTX_MAPPING from inputremapper.injection.injector import ( InjectorState, InjectorStateMessage, ) from inputremapper.logging.logger import logger if TYPE_CHECKING: # avoids gtk import error in tests from inputremapper.gui.user_interface import UserInterface MAPPING_DEFAULTS = {"target_uinput": "keyboard"} class Controller: """Implements the behaviour of the gui.""" def __init__( self, message_broker: MessageBroker, data_manager: DataManager, ) -> None: self.message_broker = message_broker self.data_manager = data_manager self.gui: Optional[UserInterface] = None self.button_left_warn = False self._attach_to_events() def set_gui(self, gui: UserInterface): """Let the Controller know about the user interface singleton..""" self.gui = gui def _attach_to_events(self) -> None: self.message_broker.subscribe(MessageType.groups, self._on_groups_changed) self.message_broker.subscribe(MessageType.preset, self._on_preset_changed) self.message_broker.subscribe(MessageType.init, self._on_init) self.message_broker.subscribe( MessageType.preset, self._publish_mapping_errors_as_status_msg ) self.message_broker.subscribe( MessageType.mapping, self._publish_mapping_errors_as_status_msg ) def _on_init(self, __): """Initialize the gui and the data_manager.""" # make sure we get a groups_changed event when everything is ready # this might not be necessary if the reader-service takes longer to provide the # initial groups self.data_manager.publish_groups() self.data_manager.publish_uinputs() def _on_groups_changed(self, _): """Load the newest group as soon as everyone got notified about the updated groups.""" if self.data_manager.active_group is not None: # don't jump to a different group and preset suddenly, if the user # is already looking at one logger.debug("A group is already active") return group_key = self.get_a_group() if group_key is None: logger.debug("Could not find a group") return self.load_group(group_key) def _on_preset_changed(self, data: PresetData): """Load a mapping as soon as everyone got notified about the new preset.""" if data.mappings: mappings = list(data.mappings) mappings.sort( key=lambda mapping: ( mapping.format_name() or mapping.input_combination.beautify() ) ) combination = mappings[0].input_combination self.load_mapping(combination) self.load_input_config(combination[0]) else: # send an empty mapping to make sure the ui is reset to default values self.message_broker.publish(MappingData(**MAPPING_DEFAULTS)) def _on_combination_recorded(self, data: CombinationRecorded): combination = self._auto_use_as_analog(data.combination) self.update_combination(combination) def _format_status_bar_validation_errors(self) -> Optional[Tuple[str, str]]: if not self.data_manager.active_preset: return None if self.data_manager.active_preset.is_valid(): self.message_broker.publish(StatusData(CTX_MAPPING)) return None mappings = list(self.data_manager.active_preset) # Move the selected (active) mapping to the front, so that it is checked first. active_mapping = self.data_manager.active_mapping if active_mapping is not None: mappings.remove(active_mapping) mappings.insert(0, active_mapping) for mapping in mappings: if not mapping.has_input_defined(): # Empty mapping, nothing recorded yet so nothing can be configured, # therefore there isn't anything to validate. continue position = mapping.format_name() error_strings = self._get_ui_error_strings(mapping) if len(error_strings) == 0: continue if len(error_strings) > 1: msg = _('%d Mapping errors at "%s", hover for info') % ( len(error_strings), position, ) tooltip = "– " + "\n– ".join(error_strings) else: msg = f'"{position}": {error_strings[0]}' tooltip = error_strings[0] return msg.replace("\n", " "), tooltip return None def _publish_mapping_errors_as_status_msg(self, *__) -> None: """Send mapping ValidationErrors to the MessageBroker.""" validation_result = self._format_status_bar_validation_errors() if validation_result is None: return self.show_status( CTX_MAPPING, validation_result[0], validation_result[1], ) @staticmethod def format_error_message(mapping, error_type, error_message: str) -> str: """Check all the different error messages which are not useful for the user.""" # There is no more elegant way of comparing error_type with the base class. # https://github.com/pydantic/pydantic/discussions/5112 if ( pydantify(MacroButTypeOrCodeSetError) in error_type or pydantify(SymbolAndCodeMismatchError) in error_type ) and mapping.input_combination.defines_analog_input: return _( "Remove the macro or key from the macro input field " "when specifying an analog output" ) if ( pydantify(MacroButTypeOrCodeSetError) in error_type or pydantify(SymbolAndCodeMismatchError) in error_type ) and not mapping.input_combination.defines_analog_input: return _( "Remove the Analog Output Axis when specifying a macro or key output" ) if pydantify(MissingOutputAxisError) in error_type: error_message = _( "The input specifies an analog axis, but no output axis is selected." ) if mapping.output_symbol is not None: event = [ event for event in mapping.input_combination if event.defines_analog_input ][0] error_message += _( "\nIf you mean to create a key or macro mapping " "go to the advanced input configuration" ' and set a "Trigger Threshold" for ' f'"{event.description()}"' ) return error_message if pydantify(WrongMappingTypeForKeyError) in error_type: error_message = _( "The input specifies a key, but the output type is not " f'"{OutputTypeNames.key_or_macro}".' ) if mapping.output_type in (EV_ABS, EV_REL): error_message += _( "\nIf you mean to create an analog axis mapping go to the " 'advanced input configuration and set an input to "Use as Analog".' ) return error_message if pydantify(MissingMacroOrKeyError) in error_type: return _("Missing macro or key") return error_message @staticmethod def _get_ui_error_strings(mapping: UIMapping) -> List[str]: """Get a human readable error message from a mapping error.""" validation_error = mapping.get_error() if validation_error is None: return [] formatted_errors = [] for error in validation_error.errors(): if pydantify(OutputSymbolVariantError) in error["type"]: # this is rather internal, when this error appears in the gui, there is # also always another more readable error at the same time that explains # this problem. continue error_string = f'"{mapping.format_name()}": ' error_message = error["msg"] error_location = error["loc"][0] if error_location != "__root__": error_string += f"{error_location}: " # check all the different error messages which are not useful for the user formatted_errors.append( Controller.format_error_message( mapping, error["type"], error_message, ) ) return formatted_errors def get_a_preset(self) -> str: """Attempts to get the newest preset in the current group creates a new preset if that fails.""" try: return self.data_manager.get_newest_preset_name() except FileNotFoundError: pass self.data_manager.create_preset(self.data_manager.get_available_preset_name()) return self.data_manager.get_newest_preset_name() def get_a_group(self) -> Optional[str]: """Attempts to get the group with the newest preset returns any if that fails.""" try: return self.data_manager.get_newest_group_key() except FileNotFoundError: pass keys = self.data_manager.get_group_keys() return keys[0] if keys else None def copy_preset(self): """Create a copy of the active preset and name it `preset_name copy`.""" name = self.data_manager.active_preset.name match = re.search(" copy *\d*$", name) if match: name = name[: match.start()] self.data_manager.copy_preset( self.data_manager.get_available_preset_name(f"{name} copy") ) self.message_broker.publish(DoStackSwitch(1)) def _auto_use_as_analog(self, combination: InputCombination) -> InputCombination: """If output is analog, set the first fitting input to analog.""" if self.data_manager.active_mapping is None: return combination if not self.data_manager.active_mapping.is_analog_output(): return combination if combination.find_analog_input_config(): # something is already set to do that return combination for i, input_config in enumerate(combination): # find the first analog input and set it to "use as analog" if input_config.type in (EV_ABS, EV_REL): logger.info("Using %s as analog input", input_config) # combinations and input_configs are immutable, a new combination # is created to fit the needs instead combination_list = list(combination) combination_list[i] = input_config.modify(analog_threshold=0) new_combination = InputCombination(combination_list) return new_combination return combination def update_combination(self, combination: InputCombination): """Update the input_combination of the active mapping.""" combination = self._auto_use_as_analog(combination) try: self.data_manager.update_mapping(input_combination=combination) self.save() except KeyError: self.show_status( CTX_MAPPING, f'"{combination.beautify()}" already mapped to something else', ) return if combination.is_problematic(): self.show_status( CTX_WARNING, _("ctrl, alt and shift may not combine properly"), _( "Your system might reinterpret combinations with those after they " + "are injected, and by doing so break them. Play around with the " + 'advanced "Release Input" toggle.' ), ) def move_input_config_in_combination( self, input_config: InputConfig, direction: Union[Literal["up"], Literal["down"]], ): """Move the active_input_config up or down in the input_combination of the active_mapping.""" if ( not self.data_manager.active_mapping or len(self.data_manager.active_mapping.input_combination) == 1 ): return combination: Sequence[InputConfig] = ( self.data_manager.active_mapping.input_combination ) i = combination.index(input_config) if ( i + 1 == len(combination) and direction == "down" or i == 0 and direction == "up" ): return if direction == "up": combination = ( list(combination[: i - 1]) + [input_config] + [combination[i - 1]] + list(combination[i + 1 :]) ) elif direction == "down": combination = ( list(combination[:i]) + [combination[i + 1]] + [input_config] + list(combination[i + 2 :]) ) else: raise ValueError(f"unknown direction: {direction}") self.update_combination(InputCombination(combination)) self.load_input_config(input_config) def load_input_config(self, input_config: InputConfig): """Load an InputConfig form the active mapping input combination.""" self.data_manager.load_input_config(input_config) def update_input_config(self, new_input_config: InputConfig): """Modify the active input configuration.""" try: self.data_manager.update_input_config(new_input_config) except KeyError: # we need to synchronize the gui self.data_manager.publish_mapping() self.data_manager.publish_event() def remove_event(self): """Remove the active InputEvent from the active mapping event combination.""" if ( not self.data_manager.active_mapping or not self.data_manager.active_input_config ): return combination = list(self.data_manager.active_mapping.input_combination) combination.remove(self.data_manager.active_input_config) try: self.data_manager.update_mapping( input_combination=InputCombination(combination) ) self.load_input_config(combination[0]) self.save() except (KeyError, ValueError): # we need to synchronize the gui self.data_manager.publish_mapping() self.data_manager.publish_event() def set_event_as_analog(self, analog: bool): """Use the active event as an analog input.""" assert self.data_manager.active_input_config is not None event = self.data_manager.active_input_config if event.type != EV_KEY: if analog: try: self.data_manager.update_input_config( event.modify(analog_threshold=0) ) self.save() return except KeyError: pass else: try_values = {EV_REL: [1, -1], EV_ABS: [10, -10]} for value in try_values[event.type]: try: self.data_manager.update_input_config( event.modify(analog_threshold=value) ) self.save() return except KeyError: pass # didn't update successfully # we need to synchronize the gui self.data_manager.publish_mapping() self.data_manager.publish_event() def load_groups(self): """Refresh the groups.""" self.data_manager.refresh_groups() def load_group(self, group_key: str): """Load the group and then a preset of that group.""" self.data_manager.load_group(group_key) self.load_preset(self.get_a_preset()) def load_preset(self, name: str): """Load the preset.""" self.data_manager.load_preset(name) # self.load_mapping(...) # not needed because we have on_preset_changed() def rename_preset(self, new_name: str): """Rename the active_preset.""" if ( not self.data_manager.active_preset or not new_name or new_name == self.data_manager.active_preset.name ): return new_name = PathUtils.sanitize_path_component(new_name) new_name = self.data_manager.get_available_preset_name(new_name) self.data_manager.rename_preset(new_name) def add_preset(self, name: str = DEFAULT_PRESET_NAME): """Create a new preset called `new preset n`, add it to the active_group.""" name = self.data_manager.get_available_preset_name(name) try: self.data_manager.create_preset(name) self.data_manager.load_preset(name) except PermissionError as e: self.show_status(CTX_ERROR, _("Permission denied!"), str(e)) def delete_preset(self): """Delete the active_preset from the disc.""" def f(answer: bool): if answer: self.data_manager.delete_preset() self.data_manager.load_preset(self.get_a_preset()) self.message_broker.publish(DoStackSwitch(1)) if not self.data_manager.active_preset: return msg = ( _('Are you sure you want to delete the preset "%s"?') % self.data_manager.active_preset.name ) self.message_broker.publish(UserConfirmRequest(msg, f)) def load_mapping(self, input_combination: InputCombination): """Load the mapping with the given input_combination form the active_preset.""" self.data_manager.load_mapping(input_combination) self.load_input_config(input_combination[0]) def update_mapping(self, **changes): """Update the active_mapping with the given keywords and values.""" if "mapping_type" in changes.keys(): if not (changes := self._change_mapping_type(changes)): # we need to synchronize the gui self.data_manager.publish_mapping() self.data_manager.publish_event() return self.data_manager.update_mapping(**changes) self.save() def create_mapping(self): """Create a new empty mapping in the active_preset.""" try: self.data_manager.create_mapping() except KeyError: # there is already an empty mapping return self.data_manager.load_mapping(combination=InputCombination.empty_combination()) self.data_manager.update_mapping(**MAPPING_DEFAULTS) def delete_mapping(self): """Remove the active_mapping form the active_preset.""" def get_answer(answer: bool): if answer: self.data_manager.delete_mapping() self.save() if not self.data_manager.active_mapping: return self.message_broker.publish( UserConfirmRequest( _("Are you sure you want to delete this mapping?"), get_answer, ) ) def set_autoload(self, autoload: bool): """Set the autoload state for the active_preset and active_group.""" self.data_manager.set_autoload(autoload) self.data_manager.refresh_service_config_path() def save(self): """Save all data to the disc.""" try: self.data_manager.save() except PermissionError as e: self.show_status(CTX_ERROR, _("Permission denied!"), str(e)) def start_key_recording(self): """Record the input of the active_group Updates the active_mapping.input_combination with the recorded events. """ state = self.data_manager.get_state() if state == InjectorState.RUNNING or state == InjectorState.STARTING: self.data_manager.stop_combination_recording() self.message_broker.signal(MessageType.recording_finished) self.show_status(CTX_ERROR, _('Use "Stop" to stop before editing')) return logger.debug("Recording Keys") def on_recording_finished(_): self.message_broker.unsubscribe(on_recording_finished) self.message_broker.unsubscribe(self._on_combination_recorded) self.gui.connect_shortcuts() self.gui.disconnect_shortcuts() self.message_broker.subscribe( MessageType.combination_recorded, self._on_combination_recorded, ) self.message_broker.subscribe( MessageType.recording_finished, on_recording_finished ) self.data_manager.start_combination_recording() def stop_key_recording(self): """Stop recording the input.""" logger.debug("Stopping Recording Keys") self.data_manager.stop_combination_recording() def start_injecting(self): """Inject the active_preset for the active_group.""" if len(self.data_manager.active_preset) == 0: logger.error(_("Cannot apply empty preset file")) # also helpful for first time use self.show_status(CTX_ERROR, _("You need to add mappings first")) return if not self.button_left_warn: if self.data_manager.active_preset.dangerously_mapped_btn_left(): self.show_status( CTX_ERROR, "This would disable your click button", "Map a button to BTN_LEFT to avoid this.\n" "To overwrite this warning, press apply again.", ) self.button_left_warn = True return # todo: warn about unreleased keys self.button_left_warn = False self.message_broker.subscribe( MessageType.injector_state, self.show_injector_result, ) self.show_status(CTX_APPLY, _("Starting injection...")) if not self.data_manager.start_injecting(): self.message_broker.unsubscribe(self.show_injector_result) self.show_status( CTX_APPLY, _('Failed to apply preset "%s"') % self.data_manager.active_preset.name, ) def show_injector_result(self, msg: InjectorStateMessage) -> None: """Show if the injection was successfully started.""" self.message_broker.unsubscribe(self.show_injector_result) state = msg.state def running() -> None: assert self.data_manager.active_preset is not None msg = _('Applied preset "%s"') % self.data_manager.active_preset.name if self.data_manager.active_preset.dangerously_mapped_btn_left(): msg += _(", CTRL + DEL to stop") self.show_status(CTX_APPLY, msg) logger.info( 'Group "%s" is currently mapped', self.data_manager.active_group.key ) def no_grab() -> None: assert self.data_manager.active_preset is not None msg = ( _('Failed to apply preset "%s"') % self.data_manager.active_preset.name ) tooltip = ( "Maybe your preset doesn't contain anything that is sent by the " "device or another device is already grabbing it" ) # InjectorState.NO_GRAB also happens when all mappings have validation # errors. In that case, we can show something more useful. validation_result = self._format_status_bar_validation_errors() if validation_result is not None: msg = f"{msg}. {validation_result[0]}" tooltip = validation_result[1] self.show_status(CTX_ERROR, msg, tooltip) assert self.data_manager.active_preset # make mypy happy state_calls: Dict[InjectorState, Callable] = { InjectorState.RUNNING: running, InjectorState.ERROR: partial( self.show_status, CTX_ERROR, _('Error applying preset "%s"') % self.data_manager.active_preset.name, ), InjectorState.NO_GRAB: no_grab, InjectorState.UPGRADE_EVDEV: partial( self.show_status, CTX_ERROR, "Upgrade python-evdev", "Your python-evdev version is too old.", ), } if state in state_calls: state_calls[state]() def stop_injecting(self): """Stop injecting any preset for the active_group.""" def show_result(msg: InjectorStateMessage): self.message_broker.unsubscribe(show_result) if not msg.inactive(): # some speculation: there might be unexpected additional status messages # with a different state, or the status is wrong because something in # the long pipeline of status messages is broken. logger.error( "Expected the injection to eventually stop, but got state %s", msg.state, ) return self.show_status(CTX_APPLY, _("Stopped the injection")) try: self.message_broker.subscribe(MessageType.injector_state, show_result) self.data_manager.stop_injecting() except DataManagementError: self.message_broker.unsubscribe(show_result) def show_status( self, ctx_id: int, msg: Optional[str] = None, tooltip: Optional[str] = None, ): """Send a status message to the ui to show it in the status-bar.""" self.message_broker.publish(StatusData(ctx_id, msg, tooltip)) def is_empty_mapping(self) -> bool: """Check if the active_mapping is empty.""" return ( self.data_manager.active_mapping == UIMapping(**MAPPING_DEFAULTS) or self.data_manager.active_mapping is None ) def refresh_groups(self): """Reload the connected devices and send them as a groups message. Runs asynchronously. """ self.data_manager.refresh_groups() def close(self): """Safely close the application.""" logger.debug("Closing Application") self.save() self.message_broker.signal(MessageType.terminate) logger.debug("Quitting") Gtk.main_quit() def set_focus(self, component): """Focus the given component.""" self.gui.window.set_focus(component) def _change_mapping_type(self, changes: Dict[str, Any]): """Query the user to update the mapping in order to change the mapping type.""" mapping = self.data_manager.active_mapping if mapping is None: return changes if changes["mapping_type"] == mapping.mapping_type: return changes if changes["mapping_type"] == MappingType.ANALOG.value: msg = _("You are about to change the mapping to analog.") if mapping.output_symbol: msg += _('\nThis will remove "{}" ' "from the text input!").format( mapping.output_symbol ) if not [ input_config for input_config in mapping.input_combination if input_config.defines_analog_input ]: # there is no analog input configured, let's try to autoconfigure it inputs: List[InputConfig] = list(mapping.input_combination) for i, input_config in enumerate(inputs): if input_config.type in [EV_ABS, EV_REL]: inputs[i] = input_config.modify(analog_threshold=0) changes["input_combination"] = InputCombination(inputs) msg += _( '\nThe input "{}" will be used as analog input.' ).format(input_config.description()) break else: # not possible to autoconfigure inform the user msg += _("\nYou need to record an analog input.") elif not mapping.output_symbol: return changes answer = None def get_answer(answer_: bool): nonlocal answer answer = answer_ self.message_broker.publish(UserConfirmRequest(msg, get_answer)) if answer: changes["output_symbol"] = None return changes else: return None if changes["mapping_type"] == MappingType.KEY_MACRO.value: try: analog_input = tuple( filter(lambda i: i.defines_analog_input, mapping.input_combination) )[0] except IndexError: changes["output_type"] = None changes["output_code"] = None return changes answer = None def get_answer(answer_: bool): nonlocal answer answer = answer_ self.message_broker.publish( UserConfirmRequest( f"You are about to change the mapping to a Key or Macro mapping!\n" f"Go to the advanced input configuration and set a " f'"Trigger Threshold" for "{analog_input.description()}".', get_answer, ) ) if answer: changes["output_type"] = None changes["output_code"] = None return changes else: return None return changes input-remapper-2.1.1/inputremapper/gui/data_manager.py000066400000000000000000000533111475433465200231370ustar00rootroot00000000000000# -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings # Copyright (C) 2025 sezanzeb # # This file is part of input-remapper. # # input-remapper is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # input-remapper is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with input-remapper. If not, see . import glob import os import re import time from typing import Optional, List, Tuple, Set from gi.repository import GLib from inputremapper.configs.global_config import GlobalConfig from inputremapper.configs.input_config import InputCombination, InputConfig from inputremapper.configs.mapping import UIMapping, MappingData from inputremapper.configs.paths import PathUtils from inputremapper.configs.preset import Preset from inputremapper.configs.keyboard_layout import KeyboardLayout from inputremapper.daemon import DaemonProxy from inputremapper.exceptions import DataManagementError from inputremapper.groups import _Group from inputremapper.gui.gettext import _ from inputremapper.gui.messages.message_broker import ( MessageBroker, ) from inputremapper.gui.messages.message_data import ( UInputsData, GroupData, PresetData, CombinationUpdate, ) from inputremapper.gui.reader_client import ReaderClient from inputremapper.injection.global_uinputs import GlobalUInputs from inputremapper.injection.injector import ( InjectorState, InjectorStateMessage, ) from inputremapper.logging.logger import logger DEFAULT_PRESET_NAME = _("new preset") # useful type aliases Name = str GroupKey = str class DataManager: """DataManager provides an interface to create and modify configurations as well as modify the state of the Service. Any state changes will be announced via the MessageBroker. """ def __init__( self, message_broker: MessageBroker, config: GlobalConfig, reader_client: ReaderClient, daemon: DaemonProxy, uinputs: GlobalUInputs, keyboard_layout: KeyboardLayout, ): self.message_broker = message_broker self._reader_client = reader_client self._daemon = daemon self._uinputs = uinputs self._keyboard_layout = keyboard_layout uinputs.prepare_all() self._config = config self._config.load_config() self._active_preset: Optional[Preset[UIMapping]] = None self._active_mapping: Optional[UIMapping] = None self._active_input_config: Optional[InputConfig] = None def publish_group(self): """Send active group to the MessageBroker. This is internally called whenever the group changes. It is usually not necessary to call this explicitly from outside DataManager. """ self.message_broker.publish( GroupData(self.active_group.key, self.get_preset_names()) ) def publish_preset(self): """Send active preset to the MessageBroker. This is internally called whenever the preset changes. It is usually not necessary to call this explicitly from outside DataManager. """ self.message_broker.publish( PresetData( self.active_preset.name, self.get_mappings(), self.get_autoload() ) ) def publish_mapping(self): """Send active mapping to the MessageBroker This is internally called whenever the mapping changes. It is usually not necessary to call this explicitly from outside DataManager. """ if self.active_mapping: self.message_broker.publish(self.active_mapping.get_bus_message()) def publish_event(self): """Send active event to the MessageBroker. This is internally called whenever the event changes. It is usually not necessary to call this explicitly from outside DataManager """ if self.active_input_config: assert self.active_input_config in self.active_mapping.input_combination self.message_broker.publish(self.active_input_config) def publish_uinputs(self): """Send the "uinputs" message on the MessageBroker.""" self.message_broker.publish( UInputsData( { name: uinput.capabilities() for name, uinput in self._uinputs.devices.items() } ) ) def publish_groups(self): """Publish the "groups" message on the MessageBroker.""" self._reader_client.publish_groups() def publish_injector_state(self): """Publish the "injector_state" message for the active_group.""" if not self.active_group: return self.message_broker.publish(InjectorStateMessage(self.get_state())) @property def active_group(self) -> Optional[_Group]: """The currently loaded group.""" return self._reader_client.group @property def active_preset(self) -> Optional[Preset[UIMapping]]: """The currently loaded preset.""" return self._active_preset @property def active_mapping(self) -> Optional[UIMapping]: """The currently loaded mapping.""" return self._active_mapping @property def active_input_config(self) -> Optional[InputConfig]: """The currently loaded event.""" return self._active_input_config def get_group_keys(self) -> Tuple[GroupKey, ...]: """Get all group keys (plugged devices).""" return tuple(group.key for group in self._reader_client.groups.filter()) def get_preset_names(self) -> Tuple[Name, ...]: """Get all preset names for active_group and current user sorted by age.""" if not self.active_group: raise DataManagementError("Cannot find presets: Group is not set") device_folder = PathUtils.get_preset_path(self.active_group.name) PathUtils.mkdir(device_folder) paths = glob.glob(os.path.join(glob.escape(device_folder), "*.json")) presets = [ os.path.splitext(os.path.basename(path))[0] for path in sorted(paths, key=os.path.getmtime) ] # the highest timestamp to the front presets.reverse() return tuple(presets) def get_mappings(self) -> Optional[List[MappingData]]: """All mappings from the active_preset.""" if not self._active_preset: return None return [mapping.get_bus_message() for mapping in self._active_preset] def get_autoload(self) -> bool: """The autoload status of the active_preset.""" if not self.active_preset or not self.active_group: return False return self._config.is_autoloaded( self.active_group.key, self.active_preset.name ) def set_autoload(self, status: bool): """Set the autoload status of the active_preset. Will send "preset" message on the MessageBroker. """ if not self.active_preset or not self.active_group: raise DataManagementError("Cannot set autoload status: Preset is not set") if status: self._config.set_autoload_preset( self.active_group.key, self.active_preset.name ) elif self.get_autoload(): self._config.set_autoload_preset(self.active_group.key, None) self.publish_preset() def get_newest_group_key(self) -> GroupKey: """group_key of the group with the most recently modified preset.""" paths = [] pattern = os.path.join( glob.escape(PathUtils.get_preset_path()), "*/*.json", ) for path in glob.glob(pattern): if self._reader_client.groups.find(key=PathUtils.split_all(path)[-2]): paths.append((path, os.path.getmtime(path))) if not paths: raise FileNotFoundError() path, _ = max(paths, key=lambda x: x[1]) return PathUtils.split_all(path)[-2] def get_newest_preset_name(self) -> Name: """Preset name of the most recently modified preset in the active group.""" if not self.active_group: raise DataManagementError("Cannot find newest preset: Group is not set") pattern = os.path.join( glob.escape(PathUtils.get_preset_path(self.active_group.name)), "*.json", ) paths = [(path, os.path.getmtime(path)) for path in glob.glob(pattern)] if not paths: raise FileNotFoundError() path, _ = max(paths, key=lambda x: x[1]) return os.path.split(path)[-1].split(".")[0] def get_available_preset_name(self, name=DEFAULT_PRESET_NAME) -> Name: """The first available preset in the active group.""" if not self.active_group: raise DataManagementError("Unable find preset name. Group is not set") name = name.strip() # find a name that is not already taken if os.path.exists(PathUtils.get_preset_path(self.active_group.name, name)): # if there already is a trailing number, increment it instead of # adding another one match = re.match(r"^(.+) (\d+)$", name) if match: name = match[1] i = int(match[2]) + 1 else: i = 2 while os.path.exists( PathUtils.get_preset_path(self.active_group.name, f"{name} {i}") ): i += 1 return f"{name} {i}" return name def load_group(self, group_key: str): """Load a group. will publish "groups" and "injector_state" messages. This will render the active_mapping and active_preset invalid. """ if group_key not in self.get_group_keys(): raise DataManagementError("Unable to load non existing group") logger.info('Loading group "%s"', group_key) self._active_input_config = None self._active_mapping = None self._active_preset = None group = self._reader_client.groups.find(key=group_key) self._reader_client.set_group(group) self.publish_group() self.publish_injector_state() def load_preset(self, name: str): """Load a preset. Will send "preset" message on the MessageBroker. This will render the active_mapping invalid. """ if not self.active_group: raise DataManagementError("Unable to load preset. Group is not set") logger.info('Loading preset "%s"', name) preset_path = PathUtils.get_preset_path(self.active_group.name, name) preset = Preset(preset_path, mapping_factory=UIMapping) preset.load() self._active_input_config = None self._active_mapping = None self._active_preset = preset self.publish_preset() def load_mapping(self, combination: InputCombination): """Load a mapping. Will send "mapping" message on the MessageBroker.""" if not self._active_preset: raise DataManagementError("Unable to load mapping. Preset is not set") mapping = self._active_preset.get_mapping(combination) if not mapping: msg = ( f"the mapping with {combination = } does not " f"exist in the {self._active_preset.path}" ) logger.error(msg) raise KeyError(msg) self._active_input_config = None self._active_mapping = mapping self.publish_mapping() def load_input_config(self, input_config: InputConfig): """Load a InputConfig from the combination in the active mapping. Will send "event" message on the MessageBroker, """ if not self.active_mapping: raise DataManagementError("Unable to load event. Mapping is not set") if input_config not in self.active_mapping.input_combination: raise ValueError( f"{input_config} is not member of active_mapping.input_combination: " f"{self.active_mapping.input_combination}" ) self._active_input_config = input_config self.publish_event() def rename_preset(self, new_name: str): """Rename the current preset and move the correct file. Will send "group" and then "preset" message on the MessageBroker """ if not self.active_preset or not self.active_group: raise DataManagementError("Unable rename preset: Preset is not set") if self.active_preset.path == PathUtils.get_preset_path( self.active_group.name, new_name ): return old_path = self.active_preset.path assert old_path is not None old_name = os.path.basename(old_path).split(".")[0] new_path = PathUtils.get_preset_path(self.active_group.name, new_name) if os.path.exists(new_path): raise ValueError( f"cannot rename {old_name} to " f"{new_name}, preset already exists" ) logger.info('Moving "%s" to "%s"', old_path, new_path) os.rename(old_path, new_path) now = time.time() os.utime(new_path, (now, now)) if self._config.is_autoloaded(self.active_group.key, old_name): self._config.set_autoload_preset(self.active_group.key, new_name) self.active_preset.path = PathUtils.get_preset_path( self.active_group.name, new_name ) self.publish_group() self.publish_preset() def copy_preset(self, name: str): """Copy the current preset to the given name. Will send "group" and "preset" message to the MessageBroker and load the copy """ # todo: Do we want to load the copy here? or is this up to the controller? if not self.active_preset or not self.active_group: raise DataManagementError("Unable to copy preset: Preset is not set") if self.active_preset.path == PathUtils.get_preset_path( self.active_group.name, name ): return if name in self.get_preset_names(): raise ValueError(f"a preset with the name {name} already exits") new_path = PathUtils.get_preset_path(self.active_group.name, name) logger.info('Copy "%s" to "%s"', self.active_preset.path, new_path) self.active_preset.path = new_path self.save() self.publish_group() self.publish_preset() def create_preset(self, name: str): """Create empty preset in the active_group. Will send "group" message to the MessageBroker """ if not self.active_group: raise DataManagementError("Unable to add preset. Group is not set") path = PathUtils.get_preset_path(self.active_group.name, name) if os.path.exists(path): raise DataManagementError("Unable to add preset. Preset exists") Preset(path).save() self.publish_group() def delete_preset(self): """Delete the active preset. Will send "group" message to the MessageBroker this will invalidate the active mapping, """ preset_path = self._active_preset.path logger.info('Removing "%s"', preset_path) os.remove(preset_path) self._active_mapping = None self._active_preset = None self.publish_group() def update_mapping(self, **kwargs): """Update the active mapping with the given keywords and values. Will send "mapping" message to the MessageBroker. In case of a new input_combination. This will first send a "combination_update" message. """ if not self._active_mapping: raise DataManagementError("Cannot modify Mapping: Mapping is not set") if symbol := kwargs.get("output_symbol"): kwargs["output_symbol"] = self._keyboard_layout.correct_case(symbol) combination = self.active_mapping.input_combination for key, value in kwargs.items(): setattr(self._active_mapping, key, value) if ( "input_combination" in kwargs and combination != self.active_mapping.input_combination ): self._active_input_config = None self.message_broker.publish( CombinationUpdate(combination, self._active_mapping.input_combination) ) if "mapping_type" in kwargs: # mapping_type must be the last update because it is automatically updated # by a validation function self._active_mapping.mapping_type = kwargs["mapping_type"] self.publish_mapping() def update_input_config(self, new_input_config: InputConfig): """Update the active input configuration. Will send "combination_update", "mapping" and "event" messages to the MessageBroker (in that order) """ if not self.active_mapping or not self.active_input_config: raise DataManagementError("Cannot modify event: Event is not set") combination = list(self.active_mapping.input_combination) combination[combination.index(self.active_input_config)] = new_input_config self.update_mapping(input_combination=InputCombination(combination)) self._active_input_config = new_input_config self.publish_event() def create_mapping(self): """Create empty mapping in the active preset. Will send "preset" message to the MessageBroker """ if not self._active_preset: raise DataManagementError("Cannot create mapping: Preset is not set") self._active_preset.add(UIMapping()) self.publish_preset() def delete_mapping(self): """Delete the active mapping. Will send "preset" message to the MessageBroker """ if not self._active_mapping: raise DataManagementError( "cannot delete active mapping: active mapping is not set" ) self._active_preset.remove(self._active_mapping.input_combination) self._active_mapping = None self.publish_preset() def save(self): """Save the active preset.""" if self._active_preset: self._active_preset.save() def refresh_groups(self): """Refresh the groups (plugged devices). Should send "groups" message to MessageBroker this will not happen immediately because the system might take a bit until the groups are available """ self._reader_client.refresh_groups() def start_combination_recording(self): """Record user input. Will send "combination_recorded" messages as new input arrives. Will eventually send a "recording_finished" message. """ self._reader_client.start_recorder() def stop_combination_recording(self): """Stop recording user input. Will send a recording_finished signal if a recording is running. """ self._reader_client.stop_recorder() def stop_injecting(self) -> None: """Stop injecting for the active group. Will send "injector_state" message once the injector has stopped.""" if not self.active_group: raise DataManagementError("Cannot stop injection: Group is not set") self._daemon.stop_injecting(self.active_group.key) self.do_when_injector_state( {InjectorState.STOPPED}, self.publish_injector_state ) def start_injecting(self) -> bool: """Start injecting the active preset for the active group. returns if the startup was successfully initialized. Will send "injector_state" message once the startup is complete. """ if not self.active_preset or not self.active_group: raise DataManagementError("Cannot start injection: Preset is not set") self._daemon.set_config_dir(self._config.get_dir()) assert self.active_preset.name is not None if self._daemon.start_injecting(self.active_group.key, self.active_preset.name): self.do_when_injector_state( { InjectorState.RUNNING, InjectorState.ERROR, InjectorState.NO_GRAB, InjectorState.UPGRADE_EVDEV, }, self.publish_injector_state, ) return True return False def get_state(self) -> InjectorState: """The state of the injector.""" if not self.active_group: raise DataManagementError("Cannot read state: Group is not set") return self._daemon.get_state(self.active_group.key) def refresh_service_config_path(self): """Tell the service to refresh its config path.""" self._daemon.set_config_dir(self._config.get_dir()) def do_when_injector_state(self, states: Set[InjectorState], callback): """Run callback once the injector state is one of states.""" start = time.time() def do(): if time.time() - start > 3: # something went wrong, there should have been a state long ago. # the timeout prevents tons of GLib.timeouts to run forever, especially # after spamming the "Stop" button. logger.error("Timed out while waiting for injector state %s", states) return False if self.get_state() in states: callback() return False return True GLib.timeout_add(100, do) input-remapper-2.1.1/inputremapper/gui/gettext.py000066400000000000000000000022171475433465200222170ustar00rootroot00000000000000# -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings # Copyright (C) 2025 sezanzeb # # This file is part of input-remapper. # # input-remapper is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # input-remapper is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with input-remapper. If not, see . import gettext import locale import os.path from inputremapper.configs.data import get_data_path APP_NAME = "input-remapper" LOCALE_DIR = os.path.join(get_data_path(), "lang") locale.bindtextdomain(APP_NAME, LOCALE_DIR) locale.textdomain(APP_NAME) translate = gettext.translation(APP_NAME, LOCALE_DIR, fallback=True) _ = translate.gettext input-remapper-2.1.1/inputremapper/gui/messages/000077500000000000000000000000001475433465200217665ustar00rootroot00000000000000input-remapper-2.1.1/inputremapper/gui/messages/__init__.py000066400000000000000000000000001475433465200240650ustar00rootroot00000000000000input-remapper-2.1.1/inputremapper/gui/messages/message_broker.py000066400000000000000000000076741475433465200253460ustar00rootroot00000000000000# -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings # Copyright (C) 2025 sezanzeb # # This file is part of input-remapper. # # input-remapper is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # input-remapper is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with input-remapper. If not, see . import os.path import re import traceback from collections import defaultdict, deque from typing import ( Callable, Dict, Set, Protocol, Tuple, Deque, Any, ) from inputremapper.gui.messages.message_types import MessageType from inputremapper.logging.logger import logger class Message(Protocol): """The protocol any message must follow to be sent with the MessageBroker.""" @property def message_type(self) -> MessageType: ... # useful type aliases MessageListener = Callable[[Any], None] class MessageBroker: shorten_path = re.compile("inputremapper/") def __init__(self): self._listeners: Dict[MessageType, Set[MessageListener]] = defaultdict(set) self._messages: Deque[Tuple[Message, str, int]] = deque() self._publishing = False def publish(self, data: Message): """Schedule a massage to be sent. The message will be sent after all currently pending messages are sent.""" self._messages.append((data, *self.get_caller())) self._publish_all() def signal(self, signal: MessageType): """Send a signal without any data payload.""" # This is different from calling self.publish because self.get_caller() # looks back at the current stack 3 frames self._messages.append((Signal(signal), *self.get_caller())) self._publish_all() def _publish(self, data: Message, file: str, line: int): logger.debug( "from %s:%d: Signal=%s: %s", file, line, data.message_type.name, data ) for listener in self._listeners[data.message_type].copy(): listener(data) def _publish_all(self): """Send all scheduled messages in order.""" if self._publishing: # don't run this twice, so we not mess up the order return self._publishing = True try: while self._messages: self._publish(*self._messages.popleft()) finally: self._publishing = False def subscribe(self, massage_type: MessageType, listener: MessageListener): """Attach a listener to an event.""" logger.debug("adding new Listener for %s: %s", massage_type, listener) self._listeners[massage_type].add(listener) return self @staticmethod def get_caller(position: int = 3) -> Tuple[str, int]: """Extract a file and line from current stack and format for logging.""" tb = traceback.extract_stack(limit=position)[0] return os.path.basename(tb.filename), tb.lineno or 0 def unsubscribe(self, listener: MessageListener) -> None: for listeners in self._listeners.values(): try: listeners.remove(listener) except KeyError: pass class Signal: """Send a Message without any associated data over the MassageBus.""" def __init__(self, message_type: MessageType): self.message_type: MessageType = message_type def __str__(self): return f"Signal: {self.message_type}" def __eq__(self, other: Any): return type(self) == type(other) and self.message_type == other.message_type input-remapper-2.1.1/inputremapper/gui/messages/message_data.py000066400000000000000000000071111475433465200247550ustar00rootroot00000000000000# -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings # Copyright (C) 2025 sezanzeb # # This file is part of input-remapper. # # input-remapper is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # input-remapper is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with input-remapper. If not, see . import re from dataclasses import dataclass from typing import Dict, Tuple, Optional, Callable from inputremapper.configs.input_config import InputCombination from inputremapper.configs.mapping import MappingData from inputremapper.gui.messages.message_types import ( MessageType, Name, Capabilities, Key, DeviceTypes, ) @dataclass(frozen=True) class UInputsData: message_type = MessageType.uinputs uinputs: Dict[Name, Capabilities] def __str__(self): string = f"{self.__class__.__name__}(uinputs={self.uinputs})" # find all sequences of comma+space separated numbers, and shorten them # to the first and last number all_matches = list(re.finditer("(\d+, )+", string)) all_matches.reverse() for match in all_matches: start = match.start() end = match.end() start += string[start:].find(",") + 2 if start == end: continue string = f"{string[:start]}... {string[end:]}" return string @dataclass(frozen=True) class GroupsData: """Message containing all available groups and their device types.""" message_type = MessageType.groups groups: Dict[Key, DeviceTypes] @dataclass(frozen=True) class GroupData: """Message with the active group and available presets for the group.""" message_type = MessageType.group group_key: str presets: Tuple[str, ...] @dataclass(frozen=True) class PresetData: """Message with the active preset name and mapping names/combinations.""" message_type = MessageType.preset name: Optional[Name] mappings: Optional[Tuple[MappingData, ...]] autoload: bool = False @dataclass(frozen=True) class StatusData: """Message with the strings and id for the status bar.""" message_type = MessageType.status_msg ctx_id: int msg: Optional[str] = None tooltip: Optional[str] = None @dataclass(frozen=True) class CombinationRecorded: """Message with the latest recoded combination.""" message_type = MessageType.combination_recorded combination: "InputCombination" @dataclass(frozen=True) class CombinationUpdate: """Message with the old and new combination (hash for a mapping) when it changed.""" message_type = MessageType.combination_update old_combination: "InputCombination" new_combination: "InputCombination" @dataclass(frozen=True) class UserConfirmRequest: """Message for requesting a user response (confirm/cancel) from the gui.""" message_type = MessageType.user_confirm_request msg: str respond: Callable[[bool], None] = lambda _: None @dataclass(frozen=True) class DoStackSwitch: """Command the stack to switch to a different page.""" message_type = MessageType.do_stack_switch page_index: int input-remapper-2.1.1/inputremapper/gui/messages/message_types.py000066400000000000000000000033771475433465200252220ustar00rootroot00000000000000# -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings # Copyright (C) 2025 sezanzeb # # This file is part of input-remapper. # # input-remapper is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # input-remapper is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with input-remapper. If not, see . from enum import Enum from typing import Dict, List from inputremapper.groups import DeviceType # useful type aliases Capabilities = Dict[int, List] Name = str Key = str DeviceTypes = List[DeviceType] class MessageType(Enum): reset_gui = "reset_gui" terminate = "terminate" init = "init" uinputs = "uinputs" groups = "groups" group = "group" preset = "preset" mapping = "mapping" selected_event = "selected_event" combination_recorded = "combination_recorded" # only the reader_client should send those messages: recording_started = "recording_started" recording_finished = "recording_finished" combination_update = "combination_update" status_msg = "status_msg" injector_state = "injector_state" gui_focus_request = "gui_focus_request" user_confirm_request = "user_confirm_request" do_stack_switch = "do_stack_switch" # for unit tests: test1 = "test1" test2 = "test2" input-remapper-2.1.1/inputremapper/gui/reader_client.py000066400000000000000000000241051475433465200233330ustar00rootroot00000000000000# -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings # Copyright (C) 2025 sezanzeb # # This file is part of input-remapper. # # input-remapper is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # input-remapper is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with input-remapper. If not, see . """Talking to the ReaderService that has root permissions. see gui.reader_service.ReaderService """ import time from typing import Optional, List, Generator, Dict, Set import evdev from gi.repository import GLib from inputremapper.configs.input_config import InputCombination from inputremapper.groups import _Groups, _Group from inputremapper.gui.gettext import _ from inputremapper.gui.messages.message_broker import MessageBroker from inputremapper.gui.messages.message_data import ( GroupsData, CombinationRecorded, StatusData, ) from inputremapper.gui.messages.message_types import MessageType from inputremapper.gui.reader_service import ( MSG_EVENT, MSG_GROUPS, CMD_TERMINATE, CMD_REFRESH_GROUPS, CMD_STOP_READING, ReaderService, ) from inputremapper.gui.utils import CTX_ERROR from inputremapper.input_event import InputEvent from inputremapper.ipc.pipe import Pipe from inputremapper.logging.logger import logger BLACKLISTED_EVENTS = [(1, evdev.ecodes.BTN_TOOL_DOUBLETAP)] RecordingGenerator = Generator[None, InputEvent, None] class ReaderClient: """Processes events from the reader-service for the GUI to use. Does not serve any purpose for the injection service. When a button was pressed, the newest keycode can be obtained from this object. GTK has get_key for keyboard keys, but Reader also has knowledge of buttons like the middle-mouse button. """ # how long to wait for the reader-service at most _timeout: int = 5 def __init__(self, message_broker: MessageBroker, groups: _Groups): self.groups = groups self.message_broker = message_broker self.group: Optional[_Group] = None self._recording_generator: Optional[RecordingGenerator] = None self._results_pipe, self._commands_pipe = self.connect() self.attach_to_events() self._read_timeout = GLib.timeout_add(30, self._read) def ensure_reader_service_running(self): if ReaderService.is_running(): return logger.info("ReaderService not running anymore, restarting") ReaderService.pkexec_reader_service() # wait until the ReaderService is up # wait no more than: polling_period = 0.01 # this will make the gui non-responsive for 0.4s or something. The pkexec # password prompt will appear, so the user understands that the lag has to # be connected to the authentication. I would actually prefer the frozen gui # over a reactive one here, because the short lag shows that stuff is going on # behind the scenes. for __ in range(int(self._timeout / polling_period)): if self._results_pipe.poll(): logger.info("ReaderService started") break time.sleep(polling_period) else: msg = "The reader-service did not start" logger.error(msg) self.message_broker.publish(StatusData(CTX_ERROR, _(msg))) def _send_command(self, command: str): """Send a command to the ReaderService.""" if command not in [CMD_TERMINATE, CMD_STOP_READING]: self.ensure_reader_service_running() logger.debug('Sending "%s" to ReaderService', command) self._commands_pipe.send(command) def connect(self): """Connect to the reader-service.""" results_pipe = Pipe(ReaderService.get_pipe_paths()[0]) commands_pipe = Pipe(ReaderService.get_pipe_paths()[1]) return results_pipe, commands_pipe def attach_to_events(self): """Connect listeners to event_reader.""" self.message_broker.subscribe( MessageType.terminate, lambda _: self.terminate(), ) def _read(self): """Read the messages from the reader-service and handle them.""" while self._results_pipe.poll(): message = self._results_pipe.recv() logger.debug("received %s", message) message_type = message["type"] message_body = message["message"] if message_type == MSG_GROUPS: self._update_groups(message_body) if message_type == MSG_EVENT: # update the generator try: if self._recording_generator is not None: self._recording_generator.send(InputEvent(**message_body)) else: # the ReaderService should only send events while the gui # is recording, so this is unexpected. logger.error("Got event, but recorder is not running.") except StopIteration: # the _recording_generator returned logger.debug("Recorder finished.") self.stop_recorder() break return True def start_recorder(self) -> None: """Record user input.""" if self.group is None: logger.error("No group set") return logger.debug("Starting recorder.") self._send_command(self.group.key) self._recording_generator = self._recorder() next(self._recording_generator) self.message_broker.signal(MessageType.recording_started) def stop_recorder(self) -> None: """Stop recording the input. Will send recording_finished signals. """ logger.debug("Stopping recorder.") self._send_command(CMD_STOP_READING) if self._recording_generator: self._recording_generator.close() self._recording_generator = None else: # this would be unexpected. but this is not critical enough to # show to the user without debug logs logger.debug("No recording generator existed") self.message_broker.signal(MessageType.recording_finished) @staticmethod def _input_event_to_config(event: InputEvent): return { "type": event.type, "code": event.code, "analog_threshold": event.value, "origin_hash": event.origin_hash, } def _recorder(self) -> RecordingGenerator: """Generator which receives InputEvents. It accumulates them into EventCombinations and sends those on the message_broker. It will stop once all keys or inputs are released. """ active: Set = set() accumulator: List[InputEvent] = [] while True: event: InputEvent = yield if event.type_and_code in BLACKLISTED_EVENTS: continue if event.value == 0: try: active.remove(event.input_match_hash) except KeyError: # we haven't seen this before probably a key got released which # was pressed before we started recording. ignore it. continue if not active: # all previously recorded events are released return continue active.add(event.input_match_hash) accu_input_hashes = [e.input_match_hash for e in accumulator] if event.input_match_hash in accu_input_hashes and event not in accumulator: # the value has changed but the event is already in the accumulator # update the event i = accu_input_hashes.index(event.input_match_hash) accumulator[i] = event self.message_broker.publish( CombinationRecorded( InputCombination(map(self._input_event_to_config, accumulator)) ) ) if event not in accumulator: accumulator.append(event) self.message_broker.publish( CombinationRecorded( InputCombination(map(self._input_event_to_config, accumulator)) ) ) def set_group(self, group: Optional[_Group]): """Set the group for which input events should be read later.""" # TODO load the active_group from the controller instead? self.group = group def terminate(self): """Stop reading keycodes for good.""" self._send_command(CMD_TERMINATE) self.stop_recorder() if self._read_timeout is not None: GLib.source_remove(self._read_timeout) self._read_timeout = None while self._results_pipe.poll(): self._results_pipe.recv() def refresh_groups(self): """Ask the ReaderService for new device groups.""" self._send_command(CMD_REFRESH_GROUPS) def publish_groups(self): """Announce all known groups.""" groups: Dict[str, List[str]] = { group.key: group.types or [] for group in self.groups.filter(include_inputremapper=False) } self.message_broker.publish(GroupsData(groups)) def _update_groups(self, dump: str): if dump != self.groups.dumps(): self.groups.loads(dump) logger.debug("Received %d devices", len(self.groups)) self._groups_updated = True # send this even if the groups did not change, as the user expects the ui # to respond in some form self.publish_groups() input-remapper-2.1.1/inputremapper/gui/reader_service.py000066400000000000000000000410341475433465200235150ustar00rootroot00000000000000# -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings # Copyright (C) 2023 sezanzeb # # This file is part of input-remapper. # # input-remapper is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # input-remapper is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with input-remapper. If not, see . """Process that sends stuff to the GUI. It should be started via input-remapper-control and pkexec. GUIs should not run as root https://wiki.archlinux.org/index.php/Running_GUI_applications_as_root The service shouldn't do that even though it has root rights, because that would enable key-loggers to just ask input-remapper for all user-input. Instead, the ReaderService is used, which will be stopped when the gui closes. Whereas for the reader-service to start a password is needed and it stops whe the ui closes. This uses the backend injection.event_reader and mapping_handlers to process all the different input-events into simple on/off events and sends them to the gui. """ from __future__ import annotations import asyncio import logging import multiprocessing import os import subprocess import sys import time from collections import defaultdict from typing import Set, List, Tuple import evdev from evdev.ecodes import EV_KEY, EV_ABS, EV_REL, REL_HWHEEL, REL_WHEEL from inputremapper.configs.input_config import InputCombination, InputConfig from inputremapper.configs.mapping import Mapping from inputremapper.groups import _Groups, _Group from inputremapper.injection.event_reader import EventReader from inputremapper.injection.global_uinputs import GlobalUInputs from inputremapper.injection.mapping_handlers.abs_to_btn_handler import AbsToBtnHandler from inputremapper.injection.mapping_handlers.mapping_handler import ( NotifyCallback, InputEventHandler, MappingHandler, ) from inputremapper.injection.mapping_handlers.rel_to_btn_handler import RelToBtnHandler from inputremapper.input_event import InputEvent, EventActions from inputremapper.ipc.pipe import Pipe from inputremapper.logging.logger import logger from inputremapper.user import UserUtils from inputremapper.utils import get_device_hash # received by the reader-service CMD_TERMINATE = "terminate" CMD_STOP_READING = "stop-reading" CMD_REFRESH_GROUPS = "refresh_groups" # sent by the reader-service to the reader MSG_GROUPS = "groups" MSG_EVENT = "event" MSG_STATUS = "status" class ReaderService: """Service that only reads events and is supposed to run as root. Sends device information and keycodes to the GUI. Commands are either numbers for generic commands, or strings to start listening on a specific device. """ # the speed threshold at which relative axis are considered moving # and will be sent as "pressed" to the frontend. # We want to allow some mouse movement before we record it as an input rel_xy_speed = defaultdict(lambda: 3) # wheel events usually don't produce values higher than 1 rel_xy_speed[REL_WHEEL] = 1 rel_xy_speed[REL_HWHEEL] = 1 # Polkit won't ask for another password if the pid stays the same or something, and # if the previous request was no more than 5 minutes ago. see # https://unix.stackexchange.com/a/458260. # If the user does something after 6 minutes they will get a prompt already if the # reader timed out already, which sounds annoying. Instead, I'd rather have the # password prompt appear at most every 15 minutes. _maximum_lifetime: int = 60 * 15 _timeout_tolerance: int = 60 def __init__(self, groups: _Groups, global_uinputs: GlobalUInputs) -> None: """Construct the reader-service and initialize its communication pipes.""" self._start_time = time.time() self.groups = groups self.global_uinputs = global_uinputs self._results_pipe = Pipe(self.get_pipe_paths()[0]) self._commands_pipe = Pipe(self.get_pipe_paths()[1]) self._pipe = multiprocessing.Pipe() self._tasks: Set[asyncio.Task] = set() self._stop_event = asyncio.Event() self._results_pipe.send({"type": MSG_STATUS, "message": "ready"}) @staticmethod def get_pipe_paths() -> Tuple[str, str]: """Get the path where the pipe can be found.""" return ( f"/tmp/input-remapper-{UserUtils.home}/reader-results", f"/tmp/input-remapper-{UserUtils.home}/reader-commands", ) @staticmethod def pipes_exist() -> bool: # Just checking for one of the 4 files (results, commands both read and write) # should be enough I guess. path = f"{ReaderService.get_pipe_paths()[0]}r" # Use os.path.exists, not lexists or islink, because broken links are bad. # New pipes and symlinks need to be made. return os.path.exists(path) @staticmethod def is_running() -> bool: """Check if the reader-service is running.""" try: subprocess.check_output(["pgrep", "-f", "input-remapper-reader-service"]) except subprocess.CalledProcessError: return False return True @staticmethod def pkexec_reader_service(): """Start reader-service via pkexec to run in the background.""" debug = " -d" if logger.level <= logging.DEBUG else "" cmd = f"pkexec input-remapper-control --command start-reader-service{debug}" logger.debug("Running `%s`", cmd) exit_code = os.system(cmd) if exit_code != 0: raise Exception(f"Failed to pkexec the reader-service, code {exit_code}") async def run(self): """Start doing stuff.""" # the reader will check for new commands later, once it is running # it keeps running for one device or another. logger.debug("Discovering initial groups") self.groups.refresh() self._send_groups() await asyncio.gather( self._read_commands(), self._timeout(), self._stop_if_pipes_broken(), ) def _send_groups(self): """Send the groups to the gui.""" logger.debug("Sending groups") self._results_pipe.send({"type": MSG_GROUPS, "message": self.groups.dumps()}) async def _timeout(self): """Stop automatically after some time.""" # Prevents a permanent hole for key-loggers to exist, in case the gui crashes. # If the ReaderService stops even though the gui needs it, it needs to restart # it. This makes it also more comfortable to have debug mode running during # development, because it won't keep writing inputs containing passwords and # such to the terminal forever. await asyncio.sleep(self._maximum_lifetime) # if it is currently reading, wait a bit longer for the gui to complete # what it is doing. if self._is_reading(): logger.debug("Waiting a bit longer for the gui to finish reading") for _ in range(self._timeout_tolerance): if not self._is_reading(): # once reading completes, it should terminate right away break await asyncio.sleep(1) logger.debug("Maximum life-span reached, terminating") sys.exit(1) async def _read_commands(self): """Handle all unread commands. this will run until it receives CMD_TERMINATE """ logger.debug("Waiting for commands") async for cmd in self._commands_pipe: logger.debug('Received command "%s"', cmd) if cmd == CMD_TERMINATE: await self._stop_reading() logger.debug("Terminating") sys.exit(0) if cmd == CMD_REFRESH_GROUPS: self.groups.refresh() self._send_groups() continue if cmd == CMD_STOP_READING: await self._stop_reading() continue group = self.groups.find(key=cmd) if group is None: # this will block for a bit maybe we want to do this async? self.groups.refresh() group = self.groups.find(key=cmd) if group is not None: await self._stop_reading() self._start_reading(group) continue logger.error('Received unknown command "%s"', cmd) async def _stop_if_pipes_broken(self): # The GUI probably exited, and failed to tell the reader-service to stop. # Pipes are owned by the GUI process, because the non-privileged GUI process # needs to be able to read them. Therefore, they are gone. while True: await asyncio.sleep(1) if not self.pipes_exist(): await self._stop_reading() logger.debug("Pipes broken, exiting") sys.exit(13) def _is_reading(self) -> bool: """Check if the ReaderService is currently sending events to the GUI.""" return len(self._tasks) > 0 def _start_reading(self, group: _Group): """Find all devices of that group, filter interesting ones and send the events to the gui.""" sources = [] for path in group.paths: try: device = evdev.InputDevice(path) except (FileNotFoundError, OSError): logger.error('Could not find "%s"', path) return None capabilities = device.capabilities(absinfo=False) if ( EV_KEY in capabilities or EV_ABS in capabilities or EV_REL in capabilities ): sources.append(device) context = self._create_event_pipeline(sources) # create the event reader and start it for device in sources: reader = EventReader(context, device, self._stop_event) self._tasks.add(asyncio.create_task(reader.run())) async def _stop_reading(self): """Stop the running event_reader.""" self._stop_event.set() if self._tasks: await asyncio.gather(*self._tasks) self._tasks = set() self._stop_event.clear() def _create_event_pipeline(self, sources: List[evdev.InputDevice]) -> ContextDummy: """Create a custom event pipeline for each event code in the capabilities. Instead of sending the events to an uinput they will be sent to the frontend. """ context_dummy = ContextDummy() # create a context for each source for device in sources: device_hash = get_device_hash(device) capabilities = device.capabilities(absinfo=False) for ev_code in capabilities.get(EV_KEY) or (): input_config = InputConfig( type=EV_KEY, code=ev_code, origin_hash=device_hash ) context_dummy.add_handler( input_config, ForwardToUIHandler(self._results_pipe) ) for ev_code in capabilities.get(EV_ABS) or (): # positive direction input_config = InputConfig( type=EV_ABS, code=ev_code, analog_threshold=30, origin_hash=device_hash, ) mapping = Mapping( input_combination=InputCombination([input_config]), target_uinput="keyboard", output_symbol="KEY_A", ) handler: MappingHandler = AbsToBtnHandler( InputCombination([input_config]), mapping, self.global_uinputs, ) handler.set_sub_handler(ForwardToUIHandler(self._results_pipe)) context_dummy.add_handler(input_config, handler) # negative direction input_config = input_config.modify(analog_threshold=-30) mapping = Mapping( input_combination=InputCombination([input_config]), target_uinput="keyboard", output_symbol="KEY_A", ) handler = AbsToBtnHandler( InputCombination([input_config]), mapping, self.global_uinputs, ) handler.set_sub_handler(ForwardToUIHandler(self._results_pipe)) context_dummy.add_handler(input_config, handler) for ev_code in capabilities.get(EV_REL) or (): # positive direction input_config = InputConfig( type=EV_REL, code=ev_code, analog_threshold=self.rel_xy_speed[ev_code], origin_hash=device_hash, ) mapping = Mapping( input_combination=InputCombination([input_config]), target_uinput="keyboard", output_symbol="KEY_A", release_timeout=0.3, force_release_timeout=True, ) handler = RelToBtnHandler( InputCombination([input_config]), mapping, self.global_uinputs, ) handler.set_sub_handler(ForwardToUIHandler(self._results_pipe)) context_dummy.add_handler(input_config, handler) # negative direction input_config = input_config.modify( analog_threshold=-self.rel_xy_speed[ev_code] ) mapping = Mapping( input_combination=InputCombination([input_config]), target_uinput="keyboard", output_symbol="KEY_A", release_timeout=0.3, force_release_timeout=True, ) handler = RelToBtnHandler( InputCombination([input_config]), mapping, self.global_uinputs, ) handler.set_sub_handler(ForwardToUIHandler(self._results_pipe)) context_dummy.add_handler(input_config, handler) return context_dummy class ForwardDummy: @staticmethod def write(*_): pass class ContextDummy: """Used for the reader so that no events are actually written to any uinput.""" def __init__(self): self.listeners = set() self._notify_callbacks = defaultdict(list) self.forward_dummy = ForwardDummy() def add_handler(self, input_config: InputConfig, handler: InputEventHandler): self._notify_callbacks[input_config.input_match_hash].append(handler.notify) def get_notify_callbacks(self, input_event: InputEvent) -> List[NotifyCallback]: return self._notify_callbacks[input_event.input_match_hash] def reset(self): pass def get_forward_uinput(self, origin_hash) -> evdev.UInput: """Don't actually write anything.""" return self.forward_dummy class ForwardToUIHandler: """Implements the InputEventHandler protocol. Sends all events into the pipe.""" def __init__(self, pipe: Pipe): self.pipe = pipe self._last_event = InputEvent.from_tuple((99, 99, 99)) def notify( self, event: InputEvent, source: evdev.InputDevice, suppress: bool = False, ) -> bool: """Filter duplicates and send into the pipe.""" if event != self._last_event: self._last_event = event if EventActions.negative_trigger in event.actions: event = event.modify(value=-1) logger.debug("Sending to %s frontend", event) self.pipe.send( { "type": MSG_EVENT, "message": { "sec": event.sec, "usec": event.usec, "type": event.type, "code": event.code, "value": event.value, "origin_hash": event.origin_hash, }, } ) return True def reset(self): pass input-remapper-2.1.1/inputremapper/gui/user_interface.py000066400000000000000000000366731475433465200235460ustar00rootroot00000000000000# -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings # Copyright (C) 2025 sezanzeb # # This file is part of input-remapper. # # input-remapper is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # input-remapper is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with input-remapper. If not, see . """User Interface.""" from typing import Dict, Callable from gi.repository import Gtk, GtkSource, Gdk, GObject from inputremapper.configs.data import get_data_path from inputremapper.configs.input_config import InputCombination from inputremapper.configs.mapping import MappingData from inputremapper.gui.autocompletion import Autocompletion from inputremapper.gui.components.common import Breadcrumbs from inputremapper.gui.components.device_groups import DeviceGroupSelection from inputremapper.gui.components.editor import ( MappingListBox, TargetSelection, CodeEditor, RecordingToggle, RecordingStatus, AutoloadSwitch, ReleaseCombinationSwitch, CombinationListbox, AnalogInputSwitch, TriggerThresholdInput, OutputAxisSelector, ReleaseTimeoutInput, TransformationDrawArea, Sliders, RelativeInputCutoffInput, KeyAxisStackSwitcher, RequireActiveMapping, GdkEventRecorder, ) from inputremapper.gui.components.main import Stack, StatusBar from inputremapper.gui.components.presets import PresetSelection from inputremapper.gui.controller import Controller from inputremapper.gui.gettext import _ from inputremapper.gui.messages.message_broker import ( MessageBroker, MessageType, ) from inputremapper.gui.messages.message_data import UserConfirmRequest from inputremapper.gui.utils import ( gtk_iteration, ) from inputremapper.injection.injector import InjectorStateMessage from inputremapper.logging.logger import logger, COMMIT_HASH, VERSION, EVDEV_VERSION # https://cjenkins.wordpress.com/2012/05/08/use-gtksourceview-widget-in-glade/ GObject.type_register(GtkSource.View) # GtkSource.View() also works: # https://stackoverflow.com/questions/60126579/gtk-builder-error-quark-invalid-object-type-webkitwebview def on_close_about(about, _): """Hide the about dialog without destroying it.""" about.hide() return True class UserInterface: """The input-remapper gtk window.""" def __init__( self, message_broker: MessageBroker, controller: Controller, ): self.message_broker = message_broker self.controller = controller # all shortcuts executed when ctrl+... self.shortcuts: Dict[int, Callable] = { Gdk.KEY_q: self.controller.close, Gdk.KEY_r: self.controller.refresh_groups, Gdk.KEY_Delete: self.controller.stop_injecting, Gdk.KEY_n: self.controller.add_preset, } # stores the ids for all the listeners attached to the gui self.gtk_listeners: Dict[Callable, int] = {} self.message_broker.subscribe(MessageType.terminate, lambda _: self.close()) self.builder = Gtk.Builder() self._build_ui() self.window: Gtk.Window = self.get("window") self.about: Gtk.Window = self.get("about-dialog") self.combination_editor: Gtk.Dialog = self.get("combination-editor") self._create_dialogs() self._create_components() self._connect_gtk_signals() self._connect_message_listener() self.window.show() # hide everything until stuff is populated self.get("vertical-wrapper").set_opacity(0) # if any of the next steps take a bit to complete, have the window # already visible (without content) to make it look more responsive. gtk_iteration() # now show the proper finished content of the window self.get("vertical-wrapper").set_opacity(1) def _build_ui(self): """Build the window from stylesheet and gladefile.""" css_provider = Gtk.CssProvider() with open(get_data_path("style.css"), "r") as file: css_provider.load_from_data(bytes(file.read(), encoding="UTF-8")) Gtk.StyleContext.add_provider_for_screen( Gdk.Screen.get_default(), css_provider, Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION, ) gladefile = get_data_path("input-remapper.glade") self.builder.add_from_file(gladefile) self.builder.connect_signals(self) def _create_components(self): """Setup all objects which manage individual components of the ui.""" message_broker = self.message_broker controller = self.controller DeviceGroupSelection(message_broker, controller, self.get("device_selection")) PresetSelection(message_broker, controller, self.get("preset_selection")) MappingListBox(message_broker, controller, self.get("selection_label_listbox")) TargetSelection(message_broker, controller, self.get("target-selector")) Breadcrumbs( message_broker, self.get("selected_device_name"), show_device_group=True, ) Breadcrumbs( message_broker, self.get("selected_preset_name"), show_device_group=True, show_preset=True, ) Stack(message_broker, controller, self.get("main_stack")) RecordingToggle(message_broker, controller, self.get("key_recording_toggle")) StatusBar( message_broker, controller, self.get("status_bar"), self.get("error_status_icon"), self.get("warning_status_icon"), ) RecordingStatus(message_broker, self.get("recording_status")) AutoloadSwitch(message_broker, controller, self.get("preset_autoload_switch")) ReleaseCombinationSwitch( message_broker, controller, self.get("release-combination-switch") ) CombinationListbox(message_broker, controller, self.get("combination-listbox")) AnalogInputSwitch(message_broker, controller, self.get("analog-input-switch")) TriggerThresholdInput( message_broker, controller, self.get("trigger-threshold-spin-btn") ) RelativeInputCutoffInput( message_broker, controller, self.get("input-cutoff-spin-btn") ) OutputAxisSelector(message_broker, controller, self.get("output-axis-selector")) KeyAxisStackSwitcher( message_broker, controller, self.get("editor-stack"), self.get("key_macro_toggle_btn"), self.get("analog_toggle_btn"), ) ReleaseTimeoutInput( message_broker, controller, self.get("release-timeout-spin-button") ) TransformationDrawArea( message_broker, controller, self.get("transformation-draw-area") ) Sliders( message_broker, controller, self.get("gain-scale"), self.get("deadzone-scale"), self.get("expo-scale"), ) GdkEventRecorder(self.window, self.get("gdk-event-recorder-label")) RequireActiveMapping( message_broker, self.get("edit-combination-btn"), require_recorded_input=True, ) RequireActiveMapping( message_broker, self.get("output"), require_recorded_input=True, ) RequireActiveMapping( message_broker, self.get("delete-mapping"), require_recorded_input=False, ) # code editor and autocompletion code_editor = CodeEditor(message_broker, controller, self.get("code_editor")) autocompletion = Autocompletion(message_broker, controller, code_editor) autocompletion.set_relative_to(self.get("code_editor_container")) self.autocompletion = autocompletion # only for testing def _create_dialogs(self): """Setup different dialogs, such as the about page.""" self.about.connect("delete-event", on_close_about) # set_position needs to be done once initially, otherwise the # dialog is not centered when it is opened for the first time self.about.set_position(Gtk.WindowPosition.CENTER_ON_PARENT) self.get("version-label").set_text( f"input-remapper {VERSION} {COMMIT_HASH[:7]}" f"\npython-evdev {EVDEV_VERSION}" if EVDEV_VERSION else "" ) def _connect_gtk_signals(self): self.get("delete_preset").connect( "clicked", lambda *_: self.controller.delete_preset() ) self.get("copy_preset").connect( "clicked", lambda *_: self.controller.copy_preset() ) self.get("create_preset").connect( "clicked", lambda *_: self.controller.add_preset() ) self.get("apply_preset").connect( "clicked", lambda *_: self.controller.start_injecting() ) self.get("stop_injection_preset_page").connect( "clicked", lambda *_: self.controller.stop_injecting() ) self.get("stop_injection_editor_page").connect( "clicked", lambda *_: self.controller.stop_injecting() ) self.get("rename-button").connect("clicked", self.on_gtk_rename_clicked) self.get("preset_name_input").connect( "key-release-event", self.on_gtk_preset_name_input_return ) self.get("create_mapping_button").connect( "clicked", lambda *_: self.controller.create_mapping() ) self.get("delete-mapping").connect( "clicked", lambda *_: self.controller.delete_mapping() ) self.combination_editor.connect( # it only takes self as argument, but delete-events provides more # probably a gtk bug "delete-event", lambda dialog, *_: Gtk.Widget.hide_on_delete(dialog), ) self.get("edit-combination-btn").connect( "clicked", lambda *_: self.combination_editor.show() ) self.get("remove-event-btn").connect( "clicked", lambda *_: self.controller.remove_event() ) self.connect_shortcuts() def _connect_message_listener(self): self.message_broker.subscribe( MessageType.mapping, self.update_combination_label ) self.message_broker.subscribe( MessageType.injector_state, self.on_injector_state_msg ) self.message_broker.subscribe( MessageType.user_confirm_request, self._on_user_confirm_request ) def _create_dialog(self, primary: str, secondary: str) -> Gtk.MessageDialog: """Create a message dialog with cancel and confirm buttons.""" message_dialog = Gtk.MessageDialog( self.window, Gtk.DialogFlags.MODAL | Gtk.DialogFlags.DESTROY_WITH_PARENT, Gtk.MessageType.QUESTION, Gtk.ButtonsType.NONE, primary, ) if secondary: message_dialog.format_secondary_text(secondary) message_dialog.add_button("Cancel", Gtk.ResponseType.CANCEL) confirm_button = message_dialog.add_button("Confirm", Gtk.ResponseType.ACCEPT) confirm_button.get_style_context().add_class(Gtk.STYLE_CLASS_DESTRUCTIVE_ACTION) return message_dialog def _on_user_confirm_request(self, msg: UserConfirmRequest): # if the message contains a line-break, use the first chunk for the primary # message, and the rest for the secondary message. chunks = msg.msg.split("\n") primary = chunks[0] secondary = " ".join(chunks[1:]) message_dialog = self._create_dialog(primary, secondary) response = message_dialog.run() msg.respond(response == Gtk.ResponseType.ACCEPT) message_dialog.hide() def on_injector_state_msg(self, msg: InjectorStateMessage): """Update the ui to reflect the status of the injector.""" stop_injection_preset_page: Gtk.Button = self.get("stop_injection_preset_page") stop_injection_editor_page: Gtk.Button = self.get("stop_injection_editor_page") recording_toggle: Gtk.ToggleButton = self.get("key_recording_toggle") if msg.active(): stop_injection_preset_page.set_opacity(1) stop_injection_editor_page.set_opacity(1) stop_injection_preset_page.set_sensitive(True) stop_injection_editor_page.set_sensitive(True) recording_toggle.set_opacity(0.5) else: stop_injection_preset_page.set_opacity(0.5) stop_injection_editor_page.set_opacity(0.5) stop_injection_preset_page.set_sensitive(True) stop_injection_editor_page.set_sensitive(True) recording_toggle.set_opacity(1) def disconnect_shortcuts(self): """Stop listening for shortcuts. e.g. when recording key combinations """ try: self.window.disconnect(self.gtk_listeners.pop(self.on_gtk_shortcut)) except KeyError: logger.debug("key listeners seem to be not connected") def connect_shortcuts(self): """Start listening for shortcuts.""" if not self.gtk_listeners.get(self.on_gtk_shortcut): self.gtk_listeners[self.on_gtk_shortcut] = self.window.connect( "key-press-event", self.on_gtk_shortcut ) def get(self, name: str): """Get a widget from the window.""" return self.builder.get_object(name) def close(self): """Close the window.""" logger.debug("Closing window") self.window.hide() def update_combination_label(self, mapping: MappingData): """Listens for mapping and updates the combination label.""" label: Gtk.Label = self.get("combination-label") if mapping.input_combination.beautify() == label.get_label(): return if mapping.input_combination == InputCombination.empty_combination(): label.set_opacity(0.5) label.set_label(_("no input configured")) return label.set_opacity(1) label.set_label(mapping.input_combination.beautify()) def on_gtk_shortcut(self, _, event: Gdk.EventKey): """Execute shortcuts.""" if event.state & Gdk.ModifierType.CONTROL_MASK: try: self.shortcuts[event.keyval]() except KeyError: pass def on_gtk_close(self, *_): self.controller.close() def on_gtk_about_clicked(self, _): """Show the about/help dialog.""" self.about.show() def on_gtk_about_key_press(self, _, event): """Hide the about/help dialog.""" gdk_keycode = event.get_keyval()[1] if gdk_keycode == Gdk.KEY_Escape: self.about.hide() def on_gtk_rename_clicked(self, *_): name = self.get("preset_name_input").get_text() self.controller.rename_preset(name) self.get("preset_name_input").set_text("") def on_gtk_preset_name_input_return(self, _, event: Gdk.EventKey): if event.keyval == Gdk.KEY_Return: self.on_gtk_rename_clicked() input-remapper-2.1.1/inputremapper/gui/utils.py000066400000000000000000000205071475433465200216750ustar00rootroot00000000000000# -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings # Copyright (C) 2025 sezanzeb # # This file is part of input-remapper. # # input-remapper is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # input-remapper is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with input-remapper. If not, see . from __future__ import annotations import time from dataclasses import dataclass from typing import List, Callable, Dict, Optional from gi.repository import Gtk, GLib, Gdk from inputremapper.logging.logger import logger # status ctx ids CTX_SAVE = 0 CTX_APPLY = 1 CTX_KEYCODE = 2 CTX_ERROR = 3 CTX_WARNING = 4 CTX_MAPPING = 5 @dataclass() class DebounceInfo: # constant after register: function: Optional[Callable] other: object key: int # can change when called again: args: list kwargs: dict glib_timeout: Optional[int] class DebounceManager: """Stops all debounced functions if needed.""" debounce_infos: Dict[int, DebounceInfo] = {} def _register(self, other, function): debounce_info = DebounceInfo( function=function, glib_timeout=None, other=other, args=[], kwargs={}, key=self._get_key(other, function), ) key = self._get_key(other, function) self.debounce_infos[key] = debounce_info return debounce_info def get(self, other: object, function: Callable) -> Optional[DebounceInfo]: """Find the debounce_info that matches the given callable.""" key = self._get_key(other, function) return self.debounce_infos.get(key) def _get_key(self, other, function): return f"{id(other)},{function.__name__}" def debounce(self, other, function, timeout_ms, *args, **kwargs): """Call this function with the given args later.""" debounce_info = self.get(other, function) if debounce_info is None: debounce_info = self._register(other, function) debounce_info.args = args debounce_info.kwargs = kwargs glib_timeout = debounce_info.glib_timeout if glib_timeout is not None: GLib.source_remove(glib_timeout) def run(): self.stop(other, function) return function(other, *args, **kwargs) debounce_info.glib_timeout = GLib.timeout_add( timeout_ms, lambda: run(), ) def stop(self, other: object, function: Callable): """Stop the current debounce timeout of this function and don't call it. New calls to that function will be debounced again. """ debounce_info = self.get(other, function) if debounce_info is None: logger.debug("Tried to stop function that is not currently scheduled") return if debounce_info.glib_timeout is not None: GLib.source_remove(debounce_info.glib_timeout) debounce_info.glib_timeout = None def stop_all(self): """No debounced function should be called anymore after this. New calls to that function will be debounced again. """ for debounce_info in self.debounce_infos.values(): self.stop(debounce_info.other, debounce_info.function) def run_all_now(self): """Don't wait any longer.""" for debounce_info in self.debounce_infos.values(): if debounce_info.glib_timeout is None: # nothing is currently waiting for this function to be called continue self.stop(debounce_info.other, debounce_info.function) try: logger.warning( 'Running "%s" now without waiting', debounce_info.function.__name__, ) debounce_info.function( debounce_info.other, *debounce_info.args, **debounce_info.kwargs, ) except Exception as exception: # if individual functions fails, continue calling the others. # also, don't raise this because there is nowhere this exception # could be caught in a useful way logger.error(exception) def debounce(timeout): """Debounce a method call to improve performance. Calling this with a millisecond value creates the decorator, so use something like @debounce(50) def function(self): ... In tests, run_all_now can be used to avoid waiting to speed them up. """ # the outside `debounce` function is needed to obtain the millisecond value def decorator(function): # the regular decorator. # @decorator # def foo(): # ... def wrapped(self, *args, **kwargs): # this is the function that will actually be called debounce_manager.debounce(self, function, timeout, *args, **kwargs) wrapped.__name__ = function.__name__ return wrapped return decorator debounce_manager = DebounceManager() class HandlerDisabled: """Safely modify a widget without causing handlers to be called. Use in a `with` statement. """ def __init__(self, widget: Gtk.Widget, handler: Callable): self.widget = widget self.handler = handler def __enter__(self): try: self.widget.handler_block_by_func(self.handler) except TypeError as error: # if nothing is connected to the given signal, it is not critical # at all logger.warning('HandlerDisabled entry failed: "%s"', error) def __exit__(self, *_): try: self.widget.handler_unblock_by_func(self.handler) except TypeError as error: logger.warning('HandlerDisabled exit failed: "%s"', error) def gtk_iteration(iterations=0): """Iterate while events are pending.""" while Gtk.events_pending(): Gtk.main_iteration() for _ in range(iterations): time.sleep(0.002) while Gtk.events_pending(): Gtk.main_iteration() class Colors: """Looks up colors from the GTK theme. Defaults to libadwaita-light theme colors if the lookup fails. """ fallback_accent = Gdk.RGBA(0.21, 0.52, 0.89, 1) fallback_background = Gdk.RGBA(0.98, 0.98, 0.98, 1) fallback_base = Gdk.RGBA(1, 1, 1, 1) fallback_border = Gdk.RGBA(0.87, 0.87, 0.87, 1) fallback_font = Gdk.RGBA(0.20, 0.20, 0.20, 1) @staticmethod def get_color(names: List[str], fallback: Gdk.RGBA) -> Gdk.RGBA: """Get theme colors. Provide multiple names for fallback purposes.""" for name in names: found, color = Gtk.StyleContext().lookup_color(name) if found: return color return fallback @staticmethod def get_accent_color() -> Gdk.RGBA: """Look up the accent color from the current theme.""" return Colors.get_color( ["accent_bg_color", "theme_selected_bg_color"], Colors.fallback_accent, ) @staticmethod def get_background_color() -> Gdk.RGBA: """Look up the background-color from the current theme.""" return Colors.get_color( ["theme_bg_color"], Colors.fallback_background, ) @staticmethod def get_base_color() -> Gdk.RGBA: """Look up the base-color from the current theme.""" return Colors.get_color( ["theme_base_color"], Colors.fallback_base, ) @staticmethod def get_border_color() -> Gdk.RGBA: """Look up the border from the current theme.""" return Colors.get_color(["borders"], Colors.fallback_border) @staticmethod def get_font_color() -> Gdk.RGBA: """Look up the border from the current theme.""" return Colors.get_color( ["theme_fg_color"], Colors.fallback_font, ) input-remapper-2.1.1/inputremapper/injection/000077500000000000000000000000001475433465200213555ustar00rootroot00000000000000input-remapper-2.1.1/inputremapper/injection/__init__.py000066400000000000000000000000001475433465200234540ustar00rootroot00000000000000input-remapper-2.1.1/inputremapper/injection/context.py000066400000000000000000000114031475433465200234120ustar00rootroot00000000000000# -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings # Copyright (C) 2025 sezanzeb # # This file is part of input-remapper. # # input-remapper is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # input-remapper is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with input-remapper. If not, see . """Stores injection-process wide information.""" from __future__ import annotations from collections import defaultdict from typing import List, Dict, Set, Hashable import evdev from inputremapper.configs.preset import Preset from inputremapper.injection.mapping_handlers.mapping_handler import ( EventListener, NotifyCallback, ) from inputremapper.injection.mapping_handlers.mapping_parser import ( MappingParser, EventPipelines, ) from inputremapper.input_event import InputEvent from inputremapper.logging.logger import logger from inputremapper.utils import DeviceHash class Context: """Stores injection-process wide information. In some ways this is a wrapper for the preset that derives some information that is specifically important to the injection. The information in the context does not change during the injection. One Context exists for each injection process, which is shared with all coroutines and used objects. Benefits of the context: - less redundant passing around of parameters - easier to add new process wide information without having to adjust all function calls in unittests - makes the injection class shorter and more specific to a certain task, which is actually spinning up the injection. Note, that for the reader_service a ContextDummy is used. Members ------- preset : Preset The preset holds all Mappings for the injection process listeners : Set[EventListener] A set of callbacks which receive all events callbacks : Dict[Tuple[int, int], List[NotifyCallback]] All entry points to the event pipeline sorted by InputEvent.type_and_code """ listeners: Set[EventListener] _notify_callbacks: Dict[Hashable, List[NotifyCallback]] _handlers: EventPipelines _forward_devices: Dict[DeviceHash, evdev.UInput] _source_devices: Dict[DeviceHash, evdev.InputDevice] def __init__( self, preset: Preset, source_devices: Dict[DeviceHash, evdev.InputDevice], forward_devices: Dict[DeviceHash, evdev.UInput], mapping_parser: MappingParser, ): if len(forward_devices) == 0: logger.warning("forward_devices not set") if len(source_devices) == 0: logger.warning("source_devices not set") self.listeners = set() self._source_devices = source_devices self._forward_devices = forward_devices self._notify_callbacks = defaultdict(list) self._handlers = mapping_parser.parse_mappings(preset, self) self._create_callbacks() def reset(self) -> None: """Call the reset method for each handler in the context.""" for handlers in self._handlers.values(): for handler in handlers: handler.reset() def _create_callbacks(self) -> None: """Add the notify method from all _handlers to self.callbacks.""" for input_config, handler_list in self._handlers.items(): input_match_hash = input_config.input_match_hash logger.debug("Adding NotifyCallback for %s", input_match_hash) self._notify_callbacks[input_match_hash].extend( handler.notify for handler in handler_list ) def get_notify_callbacks(self, input_event: InputEvent) -> List[NotifyCallback]: input_match_hash = input_event.input_match_hash return self._notify_callbacks[input_match_hash] def get_forward_uinput(self, origin_hash: DeviceHash) -> evdev.UInput: """Get the "forward" uinput events from the given origin should go into.""" return self._forward_devices[origin_hash] def get_source(self, key: DeviceHash) -> evdev.InputDevice: return self._source_devices[key] def get_leds(self) -> Set[int]: """Get a set of LED_* ecodes that are currently on.""" leds = set() for device in self._source_devices.values(): leds.update(device.leds()) return leds input-remapper-2.1.1/inputremapper/injection/event_reader.py000066400000000000000000000163101475433465200243730ustar00rootroot00000000000000# -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings # Copyright (C) 2025 sezanzeb # # This file is part of input-remapper. # # input-remapper is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # input-remapper is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with input-remapper. If not, see . """Because multiple calls to async_read_loop won't work.""" import asyncio import os import traceback from typing import AsyncIterator, Protocol, Set, List import evdev from inputremapper.injection.mapping_handlers.mapping_handler import ( EventListener, NotifyCallback, ) from inputremapper.input_event import InputEvent from inputremapper.logging.logger import logger from inputremapper.utils import get_device_hash, DeviceHash class Context(Protocol): listeners: Set[EventListener] def reset(self): ... def get_notify_callbacks(self, input_event: InputEvent) -> List[NotifyCallback]: ... def get_forward_uinput(self, origin_hash: DeviceHash) -> evdev.UInput: ... class EventReader: """Reads input events from a single device and distributes them. There is one EventReader object for each source, which tells multiple mapping_handlers that a new event is ready so that they can inject all sorts of funny things. Other devnodes may be present for the hardware device, in which case this needs to be created multiple times. """ def __init__( self, context: Context, source: evdev.InputDevice, stop_event: asyncio.Event, ) -> None: """Initialize all mapping_handlers Parameters ---------- source where to read keycodes from """ self._device_hash = get_device_hash(source) self._source = source self.context = context self.stop_event = stop_event def stop(self): """Stop the reader.""" self.stop_event.set() async def read_loop(self) -> AsyncIterator[evdev.InputEvent]: stop_task = asyncio.Task(self.stop_event.wait()) loop = asyncio.get_running_loop() events_ready = asyncio.Event() loop.add_reader(self._source.fileno(), events_ready.set) while True: _, pending = await asyncio.wait( {stop_task, asyncio.Task(events_ready.wait())}, return_when=asyncio.FIRST_COMPLETED, ) fd_broken = os.stat(self._source.fileno()).st_nlink == 0 if fd_broken: # happens when the device is unplugged while reading, causing 100% cpu # usage because events_ready.set is called repeatedly forever, # while read_loop will hang at self._source.read_one(). logger.error("fd broke, was the device unplugged?") if stop_task.done() or fd_broken: for task in pending: task.cancel() loop.remove_reader(self._source.fileno()) logger.debug("read loop stopped") return events_ready.clear() while event := self._source.read_one(): yield event def send_to_handlers(self, event: InputEvent) -> bool: """Send the event to the NotifyCallbacks. Return if anyone took care of the event. """ if event.type == evdev.ecodes.EV_MSC: return False if event.type == evdev.ecodes.EV_SYN: return False handled = False notify_callbacks = self.context.get_notify_callbacks(event) if notify_callbacks: for notify_callback in notify_callbacks: handled = notify_callback(event, source=self._source) | handled return handled async def send_to_listeners(self, event: InputEvent) -> None: """Send the event to listeners.""" if event.type == evdev.ecodes.EV_MSC: return if event.type == evdev.ecodes.EV_SYN: return for listener in self.context.listeners.copy(): # use a copy, since the listeners might remove themselves from the set await listener(event) # Running macros have priority, give them a head-start for processing the # event. If if_single injects a modifier, this modifier should be active # before the next handler injects an "a" or something, so that it is # possible to capitalize it via if_single. # 1. Event from keyboard arrives (e.g. an "a") # 2. the listener for if_single is called # 3. if_single decides runs then (e.g. injects shift_L) # 4. The original event is forwarded (or whatever it is supposed to do) # 5. Capitalized "A" is injected. # So make sure to call the listeners before notifying the handlers. for _ in range(5): await asyncio.sleep(0) def forward(self, event: InputEvent) -> None: """Forward an event, which injects it unmodified.""" forward_to = self.context.get_forward_uinput(self._device_hash) if event.type == evdev.ecodes.EV_KEY: logger.write(event, forward_to) forward_to.write(*event.event_tuple) async def handle(self, event: InputEvent) -> None: if event.type == evdev.ecodes.EV_KEY and event.value == 2: # button-hold event. Environments (gnome, etc.) create them on # their own for the injection-fake-device if the release event # won't appear, no need to forward or map them. return await self.send_to_listeners(event) handled = self.send_to_handlers(event) if not handled: # no handler took care of it, forward it self.forward(event) async def run(self): """Start doing things. Can be stopped by stopping the asyncio loop or by setting the stop_event. This loop reads events from a single device only. """ logger.debug( "Starting to listen for events from %s, fd %s", self._source.path, self._source.fd, ) async for event in self.read_loop(): try: # Fire and forget, so that handlers and listeners can take their time, # if they want to wait for something special to happen. asyncio.ensure_future( self.handle( InputEvent.from_event(event, origin_hash=self._device_hash) ) ) except Exception as e: logger.error("Handling event %s failed with %s", event, type(e)) traceback.print_exception(e) self.context.reset() logger.info("read loop for %s stopped", self._source.path) input-remapper-2.1.1/inputremapper/injection/global_uinputs.py000066400000000000000000000143721475433465200247650ustar00rootroot00000000000000# -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings # Copyright (C) 2025 sezanzeb # # This file is part of input-remapper. # # input-remapper is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # input-remapper is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with input-remapper. If not, see . from typing import Dict, Union, Tuple, Optional, List, Type import evdev import inputremapper.exceptions import inputremapper.utils from inputremapper.logging.logger import logger MIN_ABS = -(2**15) # -32768 MAX_ABS = 2**15 # 32768 DEV_NAME = "input-remapper" DEFAULT_UINPUTS = { # for event codes see linux/input-event-codes.h "keyboard": { evdev.ecodes.EV_KEY: list(evdev.ecodes.KEY.keys() & evdev.ecodes.keys.keys()) }, "gamepad": { evdev.ecodes.EV_KEY: [*range(0x130, 0x13F)], # BTN_SOUTH - BTN_THUMBR evdev.ecodes.EV_ABS: [ *( (i, evdev.AbsInfo(0, MIN_ABS, MAX_ABS, 0, 0, 0)) for i in range(0x00, 0x06) ), *((i, evdev.AbsInfo(0, -1, 1, 0, 0, 0)) for i in range(0x10, 0x12)), ], # 6-axis and 1 hat switch }, "mouse": { evdev.ecodes.EV_KEY: [*range(0x110, 0x118)], # BTN_LEFT - BTN_TASK evdev.ecodes.EV_REL: [*range(0x00, 0x0D)], # all REL axis }, } DEFAULT_UINPUTS["keyboard + mouse"] = { evdev.ecodes.EV_KEY: [ *DEFAULT_UINPUTS["keyboard"][evdev.ecodes.EV_KEY], *DEFAULT_UINPUTS["mouse"][evdev.ecodes.EV_KEY], ], evdev.ecodes.EV_REL: [ *DEFAULT_UINPUTS["mouse"][evdev.ecodes.EV_REL], ], } class UInput(evdev.UInput): _capabilities_cache: Optional[Dict] = None def __init__(self, *args, **kwargs): name = kwargs["name"] logger.debug('creating UInput device: "%s"', name) super().__init__(*args, **kwargs) def can_emit(self, event: Tuple[int, int, int]): """Check if an event can be emitted by the UIinput. Wrong events might be injected if the group mappings are wrong, """ # this will never change, so we cache it since evdev runs an expensive loop to # gather the capabilities. (can_emit is called regularly) if self._capabilities_cache is None: self._capabilities_cache = self.capabilities(absinfo=False) return event[1] in self._capabilities_cache.get(event[0], []) class FrontendUInput: """Uinput which can not actually send events, for use in the frontend.""" def __init__(self, *_, events=None, name="py-evdev-uinput", **__): # see https://python-evdev.readthedocs.io/en/latest/apidoc.html#module-evdev.uinput # noqa pylint: disable=line-too-long self.events = events self.name = name logger.debug('creating fake UInput device: "%s"', self.name) def capabilities(self): return self.events class GlobalUInputs: """Manages all UInputs that are shared between all injection processes.""" def __init__( self, uinput_factory: Union[Type[UInput], Type[FrontendUInput]], ): self.devices: Dict[str, Union[UInput, FrontendUInput]] = {} self._uinput_factory = uinput_factory def __iter__(self): return iter(uinput for _, uinput in self.devices.items()) @staticmethod def can_default_uinput_emit(target: str, type_: int, code: int) -> bool: """Check if the uinput with the target name is capable of the event.""" capabilities = DEFAULT_UINPUTS.get(target, {}).get(type_) return capabilities is not None and code in capabilities @staticmethod def find_fitting_default_uinputs(type_: int, code: int) -> List[str]: """Find the names of default uinputs that are able to emit this event.""" return [ uinput for uinput in DEFAULT_UINPUTS if code in DEFAULT_UINPUTS[uinput].get(type_, []) ] def reset(self): self.devices = {} self.prepare_all() def prepare_all(self): """Generate UInputs.""" for name, events in DEFAULT_UINPUTS.items(): if name in self.devices.keys(): continue self.devices[name] = self._uinput_factory( name=f"{DEV_NAME} {name}", phys=DEV_NAME, events=events, ) def prepare_single(self, name: str): """Generate a single uinput. This has to be done in the main process before injections that use it start. """ if name not in DEFAULT_UINPUTS: raise KeyError("Could not find a matching uinput to generate.") if name in self.devices: logger.debug('Target "%s" already exists', name) return self.devices[name] = self._uinput_factory( name=f"{DEV_NAME} {name}", phys=DEV_NAME, events=DEFAULT_UINPUTS[name], ) def write(self, event: Tuple[int, int, int], target_uinput): """Write event to target uinput.""" uinput = self.get_uinput(target_uinput) if not uinput: raise inputremapper.exceptions.UinputNotAvailable(target_uinput) if not uinput.can_emit(event): raise inputremapper.exceptions.EventNotHandled(event) logger.write(event, uinput) uinput.write(*event) uinput.syn() def get_uinput(self, name: str) -> Optional[evdev.UInput]: """UInput with name Or None if there is no uinput with this name. Parameters ---------- name uniqe name of the uinput device """ if name not in self.devices: logger.error( f'UInput "{name}" is unknown. ' + f"Available: {list(self.devices.keys())}" ) return None return self.devices.get(name) input-remapper-2.1.1/inputremapper/injection/injector.py000066400000000000000000000430251475433465200235500ustar00rootroot00000000000000# -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings # Copyright (C) 2025 sezanzeb # # This file is part of input-remapper. # # input-remapper is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # input-remapper is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with input-remapper. If not, see . """Keeps injecting keycodes in the background based on the preset.""" from __future__ import annotations import asyncio import enum import multiprocessing import sys import time from collections import defaultdict from dataclasses import dataclass from multiprocessing.connection import Connection from typing import Dict, List, Optional, Tuple, Union import evdev from inputremapper.configs.input_config import InputCombination, InputConfig from inputremapper.configs.preset import Preset from inputremapper.groups import ( _Group, classify, DeviceType, ) from inputremapper.gui.messages.message_broker import MessageType from inputremapper.injection.context import Context from inputremapper.injection.event_reader import EventReader from inputremapper.injection.mapping_handlers.mapping_parser import MappingParser from inputremapper.injection.numlock import set_numlock, is_numlock_on, ensure_numlock from inputremapper.logging.logger import logger from inputremapper.utils import get_device_hash, DeviceHash CapabilitiesDict = Dict[int, List[int]] DEV_NAME = "input-remapper" # messages sent to the injector process class InjectorCommand(str, enum.Enum): CLOSE = "CLOSE" # messages the injector process reports back to the service class InjectorState(str, enum.Enum): UNKNOWN = "UNKNOWN" STARTING = "STARTING" ERROR = "FAILED" RUNNING = "RUNNING" STOPPED = "STOPPED" NO_GRAB = "NO_GRAB" UPGRADE_EVDEV = "UPGRADE_EVDEV" def is_in_capabilities( combination: InputCombination, capabilities: CapabilitiesDict ) -> bool: """Are this combination or one of its sub keys in the capabilities?""" for event in combination: if event.code in capabilities.get(event.type, []): return True return False def get_udev_name(name: str, suffix: str) -> str: """Make sure the generated name is not longer than 80 chars.""" max_len = 80 # based on error messages remaining_len = max_len - len(DEV_NAME) - len(suffix) - 2 middle = name[:remaining_len] name = f"{DEV_NAME} {middle} {suffix}" return name @dataclass(frozen=True) class InjectorStateMessage: message_type = MessageType.injector_state state: Union[InjectorState] def active(self) -> bool: return self.state in [InjectorState.RUNNING, InjectorState.STARTING] def inactive(self) -> bool: return self.state in [InjectorState.STOPPED, InjectorState.NO_GRAB] class Injector(multiprocessing.Process): """Initializes, starts and stops injections. Is a process to make it non-blocking for the rest of the code and to make running multiple injector easier. There is one process per hardware-device that is being mapped. """ group: _Group preset: Preset context: Optional[Context] _devices: List[evdev.InputDevice] _state: InjectorState _msg_pipe: Tuple[Connection, Connection] _event_readers: List[EventReader] _stop_event: asyncio.Event regrab_timeout = 0.2 def __init__( self, group: _Group, preset: Preset, mapping_parser: MappingParser, ) -> None: """ Parameters ---------- group the device group """ self.group = group self.mapping_parser = mapping_parser self._state = InjectorState.UNKNOWN # used to interact with the parts of this class that are running within # the new process self._msg_pipe = multiprocessing.Pipe() self.preset = preset self.context = None # only needed inside the injection process self._event_readers = [] super().__init__(name=group.key) """Functions to interact with the running process.""" def get_state(self) -> InjectorState: """Get the state of the injection. Can be safely called from the main process. """ # before we try to we try to guess anything lets check if there is a message state = self._state while self._msg_pipe[1].poll(): state = self._msg_pipe[1].recv() # figure out what is going on step by step alive = self.is_alive() # if `self.start()` has been called started = state != InjectorState.UNKNOWN or alive if started: if state == InjectorState.UNKNOWN and alive: # if it is alive, it is definitely at least starting up. state = InjectorState.STARTING if state in (InjectorState.STARTING, InjectorState.RUNNING) and not alive: # we thought it is running (maybe it was when get_state was previously), # but the process is not alive. It probably crashed state = InjectorState.ERROR logger.error("Injector was unexpectedly found stopped") logger.debug( 'Injector state of "%s", "%s": %s', self.group.key, self.preset.name, state, ) self._state = state return self._state @ensure_numlock def stop_injecting(self) -> None: """Stop injecting keycodes. Can be safely called from the main procss. """ logger.info('Stopping injecting keycodes for group "%s"', self.group.key) self._msg_pipe[1].send(InjectorCommand.CLOSE) """Process internal stuff.""" def _find_input_device( self, input_config: InputConfig ) -> Optional[evdev.InputDevice]: """find the InputDevice specified by the InputConfig ensures the devices supports the type and code specified by the InputConfig""" devices_by_hash = {get_device_hash(device): device for device in self._devices} # mypy thinks None is the wrong type for dict.get() if device := devices_by_hash.get(input_config.origin_hash): # type: ignore if input_config.code in device.capabilities(absinfo=False).get( input_config.type, [] ): return device return None def _find_input_device_fallback( self, input_config: InputConfig ) -> Optional[evdev.InputDevice]: """find the InputDevice specified by the InputConfig fallback logic""" ranking = [ DeviceType.KEYBOARD, DeviceType.GAMEPAD, DeviceType.MOUSE, DeviceType.TOUCHPAD, DeviceType.GRAPHICS_TABLET, DeviceType.CAMERA, DeviceType.UNKNOWN, ] candidates: List[evdev.InputDevice] = [ device for device in self._devices if input_config.code in device.capabilities(absinfo=False).get(input_config.type, []) ] if len(candidates) > 1: # there is more than on input device which can be used for this # event we choose only one determined by the ranking return sorted(candidates, key=lambda d: ranking.index(classify(d)))[0] if len(candidates) == 1: return candidates.pop() logger.error(f"Could not find input for {input_config}") return None def _grab_devices(self) -> Dict[DeviceHash, evdev.InputDevice]: """Grab all InputDevices that match a mappings' origin_hash.""" # use a dict because the InputDevice is not directly hashable needed_devices = {} input_configs = set() # find all unique input_config's for mapping in self.preset: for input_config in mapping.input_combination: input_configs.add(input_config) # find all unique input_device's for input_config in input_configs: if not (device := self._find_input_device(input_config)): # there is no point in trying the fallback because # self._update_preset already did that. continue needed_devices[device.path] = device grabbed_devices = {} for device in needed_devices.values(): if device := self._grab_device(device): grabbed_devices[get_device_hash(device)] = device return grabbed_devices def _update_preset(self): """Update all InputConfigs in the preset to include correct origin_hash information.""" mappings_by_input = defaultdict(list) for mapping in self.preset: for input_config in mapping.input_combination: mappings_by_input[input_config].append(mapping) for input_config in mappings_by_input: if self._find_input_device(input_config): continue if not (device := self._find_input_device_fallback(input_config)): # fallback failed, this mapping will be ignored continue for mapping in mappings_by_input[input_config]: combination: List[InputConfig] = list(mapping.input_combination) device_hash = get_device_hash(device) idx = combination.index(input_config) combination[idx] = combination[idx].modify(origin_hash=device_hash) mapping.input_combination = combination def _grab_device(self, device: evdev.InputDevice) -> Optional[evdev.InputDevice]: """Try to grab the device, return None if not possible. Without grab, original events from it would reach the display server even though they are mapped. """ error = None for attempt in range(10): try: device.grab() logger.debug("Grab %s", device.path) return device except IOError as err: # it might take a little time until the device is free if # it was previously grabbed. error = err logger.debug("Failed attempts to grab %s: %d", device.path, attempt + 1) time.sleep(self.regrab_timeout) logger.error("Cannot grab %s, it is possibly in use", device.path) logger.error(str(error)) return None @staticmethod def _copy_capabilities(input_device: evdev.InputDevice) -> CapabilitiesDict: """Copy capabilities for a new device.""" ecodes = evdev.ecodes # copy the capabilities because the uinput is going # to act like the device. capabilities = input_device.capabilities(absinfo=True) # just like what python-evdev does in from_device if ecodes.EV_SYN in capabilities: del capabilities[ecodes.EV_SYN] if ecodes.EV_FF in capabilities: del capabilities[ecodes.EV_FF] if ecodes.ABS_VOLUME in capabilities.get(ecodes.EV_ABS, []): # For some reason an ABS_VOLUME capability likes to appear # for some users. It prevents mice from moving around and # keyboards from writing symbols capabilities[ecodes.EV_ABS].remove(ecodes.ABS_VOLUME) return capabilities async def _msg_listener(self) -> None: """Wait for messages from the main process to do special stuff.""" loop = asyncio.get_event_loop() while True: frame_available = asyncio.Event() loop.add_reader(self._msg_pipe[0].fileno(), frame_available.set) await frame_available.wait() frame_available.clear() msg = self._msg_pipe[0].recv() if msg == InjectorCommand.CLOSE: await self._close() return async def _close(self): logger.debug("Received close signal") self._stop_event.set() # give the event pipeline some time to reset devices # before shutting the loop down await asyncio.sleep(0.1) # stop the event loop and cause the process to reach its end # cleanly. Using .terminate prevents coverage from working. loop = asyncio.get_event_loop() loop.stop() self._msg_pipe[0].send(InjectorState.STOPPED) def _create_forwarding_device(self, source: evdev.InputDevice) -> evdev.UInput: # copy as much information as possible, because libinput uses the extra # information to enable certain features like "Disable touchpad while # typing" try: forward_to = evdev.UInput( name=get_udev_name(source.name, "forwarded"), events=self._copy_capabilities(source), # phys=source.phys, # this leads to confusion. the appearance of # a uinput with this "phys" property causes the udev rule to # autoload for the original device, overwriting our previous # attempts at starting an injection. vendor=source.info.vendor, product=source.info.product, version=source.info.version, bustype=source.info.bustype, input_props=source.input_props(), ) except TypeError as e: if "input_props" in str(e): # UInput constructor doesn't support input_props and # source.input_props doesn't exist with old python-evdev versions. logger.error("Please upgrade your python-evdev version. Exiting") self._msg_pipe[0].send(InjectorState.UPGRADE_EVDEV) sys.exit(12) raise e return forward_to def run(self) -> None: """The injection worker that keeps injecting until terminated. Stuff is non-blocking by using asyncio in order to do multiple things somewhat concurrently. Use this function as starting point in a process. It creates the loops needed to read and map events and keeps running them. """ logger.info('Starting injecting the preset for "%s"', self.group.key) # create a new event loop, because somehow running an infinite loop # that sleeps on iterations (joystick_to_mouse) in one process causes # another injection process to screw up reading from the grabbed # device. loop = asyncio.new_event_loop() asyncio.set_event_loop(loop) self._devices = self.group.get_devices() # InputConfigs may not contain the origin_hash information, this will try to # make a good guess if the origin_hash information is missing or invalid. self._update_preset() # grab devices as early as possible. If events appear that won't get # released anymore before the grab they appear to be held down forever sources = self._grab_devices() forward_devices = {} for device_hash, device in sources.items(): forward_devices[device_hash] = self._create_forwarding_device(device) # create this within the process after the event loop creation, # so that the macros use the correct loop self.context = Context( self.preset, sources, forward_devices, self.mapping_parser, ) self._stop_event = asyncio.Event() if len(sources) == 0: # maybe the preset was empty or something logger.error("Did not grab any device") self._msg_pipe[0].send(InjectorState.NO_GRAB) return numlock_state = is_numlock_on() coroutines = [] for device_hash in sources: # actually doing things event_reader = EventReader( self.context, sources[device_hash], self._stop_event, ) coroutines.append(event_reader.run()) self._event_readers.append(event_reader) coroutines.append(self._msg_listener()) # set the numlock state to what it was before injecting, because # grabbing devices screws this up set_numlock(numlock_state) self._msg_pipe[0].send(InjectorState.RUNNING) try: loop.run_until_complete(asyncio.gather(*coroutines)) except RuntimeError as error: # the loop might have been stopped via a `CLOSE` message, # which causes the error message below. This is expected behavior if str(error) != "Event loop stopped before Future completed.": raise error except OSError as error: logger.error("Failed to run injector coroutines: %s", str(error)) if len(coroutines) > 0: # expected when stop_injecting is called, # during normal operation as well as tests this point is not # reached otherwise. logger.debug("Injector coroutines ended") for source in sources.values(): # ungrab at the end to make the next injection process not fail # its grabs try: source.ungrab() except OSError as error: # it might have disappeared logger.debug("OSError for ungrab on %s: %s", source.path, str(error)) input-remapper-2.1.1/inputremapper/injection/macros/000077500000000000000000000000001475433465200226415ustar00rootroot00000000000000input-remapper-2.1.1/inputremapper/injection/macros/__init__.py000066400000000000000000000000001475433465200247400ustar00rootroot00000000000000input-remapper-2.1.1/inputremapper/injection/macros/argument.py000066400000000000000000000274131475433465200250440ustar00rootroot00000000000000#!/usr/bin/env python3 # -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings # Copyright (C) 2025 sezanzeb # # This file is part of input-remapper. # # input-remapper is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # input-remapper is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with input-remapper. If not, see . from __future__ import annotations from dataclasses import dataclass from enum import Enum from typing import Optional, Any, Union, List, Literal, Type, TYPE_CHECKING from evdev._ecodes import EV_KEY from inputremapper.configs.keyboard_layout import keyboard_layout from inputremapper.configs.validation_errors import ( MacroError, SymbolNotAvailableInTargetError, ) from inputremapper.injection.global_uinputs import GlobalUInputs from inputremapper.injection.macros.macro import Macro from inputremapper.injection.macros.variable import Variable if TYPE_CHECKING: from inputremapper.injection.macros.raw_value import RawValue from inputremapper.configs.mapping import Mapping class ArgumentFlags(Enum): # No default value is set, and the user has to provide one when using the macro required = "required" # If used, acts like foo(*bar) spread = "spread" @dataclass class ArgumentConfig: """Definition what kind of arguments a task may take.""" position: Union[int, Literal[ArgumentFlags.spread]] name: str types: List[Optional[Type]] is_symbol: bool = False default: Any = ArgumentFlags.required # If True, then the value (which should be a string), is the name of a non-constant # variable. Tasks that overwrite their value need this, like `set`. The specified # types are those that the current value of that variable may have. For `set` this # doesn't matter, but something like `add` requires them to be numbers. is_variable_name: bool = False def is_required(self) -> bool: return self.default == ArgumentFlags.required def is_spread(self): """Does this Argument store all remaining Variables of a Task as a list?""" return self.position == ArgumentFlags.spread class Argument(ArgumentConfig): """Validation of variables and access to their value for Tasks during runtime.""" _variable: Optional[Variable] = None # If the position is set to ArgumentFlags.spread, then _variables will be filled # with all remaining positional arguments that were passed to a task. _variables: List[Variable] _mapping: Optional[Mapping] = None def __init__( self, argument_config: ArgumentConfig, mapping: Mapping, ) -> None: # If a default of None is specified, but None is not an allowed type, then # input-remapper has a bug here. Add "None" to your ArgumentConfig.types assert not ( argument_config.default is None and None not in argument_config.types ) self.position = argument_config.position self.name = argument_config.name self.types = argument_config.types self.is_symbol = argument_config.is_symbol self.default = argument_config.default self.is_variable_name = argument_config.is_variable_name self._mapping = mapping self._variables = [] def initialize_variables(self, raw_values: List[RawValue]) -> None: """If the macro is supposed to contain multiple variables, set them. Should be done during parsing.""" assert len(self._variables) == 0 assert self._variable is None assert self.is_spread() for raw_value in raw_values: variable = self._parse_raw_value(raw_value) self._variables.append(variable) def initialize_variable(self, raw_value: RawValue) -> None: """Set the Arguments Variable. Done during parsing.""" assert len(self._variables) == 0 assert self._variable is None assert not self.is_spread() variable = self._parse_raw_value(raw_value) self._variable = variable def initialize_default(self) -> None: """Set the Arguments to its default value. Done during parsing.""" assert len(self._variables) == 0 assert self._variable is None assert not self.is_spread() variable = Variable(value=self.default, const=True) self._variable = variable def get_value(self) -> Any: """To ask for the current value of the variable during runtime.""" assert not self.is_spread(), f"Use .{self.get_values.__name__}()" # If a user passed None as value, it should be a Variable(None, const=True) here. # If not, a test or input-remapper is broken. assert self._variable is not None value = self._variable.get_value() if not self._variable.const: # Dynamic value. Hasn't been validated yet value = self._validate_dynamic_value(self._variable) return value def get_values(self) -> List[Any]: """To ask for the current values of the variables during runtime.""" assert self.is_spread(), f"Use .{self.get_value.__name__}()" values = [] for variable in self._variables: if not variable.const: values.append(self._validate_dynamic_value(variable)) else: values.append(variable.get_value()) return values def get_variable_name(self) -> str: """If the variable is not const, return its name.""" assert self._variable is not None return self._variable.get_name() def contains_macro(self) -> bool: """Does the underlying Variable contain another child-macro?""" assert self._variable is not None return isinstance(self._variable.get_value(), Macro) def set_value(self, value: Any) -> Any: """To set the value of the underlying Variable during runtime. Fails for constants.""" assert self._variable is not None if self._variable.const: raise Exception("Can't set value of a constant") self._variable.set_value(value) def assert_is_symbol(self, symbol: str) -> None: """Checks if the key/symbol-name is valid. Like "KEY_A" or "escape". Using `is_symbol` on the ArgumentConfig is prefered, which causes it to automatically do this for you. But some macros may be a bit more flexible, and there we want to assert this ourselves only in certain cases.""" symbol = str(symbol) code = keyboard_layout.get(symbol) if code is None: raise MacroError(msg=f'Unknown key "{symbol}"') if self._mapping is not None: target = self._mapping.target_uinput if target is not None and not GlobalUInputs.can_default_uinput_emit( target, EV_KEY, code ): raise SymbolNotAvailableInTargetError(symbol, target) def _parse_raw_value(self, raw_value: RawValue) -> Variable: """Validate and parse.""" value = raw_value.value # The order of steps below matters. if isinstance(value, Macro): return Variable(value=value, const=True) if self.is_variable_name: # Treat this as a non-constant variable, # even without a `$` in front of its name if value.startswith('"'): # Remove quotes from the string value = value[1:-1] return Variable(value=value, const=False) if value.startswith("$"): # Will be resolved during the macros runtime return Variable(value=value[1:], const=False) if self.is_symbol: if value.startswith('"'): value = value[1:-1] self.assert_is_symbol(value) return Variable(value=value, const=True) if (value == "" or value == "None") and None in self.types: # I think "" is the deprecated alternative to "None" return Variable(value=None, const=True) if value.startswith('"') and str in self.types: # Something with explicit quotes should never be parsed as a number. # Treat it as a string no matter the content. value = value[1:-1] return Variable(value=value, const=True) if float in self.types and "." in value: try: return Variable(value=float(value), const=True) except (ValueError, TypeError) as e: pass if int in self.types: try: return Variable(value=int(value), const=True) except (ValueError, TypeError) as e: pass if not value.startswith('"') and ("(" in value or ")" in value): # Looks like something that should have been a macro. It is not explicitly # wrapped in quotes. Most likely an error. If it was a valid macro, the # parser would have parsed it as such. raise MacroError( msg=f"A broken macro was passed as parameter to {self.name}" ) if str in self.types: # Treat as a string. Something like KEY_A in key(KEY_A) return Variable(value=value, const=True) raise self._type_error_factory(value) def _validate_dynamic_value(self, variable: Variable) -> Any: """To make sure the value of a non-const variable, asked for at runtime, is fitting for the given ArgumentConfig.""" # Most of the stuff has already been taken care of when, for example, # the "1" of set(foo, 1), or the '"bar"' or set(foo, "bar") was parsed the # first time. In the first case we get a number 1, and in the second a string # `bar` without quotes assert not variable.const value = variable.get_value() if self.is_symbol: # value might be int `1`, which is a valid symbol for `key(1)` value = str(value) self.assert_is_symbol(value) return value if None in self.types and value is None: return value if type(value) in self.types: return value if type(value) not in self.types and str in self.types: # `set` cannot make predictions where the variable will be used. Make sure # the type is compatible, and turn numbers back into strings if need be. return str(value) # If the value is "1", we don't attempt to parse it as a number. This being a # string means that something like `set(foo, "1")` was used, which enforces a # string datatype. Otherwise, `set` would have already turned it into an int. raise self._type_error_factory(value) def _is_numeric_string(self, value: str) -> bool: """Check if the value can be turned into a number.""" try: float(value) return True except ValueError: return False def _type_error_factory(self, value: Any) -> MacroError: formatted_types: List[str] = [] for type_ in self.types: if type_ is None: formatted_types.append("None") else: formatted_types.append(type_.__name__) return MacroError( msg=( f'Expected "{self.name}" to be one of {formatted_types}, but got ' f'{type(value).__name__} "{value}"' ) ) input-remapper-2.1.1/inputremapper/injection/macros/macro.py000066400000000000000000000074661475433465200243310ustar00rootroot00000000000000# -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings # Copyright (C) 2025 sezanzeb # # This file is part of input-remapper. # # input-remapper is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # input-remapper is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with input-remapper. If not, see . """Executes more complex patterns of keystrokes.""" from __future__ import annotations import asyncio from typing import List, Callable, Optional, TYPE_CHECKING from inputremapper.ipc.shared_dict import SharedDict from inputremapper.logging.logger import logger if TYPE_CHECKING: from inputremapper.injection.macros.task import Task from inputremapper.injection.context import Context from inputremapper.configs.mapping import Mapping InjectEventCallback = Callable[[int, int, int], None] macro_variables = SharedDict() class Macro: """Chains tasks (like `modify` or `repeat`). Tasks may have child_macros. Running a Macro runs Tasks, which in turn may run their child_macros based on certain conditions (depending on the Task). """ def __init__( self, code: Optional[str], context: Optional[Context] = None, mapping: Optional[Mapping] = None, ): """Create a macro instance that can be populated with tasks. Parameters ---------- code The original parsed code, for logging purposes. context : Context mapping : UIMapping """ self.code = code self.context = context self.mapping = mapping # List of coroutines that will be called sequentially. # This is the compiled code self.tasks: List[Task] = [] self.running = False self.keystroke_sleep_ms = None async def run(self, callback: InjectEventCallback): """Run the macro. Parameters ---------- callback Will receive int type, code and value for an event to write """ if not callable(callback): raise ValueError("handler is not callable") if self.running: logger.error('Tried to run already running macro "%s"', self.code) return self.keystroke_sleep_ms = self.mapping.macro_key_sleep_ms self.running = True try: for task in self.tasks: coroutine = task.run(callback) if asyncio.iscoroutine(coroutine): await coroutine except Exception: raise finally: # done self.running = False def press_trigger(self): """The user pressed the trigger key down.""" for task in self.tasks: task.press_trigger() def release_trigger(self): """The user released the trigger key.""" for task in self.tasks: task.release_trigger() async def _keycode_pause(self, _=None): """To add a pause between keystrokes. This was needed at some point because it appeared that injecting keys too fast will prevent them from working. It probably depends on the environment. """ await asyncio.sleep(self.keystroke_sleep_ms / 1000) def __repr__(self): return f'' def add_task(self, task): self.tasks.append(task) input-remapper-2.1.1/inputremapper/injection/macros/parse.py000066400000000000000000000377101475433465200243350ustar00rootroot00000000000000# -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings # Copyright (C) 2025 sezanzeb # # This file is part of input-remapper. # # input-remapper is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # input-remapper is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with input-remapper. If not, see . """Parse macro code""" from __future__ import annotations import re from typing import Optional, Any, Type, TYPE_CHECKING, Dict, List from inputremapper.configs.validation_errors import MacroError from inputremapper.injection.macros.macro import Macro from inputremapper.injection.macros.raw_value import RawValue from inputremapper.injection.macros.task import Task from inputremapper.injection.macros.tasks.add import AddTask from inputremapper.injection.macros.tasks.event import EventTask from inputremapper.injection.macros.tasks.hold import HoldTask from inputremapper.injection.macros.tasks.hold_keys import HoldKeysTask from inputremapper.injection.macros.tasks.if_eq import IfEqTask from inputremapper.injection.macros.tasks.if_led import IfNumlockTask, IfCapslockTask from inputremapper.injection.macros.tasks.if_single import IfSingleTask from inputremapper.injection.macros.tasks.if_tap import IfTapTask from inputremapper.injection.macros.tasks.ifeq import DeprecatedIfEqTask from inputremapper.injection.macros.tasks.key import KeyTask from inputremapper.injection.macros.tasks.key_down import KeyDownTask from inputremapper.injection.macros.tasks.key_up import KeyUpTask from inputremapper.injection.macros.tasks.mod_tap import ModTapTask from inputremapper.injection.macros.tasks.modify import ModifyTask from inputremapper.injection.macros.tasks.mouse import MouseTask from inputremapper.injection.macros.tasks.parallel import ParallelTask from inputremapper.injection.macros.tasks.mouse_xy import MouseXYTask from inputremapper.injection.macros.tasks.repeat import RepeatTask from inputremapper.injection.macros.tasks.set import SetTask from inputremapper.injection.macros.tasks.wait import WaitTask from inputremapper.injection.macros.tasks.wheel import WheelTask from inputremapper.logging.logger import logger if TYPE_CHECKING: from inputremapper.injection.context import Context from inputremapper.configs.mapping import Mapping class Parser: TASK_CLASSES: dict[str, type[Task]] = { "modify": ModifyTask, "repeat": RepeatTask, "key": KeyTask, "key_down": KeyDownTask, "key_up": KeyUpTask, "event": EventTask, "wait": WaitTask, "hold": HoldTask, "hold_keys": HoldKeysTask, "mouse": MouseTask, "mouse_xy": MouseXYTask, "wheel": WheelTask, "if_eq": IfEqTask, "if_numlock": IfNumlockTask, "if_capslock": IfCapslockTask, "set": SetTask, "if_tap": IfTapTask, "if_single": IfSingleTask, "add": AddTask, "mod_tap": ModTapTask, "parallel": ParallelTask, # Those are only kept for backwards compatibility with old macros. The space for # writing macro was very constrained in the past, so shorthands were introduced: "m": ModifyTask, "r": RepeatTask, "k": KeyTask, "e": EventTask, "w": WaitTask, "h": HoldTask, # It was not possible to adjust ifeq to support variables without breaking old # macros, so this function is deprecated and if_eq introduced. Kept for backwards # compatibility: "ifeq": DeprecatedIfEqTask, } @staticmethod def is_this_a_macro(output: Any): """Figure out if this is a macro.""" if not isinstance(output, str): return False if "+" in output.strip(): # for example "a + b" return True return "(" in output and ")" in output and len(output) >= 4 @staticmethod def _extract_args(inner: str): """Extract parameters from the inner contents of a call. This does not parse them. Parameters ---------- inner for example '1, r, r(2, k(a))' should result in ['1', 'r', 'r(2, k(a))'] """ inner = inner.strip() brackets = 0 params = [] start = 0 string = False for position, char in enumerate(inner): # ignore anything between string quotes if char == '"': string = not string if string: continue # ignore commas inside child macros if char == "(": brackets += 1 if char == ")": brackets -= 1 if char == "," and brackets == 0: # , potentially starts another parameter, but only if # the current brackets are all closed. params.append(inner[start:position].strip()) # skip the comma start = position + 1 # one last parameter params.append(inner[start:].strip()) return params @staticmethod def _count_brackets(macro): """Find where the first opening bracket closes.""" openings = macro.count("(") closings = macro.count(")") if openings != closings: raise MacroError( macro, f"Found {openings} opening and {closings} closing brackets" ) brackets = 0 position = 0 for char in macro: position += 1 if char == "(": brackets += 1 continue if char == ")": brackets -= 1 if brackets == 0: # the closing bracket of the call break return position @staticmethod def _split_keyword_arg(param): """Split "foo=bar" into "foo" and "bar". If not a keyward param, return None and the param. """ if re.match(r"[a-zA-Z_][a-zA-Z_\d]*=.+", param): split = param.split("=", 1) return split[0], split[1] return None, param @staticmethod def _validate_keyword_argument_names( keyword_args: Dict[str, Any], task_class: Type[Task], ) -> None: for keyword_arg in keyword_args: for argument in task_class.argument_configs: if argument.name == keyword_arg: break else: raise MacroError(msg=f"Unknown keyword argument {keyword_arg}") @staticmethod def _parse_recurse( code: str, context: Optional[Context], mapping: Mapping, verbose: bool, macro_instance: Optional[Macro] = None, depth: int = 0, ) -> RawValue: """Handle a subset of the macro, e.g. one parameter or function call. Not using eval for security reasons. Parameters ---------- code Just like parse. A single parameter or the complete macro as string. Comments and redundant whitespace characters are expected to be removed already. Example: - "parallel(key(a),key(b).key($foo))" - "key(a)" - "a" - "key(b).key($foo)" - "b" - "key($foo)" - "$foo" context : Context macro_instance A macro instance to add tasks to. This is the output of the parser, and is organized like a tree. depth For logging porposes """ assert isinstance(code, str) assert isinstance(depth, int) def debug(*args, **kwargs): if verbose: logger.debug(*args, **kwargs) space = " " * depth code = code.strip() # is it another macro? task_call_match = re.match(r"^(\w+)\(", code) task_name = task_call_match[1] if task_call_match else None if task_name is None: # It is probably either a key name like KEY_A or a variable name as in `set(var,1)`, # both won't contain special characters that can break macro syntax so they don't # have to be wrapped in quotes. The argument configuration of the tasks will # detemrine how to parse it. debug("%svalue %s", space, code) return RawValue(value=code) if macro_instance is None: # start a new chain macro_instance = Macro(code, context, mapping) else: # chain this call to the existing instance assert isinstance(macro_instance, Macro) task_class = Parser.TASK_CLASSES.get(task_name) if task_class is None: raise MacroError(code, f"Unknown function {task_name}") # get all the stuff inbetween closing_bracket_position = Parser._count_brackets(code) - 1 inner = code[code.index("(") + 1 : closing_bracket_position] debug("%scalls %s with %s", space, task_name, inner) # split "3, foo=a(2, k(a).w(10))" into arguments raw_string_args = Parser._extract_args(inner) # parse and sort the params positional_args: List[RawValue] = [] keyword_args: Dict[str, RawValue] = {} for param in raw_string_args: key, value = Parser._split_keyword_arg(param) parsed = Parser._parse_recurse( value.strip(), context, mapping, verbose, None, depth + 1, ) if key is None: if len(keyword_args) > 0: msg = f'Positional argument "{key}" follows keyword argument' raise MacroError(code, msg) positional_args.append(parsed) else: if key in keyword_args: raise MacroError(code, f'The "{key}" argument was specified twice') keyword_args[key] = parsed debug( "%sadd call to %s with %s, %s", space, task_name, positional_args, keyword_args, ) Parser._validate_keyword_argument_names( keyword_args, task_class, ) Parser._validate_num_args( code, task_name, task_class, raw_string_args, ) try: task = task_class( positional_args, keyword_args, context, mapping, ) macro_instance.add_task(task) except TypeError as exception: raise MacroError(msg=str(exception)) from exception # is after this another call? Chain it to the macro_instance more_code_exists = len(code) > closing_bracket_position + 1 if more_code_exists: next_char = code[closing_bracket_position + 1] statement_closed = next_char == "." if statement_closed: # skip over the ")." chain = code[closing_bracket_position + 2 :] debug("%sfollowed by %s", space, chain) Parser._parse_recurse( chain, context, mapping, verbose, macro_instance, depth, ) elif re.match(r"[a-zA-Z_]", next_char): # something like foo()bar raise MacroError( code, f'Expected a "." to follow after ' f"{code[:closing_bracket_position + 1]}", ) return RawValue(value=macro_instance) @staticmethod def _validate_num_args( code: str, task_name: str, task_class: Type[Task], raw_string_args: List[str], ) -> None: min_args, max_args = task_class.get_num_parameters() num_provided_args = len(raw_string_args) if num_provided_args < min_args or num_provided_args > max_args: if min_args != max_args: msg = ( f"{task_name} takes between {min_args} and {max_args}, " f"not {num_provided_args} parameters" ) else: msg = ( f"{task_name} takes {min_args}, not {num_provided_args} parameters" ) raise MacroError(code, msg) @staticmethod def handle_plus_syntax(macro): """Transform a + b + c to hold_keys(a,b,c).""" if "+" not in macro: return macro if "(" in macro or ")" in macro: raise MacroError(macro, f'Mixing "+" and macros is unsupported: "{ macro}"') chunks = [chunk.strip() for chunk in macro.split("+")] if "" in chunks: raise MacroError(f'Invalid syntax for "{macro}"') output = f"hold_keys({','.join(chunks)})" logger.debug('Transformed "%s" to "%s"', macro, output) return output @staticmethod def remove_whitespaces(macro, delimiter='"'): """Remove whitespaces, tabs, newlines and such outside of string quotes.""" result = "" for i, chunk in enumerate(macro.split(delimiter)): # every second chunk is inside string quotes if i % 2 == 0: result += re.sub(r"\s", "", chunk) else: result += chunk result += delimiter # one extra delimiter was added return result[: -len(delimiter)] @staticmethod def remove_comments(macro): """Remove comments from the macro and return the resulting code.""" # keep hashtags inside quotes intact result = "" for i, line in enumerate(macro.split("\n")): for j, chunk in enumerate(line.split('"')): if j > 0: # add back the string quote chunk = f'"{chunk}' # every second chunk is inside string quotes if j % 2 == 0 and "#" in chunk: # everything from now on is a comment and can be ignored result += chunk.split("#")[0] break else: result += chunk if i < macro.count("\n"): result += "\n" return result @staticmethod def clean(code): """Remove everything irrelevant for the macro.""" return Parser.remove_whitespaces( Parser.remove_comments(code), '"', ) @staticmethod def parse(macro: str, context=None, mapping=None, verbose: bool = True) -> Macro: """Parse and generate a Macro that can be run as often as you want. Parameters ---------- macro "repeat(3, key(a).wait(10))" "repeat(2, key(a).key(KEY_A)).key(b)" "wait(1000).modify(Shift_L, repeat(2, k(a))).wait(10, 20).key(b)" context : Context, or None for use in Frontend mapping the mapping for the macro, or None for use in Frontend verbose log the parsing True by default """ # TODO pass mapping in frontend and do the target check for keys? logger.debug("parsing macro %s", macro.replace("\n", "")) macro = Parser.clean(macro) macro = Parser.handle_plus_syntax(macro) macro_obj = Parser._parse_recurse( macro, context, mapping, verbose, ).value if not isinstance(macro_obj, Macro): raise MacroError(macro, "The provided code was not a macro") return macro_obj input-remapper-2.1.1/inputremapper/injection/macros/raw_value.py000066400000000000000000000023101475433465200251740ustar00rootroot00000000000000#!/usr/bin/env python3 # -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings # Copyright (C) 2025 sezanzeb # # This file is part of input-remapper. # # input-remapper is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # input-remapper is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with input-remapper. If not, see . from dataclasses import dataclass from typing import Union from inputremapper.injection.macros.macro import Macro @dataclass class RawValue: """Store a value exactly as it is in the macro-code. Values still have to be validated according to the ArgumentConfig of the Tasks. Child-macros are passed as Macro objects though. """ value: Union[str, Macro] input-remapper-2.1.1/inputremapper/injection/macros/task.py000066400000000000000000000210211475433465200241510ustar00rootroot00000000000000#!/usr/bin/env python3 # -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings # Copyright (C) 2025 sezanzeb # # This file is part of input-remapper. # # input-remapper is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # input-remapper is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with input-remapper. If not, see . from __future__ import annotations import asyncio from itertools import chain from typing import List, Dict, TYPE_CHECKING, Optional, Tuple, Union from inputremapper.configs.validation_errors import MacroError from inputremapper.injection.macros.argument import ( Argument, ArgumentConfig, ArgumentFlags, ) from inputremapper.injection.macros.macro import Macro, InjectEventCallback from inputremapper.logging.logger import logger if TYPE_CHECKING: from inputremapper.injection.mapping_handlers.mapping_handler import EventListener from inputremapper.injection.macros.raw_value import RawValue from inputremapper.injection.context import Context from inputremapper.configs.mapping import Mapping class Task: """Base Class for functions like `if_eq` or `key` A macro like `key(a).key(b)` will contain two instances of this class. """ argument_configs: List[ArgumentConfig] arguments: Dict[str, Argument] mapping: Mapping # The context is None during frontend-parsing/validation I believe context: Optional[Context] child_macros: List[Macro] def __init__( self, positional_args: List[RawValue], keyword_args: Dict[str, RawValue], context: Optional[Context], mapping: Mapping, ) -> None: self.context = context self.mapping = mapping self.child_macros = [] self._validate_argument_configs() self.arguments = { argument_config.name: Argument(argument_config, mapping) for argument_config in self.argument_configs } self._setup_asyncio_events() for argument in self.arguments.values(): self._initialize_argument(argument, keyword_args, positional_args) self._initialize_spread_arg(positional_args) for raw_value in chain(keyword_args.values(), positional_args): if isinstance(raw_value.value, Macro): self.child_macros.append(raw_value.value) async def run(self, callback: InjectEventCallback) -> None: """Macro logic goes here. Call the callback with the type, code and value that should be injected. """ raise NotImplementedError() def add_event_listener(self, listener: EventListener) -> None: """Listeners get each event from the source device. After all listeners are done, the event will go into the mapping handlers. Make sure to remove your event_listener once you are done. """ # The context will be there when the macro is parsed by the service assert self.context is not None self.context.listeners.add(listener) def remove_event_listener(self, listener: EventListener) -> None: assert self.context is not None self.context.listeners.remove(listener) @classmethod def get_macro_argument_names(cls): return [argument_config.name for argument_config in cls.argument_configs] @classmethod def get_num_parameters(cls) -> Tuple[int, Union[int, float]]: """Get the number of required parameters and the maximum number of parameters.""" min_num_args = 0 argument_configs = cls.argument_configs max_num_args: Union[int, float] = len(argument_configs) for argument_config in argument_configs: if argument_config.position == ArgumentFlags.spread: # 0 or more max_num_args = float("inf") continue if argument_config.is_required(): min_num_args += 1 return min_num_args, max_num_args def get_argument(self, argument_name) -> Argument: return self.arguments[argument_name] def press_trigger(self) -> None: """The user pressed the trigger key down.""" for macro in self.child_macros: macro.press_trigger() if self.is_holding(): logger.error("Already holding") return self._trigger_release_event.clear() self._trigger_press_event.set() def release_trigger(self) -> None: """The user released the trigger key.""" if not self.is_holding(): return self._trigger_release_event.set() self._trigger_press_event.clear() for macro in self.child_macros: macro.release_trigger() def is_holding(self) -> bool: """Check if the macro is waiting for a key to be released.""" return not self._trigger_release_event.is_set() async def keycode_pause(self, _=None) -> None: """To add a pause between keystrokes. This was needed at some point because it appeared that injecting keys too fast will prevent them from working. It probably depends on the environment. """ await asyncio.sleep(self.mapping.macro_key_sleep_ms / 1000) def _initialize_spread_arg( self, positional_args: List[RawValue], ) -> None: """Put all positional arguments that aren't used into the spread argument.""" spread_argument: Optional[Argument] = None for argument in self.arguments.values(): if argument.position == ArgumentFlags.spread: spread_argument = argument break if spread_argument is None: return remaining_positional_args = [*positional_args] for argument in self.arguments.values(): if argument.position != ArgumentFlags.spread and argument.position < len( remaining_positional_args ): del remaining_positional_args[argument.position] spread_argument.initialize_variables(remaining_positional_args) def _find_argument_by_position(self, position: int) -> Optional[Argument]: for argument in self.arguments.values(): if argument.position == position: return argument return None def _setup_asyncio_events(self) -> None: # Can be used to wait for the press and release of the input event/key, that is # configured as the trigger of the macro, via asyncio. self._trigger_release_event = asyncio.Event() self._trigger_press_event = asyncio.Event() # released by default self._trigger_release_event.set() self._trigger_press_event.clear() def _initialize_argument( self, argument: Argument, keyword_args: Dict[str, RawValue], positional_args: List[RawValue], ) -> None: if argument.position == ArgumentFlags.spread: # Will get all the remaining positional arguments afterward. return for name, value in keyword_args.items(): if argument.name == name: argument.initialize_variable(value) return if argument.position < len(positional_args): argument.initialize_variable(positional_args[argument.position]) return if not argument.is_required(): argument.initialize_default() return # This shouldn't be possible, the parser should have ensured things are valid # already. raise MacroError(f"Could not initialize argument {argument.name}") def _validate_argument_configs(self): # Might help during development positions = set() names = set() for argument_config in self.argument_configs: position = argument_config.position if position in positions: raise MacroError(f"Duplicate position {positions} in ArgumentConfig") positions.add(position) name = argument_config.name if name in names: raise MacroError(f"Duplicate name {name} in ArgumentConfig") names.add(name) input-remapper-2.1.1/inputremapper/injection/macros/tasks/000077500000000000000000000000001475433465200237665ustar00rootroot00000000000000input-remapper-2.1.1/inputremapper/injection/macros/tasks/__init__.py000066400000000000000000000000001475433465200260650ustar00rootroot00000000000000input-remapper-2.1.1/inputremapper/injection/macros/tasks/add.py000066400000000000000000000044141475433465200250730ustar00rootroot00000000000000#!/usr/bin/env python3 # -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings # Copyright (C) 2025 sezanzeb # # This file is part of input-remapper. # # input-remapper is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # input-remapper is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with input-remapper. If not, see . from __future__ import annotations from inputremapper.configs.validation_errors import MacroError from inputremapper.injection.macros.argument import ArgumentConfig from inputremapper.injection.macros.task import Task from inputremapper.logging.logger import logger class AddTask(Task): """Add a number to a variable.""" argument_configs = [ ArgumentConfig( name="variable", position=0, types=[int, float, None], is_variable_name=True, ), ArgumentConfig( name="value", position=1, types=[int, float], ), ] async def run(self, callback) -> None: argument = self.get_argument("variable") try: current = argument.get_value() except MacroError: return if current is None: logger.debug( '"%s" initialized with 0', self.arguments["variable"].get_variable_name(), ) argument.set_value(0) current = 0 addend = self.get_argument("value").get_value() if not isinstance(current, (int, float)): logger.error( 'Expected variable "%s" to contain a number, but got "%s"', argument.get_value(), current, ) return logger.debug("%s += %s", current, addend) argument.set_value(current + addend) input-remapper-2.1.1/inputremapper/injection/macros/tasks/event.py000066400000000000000000000036171475433465200254700ustar00rootroot00000000000000#!/usr/bin/env python3 # -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings # Copyright (C) 2025 sezanzeb # # This file is part of input-remapper. # # input-remapper is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # input-remapper is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with input-remapper. If not, see . from __future__ import annotations from evdev.ecodes import ecodes from inputremapper.injection.macros.argument import ArgumentConfig from inputremapper.injection.macros.task import Task class EventTask(Task): """Write any event. For example event(EV_KEY, KEY_A, 1) """ argument_configs = [ ArgumentConfig( name="type", position=0, types=[str, int], ), ArgumentConfig( name="code", position=1, types=[str, int], ), ArgumentConfig( name="value", position=2, types=[int], ), ] async def run(self, callback) -> None: type_ = self.get_argument("type").get_value() code = self.get_argument("code").get_value() value = self.get_argument("value").get_value() if isinstance(type_, str): type_ = ecodes[type_.upper()] if isinstance(code, str): code = ecodes[code.upper()] callback(type_, code, value) await self.keycode_pause() input-remapper-2.1.1/inputremapper/injection/macros/tasks/hold.py000066400000000000000000000045541475433465200252760ustar00rootroot00000000000000#!/usr/bin/env python3 # -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings # Copyright (C) 2025 sezanzeb # # This file is part of input-remapper. # # input-remapper is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # input-remapper is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with input-remapper. If not, see . from __future__ import annotations import asyncio from evdev.ecodes import EV_KEY from inputremapper.configs.keyboard_layout import keyboard_layout from inputremapper.injection.macros.argument import ArgumentConfig from inputremapper.injection.macros.macro import Macro from inputremapper.injection.macros.task import Task class HoldTask(Task): """Loop the macro until the trigger-key is released.""" argument_configs = [ ArgumentConfig( name="macro", position=0, types=[Macro, str, None], ) ] async def run(self, callback) -> None: macro = self.get_argument("macro").get_value() if macro is None: await self._trigger_release_event.wait() return if isinstance(macro, str): # if macro is a key name, hold down the key while the # keyboard key is physically held down symbol = macro self.get_argument("macro").assert_is_symbol(symbol) code = keyboard_layout.get(symbol) callback(EV_KEY, code, 1) await self._trigger_release_event.wait() callback(EV_KEY, code, 0) if isinstance(macro, Macro): # repeat the macro forever while the key is held down while self.is_holding(): # run the child macro completely to avoid # not-releasing any key await macro.run(callback) # give some other code a chance to run await asyncio.sleep(1 / 1000) input-remapper-2.1.1/inputremapper/injection/macros/tasks/hold_keys.py000066400000000000000000000034641475433465200263300ustar00rootroot00000000000000#!/usr/bin/env python3 # -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings # Copyright (C) 2025 sezanzeb # # This file is part of input-remapper. # # input-remapper is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # input-remapper is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with input-remapper. If not, see . from __future__ import annotations from evdev.ecodes import EV_KEY from inputremapper.configs.keyboard_layout import keyboard_layout from inputremapper.injection.macros.argument import ArgumentConfig, ArgumentFlags from inputremapper.injection.macros.task import Task class HoldKeysTask(Task): """Hold down multiple keys, equivalent to `a + b + c + ...`.""" argument_configs = [ ArgumentConfig( name="*symbols", position=ArgumentFlags.spread, types=[str], is_symbol=True, ) ] async def run(self, callback) -> None: symbols = self.get_argument("*symbols").get_values() codes = [keyboard_layout.get(symbol) for symbol in symbols] for code in codes: callback(EV_KEY, code, 1) await self.keycode_pause() await self._trigger_release_event.wait() for code in codes[::-1]: callback(EV_KEY, code, 0) await self.keycode_pause() input-remapper-2.1.1/inputremapper/injection/macros/tasks/if_eq.py000066400000000000000000000041131475433465200254220ustar00rootroot00000000000000#!/usr/bin/env python3 # -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings # Copyright (C) 2025 sezanzeb # # This file is part of input-remapper. # # input-remapper is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # input-remapper is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with input-remapper. If not, see . from __future__ import annotations from inputremapper.injection.macros.argument import ArgumentConfig from inputremapper.injection.macros.macro import Macro from inputremapper.injection.macros.task import Task class IfEqTask(Task): """Compare two values.""" argument_configs = [ ArgumentConfig( name="value_1", position=0, types=[str, int, float], ), ArgumentConfig( name="value_2", position=1, types=[str, int, float], ), ArgumentConfig( name="then", position=2, types=[Macro, None], default=None, ), ArgumentConfig( name="else", position=3, types=[Macro, None], default=None, ), ] async def run(self, callback) -> None: value_1 = self.get_argument("value_1").get_value() value_2 = self.get_argument("value_2").get_value() then = self.get_argument("then").get_value() else_ = self.get_argument("else").get_value() if value_1 == value_2: if then is not None: await then.run(callback) elif else_ is not None: await else_.run(callback) input-remapper-2.1.1/inputremapper/injection/macros/tasks/if_led.py000066400000000000000000000040471475433465200255670ustar00rootroot00000000000000#!/usr/bin/env python3 # -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings # Copyright (C) 2025 sezanzeb # # This file is part of input-remapper. # # input-remapper is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # input-remapper is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with input-remapper. If not, see . from __future__ import annotations from evdev.ecodes import ( LED_NUML, LED_CAPSL, ) from inputremapper.injection.macros.argument import ArgumentConfig from inputremapper.injection.macros.macro import Macro from inputremapper.injection.macros.task import Task class IfLedTask(Task): argument_configs = [ ArgumentConfig( name="then", position=0, types=[Macro, None], default=None, ), ArgumentConfig( name="else", position=1, types=[Macro, None], default=None, ), ] led_code = None async def run(self, callback) -> None: then = self.get_argument("then").get_value() else_ = self.get_argument("else").get_value() # self.context is only None when the frontend is parsing the macro assert self.context is not None led_on = self.led_code in self.context.get_leds() if led_on: if then is not None: await then.run(callback) elif else_ is not None: await else_.run(callback) class IfNumlockTask(IfLedTask): led_code = LED_NUML class IfCapslockTask(IfLedTask): led_code = LED_CAPSL input-remapper-2.1.1/inputremapper/injection/macros/tasks/if_single.py000066400000000000000000000060321475433465200263000ustar00rootroot00000000000000#!/usr/bin/env python3 # -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings # Copyright (C) 2025 sezanzeb # # This file is part of input-remapper. # # input-remapper is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # input-remapper is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with input-remapper. If not, see . from __future__ import annotations import asyncio from evdev.ecodes import EV_KEY from inputremapper.injection.macros.argument import ArgumentConfig from inputremapper.injection.macros.macro import Macro from inputremapper.injection.macros.task import Task class IfSingleTask(Task): """If a key was pressed without combining it.""" argument_configs = [ ArgumentConfig( name="then", position=0, types=[Macro, None], ), ArgumentConfig( name="else", position=1, types=[Macro, None], ), ArgumentConfig( name="timeout", position=2, types=[int, float, None], default=None, ), ] async def run(self, callback) -> None: assert self.context is not None another_key_pressed_event = asyncio.Event() then = self.get_argument("then").get_value() else_ = self.get_argument("else").get_value() async def listener(event) -> None: if event.type != EV_KEY: # Ignore anything that is not a key return if event.value == 1: # Another key was pressed another_key_pressed_event.set() return self.add_event_listener(listener) timeout = self.get_argument("timeout").get_value() # Wait for anything of importance to happen, that would determine the # outcome of the if_single macro. await asyncio.wait( [ asyncio.Task(another_key_pressed_event.wait()), asyncio.Task(self._trigger_release_event.wait()), ], timeout=timeout / 1000 if timeout else None, return_when=asyncio.FIRST_COMPLETED, ) self.remove_event_listener(listener) if not self.is_holding(): if then: await then.run(callback) else: # If the trigger has not been released, then `await asyncio.wait` above # could only have finished waiting due to a timeout, or because another # key was pressed. if else_: await else_.run(callback) input-remapper-2.1.1/inputremapper/injection/macros/tasks/if_tap.py000066400000000000000000000047731475433465200256150ustar00rootroot00000000000000#!/usr/bin/env python3 # -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings # Copyright (C) 2025 sezanzeb # # This file is part of input-remapper. # # input-remapper is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # input-remapper is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with input-remapper. If not, see . from __future__ import annotations import asyncio from inputremapper.injection.macros.argument import ArgumentConfig from inputremapper.injection.macros.macro import Macro from inputremapper.injection.macros.task import Task class IfTapTask(Task): """If a key was pressed quickly. macro key pressed -> if_tap starts -> key released -> then macro key pressed -> released (does other stuff in the meantime) -> if_tap starts -> pressed -> released -> then """ argument_configs = [ ArgumentConfig( name="then", position=0, types=[Macro, None], default=None, ), ArgumentConfig( name="else", position=1, types=[Macro, None], default=None, ), ArgumentConfig( name="timeout", position=2, types=[int, float], default=300, ), ] async def run(self, callback) -> None: then = self.get_argument("then").get_value() else_ = self.get_argument("else").get_value() timeout = self.get_argument("timeout").get_value() / 1000 try: await asyncio.wait_for(self._wait(), timeout) if then: await then.run(callback) except asyncio.TimeoutError: if else_: await else_.run(callback) async def _wait(self): """Wait for a release, or if nothing pressed yet, a press and release.""" if self.is_holding(): await self._trigger_release_event.wait() else: await self._trigger_press_event.wait() await self._trigger_release_event.wait() input-remapper-2.1.1/inputremapper/injection/macros/tasks/ifeq.py000066400000000000000000000047161475433465200252740ustar00rootroot00000000000000#!/usr/bin/env python3 # -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings # Copyright (C) 2025 sezanzeb # # This file is part of input-remapper. # # input-remapper is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # input-remapper is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with input-remapper. If not, see . from __future__ import annotations from inputremapper.injection.macros.argument import ArgumentConfig from inputremapper.injection.macros.macro import Macro from inputremapper.injection.macros.task import Task class DeprecatedIfEqTask(Task): """Old version of if_eq, kept for compatibility reasons. This can't support a comparison like ifeq("foo", $blub) with blub containing "foo" without breaking old functionality, because "foo" is treated as a variable name. """ argument_configs = [ ArgumentConfig( name="variable", position=0, types=[str, float, int, None], is_variable_name=True, ), ArgumentConfig( name="value", position=1, types=[str, float, int, None], ), ArgumentConfig( name="then", position=2, types=[Macro, None], ), ArgumentConfig( name="else", position=3, types=[Macro, None], ), ] async def run(self, callback) -> None: actual_value = self.get_argument("variable").get_value() value = self.get_argument("value").get_value() then = self.get_argument("then").get_value() else_ = self.get_argument("else").get_value() # The old ifeq function became somewhat incompatible with the new macro code. # I need to compare them as strings to keep this working. if str(actual_value) == str(value): if then is not None: await then.run(callback) elif else_ is not None: await else_.run(callback) input-remapper-2.1.1/inputremapper/injection/macros/tasks/key.py000066400000000000000000000031061475433465200251300ustar00rootroot00000000000000#!/usr/bin/env python3 # -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings # Copyright (C) 2025 sezanzeb # # This file is part of input-remapper. # # input-remapper is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # input-remapper is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with input-remapper. If not, see . from __future__ import annotations from evdev.ecodes import EV_KEY from inputremapper.configs.keyboard_layout import keyboard_layout from inputremapper.injection.macros.argument import ArgumentConfig from inputremapper.injection.macros.task import Task class KeyTask(Task): """Write the symbol.""" argument_configs = [ ArgumentConfig( name="symbol", position=0, types=[str], is_symbol=True, ) ] async def run(self, callback) -> None: symbol = self.get_argument("symbol").get_value() code = keyboard_layout.get(symbol) callback(EV_KEY, code, 1) await self.keycode_pause() callback(EV_KEY, code, 0) await self.keycode_pause() input-remapper-2.1.1/inputremapper/injection/macros/tasks/key_down.py000066400000000000000000000027411475433465200261630ustar00rootroot00000000000000#!/usr/bin/env python3 # -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings # Copyright (C) 2025 sezanzeb # # This file is part of input-remapper. # # input-remapper is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # input-remapper is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with input-remapper. If not, see . from __future__ import annotations from evdev.ecodes import EV_KEY from inputremapper.configs.keyboard_layout import keyboard_layout from inputremapper.injection.macros.argument import ArgumentConfig from inputremapper.injection.macros.task import Task class KeyDownTask(Task): """Press the symbol.""" argument_configs = [ ArgumentConfig( name="symbol", position=0, types=[str], is_symbol=True, ) ] async def run(self, callback) -> None: symbol = self.get_argument("symbol").get_value() code = keyboard_layout.get(symbol) callback(EV_KEY, code, 1) input-remapper-2.1.1/inputremapper/injection/macros/tasks/key_up.py000066400000000000000000000027411475433465200256400ustar00rootroot00000000000000#!/usr/bin/env python3 # -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings # Copyright (C) 2025 sezanzeb # # This file is part of input-remapper. # # input-remapper is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # input-remapper is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with input-remapper. If not, see . from __future__ import annotations from evdev.ecodes import EV_KEY from inputremapper.configs.keyboard_layout import keyboard_layout from inputremapper.injection.macros.argument import ArgumentConfig from inputremapper.injection.macros.task import Task class KeyUpTask(Task): """Release the symbol.""" argument_configs = [ ArgumentConfig( name="symbol", position=0, types=[str], is_symbol=True, ) ] async def run(self, callback) -> None: symbol = self.get_argument("symbol").get_value() code = keyboard_layout.get(symbol) callback(EV_KEY, code, 0) input-remapper-2.1.1/inputremapper/injection/macros/tasks/mod_tap.py000066400000000000000000000125041475433465200257650ustar00rootroot00000000000000#!/usr/bin/env python3 # -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings # Copyright (C) 2025 sezanzeb # # This file is part of input-remapper. # # input-remapper is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # input-remapper is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with input-remapper. If not, see . from __future__ import annotations import asyncio from collections import deque from typing import Deque from evdev.ecodes import EV_KEY from inputremapper.configs.keyboard_layout import keyboard_layout from inputremapper.injection.macros.argument import ArgumentConfig from inputremapper.injection.macros.task import Task from inputremapper.input_event import InputEvent from inputremapper.logging.logger import logger class ModTapTask(Task): """If pressed long enough in combination with other keys, it turns into a modifier. Can be used to make home-row-modifiers. Works similar to the default of https://github.com/qmk/qmk_firmware/blob/78a0adfbb4d2c4e12f93f2a62ded0020d406243e/docs/tap_hold.md#comparison-comparison """ argument_configs = [ ArgumentConfig( name="default", position=0, types=[str], is_symbol=True, ), ArgumentConfig( name="modifier", position=1, types=[str], is_symbol=True, ), ArgumentConfig( name="tapping_term", position=2, types=[int, float], default=200, ), ] async def run(self, callback) -> None: tapping_term = self.get_argument("tapping_term").get_value() / 1000 jamming_asyncio_events: Deque[asyncio.Event] = deque() async def listener(event: InputEvent) -> None: trigger = self.mapping.input_combination[-1] if event.type_and_code == trigger.type_and_code: # We don't block the event that would set _trigger_release_event. return if event.type != EV_KEY: return asyncio_event = asyncio.Event() jamming_asyncio_events.append(asyncio_event) # Make the EventReader wait until the mod_tap macro allows it to continue # processing the event. Because we want to wait until mod_tap injected the # modifier. await asyncio_event.wait() self.add_event_listener(listener) timeout = asyncio.Task(asyncio.sleep(tapping_term)) await asyncio.wait( [asyncio.Task(self._trigger_release_event.wait()), timeout], return_when=asyncio.FIRST_COMPLETED, ) has_timed_out = timeout.done() if has_timed_out: # The timeout happened before the trigger got released. # We therefore modify stuff. symbol = self.get_argument("modifier").get_value() logger.debug("Modifying with %s", symbol) else: # The trigger got released before the timeout. # We therefore do not modify stuff. symbol = self.get_argument("default").get_value() logger.debug("Writing default %s", symbol) code = keyboard_layout.get(symbol) callback(EV_KEY, code, 1) await self.keycode_pause() # Now that we know if the key was pressed with the intention of modifying other # keys, we can let the jammed keys go on their journey through the handlers. # Those other handlers may map them to other keys and stuff. while len(jamming_asyncio_events) > 0: asyncio_event = jamming_asyncio_events.popleft() asyncio_event.set() await self.keycode_pause() await self.throttle() # While we are emptying the queue, more events might still arrive and add # to the queue. # We remove this as late as possible, because if more keys are pressed while # jamming_asyncio_events is still being taken care of, they should wait until # all is done. This ensures the order of all events that are pressed, until # mod_tap is completely finished. self.remove_event_listener(listener) # Keep the modifier pressed until the input/trigger is released await self._trigger_release_event.wait() callback(EV_KEY, code, 0) await self.keycode_pause() async def throttle(self) -> None: # In case the keycode_pause ist set to 0ms, we need to give the event handlers # a chance to inject the withheld events, before we go on. This ensures the # correct order of injections. Since we are using asyncio, something like # `callback(EV_KEY, code, 0)` might be faster than the event handlers, even if # it is the last step of the macro. if self.mapping.macro_key_sleep_ms == 0: await asyncio.sleep(0.01) input-remapper-2.1.1/inputremapper/injection/macros/tasks/modify.py000066400000000000000000000035501475433465200256320ustar00rootroot00000000000000#!/usr/bin/env python3 # -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings # Copyright (C) 2025 sezanzeb # # This file is part of input-remapper. # # input-remapper is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # input-remapper is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with input-remapper. If not, see . from __future__ import annotations from evdev.ecodes import EV_KEY from inputremapper.configs.keyboard_layout import keyboard_layout from inputremapper.injection.macros.argument import ArgumentConfig from inputremapper.injection.macros.macro import Macro from inputremapper.injection.macros.task import Task class ModifyTask(Task): """Do stuff while a modifier is activated.""" argument_configs = [ ArgumentConfig( name="modifier", position=0, types=[str], is_symbol=True, ), ArgumentConfig( name="macro", position=1, types=[Macro], ), ] async def run(self, callback) -> None: modifier = self.get_argument("modifier").get_value() code = keyboard_layout.get(modifier) macro = self.get_argument("macro").get_value() callback(EV_KEY, code, 1) await self.keycode_pause() await macro.run(callback) callback(EV_KEY, code, 0) await self.keycode_pause() input-remapper-2.1.1/inputremapper/injection/macros/tasks/mouse.py000066400000000000000000000042001475433465200254640ustar00rootroot00000000000000#!/usr/bin/env python3 # -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings # Copyright (C) 2025 sezanzeb # # This file is part of input-remapper. # # input-remapper is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # input-remapper is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with input-remapper. If not, see . from __future__ import annotations from evdev._ecodes import REL_Y, REL_X from inputremapper.injection.macros.argument import ArgumentConfig from inputremapper.injection.macros.macro import InjectEventCallback from inputremapper.injection.macros.tasks.mouse_xy import MouseXYTask class MouseTask(MouseXYTask): """Move the mouse cursor.""" argument_configs = [ ArgumentConfig( name="direction", position=0, types=[str], ), ArgumentConfig( name="speed", position=1, types=[int, float], ), ArgumentConfig( name="acceleration", position=2, types=[int, float], default=1, ), ] async def run(self, callback: InjectEventCallback) -> None: direction = self.get_argument("direction").get_value() speed = self.get_argument("speed").get_value() acceleration = self.get_argument("acceleration").get_value() code, direction = { "up": (REL_Y, -1), "down": (REL_Y, 1), "left": (REL_X, -1), "right": (REL_X, 1), }[direction.lower()] await self.axis( code, direction * speed, acceleration, callback, ) input-remapper-2.1.1/inputremapper/injection/macros/tasks/mouse_xy.py000066400000000000000000000066431475433465200262210ustar00rootroot00000000000000#!/usr/bin/env python3 # -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings # Copyright (C) 2025 sezanzeb # # This file is part of input-remapper. # # input-remapper is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # input-remapper is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with input-remapper. If not, see . from __future__ import annotations import asyncio from typing import Union from evdev._ecodes import REL_Y, REL_X from evdev.ecodes import EV_REL from inputremapper.injection.macros.argument import ArgumentConfig from inputremapper.injection.macros.macro import InjectEventCallback from inputremapper.injection.macros.task import Task from inputremapper.injection.macros.tasks.util import precise_iteration_frequency class MouseXYTask(Task): """Move the mouse cursor.""" argument_configs = [ ArgumentConfig( name="x", position=0, types=[int, float], default=0, ), ArgumentConfig( name="y", position=1, types=[int, float], default=0, ), ArgumentConfig( name="acceleration", position=2, types=[int, float], default=1, ), ] async def run(self, callback: InjectEventCallback) -> None: x = self.get_argument("x").get_value() y = self.get_argument("y").get_value() acceleration = self.get_argument("acceleration").get_value() await asyncio.gather( self.axis(REL_X, x, acceleration, callback), self.axis(REL_Y, y, acceleration, callback), ) async def axis( self, code: int, speed: Union[int, float], fractional_acceleration: Union[int, float], callback: InjectEventCallback, ) -> None: acceleration = speed * fractional_acceleration direction = -1 if speed < 0 else 1 current_speed = 0.0 displacement_accumulator = 0.0 displacement = 0 if acceleration <= 0: displacement = int(speed) async for _ in precise_iteration_frequency(self.mapping.rel_rate): if not self.is_holding(): return # Cursors can only move by integers. To get smooth acceleration for # small acceleration values, the cursor needs to move by a pixel every # few iterations. This can be achieved by remembering the decimal # places that were cast away, and using them for the next iteration. if acceleration: current_speed += acceleration current_speed = direction * min(abs(current_speed), abs(speed)) displacement_accumulator += current_speed displacement = int(displacement_accumulator) displacement_accumulator -= displacement if displacement != 0: callback(EV_REL, code, displacement) input-remapper-2.1.1/inputremapper/injection/macros/tasks/parallel.py000066400000000000000000000031201475433465200261300ustar00rootroot00000000000000#!/usr/bin/env python3 # -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings # Copyright (C) 2025 sezanzeb # # This file is part of input-remapper. # # input-remapper is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # input-remapper is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with input-remapper. If not, see . from __future__ import annotations import asyncio from typing import List from inputremapper.injection.macros.argument import ArgumentConfig, ArgumentFlags from inputremapper.injection.macros.macro import Macro, InjectEventCallback from inputremapper.injection.macros.task import Task class ParallelTask(Task): """Run all provided macros in parallel.""" argument_configs = [ ArgumentConfig( name="*macros", position=ArgumentFlags.spread, types=[Macro], ), ] async def run(self, callback: InjectEventCallback) -> None: macros: List[Macro] = self.get_argument("*macros").get_values() coroutines = [macro.run(callback) for macro in macros] await asyncio.gather(*coroutines) input-remapper-2.1.1/inputremapper/injection/macros/tasks/repeat.py000066400000000000000000000030731475433465200256230ustar00rootroot00000000000000#!/usr/bin/env python3 # -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings # Copyright (C) 2025 sezanzeb # # This file is part of input-remapper. # # input-remapper is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # input-remapper is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with input-remapper. If not, see . from __future__ import annotations from inputremapper.injection.macros.argument import ArgumentConfig from inputremapper.injection.macros.macro import Macro from inputremapper.injection.macros.task import Task class RepeatTask(Task): """Repeat macros.""" argument_configs = [ ArgumentConfig( name="repeats", position=0, types=[int], ), ArgumentConfig( name="macro", position=1, types=[Macro], ), ] async def run(self, callback) -> None: repeats = self.get_argument("repeats").get_value() macro = self.get_argument("macro").get_value() for _ in range(repeats): await macro.run(callback) input-remapper-2.1.1/inputremapper/injection/macros/tasks/set.py000066400000000000000000000032211475433465200251310ustar00rootroot00000000000000#!/usr/bin/env python3 # -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings # Copyright (C) 2025 sezanzeb # # This file is part of input-remapper. # # input-remapper is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # input-remapper is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with input-remapper. If not, see . from __future__ import annotations from inputremapper.injection.macros.argument import ArgumentConfig from inputremapper.injection.macros.macro import macro_variables from inputremapper.injection.macros.task import Task class SetTask(Task): """Set a variable to a certain value.""" argument_configs = [ ArgumentConfig( name="variable", position=0, types=[str, float, int, None], is_variable_name=True, ), ArgumentConfig( name="value", position=1, types=[str, float, int, None], default=None, ), ] async def run(self, callback) -> None: value = self.get_argument("value").get_value() assert macro_variables.is_alive() self.arguments["variable"].set_value(value) input-remapper-2.1.1/inputremapper/injection/macros/tasks/util.py000066400000000000000000000027521475433465200253230ustar00rootroot00000000000000#!/usr/bin/env python3 # -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings # Copyright (C) 2025 sezanzeb # # This file is part of input-remapper. # # input-remapper is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # input-remapper is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with input-remapper. If not, see . from __future__ import annotations import asyncio import time from typing import AsyncIterator async def precise_iteration_frequency(frequency: float) -> AsyncIterator[None]: """A generator to iterate over in a fixed frequency. asyncio.sleep might end up sleeping too long, for whatever reason. Maybe there are other async function calls that take longer than expected in the background. """ sleep = 1 / frequency corrected_sleep = sleep error = 0.0 while True: start = time.time() yield corrected_sleep -= error await asyncio.sleep(corrected_sleep) error = (time.time() - start) - sleep input-remapper-2.1.1/inputremapper/injection/macros/tasks/wait.py000066400000000000000000000032421475433465200253050ustar00rootroot00000000000000#!/usr/bin/env python3 # -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings # Copyright (C) 2025 sezanzeb # # This file is part of input-remapper. # # input-remapper is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # input-remapper is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with input-remapper. If not, see . from __future__ import annotations import asyncio import random from inputremapper.injection.macros.argument import ArgumentConfig from inputremapper.injection.macros.task import Task class WaitTask(Task): """Wait time in milliseconds.""" argument_configs = [ ArgumentConfig( name="time", position=0, types=[float, int], ), ArgumentConfig( name="max_time", position=1, types=[float, int, None], default=None, ), ] async def run(self, callback) -> None: time = self.get_argument("time").get_value() max_time = self.get_argument("max_time").get_value() if max_time is not None and max_time > time: time = random.uniform(time, max_time) await asyncio.sleep(time / 1000) input-remapper-2.1.1/inputremapper/injection/macros/tasks/wheel.py000066400000000000000000000047611475433465200254540ustar00rootroot00000000000000#!/usr/bin/env python3 # -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings # Copyright (C) 2025 sezanzeb # # This file is part of input-remapper. # # input-remapper is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # input-remapper is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with input-remapper. If not, see . from __future__ import annotations import math from evdev.ecodes import ( EV_REL, REL_WHEEL_HI_RES, REL_HWHEEL_HI_RES, REL_WHEEL, REL_HWHEEL, ) from inputremapper.injection.macros.argument import ArgumentConfig from inputremapper.injection.macros.task import Task from inputremapper.injection.macros.tasks.util import precise_iteration_frequency class WheelTask(Task): """Move the scroll wheel.""" argument_configs = [ ArgumentConfig( name="direction", position=0, types=[str], ), ArgumentConfig( name="speed", position=1, types=[int, float], ), ] async def run(self, callback) -> None: direction = self.get_argument("direction").get_value() # 120, see https://www.kernel.org/doc/html/latest/input/event-codes.html#ev-rel code, value = { "up": ([REL_WHEEL, REL_WHEEL_HI_RES], [1 / 120, 1]), "down": ([REL_WHEEL, REL_WHEEL_HI_RES], [-1 / 120, -1]), "left": ([REL_HWHEEL, REL_HWHEEL_HI_RES], [1 / 120, 1]), "right": ([REL_HWHEEL, REL_HWHEEL_HI_RES], [-1 / 120, -1]), }[direction.lower()] speed = self.get_argument("speed").get_value() remainder = [0.0, 0.0] async for _ in precise_iteration_frequency(self.mapping.rel_rate): if not self.is_holding(): return for i in range(0, 2): float_value = value[i] * speed + remainder[i] remainder[i] = math.fmod(float_value, 1) if abs(float_value) >= 1: callback(EV_REL, code[i], int(float_value)) input-remapper-2.1.1/inputremapper/injection/macros/variable.py000066400000000000000000000062531475433465200250060ustar00rootroot00000000000000#!/usr/bin/env python3 # -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings # Copyright (C) 2025 sezanzeb # # This file is part of input-remapper. # # input-remapper is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # input-remapper is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with input-remapper. If not, see . import re from typing import Any from inputremapper.configs.validation_errors import MacroError from inputremapper.injection.macros.macro import macro_variables class Variable: """Something that the user passed into a macro function as parameter. The value should already be parsed and validated, if const=True, according to the argument_configs of a given Task. Examples: - const string "KEY_A" in `key(KEY_A)` - non-const string "foo" in `repeat($foo, key(KEY_A))` (`value` is the name here) - const Macro `key(a)` in `repeat(1, key(a))` - const int 1 in `repeat(1, key(a)) """ def __init__(self, value: Any, const: bool) -> None: if not const and not isinstance(value, str): raise MacroError(f"Variables require a string name, not {value}") self.value = value self.const = const if not const: self.validate_variable_name() def get_name(self) -> str: """If the variable is not const, return its name.""" assert not self.const assert isinstance(self.value, str) return self.value def get_value(self) -> Any: """Get the variables value from the common variable storage process.""" if self.const: return self.value return macro_variables.get(self.value) def set_value(self, value: Any) -> None: """Set the variables value across all macros.""" assert not self.const macro_variables[self.value] = value def validate_variable_name(self) -> None: """Check if this is a legit variable name. Because they could clash with language features. If the macro can be parsed at all due to a problematic choice of a variable name. Allowed examples: "foo", "Foo1234_", "_foo_1234" Not allowed: "1_foo", "foo=blub", "$foo", "foo,1234", "foo()" """ if not isinstance(self.value, str) or not re.match( r"^[A-Za-z_][A-Za-z_0-9]*$", self.value ): raise MacroError(msg=f'"{self.value}" is not a legit variable name') def __repr__(self) -> str: return f'' def __eq__(self, other) -> bool: if not isinstance(other, Variable): return False return self.const == other.const and self.value == other.value input-remapper-2.1.1/inputremapper/injection/mapping_handlers/000077500000000000000000000000001475433465200246705ustar00rootroot00000000000000input-remapper-2.1.1/inputremapper/injection/mapping_handlers/__init__.py000066400000000000000000000014671475433465200270110ustar00rootroot00000000000000# -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings # Copyright (C) 2025 sezanzeb # # This file is part of input-remapper. # # input-remapper is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # input-remapper is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with input-remapper. If not, see . input-remapper-2.1.1/inputremapper/injection/mapping_handlers/abs_to_abs_handler.py000066400000000000000000000130271475433465200310360ustar00rootroot00000000000000# -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings # Copyright (C) 2025 sezanzeb # # This file is part of input-remapper. # # input-remapper is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # input-remapper is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with input-remapper. If not, see . from typing import Tuple, Optional, Dict import evdev from evdev.ecodes import EV_ABS from inputremapper import exceptions from inputremapper.configs.input_config import InputCombination, InputConfig from inputremapper.configs.mapping import Mapping from inputremapper.injection.global_uinputs import GlobalUInputs from inputremapper.injection.mapping_handlers.axis_transform import Transformation from inputremapper.injection.mapping_handlers.mapping_handler import ( MappingHandler, HandlerEnums, InputEventHandler, ) from inputremapper.input_event import InputEvent, EventActions from inputremapper.logging.logger import logger from inputremapper.utils import get_evdev_constant_name class AbsToAbsHandler(MappingHandler): """Handler which transforms EV_ABS to EV_ABS events.""" _map_axis: InputConfig # the InputConfig for the axis we map _output_axis: Tuple[int, int] # the (type, code) of the output axis _transform: Optional[Transformation] _target_absinfo: evdev.AbsInfo def __init__( self, combination: InputCombination, mapping: Mapping, global_uinputs: GlobalUInputs, **_, ) -> None: super().__init__(combination, mapping, global_uinputs) # find the input event we are supposed to map. If the input combination is # BTN_A + ABS_X + BTN_B, then use the value of ABS_X for the transformation assert (map_axis := combination.find_analog_input_config(type_=EV_ABS)) self._map_axis = map_axis assert mapping.output_code is not None assert mapping.output_type == EV_ABS self._output_axis = (mapping.output_type, mapping.output_code) target_uinput = global_uinputs.get_uinput(mapping.target_uinput) assert target_uinput is not None abs_capabilities = target_uinput.capabilities(absinfo=True)[EV_ABS] self._target_absinfo = dict(abs_capabilities)[mapping.output_code] self._transform = None def __str__(self): name = get_evdev_constant_name(*self._map_axis.type_and_code) return f'AbsToAbsHandler for "{name}" {self._map_axis}' def __repr__(self): return f"<{str(self)} at {hex(id(self))}>" @property def child(self): # used for logging return ( f"maps to: {self.mapping.get_output_name_constant()} " f"{self.mapping.get_output_type_code()} at " f"{self.mapping.target_uinput}" ) def notify( self, event: InputEvent, source: evdev.InputDevice, suppress: bool = False, ) -> bool: if event.input_match_hash != self._map_axis.input_match_hash: return False if EventActions.recenter in event.actions: self._write(self._scale_to_target(0)) return True if not self._transform: absinfo = dict(source.capabilities(absinfo=True)[EV_ABS])[event.code] self._transform = Transformation( max_=absinfo.max, min_=absinfo.min, deadzone=self.mapping.deadzone, gain=self.mapping.gain, expo=self.mapping.expo, ) try: self._write(self._scale_to_target(self._transform(event.value))) return True except (exceptions.UinputNotAvailable, exceptions.EventNotHandled): return False def reset(self) -> None: self._write(self._scale_to_target(0)) def _scale_to_target(self, x: float) -> int: """Scales a x value between -1 and 1 to an integer between target_absinfo.min and target_absinfo.max input values above 1 or below -1 are clamped to the extreme values """ factor = (self._target_absinfo.max - self._target_absinfo.min) / 2 offset = self._target_absinfo.min + factor y = factor * x + offset if y > offset: return int(min(self._target_absinfo.max, y)) else: return int(max(self._target_absinfo.min, y)) def _write(self, value: int): """Inject.""" try: self.global_uinputs.write( (*self._output_axis, value), self.mapping.target_uinput ) except OverflowError: # screwed up the calculation of the event value logger.error("OverflowError (%s, %s, %s)", *self._output_axis, value) def needs_wrapping(self) -> bool: return len(self.input_configs) > 1 def set_sub_handler(self, handler: InputEventHandler) -> None: assert False # cannot have a sub-handler def wrap_with(self) -> Dict[InputCombination, HandlerEnums]: if self.needs_wrapping(): return {InputCombination(self.input_configs): HandlerEnums.axisswitch} return {} input-remapper-2.1.1/inputremapper/injection/mapping_handlers/abs_to_btn_handler.py000066400000000000000000000105251475433465200310540ustar00rootroot00000000000000# -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings # Copyright (C) 2025 sezanzeb # # This file is part of input-remapper. # # input-remapper is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # input-remapper is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with input-remapper. If not, see . from typing import Tuple import evdev from evdev.ecodes import EV_ABS from inputremapper.configs.input_config import InputCombination, InputConfig from inputremapper.configs.mapping import Mapping from inputremapper.injection.global_uinputs import GlobalUInputs from inputremapper.injection.mapping_handlers.mapping_handler import ( MappingHandler, InputEventHandler, ) from inputremapper.input_event import InputEvent, EventActions from inputremapper.utils import get_evdev_constant_name class AbsToBtnHandler(MappingHandler): """Handler which transforms an EV_ABS to a button event.""" _input_config: InputConfig _active: bool _sub_handler: InputEventHandler def __init__( self, combination: InputCombination, mapping: Mapping, global_uinputs: GlobalUInputs, **_, ): super().__init__(combination, mapping, global_uinputs) self._active = False self._input_config = combination[0] assert self._input_config.analog_threshold assert len(combination) == 1 def __str__(self): name = get_evdev_constant_name(*self._input_config.type_and_code) return f'AbsToBtnHandler for "{name}" ' f"{self._input_config.type_and_code}" def __repr__(self): return f"<{str(self)} at {hex(id(self))}>" @property def child(self): # used for logging return self._sub_handler def _trigger_point(self, abs_min: int, abs_max: int) -> Tuple[float, float]: """Calculate the axis mid and trigger point.""" # TODO: potentially cache this function assert self._input_config.analog_threshold if abs_min == -1 and abs_max == 1: # this is a hat switch # return +-1 return ( self._input_config.analog_threshold // abs(self._input_config.analog_threshold), 0, ) half_range = (abs_max - abs_min) / 2 middle = half_range + abs_min trigger_offset = half_range * self._input_config.analog_threshold / 100 # threshold, middle return middle + trigger_offset, middle def notify( self, event: InputEvent, source: evdev.InputDevice, suppress: bool = False, ) -> bool: if event.input_match_hash != self._input_config.input_match_hash: return False absinfo = { entry[0]: entry[1] for entry in source.capabilities(absinfo=True)[EV_ABS] } threshold, mid_point = self._trigger_point( absinfo[event.code].min, absinfo[event.code].max ) value = event.value if (value < threshold > mid_point) or (value > threshold < mid_point): if self._active: event = event.modify(value=0, actions=(EventActions.as_key,)) else: # consume the event. # We could return False to forward events return True else: if value >= threshold > mid_point: direction = EventActions.positive_trigger else: direction = EventActions.negative_trigger event = event.modify(value=1, actions=(EventActions.as_key, direction)) self._active = bool(event.value) # logger.debug(event.event_tuple, "sending to sub_handler") return self._sub_handler.notify( event, source=source, suppress=suppress, ) def reset(self) -> None: self._active = False self._sub_handler.reset() input-remapper-2.1.1/inputremapper/injection/mapping_handlers/abs_to_rel_handler.py000066400000000000000000000171171475433465200310570ustar00rootroot00000000000000# -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings # Copyright (C) 2025 sezanzeb # # This file is part of input-remapper. # # input-remapper is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # input-remapper is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with input-remapper. If not, see . import asyncio import math import time from functools import partial from typing import Dict, Tuple, Optional import evdev from evdev.ecodes import ( EV_REL, EV_ABS, REL_WHEEL, REL_HWHEEL, REL_WHEEL_HI_RES, REL_HWHEEL_HI_RES, ) from inputremapper.configs.input_config import InputCombination, InputConfig from inputremapper.configs.mapping import ( Mapping, REL_XY_SCALING, WHEEL_SCALING, WHEEL_HI_RES_SCALING, DEFAULT_REL_RATE, ) from inputremapper.injection.global_uinputs import GlobalUInputs from inputremapper.injection.mapping_handlers.axis_transform import Transformation from inputremapper.injection.mapping_handlers.mapping_handler import ( MappingHandler, HandlerEnums, InputEventHandler, ) from inputremapper.input_event import InputEvent, EventActions from inputremapper.logging.logger import logger from inputremapper.utils import get_evdev_constant_name def calculate_output(value, weight, remainder): # self._value is between 0 and 1, scale up with weight scaled = value * weight + remainder # float_value % 1 will result in wrong calculations for negative values remainder = math.fmod(scaled, 1) return int(scaled), remainder # TODO move into class? async def _run_normal_output(self) -> None: """Start injecting events.""" self._running = True self._stop = False remainder = 0.0 start = time.time() # if the rate is configured to be slower than the default, increase the value, so # that the overall speed stays the same. rate_compensation = DEFAULT_REL_RATE / self.mapping.rel_rate weight = REL_XY_SCALING * rate_compensation while not self._stop: value, remainder = calculate_output( self._value, weight, remainder, ) self._write(EV_REL, self.mapping.output_code, value) time_taken = time.time() - start sleep = max(0.0, (1 / self.mapping.rel_rate) - time_taken) await asyncio.sleep(sleep) start = time.time() self._running = False # TODO move into class? async def _run_wheel_output(self, codes: Tuple[int, int]) -> None: """Start injecting wheel events. made to inject both REL_WHEEL and REL_WHEEL_HI_RES events, because otherwise wheel output doesn't work for some people. See issue #354 """ weights = (WHEEL_SCALING, WHEEL_HI_RES_SCALING) self._running = True self._stop = False remainder = [0.0, 0.0] start = time.time() while not self._stop: for i in range(len(codes)): value, remainder[i] = calculate_output( self._value, weights[i], remainder[i], ) self._write(EV_REL, codes[i], value) time_taken = time.time() - start await asyncio.sleep(max(0.0, (1 / self.mapping.rel_rate) - time_taken)) start = time.time() self._running = False class AbsToRelHandler(MappingHandler): """Handler which transforms an EV_ABS to EV_REL events.""" _map_axis: InputConfig # the InputConfig for the axis we map _value: float # the current output value _running: bool # if the run method is active _stop: bool # if the run loop should return _transform: Optional[Transformation] def __init__( self, combination: InputCombination, mapping: Mapping, global_uinputs: GlobalUInputs, **_, ) -> None: super().__init__(combination, mapping, global_uinputs) # find the input event we are supposed to map assert (map_axis := combination.find_analog_input_config(type_=EV_ABS)) self._map_axis = map_axis self._value = 0 self._running = False self._stop = True self._transform = None # bind the correct run method if self.mapping.output_code in ( REL_WHEEL, REL_HWHEEL, REL_WHEEL_HI_RES, REL_HWHEEL_HI_RES, ): if self.mapping.output_code in (REL_WHEEL, REL_WHEEL_HI_RES): codes = (REL_WHEEL, REL_WHEEL_HI_RES) else: codes = (REL_HWHEEL, REL_HWHEEL_HI_RES) self._run = partial(_run_wheel_output, self, codes=codes) else: self._run = partial(_run_normal_output, self) def __str__(self): name = get_evdev_constant_name(*self._map_axis.type_and_code) return f'AbsToRelHandler for "{name}" {self._map_axis}' def __repr__(self): return f"<{str(self)} at {hex(id(self))}>" @property def child(self): # used for logging return ( f"maps to: {self.mapping.get_output_name_constant()} " f"{self.mapping.get_output_type_code()} at " f"{self.mapping.target_uinput}" ) def notify( self, event: InputEvent, source: evdev.InputDevice, suppress: bool = False, ) -> bool: if event.input_match_hash != self._map_axis.input_match_hash: return False if EventActions.recenter in event.actions: self._stop = True return True if not self._transform: absinfo = { entry[0]: entry[1] for entry in source.capabilities(absinfo=True)[EV_ABS] } self._transform = Transformation( max_=absinfo[event.code].max, min_=absinfo[event.code].min, deadzone=self.mapping.deadzone, gain=self.mapping.gain, expo=self.mapping.expo, ) transformed = self._transform(event.value) self._value = transformed if transformed == 0: self._stop = True return True if not self._running: asyncio.ensure_future(self._run()) return True def reset(self) -> None: self._stop = True def _write(self, type_, keycode, value): """Inject.""" # if the mouse won't move even though correct stuff is written here, # the capabilities are probably wrong if value == 0: return # rel 0 does not make sense try: self.global_uinputs.write( (type_, keycode, value), self.mapping.target_uinput ) except OverflowError: # screwed up the calculation of mouse movements logger.error("OverflowError (%s, %s, %s)", type_, keycode, value) def needs_wrapping(self) -> bool: return len(self.input_configs) > 1 def set_sub_handler(self, handler: InputEventHandler) -> None: assert False # cannot have a sub-handler def wrap_with(self) -> Dict[InputCombination, HandlerEnums]: if self.needs_wrapping(): return {InputCombination(self.input_configs): HandlerEnums.axisswitch} return {} input-remapper-2.1.1/inputremapper/injection/mapping_handlers/axis_switch_handler.py000066400000000000000000000143261475433465200312720ustar00rootroot00000000000000# -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings # Copyright (C) 2025 sezanzeb # # This file is part of input-remapper. # # input-remapper is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # input-remapper is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with input-remapper. If not, see . from typing import Dict, Tuple, Hashable import evdev from inputremapper.configs.input_config import InputCombination from inputremapper.configs.input_config import InputConfig from inputremapper.configs.mapping import Mapping from inputremapper.injection.global_uinputs import GlobalUInputs from inputremapper.injection.mapping_handlers.mapping_handler import ( MappingHandler, HandlerEnums, InputEventHandler, ContextProtocol, ) from inputremapper.input_event import InputEvent, EventActions from inputremapper.logging.logger import logger from inputremapper.utils import get_device_hash class AxisSwitchHandler(MappingHandler): """Enables or disables an axis. Generally, if multiple events are mapped to something in a combination, all of them need to be triggered in order to map to the output. If an analog input is combined with a key input, then the same thing should happen. The key needs to be pressed and the joystick needs to be moved in order to generate output. """ _map_axis: InputConfig # the InputConfig for the axis we switch on or off _trigger_keys: Tuple[Hashable, ...] # all events that can switch the axis _active: bool # whether the axis is on or off _last_value: int # the value of the last axis event that arrived _axis_source: evdev.InputDevice # the cached source of the axis input events _forward_device: evdev.UInput # the cached forward uinput _sub_handler: InputEventHandler def __init__( self, combination: InputCombination, mapping: Mapping, context: ContextProtocol, global_uinputs: GlobalUInputs, **_, ): super().__init__(combination, mapping, global_uinputs) trigger_keys = tuple( event.input_match_hash for event in combination if not event.defines_analog_input ) assert len(trigger_keys) >= 1 assert (map_axis := combination.find_analog_input_config()) self._map_axis = map_axis self._trigger_keys = trigger_keys self._active = False self._last_value = 0 self._axis_source = None self._forward_device = None self.context = context def __str__(self): return f"AxisSwitchHandler for {self._map_axis.type_and_code}" def __repr__(self): return f"<{str(self)} at {hex(id(self))}>" @property def child(self): return self._sub_handler def _handle_key_input(self, event: InputEvent): """If a key is pressed, allow mapping analog events in subhandlers. Analog events (e.g. ABS_X, REL_Y) that have gone through Handlers that transform them to buttons also count as keys. """ key_is_pressed = bool(event.value) if self._active == key_is_pressed: # nothing changed return False self._active = key_is_pressed if self._axis_source is None: return True if not key_is_pressed: # recenter the axis logger.debug("Stopping axis for %s", self.mapping.input_combination) event = InputEvent( 0, 0, *self._map_axis.type_and_code, 0, actions=(EventActions.recenter,), origin_hash=self._map_axis.origin_hash, ) self._sub_handler.notify(event, self._axis_source) return True if self._map_axis.type == evdev.ecodes.EV_ABS: # send the last cached value so that the abs axis # is at the correct position logger.debug("Starting axis for %s", self.mapping.input_combination) event = InputEvent( 0, 0, *self._map_axis.type_and_code, self._last_value, origin_hash=self._map_axis.origin_hash, ) self._sub_handler.notify(event, self._axis_source) return True return True def _should_map(self, event: InputEvent): return ( event.input_match_hash in self._trigger_keys or event.input_match_hash == self._map_axis.input_match_hash ) def notify( self, event: InputEvent, source: evdev.InputDevice, suppress: bool = False, ) -> bool: if not self._should_map(event): return False if event.is_key_event: return self._handle_key_input(event) # do some caching so that we can generate the # recenter event and an initial abs event if self._axis_source is None: self._axis_source = source if self._forward_device is None: device_hash = get_device_hash(source) self._forward_device = self.context.get_forward_uinput(device_hash) # always cache the value self._last_value = event.value if self._active: return self._sub_handler.notify(event, source, suppress) return False def reset(self) -> None: self._last_value = 0 self._active = False self._sub_handler.reset() def needs_wrapping(self) -> bool: return True def wrap_with(self) -> Dict[InputCombination, HandlerEnums]: combination = [ config for config in self.input_configs if not config.defines_analog_input ] return {InputCombination(combination): HandlerEnums.combination} input-remapper-2.1.1/inputremapper/injection/mapping_handlers/axis_transform.py000066400000000000000000000113641475433465200303060ustar00rootroot00000000000000# -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings # Copyright (C) 2025 sezanzeb # # This file is part of input-remapper. # # input-remapper is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # input-remapper is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with input-remapper. If not, see . import math from typing import Dict, Union class Transformation: """Callable that returns the axis transformation at x.""" def __init__( self, # if input values are > max_, the return value will be > 1 max_: Union[int, float], min_: Union[int, float], deadzone: float, gain: float = 1, expo: float = 0, ) -> None: self._max = max_ self._min = min_ self._deadzone = deadzone self._gain = gain self._expo = expo self._cache: Dict[float, float] = {} def __call__(self, /, x: Union[int, float]) -> float: if x not in self._cache: y = ( self._calc_qubic(self._flatten_deadzone(self._normalize(x))) * self._gain ) self._cache[x] = y return self._cache[x] def set_range(self, min_, max_): # TODO docstring if min_ != self._min or max_ != self._max: self._cache = {} self._min = min_ self._max = max_ def _normalize(self, x: Union[int, float]) -> float: """Move and scale x to be between -1 and 1 return: x """ if self._min == -1 and self._max == 1: return x half_range = (self._max - self._min) / 2 middle = half_range + self._min return (x - middle) / half_range def _flatten_deadzone(self, x: float) -> float: """ y ^ y ^ | | 1 | / 1 | / | / | / | / ==> | --- | / | / -1 | / -1 | / |------------> |------------> -1 1 x -1 1 x """ if abs(x) <= self._deadzone: return 0 return (x - self._deadzone * x / abs(x)) / (1 - self._deadzone) def _calc_qubic(self, x: float) -> float: """Transforms an x value by applying a qubic function k = 0 : will yield no transformation f(x) = x 1 > k > 0 : will yield low sensitivity for low x values and high sensitivity for high x values -1 < k < 0 : will yield high sensitivity for low x values and low sensitivity for high x values see also: https://www.geogebra.org/calculator/mkdqueky Mathematical definition: f(x,d) = d * x + (1 - d) * x ** 3 | d = 1 - k | k ∈ [0,1] the function is designed such that if follows these constraints: f'(0, d) = d and f(1, d) = 1 and f(-x,d) = -f(x,d) for k ∈ [-1,0) the above function is mirrored at y = x and d = 1 + k """ k = self._expo if k == 0 or x == 0: return x if 0 < k <= 1: d = 1 - k return d * x + (1 - d) * x**3 if -1 <= k < 0: # calculate return value with the real inverse solution # of y = b * x + a * x ** 3 # LaTeX for better readability: # # y=\frac{{{\left( \sqrt{27 {{x}^{2}}+\frac{4 {{b}^{3}}}{a}} # +{{3}^{\frac{3}{2}}} x\right) }^{\frac{1}{3}}}} # {{{2}^{\frac{1}{3}}} \sqrt{3} {{a}^{\frac{1}{3}}}} # -\frac{{{2}^{\frac{1}{3}}} b} # {\sqrt{3} {{a}^{\frac{2}{3}}} # {{\left( \sqrt{27 {{x}^{2}}+\frac{4 {{b}^{3}}}{a}} # +{{3}^{\frac{3}{2}}} x\right) }^{\frac{1}{3}}}} sign = x / abs(x) x = math.fabs(x) d = 1 + k a = 1 - d b = d c = (math.sqrt(27 * x**2 + (4 * b**3) / a) + 3 ** (3 / 2) * x) ** (1 / 3) y = c / (2 ** (1 / 3) * math.sqrt(3) * a ** (1 / 3)) - ( 2 ** (1 / 3) * b ) / (math.sqrt(3) * a ** (2 / 3) * c) return y * sign raise ValueError("k must be between -1 and 1") input-remapper-2.1.1/inputremapper/injection/mapping_handlers/combination_handler.py000066400000000000000000000253511475433465200312470ustar00rootroot00000000000000# -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings # Copyright (C) 2025 sezanzeb # # This file is part of input-remapper. # # input-remapper is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # input-remapper is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with input-remapper. If not, see . from __future__ import annotations # needed for the TYPE_CHECKING import from typing import TYPE_CHECKING, Dict, Hashable, Tuple import evdev from evdev.ecodes import EV_ABS, EV_REL from inputremapper.configs.input_config import InputCombination from inputremapper.configs.mapping import Mapping from inputremapper.injection.global_uinputs import GlobalUInputs from inputremapper.injection.mapping_handlers.mapping_handler import ( MappingHandler, InputEventHandler, HandlerEnums, ) from inputremapper.input_event import InputEvent from inputremapper.logging.logger import logger if TYPE_CHECKING: from inputremapper.injection.context import Context class CombinationHandler(MappingHandler): """Keeps track of a combination and notifies a sub handler.""" # map of InputEvent.input_match_hash -> bool , keep track of the combination state _pressed_keys: Dict[Hashable, bool] # the last update we sent to a sub-handler. If this is true, the output key is # still being held down. _output_previously_active: bool _sub_handler: InputEventHandler _handled_input_hashes: list[Hashable] _requires_a_release: Dict[Tuple[int, int], bool] def __init__( self, combination: InputCombination, mapping: Mapping, context: Context, global_uinputs: GlobalUInputs, **_, ) -> None: logger.debug(str(mapping)) super().__init__(combination, mapping, global_uinputs) self._pressed_keys = {} self._output_previously_active = False self._context = context self._requires_a_release = {} # prepare a key map for all events with non-zero value for input_config in combination: assert not input_config.defines_analog_input self._pressed_keys[input_config.input_match_hash] = False self._handled_input_hashes = [ input_config.input_match_hash for input_config in combination ] assert len(self._pressed_keys) > 0 # no combination handler without a key def __str__(self): return ( f'CombinationHandler for "{str(self.mapping.input_combination)}" ' f"{tuple(t for t in self._pressed_keys.keys())}" ) def __repr__(self): description = ( f'CombinationHandler for "{repr(self.mapping.input_combination)}" ' f"{tuple(t for t in self._pressed_keys.keys())}" ) return f"<{description} at {hex(id(self))}>" @property def child(self): # used for logging return self._sub_handler def notify( self, event: InputEvent, source: evdev.InputDevice, suppress: bool = False, ) -> bool: if event.input_match_hash not in self._handled_input_hashes: # we are not responsible for the event return False # update the state # The value of non-key input should have been changed to either 0 or 1 at this # point by other handlers. is_pressed = event.value == 1 self._pressed_keys[event.input_match_hash] = is_pressed # maybe this changes the activation status (triggered/not-triggered) changed = self._is_activated() != self._output_previously_active if changed: if is_pressed: return self._handle_freshly_activated(suppress, event, source) else: return self._handle_freshly_deactivated(event, source) else: if is_pressed: return self._handle_no_change_press(event) else: return self._handle_no_change_release(event) def _handle_no_change_press(self, event: InputEvent) -> bool: """A key was pressed, but this doesn't change the combinations activation state. Can only happen if either the combination wasn't already active, or a duplicate key-down event arrived (EV_ABS?) """ # self._output_previously_active is negated, because if the output is active, a # key-down event triggered it, which then did not get forwarded, therefore # it doesn't require a release. self._require_release_later(not self._output_previously_active, event) # output is active: consume the event # output inactive: forward the event return self._output_previously_active def _handle_no_change_release(self, event: InputEvent) -> bool: """One of the combinations keys was released, but it didn't untrigger the combination yet.""" # Negate: `False` means that the event-reader will forward the release. return not self._should_release_event(event) def _handle_freshly_activated( self, suppress: bool, event: InputEvent, source: evdev.InputDevice, ) -> bool: """The combination was deactivated, but is activated now.""" if suppress: return False # Send key up events to the forwarded uinput if configured to do so. self._forward_release() logger.debug( "Sending %s to sub-handler %s", repr(event), repr(self._sub_handler), ) self._output_previously_active = bool(event.value) sub_handler_result = self._sub_handler.notify(event, source, suppress) # Only if the sub-handler return False, we need a release-event later. # If it handled the event, the user never sees this key-down event. self._require_release_later(not sub_handler_result, event) return sub_handler_result def _handle_freshly_deactivated( self, event: InputEvent, source: evdev.InputDevice, ) -> bool: """The combination was activated, but is deactivated now.""" # We ignore the `suppress` argument for release events. Otherwise, we # might end up with stuck keys (test_event_pipeline.test_combination). # In the case of output axis, this will enable us to activate multiple # axis with the same button. logger.debug( "Sending %s to sub-handler %s", repr(event), repr(self._sub_handler), ) self._output_previously_active = bool(event.value) self._sub_handler.notify(event, source, suppress=False) # Negate: `False` means that the event-reader will forward the release. return not self._should_release_event(event) def _should_release_event(self, event: InputEvent) -> bool: """Check if the key-up event should be forwarded by the event-reader. After this, the release event needs to be injected by someone, otherwise the dictionary was modified erroneously. If there is no entry, we assume that there was no key-down event to release. Maybe a duplicate event arrived. """ # Ensure that all injected key-down events will get their release event # injected eventually. # If a key-up event arrives that will inactivate the combination, but # for which previously a key-down event was injected (because it was # an earlier key in the combination chain), then we need to ensure that its # release is injected as well. So we get two release events in that case: # one for the key, and one for the output. assert event.value == 0 return self._requires_a_release.pop(event.type_and_code, False) def _require_release_later(self, require: bool, event: InputEvent) -> None: """Remember if this key-down event will need a release event later on.""" assert event.value == 1 self._requires_a_release[event.type_and_code] = require def reset(self) -> None: self._sub_handler.reset() for key in self._pressed_keys: self._pressed_keys[key] = False self._requires_a_release = {} self._output_previously_active = False def _is_activated(self) -> bool: """Return if all keys in the keymap are set to True.""" return False not in self._pressed_keys.values() def _forward_release(self) -> None: """Forward a button release for all keys if this is a combination. This might cause duplicate key-up events but those are ignored by evdev anyway """ if len(self._pressed_keys) == 1 or not self.mapping.release_combination_keys: return keys_to_release = filter( lambda cfg: self._pressed_keys.get(cfg.input_match_hash), self.mapping.input_combination, ) logger.debug("Forwarding release for %s", self.mapping.input_combination) for input_config in keys_to_release: if not self._requires_a_release.get(input_config.type_and_code): continue origin_hash = input_config.origin_hash if origin_hash is None: logger.error( f"Can't forward due to missing origin_hash in {repr(input_config)}" ) continue forward_to = self._context.get_forward_uinput(origin_hash) logger.write(input_config, forward_to) forward_to.write(*input_config.type_and_code, 0) forward_to.syn() # We are done with this key, forget about it del self._requires_a_release[input_config.type_and_code] def needs_ranking(self) -> bool: return bool(self.input_configs) def rank_by(self) -> InputCombination: return InputCombination( [event for event in self.input_configs if not event.defines_analog_input] ) def wrap_with(self) -> Dict[InputCombination, HandlerEnums]: return_dict = {} for config in self.input_configs: if config.type == EV_ABS and not config.defines_analog_input: return_dict[InputCombination([config])] = HandlerEnums.abs2btn if config.type == EV_REL and not config.defines_analog_input: return_dict[InputCombination([config])] = HandlerEnums.rel2btn return return_dict input-remapper-2.1.1/inputremapper/injection/mapping_handlers/hierarchy_handler.py000066400000000000000000000071631475433465200307240ustar00rootroot00000000000000# -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings # Copyright (C) 2025 sezanzeb # # This file is part of input-remapper. # # input-remapper is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # input-remapper is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with input-remapper. If not, see . from typing import List, Dict import evdev from evdev.ecodes import EV_ABS, EV_REL from inputremapper.configs.input_config import InputCombination, InputConfig from inputremapper.injection.global_uinputs import GlobalUInputs from inputremapper.injection.mapping_handlers.mapping_handler import ( MappingHandler, InputEventHandler, HandlerEnums, ) from inputremapper.input_event import InputEvent class HierarchyHandler(MappingHandler): """Handler consisting of an ordered list of MappingHandler only the first handler which successfully handles the event will execute it, all other handlers will be notified, but suppressed """ _input_config: InputConfig def __init__( self, handlers: List[MappingHandler], input_config: InputConfig, global_uinputs: GlobalUInputs, ) -> None: self.handlers = handlers self._input_config = input_config combination = InputCombination([input_config]) # use the mapping from the first child TODO: find a better solution mapping = handlers[0].mapping super().__init__(combination, mapping, global_uinputs) def __str__(self): return f"HierarchyHandler for {self._input_config}" def __repr__(self): return f"<{str(self)} at {hex(id(self))}>" @property def child(self): # used for logging return self.handlers def notify( self, event: InputEvent, source: evdev.InputDevice = None, suppress: bool = False, ) -> bool: if event.input_match_hash != self._input_config.input_match_hash: return False handled = False for handler in self.handlers: if handled: # To allow an arbitrary number of output axes to be activated at the # same time, we don't suppress them. handler.notify( event, source, suppress=not handler.mapping.input_combination.defines_analog_input, ) continue handled = handler.notify(event, source) return handled def reset(self) -> None: for sub_handler in self.handlers: sub_handler.reset() def wrap_with(self) -> Dict[InputCombination, HandlerEnums]: if ( self._input_config.type == EV_ABS and not self._input_config.defines_analog_input ): return {InputCombination([self._input_config]): HandlerEnums.abs2btn} if ( self._input_config.type == EV_REL and not self._input_config.defines_analog_input ): return {InputCombination([self._input_config]): HandlerEnums.rel2btn} return {} def set_sub_handler(self, handler: InputEventHandler) -> None: assert False input-remapper-2.1.1/inputremapper/injection/mapping_handlers/key_handler.py000066400000000000000000000061751475433465200275400ustar00rootroot00000000000000# -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings # Copyright (C) 2025 sezanzeb # # This file is part of input-remapper. # # input-remapper is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # input-remapper is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with input-remapper. If not, see . from typing import Tuple, Dict from inputremapper import exceptions from inputremapper.configs.input_config import InputCombination from inputremapper.configs.mapping import Mapping from inputremapper.exceptions import MappingParsingError from inputremapper.injection.global_uinputs import GlobalUInputs from inputremapper.injection.mapping_handlers.mapping_handler import ( MappingHandler, HandlerEnums, ) from inputremapper.input_event import InputEvent from inputremapper.logging.logger import logger from inputremapper.utils import get_evdev_constant_name class KeyHandler(MappingHandler): """Injects the target key if notified.""" _active: bool _maps_to: Tuple[int, int] def __init__( self, combination: InputCombination, mapping: Mapping, global_uinputs: GlobalUInputs, **_, ): super().__init__(combination, mapping, global_uinputs) maps_to = mapping.get_output_type_code() if not maps_to: raise MappingParsingError( "Unable to create key handler from mapping", mapping=mapping ) self._maps_to = maps_to self._active = False def __str__(self): return f"KeyHandler to {self._maps_to}" def __repr__(self): return f"<{str(self)} at {hex(id(self))}>" @property def child(self): # used for logging name = get_evdev_constant_name(*self._maps_to) return f"maps to: {name} {self._maps_to} on {self.mapping.target_uinput}" def notify(self, event: InputEvent, *_, **__) -> bool: """Inject event.value to the target key.""" event_tuple = (*self._maps_to, event.value) try: self.global_uinputs.write(event_tuple, self.mapping.target_uinput) self._active = bool(event.value) return True except exceptions.Error: return False def reset(self) -> None: logger.debug("resetting key_handler") if self._active: event_tuple = (*self._maps_to, 0) self.global_uinputs.write(event_tuple, self.mapping.target_uinput) self._active = False def needs_wrapping(self) -> bool: return True def wrap_with(self) -> Dict[InputCombination, HandlerEnums]: return {InputCombination(self.input_configs): HandlerEnums.combination} input-remapper-2.1.1/inputremapper/injection/mapping_handlers/macro_handler.py000066400000000000000000000102541475433465200300420ustar00rootroot00000000000000# -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings # Copyright (C) 2025 sezanzeb # # This file is part of input-remapper. # # input-remapper is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # input-remapper is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with input-remapper. If not, see . import asyncio import traceback from typing import Dict, Callable, Tuple from inputremapper.configs.input_config import InputCombination from inputremapper.configs.mapping import Mapping from inputremapper.injection.global_uinputs import GlobalUInputs from inputremapper.injection.macros.macro import Macro from inputremapper.injection.macros.parse import Parser from inputremapper.injection.mapping_handlers.mapping_handler import ( ContextProtocol, MappingHandler, HandlerEnums, ) from inputremapper.input_event import InputEvent from inputremapper.logging.logger import logger class MacroHandler(MappingHandler): """Runs the target macro if notified.""" # TODO: replace this by the macro itself _macro: Macro _active: bool def __init__( self, combination: InputCombination, mapping: Mapping, global_uinputs: GlobalUInputs, *, context: ContextProtocol, ): super().__init__(combination, mapping, global_uinputs) self._pressed_keys: Dict[Tuple[int, int], int] = {} self._active = False assert self.mapping.output_symbol is not None self._macro = Parser.parse(self.mapping.output_symbol, context, mapping) def __str__(self): return f"MacroHandler" def __repr__(self): return f"<{str(self)} at {hex(id(self))}>" @property def child(self): # used for logging return f"maps to {self._macro} on {self.mapping.target_uinput}" async def run_macro(self, handler: Callable): """Run the macro with the provided function.""" try: await self._macro.run(handler) except Exception as exception: logger.error('Macro "%s" failed with %s', self._macro.code, type(exception)) traceback.print_exc() def notify(self, event: InputEvent, *_, **__) -> bool: if event.value == 1: self._active = True self._macro.press_trigger() if self._macro.running: return True def handler(type_, code, value) -> None: """Handler for macros.""" self._remember_pressed_keys((type_, code, value)) self.global_uinputs.write( (type_, code, value), self.mapping.target_uinput, ) asyncio.ensure_future(self.run_macro(handler)) return True else: self._active = False self._macro.release_trigger() return True def reset(self) -> None: self._active = False # To avoid a key hanging forever. Can be pretty annoying, especially if it is # a modifier that makes you unable to interact with your system. for (type, code), value in self._pressed_keys.items(): if value == 1: logger.debug("Releasing key %s", (type, code, value)) self.global_uinputs.write( (type, code, 0), self.mapping.target_uinput, ) def needs_wrapping(self) -> bool: return True def wrap_with(self) -> Dict[InputCombination, HandlerEnums]: return {InputCombination(self.input_configs): HandlerEnums.combination} def _remember_pressed_keys(self, event: Tuple[int, int, int]) -> None: type, code, value = event self._pressed_keys[(type, code)] = value input-remapper-2.1.1/inputremapper/injection/mapping_handlers/mapping_handler.py000066400000000000000000000150041475433465200303720ustar00rootroot00000000000000# -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings # Copyright (C) 2025 sezanzeb # # This file is part of input-remapper. # # input-remapper is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # input-remapper is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with input-remapper. If not, see . """Provides protocols for mapping handlers *** The architecture behind mapping handlers *** Handling an InputEvent is done in 3 steps: 1. Input Event Handling A MappingHandler that does Input event handling receives Input Events directly from the EventReader. To do so it must implement the InputEventHandler protocol. An InputEventHandler may handle multiple events (InputEvent.type_and_code) 2. Event Transformation The event gets transformed as described by the mapping. e.g.: combining multiple events to a single one transforming EV_ABS to EV_REL macros ... Multiple transformations may get chained 3. Event Injection The transformed event gets injected to a global_uinput MappingHandlers can implement one or more of these steps. Overview of implemented handlers and the steps they implement: Step 1: - HierarchyHandler Step 1 and 2: - CombinationHandler - AbsToBtnHandler - RelToBtnHandler Step 1, 2 and 3: - AbsToRelHandler - NullHandler Step 2 and 3: - KeyHandler - MacroHandler """ from __future__ import annotations import enum from typing import Dict, Protocol, Set, Optional, List import evdev from inputremapper.configs.input_config import InputCombination, InputConfig from inputremapper.configs.mapping import Mapping from inputremapper.exceptions import MappingParsingError from inputremapper.injection.global_uinputs import GlobalUInputs from inputremapper.input_event import InputEvent from inputremapper.logging.logger import logger class EventListener(Protocol): async def __call__(self, event: evdev.InputEvent) -> None: ... class ContextProtocol(Protocol): """The parts from context needed for handlers.""" listeners: Set[EventListener] def get_forward_uinput(self, origin_hash) -> evdev.UInput: pass class NotifyCallback(Protocol): """Type signature of InputEventHandler.notify return True if the event was actually taken care of """ def __call__( self, event: InputEvent, source: evdev.InputDevice, suppress: bool = False, ) -> bool: ... class InputEventHandler(Protocol): """The protocol any handler, which can be part of an event pipeline, must follow.""" def notify( self, event: InputEvent, source: evdev.InputDevice, suppress: bool = False, ) -> bool: ... def reset(self) -> None: """Reset the state of the handler e.g. release any buttons.""" ... class HandlerEnums(enum.Enum): # converting to btn abs2btn = enum.auto() rel2btn = enum.auto() macro = enum.auto() key = enum.auto() # converting to "analog" btn2rel = enum.auto() rel2rel = enum.auto() abs2rel = enum.auto() btn2abs = enum.auto() rel2abs = enum.auto() abs2abs = enum.auto() # special handlers combination = enum.auto() hierarchy = enum.auto() axisswitch = enum.auto() disable = enum.auto() class MappingHandler: """The protocol an InputEventHandler must follow if it should be dynamically integrated in an event-pipeline by the mapping parser """ mapping: Mapping # all input events this handler cares about # should always be a subset of mapping.input_combination input_configs: List[InputConfig] _sub_handler: Optional[InputEventHandler] # https://bugs.python.org/issue44807 def __init__( self, combination: InputCombination, mapping: Mapping, global_uinputs: GlobalUInputs, **_, ) -> None: """Initialize the handler Parameters ---------- combination the combination from sub_handler.wrap_with() mapping """ self.mapping = mapping self.input_configs = list(combination) self._sub_handler = None self.global_uinputs = global_uinputs def notify( self, event: InputEvent, source: evdev.InputDevice, suppress: bool = False, ) -> bool: """Notify this handler about an incoming event. Parameters ---------- event The newest event that came from `source`, and that should be mapped to something else source Where `event` comes from """ raise NotImplementedError def reset(self) -> None: """Reset the state of the handler e.g. release any buttons.""" raise NotImplementedError def needs_wrapping(self) -> bool: """If this handler needs to be wrapped in another MappingHandler.""" return len(self.wrap_with()) > 0 def needs_ranking(self) -> bool: """If this handler needs ranking and wrapping with a HierarchyHandler.""" return False def rank_by(self) -> Optional[InputCombination]: """The combination for which this handler needs ranking.""" def wrap_with(self) -> Dict[InputCombination, HandlerEnums]: """A dict of InputCombination -> HandlerEnums. for each InputCombination this handler should be wrapped with the given MappingHandler. """ return {} def set_sub_handler(self, handler: InputEventHandler) -> None: """Give this handler a sub_handler.""" self._sub_handler = handler def occlude_input_event(self, input_config: InputConfig) -> None: """Remove the config from self.input_configs.""" if not self.input_configs: logger.debug_mapping_handler(self) raise MappingParsingError( "Cannot remove a non existing config", mapping_handler=self ) # should be called for each event a wrapping-handler # has in its input_configs InputCombination self.input_configs.remove(input_config) input-remapper-2.1.1/inputremapper/injection/mapping_handlers/mapping_parser.py000066400000000000000000000335621475433465200302620ustar00rootroot00000000000000# -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings # Copyright (C) 2025 sezanzeb # # This file is part of input-remapper. # # input-remapper is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # input-remapper is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with input-remapper. If not, see . """Functions to assemble the mapping handler tree.""" from collections import defaultdict from typing import Dict, List, Type, Optional, Set, Iterable, Sized, Tuple, Sequence from evdev.ecodes import EV_KEY, EV_ABS, EV_REL from inputremapper.configs.input_config import InputCombination, InputConfig from inputremapper.configs.keyboard_layout import DISABLE_CODE, DISABLE_NAME from inputremapper.configs.mapping import Mapping from inputremapper.configs.preset import Preset from inputremapper.exceptions import MappingParsingError from inputremapper.injection.global_uinputs import GlobalUInputs from inputremapper.injection.macros.parse import Parser from inputremapper.injection.mapping_handlers.abs_to_abs_handler import AbsToAbsHandler from inputremapper.injection.mapping_handlers.abs_to_btn_handler import AbsToBtnHandler from inputremapper.injection.mapping_handlers.abs_to_rel_handler import AbsToRelHandler from inputremapper.injection.mapping_handlers.axis_switch_handler import ( AxisSwitchHandler, ) from inputremapper.injection.mapping_handlers.combination_handler import ( CombinationHandler, ) from inputremapper.injection.mapping_handlers.hierarchy_handler import HierarchyHandler from inputremapper.injection.mapping_handlers.key_handler import KeyHandler from inputremapper.injection.mapping_handlers.macro_handler import MacroHandler from inputremapper.injection.mapping_handlers.mapping_handler import ( HandlerEnums, MappingHandler, ContextProtocol, InputEventHandler, ) from inputremapper.injection.mapping_handlers.null_handler import NullHandler from inputremapper.injection.mapping_handlers.rel_to_abs_handler import RelToAbsHandler from inputremapper.injection.mapping_handlers.rel_to_btn_handler import RelToBtnHandler from inputremapper.injection.mapping_handlers.rel_to_rel_handler import RelToRelHandler from inputremapper.logging.logger import logger from inputremapper.utils import get_evdev_constant_name EventPipelines = Dict[InputConfig, Set[InputEventHandler]] mapping_handler_classes: Dict[HandlerEnums, Optional[Type[MappingHandler]]] = { # all available mapping_handlers HandlerEnums.abs2btn: AbsToBtnHandler, HandlerEnums.rel2btn: RelToBtnHandler, HandlerEnums.macro: MacroHandler, HandlerEnums.key: KeyHandler, HandlerEnums.btn2rel: None, # can be a macro HandlerEnums.rel2rel: RelToRelHandler, HandlerEnums.abs2rel: AbsToRelHandler, HandlerEnums.btn2abs: None, # can be a macro HandlerEnums.rel2abs: RelToAbsHandler, HandlerEnums.abs2abs: AbsToAbsHandler, HandlerEnums.combination: CombinationHandler, HandlerEnums.hierarchy: HierarchyHandler, HandlerEnums.axisswitch: AxisSwitchHandler, HandlerEnums.disable: NullHandler, } class MappingParser: def __init__( self, global_uinputs: GlobalUInputs, ): self.global_uinputs = global_uinputs def parse_mappings( self, preset: Preset, context: ContextProtocol, ) -> EventPipelines: """Create a dict with a list of MappingHandler for each InputEvent.""" handlers = [] for mapping in preset: # start with the last handler in the chain, each mapping only has one output, # but may have multiple inputs, therefore the last handler is a good starting # point to assemble the pipeline handler_enum = self._get_output_handler(mapping) constructor = mapping_handler_classes[handler_enum] if not constructor: logger.warning( "a mapping handler '%s' for %s is not implemented", handler_enum, mapping.format_name(), ) continue output_handler = constructor( mapping.input_combination, mapping, context=context, global_uinputs=self.global_uinputs, ) # layer other handlers on top until the outer handler needs ranking or can # directly handle a input event handlers.extend(self._create_event_pipeline(output_handler, context)) # figure out which handlers need ranking and wrap them with hierarchy_handlers need_ranking = defaultdict(set) for handler in handlers.copy(): if handler.needs_ranking(): combination = handler.rank_by() if not combination: raise MappingParsingError( f"{type(handler).__name__} claims to need ranking but does not " f"return a combination to rank by", mapping_handler=handler, ) need_ranking[combination].add(handler) handlers.remove(handler) # the HierarchyHandler's might not be the starting point of the event pipeline, # layer other handlers on top again. ranked_handlers = self._create_hierarchy_handlers(need_ranking) for handler in ranked_handlers: handlers.extend( self._create_event_pipeline(handler, context, ignore_ranking=True) ) # group all handlers by the input events they take care of. One handler might end # up in multiple groups if it takes care of multiple InputEvents event_pipelines: EventPipelines = defaultdict(set) for handler in handlers: assert handler.input_configs for input_config in handler.input_configs: logger.debug( "event-pipeline with entry point: %s %s", get_evdev_constant_name(*input_config.type_and_code), input_config.input_match_hash, ) logger.debug_mapping_handler(handler) event_pipelines[input_config].add(handler) return event_pipelines def _create_event_pipeline( self, handler: MappingHandler, context: ContextProtocol, ignore_ranking=False, ) -> List[MappingHandler]: """Recursively wrap a handler with other handlers until the outer handler needs ranking or is finished wrapping. """ if not handler.needs_wrapping() or ( handler.needs_ranking() and not ignore_ranking ): return [handler] handlers = [] for combination, handler_enum in handler.wrap_with().items(): constructor = mapping_handler_classes[handler_enum] if not constructor: raise NotImplementedError( f"mapping handler {handler_enum} is not implemented" ) super_handler = constructor( combination, handler.mapping, context=context, global_uinputs=self.global_uinputs, ) super_handler.set_sub_handler(handler) for event in combination: # the handler now has a super_handler which takes care about the events. # so we need to hide them on the handler handler.occlude_input_event(event) handlers.extend(self._create_event_pipeline(super_handler, context)) if handler.input_configs: # the handler was only partially wrapped, # we need to return it as a toplevel handler handlers.append(handler) return handlers def _get_output_handler(self, mapping: Mapping) -> HandlerEnums: """Determine the correct output handler. this is used as a starting point for the mapping parser """ if mapping.output_code == DISABLE_CODE or mapping.output_symbol == DISABLE_NAME: return HandlerEnums.disable if mapping.output_symbol: if Parser.is_this_a_macro(mapping.output_symbol): return HandlerEnums.macro return HandlerEnums.key if mapping.output_type == EV_KEY: return HandlerEnums.key input_event = self._maps_axis(mapping.input_combination) if not input_event: raise MappingParsingError( f"This {mapping = } does not map to an axis, key or macro", mapping=Mapping, ) if mapping.output_type == EV_REL: if input_event.type == EV_KEY: return HandlerEnums.btn2rel if input_event.type == EV_REL: return HandlerEnums.rel2rel if input_event.type == EV_ABS: return HandlerEnums.abs2rel if mapping.output_type == EV_ABS: if input_event.type == EV_KEY: return HandlerEnums.btn2abs if input_event.type == EV_REL: return HandlerEnums.rel2abs if input_event.type == EV_ABS: return HandlerEnums.abs2abs raise MappingParsingError( f"the output of {mapping = } is unknown", mapping=Mapping ) def _maps_axis(self, combination: InputCombination) -> Optional[InputConfig]: """Whether this InputCombination contains an InputEvent that is treated as an axis and not a binary (key or button) event. """ for event in combination: if event.defines_analog_input: return event return None def _create_hierarchy_handlers( self, handlers: Dict[InputCombination, Set[MappingHandler]], ) -> Set[MappingHandler]: """Sort handlers by input events and create Hierarchy handlers.""" sorted_handlers = set() all_combinations = handlers.keys() events = set() # gather all InputEvents from all handlers for combination in all_combinations: for event in combination: events.add(event) # create a ranking for each event for event in events: # find all combinations (from handlers) which contain the event combinations_with_event = [ combination for combination in all_combinations if event in combination ] if len(combinations_with_event) == 1: # there was only one handler containing that event return it as is sorted_handlers.update(handlers[combinations_with_event[0]]) continue # there are multiple handler with the same event. # rank them and create the HierarchyHandler sorted_combinations = self._order_combinations( combinations_with_event, event, ) sub_handlers: List[MappingHandler] = [] for combination in sorted_combinations: sub_handlers.extend(handlers[combination]) sorted_handlers.add( HierarchyHandler( sub_handlers, event, self.global_uinputs, ) ) for handler in sub_handlers: # the handler now has a HierarchyHandler which takes care about this event. # so we hide need to hide it on the handler handler.occlude_input_event(event) return sorted_handlers def _order_combinations( self, combinations: List[InputCombination], common_config: InputConfig, ) -> List[InputCombination]: """Reorder the keys according to some rules. such that a combination a+b+c is in front of a+b which is in front of b for a+b+c vs. b+d+e: a+b+c would be in front of b+d+e, because the common key b has the higher index in the a+b+c (1), than in the b+c+d (0) list in this example b would be the common key as for combinations like a+b+c and e+d+c with the common key c: ¯\\_(ツ)_/¯ Parameters ---------- combinations the list which needs ordering common_config the InputConfig all InputCombination's in combinations have in common """ combinations.sort(key=len) for start, end in self._ranges_with_constant_length(combinations.copy()): sub_list = combinations[start:end] sub_list.sort(key=lambda x: x.index(common_config)) combinations[start:end] = sub_list combinations.reverse() return combinations def _ranges_with_constant_length( self, x: Sequence[Sized], ) -> Iterable[Tuple[int, int]]: """Get all ranges of x for which the elements have constant length Parameters ---------- x: Sequence[Sized] l must be ordered by increasing length of elements """ start_idx = 0 last_len = 0 for idx, y in enumerate(x): if len(y) > last_len and idx - start_idx > 1: yield start_idx, idx if len(y) == last_len and idx + 1 == len(x): yield start_idx, idx + 1 if len(y) > last_len: start_idx = idx if len(y) < last_len: raise MappingParsingError( "ranges_with_constant_length was called with an unordered list" ) last_len = len(y) input-remapper-2.1.1/inputremapper/injection/mapping_handlers/null_handler.py000066400000000000000000000040551475433465200277150ustar00rootroot00000000000000# -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings # Copyright (C) 2025 sezanzeb # # This file is part of input-remapper. # # input-remapper is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # input-remapper is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with input-remapper. If not, see . from typing import Dict import evdev from inputremapper.configs.input_config import InputCombination from inputremapper.injection.mapping_handlers.mapping_handler import ( MappingHandler, HandlerEnums, ) from inputremapper.input_event import InputEvent class NullHandler(MappingHandler): """Handler which consumes the event and does nothing.""" def __str__(self): return f"NullHandler for {self.mapping.input_combination}<{id(self)}>" @property def child(self): return "Voids all events" def needs_wrapping(self) -> bool: return False in [ input_.defines_analog_input for input_ in self.mapping.input_combination ] def wrap_with(self) -> Dict[InputCombination, HandlerEnums]: if not self.mapping.input_combination.defines_analog_input: return {self.mapping.input_combination: HandlerEnums.combination} assert len(self.mapping.input_combination) > 1, "nees_wrapping ensures this!" return {self.mapping.input_combination: HandlerEnums.axisswitch} def notify( self, event: InputEvent, source: evdev.InputDevice, suppress: bool = False, ) -> bool: return True def reset(self) -> None: pass input-remapper-2.1.1/inputremapper/injection/mapping_handlers/rel_to_abs_handler.py000066400000000000000000000215121475433465200310510ustar00rootroot00000000000000# -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings # Copyright (C) 2025 sezanzeb # # This file is part of input-remapper. # # input-remapper is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # input-remapper is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with input-remapper. If not, see . import asyncio from typing import Tuple, Dict, Optional import evdev from evdev.ecodes import ( EV_ABS, EV_REL, REL_WHEEL, REL_HWHEEL, REL_HWHEEL_HI_RES, REL_WHEEL_HI_RES, ) from inputremapper import exceptions from inputremapper.configs.input_config import InputCombination, InputConfig from inputremapper.configs.mapping import ( Mapping, WHEEL_SCALING, WHEEL_HI_RES_SCALING, REL_XY_SCALING, DEFAULT_REL_RATE, ) from inputremapper.injection.global_uinputs import GlobalUInputs from inputremapper.injection.mapping_handlers.axis_transform import Transformation from inputremapper.injection.mapping_handlers.mapping_handler import ( MappingHandler, HandlerEnums, InputEventHandler, ) from inputremapper.input_event import InputEvent, EventActions from inputremapper.logging.logger import logger class RelToAbsHandler(MappingHandler): """Handler which transforms EV_REL to EV_ABS events. High EV_REL input results in high EV_ABS output. If no new EV_REL events are seen, the EV_ABS output is set to 0 after release_timeout. """ _map_axis: InputConfig # InputConfig for the relative movement we map _output_axis: Tuple[int, int] # the (type, code) of the output axis _transform: Transformation _target_absinfo: evdev.AbsInfo # infinite loop which centers the output when input stops _recenter_loop: Optional[asyncio.Task] _moving: asyncio.Event # event to notify the _recenter_loop _previous_event: Optional[InputEvent] _observed_rate: float # input events per second def __init__( self, combination: InputCombination, mapping: Mapping, global_uinputs: GlobalUInputs, **_, ) -> None: super().__init__(combination, mapping, global_uinputs) # find the input event we are supposed to map. If the input combination is # BTN_A + REL_X + BTN_B, then use the value of REL_X for the transformation assert (map_axis := combination.find_analog_input_config(type_=EV_REL)) self._map_axis = map_axis assert mapping.output_code is not None assert mapping.output_type == EV_ABS self._output_axis = (mapping.output_type, mapping.output_code) target_uinput = global_uinputs.get_uinput(mapping.target_uinput) assert target_uinput is not None abs_capabilities = target_uinput.capabilities(absinfo=True)[EV_ABS] self._target_absinfo = dict(abs_capabilities)[mapping.output_code] max_ = self._get_default_cutoff() self._transform = Transformation( min_=-max(1, int(max_)), max_=max(1, int(max_)), deadzone=mapping.deadzone, gain=mapping.gain, expo=mapping.expo, ) self._moving = asyncio.Event() self._recenter_loop = None self._previous_event = None self._observed_rate = DEFAULT_REL_RATE def __str__(self): return f"RelToAbsHandler for {self._map_axis}" def __repr__(self): return f"<{str(self)} at {hex(id(self))}>" @property def child(self): # used for logging return ( f"maps to: {self.mapping.get_output_name_constant()} " f"{self.mapping.get_output_type_code()} at " f"{self.mapping.target_uinput}" ) def _observe_rate(self, event: InputEvent): """Watch incoming events and remember how many events appear per second.""" if self._previous_event is not None: delta_time = event.timestamp() - self._previous_event.timestamp() if delta_time == 0: logger.error("Observed two events with the same timestamp") return rate = 1 / delta_time # mice seem to have a constant rate. wheel events are jaggy and the # rate depends on how fast it is turned. if rate > self._observed_rate: logger.debug("Updating rate to %s", rate) self._observed_rate = rate self._calculate_cutoff() self._previous_event = event def _get_default_cutoff(self): """Get the cutoff value assuming the default input rate.""" if self._map_axis.code in [REL_WHEEL, REL_HWHEEL]: return self.mapping.rel_to_abs_input_cutoff * WHEEL_SCALING if self._map_axis.code in [REL_WHEEL_HI_RES, REL_HWHEEL_HI_RES]: return self.mapping.rel_to_abs_input_cutoff * WHEEL_HI_RES_SCALING return self.mapping.rel_to_abs_input_cutoff * REL_XY_SCALING def _calculate_cutoff(self): """Correct the default cutoff with the observed input rate, and set it.""" # Mice that have very high input rates report low values at the same time. # If the rate is high, use a lower cutoff-value. If the rate is low, use a # higher cutoff-value. cutoff = self._get_default_cutoff() cutoff *= DEFAULT_REL_RATE / self._observed_rate self._transform.set_range(-max(1, int(cutoff)), max(1, int(cutoff))) def notify( self, event: InputEvent, source: evdev.InputDevice, suppress: bool = False, ) -> bool: self._observe_rate(event) if event.input_match_hash != self._map_axis.input_match_hash: return False if EventActions.recenter in event.actions: if self._recenter_loop: self._recenter_loop.cancel() self._recenter() return True if not self._recenter_loop or self._recenter_loop.cancelled(): self._recenter_loop = asyncio.create_task(self._create_recenter_loop()) self._moving.set() # notify the _recenter_loop try: self._write(self._scale_to_target(self._transform(event.value))) return True except (exceptions.UinputNotAvailable, exceptions.EventNotHandled): return False def reset(self) -> None: if self._recenter_loop: self._recenter_loop.cancel() self._recenter() def _recenter(self) -> None: """Recenter the output.""" self._write(self._scale_to_target(0)) async def _create_recenter_loop(self) -> None: """Coroutine which waits for the input to start moving, then waits until the input stops moving, centers the output and repeat. Runs forever. """ while True: await self._moving.wait() # input moving started while ( await asyncio.wait( (asyncio.create_task(self._moving.wait()),), timeout=self.mapping.release_timeout, ) )[0]: self._moving.clear() # still moving self._recenter() # input moving stopped def _scale_to_target(self, x: float) -> int: """Scales a x value between -1 and 1 to an integer between target_absinfo.min and target_absinfo.max input values above 1 or below -1 are clamped to the extreme values """ factor = (self._target_absinfo.max - self._target_absinfo.min) / 2 offset = self._target_absinfo.min + factor y = factor * x + offset if y > offset: return int(min(self._target_absinfo.max, y)) else: return int(max(self._target_absinfo.min, y)) def _write(self, value: int) -> None: """Inject.""" try: self.global_uinputs.write( (*self._output_axis, value), self.mapping.target_uinput, ) except OverflowError: # screwed up the calculation of the event value logger.error("OverflowError (%s, %s, %s)", *self._output_axis, value) def needs_wrapping(self) -> bool: return len(self.input_configs) > 1 def set_sub_handler(self, handler: InputEventHandler) -> None: assert False # cannot have a sub-handler def wrap_with(self) -> Dict[InputCombination, HandlerEnums]: if self.needs_wrapping(): return {InputCombination(self.input_configs): HandlerEnums.axisswitch} return {} input-remapper-2.1.1/inputremapper/injection/mapping_handlers/rel_to_btn_handler.py000066400000000000000000000114561475433465200310750ustar00rootroot00000000000000# -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings # Copyright (C) 2025 sezanzeb # # This file is part of input-remapper. # # input-remapper is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # input-remapper is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with input-remapper. If not, see . import asyncio import time import evdev from evdev.ecodes import EV_REL from inputremapper.configs.input_config import InputCombination, InputConfig from inputremapper.configs.mapping import Mapping from inputremapper.injection.global_uinputs import GlobalUInputs from inputremapper.injection.mapping_handlers.mapping_handler import ( MappingHandler, InputEventHandler, ) from inputremapper.input_event import InputEvent, EventActions from inputremapper.logging.logger import logger class RelToBtnHandler(MappingHandler): """Handler which transforms an EV_REL to a button event and sends that to a sub_handler adheres to the MappingHandler protocol """ _active: bool _input_config: InputConfig _last_activation: float _sub_handler: InputEventHandler def __init__( self, combination: InputCombination, mapping: Mapping, global_uinputs: GlobalUInputs, **_, ) -> None: super().__init__(combination, mapping, global_uinputs) self._active = False self._input_config = combination[0] self._last_activation = time.time() self._abort_release = False assert self._input_config.analog_threshold != 0 assert len(combination) == 1 def __str__(self): return f'RelToBtnHandler for "{self._input_config}"' def __repr__(self): return f"<{str(self)} at {hex(id(self))}>" @property def child(self): # used for logging return self._sub_handler async def _stage_release( self, source: InputEvent, suppress: bool, ): while time.time() < self._last_activation + self.mapping.release_timeout: await asyncio.sleep(1 / self.mapping.rel_rate) if self._abort_release: self._abort_release = False return event = InputEvent( 0, 0, *self._input_config.type_and_code, value=0, actions=(EventActions.as_key,), origin_hash=self._input_config.origin_hash, ) logger.debug("Sending %s to sub_handler", event) self._sub_handler.notify(event, source, suppress) self._active = False def notify( self, event: InputEvent, source: evdev.InputDevice, suppress: bool = False, ) -> bool: assert event.type == EV_REL if event.input_match_hash != self._input_config.input_match_hash: return False assert (threshold := self._input_config.analog_threshold) value = event.value if (value < threshold > 0) or (value > threshold < 0): if self._active: # the axis is below the threshold and the stage_release # function is running if self.mapping.force_release_timeout: # consume the event return True event = event.modify(value=0, actions=(EventActions.as_key,)) logger.debug("Sending %s to sub_handler", event) self._abort_release = True else: # don't consume the event. # We could return True to consume events return False else: # the axis is above the threshold if not self._active: asyncio.ensure_future(self._stage_release(source, suppress)) if value >= threshold > 0: direction = EventActions.positive_trigger else: direction = EventActions.negative_trigger self._last_activation = time.time() event = event.modify(value=1, actions=(EventActions.as_key, direction)) self._active = bool(event.value) # logger.debug("Sending %s to sub_handler", event) return self._sub_handler.notify(event, source=source, suppress=suppress) def reset(self) -> None: if self._active: self._abort_release = True self._active = False self._sub_handler.reset() input-remapper-2.1.1/inputremapper/injection/mapping_handlers/rel_to_rel_handler.py000066400000000000000000000232311475433465200310660ustar00rootroot00000000000000# -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings # Copyright (C) 2025 sezanzeb # # This file is part of input-remapper. # # input-remapper is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # input-remapper is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with input-remapper. If not, see . import math from typing import Dict import evdev from evdev.ecodes import ( EV_REL, REL_WHEEL, REL_HWHEEL, REL_WHEEL_HI_RES, REL_HWHEEL_HI_RES, ) from inputremapper import exceptions from inputremapper.configs.input_config import InputCombination, InputConfig from inputremapper.configs.mapping import ( Mapping, REL_XY_SCALING, WHEEL_SCALING, WHEEL_HI_RES_SCALING, ) from inputremapper.injection.global_uinputs import GlobalUInputs from inputremapper.injection.mapping_handlers.axis_transform import Transformation from inputremapper.injection.mapping_handlers.mapping_handler import ( MappingHandler, HandlerEnums, InputEventHandler, ) from inputremapper.input_event import InputEvent from inputremapper.logging.logger import logger def is_wheel(event) -> bool: return event.type == EV_REL and event.code in (REL_WHEEL, REL_HWHEEL) def is_high_res_wheel(event) -> bool: return event.type == EV_REL and event.code in (REL_WHEEL_HI_RES, REL_HWHEEL_HI_RES) class Remainder: _scale: float _remainder: float def __init__(self, scale: float): self._scale = scale self._remainder = 0 def input(self, value: float) -> int: # if the mouse moves very slow, it might not move at all because of the # int-conversion (which is required when writing). store the remainder # (the decimal places) and add it up, until the mouse moves a little. scaled = value * self._scale + self._remainder self._remainder = math.fmod(scaled, 1) return int(scaled) class RelToRelHandler(MappingHandler): """Handler which transforms EV_REL to EV_REL events.""" _input_config: InputConfig # the relative movement we map _max_observed_input: float _transform: Transformation _remainder: Remainder _wheel_remainder: Remainder _wheel_hi_res_remainder: Remainder def __init__( self, combination: InputCombination, mapping: Mapping, global_uinputs: GlobalUInputs, **_, ) -> None: super().__init__(combination, mapping, global_uinputs) assert self.mapping.output_code is not None # find the input event we are supposed to map. If the input combination is # BTN_A + REL_X + BTN_B, then use the value of REL_X for the transformation input_config = combination.find_analog_input_config(type_=EV_REL) assert input_config is not None self._input_config = input_config self._max_observed_input = 1 self._remainder = Remainder(REL_XY_SCALING) self._wheel_remainder = Remainder(WHEEL_SCALING) self._wheel_hi_res_remainder = Remainder(WHEEL_HI_RES_SCALING) self._transform = Transformation( max_=1, min_=-1, deadzone=self.mapping.deadzone, gain=self.mapping.gain, expo=self.mapping.expo, ) def __str__(self): return f"RelToRelHandler for {self._input_config}" def __repr__(self): return f"<{str(self)} at {hex(id(self))}>" @property def child(self): # used for logging return f"maps to: {self.mapping.output_code} at {self.mapping.target_uinput}" def _should_map(self, event: InputEvent): """Check if this input event is relevant for this handler.""" return event.input_match_hash == self._input_config.input_match_hash def notify( self, event: InputEvent, source: evdev.InputDevice, suppress: bool = False, ) -> bool: if not self._should_map(event): return False """ There was the idea to define speed as "movemnt per second". There are deprecated mapping variables in this explanation. rel2rel example: - input every 0.1s (`input_rate` of 10 events/s), value of 200 - input speed is 2000, because in 1 second a value of 2000 acumulates - `input_rel_speed` is a const defined as 4000 px/s, how fast mice usually move - `transformed = Transformation(input.value, max=input_rel_speed / input_rate)` - get 0.5 because the expo is 0 - `abs_to_rel_speed` is 5000 - inject 2500 therefore per second, making it a bit faster - divide 2500 by the rate of 10 to inject a value of 250 each time input occurs ``` output_value = Transformation( input.value, max=input_rel_speed / input_rate ) * abs_to_rel_speed / input_rate ``` The input_rel_speed could be used here instead of abs_to_rel_speed, because the gain already controls the speed. In that case it would be a 1:1 ratio of input-to-output value if the gain is 1. for wheel and wheel_hi_res, different input speed constants must be set. abs2rel needs a base value for the output, so `abs_to_rel_speed` is still required. `abs_to_rel_speed / rel_rate * transform(input.value, max=absinfo.max)` is the output value. Both abs_to_rel_speed and the transformation-gain control speed. if abs_to_rel_speed controls speed in the abs2rel output, it should also do so in other handlers that have EV_REL output. unfortunately input_rate needs to be determined during runtime, which screws the overall speed up when slowly moving the input device in the beginning, because slow input is thought to be the regular input. --- transforming from rate based to rate based speed values won't work well. better to use fractional speed values. REL_X of 40 = REL_WHEEL of 1 = REL_WHEE_HI_RES of 1/120 this is why abs_to_rel_speed does not affect the rel_to_rel handler. The expo calculation will be wrong in the beginning, because it is based on the highest observed value. The overall gain will be fine though. """ input_value = float(event.value) # scale down now, the remainder calculation scales up by the same factor later # depending on what kind of event this becomes. if event.is_wheel_event: input_value /= WHEEL_SCALING elif event.is_wheel_hi_res_event: input_value /= WHEEL_HI_RES_SCALING else: # even though the input rate is unknown we can apply REL_XY_SCALING, which # is based on 60hz or something, because the un-scaling also uses values # based on 60hz. So the rate cancels out input_value /= REL_XY_SCALING if abs(input_value) > self._max_observed_input: self._max_observed_input = abs(input_value) # If _max_observed_input is wrong when the injection starts and the correct # value learned during runtime, results can be weird at the beginning. # If expo and deadzone are not set, then it is linear and doesn't matter. transformed = self._transform(input_value / self._max_observed_input) transformed *= self._max_observed_input is_wheel_output = self.mapping.is_wheel_output() is_hi_res_wheel_output = self.mapping.is_high_res_wheel_output() horizontal = self.mapping.output_code in ( REL_HWHEEL_HI_RES, REL_HWHEEL, ) try: if is_wheel_output or is_hi_res_wheel_output: # inject both kinds of wheels, otherwise wheels don't work for some # people. See issue #354 self._write( REL_HWHEEL if horizontal else REL_WHEEL, self._wheel_remainder.input(transformed), ) self._write( REL_HWHEEL_HI_RES if horizontal else REL_WHEEL_HI_RES, self._wheel_hi_res_remainder.input(transformed), ) else: self._write( self.mapping.output_code, self._remainder.input(transformed), ) return True except OverflowError: # screwed up the calculation of the event value logger.error("OverflowError while handling %s", event) return True except (exceptions.UinputNotAvailable, exceptions.EventNotHandled): return False def reset(self) -> None: pass def _write(self, code: int, value: int): if value == 0: return self.global_uinputs.write( (EV_REL, code, value), self.mapping.target_uinput, ) def needs_wrapping(self) -> bool: return len(self.input_configs) > 1 def set_sub_handler(self, handler: InputEventHandler) -> None: assert False # cannot have a sub-handler def wrap_with(self) -> Dict[InputCombination, HandlerEnums]: if self.needs_wrapping(): return {InputCombination(self.input_configs): HandlerEnums.axisswitch} return {} input-remapper-2.1.1/inputremapper/injection/numlock.py000066400000000000000000000045271475433465200234070ustar00rootroot00000000000000# -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings # Copyright (C) 2025 sezanzeb # # This file is part of input-remapper. # # input-remapper is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # input-remapper is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with input-remapper. If not, see . """Functions to handle numlocks. For unknown reasons the numlock status can change when starting injections, which is why these functions exist. """ import re import subprocess from inputremapper.logging.logger import logger def is_numlock_on(): """Get the current state of the numlock.""" try: xset_q = subprocess.check_output( ["xset", "q"], stderr=subprocess.STDOUT, ).decode() num_lock_status = re.search(r"Num Lock:\s+(.+?)\s", xset_q) if num_lock_status is not None: return num_lock_status[1] == "on" return False except (FileNotFoundError, subprocess.CalledProcessError): # tty return None def set_numlock(state): """Set the numlock to a given state of True or False.""" if state is None: return value = {True: "on", False: "off"}[state] try: subprocess.check_output(["numlockx", value]) except subprocess.CalledProcessError: # might be in a tty pass except FileNotFoundError: # doesn't seem to be installed everywhere logger.debug("numlockx not found") def ensure_numlock(func): """Decorator to reset the numlock to its initial state afterwards.""" def wrapped(*args, **kwargs): # for some reason, grabbing a device can modify the num lock state. # remember it and apply back later numlock_before = is_numlock_on() result = func(*args, **kwargs) set_numlock(numlock_before) return result return wrapped input-remapper-2.1.1/inputremapper/input_event.py000066400000000000000000000175171475433465200223200ustar00rootroot00000000000000# -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings # Copyright (C) 2025 sezanzeb # # This file is part of input-remapper. # # input-remapper is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # input-remapper is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with input-remapper. If not, see . from __future__ import annotations import enum from dataclasses import dataclass from typing import Tuple, Optional, Hashable, Literal import evdev from evdev import ecodes from inputremapper.utils import get_evdev_constant_name, DeviceHash class EventActions(enum.Enum): """Additional information an InputEvent can send through the event pipeline.""" as_key = enum.auto() # treat this event as a key event recenter = enum.auto() # recenter the axis when receiving this none = enum.auto() # used in combination with as_key, for originally abs or rel events positive_trigger = enum.auto() # original event was positive direction negative_trigger = enum.auto() # original event was negative direction # Todo: add slots=True as soon as python 3.10 is in common distros @dataclass(frozen=True) class InputEvent: """Events that are generated during runtime. Is a drop-in replacement for evdev.InputEvent """ sec: int usec: int type: int code: int value: int actions: Tuple[EventActions, ...] = () origin_hash: Optional[DeviceHash] = None def __eq__(self, other: InputEvent | evdev.InputEvent | Tuple[int, int, int]): # useful in tests if isinstance(other, InputEvent) or isinstance(other, evdev.InputEvent): return self.event_tuple == (other.type, other.code, other.value) if isinstance(other, tuple): return self.event_tuple == other raise TypeError(f"cannot compare {type(other)} with InputEvent") @staticmethod def validate_event(event): """Test if the event is valid.""" if not isinstance(event.type, int): raise TypeError(f"Expected type to be an int, but got {event.type}") if not isinstance(event.code, int): raise TypeError(f"Expected code to be an int, but got {event.code}") if not isinstance(event.value, int): # this happened to me because I screwed stuff up raise TypeError(f"Expected value to be an int, but got {event.value}") return event @property def input_match_hash(self) -> Hashable: """a Hashable object which is intended to match the InputEvent with a InputConfig. """ return self.type, self.code, self.origin_hash @classmethod def from_event( cls, event: evdev.InputEvent, origin_hash: Optional[DeviceHash] = None, ) -> InputEvent: """Create a InputEvent from another InputEvent or evdev.InputEvent.""" try: return cls( event.sec, event.usec, event.type, event.code, event.value, origin_hash=origin_hash, ) except AttributeError as exception: raise TypeError( f"Failed to create InputEvent from {event = }" ) from exception @classmethod def from_tuple( cls, event_tuple: Tuple[int, int, int], origin_hash: Optional[DeviceHash] = None, ) -> InputEvent: """Create a InputEvent from a (type, code, value) tuple.""" # use this as rarely as possible. Construct objects early on and pass them # around instead of passing around integers if len(event_tuple) != 3: raise TypeError( f"failed to create InputEvent {event_tuple = } must have length 3" ) return cls.validate_event( cls( 0, 0, int(event_tuple[0]), int(event_tuple[1]), int(event_tuple[2]), origin_hash=origin_hash, ) ) @classmethod def abs(cls, code: int, value: int, origin_hash: Optional[DeviceHash] = None): """Create an abs event, like joystick movements.""" return cls.validate_event( cls( 0, 0, ecodes.EV_ABS, code, value, origin_hash=origin_hash, ) ) @classmethod def rel(cls, code: int, value: int, origin_hash: Optional[str] = None): """Create a rel event, like mouse movements.""" return cls.validate_event( cls( 0, 0, ecodes.EV_REL, code, value, origin_hash=origin_hash, ) ) @classmethod def key(cls, code: int, value: Literal[0, 1], origin_hash: Optional[str] = None): """Create a key event, like keyboard keys or gamepad buttons. A value of 1 means "press", a value of 0 means "release". """ return cls.validate_event( cls( 0, 0, ecodes.EV_KEY, code, value, origin_hash=origin_hash, ) ) @property def type_and_code(self) -> Tuple[int, int]: """Event type, code.""" return self.type, self.code @property def event_tuple(self) -> Tuple[int, int, int]: """Event type, code, value.""" return self.type, self.code, self.value @property def is_key_event(self) -> bool: """Whether this is interpreted as a key event.""" return self.type == evdev.ecodes.EV_KEY or EventActions.as_key in self.actions @property def is_wheel_event(self) -> bool: """Whether this is interpreted as a key event.""" return self.type == evdev.ecodes.EV_REL and self.code in [ ecodes.REL_WHEEL, ecodes.REL_HWHEEL, ] @property def is_wheel_hi_res_event(self) -> bool: """Whether this is interpreted as a key event.""" return self.type == evdev.ecodes.EV_REL and self.code in [ ecodes.REL_WHEEL_HI_RES, ecodes.REL_HWHEEL_HI_RES, ] def __str__(self): name = get_evdev_constant_name(self.type, self.code) return f"InputEvent for {self.event_tuple} {name}" def __repr__(self): return f"<{str(self)} at {hex(id(self))}>" def timestamp(self): """Return the unix timestamp of when the event was seen.""" return self.sec + self.usec / 1000000 def modify( self, sec: Optional[int] = None, usec: Optional[int] = None, type_: Optional[int] = None, code: Optional[int] = None, value: Optional[int] = None, actions: Optional[Tuple[EventActions, ...]] = None, origin_hash: Optional[str] = None, ) -> InputEvent: """Return a new modified event.""" return InputEvent( sec if sec is not None else self.sec, usec if usec is not None else self.usec, type_ if type_ is not None else self.type, code if code is not None else self.code, value if value is not None else self.value, actions if actions is not None else self.actions, origin_hash=origin_hash if origin_hash is not None else self.origin_hash, ) input-remapper-2.1.1/inputremapper/ipc/000077500000000000000000000000001475433465200201465ustar00rootroot00000000000000input-remapper-2.1.1/inputremapper/ipc/__init__.py000066400000000000000000000020121475433465200222520ustar00rootroot00000000000000# -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings # Copyright (C) 2025 sezanzeb # # This file is part of input-remapper. # # input-remapper is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # input-remapper is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with input-remapper. If not, see . """Since I'm not forking, I can't use multiprocessing.Pipe. Processes that need privileges are spawned with pkexec, which connect to known pipe paths to communicate with the non-privileged parent process. """ input-remapper-2.1.1/inputremapper/ipc/pipe.py000066400000000000000000000137471475433465200214710ustar00rootroot00000000000000# -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings # Copyright (C) 2025 sezanzeb # # This file is part of input-remapper. # # input-remapper is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # input-remapper is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with input-remapper. If not, see . """Named bidirectional non-blocking pipes. >>> p1 = Pipe('foo') >>> p2 = Pipe('foo') >>> p1.send(1) >>> p2.poll() >>> p2.recv() >>> p2.send(2) >>> p1.poll() >>> p1.recv() Beware that pipes read any available messages, even those written by themselves. """ import asyncio import json import os import time from typing import Optional, AsyncIterator, Union from inputremapper.configs.paths import PathUtils from inputremapper.logging.logger import logger class Pipe: """Pipe object. This is not for secure communication. If pipes already exist, they will be used, but existing pipes might have open permissions! Only use this for stuff that non-privileged users would be allowed to read. """ def __init__(self, path): """Create a pipe, or open it if it already exists.""" self._path = path self._unread = [] self._created_at = time.time() self._transport: Optional[asyncio.ReadTransport] = None self._async_iterator: Optional[AsyncIterator] = None paths = (f"{path}r", f"{path}w") PathUtils.mkdir(os.path.dirname(path)) if not os.path.exists(paths[0]): logger.debug("Creating new pipes %s", paths) # The fd the link points to is closed, or none ever existed # If there is a link, remove it. if os.path.islink(paths[0]): os.remove(paths[0]) if os.path.islink(paths[1]): os.remove(paths[1]) self._fds = os.pipe() fds_dir = f"/proc/{os.getpid()}/fd/" PathUtils.chown(f"{fds_dir}{self._fds[0]}") PathUtils.chown(f"{fds_dir}{self._fds[1]}") # to make it accessible by path constants, create symlinks os.symlink(f"{fds_dir}{self._fds[0]}", paths[0]) os.symlink(f"{fds_dir}{self._fds[1]}", paths[1]) else: logger.debug("Using existing pipes %s", paths) # thanks to os.O_NONBLOCK, readline will return b'' when there # is nothing to read self._fds = ( os.open(paths[0], os.O_RDONLY | os.O_NONBLOCK), os.open(paths[1], os.O_WRONLY | os.O_NONBLOCK), ) self._handles = (open(self._fds[0], "r"), open(self._fds[1], "w")) # clear the pipe of any contents, to avoid leftover messages from breaking # the reader-client or reader-service while self.poll(): leftover = self.recv() logger.debug('Cleared leftover message "%s"', leftover) def __del__(self): if self._transport: logger.debug("closing transport") self._transport.close() for file in self._handles: file.close() def recv(self): """Read an object from the pipe or None if nothing available. Doesn't transmit pickles, to avoid injection attacks on the privileged reader-service. Only messages that can be converted to json are allowed. """ if len(self._unread) > 0: return self._unread.pop(0) line = self._handles[0].readline() if len(line) == 0: return None return self._get_msg(line) def _get_msg(self, line: str): parsed = json.loads(line) if parsed[0] < self._created_at and os.environ.get("UNITTEST"): # important to avoid race conditions between multiple unittests, # for example old terminate messages reaching a new instance of # the reader-service. logger.debug("Ignoring old message %s", parsed) return None return parsed[1] def send(self, message: Union[str, int, float, dict, list, tuple]): """Write a serializable object to the pipe.""" dump = json.dumps((time.time(), message)) # there aren't any newlines supposed to be, # but if there are it breaks readline(). self._handles[1].write(dump.replace("\n", "")) self._handles[1].write("\n") self._handles[1].flush() def poll(self): """Check if there is anything that can be read.""" if len(self._unread) > 0: return True # using select.select apparently won't mark the pipe as ready # anymore when there are multiple lines to read but only a single # line is retreived. Using read instead. msg = self.recv() if msg is not None: self._unread.append(msg) return len(self._unread) > 0 def fileno(self): """Compatibility to select.select.""" return self._handles[0].fileno() def __aiter__(self): return self async def __anext__(self): if not self._async_iterator: loop = asyncio.get_running_loop() reader = asyncio.StreamReader() self._transport, _ = await loop.connect_read_pipe( lambda: asyncio.StreamReaderProtocol(reader), self._handles[0] ) self._async_iterator = reader.__aiter__() return self._get_msg(await self._async_iterator.__anext__()) async def recv_async(self): """Read the next line with async. Do not use this when using the async for loop.""" return await self.__aiter__().__anext__() input-remapper-2.1.1/inputremapper/ipc/shared_dict.py000066400000000000000000000074301475433465200227750ustar00rootroot00000000000000# -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings # Copyright (C) 2025 sezanzeb # # This file is part of input-remapper. # # input-remapper is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # input-remapper is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with input-remapper. If not, see . """Share a dictionary across processes.""" import atexit import multiprocessing import select from typing import Optional, Any from inputremapper.logging.logger import logger class SharedDict: """Share a dictionary across processes.""" # because unittests terminate all child processes in cleanup I can't use # multiprocessing.Manager def __init__(self): """Create a shared dictionary.""" super().__init__() # To avoid blocking forever if something goes wrong. The maximum # observed time communication takes was 0.001 for me on a slow pc self._timeout = 0.02 self.pipe = multiprocessing.Pipe() self.process = None atexit.register(self._stop) def start(self): """Ensure the process to manage the dictionary is running.""" if self.process is not None and self.process.is_alive(): logger.debug("SharedDict process already running") return # if the manager has already been running in the past but stopped # for some reason, the dictionary contents are lost. logger.debug("Starting SharedDict process") self.process = multiprocessing.Process(target=self.manage) self.process.start() def manage(self): """Manage the dictionary, handle read and write requests.""" logger.debug("SharedDict process started") shared_dict = {} while True: message = self.pipe[0].recv() logger.debug("SharedDict got %s", message) if message[0] == "stop": return if message[0] == "set": shared_dict[message[1]] = message[2] if message[0] == "clear": shared_dict.clear() if message[0] == "get": self.pipe[0].send(shared_dict.get(message[1])) if message[0] == "ping": self.pipe[0].send("pong") def _stop(self): """Stop the managing process.""" self.pipe[1].send(("stop",)) def _clear(self): """Clears the memory.""" self.pipe[1].send(("clear",)) def get(self, key: str): """Get a value from the dictionary. If it doesn't exist, returns None. """ return self[key] def is_alive(self, timeout: Optional[int] = None): """Check if the manager process is running.""" self.pipe[1].send(("ping",)) select.select([self.pipe[1]], [], [], timeout or self._timeout) if self.pipe[1].poll(): return self.pipe[1].recv() == "pong" return False def __setitem__(self, key: str, value: Any): self.pipe[1].send(("set", key, value)) def __getitem__(self, key: str): self.pipe[1].send(("get", key)) select.select([self.pipe[1]], [], [], self._timeout) if self.pipe[1].poll(): return self.pipe[1].recv() logger.error("select.select timed out") return None def __del__(self): self._stop() input-remapper-2.1.1/inputremapper/ipc/socket.py000066400000000000000000000223121475433465200220100ustar00rootroot00000000000000# -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings # Copyright (C) 2025 sezanzeb # # This file is part of input-remapper. # # input-remapper is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # input-remapper is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with input-remapper. If not, see . """Non-blocking abstraction of unix domain sockets. >>> server = Server('foo') >>> client = Client('foo') >>> server.send(1) >>> client.poll() >>> client.recv() >>> client.send(2) >>> server.poll() >>> server.recv() I seems harder to sniff on a socket than using pipes for other non-root processes, but it doesn't guarantee security. As long as the GUI is open and not running as root user, it is most likely possible to somehow log keycodes by looking into the memory of the gui process (just like with most other applications because they end up receiving keyboard input as well). It still appears to be a bit overkill to use a socket considering pipes are much easier to handle. """ # Issues: # - Tests don't pass with Server and Client instead of Pipe for reader-client # and service communication or something # - Had one case of a test that was blocking forever, seems very rare. # - Hard to debug, generally very problematic compared to Pipes # The tool works fine, it's just the tests. BrokenPipe errors reported # by _Server all the time. import json import os import select import socket import time from typing import Union from inputremapper.configs.paths import PathUtils from inputremapper.logging.logger import logger # something funny that most likely won't appear in messages. # also add some ones so that 01 in the payload won't offset # a match by 2 bits END = b"\x55\x55\xff\x55" # should be 01010101 01010101 11111111 01010101 ENCODING = "utf8" # reusing existing objects makes tests easier, no headaches about closing # and reopening anymore. The ui also only runs only one instance of each all # the time. existing_servers = {} existing_clients = {} class Base: """Abstract base class for Socket and Client.""" def __init__(self, path): self._path = path self._unread = [] self.unsent = [] PathUtils.mkdir(os.path.dirname(path)) self.connection = None self.socket = None self._created_at = 0 self.reset() def reset(self): """Ignore older messages than now.""" # ensure it is connected self.connect() self._created_at = time.time() def connect(self): """Returns True if connected, and if not attempts to connect.""" raise NotImplementedError def fileno(self): """For compatibility with select.select.""" raise NotImplementedError def reconnect(self): """Try to make a new connection.""" raise NotImplementedError def _receive_new_messages(self): if not self.connect(): logger.debug("Not connected") return messages = b"" attempts = 0 while True: try: chunk = self.connection.recvmsg(4096)[0] messages += chunk if len(chunk) == 0: # select keeps telling me the socket has messages # ready to be received, and I keep getting empty # buffers. Happened during a test that ran two reader-service # processes without stopping the first one. attempts += 1 if attempts == 2 or not self.reconnect(): return except (socket.timeout, BlockingIOError): break split = messages.split(END) for message in split: if len(message) > 0: parsed = json.loads(message.decode(ENCODING)) if parsed[0] < self._created_at: # important to avoid race conditions between multiple # unittests, for example old terminate messages reaching # a new instance of the reader-service. logger.debug("Ignoring old message %s", parsed) continue self._unread.append(parsed[1]) def recv(self): """Get the next message or None if nothing to read. Doesn't transmit pickles, to avoid injection attacks on the privileged reader-service. Only messages that can be converted to json are allowed. """ self._receive_new_messages() if len(self._unread) == 0: return None return self._unread.pop(0) def poll(self): """Check if a message to read is available.""" if len(self._unread) > 0: return True self._receive_new_messages() return len(self._unread) > 0 def send(self, message: Union[str, int, float, dict, list, tuple]): """Send json-serializable messages.""" dump = bytes(json.dumps((time.time(), message)), ENCODING) self.unsent.append(dump) if not self.connect(): logger.debug("Not connected") return def send_all(): while len(self.unsent) > 0: unsent = self.unsent[0] self.connection.sendall(unsent + END) # sending worked, remove message self.unsent.pop(0) # attempt sending twice in case it fails try: send_all() except BrokenPipeError: if not self.reconnect(): logger.error( '%s: The other side of "%s" disappeared', type(self).__name__, self._path, ) return try: send_all() except BrokenPipeError as error: logger.error( '%s: Failed to send via "%s": %s', type(self).__name__, self._path, error, ) class _Client(Base): """A socket that can be written to and read from.""" def connect(self): if self.socket is not None: return True try: _socket = socket.socket(socket.AF_UNIX) _socket.connect(self._path) logger.debug('Connected to socket: "%s"', self._path) _socket.setblocking(False) except Exception as error: logger.debug('Failed to connect to "%s": "%s"', self._path, error) return False self.socket = _socket self.connection = _socket existing_clients[self._path] = self return True def fileno(self): """For compatibility with select.select.""" self.connect() return self.socket.fileno() def reconnect(self): self.connection = None self.socket = None return self.connect() def Client(path): if path in existing_clients: # ensure it is running, might have been closed existing_clients[path].reset() return existing_clients[path] return _Client(path) class _Server(Base): """A socket that can be written to and read from. It accepts one connection at a time, and drops old connections if a new one is in sight. """ def connect(self): if self.socket is None: if os.path.exists(self._path): # leftover from the previous execution os.remove(self._path) _socket = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) _socket.bind(self._path) _socket.listen(1) PathUtils.chown(self._path) logger.debug('Created socket: "%s"', self._path) self.socket = _socket self.socket.setblocking(False) existing_servers[self._path] = self incoming = len(select.select([self.socket], [], [], 0)[0]) != 0 if not incoming and self.connection is None: # no existing connection, no client attempting to connect return False if not incoming and self.connection is not None: # old connection return True if incoming: logger.debug('Incoming connection: "%s"', self._path) connection = self.socket.accept()[0] self.connection = connection self.connection.setblocking(False) return True def fileno(self): """For compatibility with select.select.""" self.connect() return self.connection.fileno() def reconnect(self): self.connection = None return self.connect() def Server(path): if path in existing_servers: # ensure it is running, might have been closed existing_servers[path].reset() return existing_servers[path] return _Server(path) input-remapper-2.1.1/inputremapper/logging/000077500000000000000000000000001475433465200210215ustar00rootroot00000000000000input-remapper-2.1.1/inputremapper/logging/__init__.py000066400000000000000000000000001475433465200231200ustar00rootroot00000000000000input-remapper-2.1.1/inputremapper/logging/formatter.py000066400000000000000000000121501475433465200233750ustar00rootroot00000000000000# -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings # Copyright (C) 2025 sezanzeb # # This file is part of input-remapper. # # input-remapper is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # input-remapper is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with input-remapper. If not, see . """Logging setup for input-remapper.""" import logging import os import sys from datetime import datetime from typing import Dict class ColorfulFormatter(logging.Formatter): """Overwritten Formatter to print nicer logs. It colors all logs from the same filename in the same color to visually group them together. It also adds process name, process id, file, line-number and time. If debug mode is not active, it will not do any of this. """ def __init__(self, debug_mode: bool = False): super().__init__() self.debug_mode = debug_mode self.file_color_mapping: Dict[str, int] = {} # see https://en.wikipedia.org/wiki/ANSI_escape_code#8-bit self.allowed_colors = [] for r in range(0, 6): for g in range(0, 6): for b in range(0, 6): # https://stackoverflow.com/a/596243 brightness = 0.2126 * r + 0.7152 * g + 0.0722 * b if brightness < 1: # prefer light colors, because most people have a dark # terminal background continue if g + b <= 1: # red makes it look like it's an error continue if abs(g - b) < 2 and abs(b - r) < 2 and abs(r - g) < 2: # no colors that are too grey continue self.allowed_colors.append(self._get_ansi_code(r, g, b)) self.level_based_colors = { logging.WARNING: 11, logging.ERROR: 9, logging.FATAL: 9, } def _get_ansi_code(self, r: int, g: int, b: int) -> int: return 16 + b + (6 * g) + (36 * r) def _word_to_color(self, word: str) -> int: """Convert a word to a 8bit ansi color code.""" digit_sum = sum([ord(char) for char in word]) index = digit_sum % len(self.allowed_colors) return self.allowed_colors[index] def _allocate_debug_log_color(self, record: logging.LogRecord): """Get the color that represents the source file of the log.""" if self.file_color_mapping.get(record.filename) is not None: return self.file_color_mapping[record.filename] color = self._word_to_color(record.filename) if self.file_color_mapping.get(record.filename) is None: # calculate the color for each file only once self.file_color_mapping[record.filename] = color return color def _get_process_name(self): """Generate a beaitiful to read name for this process.""" process_path = sys.argv[0] process_name = process_path.split("/")[-1] if "input-remapper-" in process_name: process_name = process_name.replace("input-remapper-", "") if process_name == "gtk": process_name = "GUI" return process_name def _get_format(self, record: logging.LogRecord): """Generate a message format string.""" if record.levelno == logging.INFO and not self.debug_mode: # if not launched with --debug, then don't print "INFO:" return "%(message)s" if not self.debug_mode: color = self.level_based_colors.get(record.levelno, 9) return f"\033[38;5;{color}m%(levelname)s\033[0m: %(message)s" color = self._allocate_debug_log_color(record) if record.levelno in [logging.ERROR, logging.WARNING, logging.FATAL]: # underline style = f"\033[4;38;5;{color}m" else: style = f"\033[38;5;{color}m" process_color = self._word_to_color(f"{os.getpid()}{sys.argv[0]}") return ( # noqa f'{datetime.now().strftime("%H:%M:%S.%f")} ' f"\033[38;5;{process_color}m" # color f"{os.getpid()} " f"{self._get_process_name()} " "\033[0m" # end style f"{style}" f"%(levelname)s " f"%(filename)s:%(lineno)d: " "%(message)s" "\033[0m" # end style ).replace(" ", " ") def format(self, record: logging.LogRecord): """Overwritten format function.""" # pylint: disable=protected-access self._style._fmt = self._get_format(record) return super().format(record) input-remapper-2.1.1/inputremapper/logging/logger.py000066400000000000000000000123601475433465200226540ustar00rootroot00000000000000# -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings # Copyright (C) 2025 sezanzeb # # This file is part of input-remapper. # # input-remapper is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # input-remapper is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with input-remapper. If not, see . """Logging setup for input-remapper.""" import logging import time from typing import cast from inputremapper.logging.formatter import ColorfulFormatter try: from inputremapper.commit_hash import COMMIT_HASH except ImportError: COMMIT_HASH = "" start = time.time() previous_key_debug_log = None previous_write_debug_log = None class Logger(logging.Logger): def debug_mapping_handler(self, mapping_handler): """Parse the structure of a mapping_handler and log it.""" if not self.isEnabledFor(logging.DEBUG): return lines_and_indent = self._parse_mapping_handler(mapping_handler) for line in lines_and_indent: indent = " " msg = indent * line[1] + line[0] self._log(logging.DEBUG, msg, args=None) def write(self, key, uinput): """Log that an event is being written Parameters ---------- key anything that can be string formatted, but usually a tuple of (type, code, value) tuples """ # pylint: disable=protected-access if not self.isEnabledFor(logging.DEBUG): return global previous_write_debug_log str_key = repr(key) str_key = str_key.replace(",)", ")") msg = f'Writing {str_key} to "{uinput.name}"' if msg == previous_write_debug_log: # avoid some super spam from EV_ABS events return previous_write_debug_log = msg self._log(logging.DEBUG, msg, args=None, stacklevel=2) def _parse_mapping_handler(self, mapping_handler): indent = 0 lines_and_indent = [] while True: if isinstance(mapping_handler, list): for sub_handler in mapping_handler: sub_list = self._parse_mapping_handler(sub_handler) for line in sub_list: line[1] += indent lines_and_indent.extend(sub_list) break lines_and_indent.append([repr(mapping_handler), indent]) try: mapping_handler = mapping_handler.child except AttributeError: break indent += 1 return lines_and_indent def is_debug(self) -> bool: """True, if the logger is currently in DEBUG mode.""" return self.level <= logging.DEBUG def log_info(self, name: str = "input-remapper") -> None: """Log version and name to the console.""" logger.info( "%s %s %s https://github.com/sezanzeb/input-remapper", name, VERSION, COMMIT_HASH, ) if EVDEV_VERSION: logger.info("python-evdev %s", EVDEV_VERSION) if self.is_debug(): logger.warning( "Debug level will log all your keystrokes! Do not post this " "output in the internet if you typed in sensitive or private " "information with your device!" ) def update_verbosity(self, debug: bool) -> None: """Set the logging verbosity according to the settings object.""" if debug: self.setLevel(logging.DEBUG) else: self.setLevel(logging.INFO) for handler in self.handlers: handler.setFormatter(ColorfulFormatter(debug)) @classmethod def bootstrap_logger(cls): # https://github.com/python/typeshed/issues/1801 logging.setLoggerClass(cls) logger = cast(cls, logging.getLogger("input-remapper")) handler = logging.StreamHandler() handler.setFormatter(ColorfulFormatter(False)) logger.addHandler(handler) logger.setLevel(logging.INFO) logging.getLogger("asyncio").setLevel(logging.WARNING) return logger logger = Logger.bootstrap_logger() # using pkg_resources to figure out the version fails in many cases, # so we hardcode it instead VERSION = "2.1.1" EVDEV_VERSION = None try: # pkg_resources very commonly fails/breaks import pkg_resources EVDEV_VERSION = pkg_resources.require("evdev")[0].version except Exception as error: # there have been pkg_resources.DistributionNotFound and # pkg_resources.ContextualVersionConflict errors so far. # We can safely ignore all Exceptions here logger.info("Could not figure out the version") logger.debug(error) # check if the version is something like 1.5.0-beta or 1.5.0-beta.5 IS_BETA = "beta" in VERSION input-remapper-2.1.1/inputremapper/user.py000066400000000000000000000043251475433465200207270ustar00rootroot00000000000000# -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings # Copyright (C) 2025 sezanzeb # # This file is part of input-remapper. # # input-remapper is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # input-remapper is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with input-remapper. If not, see . """Figure out the user.""" import getpass import os import pwd class UserUtils: @staticmethod def get_user(): """Try to find the user who called sudo/pkexec.""" try: return os.getlogin() except OSError: # failed in some ubuntu installations and in systemd services pass try: user = os.environ["USER"] except KeyError: # possibly the systemd service. no sudo was used return getpass.getuser() if user == "root": try: return os.environ["SUDO_USER"] except KeyError: # no sudo was used pass try: pkexec_uid = int(os.environ["PKEXEC_UID"]) return pwd.getpwuid(pkexec_uid).pw_name except KeyError: # no pkexec was used or the uid is unknown pass return user @staticmethod def get_home(user): """Try to find the user's home directory.""" return pwd.getpwnam(user).pw_dir # An odd construct, but it can't really be helped because this was done as an # afterthought, after I learned about proper object-oriented software architecture. # TODO Eventually, UserUtils should be constructed and injected as a UserService, # which then initializes stuff. user = get_user() home = get_home(user) input-remapper-2.1.1/inputremapper/utils.py000066400000000000000000000042141475433465200211060ustar00rootroot00000000000000# -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings # Copyright (C) 2025 sezanzeb # # This file is part of input-remapper. # # input-remapper is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # input-remapper is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with input-remapper. If not, see . """Utility functions.""" import sys from hashlib import md5 from typing import Optional, NewType import evdev DeviceHash = NewType("DeviceHash", str) def is_service() -> bool: return sys.argv[0].endswith("input-remapper-service") def get_device_hash(device: evdev.InputDevice) -> DeviceHash: """get a unique hash for the given device.""" # The builtin hash() function can not be used because it is randomly # seeded at python startup. # A non-cryptographic hash would be faster but there is none in the standard lib # This hash needs to stay the same across reboots, and even stay the same when # moving the config to a new computer. s = str(device.capabilities(absinfo=False)) + device.name return DeviceHash(md5(s.encode()).hexdigest().lower()) def get_evdev_constant_name(type_: Optional[int], code: Optional[int], *_) -> str: """Handy function to get the evdev constant name for display purposes. Returns "unknown" for unknown events. """ # using this function is more readable than # type_, code = event.type_and_code # name = evdev.ecodes.bytype[type_][code] name = evdev.ecodes.bytype.get(type_, {}).get(code) if type(name) in [list, tuple]: # python-evdev >= 1.8.0 uses tuples name = name[0] if name is None: return "unknown" return name input-remapper-2.1.1/po/000077500000000000000000000000001475433465200151165ustar00rootroot00000000000000input-remapper-2.1.1/po/fr.po000077700000000000000000000000001475433465200174162fr_FR.poustar00rootroot00000000000000input-remapper-2.1.1/po/fr_FR.po000066400000000000000000000343461475433465200164660ustar00rootroot00000000000000# SOME DESCRIPTIVE TITLE. # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER # This file is distributed under the same license as the PACKAGE package. # FIRST AUTHOR , YEAR. # msgid "" msgstr "" "Project-Id-Version: \n" "Report-Msgid-Bugs-To: \n" "POT-Creation-Date: 2023-05-16 08:30-0300\n" "PO-Revision-Date: 2023-02-03 09:09+0100\n" "Last-Translator: \n" "Language-Team: \n" "Language: fr_FR\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n > 1);\n" "X-Generator: Poedit 3.2.2\n" #: inputremapper/gui/controller.py:222 msgid "" "\n" "If you mean to create a key or macro mapping go to the advanced input " "configuration and set a \"Trigger Threshold\" for " msgstr "" #: inputremapper/gui/controller.py:239 msgid "" "\n" "If you mean to create an analog axis mapping go to the advanced input " "configuration and set an input to \"Use as Analog\"." msgstr "" #: inputremapper/gui/controller.py:779 msgid "" "\n" "The input \"{}\" will be used as analog input." msgstr "" #: inputremapper/gui/controller.py:763 msgid "" "\n" "This will remove \"{}\" from the text input!" msgstr "" #: inputremapper/gui/controller.py:784 msgid "" "\n" "You need to record an analog input." msgstr "" #: data/input-remapper.glade:544 msgid " (recording ...)" msgstr "" #: inputremapper/gui/controller.py:174 #, fuzzy, python-format msgid "%d Mapping errors at \"%s\", hover for info" msgstr "Erreur de syntaxe à %s, survoller pour plus d'informations" #: inputremapper/gui/controller.py:661 msgid ", CTRL + DEL to stop" msgstr ", CTRL + SUPPR pour arrêter" #: data/input-remapper.glade:1421 msgid "About" msgstr "À propos" #: data/input-remapper.glade:422 msgid "" "Activate this to load the preset next time the device connects, or when the " "user logs in" msgstr "" #: data/input-remapper.glade:575 msgid "Add" msgstr "" #: inputremapper/gui/components/editor.py:554 msgid "Add a mapping first" msgstr "" #: data/input-remapper.glade:606 data/input-remapper.glade:1618 msgid "Advanced" msgstr "" #: data/input-remapper.glade:773 data/input-remapper.glade:1118 msgid "Analog Axis" msgstr "" #: inputremapper/gui/controller.py:657 #, python-format msgid "Applied preset %s" msgstr "Préréglage %s appliqué" #: data/input-remapper.glade:318 msgid "Apply" msgstr "Appliquer" #: inputremapper/gui/controller.py:522 #, fuzzy, python-format msgid "Are you sure you want to delete the preset \"%s\"?" msgstr "Êtes-vous sûr de vouloir supprimer le préréglage %s ?" #: inputremapper/gui/controller.py:567 #, fuzzy msgid "Are you sure you want to delete this mapping?" msgstr "Êtes-vous sûr de vouloir supprimer ce mapping ?" #: data/input-remapper.glade:410 msgid "Autoload" msgstr "Charger automatiquement" #: data/input-remapper.glade:854 msgid "Available output axes are affected by the Target setting." msgstr "" #: inputremapper/gui/controller.py:621 msgid "Cannot apply empty preset file" msgstr "Impossible d'appliquer un fichier de préréglage vide" #: inputremapper/gui/components/editor.py:238 msgid "Change Mapping Name" msgstr "" #: data/input-remapper.glade:352 msgid "Copy" msgstr "Copier" #: data/input-remapper.glade:189 msgid "Create a new preset" msgstr "Créer un nouveau préréglage" #: data/input-remapper.glade:925 msgid "Deadzone" msgstr "" #: data/input-remapper.glade:368 data/input-remapper.glade:620 msgid "Delete" msgstr "Supprimer" #: data/input-remapper.glade:624 msgid "Delete this entry" msgstr "Supprimer ce mapping" #: data/input-remapper.glade:372 msgid "Delete this preset" msgstr "Supprimer ce préréglage" #: data/input-remapper.glade:162 #, fuzzy msgid "Device Name" msgstr "Périphérique" #: data/input-remapper.glade:148 #, fuzzy msgid "Devices" msgstr "Périphérique" #: data/input-remapper.glade:356 msgid "Duplicate this preset" msgstr "Dupliquer ce préréglage" #: data/input-remapper.glade:1208 msgid "Editor" msgstr "" #: inputremapper/configs/mapping.py:77 msgid "Empty Mapping" msgstr "" #: inputremapper/gui/components/editor.py:404 msgid "Enter your output here" msgstr "" #: data/input-remapper.glade:1762 msgid "Event Specific" msgstr "" #: data/input-remapper.glade:1009 msgid "Expo" msgstr "" #: inputremapper/gui/controller.py:648 inputremapper/gui/controller.py:673 #, python-format msgid "Failed to apply preset %s" msgstr "Échec de l'application du préréglage %s" #: data/input-remapper.glade:967 msgid "Gain" msgstr "" #: data/input-remapper.glade:1736 msgid "General" msgstr "" #: data/input-remapper.glade:1312 msgid "Help" msgstr "Aide" #: data/input-remapper.glade:510 msgid "Input" msgstr "" #: data/input-remapper.glade:1296 msgid "Input Remapper" msgstr "Input Remapper" #: data/input-remapper.glade:1087 msgid "Input cutoff" msgstr "" #: data/input-remapper.glade:760 data/input-remapper.glade:1180 msgid "Key or Macro" msgstr "" #: data/input-remapper.glade:1801 msgid "Map this input to an Analog Axis" msgstr "" #: data/input-remapper.glade:185 msgid "New" msgstr "Nouveau" #: inputremapper/gui/components/editor.py:980 msgid "No Axis" msgstr "" #: data/input-remapper.glade:721 msgid "Output" msgstr "" #: data/input-remapper.glade:862 msgid "Output axis" msgstr "" #: inputremapper/gui/controller.py:508 inputremapper/gui/controller.py:582 msgid "Permission denied!" msgstr "Autorisation refusée !" #: data/input-remapper.glade:287 #, fuzzy msgid "Preset Name" msgstr "Préréglage" #: data/input-remapper.glade:272 #, fuzzy msgid "Presets" msgstr "Préréglage" #: data/input-remapper.glade:590 msgid "Record" msgstr "" #: data/input-remapper.glade:594 msgid "Record a button of your device that should be remapped" msgstr "Enregistrer un bouton de votre périphérique qui devrait être remappé" #: inputremapper/gui/components/editor.py:563 msgid "Record input first" msgstr "" #: inputremapper/gui/components/editor.py:65 #, fuzzy msgid "Record the input first" msgstr "Set the key first" #: data/input-remapper.glade:1708 msgid "" "Release all inputs which are part of the combination before the mapping is " "injected" msgstr "" #: data/input-remapper.glade:1711 msgid "Release input" msgstr "" #: data/input-remapper.glade:1683 msgid "Release timeout" msgstr "" #: inputremapper/gui/controller.py:208 msgid "Remove the Analog Output Axis when specifying a macro or key output" msgstr "" #: inputremapper/gui/controller.py:199 msgid "" "Remove the macro or key from the macro input field when specifying an analog " "output" msgstr "" #: data/input-remapper.glade:1776 msgid "Remove this input" msgstr "" #: data/input-remapper.glade:394 msgid "Rename" msgstr "Renommer" #: data/input-remapper.glade:452 msgid "Save the entered name" msgstr "Enregistrer le nom saisi" #: data/input-remapper.glade:1449 msgid "" "See usage.md online on github for comprehensive information.\n" "\n" "A \"key + key + ... + key\" syntax can be used to trigger key combinations. " "For example \"Control_L + a\".\n" "\n" "Writing \"disable\" as a mapping disables a key.\n" "\n" "Macros allow multiple characters to be written with a single key-press. " "Information about programming them is available online on github. See macros.md and examples.md" msgstr "" "Voir usage.md en ligne sur github pour des informations " "complètes.\n" "La syntaxe \"touche + touche + ... + touche\" peut être utilisée pour " "déclencher des combinaisons de touches.\n" "Par exemple \"Control_L + a\".\n" "Écrire \"disable\" comme mapping désactive une touche.\n" "Les macros permettent d'écrire plusieurs caractères avec un seul appui de " "touche. Des informations sur leur programmation sont disponibles en ligne " "sur github. See macros.md et examples.md" #: data/input-remapper.glade:1590 msgid "Shortcuts" msgstr "Raccourcis" #: data/input-remapper.glade:1492 msgid "" "Shortcuts only work while keys are not being recorded and the gui is in " "focus." msgstr "" "Les raccourcis fonctionnent uniquement lorsque l'enregistrement des touches " "n'est pas en cours et l'interface graphique a le focus." #: data/input-remapper.glade:322 msgid "Start injecting. Don't hold down any keys while the injection starts" msgstr "" "Démarrer l'injection. Ne maintenir aucune touche pendant que l'injection " "démarre" #: inputremapper/gui/controller.py:643 msgid "Starting injection..." msgstr "Injection en cours..." #: data/input-remapper.glade:201 data/input-remapper.glade:334 msgid "Stop" msgstr "" #: inputremapper/gui/controller.py:710 #, fuzzy msgid "Stopped the injection" msgstr "arrête l'injection" #: data/input-remapper.glade:205 data/input-remapper.glade:338 msgid "" "Stops the Injection for the selected device,\n" "gives your keys their original function back\n" "Shortcut: ctrl + del" msgstr "" #: data/input-remapper.glade:812 msgid "Target" msgstr "" #: data/input-remapper.glade:1078 msgid "" "The Speed at which the Input is considered at maximum.\n" "Only relevant when mapping relative inputs (e.g. mouse) to absolute outputs " "(e.g. gamepad)" msgstr "" #: inputremapper/gui/controller.py:234 msgid "" "The input specifies a key or macro input, but no macro or key is programmed." msgstr "" #: inputremapper/gui/controller.py:213 msgid "The input specifies an analog axis, but no output axis is selected." msgstr "" #: data/input-remapper.glade:803 msgid "The type of device this mapping is emulating." msgstr "Le type de périphérique émulé par ce mapping." #: data/input-remapper.glade:1831 msgid "Trigger threshold" msgstr "" #: data/input-remapper.glade:743 msgid "Type" msgstr "" #: data/input-remapper.glade:1473 msgid "Usage" msgstr "Utilisation" #: inputremapper/gui/controller.py:593 #, fuzzy msgid "Use \"Stop\" to stop before editing" msgstr "Utiliser \"Arrêter l'injection\" pour arrêter avant l'édition" #: data/input-remapper.glade:1805 msgid "Use as analog" msgstr "" #: data/input-remapper.glade:1366 msgid "Version unknown" msgstr "Version inconnue" #: data/input-remapper.glade:1134 msgid "What should be written. For example KEY_A" msgstr "" #: inputremapper/gui/controller.py:761 msgid "You are about to change the mapping to analog." msgstr "" #: data/input-remapper.glade:1161 msgid "You can copy this text into the output" msgstr "" #: data/input-remapper.glade:1383 msgid "" "You can find more information and report bugs at\n" "https://github.com/" "sezanzeb/input-remapper" msgstr "" "Vous pouvez trouver plus d'informations ou signaler un bug sur\n" "https://github.com/" "sezanzeb/input-remapper" #: inputremapper/gui/controller.py:623 #, fuzzy msgid "You need to add mappings first" msgstr "Vous devez d'abord ajouter des touches et sauvegarder" #: inputremapper/gui/controller.py:358 #, fuzzy msgid "" "Your system might reinterpret combinations with those after they are " "injected, and by doing so break them." msgstr "avec celles-ci après leur injection, et ce faisant " #: data/input-remapper.glade:1524 msgid "closes the application" msgstr "ferme l'application" #: data/input-remapper.glade:1512 msgid "ctrl + del" msgstr "ctrl + del" #: data/input-remapper.glade:1536 msgid "ctrl + q" msgstr "ctrl + q" #: data/input-remapper.glade:1548 msgid "ctrl + r" msgstr "ctrl + r" #: inputremapper/gui/controller.py:356 msgid "ctrl, alt and shift may not combine properly" msgstr "ctrl, alt et shift peuvent ne pas se combiner correctement" #: inputremapper/gui/data_manager.py:56 #, fuzzy msgid "new preset" msgstr "Créer un nouveau préréglage" #: data/input-remapper.glade:531 inputremapper/gui/user_interface.py:385 msgid "no input configured" msgstr "" #: data/input-remapper.glade:1560 msgid "refreshes the device list" msgstr "rafraîchit la liste des périphériques" #: data/input-remapper.glade:1572 msgid "stops the injection" msgstr "arrête l'injection" #: data/input-remapper.glade:1403 #, fuzzy msgid "" "© 2023 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" "Ce programme est fourni sans aucune garantie.\n" "Voir la Licence GNU " "General Public, version 3 ou ultérieure pour plus de détails." #, python-format #~ msgid "\"%s\" already mapped to \"%s\"" #~ msgstr "\\\"%s\\\" déjà mappé sur \\\"%s\\\"" #~ msgid "Applied the system default" #~ msgstr "Réglage système restauré" #~ msgid "Buttons" #~ msgstr "Boutons" #~ msgid "Cancel" #~ msgstr "Annuler" #~ msgid "Change Key" #~ msgstr "Changer la touche" #~ msgid "Joystick" #~ msgstr "Joystick" #~ msgid "Left joystick" #~ msgstr "Joystick gauche" #~ msgid "Mouse" #~ msgstr "Souris" #~ msgid "Mouse speed" #~ msgstr "Vitesse de la souris" #~ msgid "Press Key" #~ msgstr "Appuyer sur une touche" #~ msgid "Right joystick" #~ msgstr "Joystick droite" #~ msgid "" #~ "Shortcut: ctrl + del\n" #~ "Gives your keys back their original function" #~ msgstr "" #~ "Raccourci : ctrl + del\n" #~ "Réinitialise vos touches à leur fonctionnalité d'origine" #~ msgid "Stop Injection" #~ msgstr "Arrêter l'injection" #~ msgid "The helper did not start" #~ msgstr "Le helper n'a pas démarré" #~ msgid "" #~ "To automatically apply the preset after your login or when it connects." #~ msgstr "" #~ "Pour appliquer automatiquement le préréglage après votre login ou à la " #~ "connexion." #, python-format #~ msgid "Unknown mapping %s" #~ msgstr "Mapping inconnu %s" #~ msgid "Wheel" #~ msgstr "Roulette" #~ msgid "Your system might reinterpret combinations " #~ msgstr "Votre système peut réinterpréter les combinaisons " #~ msgid "break them." #~ msgstr "les casser." #~ msgid "new entry" #~ msgstr "nouveau mapping" input-remapper-2.1.1/po/input-remapper.pot000066400000000000000000000241671475433465200206240ustar00rootroot00000000000000# SOME DESCRIPTIVE TITLE. # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER # This file is distributed under the same license as the PACKAGE package. # FIRST AUTHOR , YEAR. # #, fuzzy msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" "POT-Creation-Date: 2023-05-16 08:30-0300\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" "Language: \n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" #: inputremapper/gui/controller.py:222 msgid "" "\n" "If you mean to create a key or macro mapping go to the advanced input " "configuration and set a \"Trigger Threshold\" for " msgstr "" #: inputremapper/gui/controller.py:239 msgid "" "\n" "If you mean to create an analog axis mapping go to the advanced input " "configuration and set an input to \"Use as Analog\"." msgstr "" #: inputremapper/gui/controller.py:779 msgid "" "\n" "The input \"{}\" will be used as analog input." msgstr "" #: inputremapper/gui/controller.py:763 msgid "" "\n" "This will remove \"{}\" from the text input!" msgstr "" #: inputremapper/gui/controller.py:784 msgid "" "\n" "You need to record an analog input." msgstr "" #: data/input-remapper.glade:544 msgid " (recording ...)" msgstr "" #: inputremapper/gui/controller.py:174 #, python-format msgid "%d Mapping errors at \"%s\", hover for info" msgstr "" #: inputremapper/gui/controller.py:661 msgid ", CTRL + DEL to stop" msgstr "" #: data/input-remapper.glade:1421 msgid "About" msgstr "" #: data/input-remapper.glade:422 msgid "" "Activate this to load the preset next time the device connects, or when the " "user logs in" msgstr "" #: data/input-remapper.glade:575 msgid "Add" msgstr "" #: inputremapper/gui/components/editor.py:554 msgid "Add a mapping first" msgstr "" #: data/input-remapper.glade:606 data/input-remapper.glade:1618 msgid "Advanced" msgstr "" #: data/input-remapper.glade:773 data/input-remapper.glade:1118 msgid "Analog Axis" msgstr "" #: inputremapper/gui/controller.py:657 #, python-format msgid "Applied preset %s" msgstr "" #: data/input-remapper.glade:318 msgid "Apply" msgstr "" #: inputremapper/gui/controller.py:522 #, python-format msgid "Are you sure you want to delete the preset \"%s\"?" msgstr "" #: inputremapper/gui/controller.py:567 msgid "Are you sure you want to delete this mapping?" msgstr "" #: data/input-remapper.glade:410 msgid "Autoload" msgstr "" #: data/input-remapper.glade:854 msgid "Available output axes are affected by the Target setting." msgstr "" #: inputremapper/gui/controller.py:621 msgid "Cannot apply empty preset file" msgstr "" #: inputremapper/gui/components/editor.py:238 msgid "Change Mapping Name" msgstr "" #: data/input-remapper.glade:352 msgid "Copy" msgstr "" #: data/input-remapper.glade:189 msgid "Create a new preset" msgstr "" #: data/input-remapper.glade:925 msgid "Deadzone" msgstr "" #: data/input-remapper.glade:368 data/input-remapper.glade:620 msgid "Delete" msgstr "" #: data/input-remapper.glade:624 msgid "Delete this entry" msgstr "" #: data/input-remapper.glade:372 msgid "Delete this preset" msgstr "" #: data/input-remapper.glade:162 msgid "Device Name" msgstr "" #: data/input-remapper.glade:148 msgid "Devices" msgstr "" #: data/input-remapper.glade:356 msgid "Duplicate this preset" msgstr "" #: data/input-remapper.glade:1208 msgid "Editor" msgstr "" #: inputremapper/configs/mapping.py:77 msgid "Empty Mapping" msgstr "" #: inputremapper/gui/components/editor.py:404 msgid "Enter your output here" msgstr "" #: data/input-remapper.glade:1762 msgid "Event Specific" msgstr "" #: data/input-remapper.glade:1009 msgid "Expo" msgstr "" #: inputremapper/gui/controller.py:648 inputremapper/gui/controller.py:673 #, python-format msgid "Failed to apply preset %s" msgstr "" #: data/input-remapper.glade:967 msgid "Gain" msgstr "" #: data/input-remapper.glade:1736 msgid "General" msgstr "" #: data/input-remapper.glade:1312 msgid "Help" msgstr "" #: data/input-remapper.glade:510 msgid "Input" msgstr "" #: data/input-remapper.glade:1296 msgid "Input Remapper" msgstr "" #: data/input-remapper.glade:1087 msgid "Input cutoff" msgstr "" #: data/input-remapper.glade:760 data/input-remapper.glade:1180 msgid "Key or Macro" msgstr "" #: data/input-remapper.glade:1801 msgid "Map this input to an Analog Axis" msgstr "" #: data/input-remapper.glade:185 msgid "New" msgstr "" #: inputremapper/gui/components/editor.py:980 msgid "No Axis" msgstr "" #: data/input-remapper.glade:721 msgid "Output" msgstr "" #: data/input-remapper.glade:862 msgid "Output axis" msgstr "" #: inputremapper/gui/controller.py:508 inputremapper/gui/controller.py:582 msgid "Permission denied!" msgstr "" #: data/input-remapper.glade:287 msgid "Preset Name" msgstr "" #: data/input-remapper.glade:272 msgid "Presets" msgstr "" #: data/input-remapper.glade:590 msgid "Record" msgstr "" #: data/input-remapper.glade:594 msgid "Record a button of your device that should be remapped" msgstr "" #: inputremapper/gui/components/editor.py:563 msgid "Record input first" msgstr "" #: inputremapper/gui/components/editor.py:65 msgid "Record the input first" msgstr "" #: data/input-remapper.glade:1708 msgid "" "Release all inputs which are part of the combination before the mapping is " "injected" msgstr "" #: data/input-remapper.glade:1711 msgid "Release input" msgstr "" #: data/input-remapper.glade:1683 msgid "Release timeout" msgstr "" #: inputremapper/gui/controller.py:208 msgid "Remove the Analog Output Axis when specifying a macro or key output" msgstr "" #: inputremapper/gui/controller.py:199 msgid "" "Remove the macro or key from the macro input field when specifying an analog " "output" msgstr "" #: data/input-remapper.glade:1776 msgid "Remove this input" msgstr "" #: data/input-remapper.glade:394 msgid "Rename" msgstr "" #: data/input-remapper.glade:452 msgid "Save the entered name" msgstr "" #: data/input-remapper.glade:1449 msgid "" "See usage.md online on github for comprehensive information.\n" "\n" "A \"key + key + ... + key\" syntax can be used to trigger key combinations. " "For example \"Control_L + a\".\n" "\n" "Writing \"disable\" as a mapping disables a key.\n" "\n" "Macros allow multiple characters to be written with a single key-press. " "Information about programming them is available online on github. See macros.md and examples.md" msgstr "" #: data/input-remapper.glade:1590 msgid "Shortcuts" msgstr "" #: data/input-remapper.glade:1492 msgid "" "Shortcuts only work while keys are not being recorded and the gui is in " "focus." msgstr "" #: data/input-remapper.glade:322 msgid "Start injecting. Don't hold down any keys while the injection starts" msgstr "" #: inputremapper/gui/controller.py:643 msgid "Starting injection..." msgstr "" #: data/input-remapper.glade:201 data/input-remapper.glade:334 msgid "Stop" msgstr "" #: inputremapper/gui/controller.py:710 msgid "Stopped the injection" msgstr "" #: data/input-remapper.glade:205 data/input-remapper.glade:338 msgid "" "Stops the Injection for the selected device,\n" "gives your keys their original function back\n" "Shortcut: ctrl + del" msgstr "" #: data/input-remapper.glade:812 msgid "Target" msgstr "" #: data/input-remapper.glade:1078 msgid "" "The Speed at which the Input is considered at maximum.\n" "Only relevant when mapping relative inputs (e.g. mouse) to absolute outputs " "(e.g. gamepad)" msgstr "" #: inputremapper/gui/controller.py:234 msgid "" "The input specifies a key or macro input, but no macro or key is programmed." msgstr "" #: inputremapper/gui/controller.py:213 msgid "The input specifies an analog axis, but no output axis is selected." msgstr "" #: data/input-remapper.glade:803 msgid "The type of device this mapping is emulating." msgstr "" #: data/input-remapper.glade:1831 msgid "Trigger threshold" msgstr "" #: data/input-remapper.glade:743 msgid "Type" msgstr "" #: data/input-remapper.glade:1473 msgid "Usage" msgstr "" #: inputremapper/gui/controller.py:593 msgid "Use \"Stop\" to stop before editing" msgstr "" #: data/input-remapper.glade:1805 msgid "Use as analog" msgstr "" #: data/input-remapper.glade:1366 msgid "Version unknown" msgstr "" #: data/input-remapper.glade:1134 msgid "What should be written. For example KEY_A" msgstr "" #: inputremapper/gui/controller.py:761 msgid "You are about to change the mapping to analog." msgstr "" #: data/input-remapper.glade:1161 msgid "You can copy this text into the output" msgstr "" #: data/input-remapper.glade:1383 msgid "" "You can find more information and report bugs at\n" "https://github.com/" "sezanzeb/input-remapper" msgstr "" #: inputremapper/gui/controller.py:623 msgid "You need to add mappings first" msgstr "" #: inputremapper/gui/controller.py:358 msgid "" "Your system might reinterpret combinations with those after they are " "injected, and by doing so break them." msgstr "" #: data/input-remapper.glade:1524 msgid "closes the application" msgstr "" #: data/input-remapper.glade:1512 msgid "ctrl + del" msgstr "" #: data/input-remapper.glade:1536 msgid "ctrl + q" msgstr "" #: data/input-remapper.glade:1548 msgid "ctrl + r" msgstr "" #: inputremapper/gui/controller.py:356 msgid "ctrl, alt and shift may not combine properly" msgstr "" #: inputremapper/gui/data_manager.py:56 msgid "new preset" msgstr "" #: data/input-remapper.glade:531 inputremapper/gui/user_interface.py:385 msgid "no input configured" msgstr "" #: data/input-remapper.glade:1560 msgid "refreshes the device list" msgstr "" #: data/input-remapper.glade:1572 msgid "stops the injection" msgstr "" #: data/input-remapper.glade:1403 msgid "" "© 2023 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-2.1.1/po/it.po000077700000000000000000000000001475433465200175722./it_IT.poustar00rootroot00000000000000input-remapper-2.1.1/po/it_IT.po000066400000000000000000000455171475433465200165020ustar00rootroot00000000000000# ITALIAN TRANSLATION FOR INPUT-REMAPPER. # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER # This file is distributed under the same license as the PACKAGE package. # ALBANO BATTISTELLA , 2021,2023. # msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" "POT-Creation-Date: 2023-05-16 08:30-0300\n" "PO-Revision-Date: 2023-11-02 20:00+0200\n" "Last-Translator: Albano Battistella \n" "Language-Team: ITALIAN \n" "Language: it\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" #: inputremapper/gui/controller.py:222 msgid "" "\n" "If you mean to create a key or macro mapping go to the advanced input " "configuration and set a \"Trigger Threshold\" for " msgstr "" "\n" "Se intendi creare una mappatura di tasti o macro vai all'input avanzato " "e impostare una \"Soglia di attivazione\" per " #: inputremapper/gui/controller.py:239 msgid "" "\n" "If you mean to create an analog axis mapping go to the advanced input " "configuration and set an input to \"Use as Analog\"." msgstr "" "\n" "Se intendi creare una mappatura degli assi analogici vai all'input avanzato " "configurazione e impostare un ingresso su \"Utilizza come analogico\"." #: inputremapper/gui/controller.py:779 msgid "" "\n" "The input \"{}\" will be used as analog input." msgstr "" "\n" "L'ingresso \"{}\" verrà utilizzato come ingresso analogico." #: inputremapper/gui/controller.py:763 msgid "" "\n" "This will remove \"{}\" from the text input!" msgstr "" "\n" "Questo rimuoverà \"{}\" dal testo inserito!" #: inputremapper/gui/controller.py:784 msgid "" "\n" "You need to record an analog input." msgstr "" "\n" "Devi registrare un ingresso analogico." #: data/input-remapper.glade:544 msgid " (recording ...)" msgstr " (registrazione ...)" #: inputremapper/gui/controller.py:174 #, python-format msgid "%d Mapping errors at \"%s\", hover for info" msgstr "%d Errori di mappatura in \"%s\", passa il mouse per informazioni" #: inputremapper/gui/controller.py:661 msgid ", CTRL + DEL to stop" msgstr ",CTRL + CANC per interrompere" #: data/input-remapper.glade:1421 msgid "About" msgstr "Informazioni" #: data/input-remapper.glade:422 msgid "" "Activate this to load the preset next time the device connects, or when the " "user logs in" msgstr "" "Attivarlo per caricare la preimpostazione la prossima volta che il dispositivo si connette o quando " "l'utente accede" #: data/input-remapper.glade:575 msgid "Add" msgstr "Aggiungi" #: inputremapper/gui/components/editor.py:554 msgid "Add a mapping first" msgstr "Aggiungi prima una mappatura" #: data/input-remapper.glade:606 data/input-remapper.glade:1618 msgid "Advanced" msgstr "Avanzato" #: data/input-remapper.glade:773 data/input-remapper.glade:1118 msgid "Analog Axis" msgstr "Asse analogico" #: inputremapper/gui/controller.py:657 #, python-format msgid "Applied preset %s" msgstr "Preimpostazione applicata %s" #: data/input-remapper.glade:318 msgid "Apply" msgstr "Applica" #: inputremapper/gui/controller.py:522 #, python-format msgid "Are you sure you want to delete the preset \"%s\"?" msgstr "Sei sicuro di voler eliminare la preimpostazione \"%s\"?" #: inputremapper/gui/controller.py:567 msgid "Are you sure you want to delete this mapping?" msgstr "Sei sicuro di voler eliminare questa mappatura?" #: data/input-remapper.glade:410 msgid "Autoload" msgstr "Caricamento automatico" #: data/input-remapper.glade:854 msgid "Available output axes are affected by the Target setting." msgstr "Gli assi di uscita disponibili sono influenzati dall'impostazione Target." #: inputremapper/gui/controller.py:621 msgid "Cannot apply empty preset file" msgstr "Impossibile applicare un file preimpostato vuoto" #: inputremapper/gui/components/editor.py:238 msgid "Change Mapping Name" msgstr "Modifica nome mappatura" #: data/input-remapper.glade:352 msgid "Copy" msgstr "Copia" #: data/input-remapper.glade:189 msgid "Create a new preset" msgstr "Crea una nuova preimpostazione" #: data/input-remapper.glade:925 msgid "Deadzone" msgstr "Zona morta" #: data/input-remapper.glade:368 data/input-remapper.glade:620 msgid "Delete" msgstr "Cancella" #: data/input-remapper.glade:624 msgid "Delete this entry" msgstr "Elimina questa voce" #: data/input-remapper.glade:372 msgid "Delete this preset" msgstr "Elimina questa preimpostazione" #: data/input-remapper.glade:162 msgid "Device Name" msgstr "Nome dispositivo" #: data/input-remapper.glade:148 msgid "Devices" msgstr "Dispositivi" #: data/input-remapper.glade:356 msgid "Duplicate this preset" msgstr "Duplica questa preimpostazione" #: data/input-remapper.glade:1208 msgid "Editor" msgstr "" #: inputremapper/configs/mapping.py:77 msgid "Empty Mapping" msgstr "Mappatura vuota" #: inputremapper/gui/components/editor.py:404 msgid "Enter your output here" msgstr "Inserisci il tuo output qui" #: data/input-remapper.glade:1762 msgid "Event Specific" msgstr "Evento specifico" #: data/input-remapper.glade:1009 msgid "Expo" msgstr "" #: inputremapper/gui/controller.py:648 inputremapper/gui/controller.py:673 #, python-format msgid "Failed to apply preset %s" msgstr "Impossibile applicare la preimpostazione %s" #: data/input-remapper.glade:967 msgid "Gain" msgstr "Guadagno" #: data/input-remapper.glade:1736 msgid "General" msgstr "Generale" #: data/input-remapper.glade:1312 msgid "Help" msgstr "Aiuto" #: data/input-remapper.glade:510 msgid "Input" msgstr "Input" #: data/input-remapper.glade:1296 msgid "Input Remapper" msgstr "Input Remapper" #: data/input-remapper.glade:1087 msgid "Input cutoff" msgstr "" #: data/input-remapper.glade:760 data/input-remapper.glade:1180 msgid "Key or Macro" msgstr "Tasto o macro" #: data/input-remapper.glade:1801 msgid "Map this input to an Analog Axis" msgstr "Mappa questo ingresso su un asse analogico" #: data/input-remapper.glade:185 msgid "New" msgstr "Nuovo" #: inputremapper/gui/components/editor.py:980 msgid "No Axis" msgstr "Nessun asse" #: data/input-remapper.glade:721 msgid "Output" msgstr "Output" #: data/input-remapper.glade:862 msgid "Output axis" msgstr "Asse di uscita" #: inputremapper/gui/controller.py:508 inputremapper/gui/controller.py:582 msgid "Permission denied!" msgstr "Permesso negato!" #: data/input-remapper.glade:287 msgid "Preset Name" msgstr "Nome preimpostazione" #: data/input-remapper.glade:272 msgid "Presets" msgstr "Preimpostazioni" #: data/input-remapper.glade:590 msgid "Record" msgstr "Registra" #: data/input-remapper.glade:594 msgid "Record a button of your device that should be remapped" msgstr "Registra un pulsante del tuo dispositivo che dovrebbe essere rimappato" #: inputremapper/gui/components/editor.py:563 msgid "Record input first" msgstr "Registrare prima l'input" #: inputremapper/gui/components/editor.py:65 msgid "Record the input first" msgstr "Registrare prima l'input" #: data/input-remapper.glade:1708 msgid "" "Release all inputs which are part of the combination before the mapping is " "injected" msgstr "" "Rilascia tutti gli input che fanno parte della combinazione prima che la mappatura sia " "iniettata" #: data/input-remapper.glade:1711 msgid "Release input" msgstr "Rilascia l'input" #: data/input-remapper.glade:1683 msgid "Release timeout" msgstr "Rilascia l'input" #: inputremapper/gui/controller.py:208 msgid "Remove the Analog Output Axis when specifying a macro or key output" msgstr "Rimuovere l'asse di uscita analogica quando si specifica una macro o un tasto d'uscita" #: inputremapper/gui/controller.py:199 msgid "" "Remove the macro or key from the macro input field when specifying an analog " "output" msgstr "" "Rimuovere la macro o il tasto dal campo di immissione macro quando si specifica un output " "analogico" #: data/input-remapper.glade:1776 msgid "Remove this input" msgstr "Rimuovi questo input" #: data/input-remapper.glade:394 msgid "Rename" msgstr "Rinomina" #: data/input-remapper.glade:452 msgid "Save the entered name" msgstr "Salva il nome inserito" #: data/input-remapper.glade:1449 msgid "" "See usage.md online on github for comprehensive information.\n" "\n" "A \"key + key + ... + key\" syntax can be used to trigger key combinations. " "For example \"Control_L + a\".\n" "\n" "Writing \"disable\" as a mapping disables a key.\n" "\n" "Macros allow multiple characters to be written with a single key-press. " "Information about programming them is available online on github. See macros.md and examples.md" msgstr "" "Vedi usage.md online su github per informazioni complete.\n" "\n" "Una sintassi \"tasto + tasto + ... + tasto\" può essere utilizzata per attivare combinazioni di tasti. " "Ad esempio \"Control_L + a\".\n" "\n" "Scrivere \"disabilita\" come mappatura disabilitata di un tasto.\n" "\n" "Le macro consentono di scrivere più caratteri premendo un solo tasto. " "Le informazioni su come programmarli sono disponibili online su github. Vedi macros.md e examples.md" #: data/input-remapper.glade:1590 msgid "Shortcuts" msgstr "Scorciatoie" #: data/input-remapper.glade:1492 msgid "" "Shortcuts only work while keys are not being recorded and the gui is in " "focus." msgstr "" "Le scorciatoie funzionano solo mentre i tasti non vengono registrati e la " "GUI è in messa a fuoco." #: data/input-remapper.glade:322 msgid "Start injecting. Don't hold down any keys while the injection starts" msgstr "Inizia a iniettare. Non tenere premuto alcun tasto durante l'avvio dell'iniezione" #: inputremapper/gui/controller.py:643 msgid "Starting injection..." msgstr "Avvio dell'iniezione..." #: data/input-remapper.glade:201 data/input-remapper.glade:334 msgid "Stop" msgstr "Ferma" #: inputremapper/gui/controller.py:710 msgid "Stopped the injection" msgstr "Fermata l'iniezione" #: data/input-remapper.glade:205 data/input-remapper.glade:338 msgid "" "Stops the Injection for the selected device,\n" "gives your keys their original function back\n" "Shortcut: ctrl + del" msgstr"" "Interrompe l'iniezione per il dispositivo selezionato,\n" "ridona ai tuoi tasti la loro funzione originale\n" "Scorciatoia: ctrl + canc" #: data/input-remapper.glade:812 msgid "Target" msgstr "" #: data/input-remapper.glade:1078 msgid "" "The Speed at which the Input is considered at maximum.\n" "Only relevant when mapping relative inputs (e.g. mouse) to absolute outputs " "(e.g. gamepad)" msgstr "" "La velocità alla quale l'input è considerato massimo.\n" "Rilevante solo quando si mappano input relativi (ad esempio mouse) su output assoluti" "(ad esempio gamepad)" #: inputremapper/gui/controller.py:234 msgid "" "The input specifies a key or macro input, but no macro or key is programmed." msgstr "" "L'ingresso specifico ad un tasto o un ingresso macro, ma non è programmata alcuna macro o tasto." #: inputremapper/gui/controller.py:213 msgid "The input specifies an analog axis, but no output axis is selected." msgstr "L'ingresso specifica un asse analogico, ma nessun asse di uscita è selezionato." #: data/input-remapper.glade:803 msgid "The type of device this mapping is emulating." msgstr "Il tipo di dispositivo che questa mappatura sta emulando." #: data/input-remapper.glade:1831 msgid "Trigger threshold" msgstr "Soglia di attivazione" #: data/input-remapper.glade:743 msgid "Type" msgstr "Tipo" #: data/input-remapper.glade:1473 msgid "Usage" msgstr "Uso" #: inputremapper/gui/controller.py:593 msgid "Use \"Stop\" to stop before editing" msgstr "Utilizza \"Ferma\" per interrompere prima della modifica" #: data/input-remapper.glade:1805 msgid "Use as analog" msgstr "Usa come analogico" #: data/input-remapper.glade:1366 msgid "Version unknown" msgstr "Versione sconosciuta" #: data/input-remapper.glade:1134 msgid "What should be written. For example KEY_A" msgstr "Cosa dovrebbe essere scritto. Ad esempio KEY_A" #: inputremapper/gui/controller.py:761 msgid "You are about to change the mapping to analog." msgstr "Stai per cambiare la mappatura in analogica." #: data/input-remapper.glade:1161 msgid "You can copy this text into the output" msgstr "Puoi copiare questo testo nell'output" #: data/input-remapper.glade:1383 msgid "" "You can find more information and report bugs at\n" "https://github.com/" "sezanzeb/input-remapper" msgstr "" "Puoi trovare maggiori informazioni e segnalare bug su\n" "https://github.com/" "sezanzeb/input-remapper" #: inputremapper/gui/controller.py:623 msgid "You need to add mappings first" msgstr "È necessario prima aggiungere le mappature" #: inputremapper/gui/controller.py:358 msgid "" "Your system might reinterpret combinations with those after they are " "injected, and by doing so break them." msgstr "" "Il tuo sistema potrebbe reinterpretare le combinazioni con quelle successive " "iniettati, e così facendo li romperete." #: data/input-remapper.glade:1524 msgid "closes the application" msgstr "chiude l'applicazione" #: data/input-remapper.glade:1512 msgid "ctrl + del" msgstr "ctrl + del" #: data/input-remapper.glade:1536 msgid "ctrl + q" msgstr "ctrl + q" #: data/input-remapper.glade:1548 msgid "ctrl + r" msgstr "ctrl + r" #: inputremapper/gui/controller.py:356 msgid "ctrl, alt and shift may not combine properly" msgstr "ctrl, alt e shift potrebbero non combinarsi correttamente" #: inputremapper/gui/data_manager.py:56 msgid "new preset" msgstr "Nuova preinpostazione" #: data/input-remapper.glade:531 inputremapper/gui/user_interface.py:385 msgid "no input configured" msgstr "nessun imput configurato" #: data/input-remapper.glade:1560 msgid "refreshes the device list" msgstr "aggiorna l'elenco dei dispositivi" #: data/input-remapper.glade:1572 msgid "stops the injection" msgstr "ferma l'iniezione" #: data/input-remapper.glade:1403 msgid "" "© 2023 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 "" "© 2023 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." #~ msgid "." #~ msgstr "." #~ msgid "1, 2" #~ msgstr "1, 2" #~ msgid "" #~ "A \"key + key + ... + key\" syntax can be used to trigger key " #~ "combinations. For example \"control_l + a\".\n" #~ "\n" #~ "\"disable\" disables a key." #~ msgstr "" #~ "Sintassi\"tasti + tasti + ... + tasti\"puoi usare per entrare nella " #~ "combinazioni di tasti. Ad esempio \"control_l + a\".\n" #~ "\n" #~ "\"disabilita\"disabilitato la mappatura dei tasti." #~ msgid "" #~ "Between calls to k, key down and key up events, macros will sleep for " #~ "10ms by default, which can be configured in ~/.config/input-remapper/" #~ "config" #~ msgstr "" #~ "Tra chiamate a k, battitura ed eventi rilasciati,le macro dormiranno per " #~ "10 ms per impostazione predefinita, che può essere configurato in ~/." #~ "config/input-remapper/config" #~ msgid "Buttons" #~ msgstr "Pulsanti" #~ msgid "CTRL + a, CTRL + x" #~ msgstr "CTRL + a, CTRL + x" #~ msgid "" #~ "Click on a cell below and hit a key on your device. Click the \"Restore " #~ "Defaults\" button beforehand." #~ msgstr "" #~ "Fai clic su una cella in basso e premi un tasto sul dispositivo. Fai clic " #~ "su \"Ripristina Pulsante \"Predefiniti\" in anticipo." #~ msgid "Displays additional debug information" #~ msgstr "Visualizza ulteriori informazioni di debug" #~ msgid "Examples" #~ msgstr "Esempi" #~ msgid "Go Back" #~ msgstr "Indietro" #~ msgid "Joystick" #~ msgstr "Joystick" #~ msgid "Key" #~ msgstr "Tasto" #~ msgid "Left joystick" #~ msgstr "Joystick sinistro" #~ msgid "Macros" #~ msgstr "Macro" #~ msgid "" #~ "Macros allow multiple characters to be written with a single key-press." #~ msgstr "" #~ "Le macro consentono di scrivere più caratteri premendo un solo tasto." #~ msgid "Mouse" #~ msgstr "Mouse" #~ msgid "Mouse speed" #~ msgstr "Velocità mouse" #~ msgid "Right joystick" #~ msgstr "Joystick destro" #~ msgid "" #~ "Shortcut: ctrl + del\n" #~ "To give your keys back their original mapping." #~ msgstr "" #~ "Scorciatoia: ctrl + del\n" #~ "Per restituire alle tue chiavi la loro mappatura originale." #~ msgid "" #~ "To automatically apply the preset after your login or when it connects." #~ msgstr "" #~ "Per applicare automaticamente il predefinito dopo il login o quando si " #~ "connette." #~ msgid "a, a, a with 500ms pause" #~ msgstr "a, a, a con pausa di 500 ms" #~ msgid "e" #~ msgstr "e" #~ msgid "e(EV_REL, REL_X, 10)" #~ msgstr "e(EV_REL, REL_X, 10)" #~ msgid "executes the parameter as long as the key is pressed down" #~ msgstr "esegue il parametro fintanto che si tiene premuto il tasto" #~ msgid "executes two actions behind each other" #~ msgstr "esegue due azioni una dietro l'altra" #~ msgid "h" #~ msgstr "h" #~ msgid "holds a modifier while executing the second parameter" #~ msgstr "contiene un modificatore durante l'esecuzione del secondo parametro" #~ msgid "k" #~ msgstr "k" #~ msgid "k(1).h(k(2)).k(3)" #~ msgstr "k(1).h(k(2)).k(3)" #~ msgid "k(1).k(2)" #~ msgstr "k(1).k(2)" #~ msgid "keeps scrolling down while held" #~ msgstr "continua a scorrere verso il basso mentre si tiene premuto" #~ msgid "m" #~ msgstr "m" #~ msgid "m(Control_L, k(a).k(x))" #~ msgstr "m(Control_L, k(a).k(x))" #~ msgid "mouse" #~ msgstr "mouse" #~ msgid "mouse(right, 4)" #~ msgstr "mouse(destra, 4)" #~ msgid "moves the mouse cursor 10px to the right" #~ msgstr "sposta il cursore del mouse di 10 pixel a destra" #~ msgid "r" #~ msgstr "r" #~ msgid "r(3, k(a).w(500))" #~ msgstr "r(3, k(a).w(500))" #~ msgid "repeats the execution of the second parameter" #~ msgstr "ripete l'esecuzione del secondo parametro" #~ msgid "same as mouse" #~ msgstr "come al mouse" #~ msgid "takes direction (up, left, ...) and speed as parameters" #~ msgstr "" #~ "prende la direzione (su, sinistra, ...) e la velocità come parametri" #~ msgid "w" #~ msgstr "w" #~ msgid "waits in milliseconds" #~ msgstr "in attesa (in millisecondi)" #~ msgid "which keeps moving the mouse while pressed" #~ msgstr "che continua a muovere il mouse mentre viene premuto" #~ msgid "writes 1 2 2 ... 2 2 3 while the key is pressed" #~ msgstr "scrive 1 2 2 ... 2 2 3 mentre il tasto è premuto" #~ msgid "writes a single keystroke" #~ msgstr "scrive una singola sequenza di tasti" #~ msgid "writes an event" #~ msgstr "scrive un evento" input-remapper-2.1.1/po/pt.po000077700000000000000000000000001475433465200174422pt_BR.poustar00rootroot00000000000000input-remapper-2.1.1/po/pt_BR.po000066400000000000000000000356431475433465200164770ustar00rootroot00000000000000# Portuguese translations for input-mapper package # Traduções em português brasileiro para o pacote input-mapper. # Copyright (C) 2023 THE input-mapper'S COPYRIGHT HOLDER # This file is distributed under the same license as the input-mapper package. # Rafael Fontenelle , 2023. # msgid "" msgstr "" "Project-Id-Version: input-mapper\n" "Report-Msgid-Bugs-To: https://github.com/sezanzeb/input-remapper/issues\n" "POT-Creation-Date: 2023-05-16 08:30-0300\n" "PO-Revision-Date: 2023-05-16 08:56-0300\n" "Last-Translator: Rafael Fontenelle \n" "Language-Team: Brazilian Portuguese\n" "Language: pt_BR\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n > 1)\n" "X-Generator: Gtranslator 42.0\n" #: inputremapper/gui/controller.py:222 msgid "" "\n" "If you mean to create a key or macro mapping go to the advanced input " "configuration and set a \"Trigger Threshold\" for " msgstr "" "\n" "Se você pretende criar um mapeamento de macro ou tecla, vá para a " "configuração avançada de entrada e defina um \"Limiar de acionamento\"" #: inputremapper/gui/controller.py:239 msgid "" "\n" "If you mean to create an analog axis mapping go to the advanced input " "configuration and set an input to \"Use as Analog\"." msgstr "" "\n" "Se você deseja criar um mapeamento de eixo analógico, vá para a configuração " "avançada de entrada e defina uma entrada para \"Usar como analógico\"." #: inputremapper/gui/controller.py:779 msgid "" "\n" "The input \"{}\" will be used as analog input." msgstr "" "\n" "A entrada \"{}\" será usada como entrada analógica." #: inputremapper/gui/controller.py:763 msgid "" "\n" "This will remove \"{}\" from the text input!" msgstr "" "\n" "Isso removerá \"{}\" da entrada de texto!" #: inputremapper/gui/controller.py:784 msgid "" "\n" "You need to record an analog input." msgstr "" "\n" "Você precisa gravar uma entrada analógica." #: data/input-remapper.glade:544 msgid " (recording ...)" msgstr " (gravando ...)" #: inputremapper/gui/controller.py:174 #, python-format msgid "%d Mapping errors at \"%s\", hover for info" msgstr "%d erros de mapeamento em \"%s\", passe o mouse para obter informações" #: inputremapper/gui/controller.py:661 msgid ", CTRL + DEL to stop" msgstr ", CTRL + DEL para interromper" #: data/input-remapper.glade:1421 msgid "About" msgstr "Sobre" #: data/input-remapper.glade:422 msgid "" "Activate this to load the preset next time the device connects, or when the " "user logs in" msgstr "" "Ative para carregar a predefinição na próxima vez que o dispositivo se " "conectar ou quando o usuário fizer login" #: data/input-remapper.glade:575 msgid "Add" msgstr "Adicionar" #: inputremapper/gui/components/editor.py:554 msgid "Add a mapping first" msgstr "Adicione um mapeamento primeiro" #: data/input-remapper.glade:606 data/input-remapper.glade:1618 msgid "Advanced" msgstr "Avançado" #: data/input-remapper.glade:773 data/input-remapper.glade:1118 msgid "Analog Axis" msgstr "Eixo analógico" #: inputremapper/gui/controller.py:657 #, python-format msgid "Applied preset %s" msgstr "Aplicada a predefinição %s" #: data/input-remapper.glade:318 msgid "Apply" msgstr "Aplicar" #: inputremapper/gui/controller.py:522 #, python-format msgid "Are you sure you want to delete the preset \"%s\"?" msgstr "Tem certeza que deseja excluir a predefinição \"%s\"?" #: inputremapper/gui/controller.py:567 msgid "Are you sure you want to delete this mapping?" msgstr "Tem certeza que deseja excluir este mapeamento?" #: data/input-remapper.glade:410 msgid "Autoload" msgstr "Autocarregamento" #: data/input-remapper.glade:854 msgid "Available output axes are affected by the Target setting." msgstr "Os eixos de saída disponíveis são afetados pela configuração de Alvo." #: inputremapper/gui/controller.py:621 msgid "Cannot apply empty preset file" msgstr "Não é possível aplicar um arquivo de predefinição vazio" #: inputremapper/gui/components/editor.py:238 msgid "Change Mapping Name" msgstr "Altera o nome do mapeamento" #: data/input-remapper.glade:352 msgid "Copy" msgstr "Copiar" #: data/input-remapper.glade:189 msgid "Create a new preset" msgstr "Criar uma nova predefinição" #: data/input-remapper.glade:925 msgid "Deadzone" msgstr "Zona morta" #: data/input-remapper.glade:368 data/input-remapper.glade:620 msgid "Delete" msgstr "Excluir" #: data/input-remapper.glade:624 msgid "Delete this entry" msgstr "Excluir esta entrada" #: data/input-remapper.glade:372 msgid "Delete this preset" msgstr "Exclui esta predefinição" #: data/input-remapper.glade:162 msgid "Device Name" msgstr "Nome do dispositivo" #: data/input-remapper.glade:148 msgid "Devices" msgstr "Dispositivos" #: data/input-remapper.glade:356 msgid "Duplicate this preset" msgstr "Duplica esta predefinição" #: data/input-remapper.glade:1208 msgid "Editor" msgstr "Editor" #: inputremapper/configs/mapping.py:77 msgid "Empty Mapping" msgstr "Mapeamento vazio" #: inputremapper/gui/components/editor.py:404 msgid "Enter your output here" msgstr "Insira sua saída aqui" #: data/input-remapper.glade:1762 msgid "Event Specific" msgstr "Específico de evento" #: data/input-remapper.glade:1009 msgid "Expo" msgstr "Expo" #: inputremapper/gui/controller.py:648 inputremapper/gui/controller.py:673 #, python-format msgid "Failed to apply preset %s" msgstr "Falha ao aplicar a predefinição %s" #: data/input-remapper.glade:967 msgid "Gain" msgstr "Ganho" #: data/input-remapper.glade:1736 msgid "General" msgstr "Geral" #: data/input-remapper.glade:1312 msgid "Help" msgstr "Ajuda" #: data/input-remapper.glade:510 msgid "Input" msgstr "Entrada" #: data/input-remapper.glade:1296 msgid "Input Remapper" msgstr "Remapeador de entrada" #: data/input-remapper.glade:1087 msgid "Input cutoff" msgstr "Corte da entrada" #: data/input-remapper.glade:760 data/input-remapper.glade:1180 msgid "Key or Macro" msgstr "Chave ou macro" #: data/input-remapper.glade:1801 msgid "Map this input to an Analog Axis" msgstr "Mapear esta entrada para um eixo analógico" # Nova predefinição -- Rafael #: data/input-remapper.glade:185 msgid "New" msgstr "Nova" #: inputremapper/gui/components/editor.py:980 msgid "No Axis" msgstr "Nenhum eixo" #: data/input-remapper.glade:721 msgid "Output" msgstr "Saída" #: data/input-remapper.glade:862 msgid "Output axis" msgstr "Eixo de saída" #: inputremapper/gui/controller.py:508 inputremapper/gui/controller.py:582 msgid "Permission denied!" msgstr "Permissão negada!" #: data/input-remapper.glade:287 msgid "Preset Name" msgstr "Nome da predefinição" #: data/input-remapper.glade:272 msgid "Presets" msgstr "Predefinições" #: data/input-remapper.glade:590 msgid "Record" msgstr "Gravar" #: data/input-remapper.glade:594 msgid "Record a button of your device that should be remapped" msgstr "Grava um botão de seu dispositivo que deve ser remapeado" #: inputremapper/gui/components/editor.py:563 msgid "Record input first" msgstr "Gravar primeira entrada" #: inputremapper/gui/components/editor.py:65 msgid "Record the input first" msgstr "Grava a primeira entrada" #: data/input-remapper.glade:1708 msgid "" "Release all inputs which are part of the combination before the mapping is " "injected" msgstr "" "Libere todas as entradas que fazem parte da combinação antes que o " "mapeamento seja injetado" #: data/input-remapper.glade:1711 msgid "Release input" msgstr "Liberar entrada" #: data/input-remapper.glade:1683 msgid "Release timeout" msgstr "Tempo limite para liberar" #: inputremapper/gui/controller.py:208 msgid "Remove the Analog Output Axis when specifying a macro or key output" msgstr "" "Remove o eixo de saída analógica ao especificar uma saída de macro ou tecla" #: inputremapper/gui/controller.py:199 msgid "" "Remove the macro or key from the macro input field when specifying an analog " "output" msgstr "" "Remova a macro ou chave do campo de entrada de macro ao especificar uma " "saída analógica" #: data/input-remapper.glade:1776 msgid "Remove this input" msgstr "Remove esta entrada" #: data/input-remapper.glade:394 msgid "Rename" msgstr "Renomear" #: data/input-remapper.glade:452 msgid "Save the entered name" msgstr "Salva o nome inserido" #: data/input-remapper.glade:1449 msgid "" "See usage.md online on github for comprehensive information.\n" "\n" "A \"key + key + ... + key\" syntax can be used to trigger key combinations. " "For example \"Control_L + a\".\n" "\n" "Writing \"disable\" as a mapping disables a key.\n" "\n" "Macros allow multiple characters to be written with a single key-press. " "Information about programming them is available online on github. See macros.md and examples.md" msgstr "" "Consulte usage.md online no github para obter informações " "abrangentes.\n" "\n" "Uma sintaxe \"tecla + tecla + ... + tecla\" pode ser usada para acionar " "combinações de teclas. Por exemplo, \"Control_L + a\".\n" "\n" "Escrever \"disable\" como mapeamento desativa uma tecla.\n" "\n" "As macros permitem que vários caracteres sejam escritos com um único " "pressionamento de tecla. Informações sobre como programá-los estão " "disponíveis online no github. Consulte macros.md e examples.md" #: data/input-remapper.glade:1590 msgid "Shortcuts" msgstr "Atalhos" #: data/input-remapper.glade:1492 msgid "" "Shortcuts only work while keys are not being recorded and the gui is in " "focus." msgstr "" "Os atalhos funcionam apenas enquanto as teclas não estão sendo gravadas e a " "interface gráfica está em foco." #: data/input-remapper.glade:322 msgid "Start injecting. Don't hold down any keys while the injection starts" msgstr "Começa a injeção. Não segure nenhuma tecla enquanto a injeção começa" #: inputremapper/gui/controller.py:643 msgid "Starting injection..." msgstr "Iniciando a injeção..." #: data/input-remapper.glade:201 data/input-remapper.glade:334 msgid "Stop" msgstr "Parar" #: inputremapper/gui/controller.py:710 msgid "Stopped the injection" msgstr "Parou a injeção" #: data/input-remapper.glade:205 data/input-remapper.glade:338 msgid "" "Stops the Injection for the selected device,\n" "gives your keys their original function back\n" "Shortcut: ctrl + del" msgstr "" "Interrompe a injeção para o dispositivo selecionado,\n" "devolve às suas teclas a função original\n" "Atalho: ctrl + del" #: data/input-remapper.glade:812 msgid "Target" msgstr "Alvo" #: data/input-remapper.glade:1078 msgid "" "The Speed at which the Input is considered at maximum.\n" "Only relevant when mapping relative inputs (e.g. mouse) to absolute outputs " "(e.g. gamepad)" msgstr "" "A velocidade na qual a entrada é considerada no máximo.\n" "Relevante apenas ao mapear entradas relativas (por exemplo, mouse) para " "saídas absolutas (por exemplo, gamepad)" #: inputremapper/gui/controller.py:234 msgid "" "The input specifies a key or macro input, but no macro or key is programmed." msgstr "" "A entrada especifica uma entrada de tecla ou macro, mas nenhuma macro ou " "chave está programada." #: inputremapper/gui/controller.py:213 msgid "The input specifies an analog axis, but no output axis is selected." msgstr "" "A entrada especifica um eixo analógico, mas nenhum eixo de saída está " "selecionado." #: data/input-remapper.glade:803 msgid "The type of device this mapping is emulating." msgstr "O tipo de dispositivo que este mapeamento está emulando." #: data/input-remapper.glade:1831 msgid "Trigger threshold" msgstr "Limiar de acionamento" #: data/input-remapper.glade:743 msgid "Type" msgstr "Tipo" #: data/input-remapper.glade:1473 msgid "Usage" msgstr "Uso" #: inputremapper/gui/controller.py:593 msgid "Use \"Stop\" to stop before editing" msgstr "Use \"Parar\" para parar antes de editar" #: data/input-remapper.glade:1805 msgid "Use as analog" msgstr "Usa como analógico" #: data/input-remapper.glade:1366 msgid "Version unknown" msgstr "Versão desconhecida" #: data/input-remapper.glade:1134 msgid "What should be written. For example KEY_A" msgstr "O que deve ser escrito. Por exemplo, KEY_A" #: inputremapper/gui/controller.py:761 msgid "You are about to change the mapping to analog." msgstr "Você está prestes a alterar o mapeamento para analógico." #: data/input-remapper.glade:1161 msgid "You can copy this text into the output" msgstr "Você pode copiar este texto na saída" #: data/input-remapper.glade:1383 msgid "" "You can find more information and report bugs at\n" "https://github.com/" "sezanzeb/input-remapper" msgstr "" "Você pode encontrar mais informações e relatar bugs em\n" "https://github.com/" "sezanzeb/input-remapper" #: inputremapper/gui/controller.py:623 msgid "You need to add mappings first" msgstr "Você precisa adicionar mapeamentos primeiro" #: inputremapper/gui/controller.py:358 msgid "" "Your system might reinterpret combinations with those after they are " "injected, and by doing so break them." msgstr "" "Seu sistema pode reinterpretar combinações com aquelas após serem injetadas " "e, ao fazê-lo, quebrá-las." #: data/input-remapper.glade:1524 msgid "closes the application" msgstr "fecha o aplicativo" #: data/input-remapper.glade:1512 msgid "ctrl + del" msgstr "ctrl + del" #: data/input-remapper.glade:1536 msgid "ctrl + q" msgstr "ctrl + q" #: data/input-remapper.glade:1548 msgid "ctrl + r" msgstr "ctrl + r" #: inputremapper/gui/controller.py:356 msgid "ctrl, alt and shift may not combine properly" msgstr "ctrl, alt e shift podem não combinar adequadamente" #: inputremapper/gui/data_manager.py:56 msgid "new preset" msgstr "nova predefinição" #: data/input-remapper.glade:531 inputremapper/gui/user_interface.py:385 msgid "no input configured" msgstr "nenhuma entrada configurada" #: data/input-remapper.glade:1560 msgid "refreshes the device list" msgstr "atualiza a lista de dispositivos" #: data/input-remapper.glade:1572 msgid "stops the injection" msgstr "para a injeção" #: data/input-remapper.glade:1403 msgid "" "© 2023 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 "" "© 2023 Sezanzeb proxima@sezanzeb.de\n" "Este programa vem com absolutamente nenhuma garantia.\n" "Veja a Licença Pública " "Geral GNU, versão 3 ou posterior para detalhes." input-remapper-2.1.1/po/ru.po000077700000000000000000000000001475433465200176302./ru_RU.poustar00rootroot00000000000000input-remapper-2.1.1/po/ru_RU.po000066400000000000000000000346541475433465200165260ustar00rootroot00000000000000# SOME DESCRIPTIVE TITLE. # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER # This file is distributed under the same license as the PACKAGE package. # FIRST AUTHOR , YEAR. # msgid "" msgstr "" "Project-Id-Version: \n" "Report-Msgid-Bugs-To: \n" "POT-Creation-Date: 2023-05-16 08:30-0300\n" "PO-Revision-Date: 2023-01-11 21:48+0100\n" "Last-Translator: Sviatoslav Vorona \n" "Language-Team: \n" "Language: ru_RU\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" "X-Generator: Poedit 3.0\n" "Plural-Forms: nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && " "n%10<=4 && (n%100<12 || n%100>14) ? 1 : 2);\n" #: inputremapper/gui/controller.py:222 msgid "" "\n" "If you mean to create a key or macro mapping go to the advanced input " "configuration and set a \"Trigger Threshold\" for " msgstr "" #: inputremapper/gui/controller.py:239 msgid "" "\n" "If you mean to create an analog axis mapping go to the advanced input " "configuration and set an input to \"Use as Analog\"." msgstr "" #: inputremapper/gui/controller.py:779 msgid "" "\n" "The input \"{}\" will be used as analog input." msgstr "" #: inputremapper/gui/controller.py:763 msgid "" "\n" "This will remove \"{}\" from the text input!" msgstr "" #: inputremapper/gui/controller.py:784 msgid "" "\n" "You need to record an analog input." msgstr "" #: data/input-remapper.glade:544 msgid " (recording ...)" msgstr " (запись ...)" #: inputremapper/gui/controller.py:174 #, python-format msgid "%d Mapping errors at \"%s\", hover for info" msgstr "" #: inputremapper/gui/controller.py:661 msgid ", CTRL + DEL to stop" msgstr "" #: data/input-remapper.glade:1421 msgid "About" msgstr "О программе" #: data/input-remapper.glade:422 msgid "" "Activate this to load the preset next time the device connects, or when the " "user logs in" msgstr "" #: data/input-remapper.glade:575 msgid "Add" msgstr "Добавить" #: inputremapper/gui/components/editor.py:554 msgid "Add a mapping first" msgstr "" #: data/input-remapper.glade:606 data/input-remapper.glade:1618 msgid "Advanced" msgstr "Дополнительные" #: data/input-remapper.glade:773 data/input-remapper.glade:1118 msgid "Analog Axis" msgstr "Аналоговая Ось" #: inputremapper/gui/controller.py:657 #, python-format msgid "Applied preset %s" msgstr "" #: data/input-remapper.glade:318 msgid "Apply" msgstr "Применить" #: inputremapper/gui/controller.py:522 #, python-format msgid "Are you sure you want to delete the preset \"%s\"?" msgstr "" #: inputremapper/gui/controller.py:567 msgid "Are you sure you want to delete this mapping?" msgstr "" #: data/input-remapper.glade:410 msgid "Autoload" msgstr "Автозагрузка" #: data/input-remapper.glade:854 msgid "Available output axes are affected by the Target setting." msgstr "Доступные оси вывода затрагиваются настройками Цели." #: inputremapper/gui/controller.py:621 msgid "Cannot apply empty preset file" msgstr "" #: inputremapper/gui/components/editor.py:238 msgid "Change Mapping Name" msgstr "" #: data/input-remapper.glade:352 msgid "Copy" msgstr "Копировать" #: data/input-remapper.glade:189 msgid "Create a new preset" msgstr "Создать новую предустановку" #: data/input-remapper.glade:925 msgid "Deadzone" msgstr "Мёртвая зона" #: data/input-remapper.glade:368 data/input-remapper.glade:620 msgid "Delete" msgstr "Удалить" #: data/input-remapper.glade:624 msgid "Delete this entry" msgstr "Удалить эту запись" #: data/input-remapper.glade:372 msgid "Delete this preset" msgstr "Удалить эту предустановку" #: data/input-remapper.glade:162 msgid "Device Name" msgstr "Имя устройства" #: data/input-remapper.glade:148 msgid "Devices" msgstr "Устройства" #: data/input-remapper.glade:356 msgid "Duplicate this preset" msgstr "Скопировать эту предустановку" #: data/input-remapper.glade:1208 msgid "Editor" msgstr "Редактор" #: inputremapper/configs/mapping.py:77 msgid "Empty Mapping" msgstr "" #: inputremapper/gui/components/editor.py:404 msgid "Enter your output here" msgstr "" #: data/input-remapper.glade:1762 msgid "Event Specific" msgstr "Специфичные для событий" #: data/input-remapper.glade:1009 msgid "Expo" msgstr "Expo" #: inputremapper/gui/controller.py:648 inputremapper/gui/controller.py:673 #, python-format msgid "Failed to apply preset %s" msgstr "" #: data/input-remapper.glade:967 msgid "Gain" msgstr "Усиление" #: data/input-remapper.glade:1736 msgid "General" msgstr "Общие" #: data/input-remapper.glade:1312 msgid "Help" msgstr "Помощь" #: data/input-remapper.glade:510 msgid "Input" msgstr "Ввод" #: data/input-remapper.glade:1296 msgid "Input Remapper" msgstr "Input Remapper" #: data/input-remapper.glade:1087 msgid "Input cutoff" msgstr "Входная отсечка" #: data/input-remapper.glade:760 data/input-remapper.glade:1180 msgid "Key or Macro" msgstr "Клавиша или Макрос" #: data/input-remapper.glade:1801 msgid "Map this input to an Analog Axis" msgstr "" #: data/input-remapper.glade:185 msgid "New" msgstr "Новая" #: inputremapper/gui/components/editor.py:980 #, fuzzy msgid "No Axis" msgstr "Аналоговая Ось" #: data/input-remapper.glade:721 msgid "Output" msgstr "Вывод" #: data/input-remapper.glade:862 msgid "Output axis" msgstr "Ось вывода" #: inputremapper/gui/controller.py:508 inputremapper/gui/controller.py:582 msgid "Permission denied!" msgstr "" #: data/input-remapper.glade:287 msgid "Preset Name" msgstr "Имя предустановки" #: data/input-remapper.glade:272 msgid "Presets" msgstr "Предустановки" #: data/input-remapper.glade:590 msgid "Record" msgstr "Запись" #: data/input-remapper.glade:594 msgid "Record a button of your device that should be remapped" msgstr "Записать клавишу вашего устройства которая должна быть привязана" #: inputremapper/gui/components/editor.py:563 msgid "Record input first" msgstr "" #: inputremapper/gui/components/editor.py:65 #, fuzzy msgid "Record the input first" msgstr "Удалить этот ввод" #: data/input-remapper.glade:1708 msgid "" "Release all inputs which are part of the combination before the mapping is " "injected" msgstr "" #: data/input-remapper.glade:1711 msgid "Release input" msgstr "Освободить" #: data/input-remapper.glade:1683 msgid "Release timeout" msgstr "Таймаут освобождения" #: inputremapper/gui/controller.py:208 msgid "Remove the Analog Output Axis when specifying a macro or key output" msgstr "" #: inputremapper/gui/controller.py:199 msgid "" "Remove the macro or key from the macro input field when specifying an analog " "output" msgstr "" #: data/input-remapper.glade:1776 msgid "Remove this input" msgstr "Удалить этот ввод" #: data/input-remapper.glade:394 msgid "Rename" msgstr "Переименовать" #: data/input-remapper.glade:452 msgid "Save the entered name" msgstr "Сохранить введённое имя" #: data/input-remapper.glade:1449 msgid "" "See usage.md online on github for comprehensive information.\n" "\n" "A \"key + key + ... + key\" syntax can be used to trigger key combinations. " "For example \"Control_L + a\".\n" "\n" "Writing \"disable\" as a mapping disables a key.\n" "\n" "Macros allow multiple characters to be written with a single key-press. " "Information about programming them is available online on github. See macros.md and examples.md" msgstr "" "Смотрите usage.md в сети на github для исчерпывающей " "информации.\n" "\n" "A \"key + key + ... + key\" синтаксис может быть использован чтобы вызвать " "комбинацию клавиш. Например \"Control_L + a\".\n" "\n" "Написание \"disable\" как привязки отключает клавишу.\n" "\n" "Макросы позволяют написать множество символов нажатием одной клавиши. " "Информация об их программировании доступна в сети на github. Смотрите macros.md and examples.md" #: data/input-remapper.glade:1590 msgid "Shortcuts" msgstr "Комбинации" #: data/input-remapper.glade:1492 msgid "" "Shortcuts only work while keys are not being recorded and the gui is in " "focus." msgstr "" "Комбинации работают только пока клавиши не записываются и интерфейс " "приложения в фокусе." #: data/input-remapper.glade:322 msgid "Start injecting. Don't hold down any keys while the injection starts" msgstr "Начать ввод. Не зажимайте никакие клавиши пока ввод запускается" #: inputremapper/gui/controller.py:643 #, fuzzy msgid "Starting injection..." msgstr "останавливает ввод" #: data/input-remapper.glade:201 data/input-remapper.glade:334 msgid "Stop" msgstr "Остановить" #: inputremapper/gui/controller.py:710 #, fuzzy msgid "Stopped the injection" msgstr "останавливает ввод" #: data/input-remapper.glade:205 data/input-remapper.glade:338 msgid "" "Stops the Injection for the selected device,\n" "gives your keys their original function back\n" "Shortcut: ctrl + del" msgstr "" "Останавливает Ввод для выбранного устройства,\n" "возвращает функции ваших клавиш назад\n" "Комбинация: ctrl + del" #: data/input-remapper.glade:812 msgid "Target" msgstr "Цель" #: data/input-remapper.glade:1078 #, fuzzy msgid "" "The Speed at which the Input is considered at maximum.\n" "Only relevant when mapping relative inputs (e.g. mouse) to absolute outputs " "(e.g. gamepad)" msgstr "" "Скорость при которой Ввод считается максимальным.\n" "Актуально только при эмуляции относительных устройств ввода (например, мыши) " "как абсолютных устройств вывода (например, геймпада)." #: inputremapper/gui/controller.py:234 msgid "" "The input specifies a key or macro input, but no macro or key is programmed." msgstr "" #: inputremapper/gui/controller.py:213 msgid "The input specifies an analog axis, but no output axis is selected." msgstr "" #: data/input-remapper.glade:803 msgid "The type of device this mapping is emulating." msgstr "Тип устройства которое эмулируется этой привязкой." #: data/input-remapper.glade:1831 msgid "Trigger threshold" msgstr "Порог срабатывания" #: data/input-remapper.glade:743 msgid "Type" msgstr "Тип" #: data/input-remapper.glade:1473 msgid "Usage" msgstr "Использование" #: inputremapper/gui/controller.py:593 msgid "Use \"Stop\" to stop before editing" msgstr "" #: data/input-remapper.glade:1805 msgid "Use as analog" msgstr "Использовать как аналоговое" #: data/input-remapper.glade:1366 msgid "Version unknown" msgstr "Неизвестная версия" #: data/input-remapper.glade:1134 msgid "What should be written. For example KEY_A" msgstr "Что должно быть написано. Например KEY_A" #: inputremapper/gui/controller.py:761 msgid "You are about to change the mapping to analog." msgstr "" #: data/input-remapper.glade:1161 msgid "You can copy this text into the output" msgstr "" #: data/input-remapper.glade:1383 msgid "" "You can find more information and report bugs at\n" "https://github.com/" "sezanzeb/input-remapper" msgstr "" "Вы можете найти больше информации и сообщить о проблемах по адресу\n" "https://github.com/" "sezanzeb/input-remapper" #: inputremapper/gui/controller.py:623 msgid "You need to add mappings first" msgstr "" #: inputremapper/gui/controller.py:358 msgid "" "Your system might reinterpret combinations with those after they are " "injected, and by doing so break them." msgstr "" #: data/input-remapper.glade:1524 msgid "closes the application" msgstr "закрывает приложение" #: data/input-remapper.glade:1512 msgid "ctrl + del" msgstr "ctrl + del" #: data/input-remapper.glade:1536 msgid "ctrl + q" msgstr "ctrl + q" #: data/input-remapper.glade:1548 msgid "ctrl + r" msgstr "ctrl + r" #: inputremapper/gui/controller.py:356 msgid "ctrl, alt and shift may not combine properly" msgstr "" #: inputremapper/gui/data_manager.py:56 #, fuzzy msgid "new preset" msgstr "Создать новую предустановку" #: data/input-remapper.glade:531 inputremapper/gui/user_interface.py:385 msgid "no input configured" msgstr "ввод не сконфигурирован" #: data/input-remapper.glade:1560 msgid "refreshes the device list" msgstr "обновляет список устройств" #: data/input-remapper.glade:1572 msgid "stops the injection" msgstr "останавливает ввод" #: data/input-remapper.glade:1403 msgid "" "© 2023 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 "" "© 2023 Sezanzeb proxima@sezanzeb.de\n" "Программа распространяется без какой либо гарантии.\n" "Смотрите подробности по ссылке GNU General Public License, версия 3 или новее." input-remapper-2.1.1/po/sk.po000077700000000000000000000000001475433465200175752./sk_SK.poustar00rootroot00000000000000input-remapper-2.1.1/po/sk_SK.po000066400000000000000000000431751475433465200165020ustar00rootroot00000000000000# Slovak translation of input-remapper. # Copyright (C) 2023. # 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: 2023-05-16 08:30-0300\n" "PO-Revision-Date: 2022-02-08 08:58+0100\n" "Last-Translator: Jose Riha \n" "Language-Team: \n" "Language: sk_SK\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" "X-Generator: Poedit 3.0\n" "Plural-Forms: nplurals=3; plural=(n==1 ? 0 : n>=2 && n<=4 ? 1 : 2);\n" #: inputremapper/gui/controller.py:222 msgid "" "\n" "If you mean to create a key or macro mapping go to the advanced input " "configuration and set a \"Trigger Threshold\" for " msgstr "" #: inputremapper/gui/controller.py:239 msgid "" "\n" "If you mean to create an analog axis mapping go to the advanced input " "configuration and set an input to \"Use as Analog\"." msgstr "" #: inputremapper/gui/controller.py:779 msgid "" "\n" "The input \"{}\" will be used as analog input." msgstr "" #: inputremapper/gui/controller.py:763 msgid "" "\n" "This will remove \"{}\" from the text input!" msgstr "" #: inputremapper/gui/controller.py:784 msgid "" "\n" "You need to record an analog input." msgstr "" #: data/input-remapper.glade:544 msgid " (recording ...)" msgstr "" #: inputremapper/gui/controller.py:174 #, fuzzy, python-format msgid "%d Mapping errors at \"%s\", hover for info" msgstr "Chyba syntaxe na %s, prejdite myšou pre viac informácií" #: inputremapper/gui/controller.py:661 msgid ", CTRL + DEL to stop" msgstr ", zastavíte stlačením CTRL + DEL" #: data/input-remapper.glade:1421 msgid "About" msgstr "O programe" #: data/input-remapper.glade:422 msgid "" "Activate this to load the preset next time the device connects, or when the " "user logs in" msgstr "" #: data/input-remapper.glade:575 msgid "Add" msgstr "" #: inputremapper/gui/components/editor.py:554 msgid "Add a mapping first" msgstr "" #: data/input-remapper.glade:606 data/input-remapper.glade:1618 msgid "Advanced" msgstr "" #: data/input-remapper.glade:773 data/input-remapper.glade:1118 msgid "Analog Axis" msgstr "" #: inputremapper/gui/controller.py:657 #, python-format msgid "Applied preset %s" msgstr "Použité prednastavenie %s" #: data/input-remapper.glade:318 msgid "Apply" msgstr "Použiť" #: inputremapper/gui/controller.py:522 #, fuzzy, python-format msgid "Are you sure you want to delete the preset \"%s\"?" msgstr "Naozaj chcete odstrániť prednastavenie %s?" #: inputremapper/gui/controller.py:567 #, fuzzy msgid "Are you sure you want to delete this mapping?" msgstr "Naozaj chcete odstrániť toto mapovanie?" #: data/input-remapper.glade:410 msgid "Autoload" msgstr "Automaticky načítať" #: data/input-remapper.glade:854 msgid "Available output axes are affected by the Target setting." msgstr "" #: inputremapper/gui/controller.py:621 msgid "Cannot apply empty preset file" msgstr "Nedá sa použiť prázdny súbor s prednastavením" #: inputremapper/gui/components/editor.py:238 msgid "Change Mapping Name" msgstr "" #: data/input-remapper.glade:352 msgid "Copy" msgstr "Kopírovať" #: data/input-remapper.glade:189 msgid "Create a new preset" msgstr "Vytvoriť nové prednastavenie" #: data/input-remapper.glade:925 msgid "Deadzone" msgstr "" #: data/input-remapper.glade:368 data/input-remapper.glade:620 msgid "Delete" msgstr "Odstrániť" #: data/input-remapper.glade:624 msgid "Delete this entry" msgstr "Odstrániť túto položku" #: data/input-remapper.glade:372 msgid "Delete this preset" msgstr "Odstrániť toto prednastavenie" #: data/input-remapper.glade:162 #, fuzzy msgid "Device Name" msgstr "Zariadenie" #: data/input-remapper.glade:148 #, fuzzy msgid "Devices" msgstr "Zariadenie" #: data/input-remapper.glade:356 msgid "Duplicate this preset" msgstr "Duplikovať toto prednastavenie" #: data/input-remapper.glade:1208 msgid "Editor" msgstr "" #: inputremapper/configs/mapping.py:77 #, fuzzy msgid "Empty Mapping" msgstr "Mapovanie" #: inputremapper/gui/components/editor.py:404 msgid "Enter your output here" msgstr "" #: data/input-remapper.glade:1762 msgid "Event Specific" msgstr "" #: data/input-remapper.glade:1009 msgid "Expo" msgstr "" #: inputremapper/gui/controller.py:648 inputremapper/gui/controller.py:673 #, python-format msgid "Failed to apply preset %s" msgstr "Nepodarilo sa načítať prednastavenie %s" #: data/input-remapper.glade:967 msgid "Gain" msgstr "" #: data/input-remapper.glade:1736 msgid "General" msgstr "" #: data/input-remapper.glade:1312 msgid "Help" msgstr "Pomocník" #: data/input-remapper.glade:510 msgid "Input" msgstr "" #: data/input-remapper.glade:1296 msgid "Input Remapper" msgstr "Mapovač vstupu" #: data/input-remapper.glade:1087 msgid "Input cutoff" msgstr "" #: data/input-remapper.glade:760 data/input-remapper.glade:1180 msgid "Key or Macro" msgstr "" #: data/input-remapper.glade:1801 msgid "Map this input to an Analog Axis" msgstr "" #: data/input-remapper.glade:185 msgid "New" msgstr "Nové" #: inputremapper/gui/components/editor.py:980 msgid "No Axis" msgstr "" #: data/input-remapper.glade:721 msgid "Output" msgstr "" #: data/input-remapper.glade:862 msgid "Output axis" msgstr "" #: inputremapper/gui/controller.py:508 inputremapper/gui/controller.py:582 msgid "Permission denied!" msgstr "Odopretý prístup!" #: data/input-remapper.glade:287 #, fuzzy msgid "Preset Name" msgstr "Prednastavenie" #: data/input-remapper.glade:272 #, fuzzy msgid "Presets" msgstr "Prednastavenie" #: data/input-remapper.glade:590 msgid "Record" msgstr "" #: data/input-remapper.glade:594 msgid "Record a button of your device that should be remapped" msgstr "Zaznamenajte tlačidlo zariadenia, ktoré sa má premapovať" #: inputremapper/gui/components/editor.py:563 msgid "Record input first" msgstr "" #: inputremapper/gui/components/editor.py:65 #, fuzzy msgid "Record the input first" msgstr "Najprv nastavte kláves" #: data/input-remapper.glade:1708 msgid "" "Release all inputs which are part of the combination before the mapping is " "injected" msgstr "" #: data/input-remapper.glade:1711 msgid "Release input" msgstr "" #: data/input-remapper.glade:1683 msgid "Release timeout" msgstr "" #: inputremapper/gui/controller.py:208 msgid "Remove the Analog Output Axis when specifying a macro or key output" msgstr "" #: inputremapper/gui/controller.py:199 msgid "" "Remove the macro or key from the macro input field when specifying an analog " "output" msgstr "" #: data/input-remapper.glade:1776 msgid "Remove this input" msgstr "" #: data/input-remapper.glade:394 msgid "Rename" msgstr "Premenovať" #: data/input-remapper.glade:452 msgid "Save the entered name" msgstr "Uložiť zadané meno" #: data/input-remapper.glade:1449 msgid "" "See usage.md online on github for comprehensive information.\n" "\n" "A \"key + key + ... + key\" syntax can be used to trigger key combinations. " "For example \"Control_L + a\".\n" "\n" "Writing \"disable\" as a mapping disables a key.\n" "\n" "Macros allow multiple characters to be written with a single key-press. " "Information about programming them is available online on github. See macros.md and examples.md" msgstr "" "Vyčerpávajúce informácie nájdete na online stránke usage.md na " "githube.\n" "\n" "Syntax \"key + key + ... + key\" môžete použit na aktivovanie kombinácie " "klávesov. Napríklad \"Control_L + a\".\n" "\n" "Ak použijete \"disable\" ako mapovanie, mapovanie klávesu deaktivujete.\n" "\n" "Makrá vám umožnia aktivovať viacero znakov pomocou stlačenia jedného " "klávesu. Informácie o možnostiach programovania nájdete online na githube. " "Pozrite macros.md a examples.md" #: data/input-remapper.glade:1590 msgid "Shortcuts" msgstr "Skratky" #: data/input-remapper.glade:1492 msgid "" "Shortcuts only work while keys are not being recorded and the gui is in " "focus." msgstr "" "Skratky fungujú iba vtedy, ak sa tlačidlá nenahrávaju a okno programu je " "aktívne." #: data/input-remapper.glade:322 msgid "Start injecting. Don't hold down any keys while the injection starts" msgstr "" "Spustiť injektáž. Uistite sa, že pri spúšťaní injektáže nie sú stlačené " "žiadne tlačidlá" #: inputremapper/gui/controller.py:643 msgid "Starting injection..." msgstr "Spúšťanie injektáže..." #: data/input-remapper.glade:201 data/input-remapper.glade:334 msgid "Stop" msgstr "Zastaviť injektáž" #: inputremapper/gui/controller.py:710 #, fuzzy msgid "Stopped the injection" msgstr "zastaví injektáž" #: data/input-remapper.glade:205 data/input-remapper.glade:338 msgid "" "Stops the Injection for the selected device,\n" "gives your keys their original function back\n" "Shortcut: ctrl + del" msgstr "" #: data/input-remapper.glade:812 msgid "Target" msgstr "" #: data/input-remapper.glade:1078 msgid "" "The Speed at which the Input is considered at maximum.\n" "Only relevant when mapping relative inputs (e.g. mouse) to absolute outputs " "(e.g. gamepad)" msgstr "" #: inputremapper/gui/controller.py:234 msgid "" "The input specifies a key or macro input, but no macro or key is programmed." msgstr "" #: inputremapper/gui/controller.py:213 msgid "The input specifies an analog axis, but no output axis is selected." msgstr "" #: data/input-remapper.glade:803 msgid "The type of device this mapping is emulating." msgstr "Typ zariadenia, ktoré toto mapovanie emuluje." #: data/input-remapper.glade:1831 msgid "Trigger threshold" msgstr "" #: data/input-remapper.glade:743 msgid "Type" msgstr "" #: data/input-remapper.glade:1473 msgid "Usage" msgstr "Použitie" #: inputremapper/gui/controller.py:593 msgid "Use \"Stop\" to stop before editing" msgstr "Pred editovaním použite \"Zastaviť injektáž\"" #: data/input-remapper.glade:1805 msgid "Use as analog" msgstr "" #: data/input-remapper.glade:1366 msgid "Version unknown" msgstr "Neznáma verzia" #: data/input-remapper.glade:1134 msgid "What should be written. For example KEY_A" msgstr "" #: inputremapper/gui/controller.py:761 msgid "You are about to change the mapping to analog." msgstr "" #: data/input-remapper.glade:1161 msgid "You can copy this text into the output" msgstr "" #: data/input-remapper.glade:1383 msgid "" "You can find more information and report bugs at\n" "https://github.com/" "sezanzeb/input-remapper" msgstr "" "Viac informácií a hlásenia chýb nájdete na\n" "https://github.com/" "sezanzeb/input-remapper" #: inputremapper/gui/controller.py:623 #, fuzzy msgid "You need to add mappings first" msgstr "Musíte najprv pridať klávesy a uložiť ich" #: inputremapper/gui/controller.py:358 #, fuzzy msgid "" "Your system might reinterpret combinations with those after they are " "injected, and by doing so break them." msgstr "kombinácie pôvodných a emulovaných kláves a takto " #: data/input-remapper.glade:1524 msgid "closes the application" msgstr "zavrie aplikáciu" #: data/input-remapper.glade:1512 msgid "ctrl + del" msgstr "ctrl + del" #: data/input-remapper.glade:1536 msgid "ctrl + q" msgstr "ctrl + q" #: data/input-remapper.glade:1548 msgid "ctrl + r" msgstr "ctrl + r" #: inputremapper/gui/controller.py:356 msgid "ctrl, alt and shift may not combine properly" msgstr "ctrl, alt a shift nemusia v kombinácii fungovať správne" #: inputremapper/gui/data_manager.py:56 #, fuzzy msgid "new preset" msgstr "Vytvoriť nové prednastavenie" #: data/input-remapper.glade:531 inputremapper/gui/user_interface.py:385 msgid "no input configured" msgstr "" #: data/input-remapper.glade:1560 msgid "refreshes the device list" msgstr "aktualizuje zoznam zariadení" #: data/input-remapper.glade:1572 msgid "stops the injection" msgstr "zastaví injektáž" #: data/input-remapper.glade:1403 msgid "" "© 2023 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 "" "© 2023 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." #, python-format #~ msgid "\"%s\" already mapped to \"%s\"" #~ msgstr "\"%s\" je už namapované na \"%s\"" #~ msgid "Applied the system default" #~ msgstr "Použité systémové nastavenia" #~ msgid "Buttons" #~ msgstr "Tlačidlá" #~ msgid "Cancel" #~ msgstr "Zrušiť" #~ msgid "Change Key" #~ msgstr "Zmeniť kláves" #~ msgid "Joystick" #~ msgstr "Joystick" #~ msgid "Left joystick" #~ msgstr "Ľavý joystick" #~ msgid "Mouse" #~ msgstr "Myš" #~ msgid "Mouse speed" #~ msgstr "Citlivosť myši" #~ msgid "Right joystick" #~ msgstr "Pravý joystick" #~ msgid "" #~ "Shortcut: ctrl + del\n" #~ "Gives your keys back their original function" #~ msgstr "" #~ "Skratka: ctrl + del\n" #~ "Vráti vašim klávesom pôvodnú funkciu" #~ msgid "The helper did not start" #~ msgstr "Pomocná aplikácia sa nespustila" #~ msgid "" #~ "To automatically apply the preset after your login or when it connects." #~ msgstr "" #~ "Na automatické načítanie prednastavenia po prihlásení alebo pripojení " #~ "zariadenia." #, python-format #~ msgid "Unknown mapping %s" #~ msgstr "Neznáme mapovanie %s" #~ msgid "Wheel" #~ msgstr "Koliesko" #~ msgid "Your system might reinterpret combinations " #~ msgstr "Váš systém by mohol zle interpretovať " #~ msgid "break them." #~ msgstr "ich rozbiť." #~ msgid "new entry" #~ msgstr "nová položka" #~ msgid "." #~ msgstr "." #~ msgid "1, 2" #~ msgstr "1, 2" #~ msgid "" #~ "A \"key + key + ... + key\" syntax can be used to trigger key " #~ "combinations. For example \"control_l + a\".\n" #~ "\n" #~ "\"disable\" disables a key." #~ msgstr "" #~ "Syntax \"kláves + kláves + ... + kláves\" môžete použiť na zadávanie " #~ "kombinácie klávesov. Napríklad \"control_l + a\".\n" #~ "\n" #~ "\"disable\" deaktivuje mapovanie klávesu." #~ msgid "" #~ "Between calls to k, key down and key up events, macros will sleep for " #~ "10ms by default, which can be configured in ~/.config/input-remapper/" #~ "config" #~ msgstr "" #~ "Medzi volaniami k, udalosťami stlačeného a uvoľneného klávesu štandardne " #~ "čakajú makrá 10 ms. Toto nastavenie môžete zmeniť v ~/.config/input-" #~ "remapper/config" #~ msgid "CTRL + a, CTRL + x" #~ msgstr "CTRL + a, CTRL + x" #~ msgid "" #~ "Click on a cell below and hit a key on your device. Click the \"Restore " #~ "Defaults\" button beforehand." #~ msgstr "" #~ "Kliknite do poľa nižšie a stlačte tlačidlo na vašom zariadení. Predtým " #~ "kliknite na tlačidlo \"Obnoviť predvolené\"." #~ msgid "Examples" #~ msgstr "Príklady" #~ msgid "Go Back" #~ msgstr "Prejsť späť" #~ msgid "Key" #~ msgstr "Kláves" #~ msgid "Macros" #~ msgstr "Makrá" #~ msgid "" #~ "Macros allow multiple characters to be written with a single key-press." #~ msgstr "" #~ "Makrá vám umožnia zapísať po stlačení jedného klávesu viacero znakov." #~ msgid "a, a, a with 500ms pause" #~ msgstr "a, a, a s 500ms oneskorením" #~ msgid "e" #~ msgstr "e" #~ msgid "e(EV_REL, REL_X, 10)" #~ msgstr "e(EV_REL, REL_X, 10)" #~ msgid "executes the parameter as long as the key is pressed down" #~ msgstr "vykoná parameter, kým je kláves stlačený" #~ msgid "executes two actions behind each other" #~ msgstr "vykoná dve akcie za sebou" #~ msgid "h" #~ msgstr "h" #~ msgid "holds a modifier while executing the second parameter" #~ msgstr "počas vykonávania druhého parametra je stlačený modifikátor" #~ msgid "k" #~ msgstr "k" #~ msgid "k(1).h(k(2)).k(3)" #~ msgstr "k(1).h(k(2)).k(3)" #~ msgid "k(1).k(2)" #~ msgstr "k(1).k(2)" #~ msgid "keeps scrolling down while held" #~ msgstr "kým je držaný aktivuje sa skrolovanie smerom nadol" #~ msgid "m" #~ msgstr "m" #~ msgid "m(Control_L, k(a).k(x))" #~ msgstr "m(Control_L, k(a).k(x))" #~ msgid "mouse" #~ msgstr "mouse" #~ msgid "mouse(right, 4)" #~ msgstr "mouse(right, 4)" #~ msgid "moves the mouse cursor 10px to the right" #~ msgstr "posunie kurzor myši o 10 pixelov doprava" #~ msgid "r" #~ msgstr "r" #~ msgid "r(3, k(a).w(500))" #~ msgstr "r(3, k(a).w(500))" #~ msgid "repeats the execution of the second parameter" #~ msgstr "zopakuje spustenie druhého parametra" #~ msgid "same as mouse" #~ msgstr "tie isté ako pri myši" #~ msgid "takes direction (up, left, ...) and speed as parameters" #~ msgstr "prijíma smer (hore, vľavo, ...) a rýchlosť ako parametre" #~ msgid "w" #~ msgstr "w" #~ msgid "waits in milliseconds" #~ msgstr "čakanie (v milisekundách)" #~ msgid "wheel" #~ msgstr "wheel" #~ msgid "wheel(down, 1)" #~ msgstr "wheel(down, 1)" #~ msgid "which keeps moving the mouse while pressed" #~ msgstr "kým je stlačený, kurzor myši sa bude posúvať" #~ msgid "writes 1 2 2 ... 2 2 3 while the key is pressed" #~ msgstr "zapíše 1 2 2 ... 2 2 3, kým je kláves stlačený" #~ msgid "writes a single keystroke" #~ msgstr "zapíše jedno stlačenie klávesu" #~ msgid "writes an event" #~ msgstr "zapíše udalosť" input-remapper-2.1.1/po/uk.po000077700000000000000000000000001475433465200174342uk_UA.poustar00rootroot00000000000000input-remapper-2.1.1/po/uk_UA.po000066400000000000000000000416101475433465200164640ustar00rootroot00000000000000# SOME DESCRIPTIVE TITLE. # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER # This file is distributed under the same license as the PACKAGE package. # FIRST AUTHOR , YEAR. # msgid "" msgstr "" "Project-Id-Version: \n" "Report-Msgid-Bugs-To: \n" "POT-Creation-Date: 2023-05-16 08:30-0300\n" "PO-Revision-Date: 2022-11-14 10:56+0200\n" "Last-Translator: coffebar, 2022\n" "Language-Team: \n" "Language: uk_UA\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && " "n%10<=4 && (n%100<12 || n%100>14) ? 1 : 2);\n" "X-Generator: Poedit 3.2.1\n" #: inputremapper/gui/controller.py:222 msgid "" "\n" "If you mean to create a key or macro mapping go to the advanced input " "configuration and set a \"Trigger Threshold\" for " msgstr "" #: inputremapper/gui/controller.py:239 msgid "" "\n" "If you mean to create an analog axis mapping go to the advanced input " "configuration and set an input to \"Use as Analog\"." msgstr "" #: inputremapper/gui/controller.py:779 msgid "" "\n" "The input \"{}\" will be used as analog input." msgstr "" #: inputremapper/gui/controller.py:763 msgid "" "\n" "This will remove \"{}\" from the text input!" msgstr "" #: inputremapper/gui/controller.py:784 msgid "" "\n" "You need to record an analog input." msgstr "" #: data/input-remapper.glade:544 msgid " (recording ...)" msgstr " (записуємо...)" #: inputremapper/gui/controller.py:174 #, fuzzy, python-format msgid "%d Mapping errors at \"%s\", hover for info" msgstr "Синтаксична помилка в %s, наведіть курсор для подробиць" #: inputremapper/gui/controller.py:661 msgid ", CTRL + DEL to stop" msgstr ", CTRL + DEL щоб зупинити" #: data/input-remapper.glade:1421 msgid "About" msgstr "Про програму" #: data/input-remapper.glade:422 msgid "" "Activate this to load the preset next time the device connects, or when the " "user logs in" msgstr "" #: data/input-remapper.glade:575 msgid "Add" msgstr "Додати" #: inputremapper/gui/components/editor.py:554 msgid "Add a mapping first" msgstr "" #: data/input-remapper.glade:606 data/input-remapper.glade:1618 msgid "Advanced" msgstr "Розширені" #: data/input-remapper.glade:773 data/input-remapper.glade:1118 msgid "Analog Axis" msgstr "Аналоговий" #: inputremapper/gui/controller.py:657 #, python-format msgid "Applied preset %s" msgstr "Застосовано пресет %s" #: data/input-remapper.glade:318 msgid "Apply" msgstr "Застосувати" #: inputremapper/gui/controller.py:522 #, fuzzy, python-format msgid "Are you sure you want to delete the preset \"%s\"?" msgstr "Ви впевнені що хочете видалити пресет %s?" #: inputremapper/gui/controller.py:567 #, fuzzy msgid "Are you sure you want to delete this mapping?" msgstr "Ви впевнені, що хочете видалити цей мапінг?" #: data/input-remapper.glade:410 msgid "Autoload" msgstr "Автозавантаження" #: data/input-remapper.glade:854 msgid "Available output axes are affected by the Target setting." msgstr "Доступні вихідні осі залежать від параметра Ціль." #: inputremapper/gui/controller.py:621 msgid "Cannot apply empty preset file" msgstr "Неможливо застосувати порожній файл попереднього налаштування" #: inputremapper/gui/components/editor.py:238 msgid "Change Mapping Name" msgstr "" #: data/input-remapper.glade:352 msgid "Copy" msgstr "Копіювати" #: data/input-remapper.glade:189 msgid "Create a new preset" msgstr "Стваорити новий пресет" #: data/input-remapper.glade:925 msgid "Deadzone" msgstr "Мертва зона" #: data/input-remapper.glade:368 data/input-remapper.glade:620 msgid "Delete" msgstr "Видалити" #: data/input-remapper.glade:624 msgid "Delete this entry" msgstr "Видалити запис" #: data/input-remapper.glade:372 msgid "Delete this preset" msgstr "Видалити пресет" #: data/input-remapper.glade:162 msgid "Device Name" msgstr "Назва пристрою" #: data/input-remapper.glade:148 msgid "Devices" msgstr "Пристрої" #: data/input-remapper.glade:356 msgid "Duplicate this preset" msgstr "Копіювати пресет" #: data/input-remapper.glade:1208 msgid "Editor" msgstr "Редактор" #: inputremapper/configs/mapping.py:77 msgid "Empty Mapping" msgstr "" #: inputremapper/gui/components/editor.py:404 msgid "Enter your output here" msgstr "" #: data/input-remapper.glade:1762 msgid "Event Specific" msgstr "Залежно від події" #: data/input-remapper.glade:1009 msgid "Expo" msgstr "Експо" #: inputremapper/gui/controller.py:648 inputremapper/gui/controller.py:673 #, python-format msgid "Failed to apply preset %s" msgstr "Не вдалося застосувати пресет %s" #: data/input-remapper.glade:967 msgid "Gain" msgstr "Посилення" #: data/input-remapper.glade:1736 msgid "General" msgstr "Загалні" #: data/input-remapper.glade:1312 msgid "Help" msgstr "Допомога" #: data/input-remapper.glade:510 msgid "Input" msgstr "Ввод" #: data/input-remapper.glade:1296 msgid "Input Remapper" msgstr "Input Remapper" #: data/input-remapper.glade:1087 msgid "Input cutoff" msgstr "Відсічення вводу" #: data/input-remapper.glade:760 data/input-remapper.glade:1180 msgid "Key or Macro" msgstr "Клавіша або макрос" #: data/input-remapper.glade:1801 msgid "Map this input to an Analog Axis" msgstr "" #: data/input-remapper.glade:185 msgid "New" msgstr "Новий" #: inputremapper/gui/components/editor.py:980 #, fuzzy msgid "No Axis" msgstr "Аналоговий" #: data/input-remapper.glade:721 msgid "Output" msgstr "Вивод" #: data/input-remapper.glade:862 msgid "Output axis" msgstr "Вихідна вісь" #: inputremapper/gui/controller.py:508 inputremapper/gui/controller.py:582 msgid "Permission denied!" msgstr "Доступ відсутній!" #: data/input-remapper.glade:287 msgid "Preset Name" msgstr "Назва пресету" #: data/input-remapper.glade:272 msgid "Presets" msgstr "Пресети" #: data/input-remapper.glade:590 msgid "Record" msgstr "Запис" #: data/input-remapper.glade:594 msgid "Record a button of your device that should be remapped" msgstr "Записати клавішу яку потрібно перепризначити" #: inputremapper/gui/components/editor.py:563 msgid "Record input first" msgstr "" #: inputremapper/gui/components/editor.py:65 #, fuzzy msgid "Record the input first" msgstr "Спочатку задайте клавішу" #: data/input-remapper.glade:1708 msgid "" "Release all inputs which are part of the combination before the mapping is " "injected" msgstr "" #: data/input-remapper.glade:1711 msgid "Release input" msgstr "Звільнити ввод" #: data/input-remapper.glade:1683 msgid "Release timeout" msgstr "Таймаут звільнення" #: inputremapper/gui/controller.py:208 msgid "Remove the Analog Output Axis when specifying a macro or key output" msgstr "" #: inputremapper/gui/controller.py:199 msgid "" "Remove the macro or key from the macro input field when specifying an analog " "output" msgstr "" #: data/input-remapper.glade:1776 msgid "Remove this input" msgstr "Видалити цей ввод" #: data/input-remapper.glade:394 msgid "Rename" msgstr "Перейменувати" #: data/input-remapper.glade:452 msgid "Save the entered name" msgstr "Зберігти вказану назву" #: data/input-remapper.glade:1449 msgid "" "See usage.md online on github for comprehensive information.\n" "\n" "A \"key + key + ... + key\" syntax can be used to trigger key combinations. " "For example \"Control_L + a\".\n" "\n" "Writing \"disable\" as a mapping disables a key.\n" "\n" "Macros allow multiple characters to be written with a single key-press. " "Information about programming them is available online on github. See macros.md and examples.md" msgstr "" "Дивіться usage.md в Інтернеті на github для вичерпної " "інформації.\n" "\n" "Ви можете використовувати синтаксис \"key + key + ... + key\" щоб викликати " "комбінацію клавіш. Приклад: \"Control_L + a\".\n" "\n" "Слово \"disable\" в полі прив'язки відключає клавішу.\n" "\n" "Макроси дозволяють вводити декілька символів одним натисканням клавіші. " "Інформація про їх програмування доступна в Інтернеті на github. Дивіться macros.md та examples.md" #: data/input-remapper.glade:1590 msgid "Shortcuts" msgstr "Комбінації" #: data/input-remapper.glade:1492 msgid "" "Shortcuts only work while keys are not being recorded and the gui is in " "focus." msgstr "" "Комбінації працюють лише тоді, коли клавіші не записуються, а графічний " "інтерфейс у фокусі." #: data/input-remapper.glade:322 msgid "Start injecting. Don't hold down any keys while the injection starts" msgstr "" "Запуск перехоплення. Не нажимайте нічого поки перехоплення запускається" #: inputremapper/gui/controller.py:643 msgid "Starting injection..." msgstr "Запуск перехоплення..." #: data/input-remapper.glade:201 data/input-remapper.glade:334 msgid "Stop" msgstr "Зупинити" #: inputremapper/gui/controller.py:710 #, fuzzy msgid "Stopped the injection" msgstr "зупиняє перехоплення" #: data/input-remapper.glade:205 data/input-remapper.glade:338 msgid "" "Stops the Injection for the selected device,\n" "gives your keys their original function back\n" "Shortcut: ctrl + del" msgstr "" "Зупиняє ін’єкцію для вибраного пристрою,\n" "повертає вашим клавішам їх початкову функцію\n" "Комбінація клавіш: ctrl + del" #: data/input-remapper.glade:812 msgid "Target" msgstr "Ціль" #: data/input-remapper.glade:1078 msgid "" "The Speed at which the Input is considered at maximum.\n" "Only relevant when mapping relative inputs (e.g. mouse) to absolute outputs " "(e.g. gamepad)" msgstr "" "Швидкість, з якою ввод вважається максимальним.\n" "Доречно лише під час зіставлення відносних входів (наприклад, миші) з " "абсолютними виходами (наприклад, геймпад)" #: inputremapper/gui/controller.py:234 msgid "" "The input specifies a key or macro input, but no macro or key is programmed." msgstr "" #: inputremapper/gui/controller.py:213 msgid "The input specifies an analog axis, but no output axis is selected." msgstr "" #: data/input-remapper.glade:803 msgid "The type of device this mapping is emulating." msgstr "Тип пристрою, який емулює цей мапінг." #: data/input-remapper.glade:1831 msgid "Trigger threshold" msgstr "Поріг спрацьовування" #: data/input-remapper.glade:743 msgid "Type" msgstr "Тип" #: data/input-remapper.glade:1473 msgid "Usage" msgstr "Використання" #: inputremapper/gui/controller.py:593 msgid "Use \"Stop\" to stop before editing" msgstr "Натисніть \"Зупинити\" перед початком редагування" #: data/input-remapper.glade:1805 msgid "Use as analog" msgstr "Використовувати як аналоговий" #: data/input-remapper.glade:1366 msgid "Version unknown" msgstr "Невідома версія" #: data/input-remapper.glade:1134 msgid "What should be written. For example KEY_A" msgstr "Що треба написати. Наприклад KEY_A" #: inputremapper/gui/controller.py:761 msgid "You are about to change the mapping to analog." msgstr "" #: data/input-remapper.glade:1161 msgid "You can copy this text into the output" msgstr "" #: data/input-remapper.glade:1383 msgid "" "You can find more information and report bugs at\n" "https://github.com/" "sezanzeb/input-remapper" msgstr "" "Ви можете знайти більше інформації та повідомити про помилки на\n" "https://github.com/" "sezanzeb/input-remapper" #: inputremapper/gui/controller.py:623 #, fuzzy msgid "You need to add mappings first" msgstr "Вам потрібно спочатку додати клавіші та зберегти" #: inputremapper/gui/controller.py:358 #, fuzzy msgid "" "Your system might reinterpret combinations with those after they are " "injected, and by doing so break them." msgstr "з ними після мапінгу, таким чином " #: data/input-remapper.glade:1524 msgid "closes the application" msgstr "закриває програму" #: data/input-remapper.glade:1512 msgid "ctrl + del" msgstr "" #: data/input-remapper.glade:1536 msgid "ctrl + q" msgstr "" #: data/input-remapper.glade:1548 msgid "ctrl + r" msgstr "" #: inputremapper/gui/controller.py:356 msgid "ctrl, alt and shift may not combine properly" msgstr "ctrl, alt и shift можуть не поєднуватися належним чином" #: inputremapper/gui/data_manager.py:56 #, fuzzy msgid "new preset" msgstr "Стваорити новий пресет" #: data/input-remapper.glade:531 inputremapper/gui/user_interface.py:385 msgid "no input configured" msgstr "ввод не налаштовано" #: data/input-remapper.glade:1560 msgid "refreshes the device list" msgstr "оновлює список пристроїв" #: data/input-remapper.glade:1572 msgid "stops the injection" msgstr "зупиняє перехоплення" #: data/input-remapper.glade:1403 msgid "" "© 2023 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 "" "© 2023 Sezanzeb proxima@sezanzeb.de\n" "Ця програма постачається без жодних гарантій.\n" "Дивіться GNU General " "Public License, версії 3 або новіше щоб дізнатись більше." #, python-format #~ msgid "\"%s\" already mapped to \"%s\"" #~ msgstr "\"%s\" вже призначено до \"%s\"" #~ msgid "Applied the system default" #~ msgstr "Застосовано за замовченням" #~ msgid "Buttons" #~ msgstr "Кнопки" #~ msgid "Cancel" #~ msgstr "Скасувати" #~ msgid "Change Key" #~ msgstr "Змінити клавішу" #~ msgid "Joystick" #~ msgstr "Джойстик" #~ msgid "Left joystick" #~ msgstr "Лівий джойстик" #~ msgid "Mouse" #~ msgstr "Миша" #~ msgid "Mouse speed" #~ msgstr "Швидкість миши" #~ msgid "Press Key" #~ msgstr "Натисніть клавішу" #~ msgid "Right joystick" #~ msgstr "Правий джойстик" #~ msgid "" #~ "Shortcut: ctrl + del\n" #~ "Gives your keys back their original function" #~ msgstr "" #~ "Комбінація: ctrl + del\n" #~ "Повертає вашим клавішам їх початкову функцію" #~ msgid "The helper did not start" #~ msgstr "Помічник не запущено" #~ msgid "" #~ "To automatically apply the preset after your login or when it connects." #~ msgstr "" #~ "Для автоматичного застосування попереднього налаштування після вашого " #~ "входу або під час підключення." #, python-format #~ msgid "Unknown mapping %s" #~ msgstr "Невідомий мапінг %s" #~ msgid "Wheel" #~ msgstr "Колесо" #~ msgid "Your system might reinterpret combinations " #~ msgstr "Ваша система може повторно інтерпретувати комбінації " #~ msgid "break them." #~ msgstr "порушуючи їх." #~ msgid "new entry" #~ msgstr "новий запис" #~ msgid "Stop Injection" #~ msgstr "Зупинити перехоплення" input-remapper-2.1.1/po/zh.po000077700000000000000000000000001475433465200175702./zh_CN.poustar00rootroot00000000000000input-remapper-2.1.1/po/zh_CN.po000066400000000000000000000325761475433465200164740ustar00rootroot00000000000000# SOME DESCRIPTIVE TITLE. # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER # This file is distributed under the same license as the PACKAGE package. # FIRST AUTHOR , YEAR. # msgid "" msgstr "" "Project-Id-Version: \n" "Report-Msgid-Bugs-To: \n" "POT-Creation-Date: 2023-05-16 08:30-0300\n" "PO-Revision-Date: 2022-04-09 16:04+0800\n" "Last-Translator: \n" "Language-Team: \n" "Language: zh_CN\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=1; plural=0;\n" "X-Generator: Poedit 3.0.1\n" #: inputremapper/gui/controller.py:222 msgid "" "\n" "If you mean to create a key or macro mapping go to the advanced input " "configuration and set a \"Trigger Threshold\" for " msgstr "" #: inputremapper/gui/controller.py:239 msgid "" "\n" "If you mean to create an analog axis mapping go to the advanced input " "configuration and set an input to \"Use as Analog\"." msgstr "" #: inputremapper/gui/controller.py:779 msgid "" "\n" "The input \"{}\" will be used as analog input." msgstr "" #: inputremapper/gui/controller.py:763 msgid "" "\n" "This will remove \"{}\" from the text input!" msgstr "" #: inputremapper/gui/controller.py:784 msgid "" "\n" "You need to record an analog input." msgstr "" #: data/input-remapper.glade:544 msgid " (recording ...)" msgstr "" #: inputremapper/gui/controller.py:174 #, fuzzy, python-format msgid "%d Mapping errors at \"%s\", hover for info" msgstr "%s有语法错误,请悬停以获取信息" #: inputremapper/gui/controller.py:661 msgid ", CTRL + DEL to stop" msgstr ", CTRL + DEL 停止" #: data/input-remapper.glade:1421 msgid "About" msgstr "关于" #: data/input-remapper.glade:422 msgid "" "Activate this to load the preset next time the device connects, or when the " "user logs in" msgstr "" #: data/input-remapper.glade:575 msgid "Add" msgstr "" #: inputremapper/gui/components/editor.py:554 msgid "Add a mapping first" msgstr "" #: data/input-remapper.glade:606 data/input-remapper.glade:1618 msgid "Advanced" msgstr "" #: data/input-remapper.glade:773 data/input-remapper.glade:1118 msgid "Analog Axis" msgstr "" #: inputremapper/gui/controller.py:657 #, python-format msgid "Applied preset %s" msgstr "应用预设%s" #: data/input-remapper.glade:318 msgid "Apply" msgstr "应用" #: inputremapper/gui/controller.py:522 #, fuzzy, python-format msgid "Are you sure you want to delete the preset \"%s\"?" msgstr "确定要删除预设%s吗?" #: inputremapper/gui/controller.py:567 #, fuzzy msgid "Are you sure you want to delete this mapping?" msgstr "确定要删除此映射吗?" #: data/input-remapper.glade:410 msgid "Autoload" msgstr "自动加载" #: data/input-remapper.glade:854 msgid "Available output axes are affected by the Target setting." msgstr "" #: inputremapper/gui/controller.py:621 msgid "Cannot apply empty preset file" msgstr "无法应用空的预设文件" #: inputremapper/gui/components/editor.py:238 msgid "Change Mapping Name" msgstr "" #: data/input-remapper.glade:352 msgid "Copy" msgstr "复制" #: data/input-remapper.glade:189 msgid "Create a new preset" msgstr "创建新预设" #: data/input-remapper.glade:925 msgid "Deadzone" msgstr "" #: data/input-remapper.glade:368 data/input-remapper.glade:620 msgid "Delete" msgstr "删除" #: data/input-remapper.glade:624 msgid "Delete this entry" msgstr "删除此条目" #: data/input-remapper.glade:372 msgid "Delete this preset" msgstr "删除此预设" #: data/input-remapper.glade:162 #, fuzzy msgid "Device Name" msgstr "设备" #: data/input-remapper.glade:148 #, fuzzy msgid "Devices" msgstr "设备" #: data/input-remapper.glade:356 msgid "Duplicate this preset" msgstr "复制此预设" #: data/input-remapper.glade:1208 msgid "Editor" msgstr "" #: inputremapper/configs/mapping.py:77 msgid "Empty Mapping" msgstr "" #: inputremapper/gui/components/editor.py:404 msgid "Enter your output here" msgstr "" #: data/input-remapper.glade:1762 msgid "Event Specific" msgstr "" #: data/input-remapper.glade:1009 msgid "Expo" msgstr "" #: inputremapper/gui/controller.py:648 inputremapper/gui/controller.py:673 #, python-format msgid "Failed to apply preset %s" msgstr "应用预设%s失败" #: data/input-remapper.glade:967 msgid "Gain" msgstr "" #: data/input-remapper.glade:1736 msgid "General" msgstr "" #: data/input-remapper.glade:1312 msgid "Help" msgstr "帮助" #: data/input-remapper.glade:510 msgid "Input" msgstr "" #: data/input-remapper.glade:1296 msgid "Input Remapper" msgstr "Input Remapper" #: data/input-remapper.glade:1087 msgid "Input cutoff" msgstr "" #: data/input-remapper.glade:760 data/input-remapper.glade:1180 msgid "Key or Macro" msgstr "" #: data/input-remapper.glade:1801 msgid "Map this input to an Analog Axis" msgstr "" #: data/input-remapper.glade:185 msgid "New" msgstr "新建" #: inputremapper/gui/components/editor.py:980 msgid "No Axis" msgstr "" #: data/input-remapper.glade:721 msgid "Output" msgstr "" #: data/input-remapper.glade:862 msgid "Output axis" msgstr "" #: inputremapper/gui/controller.py:508 inputremapper/gui/controller.py:582 msgid "Permission denied!" msgstr "权限不足!" #: data/input-remapper.glade:287 #, fuzzy msgid "Preset Name" msgstr "预设" #: data/input-remapper.glade:272 #, fuzzy msgid "Presets" msgstr "预设" #: data/input-remapper.glade:590 msgid "Record" msgstr "" #: data/input-remapper.glade:594 msgid "Record a button of your device that should be remapped" msgstr "记录你设备上应重新映射的按钮" #: inputremapper/gui/components/editor.py:563 msgid "Record input first" msgstr "" #: inputremapper/gui/components/editor.py:65 #, fuzzy msgid "Record the input first" msgstr "先设置按键" #: data/input-remapper.glade:1708 msgid "" "Release all inputs which are part of the combination before the mapping is " "injected" msgstr "" #: data/input-remapper.glade:1711 msgid "Release input" msgstr "" #: data/input-remapper.glade:1683 msgid "Release timeout" msgstr "" #: inputremapper/gui/controller.py:208 msgid "Remove the Analog Output Axis when specifying a macro or key output" msgstr "" #: inputremapper/gui/controller.py:199 msgid "" "Remove the macro or key from the macro input field when specifying an analog " "output" msgstr "" #: data/input-remapper.glade:1776 msgid "Remove this input" msgstr "" #: data/input-remapper.glade:394 msgid "Rename" msgstr "重命名" #: data/input-remapper.glade:452 msgid "Save the entered name" msgstr "以输入的名字保存" #: data/input-remapper.glade:1449 msgid "" "See usage.md online on github for comprehensive information.\n" "\n" "A \"key + key + ... + key\" syntax can be used to trigger key combinations. " "For example \"Control_L + a\".\n" "\n" "Writing \"disable\" as a mapping disables a key.\n" "\n" "Macros allow multiple characters to be written with a single key-press. " "Information about programming them is available online on github. See macros.md and examples.md" msgstr "" "更全面的信息,参见 github 在线文档 usage.md。\n" "\n" "“键 + 键 + …… + 键”语言可用于触发组合键。例如“Control_L + a” 。\n" "\n" "输入“disable”作为映射值来禁用此按键。\n" "\n" "“宏”可以做到按一次键写入多个字符。有关编程的信息可以在 github 上在线获得。 查" "阅 macros.mdexamples.md" #: data/input-remapper.glade:1590 msgid "Shortcuts" msgstr "快捷键" #: data/input-remapper.glade:1492 msgid "" "Shortcuts only work while keys are not being recorded and the gui is in " "focus." msgstr "快捷键仅在按键未被录制且应用界面处于焦点时有效。" #: data/input-remapper.glade:322 msgid "Start injecting. Don't hold down any keys while the injection starts" msgstr "启动注入。注入启动时不要按任何键" #: inputremapper/gui/controller.py:643 msgid "Starting injection..." msgstr "注入启动中……" #: data/input-remapper.glade:201 data/input-remapper.glade:334 msgid "Stop" msgstr "停止注入" #: inputremapper/gui/controller.py:710 #, fuzzy msgid "Stopped the injection" msgstr "停止此注入" #: data/input-remapper.glade:205 data/input-remapper.glade:338 msgid "" "Stops the Injection for the selected device,\n" "gives your keys their original function back\n" "Shortcut: ctrl + del" msgstr "" #: data/input-remapper.glade:812 msgid "Target" msgstr "" #: data/input-remapper.glade:1078 msgid "" "The Speed at which the Input is considered at maximum.\n" "Only relevant when mapping relative inputs (e.g. mouse) to absolute outputs " "(e.g. gamepad)" msgstr "" #: inputremapper/gui/controller.py:234 msgid "" "The input specifies a key or macro input, but no macro or key is programmed." msgstr "" #: inputremapper/gui/controller.py:213 msgid "The input specifies an analog axis, but no output axis is selected." msgstr "" #: data/input-remapper.glade:803 msgid "The type of device this mapping is emulating." msgstr "该映射正在模拟的设备类型。" #: data/input-remapper.glade:1831 msgid "Trigger threshold" msgstr "" #: data/input-remapper.glade:743 msgid "Type" msgstr "" #: data/input-remapper.glade:1473 msgid "Usage" msgstr "用法" #: inputremapper/gui/controller.py:593 msgid "Use \"Stop\" to stop before editing" msgstr "使用“停止注入”停止后再编辑" #: data/input-remapper.glade:1805 msgid "Use as analog" msgstr "" #: data/input-remapper.glade:1366 msgid "Version unknown" msgstr "版本未知" #: data/input-remapper.glade:1134 msgid "What should be written. For example KEY_A" msgstr "" #: inputremapper/gui/controller.py:761 msgid "You are about to change the mapping to analog." msgstr "" #: data/input-remapper.glade:1161 msgid "You can copy this text into the output" msgstr "" #: data/input-remapper.glade:1383 msgid "" "You can find more information and report bugs at\n" "https://github.com/" "sezanzeb/input-remapper" msgstr "" "你可以在此找到更多信息并报告 bug\n" "https://github.com/" "sezanzeb/input-remapper" #: inputremapper/gui/controller.py:623 #, fuzzy msgid "You need to add mappings first" msgstr "你需要先添加键并保存" #: inputremapper/gui/controller.py:358 #, fuzzy msgid "" "Your system might reinterpret combinations with those after they are " "injected, and by doing so break them." msgstr "系统可能会重新解释此组合 " #: data/input-remapper.glade:1524 msgid "closes the application" msgstr "关闭应用" #: data/input-remapper.glade:1512 msgid "ctrl + del" msgstr "ctrl + del" #: data/input-remapper.glade:1536 msgid "ctrl + q" msgstr "ctrl + q" #: data/input-remapper.glade:1548 msgid "ctrl + r" msgstr "ctrl + r" #: inputremapper/gui/controller.py:356 msgid "ctrl, alt and shift may not combine properly" msgstr "ctrl、alt 和 shift 键可能无法正确组合" #: inputremapper/gui/data_manager.py:56 #, fuzzy msgid "new preset" msgstr "创建新预设" #: data/input-remapper.glade:531 inputremapper/gui/user_interface.py:385 msgid "no input configured" msgstr "" #: data/input-remapper.glade:1560 msgid "refreshes the device list" msgstr "刷新设备列表" #: data/input-remapper.glade:1572 msgid "stops the injection" msgstr "停止此注入" #: data/input-remapper.glade:1403 msgid "" "© 2023 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 "" "© 2023 Sezanzeb proxima@sezanzeb.de\n" "此程序毫无担保。\n" "详情参见 GNU General " "Public License, version 3 or later。" #, python-format #~ msgid "\"%s\" already mapped to \"%s\"" #~ msgstr "“%s”已映射到“%s”" #~ msgid "Applied the system default" #~ msgstr "系统默认设置已应用" #~ msgid "Buttons" #~ msgstr "按键" #~ msgid "Cancel" #~ msgstr "取消" #~ msgid "Change Key" #~ msgstr "修改键位" #~ msgid "Joystick" #~ msgstr "摇杆" #~ msgid "Left joystick" #~ msgstr "左摇杆" #~ msgid "Mouse" #~ msgstr "鼠标" #~ msgid "Mouse speed" #~ msgstr "鼠标速度" #~ msgid "Press Key" #~ msgstr "按按键" #~ msgid "Right joystick" #~ msgstr "右摇杆" #~ msgid "" #~ "Shortcut: ctrl + del\n" #~ "Gives your keys back their original function" #~ msgstr "" #~ "快捷键: ctrl + del\n" #~ "一键恢复到按键的原有功能" #~ msgid "The helper did not start" #~ msgstr "辅助程序未启动" #~ msgid "" #~ "To automatically apply the preset after your login or when it connects." #~ msgstr "用于在登录后或连接时自动应用该预设。" #, python-format #~ msgid "Unknown mapping %s" #~ msgstr "未知映射 %s" #~ msgid "Wheel" #~ msgstr "滚轮" #~ msgid "Your system might reinterpret combinations " #~ msgstr "在这些组合键位被注入后 " #~ msgid "break them." #~ msgstr "从而破坏这些组合键。" #~ msgid "new entry" #~ msgstr "新条目" input-remapper-2.1.1/readme/000077500000000000000000000000001475433465200157355ustar00rootroot00000000000000input-remapper-2.1.1/readme/architecture.png000066400000000000000000007651761475433465200211530ustar00rootroot00000000000000PNG  IHDR\3uoiCCPicc(uKBQjQ`CTCEDc"!Vբׯk{֠AhZj Z "ZBn@ ;s99g$V[BьC7x7YL4uCջ+4pt OjYl ;½Z.>r@k'8[Bl/&r.<*¦< Y Vha% pHYs+ IDATx^$:w5݃k5XK(4N w vy~utu}J<#IP@P@P@P0]W.  ( tJ`Nel ( ( ( ( ( ( 4#`-U@P@P@P@P@P@蘀њ ( ( ( ( ( (@3r[P@P@P@P@P@P@ + ( ( ( ( ( 4#`-U@P@P@P@P@P@蘀њ ( ( ( ( ( (@3r[P@P@P@P@P@P@ + ( ( ( ( ( 4#`-U@P@P@P@P@P@蘀њ ( ( ( ( ( (@3r[P@P@P@P@P@P@ jM4Q:th ( ( ( (0h>74}#F4Ke!cN9i ( (ІןˆΡ裏1) ( ( ( .0dȐZ-k0tN ( (@C7S-o ( ( ( ( ( (e*C<P@P@P@P@P@P@0p6( ( ( ( ( ( !` EP@P@P@P@P@P@h[Uۄf ( ( ( ( ( (P2CP@P@P@P@P@PmWm ( ( ( ( ( (@P4P@P@P@P@P@P@ \Mh ( ( ( ( ( (e*C<P@P@P@P@P@P@0p6( ( ( ( ( ( !` EP@P@P@P@P@P@h[Uۄf ( ( ( ( ( (P2CP@P@P@P@P@PmWm ( ( ( ( ( (@P4P@P@P@P@P@P@ \Mh ( ( ( ( ( (e*C<P@P@P@P@P@P@0p6( ( ( ( ( ( !` EP@P@P@P@P@P@h[Uۄf ( ( ( ( ( (P2CP@P@P@P@P@PmWm ( ( ( ( ( (@P4P@P@P@P@P@P@ \Mh ( ( ( ( ( (e*C<P@P@P@P@P@P@0p6( ( ( ( ( ( !` EP@P@P@P@P@P@h[Uۄf ( ( ( ( ( (P2CP@P@P@P@P@PmWm ( ( ( ( ( (@P4P@P@P@P@P@P@ \Mh ( ( ( ( ( (e*C<P@P@P@P@P@P@0p6( ( ( ( ( ( !` EP@P@P@P@P@P@h[Uۄf ( ( ( ( ( (P2CP@P@P@P@P@PmWm ( ( ( ( ( (@P4P@P@P@P@P@P@ \Mh ( ( ( ( ( (e*C<P@P@P@P@P@P@0p6( ( ( ( ( ( !` EP@P@P@P@P@P@h[Uۄf ( ( ( ( ( (P2CP@P@P@P@P@PmWm ( ( ( ( ( (@P4P@P@P@P@P@P@ \Mh ( ( ( ( ( (e^F&桀 (мy>kv3q#?cw}7SLf}0s{ ( ( (0Xp饗VNv 6ؓveJyPWzZ (~A~4p#<: /<K,=>*Q@P@P@Po>pꩧV; \QNUY{$(A_ט mQ ,?66lOO<EY$|q?*+ByC:hСaN8a6lXs9J+fe#(F{]wzo{/38a& s5W~Kg}򗿄n)K᭷ c5Vr)ZkS7_P`@ noqGydyaVA\>87x7MwyY|e_}駱ސ0s JOOZF0p]Fƙow3pU1g蕫:lZ[)@~W Z8a/3_}UxWcC+88<7z 3|ؑN -P8C 3̠V gyfѨq ҵ^? w\ |>^{G_|El>s/kfdD٤ (ОW?8SCs9LAP`$'IR*~mtO=T4@h2mqd?<8yH#ԉ Гc+L&`զn&t*?N>W\wFV}ix :1PoR` ;a {w#մˆE]jK^g^[neSO3O 5\%eR@hO_~L?+h (@ZsOCFb0lDvJ}hDя~H#kQj}'.`N6ׇjQT)"@a>\}tXhSJ^tE=q|wL }rH#Ι. "]q3\pA,JSӘ뮻e$F!5Kw.pUW;Nԅq:% (@k4t[{1JeIs=wclIdc1* (@75G1LFbn)"?ؓ6)n3׽*:pFIL@H)g>n\1"o+PLSvyu%R FEf\veqzLP^jF??6:iHguV Df .`s̰D'x" (0X3?OqVRZ}[}O`65Xo)̽-P@ ꜭ9+@Ȫ%\2,21>(.n-04izKwyEz# GV3%S2UZJz8ꨣ 7_sgQ^M|A\/3%FQ]&Vt駷[W@ߧilF0i6[1Bvi S{M6YrwoP@ \y(@ ?5DN4D+\)wސ'e|@ \1 FZ-N/񩧾`#͟UW]>iTHYø[8[M)XxRW`}#Zj٥ؐ)F? (@O.^s5+le|~>SP@zѻPXT9LCj'ov`]efaBV{g6L]Y /0-!/ $АsO\0 az+,bg2$f1ڏ=쳁q$z5:묁fQLDb-RTBkl5={mzT@X0fQG?G1! 13E}~g9,|Ɂ6S"ص~2UI܇\@:1]!ͺ i% L}9gq>5 'ך虹*%XH[چ{nל׆rO2$~LY<|c^{R˧xɔRn ^{mx]9Wqx\?OcF)ϟ]v%x(ߋ3џieg '->L|( >S ,@h=W4=ӧ~:+1x3;-ɺI;sܜ?+9Ç5xϬ"m<_3<xΦwKDz69*NQ.7)cs7[z+ םh|ȃIPeD݅gm3ϾԡnzNjO}z?Nof}2S2mloN矞c^>>B ŵ IL{O>d|ߧOlYfm}טwP%&ZPޫS=#K3AgPd"uоNS^:|ZX{gM`'uDz.Kݕw0ޕѮFRmڵur \in (|@*q${y|!1gw(R *ʧ SɫN42V'=3)ȐoCŞG}4w '@BC>+ՉJ ?,űVPbX9sFSy/Z4ḻ#Zx>|4N"TcF'^Ph3";.@L0y=M)^!ý u|692VZ^4C3OsΕ7AFEe`i%ߗC1Vbj𹪞5ue wH|oCߛئ ~3'S]gEb-z-/<+h7,}Vyqq$Ke滠I|6(/8X!=sRkfwޡx+ "m{q3V Fd:[N׉w:e>huFzHmttM L)sA^>ٕwz:lذ&M|,@4@K+zx*2"!g#x@nVN$ BhަjX* [*_e!L&1SLtK㈮VTttM7RFKB H0~2HW*[o]iJÖ eBjܥ ׋=M$zת9眭/xSN9e̟zU'F =0LJ 1 F_?+g:DO6Y |N$#(Kx?F;1Oɦ(;*2#ohY*ּ`bOqpŔX1=-qZ6|,O# CAb4"JΏ>Zk:熑'tR#2G1C \q q=jA0%x6%똂ZkUhzĊ@@/*xqa;^jFc~3kݴ>Wya^\2u Zqz#;wC%:0==gqCyhR}ΏR zq*ނ@|?{ZLsn#?`WuD#9>A@{S3Yf JzHhޔ1!Kyȓh):f㎕3f8(27&e_'L3$sy~ĵSensmig|ёH'o 6P@hXLmNTNS:ATN @Ni꿔?#hY"TbϚ^鵂VGDS"0슞oY4b Apz-7lظFѻV"5 _-VKO1LLPyyU IDAT(^R/8yC-ԚFF";IܣG܏F \gWJ3|7eҵ{z4fsa9VBriڟ,;YV^v#ަee*ċZG\4ꑑ\F Lhg|?cNj8 Rc$̓JkM3n]Q+hŶ4>ѫ3%:i"N $t7$tH[>ҭ?ߓH@#}Fҥ:FA>h$:/To *AZS2(:^CN>GQW.2:׍`:ۦD|o1Ά MKޞJGT4I4"ߩq_:Rw6KLLj1%*(vF*mGF` _K3a9 $&^$5g(%?oGObFHPP+c0.AeT#@S 5fƨF/U$/ }[b4pB(pK^(_yYO<M͇/bUDSTtnH]ictU}fE݆z=S±3FW([zeE6 (O fh]|vÙHʏOYh߲tΏ}'x5 0kJocuE^d )G?:'7:^`>ug0r]i(~;My(7)PK t@GV{RY U .;(iwTzæQJ눤\+&^JE`<ŔwՉGzCH(ݔPх'>%* LIV f^0j/#R@2nD@yɢK^^Nᕯc@?xa,1j/bgzcZkwek 6})Y25J| j>%PZѱ07m_V$y̟eZ_ypVzn*:EV! |)\u@K (@kbN<{ϔ3FAZ+QϽ@A9Sy:N_Į>My}/M]}|}"S%~R^ўwL'R>VQsF=7=k%qom?yΕ`W$y0%#KRJSjVIHn`[K/40;<$F]E5Kc#HoE+rlNy-3|z'myʧs\)^fc!Fdb"9FV\YAW@::{봙7}h:oH*_}N_نES^QC霘wB˵rs'k~,:1 /P9FL^VzpP! /e)x (0M=kF,X<쳕P3u?3(̟LLp \E"S~ϡFӲ]OhtSNA7 $fa2u*':I[yY~wNѪc3EgyJP[[;Aoى5y5؊Ot/ys?שּׂt-:񾖿I2}Y[>zkiJ3pUWӢzuYӾ AJ‘OKZ]V4phXtWȐ"6?=[I\<mmݲO3PZ`͜OyjW=Go εϷ/9ur2ӵ>wlfNu2oaU<ދ2F޻[*zĪA+X[٧ cqt[iPwf ޾GV]F=;ܪ.G3E_mnk&ba|mWE]k d:,BӉ=Ni}; Ew9S4nhσzy̯>N/zh^lG>k ?lذYб{c Fkb5SQ-뙲ng7b+e+:Ukffε[tdww^st:뮻n5[? \d)TT14p5HKQo" e72Yc[n֧-_"j9UVFhO~)w3#!?u.7_3䛶-:M`>wl5J9G>O?}KF:<#/댲%sQG;bp*:|lc=}Sx)w;q fcgF;ܪvwFx7ګro#5`{iTsWA%oL*OuP` ИK?1=f7v keS3w:Z+& 30oArVsKUwvu7NY˼Nyfߡloz0ܓ̞q:K;cpk ;sڹ۽X2@>M`+gj-آ7HìQfy!]tE9$]cI5\} k4u65s^f/vΩmx_]N;}H_]pXL^([p,VC(9‡iGъ(3p}~an_cS+N=Zuj;:瞕i7d#:. `3麰\ Tݾ!E߅|7O`K/taz3||ɎQ,%R>ѧփh}z4 4ࡔbC4uQ+ =opaf{oUQO>w6L[s|Ѿ<|fz9D/|t?Í7=*fOj$㢍}z`44rz w=yݖ~RןvLzׯBA97wyyn΋*SM>legq:*ZZm6 Z?F`TR56 (# kOj&V}0EV([t)M h{'vbFֶbݡگVOcEV܏e]nzdLjĢ_dj.NmOkMG֞]/@#ST\rZY'Wq]wuzV R4F1 y¢A( Kܣ>ZAR;w5׬lJ>E=R@r;9^چFoPSO5?By+HКZ\*DHoSKzOj\|Coy壽9fZt͜k2[MNjŢ#"6[ve+>,Y-}a ԃuRN_CFGW@L !h =S"^s7gyfyCR^@nmetFj>*a Vc' 뭷^w_˾,& 0:!_#'otWUG´\;HA e]w ׊"[^43e 8)))[9 N8ᄆ i&HvA>2F.><.r[4*w+1?k:ĚW칾%l#iwD`OS'+hxoJz(uU<چQky?yqΞmV_x{l0%-_^ٞM ( L@% +uNAލ:0es#!~Wxo *;fXh㐣dשqY[o!ޖfܩڰX1x0۠iM"_&Qd?5 4{\|S o%<ꫯn%cN:_27~ᡙK-phJ"#@f}õo馰;#|W# *x1Zh!L2:^0=FCA&Γ zPt3kQq,'N9EO)'fq0$FVӴ_u" 7sr8GX+ e'!3O4P'9(>z/3<w#O>9/htKדsFKilTp=Gٹʨ1U/I1<4c_4O<Ė^d5g/ |MÜs_y䁉#˺O,w ع+NJPGpaСCc &"j;|^S+rzs1lRƾХ{ȱrFㅒN;(ս5JCBD@Yc= idَVoϭ"6i9O)=b|Й~8?̐Q<#-+ ݷzVg/ɏ;9f'ёNOM36 byВ/yFYכSem7_Bt8.&ixoJSLN 2KɨNtBdͪ~DW19Կܤ#[>orn̹ܺ]xv fRG5wi<3Ukx8k[ѦBFp"-Ψ+Jdhl! |>FZ=9n7W r.Tpm*}yc[*4Sa$(AKO?]a*#AF*rx`f 5wgKG75 GZ@cm=$*4H$('JpFROok^"Y" εaǟ5rI&_Vp F$Ro.v>BAx{5Mu Zk^Ӎ'xiBQz3 묳N|!N;[og^5sm㻲Y#JN B9[ozzeh}T'FBLkf-&jLaI0-MeKoZ΃]ԩVw^ܫGY1kfο kqi {FZR2uGYYU@-:HIYA]3N}it ͵8=ΆsfWzte8踓x]N%׎+tirub?:PҁsMH~L )!Ot<ɻyI\4PAGf5>OJo*_ՃiKI[t$W=?Wfi}k+`{%S`y睕ZjŠWw \]{vKP4EOF|4HOp!4bb7$Cc7 t%^" $D8|P` zË,F4xa#N:,e'YDӓ^bAQ׿TtyLwDOBGHC0~جꪫV2^Ek}\wuWC++ sE奔?RP4|2tN425RG+R|~g|4㻌|zm3)/k<#^g#iwP=޷X6uXjh^a_15G:ۤf~w:^{aYIxo6XtM3vItd:Ф:OQnA> ;AQy|>ԝ蜕ޟ7sSoJ-`x{bw11h{^_F}6==Frb42wm+rP@P` 0 @ ?&ҘR._~啅ixhf²`> (@hlN54s6?tx3T: sʖu':!g~I3y`DfQ`5Ĩm:2:ʈ%:I?h0jP@P@PShWP@F@οyN7V@G4B0`?褴l|< (Лh( ( ( ( ( tmVi{ Z:|WFbP@n0pWDz) ( ( ( (`=єn=^>jR@YU7_˦ ( ( ( (^`' ^kŒ1a [qeR@]U_!˧ ( ( ( ( O`M6 kf[>sqTէ~Fw0i&0 a K-T xP@"0JaZ:ƽZsP@P@P@\L_7}cmcL9eK ( (Ю?·_4hM ( ( ( ( ( ( t@UPRP@P@P@P@P@PyW͛ ( ( ( ( ( (@ \u,P@P@P@P@P@P@0pռ{( ( ( ( ( ( t@UPRP@P@P@P@P@PyW͛ ( ( ( ( ( (@ \u,P@P@P@P@P@P@0pռ{( ( ( ( ( ( t@UPRP@P@P@P@P@PyW͛ ( ( ( ( ( (@ \u,P@P@P@P@P@P@0pռ{( ( ( ( ( ( t@UPRP@P@P@P@P@PyW͛ ( ( ( ( ( (@ \u,P@P@P@P@P@P@0pռ{( ( ( (}"0bĈ>9Q@P@EU\ ˡ (@6[xZvEY<ܹ\M74-7SsS@P@~'0dȐ~Wf  ( #`=U@P_|EXveÿQ^{d_@#< >Sa 3~w};xY{ܚ{) ( ( LiyV ( ;a''kgyc'֟z~aI& 7_\r%믿>*=q^~ zwS[/<"_wuaW/+7O,Lknyq;#:qߕW^9yOSXuUK/OT5뮻n{v}h 0o[eF#mFfW ^xaHkExOk¯~;9s=|O=x8∸ z+LJ׋r\ҷ~K,X`lGw19h'%'?Iw&[aE]#g=?ׂm6ov,;W_SN`D/ZjpYg{t 6#{gƠ??%d_d\i¢.6 s]9q6d裏5\33G}h~ƾ 7P9aܟ/,cQ@P@P@P 8⪿^9˭ (s11QCͦaÆ؅O?l>\ T+1hUc3$OLW=]`~4ʈYJ-ΉIQH[&FHP0^Y#?c|C)"/tE ?2B;"Hˆi so):\Fct#}zKEY WG"_JaS*Q}!˯^zi,'|?kL_C4:ن#Rj 2$\F<պ5v{P@P@P@vE=P@~#H$9L}tL(#0]T>b믿^9tɃSh[c>գ+F@t~btSLG t7=G,B=gT{rI45+tu{g4{]w ovabo孷^VF & ,2q_^xa8c+Ӹ} \}畵uqkpa:+L? P4@#(E({J|fmbCctD Vau mQ3u6bѲ#(s=/H@$W]uU pJz x4s>lm67|+=t߳mmYߊ "0~w?7{n ( ( (@iaFrRN:il0) (@k_}` 0Bx㍰V[ϚkZ@6[ WwM, ( ( ( ( ( (0( \ I+ ( ( ( ( ('`%R@P@P@P@P@P@Ay=iP@P@P@P@P@P \u5D ( ( ( ( ( (0p5(/' ( ( ( ( ( t&HP@P@P@P@P@eP@P@P@P@P@P@0p}) ( ( ( ( (Rՠ잴 ( ( ( ( ( (}X"P@P@P@P@P@P`P ݓV@P@P@P@P@P@OU]K ( ( ( ( ( JW{ ( ( ( ( ( (@ kbP@P@P@P@P@P@A)`jP^vOZP@P@P@P@P@>WwM, ( ( ( ( ( (0( \ I+ ( ( ( ( ('`%R@P@P@P@P@P@Ay=iP@P@P@P@P@P \u5D ( ( ( ( ( (0p5(/' ( ( ( ( ( t&HP@P@P@P@P@eP@P@P@P@P@P@0p}) ( ( ( ( (Rՠ잴 ( ( ( ( ( (}X"P@P@P@P@P@P`P ݓV@P@P@P@P@P@OU]K ( ( ( ( ( JW{ ( ( ( ( ( (@ kbP@P@P@P@P@P@A)`jP^vOZP@P@P@P@P@>WwM, ( ( ( ( ( (0( \ I+ ( ( ( ( ('`%R@P@P@P@P@P@Ay=iP@P@P@P@P@P \u5D ( ( ( ( ( (0p5(/' ( ( ( ( ( t&HP@P@P@P@P@eP@P@P@P@P@P@0p}) ( ( ( ( (Rՠ잴 ( ( ( ( ( (}X"P@P@P@P 1B P@P`P TۓU@P@P@P@$0dȐT\˪ ( -`mB3P@P@P@P@P@P@(CU桀 ( ( ( ( ( (ж @P@P@P@P@P@P We( ( ( ( ( ( (@&4P@P@P@P@P@P@2 \h ( ( ( ( (S'n IDAT (m j P@P@P@P@P@P@0pUy( ( ( ( ( ( -`mB3P@P@P@P@P@P@(CU桀 ( ( ( ( ( (ж @P@P@P@P@P@P We( ( ( ( ( ( (@&4P@P@P@P@P@P@2 \h ( ( ( ( ( (m j P@P@P@P@P@P@0pUy( ( ( ( ( ( -0z9 (0>O 3P@~i>|-DGl]P@j <( (@ L:aذa}xre\OsS@~*/ų ( (k 'JZP@Fj~sU9梀D{ c59OCP@xonƾ?xG{áF (Л5<)DR@P@ uҟQ:k\uP@P@P@P@P@P@ *f ( ( ( ( ( (0pY_sW@P@P@P@P@P@((` ) ( ( ( ( ( tVUg}]P@P@P@P@P@PPn ( (}+'O>9laW[ouӟk!>zw=1"n{WUVY%{}{MP@P@PiWM ( ( nưbw9L9ᬳ 7tSOl⊰"E]/1P@P@P@}UP@P@kGH-Ra]wXafm~߅W^qۭ.Co&N)* ( ( +r=MP@P@JxWc.s9g%1#4L7_}Uݣ 2$niΰ馛'R ( ( (@ \uZP` / /x+ܹyB# F ,ɣ (P_|>hZi{U!Ƭ?쳒Jn6 ( ( (hmӥ3P >7`|_{.}}}Asq{@Yկ~ cW\qpaUʕB@C j T| .`;*ec=G}Tg nmoW y'L=ԱGg駟]v%rW\qE8{c} Lquz饗nR9?tI2,# ΝkF9gsI&)zNPwmϳNkV9c'x~xa 7l ( ( (ЗΓїK<86`w|W9^87(GDE1FQo+vy8j _< cDշfظG)f!viqDArHXapꩧVF`|ثGqJ7^)?c5I8&uer-ëmn9PX[_87iՉv+HBofj@PV4P@P@P@P@P\Wz b'hEr)cO>!BPD7x^clfa'!z#HL''΃DuigKy3 襓N:)N;)=˺QG"G^iZ'ǎk/r}5"Le? "=㱬?pwUz83TToP@P@P\v:-m8׌|ðLJmJXwEZ*9]c0㵴;) (@74P 0(׆o⟿f)/^ٗQC)1^ .UOcKzm<E>.,E`QJ:1Z)*o*O}mvK:/k2Qg$QkLHZ~ZUEy@gm" ( ( $6]oùg_j.W-  (*\P@B6z'>M7]ehzGʓi|q4dMz1%^4lذa֚"b Vk>V_}wQ3+cTSMU)oHGy$NLj(M4Dagy2J*3,jztX#RZzXtaQk_|q) [t?P@P@h,pp IXaxpʹTf*1a?H]? Z_Xs M {b _UAҠAU=M+*U*D/ʞ={_9qn0PTp;&yF ACJE8ϟϟjڴNQ7ÑSqh <"̙3M|/^7oÇ:t`w,H$@$@$@$@$r68)֖MNJ2$I~:>~E5qx#r WTɕP"}.Kۦƿ$S$RXYj@ W iұ< zՐ~#VH4%z4߬U;e琢}S3HHH>@Oo- Kf͚Z gI=Z32x`0u-'/ҥvh\*rYVF:~"GWgM!ֻwoɝ;qj7D#8OpWD;Z:u )St@^ <ߠ_8W *OM6Iǎ͛ZBČjpBc |~e~NL+gם{x<7/ɃϤS2i_ɡc+}RdNiݸ:gYntirT_ʩrm9,NH D<|Ppr|'n.R"DN!US ( )TV۹vC*#Jj_lHH*c2vc7>}߽ rTHHHHHHHH#ǯȖ]ᘊbq,$@$@$@A$@*XHHHHHHHH lX!پ\Kq4$@$@$@A"@*HXHHHHHHHH 9nXC$@$@$LYHHHHHHHHHHHHH D P QlH6ƍˮ]Zjrĉ=HHHHHHHHH&@^^NHHHHHHHHHHH Wg8R            P#|||d̙&ʕ͛P:u$iX'OEʛ7o{_B ɝ;wdһwo2dmVի'}W^껻K˖-B RfMٽ{ʍ=Zʗ/㵃CHN<)uu4h`JؤI?|-R,[K,/$    #ܯޑ杧: 7ʎg-gY9w ׳иy o Xdx>~i.HSI {g^R/lW#Wlp] @ @*\,I$,ZH:$s̑7JӦM}C)X>|4;wJ)LA8J($NXٺu-[VL" .///YbhvZ5j 4HnܸϚ5K^*+WӦM+h?eƌaiӦtM={&5jԐ5k֘B۾}TT)HKc!V=A~M/A$@$@$@$@$@hZΟ1bo}F_zi qbGv[G^b()S&H}0 O`3u)e"x{OޯďS*rſk7=dԤG#G=K.r]>~<J"9:kKdH>$m5r}'J?PzZ"iS%G.ɠ^5${&譺Δ[*xz#jKY%{qbDΈ~ⅲ[RB>P:7ջrY69p^f/ީ%!MEv;>WT-O{tWɓƕݫ72?|x+;WҵmyӒ..[ON%>7_jtiʱT6oɐ6&s{Rc*VF[6ߑ׉Zׇ5Skmi9r FR/}T ZYS"^rth./oIȎrGȞJ.|F׏c+aZسK"jC9pVʗʥǼ+.7D+YME1mfxkfX@4D^xZ`i-D+D LsYv#uY$u6\Fغ79uM7ͪ}K9,־4լ\T\Nӥ[)U4M AUX'H RvPeΜY ༫Ǐk)fϞJ2eʤ+^QDk _U/>h@ CjXbIɒ%e-[6&Rz@~P)QpȑC5Ne8K+cF8cZ.]l Bal,HHHHH*N^nDv#`4k8p3y#*GОhTثWY9]#VhӼ}iMX5xJ0S&!-q: @#ݚ6Бc  sM'NNksxl*!d DLbe @Hٯ>PDwjsJ9aT%d@:{pDazݗ>d̚I! sl?Fʚ݂5fB:m$ĤJۥjc% /G+7 C>NoB A *OAŲ$@$h޼ܾ}[p~Eotp$@$@$@$@_m3>%]ڧyjs[|a[)WJ "y#jXEq |(\} @H5+qCA$@$@$@$@$DbNgJ ^r3$@ mRJFLXTBX:U&2 "(\}HHHHHHHH 4 ĊUMn]|WEeaQkv sH-3Y?;؍" A$@$@$@$@$@$@$@$@$@$@$@$@_/HHHHHHHHHHHH+            0AUXHHHHHHHH[$ 2!:MƉ!ڦɤ#'-;OJ?_rG޾ &0r<O.^_!6x6D$@6D"         /C t=K/9{ #ZXѬ΅+/1'@$ Ws$@$@$@$@$@$@$@$ {! $FtgW)^(\u_{,U+ s ncd͘v$s9x]vL3yB֧cd[9r|'L".[ON%>NB;1p<{RU Hryܳjw=K?J:ً9&KGbtﵛ2jz~##%QBfԮZP-%*:nLu^ھDQ^zN~!kY,\^ސ]Lu1&ǃ'/W:ysr+Gj]~-%5[JҺ{M=(N:e=W;4%;~756nbLިyYޜiyQk]2bZyoĈGJ5S2}lf.خXɥ]2WKjTׇ[#R{ȒwxBex@$@!@U@d$@$@$@$@$@$@$@$@um25<׾sZȘ=!굞,eЂ DZ(a(NͷnQ-dІJp- emdF-9[72%3JYuUe*qoID Kh1f Njr^WjŜ+JKe.aw@ŖًwʟKzt=W D)cq ^kn@x-\!0_՗dIwwG*i4ӎY^)ԕ \7aF~DsZeռzV; j)AhIڢдn1P%aW,{љ d-kt3ٱ\rWV:vor=i+,[?HxڼRh)Q$:I}6-ɳ6 dǘD*==9]2Q[0vRAZ4)C:B@׵TB’70$P,L$@A$@*XHHHHHHHHhĵRT.%$ՙ?|v>]~Ohz#bD7$bk 0~,/;|wN(\.J86D,\˩D&\<)+gT۶6u"z]8cu+7 @lrY(3?N %xIUn&QljlUʩ5)U4~ ϤNy%| ~1#jV.`s9Тj{WzTc`=eސ⊀ 7v N9X^2sֱ|2_%? XOgkk1JSkWdK^9BB=}vAf;q/Hes^   @*         'pUAtoWQ3NEiqi~w꩔x( Gs Q@ZB>~,Q pBmkq.ԏdNGvS$]J)S$p.Q"[QҤJ&;j:Ǧ"}Čj3uahQlՒqhq0ARXd3H|0aRO|NqÇԑƚ^4mV(Ѵt*% [Oˈ7r*a++eـx9x^   <c$@$@$@$@$@$@$@$@$RpR\B^/_kWRWHPg^țAVo8:op"zpW|%.ʙ bԞ4gycfY[YԹVOt<״&C@ʻ*u8*: &N]T:BqVy%vZu@#yQ Up!ʖt3*:eNίڴN'vtyR)2:̫TFk=t>%ksv|ap`!)/RH"(A:+U]{ AJS٭myw袾LJqSg] 0'@ @#0n]sTeHZZ<Ѱ.NA7c\KI/ܽt7_>j.(ϔ>$KGb)!^.3t*NjDZtyD4pYXFAJhiiV"-kjQ*:u~^ʪ ,ʖpJlW-Iih8 B[|Rǭq*@ {%yһ~bX:@eO]m8)7[ ɤFu!-s*3M1g`>K3F26-yRg!ҧM\+ǴؙC:C`CǮR;WD Hl׬2S"3K"E eY wJ)]V}֬R@sϜ1 [;y=$W;gE  ݿLqUc{فEHsΉ۷OCoq$@$@$@vvԨQC>iG ! xz8m=X@-99ه)%BR<==@~qOBj* ~^h|kkM=Λ߸yʗʩ_OU&oxKoW7J9hMP$zՐ [Kt 1a~-#F+ILGcbY2n=O:`}c!Bqt7Wu~OruP<?w˘A%~ܘrC:n~kۥdɘLqttԃа>tߔQK'GI2BB :zVbQRzaS!|ܻDZ'Wߓ7o%Wҵmy?aj>S,}C%aXҥY8Duv}Ky:fڅ'ؤq"B{ϕBJ,U72vʟ~Z:A9,__"5k3FT\Zu)3ǷRΑ%d*V=fr<XGsa}kku[* e5,U+ sm5\K#;5L+'ސ{(: v}V,ߣ_'OWt&"9Hhzx"%dScUMcqpWR\7rnsrj5߻]oa;ۺQ{h5*E|?91[jpֶdΐ4\xwRjAcn{2Bͩ{JjW>\k\m:*Ox{}mmJzcsꕷxz#j-YZW[Z?{A* Ws$@@˖-,}  =J$@$@$@_9EosHNLw;/[կ+.үvNJ,K(UQ_peDKdO5w m{ΑsHWHGhH_z~7-]K]mV2et3CE _EXBd Kp!Gmf-!k믞[|9V9rIy%qZefp֦1oVq,Mc.eDl)K8\k3bv!Z~h"sGk^^o$S,}q\c}=iƎ!eQȜnb=m8t4oJ{h3(QLs L@=zmө|%ԾZe֎}eOA$ P + yI$ܹsqr$@$@$@$@$@$;yUCtoWQϓpry&pӶ:mטpк,ShQ}$V[a< 7R)hP-S/ LyJqOSQHaH{vOeLDNro3dNMW?pPe\=\.Kp1E3;'#-tzGf/کw{bؓBVpva-*~7{G:A? 24e vRby@| mi 1uQ.3aЃndj Y?}Y{X! 5kk{4@p-^$P|R^ kʞvF,Oᙀߤy&; WEW!~,E+L:r 4])ӢAJ"^Ug;{\THlj 8O6ۮY+vw0v=h5ުH9~[JW oY>; 3 *XάppA\8z⪿2`t>p f,.f:VV.YU9ʫsN(giҌqWrvʞ yXaO^_B83 gm#k 9W8X"(lWYsfbkӻܭՎ]uh1Pv*'_ {:g=P&܋ԙp]4gX;v?e. gt\IHHHHHHH&dJ}Hx(\X,]fp>=󕆎]ĜwZ gW!έD*[buOĈ^\,ChȔ>N?mۨ"2uT`1D L8*+TtiMAjv'2c\K3zZ"p yHF%i۴JNj&ZU ȾeޯmtmOkqpeQwQQTJAiU4S_feqQKuT@a[9TÅZDַsj뺧 ujUVUpXj?AXsjҹ|]ݻҧs1Yg YPҚGt(j/4KOhVMǯ* p<ҦJuWJ pamX7/#ǬʱGaMueH;rzhQ]9g-ܩ9;%4vm[Awo*upvv,O_ {L6nܸ{}- osM'NNSGQ˖-u@zC$@#'O9zh*7KڵkRF 9|Čo'N$>|)Dweoq?SJ U7*yzzJdJIRͲu՛YZ|P:3QfA/<=U[8b|W ;nϳSJIjmLŗ         k8R* 'gQ}Ꮈpha0 |^ΞHHHHHHHHHʹ]vaw `@coWs$@$@$@$@$@$@$@$@$@$@$@$P {k 7I74 =ޚpD$@$@$@$@$@$@$@$@pzGwԍ[aB݀+Qw^&k;9qly2DNOGCdwyZ]WOC6ra1< Jq$@$@$F =ZV\FGa G B_|c488>#/>B7nƜ;D_cs= ƶrqe  NUx_AHH"?V;w,2rHI:c[N<)ǗDr崐$I N:%&M~$Z`HU?.?~7>|X+|} 5 Ϙ1C F͜&E/^wwwԟ!PɓG޽l[?~dժUZHΔ)(tQ(s]R _qv,sسtf'III4{ĨI$[J ׼I5cRAY#Z?k!LbqsʘfrG9A4'N$ b_ORXblk,?H|H) Wt8l  T<%Kh xy 8Pƌ#}#h=+1ziڴٳ$\;vL~g][nڍ%Jp :T?O*Ď4ǏrvA8W׻}$H@"E 8q;wNth"Yb,_\ׇSlĈ/ ux'O$gΜgxxxHh$]t2eJpQVZUbĈ3>s,{v S$ k+7Ѭ LWGп?WӬE;q"gYeD쩴H(v 9uM7/_Chetuy${弼HbfpጜVʗʥĜ+,J4 ZFoIȎrcUb厂hH>֢hXrB9_r^ _FU(N~CDʨo(}&"ur_2#-aq|rIIJW^״-;qXu%jmiLw|!ϝNVbhWZ`qei] sA jx\ hq&ȌljO׈Sgojq{ Qi+1.u3ou /(\8IH |lJ/iqhpR!  I%K9sh! !eo߾~Iv*EΎƍkb-[6-AjժfϞ=g96o<ԩlݺUƏ?\c:mf  `f#~C'"5/oí[d޽Z+A$@$@Wѽ]E>Oz0~FI*tn]^ׁ' IޫYQ(/W:ZD!Z,A6m;.OQ?ו>fLW 4f`}-h>㸕eN2o!r4nc[dh8poJiij+jlT@ gݼ$bj*SVecklqI ?[&m|}s q~a| |>|!p+{b GȐ!~}}&`IbpT?ڵa<6ځPӧ|ȑC_K,NU z%J<8-pv׆ tBDD mn H *?;w2eHxZIHH D&OPD+tsR\3}l 5[*ٱ\e9ܕhX_s AY ʗΥOԲI{h R$E+C:ĠRAp3G*+eV2Tg/kO@}+A "sdæGn*"b[]ge [뢯slri 0[g{P3.1OsH_[/{,C$@Wi8V  ! 6L0l``SiРA:堵^*رn\_o=~23!ba>HQ޽{RZ5Ss3O5˦OX5ju4h(` 'NEKg"!!ά JQܯ$@$@$ 4]T'p^ʚ)y@@c1-)+&K~^M:߆#O*ZeQ|QۯKTrNU$]YMJ}H.3$f g)Q$[,W=Zp|! U'u >_m !lEI$:aΟ8 U~-Ro_Ҹ+dWVe.C_ ^a}k4Ǭm&K̩ i lk&{Vfi {w7]r-=M $@$ DPߜJtHb+~YHJx`GHE˖-%w:]!˟E% @!-H5k,H2nݺү_?ɒ%L4Inܸ!cǎ5ΐySHhanɓ'k gdQ g\]~] bK.5٫W/-TU^]Wv[!EY6mtB8ŋbŊ2sLq֣GRN {gSk(yUӧO!N+F͈ԞjRbj*v{СC,I~ X~a-3HH YfIt[A )%B/@~q~6Xb%   @ bq-|RJo~u9W @@S( W2   ; 3YfO;pFDV6L3 d€ ia2m=j(ʪT*T`j[8 X!S06f-QT)2so'L# ׁHHHHHHBϸ MlHHAWƍV͛m6e ソZKСCiƐV pnݪWSL) 'Nhu,cR%2H'gHpKAn\fNG%HHHHHHBФ˶IHH"py%L{ܽ{߹UN>S+W'OʱctJO{3N8tFҥKڵk wHHHHHHB"vIHH#)S&jР/sҥKoK_D|e[liXj"7Y^'      O&@   zNJ$@$@$@$@$@$@$@$@$u            (\}HHHHHHHH D _#;O6sc m r=Tdx>~UxD]Tdue`Ӹy< ]>8o)ypm8}]??}%UbM2A}^/9osF_cO5eBs3 zO-W{ S }HHHHHHHHBL5 o}F_b]@Ď`{sVJWƭMێ:/XG<+ jyˆ?ev2ƖS:pϸ ! X'pV^#H$kd{6n9 Q]%t֕kwe+ ~k'c&!R'ǯk%JS[nXf-)Q:+gZU οʂ{tIʀ$R$Y,]O\\IA uGɖ}%B2` fz5eksçV>ԩVHooeי#kJyG]f12O-}r tT;N 1;KhrCDJf/ѷoi&G&Zc60o.V1T,=gs=Sn}/颮϶ IDATRJս&?[5l}e~Og.EB'GI2gp8#        'z^"!3${(m{ΑsOC Uέɚ 6MJe4W\Z7.r<Y)zڣ}E} iF + 臘x>Rn.~]jxnGfMh)Qd׾s3{Rk^R _I"XK&dd҈&a9DZO|ez8tf'I 'p5ӥN$ݓwJxAj[gD] `CXC}<mRŲ>YZT9VWw0~,@ۈJD<~@߮l-"qM#F e! ,C?L+foL铘<]dMkF6gW)TWiad͘TNj(|Ai۴'a:H%haGͦpXƵhZ-Z"ٯ]}q̙F_ЂQ+p>Mb9sᖌA]ĈX 6aVX~;0pA=Lf˔\Nd˰R$+oSi'i Lߧ&U#\y3irH{ %d>g@]pV]b˶ݧP8y}S=^;{?oܴ5W /'+v?s,;KW*އCǮ#kڴ9`sg9DJXL\|\9K+         Ğ>D+ oyz~ʍ(}&gj)@*`2 >KDv; %fpf({ { 0@B@1r<&A.8-Zuij•$_q8Zny${oyyX17 QP-YvdT3&GO^ny>>yƏCVF}5ѯ桜Z`}u=-۶GpwŌ-LM9:vY7%2 erJR W?0 k֢H9"98(G33D+z>kRǭvCc6mVXb%V1is>>%1i7rMQbإ+w0uK^ 0 ]ޜL5gg?hxq qJS w"aky]}{7q_{3S|n4ƚ w%%tТ|2_ lq\>l}-/W:=h޷gh;ٳPk^SWs&       p@KAtoW_/) p͞J]Zw%cE!jH۵xZ{ٯMB9}I\ߴ~Ha987ly[(WO=73Fb.V#՞qmPLE#X 8Yf.ܮD:OH{֨NQSKa7&'%Ǒ9۲U{<_Pr/=【ּq2纒1]?C?mV:hnL{SxЏupVi&|L9\kc b>l̪Uʹ*>Vy )h2bm/YsYWBWCeJyQirػG}fٖU%ҍ.QmI0K{lOV{XX倀>Pq@y[] ,l}=Ri#Nc         U8CcyV)?g//CxRJ<vH! optSS)R$E+A'kGL,%\!pXDRCa [RzFsg+-|)YKpi!cbX3gHҶݗgJEgo贁Y>l-(X?;mopəF6l9nzL#=a-(NlC%X [ҹZ\UgVgV!>xpr]Op@:0/ИX۰57[b{, +J1c8֝U?g[4J$}q0g5嶂.E [s,gkٳ?pΨx 1̱U{q1ӧ6sߌ36m? [U*Ya|n#>l|ҽE1[p5գkZM΅HHHHHHH!R/P74t*`~ R~&_8{J%Fum[A?HpDQ̣J?չie~(l\1pBe~j߼ J귙5U i~69tn!k*ܨvQ=օ+jHfzofVluTc9hN3#Wͼ|}GYm$K@:t+E@"mDXD](X@\DEt)z710r̼)7.kCIk'-Ab5Z[Súe圲iYZj Gcw+e`fQO!QY\Vr ?5Z|B} KPߛ c|ƌw},qqh"tnhĴ{3.ү xuhj[;JĿ#S,]jusK>rxtǏOX'O>^|!@8|tA6olG{}'G0`T^] @<jԨ!vǕ\?NΝ;ˎ;@ \b RܯKpqw2d-"2OԽuSppţ͹8O5e,ACUvv=ڡgW?i1xT`) @4&gLr_I ,Բb%Hŵ @"d݉[@@@w&$tzNgKՋbHJ-p}- `Wa   Pݚ<4ZKC+=#i܈98[S1gyMlHC+xҨyXVZLj*qssKe}  N PR1sN XΟ!ؗ@zA@'-pQYtܸqC4!    ZW%8 83l39AD@@@҂UZx@dЭwӧ'St    A@#*1Tǎs碄    @ \81  zkS[d:B%    \9c $ѣGEϳ)=zڵkKVkhQu|      nnn&p_/..\'ʺuRti@@@@HQ);# Cxyy+˗7_j5p@XD@@@pl+~~@[ @@@@`f,@bz|޽{et w^[nɗ_~9{,X aaa^ ͛/@@@@WO@{%K3W.? u&矦6gN~gΜ˗/AAAqF W_}UF-M6+WH Oc i͛˒%KC2edkO<);v4aW۶mѣ5kV)Z۷̽RJٻwo9rL4II>قP*V\tI8 m۶ɐ!Cd͚5*eʔ 6ujb>/U6    iT4`Y ܽ{WҥKgpMHtAϟ߄S<0ӧ`ҬY3پ} 48y-7nK(!KG wiB'N{k˗/??? ,]TVX!;v0 >\; y>x ^ct۷̙3@@@@SJj@;0a.]ڄKg޼y/_6!ѣiӦ&`޽ܹsDŽ^m۶[ʹs$88Xv*'O&M3ȄaO=T~+_!  ا"P-~i&rSOY&u[-}R)tǏo~GaXb@>,:t͛7mh  \NΝ;ʔ$n&d  '6s}E$]7n_V.- {$W) WF̍Ug JS`G~rMϘ&"~1:!hq-     jU+)mZTue% %!= \SaN |,@@@ 5o#ٳDrPf] wjN@t@2| 4/]|Y:d   ݲ"@ҳs4j&&|@@ 4;whݺʕKA@ q 8ٿX? Q&Nh{EϽjذ!F    U03 #PzuiժdϞ]+    %V%8 |?~)΢_XbW I`ܹδ\֊   @ J3 = ;vLK+Xh #0Q@@@@ @Z 8@T J%hA@@@ 8*P@@@@@@ W 7@@@@@@ JTD@@@@@HU͸@@@@@@ R.@@@@@@.@pp3@@@@@@H@K@@@@@@ \%܌;@@@@@y]'RT}gb[ˤÔc׿\bCzԹ<sq.]eBg؃Oց R] cȀ     q !_;(+[J+g;e̐^xUR~3+;{ L@@@@O$ }O;2Mv /;(yܲʝ{ar-\ R< 8)-S^h`l4?a|Gyw9v 4W}K 3ۭmW Z|>Yɖ>oݽ/2޶ʞE,sTR xT/陨9D}=Pn[uR1XǬ[}2{$Gv)??|[Y*O1!g.^wY :u4d̏RY p     64;KN,UkEY3 JN_: rݐVdw䓗zݠL#m|}S22lێI:>}pLǧ;\gnWޞ,"Y27Yz8pJ٦0 IDATb;}*g059}рfw-T8}Qp(o֠'?ɠVU ՓqgT@bxh oF{ ,YHڵeҤIvZiҤIZ^"kC@@4.PTۿ u5gm+.[(-()j ʘ>6|Im?m,2_y'e阶 &`vېٳd2rih`^w|3^S. "AKhY˿i[Bn޵]ۘ,!S"lXC!}:Ser&Ɲ S\ L>]䭷ޒ2e͛7ɓi>uΝ;IY6  iE%sHK}Kh=lgǞ"6N/cҲ|1iX/Wr[({?L[ƛ39_K 9Z95utl[B;uAf6tlc/uFM jWP^Př?ZntNuKP)@peυY!8sdŊȑCVjXxyݻRre7nÇeΜ9R`A9}\pA $۷oSNw3Ϙڴi#۷{ʕ+WLUא!C,xyD\.\,m۶.]n߾-ڵK2g,ԭ[׼+lbO-ɘ1L8rYٳ-[VƎ+رC߿/E?CCvmOqT^ݩb@@$CnGs9nHe=k~l.̹Y m(nv%)cnwTaU(Om:,7i9]<% yݳ1R4lee`.X[6[N2e.&OE?)J $X@C U~7.\(ٳgyԩSeʔ)R K)W 42-ZH|yeԨQ&С4o\r%f2I``K_mܸQ>Cɔ)5kj2x`YhnÆ &P OyBGqCLjUcjդFIF8p/8t i  # T)![t향LT>U-_zXܮϬ*ݜG\)_lRD&0~4PvVl=.ᖪ&~9A1˖OnVJ왥Q"- شBlT2`jq",&e &BJ+rdwܖ0OJR27؟=f &yxxĺիWK\ӧOiܸܹ *dB+mK6OӦM0,44vT4Dj׮_Wq-J дK4Zf,[lmUPpYGlҌMj .ZrqSqSOI"Ee'UTieT$Kpӭ=i+`LQ @.0W}ۋCZUtook3=$?Mt{|ԗ=֋z1Qk rO^nZ>^֦{9 g^խ^Ǎ﷨^|ԶO7o4\rk%[;[~ON32 \]]MQlŋ7o^...3gN|yMܳ6 ef@>>@  N$0-kf(aŦ#[:i  t{Zj=jt@=IOEv4 [9S*M%KHk))DC5miU\M/ 4l>-uKBkqȑ#ECM̙#;wT85=mO@@Hnc} 'PH>Y0T><މ?3+q.W:@RA`2w\CwPM62b\iԭۣ=+U$zf9R]v7oP%eaÆ,۷KLT֬Y;OPץʳ^&Md3ԠhѢZ+79n oCi&Le`Æ Yq5jipAcOZ<_N*Ŋ @@@D \%@H 4ҏڋ/(+WT`Y~.m_k5WNGIGo7o Ǝk>?0K臵 4HھAWп-/,7n0Y˜ǶqF1n8fzLU~+w-'N~;Ɇ7H>}EJ4\xdo4sȟ?9N+Bi   qj1@@ VA9 .PLSyճgOTkS>sCPJC>ȄSu1XfErpժU5Jj`M,Jwٳ2k,sn!ۏ?(xiֻwosnͩ}6jHk>]]]MVBsvޑ#GL( kڮSM׮6lٲTO2DڶmkժO?Tg- u=oU?S}E    -W=  @ʼ[^9sf5,XSznuKNrʕIv1N8a4 VjS&Ԁg&ҀH(Uf$RS_ JZjemݺ BJע[j4fs&`Zq'0ꫯESJ4I&} /S믿ɓ'Mo͘ZXJ2$   )48Y  di8hѢHSƶeʀLVEmhSRnŧ|Ȯ]6֦jiXkIwU!oɳ>+z>^g2esם;wLEVkΝ3Yڷ6}MVE\O?mB… ˊ+loiŕzkf9zWR @@@ U`{@@ ԩSb(6zt55U͛lgmi0[EӧO7ӺuLuVW777V\wvi;~6dɒ& 3 t0Yi:ǻwց{5ÇMem:gz걎e+I܌   Js! $VTNW]v~;ԨQC\b*شү_?yw֭[fBZ ȶmLen!dr)#G4ᔶ065JSN2i$߿趀Qof*j矏u<PᲞ!j ._\+Ɉt   JS  $@HϖҐItwwO;vIֶl2?,ElǏn٧T'DZC- tK.ɫj#!MofMmGhЦDƊx]1@@@ M \b@@K@)9SBmժU&dʔ) @@@O3F@H%GH+\,   "W'      fy      U<)   8@tճt@gY,5u\9    } }O!E*),LcI `Wt  S ߿_~ N:ɺuE%k׮,Y[k *$~qcǎɈ#ד'Ocʂ Laaaҷo_ĉ\rr5Xԭ[WV\)gΜ1O\]]mON8a=ƍ3=` ά3&^}:;SD@@xWO@@HI -Z$ 9sP   @\% #   `߈#ob@@n٬ĩU֨R\xUG8)   6mZ.]*=zT6n0>}z+Wʑ#GO:C@Hy i@%' Eo@@Ҁ@tb]Eҥ~i`,@@ %zv'6Zr*o@@T@+&N(e˖WSO=%5j3fԩSeܸqCYpܼyS';wr9S̙3Ge&sɕ+W?7Jzl={V{yT\Y6m*3g///whXֳgO_~)r`SQ  8wl2vx[y卅4@W%8  $B@Cݻː!Cd͚5Uer%tEm&:tVo߾2rH#R`A1by/ "Z^zIPP VZ%_~YrX6  eΜ_׮]3cj+P@"V-  $ak,Ҩ^9U=M U|[Fm:n5'Oh@FC@ 9 -@ #WBBaޠ ֔v-k:S?~  `ZդRQE5kܹsMS,YkZ9Qܹ} 7khWӳf͚e?zV6Z}EC@ v=) O@uJ`ϓՓg\+KzaI3@ȗ/9;*bkԨGĦg[Em]vTbEYk/Ǎ.]Ds̘1׬}YXQM3`dΝ;LLց @ [ U}?7" `Se~W  `y5@H@@@@B.OԵ1m͗Mɾ" gϞ!   @oŕ ه W@RBOSv3JĊXn;c         =PqeO9 8s挌?σ  @z x%K, @@@K*$Z֭[sN۷/;d @ vО@*Uuo%,,,Y3@@@ \%̋@ XׯU,@{lK.:uH…= K/$W'ݞ:uW/!  $L3  8@t4O -}%i"܌   TT\9f  ,pu3gd˖M}2m43fYԩSeܸqիWV߻wO:ɓj*SY+UVyXbc߾}riӦXoԾt .3JJQF@@@HʎSA@@q&ѣGP+Wڵk˺u9ϟ&MHj̽7oeBȕ+W^wԾ>l)SL  UYq>.@@@[K@@2eʘH)yxx/.]֭[ˑ#G? SYU`H}gԾ?3L۷os@@@HT\@@G@4h`RJ_xST3[vV˗/ٳgTR{bڗ׮];/ @@@ JNMB@@ N8!f *?k֬)sWWWɒ%yM?Scθ4h_^t;wJӦM\r>H"[UL}.]vƕ7WM   $@pLO" 8@%dĈШQ#9U7tmz~DlÆ uŊm_3|S_y,,e  @._ !=w᜶m_A*^捻reP;6o6 |n)s)T4:i'g嫹$[vѿ͟wͧ,a IDATVb#˚dMw! \#aB     `n2cAmIkт..m2xT)RL2͵˾%ׯߖ[7IxC|[nh9L{w㗤sRGڟ[,})ъfw+ʎͧӧn*9ʛP҅P˵̼d̘A6rT2eȐeĿ˞ͻwYy`sN~lWKLOk|dLfm;ary \| ]]dظF/ۯ$2绷K#Y¹2MrUvbpw_5coٴOҋ4j[e sX}hܱG5RP\+@pތ    i@@v6 H.+3]k tT,c/l32Og6[–vzV&vVO>SZ]zHʤm$glmxvhh xid>U^}g &jk&.Kvm=#=6!ڍ;ɴ2&t]~mLٲ`ftRFVbɣu6IRVڑ@9sߖ0rӱͣ\lŧΕdhiڲoZTCEm',*Z" -']moغ?5e+uU ||f>*3wMEV\M: JJ[%dΌvO>Z=?k_G J]oFC@@ UM&cƌ4ѣG… R~dʕ+Gʔ)gzmڵ%?;q  M[ED|#gVgq.L[%YcӢ_o\eT.1Zmۣ-?W),e#j&JtiX? 36Ų.v0b,bJMo~̘L~dc/WI1 }:1&# Jռbnu)K@w)R<\4BErٺXu*XyZy6xty'v^܇#@p:Ό  (iX3l0ٻwٳGܹ#wޕӧOj۷q&9uQ-jԨ!VY>~\jժ%B+ 4ٳg{5`ҰK4ڶmtC>} 44{n>֭ HΥ_~aK=z 6l(}+b۱c 4Ȍh"ӏV߬MNC0  `oy=dcQ g;M>]z׈?e=*gl2mN.?9[Ql611~ J/ѯQ-6as^d #ȿoέ߰z6QЧm૑nѶy]iM#WV+~DmzMt Å~k˛ dWqgT@@4,~SJ>YKL4|,XPW.z;͚53aɓ'ĉruy7_|Ote@M>͛W2ed>HCM1j044۳YMAAA矛ˬkM8U`d x: o6US˖-3T *u-Ŋ{~XNWQ4_#  i[*m?_V  t<4Lt<6ҀJåKJ\  tׯ%K#nnn#~{ɭ[dĈI! 6H۶mbL@@@ Rߜ@@D`ݶF<ӿt,ZH2g,ҫW/Ӳj*yL^YMաCI&fV Vp":jY*  Bpw  h_JB"]aր,3,XPcCCZ[9p]|iUt<W4,uZݥM߳!olA4pեհ:i.5?[r ,ҥsN@@\  @ wܑ/BzaЋغwnKhG={V<==cBԫ]ٮO|\˓'+bPJӦΝ[۷o,m͛ׄ^17QG/TP: t+BmfVnie @@@+@@ 4w\ɑ#{&-iؤ%uT%YTZU\9sTV|yYdKϹz\ӀI+On*^y|`ϸV-_\_.f1Wϊ4d3kcjZxbYl 8`*6mx@9}"@@ ܺ\9wG@ lٲEf͚e*' [Y/-㪂TӊM#T("e+V^N"}]ti3fL"~}tOL8͛ۮcoz-uz @ |^u 8ӎ;9UrI  YJz^UlæbҫW/UQQ[jդM6vΤL,7" TOj܏ XP/<v @8*d܀ Q֭"~ 0o<_```>}Z^uy饗D34Z͙3G&L`KJ9DJA@@a&0e vJ;ϒ 8VD|7KPPẞ-3i$~̙3'Pd$5YQ42QT5nX;{ԨQÙX+   ODꉰ3( ЭDψmn޼)'OLRh3Yj/^*qX`ƌj;w䫯2*T?\ʕ+g+`j   8s=oV Ν+Vj+OOO9rHժUmZxy_*W,ƍwwwpr)SmҶm[ҥY={v7ސ;w۷O6m$gϖ%J[nK֭j^Gɍ7\u[Y9|k…f^xS7=6Q|Mɕ+Z   %@pe_σ  ;Lc ^oڵkM!3uT2et˖-/ʨQLVy3Ϙ0VZҲeKsW}=Z}]Sѥ?ϔ)Z޽{˗KNbŋeĉ2w\)RL6Mϟ/Ç7l۶M'W6}HN Z#6VԀsq 4@@@Hq@R\ի8t]5}1ghڼzs ,(gϞ5'1+JfloieVseȐTLW>W֭J[Mŕ5׆*3IRX~V3bӪEjDj34@@@H~J< @ ʕ+WbGk{_˙3\|ټGl@=x a^ViECC4qMJݻw>|([4<#Jo>qAAAҳgOsN\6mlk׮$A!UlϚU:n5KCQ`Gfٵ롷dOMyX3U*b3 IRpʫܹsG*O<$/$$ĄYׯ_7ޓ%KsUı/]d{\pK9rd5nR׹4*9kǎ30Zs+֩4OG=믿___]_|!u5S\M <}c/kѢ9JϽPLg{O _~ҪU+}ϟ… 5%G I !`o-[m^@ԧ c@dQ8& *SeA@df*ovF?X5k֔EkNDY1   `?6ջ;&5Ȳu[4@ 5J e@@@@@\-zH,?{%/! \9Cc   ݻw5jȑ#G ^zf^nݲy1@H@DϳۊZ4@ RKq@@@@@V]iA HMθJMmB@@(.]$ 6&͌pp>^@;@gkH5n9aԤ/@@@ ]v?+/TVM͛g^[z̚5KBCCUVikƍRdI6lT^]y9y,X@Tb0a+"={ԗnW~}ܹ@2g0`ϭsUu!!!2m4ټyd̘Q*W,F//q) H`ޓR7OnH@Ho.0Jq   ϟ7SE_XW_}%}-[V7n,k֬Ç˲egٳgˆ LplڴIjYf1FC۷˵kפD^e۶mҠAquu5ܹsd&Ȣ! [KLm\ =~a暒匫ԥo@@@ ? ֭+},_܄@WӧwyG^xyݻvZ\i[~s&Ҋ8W5hҊĴ?ӄV>>>2zh4h N8!GIL܃  N"9Ƀf   xgϞ5STVi(-88Qt{M ,(˗CH+YDt|%ԩScǎI֭#$*p{q@@4.@p0C@@xپ1NV߳ 2>3e͚U&O^=JT\f@ݦO|\˕+Wo<ݺu˜o[WR%ݻwʔ)c"  `'F   8i;sZIeu2ܹy?lly9r۷%o޼R~}QfM)TNϲk֭f___ə3ggyFz6iUF ۼ*V( @@@rj@@@4ٱc93OZ-5{lYnKBBB$444 '3f̐x IDAT!C* @,X %K4[U^ݼE ЭZk̙f9| /W7JݥrrE9y򤹟  &@   3f ut@>}zeUZ-դI`Elݺu#Fʫ_~TUV5X`} t #SLri0`@ ;ұcGy^ڄV:   8*@@@@ѢE>4<[۶mE?"۾m5̊hE}q5=+-[hghuSL]^WWW׿e>h   @|!     W)K zϟ     @W| )(#ǎ;w(׵gt4G@nh˞=STVͩ@@@pd~O# 5h 6mH 4x$pu8~~~     pqp #-0aaٵkscz",]TBCCĉA@@@R!FS`ʕ0k} h%%Kloh~z@@@>jzykc”]$`߁X5ƾ\֬ o.r[I+Q&p tGD@mfΜ !((H͛' pnV@+"6 v' to@@@sRFʝA|T\1IԕW vfyf5ɗ7CRJ"^DP3fDU&[///pJ#G[ ,8` `';v7|STDf˽{sW?"I߷/3Q5,O.^$wݗZիH uZ9Uj% qSH>Ҳyp|4{'];u~Sg|"e|Jʕrmɔ1C2d k~]/ٲfMU_-^޽'aaaҮu )WMyIg ʾ%ػx;=ٷ([,-ꇒ-JI"PT)"%E*hSEJ]ʾLs6s19>ϙ9|_~ewwvۛooG'Oձu}6$!3@!; Unsc>=u_u3gN+Qg 6mZ|b;#{w}vw(MH:udV6 67Iwi]w @RNY#q)omիT6nTV?X ,6<*^=po{o/n2۰qo_).nt/}YN b˖-{fGnwN˾7zu4M; :[{_o{t@C[L+]ؼC֬Yt7iveX߼n'=#@j q@ *V\9k߾+o0_.:PBfY@׽}˕+u *duk4H_ 8[.DOLߌ֗?.R˞={Q>s*[l|7]9'GIT/_>+VXmʔ)VZ5͎;k׮Wwȑ,eؔ)Sƭ1`M~'nږ,Ybv 4J:sR2o޼yK/H"jkeթS5jtUpkɮ5f͚ 9y9ܷo,Y}Qۛ:YӦMW_;p}?~pgv?\FƤˮJ/ۜ9sT@;wxF~m. .%kGcٺu+:vh^5㵁,^}YHcֹ >ܽy=};Ɩ-[6ѣ!,RyϿ4]+קնZT=dvoYbjesk^_|i_ӧOE\}O|v <1J@ U0ݸ[#R@l*,i  t̜9ݸQqѢE]0H.k (ذgHRDA*5TM}^huy1tPkٲ5oܽqƳVD:v=^{Ǝћq#Fp\~.Y|70Qmƌnv s` )`n3w?3?_|ei~ YA^Z\Gt,pZtiL :? )CVAS@O<pRKE\@MSVn.]ot}z{lָqc[~{]h>cJEe$z v~MlU^k׷@ZT9}f/ݡVbykTIUu1ZРUp8p厤6J*a~gMlSIBevU,_f~4ewɓ8um7}E\YUܿiXi7!dSָ ǰ@@.`  ҏ2f% ,8bŊ׹˛7/_exM7^8[(=XS9?egnld۶mߤf͚aL-Ƽ5@2ǩU|` )hw}!FQmذ_z`P d=XQ. Z5hemijfr#}~k<|R1uܹ.oM5Om Z)+4]k{McP3zF{l'*+eLL@xac~ e]+uiD.cOQ&cqYyUg&końwoxkF5= ig5f~~n:t8!|}[q/y{%36A *G"Ryڙt<+Lz5M@ T+/ 2z/+I%ofk׮]A'e|'䜚A xyMV6 )+nݺRq5з ֶonWW1Gqו}KLTkZ))z+(Ϻ2 T: b]~nnoΑ,%T١CgW*We~Z;:Tk}24]?v˂S^&K`'ZbE8q曮dJ?ve@} j_bz6e;ul*><?S6XWG6A{OMnjy}}knbz6S5nߵկW}w<\K ~\sL@ p1&P V=B*xl@oi}'=9P^fmժU̜ʾ)JMkWA`ڴ 7Z?KU39LQ;<;?},Wi9~:O^<-߻@T*}URr%`Ʀ5Uvȑq+@[KX lEz+[駟eypct~b;C2TPN"NC/˛4ET`؝2 Pƕ]҇J) ZGJMC .hqv5e+) @~~X!֮]N"y:*>cʜJ$ZJRSPHA- lя2yJ*7m4O;ʺR2-,J* _`+ v6՜҅q&M)LMA6ei4/Pytb;?W ** N@G`6bx[n]\-jô"@q.gq" qx+n^+sw+ SݺuPj^{+S+x۷}v79Eh*x @޽{,m[={l\BֹvժUvb* Jr\Jm.{IrmϹQ%tY= (KؼuOn=zw*#pn[nZJA0;}x*0u뭷}Y5T )[O)4vXWPP*}S Mst~b;RvSʇz(sd@8WV6zɾ GF.Ͷ3_K`SiN`wl;~mo8Y*]& F3DnN6hx㍳y3G"6mؒ%Kz?oNlٲeuX:JdD(SQAl䜸J9&&1[S+ӦD %S!2IwRF|J(ު6|i]>@Ps֨~qՉqùmgGrx*S ϴT`rAHU/$Uq5B| "H@Aueܨ @@KRq :  F U0@::thW.9  +@*bO=GW{y ʕU3s5!   T`B@ $\i`!*    +@@  d˖͎;fT@@@@ \Ef  rMt    WU= H*FC8x 4    @X  Ơ@\q \@ ց@@@@p pgq# A=\;z(6 @ ||Τ.#  +@۷M4֍Yfc=s`ofӧO[C \l,XR_̞=V^NݻwF) 8qFMfg::L@@Pjb< Dؾ}-\ݜoР+W>#{l۶m+W.ӧ)ӄ@ ,_ƌn$_qvyپ}[n?8=O ]-[/b g˖kիWbŊ;wn[Yy׭aÆVT);v옽k֮];w-k*%駟Z˖-E?Z+Qk<)Ь_N>|2&OlF:wl͚5s}g.(}9߅zߗ,Y}Q˞=ܹmߺuk[d>|2ed r׊}mժU믿޺tb2dp+؎;\C 7xܵkW>?fx 6 @*pȱ:͋U@ -qս{w;p٪r]wJ1CBC.W_}u֘j֬i-Z2\\r]|VF Pi׮]3g!۸q>}:ENHAJʸ2vVxq1c˘ԹWu1rH0a)SӳgO6 hv饗کSL/yԩ{]-]O?YAͱcǺD6sL;t][pak޼6b{gyRKק KM >@ A A۳1 )߿sI5 @ ?ws>zMyݸ!ʰ[(K MVX xM5OA+wߙʽ)ֱcG˜9+ߛ\I`$9re.O= b-[M2f[~xjժ.ӸR"D:U֙2$O ?;OYq Z)w?p䯦s ̮m۶;S25ed|?n_e)ȭ,/]: 7.ɓHCB`ףx{wV zR@ \9GDHEH!*T0P`7#(T-0hTU(RPk*ӧRʕI&nŋܹs%UNW^y+aTVVZ]@^CƕiݿL zcR`SVHWk΅ΥNzNI/[?~.SbŴ&  ~Z6U@ 3ϼ@0PI)ݘZ^oٴ.& e4d럎_`Ϟ=vFXi+5e]:T+M)o֭[]0C(]˜O\bjZJAh (J@me\y*Te6֢J9{뭷,K,guɼ rČ}@@@ y&@ DT"IWB۷ё2,F@%@96q ( )zAvM7RrdSPW^qo6!)敨6R0#o޼ԴQmƹV9DlذaJ 喞^jժ5To֬Yn:ӦM) )7n88SSV2Ԯ:՟~>|u֮]ó/   +.@J2E1Jj$!c(T<6e=)(=FJ'O z@=SZM6q%8`N>}z{^ljM}wJ8Kdx=|p۵kˮꪫlnB_||A;Ӕ \cǎY֭ꫯveW׽ӧ=vݻ 8Y! @Ț9Sf&@P;#ҝ֭[WZO{i=tu]T7Zig7n~])SƕwTu86mtKʀњF k bFo rE8,a`I ﭣm袋]ܹet|m{? ւ+.b\T*XSH`k1=[N?%KDyRJҎ^uTLYfG [jk9" ٲr8T B 0C([LH D駟NW.+_|I@8\ \)[Eؔ]SX1ΔQ^y>oʦSg…VJlE5 | Ν[ <ְҗ-XRŋ]@dO?uk,/^ܕTUժUc]JVQF5TOA-ظXO)>{  '_#֨ae;79Qٲm͚]YU,W4`?@ ^F @t3.MI락H IDATh.u&M\i6e;-_ܕR D)5nF[t pR +!hѢn?̔Զm[k޼}rJS> 8i˖-}O|3X͛4̍m@@/?T[fmܼ-]qfreJ<1{"@<\@@Hne|q58e˖뮸 G`S,XYmJ(az lRNCM\~{탍+ɰ  @ ¾g;p7տWҋ/&'KS20:Aq3f     QTWŨCREU0s@@@@@ nm{)*zUVHhj(nL袋F5t\viKvsӵ^k*w\f͚~ }n}3,K,VB˘1jG,Ri%nݺ-[6ٳL2  k /իWS H_]`ve f>v/Rկ_߲f~[N:Ƒ;w(5n|2dpQ?{XֱcG˜ʤ7Dt!+SNMsa @eAɿ1x@ mt#Q/@7.Ǎgt7Ք#G8|$} x6o첹*VhLQCB gΜּysw?sO>W_}Վ;nQ`H-[Am۶ٮ]5o:^?[lÇۆ sq\?b *t'z_yM7\r٘1c{^sw\x {wq*@1)k7x )mҥ.xF3fpA#/QV;: {mnoY5믷 RVZnUVϱ-ZO?Y}>*`dM=]3 +VX:uTe˖.L|M8LVڢE|OVK?@O oƌ@"WIc_@ BtS}ܹ!?rH裏 ^zF;3gδiӦu]gqTeƫbSyF쫯r%t\7lg%u{9WPk膼nٳǿ… 駟v?mm?h w#W7 Zť9+` t醻YfYӦMW^qu}fO^5`v9Ơr-[eQ0@@@/@U?H6^zRNʶMueN͟?.r+YY+CY"+tJrA>mֿWzjժvUW禛Hg}2Y4EZ*U\׷2 M|1 :LCexYQ^7llm@n)ȣ 7|ӽ2QUװE ,5,ڍY_eG*024'5e\qw.QcvmRD`r͚5 ;>ʤSSVT/W:? lSW|яhyWSWI@Q`Uo z1J Pčqh @@@ B\ jзnoަ2%E@7tgϞ ؉k[ky]I (B2tZdj澲DMUkq [ ycM]/ āt,n{ͻiu8p=EJn+Oet (Α~˨\lװ#ʞя`)[ +#ŕ,[*3Oeck /ڵs1lq":b   @z pάPn2he6xt2U@;RfPI=P`y*w^vֱkeuY,1{GK=zpꆹl8n^`*q5gߦG{ʔ)QW-XҧUV]ZJ|ZMיʥZBG6LuX6m2}vjm&5}aF5/RRE |)+2e };)*}Y)0+zʎ lVz^r%)ߙSmzl`b   '@*Y#~])"3t6M@ (h;X.]=O˗/|ZFĔe!;n*JW醦mV^uÆ ne&n{7c3شաiZGc:]ICeu15TWtXVAؚt39z4:-6 tUTkLP'}嵄^1MM75/IׯJiMnZSNse.]ږ,Yb7ov${Om}qFƏ%+Ǘ^SƜS|TQu4O/2شΕR ѻ&*'b]}Y)٧I]۶ms->46}IJ}(kKqFweR*@򃚇Yre˵<@@@"@*IPر{s=6h  0y_~nt_A$b3ndN<ٞ}YС[SE7[Ji˥kZg̘1q],ۛn>S4g5e_3Ak0F)SO%pu?M4q+~WZ+-9U QO?TL(.5jp+'~t*Iف: VzY_om̙.J߲e˺(m۶QX@@@ <2Ytbr14-ttI&Π a"VZo]^Sp8q"0"q B^r%Q WM󡅎 \+#KOuby̸*U$fg֦MMSNlٲe]kjM֔hZ 39N,,Wz㳟ɔeZ8  '2;u4}fL*STC@PNPTAZ &Tzs4@@@ pó2I ~Fॗ^ьnLE2R@@@@ \\˙b @ L>;J*ֵkWΝ;ՂeD0S0kŋݬ4ibڵ۽k =aE@@@ 2? @曭tVZ5۳g._jeD_'0C(\=_[ƌiӦh"@@@@ p6"+вe'`4"U W\֡C_nHa     ÓƐ@@@@@@(@*=U     08jԨt+ 5kZΜ9ABh^zիWH@Ff7ƦMy Tds KvX@@xSކ*O25}C1(X+4Ǩ޽WHt@ 3+,/u-Ad (pEC@@ !qVeqִi1CB@@@@@ JWƓO>es6e[@@@@@@p pNg+֩S'ZV*24@@@@@@ \يe=)JeU>    ?ɪ6|udT @4O=7dȐ( t H~cK.WK2N  a/SH_M3cJ  @Æ ɓ~ЩSZj\Cرc'8pSkҥ6e˙3>|2fh[۹s{m۶ָqc?mҤISOQ̚5k ~ojղ[o5({4ʒ%=s.u~UYAqUF={vTqFSzQׇol@VHԇ @XЉC#   k.P ȑ# ~ĜD*k^^TQ֏Z 8Eo ~, tF}M WƠsa}uG}yRIrۻkm 8׺~KWq>5k#?th* TJp3 _?_X qen2ƥãuWa{E2p@J   K`ѢE.GN*WT)W$R'*WOYF*=XhQ#XSI>>c(q&믿y1oذƕn/X*8ԩcӦMGXw`RpMviZ?LYd q* fi] 7o[/LAޭSkݺ1{1 T˓k\yڹd:|[رW>ݯs^y˪TFе/9ډ_?wMz.K,U,|MP1g $?@@@nôR^ܚWzh)tltMn(ԥK%94l05j X)52yq[oՆC *)`ck%K>}\z{=a޽{1.MrŔ!hM 8'i,e)X]iCek2QfXrrxa_}mZ_q9: p6bL7+WbmO h[wqA̾/ڭ{?,o6vm6. X ;xjֶe];t}r8 J8}_sQ*E~>ܸqc^vc2[ovENuӧ4͛T)WyQ=t\CϫŬY\-̧={:?~aWbرw^b*>իJ_xM@@@@@_/F_ 2=kmף?>TԲ,dkáuM\/>}YWP>ra{@dt <[l^xYT7]A2eʸE~Q ?pc+o`]%XSJ)|QG7:wl 6tرW@kq4.3gu-땀>jZlXdKSH= .tuU Hi1ީS{ѽԷEx5~*h/Zl'p)_ Hi`W_#GW\akv/D*>;tj*My^`Mjժd  tn-1G/nb 6dj;GEotV[IJ VRŝr֭[t2҆ \TFR\SAeWy?.\V^^Sւ ,k֬n\*qEA4~z[tOea]gƠQSoժUx Z]}^~to=+ G۫n$\%w       6pOo(J֩)Tb/zۿu=}w?ʳݴi F66'OvO 7z/{MuׯTbk*R+JFo6 (!(Imϗ*Uʕil֬Y|6g@@@@ $:w! @2&|CAŋIhݩŋk$/;꯿r_)r{jZiڴilMH_}[J.*T벝f16o<Ƙ~^h{^d=^z[k˲R-X{ꩧ\>2dRtl=[ˑ#?A7:]5bj^{+ei;e@@@@@@ } یN2TOk.)H3h i6mM}ΝJ,WfJuŭ!Ҁ1E]we=zpTPIR/VB3f 鵡C_SpJSL% ]zˢR>`|[lqtM֭[-ԏ{Gs7o׷~*+>4wEiZ]Z΋5      @ d8mZ+sSfĉqf5df̘f!]j„ gIϷh.oDЊ+72>A@Y,Wh"EpBhV;}I$V瞳&M.' SNڵkGȮ] mٲ\6%t7}%-C‰7vRz٬7% %W 1=Fy&͍NU;u4㈸  @ (w^/[l VRFLw8Mq_˗ @@HMWͱ@%2*L&M>:dSLqY),(U߾}]po7S5%_{5Wz6vHa HVZY:uwdزe/_>2&Y Ny?ڌًmni78rzZt|ʆ hc5N9DB_ WT&PnB+}п! yﻛz.]ڭk&B2ʔ)ck׮#F::tOH_@h^=h1 ,\О}Y[t)L @/Zm ꖷٲ" Ș}@TP2~z|Ϟ=Su< P|(q!*ۊU1ƃ Uĉ6Y _,9L~cKn2dc?^;lt3/e?N7{A۾cOF"t2IDV 2L1p@ n̏9,n-Ƶ"Gy. *j4/>fO4UJ9>l`{{٦kreؤ/TZ){ύ;W;p=׍|yrوG;؅% J3 _Y쾁m=_L2w؊6س>SNِ޳e?ss9\rQcyU읇,sLg] Gk/4~e˖%v1?wJ7sdO `5b)c徉ֱ֬u߲^cyi֝:\6y|<6jM [a?v)+Q{E[Yۖeօ|t;7>w_蹭X mz8|7?ۖwfQg!֗,ӎ=n*gO &_Zc{r&:}{>]{X.:ZgJijz]EK;"@d l@PȜ9slʕ9(ua;'@r (@ߺ5߼nٲe0 7~:[,mkLAOzWL 3lmR3W-p^m眓-lo-{,oe(QoW1쓷[Fcw۹q)5jVC]]olOlJvb-[ccu|GYؔ_Yۛy>WZoO&<ŪT(n ȑf~pQ4b܇ۭm/w)_c2$2|ysٝ6K_hǏޞyKͭ;3WGks篲*)mwڔv7~Oqc\5s7DѴw} ^C)V$J/hjy(_ *;vxaoMy Y. h3F Z@DYL9u 4g&.\nT82@@BC.uAžeiQ*W(/{:,We}X~QХFb/D|X5L_͙WYoov](c}YfjK;#/M9ˬU.h7ON re^U,W.B5v=#Sm3cKJm^w?na'O`խCѱ)ؔݗm %xnW_YF=7֬fe/,V^3/Y;k\>Ӻq}[A2eF}rᷛrKuBoB}H){x1 em]Jو_l ?z٪)Kk}I`g< \=rPw&댷n"*Jg@"EL$jՊLha x=Map" $@LsJ_`۶c edz zLvQkԠȞզWf=MCpS}~ ՙTMbjʔ)Q;ݝv&pY9Kz&o\k\>XT/hԠUTm1ѡ+Kr um^/<}/_#OsV?n7ei wYgƚe͚Z>Spgg?M$t*gm|׳~ f}o~g \_L-JjZMmm3٘ /ϸ*A+]G K2  @MoG5-Z4j? @ }۶m[2~]VbEH>&#V O<~MH@VZ5WV3f$^'jղڵkGԙ3 )&_Lʘh gCj[̺<[K˚ (آ1m??ǧ[y_QjmvWNZs) '>Psݺ]U׫ eZ)h[Yp rK#ge,WB$v^{­C5_!Jps+ۚ6~d$K;VOA9Ȏ;"g0+}k ƋW2ֽsS@+]{ۚl6iVmW-ó  E lWz!УG}ЧO@ ̘1/+X2tqN+x(QU+瞳%K(8@$7FgT pїG0g }%M>ei:ss/ͱtumں.efo+pQUE cw]Ķwlv~d|•%LlSh.KIkT ;S_ifM.vO}{]۸zTU0cJ$\>ʔ*ZfU] ?=F1ĴQbuؿ.dϾ\h^ +iC}sjbu*yc ŗ'|ߵz8h}ޝ[pXweY/PtZٷ==Lklw)I=]^C-I;#@U=L HW^imڴI^l}][n]zVG$+W,}I x/e:t{ַ6gg^%}M3xlgP;zgTAJD@5k֜)T?~߷<{%-E7'*Eyر.]UH6mZ$Ď^B{G \yI(pըQ##k?-9&!pBׯ-].!,+FbH IDATk.W:-[S+ӦϔeZ8Q&N{z٬7%1\P?-:>e=jT ^/9E_|*{{BT`ĸm6z3i:āUf&xG    D}5;Cϑ#a{}k<]9f @ 2t    Wඞϻnvu~5 <Ǒ^@@@@H`֛6^{>Y@T`r(      @\%@@@@@@CUr(      @\%@@@@@@CUr(    i$ϮVitt @ J^OzC@@@He]ʟ6Q}T;B@ \Ef    @:߫-S$f6eW>W7ﴃ5 2k1@TȜ     ڐmf6t ˛'~x=l6nno˅?YYmێZeO-k̶f6Gǻh{ޫ}mۂo?e/:|+szx8DԩS6lُSsܜ*+ ̵eu/-Y2,Ύ?aA}o…kWڤl?,{VIC@ \8C@@@HQ>^boM 8Zte떷*r>]\ 9{k&q>_k v[+UʀjԠnǴAƌW״G,Y2ɟ؉]瑣] nq>߶cŹU؄8={{Y܏ @H W)J    i&j.h;W+_[~?pUWVPA+5eYҦ=;iNW&w['yk^~r6Ǘݒ]6}L`wY\k;\i uսf^krŶsY>X! VJ"   @hL3؉':7w(-\{rg` 0:m+q)SF_Q;ǽvy@C-29_9ĝX\o{U*_,@`@@@@H',C3:Nm] ̙35?OJ1n쓝W%TGsOƹ  RCc    -죹x?l/]۸HvI_i,2|̦5SGk$>k+zA>Sc·'k_2&{E5p$$B{ U) J (*(ED_(Mz)J@7]7ɦ>&3w0sﬕrR-kZ lӭw !T1@@@@RM~ɔVȝ{JL2[u!G-gOt6wi"ì %4Q55rfi-c& 3殒lsБc}}inBrJuڪ|2fx(:_zʛsM2xZbv80 @ qv=#Gz̙3G[󫧓KܹJ?KJb| 'O4hժ4jHv!;w]   @,;~7^i=09C}iUїct{׊e 1KL:ǯ6 Dfg;5.QѪ+} >p1{o-͚5}]vM\Iߝ^zIٲeEwÆ KŊdɒI0ZD@@TЩ@pMWy^ j[lyJ˖-}Ț5mk/ .XH-LU&zW.Ν37o\+w1^zInL>(=zŋoƍ ,((Hnܸ!/dϞ]_.'O6D[pp}Mi[[zEk-[6)\\tI;&w6ǟ9s5MIi .]>}|={J\Rׯhhȑ&t}M}&ˤILjɒ%fy⼝NK8tPڷo GoD˖-~33MݺuK d~6Vt3gHBL14yקOĉe֭RV-2e_rio  @b J}MGE@Hʱ (.##F0ZPj$M\i)SW7y1I+ E\;{OJEZ)帿&44?8 :/>9sf)W.|>UM2K@J)g1uTɔ)8p9rWVM3iҤNբE$4yȑ#;)"f͚)]jƌ2LE = !`fΜipkWź\˖-#T 2D.\ ZaŊ]m @@@@H\ :%V5o$R+t ^{-N,ƍg; @_K#=â/[z ]WK_QKC6lٶtN?|}0W* w}'OS}Uzt]l~ӫW/)^Rشi~3DL5<Րq!@D+W'  I+@*i=i HN.\дUW;vvIn6;'^sxCZH֭[Ǝkָ*,]-/#]`V\DĸvIX{psdѲ{n9rd C   H\3f,^X}YF-tʊI&Q/YHY˗/˞={$W^Mكs4 0Ъ˗$ŋ#$tM:]*88XH׼?~Ӄ9ctZҟlG:tZp4kL_7Z~'.K"ӯnٲEtjbŊ_|a  $Lԩ#g6k4KټyL2ޏMh8w}'_~YW`3gN8 ,asVeC"P UGׯoZP@( Mϟ'Zy裏ܹseΜ9һwof"@O^j֬iXmҤI|ve[@@x`ڦ=?L:~1tuH97ntԩdȐ!Α(I))ƍUV}9>}$ew]-bYL:V۷@@@pWs.p…okŏN#H'/h%V/h"^W@H.I:wܑӧV<o&k׮5h&֬YcaZI?=S2BZuվ}{SEq= ֭[GBqtѪ0 ҥKfz4.G]ڏk׮cXյOyppJgϞ+W.W @wS۶mMŕEI    k rAZygC`"JF#cƌ)p1J}chj֭fLWV-1b]D+D4Y N+mԄ=:qժUjJXVLɪ3gHBd޽6}*O&Nhơ2e_rio_~xz38hź&@@@p=H\78z)# xBZ!`[!RҪAS?4R{ *ڵK?n_K+4Ao*ө:֭[jеޘ={v8p9r)SVjZZʕ3׵4BCCke%   8pܼˣ.H1r%5yJ% H@]+MZ=IsUx@+:5WZYfIڵ:G E6mjָo$5;whժԯ_$^u5jh1tjBQG;wg}&:M>e7O1e aaauu@@@ ޝDy4B(BfҧG$:ZQz\CI}uEY3>-H03XzuoԬYH'xB^}UΔSbEY|tF7QzS;vÇŋ-t-9~c.C r3UkNѩ:K\Ņb۶mҸqcd.͛77I+VըQ#&   @ $C@%a֒խMk[_J _IvݭQI\p\@@@@+@@@@@@%r@'@@@@I`ۮco藒?o6987zˤ00Ix{GUO:z$d#iڨRE*[v%(b֦\! ,ٳf)W> Msp=Id47_A>"ZN]P)R(wpM~H>5Y߭$bXțH9HH\݊-@@@@ 4_^ 2N"woפhj;]cLZiǿzTXiK&dٟ$to\k-l>^k/to&me_ystk..[~s[)JHUYDSӽv7eӟGd?suKǤtUjsL@@@@xy#\tCΞ*W[Ֆ3~=|!ϴo Fb2wi޸zb19<\ŏ;D뷥d|2#KʼT*ܭdY2wyr_Aeܛl޿eYRFITD?[ͪ!\Gٵ֢Id29s9vV^><^ך kv+biPܼuWrZ?fSj$OMOD+/kT-n%Z4кǤEVʀL&sTWIv}g/\aA}/}mVRLΜ"ޒ^j#ŋ5U\=pRksCV{G'?%qڪ\ì߯9&3qv]Tĕ    7'N֪VB^WF$Av&UϮYӷI3hj;#GϚoHgS'4o ryrՌj#7m?ܴjdK{2}b/9},_S:'ă{kKFݺ62g'%Ъ|V`i|fqzhTZ1]k32I\jI=l&}̙tR٭Wo<)笴'ʃ +ʾ5۟:¯_&#ه" ^}n9f%9-c'.i405Es jjŢ..ZW6}Ӫ-ssD@@@@\P@oAeKZi7IתHxe 4UÚpXvhLTHi%4mVu"G)+!I+ ]8رʗ&-g t)  V2tr8_kU*1I+|z IDATֵvXOTg/?yA&_b݇ G@ 4igS d7"Ժ׵{H5*7iIêPy[׉-IYd9u\K6h)+wrȸkuGo)=OGFIΝ駟!Cȕ+WbץcǎfիWl駟ʚ5k;wWB@@@S }U"Ɗ-41fJ>PG`}['߳}e3G(}}SҊ/O^tNk-\c!]\5?_ήN&;lP;gSznb .c7u VuiUYFےN}4qhMC rsTbE3g|RLg}V֭kv5E    $̺|%*'}[X^껵"hL@Ѫ*VDMkݙu]AUfqLƌTOܻ,k8 gW-Uw/҅*r&R:% Ik͙^ŋq}hzhT.E|5ak]UZU/U>M%Bt#R`Nsk_u4ަU^[tNGOX(wf^]Ell=Mǧ8 hI4?.ƍ5 rEҪUd…ԩSjժ\w֭JѢEME    wur ?κJ#k-./L4v]fed엝~^pnkɄ:K>xzZF[׷iDoj޸YKc:"k@&cLt~_W1]@ϓWU&I끁xbUioĈUlY9uꔼKzYl޼Y8qO_fӦMモCJ,v$'F@@@@ hhoE[C×q"$԰w|j{Uu?D-Z(3(lH-WX27z?nn=l֗&MU}9FY+i@{!5vhRZ-<1]ka׼kM7!"R_ sahg,&h:בVeq;{%h֖ҵieZ16Qw^؈B+"\[ĕ {JnݬE:IÆ K.r5D&4 ,(y'NH޼y#ɓ2|po%c7 @@@@Xip=f, ri5>=չKʰm)j|5tWK]+T 'Op8… ҬY3;w?!    @ >{E;O3QS) pSj-M+oH#h9s???ٿYٳr9)RrjժɀW^qFsj&*N,]@@@@4+LT~ "Ofxr9Z0Qe4)и~yWLlj4ŠrËb̘12vX+Fpȑfȉ+=Z+SL0vɸq̴FrC      $@Efɒ%LhjѢE$7o٬q楑?~Y`_QF/@@@@@poW}@@@@@@@      Kr@'@@@@@@Xkp3իW˾}ܬt7Ο?/ Nf=YfILR+AyNCFΕ9m/j; g"9˓K 8N_l-eJHyq駟ׯ'Qbn͛ҽ{dúu뒭mF@ ,[$4ƍ'7n܈l  8uU t ]ϝGd?pWsSL;ɴiӒmE@ hjҤIΞ=+ ,{l   S&I?~O^._,W^5kJ4=^^^{]vuԩSe۶mPhB:u$cǎ%KJ˔)Sɓ&I"___>|˗O=jMU%]tQF_{yիeŊc}뭷$W\8Zn-d>f۶moYf_% zI@&@@ 3g4&p;#m@@@@T*Aȑ#壏> .Ț5k-ʰaDצѤڵkI6rq8p,Z{믿nLcƌ#F51駟s]~Μ92c I+gۿI~;… 4gmSf*&L iD  _ÇGq    WItVjoAf}ƍr`ҼysMdǎҷo_{pp;wN-j\=hBH6jO]&wޕJ*bR*UL~[+l8bk@@ u /Ȝ9d˖MN:es_^'    $ 4~ПIK%KHΝ%o޼&O>'HGuV_Z67o;b]o5i9 " N\,Y2k`v7n˪u{eј%ZiTUxux7kc9&݀+#!f@@@@jV-.}hK_nIˎcíY'1wL"T_zg ܔޑgh@ 6IR5UX>yYbܸh="m J#   V<ؠZrzLRtA]sӤ#g\g:MRm.gӵ9cL'蔽}z4ʵOa;ُ/C=#q`    8@ȟ7^ЄBt1zX: qM΢LL}Ư>+.@@@@\X@׀q҄ksîiZj!hq-"7]v1&#ۯ uS*!q*@@@@ 4nZ` m[OE kFX4w`S.+@@@@@q7ms]ԄcךH7趮Ix  YJu'qG駟ɓ'OwKCxbdղyf2dHfG@@@HC3ʍtic1JUb{QN),B )uOD hu{@$&FwPL}\.+;g)cue˖Ivm۶ڵk'=vF@@@ - F;ѷ,X@p@ gsInL>r"shO3ʝd}y m8& rZ_H1x4MAߣGS4{ldiժr%{GjԨ!ǏtRV-s玜;wNSTjU aÆɍ7͛Ҿ}{yGMʕ+M_pp1Befʤ˗/իWH"2`\$RPP%_|f%JJŋoac:ޓӧOKHH1t{y晷N*k֬GsNd2zh)ZޒuJf֭_U2e$ݻ̙cڳǭ[Z? @@@K@ "mt}/H E%\e*~{@3$kO?~/cƌQ.5ePW^R\9rӶf͚e+O+4 [FaÆvŋN۞6mZI|    1 eyPB.,*QzM+SbӦM&ߛt'M*iTR^Μ9rkzK:0aYKOH$:!d@,aaaaz     8x      +Pq g> ),ɓSHɝ;TZ5in* +79ҧUuyziwn>=y9ytzo/_Z[~Κ)N~f6~KGǖX넼G$K~"yǃHi}@"@⊋H+V~3lpgg.|OޝB]H̙32rHfR :-tHuDl/T9Tg\%]!IG(kl6I\$Iw:mGe&\t^\싗1#/́r6/r5ߛPlfH,ud+_Y_R6 qՁQQF=Fw8v~j$C <BG5 u^@l*4Z?2mR4oV_^WfKnޓF []I>RgW2mmC7fg IDAT{&ޱyp|%KN6$ײ!n*KӪEe瑳z:c!ə%pCNM>xQܪk=C}8;W+v˕[R2R8OV6$4LˮcJQχLU& ^Jݺ++tebf)uJ8$I;eH?˕JރI^\l.{H\쩡c    )! "}Ie̼?d@Rpno2ۚүgjfV"EҬZQɓ-_J O[luJ:sX}!%g7]m6c˚n4䷿u⥇M_c*#m]TQiJU"Vu]'`,V=$yξXp]J=f58$|sV{WK0I=VuL~^9sbUi8~ihW&@@@@OߚJ5ɷVeAzpDlZ*rYQu cY ?-&[˶3յ0]h5D왭A)dMW/=ԽiUZZ헶Sh%X}J%~IFM7䐧uzI$۟ :VK}K)B4^J9Ѭ-[B>䷮gn==@ksߺVIj5idJ//J#?3Znݟf j-Ŭu:$l!ⵦa4lךYsuq;l@@@@4 ;7mַT[U\*/VJ>?ӵj]k鱢#ՄNvU 1^V.lM9Wl8hK ph%{oVJFl# r|_S-Ѿ_cR%Z;/kUǽ׭kT$M*5Ӣ Mq$͞%1Uч-I5-_-pLG$={Ԝ`MZǺ-2ٚ>4]K.R%~yUz~tz?p*/<i<.>U>wkoVBLEa+yi"I׭K躀Zq5Z둚ťrثOu?uT."7quK?&a^U83gN!ۤO>RdI4hP'w^۷Z \^?;v@Mرcҿ?d>@b=*;v[k$]Gl.]x⽟.cJu5\"u֕%s_bc/C<}Խt}o{ 'kc4X߾3Q&IlٲɨQczꩧv|t@@@`@@@xMU-^jXbҦMD@@@H\n 7n4jH=< hV3x h?tޏ!@@@\@ĕ xΝ;rښ5kdɒ%)~\0kE/ z!XbqW+WNs @@@O qgq!([oիz) M~h=.KtRɔ)Sa4'X]SRm.   $<3<@ 5 $<]~֬YRfMYrKի'?hu-  jՒUVIv649[n5mӾ{SN5۽Kr9ٶmysNo_%d_i_ck?ϟZݱg6=?2fiذlRΝkG;8=񰅭/ͳZ>oӠAygモf͚i?cъ%[Urо>䓲p8C_-ҥK&IC>}m9sƼAAAҤIݾ}l"jJ֭k-ZhL46ltN>8LE%JN1I+B@@@@0@W2d҄&Fafj}yu֙$[h'9sIbmСr1>޹s >\r-m۶-[{'&)q5yq&ɢCyʲe?4/ZhE& *ds:nM:+V$lbJ?7x$l,M@>ӟ˗//:uZ\Dodٳgi/n{n{0.ФU޽VPx:~M੣-Y +__(g̙ 2HٲeƍMzRԩS&Qh;]'Þ0DA'M,;&ՠ\T)yݴt@@@$l߾<͞={ׇ@ʔ)#SL"Ny9"Z8VPt]NTO$v4^|E&д??=q?lӦMf{qʕTirEMz{VSy{{{O>;vDH\թSG>[]8`7ʭ*Uoml4qeߑ-]CH~~~flq=o1TSMi?7l ?=qi믿6oRjUN^/zkҥW^1hRxyݣν;~!ɟ?Yw#}&z @@@$Wt6!NWD>]xȑ<,i46+F?H*'0$V(i2͛FHwϵ)%J:-4V}5rCǡ'O_Ҥ{:ٳg7*q>k&J,i:&lhP֯q`/'g֭[{V]L׈1t{ViJ<*4RjժE0_4qFz5i3CzcCZJ\u5jD1䍴% sM^   x+: 4MyMP_F>uO믿.&M2J4Pm ZAk̘1T 75y'ZA1}p!-iC@ͳ-yz_-y&[89LQ4:͝-?GNێ=e[tw1cFm :]t1zhS)#u֯_/_}UmMzVPm"~;WvtpVVi_5W\1N 9]+t*Tiߵٽ6^g׆+WnݺEpqL?I@@@\WtnAرcMRIo  0wKOYd1c͙3$t+={\[ .,@XlZhٮ\rf ]CC'ĕ&4I%G"G;v2qiÖ54 ,]1lݺue[ڵkB(QBN8aۨQ#&Lt}ViR+VX)M"iFS{1X_бmY Mijرkh5*BUo-Vi2ǖJ~۷oK&M/lczV]ͣJkʕw^}BϿ&3VZ5B    x+9E30[R@H<.6w1  @L% "\`_8+'p*]&INϮNON `i#ΜhM&n-@ʭO_v^U?Fu+}Bs-N举LӑZJPX:G@@@@y/sF=sT,xKv`  I.$L z%ؽq"Hn%?NC>n)@-OI@:z(+.L ln\<^  |IwA>r On+w#a"Žl;I2wI2[Mx]&[vuU-'r'J%QM ^A²dvVzwuob:{圷[K'yɵZQl%tz[* N!H\y e8"s疓'OB   x+aU3M ywsArJbY/$+A"w 2VR\@)m7f4mxOﵒU; "*JUt?y$&o[.JHU$R 2AwEΞH'g[ Vꄷ~%|$+Kr" +s$Y"~V -iMyǚ|iFzUEe%6UTZMu\K |ECPPs_:bms4Yn$uKis4u$b$Ǯ    @y:Q&?.Jm: +2)U QY}Vb+5!8[bUgY 5 ޵HIZ˗>Iv|{6o6IΏ)҇W>TbBn{“X IDATZ5!a9@LUT>$oPo%굾'XP9?l[+W DźAp׻R4ʍNuUs`@@@@Vx xVgf7nQ H wA딂G*VKYZ$(#JXI,MftwDeDx<+K׊5E_X<<Ǝx]i%t*9 +WšחZV)IB^zX.]| */a3&q*VJR V*W0{"ādծxKL}UAM+=ut@@@@oyݔt̚*PX]o<ִ"$V1JRj_[:cuNC׿Y9~VUV[%8-#t{BJ\jȵ"Y략nՎ),[ fURGXޜ֦Zkay;ɇɡITE^N75VunU\:ZUYָ -UDzy=Տ}S NN~4Vd"Ne }Y$ ]֢֟:k[ik-֢Q|U>rlIPiF`Wu~f2&W\ ).Э[7ܹgKz(BB%(RĂŊbXEAAQPEKzMϖl؄@B&7μy?Ksn Xvapg b8z s߷TS8| _K4I7 ds+҉`)\U"yߟ!9U 袊N&n*UVur6A[tUٻ\ӷ璴oU~.{J@_J@ (%PDDDB f[aPx^ Y ܅&W (%V۷/{ +%J96D Mē~XMఄ:[ɩZם%^ lqg'YAp]YjGQ9>[ijQ),}^݅L_c78$Ϸ.+=. 9pr< -ۄ`ټ#~]2b$%тTkAz*p}<7iFsQUM"j7vE&"Ud ''5xMU+ؑxĊz-G*ZuӾU9E@|ӃPJ;v /V^ Á+_4Sn;v,f͚ti?0Inqw`ĈxW:|cy+~T>|o-[˅֭[Dʕ͐{NVG}n >j׮mJ@ (%PJ@ (q}D V*h >AAVncf:qeѡ )E)˂hKҕEW{g\\4N/D|+5UHH<xE*wVdRq\Yc}Eu;F~9˺ |E<`PޘnKZԙw!$<nFZ1?ٱ/&g/PJ͚u%@*\@ (%I+iڴiHQF 3/-LxL3mǔ*ᵔht% uY"5(NE;26#kD RZsVQ!ҟWMDj&.&"fQb.߲Pn|A9;@JAY2}.&==}t:nѸ-L[fE6I(t(Z*bvEϴT V.8ѷ*)PV`I W%霕Pe@Æ 1c ,^;v4⑷On\OGM7ݔ!\>Yْ Ѫdn۷ϜѰ=˼[AQ}:jfOwMePJ@ (%(avȶn_^Xx?.vυAwV [٤VFeugOΪaMCqfEPc-HǸI\%ZAaѿ8$U==o]}zm E$բ!,v$K6FJ mel_gǬ/Oۋ \Tqs,شQvW/wvƧШZ(6*\z=PJtXnwo;ڡsͻXwq\9ňk\sy~&+&q$&z:Ysq~(ٳG&O%\ɜt_%PJ@ (%@V܄7Tܞ~X*Ƅz8pD]#N, ]YqgIo+- KSx ScYR`]]# s;ѱUlN\w^ aǙKk4;G,+S0Ĵ4\zS*ŬEu&ܽfbWc<}ޞ]?L*PJ_J@ (%P(,\cFGG#..}v܉ . ;w7x>b~ZFKe=jժUJJJxZ8V{>/scPJ@ V?q,ߛOJzu]J@ ($P•Z؍UVcE(j)"` 8FFʝŏ۽+H "blYiIUXCƘfXl1*u{:[Ĝ_vdb`hD=01Fr5U%wjG{ewOѱWjg ln](T-wn[(:_hߪf獀 WyG)%@>jѢڶmt a9Ҍm6TRR@P(?0j(͓tˠ֡C;xO?57nl?RJظqcW@*%~g{27.cy׉s7ߜ횎=jzX1C 17ngRJ@ (栽f Jx'*^(G k?*X,QTA) OX`c3 0΢%5!bd c3ioVYat#¸d"AugjuS@6Wb2/W';rgcgTG+E*BNԔP [{ۍ+lXv[{ns'_q".0/>r_Eɨ4Y[Z"a@siZ8r^֢(p %(rtQî7|Nk֬>#xG/\]t{":8tP;vܵ_ c _{5̳y{oaNϳE3gn3h)%(nL0eʔS Woe˖V_ӛ?GsQ*8%i4~SO8,L/gKŝ?0cΧ:q/)y r`NK (%@i'KKW?h˟yͯ 6qK28qb&D1%((Hk<=ś\7}yyׯ|P>xrdbq0;ӧOGBBڷoG}x70m4sѣK.AϞ=3_g]PJ 'v]]\VluU @D X}NUaێ=;QzE !)#cg$e jbG#ǝ"7\XV3*-'}5RPϻ+W8esI_0y|ؾ W~X-GuQu Dޞߧk||8TNL+뉕@. 6nJ@ (%PJ@ (%P ,YĸnV|FDbc}N:FJJJMȬպukYw}hҤL+::K/ ,ԩSq8˗m΁x`0>aQjժ/E)F(fo֜ W̟?g6sRJ@ ( P2F_KF'[~X%2|c>DJNqA}Rχ+,Όl+0nmQtb1bZg~(ĝneHY1㽳hw*zY ҷqe׷ʺ?ˇ>R~^z5%=/mcd('Vp8-'ʋ_qDH慎?J0=@I&UIz:w%PJ@ 1A!..}MFiƏgSSJ 脪Wk+*\1},銺+=wߍ}aΜ9FI|( T* WQנPJ@ (?%@? tG0륗^BNt:-%KX!!!摂O^_~W6Q}Z)J?orرzL4 =}݌YgWH1˷(y+zSx@(JmEyB9{] Mē')x%tg}Z)9gP:'uj/qW6],mkݵ0[Z I|b}qc5D 6Yet\\ XK'wl*3mll ~[hEU^zQEzzQIB'6; 5S7 Aeqa7˟}!-*^M dG@+}]x+Y̚/_|=zԼؘ_ɓѵkW,:k?/ϫ*%(iG/7`;?~Y#ƍTsϡaÆ湿˸`?n>- 99Æ 3_G1x3ƉŨQm6;Yg?D٥[1ǵ;MQdb`ڿ?؃ng}86mdv@IƌYfCf8|&POtSy;{rNG (%PvSzE<Ry9SQt2qɷO¹cg/bea7u5VEݹw^QSqkLzdA֥G3 (Bc,j&VSV"䢢x|ģmajq[Fڷ*7EUpז~c[fٰO  ="VF8ڰt+~+q$j)@ w( ]c%E=c=fbE ZeW3E}eZ*?'P4bݴ]ĪҷJVApuU.̱o|u'HXY.YWKG!-{XW1"f5erwE*TtQyDDqm㢪Dz}Bse\MYe~X}khd?,*JNM=YŅ6ѡUjd~ ?8TEB@"')L7p)g-C_q L2+V0W^8=SЧPJ@ h(*. ,0cY9W^K.NkiѢECO0yf_FEq{5?ޜ_~efW%#石}wEay͚5yu뭷kX^߾}ͱUR%,^8o `,ȢrʕFĦpuUW!--ͤ4F@-%x&$D:&}L"ܖPk#W:C1E.˞gD[5+n& ּKh96ޞ?CMqqYkANֺ ŊhKqdXEb` cʍSoHYkInVu$֥`kRD[uv#(^_jr-<;ݜ͜Ƙ^xёPO.N#x%?]QH!e>/30knP6RJ@ (%N`c*T1w\㌢ ,:kٲe{Ɖy5c޾EGw\b jrGHn*Rhv۷o~'{GTuaT޽3vߧ>+-k1JU[g}6.];XPJ@ @9 T/kYӰCS6D5+J%`Yqp5en+N`Q *8!stqgmg:W0J NC..4+K^Q+tyc떘Ggϳ,q{=eϜo^ƅUlFZ0I4jkKNJ YE(T*4-ΧSbɒ%8lxBzL坚{]ZPJ@  .dz IDATb_Qt/3``~rF-p+fe['?c(\o%xa+ tt%+Jƕۂ}h)iZJ4:pE!\"Bo`pºqVU0=C.tMNsdt qg\'ֈ51J ck8N R˻G,Y|q|'^+.K -k6ltݹ(b5<ؾjN* z!UqtA@quB+7xBg;:b F j)%PtFCf^u"""8Eػ[n1N&M?}iΤx,kի^{L}#͈~8kҏSQJ@ (B$)m+R+hO,:g͚eɰ/ Vn߾<mz<طo9~f;j̘1xoHAd,W ( wRJ@ (%@Yz44 ajjU@߀C"bF=a"ECWFyrUMhnK2oӝ%ά"򭓸)]Y,F 1KXUwVvU]TNtu=8Iq]w(%dI:W[eO[ MBVҷʪ^=tPt_2:~Ż/y6.5"yPJ L8s oi={f|߱cGd' ݻ˜4iRti}M}CথPJ@ (%pk>Z.=Q[l( x?5 -'W/ߣ p׫ ]h bwQ=|;xX=Qqc~Z8>rF"QرGV}X-oAŃV/?Y@~ lirZNTPy^߅#ĸ#*K>hf)?cG.X6D#ňTWKEsx]9J7q},XpA1OY|t@=P #F9tp%PJ@ (%PJ?Pxh݃AbR=/^"η \ZlcKD$tbEVⷋu(BpFV `G:hD,ݨNCd HARm)Qw<Z+ܗcl`ߒDġG,8ոRJ T=dlƍqWc=T ($l2PJ@ (%PJ@ (%P d[%ym6=-qW8pVMe@+pJ{D_km6&_;d݈BN: M@͆Nx\&8ZV8"U\[pɈAQ E (`j2[?!VAn (ժؤoZD E/WpwmwTR)!U>0ł̆C(zPI࣏>ի :PJ@ (%PgH`ȑXt)7o^z ݕ@&(RDR6g[u\umo[6;ǟڄ&IbnzL8G' W+9.l6+E?tnJpUT1~x>|رc>|8ڵkgFӧ+Vܹ3n#jxfVZ}ϟ1bv *111x'Nـ~/Jƌ o&жm[IbyA3O#99Æ 7|#G ==O?46lhy7(W{9ܚ5kVre޽;dw2&/))?䓨Xy?p8зo_|_PP_,X>Zw^<쳈5^|Ÿ{O;&rw5qUT8' /kʟoqQt\2=zY[`` xt5u7oY 0Bڵ3 e][oռs„ _䩥@"oUC>䏁Ap5X Cz"<Cv/GqaݑPV'H?>Y*uQXb&IX-άQVD-jppmI3ΒGZu}]Wj\ĪM+mwV n]s@6gBKXkDyc[}(T*W^׭PJ@ (%("3f_J*_W_}eNC~g#s]wTu!#p˃& Ku?؜/yq{ψf=`]`l'Wy#@[ZJпJ)%PJ@ (% ?Cw?1x܉AEW/+=%N5'H踢iٲefxǫB _{y4w\s<#|=qD|sPtIѵDg›7R%+ ~|gKD r1}E*G\]^YL?fd";]5B![5kTI%P H6uχ"Ftl "-+hs(gS)!()IF*~T"a#͂Z]rJ j8G Ͼ)W5?pKD"l;KqMmhfQX*JU+Et;?|7%7Y6ˠް8o` 11EG(T*WVץPJ@ (%($DQA(þPt)(r BO֨̉]LOOE3E35-|2=;i.7$E3όpb e|וDZn.u[S@jv)rqJ %VMVwGP}K(zSоR2:D'SˍJV?a,[z^A6ב釵 VYn:$Ĉ7aI Y (%PJ@ (%P P`_); 7` zjiƸGbѣG3~L Br z&MGy)p.oQxaK6m2"ߝwޙ˴iL|#hkE?71رckŗu^Xkb<E zag:&#Yk L*F$:!.xm9ga#F8o-%NM@J$ˡ#ҿgGڳFumA\},5Yb6Nl]TIZ7Sǜ0Umm 0ZI`EE\xZ4878qln*)cP /!Q ֹ YYVSsܙ#}CTt'l~X.{q˿%KST룳SJ@ (%PJ@ jt'pEG'E&F߱7z?3_A8 [Fʑŕg}DR\kf;ѣGPt Qh-Zh>CG裏fr.wa7onN,yq 7ǞJڼy(\1{5YuL΃=><#bg\QXzȕ뭷L0 gժUØ1cr~FP:k*%طʲ|5,uvDݹ5g;ix?EuhU@dMqO@뜨dUWPv0-5'W{ <,GNWMf?ֆ[5?V]zc*ZE(d+8J gN+@ݨgMEVS:ZD1(h~²fqaY F"V q/Olܸ1Oqʕ|4ɓ'ߕ=>AaÆ /]w%ƅͅ7sZJ /ox~H>j)%P~#6~ p6nK.5b |[a9sL/% ֬YѣGcܸq." z??׭9Tt8g|m;8` K`~dy(Q}WۿcYz 5 aۊu=y־UA~ח'-U\T-"UT>s?U]Dj䱞 U8м!_""b^6Xqe:4Zҷ*lXEEy[umj u`\qg٥w51;KZ E w"P/յ+LY8o~I1rVJLg#nwg~4e>8&r:&N̓jذ!~0sUW]e2 ƦNjE֑#G2faoa<`?޸w„ I!CL"y/2#٘l9q<~0M~M7_~ӭ.R (%PJ@ (\5k>Kz7Jru-J@ QYV#թ紇r"rxG-_Dn *Ey"u+L_*̟Ł!̶UĈx߶m[ܬ/Z (%PJ@ (%P%@־Un·am;t@DoMzRʼn@G\U)I]ۉh;ty~T]'Bƣ!x8d72*Z,A wr+`Dn_g[աUjSPk>[{$VB:;J"Z҇gA%MzgYHQ. TJj͕ 5FIJˉ">GU.(:3.eEъ )Z}Ě1cq2 08X|Md'|V+qcոb /]Jv7|BV IDAT䌢Ljhb3nC7^?0"""(:^x~:wlźѡC\tE[1sLU\\xw!ֽ{w?u^/ĉyXc+obn֕+G*%PJ@ (%PJlu2XKbj׍nhnؿq]DTusʉ-']zQ9!.3*8w%I?9iDĊc PSbV>A:vDYb?UI,U^%0ʞG3ƣ;ˈYthI0|Z (q xDf"d5Z"ur0+q6/С|i)&Pw)ٗmڴ)c毾rXt;-FR7oq?yҩڵky<#]LtV1uС =Zu(2}&pFbD^ᢢ˷[\pE-(\E+m+M4:Íh :u4@ (%PJ@ (%PJ ˑXHOʼnw(Ws "]SQ⢢XE {EՖ:NuIrz'BJ*/)}m8uKiu6#Va"q6lĕRhU}{ xZ3|MrbbE`MNgZʈ1jZgbzJ%PJ@ (%ofSPJ@ X[nnnU<ģnJJ}Y&r"Jsv.fԟ Ee-["UNp#ũsGvbjZ(طbyt* mK7 -5l؏dHUf--TA7VS[*A~3tgd U덈e; 5N, W TSQfMl޼s>j \횁}rSHO%xݧlP3P{5Q|cl2^ڈVޞU?]U=}byO1耺+CO?X%{R1/`l {JэuT F1;0Plz7ERӦMssGEEaϞ=t[y6lb//:XyYWPJ@ (%8%TxSKѮ];s{<fԩSOǚ5kM}/4GҥK>ǛXQ|oo>^b>Ƌ/0Ga99RJ@ D[ X Q"ud,_&.?4g^9oUTegpGTKg3~qCGqR;d5"DdMļp答D A~TDEj5URPUb-?#P˒{wm#X&UG/<]Vh2\>:gz8}ZEqDa"βK,9.qj(VX\MlAǓ(54DX~Xtdyk\jiqPC^tPJ@ (%PN Hto݋/ĐӅE!o<;٭sƙEa-Z0+ `,>ZyTƍQbEs.SviL94_VȖMA$0B"2Gqqӛw!QB־Unܭq ՗'~ )RJT;*{ݨ\(PyzQ늑je*:ۆ3<а:fLb5Qsض!" 2bߪU`ٯvlώ ҷfZ%@%ǻڏMb&qet>`Zb%vjĬfD^?-:EN: VמeR]X)fqXl^k8d@Y9P (%PJ@ (Dȑ#&ƍϚ^t!]z饸{w^}׼4>Ú?>w~Gow}pUW>:0%C&nڈP!:thxYge|XBoxXTEJ@ (b&jN98,oP[`|[_ )}.Έ6 B<ٰg4 C݈:j%MTNDv"ż\=}f;j 5-">V%tcBJ*G[#ܘ8:b5O+CX\UDA[Y{$V:d$qmuE+̂+TozvC4;4= a~.J/=Iܾy|Yp^uqNOWpUQ (%PJ@ (b!0g3)66M4Af5_~֭Kf-V]۱㻘 ъ? <?ؽ>xE%=b::*|2?*{l[Mӥϑo W k>tu"0؍w3/A!ڷD<{濫V:CVg-waJ ]CMkz6[Ejזx*weYZ WejZPJ@ (%]vjܹfFW87mڴi+_՜wٲe`\N_|5ɓѰaC3 6}m)ĘOo1a׬/y"PJ@ (%p&g2[ šZj%{~:wyǏCq~Q?yN.DEE=E=<*{E15}tH BQ{/:vFFn8@w~RKn-`ȑf=_|[oe(bܙ Wt~mٲpW^ye`c*%PKWl)ܓѯp]V2=%l%=i?.A#W*Ukt.:&%C%b8}ͺ8J;uhS6(?˟kݨ &2M<md`nwZK|uJ@ (%@#b)X[nEzPvܴkθm ]J3 xɗ3DVZaԩ9s&nV 2XYkѢE1cq ёE[tU(ЕW\qqt֏?h(eWtQ<;UaE男NynE[̷֢zLsޢ[\sMn)%g(\}>7opwMH&&Tg]M tz"YY[?I{vEjU Tb?=ɒE*/Jmx [QJe0(x%u9 =p*WT*Uap!㔢hE3b{K1c(R WÇ?5<>2kĈXbPJ@ (%P^t}jС>9CꫯƟYdaм |k?n|,իWup%PJHLL4o}'%o!:c&ɧM{,S/9D=۷e5j(fXod{UfM 4w}hOW܏O?lҿkzge8LK (%PJ@ (% Rz PN/%I4Cu,<PJ@ L-:ΝO\~_sScǎZjaʔ)1/z'Na[PJ@ (%PJP᪄]7W^1iiihР^u0=zixޝmr̻9z3g. 80Aw3{c=Nu߼g:qM7eTsΌ a m͆1cƘ,~"%셩UJ@ (%P]G}.]t} yz:|)%O{=,ZN)].K (%PJ@ (%_Tl(R1Z].vaD+fGyD11#ƍC:u̸.~.Mg-ҏx:qx78d|cgH=O?\biEMY)PJ@ (/7|3oYۄ p+0%P(۪ [tP%PJ@ (%@)#=JmԨfϞM6jnݺf ,@j26׫W;v¶mۚ|xlb栠 #@y>Ycf=3Vڵkɓ'sz>; lҤy0-%PJ@ x H@}›p{9s|[o&.PK (%PJ@ (%(qUDא6x 3t+WHr'͈?>O71rHСCѵkW޽|3`SRR2 Pbԙ,;q[x3+ܺPJ@ (%ȿQ۷o7P=oFqKK (%PJ@ (%(T*k~RY{J:00UTAll,"""2JOO7T(`I47z衇0~x׼y1sd8Aɷ7^0糋)|8jbyx3~<6lx #k`O.-%PJ@ (%PJ@ (%PJlPʏWub+nşz+E {J>^|Ǔes `pۍ;y?p'pU^  '9gϞRؿ?:uTbGO>}E$/C (%PJ@ (% \.,o#YGquQݥXZ8RJSW-).]Mg,d7,s_7Y:g}%gHy NP|;nd)W5Ď9PX:wJH*[(Ez9tN}.OQ;F qs/7%s9rx8hڌ|,noیpSGi3 t{o@g}̹nx [֟3GQ[>nkĨ%e8Ri>KbƎn8lu{2aV#\=N:iS MGAR-_*,QG?Fl~[ivÆn_|-[6΁'l2)[lxG:(q/L3$@$@$@$@$@$@$=xEN.:"Ji2aVv[L)}")kСCIcRV^#[;+e[Ӓ2Mlici9w:>ʗL>}.?~H|IۊeĬ=H1d3En^/őK=u?c~Kud.|S \Wټe8یwVuv_Y&p%ܞ80Q._+C~^u2iD)b gs=&lho~Zor}pa󺹤Rl>mz0aB\wp uqݐ3Ԫs ۼcQRf)o9DL?kIxء+ꂹUps]H$~ Uxq#m0ח5ZYE}\OKdqWŊYR6gO5'KX3V5-E8C~]og0n<^Ҭ'){"auͩR\z)^.&C<@[k3sYYǯGh3ha>Ka|?` ۝%_zM(qֶ[Iu yTʛTfύcFe V,<'Tv,676']V_Kp9G"on>1|A7N17{"Cma8|R2̙[i[DJUkp&q $MCN'e*ew?.>yLŨWu]Jqj͛ݩ7-CNo7JH:D+TI&Npjr3W>+66b{>+z] )uGׯ<.G\F5rfU>371yNgכ8kG sU/V& qlφsǯ5"uM۾錭Ig9X}>vQi݊cR|.#D+/yҡgi֚c 1Mb}чnҲPgA!i:(eРArYs B@&M%5j$ժU;vȃ̓ak׮"E _غuG͛g]|YPmɒ%=zt?{ݫW/ܹ֭sG &g϶틼֭+p=}T/<*T 7}՜'dƥLܰJB(a}6 vKJhoY̡chn۷|>dzgE^خgrQs衫;/UΣ>CAp.`m/\`ΠP]?? 3^C$:z l!c>2P8AFGFή7.\Va5%qE5=[s}6];q0D$8 plY?\sNq:YI^?|t wpB׬e9qVgWx gE2¸"D}~F(\Ove ܿ_:u$lRPc1qD|++WӧlETɕzSL۷bі-[Y$vl/)nܸ*0Al)PdʔIfΜ)>T ѱcGə3kAh0Ns̑ %BJVJJZn-C ? AXNc1 $+-Rq}_0/T 4Voܹs;1d<HHHHHH ynhK|kB 6d/R^=r_-?~DF3Li)('ծl_% J9s,vr/ Lj a7? y+5Ӱf\^PZq_M`R_:W?>v@tiJϝs9tbMMP6~~:~>|+Y6WϲJY#mT;aʻ ijҡinbA(w .WG\9ʚk~<^7pq^]oPj(8 bi@gúvZd]V->lg5sکh}[P\R©SV/8mQJZ& t[K$Il }w\5j̕+:8nbD*U?8gp1駟 f͚އKR^N8aI@̰n/^pAT !DE tpYB[XQxqN)ѣGuٳgW0 >MtGp0޽[C#yN;'D+D ԅf AVXQr' nh 1 1w5/G$@$@$@$@$@$aȔ=cf3J.UZP8״ rXVn7.nȧf@m~0/<ſ8WAAIFp('eGtrN9/K.:PV %ES.c]:>(Zg ݗK;qd3n;Dqs͝8fEp{( qŒcXg ?6[$JnZ>ɞẋK}{7s֘FS1ی8r邹f>3Xʿf\`?wa5e#=}S 7\/p=4g?\|Wa45܎klYa (Ow9_ixaߠ_9w/ެ~!>sk{LjObq½gquu>>o 03fP' ^(ypSd֍|C^ ݜ &٣D"wĊ+sNuAԁիWO5uTu*!BI?ͫ_l޼iQoС:ZJ#GT(aD~P9[>9Bpc \B!^9 Z#r ""E~PC8^{BƟĉ.1SD/->?ekCD)K~nذA {`dCﯯќ ]r"k߾rK# ˾~ͻSLӧ2D1hq8c1g ,sZ*gj1\fr?6~2H ԡC8p];kJ Soүw՗w}o1B-_4&x\$нB)+|1koB -v), =ϐrAT0p㉃yzD<Dp*`ZJJp/5ʭ~'xK]J.%evQ kN!_'\apD%7P.RHNq۱CKB:4(˕@3f4VF)ÍLW ZA_|!u|NYgΜp&B+V,=mzn" #?#1p 94 BD8/wb)\e:e%!𝀇"Ha x`| 2ɋk8իC x@95ϝ;kb D\_|OC΃X5:wx Q"DЇ99q]pFѣGc!w/RSZ)dHHHHHHH pDF "˰adTX1u\6 jU,YTD L`kA,V2|pV č98 \ծ];W sղeK_eĉz OYIUp_ҤISNv0d kРA*^^^r)X~uձcG nDb2쇛pf߿_wR؎\P>bSR)r"k֬*ÍY:iuFڡM6ҷo_3̹yM UT*6B`¿s=mK  KqxZvj/BƵ7ܷouڸqcߕp\ 7)Y9?<УG=/p/Cp΀ęE$@$@$@$@$@$@$@A0 n<9 L/}U&;6vq/+ۀvԥKIu BD8MPzў n BKMKǰA8(b{*%\D{Qοv˿p?7Ǐ{qٜr6Wltѯs0KBH,Rp*ga<2D%WX3@NTBxa"{W-D*+&9Õ~ߝcǎ5X!SZ&'.        aJO[7Bj=/XčM p#Z!PD-BJp7^9|I 8p7ʬBGiTذaC6QD]H+*QpjABi\ǀ ڇKr\/[8+ro~;~y]SpJ$@$@$@$@$@$@$r qdEo(x,v#]BizI&Mt}bݒlٲyﲑ8pDs x`CYÇKʔ)\,B@yW,|8 쏵hߝpGA 1࠲m۶H n'E>ou)1\p%[#nٲE]VܹS]hPF+} ܁HHHHHHH W>nVp@:u!?!e\ 7'0w\ٳg>`Z " %,R Zƒi IDATgTnԙ m/|xa̘16K5k|0p<$Gɿ`ƒ%%!9+BYچ [pwoxŠرc6ooo-)Ҽow        w@;S x@Z/pQNY1 $UЯ㬃*U$xX_g'_ "_B\̙_#nܸZZ1dDz! @$}ْ QtS9ܽPhG2dIVg\+Fm'LIhN4Wj;e(JN< xx1{BYmDM^ }G1x=pYI֜`4NsYy;{KOڸsĉ%pQ!Es=gܹ̹rkVjw•ǑA~g/|WJR|yٱc?{Lɾ}KLbm< w}gYa*<3gΈP/^k׮ɉ'dźX=:!   wG뻠lKj)B7lT-R6"Mr囶E^w߮SgxRNo+M~&Ws#hKs6Odm'U֯<&EJP1byiYJ@$1C7wڀ΍z;#GHĈ%Elս-[&e˖u ,ܾ}[:$ r^Z.]$sΕPBg"čΝ;9ƍK*Uy?⁂۷ƌSĉ#;vB \ D W8;.CrMI"V@p}Ywe%Yw1y6ٹ/*PGmq(-݉%̉;0 .3÷1J;7!I&Iҥߛp'OL<՚5kԩSAR:}O^„brN\$i?֭[Kwޒ$I;O]$@$@$@$4ny-?!B8S^L}CGKJ^2wC-AhVǎUqs &/um`n5mlX"D \\Vc|8ހ+qx3k\5f}8s՞Rr7l0ٰa߿_F-M4bŊeРA… 'Jmÿ~G7\ƍ'p=}Tr!:tжЯoFڷo/}cvڌ=F_o޼)X; TGeS> N>]̙@5k&ecѕ3i̘1C„ sΒ2eJ=?ױ[vҴiS)R\R?h5CB|Ck8Ǐ nxٳǖkLcIސi3 {,{G;YX:.\P{%zңG=''KL͛ ' ;wnSKmVf͚%-%3gNdPÆ h"]; ]zUۄ0*38P " ͜9S{%Kdԩ#F BD0,v٦M?{c-(l/Yip#Y 8%X ڃޕ+W\e@Xؗ{)RH*<|PYV)(Q"߱b^<É, B=bVk K0\p%!.)zap!%ٷo 쟤!D@{ B؆oѣGe[',jo``Ae6r.ĿvH`b۶mzBx}xbu"n"ǎS+$ Y@…mv=L.[h cgqsi_|#_0$@$@$@$@$@$@$@$!pG76n+VO\8>8W7(Ç*P^n͘1V\o/Z_X?O>M(/%ԀB@ǰ7g\\Ǣ͛u2h#~2w̱mw hhr.W?<5 @ Wm~tM@|Aի^ [tiu5Z`x!mڴRV-z@겳8'ܤ~ + ve_z@$@$@$@$@$@$@$@oK57 \DVm|͛WA@y<rR k'nxPn7oH,=DXnJ~Gk.ucamG5n:r{С7D-W        Wp5j$ݺu'&P 6L /TX1 ڵk'?XXOz*2M8iP/|5p G[;7U,ТE ۍEW\ZnP[|+ʗ/7&!­ӦM "6mqIa=.OtEk֬)XzY;w޽{ 0ÓDٲeu."p'?mX{.w}I>(Wkp>od(ϊpr޽[ƀ;dɒfڎ:        pgD 8jOx=ڥ, j85 끗_P>pAY`)7΢L2}ԯ_\ 6YSժU7۷ot;nF6h@_TvGw FqЯf}cs6mdKo:97֎[lw_cs OR!g5hX+_|ꮂh&\OC _\ƒ .%\`-y1P!7<ZF $?%       j߼-Cg[s  7!ӧ6oQg09 ZATJ2 w)ÇW+rzSV_`Y'gk>e_u/e T r6gew%    ֊e @ջs @0%0iҤ`9&    (\Qc$@$@$@$@$@$ؗA}GiHHHHH>`?ླ$@$@$@$@$@$@$@$@$@$@$@$@A 4LHHHHHHHHHHH>do߾zFcϞ=olHHHHHHHHHHH Wo*$           p+            A hmLɓҪU+ի̙S?./|wRHiԨ_ŊmSbĈ!-[DϞ=ҥKѣG(Q"i׮d̘Q{:uJnݺ%.\Q7|#5k֔S/_GJ4Yn߾-҇a„ *Hƍ%TP|rٽ{$MT&L۷=Z-[&O<Ѷ[)Yd A{gJ6m{*}DQ2\            T; ׮]֭[KN$o޼z7J„ UBLR#6l%J'NUVIt$EfѨI&*Z! ( 9rPG.*  ٳ 2dP mp!_.[nf͚IСե駟ʚ5kl9A "Aq-f̘w            Pz2h -        9uCz^nr$Mǣ={x]6 >y] RhQ>x}*jc ^^^rJBU|yzUxam-͕8qb5g ñ/ %ᮺs玖?D)A        otΠ}ĸryٯqPM1XU|`'hԨԨ'e[da\tSE.qbE%PD3gl&L`;eʔ+MBɔ>i9RvX0a uE/ @!(AL3 2f*‡3BR"Y` '& nǍMʕ!fnsH-KVOId ^ )R ̜{Q"ѓ~OW)eҴAIٵ甄Z^x)&ItmWn%K$B\A -;y.UsγMێHs&thɝ3luBϕ=,Z[F"繋7l[s6]D{KRLN0}TGs oiӬ:IJ˦q9ovB X*0H          TWo2H@ IGsۦ/_ȕkw$NZnп5JDoĪKo)~FL#LD%a@.UB8O:IoĆi>Vh^=^2ųS !S6^z[Rr~I,wHMoIhDWo'N]6._cEhdRWY` <þg]vIldo,Ǐٓ CLW_HVYtDN ʞňeּmҀFÚ5^9[$q=݈ ̛'Ome}V x^+>ΝtBKVFxbWeeN-Qhڰ٢IY ו_$Qliu9W}rpPYו1:Jk}VJPZ21w`@)B+W(q         NKNV(W24l2JU^U2:fSۗJ(^ "  pQlmؾ}T^]>S3g8[i7o^eر.6wlذ[mm M tWe򚵀Vo8 foRXZB͚16m?"+-s h[9k[1Hmx Bcf)PGԬ ,X@t?#GhRH_Uڵkl^xaޖC=ׯ/d˖-w A*qe\kTG*kĵRkeyUˍk'0rѬT\ncSjTXjTX/)-;ה cp&в]Ӳ驾l]?~kTaM4|i LUgϞɀ$w6*G/ޫZA$UӘ4i.]:P={V2eʤ :@;uTڶm+iӦu)         OP*rH%"ʮdoK#Om} k7X-}t%6 WA`&N(p!_Ķmۤy\SȵbŊ+3gʌ3$H@v*)S{%ݺuӱq ӵ" J5k&eʔ>s]]V=zdjc?P7tPY~'d =wk7Jҹ^r(X սt5h֨VzN?uB]Vkϣ'.زrI\`}R@p[!ƌǀ6lXO~U8ѣ >(2e@D DY5oj3Je͚UŊSW6s*\vMX7(QuԱ9ҤI.$0'OclٲE-Fȑ#X4w\Z`\}#(=zt} }ǘ޸qö z%KuYdʕ*FIáer  xK ̱Na'XTpVY}^(]@IF1p~$@$@oenwε,YDi6VK¿Lf L5|@@Ȃ OH0<%ֶS $w4v(c_1O";[? .zNX>ƍ+pD(f/`?!{'\e~mhn`ȱdɒ&J J3m֖z`Օ>Y'#v:fB%":܏HHQFSŅAX# p}Fry$KL7C[b g3H-[ڹsK,e۶m(S " uo D+0_7ĉu˗/q` $SU޼y}ޠAn2HHHH /J, EYOP1$\S>}[`WAhZl)]tѵƙխ[W(wTJYfFS֭Uhv(E2= n]_.}Q PBtR[SX?[nRB m8y`,L{~lٲVa(yءC-lq{Lws@F ;*Hr~ijv+J>b+WcK(Y6 ^–nk8믷$  xFBW~58 4& o1 %y@!Z;ۇ.4y{N/~x7D3P 20=@y1Fp?fΜ92f={vp 'K`&xСgO Bk B@Xp+s vQܹSKb3HHnܸk7½(aι{&Rr Ձj u|Dz`j{I,^[pxAUj!;2q焫ߎȱyC0(f̞+KfpձwΉ< 'o*\2_n?*zyyS˝ZܗHHHHHHHH(ҥy/I&#N9#\y:ĎŹi=\F6͐@k\QdHHHHHHHHHHHH p]            @UHEHHHHHHHHHHHB W!`            (\QdHHHHHHHHHHHH p]            @UHEHHHHHHHHHHHB W!`مI?}αW$@$@$@$@$@$@$@$@$@$@$>-ׯŏ#Gș3gx׬Y#=ruI۶m> NUUҦMb{9iҤ7={& 'O)GW_}           p^J8p@B Boߖ]ʧ~*UVc˗/my~ M62qD߿n?q℔(QWo.k׶׫W/9z|rӧL:UJ*%:tW^-_|TXQ4i"Oj*UTTIs>|S8o rʺСCdeg鹺u&~vv:uT6llذAE'yڵӥZj*UVϲh"ٳ|'2ei׮\rEv._lS`Az/^hoA홸aҼys/QF~N<{n_{5jԐ !oĝ;wlwA} $@$@$@$@$@$@$@$@$@$@$ia= /BU t֭[Kdd޼yr}o$~*@7n3F&M*VqB`"Ue…>|x9xY8G)Tԁ3sL *C %JHKݼy'N,З[npThQɖ-99⧗[5kDQ!g עE ٳgԫWOeEٵѣG;vWdI &2bޥKm]Ah|#F .]Pذa%wܲb _a˖-2h .-K(QlC%gΜ*&Aر/^\ƍ%OC@~ [p5mԥ/ Z! 2HHHHHHHHHHH]]6"8xMh9sf_ƋOK6 ً5Rsh\S#Gʌ3$`,Y" _;^Z"ET4 N".YS  !Y n% A Yb}ĉWY!C(K \VkE)wV $q$@$@$@$@$@$@$@$@$@$@$(\1B BAT0dn[@>uwaPn9 .lG@W(3h~(U% 9?V/V!-gnp_; <`L@'OzdX qnQY@հaC`q ^+ $@$@$@$@$@$@$@$@$@$@$7뤑[#% .1w\tI/UЃS Yw DSN[h(7g9rǏp*a]-Ğ,YH" Į]СC!B%Ap:s挶30?ֿcŊ%V#5/_iӦ1ptI?sJ8|_kaoB09a.bD$FP !^-[LAG\iX 0?WB]sk03 W~G>|TVME!.p5a ?XׯkA@H.-[vjσKK.‚k ҥK]N/k֬ҹsguLA1Bv9-Շ 8UVIժUK>}9`ǺuX[hX !yh ѨQ#֭XbJ bͫe˖z߿GTTcgX kApr6mhƎ8;x`!a ׭[B_=oVWX _A~gs>رc˸q㜺$@$@$@$@$@$@$@$@$@$@$[DzfশzDn"vS ٳg :T._FD7#GAc"kɞ={?$ ~!;uXC$@ԨQCvAH$ ܸq#PkVGB9΅\Bc=uSKdJ?V'cC0y<ߎdJXl(u0)Z |ei!k2~ dž?#NHA#d~oԙ>CQe?yySmΝ`@whq_        p qviԬܾsTFy!?"zj*A.3ib (5       9=w<\*W(+ɒ&ю{@f̞'wݓ\ٳgInjrR|iuAM>[q<}L;gw+9f\tEb0kUש!ӧU\.燏tiSI%tftm57p̯Qݚr̿2hhiXLtjB&Ir*dMfG/U6Jh#U(S̓h^[J)SgJpYs`سL5Wnݹ#|H&L%paZTe}i' .Cf Ҥc a„_mO$WҸ~m3HXႲi=G$Axɦ04k%3!;AR$K*_7+cTid¥RT1)Yp:B؀;$@$@$@$@$@$@$@$@s+/]Bx`nҎ0Eg,Ϳn${͍e#3Y'v,{󶿥engrZbƔF-Fs:y9&jU"19-wJ$|ŋנ8G3Swֵk7'؆leJgϟ 8`hQ8b`̯&3 A"8H:*wzKhQ䔙*ܸqKgͬb&P vYQlIɐ.9jdj3|"edVPeݦrgp>m)IAIh\1{ .]a~~ߪqs#CB$ܜ[n9zfM*X%N@DU=334D,7nޔFX}Ǯ*=}T߃)RD!lw玷܅ VQ +W͵p-`aA802j `^ZIRgA03+N        NZ,ʜ1ސG|eֻB5DĈq@Z|YP/bm6 l7{gJstnZsc P91rČIǛ7жt[Lת ':9-8NFeҤJaă[S ms MivI53To[(*IW~r!rP4H1Bef*B˝SKMֱ\TGQ |vw? ~vwww("9x-.,oއ{9svΌu,*/-Ħ\ٳHO$4^8][~:mA9e5w~bݴk-leYHgo%/D'T#DrQZ+NwRHΩchw?M{* Y2eT~T:ɒHG]0#vvE(H+$)䌥 4_% 7%]deuD G>)Ih$ϊIÖ7wδ (!{ŃSE^|k p$rPCG7_% {HRI*Mv)aSqNwK^|EjN.Q pr}Mi,z*Zg/\s_EVE>tro4{ \5G        ᔀDKϖ{8 *JM=?$]'Ng ^A!%m*UHH:7)QB[?ĊӤ@JXv*!QZٌ :׍fkL9/T(Gޠ\Jjd*Tqiݦ!mMPx?lh5\޺c'JŲTpZ~3]^('3eH&7  ׶*0 kuӖ{(/_YMȖk3o1XWa@GGG8@ ?̶i&rpp7Ff ;i .[nŋô۹ٳgիWI9=zD={;+mqh ᅀ5jԈ.^5Cb'@8"ꪢ!kVi`S-b(}j){=-ZOiSٍ-OGN=}zv#GNM6E͸a H$ӧܾKӲsZ[o@W@#         tWTWIQ: ݼM"C Sx򓔑46 6Bt        t_~|Wҹ@ 4|􉖮ZGɓ%{U KP!NQ @Up xF5@$M^ʱ"%@  *H`p,|8@@@@@@@@@@@@ @ )@@@@@@@@0k7G4zt^xv=Hݤ@ (G 8&r&4i٢UX'x    8;;SÀ'p"7SԨx/r@@@8=~BX`TY@ؐMlkG,Mlrww'///WRv-b>2YQ"4u/B@pS]ڍ9Zu;zC;}3D+ 0M`(ctNO RvtIzșQlYBt)rŋKoߺK-Dg/\oe4JljT}_ЇQZtHVV=ztj޸>YXG|y(Ax:k웧-C4Tb9Jh@&`a+Zy{^Q ok*Vϛ|J+l +Pӗ/_UD]T)Y(Q}])fOjӼ3[w{j&o)jo\ eΘ^+EdaA1ծ(vgv=z5kTO/HJ>KW    `&ooo2O4/.@5@aesW!0>Rj(F +ZtLIޠKWS\Gĭf+J,>VkWW4 CݠJSz͊)%N!W8R4_O% 9pN@%kC[f3 \   zƏO3ׯ_izG[5)M%d=SZJIkeEAY͛֞Vo}/Q|&+j׮?~rQΜ9A.j5J_|h       DUWbŢ;|U#1wRb$.]:Y%@HpDD0͚5#K& I AWA}@" U2PL7MSCP* -\:Е+W,FIG . ) @2Z@'*Uʔ)cE=J?(ӉTx-54HF7߼0eJN.g7*^m$ŶAg?o0$*a+ vvv={*RqmWt==諛;}NgA@@@@@@B&@@@@">m:{ YE4}PEe]FTWCQ)]j wP     ,]sfE:Ңǔ4q<w=z 98DTDvٿEс|Gֱtl4G-;=tllb]S y,gq-{T`F0 ᶆ)L/^W}ƊiE km+>i}cez8ζoTaeISl;SLs)J]TTIZYЩgJhkC6M˨5v25NҵO(]\[M*W'2RR| ΪHѢ/&J/Wس6%O@euǏdmް\9Bn9Co]}|-],+ ]erCyo]Ӷ]Bcn ܽc/Zs =}*c p۩[p{H;*?0 v):p4ܾӠ^o4vfq yzbӤM7-daI$yO!}MglXBrRҥkǠcIeg'n(O4J(TV,bU)n~861Y^=K _G#qUYD;4g>s0e"LM*ɥīyKӪZXÊTpfޮ͜\*1Θ]?|I&뷞<9SSXA9Q p t-%BKcǢP6]w}Pqkhơ7N,5oFnS6?} cǎwaVm8InD;͜ǵJes7kd(Z͚En93J+_*'3xzԅ[FSkҥuEP@@2dISd&|b ʚ)Y*ݾ{-DRGC!ir> I/0"W    #u!B [p9X4jF<{;Ҙ;ؔҧIL gto!Upe5~hcArS UT]CGlbLTQtSji]W}lnaYZp$%whTJ׮c ԡE9f\ZIse+ NlnWJ?6 6it#%\]gMuKϖ](K*ɘyyy=(MD4l¿mErV•uj%mD WY2&%;j:9:R?"fIb:UM˲(D3^**30<LN$?0m}> q?, &ބX?2OJ:ً:Dljm}:,Q91Ғ 39{expb2PjR_Y@K dꎥܥ(JW5K,IJ#;_m:K)RlJ4͘굔lh%'JK:{> HRI,md\$JL|{) {]A%yTw_`vZd_-Y$:&t*ɔIؽ/b GD+KA") EfO'`qUk2^7zCR6 IDAT ĤO6l?Сx@@@@@@~+D\V @@@ ";|da@mԙӯd$]IRCiԍ\Ϩ$5Q"T|B,9.:G6hqƏ뿈$MH1Ibq5vP#t+f?ԯo=kK'QVklKWU?"\S R̆E]ͤDIS~tkNIfmmqA=9RS|u5Srrq^&i ]ԋ>BkΕDpqi_sALܴ$/􅣱D@X *,|fvXDGq}Y-L8haUjJ(.Hx2f . $&>H]C}N$mKБz|jd'7dgk$wEj9{iʨf6yrS<$wJiR[(~΢mU$raY7$PltqBPD,& i6pE Hzb-6}8W8vdğ^#?I }_wutW9]MG Jʾ-ΓD YT~"䙲 CzZ^ٮUZes;RRaEeJ,akLlϵ]K^R+kUOWu&OLP+I]2P0@@@@@@I @-ܺצM6nHzh ,\nݺE/]CqfϞMW^%oߞ2d@ 6 [ao6l@>TOmsvvӉ8՝(?,SlUk]2f,VNni}IWx*V0҄symzS)X% bjSϟ?=StQFtE~]_,@~'WWWc[YzSl6"fj=q ޿OE)::@?   YLg}p3f){}t#r+Oƅ@@@@ 3Pf41 jԨQT0dڶYE;R Y@@@@@@4WazyF6 Mє>BtyIQǖu)A @@@@@@@ |pރ!#KJqbcOiU       1 D¬@@@@@@@@@@@@Wm/DP"bZ            @ o+A@@@@@@@@@@@ pA?M`͚5t…?]x1P>ܽ{^xctcO?`pl@@@ZjQ~ھ}*V14:wKlllhƍj"EСC@K_ٲedɒ;ڹs=I$Ծ}{-Q>}Dj^bcǦL2 L2JcǎQ>}<{;ׇbXE7{?ѓ7l1^1_oP2HGoǯD,F|11BϠc@@@@@@@w])vJ^^^JLҥ 5nܘ>|H6lAсaÆ-[6‚$Rׯ_c̣Ghʔ)XoӦMԺuk_zzzխ[W d9rP(Qh5l0%V-_\E˗\Gӧ+L3=N8fפI4"ݻwOElլYS Xw/=t9zZuImtJ:LrZҪ 'h߽}eV!zM?5=x>F?QrS _[3y%qū4v&zԅЛ2gHJEo\>Ҁkș_of%Ȃ7-\qvL,˕K6c{8Q\哤(ly.5[^tn"*ؐҦSj65,AORH:{Mʝ#zϰT(oz~qX-S&4o~ūdѬԫcU]|E|ÓR$OzԦYR]C:e+e4̻Hѣ|T.Yc$-ݙy'UKySGә F} hAM        @ NjA@@?3gVo /^<]Q T4D>ItV%)%S߾}S$g3fTX޼yFkDw'''B'OVǸ&]tϡִ{@u3gN:s;vb-[ӏ'VBr.&OD^ C'gɯō^rR岹-ӺE.a\%^j6L9swRꅨvB[^_&T(?e"u鷘z7/GY4f6.;dc,ܧu=)V,+5e 7qD$Rؒ5Ghd?ؓg)͒E!sAKiϺA~1E@Ǣwԫ_ҧMbt>~g/J5عmYя&Տ0w_՝\EQ.>ּt @@@ xLpPԨQKtßJ虷`FC7ɓ&[$͟U^C?gmY[{6q=-)zCǯSC&;aY%?̚J7Ύ}ve8y;\OG |շP~hM{(,1i} 3g?&(sů?q|>!)Q ץ:-֕0-@@@ H(zHqѩShԨQԣG%*ŏ&L,8D|L~("cf!i %2,8&e7nEDTX`MRϟʒŪV% @?wUX2h%&O3&^'݇R6t} MRh%&bQ Wo}#j鄤JQ.sӿ]]"թV@!V$FJ$/K Oso}D+1I37gZs R&UXlԫSUjsY•>y@@@ <aI{F"Ǘw#k~FL0qhBkGKJ4Ef`\u " Bӑ>1-~pL| BO(T No}F' W0 C \ŀ+   HS1TՇTͫ4iҐ3?ޤ۷UV̘1EVTjUHi֬R*a„*KDU͟?f̘栙DsM6nU`F~wؑ/NZm$k2ߵOzI_, 7 ~XRӉeOuk[I34I}jIN5U+ʞ$?x~W--T9< D.*GyrzB}3L9ֆ`d_j`W[Kl#i,I|yMbO_9]7Qa}4&Jha@PEHrg Xӥz\{ͩ((\5S1ȱу:]HJ z?8?[ly^:tC_).gNeUrz͟Y~3ƶ!~vL|ޜh]L>xreOMߩ߈UY;SLIQ>xN?8ՐɍQS7s~)A|Ά*SL%ǯtJІTǕϽ1KMW)3G;<${B!$N"%Z"s5eU$+otn5u<3ūwًZpj쪜[nϜK"-@}$ ML}>wj/dH懷KN}Sҽ]%D2G;msQ|%M<;ڶsޙ))F D^d_ IG*CxۥN2}ڗ>hgLwS/d.Em욠BR1k)#zPc򰞱 \gx{]2}/fljsAҧf䀊Fyʨ=@hFE fTk|sܔ+'S绩} YRzJF;jRK$\%uu1ztK?}_f;Hw- =kgG=ύR~8 6Ʈrݕl X ~?';^C6DbR_J",--Jy3`D5oޜ$Š1K.͝;W RO0fDwIK/Сd6mژ[ TpB5ڵk%\]x^z*_|*-YdJ3f̢}%mN'+Pa6QxĚ4kV?Ss⣥ Ue\R݈MCh1)96) Iȧeuoo'j|W|G%ˇ|Y64$7XM.Y-a4N)7'4s/fG>6    Mm7=niJ7ơ=krtuK_Lk,Mm*n=u,;HY ~249J^}_!vJ3Ti7oiws>>nv7SdlCn:~5+kEZR;:VQ}ǰ/Iv#ZJ(fc< Ut%\dß$M) ?RuzIdK jQ!8pʍsnnv}{ H\经/8uvY_nTKz}XGd'ڊP0~frQ%&JYslz^cyÒjۣZ֡7UVeN >EOMJ&ޑy]gO]J<$WYI!)̗ϸIƬ,]ϳ&GD5A<hj}G 9{^IBk/ܗp&bQ~sʒ1۠ ھWi1u75]es*Q]*}vcu r-QH\S]j[OJ%-, \0Ou־|>}oI8i$w ԏITjT+@|qںu+mE }hF|87%?!eM6%I|Wwޥ,Y5_b"IDkxqc+?_^},lW?4n{J]pdZtW O/):eBխ8Yȋr+MTF!OML'_2򓖦RNJr>yT"%MXZRs3ɗm1!Oyj&^חPϧ0>;s=I|0)X*q1oEtҦ"_:Cg`٣=D eRSr\D W};bv^< k\r̟%m37.4q,ES>$7۽yJng-etvnvV7JGfTvG䱧b}&U"~,Hׯ&fZ=0\ieMȘ}D7yG?)Ҧ\D+m=P>cH2,\qT/8}C6W6sǺtˣxϯnJhIW(Nj٨J=oRcMJLk'-=|ZEjI֤}DE]H@ ie,E!}"ǘڣZ"^:=~1yMreK"2MLocF9 g}/窈Vb"8ɗpeOa+9] (4vMƕ4!t,?ݯP'9L۠ kRXd a%"w •绱}Pjc~g͔ywhQiИꚞYS畿)JhL4m-7Ac"̜>&z HjO>+RɗXb._IWX1%(c3ĉGEqID_DV5i҄$5``ɬX,&cwƥjX)K)O\yBUX 5PO eRJ˔>AMDZ]ؓZ5)kc>}+|CtP\lX:'ɓ"rڙq7CĚ5(N&&,OKfRThirͧm2J|'䆁6:b ۫''OLB\A!^Ӱ{^UmR X?)wP A'    uPDέ+g @6X1}ji&7JxwU7ٻ\b6/iWK;]VBH">) }п!'i!$cѪú$ XDL h̛.^qⴄ&YoQ򏯹c$Wpڣ+kfA U懙Ĥnׁ+4iOC1NnKWY4fɥX*MJXp9fjڣZvֲ/7YG3d*2H̿u6w]K0{)?%Ŝl먱kװwۘ,雴oy_pV/0$9]jˮ*KG?<@S6979صϔs%]e?:ύW!kwjNv IDAT6Z@(4߇pt72RJIҥKU)///%H2 .!ޗx$?V/DY4}=9('IVp}|획󤠾Oi5Ε[%u];41J{Qh&imG3ߖE0 }4Wqm櫛<9SSGƔIso>]i^1$ʿ1=|uնL߼ڧ9AWA%@RI (r3V7֗DKb* %ɘ:R_JRItԄId#i/=<$)D $JCI:?acjE>8irzɤA|%"םϰ\`ʳ#&'uDAP63}q5VzHC*DmIl\LIefdrM6Y'3f"`Y@{4Ojq=5*9Zp*]KޏCR :[*{ڨ:V"[RVJ:9OD0+w:oS~9NYRjJ7 lT I(5>|tuu7ɚʺ;}ñٷǘ/&_8qy}UT" 廨D ˄ b+\L.yJC35'}A\6 Lm(LsHD.*ᥥ3w>o,(5µ&ڪ+Ԑ:C9}0w1p)Ĥ왓ēAc i@&qG%5eg#d/<֔DH@IӖWjUD6jee9mf6߈*c j ZuRrX_J1姩K)iՐk~0cW4aécsQ"u(JP+;ל}\kҿHNYvCܺ׉' ZESJ" ~<|GiFΝ߄gϞMW^%oߞ2d@ ML`Æ Crppq0[8;;SQ̡ᵚMQ=7&9Y&{uLCjL'''UORJ;4WWWXO{jo35E8HdžAoѢEi6]Hu",X5wMx *v_~׸@\҂fv}21fCgy}!=tV%K$E-Z iq7L 3?D`h?;ldA@@ ض" -L޹Fs`d ;Y;4>!\Հ/I2d`pMᴃX  }lJZt'/{fwy_GOޘj8aqnˮT 1i'}˽Pǜs$6_뎅u.{ۀ53SV5eq6x dσsnߺAM:C%k8GAHooNk8t:y) Eh]C2gtGF˗iҤI$I-DѪ^z`or乻tg/=SrnT0oz/!یq-(jԐe'"ҵK%Ow9?}G]TlRVm8Iw1jjpȜ}PųQ7ٱ2EFw[W]ؓ6(A]^ޝ:^Tr>jXCʏfv"4aF,Zĝf7.jdCV3D mܿl#1<{KCԥESdڰ, [zO[vWK Iih_QHk7R7[Ȩsٻni5?j{]fo'仼ZgZ$}qM 1Osk9Rr%.S GӋtFys]}ڱ|s$1tcjǥ!OI%>VP׶b9!}[vnz^J3𦑓7ЋWËZ4,AQDQ~ IGM~#VѷoɍaD**@qĤnи!g4s.5liԀ8cswy.^u!b[w_g/Q3LD?#KK =!%"z^@䢆}T+HWo<\GMH{> 5 "bhV,DTݹ:LSI~r[J81{^7xV/Mߘo"JeϒLYZؚ͞p_rz`aaI$ExY2zאe*DnЋQdvu#k0s+-~7/Gcjo{xxd妮G6#XV9ѣGcFlHgea+=a6uv3p I9CWFsXncE,Mm*Dnӂei8 fbVc7R^ݖW6+09W)$mٽiJ[~36*sܮV8dID+;UtCoAٙ6 T(_/ޯѬYKY{mh>~ al%"W7_/֧A$8/qX D57,vlro4ovL3;m"d"ΟJ,C}TX1 Y"ii9^,_7%Xj&>׃zn-DQ5_f2F6.(4C;v3|mj@R۩3Nˢyuu 6Z4ӧIL gtPչ)\ST [*iVk65ԚUZ<. UI#9Z{%5/k&+?̀*WAㆣ@@@@@@@@ o4;̡7TD@7J?|.DbrS^"2Dm*D7h%Vl.Q̤H+&TLypD&?4,iE!BX:k*EB(*&HDY7ׯ GN=dU-7$꛱Zʖ._L&VDrGJq6~l]Wr_L"䬬"f2EK2ݸLIĸAc( Jes?^59=&,7GCGr\,)GmI4D):&}TKCH1D\ۂE/_U/_I01S{[ ho\>qDNl%Z,.]sw:"mZbP#ΰH!柏XXLR~ײ s@- pNu8k0G< >Q (;C35G9rqr~MUQڧ)bN3mLs*c9^,{Xn5LsnBi6WfBCB@n>j7%DH6 }妤Ur[TjqNАu|gL 7}1ERɿ95X)1Ǭ,}߆1JLj>U&Lrd!wReݑ`%YH%I'Qj~I4JSfL]"Ω蒩ںRED~雤 i¦֏ϜD?sNokxxYsf7MQk#Hȗ_c\Ҏpkv(qlXL$Q>Rޝړv@~"&*Kg46jE{i2%OTj̀+?c{!un-ץNH+4iOBc&n"m4Btܮ}Lr}?gk?Ʈ_"LKĕf/oMKc >ژګroH8|6v .͹f\,(![1PC1G@D*d̤]8J,|XN9uDeLD Gjܼ"nI*-ŔLiˮ *Jg7&oJnL:wW*DXM`C"=~u{|bGBD+ -ur155S]co뺐9.Q\vI?7o^?n~ҨU4|b܈,L 3ƿ9UGE$L[E)^gRRzKta"NAi>4 pE~qzg"I$"-Ԑܹ:\ǾZpz}i~*Kox}njATruhP:"j'm%=g+=</DZdɵCULj%4 D,u}&{@"\2Ԓb"_5XǠr3S{UZI]=e \äSL5, *X'P";G*t_5MTѶ7͝9{>'[6.i|qXy\D8J-L|չ#,--|TwqƓ*Ԕ-F;ϥx/譇cܒ}*`/k~ =۫lmhud4K6U==c{|uo)⪤\ԏ4gQOkkkF^`iFa6 `@`t*aa@)C ԰a˰͆ Ç9;;SQf?u0D24vNs(tH5jԈ.^ONWG#$@_ +Gil+Kom4H,b(ؐ:ThQھ?M#Of)cwL^HRED䚊޾+H7+Q8Jw)YuKYaNa jO7FSj6E{j{AMg/:0s ݸ$TƒN_~@߿{hݘ_GOި^|C뚍E ]:~ݼd{m[_.$"-ko8^5?uܱZ?K?"ۺ-]s4"Os \EЅŴ@@@@@@@@ 2eZ(,X٨~B͕[wswuuiSeJjMp@>H(QBÎFOߪơaS?|E.MZWc[Ph!@߾ cgkAQmeWE9 @D 18@@@@@@@@~+֘:wݹ~xSũZ4oő0=/u)AV>SEA"m}uE%Ԩ-F!ƱSw(QBݹ: /&74aXJNn;;VP7l;SQfa:v6:wrfKE/^WH@&_{K_߾}W{x̠F1uI16Ѭv6ydSJ8>M4)}Z@'Rc-)SJ XL>QxWW;;\7?y/~j\?k:e"k |LC]\?Ӏ5չ&pBi#TJ~^1b7ϖūwޢa T6ת8> c0c߳Y $"Q"C$[JvE EY#eOmlyθsgu|{,}?G2Y9F%\I+cA@@@Q`٪?mjN恮nh(Shl0iƍfLwW^n9N$%Sv0AҲf|9};gzyrQo/[(q"o<ҭ`}6pmQ_|rVPm3`|641v hI(r^n9R+ +ʖC>6 &7YrhT)0KB 5IO_gKm>1I|oԙwfLul\ ?kzˁCS|Ї3Y'_oU"˸F|DtE} hSv/u7]<%fթӅLP-T5kiI4O}6g L$OHf|&2> *rgA!-M=ìu;==HcжZkJ7,p/O&R|!6n;:leky&sQ^frrjU mΚ]1Z4;,8Bj_ÛOB&xc4h%ut/p<}rə/_`]dJe]-SKӦ]5 y#dY !-U*vAː>Wի5Ҳq%Tp-V(eu9aW^\`6^Zw/1YiɢӢA򴩓 A5;K} 5 sIdƞdj / Sp7Wb@@@@2 ZB;;)ѣnLҫ߶arl9Y_tMvVGҭ4(eK呟Mք>wC9t<0Lь׌wGy:T=@\u0[jpəHYt:2VB'_P-u993 }< vPK-YGdYv>C< ~a=|5q5]^pUǤQ~ Yg۬l.dQ0MF#~q!}Vtksֈfva_5J=g_,>G- 5#27M \|5c4k i5W;_.ҋą  qNرc2{87/&  > ߪ5HqŖ OEu;rŜ{EAn\/,KUvR΂ o,~'RqZ4BD_z5VL h֌Lc'/3L4[DK,+-WMVYВ8ϜݣEϹҭf}4{&h9pɚv˓s߸yG3h mrR49 mu6wlۦgY-z'}Yʐ/C*4ۀơ!*~fh4&Ǒ4(AuY~ˮ ]lLDCW~$3g}=U&Б1Z:ݰlٗl+_?vg,<S?ڏog3$Jς7,wfX:^ m&6FshsO"8ȸz 8W͛wM" @%myq˜AU>dRbaiqn |;m{NVipJf Z63殓Dw2@Ut ;9륟-{Y?zfQDn7g,hҿW}_cl;Fݾp /]&N[i5YX፣YeȨe9S*J1wkHӶxRpvyszԬ3T55)2`Ob F lwMR$O,5Y!YYY(C(Pv[X9plO~m39}ݮel&ncEsdlKsӳ,1݆ұYkjfJG hD/S9|Գҟƥ/1+( VmYf&hx cLF%ӭ*.,hɋLрj緧Y ROLk Dlw\hɕAnI9ulۓ6s`Gdoe[Cڭ@5 ֳËv[ȠzV%EĒ=K:n"Lƨc r %a2iӚ/i^P;3f̐˗˼ybCzA1 ;Vo7?)8#f1b,]ԙqǥAҁg 21\P@ Ї6m ȑ#Ҹqcٲe9"Yd:@ T/߀a!$YN/8kK.Ieޒ+_p; 1f+x|%yo|<;/gǾǭrdKj6h0Y2OB*]\Fզ|k&SAķ$ez@@@@@Oף'Lө9զSƑJ!4jgƩRqdcO      2@@@@@Ș!LqbB>{L繘mr3 @c@K@@@@@@+@@@@@@\BK,@@@@@@ p=      \b      +@@@@@@ p @p}___$#D@@@pkn=z &@Jw~3ﱶtrm,B   \Ž5eF  C)RmP4TL-   +w]9ƍ .2eJi߾}K    pƕ4D@@@@@\\/C@@@@@   @ lҲ`^$#C@ N $ %\1x@@@@u&M*3dd  rqrK€@@@@@@ pά@@@@@@ prK€@p=ooo#B@@@sܒ2!@_U"    @pW      .!@%A      @@@@@@p W. @@@@@@      Kre`      @@@@@@\BK,@@@@@@ p=      \b      +@@@@@@ p @@@@>@(WL@@@I ^x4\Ɗ DYU i@@@@@@ :\E"m  0K    8<@GE si%&MsbB @\qthL~\"B@*<]E:vrrv@ x;wd۟;u'{@l ;Y, @޽+lfT)D%.E@g~c"   ! @@bY md;;Az>gէV?HY4>.F@@@"*%  X]V%I鳗+{zԷ$N-/]oMkYȤV˲Rɜү˒+LmٸZ^-k:|i^"6hR}-KZҒ,i"޾_wJN/Jt)m{@@@@ȸr`, uX,^ݻBse%a};a|1uɜ!Sm 3On挩M&mnam )    + rUaL H, 9[2x9*WKHB֮TSEv@u5(Wud[@@@pQqхaX oϳ8 >n|XagK,W [ŏ%m^fB*E fuxV@̗Eb;Ü~sR@@@ E@@  fU9ɓ'?~iޜFd%ɤ  m}bz',@ȸrΉZ xf]iVըQ]Z m1\u@   wZ L&NA@!@*n#@@ F4Jϱڶm`2eb?Oht׽=a@1I|$w 'Ic}0 ľ7G@J@ZhaǬ[mV`@@8'PP62}yLj yZ*s肥McyaR{L:*fRɖGd])6gT6gj_!lyL %7nޑuI n/I~OɹW$ɤzKm zxyyÇeޓҼ8@L]"@SKK/ˎ]ǤEdS&5|` )Q,z2?t,5]5VoƼu"6 IDATjJZKHNd-6FA@W EΝ;RHF3.^rڽ{$J˭ B@bF W wL"R䷍kגRڿ-FNX*.^5+nܸh2A+-p, O޼lbsW0r9Cz/" ȸ @O@V5r?>|1N  )W-ul(\Y'[/E d66[ݖ7:ذ\+G/_{ awL`L˽~mQO2=:'K$Ʀ%}! q劫˜@@@@"$0eٲ/e$2s:9to?_UF6YYG#-"nc%Gt!=owJէ ۟ J<"4W*# ȸ˫@@@@8&ӜO1f2e?gҤ'[O.F-;IOSg=N{5x߈_iOhUYn|9?x{'vͪJODZb: DN z$rc*@@@@y >9~n-koKs+o8*VlWV_]rC6m;$U_ʖTU>ձu Ud)UF - o< k~-ҦB /G@ȸUf    z;,LQ&L KowOhl>菣d̐: k/6φ  j0|@bNe@@@@x&@%WM7      a U w    شjH$@K+@@@@@@ p @@@@@@\q      +X      @{@@@@@@%\20@@@@@@     ܐ]{OJŧ :Qj72gYm?Iw{ƯT)xUʔ_ϝG׻3e>,ip B~KF2P߬џ/I|:$0s-S2ӣKf7ʹ W;)U\R9h$ċ'&U2\)iߪx*W˙nݾkƜ ”G9ϝM?{Kj}eOC+RK-^>}]:HzZ='$}Gǖ2v2БҺDc]όum~jZE^Q*fH+Y+F    &0K<姵;VfA+}Hyx t~{[2|s/eۍl޽{IJZ9rd ٩w5̉Ծ}_TҚ5x<(7~elsf!*˙,Yby3 YF|T{>A.¹%CT[Ba3V޾i!?G<țY4מaW}GO.}JɤK^c^IEE3{W}Pا+5 \iPy60:տcBj+{~6b9{h8D]s6;ۥ|>^72^|^LѳQ _,+.>Og P63S?[7^I%؉ ީ3l'lJ*aDArMk5aJٵ?ΌHi2>3գژϗ{&,a4k76r4LH~#ZZ6,LW?E+Ȼo jϿ䙗?m{q.+8@@@@ D6`F3殕-2eU+-?g9q5d?,ի3eEt ;Y8-5nu<uɴn__j 2Fy,X/M^./3.CLn͸vF)[2bJjyt< &Sɘ!Ұ|jw_a{˾ ]hQy 0`рҚ{l@M,&tk lCm}>@ȜI]Ңx]߳fiղUJjO.+W($XOQZ4h'E34i_z*>D>ZV,, ew?"ZzV#ב䏲@O"=F' &0jz?~?`oa NJ%Ӏ 3"bYqm,>#N)L0Үeh!bZUL > *0{lɗ/-tPB  D L&ҀI?6Ҳ)cjҢ[t^Yc35*s al iaҌ'=ggԇ-mKOɼj߶fWUU࿗KYsnf°휺=fZܞT$ܱ9Tu_&o%[hPXg\|C|t0A/-cL&挬koK|͙U%}gY9J'RHml eRg6>rE`4xL1ۺq݆v-K%yDm[iS'y_iH}`]ת 8G$1g }YljævB-HӬZzޗ?Πi6[ n/7_v7{XNXG^+g}추i33N}LF'mv6|_0slIG+`z%M3".-@*@[w 㘶&O,'N;8W^r 0`d߁Ïc  X EKBsſRs#h/1ghd9ʱ?U/w&^xԺ3[i R큶 ͸yh)YEzϚw޳9zbaVM=g!굛n"oL1g]5_֊f|bV]eK,/ؚGZ6xl}DW32\F j-3g9l;l߷1_6[jb^&}ҵr|4&PII;cE@Iӧ`~\tI)bn#/^gJm 2gľҥKɔ)|r[5jk͛7ƍm_ھ\%y>z)߿)SFΜ9c۫VTT6eYt{ƌ70m=ZoM"(Ũu׭['xk׮4m4B;DTC@ O_ +^V3A}ٌ7|S>c )h;z VS/.}|wJm} ؀˗mwߵcH8]6kqU4ht?hUveNiQ&M$Y&cB@@8y}@E_1k 뺔)ȋfUkwJ͆ZүG=YoHU_si"")ͮ Z*zlɗ$EzR ep&6p iLTʈ?b Ƥq2d&3/%MSeIk_kƧ&ުTXtQφҵm#͚j(YT5q dfI>x,/Y3Ao7gEh֏9;(KC{r3?N5\ %]vv(Udts@Cls/kU,"+~n{6$op9jtal):x,ܱPWwA_7`I6m`TG f̘!˗/yx_t;vl߾]Ok 6yyYPQ)rlӧhf"iPH34P4tPۅ֯_o3v*9s/n+5k@SHE ܴitپ}9OeȐ!6 C>}2d`}gR|y']ٺuĊl?:'x"Ztm^[paٳgtѾ sBgw GQ7ۗF6>%=l$YĿn9$z\Eųz;=Zhc]a2,$G{\ӡ_p1 Jf-> .0Erfr9jt]K^&d\E @;(Q"mW:~ >>ұcGL͒3f(-Z[ Tr-Ǐ)R6B*G}$ׯ_f͚ mVFejp(WXUʕm[mg9[N>hCk@  ; hpTj0iܑ=1b>i\Oypv{=\:1J@"$k?rHN , /s7Z׭[' .6mڄ8 hi)h :Ͷm=Kքu]iKdիH©.fϞ_OazU̙J  1'P?@NE( 7\ueV "A 6mեL28yԨQ͂J$ѰnWBr8[ϙ«Sn]Z b͝;n謉,3r     rΉZ fOt6m=gJL%͞ҳ͛7Ggږ[t?gt#FPh4!   .@o @PnN-[V'2lذ Z "Z?N     xy2      rEaH    9#  @V1    !ċ@@\yr3Y@@@@@@u\02@@@@@@\yr3Y@@@@@@u\02@@@@@@\yr3Y@@@@@@u\02@@@@@@\yr3Y@@@@@@u\02@@@@@@\yr3Y@@@@@@u\02@@@@@@\yr3Y@@@@@@u    ~+g]Åʔ\vK> ,)'Pcbr %K4kSr_1#,i")/K4Frb    ,ZE&N[wyuxǵii_1'0ul)[2bAH1#-wl]C:ˇ~X8 fJ-wݨUWnX @@@dopO z(U Q⋶_21JmE6N8+eӶW ={ouQpu@@@@ p4@y 聞 IDAT1Y[ԍ      @;j4!     ^8g      d\0$@@@@WrK ,ZUhmcrE|i'jW%e9#o7S "׮ߒKEE˯uW)&/*Nk&)S$K/Dh,rfH|99aYYz;ShmlO޽'#>[*t!}?yAv>!=]XNI:4ytxKYuoIG.]!=_/CGIW&.a_qz]ƈ> ՟Kw:^f};\չy+]ޞfW+Kijzd_Mbd1hidG-m:4p/tBUC"m^Ωɿ/BդӅe_Ge |Px{'wھ^ dn Z㚺z='IUSeԕI di)`/c;W؛}dl@YsRiJX5aa~0LRI񢁿{~:;;s,SD kpfؚ4'ئFT[@@@@ݻ/~.eJ斋&Pl~o7d*ɒ&EqC[K彏d -ܛϴY8oߕ&Sr%9a͞H*}ǖryɕ# i͢?L!fyiTgKج{wM^/۬ۇWsɶGmW;}៫r%{MA6Mrٵ\r=Z|~+k.XH%|T4[G4S.q\q^sC4(C4JÊr̿2e2OCKIumTv|w͕QzeM]_w| 6/ioޟo>)S&bfА쫘@lfNV:!>o W}0@@@@xwcg~jaߟ&Kto;qAܚho U-]ϰٮq^P%_vEiSF , GK„xdɒom'cV >YoA#p*PiYh*,26`z]n[9;;k31slt{U*2e Њ{IךaԤ^\=(n2hpV3ώ]ǤM8ӧlm&k~ghwf,A+-uC II3n.ӧ?* ~79DM 66W#     E>z(a4 QXK4 q5n'X̏>drYT XB Xt+,m[ӀT7N"oo"ozNhlA ąa*,?:l%h0KbLK`ifw~^fٽ`v?i XAl--`=GOĕ5u I̬ݵ[a~4n&; nW-A?^\_z|^[j߫S =O&kX!}Ԧf~M6CmѨɆaDMq5    =h=P,gCu {< Z#̈́qF}bjtɆ L}6#'yg(iPX%>欖o7$}d݆=&o;8-V.j*R0yo{. ly=拏f.iRݣťgt{gR]ۑtw|9[>]JDf+~c{V~u 3o|1VcnFOX&&`Ԣ[hQZRҕl]jvm[J4۝UZ5bܿ[.;B; ԳҤJf ZꖡC?]j*P4A_ܩb9E |-'Pf{&s͑3&ЬٯG= %~O9V[J~r &Q~ tԨB"/bog?Eu{װ2WwH6U@`ƌ|r7o!"ycE Ǐ ȯK:u`-Sع9'`lڴgsΑ#GqƲeI,3Pŋtwh$| ⟌P_xTcҥKR|yYvLJOJ p7֑Y      ql,U\[Q     s+M(2-)B@I$bEP?oq|B@@{ޚ2#@!ؖC=,8#EWHQ@@p?Wf@@9d˖-wUӧOKͥYfҾ}XEٺuN:/!   IqI\@@79r\~]&OlHY4hdɒ쒾@@@8WL@pO5kڵk?h F@@fS2Y(("@UVq  *p5lE`@@ҹuwu11 W! Ģٳ̙3z8phP   qCUXGf Y3B*z_|g@@@4W@p3G`*iҤҴiSI&4nXgg2w\9}͊"   @H<}  eʔS$O\V^mݧOslb_    r5d  @h޼y+]@@@3 sĵg@@@@@H!3if     ܹ+\     w|l`Hq    @l ]Gv;67xZv=iܹ2ox&S8u_Y_jI\Ҥ8m -|iɒ)M@ \Ŕ,"   D {_wK飭MDP \>{I&L])ef\\[2%rVUC  #   ĸ@<'W@o}Ao> =ψӌ^t=G@NS!ZA|}9!ZWH#      8qGߔ@@@@@@"!@*h\     > 8&@*-(A@@@@pN;s&@*֨@@@Ç#v@@7 p @@@@ ċ/N! M ɜ&JhK,@@@@@@cd\6 Q]vIvbGbBܹscP)6ef@ȸrd  <|PL@<\@MkwW3E;er2gmZUIa<9ҧ %OBKS6j=DFKzjZ͔JvOxCM_of[RL)mL/z gyYz-+/ȢHeo#d#2R,W`+?2e:R|~F[ |<}I>hQ>ĥj2@@1 t)GpyI>tDž So)>  Ɲ{Rk7~߲dA~ʚ._ Z+^"#->G޲Ԫgh_ުWJ y_-_(p- \b$  V2A@;v D:tHF)r20a bbz IDATҀU.]$uҿ9{lڴI "ӧA\pA4jƌqFYd73FZnm2ڴi# Ģ  ĝ@'C4:_* lm&ۓ;U:wʱΛɜchl͇-//+Vݨ/zֵ?E9Tֿi9qٟy-o-%p,9f@ pƴ 8޽{eҤI&uytɆ ̗ZN:I…} RLiZܹS4$I}y?Yd&h_o߾&[[}]I4y矗+W?3 n89w j 4lR!8y\} b]f5nX5j$W&]'q!  Awn0L+f [l՝;wdҥ2gn'[z# ѣüR\WPBfٳgri  @`] 4D@8q(P J>>>[M8Q>|(3g 2D^.r? ^m޼ƈ@@hIWZT &{Ò)u2~+H7+'ӧKoKv2R kYuPF"}gmJdIdӲSҫa@" p@@!V@@@@H=ur&u&Ս;w͋%ۖ]SƵ& ǔ[eIa>l:H~̠k$>^qǣxĦ)@@@@pmH].uKN wK>jKˑNN\@gH)돜" VD +1v;w&Nsll2@@@@[H˪)GtdVntARl?|V>zKB+~ܚr|غJÅ "X     1EZZT-dF:9S$GAOHcg:>E\y3d@@@@w+ɐW:~&frw p6R  p8!G@@@@B $L@Bgl s1@@@@@@'t5N˱*@*V9 @@@@@[ (^|7I{q$@*`@@@@@@ z      q$@*`@@@@@@ z      q$@*`@@@@@^+xHo- |X &uu1i-'N4*UHҥMK, Ⱦ}$SLkBh@7ϜF#6sl~HfMca r? 8@9ޓĉGD9DG@^}U?[={/^\z!)S:uȽ{䫯 /^,ÇYh$H ^hOԩOfa*Ul+O^@@'!pd3$܍ "/^>d2eʘspfϞ- .7o Ӳed֭!N:%o޽[jժ%e˖˛o) & *W\i\a^xAܹ#Ν3׶{)Bk ji>l3tY   rݹ ĻfCMVkж}ҧO/ϟ7[iDT{ `n7XR%U%Vտk׫T?^8:f['ȗ/'ʷz8qbw ,(3f0O.ӧO3v@@x<}0w=mOm  {I4BʹЯ%J$=j֭+W.)VFɓ'˚5kLpƍ!:ֲeKYr ns "۷o7VN]rdK/3筕7L挩Zya!9A/@@@@@- r59k Ds3{֯c'ۃV*2b@sτa8+'    'ЪYeyd0,Ȣ@%y$a:BœoSH/=@L2CD@@@@ ^?L%Y=@V1˵@CbA     Q(_^o(,(9%=W of{tHN8Q蒷wh\ͥ.@g!    hC3tѯG-{NXȢ t'    %gKJ{dVw5ɧV޸qCxh72q8e޽qR3U|r6mZk.P܀  8+' %p!Ybku:{mSRf̯^}:V֜5I@|T~y|R(/.?u=pZ>Y/\VZ5oc!@i {v, AU  @L4EɈ#LUM~x?i$zeRT)9r.cƌO>~A… KN̥YO»֮QfϞ-sΕI綢mk fV@aʕ&X4С[͙3;4x6wt{= iΝիWeذafH i]vƮe˖Z HըQ #2ڼy4jHzeرcLL:/ҥKMJHAs,YL@Vl֭2o2c yjժr%Ftiq\\j\|Yf͚e;c\Ww}gXsX@8m^NXޠcZE@_L2g[atٸ$"ʘ!-$cT=ղf.Y`ϴW@D q@`P)/@   /_̙S" ۷On^}||< *Mfr=N $iJKx;6k.)[ ZiѬ XiL;vHnךi k4Kv]X4RvmТ&>}d (8md+3g6kVJ(ajx&(&%mڴRZ5Sքmh0vfiJ1sd+Eϧ) M͠{8Cqjf &9 ]Xs1Y)܋x[Fm!WfKS+ջk== (%L@@78 ]ΔSoߺFU34(־tYaָL\_QRʈ%g Qɯ)g,93Y)eĎ窷B^1%Nܼ(Zצm@2axkٶ |>W'.B@ >\Ň2m  ضs@Uʔ)MUd]ۮiI.LMn1-l%ׯo_?ql[ /n_ "O}Ǫui#cѼFl#ҢƍmqqmjƛcѹѢOF˿kV뻾6 ]=q;nQ}c͹wCbW@6IxK5# e+34RʔpIfe)=[2T([@߸# կ]dbNLYwf W'gFnV/Ќ(-׮ߒϿZ#zϐʭA/]H~ֿA&ӪuFWnV,t9f[ժ8g47ty 3 qLA_@x;E@\F@և)=3(Q;֜A͚j֬Y[:wl'mt۾yl"_~^PA={4G52tdqi ˖uUJ9zn=J3d4GϬ,EnEV+wvA2Er£LDBzf+ MDT;}omsXq-gLJd[zR` ߉+7D !)S;I֢]M6-ҎkF>BJ*>BKD;^#ҦMGu 2oCwnE!pRjUчk@o޼ 8]GGDb`x~DkBlE3U?8>נ-0C[8ޣH ]"Oc͹wcAN`{zba=uOC/$4K_|οhx J2gHf0+de77SR$5O9&qֶ}%]ߨ){}feߕ6V#(dT5h9N^T$[6m}jm'W_ 7ZM?4`_  ,@ʙg!   Hzbmͣ_ V]?Թ1!bocƤNa˗;}]ñ|>/TҹmuчcA0MK>|7 L?} Im/Ek XjP"Ff|^.!G> ޺(]Z̓{E2fn:fxrE=m8[Η>6prθr {     6 fI_S<}Ң;8|%]ѪrDaZl>ۯ-ź&<_)/Q"n~jIW˴묀=3w˔ٯʁ$a„2sI+`tټF?%IW3O"g63J+h噲e&zV[ҙ~iXDK]3uh]ީ,ۖ%co Ҽ 8JȌo[Jw*IOn SV_W-cW?j >>^aƣ=tAfZ|$M QhN<^ &pq@@@@@@Z[iʱXYQIL(Yroիw%y |%I/ɝ?dg^s5GjsU~+ ı`cɑ;u_r9sykٱ<| &*pxd24u=?+*(J'g[uEK]+sJa͟7ߑ&\WnǠWZ;3lָ Ζv*D;ңE:m2y6ұ rI&m    DW`2c|+4'>(R2l%Q9run[A;Om4x:MR3}|AK&`Ǫ'}c }7ovHIz&NP.]i_:|d͞*_oYפ6}e֘ɠ1uM*DB}:gsiF6K)MLfDN.,2 pŒ@AWn߾&_}L6-ƶnݺpv+V( 64-Zz֮]uFTիW套^R{ׯ_͛ۯ4i4iDwQD揻q^Ze#G+V<$緸luF^L~@@@ >ޕ#KRƳfNc970@7Vkxi,/Y+IVRң~9wU_&ㇾPNX۽i{=w)JK7p;e$ޫ\iX3ddOkI+]ՔyomޱdϚ.DpF5<2;p oҹWep,^+)p,z˰_i(2{v=i߽ /۟;G:5 ѯU>}4$=[Ef͚G[z|:u:j6mw}ggǎM[^^^ƍѣGÇˈ#X{O;Dǥ~ٔ'Os%_|ߛ:^S vɆkԨwY3j(I*x;͛7:t|%J9r$ܹt_n޼)iҤ1sKA@@/kvJ ⟷șW+ry=ܸyG/{7CVjイhk iRy\P!վyˮ'#nxe2 ^reKDќ2|Bf]pzҠγru_SIFp=U6m?d>mʐ> jwǣ>m@BbA )S&[?wܑ<\nݲd0,X`Z4-R}Ǟ$IС Ό?>\yʕJ!C`SN-ݻw7\hq„ k e˖Mz-I5W_5}ֶm jM(SNPL@F=[]>oܸQʕ+'6[ʱM݂Rbw5jHם nٲŌw5.\:w,-|_~i [ι4i^51 ZWxbѾ2e <ܣ3fo-V5kÛ>di`Ldso  .$0e?GYܳM^ e 93+ ${%9b[(Aw%2}jYbtPKrd GQ3M {YJJJ8thYMF.m_SV_&xKV}7@Z>_Z 3LBKdgJmw M']ElF03$4G3vj׮m2x4 ˖iKM[57pӀ,5_1m۶E`*Af< ٳG ?7_-); .]m )`zEǯf#<2Yfϥ =֩}־kG#kʖ-k=K,r93>=sMVZ4p=lhJZ41}>L4#JM5 e+4h_5k=LfYif {n֭&%E:{%4YV O;cE@<]Uʢǒ9C*9~F$Ғ+Gqf@̉$KԒ/w&+!s]9yUof[oedyYNm/W?l֖HKdB{> 6[Y?褯i%mj?9p8@TV2ZTk&?.$+%4_i {x|lgprWN>Cyqhn}믛,gϚݭ8f؊~\ n```H^x4iܺudi:k?lAM%޲eˤUV&(lG:ǵMbQ~7y=8lkؓ|5ֿʣ`%K^fi&fiesm#;qD$@@@ 50&@*e2sUߡ_mjHF+ h4G, /(^jjmh+e9 ~YR 2[jq!]Jy|2E&8ձՋ2TֹWz5)@7>e4AӖYi]5o74b 23~\ @\ K]vi9r><[d+iA3<*pҳ4@A 6يfhPtjʗ/oWL2eJYv9Jf=.Mh<@4H5zffizfb9mg]-_\:7R48m-5C)"?V5YaZ~wch+}R343V֯_oRPKeE4wz^9W8(-4.~SvӊGA@@@|x˄a`xñyq|<:+tlfMu Hi4$>2qBeԷLU2 N9бԩ¢mk_p}z\8Y-0"yweɞ+h[r]6n$L@ݗ~jIb+sб}?4dΚR^y|4l[@RLǂ,qAI>ܼ(Wm[7d3Rh&)ZR d疓dmSk?W >V(㇮\ҙj/,e+zl?O)#B 2d0g(}n3zfvuz- G@f8iB?;qA%ZKÆ ͹Gzh4uTsFnoCѳ4_hG~ܴi /tѠ5kfqS4qQ4clС1ooo׈uk@=GL[eTХ]v&7{ly1[р˥gR~A? 0Э5jht@b3DqAE   <)%~Яx.!)S Q\M-*[zX3R&X- Sm'%w?Ҽm-o .KBՒR|No^G `dZ_#߷^NDsG    FQo= /7).W@I&ݓm3eɴ }f\ݹ}O$2uOʴdB~Xx+۵JQ_:()gm⥳Fiܓ)BsB[L#@ v7ݚNm}.]*u5[:C5Pd^Q,/Α   'AKXV JO˔1mO([<.V!w>L#SngAjJcqId}fjI8JH{/Ģ#>à qqS΀@@@@XtʞAʖetn/oYkq/A>c-&H`mu_[U~^Ko:aηʔ%`xEHU%V2 E._Vznn8kQ?#\ܲ#lO x.ӭCg3@0X    n#k yo3e씥8QB\I2 $7h|ɟ's 񍕍<>T>ܻ@Y*'O]Y&#KR_SOiB7sorP@i,;%l''۵*ŋ0:rFUޓ e H;CJl%+aһk=9|윌DҧK!/\Er :;I, 5oTN|DF|ռV/d3KlӖɥ+7:_n&\y ύzsf8}?x0 IDATfXu ,p~P~% $Nm ^8hXxEϾ2'xR@\y3z@@@@>P}Lf]ɞ5s2OW+씺5Ȍ?m4=~^*/$`ט_7=p3s ȗ߬˙sWa2-r{g_Rgeイh(;{L&}\d͖o=iW/]}ER%5nw <u=qzug^v9F^LΌvV-udY Q\W+GMg /8Ov:=oο X  @< \vM:t ~ÅlrE)X`\6gu/_\=*]v6⧱ndÆ RbEر;VRN-ׯJ*9m_ @ L4Ks&se+гtB3JL]GMƓ:zQKϸrUgme%iv??sj󾯯ܾd4CJKlBNd dM嵂SZlB>&K|۷)|MJ[<Ӭ* ZiaXJ%\YrV6Wkfok;9[*&h%վC*}=t?#oZL x[?RG W*(  n)fٻw[AŝCdŊq@,sH@@ nܸ!ӧߗMJz۱id:uJΞ=+UV͛{wi۶Æ_y>}Ke3p@ɝ;lܸQ,ɒ%3&޽{KB)[NfϞm>D˗//СC%E~>|ԧc?\d|oK~L5BhӮ];c1w\iذ1>fN.]ZN<n;ڮgC8qg}&xҽ{w뷝ʺ:CAݺu#\___裏DWZn-%K>:2j(9~=9r[>ڵkeok'V ]Æ szL|9sF^z%ns)?Y ]v>;wN̙#ݺuiӦ F[_˗//M41_1Κ5 0 9ZPeL&T)V^+tJjݼr(e#`kAHͫY psZ`dZ /ݞP"*%~I)#kUd6 F9]{Ol+LN{t^>Lɠ3lZdeiջ{` N/ޜ~  8)"Ç;wHΝMIML?oРh`VRJ%-[HٲeMFTƌC|n޼Y5j$z peK4h $iPgϞ2oCQdȐB {RX1F,YLB)[n5kAc{۷KRBT7iuu5oX\3B[ڼZEBf%\߭C0}< k>hiΙQZ["W&l ̥U}Jxl6@' pdn܅ .kFK$IJ*Cv \it4C34ۊfVl!4+&tI6TVͼk@vV4 u19qiOZ4Xe?i4JVZlSEA' Dhff]يfJJ̙E 84[LYAE= .Ν;cfDԎ+.A^=Ӯv&ȢZ7ZDVӧOuuk@SBkGaDDF3KѢEe߾}fiGfjL^FO)W\Uxk1aLCW\7@ v6n=(Y+mmR DUUT@pЬ*Ǣ; fEd4CEQׯƬn h+Q ݗLT 'N3_e+\Tng+ڎfvi±vc]nۻ鶒X7ZDVdhݚ Dn7& jpVv!9r0[]Sdʔ)#~T+ xn  8@BB@[@?~gI]6 m^d#+Udɗ/9*W8 -zkUbiYfL"G ]tB탭>[@E ʖ-\'iь2=V4KKDOv5_Ŵ Nu_~l; DDwh֍sQf־֯k'F׉nZ*T`|{D߃֢Z6m2#ݼy3Jp  @PPĿ麣̞qΨ@@ ]t#GJv6{՛S^~e#YAW,%;3jQt7^{&[TWt > 9ZJZfΜiСhLGy\"}_6|.-j0SfMi0j$W\1jnֳtDw+.mվFwDV#Z7z-'t k<2 Z 80F׳tjѭ7M&nX6mjmZUV2`{]GW,   3G! wۺ%C2@\V?nݺ<)g)AAAҨQ#YhQ󯜥3>(Oue˖(nR7c{9ozիtv5"@l \xItIDto3'! 3>ѽu˗Mߕ\3ĨaJ`]9,7߽{OOI0Z]|<*էUڼ_8[6D]hm+V^m'j!o/JHv5=dahf6s~>r喟L8[ܻ|)l-E@bQ@[ //X]u3@|iy̘1O4W5 Y%M">&k*-} ~}彞 s2nRI.uc4oT^._)oܖuȮ'?H/ʻCqC_#re擱% 0Z,YUҤN..kJA@]\2.@pZ̙3{(DGu-E@ nRXA>l+?.%/I.=O7?!{7idЇ ԑVlWZg`z כu%{tfϥAg ٪_:@W p3G@@@@@"M'N]Nʮ='ҕrm]A+-9ӿ7_0_reufrݼyGX%L|GiʘipfߡGuWQJ@@@@ Rʊʘ!ɊesW'O_Rso/kڂRҾe5bi+Aw4T83% x[RGtd$    (PJ1fm&ԯg}iZUZt)7%唅?m2Sݾ$)|#eV/~*eRЫ4WVz:$O$GHS Ŀ7E@@@@ѱ'a5 1dY֚?ȄaŒx҈5۟*ؠB=VW5Q@@@@@ ly8QCn6*^ڡ hkOT;}O9 %r˗tq.{_\;9 "    TxT,Ws9(0cT"5m(ٳǨn6c^@'#    jҦZ/N)4iKV|OƘ1PN$pW1^JT      bC:@@@@@@b,@*ƄT      bC:@@@@@@b,@*ƄT      c@@H0ܾ}[O gΜwG,ƭ[Lm $Z @@p}W?@( HBʔ)#;v=\\~]>|cooX@@@ pG@@"EdܹѺ@[J6m   q4      WQQ@@@@@@8 p4      WQQ@@@@@@8 p4      WQQ@@@@@@8 p4   xJBKB" W(T H"~TBAAPE){EJhJo!B@MdC->!;s7/\   $GUr@@@@@@ H\91      $GUr@@@@@@ H\91      $GUr@@@@@ Iw햎 HV^+`Μ=WM갱c`$qՙ3    8'& $&&F>|2nܾy8tGn\?租eddݛEn<7qfKW>}W".E&k>>oDz`%+x[Эl[bX?oR,(0 ]._" ɎwIO$)\PM[Bd³,Iר? YH@@.9+H\Ώ@@pI˼y$444ڶm+e˖uy3)@@V ˕Q~nTQ#.FJ*ˊ5FJ)ӿ?Ο7I>鶂̡%+\L IDATH]lX̲l3hm^z~@.sF&%OrG[ _>5HYvydU*y?/<ù-I"g\iKl}Yt鄭ӅA@@p>Ç'`  8eK2M.X@Z6}Z>7ɲ`rjnVƿ&6~Rr)_ɓ%2J |O \=n):rO'?=pT\MǍ     @ ܾ}[FO23¤Hr3*JN='Ϸl*y?/ #$RtN>r\oEZ> g7Y%kR ^i֨Sgdݢ\RiRԮY=#u,dU+ NXledʔIl?X5FHSRCPEYsgKdr_Rr4_ej>Q`\y' v Ut܈    d@^HU6o̖j(S)22 HnϭK/On7iڳ/Iiu&RT~TiB ֤UkAF dTiJ[n3ϓyVm &?.Xl (KJ[=GK&O5)gҕkRDdIZi+dIT"&*+c*R$2֟@@@@@T 'ѴIki)T$saͯO<[yRFWeu z@h, ;U+eJ -ڍU F,UIkׯF6k>Q0o>e{>kr[* 95f̤_=~ӯ$ڝg1l1    |:9fI8X+hK_t r.mjZP`aV؏?9rH.%U*7/Z 8{FF@@@@\R`M?S\r~L*}`Fh!@*=@@@@przhV0}qsx:GD      $\}      $$d@@@@@puW@@ C^;6CA׭[g߷oTһwo׆ R   8@&@@pL5kJtttM:1v֯_/.]͛,   $\!  @*U$4YrYɑ#ϟߌ%K==s4POK]?X *D*@@HWtX+   ̜9S[ܹu:u*]t?#oԭ[W}Y k}L4I~iyGW_۷'_uݼm6֑#GD߫_yil;w4EDD^zO-N:ۗ-Z]Ͼki֬~QLLL5קOss='GMpMhhI-4i"F7n3a5L<ǎ3<ܱchӦEZci\/rvs̑.]^~7z*   X$k=@@Ν+ $%22rƌ_J>>&!gۮ^*=z{JF$((HfϞ-治nG=pK֬Ye̘1-2H" 8j꫉˗/Kɒ%:O~8i2W*T`2d]܄   z L!=   @LF+Z2I)MTR$t+CDn5駟ի})VpႹGLZݥ^m&~W ov횬]V |=~[r%k֬1˗Ox B68lZdyKVߔ)Sdɱ-ZT}AU0`;mhMcФՃ>h>V+Mi26U4yUp?9Rf͚e*VZ2zhSղeK>b[9@@@@@H&I+MZtEs>&KyiڴIZi;sL:wnѧmĈf>분y_t}iJӧһwoS6MQu3$49[̙$lV=S"k׮&Qo߾84kTnYTT85_?#Wbv[-<4fMBie&>- ,s挭6mccڳg]vqb8tFMh   /@U3"  Fॗ^7|T2\$L,Ɗ*W,ݺu3iN:ҫW8i5V<-XT0+ҪU+sO֪KՓO>yו 2[yX=&Fe΂4 2MpM0TRYn8qDMCLIϷyh[)mwh|-I% +@@@@_h/7[8$.$IZL g\B2 @@IUVӔ+   $b$  @Z ԯ__:aΟ?/77o&. #G~|MUPx5kƍ!!   q3  @: <wFo$W nu떌=:IB   .+W.L @@ /_.֭lٲӧҥK$C ,Yȅ gϞbBQFɮ]K4i"xxxWZ%ӦMgϚ &7n 6ȟ)_|tY*T߱c%::Z*U$ӯn/ڵkMEҵkפK.R^D/ڟVHNu֢ɡӧҥKM? 4)S3|pÇKdd믿.ǎ[{/CٳV[l)۶m3ɓG `ooߖ/R-[&QQQRreyGoGmXW)Sȕ+W@2p@)VXF=2   ?W<   ʕ+?իQ߾}eΜ9Ү]iU``̟?_^*]v|If9N#GO?Tʖ-k\~ֶm[m iǶ <$͛'&QMXb`ӊ$M޽[x ѭ4c._,=z޽{KFLu3g&r)VkM-YDf͚e>kuM^i,ӧ̝;7ɓ&>̄1ydS6q~M~IP:~=G={S}hѢh"h%si4%   @r*0R\  @ J(aV4SJ8YdӖ-[[n:???i޼IiӤViJ&~4quf*4)S&ӷVH=zTrڴoMZi+_{͛7K"EL51h,~gSdN:&g֦U\+Vhyj$VilMc6M|4q-\Th^ySQT[`I jJ[ӦMMU\r(=#  /@ŕv܉  @zޔmV)ogΜ1MXnwim[[(**>K.mKnznXӱ_ۦܬ-W\V{Y+Mmij6ʚ5&8}IYVNi5)gۯ^ɻǾ_x1%\   $.@@&g%7o^p={vN4Ji~:IVhibHM2%7ܹs3kLz&V{yrDZܹs2M*]~=vAt>z~Vyoah՗V@@@Kk=@@4RF 7n\QW\bָqcsuk׮nRLTLt &J&=GJϠ*T*U*u5[Zܫ[ jbInEEFF ;wJ R< ӧO7KQfVz}-W3bMcզTrخz=O+444S5nF@@HW)&@@o3=N0{ҳgO4ԧO8pIXw|.С s{ǎ_vډ&^z%&ƌc*mz^U``9Jb:%ڴicҥ\|TVzn^zɠAV:=OK=?C|Ӥ&>3{>@@@$a/ c+ =  'b۶msݧie&2P*on}Ugi?*# zFVd_)\5HG@@@W ::77H"C@4 rr#x@@@@qr% ! pƕ- !     )@=םY#     '@ᖄ@@@@@@ qά@xeAIDAT@@@@@ qpKB@      { rug      r%! @@@@@@=H\3k@@@@@@H\9ܒ      s@@ LC?)pI;6@@@ mH\/# n''5jԐ;vܙ0$Oi   r!@@ʔ)#3fpY>   dg\e:c"     $ qC      $b@@@@@@      Cre @@@@@@W<      !@! @@@@@@H\      8+X@@@@@@ q3      $b@@@@@@      Cre @@@@@@W<      !@! @@@@@@H\      8+X@@@@@@ q3      $b@@@@@@      Cre @@@@@@L{+!   GEEC@H+'Z,BE@@@@@\Yĕ+.sC@@@@@Hĕ-"     ,@ʕW!     N$@ʉP@@@@@@W qʫ@@@@@@' qDE      + ren       r"T@@@@@@H\27@@@@@@H\9b*      $\yu      D$h@@@@@peLޱ󋎎OOeĈ+qۜuiםnϵ>8gw)w>#Wwfw;]D޾}[\7pٹGm#2S&˿< ӹ)wY;ov?V4w3v =IENDB`input-remapper-2.1.1/readme/capabilities.md000066400000000000000000000162001475433465200207070ustar00rootroot00000000000000# 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-2.1.1/readme/coverage.svg000066400000000000000000000020201475433465200202430ustar00rootroot00000000000000 coverage coverage 88% 88% input-remapper-2.1.1/readme/development.md000066400000000000000000000136141475433465200206060ustar00rootroot00000000000000Development =========== Contributions are very welcome, I will gladly review and discuss any merge requests. If you have questions about the code and architecture, feel free to [open an issue](https://github.com/sezanzeb/input-remapper/issues). This file should give an overview about some internals of input-remapper. All pull requests will at some point require unittests (see below for more info). The code coverage may only be improved, not decreased. It also has to be mostly compliant with pylint. Running ------- To quickly restart input-remapper without pkexec prompts, you can use ```bash sudo pkill -f input-remapper && sudo input-remapper-reader-service -d & sudo input-remapper-service -d & input-remapper-gtk -d ``` Linting ------- ```bash mypy inputremapper # find typing issues black . # auto-format all code in-place pip install pylint-pydantic --user # https://github.com/fcfangcc/pylint-pydantic pylint inputremapper # get a code quality rating from pylint ``` Pylint gives lots of great advice on how to write better python code and even detects errors. Mypy checks for typing errors. Use black to format it. Automated tests --------------- You should be able to use your IDEs built in python unittest features to run tests. But you can also run them from your console: ```bash pip install psutil # https://github.com/giampaolo/psutil pip install -e . sudo pkill -f input-remapper python3 -m unittest discover -s ./tests/ ``` This assumes you are using your system's `pip`. If you are in a virtual env, a `sudo pip install` is not recommended. See [Scripts](#scripts) for alternatives. ``` python -m unittest tests/unit/test_daemon.py python -m unittest tests.unit.test_ipc.TestPipe -k "test_pipe" -f # See `python -m unittest -h` for more. ``` Don't use your computer during integration tests to avoid interacting with the gui, which might make tests fail. To read events for manual testing, `evtest` is very helpful. Add `-d` to `input-remapper-gtk` to get debug output. Writing Tests ------------- Tests are in https://github.com/sezanzeb/input-remapper/tree/main/tests 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. Scripts ------- To automate some of the development tasks, you can use the [setup.sh](/scripts/setup.sh) script. The script avoids using `pip` for installation. Instead, it uses either your local `python3` in your virtual env, or using `/usr/bin/python3` explicitly. For more information run ``` scripts/setup.sh help ``` Advices ------- Do not use GTKs `foreach` methods, because when the function fails it just freezes up completely. Use `get_children()` and iterate over it with regular python `for` loops. Use `gtk_iteration()` in tests when interacting with GTK methods to trigger events to be emitted. Do not do `from evdev import list_devices; list_devices()`, and instead do `import evdev; evdev.list_devices()`. The first variant cannot be easily patched in tests (there are ways, but as far as I can tell it has to be configured individually for each source-file/module). The second option allows for patches to be defiend in one central places. Importing `KEY_*`, `BTN_*`, etc. constants via `from evdev` is fine. Releasing --------- ssh/login into a debian/ubuntu environment ```bash scripts/build.sh ``` This will generate `input-remapper/deb/input-remapper-2.1.1.deb` Badges ------ ```bash # https://github.com/nedbat/coveragepy https://github.com/giampaolo/psutil pip install coverage anybadge pylint psutil sudo pkill -f input-remapper # Make sure input-remapper is uninstalled, then install it editable (without sudo # should be fine), so that the path for the coverage collection is correct. # Use `find /usr/ -iname "*inputremapper*"` to check if it is uninstalled. pip install -e . ./scripts/badges.sh ``` New badges, if needed, will be created in `readme/` and they just need to be commited. Translations ------------ To regenerate the `po/input-remapper.pot` file, run ```bash xgettext -k --keyword=translatable --sort-output -o po/input-remapper.pot data/input-remapper.glade xgettext --keyword=_ -L Python --sort-output -jo po/input-remapper.pot inputremapper/configs/mapping.py inputremapper/gui/*.py inputremapper/gui/components/*.py ``` This is the template file that you can copy to fill in the translations. Also create a corresponding symlink, like `ln -s it_IT.po it.po`, because some environments expect different names, apparently. See https://github.com/sezanzeb/input-remapper/tree/main/po for examples. Architecture ------------ There is a miro board describing input-remappers architecture: https://miro.com/app/board/uXjVPLa8ilM=/?share_link_id=272180986764 ![architecture.png](./architecture.png) Resources --------- - [Guidelines for device capabilities](https://www.kernel.org/doc/Documentation/input/event-codes.txt) - [PyGObject API Reference](https://lazka.github.io/pgi-docs/) - [python-evdev](https://python-evdev.readthedocs.io/en/stable/) - [Python Unix Domain Sockets](https://pymotw.com/2/socket/uds.html) - [GNOME HIG](https://developer.gnome.org/hig/stable/) - [GtkSource Example](https://github.com/wolfthefallen/py-GtkSourceCompletion-example) - [linux/input-event-codes.h](https://github.com/torvalds/linux/blob/master/include/uapi/linux/input-event-codes.h) - [Screenshot Guidelines](https://www.freedesktop.org/software/appstream/docs/chap-Quickstart.html) input-remapper-2.1.1/readme/examples.md000066400000000000000000000151551475433465200201040ustar00rootroot00000000000000# Examples Examples for particular devices and/or use cases: ## Event Names - Alphanumeric `a` to `z` and `0` to `9` - Modifiers `Alt_L` `Control_L` `Control_R` `Shift_L` `Shift_R` - Mouse buttons `BTN_LEFT` `BTN_RIGHT` `BTN_MIDDLE` `BTN_SIDE` ... - Multimedia keys `KEY_NEXTSONG` `KEY_PLAYPAUSE` `XF86AudioMicMute` ... Mouse movements have to be performed by macros. See below. ## Short Macro Examples - `key(BTN_LEFT)` a single mouse-click - `key(1).key(2)` 1, 2 - `wheel(down, 10)` `wheel(up, 10)` Scroll while the input is pressed. - `mouse(left, 5)` `mouse(right, 2)` `mouse(up, 1)` `mouse(down, 3)` Move the cursor while the input is pressed. - `repeat(3, key(a).w(500))` a, a, a with 500ms pause - `modify(Control_L, key(a).key(x))` CTRL + a, CTRL + x - `key(1).hold(key(2)).key(3)` writes 1 2 2 ... 2 2 3 while the key is pressed - `event(EV_REL, REL_X, 10)` moves the mouse cursor 10px to the right - `mouse(right, 4)` which keeps moving the mouse while pressed - `wheel(down, 1)` keeps scrolling down while held - `set(foo, 1)` set ["foo"](https://en.wikipedia.org/wiki/Metasyntactic_variable) to 1 - `if_eq($foo, 1, key(x), key(y))` if "foo" is 1, write x, otherwise y - `hold()` does nothing as long as your key is held down - `hold_keys(a)` holds down "a" as long as the key is pressed, just like a regular non-macro mapping - `if_tap(key(a), key(b))` writes a if the key is tapped, otherwise b - `if_tap(key(a), key(b), 1000)` writes a if the key is released within a second, otherwise b - `if_single(key(a), key(b))` writes b if another key is pressed, or a if the key is released and no other key was pressed in the meantime. - `if_tap(if_tap(key(a), key(b)), key(c))` "a" if tapped twice, "b" if tapped once and "c" if held down long enough - `key_up(a).wait(1000).key_down(a)` keeps a pressed for one second - `hold_keys(Control_L, a)` holds down those two keys - `key(BTN_LEFT).wait(100).key(BTN_LEFT)` a double-click ## Double Tap ``` if_tap( if_tap( key(a), key(c) ), key(b) ) ``` - press twice: a - press and hold: b - press and release: c ## Combinations Spanning Multiple Devices For regular combinations on only single devices it is not required to configure macros. See [readme/usage.md](usage.md#combinations). **Keyboard** `space` `set(foo, 1).hold_keys(space).set(foo, 0)` **Mouse** `middle` `if_eq($foo, 1, hold_keys(a), hold_keys(BTN_MIDDLE))` Apply both presets. If you press space on your keyboard, it will write a space exactly like it used to. If you hold down space and press the middle button of your mouse, it will write "a" instead. If you just press the middle button of your mouse it behaves like a regular middle mouse button. **Explanation** `hold_keys(space)` makes your key work exactly like if it was mapped to "space". It will inject a key-down event if you press it, does nothing as long you hold your key down, and injects a key-up event after releasing. `set(foo, 1).set(foo, 0)` sets "foo" to 1 and then sets "foo" to 0. `set` and `if_eq` work on shared memory, so all injections will see your variables. Combine both to get a key that works like a normal key, but that also works as a modifier for other keys of other devices. `if_eq($foo, 1, ..., ...)` runs the first param if foo is 1, or the second one if foo is not 1. ## Scroll and Click on a Keyboard Seldom used PrintScreen, ScrollLock and Pause keys on keyboards with TKL (ten key less) layout are easily accessible by the right hand thanks to the missing numeric block, so they can be mapped to mouse scroll and click events: - Print: `wheel(up, 1)` - Pause: `wheel(down, 1)` - Scroll Lock: `BTN_LEFT` - Menu: `BTN_RIGHT` - F12: `KEY_LEFTCTRL + w` In contrast to libinput's `ScrollMethod` `button` which requires the scroll button to belong to the same (mouse) device, clicking and scrolling events mapped to a keyboard key can fully cooperate with events from a real mouse, e.g. drag'n'drop by holding a (mapped) keyboard key and moving the cursor by mouse. Mapping the scrolling to a keyboard key is also useful for trackballs without a scroll ring. In contrast to a real scroll wheel, holding a key which has mouse wheel event mapped produces linear auto-repeat, without any acceleration. Using a PageDown key for fast scrolling requires only a small adjustment of the right hand position. ## Scroll on a 3-Button Mouse Cheap 3-button mouse without a scroll wheel can scroll using the middle button: - Button MIDDLE: `wheel(down, 1)` ## Click on Lower Buttons of Trackball Trackball with 4 buttons (e.g. Kensington Wireless Expert Mouse) with lower 2 buttons by default assigned to middle and side button can be remapped to provide left and right click on both the upper and lower pairs of buttons to avoid readjusting a hand after moving the cursor down: - Button MIDDLE: BTN_LEFT - Button SIDE: BTN_RIGHT ## Scroll on Foot Pedals While Kinesis Savant Elite 2 foot pedals can be programmed to emit key press or mouse click events, they cannot emit scroll events themselves. Using the pedals for scrolling while standing at a standing desk is possible thanks to remapping: - Button LEFT: `wheel(up, 1)` - Button RIGHT: `wheel(down, 1)` ## Gamepads Joystick movements will be translated to mouse movements, while the second joystick acts as a mouse wheel. You can swap this in the user interface. All buttons, triggers and D-Pads can be mapped to keycodes and macros. The D-Pad can be mapped to W, A, S, D for example, to run around in games, while the joystick turns the view (depending on the game). Tested with the XBOX 360 Gamepad. On Ubuntu, gamepads worked better in Wayland than with X11. ## Sequence of Keys with Modifiers Alt+TAB, Enter, Alt+TAB: ``` modify(Alt_L, key(tab)).wait(250). key(KP_Enter).key(key_UP).wait(150). modify(Alt_L, key(tab)) ``` ## Home Row Mods See https://precondition.github.io/home-row-mods#home-row-mods-order - a: `mod_tap(a, Super_L)` - s: `mod_tap(s, Alt_L)` - d: `mod_tap(d, Shift_L)` - f: `mod_tap(f, Control_L)` ## Emitting Unavailable Symbols For example Japanese letters without overwriting any existing key of your system-layout. Only works in X11. ``` xmodmap -pke > keyboard_layout mousepad keyboard_layout & ``` Find a code that is not mapped to anything, for example `keycode 93 = `, and map it like `keycode 93 = kana_YA`. See [this gist](https://gist.github.com/sezanzeb/e29bae637b8a799ccf2490b8537487df) for available symbols. ``` xmodmap keyboard_layout input-remapper-gtk ``` "kana_YA" should be in the dropdown of available symbols now. Map it to a key and press apply. Now run ``` xmodmap keyboard_layout ``` again for the injection to use that xmodmap as well. It should be possible to write "ヤ" now when pressing the key. input-remapper-2.1.1/readme/history.md000066400000000000000000000144031475433465200177620ustar00rootroot00000000000000# 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-2.1.1/readme/macros.md000066400000000000000000000173441475433465200175540ustar00rootroot00000000000000# Macros input-remapper comes with an optional custom macro language with support for cross-device variables, conditions and named parameters. Syntax errors are shown in the UI on save. Each `key` function adds a short delay of 10ms between key-down, key-up and at the end. See [usage.md](usage.md#configuration-files) for more info. Macros are written into the same text field, that would usually contain the output symbol. Bear in mind that anti-cheat software might detect macros in games. ### key > Acts like a pressed key. All names that are available in regular mappings can be used > here. > > ```ts > key(symbol: str) > ``` > > Examples: > > ```ts > key(symbol=KEY_A) > key(b).key(space) > ``` ### key_down and key_up > Inject the press/down/1 and release/up/0 events individually with those macros. > > ```ts > key_down(symbol: str) > key_up(symbol: str) > ``` > > Examples: > > ```ts > key_down(KEY_A) > key_up(KEY_B) > ``` ### wait > Waits in milliseconds before continuing the macro. If the max_time argument is > provided, it will randomize the time between time and max_time. > > ```ts > wait(time: int, max_time: int | None) > ``` > > Examples: > > ```ts > wait(time=100) > wait(500) > wait(10, 1000) > ``` ### repeat > Repeats the execution of the second parameter a few times > > ```ts > repeat(repeats: int, macro: Macro) > ``` > > Examples: > > ```ts > repeat(1, key(KEY_A)) > repeat(repeats=2, key(space)) > ``` ### modify > Holds a modifier while executing the second parameter > > ```ts > modify(modifier: str, macro: Macro) > ``` > > Examples: > > ```ts > modify(Control_L, key(a).key(x)) > ``` ### mod_tap > If an input is held down long enough, then it turns into a modifier for all keys > that came and come afterwards. > > You can use this to create home row mods for example. > > Behaves similar to the Mod-Tap feature of QMK. > > ```ts > mod_tap( > default: str, > modifier: str, > tapping_term: int > ) > ``` > > Examples: > > ```ts > mod_tap(a, Shift_L) > mod_tap(s, Control_L, 300) > ``` ### hold_keys > Holds down all the provided symbols like a combination, and releases them when the > actual key on your keyboard is released. > > An arbitrary number of symbols can be provided. > > When provided with a single key, it will behave just like a regular keyboard key. > > ```ts > hold_keys(*symbols: str) > ``` > > Examples: > > ```ts > hold_keys(KEY_B) > hold_keys(KEY_LEFTCTRL, KEY_A) > hold_keys(Control_L, Alt_L, Delete) > set(foo, KEY_A).hold_keys($foo) > ``` ### hold > Runs the child macro repeatedly as long as the input is pressed. > > ```ts > hold(macro: Macro) > ``` > > Examples: > > ```ts > hold(key(space)) > ``` ### mouse > Moves the mouse cursor > > If the fractional `acceleration` value is provided then the cursor will accelerate > from zero to a maximum speed of `speed`. > > ```ts > mouse( > direction: up | down | left | right, > speed: int, > acceleration: int | float > ) > ``` > > Examples: > > ```ts > mouse(up, 1) > mouse(left, 2) > mouse(down, 10, 0.05) > ``` ### mouse_xy > Moves the mouse cursor in both x and y direction. > > If the fractional `acceleration` value is provided then the cursor will accelerate > from zero to the maximum specified x and y speeds. > > ```ts > mouse( > x: int | float, > y: int | float, > acceleration: int | float > ) > ``` > > Examples: > > ```ts > mouse_xy(x=10, y=20) > mouse_xy(-5, -1, 0.01) > mouse_xy(x=10, acceleration=0.05) > ``` ### wheel > Injects scroll wheel events > > ```ts > wheel( > direction: up | down | left | right, > speed: int > ) > ``` > > Examples: > > ```ts > mouse(up, 10) > mouse(left, 20) > ``` ### event > Writes an event. Examples for `type`, `code` and `value` can be found via the > `sudo evtest` command. Also check out [input-event-codes.h](https://github.com/torvalds/linux/blob/master/include/uapi/linux/input-event-codes.h). > `EV_KEY` for keys, `EV_REL` for mouse movements, `EV_ABS` for gamepad events among > others. > > ```ts > event( > type: str | int, > code: str | int, > value: int > ) > ``` > > Examples: > > ```ts > event(EV_KEY, KEY_A, 1) > event(EV_REL, REL_X, -10) > event(2, 8, 1) > ``` ### set > Set a variable to a value. This variable and its value is available in all injection > processes. > > Variables can be used in function arguments by adding a `$` in front of their name: > `repeat($foo, key(KEY_A))` > > Their values are available for other injections/devices as well, so you can make them > interact with each other. In other words, using `set` on a keyboard and `if_eq` with > the previously used variable name on a mouse will work. > > ```ts > set(variable: str, value: str | int) > ``` > > Examples: > > ```ts > set(foo, 1) > set(foo, "qux") > ``` ### add > Adds a number fo a variable. > > ```ts > add(variable: str, value: int) > ``` > > Examples: > > ```ts > set(a, 1).add(a, 2).if_eq($a, 3, key(x), key(y)) > ``` ### if_eq > Compare two values and run different macros depending on the outcome. > > ```ts > if_eq( > value_1: str | int, > value_2: str | int, > then: Macro | None, > else: Macro | None > ) > ``` > > Examples: > > ```ts > set(a, 1).if_eq($a, 1, key(KEY_A), key(KEY_B)) > set(a, 1).set(b, 1).if_eq($a, $b, else=key(KEY_B).key(KEY_C)) > set(a, "foo").if_eq("foo", $a, key(KEY_A)) > set(a, 1).if_eq($a, 1, None, key(KEY_B)) > ``` ### if_capslock > Run the first macro if your capslock is on, otherwise the second. > > ```ts > if_capslock( > then: Macro | None, > else: Macro | None > ) > ``` > > Examples: > > ```ts > if_capslock( > then=hold_keys(KEY_3), > else=hold_keys(KEY_4) > ) > ``` ### if_numlock > Run the first macro if your numlock is on, otherwise the second. > > ```ts > if_numlock( > then: Macro | None, > else: Macro | None > ) > ``` > > Examples: > > ```ts > if_numlock(hold_keys(KEY_3), hold_keys(KEY_4)) > ``` ### if_tap > If the key is tapped quickly, run the `then` macro, otherwise the > second. The third param is the optional time in milliseconds and defaults to > 300ms > > ```ts > if_tap( > then: Macro | None, > else: Macro | None, > timeout: int > ) > ``` > > Examples: > > ```ts > if_tap(key(KEY_A), key(KEY_B), timeout=500) > if_tap(then=key(KEY_A), else=key(KEY_B)) > ``` ### if_single > If the key that is mapped to the macro is pressed and released, run the `then` macro. > > If another key is pressed while the triggering key is held down, run the `else` macro. > > If a timeout number is provided, the macro will run `else` if no event arrives for > more than the configured number in milliseconds. > > ```ts > if_single( > then: Macro | None, > else: Macro | None, > timeout: int | None > ) > ``` > > Examples: > > ```ts > if_single(key(KEY_A), key(KEY_B)) > if_single(None, key(KEY_B)) > if_single(then=key(KEY_A), else=key(KEY_B)) > if_single(key(KEY_A), key(KEY_B), timeout=1000) > ``` ### parallel > Run all provided macros in parallel. > > ```ts > parallel(*macros: Macro) > ``` > > Examples: > > ```ts > parallel( > mouse(up, 10), > hold_keys(a), > wheel(down, 10) > ) > ``` ## Syntax Multiple functions are chained using `.`. Unlike other programming languages, `qux(bar())` would not run `bar` and then `qux`. Instead, `cux` can decide to run `bar` during runtime depending on various other factors. Like `repeat` is running its parameter multiple times. Whitespaces, newlines and tabs don't have any meaning and are removed when the macro gets compiled, unless you wrap your strings in "quotes". Similar to python, arguments can be either positional or keyword arguments. `key(symbol=KEY_A)` is the same as `key(KEY_A)`. Using `$` resolves a variable during runtime. For example `set(a, $1)` and `if_eq($a, 1, key(KEY_A), key(KEY_B))`. Comments can be written with '#', like `key(KEY_A) # write an "a"` input-remapper-2.1.1/readme/plus.png000066400000000000000000000267621475433465200174430ustar00rootroot00000000000000PNG  IHDR$roiCCPicc(u;KA? ")T,Xj,lHT0jMl\v7 6hh+*"X|5"$ ,̜ų5Dnb:YL.Eh kLƩ9S~XhMucuQ#ed+Q^W"*vSR=# +ȸp󨛴9=2{H0M:yV3,5'UE caWi25/]M|Y6Tёb4G?4n)?U_ƿD-k}Gނ벦6t?9z!˂shOB,*sDpp?h ") pHYs  ~ IDATx^xTO zBIB IoB^DŊ (~(*EDK/R%HNBoB Lu7fﶻyܙ9{ߙ3N0&l׽GA@@$:NP .]@@@s@@T@A   A9  * AW `@q @U0p@@ 8@@@@ *D  t   t "\:P .]@@@s@@T@A    %P/ y/5k֘SJJ ]pV\OKt;Or%0( @[sŋUPFǧ ݺu\]])0 ED4@ժU&OGwޣ=FohÐ~ @q& \߸q:vEM#^:u[ш^kג7kᮿ7RXL4~{E&4uxs~֮}+TB(-^#;E7, (ֆqipht~8oMge3g!Eǜvgi߾m["E ѣGI򘘘s|ZڼygZlѭqD3O$__ɭ +BA@C@8E&wyЉY>~, Q/]:tDk>/hzu]^|,t)" nǐ6 A!AW#*899e9Lk忣?]'%%PRգ+[:&pz(aTKd=F3oȒKobFw Dn> y'& ؋i+{6nܢU-ZRf'$$Rxxy)غ7(-581/: G2Ja^@ gNy{ ~cV6sB+t (kVb *tU 'pT;oU ]U g@@QG~]U g@@QG~]U g@@QG~]U g@@QG~]U g@@QG~]U g@@QG~]U g@@QG~]U g@@QG~]U g@@QG~]U g@@:'D%KS˓\] l+W6Otб Rrr2%&&)8R-?UR (`NI7!G޾vr"ʗUsww'Os!wPwETj%}#z `'\]]ǧ+~++Yҏ xyٯ9uwz AW }W{0pssSAb("NXrltˎͶlvt @@8QN)+E @ЭMR JD=   `Et+G   R$Q eK;7PDDC/>QjrF۷34mO6s֐!FV]v40 }P[?fΜO{vx3iݺH>z^lÆ-fF 4@~iྲ~@|;JmS}+R0ǥr&A7ٱF|!]2^lRvugMoٺL9f>曯"@{#]Awwwʕs@6mSx{GRR25oޘ~7?~ *6I60ρm7뤦zݻu֙bcoҎyzzЫ,VhU3g˅/ٳiz4VV5R%<֭;s]~ZAqs_߂!ڸq ZСխ[)..vK[#ޡ2uk-[<{;fG>nܸ?}||| = 1i_n3~dLXX(gN2n޼-.Qh3c8/n>d~z_rԪU{~| vZȾRBBĜser'scH$LR;{6}5G۶Gi1pW>{w/BoђiŊr=WzzY^7ׅi޼s ;sn@*ZݹG"ŵ'zSx1X|xw:ujC*Vnѕ+ײ;7J'W=z 9P\Zxu7{*UʟF:oImZ7=_2KRJc:ڳ#6Y|ĉh &~)BCk.^"?jܨݍ'[E!!eLcu_0ܴ,\NW ڒ֯_7n/M.\_d]cAm۶9=zHQQE,^zyծ]M˾NYĜ?'B, !~o)/8?*Var Yv)52!'l'Nr(_CB,<۷-k]~+O8x{!ϮL<m{sYnhbZ@  z*ƫbL$hDg۷Eѵ NYLBB&ζ况D :uK`'ryI!/eh~f03G>;wmjѼ带I,\WqHF"^{|pF?:Y>O.]Bo<-^jT~>lh:"A"7eYzӈ~^$G>^ Gg͜L׻/&Liԩc[ JrmT޽?K<`KNNU|jٞ0 BτcJܹ r˂R/)o1CN5:/(Ϟ+fib|pŜ#kȱ.ONO8z0= ,7E,11+J*)?r,lP'PZO7n"'eO<-_["tB[-8!QP~4?KJJҊyƅ/뾣n?K7GC|C D|^9q$soZjFҫ'C,y8DC{gk ex%bvA\bBʇj u!!ᑼvdvEe47m1j;Aݪ$EtELپ4JBЭ8vo/BUN|)y]wY!ڥ"LbE4 տp`hhYߨ kZONN{W>Z䉉G7tW8|ieg:tT :5GR&Ag<\q9;UV6miɀOc}4 3@[ c̷Z۶-z|Cmt15^Y;R'|wus"IkuytsFxOpqlE] '&_Okn2 JKa݊{UJ'g٠@ arMT%MXY{1)B" !95.\kE7or=U˺ti/WlW^^0#CjqȚqr!&ڳ3#r« u};o9r\7NJ}EHs9ZZ$;64B,3pr՜<UY9lY 4"u ŤrX[qhY(Nre}5?TZ:#."!Oz5*;#KpU$˟OD(b YVt[yÒ,D3ul}aú)SRch+t@?I{ܹ{}&M^#W/ǎ@H84nڵ(N7+{go=X( ƌc7)|~,opd{șݜt>G 1M>Na?_cmgGq)F9YD9r!m6-v:AWϮ~C}I1gϳqVuhvvRu^7䗹5ݰB ','uؚ. c߻ LVm/V9ޚbeWo/V ,0,;~u\m`|_4vg*ɖ,Y-C:.[6@Io͞">~)k23W+>7iR_cʔYr_f̘+oc}Hcn;YٶX2Rl*[Ï't|ĉ( 9RzN'OIf͗ߙ'2Nܒ?͘1Gn3Է$o|B{Ё"RP\D7Ys'_pFZ7ap"Ⱦީ8{"5=hjTw7 /\#F6qLP ݳ'`jK̺ VIV"=!V# 838g!sނ{@> wd4nT_>W+6aA!w+? Y۶rTF9ZLц n9hIyJ!ʏ]AaCTgJ :,@A@@):NI -'<}j" @h<|h#K[x A@uئn*Aߡ *"mtr zMǂ2í2R$~?9ors}Y,[% Qů%oڴM٢eJQXxyڼy6 A7b]2^\Rvu)M[0 4_6Rs:F,=]*؈K@6WBիW5͡CGС#5G!T?זNINT{… QɯD1ʟ?ݾGE7oޖe^|;v P;whժkJ]QzT|(9 =w\FU*ӲkתUT '[P=tMj޼1UsC()1)Wϯ8͞5-BӦDKoGڋZjJ >[IK&ˆ}7[v'' ̔?w1X9__9[ +GW^SgEV"dwѦȭ4bz8UyԫT\ #EDc޼_o<>xF|j|>K:u*F G~9gJII_9x @& 0@3U=t''iu`2 @MZ^ !G˿խ[BCRd6Zt5ݍGM>wT1"7mu/+WcNmӓ(A̙st:,M>*bΫgܩS&HAeYXJh͚B<-'M>H{'iTR<- ߃wǺpa_1kWܿO?@N۷/|X$סb!e,]A tiۄGt)*"Vט+mܸbcoHaזVZ;H/^t{:^]ڵ^Ҟ=)Aޫ>N4ʔ))W߳gϗ/^Zl*E|IrU7:uj#,gī_kmЦMՁP^ߏZن Mo!f䊟ѓԡc9jM=-2]x?4=}>4"g[s MEP+BE̗`إɖxũ\\޸qS'DTI?$SDFB`[[zx[A{S``w4Cۥвr+t;;; ᎔+xe*O?3";t8#Hz.$?("G?_X1W[]Q^=_9Иg ,m6fΓnV9> pkjUڒs b_K}`,O/Aԧ®P` _˱ q;{uqmX=J+bz/K(ǎ&KWy{17ayM=j]ӄu04xb$7nH9=QP] | ͛k7OTk^xe81. uExY#`is"͢7P֫U,9{Hxx][H7oޒ ~mݺȢc%=(ˤ0^g . EV@ԯPp֯L;)y>]k-_s4/ ^D׮]bN4h`/ҥ-R\_,lҖ[)<jD~aT91|̇B6m"g["WW#47>G^ iӖ<88~k UF^^sj<cKD_@@,@*ޱC+Z|g=GRcSiГtZ6R[ߤ9sQoA?c)rcM[p45K&}bcoUhzpmZ`\ j]4ylJLL{ƍA/wAQ@TD^+ӧI'ںm,wU *C''NF AU[׊]S4|=yD5\+C5ҤI#󹺒[~s*M諉ӳzmŪٓ(_>kJIy,?1x Ť!7۳V̹1mVw^yݺ6iR7kDZmӀ),,ݥA|  `,Xt˕+#6 SVOKKB9]ncWĘ6s;/ ʍ)%9C?8킾ޡz҈4P :3۲5^y#.-BfѯZ%\KW)55`iiO%w7lΙPHfBĞk7h65cE+X/[#ݾG:pmbb6?>ز7p2};i&tL苽~ƊΜ5^   `sN3'O/o#J ЫhafP?%d͓KVY祣v go۲"n-^g[2  b<.g7lBa   ,ӿm PQ%X-,̀@   ` )p,   Bǂ@   ` )p,   Bǂ@   ` ^΢Lqǂ QG~]U g@@QG~]U g@@QG~]U g@@QG~]U g@@QG~]U g@@QG~]U g@@QG~]U g@@QG~]U g@@QG~]U g@@J T~IENDB`input-remapper-2.1.1/readme/pylint.svg000066400000000000000000000020161475433465200177740ustar00rootroot00000000000000 pylint pylint 9.21 9.21 input-remapper-2.1.1/readme/screenshot.png000066400000000000000000001614471475433465200206350ustar00rootroot00000000000000PNG  IHDRp-niCCPicc(uKBQjQPCDEBDR6Hd+^%5hiZւ ({2PBs3tmBьޥNz/Yl$ǡ]@j}N- “aeECpK (|d_gk،Ezz l`-g£>=_Q_N"J ^X#OԂd7g]< ʘȒ_ԒtMK͈'OY?O+31^BmC.T+}lp=e_>E5!x%b\ AOnk9۔_t0"=?h z7 pHYs+ IDATx^`T$ЫJt"*b{ EDE&Ҥ :(" Hs6$lIv-ٹsgnΞ&KdddXͻT^dTt0C    @PHfF-\gсHXȖm:Γ쓲2%+;[@A@@@@ LbXDxDDFX?=A֬ɣ3ROJO; YYYȂj     @I 4q11񱰺 Oh\jdq    8sGRq'@jx@@@@T@w-[𘘘 A@@@@ Tbcbn 7y0n@@@@#"À    @ $ +#   HdaafkUbB*(`J~LINNUk[R@@@@ ~UVMʔ)#k7l垒_ Ɋ`)DCj|DFDJjUQ_.@@@I*&m lїʔ;vHJzt@@@`(4,*2R222q^SNNڢ0YEA@@@`~z    P SZȔr>%%)@@@@ 8 EA     rR@    %OV`?(WXX>ٳw[^ Zf66pIDDCKsO?.m۴w~>e6\V^c5lP_eΏ<  ~/pV~HpbKύO8޾t:ۗ a_?H8  . #Fډ6z+cD:{Jyٽ{wLHH^+yE6o~y[o6!cO>+o > [l!I&A_H:K zIWߐMf9dϞ=n4P%W\+'O_c(+.]UҬi?'Qkrȑ|?_xw(0 yzj#l޼dq,N=Sz^||7jyi԰k5@A6r|Mu=<ѯQ{.8[mhh{Bc/_^2K:pIN+{o@} 6 Ӡ3&4Nd) Ṯ{ TУvS:=M4N&0Z0Ű0gr;}_'_əfT³2}xy$yż՟ֶ#7˸Q#칵ux챍z/;;FoGk34m=cجi]δߏF.unaS ;gmNNdee~9OLf"Oޓ6'O>/;[nAN;\wUym%$ċ:hwģIJru^^z5bh@|룻}Ϳ_I3:v\ '*Gc|r2﷙X}?ͼ~y-ve\D3~0Iv|LJ5+VW(& Ǐmifhz8Ǽ _~Ѿ7נ'{3u8swlNSۖ~=p]nՕ;}>4i~b3@! >o`V7~2Q+oԩߠ{rldV*(:[/\$k} SO wqҡ}[#Su" o(~_D6ٴ3lP>#6o6fF,?p$'?L zo=uiɍ8HKVI*=QҲʼnr`S[FfUBS=,)S&V4񧟓 +W)X{Ƣ}>jܭZ6WO >ȃvꍽߐ(y{ <_Jxn2&85#/)vD7xrÏ.}/ko#\u@DJ6n,]S^0ui{?ؓSRyWEzAΆ+t&fOC^m]{rCש>?# #yR^]癲l Yfjw|mkEK3 wz? -`~1t@3u߃6E?v[{Nw=ʼ[>YnYPm.LPHKZ5j|d}v&'[N[d bQ{z͚cS {KRM0Ipֶ4( lNX,|u6I/ݻQƍh3KWO,x\94^)_W_y\'Ev\26Kqj]nZ ~1d&:xPSveܝ;om_ dyW}p}\_{5hoAi9^gL/%q}?;gΒȷ#Gעk3_'ֱY~wgYzq֪Yfn_o20@wkk;&}F@ٯaV_̝ 2 aVj.g6OjF݃=t~:H&vF3P-UTo<Ƨ@HgUh{7T\ɾQܑF6n$7ztõ2v76hnӀTIݗ6qu\A5|wlzKR3'L:򽫹jINIɭ2]"KKE3( 'ռp 4i:r)Q=^&a8^;y<#ȹxYhpOӴ'_|W]yDDX {< y߷x  f]bdhSM*oq?z ۦ}Q{GϱYލ5P{e_ Rdl\sCS0^Ik;0c̓wf`ΝG#ֆ[ܭe'U1/~^yŽwy(EfEi)X+h09 kn4}F~9(=dˆ^?866ƶmxT c;G}O|{q-\=/t0Sw//[nץyjw==O-SF@ &Ld~teAEˮb>uYsC^yaiv:hͱssG=ʞ~EQv-wzrK B  Я>?\R?Y=6_,Ȓ:3~O?h ntwwJrúUۺ|ѱ_?]o|볫>^|XϼOw*cO 5lZǽWOE]@U@ҍ1-Wm:mkmڻbNr&5ZX]=;;&! I;n i^M7 cZR|g53ef掋AB+sL| 3],ʱ˯A PJc̛wPf2zPru5VW\O9  P>]nYã  &+dz;G 4/>K{]l@(d%I@(7_}i-[4/z#A ʗ//w~ԨQs ߏc[N!/ٯ~ '/|٬ʠO?.ϴ;yϖ'{ZT\`7vaC>kzP{{Cvmr|?<|56E@ 2&@Ζs{^$]T{ԹgwAꉉn~l+YLrJgKmAz\fvTTI."y՗m?Ϝ%C~1Vqoeg 8lپc_7 V}\ R2 @ $+U~N @4 ^?O]~#' #J1d]'_{uv2C.q9!-C>hv)zW_gNY[T~$VB1LjJMr!8GNo3ϱE8lVز+.)]r36_ϿW:ٟ3뮕[3mϙw ^[ܟi`vi$pk\vEkڵk4~o-M4ν>`ۥM);2խ+z_ sUma6o^&0>4Grk,ΞKnS@JT Yrs2@/A z'ʆ mPk$>>CLT_e;w&`Guy DBBg2[F^nf7ysʌ3K$<ۉ-^Sd快lF׎I09~1ٴiD :-5k0֮ݻӦQ6ntzDA$33SyIٳH{ ƌ{VϲeJmESY"s~ho-gq=\ fN+Y+3:&/y!x:vh'~Cjլ){k/k %%,a";s5={kL8UUw^zggsͷmh`N'$̒ vm\\Yi{M[4o~<~)ksym63g`.5-[ w&'[Xg%  %.Yg  5M6Ŷmf4oJ.g TkҠA}ٶ}\t&&}]XPfwrɄ AȢKl5FŊV;NRT o4GWhN8{ }uaD^OJ (jٸi [+롃m w쯼Esuz6OfXA~lqeU\L4 M(**J:vDGG? i'c #dlR=4L34eJﯽ$H f]%%픋/,= 87_fv)zϾ`Y3)i~b39sɽw)O>Irۄ l;-7]lMT1mo4YhjNq;>KNuNyN\rǐ:  P:Jǝ" &@Ͽ?εA;%R 551Y\KMR#PVf q5Z_33ekתiqcLSN2 ie2ܹь-G  ~5ܣyrZٗw]/wRi5slD4AXGV_aA2=vEjq͚Ԍ5u6X@@@.^h}uaQ% _b(;ͭ&g*!D+_\LOaҬ{S*o ")M~Z}(utygL8T'޷ݙJaZiq6HֵKI׌W]#-Qȕ~j.5٧Iu7ȧ/kY]r\Ǟwᥖ 9pW\alv.˖/7Kj@cYfA}ȵ=`q )?ZkooNJpQ]Όx @O =B@mmղĚl"&ؾQҢufL9/_>UW5kf^QOi=4@=tyqf1L@G/<4Yh4 * Ry~6K4H{QTbu]W6Nj~bj ]<5 M3ti]ڶi#!>ifq5VˬٳX?Ώ\M-?=eK,VL-WEQJ[b7n䪺[c8w|$;,[j׮ݶ7\d51>-\:nNpkmYZm*2s嘛~#R>rkPmYduбǩ9&Kz͗嬨jlНU~ WqNwdɒ$;'udכz(ʞ@@tesV@<hР 9YQ?5dPz.E{lH6O<;ѽ/EOݰ_3wřa&LC=2Q&PPfWիW7֭wmivgte˖K[EDD]ץz7M m6Xo_6-kkr׽nroSMAjnqihk3:v]:xt1}:pԩS-?d7[\Λ@ZMf_2 T\*z l41 n'yawL)SIgבdG:6_ۖ-_aꗳzov7gY֘sn]; >gkбX {.yE]@(9f-[8 Gی0kK/ NzwG 20xO/5{o=ن3ђ37V (@ @ Xdolo7A=%i?{U̞iҿkotsy@@%rKoI    @ qNG@@@A2oI    @ $ ة     H-IA@@@Xd;ut@@@@[ɼ%I;    +@,`#   xK $i@@@@ `q@@@@o $$    A::    -dޒ@@@@ HSG@@@@%@[    v8    yKv@@@@V YNG@@@A2oI    @ Dl8 @¤VRre*r;XrS6n,99984>-+%9T$,9pٳG QE-zoOQNΜ]AOd9/ @5\xYadff\4HXMjHƍ l4>>^cdӊå\8)oMe>I[1LC,yٷ[t766F"##U$==~(SAvN[lns,[BbJv6e-6̑axʼ V-%>>^vLfm\Ṛ\y>tLi;uV*+-ڵh;΀@s'{Zѓ챼um={ʢK侇?Zc[]4%d~d璝}}{k3yk_n}wپ?? @@'Y@LDBCyfkȀwޒ6]Yv%dqǜyW6LZ?ǟ2/ˏS;o* &@(zߍ#5G)WyF'; Cc|?JE|4L\R\ٹ3#mLK7S7,,LF-_} /2O/yΈ  ɂa uԖM)Sme\|tjjIvmrz\+jZ׽[Yacnp&}>d4?ilٲ.EҰaB[\4{͙;O2/^3OV{oo~{o{]ԪU t=u髣DEF un]G[E9tT8+ @ $ ٣ @x!{{|31Wṧ̲/*gU"e[5FyqGCfҤУO~ ֙V4hɎ+fY1dllA 2rfr_5TK))vz8G]vi ,celqsD>t9TRKrYfFa=rsL p6@``e0*cBD@c>7סӽ]7oB_{]t]/EXkϙ7uxjz'<n&$e( *U*KڵsR,#G=6kCNҾm7I$,ҪE 7nܼ9lJ?M(U'/|ۥ~6Ȣ,kP6m,.{ 8t5~ϝˊV;gřM0+ۛ%O2r塇ohKk<}>jSN]}6>x{1 ,@&W %pwh I_Yc< vh/ݶ=i6Pa&y'l@Q\=9r{uc] 5ϻ.&P +d2ݤ?GegR|hҥ6?\smi uʴ29|g=sU٬_g~2MҼ;$oVUJ=rGIί}'}i4.6e_,1aՔƗJ8).s@!@ G/<wԿr|t3o+gW~5Ut@;ut@@@@[ɼ%I;    +@,`#   xK=ɼ%I; @ pƀ2:y275"z @k@=;,⣒:L %<Tenq&@J_喥?(qժ<'Dp&S VJeٺmd *5  @ Is@cbK2ȸ0@Q Ta%,0 KK='@ $ 9f    .$A@@(dSE7d6#@@ |K@(Aq4  a' @$ If  -i6  ?l?sAO@@@@JILR   P4d&wpZZ<|Yx˺T@ 2Bc%  !%pֹ~i?/|'' ) Pd\ 8Gխk e=hYfMH0x@ZM:毿+.} =/įK@JF Y8scy %::Z6h *)#wIrr! 2oc )111<md=h&يIʕlL2rzSVZ-W#s7Fz}W\dk٢TXQȎ[kSthNٿw_HNjBe׮2ҬYSiԨ[ǜ0  P'@ Kd.")H#"dmvgw&{/&1rKjj\6O?NVU IDATݻHM[2xݗejz3gΝR|yKduy{|-dr֭\z66ĜB r5;KDmWN׳sN{. @ qO!@ mZnfi~2v8쮚5jLy6V<啧,[f&+**Jʕ5II;c{~g)G/[\o Y26L]P!ׂ@@@P ,Tgq#@G_ ԗgnaZʖ^ܺ6DzZ5l(M46Yh1vdE$SpTA(9rOY´" !(:p' O@ H:sH@frh~oVfU<v(IKK/–h$&&Je}v{e׎;   4e"T `% G~f_Uإ[dN=Ub͝ŕf0wJٲeDosN[ZjRR%w=NqRr   Bdd3Tp-gqi2M6yT9cRI9sIʮ].lhIJs#ieQKqRsr ~)p.N㬮;uN!B Y9h6@W]62mT =Ci./(@_Öu.U1uG~]pZU G'2 _ IC@@@SWA/Oۣ> @h'Yh3D@@@p"@@@@@ %    A2@@@@ H    ݒk@@L 'Kw@AL@%xY --Mbbc*! P|XS 8Y |Esg` ~  };KD  /ԴI3پ}{]JJJ:kJt 9js%3< ?5kٚ\e @XX$&VjUIL T N4Ld۶M6AZRjU4,i{lشI/E] Y0C@@ $;;rK_01Yh@OظO&n   {"p@ g}+j"   A X    A2    ɂtb    ܷ&    @ $ ҉eX     $sߊ    A*bX EBBH{xS%==]dݒkdS@@@` H @b FHڵ$!!^O{Ir.ȔzxXDEEYTPAjת%앍6JZzZ1{   @ 5ko?@\ԫ[Wv̰v!dU\du$99٭㨄  A2@5$Z5ٸih?)iė0deeɺu륲Y&Zb"/   -&P MkR4&m[ohڮT7ldffy%,oZ dݷOʛ/_lMfYg]s7ΰ0Me@@@A29mZ-IMFJRjUOdq>}{%!b(23#l2_&  !,@,'#zĦMػ$qtR+Z-d11rAg92L3tv#*Ux8@@(L @ իk7`ұlYf?ql1wSԢKwӍ2tҪUKiؠ86˔)#v kגHٸq7ٿ?ZWu٥ZVJ~#v靤j*nοHS%n*喎>OM%H ]@@BT YN}E|?++۶髍#L۾TST     H-IAнš7dk9J.mPʱfi -[482 ƚ;Hwҭkg;nl߾{٥ 9Ru3L|QmݗU83{8vϷɱ   , g5{ʦMe_ɚ]jV]kџhdUZ=N4m2mg,:C͛EdյQ yS@@@@  !,T1w,NuO*VDUY:y+Nc˕dzӅ 7zI@@@ pwK.@ 3念~:jZfl[o{Bӛ&&r,  `O2WB<@)ADYkn>zH# 2 <&ae$8%3+,L.Ro@A@@|%Pb|+E(m[IZZ4_WSRdno{,+%M6Nsgspn_"@@B^L@DLWڵb g^mIFfdfxY%ebc$>>^˗dGغu%=Y)a^ެA@@JJ YIIs@rXـFHkL7גm@VL;h?dI7Yh<챰0!C@@ `% !Β$پcdggmr:O\  A`EƀxS[̛  /]    V o}i@@@@ $E@@@@ $/#   A$    [du@@@@ HD@@@@|+@̷    `"    o֗@@@@@ YL]D@@@A2:    @$ I     H[_ZG@@@d0It@@@@|K     @,&."   V o}i@@@@ $E@@@@ $/#   A$    [du@@@@ HD@@@@|+@̷    `"    o֗@@@@@ YL]D@@@A2:    @Dk&I@@@@@Xs|<-# -_# P>ĥi@@@@ HD/@@@@|(@̇4    c%    @@@@C Y`D@@@A24    @`$ y    > HC\F@@@ d1O@@@@|K    !@,0^"   P qi@@@@ 0ĥi@@@@ HD/@@@@|(@̇4    c%    @@@@C Y`D@@@A24.ʟ ~}9*_yRF=/'C@JS 'H^S'˼g䞻ؘSlrAj ~R۩7gQ<6HV=1D@@h~b3gҭk++;vHڵ['|D{/O +㡇  W#~`{929δn^wU2}Dj>RLܑi_ s%?M$I5Cm?rޱC{z{/5k:J  @ $&V0ٻo1jR͚5SWs Gh2 C%׮=̊;gҿ/\t] @@h&W"m @I $+Im΅n 3rj.C9X"  [n}T|XF?8;M6:ҢZ\8  Hbp&mthS^fժV?? ;++Lo/NA@]`=xɟv?xDGGkP.~ϳr~j~U1>O 2Ĉ@ 6i,hS:t3:&fQ_}^Z4ibe˗Kms~LzS 3nz72mO  @ V?nrKJJ8*[hLcp~jtuWP2ɸ@Tz]t:|ڵk-oRVmof1Ƕ' דƍ'|Fvڝ}ݰɟI\8^=<ˣS@4-oEYJ*ڻr]梸xhWEkֲu_gJ2emNo( @ h }WB@@ ,Pg~#9  *{5 $@@@@y6KOig^1  "W fdA8 @@@@3dyQ@@@@ 2$@@@@yEm@@@@  Hʐ@@@@< H@@@@P YN*CB@@@L g^F@@@BdA8 @@@@3dyQ@@@@ 2$@@@@yEm@@@@  Hʐ@@@@< H@@@@PxTER!E@O* bX*XADED7J齆^]$lɖo|v;ݐ={f *]B@@@pL c^    pP    ch@@@@?>%@@|R ]tR@~!> 'ȩ'CK$s #   @%<<\?!qqqWH  2y;]V!s!!!;WN$rA*~6-]H%  8/5kD&@<%5 @ll9zLgI2f2 IDATJ*B@@9=Œ 2 9w jJ;Hr.I2J*B@@@U2|uh7   g\8g@^28pJ`aҲEskaaa2qXyo ?e+.)Oʕ*&yM}%**=̚S')\H+t=T  _z<px=ʟ2\I] `^oeɓn?AJ*+u<-s~+ $Yg|\ӳ]sf/7Ŋ9z#  A2WWPϜ9+0]Ξ;' J&.%ԮY#YlT0XP,'m[}H;0f*OT;JjU̱~%/m{2o6]^pl޼Eh%I!!!&UһWOйMuW^)]tĨmup?KUMlĨ12dZ,o>*G3z|W~˗,"  kـY` ;ع謲OTv>/ݻtHX_>BcUyTQG5mfmZ˔ߘ]7gS&:=,T  TP׵u/&Gʔu{d^04@o.y{}dd}N_낅Ҷ[kutY f6e2y[/^+>YLӧh¦ 8XNFH/` Iu*8& [o!_}Lg~8vZeE+9Wn#eJK^e%q|2~(ٹ?iӾsl ekx+M u<+R+,jIySj̖.Wz]d@H#Ԛ,:xdȐV詛ұs7S叕Ke҄qr}sLZVi%WΜҫ{y̬1=vd1ohJ~OTykȬ͗ݺO#ϕe+~7_Ut`S՗%ng^lu>TCZ߷K%s蹧}YwY?Ξ#>mA}c̝gWr4ݷ߼O{CgyT^JL2Re~LO^>z i^rK6pe1wR3f ?~d6'Nzz^ L@@hS㊪N6VnNEq(K.\ j%@Oٴny9*p9Z}>t2Pt@KQQ0=b)*P^.TY5D/Pj-xkCMw)qb)C.-=eնMK]rjָym&=V9sf3}nK.\S2k&nm+JsMu:;,LMa:b b[ʑcǬU]T}ƪkܺWTJ>Iҁ3#{vu`8OG=z5d{?pMnX*֛ļڼ4{8[Yx }"՗0 H@+@@P`OK qGۂ%zwdK@YTB8.]d~tk\RF P?A 6AUa=ҥU&2Wep=RwQoZzjʲCz]?TŐlsuj4-z[׹*RtQx[mn*5چC:8ۣem8+߫WETvi@iz}Ǥ^^Ңu{|٧zI>сbd:K 3:n%8I{vzSF1z1ykٽgCku:wh+著]`w@@HC똞RBy+WnjckA*[&+AO+5t@MOx^@O2R:wl6Rc!YͭY':u*6I6"'q٠2^ّ[/ZZnϞ=S<[KTT\TAX=R… l]tN5^v%UTuP5Z|\o92,=a'UDՙu(VLI[1@W-Tv3~ݲuwκM4--w߹>Y⢧ޣBuڽǴ=Bi+-A@@m7nfڝ֢׿љ?YoLO˳dY.oLeDlљرUmc&ZV-V9l@V,^PMY )=?|;U^xd]U/p@=ٷ]u\{MTZs RR PE~XKeLۣWS՜ɬߚ//t%zcu|Ϸ5ysfwSPI~fOV>{Sh5RoSVm6e/?Ϫ]:=\]=圁 D{}y.(U! (SYveJVSe)X~SSR5HxvG TXA6i$ohWY&*kͦv ȟ_r\l@jPWE&;-E@pb(HYaC[̸TaQ33 y؞!KOrn7hz3YZL_x<  @ ]9V͖$Zm븺F0s- A~}]0w]z@;&zݰj`M-w9 x-d LA $#tKn S9. @@@@GXGf#   N , @@@@GXGf#  xZ z AdR  $'OA/ F慃BY ,4T\u-]FIE  8'}DFfU2~MwN@]@+tcCES@i~9MH J Y8 SKrwko2,$13=$p߽kҫO_9y򔇮e<)MI^<   `}v%$g;ϕK$KI^no_ *(X%,J n>M1Xeul̈pgrpבGuձӿJ*U(|D@g{{{{{{I怦`KjHF/HŋSyUԡ1KMtYYv "SIf {Qs .Ⱦ˩ȢKS$מEc1"#1V:QkG<4^@OL)@fz~UV%,GUz ϔ# Hs9{ =|ڳ&k({=}Kiܿ͒Ky { hŤaT޴ыRhTI {:Ӵ㏹i.(*Cpdz:Yu?.e/]dڌolawgpTF wRN-ӧefq:f~UߓOkG)#jnA22|X}:i*-Qc+m )Ciw&u۽py;[K&~={N֭ :u)'M^Rc9{V\stl'D%yGL_~yn~7h gh^wE3/6Qn1C,zw?L۶xoO_8d"F_7? k׭3feJ?̝y g5LYd ʙ.1Jz_4|_ Tz}_*W-_}_tI^H33-xj,\-{6q{S:v?)OO`Ñ҆k#7d.\_,CV?\BC'z ::6şF")\Yv@*j׬a>ϺIN]7ֽx$kO*5$yl2e>8{"ӯwK^jz=cnW7o55m"!Cqژ`I޼yLD@:y-%K4l e:$,,Le(9Ԯ}L;vcꀭۮ .FbfW'>:QMkUVEN8i#GbqkPޅQ&O|9zs.]6YRzʈٻ@{K6ݗ(Юif:1=yɲ1|*uj4,Eg >4, IDATUdɒ<_(H>r}k:pKhh $u/غaz:qP;4Sy&II9ljq*^+pN,]l`E|٧eqajUS}@U?W3Ŕw#=Y Kaz]l|M۪Up-lݾC6_=Qt@|԰f颳H:d2#TǏ)N6kek,ڭRҼe[kSkת!;u'O*Cysٳ&\?3*۴2_ybpޱ?ɣ_Ol`|y҇+c}:[Z[C;h?LA<Ŗ^Fի[ed5LlΩ3=N>ԫ[[v2Mtv3/LC%Ȳ>JlW\;H"M*xs[o! S24R,ze3gLj ԖZƌD96@{A6lh:wl+m՚}:[1=2W4,;=M޽}nf8?PLKޚg4:!ܽ` qs=EZ@MŖwKsߨsT|F@c)gUG""XKMtk[0_ڵTvU>yr/TYc۶o{Uy/=.˟/]pRic6&\8޼e3gͶ6|I#)Spb+Vs f{z $ժQݚ-VZUo/jdڎ5|r:#z&ӳ6x,8ئ }Wշ{4kze[;D;}QE٣l}";d!j Dp/Qtl'Y_z .[s=ZͱV\gKq\:1iӲYw?iR%^ ŴACK=2mgp'Է,NY]zz̎;=P[+{=neUbQ0Ǎ&:|iN_ƞcg] XNmyF-)3|X;u%]kf)})Ӻ_2?QDf30KIEӌD?tw_ӜukK\,vs˴;Tf=:y԰AOCン89в} C@4ody2md:ƛ/vO=-_!:q6BλU7G2S T<ۚmۏ5v9 S >&Vg%.37y0[ֳkgKw)$}yɘ*pRZWn= 2d/՗zc8MfMVEFJj:k%,u|=1U+Whx;eZQk)s{W,9jK=vjyX_kdyTOW2:FnWn㵟c )CnwO~sVf%2w4ܡY-DgX{ְ\:v_gfKjWS} [s/[cx@ ZWjf*+jYj*區ҭMx u,Eyd)._~t=-SםEbo^fC]g]yUaٮSWcLfYϾ$zq>$L&kjj3:&cM?Q2hp[o tJs=m3e3F/ovݫY#Jͺ*#8@,Y*^eG&/5:6Ex@*zoh[>ы/O7[T݌+W /lpABrm*./-+TMikzW֭=ebf3MG'T`{GLiLfy 8yB~2,˴i3e ?.<~}@n٩kkjYy7[>fP&~G{xwNC[_g^hp' N$Gc@@B o5\4@@9saKcՎ:@d2w^cu>q}W3WN@n H  uu7~ciF }B@eLt%!   dB+c#ާ7@@ Hfx\U\)#ܽs53327fZ̘m# A2;Gĉf 2;0!.5f'LkgL&S"hϘ:\)' pdܐa@ Hf0@ʽ:ıAI.W-dL}ki-cjocϘVh- ݳ\ @\=!C%{"..NDS,  ¸89ry-81qrv#U±eJkBW yH̙  sC\E&:b+@u% KbLd h],ws@?p d~rs @@KDS|ٻ ?@n@̝F  8(@` ^ES      @F6"   U [y@@@@(F@@@@ p[y@S WC@X kdKZ@&K@@@@v@@@@$s #    A2_=ڎ    d.a@@@@_ HˣG@@@@\"@%T    |yh;    KJ@@@@|Y LR~ڎ  !v=ո   Y x\ @@&jժ#  LL    i/@,ǀ    A4.    ~ h    @ $K  L4A:om͢=  [@@@@|A /mD@@@pA2R9    /$Q    n HV^*G@@@d0J@@@@!n@@|R $8XҧOom{\\OF#  $W@@hۦ臥̞s" @ $ Q  @ vͲtBճDDDmD@@d>0H4_ G'[`4lHR2cرr}Ig)cuCGUu,.sԢӑC@@@@  M@ҥK'C8ԥMhT9{+V4E}_^+WJ˷ZXϞ=J ,k]6nXn%W\)_|.۶^l 2R+Z,_Hj@f8SldլQݟ!  /@,opVq%2kn^bc㒼Xl٤+/KxxFYf] d??@Ν?//^TXM߼_JxH-[.%Kϴ7|gP ^{{LӦKL$@@@?+B`ժMVW9[׮i1rbeW_׭sy+'O2ɓd;vZ>}Jrq;^+SRYd -VERBEI>T,_A.[f=С?Ę?:>F@@d3Vo.؛-[IFTV Ag5lP_,Yj[`曲~У   AwzE{?vYҡ][ٵcj[VO=-gΜVղu[?~[!+/QQ571cqq6u)RY   AyOs@@ U.7[K @@ ?Ǖ^!   8 @,E@@@Od9 @p@ܹ宻riT x@75  xEPtIy_#i  A2'9@g ˞=Jnۮ"d Rp! JN8ݦo  L A  =d111+U,k^1  S|jh,  [d(?WA@ $9WD@Z 26 N $s@@ #POM_@@ A2@@#`o@7  A2U 8(h@ ^/@뇈" Hm@{Dž@@<@@p6@F̫  AyOp|NE@".].\DrޑSBr@ܹs*[mpH…$.6Nd:f1rI9x$$<  H*IA@ )ZLn:'Yd1hRcr񛺹|"|ɊѣGsuR~ٲu$>2bM/ۺ̥O>`A}:՟bWCN  pC   :rC@R#{v`RREYoq&գb^/Rp|:cX {qFٶ}{C%}G(u-[A<at?nrC G;@@ $s!&U! B]WԔ1CFѽ,Ua #S)_M5k-ɜ9jl{n2^lּo+d=*JRS-նMkپe1|Sk> W\Muİ}B@ nf\@RV,\xm nkB .'AR   zY3o_n/@0d6t@pT@/_N=GOx@@X̧"   C ;T@@@@pX@@@@w$s*u"   A2.    dPN@@@@ HSEc@@@@!@ԉ    S|jh,    ;C:@@@@|J O E@@@pA2wR'    O $ᢱ     HUD@@@)d>5\4);wNpe@@@ y$]` ҡ};k7䛩Sd$uҏ֭ժ&ɒX9>    B\\! ѣҭGOIHH01RF%Yf'ՕOƎZuʎ;oĨ2r(p\q<   IHUt$>>N:uSWt,::Z|5UΞ=+ŊM8R% d3ed颅RFuk[|oYlTTW)i7   d9  :=JdHy&Tɖ-4g5k:|mZ{ɋ/rX   >.@#e{e+iҨZ+[T'$$$Dg^7*ۍ?1T  Y IDAT ɼvhh!ce2h(?U]2-w[e߾}ifؾY,Spi-[:   $[ =s~+~;֬Qhi߶}lVwlզ !,?͞-/^y.   @P|[@|]Zp_Gm=:;?AT IH@@@@ H!5    A2@    @UP  B@o`Sp!ɖ-Tw&:ZNΟwEԁ )I  YGJݻ#V\tT]  A2.  :ԙh9uƚ*v~! 5ɼf(h  @ )g[3jed w@<"@#\@\P3j_Z~_B2d)<$$dZs5  W {}@ICvk7S$((H{w4֭ժl-uj66iks"  *7p$  ^"U~C)Bsd˲e& ?)e͚nѣeRcYojWϿR *a$J-J߹k7s3fJ%嫩_ RL2WjR  M7UHƌ˛3tڵi-;n KʕMCjy|lGdccf̐uꤪ>NB@JL  V]Gf͘.ݺv|ӕF#պd(wi:q4kuaj7Çz@@<&@c\@vul֮[~?-יcԪSOΝ;g5&@@[n-#A;@@ رCjC   w`I>"w5*޻}$22\#W\ro)^SR1En   Ns*  &9︩vs%dرg|[~X9?KvE˖vϞt K/ǎhK3F`v=vL8ɩq2  @PGuwV@@Ul. Vի˖-[l^ѢEeժUNJ @`M   gϚy[Ο >   HWjE@|J &&,OA@Uu7    U 7    @ $ [@@@@d    /@,o@@@@q    A@@@@ H=    @@@@   ^ +!!!D?_  {ח@@K iz۱ӧOU! @ގ3@@pΝ;%*2Dn2CY?cN]@@ id  x?t e}r/h!M@@n_K@@ZK.뼶}4 @ ̟G!   %@&]L /![cmLUdYx|^4Wy\L?'Sw\UGvi]Wl3rJGrJN5$-:Ωz8@@@w$󝱢@-YNנ%z>vQtR,H2Y,II(#(   !@,0ƙ^"5*Lz/ĩ Wϋv%.gˬ=|Ldۧ,FH[dtn,2V6Q3^$UgN& KrILF=e즋^aD#@@@FLq[!4/.l90օ|V9ReDgOd:S>Z,k w;/%3PզɥC@@id>=|4اv^3MhqYK/OA@@d?XZ?,d1k9[>Q[tƚ.SgsDɰ#-I6ڽz.z}پv윩w|m|@@@0h2yoi @9… T TV]VYʳ9 @n {@@@@ -    A2@@@@ H    ɸ@@@@^ Y    $@@@@xd  8/|"Y%ST(_Μ*cFkW+WH .*F@ H?cIO@@ ֩-Z)vloNO3gȎˏ3IY_/]|1y lۼQhICv6sQ]{Ųuӿݻvtli$" 9~L1C֩#3g$TpN?tٶmlݺMR:/d{OF%;vL)XNoeU^L& B5\H  zQ1cM֠Kxp5r)(;|m̙S֭[ծ~g`,ZJu4| =)l\C.UFL>|jm>b3y=tO~MI  pUpS  i(V .9?ϕrڱcԫ[E , E^\['Ѣ3tYn-} 4PǾ7@9yɵիf=M7[2&$[m%HBD@ [8K@"KrK͚Z/.*U5k6|_e\5{lU 3g$=|8iٰ QLOQWw1x>iȕ+[yWT@[%n^OMG_O{FVYbm찍x7*   P@@ VumJMԮ%=w\9s)_J/K sjJdԯ /[\6{Uڶj%o@^}2MedM]hۦ臥̞i^Zl)ov\C7;k525IpysR)YtLk.Uq hy}'x$LTaOMPg,G/IJ>a%2J*BNF@8p "NofדBL 63~+˖pFD IA"D@@2|mi:;]g=Hr  Pa$*@@+pT~4p  8d2F@b/03ZWF@prh+    @$V*E@@@pI2'"   TI aR@@@@' $sh+    @$V*E@@@pI2'"   TI aR@@@@' $sh+    @$V*E@@@pI2'"   TI aR@@@@' $sh+    @UHT @gbP U mJΎ+@$L8,    @$K @@@@8 $<"   $Ig IDAT "A@@@I8,    @$K @@@@8 $<"   $I "A@@@I8,    @$K @@@@8 $<"   $I "A@@@I8,    @$K @@@@8 $<"   $I "A@@@I8,    @$K @@@@8 $<" @r ?g=T5d[/'III}k2?=GyO U_J Ԯm[ygdE1,Z0_e{聟ʚZW[ڵΘ<+7OPw@@HK.#@@ ii_}#ZJKKm@Ͻ< JJJ6ml_-|@$oѢܹL{2zRXX(-ZFϫV>j_^n&[ܰar3Z=cyyyּjgeСC瞕6 %HF/PLĥǖr=!?m~\I$!p 8\d@G@HMMRRrN~KϞ=~54iy_ڎiZF2BA;cɇ},eƍrqY/,t;0;[<`sqc3d`iРLdyϻ1˔ȡÇeÊ'h}(&_;8huߍ+I  Iܱ#r@H0SzuOx&B)<\ջtU}ycxٶm[S5եKg9n5a̛?_jgI+z;~0o:b=M-_ۻ/.;FlvTOK+V%$@@ JdQr:  &tfwٳs_[GOJ:tHfoIn]ſQF݅~mݺttLI[~6~0q]*cFakDFCy?{[Jƍ##o]viO5q@G|b+gH@HrdI>t@*O = Hկ5u]K#o y埯񢢠AgNs< TdU`" T9zM <8Ϟ=+G1{x ]"ꏸfkMgy=3$kАd2eAzݣkҔvm6i2AΜiI n{/**6Q~:u*S $OL  8&<-[F}-=7O>ؙgSLkyevҥSgپck,LoYUx\ۚ|L9sd$1~OȇӧɤSpH3OLOOwg+6ܱx@p3ɜ?@P`Ƈ&^{iy聟 ݯdT%c[.Q{gjT{v6]fΚm׷ov%K,EeϞ=~c eIdf ָ_CB)*pNF@Hi޲UiR! #$grd@egdZm ,rd] ^|0r}8~<5'Q:_ E@HFd8 @B@:lxR%X'R_Y> G=N   FY:vP.Aw+E| $@@@@,   @1J,].ݻu:uj#Gda"),F fU*U"  UY@d7d͒3tdWe IA"D@@@Ik֮cǏ˹Yk9{欴hI] V$ɪe@@@ \RwII)'OI͚6 @ $TnC@@)p1ǘw[qZ5O1YYYbgv@$2CMG@@@寗K֭F)W.kg-e*2ҵKgIIM;wD8g" -+&@@@ l߾C>+=wDQٳWP۰q "Y&QvQSN@ $ɒ|   +-[D_CdҔw @B 2`@@@ >d@i$ɜ6bċ    s[Ɯ @@ OE5ԁ@ >g$i{k$@ 0]  IGjA@"8p,["9@@ z[FoH      I$|@@@@HEoH      I$|@@@@HEoH      I$|@@@@HEoH      I$|@@@@HEoH      I$|@@@@HEoH      I$|@@@@RSRɓEH   @dXdn Z FXI}  ! b!Bq  PiӧOIiII7G  *34Ao1T@@+r-o    @|R:tN,c@    q`8#   _$Yǀ@@@@,@,@     I1 @@@@8 $<    @H @@@@ $<4    d"@@@@I8#   _$Yǀ@@@@,@,@    H 7{J㯾Ts2    ;ID6S    *rsi9    $@ISO1wIi%1JZnUy VhGYZhM[ Yj{뽒^Z&<@c EUn!c"!  8[zͷ&Jzt;}}K^XZjʝcxSsEʰ!w>Y TjaC2g<:X.jҾwwaY嗲园BӺ6i"oO*'Np߲其͉oO;6Tٻo_5_ԩ>(b}w_cQEbYGׄud2ٿo,CXWUJÌQC4&}=ƈj@@@ Nǫs-Կ2Am8qRJKKԩSrI9q,5M@ 4POe⋕&WC.b{̔C s5W˧f|v"wd3:v /O6k̃BFXM*Ψo+n(ul߾#V!TOB]H'A4&}=&3@@F &I2 @g矣 ܒ9zkIyп?3ם sI&fZgiذ$+ݺJK.6 ?33S4oܶ.,]\.lBfISnŒ!n!,5k&mZ /l!M싕_Xc_Y+_}LwϢ}Y,qR^=X\tlWMӻ]&;vI9헖{xc),m~p]2 ҥKg}~-Y֩S[9" 눤N_W.^NW.͸L.;fc렁vd}Iv5錽Çdkt$WۈX/^lݺug#GO0C_CٟK^WJfgрPQ^#7ץޏ`ub5&!˪Wb~ QT\, ]ף^Ox7_o-ز>yw8_W d$Bq   NI̵ /ve/㕘|>w sgukR_$#=]>r#ׁ&Nv33gHԷsWv>)Yʖcǎfg^Fo( ~]ku?}]дטxIV[mDd׾e`RKM,}eIۻA]1]efȔw->W_iLH.7 Cd„m3P TyMs)fi]&IYۣ[7wL{[]$O]ENÏ>mMs-7df58]%kk/6Ƿ2uj|գ{7әrQmүm}^[inN6_uv~>d[;ܵ[ufzi1wcj|֌ήӤiQV-sDWW;Zlzv /C,{s7P?'b{m   (-\8oDNkln|W=%qK7y ]>鿦OMZto4=Oq&5lhخ3B;~ў<]{.?1I+̓+}43<_-&b]զ>Og_;ZJ̬1]Ҫf'캾}֭[}؄.t}=t6 u&kF^uz;EGҼy ܇^ƠO$M~Qo i\[_c1kg!U*JAfz}W$s-j V>ȸ1[m?z=͜=$pƍɦ&̃1{ԙ=z^ѣC0"`YfӍ7AJ~>lεmc[8X@@M C.yZ%d,р;1&_u j<>WNxI Pq*Ζ@@p@3tϖq+/9G@L KǍU:͵鵯4ɫd?N\>6~:C vH&{  @" $lf_ĖN}xB8EPn]4l]pN۱3n0p}  @R2)zM'@@@@"    f IDATL$ɒi4     @D$"b$@@@@d ILI_@@@@" I'!   $IdM    I8 @@@@ H%h@@@@HEI    $@,F    D$ЅIENDB`input-remapper-2.1.1/readme/screenshot_2.png000066400000000000000000002005041475433465200210420ustar00rootroot00000000000000PNG  IHDRp-niCCPicc(uKBQjQPCDEBDR6Hd+^%5hiZւ ({2PBs3tmBьޥNz/Yl$ǡ]@j}N- “aeECpK (|d_gk،Ezz l`-g£>=_Q_N"J ^X#OԂd7g]< ʘȒ_ԒtMK͈'OY?O+31^BmC.T+}lp=e_>E5!x%b\ AOnk9۔_t0"=?h z7 pHYs+ IDATx^`TF!.*ҥ) *bÎػX+!R,X (H (kH%ؘ@{7MGΝ;dsvfn(wbʉƕh)F@@@@YYY 9iu7πl&t QZQ2;dJFf9    @X a1[B$*zo tڷggVyv3RHO31cax    K@_钙^jGm\ztT݆TOHc\-    &;mۚT#Lr 3^@@@@EʔSn&`A@@@@ euMig    @tLLh(@@@@td~0~@@@@S&ի%Jo5S \7钜"W4̀@@@BH WUtҲj:_)HZ|ڶn%W'@lLT(G5l @@@@ ]nYRlڼY(EZ =#]n* +zt@@@pw&Y\l6EC!ffeƚ@@@BKC+@@@@OiaS ,D@@@/5IbB9ǃ@@@@Aŀ)@@@@BKL2zLo@@@@ 0r    6$+D(IV<E@@@ LJ{%ݺ* ׷w*:m|7~=,wt=    PUs<%6o?H^̞1] =8]yIIږT Ǔը^]6nڔм=[i2c˘ )P%!AƎ)e˖-rbfl@@@p&34A6mtO~cGǯGJfQh]Jj֨!~WZF{|/=/ڷ}HMKM6gɐa#d׮-Ϻo,kRǂ!]d52t2sB1G7Y}HZl|SN7~gLVXi5lP_e<  !/pZS垻nd_I=h3%!M'۾ٳG֬]'F|jWP$$  P|Rg}6|;g#&Mgʘo-j=&;vʆ֯7]o%eЛoMmH{$ m͛ >L,m:*=%{Q4>VtX  2g<ٹsg)ԧm.r%Wg5KW]~4mҸ ?kuُ7y?&$+Xv>u9rt=5@BX@NxG6lXd 2O_,_~=ZհAk5@Acr5D9˗_]┎dт;o*+qLW_\wb[n&ء]_L=M=q;Fd4{$9$Δa?sΝDwݔ+{gAޔ ce·Y(^|a>7I|RmҩrJ'NgP~~m{f~t2iݺ=NEF=i_cgi3>M*+2k9iǛ=w&]bݿsy MAfyh2%###iZ&|6#7I'j潷Ik'+L>|M9m lRr6xO>&_!:s >IO;OOC>o{ek<qi,oܿ=wfLJ&T\_8oC:ujZ_ͷ?@w3־/Aƙe˔?;2Tt_ʪ/qy;lg#e LTumwɟKu)fmZ/睼%1m@ vҏnw̫~U̇Q8K cqhƾwx.Ulj&<{-0w:ݣ=eg]{C_qA]nI(=!ͼgϺ7fCwOVu -,22nN?ۧ^t ntwm}\ra]~m]Yw\*2TKA8~rMTTMhy̦͛mt)ƾIz_|XۼOgrmQ jXb~ U@ҍO\I9y3yq҉ ;whxfuoMi,g' 2={L?a>2wtWf֝vO>l?{ߣfkݷ^;xktb6% R˜}^̆ȸ1_ ʵW_ixg6wcfIkUqmP<>Xy7m{C';e6&(ʣ4q{ߛ,_~R|bnnUɼ_MO 8kyWퟺag=yΰ+?G8R@qvO0i z>;'WbEvy3dåi9GLK/P6}`rTy;;o y}F%WTMli>Mt,,_!W]{t].qA#IIrNʽ1|{ON%;<=_vyO246yKWu"Æ|h;{vT&>PgҲII '&f}a_ןd9٧m׶S|] o]u1x(  ^$+@9sJm%..Ίt=49% zWszʁ6ukRCkyV͚s.}M ,ٳg]&MiBMڕ.U&I%'Z&,3YiˡS/QcҥKˁ3ei#1<{twf2mRI1O:vݺCCdƍұC{9],ToƎ_vi,Yjwvm?̘ilq҉RR%7ntMBtKKcΛ@2Lic=q?Ca$  ~ӺuV-O"]/HNN3ƚJ| /I/MJ۷/8]ɧ_zYRL{G&6o,]p?p@^{%sSL⬇Ybw03xD|;l'3ԩ-_}=Z&Lllf>a E.wL/t΄.C?ʮFʋ/C6cNT4t3-iiioFؿsnݺHuS=y%>>~oΝOMܹ˺l@%$u|QsM̕믽F6ܜ1G3[pee:Nr\>$%m/,g:CWф]Ӻʕ_fO9'şk51U IDAT}IW_}y@(~>*@*Igϖ,3 sNR51DM̗ .[6A%gQMiD3 M@oadZQFvp%??Kc 5];K()Jn+}sNc~}Ϯr6}g[ѥA]>Wȴi"S-2-K/:Ozf1i/Y/i?m ?k֬aԙazS]N7&JkCu/\h =/; &t6' @\CޮQ]Ҫs5cҒߍ#:  z$B/&@B hrGgd]Nlg}?mvcM2uh6Yڗ3WӢn+>l<]K>x-'_찗 :걇 'W߃eQ^3Ӯ}^9rY;K'ZUd7ׇwW`ͷ.W\}:Kϳo~[]n7lW1M|&eZ*Q]?u/Q_9ϳ1.|̚}eٵ{ML9[L8CK-'L+Na'19e˖-oר.r=_c2 IЎC@_n~>aWދKi9ܡD.ә@>ռٳڵimwYO{̝3z=T@\:6s>^Z4)_YoͦwԽ<ҬE~y6S, d8e^lǷpѯ6Ax]sӖB,AWu2w\jܽRWlm*fj>, VDM_iղ}[.{2s,P{_a}fͲ|:^C~vNa'1oD䢖N}o+1~kXh]LA@pqq  1? ucvKjOW&2ɘf7ɒ%: ~#M2g̨/d6I ߎ3ŋ]IG:ގͯӚ$S?fEOMu욐{7팶^yM4n\p_I*O?Tm,U&nﭿ ־7e}v=w=`g,{]ZҴkڤ}zҔ2ljʎs)cdevҿ%KRڦ2,LHH=jX?LYa?c-Sgiфu4qSu1eᄑv?._ESw~"ui[KL±I&a⯱<\9kTc1l]V\,e  #L%#A@ . K#sR4s-لSLlmn^`?ΆWݯ&<g6zTY`º(-ގaN*[T^Mo{g謤Y]4hPn,y8yz6A櫿;U=/ *OuNu$X]o55w*q=[Oc;ejժk` 8O&o&#lMg zMcSxsJ Uf&StiC?fTZUY~<>/)M g,gy\[ ! , 2.@ ;dYN #G):|? d쒋@M|iMoc**"3Ɋ "-PK<ɲ?\"C" 1k.T:"ٻwoY碛,omݪvMҨQr`Yfd_rэ$&&Fwcj BL=}~w<(g:lGl ԗkJZ8IW(/))2slyi GLJKe6A}CvԴ4ٴiq 6BvWXIq TdAq@o6Y3RdYttNOY_qFwz7@>"u.]J{lܸɎ/_="dng&zD_M;o&'N21W6o*5kTJ*â?8|%>|`ؖ/_NjPnZ:=sC_ٹs_rR׸% Z$YEip@Qdp,3lYٞe:ermW:/>@J]YDZl+Z|=o&T'e3e'el'd= /TC)N`oŊRT)?a޽>nMM{ҵ)Ү]2lg6*UMڶn%%K_/.[3N:\ uB!9Wl̼^>QB{L6]i`ve,0;yꀋ"<8ޞY:4v]r!okk Yͷ{|$;o[]oqS"{W<  jo*/=>eie_KF+sΓeJf'1ҮzDqL2UW]Z˯G˹fƚ+WNNQƌozW;vz?'gV,)\y .N>%^vY~{lA_'.⡆z_zpFY^E5m5S-(55M~?]ڷk}}Is.+^u9U.WV7׸?gDdEƀD@:Ice)v˜oE&tg_u|=u&Z׽[Yvi;O}$OɪWfg;۷o zm=t8wpFv=,<%}7Əh\_ϊjմFf-ۖ,ߍ(u=SspA)Ί f[9z3[^<)S|; +%Jް3<%׺G%&$8~^+ꌉ3- -=Δ|f7G!CKGf qlU%##Ce?wH?zAKb*y󑉲Əh\#5WK=cu髧ƙ玌^Nz*1ţY@,@ѣ @!,YRC~غl'e${Uew5F=/v>3>>lXl @0a/z)fiWV-m=mw_=u߆_& s=ΐ?]m/Ѣ6t@a.$cZ.7,uAoI_N)ZΆ , Ǩ2&@%{#fo֯C;{7{yY sDz)jժڿ^uEoH;o=oh7~d].Qvg7u9m,V;[l>7]sZ~pan1Mfe69zpV{=mxGa.=ܳ[o32FG5 Hx&:Ώ7+JY> Mt o!sgX^=5磠& y 0+T3;fC?1E=6F;63[ڷm#~ٶ=ad 6Q{?e)܍Mhy̆q $c\ 4|[$cJW5z|9fC!7͍~<._Ǎ%~sӆL q 4 T1ڽGMן+wu178$˔.m~|Q,z;þx]kLR掿β_;q˖15/P,گW!_?\*:w=װo(˧ĸ>*^:v_N_5 3=rtd3[7翩uNR @/$ɸ<@L/ZPDymuonw6_%ǬPxbCO_o5rɎ k>  $B9: @ $>=쯦?Iƌ} Q@I@( @H \NzS 7N_.*o5ZTQ< w 2:@@@@$ Q@@@@ Hw|    $sD@ <sX?*~1%'@EdY 5-Mb$Iq@8IMKͷ=/fzLL} y_1u߈1 @~,@"P ))IԮ)ƟUm0quG,s&Ȉb櫷N} y'1uߨ1 @~QM`? a&IVK$&(sIuYҖ$Y~DGquI0u+^NzKL(kD@I2@ B222$33CJqDIHLLWw(XJLBØhDt@{qy *_ q×g)1 ?F @h 'Yhƅ^!   I"T    )@,4B@@@@P$Ybs*@@@@`Ќ B++%;].[t U{.ZG@@Ǝo9g"y%jՔ* UKMKmnvS(@=$+zLd]xovi@V*>,%:]&?ȳ5kJdw"0}ylllԨVUjE[>P @ 2DC@` >$|Y0i@ofINUd-AOi4 J0( /L1#DO$Au )[DNWJ%ifdQ]r 3?ƌ@@@@3ɸD@@@]lx[@%$sI& PW./Sr>@ }&STBp3\B  @ h @`H֓@@@  20#*r Y,6!E$Pf V[Q_.]Rj׬)fYe i.Nۻo$oɻC3rA@@ I%D@ʕn:zIKO/B\lԯWW6lhj>hqqqRL4ִv:3[-Ms<   w@"Pِ^:&ARMLmݰAji1q1՛ tٿVˆnRNBM   @^$ɸ.@]}exݻrT{+WYe$"e  I"@ @( ęYd*TmAh?)iT(_Ab%##CV^# fhJC   @0@a @ }4hP?Mڶʕ-+}oQJ,Wە+V4 9 -$v-͌`lfYg]s7Ψ(Me@@@I28V-[ fFJBTMLKw>ڽK+W,E^1,  @ $3t"Q@ؤIc{ģ5KbUw'J*),2ό}dɠ%4h;vʕ+?@@@$z/X('k#.Yk7^?A6)jѥFT7o& 7ז&Ypb{|ҥ}Rv-uˬ~={A뫮ݶMkR˲O?ϱ_>bt(U$55՜DlQK\\lP[zg^d "  $"4 H8dɒv:\ԨQ=;!dO?KjUeȟ\ޭh泑_&aֳGQG$|ս~1SmK%] V6ݻp2~$]rGĈm=3`BrK@@-%I; +K* 6y_qM˕Zjʜs.M8K$ vi@@"OdsF@ 4m,m 1fV.%y(~rٽr.DUL\M:{TÆҤLJ*YYٳ`gdd6~tti;x3Uf@@@ P$%I; WXc|vZҵ˩6)KgiL)S49 ed'4ST)s]UZjҭ2f8ٲe= {?5əsREgF FѶu_2_ œٳm#|+L  -rȎ?G bntڹK֯ ~i'kjvʴrG݀_~ew~.\aknd\͒$T2gUWgm۶êV* +ۯw2-[oz-N,P8 Jjv9dJqAuru@@@bx@ N=,]lݺ5Pw!';UlkMD5o~M{ѲaF^a6Xjfkݺ7lͲzZ[tQN883kL1$카Zs۷{$*XIYc$$֭I&^{yoKzdӟu+3`f/]w~fz.:,s5n@@@ 6k|0t<+ K-3ɵ̀,&&̾dYv[ ^&6)   @Xn(IA\"ާYsULMifȢ z$)K[    S@W7?+Wt8 ySR劲,Qe} ӦrdzӅI@@@r pwK.@ RRUk~:|JZzJٶ֬Ygn%): MoN`3ǚK, #ʱ   K=| < M jT&W 2M@b JKgaJzFY*{wz    ,^. @l޴YHJrJlٲfGɲYbFf c   @ 0,/@83vZRbEٹs0HZzdftY'K *H$zIMoDJT7'6   @Q $+*i΃Yc]*U4WR%JJ טn¯%,L5w`Y"ے%BY ?@@@ _[rq 0I IJJ-[Hff,cIq!  @8$ (2@ &Yb$-@@}"=D@@@@ $ɂK    . I E@@@@ $ /#   @$ D@@@@+@,    d.]D@@@I:    H Ht@@@@ $ɂK    . I E@@@@ $ /#   @$ D@@@@+@,    d.]D@@@I:    H Ht@@@@ $ɂK    . I E@@@@ $ /#   @$ D@@@@+@,    d.]D@@@I:    H Ht@@@@ $ɂK    . I E@@@@ $ /#   @ u.&]D@@@@ xQM ^  K@?7j<  D[@@@@!@q    A ID\F@@@pI2wĉ^"   Q$Yqi@@@@$'z    DdAĥi@@@@w$sG%    @H@@@@!@q    A ID\F@@@pI2wĉ^"   Q$Yqi@@@@$'z    DdAĥi@@@@w$sG%    @H@@@@!@q    A ID\F@@@pI2wĉ^"   Q$Yqi@@@@$'z    DdAĥi_6"*_FQz! )p1ȫ/ ӧLy?ϒqcoEJ,w~*m=P@ I1fooa;nz𣏋g&ɪWVd  @q \Sҭk)[lM*kגnN{M` fy@ W0C@%K[~sgٯΞa?ݼsKҥGuNq_1  !W@3LPdaT    I2    $0 @@@@?dyQ@@@@ HaP    $6    @ $ à2$@@@@HEm@@@@0 IAeH     $ϋ    a(@, ʐ@@@@ I@@@@P$Y!!   '@?/j#   I0 *CB@@@O$^F@@@CdaT    I2    a8&  +VR%!A\9: @QdddȎ;U$333 $IFA@@k֔r+%== L1d k@p111Rbԯ_OVp:a@@ /PJlڼY)i@ t&ۥb||FLQ  (aXF@ B222Eg0,P    Zd G>dܮW%;u@@]ٻ o! tP(M bCWD #{Sz =@zr盰KKI6ɖwa93n~3ÖQ`B%syJr߽zj]c̏?G,r?ƈaCo˷xqlg=EJ(@ PT р{C إ@=@mmz `KoϴC wĎ,q A P(@ PV$  B{ }ucuۯP_=n7_ć  |+֯L|u+3Mj ĊN|yy&g??_|gZl۴~VW,AxL^/@˞n$aE PV"F>9`%ol(2qY5(&#,,Ow y54vf͞_z)fMWU:vx;uAtt4J*wZEd IDATj5jתE-L27v;#.>%o;L (@ F@2X(@ PNs^%P20 3;66W᯲@Mk1)ϴ{ s,?;w/UT #tYVO_3$4䡯bŊA`ҷ8gM:xL=^6ׯ|(@ PlTA286" 1dn+^.\1w4oU*W}݋V'kx}'w2=U}M?L'?#?2R䩿f݇g~󒒒nlŞ(@ P# L3~0]vnߴ־c+Vuo\a-yO?OxTrnY>_Gl+d83X )j :Lzb,_}㍷AZPf͞:kaͩrQ|X׭ֻPVXIS ݺѣzEjujcd64EX=(@ Ld}&.e6ؾd_~iyAV2;˗uX#S)eͳJΦېy|G 3*:2I)06Uz̿7ؾc*㭧o޲.]KtF Td3ع+.ܟ;/KGJ%y ݿhR aLPK1FC_|!uH&;Tc\su?GbЀzCw[UjG^=hQڨ9PV-ǟsߍϛGG x %t=+Vƨ?|}Uhpqu-0\-OaEfmϞ6@y!\S3}^ĒoTLP l{Z`,[>L4ZD/9@ax (@ P Ąv}{%RByVKȖXӦ)3mo" LWfϝ3eg{LG|1@&-O!m{EfMlD}ݓFQK44y^!cG:3DFg(f͙6OׁRS%""R-Chk|z8pH}K/l8iw6[>/?vz;>A}kӡ07RvO=W==*0o=Z˸[ѢyLfnY^f}Ao[m7 rm۴+_dzdqu>G VA)`7gffShWk_x (@ PR'OBaqRt+UYwId)vA$%&_ʨ2}޶;c7ncPYmFÒ بzg ցq?ODbb)lkZ OZ4CY[MM_J YhcTFue?td6nڬ2P$ݲZg~xwGvNpñ'.^Kez<&GΜ=2N_$S:'Mߗ*W,*u-ߩP˯LcN/QO?@eD\UYmۦ •D%tK 6/ [Sx<(@ P8r(.{#P" E>]qwKP$"SXO@2R{Lg c~/.өdo)nniBB"Z=={ɶcw4:0 @nezILv&2L>tDy/-*i]H[ntqqn nomϿ]@^g2pԴGW5] C Lݴc2]4OGYrvvV^:Ikj0yI&ԛ8N[s/зX$E~Ǽ1d(< &O?@YCA2(@ Pj'V鋗WZ0Ldɲ@l\oyz3Ptt S?†1~YJW"\6UeV}V[m6iZ>tS!X:j,ú9E2ZLO/@]5d'Pkb2}D_YO#2J]7];n9 1ȥ &S07_SAV5{nӭ _{S/{GRƎEDF: .|,[fy{bn%hŞXWw^lό%Xe~(ƥc6?|wss\t pY(@ Pm DYIFz 2-GcRd ;&>40Q&5Yo%N>Ͼ7L/-%`.HBrg(yKf:5+sa~Xh:]EU1\IXSL\;W02;~~LliRdGǎAu&ezo^='9MSI ejjcʱ)5_b0{,ˌE6VٯKOo@W^=qB))!y\3|vdrml!'sFa}42j)ؗz$`K(@ P PZL--e¹X4O}j'M!s+]L֣ۙ6={[hhKFa#J0a*}d=[cʦ-'e|d1T|߻7>PA3g`ƯSiLsx 8UY׼kk(@ P-  dg,Z fΚ}nUkm: <7/ /A./pm>32Uۦ ˾$`QxówL_odcKL2{~G(@ P.?T_Mo2PqlVíT@4?~OȬt, 䕀d~e*u +p @(@ Py# ;S4Yc갉ysUZ&M@2dJ,kIA7קY@3Mdc pC P p ];|.Pz8zdž-slNtg)@ P(@ P,,5, (@ P(@ PlOA23(@ P(@ P\ (@ PH<(@ 0H#6D P(@ $$& ,(@ d.d1q(Y(@ Pr'eJ[*(@;Ybp5ddE(@ Pȝ ![WRQl 䉀>Y(@ 2 9{b bb(@ PHMM(@t(@ P(@ PPA2+6(@ P(@ P $_o^(@ P(@ P &Qw2S`-#qEӧ7< {ť`̫g) 3"** Gk5KF:)j̲j9cU|(@ P! eJFo=wIIIf R"aw[ ZT4RRR (R j$PfjbcjEfFSS3!(@ Pw$C0dfjYa̼|%e5Hu Y@fLukU( s^S.bΘU 0#Aݦ(@l Bjeb[򢑱[))ɶ)oWfScZ'HVS-@ 0{6y% Pmm9S(@ ؑdJ&nWhpz^|لg ::&[#t S \\\plσ)@ P(@4ĉSѶM+[(S6qGi`SL*'Mrٻխ[!TP͛6Ak,P^{{ 88u ֮e@g}e_c:&.nOw`x 2y~2 EPR%ܯ7~< DTTTe==}2Hf(@ P ɲLl4xhvظQ`Sk`]6t) :WhLSYR iY=>9T[9gWH3H&MUcN¯xqDFDGa4|ȸȱؽ{1>ʖ ;wkm=P} *3Q.\-[3 < 6C@,Yh԰J#>>H{n'G2ǯZ**@6Xj >.zq}ZdU|7j]8s1m/]?g56񫑟 44L]sWAKYr5FI`8y*xа076nҏ_EG lن|ŋa7XERR-^ L'){[Vcj.Qݳ|Js2{,iiQQػo?^{6uzU 洫̎}KefMz}KgY֟(C+޵ |||Lle8BIHhhy9nO(@ XX@>J2Y>>sɦ]YQjX <۱/ ۶wVد2/?D?QA5{VoEi9ǺrWoټ3g;=V~Uf~f͝dPh7qra.YPe "6o~Y^OQ*{mt`Ytdku?k*U+Vd5&*CCL27lt2>?qU{m*6nڌ={C.X~[>N7_~>?}WѶ]Gx{cOc=7_?22ؼ̎^=?kx@Ugg|(`Q{[WlڲgR;T[esndae(@+pQi۴j /5eHF 1aɬ|< yV-3y]S7oc˩w/6Bg,\;7Jl$y|:kIndHqT5k^ke7J cǎ@tLa 3g#.6NKɚfEx]Շx)vDQ_g-I9?˵$"A#m$+W]Ə)f I[l a72 5&a:P;תEs}L8col̙>e^.G02_PÓ՗0`ߠ}Q)<׽|Do. ռeO P䳥,k#ڬN!P kdV,2.d|3Cwww|o5m*$Rq(M ɱY=wvpr|O攳جʙ3zuFj92%^cR.,))ooe*!3PbT'ի)A! Tm ɜ)o"H yst<{~=5uyle坾&s/3>M9'g=Y-yo|ꉶ褖 _'C1dwʿ%ZHOKCֶӣ{W}(f(B%'_xr B~JZzA6L'8z./?g5&z3 …פ(@0dS8( 2dyiH˘ޤ/fO[}xD 2 *XP/ .)Gm-mK( Le#ǦvK1iRVSَ-4ԳrRn?ݻ lĥt{9tU!zԕ}f{T֬[E}Z(:az-Y8k/Fo|&sbެ{ }z,[4CxMYԫj(8eZ5Y;yگ5]kV,֯ӜqjL8A P+&s[ԎE26A;f(/߱qiY۳oɴL 23EiȓHf|!dȨ6t1|Ht| ycmOwIZ~ פ(@;@dS ʦsƵGNkM 8/euW5}v֯^ff8ZGcT *]mgl%Wwqak*SjemAh]\v`4U,Zݵuzf~`RXD ש܆.Z*CK)~W+{=_6oipŊФq#̒LC2Q;:]d ˱"gi[fr=*Ks DyZexxyy/ l֏ GY s3e䆍teٮ=}6go8Jo>ll: OWE'N>tԮمd"*a}I?wQ]) ĉə8M PH'`6'@V;kӰE[ Q+;brc^}zǖ`y)*Ps)EOW ջ[fVV>=UmZSAiSX yTM30msz޽zb^d2I:ѐ13d;]F56_SsƲ UP {G)kZUO2Jz1LOP;aW~gX}}+:M ϿX(@ P90~9=dfw&afX[&M!YG&!),{nWc&YƖdϣ}GN0iʴ|9sty- PK5>P(@ XD@bRdzj˗K"`%(@ X-sL)@ PM .) b [o{&C P (@ PMCn,(@ 8[:ޘ(@ P(@ PID݂uWΏqLCנ(@ P# 0Hf'\,#PH-p'm]))oWv _u11(@ P eJ*@lz|.,,4~||@Yy _YZQN92ZÜ15&EG`#>N P' ΝWA TR $,,$ .\3UTŋ;- 2 ɸ999kն$N97#+(@ P9UY_U&SR4/)VKԔrX x̹$8tVѴc|rLdicjNUtLaݎe.AA]g/B PB0!::.^3ɲ(F6*9I66VI11e(@̞3(`9R̫Q eWS(@ P d`Ǚg9{!Y-(@ ؉@``N(@ P}0{>Ƒ(@0-(@ P(@ P@$1O(@ P(@ Pe$ P(@ P(@ 0Hc:H P(@ P(`/\^F(@ Py'''l?(@ P /"55"a"(@ P(S4x HJJ}(`( ' %[ZP(@ P ~|%S Ps2I~gkKfYJP(@ P D5}(@dʥTa&$Y(@ P(@ P 0f (@ PK ->l6(@ P $C\VM P(@ Xf^S(|P(@ P(@ 83)@ P(@kHtKkHd4*(@ P(@ Pi֣ (@ P(`%$`3(@ P\׬Ypf)@ P5 lʚöP(@tKvv(@ P;u@2AuP(P\(@ P$Pd խZT;"%%Ŭ!foAyQ\w 3 fct <~:ϝgb .hR[)j w/V gw]U(@ pqqA qLTg"999 $3ZVL PKl2hۦ5݇M >.%ѸaCz@j׮K/[4HuJgΩTA_&OӁ.///4k#?Px8un䩿b|3՗Qca5gӇ"vYA >K P\cmZ@QVC*jcb¤)y$s)Zc;eW(@ P TT/^vgl'uvU||<7QQ* kH$饾u,&&FW^#GQ(U$j_]!PQֽkơexQRED^j#'uoZ?Rׯ]&<==qy+[:DZǑ… DEG㪺t5ȆX͛5EzuqT{cq *]WBΠkӺ%B°{>$$$j1'㒉k'7C,4N6Q\Y[:LƗ(F ō7p۪]N~Kvz;dIK P($Txq["d d*yV Gw۱u+xmo?q* YɬNy*xsGQYY?ț7m%˖cugndjsus Lut|/[8' bެx@._s.S0Y(@ PUkt5mZ/dV,2dMO P(ZAXuVnʿF3YVj2*sdn^zXGZG-"">>Kض}:uh'ڶ5&J֘w9 uE\u&K^z_eP>/]fId5u?#sTO(@ H({u+cs-pпXruȤ] I P(kظ8]dÌ !wF'wrL͐sͭprJT^V>UdRdefMHJJ׮Ea¿PGֽ[WTSm߮LRkɺdMzd#.5qTʜy г{W̘7 ԵX(@ 8!P3~dOn~P(@ P Iߪ\+V~j"tJĒ9qNh"Egi)Eرk7_y ʜ2ɒ{p!WݭY(RK>91'Oƀr]WfHpPb (@ P /$&YcTpLds~Ȥ? ŨN P([mGT*Td!ba=2XL`W XrC-g|%J7/.:\F2nҴc8dm3Yy&鋴KRdoς~<7=0g'go8yU/cTQ P52c oCkx (@ PaZJدen*rM%VJi3)+V@1ROs޳W{^C VD^Y01uJgqt{}34,T_KJte}jɚj+7%l-,3ɸ ƍr6jYEy \d3_OE}3E/lf(׬È>6Ѽy(@ P6u^)\3 DxM P(`-[N5Տݻv\?Xk]d(@ PU~L2 +(@ P+S:XOxGeͷ~9" L P( :L P(@2ݵkD(@ 8d=<(@ P Վ,(@ P /e^n P(@ P(@`&(@ P(@ PKRuS(@ P(@ ؄$ab#)@ PG qʞRHA2+ 6 0u/hެJll,.]KbHNN6Wgg}F rrR_d.]JO 1y DFF D }/^2lXdS(@ Pp`E Ly >w.Di;^JRbnkb% WWD⾐\ Ec(@ P(@ d% YVB|ȱd+[v9q$7l-Q /x]3'%nۆÆBcpCCpرn'L5\]9[Ȭ(@ P@ 0HV4UWm8~|16i+cἹYǎ{vZL۶lڸ~xi@E-]>;6,} G P(@ P(T))@ P@˖snBP \@fr+??xyyq#6 @rr2pDFDBd|!Kj. P(@ P JJ JPZdg(_<Նm'DbrŚ (Y(@ P(@ P6$Ld/_3ml H622B*V*U*ZRe\B (@ P(@ P sjd1@g>} qq(^Ś (Y(@ P(@ P6|6V;@ٳ(Wbgb(@ P(`^Ƴ+uMo0a5,%z(@ P([6n܈Ōb_3U)ow+{i uG{ǹ h#f)`yW$'q~˲ƼHRksRA2KI P(@ 8W_|3:mDR%Occ'qf2-[4ǻo}Xה7-^|ye82O Py%y%z)@ P(@KBڵ0vxĨ'Nbh& IDATxsǻ*ƢW{;INIdn g;aҔiʮR@~ 0Hڼ(@ PTb @DdGC[rN- P(ht y} P(@ P<<<sv7mDSs]+(@ MA2>7(@ P(@\ D 2 f/EԎc7bo]Sk={s]+(@ 0H(@ P(gg[8=TɓruMPB7fJ'S9$3GP(@ P C{e(\0*V.jK DѢ>ْ:BCf*Y~~&?Xd=>l(@ PlFϿDظn&Ob[AMCOa/pGKx (`m k(ylOWP(@ P@hX^ɪJ7֮p3 e,qhqK4uPwsuq1^Ugū6`&u [E P(@SXWU PwHEf8d|a&u[G P(@Xz B P YdII*,-bd `& [F P(@ P bc㌭7lxeMf&1;H P(@ P@~ \x$&&H}iY,%%Yb$qa(@ P(@ PaS0Ȭ{@IfQ(@ P(@p)R>=bk!5)@ Y(@ P(@ PQiWk΅tc"i^B0HV8(@ P(@ Pp''Ok:y$+!`(@ P(@ P} ~BኵnhS[48d>l(@ P@^Baʴ_Վb yq I P@Ξ>}bD.mv 5@ P\mP\98ott N9={2ecQ = vH[Ia&V U C PW`ص{ /ׯ;`ߎg(@(X CunÛ>$JɬrX( P(`2Xl:w:ujahܨԋ>r;vBjj~l2xR?-[}||Фq#(Ꮔψ-]pavk֮l(S&86mقow*W*W\߿uv>}Ƭ6Hg(@ .(EkNn#.!bw~&c̪(@ P+SNR YV-ThL>*Lp <6s; vIM΍V` 7kfĻr ~}wѢ>ء=8ݪEs\Wט9k=fOm9spZW-5ԳӑyY~G=(@ (T~B+ߺ3EM\;jjejBs1HfCR(@duILҥJ5tIcGCʕAW1TP,R獘QW&U(8;;efطo?BBC]Z+3~gRnێ>z a}De=2ּԹRj^zVT /< Jbt39\fB P6"|C=~636 $\:e#$bC)@ P Qشo<=;uxQ jIP*WjݫTpEe}WOO!a=ŀ?XIIطw;KLLD H ⢮kefuՁ[!=wϞōX]5k!>!ћ;Pu HX 챪Ɔ"bxD3GNi+A2[)(@ ؙuO*8\JK ̞;䎗XM% /YL-,g)M2$~X2-RS2Ѱt~ŪznjGIq#VeDիVs/]T:BBR$k՚5jwxGw]ferikUVMٸa}rY}zZZ </tCZKw8HٮSS,%,T1-r'QN]hi<X)\WJ8w!}3y|)\Y(@ P DeVعkw\@^ " 6gAfj*O U uZ̟.rX7D@Xf51@/-v3:.U:9x(֦/]Үn—N.U,w3 jy] ءɿyfg̥KxR3V2fNzu88CMgN]qZ/_{D>}V;QY'LeͩPT O TkyzT^a*hdW*+UQjoe,<& D]joO 8{ xp=b̿Õ$d^$1nL!B}c2mof1ܿvu[}fUfAZZG|5k'' OjoG8vW!(@e$}nywej.^8'12c\reqQ=HpUk/*URȸy) W \/{|B [o{QV"mUT\تÇTnCS'/Omwֆn>9?|?ھ?P$C3`knP6[ݡ#nIIi7)*eI&㚬SjV"fPC  GwXeLE6L06q27B HRÆu RxlүO SA٫2@&#L2y'*j*jzxwjۧDu1g<9lP^ެE+$> NO3~2ΪWAZu X{xxM|{72kPy(@ 8@!e;:[}(SB P(PEO=5j2}Քc\?w>"/L7)`Й]G;e?-[-ҥ.d 0˯!]7nP?UVkLVk.j&Vf ?HvL8Bv(@ {D T`9- x9QKۀ\z޺[d %}LIVO@^&0nzM2'q2eݷߨ0MvUn)]bS \ 5)a k`j׬Sgf˛_ٻW-@/D ؑK(4Z{9zRhjl-mqf ؀d+[v[|I4nuӧwz_g*}غmF cCCpر<Э6X9(@ PQ|l:'.6/}!B Py#RJ:YIs ϠyCZ)@G0%kx͟;O3r|hڤ V\Bf1omY?v8 (@ P(@ Py+Y!|~xrR^ԆkğM(@ P(@ PJE@OiؓH l+bJA2>/(@ P(@ P N}UReL}Whrw(@ P(@ Pv$y( {+"90o Y޸V P(@ P(?"0^Wvp4adGm(@ P(@ Pd2o޾ۗL̄a(@ P(@ PZ\\§Ȥ$\9Yks] Y尰Q(@ P(@ P|b-{|u}BjR"BX+`O P(@ P(`JWE6}=\: p ;J P(@ Pr- ,Kt{pvuŝڏf^GA2Gu(@ P(@(ڤ+ܧ??W?E 8G P(@ P, Z$g)rD$[fǬA2w(@ P@ ৱm[}zkxS'MǮ] 7ݍTZNE׷ltyS5urk7Kٵ927 Ynx.(@ P(@ %ʕl$^~Ut5jxsG?Y[EN]p=UѻWO}|B0Xo4l_A֭j =xVkuEM Rk (@ P(KBڵ0vx\'NbxsG*⅃ՏGGGU\]]1m $S0i4r*Uh씔|?j jBeիao]]]۹Ρ|rfP6 #)шXw z  @ P(@ P<<<s.^f-A裦Y_aa;//OnNNNP<{fd(=}PM_F)H&S$˹Ϥ(@ P(@3"Uҗ"^^{E`X/}UJ*'ᆿ6ڴjxܹۻ콚QwK tA)Q(K ?OD*M]:){?rMnr}~g<&s>''77`qDvV)79( H1J*B@@hL&ݿ?:JLLЂ>v?BWcQEA X/d Xme6N[5 !  Jnp\O8+*@@@/0g|N-r-'Vd%;A$kTD@@@G;C"H;sAO@}[;,h&Oʖ@@h{q*4*iVl^q ɂx:m%[oꖛojz4Hsv6y|i7ޠŋ>V\\.;kϾҮG@@@@Hd}3icdm L 6w1]CnPEcs=/];w~3W}z7z *A@@H5햶TfTjcdm L vQ}b6_:vм7`_OC GZxn{eSg?o-\|4hT[+.K/=4_ѣGo]$'R@@@MBBÔ4[6>~E5Um&K#P@6x5qE=ܳ:k‹O )oiojŗ*??_]tQyEޛ7ߔ]uԑ#G5a*##eݻw 3~DΞ}:=}䏻e IDATuߨJ1B \sZb87} #{xuu:\]i|\**+UVV;2dmoL uر9f ak YYq˴{mz衘X`Vqk:py :|eee;m:JJJ^zzw}`Go]@ 0Æ Qn|VCMrU{ODDBhØhD >2t ܹرc+''[ZW^͜iV]ɮ*ε{ՕW]>p`+((8-׶iW~rrr\o6mlj]}uΟѼ r Q@[_ lSdDӔ߬Yq @` $Bqn *aK=9%,&^ѽj/TV(ә͹ͯtQYxXV,#HWE;0'Y]*|HI頺_0^~}Ogun~kwЏSs}ˣͯO~s՘4Vs6Mn7߇*?Yz] x:wtF &E=6h ;SS> o DFejѭ2V̍@46heו^-_eݥz0>l~k92g…?G탿 ޻{Yщcӟ4ol}‹/ @݇-'b0G6Bgd&GY]ؕʉ :tWFif[l )|b @QݺtSj2K!L} 'Rx4Vl+L;#XJXBΞnbybbbjUg)oڀ%[AVVU:ɼYyS@űwڵkkt6ӎ6>>ٖbJu_lluͷ[O^\'WfE$ 犑^ 1!QG STDMn/ &Y_PP{ HZG8͕ޠ7mvu[lJMowR6˺y!H̯0|CK.mv:g?BA@vEʀ]jeKs zYgF1 {]ѹmNٷ J/6 f_6p֫GwMS /Q1JwἏ^nNX6q|A{Ə9%BfKes;o懝7^}u X`񒥺|T{xʿEڣJll8?##EA d6@vlb ޳WDѣFE̳Am$\λTqV8T*XIjB*@:N|"\ @KlND6_Ym?),,:q}nuf}s%<+0l+Ț k^4bp(#XBBÔ4zח>~U5U@AX  иL6'7Vl^1{ZecW^sUu_|I#M2ge dM;dݼh@ܐ OPU5f;d~7et@@ZD:;T ~%sڷwWr5kf>Of+tjn l|'/9vfj )m;Zj'H3  MS=|BՔ(v DiGSyNiɶnɳjNpd`v3)@'N!  д@Me2}R/ ~&1CCѮ={?,ֵ֬] |wꣳ,zF햞q@@@7eeգw^SZR#G}/7 p:,K*ܹDɂh*   KWU>nkU mF– 7{:Բ/i@@@ b* 2DzFosͰd2?#)cӧ2Oδ9ޘ@@@8v>^F ݻiRqqsGllԽ[4 B0Esap]-z]Uyh9` D@@Vctz g%&h$9IQS]`B N  Ҭ{Q@0Gw^55Uc/c6(J26OQŷΙ(+ɂo1   6 nJ\VhL3c{5>|7ߊ+@@@@ zїF3-+h dhq-   @o~/~HTyI@AL Yܸ @@@X ٥3eg? ]A2O(R   @DFwL9Ϩ2AqV@@@E2[*$"]ف-_)}jgd Ns    = +$_~H5UU-A2 :   m)`sLDΜlH M]E@@@ F(*$<©l&eqW@ $ ieP    P_ (cOK4c/m<" @    @@ $\ c[3s_ f܁   ~"eҮ>PY~{ٞS@@@@q# r,?Ko^(ɋb-;Z @],1vY׸@ٯU(NTL3[lnr WF&jyVA،P-6s 9` ?)G-c}{ wW}ٲ/ 92T]+۷9Œ$6^-O~ ~q^d쾕 y{O1m!=hX\#!\OfhV{։{Mvk8hS\l~9q< Z!p<>Z%Y+ :DΞ\Nk&5 aqI| d%w )^\QO)Snq|aR[cV5L[8'`RsոE{Ձ2Ɓ #d#e?𴮮,{GoQ5U|Zj̬,NMr~b? yf^v{ %19J | @@|A nJ7 NMf=3|{!؄2%`OD''Kl_j{@@S $4L)SPc D__E> A3* $GQmN   ua ʸA;ׅRy@Gs m&@h@@@?R7= (+ś Quq;Uq-n $s@@@@Jv&}l4MRg39' Hԏ    Df4~n]Td،_dg!nڍ@@@@%`'VWB"\_.ٲ\^z@U`!Юڕ@@%pKԣ{o޲U[]]@ hS({wˠ\=E  cl%K#NGbbb4vhuE:p/]EA8!٩Ү{sÊJ-xAs̟)xS   A*o~mݶ]&LPɏ3aڽg +6@9zھc^zj\4y9-D6S3^|Y%6uS@Ǧ+ +|b  @{t*+n)5bl)66F [++**dSZ'#)*""R(IDATR{{U\RTki!*+/3֨6IA DU?VttZeʏ~'' $@@zurQF1\{v9b]FveB|PeN^wm @Y7zHm3[l@̾lWf&wJ;~5r/R 4:Nݪ Ka'BUyʿ`\Jcno;r-5#  p٠Vݫ.o؄ ; mB;wd6)6hN`̖C+qC&6Z68f=ROw 2 [~w_>Cή=j.[;Oh_0%' TW)kTbZsSdnq9  kVW]ټe(5^-6?5W_zeNx(31Ŀ6y ttӷoPٶya͞;Ov[&e#KxfV+n߹S# Nj5>.D -_/EѻAe+Ϳ7B @nfh@@1S.>pNl2Oo46;'G|c NJ &lHbfmVM;dltp[ $,LbkpoeQe g۪MVyS@@Bn[u6$)W:xv fX]fZ?N @ -ۍ@@@)&yYyzԥ_yLGu^n@@   kկOo6_ھ}hg+fl5.D/@@@|J@+VҰ!Cԫg. b\3l`-YLE%>5: ?$󟹢  O4bpMPffX{.i .TQIEK$!@@@(((4E1$C*:&kIi e;Im2{%2F:A2*:  @p =z\USSm^5K`٠XHH(9Ȃ` y @@@-l@ݔm!K V<    A/@,@@@@    A@@@v*;3pUUUzlmb¬%<<\VWd<+ 2[l .6&V5?%%%m@-^|XvKO(R  @=g5hאsQ׮]cV%))QMm΋\nv]%7ŗT^^ܾl vJ7+ʎ;nmr}FF`@wlO$oS}5wl:dhq-  @ʺRz̞YWo)+NUBҬ6EvaYVdFMOJ]v{c}mMOoU:v`N  D=_9ݒ-y@@Mᪿ2l[6V1bXv q^[WFDD(::9l^6? kۛ}m@E D@H);q 18##v19b?-5~Y:o(H?RvMܒzP{rѣec6#Mld;w61C:F6XfNA@ ݲv܉ @޽5J+.wglⲩ(ެ 3(Ŀ6קBs2e^=Ui7ع9M^V&^[Mnki ڵ;n{׼ ZTy t9oߠjÚ=wWWnYm fKh]L@Z&ҥ{Iݲ6 @@4S\UNK-מ={fʙUj'! I$KNNqzԁ  "HA@v A2_   lq$'Y> @K武i@hTd<    A/@,@@@@    A@@@@ H3  TWW+4}d: ~T;$@@V TUV(&&BZY# {elL*+=6pDE   **axXhYQ*InFtȪk<6Td"@@Z+Pc~ؗ[[# lt@@@@O Y)#B@@@pS `\    xoN    r@@@@ Hxsʈ@@@@ H&#   ASF    A27@@@@ ޜ2"@@@@7     '@,!   )@M0.G@@@<d7@@@@MАPdnq9  1~%! jU܌  r~kw" q+//SMu'.@@F 2 Y (@@ |kZG@@@@!'H9u@@@@/ @     H9    ^ H y@@@@ $@@@@/ $<    y    yyh@@@@ɼ?@@@@ɼ<4    }dޟz    ed^G@@@A2=@@@@A2/O#   x_     xY'd7^z\sڵKtם+**Es    @p8Hv~-lԊy<3g#-5U鍎񲩗8],С &R@@@@Z&Т Yhh_1GS3h!!! 诈]cw֭`ٚiafޏVii3p5A >wmO_Ncnjvk8KعުY|[^|YE-GK#A@@@|EEAs:K6m4>rD >=G&r9gk꥗_u kS]|dhƌeE_NoB6He/10M8MaZftsSEEve߱c^>SeeJHHPuumg{lۮ/hlyf @@@@p{%٠7xK sK]hniEEi':_ʯ޲ ۺMnKZQa{뺵uDxiG@US3xK&pWۯ 4J|BWl̎vCt"pg\   [A2Rnr\4q,.6e NXMKJkp񦳲+ ]+m;&>vM4A6XIcvgmpl봁/.[]=:رy۠]f]iW?0e;cZ@@@@=nis`ɍeWpսE?we 1'>lqc*:*yR?~effiIoWCOkNjߎnEŮ'FG%JKMuoxMl3=\Cю l=d:}&k3f    n$;Adb\ؽ[7ݷy{l%N.<IkS vyΩ7'j'iWT͝?ߜnysӍN>%˖Gۇӵew6ѿ]UYY,sEOw}搁 u-7k9s y;t谳ʼ7̝YvӷoPn٧ /pNĬ4+əve-G[<ԉ   @Ƞk<Ѝ&eT{LSt|1N@@@@hZMWw⊦ 6;mZ_CK}    h HM    @ll.S;    `%g= @@@@e@@@@ $'!   A2?4    Yd6@@@@? H懓F@@@@<+@̳Ԇ    p2    gy֓@@@@P N]F@@@m$Z)`IENDB`input-remapper-2.1.1/readme/usage.md000066400000000000000000000435071475433465200173740ustar00rootroot00000000000000# Usage Look into your applications menu and search for **Input Remapper** to open the UI. You should be prompted for your sudo password as special permissions are needed to read events from `/dev/input/` files. You can also start it via `input-remapper-gtk`. First, select your device (like your keyboard) on the first page, then create a new preset on the second page, and add a mapping. Then you can already edit your inputs, as shown in the screenshots below.

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

## UI Shortcuts - `ctrl` + `del` stops the injection (only works while the gui is in focus) - `ctrl` + `q` closes the application - `ctrl` + `r` refreshes the device list ## Key Names Check the autocompletion of the GUI for possible values. You can also obtain a complete list of possiblities using `input-remapper-control --symbol-names`. Input-remapper only recognizes symbol names, but not the symbols themselves. So for example, input-remapper might (depending on the system layout) know what a `minus` is, but it doesn't know `-`. Key names that start with `KEY_` are keyboard layout independent constants that might not result in the expected output. For example using `KEY_Y` would result in "z" if the layout of the environment is set to german. Using `y` on the other hand would correctly result in "y" to be written. It is also possible to map a key to `disable` to stop it from doing anything. ## Limitations **If your fingers can't type it on your keyboard, input-remapper can't inject it.** The available symbols depend on the environments keyboard layout, and only those that don't require a combination to be pressed can be used without workarounds (so most special characters need some extra steps to use them). Furthermore, if your configured keyboard layout doesn't support the special character at all (not even via a combination), then it also won't be possible for input-remapper to map that character at all. For example, mapping a key to an exclamation mark is not possible if the keyboard layout is set to german. However, it is possible to mimic the combination that would be required to write it, by writing `Shift_L + 1` into the mapping. This is because input-remapper creates a new virtual keyboard and injects numeric keycodes, and it won't be able to inject anything a usb keyboard wouldn't been able to. This has the benefit of being compatible to all display servers, but means the environment will ultimately decide which character to write. ## Analog Axis It is possible to map analog inputs to analog outputs. E.g. use a gamepad as a mouse. For this you need to create a mapping and record the input axis. Then click on `Advanced` and select `Use as Analog`. Make sure to select a target which supports analog axis and switch to the `Analog Axis` tab. There you can select an output axis and use the different sliders to configure the sensitivity, non-linearity and other parameters as you like. It is also possible to use an analog output with an input combination. This will result in the analog axis to be only injected if the combination is pressed ## Wheels When mapping wheels, you need to be aware that there are both `WHEEL` and `WHEEL_HI_RES` events. This can cause your wheel to scroll, despite being mapped to something. By fiddling around with the advanced settings when editing one of your inputs, you can map the "Hi Res" inputs to `disable`. # External tools Repositories listed here are made by input-remappers users. Feel free to extend. Beware, that I can't review their code, so use them at your own risk (just like everything). - input-remapper-xautopresets: https://github.com/DreadPirateLynx/input-remapper-xautopresets # Advanced ## Configuration Files If you don't have a graphical user interface, you'll need to edit the configuration files. All configuration files need to be valid json files, otherwise the parser refuses to work. Note for the Beta branch: All configuration files are copied to: `~/.config/input-remapper/beta_VERSION/` 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.6 looks like, with an example autoload entry: ```json { "autoload": { "Logitech USB Keyboard": "preset name" }, "version": "1.6" } ``` `preset name` refers to `~/.config/input-remapper/presets/device name/preset name.json`. The device name can be found with `sudo input-remapper-control --list-devices`. ### Preset The preset files are a collection of mappings. Here is an example configuration for preset "a" for the "gamepad" device: `~/.config/input-remapper/presets/gamepad/a.json` ```json [ { "input_combination": [ {"type": 1, "code": 307} ], "target_uinput": "keyboard", "output_symbol": "key(2).key(3)", "macro_key_sleep_ms": 100 }, { "input_combination": [ {"type": 1, "code": 315, "origin_hash": "07f543a6d19f00769e7300c2b1033b7a"}, {"type": 3, "code": 1, "analog_threshold": 10} ], "target_uinput": "keyboard", "output_symbol": "1" }, { "input_combination": [ {"type": 3, "code": 1} ], "target_uinput": "mouse", "output_type": 2, "output_code": 1, "gain": 0.5 } ] ``` This preset consists of three mappings. * The first maps the key event with code 307 to a macro and sets the time between injected events of macros to 100 ms. The macro injects its events to the virtual keyboard. * The second mapping is a combination of a key event with the code 315 and a analog input of the axis 1 (y-Axis). * The third maps the y-Axis of a joystick to the y-Axis on the virtual mouse. ### Mapping As shown above, the mapping is part of the preset. It consists of the input-combination, which is a list of input-configurations and the mapping parameters. ``` { "input_combination": [ , ] : , : } ``` #### Input Combination and Configuration The input-combination is a list of one or more input configurations. To trigger a mapping, all input configurations must trigger. A input configuration is a dictionary with some or all of the following parameters: | Parameter | Default | Type | Description | |------------------|---------|------------------------|---------------------------------------------------------------------| | type | - | int | Input Event Type | | code | - | int | Input Evnet Code | | origin_hash | None | hex (string formatted) | A unique identifier for the device which emits the described event. | | analog_threshold | None | int | The threshold above which a input axis triggers the mapping. | ##### type, code The `type` and `code` parameters are always needed. Use the program `evtest` to find Available types and codes. See also the [evdev documentation](https://www.kernel.org/doc/html/latest/input/event-codes.html#input-event-codes) ##### origin_hash The origin_hash is an internally computed hash. It is used associate the input with a specific `/dev/input/eventXX` device. This is useful when a single pyhsical device creates multiple `/dev/input/eventXX` devices wihth similar capabilities. See also: [Issue#435](https://github.com/sezanzeb/input-remapper/issues/435) ##### analog_threshold Setting the `analog_threshold` to zero or omitting it means that the input will be mapped to an axis. There can only be one axis input with a threshold of 0 in a mapping. If the `type` is 1 (EV_KEY) the `analog_threshold` has no effect. The `analog_threshold` is needend when the input is a analog axis which should be treated as a key input. If the event type is `3 (EV_ABS)` (as in: map a joystick axis to a key or macro) the threshold can be between `-100 [%]` and `100 [%]`. The mapping will be triggered once the joystick reaches the position described by the value. If the event type is `2 (EV_REL)` (as in: map a relative axis (e.g. mouse wheel) to a key or macro) the threshold can be anything. The mapping will be triggered once the speed and direction of the axis is higher than described by the threshold. #### Mapping Parameters The following table contains all possible parameters and their default values: | Parameter | Default | Type | Description | |--------------------------|---------|-----------------|-------------------------------------------------------------------------------------------------------------------------| | input_combination | | list | see [above](#input-combination-and-configuration) | | target_uinput | | string | The UInput to which the mapped event will be sent | | output_symbol | | string | The symbol or macro string if applicable | | output_type | | int | The event type of the mapped event | | output_code | | int | The event code of the mapped event | | release_combination_keys | true | bool | If release events will be sent to the forwarded device as soon as a combination triggers see also #229 | | **Macro settings** | | | | | macro_key_sleep_ms | 0 | positive int | | | **Axis settings** | | | | | deadzone | 0.1 | float ∈ (0, 1) | The deadzone of the input axis | | gain | 1.0 | float | Scale factor when mapping an axis to an axis | | expo | 0 | float ∈ (-1, 1) | Non liniarity factor see also [GeoGebra](https://www.geogebra.org/calculator/mkdqueky) | | **EV_REL output** | | | | | rel_rate | 60 | positive int | The frequency `[Hz]` at which `EV_REL` events get generated (also effects mouse macro) | | **EV_REL as input** | | | | | rel_to_abs_input_cutoff | 2 | positive float | The value relative to a predefined base-speed, at which `EV_REL` input (cursor and wheel) is considered at its maximum. | | release_timeout | 0.05 | positive float | The time `[s]` until a relative axis is considered stationary if no new events arrive | ## CLI **input-remapper-control** `--command` requires the service to be running. You can start it via `systemctl start input-remapper` or `sudo input-remapper-service` if it isn't already running (or without sudo if your user has the appropriate permissions). Examples: | Description | Command | |---------------------------------------------------------------------------------------------------------|-------------------------------------------------------------------------------------------| | Load all configured presets for all devices | `input-remapper-control --command autoload` | | If you are running as root user, provide information about the whereabouts of the input-remapper config | `input-remapper-control --command autoload --config-dir "~/.config/input-remapper/"` | | List available device names for the `--device` parameter | `sudo input-remapper-control --list-devices` | | Stop injecting | `input-remapper-control --command stop --device "Razer Razer Naga Trinity"` | | Load `~/.config/input-remapper/presets/Razer Razer Naga Trinity/a.json` | `input-remapper-control --command start --device "Razer Razer Naga Trinity" --preset "a"` | | Loads the configured preset for whatever device is using this /dev path | `/bin/input-remapper-control --command autoload --device /dev/input/event5` | | Make the input-remapper-service process exit | `/bin/input-remapper-control --command quit` | **systemctl** Stopping the service will stop all ongoing injections ```bash sudo systemctl stop input-remapper sudo systemctl start input-remapper systemctl status input-remapper ``` ## Testing your Installation The following commands can be used to make sure it works: ```bash sudo input-remapper-service & input-remapper-control --command hello ``` should print `Daemon answered with "hello"`. And ```bash sudo input-remapper-control --list-devices ``` should print `Found "...", ...`. If anything looks wrong, feel free to [create an issue](https://github.com/sezanzeb/input-remapper/issues/new). ## Migrating beta configs to version 2 By default, Input Remapper will not migrate configurations from the beta. If you want to use those you will need to copy them manually. ```bash rm ~/.config/input-remapper-2 -r cp ~/.config/input-remapper/beta_1.6.0-beta ~/.config/input-remapper-2 -r ``` Then start input-remapper input-remapper-2.1.1/readme/usage_1.png000066400000000000000000000434311475433465200177740ustar00rootroot00000000000000PNG  IHDRKoiCCPicc(u;KA? ")T,Xj,lHT0jMl\v7 6hh+*"X|5"$ ,̜ų5Dnb:YL.Eh kLƩ9S~XhMucuQ#ed+Q^W"*vSR=# +ȸp󨛴9=2{H0M:yV3,5'UE caWi25/]M|Y6Tёb4G?4n)?U_ƿD-k}Gނ벦6t?9z!˂shOB,*sDpp?h ") pHYs  ~ IDATx^|ǟ,HF@ `Q֊ںUhQU[[k[[.ֽQQJNBH?syͺ77'<{y9,:(L$@$@$@$@ D @(xu Ko͘ >"ߢ3c35z&߹-#kxk? h0(LWD 99I^?w#[g!gyFfFEEʻLX9pt^Ҵi87OB">?H$@N'@ E.~*6l6 [ǟ|&_|Lpߟ- eҤɪUkL~y}ʿ~HNb=H,sx&lٲpHJJc"Y%zڶi`$@$+%_1? ؄@ Kj*2*XDO}nWbMZH3HH(F`YlYmH;w6廂3[2bwl))-=UF\d$@$`Wv5v@C&ХKG+ _ϿZ1#6':(Q$77O>d)buhVn&ջѿLU;G_&  ?%-$  !g$@$@$@'@d>$@$@$@!$@BHHH(GHHH (BU ؟Œ Rj   XB   X !|VM$@$@$`K#ZH$@$@$BK!ϪIHHOb}D IHHBHb)Y5 P,ٿh! @ P,>&  ?%-$  !g$@$@$@'@d>$@$@$@!$@BHHHo"- g\0NȊùumҷOOo/_$&&H^^,^sCaAҶMkyݩ27Z"##e9~- 8=KCG`]6E?~^i˖2DҬ_K/biܹ[ m0 #\wO%!? Xl׵سg7˗[ʐ.ȗSHY3P ~ݱc;?V5K' eǎlܸLx?\5+9~eR^^4k/.6V&7GoY45u _!qqr!`IxQIgټyaQW:w񔡌o2|ؙѪrQIKknOOO3{˖frֈ!RJJKdu|*s1B!l*ٰq9>x>yd7iuRC<ԩ3L9_-|[23[!>mlGWjԨ*hR$[9z_\~ُ_9m11yPERm(xDb)@`ñX\ >}o u17{m߾=U@-4/F=¼,֯5AAT8,Y$=["4͛4{W\>|n 2/UMM4]!.i̟X9jD攗hGNL<ͱcG!NMPRkwx-jsH=fDEqڷ;))!oY5%w;SӦ}^q"d %x(vh*bڰAOuv$r;vhW1^CVk .;|8ד:f %|j^EyaRtti=9neGU7uH 4(BݑjJN 4P^Vpz*^p7r_;ڨSyotkj/=l:YSKV*NG#.%/ֲ/b\Mt7k*MÓ!^*Y1g^IqBYu$_1WqMhަitѢ0dQ.<1l1VҔbi7i$qqF\U6\vW^w5K_wS%Ϩb`ԲeeO_m_!=67u$-k%j2.??_JT Z0y޼RJXMkj'R/0^AaWBX 7K@p5زDB YP^5Y62%ٍ۷4TXՂ0 εγ n7qNK ˩*G[j,O0_Nԑm< R@6B!TC2׷ w;3JgI! o7T aAE!j{30NHHf"82e!^km0/u/:}5jg0 %"_&^:MVv+<LXsx&} AD1p-%&&X!@ 6؃81k Q4ID CqV=ϊ{QwzT#TpU-oBD׵?qc}<p"9lj3m5r$j c: B /-Tp 2)7fL\y%ƦCHWÆȰʀ'_l[Ȣ:-|4Vޒs]^H=eXR;vΈ'+y.VJ!lβwŚr 1k3tAM  EJJJHN(i*CZHXe_ 99d߾RVe{KhAȆ\W^V&KZ[go,dMXԇnrrԮ^11'..N՗qll#ۧ$&&ڵY .&I'eYu2`&} +ڷe N]ߺx}[*pcS(9₨E9rV۷$&$HF1R_{fI]s)~ACatx wk-+aQA֓`]Ya7^g(XQRRZ_@(gfoVvixzQ%ؑ@CRJJSsA %ubqqzi%KFFKc]Z%&{z:Ei_Q&fr&kKFhg|{СRXXdL&{M, x7>ם`O^g(ɂ}Гe}~}~Kቈ'!&233o$-$ XrPgT̚c?ÇHZH$@"@ ;w D$@$? *"{܈TRӦMHIIr9#O}#k׮sH^^~ 21 GRXvmլYL|>ĬݻʂK><Bp 4(ގY_o_Ȟ=UM`m=8\}ef{ԶmgQo_㭩ȇ)O=[g3' %L "}~ق)ۄ>7lhb[NK'_$_eeAw~IZΐx|b"@XϮ\j󣏦_g_@ A(]UmjIQȃT`kd8gΜ+R(ZN%&D2:09C>MJJ?A]˒%+jm'G_޺ul|?Z+.[˒_;؂`̒vr`dOM/iA͂a8& ͚?sUyܛ.77ϬX͛{*v8OZV^ 4Hȷid֯PY ` %oSx|N|]ND*Ht}?ݫ0~z˯nN T5bn7igyİĉeȐq=e2kW[H k4mT?.7o^z~N\*#99p-]B> ]ߨ#UyO1ؽ{gyUM!.->pٖ3j`i NXI\3GhO* +—SKTϭaIӻWwSGXUwcG-xz즢9x,],Q9dGR}USQ#wʜz-[5?^OXkmKx)dL0Խ`R}.1[YF2lؙݭVe&Oe룛9EႾv/6ޏJ?*!=BPBFd!Y-'$=5i$wy\ ]^f+7+&͇B5 ұcyϬ6boAA‹mJ x T-\/2 IDATO: C/==J,٥KGs_^b$Sc[l׶9/z]vy6\ 1s-U|ayV-M``n+Aü ߷`Zr4#GUogҼyJE__/o"wSjj3-c|^ۍꡇ~g%`xgDO0<+R,ŷP<E 3P9[YHx(C(!AMr= g}{f:&9+ BQ؊V*7^;SXXX!Nnʜ5%$\〡Xx!vڣP1A>3e67!^6(+b8x0LuBYD0.ACr-S^Sq*%3~Lј''<ȰQ0bbu>)} ֖7 [oˋHzн[gbTFDDzզJ':hNaj zQkaPڹss`/WT\)\Oc::[igRXQu ؍AUoԄ ddſx7yPg0>K:pܙ3l9w&'oU*)S^V%͞c$|xY[A.effjݜxҊ1Ծ}@ ow 0٬8B}y x+Oxi,/b7nlxꆿRvංJ8m>,Mxfy.K 1p^0+btʽn8XMK,_xݓ魝XA˺ߎ=zjU"-{ޞ&R [sm9F$X?Ǐc< 7T1w`sxUk3SlظY:vljuK= /qk  Uo5o<@[f+ևaښY_APΝ;u逫'k]}gь0wjǃ= gx;jb*Ǫۡ3);Xekl R #jZx 0q.,hy |!}bHFiF*P,i-5k5Co0S#0 ?6m[nE=KO>Kݦ`~gO\zufI5Ő֡b ZE@2fӺg;D 7W]%% T-eڣ|SZ_!c9&/dZY\k0yG]v2oZLB}f#4n_V^ٻҫ—lR 250k3󞀧5r۳l-~U6XbəYlMok{ ,h@3luǎuEf+S*oݿmqR YO׫a7% mWsj OWz E|:/-k=={٢:ufG2xX `;VQJE.pN>MFMurE07o|TJpVt^pcǞ^|]'tʞcwz iMu V]YdlIsiUڠE||6gbaţ4 u%YScpb2}$]3v:Kau?%XGy?KnW!aVwPwFGXҟ JtKuqoX}[^zP5bip+n^}) oɱC͆o Ek%ɔU G?P|lÕ:D՗2h >'FHj(AM.SUo)fiiG9|sYM}TEϒ/|wϒo37 2q2ifn+.m޻p4f?oz3n(!g)p)ǖ%7Pfju &JߦM+I5kz1f^#l6 R Dle$5 Ob0(ї4H(x @ @0EDpC*  F# !  WKڳl _P,# !  WKڳl _p6_0  \tr_ҳ>6[B$@$@$K"IHH‡R%[B$@$@$K"IHH‡R%[B$@$@$K"IHH‡R%[B$@$@$K"IHH‡R%[B$@$@$fhI$@$@$@'`R΁#o [@$@$@ @zZJn}`ae$@$@$@'@$@$@$@%@X,HHH(ށ4HHH{[gH]?Aso&   v2%=-՜Z?%%r,7Wv'%%%)24iDRSSd۶vx-$66V$""" a$@$@$UlJQQ8Qh?^ ۶T})q٦tN6mjkk2.%IK"άBIHH6?q*@˓2[[®v QdRTT32ZJzzs' NiwGgL(F;{^9r9*똲(,:)YY9r ݻwi$-afm[KJJS#GbޮdffRgluQM7HHH 4 08rXh Vx0eD!NysVTڥ]^Qn!*T ,*V-[4ٹsbҮm9. N<)7o̐Mh;KIIb]YwȨH^AR(x0; Bbl=$N8"0 Yb'//ߐٳw57Iig~9t9Ɩzl^&M='X-[wh"R3uuX)80jK8eD$@$@$`x/Q,%&&' lAjji׮\rؑ‹nŕʻqOh6<|(`gs|ͬ$  Cq:vfpjjZϊu3&&Xr^ (**lXS_Ieȡ_|)ܢ^g!'Ng3ʔ߬(͞@u$GBͳ`2yM1zر<9;qv<+III<OȒ%_W3q2@=TmǓd3qgW|M-']jܸl۾S)yv.}ɳ~fy7*ʝ3wtA>{9^ж皿cbbV#$2*R.\.|LTw •*H3%\Uc1L$a8g1nٲm*?yotwnK{re?AU1;^{剿G:kxkߞ|b8oOiSTTTE9AeŊ5Ro1OΟ0Ə*}@/^!\selw7{mSj.ʯ}<3x;?~ՓCO~Jn^[~g$=KA yKνr(w<!ӧV}7ˇN7CZf%T8m۾K>x |֥sJVnP|*{*v;2 '@8%$4T|KVР#t*/+/1!Y {t"=[v9W^l1ަ=I&p8g{U~4NI$'CS]v#7yʇ?mb,Z>b"Yryƒ]zw;"YWs@ZZsK$@5zE5zik/y 2ϼ=/ۛ<~1F ճg7·U$!zxYT5m r9z\]ubm 䦧Ȏ've,g @}:l6,!0˅Aۛ<0suE 8mXvɺVSmtӮRhV@rrIiaH z74ix+ʉB7G֕(»:ӣJ.,mM^xuws8IDATs'_$-{ |r  ',O#  P`Rs3g$@$@$@"@΢$@$@$@'@|欑HHHA(Y4HHH (Ϝ5 8Œ: RF   (韃z =CRIIihb$@$@$@£X*** UHHHCsQxKERRRX+ @O($3f S0gX% "0L!]Weeer1XNq   `n(aSKV(v   G:K.K$@$@$lK&HHHEbQEcIHHMb)Y P,9h, @ P,8#  p%Gu%  6`g}$@$@$@"@$@$@$@&@l⬏HHHQ(]4HHH (M 8Œƒ R>   GXrTwX   `X 6qG$@$@$(K.K$@$@$lK&HHHEbQEcIHHMb)Y P,9h, @ P,8#  p%Gu%  6`g}$@$@$@"@$@$@$@&@l⬏HHHQ(]4HHH (M 8Œƒ R>   GXrTwX   `X 6qG$@$@$(K.K$@$@$lK&HHHEbQEcIHHMb)Y P,9h, @ P,8#  p%Gu%  6`g}$@$@$@"@$@$@$@&@l⬏HHHQ(]4HHH (M 8Œƒ R>   GXrTwX   `X 6qG$@$@$(K.K$@$@$lK&HHHEbQEcIHHMb)Y ?qq[IENDB`input-remapper-2.1.1/readme/usage_2.png000066400000000000000000000334661475433465200200040ustar00rootroot00000000000000PNG  IHDRKoiCCPicc(u;KA? ")T,Xj,lHT0jMl\v7 6hh+*"X|5"$ ,̜ų5Dnb:YL.Eh kLƩ9S~XhMucuQ#ed+Q^W"*vSR=# +ȸp󨛴9=2{H0M:yV3,5'UE caWi25/]M|Y6Tёb4G?4n)?U_ƿD-k}Gނ벦6t?9z!˂shOB,*sDpp?h ") pHYs  ~ IDATx^|E/IBRD)콀?>VԟclH4{I ;K;.Krݙywgfwթ찐@@rH@@ o%@@ %v@@`}@NX @|"@䓆  Rtn > XICSM@N`):7B@,& D'@k! O|T@ X΍@@'K>ih  ,EZ  %44D@sc-@Oj" @tKѹ D`' M5@@ :zD&k! >HZrI@z11ʋ-G/=  X`)Ơl@K`)ړ  @b @@ =  X`)Ơl@K`)ړ  @b @@ =  X`)Ơl@K`)ړ  @b @@ =  X`)Ơl@K`)ړ  @b @@ =  X`)Ơl@K`)ړ  @b @@ =  X`)Ơl@K`)ړ  @ 6Y,?DnF:w:9X@ TP^sx/bŊE-98Vy$+_k[lիIuλ˸ql%vpBk⓲cN2m-!@ʗ/';wD&Pje͇t,+wlٲUnҭ۩ҰA]3vbd5K;qe,-Y\S zAyeϞ?|-/Z'_ }O;E&MV, -{l#rG O .7}h{c&\]{w?Y@xRZUٴi}tvL={!)f` ڙʕ++~\ʪUkf#}6YVMLL/x_J(![n =+znTߟj ˯\$@$\ ZdYfذ2cL{#"+k%>}2JYpo۶UFn¾. 6IҥL{V؍5t2kOk, @ T\QKj_8Gu_d?SVDᜃvwU4a;I.]a˖-~;MMM33:kFX,  6;&w v;tP pC&-Sv  "7fOIw\6V^n;ϻ*X w/tIvd;^ӪUk͜4^f kѢYMgd.F #aΓ~p WIӦѣ?9m$vƍ_e+^wG;")G^gJcJ2ej*{1b,3,W^[MM6`ʞVXe?^quI1X@xWVիc׻JJN9)4s,w^LM:yJ& ɞ{UH|xRlIv &Ӻu%Kcɑg}m۷/E5k\? q(U:>?+WZ4gnmo}κdXnwk;Q.b%P\:Vc; rYer-@ b,l@]Sp4"Ka87E,e( B`@!@@Knmʅ  XrE3P@p[[r! +\ @*@֖\  %W4@@ ,e( B`@!@@Knmʅ  XrE3P@p[[r! +\ @*@֖\  %W4@@ ,e( B`@!@@Knmʅ HrE)( a$$51TҥKIbBbkG&@td~ @GIͥL2H6d ,' ^ !!AZ:Nʔ.R`R)5BNFjJqתީwڊ"9J$J`@ lMB<,%&&=*"-Z4+*7EpP&]12hГ?U+Fs" .ਫ਼v펗~-w`Ð  YqTdZ@pDS=K   "@ #a8Z@ 6}\&^}d䣭|s_i۶;Qfyݺn$<Ͽ\/T16KkMQ]_#- ґ > Aߎ(M۷rG&{&-r[ŋ˷ RvM B%K=KQ2@v4IOO;v?eÆ2ygzs_%#G3= dѢOnҸR֮ O23WKV\#~8$^J)2e&H8,]P&M˶o:hmg/(}^n2eʘVK3l`6{jj TK-n6s-\mm0<jӰa][g 9 ޽spk~AU9 .[9íc:?zgIw&v8j* @~K̉Yڵkh]T{ pbhzf͞k?`p-d)~F/znذ̝;?n@{*UhWߙ^z*T(/'F>,|0L3gαAz?@6`8F`bijz岧ʕ+eS8kO>P{֯פoxeŊUۓWҞSO=nG{J.-͚56m@8ceVJ~l4ި /eXƍјTE5(Q"ǶB˙i~ cdTl@TӃ|JijN,jiڶu{2)fNe˖%cNҵ)ĨC44P8jժ|#ܓA$};iϓ))RJ`Pݭf#]N%M6lmt:uje Fop -XHZ:N4(kXl鰢fO:I4XӹMnZ`˨UZ5GTp2K< ; fN97*S:wFWR7ɜe;p@\ j01lH3>iԨtzJpx-PpȐ~.gqb^eIʞo FeYsmP˖M`s@̙#X馛=J:GIVgOpSh9#cyDs"bY@Gk֬Tsr_40CgaA`){ʕݞ={A3TSbyٻw_܆pumm.3iݺI{h쿂?:~:?IrrI3]O{rKF=]Vi%6k׮?"Z-ItvΜyf.g[V (6=\YC 0I˛_*:DR8J,P@O .M!fQhViRcY3XtRhm3h&:s.~Tqs"fV88'O>AkT^~-s39䮻nݪ]Z"?N=K:dbjdmȕdşKf{ ,j믅zudhMMYsKG"sYչS:wq):37=`y =K(  SNiРhNM+R\uһYʜC4:c:;A/ ?RrSOK@ ,_ 8ܳEMC^!K9KjJ!Ybwm-.sW{P@p IX! pD{#ʈE`@7ov})` ,oR3@ n֯${틛Po ,y(- K}uWpR`ImBZ -̜,^Lv-7 "a P0X @:,m?$g)iA@O ,y(4 8%@4  '6-"- k x$SdIٵks6K Rђ1+mZNM:]F?.M^#O iAP~9j(}w|w*ܒӦnM@/+T(/eʔ6Ō8'`I9@Wy%vGɹ'9ѦPt6 jSwxI b/V3җ(Q"`)_Ohi@Mk-5rJ b'^M/hk^)1y~++m-__mS% v|rTR~wqכ7oԛ_._F\[OKm&j[ _Ե'X* @p! @Q E CX\rɹrJRFu9lIf::{n@PK@<bڳ,Ӧ?j%{<ct<.5IÆsJHR%c _e Nt|?|;ԭ۩M 9Y*`rM}R~;J58@icweK3<&yK׮ؿkϔgٲ2zc~3'UW],˖_~É@L{m^T=N_{ͻ[x4:[ohd}ck^Pwx2\v##Qmv:r-KN(>p։<"EHb|k3&+Wm۶˘>m>#(.={"x…KINΝeQGvZh&kהEʆ lASOUJ| >kҸqywdȐavU+7_hOW\/#hҸ/lٲMƎ$iG]V箛Iuv2t[]<5agRsdm߯7CLgqf1b [Ȱ)NE(1\(鐛& ;*gЋO}G{]F}޹=Ͼ7ѣɩfŗgϤu:w:Yծv%ٟKKko*"|ߣQDcifMd9@CcO˗ KٲeھWcݗ.+_{NN2Wz޵kr''ޞpn&w}a=NU:W:g:d0r8uvZ'(͝@V^k iq\SY` P篿ڵzc[Zn\V\lo x5(i`5ʞdrm7u]FAuzd@v@ierǝw< Vɱܥig03lg3ݷu4MIIXJF|?V~iz 7'|i"o /ԠAݢޑr26"N s3j=Ȝ^oi:|&˗ 毽P~̛ОO8[{ȬYsef m[wMlڴtN4pܾ}6 27GycY5C18}?23ҠW+lC6@鐊}yތ~-?{U5j`ܛIjUewc2s jZ3pL~&o{U'2һBKʝw=hi O^TfK'+MjeLYgwwv+u~W\%k`yIzzر3Ancwev /%^k{lݶMf$&&JN'F`.0-_i/Z@CK=SE^WIi Iq7_#)zoufZwK?l7록 ͚6u6/ipF8S$;KHHO(iZi^fu9|1B>jOʚj={@Iu[eʔ.zqz`zih}6f4|7֨ 쫽RzgGi6p9PͿ5UW^,1wce6J9OFzYr  Գ=VJFۿf MADª_qqjjfόv8.4ʈ|vbbֹJȒo46p$q}! zIOOSN ݻwB۴iiC^Ebe؉`+t6c&nM:|њ0a B/=m>"85B{ou*e+qmmzn]FV@uҺuգL|Ry`֮[quZ<ΞQaIDATj,G.ѬԑqY˨^p9 dAVeڴvZ̶=z^IzsI mѢ},ܿ/ƋG˨5yo-NA]t~ۯh 4}7bL*3Qyۛ?t;RREۛvD2৑oy'i=PN`Zcߗ.YgI'IFՆQ^%iҫ /l5ʭc.#od&XzK:wh*mK5 eI䒦;mO`  ­STtJ:4DWzP{GL[&$`n?V't'Q?aI`yL`NFoq ,ގnz~jmf}=i0Clz&;5iM2rϒ%ߓf[|z^D7u"Iz|cݹv^k9­S4s:8'|>GV6 4<\nʯa`^l5't6yΰa䧟>"ڄ2>D?lyIAiϞ}azuOh }= 9`>פ{lA8ҿ͍L}2";yk&7oյNE9]SmGz X/\CGqs BGq2HOwgHϑ{/S#ܣ؎a[ne7M1ɼ5|l@^mV @\מ#,*T(gzG.'+9gJJ^pz:NY^:`ILV[tb&x^֭[@cPm:YJ| iY$)c>Nyi4i}ΜHѾf'_?.k9ujhg}_wC?갤Wtnsn8G`G/һ"Go /ͳ :/L[n旽>B6@޳Heڑ.޽H^ F:Gkꎐ[ {8_"rpWL+=(:T{Buׯhcژ6G 43М]]뤏yX2A'A!/1iÄ5;1r?(?ڻ.:uj*2sKW=ĭwFdx5}OmoQ i<@6ǨbyD5Dۮo.N'kozzG*44"ZG/Hw`ߏ^@~1N^UݟU9V}plYގ5c$PT=K1*>E-:vlol=K*>)ELA#Yb}nyܶ) WSnn =0Ahf &OKz֧i vBz")e8mXPz,RU>$P<}5}ªZ߫äܽM-bUh4Vy 聾2))_HՓv]Q UMYM;@`@iG}(ja!O@w F#2^&}}4gt =۱ft,EZ  %44D@sc-@Oj" @tKѹ D|T *]g)J8VC@,% D)@%! ?@ X@@Khgj Q ,E j  %3D@(c5@@RzuQSj@`ҺmlM W6oᕲRN@*|Vcn`΍B@@5Ki  n XrcP&@pk  * @\#TJ% @| >|X3$--MRRR㻲F )njBE@ŊœOɒ%d׮=r!וAr7HMtRxryWe%++B`rHJJrʺf}Ok˓OweY)R!SrrIl޼U>F֬Y'[nom1Ooڜ#*%JnR .M’6HJH!⮲0 =K8 ٻ*;ohS>}= Xr֛@ NvSjެKeϞh5jVsS) 8,@08!o$Vۂ֮]Ss M@PTl&ry2{D{6l$O |I}U"@bf@ ~>џԾC 5A@@oK~kq  ,E  7%8E@"ba@N@wɩ0 w3%Ѽ-{ڸq̞=O^aD|#@ϒo"5P ݺqr oc,ā=KqЈTX $$d^CX$foI'Hj𣡱̚m! XreP(^vn޽oF}a) PKLL믻Bƍ"M4(D6?:d+f,?ꏤA}yϰ!!,ਫ਼*U*e8@^I݊T ##Ct(~9Qr,ĻR0C钒R4@@`D@]IdD",N9J:FґH.8)e6'#/@@S y(, 8-@8! <\@ XrZ@@SKj.  N ,9-N~  )%O5E@'?@" KN xJ`SEa@@i%@<%@械    XTsQX@pZ`iqC@O ,y(, 8-@8! <\@ XrZ@@SKj.  N ,9-N~  )%O5E@'?@" KN xJ`SEa@@i%@<%@械    XTsQX@pZ`iqC@O ,y(, 8-@8! <\@ XrZ@@SKj.  N ,9-N~  )%O5E@'?@" KN xJ`SEa@@i%@<%@械    XTsQX@pZ`iqC@O ,y(, 8-@8! <\@ XrZ@@SKj.  N ,9-N~  )%O5E@'?@" ϋG `ZIENDB`input-remapper-2.1.1/scripts/000077500000000000000000000000001475433465200161675ustar00rootroot00000000000000input-remapper-2.1.1/scripts/badges.sh000077500000000000000000000016241475433465200177560ustar00rootroot00000000000000#!/usr/bin/env bash # pip install git+https://github.com/jongracecox/anybadge coverage_badge() { python3 -m coverage erase python3 -m coverage run -m unittest discover -s ./tests/ python3 -m coverage combine rating=$(python3 -m coverage report | tail -n 1 | ack "\d+%" -o | ack "\d+" -o) echo "coverage rating: $rating" rm readme/coverage.svg python3 -m anybadge -l coverage -v $rating -f readme/coverage.svg coverage python3 -m coverage report -m echo "coverage badge created" } pylint_badge() { pylint_output=$(python3 -m pylint inputremapper --extension-pkg-whitelist=evdev) rating=$(echo $pylint_output | grep -Po "rated at .+?/" | grep -Po "\d+.\d+") rm readme/pylint.svg python3 -m anybadge -l pylint -v $rating -f readme/pylint.svg pylint echo "pylint rating: $rating" echo "pylint badge created" } pylint_badge & coverage_badge & # wait for all badges to be created wait input-remapper-2.1.1/scripts/build.sh000077500000000000000000000007461475433465200176340ustar00rootroot00000000000000#!/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-2.1.1.deb || true dpkg-deb -Z gzip -b build/deb dist/input-remapper-2.1.1.deb } build_deb & # add more build targets here wait input-remapper-2.1.1/scripts/ci-install-deps.sh000077500000000000000000000010301475433465200215100ustar00rootroot00000000000000#!/usr/bin/env bash # Called from multiple CI pipelines in .github/workflows set -xeuo pipefail # native deps # gettext required to generate translations, others are python deps sudo apt-get install -y gettext python3-evdev python3-pydbus python3-pydantic python3-gi gir1.2-gtk-3.0 gir1.2-gtksource-4 # ensure pip and setuptools/wheel up to date so can install all pip modules sudo apt-get install python3-pip python3-wheel python3-setuptools # install test deps which aren't in setup.py python -m pip install psutil pylint-pydantic input-remapper-2.1.1/scripts/setup.sh000077500000000000000000000155601475433465200176750ustar00rootroot00000000000000#!/usr/bin/env bash # Provides commands for installing and uninstalling input-remapper in the system. # Supports using the system's `/usr/bin/python3` or the local `python3`. # Provides commands for cleaning up everything. # Supports installation of the modules in a virtual env. python=/usr/bin/python3 # python executable used by this script script="$(readlink -f "$0")" # absolute path of this script scripts="$(dirname $"$script")" # dir of this script source="$(dirname "$scripts")" # input-remapper source dir build="$source/build" # build dir used during installation bin="$source/bin" # source dir of the binaries project="$(basename "$source")" # name of the source dir (must be "input-remapper") # sanity that check we are managing the right source code if test "$project" = "input-remapper" then echo "using input-remapper sources in '$source'" else echo "could not find input-remapper at '$source'"; exit 1 fi stop_service() { echo "disabling service" sudo systemctl stop input-remapper.service 2> /dev/null sudo systemctl disable input-remapper.service 2> /dev/null } start_service() { echo "starting service" sudo systemctl enable input-remapper.service sudo systemctl restart input-remapper.service input-remapper-control --command autoload sudo systemctl status input-remapper.service --no-pager -l } # install using the defined $python and record which file are installed system_install() { echo "install: installing using '$python'" sudo $python "$source/setup.py" install --record "$build/files.txt" sudo chown "$USER:$USER" build "$build/files.txt" echo "install: writing list of install dirs to 'build/dirs.txt'" grep -o '.*input[-_]*remapper.*/' "$build/files.txt" | sort -r -u > "$build/dirs.txt" } # use whatever python3 is currently used even in a virtual env local_install() { if test -n "$VIRTUAL_ENV"; then echo "install: running in virtual env '$VIRTUAL_ENV'" site_packages="$(find "$VIRTUAL_ENV" -name site-packages)" echo "install: temporarily ingesting site-packages path '$site_packages' into binaries" inject_path inject "$site_packages" fi echo "install: using local python3" python=python3 system_install if test -n "$VIRTUAL_ENV"; then echo "install: removing temporary site-packages path from binaries" inject_path uninject fi } # determine which files were installed an then remove them together with any empty target dirs uninstall() { echo "uninstall: removing previously recorded installation files" if test -e "$build/files.txt" -a -e "$build/dirs.txt"; then echo "uninstall: removing files from build/files.txt" sudo xargs -I "FILE" rm -v -f "FILE" <"$build/files.txt" echo "uninstall: removing empty dirs from build/dirs.txt" sudo xargs -I "FILE" rmdir --parents --ignore-fail-on-non-empty "FILE" <"$build/dirs.txt" 2> /dev/null return 0 else echo "uninstall: build/files.txt or build/dirs.txt not found, please reinstall using '$0 install' first" return 1 fi } # basic build file cleanup remove_build_files() { echo "clean: removing build files" sudo rm -rf "$source/build" sudo rm -rf "$source/input_remapper.egg-info" } # manual removal of the main system files remove_system_files() { echo "manual removal: cleaning up /usr/bin binaries" sudo rm -f /usr/bin/input-remapper-gtk sudo rm -f /usr/bin/input-remapper-service sudo rm -f /usr/bin/input-remapper-reader-service sudo rm -f /usr/bin/input-remapper-control sudo rm -f /usr/bin/key-remapper-gtk sudo rm -f /usr/bin/key-remapper-service sudo rm -f /usr/bin/key-remapper-control echo "manual removal: cleaning up /usr/share and service files" sudo rm -rf /usr/share/input-remapper sudo rm -f /usr/share/applications/input-remapper-gtk.desktop sudo rm -f /usr/lib/systemd/system/input-remapper.service echo "manual removal: cleaning up /etc, config, and startup files" sudo rm -f /etc/dbus-1/system.d/inputremapper.Control.conf sudo rm -f /etc/xdg/autostart/input-remapper-autoload.desktop sudo rm -f /usr/lib/udev/rules.d/99-input-remapper.rules } # find what is installed and print it (returns 1 if anything is found) check_system_files() { echo "checking for installed system files" files="$( find /usr -name 'input*remapper*' 2> /dev/null find /etc -name 'input*remapper*' 2> /dev/null find $HOME/.local -name 'input*remapper*' 2> /dev/null )" if test -n "$files"; then echo -e "system files installed:\n$files" return 1 fi } inject_path() { case "$1" in inject) inject_path="${2:-"$source"}" echo "inject import path '$inject_path' in bin file sources" sed -i "s#^import sys\$#import sys; sys.path.append(\"$inject_path\")#" "$bin"/input-remapper* ;; uninject) echo "remove extra import path in bin file sources" sed -i "s#^import sys; sys\\.path\\.append.*#import sys#" "$bin"/input-remapper* ;; *) echo "usage: $0 inject|uninject [PATH]"; return 1;; esac echo "injection result:" grep --color -E 'import sys$|import sys;.*' "$bin"/* echo "injection finished" } usage() { cat <<-EOF usage: $script [COMMAND..] commands: help show this help install install using '$python $source/setup.py' (system python) local-install install using 'python3 $source/setup.py' (local python) uninstall uninstall everything show find and show all installed filles clean clean up build files purge find and remove everything that was installed inject [path] inject a 'sys.path' into the files in '$bin' uninject undo the path injection start start the service stop stop the service EOF } while test $# -gt 0; do case "$1" in inst*) stop_service; system_install && start_service || exit 1 ;; local-inst*) stop_service; local_install && start_service || exit 1 ;; uninst*) stop_service; uninstall && check_system_files || exit 1 ;; start) start_service ;; stop) stop_service ;; show) check_system_files ;; clean) remove_build_files ;; inject) if test -e "$2" # check if next arg is a 'path' then inject_path inject "$2"; shift # use it and remove it else inject_path inject # use the default path fi ;; uninject) inject_path uninject ;; purge) uninstall; remove_system_files; remove_build_files; check_system_files || exit 1 ;; help|-h|--help) usage; exit 0 ;; *) usage; exit 1 ;; esac; shift; done input-remapper-2.1.1/setup.py000066400000000000000000000107611475433465200162170ustar00rootroot00000000000000#!/usr/bin/env python3 # -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings # Copyright (C) 2025 sezanzeb # # This file is part of input-remapper. # # input-remapper is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # input-remapper is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with input-remapper. If not, see . import 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.mapping_handlers' """ 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="2.1.1", 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-gtk.desktop"]), ( "/usr/share/metainfo/", ["data/io.github.sezanzeb.input_remapper.metainfo.xml"], ), ("/usr/share/icons/hicolor/scalable/apps/", ["data/input-remapper.svg"]), ("/usr/share/polkit-1/actions/", ["data/input-remapper.policy"]), ("/usr/lib/systemd/system", ["data/input-remapper.service"]), ("/usr/share/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-reader-service"]), ], install_requires=["setuptools", "evdev", "pydbus", "pygobject", "pydantic"], cmdclass={ "install": Install, }, ) input-remapper-2.1.1/shell.nix000066400000000000000000000025161475433465200163330ustar00rootroot00000000000000# shell.nix - used with nix-shell to get a development environment with necessary dependencies # Should be enough to run unit tests, integration tests and the service won't work # If you don't use nix, don't worry about/use this file let pkgs = import { }; python = pkgs.python310; in pkgs.mkShell { nativeBuildInputs = [ pkgs.pkg-config pkgs.wrapGAppsHook ]; buildInputs = [ pkgs.gobject-introspection pkgs.gtk3 pkgs.bashInteractive pkgs.gobject-introspection pkgs.xlibs.xmodmap pkgs.gtksourceview4 (python.withPackages ( python-packages: with python-packages; [ pip wheel setuptools # for pkg_resources types-setuptools evdev pydbus pygobject3 pydantic psutil # only used in tests ] )) ]; # https://nixos.wiki/wiki/Python#Emulating_virtualenv_with_nix-shell shellHook = '' # Tells pip to put packages into $PIP_PREFIX instead of the usual locations. # See https://pip.pypa.io/en/stable/user_guide/#environment-variables. export PIP_PREFIX=$(pwd)/venv export PYTHONPATH="$PIP_PREFIX/${python.sitePackages}:$PYTHONPATH" export PATH="$PIP_PREFIX/bin:$PATH" unset SOURCE_DATE_EPOCH python setup.py egg_info pip install `grep -v '^\[' *.egg-info/requires.txt` || true ''; } input-remapper-2.1.1/tests/000077500000000000000000000000001475433465200156425ustar00rootroot00000000000000input-remapper-2.1.1/tests/__init__.py000066400000000000000000000000001475433465200177410ustar00rootroot00000000000000input-remapper-2.1.1/tests/__main__.py000066400000000000000000000001001475433465200177230ustar00rootroot00000000000000import unittest if __name__ == "__main__": unittest.main() input-remapper-2.1.1/tests/integration/000077500000000000000000000000001475433465200201655ustar00rootroot00000000000000input-remapper-2.1.1/tests/integration/__init__.py000066400000000000000000000003311475433465200222730ustar00rootroot00000000000000"""Tests that require a linux desktop environment to be running.""" import gi gi.require_version("Gdk", "3.0") gi.require_version("Gtk", "3.0") gi.require_version("GLib", "2.0") gi.require_version("GtkSource", "4") input-remapper-2.1.1/tests/integration/test_components.py000066400000000000000000002101641475433465200237670ustar00rootroot00000000000000#!/usr/bin/env python3 # -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings # Copyright (C) 2025 sezanzeb # # This file is part of input-remapper. # # input-remapper is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # input-remapper is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with input-remapper. If not, see . import time import unittest from typing import Optional, Tuple, Union from unittest.mock import MagicMock, call import evdev import gi from evdev.ecodes import KEY_A, KEY_B, KEY_C gi.require_version("Gdk", "3.0") gi.require_version("Gtk", "3.0") gi.require_version("GLib", "2.0") gi.require_version("GtkSource", "4") from gi.repository import Gtk, GLib, GtkSource, Gdk from tests.lib.spy import spy from tests.lib.logger import logger from inputremapper.gui.controller import Controller from inputremapper.configs.keyboard_layout import XKB_KEYCODE_OFFSET from inputremapper.gui.utils import CTX_ERROR, CTX_WARNING, gtk_iteration from inputremapper.gui.messages.message_broker import ( MessageBroker, MessageType, ) from inputremapper.gui.messages.message_data import ( UInputsData, GroupsData, GroupData, PresetData, StatusData, CombinationUpdate, DoStackSwitch, ) from inputremapper.groups import DeviceType from inputremapper.gui.components.editor import ( TargetSelection, MappingListBox, MappingSelectionLabel, CodeEditor, RecordingToggle, AutoloadSwitch, ReleaseCombinationSwitch, CombinationListbox, InputConfigEntry, AnalogInputSwitch, TriggerThresholdInput, ReleaseTimeoutInput, OutputAxisSelector, KeyAxisStackSwitcher, Sliders, TransformationDrawArea, RelativeInputCutoffInput, RecordingStatus, RequireActiveMapping, GdkEventRecorder, ) from inputremapper.gui.components.main import Stack, StatusBar from inputremapper.gui.components.common import FlowBoxEntry, Breadcrumbs from inputremapper.gui.components.presets import PresetSelection from inputremapper.gui.components.device_groups import ( DeviceGroupEntry, DeviceGroupSelection, ) from inputremapper.configs.mapping import MappingData from inputremapper.configs.input_config import InputCombination, InputConfig from tests.lib.test_setup import test_setup class ComponentBaseTest(unittest.TestCase): """Test a gui component.""" def setUp(self) -> None: self.message_broker = MessageBroker() self.controller_mock: Controller = MagicMock() def destroy_all_member_widgets(self): # destroy all Gtk Widgets that are stored in self # TODO why is this necessary? for attribute in dir(self): stuff = getattr(self, attribute, None) if isinstance(stuff, Gtk.Widget): logger.info('destroying member "%s" %s', attribute, stuff) GLib.timeout_add(0, stuff.destroy) setattr(self, attribute, None) def tearDown(self) -> None: super().tearDown() self.message_broker.signal(MessageType.terminate) # Shut down the gui properly self.destroy_all_member_widgets() GLib.timeout_add(0, Gtk.main_quit) # Gtk.main() will start the Gtk event loop and process all pending events. # So the gui will do whatever is queued up this ensures that the next tests # starts without pending events. Gtk.main() class FlowBoxTestUtils: """Methods to test the FlowBoxes that contain presets and devices. Those are only used in tests, so I moved them here instead. """ @staticmethod def set_active(flow_box: Gtk.FlowBox, name: str): """Change the currently selected group.""" for child in flow_box.get_children(): flow_box_entry: FlowBoxEntry = child.get_children()[0] flow_box_entry.set_active(flow_box_entry.name == name) @staticmethod def get_active_entry(flow_box: Gtk.FlowBox) -> Union[DeviceGroupEntry, None]: """Find the currently selected DeviceGroupEntry.""" children = flow_box.get_children() if len(children) == 0: return None for child in children: flow_box_entry: FlowBoxEntry = child.get_children()[0] if flow_box_entry.get_active(): return flow_box_entry raise AssertionError("Expected one entry to be selected.") @staticmethod def get_child_names(flow_box: Gtk.FlowBox): names = [] for child in flow_box.get_children(): flow_box_entry: FlowBoxEntry = child.get_children()[0] names.append(flow_box_entry.name) return names @staticmethod def get_child_icons(flow_box: Gtk.FlowBox): icon_names = [] for child in flow_box.get_children(): flow_box_entry: FlowBoxEntry = child.get_children()[0] icon_names.append(flow_box_entry.icon_name) return icon_names @test_setup class TestDeviceGroupSelection(ComponentBaseTest): def setUp(self) -> None: super().setUp() self.gui = Gtk.FlowBox() self.selection = DeviceGroupSelection( self.message_broker, self.controller_mock, self.gui, ) self.message_broker.publish( GroupsData( { "foo": [DeviceType.GAMEPAD, DeviceType.KEYBOARD], "bar": [], "baz": [DeviceType.GRAPHICS_TABLET], } ) ) def get_displayed_group_keys_and_icons(self): """Get a list of all group_keys and icons of the displayed groups.""" group_keys = [] icons = [] for child in self.gui.get_children(): device_group_entry = child.get_children()[0] group_keys.append(device_group_entry.group_key) icons.append(device_group_entry.icon_name) return group_keys, icons def test_populates_devices(self): # tests that all devices sent via the broker end up in the gui group_keys, icons = self.get_displayed_group_keys_and_icons() self.assertEqual(group_keys, ["foo", "bar", "baz"]) self.assertEqual(icons, ["input-gaming", None, "input-tablet"]) self.message_broker.publish( GroupsData( { "kuu": [DeviceType.KEYBOARD], "qux": [DeviceType.GAMEPAD], } ) ) group_keys, icons = self.get_displayed_group_keys_and_icons() self.assertEqual(group_keys, ["kuu", "qux"]) self.assertEqual(icons, ["input-keyboard", "input-gaming"]) def test_selects_correct_device(self): self.message_broker.publish(GroupData("bar", ())) self.assertEqual(FlowBoxTestUtils.get_active_entry(self.gui).group_key, "bar") self.message_broker.publish(GroupData("baz", ())) self.assertEqual(FlowBoxTestUtils.get_active_entry(self.gui).group_key, "baz") def test_loads_group(self): FlowBoxTestUtils.set_active(self.gui, "bar") self.controller_mock.load_group.assert_called_once_with("bar") def test_avoids_infinite_recursion(self): self.message_broker.publish(GroupData("bar", ())) self.controller_mock.load_group.assert_not_called() @test_setup class TestTargetSelection(ComponentBaseTest): def setUp(self) -> None: super().setUp() self.gui = Gtk.ComboBox() self.selection = TargetSelection( self.message_broker, self.controller_mock, self.gui ) self.message_broker.publish( UInputsData( { "foo": {}, "bar": {}, "baz": {}, } ) ) def test_populates_devices(self): names = [row[0] for row in self.gui.get_model()] self.assertEqual(names, ["foo", "bar", "baz"]) self.message_broker.publish( UInputsData( { "kuu": {}, "qux": {}, } ) ) names = [row[0] for row in self.gui.get_model()] self.assertEqual(names, ["kuu", "qux"]) def test_updates_mapping(self): self.gui.set_active_id("baz") self.controller_mock.update_mapping.assert_called_once_with(target_uinput="baz") def test_selects_correct_target(self): self.message_broker.publish(MappingData(target_uinput="baz")) self.assertEqual(self.gui.get_active_id(), "baz") self.message_broker.publish(MappingData(target_uinput="bar")) self.assertEqual(self.gui.get_active_id(), "bar") def test_avoids_infinite_recursion(self): self.message_broker.publish(MappingData(target_uinput="baz")) self.controller_mock.update_mapping.assert_not_called() @test_setup class TestPresetSelection(ComponentBaseTest): def setUp(self) -> None: super().setUp() self.gui = Gtk.FlowBox() self.selection = PresetSelection( self.message_broker, self.controller_mock, self.gui ) self.message_broker.publish(GroupData("foo", ("preset1", "preset2"))) def test_populates_presets(self): names = FlowBoxTestUtils.get_child_names(self.gui) self.assertEqual(names, ["preset1", "preset2"]) self.message_broker.publish(GroupData("foo", ("preset3", "preset4"))) names = FlowBoxTestUtils.get_child_names(self.gui) self.assertEqual(names, ["preset3", "preset4"]) def test_selects_preset(self): self.message_broker.publish( PresetData( "preset2", ( MappingData( name="m1", input_combination=InputCombination( [InputConfig(type=1, code=2)] ), ), ), ) ) self.assertEqual(FlowBoxTestUtils.get_active_entry(self.gui).name, "preset2") self.message_broker.publish( PresetData( "preset1", ( MappingData( name="m1", input_combination=InputCombination( [InputConfig(type=1, code=2)] ), ), ), ) ) self.assertEqual(FlowBoxTestUtils.get_active_entry(self.gui).name, "preset1") def test_avoids_infinite_recursion(self): self.message_broker.publish( PresetData( "preset2", ( MappingData( name="m1", input_combination=InputCombination( [InputConfig(type=1, code=2)] ), ), ), ) ) self.controller_mock.load_preset.assert_not_called() def test_loads_preset(self): FlowBoxTestUtils.set_active(self.gui, "preset2") self.controller_mock.load_preset.assert_called_once_with("preset2") @test_setup class TestMappingListbox(ComponentBaseTest): def setUp(self) -> None: super().setUp() self.gui = Gtk.ListBox() self.listbox = MappingListBox( self.message_broker, self.controller_mock, self.gui ) self.message_broker.publish( PresetData( "preset1", ( MappingData( name="mapping1", input_combination=InputCombination( [InputConfig(type=1, code=KEY_C)] ), ), MappingData( name="", input_combination=InputCombination( [ InputConfig(type=1, code=KEY_A), InputConfig(type=1, code=KEY_B), ] ), ), MappingData( name="mapping2", input_combination=InputCombination( [InputConfig(type=1, code=KEY_B)] ), ), ), ) ) def get_selected_row(self) -> MappingSelectionLabel: for label in self.gui.get_children(): if label.is_selected(): return label raise Exception("Expected one MappingSelectionLabel to be selected") def select_row(self, combination: InputCombination): def select(label_: MappingSelectionLabel): if label_.combination == combination: self.gui.select_row(label_) for label in self.gui.get_children(): select(label) def test_populates_listbox(self): labels = {row.name for row in self.gui.get_children()} self.assertEqual(labels, {"mapping1", "mapping2", "a + b"}) def test_alphanumerically_sorted(self): labels = [row.name for row in self.gui.get_children()] self.assertEqual(labels, ["a + b", "mapping1", "mapping2"]) def test_activates_correct_row(self): self.message_broker.publish( MappingData( name="mapping1", input_combination=InputCombination([InputConfig(type=1, code=KEY_C)]), ) ) selected = self.get_selected_row() self.assertEqual(selected.name, "mapping1") self.assertEqual( selected.combination, InputCombination([InputConfig(type=1, code=KEY_C)]), ) def test_loads_mapping(self): self.select_row(InputCombination([InputConfig(type=1, code=KEY_B)])) self.controller_mock.load_mapping.assert_called_once_with( InputCombination([InputConfig(type=1, code=KEY_B)]) ) def test_avoids_infinite_recursion(self): self.message_broker.publish( MappingData( name="mapping1", input_combination=InputCombination([InputConfig(type=1, code=KEY_C)]), ) ) self.controller_mock.load_mapping.assert_not_called() def test_sorts_empty_mapping_to_bottom(self): self.message_broker.publish( PresetData( "preset1", ( MappingData( name="qux", input_combination=InputCombination( [InputConfig(type=1, code=KEY_C)] ), ), MappingData( name="foo", input_combination=InputCombination.empty_combination(), ), MappingData( name="bar", input_combination=InputCombination( [InputConfig(type=1, code=KEY_B)] ), ), ), ) ) bottom_row: MappingSelectionLabel = self.gui.get_row_at_index(2) self.assertEqual(bottom_row.combination, InputCombination.empty_combination()) self.message_broker.publish( PresetData( "preset1", ( MappingData( name="foo", input_combination=InputCombination.empty_combination(), ), MappingData( name="qux", input_combination=InputCombination( [InputConfig(type=1, code=KEY_C)] ), ), MappingData( name="bar", input_combination=InputCombination( [InputConfig(type=1, code=KEY_B)] ), ), ), ) ) bottom_row: MappingSelectionLabel = self.gui.get_row_at_index(2) self.assertEqual(bottom_row.combination, InputCombination.empty_combination()) @test_setup class TestMappingSelectionLabel(ComponentBaseTest): def setUp(self) -> None: super().setUp() self.gui = Gtk.ListBox() self.mapping_selection_label = MappingSelectionLabel( self.message_broker, self.controller_mock, "", InputCombination( [ InputConfig(type=1, code=KEY_A), InputConfig(type=1, code=KEY_B), ] ), ) self.gui.insert(self.mapping_selection_label, -1) def assert_edit_mode(self): self.assertTrue(self.mapping_selection_label.name_input.get_visible()) self.assertFalse(self.mapping_selection_label.label.get_visible()) def assert_selected(self): self.assertTrue(self.mapping_selection_label.label.get_visible()) self.assertFalse(self.mapping_selection_label.name_input.get_visible()) def test_repr(self): self.mapping_selection_label.name = "name" self.assertIn("name", repr(self.mapping_selection_label)) self.assertIn("KEY_A", repr(self.mapping_selection_label)) self.assertIn("KEY_B", repr(self.mapping_selection_label)) def test_shows_combination_without_name(self): self.assertEqual(self.mapping_selection_label.label.get_label(), "a + b") def test_shows_name_when_given(self): self.gui = MappingSelectionLabel( self.message_broker, self.controller_mock, "foo", InputCombination( ( InputConfig(type=1, code=KEY_A), InputConfig(type=1, code=KEY_B), ) ), ) self.assertEqual(self.gui.label.get_label(), "foo") def test_updates_combination_when_selected(self): self.gui.select_row(self.mapping_selection_label) self.assertEqual( self.mapping_selection_label.combination, InputCombination( ( InputConfig(type=1, code=KEY_A), InputConfig(type=1, code=KEY_B), ) ), ) self.message_broker.publish( CombinationUpdate( InputCombination( ( InputConfig(type=1, code=KEY_A), InputConfig(type=1, code=KEY_B), ) ), InputCombination([InputConfig(type=1, code=KEY_A)]), ) ) self.assertEqual( self.mapping_selection_label.combination, InputCombination([InputConfig(type=1, code=KEY_A)]), ) def test_doesnt_update_combination_when_not_selected(self): self.assertEqual( self.mapping_selection_label.combination, InputCombination( ( InputConfig(type=1, code=KEY_A), InputConfig(type=1, code=KEY_B), ) ), ) self.message_broker.publish( CombinationUpdate( InputCombination( ( InputConfig(type=1, code=KEY_A), InputConfig(type=1, code=KEY_B), ) ), InputCombination([InputConfig(type=1, code=KEY_A)]), ) ) self.assertEqual( self.mapping_selection_label.combination, InputCombination( ( InputConfig(type=1, code=KEY_A), InputConfig(type=1, code=KEY_B), ) ), ) def test_updates_name_when_mapping_changed_and_combination_matches(self): self.message_broker.publish( MappingData( input_combination=InputCombination( ( InputConfig(type=1, code=KEY_A), InputConfig(type=1, code=KEY_B), ) ), name="foo", ) ) self.assertEqual(self.mapping_selection_label.label.get_label(), "foo") def test_ignores_mapping_when_combination_does_not_match(self): self.message_broker.publish( MappingData( input_combination=InputCombination( ( InputConfig(type=1, code=KEY_A), InputConfig(type=1, code=KEY_C), ) ), name="foo", ) ) self.assertEqual(self.mapping_selection_label.label.get_label(), "a + b") def test_edit_button_visibility(self): # start off invisible self.assertFalse(self.mapping_selection_label.edit_btn.get_visible()) # load the mapping associated with the ListBoxRow self.message_broker.publish( MappingData( input_combination=InputCombination( ( InputConfig(type=1, code=KEY_A), InputConfig(type=1, code=KEY_B), ) ), ) ) self.assertTrue(self.mapping_selection_label.edit_btn.get_visible()) # load a different row self.message_broker.publish( MappingData( input_combination=InputCombination( ( InputConfig(type=1, code=KEY_A), InputConfig(type=1, code=KEY_C), ) ), ) ) self.assertFalse(self.mapping_selection_label.edit_btn.get_visible()) def test_enter_edit_mode_focuses_name_input(self): self.message_broker.publish( MappingData( input_combination=InputCombination( ( InputConfig(type=1, code=KEY_A), InputConfig(type=1, code=KEY_B), ) ), ) ) self.mapping_selection_label.edit_btn.clicked() self.controller_mock.set_focus.assert_called_once_with( self.mapping_selection_label.name_input ) def test_enter_edit_mode_updates_visibility(self): self.message_broker.publish( MappingData( input_combination=InputCombination( ( InputConfig(type=1, code=KEY_A), InputConfig(type=1, code=KEY_B), ) ), ) ) self.assert_selected() self.mapping_selection_label.edit_btn.clicked() self.assert_edit_mode() self.mapping_selection_label.name_input.activate() # aka hit the return key self.assert_selected() def test_leaves_edit_mode_on_esc(self): self.message_broker.publish( MappingData( input_combination=InputCombination( ( InputConfig(type=1, code=KEY_A), InputConfig(type=1, code=KEY_B), ) ), ) ) self.mapping_selection_label.edit_btn.clicked() self.assert_edit_mode() self.mapping_selection_label.name_input.set_text("foo") event = Gdk.Event() event.key.keyval = Gdk.KEY_Escape # send the "key-press-event" self.mapping_selection_label._on_gtk_rename_abort(None, event.key) self.assert_selected() self.assertEqual(self.mapping_selection_label.label.get_text(), "a + b") self.controller_mock.update_mapping.assert_not_called() def test_update_name(self): self.message_broker.publish( MappingData( input_combination=InputCombination( ( InputConfig(type=1, code=KEY_A), InputConfig(type=1, code=KEY_B), ) ), ) ) self.mapping_selection_label.edit_btn.clicked() self.mapping_selection_label.name_input.set_text("foo") self.mapping_selection_label.name_input.activate() self.controller_mock.update_mapping.assert_called_once_with(name="foo") def test_name_input_contains_combination_when_name_not_set(self): self.message_broker.publish( MappingData( input_combination=InputCombination( ( InputConfig(type=1, code=KEY_A), InputConfig(type=1, code=KEY_B), ) ), ) ) self.mapping_selection_label.edit_btn.clicked() self.assertEqual(self.mapping_selection_label.name_input.get_text(), "a + b") def test_name_input_contains_name(self): self.message_broker.publish( MappingData( input_combination=InputCombination( ( InputConfig(type=1, code=KEY_A), InputConfig(type=1, code=KEY_B), ) ), name="foo", ) ) self.mapping_selection_label.edit_btn.clicked() self.assertEqual(self.mapping_selection_label.name_input.get_text(), "foo") def test_removes_name_when_name_matches_combination(self): self.message_broker.publish( MappingData( input_combination=InputCombination( ( InputConfig(type=1, code=KEY_A), InputConfig(type=1, code=KEY_B), ) ), name="foo", ) ) self.mapping_selection_label.edit_btn.clicked() self.mapping_selection_label.name_input.set_text("a + b") self.mapping_selection_label.name_input.activate() self.controller_mock.update_mapping.assert_called_once_with(name="") @test_setup class TestGdkEventRecorder(ComponentBaseTest): def _emit_key(self, window, code, type_): event = Gdk.Event() event.type = type_ event.hardware_keycode = code + XKB_KEYCODE_OFFSET window.emit("event", event) gtk_iteration() def _emit_button(self, window, button, type_): event = Gdk.Event() event.type = type_ event.button = button window.emit("event", event) gtk_iteration() def test_records_combinations(self): label = Gtk.Label() window = Gtk.Window() GdkEventRecorder(window, label) self._emit_key(window, KEY_A, Gdk.EventType.KEY_PRESS) self._emit_key(window, KEY_B, Gdk.EventType.KEY_PRESS) self.assertEqual(label.get_text(), "a + b") self._emit_key(window, KEY_A, Gdk.EventType.KEY_RELEASE) self._emit_key(window, KEY_B, Gdk.EventType.KEY_RELEASE) self.assertEqual(label.get_text(), "a + b") self._emit_key(window, KEY_C, Gdk.EventType.KEY_PRESS) self.assertEqual(label.get_text(), "c") # buttons self._emit_button(window, Gdk.BUTTON_PRIMARY, Gdk.EventType.BUTTON_PRESS) self._emit_button(window, Gdk.BUTTON_SECONDARY, Gdk.EventType.BUTTON_PRESS) self._emit_button(window, Gdk.BUTTON_MIDDLE, Gdk.EventType.BUTTON_PRESS) # no constants seem to exist, but this is the value that was observed during # usage: self._emit_button(window, 8, Gdk.EventType.BUTTON_PRESS) self._emit_button(window, 9, Gdk.EventType.BUTTON_PRESS) self.assertEqual( label.get_text(), "c + BTN_LEFT + BTN_RIGHT + BTN_MIDDLE + BTN_SIDE + BTN_EXTRA", ) # releasing anything resets the combination self._emit_button(window, 9, Gdk.EventType.BUTTON_RELEASE) self._emit_key(window, KEY_A, Gdk.EventType.KEY_PRESS) self.assertEqual(label.get_text(), "a") @test_setup class TestCodeEditor(ComponentBaseTest): def setUp(self) -> None: super().setUp() self.gui = GtkSource.View() self.editor = CodeEditor(self.message_broker, self.controller_mock, self.gui) # TODO why is mocking this to False needed? self.controller_mock.is_empty_mapping.return_value = False def get_text(self) -> str: buffer = self.gui.get_buffer() return buffer.get_text(buffer.get_start_iter(), buffer.get_end_iter(), True) def test_shows_output_symbol(self): self.message_broker.publish(MappingData(output_symbol="foo")) self.assertEqual(self.get_text(), "foo") def test_shows_record_input_first_message_when_mapping_is_empty(self): self.controller_mock.is_empty_mapping.return_value = True self.message_broker.publish(MappingData(output_symbol="foo")) self.assertEqual(self.get_text(), "Record the input first") def test_active_when_mapping_is_not_empty(self): self.message_broker.publish(MappingData(output_symbol="foo")) self.assertTrue(self.gui.get_sensitive()) self.assertEqual(self.gui.get_opacity(), 1) def test_expands_to_multiline(self): self.message_broker.publish(MappingData(output_symbol="foo\nbar")) self.assertIn("multiline", self.gui.get_style_context().list_classes()) def test_shows_line_numbers_when_multiline(self): self.message_broker.publish(MappingData(output_symbol="foo\nbar")) self.assertTrue(self.gui.get_show_line_numbers()) def test_no_multiline_when_macro_not_multiline(self): self.message_broker.publish(MappingData(output_symbol="foo")) self.assertNotIn("multiline", self.gui.get_style_context().list_classes()) def test_no_line_numbers_macro_not_multiline(self): self.message_broker.publish(MappingData(output_symbol="foo")) self.assertFalse(self.gui.get_show_line_numbers()) def test_shows_placeholder_when_mapping_has_no_output_symbol(self): self.message_broker.publish(MappingData()) self.assertEqual(self.get_text(), self.editor.placeholder) # there are no side-effects because the placeholder is inserted: self.controller_mock.update_mapping.assert_not_called() def test_updates_mapping(self): self.message_broker.publish(MappingData()) buffer = self.gui.get_buffer() self.controller_mock.update_mapping.assert_not_called() buffer.set_text("foo") call_args_list = self.controller_mock.update_mapping.call_args_list # this test emits 2 events for whatever reason, the first one with an empty # symbol. this doesn't actually seem to happen when using it. self.assertEqual(call_args_list[-1], call(output_symbol="foo")) def test_avoids_infinite_recursion_when_loading_mapping(self): self.message_broker.publish(MappingData(output_symbol="foo")) self.controller_mock.update_mapping.assert_not_called() def test_gets_focus_when_input_recording_finises(self): self.message_broker.signal(MessageType.recording_finished) self.controller_mock.set_focus.assert_called_once_with(self.gui) def test_placeholder(self): self.assertEqual(self.get_text(), self.editor.placeholder) window = Gtk.Window() window.add(self.gui) window.show_all() def focus(): self.gui.grab_focus() # Do as many iterations as needed to make it work. gtk_iteration(15) def unfocus(): window.set_focus(None) gtk_iteration(15) # clears the input when we enter the editor widget focus() self.assertEqual(self.get_text(), "") self.assertNotIn("opaque-text", self.gui.get_style_context().list_classes()) # adds the placeholder back when we leave it unfocus() self.assertEqual(self.get_text(), self.editor.placeholder) self.assertIn("opaque-text", self.gui.get_style_context().list_classes()) # if we enter text and then leave, it won't show the placeholder focus() self.assertEqual(self.get_text(), "") buffer = self.gui.get_buffer() buffer.set_text("foo") self.assertNotIn("opaque-text", self.gui.get_style_context().list_classes()) unfocus() self.assertEqual(self.get_text(), "foo") self.assertNotIn("opaque-text", self.gui.get_style_context().list_classes()) @test_setup class TestRecordingToggle(ComponentBaseTest): def setUp(self) -> None: super().setUp() self.toggle_button = Gtk.ToggleButton() self.recording_toggle = RecordingToggle( self.message_broker, self.controller_mock, self.toggle_button, ) self.label = Gtk.Label() self.recording_status = RecordingStatus(self.message_broker, self.label) def assert_not_recording(self): self.assertFalse(self.label.get_visible()) self.assertFalse(self.toggle_button.get_active()) def test_starts_recording(self): self.toggle_button.set_active(True) self.controller_mock.start_key_recording.assert_called_once() def test_stops_recording_when_clicked(self): self.toggle_button.set_active(True) self.toggle_button.set_active(False) self.controller_mock.stop_key_recording.assert_called_once() def test_not_recording_initially(self): self.assert_not_recording() def test_shows_recording_when_message_sent(self): self.assertFalse(self.label.get_visible()) self.message_broker.signal(MessageType.recording_started) self.assertTrue(self.label.get_visible()) def test_shows_not_recording_after_toggle(self): self.toggle_button.set_active(True) self.toggle_button.set_active(False) self.assert_not_recording() def test_shows_not_recording_when_recording_finished(self): self.toggle_button.set_active(True) self.message_broker.signal(MessageType.recording_finished) self.assert_not_recording() @test_setup class TestStatusBar(ComponentBaseTest): def setUp(self) -> None: super().setUp() self.gui = Gtk.Statusbar() self.err_icon = Gtk.Image() self.warn_icon = Gtk.Image() self.statusbar = StatusBar( self.message_broker, self.controller_mock, self.gui, self.err_icon, self.warn_icon, ) self.message_broker.signal(MessageType.init) def assert_empty(self): self.assertFalse(self.err_icon.get_visible()) self.assertFalse(self.warn_icon.get_visible()) self.assertEqual(self.get_text(), "") self.assertIsNone(self.get_tooltip()) def assert_error_status(self): self.assertTrue(self.err_icon.get_visible()) self.assertFalse(self.warn_icon.get_visible()) def assert_warning_status(self): self.assertFalse(self.err_icon.get_visible()) self.assertTrue(self.warn_icon.get_visible()) def get_text(self) -> str: return self.gui.get_message_area().get_children()[0].get_text() def get_tooltip(self) -> Optional[str]: return self.gui.get_tooltip_text() def test_starts_empty(self): self.assert_empty() def test_shows_error_status(self): self.message_broker.publish(StatusData(CTX_ERROR, "msg", "tooltip")) self.assertEqual(self.get_text(), "msg") self.assertEqual(self.get_tooltip(), "tooltip") self.assert_error_status() def test_shows_warning_status(self): self.message_broker.publish(StatusData(CTX_WARNING, "msg", "tooltip")) self.assertEqual(self.get_text(), "msg") self.assertEqual(self.get_tooltip(), "tooltip") self.assert_warning_status() def test_shows_newest_message(self): self.message_broker.publish(StatusData(CTX_ERROR, "msg", "tooltip")) self.message_broker.publish(StatusData(CTX_WARNING, "msg2", "tooltip2")) self.assertEqual(self.get_text(), "msg2") self.assertEqual(self.get_tooltip(), "tooltip2") self.assert_warning_status() def test_data_without_message_removes_messages(self): self.message_broker.publish(StatusData(CTX_WARNING, "msg", "tooltip")) self.message_broker.publish(StatusData(CTX_WARNING, "msg2", "tooltip2")) self.message_broker.publish(StatusData(CTX_WARNING)) self.assert_empty() def test_restores_message_from_not_removed_ctx_id(self): self.message_broker.publish(StatusData(CTX_ERROR, "msg", "tooltip")) self.message_broker.publish(StatusData(CTX_WARNING, "msg2", "tooltip2")) self.message_broker.publish(StatusData(CTX_WARNING)) self.assertEqual(self.get_text(), "msg") self.assert_error_status() # works also the other way round self.message_broker.publish(StatusData(CTX_ERROR)) self.message_broker.publish(StatusData(CTX_WARNING, "msg", "tooltip")) self.message_broker.publish(StatusData(CTX_ERROR, "msg2", "tooltip2")) self.message_broker.publish(StatusData(CTX_ERROR)) self.assertEqual(self.get_text(), "msg") self.assert_warning_status() def test_sets_msg_as_tooltip_if_tooltip_is_none(self): self.message_broker.publish(StatusData(CTX_ERROR, "msg")) self.assertEqual(self.get_tooltip(), "msg") @test_setup class TestAutoloadSwitch(ComponentBaseTest): def setUp(self) -> None: super().setUp() self.gui = Gtk.Switch() self.switch = AutoloadSwitch( self.message_broker, self.controller_mock, self.gui ) def test_sets_autoload(self): self.gui.set_active(True) self.controller_mock.set_autoload.assert_called_once_with(True) self.controller_mock.reset_mock() self.gui.set_active(False) self.controller_mock.set_autoload.assert_called_once_with(False) def test_updates_state(self): self.message_broker.publish(PresetData(None, None, autoload=True)) self.assertTrue(self.gui.get_active()) self.message_broker.publish(PresetData(None, None, autoload=False)) self.assertFalse(self.gui.get_active()) def test_avoids_infinite_recursion(self): self.message_broker.publish(PresetData(None, None, autoload=True)) self.message_broker.publish(PresetData(None, None, autoload=False)) self.controller_mock.set_autoload.assert_not_called() @test_setup class TestReleaseCombinationSwitch(ComponentBaseTest): def setUp(self) -> None: super().setUp() self.gui = Gtk.Switch() self.switch = ReleaseCombinationSwitch( self.message_broker, self.controller_mock, self.gui ) def test_updates_mapping(self): self.gui.set_active(True) self.controller_mock.update_mapping.assert_called_once_with( release_combination_keys=True ) self.controller_mock.reset_mock() self.gui.set_active(False) self.controller_mock.update_mapping.assert_called_once_with( release_combination_keys=False ) def test_updates_state(self): self.message_broker.publish(MappingData(release_combination_keys=True)) self.assertTrue(self.gui.get_active()) self.message_broker.publish(MappingData(release_combination_keys=False)) self.assertFalse(self.gui.get_active()) def test_avoids_infinite_recursion(self): self.message_broker.publish(MappingData(release_combination_keys=True)) self.message_broker.publish(MappingData(release_combination_keys=False)) self.controller_mock.update_mapping.assert_not_called() @test_setup class TestEventEntry(ComponentBaseTest): def setUp(self) -> None: super().setUp() self.gui = InputConfigEntry( InputConfig(type=3, code=0, analog_threshold=1), self.controller_mock ) def test_move_event(self): self.gui._up_btn.clicked() self.controller_mock.move_input_config_in_combination.assert_called_once_with( InputConfig(type=3, code=0, analog_threshold=1), "up" ) self.controller_mock.reset_mock() self.gui._down_btn.clicked() self.controller_mock.move_input_config_in_combination.assert_called_once_with( InputConfig(type=3, code=0, analog_threshold=1), "down" ) @test_setup class TestCombinationListbox(ComponentBaseTest): def setUp(self) -> None: super().setUp() self.gui = Gtk.ListBox() self.listbox = CombinationListbox( self.message_broker, self.controller_mock, self.gui ) self.controller_mock.is_empty_mapping.return_value = False combination = InputCombination( ( InputConfig(type=1, code=1), InputConfig(type=3, code=0, analog_threshold=1), InputConfig(type=1, code=2), ) ) self.message_broker.publish( MappingData( input_combination=combination.to_config(), target_uinput="keyboard" ) ) def get_selected_row(self) -> InputConfigEntry: for entry in self.gui.get_children(): if entry.is_selected(): return entry raise Exception("Expected one InputConfigEntry to be selected") def select_row(self, input_cfg: InputConfig): for entry in self.gui.get_children(): if entry.input_event == input_cfg: self.gui.select_row(entry) def test_loads_selected_row(self): self.select_row(InputConfig(type=1, code=2)) self.controller_mock.load_input_config.assert_called_once_with( InputConfig(type=1, code=2) ) def test_does_not_create_rows_when_mapping_is_empty(self): self.controller_mock.is_empty_mapping.return_value = True combination = InputCombination( ( InputConfig(type=1, code=1), InputConfig(type=3, code=0, analog_threshold=1), ) ) self.message_broker.publish(MappingData(input_combination=combination)) self.assertEqual(len(self.gui.get_children()), 0) def test_selects_row_when_selected_event_message_arrives(self): self.message_broker.publish(InputConfig(type=3, code=0, analog_threshold=1)) self.assertEqual( self.get_selected_row().input_event, InputConfig(type=3, code=0, analog_threshold=1), ) def test_avoids_infinite_recursion(self): self.message_broker.publish(InputConfig(type=3, code=0, analog_threshold=1)) self.controller_mock.load_event.assert_not_called() @test_setup class TestAnalogInputSwitch(ComponentBaseTest): def setUp(self) -> None: super().setUp() self.gui = Gtk.Switch() self.switch = AnalogInputSwitch( self.message_broker, self.controller_mock, self.gui ) def test_updates_event_as_analog(self): self.gui.set_active(True) self.controller_mock.set_event_as_analog.assert_called_once_with(True) self.controller_mock.reset_mock() self.gui.set_active(False) self.controller_mock.set_event_as_analog.assert_called_once_with(False) def test_updates_state(self): self.message_broker.publish(InputConfig(type=3, code=0)) self.assertTrue(self.gui.get_active()) self.message_broker.publish(InputConfig(type=3, code=0, analog_threshold=10)) self.assertFalse(self.gui.get_active()) def test_avoids_infinite_recursion(self): self.message_broker.publish(InputConfig(type=3, code=0)) self.message_broker.publish(InputConfig(type=3, code=0, analog_threshold=-10)) self.controller_mock.set_event_as_analog.assert_not_called() def test_disables_switch_when_key_event(self): self.message_broker.publish(InputConfig(type=1, code=1)) self.assertLess(self.gui.get_opacity(), 0.6) self.assertFalse(self.gui.get_sensitive()) def test_enables_switch_when_axis_event(self): self.message_broker.publish(InputConfig(type=1, code=1)) self.message_broker.publish(InputConfig(type=3, code=0, analog_threshold=10)) self.assertEqual(self.gui.get_opacity(), 1) self.assertTrue(self.gui.get_sensitive()) self.message_broker.publish(InputConfig(type=1, code=1)) self.message_broker.publish(InputConfig(type=2, code=0, analog_threshold=10)) self.assertEqual(self.gui.get_opacity(), 1) self.assertTrue(self.gui.get_sensitive()) @test_setup class TestTriggerThresholdInput(ComponentBaseTest): def setUp(self) -> None: super().setUp() self.gui = Gtk.SpinButton() self.input = TriggerThresholdInput( self.message_broker, self.controller_mock, self.gui ) self.message_broker.publish(InputConfig(type=3, code=0, analog_threshold=-10)) def assert_abs_event_config(self): self.assertEqual(self.gui.get_range(), (-99, 99)) self.assertTrue(self.gui.get_sensitive()) self.assertEqual(self.gui.get_opacity(), 1) def assert_rel_event_config(self): self.assertEqual(self.gui.get_range(), (-999, 999)) self.assertTrue(self.gui.get_sensitive()) self.assertEqual(self.gui.get_opacity(), 1) def assert_key_event_config(self): self.assertFalse(self.gui.get_sensitive()) self.assertLess(self.gui.get_opacity(), 0.6) def test_updates_event(self): self.gui.set_value(15) self.controller_mock.update_input_config.assert_called_once_with( InputConfig(type=3, code=0, analog_threshold=15) ) def test_sets_value_on_selected_event_message(self): self.message_broker.publish(InputConfig(type=3, code=0, analog_threshold=10)) self.assertEqual(self.gui.get_value(), 10) def test_avoids_infinite_recursion(self): self.message_broker.publish(InputConfig(type=3, code=0, analog_threshold=10)) self.controller_mock.update_input_config.assert_not_called() def test_updates_configuration_according_to_selected_event(self): self.assert_abs_event_config() self.message_broker.publish(InputConfig(type=2, code=0, analog_threshold=-10)) self.assert_rel_event_config() self.message_broker.publish(InputConfig(type=1, code=1)) self.assert_key_event_config() @test_setup class TestReleaseTimeoutInput(ComponentBaseTest): def setUp(self) -> None: super().setUp() self.gui = Gtk.SpinButton() self.input = ReleaseTimeoutInput( self.message_broker, self.controller_mock, self.gui ) self.message_broker.publish( MappingData( input_combination=InputCombination( [InputConfig(type=2, code=0, analog_threshold=1)] ), target_uinput="keyboard", ) ) def test_updates_timeout_on_mapping_message(self): self.message_broker.publish( MappingData( input_combination=InputCombination( [InputConfig(type=2, code=0, analog_threshold=1)] ), release_timeout=1, ) ) self.assertEqual(self.gui.get_value(), 1) def test_updates_mapping(self): self.gui.set_value(0.5) self.controller_mock.update_mapping.assert_called_once_with(release_timeout=0.5) def test_avoids_infinite_recursion(self): self.message_broker.publish( MappingData( input_combination=InputCombination( [InputConfig(type=2, code=0, analog_threshold=1)] ), release_timeout=1, ) ) self.controller_mock.update_mapping.assert_not_called() def test_disables_input_based_on_input_combination(self): self.message_broker.publish( MappingData( input_combination=InputCombination( ( InputConfig(type=2, code=0, analog_threshold=1), InputConfig(type=1, code=1), ) ) ) ) self.assertTrue(self.gui.get_sensitive()) self.assertEqual(self.gui.get_opacity(), 1) self.message_broker.publish( MappingData( input_combination=InputCombination( ( InputConfig(type=1, code=1), InputConfig(type=1, code=2), ) ) ) ) self.assertFalse(self.gui.get_sensitive()) self.assertLess(self.gui.get_opacity(), 0.6) self.message_broker.publish( MappingData( input_combination=InputCombination( ( InputConfig(type=2, code=0, analog_threshold=1), InputConfig(type=1, code=1), ) ) ) ) self.message_broker.publish( MappingData( input_combination=InputCombination( ( InputConfig(type=3, code=0, analog_threshold=1), InputConfig( type=1, code=2, ), ) ) ) ) self.assertFalse(self.gui.get_sensitive()) self.assertLess(self.gui.get_opacity(), 0.6) @test_setup class TestOutputAxisSelector(ComponentBaseTest): def setUp(self) -> None: super().setUp() self.gui = Gtk.ComboBox() self.selection = OutputAxisSelector( self.message_broker, self.controller_mock, self.gui ) absinfo = evdev.AbsInfo(0, -10, 10, 0, 0, 0) self.message_broker.publish( UInputsData( { "mouse": {1: [1, 2, 3, 4], 2: [0, 1, 2, 3]}, "keyboard": {1: [1, 2, 3, 4]}, "gamepad": { 2: [0, 1, 2, 3], 3: [(0, absinfo), (1, absinfo), (2, absinfo), (3, absinfo)], }, } ) ) self.message_broker.publish( MappingData( target_uinput="mouse", input_combination=InputCombination([InputConfig(type=1, code=1)]), ) ) def set_active_selection(self, selection: Tuple): self.gui.set_active_id(f"{selection[0]}, {selection[1]}") def get_active_selection(self) -> Tuple[int, int]: return tuple(int(i) for i in self.gui.get_active_id().split(",")) # type: ignore def test_updates_mapping(self): self.set_active_selection((2, 0)) self.controller_mock.update_mapping.assert_called_once_with( output_type=2, output_code=0 ) def test_updates_mapping_with_none(self): self.set_active_selection((2, 0)) self.controller_mock.reset_mock() self.set_active_selection((None, None)) self.controller_mock.update_mapping.assert_called_once_with( output_type=None, output_code=None ) def test_selects_correct_entry(self): self.assertEqual(self.gui.get_active_id(), "None, None") self.message_broker.publish( MappingData(target_uinput="mouse", output_type=2, output_code=3) ) self.assertEqual(self.get_active_selection(), (2, 3)) def test_avoids_infinite_recursion(self): self.message_broker.publish( MappingData(target_uinput="mouse", output_type=2, output_code=3) ) self.controller_mock.update_mapping.assert_not_called() def test_updates_dropdown_model(self): self.assertEqual(len(self.gui.get_model()), 5) self.message_broker.publish(MappingData(target_uinput="keyboard")) self.assertEqual(len(self.gui.get_model()), 1) self.message_broker.publish(MappingData(target_uinput="gamepad")) self.assertEqual(len(self.gui.get_model()), 9) @test_setup class TestKeyAxisStackSwitcher(ComponentBaseTest): def setUp(self) -> None: super().setUp() self.gui = Gtk.Box() self.gtk_stack = Gtk.Stack() self.analog_toggle = Gtk.ToggleButton() self.key_toggle = Gtk.ToggleButton() self.gui.add(self.gtk_stack) self.gui.add(self.analog_toggle) self.gui.add(self.key_toggle) self.gtk_stack.add_named(Gtk.Box(), "Analog Axis") self.gtk_stack.add_named(Gtk.Box(), "Key or Macro") self.stack = KeyAxisStackSwitcher( self.message_broker, self.controller_mock, self.gtk_stack, self.key_toggle, self.analog_toggle, ) self.gui.show_all() self.gtk_stack.set_visible_child_name("Key or Macro") def assert_key_macro_active(self): self.assertEqual(self.gtk_stack.get_visible_child_name(), "Key or Macro") self.assertTrue(self.key_toggle.get_active()) self.assertFalse(self.analog_toggle.get_active()) def assert_analog_active(self): self.assertEqual(self.gtk_stack.get_visible_child_name(), "Analog Axis") self.assertFalse(self.key_toggle.get_active()) self.assertTrue(self.analog_toggle.get_active()) def test_switches_to_axis(self): self.message_broker.publish(MappingData(mapping_type="analog")) self.assert_analog_active() def test_switches_to_key_macro(self): self.message_broker.publish(MappingData(mapping_type="analog")) self.message_broker.publish(MappingData(mapping_type="key_macro")) self.assert_key_macro_active() def test_updates_mapping_type(self): self.key_toggle.set_active(True) self.controller_mock.update_mapping.assert_called_once_with( mapping_type="key_macro" ) self.controller_mock.update_mapping.reset_mock() self.analog_toggle.set_active(True) self.controller_mock.update_mapping.assert_called_once_with( mapping_type="analog" ) def test_avoids_infinite_recursion(self): self.message_broker.publish(MappingData(mapping_type="analog")) self.message_broker.publish(MappingData(mapping_type="key_macro")) self.controller_mock.update_mapping.assert_not_called() @test_setup class TestTransformationDrawArea(ComponentBaseTest): def setUp(self) -> None: super().setUp() self.gui = Gtk.Window() self.draw_area = Gtk.DrawingArea() self.gui.add(self.draw_area) self.transform_draw_area = TransformationDrawArea( self.message_broker, self.controller_mock, self.draw_area, ) def test_draws_transform(self): with spy(self.transform_draw_area, "_transformation") as mock: # show the window, it takes some time and iterations until it pops up self.gui.show_all() for _ in range(5): gtk_iteration() time.sleep(0.01) mock.assert_called() def test_updates_transform_when_mapping_updates(self): old_tf = self.transform_draw_area._transformation self.message_broker.publish(MappingData(gain=2)) self.assertIsNot(old_tf, self.transform_draw_area._transformation) def test_redraws_when_mapping_updates(self): self.gui.show_all() gtk_iteration(20) mock = MagicMock() self.draw_area.connect("draw", mock) self.message_broker.publish(MappingData(gain=2)) gtk_iteration(20) mock.assert_called() @test_setup class TestSliders(ComponentBaseTest): def setUp(self) -> None: super().setUp() self.gui = Gtk.Box() self.gain = Gtk.Scale() self.deadzone = Gtk.Scale() self.expo = Gtk.Scale() # add everything to a box: it will be cleand up properly self.gui.add(self.gain) self.gui.add(self.deadzone) self.gui.add(self.expo) self.sliders = Sliders( self.message_broker, self.controller_mock, self.gain, self.deadzone, self.expo, ) self.message_broker.publish( MappingData( input_combination=InputCombination([InputConfig(type=3, code=0)]), target_uinput="mouse", ) ) @staticmethod def get_range(range: Gtk.Range) -> Tuple[int, int]: """the Gtk.Range, has no get_range method. this is a workaround""" v = range.get_value() range.set_value(-(2**16)) min_ = range.get_value() range.set_value(2**16) max_ = range.get_value() range.set_value(v) return min_, max_ def test_slider_ranges(self): self.assertEqual(self.get_range(self.gain), (-2, 2)) self.assertEqual(self.get_range(self.deadzone), (0, 0.9)) self.assertEqual(self.get_range(self.expo), (-1, 1)) def test_updates_value(self): self.message_broker.publish( MappingData( gain=0.5, deadzone=0.6, expo=0.3, ) ) self.assertEqual(self.gain.get_value(), 0.5) self.assertEqual(self.expo.get_value(), 0.3) self.assertEqual(self.deadzone.get_value(), 0.6) def test_gain_updates_mapping(self): self.gain.set_value(0.5) self.controller_mock.update_mapping.assert_called_once_with(gain=0.5) def test_expo_updates_mapping(self): self.expo.set_value(0.5) self.controller_mock.update_mapping.assert_called_once_with(expo=0.5) def test_deadzone_updates_mapping(self): self.deadzone.set_value(0.5) self.controller_mock.update_mapping.assert_called_once_with(deadzone=0.5) def test_avoids_recursion(self): self.message_broker.publish(MappingData(gain=0.5)) self.controller_mock.update_mapping.assert_not_called() self.message_broker.publish(MappingData(expo=0.5)) self.controller_mock.update_mapping.assert_not_called() self.message_broker.publish(MappingData(deadzone=0.5)) self.controller_mock.update_mapping.assert_not_called() @test_setup class TestRelativeInputCutoffInput(ComponentBaseTest): def setUp(self) -> None: super().setUp() self.gui = Gtk.SpinButton() self.input = RelativeInputCutoffInput( self.message_broker, self.controller_mock, self.gui ) self.message_broker.publish( MappingData( target_uinput="mouse", input_combination=InputCombination([InputConfig(type=2, code=0)]), rel_to_abs_input_cutoff=1, output_type=3, output_code=0, ) ) def assert_active(self): self.assertTrue(self.gui.get_sensitive()) self.assertEqual(self.gui.get_opacity(), 1) def assert_inactive(self): self.assertFalse(self.gui.get_sensitive()) self.assertLess(self.gui.get_opacity(), 0.6) def test_avoids_infinite_recursion(self): self.message_broker.publish( MappingData( target_uinput="mouse", input_combination=InputCombination([InputConfig(type=2, code=0)]), rel_to_abs_input_cutoff=3, output_type=3, output_code=0, ) ) self.controller_mock.update_mapping.assert_not_called() def test_updates_value(self): rel_to_abs_input_cutoff = 3 self.message_broker.publish( MappingData( target_uinput="mouse", input_combination=InputCombination([InputConfig(type=2, code=0)]), rel_to_abs_input_cutoff=rel_to_abs_input_cutoff, output_type=3, output_code=0, ) ) self.assertEqual(self.gui.get_value(), rel_to_abs_input_cutoff) def test_updates_mapping(self): self.gui.set_value(300) self.controller_mock.update_mapping.assert_called_once_with(rel_xy_cutoff=300) def test_disables_input_when_no_rel_axis_input(self): self.assert_active() self.message_broker.publish( MappingData( target_uinput="mouse", input_combination=InputCombination([InputConfig(type=3, code=0)]), output_type=3, output_code=0, ) ) self.assert_inactive() def test_disables_input_when_no_abs_axis_output(self): self.assert_active() self.message_broker.publish( MappingData( target_uinput="mouse", input_combination=InputCombination([InputConfig(type=2, code=0)]), rel_to_abs_input_cutoff=3, output_type=2, output_code=0, ) ) self.assert_inactive() def test_enables_input(self): self.message_broker.publish( MappingData( target_uinput="mouse", input_combination=InputCombination([InputConfig(type=3, code=0)]), output_type=3, output_code=0, ) ) self.assert_inactive() self.message_broker.publish( MappingData( target_uinput="mouse", input_combination=InputCombination([InputConfig(type=2, code=0)]), rel_to_abs_input_cutoff=1, output_type=3, output_code=0, ) ) self.assert_active() @test_setup class TestRequireActiveMapping(ComponentBaseTest): def test_no_reqorded_input_required(self): self.box = Gtk.Box() RequireActiveMapping( self.message_broker, self.box, require_recorded_input=False, ) combination = InputCombination([InputConfig(type=1, code=KEY_A)]) self.message_broker.publish(MappingData()) self.assert_inactive(self.box) self.message_broker.publish(PresetData(name="preset", mappings=())) self.assert_inactive(self.box) # a mapping is available, that is all the widget needs to be activated. one # mapping is always selected, so there is no need to check the mapping message self.message_broker.publish(PresetData(name="preset", mappings=(combination,))) self.assert_active(self.box) self.message_broker.publish(MappingData(input_combination=combination)) self.assert_active(self.box) self.message_broker.publish(MappingData()) self.assert_active(self.box) def test_recorded_input_required(self): self.box = Gtk.Box() RequireActiveMapping( self.message_broker, self.box, require_recorded_input=True, ) combination = InputCombination([InputConfig(type=1, code=KEY_A)]) self.message_broker.publish(MappingData()) self.assert_inactive(self.box) self.message_broker.publish(PresetData(name="preset", mappings=())) self.assert_inactive(self.box) self.message_broker.publish(PresetData(name="preset", mappings=(combination,))) self.assert_inactive(self.box) # the widget will be enabled once a mapping with recorded input is selected self.message_broker.publish(MappingData(input_combination=combination)) self.assert_active(self.box) # this mapping doesn't have input recorded, so the box is disabled self.message_broker.publish(MappingData()) self.assert_inactive(self.box) def assert_inactive(self, widget: Gtk.Widget): self.assertFalse(widget.get_sensitive()) self.assertLess(widget.get_opacity(), 0.6) self.assertGreater(widget.get_opacity(), 0.4) def assert_active(self, widget: Gtk.Widget): self.assertTrue(widget.get_sensitive()) self.assertEqual(widget.get_opacity(), 1) @test_setup class TestStack(ComponentBaseTest): def test_switches_pages(self): self.stack = Gtk.Stack() self.stack.add_named(Gtk.Label(), "Devices") self.stack.add_named(Gtk.Label(), "Presets") self.stack.add_named(Gtk.Label(), "Editor") self.stack.show_all() stack_wrapper = Stack(self.message_broker, self.controller_mock, self.stack) self.message_broker.publish(DoStackSwitch(Stack.devices_page)) self.assertEqual(self.stack.get_visible_child_name(), "Devices") self.message_broker.publish(DoStackSwitch(Stack.presets_page)) self.assertEqual(self.stack.get_visible_child_name(), "Presets") self.message_broker.publish(DoStackSwitch(Stack.editor_page)) self.assertEqual(self.stack.get_visible_child_name(), "Editor") @test_setup class TestBreadcrumbs(ComponentBaseTest): def test_breadcrumbs(self): self.label_1 = Gtk.Label() self.label_2 = Gtk.Label() self.label_3 = Gtk.Label() self.label_4 = Gtk.Label() self.label_5 = Gtk.Label() Breadcrumbs( self.message_broker, self.label_1, show_device_group=False, show_preset=False, show_mapping=False, ) Breadcrumbs( self.message_broker, self.label_2, show_device_group=True, show_preset=False, show_mapping=False, ) Breadcrumbs( self.message_broker, self.label_3, show_device_group=True, show_preset=True, show_mapping=False, ) Breadcrumbs( self.message_broker, self.label_4, show_device_group=True, show_preset=True, show_mapping=True, ) Breadcrumbs( self.message_broker, self.label_5, show_device_group=False, show_preset=False, show_mapping=True, ) self.assertEqual(self.label_1.get_text(), "") self.assertEqual(self.label_2.get_text(), "?") self.assertEqual(self.label_3.get_text(), "? / ?") self.assertEqual(self.label_4.get_text(), "? / ? / ?") self.assertEqual(self.label_5.get_text(), "?") self.message_broker.publish(PresetData("preset", None)) self.assertEqual(self.label_1.get_text(), "") self.assertEqual(self.label_2.get_text(), "?") self.assertEqual(self.label_3.get_text(), "? / preset") self.assertEqual(self.label_4.get_text(), "? / preset / ?") self.assertEqual(self.label_5.get_text(), "?") self.message_broker.publish(GroupData("group", ())) self.assertEqual(self.label_1.get_text(), "") self.assertEqual(self.label_2.get_text(), "group") self.assertEqual(self.label_3.get_text(), "group / preset") self.assertEqual(self.label_4.get_text(), "group / preset / ?") self.assertEqual(self.label_5.get_text(), "?") self.message_broker.publish(MappingData()) self.assertEqual(self.label_1.get_text(), "") self.assertEqual(self.label_2.get_text(), "group") self.assertEqual(self.label_3.get_text(), "group / preset") self.assertEqual(self.label_4.get_text(), "group / preset / Empty Mapping") self.assertEqual(self.label_5.get_text(), "Empty Mapping") self.message_broker.publish(MappingData(name="mapping")) self.assertEqual(self.label_4.get_text(), "group / preset / mapping") self.assertEqual(self.label_5.get_text(), "mapping") combination = InputCombination( ( InputConfig(type=1, code=KEY_A), InputConfig(type=1, code=KEY_B), ) ) self.message_broker.publish(MappingData(input_combination=combination)) self.assertEqual(self.label_4.get_text(), "group / preset / a + b") self.assertEqual(self.label_5.get_text(), "a + b") combination = InputCombination([InputConfig(type=1, code=KEY_A)]) self.message_broker.publish( MappingData(name="qux", input_combination=combination) ) self.assertEqual(self.label_4.get_text(), "group / preset / qux") self.assertEqual(self.label_5.get_text(), "qux") input-remapper-2.1.1/tests/integration/test_daemon.py000066400000000000000000000044361475433465200230500ustar00rootroot00000000000000#!/usr/bin/env python3 # -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings # Copyright (C) 2025 sezanzeb # # This file is part of input-remapper. # # input-remapper is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # input-remapper is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with input-remapper. If not, see . import multiprocessing import os import time import unittest import gi from tests.lib.test_setup import is_service_running gi.require_version("Gtk", "3.0") from gi.repository import Gtk from inputremapper.daemon import Daemon, BUS_NAME from tests.lib.test_setup import test_setup def gtk_iteration(): """Iterate while events are pending.""" while Gtk.events_pending(): Gtk.main_iteration() @test_setup class TestDBusDaemon(unittest.TestCase): def setUp(self): # You need to install input-remapper into your system in order for this test # to work. self.process = multiprocessing.Process( target=os.system, args=("input-remapper-service -d",) ) self.process.start() time.sleep(1) # should not use pkexec, but rather connect to the previously # spawned process self.interface = Daemon.connect() def tearDown(self): self.interface.stop_all() os.system("pkill -f input-remapper-service") for _ in range(10): time.sleep(0.1) if not is_service_running(): break self.assertFalse(is_service_running()) def test_can_connect(self): # 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-2.1.1/tests/integration/test_data.py000066400000000000000000000035761475433465200225220ustar00rootroot00000000000000#!/usr/bin/env python3 # -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings # Copyright (C) 2025 sezanzeb # # This file is part of input-remapper. # # input-remapper is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # input-remapper is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with input-remapper. If not, see . import os import unittest from unittest.mock import patch import pkg_resources from inputremapper.configs.data import get_data_path from tests.lib.test_setup import test_setup egg_info_distribution = pkg_resources.require("input-remapper")[0] project_root = os.getcwd().replace("/tests/integration", "") @test_setup class TestData(unittest.TestCase): @patch.object(egg_info_distribution, "location", project_root) def test_data_editable(self): self.assertEqual(get_data_path(), project_root + "/data/") self.assertEqual(get_data_path("a"), project_root + "/data/a") @patch.object( egg_info_distribution, "location", "/usr/some/where/python3.8/dist-packages/", ) def test_data_usr(self): 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-2.1.1/tests/integration/test_gui.py000066400000000000000000002531501475433465200223700ustar00rootroot00000000000000#!/usr/bin/env python3 # -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings # Copyright (C) 2025 sezanzeb # # This file is part of input-remapper. # # input-remapper is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # input-remapper is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with input-remapper. If not, see . import asyncio import atexit import multiprocessing import os import time import unittest from contextlib import contextmanager from typing import Tuple, List, Optional, Iterable from unittest.mock import patch, MagicMock, call import evdev import gi from evdev.ecodes import ( EV_KEY, EV_ABS, KEY_LEFTSHIFT, KEY_A, KEY_Q, EV_REL, ) from inputremapper.gui.autocompletion import ( get_incomplete_parameter, get_incomplete_function_name, ) from inputremapper.injection.global_uinputs import GlobalUInputs, FrontendUInput, UInput from inputremapper.injection.mapping_handlers.mapping_parser import MappingParser from inputremapper.input_event import InputEvent from tests.integration.test_components import FlowBoxTestUtils from tests.lib.cleanup import cleanup from tests.lib.constants import EVENT_READ_TIMEOUT from tests.lib.fixtures import fixtures from tests.lib.fixtures import prepare_presets from tests.lib.logger import logger from tests.lib.pipes import push_event, push_events, uinput_write_history_pipe from tests.lib.spy import spy gi.require_version("Gdk", "3.0") gi.require_version("Gtk", "3.0") gi.require_version("GtkSource", "4") gi.require_version("GLib", "2.0") from gi.repository import Gtk, GLib, Gdk, GtkSource from inputremapper.configs.keyboard_layout import keyboard_layout from inputremapper.configs.mapping import Mapping from inputremapper.configs.paths import PathUtils from inputremapper.configs.global_config import GlobalConfig from inputremapper.groups import _Groups from inputremapper.gui.data_manager import DataManager from inputremapper.gui.messages.message_broker import ( MessageBroker, MessageType, ) from inputremapper.gui.messages.message_data import StatusData, CombinationRecorded from inputremapper.gui.components.editor import ( MappingSelectionLabel, SET_KEY_FIRST, CodeEditor, ) from inputremapper.gui.components.device_groups import DeviceGroupEntry from inputremapper.gui.controller import Controller from inputremapper.gui.reader_service import ReaderService from inputremapper.gui.utils import gtk_iteration, Colors, debounce, debounce_manager from inputremapper.gui.user_interface import UserInterface from inputremapper.injection.injector import InjectorState from inputremapper.configs.input_config import InputCombination, InputConfig from inputremapper.daemon import Daemon, DaemonProxy from inputremapper.bin.input_remapper_gtk import InputRemapperGtkBin from tests.lib.test_setup import test_setup # iterate a few times when Gtk.main() is called, but don't block # there and just continue to the tests while the UI becomes # unresponsive Gtk.main = gtk_iteration # doesn't do much except avoid some Gtk assertion error, whatever: Gtk.main_quit = lambda: None def launch() -> Tuple[ UserInterface, Controller, DataManager, MessageBroker, DaemonProxy, GlobalConfig, ]: """Start input-remapper-gtk.""" return_ = InputRemapperGtkBin.main() gtk_iteration() # otherwise a new handler is added with each call to launch, which # spams tons of garbage when all tests finish atexit.unregister(InputRemapperGtkBin.stop) return return_ def start_reader_service(): def process(): global_uinputs = GlobalUInputs(FrontendUInput) reader_service = ReaderService(_Groups(), global_uinputs) loop = asyncio.new_event_loop() loop.run_until_complete(reader_service.run()) multiprocessing.Process(target=process).start() def os_system_patch(cmd, original_os_system=os.system): # instead of running pkexec, fork instead. This will make # the reader-service aware of all the test patches if "pkexec input-remapper-control --command start-reader-service" in cmd: logger.info("pkexec-patch starting ReaderService process") start_reader_service() return 0 return original_os_system(cmd) @contextmanager def patch_services(): """Don't connect to the dbus and don't use pkexec to start the reader-service""" def bootstrap_daemon(): # The daemon gets fresh instances of everything, because as far as I remember # it runs in a separate process. global_config = GlobalConfig() global_uinputs = GlobalUInputs(UInput) mapping_parser = MappingParser(global_uinputs) return Daemon( global_config, global_uinputs, mapping_parser, ) with patch.object( os, "system", os_system_patch, ), patch.object(Daemon, "connect", bootstrap_daemon): yield def clean_up_integration(test): logger.info("clean_up_integration") test.controller.stop_injecting() gtk_iteration() test.user_interface.on_gtk_close() test.user_interface.window.destroy() gtk_iteration() cleanup() # do this now, not when all tests are finished test.daemon.stop_all() if isinstance(test.daemon, Daemon): atexit.unregister(test.daemon.stop_all) class GtkKeyEvent: def __init__(self, keyval): self.keyval = keyval def get_keyval(self): return True, self.keyval @test_setup class TestGroupsFromReaderService(unittest.TestCase): def patch_os_system(self): def os_system(cmd, original_os_system=os.system): # instead of running pkexec, fork instead. This will make # the reader-service aware of all the test patches if "pkexec input-remapper-control --command start-reader-service" in cmd: # don't start the reader-service just log that it was. self.reader_service_started() return 0 return original_os_system(cmd) self.os_system_patch = patch.object( os, "system", os_system, ) # this is already part of the test. we need a bit of patching and hacking # because we want to discover the groups as early a possible, to reduce startup # time for the application self.os_system_patch.start() def bootstrap_daemon(self): # The daemon gets fresh instances of everything, because as far as I remember # it runs in a separate process. global_config = GlobalConfig() global_uinputs = GlobalUInputs(UInput) mapping_parser = MappingParser(global_uinputs) return Daemon( global_config, global_uinputs, mapping_parser, ) def patch_daemon(self): # don't try to connect, return an object instance of it instead self.daemon_connect_patch = patch.object( Daemon, "connect", lambda: self.bootstrap_daemon(), ) self.daemon_connect_patch.start() def setUp(self): self.reader_service_started = MagicMock() self.patch_os_system() self.patch_daemon() ( self.user_interface, self.controller, self.data_manager, self.message_broker, self.daemon, self.global_config, ) = launch() def tearDown(self): clean_up_integration(self) self.os_system_patch.stop() self.daemon_connect_patch.stop() def test_knows_devices(self): # verify that it is working as expected. The gui doesn't have knowledge # of groups until the root-reader-service provides them self.data_manager._reader_client.groups.set_groups([]) gtk_iteration() self.reader_service_started.assert_called() self.assertEqual(len(self.data_manager.get_group_keys()), 0) # start the reader-service delayed start_reader_service() # perform some iterations so that the reader ends up reading from the pipes # which will make it receive devices. for _ in range(10): time.sleep(0.02) gtk_iteration() self.assertIn("Foo Device 2", self.data_manager.get_group_keys()) self.assertIn("Foo Device 2", self.data_manager.get_group_keys()) self.assertIn("Bar Device", self.data_manager.get_group_keys()) self.assertIn("gamepad", self.data_manager.get_group_keys()) self.assertEqual(self.data_manager.active_group.name, "Foo Device") @contextmanager def patch_confirm_delete( user_interface: UserInterface, response=Gtk.ResponseType.ACCEPT, ): original_create_dialog = user_interface._create_dialog def _create_dialog_patch(*args, **kwargs): """A patch for the deletion confirmation that briefly shows the dialog.""" confirm_cancel_dialog = original_create_dialog(*args, **kwargs) # the emitted signal causes the dialog to close GLib.timeout_add( 100, lambda: confirm_cancel_dialog.emit("response", response), ) # don't recursively call the patch Gtk.MessageDialog.run(confirm_cancel_dialog) confirm_cancel_dialog.run = lambda: response return confirm_cancel_dialog with patch.object( user_interface, "_create_dialog", _create_dialog_patch, ): # Tests are run during `yield` yield class GuiTestBase(unittest.TestCase): def setUp(self): prepare_presets() with patch_services(): ( self.user_interface, self.controller, self.data_manager, self.message_broker, self.daemon, self.global_config, ) = launch() get = self.user_interface.get self.device_selection: Gtk.FlowBox = get("device_selection") self.preset_selection: Gtk.ComboBoxText = get("preset_selection") self.selection_label_listbox: Gtk.ListBox = get("selection_label_listbox") self.target_selection: Gtk.ComboBox = get("target-selector") self.recording_toggle: Gtk.ToggleButton = get("key_recording_toggle") self.recording_status: Gtk.ToggleButton = get("recording_status") self.status_bar: Gtk.Statusbar = get("status_bar") self.autoload_toggle: Gtk.Switch = get("preset_autoload_switch") self.code_editor: GtkSource.View = get("code_editor") self.output_box: GtkSource.View = get("output") self.delete_preset_btn: Gtk.Button = get("delete_preset") self.copy_preset_btn: Gtk.Button = get("copy_preset") self.create_preset_btn: Gtk.Button = get("create_preset") self.start_injector_btn: Gtk.Button = get("apply_preset") self.stop_injector_btn: Gtk.Button = get("stop_injection_preset_page") self.rename_btn: Gtk.Button = get("rename-button") self.rename_input: Gtk.Entry = get("preset_name_input") self.create_mapping_btn: Gtk.Button = get("create_mapping_button") self.delete_mapping_btn: Gtk.Button = get("delete-mapping") self._test_initial_state() self.grab_fails = False def grab(_): if self.grab_fails: raise OSError() evdev.InputDevice.grab = grab self.global_config._save_config() self.throttle(20) self.assertIsNotNone(self.data_manager.active_group) self.assertIsNotNone(self.data_manager.active_preset) def tearDown(self): clean_up_integration(self) # this is important, otherwise it keeps breaking things in the background self.assertIsNone(self.data_manager._reader_client._read_timeout) self.throttle(20) def get_code_input(self): buffer = self.code_editor.get_buffer() return buffer.get_text( buffer.get_start_iter(), buffer.get_end_iter(), True, ) def _test_initial_state(self): # make sure each test deals with the same initial state self.assertEqual(self.controller.data_manager, self.data_manager) self.assertEqual(self.data_manager.active_group.key, "Foo Device") # if the modification-date from `prepare_presets` is not destroyed, preset3 # should be selected as the newest one self.assertEqual(self.data_manager.active_preset.name, "preset3") self.assertEqual(self.data_manager.active_mapping.target_uinput, "keyboard") self.assertEqual(self.target_selection.get_active_id(), "keyboard") self.assertEqual( self.data_manager.active_mapping.input_combination, InputCombination([InputConfig(type=1, code=5)]), ) self.assertEqual( self.data_manager.active_input_config, InputConfig(type=1, code=5) ) self.assertGreater( len(self.user_interface.autocompletion._target_key_capabilities), 0 ) def _callTestMethod(self, method): """Retry all tests if they fail. GUI tests suddenly started to lag a lot and fail randomly, and even though that improved drastically, sometimes they still do. """ attempts = 0 while True: attempts += 1 try: method() break except Exception as e: if attempts == 2: raise e # try again print("Test failed, trying again...") self.tearDown() self.setUp() def throttle(self, time_=10): """Give GTK some time in ms to process everything.""" # tests suddenly started to freeze my computer up completely and tests started # to fail. By using this (and by optimizing some redundant calls in the gui) it # worked again. EDIT: Might have been caused by my broken/bloated ssd. I'll # keep it in some places, since it did make the tests more reliable after all. for _ in range(time_ // 2): gtk_iteration() time.sleep(0.002) def set_focus(self, widget): logger.info("Focusing %s", widget) self.user_interface.window.set_focus(widget) self.throttle(20) def focus_source_view(self): # despite the focus and gtk_iterations, gtk never runs the event handlers for # the focus-in-event (_update_placeholder), which would clear the placeholder # text. Remove it manually, it can't be helped. Fun fact: when the # window gets destroyed, gtk runs the handler 10 times for good measure. # Lost one hour of my life on GTK again. It's gone! Forever! Use qt next time. source_view = self.code_editor self.set_focus(source_view) self.code_editor.get_buffer().set_text("") return source_view def get_selection_labels(self) -> List[MappingSelectionLabel]: return self.selection_label_listbox.get_children() def get_status_text(self): status_bar = self.user_interface.get("status_bar") return status_bar.get_message_area().get_children()[0].get_label() def get_unfiltered_symbol_input_text(self): buffer = self.code_editor.get_buffer() return buffer.get_text(buffer.get_start_iter(), buffer.get_end_iter(), True) def select_mapping(self, i: int): """Select one of the mappings of a preset. Parameters ---------- i if -1, will select the last row, 0 will select the uppermost row. 1 will select the second row, and so on """ selection_label = self.get_selection_labels()[i] self.selection_label_listbox.select_row(selection_label) logger.info( 'Selecting mapping %s "%s"', selection_label.combination, selection_label.name, ) gtk_iteration() return selection_label def add_mapping(self, mapping: Optional[Mapping] = None): self.controller.create_mapping() self.controller.load_mapping(InputCombination.empty_combination()) gtk_iteration() if mapping: self.controller.update_mapping(**mapping.dict(exclude_defaults=True)) gtk_iteration() def sleep(self, num_events): for _ in range(num_events * 2): time.sleep(EVENT_READ_TIMEOUT) gtk_iteration() time.sleep(1 / 30) # one window iteration gtk_iteration() @test_setup class TestColors(GuiTestBase): # requires a running ui, otherwise fails with segmentation faults def test_get_color_falls_back(self): fallback = Gdk.RGBA(0, 0.5, 1, 0.8) color = Colors.get_color(["doesnt_exist_1234"], fallback) self.assertIsInstance(color, Gdk.RGBA) self.assertAlmostEqual(color.red, fallback.red, delta=0.01) self.assertAlmostEqual(color.green, fallback.green, delta=0.01) self.assertAlmostEqual(color.blue, fallback.blue, delta=0.01) self.assertAlmostEqual(color.alpha, fallback.alpha, delta=0.01) def test_get_color_works(self): fallback = Gdk.RGBA(1, 0, 1, 0.1) color = Colors.get_color( ["accent_bg_color", "theme_selected_bg_color"], fallback ) self.assertIsInstance(color, Gdk.RGBA) self.assertNotAlmostEqual(color.red, fallback.red, delta=0.01) self.assertNotAlmostEqual(color.green, fallback.blue, delta=0.01) self.assertNotAlmostEqual(color.blue, fallback.green, delta=0.01) self.assertNotAlmostEqual(color.alpha, fallback.alpha, delta=0.01) def _test_color_wont_fallback(self, get_color, fallback): color = get_color() self.assertIsInstance(color, Gdk.RGBA) if ( (abs(color.green - fallback.green) < 0.01) and (abs(color.red - fallback.red) < 0.01) and (abs(color.blue - fallback.blue) < 0.01) and (abs(color.alpha - fallback.alpha) < 0.01) ): raise AssertionError( f"Color {color.to_string()} is similar to {fallback.toString()}" ) def test_get_colors(self): self._test_color_wont_fallback(Colors.get_accent_color, Colors.fallback_accent) self._test_color_wont_fallback(Colors.get_border_color, Colors.fallback_border) self._test_color_wont_fallback( Colors.get_background_color, Colors.fallback_background ) self._test_color_wont_fallback(Colors.get_base_color, Colors.fallback_base) self._test_color_wont_fallback(Colors.get_font_color, Colors.fallback_font) @test_setup class TestGui(GuiTestBase): """For tests that use the window. It is intentional that there is no access to the Components. Try to modify the configuration only by calling functions of the window. For example by simulating clicks on buttons. Get the widget to interact with by going through the windows children. (See click_on_group for inspiration) """ def click_on_group(self, group_key: str): for child in self.device_selection.get_children(): device_group_entry = child.get_children()[0] if device_group_entry.group_key == group_key: device_group_entry.set_active(True) def test_can_start(self): self.assertIsNotNone(self.user_interface) self.assertTrue(self.user_interface.window.get_visible()) def assert_gui_clean(self): selection_labels = self.selection_label_listbox.get_children() self.assertEqual(len(selection_labels), 0) self.assertEqual(len(self.data_manager.active_preset), 0) self.assertEqual( FlowBoxTestUtils.get_active_entry(self.preset_selection).name, "new preset" ) self.assertEqual(self.recording_toggle.get_label(), "Record") self.assertEqual(self.get_unfiltered_symbol_input_text(), SET_KEY_FIRST) def test_initial_state(self): self.assertEqual(self.data_manager.active_group.key, "Foo Device") self.assertEqual( FlowBoxTestUtils.get_active_entry(self.device_selection).name, "Foo Device" ) self.assertEqual(self.data_manager.active_preset.name, "preset3") self.assertEqual( FlowBoxTestUtils.get_active_entry(self.preset_selection).name, "preset3" ) self.assertFalse(self.data_manager.get_autoload()) self.assertFalse(self.autoload_toggle.get_active()) self.assertEqual( self.selection_label_listbox.get_selected_row().combination, InputCombination([InputConfig(type=1, code=5)]), ) self.assertEqual( self.data_manager.active_mapping.input_combination, InputCombination( [ InputConfig(type=1, code=5), ] ), ) self.assertEqual(self.selection_label_listbox.get_selected_row().name, "4") self.assertIsNone(self.data_manager.active_mapping.name) self.assertTrue(self.data_manager.active_mapping.is_valid()) self.assertTrue(self.data_manager.active_preset.is_valid()) # todo def test_set_autoload_refreshes_service_config(self): self.assertFalse(self.data_manager.get_autoload()) with spy(self.daemon, "set_config_dir") as set_config_dir: self.autoload_toggle.set_active(True) gtk_iteration() set_config_dir.assert_called_once() self.assertTrue(self.data_manager.get_autoload()) def test_autoload_sets_correctly(self): self.assertFalse(self.data_manager.get_autoload()) self.assertFalse(self.autoload_toggle.get_active()) self.autoload_toggle.set_active(True) gtk_iteration() self.assertTrue(self.data_manager.get_autoload()) self.assertTrue(self.autoload_toggle.get_active()) self.autoload_toggle.set_active(False) gtk_iteration() self.assertFalse(self.data_manager.get_autoload()) self.assertFalse(self.autoload_toggle.get_active()) def test_autoload_is_set_when_changing_preset(self): self.assertFalse(self.data_manager.get_autoload()) self.assertFalse(self.autoload_toggle.get_active()) self.click_on_group("Foo Device 2") FlowBoxTestUtils.set_active(self.preset_selection, "preset2") gtk_iteration() self.assertTrue(self.data_manager.get_autoload()) self.assertTrue(self.autoload_toggle.get_active()) def test_only_one_autoload_per_group(self): self.assertFalse(self.data_manager.get_autoload()) self.assertFalse(self.autoload_toggle.get_active()) self.click_on_group("Foo Device 2") FlowBoxTestUtils.set_active(self.preset_selection, "preset2") gtk_iteration() self.assertTrue(self.data_manager.get_autoload()) self.assertTrue(self.autoload_toggle.get_active()) FlowBoxTestUtils.set_active(self.preset_selection, "preset3") gtk_iteration() self.autoload_toggle.set_active(True) gtk_iteration() FlowBoxTestUtils.set_active(self.preset_selection, "preset2") gtk_iteration() self.assertFalse(self.data_manager.get_autoload()) self.assertFalse(self.autoload_toggle.get_active()) def test_each_device_can_have_autoload(self): self.autoload_toggle.set_active(True) gtk_iteration() self.assertTrue(self.data_manager.get_autoload()) self.assertTrue(self.autoload_toggle.get_active()) self.click_on_group("Foo Device 2") gtk_iteration() self.autoload_toggle.set_active(True) gtk_iteration() self.assertTrue(self.data_manager.get_autoload()) self.assertTrue(self.autoload_toggle.get_active()) self.click_on_group("Foo Device") gtk_iteration() self.assertTrue(self.data_manager.get_autoload()) self.assertTrue(self.autoload_toggle.get_active()) def test_select_device_without_preset(self): # creates a new empty preset when no preset exists for the device self.click_on_group("Bar Device") self.assertEqual( FlowBoxTestUtils.get_active_entry(self.preset_selection).name, "new preset" ) self.assertEqual(len(self.data_manager.active_preset), 0) # it creates the file for that right away. It may have been possible # to write it such that it doesn't (its empty anyway), but it does, # so use that to test it in more detail. path = PathUtils.get_preset_path("Bar Device", "new preset") self.assertTrue(os.path.exists(path)) with open(path, "r") as file: self.assertEqual(file.read(), "") def test_recording_toggle_labels(self): self.assertFalse(self.recording_status.get_visible()) self.recording_toggle.set_active(True) gtk_iteration() self.assertTrue(self.recording_status.get_visible()) self.recording_toggle.set_active(False) gtk_iteration() self.assertFalse(self.recording_status.get_visible()) def test_recording_label_updates_on_recording_finished(self): self.assertFalse(self.recording_status.get_visible()) self.recording_toggle.set_active(True) gtk_iteration() self.assertTrue(self.recording_status.get_visible()) self.message_broker.signal(MessageType.recording_finished) gtk_iteration() self.assertFalse(self.recording_status.get_visible()) self.assertFalse(self.recording_toggle.get_active()) def test_events_from_reader_service_arrive(self): # load a device with more capabilities self.controller.load_group("Foo Device 2") gtk_iteration() mock1 = MagicMock() mock2 = MagicMock() mock3 = MagicMock() self.message_broker.subscribe(MessageType.combination_recorded, mock1) self.message_broker.subscribe(MessageType.recording_finished, mock2) self.message_broker.subscribe(MessageType.recording_started, mock3) self.recording_toggle.set_active(True) mock3.assert_called_once() gtk_iteration() push_events( fixtures.foo_device_2_keyboard, [ InputEvent(0, 0, 1, 30, 1), InputEvent(0, 0, 1, 31, 1), ], ) self.throttle(60) origin = fixtures.foo_device_2_keyboard.get_device_hash() mock1.assert_has_calls( ( call( CombinationRecorded( InputCombination( [InputConfig(type=1, code=30, origin_hash=origin)] ) ) ), call( CombinationRecorded( InputCombination( [ InputConfig(type=1, code=30, origin_hash=origin), InputConfig(type=1, code=31, origin_hash=origin), ] ) ) ), ), any_order=False, ) self.assertEqual(mock1.call_count, 2) mock2.assert_not_called() push_events(fixtures.foo_device_2_keyboard, [InputEvent(0, 0, 1, 31, 0)]) self.throttle(60) self.assertEqual(mock1.call_count, 2) mock2.assert_not_called() push_events(fixtures.foo_device_2_keyboard, [InputEvent(0, 0, 1, 30, 0)]) self.throttle(60) self.assertEqual(mock1.call_count, 2) mock2.assert_called_once() self.assertFalse(self.recording_toggle.get_active()) mock3.assert_called_once() def test_cannot_create_duplicate_input_combination(self): # load a device with more capabilities self.controller.load_group("Foo Device 2") gtk_iteration() # update the combination of the active mapping self.controller.start_key_recording() push_events( fixtures.foo_device_2_keyboard, [InputEvent(0, 0, 1, 30, 1), InputEvent(0, 0, 1, 30, 0)], ) self.throttle(60) # if this fails with : this is the initial # mapping or something, so it was never overwritten. origin = fixtures.foo_device_2_keyboard.get_device_hash() self.assertEqual( self.data_manager.active_mapping.input_combination, InputCombination([InputConfig(type=1, code=30, origin_hash=origin)]), ) # create a new mapping self.controller.create_mapping() gtk_iteration() self.assertEqual( self.data_manager.active_mapping.input_combination, InputCombination.empty_combination(), ) # try to record the same combination self.controller.start_key_recording() push_events( fixtures.foo_device_2_keyboard, [InputEvent(0, 0, 1, 30, 1), InputEvent(0, 0, 1, 30, 0)], ) self.throttle(60) # should still be the empty mapping self.assertEqual( self.data_manager.active_mapping.input_combination, InputCombination.empty_combination(), ) # try to record a different combination self.controller.start_key_recording() push_events(fixtures.foo_device_2_keyboard, [InputEvent(0, 0, 1, 30, 1)]) self.throttle(60) # nothing changed yet, as we got the duplicate combination self.assertEqual( self.data_manager.active_mapping.input_combination, InputCombination.empty_combination(), ) push_events(fixtures.foo_device_2_keyboard, [InputEvent(0, 0, 1, 31, 1)]) self.throttle(60) # now the combination is different self.assertEqual( self.data_manager.active_mapping.input_combination, InputCombination( [ InputConfig(type=1, code=30, origin_hash=origin), InputConfig(type=1, code=31, origin_hash=origin), ] ), ) # let's make the combination even longer push_events(fixtures.foo_device_2_keyboard, [InputEvent(0, 0, 1, 32, 1)]) self.throttle(60) self.assertEqual( self.data_manager.active_mapping.input_combination, InputCombination( [ InputConfig(type=1, code=30, origin_hash=origin), InputConfig(type=1, code=31, origin_hash=origin), InputConfig(type=1, code=32, origin_hash=origin), ] ), ) # make sure we stop recording by releasing all keys push_events( fixtures.foo_device_2_keyboard, [ InputEvent(0, 0, 1, 31, 0), InputEvent(0, 0, 1, 30, 0), InputEvent(0, 0, 1, 32, 0), ], ) self.throttle(60) # sending a combination update now should not do anything self.message_broker.publish( CombinationRecorded(InputCombination([InputConfig(type=1, code=35)])) ) gtk_iteration() self.assertEqual( self.data_manager.active_mapping.input_combination, InputCombination( [ InputConfig(type=1, code=30, origin_hash=origin), InputConfig(type=1, code=31, origin_hash=origin), InputConfig(type=1, code=32, origin_hash=origin), ] ), ) def test_create_simple_mapping(self): self.click_on_group("Foo Device 2") # 1. create a mapping self.create_mapping_btn.clicked() gtk_iteration() self.assertEqual( self.selection_label_listbox.get_selected_row().combination, InputCombination.empty_combination(), ) self.assertEqual( self.data_manager.active_mapping.input_combination, InputCombination.empty_combination(), ) self.assertEqual( self.selection_label_listbox.get_selected_row().name, "Empty Mapping" ) self.assertIsNone(self.data_manager.active_mapping.name) # there are now 2 mappings self.assertEqual(len(self.selection_label_listbox.get_children()), 2) self.assertEqual(len(self.data_manager.active_preset), 2) # 2. record a combination for that mapping self.recording_toggle.set_active(True) gtk_iteration() push_events(fixtures.foo_device_2_keyboard, [InputEvent(0, 0, 1, 30, 1)]) self.throttle(60) push_events(fixtures.foo_device_2_keyboard, [InputEvent(0, 0, 1, 30, 0)]) self.throttle(60) # check the input_combination origin = fixtures.foo_device_2_keyboard.get_device_hash() self.assertEqual( self.selection_label_listbox.get_selected_row().combination, InputCombination([InputConfig(type=1, code=30, origin_hash=origin)]), ) self.assertEqual( self.data_manager.active_mapping.input_combination, InputCombination([InputConfig(type=1, code=30, origin_hash=origin)]), ) self.assertEqual(self.selection_label_listbox.get_selected_row().name, "a") self.assertIsNone(self.data_manager.active_mapping.name) # 3. set the output symbol self.code_editor.get_buffer().set_text("Shift_L") gtk_iteration() # the mapping and preset should be valid by now self.assertTrue(self.data_manager.active_mapping.is_valid()) self.assertTrue(self.data_manager.active_preset.is_valid()) self.assertEqual( self.data_manager.active_mapping, Mapping( input_combination=InputCombination( [InputConfig(type=1, code=30, origin_hash=origin)] ), output_symbol="Shift_L", target_uinput="keyboard", ), ) self.assertEqual(self.target_selection.get_active_id(), "keyboard") buffer = self.code_editor.get_buffer() self.assertEqual( buffer.get_text(buffer.get_start_iter(), buffer.get_end_iter(), True), "Shift_L", ) self.assertEqual( self.selection_label_listbox.get_selected_row().combination, InputCombination([InputConfig(type=1, code=30, origin_hash=origin)]), ) # 4. update target self.target_selection.set_active_id("keyboard + mouse") gtk_iteration() self.assertEqual( self.data_manager.active_mapping, Mapping( input_combination=InputCombination( [InputConfig(type=1, code=30, origin_hash=origin)] ), output_symbol="Shift_L", target_uinput="keyboard + mouse", ), ) def test_show_status(self): self.message_broker.publish(StatusData(0, "a")) text = self.get_status_text() self.assertEqual("a", text) def test_hat_switch(self): # load a device with more capabilities self.controller.load_group("Foo Device 2") gtk_iteration() # it should be possible to add all of them ev_1 = InputEvent.abs(evdev.ecodes.ABS_HAT0X, -1) ev_2 = InputEvent.abs(evdev.ecodes.ABS_HAT0X, 1) ev_3 = InputEvent.abs(evdev.ecodes.ABS_HAT0Y, -1) ev_4 = InputEvent.abs(evdev.ecodes.ABS_HAT0Y, 1) def add_mapping(event, symbol) -> InputCombination: """adds mapping and returns the expected input combination""" self.controller.create_mapping() gtk_iteration() self.controller.start_key_recording() push_events(fixtures.foo_device_2_gamepad, [event, event.modify(value=0)]) self.throttle(60) gtk_iteration() self.code_editor.get_buffer().set_text(symbol) gtk_iteration() return InputCombination( [ InputConfig.from_input_event(event).modify( origin_hash=fixtures.foo_device_2_gamepad.get_device_hash() ) ] ) config_1 = add_mapping(ev_1, "a") config_2 = add_mapping(ev_2, "b") config_3 = add_mapping(ev_3, "c") config_4 = add_mapping(ev_4, "d") self.assertEqual( self.data_manager.active_preset.get_mapping( InputCombination(config_1) ).output_symbol, "a", ) self.assertEqual( self.data_manager.active_preset.get_mapping( InputCombination(config_2) ).output_symbol, "b", ) self.assertEqual( self.data_manager.active_preset.get_mapping( InputCombination(config_3) ).output_symbol, "c", ) self.assertEqual( self.data_manager.active_preset.get_mapping( InputCombination(config_4) ).output_symbol, "d", ) def test_combination(self): # if this test freezes, try waiting a few minutes and then look for # stack traces in the console # load a device with more capabilities self.controller.load_group("Foo Device 2") gtk_iteration() # it should be possible to write a combination ev_1 = InputEvent.key( evdev.ecodes.KEY_A, 1, origin_hash=fixtures.foo_device_2_keyboard.get_device_hash(), ) ev_2 = InputEvent.abs( evdev.ecodes.ABS_HAT0X, 1, origin_hash=fixtures.foo_device_2_gamepad.get_device_hash(), ) ev_3 = InputEvent.key( evdev.ecodes.KEY_C, 1, origin_hash=fixtures.foo_device_2_keyboard.get_device_hash(), ) ev_4 = InputEvent.abs( evdev.ecodes.ABS_HAT0X, -1, origin_hash=fixtures.foo_device_2_gamepad.get_device_hash(), ) combination_1 = (ev_1, ev_2, ev_3) combination_2 = (ev_2, ev_1, ev_3) # same as 1, but different D-Pad direction combination_3 = (ev_1, ev_4, ev_3) combination_4 = (ev_4, ev_1, ev_3) # same as 1, but the last combination is different combination_5 = (ev_1, ev_3, ev_2) combination_6 = (ev_3, ev_1, ev_2) def get_combination(combi: Iterable[InputEvent]) -> InputCombination: """Create an InputCombination from a list of events. Ensures the origin_hash is set correctly. """ configs = [] for event in combi: config = InputConfig.from_input_event(event) configs.append(config) return InputCombination(configs) def add_mapping(combi: Iterable[InputEvent], symbol): logger.info("add_mapping %s", combi) self.controller.create_mapping() gtk_iteration() self.controller.start_key_recording() for event in combi: if event.type == EV_KEY: push_event(fixtures.foo_device_2_keyboard, event) if event.type == EV_ABS: push_event(fixtures.foo_device_2_gamepad, event) if event.type == EV_REL: push_event(fixtures.foo_device_2_mouse, event) # avoid race condition if we switch fixture in push_event. The order # of events needs to be correct. self.throttle(20) for event in combi: if event.type == EV_KEY: push_event(fixtures.foo_device_2_keyboard, event.modify(value=0)) if event.type == EV_ABS: push_event(fixtures.foo_device_2_gamepad, event.modify(value=0)) if event.type == EV_REL: pass self.throttle(60) gtk_iteration() self.code_editor.get_buffer().set_text(symbol) gtk_iteration() add_mapping(combination_1, "a") self.assertEqual( self.data_manager.active_preset.get_mapping( get_combination(combination_1) ).output_symbol, "a", ) self.assertEqual( self.data_manager.active_preset.get_mapping( get_combination(combination_2) ).output_symbol, "a", ) self.assertIsNone( self.data_manager.active_preset.get_mapping(get_combination(combination_3)) ) self.assertIsNone( self.data_manager.active_preset.get_mapping(get_combination(combination_4)) ) self.assertIsNone( self.data_manager.active_preset.get_mapping(get_combination(combination_5)) ) self.assertIsNone( self.data_manager.active_preset.get_mapping(get_combination(combination_6)) ) # it won't write the same combination again, even if the # first two events are in a different order add_mapping(combination_2, "b") self.assertEqual( self.data_manager.active_preset.get_mapping( get_combination(combination_1) ).output_symbol, "a", ) self.assertEqual( self.data_manager.active_preset.get_mapping( get_combination(combination_2) ).output_symbol, "a", ) self.assertIsNone( self.data_manager.active_preset.get_mapping(get_combination(combination_3)) ) self.assertIsNone( self.data_manager.active_preset.get_mapping(get_combination(combination_4)) ) self.assertIsNone( self.data_manager.active_preset.get_mapping(get_combination(combination_5)) ) self.assertIsNone( self.data_manager.active_preset.get_mapping(get_combination(combination_6)) ) add_mapping(combination_3, "c") self.assertEqual( self.data_manager.active_preset.get_mapping( get_combination(combination_1) ).output_symbol, "a", ) self.assertEqual( self.data_manager.active_preset.get_mapping( get_combination(combination_2) ).output_symbol, "a", ) self.assertEqual( self.data_manager.active_preset.get_mapping( get_combination(combination_3) ).output_symbol, "c", ) self.assertEqual( self.data_manager.active_preset.get_mapping( get_combination(combination_4) ).output_symbol, "c", ) self.assertIsNone( self.data_manager.active_preset.get_mapping(get_combination(combination_5)) ) self.assertIsNone( self.data_manager.active_preset.get_mapping(get_combination(combination_6)) ) # same as with combination_2, the existing combination_3 blocks # combination_4 because they have the same keys and end in the # same key. add_mapping(combination_4, "d") self.assertEqual( self.data_manager.active_preset.get_mapping( get_combination(combination_1) ).output_symbol, "a", ) self.assertEqual( self.data_manager.active_preset.get_mapping( get_combination(combination_2) ).output_symbol, "a", ) self.assertEqual( self.data_manager.active_preset.get_mapping( get_combination(combination_3) ).output_symbol, "c", ) self.assertEqual( self.data_manager.active_preset.get_mapping( get_combination(combination_4) ).output_symbol, "c", ) self.assertIsNone( self.data_manager.active_preset.get_mapping(get_combination(combination_5)) ) self.assertIsNone( self.data_manager.active_preset.get_mapping(get_combination(combination_6)) ) add_mapping(combination_5, "e") self.assertEqual( self.data_manager.active_preset.get_mapping( get_combination(combination_1) ).output_symbol, "a", ) self.assertEqual( self.data_manager.active_preset.get_mapping( get_combination(combination_2) ).output_symbol, "a", ) self.assertEqual( self.data_manager.active_preset.get_mapping( get_combination(combination_3) ).output_symbol, "c", ) self.assertEqual( self.data_manager.active_preset.get_mapping( get_combination(combination_4) ).output_symbol, "c", ) self.assertEqual( self.data_manager.active_preset.get_mapping( get_combination(combination_5) ).output_symbol, "e", ) self.assertEqual( self.data_manager.active_preset.get_mapping( get_combination(combination_6) ).output_symbol, "e", ) error_icon = self.user_interface.get("error_status_icon") warning_icon = self.user_interface.get("warning_status_icon") self.assertFalse(error_icon.get_visible()) self.assertFalse(warning_icon.get_visible()) def test_only_one_empty_mapping_possible(self): self.assertEqual( self.selection_label_listbox.get_selected_row().combination, InputCombination([InputConfig(type=1, code=5)]), ) self.assertEqual(len(self.selection_label_listbox.get_children()), 1) self.assertEqual(len(self.data_manager.active_preset), 1) self.create_mapping_btn.clicked() gtk_iteration() self.assertEqual( self.selection_label_listbox.get_selected_row().combination, InputCombination.empty_combination(), ) self.assertEqual(len(self.selection_label_listbox.get_children()), 2) self.assertEqual(len(self.data_manager.active_preset), 2) self.create_mapping_btn.clicked() gtk_iteration() self.assertEqual(len(self.selection_label_listbox.get_children()), 2) self.assertEqual(len(self.data_manager.active_preset), 2) def test_selection_labels_sort_alphabetically(self): self.controller.load_preset("preset1") # contains two mappings (1,1,1 -> b) and (1,2,1 -> a) gtk_iteration() # we expect (1,2,1 -> a) to be selected because "1" < "Escape" self.assertEqual(self.data_manager.active_mapping.output_symbol, "a") self.assertIs( self.selection_label_listbox.get_row_at_index(0), self.selection_label_listbox.get_selected_row(), ) self.recording_toggle.set_active(True) gtk_iteration() self.message_broker.publish( CombinationRecorded( InputCombination([InputConfig(type=EV_KEY, code=KEY_Q)]) ) ) gtk_iteration() self.message_broker.signal(MessageType.recording_finished) gtk_iteration() # the combination and the order changed "Escape" < "q" self.assertEqual(self.data_manager.active_mapping.output_symbol, "a") self.assertIs( self.selection_label_listbox.get_row_at_index(1), self.selection_label_listbox.get_selected_row(), ) def test_selection_labels_sort_empty_mapping_to_the_bottom(self): # make sure we have a mapping which would sort to the bottom only # considering alphanumeric sorting: "q" > "Empty Mapping" self.controller.load_preset("preset1") gtk_iteration() self.recording_toggle.set_active(True) gtk_iteration() self.message_broker.publish( CombinationRecorded( InputCombination([InputConfig(type=EV_KEY, code=KEY_Q)]) ) ) gtk_iteration() self.message_broker.signal(MessageType.recording_finished) gtk_iteration() self.controller.create_mapping() gtk_iteration() row: MappingSelectionLabel = self.selection_label_listbox.get_selected_row() self.assertEqual(row.combination, InputCombination.empty_combination()) self.assertEqual(row.label.get_text(), "Empty Mapping") self.assertIs(self.selection_label_listbox.get_row_at_index(2), row) def test_select_mapping(self): self.controller.load_preset("preset1") # contains two mappings (1,1,1 -> b) and (1,2,1 -> a) gtk_iteration() # we expect (1,2,1 -> a) to be selected because "1" < "Escape" self.assertEqual(self.data_manager.active_mapping.output_symbol, "a") # select the second entry in the listbox row = self.selection_label_listbox.get_row_at_index(1) self.selection_label_listbox.select_row(row) gtk_iteration() self.assertEqual(self.data_manager.active_mapping.output_symbol, "b") def test_selection_label_uses_name_if_available(self): self.controller.load_preset("preset1") gtk_iteration() row: MappingSelectionLabel = self.selection_label_listbox.get_selected_row() self.assertEqual(row.label.get_text(), "1") self.assertIs(row, self.selection_label_listbox.get_row_at_index(0)) self.controller.update_mapping(name="foo") gtk_iteration() self.assertEqual(row.label.get_text(), "foo") self.assertIs(row, self.selection_label_listbox.get_row_at_index(1)) # Empty Mapping still sorts to the bottom self.controller.create_mapping() gtk_iteration() row = self.selection_label_listbox.get_selected_row() self.assertEqual(row.combination, InputCombination.empty_combination()) self.assertEqual(row.label.get_text(), "Empty Mapping") self.assertIs(self.selection_label_listbox.get_row_at_index(2), row) def test_fake_empty_mapping_does_not_sort_to_bottom(self): """If someone chooses to name a mapping "Empty Mapping" it is not sorted to the bottom""" self.controller.load_preset("preset1") gtk_iteration() self.controller.update_mapping(name="Empty Mapping") self.throttle(20) # sorting seems to take a bit # "Empty Mapping" < "Escape" so we still expect this to be the first row row = self.selection_label_listbox.get_selected_row() self.assertIs(row, self.selection_label_listbox.get_row_at_index(0)) # now create a real empty mapping self.controller.create_mapping() self.throttle(20) # for some reason we no longer can use assertIs maybe a gtk bug? # self.assertIs(row, self.selection_label_listbox.get_row_at_index(0)) # we expect the fake empty mapping in row 0 and the real one in row 2 self.selection_label_listbox.select_row( self.selection_label_listbox.get_row_at_index(0) ) gtk_iteration() self.assertEqual(self.data_manager.active_mapping.name, "Empty Mapping") self.assertEqual(self.data_manager.active_mapping.output_symbol, "a") self.selection_label_listbox.select_row( self.selection_label_listbox.get_row_at_index(2) ) self.assertIsNone(self.data_manager.active_mapping.name) self.assertEqual( self.data_manager.active_mapping.input_combination, InputCombination.empty_combination(), ) def test_remove_mapping(self): self.controller.load_preset("preset1") gtk_iteration() self.assertEqual(len(self.data_manager.active_preset), 2) self.assertEqual(len(self.selection_label_listbox.get_children()), 2) with patch_confirm_delete(self.user_interface): self.delete_mapping_btn.clicked() gtk_iteration() self.assertEqual(len(self.data_manager.active_preset), 1) self.assertEqual(len(self.selection_label_listbox.get_children()), 1) def test_problematic_combination(self): # load a device with more capabilities self.controller.load_group("Foo Device 2") gtk_iteration() def add_mapping(combi: Iterable[Tuple[int, int, int]], symbol): combi = [InputEvent(0, 0, *t) for t in combi] self.controller.create_mapping() gtk_iteration() self.controller.start_key_recording() push_events(fixtures.foo_device_2_keyboard, combi) push_events( fixtures.foo_device_2_keyboard, [event.modify(value=0) for event in combi], ) self.throttle(60) gtk_iteration() self.code_editor.get_buffer().set_text(symbol) gtk_iteration() combination = [(EV_KEY, KEY_LEFTSHIFT, 1), (EV_KEY, 82, 1)] add_mapping(combination, "b") text = self.get_status_text() self.assertIn("shift", text) error_icon = self.user_interface.get("error_status_icon") warning_icon = self.user_interface.get("warning_status_icon") self.assertFalse(error_icon.get_visible()) self.assertTrue(warning_icon.get_visible()) def test_rename_and_save(self): # only a basic test, TestController and TestDataManager go more in detail self.rename_input.set_text("foo") self.rename_btn.clicked() gtk_iteration() preset_path = f"{PathUtils.config_path()}/presets/Foo Device/foo.json" self.assertTrue(os.path.exists(preset_path)) error_icon = self.user_interface.get("error_status_icon") self.assertFalse(error_icon.get_visible()) def save(): raise PermissionError with patch.object(self.data_manager.active_preset, "save", save): self.code_editor.get_buffer().set_text("f") gtk_iteration() status = self.get_status_text() self.assertIn("Permission denied", status) with patch_confirm_delete(self.user_interface): self.delete_preset_btn.clicked() gtk_iteration() self.assertFalse(os.path.exists(preset_path)) def test_check_for_unknown_symbols(self): first_input = InputCombination([InputConfig(type=1, code=1)]) second_input = InputCombination([InputConfig(type=1, code=2)]) status = self.user_interface.get("status_bar") error_icon = self.user_interface.get("error_status_icon") warning_icon = self.user_interface.get("warning_status_icon") self.controller.load_preset("preset1") self.throttle(20) # Switch to the first mapping, and change it self.controller.load_mapping(first_input) gtk_iteration() self.controller.update_mapping(output_symbol="foo") gtk_iteration() # Switch to the second mapping, and change it self.controller.load_mapping(second_input) gtk_iteration() self.controller.update_mapping(output_symbol="qux") gtk_iteration() # The tooltip should show the error of the currently selected mapping tooltip = status.get_tooltip_text().lower() self.assertIn("qux", tooltip) self.assertTrue(error_icon.get_visible()) self.assertFalse(warning_icon.get_visible()) # So switching to the other mapping changes the tooltip self.controller.load_mapping(first_input) gtk_iteration() tooltip = status.get_tooltip_text().lower() self.assertIn("foo", tooltip) # It will still save it though with open(PathUtils.get_preset_path("Foo Device", "preset1")) as f: content = f.read() self.assertIn("qux", content) self.assertIn("foo", content) # Fix the current active mapping. # It should show the error of the other mapping now. self.controller.update_mapping(output_symbol="a") gtk_iteration() tooltip = status.get_tooltip_text().lower() self.assertIn("qux", tooltip) self.assertTrue(error_icon.get_visible()) self.assertFalse(warning_icon.get_visible()) # Fix the other mapping as well. No tooltip should be shown afterward. self.controller.load_mapping(second_input) gtk_iteration() self.controller.update_mapping(output_symbol="b") gtk_iteration() tooltip = status.get_tooltip_text() self.assertIsNone(tooltip) self.assertFalse(error_icon.get_visible()) self.assertFalse(warning_icon.get_visible()) def test_no_validation_tooltip_for_empty_mappings(self): self.controller.load_preset("preset1") self.throttle(20) status = self.user_interface.get("status_bar") self.assertIsNone(status.get_tooltip_text()) self.controller.create_mapping() gtk_iteration() self.assertTrue(self.controller.is_empty_mapping()) self.assertIsNone(status.get_tooltip_text()) def test_check_macro_syntax(self): status = self.status_bar error_icon = self.user_interface.get("error_status_icon") warning_icon = self.user_interface.get("warning_status_icon") self.code_editor.get_buffer().set_text("k(1))") tooltip = status.get_tooltip_text().lower() self.assertIn("brackets", tooltip) self.assertTrue(error_icon.get_visible()) self.assertFalse(warning_icon.get_visible()) self.code_editor.get_buffer().set_text("k(1)") tooltip = (status.get_tooltip_text() or "").lower() self.assertNotIn("brackets", tooltip) self.assertFalse(error_icon.get_visible()) self.assertFalse(warning_icon.get_visible()) self.assertEqual( self.data_manager.active_mapping.output_symbol, "k(1)", ) def test_check_on_typing(self): status = self.user_interface.get("status_bar") error_icon = self.user_interface.get("error_status_icon") warning_icon = self.user_interface.get("warning_status_icon") tooltip = status.get_tooltip_text() # nothing wrong yet self.assertIsNone(tooltip) # now change the mapping by typing into the field buffer = self.code_editor.get_buffer() buffer.set_text("sdfgkj()") gtk_iteration() # the mapping is validated tooltip = status.get_tooltip_text() self.assertIn("Unknown function sdfgkj", tooltip) self.assertTrue(error_icon.get_visible()) self.assertFalse(warning_icon.get_visible()) self.assertEqual(self.data_manager.active_mapping.output_symbol, "sdfgkj()") def test_select_device(self): # simple test to make sure we can switch between devices # more detailed tests in TestController and TestDataManager self.click_on_group("Bar Device") gtk_iteration() entries = {*FlowBoxTestUtils.get_child_names(self.preset_selection)} self.assertEqual(entries, {"new preset"}) self.click_on_group("Foo Device") gtk_iteration() entries = {*FlowBoxTestUtils.get_child_names(self.preset_selection)} self.assertEqual(entries, {"preset1", "preset2", "preset3"}) # make sure a preset and mapping was loaded self.assertIsNotNone(self.data_manager.active_preset) self.assertEqual( self.data_manager.active_preset.name, FlowBoxTestUtils.get_active_entry(self.preset_selection).name, ) self.assertIsNotNone(self.data_manager.active_mapping) self.assertEqual( self.data_manager.active_mapping.input_combination, self.selection_label_listbox.get_selected_row().combination, ) def test_select_preset(self): # simple test to make sure we can switch between presets # more detailed tests in TestController and TestDataManager self.click_on_group("Foo Device 2") gtk_iteration() FlowBoxTestUtils.set_active(self.preset_selection, "preset1") gtk_iteration() mappings = { row.combination for row in self.selection_label_listbox.get_children() } self.assertEqual( mappings, { InputCombination([InputConfig(type=1, code=1)]), InputCombination([InputConfig(type=1, code=2)]), }, ) self.assertFalse(self.autoload_toggle.get_active()) FlowBoxTestUtils.set_active(self.preset_selection, "preset2") gtk_iteration() mappings = { row.combination for row in self.selection_label_listbox.get_children() } self.assertEqual( mappings, { InputCombination([InputConfig(type=1, code=3)]), InputCombination([InputConfig(type=1, code=4)]), }, ) self.assertTrue(self.autoload_toggle.get_active()) def test_copy_preset(self): # simple tests to ensure it works # more detailed tests in TestController and TestDataManager # check the initial state entries = {*FlowBoxTestUtils.get_child_names(self.preset_selection)} self.assertEqual(entries, {"preset1", "preset2", "preset3"}) self.assertEqual( FlowBoxTestUtils.get_active_entry(self.preset_selection).name, "preset3" ) self.copy_preset_btn.clicked() gtk_iteration() entries = {*FlowBoxTestUtils.get_child_names(self.preset_selection)} self.assertEqual(entries, {"preset1", "preset2", "preset3", "preset3 copy"}) self.assertEqual( FlowBoxTestUtils.get_active_entry(self.preset_selection).name, "preset3 copy", ) self.copy_preset_btn.clicked() gtk_iteration() entries = {*FlowBoxTestUtils.get_child_names(self.preset_selection)} self.assertEqual( entries, {"preset1", "preset2", "preset3", "preset3 copy", "preset3 copy 2"} ) def test_wont_start(self): def wait(): """Wait for the injector process to finish doing stuff.""" for _ in range(10): time.sleep(0.1) gtk_iteration() if "Starting" not in self.get_status_text(): return error_icon = self.user_interface.get("error_status_icon") self.controller.load_group("Bar Device") # empty self.start_injector_btn.clicked() gtk_iteration() wait() text = self.get_status_text() self.assertIn("add mappings", text) self.assertTrue(error_icon.get_visible()) self.assertNotEqual(self.daemon.get_state("Bar Device"), InjectorState.RUNNING) # device grabbing fails self.controller.load_group("Foo Device 2") gtk_iteration() for i in range(2): # just pressing apply again will overwrite the previous error self.grab_fails = True self.start_injector_btn.clicked() gtk_iteration() text = self.get_status_text() # it takes a little bit of time self.assertIn("Starting injection", text) self.assertFalse(error_icon.get_visible()) wait() text = self.get_status_text() self.assertIn("Failed to apply preset", text) self.assertTrue(error_icon.get_visible()) self.assertNotEqual( self.daemon.get_state("Foo Device 2"), InjectorState.RUNNING ) # this time work properly self.grab_fails = False self.start_injector_btn.clicked() gtk_iteration() text = self.get_status_text() self.assertIn("Starting injection", text) self.assertFalse(error_icon.get_visible()) wait() text = self.get_status_text() self.assertIn("Applied", text) text = self.get_status_text() self.assertNotIn("CTRL + DEL", text) # only shown if btn_left mapped self.assertFalse(error_icon.get_visible()) self.assertEqual(self.daemon.get_state("Foo Device 2"), InjectorState.RUNNING) def test_start_with_btn_left(self): self.controller.load_group("Foo Device 2") gtk_iteration() self.controller.create_mapping() gtk_iteration() self.controller.update_mapping( input_combination=InputCombination([InputConfig.btn_left()]), output_symbol="a", ) gtk_iteration() def wait(): """Wait for the injector process to finish doing stuff.""" for _ in range(10): time.sleep(0.1) gtk_iteration() if "Starting" not in self.get_status_text(): return # first apply, shows btn_left warning self.start_injector_btn.clicked() gtk_iteration() text = self.get_status_text() self.assertIn("click", text) self.assertEqual(self.daemon.get_state("Foo Device 2"), InjectorState.UNKNOWN) # second apply, overwrites self.start_injector_btn.clicked() gtk_iteration() wait() self.assertEqual(self.daemon.get_state("Foo Device 2"), InjectorState.RUNNING) text = self.get_status_text() # because btn_left is mapped, shows help on how to stop # injecting via the keyboard self.assertIn("CTRL + DEL", text) def test_cannot_record_keys(self): self.controller.load_group("Foo Device 2") self.assertNotEqual(self.data_manager.get_state(), InjectorState.RUNNING) self.assertNotIn("Stop", self.get_status_text()) self.recording_toggle.set_active(True) gtk_iteration() self.assertTrue(self.recording_toggle.get_active()) self.controller.stop_key_recording() gtk_iteration() self.assertFalse(self.recording_toggle.get_active()) self.start_injector_btn.clicked() gtk_iteration() # wait for the injector to start for _ in range(10): time.sleep(0.1) gtk_iteration() if "Starting" not in self.get_status_text(): break self.assertEqual(self.data_manager.get_state(), InjectorState.RUNNING) # the toggle button should reset itself shortly self.recording_toggle.set_active(True) gtk_iteration() self.assertFalse(self.recording_toggle.get_active()) text = self.get_status_text() self.assertIn("Stop", text) def test_start_injecting(self): self.controller.load_group("Foo Device 2") with spy(self.daemon, "set_config_dir") as spy1: with spy(self.daemon, "start_injecting") as spy2: self.start_injector_btn.clicked() gtk_iteration() # correctly uses group.key, not group.name spy2.assert_called_once_with("Foo Device 2", "preset3") spy1.assert_called_once_with(PathUtils.get_config_path()) for _ in range(10): time.sleep(0.1) gtk_iteration() if self.data_manager.get_state() == InjectorState.RUNNING: break # fail here so we don't block forever self.assertEqual(self.data_manager.get_state(), InjectorState.RUNNING) # this is a stupid workaround for the bad test fixtures # by switching the group we make sure that the reader-service no longer # listens for events on "Foo Device 2" otherwise we would have two processes # (reader-service and injector) reading the same pipe which can block this test # indefinitely self.controller.load_group("Foo Device") gtk_iteration() push_events( fixtures.foo_device_2_keyboard, [ InputEvent.key(5, 1), InputEvent.key(5, 0), ], ) event = uinput_write_history_pipe[0].recv() self.assertEqual(event.type, evdev.events.EV_KEY) self.assertEqual(event.code, KEY_A) self.assertEqual(event.value, 1) event = uinput_write_history_pipe[0].recv() self.assertEqual(event.type, evdev.events.EV_KEY) self.assertEqual(event.code, KEY_A) self.assertEqual(event.value, 0) # the input-remapper device will not be shown self.controller.refresh_groups() gtk_iteration() for child in self.device_selection.get_children(): device_group_entry = child.get_children()[0] self.assertNotIn("input-remapper", device_group_entry.name) def test_stop_injecting(self): self.controller.load_group("Foo Device 2") self.start_injector_btn.clicked() gtk_iteration() for _ in range(10): time.sleep(0.1) gtk_iteration() if self.data_manager.get_state() == InjectorState.RUNNING: break # fail here so we don't block forever self.assertEqual(self.data_manager.get_state(), InjectorState.RUNNING) # stupid fixture workaround self.controller.load_group("Foo Device") gtk_iteration() pipe = uinput_write_history_pipe[0] self.assertFalse(pipe.poll()) push_events( fixtures.foo_device_2_keyboard, [ InputEvent.key(5, 1), InputEvent.key(5, 0), ], ) time.sleep(0.2) self.assertTrue(pipe.poll()) while pipe.poll(): pipe.recv() self.controller.load_group("Foo Device 2") self.controller.stop_injecting() gtk_iteration() for _ in range(10): time.sleep(0.1) gtk_iteration() if self.data_manager.get_state() == InjectorState.STOPPED: break self.assertEqual(self.data_manager.get_state(), InjectorState.STOPPED) push_events( fixtures.foo_device_2_keyboard, [ InputEvent.key(5, 1), InputEvent.key(5, 0), ], ) time.sleep(0.2) self.assertFalse(pipe.poll()) def test_delete_preset(self): # as per test_initial_state we already have preset3 loaded self.assertTrue( os.path.exists(PathUtils.get_preset_path("Foo Device", "preset3")) ) with patch_confirm_delete(self.user_interface, Gtk.ResponseType.CANCEL): self.delete_preset_btn.clicked() gtk_iteration() self.assertTrue( os.path.exists(PathUtils.get_preset_path("Foo Device", "preset3")) ) self.assertEqual(self.data_manager.active_preset.name, "preset3") self.assertEqual(self.data_manager.active_group.name, "Foo Device") with patch_confirm_delete(self.user_interface): self.delete_preset_btn.clicked() gtk_iteration() self.assertFalse( os.path.exists(PathUtils.get_preset_path("Foo Device", "preset3")) ) self.assertEqual(self.data_manager.active_preset.name, "preset2") self.assertEqual(self.data_manager.active_group.name, "Foo Device") def test_refresh_groups(self): # sanity check: preset3 should be the newest self.assertEqual( FlowBoxTestUtils.get_active_entry(self.preset_selection).name, "preset3" ) # select the older one FlowBoxTestUtils.set_active(self.preset_selection, "preset1") gtk_iteration() self.assertEqual(self.data_manager.active_preset.name, "preset1") # add a device that doesn't exist to the dropdown unknown_key = "key-1234" self.device_selection.insert( DeviceGroupEntry(self.message_broker, self.controller, None, unknown_key), 0, # 0, [unknown_key, None, "foo"] ) self.controller.refresh_groups() gtk_iteration() self.throttle(200) # the gui should not jump to a different preset suddenly self.assertEqual(self.data_manager.active_preset.name, "preset1") # just to verify that the mtime still tells us that preset3 is the newest one self.assertEqual(self.controller.get_a_preset(), "preset3") # the list contains correct entries # and the non-existing entry should be removed names = FlowBoxTestUtils.get_child_names(self.device_selection) icons = FlowBoxTestUtils.get_child_icons(self.device_selection) self.assertNotIn(unknown_key, names) self.assertIn("Foo Device", names) self.assertIn("Foo Device 2", names) self.assertIn("Bar Device", names) self.assertIn("gamepad", names) self.assertIn("input-keyboard", icons) self.assertIn("input-gaming", icons) self.assertIn("input-keyboard", icons) self.assertIn("input-gaming", icons) # it won't crash due to "list index out of range" # when `types` is an empty list. Won't show an icon self.data_manager._reader_client.groups.find(key="Foo Device 2").types = [] self.data_manager._reader_client.publish_groups() gtk_iteration() self.assertIn( "Foo Device 2", FlowBoxTestUtils.get_child_names(self.device_selection), ) def test_shared_presets(self): # devices with the same name (but different key because the key is # unique) share the same presets. # Those devices would usually be of the same model of keyboard for example # Todo: move this to unit tests, there is no point in having the ui around self.controller.load_group("Foo Device") presets1 = self.data_manager.get_preset_names() self.controller.load_group("Foo Device 2") gtk_iteration() presets2 = self.data_manager.get_preset_names() self.controller.load_group("Bar Device") gtk_iteration() presets3 = self.data_manager.get_preset_names() self.assertEqual(presets1, presets2) self.assertNotEqual(presets1, presets3) def test_delete_last_preset(self): with patch_confirm_delete(self.user_interface): # as per test_initial_state we already have preset3 loaded self.assertEqual(self.data_manager.active_preset.name, "preset3") self.delete_preset_btn.clicked() gtk_iteration() # the next newest preset should be loaded self.assertEqual(self.data_manager.active_preset.name, "preset2") self.delete_preset_btn.clicked() gtk_iteration() self.delete_preset_btn.clicked() # the ui should be clean self.assert_gui_clean() device_path = f"{PathUtils.config_path()}/presets/{self.data_manager.active_group.name}" self.assertTrue(os.path.exists(f"{device_path}/new preset.json")) self.delete_preset_btn.clicked() gtk_iteration() # deleting an empty preset als doesn't do weird stuff self.assert_gui_clean() device_path = f"{PathUtils.config_path()}/presets/{self.data_manager.active_group.name}" self.assertTrue(os.path.exists(f"{device_path}/new preset.json")) def test_enable_disable_output(self): # load a group without any presets self.controller.load_group("Bar Device") # should be disabled by default since no key is recorded yet self.assertEqual(self.get_unfiltered_symbol_input_text(), SET_KEY_FIRST) self.assertFalse(self.output_box.get_sensitive()) # create a mapping self.controller.create_mapping() gtk_iteration() # should still be disabled self.assertEqual(self.get_unfiltered_symbol_input_text(), SET_KEY_FIRST) self.assertFalse(self.output_box.get_sensitive()) # enable it by sending a combination self.controller.start_key_recording() gtk_iteration() push_events( fixtures.bar_device, [ InputEvent(0, 0, 1, 30, 1), InputEvent(0, 0, 1, 30, 0), ], ) self.throttle(100) # give time for the input to arrive self.assertEqual( self.get_unfiltered_symbol_input_text(), CodeEditor.placeholder ) self.assertTrue(self.output_box.get_sensitive()) # disable it by deleting the mapping with patch_confirm_delete(self.user_interface): self.delete_mapping_btn.clicked() gtk_iteration() self.assertEqual(self.get_unfiltered_symbol_input_text(), SET_KEY_FIRST) self.assertFalse(self.output_box.get_sensitive()) @test_setup class TestAutocompletion(GuiTestBase): def press_key(self, keyval): event = Gdk.EventKey() event.keyval = keyval self.user_interface.autocompletion.navigate(None, event) def get_suggestions(self, autocompletion): return [ row.get_children()[0].get_text() for row in autocompletion.list_box.get_children() ] def test_get_incomplete_parameter(self): def test(text, expected): text_view = Gtk.TextView() Gtk.TextView.do_insert_at_cursor(text_view, text) text_iter = text_view.get_iter_at_location(0, 0)[1] text_iter.set_offset(len(text)) self.assertEqual(get_incomplete_parameter(text_iter), expected) test("bar(foo", "foo") test("bar(a=foo", "foo") test("bar(qux, foo", "foo") test("foo", "foo") test("bar + foo", "foo") def test_get_incomplete_function_name(self): def test(text, expected): text_view = Gtk.TextView() Gtk.TextView.do_insert_at_cursor(text_view, text) text_iter = text_view.get_iter_at_location(0, 0)[1] text_iter.set_offset(len(text)) self.assertEqual(get_incomplete_function_name(text_iter), expected) test("bar().foo", "foo") test("bar()\n.foo", "foo") test("bar().\nfoo", "foo") test("bar(\nfoo", "foo") test("bar(\nqux=foo", "foo") test("bar(KEY_A,\nfoo", "foo") test("foo", "foo") def test_autocomplete_names(self): autocompletion = self.user_interface.autocompletion def setup(text): self.set_focus(self.code_editor) self.code_editor.get_buffer().set_text("") Gtk.TextView.do_insert_at_cursor(self.code_editor, text) self.throttle(200) text_iter = self.code_editor.get_iter_at_location(0, 0)[1] text_iter.set_offset(len(text)) setup("disa") self.assertNotIn("KEY_A", self.get_suggestions(autocompletion)) self.assertIn("disable", self.get_suggestions(autocompletion)) setup(" + _A") self.assertIn("KEY_A", self.get_suggestions(autocompletion)) self.assertNotIn("disable", self.get_suggestions(autocompletion)) def test_autocomplete_key(self): self.controller.update_mapping(output_symbol="") gtk_iteration() self.set_focus(self.code_editor) self.code_editor.get_buffer().set_text("") complete_key_name = "Test_Foo_Bar" keyboard_layout.clear() keyboard_layout._set(complete_key_name, 1) keyboard_layout._set("KEY_A", 30) # we need this for the UIMapping to work # it can autocomplete a combination inbetween other things incomplete = "qux_1\n + + qux_2" Gtk.TextView.do_insert_at_cursor(self.code_editor, incomplete) Gtk.TextView.do_move_cursor( self.code_editor, Gtk.MovementStep.VISUAL_POSITIONS, -8, False, ) Gtk.TextView.do_insert_at_cursor(self.code_editor, "foo") self.throttle(200) gtk_iteration() autocompletion = self.user_interface.autocompletion self.assertTrue(autocompletion.visible) self.press_key(Gdk.KEY_Down) self.press_key(Gdk.KEY_Return) self.throttle(200) gtk_iteration() # the first suggestion should have been selected modified_symbol = self.get_code_input() self.assertEqual(modified_symbol, f"qux_1\n + {complete_key_name} + qux_2") # try again, but a whitespace completes the word and so no autocompletion # should be shown Gtk.TextView.do_insert_at_cursor(self.code_editor, " + foo ") time.sleep(0.11) gtk_iteration() self.assertFalse(autocompletion.visible) def test_autocomplete_function(self): self.controller.update_mapping(output_symbol="") gtk_iteration() source_view = self.focus_source_view() incomplete = "key(KEY_A).\nepea" Gtk.TextView.do_insert_at_cursor(source_view, incomplete) time.sleep(0.11) gtk_iteration() autocompletion = self.user_interface.autocompletion self.assertTrue(autocompletion.visible) self.press_key(Gdk.KEY_Down) self.press_key(Gdk.KEY_Return) # the first suggestion should have been selected modified_symbol = self.get_code_input() self.assertEqual(modified_symbol, "key(KEY_A).\nrepeat") def test_close_autocompletion(self): self.controller.update_mapping(output_symbol="") gtk_iteration() source_view = self.focus_source_view() Gtk.TextView.do_insert_at_cursor(source_view, "KEY_") time.sleep(0.11) gtk_iteration() autocompletion = self.user_interface.autocompletion self.assertTrue(autocompletion.visible) self.press_key(Gdk.KEY_Down) self.press_key(Gdk.KEY_Escape) self.assertFalse(autocompletion.visible) symbol = self.get_code_input() self.assertEqual(symbol, "KEY_") def test_writing_still_works(self): self.controller.update_mapping(output_symbol="") gtk_iteration() source_view = self.focus_source_view() Gtk.TextView.do_insert_at_cursor(source_view, "KEY_") autocompletion = self.user_interface.autocompletion time.sleep(0.11) gtk_iteration() self.assertTrue(autocompletion.visible) # writing still works while an entry is selected self.press_key(Gdk.KEY_Down) Gtk.TextView.do_insert_at_cursor(source_view, "A") time.sleep(0.11) gtk_iteration() self.assertTrue(autocompletion.visible) Gtk.TextView.do_insert_at_cursor(source_view, "1234foobar") time.sleep(0.11) gtk_iteration() # no key matches this completion, so it closes again self.assertFalse(autocompletion.visible) def test_cycling(self): self.controller.update_mapping(output_symbol="") gtk_iteration() source_view = self.focus_source_view() Gtk.TextView.do_insert_at_cursor(source_view, "KEY_") autocompletion = self.user_interface.autocompletion time.sleep(0.11) gtk_iteration() self.assertTrue(autocompletion.visible) self.assertEqual( autocompletion.scrolled_window.get_vadjustment().get_value(), 0 ) # cycle to the end of the list because there is no element higher than index 0 self.press_key(Gdk.KEY_Up) self.assertGreater( autocompletion.scrolled_window.get_vadjustment().get_value(), 0 ) # go back to the start, because it can't go down further self.press_key(Gdk.KEY_Down) self.assertEqual( autocompletion.scrolled_window.get_vadjustment().get_value(), 0 ) @test_setup class TestDebounce(unittest.TestCase): def test_debounce(self): calls = 0 class A: @debounce(20) def foo(self): nonlocal calls calls += 1 # two methods with the same name don't confuse debounce class B: @debounce(20) def foo(self): nonlocal calls calls += 1 a = A() b = B() self.assertEqual(calls, 0) a.foo() gtk_iteration() self.assertEqual(calls, 0) b.foo() gtk_iteration() self.assertEqual(calls, 0) time.sleep(0.021) gtk_iteration() self.assertEqual(calls, 2) a.foo() b.foo() a.foo() b.foo() gtk_iteration() self.assertEqual(calls, 2) time.sleep(0.021) gtk_iteration() self.assertEqual(calls, 4) def test_run_all_now(self): calls = 0 class A: @debounce(20) def foo(self): nonlocal calls calls += 1 a = A() a.foo() gtk_iteration() self.assertEqual(calls, 0) debounce_manager.run_all_now() self.assertEqual(calls, 1) # waiting for some time will not call it again time.sleep(0.021) gtk_iteration() self.assertEqual(calls, 1) def test_stop_all(self): calls = 0 class A: @debounce(20) def foo(self): nonlocal calls calls += 1 a = A() a.foo() gtk_iteration() self.assertEqual(calls, 0) debounce_manager.stop_all() # waiting for some time will not call it time.sleep(0.021) gtk_iteration() self.assertEqual(calls, 0) def test_stop(self): calls = 0 class A: @debounce(20) def foo(self): nonlocal calls calls += 1 a = A() a.foo() gtk_iteration() self.assertEqual(calls, 0) debounce_manager.stop(a, a.foo) # waiting for some time will not call it time.sleep(0.021) gtk_iteration() self.assertEqual(calls, 0) if __name__ == "__main__": unittest.main() input-remapper-2.1.1/tests/integration/test_numlock.py000066400000000000000000000033251475433465200232510ustar00rootroot00000000000000#!/usr/bin/env python3 # -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings # Copyright (C) 2025 sezanzeb # # This file is part of input-remapper. # # input-remapper is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # input-remapper is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with input-remapper. If not, see . import unittest from inputremapper.injection.numlock import is_numlock_on, set_numlock, ensure_numlock from tests.lib.test_setup import test_setup @test_setup class TestNumlock(unittest.TestCase): def test_numlock(self): before = is_numlock_on() set_numlock(not before) # should change self.assertEqual(not before, is_numlock_on()) @ensure_numlock def wrapped_1(): set_numlock(not is_numlock_on()) @ensure_numlock def wrapped_2(): pass # should not change wrapped_1() self.assertEqual(not before, is_numlock_on()) wrapped_2() self.assertEqual(not before, is_numlock_on()) # toggle one more time to restore the previous configuration set_numlock(before) self.assertEqual(before, is_numlock_on()) if __name__ == "__main__": unittest.main() input-remapper-2.1.1/tests/integration/test_user_interface.py000066400000000000000000000077351475433465200246100ustar00rootroot00000000000000import unittest from unittest.mock import MagicMock import gi from evdev.ecodes import EV_KEY, KEY_A gi.require_version("Gdk", "3.0") gi.require_version("Gtk", "3.0") gi.require_version("GLib", "2.0") gi.require_version("GtkSource", "4") from gi.repository import Gtk, Gdk, GLib from inputremapper.gui.utils import gtk_iteration from inputremapper.gui.messages.message_broker import MessageBroker, MessageType from inputremapper.gui.user_interface import UserInterface from inputremapper.configs.mapping import MappingData from inputremapper.configs.input_config import InputCombination, InputConfig from tests.lib.test_setup import test_setup @test_setup class TestUserInterface(unittest.TestCase): def setUp(self) -> None: self.message_broker = MessageBroker() self.controller_mock = MagicMock() self.user_interface = UserInterface(self.message_broker, self.controller_mock) def tearDown(self) -> None: super().tearDown() self.message_broker.signal(MessageType.terminate) GLib.timeout_add(0, self.user_interface.window.destroy) GLib.timeout_add(0, Gtk.main_quit) Gtk.main() def test_shortcut(self): mock = MagicMock() self.user_interface.shortcuts[Gdk.KEY_x] = mock event = Gdk.Event() event.key.keyval = Gdk.KEY_x event.key.state = Gdk.ModifierType.SHIFT_MASK self.user_interface.window.emit("key-press-event", event) gtk_iteration() mock.assert_not_called() event.key.state = Gdk.ModifierType.CONTROL_MASK self.user_interface.window.emit("key-press-event", event) gtk_iteration() mock.assert_called_once() mock.reset_mock() event.key.keyval = Gdk.KEY_y self.user_interface.window.emit("key-press-event", event) gtk_iteration() mock.assert_not_called() def test_connected_shortcuts(self): should_be_connected = {Gdk.KEY_q, Gdk.KEY_r, Gdk.KEY_Delete, Gdk.KEY_n} connected = set(self.user_interface.shortcuts.keys()) self.assertEqual(connected, should_be_connected) self.assertIs( self.user_interface.shortcuts[Gdk.KEY_q], self.controller_mock.close ) self.assertIs( self.user_interface.shortcuts[Gdk.KEY_r], self.controller_mock.refresh_groups, ) self.assertIs( self.user_interface.shortcuts[Gdk.KEY_Delete], self.controller_mock.stop_injecting, ) def test_connect_disconnect_shortcuts(self): mock = MagicMock() self.user_interface.shortcuts[Gdk.KEY_x] = mock event = Gdk.Event() event.key.keyval = Gdk.KEY_x event.key.state = Gdk.ModifierType.CONTROL_MASK self.user_interface.disconnect_shortcuts() self.user_interface.window.emit("key-press-event", event) gtk_iteration() mock.assert_not_called() self.user_interface.connect_shortcuts() gtk_iteration() self.user_interface.window.emit("key-press-event", event) gtk_iteration() mock.assert_called_once() def test_combination_label_shows_combination(self): self.message_broker.publish( MappingData( input_combination=InputCombination( [InputConfig(type=EV_KEY, code=KEY_A)] ), name="foo", ) ) gtk_iteration() label: Gtk.Label = self.user_interface.get("combination-label") self.assertEqual(label.get_text(), "a") self.assertEqual(label.get_opacity(), 1) def test_combination_label_shows_text_when_empty_mapping(self): self.message_broker.publish(MappingData()) gtk_iteration() label: Gtk.Label = self.user_interface.get("combination-label") self.assertEqual(label.get_text(), "no input configured") # 0.5 != 0.501960..., for whatever reason this number is all screwed up self.assertAlmostEqual(label.get_opacity(), 0.5, delta=0.1) input-remapper-2.1.1/tests/lib/000077500000000000000000000000001475433465200164105ustar00rootroot00000000000000input-remapper-2.1.1/tests/lib/__init__.py000066400000000000000000000000001475433465200205070ustar00rootroot00000000000000input-remapper-2.1.1/tests/lib/cleanup.py000066400000000000000000000122151475433465200204120ustar00rootroot00000000000000#!/usr/bin/env python3 # -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings # Copyright (C) 2025 sezanzeb # # This file is part of input-remapper. # # input-remapper is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # input-remapper is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with input-remapper. If not, see . from __future__ import annotations import asyncio import copy import os import shutil import time from pickle import UnpicklingError import psutil from tests.lib.constants import EVENT_READ_TIMEOUT from tests.lib.fixtures import fixtures from tests.lib.logger import logger from tests.lib.patches import uinputs from tests.lib.pipes import ( uinput_write_history_pipe, uinput_write_history, pending_events, setup_pipe, ) from tests.lib.tmp import tmp # TODO on it. You don't need a framework for this by the way: # don't import anything from input_remapper gloablly here, because some files execute # code when imported, which can screw up patches. I wish we had a dependency injection # framework that patches together the dependencies during runtime... environ_copy = copy.deepcopy(os.environ) def join_children(): """Wait for child processes to exit. Stop them if it takes too long.""" this = psutil.Process(os.getpid()) i = 0 time.sleep(EVENT_READ_TIMEOUT) children = this.children(recursive=True) while len([c for c in children if c.status() != "zombie"]) > 0: for child in children: if i > 10: child.kill() logger.info("Killed pid %s because it didn't finish in time", child.pid) children = this.children(recursive=True) time.sleep(EVENT_READ_TIMEOUT) i += 1 def clear_write_history(): """Empty the history in preparation for the next test.""" while len(uinput_write_history) > 0: uinput_write_history.pop() while uinput_write_history_pipe[0].poll(): uinput_write_history_pipe[0].recv() def quick_cleanup(log=True): """Reset the applications state.""" # TODO no: # Reminder: before patches are applied in test.py, no inputremapper module # may be imported. So tests.lib imports them just-in-time in functions instead. from inputremapper.injection.macros.macro import macro_variables from inputremapper.configs.keyboard_layout import keyboard_layout from inputremapper.gui.utils import debounce_manager if log: logger.info("Quick cleanup...") debounce_manager.stop_all() for device in list(pending_events.keys()): try: while pending_events[device][1].poll(): pending_events[device][1].recv() except (UnpicklingError, EOFError): pass # setup new pipes for the next test pending_events[device][1].close() pending_events[device][0].close() del pending_events[device] setup_pipe(device) try: if asyncio.get_event_loop().is_running(): for task in asyncio.all_tasks(): task.cancel() except RuntimeError: # happens when the event loop disappears for magical reasons # create a fresh event loop asyncio.set_event_loop(asyncio.new_event_loop()) if macro_variables.process is not None and not macro_variables.process.is_alive(): # nothing should stop the process during runtime, if it has been started by # the injector once raise AssertionError("the SharedDict manager is not running anymore") if macro_variables.process is not None: macro_variables._stop() join_children() macro_variables.start() if os.path.exists(tmp): shutil.rmtree(tmp) keyboard_layout.populate() clear_write_history() for name in list(uinputs.keys()): del uinputs[name] # for device in list(active_macros.keys()): # del active_macros[device] # for device in list(unreleased.keys()): # del unreleased[device] fixtures.reset() os.environ.update(environ_copy) for device in list(os.environ.keys()): if device not in environ_copy: del os.environ[device] for _, pipe in pending_events.values(): assert not pipe.poll() assert macro_variables.is_alive(1) if log: logger.info("Quick cleanup done") def cleanup(): """Reset the applications state. Using this is slower, usually quick_cleanup() is sufficient. """ from inputremapper.groups import groups logger.info("Cleanup...") os.system("pkill -f input-remapper-service") os.system("pkill -f input-remapper-control") time.sleep(0.05) quick_cleanup(log=False) groups.refresh() logger.info("Cleanup done") input-remapper-2.1.1/tests/lib/constants.py000066400000000000000000000022331475433465200207760ustar00rootroot00000000000000#!/usr/bin/env python3 # -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings # Copyright (C) 2025 sezanzeb # # This file is part of input-remapper. # # input-remapper is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # input-remapper is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with input-remapper. If not, see . # give tests some time to test stuff while the process # is still running EVENT_READ_TIMEOUT = 0.01 # based on experience how much time passes at most until # the reader-service starts receiving previously pushed events after a # call to start_reading START_READING_DELAY = 0.05 # for joysticks MIN_ABS = -(2**15) MAX_ABS = 2**15 input-remapper-2.1.1/tests/lib/fixture_pipes.py000066400000000000000000000024121475433465200216470ustar00rootroot00000000000000#!/usr/bin/env python3 # -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings # Copyright (C) 2025 sezanzeb # # This file is part of input-remapper. # # input-remapper is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # input-remapper is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with input-remapper. If not, see . from __future__ import annotations from tests.lib.fixtures import fixtures from tests.lib.pipes import setup_pipe, close_pipe def create_fixture_pipes(): # make sure those pipes exist before any process (the reader-service) gets forked, # so that events can be pushed after the fork. for _fixture in fixtures: setup_pipe(_fixture) def remove_fixture_pipes(): for _fixture in fixtures: close_pipe(_fixture) input-remapper-2.1.1/tests/lib/fixtures.py000066400000000000000000000272171475433465200206440ustar00rootroot00000000000000#!/usr/bin/env python3 # -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings # Copyright (C) 2025 sezanzeb # # This file is part of input-remapper. # # input-remapper is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # input-remapper is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with input-remapper. If not, see . from __future__ import annotations import dataclasses import json import time from hashlib import md5 from typing import Dict, Optional import evdev from inputremapper.configs.input_config import InputCombination from inputremapper.configs.mapping import Mapping from inputremapper.configs.paths import PathUtils from inputremapper.configs.preset import Preset from tests.lib.logger import logger # input-remapper is only interested in devices that have EV_KEY, add some # random other stuff to test that they are ignored. phys_foo = "usb-0000:03:00.0-1/input2" info_foo = evdev.device.DeviceInfo(1, 1, 1, 1) keyboard_keys = sorted(evdev.ecodes.keys.keys())[:255] @dataclasses.dataclass(frozen=True) class Fixture: path: str capabilities: Dict = dataclasses.field(default_factory=dict) name: str = "unset" info: evdev.device.DeviceInfo = evdev.device.DeviceInfo(None, None, None, None) phys: str = "unset" group_key: Optional[str] = None # uniq is typically empty uniq: str = "" def __hash__(self): return hash(self.path) def get_device_hash(self): s = str(self.capabilities) + self.name device_hash = md5(s.encode()).hexdigest() logger.info( 'Hash for fixture "%s" "%s": "%s"', self.path, self.name, device_hash, ) return device_hash class _Fixtures: """contains all predefined Fixtures. Can be extended with new Fixtures during runtime""" dev_input_event1 = Fixture( capabilities={ evdev.ecodes.EV_KEY: [evdev.ecodes.KEY_A], }, phys="usb-0000:03:00.0-0/input1", info=info_foo, name="Foo Device", path="/dev/input/event1", ) # Another "Foo Device", which will get an incremented key. # If possible write tests using this one, because name != key here and # that would be important to test as well. Otherwise, the tests can't # see if the groups correct attribute is used in functions and paths. dev_input_event11 = Fixture( capabilities={ evdev.ecodes.EV_KEY: [ evdev.ecodes.BTN_LEFT, evdev.ecodes.BTN_TOOL_DOUBLETAP, ], evdev.ecodes.EV_REL: [ evdev.ecodes.REL_X, evdev.ecodes.REL_Y, evdev.ecodes.REL_WHEEL, evdev.ecodes.REL_HWHEEL, ], }, phys=f"{phys_foo}/input2", info=info_foo, name="Foo Device foo", group_key="Foo Device 2", # expected key path="/dev/input/event11", ) dev_input_event10 = Fixture( capabilities={evdev.ecodes.EV_KEY: keyboard_keys}, phys=f"{phys_foo}/input3", info=info_foo, name="Foo Device", group_key="Foo Device 2", path="/dev/input/event10", ) dev_input_event13 = Fixture( capabilities={evdev.ecodes.EV_KEY: [], evdev.ecodes.EV_SYN: []}, phys=f"{phys_foo}/input1", info=info_foo, name="Foo Device", group_key="Foo Device 2", path="/dev/input/event13", ) dev_input_event14 = Fixture( capabilities={evdev.ecodes.EV_SYN: []}, phys=f"{phys_foo}/input0", info=info_foo, name="Foo Device qux", group_key="Foo Device 2", path="/dev/input/event14", ) dev_input_event15 = Fixture( capabilities={ evdev.ecodes.EV_SYN: [], evdev.ecodes.EV_ABS: [ evdev.ecodes.ABS_X, evdev.ecodes.ABS_Y, evdev.ecodes.ABS_RX, evdev.ecodes.ABS_RY, evdev.ecodes.ABS_Z, evdev.ecodes.ABS_RZ, evdev.ecodes.ABS_HAT0X, evdev.ecodes.ABS_HAT0Y, ], evdev.ecodes.EV_KEY: [evdev.ecodes.BTN_A], }, phys=f"{phys_foo}/input4", info=info_foo, name="Foo Device bar", group_key="Foo Device 2", path="/dev/input/event15", ) # Bar Device dev_input_event20 = Fixture( capabilities={evdev.ecodes.EV_KEY: keyboard_keys}, phys="usb-0000:03:00.0-2/input1", info=evdev.device.DeviceInfo(2, 1, 2, 1), name="Bar Device", path="/dev/input/event20", ) dev_input_event30 = Fixture( capabilities={ evdev.ecodes.EV_SYN: [], evdev.ecodes.EV_ABS: [ evdev.ecodes.ABS_X, evdev.ecodes.ABS_Y, evdev.ecodes.ABS_RX, evdev.ecodes.ABS_RY, evdev.ecodes.ABS_Z, evdev.ecodes.ABS_RZ, evdev.ecodes.ABS_HAT0X, evdev.ecodes.ABS_HAT0Y, ], evdev.ecodes.EV_KEY: [ evdev.ecodes.BTN_A, evdev.ecodes.BTN_B, evdev.ecodes.BTN_X, evdev.ecodes.BTN_Y, ], }, phys="", # this is empty sometimes info=evdev.device.DeviceInfo(3, 1, 3, 1), name="gamepad", path="/dev/input/event30", ) # device that is completely ignored dev_input_event31 = Fixture( capabilities={evdev.ecodes.EV_SYN: []}, phys="usb-0000:03:00.0-4/input1", info=evdev.device.DeviceInfo(4, 1, 4, 1), name="Power Button", path="/dev/input/event31", ) # input-remapper devices are not displayed in the ui, some instance # of input-remapper started injecting, apparently. dev_input_event40 = Fixture( capabilities={evdev.ecodes.EV_KEY: keyboard_keys}, phys="input-remapper/input1", info=evdev.device.DeviceInfo(5, 1, 5, 1), name="input-remapper Bar Device", path="/dev/input/event40", ) # denylisted dev_input_event51 = Fixture( capabilities={evdev.ecodes.EV_KEY: keyboard_keys}, phys="usb-0000:03:00.0-5/input1", info=evdev.device.DeviceInfo(6, 1, 6, 1), name="YuBiCofooYuBiKeYbar", path="/dev/input/event51", ) # name requires sanitation dev_input_event52 = Fixture( capabilities={evdev.ecodes.EV_KEY: keyboard_keys}, phys="usb-0000:03:00.0-3/input1", info=evdev.device.DeviceInfo(2, 1, 2, 1), name="Qux/[Device]?", path="/dev/input/event52", ) def __init__(self): self._iter = [ self.dev_input_event1, self.dev_input_event11, self.dev_input_event10, self.dev_input_event13, self.dev_input_event14, self.dev_input_event15, self.dev_input_event20, self.dev_input_event30, self.dev_input_event31, self.dev_input_event40, self.dev_input_event51, self.dev_input_event52, ] self._dynamic_fixtures = {} def __getitem__(self, path: str) -> Fixture: """get a Fixture by it's unique /dev/input/eventX path""" if fixture := self._dynamic_fixtures.get(path): return fixture path = self._path_to_attribute(path) try: return getattr(self, path) except AttributeError as e: raise KeyError(str(e)) def __setitem__(self, key: str, value: [Fixture | dict]): if isinstance(value, Fixture): self._dynamic_fixtures[key] = value elif isinstance(value, dict): self._dynamic_fixtures[key] = Fixture(path=key, **value) def __iter__(self): return iter([*self._iter, *self._dynamic_fixtures.values()]) def get_paths(self): """Get a list of all available device paths.""" return list(self._dynamic_fixtures.keys()) def reset(self): self._dynamic_fixtures = {} @staticmethod def _path_to_attribute(path) -> str: if path.startswith("/"): path = path[1:] if "/" in path: path = path.replace("/", "_") return path def get(self, item) -> Optional[Fixture]: try: return self[item] except KeyError: return None @property def foo_device_1_1(self): return self["/dev/input/event1"] @property def foo_device_2_mouse(self): return self["/dev/input/event11"] @property def foo_device_2_keyboard(self): return self["/dev/input/event10"] @property def foo_device_2_13(self): return self["/dev/input/event13"] @property def foo_device_2_qux(self): return self["/dev/input/event14"] @property def foo_device_2_gamepad(self): return self["/dev/input/event15"] @property def bar_device(self): return self["/dev/input/event20"] @property def gamepad(self): return self["/dev/input/event30"] @property def power_button(self): return self["/dev/input/event31"] @property def input_remapper_bar_device(self): return self["/dev/input/event40"] @property def YuBiCofooYuBiKeYbar(self): return self["/dev/input/event51"] @property def QuxSlashDeviceQuestionmark(self): return self["/dev/input/event52"] fixtures = _Fixtures() def new_event(type, code, value, timestamp): """Create a new InputEvent. Handy because of the annoying sec and usec arguments of the regular evdev.InputEvent constructor. Prefer using `InputEvent.key()`, `InputEvent.abs()`, `InputEvent.rel()` or just `InputEvent(0, 0, 1234, 2345, 3456)`. """ from inputremapper.input_event import InputEvent if timestamp is None: timestamp = time.time() sec = int(timestamp) usec = timestamp % 1 * 1000000 event = InputEvent(sec, usec, type, code, value) return event def prepare_presets(): """prepare a few presets for use in tests "Foo Device 2/preset3" is the newest and "Foo Device 2/preset2" is set to autoload """ preset1 = Preset(PathUtils.get_preset_path("Foo Device", "preset1")) preset1.add( Mapping.from_combination( InputCombination.from_tuples((1, 1)), output_symbol="b", ) ) preset1.add(Mapping.from_combination(InputCombination.from_tuples((1, 2)))) preset1.save() time.sleep(0.1) preset2 = Preset(PathUtils.get_preset_path("Foo Device", "preset2")) preset2.add(Mapping.from_combination(InputCombination.from_tuples((1, 3)))) preset2.add(Mapping.from_combination(InputCombination.from_tuples((1, 4)))) preset2.save() # make sure the timestamp of preset 3 is the newest, # so that it will be automatically loaded by the GUI time.sleep(0.1) preset3 = Preset(PathUtils.get_preset_path("Foo Device", "preset3")) preset3.add(Mapping.from_combination(InputCombination.from_tuples((1, 5)))) preset3.save() with open(PathUtils.get_config_path("config.json"), "w") as file: json.dump({"autoload": {"Foo Device 2": "preset2"}}, file, indent=4) return preset1, preset2, preset3 input-remapper-2.1.1/tests/lib/is_service_running.py000066400000000000000000000021621475433465200226560ustar00rootroot00000000000000#!/usr/bin/env python3 # -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings # Copyright (C) 2025 sezanzeb # # This file is part of input-remapper. # # input-remapper is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # input-remapper is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with input-remapper. If not, see . from __future__ import annotations import subprocess def is_service_running(): """Check if the daemon is running.""" try: subprocess.check_output(["pgrep", "-f", "input-remapper-service"]) return True except subprocess.CalledProcessError: return False input-remapper-2.1.1/tests/lib/logger.py000066400000000000000000000032641475433465200202460ustar00rootroot00000000000000#!/usr/bin/env python3 # -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings # Copyright (C) 2025 sezanzeb # # This file is part of input-remapper. # # input-remapper is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # input-remapper is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with input-remapper. If not, see . from __future__ import annotations import sys import traceback import tracemalloc import warnings import logging tracemalloc.start() logger = logging.getLogger("input-remapper-test") handler = logging.StreamHandler() handler.setFormatter(logging.Formatter("\033[90mTest: %(message)s\033[0m")) logger.addHandler(handler) logger.setLevel(logging.INFO) def update_inputremapper_verbosity(): from inputremapper.logging.logger import logger logger.update_verbosity(True) def warn_with_traceback(message, category, filename, lineno, file=None, line=None): log = file if hasattr(file, "write") else sys.stderr traceback.print_stack(file=log) log.write(warnings.formatwarning(message, category, filename, lineno, line)) def patch_warnings(): # show traceback warnings.showwarning = warn_with_traceback warnings.simplefilter("always") input-remapper-2.1.1/tests/lib/patches.py000066400000000000000000000273661475433465200204270ustar00rootroot00000000000000#!/usr/bin/env python3 # -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings # Copyright (C) 2025 sezanzeb # # This file is part of input-remapper. # # input-remapper is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # input-remapper is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with input-remapper. If not, see . from __future__ import annotations import asyncio import copy import os import subprocess import time from pickle import UnpicklingError from unittest.mock import patch import evdev from inputremapper.utils import get_evdev_constant_name from tests.lib.constants import EVENT_READ_TIMEOUT, MIN_ABS, MAX_ABS from tests.lib.fixtures import Fixture, fixtures, new_event from tests.lib.pipes import ( setup_pipe, push_events, uinput_write_history, uinput_write_history_pipe, pending_events, ) from tests.lib.xmodmap import xmodmap from tests.lib.tmp import tmp from tests.lib.logger import logger def patch_paths(): from inputremapper.user import UserUtils return patch.object(UserUtils, "home", tmp) class InputDevice: # expose as existing attribute, otherwise the patch for # evdev < 1.0.0 will crash the test path = None def __init__(self, path): if path != "justdoit" and not fixtures.get(path): # beware that fixtures keys and the path attribute of a fixture can # theoretically be different. I don't know if this is the case right now logger.error( 'path "%s" was not found in fixtures. available: %s', path, list(fixtures.get_paths()), ) raise FileNotFoundError() if path == "justdoit": self._fixture = Fixture(path="justdoit") else: self._fixture = fixtures[path] self.path = path self.phys = self._fixture.phys self.info = self._fixture.info self.name = self._fixture.name self.uniq = self._fixture.uniq # this property exists only for test purposes and is not part of # the original evdev.InputDevice class self.group_key = self._fixture.group_key or self._fixture.name # ensure a pipe exists to make this object act like # it is reading events from a device setup_pipe(self._fixture) self.fd = pending_events[self._fixture][1].fileno() def push_events(self, events): push_events(self._fixture, events) def fileno(self): """Compatibility to select.select.""" return self.fd def log(self, key, msg): logger.info(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): logger.info("starting read loop for %s", self.path) new_frame = asyncio.Event() asyncio.get_running_loop().add_reader(self.fd, new_frame.set) while True: await new_frame.wait() new_frame.clear() if not pending_events[self._fixture][1].poll(): # todo: why? why do we need this? # sometimes this happens, as if a other process calls recv on # the pipe continue event = pending_events[self._fixture][1].recv() logger.info("got %s at %s", event, self.path) yield event def read(self): # the patched fake InputDevice objects read anything pending from # that group. # To be realistic it would have to check if the provided # element is in its capabilities. if self.group_key not in pending_events: self.log("no events to read", self.group_key) return # consume all of them while pending_events[self._fixture][1].poll(): event = pending_events[self._fixture][1].recv() self.log(event, "read") yield event time.sleep(EVENT_READ_TIMEOUT) def read_loop(self): """Endless loop that yields events.""" while True: event = pending_events[self._fixture][1].recv() if event is not None: self.log(event, "read_loop") yield event time.sleep(EVENT_READ_TIMEOUT) def read_one(self): """Read one event or none if nothing available.""" if not pending_events.get(self._fixture): return None if not pending_events[self._fixture][1].poll(): return None try: event = pending_events[self._fixture][1].recv() except (UnpicklingError, EOFError): # failed in tests sometimes return None self.log(event, "read_one") return event def capabilities(self, absinfo=True, verbose=False): result = copy.deepcopy(self._fixture.capabilities) if absinfo and evdev.ecodes.EV_ABS in result: absinfo_obj = evdev.AbsInfo( value=None, min=MIN_ABS, fuzz=None, flat=None, resolution=None, max=MAX_ABS, ) ev_abs = [] for ev_code in result[evdev.ecodes.EV_ABS]: if ev_code in range(0x10, 0x18): # ABS_HAT0X - ABS_HAT3Y absinfo_obj = evdev.AbsInfo( value=None, min=-1, fuzz=None, flat=None, resolution=None, max=1, ) ev_abs.append((ev_code, absinfo_obj)) result[evdev.ecodes.EV_ABS] = ev_abs return result def input_props(self): return [] def leds(self): return [] uinputs = {} class UInputMock: def __init__(self, events=None, name="unnamed", *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, verbose=False, absinfo=True): if absinfo or 3 not in self.events: return self.events else: events = self.events.copy() events[3] = [code for code, _ in self.events[3]] return events def write(self, type, code, value): self.write_count += 1 event = new_event(type, code, value, time.time()) uinput_write_history.append(event) uinput_write_history_pipe[1].send(event) self.write_history.append(event) logger.info( '%s %s written to "%s"', (type, code, value), get_evdev_constant_name(type, code), self.name, ) def syn(self): pass def patch_evdev(): def list_devices(): return [fixture_.path for fixture_ in fixtures] class PatchedInputEvent(evdev.InputEvent): def __init__(self, sec, usec, type, code, value): self.t = (type, code, value) super().__init__(sec, usec, type, code, value) def copy(self): return PatchedInputEvent( self.sec, self.usec, self.type, self.code, self.value, ) return [ patch.object(evdev, "list_devices", list_devices), patch.object(evdev, "InputDevice", InputDevice), patch.object(evdev.UInput, "capabilities", UInputMock.capabilities), patch.object(evdev.UInput, "write", UInputMock.write), patch.object(evdev.UInput, "syn", UInputMock.syn), patch.object(evdev.UInput, "__init__", UInputMock.__init__), patch.object(evdev, "InputEvent", PatchedInputEvent), ] def patch_events(): # improve logging of stuff return patch.object( evdev.InputEvent, "__str__", lambda self: (f"InputEvent{(self.type, self.code, self.value)}"), ) def patch_os_system(): """Avoid running pkexec.""" original_system = os.system def system(command): if "pkexec" in command: # because it # - will open a window for user input # - has no knowledge of the fixtures and patches raise Exception("Write patches to avoid running pkexec stuff") return original_system(command) return patch.object(os, "system", system) def patch_check_output(): """Xmodmap -pke should always return a fixed set of symbols. On some installations the `xmodmap` command might be missig completely, which would break the tests. """ original_check_output = subprocess.check_output def check_output(command, *args, **kwargs): if "xmodmap" in command and "-pke" in command: return xmodmap return original_check_output(command, *args, **kwargs) return patch.object(subprocess, "check_output", check_output) def patch_regrab_timeout(): # no need for a high number in tests from inputremapper.injection.injector import Injector return patch.object(Injector, "regrab_timeout", 0.05) def is_running_patch(): logger.info("is_running is patched to always return True") return True def patch_is_running(): from inputremapper.gui.reader_service import ReaderService return patch.object(ReaderService, "is_running", is_running_patch) class FakeDaemonProxy: def __init__(self): self.calls = { "stop_injecting": [], "get_state": [], "start_injecting": [], "stop_all": 0, "set_config_dir": [], "autoload": 0, "autoload_single": [], "hello": [], "quit": 0, } def stop_injecting(self, group_key: str) -> None: self.calls["stop_injecting"].append(group_key) def get_state(self, group_key: str): from inputremapper.injection.injector import InjectorState self.calls["get_state"].append(group_key) return InjectorState.STOPPED def start_injecting(self, group_key: str, preset: str) -> bool: self.calls["start_injecting"].append((group_key, preset)) return True def stop_all(self) -> None: self.calls["stop_all"] += 1 def set_config_dir(self, config_dir: str) -> None: self.calls["set_config_dir"].append(config_dir) def autoload(self) -> None: self.calls["autoload"] += 1 def autoload_single(self, group_key: str) -> None: self.calls["autoload_single"].append(group_key) def hello(self, out: str) -> str: self.calls["hello"].append(out) return out def quit(self): self.calls["quit"] += 1 def create_patches(): return [ # Sketchy, they only work because the whole modules are imported, instead of # importing `check_output` and `system` from the module. *patch_evdev(), patch_os_system(), patch_check_output(), # Those are comfortably wrapped in a class, and are therefore easy to patch patch_paths(), patch_regrab_timeout(), patch_is_running(), patch_events(), ] input-remapper-2.1.1/tests/lib/pipes.py000066400000000000000000000062051475433465200201050ustar00rootroot00000000000000#!/usr/bin/env python3 # -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings # Copyright (C) 2025 sezanzeb # # This file is part of input-remapper. # # input-remapper is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # input-remapper is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with input-remapper. If not, see . """Reading events from fixtures, making fixtures act like they are sending events.""" from __future__ import annotations import multiprocessing from multiprocessing.connection import Connection from typing import Dict, Tuple from tests.lib.fixtures import Fixture from tests.lib.logger import logger uinput_write_history = [] # for tests that makes the injector create its processes uinput_write_history_pipe = multiprocessing.Pipe() pending_events: Dict[Fixture, Tuple[Connection, Connection]] = {} def read_write_history_pipe(): """Convert the write history from the pipe to some easier to manage list.""" history = [] while uinput_write_history_pipe[0].poll(): event = uinput_write_history_pipe[0].recv() history.append((event.type, event.code, event.value)) return history def setup_pipe(fixture: Fixture): """Create a pipe that can be used to send events to the reader-service, which in turn will be sent to the reader-client """ if pending_events.get(fixture) is None: pending_events[fixture] = multiprocessing.Pipe() def close_pipe(fixture: Fixture): if fixture in pending_events: pipe1, pipe2 = pending_events[fixture] pipe1.close() pipe2.close() del pending_events[fixture] def get_events(): """Get all events written by the injector.""" return uinput_write_history def push_event(fixture: Fixture, event, force: bool = False): """Make a device act like it is reading events from evdev. push_event is like hitting a key on a keyboard for stuff that reads from evdev.InputDevice (which is patched in test.py to work that way) Parameters ---------- fixture For example 'Foo Device' event The InputEvent to send force don't check if the event is in fixture.capabilities """ setup_pipe(fixture) if not force and ( not fixture.capabilities.get(event.type) or event.code not in fixture.capabilities[event.type] ): raise AssertionError(f"Fixture {fixture.path} cannot send {event}") logger.info("Simulating %s for %s", event, fixture.path) pending_events[fixture][0].send(event) def push_events(fixture: Fixture, events, force=False): """Push multiple events.""" for event in events: push_event(fixture, event, force) input-remapper-2.1.1/tests/lib/spy.py000066400000000000000000000020161475433465200175740ustar00rootroot00000000000000#!/usr/bin/env python3 # -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings # Copyright (C) 2025 sezanzeb # # This file is part of input-remapper. # # input-remapper is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # input-remapper is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with input-remapper. If not, see . from unittest.mock import patch def spy(obj, name): """Convenient wrapper for patch.object(..., ..., wraps=...).""" return patch.object(obj, name, wraps=obj.__getattribute__(name)) input-remapper-2.1.1/tests/lib/test_setup.py000066400000000000000000000056711475433465200211720ustar00rootroot00000000000000#!/usr/bin/env python3 # -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings # Copyright (C) 2025 sezanzeb # # This file is part of input-remapper. # # input-remapper is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # input-remapper is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with input-remapper. If not, see . from __future__ import annotations import os import tracemalloc from tests.lib.cleanup import cleanup, quick_cleanup from tests.lib.fixture_pipes import create_fixture_pipes, remove_fixture_pipes from tests.lib.is_service_running import is_service_running from tests.lib.logger import update_inputremapper_verbosity from tests.lib.patches import create_patches def test_setup(cls): """A class decorator to - apply the patches to all tests - check if the deamon is already running - create pipes to send events to the reader service - reset stuff automatically """ original_setUp = cls.setUp original_tearDown = cls.tearDown original_setUpClass = cls.setUpClass original_tearDownClass = cls.tearDownClass tracemalloc.start() os.environ["UNITTEST"] = "1" update_inputremapper_verbosity() patches = create_patches() def setUpClass(): if is_service_running(): # let tests control daemon existance raise Exception("Expected the service not to be running already.") create_fixture_pipes() # I don't know. Somehow tearDownClass is called before the test, so lets # make sure the patches are started already when the class is set up, so that # an unpatched `prepare_all` doesn't take ages to finish, and doesn't do funky # stuff with the real evdev. for patch in patches: patch.start() original_setUpClass() def tearDownClass(): original_tearDownClass() remove_fixture_pipes() # Do the more thorough cleanup only after all tests of classes, because it # slows tests down. If this is required after each test, call it in your # tearDown method. cleanup() def setUp(self): for patch in patches: patch.start() original_setUp(self) def tearDown(self): original_tearDown(self) quick_cleanup() for patch in patches: patch.stop() cls.setUp = setUp cls.tearDown = tearDown cls.setUpClass = setUpClass cls.tearDownClass = tearDownClass return cls input-remapper-2.1.1/tests/lib/tmp.py000066400000000000000000000021621475433465200175630ustar00rootroot00000000000000#!/usr/bin/env python3 # -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings # Copyright (C) 2025 sezanzeb # # This file is part of input-remapper. # # input-remapper is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # input-remapper is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with input-remapper. If not, see . from __future__ import annotations import tempfile # 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 input-remapper-2.1.1/tests/lib/xmodmap.py000066400000000000000000000323201475433465200204270ustar00rootroot00000000000000#!/usr/bin/env python3 # -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings # Copyright (C) 2025 sezanzeb # # This file is part of input-remapper. # # input-remapper is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # input-remapper is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with input-remapper. If not, see . xmodmap = ( b"keycode 8 =\nkeycode 9 = Escape NoSymbol Escape\nkeycode 10 = 1 exclam 1 exclam onesuperior exclamdown ones" b"uperior\nkeycode 11 = 2 quotedbl 2 quotedbl twosuperior oneeighth twosuperior\nkeycode 12 = 3 section 3 sectio" b"n threesuperior sterling threesuperior\nkeycode 13 = 4 dollar 4 dollar onequarter currency onequarter\nkeycode " b" 14 = 5 percent 5 percent onehalf threeeighths onehalf\nkeycode 15 = 6 ampersand 6 ampersand notsign fiveeighth" b"s notsign\nkeycode 16 = 7 slash 7 slash braceleft seveneighths braceleft\nkeycode 17 = 8 parenleft 8 parenleft" b" bracketleft trademark bracketleft\nkeycode 18 = 9 parenright 9 parenright bracketright plusminus bracketright" b"\nkeycode 19 = 0 equal 0 equal braceright degree braceright\nkeycode 20 = ssharp question ssharp question back" b"slash questiondown U1E9E\nkeycode 21 = dead_acute dead_grave dead_acute dead_grave dead_cedilla dead_ogonek dea" b"d_cedilla\nkeycode 22 = BackSpace BackSpace BackSpace BackSpace\nkeycode 23 = Tab ISO_Left_Tab Tab ISO_Left_Ta" b"b\nkeycode 24 = q Q q Q at Greek_OMEGA at\nkeycode 25 = w W w W lstroke Lstroke lstroke\nkeycode 26 = e E e E" b" EuroSign EuroSign EuroSign\nkeycode 27 = r R r R paragraph registered paragraph\nkeycode 28 = t T t T tslash " b"Tslash tslash\nkeycode 29 = z Z z Z leftarrow yen leftarrow\nkeycode 30 = u U u U downarrow uparrow downarrow" b"\nkeycode 31 = i I i I rightarrow idotless rightarrow\nkeycode 32 = o O o O oslash Oslash oslash\nkeycode 33 " b"= p P p P thorn THORN thorn\nkeycode 34 = udiaeresis Udiaeresis udiaeresis Udiaeresis dead_diaeresis dead_above" b"ring dead_diaeresis\nkeycode 35 = plus asterisk plus asterisk asciitilde macron asciitilde\nkeycode 36 = Retur" b"n NoSymbol Return\nkeycode 37 = Control_L NoSymbol Control_L\nkeycode 38 = a A a A ae AE ae\nkeycode 39 = s S" b" s S U017F U1E9E U017F\nkeycode 40 = d D d D eth ETH eth\nkeycode 41 = f F f F dstroke ordfeminine dstroke\nke" b"ycode 42 = g G g G eng ENG eng\nkeycode 43 = h H h H hstroke Hstroke hstroke\nkeycode 44 = j J j J dead_below" b"dot dead_abovedot dead_belowdot\nkeycode 45 = k K k K kra ampersand kra\nkeycode 46 = l L l L lstroke Lstroke " b"lstroke\nkeycode 47 = odiaeresis Odiaeresis odiaeresis Odiaeresis dead_doubleacute dead_belowdot dead_doubleacu" b"te\nkeycode 48 = adiaeresis Adiaeresis adiaeresis Adiaeresis dead_circumflex dead_caron dead_circumflex\nkeycod" b"e 49 = dead_circumflex degree dead_circumflex degree U2032 U2033 U2032\nkeycode 50 = Shift_L NoSymbol Shift_L" b"\nkeycode 51 = numbersign apostrophe numbersign apostrophe rightsinglequotemark dead_breve rightsinglequotemark" b"\nkeycode 52 = y Y y Y guillemotright U203A guillemotright\nkeycode 53 = x X x X guillemotleft U2039 guillemot" b"left\nkeycode 54 = c C c C cent copyright cent\nkeycode 55 = v V v V doublelowquotemark singlelowquotemark dou" b"blelowquotemark\nkeycode 56 = b B b B leftdoublequotemark leftsinglequotemark leftdoublequotemark\nkeycode 57 " b"= n N n N rightdoublequotemark rightsinglequotemark rightdoublequotemark\nkeycode 58 = m M m M mu masculine mu" b"\nkeycode 59 = comma semicolon comma semicolon periodcentered multiply periodcentered\nkeycode 60 = period col" b"on period colon U2026 division U2026\nkeycode 61 = minus underscore minus underscore endash emdash endash\nkeyc" b"ode 62 = Shift_R NoSymbol Shift_R\nkeycode 63 = KP_Multiply KP_Multiply KP_Multiply KP_Multiply KP_Multiply KP" b"_Multiply XF86ClearGrab\nkeycode 64 = Alt_L Meta_L Alt_L Meta_L\nkeycode 65 = space NoSymbol space\nkeycode 6" b"6 = Caps_Lock NoSymbol Caps_Lock\nkeycode 67 = F1 F1 F1 F1 F1 F1 XF86Switch_VT_1\nkeycode 68 = F2 F2 F2 F2 F2 " b"F2 XF86Switch_VT_2\nkeycode 69 = F3 F3 F3 F3 F3 F3 XF86Switch_VT_3\nkeycode 70 = F4 F4 F4 F4 F4 F4 XF86Switch_" b"VT_4\nkeycode 71 = F5 F5 F5 F5 F5 F5 XF86Switch_VT_5\nkeycode 72 = F6 F6 F6 F6 F6 F6 XF86Switch_VT_6\nkeycode " b" 73 = F7 F7 F7 F7 F7 F7 XF86Switch_VT_7\nkeycode 74 = F8 F8 F8 F8 F8 F8 XF86Switch_VT_8\nkeycode 75 = F9 F9 F9" b" F9 F9 F9 XF86Switch_VT_9\nkeycode 76 = F10 F10 F10 F10 F10 F10 XF86Switch_VT_10\nkeycode 77 = Num_Lock NoSymb" b"ol Num_Lock\nkeycode 78 = Scroll_Lock NoSymbol Scroll_Lock\nkeycode 79 = KP_Home KP_7 KP_Home KP_7\nkeycode 8" b"0 = KP_Up KP_8 KP_Up KP_8\nkeycode 81 = KP_Prior KP_9 KP_Prior KP_9\nkeycode 82 = KP_Subtract KP_Subtract KP_S" b"ubtract KP_Subtract KP_Subtract KP_Subtract XF86Prev_VMode\nkeycode 83 = KP_Left KP_4 KP_Left KP_4\nkeycode 84" b" = KP_Begin KP_5 KP_Begin KP_5\nkeycode 85 = KP_Right KP_6 KP_Right KP_6\nkeycode 86 = KP_Add KP_Add KP_Add KP" b"_Add KP_Add KP_Add XF86Next_VMode\nkeycode 87 = KP_End KP_1 KP_End KP_1\nkeycode 88 = KP_Down KP_2 KP_Down KP_" b"2\nkeycode 89 = KP_Next KP_3 KP_Next KP_3\nkeycode 90 = KP_Insert KP_0 KP_Insert KP_0\nkeycode 91 = KP_Delete" b" KP_Separator KP_Delete KP_Separator\nkeycode 92 = ISO_Level3_Shift NoSymbol ISO_Level3_Shift\nkeycode 93 =\nk" b"eycode 94 = less greater less greater bar dead_belowmacron bar\nkeycode 95 = F11 F11 F11 F11 F11 F11 XF86Switc" b"h_VT_11\nkeycode 96 = F12 F12 F12 F12 F12 F12 XF86Switch_VT_12\nkeycode 97 =\nkeycode 98 = Katakana NoSymbol " b"Katakana\nkeycode 99 = Hiragana NoSymbol Hiragana\nkeycode 100 = Henkan_Mode NoSymbol Henkan_Mode\nkeycode 101 " b"= Hiragana_Katakana NoSymbol Hiragana_Katakana\nkeycode 102 = Muhenkan NoSymbol Muhenkan\nkeycode 103 =\nkeycode" b" 104 = KP_Enter NoSymbol KP_Enter\nkeycode 105 = Control_R NoSymbol Control_R\nkeycode 106 = KP_Divide KP_Divide" b" KP_Divide KP_Divide KP_Divide KP_Divide XF86Ungrab\nkeycode 107 = Print Sys_Req Print Sys_Req\nkeycode 108 = IS" b"O_Level3_Shift NoSymbol ISO_Level3_Shift\nkeycode 109 = Linefeed NoSymbol Linefeed\nkeycode 110 = Home NoSymbol " b"Home\nkeycode 111 = Up NoSymbol Up\nkeycode 112 = Prior NoSymbol Prior\nkeycode 113 = Left NoSymbol Left\nkeycod" b"e 114 = Right NoSymbol Right\nkeycode 115 = End NoSymbol End\nkeycode 116 = Down NoSymbol Down\nkeycode 117 = Ne" b"xt NoSymbol Next\nkeycode 118 = Insert NoSymbol Insert\nkeycode 119 = Delete NoSymbol Delete\nkeycode 120 =\nkey" b"code 121 = XF86AudioMute NoSymbol XF86AudioMute\nkeycode 122 = XF86AudioLowerVolume NoSymbol XF86AudioLowerVolum" b"e\nkeycode 123 = XF86AudioRaiseVolume NoSymbol XF86AudioRaiseVolume\nkeycode 124 = XF86PowerOff NoSymbol XF86Pow" b"erOff\nkeycode 125 = KP_Equal NoSymbol KP_Equal\nkeycode 126 = plusminus NoSymbol plusminus\nkeycode 127 = Pause" b" Break Pause Break\nkeycode 128 = XF86LaunchA NoSymbol XF86LaunchA\nkeycode 129 = KP_Decimal KP_Decimal KP_Decim" b"al KP_Decimal\nkeycode 130 = Hangul NoSymbol Hangul\nkeycode 131 = Hangul_Hanja NoSymbol Hangul_Hanja\nkeycode 1" b"32 =\nkeycode 133 = Super_L NoSymbol Super_L\nkeycode 134 = Super_R NoSymbol Super_R\nkeycode 135 = Menu NoSymbo" b"l Menu\nkeycode 136 = Cancel NoSymbol Cancel\nkeycode 137 = Redo NoSymbol Redo\nkeycode 138 = SunProps NoSymbol " b"SunProps\nkeycode 139 = Undo NoSymbol Undo\nkeycode 140 = SunFront NoSymbol SunFront\nkeycode 141 = XF86Copy NoS" b"ymbol XF86Copy\nkeycode 142 = XF86Open NoSymbol XF86Open\nkeycode 143 = XF86Paste NoSymbol XF86Paste\nkeycode 14" b"4 = Find NoSymbol Find\nkeycode 145 = XF86Cut NoSymbol XF86Cut\nkeycode 146 = Help NoSymbol Help\nkeycode 147 = " b"XF86MenuKB NoSymbol XF86MenuKB\nkeycode 148 = XF86Calculator NoSymbol XF86Calculator\nkeycode 149 =\nkeycode 150" b" = XF86Sleep NoSymbol XF86Sleep\nkeycode 151 = XF86WakeUp NoSymbol XF86WakeUp\nkeycode 152 = XF86Explorer NoSymb" b"ol XF86Explorer\nkeycode 153 = XF86Send NoSymbol XF86Send\nkeycode 154 =\nkeycode 155 = XF86Xfer NoSymbol XF86Xf" b"er\nkeycode 156 = XF86Launch1 NoSymbol XF86Launch1\nkeycode 157 = XF86Launch2 NoSymbol XF86Launch2\nkeycode 158 " b"= XF86WWW NoSymbol XF86WWW\nkeycode 159 = XF86DOS NoSymbol XF86DOS\nkeycode 160 = XF86ScreenSaver NoSymbol XF86S" b"creenSaver\nkeycode 161 = XF86RotateWindows NoSymbol XF86RotateWindows\nkeycode 162 = XF86TaskPane NoSymbol XF86" b"TaskPane\nkeycode 163 = XF86Mail NoSymbol XF86Mail\nkeycode 164 = XF86Favorites NoSymbol XF86Favorites\nkeycode " b"165 = XF86MyComputer NoSymbol XF86MyComputer\nkeycode 166 = XF86Back NoSymbol XF86Back\nkeycode 167 = XF86Forwar" b"d NoSymbol XF86Forward\nkeycode 168 =\nkeycode 169 = XF86Eject NoSymbol XF86Eject\nkeycode 170 = XF86Eject XF86E" b"ject XF86Eject XF86Eject\nkeycode 171 = XF86AudioNext NoSymbol XF86AudioNext\nkeycode 172 = XF86AudioPlay XF86Au" b"dioPause XF86AudioPlay XF86AudioPause\nkeycode 173 = XF86AudioPrev NoSymbol XF86AudioPrev\nkeycode 174 = XF86Aud" b"ioStop XF86Eject XF86AudioStop XF86Eject\nkeycode 175 = XF86AudioRecord NoSymbol XF86AudioRecord\nkeycode 176 = " b"XF86AudioRewind NoSymbol XF86AudioRewind\nkeycode 177 = XF86Phone NoSymbol XF86Phone\nkeycode 178 =\nkeycode 179" b" = XF86Tools NoSymbol XF86Tools\nkeycode 180 = XF86HomePage NoSymbol XF86HomePage\nkeycode 181 = XF86Reload NoSy" b"mbol XF86Reload\nkeycode 182 = XF86Close NoSymbol XF86Close\nkeycode 183 =\nkeycode 184 =\nkeycode 185 = XF86Scr" b"ollUp NoSymbol XF86ScrollUp\nkeycode 186 = XF86ScrollDown NoSymbol XF86ScrollDown\nkeycode 187 = parenleft NoSym" b"bol parenleft\nkeycode 188 = parenright NoSymbol parenright\nkeycode 189 = XF86New NoSymbol XF86New\nkeycode 190" b" = Redo NoSymbol Redo\nkeycode 191 = XF86Tools NoSymbol XF86Tools\nkeycode 192 = XF86Launch5 NoSymbol XF86Launch" b"5\nkeycode 193 = XF86Launch6 NoSymbol XF86Launch6\nkeycode 194 = XF86Launch7 NoSymbol XF86Launch7\nkeycode 195 =" b" XF86Launch8 NoSymbol XF86Launch8\nkeycode 196 = XF86Launch9 NoSymbol XF86Launch9\nkeycode 197 =\nkeycode 198 = " b"XF86AudioMicMute NoSymbol XF86AudioMicMute\nkeycode 199 = XF86TouchpadToggle NoSymbol XF86TouchpadToggle\nkeycod" b"e 200 = XF86TouchpadOn NoSymbol XF86TouchpadOn\nkeycode 201 = XF86TouchpadOff NoSymbol XF86TouchpadOff\nkeycode " b"202 =\nkeycode 203 = Mode_switch NoSymbol Mode_switch\nkeycode 204 = NoSymbol Alt_L NoSymbol Alt_L\nkeycode 205 " b"= NoSymbol Meta_L NoSymbol Meta_L\nkeycode 206 = NoSymbol Super_L NoSymbol Super_L\nkeycode 207 = NoSymbol Hyper" b"_L NoSymbol Hyper_L\nkeycode 208 = XF86AudioPlay NoSymbol XF86AudioPlay\nkeycode 209 = XF86AudioPause NoSymbol X" b"F86AudioPause\nkeycode 210 = XF86Launch3 NoSymbol XF86Launch3\nkeycode 211 = XF86Launch4 NoSymbol XF86Launch4\nk" b"eycode 212 = XF86LaunchB NoSymbol XF86LaunchB\nkeycode 213 = XF86Suspend NoSymbol XF86Suspend\nkeycode 214 = XF8" b"6Close NoSymbol XF86Close\nkeycode 215 = XF86AudioPlay NoSymbol XF86AudioPlay\nkeycode 216 = XF86AudioForward No" b"Symbol XF86AudioForward\nkeycode 217 =\nkeycode 218 = Print NoSymbol Print\nkeycode 219 =\nkeycode 220 = XF86Web" b"Cam NoSymbol XF86WebCam\nkeycode 221 = XF86AudioPreset NoSymbol XF86AudioPreset\nkeycode 222 =\nkeycode 223 = XF" b"86Mail NoSymbol XF86Mail\nkeycode 224 = XF86Messenger NoSymbol XF86Messenger\nkeycode 225 = XF86Search NoSymbol " b"XF86Search\nkeycode 226 = XF86Go NoSymbol XF86Go\nkeycode 227 = XF86Finance NoSymbol XF86Finance\nkeycode 228 = " b"XF86Game NoSymbol XF86Game\nkeycode 229 = XF86Shop NoSymbol XF86Shop\nkeycode 230 =\nkeycode 231 = Cancel NoSymb" b"ol Cancel\nkeycode 232 = XF86MonBrightnessDown NoSymbol XF86MonBrightnessDown\nkeycode 233 = XF86MonBrightnessUp" b" NoSymbol XF86MonBrightnessUp\nkeycode 234 = XF86AudioMedia NoSymbol XF86AudioMedia\nkeycode 235 = XF86Display N" b"oSymbol XF86Display\nkeycode 236 = XF86KbdLightOnOff NoSymbol XF86KbdLightOnOff\nkeycode 237 = XF86KbdBrightness" b"Down NoSymbol XF86KbdBrightnessDown\nkeycode 238 = XF86KbdBrightnessUp NoSymbol XF86KbdBrightnessUp\nkeycode 239" b" = XF86Send NoSymbol XF86Send\nkeycode 240 = XF86Reply NoSymbol XF86Reply\nkeycode 241 = XF86MailForward NoSymbo" b"l XF86MailForward\nkeycode 242 = XF86Save NoSymbol XF86Save\nkeycode 243 = XF86Documents NoSymbol XF86Documents" b"\nkeycode 244 = XF86Battery NoSymbol XF86Battery\nkeycode 245 = XF86Bluetooth NoSymbol XF86Bluetooth\nkeycode 24" b"6 = XF86WLAN NoSymbol XF86WLAN\nkeycode 247 =\nkeycode 248 =\nkeycode 249 =\nkeycode 250 =\nkeycode 251 = XF86Mo" b"nBrightnessCycle NoSymbol XF86MonBrightnessCycle\nkeycode 252 =\nkeycode 253 =\nkeycode 254 = XF86WWAN NoSymbol " b"XF86WWAN\nkeycode 255 = XF86RFKill NoSymbol XF86RFKill\n" ) input-remapper-2.1.1/tests/unit/000077500000000000000000000000001475433465200166215ustar00rootroot00000000000000input-remapper-2.1.1/tests/unit/__init__.py000066400000000000000000000000711475433465200207300ustar00rootroot00000000000000"""Tests that don't require a complete linux desktop.""" input-remapper-2.1.1/tests/unit/test_config.py000066400000000000000000000124331475433465200215020ustar00rootroot00000000000000#!/usr/bin/env python3 # -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings # Copyright (C) 2025 sezanzeb # # This file is part of input-remapper. # # input-remapper is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # input-remapper is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with input-remapper. If not, see . import os import unittest from inputremapper.configs.global_config import GlobalConfig from inputremapper.configs.paths import PathUtils from tests.lib.test_setup import test_setup from tests.lib.tmp import tmp @test_setup class TestConfig(unittest.TestCase): def test_basic(self): global_config = GlobalConfig() self.assertEqual(global_config.get("a"), None) global_config.set("a", 1) self.assertEqual(global_config.get("a"), 1) global_config.remove("a") global_config.set("a.b", 2) self.assertEqual(global_config.get("a.b"), 2) self.assertEqual(global_config._config["a"]["b"], 2) global_config.remove("a.b") global_config.set("a.b.c", 3) self.assertEqual(global_config.get("a.b.c"), 3) self.assertEqual(global_config._config["a"]["b"]["c"], 3) def test_autoload(self): global_config = GlobalConfig() self.assertEqual(len(global_config.iterate_autoload_presets()), 0) self.assertFalse(global_config.is_autoloaded("d1", "a")) self.assertFalse(global_config.is_autoloaded("d2.foo", "b")) self.assertEqual(global_config.get(["autoload", "d1"]), None) self.assertEqual(global_config.get(["autoload", "d2.foo"]), None) global_config.set_autoload_preset("d1", "a") self.assertEqual(len(global_config.iterate_autoload_presets()), 1) self.assertTrue(global_config.is_autoloaded("d1", "a")) self.assertFalse(global_config.is_autoloaded("d2.foo", "b")) global_config.set_autoload_preset("d2.foo", "b") self.assertEqual(len(global_config.iterate_autoload_presets()), 2) self.assertTrue(global_config.is_autoloaded("d1", "a")) self.assertTrue(global_config.is_autoloaded("d2.foo", "b")) self.assertEqual(global_config.get(["autoload", "d1"]), "a") self.assertEqual(global_config.get("autoload.d1"), "a") self.assertEqual(global_config.get(["autoload", "d2.foo"]), "b") global_config.set_autoload_preset("d2.foo", "c") self.assertEqual(len(global_config.iterate_autoload_presets()), 2) self.assertTrue(global_config.is_autoloaded("d1", "a")) self.assertFalse(global_config.is_autoloaded("d2.foo", "b")) self.assertTrue(global_config.is_autoloaded("d2.foo", "c")) self.assertEqual(global_config._config["autoload"]["d2.foo"], "c") self.assertListEqual( list(global_config.iterate_autoload_presets()), [("d1", "a"), ("d2.foo", "c")], ) global_config.set_autoload_preset("d2.foo", None) self.assertTrue(global_config.is_autoloaded("d1", "a")) self.assertFalse(global_config.is_autoloaded("d2.foo", "b")) self.assertFalse(global_config.is_autoloaded("d2.foo", "c")) self.assertListEqual( list(global_config.iterate_autoload_presets()), [("d1", "a")], ) self.assertEqual(global_config.get(["autoload", "d1"]), "a") self.assertRaises(ValueError, global_config.is_autoloaded, "d1", None) self.assertRaises(ValueError, global_config.is_autoloaded, None, "a") def test_initial(self): global_config = GlobalConfig() # when loading for the first time, create a config file with # the default values self.assertFalse(os.path.exists(global_config.path)) global_config.load_config() self.assertTrue(os.path.exists(global_config.path)) with open(global_config.path, "r") as file: contents = file.read() self.assertIn('"autoload": {}', contents) def test_save_load(self): global_config = GlobalConfig() self.assertEqual(len(global_config.iterate_autoload_presets()), 0) global_config.load_config() self.assertEqual(len(global_config.iterate_autoload_presets()), 0) global_config.set_autoload_preset("d1", "a") global_config.set_autoload_preset("d2.foo", "b") global_config.load_config() self.assertListEqual( list(global_config.iterate_autoload_presets()), [("d1", "a"), ("d2.foo", "b")], ) config_2 = os.path.join(tmp, "config_2.json") PathUtils.touch(config_2) with open(config_2, "w") as f: f.write('{"a":"b"}') global_config.load_config(config_2) self.assertEqual(global_config.get("a"), "b") self.assertEqual(global_config.get(["a"]), "b") if __name__ == "__main__": unittest.main() input-remapper-2.1.1/tests/unit/test_context.py000066400000000000000000000112641475433465200217220ustar00rootroot00000000000000#!/usr/bin/env python3 # -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings # Copyright (C) 2025 sezanzeb # # This file is part of input-remapper. # # input-remapper is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # input-remapper is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with input-remapper. If not, see . import unittest from unittest.mock import patch from evdev.ecodes import ( EV_REL, EV_ABS, ABS_X, ABS_Y, REL_WHEEL_HI_RES, REL_HWHEEL_HI_RES, ) from inputremapper.configs.input_config import InputCombination from inputremapper.configs.mapping import Mapping from inputremapper.configs.preset import Preset from inputremapper.injection.context import Context from inputremapper.injection.global_uinputs import GlobalUInputs, UInput from inputremapper.injection.mapping_handlers.macro_handler import MacroHandler from inputremapper.injection.mapping_handlers.mapping_parser import MappingParser from inputremapper.input_event import InputEvent from tests.lib.test_setup import test_setup @test_setup class TestContext(unittest.TestCase): def test_callbacks(self): global_uinputs = GlobalUInputs(UInput) mapping_parser = MappingParser(global_uinputs) preset = Preset() cfg = { "input_combination": InputCombination.from_tuples((EV_ABS, ABS_X)), "target_uinput": "mouse", "output_type": EV_REL, "output_code": REL_HWHEEL_HI_RES, } preset.add(Mapping(**cfg)) # abs x -> wheel cfg["input_combination"] = InputCombination.from_tuples((EV_ABS, ABS_Y)) cfg["output_code"] = REL_WHEEL_HI_RES preset.add(Mapping(**cfg)) # abs y -> wheel preset.add( Mapping.from_combination( InputCombination.from_tuples((1, 31)), "keyboard", "key(a)" ) ) preset.add( Mapping.from_combination( InputCombination.from_tuples((1, 32)), "keyboard", "b" ) ) # overlapping combination for (1, 32, 1) preset.add( Mapping.from_combination( InputCombination.from_tuples((1, 32), (1, 33), (1, 34)), "keyboard", "c", ) ) # map abs x to key "b" preset.add( Mapping.from_combination( InputCombination.from_tuples((EV_ABS, ABS_X, 20)), "keyboard", "d", ), ) context = Context(preset, {}, {}, mapping_parser) expected_num_callbacks = { # ABS_X -> "d" and ABS_X -> wheel have the same type and code InputEvent.abs(ABS_X, 1): 2, InputEvent.abs(ABS_Y, 1): 1, InputEvent.key(31, 1): 1, # even though we have 2 mappings with this type and code, we only expect # one callback because they both map to keys. We don't want to trigger two # mappings with the same key press InputEvent.key(32, 1): 1, InputEvent.key(33, 1): 1, InputEvent.key(34, 1): 1, } self.assertEqual( set([event.input_match_hash for event in expected_num_callbacks.keys()]), set(context._notify_callbacks.keys()), ) for input_event, num_callbacks in expected_num_callbacks.items(): self.assertEqual( num_callbacks, len(context.get_notify_callbacks(input_event)), ) # 7 unique input events in the preset self.assertEqual(7, len(context._handlers)) def test_reset(self): global_uinputs = GlobalUInputs(UInput) mapping_parser = MappingParser(global_uinputs) preset = Preset() preset.add( Mapping.from_combination( InputCombination.from_tuples((1, 31)), "keyboard", "key(a)", ) ) context = Context(preset, {}, {}, mapping_parser) self.assertEqual(1, len(context._handlers)) with patch.object(MacroHandler, "reset") as reset_mock: context.reset() reset_mock.assert_called_once() if __name__ == "__main__": unittest.main() input-remapper-2.1.1/tests/unit/test_control.py000066400000000000000000000335661475433465200217270ustar00rootroot00000000000000#!/usr/bin/env python3 # -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings # Copyright (C) 2025 sezanzeb # # This file is part of input-remapper. # # input-remapper is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # input-remapper is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with input-remapper. If not, see . """Testing the input-remapper-control command""" import collections import os import time import unittest from unittest.mock import patch, MagicMock from inputremapper.bin.input_remapper_control import InputRemapperControlBin from inputremapper.configs.global_config import GlobalConfig from inputremapper.configs.migrations import Migrations from inputremapper.configs.paths import PathUtils from inputremapper.configs.preset import Preset from inputremapper.daemon import Daemon from inputremapper.groups import groups from inputremapper.injection.global_uinputs import GlobalUInputs, FrontendUInput from inputremapper.injection.mapping_handlers.mapping_parser import MappingParser from tests.lib.test_setup import test_setup from tests.lib.tmp import tmp options = collections.namedtuple( "options", ["command", "config_dir", "preset", "device", "list_devices", "key_names", "debug"], ) @test_setup class TestControl(unittest.TestCase): def setUp(self): self.global_config = GlobalConfig() self.global_uinputs = GlobalUInputs(FrontendUInput) self.migrations = Migrations(self.global_uinputs) self.mapping_parser = MappingParser(self.global_uinputs) self.input_remapper_control = InputRemapperControlBin( self.global_config, self.migrations ) def test_autoload(self): device_keys = ["Foo Device 2", "Bar Device"] groups_ = [groups.find(key=key) for key in device_keys] presets = ["bar0", "bar", "bar2"] paths = [ PathUtils.get_preset_path(groups_[0].name, presets[0]), PathUtils.get_preset_path(groups_[1].name, presets[1]), PathUtils.get_preset_path(groups_[1].name, presets[2]), ] Preset(paths[0]).save() Preset(paths[1]).save() Preset(paths[2]).save() daemon = Daemon(self.global_config, self.global_uinputs, self.mapping_parser) self.input_remapper_control.set_daemon(daemon) start_history = [] stop_counter = 0 # using an actual injector is not within the scope of this test class Injector: def stop_injecting(self, *args, **kwargs): nonlocal stop_counter stop_counter += 1 def start_injecting(device: str, preset: str): print(f'\033[90mstart_injecting "{device}" "{preset}"\033[0m') start_history.append((device, preset)) daemon.injectors[device] = Injector() patch.object(daemon, "start_injecting", start_injecting).start() self.global_config.set_autoload_preset(groups_[0].key, presets[0]) self.global_config.set_autoload_preset(groups_[1].key, presets[1]) self.input_remapper_control.communicate( command="autoload", config_dir=None, preset=None, device=None, ) self.assertEqual(len(start_history), 2) self.assertEqual(start_history[0], (groups_[0].key, presets[0])) self.assertEqual(start_history[1], (groups_[1].key, presets[1])) self.assertIn(groups_[0].key, daemon.injectors) self.assertIn(groups_[1].key, daemon.injectors) self.assertFalse( daemon.autoload_history.may_autoload(groups_[0].key, presets[0]) ) self.assertFalse( daemon.autoload_history.may_autoload(groups_[1].key, presets[1]) ) # calling autoload again doesn't load redundantly self.input_remapper_control.communicate( command="autoload", config_dir=None, preset=None, device=None, ) self.assertEqual(len(start_history), 2) self.assertEqual(stop_counter, 0) self.assertFalse( daemon.autoload_history.may_autoload(groups_[0].key, presets[0]) ) self.assertFalse( daemon.autoload_history.may_autoload(groups_[1].key, presets[1]) ) # unless the injection in question ist stopped self.input_remapper_control.communicate( command="stop", config_dir=None, preset=None, device=groups_[0].key, ) self.assertEqual(stop_counter, 1) self.assertTrue( daemon.autoload_history.may_autoload(groups_[0].key, presets[0]) ) self.assertFalse( daemon.autoload_history.may_autoload(groups_[1].key, presets[1]) ) self.input_remapper_control.communicate( command="autoload", config_dir=None, preset=None, device=None, ) self.assertEqual(len(start_history), 3) self.assertEqual(start_history[2], (groups_[0].key, presets[0])) self.assertFalse( daemon.autoload_history.may_autoload(groups_[0].key, presets[0]) ) self.assertFalse( daemon.autoload_history.may_autoload(groups_[1].key, presets[1]) ) # if a device name is passed, will only start injecting for that one self.input_remapper_control.communicate( command="stop-all", config_dir=None, preset=None, device=None, ) self.assertTrue( daemon.autoload_history.may_autoload(groups_[0].key, presets[0]) ) self.assertTrue( daemon.autoload_history.may_autoload(groups_[1].key, presets[1]) ) self.assertEqual(stop_counter, 3) self.global_config.set_autoload_preset(groups_[1].key, presets[2]) self.input_remapper_control.communicate( command="autoload", config_dir=None, preset=None, device=groups_[1].key, ) self.assertEqual(len(start_history), 4) self.assertEqual(start_history[3], (groups_[1].key, presets[2])) self.assertTrue( daemon.autoload_history.may_autoload(groups_[0].key, presets[0]) ) self.assertFalse( daemon.autoload_history.may_autoload(groups_[1].key, presets[2]) ) # autoloading for the same device again redundantly will not autoload # again self.input_remapper_control.communicate( command="autoload", config_dir=None, preset=None, device=groups_[1].key, ) self.assertEqual(len(start_history), 4) self.assertEqual(stop_counter, 3) self.assertFalse( daemon.autoload_history.may_autoload(groups_[1].key, presets[2]) ) # any other arbitrary preset may be autoloaded self.assertTrue(daemon.autoload_history.may_autoload(groups_[1].key, "quuuux")) # after 15 seconds it may be autoloaded again daemon.autoload_history._autoload_history[groups_[1].key] = ( time.time() - 16, presets[2], ) self.assertTrue( daemon.autoload_history.may_autoload(groups_[1].key, presets[2]) ) def test_autoload_other_path(self): device_names = ["Foo Device", "Bar Device"] groups_ = [groups.find(name=name) for name in device_names] presets = ["bar123", "bar2"] config_dir = os.path.join(tmp, "qux", "quux") paths = [ os.path.join(config_dir, "presets", device_names[0], presets[0] + ".json"), os.path.join(config_dir, "presets", device_names[1], presets[1] + ".json"), ] Preset(paths[0]).save() Preset(paths[1]).save() daemon = Daemon(self.global_config, self.global_uinputs, self.mapping_parser) self.input_remapper_control.set_daemon(daemon) start_history = [] daemon.start_injecting = lambda *args: start_history.append(args) self.global_config.path = os.path.join(config_dir, "config.json") self.global_config.load_config() self.global_config.set_autoload_preset(device_names[0], presets[0]) self.global_config.set_autoload_preset(device_names[1], presets[1]) self.input_remapper_control.communicate( command="autoload", config_dir=config_dir, preset=None, device=None, ) self.assertEqual(len(start_history), 2) self.assertEqual(start_history[0], (groups_[0].key, presets[0])) self.assertEqual(start_history[1], (groups_[1].key, presets[1])) def test_start_stop(self): group = groups.find(key="Foo Device 2") preset = "preset9" daemon = Daemon(self.global_config, self.global_uinputs, self.mapping_parser) self.input_remapper_control.set_daemon(daemon) start_history = [] stop_history = [] stop_all_history = [] daemon.start_injecting = lambda *args: start_history.append(args) daemon.stop_injecting = lambda *args: stop_history.append(args) daemon.stop_all = lambda *args: stop_all_history.append(args) self.input_remapper_control.communicate( command="start", config_dir=None, preset=preset, device=group.paths[0], ) self.assertEqual(len(start_history), 1) self.assertEqual(start_history[0], (group.key, preset)) self.input_remapper_control.communicate( command="stop", config_dir=None, preset=None, device=group.paths[1], ) self.assertEqual(len(stop_history), 1) # provided any of the groups paths as --device argument, figures out # the correct group.key to use here self.assertEqual(stop_history[0], (group.key,)) self.input_remapper_control.communicate( command="stop-all", config_dir=None, preset=None, device=None, ) self.assertEqual(len(stop_all_history), 1) self.assertEqual(stop_all_history[0], ()) @patch.object(Daemon, "quit") def test_quit(self, quit_mock: MagicMock) -> None: group = groups.find(key="Foo Device 2") assert group is not None preset = "preset9" daemon = Daemon(self.global_config, self.global_uinputs, self.mapping_parser) self.input_remapper_control.set_daemon(daemon) self.input_remapper_control.communicate( command="quit", config_dir=None, preset=preset, device=group.paths[0], ) quit_mock.assert_called_once() def test_config_not_found(self): key = "Foo Device 2" path = "~/a/preset.json" config_dir = "/foo/bar" daemon = Daemon(self.global_config, self.global_uinputs, self.mapping_parser) self.input_remapper_control.set_daemon(daemon) start_history = [] stop_history = [] daemon.start_injecting = lambda *args: start_history.append(args) daemon.stop_injecting = lambda *args: stop_history.append(args) self.assertRaises( SystemExit, lambda: self.input_remapper_control.communicate( command="start", config_dir=config_dir, preset=path, device=key, ), ) self.assertRaises( SystemExit, lambda: self.input_remapper_control.communicate( command="stop", config_dir=config_dir, preset=None, device=key, ), ) def test_autoload_config_dir(self): daemon = Daemon(self.global_config, self.global_uinputs, self.mapping_parser) path = os.path.join(tmp, "foo") os.makedirs(path) with open(os.path.join(path, "config.json"), "w") as file: file.write('{"foo":"bar"}') self.assertIsNone(self.global_config.get("foo")) daemon.set_config_dir(path) # since daemon and this test share the same memory, the global_config # object that this test can access will be modified self.assertEqual(self.global_config.get("foo"), "bar") # passing a path that doesn't exist or a path that doesn't contain # a config.json file won't do anything os.makedirs(os.path.join(tmp, "bar")) daemon.set_config_dir(os.path.join(tmp, "bar")) self.assertEqual(self.global_config.get("foo"), "bar") daemon.set_config_dir(os.path.join(tmp, "qux")) self.assertEqual(self.global_config.get("foo"), "bar") def test_internals_reader(self): with patch.object(os, "system") as os_system_patch: self.input_remapper_control.internals("start-reader-service", False) os_system_patch.assert_called_once() self.assertIn( "input-remapper-reader-service", os_system_patch.call_args.args[0] ) self.assertNotIn("-d", os_system_patch.call_args.args[0]) def test_internals_daemon(self): with patch.object(os, "system") as os_system_patch: self.input_remapper_control.internals("start-daemon", True) os_system_patch.assert_called_once() self.assertIn("input-remapper-service", os_system_patch.call_args.args[0]) self.assertIn("-d", os_system_patch.call_args.args[0]) if __name__ == "__main__": unittest.main() input-remapper-2.1.1/tests/unit/test_controller.py000066400000000000000000001701451475433465200224250ustar00rootroot00000000000000#!/usr/bin/env python3 # -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings # Copyright (C) 2025 sezanzeb # # This file is part of input-remapper. # # input-remapper is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # input-remapper is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with input-remapper. If not, see . import os.path import unittest from typing import List from unittest.mock import patch, MagicMock, call import gi from evdev.ecodes import EV_ABS, ABS_X, ABS_Y, ABS_RX from inputremapper.configs.keyboard_layout import keyboard_layout from inputremapper.injection.injector import InjectorState gi.require_version("Gtk", "3.0") from gi.repository import Gtk from inputremapper.configs.input_config import InputCombination, InputConfig from inputremapper.groups import _Groups from inputremapper.gui.messages.message_broker import ( MessageBroker, MessageType, ) from inputremapper.gui.messages.message_data import ( GroupsData, GroupData, PresetData, StatusData, CombinationRecorded, CombinationUpdate, UserConfirmRequest, ) from inputremapper.gui.reader_client import ReaderClient from inputremapper.gui.utils import CTX_ERROR, CTX_APPLY, gtk_iteration from inputremapper.gui.gettext import _ from inputremapper.injection.global_uinputs import GlobalUInputs, FrontendUInput from inputremapper.configs.mapping import UIMapping, MappingData, Mapping from tests.lib.spy import spy from tests.lib.patches import FakeDaemonProxy from tests.lib.fixtures import fixtures, prepare_presets from inputremapper.configs.global_config import GlobalConfig from inputremapper.gui.controller import Controller, MAPPING_DEFAULTS from inputremapper.gui.data_manager import DataManager, DEFAULT_PRESET_NAME from inputremapper.configs.paths import PathUtils from inputremapper.configs.preset import Preset from tests.lib.test_setup import test_setup @test_setup class TestController(unittest.TestCase): def setUp(self) -> None: super().setUp() self.message_broker = MessageBroker() uinputs = GlobalUInputs(FrontendUInput) uinputs.prepare_all() self.data_manager = DataManager( self.message_broker, GlobalConfig(), ReaderClient(self.message_broker, _Groups()), FakeDaemonProxy(), uinputs, keyboard_layout, ) self.user_interface = MagicMock() self.controller = Controller(self.message_broker, self.data_manager) self.controller.set_gui(self.user_interface) def test_should_get_newest_group(self): """get_a_group should the newest group.""" with patch.object( self.data_manager, "get_newest_group_key", MagicMock(return_value="foo") ): self.assertEqual(self.controller.get_a_group(), "foo") def test_should_get_any_group(self): """get_a_group should return a valid group.""" with patch.object( self.data_manager, "get_newest_group_key", MagicMock(side_effect=FileNotFoundError), ): fixture_keys = [fixture.group_key or fixture.name for fixture in fixtures] self.assertIn(self.controller.get_a_group(), fixture_keys) def test_should_get_newest_preset(self): """get_a_group should the newest group.""" with patch.object( self.data_manager, "get_newest_preset_name", MagicMock(return_value="bar") ): self.data_manager.load_group("Foo Device") self.assertEqual(self.controller.get_a_preset(), "bar") def test_should_get_any_preset(self): """get_a_preset should return a new preset if none exist.""" self.data_manager.load_group("Foo Device") # the default name self.assertEqual(self.controller.get_a_preset(), "new preset") def test_on_init_should_provide_uinputs(self): calls = [] def f(data): calls.append(data) self.message_broker.subscribe(MessageType.uinputs, f) self.message_broker.signal(MessageType.init) self.assertEqual( ["keyboard", "gamepad", "mouse", "keyboard + mouse"], list(calls[-1].uinputs.keys()), ) def test_on_init_should_provide_groups(self): calls: List[GroupsData] = [] def f(groups): calls.append(groups) self.message_broker.subscribe(MessageType.groups, f) self.message_broker.signal(MessageType.init) self.assertEqual( ["Foo Device", "Foo Device 2", "Bar Device", "gamepad", "Qux/[Device]?"], list(calls[-1].groups.keys()), ) def test_on_init_should_provide_a_group(self): calls: List[GroupData] = [] def f(data): calls.append(data) self.message_broker.subscribe(MessageType.group, f) self.message_broker.signal(MessageType.init) self.assertGreaterEqual(len(calls), 1) def test_on_init_should_provide_a_preset(self): calls: List[PresetData] = [] def f(data): calls.append(data) self.message_broker.subscribe(MessageType.preset, f) self.message_broker.signal(MessageType.init) self.assertGreaterEqual(len(calls), 1) def test_on_init_should_provide_a_mapping(self): """Only if there is one.""" prepare_presets() calls: List[MappingData] = [] def f(data): calls.append(data) self.message_broker.subscribe(MessageType.mapping, f) self.message_broker.signal(MessageType.init) self.assertTrue(calls[-1].is_valid()) def test_on_init_should_provide_a_default_mapping(self): """If there is no real preset available""" calls: List[MappingData] = [] def f(data): calls.append(data) self.message_broker.subscribe(MessageType.mapping, f) self.message_broker.signal(MessageType.init) for m in calls: self.assertEqual(m, UIMapping(**MAPPING_DEFAULTS)) def test_on_load_group_should_provide_preset(self): with patch.object(self.data_manager, "load_preset") as mock: self.controller.load_group("Foo Device") mock.assert_called_once() def test_on_load_group_should_provide_mapping(self): """If there is one""" prepare_presets() calls: List[MappingData] = [] def f(data): calls.append(data) self.message_broker.subscribe(MessageType.mapping, f) self.controller.load_group(group_key="Foo Device 2") self.assertTrue(calls[-1].is_valid()) def test_on_load_group_should_provide_default_mapping(self): """If there is none.""" calls: List[MappingData] = [] def f(data): calls.append(data) self.message_broker.subscribe(MessageType.mapping, f) self.controller.load_group(group_key="Foo Device") for m in calls: self.assertEqual(m, UIMapping(**MAPPING_DEFAULTS)) def test_on_load_preset_should_provide_mapping(self): """If there is one.""" prepare_presets() self.data_manager.load_group("Foo Device 2") calls: List[MappingData] = [] def f(data): calls.append(data) self.message_broker.subscribe(MessageType.mapping, f) self.controller.load_preset(name="preset2") self.assertTrue(calls[-1].is_valid()) def test_on_load_preset_should_provide_default_mapping(self): """If there is none.""" Preset(PathUtils.get_preset_path("Foo Device", "bar")).save() self.data_manager.load_group("Foo Device 2") calls: List[MappingData] = [] def f(data): calls.append(data) self.message_broker.subscribe(MessageType.mapping, f) self.controller.load_preset(name="bar") for m in calls: self.assertEqual(m, UIMapping(**MAPPING_DEFAULTS)) def test_on_delete_preset_asks_for_confirmation(self): prepare_presets() self.message_broker.signal(MessageType.init) mock = MagicMock() self.message_broker.subscribe(MessageType.user_confirm_request, mock) self.controller.delete_preset() mock.assert_called_once() def test_deletes_preset_when_confirmed(self): prepare_presets() self.assertTrue( os.path.isfile(PathUtils.get_preset_path("Foo Device", "preset2")) ) self.data_manager.load_group("Foo Device 2") self.data_manager.load_preset("preset2") self.message_broker.subscribe( MessageType.user_confirm_request, lambda msg: msg.respond(True) ) self.controller.delete_preset() self.assertFalse( os.path.exists(PathUtils.get_preset_path("Foo Device", "preset2")) ) def test_does_not_delete_preset_when_not_confirmed(self): prepare_presets() self.assertTrue( os.path.isfile(PathUtils.get_preset_path("Foo Device", "preset2")) ) self.data_manager.load_group("Foo Device 2") self.data_manager.load_preset("preset2") self.user_interface.confirm_delete.configure_mock( return_value=Gtk.ResponseType.CANCEL ) self.controller.delete_preset() self.assertTrue( os.path.isfile(PathUtils.get_preset_path("Foo Device", "preset2")) ) def test_copy_preset(self): prepare_presets() self.assertTrue( os.path.isfile(PathUtils.get_preset_path("Foo Device", "preset2")) ) self.data_manager.load_group("Foo Device 2") self.data_manager.load_preset("preset2") self.controller.copy_preset() self.assertTrue( os.path.exists(PathUtils.get_preset_path("Foo Device", "preset2")) ) self.assertTrue( os.path.exists(PathUtils.get_preset_path("Foo Device", "preset2 copy")) ) def test_copy_preset_should_add_number(self): prepare_presets() self.assertTrue( os.path.isfile(PathUtils.get_preset_path("Foo Device", "preset2")) ) self.data_manager.load_group("Foo Device 2") self.data_manager.load_preset("preset2") self.controller.copy_preset() # creates "preset2 copy" self.data_manager.load_preset("preset2") self.controller.copy_preset() # creates "preset2 copy 2" self.assertTrue( os.path.exists(PathUtils.get_preset_path("Foo Device", "preset2")) ) self.assertTrue( os.path.exists(PathUtils.get_preset_path("Foo Device", "preset2 copy")) ) self.assertTrue( os.path.exists(PathUtils.get_preset_path("Foo Device", "preset2 copy 2")) ) def test_copy_preset_should_increment_existing_number(self): prepare_presets() self.assertTrue( os.path.isfile(PathUtils.get_preset_path("Foo Device", "preset2")) ) self.data_manager.load_group("Foo Device 2") self.data_manager.load_preset("preset2") self.controller.copy_preset() # creates "preset2 copy" self.data_manager.load_preset("preset2") self.controller.copy_preset() # creates "preset2 copy 2" self.data_manager.load_preset("preset2") self.controller.copy_preset() # creates "preset2 copy 3" self.assertTrue( os.path.exists(PathUtils.get_preset_path("Foo Device", "preset2")) ) self.assertTrue( os.path.exists(PathUtils.get_preset_path("Foo Device", "preset2 copy")) ) self.assertTrue( os.path.exists(PathUtils.get_preset_path("Foo Device", "preset2 copy 2")) ) self.assertTrue( os.path.exists(PathUtils.get_preset_path("Foo Device", "preset2 copy 3")) ) def test_copy_preset_should_not_append_copy_twice(self): prepare_presets() self.assertTrue( os.path.isfile(PathUtils.get_preset_path("Foo Device", "preset2")) ) self.data_manager.load_group("Foo Device 2") self.data_manager.load_preset("preset2") self.controller.copy_preset() # creates "preset2 copy" self.controller.copy_preset() # creates "preset2 copy 2" not "preset2 copy copy" self.assertTrue( os.path.exists(PathUtils.get_preset_path("Foo Device", "preset2")) ) self.assertTrue( os.path.exists(PathUtils.get_preset_path("Foo Device", "preset2 copy")) ) self.assertTrue( os.path.exists(PathUtils.get_preset_path("Foo Device", "preset2 copy 2")) ) def test_copy_preset_should_not_append_copy_to_copy_with_number(self): prepare_presets() self.assertTrue( os.path.isfile(PathUtils.get_preset_path("Foo Device", "preset2")) ) self.data_manager.load_group("Foo Device 2") self.data_manager.load_preset("preset2") self.controller.copy_preset() # creates "preset2 copy" self.data_manager.load_preset("preset2") self.controller.copy_preset() # creates "preset2 copy 2" self.controller.copy_preset() # creates "preset2 copy 3" not "preset2 copy 2 copy" self.assertTrue( os.path.exists(PathUtils.get_preset_path("Foo Device", "preset2")) ) self.assertTrue( os.path.exists(PathUtils.get_preset_path("Foo Device", "preset2 copy")) ) self.assertTrue( os.path.exists(PathUtils.get_preset_path("Foo Device", "preset2 copy 2")) ) self.assertTrue( os.path.exists(PathUtils.get_preset_path("Foo Device", "preset2 copy 3")) ) def test_rename_preset(self): prepare_presets() self.assertTrue( os.path.isfile(PathUtils.get_preset_path("Foo Device", "preset2")) ) self.assertFalse(os.path.exists(PathUtils.get_preset_path("Foo Device", "foo"))) self.data_manager.load_group("Foo Device 2") self.data_manager.load_preset("preset2") self.controller.rename_preset(new_name="foo") self.assertFalse( os.path.exists(PathUtils.get_preset_path("Foo Device", "preset2")) ) self.assertTrue(os.path.exists(PathUtils.get_preset_path("Foo Device", "foo"))) def test_rename_preset_sanitized(self): Preset(PathUtils.get_preset_path("Qux/[Device]?", "bla")).save() self.assertTrue( os.path.isfile(PathUtils.get_preset_path("Qux/[Device]?", "bla")) ) self.assertFalse( os.path.exists(PathUtils.get_preset_path("Qux/[Device]?", "blubb")) ) self.data_manager.load_group("Qux/[Device]?") self.data_manager.load_preset("bla") self.controller.rename_preset(new_name="foo:/bar") # all functions expect the true name, which is also shown to the user, but on # the file system it always uses sanitized names. self.assertTrue( os.path.exists(PathUtils.get_preset_path("Qux/[Device]?", "foo__bar")) ) # since the name is never stored in an un-sanitized way, this can't work self.assertFalse( os.path.exists(PathUtils.get_preset_path("Qux/[Device]?", "foo:/bar")) ) path = os.path.join( PathUtils.config_path(), "presets", "Qux_[Device]_", "foo__bar.json" ) self.assertTrue(os.path.exists(path)) # using the sanitized name in function calls works as well self.assertTrue( os.path.isfile(PathUtils.get_preset_path("Qux_[Device]_", "foo__bar")) ) def test_rename_preset_should_pick_available_name(self): prepare_presets() self.assertTrue( os.path.exists(PathUtils.get_preset_path("Foo Device", "preset2")) ) self.assertTrue( os.path.exists(PathUtils.get_preset_path("Foo Device", "preset3")) ) self.assertFalse( os.path.exists(PathUtils.get_preset_path("Foo Device", "preset3 2")) ) self.data_manager.load_group("Foo Device") self.data_manager.load_preset("preset2") self.controller.rename_preset(new_name="preset3") self.assertFalse( os.path.exists(PathUtils.get_preset_path("Foo Device", "preset2")) ) self.assertTrue( os.path.exists(PathUtils.get_preset_path("Foo Device", "preset3")) ) self.assertTrue( os.path.exists(PathUtils.get_preset_path("Foo Device", "preset3 2")) ) def test_rename_preset_should_not_rename_to_empty_name(self): prepare_presets() self.assertTrue( os.path.exists(PathUtils.get_preset_path("Foo Device", "preset2")) ) self.data_manager.load_group("Foo Device") self.data_manager.load_preset("preset2") self.controller.rename_preset(new_name="") self.assertTrue( os.path.exists(PathUtils.get_preset_path("Foo Device", "preset2")) ) def test_rename_preset_should_not_update_same_name(self): """When the new name is the same as the current name.""" prepare_presets() self.assertTrue( os.path.exists(PathUtils.get_preset_path("Foo Device", "preset2")) ) self.data_manager.load_group("Foo Device 2") self.data_manager.load_preset("preset2") self.controller.rename_preset(new_name="preset2") self.assertTrue( os.path.exists(PathUtils.get_preset_path("Foo Device", "preset2")) ) self.assertFalse( os.path.exists(PathUtils.get_preset_path("Foo Device", "preset2 2")) ) def test_on_add_preset_uses_default_name(self): self.assertFalse( os.path.exists(PathUtils.get_preset_path("Foo Device", DEFAULT_PRESET_NAME)) ) self.data_manager.load_group("Foo Device 2") self.controller.add_preset() self.assertTrue( os.path.exists(PathUtils.get_preset_path("Foo Device", "new preset")) ) def test_on_add_preset_uses_provided_name(self): self.assertFalse(os.path.exists(PathUtils.get_preset_path("Foo Device", "foo"))) self.data_manager.load_group("Foo Device 2") self.controller.add_preset(name="foo") self.assertTrue(os.path.exists(PathUtils.get_preset_path("Foo Device", "foo"))) def test_on_add_preset_shows_permission_error_status(self): self.data_manager.load_group("Foo Device 2") msg = None def f(data): nonlocal msg msg = data self.message_broker.subscribe(MessageType.status_msg, f) mock = MagicMock(side_effect=PermissionError) with patch("inputremapper.configs.preset.Preset.save", mock): self.controller.add_preset("foo") mock.assert_called() self.assertIsNotNone(msg) self.assertIn("Permission denied", msg.msg) def test_on_update_mapping(self): """Update_mapping should call data_manager.update_mapping. This ensures mapping_changed is emitted. """ prepare_presets() self.data_manager.load_group("Foo Device 2") self.data_manager.load_preset("preset2") self.data_manager.load_mapping( combination=InputCombination([InputConfig(type=1, code=4)]) ) with patch.object(self.data_manager, "update_mapping") as mock: self.controller.update_mapping( name="foo", output_symbol="f", release_timeout=0.3, ) mock.assert_called_once() def test_create_mapping_will_load_the_created_mapping(self): prepare_presets() self.data_manager.load_group("Foo Device 2") self.data_manager.load_preset("preset2") calls: List[MappingData] = [] def f(data): calls.append(data) self.message_broker.subscribe(MessageType.mapping, f) self.controller.create_mapping() self.assertEqual(calls[-1], UIMapping(**MAPPING_DEFAULTS)) def test_create_mapping_should_not_create_multiple_empty_mappings(self): prepare_presets() self.data_manager.load_group("Foo Device 2") self.data_manager.load_preset("preset2") self.controller.create_mapping() # create a first empty mapping calls = [] def f(data): calls.append(data) self.message_broker.subscribe(MessageType.mapping, f) self.message_broker.subscribe(MessageType.preset, f) self.controller.create_mapping() # try to create a second one self.assertEqual(len(calls), 0) def test_delete_mapping_asks_for_confirmation(self): prepare_presets() self.message_broker.signal(MessageType.init) mock = MagicMock() self.message_broker.subscribe(MessageType.user_confirm_request, mock) self.controller.delete_mapping() mock.assert_called_once() def test_deletes_mapping_when_confirmed(self): prepare_presets() self.assertTrue( os.path.isfile(PathUtils.get_preset_path("Foo Device", "preset2")) ) self.data_manager.load_group("Foo Device 2") self.data_manager.load_preset("preset2") self.data_manager.load_mapping(InputCombination([InputConfig(type=1, code=3)])) self.message_broker.subscribe( MessageType.user_confirm_request, lambda msg: msg.respond(True) ) self.controller.delete_mapping() self.controller.save() preset = Preset(PathUtils.get_preset_path("Foo Device", "preset2")) preset.load() self.assertIsNone( preset.get_mapping(InputCombination([InputConfig(type=1, code=3)])) ) def test_does_not_delete_mapping_when_not_confirmed(self): prepare_presets() self.assertTrue( os.path.isfile(PathUtils.get_preset_path("Foo Device", "preset2")) ) self.data_manager.load_group("Foo Device 2") self.data_manager.load_preset("preset2") self.data_manager.load_mapping(InputCombination([InputConfig(type=1, code=3)])) self.user_interface.confirm_delete.configure_mock( return_value=Gtk.ResponseType.CANCEL ) self.controller.delete_mapping() self.controller.save() preset = Preset(PathUtils.get_preset_path("Foo Device", "preset2")) preset.load() self.assertIsNotNone( preset.get_mapping(InputCombination([InputConfig(type=1, code=3)])) ) def test_should_update_combination(self): """When combination is free.""" prepare_presets() self.data_manager.load_group("Foo Device 2") self.data_manager.load_preset("preset2") self.data_manager.load_mapping(InputCombination([InputConfig(type=1, code=3)])) calls: List[CombinationUpdate] = [] def f(data): calls.append(data) self.message_broker.subscribe(MessageType.combination_update, f) self.controller.update_combination( InputCombination([InputConfig(type=1, code=10)]) ) self.assertEqual( calls[0], CombinationUpdate( InputCombination([InputConfig(type=1, code=3)]), InputCombination([InputConfig(type=1, code=10)]), ), ) def test_should_not_update_combination(self): """When combination is already used.""" prepare_presets() self.data_manager.load_group("Foo Device 2") self.data_manager.load_preset("preset2") self.data_manager.load_mapping(InputCombination([InputConfig(type=1, code=3)])) calls: List[CombinationUpdate] = [] def f(data): calls.append(data) self.message_broker.subscribe(MessageType.combination_update, f) self.controller.update_combination( InputCombination([InputConfig(type=1, code=4)]) ) self.assertEqual(len(calls), 0) def test_sets_input_to_analog(self): prepare_presets() input_config = InputConfig(type=EV_ABS, code=ABS_RX) self.data_manager.load_group("Foo Device 2") self.data_manager.load_preset("preset2") self.data_manager.active_preset.add( Mapping( input_combination=InputCombination([input_config]), output_type=EV_ABS, output_code=ABS_X, target_uinput="gamepad", ) ) self.data_manager.load_mapping(InputCombination([input_config])) self.controller.start_key_recording() self.message_broker.publish( CombinationRecorded( InputCombination( [ InputConfig( type=EV_ABS, code=ABS_Y, analog_threshold=50, ), InputConfig( type=EV_ABS, code=ABS_RX, analog_threshold=60, ), ] ) ) ) # the analog_threshold is removed automatically, otherwise the mapping doesn't # make sense because only analog inputs can map to analog outputs. # This is indicated by is_analog_output being true. self.assertTrue(self.controller.data_manager.active_mapping.is_analog_output()) # only the first input is modified active_mapping = self.controller.data_manager.active_mapping self.assertEqual(active_mapping.input_combination[0].analog_threshold, None) self.assertEqual(active_mapping.input_combination[1].analog_threshold, 60) def test_key_recording_disables_gui_shortcuts(self): self.message_broker.signal(MessageType.init) self.user_interface.disconnect_shortcuts.assert_not_called() self.controller.start_key_recording() self.user_interface.disconnect_shortcuts.assert_called_once() def test_key_recording_enables_gui_shortcuts_when_finished(self): self.message_broker.signal(MessageType.init) self.controller.start_key_recording() self.user_interface.connect_shortcuts.assert_not_called() self.message_broker.signal(MessageType.recording_finished) self.user_interface.connect_shortcuts.assert_called_once() def test_key_recording_enables_gui_shortcuts_when_stopped(self): self.message_broker.signal(MessageType.init) self.controller.start_key_recording() self.user_interface.connect_shortcuts.assert_not_called() self.controller.stop_key_recording() self.user_interface.connect_shortcuts.assert_called_once() def test_recording_messages(self): mock1 = MagicMock() mock2 = MagicMock() self.message_broker.subscribe(MessageType.recording_started, mock1) self.message_broker.subscribe(MessageType.recording_finished, mock2) self.message_broker.signal(MessageType.init) self.controller.start_key_recording() mock1.assert_called_once() mock2.assert_not_called() self.controller.stop_key_recording() mock1.assert_called_once() mock2.assert_called_once() def test_key_recording_updates_mapping_combination(self): prepare_presets() self.data_manager.load_group("Foo Device 2") self.data_manager.load_preset("preset2") self.data_manager.load_mapping(InputCombination([InputConfig(type=1, code=3)])) calls: List[CombinationUpdate] = [] def f(data): calls.append(data) self.message_broker.subscribe(MessageType.combination_update, f) self.controller.start_key_recording() self.message_broker.publish( CombinationRecorded(InputCombination([InputConfig(type=1, code=10)])) ) self.assertEqual( calls[0], CombinationUpdate( InputCombination([InputConfig(type=1, code=3)]), InputCombination([InputConfig(type=1, code=10)]), ), ) self.message_broker.publish( CombinationRecorded( InputCombination(InputCombination.from_tuples((1, 10), (1, 3))) ) ) self.assertEqual( calls[1], CombinationUpdate( InputCombination([InputConfig(type=1, code=10)]), InputCombination(InputCombination.from_tuples((1, 10), (1, 3))), ), ) def test_no_key_recording_when_not_started(self): prepare_presets() self.data_manager.load_group("Foo Device 2") self.data_manager.load_preset("preset2") self.data_manager.load_mapping(InputCombination([InputConfig(type=1, code=3)])) calls: List[CombinationUpdate] = [] def f(data): calls.append(data) self.message_broker.subscribe(MessageType.combination_update, f) self.message_broker.publish( CombinationRecorded(InputCombination([InputConfig(type=1, code=10)])) ) self.assertEqual(len(calls), 0) def test_key_recording_stops_when_finished(self): prepare_presets() self.data_manager.load_group("Foo Device 2") self.data_manager.load_preset("preset2") self.data_manager.load_mapping(InputCombination([InputConfig(type=1, code=3)])) calls: List[CombinationUpdate] = [] def f(data): calls.append(data) self.message_broker.subscribe(MessageType.combination_update, f) self.controller.start_key_recording() self.message_broker.publish( CombinationRecorded(InputCombination([InputConfig(type=1, code=10)])) ) self.message_broker.signal(MessageType.recording_finished) self.message_broker.publish( CombinationRecorded( InputCombination(InputCombination.from_tuples((1, 10), (1, 3))) ) ) self.assertEqual(len(calls), 1) # only the first was processed def test_key_recording_stops_when_stopped(self): prepare_presets() self.data_manager.load_group("Foo Device 2") self.data_manager.load_preset("preset2") self.data_manager.load_mapping(InputCombination([InputConfig(type=1, code=3)])) calls: List[CombinationUpdate] = [] def f(data): calls.append(data) self.message_broker.subscribe(MessageType.combination_update, f) self.controller.start_key_recording() self.message_broker.publish( CombinationRecorded(InputCombination([InputConfig(type=1, code=10)])) ) self.controller.stop_key_recording() self.message_broker.publish( CombinationRecorded( InputCombination(InputCombination.from_tuples((1, 10), (1, 3))) ) ) self.assertEqual(len(calls), 1) # only the first was processed def test_start_injecting_shows_status_when_preset_empty(self): self.data_manager.load_group("Foo Device 2") self.data_manager.create_preset("foo") self.data_manager.load_preset("foo") calls: List[StatusData] = [] def f(data): calls.append(data) self.message_broker.subscribe(MessageType.status_msg, f) def f2(): raise AssertionError("Injection started unexpectedly") self.data_manager.start_injecting = f2 self.controller.start_injecting() self.assertEqual( calls[-1], StatusData(CTX_ERROR, _("You need to add mappings first")) ) def test_start_injecting_warns_about_btn_left(self): self.data_manager.load_group("Foo Device 2") self.data_manager.create_preset("foo") self.data_manager.load_preset("foo") self.data_manager.create_mapping() self.data_manager.update_mapping( input_combination=InputCombination([InputConfig.btn_left()]), target_uinput="keyboard", output_symbol="a", ) calls: List[StatusData] = [] def f(data): calls.append(data) self.message_broker.subscribe(MessageType.status_msg, f) def f2(): raise AssertionError("Injection started unexpectedly") self.data_manager.start_injecting = f2 self.controller.start_injecting() self.assertEqual(calls[-1].ctx_id, CTX_ERROR) self.assertIn("BTN_LEFT", calls[-1].tooltip) def test_start_injecting_starts_with_btn_left_on_second_try(self): self.data_manager.load_group("Foo Device 2") self.data_manager.create_preset("foo") self.data_manager.load_preset("foo") self.data_manager.create_mapping() self.data_manager.update_mapping( input_combination=InputCombination([InputConfig.btn_left()]), target_uinput="keyboard", output_symbol="a", ) with patch.object(self.data_manager, "start_injecting") as mock: self.controller.start_injecting() mock.assert_not_called() self.controller.start_injecting() mock.assert_called_once() def test_start_injecting_starts_with_btn_left_when_mapped_to_other_button(self): self.data_manager.load_group("Foo Device 2") self.data_manager.create_preset("foo") self.data_manager.load_preset("foo") self.data_manager.create_mapping() self.data_manager.update_mapping( input_combination=InputCombination([InputConfig.btn_left()]), target_uinput="keyboard", output_symbol="a", ) self.data_manager.create_mapping() self.data_manager.load_mapping(InputCombination.empty_combination()) self.data_manager.update_mapping( input_combination=InputCombination([InputConfig(type=1, code=5)]), target_uinput="mouse", output_symbol="BTN_LEFT", ) mock = MagicMock(return_value=True) self.data_manager.start_injecting = mock self.controller.start_injecting() mock.assert_called() def test_start_injecting_shows_status(self): prepare_presets() self.data_manager.load_group("Foo Device 2") self.data_manager.load_preset("preset2") calls: List[StatusData] = [] def f(data): calls.append(data) self.message_broker.subscribe(MessageType.status_msg, f) mock = MagicMock(return_value=True) self.data_manager.start_injecting = mock self.controller.start_injecting() mock.assert_called() self.assertEqual(calls[0], StatusData(CTX_APPLY, _("Starting injection..."))) def test_start_injecting_shows_failure_status(self): prepare_presets() self.data_manager.load_group("Foo Device 2") self.data_manager.load_preset("preset2") calls: List[StatusData] = [] def f(data): calls.append(data) self.message_broker.subscribe(MessageType.status_msg, f) mock = MagicMock(return_value=False) self.data_manager.start_injecting = mock self.controller.start_injecting() mock.assert_called() self.assertEqual( calls[-1], StatusData( CTX_APPLY, _('Failed to apply preset "%s"') % self.data_manager.active_preset.name, ), ) def test_start_injecting_adds_listener_to_update_injector_status(self): prepare_presets() self.data_manager.load_group("Foo Device 2") self.data_manager.load_preset("preset2") with patch.object(self.message_broker, "subscribe") as mock: self.controller.start_injecting() mock.assert_called_once_with( MessageType.injector_state, self.controller.show_injector_result ) def test_stop_injecting_shows_status(self): prepare_presets() self.data_manager.load_group("Foo Device 2") self.data_manager.load_preset("preset2") calls: List[StatusData] = [] def f(data): calls.append(data) self.message_broker.subscribe(MessageType.status_msg, f) mock = MagicMock(return_value=InjectorState.STOPPED) self.data_manager.get_state = mock self.controller.stop_injecting() gtk_iteration(50) mock.assert_called() self.assertEqual(calls[-1], StatusData(CTX_APPLY, _("Stopped the injection"))) def test_show_injection_result(self): prepare_presets() self.data_manager.load_group("Foo Device 2") self.data_manager.load_preset("preset2") mock = MagicMock(return_value=InjectorState.RUNNING) self.data_manager.get_state = mock calls: List[StatusData] = [] def f(data): calls.append(data) self.message_broker.subscribe(MessageType.status_msg, f) self.controller.start_injecting() gtk_iteration(50) self.assertEqual(calls[-1].msg, _('Applied preset "%s"') % "preset2") mock.return_value = InjectorState.ERROR self.controller.start_injecting() gtk_iteration(50) self.assertEqual(calls[-1].msg, _('Error applying preset "%s"') % "preset2") mock.return_value = InjectorState.NO_GRAB self.controller.start_injecting() gtk_iteration(50) self.assertEqual(calls[-1].msg, _('Failed to apply preset "%s"') % "preset2") mock.return_value = InjectorState.UPGRADE_EVDEV self.controller.start_injecting() gtk_iteration(50) self.assertEqual(calls[-1].msg, "Upgrade python-evdev") def test_close(self): mock_save = MagicMock() listener = MagicMock() self.message_broker.subscribe(MessageType.terminate, listener) self.data_manager.save = mock_save self.controller.close() mock_save.assert_called() listener.assert_called() def test_set_autoload_refreshes_service_config(self): prepare_presets() self.data_manager.load_group("Foo Device 2") self.data_manager.load_preset("preset2") with patch.object(self.data_manager, "refresh_service_config_path") as mock: self.controller.set_autoload(True) mock.assert_called() def test_move_event_up(self): prepare_presets() self.data_manager.load_group("Foo Device 2") self.data_manager.load_preset("preset2") self.data_manager.load_mapping( InputCombination(InputCombination.from_tuples((1, 3))) ) self.data_manager.update_mapping( input_combination=InputCombination( InputCombination.from_tuples((1, 1), (1, 2), (1, 3)) ) ) self.controller.move_input_config_in_combination( InputConfig(type=1, code=2), "up" ) self.assertEqual( self.data_manager.active_mapping.input_combination, InputCombination(InputCombination.from_tuples((1, 2), (1, 1), (1, 3))), ) # now nothing changes self.controller.move_input_config_in_combination( InputConfig(type=1, code=2), "up" ) self.assertEqual( self.data_manager.active_mapping.input_combination, InputCombination(InputCombination.from_tuples((1, 2), (1, 1), (1, 3))), ) def test_move_event_down(self): prepare_presets() self.data_manager.load_group("Foo Device 2") self.data_manager.load_preset("preset2") self.data_manager.load_mapping( InputCombination(InputCombination.from_tuples((1, 3))) ) self.data_manager.update_mapping( input_combination=InputCombination( InputCombination.from_tuples((1, 1), (1, 2), (1, 3)) ) ) self.controller.move_input_config_in_combination( InputConfig(type=1, code=2), "down" ) self.assertEqual( self.data_manager.active_mapping.input_combination, InputCombination(InputCombination.from_tuples((1, 1), (1, 3), (1, 2))), ) # now nothing changes self.controller.move_input_config_in_combination( InputConfig(type=1, code=2), "down" ) self.assertEqual( self.data_manager.active_mapping.input_combination, InputCombination(InputCombination.from_tuples((1, 1), (1, 3), (1, 2))), ) def test_move_event_in_combination_of_len_1(self): prepare_presets() self.data_manager.load_group("Foo Device 2") self.data_manager.load_preset("preset2") self.data_manager.load_mapping( InputCombination(InputCombination.from_tuples((1, 3))) ) self.controller.move_input_config_in_combination( InputConfig(type=1, code=3), "down" ) self.assertEqual( self.data_manager.active_mapping.input_combination, InputCombination(InputCombination.from_tuples((1, 3))), ) def test_move_event_loads_it_again(self): prepare_presets() self.data_manager.load_group("Foo Device 2") self.data_manager.load_preset("preset2") self.data_manager.load_mapping( InputCombination(InputCombination.from_tuples((1, 3))) ) self.data_manager.update_mapping( input_combination=InputCombination( InputCombination.from_tuples((1, 1), (1, 2), (1, 3)) ) ) mock = MagicMock() self.message_broker.subscribe(MessageType.selected_event, mock) self.controller.move_input_config_in_combination( InputConfig(type=1, code=2), "down" ) mock.assert_called_once_with(InputConfig(type=1, code=2)) def test_update_event(self): prepare_presets() self.data_manager.load_group("Foo Device 2") self.data_manager.load_preset("preset2") self.data_manager.load_mapping( InputCombination(InputCombination.from_tuples((1, 3))) ) self.data_manager.load_input_config(InputConfig(type=1, code=3)) mock = MagicMock() self.message_broker.subscribe(MessageType.selected_event, mock) self.controller.update_input_config(InputConfig(type=1, code=10)) mock.assert_called_once_with(InputConfig(type=1, code=10)) def test_update_event_reloads_mapping_and_event_when_update_fails(self): prepare_presets() self.data_manager.load_group("Foo Device 2") self.data_manager.load_preset("preset2") self.data_manager.load_mapping( InputCombination(InputCombination.from_tuples((1, 3))) ) self.data_manager.load_input_config(InputConfig(type=1, code=3)) mock = MagicMock() self.message_broker.subscribe(MessageType.selected_event, mock) self.message_broker.subscribe(MessageType.mapping, mock) calls = [ call(self.data_manager.active_mapping.get_bus_message()), call(InputConfig(type=1, code=3)), ] self.controller.update_input_config( InputConfig(type=1, code=4) ) # already exists mock.assert_has_calls(calls, any_order=False) def test_remove_event_does_nothing_when_mapping_not_loaded(self): with spy(self.data_manager, "update_mapping") as mock: self.controller.remove_event() mock.assert_not_called() def test_remove_event_removes_active_event(self): prepare_presets() self.data_manager.load_group("Foo Device 2") self.data_manager.load_preset("preset2") self.data_manager.load_mapping( InputCombination(InputCombination.from_tuples((1, 3))) ) self.data_manager.update_mapping( input_combination=InputCombination.from_tuples((1, 3), (1, 4)) ) self.assertEqual( self.data_manager.active_mapping.input_combination, InputCombination(InputCombination.from_tuples((1, 3), (1, 4))), ) self.data_manager.load_input_config(InputConfig(type=1, code=4)) self.controller.remove_event() self.assertEqual( self.data_manager.active_mapping.input_combination, InputCombination(InputCombination.from_tuples((1, 3))), ) def test_remove_event_loads_a_event(self): prepare_presets() self.data_manager.load_group("Foo Device 2") self.data_manager.load_preset("preset2") self.data_manager.load_mapping( InputCombination(InputCombination.from_tuples((1, 3))) ) self.data_manager.update_mapping( input_combination=InputCombination.from_tuples((1, 3), (1, 4)) ) self.assertEqual( self.data_manager.active_mapping.input_combination, InputCombination(InputCombination.from_tuples((1, 3), (1, 4))), ) self.data_manager.load_input_config(InputConfig(type=1, code=4)) mock = MagicMock() self.message_broker.subscribe(MessageType.selected_event, mock) self.controller.remove_event() mock.assert_called_once_with(InputConfig(type=1, code=3)) def test_remove_event_reloads_mapping_and_event_when_update_fails(self): prepare_presets() self.data_manager.load_group("Foo Device 2") self.data_manager.load_preset("preset2") self.data_manager.load_mapping( InputCombination(InputCombination.from_tuples((1, 3))) ) self.data_manager.update_mapping( input_combination=InputCombination.from_tuples((1, 3), (1, 4)) ) self.data_manager.load_input_config(InputConfig(type=1, code=3)) # removing "1,3,1" will throw a key error because a mapping with combination # "1,4,1" already exists in preset mock = MagicMock() self.message_broker.subscribe(MessageType.selected_event, mock) self.message_broker.subscribe(MessageType.mapping, mock) calls = [ call(self.data_manager.active_mapping.get_bus_message()), call(InputConfig(type=1, code=3)), ] self.controller.remove_event() mock.assert_has_calls(calls, any_order=False) def test_set_event_as_analog_saves(self): prepare_presets() self.data_manager.load_group("Foo Device 2") self.data_manager.load_preset("preset2") self.data_manager.update_mapping( input_combination=InputCombination.from_tuples((3, 0, 10)) ) self.data_manager.load_mapping( InputCombination(InputCombination.from_tuples((3, 0, 10))) ) self.data_manager.load_input_config( InputConfig(type=3, code=0, analog_threshold=10) ) with patch.object(self.data_manager, "save") as mock: self.controller.set_event_as_analog(False) mock.assert_called_once() with patch.object(self.data_manager, "save") as mock: self.controller.set_event_as_analog(True) mock.assert_called_once() def test_set_event_as_analog_sets_input_to_analog(self): prepare_presets() self.data_manager.load_group("Foo Device 2") self.data_manager.load_preset("preset2") self.data_manager.load_mapping( InputCombination(InputCombination.from_tuples((1, 3))) ) self.data_manager.update_mapping( input_combination=InputCombination.from_tuples((3, 0, 10)) ) self.data_manager.load_input_config( InputConfig(type=3, code=0, analog_threshold=10) ) self.controller.set_event_as_analog(True) self.assertEqual( self.data_manager.active_mapping.input_combination, InputCombination(InputCombination.from_tuples((3, 0))), ) def test_set_event_as_analog_adds_rel_threshold(self): prepare_presets() self.data_manager.load_group("Foo Device 2") self.data_manager.load_preset("preset2") self.data_manager.load_mapping( InputCombination(InputCombination.from_tuples((1, 3))) ) self.data_manager.update_mapping( input_combination=InputCombination.from_tuples((2, 0)) ) self.data_manager.load_input_config(InputConfig(type=2, code=0)) self.controller.set_event_as_analog(False) combinations = [ InputCombination(InputCombination.from_tuples((2, 0, 1))), InputCombination(InputCombination.from_tuples((2, 0, -1))), ] self.assertIn(self.data_manager.active_mapping.input_combination, combinations) def test_set_event_as_analog_adds_abs_threshold(self): prepare_presets() self.data_manager.load_group("Foo Device 2") self.data_manager.load_preset("preset2") self.data_manager.load_mapping( InputCombination(InputCombination.from_tuples((1, 3))) ) self.data_manager.update_mapping( input_combination=InputCombination.from_tuples((3, 0)) ) self.data_manager.load_input_config(InputConfig(type=3, code=0)) self.controller.set_event_as_analog(False) combinations = [ InputCombination(InputCombination.from_tuples((3, 0, 10))), InputCombination(InputCombination.from_tuples((3, 0, -10))), ] self.assertIn(self.data_manager.active_mapping.input_combination, combinations) def test_set_event_as_analog_reloads_mapping_and_event_when_key_event(self): prepare_presets() self.data_manager.load_group("Foo Device 2") self.data_manager.load_preset("preset2") self.data_manager.load_mapping( InputCombination(InputCombination.from_tuples((1, 3))) ) self.data_manager.load_input_config(InputConfig(type=1, code=3)) mock = MagicMock() self.message_broker.subscribe(MessageType.selected_event, mock) self.message_broker.subscribe(MessageType.mapping, mock) calls = [ call(self.data_manager.active_mapping.get_bus_message()), call(InputConfig(type=1, code=3)), ] self.controller.set_event_as_analog(True) mock.assert_has_calls(calls, any_order=False) def test_set_event_as_analog_reloads_when_setting_to_analog_fails(self): prepare_presets() self.data_manager.load_group("Foo Device 2") self.data_manager.load_preset("preset2") self.data_manager.load_mapping( InputCombination(InputCombination.from_tuples((1, 3))) ) self.data_manager.update_mapping( input_combination=InputCombination.from_tuples((3, 0, 10)) ) self.data_manager.load_input_config( InputConfig(type=3, code=0, analog_threshold=10) ) mock = MagicMock() self.message_broker.subscribe(MessageType.selected_event, mock) self.message_broker.subscribe(MessageType.mapping, mock) calls = [ call(self.data_manager.active_mapping.get_bus_message()), call(InputConfig(type=3, code=0, analog_threshold=10)), ] with patch.object(self.data_manager, "update_mapping", side_effect=KeyError): self.controller.set_event_as_analog(True) mock.assert_has_calls(calls, any_order=False) def test_set_event_as_analog_reloads_when_setting_to_key_fails(self): prepare_presets() self.data_manager.load_group("Foo Device 2") self.data_manager.load_preset("preset2") self.data_manager.load_mapping( InputCombination(InputCombination.from_tuples((1, 3))) ) self.data_manager.update_mapping( input_combination=InputCombination.from_tuples((3, 0)) ) self.data_manager.load_input_config(InputConfig(type=3, code=0)) mock = MagicMock() self.message_broker.subscribe(MessageType.selected_event, mock) self.message_broker.subscribe(MessageType.mapping, mock) calls = [ call(self.data_manager.active_mapping.get_bus_message()), call(InputConfig(type=3, code=0)), ] with patch.object(self.data_manager, "update_mapping", side_effect=KeyError): self.controller.set_event_as_analog(False) mock.assert_has_calls(calls, any_order=False) def test_update_mapping_type_will_ask_user_when_output_symbol_is_set(self): prepare_presets() self.data_manager.load_group("Foo Device 2") self.data_manager.load_preset("preset2") self.data_manager.load_mapping( InputCombination(InputCombination.from_tuples((1, 3))) ) request: UserConfirmRequest = None def f(r: UserConfirmRequest): nonlocal request request = r self.message_broker.subscribe(MessageType.user_confirm_request, f) self.controller.update_mapping(mapping_type="analog") self.assertIn('This will remove "a" from the text input', request.msg) def test_update_mapping_type_will_notify_user_to_recorde_analog_input(self): prepare_presets() self.data_manager.load_group("Foo Device 2") self.data_manager.load_preset("preset2") self.data_manager.load_mapping( InputCombination(InputCombination.from_tuples((1, 3))) ) self.data_manager.update_mapping(output_symbol=None) request: UserConfirmRequest = None def f(r: UserConfirmRequest): nonlocal request request = r self.message_broker.subscribe(MessageType.user_confirm_request, f) self.controller.update_mapping(mapping_type="analog") self.assertIn("You need to record an analog input.", request.msg) def test_update_mapping_type_will_tell_user_which_input_is_used_as_analog(self): prepare_presets() self.data_manager.load_group("Foo Device 2") self.data_manager.load_preset("preset2") self.data_manager.load_mapping( InputCombination(InputCombination.from_tuples((1, 3))) ) self.data_manager.update_mapping( input_combination=InputCombination.from_tuples((1, 3), (2, 1, 1)), output_symbol=None, ) request: UserConfirmRequest = None def f(r: UserConfirmRequest): nonlocal request request = r self.message_broker.subscribe(MessageType.user_confirm_request, f) self.controller.update_mapping(mapping_type="analog") self.assertIn('The input "Y Down 1" will be used as analog input.', request.msg) def test_update_mapping_type_will_will_autoconfigure_the_input(self): prepare_presets() self.data_manager.load_group("Foo Device 2") self.data_manager.load_preset("preset2") self.data_manager.load_mapping( InputCombination(InputCombination.from_tuples((1, 3))) ) self.data_manager.update_mapping( input_combination=InputCombination.from_tuples((1, 3), (2, 1, 1)), output_symbol=None, ) self.message_broker.subscribe( MessageType.user_confirm_request, lambda r: r.respond(True) ) with patch.object(self.data_manager, "update_mapping") as mock: self.controller.update_mapping(mapping_type="analog") mock.assert_called_once_with( mapping_type="analog", output_symbol=None, input_combination=InputCombination( InputCombination.from_tuples((1, 3), (2, 1)) ), ) def test_update_mapping_type_will_abort_when_user_denys(self): prepare_presets() self.data_manager.load_group("Foo Device 2") self.data_manager.load_preset("preset2") self.data_manager.load_mapping( InputCombination(InputCombination.from_tuples((1, 3))) ) self.message_broker.subscribe( MessageType.user_confirm_request, lambda r: r.respond(False) ) with patch.object(self.data_manager, "update_mapping") as mock: self.controller.update_mapping(mapping_type="analog") mock.assert_not_called() self.data_manager.update_mapping( input_combination=InputCombination.from_tuples((1, 3), (2, 1)), output_symbol=None, mapping_type="analog", ) with patch.object(self.data_manager, "update_mapping") as mock: self.controller.update_mapping(mapping_type="key_macro") mock.assert_not_called() def test_update_mapping_type_will_delete_output_symbol_when_user_confirms(self): prepare_presets() self.data_manager.load_group("Foo Device 2") self.data_manager.load_preset("preset2") self.data_manager.load_mapping( InputCombination(InputCombination.from_tuples((1, 3))) ) self.message_broker.subscribe( MessageType.user_confirm_request, lambda r: r.respond(True) ) with patch.object(self.data_manager, "update_mapping") as mock: self.controller.update_mapping(mapping_type="analog") mock.assert_called_once_with(mapping_type="analog", output_symbol=None) def test_update_mapping_will_ask_user_to_set_trigger_threshold(self): prepare_presets() self.data_manager.load_group("Foo Device 2") self.data_manager.load_preset("preset2") self.data_manager.load_mapping( InputCombination(InputCombination.from_tuples((1, 3))) ) self.data_manager.update_mapping( input_combination=InputCombination.from_tuples((1, 3), (2, 1)), output_symbol=None, mapping_type="analog", ) request: UserConfirmRequest = None def f(r: UserConfirmRequest): nonlocal request request = r self.message_broker.subscribe(MessageType.user_confirm_request, f) self.controller.update_mapping(mapping_type="key_macro") self.assertIn('and set a "Trigger Threshold" for "Y".', request.msg) def test_update_mapping_update_to_analog_without_asking(self): prepare_presets() self.data_manager.load_group("Foo Device 2") self.data_manager.load_preset("preset2") self.data_manager.load_mapping( InputCombination(InputCombination.from_tuples((1, 3))) ) self.data_manager.update_mapping( input_combination=InputCombination.from_tuples((1, 3), (2, 1)), output_symbol=None, ) mock = MagicMock() self.message_broker.subscribe(MessageType.user_confirm_request, mock) self.controller.update_mapping(mapping_type="analog") mock.assert_not_called() def test_update_mapping_update_to_key_macro_without_asking(self): prepare_presets() self.data_manager.load_group("Foo Device 2") self.data_manager.load_preset("preset2") self.data_manager.load_mapping( InputCombination(InputCombination.from_tuples((1, 3))) ) self.data_manager.update_mapping( input_combination=InputCombination.from_tuples((1, 3), (2, 1, 1)), mapping_type="analog", output_symbol=None, ) mock = MagicMock() self.message_broker.subscribe(MessageType.user_confirm_request, mock) self.controller.update_mapping(mapping_type="key_macro") mock.assert_not_called() def test_update_mapping_will_remove_output_type_and_code(self): prepare_presets() self.data_manager.load_group("Foo Device 2") self.data_manager.load_preset("preset2") self.data_manager.load_mapping( InputCombination(InputCombination.from_tuples((1, 3))) ) self.data_manager.update_mapping( input_combination=InputCombination.from_tuples((1, 3), (2, 1)), output_symbol=None, mapping_type="analog", ) self.message_broker.subscribe( MessageType.user_confirm_request, lambda r: r.respond(True) ) with patch.object(self.data_manager, "update_mapping") as mock: self.controller.update_mapping(mapping_type="key_macro") mock.assert_called_once_with( mapping_type="key_macro", output_type=None, output_code=None, ) input-remapper-2.1.1/tests/unit/test_daemon.py000066400000000000000000000517041475433465200215040ustar00rootroot00000000000000#!/usr/bin/env python3 # -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings # Copyright (C) 2025 sezanzeb # # This file is part of input-remapper. # # input-remapper is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # input-remapper is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with input-remapper. If not, see . import json import os import time import unittest from unittest.mock import patch, MagicMock import evdev from evdev._ecodes import EV_ABS from evdev.ecodes import EV_KEY, KEY_B, KEY_A, ABS_X, BTN_A, BTN_B from pydbus import SystemBus from inputremapper.configs.global_config import GlobalConfig from inputremapper.configs.input_config import InputCombination, InputConfig from inputremapper.configs.mapping import Mapping from inputremapper.configs.paths import PathUtils from inputremapper.configs.preset import Preset from inputremapper.configs.keyboard_layout import keyboard_layout from inputremapper.daemon import Daemon from inputremapper.groups import groups from inputremapper.injection.global_uinputs import GlobalUInputs, UInput from inputremapper.injection.injector import InjectorState from inputremapper.injection.mapping_handlers.mapping_parser import MappingParser from inputremapper.input_event import InputEvent from tests.lib.cleanup import cleanup from tests.lib.fixtures import Fixture from tests.lib.fixtures import fixtures from tests.lib.logger import logger from tests.lib.pipes import push_events, uinput_write_history_pipe from tests.lib.test_setup import test_setup, is_service_running from tests.lib.tmp import tmp @test_setup class TestDaemon(unittest.TestCase): new_fixture_path = "/dev/input/event9876" def setUp(self): self.daemon = None self.global_config = GlobalConfig() self.global_uinputs = GlobalUInputs(UInput) PathUtils.mkdir(PathUtils.get_config_path()) self.global_config._save_config() self.mapping_parser = MappingParser(self.global_uinputs) # the daemon should be able to create them on demand: self.global_uinputs.devices = {} self.global_uinputs.is_service = True def tearDown(self): # avoid race conditions with other tests, daemon may run processes if self.daemon is not None: self.daemon.stop_all() self.daemon = None cleanup() @patch.object(os, "system") def test_connect(self, os_system_mock: MagicMock): self.assertFalse(is_service_running()) # no daemon runs, should try to run it via pkexec instead. # It fails due to the patch on os.system and therefore exits the process self.assertRaises(SystemExit, Daemon.connect) os_system_mock.assert_called_once() self.assertIsNone(Daemon.connect(False)) # make the connect command work this time by acting like a connection is # available: set_config_dir_callcount = 0 class FakeConnection: def set_config_dir(self, *_, **__): nonlocal set_config_dir_callcount set_config_dir_callcount += 1 system_bus = SystemBus() with patch.object(type(system_bus), "get") as get_mock: get_mock.return_value = FakeConnection() self.assertIsInstance(Daemon.connect(), FakeConnection) self.assertEqual(set_config_dir_callcount, 1) self.assertIsInstance(Daemon.connect(False), FakeConnection) self.assertEqual(set_config_dir_callcount, 2) def test_daemon(self): # remove the existing system mapping to force our own into it if os.path.exists(PathUtils.get_config_path("xmodmap.json")): os.remove(PathUtils.get_config_path("xmodmap.json")) preset_name = "foo" group = groups.find(name="gamepad") # unrelated group that shouldn't be affected at all group2 = groups.find(name="Bar Device") preset = Preset(group.get_preset_path(preset_name)) preset.add( Mapping.from_combination( input_combination=InputCombination( [InputConfig(type=EV_KEY, code=BTN_A)] ), target_uinput="keyboard", output_symbol="a", ) ) preset.add( Mapping.from_combination( input_combination=InputCombination( [InputConfig(type=EV_ABS, code=ABS_X, analog_threshold=-1)] ), target_uinput="keyboard", output_symbol="b", ) ) preset.save() self.global_config.set_autoload_preset(group.key, preset_name) """Injection 1""" # should forward the event unchanged push_events( fixtures.gamepad, [InputEvent.key(BTN_B, 1, fixtures.gamepad.get_device_hash())], ) self.daemon = Daemon( self.global_config, self.global_uinputs, self.mapping_parser, ) self.assertFalse(uinput_write_history_pipe[0].poll()) # has been cleanedUp in setUp self.assertNotIn("keyboard", self.global_uinputs.devices) logger.info(f"start injector for {group.key}") self.daemon.start_injecting(group.key, preset_name) # created on demand self.assertIn("keyboard", self.global_uinputs.devices) self.assertNotIn("gamepad", self.global_uinputs.devices) self.assertEqual(self.daemon.get_state(group.key), InjectorState.STARTING) self.assertEqual(self.daemon.get_state(group2.key), InjectorState.UNKNOWN) event = uinput_write_history_pipe[0].recv() self.assertEqual(self.daemon.get_state(group.key), InjectorState.RUNNING) self.assertEqual(event.type, EV_KEY) self.assertEqual(event.code, BTN_B) self.assertEqual(event.value, 1) logger.info(f"stopping injector for {group.key}") self.daemon.stop_injecting(group.key) time.sleep(0.2) self.assertEqual(self.daemon.get_state(group.key), InjectorState.STOPPED) try: self.assertFalse(uinput_write_history_pipe[0].poll()) except AssertionError: print("Unexpected", uinput_write_history_pipe[0].recv()) # possibly a duplicate write! raise """Injection 2""" logger.info(f"start injector for {group.key}") self.daemon.start_injecting(group.key, preset_name) time.sleep(0.1) # -1234 will be classified as -1 by the injector push_events( fixtures.gamepad, [InputEvent.abs(ABS_X, -1234, fixtures.gamepad.get_device_hash())], ) time.sleep(0.1) self.assertTrue(uinput_write_history_pipe[0].poll()) # the written key is a key-down event, not the original # event value of -1234 event = uinput_write_history_pipe[0].recv() self.assertEqual(event.type, EV_KEY) self.assertEqual(event.code, KEY_B) self.assertEqual(event.value, 1) def test_config_dir(self): self.global_config.set("foo", "bar") self.assertEqual(self.global_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.global_config, self.global_uinputs, self.mapping_parser, ) self.assertEqual(self.daemon.config_dir, PathUtils.get_config_path()) self.assertIsNone(self.global_config.get("foo")) def test_refresh_on_start(self): if os.path.exists(PathUtils.get_config_path("xmodmap.json")): os.remove(PathUtils.get_config_path("xmodmap.json")) preset_name = "foo" key_code = 9 group_name = "9876 name" # expected key of the group group_key = group_name group = groups.find(name=group_name) # this test only makes sense if this device is unknown yet self.assertIsNone(group) keyboard_layout.clear() keyboard_layout._set("a", KEY_A) preset = Preset(PathUtils.get_preset_path(group_name, preset_name)) preset.add( Mapping.from_combination( InputCombination([InputConfig(type=EV_KEY, code=key_code)]), "keyboard", "a", ) ) # make the daemon load the file instead with open(PathUtils.get_config_path("xmodmap.json"), "w") as file: json.dump(keyboard_layout._mapping, file, indent=4) keyboard_layout.clear() preset.save() self.global_config.set_autoload_preset(group_key, preset_name) self.daemon = Daemon( self.global_config, self.global_uinputs, self.mapping_parser, ) # make sure the devices are populated groups.refresh() # the daemon is supposed to find this device by calling refresh fixture = Fixture( capabilities={evdev.ecodes.EV_KEY: [key_code]}, phys="9876 phys", info=evdev.device.DeviceInfo(4, 5, 6, 7), name=group_name, path=self.new_fixture_path, ) fixtures[self.new_fixture_path] = fixture push_events(fixture, [InputEvent.key(key_code, 1, fixture.get_device_hash())]) self.daemon.start_injecting(group_key, preset_name) # test if the injector called groups.refresh successfully group = groups.find(key=group_key) self.assertEqual(group.name, group_name) self.assertEqual(group.key, group_key) time.sleep(0.1) self.assertTrue(uinput_write_history_pipe[0].poll()) event = uinput_write_history_pipe[0].recv() self.assertEqual(event, (EV_KEY, KEY_A, 1)) self.daemon.stop_injecting(group_key) time.sleep(0.2) self.assertEqual(self.daemon.get_state(group_key), InjectorState.STOPPED) def test_refresh_for_unknown_key(self): device_9876 = "9876 name" # this test only makes sense if this device is unknown yet self.assertIsNone(groups.find(name=device_9876)) self.daemon = Daemon( self.global_config, self.global_uinputs, self.mapping_parser, ) # make sure the devices are populated groups.refresh() self.daemon.refresh() fixtures[self.new_fixture_path] = Fixture( capabilities={evdev.ecodes.EV_KEY: [evdev.ecodes.KEY_A]}, phys="9876 phys", info=evdev.device.DeviceInfo(4, 5, 6, 7), name=device_9876, path=self.new_fixture_path, ) self.daemon._autoload("25v7j9q4vtj") # this is unknown, so the daemon will scan the devices again # test if the injector called groups.refresh successfully self.assertIsNotNone(groups.find(name=device_9876)) def test_xmodmap_file(self): """Create a custom xmodmap file, expect the daemon to read keycodes from it.""" from_keycode = evdev.ecodes.KEY_A target = "keyboard" to_name = "q" to_keycode = 100 name = "Bar Device" preset_name = "foo" group = groups.find(name=name) config_dir = os.path.join(tmp, "foo") path = os.path.join(config_dir, "presets", name, f"{preset_name}.json") preset = Preset(path) preset.add( Mapping.from_combination( InputCombination([InputConfig(type=EV_KEY, code=from_keycode)]), target, to_name, ) ) preset.save() keyboard_layout.clear() push_events( fixtures.bar_device, [ InputEvent.key( from_keycode, 1, origin_hash=fixtures.bar_device.get_device_hash(), ) ], ) # an existing config file is needed otherwise set_config_dir refuses # to use the directory config_path = os.path.join(config_dir, "config.json") self.global_config.path = config_path self.global_config._save_config() # finally, create the xmodmap file xmodmap_path = os.path.join(config_dir, "xmodmap.json") with open(xmodmap_path, "w") as file: file.write(f'{{"{to_name}":{to_keycode}}}') # test setup complete self.daemon = Daemon( self.global_config, self.global_uinputs, self.mapping_parser, ) self.daemon.set_config_dir(config_dir) self.daemon.start_injecting(group.key, preset_name) time.sleep(0.1) self.assertTrue(uinput_write_history_pipe[0].poll()) event = uinput_write_history_pipe[0].recv() self.assertEqual(event.type, EV_KEY) self.assertEqual(event.code, to_keycode) self.assertEqual(event.value, 1) def test_start_stop(self): group_key = "Qux/[Device]?" group = groups.find(key=group_key) preset_name = "preset8" daemon = Daemon( self.global_config, self.global_uinputs, self.mapping_parser, ) self.daemon = daemon pereset = Preset(group.get_preset_path(preset_name)) pereset.add( Mapping.from_combination( InputCombination([InputConfig(type=EV_KEY, code=KEY_A)]), "keyboard", "a", ) ) pereset.save() # start daemon.start_injecting(group_key, preset_name) # explicit start, not autoload, so the history stays empty self.assertNotIn(group_key, daemon.autoload_history._autoload_history) self.assertTrue(daemon.autoload_history.may_autoload(group_key, preset_name)) # path got translated to the device name self.assertIn(group_key, daemon.injectors) # start again previous_injector = daemon.injectors[group_key] self.assertNotEqual(previous_injector.get_state(), InjectorState.STOPPED) daemon.start_injecting(group_key, preset_name) self.assertNotIn(group_key, daemon.autoload_history._autoload_history) self.assertTrue(daemon.autoload_history.may_autoload(group_key, preset_name)) self.assertIn(group_key, daemon.injectors) time.sleep(0.2) self.assertEqual(previous_injector.get_state(), InjectorState.STOPPED) # a different injetor is now running self.assertNotEqual(previous_injector, daemon.injectors[group_key]) self.assertNotEqual( daemon.injectors[group_key].get_state(), InjectorState.STOPPED ) # trying to inject a non existing preset keeps the previous inejction # alive injector = daemon.injectors[group_key] daemon.start_injecting(group_key, "qux") self.assertEqual(injector, daemon.injectors[group_key]) self.assertNotEqual( daemon.injectors[group_key].get_state(), InjectorState.STOPPED ) # trying to start injecting for an unknown device also just does # nothing daemon.start_injecting("quux", "qux") self.assertNotEqual( daemon.injectors[group_key].get_state(), InjectorState.STOPPED ) # after all that stuff autoload_history is still unharmed self.assertNotIn(group_key, daemon.autoload_history._autoload_history) self.assertTrue(daemon.autoload_history.may_autoload(group_key, preset_name)) # stop daemon.stop_injecting(group_key) time.sleep(0.2) self.assertNotIn(group_key, daemon.autoload_history._autoload_history) self.assertEqual(daemon.injectors[group_key].get_state(), InjectorState.STOPPED) self.assertTrue(daemon.autoload_history.may_autoload(group_key, preset_name)) def test_autoload(self): preset_name = "preset7" group_key = "Qux/[Device]?" group = groups.find(key=group_key) daemon = Daemon(self.global_config, self.global_uinputs, self.mapping_parser) self.daemon = daemon preset = Preset(group.get_preset_path(preset_name)) preset.add( Mapping.from_combination( InputCombination([InputConfig(type=EV_KEY, code=KEY_A)]), "keyboard", "a", ) ) preset.save() # no autoloading is configured yet self.daemon._autoload(group_key) self.assertNotIn(group_key, daemon.autoload_history._autoload_history) self.assertTrue(daemon.autoload_history.may_autoload(group_key, preset_name)) self.global_config.set_autoload_preset(group_key, preset_name) len_before = len(self.daemon.autoload_history._autoload_history) # now autoloading is configured, so it will autoload self.daemon._autoload(group_key) len_after = len(self.daemon.autoload_history._autoload_history) self.assertEqual( daemon.autoload_history._autoload_history[group_key][1], preset_name, ) self.assertFalse(daemon.autoload_history.may_autoload(group_key, preset_name)) injector = daemon.injectors[group_key] self.assertEqual(len_before + 1, len_after) # calling duplicate get_autoload does nothing self.daemon._autoload(group_key) self.assertEqual( daemon.autoload_history._autoload_history[group_key][1], preset_name, ) self.assertEqual(injector, daemon.injectors[group_key]) self.assertFalse(daemon.autoload_history.may_autoload(group_key, preset_name)) # explicit start_injecting clears the autoload history self.daemon.start_injecting(group_key, preset_name) self.assertTrue(daemon.autoload_history.may_autoload(group_key, preset_name)) # calling autoload for (yet) unknown devices does nothing len_before = len(self.daemon.autoload_history._autoload_history) self.daemon._autoload("unknown-key-1234") len_after = len(self.daemon.autoload_history._autoload_history) self.assertEqual(len_before, len_after) # autoloading input-remapper devices does nothing len_before = len(self.daemon.autoload_history._autoload_history) self.daemon.autoload_single("Bar Device") len_after = len(self.daemon.autoload_history._autoload_history) self.assertEqual(len_before, len_after) def test_autoload_2(self): self.daemon = Daemon( self.global_config, self.global_uinputs, self.mapping_parser, ) history = self.daemon.autoload_history._autoload_history # existing device preset_name = "preset7" group = groups.find(key="Foo Device 2") preset = Preset(group.get_preset_path(preset_name)) preset.add( Mapping.from_combination( InputCombination([InputConfig(type=3, code=2, analog_threshold=1)]), "keyboard", "a", ) ) preset.save() self.global_config.set_autoload_preset(group.key, preset_name) # ignored, won't cause problems: self.global_config.set_autoload_preset("non-existant-key", "foo") self.daemon.autoload() self.assertEqual(len(history), 1) self.assertEqual(history[group.key][1], preset_name) def test_autoload_3(self): # based on a bug preset_name = "preset7" group = groups.find(key="Foo Device 2") preset = Preset(group.get_preset_path(preset_name)) preset.add( Mapping.from_combination( InputCombination([InputConfig(type=3, code=2, analog_threshold=1)]), "keyboard", "a", ) ) preset.save() self.global_config.set_autoload_preset(group.key, preset_name) self.daemon = Daemon( self.global_config, self.global_uinputs, self.mapping_parser, ) groups.set_groups([]) # caused the bug self.assertIsNone(groups.find(key="Foo Device 2")) self.daemon.autoload() # it should try to refresh the groups because all the # group_keys are unknown at the moment history = self.daemon.autoload_history._autoload_history self.assertEqual(history[group.key][1], preset_name) self.assertEqual(self.daemon.get_state(group.key), InjectorState.STARTING) self.assertIsNotNone(groups.find(key="Foo Device 2")) if __name__ == "__main__": unittest.main() input-remapper-2.1.1/tests/unit/test_data_manager.py000066400000000000000000001135571475433465200226510ustar00rootroot00000000000000#!/usr/bin/env python3 # -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings # Copyright (C) 2025 sezanzeb # # This file is part of input-remapper. # # input-remapper is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # input-remapper is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with input-remapper. If not, see . import os import time import unittest from itertools import permutations from typing import List from unittest.mock import MagicMock, call from inputremapper.configs.global_config import GlobalConfig from inputremapper.configs.input_config import InputCombination, InputConfig from inputremapper.configs.mapping import UIMapping, MappingData from inputremapper.configs.paths import PathUtils from inputremapper.configs.preset import Preset from inputremapper.configs.keyboard_layout import keyboard_layout from inputremapper.exceptions import DataManagementError from inputremapper.groups import _Groups from inputremapper.gui.data_manager import DataManager, DEFAULT_PRESET_NAME from inputremapper.gui.messages.message_broker import ( MessageBroker, MessageType, ) from inputremapper.gui.messages.message_data import ( GroupData, CombinationUpdate, ) from inputremapper.gui.reader_client import ReaderClient from inputremapper.injection.global_uinputs import GlobalUInputs, FrontendUInput from tests.lib.fixtures import prepare_presets from tests.lib.patches import FakeDaemonProxy from tests.lib.test_setup import test_setup class Listener: def __init__(self): self.calls: List = [] def __call__(self, data): self.calls.append(data) @test_setup class TestDataManager(unittest.TestCase): def setUp(self) -> None: self.message_broker = MessageBroker() self.reader = ReaderClient(self.message_broker, _Groups()) self.uinputs = GlobalUInputs(FrontendUInput) self.uinputs.prepare_all() self.global_config = GlobalConfig() self.data_manager = DataManager( self.message_broker, self.global_config, self.reader, FakeDaemonProxy(), self.uinputs, keyboard_layout, ) def test_load_group_provides_presets(self): """we should get all preset of a group, when loading it""" prepare_presets() response: List[GroupData] = [] def listener(data: GroupData): response.append(data) self.message_broker.subscribe(MessageType.group, listener) self.data_manager.load_group("Foo Device 2") for preset_name in response[0].presets: self.assertIn( preset_name, ( "preset1", "preset2", "preset3", ), ) self.assertEqual(response[0].group_key, "Foo Device 2") def test_load_group_without_presets_provides_none(self): """We should get no presets when loading a group without presets.""" response: List[GroupData] = [] def listener(data: GroupData): response.append(data) self.message_broker.subscribe(MessageType.group, listener) self.data_manager.load_group(group_key="Foo Device 2") self.assertEqual(len(response[0].presets), 0) def test_load_non_existing_group(self): """we should not be able to load an unknown group""" with self.assertRaises(DataManagementError): self.data_manager.load_group(group_key="Some Unknown Device") def test_cannot_load_preset_without_group(self): """Loading a preset without a loaded group raises a DataManagementError.""" prepare_presets() self.assertRaises( DataManagementError, self.data_manager.load_preset, name="preset1", ) def test_load_preset(self): """loading an existing preset should be possible""" prepare_presets() self.data_manager.load_group(group_key="Foo Device") listener = Listener() self.message_broker.subscribe(MessageType.preset, listener) self.data_manager.load_preset(name="preset1") mappings = listener.calls[0].mappings preset_name = listener.calls[0].name expected_preset = Preset(PathUtils.get_preset_path("Foo Device", "preset1")) expected_preset.load() expected_mappings = list(expected_preset) self.assertEqual(preset_name, "preset1") for mapping in expected_mappings: self.assertIn(mapping, mappings) def test_cannot_load_non_existing_preset(self): """Loading a non-existing preset should raise a KeyError.""" prepare_presets() self.data_manager.load_group(group_key="Foo Device") self.assertRaises( FileNotFoundError, self.data_manager.load_preset, name="unknownPreset", ) def test_save_preset(self): """Modified preses should be saved to the disc.""" prepare_presets() # make sure the correct preset is loaded self.data_manager.load_group(group_key="Foo Device") self.data_manager.load_preset(name="preset1") listener = Listener() self.message_broker.subscribe(MessageType.mapping, listener) self.data_manager.load_mapping( combination=InputCombination([InputConfig(type=1, code=1)]) ) mapping: MappingData = listener.calls[0] control_preset = Preset(PathUtils.get_preset_path("Foo Device", "preset1")) control_preset.load() self.assertEqual( control_preset.get_mapping( InputCombination([InputConfig(type=1, code=1)]) ).output_symbol, mapping.output_symbol, ) # change the mapping provided with the mapping_changed event and save self.data_manager.update_mapping(output_symbol="key(a)") self.data_manager.save() # reload the control_preset control_preset.empty() control_preset.load() self.assertEqual( control_preset.get_mapping( InputCombination([InputConfig(type=1, code=1)]) ).output_symbol, "key(a)", ) def test_copy_preset(self): prepare_presets() self.data_manager.load_group(group_key="Foo Device 2") self.data_manager.load_preset(name="preset2") listener = Listener() self.message_broker.subscribe(MessageType.group, listener) self.message_broker.subscribe(MessageType.preset, listener) self.data_manager.copy_preset("foo") # we expect the first data to be group data and the second # one a preset data of the new copy presets_in_group = [preset for preset in listener.calls[0].presets] self.assertIn("preset2", presets_in_group) self.assertIn("foo", presets_in_group) self.assertEqual(listener.calls[1].name, "foo") # this should pass without error: self.data_manager.load_preset("preset2") self.data_manager.copy_preset("preset2") def test_cannot_copy_preset(self): prepare_presets() self.assertRaises( DataManagementError, self.data_manager.copy_preset, "foo", ) self.data_manager.load_group("Foo Device 2") self.assertRaises( DataManagementError, self.data_manager.copy_preset, "foo", ) def test_copy_preset_to_existing_name_raises_error(self): prepare_presets() self.data_manager.load_group(group_key="Foo Device 2") self.data_manager.load_preset(name="preset2") self.assertRaises( ValueError, self.data_manager.copy_preset, "preset3", ) def test_rename_preset(self): """should be able to rename a preset""" prepare_presets() self.data_manager.load_group(group_key="Foo Device 2") self.data_manager.load_preset(name="preset2") listener = Listener() self.message_broker.subscribe(MessageType.group, listener) self.message_broker.subscribe(MessageType.preset, listener) self.data_manager.rename_preset(new_name="new preset") # we expect the first data to be group data and the second # one a preset data presets_in_group = [preset for preset in listener.calls[0].presets] self.assertNotIn("preset2", presets_in_group) self.assertIn("new preset", presets_in_group) self.assertEqual(listener.calls[1].name, "new preset") # this should pass without error: self.data_manager.load_preset(name="new preset") self.data_manager.rename_preset(new_name="new preset") def test_rename_preset_sets_autoload_correct(self): """when renaming a preset the autoload status should still be set correctly""" prepare_presets() self.data_manager.load_group(group_key="Foo Device 2") listener = Listener() self.message_broker.subscribe(MessageType.preset, listener) self.data_manager.load_preset(name="preset2") # sends PresetData # sends PresetData with updated name, e. e. should be equal self.data_manager.rename_preset(new_name="foo") self.assertEqual(listener.calls[0].autoload, listener.calls[1].autoload) def test_cannot_rename_preset(self): """rename preset should raise a DataManagementError if a preset with the new name already exists in the current group""" prepare_presets() self.data_manager.load_group(group_key="Foo Device 2") self.data_manager.load_preset(name="preset2") self.assertRaises( ValueError, self.data_manager.rename_preset, new_name="preset3", ) def test_cannot_rename_preset_without_preset(self): prepare_presets() self.assertRaises( DataManagementError, self.data_manager.rename_preset, new_name="foo", ) self.data_manager.load_group(group_key="Foo Device 2") self.assertRaises( DataManagementError, self.data_manager.rename_preset, new_name="foo", ) def test_add_preset(self): """should be able to add a preset""" prepare_presets() self.data_manager.load_group(group_key="Foo Device 2") listener = Listener() self.message_broker.subscribe(MessageType.group, listener) # should emit group_changed self.data_manager.create_preset(name="new preset") presets_in_group = [preset for preset in listener.calls[0].presets] self.assertIn("preset2", presets_in_group) self.assertIn("preset3", presets_in_group) self.assertIn("new preset", presets_in_group) def test_cannot_add_preset(self): """adding a preset with the same name as an already existing preset (of the current group) should raise a DataManagementError""" prepare_presets() self.data_manager.load_group(group_key="Foo Device 2") self.data_manager.load_preset(name="preset2") self.assertRaises( DataManagementError, self.data_manager.create_preset, name="preset3", ) def test_cannot_add_preset_without_group(self): self.assertRaises( DataManagementError, self.data_manager.create_preset, name="foo", ) def test_delete_preset(self): """should be able to delete the current preset""" prepare_presets() self.data_manager.load_group(group_key="Foo Device 2") self.data_manager.load_preset(name="preset2") listener = Listener() self.message_broker.subscribe(MessageType.group, listener) self.message_broker.subscribe(MessageType.preset, listener) self.message_broker.subscribe(MessageType.mapping, listener) # should emit only group_changed self.data_manager.delete_preset() presets_in_group = [preset for preset in listener.calls[0].presets] self.assertEqual(len(presets_in_group), 2) self.assertNotIn("preset2", presets_in_group) self.assertEqual(len(listener.calls), 1) def test_delete_preset_sanitized(self): """should be able to delete the current preset""" Preset(PathUtils.get_preset_path("Qux/[Device]?", "bla")).save() Preset(PathUtils.get_preset_path("Qux/[Device]?", "foo")).save() self.assertTrue( os.path.exists(PathUtils.get_preset_path("Qux/[Device]?", "bla")) ) self.data_manager.load_group(group_key="Qux/[Device]?") self.data_manager.load_preset(name="bla") listener = Listener() self.message_broker.subscribe(MessageType.group, listener) self.message_broker.subscribe(MessageType.preset, listener) self.message_broker.subscribe(MessageType.mapping, listener) # should emit only group_changed self.data_manager.delete_preset() presets_in_group = [preset for preset in listener.calls[0].presets] self.assertEqual(len(presets_in_group), 1) self.assertNotIn("bla", presets_in_group) self.assertIn("foo", presets_in_group) self.assertEqual(len(listener.calls), 1) self.assertFalse( os.path.exists(PathUtils.get_preset_path("Qux/[Device]?", "bla")) ) def test_load_mapping(self): """should be able to load a mapping""" preset, _, _ = prepare_presets() expected_mapping = preset.get_mapping( InputCombination([InputConfig(type=1, code=1)]) ) self.data_manager.load_group(group_key="Foo Device") self.data_manager.load_preset(name="preset1") listener = Listener() self.message_broker.subscribe(MessageType.mapping, listener) self.data_manager.load_mapping( combination=InputCombination([InputConfig(type=1, code=1)]) ) mapping = listener.calls[0] self.assertEqual(mapping, expected_mapping) def test_cannot_load_non_existing_mapping(self): """loading a mapping tha is not present in the preset should raise a KeyError""" prepare_presets() self.data_manager.load_group(group_key="Foo Device 2") self.data_manager.load_preset(name="preset2") self.assertRaises( KeyError, self.data_manager.load_mapping, combination=InputCombination([InputConfig(type=1, code=1)]), ) def test_cannot_load_mapping_without_preset(self): """loading a mapping if no preset is loaded should raise an DataManagementError""" prepare_presets() self.assertRaises( DataManagementError, self.data_manager.load_mapping, combination=InputCombination([InputConfig(type=1, code=1)]), ) self.data_manager.load_group("Foo Device") self.assertRaises( DataManagementError, self.data_manager.load_mapping, combination=InputCombination([InputConfig(type=1, code=1)]), ) def test_load_event(self): prepare_presets() mock = MagicMock() self.message_broker.subscribe(MessageType.selected_event, mock) self.data_manager.load_group("Foo Device") self.data_manager.load_preset("preset1") self.data_manager.load_mapping(InputCombination([InputConfig(type=1, code=1)])) self.data_manager.load_input_config(InputConfig(type=1, code=1)) mock.assert_called_once_with(InputConfig(type=1, code=1)) self.assertEqual( self.data_manager.active_input_config, InputConfig(type=1, code=1) ) def test_cannot_load_event_when_mapping_not_set(self): prepare_presets() self.data_manager.load_group("Foo Device") self.data_manager.load_preset("preset1") with self.assertRaises(DataManagementError): self.data_manager.load_input_config(InputConfig(type=1, code=1)) def test_cannot_load_event_when_not_in_mapping_combination(self): prepare_presets() self.data_manager.load_group("Foo Device") self.data_manager.load_preset("preset1") self.data_manager.load_mapping(InputCombination([InputConfig(type=1, code=1)])) with self.assertRaises(ValueError): self.data_manager.load_input_config(InputConfig(type=1, code=5)) def test_update_event(self): prepare_presets() self.data_manager.load_group("Foo Device") self.data_manager.load_preset("preset1") self.data_manager.load_mapping(InputCombination([InputConfig(type=1, code=1)])) self.data_manager.load_input_config(InputConfig(type=1, code=1)) self.data_manager.update_input_config(InputConfig(type=1, code=5)) self.assertEqual( self.data_manager.active_input_config, InputConfig(type=1, code=5) ) def test_update_event_sends_messages(self): prepare_presets() self.data_manager.load_group("Foo Device") self.data_manager.load_preset("preset1") self.data_manager.load_mapping(InputCombination([InputConfig(type=1, code=1)])) self.data_manager.load_input_config(InputConfig(type=1, code=1)) mock = MagicMock() self.message_broker.subscribe(MessageType.selected_event, mock) self.message_broker.subscribe(MessageType.combination_update, mock) self.message_broker.subscribe(MessageType.mapping, mock) self.data_manager.update_input_config(InputConfig(type=1, code=5)) expected = [ call( CombinationUpdate( InputCombination([InputConfig(type=1, code=1)]), InputCombination([InputConfig(type=1, code=5)]), ) ), call(self.data_manager.active_mapping.get_bus_message()), call(InputConfig(type=1, code=5)), ] mock.assert_has_calls(expected, any_order=False) def test_cannot_update_event_when_resulting_combination_exists(self): prepare_presets() self.data_manager.load_group("Foo Device") self.data_manager.load_preset("preset1") self.data_manager.load_mapping(InputCombination([InputConfig(type=1, code=1)])) self.data_manager.load_input_config(InputConfig(type=1, code=1)) with self.assertRaises(KeyError): self.data_manager.update_input_config(InputConfig(type=1, code=2)) def test_cannot_update_event_when_not_loaded(self): prepare_presets() self.data_manager.load_group("Foo Device") self.data_manager.load_preset("preset1") self.data_manager.load_mapping(InputCombination([InputConfig(type=1, code=1)])) with self.assertRaises(DataManagementError): self.data_manager.update_input_config(InputConfig(type=1, code=2)) def test_update_mapping_emits_mapping_changed(self): """update mapping should emit a mapping_changed event""" prepare_presets() self.data_manager.load_group(group_key="Foo Device 2") self.data_manager.load_preset(name="preset2") self.data_manager.load_mapping( combination=InputCombination([InputConfig(type=1, code=4)]) ) listener = Listener() self.message_broker.subscribe(MessageType.mapping, listener) self.data_manager.update_mapping( name="foo", output_symbol="f", release_timeout=0.3, ) response = listener.calls[0] self.assertEqual(response.name, "foo") self.assertEqual(response.output_symbol, "f") self.assertEqual(response.release_timeout, 0.3) def test_updated_mapping_can_be_saved(self): """make sure that updated changes can be saved""" prepare_presets() self.data_manager.load_group(group_key="Foo Device 2") self.data_manager.load_preset(name="preset2") self.data_manager.load_mapping( combination=InputCombination([InputConfig(type=1, code=4)]) ) self.data_manager.update_mapping( name="foo", output_symbol="f", release_timeout=0.3, ) self.data_manager.save() preset = Preset(PathUtils.get_preset_path("Foo Device", "preset2"), UIMapping) preset.load() mapping = preset.get_mapping(InputCombination([InputConfig(type=1, code=4)])) self.assertEqual(mapping.format_name(), "foo") self.assertEqual(mapping.output_symbol, "f") self.assertEqual(mapping.release_timeout, 0.3) def test_updated_mapping_saves_invalid_mapping(self): """make sure that updated changes can be saved even if they are not valid""" prepare_presets() self.data_manager.load_group(group_key="Foo Device 2") self.data_manager.load_preset(name="preset2") self.data_manager.load_mapping( combination=InputCombination([InputConfig(type=1, code=4)]) ) self.data_manager.update_mapping( output_symbol="bar", # not a macro and not a valid symbol ) self.data_manager.save() preset = Preset(PathUtils.get_preset_path("Foo Device", "preset2"), UIMapping) preset.load() mapping = preset.get_mapping(InputCombination([InputConfig(type=1, code=4)])) self.assertIsNotNone(mapping.get_error()) self.assertEqual(mapping.output_symbol, "bar") def test_update_mapping_combination_sends_massage(self): prepare_presets() self.data_manager.load_group(group_key="Foo Device 2") self.data_manager.load_preset(name="preset2") self.data_manager.load_mapping( combination=InputCombination([InputConfig(type=1, code=4)]) ) listener = Listener() self.message_broker.subscribe(MessageType.mapping, listener) self.message_broker.subscribe(MessageType.combination_update, listener) # we expect a message for combination update first, and then for mapping self.data_manager.update_mapping( input_combination=InputCombination( InputCombination.from_tuples((1, 5), (1, 6)) ) ) self.assertEqual(listener.calls[0].message_type, MessageType.combination_update) self.assertEqual( listener.calls[0].old_combination, InputCombination([InputConfig(type=1, code=4)]), ) self.assertEqual( listener.calls[0].new_combination, InputCombination(InputCombination.from_tuples((1, 5), (1, 6))), ) self.assertEqual(listener.calls[1].message_type, MessageType.mapping) self.assertEqual( listener.calls[1].input_combination, InputCombination(InputCombination.from_tuples((1, 5), (1, 6))), ) def test_cannot_update_mapping_combination(self): """updating a mapping with an already existing combination should raise a KeyError""" prepare_presets() self.data_manager.load_group(group_key="Foo Device 2") self.data_manager.load_preset(name="preset2") self.data_manager.load_mapping( combination=InputCombination([InputConfig(type=1, code=4)]) ) self.assertRaises( KeyError, self.data_manager.update_mapping, input_combination=InputCombination([InputConfig(type=1, code=3)]), ) def test_cannot_update_mapping(self): """updating a mapping should not be possible if the mapping was not loaded""" prepare_presets() self.assertRaises( DataManagementError, self.data_manager.update_mapping, name="foo", ) self.data_manager.load_group(group_key="Foo Device 2") self.assertRaises( DataManagementError, self.data_manager.update_mapping, name="foo", ) self.data_manager.load_preset("preset2") self.assertRaises( DataManagementError, self.data_manager.update_mapping, name="foo", ) def test_create_mapping(self): """should be able to add a mapping to the current preset""" prepare_presets() self.data_manager.load_group(group_key="Foo Device 2") self.data_manager.load_preset(name="preset2") listener = Listener() self.message_broker.subscribe(MessageType.mapping, listener) self.message_broker.subscribe(MessageType.preset, listener) self.data_manager.create_mapping() # emits preset_changed self.data_manager.load_mapping(combination=InputCombination.empty_combination()) self.assertEqual(listener.calls[0].name, "preset2") self.assertEqual(len(listener.calls[0].mappings), 3) self.assertEqual(listener.calls[1], UIMapping()) def test_cannot_create_mapping_without_preset(self): """adding a mapping if not preset is loaded should raise an DataManagementError""" prepare_presets() self.assertRaises(DataManagementError, self.data_manager.create_mapping) self.data_manager.load_group(group_key="Foo Device 2") self.assertRaises(DataManagementError, self.data_manager.create_mapping) def test_delete_mapping(self): """should be able to delete a mapping""" prepare_presets() old_preset = Preset(PathUtils.get_preset_path("Foo Device", "preset2")) old_preset.load() self.data_manager.load_group(group_key="Foo Device 2") self.data_manager.load_preset(name="preset2") self.data_manager.load_mapping( combination=InputCombination([InputConfig(type=1, code=3)]) ) listener = Listener() self.message_broker.subscribe(MessageType.preset, listener) self.message_broker.subscribe(MessageType.mapping, listener) self.data_manager.delete_mapping() # emits preset self.data_manager.save() deleted_mapping = old_preset.get_mapping( InputCombination([InputConfig(type=1, code=3)]) ) mappings = listener.calls[0].mappings preset_name = listener.calls[0].name expected_preset = Preset(PathUtils.get_preset_path("Foo Device", "preset2")) expected_preset.load() expected_mappings = list(expected_preset) self.assertEqual(preset_name, "preset2") for mapping in expected_mappings: self.assertIn(mapping, mappings) self.assertNotIn(deleted_mapping, mappings) def test_cannot_delete_mapping(self): """deleting a mapping should not be possible if the mapping was not loaded""" prepare_presets() self.assertRaises(DataManagementError, self.data_manager.delete_mapping) self.data_manager.load_group(group_key="Foo Device 2") self.assertRaises(DataManagementError, self.data_manager.delete_mapping) self.data_manager.load_preset(name="preset2") self.assertRaises(DataManagementError, self.data_manager.delete_mapping) def test_set_autoload(self): """should be able to set the autoload status""" prepare_presets() self.data_manager.load_group(group_key="Foo Device") listener = Listener() self.message_broker.subscribe(MessageType.preset, listener) self.data_manager.load_preset(name="preset1") # sends updated preset data self.data_manager.set_autoload(True) # sends updated preset data self.data_manager.set_autoload(False) # sends updated preset data self.assertFalse(listener.calls[0].autoload) self.assertTrue(listener.calls[1].autoload) self.assertFalse(listener.calls[2].autoload) def test_each_device_can_have_autoload(self): prepare_presets() self.data_manager.load_group("Foo Device 2") self.data_manager.load_preset("preset1") self.data_manager.set_autoload(True) # switch to another device self.data_manager.load_group("Foo Device") self.data_manager.load_preset("preset1") self.data_manager.set_autoload(True) # now check that both are set to autoload self.data_manager.load_group("Foo Device 2") self.data_manager.load_preset("preset1") self.assertTrue(self.data_manager.get_autoload()) self.data_manager.load_group("Foo Device") self.data_manager.load_preset("preset1") self.assertTrue(self.data_manager.get_autoload()) def test_cannot_set_autoload_without_preset(self): prepare_presets() self.assertRaises( DataManagementError, self.data_manager.set_autoload, True, ) self.data_manager.load_group(group_key="Foo Device 2") self.assertRaises( DataManagementError, self.data_manager.set_autoload, True, ) def test_finds_newest_group(self): Preset(PathUtils.get_preset_path("Foo Device", "preset 1")).save() time.sleep(0.01) Preset(PathUtils.get_preset_path("Bar Device", "preset 2")).save() self.assertEqual(self.data_manager.get_newest_group_key(), "Bar Device") def test_finds_newest_preset(self): Preset(PathUtils.get_preset_path("Foo Device", "preset 1")).save() time.sleep(0.01) Preset(PathUtils.get_preset_path("Foo Device", "preset 2")).save() self.data_manager.load_group("Foo Device") self.assertEqual(self.data_manager.get_newest_preset_name(), "preset 2") def test_newest_group_ignores_unknown_filetypes(self): Preset(PathUtils.get_preset_path("Foo Device", "preset 1")).save() time.sleep(0.01) Preset(PathUtils.get_preset_path("Bar Device", "preset 2")).save() # not a preset, ignore time.sleep(0.01) path = os.path.join(PathUtils.get_preset_path("Foo Device"), "picture.png") os.mknod(path) self.assertEqual(self.data_manager.get_newest_group_key(), "Bar Device") def test_newest_preset_ignores_unknown_filetypes(self): Preset(PathUtils.get_preset_path("Bar Device", "preset 1")).save() time.sleep(0.01) Preset(PathUtils.get_preset_path("Bar Device", "preset 2")).save() time.sleep(0.01) Preset(PathUtils.get_preset_path("Bar Device", "preset 3")).save() # not a preset, ignore time.sleep(0.01) path = os.path.join(PathUtils.get_preset_path("Bar Device"), "picture.png") os.mknod(path) self.data_manager.load_group("Bar Device") self.assertEqual(self.data_manager.get_newest_preset_name(), "preset 3") def test_newest_group_ignores_unknown_groups(self): Preset(PathUtils.get_preset_path("Bar Device", "preset 1")).save() time.sleep(0.01) # not a known group Preset(PathUtils.get_preset_path("unknown_group", "preset 2")).save() self.assertEqual(self.data_manager.get_newest_group_key(), "Bar Device") def test_newest_group_and_preset_raises_file_not_found(self): """should raise file not found error when all preset folders are empty""" self.assertRaises(FileNotFoundError, self.data_manager.get_newest_group_key) os.makedirs(PathUtils.get_preset_path("Bar Device")) self.assertRaises(FileNotFoundError, self.data_manager.get_newest_group_key) self.data_manager.load_group("Bar Device") self.assertRaises(FileNotFoundError, self.data_manager.get_newest_preset_name) def test_newest_preset_raises_data_management_error(self): """should raise data management error without an active group""" self.assertRaises(DataManagementError, self.data_manager.get_newest_preset_name) def test_newest_preset_only_searches_active_group(self): Preset(PathUtils.get_preset_path("Foo Device", "preset 1")).save() time.sleep(0.01) Preset(PathUtils.get_preset_path("Foo Device", "preset 3")).save() time.sleep(0.01) Preset(PathUtils.get_preset_path("Bar Device", "preset 2")).save() self.data_manager.load_group("Foo Device") self.assertEqual(self.data_manager.get_newest_preset_name(), "preset 3") def test_available_preset_name_default(self): self.data_manager.load_group("Foo Device") self.assertEqual( self.data_manager.get_available_preset_name(), DEFAULT_PRESET_NAME ) def test_available_preset_name_adds_number_to_default(self): Preset(PathUtils.get_preset_path("Foo Device", DEFAULT_PRESET_NAME)).save() self.data_manager.load_group("Foo Device") self.assertEqual( self.data_manager.get_available_preset_name(), f"{DEFAULT_PRESET_NAME} 2" ) def test_available_preset_name_returns_provided_name(self): self.data_manager.load_group("Foo Device") self.assertEqual(self.data_manager.get_available_preset_name("bar"), "bar") def test_available_preset_name__adds_number_to_provided_name(self): Preset(PathUtils.get_preset_path("Foo Device", "bar")).save() self.data_manager.load_group("Foo Device") self.assertEqual(self.data_manager.get_available_preset_name("bar"), "bar 2") def test_available_preset_name_raises_data_management_error(self): """should raise DataManagementError when group is not set""" self.assertRaises( DataManagementError, self.data_manager.get_available_preset_name ) def test_get_preset_names(self): self.data_manager.load_group("Qux/[Device]?") Preset(PathUtils.get_preset_path("Qux/[Device]?", "new preset")).save() # get_preset_names uses glob, the special characters in the device name # don't break it. self.assertEqual(self.data_manager.get_preset_names(), ("new preset",)) def test_available_preset_name_sanitized(self): self.data_manager.load_group("Qux/[Device]?") self.assertEqual( self.data_manager.get_available_preset_name(), DEFAULT_PRESET_NAME ) Preset(PathUtils.get_preset_path("Qux/[Device]?", DEFAULT_PRESET_NAME)).save() self.assertEqual( self.data_manager.get_available_preset_name(), f"{DEFAULT_PRESET_NAME} 2" ) Preset(PathUtils.get_preset_path("Qux/[Device]?", "foo")).save() self.assertEqual(self.data_manager.get_available_preset_name("foo"), "foo 2") def test_available_preset_name_increments_default(self): Preset(PathUtils.get_preset_path("Foo Device", DEFAULT_PRESET_NAME)).save() Preset( PathUtils.get_preset_path("Foo Device", f"{DEFAULT_PRESET_NAME} 2") ).save() Preset( PathUtils.get_preset_path("Foo Device", f"{DEFAULT_PRESET_NAME} 3") ).save() self.data_manager.load_group("Foo Device") self.assertEqual( self.data_manager.get_available_preset_name(), f"{DEFAULT_PRESET_NAME} 4" ) def test_available_preset_name_increments_provided_name(self): Preset(PathUtils.get_preset_path("Foo Device", "foo")).save() Preset(PathUtils.get_preset_path("Foo Device", "foo 1")).save() Preset(PathUtils.get_preset_path("Foo Device", "foo 2")).save() self.data_manager.load_group("Foo Device") self.assertEqual(self.data_manager.get_available_preset_name("foo 1"), "foo 3") def test_should_publish_groups(self): listener = Listener() self.message_broker.subscribe(MessageType.groups, listener) self.data_manager.publish_groups() data = listener.calls[0] # we expect a list of tuples with the group key and their device types self.assertEqual( data.groups, { "Foo Device": ["keyboard"], "Foo Device 2": ["gamepad", "keyboard", "mouse"], "Bar Device": ["keyboard"], "gamepad": ["gamepad"], "Qux/[Device]?": ["keyboard"], }, ) def test_should_load_group(self): prepare_presets() listener = Listener() self.message_broker.subscribe(MessageType.group, listener) self.data_manager.load_group("Foo Device 2") self.assertEqual(self.data_manager.active_group.key, "Foo Device 2") data = ( GroupData("Foo Device 2", (p1, p2, p3)) for p1, p2, p3 in permutations(("preset3", "preset2", "preset1")) ) self.assertIn(listener.calls[0], data) def test_should_start_reading_active_group(self): def f(*_): raise AssertionError() self.reader.set_group = f self.assertRaises(AssertionError, self.data_manager.load_group, "Foo Device") def test_should_send_uinputs(self): listener = Listener() self.message_broker.subscribe(MessageType.uinputs, listener) self.data_manager.publish_uinputs() data = listener.calls[0] # we expect a list of tuples with the group key and their device types self.assertEqual( data.uinputs, { "gamepad": self.uinputs.get_uinput("gamepad").capabilities(), "keyboard": self.uinputs.get_uinput("keyboard").capabilities(), "mouse": self.uinputs.get_uinput("mouse").capabilities(), "keyboard + mouse": self.uinputs.get_uinput( "keyboard + mouse" ).capabilities(), }, ) def test_cannot_stop_injecting_without_group(self): self.assertRaises(DataManagementError, self.data_manager.stop_injecting) def test_cannot_start_injecting_without_preset(self): self.data_manager.load_group("Foo Device") self.assertRaises(DataManagementError, self.data_manager.start_injecting) def test_cannot_get_injector_state_without_group(self): self.assertRaises(DataManagementError, self.data_manager.get_state) input-remapper-2.1.1/tests/unit/test_event_pipeline/000077500000000000000000000000001475433465200226665ustar00rootroot00000000000000input-remapper-2.1.1/tests/unit/test_event_pipeline/__init__.py000066400000000000000000000000001475433465200247650ustar00rootroot00000000000000input-remapper-2.1.1/tests/unit/test_event_pipeline/test_axis_transformation.py000066400000000000000000000166531475433465200304040ustar00rootroot00000000000000#!/usr/bin/env python3 # -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings # Copyright (C) 2025 sezanzeb # # This file is part of input-remapper. # # input-remapper is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # input-remapper is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with input-remapper. If not, see . import dataclasses import functools import itertools import unittest from typing import Iterable, List from inputremapper.injection.mapping_handlers.axis_transform import Transformation from tests.lib.test_setup import test_setup @test_setup class TestAxisTransformation(unittest.TestCase): @dataclasses.dataclass class InitArgs: max_: int min_: int deadzone: float gain: float expo: float def values(self): return self.__dict__.values() def get_init_args( self, max_=(255, 1000, 2**15), min_=(50, 0, -255), deadzone=(0, 0.5), gain=(0.5, 1, 2), expo=(-0.9, 0, 0.3), ) -> Iterable[InitArgs]: for args in itertools.product(max_, min_, deadzone, gain, expo): yield self.InitArgs(*args) @staticmethod def scale_to_range(min_, max_, x=(-1, -0.2, 0, 0.6, 1)) -> List[float]: """Scale values between -1 and 1 up, such that they are between min and max.""" half_range = (max_ - min_) / 2 return [float_x * half_range + min_ + half_range for float_x in x] def test_scale_to_range(self): """Make sure scale_to_range will actually return the min and max values (avoid "off by one" errors)""" max_ = (255, 1000, 2**15) min_ = (50, 0, -255) for x1, x2 in itertools.product(min_, max_): scaled = self.scale_to_range(x1, x2, (-1, 1)) self.assertEqual(scaled, [x1, x2]) def test_expo_symmetry(self): """Test that the transformation is symmetric for expo parameter x = f(g(x)), if f._expo == - g._expo with the following constraints: min = -1, max = 1 gain = 1 deadzone = 0 we can remove the constraints for min, max and gain, by scaling the values appropriately after each transformation """ for init_args in self.get_init_args(deadzone=(0,)): f = Transformation(*init_args.values()) init_args.expo = -init_args.expo g = Transformation(*init_args.values()) scale = functools.partial( self.scale_to_range, init_args.min_, init_args.max_, ) for x in scale(): y1 = g(x) y1 = y1 / init_args.gain # remove the gain y1 = scale((y1,))[0] # remove the min/max constraint y2 = f(y1) y2 = y2 / init_args.gain # remove the gain y2 = scale((y2,))[0] # remove the min/max constraint self.assertAlmostEqual(x, y2, msg=f"test expo symmetry for {init_args}") def test_origin_symmetry(self): """Test that the transformation is symmetric to the origin_hash f(x) = - f(-x) within the constraints: min = -max """ for init_args in self.get_init_args(): init_args.min_ = -init_args.max_ f = Transformation(*init_args.values()) for x in self.scale_to_range(init_args.min_, init_args.max_): self.assertAlmostEqual( f(x), -f(-x), msg=f"test origin_hash symmetry at {x=} for {init_args}", ) def test_gain(self): """Test that f(max) = gain and f(min) = -gain.""" for init_args in self.get_init_args(): f = Transformation(*init_args.values()) self.assertAlmostEqual( f(init_args.max_), init_args.gain, msg=f"test gain for {init_args}", ) self.assertAlmostEqual( f(init_args.min_), -init_args.gain, msg=f"test gain for {init_args}", ) def test_deadzone(self): """Test the Transfomation returns exactly 0 in the range of the deadzone.""" for init_args in self.get_init_args(deadzone=(0.1, 0.2, 0.9)): f = Transformation(*init_args.values()) for x in self.scale_to_range( init_args.min_, init_args.max_, x=( init_args.deadzone * 0.999, -init_args.deadzone * 0.999, 0.3 * init_args.deadzone, 0, ), ): self.assertEqual(f(x), 0, msg=f"test deadzone at {x=} for {init_args}") def test_continuity_near_deadzone(self): """Test that the Transfomation is continues (no sudden jump) next to the deadzone""" for init_args in self.get_init_args(deadzone=(0.1, 0.2, 0.9)): f = Transformation(*init_args.values()) scale = functools.partial( self.scale_to_range, init_args.min_, init_args.max_, ) x = ( init_args.deadzone * 1.00001, init_args.deadzone * 1.001, -init_args.deadzone * 1.00001, -init_args.deadzone * 1.001, ) scaled_x = scale(x=x) p1 = (x[0], f(scaled_x[0])) # first point right of deadzone p2 = (x[1], f(scaled_x[1])) # second point right of deadzone # calculate a linear function y = m * x + b from p1 and p2 m = (p1[1] - p2[1]) / (p1[0] - p2[0]) b = p1[1] - m * p1[0] # the zero intersection of that function must be close to the # edge of the deadzone self.assertAlmostEqual( -b / m, init_args.deadzone, places=5, msg=f"test continuity at {init_args.deadzone} for {init_args}", ) # same thing on the other side p1 = (x[2], f(scaled_x[2])) p2 = (x[3], f(scaled_x[3])) m = (p1[1] - p2[1]) / (p1[0] - p2[0]) b = p1[1] - m * p1[0] self.assertAlmostEqual( -b / m, -init_args.deadzone, places=5, msg=f"test continuity at {- init_args.deadzone} for {init_args}", ) def test_expo_out_of_range(self): f = Transformation(deadzone=0.1, min_=-20, max_=5, expo=1.3) self.assertRaises(ValueError, f, 0) f = Transformation(deadzone=0.1, min_=-20, max_=5, expo=-1.3) self.assertRaises(ValueError, f, 0) def test_returns_one_for_range_between_minus_and_plus_one(self): for init_args in self.get_init_args(max_=(1,), min_=(-1,), gain=(1,)): f = Transformation(*init_args.values()) self.assertEqual(f(1), 1) self.assertEqual(f(-1), -1) input-remapper-2.1.1/tests/unit/test_event_pipeline/test_event_pipeline.py000066400000000000000000002234121475433465200273110ustar00rootroot00000000000000#!/usr/bin/env python3 # -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings # Copyright (C) 2025 sezanzeb # # This file is part of input-remapper. # # input-remapper is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # input-remapper is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with input-remapper. If not, see . import asyncio import unittest from typing import Iterable import evdev from evdev.ecodes import ( EV_KEY, EV_ABS, EV_REL, ABS_X, ABS_Y, REL_X, REL_Y, BTN_A, REL_HWHEEL, REL_WHEEL, REL_WHEEL_HI_RES, REL_HWHEEL_HI_RES, ABS_HAT0X, BTN_LEFT, BTN_B, KEY_A, ABS_HAT0Y, KEY_B, KEY_C, KEY_D, BTN_TL, KEY_1, ) from inputremapper.configs.mapping import ( Mapping, REL_XY_SCALING, WHEEL_SCALING, WHEEL_HI_RES_SCALING, DEFAULT_REL_RATE, ) from inputremapper.configs.preset import Preset from inputremapper.configs.keyboard_layout import keyboard_layout from inputremapper.configs.input_config import InputCombination, InputConfig from inputremapper.injection.context import Context from inputremapper.injection.event_reader import EventReader from inputremapper.injection.global_uinputs import GlobalUInputs, UInput from inputremapper.injection.mapping_handlers.mapping_parser import MappingParser from inputremapper.input_event import InputEvent from tests.lib.cleanup import cleanup from tests.lib.logger import logger from tests.lib.constants import MAX_ABS, MIN_ABS from tests.lib.fixtures import Fixture, fixtures from tests.lib.pipes import uinput_write_history from tests.lib.test_setup import test_setup class EventPipelineTestBase(unittest.IsolatedAsyncioTestCase): """Test the event pipeline form event_reader to UInput.""" def setUp(self): self.global_uinputs = GlobalUInputs(UInput) self.global_uinputs.prepare_all() self.mapping_parser = MappingParser(self.global_uinputs) self.global_uinputs.is_service = True self.global_uinputs.prepare_all() self.forward_uinput = evdev.UInput(name="test-forward-uinput") self.stop_event = asyncio.Event() def tearDown(self) -> None: cleanup() async def asyncTearDown(self) -> None: logger.info("setting stop_event for the reader") self.stop_event.set() await asyncio.sleep(0.5) @staticmethod async def send_events(events: Iterable[InputEvent], event_reader: EventReader): for event in events: logger.info("sending into event_pipeline: %s", event) await event_reader.handle(event) def create_event_reader( self, preset: Preset, source: Fixture, ) -> EventReader: """Create and start an EventReader.""" context = Context( preset, source_devices={}, forward_devices={source.get_device_hash(): self.forward_uinput}, mapping_parser=self.mapping_parser, ) reader = EventReader( context, evdev.InputDevice(source.path), self.stop_event, ) asyncio.ensure_future(reader.run()) return reader @test_setup class TestCombination(EventPipelineTestBase): async def test_any_event_as_button(self): """As long as there is an event handler and a mapping we should be able to map anything to a button""" # value needs to be higher than 10% below center of axis (absinfo) w_down = (EV_ABS, ABS_Y, -12345) w_up = (EV_ABS, ABS_Y, 0) s_down = (EV_ABS, ABS_Y, 12345) s_up = (EV_ABS, ABS_Y, 0) d_down = (EV_REL, REL_X, 100) d_up = (EV_REL, REL_X, 0) a_down = (EV_REL, REL_X, -100) a_up = (EV_REL, REL_X, 0) b_down = (EV_ABS, ABS_HAT0X, 1) b_up = (EV_ABS, ABS_HAT0X, 0) c_down = (EV_ABS, ABS_HAT0X, -1) c_up = (EV_ABS, ABS_HAT0X, 0) # first change the system mapping because Mapping will validate against it keyboard_layout.clear() code_w = 71 code_b = 72 code_c = 73 code_d = 74 code_a = 75 code_s = 76 keyboard_layout._set("w", code_w) keyboard_layout._set("d", code_d) keyboard_layout._set("a", code_a) keyboard_layout._set("s", code_s) keyboard_layout._set("b", code_b) keyboard_layout._set("c", code_c) preset = Preset() preset.add( Mapping.from_combination( InputCombination(InputCombination.from_tuples(b_down)), "keyboard", "b" ) ) preset.add( Mapping.from_combination( InputCombination(InputCombination.from_tuples(c_down)), "keyboard", "c" ) ) preset.add( Mapping.from_combination( InputCombination(InputCombination.from_tuples((*w_down[:2], -10))), "keyboard", "w", ) ) preset.add( Mapping.from_combination( InputCombination(InputCombination.from_tuples((*d_down[:2], 10))), "keyboard", "k(d)", ) ) preset.add( Mapping.from_combination( InputCombination(InputCombination.from_tuples((*s_down[:2], 10))), "keyboard", "s", ) ) preset.add( Mapping.from_combination( InputCombination(InputCombination.from_tuples((*a_down[:2], -10))), "keyboard", "a", ) ) # gamepad fixture event_reader = self.create_event_reader(preset, fixtures.gamepad) await self.send_events( [ InputEvent.from_tuple(b_down), InputEvent.from_tuple(c_down), InputEvent.from_tuple(w_down), InputEvent.from_tuple(d_down), InputEvent.from_tuple(s_down), InputEvent.from_tuple(a_down), InputEvent.from_tuple(b_up), InputEvent.from_tuple(c_up), InputEvent.from_tuple(w_up), InputEvent.from_tuple(d_up), InputEvent.from_tuple(s_up), InputEvent.from_tuple(a_up), ], event_reader, ) # wait a bit for the rel_to_btn handler to send the key up await asyncio.sleep(0.1) history = self.global_uinputs.get_uinput("keyboard").write_history self.assertEqual(history.count((EV_KEY, code_b, 1)), 1) self.assertEqual(history.count((EV_KEY, code_c, 1)), 1) self.assertEqual(history.count((EV_KEY, code_w, 1)), 1) self.assertEqual(history.count((EV_KEY, code_d, 1)), 1) self.assertEqual(history.count((EV_KEY, code_a, 1)), 1) self.assertEqual(history.count((EV_KEY, code_s, 1)), 1) self.assertEqual(history.count((EV_KEY, code_b, 0)), 1) self.assertEqual(history.count((EV_KEY, code_c, 0)), 1) self.assertEqual(history.count((EV_KEY, code_w, 0)), 1) self.assertEqual(history.count((EV_KEY, code_d, 0)), 1) self.assertEqual(history.count((EV_KEY, code_a, 0)), 1) self.assertEqual(history.count((EV_KEY, code_s, 0)), 1) async def test_reset_releases_keys(self): """Make sure that macros and keys are releases when the stop event is set.""" preset = Preset() input_cfg = InputCombination([InputConfig(type=EV_KEY, code=1)]).to_config() preset.add( Mapping.from_combination( input_combination=input_cfg, output_symbol="hold(a)" ) ) input_cfg = InputCombination([InputConfig(type=EV_KEY, code=2)]).to_config() preset.add( Mapping.from_combination(input_combination=input_cfg, output_symbol="b") ) input_cfg = InputCombination([InputConfig(type=EV_KEY, code=3)]).to_config() preset.add( Mapping.from_combination( input_combination=input_cfg, output_symbol="modify(c,hold(d))" ), ) event_reader = self.create_event_reader(preset, fixtures.foo_device_2_keyboard) a = keyboard_layout.get("a") b = keyboard_layout.get("b") c = keyboard_layout.get("c") d = keyboard_layout.get("d") await self.send_events( [ InputEvent.key(1, 1), InputEvent.key(2, 1), InputEvent.key(3, 1), ], event_reader, ) await asyncio.sleep(0.1) forwarded_history = self.forward_uinput.write_history keyboard_history = self.global_uinputs.get_uinput("keyboard").write_history self.assertEqual(len(forwarded_history), 0) # a down, b down, c down, d down self.assertEqual(len(keyboard_history), 4) event_reader.context.reset() await asyncio.sleep(0.1) forwarded_history = self.forward_uinput.write_history keyboard_history = self.global_uinputs.get_uinput("keyboard").write_history self.assertEqual(len(forwarded_history), 0) # all a, b, c, d down+up self.assertEqual(len(keyboard_history), 8) keyboard_history = keyboard_history[-4:] self.assertIn((EV_KEY, a, 0), keyboard_history) self.assertIn((EV_KEY, b, 0), keyboard_history) self.assertIn((EV_KEY, c, 0), keyboard_history) self.assertIn((EV_KEY, d, 0), keyboard_history) async def test_forward_abs(self): """Test if EV_ABS events are forwarded when other events of the same input are not.""" preset = Preset() # BTN_A -> 77 keyboard_layout._set("b", 77) preset.add( Mapping.from_combination( InputCombination([InputConfig(type=EV_KEY, code=BTN_A)]), "keyboard", "b", ) ) event_reader = self.create_event_reader(preset, fixtures.gamepad) # should forward them unmodified await self.send_events( [ InputEvent.abs(ABS_X, 10), InputEvent.abs(ABS_Y, 20), InputEvent.abs(ABS_X, -30), InputEvent.abs(ABS_Y, -40), # send them to keyboard 77 InputEvent.key(BTN_A, 1), InputEvent.key(BTN_A, 0), ], event_reader, ) history = self.forward_uinput.write_history keyboard_history = self.global_uinputs.get_uinput("keyboard").write_history self.assertEqual(history.count((EV_ABS, ABS_X, 10)), 1) self.assertEqual(history.count((EV_ABS, ABS_Y, 20)), 1) self.assertEqual(history.count((EV_ABS, ABS_X, -30)), 1) self.assertEqual(history.count((EV_ABS, ABS_Y, -40)), 1) self.assertEqual(keyboard_history.count((EV_KEY, 77, 1)), 1) self.assertEqual(keyboard_history.count((EV_KEY, 77, 0)), 1) async def test_forward_rel(self): """Test if EV_REL events are forwarded when other events of the same input are not.""" preset = Preset() # BTN_A -> 77 keyboard_layout._set("b", 77) preset.add( Mapping.from_combination( InputCombination([InputConfig(type=EV_KEY, code=BTN_LEFT)]), "keyboard", "b", ) ) event_reader = self.create_event_reader(preset, fixtures.gamepad) # should forward them unmodified await self.send_events( [ InputEvent.rel(REL_X, 10), InputEvent.rel(REL_Y, 20), InputEvent.rel(REL_X, -30), InputEvent.rel(REL_Y, -40), # send them to keyboard 77 InputEvent.key(BTN_LEFT, 1), InputEvent.key(BTN_LEFT, 0), ], event_reader, ) await asyncio.sleep(0.1) history = self.forward_uinput.write_history keyboard_history = self.global_uinputs.get_uinput("keyboard").write_history self.assertEqual(history.count((EV_REL, REL_X, 10)), 1) self.assertEqual(history.count((EV_REL, REL_Y, 20)), 1) self.assertEqual(history.count((EV_REL, REL_X, -30)), 1) self.assertEqual(history.count((EV_REL, REL_Y, -40)), 1) self.assertEqual(keyboard_history.count((EV_KEY, 77, 1)), 1) self.assertEqual(keyboard_history.count((EV_KEY, 77, 0)), 1) async def test_combination(self): """Test if combinations map to keys properly.""" a = keyboard_layout.get("a") b = keyboard_layout.get("b") c = keyboard_layout.get("c") origin = fixtures.gamepad origin_hash = origin.get_device_hash() mapping_1 = Mapping.from_combination( InputCombination( [ InputConfig( type=EV_ABS, code=ABS_X, analog_threshold=1, origin_hash=origin_hash, ) ] ), output_symbol="a", ) mapping_2 = Mapping.from_combination( InputCombination( [ InputConfig( type=EV_ABS, code=ABS_X, analog_threshold=1, origin_hash=origin_hash, ), InputConfig(type=EV_KEY, code=BTN_A, origin_hash=origin_hash), ] ), output_symbol="b", ) mapping_3 = Mapping.from_combination( InputCombination( [ InputConfig( type=EV_ABS, code=ABS_X, analog_threshold=1, origin_hash=origin_hash, ), InputConfig(type=EV_KEY, code=BTN_A, origin_hash=origin_hash), InputConfig(type=EV_KEY, code=BTN_B, origin_hash=origin_hash), ] ), output_symbol="c", ) assert mapping_1.release_combination_keys assert mapping_2.release_combination_keys assert mapping_3.release_combination_keys preset = Preset() preset.add(mapping_1) preset.add(mapping_2) preset.add(mapping_3) event_reader = self.create_event_reader(preset, origin) # send_events awaits the event_reader to do its thing await self.send_events( [ # forwarded InputEvent.key(BTN_A, 1, origin_hash), # triggers b, releases BTN_A, ABS_X InputEvent.abs(ABS_X, 1234, origin_hash), # triggers c, releases BTN_A, ABS_X, BTN_B InputEvent.key(BTN_B, 1, origin_hash), ], event_reader, ) keyboard_history = self.global_uinputs.get_uinput("keyboard").write_history forwarded_history = self.forward_uinput.write_history # I don't remember the specifics. I guess if there is a combination ongoing, # it shouldn't trigger ABS_X -> a? self.assertNotIn((EV_KEY, a, 1), keyboard_history) # c and b should have been written, because the input from send_events # should trigger the combination self.assertEqual(keyboard_history.count((EV_KEY, c, 1)), 1) self.assertEqual(keyboard_history.count((EV_KEY, b, 1)), 1) self.assertEqual(forwarded_history.count((EV_KEY, BTN_A, 1)), 1) self.assertIn((EV_KEY, BTN_A, 0), forwarded_history) self.assertNotIn((EV_ABS, ABS_X, 1234), forwarded_history) self.assertNotIn((EV_KEY, BTN_B, 1), forwarded_history) # release b and c) await self.send_events( [InputEvent.abs(ABS_X, 0, origin_hash)], event_reader, ) keyboard_history = self.global_uinputs.get_uinput("keyboard").write_history self.assertNotIn((EV_KEY, a, 1), keyboard_history) self.assertNotIn((EV_KEY, a, 0), keyboard_history) self.assertEqual(keyboard_history.count((EV_KEY, c, 0)), 1) self.assertEqual(keyboard_history.count((EV_KEY, b, 0)), 1) async def test_combination_manual_release_in_press_order(self): """Test if combinations map to keys properly.""" # release_combination_keys is off # press 5, then 6, then release 5, then release 6 in_1 = keyboard_layout.get("7") in_2 = keyboard_layout.get("8") out = keyboard_layout.get("a") origin = fixtures.foo_device_2_keyboard origin_hash = origin.get_device_hash() input_combination = InputCombination( [ InputConfig( type=EV_KEY, code=in_1, origin_hash=origin_hash, ), InputConfig( type=EV_KEY, code=in_2, origin_hash=origin_hash, ), ] ) mapping = Mapping( input_combination=input_combination.to_config(), target_uinput="keyboard", output_symbol="a", release_combination_keys=False, ) assert not mapping.release_combination_keys preset = Preset() preset.add(mapping) event_reader = self.create_event_reader(preset, origin) keyboard_history = self.global_uinputs.get_uinput("keyboard").write_history forwarded_history = self.forward_uinput.write_history # press the first key of the combination await self.send_events([InputEvent.key(in_1, 1, origin_hash)], event_reader) self.assertListEqual(forwarded_history, [(EV_KEY, in_1, 1)]) # then the second, it should trigger the combination await self.send_events([InputEvent.key(in_2, 1, origin_hash)], event_reader) self.assertListEqual(forwarded_history, [(EV_KEY, in_1, 1)]) self.assertListEqual(keyboard_history, [(EV_KEY, out, 1)]) # release the first key. A key-down event was injected for it previously, so # now we find a key-up event here as well. await self.send_events([InputEvent.key(in_1, 0, origin_hash)], event_reader) self.assertListEqual(forwarded_history, [(EV_KEY, in_1, 1), (EV_KEY, in_1, 0)]) self.assertListEqual(keyboard_history, [(EV_KEY, out, 1), (EV_KEY, out, 0)]) # release the second key. No key-down event was injected, so we don't have a # key-up event here either. await self.send_events([InputEvent.key(in_2, 0, origin_hash)], event_reader) self.assertListEqual(forwarded_history, [(EV_KEY, in_1, 1), (EV_KEY, in_1, 0)]) self.assertListEqual(keyboard_history, [(EV_KEY, out, 1), (EV_KEY, out, 0)]) async def test_releases_before_triggering(self): origin = fixtures.foo_device_2_keyboard origin_hash = origin.get_device_hash() input_combination = InputCombination( [ InputConfig( type=EV_KEY, code=KEY_A, origin_hash=origin_hash, ), InputConfig( type=EV_KEY, code=KEY_B, origin_hash=origin_hash, ), ] ) mapping = Mapping( input_combination=input_combination.to_config(), target_uinput="keyboard", output_symbol="1", release_combination_keys=True, ) preset = Preset() preset.add(mapping) event_reader = self.create_event_reader(preset, origin) await self.send_events( [ InputEvent.key(KEY_A, 1, origin_hash), InputEvent.key(KEY_B, 1, origin_hash), ], event_reader, ) # Other tests check forwarded_history and keyboard_history individually, # so here is one that looks at the order in uinput_write_history self.assertListEqual( uinput_write_history, [ (EV_KEY, KEY_A, 1), (EV_KEY, KEY_A, 0), (EV_KEY, KEY_1, 1), ], ) async def test_suppressed_doesnt_forward_releases(self): origin = fixtures.foo_device_2_keyboard origin_hash = origin.get_device_hash() input_combination_1 = InputCombination( [ InputConfig( type=EV_KEY, code=KEY_A, origin_hash=origin_hash, ), InputConfig( type=EV_KEY, code=KEY_B, origin_hash=origin_hash, ), InputConfig( type=EV_KEY, code=KEY_C, origin_hash=origin_hash, ), ] ) mapping_1 = Mapping( input_combination=input_combination_1.to_config(), target_uinput="keyboard", output_symbol="1", release_combination_keys=False, ) input_combination_2 = InputCombination( [ InputConfig( type=EV_KEY, code=KEY_A, origin_hash=origin_hash, ), InputConfig( type=EV_KEY, code=KEY_C, origin_hash=origin_hash, ), ] ) mapping_2 = Mapping( input_combination=input_combination_2.to_config(), target_uinput="keyboard", output_symbol="2", release_combination_keys=True, ) preset = Preset() preset.add(mapping_1) preset.add(mapping_2) event_reader = self.create_event_reader(preset, origin) # Trigger mapping_1, mapping_2 should be suppressed await self.send_events( [ InputEvent.key(KEY_A, 1, origin_hash), InputEvent.key(KEY_B, 1, origin_hash), InputEvent.key(KEY_C, 1, origin_hash), ], event_reader, ) keyboard_history = self.global_uinputs.get_uinput("keyboard").write_history forwarded_history = self.forward_uinput.write_history # There used to be an incorrect KEY_A release, caused by # `release_combination_keys` on the suppressed mapping. self.assertListEqual( forwarded_history, [ (EV_KEY, KEY_A, 1), (EV_KEY, KEY_B, 1), ], ) self.assertListEqual( keyboard_history, [ (EV_KEY, KEY_1, 1), ], ) async def test_no_stuck_key(self): origin = fixtures.foo_device_2_keyboard origin_hash = origin.get_device_hash() input_combination_1 = InputCombination( [ InputConfig( type=EV_KEY, code=KEY_A, origin_hash=origin_hash, ), InputConfig( type=EV_KEY, code=KEY_B, origin_hash=origin_hash, ), InputConfig( type=EV_KEY, code=KEY_D, origin_hash=origin_hash, ), ] ) mapping_1 = Mapping( input_combination=input_combination_1.to_config(), target_uinput="keyboard", output_symbol="1", release_combination_keys=False, ) input_combination_2 = InputCombination( [ InputConfig( type=EV_KEY, code=KEY_C, origin_hash=origin_hash, ), InputConfig( type=EV_KEY, code=KEY_D, origin_hash=origin_hash, ), ] ) mapping_2 = Mapping( input_combination=input_combination_2.to_config(), target_uinput="keyboard", output_symbol="2", release_combination_keys=False, ) preset = Preset() preset.add(mapping_1) preset.add(mapping_2) event_reader = self.create_event_reader(preset, origin) # Trigger mapping_1, mapping_2 should be suppressed await self.send_events( [ InputEvent.key(KEY_A, 1, origin_hash), InputEvent.key(KEY_B, 1, origin_hash), InputEvent.key(KEY_C, 1, origin_hash), InputEvent.key(KEY_D, 1, origin_hash), # Release c -> mapping_2 transitions from suppressed to passive. # The release of c should be forwarded. InputEvent.key(KEY_C, 0, origin_hash), ], event_reader, ) keyboard_history = self.global_uinputs.get_uinput("keyboard").write_history forwarded_history = self.forward_uinput.write_history self.assertListEqual( forwarded_history, [ (EV_KEY, KEY_A, 1), (EV_KEY, KEY_B, 1), (EV_KEY, KEY_C, 1), (EV_KEY, KEY_C, 0), ], ) self.assertListEqual( keyboard_history, [ (EV_KEY, KEY_1, 1), ], ) async def test_ignore_hold(self): # hold as in event-value 2, not in macro-hold. # linux will generate events with value 2 after input-remapper injected # the key-press, so input-remapper doesn't need to forward them. That # would cause duplicate events of those values otherwise. ev_1 = InputEvent.key(KEY_A, 1) ev_2 = InputEvent.key(KEY_A, 2) ev_3 = InputEvent.key(KEY_A, 0) preset = Preset() preset.add( Mapping.from_combination( input_combination=InputCombination( [InputConfig(type=EV_KEY, code=KEY_A)] ), output_symbol="a", ) ) a = keyboard_layout.get("a") event_reader = self.create_event_reader(preset, fixtures.gamepad) await self.send_events( [ev_1, ev_2, ev_3], event_reader, ) keyboard_history = self.global_uinputs.get_uinput("keyboard").write_history forwarded_history = self.forward_uinput.write_history self.assertEqual(len(keyboard_history), 2) self.assertEqual(len(forwarded_history), 0) self.assertNotIn((EV_KEY, a, 2), keyboard_history) async def test_ignore_disabled(self): origin = fixtures.gamepad origin_hash = origin.get_device_hash() ev_1 = InputEvent.abs(ABS_HAT0Y, 1, origin_hash) ev_2 = InputEvent.abs(ABS_HAT0Y, 0, origin_hash) ev_3 = InputEvent.abs(ABS_HAT0X, 1, origin_hash) # disabled ev_4 = InputEvent.abs(ABS_HAT0X, 0, origin_hash) ev_5 = InputEvent.key(KEY_A, 1, origin_hash) ev_6 = InputEvent.key(KEY_A, 0, origin_hash) combi_1 = (ev_5, ev_3) combi_2 = (ev_3, ev_5) preset = Preset() preset.add( Mapping.from_combination( input_combination=InputCombination( [ InputConfig.from_input_event(ev_1), ] ), output_symbol="a", ) ) preset.add( Mapping.from_combination( input_combination=InputCombination( [ InputConfig.from_input_event(ev_3), ] ), output_symbol="disable", ) ) preset.add( Mapping.from_combination( input_combination=InputCombination( ( InputConfig.from_input_event(combi_1[0]), InputConfig.from_input_event(combi_1[1]), ) ), output_symbol="b", ) ) preset.add( Mapping.from_combination( input_combination=InputCombination( ( InputConfig.from_input_event(combi_2[0]), InputConfig.from_input_event(combi_2[1]), ) ), output_symbol="c", ) ) a = keyboard_layout.get("a") b = keyboard_layout.get("b") c = keyboard_layout.get("c") event_reader = self.create_event_reader(preset, origin) """Single keys""" await self.send_events( [ ev_1, # press a ev_3, # disabled ev_2, # release a ev_4, # disabled ], event_reader, ) keyboard_history = self.global_uinputs.get_uinput("keyboard").write_history forwarded_history = self.forward_uinput.write_history self.assertIn((EV_KEY, a, 1), keyboard_history) self.assertIn((EV_KEY, a, 0), keyboard_history) self.assertEqual(len(keyboard_history), 2) self.assertEqual(len(forwarded_history), 0) """A combination that ends in a disabled key""" # ev_5 should be forwarded and the combination triggered await self.send_events(combi_1, event_reader) keyboard_history = self.global_uinputs.get_uinput("keyboard").write_history forwarded_history = self.forward_uinput.write_history self.assertIn((EV_KEY, b, 1), keyboard_history) self.assertEqual(len(keyboard_history), 3) self.assertEqual(forwarded_history.count(ev_3), 0) self.assertEqual(forwarded_history.count(ev_5), 1) self.assertTrue(forwarded_history.count(ev_6) >= 1) # release what the combination maps to await self.send_events([ev_4, ev_6], event_reader) keyboard_history = self.global_uinputs.get_uinput("keyboard").write_history forwarded_history = self.forward_uinput.write_history self.assertIn((EV_KEY, b, 0), keyboard_history) self.assertEqual(len(keyboard_history), 4) self.assertEqual(forwarded_history.count(ev_3), 0) self.assertTrue(forwarded_history.count(ev_6) >= 1) """A combination that starts with a disabled key""" # only the combination should get triggered await self.send_events(combi_2, event_reader) keyboard_history = self.global_uinputs.get_uinput("keyboard").write_history forwarded_history = self.forward_uinput.write_history self.assertIn((EV_KEY, c, 1), keyboard_history) self.assertEqual(len(keyboard_history), 5) self.assertEqual(forwarded_history.count(ev_3), 0) self.assertEqual(forwarded_history.count(ev_5), 1) self.assertTrue(forwarded_history.count(ev_6) >= 1) # release what the combination maps to await self.send_events([ev_4, ev_6], event_reader) keyboard_history = self.global_uinputs.get_uinput("keyboard").write_history forwarded_history = self.forward_uinput.write_history for event in keyboard_history: print(event.event_tuple) self.assertIn((EV_KEY, c, 0), keyboard_history) self.assertEqual(len(keyboard_history), 6) self.assertEqual(forwarded_history.count(ev_3), 0) self.assertTrue(forwarded_history.count(ev_6) >= 1) async def test_combination_keycode_macro_mix(self): """Ev_1 triggers macro, ev_1 + ev_2 triggers key while the macro is still running""" down_1 = (EV_ABS, ABS_HAT0X, 1) down_2 = (EV_ABS, ABS_HAT0Y, -1) up_1 = (EV_ABS, ABS_HAT0X, 0) up_2 = (EV_ABS, ABS_HAT0Y, 0) a = keyboard_layout.get("a") b = keyboard_layout.get("b") preset = Preset() preset.add( Mapping.from_combination( InputCombination(InputCombination.from_tuples(down_1)), output_symbol="h(k(a))", ) ) preset.add( Mapping.from_combination( InputCombination(InputCombination.from_tuples(down_1, down_2)), output_symbol="b", ) ) event_reader = self.create_event_reader(preset, fixtures.gamepad) # macro starts await self.send_events([InputEvent.from_tuple(down_1)], event_reader) await asyncio.sleep(0.05) keyboard_history = self.global_uinputs.get_uinput("keyboard").write_history forwarded_history = self.forward_uinput.write_history self.assertEqual(len(forwarded_history), 0) self.assertGreater(len(keyboard_history), 1) self.assertNotIn((EV_KEY, b, 1), keyboard_history) self.assertIn((EV_KEY, a, 1), keyboard_history) self.assertIn((EV_KEY, a, 0), keyboard_history) # combination triggered await self.send_events([InputEvent.from_tuple(down_2)], event_reader) await asyncio.sleep(0) keyboard_history = self.global_uinputs.get_uinput("keyboard").write_history self.assertIn((EV_KEY, b, 1), keyboard_history) len_a = len(self.global_uinputs.get_uinput("keyboard").write_history) await asyncio.sleep(0.05) len_b = len(self.global_uinputs.get_uinput("keyboard").write_history) # still running self.assertGreater(len_b, len_a) # release await self.send_events([InputEvent.from_tuple(up_1)], event_reader) keyboard_history = self.global_uinputs.get_uinput("keyboard").write_history self.assertEqual(keyboard_history[-1], (EV_KEY, b, 0)) await asyncio.sleep(0.05) len_c = len(self.global_uinputs.get_uinput("keyboard").write_history) await asyncio.sleep(0.05) len_d = len(self.global_uinputs.get_uinput("keyboard").write_history) # not running anymore self.assertEqual(len_c, len_d) await self.send_events([InputEvent.from_tuple(up_2)], event_reader) await asyncio.sleep(0.05) len_e = len(self.global_uinputs.get_uinput("keyboard").write_history) self.assertEqual(len_e, len_d) async def test_wheel_combination_release_failure(self): # test based on a bug that once occurred # 1 | 22.6698, ((1, 276, 1)) -------------- forwarding # 2 | 22.9904, ((1, 276, 1), (2, 8, -1)) -- maps to 30 # 3 | 23.0103, ((1, 276, 1), (2, 8, -1)) -- duplicate key down # 4 | ... 34 more duplicate key downs (scrolling) # 5 | 23.7104, ((1, 276, 1), (2, 8, -1)) -- duplicate key down # 6 | 23.7283, ((1, 276, 0)) -------------- forwarding release # 7 | 23.7303, ((2, 8, -1)) --------------- forwarding # 8 | 23.7865, ((2, 8, 0)) ---------------- not forwarding release # line 7 should have been "duplicate key down" as well # line 8 should have released 30, instead it was never released # # Note: the test was modified for the new Event pipeline: # line 6 now releases the combination # line 7 get forwarded # line 8 get forwarded scroll = InputEvent.from_tuple((2, 8, -1)) scroll_release = InputEvent.from_tuple((2, 8, 0)) btn_down = InputEvent.key(276, 1) btn_up = InputEvent.key(276, 0) combination = InputCombination( InputCombination.from_tuples((1, 276, 1), (2, 8, -1)) ) keyboard_layout.clear() keyboard_layout._set("a", 30) a = 30 m = Mapping.from_combination(combination, output_symbol="a") m.release_timeout = 0.1 # a higher release timeout to give time for assertions preset = Preset() preset.add(m) event_reader = self.create_event_reader(preset, fixtures.foo_device_2_mouse) await self.send_events([btn_down], event_reader) forwarded_history = self.forward_uinput.write_history self.assertEqual(forwarded_history[0], btn_down) await self.send_events([scroll], event_reader) # "maps to 30" keyboard_history = self.global_uinputs.get_uinput("keyboard").write_history self.assertEqual(keyboard_history[0], (EV_KEY, a, 1)) await self.send_events([scroll] * 5, event_reader) # nothing new since all of them were duplicate key downs keyboard_history = self.global_uinputs.get_uinput("keyboard").write_history self.assertEqual(len(keyboard_history), 1) await self.send_events([btn_up], event_reader) # releasing the combination keyboard_history = self.global_uinputs.get_uinput("keyboard").write_history self.assertEqual(keyboard_history[1], (EV_KEY, a, 0)) # more scroll events # it should be ignored as duplicate key-down await self.send_events([scroll] * 5, event_reader) forwarded_history = self.forward_uinput.write_history self.assertEqual(forwarded_history.count(scroll), 5) await self.send_events([scroll_release], event_reader) forwarded_history = self.forward_uinput.write_history self.assertEqual(forwarded_history[-1], scroll_release) async def test_can_not_map(self): """Inject events to wrong or invalid uinput.""" ev_1 = InputEvent.key(KEY_A, 1) ev_2 = InputEvent.key(KEY_B, 1) ev_3 = InputEvent.key(KEY_C, 1) ev_4 = InputEvent.key(KEY_A, 0) ev_5 = InputEvent.key(KEY_B, 0) ev_6 = InputEvent.key(KEY_C, 0) mapping_1 = Mapping( input_combination=InputCombination([InputConfig.from_input_event(ev_2)]), target_uinput="keyboard", output_type=EV_KEY, output_code=BTN_TL, ) mapping_2 = Mapping( input_combination=InputCombination([InputConfig.from_input_event(ev_3)]), target_uinput="keyboard", output_type=EV_KEY, output_code=KEY_A, ) preset = Preset() preset.add(mapping_1) preset.add(mapping_2) event_reader = self.create_event_reader(preset, fixtures.foo_device_2_mouse) # send key-down and up await self.send_events( [ ev_1, ev_2, ev_3, ev_4, ev_5, ev_6, ], event_reader, ) keyboard_history = self.global_uinputs.get_uinput("keyboard").write_history forwarded_history = self.forward_uinput.write_history self.assertEqual(len(forwarded_history), 4) self.assertEqual(len(keyboard_history), 2) self.assertIn(ev_1, forwarded_history) self.assertIn(ev_2, forwarded_history) self.assertIn(ev_4, forwarded_history) self.assertIn(ev_5, forwarded_history) self.assertNotIn(ev_3, forwarded_history) self.assertNotIn(ev_6, forwarded_history) self.assertIn((EV_KEY, KEY_A, 1), keyboard_history) self.assertIn((EV_KEY, KEY_A, 0), keyboard_history) async def test_axis_switch(self): """Test a mapping for an axis that can be switched on or off.""" rel_rate = 60 # rate [Hz] at which events are produced gain = 0.5 # halve the speed of the rel axis preset = Preset() mouse = self.global_uinputs.get_uinput("mouse") forward_history = self.forward_uinput.write_history mouse_history = mouse.write_history # ABS_X to REL_Y if ABS_Y is above 10% combination = InputCombination( InputCombination.from_tuples((EV_ABS, ABS_X, 0), (EV_ABS, ABS_Y, 10)) ) cfg = { "input_combination": combination.to_config(), "target_uinput": "mouse", "output_type": EV_REL, "output_code": REL_X, "rel_rate": rel_rate, "gain": gain, "deadzone": 0, } m1 = Mapping(**cfg) preset.add(m1) event_reader = self.create_event_reader(preset, fixtures.gamepad) # set ABS_X input to 100% await event_reader.handle(InputEvent.abs(ABS_X, MAX_ABS)) # wait a bit more for nothing to sum up, because ABS_Y is still 0 await asyncio.sleep(0.2) self.assertEqual(len(mouse_history), 0) self.assertEqual(len(forward_history), 1) self.assertEqual( InputEvent.from_event(forward_history[0]), (EV_ABS, ABS_X, MAX_ABS), ) # move ABS_Y above 10% await self.send_events( ( InputEvent.abs(ABS_Y, int(MAX_ABS * 0.05)), InputEvent.abs(ABS_Y, int(MAX_ABS * 0.11)), InputEvent.abs(ABS_Y, int(MAX_ABS * 0.5)), ), event_reader, ) # wait a bit more for it to sum up sleep = 0.5 await asyncio.sleep(sleep) self.assertAlmostEqual(len(mouse_history), rel_rate * sleep, delta=3) self.assertEqual(len(forward_history), 1) # send some more x events await self.send_events( ( InputEvent.abs(ABS_X, MAX_ABS), InputEvent.abs(ABS_X, int(MAX_ABS * 0.9)), ), event_reader, ) # stop it await event_reader.handle(InputEvent.abs(ABS_Y, int(MAX_ABS * 0.05))) await asyncio.sleep(0.2) # wait a bit more for nothing to sum up if mouse_history[0].type == EV_ABS: raise AssertionError( "The injector probably just forwarded them unchanged" # possibly in addition to writing mouse events ) self.assertAlmostEqual(len(mouse_history), rel_rate * sleep, delta=3) # does not contain anything else expected_rel_event = (EV_REL, REL_X, int(gain * REL_XY_SCALING)) count_x = mouse_history.count(expected_rel_event) self.assertEqual(len(mouse_history), count_x) async def test_key_axis_combination_to_disable(self): combination = InputCombination( [ InputConfig(type=EV_ABS, code=ABS_X), InputConfig(type=EV_ABS, code=ABS_Y, analog_threshold=5), ] ) preset = Preset() forward_history = self.forward_uinput.write_history mapping = Mapping( input_combination=combination, output_symbol="disable", target_uinput="keyboard", ) preset.add(mapping) event_reader = self.create_event_reader(preset, fixtures.gamepad) await self.send_events( [ InputEvent.from_tuple((EV_ABS, ABS_X, 10)), # forwarded InputEvent.from_tuple((EV_ABS, ABS_Y, int(0.1 * MAX_ABS))), InputEvent.from_tuple((EV_ABS, ABS_X, 20)), # disabled InputEvent.from_tuple((EV_ABS, ABS_Y, int(0.02 * MAX_ABS))), InputEvent.from_tuple((EV_ABS, ABS_X, 30)), # forwarded ], event_reader, ) self.assertEqual( forward_history, [ InputEvent.from_tuple((EV_ABS, ABS_X, 10)), InputEvent.from_tuple((EV_ABS, ABS_X, 30)), ], ) async def test_multiple_axis_in_hierarchy_handler(self): preset = Preset() # Add two mappings that map EV_REL to EV_ABS. We want to test that they don't # suppress each other when they are part of a hierarchy handler. So having at # least two of them is important for this test. cutoff = 2 for in_, out in [(REL_X, ABS_X), (REL_Y, ABS_Y)]: input_combination = InputCombination( [ InputConfig(type=EV_KEY, code=KEY_A), InputConfig(type=EV_REL, code=in_), ] ) mapping = Mapping( input_combination=input_combination.to_config(), target_uinput="gamepad", output_type=EV_ABS, output_code=out, gain=0.5, rel_to_abs_input_cutoff=cutoff, release_timeout=0.1, deadzone=0, ) preset.add(mapping) # Add a non-analog mapping, to make sure a HierarchyHandler exists key_input = InputCombination( ( InputConfig(type=EV_KEY, code=KEY_A), InputConfig(type=EV_KEY, code=KEY_B), ) ) key_mapping = Mapping( input_combination=key_input.to_config(), target_uinput="keyboard", output_type=EV_KEY, output_code=KEY_C, ) preset.add(key_mapping) event_reader = self.create_event_reader(preset, fixtures.gamepad) # Trigger all of them at the same time value = int(REL_XY_SCALING * cutoff) await asyncio.sleep(0.1) await self.send_events( [ InputEvent(0, 0, EV_KEY, KEY_A, 1), InputEvent(0, 0, EV_REL, REL_X, value), InputEvent(0, 0, EV_REL, REL_Y, value), InputEvent(0, 0, EV_KEY, KEY_B, 1), InputEvent(0, 0, EV_KEY, KEY_B, 0), ], event_reader, ) await asyncio.sleep(0.4) # We expect all of it to be present. No mapping was suppressed. # So we can trigger combinations that inject keys while a joystick is being # simulated in multiple directions. self.assertEqual( uinput_write_history, [ InputEvent.from_tuple((EV_ABS, ABS_X, MAX_ABS / 2)), InputEvent.from_tuple((EV_ABS, ABS_Y, MAX_ABS / 2)), InputEvent.from_tuple((EV_KEY, KEY_C, 1)), InputEvent.from_tuple((EV_KEY, KEY_C, 0)), InputEvent.from_tuple((EV_ABS, ABS_X, 0)), InputEvent.from_tuple((EV_ABS, ABS_Y, 0)), ], ) @test_setup class TestAbsToAbs(EventPipelineTestBase): async def test_abs_to_abs(self): gain = 0.5 # left x to mouse x input_config = InputConfig(type=EV_ABS, code=ABS_X) mapping_config = { "input_combination": InputCombination([input_config]).to_config(), "target_uinput": "gamepad", "output_type": EV_ABS, "output_code": ABS_X, "gain": gain, "deadzone": 0, } mapping_1 = Mapping(**mapping_config) preset = Preset() preset.add(mapping_1) input_config = InputConfig(type=EV_ABS, code=ABS_Y) mapping_config["input_combination"] = InputCombination( [input_config] ).to_config() mapping_config["output_code"] = ABS_Y mapping_2 = Mapping(**mapping_config) preset.add(mapping_2) x = MAX_ABS y = MAX_ABS event_reader = self.create_event_reader(preset, fixtures.gamepad) await self.send_events( [ InputEvent.abs(ABS_X, -x), InputEvent.abs(ABS_Y, y), ], event_reader, ) await asyncio.sleep(0.2) history = self.global_uinputs.get_uinput("gamepad").write_history self.assertEqual( history, [ InputEvent.from_tuple((3, 0, MIN_ABS / 2)), InputEvent.from_tuple((3, 1, MAX_ABS / 2)), ], ) async def test_abs_to_abs_with_input_switch(self): gain = 0.5 input_combination = InputCombination( ( InputConfig(type=EV_ABS, code=0), InputConfig(type=EV_ABS, code=1, analog_threshold=10), ) ) # left x to mouse x mapping_config = { "input_combination": input_combination.to_config(), "target_uinput": "gamepad", "output_type": EV_ABS, "output_code": ABS_X, "gain": gain, "deadzone": 0, } mapping_1 = Mapping(**mapping_config) preset = Preset() preset.add(mapping_1) x = MAX_ABS y = MAX_ABS event_reader = self.create_event_reader(preset, fixtures.gamepad) await self.send_events( [ InputEvent.abs(ABS_X, -x // 5), # will not map InputEvent.abs(ABS_X, -x), # will map later # switch axis on sends initial position (previous event) InputEvent.abs(ABS_Y, y), InputEvent.abs(ABS_X, x), # normally mapped InputEvent.abs(ABS_Y, y // 15), # off, re-centers axis InputEvent.abs(ABS_X, -x // 5), # will not map ], event_reader, ) await asyncio.sleep(0.2) history = self.global_uinputs.get_uinput("gamepad").write_history self.assertEqual( history, [ InputEvent.from_tuple((3, 0, MIN_ABS / 2)), InputEvent.from_tuple((3, 0, MAX_ABS / 2)), InputEvent.from_tuple((3, 0, 0)), ], ) @test_setup class TestRelToAbs(EventPipelineTestBase): def setUp(self): self.timestamp = 0 super().setUp() def next_usec_time(self): self.timestamp += 1000000 / DEFAULT_REL_RATE return self.timestamp async def test_rel_to_abs(self): # first mapping # left mouse x to abs x gain = 0.5 cutoff = 2 input_combination = InputCombination([InputConfig(type=EV_REL, code=REL_X)]) mapping_config = { "input_combination": input_combination.to_config(), "target_uinput": "gamepad", "output_type": EV_ABS, "output_code": ABS_X, "gain": gain, "rel_to_abs_input_cutoff": cutoff, "release_timeout": 0.5, "deadzone": 0, } mapping_1 = Mapping(**mapping_config) preset = Preset() preset.add(mapping_1) # second mapping input_combination = InputCombination([InputConfig(type=EV_REL, code=REL_Y)]) mapping_config["input_combination"] = input_combination.to_config() mapping_config["output_code"] = ABS_Y mapping_2 = Mapping(**mapping_config) preset.add(mapping_2) event_reader = self.create_event_reader(preset, fixtures.gamepad) next_time = self.next_usec_time() await self.send_events( [ InputEvent(0, next_time, EV_REL, REL_X, -int(REL_XY_SCALING * cutoff)), InputEvent(0, next_time, EV_REL, REL_Y, int(REL_XY_SCALING * cutoff)), ], event_reader, ) await asyncio.sleep(0.1) history = self.global_uinputs.get_uinput("gamepad").write_history self.assertEqual( history, [ InputEvent.from_tuple((3, 0, MIN_ABS / 2)), InputEvent.from_tuple((3, 1, MAX_ABS / 2)), ], ) # send more events, then wait until the release timeout next_time = self.next_usec_time() await self.send_events( [ InputEvent(0, next_time, EV_REL, REL_X, -int(REL_XY_SCALING)), InputEvent(0, next_time, EV_REL, REL_Y, int(REL_XY_SCALING)), ], event_reader, ) await asyncio.sleep(0.7) history = self.global_uinputs.get_uinput("gamepad").write_history self.assertEqual( history, [ InputEvent.from_tuple((3, 0, MIN_ABS / 2)), InputEvent.from_tuple((3, 1, MAX_ABS / 2)), InputEvent.from_tuple((3, 0, MIN_ABS / 4)), InputEvent.from_tuple((3, 1, MAX_ABS / 4)), InputEvent.from_tuple((3, 0, 0)), InputEvent.from_tuple((3, 1, 0)), ], ) async def test_rel_to_abs_reset_multiple(self): # Recenters correctly when triggering the mapping a second time. # Could only be reproduced if a key input is part of the combination, that is # released and pressed again. # left mouse x to abs x gain = 0.5 cutoff = 2 input_combination = InputCombination( [ InputConfig(type=EV_KEY, code=KEY_A), InputConfig(type=EV_REL, code=REL_X), ] ) mapping_config = { "input_combination": input_combination.to_config(), "target_uinput": "gamepad", "output_type": EV_ABS, "output_code": ABS_X, "gain": gain, "rel_to_abs_input_cutoff": cutoff, "release_timeout": 0.1, "deadzone": 0, } mapping_1 = Mapping(**mapping_config) preset = Preset() preset.add(mapping_1) event_reader = self.create_event_reader(preset, fixtures.gamepad) for _ in range(3): next_time = self.next_usec_time() value = int(REL_XY_SCALING * cutoff) await event_reader.handle(InputEvent(0, next_time, EV_KEY, KEY_A, 1)) await event_reader.handle(InputEvent(0, next_time, EV_REL, REL_X, value)) await asyncio.sleep(0.2) history = self.global_uinputs.get_uinput("gamepad").write_history self.assertIn( InputEvent.from_tuple((3, 0, 0)), history, ) await event_reader.handle(InputEvent(0, next_time, EV_KEY, KEY_A, 0)) await asyncio.sleep(0.05) self.global_uinputs.get_uinput("gamepad").write_history = [] async def test_rel_to_abs_with_input_switch(self): # use 0 everywhere, because that will cause the handler to not update the rate, # and we are able to test things without worrying about that at all timestamp = 0 gain = 0.5 cutoff = 1 input_combination = InputCombination( ( InputConfig(type=EV_REL, code=REL_X), InputConfig(type=EV_REL, code=REL_Y, analog_threshold=10), ) ) # left mouse x to x mapping_config = { "input_combination": input_combination.to_config(), "target_uinput": "gamepad", "output_type": EV_ABS, "output_code": ABS_X, "gain": gain, "rel_to_abs_input_cutoff": cutoff, "deadzone": 0, } mapping_1 = Mapping(**mapping_config) preset = Preset() preset.add(mapping_1) event_reader = self.create_event_reader(preset, fixtures.gamepad) # if the cutoff is higher, the test sends higher values to overcome the cutoff await self.send_events( [ # will not map InputEvent(0, timestamp, EV_REL, REL_X, -REL_XY_SCALING / 4 * cutoff), # switch axis on InputEvent(0, timestamp, EV_REL, REL_Y, REL_XY_SCALING / 5 * cutoff), # normally mapped InputEvent(0, timestamp, EV_REL, REL_X, REL_XY_SCALING * cutoff), # off, re-centers axis InputEvent(0, timestamp, EV_REL, REL_Y, REL_XY_SCALING / 20 * cutoff), # will not map InputEvent(0, timestamp, EV_REL, REL_X, REL_XY_SCALING / 2 * cutoff), ], event_reader, ) await asyncio.sleep(0.2) history = self.global_uinputs.get_uinput("gamepad").write_history self.assertEqual( history, [ InputEvent.from_tuple((3, 0, MAX_ABS / 2)), InputEvent.from_tuple((3, 0, 0)), ], ) @test_setup class TestAbsToRel(EventPipelineTestBase): async def test_abs_to_rel(self): """Map gamepad EV_ABS events to EV_REL events.""" rel_rate = 60 # rate [Hz] at which events are produced gain = 0.5 # halve the speed of the rel axis # left x to mouse x input_config = InputConfig(type=EV_ABS, code=ABS_X) mapping_config = { "input_combination": InputCombination([input_config]).to_config(), "target_uinput": "mouse", "output_type": EV_REL, "output_code": REL_X, "rel_rate": rel_rate, "gain": gain, "deadzone": 0, } mapping_1 = Mapping(**mapping_config) preset = Preset() preset.add(mapping_1) # left y to mouse y input_config = InputConfig(type=EV_ABS, code=ABS_Y) mapping_config["input_combination"] = InputCombination( [input_config] ).to_config() mapping_config["output_code"] = REL_Y mapping_2 = Mapping(**mapping_config) preset.add(mapping_2) # set input axis to 100% in order to move # (gain * REL_XY_SCALING) pixel per event x = MAX_ABS y = MAX_ABS event_reader = self.create_event_reader(preset, fixtures.gamepad) await self.send_events( [ InputEvent.abs(ABS_X, -x), InputEvent.abs(ABS_Y, -y), ], event_reader, ) # wait a bit more for it to sum up sleep = 0.5 await asyncio.sleep(sleep) # stop it await self.send_events( [ InputEvent.abs(ABS_X, 0), InputEvent.abs(ABS_Y, 0), ], event_reader, ) mouse_history = self.global_uinputs.get_uinput("mouse").write_history if mouse_history[0].type == EV_ABS: raise AssertionError( "The injector probably just forwarded them unchanged" # possibly in addition to writing mouse events ) self.assertAlmostEqual(len(mouse_history), rel_rate * sleep * 2, delta=5) # those may be in arbitrary order expected_value = -gain * REL_XY_SCALING * (rel_rate / DEFAULT_REL_RATE) count_x = mouse_history.count((EV_REL, REL_X, expected_value)) count_y = mouse_history.count((EV_REL, REL_Y, expected_value)) self.assertGreater(count_x, 1) self.assertGreater(count_y, 1) # only those two types of events were written self.assertEqual(len(mouse_history), count_x + count_y) async def test_abs_to_wheel_hi_res_quirk(self): """When mapping to wheel events we always expect to see both, REL_WHEEL and REL_WHEEL_HI_RES events with an accumulative value ratio of 1/120 """ rel_rate = 60 # rate [Hz] at which events are produced gain = 1 # left x to mouse x input_config = InputConfig(type=EV_ABS, code=ABS_X) mapping_config = { "input_combination": InputCombination([input_config]).to_config(), "target_uinput": "mouse", "output_type": EV_REL, "output_code": REL_WHEEL, "rel_rate": rel_rate, "gain": gain, "deadzone": 0, } mapping_1 = Mapping(**mapping_config) preset = Preset() preset.add(mapping_1) # left y to mouse y input_config = InputConfig(type=EV_ABS, code=ABS_Y) mapping_config["input_combination"] = InputCombination( [input_config] ).to_config() mapping_config["output_code"] = REL_HWHEEL_HI_RES mapping_2 = Mapping(**mapping_config) preset.add(mapping_2) # set input axis to 100% in order to move # speed*gain*rate=1*0.5*60 pixel per second x = MAX_ABS y = MAX_ABS event_reader = self.create_event_reader(preset, fixtures.gamepad) await self.send_events( [ InputEvent.abs(ABS_X, x), InputEvent.abs(ABS_Y, -y), ], event_reader, ) # wait a bit more for it to sum up sleep = 0.8 await asyncio.sleep(sleep) # stop it await self.send_events( [ InputEvent.abs(ABS_X, 0), InputEvent.abs(ABS_Y, 0), ], event_reader, ) m_history = self.global_uinputs.get_uinput("mouse").write_history rel_wheel = sum([event.value for event in m_history if event.code == REL_WHEEL]) rel_wheel_hi_res = sum( [event.value for event in m_history if event.code == REL_WHEEL_HI_RES] ) rel_hwheel = sum( [event.value for event in m_history if event.code == REL_HWHEEL] ) rel_hwheel_hi_res = sum( [event.value for event in m_history if event.code == REL_HWHEEL_HI_RES] ) self.assertAlmostEqual(rel_wheel, rel_wheel_hi_res / 120, places=0) self.assertAlmostEqual(rel_hwheel, rel_hwheel_hi_res / 120, places=0) @test_setup class TestRelToBtn(EventPipelineTestBase): async def test_rel_to_btn(self): """Rel axis mapped to buttons are automatically released if no new rel event arrives.""" # map those two to stuff w_up = (EV_REL, REL_WHEEL, -1) hw_right = (EV_REL, REL_HWHEEL, 1) # should be forwarded and present in the capabilities hw_left = (EV_REL, REL_HWHEEL, -1) keyboard_layout.clear() code_b = 91 code_c = 92 keyboard_layout._set("b", code_b) keyboard_layout._set("c", code_c) # set a high release timeout to make sure the tests pass release_timeout = 0.2 mapping_1 = Mapping.from_combination( InputCombination(InputCombination.from_tuples(hw_right)), "keyboard", "k(b)" ) mapping_2 = Mapping.from_combination( InputCombination(InputCombination.from_tuples(w_up)), "keyboard", "c" ) mapping_1.release_timeout = release_timeout mapping_2.release_timeout = release_timeout preset = Preset() preset.add(mapping_1) preset.add(mapping_2) event_reader = self.create_event_reader(preset, fixtures.foo_device_2_mouse) await self.send_events( [InputEvent.from_tuple(hw_right), InputEvent.from_tuple(w_up)] * 5, event_reader, ) # wait less than the release timeout and send more events await asyncio.sleep(release_timeout / 5) await self.send_events( [InputEvent.from_tuple(hw_right), InputEvent.from_tuple(w_up)] * 5 + [InputEvent.from_tuple(hw_left)] * 3, # one event will release hw_right, the others are forwarded event_reader, ) # wait more than the release_timeout to make sure all handlers finish await asyncio.sleep(release_timeout * 1.2) keyboard_history = self.global_uinputs.get_uinput("keyboard").write_history forwarded_history = self.forward_uinput.write_history self.assertEqual(keyboard_history.count((EV_KEY, code_b, 1)), 1) self.assertEqual(keyboard_history.count((EV_KEY, code_c, 1)), 1) self.assertEqual(keyboard_history.count((EV_KEY, code_b, 0)), 1) self.assertEqual(keyboard_history.count((EV_KEY, code_c, 0)), 1) # the unmapped wheel direction self.assertEqual(forwarded_history.count(hw_left), 2) # the unmapped wheel won't get a debounced release command, it's # forwarded as is self.assertNotIn((EV_REL, REL_HWHEEL, 0), forwarded_history) async def test_rel_trigger_threshold(self): """Test that different activation points for rel_to_btn work correctly.""" # at 5 map to a mapping_1 = Mapping.from_combination( InputCombination( [InputConfig(type=EV_REL, code=REL_X, analog_threshold=5)] ), output_symbol="a", ) # at 15 map to b mapping_2 = Mapping.from_combination( InputCombination( [InputConfig(type=EV_REL, code=REL_X, analog_threshold=15)] ), output_symbol="b", ) release_timeout = 0.2 # give some time to do assertions before the release mapping_1.release_timeout = release_timeout mapping_2.release_timeout = release_timeout preset = Preset() preset.add(mapping_1) preset.add(mapping_2) a = keyboard_layout.get("a") b = keyboard_layout.get("b") event_reader = self.create_event_reader(preset, fixtures.foo_device_2_mouse) await self.send_events( [ InputEvent.rel(REL_X, -5), # forward InputEvent.rel(REL_X, 0), # forward InputEvent.rel(REL_X, 3), # forward InputEvent.rel(REL_X, 10), # trigger a ], event_reader, ) await asyncio.sleep(release_timeout * 1.5) # release a keyboard_history = self.global_uinputs.get_uinput("keyboard").write_history self.assertEqual(keyboard_history, [(EV_KEY, a, 1), (EV_KEY, a, 0)]) self.assertEqual(keyboard_history.count((EV_KEY, a, 1)), 1) self.assertEqual(keyboard_history.count((EV_KEY, a, 0)), 1) self.assertNotIn((EV_KEY, b, 1), keyboard_history) await self.send_events( [ InputEvent.rel(REL_X, 10), # trigger a InputEvent.rel(REL_X, 20), # trigger b InputEvent.rel(REL_X, 10), # release b ], event_reader, ) keyboard_history = self.global_uinputs.get_uinput("keyboard").write_history self.assertEqual(keyboard_history.count((EV_KEY, a, 1)), 2) self.assertEqual(keyboard_history.count((EV_KEY, b, 1)), 1) self.assertEqual(keyboard_history.count((EV_KEY, b, 0)), 1) self.assertEqual(keyboard_history.count((EV_KEY, a, 0)), 1) await asyncio.sleep(release_timeout * 1.5) # release a keyboard_history = self.global_uinputs.get_uinput("keyboard").write_history forwarded_history = self.forward_uinput.write_history self.assertEqual(keyboard_history.count((EV_KEY, a, 0)), 2) self.assertEqual( forwarded_history, [(EV_REL, REL_X, -5), (EV_REL, REL_X, 0), (EV_REL, REL_X, 3)], ) @test_setup class TestAbsToBtn(EventPipelineTestBase): async def test_abs_trigger_threshold(self): """Test that different activation points for abs_to_btn work correctly.""" # at 30% map to a mapping_1 = Mapping.from_combination( InputCombination( [InputConfig(type=EV_ABS, code=ABS_X, analog_threshold=30)] ), output_symbol="a", ) # at 70% map to b mapping_2 = Mapping.from_combination( InputCombination( [InputConfig(type=EV_ABS, code=ABS_X, analog_threshold=70)] ), output_symbol="b", ) preset = Preset() preset.add(mapping_1) preset.add(mapping_2) a = keyboard_layout.get("a") b = keyboard_layout.get("b") event_reader = self.create_event_reader(preset, fixtures.gamepad) await self.send_events( [ # -10%, do nothing InputEvent.abs(ABS_X, MIN_ABS // 10), # 0%, do noting InputEvent.abs(ABS_X, 0), # 10%, do nothing InputEvent.abs(ABS_X, MAX_ABS // 10), # 50%, trigger a InputEvent.abs(ABS_X, MAX_ABS // 2), ], event_reader, ) keyboard_history = self.global_uinputs.get_uinput("keyboard").write_history self.assertEqual(keyboard_history.count((EV_KEY, a, 1)), 1) self.assertNotIn((EV_KEY, a, 0), keyboard_history) self.assertNotIn((EV_KEY, b, 1), keyboard_history) await self.send_events( [ # 80%, trigger b InputEvent.abs(ABS_X, int(MAX_ABS * 0.8)), InputEvent.abs(ABS_X, MAX_ABS // 2), # 50%, release b ], event_reader, ) keyboard_history = self.global_uinputs.get_uinput("keyboard").write_history self.assertEqual(keyboard_history.count((EV_KEY, a, 1)), 1) self.assertEqual(keyboard_history.count((EV_KEY, b, 1)), 1) self.assertEqual(keyboard_history.count((EV_KEY, b, 0)), 1) self.assertNotIn((EV_KEY, a, 0), keyboard_history) # 0% release a await event_reader.handle(InputEvent.abs(ABS_X, 0)) keyboard_history = self.global_uinputs.get_uinput("keyboard").write_history forwarded_history = self.forward_uinput.write_history self.assertEqual(keyboard_history.count((EV_KEY, a, 0)), 1) self.assertEqual(len(forwarded_history), 0) @test_setup class TestRelToRel(EventPipelineTestBase): async def _test(self, input_code, input_value, output_code, output_value, gain=1): preset = Preset() input_config = InputConfig(type=EV_REL, code=input_code) mapping = Mapping( input_combination=InputCombination([input_config]).to_config(), target_uinput="mouse", output_type=EV_REL, output_code=output_code, deadzone=0, gain=gain, ) preset.add(mapping) event_reader = self.create_event_reader(preset, fixtures.gamepad) await self.send_events( [InputEvent(0, 0, EV_REL, input_code, input_value)], event_reader, ) history = self.global_uinputs.get_uinput("mouse").write_history self.assertEqual(len(history), 1) self.assertEqual( history[0], InputEvent(0, 0, EV_REL, output_code, output_value), ) async def test_wheel_to_y(self): await self._test( input_code=REL_WHEEL, input_value=2 * WHEEL_SCALING, output_code=REL_Y, output_value=2 * REL_XY_SCALING, ) async def test_hi_res_wheel_to_y(self): await self._test( input_code=REL_WHEEL_HI_RES, input_value=3 * WHEEL_HI_RES_SCALING, output_code=REL_Y, output_value=3 * REL_XY_SCALING, ) async def test_x_to_hwheel(self): # injects both hi_res and regular wheel events at the same time input_code = REL_X input_value = 100 output_code = REL_HWHEEL gain = 2 output_value = int(input_value / REL_XY_SCALING * WHEEL_SCALING * gain) output_value_hi_res = int( input_value / REL_XY_SCALING * WHEEL_HI_RES_SCALING * gain ) preset = Preset() input_config = InputConfig(type=EV_REL, code=input_code) mapping = Mapping( input_combination=InputCombination([input_config]).to_config(), target_uinput="mouse", output_type=EV_REL, output_code=output_code, deadzone=0, gain=gain, ) preset.add(mapping) event_reader = self.create_event_reader(preset, fixtures.gamepad) await self.send_events( [InputEvent(0, 0, EV_REL, input_code, input_value)], event_reader, ) history = self.global_uinputs.get_uinput("mouse").write_history # injects both REL_WHEEL and REL_WHEEL_HI_RES events self.assertEqual(len(history), 2) self.assertEqual( history[0], InputEvent( 0, 0, EV_REL, REL_HWHEEL, output_value, ), ) self.assertEqual( history[1], InputEvent( 0, 0, EV_REL, REL_HWHEEL_HI_RES, output_value_hi_res, ), ) async def test_remainder(self): preset = Preset() history = self.global_uinputs.get_uinput("mouse").write_history # REL_WHEEL_HI_RES to REL_Y input_config = InputConfig(type=EV_REL, code=REL_WHEEL_HI_RES) gain = 0.01 mapping = Mapping( input_combination=InputCombination([input_config]).to_config(), target_uinput="mouse", output_type=EV_REL, output_code=REL_Y, deadzone=0, gain=gain, ) preset.add(mapping) event_reader = self.create_event_reader(preset, fixtures.gamepad) events_until_one_rel_y_written = int( WHEEL_HI_RES_SCALING / REL_XY_SCALING / gain ) # due to the low gain and low input value, it needs to be sent many times # until one REL_Y event is written await self.send_events( [InputEvent(0, 0, EV_REL, REL_WHEEL_HI_RES, 1)] * (events_until_one_rel_y_written - 1), event_reader, ) self.assertEqual(len(history), 0) # write the final event that causes the input to accumulate to 1 # plus one extra event because of floating-point math await self.send_events( [InputEvent(0, 0, EV_REL, REL_WHEEL_HI_RES, 1)], event_reader, ) self.assertEqual(len(history), 1) self.assertEqual( history[0], InputEvent(0, 0, EV_REL, REL_Y, 1), ) # repeat it one more time to see if the remainder is reset correctly await self.send_events( [InputEvent(0, 0, EV_REL, REL_WHEEL_HI_RES, 1)] * (events_until_one_rel_y_written - 1), event_reader, ) self.assertEqual(len(history), 1) # the event that causes the second REL_Y to be written # this should never need the one extra if the remainder is reset correctly await self.send_events( [InputEvent(0, 0, EV_REL, REL_WHEEL_HI_RES, 1)], event_reader, ) self.assertEqual(len(history), 2) self.assertEqual( history[1], InputEvent(0, 0, EV_REL, REL_Y, 1), ) if __name__ == "__main__": unittest.main() input-remapper-2.1.1/tests/unit/test_event_pipeline/test_mapping_handlers.py000066400000000000000000000473161475433465200276250ustar00rootroot00000000000000#!/usr/bin/env python3 # -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings # Copyright (C) 2025 sezanzeb # # This file is part of input-remapper. # # input-remapper is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # input-remapper is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with input-remapper. If not, see . """See TestEventPipeline for more tests.""" import asyncio import unittest from unittest.mock import MagicMock import evdev from evdev.ecodes import ( EV_KEY, EV_ABS, EV_REL, ABS_X, REL_X, BTN_LEFT, BTN_RIGHT, KEY_A, REL_Y, REL_WHEEL, ) from inputremapper.configs.input_config import InputCombination, InputConfig from inputremapper.configs.mapping import Mapping, DEFAULT_REL_RATE, KnownUinput from inputremapper.injection.global_uinputs import GlobalUInputs, UInput from inputremapper.injection.mapping_handlers.abs_to_abs_handler import AbsToAbsHandler from inputremapper.injection.mapping_handlers.abs_to_btn_handler import AbsToBtnHandler from inputremapper.injection.mapping_handlers.abs_to_rel_handler import AbsToRelHandler from inputremapper.injection.mapping_handlers.axis_switch_handler import ( AxisSwitchHandler, ) from inputremapper.injection.mapping_handlers.combination_handler import ( CombinationHandler, ) from inputremapper.injection.mapping_handlers.hierarchy_handler import HierarchyHandler from inputremapper.injection.mapping_handlers.key_handler import KeyHandler from inputremapper.injection.mapping_handlers.macro_handler import MacroHandler from inputremapper.injection.mapping_handlers.mapping_handler import ( MappingHandler, InputEventHandler, ) from inputremapper.injection.mapping_handlers.rel_to_abs_handler import RelToAbsHandler from inputremapper.injection.mapping_handlers.rel_to_btn_handler import RelToBtnHandler from inputremapper.injection.mapping_handlers.rel_to_rel_handler import RelToRelHandler from inputremapper.input_event import InputEvent, EventActions from tests.lib.cleanup import cleanup from tests.lib.constants import MAX_ABS from tests.lib.fixtures import fixtures from tests.lib.patches import InputDevice from tests.lib.test_setup import test_setup class BaseTests: """implements test that should pass on most mapping handlers in special cases override specific tests. """ handler: MappingHandler def setUp(self): raise NotImplementedError def tearDown(self) -> None: cleanup() def test_reset(self): mock = MagicMock() self.handler.set_sub_handler(mock) self.handler.reset() mock.reset.assert_called() @test_setup class TestAxisSwitchHandler(BaseTests, unittest.IsolatedAsyncioTestCase): def setUp(self): input_combination = InputCombination( ( InputConfig(type=2, code=5), InputConfig(type=1, code=3), ) ) self.global_uinputs = GlobalUInputs(UInput) self.global_uinputs.prepare_all() self.handler = AxisSwitchHandler( input_combination, Mapping( input_combination=input_combination.to_config(), target_uinput="mouse", output_type=2, output_code=1, ), MagicMock(), self.global_uinputs, ) @test_setup class TestAbsToBtnHandler(BaseTests, unittest.IsolatedAsyncioTestCase): def setUp(self): input_combination = InputCombination( [InputConfig(type=3, code=5, analog_threshold=10)] ) self.global_uinputs = GlobalUInputs(UInput) self.global_uinputs.prepare_all() self.handler = AbsToBtnHandler( input_combination, Mapping( input_combination=input_combination.to_config(), target_uinput="mouse", output_symbol="BTN_LEFT", ), global_uinputs=self.global_uinputs, ) @test_setup class TestAbsToAbsHandler(BaseTests, unittest.IsolatedAsyncioTestCase): def setUp(self): input_combination = InputCombination([InputConfig(type=EV_ABS, code=ABS_X)]) self.global_uinputs = GlobalUInputs(UInput) self.global_uinputs.prepare_all() self.handler = AbsToAbsHandler( input_combination, Mapping( input_combination=input_combination.to_config(), target_uinput="gamepad", output_type=EV_ABS, output_code=ABS_X, ), global_uinputs=self.global_uinputs, ) async def test_reset(self): self.handler.notify( InputEvent(0, 0, EV_ABS, ABS_X, MAX_ABS), source=InputDevice("/dev/input/event15"), ) self.handler.reset() history = self.global_uinputs.get_uinput("gamepad").write_history self.assertEqual( history, [InputEvent.from_tuple((3, 0, MAX_ABS)), InputEvent.from_tuple((3, 0, 0))], ) @test_setup class TestRelToAbsHandler(BaseTests, unittest.IsolatedAsyncioTestCase): def setUp(self): input_combination = InputCombination([InputConfig(type=EV_REL, code=REL_X)]) self.global_uinputs = GlobalUInputs(UInput) self.global_uinputs.prepare_all() self.handler = RelToAbsHandler( input_combination, Mapping( input_combination=input_combination.to_config(), target_uinput="gamepad", output_type=EV_ABS, output_code=ABS_X, ), self.global_uinputs, ) async def test_reset(self): self.handler.notify( InputEvent(0, 0, EV_REL, REL_X, 123), source=InputDevice("/dev/input/event15"), ) self.handler.reset() history = self.global_uinputs.get_uinput("gamepad").write_history self.assertEqual(len(history), 2) # something large, doesn't matter self.assertGreater(history[0].value, MAX_ABS / 10) # 0, because of the reset self.assertEqual(history[1].value, 0) async def test_rate_changes(self): expected_rate = 100 # delta in usec delta = 1000000 / expected_rate self.handler.notify( InputEvent(0, delta, EV_REL, REL_X, 100), source=InputDevice("/dev/input/event15"), ) self.handler.notify( InputEvent(0, delta * 2, EV_REL, REL_X, 100), source=InputDevice("/dev/input/event15"), ) self.assertEqual(self.handler._observed_rate, expected_rate) async def test_rate_stays(self): # if two timestamps are equal, the rate stays at its previous value, # in this case the default self.handler.notify( InputEvent(0, 50, EV_REL, REL_X, 100), source=InputDevice("/dev/input/event15"), ) self.handler.notify( InputEvent(0, 50, EV_REL, REL_X, 100), source=InputDevice("/dev/input/event15"), ) self.assertEqual(self.handler._observed_rate, DEFAULT_REL_RATE) @test_setup class TestAbsToRelHandler(BaseTests, unittest.IsolatedAsyncioTestCase): def setUp(self): input_combination = InputCombination([InputConfig(type=EV_ABS, code=ABS_X)]) self.global_uinputs = GlobalUInputs(UInput) self.global_uinputs.prepare_all() self.handler = AbsToRelHandler( input_combination, Mapping( input_combination=input_combination.to_config(), target_uinput="mouse", output_type=EV_REL, output_code=REL_X, ), self.global_uinputs, ) async def test_reset(self): self.handler.notify( InputEvent(0, 0, EV_ABS, ABS_X, MAX_ABS), source=InputDevice("/dev/input/event15"), ) await asyncio.sleep(0.2) self.handler.reset() await asyncio.sleep(0.05) count = self.global_uinputs.get_uinput("mouse").write_count self.assertGreater(count, 6) # count should be 60*0.2 = 12 await asyncio.sleep(0.2) self.assertEqual(count, self.global_uinputs.get_uinput("mouse").write_count) @test_setup class TestCombinationHandler(BaseTests, unittest.IsolatedAsyncioTestCase): handler: CombinationHandler def setUp(self): mouse = fixtures.foo_device_2_mouse self.mouse_hash = mouse.get_device_hash() keyboard = fixtures.foo_device_2_keyboard self.keyboard_hash = keyboard.get_device_hash() gamepad = fixtures.gamepad self.gamepad_hash = gamepad.get_device_hash() input_combination = InputCombination( ( InputConfig( type=EV_REL, code=5, analog_threshold=10, origin_hash=self.mouse_hash, ), InputConfig( type=EV_KEY, code=3, origin_hash=self.keyboard_hash, ), InputConfig( type=EV_KEY, code=4, origin_hash=self.gamepad_hash, ), ) ) self.input_combination = input_combination self.context_mock = MagicMock() self.global_uinputs = GlobalUInputs(UInput) self.global_uinputs.prepare_all() self.handler = CombinationHandler( input_combination, Mapping( input_combination=input_combination.to_config(), target_uinput="mouse", output_symbol="BTN_LEFT", release_combination_keys=True, ), self.context_mock, global_uinputs=self.global_uinputs, ) sub_handler_mock = MagicMock(InputEventHandler) self.handler.set_sub_handler(sub_handler_mock) # insert our own test-uinput to see what is being written to it self.uinputs = { self.mouse_hash: evdev.UInput(), self.keyboard_hash: evdev.UInput(), self.gamepad_hash: evdev.UInput(), } self.context_mock.get_forward_uinput = lambda origin_hash: self.uinputs[ origin_hash ] def test_forward_correctly(self): # In the past, if a mapping has inputs from two different sub devices, it # always failed to send the release events to the correct one. # Nowadays, self._context.get_forward_uinput(origin_hash) is used to # release them correctly. # 1. trigger the combination self.handler.notify( InputEvent.rel( code=self.input_combination[0].code, value=1, origin_hash=self.input_combination[0].origin_hash, ), source=fixtures.foo_device_2_mouse, ) self.handler.notify( InputEvent.key( code=self.input_combination[1].code, value=1, origin_hash=self.input_combination[1].origin_hash, ), source=fixtures.foo_device_2_keyboard, ) self.handler.notify( InputEvent.key( code=self.input_combination[2].code, value=1, origin_hash=self.input_combination[2].origin_hash, ), source=fixtures.gamepad, ) # 2. expect release events to be written to the correct devices, as indicated # by the origin_hash of the InputConfigs self.assertListEqual( self.uinputs[self.mouse_hash].write_history, [InputEvent.rel(self.input_combination[0].code, 0)], ) self.assertListEqual( self.uinputs[self.keyboard_hash].write_history, [InputEvent.key(self.input_combination[1].code, 0)], ) # We do not expect a release event for this, because there was no key-down # event when the combination triggered. # self.assertListEqual( # self.uinputs[self.gamepad_hash].write_history, # [InputEvent.key(self.input_combination[2].code, 0)], # ) def test_no_forwards(self): # if a combination is not triggered, nothing is released # 1. inject any two events self.handler.notify( InputEvent.rel( code=self.input_combination[0].code, value=1, origin_hash=self.input_combination[0].origin_hash, ), source=fixtures.foo_device_2_mouse, ) self.handler.notify( InputEvent.key( code=self.input_combination[1].code, value=1, origin_hash=self.input_combination[1].origin_hash, ), source=fixtures.foo_device_2_keyboard, ) # 2. expect no release events to be written self.assertListEqual(self.uinputs[self.mouse_hash].write_history, []) self.assertListEqual(self.uinputs[self.keyboard_hash].write_history, []) @test_setup class TestHierarchyHandler(BaseTests, unittest.IsolatedAsyncioTestCase): def setUp(self): self.mock1 = MagicMock() self.mock2 = MagicMock() self.mock3 = MagicMock() self.global_uinputs = GlobalUInputs(UInput) self.global_uinputs.prepare_all() self.handler = HierarchyHandler( [self.mock1, self.mock2, self.mock3], InputConfig(type=EV_KEY, code=KEY_A), self.global_uinputs, ) def test_reset(self): self.handler.reset() self.mock1.reset.assert_called() self.mock2.reset.assert_called() self.mock3.reset.assert_called() @test_setup class TestKeyHandler(BaseTests, unittest.IsolatedAsyncioTestCase): def setUp(self): input_combination = InputCombination( ( InputConfig(type=2, code=0, analog_threshold=10), InputConfig(type=1, code=3), ) ) self.global_uinputs = GlobalUInputs(UInput) self.global_uinputs.prepare_all() self.handler = KeyHandler( input_combination, Mapping( input_combination=input_combination.to_config(), target_uinput="mouse", output_symbol="BTN_LEFT", ), self.global_uinputs, ) def test_reset(self): self.handler.notify( InputEvent(0, 0, EV_REL, REL_X, 1, actions=(EventActions.as_key,)), source=InputDevice("/dev/input/event11"), ) history = self.global_uinputs.get_uinput("mouse").write_history self.assertEqual(history[0], InputEvent.key(BTN_LEFT, 1)) self.assertEqual(len(history), 1) self.handler.reset() history = self.global_uinputs.get_uinput("mouse").write_history self.assertEqual(history[1], InputEvent.key(BTN_LEFT, 0)) self.assertEqual(len(history), 2) @test_setup class TestMacroHandler(BaseTests, unittest.IsolatedAsyncioTestCase): def setUp(self): self.input_combination = InputCombination( ( InputConfig(type=2, code=0, analog_threshold=10), InputConfig(type=1, code=3), ) ) self.context_mock = MagicMock() self.global_uinputs = GlobalUInputs(UInput) self.global_uinputs.prepare_all() self.set_handler(KnownUinput.KEYBOARD, "key(a)") def set_handler(self, target_uinput: KnownUinput, macro: str): self.handler = MacroHandler( self.input_combination, Mapping( input_combination=self.input_combination.to_config(), target_uinput=target_uinput, output_symbol=macro, ), context=self.context_mock, global_uinputs=self.global_uinputs, ) async def test_reset(self): self.set_handler(KnownUinput.MOUSE, "hold_keys(BTN_LEFT, BTN_RIGHT)") self.handler.notify( InputEvent(0, 0, EV_REL, REL_X, 1, actions=(EventActions.as_key,)), source=InputDevice("/dev/input/event11"), ) await asyncio.sleep(0.1) history = self.global_uinputs.get_uinput(KnownUinput.MOUSE).write_history self.assertIn(InputEvent.key(BTN_LEFT, 1), history) self.assertIn(InputEvent.key(BTN_RIGHT, 1), history) self.assertEqual(len(history), 2) self.handler.reset() await asyncio.sleep(0.1) history = self.global_uinputs.get_uinput(KnownUinput.MOUSE).write_history self.assertIn(InputEvent.key(BTN_LEFT, 0), history[-2:]) self.assertIn(InputEvent.key(BTN_RIGHT, 0), history[-2:]) self.assertEqual(len(history), 4) async def test_reset_output(self): self.set_handler(KnownUinput.KEYBOARD, "key_down(a)") history = self.global_uinputs.get_uinput(KnownUinput.KEYBOARD).write_history self.handler.reset() await asyncio.sleep(0.1) self.assertEqual(len(history), 0) self.handler.notify( InputEvent(0, 0, EV_REL, REL_X, 1, actions=(EventActions.as_key,)), source=InputDevice("/dev/input/event11"), ) await asyncio.sleep(0.1) self.assertEqual(len(history), 1) self.assertIn(InputEvent.key(KEY_A, 1), history) self.handler.reset() await asyncio.sleep(0.1) self.assertEqual(len(history), 2) self.assertIn(InputEvent.key(KEY_A, 0), history) @test_setup class TestRelToBtnHandler(BaseTests, unittest.IsolatedAsyncioTestCase): def setUp(self): input_combination = InputCombination( [InputConfig(type=2, code=0, analog_threshold=10)] ) self.global_uinputs = GlobalUInputs(UInput) self.global_uinputs.prepare_all() self.handler = RelToBtnHandler( input_combination, Mapping( input_combination=input_combination.to_config(), target_uinput="mouse", output_symbol="BTN_LEFT", ), self.global_uinputs, ) @test_setup class TestRelToRelHanlder(BaseTests, unittest.IsolatedAsyncioTestCase): handler: RelToRelHandler def setUp(self): input_combination = InputCombination([InputConfig(type=EV_REL, code=REL_X)]) self.global_uinputs = GlobalUInputs(UInput) self.global_uinputs.prepare_all() self.handler = RelToRelHandler( input_combination, Mapping( input_combination=input_combination.to_config(), output_type=EV_REL, output_code=REL_Y, output_value=20, target_uinput="mouse", ), self.global_uinputs, ) def test_should_map(self): self.assertTrue( self.handler._should_map( InputEvent( 0, 0, EV_REL, REL_X, 0, ) ) ) self.assertFalse( self.handler._should_map( InputEvent( 0, 0, EV_REL, REL_WHEEL, 1, ) ) ) def test_reset(self): # nothing special has to happen here pass input-remapper-2.1.1/tests/unit/test_event_reader.py000066400000000000000000000245501475433465200227030ustar00rootroot00000000000000#!/usr/bin/env python3 # -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings # Copyright (C) 2025 sezanzeb # # This file is part of input-remapper. # # input-remapper is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # input-remapper is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with input-remapper. If not, see . import asyncio import os import unittest from unittest.mock import MagicMock, patch import evdev from evdev.ecodes import ( EV_KEY, EV_ABS, BTN_A, ABS_X, ABS_Y, ABS_RX, ABS_RY, EV_REL, REL_X, REL_Y, REL_HWHEEL_HI_RES, REL_WHEEL_HI_RES, ) from inputremapper.configs.input_config import InputCombination, InputConfig from inputremapper.configs.keyboard_layout import keyboard_layout from inputremapper.configs.mapping import Mapping from inputremapper.configs.preset import Preset from inputremapper.injection.context import Context from inputremapper.injection.event_reader import EventReader from inputremapper.injection.global_uinputs import GlobalUInputs, UInput from inputremapper.injection.mapping_handlers.mapping_parser import MappingParser from inputremapper.input_event import InputEvent from inputremapper.utils import get_device_hash from tests.lib.fixtures import fixtures from tests.lib.test_setup import test_setup @test_setup class TestEventReader(unittest.IsolatedAsyncioTestCase): def setUp(self): self.gamepad_source = evdev.InputDevice(fixtures.gamepad.path) self.source_hash = fixtures.gamepad.get_device_hash() self.global_uinputs = GlobalUInputs(UInput) self.mapping_parser = MappingParser(self.global_uinputs) self.stop_event = asyncio.Event() self.preset = Preset() self.forward_uinput = evdev.UInput(name="test-forward-uinput") self.global_uinputs.is_service = True self.global_uinputs.prepare_all() async def setup(self, mapping): """Set a EventReader up for the test and run it in the background.""" context = Context( mapping, {}, {self.source_hash: self.forward_uinput}, self.mapping_parser, ) context.uinput = evdev.UInput() event_reader = EventReader( context, self.gamepad_source, self.stop_event, ) asyncio.ensure_future(event_reader.run()) await asyncio.sleep(0.1) return context, event_reader async def test_if_single_joystick_then(self): # TODO: Move this somewhere more sensible # Integration test style for if_single. # won't care about the event, because the purpose is not set to BUTTON code_a = keyboard_layout.get("a") code_shift = keyboard_layout.get("KEY_LEFTSHIFT") trigger = evdev.ecodes.BTN_A self.preset.add( Mapping.from_combination( InputCombination( [ InputConfig( type=EV_KEY, code=trigger, origin_hash=fixtures.gamepad.get_device_hash(), ) ] ), "keyboard", "if_single(key(a), key(KEY_LEFTSHIFT))", ) ) self.preset.add( Mapping.from_combination( InputCombination( [ InputConfig( type=EV_ABS, code=ABS_Y, analog_threshold=1, origin_hash=fixtures.gamepad.get_device_hash(), ) ] ), "keyboard", "b", ), ) # left x to mouse x config = { "input_combination": [ InputConfig( type=EV_ABS, code=ABS_X, origin_hash=fixtures.gamepad.get_device_hash(), ) ], "target_uinput": "mouse", "output_type": EV_REL, "output_code": REL_X, } self.preset.add(Mapping(**config)) # left y to mouse y config["input_combination"] = [ InputConfig( type=EV_ABS, code=ABS_Y, origin_hash=fixtures.gamepad.get_device_hash(), ) ] config["output_code"] = REL_Y self.preset.add(Mapping(**config)) # right x to wheel x config["input_combination"] = [ InputConfig( type=EV_ABS, code=ABS_RX, origin_hash=fixtures.gamepad.get_device_hash(), ) ] config["output_code"] = REL_HWHEEL_HI_RES self.preset.add(Mapping(**config)) # right y to wheel y config["input_combination"] = [ InputConfig( type=EV_ABS, code=ABS_RY, origin_hash=fixtures.gamepad.get_device_hash(), ) ] config["output_code"] = REL_WHEEL_HI_RES self.preset.add(Mapping(**config)) context, _ = await self.setup(self.preset) gamepad_hash = get_device_hash(self.gamepad_source) self.gamepad_source.push_events( [ InputEvent.key(evdev.ecodes.BTN_Y, 0, gamepad_hash), # start the macro InputEvent.key(trigger, 1, gamepad_hash), # start the macro InputEvent.abs(ABS_Y, 10, gamepad_hash), # ignored InputEvent.key(evdev.ecodes.BTN_B, 2, gamepad_hash), # ignored InputEvent.key(evdev.ecodes.BTN_B, 0, gamepad_hash), # ignored # release the trigger, which runs `then` of if_single InputEvent.key(trigger, 0, gamepad_hash), ] ) await asyncio.sleep(0.1) self.stop_event.set() # stop the reader history = self.global_uinputs.get_uinput("keyboard").write_history self.assertIn((EV_KEY, code_a, 1), history) self.assertIn((EV_KEY, code_a, 0), history) self.assertNotIn((EV_KEY, code_shift, 1), history) self.assertNotIn((EV_KEY, code_shift, 0), history) # after if_single takes an action, the listener should have been removed self.assertSetEqual(context.listeners, set()) async def test_if_single_joystick_under_threshold(self): """Triggers then because the joystick events value is too low.""" # TODO: Move this somewhere more sensible code_a = keyboard_layout.get("a") trigger = evdev.ecodes.BTN_A self.preset.add( Mapping.from_combination( InputCombination( [ InputConfig( type=EV_KEY, code=trigger, origin_hash=fixtures.gamepad.get_device_hash(), ) ] ), "keyboard", "if_single(k(a), k(KEY_LEFTSHIFT))", ) ) self.preset.add( Mapping.from_combination( InputCombination( [ InputConfig( type=EV_ABS, code=ABS_Y, analog_threshold=1, origin_hash=fixtures.gamepad.get_device_hash(), ) ] ), "keyboard", "b", ), ) # self.preset.set("gamepad.joystick.left_purpose", BUTTONS) # self.preset.set("gamepad.joystick.right_purpose", BUTTONS) context, _ = await self.setup(self.preset) self.gamepad_source.push_events( [ InputEvent.key(trigger, 1), # start the macro InputEvent.abs(ABS_Y, 1), # ignored because value too low InputEvent.key(trigger, 0), # stop, only way to trigger `then` ] ) await asyncio.sleep(0.1) self.assertEqual(len(context.listeners), 0) history = self.global_uinputs.get_uinput("keyboard").write_history # the key that triggered if_single should be injected after # if_single had a chance to inject keys (if the macro is fast enough), # so that if_single can inject a modifier to e.g. capitalize the # triggering key. This is important for the space cadet shift self.assertListEqual( history, [ (EV_KEY, code_a, 1), (EV_KEY, code_a, 0), ], ) @patch.object(Context, "reset") async def test_reset_handlers_on_stop(self, reset_mock: MagicMock) -> None: await self.setup(self.preset) self.stop_event.set() await asyncio.sleep(0.1) reset_mock.assert_called_once() @patch.object(Context, "reset") @patch.object(os, "stat") async def test_reset_handlers_after_unplugging( self, stat_mock: MagicMock, reset_mock: MagicMock, ) -> None: await self.setup(self.preset) self.gamepad_source.push_events([InputEvent.key(BTN_A, 1)]) await asyncio.sleep(0.1) reset_mock.assert_not_called() # unplug the device stat_mock().st_nlink = 0 # It seems that once a device is unplugged, asyncio stops waiting for new input # from _source.fileno() or something. I didn't manage to replicate this # behavior in tests using the fd of pending_events[fixtures.gamepad][0].fileno() # and/or pending_events[fixtures.gamepad][1].fileno(). So instead, I push # another event, so that the EventReader makes another iteration. self.gamepad_source.push_events([InputEvent.key(BTN_A, 1)]) await asyncio.sleep(0.1) reset_mock.assert_called_once() input-remapper-2.1.1/tests/unit/test_global_uinputs.py000066400000000000000000000063661475433465200232740ustar00rootroot00000000000000#!/usr/bin/env python3 # -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings # Copyright (C) 2025 sezanzeb # # This file is part of input-remapper. # # input-remapper is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # input-remapper is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with input-remapper. If not, see . import sys import unittest from unittest.mock import patch import evdev from evdev.ecodes import ( KEY_A, ABS_X, ) from inputremapper.exceptions import EventNotHandled, UinputNotAvailable from inputremapper.injection.global_uinputs import ( FrontendUInput, GlobalUInputs, UInput, ) from inputremapper.input_event import InputEvent from tests.lib.cleanup import cleanup from tests.lib.test_setup import test_setup @test_setup class TestFrontendUinput(unittest.TestCase): def setUp(self) -> None: cleanup() def test_init(self): name = "foo" capabilities = {1: [1, 2, 3], 2: [4, 5, 6]} uinput_defaults = FrontendUInput() uinput_custom = FrontendUInput(name=name, events=capabilities) self.assertEqual(uinput_defaults.name, "py-evdev-uinput") self.assertIsNone(uinput_defaults.capabilities()) self.assertEqual(uinput_custom.name, name) self.assertEqual(uinput_custom.capabilities(), capabilities) @test_setup class TestGlobalUInputs(unittest.TestCase): def setUp(self) -> None: cleanup() def test_iter(self): global_uinputs = GlobalUInputs(FrontendUInput) for uinput in global_uinputs: self.assertIsInstance(uinput, evdev.UInput) def test_write(self): """Test write and write failure implicitly tests get_uinput and UInput.can_emit """ global_uinputs = GlobalUInputs(UInput) global_uinputs.prepare_all() ev_1 = InputEvent.key(KEY_A, 1) ev_2 = InputEvent.abs(ABS_X, 10) keyboard = global_uinputs.get_uinput("keyboard") global_uinputs.write(ev_1.event_tuple, "keyboard") self.assertEqual(keyboard.write_count, 1) with self.assertRaises(EventNotHandled): global_uinputs.write(ev_2.event_tuple, "keyboard") with self.assertRaises(UinputNotAvailable): global_uinputs.write(ev_1.event_tuple, "foo") def test_creates_frontend_uinputs(self): frontend_uinputs = GlobalUInputs(FrontendUInput) frontend_uinputs.prepare_all() uinput = frontend_uinputs.get_uinput("keyboard") self.assertIsInstance(uinput, FrontendUInput) def test_creates_backend_service_uinputs(self): frontend_uinputs = GlobalUInputs(UInput) frontend_uinputs.prepare_all() uinput = frontend_uinputs.get_uinput("keyboard") self.assertIsInstance(uinput, UInput) input-remapper-2.1.1/tests/unit/test_groups.py000066400000000000000000000252221475433465200215540ustar00rootroot00000000000000#!/usr/bin/env python3 # -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings # Copyright (C) 2025 sezanzeb # # This file is part of input-remapper. # # input-remapper is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # input-remapper is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with input-remapper. If not, see . import json import os import unittest import evdev from evdev.ecodes import EV_KEY, KEY_A from inputremapper.configs.paths import PathUtils from inputremapper.groups import ( _FindGroups, groups, classify, DeviceType, _Group, ) from tests.lib.fixtures import fixtures, keyboard_keys from tests.lib.test_setup import test_setup class FakePipe: groups = None def send(self, groups): self.groups = groups @test_setup class TestGroups(unittest.TestCase): def test_group(self): group = _Group( paths=["/dev/a", "/dev/b", "/dev/c"], names=["name_bar", "name_a", "name_foo"], types=[DeviceType.MOUSE, DeviceType.KEYBOARD, DeviceType.UNKNOWN], key="key", ) self.assertEqual(group.name, "name_a") self.assertEqual(group.key, "key") self.assertEqual( group.get_preset_path("preset1234"), os.path.join( PathUtils.config_path(), "presets", group.name, "preset1234.json", ), ) def test_find_groups(self): pipe = FakePipe() _FindGroups(pipe).run() self.assertIsInstance(pipe.groups, str) groups.loads(pipe.groups) self.maxDiff = None self.assertEqual( groups.dumps(), json.dumps( [ json.dumps( { "paths": [ "/dev/input/event1", ], "names": ["Foo Device"], "types": [DeviceType.KEYBOARD], "key": "Foo Device", } ), json.dumps( { "paths": [ "/dev/input/event11", "/dev/input/event10", "/dev/input/event13", "/dev/input/event15", ], "names": [ "Foo Device foo", "Foo Device", "Foo Device", "Foo Device bar", ], "types": [ DeviceType.GAMEPAD, DeviceType.KEYBOARD, DeviceType.MOUSE, ], "key": "Foo Device 2", } ), json.dumps( { "paths": ["/dev/input/event20"], "names": ["Bar Device"], "types": [DeviceType.KEYBOARD], "key": "Bar Device", } ), json.dumps( { "paths": ["/dev/input/event30"], "names": ["gamepad"], "types": [DeviceType.GAMEPAD], "key": "gamepad", } ), json.dumps( { "paths": ["/dev/input/event40"], "names": ["input-remapper Bar Device"], "types": [DeviceType.KEYBOARD], "key": "input-remapper Bar Device", } ), json.dumps( { "paths": ["/dev/input/event52"], "names": ["Qux/[Device]?"], "types": [DeviceType.KEYBOARD], "key": "Qux/[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", "Qux/[Device]?", ], ) 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): # As Input Mapper can now map axes to buttons, # a single EV_ABS device is valid for mapping. 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.assertIsNotNone(groups.find(name="qux")) def test_device_with_no_capabilities(self): fixtures["/foo/bar"] = { "name": "nulcap", "phys": "abcd3", "info": evdev.DeviceInfo(1, 2, 3, 4), "capabilities": {}, } groups.refresh() self.assertIsNotNone(groups.find(name="gamepad")) self.assertIsNone(groups.find(name="nulcap")) def test_duplicate_device(self): fixtures["/dev/input/event100"] = { "capabilities": {evdev.ecodes.EV_KEY: keyboard_keys}, "phys": "usb-0000:03:00.0-3/input1", "info": evdev.device.DeviceInfo(2, 1, 2, 1), "name": "Foo Device", } groups.refresh() group1 = groups.find(key="Foo Device") group2 = groups.find(key="Foo Device 2") group3 = groups.find(key="Foo Device 3") self.assertIn("/dev/input/event1", group1.paths) self.assertIn("/dev/input/event10", group2.paths) self.assertIn("/dev/input/event100", group3.paths) self.assertEqual(group1.key, "Foo Device") self.assertEqual(group2.key, "Foo Device 2") self.assertEqual(group3.key, "Foo Device 3") self.assertEqual(group1.name, "Foo Device") self.assertEqual(group2.name, "Foo Device") self.assertEqual(group3.name, "Foo Device") def test_classify(self): # properly detects if the device is a gamepad EV_ABS = evdev.ecodes.EV_ABS EV_KEY = evdev.ecodes.EV_KEY EV_REL = evdev.ecodes.EV_REL class FakeDevice: def __init__(self, capabilities): self.c = capabilities def capabilities(self, absinfo): assert not absinfo return self.c """Gamepads""" self.assertEqual( classify( FakeDevice( { EV_ABS: [evdev.ecodes.ABS_X, evdev.ecodes.ABS_Y], EV_KEY: [evdev.ecodes.BTN_A], } ) ), DeviceType.GAMEPAD, ) """Mice""" self.assertEqual( classify( FakeDevice( { EV_REL: [ evdev.ecodes.REL_X, evdev.ecodes.REL_Y, evdev.ecodes.REL_WHEEL, ], EV_KEY: [evdev.ecodes.BTN_LEFT], } ) ), DeviceType.MOUSE, ) """Keyboard""" self.assertEqual( classify(FakeDevice({EV_KEY: [evdev.ecodes.KEY_A]})), DeviceType.KEYBOARD ) """Touchpads""" self.assertEqual( classify( FakeDevice( { EV_KEY: [evdev.ecodes.KEY_A], EV_ABS: [evdev.ecodes.ABS_MT_POSITION_X], } ) ), DeviceType.TOUCHPAD, ) """Graphics tablets""" self.assertEqual( classify( FakeDevice( { EV_ABS: [evdev.ecodes.ABS_X, evdev.ecodes.ABS_Y], EV_KEY: [evdev.ecodes.BTN_STYLUS], } ) ), DeviceType.GRAPHICS_TABLET, ) """Weird combos""" self.assertEqual( classify( FakeDevice( { EV_ABS: [evdev.ecodes.ABS_X, evdev.ecodes.ABS_Y], EV_KEY: [evdev.ecodes.KEY_1], } ) ), DeviceType.UNKNOWN, ) self.assertEqual( classify( FakeDevice({EV_ABS: [evdev.ecodes.ABS_X], EV_KEY: [evdev.ecodes.BTN_A]}) ), DeviceType.UNKNOWN, ) self.assertEqual( classify(FakeDevice({EV_KEY: [evdev.ecodes.BTN_A]})), DeviceType.UNKNOWN ) self.assertEqual( classify(FakeDevice({EV_ABS: [evdev.ecodes.ABS_X]})), DeviceType.UNKNOWN ) if __name__ == "__main__": unittest.main() input-remapper-2.1.1/tests/unit/test_injector.py000066400000000000000000000576531475433465200220670ustar00rootroot00000000000000#!/usr/bin/env python3 # -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings # Copyright (C) 2025 sezanzeb # # This file is part of input-remapper. # # input-remapper is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # input-remapper is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with input-remapper. If not, see . from inputremapper.injection.global_uinputs import GlobalUInputs, UInput from inputremapper.injection.macros.parse import Parser from inputremapper.injection.mapping_handlers.mapping_parser import MappingParser try: from pydantic.v1 import ValidationError except ImportError: from pydantic import ValidationError import time import unittest from unittest import mock import evdev from evdev.ecodes import ( EV_REL, EV_KEY, EV_ABS, ABS_HAT0X, KEY_A, REL_HWHEEL, BTN_A, ABS_X, ABS_VOLUME, ) from inputremapper.configs.input_config import InputCombination, InputConfig from inputremapper.configs.mapping import Mapping from inputremapper.configs.preset import Preset from inputremapper.configs.keyboard_layout import ( keyboard_layout, DISABLE_CODE, DISABLE_NAME, ) from inputremapper.groups import groups, classify, DeviceType from inputremapper.injection.context import Context from inputremapper.injection.injector import ( Injector, is_in_capabilities, InjectorState, get_udev_name, ) from inputremapper.injection.numlock import is_numlock_on from inputremapper.input_event import InputEvent from tests.lib.constants import EVENT_READ_TIMEOUT from tests.lib.fixtures import fixtures from tests.lib.fixtures import keyboard_keys from tests.lib.patches import uinputs from tests.lib.pipes import read_write_history_pipe, push_events from tests.lib.pipes import uinput_write_history_pipe from tests.lib.test_setup import test_setup def wait_for_uinput_write(): start = time.time() if not uinput_write_history_pipe[0].poll(timeout=10): raise AssertionError("No event written within 10 seconds") return float(time.time() - start) @test_setup class TestInjector(unittest.IsolatedAsyncioTestCase): new_gamepad_path = "/dev/input/event100" @classmethod def setUpClass(cls): cls.injector = None cls.grab = evdev.InputDevice.grab def setUp(self): self.failed = 0 self.make_it_fail = 2 self.global_uinputs = GlobalUInputs(UInput) self.global_uinputs.prepare_all() self.mapping_parser = MappingParser(self.global_uinputs) def grab_fail_twice(_): if self.failed < self.make_it_fail: self.failed += 1 raise OSError() evdev.InputDevice.grab = grab_fail_twice def tearDown(self): if self.injector is not None and self.injector.is_alive(): self.injector.stop_injecting() time.sleep(0.2) self.assertIn( self.injector.get_state(), (InjectorState.STOPPED, InjectorState.ERROR, InjectorState.NO_GRAB), ) self.injector = None evdev.InputDevice.grab = self.grab def initialize_injector(self, group, preset: Preset): self.injector = Injector(group, preset, self.mapping_parser) self.injector._devices = self.injector.group.get_devices() self.injector._update_preset() def test_grab(self): # path is from the fixtures path = "/dev/input/event10" preset = Preset() preset.add( Mapping.from_combination( InputCombination([InputConfig(type=EV_KEY, code=10)]), "keyboard", "a", ) ) self.injector = Injector( groups.find(key="Foo Device 2"), preset, self.mapping_parser, ) # this test needs to pass around all other constraints of # _grab_device self.injector.context = Context(preset, {}, {}, self.mapping_parser) device = self.injector._grab_device(evdev.InputDevice(path)) gamepad = classify(device) == DeviceType.GAMEPAD self.assertFalse(gamepad) self.assertEqual(self.failed, 2) # success on the third try self.assertEqual(device.name, fixtures[path].name) def test_fail_grab(self): self.make_it_fail = 999 preset = Preset() preset.add( Mapping.from_combination( InputCombination([InputConfig(type=EV_KEY, code=10)]), "keyboard", "a", ) ) self.injector = Injector( groups.find(key="Foo Device 2"), preset, self.mapping_parser, ) path = "/dev/input/event10" self.injector.context = Context(preset, {}, {}, self.mapping_parser) device = self.injector._grab_device(evdev.InputDevice(path)) self.assertIsNone(device) self.assertGreaterEqual(self.failed, 1) self.assertEqual(self.injector.get_state(), InjectorState.UNKNOWN) self.injector.start() self.assertEqual(self.injector.get_state(), InjectorState.STARTING) # since none can be grabbed, the process will terminate. But that # actually takes quite some time. time.sleep(self.injector.regrab_timeout * 12) self.assertFalse(self.injector.is_alive()) self.assertEqual(self.injector.get_state(), InjectorState.NO_GRAB) def test_grab_device_1(self): device_hash = fixtures.gamepad.get_device_hash() preset = Preset() preset.add( Mapping.from_combination( InputCombination( [ InputConfig( type=EV_ABS, code=ABS_HAT0X, analog_threshold=1, origin_hash=device_hash, ) ] ), "keyboard", "a", ), ) self.initialize_injector(groups.find(name="gamepad"), preset) self.injector.context = Context(preset, {}, {}, self.mapping_parser) self.injector.group.paths = [ "/dev/input/event10", "/dev/input/event30", "/dev/input/event1234", ] grabbed = self.injector._grab_devices() self.assertEqual(len(grabbed), 1) self.assertEqual(grabbed[device_hash].path, "/dev/input/event30") def test_forward_gamepad_events(self): device_hash = fixtures.gamepad.get_device_hash() # forward abs joystick events preset = Preset() preset.add( Mapping.from_combination( input_combination=InputCombination( [InputConfig(type=EV_KEY, code=BTN_A, origin_hash=device_hash)] ), target_uinput="keyboard", output_symbol="a", ), ) self.initialize_injector(groups.find(name="gamepad"), preset) self.injector.context = Context(preset, {}, {}, self.mapping_parser) path = "/dev/input/event30" devices = self.injector._grab_devices() self.assertEqual(len(devices), 1) self.assertEqual(devices[device_hash].path, path) gamepad = classify(devices[device_hash]) == DeviceType.GAMEPAD self.assertTrue(gamepad) def test_skip_unused_device(self): # skips a device because its capabilities are not used in the preset preset = Preset() preset.add( Mapping.from_combination( InputCombination([InputConfig(type=EV_KEY, code=10)]), "keyboard", "a", ) ) self.initialize_injector(groups.find(key="Foo Device 2"), preset) self.injector.context = Context(preset, {}, {}, self.mapping_parser) # grabs only one device even though the group has 4 devices devices = self.injector._grab_devices() self.assertEqual(len(devices), 1) self.assertEqual(self.failed, 2) def test_skip_unknown_device(self): preset = Preset() preset.add( Mapping.from_combination( InputCombination([InputConfig(type=EV_KEY, code=1234)]), "keyboard", "a", ) ) # skips a device because its capabilities are not used in the preset self.initialize_injector(groups.find(key="Foo Device 2"), preset) self.injector.context = Context(preset, {}, {}, self.mapping_parser) devices = self.injector._grab_devices() # skips the device alltogether, so no grab attempts fail self.assertEqual(self.failed, 0) self.assertEqual(devices, {}) def test_get_udev_name(self): self.injector = Injector( groups.find(key="Foo Device 2"), Preset(), self.mapping_parser, ) suffix = "mapped" prefix = "input-remapper" expected = f'{prefix} {"a" * (80 - len(suffix) - len(prefix) - 2)} {suffix}' self.assertEqual(len(expected), 80) self.assertEqual(get_udev_name("a" * 100, suffix), expected) self.injector.device = "abcd" self.assertEqual( get_udev_name("abcd", "forwarded"), "input-remapper abcd forwarded", ) @mock.patch("evdev.InputDevice.ungrab") def test_capabilities_and_uinput_presence(self, ungrab_patch): preset = Preset() m1 = Mapping.from_combination( InputCombination( [ InputConfig( type=EV_KEY, code=KEY_A, origin_hash=fixtures.foo_device_2_keyboard.get_device_hash(), ) ] ), "keyboard", "c", ) m2 = Mapping.from_combination( InputCombination( [ InputConfig( type=EV_REL, code=REL_HWHEEL, analog_threshold=1, origin_hash=fixtures.foo_device_2_mouse.get_device_hash(), ) ] ), "keyboard", "key(b)", ) preset.add(m1) preset.add(m2) self.injector = Injector( groups.find(key="Foo Device 2"), preset, self.mapping_parser, ) self.injector.stop_injecting() self.injector.run() self.assertEqual( self.injector.preset.get_mapping( InputCombination( [ InputConfig( type=EV_KEY, code=KEY_A, origin_hash=fixtures.foo_device_2_keyboard.get_device_hash(), ) ] ) ), m1, ) self.assertEqual( self.injector.preset.get_mapping( InputCombination( [ InputConfig( type=EV_REL, code=REL_HWHEEL, analog_threshold=1, origin_hash=fixtures.foo_device_2_mouse.get_device_hash(), ) ] ) ), m2, ) # reading and preventing original events from reaching the # display server forwarded_foo = uinputs.get("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): numlock_before = is_numlock_on() # stuff the preset outputs keyboard_layout.clear() code_a = 100 code_q = 101 code_w = 102 keyboard_layout._set("a", code_a) keyboard_layout._set("key_q", code_q) keyboard_layout._set("w", code_w) preset = Preset() preset.add( Mapping.from_combination( InputCombination( [ InputConfig( type=EV_KEY, code=8, origin_hash=fixtures.foo_device_2_keyboard.get_device_hash(), ), InputConfig( type=EV_KEY, code=9, origin_hash=fixtures.foo_device_2_keyboard.get_device_hash(), ), ] ), "keyboard", "k(KEY_Q).k(w)", ) ) preset.add( Mapping.from_combination( InputCombination( [ InputConfig( type=EV_ABS, code=ABS_HAT0X, analog_threshold=-1, origin_hash=fixtures.foo_device_2_gamepad.get_device_hash(), ) ] ), "keyboard", "a", ) ) # one mapping that is unknown in the keyboard_layout on purpose input_b = 10 with self.assertRaises(ValidationError): preset.add( Mapping.from_combination( InputCombination( [ InputConfig( type=EV_KEY, code=input_b, origin_hash=fixtures.foo_device_2_keyboard.get_device_hash(), ) ] ), "keyboard", "b", ) ) self.injector = Injector( groups.find(key="Foo Device 2"), preset, self.mapping_parser, ) self.assertEqual(self.injector.get_state(), InjectorState.UNKNOWN) self.injector.start() self.assertEqual(self.injector.get_state(), InjectorState.STARTING) uinput_write_history_pipe[0].poll(timeout=1) self.assertEqual(self.injector.get_state(), InjectorState.RUNNING) time.sleep(EVENT_READ_TIMEOUT * 10) push_events( fixtures.foo_device_2_keyboard, [ # should execute a macro... InputEvent.key(8, 1), # forwarded InputEvent.key(9, 1), # triggers macro, not forwarding # macro runs now and injects a few more keys InputEvent.key(8, 0), # releases macro (needs to be forwarded as well) InputEvent.key(9, 0), # not forwarded, just like the down-event ], ) time.sleep(0.1) # give a chance that everything arrives in order push_events( fixtures.foo_device_2_gamepad, [ # gamepad stuff. trigger a combination InputEvent.abs(ABS_HAT0X, -1), InputEvent.abs(ABS_HAT0X, 0), ], ) time.sleep(0.1) push_events( fixtures.foo_device_2_keyboard, [ # just pass those over without modifying InputEvent.key(10, 1), InputEvent.key(10, 0), InputEvent(0, 0, 3124, 3564, 6542), ], force=True, ) # the injector needs time to process this time.sleep(0.1) # sending anything arbitrary does not stop the process # (is_alive checked later after some time) self.injector._msg_pipe[1].send(1234) # convert the write history to some easier to manage list history = read_write_history_pipe() # 1 event before the combination was triggered # 4 events for the macro # 1 event for releasing the previous key-down event # 2 for mapped keys # 3 for forwarded events self.assertEqual(len(history), 11) # the first bit is ordered properly self.assertEqual(history[0], (EV_KEY, 8, 1)) # forwarded del history[0] # since the macro takes a little bit of time to execute, its # keystrokes are all over the place. # just check if they are there and if so, remove them from the list. # the macro itself self.assertIn((EV_KEY, code_q, 1), history) self.assertIn((EV_KEY, code_q, 0), history) self.assertIn((EV_KEY, code_w, 1), history) self.assertIn((EV_KEY, code_w, 0), history) index_q_1 = history.index((EV_KEY, code_q, 1)) index_q_0 = history.index((EV_KEY, code_q, 0)) index_w_1 = history.index((EV_KEY, code_w, 1)) index_w_0 = history.index((EV_KEY, code_w, 0)) self.assertGreater(index_q_0, index_q_1) self.assertGreater(index_w_1, index_q_0) self.assertGreater(index_w_0, index_w_1) del history[index_w_0] del history[index_w_1] del history[index_q_0] del history[index_q_1] # The rest should be in order now. # First the released combination key which did not release the macro. # The combination key which released the macro won't appear here, because # it also didn't have a key-down event and therefore doesn't need to be # released itself. self.assertEqual(history[0], (EV_KEY, 8, 0)) # value should be 1, even if the input event was -1. # Injected keycodes should always be either 0 or 1 self.assertEqual(history[1], (EV_KEY, code_a, 1)) self.assertEqual(history[2], (EV_KEY, code_a, 0)) self.assertEqual(history[3], (EV_KEY, input_b, 1)) self.assertEqual(history[4], (EV_KEY, input_b, 0)) self.assertEqual(history[5], (3124, 3564, 6542)) time.sleep(0.1) self.assertTrue(self.injector.is_alive()) numlock_after = is_numlock_on() self.assertEqual(numlock_before, numlock_after) self.assertEqual(self.injector.get_state(), InjectorState.RUNNING) def test_is_in_capabilities(self): key = InputCombination(InputCombination.from_tuples((1, 2, 1))) capabilities = {1: [9, 2, 5]} self.assertTrue(is_in_capabilities(key, capabilities)) key = InputCombination(InputCombination.from_tuples((1, 2, 1), (1, 3, 1))) capabilities = {1: [9, 2, 5]} # only one of the codes of the combination is required. # The goal is to make combinations= across those sub-devices possible, # that make up one hardware device self.assertTrue(is_in_capabilities(key, capabilities)) key = InputCombination(InputCombination.from_tuples((1, 2, 1), (1, 5, 1))) capabilities = {1: [9, 2, 5]} self.assertTrue(is_in_capabilities(key, capabilities)) @test_setup class TestModifyCapabilities(unittest.TestCase): def setUp(self): self.global_uinputs = GlobalUInputs(UInput) self.global_uinputs.prepare_all() self.mapping_parser = MappingParser(self.global_uinputs) class FakeDevice: def __init__(self): self._capabilities = { evdev.ecodes.EV_SYN: [1, 2, 3], evdev.ecodes.EV_FF: [1, 2, 3], EV_ABS: [ ( 1, evdev.AbsInfo( value=None, min=None, max=1234, fuzz=None, flat=None, resolution=None, ), ), ( 2, evdev.AbsInfo( value=None, min=50, max=2345, fuzz=None, flat=None, resolution=None, ), ), 3, ], } def capabilities(self, absinfo=False): assert absinfo is True return self._capabilities preset = Preset() preset.add( Mapping.from_combination( InputCombination([InputConfig(type=EV_KEY, code=80)]), "keyboard", "a", ) ) preset.add( Mapping.from_combination( InputCombination([InputConfig(type=EV_KEY, code=81)]), "keyboard", DISABLE_NAME, ), ) macro_code = "r(2, m(sHiFt_l, r(2, k(1).k(2))))" macro = Parser.parse(macro_code, preset) preset.add( Mapping.from_combination( InputCombination([InputConfig(type=EV_KEY, code=60)]), "keyboard", macro_code, ), ) # going to be ignored, because EV_REL cannot be mapped, that's # mouse movements. preset.add( Mapping.from_combination( InputCombination( [InputConfig(type=EV_REL, code=1234, analog_threshold=3)] ), "keyboard", "b", ), ) self.a = keyboard_layout.get("a") self.shift_l = keyboard_layout.get("ShIfT_L") self.one = keyboard_layout.get(1) self.two = keyboard_layout.get("2") self.left = keyboard_layout.get("BtN_lEfT") self.fake_device = FakeDevice() self.preset = preset self.macro = macro def check_keys(self, capabilities): """No matter the configuration, EV_KEY will be mapped to EV_KEY.""" self.assertIn(EV_KEY, capabilities) keys = capabilities[EV_KEY] self.assertIn(self.a, keys) self.assertIn(self.one, keys) self.assertIn(self.two, keys) self.assertIn(self.shift_l, keys) self.assertNotIn(DISABLE_CODE, keys) def test_copy_capabilities(self): # I don't know what ABS_VOLUME is, for now I would like to just always # remove it until somebody complains, since its presence broke stuff self.injector = Injector(mock.Mock(), self.preset, self.mapping_parser) self.fake_device._capabilities = { EV_ABS: [ABS_VOLUME, (ABS_X, evdev.AbsInfo(0, 0, 500, 0, 0, 0))], EV_KEY: [1, 2, 3], EV_REL: [11, 12, 13], evdev.ecodes.EV_SYN: [1], evdev.ecodes.EV_FF: [2], } capabilities = self.injector._copy_capabilities(self.fake_device) self.assertNotIn(ABS_VOLUME, capabilities[EV_ABS]) self.assertNotIn(evdev.ecodes.EV_SYN, capabilities) self.assertNotIn(evdev.ecodes.EV_FF, capabilities) self.assertListEqual(capabilities[EV_KEY], [1, 2, 3]) self.assertListEqual(capabilities[EV_REL], [11, 12, 13]) self.assertEqual(capabilities[EV_ABS][0][1].max, 500) if __name__ == "__main__": unittest.main() input-remapper-2.1.1/tests/unit/test_input_config.py000066400000000000000000000474121475433465200227260ustar00rootroot00000000000000#!/usr/bin/env python3 # -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings # Copyright (C) 2025 sezanzeb # # This file is part of input-remapper. # # input-remapper is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # input-remapper is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with input-remapper. If not, see . import unittest from evdev.ecodes import ( EV_KEY, EV_ABS, EV_REL, BTN_C, BTN_B, BTN_A, BTN_MIDDLE, REL_X, REL_Y, REL_WHEEL, REL_HWHEEL, ABS_RY, ABS_X, ABS_HAT0Y, ABS_HAT0X, KEY_A, KEY_LEFTSHIFT, KEY_RIGHTALT, KEY_LEFTCTRL, ) from inputremapper.configs.input_config import InputCombination, InputConfig from tests.lib.test_setup import test_setup @test_setup class TestInputConfig(unittest.TestCase): def test_input_config(self): test_cases = [ # basic test, nothing fancy here { "input": { "type": EV_KEY, "code": KEY_A, "origin_hash": "foo", }, "properties": { "type": EV_KEY, "code": KEY_A, "origin_hash": "foo", "input_match_hash": (EV_KEY, KEY_A, "foo"), "defines_analog_input": False, "type_and_code": (EV_KEY, KEY_A), }, "methods": [ { "name": "description", "args": (), "kwargs": {}, "return": "a", }, { "name": "__hash__", "args": (), "kwargs": {}, "return": hash((EV_KEY, KEY_A, "foo", None)), }, ], }, # removes analog_threshold { "input": { "type": EV_KEY, "code": KEY_A, "origin_hash": "foo", "analog_threshold": 10, }, "properties": { "type": EV_KEY, "code": KEY_A, "origin_hash": "foo", "analog_threshold": None, "input_match_hash": (EV_KEY, KEY_A, "foo"), "defines_analog_input": False, "type_and_code": (EV_KEY, KEY_A), }, "methods": [ { "name": "description", "args": (), "kwargs": {}, "return": "a", }, { "name": "__hash__", "args": (), "kwargs": {}, "return": hash((EV_KEY, KEY_A, "foo", None)), }, ], }, # abs to btn { "input": { "type": EV_ABS, "code": ABS_X, "origin_hash": "foo", "analog_threshold": 10, }, "properties": { "type": EV_ABS, "code": ABS_X, "origin_hash": "foo", "analog_threshold": 10, "input_match_hash": (EV_ABS, ABS_X, "foo"), "defines_analog_input": False, "type_and_code": (EV_ABS, ABS_X), }, "methods": [ { "name": "description", "args": (), "kwargs": {}, "return": "Joystick-X Right 10%", }, { "name": "description", "args": (), "kwargs": {"exclude_threshold": True}, "return": "Joystick-X Right", }, { "name": "description", "args": (), "kwargs": { "exclude_threshold": True, "exclude_direction": True, }, "return": "Joystick-X", }, { "name": "__hash__", "args": (), "kwargs": {}, "return": hash((EV_ABS, ABS_X, "foo", 10)), }, ], }, # abs to btn with d-pad { "input": { "type": EV_ABS, "code": ABS_HAT0Y, "origin_hash": "foo", "analog_threshold": 10, }, "properties": { "type": EV_ABS, "code": ABS_HAT0Y, "origin_hash": "foo", "analog_threshold": 10, "input_match_hash": (EV_ABS, ABS_HAT0Y, "foo"), "defines_analog_input": False, "type_and_code": (EV_ABS, ABS_HAT0Y), }, "methods": [ { "name": "description", "args": (), "kwargs": {}, "return": "DPad-Y Down 10%", }, { "name": "__hash__", "args": (), "kwargs": {}, "return": hash((EV_ABS, ABS_HAT0Y, "foo", 10)), }, ], }, # rel to btn { "input": { "type": EV_REL, "code": REL_Y, "origin_hash": "foo", "analog_threshold": 10, }, "properties": { "type": EV_REL, "code": REL_Y, "origin_hash": "foo", "analog_threshold": 10, "input_match_hash": (EV_REL, REL_Y, "foo"), "defines_analog_input": False, "type_and_code": (EV_REL, REL_Y), }, "methods": [ { "name": "description", "args": (), "kwargs": {}, "return": "Y Down 10", }, { "name": "__hash__", "args": (), "kwargs": {}, "return": hash((EV_REL, REL_Y, "foo", 10)), }, ], }, # abs as axis { "input": { "type": EV_ABS, "code": ABS_X, "origin_hash": "foo", "analog_threshold": 0, }, "properties": { "type": EV_ABS, "code": ABS_X, "origin_hash": "foo", "analog_threshold": None, "input_match_hash": (EV_ABS, ABS_X, "foo"), "defines_analog_input": True, "type_and_code": (EV_ABS, ABS_X), }, "methods": [ { "name": "description", "args": (), "kwargs": {}, "return": "Joystick-X", }, { "name": "description", "args": (), "kwargs": { "exclude_threshold": True, "exclude_direction": True, }, "return": "Joystick-X", }, { "name": "__hash__", "args": (), "kwargs": {}, "return": hash((EV_ABS, ABS_X, "foo", None)), }, ], }, # rel as axis { "input": { "type": EV_REL, "code": REL_WHEEL, "origin_hash": "foo", }, "properties": { "type": EV_REL, "code": REL_WHEEL, "origin_hash": "foo", "analog_threshold": None, "input_match_hash": (EV_REL, REL_WHEEL, "foo"), "defines_analog_input": True, "type_and_code": (EV_REL, REL_WHEEL), }, "methods": [ { "name": "description", "args": (), "kwargs": {}, "return": "Wheel", }, { "name": "__hash__", "args": (), "kwargs": {}, "return": hash((EV_REL, REL_WHEEL, "foo", None)), }, ], }, ] for test_case in test_cases: input_config = InputConfig(**test_case["input"]) for property_, value in test_case["properties"].items(): self.assertEqual( value, getattr(input_config, property_), f"property mismatch for input: {test_case['input']} " f"property: {property_} expected value: {value}", ) for method in test_case["methods"]: self.assertEqual( method["return"], getattr(input_config, method["name"])( *method["args"], **method["kwargs"] ), f"wrong method return for input: {test_case['input']} " f"method: {method}", ) def test_is_immutable(self): input_config = InputConfig(type=1, code=2) with self.assertRaises(TypeError): input_config.origin_hash = "foo" @test_setup class TestInputCombination(unittest.TestCase): def test_eq(self): a = InputCombination( [ InputConfig(type=EV_REL, code=REL_X, value=1, origin_hash="1234"), InputConfig(type=EV_KEY, code=KEY_A, value=1, origin_hash="abcd"), ] ) b = InputCombination( [ InputConfig(type=EV_REL, code=REL_X, value=1, origin_hash="1234"), InputConfig(type=EV_KEY, code=KEY_A, value=1, origin_hash="abcd"), ] ) self.assertEqual(a, b) def test_not_eq(self): a = InputCombination( [ InputConfig(type=EV_REL, code=REL_X, value=1, origin_hash="2345"), InputConfig(type=EV_KEY, code=KEY_A, value=1, origin_hash="bcde"), ] ) b = InputCombination( [ InputConfig(type=EV_REL, code=REL_X, value=1, origin_hash="1234"), InputConfig(type=EV_KEY, code=KEY_A, value=1, origin_hash="abcd"), ] ) self.assertNotEqual(a, b) def test_can_be_used_as_dict_key(self): dict_ = { InputCombination( [ InputConfig(type=EV_REL, code=REL_X, value=1, origin_hash="1234"), InputConfig(type=EV_KEY, code=KEY_A, value=1, origin_hash="abcd"), ] ): "foo" } key = InputCombination( [ InputConfig(type=EV_REL, code=REL_X, value=1, origin_hash="1234"), InputConfig(type=EV_KEY, code=KEY_A, value=1, origin_hash="abcd"), ] ) self.assertEqual(dict_.get(key), "foo") def test_get_permutations(self): key_1 = InputCombination(InputCombination.from_tuples((1, 3, 1))) self.assertEqual(len(key_1.get_permutations()), 1) self.assertEqual(key_1.get_permutations()[0], key_1) key_2 = InputCombination(InputCombination.from_tuples((1, 3, 1), (1, 5, 1))) self.assertEqual(len(key_2.get_permutations()), 1) self.assertEqual(key_2.get_permutations()[0], key_2) key_3 = InputCombination( InputCombination.from_tuples((1, 3, 1), (1, 5, 1), (1, 7, 1)) ) self.assertEqual(len(key_3.get_permutations()), 2) self.assertEqual( key_3.get_permutations()[0], InputCombination( InputCombination.from_tuples((1, 3, 1), (1, 5, 1), (1, 7, 1)) ), ) self.assertEqual( key_3.get_permutations()[1], InputCombination( InputCombination.from_tuples((1, 5, 1), (1, 3, 1), (1, 7, 1)) ), ) def test_is_problematic(self): key_1 = InputCombination( InputCombination.from_tuples((1, KEY_LEFTSHIFT, 1), (1, 5, 1)) ) self.assertTrue(key_1.is_problematic()) key_2 = InputCombination( InputCombination.from_tuples((1, KEY_RIGHTALT, 1), (1, 5, 1)) ) self.assertTrue(key_2.is_problematic()) key_3 = InputCombination( InputCombination.from_tuples((1, 3, 1), (1, KEY_LEFTCTRL, 1)) ) self.assertTrue(key_3.is_problematic()) key_4 = InputCombination(InputCombination.from_tuples((1, 3, 1))) self.assertFalse(key_4.is_problematic()) key_5 = InputCombination(InputCombination.from_tuples((1, 3, 1), (1, 5, 1))) self.assertFalse(key_5.is_problematic()) def test_init(self): self.assertRaises(TypeError, lambda: InputCombination(1)) self.assertRaises(TypeError, lambda: InputCombination(None)) self.assertRaises(TypeError, lambda: InputCombination([1])) self.assertRaises(TypeError, lambda: InputCombination((1,))) self.assertRaises(TypeError, lambda: InputCombination((1, 2))) self.assertRaises(TypeError, lambda: InputCombination("1")) self.assertRaises(TypeError, lambda: InputCombination("(1,2,3)")) self.assertRaises( TypeError, lambda: InputCombination(((1, 2, 3), (1, 2, 3), None)), ) # those don't raise errors InputCombination(({"type": 1, "code": 2}, {"type": 1, "code": 1})) InputCombination(({"type": 1, "code": 2},)) InputCombination(({"type": "1", "code": "2"},)) InputCombination([InputConfig(type=1, code=2, analog_threshold=3)]) InputCombination( ( {"type": 1, "code": 2}, {"type": "1", "code": "2"}, InputConfig(type=1, code=2), ) ) def test_to_config(self): c1 = InputCombination([InputConfig(type=1, code=2, analog_threshold=3)]) c2 = InputCombination( ( InputConfig(type=1, code=2, analog_threshold=3), InputConfig(type=4, code=5, analog_threshold=6), ) ) # analog_threshold is removed for key events self.assertEqual(c1.to_config(), ({"type": 1, "code": 2},)) self.assertEqual( c2.to_config(), ({"type": 1, "code": 2}, {"type": 4, "code": 5, "analog_threshold": 6}), ) def test_beautify(self): # not an integration test, but I have all the selection_label tests here already self.assertEqual( InputCombination( InputCombination.from_tuples((EV_KEY, KEY_A, 1)) ).beautify(), "a", ) self.assertEqual( InputCombination( InputCombination.from_tuples((EV_KEY, KEY_A, 1)) ).beautify(), "a", ) self.assertEqual( InputCombination( InputCombination.from_tuples((EV_ABS, ABS_HAT0Y, -1)) ).beautify(), "DPad-Y Up", ) self.assertEqual( InputCombination( InputCombination.from_tuples((EV_KEY, BTN_A, 1)) ).beautify(), "Button A", ) self.assertEqual( InputCombination( InputCombination.from_tuples((EV_KEY, 1234, 1)) ).beautify(), "unknown (1, 1234)", ) self.assertEqual( InputCombination( InputCombination.from_tuples((EV_ABS, ABS_HAT0X, -1)) ).beautify(), "DPad-X Left", ) self.assertEqual( InputCombination( InputCombination.from_tuples((EV_ABS, ABS_HAT0Y, -1)) ).beautify(), "DPad-Y Up", ) self.assertEqual( InputCombination( InputCombination.from_tuples((EV_KEY, BTN_A, 1)) ).beautify(), "Button A", ) self.assertEqual( InputCombination( InputCombination.from_tuples((EV_ABS, ABS_X, 1)) ).beautify(), "Joystick-X Right", ) self.assertEqual( InputCombination( InputCombination.from_tuples((EV_ABS, ABS_RY, 1)) ).beautify(), "Joystick-RY Down", ) self.assertEqual( InputCombination( InputCombination.from_tuples((EV_REL, REL_HWHEEL, 1)) ).beautify(), "Wheel Right", ) self.assertEqual( InputCombination( InputCombination.from_tuples((EV_REL, REL_WHEEL, -1)) ).beautify(), "Wheel Down", ) # combinations self.assertEqual( InputCombination( InputCombination.from_tuples( (EV_KEY, BTN_A, 1), (EV_KEY, BTN_B, 1), (EV_KEY, BTN_C, 1), ), ).beautify(), "Button A + Button B + Button C", ) def test_find_analog_input_config(self): analog_input = InputConfig(type=EV_REL, code=REL_X) combination = InputCombination( ( InputConfig(type=EV_KEY, code=BTN_MIDDLE), InputConfig(type=EV_REL, code=REL_Y, analog_threshold=1), analog_input, ) ) self.assertIsNone(combination.find_analog_input_config(type_=EV_ABS)) self.assertEqual( combination.find_analog_input_config(type_=EV_REL), analog_input ) self.assertEqual(combination.find_analog_input_config(), analog_input) combination = InputCombination( ( InputConfig(type=EV_REL, code=REL_X, analog_threshold=1), InputConfig(type=EV_KEY, code=BTN_MIDDLE), ) ) self.assertIsNone(combination.find_analog_input_config(type_=EV_ABS)) self.assertIsNone(combination.find_analog_input_config(type_=EV_REL)) self.assertIsNone(combination.find_analog_input_config()) if __name__ == "__main__": unittest.main() input-remapper-2.1.1/tests/unit/test_input_event.py000066400000000000000000000104431475433465200225740ustar00rootroot00000000000000#!/usr/bin/env python3 # -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings # Copyright (C) 2025 sezanzeb # # This file is part of input-remapper. # # input-remapper is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # input-remapper is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with input-remapper. If not, see . import unittest import evdev from dataclasses import FrozenInstanceError from inputremapper.input_event import InputEvent from tests.lib.test_setup import test_setup @test_setup class TestInputEvent(unittest.TestCase): def test_from_event(self): e1 = InputEvent.from_event(evdev.InputEvent(1, 2, 3, 4, 5)) e2 = InputEvent.from_event(e1) self.assertEqual(e1, e2) self.assertEqual(e1.sec, 1) self.assertEqual(e1.usec, 2) self.assertEqual(e1.type, 3) self.assertEqual(e1.code, 4) self.assertEqual(e1.value, 5) self.assertEqual(e1.sec, e2.sec) self.assertEqual(e1.usec, e2.usec) self.assertEqual(e1.type, e2.type) self.assertEqual(e1.code, e2.code) self.assertEqual(e1.value, e2.value) self.assertRaises(TypeError, InputEvent.from_event, "1,2,3") def test_from_event_tuple(self): t1 = (1, 2, 3) t2 = (1, "2", 3) t3 = (1, 2, 3, 4, 5) t4 = (1, "b", 3) e1 = InputEvent.from_tuple(t1) e2 = InputEvent.from_tuple(t2) self.assertEqual(e1, e2) self.assertEqual(e1.sec, 0) self.assertEqual(e1.usec, 0) self.assertEqual(e1.type, 1) self.assertEqual(e1.code, 2) self.assertEqual(e1.value, 3) def test_properties(self): e1 = InputEvent.from_tuple((evdev.ecodes.EV_KEY, evdev.ecodes.BTN_LEFT, 1)) self.assertEqual( e1.event_tuple, (evdev.ecodes.EV_KEY, evdev.ecodes.BTN_LEFT, 1), ) self.assertEqual(e1.type_and_code, (evdev.ecodes.EV_KEY, evdev.ecodes.BTN_LEFT)) with self.assertRaises( FrozenInstanceError ): # would be TypeError on a slotted class e1.event_tuple = (1, 2, 3) with self.assertRaises( FrozenInstanceError ): # would be TypeError on a slotted class e1.type_and_code = (1, 2) with self.assertRaises(FrozenInstanceError): e1.value = 5 def test_modify(self): e1 = InputEvent(1, 2, 3, 4, 5) e2 = e1.modify(value=6) e3 = e1.modify(sec=0, usec=0, type_=0, code=0, value=0) self.assertNotEqual(e1, e2) self.assertEqual(e1.sec, e2.sec) self.assertEqual(e1.usec, e2.usec) self.assertEqual(e1.type, e2.type) self.assertEqual(e1.code, e2.code) self.assertNotEqual(e1.value, e2.value) self.assertEqual(e3.sec, 0) self.assertEqual(e3.usec, 0) self.assertEqual(e3.type, 0) self.assertEqual(e3.code, 0) self.assertEqual(e3.value, 0) def test_is_wheel_event(self): input_event_x = InputEvent( 0, 0, evdev.ecodes.EV_REL, evdev.ecodes.REL_X, 1, ) self.assertFalse(input_event_x.is_wheel_event) self.assertFalse(input_event_x.is_wheel_hi_res_event) input_event_wheel = InputEvent( 0, 0, evdev.ecodes.EV_REL, evdev.ecodes.REL_WHEEL, 1, ) self.assertTrue(input_event_wheel.is_wheel_event) self.assertFalse(input_event_wheel.is_wheel_hi_res_event) input_event_wheel_hi_res = InputEvent( 0, 0, evdev.ecodes.EV_REL, evdev.ecodes.REL_WHEEL_HI_RES, 1, ) self.assertFalse(input_event_wheel_hi_res.is_wheel_event) self.assertTrue(input_event_wheel_hi_res.is_wheel_hi_res_event) input-remapper-2.1.1/tests/unit/test_ipc.py000066400000000000000000000146131475433465200210120ustar00rootroot00000000000000#!/usr/bin/env python3 # -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings # Copyright (C) 2025 sezanzeb # # This file is part of input-remapper. # # input-remapper is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # input-remapper is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with input-remapper. If not, see . import asyncio import multiprocessing import os import select import time import unittest from inputremapper.ipc.pipe import Pipe from inputremapper.ipc.shared_dict import SharedDict from inputremapper.ipc.socket import Server, Client, Base from tests.lib.test_setup import test_setup from tests.lib.tmp import tmp @test_setup class TestSharedDict(unittest.TestCase): def setUp(self): self.shared_dict = SharedDict() self.shared_dict.start() time.sleep(0.02) def test_returns_none(self): self.assertIsNone(self.shared_dict.get("a")) self.assertIsNone(self.shared_dict["a"]) def test_set_get(self): self.shared_dict["a"] = 3 self.assertEqual(self.shared_dict.get("a"), 3) self.assertEqual(self.shared_dict["a"], 3) @test_setup class TestSocket(unittest.TestCase): def test_socket(self): def test(s1, s2): self.assertEqual(s2.recv(), None) s1.send(1) self.assertTrue(s2.poll()) self.assertEqual(s2.recv(), 1) self.assertFalse(s2.poll()) self.assertEqual(s2.recv(), None) s1.send(2) self.assertTrue(s2.poll()) s1.send(3) self.assertTrue(s2.poll()) self.assertEqual(s2.recv(), 2) self.assertTrue(s2.poll()) self.assertEqual(s2.recv(), 3) self.assertFalse(s2.poll()) self.assertEqual(s2.recv(), None) server = Server(os.path.join(tmp, "socket1")) client = Client(os.path.join(tmp, "socket1")) test(server, client) client = Client(os.path.join(tmp, "socket2")) server = Server(os.path.join(tmp, "socket2")) test(client, server) def test_not_connected_1(self): # client discards old message, because it might have had a purpose # for a different client and not for the current one server = Server(os.path.join(tmp, "socket3")) server.send(1) client = Client(os.path.join(tmp, "socket3")) server.send(2) self.assertTrue(client.poll()) self.assertEqual(client.recv(), 2) self.assertFalse(client.poll()) self.assertEqual(client.recv(), None) def test_not_connected_2(self): client = Client(os.path.join(tmp, "socket4")) client.send(1) server = Server(os.path.join(tmp, "socket4")) client.send(2) self.assertTrue(server.poll()) self.assertEqual(server.recv(), 2) self.assertFalse(server.poll()) self.assertEqual(server.recv(), None) def test_select(self): """Is compatible to select.select.""" server = Server(os.path.join(tmp, "socket6")) client = Client(os.path.join(tmp, "socket6")) server.send(1) ready = select.select([client], [], [], 0)[0][0] self.assertEqual(ready, client) client.send(2) ready = select.select([server], [], [], 0)[0][0] self.assertEqual(ready, server) def test_base_abstract(self): self.assertRaises(NotImplementedError, lambda: Base("foo")) self.assertRaises(NotImplementedError, lambda: Base.connect(None)) self.assertRaises(NotImplementedError, lambda: Base.reconnect(None)) self.assertRaises(NotImplementedError, lambda: Base.fileno(None)) @test_setup class TestPipe(unittest.IsolatedAsyncioTestCase): def test_pipe_single(self): p1 = Pipe(os.path.join(tmp, "pipe")) self.assertEqual(p1.recv(), None) p1.send(1) self.assertTrue(p1.poll()) self.assertEqual(p1.recv(), 1) self.assertFalse(p1.poll()) self.assertEqual(p1.recv(), None) p1.send(2) self.assertTrue(p1.poll()) p1.send(3) self.assertTrue(p1.poll()) self.assertEqual(p1.recv(), 2) self.assertTrue(p1.poll()) self.assertEqual(p1.recv(), 3) self.assertFalse(p1.poll()) self.assertEqual(p1.recv(), None) def test_pipe_duo(self): p1 = Pipe(os.path.join(tmp, "pipe")) p2 = Pipe(os.path.join(tmp, "pipe")) self.assertEqual(p2.recv(), None) p1.send(1) self.assertEqual(p2.recv(), 1) self.assertEqual(p2.recv(), None) p1.send(2) p1.send(3) self.assertEqual(p2.recv(), 2) self.assertEqual(p2.recv(), 3) self.assertEqual(p2.recv(), None) async def test_async_for_loop(self): p1 = Pipe(os.path.join(tmp, "pipe")) iterator = p1.__aiter__() p1.send(1) self.assertEqual(await iterator.__anext__(), 1) read_task = asyncio.Task(iterator.__anext__()) timeout_task = asyncio.Task(asyncio.sleep(1)) done, pending = await asyncio.wait( (read_task, timeout_task), return_when=asyncio.FIRST_COMPLETED ) self.assertIn(timeout_task, done) self.assertIn(read_task, pending) read_task.cancel() async def test_async_for_loop_duo(self): def writer(): p = Pipe(os.path.join(tmp, "pipe")) for i in range(3): p.send(i) time.sleep(0.5) for i in range(3): p.send(i) time.sleep(0.1) p.send("stop now") p1 = Pipe(os.path.join(tmp, "pipe")) w_process = multiprocessing.Process(target=writer) w_process.start() messages = [] async for msg in p1: messages.append(msg) if msg == "stop now": break self.assertEqual(messages, [0, 1, 2, 0, 1, 2, "stop now"]) if __name__ == "__main__": unittest.main() input-remapper-2.1.1/tests/unit/test_logger.py000066400000000000000000000112221475433465200215070ustar00rootroot00000000000000#!/usr/bin/env python3 # -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings # Copyright (C) 2025 sezanzeb # # This file is part of input-remapper. # # input-remapper is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # input-remapper is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with input-remapper. If not, see . from tests.lib.tmp import tmp import logging import os import shutil import unittest import evdev from inputremapper.configs.paths import PathUtils from inputremapper.logging.logger import ( logger, ColorfulFormatter, ) from tests.lib.test_setup import test_setup def add_filehandler(log_path: str, debug: bool) -> None: """Start logging to a file.""" log_path = os.path.expanduser(log_path) os.makedirs(os.path.dirname(log_path), exist_ok=True) file_handler = logging.FileHandler(log_path) file_handler.setFormatter(ColorfulFormatter(debug)) logger.addHandler(file_handler) logger.info('Starting logging to "%s"', log_path) @test_setup class TestLogger(unittest.TestCase): def tearDown(self): logger.update_verbosity(debug=True) # remove the file handler logger.handlers = [ handler for handler in logger.handlers if not isinstance(logger.handlers, logging.FileHandler) ] path = os.path.join(tmp, "logger-test") PathUtils.remove(path) def test_write(self): uinput = evdev.UInput(name="foo") path = os.path.join(tmp, "logger-test") add_filehandler(path, False) logger.write((evdev.ecodes.EV_KEY, evdev.ecodes.KEY_A, 1), uinput) with open(path, "r") as f: content = f.read() self.assertIn( f'Writing (1, 30, 1) to "foo"', content, ) def test_log_info(self): logger.update_verbosity(debug=False) path = os.path.join(tmp, "logger-test") add_filehandler(path, False) logger.log_info() with open(path, "r") as f: content = f.read().lower() self.assertIn("input-remapper", content) def test_makes_path(self): path = os.path.join(tmp, "logger-test") if os.path.exists(path): shutil.rmtree(path) new_path = os.path.join(tmp, "logger-test", "a", "b", "c") add_filehandler(new_path, False) self.assertTrue(os.path.exists(new_path)) def test_debug(self): path = os.path.join(tmp, "logger-test") logger.update_verbosity(True) add_filehandler(path, True) logger.error("abc") logger.warning("foo") logger.info("123") logger.debug("456") logger.debug("789") with open(path, "r") as f: content = f.read().lower() self.assertIn("logger.py", content) self.assertIn("error", content) self.assertIn("abc", content) self.assertIn("warn", content) self.assertIn("foo", content) self.assertIn("info", content) self.assertIn("123", content) self.assertIn("debug", content) self.assertIn("456", content) self.assertIn("debug", content) self.assertIn("789", content) def test_default(self): path = os.path.join(tmp, "logger-test") logger.update_verbosity(debug=False) add_filehandler(path, False) logger.error("abc") logger.warning("foo") logger.info("123") logger.debug("456") logger.debug("789") with open(path, "r") as f: content = f.read().lower() self.assertNotIn("logger.py", content) self.assertNotIn("line", content) self.assertIn("error", content) self.assertIn("abc", content) self.assertIn("warn", content) self.assertIn("foo", content) self.assertNotIn("info", content) self.assertIn("123", content) self.assertNotIn("debug", content) self.assertNotIn("456", content) self.assertNotIn("debug", content) self.assertNotIn("789", content) if __name__ == "__main__": unittest.main() input-remapper-2.1.1/tests/unit/test_macros/000077500000000000000000000000001475433465200211445ustar00rootroot00000000000000input-remapper-2.1.1/tests/unit/test_macros/__init__.py000066400000000000000000000000001475433465200232430ustar00rootroot00000000000000input-remapper-2.1.1/tests/unit/test_macros/macro_test_base.py000066400000000000000000000101611475433465200246470ustar00rootroot00000000000000#!/usr/bin/env python3 # -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings # Copyright (C) 2025 sezanzeb # # This file is part of input-remapper. # # input-remapper is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # input-remapper is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with input-remapper. If not, see . import asyncio import unittest from inputremapper.configs.preset import Preset from inputremapper.configs.validation_errors import MacroError from inputremapper.injection.context import Context from inputremapper.injection.global_uinputs import GlobalUInputs, UInput from inputremapper.injection.macros.macro import Macro, macro_variables from inputremapper.injection.macros.parse import Parser from inputremapper.injection.mapping_handlers.mapping_parser import MappingParser from tests.lib.fixtures import fixtures from tests.lib.logger import logger from tests.lib.patches import InputDevice class MacroTestBase(unittest.IsolatedAsyncioTestCase): @classmethod def setUpClass(cls): macro_variables.start() def setUp(self): self.result = [] self.global_uinputs = GlobalUInputs(UInput) self.mapping_parser = MappingParser(self.global_uinputs) try: self.loop = asyncio.get_event_loop() except RuntimeError: # suddenly "There is no current event loop in thread 'MainThread'" # errors started to appear self.loop = asyncio.new_event_loop() asyncio.set_event_loop(self.loop) self.source_device = InputDevice(fixtures.bar_device.path) self.context = Context( Preset(), source_devices={fixtures.bar_device.get_device_hash(): self.source_device}, forward_devices={}, mapping_parser=self.mapping_parser, ) def tearDown(self): self.result = [] def handler(self, type_: int, code: int, value: int): """Where macros should write codes to.""" logger.info(f"macro wrote{(type_, code, value)}") self.result.append((type_, code, value)) async def trigger_sequence(self, macro: Macro, event): for listener in self.context.listeners: asyncio.ensure_future(listener(event)) # this still might cause race conditions and the test to fail await asyncio.sleep(0) macro.press_trigger() if macro.running: return asyncio.ensure_future(macro.run(self.handler)) async def release_sequence(self, macro: Macro, event): for listener in self.context.listeners: asyncio.ensure_future(listener(event)) # this still might cause race conditions and the test to fail await asyncio.sleep(0) macro.release_trigger() def count_child_macros(self, macro) -> int: count = 0 for task in macro.tasks: count += len(task.child_macros) for child_macro in task.child_macros: count += self.count_child_macros(child_macro) return count def count_tasks(self, macro) -> int: count = len(macro.tasks) for task in macro.tasks: for child_macro in task.child_macros: count += self.count_tasks(child_macro) return count def expect_string_in_error(self, string: str, macro: str): with self.assertRaises(MacroError) as cm: Parser.parse(macro, self.context) error = str(cm.exception) self.assertIn(string, error) class DummyMapping: macro_key_sleep_ms = 10 rel_rate = 60 target_uinput = "keyboard + mouse" if __name__ == "__main__": unittest.main() input-remapper-2.1.1/tests/unit/test_macros/test_add.py000066400000000000000000000052141475433465200233070ustar00rootroot00000000000000#!/usr/bin/env python3 # -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings # Copyright (C) 2025 sezanzeb # # This file is part of input-remapper. # # input-remapper is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # input-remapper is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with input-remapper. If not, see . import unittest from inputremapper.configs.validation_errors import MacroError from inputremapper.injection.macros.macro import macro_variables from inputremapper.injection.macros.parse import Parser from tests.lib.test_setup import test_setup from tests.unit.test_macros.macro_test_base import DummyMapping, MacroTestBase @test_setup class TestAdd(MacroTestBase): async def test_add(self): await Parser.parse("set(a, 1).add(a, 1)", self.context, DummyMapping).run( self.handler ) self.assertEqual(macro_variables.get("a"), 2) await Parser.parse("set(b, 1).add(b, -1)", self.context, DummyMapping).run( self.handler ) self.assertEqual(macro_variables.get("b"), 0) await Parser.parse("set(c, -1).add(c, 500)", self.context, DummyMapping).run( self.handler ) self.assertEqual(macro_variables.get("c"), 499) await Parser.parse("add(d, 500)", self.context, DummyMapping).run(self.handler) self.assertEqual(macro_variables.get("d"), 500) async def test_add_invalid(self): # For invalid input it should do nothing (except to log to the console) await Parser.parse('set(e, "foo").add(e, 1)', self.context, DummyMapping).run( self.handler ) self.assertEqual(macro_variables.get("e"), "foo") await Parser.parse('set(e, "2").add(e, 3)', self.context, DummyMapping).run( self.handler ) self.assertEqual(macro_variables.get("e"), "2") async def test_raises_error(self): Parser.parse("add(a, 1)", self.context) # no error self.assertRaises(MacroError, Parser.parse, "add(a, b)", self.context) self.assertRaises(MacroError, Parser.parse, 'add(a, "1")', self.context) if __name__ == "__main__": unittest.main() input-remapper-2.1.1/tests/unit/test_macros/test_argument.py000066400000000000000000000151761475433465200244110ustar00rootroot00000000000000#!/usr/bin/env python3 # -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings # Copyright (C) 2025 sezanzeb # # This file is part of input-remapper. # # input-remapper is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # input-remapper is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with input-remapper. If not, see . import unittest from inputremapper.configs.validation_errors import ( MacroError, ) from inputremapper.injection.macros.argument import Argument, ArgumentConfig from inputremapper.injection.macros.macro import Macro, macro_variables from inputremapper.injection.macros.raw_value import RawValue from inputremapper.injection.macros.variable import Variable from tests.lib.test_setup import test_setup from tests.unit.test_macros.macro_test_base import DummyMapping, MacroTestBase @test_setup class TestArgument(MacroTestBase): def test_resolve(self): self.assertEqual(Variable("a", const=True).get_value(), "a") self.assertEqual(Variable(1, const=True).get_value(), 1) self.assertEqual(Variable(None, const=True).get_value(), None) # $ is part of a custom string here self.assertEqual(Variable('"$a"', const=True).get_value(), '"$a"') self.assertEqual(Variable("'$a'", const=True).get_value(), "'$a'") self.assertEqual(Variable("$a", const=True).get_value(), "$a") variable = Variable("a", const=False) self.assertEqual(variable.get_value(), None) macro_variables["a"] = 1 self.assertEqual(variable.get_value(), 1) def test_type_check(self): def test(value, types, name, position): argument = Argument( ArgumentConfig( types=types, name=name, position=position, ), DummyMapping(), ) argument.initialize_variable(RawValue(value=value)) return argument.get_value() def test_variable(variable, types, name, position): argument = Argument( ArgumentConfig( types=types, name=name, position=position, ), DummyMapping(), ) argument._variable = variable return argument.get_value() # allows params that can be cast to the target type self.assertEqual(test("1", [str, None], "foo", 0), "1") self.assertEqual(test("1.2", [str], "foo", 2), "1.2") self.assertRaises( MacroError, lambda: test("1.2", [int], "foo", 3), ) self.assertRaises(MacroError, lambda: test("a", [None], "foo", 0)) self.assertRaises(MacroError, lambda: test("a", [int], "foo", 1)) self.assertRaises( MacroError, lambda: test("a", [int, float], "foo", 2), ) self.assertRaises( MacroError, lambda: test("a", [int, None], "foo", 3), ) self.assertEqual(test("a", [int, float, None, str], "foo", 4), "a") # variables are expected to be of the Variable type here, not a $string self.assertRaises( MacroError, lambda: test("$a", [int], "foo", 4), ) # We don't cast values that were explicitly set as strings back into numbers. variable = Variable("a", const=False) variable.set_value("5") self.assertRaises( MacroError, lambda: test_variable(variable, [int], "foo", 4), ) self.assertRaises( MacroError, lambda: test("a", [Macro], "foo", 0), ) self.assertRaises(MacroError, lambda: test("1", [Macro], "foo", 0)) def test_validate_variable_name(self): self.assertRaises( MacroError, lambda: Variable("1a", const=False).validate_variable_name(), ) self.assertRaises( MacroError, lambda: Variable("$a", const=False).validate_variable_name(), ) self.assertRaises( MacroError, lambda: Variable("a()", const=False).validate_variable_name(), ) self.assertRaises( MacroError, lambda: Variable("1", const=False).validate_variable_name(), ) self.assertRaises( MacroError, lambda: Variable("+", const=False).validate_variable_name(), ) self.assertRaises( MacroError, lambda: Variable("-", const=False).validate_variable_name(), ) self.assertRaises( MacroError, lambda: Variable("*", const=False).validate_variable_name(), ) self.assertRaises( MacroError, lambda: Variable("a,b", const=False).validate_variable_name(), ) self.assertRaises( MacroError, lambda: Variable("a,b", const=False).validate_variable_name(), ) self.assertRaises( MacroError, lambda: Variable("#", const=False).validate_variable_name(), ) self.assertRaises( MacroError, lambda: Variable(1, const=False).validate_variable_name(), ) self.assertRaises( MacroError, lambda: Variable(None, const=False).validate_variable_name(), ) self.assertRaises( MacroError, lambda: Variable([], const=False).validate_variable_name(), ) self.assertRaises( MacroError, lambda: Variable((), const=False).validate_variable_name(), ) # doesn't raise Variable("a", const=False).validate_variable_name() Variable("_a", const=False).validate_variable_name() Variable("_A", const=False).validate_variable_name() Variable("A", const=False).validate_variable_name() Variable("Abcd", const=False).validate_variable_name() Variable("Abcd_", const=False).validate_variable_name() Variable("Abcd_1234", const=False).validate_variable_name() Variable("Abcd1234_", const=False).validate_variable_name() if __name__ == "__main__": unittest.main() input-remapper-2.1.1/tests/unit/test_macros/test_dynamic_types.py000066400000000000000000000124211475433465200254250ustar00rootroot00000000000000#!/usr/bin/env python3 # -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings # Copyright (C) 2025 sezanzeb # # This file is part of input-remapper. # # input-remapper is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # input-remapper is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with input-remapper. If not, see . import unittest from inputremapper.configs.validation_errors import ( MacroError, ) from inputremapper.injection.macros.argument import ArgumentConfig from inputremapper.injection.macros.macro import macro_variables from inputremapper.injection.macros.parse import Parser from inputremapper.injection.macros.raw_value import RawValue from inputremapper.injection.macros.task import Task from tests.unit.test_macros.macro_test_base import DummyMapping, MacroTestBase class TestDynamicTypes(MacroTestBase): # "Dynamic" meaning const=False async def test_set_type_int(self): await Parser.parse( "set(a, 1)", self.context, DummyMapping, True, ).run(lambda *_, **__: None) self.assertEqual(macro_variables.get("a"), 1) # assertEqual(1.0, 1) passes, so check for the type to be sure: self.assertIsInstance(macro_variables.get("a"), int) async def test_set_type_float(self): await Parser.parse( "set(a, 2.2)", self.context, DummyMapping, True, ).run(lambda *_, **__: None) self.assertEqual(macro_variables.get("a"), 2.2) async def test_set_type_str(self): await Parser.parse( 'set(a, "3")', self.context, DummyMapping, True, ).run(lambda *_, **__: None) self.assertEqual(macro_variables.get("a"), "3") def make_test_task(self, types): # Make a new test task, with a different types array each time. class TestTask(Task): argument_configs = [ ArgumentConfig( name="testvalue", position=0, types=types, ) ] return TestTask( [RawValue("$a")], {}, self.context, DummyMapping, ) async def test_dynamic_int_parsing(self): # set(a, 4) was used. Could be meant as an integer, or as a string # (just like how key(KEY_A) doesn't require string quotes to be a string) macro_variables["a"] = 4 test_task = self.make_test_task([str, int]) self.assertEqual(test_task.get_argument("testvalue").get_value(), 4) test_task = self.make_test_task([int]) self.assertEqual(test_task.get_argument("testvalue").get_value(), 4) # Now that ints are not allowed, it will be used as a string test_task = self.make_test_task([str]) self.assertEqual(test_task.get_argument("testvalue").get_value(), "4") async def test_dynamic_float_parsing(self): # set(a, 5.5) was used. macro_variables["a"] = 5.5 test_task = self.make_test_task([str, float]) self.assertEqual(test_task.get_argument("testvalue").get_value(), 5.5) test_task = self.make_test_task([float]) self.assertEqual(test_task.get_argument("testvalue").get_value(), 5.5) test_task = self.make_test_task([str]) self.assertEqual(test_task.get_argument("testvalue").get_value(), "5.5") async def test_no_float_allowed(self): # set(a, 6.6) was used. macro_variables["a"] = 6.6 test_task = self.make_test_task([str, int]) self.assertEqual(test_task.get_argument("testvalue").get_value(), "6.6") test_task = self.make_test_task([int]) self.assertRaises( MacroError, lambda: test_task.get_argument("testvalue").get_value(), ) async def test_force_string_float(self): # set(a, "7.7") was used. Since quotes are explicitly added, the variable is # not intended to be used as a float. macro_variables["a"] = "7.7" test_task = self.make_test_task([str, float]) self.assertEqual(test_task.get_argument("testvalue").get_value(), "7.7") test_task = self.make_test_task([float]) self.assertRaises( MacroError, lambda: test_task.get_argument("testvalue").get_value(), ) async def test_force_string_int(self): # set(a, "8") was used. macro_variables["a"] = "8" test_task = self.make_test_task([int, str]) self.assertEqual(test_task.get_argument("testvalue").get_value(), "8") test_task = self.make_test_task([int]) self.assertRaises( MacroError, lambda: test_task.get_argument("testvalue").get_value(), ) if __name__ == "__main__": unittest.main() input-remapper-2.1.1/tests/unit/test_macros/test_event.py000066400000000000000000000043061475433465200237010ustar00rootroot00000000000000#!/usr/bin/env python3 # -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings # Copyright (C) 2025 sezanzeb # # This file is part of input-remapper. # # input-remapper is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # input-remapper is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with input-remapper. If not, see . import unittest from evdev.ecodes import ( EV_REL, EV_KEY, REL_X, ) from inputremapper.configs.keyboard_layout import keyboard_layout from inputremapper.injection.macros.parse import Parser from tests.lib.test_setup import test_setup from tests.unit.test_macros.macro_test_base import MacroTestBase, DummyMapping @test_setup class TestEvent(MacroTestBase): async def test_event_1(self): macro = Parser.parse("e(EV_KEY, KEY_A, 1)", self.context, DummyMapping) a_code = keyboard_layout.get("a") await macro.run(self.handler) self.assertListEqual(self.result, [(EV_KEY, a_code, 1)]) self.assertEqual(self.count_child_macros(macro), 0) async def test_event_2(self): macro = Parser.parse( "repeat(1, event(type=5421, code=324, value=154))", self.context, DummyMapping, ) code = 324 await macro.run(self.handler) self.assertListEqual(self.result, [(5421, code, 154)]) self.assertEqual(self.count_child_macros(macro), 1) async def test_event_mouse(self): macro = Parser.parse("e(EV_REL, REL_X, 10)", self.context, DummyMapping) await macro.run(self.handler) self.assertListEqual(self.result, [(EV_REL, REL_X, 10)]) self.assertEqual(self.count_child_macros(macro), 0) if __name__ == "__main__": unittest.main() input-remapper-2.1.1/tests/unit/test_macros/test_hold.py000066400000000000000000000157051475433465200235130ustar00rootroot00000000000000#!/usr/bin/env python3 # -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings # Copyright (C) 2025 sezanzeb # # This file is part of input-remapper. # # input-remapper is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # input-remapper is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with input-remapper. If not, see . import asyncio import unittest from evdev.ecodes import EV_KEY from inputremapper.configs.keyboard_layout import keyboard_layout from inputremapper.configs.validation_errors import MacroError from inputremapper.injection.macros.parse import Parser from tests.lib.test_setup import test_setup from tests.unit.test_macros.macro_test_base import MacroTestBase, DummyMapping @test_setup class TestHold(MacroTestBase): async def test_hold(self): # repeats key(a) as long as the key is held down macro = Parser.parse("key(1).hold(key(a)).key(3)", self.context, DummyMapping) """down""" macro.press_trigger() await asyncio.sleep(0.05) self.assertTrue(macro.tasks[1].is_holding()) macro.press_trigger() # redundantly calling doesn't break anything asyncio.ensure_future(macro.run(self.handler)) await asyncio.sleep(0.2) self.assertTrue(macro.tasks[1].is_holding()) self.assertGreater(len(self.result), 2) """up""" macro.release_trigger() await asyncio.sleep(0.05) self.assertFalse(macro.tasks[1].is_holding()) self.assertEqual(self.result[0], (EV_KEY, keyboard_layout.get("1"), 1)) self.assertEqual(self.result[-1], (EV_KEY, keyboard_layout.get("3"), 0)) code_a = keyboard_layout.get("a") self.assertGreater(self.result.count((EV_KEY, code_a, 1)), 2) self.assertEqual(self.count_child_macros(macro), 1) self.assertEqual(self.count_tasks(macro), 4) async def test_hold_failing_child(self): # if a child macro fails, hold will not try to run it again. # The exception is properly propagated through both `hold`s and the macro # stops. If the code is broken, this test might enter an infinite loop. macro = Parser.parse("hold(hold(key(a)))", self.context, DummyMapping) class MyException(Exception): pass def f(*_): raise MyException("foo") macro.press_trigger() with self.assertRaises(MyException): await macro.run(f) await asyncio.sleep(0.1) self.assertFalse(macro.running) async def test_dont_hold(self): macro = Parser.parse("key(1).hold(key(a)).key(3)", self.context, DummyMapping) asyncio.ensure_future(macro.run(self.handler)) await asyncio.sleep(0.2) self.assertFalse(macro.tasks[1].is_holding()) # press_trigger was never called, so the macro completes right away # and the child macro of hold is never called. self.assertEqual(len(self.result), 4) self.assertEqual(self.result[0], (EV_KEY, keyboard_layout.get("1"), 1)) self.assertEqual(self.result[-1], (EV_KEY, keyboard_layout.get("3"), 0)) self.assertEqual(self.count_child_macros(macro), 1) self.assertEqual(self.count_tasks(macro), 4) async def test_just_hold(self): macro = Parser.parse("key(1).hold().key(3)", self.context, DummyMapping) """down""" macro.press_trigger() asyncio.ensure_future(macro.run(self.handler)) await asyncio.sleep(0.1) self.assertTrue(macro.tasks[1].is_holding()) self.assertEqual(len(self.result), 2) await asyncio.sleep(0.1) # doesn't do fancy stuff, is blocking until the release self.assertEqual(len(self.result), 2) """up""" macro.release_trigger() await asyncio.sleep(0.05) self.assertFalse(macro.tasks[1].is_holding()) self.assertEqual(len(self.result), 4) self.assertEqual(self.result[0], (EV_KEY, keyboard_layout.get("1"), 1)) self.assertEqual(self.result[-1], (EV_KEY, keyboard_layout.get("3"), 0)) self.assertEqual(self.count_child_macros(macro), 0) self.assertEqual(self.count_tasks(macro), 3) async def test_dont_just_hold(self): macro = Parser.parse("key(1).hold().key(3)", self.context, DummyMapping) asyncio.ensure_future(macro.run(self.handler)) await asyncio.sleep(0.1) self.assertFalse(macro.tasks[1].is_holding()) # since press_trigger was never called it just does the macro # completely self.assertEqual(len(self.result), 4) self.assertEqual(self.result[0], (EV_KEY, keyboard_layout.get("1"), 1)) self.assertEqual(self.result[-1], (EV_KEY, keyboard_layout.get("3"), 0)) self.assertEqual(self.count_child_macros(macro), 0) async def test_hold_down(self): # writes down and waits for the up event until the key is released macro = Parser.parse("hold(a)", self.context, DummyMapping) self.assertEqual(self.count_child_macros(macro), 0) """down""" macro.press_trigger() await asyncio.sleep(0.05) self.assertTrue(macro.tasks[0].is_holding()) asyncio.ensure_future(macro.run(self.handler)) macro.press_trigger() # redundantly calling doesn't break anything await asyncio.sleep(0.2) self.assertTrue(macro.tasks[0].is_holding()) self.assertEqual(len(self.result), 1) self.assertEqual(self.result[0], (EV_KEY, keyboard_layout.get("a"), 1)) """up""" macro.release_trigger() await asyncio.sleep(0.05) self.assertFalse(macro.tasks[0].is_holding()) self.assertEqual(len(self.result), 2) self.assertEqual(self.result[0], (EV_KEY, keyboard_layout.get("a"), 1)) self.assertEqual(self.result[1], (EV_KEY, keyboard_layout.get("a"), 0)) async def test_hold_variable(self): code_a = keyboard_layout.get("a") macro = Parser.parse("set(foo, a).hold($foo)", self.context, DummyMapping) await macro.run(self.handler) self.assertListEqual( self.result, [ (EV_KEY, code_a, 1), (EV_KEY, code_a, 0), ], ) async def test_raises_error(self): self.assertRaises(MacroError, Parser.parse, "h(1, 1)", self.context) self.assertRaises(MacroError, Parser.parse, "h(hold(h(1, 1)))", self.context) self.assertRaises(MacroError, Parser.parse, "hold(key(a)key(b))", self.context) if __name__ == "__main__": unittest.main() input-remapper-2.1.1/tests/unit/test_macros/test_hold_keys.py000066400000000000000000000107411475433465200245410ustar00rootroot00000000000000#!/usr/bin/env python3 # -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings # Copyright (C) 2025 sezanzeb # # This file is part of input-remapper. # # input-remapper is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # input-remapper is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with input-remapper. If not, see . import asyncio import unittest from evdev.ecodes import EV_KEY from inputremapper.configs.keyboard_layout import keyboard_layout from inputremapper.configs.validation_errors import MacroError from inputremapper.injection.macros.parse import Parser from tests.lib.test_setup import test_setup from tests.unit.test_macros.macro_test_base import MacroTestBase, DummyMapping @test_setup class TestHoldKeys(MacroTestBase): async def test_hold_keys(self): macro = Parser.parse( "set(foo, b).hold_keys(a, $foo, c)", self.context, DummyMapping ) # press first macro.press_trigger() # then run, just like how it is going to happen during runtime asyncio.ensure_future(macro.run(self.handler)) code_a = keyboard_layout.get("a") code_b = keyboard_layout.get("b") code_c = keyboard_layout.get("c") await asyncio.sleep(0.2) self.assertListEqual( self.result, [ (EV_KEY, code_a, 1), (EV_KEY, code_b, 1), (EV_KEY, code_c, 1), ], ) macro.release_trigger() await asyncio.sleep(0.2) self.assertListEqual( self.result, [ (EV_KEY, code_a, 1), (EV_KEY, code_b, 1), (EV_KEY, code_c, 1), (EV_KEY, code_c, 0), (EV_KEY, code_b, 0), (EV_KEY, code_a, 0), ], ) async def test_hold_keys_broken(self): # Won't run any of the keys when one of them is invalid macro = Parser.parse( "set(foo, broken).hold_keys(a, $foo, c)", self.context, DummyMapping ) # press first macro.press_trigger() # then run, just like how it is going to happen during runtime asyncio.ensure_future(macro.run(self.handler)) await asyncio.sleep(0.2) self.assertListEqual(self.result, []) macro.release_trigger() await asyncio.sleep(0.2) self.assertListEqual(self.result, []) async def test_aldjfakl(self): repeats = 5 macro = Parser.parse( f"repeat({repeats}, key(k))", self.context, DummyMapping, ) self.assertEqual(self.count_child_macros(macro), 1) async def test_run_plus_syntax(self): macro = Parser.parse("a + b + c + d", self.context, DummyMapping) macro.press_trigger() asyncio.ensure_future(macro.run(self.handler)) await asyncio.sleep(0.2) self.assertTrue(macro.tasks[0].is_holding()) # starting from the left, presses each one down self.assertEqual(self.result[0], (EV_KEY, keyboard_layout.get("a"), 1)) self.assertEqual(self.result[1], (EV_KEY, keyboard_layout.get("b"), 1)) self.assertEqual(self.result[2], (EV_KEY, keyboard_layout.get("c"), 1)) self.assertEqual(self.result[3], (EV_KEY, keyboard_layout.get("d"), 1)) # and then releases starting with the previously pressed key macro.release_trigger() await asyncio.sleep(0.2) self.assertFalse(macro.tasks[0].is_holding()) self.assertEqual(self.result[4], (EV_KEY, keyboard_layout.get("d"), 0)) self.assertEqual(self.result[5], (EV_KEY, keyboard_layout.get("c"), 0)) self.assertEqual(self.result[6], (EV_KEY, keyboard_layout.get("b"), 0)) self.assertEqual(self.result[7], (EV_KEY, keyboard_layout.get("a"), 0)) async def test_raises_error(self): self.assertRaises( MacroError, Parser.parse, "hold_keys(a, broken, b)", self.context ) if __name__ == "__main__": unittest.main() input-remapper-2.1.1/tests/unit/test_macros/test_if_eq.py000066400000000000000000000216171475433465200236470ustar00rootroot00000000000000#!/usr/bin/env python3 # -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings # Copyright (C) 2025 sezanzeb # # This file is part of input-remapper. # # input-remapper is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # input-remapper is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with input-remapper. If not, see . import asyncio import multiprocessing import unittest from evdev.ecodes import ( EV_KEY, ) from inputremapper.configs.keyboard_layout import keyboard_layout from inputremapper.configs.validation_errors import MacroError from inputremapper.injection.macros.macro import macro_variables from inputremapper.injection.macros.parse import Parser from tests.lib.logger import logger from tests.lib.test_setup import test_setup from tests.unit.test_macros.macro_test_base import DummyMapping, MacroTestBase @test_setup class TestIfEq(MacroTestBase): async def test_if_eq(self): """new version of ifeq""" code_a = keyboard_layout.get("a") code_b = keyboard_layout.get("b") a_press = [(EV_KEY, code_a, 1), (EV_KEY, code_a, 0)] b_press = [(EV_KEY, code_b, 1), (EV_KEY, code_b, 0)] async def test(macro, expected): """Run the macro and compare the injections with an expectation.""" logger.info("Testing %s", macro) # cleanup macro_variables._clear() self.assertIsNone(macro_variables.get("a")) self.result.clear() # test macro = Parser.parse(macro, self.context, DummyMapping) await macro.run(self.handler) self.assertListEqual(self.result, expected) await test("if_eq(1, 1, key(a), key(b))", a_press) await test("if_eq(1, 2, key(a), key(b))", b_press) await test("if_eq(value_1=1, value_2=1, then=key(a), else=key(b))", a_press) await test('set(a, "foo").if_eq($a, "foo", key(a), key(b))', a_press) await test('set(a, "foo").if_eq("foo", $a, key(a), key(b))', a_press) await test('set(a, "foo").if_eq("foo", $a, , key(b))', []) await test('set(a, "foo").if_eq("foo", $a, None, key(b))', []) await test('set(a, "qux").if_eq("foo", $a, key(a), key(b))', b_press) await test('set(a, "qux").if_eq($a, "foo", key(a), key(b))', b_press) await test('set(a, "qux").if_eq($a, "foo", key(a), )', []) await test('set(a, "x").set(b, "y").if_eq($b, $a, key(a), key(b))', b_press) await test('set(a, "x").set(b, "y").if_eq($b, $a, key(a), )', []) await test('set(a, "x").set(b, "y").if_eq($b, $a, key(a), None)', []) await test('set(a, "x").set(b, "y").if_eq($b, $a, key(a), else=None)', []) await test('set(a, "x").set(b, "x").if_eq($b, $a, key(a), key(b))', a_press) await test('set(a, "x").set(b, "x").if_eq($b, $a, , key(b))', []) await test("if_eq($q, $w, key(a), else=key(b))", a_press) # both None await test("set(q, 1).if_eq($q, $w, key(a), else=key(b))", b_press) await test("set(q, 1).set(w, 1).if_eq($q, $w, key(a), else=key(b))", a_press) await test('set(q, " a b ").if_eq($q, " a b ", key(a), key(b))', a_press) await test('if_eq("\t", "\n", key(a), key(b))', b_press) # treats values in quotes as strings, not as code await test('set(q, "$a").if_eq($q, "$a", key(a), key(b))', a_press) await test('set(q, "a,b").if_eq("a,b", $q, key(a), key(b))', a_press) await test('set(q, "c(1, 2)").if_eq("c(1, 2)", $q, key(a), key(b))', a_press) await test('set(q, "c(1, 2)").if_eq("c(1, 2)", "$q", key(a), key(b))', b_press) await test('if_eq("value_1=1", 1, key(a), key(b))', b_press) # won't compare strings and int, be similar to python await test('set(a, "1").if_eq($a, 1, key(a), key(b))', b_press) await test('set(a, 1).if_eq($a, "1", key(a), key(b))', b_press) async def test_if_eq_runs_multiprocessed(self): """ifeq on variables that have been set in other processes works.""" macro = Parser.parse( "if_eq($foo, 3, key(a), key(b))", self.context, DummyMapping ) code_a = keyboard_layout.get("a") code_b = keyboard_layout.get("b") self.assertEqual(self.count_child_macros(macro), 2) def set_foo(value): # will write foo = 2 into the shared dictionary of macros macro_2 = Parser.parse(f"set(foo, {value})", self.context, DummyMapping) loop = asyncio.new_event_loop() loop.run_until_complete(macro_2.run(lambda: None)) """foo is not 3""" process = multiprocessing.Process(target=set_foo, args=(2,)) process.start() process.join() await macro.run(self.handler) self.assertListEqual(self.result, [(EV_KEY, code_b, 1), (EV_KEY, code_b, 0)]) """foo is 3""" process = multiprocessing.Process(target=set_foo, args=(3,)) process.start() process.join() await macro.run(self.handler) self.assertListEqual( self.result, [ (EV_KEY, code_b, 1), (EV_KEY, code_b, 0), (EV_KEY, code_a, 1), (EV_KEY, code_a, 0), ], ) async def test_raises_error(self): Parser.parse("if_eq(2, $a, k(a),)", self.context) # no error Parser.parse("if_eq(2, $a, , else=k(a))", self.context) # no error self.assertRaises(MacroError, Parser.parse, "if_eq(2, $a, 1,)", self.context) self.assertRaises(MacroError, Parser.parse, "if_eq(2, $a, , 2)", self.context) self.expect_string_in_error("blub", "if_eq(2, $a, key(a), blub=a)") class TestIfEqDeprecated(MacroTestBase): async def test_ifeq_runs(self): # deprecated ifeq function, but kept for compatibility reasons macro = Parser.parse( "set(foo, 2).ifeq(foo, 2, key(a), key(b))", self.context, DummyMapping, ) code_a = keyboard_layout.get("a") code_b = keyboard_layout.get("b") await macro.run(self.handler) self.assertListEqual(self.result, [(EV_KEY, code_a, 1), (EV_KEY, code_a, 0)]) self.assertEqual(self.count_child_macros(macro), 2) async def test_ifeq_none(self): code_a = keyboard_layout.get("a") # first param None macro = Parser.parse( "set(foo, 2).ifeq(foo, 2, None, key(b))", self.context, DummyMapping ) self.assertEqual(self.count_child_macros(macro), 1) await macro.run(self.handler) self.assertListEqual(self.result, []) # second param None self.result = [] macro = Parser.parse( "set(foo, 2).ifeq(foo, 2, key(a), None)", self.context, DummyMapping ) self.assertEqual(self.count_child_macros(macro), 1) await macro.run(self.handler) self.assertListEqual(self.result, [(EV_KEY, code_a, 1), (EV_KEY, code_a, 0)]) """Old syntax, use None instead""" # first param "" self.result = [] macro = Parser.parse( "set(foo, 2).ifeq(foo, 2, , key(b))", self.context, DummyMapping ) self.assertEqual(self.count_child_macros(macro), 1) await macro.run(self.handler) self.assertListEqual(self.result, []) # second param "" self.result = [] macro = Parser.parse( "set(foo, 2).ifeq(foo, 2, key(a), )", self.context, DummyMapping ) self.assertEqual(self.count_child_macros(macro), 1) await macro.run(self.handler) self.assertListEqual(self.result, [(EV_KEY, code_a, 1), (EV_KEY, code_a, 0)]) async def test_ifeq_unknown_key(self): macro = Parser.parse("ifeq(qux, 2, key(a), key(b))", self.context, DummyMapping) code_a = keyboard_layout.get("a") code_b = keyboard_layout.get("b") await macro.run(self.handler) self.assertListEqual(self.result, [(EV_KEY, code_b, 1), (EV_KEY, code_b, 0)]) self.assertEqual(self.count_child_macros(macro), 2) async def test_raises_error(self): Parser.parse("ifeq(a, 2, k(a),)", self.context) # no error Parser.parse("ifeq(a, 2, , k(a))", self.context) # no error Parser.parse("ifeq(a, 2, None, k(a))", self.context) # no error self.assertRaises(MacroError, Parser.parse, "ifeq(a, 2, 1,)", self.context) self.assertRaises(MacroError, Parser.parse, "ifeq(a, 2, , 2)", self.context) if __name__ == "__main__": unittest.main() input-remapper-2.1.1/tests/unit/test_macros/test_if_single.py000066400000000000000000000157371475433465200245310ustar00rootroot00000000000000#!/usr/bin/env python3 # -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings # Copyright (C) 2025 sezanzeb # # This file is part of input-remapper. # # input-remapper is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # input-remapper is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with input-remapper. If not, see . import asyncio import unittest from evdev.ecodes import ( EV_KEY, ABS_Y, ) from inputremapper.configs.keyboard_layout import keyboard_layout from inputremapper.configs.validation_errors import MacroError from inputremapper.injection.macros.parse import Parser from inputremapper.input_event import InputEvent from tests.lib.test_setup import test_setup from tests.unit.test_macros.macro_test_base import DummyMapping, MacroTestBase @test_setup class TestIfSingle(MacroTestBase): async def test_if_single(self): macro = Parser.parse("if_single(key(x), key(y))", self.context, DummyMapping) self.assertEqual(self.count_child_macros(macro), 2) a = keyboard_layout.get("a") x = keyboard_layout.get("x") await self.trigger_sequence(macro, InputEvent.key(a, 1)) await asyncio.sleep(0.1) await self.release_sequence(macro, InputEvent.key(a, 0)) # the key that triggered the macro is released await asyncio.sleep(0.1) self.assertListEqual(self.result, [(EV_KEY, x, 1), (EV_KEY, x, 0)]) self.assertFalse(macro.running) async def test_if_single_ignores_releases(self): # the timeout won't break the macro, everything happens well within that # timeframe. macro = Parser.parse( "if_single(key(x), else=key(y), timeout=100000)", self.context, DummyMapping, ) self.assertEqual(self.count_child_macros(macro), 2) a = keyboard_layout.get("a") b = keyboard_layout.get("b") x = keyboard_layout.get("x") y = keyboard_layout.get("y") # pressing the macro key await self.trigger_sequence(macro, InputEvent.key(a, 1)) await asyncio.sleep(0.05) # if_single only looks out for newly pressed keys, # it doesn't care if keys were released that have been # pressed before if_single. This was decided because it is a lot # less tricky and more fluently to use if you type fast for listener in self.context.listeners: asyncio.ensure_future(listener(InputEvent.key(b, 0))) await asyncio.sleep(0.05) self.assertListEqual(self.result, []) # releasing the actual key triggers if_single await asyncio.sleep(0.05) await self.release_sequence(macro, InputEvent.key(a, 0)) await asyncio.sleep(0.05) self.assertListEqual(self.result, [(EV_KEY, x, 1), (EV_KEY, x, 0)]) self.assertFalse(macro.running) async def test_if_not_single(self): # Will run the `else` macro if another key is pressed. # Also works if if_single is a child macro, i.e. the event is passed to it # from the outside macro correctly. macro = Parser.parse( "repeat(1, if_single(then=key(x), else=key(y)))", self.context, DummyMapping, ) self.assertEqual(self.count_child_macros(macro), 3) self.assertEqual(self.count_tasks(macro), 4) a = keyboard_layout.get("a") b = keyboard_layout.get("b") x = keyboard_layout.get("x") y = keyboard_layout.get("y") # press the trigger key await self.trigger_sequence(macro, InputEvent.key(a, 1)) await asyncio.sleep(0.1) # press another key for listener in self.context.listeners: asyncio.ensure_future(listener(InputEvent.key(b, 1))) await asyncio.sleep(0.1) self.assertListEqual(self.result, [(EV_KEY, y, 1), (EV_KEY, y, 0)]) self.assertFalse(macro.running) async def test_if_not_single_none(self): macro = Parser.parse("if_single(key(x),)", self.context, DummyMapping) self.assertEqual(self.count_child_macros(macro), 1) a = keyboard_layout.get("a") b = keyboard_layout.get("b") x = keyboard_layout.get("x") # press trigger key await self.trigger_sequence(macro, InputEvent.key(a, 1)) await asyncio.sleep(0.1) # press another key for listener in self.context.listeners: asyncio.ensure_future(listener(InputEvent.key(b, 1))) await asyncio.sleep(0.1) self.assertListEqual(self.result, []) self.assertFalse(macro.running) async def test_if_single_times_out(self): macro = Parser.parse( "set(t, 300).if_single(key(x), key(y), timeout=$t)", self.context, DummyMapping, ) self.assertEqual(self.count_child_macros(macro), 2) a = keyboard_layout.get("a") y = keyboard_layout.get("y") await self.trigger_sequence(macro, InputEvent.key(a, 1)) # no timeout yet await asyncio.sleep(0.2) self.assertListEqual(self.result, []) self.assertTrue(macro.running) # times out now await asyncio.sleep(0.2) self.assertListEqual(self.result, [(EV_KEY, y, 1), (EV_KEY, y, 0)]) self.assertFalse(macro.running) async def test_if_single_ignores_joystick(self): """Triggers else + delayed_handle_keycode.""" # Integration test style for if_single. # If a joystick that is mapped to a button is moved, if_single stops macro = Parser.parse( "if_single(k(a), k(KEY_LEFTSHIFT))", self.context, DummyMapping ) code_shift = keyboard_layout.get("KEY_LEFTSHIFT") code_a = keyboard_layout.get("a") trigger = 1 await self.trigger_sequence(macro, InputEvent.key(trigger, 1)) await asyncio.sleep(0.1) for listener in self.context.listeners: asyncio.ensure_future(listener(InputEvent.abs(ABS_Y, 10))) await asyncio.sleep(0.1) await self.release_sequence(macro, InputEvent.key(trigger, 0)) await asyncio.sleep(0.1) self.assertFalse(macro.running) self.assertListEqual(self.result, [(EV_KEY, code_a, 1), (EV_KEY, code_a, 0)]) async def test_raises_error(self): Parser.parse("if_single(k(a),)", self.context) # no error self.assertRaises(MacroError, Parser.parse, "if_single(1,)", self.context) self.assertRaises(MacroError, Parser.parse, "if_single(,1)", self.context) if __name__ == "__main__": unittest.main() input-remapper-2.1.1/tests/unit/test_macros/test_if_tap.py000066400000000000000000000151701475433465200240230ustar00rootroot00000000000000#!/usr/bin/env python3 # -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings # Copyright (C) 2025 sezanzeb # # This file is part of input-remapper. # # input-remapper is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # input-remapper is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with input-remapper. If not, see . import asyncio import unittest from evdev.ecodes import ( EV_KEY, KEY_A, KEY_B, ) from inputremapper.configs.keyboard_layout import keyboard_layout from inputremapper.configs.validation_errors import MacroError from inputremapper.injection.macros.parse import Parser from tests.lib.test_setup import test_setup from tests.unit.test_macros.macro_test_base import DummyMapping, MacroTestBase @test_setup class TestIfTap(MacroTestBase): async def test_if_tap(self): macro = Parser.parse("if_tap(key(x), key(y), 100)", self.context, DummyMapping) self.assertEqual(self.count_child_macros(macro), 2) x = keyboard_layout.get("x") y = keyboard_layout.get("y") # this is the regular routine of how a macro is started. the tigger is pressed # already when the macro runs, and released during if_tap within the timeout. macro.press_trigger() asyncio.ensure_future(macro.run(self.handler)) await asyncio.sleep(0.05) macro.release_trigger() await asyncio.sleep(0.05) self.assertListEqual(self.result, [(EV_KEY, x, 1), (EV_KEY, x, 0)]) self.assertFalse(macro.running) async def test_if_tap_2(self): # when the press arrives shortly after run. # a tap will happen within the timeout even if the tigger is not pressed when # it does into if_tap macro = Parser.parse("if_tap(key(a), key(b), 100)", self.context, DummyMapping) asyncio.ensure_future(macro.run(self.handler)) await asyncio.sleep(0.01) macro.press_trigger() await asyncio.sleep(0.01) macro.release_trigger() await asyncio.sleep(0.2) self.assertListEqual(self.result, [(EV_KEY, KEY_A, 1), (EV_KEY, KEY_A, 0)]) self.assertFalse(macro.running) self.result.clear() async def test_if_double_tap(self): macro = Parser.parse( "if_tap(if_tap(key(a), key(b), 100), key(c), 100)", self.context, DummyMapping, ) self.assertEqual(self.count_child_macros(macro), 4) self.assertEqual(self.count_tasks(macro), 5) asyncio.ensure_future(macro.run(self.handler)) # first tap macro.press_trigger() await asyncio.sleep(0.05) macro.release_trigger() # second tap await asyncio.sleep(0.04) macro.press_trigger() await asyncio.sleep(0.04) macro.release_trigger() await asyncio.sleep(0.05) self.assertListEqual(self.result, [(EV_KEY, KEY_A, 1), (EV_KEY, KEY_A, 0)]) self.assertFalse(macro.running) self.result.clear() """If the second tap takes too long, runs else there""" asyncio.ensure_future(macro.run(self.handler)) # first tap macro.press_trigger() await asyncio.sleep(0.05) macro.release_trigger() # second tap await asyncio.sleep(0.06) macro.press_trigger() await asyncio.sleep(0.06) macro.release_trigger() await asyncio.sleep(0.05) self.assertListEqual(self.result, [(EV_KEY, KEY_B, 1), (EV_KEY, KEY_B, 0)]) self.assertFalse(macro.running) self.result.clear() async def test_if_tap_none(self): # first param none macro = Parser.parse("if_tap(, key(y), 100)", self.context, DummyMapping) self.assertEqual(self.count_child_macros(macro), 1) y = keyboard_layout.get("y") macro.press_trigger() asyncio.ensure_future(macro.run(self.handler)) await asyncio.sleep(0.05) macro.release_trigger() await asyncio.sleep(0.05) self.assertListEqual(self.result, []) # second param none macro = Parser.parse("if_tap(key(y), , 50)", self.context, DummyMapping) self.assertEqual(self.count_child_macros(macro), 1) y = keyboard_layout.get("y") macro.press_trigger() asyncio.ensure_future(macro.run(self.handler)) await asyncio.sleep(0.1) macro.release_trigger() await asyncio.sleep(0.05) self.assertListEqual(self.result, []) self.assertFalse(macro.running) async def test_if_not_tap(self): macro = Parser.parse("if_tap(key(x), key(y), 50)", self.context, DummyMapping) self.assertEqual(self.count_child_macros(macro), 2) y = keyboard_layout.get("y") macro.press_trigger() asyncio.ensure_future(macro.run(self.handler)) await asyncio.sleep(0.1) macro.release_trigger() await asyncio.sleep(0.05) self.assertListEqual(self.result, [(EV_KEY, y, 1), (EV_KEY, y, 0)]) self.assertFalse(macro.running) async def test_if_not_tap_named(self): macro = Parser.parse( "if_tap(key(x), key(y), timeout=50)", self.context, DummyMapping ) self.assertEqual(self.count_child_macros(macro), 2) x = keyboard_layout.get("x") y = keyboard_layout.get("y") macro.press_trigger() asyncio.ensure_future(macro.run(self.handler)) await asyncio.sleep(0.1) macro.release_trigger() await asyncio.sleep(0.05) self.assertListEqual(self.result, [(EV_KEY, y, 1), (EV_KEY, y, 0)]) self.assertFalse(macro.running) async def test_raises_error(self): Parser.parse("if_tap(, k(a), 1000)", self.context) # no error Parser.parse("if_tap(, k(a), timeout=1000)", self.context) # no error Parser.parse("if_tap(, k(a), $timeout)", self.context) # no error Parser.parse("if_tap(, k(a), timeout=$t)", self.context) # no error Parser.parse("if_tap(, key(a))", self.context) # no error Parser.parse("if_tap(k(a),)", self.context) # no error self.assertRaises(MacroError, Parser.parse, "if_tap(k(a), b)", self.context) if __name__ == "__main__": unittest.main() input-remapper-2.1.1/tests/unit/test_macros/test_key.py000066400000000000000000000107021475433465200233450ustar00rootroot00000000000000#!/usr/bin/env python3 # -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings # Copyright (C) 2025 sezanzeb # # This file is part of input-remapper. # # input-remapper is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # input-remapper is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with input-remapper. If not, see . import unittest from evdev.ecodes import EV_KEY from inputremapper.configs.keyboard_layout import keyboard_layout from inputremapper.configs.validation_errors import ( MacroError, SymbolNotAvailableInTargetError, ) from inputremapper.injection.macros.parse import Parser from tests.lib.test_setup import test_setup from tests.unit.test_macros.macro_test_base import DummyMapping, MacroTestBase @test_setup class TestKey(MacroTestBase): async def test_1(self): macro = Parser.parse("key(1)", self.context, DummyMapping, True) one_code = keyboard_layout.get("1") await macro.run(self.handler) self.assertListEqual( self.result, [(EV_KEY, one_code, 1), (EV_KEY, one_code, 0)], ) self.assertEqual(self.count_child_macros(macro), 0) async def test_named_parameter(self): macro = Parser.parse("key(symbol=1)", self.context, DummyMapping, True) one_code = keyboard_layout.get("1") await macro.run(self.handler) self.assertListEqual( self.result, [(EV_KEY, one_code, 1), (EV_KEY, one_code, 0)], ) self.assertEqual(self.count_child_macros(macro), 0) async def test_2(self): macro = Parser.parse('key(1).key("KEY_A").key(3)', self.context, DummyMapping) await macro.run(self.handler) self.assertListEqual( self.result, [ (EV_KEY, keyboard_layout.get("1"), 1), (EV_KEY, keyboard_layout.get("1"), 0), (EV_KEY, keyboard_layout.get("a"), 1), (EV_KEY, keyboard_layout.get("a"), 0), (EV_KEY, keyboard_layout.get("3"), 1), (EV_KEY, keyboard_layout.get("3"), 0), ], ) self.assertEqual(self.count_child_macros(macro), 0) async def test_key_down_up(self): code_a = keyboard_layout.get("a") code_b = keyboard_layout.get("b") macro = Parser.parse( "set(foo, b).key_down($foo).key_up($foo).key_up(a).key_down(a)", self.context, DummyMapping, ) await macro.run(self.handler) self.assertListEqual( self.result, [ (EV_KEY, code_b, 1), (EV_KEY, code_b, 0), (EV_KEY, code_a, 0), (EV_KEY, code_a, 1), ], ) async def test_raises_error(self): Parser.parse("k(1).h(k(a)).k(3)", self.context) # No error self.expect_string_in_error("bracket", "key((1)") self.expect_string_in_error("bracket", "k(1))") self.assertRaises(MacroError, Parser.parse, "k((1).k)", self.context) self.assertRaises(MacroError, Parser.parse, "key(foo=a)", self.context) self.assertRaises( MacroError, Parser.parse, "key(symbol=a, foo=b)", self.context ) self.assertRaises(MacroError, Parser.parse, "k()", self.context) self.assertRaises(MacroError, Parser.parse, "key(invalidkey)", self.context) self.assertRaises(MacroError, Parser.parse, 'key("invalidkey")', self.context) Parser.parse("key(1)", self.context) # no error self.assertRaises(MacroError, Parser.parse, "k(1, 1)", self.context) Parser.parse("key($a)", self.context) # no error self.assertRaises(MacroError, Parser.parse, "key(a)key(b)", self.context) # wrong target for BTN_A self.assertRaises( SymbolNotAvailableInTargetError, Parser.parse, "key(BTN_A)", self.context, DummyMapping, ) if __name__ == "__main__": unittest.main() input-remapper-2.1.1/tests/unit/test_macros/test_leds.py000066400000000000000000000112101475433465200234770ustar00rootroot00000000000000#!/usr/bin/env python3 # -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings # Copyright (C) 2025 sezanzeb # # This file is part of input-remapper. # # input-remapper is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # input-remapper is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with input-remapper. If not, see . import unittest from unittest.mock import patch from evdev.ecodes import ( EV_KEY, KEY_1, KEY_2, LED_CAPSL, LED_NUML, ) from inputremapper.injection.macros.parse import Parser from tests.lib.test_setup import test_setup from tests.unit.test_macros.macro_test_base import DummyMapping, MacroTestBase @test_setup class TestLeds(MacroTestBase): async def test_if_capslock(self): macro = Parser.parse( "if_capslock(key(KEY_1), key(KEY_2))", self.context, DummyMapping, True, ) self.assertEqual(self.count_child_macros(macro), 2) with patch.object(self.source_device, "leds", side_effect=lambda: [LED_NUML]): await macro.run(self.handler) self.assertListEqual(self.result, [(EV_KEY, KEY_2, 1), (EV_KEY, KEY_2, 0)]) with patch.object(self.source_device, "leds", side_effect=lambda: [LED_CAPSL]): self.result = [] await macro.run(self.handler) self.assertListEqual(self.result, [(EV_KEY, KEY_1, 1), (EV_KEY, KEY_1, 0)]) async def test_if_numlock(self): macro = Parser.parse( "if_numlock(key(KEY_1), key(KEY_2))", self.context, DummyMapping, True, ) self.assertEqual(self.count_child_macros(macro), 2) with patch.object(self.source_device, "leds", side_effect=lambda: [LED_NUML]): await macro.run(self.handler) self.assertListEqual(self.result, [(EV_KEY, KEY_1, 1), (EV_KEY, KEY_1, 0)]) with patch.object(self.source_device, "leds", side_effect=lambda: [LED_CAPSL]): self.result = [] await macro.run(self.handler) self.assertListEqual(self.result, [(EV_KEY, KEY_2, 1), (EV_KEY, KEY_2, 0)]) async def test_if_numlock_no_else(self): macro = Parser.parse( "if_numlock(key(KEY_1))", self.context, DummyMapping, True, ) self.assertEqual(self.count_child_macros(macro), 1) with patch.object(self.source_device, "leds", side_effect=lambda: [LED_CAPSL]): await macro.run(self.handler) self.assertListEqual(self.result, []) with patch.object(self.source_device, "leds", side_effect=lambda: [LED_NUML]): self.result = [] await macro.run(self.handler) self.assertListEqual(self.result, [(EV_KEY, KEY_1, 1), (EV_KEY, KEY_1, 0)]) async def test_if_capslock_no_then(self): macro = Parser.parse( "if_capslock(None, key(KEY_1))", self.context, DummyMapping, True, ) self.assertEqual(self.count_child_macros(macro), 1) with patch.object(self.source_device, "leds", side_effect=lambda: [LED_CAPSL]): await macro.run(self.handler) self.assertListEqual(self.result, []) with patch.object(self.source_device, "leds", side_effect=lambda: [LED_NUML]): self.result = [] await macro.run(self.handler) self.assertListEqual(self.result, [(EV_KEY, KEY_1, 1), (EV_KEY, KEY_1, 0)]) async def test_raises_error(self): Parser.parse("if_capslock(else=key(KEY_A))", self.context) # no error Parser.parse("if_capslock(key(KEY_A), None)", self.context) # no error Parser.parse("if_capslock(key(KEY_A))", self.context) # no error Parser.parse("if_capslock(then=key(KEY_A))", self.context) # no error Parser.parse("if_numlock(else=key(KEY_A))", self.context) # no error Parser.parse("if_numlock(key(KEY_A), None)", self.context) # no error Parser.parse("if_numlock(key(KEY_A))", self.context) # no error Parser.parse("if_numlock(then=key(KEY_A))", self.context) # no error if __name__ == "__main__": unittest.main() input-remapper-2.1.1/tests/unit/test_macros/test_macros.py000066400000000000000000000131201475433465200240360ustar00rootroot00000000000000#!/usr/bin/env python3 # -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings # Copyright (C) 2025 sezanzeb # # This file is part of input-remapper. # # input-remapper is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # input-remapper is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with input-remapper. If not, see . import asyncio import time import unittest from evdev.ecodes import ( EV_KEY, ) from inputremapper.configs.keyboard_layout import keyboard_layout from inputremapper.injection.macros.macro import Macro from inputremapper.injection.macros.parse import Parser from tests.lib.test_setup import test_setup from tests.unit.test_macros.macro_test_base import MacroTestBase, DummyMapping @test_setup class TestMacros(MacroTestBase): async def test_newlines(self): macro = Parser.parse( " repeat(2,\nkey(\nr ).key(minus\n )).key(m) ", self.context, DummyMapping, ) r = keyboard_layout.get("r") minus = keyboard_layout.get("minus") m = keyboard_layout.get("m") await macro.run(self.handler) self.assertListEqual( self.result, [ (EV_KEY, r, 1), (EV_KEY, r, 0), (EV_KEY, minus, 1), (EV_KEY, minus, 0), (EV_KEY, r, 1), (EV_KEY, r, 0), (EV_KEY, minus, 1), (EV_KEY, minus, 0), (EV_KEY, m, 1), (EV_KEY, m, 0), ], ) self.assertEqual(self.count_child_macros(macro), 1) self.assertEqual(self.count_tasks(macro), 4) async def test_various(self): start = time.time() macro = Parser.parse( "w(200).repeat(2,modify(w,\nrepeat(2,\tkey(BtN_LeFt))).w(10).key(k))", self.context, DummyMapping, ) self.assertEqual(self.count_child_macros(macro), 3) self.assertEqual(self.count_tasks(macro), 7) w = keyboard_layout.get("w") left = keyboard_layout.get("bTn_lEfT") k = keyboard_layout.get("k") await macro.run(self.handler) num_pauses = 8 + 6 + 4 keystroke_time = num_pauses * DummyMapping.macro_key_sleep_ms wait_time = 220 total_time = (keystroke_time + wait_time) / 1000 self.assertLess(time.time() - start, total_time * 1.2) self.assertGreater(time.time() - start, total_time * 0.9) expected = [(EV_KEY, w, 1)] expected += [(EV_KEY, left, 1), (EV_KEY, left, 0)] * 2 expected += [(EV_KEY, w, 0)] expected += [(EV_KEY, k, 1), (EV_KEY, k, 0)] expected *= 2 self.assertListEqual(self.result, expected) async def test_not_run(self): # does nothing without .run macro = Parser.parse("key(a).repeat(3, key(b))", self.context) self.assertIsInstance(macro, Macro) self.assertListEqual(self.result, []) async def test_duplicate_run(self): # it won't restart the macro, because that may screw up the # internal state (in particular the _trigger_release_event). # I actually don't know at all what kind of bugs that might produce, # lets just avoid it. It might cause it to be held down forever. a = keyboard_layout.get("a") b = keyboard_layout.get("b") c = keyboard_layout.get("c") macro = Parser.parse( "key(a).modify(b, hold()).key(c)", self.context, DummyMapping ) asyncio.ensure_future(macro.run(self.handler)) self.assertFalse(macro.tasks[1].child_macros[0].tasks[0].is_holding()) asyncio.ensure_future(macro.run(self.handler)) # ignored self.assertFalse(macro.tasks[1].child_macros[0].tasks[0].is_holding()) macro.press_trigger() await asyncio.sleep(0.2) self.assertTrue(macro.tasks[1].child_macros[0].tasks[0].is_holding()) asyncio.ensure_future(macro.run(self.handler)) # ignored self.assertTrue(macro.tasks[1].child_macros[0].tasks[0].is_holding()) macro.release_trigger() await asyncio.sleep(0.2) self.assertFalse(macro.tasks[1].child_macros[0].tasks[0].is_holding()) expected = [ (EV_KEY, a, 1), (EV_KEY, a, 0), (EV_KEY, b, 1), (EV_KEY, b, 0), (EV_KEY, c, 1), (EV_KEY, c, 0), ] self.assertListEqual(self.result, expected) """not ignored, since previous run is over""" asyncio.ensure_future(macro.run(self.handler)) macro.press_trigger() await asyncio.sleep(0.2) self.assertTrue(macro.tasks[1].child_macros[0].tasks[0].is_holding()) macro.release_trigger() await asyncio.sleep(0.2) self.assertFalse(macro.tasks[1].child_macros[0].tasks[0].is_holding()) expected = [ (EV_KEY, a, 1), (EV_KEY, a, 0), (EV_KEY, b, 1), (EV_KEY, b, 0), (EV_KEY, c, 1), (EV_KEY, c, 0), ] * 2 self.assertListEqual(self.result, expected) if __name__ == "__main__": unittest.main() input-remapper-2.1.1/tests/unit/test_macros/test_mod_tap.py000066400000000000000000000343471475433465200242130ustar00rootroot00000000000000import asyncio import time import unittest from unittest.mock import patch import evdev from evdev.ecodes import KEY_A, EV_KEY, KEY_B, KEY_LEFTSHIFT, KEY_C from inputremapper.configs.input_config import InputConfig from inputremapper.configs.mapping import Mapping from inputremapper.configs.preset import Preset from inputremapper.injection.context import Context from inputremapper.injection.event_reader import EventReader from inputremapper.injection.global_uinputs import GlobalUInputs, UInput from inputremapper.injection.macros.parse import Parser from inputremapper.injection.mapping_handlers.mapping_parser import MappingParser from inputremapper.input_event import InputEvent from tests.lib.fixtures import fixtures from tests.lib.patches import InputDevice from tests.lib.pipes import uinput_write_history from tests.lib.test_setup import test_setup from tests.unit.test_macros.macro_test_base import MacroTestBase, DummyMapping @test_setup class TestModTapIntegration(unittest.IsolatedAsyncioTestCase): # Testcases are from https://github.com/qmk/qmk_firmware/blob/78a0adfbb4d2c4e12f93f2a62ded0020d406243e/docs/tap_hold.md#nested-tap-abba-nested-tap # This test-setup is a bit more involved, because I needed to modify the # EventReader as well for this to work. def setUp(self): self.origin_hash = fixtures.bar_device.get_device_hash() self.forward_uinput = evdev.UInput(name="test-forward-uinput") self.source_device = InputDevice(fixtures.bar_device.path) self.stop_event = asyncio.Event() self.global_uinputs = GlobalUInputs(UInput) self.global_uinputs.prepare_all() self.target_uinput = self.global_uinputs.get_uinput("keyboard") self.mapping_parser = MappingParser(self.global_uinputs) self.mapping = Mapping.from_combination( input_combination=[ InputConfig( type=EV_KEY, code=KEY_A, origin_hash=self.origin_hash, ) ], output_symbol="mod_tap(a, Shift_L)", ) self.preset = Preset() self.preset.add(self.mapping) self.bootstrap_event_reader() self.loop = asyncio.new_event_loop() asyncio.set_event_loop(self.loop) def bootstrap_event_reader(self): self.context = Context( self.preset, source_devices={self.origin_hash: self.source_device}, forward_devices={self.origin_hash: self.forward_uinput}, mapping_parser=self.mapping_parser, ) self.event_reader = EventReader( self.context, self.source_device, self.stop_event, ) def write(self, type_, code, value): self.target_uinput.write_event(InputEvent.from_tuple((type_, code, value))) async def input(self, type_, code, value): asyncio.ensure_future( self.event_reader.handle( InputEvent.from_tuple( ( type_, code, value, ), origin_hash=self.origin_hash, ) ) ) # Make the main_loop iterate a bit for the event_reader to do its thing. await asyncio.sleep(0) async def test_distinct_taps_1(self): await self.input(EV_KEY, KEY_A, 1) await asyncio.sleep(0.190) await self.input(EV_KEY, KEY_A, 0) await asyncio.sleep(0.020) # exceeds tapping_term here await self.input(EV_KEY, KEY_B, 1) await asyncio.sleep(0.020) await self.input(EV_KEY, KEY_B, 0) self.assertEqual( uinput_write_history, [ InputEvent.from_tuple((EV_KEY, KEY_A, 1)), InputEvent.from_tuple((EV_KEY, KEY_A, 0)), InputEvent.from_tuple((EV_KEY, KEY_B, 1)), InputEvent.from_tuple((EV_KEY, KEY_B, 0)), ], ) self.assertEqual( self.target_uinput.write_history, [ InputEvent.from_tuple((EV_KEY, KEY_A, 1)), InputEvent.from_tuple((EV_KEY, KEY_A, 0)), ], ) self.assertEqual( self.forward_uinput.write_history, [ InputEvent.from_tuple((EV_KEY, KEY_B, 1)), InputEvent.from_tuple((EV_KEY, KEY_B, 0)), ], ) async def test_distinct_taps_2(self): await self.input(EV_KEY, KEY_A, 1) await asyncio.sleep(0.220) # exceeds tapping_term here await self.input(EV_KEY, KEY_A, 0) await asyncio.sleep(0.020) await self.input(EV_KEY, KEY_B, 1) await asyncio.sleep(0.020) await self.input(EV_KEY, KEY_B, 0) self.assertEqual( uinput_write_history, [ InputEvent.from_tuple((EV_KEY, KEY_LEFTSHIFT, 1)), InputEvent.from_tuple((EV_KEY, KEY_LEFTSHIFT, 0)), InputEvent.from_tuple((EV_KEY, KEY_B, 1)), InputEvent.from_tuple((EV_KEY, KEY_B, 0)), ], ) self.assertEqual( self.target_uinput.write_history, [ InputEvent.from_tuple((EV_KEY, KEY_LEFTSHIFT, 1)), InputEvent.from_tuple((EV_KEY, KEY_LEFTSHIFT, 0)), ], ) self.assertEqual( self.forward_uinput.write_history, [ InputEvent.from_tuple((EV_KEY, KEY_B, 1)), InputEvent.from_tuple((EV_KEY, KEY_B, 0)), ], ) async def test_nested_tap_1(self): await self.input(EV_KEY, KEY_A, 1) await asyncio.sleep(0.100) self.assertEqual(uinput_write_history, []) await self.input(EV_KEY, KEY_B, 1) await asyncio.sleep(0.020) self.assertEqual(uinput_write_history, []) await self.input(EV_KEY, KEY_B, 0) await asyncio.sleep(0.050) self.assertEqual(uinput_write_history, []) await self.input(EV_KEY, KEY_A, 0) # everything happened within the tapping_term, so the modifier is not activated. # "ab" should be written, in the exact order of the input. await asyncio.sleep(0.040) self.assertEqual( uinput_write_history, [ InputEvent.from_tuple((EV_KEY, KEY_A, 1)), InputEvent.from_tuple((EV_KEY, KEY_B, 1)), InputEvent.from_tuple((EV_KEY, KEY_B, 0)), InputEvent.from_tuple((EV_KEY, KEY_A, 0)), ], ) async def test_nested_tap_2(self): await self.input(EV_KEY, KEY_A, 1) await asyncio.sleep(0.100) await self.input(EV_KEY, KEY_B, 1) await asyncio.sleep(0.020) await self.input(EV_KEY, KEY_B, 0) await asyncio.sleep(0.100) # exceeds tapping_term here await self.input(EV_KEY, KEY_A, 0) await asyncio.sleep(0.020) self.assertEqual( uinput_write_history, [ InputEvent.from_tuple((EV_KEY, KEY_LEFTSHIFT, 1)), InputEvent.from_tuple((EV_KEY, KEY_B, 1)), InputEvent.from_tuple((EV_KEY, KEY_B, 0)), InputEvent.from_tuple((EV_KEY, KEY_LEFTSHIFT, 0)), ], ) async def test_nested_tap_3(self): await self.input(EV_KEY, KEY_A, 1) await asyncio.sleep(0.220) # exceeds tapping_term here await self.input(EV_KEY, KEY_B, 1) await asyncio.sleep(0.020) await self.input(EV_KEY, KEY_B, 0) await asyncio.sleep(0.020) await self.input(EV_KEY, KEY_A, 0) await asyncio.sleep(0.020) self.assertEqual( uinput_write_history, [ InputEvent.from_tuple((EV_KEY, KEY_LEFTSHIFT, 1)), InputEvent.from_tuple((EV_KEY, KEY_B, 1)), InputEvent.from_tuple((EV_KEY, KEY_B, 0)), InputEvent.from_tuple((EV_KEY, KEY_LEFTSHIFT, 0)), ], ) async def test_rolling_keys_1(self): await self.input(EV_KEY, KEY_A, 1) await asyncio.sleep(0.100) await self.input(EV_KEY, KEY_B, 1) await asyncio.sleep(0.020) await self.input(EV_KEY, KEY_A, 0) await asyncio.sleep(0.020) await self.input(EV_KEY, KEY_B, 0) # everything happened within the tapping_term, so the modifier is not activated. # "ab" should be written, in the exact order of the input. await asyncio.sleep(0.100) self.assertEqual( uinput_write_history, [ InputEvent.from_tuple((EV_KEY, KEY_A, 1)), InputEvent.from_tuple((EV_KEY, KEY_B, 1)), InputEvent.from_tuple((EV_KEY, KEY_A, 0)), InputEvent.from_tuple((EV_KEY, KEY_B, 0)), ], ) async def test_rolling_keys_2(self): await self.input(EV_KEY, KEY_A, 1) await asyncio.sleep(0.100) await self.input(EV_KEY, KEY_B, 1) await asyncio.sleep(0.100) # exceeds tapping_term here await self.input(EV_KEY, KEY_A, 0) await asyncio.sleep(0.020) await self.input(EV_KEY, KEY_B, 0) await asyncio.sleep(0.020) self.assertEqual( uinput_write_history, [ InputEvent.from_tuple((EV_KEY, KEY_LEFTSHIFT, 1)), InputEvent.from_tuple((EV_KEY, KEY_B, 1)), InputEvent.from_tuple((EV_KEY, KEY_LEFTSHIFT, 0)), InputEvent.from_tuple((EV_KEY, KEY_B, 0)), ], ) async def test_many_keys_correct_order_without_sleep(self): self.mapping.macro_key_sleep_ms = 0 await self.many_keys_correct_order() async def test_many_keys_correct_order_with_sleep(self): self.mapping.macro_key_sleep_ms = 20 await self.many_keys_correct_order() async def many_keys_correct_order(self): await self.input(EV_KEY, KEY_A, 1) # Send many events to the listener. It has to make all of them wait. for i in range(30): await self.input(EV_KEY, i, 1) # exceed tapping_term. mod_tap will inject the modifier and replay all the # previous events. await asyncio.sleep(0.201) # mod_tap is busy replaying events. While it does that, inject this await self.input(EV_KEY, 100, 1) start = time.time() timeout = 2 while len(uinput_write_history) < 32 and (time.time() - start) < timeout: # Wait for it to complete await asyncio.sleep(0.1) self.assertEqual(len(uinput_write_history), 32) # Expect it to cleanly handle all events before injecting 100. Expect # everything to be in the correct order. self.assertEqual( uinput_write_history, [ InputEvent.from_tuple((EV_KEY, KEY_LEFTSHIFT, 1)), *[InputEvent.from_tuple((EV_KEY, i, 1)) for i in range(30)], InputEvent.from_tuple((EV_KEY, 100, 1)), ], ) async def test_mapped_second_key(self): # Map b to c. # While mod_tap is waiting for the timeout to happen, press b. # We expect c to be written, because b goes through the handlers and # gets mapped. # The event_reader has to wait for listeners to complete for mod_tap to work, so # that it hands them over to the other handlers when the time comes. # That means however, that the event_readers loop blocks. Therefore, it was turned # into a fire-and-forget kind of thing. When an event arrives, it just schedules # asyncio to do that stuff later, and continues reading. self.preset.add( Mapping.from_combination( input_combination=[ InputConfig( type=EV_KEY, code=KEY_B, origin_hash=self.origin_hash, ) ], output_symbol="c", ), ) self.bootstrap_event_reader() async def async_generator(): events = [ InputEvent(0, 0, EV_KEY, KEY_A, 1), InputEvent(0, 0, EV_KEY, KEY_B, 1), InputEvent(0, 0, EV_KEY, KEY_A, 0), InputEvent(0, 0, EV_KEY, KEY_B, 0), ] for event in events: yield event # Wait a bit. During runtime, events don't come in that quickly # and the mod_tap macro needs some loop iterations until it adds # the listener to the context. await asyncio.sleep(0.010) with patch.object(self.event_reader, "read_loop", async_generator): await self.event_reader.run() await asyncio.sleep(0.020) self.assertIn(InputEvent(0, 0, EV_KEY, KEY_C, 1), uinput_write_history) self.assertIn(InputEvent(0, 0, EV_KEY, KEY_C, 0), uinput_write_history) self.assertIn(InputEvent(0, 0, EV_KEY, KEY_A, 1), uinput_write_history) self.assertIn(InputEvent(0, 0, EV_KEY, KEY_A, 0), uinput_write_history) self.assertNotIn(InputEvent(0, 0, EV_KEY, KEY_B, 1), uinput_write_history) self.assertNotIn(InputEvent(0, 0, EV_KEY, KEY_B, 0), uinput_write_history) @test_setup class TestModTapUnit(MacroTestBase): async def wait_for_timeout(self, macro): macro = Parser.parse(macro, self.context, DummyMapping, True) start = time.time() # Awaiting macro.run will cause it to wait for the tapping_term. # When it injects the modifier, release the trigger. macro.press_trigger() await macro.run(lambda *_, **__: macro.release_trigger()) return time.time() - start async def test_tapping_term_configuration_default(self): time_ = await self.wait_for_timeout("mod_tap(a, b)") # + 2 times 10ms of keycode_pause self.assertAlmostEqual(time_, 0.22, delta=0.02) async def test_tapping_term_configuration_100(self): time_ = await self.wait_for_timeout("mod_tap(a, b, 100)") self.assertAlmostEqual(time_, 0.12, delta=0.02) async def test_tapping_term_configuration_100_kwarg(self): time_ = await self.wait_for_timeout("mod_tap(a, b, tapping_term=100)") self.assertAlmostEqual(time_, 0.12, delta=0.02) input-remapper-2.1.1/tests/unit/test_macros/test_modify.py000066400000000000000000000040561475433465200240510ustar00rootroot00000000000000#!/usr/bin/env python3 # -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings # Copyright (C) 2025 sezanzeb # # This file is part of input-remapper. # # input-remapper is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # input-remapper is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with input-remapper. If not, see . import unittest from evdev.ecodes import EV_KEY from inputremapper.configs.keyboard_layout import keyboard_layout from inputremapper.configs.validation_errors import MacroError from inputremapper.injection.macros.parse import Parser from tests.lib.test_setup import test_setup from tests.unit.test_macros.macro_test_base import MacroTestBase, DummyMapping @test_setup class TestModify(MacroTestBase): async def test_modify(self): code_a = keyboard_layout.get("a") code_b = keyboard_layout.get("b") code_c = keyboard_layout.get("c") macro = Parser.parse( "set(foo, b).modify($foo, modify(a, key(c)))", self.context, DummyMapping, ) await macro.run(self.handler) self.assertListEqual( self.result, [ (EV_KEY, code_b, 1), (EV_KEY, code_a, 1), (EV_KEY, code_c, 1), (EV_KEY, code_c, 0), (EV_KEY, code_a, 0), (EV_KEY, code_b, 0), ], ) async def test_raises_error(self): self.assertRaises(MacroError, Parser.parse, "modify(asdf, k(a))", self.context) if __name__ == "__main__": unittest.main() input-remapper-2.1.1/tests/unit/test_macros/test_mouse.py000066400000000000000000000201471475433465200237110ustar00rootroot00000000000000#!/usr/bin/env python3 # -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings # Copyright (C) 2025 sezanzeb # # This file is part of input-remapper. # # input-remapper is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # input-remapper is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with input-remapper. If not, see . import asyncio import unittest from evdev._ecodes import ( REL_Y, EV_REL, REL_HWHEEL, REL_HWHEEL_HI_RES, REL_X, REL_WHEEL, REL_WHEEL_HI_RES, KEY_A, EV_KEY, ) from inputremapper.configs.validation_errors import MacroError from inputremapper.injection.macros.parse import Parser from tests.lib.test_setup import test_setup from tests.unit.test_macros.macro_test_base import DummyMapping, MacroTestBase @test_setup class TestMouse(MacroTestBase): async def test_mouse_acceleration(self): # There is a tiny float-rounding error that can break the test, therefore I use # 0.09001 to make it more robust. await self._run_macro("mouse(up, 10, 0.09001)", 0.1) self.assertEqual( [ (EV_REL, REL_Y, -2), (EV_REL, REL_Y, -3), (EV_REL, REL_Y, -4), (EV_REL, REL_Y, -4), (EV_REL, REL_Y, -5), ], self.result, ) async def test_rate(self): # It should move 200 times per second by 1px, for 0.2 seconds. rel_rate = 200 time = 0.2 speed = 1 expected_movement = time * rel_rate * speed await self._run_macro(f"mouse(down, {speed})", time, rel_rate) total_movement = sum(event[2] for event in self.result) self.assertAlmostEqual(float(total_movement), expected_movement, delta=1) async def test_slow_movement(self): await self._run_macro(f"mouse(down, 0.1)", 0.2, 200) total_movement = sum(event[2] for event in self.result) self.assertAlmostEqual(total_movement, 4, delta=1) async def test_mouse_xy_acceleration_1(self): await self._run_macro("mouse_xy(2, -10, 0.09001)", 0.1) self.assertEqual( [ (EV_REL, REL_Y, -2), (EV_REL, REL_Y, -3), (EV_REL, REL_Y, -4), (EV_REL, REL_Y, -4), (EV_REL, REL_Y, -5), ], self._get_y_movement(), ) self.assertEqual( [ (EV_REL, REL_X, 1), (EV_REL, REL_X, 1), (EV_REL, REL_X, 1), ], self._get_x_movement(), ) async def test_mouse_xy_acceleration_2(self): await self._run_macro("mouse_xy(10, -2, 0.09001)", 0.1) self.assertEqual( [ (EV_REL, REL_Y, -1), (EV_REL, REL_Y, -1), (EV_REL, REL_Y, -1), ], self._get_y_movement(), ) self.assertEqual( [ (EV_REL, REL_X, 2), (EV_REL, REL_X, 3), (EV_REL, REL_X, 4), (EV_REL, REL_X, 4), (EV_REL, REL_X, 5), ], self._get_x_movement(), ) async def test_mouse_xy_only_x(self): await self._run_macro("mouse_xy(x=10, acceleration=1)", 0.1) self.assertEqual([], self._get_y_movement()) self.assertEqual( [ (EV_REL, REL_X, 10), (EV_REL, REL_X, 10), (EV_REL, REL_X, 10), (EV_REL, REL_X, 10), (EV_REL, REL_X, 10), (EV_REL, REL_X, 10), ], self._get_x_movement(), ) async def test_mouse_xy_only_y(self): await self._run_macro("mouse_xy(y=10)", 0.1) self.assertEqual([], self._get_x_movement()) self.assertEqual( [ (EV_REL, REL_Y, 10), (EV_REL, REL_Y, 10), (EV_REL, REL_Y, 10), (EV_REL, REL_Y, 10), (EV_REL, REL_Y, 10), (EV_REL, REL_Y, 10), ], self._get_y_movement(), ) async def test_wheel_left(self): wheel_speed = 60 sleep = 0.1 await self._run_macro(f"wheel(left, {wheel_speed})", sleep) self.assertIn((EV_REL, REL_HWHEEL, 1), self.result) self.assertIn((EV_REL, REL_HWHEEL_HI_RES, 60), self.result) expected_num_hires_events = sleep * DummyMapping.rel_rate expected_num_wheel_events = int(expected_num_hires_events / 120 * wheel_speed) actual_num_wheel_events = self.result.count((EV_REL, REL_HWHEEL, 1)) actual_num_hires_events = self.result.count( ( EV_REL, REL_HWHEEL_HI_RES, wheel_speed, ) ) self.assertGreater( actual_num_wheel_events, expected_num_wheel_events * 0.9, ) self.assertLess( actual_num_wheel_events, expected_num_wheel_events * 1.1, ) self.assertGreater( actual_num_hires_events, expected_num_hires_events * 0.9, ) self.assertLess( actual_num_hires_events, expected_num_hires_events * 1.1, ) async def test_wheel_up(self): await self._run_macro(f"wheel(up, 60)", 0.1) self.assertIn((EV_REL, REL_WHEEL, 1), self.result) self.assertIn((EV_REL, REL_WHEEL_HI_RES, 60), self.result) async def test_wheel_down(self): await self._run_macro(f"wheel(down, 60)", 0.1) self.assertIn((EV_REL, REL_WHEEL, -1), self.result) self.assertIn((EV_REL, REL_WHEEL_HI_RES, -60), self.result) async def test_wheel_right(self): await self._run_macro(f"wheel(right, 60)", 0.1) self.assertIn((EV_REL, REL_HWHEEL, -1), self.result) self.assertIn((EV_REL, REL_HWHEEL_HI_RES, -60), self.result) async def test_mouse_releases(self): await self._run_macro(f"mouse(down, 1).key(a)", 0.1) self.assertEqual(self.result[-2:], [(EV_KEY, KEY_A, 1), (EV_KEY, KEY_A, 0)]) async def test_mouse_xy_releases(self): await self._run_macro(f"mouse_xy(1, 1, 1).key(a)", 0.1) self.assertEqual(self.result[-2:], [(EV_KEY, KEY_A, 1), (EV_KEY, KEY_A, 0)]) async def test_wheel_releases(self): await self._run_macro(f"wheel(down, 1).key(a)", 0.1) self.assertEqual(self.result[-2:], [(EV_KEY, KEY_A, 1), (EV_KEY, KEY_A, 0)]) async def test_raises_error(self): Parser.parse("mouse(up, 3)", self.context) # no error Parser.parse("mouse(up, speed=$a)", self.context) # no error self.assertRaises(MacroError, Parser.parse, "mouse(3, up)", self.context) Parser.parse("wheel(left, 3)", self.context) # no error self.assertRaises(MacroError, Parser.parse, "wheel(3, left)", self.context) def _get_x_movement(self): return [event for event in self.result if event[1] == REL_X] def _get_y_movement(self): return [event for event in self.result if event[1] == REL_Y] async def _run_macro( self, code: str, time: float, rel_rate: int = DummyMapping.rel_rate, ): dummy_mapping = DummyMapping() dummy_mapping.rel_rate = rel_rate macro = Parser.parse( code, self.context, dummy_mapping, ) macro.press_trigger() asyncio.ensure_future(macro.run(self.handler)) await asyncio.sleep(time) self.assertTrue(macro.tasks[0].is_holding()) macro.release_trigger() await asyncio.sleep(0.05) if __name__ == "__main__": unittest.main() input-remapper-2.1.1/tests/unit/test_macros/test_parallel.py000066400000000000000000000074601475433465200243600ustar00rootroot00000000000000#!/usr/bin/env python3 # -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings # Copyright (C) 2025 sezanzeb # # This file is part of input-remapper. # # input-remapper is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # input-remapper is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with input-remapper. If not, see . import asyncio import unittest from evdev.ecodes import ( EV_KEY, KEY_A, KEY_B, KEY_C, KEY_D, ) from inputremapper.injection.macros.parse import Parser from tests.lib.test_setup import test_setup from tests.unit.test_macros.macro_test_base import DummyMapping, MacroTestBase @test_setup class TestParallel(MacroTestBase): async def test_1_child_macro(self): macro = Parser.parse( "parallel(key(a))", self.context, DummyMapping(), True, ) self.assertEqual(len(macro.tasks[0].child_macros), 1) await macro.run(self.handler) self.assertEqual(self.result, [(EV_KEY, KEY_A, 1), (EV_KEY, KEY_A, 0)]) async def test_4_child_macros(self): macro = Parser.parse( "parallel(key(a), key(b), key(c), key(d))", self.context, DummyMapping(), True, ) self.assertEqual(len(macro.tasks[0].child_macros), 4) await macro.run(self.handler) self.assertIn((EV_KEY, KEY_A, 0), self.result) self.assertIn((EV_KEY, KEY_B, 0), self.result) self.assertIn((EV_KEY, KEY_C, 0), self.result) self.assertIn((EV_KEY, KEY_D, 0), self.result) async def test_one_wait_takes_longer(self): mapping = DummyMapping() mapping.macro_key_sleep_ms = 0 macro = Parser.parse( "parallel(wait(100), wait(10).key(b)).key(c)", self.context, mapping, True, ) asyncio.ensure_future(macro.run(self.handler)) await asyncio.sleep(0.06) # The wait(10).key(b) macro is already done, but KEY_C is not yet injected self.assertEqual(len(self.result), 2) self.assertIn((EV_KEY, KEY_B, 1), self.result) self.assertIn((EV_KEY, KEY_B, 0), self.result) # Both need to complete for it to continue to key(c) await asyncio.sleep(0.06) self.assertEqual(len(self.result), 4) self.assertIn((EV_KEY, KEY_C, 1), self.result) self.assertIn((EV_KEY, KEY_C, 0), self.result) async def test_parallel_hold(self): mapping = DummyMapping() mapping.macro_key_sleep_ms = 0 macro = Parser.parse( "parallel(hold_keys(a), hold_keys(b)).key(c)", self.context, mapping, True, ) macro.press_trigger() asyncio.ensure_future(macro.run(self.handler)) await asyncio.sleep(0.05) self.assertIn((EV_KEY, KEY_A, 1), self.result) self.assertIn((EV_KEY, KEY_B, 1), self.result) self.assertEqual(len(self.result), 2) macro.release_trigger() await asyncio.sleep(0.05) self.assertIn((EV_KEY, KEY_A, 0), self.result) self.assertIn((EV_KEY, KEY_B, 0), self.result) self.assertIn((EV_KEY, KEY_C, 1), self.result) self.assertIn((EV_KEY, KEY_C, 0), self.result) self.assertEqual(len(self.result), 6) if __name__ == "__main__": unittest.main() input-remapper-2.1.1/tests/unit/test_macros/test_parsing.py000066400000000000000000000307161475433465200242270ustar00rootroot00000000000000#!/usr/bin/env python3 # -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings # Copyright (C) 2025 sezanzeb # # This file is part of input-remapper. # # input-remapper is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # input-remapper is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with input-remapper. If not, see . import re import unittest from evdev.ecodes import EV_KEY, KEY_A, KEY_B, KEY_C, KEY_E from inputremapper.configs.validation_errors import ( MacroError, ) from inputremapper.injection.macros.argument import Argument, ArgumentConfig from inputremapper.injection.macros.parse import Parser from inputremapper.injection.macros.raw_value import RawValue from inputremapper.injection.macros.tasks.hold_keys import HoldKeysTask from inputremapper.injection.macros.tasks.if_tap import IfTapTask from inputremapper.injection.macros.tasks.key import KeyTask from inputremapper.injection.macros.variable import Variable from tests.lib.logger import logger from tests.lib.test_setup import test_setup from tests.unit.test_macros.macro_test_base import DummyMapping, MacroTestBase @test_setup class TestParsing(MacroTestBase): def test_get_macro_argument_names(self): self.assertEqual( IfTapTask.get_macro_argument_names(), ["then", "else", "timeout"], ) self.assertEqual( HoldKeysTask.get_macro_argument_names(), ["*symbols"], ) def test_get_num_parameters(self): self.assertEqual(IfTapTask.get_num_parameters(), (0, 3)) self.assertEqual(KeyTask.get_num_parameters(), (1, 1)) self.assertEqual(HoldKeysTask.get_num_parameters(), (0, float("inf"))) def test_remove_whitespaces(self): self.assertEqual(Parser.remove_whitespaces('foo"bar"foo'), 'foo"bar"foo') self.assertEqual(Parser.remove_whitespaces('foo" bar"foo'), 'foo" bar"foo') self.assertEqual( Parser.remove_whitespaces('foo" bar"fo" "o'), 'foo" bar"fo" "o' ) self.assertEqual( Parser.remove_whitespaces(' fo o"\nba r "f\noo'), 'foo"\nba r "foo' ) self.assertEqual(Parser.remove_whitespaces(' a " b " c " '), 'a" b "c" ') self.assertEqual(Parser.remove_whitespaces('"""""""""'), '"""""""""') self.assertEqual(Parser.remove_whitespaces('""""""""'), '""""""""') self.assertEqual(Parser.remove_whitespaces(" "), "") self.assertEqual(Parser.remove_whitespaces(' " '), '" ') self.assertEqual(Parser.remove_whitespaces(' " " '), '" "') self.assertEqual(Parser.remove_whitespaces("a# ##b", delimiter="##"), "a###b") self.assertEqual(Parser.remove_whitespaces("a###b", delimiter="##"), "a###b") self.assertEqual(Parser.remove_whitespaces("a## #b", delimiter="##"), "a## #b") self.assertEqual( Parser.remove_whitespaces("a## ##b", delimiter="##"), "a## ##b" ) def test_remove_comments(self): self.assertEqual(Parser.remove_comments("a#b"), "a") self.assertEqual(Parser.remove_comments('"a#b"'), '"a#b"') self.assertEqual(Parser.remove_comments('a"#"#b'), 'a"#"') self.assertEqual(Parser.remove_comments('a"#""#"#b'), 'a"#""#"') self.assertEqual(Parser.remove_comments('#a"#""#"#b'), "") self.assertEqual( re.sub( r"\s", "", Parser.remove_comments( """ # a b # c d """ ), ), "bd", ) async def test_count_brackets(self): self.assertEqual(Parser._count_brackets(""), 0) self.assertEqual(Parser._count_brackets("()"), 2) self.assertEqual(Parser._count_brackets("a()"), 3) self.assertEqual(Parser._count_brackets("a(b)"), 4) self.assertEqual(Parser._count_brackets("a(b())"), 6) self.assertEqual(Parser._count_brackets("a(b(c))"), 7) self.assertEqual(Parser._count_brackets("a(b(c))d"), 7) self.assertEqual(Parser._count_brackets("a(b(c))d()"), 7) def test_split_keyword_arg(self): self.assertTupleEqual(Parser._split_keyword_arg("_A=b"), ("_A", "b")) self.assertTupleEqual(Parser._split_keyword_arg("a_=1"), ("a_", "1")) self.assertTupleEqual( Parser._split_keyword_arg("a=repeat(2, KEY_A)"), ("a", "repeat(2, KEY_A)"), ) self.assertTupleEqual(Parser._split_keyword_arg('a="=,#+."'), ("a", '"=,#+."')) def test_is_this_a_macro(self): self.assertTrue(Parser.is_this_a_macro("key(1)")) self.assertTrue(Parser.is_this_a_macro("key(1).key(2)")) self.assertTrue(Parser.is_this_a_macro("repeat(1, key(1).key(2))")) self.assertFalse(Parser.is_this_a_macro("1")) self.assertFalse(Parser.is_this_a_macro("key_kp1")) self.assertFalse(Parser.is_this_a_macro("btn_left")) self.assertFalse(Parser.is_this_a_macro("minus")) self.assertFalse(Parser.is_this_a_macro("k")) self.assertFalse(Parser.is_this_a_macro(1)) self.assertFalse(Parser.is_this_a_macro(None)) self.assertTrue(Parser.is_this_a_macro("a+b")) self.assertTrue(Parser.is_this_a_macro("a+b+c")) self.assertTrue(Parser.is_this_a_macro("a + b")) self.assertTrue(Parser.is_this_a_macro("a + b + c")) def test_handle_plus_syntax(self): self.assertEqual(Parser.handle_plus_syntax("a + b"), "hold_keys(a,b)") self.assertEqual(Parser.handle_plus_syntax("a + b + c"), "hold_keys(a,b,c)") self.assertEqual(Parser.handle_plus_syntax(" a+b+c "), "hold_keys(a,b,c)") # invalid. The last one with `key` should not have been a parameter # of this function to begin with. strings = ["+", "a+", "+b", "a\n+\n+\nb", "key(a + b)"] for string in strings: with self.assertRaises(MacroError): logger.info(f'testing "%s"', string) Parser.handle_plus_syntax(string) self.assertEqual(Parser.handle_plus_syntax("a"), "a") self.assertEqual(Parser.handle_plus_syntax("key(a)"), "key(a)") self.assertEqual(Parser.handle_plus_syntax(""), "") def test_parse_plus_syntax(self): macro = Parser.parse("a + b") self.assertEqual(macro.code, "hold_keys(a,b)") # this is not erroneously recognized as "plus" syntax macro = Parser.parse("key(a) # a + b") self.assertEqual(macro.code, "key(a)") async def test_extract_params(self): # splits strings, doesn't try to understand their meaning yet def expect(raw, expectation): self.assertListEqual(Parser._extract_args(raw), expectation) expect("a", ["a"]) expect("a,b", ["a", "b"]) expect("a,b,c", ["a", "b", "c"]) expect("key(a)", ["key(a)"]) expect("key(a).key(b), key(a)", ["key(a).key(b)", "key(a)"]) expect("key(a), key(a).key(b)", ["key(a)", "key(a).key(b)"]) expect( 'a("foo(1,2,3)", ",,,,,, "), , ""', ['a("foo(1,2,3)", ",,,,,, ")', "", '""'], ) expect( ",1, ,b,x(,a(),).y().z(),,", ["", "1", "", "b", "x(,a(),).y().z()", "", ""], ) expect("repeat(1, key(a))", ["repeat(1, key(a))"]) expect( "repeat(1, key(a)), repeat(1, key(b))", ["repeat(1, key(a))", "repeat(1, key(b))"], ) expect( "repeat(1, key(a)), repeat(1, key(b)), repeat(1, key(c))", ["repeat(1, key(a))", "repeat(1, key(b))", "repeat(1, key(c))"], ) # will be parsed as None expect("", [""]) expect(",", ["", ""]) expect(",,", ["", "", ""]) async def test_parse_params(self): def test(value, types): argument = Argument( ArgumentConfig(position=0, name="test", types=types), DummyMapping, ) argument.initialize_variable(RawValue(value=value)) return argument._variable self.assertEqual( test("", [None, int, float]), Variable(None, const=True), ) # strings. If it is wrapped in quotes, don't parse the contents self.assertEqual( test('"foo"', [str]), Variable("foo", const=True), ) self.assertEqual( test('"\tf o o\n"', [str]), Variable("\tf o o\n", const=True), ) self.assertEqual( test('"foo(a,b)"', [str]), Variable("foo(a,b)", const=True), ) self.assertEqual( test('",,,()"', [str]), Variable(",,,()", const=True), ) # strings without quotes only work as long as there is no function call or # anything. This is only really acceptable for constants like KEY_A and for # variable names, which are not allowed to contain special characters that may # have a meaning in the macro syntax. self.assertEqual( test("foo", [str]), Variable("foo", const=True), ) self.assertEqual( test("", [str, None]), Variable(None, const=True), ) self.assertEqual( test("", [str]), Variable("", const=True), ) self.assertEqual( test("", [None]), Variable(None, const=True), ) self.assertEqual( test("None", [None]), Variable(None, const=True), ) self.assertEqual( test('"None"', [str]), Variable("None", const=True), ) self.assertEqual( test("5", [int]), Variable(5, const=True), ) self.assertEqual( test("5", [float, int]), Variable(5, const=True), ) self.assertEqual( test("5.2", [int, float]), Variable(5.2, const=True), ) self.assertIsInstance( test("$foo", [str]), Variable, ) self.assertEqual( test("$foo", [str]), Variable("foo", const=False), ) async def test_string_not_a_macro(self): # passing a string parameter. This is not a macro, even though # it might look like it without the string quotes. Everything with # explicit quotes around it has to be treated as a string. self.assertRaises(MacroError, Parser.parse, '"modify(a, b)"', self.context) async def test_multiline_macro_and_comments(self): # the parser is not confused by the code in the comments and can use hashtags # in strings in the actual code comment = '# repeat(1,key(KEY_D)).set(a,"#b")' macro = Parser.parse( f""" {comment} key(KEY_A).{comment} key(KEY_B). {comment} repeat({comment} 1, {comment} key(KEY_C){comment} ). {comment} {comment} set(a, "#").{comment} if_eq($a, "#", key(KEY_E), key(KEY_F)) {comment} {comment} """, self.context, DummyMapping, ) await macro.run(self.handler) self.assertListEqual( self.result, [ (EV_KEY, KEY_A, 1), (EV_KEY, KEY_A, 0), (EV_KEY, KEY_B, 1), (EV_KEY, KEY_B, 0), (EV_KEY, KEY_C, 1), (EV_KEY, KEY_C, 0), (EV_KEY, KEY_E, 1), (EV_KEY, KEY_E, 0), ], ) async def test_child_macro_count(self): # It correctly keeps track of child-macros for both positional and keyword-args macro = Parser.parse( "hold(macro=hold(hold())).repeat(1, macro=repeat(1, hold()))", self.context, DummyMapping, True, ) self.assertEqual(self.count_child_macros(macro), 4) self.assertEqual(self.count_tasks(macro), 6) if __name__ == "__main__": unittest.main() input-remapper-2.1.1/tests/unit/test_macros/test_repeat.py000066400000000000000000000123421475433465200240370ustar00rootroot00000000000000#!/usr/bin/env python3 # -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings # Copyright (C) 2025 sezanzeb # # This file is part of input-remapper. # # input-remapper is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # input-remapper is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with input-remapper. If not, see . import time import unittest from evdev.ecodes import EV_KEY from inputremapper.configs.keyboard_layout import keyboard_layout from inputremapper.configs.validation_errors import MacroError from inputremapper.injection.macros.parse import Parser from tests.lib.test_setup import test_setup from tests.unit.test_macros.macro_test_base import MacroTestBase, DummyMapping @test_setup class TestRepeat(MacroTestBase): async def test_1(self): start = time.time() repeats = 20 macro = Parser.parse( f"repeat({repeats}, key(k)).repeat(1, key(k))", self.context, DummyMapping, ) k_code = keyboard_layout.get("k") await macro.run(self.handler) keystroke_sleep = DummyMapping.macro_key_sleep_ms sleep_time = 2 * repeats * keystroke_sleep / 1000 self.assertGreater(time.time() - start, sleep_time * 0.9) self.assertLess(time.time() - start, sleep_time * 1.3) self.assertListEqual( self.result, [(EV_KEY, k_code, 1), (EV_KEY, k_code, 0)] * (repeats + 1), ) self.assertEqual(self.count_child_macros(macro), 2) self.assertEqual(len(macro.tasks[0].child_macros), 1) self.assertEqual(len(macro.tasks[0].child_macros[0].tasks), 1) self.assertEqual(len(macro.tasks[0].child_macros[0].tasks[0].child_macros), 0) self.assertEqual(len(macro.tasks[1].child_macros), 1) self.assertEqual(len(macro.tasks[1].child_macros[0].tasks), 1) self.assertEqual(len(macro.tasks[1].child_macros[0].tasks[0].child_macros), 0) async def test_2(self): start = time.time() macro = Parser.parse("repeat(3, key(m).w(100))", self.context, DummyMapping) m_code = keyboard_layout.get("m") await macro.run(self.handler) keystroke_time = 6 * DummyMapping.macro_key_sleep_ms total_time = keystroke_time + 300 total_time /= 1000 self.assertGreater(time.time() - start, total_time * 0.9) self.assertLess(time.time() - start, total_time * 1.2) self.assertListEqual( self.result, [ (EV_KEY, m_code, 1), (EV_KEY, m_code, 0), (EV_KEY, m_code, 1), (EV_KEY, m_code, 0), (EV_KEY, m_code, 1), (EV_KEY, m_code, 0), ], ) self.assertEqual(self.count_child_macros(macro), 1) self.assertEqual(len(macro.tasks), 1) self.assertEqual(len(macro.tasks[0].child_macros), 1) self.assertEqual(len(macro.tasks[0].child_macros[0].tasks), 2) self.assertEqual(len(macro.tasks[0].child_macros[0].tasks[0].child_macros), 0) self.assertEqual(len(macro.tasks[0].child_macros[0].tasks[1].child_macros), 0) async def test_not_an_int(self): # the first parameter for `repeat` requires an integer, not "foo", # which makes `repeat` throw macro = Parser.parse( 'set(a, "foo").repeat($a, key(KEY_A)).key(KEY_B)', self.context, DummyMapping, ) try: await macro.run(self.handler) except MacroError as e: self.assertIn("foo", str(e)) self.assertFalse(macro.running) # key(KEY_B) is not executed, the macro stops self.assertListEqual(self.result, []) async def test_raises_error(self): self.assertRaises(MacroError, Parser.parse, "r(1)", self.context) self.assertRaises(MacroError, Parser.parse, "repeat(a, k(1))", self.context) Parser.parse("repeat($a, k(1))", self.context) # no error Parser.parse("repeat(2, k(1))", self.context) # no error self.assertRaises(MacroError, Parser.parse, 'repeat("2", k(1))', self.context) self.assertRaises(MacroError, Parser.parse, "r(1, 1)", self.context) self.assertRaises(MacroError, Parser.parse, "r(k(1), 1)", self.context) Parser.parse("r(1, macro=k(1))", self.context) # no error self.assertRaises(MacroError, Parser.parse, "r(a=1, b=k(1))", self.context) self.assertRaises( MacroError, Parser.parse, "r(repeats=1, macro=k(1), a=2)", self.context, ) self.assertRaises( MacroError, Parser.parse, "r(repeats=1, macro=k(1), repeats=2)", self.context, ) if __name__ == "__main__": unittest.main() input-remapper-2.1.1/tests/unit/test_macros/test_set.py000066400000000000000000000070301475433465200233500ustar00rootroot00000000000000#!/usr/bin/env python3 # -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings # Copyright (C) 2025 sezanzeb # # This file is part of input-remapper. # # input-remapper is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # input-remapper is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with input-remapper. If not, see . import unittest from evdev.ecodes import EV_KEY from inputremapper.configs.keyboard_layout import keyboard_layout from inputremapper.configs.validation_errors import MacroError from inputremapper.injection.macros.macro import macro_variables from inputremapper.injection.macros.parse import Parser from tests.lib.test_setup import test_setup from tests.unit.test_macros.macro_test_base import MacroTestBase, DummyMapping @test_setup class TestSet(MacroTestBase): async def test_set_key(self): code_b = keyboard_layout.get("b") macro = Parser.parse("set(foo, b).key($foo)", self.context, DummyMapping) await macro.run(self.handler) self.assertListEqual( self.result, [ (EV_KEY, code_b, 1), (EV_KEY, code_b, 0), ], ) async def test_int_is_explicit_string(self): await Parser.parse( 'set( \t"b" \n, "1")', self.context, DummyMapping, ).run(self.handler) self.assertEqual(macro_variables.get("b"), "1") async def test_int_is_int(self): await Parser.parse( "set(a, 1)", self.context, DummyMapping, ).run(self.handler) self.assertEqual(macro_variables.get("a"), 1) async def test_none(self): await Parser.parse( "set(a, )", self.context, DummyMapping, ).run(self.handler) self.assertEqual(macro_variables.get("a"), None) async def test_set_case_sensitive_1(self): await Parser.parse( 'set(a, "foo")', self.context, DummyMapping, ).run(self.handler) self.assertEqual(macro_variables.get("a"), "foo") self.assertEqual(macro_variables.get("A"), None) async def test_set_case_sensitive_2(self): await Parser.parse( 'set(A, "foo")', self.context, DummyMapping, ).run(self.handler) self.assertEqual(macro_variables.get("A"), "foo") self.assertEqual(macro_variables.get("a"), None) async def test_raises_error(self): self.assertRaises(MacroError, Parser.parse, "set($a, 1)", self.context) self.assertRaises(MacroError, Parser.parse, "set(1, 2)", self.context) self.assertRaises(MacroError, Parser.parse, "set(+, 2)", self.context) self.assertRaises(MacroError, Parser.parse, "set(a(), 2)", self.context) self.assertRaises(MacroError, Parser.parse, "set('b,c', 2)", self.context) self.assertRaises(MacroError, Parser.parse, 'set("b,c", 2)', self.context) Parser.parse("set(A, 2)", self.context) # no error if __name__ == "__main__": unittest.main() input-remapper-2.1.1/tests/unit/test_macros/test_wait.py000066400000000000000000000061201475433465200235200ustar00rootroot00000000000000#!/usr/bin/env python3 # -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings # Copyright (C) 2025 sezanzeb # # This file is part of input-remapper. # # input-remapper is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # input-remapper is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with input-remapper. If not, see . import time import unittest from inputremapper.configs.validation_errors import MacroError from inputremapper.injection.macros.macro import Macro from inputremapper.injection.macros.parse import Parser from tests.lib.test_setup import test_setup from tests.unit.test_macros.macro_test_base import DummyMapping, MacroTestBase @test_setup class TestWait(MacroTestBase): async def assert_time_randomized( self, macro: Macro, min_: float, max_: float, ): for _ in range(100): start = time.time() await macro.run(self.handler) time_taken = time.time() - start # Any of the runs should be within the defined range, to prove that they # are indeed random. if min_ < time_taken < max_: return raise AssertionError("`wait` was not randomized") async def test_wait_1_core(self): mapping = DummyMapping() mapping.macro_key_sleep_ms = 0 macro = Parser.parse("repeat(5, wait(50))", self.context, mapping, True) start = time.time() await macro.run(self.handler) time_per_iteration = (time.time() - start) / 5 self.assertLess(abs(time_per_iteration - 0.05), 0.005) async def test_wait_2_ranged(self): mapping = DummyMapping() mapping.macro_key_sleep_ms = 0 macro = Parser.parse("wait(1, 100)", self.context, mapping, True) await self.assert_time_randomized(macro, 0.02, 0.08) async def test_wait_3_ranged_single_get(self): mapping = DummyMapping() mapping.macro_key_sleep_ms = 0 macro = Parser.parse("set(a, 100).wait(1, $a)", self.context, mapping, True) await self.assert_time_randomized(macro, 0.02, 0.08) async def test_wait_4_ranged_double_get(self): mapping = DummyMapping() mapping.macro_key_sleep_ms = 0 macro = Parser.parse( "set(a, 1).set(b, 100).wait($a, $b)", self.context, mapping, True ) await self.assert_time_randomized(macro, 0.02, 0.08) async def test_raises_error(self): Parser.parse("w(2)", self.context) # no error self.assertRaises(MacroError, Parser.parse, "wait(a)", self.context) if __name__ == "__main__": unittest.main() input-remapper-2.1.1/tests/unit/test_mapping.py000066400000000000000000000411641475433465200216730ustar00rootroot00000000000000#!/usr/bin/env python3 # -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings # Copyright (C) 2025 sezanzeb # # This file is part of input-remapper. # # input-remapper is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # input-remapper is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with input-remapper. If not, see . import unittest from functools import partial from evdev.ecodes import ( EV_REL, REL_X, EV_KEY, REL_Y, REL_WHEEL, REL_WHEEL_HI_RES, KEY_1, KEY_ESC, ) try: from pydantic.v1 import ValidationError except ImportError: from pydantic import ValidationError from inputremapper.configs.mapping import Mapping, UIMapping, MappingType from inputremapper.configs.keyboard_layout import keyboard_layout, DISABLE_NAME from inputremapper.configs.input_config import InputCombination, InputConfig from inputremapper.gui.messages.message_broker import MessageType from tests.lib.test_setup import test_setup @test_setup class TestMapping(unittest.IsolatedAsyncioTestCase): def test_init(self): """Test init and that defaults are set.""" cfg = { "input_combination": [{"type": 1, "code": 2}], "target_uinput": "keyboard", "output_symbol": "a", } m = Mapping(**cfg) self.assertEqual( m.input_combination, InputCombination([InputConfig(type=1, code=2)]) ) self.assertEqual(m.target_uinput, "keyboard") self.assertEqual(m.output_symbol, "a") self.assertIsNone(m.output_code) self.assertIsNone(m.output_type) self.assertEqual(m.macro_key_sleep_ms, 0) self.assertEqual(m.deadzone, 0.1) self.assertEqual(m.gain, 1) self.assertEqual(m.expo, 0) self.assertEqual(m.rel_rate, 60) self.assertEqual(m.rel_to_abs_input_cutoff, 2) self.assertEqual(m.release_timeout, 0.05) def test_is_wheel_output(self): mapping = Mapping( input_combination=InputCombination([InputConfig(type=EV_REL, code=REL_X)]), target_uinput="keyboard", output_type=EV_REL, output_code=REL_Y, ) self.assertFalse(mapping.is_wheel_output()) self.assertFalse(mapping.is_high_res_wheel_output()) mapping = Mapping( input_combination=InputCombination([InputConfig(type=EV_REL, code=REL_X)]), target_uinput="keyboard", output_type=EV_REL, output_code=REL_WHEEL, ) self.assertTrue(mapping.is_wheel_output()) self.assertFalse(mapping.is_high_res_wheel_output()) mapping = Mapping( input_combination=InputCombination([InputConfig(type=EV_REL, code=REL_X)]), target_uinput="keyboard", output_type=EV_REL, output_code=REL_WHEEL_HI_RES, ) self.assertFalse(mapping.is_wheel_output()) self.assertTrue(mapping.is_high_res_wheel_output()) def test_get_output_type_code(self): cfg = { "input_combination": [{"type": 1, "code": 2}], "target_uinput": "keyboard", "output_symbol": "a", } m = Mapping(**cfg) a = keyboard_layout.get("a") self.assertEqual(m.get_output_type_code(), (EV_KEY, a)) m.output_symbol = "key(a)" self.assertIsNone(m.get_output_type_code()) cfg = { "input_combination": [{"type": 1, "code": 2}, {"type": 3, "code": 1}], "target_uinput": "keyboard", "output_type": 2, "output_code": 3, } m = Mapping(**cfg) self.assertEqual(m.get_output_type_code(), (2, 3)) def test_strips_output_symbol(self): cfg = { "input_combination": [{"type": 1, "code": 2}], "target_uinput": "keyboard", "output_symbol": "\t a \n", } m = Mapping(**cfg) a = keyboard_layout.get("a") self.assertEqual(m.get_output_type_code(), (EV_KEY, a)) def test_combination_changed_callback(self): cfg = { "input_combination": [{"type": 1, "code": 1}], "target_uinput": "keyboard", "output_symbol": "a", } m = Mapping(**cfg) arguments = [] def callback(*args): arguments.append(tuple(args)) m.set_combination_changed_callback(callback) m.input_combination = [{"type": 1, "code": 2}] m.input_combination = [{"type": 1, "code": 3}] # make sure a copy works as expected and keeps the callback m2 = m.copy() m2.input_combination = [{"type": 1, "code": 4}] m2.remove_combination_changed_callback() m.remove_combination_changed_callback() m.input_combination = [{"type": 1, "code": 5}] m2.input_combination = [{"type": 1, "code": 6}] self.assertEqual( arguments, [ ( InputCombination([{"type": 1, "code": 2}]), InputCombination([{"type": 1, "code": 1}]), ), ( InputCombination([{"type": 1, "code": 3}]), InputCombination([{"type": 1, "code": 2}]), ), ( InputCombination([{"type": 1, "code": 4}]), InputCombination([{"type": 1, "code": 3}]), ), ], ) m.remove_combination_changed_callback() def test_init_fails(self): """Test that the init fails with invalid data.""" test = partial(self.assertRaises, ValidationError, Mapping) cfg = { "input_combination": [{"type": 1, "code": 2}], "target_uinput": "keyboard", "output_symbol": "a", } Mapping(**cfg) # missing output symbol del cfg["output_symbol"] test(**cfg) cfg["output_code"] = KEY_ESC test(**cfg) cfg["output_type"] = EV_KEY Mapping(**cfg) # matching type, code and symbol. This cannot be done via the ui, and requires # manual editing of the preset file. a = keyboard_layout.get("a") cfg["output_code"] = a cfg["output_symbol"] = "a" cfg["output_type"] = EV_KEY Mapping(**cfg) # macro cfg["output_symbol"] = "key(a)" test(**cfg) cfg["output_symbol"] = "a" Mapping(**cfg) # mismatching type, code and symbol cfg["output_symbol"] = "b" test(**cfg) del cfg["output_type"] del cfg["output_code"] Mapping(**cfg) # no error # empty symbol string without type and code cfg["output_symbol"] = "" test(**cfg) cfg["output_symbol"] = "a" # missing target del cfg["target_uinput"] test(**cfg) # unknown target cfg["target_uinput"] = "foo" test(**cfg) cfg["target_uinput"] = "keyboard" Mapping(**cfg) # missing input_combination del cfg["input_combination"] test(**cfg) cfg["input_combination"] = [{"type": 1, "code": 2}] Mapping(**cfg) # no macro and not a known symbol cfg["output_symbol"] = "qux" test(**cfg) cfg["output_symbol"] = "key(a)" Mapping(**cfg) # invalid macro cfg["output_symbol"] = "key('a')" test(**cfg) cfg["output_symbol"] = "a" Mapping(**cfg) # map axis but no output type and code given cfg["input_combination"] = [{"type": 3, "code": 0}] test(**cfg) # output symbol=disable is allowed cfg["output_symbol"] = DISABLE_NAME Mapping(**cfg) del cfg["output_symbol"] cfg["output_code"] = 1 cfg["output_type"] = 3 Mapping(**cfg) # empty symbol string is allowed when type and code is set cfg["output_symbol"] = "" Mapping(**cfg) del cfg["output_symbol"] # multiple axis as axis in event combination cfg["input_combination"] = [{"type": 3, "code": 0}, {"type": 3, "code": 1}] test(**cfg) cfg["input_combination"] = [{"type": 3, "code": 0}] Mapping(**cfg) del cfg["output_type"] del cfg["output_code"] cfg["input_combination"] = [{"type": 1, "code": 2}] cfg["output_symbol"] = "a" Mapping(**cfg) # map EV_ABS as key with trigger point out of range cfg["input_combination"] = [{"type": 3, "code": 0, "analog_threshold": 100}] test(**cfg) cfg["input_combination"] = [{"type": 3, "code": 0, "analog_threshold": 99}] Mapping(**cfg) cfg["input_combination"] = [{"type": 3, "code": 0, "analog_threshold": -100}] test(**cfg) cfg["input_combination"] = [{"type": 3, "code": 0, "analog_threshold": -99}] Mapping(**cfg) cfg["input_combination"] = [{"type": 1, "code": 2}] Mapping(**cfg) # deadzone out of range test(**cfg, deadzone=1.01) test(**cfg, deadzone=-0.01) Mapping(**cfg, deadzone=1) Mapping(**cfg, deadzone=0) # expo out of range test(**cfg, expo=1.01) test(**cfg, expo=-1.01) Mapping(**cfg, expo=1) Mapping(**cfg, expo=-1) # negative rate test(**cfg, rel_rate=-1) test(**cfg, rel_rate=0) Mapping(**cfg, rel_rate=1) Mapping(**cfg, rel_rate=200) # negative rel_to_abs_input_cutoff test(**cfg, rel_to_abs_input_cutoff=-1) test(**cfg, rel_to_abs_input_cutoff=0) Mapping(**cfg, rel_to_abs_input_cutoff=1) Mapping(**cfg, rel_to_abs_input_cutoff=3) # negative release timeout test(**cfg, release_timeout=-0.1) test(**cfg, release_timeout=0) Mapping(**cfg, release_timeout=0.05) Mapping(**cfg, release_timeout=0.3) # analog output but no analog input cfg = { "input_combination": [{"type": 3, "code": 1, "analog_threshold": -1}], "target_uinput": "gamepad", "output_type": 3, "output_code": 1, } test(**cfg) cfg["input_combination"] = [{"type": 2, "code": 1, "analog_threshold": -1}] test(**cfg) cfg["output_type"] = 2 test(**cfg) cfg["input_combination"] = [{"type": 3, "code": 1, "analog_threshold": -1}] test(**cfg) def test_automatically_detects_mapping_type(self): cfg = { "input_combination": [{"type": 1, "code": 2}], "target_uinput": "keyboard", "output_symbol": "a", } self.assertEqual(Mapping(**cfg).mapping_type, MappingType.KEY_MACRO.value) cfg = { "input_combination": [{"type": 1, "code": 2}], "target_uinput": "keyboard", "output_type": EV_KEY, "output_code": KEY_ESC, } self.assertEqual(Mapping(**cfg).mapping_type, MappingType.KEY_MACRO.value) cfg = { "input_combination": [{"type": EV_REL, "code": REL_X}], "target_uinput": "keyboard", "output_type": EV_REL, "output_code": REL_X, } self.assertEqual(Mapping(**cfg).mapping_type, MappingType.ANALOG.value) def test_revalidate_at_assignment(self): cfg = { "input_combination": [{"type": 1, "code": 1}], "target_uinput": "keyboard", "output_symbol": "a", } m = Mapping(**cfg) test = partial(self.assertRaises, ValidationError, m.__setattr__) # invalid input event test("input_combination", "1,2,3,4") # unknown target test("target_uinput", "foo") # invalid macro test("output_symbol", "key()") # we could do a lot more tests here but since pydantic uses the same validation # code as for the initialization we only need to make sure that the # assignment validation is active def test_set_invalid_combination_with_callback(self): cfg = { "input_combination": [{"type": 1, "code": 1}], "target_uinput": "keyboard", "output_symbol": "a", } m = Mapping(**cfg) m.set_combination_changed_callback(lambda *args: None) self.assertRaises(ValidationError, m.__setattr__, "input_combination", "1,2") m.input_combination = [{"type": 1, "code": 2}] m.input_combination = [{"type": 1, "code": 2}] def test_is_valid(self): cfg = { "input_combination": [{"type": 1, "code": 1}], "target_uinput": "keyboard", "output_symbol": "a", } m = Mapping(**cfg) self.assertTrue(m.is_valid()) def test_wrong_target(self): mapping = Mapping( input_combination=[{"type": EV_KEY, "code": KEY_1}], target_uinput="keyboard", output_symbol="a", ) mapping.set_combination_changed_callback(lambda *args: None) self.assertRaisesRegex( ValidationError, # the error should mention # - the symbol # - the current incorrect target # - the target that works for this symbol ".*BTN_A.*keyboard.*gamepad", mapping.__setattr__, "output_symbol", "BTN_A", ) def test_wrong_target_for_macro(self): mapping = Mapping( input_combination=[{"type": EV_KEY, "code": KEY_1}], target_uinput="keyboard", output_symbol="key(a)", ) mapping.set_combination_changed_callback(lambda *args: None) self.assertRaisesRegex( ValidationError, # the error should mention # - the symbol # - the current incorrect target # - the target that works for this symbol ".*BTN_A.*keyboard.*gamepad", mapping.__setattr__, "output_symbol", "key(BTN_A)", ) @test_setup class TestUIMapping(unittest.IsolatedAsyncioTestCase): def test_init(self): """Should be able to initialize without throwing errors.""" UIMapping() def test_is_valid(self): """Should be invalid at first and become valid once all data is provided.""" mapping = UIMapping() self.assertFalse(mapping.is_valid()) mapping.input_combination = [{"type": EV_KEY, "code": KEY_1}] mapping.output_symbol = "a" self.assertFalse(mapping.is_valid()) mapping.target_uinput = "keyboard" self.assertTrue(mapping.is_valid()) def test_updates_validation_error(self): mapping = UIMapping() self.assertGreaterEqual(len(mapping.get_error().errors()), 2) mapping.input_combination = [{"type": EV_KEY, "code": KEY_1}] mapping.output_symbol = "a" self.assertIn( "1 validation error for Mapping\ntarget_uinput", str(mapping.get_error()), ) mapping.target_uinput = "keyboard" self.assertTrue(mapping.is_valid()) self.assertIsNone(mapping.get_error()) def test_copy_returns_ui_mapping(self): """Copy should also be a UIMapping with all the invalid data.""" mapping = UIMapping() mapping_2 = mapping.copy() self.assertIsInstance(mapping_2, UIMapping) self.assertEqual( mapping_2.input_combination, InputCombination.empty_combination() ) self.assertIsNone(mapping_2.output_symbol) def test_get_bus_massage(self): mapping = UIMapping() mapping_2 = mapping.get_bus_message() self.assertEqual(mapping_2.message_type, MessageType.mapping) with self.assertRaises(TypeError): # the massage should be immutable mapping_2.output_symbol = "a" self.assertIsNone(mapping_2.output_symbol) # the original should be not immutable mapping.output_symbol = "a" self.assertEqual(mapping.output_symbol, "a") def test_has_input_defined(self): mapping = UIMapping() self.assertFalse(mapping.has_input_defined()) mapping.input_combination = InputCombination([InputConfig(type=EV_KEY, code=1)]) self.assertTrue(mapping.has_input_defined()) if __name__ == "__main__": unittest.main() input-remapper-2.1.1/tests/unit/test_message_broker.py000066400000000000000000000075661475433465200232400ustar00rootroot00000000000000#!/usr/bin/env python3 # -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings # Copyright (C) 2025 sezanzeb # # This file is part of input-remapper. # # input-remapper is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # input-remapper is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with input-remapper. If not, see . import unittest from dataclasses import dataclass from inputremapper.gui.messages.message_broker import MessageBroker, MessageType, Signal from tests.lib.test_setup import test_setup class Listener: def __init__(self): self.calls = [] def __call__(self, data): self.calls.append(data) @dataclass class Message: message_type: MessageType msg: str @test_setup class TestMessageBroker(unittest.TestCase): def test_calls_listeners(self): """The correct Listeners get called""" message_broker = MessageBroker() listener = Listener() message_broker.subscribe(MessageType.test1, listener) message_broker.publish(Message(MessageType.test1, "foo")) message_broker.publish(Message(MessageType.test2, "bar")) self.assertEqual(listener.calls[0], Message(MessageType.test1, "foo")) def test_unsubscribe(self): message_broker = MessageBroker() listener = Listener() message_broker.subscribe(MessageType.test1, listener) message_broker.publish(Message(MessageType.test1, "a")) message_broker.unsubscribe(listener) message_broker.publish(Message(MessageType.test1, "b")) self.assertEqual(len(listener.calls), 1) self.assertEqual(listener.calls[0], Message(MessageType.test1, "a")) def test_unsubscribe_unknown_listener(self): """nothing happens if we unsubscribe an unknown listener""" message_broker = MessageBroker() listener1 = Listener() listener2 = Listener() message_broker.subscribe(MessageType.test1, listener1) message_broker.unsubscribe(listener2) message_broker.publish(Message(MessageType.test1, "a")) self.assertEqual(listener1.calls[0], Message(MessageType.test1, "a")) def test_preserves_order(self): message_broker = MessageBroker() calls = [] def listener1(_): message_broker.publish(Message(MessageType.test2, "f")) calls.append(1) def listener2(_): message_broker.publish(Message(MessageType.test2, "f")) calls.append(2) def listener3(_): message_broker.publish(Message(MessageType.test2, "f")) calls.append(3) def listener4(_): calls.append(4) message_broker.subscribe(MessageType.test1, listener1) message_broker.subscribe(MessageType.test1, listener2) message_broker.subscribe(MessageType.test1, listener3) message_broker.subscribe(MessageType.test2, listener4) message_broker.publish(Message(MessageType.test1, "")) first = calls[:3] first.sort() self.assertEqual([1, 2, 3], first) self.assertEqual([4, 4, 4], calls[3:]) @test_setup class TestSignal(unittest.TestCase): def test_eq(self): self.assertEqual(Signal(MessageType.uinputs), Signal(MessageType.uinputs)) self.assertNotEqual(Signal(MessageType.uinputs), Signal(MessageType.groups)) self.assertNotEqual(Signal(MessageType.uinputs), "Signal: MessageType.uinputs") input-remapper-2.1.1/tests/unit/test_migrations.py000066400000000000000000000635471475433465200224250ustar00rootroot00000000000000# # input-remapper is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # input-remapper is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with input-remapper. If not, see . import json import os import shutil import unittest from packaging import version from evdev.ecodes import ( EV_KEY, EV_ABS, ABS_HAT0X, ABS_X, ABS_Y, ABS_RX, ABS_RY, EV_REL, REL_X, REL_Y, REL_WHEEL_HI_RES, REL_HWHEEL_HI_RES, ) from inputremapper.configs.input_config import InputCombination, InputConfig from inputremapper.configs.mapping import UIMapping from inputremapper.configs.migrations import Migrations from inputremapper.configs.paths import PathUtils from inputremapper.configs.preset import Preset from inputremapper.injection.global_uinputs import GlobalUInputs, UInput from inputremapper.logging.logger import VERSION from inputremapper.user import UserUtils from tests.lib.test_setup import test_setup from tests.lib.tmp import tmp @test_setup class TestMigrations(unittest.TestCase): def setUp(self): # some extra care to ensure those tests are not destroying actual presets self.assertTrue(UserUtils.home.startswith("/tmp")) self.assertTrue(PathUtils.config_path().startswith("/tmp")) self.assertTrue(PathUtils.get_preset_path().startswith("/tmp")) self.assertTrue(PathUtils.get_preset_path("foo", "bar").startswith("/tmp")) self.assertTrue(PathUtils.get_config_path().startswith("/tmp")) self.assertTrue(PathUtils.get_config_path("foo").startswith("/tmp")) self.v1_dir = os.path.join(UserUtils.home, ".config", "input-remapper") self.beta_dir = os.path.join( UserUtils.home, ".config", "input-remapper", "beta_1.6.0-beta" ) global_uinputs = GlobalUInputs(UInput) global_uinputs.prepare_all() self.migrations = Migrations(global_uinputs) def test_migrate_suffix(self): old = os.path.join(PathUtils.config_path(), "config") new = os.path.join(PathUtils.config_path(), "config.json") try: os.remove(new) except FileNotFoundError: pass PathUtils.touch(old) with open(old, "w") as f: f.write("{}") self.migrations.migrate() self.assertTrue(os.path.exists(new)) self.assertFalse(os.path.exists(old)) def test_rename_config(self): old = os.path.join(UserUtils.home, ".config", "key-mapper") new = PathUtils.config_path() # we are not destroying our actual config files with this test self.assertTrue(new.startswith(tmp), f'Expected "{new}" to start with "{tmp}"') try: shutil.rmtree(new) except FileNotFoundError: pass old_config_json = os.path.join(old, "config.json") PathUtils.touch(old_config_json) with open(old_config_json, "w") as f: f.write('{"foo":"bar"}') self.migrations.migrate() self.assertTrue(os.path.exists(new)) self.assertFalse(os.path.exists(old)) new_config_json = os.path.join(new, "config.json") with open(new_config_json, "r") as f: moved_config = json.loads(f.read()) self.assertEqual(moved_config["foo"], "bar") def test_wont_migrate_suffix(self): old = os.path.join(PathUtils.config_path(), "config") new = os.path.join(PathUtils.config_path(), "config.json") PathUtils.touch(new) with open(new, "w") as f: f.write("{}") PathUtils.touch(old) with open(old, "w") as f: f.write("{}") self.migrations.migrate() self.assertTrue(os.path.exists(new)) self.assertTrue(os.path.exists(old)) def test_migrate_preset(self): if os.path.exists(PathUtils.config_path()): shutil.rmtree(PathUtils.config_path()) p1 = os.path.join(PathUtils.config_path(), "foo1", "bar1.json") p2 = os.path.join(PathUtils.config_path(), "foo2", "bar2.json") PathUtils.touch(p1) PathUtils.touch(p2) with open(p1, "w") as f: f.write("{}") with open(p2, "w") as f: f.write("{}") self.migrations.migrate() self.assertFalse( os.path.exists(os.path.join(PathUtils.config_path(), "foo1", "bar1.json")) ) self.assertFalse( os.path.exists(os.path.join(PathUtils.config_path(), "foo2", "bar2.json")) ) self.assertTrue( os.path.exists( os.path.join(PathUtils.config_path(), "presets", "foo1", "bar1.json") ), ) self.assertTrue( os.path.exists( os.path.join(PathUtils.config_path(), "presets", "foo2", "bar2.json") ), ) def test_wont_migrate_preset(self): if os.path.exists(PathUtils.config_path()): shutil.rmtree(PathUtils.config_path()) p1 = os.path.join(PathUtils.config_path(), "foo1", "bar1.json") p2 = os.path.join(PathUtils.config_path(), "foo2", "bar2.json") PathUtils.touch(p1) PathUtils.touch(p2) with open(p1, "w") as f: f.write("{}") with open(p2, "w") as f: f.write("{}") # already migrated PathUtils.mkdir(os.path.join(PathUtils.config_path(), "presets")) self.migrations.migrate() self.assertTrue( os.path.exists(os.path.join(PathUtils.config_path(), "foo1", "bar1.json")) ) self.assertTrue( os.path.exists(os.path.join(PathUtils.config_path(), "foo2", "bar2.json")) ) self.assertFalse( os.path.exists( os.path.join(PathUtils.config_path(), "presets", "foo1", "bar1.json") ), ) self.assertFalse( os.path.exists( os.path.join(PathUtils.config_path(), "presets", "foo2", "bar2.json") ), ) def test_migrate_mappings(self): """Test if mappings are migrated correctly mappings like {(type, code): symbol} or {(type, code, value): symbol} should migrate to {InputCombination: {target: target, symbol: symbol, ...}} """ path = os.path.join( PathUtils.config_path(), "presets", "Foo Device", "test.json" ) os.makedirs(os.path.dirname(path), exist_ok=True) with open(path, "w") as file: json.dump( { "mapping": { f"{EV_KEY},1": "a", f"{EV_KEY}, 2, 1": "BTN_B", # can be mapped to "gamepad" f"{EV_KEY}, 3, 1": "BTN_1", # can not be mapped f"{EV_ABS},{ABS_HAT0X},-1": "b", f"{EV_ABS},1,1+{EV_ABS},2,-1+{EV_ABS},3,1": "c", f"{EV_KEY}, 4, 1": ("d", "keyboard"), f"{EV_KEY}, 5, 1": ("e", "foo"), # unknown target f"{EV_KEY}, 6, 1": ("key(a, b)", "keyboard"), # broken macro # ignored because broken f"3,1,1,2": "e", f"3": "e", f",,+3,1,2": "g", f"": "h", } }, file, ) self.migrations.migrate() # use UIMapping to also load invalid mappings preset = Preset(PathUtils.get_preset_path("Foo Device", "test"), UIMapping) preset.load() self.assertEqual( preset.get_mapping(InputCombination([InputConfig(type=EV_KEY, code=1)])), UIMapping( input_combination=InputCombination([InputConfig(type=EV_KEY, code=1)]), target_uinput="keyboard", output_symbol="a", ), ) self.assertEqual( preset.get_mapping(InputCombination([InputConfig(type=EV_KEY, code=2)])), UIMapping( input_combination=InputCombination([InputConfig(type=EV_KEY, code=2)]), target_uinput="gamepad", output_symbol="BTN_B", ), ) self.assertEqual( preset.get_mapping(InputCombination([InputConfig(type=EV_KEY, code=3)])), UIMapping( input_combination=InputCombination([InputConfig(type=EV_KEY, code=3)]), target_uinput="keyboard", output_symbol="BTN_1\n# Broken mapping:\n# No target can handle all specified keycodes", ), ) self.assertEqual( preset.get_mapping(InputCombination([InputConfig(type=EV_KEY, code=4)])), UIMapping( input_combination=InputCombination([InputConfig(type=EV_KEY, code=4)]), target_uinput="keyboard", output_symbol="d", ), ) self.assertEqual( preset.get_mapping( InputCombination( [InputConfig(type=EV_ABS, code=ABS_HAT0X, analog_threshold=-1)] ) ), UIMapping( input_combination=InputCombination( [InputConfig(type=EV_ABS, code=ABS_HAT0X, analog_threshold=-1)] ), target_uinput="keyboard", output_symbol="b", ), ) self.assertEqual( preset.get_mapping( InputCombination( InputCombination.from_tuples( (EV_ABS, 1, 1), (EV_ABS, 2, -1), (EV_ABS, 3, 1) ) ), ), UIMapping( input_combination=InputCombination( InputCombination.from_tuples( (EV_ABS, 1, 1), (EV_ABS, 2, -1), (EV_ABS, 3, 1) ), ), target_uinput="keyboard", output_symbol="c", ), ) self.assertEqual( preset.get_mapping(InputCombination([InputConfig(type=EV_KEY, code=5)])), UIMapping( input_combination=InputCombination([InputConfig(type=EV_KEY, code=5)]), target_uinput="foo", output_symbol="e", ), ) self.assertEqual( preset.get_mapping(InputCombination([InputConfig(type=EV_KEY, code=6)])), UIMapping( input_combination=InputCombination([InputConfig(type=EV_KEY, code=6)]), target_uinput="keyboard", output_symbol="key(a, b)", ), ) self.assertEqual(8, len(preset)) def test_migrate_otherwise(self): path = os.path.join( PathUtils.config_path(), "presets", "Foo Device", "test.json" ) os.makedirs(os.path.dirname(path), exist_ok=True) with open(path, "w") as file: json.dump( { "mapping": { f"{EV_KEY},1,1": ("otherwise + otherwise", "keyboard"), f"{EV_KEY},2,1": ("bar($otherwise)", "keyboard"), f"{EV_KEY},3,1": ("foo(otherwise=qux)", "keyboard"), f"{EV_KEY},4,1": ("qux(otherwise).bar(otherwise = 1)", "foo"), f"{EV_KEY},5,1": ("foo(otherwise1=2qux)", "keyboard"), } }, file, ) self.migrations.migrate() preset = Preset(PathUtils.get_preset_path("Foo Device", "test"), UIMapping) preset.load() self.assertEqual( preset.get_mapping(InputCombination([InputConfig(type=EV_KEY, code=1)])), UIMapping( input_combination=InputCombination([InputConfig(type=EV_KEY, code=1)]), target_uinput="keyboard", output_symbol="otherwise + otherwise", ), ) self.assertEqual( preset.get_mapping(InputCombination([InputConfig(type=EV_KEY, code=2)])), UIMapping( input_combination=InputCombination([InputConfig(type=EV_KEY, code=2)]), target_uinput="keyboard", output_symbol="bar($otherwise)", ), ) self.assertEqual( preset.get_mapping(InputCombination([InputConfig(type=EV_KEY, code=3)])), UIMapping( input_combination=InputCombination([InputConfig(type=EV_KEY, code=3)]), target_uinput="keyboard", output_symbol="foo(else=qux)", ), ) self.assertEqual( preset.get_mapping(InputCombination([InputConfig(type=EV_KEY, code=4)])), UIMapping( input_combination=InputCombination([InputConfig(type=EV_KEY, code=4)]), target_uinput="foo", output_symbol="qux(otherwise).bar(else=1)", ), ) self.assertEqual( preset.get_mapping(InputCombination([InputConfig(type=EV_KEY, code=5)])), UIMapping( input_combination=InputCombination([InputConfig(type=EV_KEY, code=5)]), target_uinput="keyboard", output_symbol="foo(otherwise1=2qux)", ), ) def test_add_version(self): path = os.path.join(PathUtils.config_path(), "config.json") os.makedirs(os.path.dirname(path), exist_ok=True) with open(path, "w") as file: file.write("{}") self.migrations.migrate() self.assertEqual( version.parse(VERSION), self.migrations.config_version(), ) def test_update_version(self): path = os.path.join(PathUtils.config_path(), "config.json") os.makedirs(os.path.dirname(path), exist_ok=True) with open(path, "w") as file: json.dump({"version": "0.1.0"}, file) self.migrations.migrate() self.assertEqual( version.parse(VERSION), self.migrations.config_version(), ) def test_config_version(self): path = os.path.join(PathUtils.config_path(), "config.json") with open(path, "w") as file: file.write("{}") self.assertEqual("0.0.0", self.migrations.config_version().public) try: os.remove(path) except FileNotFoundError: pass self.assertEqual("0.0.0", self.migrations.config_version().public) def test_migrate_left_and_right_purpose(self): path = os.path.join( PathUtils.config_path(), "presets", "Foo Device", "test.json" ) os.makedirs(os.path.dirname(path), exist_ok=True) with open(path, "w") as file: json.dump( { "gamepad": { "joystick": { "left_purpose": "mouse", "right_purpose": "wheel", "pointer_speed": 50, "x_scroll_speed": 10, "y_scroll_speed": 20, } } }, file, ) self.migrations.migrate() preset = Preset(PathUtils.get_preset_path("Foo Device", "test"), UIMapping) preset.load() # 2 mappings for mouse # 2 mappings for wheel self.assertEqual(len(preset), 4) self.assertEqual( preset.get_mapping( InputCombination([InputConfig(type=EV_ABS, code=ABS_X)]) ), UIMapping( input_combination=InputCombination( [InputConfig(type=EV_ABS, code=ABS_X)] ), target_uinput="mouse", output_type=EV_REL, output_code=REL_X, gain=50 / 100, ), ) self.assertEqual( preset.get_mapping( InputCombination([InputConfig(type=EV_ABS, code=ABS_Y)]) ), UIMapping( input_combination=InputCombination( [InputConfig(type=EV_ABS, code=ABS_Y)] ), target_uinput="mouse", output_type=EV_REL, output_code=REL_Y, gain=50 / 100, ), ) self.assertEqual( preset.get_mapping( InputCombination([InputConfig(type=EV_ABS, code=ABS_RX)]) ), UIMapping( input_combination=InputCombination( [InputConfig(type=EV_ABS, code=ABS_RX)] ), target_uinput="mouse", output_type=EV_REL, output_code=REL_HWHEEL_HI_RES, gain=10, ), ) self.assertEqual( preset.get_mapping( InputCombination([InputConfig(type=EV_ABS, code=ABS_RY)]) ), UIMapping( input_combination=InputCombination( [InputConfig(type=EV_ABS, code=ABS_RY)] ), target_uinput="mouse", output_type=EV_REL, output_code=REL_WHEEL_HI_RES, gain=20, ), ) def test_migrate_left_and_right_purpose2(self): # same as above, but left and right is swapped path = os.path.join( PathUtils.config_path(), "presets", "Foo Device", "test.json" ) os.makedirs(os.path.dirname(path), exist_ok=True) with open(path, "w") as file: json.dump( { "gamepad": { "joystick": { "right_purpose": "mouse", "left_purpose": "wheel", "pointer_speed": 50, "x_scroll_speed": 10, "y_scroll_speed": 20, } } }, file, ) self.migrations.migrate() preset = Preset(PathUtils.get_preset_path("Foo Device", "test"), UIMapping) preset.load() # 2 mappings for mouse # 2 mappings for wheel self.assertEqual(len(preset), 4) self.assertEqual( preset.get_mapping( InputCombination([InputConfig(type=EV_ABS, code=ABS_RX)]) ), UIMapping( input_combination=InputCombination( [InputConfig(type=EV_ABS, code=ABS_RX)] ), target_uinput="mouse", output_type=EV_REL, output_code=REL_X, gain=50 / 100, ), ) self.assertEqual( preset.get_mapping( InputCombination([InputConfig(type=EV_ABS, code=ABS_RY)]) ), UIMapping( input_combination=InputCombination( [InputConfig(type=EV_ABS, code=ABS_RY)] ), target_uinput="mouse", output_type=EV_REL, output_code=REL_Y, gain=50 / 100, ), ) self.assertEqual( preset.get_mapping( InputCombination([InputConfig(type=EV_ABS, code=ABS_X)]) ), UIMapping( input_combination=InputCombination( [InputConfig(type=EV_ABS, code=ABS_X)] ), target_uinput="mouse", output_type=EV_REL, output_code=REL_HWHEEL_HI_RES, gain=10, ), ) self.assertEqual( preset.get_mapping( InputCombination([InputConfig(type=EV_ABS, code=ABS_Y)]) ), UIMapping( input_combination=InputCombination( [InputConfig(type=EV_ABS, code=ABS_Y)] ), target_uinput="mouse", output_type=EV_REL, output_code=REL_WHEEL_HI_RES, gain=20, ), ) def _create_v1_setup(self): """Create all files needed to mimic an outdated v1 configuration.""" device_name = "device_name" PathUtils.mkdir(os.path.join(self.v1_dir, "presets", device_name)) v1_config = {"autoload": {device_name: "foo"}, "version": "1.0"} with open(os.path.join(self.v1_dir, "config.json"), "w") as file: json.dump(v1_config, file) # insert something outdated that will be migrated, to ensure the files are # first copied and then migrated. with open( os.path.join(self.v1_dir, "presets", device_name, "foo.json"), "w" ) as file: json.dump({"mapping": {f"{EV_KEY},1": "a"}}, file) def _create_beta_setup(self): """Create all files needed to mimic a beta configuration.""" device_name = "device_name" # same here, but a different contents to tell the difference PathUtils.mkdir(os.path.join(self.beta_dir, "presets", device_name)) beta_config = {"autoload": {device_name: "bar"}, "version": "1.6"} with open(os.path.join(self.beta_dir, "config.json"), "w") as file: json.dump(beta_config, file) with open( os.path.join(self.beta_dir, "presets", device_name, "bar.json"), "w" ) as file: json.dump( [ { "input_combination": [ {"type": EV_KEY, "code": 1}, ], "target_uinput": "keyboard", "output_symbol": "b", "mapping_type": "key_macro", } ], file, ) def test_prioritize_v1_over_beta_configs(self): # if both v1 and beta presets and config exist, migrate v1 PathUtils.remove(PathUtils.get_config_path()) device_name = "device_name" self._create_v1_setup() self._create_beta_setup() self.assertFalse(os.path.exists(PathUtils.get_preset_path(device_name, "foo"))) self.assertFalse(os.path.exists(PathUtils.get_config_path("config.json"))) self.migrations.migrate() self.assertTrue(os.path.exists(PathUtils.get_preset_path(device_name, "foo"))) self.assertTrue(os.path.exists(PathUtils.get_config_path("config.json"))) self.assertFalse(os.path.exists(PathUtils.get_preset_path(device_name, "bar"))) # expect all original files to still exist self.assertTrue(os.path.join(self.v1_dir, "config.json")) self.assertTrue(os.path.join(self.v1_dir, "presets", "foo.json")) self.assertTrue(os.path.join(self.beta_dir, "config.json")) self.assertTrue(os.path.join(self.beta_dir, "presets", "bar.json")) # v1 configs should be in the v2 dir now, and migrated with open(PathUtils.get_config_path("config.json"), "r") as f: config_json = json.load(f) self.assertDictEqual( config_json, {"autoload": {device_name: "foo"}, "version": VERSION} ) with open(PathUtils.get_preset_path(device_name, "foo.json"), "r") as f: os.system(f'cat { PathUtils.get_preset_path(device_name, "foo.json") }') preset_foo_json = json.load(f) self.assertEqual( preset_foo_json, [ { "input_combination": [ {"type": EV_KEY, "code": 1}, ], "target_uinput": "keyboard", "output_symbol": "a", "mapping_type": "key_macro", } ], ) def test_copy_over_beta_configs(self): # same as test_prioritize_v1_over_beta_configs, but only create the beta # directory without any v1 presets. PathUtils.remove(PathUtils.get_config_path()) device_name = "device_name" self._create_beta_setup() self.assertFalse(os.path.exists(PathUtils.get_preset_path(device_name, "bar"))) self.assertFalse(os.path.exists(PathUtils.get_config_path("config.json"))) self.migrations.migrate() self.assertTrue(os.path.exists(PathUtils.get_preset_path(device_name, "bar"))) self.assertTrue(os.path.exists(PathUtils.get_config_path("config.json"))) # expect all original files to still exist self.assertTrue(os.path.join(self.beta_dir, "config.json")) self.assertTrue(os.path.join(self.beta_dir, "presets", "bar.json")) # beta configs should be in the v2 dir now with open(PathUtils.get_config_path("config.json"), "r") as f: config_json = json.load(f) self.assertDictEqual( config_json, {"autoload": {device_name: "bar"}, "version": VERSION} ) with open(PathUtils.get_preset_path(device_name, "bar.json"), "r") as f: os.system(f'cat { PathUtils.get_preset_path(device_name, "bar.json") }') preset_foo_json = json.load(f) self.assertEqual( preset_foo_json, [ { "input_combination": [ {"type": EV_KEY, "code": 1}, ], "target_uinput": "keyboard", "output_symbol": "b", "mapping_type": "key_macro", } ], ) if __name__ == "__main__": unittest.main() input-remapper-2.1.1/tests/unit/test_paths.py000066400000000000000000000051201475433465200213470ustar00rootroot00000000000000#!/usr/bin/env python3 # -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings # Copyright (C) 2025 sezanzeb # # This file is part of input-remapper. # # input-remapper is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # input-remapper is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with input-remapper. If not, see . import os import tempfile import unittest from inputremapper.configs.paths import PathUtils from tests.lib.test_setup import test_setup from tests.lib.tmp import tmp def _raise(error): raise error @test_setup class TestPaths(unittest.TestCase): def test_touch(self): with tempfile.TemporaryDirectory() as local_tmp: path_abcde = os.path.join(local_tmp, "a/b/c/d/e") PathUtils.touch(path_abcde) self.assertTrue(os.path.exists(path_abcde)) self.assertTrue(os.path.isfile(path_abcde)) self.assertRaises( ValueError, lambda: PathUtils.touch(os.path.join(local_tmp, "a/b/c/d/f/")), ) def test_mkdir(self): with tempfile.TemporaryDirectory() as local_tmp: path_bcde = os.path.join(local_tmp, "b/c/d/e") PathUtils.mkdir(path_bcde) self.assertTrue(os.path.exists(path_bcde)) self.assertTrue(os.path.isdir(path_bcde)) def test_get_preset_path(self): self.assertTrue( PathUtils.get_preset_path().startswith(PathUtils.get_config_path()) ) self.assertTrue(PathUtils.get_preset_path().endswith("presets")) self.assertTrue(PathUtils.get_preset_path("a").endswith("presets/a")) self.assertTrue( PathUtils.get_preset_path("a", "b").endswith("presets/a/b.json") ) def test_get_config_path(self): # might end with /beta_XXX self.assertTrue( PathUtils.get_config_path().startswith(f"{tmp}/.config/input-remapper") ) self.assertTrue(PathUtils.get_config_path("a", "b").endswith("a/b")) def test_split_all(self): self.assertListEqual(PathUtils.split_all("a/b/c/d"), ["a", "b", "c", "d"]) input-remapper-2.1.1/tests/unit/test_preset.py000066400000000000000000000432611475433465200215420ustar00rootroot00000000000000#!/usr/bin/env python3 # -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings # Copyright (C) 2025 sezanzeb # # This file is part of input-remapper. # # input-remapper is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # input-remapper is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with input-remapper. If not, see . import os import unittest from unittest.mock import patch from evdev.ecodes import EV_KEY, EV_ABS from inputremapper.configs.input_config import InputCombination, InputConfig from inputremapper.configs.mapping import Mapping from inputremapper.configs.mapping import UIMapping from inputremapper.configs.paths import PathUtils from inputremapper.configs.preset import Preset from tests.lib.test_setup import test_setup @test_setup class TestPreset(unittest.TestCase): def setUp(self): self.preset = Preset(PathUtils.get_preset_path("foo", "bar2")) self.assertFalse(self.preset.has_unsaved_changes()) def test_is_mapped_multiple_times(self): combination = InputCombination( InputCombination.from_tuples((1, 1, 1), (2, 2, 2), (3, 3, 3), (4, 4, 4)) ) permutations = combination.get_permutations() self.assertEqual(len(permutations), 6) self.preset._mappings[permutations[0]] = Mapping( input_combination=permutations[0], target_uinput="keyboard", output_symbol="a", ) self.assertFalse(self.preset._is_mapped_multiple_times(permutations[2])) self.preset._mappings[permutations[1]] = Mapping( input_combination=permutations[1], target_uinput="keyboard", output_symbol="a", ) self.assertTrue(self.preset._is_mapped_multiple_times(permutations[2])) def test_has_unsaved_changes(self): self.preset.path = PathUtils.get_preset_path("foo", "bar2") self.preset.add(Mapping.from_combination()) self.assertTrue(self.preset.has_unsaved_changes()) self.preset.save() self.assertFalse(self.preset.has_unsaved_changes()) self.preset.empty() self.assertEqual(len(self.preset), 0) # empty preset but non-empty file self.assertTrue(self.preset.has_unsaved_changes()) # load again from the disc self.preset.load() self.assertEqual( self.preset.get_mapping(InputCombination.empty_combination()), Mapping.from_combination(), ) self.assertFalse(self.preset.has_unsaved_changes()) # change the path to a non exiting file self.preset.path = PathUtils.get_preset_path("bar", "foo") # the preset has a mapping, the file has not self.assertTrue(self.preset.has_unsaved_changes()) # change back to the original path self.preset.path = PathUtils.get_preset_path("foo", "bar2") # no difference between file and memory self.assertFalse(self.preset.has_unsaved_changes()) # modify the mapping mapping = self.preset.get_mapping(InputCombination.empty_combination()) mapping.gain = 0.5 self.assertTrue(self.preset.has_unsaved_changes()) self.preset.load() self.preset.path = PathUtils.get_preset_path("bar", "foo") self.preset.remove(Mapping.from_combination().input_combination) # empty preset and empty file self.assertFalse(self.preset.has_unsaved_changes()) self.preset.path = PathUtils.get_preset_path("foo", "bar2") # empty preset, but non-empty file self.assertTrue(self.preset.has_unsaved_changes()) self.preset.load() self.assertEqual(len(self.preset), 1) self.assertFalse(self.preset.has_unsaved_changes()) # delete the preset from the system: self.preset.empty() self.preset.save() self.preset.load() self.assertFalse(self.preset.has_unsaved_changes()) self.assertEqual(len(self.preset), 0) def test_save_load(self): one = InputConfig(type=EV_KEY, code=10) two = InputConfig(type=EV_KEY, code=11) three = InputConfig(type=EV_KEY, code=12) self.preset.add( Mapping.from_combination(InputCombination([one]), "keyboard", "1") ) self.preset.add( Mapping.from_combination(InputCombination([two]), "keyboard", "2") ) self.preset.add( Mapping.from_combination(InputCombination((two, three)), "keyboard", "3"), ) self.preset.path = PathUtils.get_preset_path("Foo Device", "test") self.preset.save() path = os.path.join( PathUtils.config_path(), "presets", "Foo Device", "test.json" ) self.assertTrue(os.path.exists(path)) loaded = Preset(PathUtils.get_preset_path("Foo Device", "test")) self.assertEqual(len(loaded), 0) loaded.load() self.assertEqual(len(loaded), 3) self.assertRaises(TypeError, loaded.get_mapping, one) self.assertEqual( loaded.get_mapping(InputCombination([one])), Mapping.from_combination(InputCombination([one]), "keyboard", "1"), ) self.assertEqual( loaded.get_mapping(InputCombination([two])), Mapping.from_combination(InputCombination([two]), "keyboard", "2"), ) self.assertEqual( loaded.get_mapping(InputCombination([two, three])), Mapping.from_combination(InputCombination([two, three]), "keyboard", "3"), ) # load missing file preset = Preset(PathUtils.get_config_path("missing_file.json")) self.assertRaises(FileNotFoundError, preset.load) def test_modify_mapping(self): ev_1 = InputCombination([InputConfig(type=EV_KEY, code=1)]) ev_3 = InputCombination([InputConfig(type=EV_KEY, code=2)]) # only values between -99 and 99 are allowed as mapping for EV_ABS or EV_REL ev_4 = InputCombination([InputConfig(type=EV_ABS, code=1, analog_threshold=99)]) # add the first mapping self.preset.add(Mapping.from_combination(ev_1, "keyboard", "a")) self.assertTrue(self.preset.has_unsaved_changes()) self.assertEqual(len(self.preset), 1) # change ev_1 to ev_3 and change a to b mapping = self.preset.get_mapping(ev_1) mapping.input_combination = ev_3 mapping.output_symbol = "b" self.assertIsNone(self.preset.get_mapping(ev_1)) self.assertEqual( self.preset.get_mapping(ev_3), Mapping.from_combination(ev_3, "keyboard", "b"), ) self.assertEqual(len(self.preset), 1) # add 4 self.preset.add(Mapping.from_combination(ev_4, "keyboard", "c")) self.assertEqual( self.preset.get_mapping(ev_3), Mapping.from_combination(ev_3, "keyboard", "b"), ) self.assertEqual( self.preset.get_mapping(ev_4), Mapping.from_combination(ev_4, "keyboard", "c"), ) self.assertEqual(len(self.preset), 2) # change the preset of 4 to d mapping = self.preset.get_mapping(ev_4) mapping.output_symbol = "d" self.assertEqual( self.preset.get_mapping(ev_4), Mapping.from_combination(ev_4, "keyboard", "d"), ) self.assertEqual(len(self.preset), 2) # try to change combination of 4 to 3 mapping = self.preset.get_mapping(ev_4) with self.assertRaises(KeyError): mapping.input_combination = ev_3 self.assertEqual( self.preset.get_mapping(ev_3), Mapping.from_combination(ev_3, "keyboard", "b"), ) self.assertEqual( self.preset.get_mapping(ev_4), Mapping.from_combination(ev_4, "keyboard", "d"), ) self.assertEqual(len(self.preset), 2) def test_avoids_redundant_saves(self): with patch.object(self.preset, "has_unsaved_changes", lambda: False): self.preset.path = PathUtils.get_preset_path("foo", "bar2") self.preset.add(Mapping.from_combination()) self.preset.save() with open(PathUtils.get_preset_path("foo", "bar2"), "r") as f: content = f.read() self.assertFalse(content) def test_combinations(self): ev_1 = InputConfig(type=EV_KEY, code=1, analog_threshold=111) ev_2 = InputConfig(type=EV_KEY, code=1, analog_threshold=222) ev_3 = InputConfig(type=EV_KEY, code=2, analog_threshold=111) ev_4 = InputConfig(type=EV_ABS, code=1, analog_threshold=99) combi_1 = InputCombination((ev_1, ev_2, ev_3)) combi_2 = InputCombination((ev_2, ev_1, ev_3)) combi_3 = InputCombination((ev_1, ev_2, ev_4)) self.preset.add(Mapping.from_combination(combi_1, "keyboard", "a")) self.assertEqual( self.preset.get_mapping(combi_1), Mapping.from_combination(combi_1, "keyboard", "a"), ) self.assertEqual( self.preset.get_mapping(combi_2), Mapping.from_combination(combi_1, "keyboard", "a"), ) # since combi_1 and combi_2 are equivalent, this raises a KeyError self.assertRaises( KeyError, self.preset.add, Mapping.from_combination(combi_2, "keyboard", "b"), ) self.assertEqual( self.preset.get_mapping(combi_1), Mapping.from_combination(combi_1, "keyboard", "a"), ) self.assertEqual( self.preset.get_mapping(combi_2), Mapping.from_combination(combi_1, "keyboard", "a"), ) self.preset.add(Mapping.from_combination(combi_3, "keyboard", "c")) self.assertEqual( self.preset.get_mapping(combi_1), Mapping.from_combination(combi_1, "keyboard", "a"), ) self.assertEqual( self.preset.get_mapping(combi_2), Mapping.from_combination(combi_1, "keyboard", "a"), ) self.assertEqual( self.preset.get_mapping(combi_3), Mapping.from_combination(combi_3, "keyboard", "c"), ) mapping = self.preset.get_mapping(combi_1) mapping.output_symbol = "c" with self.assertRaises(KeyError): mapping.input_combination = combi_3 self.assertEqual( self.preset.get_mapping(combi_1), Mapping.from_combination(combi_1, "keyboard", "c"), ) self.assertEqual( self.preset.get_mapping(combi_2), Mapping.from_combination(combi_1, "keyboard", "c"), ) self.assertEqual( self.preset.get_mapping(combi_3), Mapping.from_combination(combi_3, "keyboard", "c"), ) def test_remove(self): # does nothing ev_1 = InputCombination([InputConfig(type=EV_KEY, code=40)]) ev_2 = InputCombination([InputConfig(type=EV_KEY, code=30)]) ev_3 = InputCombination([InputConfig(type=EV_KEY, code=20)]) ev_4 = InputCombination([InputConfig(type=EV_KEY, code=10)]) self.assertRaises(TypeError, self.preset.remove, (EV_KEY, 10, 1)) self.preset.remove(ev_1) self.assertFalse(self.preset.has_unsaved_changes()) self.assertEqual(len(self.preset), 0) self.preset.add(Mapping.from_combination(input_combination=ev_1)) self.assertEqual(len(self.preset), 1) self.preset.remove(ev_1) self.assertEqual(len(self.preset), 0) self.preset.add(Mapping.from_combination(ev_4, "keyboard", "KEY_KP1")) self.assertTrue(self.preset.has_unsaved_changes()) self.preset.add(Mapping.from_combination(ev_3, "keyboard", "KEY_KP2")) self.preset.add(Mapping.from_combination(ev_2, "keyboard", "KEY_KP3")) self.assertEqual(len(self.preset), 3) self.preset.remove(ev_3) self.assertEqual(len(self.preset), 2) self.assertEqual( self.preset.get_mapping(ev_4), Mapping.from_combination(ev_4, "keyboard", "KEY_KP1"), ) self.assertIsNone(self.preset.get_mapping(ev_3)) self.assertEqual( self.preset.get_mapping(ev_2), Mapping.from_combination(ev_2, "keyboard", "KEY_KP3"), ) def test_empty(self): self.preset.add( Mapping.from_combination( InputCombination([InputConfig(type=EV_KEY, code=10)]), "keyboard", "1", ), ) self.preset.add( Mapping.from_combination( InputCombination([InputConfig(type=EV_KEY, code=11)]), "keyboard", "2", ), ) self.preset.add( Mapping.from_combination( InputCombination([InputConfig(type=EV_KEY, code=12)]), "keyboard", "3", ), ) self.assertEqual(len(self.preset), 3) self.preset.path = PathUtils.get_config_path("test.json") self.preset.save() self.assertFalse(self.preset.has_unsaved_changes()) self.preset.empty() self.assertEqual(self.preset.path, PathUtils.get_config_path("test.json")) self.assertTrue(self.preset.has_unsaved_changes()) self.assertEqual(len(self.preset), 0) def test_clear(self): self.preset.add( Mapping.from_combination( InputCombination([InputConfig(type=EV_KEY, code=10)]), "keyboard", "1", ), ) self.preset.add( Mapping.from_combination( InputCombination([InputConfig(type=EV_KEY, code=11)]), "keyboard", "2", ), ) self.preset.add( Mapping.from_combination( InputCombination([InputConfig(type=EV_KEY, code=12)]), "keyboard", "3", ), ) self.assertEqual(len(self.preset), 3) self.preset.path = PathUtils.get_config_path("test.json") self.preset.save() self.assertFalse(self.preset.has_unsaved_changes()) self.preset.clear() self.assertFalse(self.preset.has_unsaved_changes()) self.assertIsNone(self.preset.path) self.assertEqual(len(self.preset), 0) def test_dangerously_mapped_btn_left(self): # btn left is mapped self.preset.add( Mapping.from_combination( InputCombination([InputConfig.btn_left()]), "keyboard", "1", ) ) self.assertTrue(self.preset.dangerously_mapped_btn_left()) self.preset.add( Mapping.from_combination( InputCombination([InputConfig(type=EV_KEY, code=41)]), "keyboard", "2", ) ) self.assertTrue(self.preset.dangerously_mapped_btn_left()) # another mapping maps to btn_left self.preset.add( Mapping.from_combination( InputCombination([InputConfig(type=EV_KEY, code=42)]), "mouse", "btn_left", ) ) self.assertFalse(self.preset.dangerously_mapped_btn_left()) mapping = self.preset.get_mapping( InputCombination([InputConfig(type=EV_KEY, code=42)]) ) mapping.output_symbol = "BTN_Left" self.assertFalse(self.preset.dangerously_mapped_btn_left()) mapping.target_uinput = "keyboard + mouse" mapping.output_symbol = "3" self.assertTrue(self.preset.dangerously_mapped_btn_left()) # btn_left is not mapped self.preset.remove(InputCombination([InputConfig.btn_left()])) self.assertFalse(self.preset.dangerously_mapped_btn_left()) def test_save_load_with_invalid_mappings(self): ui_preset = Preset( PathUtils.get_config_path("test.json"), mapping_factory=UIMapping ) ui_preset.add(UIMapping()) self.assertFalse(ui_preset.is_valid()) # make the mapping valid m = ui_preset.get_mapping(InputCombination.empty_combination()) m.output_symbol = "a" m.target_uinput = "keyboard" self.assertTrue(ui_preset.is_valid()) m2 = UIMapping( input_combination=InputCombination([InputConfig(type=1, code=2)]) ) ui_preset.add(m2) self.assertFalse(ui_preset.is_valid()) ui_preset.save() # only the valid preset is loaded preset = Preset(PathUtils.get_config_path("test.json")) preset.load() self.assertEqual(len(preset), 1) a = preset.get_mapping(m.input_combination).dict() b = m.dict() a.pop("mapping_type") b.pop("mapping_type") self.assertEqual(a, b) # self.assertEqual(preset.get_mapping(m.input_combination), m) # both presets load ui_preset.clear() ui_preset.path = PathUtils.get_config_path("test.json") ui_preset.load() self.assertEqual(len(ui_preset), 2) a = ui_preset.get_mapping(m.input_combination).dict() b = m.dict() a.pop("mapping_type") b.pop("mapping_type") self.assertEqual(a, b) # self.assertEqual(ui_preset.get_mapping(m.input_combination), m) self.assertEqual(ui_preset.get_mapping(m2.input_combination), m2) if __name__ == "__main__": unittest.main() input-remapper-2.1.1/tests/unit/test_reader.py000066400000000000000000001134031475433465200214760ustar00rootroot00000000000000#!/usr/bin/env python3 # -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings # Copyright (C) 2025 sezanzeb # # This file is part of input-remapper. # # input-remapper is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # input-remapper is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with input-remapper. If not, see . import asyncio import json import multiprocessing import os import time import unittest from typing import List, Optional from unittest import mock from unittest.mock import patch, MagicMock from evdev.ecodes import ( EV_KEY, EV_ABS, ABS_HAT0X, KEY_COMMA, BTN_TOOL_DOUBLETAP, KEY_A, REL_WHEEL, REL_X, ABS_X, REL_HWHEEL, BTN_LEFT, ) from inputremapper.configs.input_config import InputCombination, InputConfig from inputremapper.groups import _Groups, DeviceType from inputremapper.gui.messages.message_broker import ( MessageBroker, Signal, ) from inputremapper.gui.messages.message_data import CombinationRecorded from inputremapper.gui.messages.message_types import MessageType from inputremapper.gui.reader_client import ReaderClient from inputremapper.gui.reader_service import ReaderService, ContextDummy from inputremapper.injection.global_uinputs import GlobalUInputs, UInput, FrontendUInput from inputremapper.input_event import InputEvent from tests.lib.constants import ( EVENT_READ_TIMEOUT, START_READING_DELAY, MAX_ABS, MIN_ABS, ) from tests.lib.fixtures import fixtures from tests.lib.fixtures import new_event from tests.lib.pipes import push_event, push_events from tests.lib.spy import spy from tests.lib.test_setup import test_setup CODE_1 = 100 CODE_2 = 101 CODE_3 = 102 class Listener: def __init__(self): self.calls: List = [] def __call__(self, data): self.calls.append(data) def wait(func, timeout=1.0): """Wait for func to return True.""" iterations = 0 sleepytime = 0.1 while not func(): time.sleep(sleepytime) iterations += 1 if iterations * sleepytime > timeout: break @test_setup class TestReaderAsyncio(unittest.IsolatedAsyncioTestCase): def setUp(self): self.reader_service = None self.groups = _Groups() self.message_broker = MessageBroker() self.reader_client = ReaderClient(self.message_broker, self.groups) def tearDown(self): try: self.reader_client.terminate() except (BrokenPipeError, OSError): pass async def create_reader_service(self, groups: Optional[_Groups] = None): # this will cause pending events to be copied over to the reader-service # process if not groups: groups = self.groups global_uinputs = GlobalUInputs(UInput) assert groups is not None self.reader_service = ReaderService(groups, global_uinputs) asyncio.ensure_future(self.reader_service.run()) async def test_should_forward_to_dummy(self): # It forwards to a ForwardDummy, because the gui process # 1. can't inject and # 2. is not even supposed to inject anything # thanks to not using multiprocessing as opposed to the other tests, we can # access this stuff context = None original_create_event_pipeline = ReaderService._create_event_pipeline def remember_context(*args, **kwargs): nonlocal context context = original_create_event_pipeline(*args, **kwargs) return context with mock.patch.object( ReaderService, "_create_event_pipeline", remember_context, ): await self.create_reader_service() listener = Listener() self.message_broker.subscribe(MessageType.combination_recorded, listener) self.reader_client.set_group(self.groups.find(key="Foo Device 2")) self.reader_client.start_recorder() await asyncio.sleep(0.1) self.assertIsInstance(context, ContextDummy) with spy( context.forward_dummy, "write", ) as write_spy: events = [InputEvent.rel(REL_X, -1)] push_events(fixtures.foo_device_2_mouse, events) await asyncio.sleep(0.1) self.reader_client._read() self.assertEqual(0, len(listener.calls)) # we want `write` to be called on the forward_dummy, because we want # those events to just disappear. self.assertEqual(write_spy.call_count, len(events)) self.assertEqual([call[0] for call in write_spy.call_args_list], events) @test_setup class TestReaderMultiprocessing(unittest.TestCase): def setUp(self): self.reader_service_process = None self.groups = _Groups() self.message_broker = MessageBroker() self.global_uinputs = GlobalUInputs(UInput) self.reader_client = ReaderClient(self.message_broker, self.groups) def tearDown(self): try: self.reader_client.terminate() except (BrokenPipeError, OSError): pass if self.reader_service_process is not None: self.reader_service_process.join(timeout=1) if self.reader_service_process.is_alive(): self.reader_service_process.terminate() def create_reader_service(self, groups: Optional[_Groups] = None): # this will cause pending events to be copied over to the reader-service # process if not groups: groups = self.groups def start_reader_service(): # this is a new process, so create a new event loop, and all dependencies # from scratch, or something loop = asyncio.new_event_loop() asyncio.set_event_loop(loop) global_uinputs = GlobalUInputs(FrontendUInput) global_uinputs.reset() reader_service = ReaderService(groups, global_uinputs) loop.run_until_complete(reader_service.run()) self.reader_service_process = multiprocessing.Process( target=start_reader_service ) self.reader_service_process.start() time.sleep(0.1) def test_reading(self): l1 = Listener() l2 = Listener() self.message_broker.subscribe(MessageType.combination_recorded, l1) self.message_broker.subscribe(MessageType.recording_finished, l2) self.create_reader_service() self.reader_client.set_group(self.groups.find(key="Foo Device 2")) self.reader_client.start_recorder() push_events(fixtures.foo_device_2_gamepad, [InputEvent.abs(ABS_HAT0X, 1)]) # we need to sleep because we have two different fixtures, # which will lead to race conditions time.sleep(0.1) # relative axis events should be released automagically after 0.3s push_events(fixtures.foo_device_2_mouse, [InputEvent.rel(REL_X, 5)]) time.sleep(0.1) # read all pending events. Having a glib mainloop would be better, # as it would call read automatically periodically self.reader_client._read() self.assertEqual( [ CombinationRecorded( InputCombination( [ InputConfig( type=3, code=16, analog_threshold=1, origin_hash=fixtures.foo_device_2_gamepad.get_device_hash(), ) ] ) ), CombinationRecorded( InputCombination( [ InputConfig( type=3, code=16, analog_threshold=1, origin_hash=fixtures.foo_device_2_gamepad.get_device_hash(), ), InputConfig( type=2, code=0, analog_threshold=1, origin_hash=fixtures.foo_device_2_mouse.get_device_hash(), ), ] ) ), ], l1.calls, ) # release the hat switch should emit the recording finished event # as both the hat and relative axis are released by now push_events(fixtures.foo_device_2_gamepad, [InputEvent.abs(ABS_HAT0X, 0)]) time.sleep(0.3) self.reader_client._read() self.assertEqual([Signal(MessageType.recording_finished)], l2.calls) def test_should_release_relative_axis(self): # the timeout is set to 0.3s l1 = Listener() l2 = Listener() self.message_broker.subscribe(MessageType.combination_recorded, l1) self.message_broker.subscribe(MessageType.recording_finished, l2) self.create_reader_service() self.reader_client.set_group(self.groups.find(key="Foo Device 2")) self.reader_client.start_recorder() push_events(fixtures.foo_device_2_mouse, [InputEvent.rel(REL_X, -5)]) time.sleep(0.1) self.reader_client._read() self.assertEqual( [ CombinationRecorded( InputCombination( [ InputConfig( type=2, code=0, analog_threshold=-1, origin_hash=fixtures.foo_device_2_mouse.get_device_hash(), ) ] ) ) ], l1.calls, ) self.assertEqual([], l2.calls) # no stop recording yet time.sleep(0.3) self.reader_client._read() self.assertEqual([Signal(MessageType.recording_finished)], l2.calls) def test_should_not_trigger_at_low_speed_for_rel_axis(self): l1 = Listener() self.message_broker.subscribe(MessageType.combination_recorded, l1) self.create_reader_service() self.reader_client.set_group(self.groups.find(key="Foo Device 2")) self.reader_client.start_recorder() push_events(fixtures.foo_device_2_mouse, [InputEvent.rel(REL_X, -1)]) time.sleep(0.1) self.reader_client._read() self.assertEqual(0, len(l1.calls)) def test_should_trigger_wheel_at_low_speed(self): l1 = Listener() self.message_broker.subscribe(MessageType.combination_recorded, l1) self.create_reader_service() self.reader_client.set_group(self.groups.find(key="Foo Device 2")) self.reader_client.start_recorder() push_events( fixtures.foo_device_2_mouse, [InputEvent.rel(REL_WHEEL, -1), InputEvent.rel(REL_HWHEEL, 1)], ) time.sleep(0.1) self.reader_client._read() self.assertEqual( [ CombinationRecorded( InputCombination( [ InputConfig( type=2, code=8, analog_threshold=-1, origin_hash=fixtures.foo_device_2_mouse.get_device_hash(), ) ] ) ), CombinationRecorded( InputCombination( [ InputConfig( type=2, code=8, analog_threshold=-1, origin_hash=fixtures.foo_device_2_mouse.get_device_hash(), ), InputConfig( type=2, code=6, analog_threshold=1, origin_hash=fixtures.foo_device_2_mouse.get_device_hash(), ), ] ) ), ], l1.calls, ) def test_wont_emit_the_same_combination_twice(self): l1 = Listener() self.message_broker.subscribe(MessageType.combination_recorded, l1) self.create_reader_service() self.reader_client.set_group(self.groups.find(key="Foo Device 2")) self.reader_client.start_recorder() push_events(fixtures.foo_device_2_keyboard, [InputEvent.key(KEY_A, 1)]) time.sleep(0.1) self.reader_client._read() # the duplicate event should be ignored push_events(fixtures.foo_device_2_keyboard, [InputEvent.key(KEY_A, 1)]) time.sleep(0.1) self.reader_client._read() self.assertEqual( [ CombinationRecorded( InputCombination( [ InputConfig( type=1, code=30, analog_threshold=1, origin_hash=fixtures.foo_device_2_keyboard.get_device_hash(), ) ] ) ) ], l1.calls, ) def test_should_read_absolut_axis(self): l1 = Listener() l2 = Listener() self.message_broker.subscribe(MessageType.combination_recorded, l1) self.message_broker.subscribe(MessageType.recording_finished, l2) self.create_reader_service() self.reader_client.set_group(self.groups.find(key="Foo Device 2")) self.reader_client.start_recorder() # over 30% should trigger push_events( fixtures.foo_device_2_gamepad, [InputEvent.abs(ABS_X, int(MAX_ABS * 0.4))], ) time.sleep(0.1) self.reader_client._read() self.assertEqual( [ CombinationRecorded( InputCombination( [ InputConfig( type=3, code=0, analog_threshold=1, origin_hash=fixtures.foo_device_2_gamepad.get_device_hash(), ) ] ) ) ], l1.calls, ) self.assertEqual([], l2.calls) # no stop recording yet # less the 30% should release push_events( fixtures.foo_device_2_gamepad, [InputEvent.abs(ABS_X, int(MAX_ABS * 0.2))], ) time.sleep(0.1) self.reader_client._read() self.assertEqual( [ CombinationRecorded( InputCombination( [ InputConfig( type=3, code=0, analog_threshold=1, origin_hash=fixtures.foo_device_2_gamepad.get_device_hash(), ) ] ) ) ], l1.calls, ) self.assertEqual([Signal(MessageType.recording_finished)], l2.calls) def test_should_change_direction(self): l1 = Listener() self.message_broker.subscribe(MessageType.combination_recorded, l1) self.create_reader_service() self.reader_client.set_group(self.groups.find(key="Foo Device 2")) self.reader_client.start_recorder() push_event(fixtures.foo_device_2_keyboard, InputEvent.key(KEY_A, 1)) time.sleep(0.1) push_event( fixtures.foo_device_2_gamepad, InputEvent.abs(ABS_X, int(MAX_ABS * 0.4)) ) time.sleep(0.1) push_event(fixtures.foo_device_2_keyboard, InputEvent.key(KEY_COMMA, 1)) time.sleep(0.1) push_events( fixtures.foo_device_2_gamepad, [ InputEvent.abs(ABS_X, int(MAX_ABS * 0.1)), InputEvent.abs(ABS_X, int(MIN_ABS * 0.4)), ], ) time.sleep(0.1) self.reader_client._read() self.assertEqual( [ CombinationRecorded( InputCombination( [ InputConfig( type=EV_KEY, code=KEY_A, origin_hash=fixtures.foo_device_2_keyboard.get_device_hash(), ) ] ) ), CombinationRecorded( InputCombination( [ InputConfig( type=EV_KEY, code=KEY_A, origin_hash=fixtures.foo_device_2_keyboard.get_device_hash(), ), InputConfig( type=EV_ABS, code=ABS_X, analog_threshold=1, origin_hash=fixtures.foo_device_2_gamepad.get_device_hash(), ), ] ) ), CombinationRecorded( InputCombination( [ InputConfig( type=EV_KEY, code=KEY_A, origin_hash=fixtures.foo_device_2_keyboard.get_device_hash(), ), InputConfig( type=EV_ABS, code=ABS_X, analog_threshold=1, origin_hash=fixtures.foo_device_2_gamepad.get_device_hash(), ), InputConfig( type=EV_KEY, code=KEY_COMMA, origin_hash=fixtures.foo_device_2_keyboard.get_device_hash(), ), ] ) ), CombinationRecorded( InputCombination( [ InputConfig( type=EV_KEY, code=KEY_A, origin_hash=fixtures.foo_device_2_keyboard.get_device_hash(), ), InputConfig( type=EV_ABS, code=ABS_X, analog_threshold=-1, origin_hash=fixtures.foo_device_2_gamepad.get_device_hash(), ), InputConfig( type=EV_KEY, code=KEY_COMMA, origin_hash=fixtures.foo_device_2_keyboard.get_device_hash(), ), ] ) ), ], l1.calls, ) def test_change_device(self): l1 = Listener() self.message_broker.subscribe(MessageType.combination_recorded, l1) push_events( fixtures.foo_device_2_keyboard, [ InputEvent.key(1, 1), ] * 10, ) push_events( fixtures.bar_device, [ InputEvent.key(2, 1), InputEvent.key(2, 0), ] * 3, ) self.create_reader_service() self.reader_client.set_group(self.groups.find(key="Foo Device 2")) self.reader_client.start_recorder() time.sleep(0.1) self.reader_client._read() self.assertEqual( l1.calls[0].combination, InputCombination( [ InputConfig( type=EV_KEY, code=1, origin_hash=fixtures.foo_device_2_keyboard.get_device_hash(), ) ] ), ) self.reader_client.set_group(self.groups.find(name="Bar Device")) time.sleep(0.1) self.reader_client._read() # we did not get the event from the "Bar Device" because the group change # stopped the recording self.assertEqual(len(l1.calls), 1) self.reader_client.start_recorder() push_events(fixtures.bar_device, [InputEvent.key(2, 1)]) time.sleep(0.1) self.reader_client._read() self.assertEqual( l1.calls[1].combination, InputCombination( [ InputConfig( type=EV_KEY, code=2, origin_hash=fixtures.bar_device.get_device_hash(), ) ] ), ) def test_reading_2(self): l1 = Listener() self.message_broker.subscribe(MessageType.combination_recorded, l1) # a combination of events push_events( fixtures.foo_device_2_keyboard, [ new_event(EV_KEY, CODE_1, 1, 10000.1234), new_event(EV_KEY, CODE_3, 1, 10001.1234), ], ) pipe = multiprocessing.Pipe() def refresh(): # from within the reader-service process notify this test that # refresh was called as expected pipe[1].send("refreshed") groups = _Groups() groups.refresh = refresh self.create_reader_service(groups) self.reader_client.set_group(self.groups.find(key="Foo Device 2")) self.reader_client.start_recorder() # sending anything arbitrary does not stop the reader-service self.reader_client._commands_pipe.send(856794) time.sleep(0.2) push_events( fixtures.foo_device_2_gamepad, [new_event(EV_ABS, ABS_HAT0X, -1, 10002.1234)], ) time.sleep(0.1) # but it makes it look for new devices because maybe its list of # self.groups is not up-to-date self.assertTrue(pipe[0].poll()) self.assertEqual(pipe[0].recv(), "refreshed") self.reader_client._read() self.assertEqual( l1.calls[-1].combination, InputCombination( [ InputConfig( type=EV_KEY, code=CODE_1, origin_hash=fixtures.foo_device_2_keyboard.get_device_hash(), ), InputConfig( type=EV_KEY, code=CODE_3, origin_hash=fixtures.foo_device_2_keyboard.get_device_hash(), ), InputConfig( type=EV_ABS, code=ABS_HAT0X, analog_threshold=-1, origin_hash=fixtures.foo_device_2_gamepad.get_device_hash(), ), ] ), ) def test_blacklisted_events(self): l1 = Listener() self.message_broker.subscribe(MessageType.combination_recorded, l1) push_events( fixtures.foo_device_2_mouse, [ InputEvent.key(BTN_TOOL_DOUBLETAP, 1), InputEvent.key(BTN_LEFT, 1), InputEvent.key(BTN_TOOL_DOUBLETAP, 1), ], force=True, ) self.create_reader_service() self.reader_client.set_group(self.groups.find(key="Foo Device 2")) self.reader_client.start_recorder() time.sleep(0.1) self.reader_client._read() self.assertEqual( l1.calls[-1].combination, InputCombination( [ InputConfig( type=EV_KEY, code=BTN_LEFT, origin_hash=fixtures.foo_device_2_mouse.get_device_hash(), ) ] ), ) def test_ignore_value_2(self): l1 = Listener() self.message_broker.subscribe(MessageType.combination_recorded, l1) # this is not a combination, because (EV_KEY CODE_3, 2) is ignored push_events( fixtures.foo_device_2_gamepad, [InputEvent.abs(ABS_HAT0X, 1), InputEvent.key(CODE_3, 2)], force=True, ) self.create_reader_service() self.reader_client.set_group(self.groups.find(key="Foo Device 2")) self.reader_client.start_recorder() time.sleep(0.2) self.reader_client._read() self.assertEqual( l1.calls[-1].combination, InputCombination( [ InputConfig( type=EV_ABS, code=ABS_HAT0X, analog_threshold=1, origin_hash=fixtures.foo_device_2_gamepad.get_device_hash(), ) ] ), ) def test_reading_ignore_up(self): l1 = Listener() self.message_broker.subscribe(MessageType.combination_recorded, l1) push_events( fixtures.foo_device_2_keyboard, [ new_event(EV_KEY, CODE_1, 0, 10), new_event(EV_KEY, CODE_2, 1, 11), new_event(EV_KEY, CODE_3, 0, 12), ], ) self.create_reader_service() self.reader_client.set_group(self.groups.find(key="Foo Device 2")) self.reader_client.start_recorder() time.sleep(0.1) self.reader_client._read() self.assertEqual( l1.calls[-1].combination, InputCombination( [ InputConfig( type=EV_KEY, code=CODE_2, origin_hash=fixtures.foo_device_2_keyboard.get_device_hash(), ) ] ), ) def test_wrong_device(self): l1 = Listener() self.message_broker.subscribe(MessageType.combination_recorded, l1) push_events( fixtures.foo_device_2_keyboard, [ InputEvent.key(CODE_1, 1), InputEvent.key(CODE_2, 1), InputEvent.key(CODE_3, 1), ], ) self.create_reader_service() self.reader_client.set_group(self.groups.find(name="Bar Device")) self.reader_client.start_recorder() time.sleep(EVENT_READ_TIMEOUT * 5) self.reader_client._read() self.assertEqual(len(l1.calls), 0) def test_inputremapper_devices(self): # Don't read from inputremapper devices, their keycodes are not # representative for the original key. As long as this is not # intentionally programmed it won't even do that. But it was at some # point. l1 = Listener() self.message_broker.subscribe(MessageType.combination_recorded, l1) push_events( fixtures.input_remapper_bar_device, [ InputEvent.key(CODE_1, 1), InputEvent.key(CODE_2, 1), InputEvent.key(CODE_3, 1), ], ) self.create_reader_service() self.reader_client.set_group(self.groups.find(name="Bar Device")) self.reader_client.start_recorder() time.sleep(EVENT_READ_TIMEOUT * 5) self.reader_client._read() self.assertEqual(len(l1.calls), 0) def test_terminate(self): self.create_reader_service() self.reader_client.set_group(self.groups.find(key="Foo Device 2")) push_events(fixtures.foo_device_2_keyboard, [InputEvent.key(CODE_3, 1)]) time.sleep(START_READING_DELAY + EVENT_READ_TIMEOUT) self.assertTrue(self.reader_client._results_pipe.poll()) self.reader_client.terminate() time.sleep(EVENT_READ_TIMEOUT) self.assertFalse(self.reader_client._results_pipe.poll()) # no new events arrive after terminating push_events(fixtures.foo_device_2_keyboard, [InputEvent.key(CODE_3, 1)]) time.sleep(EVENT_READ_TIMEOUT * 3) self.assertFalse(self.reader_client._results_pipe.poll()) def test_are_new_groups_available(self): l1 = Listener() self.message_broker.subscribe(MessageType.groups, l1) self.create_reader_service() self.reader_client.groups.set_groups([]) time.sleep(0.1) # let the reader-service send the groups # read stuff from the reader-service, which includes the devices self.assertEqual("[]", self.reader_client.groups.dumps()) self.reader_client._read() self.assertEqual( self.reader_client.groups.dumps(), json.dumps( [ json.dumps( { "paths": [ "/dev/input/event1", ], "names": ["Foo Device"], "types": [DeviceType.KEYBOARD], "key": "Foo Device", } ), json.dumps( { "paths": [ "/dev/input/event11", "/dev/input/event10", "/dev/input/event13", "/dev/input/event15", ], "names": [ "Foo Device foo", "Foo Device", "Foo Device", "Foo Device bar", ], "types": [ DeviceType.GAMEPAD, DeviceType.KEYBOARD, DeviceType.MOUSE, ], "key": "Foo Device 2", } ), json.dumps( { "paths": ["/dev/input/event20"], "names": ["Bar Device"], "types": [DeviceType.KEYBOARD], "key": "Bar Device", } ), json.dumps( { "paths": ["/dev/input/event30"], "names": ["gamepad"], "types": [DeviceType.GAMEPAD], "key": "gamepad", } ), json.dumps( { "paths": ["/dev/input/event40"], "names": ["input-remapper Bar Device"], "types": [DeviceType.KEYBOARD], "key": "input-remapper Bar Device", } ), json.dumps( { "paths": ["/dev/input/event52"], "names": ["Qux/[Device]?"], "types": [DeviceType.KEYBOARD], "key": "Qux/[Device]?", } ), ] ), ) self.assertEqual(len(l1.calls), 1) # ensure we got the event def test_starts_the_service(self): # if ReaderClient can't see the ReaderService, a new ReaderService should # be started via pkexec with patch.object(ReaderService, "is_running", lambda: False): os_system_mock = MagicMock(return_value=0) with patch.object(os, "system", os_system_mock): # the status message enables the reader-client to see, that the # reader-service has started self.reader_client._results_pipe.send( {"type": "status", "message": "ready"} ) self.reader_client._send_command("foo") os_system_mock.assert_called_once_with( "pkexec input-remapper-control --command start-reader-service -d" ) def test_wont_start_the_service(self): # already running, no call to os.system with patch.object(ReaderService, "is_running", lambda: True): mock = MagicMock(return_value=0) with patch.object(os, "system", mock): self.reader_client._send_command("foo") mock.assert_not_called() def test_reader_service_wont_start(self): # test for the "The reader-service did not start" message expected_msg = "The reader-service did not start" subscribe_mock = MagicMock() self.message_broker.subscribe(MessageType.status_msg, subscribe_mock) with patch.object(ReaderClient, "_timeout", 1): with patch.object(ReaderService, "is_running", lambda: False): os_system_mock = MagicMock(return_value=0) with patch.object(os, "system", os_system_mock): self.reader_client._send_command("foo") # no message is sent into _results_pipe, so the reader-client will # think the reader-service didn't manage to start os_system_mock.assert_called_once_with( "pkexec input-remapper-control " "--command start-reader-service -d" ) subscribe_mock.assert_called_once() status = subscribe_mock.call_args[0][0] self.assertEqual(status.msg, expected_msg) def test_reader_service_times_out(self): # after some time the reader-service just stops, to avoid leaving a hole # that exposes user-input forever with patch.object(ReaderService, "_maximum_lifetime", 1): self.create_reader_service() self.assertTrue(self.reader_service_process.is_alive()) time.sleep(0.5) self.assertTrue(self.reader_service_process.is_alive()) time.sleep(1) self.assertFalse(self.reader_service_process.is_alive()) def test_reader_service_waits_for_client_to_finish(self): # if the client is currently reading, it waits a bit longer until the # client finishes reading with patch.object(ReaderService, "_maximum_lifetime", 1): self.create_reader_service() self.assertTrue(self.reader_service_process.is_alive()) self.reader_client.set_group(self.groups.find(key="Foo Device 2")) self.reader_client.start_recorder() time.sleep(2) # still alive, without start_recorder it should have already exited self.assertTrue(self.reader_service_process.is_alive()) self.reader_client.stop_recorder() time.sleep(1) self.assertFalse(self.reader_service_process.is_alive()) def test_reader_service_wont_wait_forever(self): # if the client is reading forever, stop it after another timeout with patch.object(ReaderService, "_maximum_lifetime", 1): with patch.object(ReaderService, "_timeout_tolerance", 1): self.create_reader_service() self.assertTrue(self.reader_service_process.is_alive()) self.reader_client.set_group(self.groups.find(key="Foo Device 2")) self.reader_client.start_recorder() time.sleep(1.5) # still alive, without start_recorder it should have already exited self.assertTrue(self.reader_service_process.is_alive()) time.sleep(1) # now it stopped, even though the reader is still reading self.assertFalse(self.reader_service_process.is_alive()) def test_reader_service_stops_with_broken_pipes(self): with patch.object(ReaderService, "pipes_exist", side_effect=lambda: False): self.create_reader_service() self.assertTrue(self.reader_service_process.is_alive()) time.sleep(0.5) # still alive self.assertTrue(self.reader_service_process.is_alive()) time.sleep(1) # now it stopped self.assertFalse(self.reader_service_process.is_alive()) # It will not stop if pipes are ok with patch.object(ReaderService, "pipes_exist", side_effect=lambda: True): self.create_reader_service() self.assertTrue(self.reader_service_process.is_alive()) time.sleep(1.5) # still alive self.assertTrue(self.reader_service_process.is_alive()) if __name__ == "__main__": unittest.main() input-remapper-2.1.1/tests/unit/test_system_mapping.py000066400000000000000000000141341475433465200232740ustar00rootroot00000000000000#!/usr/bin/env python3 # -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings # Copyright (C) 2025 sezanzeb # # This file is part of input-remapper. # # input-remapper is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # input-remapper is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with input-remapper. If not, see . import json import os import subprocess import unittest from unittest.mock import patch from evdev.ecodes import BTN_LEFT, KEY_A from inputremapper.configs.paths import PathUtils from inputremapper.configs.keyboard_layout import KeyboardLayout, XMODMAP_FILENAME from tests.lib.test_setup import test_setup @test_setup class TestSystemMapping(unittest.TestCase): def test_update(self): keyboard_layout = KeyboardLayout() keyboard_layout.update({"foo1": 101, "bar1": 102}) keyboard_layout.update({"foo2": 201, "bar2": 202}) self.assertEqual(keyboard_layout.get("foo1"), 101) self.assertEqual(keyboard_layout.get("bar2"), 202) def test_xmodmap_file(self): keyboard_layout = KeyboardLayout() path = os.path.join(PathUtils.config_path(), XMODMAP_FILENAME) os.remove(path) keyboard_layout.populate() self.assertTrue(os.path.exists(path)) with open(path, "r") as file: content = json.load(file) self.assertEqual(content["a"], KEY_A) # only xmodmap stuff should be present self.assertNotIn("key_a", content) self.assertNotIn("KEY_A", content) self.assertNotIn("disable", content) def test_empty_xmodmap(self): # if xmodmap returns nothing, don't write the file empty_xmodmap = "" class SubprocessMock: def decode(self): return empty_xmodmap def check_output(*args, **kwargs): return SubprocessMock() with patch.object(subprocess, "check_output", check_output): keyboard_layout = KeyboardLayout() path = os.path.join(PathUtils.config_path(), XMODMAP_FILENAME) os.remove(path) keyboard_layout.populate() self.assertFalse(os.path.exists(path)) def test_xmodmap_command_missing(self): # if xmodmap is not installed, don't write the file def check_output(*args, **kwargs): raise FileNotFoundError with patch.object(subprocess, "check_output", check_output): keyboard_layout = KeyboardLayout() path = os.path.join(PathUtils.config_path(), XMODMAP_FILENAME) os.remove(path) keyboard_layout.populate() self.assertFalse(os.path.exists(path)) def test_correct_case(self): keyboard_layout = KeyboardLayout() keyboard_layout.clear() keyboard_layout._set("A", 31) keyboard_layout._set("a", 32) keyboard_layout._set("abcd_B", 33) self.assertEqual(keyboard_layout.correct_case("a"), "a") self.assertEqual(keyboard_layout.correct_case("A"), "A") self.assertEqual(keyboard_layout.correct_case("ABCD_b"), "abcd_B") # unknown stuff is returned as is self.assertEqual(keyboard_layout.correct_case("FOo"), "FOo") self.assertEqual(keyboard_layout.get("A"), 31) self.assertEqual(keyboard_layout.get("a"), 32) self.assertEqual(keyboard_layout.get("ABCD_b"), 33) self.assertEqual(keyboard_layout.get("abcd_B"), 33) def test_keyboard_layout(self): keyboard_layout = KeyboardLayout() keyboard_layout.populate() self.assertGreater(len(keyboard_layout._mapping), 100) # this is case-insensitive self.assertEqual(keyboard_layout.get("1"), 2) self.assertEqual(keyboard_layout.get("KeY_1"), 2) self.assertEqual(keyboard_layout.get("AlT_L"), 56) self.assertEqual(keyboard_layout.get("KEy_LEFtALT"), 56) self.assertEqual(keyboard_layout.get("kEY_LeFTSHIFT"), 42) self.assertEqual(keyboard_layout.get("ShiFt_L"), 42) self.assertEqual(keyboard_layout.get("BTN_left"), 272) self.assertIsNotNone(keyboard_layout.get("KEY_KP4")) self.assertEqual(keyboard_layout.get("KP_Left"), keyboard_layout.get("KEY_KP4")) # this only lists the correct casing, # includes linux constants and xmodmap symbols names = keyboard_layout.list_names() self.assertIn("2", names) self.assertIn("c", names) self.assertIn("KEY_3", names) self.assertNotIn("key_3", names) self.assertIn("KP_Down", names) self.assertNotIn("kp_down", names) names = keyboard_layout._mapping.keys() self.assertIn("F4", names) self.assertNotIn("f4", names) self.assertIn("BTN_RIGHT", names) self.assertNotIn("btn_right", names) self.assertIn("KEY_KP7", names) self.assertIn("KP_Home", names) self.assertNotIn("kp_home", names) self.assertEqual(keyboard_layout.get("disable"), -1) def test_get_name_no_xmodmap(self): # if xmodmap is not installed, uses the linux constant names keyboard_layout = KeyboardLayout() def check_output(*args, **kwargs): raise FileNotFoundError with patch.object(subprocess, "check_output", check_output): keyboard_layout.populate() self.assertEqual(keyboard_layout.get_name(KEY_A), "KEY_A") # testing for BTN_LEFT is especially important, because # `evdev.ecodes.BTN.get(code)` returns an array of ['BTN_LEFT', 'BTN_MOUSE'] self.assertEqual(keyboard_layout.get_name(BTN_LEFT), "BTN_LEFT") if __name__ == "__main__": unittest.main() input-remapper-2.1.1/tests/unit/test_test.py000066400000000000000000000127441475433465200212210ustar00rootroot00000000000000#!/usr/bin/env python3 # -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings # Copyright (C) 2025 sezanzeb # # This file is part of input-remapper. # # input-remapper is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # input-remapper is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with input-remapper. If not, see . import asyncio import multiprocessing import os import time import unittest import evdev from evdev.ecodes import EV_ABS, EV_KEY from inputremapper.groups import groups, _Groups from inputremapper.gui.messages.message_broker import MessageBroker from inputremapper.gui.reader_client import ReaderClient from inputremapper.gui.reader_service import ReaderService from inputremapper.injection.global_uinputs import UInput, GlobalUInputs from inputremapper.input_event import InputEvent from inputremapper.utils import get_device_hash from tests.lib.cleanup import cleanup from tests.lib.constants import EVENT_READ_TIMEOUT, START_READING_DELAY from tests.lib.fixtures import fixtures from tests.lib.logger import logger from tests.lib.patches import InputDevice from tests.lib.pipes import push_events from tests.lib.test_setup import test_setup @test_setup class TestTest(unittest.TestCase): def test_stubs(self): self.assertIsNotNone(groups.find(key="Foo Device 2")) def test_fake_capabilities(self): device = InputDevice("/dev/input/event30") capabilities = device.capabilities(absinfo=False) self.assertIsInstance(capabilities, dict) self.assertIsInstance(capabilities[EV_ABS], list) self.assertIsInstance(capabilities[EV_ABS][0], int) capabilities = device.capabilities() self.assertIsInstance(capabilities, dict) self.assertIsInstance(capabilities[EV_ABS], list) self.assertIsInstance(capabilities[EV_ABS][0], tuple) self.assertIsInstance(capabilities[EV_ABS][0][0], int) self.assertIsInstance(capabilities[EV_ABS][0][1], evdev.AbsInfo) self.assertIsInstance(capabilities[EV_ABS][0][1].max, int) self.assertIsInstance(capabilities, dict) self.assertIsInstance(capabilities[EV_KEY], list) self.assertIsInstance(capabilities[EV_KEY][0], int) def test_restore_fixtures(self): fixtures["/bar/dev"] = {"name": "bla"} cleanup() self.assertIsNone(fixtures.get("/bar/dev")) self.assertIsNotNone(fixtures.get("/dev/input/event11")) def test_restore_os_environ(self): os.environ["foo"] = "bar" del os.environ["USER"] environ = os.environ cleanup() self.assertIn("USER", environ) self.assertNotIn("foo", environ) def test_push_events(self): """Test that push_event works properly between reader service and client. Using push_events after the reader-service is already started should work, as well as using push_event twice """ reader_client = ReaderClient(MessageBroker(), groups) def create_reader_service(): # this will cause pending events to be copied over to the reader-service # process def start_reader_service(): # Create dependencies from scratch, because the reader-service runs # in a different process global_uinputs = GlobalUInputs(UInput) reader_service = ReaderService(_Groups(), global_uinputs) loop = asyncio.new_event_loop() loop.run_until_complete(reader_service.run()) self.reader_service = multiprocessing.Process(target=start_reader_service) self.reader_service.start() time.sleep(0.1) def wait_for_results(): # wait for the reader-service to send stuff for _ in range(10): time.sleep(EVENT_READ_TIMEOUT) if reader_client._results_pipe.poll(): break create_reader_service() reader_client.set_group(groups.find(key="Foo Device 2")) reader_client.start_recorder() time.sleep(START_READING_DELAY) event = InputEvent.key(102, 1) push_events(fixtures.foo_device_2_keyboard, [event]) wait_for_results() self.assertTrue(reader_client._results_pipe.poll()) reader_client._read() self.assertFalse(reader_client._results_pipe.poll()) # can push more events to the reader-service that is inside a separate # process, which end up being sent to the reader event = InputEvent.key(102, 0) logger.info("push_events") push_events(fixtures.foo_device_2_keyboard, [event]) wait_for_results() logger.info("assert") self.assertTrue(reader_client._results_pipe.poll()) reader_client.terminate() def test_device_hash_from_fixture_is_correct(self): for fixture in fixtures: self.assertEqual( fixture.get_device_hash(), get_device_hash(InputDevice(fixture.path)) ) if __name__ == "__main__": unittest.main() input-remapper-2.1.1/tests/unit/test_user.py000066400000000000000000000041731475433465200212150ustar00rootroot00000000000000#!/usr/bin/env python3 # -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings # Copyright (C) 2025 sezanzeb # # This file is part of input-remapper. # # input-remapper is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # input-remapper is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with input-remapper. If not, see . import os import unittest from unittest import mock from inputremapper.user import UserUtils from tests.lib.test_setup import test_setup def _raise(error): raise error @test_setup class TestUser(unittest.TestCase): def test_get_user(self): with mock.patch("os.getlogin", lambda: "foo"): self.assertEqual(UserUtils.get_user(), "foo") with mock.patch("os.getlogin", lambda: "root"): self.assertEqual(UserUtils.get_user(), "root") property_mock = mock.Mock() property_mock.configure_mock(pw_name="quix") with mock.patch("os.getlogin", lambda: _raise(OSError())), mock.patch( "pwd.getpwuid", return_value=property_mock ): os.environ["USER"] = "root" os.environ["SUDO_USER"] = "qux" self.assertEqual(UserUtils.get_user(), "qux") os.environ["USER"] = "root" del os.environ["SUDO_USER"] os.environ["PKEXEC_UID"] = "1000" self.assertNotEqual(UserUtils.get_user(), "root") def test_get_home(self): property_mock = mock.Mock() property_mock.configure_mock(pw_dir="/custom/home/foo") with mock.patch("pwd.getpwnam", return_value=property_mock): self.assertEqual(UserUtils.get_home("foo"), "/custom/home/foo") input-remapper-2.1.1/tests/unit/test_util.py000066400000000000000000000033261475433465200212130ustar00rootroot00000000000000#!/usr/bin/env python3 # -*- coding: utf-8 -*- # input-remapper - GUI for device specific keyboard mappings # Copyright (C) 2025 sezanzeb # # This file is part of input-remapper. # # input-remapper is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # input-remapper is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with input-remapper. If not, see . import unittest from evdev._ecodes import EV_ABS, ABS_X, BTN_WEST, BTN_Y, EV_KEY, KEY_A from inputremapper.utils import get_evdev_constant_name from tests.lib.test_setup import test_setup @test_setup class TestUtil(unittest.TestCase): def test_get_evdev_constant_name(self): # BTN_WEST and BTN_Y both are code 308. I don't care which one is chosen # in the return value, but it should return one of them without crashing. self.assertEqual(get_evdev_constant_name(EV_KEY, BTN_Y), "BTN_WEST") self.assertEqual(get_evdev_constant_name(EV_KEY, BTN_WEST), "BTN_WEST") self.assertEqual(get_evdev_constant_name(123, KEY_A), "unknown") self.assertEqual(get_evdev_constant_name(EV_KEY, 9999), "unknown") self.assertEqual(get_evdev_constant_name(EV_KEY, KEY_A), "KEY_A") self.assertEqual(get_evdev_constant_name(EV_ABS, ABS_X), "ABS_X")