pax_global_header00006660000000000000000000000064145021421050014504gustar00rootroot0000000000000052 comment=a012746a6b3d842943613fe80461bf50be3bc01d input-remapper-2.0.1/000077500000000000000000000000001450214210500144545ustar00rootroot00000000000000input-remapper-2.0.1/.coveragerc000066400000000000000000000006701450214210500166000ustar00rootroot00000000000000[run] branch = True source = /usr/lib/python3.10/site-packages/inputremapper concurrency = multiprocessing debug = multiproc omit = # not used currently due to problems /usr/lib/python3.9/site-packages/inputremapper/ipc/socket.py [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.0.1/.github/000077500000000000000000000000001450214210500160145ustar00rootroot00000000000000input-remapper-2.0.1/.github/FUNDING.yml000066400000000000000000000014071450214210500176330ustar00rootroot00000000000000# These are supported funding model platforms github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] patreon: # Replace with a single Patreon username open_collective: # Replace with a single Open Collective username ko_fi: jonasbosse tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry liberapay: # Replace with a single Liberapay username issuehunt: # Replace with a single IssueHunt username otechie: # Replace with a single Otechie username lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] input-remapper-2.0.1/.github/ISSUE_TEMPLATE/000077500000000000000000000000001450214210500201775ustar00rootroot00000000000000input-remapper-2.0.1/.github/ISSUE_TEMPLATE/autoloading-not-working.md000066400000000000000000000022101450214210500252760ustar00rootroot00000000000000--- 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 **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. (while the previous command is still running) `sudo evtest` and search for a device suffixed by "mapped". Select it, does it report any events? Share the output. 4. `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.0.1/.github/ISSUE_TEMPLATE/buttons-not-showing-up---can-t-map-a-key-in-the-gui.md000066400000000000000000000010421450214210500317650ustar00rootroot00000000000000--- 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.0.1/.github/ISSUE_TEMPLATE/key-not-getting-injected.md000066400000000000000000000013751450214210500253370ustar00rootroot00000000000000--- 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.0.1/.github/ISSUE_TEMPLATE/something-else-is-not-working.md000066400000000000000000000003721450214210500263330ustar00rootroot00000000000000--- 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.0.1/.github/workflows/000077500000000000000000000000001450214210500200515ustar00rootroot00000000000000input-remapper-2.0.1/.github/workflows/lint.yml000066400000000000000000000013711450214210500215440ustar00rootroot00000000000000name: 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.0.1/.github/workflows/reviewdog.yml000066400000000000000000000026721450214210500225760ustar00rootroot00000000000000--- 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-pkg_resources - 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.0.1/.github/workflows/test.yml000066400000000000000000000025431450214210500215570ustar00rootroot00000000000000name: 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.7", "3.11"] # 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 --no-binary :all: . - 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 tests/test.py --start-dir unit input-remapper-2.0.1/.gitignore000066400000000000000000000035421450214210500164500ustar00rootroot00000000000000inputremapper/commit_hash.py *.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.0.1/.mypy.ini000066400000000000000000000001601450214210500162260ustar00rootroot00000000000000 [mypy] plugins = pydantic.mypy # ignore the missing evdev stubs [mypy-evdev.*] ignore_missing_imports = True input-remapper-2.0.1/.pylintrc000066400000000000000000000007121450214210500163210ustar00rootroot00000000000000[_] 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.0.1/.reviewdog.yml000066400000000000000000000007061450214210500172530ustar00rootroot00000000000000--- 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.0.1/.run/000077500000000000000000000000001450214210500153365ustar00rootroot00000000000000input-remapper-2.0.1/.run/All Tests.run.xml000066400000000000000000000015121450214210500204550ustar00rootroot00000000000000 input-remapper-2.0.1/.run/Only Integration Tests.run.xml000066400000000000000000000015511450214210500231350ustar00rootroot00000000000000 input-remapper-2.0.1/.run/Only Unit Tests.run.xml000066400000000000000000000015331450214210500215710ustar00rootroot00000000000000 input-remapper-2.0.1/DEBIAN/000077500000000000000000000000001450214210500153765ustar00rootroot00000000000000input-remapper-2.0.1/DEBIAN/control000066400000000000000000000007371450214210500170100ustar00rootroot00000000000000Package: input-remapper Version: 2.0.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 Description: A tool to change the mapping of your input device buttons Replaces: python3-key-mapper, key-mapper Conflicts: python3-key-mapper, key-mapper input-remapper-2.0.1/DEBIAN/copyright000066400000000000000000000000611450214210500173260ustar00rootroot00000000000000Files: * Copyright: 2023 Sezanzeb License: GPL-3+input-remapper-2.0.1/DEBIAN/postinst000077500000000000000000000006301450214210500172060ustar00rootroot00000000000000#!/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 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.0.1/LICENSE000066400000000000000000001045151450214210500154670ustar00rootroot00000000000000 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.0.1/README.md000066400000000000000000000052301450214210500157330ustar00rootroot00000000000000

Input Remapper

An easy to use tool 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 ##### Manjaro/Arch ```bash yay -S input-remapper-git sudo systemctl restart input-remapper sudo systemctl enable input-remapper ``` ##### Ubuntu/Debian Get a .deb file from the [release page](https://github.com/sezanzeb/input-remapper/releases) or install the latest changes via: ```bash sudo apt install git python3-setuptools gettext git clone https://github.com/sezanzeb/input-remapper.git cd input-remapper && ./scripts/build.sh sudo apt install -f ./dist/input-remapper-2.0.1.deb ``` input-remapper is available in [Debian](https://tracker.debian.org/pkg/input-remapper) and [Ubuntu](https://packages.ubuntu.com/jammy/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 sudo systemctl start input-remapper ``` ##### Manual Dependencies: `python3-evdev` ≥1.3.0, `gtksourceview4`, `python3-devel`, `python3-pydantic`, `python3-pydbus` Python packages need to be installed globally for the service to be able to import them. Don't use `--user` Conda can cause problems due to changed python paths and versions. If it doesn't seem to install, you can also try `sudo python3 setup.py install` ```bash sudo pip install evdev -U # If newest version not in distros repo sudo pip uninstall key-mapper # In case the old package is still installed sudo pip install --no-binary :all: git+https://github.com/sezanzeb/input-remapper.git sudo systemctl enable input-remapper sudo systemctl restart input-remapper ``` ## 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.0.1/bin/000077500000000000000000000000001450214210500152245ustar00rootroot00000000000000input-remapper-2.0.1/bin/input-remapper-control000077500000000000000000000237141450214210500216070ustar00rootroot00000000000000#!/usr/bin/python3 # -*- 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 . """Control the dbus service from the command line.""" import os import sys import argparse import logging import subprocess from inputremapper.logger import logger, update_verbosity, log_info from inputremapper.configs.migrations import migrate from inputremapper.configs.global_config import global_config # import inputremapper modules as late as possible to make sure the correct # log level is applied before anything is logged AUTOLOAD = 'autoload' START = 'start' STOP = 'stop' STOP_ALL = 'stop-all' HELLO = 'hello' # internal stuff that the gui uses START_DAEMON = 'start-daemon' START_READER_SERVICE = 'start-reader-service' def run(cmd): """Run and log a command.""" logger.info('Running `%s`...', cmd) code = os.system(cmd) if code != 0: logger.error('Failed. exit code %d', code) COMMANDS = [AUTOLOAD, START, STOP, HELLO, STOP_ALL] INTERNALS = [START_DAEMON, START_READER_SERVICE] def utils(options): """Listing names, tasks that don't require a running daemon.""" if options.list_devices: logger.setLevel(logging.ERROR) from inputremapper.groups import groups for group in groups: print(group.key) if options.key_names: from inputremapper.configs.system_mapping import system_mapping print('\n'.join(system_mapping.list_names())) def communicate(options, daemon): """Commands that require a running daemon.""" # import stuff late to make sure the correct log level is applied # before anything is logged from inputremapper.groups import groups from inputremapper.configs.paths import USER def require_group(): if options.device is None: logger.error('--device missing') sys.exit(3) if options.device.startswith('/dev'): group = groups.find(path=options.device) else: group = groups.find(key=options.device) if group is None: logger.error( 'Device "%s" is unknown or not an appropriate input device', options.device ) sys.exit(4) return group if daemon is None: # probably broken tests logger.error('Daemon missing') sys.exit(5) if options.config_dir is not None: path = os.path.abspath(os.path.expanduser(os.path.join( options.config_dir, 'config.json' ))) if not os.path.exists(path): logger.error('"%s" does not exist', path) sys.exit(6) logger.info('Using config from "%s" instead', path) global_config.load_config(path) if USER != 'root': # Might be triggered by udev, so skip the root user. # This will also refresh the config of the daemon if the user changed # it in the meantime. # config_dir is either the cli arg or the default path in home config_dir = os.path.dirname(global_config.path) daemon.set_config_dir(config_dir) migrate() if options.command == AUTOLOAD: # if device was specified, autoload for that one. if None autoload # for all devices. if options.device is None: logger.info('Autoloading all') # timeout is not documented, for more info see # https://github.com/LEW21/pydbus/blob/master/pydbus/proxy_method.py daemon.autoload(timeout=10) else: group = require_group() logger.info('Asking daemon to autoload for %s', options.device) daemon.autoload_single(group.key, timeout=2) if options.command == START: group = require_group() logger.info( 'Starting injection: "%s", "%s"', options.device, options.preset ) daemon.start_injecting(group.key, options.preset) if options.command == STOP: group = require_group() daemon.stop_injecting(group.key) if options.command == STOP_ALL: daemon.stop_all() if options.command == HELLO: response = daemon.hello('hello') logger.info('Daemon answered with "%s"', response) def internals(options): """Methods that are needed to get the gui to work and that require root. input-remapper-control should be started with sudo or pkexec for this. """ debug = ' -d' if options.debug else '' if options.command == START_READER_SERVICE: cmd = f'input-remapper-reader-service{debug}' elif options.command == START_DAEMON: 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(): """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 _systemd_finished(): """Check if systemd finished booting.""" try: systemd_analyze = subprocess.run(['systemd-analyze'], stdout=subprocess.PIPE) except FileNotFoundError: # probably not systemd, lets assume true to not block input-remapper for good # on certain installations return True if 'finished' in systemd_analyze.stdout.decode(): # it writes into stderr otherwise or something return True return False def boot_finished(): """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 _systemd_finished(): logger.debug('System is booted') return True if _num_logged_in_users() > 0: logger.debug('User(s) logged in') return True return False def main(options): if options.debug: update_verbosity(True) if options.version: log_info() return logger.debug('Call for "%s"', sys.argv) from inputremapper.configs.paths import USER boot_finished_ = boot_finished() is_root = USER == "root" is_autoload = options.command == AUTOLOAD config_dir_set = options.config_dir is not None if is_autoload and not boot_finished_ and is_root and not config_dir_set: # this is probably happening during boot time and got # triggered by udev. There is no need to try to inject anything if the # service doesn't know where to look for a config file. This avoids a lot # of confusing service logs. And also avoids potential for problems when # input-remapper-control stresses about evdev, dbus and multiprocessing already # while the system hasn't even booted completely. logger.warning('Skipping autoload command without a logged in user') return if options.command is not None: if options.command in INTERNALS: internals(options) elif options.command in COMMANDS: from inputremapper.daemon import Daemon daemon = Daemon.connect(fallback=False) communicate(options, daemon) else: logger.error('Unknown command "%s"', options.command) else: utils(options) if options.command: logger.info('Done') if __name__ == '__main__': parser = argparse.ArgumentParser() parser.add_argument( '--command', action='store', dest='command', help=( 'Communicate with the daemon. Available commands are start, ' 'stop, autoload, hello or stop-all' ), default=None, metavar='NAME' ) parser.add_argument( '--config-dir', action='store', dest='config_dir', help=( 'path to the config directory containing config.json, ' 'xmodmap.json and the presets folder. ' 'defaults to ~/.config/input-remapper/' ), default=None, metavar='PATH', ) parser.add_argument( '--preset', action='store', dest='preset', help='The filename of the preset without the .json extension.', default=None, metavar='NAME', ) parser.add_argument( '--device', action='store', dest='device', help='One of the device keys from --list-devices', default=None, metavar='NAME' ) parser.add_argument( '--list-devices', action='store_true', dest='list_devices', help='List available device keys and exit', default=False ) parser.add_argument( '--symbol-names', action='store_true', dest='key_names', help='Print all available names for the 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 ) main(parser.parse_args(sys.argv[1:])) input-remapper-2.0.1/bin/input-remapper-gtk000077500000000000000000000072651450214210500207170ustar00rootroot00000000000000#!/usr/bin/python3 # -*- 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 . """Starts the user interface.""" from __future__ import annotations import sys import atexit from argparse import ArgumentParser import gi gi.require_version('Gtk', '3.0') gi.require_version('GLib', '2.0') gi.require_version('GtkSource', '4') from gi.repository import Gtk # 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 from inputremapper.logger import logger, update_verbosity, log_info def start_processes() -> DaemonProxy: """Start reader-service and daemon via pkexec to run in the background.""" # this function is overwritten in tests try: ReaderService.pkexec_reader_service() except Exception as e: logger.error(e) sys.exit(11) return Daemon.connect() if __name__ == '__main__': parser = ArgumentParser() parser.add_argument( '-d', '--debug', action='store_true', dest='debug', help=_('Displays additional debug information'), default=False ) options = parser.parse_args(sys.argv[1:]) update_verbosity(options.debug) log_info('input-remapper-gtk') logger.debug('Using locale directory: {}'.format(LOCALE_DIR)) # import input-remapper stuff after setting the log verbosity from inputremapper.gui.messages.message_broker import MessageBroker, MessageType from inputremapper.configs.system_mapping import system_mapping 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 from inputremapper.groups import _Groups from inputremapper.gui.reader_client import ReaderClient from inputremapper.daemon import Daemon from inputremapper.configs.global_config import GlobalConfig from inputremapper.configs.migrations import migrate migrate() message_broker = MessageBroker() # create the reader before we start the reader-service (start_processes) otherwise # it can come to race conditions with the creation of pipes reader_client = ReaderClient(message_broker, _Groups()) daemon = start_processes() data_manager = DataManager( message_broker, GlobalConfig(), reader_client, daemon, GlobalUInputs(), system_mapping ) controller = Controller(message_broker, data_manager) user_interface = UserInterface(message_broker, controller) controller.set_gui(user_interface) message_broker.signal(MessageType.init) def stop(): if isinstance(daemon, Daemon): # have fun debugging completely unrelated tests if you remove this daemon.stop_all() controller.close() atexit.register(stop) Gtk.main() input-remapper-2.0.1/bin/input-remapper-reader-service000077500000000000000000000036351450214210500230270ustar00rootroot00000000000000#!/usr/bin/python3 # -*- 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 . """Starts the root reader-service.""" import asyncio import os import sys import atexit import signal from argparse import ArgumentParser from inputremapper.logger import update_verbosity from inputremapper.groups import _Groups if __name__ == '__main__': parser = ArgumentParser() parser.add_argument( '-d', '--debug', action='store_true', dest='debug', help='Displays additional debug information', default=False ) options = parser.parse_args(sys.argv[1:]) update_verbosity(options.debug) # import input-remapper stuff after setting the log verbosity from inputremapper.gui.reader_service import ReaderService 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() reader_service = ReaderService(groups) asyncio.run(reader_service.run()) input-remapper-2.0.1/bin/input-remapper-service000077500000000000000000000032661450214210500215670ustar00rootroot00000000000000#!/usr/bin/python3 # -*- 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 . """Starts injecting keycodes based on the configuration.""" import sys from argparse import ArgumentParser from inputremapper.logger import update_verbosity, log_info if __name__ == '__main__': parser = ArgumentParser() parser.add_argument( '-d', '--debug', action='store_true', dest='debug', help='Displays additional debug information', default=False ) parser.add_argument( '--hide-info', action='store_true', dest='hide_info', help='Don\'t display version information', default=False ) options = parser.parse_args(sys.argv[1:]) update_verbosity(options.debug) # import input-remapper stuff after setting the log verbosity from inputremapper.daemon import Daemon if not options.hide_info: log_info('input-remapper-service') daemon = Daemon() daemon.publish() daemon.run() input-remapper-2.0.1/data/000077500000000000000000000000001450214210500153655ustar00rootroot00000000000000input-remapper-2.0.1/data/99-input-remapper.rules000066400000000000000000000007151450214210500216530ustar00rootroot00000000000000# 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.0.1/data/input-remapper-autoload.desktop000066400000000000000000000004531450214210500235400ustar00rootroot00000000000000[Desktop Entry] Type=Application Exec=bash -c "input-remapper-control --command stop-all && input-remapper-control --command autoload" Name=input-remapper-autoload Icon=/usr/share/input-remapper/input-remapper.svg Comment=Starts injecting all presets that are set to automatically load for the user input-remapper-2.0.1/data/input-remapper-gtk.desktop000066400000000000000000000003171450214210500225140ustar00rootroot00000000000000[Desktop Entry] Type=Application Name=Input Remapper Icon=/usr/share/input-remapper/input-remapper.svg Exec=input-remapper-gtk Terminal=false Categories=Settings Comment=GUI for device specific key mappings input-remapper-2.0.1/data/input-remapper-large.png000066400000000000000000000170011450214210500221320ustar00rootroot00000000000000‰PNG  IHDRnnÆ[&ûoiCCPicc(‘u‘;KA…?7JÄ)´±HÅB!(ˆ¥ÆÂ&ˆÄFm’u“›Í²› b+ØX¢¯Â ­`« Š bãðÕHXïd ¢³ÌÞ3s.3g@‹›zÁmŽBÁ*9‰éXx!µ¾ÒŒF'hiݵ'ggãü;¾îiRõnXõúߟ£}ÅpuhjÓm§$NŸ(I:Ñ›cí5ĽT&_%;è¿øœodœ.'57Ÿ£ÈÈ r©âÏ2í<î•.=ûhŠTàÏŽÔÚ8âx‡MdºõKî̆"ÔÕÕÓ‚*8\HÕ55ÔÔÔDçZZDYæþ(ªo_Š¥¸þ±”™‘NY”–šJÁÁÁ^CïÀU™ž|(U:ÈΆ¸ß–ËñÌa<Ï“»ëï=I'-[·Ñ›6ÑÉêÓk¤¥¦¤Pvfe‰'“bûõ3ÝŽ¥ µ1#³´-ˆ~ñ“$©ÉR&+ùqOɲ#®œîc¦ã×<¶þÞãs9]´uÇúä³µÔpê”Éák‰ŽæéFde¤¥QHˆ_Ôþ$?’"­¶u" ùqKKåAAô¦”&rêôiz}Å_¨¸¤Äßóíƒ6¦¤$‰£;;3>.Î}(9háÃÉ’ù£Ãàhü†¸eåò-Üøk<h2zÀ‘cÇ迼MgϪŠ^ºédVV&ïÊt ³E¾/gr|ÛÃ)Ò—þ¹íˆûÓ 9üt-ãA? 6à;wÓ[ï¾K.V‡\hà`^?yÀ±3ùÉæ™/˜" àä£óɇ“é7v«Ô,Fm¼Ë2¸Á¿óû+ÔÊ=J¿{íu{±@dd$¯é]ôt3"<Üðð™qù§#‚n}8N:c¸’NAÛ÷b•<œœô1÷—ªÖgEU½¸üjédíÍN",¬C>omm5[ÕÖòØ};¸×Næ'‰w©Î®ÜBóìRŸÙ‚¸Ëe¨¨>ä'Vm…ÀîÿzÉSm]¥E¼jÜ8ºùÆä`ª_]SKE%Å´k÷^:TXh©=»+…óÌLï)³²:ŠÚ d:î”i«ÍŽúڿψc È|fõÿʉÐÌÚõèk>²4Þ>¼O>ñ8O=»xõõ?±°PÙ×ðâùxͤìND&'%‘äpTózÍcË.à )ôIÕЉ´¿q»šíœ=ÛDÿZ»Îò8¯žõ/*Ãu×<ÀU·´¶N%’7Ðí?}nÿ3‹dq`ùƒ\ô w¢Êˆxàèñ„£Rb¢c(::Š¢¢ú %rß¾}ÄŸÓ¯¹Z³*³Ú߿˟øAºíñŸŒî1çÙÐKGä0^'Èiª,¿wÇ{ö~¥9…ÞðL£cö*÷ÀæÆÂúótûã¹TñZÿL»Ö‚˜ÚqrZÆ© ×Jé±ëãÆŒöiL`Y8ú†Á"JiYC7=Ý×ÄA÷ÈÊâï›Y$Ð7=ÿÀË.3Ód²WOšÈ;v ÎÍõ©U† ­›—@Œƒ<›BÎýY«¼!S2´üì^°Flgíÿç_hëXo¸v>EDèò8š½BÃ;rø0|ª:yÒÄ(ýWtê”ÉtÏߣy³fRÞÔ©4lȪ¯o ºúz#¥‘SÎÑ>6P*€.â`OëÓDœMq=è«¢²’¶nß¡:H¨ˆ\w­‘I*E£FŒ ¡,Û:,Ø[0îúö¼¹Šbb¢éÊQ#©¨¸Ä(òòhÄä/yEÞóÐ¥q0‚ò9ÞÊœ;×án 2³„z4ÐJ¿éi4eâUVªÚR–÷™Ó¦)¶Ký‚ë ¬A¼ö/ÑSOõÀ“&â^ª”:-×–&U•¼úÇ?Óß>øÐv»œK81÷°f„AêK›Âª/¨Ã DÃéHËͦvœì¢ç¸BwCr!#‚(èà3¿z–Þ}ÕÔÖm^³\qq©-íXi$ĀӒÔ§šü4ÝtS7²¦úYÀ‹}·•»ëôÕÔxto¹­½6åo¥_=ÿ[vgx“pŒúEr‡PcYy…æÐa–Òã¶½D¡9³=SE\踠OÖfhD̶wß~3Uz”Ýð péóıiiqp­¡U!1ôHp$õó°ÃX\^¡Ž¼[òÍÛeW7ÿSE͉pVÕð{4³ CçRþ¶ífª* Ÿ•Cìwé pj:×ì—þÜý\Æ}¬éGiŒ<7Lã”ûƒÃi^ë)*aú‚ïw|¼oÁ”ã ÛØRðáǰ7›é[ž5×éal¶eÅòC ²}!±0ïýíïTUxyí!ÑÝæžt2Ѽ§·6ˆŸÎœi¤ç–.£áC‡g]À¾HïÕXôlºí‰Á´òYa|ì8øòu 7‚ÙA¹—SûèÁn´Ü:Ô+Hí¦Q’ºzï®ä2»]jF|`û7`~‰‰ L‹$Êëé|[›þò9¡RF\g†~#K€ÎMdÓ Îu£ ÅJ£Ï>_o´)[Ë%Kºú Já2»IS?L#† ¥ë¿=_xš››iÃÆMÂ)X“)“å.¢sò >Ûˆš±fÏÌ##,²»SÙÅ¡£Êþ–EÅÅtüD‘­ã3ÚØ)¦_z Wfäðá´ð®;»†öà›2oö,ºáÚok7/K îÝB·ð SúH½‰à=8ËI&4øêÞ\ùŽøðü¾ú¨L{6óX,«ùÇX&E5ÀñxÝüyų@·ÙŸYTA–»dên¶K¢;}âÿ5ÖsîÌ´oÿªoè ÞzpøÈ!ÓAÿ[Ž•â’RÖóëUõÛ{„”ßßÖ(Ä€H/I©‘\ô£öÆÎ°så! Øm§y—b¡ˆþrÓfåŽ.¹.Ô#|—‘6Ù_³ÆqM¹ÙP¨3B®]ƒÐÛ°ÝÕF³˜íÇÙBe|tV²3éÛü÷Ù­§i+¿Ó‚`fÒô@‹)ëÙÅFw!1×zúúÊßo¸Þ×fz½~ÜNòÎÙZGÃZêéGü÷Ãü›Tsø˜;ÎO­lYY¹j3³ò¦f¸_v•"P^¯gÞOœ0žÎ²VcÍÇŸø¬Ö22¸Ä 4˜JÊJ…-ÌŸ·ôӧј+¯ °ÐP6Ý4ˆx¿í;;\(ÁòÁÜã&JpìÄ :^T¤:DV^…/$@ ))$—Èn˜•7£IûÓ[ÿû.µ³ŽÒ_úøÀ½ YN]@(FH× ¦“Ðe–––“Ÿ –øÎ¸ûBx§}ïÖ[DÀ%, €þëSÁEÂìäy,i¯½þ†jOh7>>úcb“½\)Ïc/knÆ&¥Vl=¸G£ ‹™®Ê¥Ûo¾QSWŠ ÊŠªJFd‰`z`à´ê"ÝüoÑŒiìm§ˆ™ðÜMqqý Z¥  `ñé1]0ÌΞ‘Çæ"ÊAâ±ã:“¿˜Y[ÊædgÑÏÿwZ·a}¶n=?ï{ND¡~kîlB¬@Ї: |Wp%Äxà2o&Œ«Y|Ìè+»!qî_nÞb¸‹+FŽeYA’žÄ1à½Ìç̘!œãž¯¾GšYHLHÇäE3¾w?ðç ‚‰ÉLTQD„vØUx¨uña]nÃ+‹²ÀÕëÁÈUY!Ò,õ*`§|÷úëè;샂#"@ᑣ°ªDá™Á1kÙYY4‚¹˜œ]€p®)“®§€QÀqïVa)Õi8m=¾ýVuD£ñ÷`$4ãÖØY£“Ô*‚}Yv¶xЖ44œ¢–ó­älw2Mp0ÝŠÖqK÷}$³óf"gá©f6nÎgÇ'e•héfÞÁVÎE )0›Í,tV T j øi¤§¥RJr²ß‘†¹AŒ¸qÁ †§¹aãFho€kÆŠ•+-1`8UÀôxA¨ÈˆÔ†Gw‰„ÚÕShý—ug.ô«ï¼CÛwí¢q£GSXxËqœ†oëvK~žø`ïºãvêωx¼-8ƒ¸Ü@Þº3¿@ \?>U²ƒíáÂ#†FwC;\°¥Ž rt&é44¨K±D†ûîþ7Ê蛫¼™µË›z Må®Àt•Ñf½Ë"Ñý c®ôëô!ÆÜÆJY5A¦hæMD:Üÿòî¼õVÊañãï®a/-ß•ž]‚–ÁÀ L‡Nâ`¸ì‚ôzµð>ø|ÅWTQ|IÅ—VPBy9ÚœthÒhÚ;=ð9IOŸ9#·kÏ^ŸåÐq汪 áÒ¡¬˜6k8„ŽŠ.5بÏÅ¢ëX˜eÅ—V DÅž¬!‰Ý”à“{o¥ê¬4Ÿû´Ò,ôðößlîD7†¦M™Â¢‡I­ŠDÛ¤+äó½ó¸kBøÈ‰ç$vvTY%…qBQ£°ÿš ´{–v¸±Ñ¶¬–ƒÛ1V˜:|X¤l„—rkRšÏ1}#;¢'7èò"S„áØåAkÚÚ­ÎD£žÄòNzÁQJ9zB *¦ºŽð›UÀ®ìm€û!¸NoÎnõ°z[̦:-æK\P2C£ë—ÜJ=ÿà3Êݾ׶µŽ/¯ǨÌRé…¾(»µæÂŸy#Äóªx‹+„5pç>‹µ•«qéWmO„­óocÜ€áß>¾n]f>Öé‡ ­`d.%àï¿ÑkK9éÚ´îAZ}Wp@E}ˆ —°[æYîš ä¤kÒ;ü?ôàÕös4”½¨–´ãömH¼Äv“ôS\¤·0v¾7ЏWœ"Á³íMôûο«#н©ÂtâÍíœCo·œ9:ou ØXêÒ“uÃ%÷²¢§«÷ÏÚš„ª*0›9ð‚Cà*qWÀ •}3µüãy$ÿpvOs×ï‡ÚÎÒ¯ß=}!ÈsZD>*8L®Z‡Îy#uœŒ¼E웿Á¥¬Ü½„è\3î»sà¼.ú€ŸSm†:ƒr€£]Ž©DÄ´2'uÇù3„2ÞAÜ ŒÓówS…ȨΆTqS¡OifÍŽ´6éœ üÃ¥ ¨™wÞ?ý–óB}v €HÕ$ü*qS!+ŒÔM®6¯FÀj !h=¼a³çQCÃ!ºãø™ …ÐTäSйS &~±yNjŽñ$r$ ÄázI–u¨Î¡W¬å«R’Šz&‘™êå#h4#h<#j,?¹ž‹Xi=;:¶×]Dõ†éÓ{þf?ïBœ‘Ø ÁÉ·4v&†:§„¸_… ¯V*_õNâØ$ä7AÑÿvüÖ}gÇÜ÷ˆ‡‹\Ù Šôz3ýݳ»}-:gu !6Ä oÐKX ÅWVE¡,àL[¾3—œ­ÎA§^W*ù¯ããø"WÖ qF5(f pÜ(3Å;ÊòNêÇ)æãKQeV÷˜eaöWt2+ŽŒëÀ4àªwŸ]ˆÃí»ím´„_èÇ»Ú0â–¾‘t66†ú6XW•º˜87$ šŒd*ËÍ¡ÊYº#ƒjÌmmOࣻ)ÄD^Ë”cE½…¸š†TqŽ€.ÄáìäãòSþm®îìm*PËê/3ˆkæ,é¨SÍô±6-…êRÙ¬“œwTæÁÔzè¸8öàÏâ ÀjßÀÓXùŒ$u °ÝgÍW&óÑ0Ä•_žCY_)_¯„Ô¥ðnbDáX­å§9ÚL^ãŽåµn\o<M)œ¥ÏéFjŠQ¼O¯ºå÷l7í®Ú q¸çš}[`Ÿ ȨNŒBqì(”³ç µF†‰]„UÃ6`7©äÁì[YÍt‰=Áx€£ eæäèè´{&;ur€9{{¦c²¼zã™6Å0äB¦ý¦v¿‹§âp99{}½e6Û¹Õ…€<·}~žxô ‚#EqLu¸í1¢*NRP[OÕ×ð/¶ŠÝY:¸ÃeJm3DZÞ8ð>™âaDœƒþà=®—“sdÐ"Oúgd2v– â­/vS§_%Õç”ñ;óRïB\«×€d5hé)´8f`RM¸,®,í=‡ÀðËä•|ÔÜi'2ôÚJ>^BXpCqÕäð!Úpècî¶=ªÝ¯yðN¦Q¹ Å£ÇÀÄñG…+ òœD/<"õ0(²d¸Qž/%GÂÝ,éz1òÌ赊éõTïQ^_Ѓb'fj«372!]°ãI¼àør“ÚñcdBXX7âÀ¾gíSÄ¡Œ®¼,“Ö!ì½7´q }Äy,,ê©A<«¼”\Øû32cÅ{Š'ÚÞØD-QÖ,:kôåé´âBšˆûI’ÔÄ´n1—«&¿ÿ‘”í‚n;B…·÷ñý·‹]¥pYˆ<Ó3›lxS3Eן¢3ýûQmj’hCɵ!ˆ£á¤¨ONìê"¡¤œ‘¶JW‡™È‡ûã³k]¸vÖ?¨wá».×øHŠ´š™ŠÏíDš8jÊOvż5ð¢)±Öá,tëIYÌý©î¦N½"väéDõ¸Ïã2åH3#ïé"MÌÁ?.ËI’t/^ÐE8döŸYù¢´TVî/íi@ž7@­•Ç_~¿“êAé5ç±°8jÕò#Ì;™Ó´·V³FÆ@Fr Î~:·“ÃêþÓÈZBÜèÇ®Ýê’¦®ÎøjÚDzûéGéÈXuÛ•7¡4àЖšõÆ_™yéI§!4k15ž «eÿãrÓo–Ó5ï| k£úhWªHþnN;ƒèfµôoq A.§J¢`¯n˜í˜7]È@ +E£†¨ŽÃóˆÒZX03Y–‚fÞ åЂتš®ÝŵXb´øDp LÑC‹_‹~œ$)Ë, • !õX›¢kÒxó‚¹T0iLWWp R :ôô>ÖÓp€‹›É´Ç3F@q8jÅ®`8ߟÎ{\§·FÞN¸BÓ‚a¤Î2ËN“Þ3QÞ¸J‹]05o|úâ–ëèØ•Ã»õµ´J9ÐïÝÄ·žÓ¹Gª¬¼ï ŸÄâr]"úì:.ù$Ðû8ô±}HpàêñôÙ=7‹“Ågè£úZl¶óñ×Í9ÂÚò]­È ÕÓ× ÈYǪìÔTxrä"%À–³ÛFÓŒƒÚ©—PþÆ%¯°Ú-ò>ŽSŽœ0¼F%< ¹ I ví0÷òYó“§eÛèà #®ß+O4Ô/\¢Òõé;óT‘†`òjzó_#.EqhOÉ‘VmÂÐEº¡|Pްˆ+PHó±.42Â5"™`òñ#p6Äù¤÷ÔüéÓ0âИL҉䈃‘S ´Ï…ÕÒp™ŒR«n€öÛµ3iȦÄ©†Um@„ã

Ýj qÌûæóM$Š õÍ;ÄUUɰ˩˜Y“Ùa¾%@‰åvkÀçßÉVÐ-‹µÞ‚〖»¼‚ÝõJ‡^®W%ïóq<ú²Ó܃5…¸ØÔ³ûJûê:Er2ìÁ[v‰ç'§ne{˜¦ ì¬7ƒ8´£t©†ÿ„¥Ám. $FtûbUšæÝ¾ioøº…KÖr%}ïÝ™|]à蘴å†9âh1òÞ\¥X4´ˆm‚yfž‹–ƒå·Â=ªÍÍÔŽC#|ma.ÅVÄE°fÄ ðµ<8y¬È>N»L‹`"˜ëÃo @Å´þÞÅ)ÒûvÙ4âÈÅtNÃÌbe€¥UQ—·óιÓÄs‘ÃNëͨ±ÌÌ×4â‚CùííÖ“¦¹‡ØÐ3DÀàùœ=—AËoTaleî¦i:aAê¿# Im§Ú«rÒ"„;9øøÂF6‚þÀˆ=Í×¹›ÞqÊŸñ™¦…¸2~¿…Ó\ä³T›Æuf×§ß}ÌÙšL‹y¯>Å øÅÞïëbX­ÇøˆÀÝ@Ïrmµïz–v\Ý¢çÒ$gÐÇlíÆ "sÌNfX¶È’œ/·çǯø±êíuKKåTVZ<ÁuîåÇnç.»ÖÅh;§˜&¿Ìù–>ªáØc´13å,!Nì9¶¶ÕÞ¿,)ž"k¥W0f2öÙòj9‰ãñ㟾Ïù03³´¿,ç¦ପæ÷h—Ý[´Œ8»Æ;°°ƒ~ÄÇÍBn3Ó®výÑ/Öwz ¾ü¤ZSÛ5®^Gœ{"|ʼn´¬Œ¦²ªò.þ Ô õ2°5|¼¼Í†äìáÐ|0Zc»`ç9Èß—Ë‘L8¯ãßfóa?Ð;±€ûü1×ßµSãaàC1Tä‚Dœ÷È_®’³qS¡¸ô®ãþ4p´†“ôë¬|äagÜä/È#âNIah{©ÐE8ïµÁýi¸Š ·:ñ»Aâ~ ¾¶7`0­ŒbäFñ¿£-‰§¹L£H‡Ë™UE’ÎŽ‡…¹ÌÅÂÛ‰ãÿe*.Å6³<ºIEND®B`‚input-remapper-2.0.1/data/input-remapper.glade000066400000000000000000003075001450214210500213400ustar00rootroot00000000000000 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 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.0.1/data/input-remapper.policy000066400000000000000000000023311450214210500215550ustar00rootroot00000000000000 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.0.1/data/input-remapper.service000066400000000000000000000005221450214210500217160ustar00rootroot00000000000000[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.0.1/data/input-remapper.svg000066400000000000000000000231611450214210500210610ustar00rootroot00000000000000 image/svg+xml input-remapper-2.0.1/data/inputremapper.Control.conf000066400000000000000000000005301450214210500225440ustar00rootroot00000000000000 input-remapper-2.0.1/data/io.github.sezanzeb.input_remapper.metainfo.xml000066400000000000000000000027171450214210500264600ustar00rootroot00000000000000 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.0.1/data/style.css000066400000000000000000000016501450214210500172410ustar00rootroot00000000000000.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.0.1/inputremapper/000077500000000000000000000000001450214210500173475ustar00rootroot00000000000000input-remapper-2.0.1/inputremapper/__init__.py000066400000000000000000000000001450214210500214460ustar00rootroot00000000000000input-remapper-2.0.1/inputremapper/configs/000077500000000000000000000000001450214210500207775ustar00rootroot00000000000000input-remapper-2.0.1/inputremapper/configs/__init__.py000066400000000000000000000000001450214210500230760ustar00rootroot00000000000000input-remapper-2.0.1/inputremapper/configs/base_config.py000066400000000000000000000110011450214210500236010ustar00rootroot00000000000000# -*- 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 . from __future__ import annotations import copy from typing import Union, List, Optional, Callable, Any from inputremapper.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.0.1/inputremapper/configs/data.py000066400000000000000000000071661450214210500222740ustar00rootroot00000000000000# -*- 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 . """Get stuff from /usr/share/input-remapper, depending on the prefix.""" import os import site import sys import pkg_resources from inputremapper.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.0.1/inputremapper/configs/global_config.py000066400000000000000000000111751450214210500241430ustar00rootroot00000000000000# -*- 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 . """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 CONFIG_PATH, USER, touch from inputremapper.logger import logger MOUSE = "mouse" WHEEL = "wheel" BUTTONS = "buttons" NONE = "none" class GlobalConfig(ConfigBase): """Global default configuration. It can also contain some extra stuff not relevant for presets, like the autoload stuff. If presets have a config key set, it will ignore the default global configuration for that one. If none of the configs have the key set, a hardcoded default value will be used. """ def __init__(self): self.path = os.path.join(CONFIG_PATH, "config.json") super().__init__() def 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 USER == "root": logger.debug("Skipping config file creation for the root user") return touch(self.path) with open(self.path, "w") as file: json.dump(self._config, file, indent=4) logger.info("Saved config to %s", self.path) file.write("\n") global_config = GlobalConfig() input-remapper-2.0.1/inputremapper/configs/input_config.py000066400000000000000000000365021450214210500240430ustar00rootroot00000000000000# -*- 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 . from __future__ import annotations import itertools from typing import Tuple, Iterable, Union, List, Dict, Optional, Hashable, NewType from evdev import ecodes 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.system_mapping import system_mapping from inputremapper.gui.messages.message_types import MessageType from inputremapper.logger import logger from inputremapper.utils import get_evdev_constant_name # 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, ] DeviceHash = NewType("DeviceHash", str) 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 = system_mapping.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) if isinstance(key_name, list): key_name = key_name[0] 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] 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.0.1/inputremapper/configs/mapping.py000066400000000000000000000415771450214210500230220ustar00rootroot00000000000000# -*- 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 . from __future__ import annotations import enum from collections import namedtuple from typing import Optional, Callable, Tuple, TypeVar, Union, Any, Dict import pkg_resources from evdev.ecodes import ( EV_KEY, EV_ABS, EV_REL, REL_WHEEL, REL_HWHEEL, REL_HWHEEL_HI_RES, REL_WHEEL_HI_RES, ) 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.system_mapping import system_mapping, DISABLE_NAME from inputremapper.configs.validation_errors import ( OutputSymbolUnknownError, SymbolNotAvailableInTargetError, OnlyOneAnalogInputError, TriggerPointInRangeError, OutputSymbolVariantError, MacroButTypeOrCodeSetError, SymbolAndCodeMismatchError, MissingMacroOrKeyError, MissingOutputAxisError, MacroParsingError, ) from inputremapper.gui.gettext import _ from inputremapper.gui.messages.message_types import MessageType from inputremapper.injection.global_uinputs import can_default_uinput_emit from inputremapper.injection.macros.parse import is_this_a_macro, parse 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 = pkg_resources.parse_version( str(VERSION) ) < pkg_resources.parse_version("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 output_symbol: Optional[str] = None # The symbol or macro string if applicable 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 system_mapping return None for unknown symbols and macros """ if self.output_code and self.output_type: return self.output_type, self.output_code if self.output_symbol and not is_this_a_macro(self.output_symbol): return EV_KEY, system_mapping.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_code is not None and not output_symbol: values["mapping_type"] = "analog" if output_type is None and output_code is None and output_symbol: values["mapping_type"] = "key_macro" 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 is_this_a_macro(symbol): mapping_mock = namedtuple("Mapping", values.keys())(**values) # raises MacroParsingError parse(symbol, mapping=mapping_mock, verbose=False) return values code = system_mapping.get(symbol) if code is None: raise OutputSymbolUnknownError(symbol) target = values.get("target_uinput") if target is not None and not 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 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 != system_mapping.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() use_as_analog = analog_input_config is not None output_type = values.get("output_type") output_symbol = values.get("output_symbol") if not use_as_analog and not output_symbol and output_type != EV_KEY: raise MissingMacroOrKeyError() if ( use_as_analog 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.0.1/inputremapper/configs/migrations.py000066400000000000000000000422171450214210500235330ustar00rootroot00000000000000# -*- 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 . """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 import pkg_resources 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 inputremapper.configs.input_config import InputCombination, InputConfig from inputremapper.configs.mapping import Mapping, UIMapping from inputremapper.configs.paths import get_preset_path, mkdir, CONFIG_PATH, remove from inputremapper.configs.preset import Preset from inputremapper.configs.system_mapping import system_mapping from inputremapper.injection.global_uinputs import global_uinputs from inputremapper.injection.macros.parse import is_this_a_macro from inputremapper.logger import logger, VERSION from inputremapper.user import HOME def all_presets() -> Iterator[Tuple[os.PathLike, Dict | List]]: """Get all presets for all groups as list.""" if not os.path.exists(get_preset_path()): return preset_path = Path(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(): """Get the version string in config.json as packaging.Version object.""" config_path = os.path.join(CONFIG_PATH, "config.json") if not os.path.exists(config_path): return pkg_resources.parse_version("0.0.0") with open(config_path, "r") as file: config = json.load(file) if "version" in config.keys(): return pkg_resources.parse_version(config["version"]) return pkg_resources.parse_version("0.0.0") def _config_suffix(): """Append the .json suffix to the config file.""" deprecated_path = os.path.join(CONFIG_PATH, "config") config_path = os.path.join(CONFIG_PATH, "config.json") if os.path.exists(deprecated_path) and not os.path.exists(config_path): logger.info('Moving "%s" to "%s"', deprecated_path, config_path) os.rename(deprecated_path, config_path) def _preset_path(): """Migrate the folder structure from < 0.4.0. Move existing presets into the new subfolder 'presets' """ new_preset_folder = os.path.join(CONFIG_PATH, "presets") if os.path.exists(get_preset_path()) or not os.path.exists(CONFIG_PATH): return logger.info("Migrating presets from < 0.4.0...") groups = os.listdir(CONFIG_PATH) mkdir(get_preset_path()) for group in groups: path = os.path.join(CONFIG_PATH, group) if os.path.isdir(path): target = path.replace(CONFIG_PATH, new_preset_folder) logger.info('Moving "%s" to "%s"', path, target) os.rename(path, target) logger.info("done") def _mapping_keys(): """Update all preset mappings. Update all keys in preset to include value e.g.: '1,5'->'1,5,1' """ for preset, preset_structure in 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(): """Write the current version to the config file.""" config_file = os.path.join(CONFIG_PATH, "config.json") if not os.path.exists(config_file): return 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(): """Rename .config/key-mapper to .config/input-remapper.""" old_config_path = os.path.join(HOME, ".config/key-mapper") if not os.path.exists(CONFIG_PATH) and os.path.exists(old_config_path): logger.info("Moving %s to %s", old_config_path, CONFIG_PATH) shutil.move(old_config_path, CONFIG_PATH) def _find_target(symbol): """Try to find a uinput with the required capabilities for the symbol.""" capabilities = {EV_KEY: set(), EV_REL: set()} if is_this_a_macro(symbol): # deprecated mechanic, cannot figure this out anymore # capabilities = parse(symbol).get_capabilities() return None capabilities[EV_KEY] = {system_mapping.get(symbol)} if len(capabilities[EV_REL]) > 0: return "mouse" for name, uinput in global_uinputs.devices.items(): if capabilities[EV_KEY].issubset(uinput.capabilities()[EV_KEY]): return name logger.info('could not find a suitable target UInput for "%s"', symbol) return None def _add_target(): """Add the target field to each preset mapping.""" for preset, preset_structure in 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 = _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(): """Conditional macros should use an "else" parameter instead of "otherwise".""" for preset, preset_structure in 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 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(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(): """Convert preset.json from {key: [symbol, target]} to [{input_combination: ..., output_symbol: symbol, ...}] """ for old_preset_path, old_preset in 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 = _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 = { "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(): """Move the beta config to the v2 path, or copy the v1 config to the v2 path.""" # TODO test if os.path.exists(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(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 migrations. logger.debug("copying all from %s to %s", old_path, CONFIG_PATH) shutil.copytree(old_path, CONFIG_PATH) return # if v1 configs don't exist, try to find beta configs. beta_path = os.path.join(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, CONFIG_PATH) shutil.move(beta_path, CONFIG_PATH) def _remove_logs(): """We will try to rely on journalctl for this in the future.""" try: remove(f"{HOME}/.log/input-remapper") remove("/var/log/input-remapper") 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 def migrate(): """Migrate config files to the current release.""" _rename_to_input_remapper() _copy_to_v2() v = config_version() if v < pkg_resources.parse_version("0.4.0"): _config_suffix() _preset_path() if v < pkg_resources.parse_version("1.2.2"): _mapping_keys() if v < pkg_resources.parse_version("1.4.0"): global_uinputs.prepare_all() _add_target() if v < pkg_resources.parse_version("1.4.1"): _otherwise_to_else() if v < pkg_resources.parse_version("1.5.0"): _remove_logs() if v < pkg_resources.parse_version("1.6.0-beta"): _convert_to_individual_mappings() # add new migrations here if v < pkg_resources.parse_version(VERSION): _update_version() input-remapper-2.0.1/inputremapper/configs/paths.py000066400000000000000000000101051450214210500224650ustar00rootroot00000000000000# -*- 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 . # TODO: convert everything to use pathlib.Path """Path constants to be used.""" import os import shutil from typing import List, Union, Optional from inputremapper.logger import logger, VERSION from inputremapper.user import USER, HOME rel_path = ".config/input-remapper-2" CONFIG_PATH = os.path.join(HOME, rel_path) def chown(path): """Set the owner of a path to the user.""" try: shutil.chown(path, user=USER, group=USER) except LookupError: # the users group was unknown in one case for whatever reason shutil.chown(path, user=USER) def touch(path: 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) mkdir(os.path.dirname(path), log=False) os.mknod(path) chown(path) def mkdir(path, log=True): """Create a folder, give it to the user.""" if path == "" or path is None: return if os.path.exists(path): return if log: logger.info('Creating dir "%s"', path) # give all newly created folders to the user. # e.g. if .config/input-remapper/mouse/ is created the latter two base = os.path.split(path)[0] mkdir(base, log=False) os.makedirs(path) chown(path) def 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 def remove(path): """Remove whatever is at the path.""" if not os.path.exists(path): return if os.path.isdir(path): shutil.rmtree(path) else: os.remove(path) def 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 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(CONFIG_PATH, "presets") if group_name is None: return presets_base group_name = 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) def get_config_path(*paths) -> str: """Get a path in ~/.config/input-remapper/.""" return os.path.join(CONFIG_PATH, *paths) input-remapper-2.0.1/inputremapper/configs/preset.py000066400000000000000000000266071450214210500226660ustar00rootroot00000000000000# -*- 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 . """Contains and manages mappings.""" from __future__ import annotations from evdev import ecodes import json import os from typing import ( Tuple, Dict, List, Optional, Iterator, Type, TypeVar, Generic, overload, ) 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 touch from inputremapper.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 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", 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.0.1/inputremapper/configs/system_mapping.py000066400000000000000000000172451450214210500244210ustar00rootroot00000000000000# -*- 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 . """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 get_config_path, touch from inputremapper.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 SystemMapping: """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 system_mapping 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 = get_config_path(XMODMAP_FILENAME) touch(path) with open(path, "w") as file: logger.debug('Writing "%s"', path) json.dump(xmodmap_dict, file, indent=4) for name, code in xmodmap_dict.items(): self._set(name, code) 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) -> int: """Return the code mapped to the key.""" # the correct casing should be shown when asking the system_mapping # for stuff. indexing case insensitive to support old presets. if name not in self._mapping: # only if not e.g. both "a" and "A" are in the mapping name = self._case_insensitive_mapping.get(str(name).lower()) return self._mapping.get(name) def clear(self): """Remove all mapped keys. Only needed for tests.""" keys = list(self._mapping.keys()) for key in keys: del self._mapping[key] def get_name(self, code: 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) == list: return btn_name[0] else: return btn_name key_name = evdev.ecodes.KEY.get(code, None) if key_name is not None: if type(key_name) == list: return key_name[0] else: 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 # this mapping represents the xmodmap output, which stays constant system_mapping = SystemMapping() input-remapper-2.0.1/inputremapper/configs/validation_errors.py000066400000000000000000000106321450214210500251010ustar00rootroot00000000000000# -*- 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 . """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.system_mapping import system_mapping from inputremapper.injection.global_uinputs import find_fitting_default_uinputs 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 = system_mapping.get(symbol) fitting_targets = 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} -> {system_mapping.get(symbol)} " f"but output_code is {code} -> {system_mapping.get_name(code)} " ) 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 MacroParsingError(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.0.1/inputremapper/daemon.py000066400000000000000000000443251450214210500211740ustar00rootroot00000000000000# -*- 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 . """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 gi.require_version("GLib", "2.0") from gi.repository import GLib from inputremapper.logger import logger, is_debug from inputremapper.injection.injector import Injector, InjectorState from inputremapper.configs.preset import Preset from inputremapper.configs.global_config import global_config from inputremapper.configs.system_mapping import system_mapping from inputremapper.groups import groups from inputremapper.configs.paths import get_config_path, sanitize_path_component, USER from inputremapper.injection.macros.macro import macro_variables from inputremapper.injection.global_uinputs import global_uinputs BUS_NAME = "inputremapper.Control" # timeout in seconds, see # https://github.com/LEW21/pydbus/blob/cc407c8b1d25b7e28a6d661a29f9e661b1c9b964/pydbus/proxy.py BUS_TIMEOUT = 10 class AutoloadHistory: """Contains the autoloading history and constraints.""" def __init__(self): """Construct this with an empty history.""" # 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: ... class Daemon: """Starts injecting keycodes based on the configuration. Can be talked to either over dbus or by instantiating it. The Daemon may not have any knowledge about the logged in user, so it can't read any config files. It has to be told what to do and will continue to do so afterwards, but it can't decide to start injecting on its own. """ # https://dbus.freedesktop.org/doc/dbus-specification.html#type-system dbus = f""" """ def __init__(self): """Constructs the daemon.""" logger.debug("Creating daemon") self.injectors: Dict[str, Injector] = {} self.config_dir = None if USER != "root": self.set_config_dir(get_config_path()) # check privileges if os.getuid() != 0: logger.warning("The service usually needs elevated privileges") self.autoload_history = AutoloadHistory() self.refreshed_devices_at = 0 atexit.register(self.stop_all) # initialize stuff that is needed alongside the daemon process macro_variables.start() @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 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 USER != "root": config_path = get_config_path() logger.debug('Telling service about "%s"', config_path) interface.set_config_dir(get_config_path(), timeout=2) return interface def publish(self): """Make the dbus interface available.""" bus = SystemBus() try: bus.publish(BUS_NAME, self) except RuntimeError as error: logger.error("Is the service already running? (%s)", str(error)) sys.exit(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 global_config.load_config(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 = 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(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", 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 system_mapping._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 system_mapping? # this kind of bug is stupid to track down system_mapping.get_name(0) system_mapping.update(xmodmap) # the service now has process wide knowledge of xmodmap # keys of the users session except FileNotFoundError: logger.error('Could not find "%s"', xmodmap_path) 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. 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) 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 input-remapper-2.0.1/inputremapper/exceptions.py000066400000000000000000000040211450214210500220770ustar00rootroot00000000000000# -*- 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 . """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.0.1/inputremapper/groups.py000066400000000000000000000411601450214210500212420ustar00rootroot00000000000000# -*- 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 . """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 import traceback from typing import List, Optional import evdev from evdev import InputDevice 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 get_preset_path from inputremapper.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: 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: 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 # - device.uniq is empty most of the time, I don't know what this is # supposed to be return ( # device.info bustype, vendor and product are unique for # a product, but multiple similar device models would be grouped # in the same group f"{device.info.bustype}_" f"{device.info.vendor}_" f"{device.info.product}_" # deivce.phys if "/input..." is removed from it, because the first # chunk seems to be unique per hardware (if it's not completely empty) f'{device.phys.split("/")[0] or "-"}' ) class _Group: """Groups multiple devnodes together. For example, name could be "Logitech USB Keyboard", devices might contain "Logitech USB Keyboard System Control" and "Logitech USB Keyboard". paths is a list of files in /dev/input that belong to the devices. They are grouped by usb port. Members ------- name : str A human readable name, generated from .names, that should always look the same for a device model. It is used to generate the presets folder structure """ def __init__( self, paths: 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 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) if key_capa is None and device_type != DeviceType.GAMEPAD: # skip devices that don't provide buttons 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.debug("Did not find any input device") else: keys = [f'"{group.key}"' for group in self._groups] logger.info("Found %s", ", ".join(keys)) def filter(self, include_inputremapper: 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.0.1/inputremapper/gui/000077500000000000000000000000001450214210500201335ustar00rootroot00000000000000input-remapper-2.0.1/inputremapper/gui/__init__.py000066400000000000000000000002241450214210500222420ustar00rootroot00000000000000import 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.0.1/inputremapper/gui/autocompletion.py000066400000000000000000000371061450214210500235560ustar00rootroot00000000000000# -*- 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 . """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.gui.controller import Controller from inputremapper.configs.mapping import MappingData from inputremapper.configs.system_mapping import system_mapping, DISABLE_NAME from inputremapper.gui.components.editor import CodeEditor 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 ( TASK_FACTORIES, get_macro_argument_names, remove_comments, ) from inputremapper.logger import logger # no deprecated shorthand function-names FUNCTION_NAMES = [name for name in TASK_FACTORIES.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 = 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) 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(system_mapping.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() return [ (name, f"{name}({', '.join(get_macro_argument_names(TASK_FACTORIES[name]))})") for name in FUNCTION_NAMES if incomplete_name in name.lower() and incomplete_name != name.lower() ] 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.0.1/inputremapper/gui/components/000077500000000000000000000000001450214210500223205ustar00rootroot00000000000000input-remapper-2.0.1/inputremapper/gui/components/__init__.py000066400000000000000000000000001450214210500244170ustar00rootroot00000000000000input-remapper-2.0.1/inputremapper/gui/components/common.py000066400000000000000000000120311450214210500241570ustar00rootroot00000000000000# -*- 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 . """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.0.1/inputremapper/gui/components/device_groups.py000066400000000000000000000071761450214210500255430ustar00rootroot00000000000000# -*- 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 . 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.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.0.1/inputremapper/gui/components/editor.py000066400000000000000000001204751450214210500241710ustar00rootroot00000000000000# -*- 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 . """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.mapping import MappingData from inputremapper.configs.input_config import InputCombination, InputConfig from inputremapper.groups import DeviceType 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.configs.system_mapping import system_mapping, XKB_KEYCODE_OFFSET 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 = [ system_mapping.get_name(code) for code in self._combination if code is not None and system_mapping.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 # Thanks to https://github.com/wolfthefallen/py-GtkSourceCompletion-example # language_manager = GtkSource.LanguageManager() # fun fact: without saving LanguageManager into its own variable it doesn't work # python = language_manager.get_language("python") # source_view.get_buffer().set_language(python) # TODO there are some similarities with python, but overall it's quite useless. # commented out until there is proper highlighting for input-remappers syntax. 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 == "analog": self._stack.set_visible_child_name("Analog Axis") active = self._analog_toggle inactive = self._key_macro_toggle else: self._stack.set_visible_child_name("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 == "analog": self._set_active("analog") if mapping.mapping_type == "key_macro": self._set_active("key_macro") 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="key_macro") else: self._controller.update_mapping(mapping_type="analog") 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.0.1/inputremapper/gui/components/main.py000066400000000000000000000100371450214210500236170ustar00rootroot00000000000000# -*- 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 . """Components that wrap everything.""" from __future__ import annotations import gi 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.0.1/inputremapper/gui/components/presets.py000066400000000000000000000066311450214210500243650ustar00rootroot00000000000000# -*- 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 . """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.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.0.1/inputremapper/gui/controller.py000066400000000000000000000744051450214210500227020ustar00rootroot00000000000000# -*- 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 . 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, ) import gi from evdev.ecodes import EV_KEY, EV_REL, EV_ABS from gi.repository import Gtk from inputremapper.configs.mapping import ( MappingData, UIMapping, MacroButTypeOrCodeSetError, SymbolAndCodeMismatchError, MissingOutputAxisError, MissingMacroOrKeyError, OutputSymbolVariantError, ) from inputremapper.configs.paths import sanitize_path_component from inputremapper.configs.input_config import InputCombination, InputConfig from inputremapper.configs.validation_errors import pydantify from inputremapper.exceptions import DataManagementError 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.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): 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 _publish_mapping_errors_as_status_msg(self, *__): """Send mapping ValidationErrors to the MessageBroker.""" if not self.data_manager.active_preset: return if self.data_manager.active_preset.is_valid(): self.message_broker.publish(StatusData(CTX_MAPPING)) return for mapping in self.data_manager.active_preset: if not mapping.get_error(): continue position = mapping.format_name() error_strings = self._get_ui_error_strings(mapping) tooltip = "" if len(error_strings) == 0: # shouldn't be possible to get to this point logger.error("Expected an error") return elif 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] self.show_status( CTX_MAPPING, msg.replace("\n", " "), tooltip, ) @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(MissingMacroOrKeyError) in error_type and mapping.output_symbol is None ): error_message = _( "The input specifies a key or macro input, but no macro or key is " "programmed." ) 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 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." ), ) 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 = 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): """Show if the injection was successfully started.""" self.message_broker.unsubscribe(self.show_injector_result) state = msg.state def running(): 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 ) assert self.data_manager.active_preset # make mypy happy state_calls: Dict[InjectorState, Callable] = { InjectorState.RUNNING: running, InjectorState.FAILED: partial( self.show_status, CTX_ERROR, _("Failed to apply preset %s") % self.data_manager.active_preset.name, ), InjectorState.NO_GRAB: partial( self.show_status, CTX_ERROR, "The device was not grabbed", "Either another application is already grabbing it, " "your preset doesn't contain anything that is sent by the " "device or your preset contains errors", ), 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"] == "analog": 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"] == "key_macro": 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.0.1/inputremapper/gui/data_manager.py000066400000000000000000000526421450214210500231210ustar00rootroot00000000000000# -*- 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 . import glob import os import re import time from typing import Optional, List, Tuple, Set import gi from gi.repository import GLib from inputremapper.configs.global_config import GlobalConfig from inputremapper.configs.mapping import UIMapping, MappingData from inputremapper.configs.paths import get_preset_path, mkdir, split_all from inputremapper.configs.preset import Preset from inputremapper.configs.system_mapping import SystemMapping from inputremapper.daemon import DaemonProxy from inputremapper.configs.input_config import InputCombination, InputConfig from inputremapper.exceptions import DataManagementError from inputremapper.gui.gettext import _ from inputremapper.groups import _Group 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.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, system_mapping: SystemMapping, ): self.message_broker = message_broker self._reader_client = reader_client self._daemon = daemon self._uinputs = uinputs self._system_mapping = system_mapping 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 = get_preset_path(self.active_group.name) mkdir(device_folder) paths = glob.glob(os.path.join(device_folder, "*.json")) presets = [ os.path.splitext(os.path.basename(path))[0] for path in sorted(paths, key=os.path.getmtime) ] # the highest timestamp to the front presets.reverse() return 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 = [] for path in glob.glob(os.path.join(get_preset_path(), "*/*.json")): if self._reader_client.groups.find(key=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 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") paths = [ (path, os.path.getmtime(path)) for path in glob.glob( os.path.join(get_preset_path(self.active_group.name), "*.json") ) ] 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(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( 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 = 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 == 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 = 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 = 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 == 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 = 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 = 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._system_mapping.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.FAILED, 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.0.1/inputremapper/gui/gettext.py000066400000000000000000000022151450214210500221710ustar00rootroot00000000000000# -*- 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 . 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.0.1/inputremapper/gui/messages/000077500000000000000000000000001450214210500217425ustar00rootroot00000000000000input-remapper-2.0.1/inputremapper/gui/messages/__init__.py000066400000000000000000000000001450214210500240410ustar00rootroot00000000000000input-remapper-2.0.1/inputremapper/gui/messages/message_broker.py000066400000000000000000000077511450214210500253160ustar00rootroot00000000000000# -*- 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 . import os.path import re import traceback from collections import defaultdict, deque from typing import ( Callable, Dict, Set, Protocol, Tuple, Deque, Any, TYPE_CHECKING, ) from inputremapper.gui.messages.message_types import MessageType from inputremapper.logger import logger if TYPE_CHECKING: pass 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.0.1/inputremapper/gui/messages/message_data.py000066400000000000000000000071071450214210500247360ustar00rootroot00000000000000# -*- 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 . 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.0.1/inputremapper/gui/messages/message_types.py000066400000000000000000000033751450214210500251740ustar00rootroot00000000000000# -*- 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 . 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.0.1/inputremapper/gui/reader_client.py000066400000000000000000000241051450214210500233070ustar00rootroot00000000000000# -*- 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 . """Talking to the ReaderService that has root permissions. see gui.reader_service.ReaderService """ import time from typing import Optional, List, Generator, Dict, Tuple, Set import evdev import gi from gi.repository import GLib from inputremapper.configs.input_config import InputCombination from inputremapper.groups import _Groups, _Group from inputremapper.gui.reader_service import ( MSG_EVENT, MSG_GROUPS, CMD_TERMINATE, CMD_REFRESH_GROUPS, CMD_STOP_READING, get_pipe_paths, ReaderService, ) from inputremapper.gui.messages.message_types import MessageType from inputremapper.gui.messages.message_broker import MessageBroker from inputremapper.gui.messages.message_data import ( GroupsData, CombinationRecorded, StatusData, ) from inputremapper.gui.utils import CTX_ERROR from inputremapper.gui.gettext import _ from inputremapper.input_event import InputEvent from inputremapper.ipc.pipe import Pipe from inputremapper.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(get_pipe_paths()[0]) commands_pipe = Pipe(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.0.1/inputremapper/gui/reader_service.py000066400000000000000000000357331450214210500235020ustar00rootroot00000000000000# -*- 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 import evdev from evdev.ecodes import EV_KEY, EV_ABS, EV_REL, REL_HWHEEL, REL_WHEEL from inputremapper.utils import get_device_hash 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.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.logger import logger from inputremapper.user import USER # 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" def get_pipe_paths(): """Get the path where the pipe can be found.""" return ( f"/tmp/input-remapper-{USER}/reader-results", f"/tmp/input-remapper-{USER}/reader-commands", ) 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): """Construct the reader-service and initialize its communication pipes.""" self._start_time = time.time() self.groups = groups self._results_pipe = Pipe(get_pipe_paths()[0]) self._commands_pipe = Pipe(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 is_running(): """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()) 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) 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 ) 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) 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) 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) 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.0.1/inputremapper/gui/user_interface.py000066400000000000000000000366741450214210500235230ustar00rootroot00000000000000# -*- 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 . """User Interface.""" from typing import Dict, Callable import gi from gi.repository import Gtk, GtkSource, Gdk, GObject from inputremapper.configs.data import get_data_path from inputremapper.configs.mapping import MappingData from inputremapper.configs.input_config import InputCombination from inputremapper.gui.autocompletion import Autocompletion 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.presets import PresetSelection from inputremapper.gui.components.main import Stack, StatusBar from inputremapper.gui.components.common import Breadcrumbs from inputremapper.gui.components.device_groups import DeviceGroupSelection from inputremapper.gui.controller import Controller 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.logger import logger, COMMIT_HASH, VERSION, EVDEV_VERSION from inputremapper.gui.gettext import _ # 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.0.1/inputremapper/gui/utils.py000066400000000000000000000205111450214210500216440ustar00rootroot00000000000000# -*- 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 . from __future__ import annotations import time from dataclasses import dataclass from typing import List, Callable, Dict, Optional import gi from gi.repository import Gtk, GLib, Gdk from inputremapper.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) debounce_manager = DebounceManager() 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 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.0.1/inputremapper/injection/000077500000000000000000000000001450214210500213315ustar00rootroot00000000000000input-remapper-2.0.1/inputremapper/injection/__init__.py000066400000000000000000000000001450214210500234300ustar00rootroot00000000000000input-remapper-2.0.1/inputremapper/injection/context.py000066400000000000000000000107521450214210500233740ustar00rootroot00000000000000# -*- 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 . """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.input_config import DeviceHash from inputremapper.input_event import InputEvent from inputremapper.configs.preset import Preset from inputremapper.injection.mapping_handlers.mapping_handler import ( EventListener, NotifyCallback, ) from inputremapper.injection.mapping_handlers.mapping_parser import ( parse_mappings, EventPipelines, ) from inputremapper.logger import logger 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], ): if len(forward_devices) == 0: logger.warning("Not forward_devices set") if len(source_devices) == 0: logger.warning("Not source_devices set") self.listeners = set() self._source_devices = source_devices self._forward_devices = forward_devices self._notify_callbacks = defaultdict(list) self._handlers = 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] input-remapper-2.0.1/inputremapper/injection/event_reader.py000066400000000000000000000162111450214210500243470ustar00rootroot00000000000000# -*- 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 . """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.utils import get_device_hash, DeviceHash from inputremapper.injection.mapping_handlers.mapping_handler import ( EventListener, NotifyCallback, ) from inputremapper.input_event import InputEvent from inputremapper.logger import logger 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 form the set # fire and forget, run them in parallel and don't wait for them, since # a listener might be blocking forever while waiting for more events. asyncio.ensure_future(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) if not self.send_to_handlers(event): # 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: await self.handle( InputEvent.from_event(event, origin_hash=self._device_hash) ) except Exception as e: logger.error("Handling event %s failed: %s", event, e) traceback.print_exception(e) self.context.reset() logger.info("read loop for %s stopped", self._source.path) input-remapper-2.0.1/inputremapper/injection/global_uinputs.py000066400000000000000000000152311450214210500247340ustar00rootroot00000000000000# -*- 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 . from typing import Dict, Union, Tuple, Optional, List import evdev import inputremapper.exceptions import inputremapper.utils from inputremapper.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], ], } 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 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_, []) ] class UInput(evdev.UInput): def __init__(self, *args, **kwargs): name = kwargs["name"] logger.debug('creating UInput device: "%s"', name) super().__init__(*args, **kwargs) # this will never change, so we cache it since evdev runs an expensive loop to # gather the capabilities. (can_emit is called regularly) self._capabilities_cache = self.capabilities(absinfo=False) 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, """ 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, *args, events=None, name="py-evdev-uinput", **kwargs): # 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): self.devices: Dict[str, Union[UInput, FrontendUInput]] = {} self._uinput_factory = None self.is_service = inputremapper.utils.is_service() def __iter__(self): return iter(uinput for _, uinput in self.devices.items()) def reset(self): self.is_service = inputremapper.utils.is_service() self._uinput_factory = None self.devices = {} self.prepare_all() def ensure_uinput_factory_set(self): if self._uinput_factory is not None: return # overwrite global_uinputs.is_service in tests to control this if self.is_service: logger.debug("Creating regular UInputs") self._uinput_factory = UInput else: logger.debug("Creating FrontendUInputs") self._uinput_factory = FrontendUInput def prepare_all(self): """Generate UInputs.""" self.ensure_uinput_factory_set() 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. """ self.ensure_uinput_factory_set() 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) global_uinputs = GlobalUInputs() input-remapper-2.0.1/inputremapper/injection/injector.py000066400000000000000000000423251450214210500235260ustar00rootroot00000000000000# -*- 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 . """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, DeviceHash 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.numlock import set_numlock, is_numlock_on, ensure_numlock from inputremapper.logger import logger from inputremapper.utils import get_device_hash 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" FAILED = "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) -> None: """ Parameters ---------- group the device group """ self.group = group 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.FAILED 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: 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.stop() self._msg_pipe[0].send(InjectorState.STOPPED) return 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._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.0.1/inputremapper/injection/macros/000077500000000000000000000000001450214210500226155ustar00rootroot00000000000000input-remapper-2.0.1/inputremapper/injection/macros/__init__.py000066400000000000000000000000001450214210500247140ustar00rootroot00000000000000input-remapper-2.0.1/inputremapper/injection/macros/macro.py000066400000000000000000000574521450214210500243050ustar00rootroot00000000000000# -*- 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 . """Executes more complex patterns of keystrokes. To keep it short on the UI, basic functions are one letter long. The outermost macro (in the examples below the one created by 'r', 'r' and 'w') will be started, which triggers a chain reaction to execute all of the configured stuff. Examples -------- r(3, k(a).w(10)): a <10ms> a <10ms> a r(2, k(a).k(KEY_A)).k(b): a - a - b w(1000).m(Shift_L, r(2, k(a))).w(10).k(b): <1s> A A <10ms> b """ from __future__ import annotations import asyncio import copy import math import re from typing import List, Callable, Awaitable, Tuple, Optional, Union, Any from evdev.ecodes import ( ecodes, EV_KEY, EV_REL, REL_X, REL_Y, REL_WHEEL_HI_RES, REL_HWHEEL_HI_RES, REL_WHEEL, REL_HWHEEL, ) from inputremapper.configs.system_mapping import system_mapping from inputremapper.configs.validation_errors import ( SymbolNotAvailableInTargetError, MacroParsingError, ) from inputremapper.injection.global_uinputs import can_default_uinput_emit from inputremapper.ipc.shared_dict import SharedDict from inputremapper.logger import logger Handler = Callable[[Tuple[int, int, int]], None] MacroTask = Callable[[Handler], Awaitable] macro_variables = SharedDict() class Variable: """Can be used as function parameter in the various add_... functions. Parsed from strings like `$foo` in `repeat($foo, k(KEY_A))` Its value is unknown during construction and needs to be set using the `set` macro during runtime. """ def __init__(self, name: str): self.name = name def resolve(self): """Get the variables value from memory.""" return macro_variables.get(self.name) def __repr__(self): return f'' def _type_check(value: Any, allowed_types, display_name=None, position=None) -> Any: """Validate a parameter used in a macro. If the value is a Variable, it will be returned and should be resolved during runtime with _resolve. """ if isinstance(value, Variable): # it is a variable and will be read at runtime return value for allowed_type in allowed_types: if allowed_type is None: if value is None: return value continue # try to parse "1" as 1 if possible if allowed_type != Macro: # the macro constructor with a single argument always succeeds, # but will definitely not result in the correct macro try: return allowed_type(value) except (TypeError, ValueError): pass if isinstance(value, allowed_type): return value if display_name is not None and position is not None: raise MacroParsingError( msg=f"Expected parameter {position} for {display_name} to be " f"one of {allowed_types}, but got {value}" ) raise MacroParsingError( msg=f"Expected parameter to be one of {allowed_types}, but got {value}" ) def _type_check_variablename(name: str): """Check if this is a legit variable name. Because they could clash with language features. If the macro is able to be parsed at all due to a problematic choice of a variable name. Allowed examples: "foo", "Foo1234_", "_foo_1234" Not allowed: "1_foo", "foo=blub", "$foo", "foo,1234", "foo()" """ if not isinstance(name, str) or not re.match(r"^[A-Za-z_][A-Za-z_0-9]*$", name): raise MacroParsingError(msg=f'"{name}" is not a legit variable name') def _resolve(argument, allowed_types=None): """If the argument is a variable, figure out its value and cast it. Variables are prefixed with `$` in the syntax. Use this just-in-time when you need the actual value of the variable during runtime. """ if isinstance(argument, Variable): value = argument.resolve() logger.debug('"%s" is "%s"', argument, value) if allowed_types: return _type_check(value, allowed_types) else: return value return argument class Macro: """Supports chaining and preparing actions. Calling functions like keycode on Macro doesn't inject any events yet, it means that once .run is used it will be executed along with all other queued tasks. Those functions need to construct an asyncio coroutine and append it to self.tasks. This makes parameter checking during compile time possible, as long as they are not variables that are resolved durig runtime. Coroutines receive a handler as argument, which is a function that can be used to inject input events into the system. 1. A few parameters of any time are thrown into a macro function like `repeat` 2. `Macro.repeat` will verify the parameter types if possible using `_type_check` (it can't for $variables). This helps debugging macros before the injection starts, but is not mandatory to make things work. 3. `Macro.repeat` - adds a task to self.tasks. This task resolves any variables with `_resolve` and does what the macro is supposed to do once `macro.run` is called. - also adds the child macro to self.child_macros. - adds the used keys to the capabilities 4. `Macro.run` will run all tasks in self.tasks """ def __init__( self, code: Optional[str], context=None, 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 # TODO check if mapping is ever none by throwing an error # List of coroutines that will be called sequentially. # This is the compiled code self.tasks: List[MacroTask] = [] # can be used to wait for the release of the event self._trigger_release_event = asyncio.Event() self._trigger_press_event = asyncio.Event() # released by default self._trigger_release_event.set() self._trigger_press_event.clear() self.running = False self.child_macros: List[Macro] = [] self.keystroke_sleep_ms = None def is_holding(self): """Check if the macro is waiting for a key to be released.""" return not self._trigger_release_event.is_set() def get_capabilities(self): """Get the merged capabilities of the macro and its children.""" capabilities = copy.deepcopy(self.capabilities) for macro in self.child_macros: macro_capabilities = macro.get_capabilities() for type_ in macro_capabilities: if type_ not in capabilities: capabilities[type_] = set() capabilities[type_].update(macro_capabilities[type_]) return capabilities async def run(self, handler: Callable): """Run the macro. Parameters ---------- handler Will receive int type, code and value for an event to write """ if not callable(handler): raise ValueError("handler is not callable") if self.running: logger.error('Tried to run already running macro "%s"', self.code) return self.keystroke_sleep_ms = self.mapping.macro_key_sleep_ms self.running = True try: for task in self.tasks: coroutine = task(handler) 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.""" if self.is_holding(): logger.error("Already holding") return self._trigger_release_event.clear() self._trigger_press_event.set() for macro in self.child_macros: macro.press_trigger() def release_trigger(self): """The user released the trigger key.""" self._trigger_release_event.set() self._trigger_press_event.clear() for macro in self.child_macros: macro.release_trigger() async def _keycode_pause(self, _=None): """To add a pause between keystrokes. 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'' """Functions that prepare the macro.""" def add_key(self, symbol: str): """Write the symbol.""" # This is done to figure out if the macro is broken at compile time, because # if KEY_A was unknown we can show this in the gui before the injection starts. self._type_check_symbol(symbol) async def task(handler: Callable): # if the code is $foo, figure out the correct code now. resolved_symbol = _resolve(symbol, [str]) code = self._type_check_symbol(resolved_symbol) resolved_code = _resolve(code, [int]) handler(EV_KEY, resolved_code, 1) await self._keycode_pause() handler(EV_KEY, resolved_code, 0) await self._keycode_pause() self.tasks.append(task) def add_key_down(self, symbol: str): """Press the symbol.""" self._type_check_symbol(symbol) async def task(handler: Callable): resolved_symbol = _resolve(symbol, [str]) code = self._type_check_symbol(resolved_symbol) resolved_code = _resolve(code, [int]) handler(EV_KEY, resolved_code, 1) self.tasks.append(task) def add_key_up(self, symbol: str): """Release the symbol.""" self._type_check_symbol(symbol) async def task(handler: Callable): resolved_symbol = _resolve(symbol, [str]) code = self._type_check_symbol(resolved_symbol) resolved_code = _resolve(code, [int]) handler(EV_KEY, resolved_code, 0) self.tasks.append(task) def add_hold(self, macro=None): """Loops the execution until key release.""" _type_check(macro, [Macro, str, None], "hold", 1) if macro is None: self.tasks.append(lambda _: self._trigger_release_event.wait()) return if not isinstance(macro, Macro): # if macro is a key name, hold down the key while the # keyboard key is physically held down symbol = macro self._type_check_symbol(symbol) async def task(handler: Callable): resolved_symbol = _resolve(symbol, [str]) code = self._type_check_symbol(resolved_symbol) resolved_code = _resolve(code, [int]) handler(EV_KEY, resolved_code, 1) await self._trigger_release_event.wait() handler(EV_KEY, resolved_code, 0) self.tasks.append(task) if isinstance(macro, Macro): # repeat the macro forever while the key is held down async def task(handler: Callable): while self.is_holding(): # run the child macro completely to avoid # not-releasing any key await macro.run(handler) # give some other code a chance to run await asyncio.sleep(1 / 1000) self.tasks.append(task) self.child_macros.append(macro) def add_modify(self, modifier: str, macro: Macro): """Do stuff while a modifier is activated. Parameters ---------- modifier macro """ _type_check(macro, [Macro], "modify", 2) self._type_check_symbol(modifier) self.child_macros.append(macro) async def task(handler: Callable): # TODO test var resolved_modifier = _resolve(modifier, [str]) code = self._type_check_symbol(resolved_modifier) handler(EV_KEY, code, 1) await self._keycode_pause() await macro.run(handler) handler(EV_KEY, code, 0) await self._keycode_pause() self.tasks.append(task) def add_hold_keys(self, *symbols): """Hold down multiple keys, equivalent to `a + b + c + ...`.""" for symbol in symbols: self._type_check_symbol(symbol) async def task(handler: Callable): resolved_symbols = [_resolve(symbol, [str]) for symbol in symbols] codes = [self._type_check_symbol(symbol) for symbol in resolved_symbols] for code in codes: handler(EV_KEY, code, 1) await self._keycode_pause() await self._trigger_release_event.wait() for code in codes[::-1]: handler(EV_KEY, code, 0) await self._keycode_pause() self.tasks.append(task) def add_repeat(self, repeats: Union[str, int], macro: Macro): """Repeat actions.""" repeats = _type_check(repeats, [int], "repeat", 1) _type_check(macro, [Macro], "repeat", 2) async def task(handler: Callable): for _ in range(_resolve(repeats, [int])): await macro.run(handler) self.tasks.append(task) self.child_macros.append(macro) def add_event(self, type_: Union[str, int], code: Union[str, int], value: int): """Write any event. Parameters ---------- type_ examples: 2, 'EV_KEY' code examples: 52, 'KEY_A' value """ type_ = _type_check(type_, [int, str], "event", 1) code = _type_check(code, [int, str], "event", 2) value = _type_check(value, [int, str], "event", 3) if isinstance(type_, str): type_ = ecodes[type_.upper()] if isinstance(code, str): code = ecodes[code.upper()] self.tasks.append(lambda handler: handler(type_, code, value)) self.tasks.append(self._keycode_pause) def add_mouse(self, direction: str, speed: int): """Move the mouse cursor.""" _type_check(direction, [str], "mouse", 1) speed = _type_check(speed, [int], "mouse", 2) code, value = { "up": (REL_Y, -1), "down": (REL_Y, 1), "left": (REL_X, -1), "right": (REL_X, 1), }[direction.lower()] async def task(handler: Callable): resolved_speed = value * _resolve(speed, [int]) while self.is_holding(): handler(EV_REL, code, resolved_speed) await asyncio.sleep(1 / self.mapping.rel_rate) self.tasks.append(task) def add_wheel(self, direction: str, speed: int): """Move the scroll wheel.""" _type_check(direction, [str], "wheel", 1) speed = _type_check(speed, [int], "wheel", 2) 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()] async def task(handler: Callable): resolved_speed = _resolve(speed, [int]) remainder = [0.0, 0.0] while self.is_holding(): for i in range(0, 2): float_value = value[i] * resolved_speed + remainder[i] remainder[i] = math.fmod(float_value, 1) if abs(float_value) >= 1: handler(EV_REL, code[i], int(float_value)) await asyncio.sleep(1 / self.mapping.rel_rate) self.tasks.append(task) def add_wait(self, time: Union[int, float]): """Wait time in milliseconds.""" time = _type_check(time, [int, float], "wait", 1) async def task(_): await asyncio.sleep(_resolve(time, [int, float]) / 1000) self.tasks.append(task) def add_set(self, variable: str, value): """Set a variable to a certain value.""" _type_check_variablename(variable) async def task(_): # can also copy with set(a, $b) resolved_value = _resolve(value) logger.debug('"%s" set to "%s"', variable, resolved_value) macro_variables[variable] = value self.tasks.append(task) def add_add(self, variable: str, value: Union[int, float]): """Add a number to a variable.""" _type_check_variablename(variable) _type_check(value, [int, float], "value", 1) async def task(_): current = macro_variables[variable] if current is None: logger.debug('"%s" initialized with 0', variable) macro_variables[variable] = 0 current = 0 resolved_value = _resolve(value) if not isinstance(resolved_value, (int, float)): logger.error('Expected delta "%s" to be a number', resolved_value) return if not isinstance(current, (int, float)): logger.error( 'Expected variable "%s" to contain a number, but got "%s"', variable, current, ) return logger.debug('"%s" += "%s"', variable, resolved_value) macro_variables[variable] += value self.tasks.append(task) def add_ifeq(self, variable, value, then=None, else_=None): """Old version of if_eq, kept for compatibility reasons. This can't support a comparison like ifeq("foo", $blub) with blub containing "foo" without breaking old functionality, because "foo" is treated as a variable name. """ _type_check(then, [Macro, None], "ifeq", 3) _type_check(else_, [Macro, None], "ifeq", 4) async def task(handler: Callable): set_value = macro_variables.get(variable) logger.debug('"%s" is "%s"', variable, set_value) if set_value == value: if then is not None: await then.run(handler) elif else_ is not None: await else_.run(handler) if isinstance(then, Macro): self.child_macros.append(then) if isinstance(else_, Macro): self.child_macros.append(else_) self.tasks.append(task) def add_if_eq(self, value_1, value_2, then=None, else_=None): """Compare two values.""" _type_check(then, [Macro, None], "if_eq", 3) _type_check(else_, [Macro, None], "if_eq", 4) async def task(handler: Callable): resolved_value_1 = _resolve(value_1) resolved_value_2 = _resolve(value_2) if resolved_value_1 == resolved_value_2: if then is not None: await then.run(handler) elif else_ is not None: await else_.run(handler) if isinstance(then, Macro): self.child_macros.append(then) if isinstance(else_, Macro): self.child_macros.append(else_) self.tasks.append(task) def add_if_tap(self, then=None, else_=None, timeout=300): """If a key was pressed quickly. macro key pressed -> if_tap starts -> key released -> then macro key pressed -> released (does other stuff in the meantime) -> if_tap starts -> pressed -> released -> then """ _type_check(then, [Macro, None], "if_tap", 1) _type_check(else_, [Macro, None], "if_tap", 2) timeout = _type_check(timeout, [int, float], "if_tap", 3) if isinstance(then, Macro): self.child_macros.append(then) if isinstance(else_, Macro): self.child_macros.append(else_) async def wait(): """Wait for a release, or if nothing pressed yet, a press and release.""" if self.is_holding(): await self._trigger_release_event.wait() else: await self._trigger_press_event.wait() await self._trigger_release_event.wait() async def task(handler: Callable): resolved_timeout = _resolve(timeout, [int, float]) / 1000 try: await asyncio.wait_for(wait(), resolved_timeout) if then: await then.run(handler) except asyncio.TimeoutError: if else_: await else_.run(handler) self.tasks.append(task) def add_if_single(self, then, else_, timeout=None): """If a key was pressed without combining it.""" _type_check(then, [Macro, None], "if_single", 1) _type_check(else_, [Macro, None], "if_single", 2) if isinstance(then, Macro): self.child_macros.append(then) if isinstance(else_, Macro): self.child_macros.append(else_) async def task(handler: Callable): listener_done = asyncio.Event() async def listener(event): if event.type != EV_KEY: # ignore anything that is not a key return if event.value == 1: # another key was pressed, trigger else listener_done.set() return self.context.listeners.add(listener) resolved_timeout = _resolve(timeout, allowed_types=[int, float, None]) await asyncio.wait( [ asyncio.Task(listener_done.wait()), asyncio.Task(self._trigger_release_event.wait()), ], timeout=resolved_timeout / 1000 if resolved_timeout else None, return_when=asyncio.FIRST_COMPLETED, ) self.context.listeners.remove(listener) if not listener_done.is_set() and self._trigger_release_event.is_set(): if then: await then.run(handler) # was trigger release else: if else_: await else_.run(handler) self.tasks.append(task) def _type_check_symbol(self, keyname: Union[str, Variable]) -> Union[Variable, int]: """Same as _type_check, but checks if the key-name is valid.""" if isinstance(keyname, Variable): # it is a variable and will be read at runtime return keyname symbol = str(keyname) code = system_mapping.get(symbol) if code is None: raise MacroParsingError(msg=f'Unknown key "{symbol}"') if self.mapping is not None: target = self.mapping.target_uinput if target is not None and not can_default_uinput_emit(target, EV_KEY, code): raise SymbolNotAvailableInTargetError(symbol, target) return code input-remapper-2.0.1/inputremapper/injection/macros/parse.py000066400000000000000000000344001450214210500243020ustar00rootroot00000000000000# -*- 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 . """Parse macro code""" import inspect import re from typing import Optional, Any from inputremapper.configs.validation_errors import MacroParsingError from inputremapper.injection.macros.macro import Macro, Variable from inputremapper.logger import logger 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 TASK_FACTORIES = { "modify": Macro.add_modify, "repeat": Macro.add_repeat, "key": Macro.add_key, "key_down": Macro.add_key_down, "key_up": Macro.add_key_up, "event": Macro.add_event, "wait": Macro.add_wait, "hold": Macro.add_hold, "hold_keys": Macro.add_hold_keys, "mouse": Macro.add_mouse, "wheel": Macro.add_wheel, "if_eq": Macro.add_if_eq, "set": Macro.add_set, "if_tap": Macro.add_if_tap, "if_single": Macro.add_if_single, "add": Macro.add_add, # 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": Macro.add_modify, "r": Macro.add_repeat, "k": Macro.add_key, "e": Macro.add_event, "w": Macro.add_wait, "h": Macro.add_hold, # 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": Macro.add_ifeq, } def use_safe_argument_names(keyword_args): """Certain names cannot be used internally as parameters, Add a trailing "_". This is the PEP 8 compliant way of avoiding conflicts with built-ins: https://www.python.org/dev/peps/pep-0008/#descriptive-naming-styles For example the macro `if_eq(1, 1, else=k(b))` uses the else_ parameter of `def add_if_eq` to work. """ # extend this list with parameter names that cannot be used in python, but should # be used in macro code. built_ins = ["else", "type"] keys = keyword_args.keys() for built_in in built_ins: if built_in in keys: keyword_args[f"{built_in}_"] = keyword_args[built_in] del keyword_args[built_in] def get_macro_argument_names(function): """Certain names, like "else" or "type" cannot be used as parameters in python. Removes the trailing "_" for displaying them correctly. """ args = inspect.getfullargspec(function).args[1:] # don't include "self" arg_names = [name[:-1] if name.endswith("_") else name for name in args] varargs = inspect.getfullargspec(function).varargs if varargs: arg_names.append(f"*{varargs}") return arg_names def get_num_parameters(function): """Get the number of required parameters and the maximum number of parameters.""" fullargspec = inspect.getfullargspec(function) num_args = len(fullargspec.args) - 1 # one of them is `self` min_num_args = num_args - len(fullargspec.defaults or ()) if fullargspec.varargs is not None: max_num_args = float("inf") else: max_num_args = num_args return min_num_args, max_num_args 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 def _count_brackets(macro): """Find where the first opening bracket closes.""" openings = macro.count("(") closings = macro.count(")") if openings != closings: raise MacroParsingError( 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 def _split_keyword_arg(param): """Split "foo=bar" into "foo" and "bar". If not a keyward param, return None and the param. """ if re.match(r"[a-zA-Z_][a-zA-Z_\d]*=.+", param): split = param.split("=", 1) return split[0], split[1] return None, param def _is_number(value): """Check if the value can be turned into a number.""" try: float(value) return True except ValueError: return False def _parse_recurse( code: str, context, mapping, verbose: bool, macro_instance: Optional[Macro] = None, depth: int = 0, ): """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. TODO add some examples. Are all of "foo(1);bar(2)" "foo(1)" and "1" valid inputs? 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() if code == "" or code == "None": # A function parameter probably # I think "" is the deprecated alternative to "None" return None if code.startswith('"'): # TODO and endswith check, if endswith fails throw error? # what is currently the error if only one quote is set? # a string, don't parse. remove quotes string = code[1:-1] debug("%sstring %s", space, string) return string if code.startswith("$"): # will be resolved during the macros runtime return Variable(code.split("$", 1)[1]) if _is_number(code): if "." in code: code = float(code) else: code = int(code) debug("%snumber %s", space, code) return code # is it another macro? call_match = re.match(r"^(\w+)\(", code) call = call_match[1] if call_match else None if call is not None: if macro_instance is None: # start a new chain macro_instance = Macro(code, context, mapping) else: # chain this call to the existing instance assert isinstance(macro_instance, Macro) task_factory = TASK_FACTORIES.get(call) if task_factory is None: raise MacroParsingError(code, f"Unknown function {call}") # get all the stuff inbetween closing_bracket_position = _count_brackets(code) - 1 inner = code[code.index("(") + 1 : closing_bracket_position] debug("%scalls %s with %s", space, call, inner) # split "3, foo=a(2, k(a).w(10))" into arguments raw_string_args = _extract_args(inner) # parse and sort the params positional_args = [] keyword_args = {} for param in raw_string_args: key, value = _split_keyword_arg(param) parsed = _parse_recurse( value.strip(), context, mapping, verbose, None, depth + 1 ) if key is None: if len(keyword_args) > 0: msg = f'Positional argument "{key}" follows keyword argument' raise MacroParsingError(code, msg) positional_args.append(parsed) else: if key in keyword_args: raise MacroParsingError( code, f'The "{key}" argument was specified twice' ) keyword_args[key] = parsed debug( "%sadd call to %s with %s, %s", space, call, positional_args, keyword_args, ) min_args, max_args = get_num_parameters(task_factory) num_provided_args = len(raw_string_args) if num_provided_args < min_args or num_provided_args > max_args: if min_args != max_args: msg = ( f"{call} takes between {min_args} and {max_args}, " f"not {num_provided_args} parameters" ) else: msg = f"{call} takes {min_args}, not {num_provided_args} parameters" raise MacroParsingError(code, msg) use_safe_argument_names(keyword_args) try: task_factory(macro_instance, *positional_args, **keyword_args) except TypeError as exception: raise MacroParsingError(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) _parse_recurse(chain, context, mapping, verbose, macro_instance, depth) elif re.match(r"[a-zA-Z_]", next_char): # something like foo()bar raise MacroParsingError( code, f'Expected a "." to follow after ' f"{code[:closing_bracket_position + 1]}", ) return macro_instance # It is probably either a key name like KEY_A or a variable name as in `set(var,1)`, # both won't contain special characters that can break macro syntax so they don't # have to be wrapped in quotes. debug("%sstring %s", space, code) return code 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 MacroParsingError( macro, f'Mixing "+" and macros is unsupported: "{ macro}"' ) chunks = [chunk.strip() for chunk in macro.split("+")] if "" in chunks: raise MacroParsingError(f'Invalid syntax for "{macro}"') output = f"hold_keys({','.join(chunks)})" logger.debug('Transformed "%s" to "%s"', macro, output) return output def remove_whitespaces(macro, delimiter='"'): """Remove whitespaces, tabs, newlines and such outside of string quotes.""" result = "" for i, chunk in enumerate(macro.split(delimiter)): # every second chunk is inside string quotes if i % 2 == 0: result += re.sub(r"\s", "", chunk) else: result += chunk result += delimiter # one extra delimiter was added return result[: -len(delimiter)] def remove_comments(macro): """Remove comments from the macro and return the resulting code.""" # keep hashtags inside quotes intact result = "" for i, line in enumerate(macro.split("\n")): for j, chunk in enumerate(line.split('"')): if j > 0: # add back the string quote chunk = f'"{chunk}' # every second chunk is inside string quotes if j % 2 == 0 and "#" in chunk: # everything from now on is a comment and can be ignored result += chunk.split("#")[0] break else: result += chunk if i < macro.count("\n"): result += "\n" return result def clean(code): """Remove everything irrelevant for the macro.""" return remove_whitespaces(remove_comments(code), '"') def parse(macro: str, context=None, mapping=None, verbose: bool = True): """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 = clean(macro) macro = handle_plus_syntax(macro) macro_obj = _parse_recurse(macro, context, mapping, verbose) if not isinstance(macro_obj, Macro): raise MacroParsingError(macro, "The provided code was not a macro") return macro_obj input-remapper-2.0.1/inputremapper/injection/mapping_handlers/000077500000000000000000000000001450214210500246445ustar00rootroot00000000000000input-remapper-2.0.1/inputremapper/injection/mapping_handlers/__init__.py000066400000000000000000000014651450214210500267630ustar00rootroot00000000000000# -*- 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 . input-remapper-2.0.1/inputremapper/injection/mapping_handlers/abs_to_abs_handler.py000066400000000000000000000127221450214210500310130ustar00rootroot00000000000000# -*- 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 . from typing import Tuple, Optional, Dict import evdev from evdev.ecodes import EV_ABS from inputremapper.configs.input_config import InputCombination, InputConfig from inputremapper import exceptions from inputremapper.configs.mapping import Mapping from inputremapper.injection.global_uinputs import global_uinputs 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.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, **_, ) -> None: super().__init__(combination, mapping) # 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: 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.0.1/inputremapper/injection/mapping_handlers/abs_to_btn_handler.py000066400000000000000000000103341450214210500310260ustar00rootroot00000000000000# -*- 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 . 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.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, **_, ): super().__init__(combination, mapping) 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.0.1/inputremapper/injection/mapping_handlers/abs_to_rel_handler.py000066400000000000000000000167541450214210500310410ustar00rootroot00000000000000# -*- 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 . 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 global_uinputs 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.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, **_, ) -> None: super().__init__(combination, mapping) # 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: 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.0.1/inputremapper/injection/mapping_handlers/axis_switch_handler.py000066400000000000000000000141431450214210500312430ustar00rootroot00000000000000# -*- 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 . from typing import Dict, Tuple, Hashable, TYPE_CHECKING import evdev from inputremapper.configs.input_config import InputConfig from inputremapper.configs.input_config import InputCombination from inputremapper.configs.mapping import Mapping from inputremapper.injection.mapping_handlers.mapping_handler import ( MappingHandler, HandlerEnums, InputEventHandler, ContextProtocol, ) from inputremapper.input_event import InputEvent, EventActions from inputremapper.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, **_, ): super().__init__(combination, mapping) 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.0.1/inputremapper/injection/mapping_handlers/axis_transform.py000066400000000000000000000114171450214210500302610ustar00rootroot00000000000000# -*- 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 . 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.0.1/inputremapper/injection/mapping_handlers/combination_handler.py000066400000000000000000000160211450214210500312150ustar00rootroot00000000000000# -*- 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 . from __future__ import annotations # needed for the TYPE_CHECKING import from typing import TYPE_CHECKING, Dict, Hashable 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.mapping_handlers.mapping_handler import ( MappingHandler, InputEventHandler, HandlerEnums, ) from inputremapper.input_event import InputEvent from inputremapper.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] _output_state: bool # the last update we sent to a sub-handler _sub_handler: InputEventHandler _handled_input_hashes: list[Hashable] def __init__( self, combination: InputCombination, mapping: Mapping, context: Context, **_, ) -> None: logger.debug(str(mapping)) super().__init__(combination, mapping) self._pressed_keys = {} self._output_state = False self._context = context # 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 was_activated = self.is_activated() # 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) is_activated = self.is_activated() if is_activated == was_activated or is_activated == self._output_state: # nothing changed if self._output_state: # combination is active, consume the event return True else: # combination inactive, forward the event return False if is_activated: # send key up events to the forwarded uinput self.forward_release() event = event.modify(value=1) else: if self._output_state or self.mapping.is_axis_mapping(): # we ignore the suppress argument for release events # otherwise we might end up with stuck keys # (test_event_pipeline.test_combination) # we also ignore it if the mapping specifies an output axis # this will enable us to activate multiple axis with the same button suppress = False event = event.modify(value=0) if suppress: return False logger.debug("Sending %s to sub-handler", self.mapping.input_combination) self._output_state = bool(event.value) return self._sub_handler.notify(event, source, suppress) def reset(self) -> None: self._sub_handler.reset() for key in self._pressed_keys: self._pressed_keys[key] = False self._output_state = 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: 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() 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.0.1/inputremapper/injection/mapping_handlers/hierarchy_handler.py000066400000000000000000000063271450214210500307010ustar00rootroot00000000000000# -*- 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 . 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.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 ) -> 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) 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 success = False for handler in self.handlers: if not success: success = handler.notify(event, source) else: handler.notify(event, source, suppress=True) return success 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.0.1/inputremapper/injection/mapping_handlers/key_handler.py000066400000000000000000000060641450214210500275110ustar00rootroot00000000000000# -*- 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 . from typing import Tuple, Dict from inputremapper.configs.input_config import InputCombination from inputremapper import exceptions from inputremapper.configs.mapping import Mapping from inputremapper.exceptions import MappingParsingError from inputremapper.injection.global_uinputs import global_uinputs from inputremapper.injection.mapping_handlers.mapping_handler import ( MappingHandler, HandlerEnums, ) from inputremapper.input_event import InputEvent from inputremapper.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, **_, ): super().__init__(combination, mapping) 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: 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) 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.0.1/inputremapper/injection/mapping_handlers/macro_handler.py000066400000000000000000000065541450214210500300260ustar00rootroot00000000000000# -*- 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 . import asyncio from typing import Dict, Callable from inputremapper.configs.input_config import InputCombination from inputremapper.configs.mapping import Mapping from inputremapper.injection.global_uinputs import global_uinputs from inputremapper.injection.macros.macro import Macro from inputremapper.injection.macros.parse import parse from inputremapper.injection.mapping_handlers.mapping_handler import ( ContextProtocol, MappingHandler, HandlerEnums, ) from inputremapper.input_event import InputEvent from inputremapper.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, *, context: ContextProtocol, ): super().__init__(combination, mapping) self._active = False assert self.mapping.output_symbol is not None self._macro = 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: %s', self._macro.code, exception) 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.""" global_uinputs.write((type_, code, value), self.mapping.target_uinput) asyncio.ensure_future(self.run_macro(handler)) return True else: self._active = False if self._macro.is_holding(): self._macro.release_trigger() return True def reset(self) -> None: self._active = False if self._macro.is_holding(): self._macro.release_trigger() def needs_wrapping(self) -> bool: return True def wrap_with(self) -> Dict[InputCombination, HandlerEnums]: return {InputCombination(self.input_configs): HandlerEnums.combination} input-remapper-2.0.1/inputremapper/injection/mapping_handlers/mapping_handler.py000066400000000000000000000145751450214210500303620ustar00rootroot00000000000000# -*- 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 . """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.input_event import InputEvent from inputremapper.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, **_, ) -> 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 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.0.1/inputremapper/injection/mapping_handlers/mapping_parser.py000066400000000000000000000304351450214210500302320ustar00rootroot00000000000000# -*- 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 . """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.mapping import Mapping from inputremapper.configs.preset import Preset from inputremapper.configs.system_mapping import DISABLE_CODE, DISABLE_NAME from inputremapper.exceptions import MappingParsingError from inputremapper.injection.macros.parse import is_this_a_macro 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.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, } def parse_mappings(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 = _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, ) # layer other handlers on top until the outer handler needs ranking or can # directly handle a input event handlers.extend(_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 = _create_hierarchy_handlers(need_ranking) for handler in ranked_handlers: handlers.extend(_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( 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) 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(_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(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 is_this_a_macro(mapping.output_symbol): return HandlerEnums.macro return HandlerEnums.key if mapping.output_type == EV_KEY: return HandlerEnums.key input_event = _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(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( 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 = _order_combinations(combinations_with_event, event) sub_handlers: List[MappingHandler] = [] for combination in sorted_combinations: sub_handlers.append(*handlers[combination]) sorted_handlers.add(HierarchyHandler(sub_handlers, event)) 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( 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 _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(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.0.1/inputremapper/injection/mapping_handlers/null_handler.py000066400000000000000000000040531450214210500276670ustar00rootroot00000000000000# -*- 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 . 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.0.1/inputremapper/injection/mapping_handlers/rel_to_abs_handler.py000066400000000000000000000212471450214210500310320ustar00rootroot00000000000000# -*- 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 . 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.configs.input_config import InputCombination, InputConfig from inputremapper import exceptions from inputremapper.configs.mapping import ( Mapping, WHEEL_SCALING, WHEEL_HI_RES_SCALING, REL_XY_SCALING, DEFAULT_REL_RATE, ) from inputremapper.injection.global_uinputs import global_uinputs 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.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, **_, ) -> None: super().__init__(combination, mapping) # 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: 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( (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: 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.0.1/inputremapper/injection/mapping_handlers/rel_to_btn_handler.py000066400000000000000000000112541450214210500310450ustar00rootroot00000000000000# -*- 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 . 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.mapping_handlers.mapping_handler import ( MappingHandler, InputEventHandler, ) from inputremapper.input_event import InputEvent, EventActions from inputremapper.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, **_, ) -> None: super().__init__(combination, mapping) 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.0.1/inputremapper/injection/mapping_handlers/rel_to_rel_handler.py000066400000000000000000000231241450214210500310430ustar00rootroot00000000000000# -*- 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 . 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.configs.input_config import InputCombination, InputConfig from inputremapper import exceptions from inputremapper.configs.mapping import ( Mapping, REL_XY_SCALING, WHEEL_SCALING, WHEEL_HI_RES_SCALING, ) from inputremapper.injection.global_uinputs import global_uinputs 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.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, **_, ) -> None: super().__init__(combination, mapping) 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 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.0.1/inputremapper/injection/numlock.py000066400000000000000000000045151450214210500233600ustar00rootroot00000000000000# -*- 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 . """Functions to handle numlocks. For unknown reasons the numlock status can change when starting injections, which is why these functions exist. """ import re import subprocess from inputremapper.logger import logger def is_numlock_on(): """Get the current state of the numlock.""" try: xset_q = subprocess.check_output( ["xset", "q"], stderr=subprocess.STDOUT, ).decode() num_lock_status = re.search(r"Num Lock:\s+(.+?)\s", xset_q) if num_lock_status is not None: return num_lock_status[1] == "on" return False except (FileNotFoundError, subprocess.CalledProcessError): # tty return None def set_numlock(state): """Set the numlock to a given state of True or False.""" if state is None: return value = {True: "on", False: "off"}[state] try: subprocess.check_output(["numlockx", value]) except subprocess.CalledProcessError: # might be in a tty pass except FileNotFoundError: # doesn't seem to be installed everywhere logger.debug("numlockx not found") def ensure_numlock(func): """Decorator to reset the numlock to its initial state afterwards.""" def wrapped(*args, **kwargs): # for some reason, grabbing a device can modify the num lock state. # remember it and apply back later numlock_before = is_numlock_on() result = func(*args, **kwargs) set_numlock(numlock_before) return result return wrapped input-remapper-2.0.1/inputremapper/input_event.py000066400000000000000000000173501450214210500222670ustar00rootroot00000000000000# -*- 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 . 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 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 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 # 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[str] = None forward_to: Optional[evdev.UInput] = 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") @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[str] = 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[str] = 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 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[str] = None): """Create an abs event, like joystick movements.""" return 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 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 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.0.1/inputremapper/ipc/000077500000000000000000000000001450214210500201225ustar00rootroot00000000000000input-remapper-2.0.1/inputremapper/ipc/__init__.py000066400000000000000000000020101450214210500222240ustar00rootroot00000000000000# -*- 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 . """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.0.1/inputremapper/ipc/pipe.py000066400000000000000000000137121450214210500214350ustar00rootroot00000000000000# -*- 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 . """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 mkdir, chown from inputremapper.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") mkdir(os.path.dirname(path)) if not os.path.exists(paths[0]): logger.debug('Creating new pipe for "%s"', path) # The fd the link points to is closed, or none ever existed # If there is a link, remove it. if os.path.islink(paths[0]): os.remove(paths[0]) if os.path.islink(paths[1]): os.remove(paths[1]) self._fds = os.pipe() fds_dir = f"/proc/{os.getpid()}/fd/" chown(f"{fds_dir}{self._fds[0]}") chown(f"{fds_dir}{self._fds[1]}") # to make it accessible by path constants, create symlinks os.symlink(f"{fds_dir}{self._fds[0]}", paths[0]) os.symlink(f"{fds_dir}{self._fds[1]}", paths[1]) else: logger.debug('Using existing pipe for "%s"', path) # thanks to os.O_NONBLOCK, readline will return b'' when there # is nothing to read self._fds = ( os.open(paths[0], os.O_RDONLY | os.O_NONBLOCK), os.open(paths[1], os.O_WRONLY | os.O_NONBLOCK), ) self._handles = (open(self._fds[0], "r"), open(self._fds[1], "w")) # 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.0.1/inputremapper/ipc/shared_dict.py000066400000000000000000000074161450214210500227550ustar00rootroot00000000000000# -*- 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 . """Share a dictionary across processes.""" import atexit import multiprocessing import select from typing import Optional, Any from inputremapper.logger import logger class SharedDict: """Share a dictionary across processes.""" # because unittests terminate all child processes in cleanup I can't use # multiprocessing.Manager def __init__(self): """Create a shared dictionary.""" super().__init__() # To avoid blocking forever if something goes wrong. The maximum # observed time communication takes was 0.001 for me on a slow pc self._timeout = 0.02 self.pipe = multiprocessing.Pipe() self.process = None atexit.register(self._stop) def start(self): """Ensure the process to manage the dictionary is running.""" if self.process is not None and self.process.is_alive(): logger.debug("SharedDict process already running") return # if the manager has already been running in the past but stopped # for some reason, the dictionary contents are lost. logger.debug("Starting SharedDict process") self.process = multiprocessing.Process(target=self.manage) self.process.start() def manage(self): """Manage the dictionary, handle read and write requests.""" logger.debug("SharedDict process started") shared_dict = {} while True: message = self.pipe[0].recv() logger.debug("SharedDict got %s", message) if message[0] == "stop": return if message[0] == "set": shared_dict[message[1]] = message[2] if message[0] == "clear": shared_dict.clear() if message[0] == "get": self.pipe[0].send(shared_dict.get(message[1])) if message[0] == "ping": self.pipe[0].send("pong") def _stop(self): """Stop the managing process.""" self.pipe[1].send(("stop",)) def _clear(self): """Clears the memory.""" self.pipe[1].send(("clear",)) def get(self, key: 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.0.1/inputremapper/ipc/socket.py000066400000000000000000000222571450214210500217740ustar00rootroot00000000000000# -*- 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 . """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 mkdir, chown from inputremapper.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 = [] 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) 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.0.1/inputremapper/logger.py000066400000000000000000000251631450214210500212070ustar00rootroot00000000000000# -*- 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 . """Logging setup for input-remapper.""" import logging import os import sys import time from datetime import datetime from typing import cast 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 def parse_mapping_handler(mapping_handler): indent = 0 lines_and_indent = [] while True: if isinstance(handler, str): lines_and_indent.append([mapping_handler, indent]) break if isinstance(mapping_handler, list): for sub_handler in mapping_handler: sub_list = 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 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 = 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) # https://github.com/python/typeshed/issues/1801 logging.setLoggerClass(Logger) logger = cast(Logger, logging.getLogger("input-remapper")) def is_debug(): """True, if the logger is currently in DEBUG or DEBUG mode.""" return logger.level <= logging.DEBUG class ColorfulFormatter(logging.Formatter): """Overwritten Formatter to print nicer logs. It colors all logs from the same filename in the same color to visually group them together. It also adds process name, process id, file, line-number and time. If debug mode is not active, it will not do any of this. """ def __init__(self): super().__init__() self.file_color_mapping = {} # see https://en.wikipedia.org/wiki/ANSI_escape_code#8-bit self.allowed_colors = [] for r in range(0, 6): for g in range(0, 6): for b in range(0, 6): # https://stackoverflow.com/a/596243 brightness = 0.2126 * r + 0.7152 * g + 0.0722 * b if brightness < 1: # prefer light colors, because most people have a dark # terminal background continue if g + b <= 1: # red makes it look like it's an error continue if abs(g - b) < 2 and abs(b - r) < 2 and abs(r - g) < 2: # no colors that are too grey continue self.allowed_colors.append(self._get_ansi_code(r, g, b)) self.level_based_colors = { logging.WARNING: 11, logging.ERROR: 9, logging.FATAL: 9, } def _get_ansi_code(self, r: int, g: int, b: int): return 16 + b + (6 * g) + (36 * r) def _word_to_color(self, word: str): """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.""" debug_mode = is_debug() if record.levelno == logging.INFO and not debug_mode: # if not launched with --debug, then don't print "INFO:" return "%(message)s" if not debug_mode: color = self.level_based_colors[record.levelno] return f"\033[38;5;{color}m%(levelname)s\033[0m: %(message)s" color = self._allocate_debug_log_color(record) if record.levelno in [logging.ERROR, logging.WARNING, logging.FATAL]: # underline style = f"\033[4;38;5;{color}m" else: style = f"\033[38;5;{color}m" process_color = self._word_to_color(f"{os.getpid()}{sys.argv[0]}") return ( # noqa f'{datetime.now().strftime("%H:%M:%S.%f")} ' f"\033[38;5;{process_color}m" # color f"{os.getpid()} " f"{self._get_process_name()} " "\033[0m" # end style f"{style}" f"%(levelname)s " f"%(filename)s:%(lineno)d: " "%(message)s" "\033[0m" # end style ).replace(" ", " ") def format(self, record: logging.LogRecord): """Overwritten format function.""" # pylint: disable=protected-access self._style._fmt = self._get_format(record) return super().format(record) handler = logging.StreamHandler() handler.setFormatter(ColorfulFormatter()) logger.addHandler(handler) logger.setLevel(logging.INFO) logging.getLogger("asyncio").setLevel(logging.WARNING) # using pkg_resources to figure out the version fails in many cases, # so we hardcode it instead VERSION = "2.0.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 def log_info(name="input-remapper"): """Log version and name to the console.""" logger.info( "%s %s %s https://github.com/sezanzeb/input-remapper", name, VERSION, COMMIT_HASH, ) if EVDEV_VERSION: logger.info("python-evdev %s", EVDEV_VERSION) if is_debug(): logger.warning( "Debug level will log all your keystrokes! Do not post this " "output in the internet if you typed in sensitive or private " "information with your device!" ) def update_verbosity(debug): """Set the logging verbosity according to the settings object. Also enable rich tracebacks in debug mode. """ # pylint really doesn't like what I'm doing with rich.traceback here # pylint: disable=broad-except,import-error,import-outside-toplevel if debug: logger.setLevel(logging.DEBUG) try: from rich.traceback import install install(show_locals=True) logger.debug("Using rich.traceback") except Exception as error: # since this is optional, just skip all exceptions if not isinstance(error, ImportError): logger.debug("Cannot use rich.traceback: %s", error) else: logger.setLevel(logging.INFO) def trim_logfile(log_path): """Keep the logfile short.""" if not os.path.exists(log_path): return file_size_mb = os.path.getsize(log_path) / 1000 / 1000 if file_size_mb > 100: # something went terribly wrong here. The service might timeout because # it takes too long to trim this file. delete it instead. This probably # only happens when doing funny things while in debug mode. logger.warning( "Removing enormous log file of %dMB", file_size_mb, ) os.remove(log_path) return # the logfile should not be too long to avoid overflowing the storage try: with open(log_path, "rb") as file: binary = file.readlines()[-1000:] content = [line.decode("utf-8", errors="ignore") for line in binary] with open(log_path, "w") as file: file.truncate(0) file.writelines(content) except PermissionError: # let the outermost PermissionError handler handle it raise except Exception as exception: logger.error('Failed to trim logfile: "%s"', str(exception)) input-remapper-2.0.1/inputremapper/user.py000066400000000000000000000034041450214210500207000ustar00rootroot00000000000000# -*- 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 . """Figure out the user.""" import getpass import os import pwd def get_user(): """Try to find the user who called sudo/pkexec.""" try: return os.getlogin() except OSError: # failed in some ubuntu installations and in systemd services pass try: user = os.environ["USER"] except KeyError: # possibly the systemd service. no sudo was used return getpass.getuser() if user == "root": try: return os.environ["SUDO_USER"] except KeyError: # no sudo was used pass try: pkexec_uid = int(os.environ["PKEXEC_UID"]) return pwd.getpwuid(pkexec_uid).pw_name except KeyError: # no pkexec was used or the uid is unknown pass return user def get_home(user): """Try to find the user's home directory.""" return pwd.getpwnam(user).pw_dir USER = get_user() HOME = get_home(USER) input-remapper-2.0.1/inputremapper/utils.py000066400000000000000000000036561450214210500210730ustar00rootroot00000000000000# -*- 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 . """Utility functions.""" import sys from hashlib import md5 from typing import Optional import evdev 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 s = str(device.capabilities(absinfo=False)) + device.name return 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 isinstance(name, list): name = name[0] if name is None: return "unknown" return name input-remapper-2.0.1/po/000077500000000000000000000000001450214210500150725ustar00rootroot00000000000000input-remapper-2.0.1/po/fr.po000077700000000000000000000000001450214210500173722fr_FR.poustar00rootroot00000000000000input-remapper-2.0.1/po/fr_FR.po000066400000000000000000000343461450214210500164420ustar00rootroot00000000000000# 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.0.1/po/input-remapper.pot000066400000000000000000000241671450214210500206000ustar00rootroot00000000000000# 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.0.1/po/it.po000077700000000000000000000000001450214210500175462./it_IT.poustar00rootroot00000000000000input-remapper-2.0.1/po/it_IT.po000066400000000000000000000366141450214210500164540ustar00rootroot00000000000000# ITALIAN TRANSLATION FOR INPUT-REMAPPER. # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER # This file is distributed under the same license as the PACKAGE package. # ALBANO BATTISTELLA , 2021. # msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" "POT-Creation-Date: 2023-05-16 08:30-0300\n" "PO-Revision-Date: 2021-07-18 19:23+0200\n" "Last-Translator: ALbano Battistella \n" "Language-Team: ITALIAN \n" "Language: it\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" #: 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 "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 "" #: 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 "Applica" #: 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 "Caricamento automatico" #: 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 "Copia" #: 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 "Cancella" #: 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 "Dispositivo" #: data/input-remapper.glade:148 #, fuzzy msgid "Devices" msgstr "Dispositivo" #: data/input-remapper.glade:356 msgid "Duplicate this preset" msgstr "" #: data/input-remapper.glade:1208 msgid "Editor" msgstr "" #: inputremapper/configs/mapping.py:77 #, fuzzy msgid "Empty Mapping" msgstr "Mappatura" #: 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 "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 "Nuovo" #: 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 "Preimpostato" #: data/input-remapper.glade:272 #, fuzzy msgid "Presets" msgstr "Preimpostato" #: 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 "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 "" #: 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 #, fuzzy msgid "Start injecting. Don't hold down any keys while the injection starts" msgstr "Non tenere premuto alcun tasto durante l'avvio dell'iniezione." #: inputremapper/gui/controller.py:643 #, fuzzy msgid "Starting injection..." msgstr "ferma l'iniezione" #: data/input-remapper.glade:201 data/input-remapper.glade:334 msgid "Stop" msgstr "Ripristina impostazioni predefinite" #: inputremapper/gui/controller.py:710 #, fuzzy msgid "Stopped the injection" msgstr "ferma 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 "" #: 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 "Uso" #: 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 "Versione sconosciuta" #: 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 "" "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 "" #: 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 "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 "" #: inputremapper/gui/data_manager.py:56 #, fuzzy msgid "new preset" msgstr "Preimpostato" #: 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 "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.0.1/po/pt.po000077700000000000000000000000001450214210500174162pt_BR.poustar00rootroot00000000000000input-remapper-2.0.1/po/pt_BR.po000066400000000000000000000356431450214210500164530ustar00rootroot00000000000000# 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.0.1/po/ru.po000077700000000000000000000000001450214210500176042./ru_RU.poustar00rootroot00000000000000input-remapper-2.0.1/po/ru_RU.po000066400000000000000000000346541450214210500165020ustar00rootroot00000000000000# 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.0.1/po/sk.po000077700000000000000000000000001450214210500175512./sk_SK.poustar00rootroot00000000000000input-remapper-2.0.1/po/sk_SK.po000066400000000000000000000431751450214210500164560ustar00rootroot00000000000000# 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.0.1/po/uk.po000077700000000000000000000000001450214210500174102uk_UA.poustar00rootroot00000000000000input-remapper-2.0.1/po/uk_UA.po000066400000000000000000000416101450214210500164400ustar00rootroot00000000000000# 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.0.1/po/zh.po000077700000000000000000000000001450214210500175442./zh_CN.poustar00rootroot00000000000000input-remapper-2.0.1/po/zh_CN.po000066400000000000000000000326041450214210500164400ustar00rootroot00000000000000# 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.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 "" #: 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.0.1/readme/000077500000000000000000000000001450214210500157115ustar00rootroot00000000000000input-remapper-2.0.1/readme/architecture.png000066400000000000000000007651761450214210500211270ustar00rootroot00000000000000‰PNG  IHDR®œ\3u¬oiCCPicc(‘u‘½KBQÆjQ˜Ñ`CTƒCE„‚DcÙà"!VբׯÀk—{•Ö ¥AhˆZúúj Z ‚ "šúúZBnïÑ@ ;—sßÏ9ÏË9Ïg$¯éV[ôBÑŒ…C¾¥ø²¯ã7x£7¡YÆL4áßñuCÕ»€êõÿ¾–£+•¶4pt Oj†YžŽl Å;½Z.‘>ö›r@ák¥'ëü¢8[çÅæBlœª§/ÛÄÉ&Ör¦.<*<¤çKÚïyÔM<éÂâ¼Ô~™ƒXÄÂG’kä)ZÌZû‚5ßëâÑäoPÆG–œxý¢–¤kZjFô´|yÊ*÷¿yZ™‰ñzwOÚŸmû}:v¡Z±íïcÛ®ž€ë . ÿºä4õ)z¥¡ BÏœ_5´ä\lCߣ‘05É%Ó™ÉÀÛtÇÁ{ î•zV¿ëœ>À¦<Ñ ìÀˆìïYý Vha ¶% pHYsÄÄ•+ IDATx^ìݘ$Åýøÿ:w‚Ã5Á݃kÁÝ5Xœ Á‚»Kð(4N€à ‡w vÿ¼ë÷­ù×ÎÍìôÌôìÍî¾ëyöÛí®®~uÏtu}J†<ÿüó#‚IP@P@P@P ëÆãÛ0þÐá]W. ¤€ ( € tJ`´Nel¾ ( € ( € ( € ( € ( € ( € 4#`àª-·U@P@P@P@P@P@蘀«ŽÑš± ( € ( € ( € ( € ( € ( @3®šÑr[P@P@P@P@P@P@Ž ¸ê­+ € ( € ( € ( € ( € ( € 4#`àª-·U@P@P@P@P@P@蘀«ŽÑš± ( € ( € ( € ( € ( € ( @3®šÑr[P@P@P@P@P@P@Ž ¸ê­+ € ( € ( € ( € ( € ( € 4#`àª-·U@P@P@P@P@P@蘀«ŽÑš± ( € ( € ( € ( € ( € ( @3®šÑr[P@P@P@P@P@P@Ž ŒÞjÎM4Q:th«»»Ÿ ( € ( € ( € (0h>üðÃðÍ7ß4}¾#F4½Ke‡!cN†Ž9i븧 ( € (ІÀן¾ˆ¯šÎ¡åÀÕè£ø1) € ( € ( € ( € ô.0dÈ–ˆZÜ-k´¡ã„0t‚–ŽëN ( € ( @ÛCþ7ø©…À•S¶-o ( € ( € ( € ( € ( € ( €e¸*CÑ<P@P@P@P@P@P@Ú0pÕ6¡( € ( € ( € ( € ( € ( € ”!`ઠEóP@P@P@P@P@P@h[ÀÀUÛ„f € ( € ( € ( € ( € ( € (P†€«2ÍCP@P@P@P@P@P mWmš ( € ( € ( € ( € ( € ( @®ÊP4P@P@P@P@P@P@¶ \µMh ( € ( € ( € ( € ( € ( €e¸*CÑ<P@P@P@P@P@P@Ú0pÕ6¡( € ( € ( € ( € ( € ( € ”!`ઠEóP@P@P@P@P@P@h[ÀÀUÛ„f € ( € ( € ( € ( € ( € (P†€«2ÍCP@P@P@P@P@P mWmš ( € ( € ( € ( € ( € ( @®ÊP4P@P@P@P@P@P@¶ \µMh ( € ( € ( € ( € ( € ( €e¸*CÑ<P@P@P@P@P@P@Ú0pÕ6¡( € ( € ( € ( € ( € ( € ”!`ઠEóP@P@P@P@P@P@h[ÀÀUÛ„f € ( € ( € ( € ( € ( € (P†€«2ÍCP@P@P@P@P@P mWmš ( € ( € ( € ( € ( € ( @®ÊP4P@P@P@P@P@P@¶ \µMh ( € ( € ( € ( € ( € ( €e¸*CÑ<P@P@P@P@P@P@Ú0pÕ6¡( € ( € ( € ( € ( € ( € ”!`ઠEóP@P@P@P@P@P@h[ÀÀUÛ„f € ( € ( € ( € ( € ( € (P†€«2ÍCP@P@P@P@P@P mWmš ( € ( € ( € ( € ( € ( @®ÊP4P@P@P@P@P@P@¶ \µMh ( € ( € ( € ( € ( € ( €e¸*CÑ<P@P@P@P@P@P@Ú0pÕ6¡( € ( € ( € ( € ( € ( € ”!`ઠEóP@P@P@P@P@P@h[ÀÀUÛ„f € ( € ( € ( € ( € ( € (P†€«2ÍCP@P@P@P@P@P mWmš ( € ( € ( € ( € ( € ( @®ÊP4P@P@P@P@P@P@¶ \µMh ( € ( € ( € ( € ( € ( €e¸*CÑ<P@P@P@P@P@P@Ú0pÕ6¡( € ( € ( € ( € ( € ( € ”!`ઠEóP@P@P@P@P@P@h[ÀÀUÛ„f € ( € ( € ( € ( € ( € (P†€«2ÍCP@P@P@P@P@P mWmš ( € ( € ( € ( € ( € ( @®ÊP4P@P@P@P@P@P@¶ \µMh ( € ( € ( € ( € ( € ( €eŒ^F&æ¡€ (мÀyç>ÿüó¸ãÚk¯¦vÚæ3q¨Àˆ#Â?þñðØc…wß}7žåSLfŸ}ö0÷Üs‡±Ç{€ž¹§¥€ ( € ( € (0XÞÿýp饗VN§v £6òØ“êívÙe—ÁJæyPWôÂzZ (ÐýçŸ~¥A~Á4pÕý—Ìö‘À#<:è ð /Ô<âK,Î=÷Ü>*‡Q@P@P@P o>üðÃpê©§V¶Ã;Ô \åÛ¸ê›ëãQúNÀÀUßY{$(A€ö_ÿúט Ûm´Q ¹Ì,Ž?þøðÛßþ6Ì6ÛláôÓOO<ñÀçEY$|ðÁq?*÷+¬°BÓy¸CçøÎ:üðÃëhèСaüñÇN8a6lX˜sÎ9ÃJ+­f™e–ÎÎ#(ÐŒF½÷Þ{Ã]wÝzè¡ðæ›o†÷Þ{/Œ3Î8a²É& sÍ5Wü~ã‡ÏK§ÒgŸ}þò—¿„›nº)¼ôÒKá­·Þ c5V˜rÊ)ÃüóÏÖZk­ðƒü S‡7_P`@ ì¶Ûnáæ›oîqŽGydøñÜÖy¿øâ‹aµÕVë‘Ç÷¾÷½‘ŽÕÖAÁÎ\›ƒ>8Œ7Þxá7¿ùM˜wÞyÁY|Še¼_}úé§±ÞÒþð‡0Çs JOOZF0på]¡€ýF€Æ™¿ýío•òþîw¿3pUçê1µÚÙgŸÿÊè•«¯¾:l»í¶ýæZ[ÐÁ)@ƒì~ûíW ZÍ8ãŒaÿý÷/3_}õUxõÕWcCíŠ+®88<ëðÍ7ßz ò3|øðØ‘á´ÓN -´P8äC 3Ì ’Vàºë® gžyfÍѨüqà‡ Òµ×^? Çw\ î–|ðÁ°Ï>û„×^{­GÖ_|ñEül>÷ÜsáòË/k®¹fìdD°Ù¤€ (ОÀïÿû¶W¼?›Ú8ñÄS´ñC‡¸sÎ9§ýLÍAP`$‘'ÈIèR*ëß~ûm¥tO=õTàÇ4²@õh•ÿþ÷¿½2m¾ùæqdÖÃ?< 8y‘H#ÎÔ‰ Г¹ÿþûc£+‰žûL¸øâ‹Çõ¬&˜`‚¸¾Õ¦›n&tÒ*Ð?N«¯>W\wÝóFV}÷»ßiªŒx ¬³Î:1PoR`  ¼óÎ;a»í¶ {ï½w #Õ´ì²Ë†E]´ÇÈj¾K•^ög‚Ñ^[n¹e ÕÔSOƒÇóÌ3O ™Ò5×\·% eR@hO€÷´—_~¹åLèöç?ÿ¹åýÝñÿøúë¯+ÿhôž­› ( @ë®Z·sOèCFb0lœDvJö«}hDúÑ~ÿH#ÿk¬Q÷j}òÉ'.`•N6¡×‡·«‡jQ€ÑT)°"@aê>¾ú\}ÿûßtXÈh¿ýöÛSJ^tÑE=¦»¡q|Çw¬L Ù}r–HÖ®¿þú#Ι.•» "]qÅáŒ3Î\pAøûßÿ§,JSÓ˜¶ë®»žóe$F¾ï¾ûî!5Øñ½ä’KÂwÜ.¾øâpÕUW…»ï¾;N÷ÉÔ…¤þóŸq:%“ ( @kù4tüã[Ëä{1J©eIsÏ=wËù¸c›l²IdcŒ1*ÿ¯‹ ( @ù®Ê75Gè€1LFbnï)¦˜"þ?ÓæØ“·6ø)§œn»í¶ø3ýôÓ×½*Øæ½Æ:pùFI–LÝ@ƒ™©ÿä½H)gê>nù\1"oá…Žô¿þõ¯+PLSvÞyçuœ%R  FE³fë\vÙeqzL¦P­^Ãj´ÑF‹ÁÜóÏ??6¦‘˜:˜iûÊHguVœ DÇ‚f .¸`¬Çs̰îºëƳ”‡Dÿ'žx¢Œ"˜‡ (0èXË3¥?ýéOqÚäVRZ}õÕ[ÉÂ}þO`³Í6‹5XoÒ)̽-P@Î ¸êœ­9+ @‰ùȪ%—\2,³Ì21÷>ú(.nª-0Í4ÓÄiÖzKwÞyç€äãE‚‘z¦þ# ÈGVöŸ3ø%íÆÏS2UZJ·ÞzëÀ¿žá 8ꨣ –óÍ7_ÃsgQóµ×^»²£ÛM|ðA\/3%FQ¥‘]µò&°¶á†Vþtúé§·[÷W@¥ß§išlF¾0i6½õÖ[1ÈBšvÚiÏ S{“M6Y¯ÏÁörwoP@ \y( @× ÐÃ?5DN4ÑD±—ýÊ+¯\)wÞÒõ'Óe|á…Â@ \1 FšZ²ËÈ-N/ùñ©§¾`Ý#ÐÍŸ«UW]µÅÚ>iTH÷èYÚüñøãŽ[8¦[M)­XxçRW`}#¿–Zj©†Ù¥©”Ø)F?þøã†û¸ ( @OÖ.^sÍ5+¿leºÀ|¤~>ýôS™P@èzÑ»¾„P½•óÔXÂT9LCðjòÉ'o¿ýv`]ef˜a†BV{î¹g¸á†â6L]“ÈYüü /Œë§0-!/ $¦Ê€òƒÐËÚ÷ÜsO\0 ïŒaz¾¹æš+,¶Øb±gôÑëå2$fË1™Úò=ûì³Þq$z5Ï:ë¬Ñfô¢¦«Q¢ÌLDb-ÌRºï¾ûâTBÕkÕl¼ñÆ5³=ûì³{m¤zýõ×ãT@䇔‘©„X0ž¤™fš©Q‘GúûóÏ?GÔ1!Ç ßä1ãŒ3ÆEé}—æ~gª9Öùàå,‚œ|òÉŸê´Í6ÛÄÅîS"صß~ûÅ2UùI܇\›”î¿ÿþ@µ:1]Ó!‡ͺ iŠ%ÊÊÚ L}É9öÙgq›í·ß>ì±Ç5‹À 'ëý°¶÷ךϽÿ虹Ê*«„%–X¢Hñ[Ú†{õ¦›nŠ×œÏ׆rO2É$‘~ÜLYÕÛç’<|ðÁø™cÞý×^{­R¦˧€ãxáÖÉ”¾ø¼²ŽRšnë±Ç ×^{mxà½]9WîqÖx¡\?ýéOcÖF)ÏŸ<ÓT_4 ßxãñ˜|}ûí·ñZÒ#–õlÌôö’Ž»ÜrËÅÏ éÔSOÓ‹IŒž¸òÊ+㦛nºiØÿý+»µû¹*rüv·©¾Ïøn®õÌÈL¾oo¾ùæÀµà3Ä´©ÜøcICQºê•ñ_ÿúW¼>¤]vÙ%þÞxãÀó‹Ï(ß‹Ü3ô˜¦ÑŸie˜¦g 'ì‘-åæ>£L|(Á >S ,°@Øh£â=W4ñÝÂ÷=ŸÓ§Ÿ~:¼òÊ+1xÀ3¾—ùžà;šóî-åçɺI;ï¼sÜœï?¾+9ÏáÇÇ5ÆxòýÏ÷Ϭ"Ïçüûñ·¿ým<_Ò3Ï<¿ëxΦÏÞw¾óè°ôÒKDz÷6ò§Þ9•õÜ*³NQôºÙ.7)c«¼¾Às7Õ[z+ ×êh|¶Èƒç‚IP ¸õe¦Êç‡DÝ…gm3ϾԡïnòzüñÇ‹àÿ¶¤®O}•zÏæÿüç?±NÁôÍÔofŸ}ö¸Æ2S2mlo©ÓÏüN矞ýœc^÷«>çô>¾ÐB ŵ ILõÈ{ïûO>ùd|ß§ÞO½l–Yf‰ï”Ômš™}‚ºטwPÞ%™•&îíZPÞ«ûS¢Ï=Œ#K¼øâ‹ñ3A›ïg´ÃPdêâ"uоºNS§^Ë:¥|†ÒûõZÞùX›œ{¡Ñg¨ÝëÕMõ`®'uDÞžzê©ø.KÝ•w0Þ•¸ž¼›Ñ®FÛR§mÚµuÿr \•ëin (Ð|š@*q${y¨|!1gw€(R *ˆôʧ±ŽÀ ŽSÉ«N42V'ö=óÌ3ã)ÈoCÅžŸG}4Ðw 'Ä@B½ÄC™Šë>ûì+›Õ‰J ?¼,°¶Å±ÇÛVPb¯½öªµŠXÕÛ¦ÿ9çœsFªSyä‡Ê/Z4¸þò—¿lØ̱Øïˆ#ŽˆœZÓýÑxÉ>®™§¨|÷‡”‚Š4N "øTäÞcFøð’’'^P¨ähà3±È"‹„ãŽ;.@ÊL¼0yä‘=M)^¾ø!ýÁ ÷u­‘|6Ž9æ˜2‹VZ^4²ÒÏ÷Cº«3OŸsΕ7AF‚¡EùØàeŸ`iš¾%ß—ãóCð–à1V£bj—þ𹪞5u<¨w-hp9à€j®»Ãuá‡ûœ5}Ž>úèðÃþ°Èe wH|þ¸oªŸ¼ˆñC£ߛئÒ þ~øáñ3œ'žSüð]Êgæ¿øEØb‹-z-/î<+h¸©7ú,}Vyþqqž‚Š$îKeæ» úÙIŽ‚Ú|6(/çØéü‹8X!=÷Üs±ÎRkfÖíæý–wÞ¡x‡+Ò ‘€õ¥¢ª"åíæm¨{žqƱ3V­”Ú è”F§d:[í°Ã…N©“׉w:e²>huûFz÷HmttM¸ ¼‰º­L‘¶)ÞsªA^>¼³ÐÙ•w‚z¬÷:lذ&ÎÚMû³€«þ|õ,»ƒ@àᇮ4@ÊàôK+zµx*2"!g#x@£ón»íV©ˆÓÀN„‡$ ÓôÏ•BàhÔ̽ަšjªX*øŒš Ñ[*_¥Öe£!󠃪LÛÀ¹ÒžÆ&1SÏö¥Lôt¿ôÒK㈮VåÿüóÏã®TtÓÿ³ütÓM7R–µFKñâB Š—˜”¨H0ú‰Æ~2±¥òH‰W*˜šª”ψÅÖ[o]ièJÖ ÇeôBjÜ¥ç ׋Ä=‘Öÿ á˜M$z‡×ªøÏ9眭ð•²÷/x¬’î˜SN9eÌŸzU'FÑ ž=Ž0LJ 1çÌ ‰FÝõ×_?Þ+ü½ŒÄg®:ØDO6îY‚œ œ|N¸îŒ$#(KÃxšŸ?•ƒÀÁƒ”¨„¦†F¤Ð;1OɦŒó(’Ÿ;*Èéå’Ï2#îo¬©h§Y*Ö¼`bOÕÛqpâÅ”—X÷1=-ùÜÓèÌqÓÔZ6ù|,O# ‹œCÛô‡ÏA’Œèb4"÷JÑÑõÎû>ZÑk“ûµ:°É熑'tR¥±žÏ#ïò2ð™ËG1ÝC \q •q=jåA0™Ï%÷£Æx¬6ä%”똂…œóZk­UhzòÄŠ@Ÿíê@/*xqa;^jé‘Fc~3kÝ´ëÓ>WyÏa^\ë2äóÆ÷u ZqŸzè¡#õÚã;ˆëÃõÅw¦C%ïÞî:0ð=Ç=œÿóÍgƒÏq¡CyÒh¦Õã³R}ÎÀR züñq*žÞ‚@|?ð™ã{™ï–ZøLsnœ#?ô`¥Wu£D£#Š9>A@‚{ÕS3òYf JzH’hôâ½Þ”´ù1é!KyÈ“ëÀhªêŽ):fí¸ãŽ•÷3f8(2ý7&e_'òL3Ä$sÞy‡®þÑ~ĵ¡Sïe§n«sÿ¦ mi¼óÖ©Égí|—Ñ‘½H'¸²íÌoÔ Œ6êí‘P@ÞhXLmNTNªSš:ßçA®¢¶Tðè‘N ë»Ð@›ªªNiê¿”?#ƒhè£âYý¥•û"ïTb™Ïš^鵂V†GDS"0“슞oYÛ4b ‰ó¦ÒAp©z-7Ûl³Ø¸˜ÓFÑ»¦VâÅ"5à _-«ƒVìKO1L‹L¡PÖy—™çÊy§Uí IDATýÄ(^ì˜R/­8–yC-¶´þ§ÇÓÔšßFF¥ùŸ™"„©;ÚIÜ£ùºGÜܵFãñ¹¡ÒÏýœî æ\gäWJœ3®|7¯øãe‰©ÊÒµâ{«z4f½s¦ažÊ9£«ƒVìÃõãûƒ—ŒôýBriÚŸ,;YV^î™v#¥Þ¦ee*¾ô‚Ä‹Z£Gø\ò·4ê‘‘®\çF‰† ŽLŸÇhªêg÷›|ª?‚cìÇ‹8 Rµ‚cŒ$̓ÆJkM3“—ün¹å–ø]Q+hŶ4>Ñ«3%:iÌ"°N Ï$tµÖ£7$ß÷tÈH‰€[>Ò­·Ï÷?ß“<çk¦æ™Ég'õ¦Ñ ÷z©SÏ­üxÍÖ)ÝO­þŽõËê §ŽH@#Ÿ¢·÷}FÒ¥:îõFóA‡Æô>Íh$òí¯‰¶:/áTo êöÌ*“A×ZSÑÕ2(û:Ñ^“¿CЛN½µ>G´QW.2:¬Ùë×õ`:Û¦DÀ®Þô¢|o1šÎ† MƒKÀÀÕàºÞž­ýJ€GT4I4Ä×êáË"ß©qž_š:¨è‰R‘§w6•ñê K­‹LLj»ð£1ѽ%*õù(ÅvƒF¼§*ÚôìmÊGÊF` _ó‰€K­¹ñ»ù3šªÑa9ò $‹&^$5óýÇg(%ŠŠ–¡?oGObF¦Ï½HéPP+ôc½0÷.AŽÞ÷eT#@S¢ 5þôfƨ¢F/Uùõ$/¾ è}Û[b4pþB×(pÅK^£Ï(ÇË_ŽyYO£Ý<M͇/ÁÂäÌõbäU‘D€¯·ÑSäÁT¾tnH‰Ñ]iúÃêctâ¹U}ŒfëEšÝ†z=«S±·©3‹äŸß÷FWç—Æ([õzeEŽï6 ( €ÿO fó®Üh]£|šÀvÙ¶«HÊ‘OYÛhß²ŸùÕÇëtþο}ß'x•5ê½ïç³ 0kJo‰cçïëù”uEÊ^dÞ )G³?µ:'7:^‘û‘à`>ýuÑû±ìëÄg0½ÿr]iÕ(ñ~È;M™©ëÁyÇ(¦7)PKÀÀ•÷… t­@ÜÈGVåÎ{žÑR¤çYõ ÓèU¤÷ .ùÔ;Œ(iwêµTzÐæQJ눤íÒ\âö+óïŒ& ÷^JE¦€`Û<Å”wÕ‰GzC‘è–H(³üÝ”÷PÑ…‹™'>%¦þ*Ò ›LIàV òÆf^0j°¨•/#ñR@–À2ënõ—D@©èô£yÏÉ¢ŸK^^ŠNᕯc@þ?þxa,µœ¼ø1jƒû™ /˜b’Äg‚Þzõ¦ócZ‘ÔkwÖek”˜ 6}Ïóù)ÒY€25J|îó–§È j>%PZ¡Ñ±ýû07Ë×Åêm_VŠ$ÖÖÊ×yÌŸ£eäÏZ_yp¹VþznÕ*Ñ:E»V¶! ›‚Œ´£|»)\õì­uœê@ÁK“ ( @kŒbNïŒ<¯{åÏ”½©3õ‡FAZ+QϽò@AÑú9­Syæ×:Nç_Ä®èû>ïMy}/M]}Œ|}×"Søæ%ëÍ~Rä<êmC½”úG³?¬aÔ©ÔÊýXöuböƒ”èØÆšfï ÖHo”Gþ÷n­óŽR­ŽÍÍœ£Û\W÷Úzf ôkOùóù¸kTÞK‡ÀUj˜,@Ãå /\dÓ8-Sê-C`e™e–)´_‘X—£hšd’I*›²}_'®M €pmò5¦z+K¾ˆ&‹öV§¼×=ù‹TÀûúÜË>^ÑžÜwL'–R>õVQsF´¥õ£š=®7×=¥¢Á¶§÷kŒ¥ÄüÔý%qom¤Í?—yï±ÞΕ`W‘$y0%#£KRJSjöËVÊIÏH¦Íøn`äÁ[ÖK/Þ4â0¢¯·—¼ü;†<Š$F]åßEµ¾»ªó©5ýK­cå#íòÏHoåÊÆE¦Ü+rŽl“ÍõÞNy-3Ûç½|óï’zå£'mÞØÐÛyðʧŒ­õÙèÔs«º\ÍÔ)Š^›f¶cú!Fd’øîbšœ"½’›9F³ÛV×É\— YA·W@þ:Óä:{ë´™7}çh×:o”§±¼H*û™_}ÌNç_äÙ†ùESþ^Q¯¾—¿C霘wBé˵r‹žs'¶kå~,ó:1“Á /¼P9µF³ÔäL^VêÖzpP¦ó!¯Ò /e»ùôÑûÿ)x (0òõªÍÇMƒ=ˆ­óÚk¯Fó,¾øâ…Xš©<û쳕P3uв?3Œ(ÌŸÇLÙÓLp±·òä \E"S~ÕÏ¡FŸÓ²]ÌOhtÚ<ýôÓãiÝsÏ=±>SýNA§7ž $faºù2u*Ô'š¤:IÑÎ[yYÊ~æwºNѪc3ïEÞ÷gžyæJP„ެ[Ú[Ê;A²oÙ‰5yó5ØŠæOÝtþùç/ºyÍí¸©¯s?æï¬­tò-ó:ñ¾–¿í”ÅI2}Y©[ëÁÌ>ÃzkiêJ¦3¤pÕUWÓ¢ŽÊzuYöæÓ¾€«ö ÍAJ Â‘OKÆüÝùëZ‡Ëé]V4pÕhíšüXùtÍìW„§Èñ"ùôÅ6ùâêü?=¼[I\ç<ø’ûmÄmå¸Ý²O3÷Põ‚öùZ`ÍœO‘õyjåW=ÕGo εöÏ·/ÒðßÌ9urÛ2ƒÓµÊÙìç>wlfú•Nu2o¸aUø<¤Þ‹¼œ2íF‘ÆðüÞ»ýöÛ[*zĪ—A+½X[Ù§È  cqtÇ [iPªwœfîßêï îßÞ¾G­ûV]¦F=“;õܪ.G3ßëE®_Ñmnºé¦k&°b¾ÆaÑ|êm—¾ŠôêÎóÉWE×]k·¼î¯€ d:Ž,´ÐB±Á—€Ó‰ï¸ãŽ=N™i”ß}÷Ýø;‚ E§ø®çÆw9Sñ²¦4³°nh³Ïƒzy—ý̯>N§ó/z¯™ºh^lGã>k “®¼òÊØÐ?lذšYб÷Þ{ïc ßFkb5SŽQ±-뙲níý÷ßg©7b+e+ó:U–kfÍÑfïÛÞε[ëÁtüdêw‚žçw^ sítÒå‡ï:ê³ë®»nÃ5§[¹ÖîÓ? \õëd)TTÀòÞ14p5ÓHK¥ºQoîÚÌ"ãùº e72Yc«[n‚²Ö§¨-_ó"ÐÝâÑj9𹿭ô«U®VFhO~Í)w3#!Ø?ÿ¼”u.­º7³_3ßÍä›¶-:M`Ú>wl5ÙJ9GÕ>ÓO?}¸ä’KF:<÷#/댲%sÔQG…ãŽ;®×bp*:®·ŒŠ|†šùl§c•=}S‚xà•)w;q ›¹«Ÿ™îßf¿cògF­¼;õܪvíôwF­ëxóÍ7‡½öÚ«ro¾ùæ#5`¶{ýó³iTàsW´A%oL*ºO»åuP`  ИKàŠôÇ?þ1ì°Ã=få Ñ7¥v§ ¼æškâ¢eòëSö3¿úÚw:ÿ¢÷Z+õÃÞò& ÉÌ3©ç0ööÛo•ÌìA½—õ£èÜrî¹çV²¢sK¾ÞUÑòwÃv¬ëuðÁ7ìÔÜNY˼Ny‡·fß¡Ëloêæz0ïܓ̞qùå—:¡§ïÖK;ñÄcpk½õÖ ;ï¼s¯ßÚ¹îîÛ½®º÷ÚX2­@>M`+ôÀgj„-¶Ø¢•Ýëî“7H•ÕìÔöQfy!ëƒ]tÑE¥9¯$]c¥”—I§ï‡üÞãå‹5\š} kç4óuž¸6¼5sü¼Ò^f/¶vΩömÔx_]ÆNÏ;}—éÍH¸_üâ±±žÄ]pXL¹^ª([pÁË,V×äõÐCÅéÓ÷(Ÿ9¦‡iGÑŠ‹§Ä(åÔ3»è‰pÿ}©®~an´_³÷cþS+ïN=·ŠZuj;:êì¹çž•i7Ùd“#¯Ê:. `Ü3麰¶\Ñ T¾<Áh“ ( @û+­´R8ì°ÃbÀ‚ÆÝ|0ŽÂ"Ñ蛦Þb¾¢ëzÖ*¼sù±ÆÖ ,GCL<ñÄñùÞ ýBç¢fRÙÏüêcw:ÿfεÌméôD£þvÛmGÖ3º‡kU¯3‚ÝvÛ-0=[LtLfäMš6{îG?úQXn¹åâ”Ô¬IK0o¥Žtà 7Œ²ÓÍß?¨—Ó®hgµ2:Ü¥ïõà)¦˜"°vÙe—Œ¥M2pÀŽgèà~Úi§…fÖ‡eß—&`àª4J3R@2öýü#fEчV£®tÜsÎ9'.DNâAWvà*o¤¡·Ï`M¼ ¤ôÖ[o•ÆOaÑß|;=Š(7§ò†O_NIEE2OÍ?¿žù¹”vóôÓŒš¶-ïéÚî”/Õd¾‡Ë¾D«­¶Z¸êª«â!¤C9$Ð#¸^/I^n1K£wËüî*ûÜÚɵªꥠëË}öÙ1XUvâþ-ú|®î¥Ýèþmç³Qk ÃN=·Ê6m&?‚V»ï¾{`Ò¦›nößÿf²(¼-,¬ùÖ(¤®Vt¶M©Ìõ Þ P@(@Cô*«¬G'þð‡?TWL˜Ö®$ÈT´¡¼š‰5#?þøÊ¯×Zk­,ËeЖý̯.S§ó/àÕ<èœt饗ÖµdºÀZ‰÷¸e–Y&Ö:±¶U«eov¿#<²´¢®GPuÞyçm6›>ݾº“ï!Eß…Ë|7ëOõ`¬K/½tüaýz®3ï||§ÑÉŽQ…,%RÝ>ѧÖƒõ©Àh}z4¦€ 4à¡”•ñ¢bìCò4uQ+ =opa¡Íf{o•UŽQO>w6ÍLãØ[Ùsß|Ѿ<ß|ê«fz9áÐÉD/õ|tÄã?ÞÉÔ7=*ó—Ôf¯Ojì$ã¢}z‚£è`44“rÇz wë=ÜÌyÝ–©ðÒù²~šÞRþÝÕן¡¢çÔîvL”z¢’ׯýëBA«ê9ø‹”£™û7¿wyynôÒ΋*SÒMÏ>ûleÓgœq¤Ý:õÜ*Z¾²·«Zm³Í6 Z¥²ÿð‡?¬œFê`Tä¼èžR­5ëŠäá6 ( €# äk²ÞOjè&ˆ•«VÃé}ˆ0E‚V͉([ÙÏüêóítþ­ú–µ×)M Éh•{î¹'væbFÖ¶bôÝ¡‡Ú¯ƒVŒòçO‰‘cE‚V­Üe]ò™nºézdLj̉û¶¬Ô_ëÁŒðdjÈË.»¬²NmOkMƒGÀÀÕà¹Öž©]/@#Sü¥ÔìÂâT¨—\rÉÊþ©ZY'žWŽèñq×]w••u×ä“zçõV ¯R…áî4 •‘òF1•›¶ªº yï¢A(¦ K©™€Ü£>ZAÝ<èU9Ï<óTþžWÜ;zàÿËœQ,óÍ7_åPwÞygáÃ2Úê±Ç«lŸçS8“ºáSO=U8ÎgâwÞ©HÌ=÷Ü5UZ¹‡™z£hðµ•ÏU§.ßL3Í6Ûl³JöŒºí-˜’¦Ða‡Ûn»­Ò¹Såùæ/Čʜm¶Ù³f‚D)Ã< Ñè Lõ‘R½{7σgQÑà"egtvoùwê¹Õè¼;ñwîÝ|¤ÕN;íöÞ{ïNªGžôÖNé¯ýkœî¦QâÞÊ?“K-µT£]ü» ( @AêÔ©ã ûL£õðÃW¾w™"˜†ßVS^§ Ud¤UÞ‘¤èqË~æW·Óù=ÏNl÷ÜsÏ…ýöÛ/޾f W“N:iì(H=9_£²Çï«<«ƒ¬íU$á3*#®òι‹&Ö«-+õ÷z0ípÜÛ)åm e™O÷ ¸êÞkcÉt·ÜrKøàƒây3ÂdþùçoÚ _|–ùŒËbÏ~Áj¸iú$úx‡¼¡;_‹¢^1¢¬¸âŠ•?ÓX\Æè3^®ÒO¼`\|ñÅmIä£õÞxãByåCÎß|óÍPd:1Îýúë¯/”;­±Æ•Ý9^>ýR;ùÝwÍ5׬lJÏ>‚E=¢R@”àr;óì9^Ú†F†›o¾¹P‘óÏÃÔSOê5þç÷ð?ÿùÏByÓ+³HКÌZù\*D‹±HošS¥ÔK«¯¾zåO¯¾új\Ì| ¥óCoy壽š9f™ÏüZÇítþÍœk™Û2[MºN®jÊïÅ¢÷#õ”"ïò6[vÙe+‡à>,ÒYŒó-³}a ÔƒóuR«ï‡N_Cóµ®F­¿GW@L !Õìh«” =‚SÏ"…Ë^ŒsóÍ7¯”˜Þægžyf¿¿†yCRÑ^@Ûn»me¾tFjì³Ï>…*a «Vc' âë­·^ÅóüóÏ÷Ýw_˾,&Ÿ ©à0:!_#'ot«WÖUËG´\à;ò™HA Îe×]w ׊¦"çß[^4ú3e ‰Î8 ¡)Ÿ›)ñù)Ò[³è9 „íN8ᄆ£ i¬&HŸ“´vµA>2”ž·F.><.r[4µò¹*šw+Û1·?k:¥ÄšWùÈÝì¹¾½%¦lµ“#i˜w¾·D`ŸïŠôOÏSÖ'+’hü¢çxo‰J¾þñzë(uâ¹Uä<ÊÚ†QkyÐêç?ÿyØqÇÛΞ‘mŒV_xá…ë—Ô{®ðlÄ0%¦ê-ØÅ÷ÿå—_^ÙžÅãM ( €å ð½ê„Lãšê@¬%¹Â +´u°¼NAÞ:«0es«#!Ê~æWŸx§óo º™*;¥f–Xhã£dש¦šªÇqÕßY[•õo»!ñÞ–f¬ ãÜ©§žÚ°X´1íxÛ0³ÿÛ ëÁiMÞ"ç_óÔ&Qd?·éÿ®úÿ5ô 4®Ý{ï½ñ\¨|S o%ÑÃ<ß÷ꫯn%›ºûcí­”N:é¤ðË_þ2䕯ô7ì¼~øá¡™ùŒK-pÌh¬J‰—"#@fŸ}öõoºé¦°Á„;î¸#|õÕW# *x1Z«Þh!¦L‹2:^0¼Œ¬½öÚ=FC‘A&ΓÀ ‰õzèýÍPt¶˜öÊ3kQq,²£'N9唨àEO)ö'ØÅïfœqÆ0É$“ÄFVüÓ´†Ê_u" 7ÇsÄr‘8GÿX+Šà e§'!×3O4P€£'9(ò>ýôÓz/âÄËÛ3Ï<w#¯“O>9Î/ÞhtK»×“s¢‘˜FKil¤üTp©ãÃ=GÙ¹ÿò©ʨà1U/­ŸëIÃ1ùÎ<óÌñø4˜cÀš_4Œ§ÄßO<ñÄ–^dÛ5ëæý¹g¸/ |ÓMãÜsÎ_úy™¡Ÿ¸ÿä‰êó#ð˺O,Ów Áعæš+NÁJP‹<ùnL£%vP®õ9*ësÕéëpÐAÅï)îÎ@5÷jubÝ'z1Ðcú>Gpøa®ÁСCcà Ÿ¡¼&Ÿ"µÓçÓjþÜ;|¦^žS+¯¼rœz—óãsË÷1£lRãÆ¾ûîÐ¥{¦È±ºÜrËÅïFöã…’©àøNå;‘Î(Õ½®5JCB‘D@„ïY¾c˜þ‘ =£ i ášñý›d¹¯ÙŽçVo©Ï­"çÓÎ6æi•ïŽ9æ˜ÀO³‰)…ª×=«îÑÛÛbà|÷Й„ëÃ÷£î~³ø8?ÔèÌ‚‰”QÝ<#óõñš-·Û+ € ÔàÝ·zúÖVg/ÉÂ;9ïŒf'Ñ‘„NO<§œrÊ@ :ÒñLæ‰w2êX¼—¤÷¯"×­SÏütìNç_ä;µ ×>MƒÈ3ž6 êbyÐ’ç/y ž”FY×›½¡Sem7_êBt8£.Ëû&ixoJï ¼S÷LïNÔÛ à2KɨNtBdͪÔ~DÇW®ëÔ1å9ïìÔ¿øÜ¤ö#î[>o¦rnæÜº­̹]xá…ñ‡v Öfãþåÿ©÷RGå5òñ¾Çw“ið¸<×Ú3U kx8çk´[ѦBFp"-ìΨ+ÚËJdhl¦! |š>ŠFÎZ=Û9n7W r.T¨Òpm*}y£c­[*Á4Sa$(A ‰ÄKÌÓO?]—›†a*#õAF*rx`¸ûî»ãf¼ ½ð 5w©gKùèùG75Ì ÈGÇÑðZ@Òcm=öˆ¹$*À³ªçÂ4ôH$(ÐéÀÇ'Jp‚FùôRˆOok^ñ"YÖÚ"¼ Ñε§a™ôüóÏÇŸêÄ5 rI€&­_VÖçp äÃç€Fô$Ro.v›>úèBAx¦ã{5MuÉ •þZk^ÑÓïÇ'žx¢i«Ÿ«B™·±ÁQz3¦à ßû묳N|!¬N´ù;Ÿ¡[o½µòg^ë5ÚsÞùÜêmµã»²îßYŒÖ#ñŒJNùÁ Bð9æ™[ozÅz…e”háûŸï}ÖüT'FBLkf-&‚jLaI0•Ž-ÂëMeKoZ΃]£Ô©çV£ã¶ów^Ü«GœY¡Ö1kõfå¾Î¿¿‡ Ökqi ¢î³÷Þ{ÇFÊZÓR2uã‘GYY‡®÷U@¨-À:ŒHIYA]§ŒÄ3žN}it ͵Ö8ä=ƒÎ†¼ƒsüfWzæ§óïtþe8·šÏ踓Öxå]žN%õ׎‘+Üt€iÔá§Õrub?:PÒ“sM¯¨‡¤ºH~LÞ ˜)!µOt¢<ÍäÉ»øyç˜ùµ…I\«4ãPžAGf5¡>O›J«ÓoÖ*_·ÕƒiKIß[t$°W=’?ÁWfiæ}¢™kä¶Ý+`àª{¯%S`ÐÜyç•ÆZÁjšÅ Ww \]{íµ†Üv§K¨®PÐØË4EäO¥ƒF|¼4ÓHOp!í4¦ÜéæÄböôÖb­¦7$ÐCc7 ‚Œtâ%¤^"Á¨¦ $ÐD8£¬¨|PÁ`Á zãÓˆ,F4ô–xùa#NîÏ:,Œ°âe‰é'YD¹¨¬Ó“ª^bäAQðÿú׿ÆúTty¹£Lœw­DOBÖGãÚ°¢ÒHC0~ŒØàج•³êª«V2«^E«ìkÏ}Å\íwÝuWCô€¤+¾+Ê þ¶sŽEö奔À?ßËèRP4ÿ|2­½tN425“RÏGž+­´R|~Ð’žƒÜg|¥ã4ô㻌—|zm3Õ)/ù<›x0ê5=ønãk<#ù^áûŸg¾#­iúwP =Þ·X‹6uXj·h^ž·ŒaäÇõ×_ëü¼¥÷1Þ5èÀGÇ:žÛ¤fë~æw:ÿö®^ë{Óa’YIxo6XÜtÓMã3œçvItˆd:Фö:OQ¤nÏA> ´;¨çæAÝQyž|>âžêµÔ蜕ޟ©7sŽ´S¨o•¸Jçß-õ`ÞÓx¸çž{b°›w1Þ1êÑÁŽïÞÅh{ ^Ù_ÞÅFå}6=ä=ìF´rb4¦´2wm+ÇrP@P` 0²‘à ‰@ ?&úƒÓñÒ˜Rš.µ¬²_~ùå•…´ixhfê²Ê`> ( @§hlN³54s¬±Æø6Œ?tx3»T¶:Îÿ¦ sÊ–öu':!Ðég~§óï„I3yÌ`DÓãÑÀ¿þúëfQ`š5ûóĨm:”2«Ï:ë¬Êˆ%:«°ªIè´À×?þ¿©”¾hú0ޏjšÌP@P@P SõhîÔñÌWP@F@§Ÿùο¯ÕyNÇ7‚VÌÆ@GŸ4ƒB­²0š`?ÌäÀ褴l­©¤ûú|<ž (ЛÀhò( € ( € ( € ( € ( € t¯Àm·ÝV™›i{ ZÕ:‹|ýWFb™P@n0pÕÍWDz) € ( € ( € ( € ( À `=Ñ”¦›nº¦=^ýõÊ>¬çjR@ºYÀÀU7_˦€ ( € ( € ( € ( €ƒ^`â‰'®<ûì³My|ñÅáŒ3Ψì³ä’K6µ¿+ €}-`યÅ=ž ( € ( € ( € ( € (ЄÓ2$îqß}÷…“N:)|ùå— søûßÿÖ_ýðÄOÄm§˜bŠøo“ (ÐÍ£wsá,› ( € ( € ( € ( € (0ئvÚ°óÎ;‡SO=5R0‚êÊ+¯ ‹/¾x6lX˜p Ãh£]õî»ï†áLJGy$¼õÖ[ºI'4œrÊ)a’I&윞¿ t¹€«.¿@OP@P@P@P@Øe—]Sž|òÉá£> ï½÷^¸öÚkÂŒ1Æa­µÖ »í¶[qeR@º]ÀÀU·_!˧€ ( € ( € ( € ( € üO`“M6 k®¹f¸å–[Ó>÷ÜsqTÕ§Ÿ~FŒÆwÜ0þøã‡i¦™&Ì0à aÁ K-µT x™P@þ"0äùçŸÑJaZ:úèÆ½Z±sP@P@P@\LÝõõ×_7}Òcñmèð¦÷c‡¡ãL†Œ9eKûº“ ( € (ЮÀ×?·_4ÍhMïá ( € ( € ( € ( € ( € ( € t@ÀÀUPÍRP@P@P@P@P@P yWÍ›¹‡ ( € ( € ( € ( € ( € ( @ \uÕ,P@P@P@P@P@P@š0pÕ¼™{( € ( € ( € ( € ( € ( € t@ÀÀUPÍRP@P@P@P@P@P yWÍ›¹‡ ( € ( € ( € ( € ( € ( @ \uÕ,P@P@P@P@P@P@š0pÕ¼™{( € ( € ( € ( € ( € ( € t@ÀÀUPÍRP@P@P@P@P@P yWÍ›¹‡ ( € ( € ( € ( € ( € ( @ \uÕ,P@P@P@P@P@P@š0pÕ¼™{( € ( € ( € ( € ( € ( € t@ÀÀUPÍRP@P@P@P@P@P yWÍ›¹‡ ( € ( € ( € ( € ( € ( @ \uÕ,P@P@P@P@P@P@š0pÕ¼™{( € ( € ( € ( €}"0bĈ>9ŽQ@P@ºEÀÀU·\ Ë¡€ ( @‹³Í6[xî¹çZÜûÿívÿý÷‡EY¤­<ܹ\M7Ý4üö·¿-7SsS@P@~'0dÈ~Wf ¬€ ( € ´#`àª=÷U@P ƒ_|ñEXvÙeÿþõ¯ÊQÖ^{í±ÿdýå—_Æ@Û#<Ò ÝÁ’>õÔSaÁ Ü3çŸ~Øwß};x´úY{¶ÆîýÜš›{) € ( € ( € LÑæiyV ( € ô±Ç;¬¿þúaòÉ''óÚk¯…gžy¦c'ÖŸzóÞ~ûía’I& óÍ7_Ç<º!ã¢×ä÷¿ÿ}XmµÕ÷ &ÓM7]Ÿ¿Ó÷gŸŸPp°ÜÏ}Hê¡P@P@P@ú±€#®úñųè ( €ý_àõ×_Ûo¿}øÑ~æž{îÀ´üüú׿Ž'wâ‰'†7Þx#¬6Þxãø;FU6zöÙgÃOúÓ0ï¼ó†å—_>\rÉ%œë¯¿>¬²Ê*=°™Ãq^~ùåÊï züîw¿‹ûS–õÖ[/<þøã½"_wÝuaõÕWÛ/¹ä’áŠ+®ˆÛóÍ7áä“OË,³LüÛk¬n½õÖyqü;î¸#¬³Î:qß•W^9ÜyçáOúSXuÕUÃÒK/Ëðä“OŽT5ë®»nüý{ï½vß}÷hòƒü 0êçoû[eF#m´ÑFÑfÅW ^xaHkExàOöŸk®¹Â¯~õ«¸ïÇø;äË9ÜsÏ=•|O=õÔxŽ8∸ åÝzë­ÃðáÃ+ÛLJ׋ÿrÎ\Ò·ß~¯ïK,X`°í¶Ûö¸lóÑG…wÜ1Ÿý9æ˜h›'®%×á'?ùIüõÝwß®½öÚÊ&ó®»îŠ–Œà[a…ÂE]Ô#ÙgŸ=ÚÿøÇ?Ž×‚m®¹æšÊ6o¿ýv,;ÿÍ÷ÜÕW_ÝðþÌ÷Áí”SN‰÷Çâšñ`êDî/Œ¹¯–Zj©pÖYgÅ{tƒ 6ˆ¿#¯¼Üä{æ™gÆ †œ?×?%¦Èd_¦d\i¥•¢‹.þùÏ6µ s]¹‡9Žù½Âq6Ùd“ð裆5×\3Ì3Ï<áÕW_­#¿Ÿ{ûP@P@P@¡€#®áE÷”P@ºG`Ÿ}ö‰#dh€'C£ù®»îƒ/y"hpöÙgÇFïûî»o¤ào‡vX˜uÖYÃÃ?\ v4*šJ0òƒ ×¸ãŽó špgœqFÊæÁŒC9$,¶Øbá­·Þ ã7^ÜîŒ3Î7ÝtS ^M?ýô1˜òË_þ2NcG)¥sÎ9'&šh¢pÞyç…ŸýìgqÊ;‚!äuÒI'Åcüá¨ìC @@ îwÜq±|ùË_ÂСCcÐá{ßû^Üžà¡=÷Ü3p¬ÿûßa—]v L0A ô`Ɖ@i¤ñÇ?”ØžÑ]W^yeØ{ï½Ãßÿþ÷FB]|ñű| ãÿ?üðx=I)HÅÿãqÔQGÅ` ‰ò<ðÀÑyÒI'61 ¼Œ5ÖXq~G`Œs|å•WÂ{ìGàm¹å–[n¹%L5ÕT1@X/p ñšL9å”á…^ˆA¿þð‡1€B¢ì”Î=÷Üðï|'<ôÐCa›m¶ 3Í4S˜cŽ9êæ›þÐèþ¬Î€k5õÔSG®}Qî'Ì)×c=6ÜpÃèÄ›kεáž%Èyf™e–(cÚO<¶Øb‹x¿ò{ùÁ¿ÑG=õvÚi§€+ÁÜ"Û0e%÷#È9çœ3–ƒû†€ÔŒ3Îóàúq-ùÌó™àüIÕ÷sCl7P@P@P@P`€ 8âj€_`OOP {)Cà‚éI4´/·Ür±!½ÙD ƒ‘ph´'8Ôh´Tõ1¾þúë°ÿþû‡ï~÷»aâ‰';ï¼s Ñ€_+h"p@0m²É&‹äItÁÄz#&ñÂèœÐIùm¾ùæ•À#š0Ùn»í*0Fs1š,eD‹Ñ6“D¹I£61jfši¦‰¿#p@Rq.Œ,ÂûÏþsS"p³ß~ûÅóI5‚Sk­µV öŒ9æ˜1÷ß?¼óÎ;•}¿ÿýïÇÑ\l;Úh£Å€c-÷wß}7øjM8á„1P„ÞäAÙ X¾ùæ›=®?#ŸEDÙñåš\~ùå=Ê^d´£”8AÎ1UÄï·Új«J°‡€׬úXÍÞ—õ¶Ç`/8E=‰GùIœÁ*Ê™•¤°"8—Ÿ)‚GcŒ1F ÖÍ<óÌáù矯ücØäï\GîI>Gù}ßh~ãÆ¾ŒÀ›þùà 7ÜP9aÖ£ŒÜŸÜ/¤êû¹,cóQ@P@P@P ¿ 8⪿^9Ë­€ (ÐïþûßÿÆsÈ×1¢1›QCͦaÆõØ…ÆûO?ý´©lå“>©\ ¥T+½øâ‹1hUþóŸÿÄc3ò$OLÃW=]`~¼4ʈÀYJüŽ -ΉÂÿøÇðI‰QH»í¶[œ¦à&FH‘P0^YÄïÈ#?¿cÔÓ|C)¤"¸Äè®/¿ü²òûtÝøE ˜¤?2B§–;Ó€"¨Hˆi ”å‰såo)¥Ñ:éß\F†¬ctÓþãÿˆ#}zKEÊY½ £÷ª¯W¯©ñG‚—Œ®"‘_šJ’ÑaS*êQ}ÝðÎï!ò˯ž—^zi,Ã'Ÿ|?kL™_Cî«4:‹ýÙ†à#¤Rj´ 2¦§$—\FÂå÷³<ÕºŸ›5v{P@P@P@š€«vE=P@~#ÀH$‚9L}ÆtLµÇ(#<²és¨5•_o™ä ÷i»´îSuÃz½| äA·´]äit"ŒdªNi$J­}™&‘üq•#v˜Z)™æéçN?ýô8â…àAš°^Yd0ûæå!ˆEP„i „1í Óæ)Ûz;O¦þ{úé§{Œò¢\$®w U+êk¿1OîñQĹÞR‘rV_·"×±Ñ6Ü×;ì°C,Z^†êûµ¨G­û¥·@/Ó2ý%SN2²ÄtyúꫯF¢«þ]£mø,0U £ë%jÕŸ—Z÷s£ÏŒW@P@P@P`  8Uà@¿ÂžŸ ( @W ¤b:6F ±&k<-²È"5Ëœèói󊜣E±”§Z£¨Bä¿'˜À4}©Á¿úXŒzòÉ'G*Á¦Ã«žŠŽm«G†)¾ Óâ1_­`*P¬ÆH)#”X¯)FªÇuòµœðbÝ"¦`L£·þõ¯5[ÜŒäó“Öÿ"‚mü›uÂzKù”wl÷Ì3Ïĵš8ÊNàŠÀ\)Ÿ>ü˜ò0]ûTöü>bÔ×믿^9t­û“éɃŸ´¦S­²õhö[öcª>¦Õ£œ¬+F@ƒtþùçÇý¶ß~ûÅbtÌSLGë´ €tüñÇ7=ãGá,´ÐB=ògTÇ<æ˜câT†xã‡gZ³‹î¸ãŽè‹!–gœqFØxãc^wß}wü/k&•‘X—ŒõÕ8±Ûn»­2•á¸ãŽ׉ºîºëâ¡ùuæ™gÆéSjõþdÿ¢Ížç´ÓNî¹çž8M ëjqTbcÊEî{ZÜGuT{œríI4´ó“ë5±Öë+ÑèÏtu{î¹g¹Ä4{”×]w o¿ýv˜a†bæ›o¾†å­·Áµ×^§V¬F Á¹ &˜ ,³Ì2q䉑_^xa8öØc+Ó¸ÌØ}÷Ýãß \}þù畵§øçuÜqÇÅkpØa‡…³Î:+L?ýô çP4@#(E({J˜|Úf›mbåÐC¶cÔët¥D¹ ø±V×aÝu× m´Qü3£uâõ6µbѲ²#þ(ËsÏ=§/ä¾äH‰@$W]uU äpŸ®´ÒJ•¿·z¦ Šx4s>lËýˆ×ßm·Ý6„Ë7ÖÛ|óÍ+÷=Âtß³m£m¸¯YßŠÏ ÷ Ó"0îí~©w?7{Žn¯€ ( € ( € ( À@ò¿iaF´rR“N:il´0) € ( @k×_}`” ëï0Bˆ©ßÞxã°ÕV[ÅÀÏšk®ÙZÆî¥@“³Í6[ ®Í<óÌMîÙ¿7¿ÿþûc“µ¦ê¥"ÛôoK¯€ (ÐWt²ÉGV=îXc|Æ:¼èæ=¶:ÎôaȘS¶´¯;) € ( €í |ýñã!|ÛsùŠ"y¸*¢ä6 ( € ”,À(Ÿ¥–Z*Žþ 3Hž^yå•0þøãôû’‹`v TXŒuš)4˜Ó"2…$»z©È6e™1‚­zÚʲò6P@Q/`àjÔ_K € ( €}+ÐjàÊ!S}{<š ( €Q`Ì1ÇŒkè<ýôÓq:=¦¸£.ë1ÅÜÞ{ï˜âϤ@_l²É&qêÂ馛®/×5Ç`M6Örc Îz©È6eÐd“MVVV检 ( € ( € ( @¿0pÕo/W@èϬeuðÁǵ”XWˆµ§XjÎ9çŒë]Í;ï¼ýùô,{?àÞ›e–YúaÉ[/ò7ß|§¾^`êfRd›ÖKàž ( € ( € ( € (P-àTÞ ( € ( € ( € ( @‡œ*°ÃÀf¯€ ( €]'ÐêT£uÝ™X P@P@P@P@P@P@A)`àjP^vOZP@P@P@P@P@è>WÝwM,‘ ( € ( € ( € ( € ( € (0( \ ÊËîI+ € ( € ( € ( € ( € ( €Ý'`àªû®‰%R@P@P@P@P@P@¥€«AyÙ=iP@P@P@P@P@P û \uß5±D ( € ( € ( € ( € ( € ( À 0p5(/»'­€ ( € ( € ( € ( € ( € tŸ€«î»&–HP@P@P@P@P@”®åe÷¤P@P@P@P@P@P@î0pÕ}×Ä) € ( € ( € ( € ( € ( €ƒRÀÀÕ ¼ìž´ ( € ( € ( € ( € ( € (Ð}®ºïšX"P@P@P@P@P@P`P ¸”—Ý“V@P@P@P@P@P@ºOÀÀU÷]K¤€ ( € ( € ( € ( € ( € JWƒò²{Ò ( € ( € ( € ( € ( € ( @÷ ¸ê¾kb‰P@P@P@P@P@P@A)`àjP^vOZP@P@P@P@P@è>WÝwM,‘ ( € ( € ( € ( € ( € (0( \ ÊËîI+ € ( € ( € ( € ( € ( €Ý'`àªû®‰%R@P@P@P@P@P@¥€«AyÙ=iP@P@P@P@P@P û \uß5±D ( € ( € ( € ( € ( € ( À 0p5(/»'­€ ( € ( € ( € ( € ( € tŸ€«î»&–HP@P@P@P@P@”®åe÷¤P@P@P@P@P@P@î0pÕ}×Ä) € ( € ( € ( € ( € ( €ƒRÀÀÕ ¼ìž´ ( € ( € ( € ( € ( € (Ð}®ºïšX"P@P@P@P@P@P`P ¸”—Ý“V@P@P@P@P@P@ºOÀÀU÷]K¤€ ( € ( € ( € ( € ( € JWƒò²{Ò ( € ( € ( € ( € ( € ( @÷ ¸ê¾kb‰P@P@P@P@P@P@A)`àjP^vOZP@P@P@P@P@è>WÝwM,‘ ( € ( € ( € ( € ( € (0( \ ÊËîI+ € ( € ( € ( € ( € ( €Ý'`àªû®‰%R@P@P@P@P@P@¥€«AyÙ=iP@P@P@P@P@P û \uß5±D ( € ( € ( € ( € ( € ( À 0p5(/»'­€ ( € ( € ( € ( € ( € tŸ€«î»&–HP@P@P@P@P@”®åe÷¤P@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@(CÀÀUŠæ¡€ ( € ( € ( € ( € ( € (戫¶ Í@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@Ê0pU†¢y( € ( € ( € ( € ( € ( € ´-`àªmB3P@P@P@P@P@P@(CÀÀUŠæ¡€ ( € ( € ( € ( € ( € (戫¶ Í@P@P@P@P@P@P  We(š‡ ( € ( € ( € ( € ( € ( @Û®Ú&4P@P@P@P@P@P@2 \•¡h ( € ( € ( € ( € ( € ( €m ¸j›Ð P@P@P@P@P@P@Ê0pU†¢y( € ( € ( € ( € ( € ( € ´-0zÛ9˜ (0>ûì³ðÄO €3ñP@µŸ~úi>|ø¨-DGßl³ÍÚØÛ]P@j <ðÀÂ( € ( @ L:é¤aذa}xÄreàª\OsS@~*ðòË/ªúéÅ³Ø ( € ”(°öÚk‡ 'œ°ÄÍJÜï¿ÿ¾ïZƒûðìP@FÀj«®~süñ£àÈåÒÀU9Žæ¢€Dàž{î c5Ö9OCP@¾xøá‡ÃöÛon¼ñƾ?xG¼÷Þ{á‡ÚF (ЛÀ5—þ<Ìð½)DR@P@ uҟßèðQ:›½k\uÖ×ÜP@P@P@P@P@P@ ¸*åf ( € ( € ( € ( € ( € ( €0pÕY_sW@P@P@P@P@P@((`ઠ”›) € ( € ( € ( € ( € ( € tVÀÀUg}Í]P@P@P@P@P@P  €«‚Pn¦€ ( € ( €}+ðÉ'Ÿ„“O>9l¸á†aõÕW[o½uøÓŸþÔk!>øàƒ°Þzë…Ýwß=Œ1"n{õÕW‡UVY%Ü{ï½}{MP@P@P iWM“¹ƒ ( € ( € ô…ÀñÇn¼ñưØb‹…wÞ9L9å”ᬳΠ7ÝtSÝÃO<ñÄa«­¶ Ï>ûl¸í¶ÛÂûï¿®¸âаÈ"‹„E]´/Ší1P@P@P@Ú½}ÝUP@P@èˆÀk¯½GH-µÔRa×]wÇXa…Âf›m~÷»ß…•W^¹îqùÛ­·Þ.¸à‚ðÐC…o¾ù&ì´ÓN)§™* € ( € ( € ”+àˆ«r=ÍMP@P@JxõÕWc.sÎ9g%·1Æ#Ì4ÓLá7Þ_}õUÝ£ 2$ì¶Ûniï¼óΰ馛†É'Ÿ¼„R™… ( € ( € ( @§ \uZØüP`À üå/ ‹/¾x¸òÊ+ܹyB# °FÊ ,þö·¿É£€ (P’À_|>þøãøÃh¨Zi´ÑÚ{U!¸õí·ßƬ?ûì³’Jn6 ( € ( € ( €hïm°Ó¥3P  >ÿüóðßÿþ7°`|_§çž{.œ}öÙ}}ØAsðÀq{¶ùñî¿ÿþ@¹YèžóþÕ¯~èñž ßÓcžéW\qÅpØa‡UÊ•ÊB@èÐC «®ºj ¾¥ÄT| .¸`¸ãŽ;*eÛc=ÂG}TÙæ™gž Ûn»m<Æj«­è©Ï4S¹?=é9ÛàY+qÜõÖ[/,ºè¢C¬.¹ä’ð³Ÿý,¼ùæ›á‘G‰£QµVâºR^\ßÿýÁvû{¾ ( @)ßÿþ÷üóÎÆwܺyòÝ̈)žE<ùÞÝa‡âó¦^zá…âèYž¬‡5ᄆ-¶Ø"Ž¸ÊŸ{¥œˆ™( € ( € ( € ”.`àªtR3T@Á*ð‹_ü"Œ1"E†8à€Ø+øàðöÛoW¶Á ¯yæ™'L=õÔ±Gý™gžÿþé§Ÿ†]vÙ%£ñrúé§W\qE8þøã{cŸ}ö‰ •Lãˆquz饗ƒnü—ÆR9þ?õÀŸtÒI£í2Ë,Ó# ÎkF9ÇgœØs’I&)zùÜNP ¾ÏwÛm·ø½Ï³‡Nk­µV¯9¬ºþúëc'Œ”x~Üxãaà 7l¡ ( € ( € (ЗΓїÚK´À¬³Î<86Ø`ƒ¨¡w÷|óÍW9ï…^8üæ7¿‰(‚GüÑDE1FQ±o‘€×+¯¼vÞyç8j¬±Æ ûí·_< ¿cD½Õ·Ùf›Ø¸GÐ)¥f˜!œvÚiqDÁA‡rHXa…Â÷¾÷½pê©§VFб‚`Ÿž|òÉØ«ýÑGíqJã7^ì)?öØcÇ5I8&£Çu–eÙrË-믾mn¸á†À9ÓPùÁÄÑXüÄùû¾ûî[Ùÿõ×_£à87iÕ‰Àvœ+£¾Hó˜BŠ€Ùæ›ofÆjùâ@P‚V4ŒšP@P@P@P@P \Wåzš› b€'hEšrÊ)càê“O>é!BPˆD ‡ÀÁ–7Þx£åõ²èìµ×^•cl¶ÙfaòÉ'¯”!zø#ŽHLõ—'ŽŸ'΃D‰u¼˜–iöÙg¿Ky3 ŠÄ襓N:)N“øÎ;ïÄà)ý=å˺QäG"ÀGàêµ×^«iÃZ'ô°ÇŽk/¿ürÜî÷¿ÿ}üÉçŸ5¦"L‰ÑeÿùÏâ? Ð"=þøã±¬?üpüw½µUzèÿ8ãŒ3âT‚üòãToç¿P@P@P \Ív:-¼öŸ÷Ãm8 ×Œß|ûðî–LJŸm·JXwÍEZ*Ä9—ÜÖ]cá0ÉÄãµ´¿;) € ( @û®Ú74P 0ª(¥Àª¦É׆úæ›o⟿þúëÊf)ðÃ/ªƒ^µ˜Ù—QC)1ý^ .UOc—¦üùϧãK©zm‘üßé<±Eª>¯Ë.»,®£E`‹QJ¬˜²¯:1Z)¥¯¾ú*þo*Oú}½mÒv¬¶ä’KöÈ:/kõù2’Qg$‰¬¶îºëÆQkLHZ~ùåãZUEyá@޼g›m¶"»¹ ( € ( € ô‘À$6]oÉðù¿ßÒÿùô¿ÃÉgß–_j.W- º“ ( €å¸*ÇÑ\P@B™6Àzâ‰'â>ÓM7]e¤£hÀzðÁGÊ“iöð|üñÇq4Ôd“Mzè¡Û1%^­4lذÈaÖš"b ¬Vk>‘V_}õ¸¶×wÜQ3+¦ÿcôÓTSMU)oõH§Gy$NãLj(ÊÈùM4ÑDaÆgŒy2J*•›3,Ôj´zt¦X‘#ÑÞá—RZãªz´Xúûî»ïËtØa‡ÅQk_|qÍ) [µt?P@P@h,ðÊð·Ãê¶Øpépëÿ ï½ÿIXaé¹Ãáû­ÞxóýpʹùßûTÃf˜*¼ûþÇáðßü1Ü÷Ðóaì±Æ?ùßHª]þ?öÎ<ªk‹ÂBAƒ»»6x‘‡+ÁÝݵxq-ZŠ»Cñ(nÅ¡¸§4H¼³N¸Ãd2“LB ¬ý¾|Ì=úŸsç‘»fíÓÜ7MúŸ[þ‘éó¶é:IÅ–=ªIÏ‹õµÊõÇHêñeýâžrþÒm1a­þo¼¸1¤u£’R¹\^1ÆñË ú2uîVI‘,®Lá›ÚœA$@$@$ði(\}?Ö& À™V5jÔÐÂÒà!u`† LB‡‡‡>÷ âÒZÎ_‚X„s¤ ´ MÄ {b „¥_ýUŸAÑçôéÓÒ A©U«–=Mø+“*U*ýDœ‹/Êž={ÄÁÁÁ_9ˆqõêÕÓn0¤PÄTp;™„&ˆyF A¤äCJE8¢æÏŸ¯ÏŸjÚ´©NíŽøï¨Q£ì7˜Ã‘†œS…qº¸¸hÑ ©ÑÞÃ<À"áÌ™3Míã|/ˆ^›7o–ÇëóÌ:tè`wÿ,H$@$@$@$@$r6ï8)ÃúÖ–MÛNÈïëJ…2¹$IÂØ~:>~­ÀE5qx#¹ríž WT¶ÌÉ•På"}†.•ü¹ÓKÛ¦¥äÆ¿$Sº$R²XYºjŸŒÒ@ W ÔßiÞÒ±÷<‰ìè ƒzÕ¿¶Ÿ~#VHº4‰%z4߬½U;eŠç¢}S®3H€H€H€>@ÄOo‚- ØK fÍšZ Ë gI=Z‹3ÎÎÎ2xð`0uëÖ-‰'ŽüòË/þšíÒ¥‹vhá\*¸¥rYVF:¿ñãÇë´~Ô"GŸ’ò‚WÁ‚gMÁ!Ö»woÉ;·¿q»ººj7D#8†îOpƒWD;ˆZ:uÒí à”)Stš@ˆ^Ž <¡ß _8W Ž*OM›6IÇŽåæÍ›ZÄBŸø¯»»»ÄŒÓjóýû÷×ëµpáBíc |~e~øNòæL+¥Šg×{x<ñ7ˆý‡/Ƀ‡Ï¤SŸù2iæ_úú¥ËÿÉ¡c—µ+«}³ÒR¶dNiݸ”:g×YÄóýn­tiÉÕr×ã±T«”_Ê©rm›–Ö×÷ÑÖ9‡ß,NœH D<|øPŸçÔpr|'Ñnµš.ïàœR"DN¬º!UÉÓÓS ( ë÷Ô)„T³VÛ¹vÓC*Õ#š—‘–J†j_lœH€H€Â*‘×Éc¯2vœõc7>ç¸}ž©óáß½ r—Tž‚ŒŒH€H€H€H€H€H€H€H€Â#ǯȖ]êᘊøñb†¥¡q,$@$@$@A$@á*ˆÀXœH€H€H€H€H€H€H€H lXõÇ!Ù¾çŒà\«K¸†­Áq4$@$@$@A"@á*H¸X˜H€H€H€H€H€H€H€H ¬9 nXÇC$@$@$LƒYÕH€H€H€H€H€H€H€H€H€H€H€H€H D P¸ QœlŒH€Â6ÆË®]»¬²Zµjrâĉ°=ŽŽH€H€H€H€H€H€H€H€Hà«&@áê«^^NŽH€H€H€H€H€H€H€H€H€H€H€Â Wág­8R           øª P¸úª——“#ø|||dæÌ™âææ&åÊ•“æÍ›ËùóçõP:uê$¿ÿþ»iX“'O–¢E‹Ê›7oô{¯_¿–B… É;wdóæÍÒ»wo2dˆ´mÛVêÕ«'}úô‘W¯^™ê»»»KË–-¥B… R³fMÙ½{·éÊ=ZÊ—/¯¯ãµƒƒC€HNž<)uëÖÕu4h`JؤIùóÏ?ýÔ|¸ú-R¤ˆ,[¶ìK,/û$    #ܯޑæ§:š Ó7ÊŽ½g-gYÀãÁ9wñß ×³¬Ð¸ýyäùü“Û o Xò«Þdœx>~¤iìÞ.HåƒSøÄéëòøIàã ÏëÜ{À’çÓg^R¥Á/ÁÁlW#WÈÁ£îºlpö‹]° @¸ @á*\,I$ž,Z´H:$sæÌ‘7JÓ¦M¥}ûöòðáC)X° >|Ø4;wJŠ)LïA8J”(‘$NœX—Ùºu«”-[V¦L™" .///Y±b…¾†×h·víÚZÐ5j” 4Hnܸ¡¯Ïš5K®^½*+W®Ô×Ó¦M+h? øûï¿eÆŒ²aÃiÓ¦tëÖMž={&5jÔ5kÖ˜ªBÛ¾}»TªT)HKë矖±cÇÊü!«V­ÒŸ=óA™ß~ûMòçÏ/ࡎA$@$@$@$@$@hZ·˜Ο1°bþ®oÛ}FÎ_ºäz–Æi qbGÿävÂ[ŸÊÏÛÛG¦ÎÝêÓ^°b(ƒù—)S&H}²0 À×O`ûž3²u×)•e"¢x{¿Oå Þ¯¶ÄS¦ÌÙ*¹r¤’ÒÅ¿“k7=dÔ¤õâýÆG#G’=ªK¢„.råÚ]>~­<ñJ"9:ÈÏêýåk÷KdÇH×ÏÐ>µ$¢úûmØø5rûާø¼}'µ«”J?æ¸PzZ"iS%”G.É ^5${æ&譺Δ™ã[Éû÷ï¥ÿˆå*‹†·x½z#j‘K¸úYœŸ•%«öé²ø{qœ½bD÷ý›Îˆº­~•â…²Èõ[÷åî½ÇRµB>©P:—€Áéó7åòÕ»rïþY6³“ì9p^f/Þ©ÇÛ%šž—!¢M™³Eví;§ÿ>­W½°T-ŸOÐÿ‚å{tWɓƕÝ«©ù½ô7î2?|§x­‘+×ïÉÅ;WöÔÒµmyÓÁÓ’..[³ONŸ»%÷>7Õ_j…tió¶Ê±“×T6oÉ6±ôíê&³íÔséØ{®RÂc­*ÿVF[ã·6ß‘׉£Zׇžé5Ž®˜Skºm÷i9rüŠ ·FR¥ˆ/}»TÕ ¬­±Ž¤¥ZÓYSªÌ"^r×ã±thñ£äþ.œžú/ªÓçoI”ÈŽrG¹Èž¨µJ.¾ó°ÖÆÐ|ÑF×cÄøï+aÏZس‰•K¢"±jËC9Èp‘œ¸VÊ—Ê¥Ǽâ+š¿.Ð7D+„ÑìY’›þÖMßE»Â1±¶þØÿ¾mûfxøüÅkíf²Œ¼X@4Œ£ÄD¬ƒƒž^xùž©ýZÝ”`i-ÐD+Dê Ls®„§Y‹vÈ#uŽY$u¾6\Fغ7ÿ9uMÚ7÷ͪ‚}šK9·,ÃÖ¾4ÊÕ¬\ÀT\³NÓ¥¶[)U4‡ÕñóM ðA€ÂUøX'Ž’H ¨R¥ŠvPeΜY Ò༫ãÇk§)øfÏž­¾ôJ2eʤß+^¼¸®ƒ”‚Q¢D±k¶… ’_ýUþúë/>h@ CjÀX±bIÉ’%eþüù’-[6Ý&Râz@~ÖP)QépåÈ‘C†®û³5Ne8K+cFßò8cËZ—.]Òl Bal†ƒ,°ùØ……H€H€H€H€H€¾*ÇN^ÕnD÷våû<émÎÏI‰8æa‘üA¥2{/.1£ªôa-ü”{lö€= xÞÊâ·ýÙ%¢Eu ”;Ò.žÖ^ö¾$ý‡¯PN®|Råƒ#•ïÞóÔé —Îì¨â#å­@ªB#ðÚÛ¢Eûø÷¤­´k¨ç7/†oKà•/W:Ñ2¬{Œr¹_½#³UJ?¤gÜ+ðsˆ£8EöÇpÓ¶ã:]ߘõµÖºû¬@YZ@óEƒQœ,ƒZù¢¨½kà¤6ó0$…p¼aNÛO•Q?וŒé|ÓãQÒ<”Èãw_‰®[ëoÉŽ*k߇õ¯¼W%|Á=þ±cE•!6ÖÓr ¹Ÿ¶QÒ¨t™[û¦Œ„[Ð[÷æ{µÍ…b+bY@ûíG7Ûó j‘²J]¾f¿4PiçýÖVœ£øÝsn. ˜ä3L,A$ð5hذ¡ä˧ruש#åË——3fÈøñãMÎ%œYñçF×Hh~¾U`LbĈ!“'OÖiûÊ–-+¥J•’îÝ»‹§§§® WÄ&¤Äij"EŠØlÿX†£ªcÇŽúÜ-i·qæ*Ö¨QC§3¬T©’Ívºté"›6m’fÍšI»v*%…§‘‚é'&Nœ¨¹`L£F’aÆé¶›O`#à¶Âû‘”`‚4k8±p3y<ðuÒ åË(¶ׯáH٩·ʓӿCÄÖX¼^¾ÖéáŠê¦ÎƒÚw袟¢žOÔXÔ9T­ @ì1§e›{öŸ×oAt;ªDŸ,ê\#ËÈ­D8“à4C@HÈ™#µ~ç»Ô²I¥ĹDÆœsdM!UÊCœ›e¼‡ó ¬û:# ¢œ7¬Žž¸ê¯K~¶¸ \Šdñ´h…t§ÏÝ4EZ¼W¯ßø«jmüÍ×Vßx†±ÎAYkm‚æôŽåÕ¹c'”‹-8´gkýïª3«:qüô5½·£:.ªcDé*•#ÒaBÈ JàܲÔêœ0Äß/X]7Ëö\³¥’{Ïè·á ƒË2líK[üáàk׬ŒúBnTíÞc „Ot\…Ïuã¨I€Â0@ðc-¢E‹&ôs)uêÔrôèQ?ïÁÕ„ó€(f¨7iÒ$«ýÀÕ§O»IÁm…€ûËVàl/¸¥ §˜µr¸6w®oj #5jdz=mÚ4›í4Ÿ ÌÅîI³ À7E¸šp~*· êK‡*ÕÝð~µõùOo¼}]U‹Ô¹P¥‹çP©ðj`XßÚ:¥Ùà1«¤¾rrDPÿ«©Ò’eH›Ä$ šY wªìŽòNõݵm?U2¥O"ÉÔÙC Ûþ¦]$ uHÃÖ¢Ë æ°S«²:Ýže$ˆK»_Zu›¥Åbý»»éb8Û éë´˜¤SÂá|®ê•¾—Ÿ»W“Îýæë2p¨õé\E§í³÷#ÏgÒsÐÒÐÛÇGZ5*é¯K~¶ø”+•S:÷Y ç3†³”(’ÍT´V•‚Ò¨íå¢q•ÆuŠ™Þ·5~[ó hmjV) ÷Dfu^Ù0å6³w ¬µ9Ùn9xÔ]±ä¨1GÒÛ¿{-°}‚ë1Áukë2­r<áÌ®Õa¯”è7è§öt÷a½ßé5¯Öxœ>#*¥±G‘ÎОhT»¨¾‡®Ø«WYÕ9]î©þ#VhÇö§‘®Ó¼ö±µ}iMXë5x‰Jè›î0Sú¤&!-°qð: @Ø#ÁÝÝÝš»6Бâìœc  ¯À¹sçÄÍÍMöíÛ'NNöék˜»½sxúô©él*¤!d À×DçLÎb¤úe @HÀÙ¯>êPÃÉñDw¸Ôjº¼ƒsJ‰9a°ê†T%d„@:òõ‹{èóp¾Dàaz®©”õÝ—è>Äû¬ÞdœÌšÐÊêùI!Þ sl­?„«Fí¦ÊšÝ‚5fœ±§Béœ:m$Ĥ³JÜìÛ¥j°Úc% /Gÿ¿÷Ø+†Œ7þË âCÏ>ÏN©oBø ÊA *OA¡Å²$@$ðhÞ¼¹Ü¾}[p~E«otpÚ$@$@$@$@_„Òm3>è%]Ú§õöyjs[|Îaµ[ëÿ)ŸîWïJ å"ÄyÔ¤‚Œ#jXEÀq‘ |å(\}å Ìé‘ @H˜5+ðÃqC¢¶A$@$@$@$@$à—ÎDb€N£×gžJ ø^r©3$@¶ ´mRJFLX«ÓTB´ÂùX½:U&2 ø"(\}ìì”H€H€H€H€H€H€H€H 4 ÄŠUæMnš]|‘¶WÎíúEúe§aƒ€­õ©ÜQkvö sæH-3ÆY?«;ز" “€¯÷“A$@$@$@$@$@$@$@$@$@$@$@$@_˜…«/¼ìžH€H€H€H€H€H€H€H€H€H€H€HÀ—…+î            0A€ÂU˜X‚H€H€H€H€H€H€H€Hà[$ð÷Á 2ú×õ!:õêMƉçã!Ú¦ÑØ›É¤™›ô¯#'®“-;O†J?_¢ÑrµGŠÏÛ»Þ¾çŒ ¿&ØÃ0r…<êìú–Oœ¾.ŸØ^ëÐØ_!6x6D$@6D"         /C ît’=KŠ/Ó9{ ÷¬Ø#Z–—XѬ΅û+Ü/1'@ß$ Wßä²sÒ$@$@$@$@$@$@$@$¾ œÕ÷{ýû¸! $Ftg©ÛêW)^(‹\¿u_îÞ{,U+ä“ ¥s ÚnÓc¶d͘ÌÔv·¶$s†¤²çÀ9xÌ]úv©ªLŽŽòðÑ3yþBõ©ÚÖ§–îcÕ‡d›‹·÷[9râŠ|Ÿ'½LÛÂæ".[³ONŸ»%÷>·òù¤NµBòîÝ;é1p±<{þRý¼’ÚU Håryõøôܳ¦”§jîw=K‡?JîïÒè:×Ù‹ÿê9&KGbÆtö×ïµ›2jÒzñ~ã#Ž‘#ÉÀÕ%QB©ßf²Ô®ZPæ-Ý%Õ*æ—:n…Luá^Ú¾ç´DŒQ^¾z£ÝNƒ~ª!³íÐkY½Ò÷º,\^§ÎÞ¾]ÝLu1&‡ˆš§Çƒ'’/W:yùòû­Û¥sër’+Gj]~êÜ-²ïð%ýÚ5[JéÒº¼®{úüM=æ(NŽ’:e=WÄí;4§%Ó;èßïÞó”®™~756nµbüLÞ¨yƒYÞœiý°y¤öQÿËå…×k‰í]­ŸéúÕ2bÂZy­öoĈ¤GûJ’5S2}lf.Ø®÷X¶ÌÉ¥]³2ºW¯¼ÅK±jT«ˆžÃµ×‡Ž[#©RÄ×{È’w‚x±BeÙÜx¼@$@!@€ÂU@d$@$@$@$@$@$@$@$@Ÿ—Àòu¥mÓ2’Ç5<ð×¾sZȘ=©µ!굞,òeЂ D«ãZ(a(’NÍ·nÓQ-¤˜Ç÷ÿdÂІJp‰- •“eú¼m²dF-¬´ë9[‹7Ù2%—Âù3JñÂYuUe”ª*qèÍoI¦D± Kh1­f³ Ç‹“rÎ^øWÆj Åœ+×îJï¡KeÅì.þ aüÃûÕÑïwí¿@޼¦Å–Ù‹wÊŸKzê±tì=W D’)cšéq ^Êßk¡n¤øÀ@¯éx-\!0÷_Õ—dIâÊå«wµ Ž›wž’G*íàâi4ÓŽ½çY®^)£Ô•„ \´°7aúF©~¥D–‹—ÿ“•s»Z·eÕ¼®zýV¬; Óójó)AhI¸Ú¢ÆÐ´n1õÑöP%ìaý‹W,ƒ{ÕÔâÑ™ ·dêœ-’kt3Ù±÷¬\ºrWüÖV³:vµ¬þó°æÑoør=çôi+‘ï¦,[½?H›xÚ¼­R²h)Q$›:ŸI}µß6-ïí§É³6Ëÿ dÖâÃÊõǘ®÷¼D*¡æ¿ÿ=”¶=çÈÊ9]äñÓ2Qñ[0¥vRA¼Z»ñˆ°Ú4)­ëCÃ:B@í×µª¤TBÂ’70ó©ý$P,L$@A$@á*ˆÀXœH€H€H€H€H€H€H€HàË€‹häĵR¾T.%åÕø¹$ÇÕ™?­ºÍÔ|ûöv>]½~O»hÚ÷š«ß÷òz#±bDõ7‰$‰bkÑ ‘0~,•Â/¹;|wÑN(\.¶—ÈJ8¸¢Ú6D,\Ë©D&\<œÎ)±+göTâÛ¶‹¾–6u"å¨z­]8–‘Çõ£cuî+7„ ü@lrŽY¼”³(²ÃŠ¢3›Æ?N %†xIUn&Q‘ÄÓìl¤Äjl­‰U¿Ê©…øçÔ5)U4»~ …¿Ï¤NæÇÖy%| õ»~Ü1#jV.`s¨9²¦Ð¢¢ˆj{õ‡¥W§Êz®ÿÝõT¢cí`ËùÁ=eޫ⊀ 7v ùN9ÆX»‡žë×^’2Ås˜Ö±|©œ2_‰˜%?Ì ¢éã¸XO·gkðޏkÁ‚›1ŽçJœ‚SÎðkßÜWd„€šK¹Ø^ª9B´B€=Æ}ý–‡vŒÁAf¤ÿƒãÜ;õ™¯Üq/¥–ÁàH³ñ©ýe³s^   @á* ²         'pìäUíüAtoWQ§Ç3N«EÊ´iÛqiÜ~ªŒú¹®wê©”x†‹È( G„¸s Qæá Äó@ZB¤>~­,ÙQ pB™‡mŒÀkˆóqø.¡—üÔâdñ¸N‚³âG£vS$]ê„J¬Ë)©S$p.Qœ"[Œý½ŒŸ¶QÒ¤J¨Òè•××Àŧȅ&¼ç;j:âǦ"}™Ì÷Čj3uaôhQlŽÕ’—Ñqåóhq0AÜRºX«õád3H‘|0ßa«ÅRÍO|˜‹Nóøq–ºˆÃ‡Ô‘Æšõ^©4€ÖmÀåÑV(ÉÑ´t*% Â[­¿­Oˈ7¦r½µ—ý*åaÿá+´+­Ê·œeÙ€x‡Ôþ²9x^   ðñÓ<c$@$@$@$@$@$@$@$@$RpÆRÌáÇ\´Bû^/_kWRô•WçHPg^È›AVo8¬Ü:oôp"¸zpŽÜWˆ¸œð|â%.檄 bÔžýçü4³gÿyýûcåfÂY[YÔ¹Vˆ»OtÊ<ÄñÓ×´›&ª³“ÝC@Ê»ñ*áˆþuõœƒ8ï*µ: ñ÷Á &Nµõ]¶T:ÝBqV”yÌ%–®v›±€£ÈZÀ­u@¹ Œ#Ãy¶Q Up§!Ê–t•½Îë3Æ*”Éœéê:óe”ÛN˜Î¯Ú´ý¤Nß'vty«Râœ)Ä÷ÛÊÝæë2‹­Öî:ãÌ«TºFk=¹tõ>Ó%ksvÕü|Óõažp`!â)ö/RH"î(Aôž:Ÿ+Uò’]¥{ ýŠýA­JSÙ­myÙw袾žÇ‡q’ªûëSúg] 0'@Ç÷ @¸#0Ùn•Î]¥ÌsTïeHïZZ<Ѱí•.‚NA7c\KI/–üܽštî7_ÏŽ–>«ˆ‘j.(“Ï”>‰$KGõñ›ÀÙb)¨!ý^‹.3t*¸N­ÊjDZåtÂyDÿª4p¯ÔYXƒÔÙFA‰¸JhiÖiºÄV"„•–ê-kéój³Qí¢*½â:u~×^í¼ÊªÎë ,Ê–p•ãJl©×úW-´¥IiÝéÇœh8ó B‹[…|RÇ­¿æq–Ô*•°@ž úÆÐ{È%ôyéõÒ»¦~bX•:Ïãþ“@ÝeÍçO]ºòŸÔmù«8)7[æ ɤF¥ïu•!Ê-…s®¢*ñé3¦óM1g`µî>K©3»FûÉ2Ú6-­yÖRg™!Ò§MìÏÕ§\ÿ+äÍÇ´Ø™ãC:C”Ç`CÇ®Râê;½WáD H¤«l׬´´ì2S"ª÷3¥K"E e–Y wJµ×ß)¾]ÛVÐ}Ö¬R@sÏœ1© ï[;°å´y=$öW°;gE ° ÁÝÝÝ¿÷ÔLqãÆUùc©{ÙŠEH€ÂsçΉ›››ìÛ·OýCÖþo¼…ƒ©qˆ$@$@$@v¸víšÔ¨QC>¬œiG !  xøð¡z8m=ÝX@-99¾“è·‚ÖÙ‡ÒÎ)%Bä„ÁªR•<==¥@²~qOBj<¡ÝNõ&ãdÖ„V¦³‰Œþ \5j7UÖ,è¬!À ³ïÐù©ce•6ÐAŸùÔãçEÚ‰žcûž3rèŸËZD´Óçm“¨Q#KƒšEÂó4ÃüØ¿ÖýæÁs€$J ¨?öŠ!cÇ¥ìoÖçÙ)õm‘ ;œ©<ÙϘ%I€H€H€H€H€H€H€H€H @ŽVòWÆ8s)8ø._½#™Ò'Õn#àö?uƒ\¾v×O“½•”,IÜàtóEêØâòúµ·lÙuJ ŒÐ%`k…n¯lH€l pÅÝA$@$@$@$@$@$@$@$@a˜@µJùeÐ蕲ÿÈ%uΑès»ô¨n3m_žŠ]C›¹p‡lS¢R"-"#t ØÚ_¡Û+['  WÜ$@$@$@$@$@$@$@$@$ªl¥î‹#ª¬]Ø=Ø}ÇC&hìúaµ"ΟÂe´hðƒà‡ñy|­ûëóÐc/$@¡A bh4Ê6I€H€H€H€H€H€H€H€H€H€H€H€H ¨(\•Ë“ „  W¡‚•’ •…« cy        /JàÁ£gòcÍáòþý{Ó8ðºl­âñà‰Ýc›0}£ìØ{ÖîòA-¸}Ï6~MP«Ù]¾\í‘âãóVž>ó’* ~±»^h|þü¥Ôkýk€MïÞÎæõ=Λ®‡ÖýÜÅ­¶ß¸ýyäùÊ—Ê©_O›·U޼&oÞxK†´‰¥oW7íJê9h‰¤M•P¹$ƒzÕ [ŽK®©¤tñïäê 1a­¼~í-#Fí+IÖLÉäöGÒcàbY2½ƒnûî=Oé:`‘þ}åúƒòÇæc!BqtŒ¤°7Wuý~Oü¡r‡u°P<?—wïÞ˘Aõ%~ܘrëöC:nµ~ÏëåkéÛ¥ªdɘLàqttԃ˂а>µt§Ïß”Q“ÖK'GI2ªûÎêBB :züмVbQ¹®R·zaSõ!|Ü»ÿD–Îè¨ÚZ'W®ß“7Þo%WöÔÒµmy?í¡ÿajŒ÷>S,}¤C‹%aüXÒ¥ßY8µDuv’Î}çKå²yÔ:¤–f§ËÚ…Ýå¤'ÎØ¤q"¯ÏýëèµBß{Ï•BJ,¬U¥ îëÕë72vÊŸâ~Í÷Zã:ÅôûAáæ9’,_»_"«5€«k¨â3FTÓ\Zu)3Ç·‡ÒR½Î‘%…d*V=Åf«rö<®XGs’a}kkÖu[ý*Å e‘ë·î«5,U+ä“ ¥sùãm‹5\K‘#;ê5ÄøòæL+'ÏÞÿ{(¥Šæ:Õ é¶vþ}V,ߣ_'OWt¯&‘"9H–“¤hÁÌzŸÜóx"%ŠdScÈëU®©McÂÞqpˆ¨çWR¾\éäåË7r×ã±n§sërj¿§–µÈ5µß»´ñ]oìa¬ñ;ÞÛºë”Q{hè¸5’*E|½?Í9¶é1[²ªýjpìÖ¶‚dÎ4Ðýæ§\‰·ïxŠÏÛwR»jA©ôcnÍäòµ{2BÍ©¤š{õJßëjWÔ>±\k¼\¹ÃÖm:*žO¼ôx{ÆÖ}mm¦Jßæzc†s¯ÿˆåòê•·x½z#jÑÌ-YÙZW[ûÊZ»?ª{—Aß* WßêÊsÞ$@Ÿ…@Ë–-åŸþù,}±  Ž=J$@$@$@_9’E³Ëos¶H¯N•õLwí;/[–Õ¯›Õ+.­—Ò¯›vœªÚÇŠé,‡Ž¹KõŠù•(UQ_ƒpeDïÁKdàO5ôÃwˆ m{Α•sºH×WÎíªŠHG‘¥h…ÊH_·z~7-òÌ]²K‹]mš”Vî°Ø2et3ÓCïE¿ï•áýêèþ ¯»ö_ E¸<®i¤ßðåò‹¾Ò§I,§ÏÝ”e«÷ûß®}çtýÙ“Zka«^ëÉR _]nÍŸ‡eÑ´Z Á¯^÷9“Úèk¿e@,©Dˆ&=ŸI}Õ֦彵Ø3nÊÉ­Æ3†³/œU ƒF,_wPÚ6-£ÇŒT†x°ß¦I)YùÇ!åÊjâ§›(N‘¥‰r¾­Vc9 ®¾‘-¨ÜÊ(ñ¢Z5µ¾Å÷ÿdÂІ’(al£gú¼m²dF½vízΖ³j½²eJ®EÏdŠS‹†%´øV³ÙÉŸ;Ÿ¦b}éÊ]µ7ºè¹Cƒ€2¸WM͹\íQZ¸BÊKì¡ãZhásô¯ëµø‚9 „ØÇ`ˆ4˜5*ï•å\_)1Âê¯2X÷ ÁñÌ…[2UÝ/¹Ôž³'JË!KVí“~]«JJ%èXÆÙ ÿÊØA G-&õºTVÌö{¿ØÚ?æmáΖ9…7ëjÎM:LU‚XRi¨¡ [þ‘!½kj¡×ˆ´©‰µµ~ ÄÕÉ£šjÑëhÜ3¶îkk{4 õ0ú‡0š-sr}÷ ÖΜU@íØÚW¼à¯]{Ö‰eHàk%@áêk]Y΋H Ì(_¾¼TªT)ÌŒ‡! ¯@«V­¾ö)r~$@$@$@Š@ åD‚ãî)<¬Î¢vã=’ÓçoIåx¹£Ü2O”¨á*^œ˜JˆÉ—꡹áI–$®A®ßòhQ?¦(³¬!¡Y§éRÛ­€vÑX‹¼9ÓiÑ —ÔÞg9Ýø÷,\¾WŸÓõL¹r^*וy\?ºR’$Š-÷•{Æó±ïYC­Ù•7*ô IDATk(ŽK4]‚Ü'8ñV¹XàBÀéÑ v‘>ÂX·BÚdޏ „žëèKpÁ!ã¦ÜGmf-Ü!ók믞[ù|Ê9¶V9àrIÕòyÅ%–ÿqZ…eöfp¸Ö¦1oˆV¬qö,ÉM‚cÂø.ÚéeDÎl)õK8Ü\ÕkË3´bv!Zé~¸h—"‡sGí²ƒë޾ö½æêk^^o$–™S,ï‡}îq\¢Ëc³´}¶æêš=•i­âÆŽ!ßeõæúðQÈœóõ‘£‹nbÒó¯å…×Ç=Œ÷mí¸8tì²4oðƒ‰óJÅ{h3(QøûLúsû ÷L@÷µµ=Øz môÓ©Ï|%Ô¾”ZÊ—–eÖŽµ}eO»AáÁ²$Þ P¸ ï+Èñ“ „yI’$‘ܹs‡ùqr€$@$@$@$@$@$Ö;yU§ïCtoWQ¾Ï“ÞÏ‘p›rçàyµ‘&pÓ¶ã:mטõµpкû,ShQ}$ËðVî[a< 7®¿R)óŒhP³ˆ”-™S–¯Ù/ ÚL–yJÄqŽÙOSQ”HaHŸ‡èØ{žvOeL—DN©rÓço3‹âdñÈNMWõ?ópP‚†e \=åæ©\.¯ŸKp1E3;'#¤-t¿zGf/Ú©ÓÅÁãw¬ïõ{¹ÌbØ“§¾Bóó‡ðœVpva-·Ÿ*£~®«ç”7{ÚG:A? ü24ÖeºÎ¼¶Ü ±vRb—y@|²Üè éüಖm ¿Ài ýöû1u¥Qßï.3¨aéЃÃëƒndj ýYÛ?æ}YÞ{öÌÑÚX!ú 5˜€îkk{4°õ@ûp-žÖ^ö¾$ý‡¯Pî¸|RÅâ^ ¬kûÊžvƒºF,Oᙀߤ»áy&; ÀWEçW!~,E+L´ˆ:èä™râô ý4])’ÅÓ¢ÎùAJ½À"^œÚUƒôgˆ;ê«{ê\ TÉHlåj‚ Ç8Oꕶψ6¨Û®Y‰+ªvwÙŸ¼0¹Ÿvì=h5¸£ÞªÔH9‡¸à~[¹Jžú«W oY½á°>; 3– *XάÂûppA\8z⪿2`¾tõ>Óûp° f,Ø.óf”Ÿ:V’£Vú«.àYU9¯Ê«s¡N(giÒŒq™Wrvެ×Êž°Å ýyXáaO›¶ÊìÙ^_BŸ83 g™‡½¬mµŸ#k 9¨ÒWŽ8‚X¯€"(¬lµWíYœsfbkÖÓ»êÜ­‹—ÿÓÕŽŸ¾¦]u†³ÐhËÖþ1ï«P¾Œ¦”Ðv*'_žœ¾çÖÙ {×: ûÚÚµg=P&Ü‹ÝÔ™pû]ÔÃ4gX;Öö•­v?eÿ². „gt\…çÕãØI€H€H€H€H€H€H€Hà&€ó‘²dJ¦}Hx(§\Xû,]fèó—p>“=󕆎]¥ÄœwZô‚ gW!έD*å[bu¦OĈ¾ž•^ê\,ChÈ”>©¤Ná?m˜­¾Û¨³‹µ›¢…¯"2ËuÞT`1D L8ç*ª+TÆt¾iÍçMAÜjØvŠ'æ2c\Kå©3«zZ"p yûøH«F%ý•iÛ´´Jù·Nj©óéÓ&–ZU ȾÃeÞ¯mtêÀm»OkqËp¼¡Üüe»åàQwÅÏQ§ÅÒÛ×QT«JAi¤ÆU¶¤«4®SÌÔ_fÅeq¾QKu¦T@a‹[éâ9TÚÃ…ZDÖ·¶Ÿs‘ãj뺧¬° ØujUV¯UpXÛj?A¼Xòs÷jÒ¹ß|]äÝ»÷Ò§s1ÒýY«gÉ âYP‚ÒšGtšÈ(j/Å4KOhÞVMµÖÃǯ•Ì* çpÅÔ<Ò¦J¨ÏuúW÷J ’ƒÔùp–amÿXºËÚ7/#ƒÇ¬’úʱGaMu¯eH°;Ïr­š¿­ûzöÜþöh¬˜Q]¸9g-Ü©¸9Ê;%4vm[AwoÉ* uµ¶¯pv–µvƒº¶,O_ îîî{L­Ì6nܸêÿœ¨{}-ó oÀ¹sçÄÍÍMöíÛ'NNÖSG‡QË–-uš@ž·z¬C$@Á#'O9zôhð*³Ö7KàÚµkR£F 9|ø°ÄŒùñðo'N$â>|¨‘)æìíÀÉñDw¸eoq?åœSJ„È ƒU7¤*yzzJdýâJÔIRͲuÕ›Œ“YZë|®PÜîà©:3®Q»©²fA·/<’ð×=÷Uø[³ð8b|áà±W ;nü¾Ï³SJ•ØIjmLøÅ—Ž         kì8R*¬ ù³'¸gQ}¶†áޏ¯Âðâpha†…«0³ |Û˜ëïÛ^ΞH€H€H€H€H€H€H€H€HÀ‚Àʹ]ÉÄœ‰µvawò î«`@c•o’Wßä²sÒ$@$@$@$@$@$@$@$@$@$@$@$öP¸ {k‘ À7I€ÂÕ7¹ìœ4 „=®ÂÞšpD$@$@$@$@$@$@$@$@p¿zGšwžî§Ô[÷¥aÛßBŒÝ€‘+äàQwÝ^õ&ãÄóñ‹k;´9qlÙy2D›N›ÛO‘GžÏCdwïyZ]WOäÜÅC¤ 6ò©ûÁrìÁaÔ1³< „®ÂËJqœ$@$@$F Œ=ZV®\FGÇa‘ À·G B„_|Òc‡48±£‡ê8¶í>#ç/ÝÕ>B«ñð<öÐbÂvI€HÀ ‰(H€H€H€Â7oÞÈ‚ 䯿þ’{÷îIäÈ‘%cƌү_?™5k–lÙ²EøÕ«Wâää$ø£4qâÄòûï¿Kݺuu¹,Y²è2»wï–iÓ¦ÉôéÓ%f̘ú=ùá‡tù„ ê÷6oÞ,ƒ ’mÛ¶IÔ¨QMï­[·N¦L™"•+W–eË–‰³³s¨À:tèdÍšU¢GÝ?lCeðl”H€H€H€H Lx÷îô¸Xž=©~^Ií*¤r¹¼²}ÏÙºë”88Doï·âùä… ïW[âÇ©BýG,—^¯%¶KtuÝÇêÜvþ}V,ߣ¯%OWt¯&‘"9Hý6“¥vÕ‚2oé.©V1¿dÉT&ÎØ$ïß«¿¿ö–ýëHªäñý´9eÎÙµïœ`¼õª–ªåóÉ´y[åØÉkòæ·dH›Xúvu“§Ï¼¤e×™’#kJyúÔKîz<–-~”Üߥñ7Æ#'®Êê?Ë“g/%G–òSÇJúï†k7=dÔ¤õâýÆG#G’=ªK¢„.~ê_¹vW†_+Ï_¼’H޲à·vúúñÓ×eݦ£Š——¤J_†õ©¥Û‹%«ö©9¾×¿S‚UŒèÎÒJuæøVŠc4©Ór’-˜YnÝ~(÷<žH‰"Ù¤NµBºÝ©s·ÊÑãWäµb]®„«ÔU k7‘¥ª]U?KÆdþæˆq._»_";F’ÝûÏÉP5žˆªÿaã×Èí;žâóö^‹J?æöW×ÖúYãŽÊ¶˜,[³ONŸ»%÷>7µnÆœÌ;D_³ïÔc‹ŸsÏÇÏý= ƶÖÍr¿ÕqóeÊ  ðN€ÂUx_AŽŸH€Hà«"€?V;wî,ŽŽŽ2räHI:µúcÙ[Nž<)ñãÇ—èD¹rå´•$I« N:%“&M’ß~ûÍ$Z¡`¤H‘ÄÕÕUŽ?.?þø£®»ÿ~‰7®>|XŠ+¦ßÃõ|ùòé×}úô 5Ñ íϘ1C† Fáê«ÚÍœÌç&€‡E/^wwwñôôÔŸ›!PçÉ“GîÞ½«Ål[‘?~ùî»ïdÕªUZHΔ)“©(ÚtQž(sÐÂ]ëÉR _qvŠ,s”سtf'‰ÕI¿¶Œ´©I™âßIÂø±4{ĨIë$[æJ ¬«×¼I‡©’5cRAY#Z?kÜ!LÚbqsʘfº¯rµGê9AÀ4é'Nß$ §¶ÓbÞ_ÛORŽXÏߨblkÝ,÷›?H|ƒH€Â) Wátá8l  ¯“ÜT<%K–h áàà x ”¸yó¦ 8PÆŒ#‰}ü#Íh±=ª…+ü1zäÈiÚ´©ìÙ³Ç$\;vL~þùg]¥[·nÚ%J¹pá‚ :T?O•*•ÄŽÛ4´ÇËàÁƒÿ½råŠv…áA8„©ÿýW×»}û¶$H@·"E ™8q¢œ;wNºté¢çºhÑ"Y±b…,_¾\ׇSlĈº/ €uxЗæ“'O$gΜú¾Çgˆ———xxxH´hÑ$]ºt’2eJÝœpQV­ZUbĈ¡ß3>s,{¸víš­ –S´â$ k²+7Ѭ ­LÃÂWýG¬Ð¿?WÂÓ¬E;ä‘q"©gÂYeDÎì©´è„H’(¶vÔ þ9uMÚ7/£_ChÈeÅÍtòÌuåèy$í{ÍÕå¼¼ÞH¬¾b¢få¦×pጜ¸VʗʥĜ¼â+š„‡ÿ¹,åJæ4 ZFŠ¿•’ÓçoI”ÈŽrçÞcåœòÒÂUb厂h…H¬Æí¡œ>Ö¢h¯X¡¬rB9_®´r^ _Fý®¯½Uޤ(NŽ~ªCD‚Ñʨo(ü}&Ý"uÊr_‰2ˆ#Ê-µaëqí|ºrýžIIJW^×´ú-;q”àóXõuàÈ%íäjÕm¦iLwÕ|!æÏN‹Vˆb…²hWZ`qèØeiÞà] sûA jxÏ\¸ hý¬qùÔÙ&¸ÈŒ¾âlj¡çOý׈Sgojq{ Qæ‡ï´Ði+¬1†Ó. u3ßoñáu /(\…—•â8I€H€¾ ûöí“òåËÛ|€lJ…ú÷ï/iÓúþqhpR!  I%K–”9sæh! ¿! eÎìû‡˜oß¾•Ÿ~úIºví*E‹ÕÎŽÆkbþüù’-[6-‚A¨jÕª•­ÐfÏž=õûègûöíÚ96oÞ<éÔ©“lݺUƯ¶?þ\cõêÕ:máÿý§Ýf  Ûà„`ìææ¦f#~÷·‡ÆC'¤"5/oÙí[·dïÞ½Z´‚+“A$@$@Ÿ›À±“Wµ«ѽ]Eù>Oz»‡0~ÚFI“*¡tn]^×È' ÁæƒÑIÞ«ÿ™ŸåÑÿYQ(›/W:šÎZDöñÿ‹!Z,šÖA6m;.ÛO•Q?וŒé>fLðõWù ”…4f`}-hµî>ë㸕e¶N²2oÿ!¢r4½×n´éc[Ødh8¾¬pŽâ·oJîÞóÔi—Îì¨Å¤Ä³þ™+ÚjlõTÊ@à gÔݼã$þÁbj*¢…SÍVÞÊÍeÖæckýlqˆIå ¨?[©&mßãÀÖÍ|¿Ù}s° „q~ýÉa|° |í>|èÇ!…³®º¯páÂúÜ+{bôèÑÚ G…­È!ƒ~È}ÿþ}&°`Á‚ÚI”bpTýóÏ?Úµa<ä6ÚP…Ý­éÓ§÷ã»|ù²äÈ‘C_K–,™NUöâÅ í¾zýúµ­%J”<Ç8-©Ípv׆ t¤BDêD €m án H„ *?ˆÆ;wî”2eÊH¼xñ‚ZåI€H€H Dàü&¤ÝÃOPD+tŽs‡R«³˜¼ \3¶ÿ}l Ö5[*Ù±÷Œþç\Áe9²¦ƒÇÜ•hóX_‚s çAY ¯—¯µó©óÊ—Î¥Oæ‘ç»Ô²I¥ƒ{Úh ©ìR$‹§E+¤šC:Ä RðApÁæ“3G*íöŠ¥„+œeVæ2‘T¿g/ükO@}ã¼+œAÑ "ͳ¶ís¼dõ†Ã¦µG¤„“n´—*õ"b¿šµ[]g…òe” [Žë¢¯•sl§riåÉé÷ 0[ëg‹{P™˜3·.1¸¦OsæH­_[ŽÝ/{ÖÍÖ,C$@á‰Wáiµ8V  ¯žÒâ!­— 6üL˜0ÁôÇl`à`‚SiРA:å µÔ^È÷—*¤ëر£n\_”òæõÍÿo=òç~2 3¹‚©!ba>HQìÞ½{R­Z5SsÚ3O5ˆ‹˦OŸ®ÝX5jÔÐu4h Ï(` ø'NEKg"î!ã!ά JÊQܯˆÜ´$@$@$ 4ª]TŸ'´pÅ^í¼Êš)y Ó@š@¤ücó1-Æ)óÌ+&ˆK~î^M:÷›¯ß†#¦Oç*âªÒZÆüe»åàQ|ùËQà¦ÒÛ¯K«T±rN·U§Å$‰ Ü]ÕÕYMåJå”Î}H‹.3$f g)Q$[ ã¶,WÒï=ñZ©öp|Á!…³¨ ¸¹UÈ'uÜ ù©>¼_mÍ ç…!©ó™lE¦ôI$™:»ªaÛßΟ Š‹8ëê UŠú÷~-ÖÍ×R§o¬_ýҸ݉+ªdWçVáºe”.žCºö_¨Ò ^“a}kë4ƒÇ¬’úm&Kõ¿šêÌ© iýž lkýâ&–{Ž¿VÝfi¡ {ªw7]Ärì-²=ëäM $@$† DPßœ¶æJtÈHb+~ •Y€H€Âœ¯ƒôJx`§GHEË–-%wîÜ:]ƒì!ðûï¿ËŸþ©Eó%® @!-ŸHÝ5kÖ,íH2¢nݺү_?É’%‹Lš4Inܸ!cÇŽµÚ5ÎÂySHÛ÷Çhan«É“'ká gdÁQ…€ã g\]¿~] bK—.5µÙ«W/-TU¯^]§¬W¯žv[!EY›6mtºB8µÐÞâÅ‹­Ž¥bÅŠ2sæLçqÁÖ£G©R¥ŠN¡È {g¸ÙSök(ƒ³ààÈÄyUæâÕÓ§Oõ·¬!Nã+ÓFÀ͈ԞµjÕRƒbùÁ°jÕ*ív„{òСCú,¼¤I“~ ¨œÎò‚X~øða-º3H€H ¤ ÀYïããäfßIt‡[A®‡ Î)%Bä/ûü»²@²~qå~ú˜¾6Xb%   @ à˽bÈØqã-Ú|žRßð°îJ¨o~u9´W†í“ @@ÀñòòÒçSáŒ( WŸƒ2û   ; àáóŒ3´ó¯Y³fò¿ÿýO;pFD¥ V6L§Ý3„ óú†˜dîÂ€Ë çäÀiaîø2êÁm=jÔ(íʪT©’§*T¨`jÜ[8 çX!Íá©S§´06fÌ-”Á½QªT)í°2s¬oòÄŸ‰'ÖÎL#í ×H€H€H€H€H€H€B“ϸ Mºl›H€H€‚AâWÅÆýÁ™Væ·ÒæÍ›m6µeË× àソÿþÛôÒZKù·ÇСCiÆV ©p¶nݪÝWS¦LÑ) 'Nœhu,ºðcR%2H€ì'gÔúõëõý‡µHëíì쬜pKAnàÞ\³fNG%ƒH€H€H€H€H€H€B“…«Ð¤Ë¶I€H€Hà"€´„py%Lè{Ž‚ƒƒƒÜ½{×ß¹UßN•>S•+W–“'OʱcÇtJO¤…{3Nœ8ŸtFîëÒ¥KËÚµkõý wƒH€H€H€H€H€H€B‹…«Ð"ËvI€H€Hà#)S&íšjР°â/sæÌÒ¥K—oŒ§K_†Dª|ùòéŸÀe[¶liµXµjÕü½ïââ"7¬Y^'      O&@áꓲ   ƒ„ÛzNJ$@$@$@$@$@$@$@$@$ˆàu            ø(\}ÊìƒH€H€H€H€H€H€H€H D ¸_½#Í;OÑ6Ãsc»÷Ÿ òðïÞó”†m r=T¨Þdœx>~¬ºUòxðDÎ]ü×TdäÄu²eçÉ`÷Ó¸ýyäù<Àú¼ £]¬>ö8oª÷)íØÛypÖmŸ8}]?±½ÌË?}æ%UübïMå‚2Öàö”A}ê^²§/ó9oßsF†_cO5«eBs¼æ÷þ§Ž3Ø çƒzOÓ-W{¤øø¼ ç³ýáS¸ }ÆìH€H€H€H€H€H€H€H€B€··L»5ÔÚÿœ oÛ}FÎ_ºb]ŽÒ@âÄŽ`{ùs§“VJ¹ÏËWïÊÆ­ÇMõ‚ÛŽ½Ê:/X±Gž<õ²·+ jyˆ?e¬v2ŒüçÆ–à³çSï‘Ï:ØpØϸ ‡‹Æ!“ X'põ†‡Œ˜°V^¿ö–ˆ#Hö•$k¦dòþý{6nâ9’ ìQ]%tñÓÖ•kweøøµòüÅ+‰äè ~k'c&ÿ!éR'”ÕŽèö†ô®©…¨£Ç¯Èk%J•Sã©[½°nÇÚXf-Ú)Q:öž+…òg”ZU ÊοÏÊ‚å{täIãÊ€îÕ$R$Y»ñˆ,]µO\\¢I–ŒÉüA· uGÉ–•}%B„2`ä ÍfôÀzºì5‡ËêùÝôëeköÉés·äþçâV>ŸÔ©VH¿o­o¯—¯¥e×™’#kJyªðïÐâGÉý]ÓÀfùÚýÙ1’À¹2´O-}í¸r ­ÛtT<ŸxIªñe˜zc»vÓ#PÞ­TŸ3Ç·‡6ûßsà‚<æ.}»TÕë2lÜj5§gj½}ôóæL+Ïž¿”!¿¬Ö{Ç[¹%°F“gmw5fpo\§˜v ™ÚQåá¶¹}ÇS|Þ¾“ÚU J¥së}Ìùä™ë2qÆ&µoE^©5Ñ¿ŽlÚvÂÏ:רô½ô¸XíÙóWR[íçÊåò 4§ÏßÔeïÝ"Íëÿ GÔ^ªö?øõé\EíÁ5råú=yãýVreO-]Û–7­ÃÖ]§ü”ïмŒ¼S®î«kêþzòô…üÔ±²^»wïÞYƒµ=it`mnqÔ~´ÕÇs,›vœªæRUÒ¥I$ëÿ:&¦o”í«ûé½ñÓ ÅRUíÉïó¤÷³Çmí%ìÙÙ‹wJDU7¶ËÏêÞŸ¥«÷ɸ! upÊ4Që\£ò÷r]íƒ#VÈ‚)íLí[Î9^œ˜òðÑ3é6`¡ÚÏ«÷2fP}‰7¦Í}fyC4Þ%ê>Æçæ;N ´1¢;K–“¤hÁÌrëöC¹çñDJÉfº/»÷Ñ·­ýo¹§¦Œi&Gýàç&˜Zcý6“õý0oé.©V1¿T,“Ûê=gÎÂòþýs=½îSçn‘}‡/颮é϶ IDATÙRJ—ÖåÕ½Ñ&‡Àî?[Ÿ»˜ÿÌÛ5ïl™“ë}e~Oá³ÃÖg.îEüÿB'GI2¾g p8#–        'z^"ª!™3$•ÿ{(m{Α•sºèùOŸ½”ñC œUŽ•Î­ÊÉš ‡õƒÈ6MJëâše4«W\Z7.¥ßÆÃr<ŽÓY)±£ºzðÚ£}E} iéF ¨+ ¸è‡˜x>Rýn½‡.“~]«jÇxèŒënùGfMh)Qd×¾sÚ3{Rký³^ëÉR _I"XK›&¥då‡dÒˆ&º«êa9D«ãZˆ£ÂO¡|eŽz8¿tf'‰ÕI¿¶ 'õp5•ê©Ó¥N$—¯Ý“wJxAj«[Šg’D±õ±]¢ `CXÁC}<ЬÆm­ïRŲË÷ÿäõà>Y’¸ZTÀˆ9‘VõW¦øw’0~,ý@ÛˆJDš<ª©~@ß®çl-"æqM#ƒF¯ ”·ùüëe! –,šC?ðèùLê+ö›–÷–ßfo‘L铘<¬]“ºÅdõŸ‡MkŒ‡ÛFü6g‹ÚW)”àTWóiÒaªd͘TâÇ‹(´±|ÝAiÛ´Œž'Øãa¼å:£ÜH%haÑGͦãµp…X£ÆµhZ-Z"–­Ù¯÷]Êäñõ}qõº‡Ì™ÔF_³Üó¥ŠåЂƒQû¯+‘pìàú’>Mb9sá–ŒŸ²A¯]Ĉ­ŽÁÚX 6ÖæaÂV¶X~Ÿ;½þç²0p¿Aô=«ÒLfË”\N»©Åd˰¶—R$‹+§o’…SÛiñç¯í'iú œö¶Lß§¸’&Ž£Uìó#ÿ\‘y3øiÞrÎØH{ ¡÷ÌÜ%»dåúƒú³ÆÖ>³g¼Ø…•@]¼pV]b˶ݧµP‡µÄ8ñy…}S¶Ö=^Ì;°{? ýo¹§ìùÜ´5ÆWê /ÿ'+çvÕãÿå·?¬Þsæ,¬Ý;öž•KWî*ñ¿­Þ‡CÇ®Ö÷#æk‹Ú´õ9`ësŸ•Õg9DJ—XÑLŸÛæ÷ˆ­Ï\|Žõ¾\îáÞ9­öå²ÕûýíK¾áŸ…+î         ¯‚Äž—ê>D+ˆ ×oyˆƒz°ùÂë•~®¢Ê…(ü}&éÔg¾µj)@*õ`ß2 >K¢Dv”;÷Ëõ Â%‹f×Åñpÿ¼f(â­{ð {óÀø 0@´B@ˆ1¢rÙ<&A.8-Zu›ijë®ê•µ±$_qÂ8Znßy$í{ÍÕoyy½‘X1¢ª‡ù7éìð Q¬P-’Y†¯›àªvñdT3ˆ&GO^Õn£y>>¬‡³yÆC‹V¶úF¹ÄÊ}†5ѯ•桜ZöÖÈ`ÇÂ}u–=¼-Û¶§ÿGÜõƒíëèêp©ÁñwÅŒñ-LMš¯µ9:vYš7øÁÄç%2འerÙŶ‘×JùR¹” ‘W?0· ŒkÖ¢òH9½"98(GÚÇ3¬à¼3D+Ëz‘>±kÿRÇ­v”¨ƒïìCc탭6mÍÍV¶XbŸÂ%åV1ŸÚï¥iÝâ²ëïsú>Â>‹âÙ߬í%œý1¢¢Ìßi±7²rM¦QbØ¥+w´0†u€Kè¥^ ˜Õýà0 ˆ]ÞœéL÷5öîÞg¢ÙÚgÑ?ŒÁhÓÚxq ûqƒJS w"œa†ˆ…ky]}×{7Ž—q_Ú{ïÛÚ3ßSö|n4Æš• ˜°ÙÃÂÚýwðè%%tçТ¢|©œ2_ ö®lqˆ¬\®¶>l}îÂ-š/W:Ó=híÞ·õ¹gœhÜ;Ù³¤Pkâÿ^ìþû¯S¸úWs&       p@à˜KàÊAtoWÑ_Ú/Ë) …›­pÍžJ»]ZwŸ%±cE•!½jê¢HÛµxZ{Ù¯ÒMõ¾B9}òI•®\ß´í¸~Hö£¸a987l…y[(WO=7Ü3F€ÆbÞ.ºÁƒV#ÕžqmóŽ“PËLE#ªùX 8Yf.Ü®D:Oùá¾®H{Ö¨NQSKaã¶Õ7„&'%ü™ÇÇ‘¼£ø­‡9ØÃÛ²U{úÇ«½Å<Ð_PÂr/š¯½=ã€ÇÖ¼qû©2ê纒1]?C?m£V:«ôh¸nL{S¥­´xДupÍVi&ÿØ|LÏ9 °\kc탭ö¬Í b‚­>l±ÌªœUîʹá‚*>ó–íV÷y )há†2Æbm/YsYå¿WBï±WåïCeªJ×yQ‰šø¬€¸iÐr³Ø»ÆG€­}fÙ–µñÞU÷%Ò.ÙQ‹mïÌ®Ió0îK{îý€ÆÍlOö¹Ø‘VÕ{XX»ÿð倀>P¬q@y[÷Ÿ­Ï]¤Î ,l}î=R©i#¨ÿ™‡ƒ°þÿN×c         U8Cé¤ðcyVµŽã)×Ü?g/ü«/Cx¹§ÎRJ•<þ›§ªÔvHß! oÓãôptSçüìS¨Í) R$‹§E+åôAª'kGL,%\!•˜pX™ÊDRíãó÷àõCa¤ [­R¾zýF¿ƒsgö+ ±àü-£|ެ)ôYKpi!ÐÚÀ·ýác±ßb®ÆX3gH¢Ò¶Ý—gàÐJ¯E°“goè´Y>¸Ùl-¼­¾íÝ(X?;±ìámoŸæå°Ïp®‘åæÉ™F6l9nzL#ë=a-–Ñ(‡ßNålCöö%X ý[ùÒ¹ÔZ\×UÍ×g‹¥VgV!þ>xÁ´þÖúpŽ‚±úrÅÙ]ØOp@°:ª„Ë0/ИƒùXÍÛ°57[ýØb‰{é,‘² +ÜJ1c8ËÖ§U?¿g[4‡ÜJ$Ä}q0gŽÔú5öÃ嶂Б.Eœ÷ú· [s¶,gkŸÙ³?pÖΨÃx ºí1û̱UßÞ{ßÞqö¹”1ÚÓ§µû¯ ºÇ6ªsߌ3£6m?©¥Á [Ÿ»ÙU*Y¸þŒýa|n›ß#¶>÷àl|«Ò½âìEÄ÷ÛÊ1øç[pçð5Õ£ãêkZMÎ…H€H€H€H€H€H€H€¾!§”˜R¥Á/¦ÖPŸ74tì*õ`þ~° ×R~áá&¾µ_­ñ8ý{J%Fum[A?Hœµp§DQΜå„÷Ì£œJ?Õ¹ÏiÑe†~(޳l\1pBàÌ<Üt«O§b3áýjëósÞxûºÃ©su,i¿ð ³aÛ)* V=ãZJ@c©U¥ 4RåË–t•ÆuŠÉÏÝ«Iç~óuÓ˜wŸÎU´XW¿úÿ¤q»)K¹Î²«3˜Ð¾eÀ‘“6uB^¬pÄ@à3ÒrÙb ^,«}§Iå+¥U꯮ýªT‰×dXßÚ·‡w`ýY^oÛ´´^ŸZÍ&èKéUªDì¡vêýÁ¿¬’¿”k ûäçžÕ%sú¤Š£>¿ªeÃ~šjß¼Œ ³Jê·™¬5Uú² i“˜~6®ùÊ9tð¨»n®›!½ké*æëܨvQ=Ö…+öjçH¶¢f•zofVçluTcë9h‰N3çíã#­•ôWͼ|¯Ž•þÏÞ}€GY¥m¨¡$¡‡ÐKè½@:ˆ€t¤+E@º"mDXD]ª®(XÀ@\DEšt)Òz¡Â7Ïá›1•ô0“ùŸ½r‘̼ï)¿7ì.¹óœk¿›CÔïIk'±­-¶Ab³Ôë5 šÿåZ[õSúåe®åœ²¨ÕióÖïY­ZøÚj ôæ¨æíGÏcÔ÷Ñw•+±¸çe`ïèfQŸO!ï¼±Û÷Y\ßú¾žµVÐrŽÕ ƒ?5Z|B}­ ŒÏßýøÎK«P÷ß› ™c|ÆŒéïŸþwñ±Ò}À,qqÉhÙ"¶ tnóh›ÀÄ´Øþ{×3.Ò¯¹ xu¾h•jË÷Ä[£;JÄ¿#S,ÿ]Ûçju¯žs•Íë©¥K>Úr“öxtÇOXëÿ÷—'OË>™ä^|ƒ!€@Ú8|ø°tèÐA6oÞlù»G{}'G0`€T¯^]˜ÝÑ €@<jÔ¨!»víŠÇ•\‚À?§N’Î;ËŽ;ÄÝÝ@ Ù®\¹b RÂܯK¦pqÍðw‚ïÓ2d-"é2çOÔ½ÉuSpp°øúúÊÊţ͹8O²é™5e,ACëæUÍvvº=Ú¡£gåWÛ?Éi1¶¬ÝxÐT¨ñ½`ƒ) €@’4йå&ÓgÌLr_Ií ,Ô²Õbø£ó%ÒHž¢Åµ €@"‚‚‚d÷î݉¸“[@@@ÀÙw&’³Û$týzNgKÕ‹µbH·JËá–-¡Ýp}Ð-À´‚† `Wöñ˜¤aÑ € € PÝš–<ƒû4“øƒÙZKC+=#i܈¶ÉÓ9½8¬À[S¿1gyM³lóHC°‚+ûxÌÒ¨À¼yóÒèÊXVZøàƒLàºjÕ*qssKëËe} € €N PµR1sN ˆïXΟ¡!€Ø—@zûš³A@'-pôèQYºt©Ü¸qC4À¢!€ € € € ZW©%Í8 €8ˆÀŒ3l3ýñÇ9£ÍAžÓD@@@Ò‚ÁUZxЬ@dЭwïÞ©·éÓ§'Sïtƒ € € € €Àã®øA@#*1…TÇŽ“¹s碄 € € € €@Š \¥81 € àzž•žkS[²d‰:ÆB˜% € € € €€Ã \9ì£câ €$ŸÀÑ£GEÏ³Òæéé)=zôÚµkK«V­ÌkhQu•|Þô„ € € € ³Áß € nnn&¤ÒpÊßß_Š/..\‰'ʺuë¤ÿþRºti¤@@@@HQŒ)Ú;#€ €€Cxyy™ÊÚòæÍ+åË—7_j¨5pà@‡X“D@@@pl‚+Ç~~Ì@¨[·®è @@@@Ô`«ÀÔÔf,@b¸zõª|ñÅòý÷ßËÞ½{ãetâÄ ¹wï^¼®é¢[·nÉ—_~™àû9òØ{,X aaa±^ ááá¶÷çÍ›÷Øþ’ºÎ/@@@@ž¨WO”ŸÁ@ùᇤ{÷î’%KÃñþûï›3¦ªW¯.?ýô“ ¨ºuë&úüù矦êçŸ6gNé–~gΜ‘Ë—/ËðáÃÍýAAA²qãF ”W_}UF-M›6•+W®Hƒ ÄÏÏOòäÉc£× iþüù¦¯æÍ›Ë’%KÄÃÃC2eÊdúÕkOž<);v4aWÛ¶måèÑ£’5kV)Z´¨ìÛ·Ï̽R¥J‘§öÙ»wo9r¤Lš4I¾ùæIŸ>½Ù‚PÏÒ*V¬˜\ºtI8 ‡–mÛ¶É!CdÍš5âêê*eÊ”‘ 6˜ujæââb>/Uªß6 € € € €iT€Š«4ú`Y €€ãܽ{WÒ¥Kg›pöìÙMHtðàAàäÏŸß„S<0ÖéÓ§¥`Á‚Ò¬Y3Ù¾}» –48ºyó¦-¸òöö–7n˜¯K”(!Ï<óŒh••†_-[¶4_[ÛÅ‹M:¦ö¡ãµk×NΞ=+÷ïß7aV:uLȤã×®]Û„iÏ=÷œyMûêÙ³§lݺ5zµjÕdùòåÒ¤IF¹»»›÷u<ŸÞŸ#GÉ—/Ÿ\¸pÁŒ£ëÕ€ÍÓÓSΟ?o[§†]:7 @@@@ í \¥ÝgËÊ@D M›6¢[ìéVº_ÆŒ ¢µâêÚµkæs }´ÚJà ¹4ŒZµj• ‘4àÑðG/mû÷ï—àà`ÛêµÊÉÚ*W®lªµÖ®]kªŸ´é½Z¥•_Ú‡Þ«}{yy™÷#Þ¯ó°T:OãºuëdÙ²ef.[•*UäÿûŸ Ê40³¾ooõêÕ¶0êöíÛæV à4D 1WÖuªƒ¾wìØ1yªL@@pl€ý§$8äÑ/HÒ@ÔHwüøñ‡‰P¨dýÁZbîç@Àžt›²:ÈæÍ›Ívd4H= ½4”ÒŠ0­¬Šºå`êÍ„‘@À™N:%;w–;vØ*DÙƒµ#€@ò hUùãÎmD—LáâšáïDM(CÖ"’.sþDÝ›\7é/EùúúÊÊÅ£¥Xaäê–~@ |[¼)ï¾ÞEšÔ¯ £1$—ÀÔü$ä–›LŸ13¹ºLt?a¡ûEÂï$ø~θJ07 € €@r téÒ%9»£/@@@@`«@~xL@@@@H —ÌÔ<¤„+}"€@ÜWqq € € € € àT™ ®œêy³XìI€àÊžžsA@@@@@œX€àʉ>KG° ˜ƒºwíÚiB'Nœ{÷îÅk’Ë—/??? ìõüñ‡,]ºTV¬X!;vì0÷ >\¾ûî; ”y󿉮>xð ^cÇtÑíÛ·åÌ™3‰¾Ÿ@@@@œS€Jó¹³j@;0a‹‹‹”.]Ú„KúgÞ¼yÍë—/_6!ÓèÑ£¥iÓ¦&`êÞ½»Ü¹sÇ„^mÛ¶•­[·Ê¹sç$88Xºví*“'O–&MšÈ3ÏúÈ„aO=õT¬óÒ~öîÝ+ÿý·™¯Î_·¤!€ € €اÀìÿþ"Ï÷ûPúŸ-~ÿÛi&rSÆOY&u[¾-ï}¼RÎÛç䙤)tǘ˜éo~GýaXbúá@À>,:tÍ›7›mÚh € à\§N’Î;›óÿ´Ê”†$·€nó¬çš&´¹d × 'ô6s}†¬E$]æü‰º7¹nÒ_˜ÒÝV.-Å {$W·ôƒ)  WýF̵çUŠËg J‘é’S`êG~rËM¦Ï˜™œÝ&ª¯°Ðý"á~1:!Š«„hq- € € € € jU+)mZTueï¾Þ% ®š%!€€= \ÙãSaN €|ûí·Ÿð,@@@ 5Æo#®Ù³DrPŸfâ] wjN…±@À‰®œøá³t@¬¡¡¡2þ| 4/]¾|Y:dò÷÷—õë׆ € € †ÜݲÉà¾Í"­°@þœÒ³s½4¼j–†ö&ÑÞ&Ä|@@ õ4°š;w®ùhݺµäÊ•K°A@ Í ìøóÑq ÎÞ8ãÊÙ¿X? €Q&Nœh{EϽjذ!F € € € €¤ŠÁUª03 €Ž#P½zuiÕª•dÏž]ôÜ+ € € € €©%ÀV©%Í8 à”|ð?~Ü)×΢_ X±b±úÊñWÄ œI`îܹδ\ÖŠ € € €@š ¸J3’… €€= ;vÌLK+Xh €©#0þüÔˆQ@@@@ Ù®’”@ÈZ 8@T ¸J%h†A@@@ 8ã*Pé@@@@@@ áW 7ã@@@@@@ ¸JTºD@@@@@H¸ÁUÂ͸@@@@@@ ®R•.@@@@@@.@p•p3î@@@@@@H‚«@¥K@@@@@@„ \%ÜŒ;@@@@@yó«ò¿]'RTâ·}gbí¿Å[ˤí¤ï¤Ã”åæcâ׿§È\b›CÐÕÒzâ·ÑÆÔ¹œ<’¬sÑq.]»eú¬ýÚBóg؃ðÇOÖé R] cªÈ€ € € € € €q œ¾!_­;(+‰õÚÃ[J¾Ùâì+±Äg‰í;©÷eÌ^¾xµUR»á~°3‚+;{ L@@@@OàÓ$äÆ ½}O„‡ËÕÐ;2©çÓâ•ÇMv ”/;(yܲÊ{aréú-þ\ ©R<¿¬ 8)þ-S^h`lé£Û4?ùa|GywÙ9vîª ó³4±„Wí}KÇ 3õÛ­âm³Wã æZ­|>÷ñ»³Y‚°É–>oݽ/÷î‡ËÐÖÕä©2Þ¶ùåÊžEÂ,s´TR x¦ŠT/陨9Dœ¤®}Îê=þPn[ÖþÆóu¤R1XǬ[® ¹}í¾Ó2ç{$Gv)–?§„?|ãÚ[Y*±¶O1Îþ¦¯Ø!g.^“wîY® ’:–u4°™dÍÌÈãü¦âRY€¿•© Îp € € € € 64™;´…¤K—N¾ýýˆ,´„Uÿêìk»ÿÔEY3©« JN_‘Á³ÿ·:Ç ‘ÅrÝVÕdêwÛä“—›ÇzÝ ÿ¬‘L–Ê#mí|}¤S½22æ¿ëlÁÕÛŽI‡:>æ}ÝÚpLǧÄÇ;·\¶„gÿýƒüònWóÞž,óë"®Y2›ùüì7Yþz‡8çpîJ¨Ù¦0Ô´ IDATb;}ášíË*ÅòËg–ª05ÙòÇ9ùì—}òÑ€f±Ž©Á•†w°-ÛÎT“8}Q¾Þp(Îoš˜Ö ýÑÒôñò'?É VU ­â垌ÁÕ“qgT@bxhù ²o¾ùF¾ÿþ{ ’,Y²HíÚµeÒ¤Iæÿä§å¶víZiÒ¤IZ^"kC@@4.P³TÛ¿Ý æu³5gm+.[(-()j© ʘ>½è6|Im³?m«À,™2Ê_–ÒÞyä§Ý'e阶– «&œú`ùvÛÙ³d2–¶r–ùih¥­`^w¹|3^SÓê. ¸"¶ˆAÖKh¤áYË¿i¯[ªÑBnÞµ]Û˜‡,!S…"ùlëªXÔC±õ—!}:SeöÀr&–þÛúÆû¢Á ìS€¿öù\˜ €€“ LŸ>]ä­·Þ’2eÊÈÍ›7åäÉ“i>´ÒuÎ;—àÊI¿ïY6 € €iEÀ%s†HK‰¸»Ý}Kh±=°lgùÇžè"6ÝN/©­cÒ²|Ë1iX±°”/œWr[¶(¼{?LÒ[Æ›3¤…¤·9Û_—®‹K 9Z95æóu²tl[Bí;uAfü°Ó6tlcê/uFýÝM Ÿâj±õW¶P^ÑPñÅ™?Z¶nt•NuK›P†ö)@peŸÏ…Y!€8¡À¹sçdÅŠ¦ÚÊÓÓÓäÈ‘CªV­jÓX¼x±yÿîÝ»R¹re7nœ¸»»ËáÇeΜ9R°`A9}ú´\¸pA $Û·o—S§N™¬wïÞòÌ3Ϙ¾Ú´i#íÛ·—½{÷Ê•+WLU×!C,ÿxy´½D\ã.\Øô,mÛ¶•.]º˜ûnß¾-ï¿ÿ¾ìÚµK2gÎ,¯¾úªÔ­[×¼÷Ê+¯ˆ¯¯¯lÙbÙOýÖ-ɘ1£Lœ8ÑÌôèÑröìYéÙ³§”-[VÆŽ+ï½÷žìرCîß¿/E‹•?þØÜCCÀžvïÞmOÓqª¹T¯^Ý©ÖËb@@Àñœº$CnŠGÎì²÷äs–§åóó9nH e»=kÛ~ôœíól.™Ì¹Y mÏÖ(n¶ä»vë®%¤)cnw±TaU(šO–m:,Ý”7¯iß9]³<¶ûÄÎÁôóŽ%Ë yݳš1ÖœŠ×R4l›øõe³e`.ËüŽXªÇÎ[ì’Ò6ú[¾×N2eŒ.&¥OîE”à§?)ãJ¯ €$X@C ©¬¡UÔ~ûí7Ñíô.\(Ù³g—yóæÉÔ©SeÊ”)æR „¾üòK)W®œ ¬4ˆš2ÁÐðáÃå矖-ZH|ÆyñÅeÔ¨Q&ëСƒ4oÞ\råÊ%³fÍ2ÛúùùI`` ¼ôÒKòõ×_›÷´mܸQ>üðCÉ”)“ü÷¿ÿ5kÑÐjüøñ2xð`Y´h‘¹nÆ &PÓ OçyæÌB«GqCLªjUcµjÕ¤FIF8p ”/ÿèý´Ô8tè Èi € €€# T)î!ï[¶é»tí–¥úéLëÓÈT>U-ž_ŠzäÁ–³ªòX¯Ü®’ñÿϬ*œÏÝœGÕýý•ò\­¶À)âº_úø’áÿ¯¯lÙRïíîõDÃ&­0Ú~4P¦¾ØÐvù”˜³£Vl=.á–ª&÷ƒ~ËŸ9ÄÖA1Ë–ˆO•ö’nÓVJŽì™¥Q¥"–- ¯ÄùØ´BlT‡Ú2`ÖjqÏæ",&e &¾BJ+¸rdw‘ö“—›Ü–0¬O³JRÛ27ØŸÁ•ý=f„ à¤&yxxĺúÕ«WK÷îÝÅÍÍÍ\Ó§Oiܸ±Ü¹óè·ï *dB+m¥K—6OÓ¦MÍ×Ö0,44Ôv¿†TÚ4Dj×®¬_¿ÞWqãíí-ÖJ дÊK«¥4œZ³f,[¶Ìlm¨×U¨PÁüp¹Y³Gï¶lÙÒŒ§Mçj ª¢.Z×rüøqSqõÔSOI‘"Eœô»‚e'UÀTieTÄê¨$Kp¥óÓ­=i©+`­òLÝQ @ˆ.0©W}Û‹CZU‹tooÑkÓ3•Þï=$Ò?Mèñt¤{µ|Ô—¾Ó=Ö‹zçÑî1µ‰QúÔk är•O^níòZ>^¢Ö¦ÁÙæ÷{Å9 ”üßî­¿ˆg^½Õ­^¤÷»ÖôïÖǩ﷨^Ü|ÄÔ¶OÑö²õóÇõ7Óo§4­\Ôü•Ýrðô…kò’%[;¹[¬~¼ON€àêÉÙ32 €‘\]]M•QlíâÅ‹’7o^ÛÛ...’3gN¹|ù²yM·Ü³6 ­²eËfù­»¶@Ð×>>òÁ@ƒ € €N$0ô¹êò¡¥êÊ-kf±ì(a–óŦöæ—#è[€¥:˜À£Øi € ðÄt{¿Zµj™³£Ž=jª˜t@=ßI›žO¥çEévÚ4 ª[·®9S*¡Mƒ¢%K–H¸åÿ¬kº½Ÿµ¢))ãè½ÿùÏDC5mˆiÕU\M/ ì4€³Þl>×-uKBkŸqõÅû¨ÀÈ‘#ECª¨M¿çÌ™#;wîŒTø85=¯múôé¶ïO„@@H¨€ncÓ} í‡ë'P¡H>Y0¼¥ÌìßT>ÐÔ<‹ÒÞ‰?3+q³à.ˆ¯Wñ•â:@RA`òäÉ2wî\óCw­¦ÒPªM›62bÄ\iÔ­Û£=¸+Uª$¯¿þz¢f¥Ûê9R]»v•›7o𳧬çP%eœaÆÉÇ,íÛ·—û÷ïKLÕTÖ¬Y;OÝÖP×¥óÑʳçŸ^&MšdúÐ3±Ô hÑ¢‰Z+79§€n©•ž §o¬­C‡²iÓ&ùî»ïLe`Æ ÍYq5jÔ­Œ©ip¥Aï±cÇÌßOZÌ<÷ïß_¦N*ÅŠƒ @@@D \%Š›@H ª4¤Ò˜Ú‹/¾(úµ•+WÎT`Y›þÐ~ýúõ‘.ûùçŸm_k5W§N¤GIGož7ož­ ¨ÆŽk>¢¶?ü0ÒK臵 4HôÃÚ¾ÿþû”A¦W§пº-àË/¿,7nÜ0ë®Y³¦ôêÕ˜ǶqãFó1nÜ8³Õf½zõLU¿~ý+wïÞ-'N”·ß~;ɆúýýÍ7ßHŸ>}¤E‹–íJšï­4\¼x±dÌøäþoº†Æ4sÈŸ?¿9·N+Bi € € €¤†À“ûqj¬Ž1@@ VýA9 ´.P¦LSyÕ³gO³Tk€¥ÛS>÷ÜsæC·¡ÔPJC¬>úÈ„SuêÔ1•XfEÜrpÕªUæ¼5­äJjÓ`M·,ÔàJ·øwòìÙ³2kÖ,s®n!ªáÛ?þ(xiÈÖ»wosžnÍ©Û}6jÔHúöíkîÑ>]]]MV¡BsvÞ‘#GL(¦áø„ ä÷ß—µk×Ú®S§ˆM×®6ï½÷žlÙ²ÅTO2DÚ¶mkîÕªªO?ýTöíÛgÂ- Ûu=Öæïïoæ«U¤?ýôS¤±ô¬ºˆ}E µ“êÊý € € €Ž-ÀWŽýü˜= € €@ʼõÖ[±^•9sfñõõ5•‚¶,X°À„Sz¦œn¡uKNÝþråÊ•Iv×1Nœ8aÂ4 ‡ªV­jëS·&Ô€göìÙ&ˆÒ€H«(«U«fÂ$ÝRS_× J«ÊZµjeîmݺµ ³´BJ×¢[jÐ4fÌs&ž†`Zqéçç'ï¼óŽ©0ûꫯ¢­E·SÔJ4Ýþ¯I“&æ} øþúë/Sõ믿ÊÉ“'M€åéé©­ÖúöÛo͘ZÙÓXûJ2$ € € €¤)‚«4õ8Y  €ñÐdë¼i8‹€ž“¶hÑ¢HÛSƶö²eËÊ€L£áVEmúhSRšnŧÛ|È®]»Ì6†Ö¦çjiXõÚk¯‰žIwõêU³µçü!o¼ñ†ɳÏ>+z>œ^³gÏ€ýðÃ2eÊs×;wLE•VékçÎ3•YÚ·6}M·µV¡E\ËÓO?mB©Â… ËŠ+loiÅ•úèøzkº†ˆçˆéˆfé9zËÚWR ¹@@@ í °U`Ú{¦¬@@ ¨ÛáÅéÔ©S¦b(¦6zôèøtë5º5 †UôäÍ›×lýgmi0¤[꺵¡†EÓ§O7ÓºuëLu•Vƒéö«W¯777³å¡V\é™wvi;~ü¸Ù6°dÉ’&ÈÒ 3 Ãtþ0Y¯‹i¢:Ç»wïÚÞÒû´éÖ{÷î5ÁÔáÇM€em:g­Ó­zõ걎eí+IˆÜŒ € € 樸Js”!€ €$‡€VTÅNé™W]»v•·ß~;ÉÃÔ¨QC®\¹b¶*ŒØ´ºêòåËÒ¯_?y÷ÝwåÖ­[fëBýZƒ¬ ȶmÛLe˜n!¨çdé¼ræÌ)#GŽ4á”¶°°0Šýûßÿ6÷Ž5J¼½½¥S§N2iÒ$éß¿¿è¶€QÛo¿ýf*¹´jëùçŸö¾u<ÝPÏᲞ!¦jˆ¥Û ._¾\ÂÃÃã+Ɉt€ € € ¦¨¸JS“Å € €$—€¿¿¿„††š@HÏ–ÒIÿtwwOò;v´õ¡ÕIÖ¶lÙ2ó©†?,ElãÇôµnÙ§TÄöÉ'ŸDúZC- Ÿt›ÄK—.É«¯¾j¾Öñ#Î!âMo¾ùfŒë³ÎMßÔmGŒíºˆ×hЦ­D‰ÑÆŠx]’1é@@@ M \¥©ÇÉb@@’K@«“´¢)9‚ªäšSBûÑÐmÕªU&dÊ”)“¼ð ½ž„®Ÿë@@@O€àÊñž3F@H%G­”H+·ô\, € € €Ž"ÀWŽò¤˜' € € € € € Æ®Òøfy € € € € € €€£°U £<)æ‰ € € €8±@ŸtâÕ³t@g¨Y·‚³,5Öu\9ý· € € € €€} Øô¾}OÙ!–ý°E*”),åËLƒ«cI `ÏWöüt˜ € €€S ìß¿_~ùå —N:ɺuë¤E‹âåå%k×®•,Y²ˆ››[¤k *$Ÿ~ú©qóññ‘cǎɈ#Ìד'O–±cÇÊ‚ LŸaaaÒ·o_ÓÏĉ¥\¹rríÚ5©X±¢Ô­[WV®\)gΜ1×öë×O\]]mÏÃÏÏONœ8aîâ=ãÆ3ïë=Öû¢Þ` ά¯3&^}:;SD@@x‚WOŸ¡@@HI ¬-Z$… –œ9s¦äPô € € €@²\% # € € `ºÝ߈#ìobÌ@@ÀîÖnÿÙ¬îÄ©‹¶UÖ¨R\Êúx§±U³°Gôö8)æ„ € € ˜6mZ´–.]*ýõ—=zT6nÜíý°°0™>}zŠãê•+WÊ‘#G’µO:C@Hy ¥õië@ï¾Þ%å'Á €€E€àŠo@@Ò€@ºtéb]EéÒ¥¥~ýúi`•,@@ %zv®'òçŒ6„ZÞr§äÐôØØ*o@@ìT@+¢&Nœ(eË–•«W¯ÊSO=%5jÔ­®3fŒ™õÔ©SeܸqòðáCY¸p¡Ü¼ySòåË';w¶­êÀrîÜ9©S§ŽÌ™3G²eË&ÞÞÞòÜsÏÉ•+WäóÏ?7úúúJ½zõl÷={V¾ÿþ{ÑyT®\Yš6m*3gÎ///¹wïžhXÖ³gOÓ÷—_~)¹rå’àà`ñññ±SQ¦… € ð8w·l2vx[yå…¶Ë4ÈÒ@‹†¤–W©%Í8 € €$B@C£îÝ»Ë!CdÍš5±ö UûöíeðàÁréÒ%tEm&°:t¨¹VÛýû÷¥oß¾2räHùý÷ß#ÝR°`A1b„y/ À¼"­Zµ’^½zIPP µV­Z%ýúõ“—_~YræŒþº‰X6· € €´eΜÙ_×®]3cj+P @"VÉ- € €ö$ a•kö,Ò¨^9©U­¤=M¹ €€°U |Øô§[Fm:¦n5˜'OÑùh@FC@ 9ú ›-á–ÿÐ@ õŠÎ#WBBåÅaŸ¦Þ Œ„€Ó ¤“öÏÖ”v-k:µÁ•S?~ € `ïZÕ¤çRéÙQºEŸ¶š5kÊܹsMåS–,YÌkZ9¥çQ…††šªªÜ¹£ž}âÄ ñ÷÷7×khWÓ³ªfÍšeúŠ©?ëýzVÖüùóÍ6Z}EC@ ¹ví=) ŸñO·äîšþ@°u«Jàù`»™Ï“šÁÕ“’g\°+ëÖKzæÇã¶a²«I3@§È—/Ÿ9;*bkÔ¨‘èGĦg[Em]»vµ½T±bEó¹žY±½öÚk¶/Çé½.]ºDës̘1¶×¬}éYXQïMé‡3`À€dâÎ;¦¿˜¶LLÖè @ Ñ[”‘ U¼}?7"€ `ï‡÷ÙûSe~W©ÂÌ  `ïyóæ5‡ÞÓ@Hˆ@@@@B.OÔµ1mÍ—¨ŽÒØM¾¾¾É¾" ³gÏžìýÒ! € € €@ü®âoÅ• †ô·Ù‡ –†WÈÒ@RBàÓOSv¿ÿŒ3JÄŠ¨”Xƒ£öÉÿn;ê“cÞ € € €ÀãÒ„ € € € € € €€=PqeO9 €À8s挌?þ‰Ïƒ € €@üzõê¿ xÕüùó%K–, ¼‹Ë@@@’K€à*¹$éZàÖ­[²sçNéÛ·/‡²;ô“dò €@ê èÿvОŒ@•*U’uàùöÛo%,,,Yû¥3@@@„ \%Ì‹«@ X¾ïׯŸ¸¸¸¤ÑU²,@äÐê{lK—.•:uêHáÂ…=½ ÈK/½$ÉÑW¢'ñ˜˜¬Ýž:uÊWáááÉÚ/!€ € €$L€3®æÅÕ € €8„@ºté’4O ­¬-©}%i"ÜŒ € € àTT\9Õãf± € €Ž,pýúu™3gŽdË–M¼½½¥}ûö2mÚ43fŒYÖÔ©SeܸqæóÕ«W‹Vß»wO´:éäÉ“²jÕ*SYìëë+U«V•yóæÉýû÷¥X±bæcß¾}ráÂiÓ¦¬X±ÂÖoÔ¾t .”Œ3J¥J•¤Q£FŽÌÊÜ@@@ìH€àÊŽSA@@àq&°êÑ£GœP•+W–Úµk˺uëÌ9ŽçÏŸ—&MšHµjÕ̽›7o–²eËÚB§È•+Wäµ×^‹ÖwÔ¾>læàéé)S¦L‘†  UYq>.@@@ˆ‡[ƉK@@°2eʘ°H«¬öîÝûØ)yxx˜÷óçÏ/—.]’Ö­[Ë‘#GäÃ?”   SYU°`ÁH}øøøÄØgÔ¾´?­ÞÒ3¾ôL¨Û·oÛs@@@HT\¥‡È@@œG@«¦4h`¶¬R¥J¤…_¼xÑöµSºýŸ†Tùòå3[vïÞÝVË—/—òåËËÙ³g¥T©R¶{b«šŠÚ—ö×®];Ó/ @@@ä ¸JNMúB@@ Nœ8!þþþf„ *˜?kÖ¬)sçÎWWWÉ’%‹yM¨?ÿüSöìÙcθ4h¬_¿^t;À»wïJÓ¦M¥\¹ræ>½¦H‘"[µUL}•.]ÚvÆ•›››ôíÛ7WM× € € €Î$@påLO›µ"€ €8´@‰%dĈ‘ÖШQ#Û9UÖ7ºtémz•~DlÆ ‹ôuÅŠm_3Æ|S_yóæñ,,‡Æeò € €@._ •!=—Šw᜶»žm_Ašµ*›€^â¾ôæ»rêøe©PÕ;ÒÅÛ6”oî6¯ž ‘|n’)s)T4—¼:¾iÜ'áŠýg嫹Û$[véÑ¿–ø”ÍŸ„ÞwëŽÍ§¤Ÿ,Üa IDATVÝbææ#ÏËš•‡dÄëM×w!€€Ý \ÙÝ#aB € € € € `ïžn2cAçæîmIÐÙkÑ‚«§ž..ú¡mìàå2xT)R”L™2ÈÀ‘õ͵˾Ø%ׯߖ[7îIxøC¹|[nhÙâ9Lü–í•{wÈÉã—¤s¯êR²ŒGœšÚŸ[Ž,²}Ó)ÑŠ­÷fwÕ+ÊŽÍ§ÍØéÓ§—×Þn*î9²Ê›¯ø™PìÒ…P˵÷̼†ÿ«‘d̘A6ürÌT2eÈÞÌeĿ˞ËÑÃçåÖÍ»²wçYyÁ`ÜsN–~±ÓlW­­ÇKµ¥LOóùký¿•úÍ|dýš£òL›òfm¡×ïÈÝ;arýÚyð \|ë“ÃÎË ËëÙ]]dظF¦/í÷Û¯$Ü2ç»·ïËK#êY¹Ü2ÿÃMræäUù÷«¥v½bÆpÕwûå_“Ÿ5cþoÅÙ´öOËÒ‹«›‹¼4¼žäÉçjú[e ¼Üs¸XÆ}hÖܱG5©R³Pœ¦\€©+@p•ºÞŒ† € € € €i@@ƒ­v²6 Hšµ.+3ß]k ®ÖýtTš´,c¤/çl•‰3ž“¬Ù2ËößOÉg³6[–æv­ªzó½V&°ùÅÿ°¬úvŸôVO>SÚZ]z×HØêʤ™m$gîlæ¾æmÊÙæ´xÁvì´êðh›h x†Žid>ŸùU^}g &Ïjk®°°&Àò.œKvm=#=û×6!ÚÐ;òÉ´õ2ùã¶&ºt]Þ~m•LŸßÙ²•`fùët°<´„q3?{Þô¯¡ÚË5oü»¥ùú½72¡Õ°±Æ{ä*9þÇEñ)—_J—Ï/¦·6&ûvý-+–앱ïÜRñ£ÕFÖV¾²—­bÉ£€»ìÝu6IRõ—´…VÚ‘Îã@À9sÖß–0©rõ¶ùÓ±­Í£€›\±lŧí¹Î•dâhiÚ²¬ÔoZÊTCEm',ë*Z" ­´éÜ Í-']²moغÓ?ç¨ê5e+°u“×ÃU Ëáëì|Ë|­f¿­>*é3¤“›¡wMEV\M®: J˜ÐJ[†%dÎŒvÿùº¸O>Z=šë?k«_ÞGÔ ¸J]oFC@@ U¦M›&cÆŒ‰4ÖÑ£GåÂ… R¿~ýdÃÊ•+ÅÇÇGÊ”)g¿zmíÚµ%þÔ?Ä;ÎÉq € M[••µ«ÿš¾E¥Dé|’#gV±ìÑg𷦵Žq„Ì.‘L«[ú%¥YÃícç–Ó¢•_o¾×Ò\e´T.í1ÛZ›m‘Û£-ÿš?WÎü—¿þ)ã,•e#ßj&ÅJætiXØ?á›õ 3÷ÿß6ÐŲ.Ýv0bÓð,bÓJ®ˆMo¿~í¶Ì˜ô«L³„~¹òd—c‡/ÈWó¶ÅI1 Œ<ŸG_eŽ2¶e¢qöÉ ú‘ÿ[!õÇgD@@H @HHˆøùù%ð.‘Ò¥K'{h•ÐI´iÓ†Ð*¡h\ €%P¯QIsþÓ:˹NÍ,!–6 °Îœ¼bΪ²6 gâjY²f2!NRÚUK¥W~/wZÝ»f‚¬ø´Û·îYΈÊ"-ÛW_KÓ‚¢Ý¦gYiuÙ勡潋çCMEWñR‘®øŒñšÐkwEÃ<ëV‡›-[þYÛ#“˜«¯ªZΫڲá„Y§¶­OJ©²ѳ„·ë@ u¨¸J]oFC@pb­6:sæŒåÐípéÔ©“¬X±B†jÉž2eŠŒ;V,X`ÞÏ–-›ôîÝ[–.]j9S Ìr¦@¹xñ¢¼üòËâïï/Z=¥­mÛ¶6ÑÅ‹ËÕ«WÅÍÍÍöÚܹsM_:FÕªUåܹs(-Z´///Y»v­dÉ’ÅÜóË/¿ØæV¨P!™8q¢”+WN®]»&+V”ºuëÚúÕ~¾üòKÉ•+—›Š«Ë—/ËÂ… -?È(•*U’FÉêÕ«Í\õl‚.]ºÈúõë¥N:röìY 7n˜þ_}õUÑùëœîÝ»g®ïÙ³§·°t@°w iF¾ô­mšÏZ ª4X)_¹€Ü(#^olÞ×hô„æ²àãß-ÛÖ…›muû½¶]ª}:ó1âõ&¶íøâ#¶÷½ ç”JÕ¼Íbn»šu‹Ê)K@¦­€w)R<Œ²\4ó‘BErÙº©X­ üu*XÞöƒyZy6xtôy'v^܇©#@p•:ÎŒ‚ € àä(iX3lØ0Ù»w¯ìÙ³Gîܹ#wïÞ•Ó§O›j¨íÛ·›¨qãÆ&Ä9uê”QÓ-øjÔ¨!«VY«>~\jÕª%®®®‘B+ 4ÒþõóÙ³g›{5`Ò°K¡˜×4€Ú¶m›tèÐÁÌCï™>}º Î44Ò{‡n>ïÖ­› ½ÞÿýHÁ•Î¥_¿~âááaKƒ¸=zˆ§§§ â6l(‡–¾}ûš€+bÛ±c‡ 4ÈŒ±hÑ"ÓV’õïß߬MÇÓÀNC0 € `oy=ÜdÙÏýcÖàQ £½§g;M™Õ>Úë]z׈ôšž?e=ƒ*g®l2mNÇÇ.?ê9[QûÓÐlÒÌ61ö1éÃ~ J/èѯ¶íºQ– -¦6aús‘^®d ‹ô#¦¶È¿o¤—£Î­ß°z‘Þ6îQЧmà«‘··nѶ¼y]Áiä›M#ÝW¡ª·íëV+Š~DmzMÄët Ã…~½kË› ðdøWà“qgT@@4,°ÿ~S½¤ÕJÏ>û¬Yé¥K—Lˆ4þ|¹ÿ¾,XPªW¯.z­†;Íš53aÒÉ“'åĉrýúu¹yó¦¹7_¾|æOteÎüè@éˆMû×>µåÍ›W2eÊd>×HC«ˆMÃ1Ýj044ÔòÛ³YM¿AAAòù矛ˬýk˜¤M8 U`›†dÖ÷ xô›¿: ´´éõ·oß6USË–-3TÄ *uù÷¿ÿ-ÅŠ“çž{ôë~Xç NWQŸ4_#€ € €i[€à*m?_V‡ € ðt›<ýˆØ4LÒí÷´¢ÈÚt›<­6Ò€JÃ¥üùóKžJ\ƒ € àtëׯ—%K–ØÖíãã#nnn¶¯#~žœ{÷îÉ­[·dĈIéÆ!î ‘ 6HÛ¶mb¾L@@@ õ®Rßœ@@D`÷îݶ™Fü<±Ó¿té’,Z´H2gÎ,îîîÒ«W/ùùçŸåôéÓ²jÕ*yî¹çL×£^§YõêÕMÅÕ¡C‡¤I“&f„V þVâp"‡:ÑjY* € BpÅw € €±hðòå—_J¡B…"]¡aÖ€ì,ùòå3÷,XPcìCC£¯¿þZ¾ýö[9þ¼©pŠ­]¾|Ùô±ið¥UÚt<W«¨4,³¾¦ïëuZÝ¥Mß³¾ïáá!Úolóµ®A«¥4»pႈžÕ¥óÕ°Í:Žþi½.â5øÓàîÃ?”   [r à,éÒ¥s–¥²N@@À\ñ€ € €@ wîÜ‘/¾øBzôèa¶Ð‹Øºwïn¶ÝKhÓÀG«™´={V<==cìB·Ô«]»¶Ù®O·|\Ë“'é+bÓPJ·íÓ¦ÛùåÎ[âúýÛ·o›ª,mýõ—ä͛ׄ^1Í7êQÕG«¦ú÷ï/¯½öšT¨PÁôõ: Êt+Bmf©£Vnie @@@àŒ+¾@@ˆ  ž½4wî\É‘#‡¼÷Þ{&øÑ-þ´iؤÛì%¦uìØÑT%éYTZU®\9sæTÔV¾|yY²d‰KϹz\Ó€I+¤¦OŸn*¨^yå³íà|`ÎÒÒíùôϸšV‘-_¾\®_¿.ÞÞÞfÍ1ÍWÏÏŠÚ4dÓ3®öîÝkÞÒícjZ¶xñbY¶l™ 8`*¶š6m×ôx@9}âŠå—"€@@ í ܺõèýœ½\9ûwëG@° lÙ²EfÍše*ôü' €¬[îY/Ò-㪂ŠTïÓŠ¤ˆM#ëÙTÖ×õ(ýˆØ"†e+VŒô^§N"}]ºti3fL¤×"~Ûç}úô‰tOLó8æÍ›Û®ùå—coÄëÞzë-Ûuz¾ @ø |þÉæø^Êu €8®ÀÓŽ;õäš9ÁUrIÒ € €€Ý èYJz^UlíðáæbéøñãÒ«W/ó¡UQQ[µjÕ¤M›6v»Î¤L,®­“Ò7÷"€ T›ÞOjÜ XöéP¦°”/ù<ÕvÃå €@‚8ã*ÁdÜ€ €Qô‡þÖ­Á"~Žö 0oÞ<³Í_```´éœ>}Z^ýuy饗D·çóóó3ç4ÅZÍ™3G&L˜`KJ‘9D­ÐJ‘Aè@@Àa¾õÛ&‡Žþí0óe¢ v¨¸J;Ï’• €8€VD|óÍ7òý÷ßKPPùẞ-3iÒ$Ë~ÿ‰ßðÿÌ™3æœ'Pd‰Î$ ç5éYQÚ4¸òòò2Ÿ‹QT5nÜX¾ûî;Û{±ùÔ¨QÙèX+ € € €OD€àꉰ3( €‰ЭÌDψÑmÏnÞ¼)'OžLRh¥3Yµj•/^œà*q…»ìX`ÆŒj›á;w䫯¾2*TÏ?ÿ\Ê•+gÇ+`j € € €8—Á•s=oV‹ àÀçΓ+V˜j+OOO³’9rHÕªUm«Z¼x±y_«§*W®,ãÆwwwÑíû´º¤páÂrêÔ)SmÒ¶m[éÒ¥‹¬Y³Æô›={vùúë¯å7Þ;wŠ‹‹‹ìÛ·O6mÚ$³gÏ–%Jˆ†[·n•ôéÓKëÖ­Íöjúùãš^ÿÑGÉ7Ì\u[¶¬Y³š9é|µÊkáÂ…f«Á^xÁŸS·7=×Êßßß6­üQÞ|óMÉ•+—¼ÿþû¦Z‘† € € €ö%@pe_σ٠€ «ÀŽ;Lðc ­¢^øÛo¿ÉÚµkM¤!”žë3uêT™2eйtË–-òâ‹/ʨQ£L¥V‡¤yóæòÌ3Ϙ0ªV­ZÒ²eKs­WŸ}ö™Œ=ZÞ}÷]SÑ¥?èÏ”)“ÙZíÞ½{¦ŸåË—K§NbóÅ‹eâĉ2wî\)R¤ˆL›6MæÏŸ/Ç7÷lÛ¶MòåË'«W¯6}ÓHN Z#6­VÔ€´sçÎq® ™‡†°4@@@H‚«äq¤@R\àêÕ«âááë8þtïÞ]ÜÜÜÌ5}úô1g÷èÖhÚ¼½½¥zõêæs ¶ ,(gÏž5Õ'1µŠ+J³fÍloie–VseÈÁTLõíÛW>ùä“ÇWëÖ­“úõë›ÐJ[ûöíMÅ•5¸Ò׆*3òIRüÈÉX¿~½ÙV3bÓªÅE‹™jDÝj3¹š†³4@@@H~J”<Žô‚ €@Š ¸ººÊ•+WbG«›òæÍk{_·úË™3§\¾|Ù¼¦ŸGl@=xð Öþ¬a“^ á—ViEìCC4óqMß×J°Ý»w›Ë>|(ááá¶[4<#´Jño§àƒ>ˆqÝAAAÒ³gOsN\›6m’l³k×®$÷AÎ! U³l‡êÏšU:¾€n5KCœQ`GÀŸfÙµª•Œ¶üë¡·dåO»¥M‹êâî–ÍyX3¤¢ÁU*b3 €I¨R¥Š©pÒʫܹsGë*Ož<‘‚$ÝÎ/$$Ä„Yׯ_7ÛýÅÖ÷žÞ“%KsæUı/]ºd¶ù{\ÓpK·9rdŒ—Å5nR¼¸×¹4”Š*é9kÇŽ30Z‘•Á•s+³úÄèÖ©4°O£Gšó=«ê믿___³]à_|!uëÖ5¡S\M· <}úôc/kÑ¢…9ŸJϽÒPLûöÙg{Oƒ ¤_¿~ÒªU+Ñùë}çÏŸ—Â… Ç5%ÞG I ý !`oÖ-[ím^Ì@Ô§¹ ®ÏËøc@Žþdƒ©Q¥8¡ß& *éSeA@d˜²fÍ*o¿ývŒãFì?êX5kÖ”E‹ÅkNÎ÷DY1 € € `?ƒû6¿Õ»äÆÍ;‘&5Ȳu [4@ 5¨¸J eÆ@@@@@ì\ÀÝ-›¼ûz—H³,?§ôì{%–/‰é!€€ \9àCcÊ € € à¼Ý»w—5jÈ‘#Gì ¡^½zf^·nݲ«y1@H˜@“úDϳ²¶±ÃÛŠZ4@ µ®RKšq@@@@@°V]i€¥A HMθJMmÆB@@’(.]º$öÀí €€ã ô6Ûñ&ÍŒpp²>^ö@øûçà’é;œ@ÛgkH»–5nÞÉ9a‚«äÔ¤/@@@ ºµž¶]»v™?÷îÝ+/½ô’T«VMæÍ›g^[½zµÌš5KBCC¥U«Vâææi„˜kýüüäÆR²dI6l˜T¯^]žþy9yò¤,X°@ªT©bî›0a‚øûûË+¯¼"={öŒÔ—n÷W¿~}éܹ³¹®@2gÎ0`€˜Ï­s®U«–„‡‡ÛæuÙ!!!2mÚ4Ù¼y³d̘Q*W®,£F//¯q) ðH`×Þ“R÷©š’7OnH@H³¿oÝ.çƒÓìúâ»0‚«øJq € € ÊçÏŸ7’†SE‹•_ýÕXÛW_}%Ÿ}ö™”-[V7n,kÖ¬‘áÇ˲eËä™gž‘Ù³gˆ Lp¥ýlÚ´I´j«Y³f1®FC´í۷˵kפD‰‰^ñøñãeÛ¶mÒ Aquu5ܹsçdñâÅ&È¢!€ ¨ç[KÊø”Lèm\ €€Ã=~Üaæš’匫”Ô¥o@@@ ?ýô“ ›êÖ­+ß}÷,_¾Ü„@›WéÓ§—wÞyG^xáyñÅåîÝ»²víZ\i[¿~½ùsÿþý&ÒŠ®üùóÇ8³«W¯š÷5hÒŠ©Ä´?ÿüÓ„V>>>2zôh4h °Nœ8!GŽIL—܃ € €N"À¯¹9Ƀf™ € € àxgÏž5“®S§ŽùÓÝÝÝTVi(¤-88ØQÚt{¿ˆM«µ ,(åË——C‡™ÐH+¯´Y­ØDtÁ|ùò%ìÔ©SæÞcÇŽIëÖ­#õ$*pÈ{¢q¹@@4.@p•Æ0ËC@@ûxøð¡Ù¾ïúõë1NVß³¶ 2Ø>×3¦´eÍšU&Ožé^=ŸJ›†T\ýþûïf›@ݦO·|\Ë•+WŒoë<µÝºuËœo[³¾W©R%éÝ»w¤ËÊ”)cÿ„"€ € €À`«À'FÏÀ € € àì8i;sæŒùÓZIeuñòò2ŸîܹÓüyçÎùã?llyòä‘9rÈíÛ·%oÞ¼R¿~}óQ³fM)T¨¹NϲÒàkëÖ­f___É™3g‚è³gÏþØyFíÌz6–žiU£F Û¼*V¬( ›‹@@@À¹¨¸r®çÍj@@@ÀŽ4ÈÙ±c‡9ÊÓÓ3ÚùOZ-5{ölY·nôêÕKBBB$444Ò úõë'3f̳xŒÀ IDAT!C†˜³°´* @,X %K–4[þU¯^ݼ¦­E‹ Эý´ZkæÌ™²fÍ9|ø°©Ü ‹±/W´7J÷îÝ¥råÊrñâE9yò¤¹Ÿ† € €Ä&@Åß € € €À3fŒ ut‹@ý˜>}z¤³¥¼½½eüøñ¢•UZ-Õ¤I`Elݺu“#F˜Ê«_~ùÅTU«V5÷X›`º}Ÿ‹‹‹ ”Út í#S¦Lrúôi0`@œÛ ¾óÎ;Ò±cG¹yó¦¬^½Ú„V: € € ð8*®øþ@@@ž@Ñ¢Eå³Ï>‹4º†<[Û¶mE?"¶ÁƒÛ¾Ôm5ÌŠhE¼¾}ûö¢q5=+¦–-[¶hghéuS¦L±]õ^WWWù׿þe>h € € €@|¨¸Š¯×!€ € € € € €¤¨W)ÊKç €€˜ízæÏŸ € € € € €@W|‹ €)(àãã#ÇŽ“;w¦à(Î×µž•¡gtè¡ð4øG@ÿnhËž=»S³T«VÍ©×Ïâ@@@pd~âçÈO¹#€€Ý Œ5Êîçèh ”6mÚHƒ Ìö4x$pýúuÛ8~~~âîî  € € € àpœqåpŒ #€Î-0a°aÃÙµk—sc°z",]ºTBCCÍÇĉ±A@@@R€àÊ!“FœS`åÊ•æÌ0kã‡óÎù}Àª£ h%â’%Kloh°»~ýz¨@@@À>ÿj©Œzãykòûæc””Ù]$`ßX5ƾù®„\»§ÖÿÖ¬• ¿oóº˜.øëï³réòÛ[Ié+Qà&p¶ t˜GÅD@çÐmÐfΜ !((HæÍ›' pnVïôú÷@+­"6 võ¬'¶ túo@@@ sûÖR»FÊÕúàA¸|÷ƒ¿T«\1IÔ•ôéW ±vÃfyªf5É—7™CRúJÒ"¸ì^€àÊîDP3fDûÁ¼¾®U&­[·/// pJ#GŽˆ¿¿´µ[· ä,8§ü¶`Ñ `';v”7ß|SªT©òDfôñÇ˽{÷„sWŸ?ƒ"€IÐê¨ß·îá/÷3ý„‡‡Ëèñ“äõQÃÅÍ5»,ùÎO.^º$wïÝ—ZÕ«HóÆ ÌuZ9U½j% ½qS®‡Hù²>Ò²yñÿé¹pñ²|4{ä÷È'];¶u~Sg|"e|JÊ•«ÁrëömÉ”1£ôïÝC2dÈ k~]/Ù²f•æMÈUËû_-û^îÞ½'aaaÒ®u )WÆÇôûëºM²yÛIg ¹ ô’ŠåÊʾ‡%èüùùÿØ»x™êÿã;¡ì’=Ù·([¨,-ꇒ-JI"¢PT)"%E*©hS¢EJ¤Í¡]–ìʾËöŸ÷×ÿLs¯¹û6sçõí1îÌ9ßóý>Ï™¹9Ÿù|¾_~eww饯»vÛ›o¿oG޵'OÚå—Õ±Ëë×u}Å6ž$!³3„¬@âÂã!;† ´–U°óš«nÎsc>=žuæ_u½–3gN+Q¢„ÿg• œ6mZ|»b;#½{÷Úwß}—äùå—vÔwƒ(¡MåHûöíïÝ:uêd«V­Š÷öÁ6 6çäè7IƒŠ€wîÜi×]w €Ñ@RNàýYŸÚã#Ÿq)o¾mÕ«T²¿6n¶ýûÏTVøù×?¬XÑ ,¾¼6Ç<*^´ˆ=po{¨o/ûnÑ2Û°q“Ûîo_)¾¾¬ª.n¶¾÷t³/}YN bµ¼þË–-«Ý{÷±­¼fö«ÔG¯nwøžÊà‚NÑÛ˾‡7µ¸Îúß×ÃzÞu»½4åM;é :ýúû[´ô{¨_o{t@»­C[L+]ª¸Ýؼ™CÖ¬Y£t7ùµiveÃËìÁ¾÷Xß¼øÊþ¹nƒ›øŒ'åÎ=#€@j q•Úâ@ Á*ƒ¦V®\9kß¾½+¨o0ÿõ×_. ¥›óÊ:©P¡B‚ûfÂY@×½Ö}Ë•+—uèÐÁ *d¯¿þº½ñÆöÖ[o¹u®ôÐk4H_ 8­[·Î.»ì²DOLߌ֗?.½ôRËž={‚úQ‘>sâÛä*[¶l|7º]°9'G¿ITìœ/_>³+VXñâÅmÊ”)V­Z5·ÍŽ;¬k×®®ôW–wäÈ‘¦,eØ”)SÆ­1©›`MÇúðÃÝñ´­~'·nÝÚ–,Yb‡vý 4ÈJ•:s³éñÇR2oÞ¼yöÒK/ÙÁƒ­H‘"öðûßíj¿Ž©ŒkeƒÕ©SÇ5jtÎÑûUpkòäÉ®õ5®fÍšù ô9yÎ9ç˜ÖÉÜ·oŸ•,YÒ}ôQ¸‹ÉÛ›ƒ:‘YÓ¦MíÕW_õ÷Ž;îp}Ž?Þç‡~pŸÁ³gÏv?¯\¹ÒF寤ãôêÕË®¼òJ÷š¾•þòË/Ûœ9sÜùT@°ÿþ–;wîx¹F¿”ß~ûm÷Å»‹.ºÈ .%à¤kGcÙºu«³®›+®¸Â:vìhÊ^Ó5㵺,^}YHcÖ¹éü >ܽÓy=}ú´;š§Æ–-[6ëÑ£‡³¤!€Ä, RyÏ¿4ÅúÊæ­]ÿ—+×§¦ÏÕ¶­ZøT=dÉäv¯oû„¶¬YÎbj§í´eô¤úÜs×Ùk^ùŽ—Á÷_|Ûi_ÄÓ§OEÝ\}¼âO|Åv ®Âã<1J@ ¢¼€U0ݸ÷[Ää#R@ÁªÀl*•Û,å¤×i ¾ï¼óŽ tÌœ9ÓݸQ¦qÑ¢E]0Hˆ×.Òk ä(ذgÏHR€DA*5TòM}^h­Œºu뺒÷y1tèPkÙ²¥5oÞܽ¾qãÆ³‚VÁD:vì˜=ÿüóîå×^{ÍÆŽëÑ›q#FŒpÇÕ\ô”~ýú™‚.Y|7É0Q€mÆŒnœ»ví²‚ s`ß )è¦`šn»3çwž?3íóÏ?·_|Ñe™i~ þËYA›˜¼¡^­Zµ\G¶¿ÿþÛÀt,¾pZºti”L¸… :¿ù椛?¾ î)CVAÀ©S§º@ÒO<áˆÊpRKˆë¢E‹\@M®òSVn—.]ìÆot}éz¹çž{lÀ€Ö¸qc[¿~½{]û´hÑÂ>þøcàJEe´Ë$z v~´MlçU¯É^k“êšÖõ×·ïÃ÷ËÈ@äÐZT9}¿fú¹/«†ûÝ¡V¹by›·àk»°TI—Uuôè1÷ZРUÀp²û¾8pÀ÷ŸÛ÷厤6’J—*aó~gM®làºSIBevU,_Öf~4ÇeˆéwùÉ“§Ü8uüƒ¾m¢7}¡¡èE\YêUÜ¿i–®Xi7·¾!©ÃdSÖ¸ Óǰ@@èÊ.` ® Ò€2f”%¥ ‰‚,ÊŠžù8ÛbÅŠù׹˛7¯•/_ÞeÖxM7Žî½÷^8Èè[(=XS9?egýùçŸn›Àl£¸dÛ¶mëߤfÍš®”a°¦L-Ƽ¾•5ªàŽ©@2­Ç© U|š` º)h¥¦ž‚ûï½÷žw½æ}!Fó«Q£†mذÁ½_ïzõê¹`“šP d©Ÿ¯¿þÚ=§óXÂQ. Z©5hÐÀemižj³fÍrÁ#}~k<Ê|R€1°Å×uîܹ.¥ •š²o•Må5OÙm Z©)ð¦±+°¦¬4]kÿüó{McPÀ3zF{lç'¶óªã*°§Œ+eÑéúôL¢L–@x÷ƒìÑa£ÜcÈðÑ~ e]Íýò+—uåµë®iâû²D.·ÖcO޶Qã&ØÑcq¯YyíUì©g&ØkožýÅ„ÄÐw½­ƒoí­ßÝÚÞxk†ëF5 †=ý¬ õñµigާ5¬f~ü™~n¢:t8Ê!ïê|‹}¹ð[îÛç©q/ؾy{%36öAð ã*¼Ï£G"R€óyÚ™t<¼Œ+ÝLèz5ñèžM@ ™TÒÍ+/§ 2…›‚z/+I%o¾ùfk×®]ŒA'eÈ|òÉ'®äœšA xyM˜VÞ6 )˜¢Ì+µnݺ¹R„q5õˆÐ·¾  Ö¶oßn¿ÿþ»Ëèñš¶WŽW1GŽqò¬×•}½KLTÑkZ¯)°)ëÇûÿŠøz+(õì³Ïº2 ð¨T : b]~ùånnÙâÑo²òΑ,”%TÖÙ¡Cg¾žW*W®e~Z‡Ì;:–²¬TúÏkú}¡¢2î4]?·Ýv›Ë‚SÆ^ô&Kç`ç'¶óZ±bE›8q¢½ùæ›®d J?vïÞÝe¨Ñ@Ì}Œ õj_bz6e;ulÛ*è>Æ<åù‡ï?S6XíêÆW¸G°6òñAþ§ì{O”Mnjy}Ð}òåËk½ïîôµë®nbz6­S5ìÑþ§nøßµþ¿ç÷õÕ¯W· }Åw<\K ~\¥ŸsÉL@ˆ p1§š‰&PÀ Vé=Bà*xlŽ@´oßÞôˆ­i}'=”9õÀ¸ýÊPŠ^fmÕªU®ÌœÊ¾)óJMkW¶èA«`¥Ú´Í 7ÜàZ?K¥UÆÎë39˜LQ îÎ;ï<«;?”},Wi9õ«ý›~Ö:O^‹«˜¼û<ÿüó-þüöý÷ß»@‚T*­§ß}÷U©RÅræÌéß%¶`¡Æ¦5¶´¶UôväÈ‘q+@¦²ŠÍ[ëKÏéX lEzÛ+[íé§Ÿ¶«¯¾ÚeËyëpö§cÄt~b;¯êC2•TPN¥"µŽ™æNC/•úË›ç¼ð4£E° T`Ø2Œ €PÆ•Á]®Ò‡€JØ) ¢¦ €ZGJM™C .hqv5e+) ¦@îç~øÁ~ùå—X!”å¤õŸÖ®]ëßNë"y:*«§>cÊœJ¬²$Z¿JÇòšÖìRSPHA-­ ˜•lÎѯ2yÊóJ*Ø7mÚ4„‹O‹Í;úþʺRæ2¬”-¥,¤J•*¹ _`™À¸Ž+ ’vîÜé6Õœ½Ò…qíýõ&Mš˜Êõ)óLMAµÀ¬6eÎi4•ëó®/P¨í€Óõ¥y©t Ö‰Þb;?±WÍÏ **¨§’” NÒ@ÂG`ßþý6bÌx[³n½]\-j†oøÌ‚‘"€@¸q.gŠq"€ €qx+Ýèön^ƒ†á+ ’s÷ÝwŸ+÷§ SݺuÝÚPj×^{­+S§ì+•xëÛ·¯}ñÅvã7Ú9¾EÜ„hÕ*x ¡@‘Þ½{»,¨”m£õ­úõëç[Ü=‹{¨l\BÖ¹ŠvÕªUíá‡vb*±§ J¹rå\°Jmøðá.{Ir”¥m£Ï¹ÿþQ§¬%ýÁtY= («KÁ¥ø´Ø¼£ï¯u®¦OŸn=zôð¿¤òw*û÷È#Äçpn›[n¹Åý©’ŒZJA0ã»ï¾;Þ}x*0uë­·º}•Y¦5¾TÐ )[O¥)•é4vìXWêPÁPÙ*€©}üS Ms‹©Åt~b;¯ÊüRv•²µSÙʇz(Ásd@´8ÏWV6zɾ´ GFô.Á÷Ͷ3_ÑK`Si„ØN`wlޤ©€¾‘¬›;ß~ûmÐo—¦éà8øY*]£›& ùF3ŒD‚€nN6hÐÀÞxã³Öy‰„ù3G’" ì”6mÚØ’%KÜz?ÉÝÔo§NlÙ²eÉÝuXô§Š:ÁJâ…ÅdD(SQAÄÀlÀ䜸J9&&ƒ1[–S–+Ó¦D %SŽ’–!ëå2ÕIwRF¨‚½³Þ|ÀJ—(”èÞª6|ÀôéiÊ]”è>Ø@PùÌsÖ¨~ëqÇÕ‰êˆqÚÞùmô˜gÝGríxâÀ*³S Ï´§T`rúAHU/³$UÊÁqÀ5®B|¨ "H@Aueܨœ @@ˆK€Rq ñ: € F ê²ÆU0†Š@::t¨ËhW½ä.9˜Îé˜ € €+@à*bO=GÂW€›òá{îyÊ äÊ•‹ÀUÊ3sˆ§€Ö5¢!€ € €  T`B´Ø@ $\…Äi`!* Œ«„èè € € € »+®@@  dË–ÍŽ;–ŽfÄT@@@@ ’\EÒÙf® € îrçÎM©Àt–™  € € €éW€ÀUú=·Ì H×*‡FC³ôÞ8xð 4 € € € €@X ¸ ËÓÆ @\q \@ï Öãê@@@@p p®gŽq#€ €AÎ=÷\;zô(6 €@Š |ùå—|Τ˜.#€ € €+®@èÛ·¯Mš4ÉöïßÖ²Y³fÙc=òs`€¤”Ào¿ýfÓ§O·­[·ºC– \¶l™õë×Ï,XR‡§_¢Ìž=ÛV¯^íNŸÝ»wïF) œ8qÂFMfg::§L@@PÈjb< €Dؾ}»-\¸ÐÝœoР•+WÎ>úè#{àlÛ¶m–+W.ëÓ§)Ó„†@¤ ,_¾ÜÆŒãn$_qÅvÞyçÙ¾}û¬[·nöÃ?8=O ]-[¶Ø/¿übÿý· ÎgË–ÍòæÍkÕ«W·bÅŠ¹þùç–;wn«[·îYyýõ×­aÆVªT);v옽öÚkÖ®];w-k*%ùé§ŸZË–-ÓEó?¾«Z¨Œ+µQöîÝkÏ<óŒ)Ьó_§N>|¸†2›&OžlÚF¿ë:wîlÍš5s¯}öÙg.(}Î9ç¸ß…zß—,YÒ}ôQËž=»íܹÓmߺuk[²d‰>|Ø2eÊdƒ r׊šú}úé§mÕªUîµë¯¿Þºtéb2dp¯ñÅöÊ+¯ØŽ;\ŸC† ±7ÞxÃýܵkW·>Î?ÿüÔfãx ¡›6Ÿù¢ @ô*päȱô:µÍ‹ÀU‚¸Ø@ -”qÕ½{w;pà€»ÙªöÇø‡r×]w´J‹Ã1CB C‡.«÷ÄW_}uÖ˜jÖ¬i-Z´‰±2ˆ³\\¹r¥]|ñÅV£F Pði×®]–3gÎ!Û¸q£>}:EúNH§ú²A¥J•ü»„ʸ2‡äØvàÀV¼xq›1c†Ë˜Ô¹WÓu1räH›0a‚•)SÆäÓ³gO¼ì²ËÜ6 h¾øâ‹v饗کS§L¿/ßyçëÔ©“{]-]OÏ?ÿ¼ûYAͱcǺ‡ÚàÁƒ­D‰6sæL;tèû][¸pakÞ¼¹ýøã6bÄ{öÙgÝyRK×§‚ä  ¾ôÒKüîMŽ €>@ AÓÞý AÛ³1 €á)ðß¿“ÂsüI5«¤Ò €@ è†Üÿþ÷?ws>zÓMyݸ§!Éʰ¸å–[‚(°K MV¬Xá‚ xM™5ùóçO‘A+ÀùÝwߙʽ)ˆ¡Ö±cGËœ9³+é÷ý÷ß›²\•I£`Š$9räðeýúõ.“OÙ= b”-[ÖM2fŒ»¹ŽùñÇ[¬~ýúîxê«jÕª.ðÓ¸R"D:UÖ™2­”õ$Oµ‚ º?õ;OYq Z©)À¤ßwï½÷ž?p¥×䯦s àç† ¢Ì®mÛ¶þŸõ;S25edé|?ùä“n_eô)È­,/]“:þ 7Üà.æÉ“Çí§óHCÒB`õ×£Òâ°ˆx{æwVᢠ¬z•RíÀä@ õ\¥¾9GDH„€¾E®’HŒ²·ž§!é*T0¨´žP`»üòËý7µ#Ý(ç¯õÉT²-0h•ÒãT©U(RPÂk*Ó§ RåÊ•­I“&n›Å‹Ûܹsý%UNŸÃW^y¥+a¨à•²üTV®V­Z±]Ù@^¹C­¢·˜Æ•ÒiÝ¿L zcR`SïíÀV¤HW¦Ïkùòå‹òºÎ…Î¥×òNzNI/𤥶¿õÖ[ýÛ?~Ü.¸à÷³ŽS±bÅ´&âø € †ï~¸ÈZ6»”ÀUž@¤ ¸ŠÔ3ϼ@0PI)ݘ×Z^ÓÏÑoê…Ù´.É& ÷ƒ‚ e4ï¿ÿþd럎’_`Ïž=vá…FéXëiÍ+5e]¥Ö:T¿ÿþ»+M¨Œ¯)ðùæ›oÚÖ­[]0C¥ãÈ(]º´ÛÄËœúä“O\¦²¶bjZËJA½hÿ (»J@­måe\y¯*TÈe¿6ýèôÖ¢JŒ©ú×9{ë­·,K–,gu¡±ɼ ’rÌÄŒ“}@@@ òâ®éy&Ì@ DT"IßÊWÓBðíÛ·Ñ‘2,ÒF@%½¦@–—9‘6£á¨q ( )z AƒvÓM7¹Rr”dSPâ•W^qo¿ý6Æ!)ˆæ•¨ó6R0#oÞ¼¦×Ô´ÆQômô³Æ¹ÿþûV9D•lذa¼J Æå–ž^×ï²jÕª¹5¨”í¤æ«T¶oÖ¬Y¶nÝ:÷¼ˆÓ¦M‹’)— ®”)7nÜ88SSV³2½Ô®»î:·öÕŸþé~>|ø°»”µ¥u¶Ö®]›”ó/ € € £+.@°ðJ2„EáÃêÌ1ØÔðJj$»©!ž´c(“Të<6e=)(=ûFÙJ'Ož z@=ÓSZ°M›6îqÉ%—Ä8`ÑNŸ>}ÖëzÎ{^lj¾M°}¢w¢À–JÜÅ8Kšdxï=|øpÛµk—Ë®»êª«lüøñnB_|±õë×Ï|ðA»þúëí¾ûî³;ï¼Ó” —\í±Ç³cÇŽYëÖ­íꫯveýõW×½ÖÎêÓ§=üðÃîµvíÚù×ÏêÝ»· 8ÐY”¤!€ €@úÈšõì¬ìô9Sf…¡&@©ÀP;#ŒÒ€žïÖ­[º›WZO¨{÷îi=„tuü®]»¦‹ëT7Zig7nñ¡~]—)SÆ•wT™½Àuˆ‚8³6mÚtÖKʀњF ‚k bFo rE8©,a`™Ií£ õï­£¤mþùç»è¢‹ü]îܹÓeàÄt|m¨¬­áôÞ{ïÙÏ?ÿìÖÑ Ö‚+.b•\Tð*XSÀH`íšk®1=[§Nü?ªß%K–Dy½R¥J¦ÒŽ^Óu¥TL­Y³f¦Gô¦ [j•±Œ„k€9"€ ªÙ²rë8TÏ ãB ½ ðé“ÞÏ0óC([¶¬±ÖLHœ Dàé§ŸNW.ÊÊ+_¾|ºš“I¸@8\× \)[E娔]S¬X1·Î”Q^y>oæÊ¦SÐgáÂ…V¥J—‘¥l­E‹¹5§â | æÎÛõ¯ ”[ <é÷”Ö°Ò—-XR–ÔâÅ‹]©@¯ädõêÕíÓO?uk,/^Ü•TUÕªUc]ßJÇVàªQ£F¦5¼TOA°è-ظXO)á×>{ € €‰˜·ð'_éà#Ö¨ae;7÷9QºÙ²m·Íšóƒ]Y¿’U,W4±‡`?@ ^®âÅÄF €@Òt3.¶MIë½Hš€®ÏôÔ´âý‰“‚Ÿ IDAT–žÎhâæ.×u“&M\i6e;-_¾Ü•ÔÚR D)°å5˜n¼ñF[ºt© ©¼›²œpR +!­hÑ¢n?Ì”åÔ¶m[kÞ¼¹}ÿý÷¶råJ÷¼‚SÙ> 8i¼Ë–-³ùóç»}ÔO|3˜ÓXçÍ›çæ½Å4®„Ìm@@/ðå×?¹àTîç²[Ëf—úþŸó¸mܼÓîèý‚-]qfÝÍreЏJ<1{"€@<\ÅŠÍ@@Hne©|›q5ª8Š­eË–Íîºë®¸º²† ºG`S°,XY¸ÀmJ”(azÄÔü lÑÇR§NÓCM™\·ß~{”íƒ+Îɰ € €@² èݾüúg;pð¨½ñî7®ÏÕ¿þW®úÒ‹/´&—'ìKSÉ20:AˆÈq3f € € € € €QT°ÇWŨòÄÃíCRE€ÀUª0s@@@@@ ´nm{¹)³*z»»óUV´H¾Ð<£Ct#@à*ÝœJ&‚ € € € €$M ¯Q:(R8ÝÒ¦AÒ:eo@ ®€Å¦ € € € € €@z¨X®¨)ÃÊkz·4•¤!€©%@à*µ¤9 € € € € ʰR¦•Ê6¹¼JŒ˜!"€@zÈœž&Ã\@@@@@’&  «÷^íC¦UÒÙ)@ÆU"áØ @@@@H¯”L¯g–y!ú®Bÿ1B@@@@@@ "\EÄif’ € € € € € €@è ¸ ýsÄ@@"\àã?¶•+W†…ÂòåËmΜ9a1V‰ € € z®Bïœ0"@ b¾øâ {øá‡Sdþ÷ÝwŸ}÷Ýw)Ò7"€1 ¬^½ÚæÏŸPþý÷_ûðÃ“Ø »#€ € €„ƒ@æp$cDH{:ئM›,S¦L–={vkРÝÿýîï¡Ò:d'N´yóæÙ¾}û,Ož<Ö¾}{»å–[¢ QÁ±n¸Áj×®¤¡ë8Mš4IRì¾zOlݺս'Nœ8a•+W¶»îºË.¾øâ°˜Ôþýû­qãÆvÞyç¹ñæÍ›×ÚµkgmÚ´ ‹ñ‡Ë Ï?ÿ|Ë•+W¸ 7dǹqãF;}útÈŽ!€Ä-Т㨸7b @H믻.YúI«N\¥•<ÇEÂP@A¡J•*ÙÞ½{íÑGµ×^{ͺuë39~ü¸Ý}÷ÝV¬X1›4i’]pÁ¶}ûv;pàÀYãS «xñâI·$#p•$ưßy„ î=¡ëáË/¿´~ýúÙ“O>™ä hjÁ(ð¬¬Úï¿ÿnÊL¼è¢‹¬F©5„t\vîÜi¥K—vsÕçÓµ×^k*§wäÈ÷\ùòå­fÍš~ }n}ýõ×î3,K–,V¡B˘1j¡„£Gº,Rõ¯ài‰%¬nݺ–-[6×ÏìÙ³­L™2î  äkû /¼ÐêÕ«ç¶WS Hãøõ×_]ðµ`Á‚vÙe—¹ fô>vïÞí¶/R¤ˆÕ¯_ß²fÍê¶Ñ~ßÿ½­[·ÎN:寑;wî(ç5®ãüðÃn|2dpóQ?çž{®û‚„¾€ðǸ¹êXú½£Ö±cGËœ™ʤû7Dt! ß+S§NMsa €„‹@þüùÃe¨AÇÉ¿öÂúô1x@ mt#Q™‹/ö@7.Çgt7½•Õ”#G8|ØÝ$}ì±ÇÜ ÕxÀ6oÞì²¹*V¬hL›“ÁQCB gΜּysw?Þsæ“O>±W_}ÕŽ;ænæëúQ`H-¶ë[A‚mÛ¶Ù®]»Ü5ª÷ÌÛo¿í:†Ç^?[¶l±áÇۆ ìœsÎq\½?Õbº† *t–›‚'zï®_¿ÞýyÓM7¹ë\ï©råÊÙ˜1cÜ{^ïs½wõÞÒû\Á¿Þxã {ùå—ÝûïwÞqÛ*@¬1)Ík7Þx£ î)©méÒ¥.x¥ëFŸ™3fÌpA#/ ®²©úœQVŸš;:Ï È{mÁ‚nÿ›o¾Ù”ôY¨çôë5çú믷 ¸ÀÖÉR ªV­Zn“U«V¹Ï±-Z¸óôÓO?™®Y}>*`¦¶dÉ»Î÷M=]3 éõ+VX:uÜëúìT€­eË–.³LŸõß|óM”±Æç8¿üò‹ûLV–¬Ú¢E‹Ü|”«ëOÇVK?Ó@ÂO ©•ÂoÆŒ@’"ÀWIÑc_@ BtS}îܹþ›Ÿÿý·»!?räHûè£Ü ú—^zÉéèF¦‚;3gδiÓ¦¹×uƒ]í™gžqßÎ×ÍTe®èÆ«×bëSÛèy£F쫯¾r%Út“\7l£g%ÄuŠž{î9WîPk§è†¼nÒïٳǿÛÂ… íé§ŸvóÑ?¸õmm?hÐ w#W7ë ZÅ¥9¯+`¤ð tªé†»®Y³fYÓ¦Mí•W^qÏÇu}þùçÿùç÷²fêOŸúò^×5`Óvñ9ŽÆ Ïr¯•-[ÖeªQ0ú»•Ÿ@@@ô/@ÆUú?ÇÌH6^½z¹RNʶÐMueN¨ÍŸ?ß.¿ür+Y²¤ûYÏ+CY"ÚÞ+¥µt”¤®¥J•rA§>øÀm£›žúÖ¿¾ñWŸz½jÕªvÕUWù禛ìÁ²HâšügŸ}æ2Y4†¢E‹Z•*U\×·2 ¼Ì•„ÓM|1 è:Öõ¢LCe°xYQÚ^7÷½àllïm«@…nä«)È£ëÒ èç7ß|Ó½¦•2Q¼ëU×°‚E ,éýªÛ5¬,œ«¯¾ÚYï_eG*0¡¦ˆ2¥4'5e¿\qÅþ’w£.ÊàQðcÀ€vÛm·¹’‰ÊúRÀD²•©`rÍš5‹ø È;·„>ʤSSVÌ×T°/°üžW:?Êæ lúSW¾|ùÜÓÑ£çhÔyWÐSWIÁÀ¦@Q`‰Uo ´À±zÁ1•ÈôJ ö¡’PÞÄÏq‚™hŒêßû Žø @@@ B\¥Ã­› Êjзnõ­çÀoÞ¦Ãé2%’E@7ßt³®gÏž ÎØ‰k­[·Žk“°y]ÙI Þ(ËBßÊ÷š2t³Zå¬Ôd©Žjúæ¾²D¬ÒMU•Ôkºqª‡·–ж ¬¿[ŸÚÖ ’ycÐM]/ ¾ º™ª›Äët©,›nÂ{Í»i¯Ÿuó8pÞñ=ÛEŽ€Jûéñnö+“OeôtÍë¾÷¾ˆëú (‹ÐËΑ¤~ö‚êÇ˨ñ”•ùä½õ\l×°‚#ÊžŒ©¾ÏôÿÑ¥`±—)¤÷²[ –«ó²®¸’…²+#½Å•,ËÈ[—*ð3Oeck ú¶ÀÏ/ïíÚµs™±1µ¸Æl¿À±¦äq"ý:bþ € € €@z p•ÎάÖP†ƒn2hýe6x‹t§³©2’U@•;RfPô›ƒI=P`y§¤ö*ûwïÞÝî½÷^·vŠÖ±ÒÍkeu¨œYô¦Ì,•1{ä‘GÜK=zôpꆹl8ònÒëï^‹­Ï`*q5gΗñߦÏG{Ê”)Q²÷W°-X‹éùø›íÒ§€ÊõU«VÍ]ßZÃJÙ|ZƒMיʥ©”ŸZB¯ï˜´ìõGÞ6ÑLÉu­êX›6mŠ2}vjm&5}aF¥5/½ôR—™è½ÄR€EŸ |)+Œ³€2ôe }ñÈûý¡àÏþýûý;)©Ì*}Y)0+z¯ÊŽ lºV¼òƒz^ŸúrÊû%¦) ªß™ÊS†¡×«m’zõ«ÿ·¥l`bÎû € € €á'@à*üÎY¬#~ðÁ])"­3øíìt6M¦ƒ@² (hõÎ;ïX—.]’=ؘùìO£Ë—/ï‚|ZÇF¥Ä”e!;²ôšn¸*ÃJëW醦—µ¡õmV¯^íuÆ Ý neºé&¬nú{7µcë3Ø´•Õ¡òiZƒGc:ÿüó]ICeuéï15ÝTWˆ²Èt“XãVA¹Øš¶ÑÜt39z‰«4:-6 t­éúUTkL©éúP’®½'”}嵄^ß1MMï75/ËIׯJ¿iMªänZSNïseè”.]Ú–,Yb›7ovŸÊ$Ó{Om}qFëÚéý­²ƒš¿²­Æ%+¹Ç—^úSÆœSß|óËþTàQ¥½uÓ4O‹/î2شΕìR Ñ»&´‚‰*'©Ïb]£ú}¤¬Y¯)à¯õõÙ§ÏI]§Û¶msç->åù46}IJ}(kKÿï¹qãFwÜÀeR£ñ*@§òƒš‡Ž¥€YreÓ˵Ã<@@@ô"@à*½œIß<´NŒnX͘1ƒ U::¯LPPp¨sçΦRˆºá9dÈ{üñÇ]¹>ÝPìØ±£{ÜsÏ=6hÐ ÷¹¤ª 0y­_¿~n¼tƒ_A$·Ôbë3˜‰ndNž<Ùž}öYëСƒ[SE7•…[àJëi­Ë¥òkZ³g̘1q®”] ,’öíÛ›nÊ>ÝS4g5e_ê3Ak0ÉFë×)¨õÔSO%×pÒu?Mš4q+•~ÖúWZ+-ú9U Q£O?ýÔœô™T¦L™(.5jÔp+­'¨ó ~ôœ×tÍ*‹IÙ:§ Vé¼zëœÅY_˜úöÛomæÌ™.€©€•‚—ÊäJÎã¨ß²e˺ã(ûªmÛ¶QÖ‹ÏXÙ@@@ <2¬Y³ætb†ªr1ú‡4-ttóè÷ß·I“&…Π a" Œ«V­Z¹›oÉ]^SßpŸ8q"ïÍ0¹"q˜ B^rÉ%QÖú W•©ÓûMó¡…ŽÀ‚ \ÀÁ+•˜#KO×ub¼”y§Ì¸*Uª$f÷ˆÜgýúõÖ¦M—M˜™´ê·S§N¶lÙ²ˆôeÒ„ƒ€þ¿]kŸjMÖ”hZ 3úúƒñ9N¶,§,W¦¨åz㳟¶É”£¤eÈZ8¾›³ € ¬'¬2;u4Á}fLðì²*×SªT©C@ÈP¹N•PT‰AZê °&Têzs4@@@ä p•<Ž!Ñ‹Êb©\ @•1TN•“ÓzM4@@@@ .jýÅ%Äë € €‰ÐZWzÐ@@@@ø ¸Š¯Û!€ € &Í›7“‘2L@@@¢ P*+@@@@@@ $\…Äi` € € € € € €” ä@@@RI [·n©t$ƒ 8pà@Bwa{@@ p•¨t‰ € € -ZÔzöì „¸ÀUW]â#dx € €@ú p•þÏ13D@@4PàªW¯^i< Ÿœ .´ûï¿ß–,Y’œÝÒ € €/ÀW € € € € € €„†«Ð8Œ@@@@@ˆxW € € € € € €„†«Ð8Œ@@@@@ˆxW € € € € € €„†«Ð8Œ@@@@@ˆxÌ/ Û¶m³—^z)ŽÄ!H¸€®ÏôÔfÏžmË—/OOSb.‰Ho×u"Ø@@@° p–§A#€@8 äʕˊ)bK—. §a‡ôX;fÙ²e é1†Óàt}^pÁá4äÇZ³fMÛºu«{Dr;räˆåÈ‘#’ Üçnz¹®#úD2y@°سgÕ«W/ìçÁ@'믿ÞFNCŽ2VWa{ê8„‹@ùòåmÒ¤Iá2Üç_|aãÆ³;ï¼ÓZ¶lòãe€©+À{ÍlìØ±6oÞ<2dˆ•)S&uOGC@¢ dÌxf•Š'¹Ù <@Hai3¾ñát %e»'p•²¾ôŽ Œ´É“'»§OŸnMš41e´Ñ@àŒÀªU«\ÐJíå—_¶áÇCƒ €¤©À©S§Üñ+W(f¥KJÓ±pp@"A`ÞŸlïáð\ùÚ @0˜6mš:tÈTzA¬0:CD Uß«W¯6e(Ò@@@@p pNg‹±"€, L’Ù³gGPf‰ž§!€€™»ëׯB¡@–2i € € € €@¸¸ —3Å8@ˆ)»JåÐhDºÀŽ;lÖ¬Yg1(3Q- € € € .®ÂåL1N@ ‚>üðó2I<ŽuëÖqc>‚¯ ¦~Fॗ^ò—ÑŒn¢LE2¹R@@@@ \\…Ë™bœ €@ LŸ>Ý;J•*ÖµkW÷÷Î;»ŸÕ‚ešD0S0µk×Úâŋݬ›4ibíÚµ³üùóÛ½÷Þk ä=a×ÓE@@@ Ü2‡û? €@ú¸ùæ›­téÒV­Z5Û³gË.©_¿¾µjÕÊe’D_×'ý‹0Cþ(\¸°é=¢ •þþõ×_[ÆŒ­iÓ¦î±hÑ"¸@@@@° p6§Š"€‘+вeË'¯`–4"U W®\Ö¡C‡§_·nÝH¥aÞ € € € †” ÓÆ@@@@@@ô(@à*=žUæ„ € € € € €„¡«0ø‹8jÔ¨tÍ+ࢠ<üðÃV±bEX@@ˆ—wüâÅš) 5sæLÛ²e‹ÿ[~_ýµ°\pÝpà ¡9xF…@ˆ (PÀzöìb£b8Átcñ“O>±š5kZΜ9ABhÊÈ^½zµÕ«WH @£FøÝòÿfú7ƦM›¬yóæ Tds¢ ¼ôÒKvàÀX@@ˆ·«xS…Þ†*O¡à•2¬‚5}«‚Cï¼1¢Ð(X° õêÕ+4Ǩ¢ìÞ½Û®ôùW¤Ht@ šÀüùóí¯¿þâ3+’, /ÂuëÖ-ÉýÐAd (pEC@@ !¬q•­ÜVeÍôÊè­qãÆÖ´iÓ1CB@@@@@‚ ¸JWÆ“O>e¹sç6e[Ñ@@@@@@p pNg+†±Ö©S'ÊZV*Ÿ¥2‚4@@@@@@ œ\…ÓÙŠe¬=ô)ÓJeU>† € € € š?ý¶Éª6|ÀÚušdT €¤¡@æ4íÊÐëO=7dÈ(Û÷èÑà tÝå ˆH€~ùcK”ž.¯WÁžªK2ôN € šaó/ýãåúë¯çˆSóêH‡Çúá‡ì×_M‡3cJ € €¤®@Æ íäÉ“É~ЩS§ZµjÕ\ÅCöرcöúë¯'8pS×êkéÒ¥6eÊË™3§>|Ø2fÌh¿ýö[Û¹s§{­mÛ¶Ö¸qcûóÏ?mÒ¤IöÔSO¹QÌš5ËöîÝk… ¶Ÿ~úÉú÷ïoµjÕ²[o½5Ê({öìé‚4ü±ËÊ’%‹=÷Üs.€¦u~UYAÇ×qUFýÀ–={v«T©’ýþûï¶qãFS–ÆzôèQׇþ“o¥l@ï–V´H¾Ô‡äˆ €¤¡@X®ÒЉC#€ € €Ä °k×.PÒ •£àÇÈ‘#ÝÖ ~¨ÄœD*k^¯^½TQÖZ 8Eo ~©„ , ´tëÖÍFíÖû}úé§Må «W¯îÆ çsäÈa}ûöuÁžG}Ôžþy¸Rÿ£‚IúrÛ»ï¾k<òH¬cÖñµ¿Ú<`?þø£;ž‚CÁÆñÅ_¸‚xÑ›]W]u•5kÖÌÍ%C† .ø¥Ÿ›4ib›7ovÁ4•îSÛ²e‹)¦í4'ÍEAÃåË—ÛØ±gÖÈQ€köìÙöòË/[žÞÖmøÛ 8׺ß~•ŽKW¬µq“>µ5k·Ùá#ÿúŸ?tøh‡*‹»Ÿ ùö÷þTÐJ­pÁ3Ç Ü_?_X²° åÊqe·nßí2µ›Æ¥öûŸÛìªÖã¼¶uûWa{E2p@ĸJŒû € € €ÄK`Ñ¢E.ÓGN*áWªT)W¶ÎË$R'Ê*W®œëOYF*=X´hQ»ýöÛÝ#XSI»ƒº>µ”‚>¥¸§c”(q&ƒáë¯¿Ž²y°1oذÁ¿Æ•n‚/X°À•*Œï8êÔ©cÓ¦MóG™XóçÏwå`RpM¥µv˜‚iZ?LYd ˆq* fi]«˜šÖ ›7ož[/LA©ï¿ÿÞ­Skݺµ1Â{ì1 T†ˆè‹Ë“×èk\yãÚ¹û ˆÊd:|ä˜[—êØ±ãÖëÁWíï¹>ݯsë^y®ËªŠÞTFеÿ/·éÿ9–ÉÿëËÊòÚ‰§Ü_½?wÓûM­z•’.K,°U,|M¬P1g €$·«ä¥?@@@¿€nÆöèÑô†R¯^½ÜšWzh)­Õtî¹çºl¯ÝtÓMný(­óÔ¥K—%•94lØ05j” X)ø£5ª¼2yq‚[o½Õ†êC *)åµ`cÖk%K–´>}ú¸¹\zé¥þ’{‰Ç=÷ÜãÆï­a¥ì²Þ½{»1½ýöÛ.ƒMërÅ”!¥ñhMª 8'­½i½,e“)ø§ÀXûöí]iCe¥k2Qf›X¹rårûxÙa_}õ•µmÛÖí¦’‹ÞZ_q9ó:¡ päè¿6bÜL7”+ëW²ßþb£ž›mO éh[wìqA«<çcwtlä¶Ù³÷¿ WRǯµ°Nœ8éÊ ®þu£ë®D±vòÿU^ÿ^ÙÂ-[w[­e\PMm÷žƒ¦uºh €D’@º \éÖ_zé¥(ÿàHŽ“©oÛ©~9-n- ¬äyß\Œ{3[ègúæ_ôZë7nt‹«_ý£©bÅŠîÛú‡ï AƒÜ?º¼¦…‘gΜé~¦!€ € €@Ê \wÝuþ̘1ÃÿwŽ´v”ך6mtM%i‚5­Éä­ËýõÀãè5eZËÛ^Á¯õïßßÿwÉ R‹žx¬˜Æ¬À•þí½ÅgÑ÷Q¦–W:Ð{MvcÆŒ9«ÿ@k½8§À¿GßQÁ¸ûï¿ÿ¬þ´FV ‘·a:/ÑÏÖÒRvXô¬”½ê踆Œ|×¼L¨š¾ì©½[ÚÄ)ŸÛ¶{­éUmø öö¿OÙ§ó~´VÍk[5ßÚQÙ³e±½û[¸õ­6lü'îÅs ­iÕò–Qöïñî*e¨u¬~‰¶ÎUÙ ‹X£•lþ7¿X›;ž±‹«–òe¦î³µvØüçÑØ @ô!ðßWÊÒÁ|ôM°ÀoÉ%Ç”TNáõ×_OŽ®"¢Ï?ÿÜ™’£8pÀ}#³M›6¦ ¾óÎ;vï½÷&G×ô € €$X€²y &cR]`½/褠7ï´uýmSßùڗŘÉúÞ}½ jõºó7®ac>°Ì¾/ÉîßÚ­{õýÒ?,ožœ6vØmÉ6îö­.³â¾ «ýŽXå Åì™Ç;ÅxïjøÀöÖ¶e];tè¨}rãÖ8 àJ8è}ê_sQ*¡E~õ¼æ¦>´ÆÜ¸qc·^»v­«c®à2™´½æþÖ[o¹ì¦víÚE¹NuìéÓ§»ç4×æÍ›»ñÈTå)ªW¯î¾y§¿ËQ¥=t\ÍCÏ«”ŬY³\©- ¬Ì§ž={:³?þØ~øaWÊbìØ±®Œ‡w^b*å¡>êÕ«çJ_xMûÐ@@@@@·_¾/Få_ŽˆòÚ ×Õ2=¼ö¿«kšmõ×£ü?>ÿTÔ²¥–,d¯ká¶uèMÙ\/>}çYÏW©Pü¬>råÊaÜ“{Ð@ˆdt¸ <‘[¶l±^xÁYT7]Aœ2eʸE~Q ç?þpõÐc+o `ØòåË]°%XSJ¥)|QGåìÒ7ñ:wîl 6t»íرÃÕW@ëšk®qåï4Õ.Ÿ3gŽuìØÑ-Êë•€¸ûî»Ý>jZlXÙdª¯ “‚K—šžS€H‹«¦¹= .tuÚûõëçêUðÈ Hi1Þ©S§º²{ѽԷæ¢Ex5~*hõâ‹/Z¶lÙì‰'žp)_Ž Hiñ`ïÕW_µ‘#GÚW\aµk×v‹/«ÉDÁ*õ­>;tèà‚j*M¡yË^­`Mç¬jÕª‘üþdî € €„µ€¾tn-¦1G/×nób¼ 6dj¦;GEot¸ªV­š[I”•¤À•J V©RŵråÊÙÖ­[“t¨ñ2†´ÆÒ† \†“š‚TÊFR«\¹²ûSAeWy?.\ØV¯^í^SæÖ‚ ,kÖ¬n\*•§q•¦•š‚EA4¯›”¥µ~ýz[ºt©ýùçŸîOµÿý×ea©]ýõg­ô¼Æ €žúQSÀoÕªUîx Z©]}õÕþÀ•^—±šçë~ˆÖtÜo¼Ñ=+ ÍGÛ«©n¼Ö$‹©é\%wéÇÆ  € € € € € æé6p¥àO°¦oº( ¤’šJÖ©)ØTb/zÛ¿¿uïÞÝ=}Çw¸?½€’þ®¾¯¼òʳ²Ý´i“ F6•ÕóšöÓ6“'OvO õéÓÇÿº7ÆÀñz/{MýuíÚÕêׯÖTbÐk*¨õ¨œRÐ+ºJFoÞ6 (Î!(¶ïImçÃÇÓöÏ—*UÊ•ilÖ¬Y|6g@@@@ $:w¸Òô !€ €@Â2&|—ðÞCAÅ‹»IhÝ©âÅ‹»¿kí$/;꯿þrë_©)à¤r{jZ»iÚ´iî¡l¡èMH_}õ•[¿JíàÁƒ.˜Ÿ¦ã*TÈ­öîÝë²¼¶fÍ—1¦6oÞ<Ƙ~^´h‘{^û¨dŸ‚=—^z©[kÊ˲RÀ-X{ê©§Ü\´>•2·”ñåìdRÀÀçtl=[Ë‘#‡ýóÏ?A7Ñ:]‹Öóæ¢5·bj×^{­+½èeŽi;eÑ@@@@@@ } ¤ÛŒ«˜N—2ŽTÒOk.)H3hÐ ·i›6mÜMŸ}ö™åÎÛJ–,éžWf–JèuéÒÅ­!¥Ò€1µ¢E‹Ú]wÝe=zôpåø”‘¤õ¡âÓT¾PÙIÊèR€ìòË/÷ïV¡B3fŒ ¬éµ¡C‡ú_SpJÇS°L% •¦€‚]·Þz«Ë¢Rß>ø`¬ÃÐ|[¶léæ©qÜtÓMîçÖ­[»ì-ðÔÊÆÖÐ{ä‘Gì»ï¾sžÑÛàÁƒ¹Ž‘7oÞ×·Ò~*+¨>4w­¯¥Ÿ•E¦ùªi¾ÞZ]ZÇë¼ó΋5Û € € € € € €@ˆ dð8â—mZ§É+·—sSðfâĉqfüÄ5df̘×f!óúöíÛ] j„ gIÏ·hÑÂ.¾øâo¨D×Њ+ì7Þõ¡2>ˆA@Y°ú,WÆh‘"EpBhÊVÖï;}I‡†$Và¹çž³ï¿ÿÞ&Mš”Ø.Ø' Š S§NµÚµkG¼È®]»ÜÒ mÙ²œ²\™6%t7·}¦%-CÖ‰Ú7¹vR…™zõêÙ¬7°Ò% %W·ôƒ €1Œ÷¡í=œÛFy&ÍNðU–;u4Á㈸Œ« ± €@š (‹ôüóÏw±Áš^/[¶lš“ VúRFLåw8àÊèM«³Ãq_åË—»  @@HMW©©Í±@% 2¬*ªL«&Mš¸>:dS¦LqY´º)¯,‘(  Uß¾}]p÷öÛo7­S©¦5%_{í5W¹zõê6vìØHäaÎ HV­ZY:u¹wdì¦ÿÿزe‹•/_>2&œ„Yª‚ ÒNàÓy?ڌًmòØni7ˆ8rz—¨Zt|ʆ hc5«•N9ºDB_ âWáT&P—nB+¨×}ôÑпÂ! ºyöþûï»›ðz”.]Ú­¿çµk®¹&ŽB„§€2­Ê”)ck×®µ#F˜ÖóÔ:’:tðOHë_Ò@„hý^=h1 ,\¸Ðž}öY[ºt)L €@/¾Zm ê–·ìÙ²"ƒ €‰Șˆ}Ø@TP‰2®¼¶~ýzÿßõ|Ïž=Su< P|(qóæÍþ!*ÛŠÀU¨1ƃ €éUàĉ“6òÙYöï¿ _Ï,9L~ýcKÐn2dȯîcÚ?^;ltìØñ„î’¨íã3/eíúí‰ê?ØN©7·¸‡¼{ÏAÛ¾cOÜFÛ"±ûÅt Ô2IðDÙÂV â2®ÂöL1p@ ‚´¶•nÌ9ò,…Ûn»-Ƶ¯"˜Œ©G˜€²®”y¨²€ÑÝ»˜. €* ÒjÛ4°/¿þÙ>f§OŸ¶þ÷4·êUJ9½>l`{{úùÙ¦àÌkÏ÷´reŠØ¤©óìã¹Ëí¸/àT³Z){¸Ï–;W;pðˆ=þôû¶ú×îµ|yrÙˆG;Ø…% ùJ3Ÿ º_¶¬Y쾯Ùö¿÷Úm=_°L™2ØøwØŠŸ6س“>µ§°Œ÷òS§NÙ§Þ³e?®sÇÏsî9®”\ÅrQ³cïyðUûì‡,sæLg] ƒG¾kß/ýÃÙ4¹¼ªõé~eË–%Êv1í?wþJ7çÿìs™dO î`5b)c×徉֬ÉÅÖ±u×ÿâþ´Ãß²÷^écyÎËiûö¶ÖÇØ··:—\”¤«6®yé|<6j†Mõ [·ïñã¶aÓ?vâÄ)+Q{ò‘›Ýù­EŸ[ÇîÏYÛ–õ¬e³ÿÖ…|tÄ;–×7Ï>w_Ÿè¹­X½Á†ùÀöú¬ äÍmz·8Ë|þ7?Û–í»­w×fQ޳gï!å»Ö—,ÿÓŽ=n—Õ*gO éèß&¦ý¾_ö‡ïZc{÷r&:‡}{Ä>‡]{XÛ.ãìéÇ:ZªgJó¾iüjzÿ¬Ûð·Íñ]—E‹äK´;"€@d ¸Š¬óÍl@°PÆÈœ9slåÊ•þ9(“¤uëÖa;'Ž@r (@õí·ßº5ß¼¦ÀnÙ²e“ó0ô… €„¬À7‹~÷Š:[–,™mÑkLA˜OßzÐW½áL âñÑ3lðm¬Rù3ÁžW§-pœ·^êm眓͆”-õÄÃílúŒo-{ö,ö‰oeõ(¸Qìÿoº¿öÖW1î÷ì“·[FúcwÛ¹¹ÏqÇ)¹·€5½¢j¢ƒVêC¯ÿ]]Óé×ÊÍoüäÏlìÄOlâèÿJ¨Çvb-[cc‡u²Ç|Á®ÃGŽYؔé_Y·Û›Æy>W¬ZoOŒþÀ&<ÝŪT(n ŠäÈ‘Åf~²ÔÞùpQÐý•4b܇¦€Û­m/wÁ)_Æc‡Ýæ2ÿ»æ’$­’2¯|ysÙ·6¶Kª_hÇŸ°»ïÙÞžù½ÝyKãÍ­ý—Ù;3ùWGŽþks篲é¾ë*)máw¿Ú”çîv×ï7‹~³ÞOq×c\5sÀÐ7­DÑöÑ´þ–Õw½ìÜ} ^C)V$¿ðïJ/hÊÊjyë(_³² æûé·ÿ*;vxàÀaëÞo²MÕÅyìÛÊÿòè Y…‹. h¯3ÀF à ¸âZ@DY·‡L’°9u 4¢g&.\Ønºé¦T82‡@@BCà†ë.uAµº—”µ¢çç³Å¾Œe©µiQÏ*W(æ¬/ý{µð¶:ù,ïïWe}ÙX~ºÌQÐ¥”ïF¾×bÛ/˜D¥òÅ|Á²ÿŽ›X­Ú5ÿËLª_»œÍ™÷£ëê…WçÚŸYooç®ýv]ûîï—Ö(cÃ}Yfj¥Kô;œ“#›/˜ÔÐÆMüÔ®âÚÿÃ9ˬUóÚ.h¥–7ON÷ç ×Õr˜š²¬îôe^U,WÌ.½øB»§Ë5vÛ=¬ï#Síïûmø 3cKJ‹m^wß?ÙÖnØa'O¶½¾`ÛÕ­‡¹CÝѱ‘)ؔݗm¦ ‹š®çÍ[w%xnW_YÍF=7ÛÖ¬Ûfe/,â‚VÊ^à3/””©Y;ß½ kƒºÎ\Ï>Óº¾q¶º}Œë[A2eF}ôÙr÷ó¸á·›rK–¯uBoµBÎó}ÁíH¬û){¯xÑüþ1«Ÿ e‹Úæm»]àJÙˆ±µç_þÌü›l ?zÌÙª)ƒKk¾½ûò}I²`gˆ<° \=šrP‘w&댷nÝÊ"ÓÉ*Jg¤®@‘"EL$¯½öšµjÕŠL’Ôåçha ˜™xÏ=÷ðÿMapÎ" €$Ÿ@ôL”ùsûJÛí÷ ´¯Ì_`Û¶c¯ óe‘dùÿ²z¾ z¾ì”LvðÐQkÔ ²åÈžÕ¦ÍøÆWfî=»¥MCëpS}—ùÛ~Þ ûä›Õ™žTºMbúÖjÊ”)£¯”áQ;¥ûÚݯvµ&­žpY9ÑKzÁ&o\òkÿø‚\ñÙûßû‚Þ>ò•XT/¦¶ÅðhÔ ’U«TÂm¢1ÝÑ¡‘+§¨K°r† u‹m^/<}§ëî/_¶Ü#O¾sV©À?Önµ7ÞýÆei °›wYµÊgÆš¹eÍšÙZý¯–½ëË>Spgög?ØMÍë$t*gmÖÜ|׳~ fÍ}o Û~ÆìÅg• \õó_®L¡”-®ý´­JjüZ«MmÍÚm¾¬¹3×Ù˜ Û/ϸÒë*ÁÙíö«üA+]³úÜG ¹Å€K2  €@Ä„MàŠoÕGÌ5™â-Z4jýç? @ Þÿ}Û¶m[2ö~]ýûï¿V±bEßÿHŸ°çŸ>ü&Œ#V O<Ú‘~MH¢@V­Z5WV3°´f$^'µjÕ²ÚµkGâÔ™3 €) õ—Û&_öLá‚çúŸÊ˜Ñh… žgCjë[Ûê̺<Ñ[ÝKËš À(Ø¢À˜Ö1Šm?­?•íÁǧ[y_˜ðQæjñm»vÿWNZûü½sŸ) '>­Psݺ]ÑÛU¾ò‡—׫´ eZ)ûhôÐ[üYp Žñ•ërK#÷gíše,WÎìñBŒÛ$v^ÿþ{­C¦5­®¬_Éõ¯Œ!ïJèÜÚÞp™µ¿sœ+‹øÛš­6~dµ$ÍK;ïŠVÞOA«Àë9¦è¼îÝÈŽ;î"Åg0+}k± ñ­ƒöÆ‹½ü™W·û2ä¼Ö½sS@+°¿]¾’‚š{·Ûšºl6¯i¯Vÿ«mW-ŸÃ³  E lW½zõâÔÅ!УG—}ЧO¬@ ̘1Ãþúë/+Xð¿2étªqN+Òxÿüó•(Q‚ÀU´+å¹çž³’%KúÖ(8³ž@$·7FòôÝg¥ÖT pÑ—“Gˆ0éïg }%Õòø²M>ôeiž:åõ¢sè¦ús/ͱ§†t´‚ùÏuëmÚºÛ.ôef­ûëo+äËpQ–ŠUE ç±cÿw]ĶŸ²¡òœwޝlÜvºŸ~ÛdŸ|¾Â•%LlS£hÃÊ.KIkT½óá÷ êê¯Í;íS_iÁfM.vûO}{¡]Û¸z¼úÐÚTý‡¼éöU0êÐácÎJÎÙþ¿$\ôކ>ØÖÊ”*ìZéõ¡£fØå—U´ûº]ç ?=ÃFî¯1Ä´QbçuôØ¿.³®dñ®ëí¾ì»Ï¾\é²h^ ™Ûççõ•Ó+iC}sjÖôâŒbš›®çu*Øyçžc ¾ýÅ—é·'ÖëÙëç|ßµz±¯´ß8ßh}ï¾Þí[¤pÞX­wíÑÚeY­ /P©¶tÅZûÙ·¦•—==ƒËëLk¹½ülw)I=ÿÖßù²Û]â^C-I;#€@º›ÀUº=L H À•W^imÚ´Ià^lžÞÞ}÷][·n]z›V²ÌGå$+W®œ,}ÑIø <öØcá;xFŽ €‰P–ÌݼìÖyÊŸ7·Ñ9Öe·µ¿ÂçŽ^/úJï±L¾ Ð ¾Œª^]¯µßÿÜj÷ ˜ã¾“ÉWð2ßšR74;³žSlûéu ú?6ÍWª-«ó­3´yË.[øÝ¯vÏÿ¹Rƒ‰iýzüφùÀ^ys¾å97§õèrµýñçÙÕ8æ½?(h÷*s¸ø‡56ᕹvÔ·.RSŸ•7À‚í¯µµî¿§¹=0øMW²P¾L¥1dª©?­Ø>øx‰/¸Ã÷e¸©õîÚÌ:t{Ö·6×»ñúÄgÈÇg^%}ë“MÐ3ÊxlÑùèÚg’¯þP;ßzgTAJêµÁþDŠ@†5kÖœ)TšÀ–?~ß·<ˆ{%-E7'ã*EyéèØ±£•.]šÀUHœ´„¸š6mZÚ$ÄŽ^¡B{ôÑG \…ØyI‹á(pÕ¨Q##k?-ô9&‘!°páBëׯŸ-]óú.‘!Á,ã+°FbHî IDATk×.Wî:¡-[–S–+Ó¦„îæ¶Ï”£¤eÈZ8Qû&×N{öì±zõêÙ¬7°Ò%¢®1•\ÇP?-:>e=ØÆjT ^ö/9E_ø|Á*{ùùöÖä{BÒT`ĸmïáÜ6zÌ3i:üÄUf§Ž&x‰ûšG‚à € € € €¤œÀéD}5;åÆCÏ‘#°ÿÀa{áÕÏ}k<]9“f¦ €@ 2•‚¸t € € € €éWඞϻµÑnò­™vu£ø­–~5˜ <®’Ç‘^@@@@ÒH`Ö›ýÓèÈ6Ò^{>êúY‘îÁü@ä T`r(Ò € € € € € €@’\%™@@@@@@’C€ÀUr(Ò € € € € € €@’\%™@@@@@@’C€ÀUr(Ò € € € €i$ðÏ®ýVýŠþitt‹ €@ò ¸J^OzC@@@Heö]ÇÙÊŸ6¤ÚQù}³­ûëïT;B@ ’\EÒÙf® € € € €@:èß«…•-S$Õf6eúW¶>W7ï´ƒœ5× 2Äkþ1í¯Ù@TÈœ Çà € € € €¤˜À‘ïÚm¬fµÒ6tÔ Ë›'§ý¹~»ðxöì=l×6®nÝnoêŽÿé¼íË…?YŽYmÛŽ½¾×Z­e¬O÷ë-kÖ̶fÝ6»£÷‹öõGùÇ»hÙ{úùÙöÞ«}mì‹Û‚o¶•?ÿe/¼:×îî|••+sÝz÷x›8¦«•¿è‚DÍóÔ©S6ä©÷lÙëìäÉS–çÜsÜœ*–+¥¿ ¯Ìµ®«eu/-åù¬Y2Ùëï,´·ÞÿÎŽ?a˵A}o´Â…òÄkÿ¹óWÚ¤©ólÇ?û,{¶¬öÔàVÃçIC@ µ\¥¶8ÇC@@@HQ>^boM¾× 8ϯZteõë–·*Š»ãÎùr¥½>¡§]\µ”ýûï ë9à{í­¯¬k§&qŽë>_€kùª v[û+¬ÉåUÜöÊ€jÔ ²ÌnœûÇ´AÆŒíW×´Gúµ²,Y2ÛøÉŸÙØ‰ŸØÄÑ]ãÕç‘£Ç] nö´þ¦ì«q“>µÃß¶Éc»Å¹ÿŠUëí‰ÑØ„§»8£={ù{YâÜ @H W)¡JŸ € € € €i&ÐôÊj.h¥–;W+_ö[»~‡?pUÎWVPA+5eYÝÒ¦=;iN¼WÁ&•ËwŒÁý['y¾µk^äï£~ír6Ç—¦öÝ’ß]6–ÚÞ}‡L`w¶¬Y\ Êk;\i €©u½µ‰Õ½fÛ^krŶÿ‡s–Y«æµý>ÊX£!€ V®ÒJžã"€ € € €¤ˆ@¾h—L3؉'ýÇ:7wŽ(Ç-ï\û{ç¾Çrúôég`§ 0½:m+q˜)SF_¦ØQ;õÿǽ¬vy›ûÞ@·ùƒC§-¨×òçÍåï2ç9Ùì_9Ä»X\ûoÿ{ŸU*_,ÅçÈ@ˆÀ™¯`Ð@@@@H',C¬3Ñ:NmóÖ]þ ­Ì™3¹5¦Û?¾àOJ·Ÿî1nøíöì“­ûíW%øãTùÂÃGþµùsÇÙO¡çÚö¿÷ƹ € ®RC™c € € € €„ŒÀ¦-»ì£¹ËÝxöí?l/¿ù¥]Û¸ºû¹Há¼vüøI_iÁíîç£Çþµ™Ÿ,‰2ö|¾Ì¦5ë¶ùŸSƒG¾k»ö$>Àµk÷+zA>Sàìð‘cö·ß'Økò_º ›2Ä&ý{÷EÕ5pü$$„BïÒ{ï U) J³€ý¢ˆ€ ¢ˆ(*(EDŠ_é(Mz)Jï@Ú7çÆ]7ɦ·ÝÍÿ¼Ï>&»3wîýÍ0ðÎÙsﬕrÍR’-kìÓþéÚZ ÜlÓ­Ûw̓ €©!ÀT©¡Î1@@@@RM žµ~ÔÖÉ”¯VÈÀ{ÒìJÒõÉL2øùÊ[¯u”!£¾–¬Å××GÕ-gOté6Ïwi"ì ©%Ëþ4•Q5«—5ö™µ²rf½ÂÉÙÀ÷i-c&ü 3æ®’l™¥ÏsÍåБÿ’c¶}Þ}ãi§nË’BrJ‡Êu«Úª|é‚2fx§(Û:Û_×Öz¹ï£òÊ›sMÂÊÏ2x÷õ§¤Zåb©vŽ80 €@Ú q•vÏ=#G¢¸zõªÌ™3GÖÓ[ó«§“ÁƒKîܹÝJíóÏ?—úõëK¥J•bí÷| 'Ož4ãÕhÕª•4jÔHvìØ!;wî”®]»ÆÚ € € €@ê,™;Ä~à7^i¥“Æ=á½ÐÐ09¤C´}¸iUÑ—ct{ª±ý׊e ‰ã1õƒÕKÞLÔàµ:êǯÿ‡6¦ ³¸DîœòÍÔþfÓg;5ŠË.Q¶Ñª+} €¤¶‰«Ô>p1{÷îÉÛo¿-Íš5“¾}ûšÞ]»vM\¬§Iß^½zIÙ²eEwÆ “òåËKÅŠ¥dÉ’I0ZD@@TЩô@pMW®y^è j[¶l‘¼yóJË–-í}Èš5«ùùömkî÷/¾ .Xó¦‡H‹-LU’†&zªW¯.çÎ3Ÿ7oÞ\öîÝ+wîÜ1‰ —^zI´¸n§íLŸ>ÝÌÍ(=zôâÅ‹‹öoãÆ¦ ,((Hnܸ!/¾ø¢dÏž]®_¿.“'O6ÇÔD[pp°é›í}MÊi[­[·–zõêEkœ-[6)\¸°\ºtIŽ;&»wï6ÇŸ9s¦5Mˆ¯Iäi› .]ºˆ———œ>}Ú|®Çôññ‘ž={J®\¹RíêׯŸhhäÈ‘&™tß}÷™„Mþüù¥}ûö&Ùóúë¯Ë¤I“L²jÉ’%²fÍyì±Çâ¼NK8tèP“ Ú·oŸüüóÏö °£GÊûï¿oDË–-“ß~ûÍ÷»ï¾3É3MšÝºuK d†ñ矚~ë6¶„VtŒÚÿ3gÎH¡B…LòÍ14y×§OóÖĉeëÖ­R«V-™2eŠôïß_ræÌiÞûæ›oŒ € €@b¨ JŒžó}M˜ôG¡E@H¨‰«„ʱ ࡚(Š.öìÙ##FŒ0ëZPš°Ñj$M\i”)SÆüW“7yòä1I+Ûïš ²E\¶;{ö¬üôÓO¦âJ“EZ)帿&­44ÁõÏ?ÿ˜Ÿ8 :…/>œ9sf)W.|>øªU«ÊøñãM2K§@ÔJ)g1uêTÉ”)“©Þ8p ù9rØú®ïW«VMûì3iÒ¤‰<øàƒQ†¨ÓûEWmfÛX§HŒü³7K–,2|øð8³  € € € €D'þD@@à_Úµk‹N¨SûÙ–°ªR¥Š¬[·Î¼­Sê”xåË—O;]C*_¾|¦m]×*.Qºtiû¶ºÎ•V`ihµ–~SU+¯:wî,»víŠKsN·Ù¶m›©ÓªªßÿÝT™iÒÊßßßLIh‹›7o&øìˆ € € € €@Z â*­žyÆD#àëëkÖ–úꫯ̚Qš”Ñéßzë-3 ŸN§§ëWiè´{EŠIK]êÍ7ß”lÙ²™)ùNž<ëq´ZQµvíZ3U`©R¥Ì>ºNÕ¢E‹ÄÏÏÏ$œ4y•ÐÈ‘#‡¼óÎ;¢‰)"°fÍš¦)]ßjÆŒ¦2L«²´ª«E‹ = û!`fΜiÖpÓkW“źŽ\Ë–-#T 2D.\¸ Za¨•ŽÅŠ“®]»šm @@@@ÀÝH\¹Û£¿ àÑ:%žV5oÞÜ$ŒR+t ¨×^{-Êáõ¡¸Nßç,Æg; @¯_¿¾èK#®Û=üðâ/[èÃz ]WK_¶¨Q£†èKC§6l˜ÓþÙ¶‰ÎtðàÁN?Š|}ú0åWº*± Üwß}’'OS}U½zõ›ët‚º]l†¶ô~Ó«W/)^¼¸™þR§ÒôÔØ´i“¼ûÿ~3D­¦L®5ú<Õq!€@D•+Wšû' € €I+@â*i=i H”€N¨Ó.\¸Ð´£UW;vìvíÚI·nÝÕ6;'^€sxCZHóÖ­[öÆÆŽkÖ¸Ò*,]Û-º©/#]“`º†V®\¹D§øôĸvíšIXýðÆ÷øã{âp¤€À¹sçdôèѲ{÷n9rd ‘C € € ¶H\¹ñù3fŒ,^¼Xž}öY³Fƒ-tÊŠI“&™Q/¾ø¢Y HYË—/Ëž={Ì$W¯^MÙƒs4· ‹0­‚ЪˆåË—»ýØ$ÀÅ‹#$štM:]ã*88X´²H×¼?~¼Óƒ9ÞctZÒŸþÙl¯ÕG:­ ¿¿ÒtÒZ¹pá‚4kÖL®_¿¡7Z±¦Ó~òÉ'.ÐKº€î" Ó¯nÙ²EtjçbÅŠÉ_|aî½ € €$­‰«¤õLÑÖêÔ©#³gÏ6k4èK§¼Ù¼y³L™2ÅÞ¦M›¦hŸ8˜Èwß}'_~ù¥YW¤`Á‚’3gNñòò‚8 èµã,ôasä¤VœeCð"P¿ ¡•U‘ÃÇÇGêׯo’ÝZP @(÷ MâäÏŸßìêçç'Zyôè£ÊܹseΜ9Ò»wo±Òû©³û¦¾§ÿf"@ø¤OŸ^jÖ¬i¦XmÒ¤I|ve[@@ˆ‡‰«x`¹Ú¦=ôù?L:·º†~ûÏ1t¡uˆH97ÊçŸn¦tëÔ©“dÈ!åΑÒ(I) ¼úŠ)ÆãçU«V}9‹>}ú$ew]º-bY«®ôßL:•¢V¦íÛ·ÏÜ@@@pW®s.ÜpáÂ…öokÅN#H¤¬À‰'Ì‹/ž²æh% V/¾ø¢´hÑ"^ÉW@H—.Iéº:wîÜ‘éÓ§›„‘VŠ<ù䓿ño¿ý&k×®5Ûh•‡&“Ö¬YcîaZI¢¡ŸÿóÏ?ößõ=ŠS2ëBèZuÕ¾}{SEqïÞ= ”Ö­[G©BqtѪ0 íÒ¥Kfz4.­G¢]Úk×®‰®ýc«XÕµé´Oúypp°™J­gÏž’+W.Wঠ@ýwSÛ¶mMÅ•®EI € € € €€k ¸r­ó‘àÞèAZy¥ÓgéC`"å´JF#cÆŒ)pŽè1¥J•}¹ch¢jëÖ­fíï¿ÿÞLïW«V-“1b„èú]ÿûßÿD+D4Y¤ ¡¸N+¨méÔ„Ï=÷œ™:qÕªU¦jJXÚVL¡Éª3gÎH¡B…dïÞ½6½}û¶Ø*O&NœhÆ¡ýž2eŠôïß_ræÌiÞûæ›o¤_¿~îxzè38hź&°@@@p=H\é7ä8àzº)Ø#­ xúé§ÍBíZ!`[¿!»àR‡Òª€€—êAÀS´‚êÏ?ÿ4R½{÷– *˜¡îÚµKŽ?n_³K+–4A¤¡o½õ–©*Ó©»:õÙøñãåÖ­[¢Ój¥”³ÐµçôÞ˜={v8p ù9r”)SÆþVµjÕÌZZåÊ•3ý×µ4BCCíke%´Ïì‡ € € 8pø”ܼ˜°ÙË£òçÍ.óçH²1r­%¥Ç5”Ô×ÚÖyœJÒ% H@–´]á‰+MZ=ûì³IsUx@+:5WZY³fIíÚµÓ:ãG Eš6mjÖ¸úöÛo£$Í5‘•;wîýhÕª•Ô¯_ß$´^ýu5j”hòÝ1tjÁØBQ£G–;wÊgŸ}&:Mêƒ>e7ÞO“Ù1…®Ïe ÛÏaaa¦Šuøðá±u…Ï@@@ ™Þ¸DþÜy4™Bóî(ðB÷fÒ§Gó$ë:×Z’Qz\CI}­uïþEYÈ3>î-µª•HÓ0‘¸Ò3X½zuÑoÕÔ¬YH'žxB^}õU³Î”®S±bEY¾|¹tîÜÙôF§Óô÷÷7ëQézS;v”ÇËÅ‹Íïºæ”-t-«9¢~c.C† råÊ3U ¶£¿kå•NÑ©Ó:K\Å…bÛ¶mÒ¸qcÑdÕï¿ÿ.Í›77I+í¯V“Õ¨Q#ÂâÒ&Û € € €@Ò $õCã¤í­¥†@·¾Ÿ%Ëa¹Ö’…Õ­M®k…[_ÉÒùJ _I–vÝ­QI\¹<ýEp\@@@@ âŠ+@@@@@@À%¨¸r‰Ó@'@@@@ÜI`Û®cÒoè—’?o6ÓíÜ9ä³ñÏÇ8„ß7”zµË¤Ø0»¼0I¾šô‚x{GÿÝõ–šUŠ‹O:§ýz¶Ï$éúd#iÚ¨RŠõ›Eˆïµ*[vü%÷×(•b”‰¹Ö¦Ì\! Ú,Ù³f– à)Wº ¼>ø ñóóM±þs p=ßÿ¸I²d4¿7®_Aú>ß"ZžN]’ÐÐP)R(wŠž»pMÆ~´H>Ó5Á÷ÚYß­•ÿÛ$é¬ûbX˜È›¯´“ª•ЦHÿ9HÜH\ÅÝŠ-@@@@° 4®_^Æ 2N"wïÉÌoפhâjâ;]cLZiÇ¿üzµT­XÄiâêäéK&‘°dÙŸ$®ât–“o£ø\k»÷ý-l>”¢‰«Ä^k/to&m©e_ysެßt€k.ù.§[~ñ¹æös[–­Ü)åJH±ÄUÞÜY­DSû»Ó½væ7«eÓŸGdîçýÄ?s¹uû®¤KǤt±çÔøœÄUj¨sL@@@@xãÝy’#»¿\¼tCΞ»*­šW“Ç[Õ–É3~‘=ûÿ‘‡|!Ï´o úÀ·Fåb2wÁiÞ¸’ü²z—Ìûb 1Ñý¿9Û<\µÅËÿ”­;ŽšDÒõë·¥dñ|2 ×#æãÅK·Ê¼Åˆ———T³*½ÐÊüÜ­ïdY2wˆÕÞyýÝùr_þæAíµ·eÜ›ÏÈ«l×Þ¿eàðYR§FIéöTãçDÙö‘šòíÂ?äêµ[’ͪˆ!\GÀÙµÖ¢IùdÚ29sþª9vVÞÒ^>¾<^ך­Ý ¯›kñ©võå¡Â+îb»Öì×iP°Ü¼uWrZ´?¯ØãµfSÕj±»÷‚$O®¬®MOD+/kT-nî%Zõ4кǤ÷õ‘EV¥ÜÊ€L²ô·&‰ßsÐT©W«´Iv·¶î}g/\—aÚA}ï/ëšÔ}m¡íV¯RLΜ½"—¯Þ’×^j#Å‹æ5U\ïú£ì=pR‚­k¢s‡òÈCV{ÖñGŒ'Ó?ê%q½×Úª\ìòª¹ß¯—9ÿ&­´™3ùqv]T€Ä•‹žº… € € € àÚ«7ì“öÝ'˜NÖªVB^í߯üì—ÞWFë$AÖÃûvÝ&˜ÄUÏ®YÓ·•Iãž3Ûhâjý¦ƒòÍÔþæÿ;öœ#GÏš„ÔoëöH³Žg¬‡¶S'ô4o þ•èryr˜¤ÕŒ‰½ÍÔj#ÇÍ7m?Ü´jó¬dðK{éŠ2}b/9}æŠ,_µS:µ­çÚ'ă{Ÿk­K§F²Ýº6ñ½Ö2gô³®±'%Ъ|òù‰V¥`i¹|åfœ®µ“ÖôqzÝhòT§ÓÔëZ“1]k3æ®2I±ÿ\¶­jI¥ò…=ølºöÐ&}ñ‹Ì™¿ÎtR“Ù­›W—‹—o˜„”Þçö<)Ó笴î'Ïʃ +˜Ê¾÷—5ÛŸ±:í诛û_ûŠ&#õ÷ÛÚÝÒóÙ‡" ^Û} n9©f%ñ9-c'.–iö4÷§Ð0™5ùEs j¾jÅ¢âíÑ..÷ZÛW¯Ý6}Óª-Âõ¨ƒsýsD@@@@\P@§oûþËAæeKZi7µêIÃתHÐõ¥ôÁ­³x¬e û4U­¬‡ÃšpÒX¹vhåLä¨TùšHØi%»4ᥔmëµ´V›¬u«"G)+!¦I+ ]—ë’õÀ8¦Ø±û¸©°ÒÊ—&ÖÃéÿ-ßî‚g ít)¥¯µŠå Ü V2´t‰ürôÄù8_kU*1I+|zž‡ IDATÖµváòõXOTg”/?yA–Ï&çÎ_³Ûb݇ ’G@§ ´Ý×4i¥¡gS§ ¿d7¥Î"Ժ׵{´ŽùHï5*7ë­iòI×êPö¾»y[׉-IY¦d9uæ²ù\«K›6¬h¿Ô)+Ûw‹rȸÞkuGo³®áœ)=OGŽ‘FIçÎåé§Ÿ–!C†È•+Wbìíõë×¥cÇŽf›Õ«WËøñã£lÿé§ŸÊš5kÌû;wW¯º¨ÝB@@@ÜS }úˆ“éUÎ"³µÆŠ-41±îýfJ>PGŽ`ë}[‡'Ãß³}æe3GŒ(}ç}²í¥S¾²¦ïÒŠ²·Æ/”½ÿ‘¿O^tÏâÁ½N®k-äßëË\c!á×]\¯5?_ßâήÇèN‰&;lPÁª;îÁgÍý†¦Sznb Ý.c†ÿª7uªÔå¿í” Vui£ºáUYŽ¡÷FÛõ¥ïÛ’üN¯µ“¡ŽûÇõú×}4qhMC©•ƒ„ë ¸rásT±bE™3gŽ|ýõ×R¦Lùì³ÏÝÛgŸ}VêÖ­kÚÑv5ÙE € € € €$¯€Ÿ•̺|%ú*'}Ø[¢X^ù껵öõ„"÷hóö¿Lò@×Ѫ¬*VDMkÝ™uÈ]«¢Aã×U»¤fµâqLÆŒé£TOÜ»,kÿ8 g†W“-°þûœU£Ów®/ ×Ò…*êâr­ý¾å¨&R÷:%Å çI–kÍ™¦^çÅ‹äq}hzh’T.Eÿ|¹º5àak]«Uë÷ZU¤§/U>MŸÿ±%¼Btó¶#R¨`NóskúÁ_­uÿ4ôÞ¦ëñU©^ñ[Ät¯ÕéNGOX(wï™fô^]EllÇáóäˆ=Mš¼Ç§õ8 h²I«¥4Ž?.ãÆ³þÐÞ5åæƒ ’råÊEÛÒªU«dáÂ…òÁÈÔ©S¥jÕª¦\wëÖ­òî»ïJÑ¢EME € € € wÇu‡rç ÏÆ?íκ¶J#k-—./L’Ï4vº]«fÕeÐë³dñì—~^¬pnék­¤É„:ÕKŠ>ÖxòñzÒÕZFØê´[‘×·ŠiDÚÜoÖËjÞ¸²Y‡KcÝÆý¦:"k@&û®¬c<÷ÒçòB÷fqbË$ˆÏµV¥baùìË_äÿN•7_i— k-ÔZ_H÷¿vý¶ ìýˆdÎäg^I}­Ù:7å«ÖúYͺH:5aûÇîO2;ŠŸ€ãWëW¾Ï·ˆ¶V‘¡om’çºæUäÐgÐ5«3ëù•+]0ÊçzMéZ}sç¯7ÕP£†´7Û´¶*µt-­§{}b¦÷{¦})˜?‡œµÖù‹-"ßkµ‚ÏÝŸz@>™¶LÚt~_ü­ŠWÝö£1]¥@¾¨®±‡Ï“W€ÄUòú&Ië²xñb©U«–ioĈæU¶lY9uꔼôÒK¦zÊYlÞ¼Y¾ùæ™8q¢¤Oÿ_™fÓ¦Må»ï¾“¡C‡JáÂ,v˜$'ŠF@@@@ ÍhÒhÃÏoEï[C×q°Å"‡$Ô°Ûßw|˜j{³¤µUé¬uœ?D-Z(·¼3â©(ÇlûH-ÑWäX27ü‹ÊÚÞôzÙ?nn=lÖ—†&ËôåMU}9F®Y¬„Ú+iæüºÒ@ã{­éÃø™Ÿö±!ò5©Äv­µhRÙZ«-<1ê±]k¶¨aûá×¼³kM7îÕí!ó"R_ ºsa»—h³gË,³&¿h:«×‘VeÚÂq;Û{%‹ç¯hÖ–Òµ¯÷ieàZ¬1ô¥6QÞw¼—Åõ^ëØˆþÙøB+ó"\[€Ä• ŸŸ½{÷J·nݬE:ÓIÆ ¥K—.ríÚ5ÑD–&­4 ,(yòä‘'NHÞ¼y#ŒæäÉ“2|øpùöÛo%cÆðÅ7 @@@@×XôÓiópÄÿŽ=f©,× ½ri®5—>=Õ¹ÅK·Ê°mŽ)êj|5t“W‰ÀKî]+T¨ “'OŽp˜ààà8öÂ… Ò¬Y3™;w®ôïß?Îû±! € € € €@Ê œ>{EÞ;Oüý3ÈøQSîÀ)Í p­¥¹SžjÖé-§ÍþM×+oÖH#ˆ‰«øh¹À¶9sæ???Ù¿¿Y×êìÙ³rîÜ9)R¤ˆ©ÄrŒjժɀ¤W¯^²qãF¹ÿþˆsÃj–&·˜*ÐN,]@@@@4+ ë«LŸøßT~Î "OÁ–f±x¢âr­9›Z0Qeç4)и~yÑWLáljÁ4‰Å £¸rËb̘12vìXÑê+FpäÈ‘fýªÈ‰+šŒ=Zúöí+S¦L‰0ÚvíÚɸqãÌ´ƒ£FrC ºŒ € € € € €ž$@âÊEÏfÉ’%£LhëjÑ¢E£$¡ô³€€™7ožÙ¬qãÆæ¥‘?~Y°`ùùå—_¶¸Q£F¢/@@@@@poWè}@@@@@@@€Ä× € € € € € €€K¸r‰Ó@'@@@@@@XãŠkp3Õ«W˾}ûܬ×t7©Ο?/… Nêf=¢½Y³fI¦L™R+AûyâNCFΕœ9üåÕþm¦¡/áj; “gü"9²ûË“×Kõó≘ùé ž8¬éÿN•—_l-eJHÐþìäy¬q•Ìçôé§Ÿ–ëׯ'óQbnþæÍ›Ò½{÷dëúuë’­mF@¸ ,[¶Ì$­4Æ'7n܈ûÎl‰ € €8üuüœU t ’]àÏGåæ­Àd?pWîs®ÜSùàƒ¼L;É´iÓ’¥mE@¸ h’jÒ¤IöΞ=+ ,ˆ{l‰ € € €¸€S&ÁI?~¼¤OŸ^._¾,W¯^5kJè4=^^^öÖÍ{]»v•ºuëÊÔ©SeÛ¶m¢ëP´hÑB:uê$cÇŽ•’%KJ‡äÀòþûïË”)SääÉ“&ñ¤I"___>|¸äË—OŽ=j¾M­UúþôéÓ%]ºtQF¤_½{÷–yóæÉêÕ«eÅŠâããcŽ­ý}ë­·$W®\Ó8Z·n-ßÿ½dÈ>¿fÛ¶måÛo¿•Y³fÉ_ý%ƒ ’zõêIûöí“@”&@@ ¾3gÎ4ÿ&pŒ¯¾úÊü;#þüñmŽí@@@@T â*‰ØõAÑÈ‘#å£>’ .Èš5kì-‡††Ê°aÃDצѤÕÚµkÍôŸþ¹I6ýüóÏrüøq8p ,Z´ÈüüÞ{ïÉ믿nLcÆŒ‘#F˜í51ôé§Ÿš¶õs]„~Μ92cÆ §I+gÃÛ¿¿I~½óÎ;òÀÈÂ… í›Å4gmõìÙS²fÍ*&L i•D×Í € _ÇGøûÜqý’  € € €¸‹WIt¦ªV­jo©Aƒf}‰Æ›÷´rª`Á‚Ò¼ysóû¦M›dÇŽÒ·o_ó{pp°œ;wNŠ-j\Ï=÷œhBH××Áƒåí·ß6ÛjÌÏÏO®]»&wïÞ•J•*™÷«»bR•*UL…–†~[+·lÓ8bk—Ï@@ uô /¶Èœ9³dË–MN:eÞÒs¬_¿^ôß' € € € àê$®’è é4~¶ÐŸI¥K—–%K–HçÎ%oÞ¼&O>ù¤<úè£QŽ~éÒ%ÑNZµ¥¡‰*­h²UYÙvÐ)þ¶éþlûkûîl‘cš4sZÉuìØ1¹ï¾ûÌ4† €$¯ÀöíÛÍ´½úo ­ÌΔ)“™ÊW§%Ö5®–/_n¦û%q•¼çÖ@@@@ i˜*0ieÆ &!¥‰¦_ýU´ªÉ?þ¸YßJ§ö ‘:uêÈâÅ‹E×½Ò¸uë–©ººråŠ|öÙg¢ëQìÛ·Ï|CZ¿1 ëÖ­³·§ÕVú¾N#¨Óþih»ºïæÍ›<ªèÆ‘3gN9þ¼i÷ĉrñâEû14‰e‹Nkøõ×_“´Jð`G@â'P­Z53e¯®=©‰«Œ3šJ•*%C‡5/¿úê«ñk”­@@@@T â*‰àsçÎm¦þÓ©ýªW¯.5ŠÐrÆ eË–-fM«^½z™u¬tJÀtéҙפI“Ìt€½{÷–ìÙ³›$×àÁƒÍöš Òê¥iÓ¦™U›6m¤cÇŽ2jÔ(?~¼Øª½t+ýVuË–-<ªèÆ¡}=z´(PÀ|“»dÉ’öctèÐAžþy³ø{—.]|lvD@„ hò*ºÐi @@@@À]H\%Ñ™ÒdÕ+¯¼¥5ý–³- dÿ¹k×®¢/ÇÐoKÛB2ÙöÕµ®&Ož¥í2eʘd–-´r+_¾|fª ÇЊ­yóæ™·tÝ-ÛÚ[ú»&Ø“lÑ£nݺ¢/g¡É6} € € € €I+0yÆ/2gþ:éÒ±‘<Ö²†½ñSg.›÷/Ý*/tof>'HŒÀ¢Ÿ·ÈÖGÍuV»ú_Z×67o;b]oë¥ÍÃ5¥i£Š‰9 û" ïN\,Y2™k­`þv‘ë7n˪u{eñ²­òј®%ZiT€ÄUxÇu¢’¨Éx7£kc9&¿âÝ€µƒ+Œ#!ýf@@@@ÀjV-.Ÿ}ù«hK_µª—«×nIËŽcííYµ„'1¥°À›wL"T_zùgÎ —®Ü”Þ‘Óg¯˜ÞhâŠ@ ±œ6IR½§é5U²X>ùyÅYºb»Ü¸h®="m ¸JÛçŸÑ#€ € € €¸°€V¾<Ø ¼¬Z¿ÏôrËö£zûLûR®tA]s­ÚÓ¤ÕÁ#g¢\g:M¢Rmå.gÓµû9¤ßcÒñ¹L'õšÓØ蔽Ó}z4£ÚʵOa²÷Î;ÙðòË/ËC=äö#õ”q¸ý‰` € € € €€ƒÀ«ýÛ8­@ÈŸ7›è^¤ЄBt1zX§¤: í¤qM¶ë§Î¢LÉüL}šÆ¯>‰+.@@@@\X@×€q– Ò„kÀ¸ð‰sîi…ŸãZj¶!h’Áq-"7]v1Έ&ß#ÇÛ¯‘ u±S•*Ý!q•*ì@@@@¸ è4nZ‰` ¦m‹»[ÆOàÕþE¨ð ¯ìk¿FØX4é®ÉwÇ`êS.›‰+®@@@@Ü@Àq7¦msƒæ¦]Ô„‚c…ך›žH7è¶®™¦Ix ÿ̘úÔ ÎYJuÑ'¥ÄqÜGàé§Ÿ–É“'‹ôë×O¾üòËwþøñãòúë¯Kúôé¥C‡²xñbùì³ÏdõêÕ²yóf2dH‚ÛfG@@@HûC3ʰtic°1²JUéôb{ÉäŸQNå),§B )àuOîó¾D h…ßÊu{¥@¾ì¢Ó$—€&FÛwÿPô¿L}š\Êî×.‰+÷;g)Öcùàƒu¼eË–I»ví¤mÛ¶¦Úµk'ª=vF@@@ - „……¥¥áF;ÖÑ·òËæ,X¨@ËÆá³p¨@¿ g¥†sI†‘nÂLñ>r"ÉÚsç†æhçOé3ʇ‘d}yä mÝ8ÉÚÚ& ÉÚrï†ä¿‰ò«õZ_„HÏ1·¥xÅà4MAâêßÓߣGS4{öl“diÕª•¼ûî»réÒ%¹{÷®ôéÓGjÔ¨!ãÇ—téÒÉõë×åüùóR«V-¹s玜;wNþùçS¡TµjU •aÆÉ7äæÍ›Ò¾}{yôÑGM¥ÑÊ•+Mº_pp°Œ1B²eËfÚÖʤ˗/ËÕ«W¥H‘"2`Àñòò­\Ò$RPPøúúÊðáÃ%_¾|fŸ%J˜J¦âÅ‹Ëo¼a¿ c:–öé½÷Þ“Ó§OKHHˆ»ŽÙ1tŒ½{÷–yóæ™·§N*kÖ¬ýGs§Ndþüù2zôh)Z´¨ùü­·Þ’ºuëJ³fÍÌïëÖ­“_ýU2eÊ$‡–îÝ»›ñÌ™cþڳǭ[·œZ§é?™ @@@K@Ÿ "š¾ëm¯¼t}/ØžÊó H†¤ “°ÒE%¬\øôe*àµ~{²@<úÈ?R ÿídi›FÝS`ÊeܳãIÜkWÿ‚Ê¡C‡ä믿6ï|ôÑGÒ´iSiܸ±I$iÒE“Cºí›o¾i’N-Z´0É¢^xAöíÛg’;º¯···¼ýöÛ&ɤÛ?óÌ3&q¥qàÀùæ›oLòjÁ‚2}útyùå—ÍgšP9r¤ùY_š(Ò>Œ3Æ$‰òæÍkÞûôÓOÍï?ÿü³™~/cÆŒQ.èŽ5eÊ©P¡‚é£ö¯W¯^R®\9“üršÓ¶fÍšeú­É+ÝO+ª4¹¥ µ­[·F˜ú¯aÆò矚vÕéâÅ‹NÛž6mZ´ÖI|½Ó € € € €1 „eñ—°yPBÀ.àåç›,š´*Qìz²´M£¸³‰+‡³§SÚÙbÓ¦M&‘õý÷ß›·t½'M*iT©RÅþ^Μ9¥råÊæ÷ÇŸËþµrÉú³~¦‰ª¬Y³š*+g‘9sæh¯ØèŽåxÇ~G׳9µuzCMtirmÅŠ&±•ˆÉ:!í± € € € € €¸§€·{v;ù{]»vmûÚNz´k×®Åë .\°¯ýôûï¿›iõl¡Óçݾ>w©Nµg«àÒß7lØ`’_š¬Òõ¡ô3M˜5£l]túÂåË—Çz,]‹jéÒ¥f;]ÃkíÚµ¦²+ºÐÏ~ùåÓ/ [BL«½4iµwï^³ÞWB"±Ö 9&û € à©:u°þÝN € € € €î(@ÅU4gM×|?~¼tîÜÙlQªT)³®U\£K—.f]3K+¯Ê—/oßU1b„I†i%•®‘e‹Ü¹sKß¾}åúõë&‘Ô¨Q#ó‘®q5nÜ8Ñõ 4iÔ¦M騱c”î蔄ºÆ–Ný§ݱtM.m³G¦ªë‰'ž0cŒ.t½¯ýû÷˳Ï>kÖïzüñÇÍK§:œ0a‚Y‡KßOH$Ö:!Çd@,aaaîÓazŠ € € € € €8x£ € € € € € €€+Pqå g> €),ðñÇËäÉ“Sø¨HÉ;·T­Z5i¤¶nÝ*þþþ˜ €¤+7¥Á9Ò§UuyÑziŒþîwùní>Ù=éy9yñºtzo±Œëþ Ô/_Š©ÌZ¹[Þû~“¬û´äΚ)NÇÕ~³fŸìü¼ÙÞ6¶~­«KïGÂÇ–˜X½ë„¼øù¯òå€G$KÆôÒ~ì"y¿ÇƒòH͉i–}@ˆ"@⊋H£+V”~ýú¥ÑÑ3lpg³gÏÊçŸ.Ÿ|ò‰¤OŸÞ‡Bß]HàÌ™32räHñöfR :-tHuœ™ä™Æ¤Dþl©Ò/¯T9¬óƒºTg\È%Ž]!IG(k³÷l’™¿í6IØýÿ\$Iw:³åòmGeÐô•&É\»tó^›·¿—\Öýì‹—1Éù#§/ËÌ­ãÙrâ6ïûù/rüÜ5ùß›âÔPlfâÔH,uûƒdÊà+_Yñí_RŸ6¢ qÅÕ¤Q€€©Q£F=ÃFw8vì˜é~µjÕ$C† î<úîBG5½ u¡^Ñ@”¼l*”4îZ?Ûâø¹«2é§mR4oV«²È_¾^½WfüºKnÞ“Få É[]IÆô>RgðW2¨mmùá÷C’7fŒg– IDAT{&Þ±žyp|ðä%ñKŸNº6©$ÿײªý!ü§½›ÉÛßnÍ*KÓªEeÔÜõò瑳’Ùz€:æÙìÇ×cýðÇ!É™%£Œ·ª›þ¾pCNÿM>x®‰´¬Qܪ°Úk=„þCæ¾ü¨}Û8®þ;Û³Wî‘™+vË•[R2¶ëR8OVó°6$4LîË®cç¥J±¼òQχLU•& ^™±J®Ýº++ŽötÄÕebÏf)uJÝî8$Iÿ;eäH“÷òÕ?Ë•ŠäJÞƒ¸Ië^\l.{¦H\¹ì©¡c € € € €)!  "}ÙÂÛI¥Óîãçe̼?d@›šR±pnôÅo2ÛšÒ¯gËjf·ÑV"ªEõâÒ¬ZQÉ“-³´¯_ÆJ ÈO[þ’–l•ÖuJÚÛ:sµX}­!%óg7Ï]½mÈ6·c¿íËšn4ºä·¿uù⥇M²é‹_¬ä¾¯´«c*¨#»¿Ÿôm]ÃTQiâJ“äUŠ•—"Vuêª]'¤`Î,VÒ=È$·ïyξXpýö]ÑJ=f58$|ºïsV{¶¨W® èKï³óÖ0I«=Vu«ÆÀ¶µLß~Þ^©9¢s©bU‰i8~ùÀi¼™hW‰&¤@@@@O¸ßšîÊJ5É·VeA»z¥å¨õp½Dþl’ÃZ*rYQu ªcÖY ?-&…ô[ÿ˶3ÿÕµ¤ª0]h5D¾ì™­äÕA)dMýWÁÚ/¶°=Ô½iUZéƒZí—¶ShßÊÊ%¿X}ÓJ‹èðÆÇ%¶~¦…ÏI’FM’Ú¥Þÿ®7ä§uzI$ÛŸ” Ö:VµKë}K¬)B§4¡^ÀJ¤9 •½Ñ¬™ýì÷-[B>¶ä·®Ãg«Šnú==@¶ÌákÛá¶sߺVI«²j§5i€dJ/õ¬/èýJ#º?3šŒZnÝŸf j-Ŭu¾:×$©láŸ!ⵦ•²aÖÿ4lךöÃY£sùçÂu³¿íËq;l•ÿ¾®‘½Ù@@@@4   ¢·;7’mÖ·ûŸ›¸T¦[U¶‡Ÿ‘‡ÿ\³*¦¢éå/V™J®ô>Ñ?‚Óµ¥ŠåÉjÖ]™k­ó’áßé±¢#Õ„’N­vñúU 1‚ªÖ^V.lM9¸Wl8h¦ÿ‹K íp¿h%Å{ßo”šVòJûFŽø¸Äå˜l#☠íÝÜéér|_«û§S„-ÕѾŸ®_c‹„¸¸ûõ’Rý·%§ZÓ;þ¼å/ÉkUǽ׭±ÓÃk’T×ÛÑ$é³M*š5Ó¢ M’ê”qš$Íž%ƒÔ1U„ч-Iú…5-åÿµ_“-¶pL’êšGš$={å¿õÔœí`M‰ùZǺòá¢-2Ùš>®™µŽÛ4]K.RÄÇ%¶~òy¸ÀÓÖúUz~tz?­p*‘/›<iÝ<Ý.>çU¯«>­ªËwk÷ÉoVBLí±Ea+y¯Óði"I×­ŠK躀Zq5ÊZ—둚ťrÑØ«OuêÀõËÈÒ?šu›T."®ÿ7½ í¸qu‰K?Ù&a^‡ŽúU‰8´•3gNññ!ïªÛ¤OŸ>R²dI4hPŠ“ý'°wï^éÛ·¯¬Zµ \^àã?–;vˆþ—@ÜMàØ±cÒ¿™?¾dÈ>Õ@bŽ=*;v”­[·Š¿ÿk”$¶]öGl—.]’àààxƒøù†Šº⽟î.cñJuÑù5–À®\¹"uëÖ•%s_‘b…cž/¦C<}£¸Ô½ü·¼t}o{â»Ý ¼'k÷ücîë4Xß¾ÚÆ3–ÀQ<•çA©“5Dúg8—À¢î–n—"Y$¬vÅ$kÓÒŠ™W¿\- ^kk­—”ˇ’¨¾{/^)¡UËIhëÆ‰jÇqç¡m¤×s¥D±ðéçÒz¼9w©üZóî3ÖõKk.CFÔ’žcnKñŠñÿ7ƒÍê݉‹åêí,òÁ„S/øÆ.‘ÐÀx÷ƒÌS¼ÉØ@@@@”Ð5°FÌ^+:µÚØ®¤|8bš°%I?ÿy»T²¦ÉLËI«4sÒSi ºnÔ¦C§­)"X`ÒtÒ*•NK–Ä•Kž:… € € € €@D‚9³È¶‰ÝaA ÙH’&;1øW`ûÑs2vÞÒ°B!yÁšb@@H\q € € € € €ØH’r1¤”ÀcuJ‰¾¼á@@@@@@@ÀH\¹ÂY  € € € € € €B⊋@@@@@@À%H\¹Äi  € € € € € €$®¸@@@@@@\B€Ä•Kœ: € € € € € @âŠk@@@@@@À%H\¹Äi  € €1 lܸQnÞ¼ét“sçÎÉ_ý  € € €x€€Œ! € €x°€&¬ÆŒcFØ´iSiРùyÏž=²~ýzùí·ß$sæÌòí·ßz°CC@@@ m¸Jç™Q"€ €n+àïï/>ú¨üøã&I¥¯lٲɨQ£ìczê©§Üv|t@@@þ`ª@®@@—xúé§MU•-®^½jÿ¹X±bÒ¦M—D@@@ØH\ÅnÄ €ñ7nœ4jÔHöîÝÏ=Ù<­ h•ŒVÒ¦µ¡3Þx hÕÕóÏ?ït¯èÞç!Ø@@@\@€Ä• œº€xšÀõë×åÎ;r÷îÝÚš5kdÉ’%)~\°°0ÑkE×/ ì‘æzè!©X±b„qëšW•+WNs @@@O qå©g–q!€©(ðÖ[oÉêÕ«¥zõê)Ú M~hµ×ÿþ÷¿=.K˜€———üðòtéRÉ”)SÂa¯4'àX]¥SRm•æ.Œ € € àá$®<ü3<@ 5 $<ð€¬]»Ö~Ö¬YR³fMY¹r¥ôêÕKêÕ«'?þ¸hu”-  µjÕ’U«VI»víÌ649[·n5môìÙÓ¾ÏôéÓÍ{S§N5Û½ôÒKrîÜ9Ù¶m›y¿sçÎN‡oëÏ_ý%šd«_¿¾i_cùòåöãk?æÏŸ¡ZݱgÏû6Ï=÷œüóÏ?2fÌiذ¡´lÙRæÎkßGûõÎ;ï˜éðô8=ö˜ñ°…­/óæÍ³ÛèZ>´oÓ Ayæ™gä»ï¾“fÍš™i?þøcÑŠ%[ÄÔï˜Æë¤Urê¡Çо>ù䓲páÂ8CÏ_‹-äÒ¥K¢&º¿öIÏCŸ>}ìmœ9sƼ§ŽAAAÒ¤IÑñݾ}Ûl"Ÿþ¹´jÕJêÖ­kÖ-Z´h‘ùL×46l˜¹¶t½ÎNŸ>—8ÇLE%JˆN1©¡I+B@@@@Ïðñœ¡0@W2dˆ©ÂÒ„„&­FŒaª£²fÍjº®‰}¯yóææ÷uëÖ™$†î[hâÃÇ'ü¯µœ9sšÄIbÜmèСrìØ1³>üÞ¹s§ >\rçÎ-mÛ¶•-[¶È{ï½'&)£qíÚ5yá…¤qãÆ&É¢ûèCô¼yóš„ʲeËäÃ?4Ç/Z´¨hEˆ&Ò *ds:nM:+VÌ$ºl¡•bº¥J•äÏ?ÿ”7ÞxÃ$ªl¡‰,M@é>šÜÓŸË—//:uZ\ú­íDodœÙ³g›i‹/núº{÷n{â0.ÇФUïÞ½©VPéxô¿:~Mà©£-Y© +__ß(çgæÌ™¢ É 2HÙ²eåøñãöóª×ÆÆM»z¾´RëÔ©S&Qh;÷±]'žðùøñãåðáÞ0”DA¯'M,;&—Õ ›î\ªT)yùå—Ý´÷t@@@¨$®<èªH—.yˆš˜øå—_ÌCbn+88X,>ø lß¾Ý<€Íž={¼›×‡”ú‘@Ê”)#S¦L"N«yŽ9"Zõä8¥ VPtïÞ]NžúÈT¥OŸÞ$v4^|ñEóž&д??ýô“=q¥Ÿ?ñĦlÓ¦MfÛ{÷î™qåÊ•ËTirEMzßÔ{³VSy{{›{«öí“O>‘;vDH\Õ©SG>øà³Ž[]8`’7¶ÐÊ­*Uª˜×Ûo¿ml4qe«ðŠ­ß‘ÇÙçòåËæ-]CH“…~~~flq=†öÿ›o¾1ãTSMði?7lØ ?ü°=q¥•iÎâ믿6oúé§RµjU³Nš^/z­¨kéÒ¥å•W^1Ûh²R“xêyÝ£ØÎ½;~èÐ!ÉŸ?¬‰Yw#}›€&¹õz @@@ð$Wt6õ!–NW•ÐÐD•>]¼x±äÈ‘Ã<,Ôiœ4ôðú6¾‰+ýFø?þHâ*¡'…ýð0­Ò$„†V(i2âæÍ›F©•H÷ÝwŸ©ªÑϵÊ)¡¡É%JÐ:¥Ÿ-4é£ÓÔÙÂV}5räȇÓéíCÇ¡‘'Oó_­ÐÒ¤•ã{:ížÆÙ³gåý÷ß7‰*ÇqØ>·µk·&¸J–,i¦ÀÓã:&®lÛhPÃÖ¯¸öÛq¼š`Ó/'ØâÙgŸ•Ö­[›{¶V]é—´’Lß׈ë1t{­±…VªiâJ×<Ó*4­¾R·jÕªE0Õ_4q¦FzÞ5i¥¡É3ÇãëCzí§c¨CZJ\éØuÊÉ5jD1ä´%  sM^ € € €x’‰+:› 4úMyÛÔMú°P_ºF‰>øÕuOô¡é믿.“&M2J4¹¥Pm• ZA¡k³Ì˜1ÃT è7ì5y¥ë›èš'ZA ‰1}p­ßÄ×é ô!-iC@§Í³…-yäz_±…-y®÷&[8®ë9éåLQ4:Í-í?GNÆÛŽ£=úe[ètwŽ‘1cFó«m :­]t1zôhS)ôÈ#˜u½Ö¯_/_}õU”Ím•Mú­zV¿Pà¶m"×~;ŽWÛvt¹pá‚ù’VVi¢ï×_5¸W®\1N 9†ö]+Ét*ÈßÿÝTÃißµ’ÍÙ½ß6^g×†íø•+W–nݺEpqLî¥?IŒ@@@ð\WtnõAàØ±cMRI§‹Šoèú  0ÓwéKOY²d1ÓcÍ™3Ç$§tÚ+ž={Ú\úíú¿ÿþ[ .,ú@X§lZ´h‘Ù®\¹rfí ]CC×'ÑÄ•&­ô4I«øž%¶GÀó´"Gï;»ví2Éq½iâÖôÒ54ù¡ ,]ƒÊ1lëݺuËþ¶¶e[ŸÊöæÚµkB–(QBNœ8aŽÛ¨Q#³&Lt}¦„†V•iè½R+©V¬Xá´)M"iâF“S{öì1ÛèºXŽ¡_Ð±ØÆmûêšYÚ M¤iÕjäèØ±£—®…¥kh5*BUªÞoµ-›VŸi2Ç–¸Jª~ëßÛ·o—¥K—šŠ¦&MšÈË/¿lºš˜cèzVº¦™þ]µ•Í£ÿþ¦JkåÊ•²wï^“ì³}ÁBÏ¿&õ3í£VZé5B € € € €xމ+Ï9—ö‘è‚ö¶Eíã3<æO«lÓqéÆ0®[·®™RŒ@÷H«ÿÇ<.ãŽË6îwÆé1 € €@Lú% "\`“_ù8+ü'p*]&ë—INâ¥Ï®N‡OŸN `¬iý“#ΜÍhÍö–MÓ&n-@âÊ­O_Òv^U?üðƒÓFuÝ+}ÙB§ús‹-Šð¶N÷举Lýúõ“¶Ó‘ZÓJÂP«À’ûXÉ:G@@@»@yŸ»²/sFù=sT,ã¡äŽxKv¯`ñ÷  I“.$L z%ñؽÄëàqó"Hn%?NîCÐ>n)@âÊ-OŽI@§î:zô(‰+.ÜL º©Ýlñîn\¦ñ‹<^¼Â € €¸¹ÀˆŒ§Ü|I×ý©wóÈÏA>r ØOn†¥+w#aÞ"Ž¥lº;¢I¾2Þw¤¬ÏÉï•ÔI¤‡«¶2¨[MŠx]¾&Þ[v‹×Æâuî’„U-'r'ÐJâ“°ü¹%¬QM ­^A²dvÕÓçVýzwñu·êob:{圷œýÛ[ÎK'§yËßÓɵ‹ZQl%Š­ÿtz[*Ô NÌ!Ø×ÃH\yØ e8"¹sç–“'OB € € €xœÀ+aU3ÝMùÎÿ°¬ ywsÈïAþrÆËJbY±/$“œÍ+AÖá«"«¬w ”ó ”2VR«\º@)mý7½f»4mxO¼¶ïµ’U;Åûð È"¡µ*JØÿU•t?®”°y$ä±&áŸÿoµøÌ[.¡JHØýU$¬R óåñ2ÒAwEΞH'gŽ[ ª£V¢ê„·œ³~¼í%é|Ã$³µ²ËÝÛÖ+ÐKrß"µ ’šÖ+s÷$®£ˆÜY¸"Ö~V–•Ä -iMyÇš|iç²Fzå¼UEe%¥Î÷6UTZMuù¬—õ\ÖK²æ •|EC¤P©PÉs_˜œ:bms4õY¨Ôn$ušK®‚isŠÓ4u‘$b°$®Ç® € € € €¤¶@«²ª‡ßyó:šQÞÍ&?ÞË.çïúJm«:ë ¿+2)ýU ´æÜ’QY¯}VåÖb+Ñ5!8¯[‰‹ìbUgYÓ –5Ó Þµª´îHI«ZË—ê¬Ô>½Iv|¯ÓçÅ{Ã6ñÒéoÝ6IªÐΚ)ÃÒ‡WìÅ>éÌTb½BnÞï­{“XΕê IDATïìZ»²„5¨!a9³ÅÖŸ»‘@LUT>éÃ$oáPÉo%©êµ¾'Š…X‰ªP9¾?l[å+üÏW¼­ DźAòp×»R²²•4õ¦ºÊNªu•ÄUªÑs`@@@@’V ´•xšñŽ ÉxVÖg‘îf—7n”Q· H ßëò¸•ÄêæwA¼ýÂ딂G­*­VÂK“YZµ$(»©Îò±ª#JXI,Mf™µ³ÌtƒwDeDêx<+ÞK׊—5E_X€¿„<Ñ<ÆŽx]»i%˜tÝ*«ºêÔ9 +˜WšחZV’)«ÂáŸIB×±^¡ÖzXº.–÷æ]âµ|½©Î »¿ª•ä*/a™3&üì™âq©¢*VÞJRµ ’V²*W0{"êÄðdÕ®õ¾xKL’ª}ÿ»ÖÚUA’þßûMŠˆº­‰+·=ut@@@@çÖäoÒØçºyÝ”t²Ìš*P“X]o—<Ö´€¦¿"¬$V1«ªJRújãû_[:ý cu–NCø×¿ÕY9­ýËé~VUV¹[%­é­‰À8É-#«„t{BÄJ\¥ûj‘ȵ"Y­…ƒÂëžµnÕŽð)ý¼•°,ÿ®[Õý ³fURGXޜ֦©„Zkayù;ɇ–É¡‚ITéÿ›ÌÑëìEñ^¶N¼¶ï7Ç5ëVõu¾nU\:Z£¢ˆUYç°Ö¸ -UDÄzy=ùˆÕ}áS NýN¼­~ë4ƒº–VdÙ"¬Ne }°ŽùÕç£Y$®âŒ÷ ¯]´ÖŸ²Ö¢:k[‹Êúùâik-ªÐÿ֢ЮŠÊÙQîÞñ’Ý|åÏU>rlIPi¢ªF“`ÉW„u«â~fØ2&W\ €).Э[7éܹ³<ôÐCŽ=nÜ8É!ƒôïß?YûôË/¿Èܹs嫯¾’&MšÈ'Ÿ|"*Xót'CèÃSÑ¢EMë—/_–æÍ›ËúõëeĈR©R%éÚµk2™&@H:AƒI™2e’®AZB …<(&LHá£r8\S ¿U-Õ+Ã9óÚ’ÙJŠª‹âgKz(BBï%ôŠ(‚ŠŠRÄ‚ ØÅŠŠb¯¨XðÃÞEAAQ°PEÞKzMÏ–ïž·lØ„@Bê&¹÷÷7Éμyï?KÌî™snÞ XvÄa¡p‡g b™¿8z ®Á½sß·êTS8| Ö_þ€óšKó4I7£ ¥çds+´Ò‰µ`)\U"àéyߟ!ÄÑ9¨Uç öñƒè¢ŠßNÊ&n*ŠUVóur‚6A[µ–Óô êt‘§UÙ»¨²›ƒâ°ñ»‰\½È«ÕÓ·ê–ç’´oU~.š{JúÛ@_J@ (%PæDDD B… fÝüš[aÕøñãPå®x^ YÜ ûÜ…µ&W (% ÊŠVíÛ·/{ ×+% J96¶D´ MÄ“ˆ•~XåM”à°„:µ¸Ð[¢û÷É©ªZÓÁ‚—×%^ löqgý'î¬ÉÇÝYAp¡]YâÐjÀGº´’QÎÇñ•Ó9³>¿[ijQ)Õ,}¹¤«^Ý…òùïLÏ_¤û‹Ècûñ78$úÏ·Üý.€+ø¸=.Ÿ²þ¼î¦ ò9ŠçpöÒrö< Í·×ôÃò-Û„`Ù¼#Ç~]2™b$%Ñ‚é‰TkAz*põ}É<¡7æiF§sQÑUM"ûj7v¢óEé¨&"Ud ¬''5æxîØMž¾U+Ø‘xÄŠz-èG*ZuÓ¾U9ÂÓòE@…«|áÓƒ•€PJ °;v /½ôV¯^ ‡ÃŠ+âÅ_4Snõ;v,fÍš…ôôt´iÓ?ü0ÊIn÷þýûqÇw`Ĉxå•W:¬|‹cy…+~ÍíTÇ>|o¼ñ–-[—Ë…Ö­[ãÁDåʕͧ{žN®ßÿ«V­ÂG}„Ûn» çŸ>j×®mŽõž»°ê¸J@ (% ”€PJ@ (Ü »êû³qÛð}ºD ¦VÄÄÔ*h â¬>AA·Vn‹cfçÎÚíÄ:qeÑ¡µÖ„ )•°ÍE)Ë‚hKšÒ•EW{g‰˜Å\–\œ4ÒêÀ¨Ñíû´ÌN/«D|+5凚UHH< í›áx™ˆ„™E*w‰VdæºúRq\ýYàøØcË}ÅE™Æu¾Üô¸²½;ÎFò~9˺ |E<`P¨—Þ˜ŠðŠn逵KÐZ„ŸÜÔ™º¨ª×w!$<nFö­Z1?ËæÙ±/Ö&¢—ÝúÈg/ÝÓP±JþÆÎÍšu%@*\éë@ (% ü’À¤I“Œ+iÚ´i°H¼ÄöíÛQ£F 3×/¿ü‹-ÝL¡¡¡xá…LôË3ϳmÇ”°Ò*áµ”ht²%à é‡u‰Y¡"5å¥(NE¤¡;2»³6ˆ#k­D RÔZî“sVÆQ·!ÒŸ«±ˆWMDÐj&.­&"fQÜbß.ß²‰Pnñ|¨¾AÆ9;@¢çJAY¶î2}¢Ð.®&õ==¬ö„}ât³:ç¹ánѸä®Ô-×LÞ[ÃfƒE6ßIº³(t•ôâò(Z±Ø*¬böÿvŠÊE•Ï´T V.8Ñ·*¬‚ô­êæÀÀá)¨ÑPûV•ô×`Iœ¿ W%ñªéœ•€Pe€@Æ 1cÆ ,^¼;v4â‘·¦OŸn\OáááæGƒÆM7Ý”!\¥¦¦š>Y‘‘‘Ù’¢ æíÓѪ•dn¯¬ÇíÛ·ÏœôèѰÛ=ÿ˼õÖ[AQ}èº:Ýó§jfO×ËwMeà’ê•€PJ@ (% ”€(aèv²ȶÔn¢_^XÏÉÖëx?¬.vóåÏ…AwV [’Ù¤¹VFeugO­œáΪaMCqfݲEPc-HǸ´ª°I\à°àÝ%Œö‰éZûAaÑ¿°8$‚U=¸¢=ïo]}zš­´”mâ Eòó$áÄÕ¢!àã³¾ÿ,v‘$»´K6F–äJ‘ÔÍ Ëm¸þñ´Œel_gǬ/OÛ‹ª \TÙqs»,Ø´’Q€vü÷W/šwvàÆ§’Ш­ƒZ¢–(6*\z=±PJ tX·nw¼õÛo¿É;™ÿÚ¡sÉÍ»ª²ÆçXçwžq\Ñ9ňÀk¯½×\sy~÷îÝ&Ð+&qÿ€€$&z²×é„òö”:ÊY£s‹q~œ‡·¸–¨¨(ìÙ³GÄ&šçO%\Éœt_% ”€PJ@ (% ”@V©îÜ„ç7ž­£TÜž‘~X³Ó*ëÆ„zˆ´8p¹Dò]#N, ]Y¹qgÅIo+ËÙ- ÜæK¿®S«Šxµ§ §S¨cYRÒ`]²Êô¢ËÊ]# îs;ÀѱU‰lNÎÄž¢\w^ ëËa™ûìÓçÇ™»Kk¸Û4…;ÐGá,Ô+S0ƒ»Ä´4õ½\zS*ìò’õŬ‚èEu&³Ü½ÝfbWü€c‡<}«úÞžŠ–]Ó’?úLæ¡û*ÓPáJ_J@ (%P (Ö,\¸ð´cFGG#..î¤}vî܉ .¸ ãç;w7îËx>ö°b¼…£§Ÿ~Úô¶ÊZÉÉÉF¸ÊKe=®jÕª¦‡UJJJ†xÅþZ­8ŠV§{>/sÐc”€PJ@ ”V?þ¸q,ó¦ß›OJëzu]J@ (Â$PÔ•ïZتUÜö¹ðƒô“š’VcE(j)Ž©þ"`õ 8FFÊÅÛ½ï+·ç¦HÖìô Ò÷ª"blž¾YŒ¬iIÍUï¬ÂXCƘ¢fXÖl1î*ËÊu{¸:¶€[Ĝґ—_vdà¼êb`À…°®ÚhD=ÛÓáž0î¶1FÄr5®çéýUÌ%­¹±w‡ñÛìˆßj‘Gš‰{©ëewÕÌOƒÑ±W¢jgŽ lÒÞn…]¨(T-wÕn™[¥(:_œŽhߪÂf¯ãç€ Wyã¦G)% ”@>ôíÛ×ô†jÑ¢Ú¶m‹ôôt ¸aÃŒ9ÒŒ¼mÛ6T©RÅÄR@¢PÄ(?ï½÷0jÔ(³Í“ËêtË ÀÖ¡C¼óÎ;xàŒÛëÓO?5½¶7nl¾?Ýó»R¥Jظqc¦Wù@§‡*% ”€ð¢~þùgÓ{27.cº¡yƒ‰×‰Íþ•sçÎÅÍ7ߜ횎=jzXò†þ¿ž1ÂC† 1®ì¼Ç7núõëgþ¶ÐRJ@ (üˆ´¤ãæ ½fÛà Á¤ÔJx'¹*^’(ÁóG žk?*é…ïâX,Q†Ÿ¥TA˜Õ)¢• O…ìÊX`c¬ªŠ3ìéõ©ì“ ­0ö΢ˆ%Ï5!‹½³bd Îcï®3¡iÝoúVYÄa…Ôt#¸îd"ýA„ñ®ÅuáÙg²¬ÂÛ×jƒ«uS@6Wb2¬Ë×ËúÖ°–/W'ûº¶‡;ªráÍÁgäc­ˆñg·TžG+öÅÊE“*áBµºNÔ”¾P [{ÄÛ+lXõ‡v[°ð{ ns'º_q".0/“>rÀ‚Ï_E¿¡É¨Ý4ûžYì[µZ"—‹»Š‘€Áa@«sÒÑÿÎÔiZ8Âr^Ö¢Ç(ì¨p¥¯ % ”€(rtQñî7ß|ÓNŒùkÖ¬>øà#ô°xGöý÷ß/ø¹Ì\]ºtÁå—{"®»î:ó8tèP;vÌܵÍçî¼óÎ_ c _{í5ôîÝÛ̳yóææ{o¤aNϳ÷Eº™3gâ¶Ûn3ãh)% ”€(‹n¿ýöLËþî»ï0eÊ”S Wo¿ý¶¹±¥eË–¨V­þý÷_Ó›×úóÏ?ñÑGáœsÎQá*¯õ8% ”Ài4–~SO†ÄâÑÐ8,L/g¢ïK¨‹½ÅÕ?ð0ÚØ<ñî…Q%ưs¸§×UÖb„᳡±?Žub½+k!XçÆ'"fퟱêXéÊJ1®, YMåØêÒO+¿e9"½ÀÈmïOߪ+/’Ø;q•°Ø»ü²ÈÏñî°8»µdóö³H/0Ûœ¿àª]ÝãÂêÐÍÏi̱ٹ¨voµ!ñ˜6ùT½j-'ª×s¢Ã…éò(¯“ú.„„Ÿ,Ò6jãÄãŸeÿÚÌë$ç„ÆmNvj±+æí'úVÉ.M;:pÝ£ÉhÒÁ!ŸŸäõŒzœ(ZúR-ZÞz6% ”€8N {÷î§u!õêÕ Ü²+ Y7ÜpƒÙ²X‹/>cΧ:Žq€/¼ðÂ)ÇËéy rü`NK (% ”@i'ÀKëKWò?þh–ËŸyÿͯ٠óï¿ÿ6qÜÁK÷2™8qb&D¼‰…1¾¼±%((ÈôÇäÆH¾þúk<ù䓯=Å›\ø7£}y£¯yóæ™ïëׯ|P>xrd¸ºéÚbqŸ°°0Œ;Ó§OGBBÚ·oG}‘‘‘xã70mÚ4sÊèÑ£ëë’K.AÏž=ñÌ3ÏààÁƒæÆþàÛ³´_g]ŸPJ 'vù]Ý]\VÜèlšu¼ÖU© Ñ@D X}‚¢š¸µNU£“«a¯ÛŽ£²=ÆÜ;«†QÜzÈä™E ¬!Ë)‚–#¿¥…ãcg$e áâÎjbG–Ì#ÆÇ•““Ì"7\XV¬3î*ëú-ÿ'}«º¶ƒ³”÷­Êé5RPÏ»+W„³·8±esI_0Ãyæ|ؾ Wóž~X-›ÀóGà¹uQu¼ ÕD¬Š¬á‚ÁŠ­úÞž‚ß§žÜçký²|ö|ˆ8ª¸ìæTã°ÊNL+¶‰ë‰•@. äü¯6—énJ@ (% ”€PJ@ (%P¶ ,Y²Ä¸¢n½õV|þùçFDb´c}«N:FäJJJÂðáÃMßȬպuk¬Y³ÆÄõÞwß}hÒ¤‰²LáŠÂ…+:±:„K/½ ,ÀÔ©Sqî¹çš8âåË—ŠmÎûÞxã`0Å&ŠgtaQøjÕª•™/E)F³(f½þúëæ˜o¿ýÖœ—¢ã ýõWÌŸ?³gÏ6sÒRJ@ (“ Pð¹2ð Ùâ¥ÖäÔÊøFâ_K‰F'[®~X—ˆ%2|cð>DJ´ßNqA}”Rχžˆý+,ÎŒl+Ž0n’m˜Qtb1‘bÖZg~“(ÄnÏÄeÖHÜY1öã½³äëhw*¬¶z¢ÿY „ÈÈÒ·Êqe¯׷ʺô?¸Ë‡ÁÍ>R~^îz5ÁÍ%=±¬Ò/Œümc§Àd—(Ææ'VÃÚp8-'õ¢Ê‹‹Ê_qDH慎?J0=¬´”@I& ÂUI¾z:w% ”€PJ@ ø1Aƒ!..ÎÄ}ÒíÀ¨MF…µiÓÆg­SSJ ¿脪W¯öîÝkÄ+ö²Ê*\1®},銺òÊ+³=åÝwß}ûöaΜ9FI|¡–( T¸* WQ× ”€PJ@ (?%@×? æ‡ÉtG0²ë¥—^B§NütÆ:-% òK€ý¨X!!!æ‘‚O^ŠŽ¨—_~«W¯6Q}Œí«ZµªÀ)J½úê«øã?ðÛo¿¡råÊèØ±£©®¿þzLš4 =ô¹Þ}÷ÝŒšYç±gÏWH1Ë·(˜y+ëz¢¢¢ÌSÞx@¯¥”€(Jõm©EyºB9{]µ MÄ“ˆÅïŽò¦Ö')‘x%tg¦ó}’Z÷ï)”9ägP:ÄÚÉÚ'í…uùjà/qWíŒÇ6‰¿]Óå,¬mÜkݵ0[œZ± °I|b}qc5‘­™D 6•ÞYÍe«t\ ËÏ\ óXKº'wl*Ì3æml§¸¨öl· ~›ñ[ùhƒÇEU^zQEzzQµIB'ç6ÔØ; 5S7 ¸Aeqaµ‘«Ø7ËŸ‹}«¶­±!á°-»æíï*^ŸÎM dG@…+}]”x¼+’YùÌš/_¾|¦õ=zÔ¼Á囨˜˜˜_ëäɓѵkWÓ,:kýóÏ?àá‹/¾¸ÀÏ«*% ”€(iØGæòË/7`¿óÎ;?~¼Y#·Æ‡ÔÔTöÜsÏ¡aÆ湿þú˸è¨`ä×ã?n>ç±ì- 99Æ 3‘_GŽ1çxúé§3ƉŨQ£°mÛ6„††âÎ;ïÌøúþûïÇYg…?ÿüÓD–Ù¥[1çÀǵ”€Èþ;ÊMQdòö­b`ÖÚ¿?؃’nÍgŸ}Ö8³6mÚdvãߨüý@ÇÿÓáI狱‚¯ÆŒƒY³f™ˆ¿¡C‡f8¯|…&ŠPü·O‘ÝëÌâtSyË;®÷{ßýr³NÝG (%Pv»ðSzE<RøÑy9ïSÅQt2q£‹É·ÞO‰Â¹öc¨gõ/¡ŽbŽe•ôa7’u5ûVEÂݹœw^‹šÂQSqö˹‰kLzd™˜A±Ö¥ãGéùõ¦3 (ˆÊâBcß,öÏj&¢VSµØÌV"䢢x…œ|ŽÄ£Ämaj›q[­F¤Ú·Ó*7¢äÆEUp×–~c[áf”ãäÙ°Oú ® ="VóF€8¶ü¥öÇÚ°tž+æàð~+úÜæq$j)²@ wï(Ê ]c‰%ÀèÞEÉ»˜=ϯø×c=fbEóÁ¯ Z¸â‡eW3EÜ…}eZ*Ý?'ßêP4ÄòbÝ´]Ī•Ò·JVApu’¾Uý.̱oû|u´' £HXÞÞY.YÅWÖKÌàG°‰ü!-{¥X€¬»¡ˆW1"f5eÿ¬ãî¬r–²åð¥µw§E*»TtQyDªDqÙäm㢪çDûéæ±z}BÂsá“›e\Më²Yõ†eÅñ~X}khˆ‰d?,öË*ŽJN°àª–ý€ØM=YÅ…6ÝÓÑ¡‡Ujd~ œÛ?­8¦¨çTEB@…«"Á¬')L7Üpƒù€‰›)g-º¡˜C_ÐÅq¯¸â L™2+V¬0ë•W^É8Õ=÷ÜSЧÕñ”€PJ@ ”h¨(ñÿÛ®¼î*.Šñ ,0ëcÏÞ¬Yü9W^áªK—.NköºiÑ¢E†C‚ßO˜0Á·yófÓ_‹¢«FÆùEqëÞ{ï5?ãÞœ‹±†_~ùe‰f¬“W%…#ÿè¤úù石í}wÑEayÍš5Æyuë­·âÚk¯ÍX^ß¾}ͱ·øïžU©R%,^¼Ø8±èôä¿o `,öÈ¢»råÊ•FĦpuÕUW!--ͤ4üý÷ßF@ãï-% ”€¿x&$ÖDê•æ:&}­žLª…"öÜ–P¤ÇÕkÇ#W:C1Ežþ‡Á¸ÁÂ.ËžgÕâ•À±D¸[5…óæ+án& Ö¼Khâ96â·Þž?CMqÛðŸ¬q½YkAøNÖºÙ ‡ÅŠhKšqdÅX“EÔbÜ` êˆc•ôÊ‹ª†Soª‰HYÃk£ÜÒßÒÝInV‘Íu$Ö¥«`¥kÁR¸ªD[Ãuv¸#(¤^ÉÛ¬_jÇr«Ö-±Ã´<;—Ýœ‚ºÍœÈƘ^x“Ñ‘•€ŸPáÊO.„N#ïx·%?Èâ–]QHÊ!˜÷³e>’/æê30kñnP6ÖRJ@ (% N`´cº*Tð¼ùûþûï1wî\㌢ ‹,:®ØkÙ²eæ{ƉyŸã÷Þãù5c»øµ·ø½·§ÇáÞ¾EG´w\þœbš· ¦ýjô«rG€îHnÞÊ*úRhâvªçÛ·o~øá”'{ä‘GÀíTuøðaóTïÞ½3váßß§úœŽ«Ï>ûì¤á+È-k1J”›·UÈÍ[gŸ}6–.]š;Xº—PJ@ äš@9‹ ÿTü/ÛýkYÓ°C«S ¾ˆ6°úD5‰Ü+°JÐÂ%«`Y´Öqp5ª×eÝán+Nœ`Q ±*ˆ«ª«8ê¸!Ès¢tqgmgÖ:W0ÖJÔà ¶¾N­ŒC°Ë..4¢+K°¦^Q+tyùcš‹ªë–˜GgϳÙ,q{=‚å‚e°Ïœo^Æ…UÀ¯íëlF¬Z¹0©I4j—Žk†K´N¯J¾ Y—E‡(ÃT¸*ÿ4-Χ©S§bÉ’%™–Å8¢ÂlþÎxBºªžzê©Lçåš§{“]šØëZ”€PJ@ œ º ÁÅè.ö°Ç³ï IDATb_ª÷ßßQt/3²Å˜é”``~ªråÊFó-Þpâ+fe×['?çÔc•€(\ìoËß%Üø÷xa¤+î tt% ”€È+Jƕۂ}©Çh½)iñZJ4:ÙpEÐ!\"B–oä`®Ïãpº’‘qÇûVU0=C¯.t·MNsdt ã¹õ÷qgíŒ\'ý³Öˆ°51¥¶Jü ckˆÀ×Ô8³NÄ RôË»G,§Yžü|q¹¨Î|¦'á®^î+.‚K¢ -k6–õ«™€ltݹ(b5“¸Á<ؾî±±jù¼ðëèºNô¸* íz¤!¬¼ŠUqýtŒÒA@…«ÒquB€Ž+ß7­žxâ‰BgÃ;:é®b–¾· F j)% ”€PtFðCfº^ýuó³ƒ"""Â8£ÕE÷•·Ø»æ–[n1NŠ&Mš˜çÙ?’}iΤx,köìÙèÕ«—ƒ×^{íL†Ñ}•€ð#£×͈À‡~8“kÒ¦©SQJ@ (B$)«›ƒöšmƒ+“R+áÕäh<—T½Ž¢¿ˆXg‰k)'¡Æ²u§ˆU"J,_#ͳ,pwl ç#çÃU»z!ξ`†ŽQötœg?š1 ¥©"f­¥CK§‡c|juÛ&.¬Æ"~5µ¥¢™‰ä×É©+?åÏ.ªü¬Ë+qîà”Í’"t¥¿_/ö÷&Âj^/®®írìsƾUtU-ÿÕŽíëì(!}«ÎK—þ\D×ñOw\¾ÙéJ ŸT¸Ê'@=ÜÄÄÄàî»ï›-³Ø·‚},Š¢è®4h9ã‡!¨¥”€PJ@ wÝu—ÁÀ>ÞèÁÿOóÿÙ,:£gÍšeúɰ/ ûVnß¾Ý<mzß<ÿüóØ·oŸ9~ðàÁf;“¢›j̘1xñÅñæ›ošHAþà´Îd,ÝW (ÿ ÀþwŒëÖRJ@ (%@ÅYô¤ôþz44 ÓËajj„éUÅê@߀C"bF=‰Òó–åÀaã Á"¬…«EC¸®ëWËFyrÐøÓUŽMhnK2›oÅÓ%άµ"ò­“¸Á±)•±]ÜY,öÉòF 1KXUwVvU’]Tù½NŒ‰tŸÕÍuèˆô=“8IqèÙ]w(%èêÐŒdI:ºéWåí[e•Oá[œ•Ž ®MBÃVҷʪîªü^=¾tPáªt_ß2·:~àÅ»/yç6¿.ª¢»ŠçûüóÏ5"°¨ ëy”€PJÀï Lœ8ñ´s ÁÛo¿iŸž={f|ß±cGdí™Ã'‡ ’é˜îÝ»ƒ›·è˜4iRÆ÷ti}øá‡ÙÎÅMè}²C‡থ”€PJ@ (%pkð>Z±ÎË.=Q»‹ûˆ[l˜Á(ÁÔ x?5 -å'âW¡ß/ߣ⺠p׫ ÷…]áh× î°bwQœœ=Àª¤ã|ˆ;ëxï¬X=Qƒ²­qc~Z8>rF"QرGV}‡X-oAŃV¾/?Yê@Ê~ lòirÕZNT¯çÇPºy¬^ß…ð²#ĸ#*ÀÙKúÚËÆ>hì‡f™½ö)?cGµ.XæîŠ6D#é˜ÅˆTWKEs­ƒÊ£¢x]ë9J7®òq},XpÊAò1¬šOìYÁ»²¯»îº|Žtæ‡óÜÿûßÿÎü@=¢P ðÿ#Fê9tp% ”€PJ@ (% ”€PJÀ?Pxh݃A›b×ê˜RŸ¶=/^÷"ηˆ «\΋¥²ZŒlcKD“ä$tÜbEÜVâ·‹˜u(›Bp¨FVË `G:áhD,ݨíNCóÀdÔ HA¤¸³ÙRm)ÛQwŒ•<Z+Ü—cÙl`ß’DÄ¡Gø,´½8áÝÃÕ¸ž‰ÔRJ ÷T¸Ê=«“ödlÍÆqõÕWçc=T (Â$°lÙ2°·–PJ@ (% ”€PJ@ (%Pú dê[%ymµÚ6Ç=-ƒqW½8¬pVM¬e@ô+ð°pJ{¹D_ÚkÅîm6©¬ˆç&_;d•À݈¬åBµºNœß: ×ÖM@͆N”ûx\î&8Zï¬õ‰œVï8«"U\[p ©ƒÉˆA‹Qƒ EÐ (å¢`j²«þÀ2é[µõ?é!VA„Àn ¾(ժؤoZˆD ®‡å­E°–/W§pwmwTåÒþRÓõ)! ÂU>0ºÅ‚̆âC‡ÍÇ(z¨P…Ià£>ÂêÕ« ó:¶PJ@ (% ”€PgH`äÈ‘Xºt)š7oŽ—^zé ÖÝ•@Ù&°Í(RDÙRË6ŸÕgê[uà\ÍÀumo¸[6;àÄÇŸ¨Ú„&âIÄbnzL‘8ÁG ®°¤€Õ'è «WÒ+9Á‚¸ã.ªÝÛlßÎ6+E¬¬«?¿ætnJ ¨¨pUT¤õûì³1~üx>|ñññæñرc>|8ÚµkgFêÓ§ú÷ï+VàÀèܹ3î¾ûn#jÜxãfßV­Z™}çÏŸüŒ™1bvíÚ *111xâ‰'NšÙ€Ì~/¼ð‚JÆŒƒ¿þú o¾ù&жm[ÐIbæyðàA3OÎ#99Æ Ã7ß|ƒ#GŽ ==O?ý46lhÎÃyŒ7©©©(W®ž{î9óÜš5kŒ V¹reìÞ½Û;dÈôêÕËwÿý÷›ó2&/))ÉÄÏ?ù䓨X±¢yþóÏ?ÇôéÓáp8зo_Ü|óÍæçœ_PPþý÷_,X°ï¿ÿ>Z´˜¡ãµwï^<û쳈5Ç^|ñŸ÷Þ{O;&çríµ×âÝwß5ÇqŽUªTÉ8'¾üòË /ßkÊŸùå—øöÛoqôèQtëÖÍ\2=z´Y[`` xàtíÚ5Óu™7ožY ¹0Òû©§žBíÚµ3® ¯ûe—]†[o½Õ¼Ès„ æú‘ _ä©¥”@Ù"©oUšC>äóîAp5®X îCþz" <‡Cvã/G¸qaÝ‘PV'úH?¬ Ãà>…YÅé¢*¬uÑQÕXb&£ÏIºíX-άõ²QÐúVDÃ-ÎjpŠpmI3ެöÎ’GŠZu…}Á]í“W›–jÁê¿°\ĪM+mwûV ž‚Òó«ÀÊnƒ«]s@6gB¬KÿóˆX¯kDyc[Á}Žô÷––(«T¸*«W^×­”€PJ@ (% ”€("3fÌÀ×_J•*áåå—_ÆW_}eÎN±ˆÅŠC‹~þùg#¼œª‚ƒƒØs×]wåTuèÐ!#®pŸòå˃â& KuêÔÁ«¯¾Š?þØœ“õË/¿˜yqß÷Þ{ψf<¾zõêF¨¢xÃcXRx,' XŸ~ú)Fežûã?Ì÷Üöïßo„~]­Z5ó=`]¿Õóï`òlØ'ýW‹†«y#@[ZJ ´пúJûÖõ)% ”€PJ@ (%  ˜À?ÿü“CwþùçãùçŸ?í¼1x܉âA¾EW·¸/£ú²+·ûÔ=%N5'¯HÅñ踢iÙ²efxŽçâ­ÕÇ«B… _Óñ–é{ßyÓ4wî\s<#ÿ|Çñ=ŽÐqD÷—·|ŸçsŒPäñtIѵDg‹Â›7R‘ßû®%+ ~Œ|æ™gŒKêÁDƒ r“1}¾E¡ŠŽ*ŠG\]^Y‹«¨¨¨L?fd"ãÿèó£é;]¥¤¤±Ì÷5B!Œ×Ê[5kÖTÑê´õI%P H6žuÍχôÒ÷Á"Ftl çàËá®îÆý¥"-é¸+hÙþs†š(Ág¥ÖS¨)âÕ!ô“(Áæ)IF˜¢‹*~‹ˆT"ìÙa…#Í‚°ŠªZ]—‰ ‹–¯«ÖrñJëôª ûjé8G Ͼ)°W·5Ò?ëôpŒK‰D"l£;KÜqMm©hf¢“QãX*ÖÍÁJÜU+E¹Ðùât´;?Ý|í7%7€¸šŠÛY6Ë Þ°¬8Þë£o` 11ƒì‡EG––(­T¸*­WV×¥”€PJ@ (% ”€($ìÑDQA¯(þPt)±(rù B¾âOÖ¨¼Ü̉‚]LŒÌO±OEì3E±Ëýç3«ðÆ5Ñ-•Ýó|Žâ…2®›=œÎ;ï¼i.œ¯7ŠÐû$E3²øì³ÏŒp–Ûb´ e| ×•DZ½n.Ž“u޹[÷S¥™@j¡vú)rÖq±Júû %îVMá¼ý¸›ÉöVÿw”´GP•}©¸ÆKºÜ(“ŽzõSоR2:D'‰SËJV?­²àa,¦[z^A6ב釵 Vþ»Y°n:´èÂê$ŽÄˆ7ßaI ®Š½žY (% ”€PJ@ (%Pæ P”`_)ö;¢ó†‚Ð 7Ü`¸Ð ´zõj´iÓÆ¸´ØûÊåG‘„bÇÑ£G3~–L B·Ür z÷î&MšG…£¬®£œÆáy)Þp.ƒ‘oQxaüÝK›6m2"ßwÞ™±Ë´iÓL|#éh¢kEÑèóÏ?71ƒÿرc kÉÅ—u^ìýÄX¿k®¹ÆôŒb<E ÆzÝag:&#Y¼§Šk¼à‚ Lï*F$ò:!.®—½ÁxàÊmü9ŸgüßéŠça¿°#Fž¿8o-% NM@õJ$Ë¡#Ò¿g•G°Ú³®FuàêÜmÅA\¸}«ò,5Ybþ6‹ƒŠNªl]TIZ7Sÿ–Çœ0ÞUmm Òë0ZI”`˜EE«ü\ƒÜËxÀZ–4878qln*þ)cP¯« /´!¹Q Ö¹ƒ±Üޝª YÜYå¥×VSésÜ™#}³‰C‹±…þTŒËtö'³l–¸½ž~X¿.†ý{q€Ë¿%ãÂòóKþÄSçâßT¸òï룳SJ@ (% ”€PJ@ ”jt±'ÔÀpEG”·'E&Fß±7Åözúï?¹3_Š¢ûAñ8 [£FʑŕgŸ}ÖDR\¢£kðàÁf;“êÑ£‡éGÅþPt Qh£€ä-öŠZ´h>üðCGøè£fr.±ÕwÜaâ›7onN,ŽËyqÝ 7ÇžJ¸Ú¼y³ö(\1þðÞ{ï5‚YåuL΃=¸>øà<òÈ#Ùb¡¨gú\QXãzÈ•çë­·Lï0 gÕªUØ1cr®î¿ÿ~¼úê«FP¤ðÆóûö·:“k£û*%àطʲ|5,‹þ…uãv¸ªDÀݹ5œg·ñ;—iî–x?‰ù‹óéEuh¯Uœ±@dM§qOµêê@µëœ¨ÑàdUWìÃPÙvºƒ0-5'WÅóÒ«{Àô <,GýNñ¿WMÞf´?Ö†¥ì[5?‡¥ûV]zc*Z“Žð“E(þd›+Ø8³ÖJÔ ûgÍN+Ýî@‘†Ý¨gME±¼‚VSù:ZD1(Æhº¯¸®~²f“qaY¿š ÈF÷¢‹"V q/úOƒÿ°lܸ1OÒqåÊ•Ë|Æ4›Çò®°É“'ûß•=>£AƒaÆ øòË/Í]w%©øÆ…Í…¿Á7sZJ /Ø€oæ³ËæÏËx¥å~ ÀH>j)%P²Ü~ûí¦#œ´Ê6~ Ϊ½½pÊ6ü¯žn•«¯¾K—.5bÖ |Á×[aýÞ9çœsL/% ¥¡Ö¬YƒÑ£GcܸqÙ.‡" …¦œz?•þ´þ¾äÿ?×­“þ9ÅTŒt8g|ö Âm;Ïø8` ©K`æ~dy(Q åë}ú„¨WÛ¿úåcYÅzè É5ë ÀûaÛŠu§=yÖ¾UA~×—'-U\TâžÚ-"U¬ˆT»¹í>ŒòsŠì?U]Dªjõ䱞 Uë8å³Ð¼!_"îž©"bý”^6±±XíqçÇeõ¸:4‡«ÇéûçZðïïÒ·*±›l¨XEâ»§£ÓEyï[uÌmÃj‰ôÆ ®u„`“\éÑqgÙ¥w–51Ò;KœZ EÐ ðw–‰ÿ‘˜"–ˆÄîðPÓ/ÎÕµßõ‹+Î×LY8÷Ëo~ÃIåð¿1¯ûrÇVJLgÊÏ#¿nÏøø Éwgñ¹·ß~Û4ñe>8…&ßr:&N†Íƒéœjذ¡É!÷Æ~0¦ãÜsÏÅUW]e2Êé £¸Æ¦¿Á¦NjæEÖ‘#G2†fãaŽoŽaÎ<Ç`?Þ¸µØÜw„ ¦Iñ!CLÌ"›ù²y/‹Ç2#ž‚óâÙ˜—l˜‰Î9qî<æ‡~0M˜™ó~ÓM7¡_¿~æ¹Ó­«.³žR (% ”€PJ@ (\àßü¥©š5k†Ï>ûì”Kzã7JÓru-J@ ”QYûV¹#ÊÃÕ©Üç´‡»rÅ"¡rxŸG”òö£Ú-_ˆ—ÎDn *Ey"þ·uâ¼+ÒL_*þÌŸª’Å!û̶Uœ<ßH”à;ÉQÚ+ËE¢3nñÏ}«j4t¢×u©h}n:Âʾ”Ók†×±›ý˜Ù¼%}Øh¢Ù?++aø:­2ŽŠÈÅHÁ†/øAöÏQ«¹ltyV¹jW¸ ¸ÖU%Jpl_L‡{ ¸ÛÆxœXëÖ|ZÕ k:n™$ ÂU1^vº®Xöã¹lDL‘ŠñxÞf·_ý5ÒÒÒðøã›}wîÜišíöêÕË4%¦U¾|yÜwß}ؽ{·›(âÔ­[sæÌ1–o}ñŦ×OLLŒiüKiذaFTªY³¦Ù•¹þl$LaªAƒF°â9öïßùóç£oß¾F¼âÆú÷ßA1.22ÒˆHK–,Á+¯¼bŽá<½Å¦Â[·n5ß2ßš"ÖòåËÁH@îûÛo¿™ç8¯€€“ow᛿O>ùĈxÌÙß¶m[»Ü¬«/µžZ (% ”€PJ@ (% ”€P%›@Ö¾UýçnÛÎË·«amä;Ÿítœé@œDûÅo—MzRʼn@µG\U)I]Û‰h‰÷;çòty”~Tõ]”Ÿ—¤ª'BÆ£!ñx8d7þ2ý°*â¡ÄZ,Aý° ’wr‚+`¹Dn_gÏè[Õ¡‡UjžÀSPkÀ>£’Ìæ[{$VBÖ:‰¤;ë›ÔJ"ZÒ‡gA´%Mzg‰ÅÞYòHQ«®¼. TJ²ÚàjÝÍ•˜ ëò5FIJ¾õ¬åˉøÜî®">GU.(:ŽÈ3®òŒ.ïÒeEÑŠ  )ZÑ}Äš1c†q2 0À8‰X|üñÇMd¤'Ÿ|V+ïqcõêÕ¸ñƱbÅ ³/]J­ºvíŠ7ß|ÓBýû÷³ôVÛ IDATÏ䌢ÀÃãÃLjÂèèhã†b3än¸ÁŒC7^?ü0"""ÌÏ(:ñû^xß~û­»:wîlž£Åºûî»Ñ¡C\tÑE¸õÖ[1sæÌLÂU\\xw!÷¡øÖ½{w³¶?þøÃu^áêâ‹/ÎîĉÍÏßyçãXcï+obnÖ•÷+¦G*% ”€PJ@ (% ”€PJ l°Äí…uá2X–þKbŠô«j×ýánÙýhñ覤Õn¦øÈØ¿ýq¸]DTu‰sʉ†-ÅáÒ']zQ9!.ªüö3ò§«*Ÿø¡«8w¸%I?¬9iDÄŠÀÀc PSbçüºV>A:¤Öú¥v«DÜYb—è?˜¾UI,¾Uùœ^%×0ÊžŽóìG3Æ£;ˈYthIÿ¬ÅÎ0|‘Z (q ŠxÕD„¬f"d5—Z¡"uå·ãéìÖÍrà0¬ÿ+q‚ÿÂ6ç/Сåé‡Õ÷¤|i)¢&P°ÿw)êÙ—ÐómÚ´)c毾úªrXt±¾ûî;³ù-FRš7ožq?yúÒ©ÅÚµk—y<ûì³Í#]LtV1æuèС Ëãç=ÝZ¾uÿý÷•·(2}þùç&†pñâÅF¤bDáµ×^›á¢¢ÛË·ýç[\pEè-º±(\ÑÅE±î+Æÿµm+M³Å4:ÀÂÃÃhÅ :ãu4°þ@ (% ”€PJ@ (% ”€PJ Ë‘X—HOöʼnÝw(¸¥WŽ£sëù ›"]SQ⢢XE§ {EÕ–¨¿:NœuIšô¡r¡z'‚BJ–‹*¿/)Š}™mŸ8u¾K«„i¥°Ööu6#VÑa•"q6låÄ•ÃRhUØ}«ò{ âxº³Z‰3‹|ÚMírb‹bE­`ŒM‰ÄN·g‡ZâÎʈ1‹½³jZäÿg²®b¸ÜzJ% ”€PJ@ (% ü€oäû—ŸþÙf£SPJ@ ø‹´ª°¬Xç‰[¿né›n¢ÃnºîêUó<áÄ£Än‘JâþŒ›J¶}±Y&¦‘r"J‰sªv©.fÔŸ ‘ÕÅEe-["UNp#Å©sGг­v†bjZÞ(Áý°Ø·ŠbÕòyà×tÒõ¸* mÎK7¯ - ¦5ÍlØdàH‚Uúfˆü--§TA²¸³ÂèÎ7VS[*š™¨A~Œà3tg¹Õd³ ¼–Uëˆe;î »‰5N, ™€ W… øTÃSœšºùæ›Q³fMlÞ¼Ùs>ÜõjŠ « \íš}³rSHO%ŽææxݧlP᪯3£òèP¢õñÇãÞ{ï5Q|ìcµlÙ2¬^½ÚˆVÞžUþûï?ó]U=ö˜}èÐbÑyôä“Oš1耺âŠ+ŒCêÓO?ÍX%{R1’ÇýòË/`l {JÑuªªT©úôéÞ Fþ1ÒïŽ;î0‡Plzýõ×Á7œì§E‘‰ÂRÓ¦Ms¤ËsGEEaÏž=§t[y6lèÒb//:¯XyYWޓӔ€PJ@ (% ”€8%ÞTÆðxSûë¾ôÒKæýÈðáÃÑ®];s{é<³fÍÂÔ©SOÚÿûï¿Çš5kÌM}¼¹ïË/¿4‰£GÆÒ¥KÍû>Ç›æXìÅË÷Q|o²oß>ó^bÀ€ææ>Æ‹³/0çÁG¾ïa¼9£9÷ÓRJ@ ”D¤ÇÑ[ ·X„ªÅ áˆQ"ÔâÆuÇd,ç–_&Á.¿ï¶?4Õëgï^9©oUTe¸Ïïg§–pGTÈK†Úg3ñ~qìCÅGqR;d5"DdM§Ä¼¹ÐöpÕöŒç­”D â¶ÐAÅ~Tòµ<îÝiƒSéÇ>DÕE¤jÙ5UúR¹PUâÝäþb-?#PË’Š{ƒw›m‰#ÜX¯&UÃó¨îGÐ/ð°<ÍÖ]óV…hÖã2\>“:è¶gÖzÙÖ8‚ð}ZEŒqDÁa±"âβK¿,9.†qƒâÔj(âV€X½\M¥íl–A½÷Ç“(Á¾54DXž~Xtdyk\jŒŠiÑíâqå‡ßÀýØP¸CƒýÎÃÏèÿ^üì‚èt”€PJ@ (% ”€PþN€É Œ§HÅto¿Ý‹/¾ØÄÓ…E!‰¢o<;ÕþÙ­söìÙÆ™EaŠ©-Z´0î+ `,>Zy›¿TãÆQ±bEs.SvîÜi¾þí·ßL´¸÷9¦4üõ×_þŽVç§”€È–ÀMAû$0B"¿2Gq…»ÒqÓ›àƒýw!ÖQÃôBÖ¾UnÜ­šÂqç ¸›Õ—®íØ'ñ~ )R‰‹JœTŒú;²ß*¿{ݨ\½¨(PyzQñ늑™ûjée*:ŠÛ†Û3ˆÅ<é‡Å×а„:·fî‡ÕLbâ¼5â‚ëQs˴ض!Ó" „Õë2÷¾bߪU`Ù¯vlýÏŽ° Ò·ª›‡§˜fZ%ƒ@%ǻڙMšè™b¤à&qe­‡ÖÚt>†`Zb%’ßvùÒÀžjĬfòØD^?-:¶E…N­à:’ ý°VמeÁR‡]XâìŒ)fÆqå½X´l^ýðk8¸±d@ÒY9®Š¹žP (% ”€PJ@ (’DàÈ‘#&®ºÿþùžöÆñì³Ïš^³t!]z饸çž{°wï^}×¼¹4Á>Ú?>ºwï~†GåowÆú}øá‡øì³ÏpÕUW™>¦:0%¢C‡&‚ûã¶nÝÚˆPÙíï ¼³!ò:thÆxÖYge|ÏXBoñxßÞXüž¬ƒ˜·ªT©’¿EëÑJ@ (b&ðjØNô9Ú8Ó,†Íüo¿ÉîPóóÁ[`|‚é[å®_ )}.ÁΈ6ˆ BÜ<¨ÆÙ°g‡Ž4 ‚C݈:î¢jÖ%MÜTNDÕv"àø׿\=}…ªÃf;jÇ é‡5-µ"¾>V%£–÷tÇ€ƒÄ×c†£BJ¢ùñ*G[ÌÙÔþÄÿ‹#ܘ8:Äô­b5ïìÀO‰+§CæX¹\‚UÄDâŽAŠ[ŸÞY{$Vú:‰dÜà$qômuEË+Ì‚ª+ØTöozvC4;÷4=‹þ û¯‹aÿ~.ºJ/=ÜäI›Ü¾‡y|õÛYp^uq¯NOW¨pU®’ÎQ (% ”€PJ@ (b!0gÎÓ3)66Mš4A³fÍò5—_~ÙôŠíÖ­ÜÒKàðáÃf<öa¢ƒéL…«ôôt¼ûî»E.\qÎŒö{ôÑGÍFŒ C½{÷6qŒçc+:°(Znïó܇ (~Q+W®\¶¬}÷?ÝÅ`¯^ gÞ:zôDtV¾.¢¬”€(&1ân¸!h?>—¸-V›]۱㻘 ÑŠ? <¸?Õ„¸ šØ½>‡xª•¢ÄE%=¨b::Ðã*§|í2?Ó*{许Ïl[ÅMóӥϑo­ ÄóW ÅkÇࣾ>tu¦ç÷ï¶"0Øþw¤Š3/A!Ú·ª¬¼’¢DœŠ²§ã<{æ¿«V:C µVúg-w†a‚üžJ ®ƒ ]ÐèÀŽîC³MkÐzï6ü[µ®ÁEñj×–ªxÑ*„wÎßߨe…YZ§ WeéjëZ•€PJ@ (% ”€È]»vÁjîܹfFáåW´â8«¥7‰ömÚ´i¾úê+óó_ýÕœwÙ²e`\îÏýØÿ•N£_|5ÂäɓѰaC3¯ 6€}m)†ÅÄĘþO<Žq}£Gûètql S›BS­ZµŒƒŒÕ¥Küïÿ3.²çž{Îüìtû“ÁöíÛ3z\õêÕËô­zà ž‡ç`Üà™yLš4ɸµ8ηß~{&‡ë¾J@ (¿$0,x¾¡!6TþÈžI´â„W‹#+°\¢¸q^ç4D‹XU½¾ A*,øå-æIÕ“~E×À¤´J'Ídr‹sÐð¬­HœÙ)iž¿]¼»É†ö=ÒÐá‚´b^žÞ_´²%›4O˨]®@1¸VÜYëäï½q1ÝP-vPõÄ>×oÁ»Ã1aׄ׬ì/ËÑyø®üà"蔀PJ@ (%p&øð©œg2Žî[² ÄÅÅ¡Zµj%{~:ûwÞyãLJ¯C‡q~Q÷ß?yäôìÙì¹ÄN .¹äDEE™Ø=E—=<*œ{E1Ž5}útãH õÄBQ¡ÐÃâØ÷Þ{/:vì‡ÃFúF¥¥¥™ÈÃýû÷Ãn·›8@Ʋ×wþùç›~RõëKÓn©ÓíË-·`äÈ‘f=_|ñ…™ÿ[o½e¢é(ãëœbÜ™ Wt~mÙ²ÅpãØW^ye³Â`¢c*%P¸–ü³KWl)Ü“”ѯpÏÁ’…À¾]VìË2ç=©ÇÐ%l%ö´ÁÞ=ò±ßÙáï²°ù°i?.Aü#º´W*¬¦ïU²k™t.:¹&%CøÁ%ÙbØ8ø}ͺ8J;¦ŒõuhSÛ6(°õ¾?î—ËŸâk«å®Ý¨´ã &·ëû¢2M—ÝÔ<ºmªúdúó‚Š`nwÞäéíZ–K…«²|õuíJ@ (% ”@‰#Àb)X”åâÙ[·nE½zõPvßÜ´k×θm´ Ž]JƒÆúõë3 †øøxÉ——ºùæ›3D—V­ZaêÔ©˜9s&n½õV 2ÄXYkÑ¢E˜1c†q Ñ‘E¡Ë[t¼¢UÖã(ÎЕÅßW\q…qtùÖ?þhþýä¶(€eWtQ<;UÑaEç”·N·ÿyçn¾EŽ[Ö¢óÌ·zè¡LßsýÞ¢ öàƒšÍ[×\sMn—®û)%àg(\}>é7Ôop¢wŸM±H§&&Tg]ÈM tóz"YY”[渰ô?¸ËÀßI{vEíjU T¸šúãbìŒ?€¨è=‹ôâáÉ’E¸*/¯ïJm·xù ›[ž‘›Qä—¤Jºeó0ý¤(ðx‹%ÆâuîÜ9ß =pà*Wöä÷³T•*UÌ׳öìažpèÐ!㔢hE‡áüùó3b³›{KÑáÄ1’’’Œ‹Âcü(Rù WÇ?ã5<þøã¹>†î2®kĈ¨X±b®Ó•€PJ@ (% ”€P^t}jù¾ÑìС>9C÷ꫯƟþYè³d¬Æüaîмï¾û õ|¼“köÖÏ?ÿŒn¸Á|Ë,ÿÕ«Wêùup% ”€PJ äHLL4‚‚o}òÉ'%o!:c¿&À¿É§M›†Ç{,S/9ÆûD=üðÃèÛ·¯ée5jÔ(¼üòËfXÆêñodö{¢˜U³fM 4wß}·hOWÜîÃO?ýÔlüšÒ¿ÿþkzgeñüŒ8¤›LK (% ”€PJ@ (%ê¸Ê µRz ïê¬P¡NÊÂ/È%³I4›ÊóCÞÊòžÛûuÖ,þ‚<¿Ž¥”€PJ@ ”Lèäð-:°æÎ›©ÿOÉ\ÎÚßð¦ª~ýú‹‚_÷Üs”òScÇŽÍöðZµjaÊ”)Ï1â/»zâ‰'Núñ°aÃÀÍ[œ§–PJ@ (% ”€PJ ¤P᪄]¹7â•W^1±iiihР^ýu°ñ0Ÿ=z´iØÎxÞémrÌ»9Ùzùòå˜3gîºë. 80ÓêåAáŠw3{c=Nuß¼óg:ÄÂÃÃqÓM7eº›óTÏsÎŒ a Êm·Ý›Í†1cƘóñÜ,~­±"%ì…©ÓUJ@ (%PÈø·]áÙÕG}„.]ºàt} yz:|)%À¿Oß{ï=,Z´ï¼óN)]¥.K (% ”€PJ@ (%à_T¸ò¯ë‘ãl(R1Z„Ù].vìØaD+fÙóÎÊGyÄDí1‡Ÿ1#ãÆC:u̸ï¾û.î¿ÿ~ð.Mæ÷g-îÏÆÓÌÒŠŠÊx:ëqŒ¡xÆ7ñÎ8Šd|cöÙg›H’Ó=O·?\b“iž‹E¡ŠM«YŒ)¤ð¦¥”€PJ@ (/7ß|3ûñoŠYüˆÛ„ pûí·+0%P(ØÛª ú[ÊätP% ”€PJ@ (% ”@)# =®JØmÔ¨fÏžM›6Ájµ¢nݺf ,@µjÕ2š6׫W;vÄï¿ÿž±Â¶mÛšì|ºœxlÖbÓæ   #@yÇå>Ycf=Å3ŠV¬Úµk›üýÉ“'›ïsz>;äŒ lÒ¤‰yŠÎ0-% ”€PJ@ x Ð ÎH@þ}›pž{î9s“Î|€[o½‘‘‘&.PK (% ”€PJ@ (% ”€(ùÔqUD×Ñ6Þx 3t+ù–WHr»Ý'͈?ó>O7ÒôéÓ1räH³ßСCѵkWìÞ½Û|€3`À€ŒãSRR2 P¾bÔ™,;ëqñññ Èå[Íx×3+§çÏäܺ¯PJ@ (% È¿¥¼Q€Û·o7Pø=o¦áFqKK (% ”€PJ@ (% ”€(ùT¸*¢kÈ~RY{Jùž:00UªTAll,"""2žJOO7¢TõêÕÍÏ(`±I47ö«z衇0~üxí×¼yó“1ßsdç²ÊÍò³Ç8AÎÉ·ø½7^0§ç³‹)ÌÍ|8<ˆððptëÖ͈W^—û[=øàƒ0Û 7ÜÈ(B _o¼ñœN§q@½ð F$*ÈjÓ¦™Ã£>jbyx‡3ç~î¹çšÓäô<÷6lžxâ #¼½öÚk`O.-% ”€PJ@ (% ”€PJ@ (% ”€PJ lPáÊ®¿Wˆ¢uªb+nÙÅŸ·Þz+Ûçüñ<­ôTÇ]tÑEàvªÊéyo?ŠÆÝ×…E ·æ{JÏ>ø^|ìÇ“ÆùeÆs޼Öß ¶`Ûæýæp·Û;®€y?­ËÓp<ޝ‡CÍñ|]ðû‚Xÿgïÿ ¾nY§b‘›I§¥9ðé;à†¾ã0¸÷X|?éßÜæ·ûü2s-î4/þÜ5±;ç{®WöüÓ¾^qÒ8ùù=ÄÁ¦û/’S͸ùý9tà—ø`ÌosüßÈ_0üÖoó½öä¤4óšõýj3Ö©XäæDüÿÃÃwÊï‹?çëû;87Çë>'¨pUŠ^  ÀŠ'ÿ‚9ÝgÏž»îº«ÄRØ¿?:uêTbçŸÝÄG…O>Éß}E$/¯·¢˜—žC (% ”€PJ@ (% ” ¸\.¼ôä,¬ÿo®¹±#þÏÞYÀGquQüâÜÝÝ¥X‘âZ¤8”RJ‹S øW -÷)®…âî.…âîÅ]‚ë÷ÎMgÙ,›d7,„sûÛ_’™7÷ýßÛY:gÎ}%ÊgH‘Ãy N¨P¡|µ;nd)W5‹ÄŽ9PçX:w¿ìØüJìø¸H*É[(E Úz›9tûN…›º}º.—§Ož¿Q;ïûàëF íÕq‘™s/¥þ7ù%sö„9rx¤å8ÏÐhÚŒñ¥|•,nÒoÛŒˆpSGû˜·i3Æ t{oë@g}̹nßx [ÖŸ’²•3ËGù“ËÄQ[åßÓ>ýnkĨþë%eÚ8R¿i>‰ÛKbÆŽä‘n8ûl¿ÉuèúÕ{2aäV#\=ÑüÞôé‘N:iÄSó MGŽAR¤Ž-_·*,Q£G”?Fl~[i‡øvÆø²ƒn¨_¿¾|ÿý÷’-[6·ŽãÎ'°lÙ2)[¶làx‹Gº:(qâ˜/L3$@$@$@$@$@$@$=xEN».­:—"¥ÒùJi×ö³2aÄV¹vÕ[òL)ÍÚ}"‘¼Âë“öŸ×É)k–•СCI½¯ó«càüÙÛRµV©^ï#[;+Œëe[‡Ó’2Mliÿci9wæ–:º>Ê—Lž>}.?~¿HÊ|šIÛŠeĬŽ=ËH¢¤1dçÖ3Æý±En^¿/©ÒÅ‘¶ÝKÉÁ=u?c~‘ÕKÊè™udéÜ.|ÉS …\WÙÙ¼ Îe8óþš¾ÛŒwVu½ÁÃv_ŒYÊ&páÃê¶%ó˜Üžê8¶0펲Q._¼+C~^£óuäÔÚ2ÙðiD·¤)b‰Ÿ gsÃêì=ïÇ&lh¹o~Zñ¸o·årÀŒ}¸paä󺹤Rµl>ý÷ãómƒz¯’0aBë\wp ÿu­qÝÔæ3Ôªs åŽùÛ¼cQ™þÇßR¹f)o9D¼„ÑL?kIxÃ䨡+ꂹïUžø÷pàs]H˜$º~Ž ¸”¯šU³xôð©q#m0×—³5Zùò»‚úYÅØý÷¿æEöü}Î\O’K²”±d®qWÅŠYºüRÎ6gOšëå×5'KX3VÌ5-Eê8¾®C~]o®‡gÿÿ­0nÓÛæ<^Ò¬í'’){"éÙa‘æöuÍ©R¢\z)^.ƒí™&C<ñó³áÇÜ@[¹­kÚ3sµÂYYÍõǯÏGh3¿àhÝa>K‹¥–Ða|?ˆ`µ ¡âÛÛ%_¡”zM(qÖ¶[IuÏ þyµT¯û‘äÊ›Tf™ÀϘcÅí›ä‹F¹e¿ V,<ôà'æëëT°ºvåž,6â7ƒë6É']ûVÐ_KŒp‘9G"‰on®ã¦ò¯£>“1½|µA7N¿17„ïß{"Cû¬µmßaò­Ý8¯|R2Ì™²[¼ï<²m5`ƒ)_8Q_wo¿zßYÖA{wž—v=JK<#L÷·¾=wú9mJÖú2¯Ü¸æSÎ1˜òk(:ÿTÖô'¡ ýåUޏy1R8{Ã>¾i[DJUÌä‹kpœ©á&q­ $M·CŠ–N'e*e’ãwÊ?ÛÿÕ.>yüLŨæŠÊñÃWu¿Ö]JŠ—qjÍ›ñª”Ý©ã7Ô-˜CøœNóªÌäo7J¶’H:D+Äú•ÇTØI™&NpœjrÞäé3ÅWÑÊ>æÏÜ+Û6ž6b{>+z­´] Ž×)uGׯ<.G\–ÖFìÃ5ríò£¶fð¯óU>37Ÿ1ÓyéNg×›8ñ¢¨k¯G¿ æsŰÛUø/V&ƒ¶ q¬ºlíÃßφsǯ5"±uM۾錭Ig9X}>vQiÝŠcRÚ|.#D+/ž¿ö­ãðy€À›ûãäÒ¡gi¢Öšc 1Ÿ‹—M¯b}Àчñ¹nÒ²P°œgA!i:®‚Â(ü—ÃñãÇeРAröìYsñ §B@“&MÌÓá%ñ5j$ÕªU“;vȃÌ“a¤k×®’"E _½Øºu«ôéÓGæÍ›g»ˆ]¾|YPÚmÉ’%=zt?{Ý«W/éÖ­›Ü¹sG† &³g϶틼êÖ­+p=}úTú÷ï/ûöíÓ<*T¨ 7Öó¡}ûöÕœŸËæµ'dë†ÓÆ¥òLÁܰJB(…Àa}6œÍ ëøvÝKJ”håúoãôY‡ÎÌ¡c‡¯hnçÛ·|>ÇÏdzgÏÕE^Ø®gØrQsëè¡«ÆÌ÷Ÿ;/UΣ‡>ýC”ªA·p.`m/Œ\˜`ΠPÿ]Ûìó?°÷¢™? ŒË3³^Cà$:zè²î‚ùÑ l!Àc>å2ÎP8AííÂFü†£‚áúÿGFή7—.ÞÑõ¡®\ôVa5ª™˜ïñú¸“§Š%qâE5¢÷=[s}6œ];q0D$8 plY?\£s°NæìóqÇÌÃæ:Y·I^?á|t ðÂw®ñpÄB¬ú׬eˆë9¢‰qVÁ…啪gW¾x ¢gûE2¸ý"Džóí}~F(\½Oúvç†Ð¡eú ¤Ü¿_:uê$¿ýö›´lÙR÷„Pòøñc›¸1qâD|¸¾öîÝ++W®”éÓ§›lETÉ•z£S¦L‘íÛ·Ëü¡bÑ–-[”ë¬Y³$vlŸ/ãùóç«Ë)nܸ*0Al)P €dÊ”IfΜ)>Tñ ѱcGÉ™3çk¢¶Ah‚˜ˆíò0NsæÌ‘† êù·%BÜJœØçŠV¤J•JZ·n-C† ?öµÇ ÜAôƒèX§NÍc1 Â$„+ˆ–‹-Rq¹ƒ£}¸Ó_ò0/T¨ ‡ 4ÐVþþûoÿܹsû›;ú˜1dª<ŒH€H€H€H€H€H „ÀynhÆKðª|›½kÊBÑÈ ë†6d/ˆËÜðR^=•ÿÂr_á-?~²ÊDáF3„£Li)('Õ®Él_%üœ J¸9s ,ÂvŽÿr°/ ˜óLj æŽaÿ7û±ƒèþ?±¿Û y¸·æ+œÌ5ÌÓ°f\^ÍíPZšqñ_¤Mצì`R©ÿé_í:W?>Çúvü@t‹iJÏÿ÷–­é¥sªÈ9tbMMP6Ð~~:~>|æÚ+±ÙYù6WùϲJY#ÒØÏmŸ¹ÅéT;aÊ» þiµÔj”çµÒ¡Áinb®A(w ƒáÂù.®WóÃG\õ9ÊškøÛ~<^þ7ÕpÍq^Ûéœ]oPj×(”8…û bi@áêgúvZíÁÝd]ÓV->l®üËÁÙçÃgžùÌ5s‹Ú©h}[üP\R›½Â©SVçšëâû/8mQJðÆÕûZ&–á– t×[ÛK’$IlåÖ }÷Ýwê\±5jØþÌ•+—:ƒ8”nƒˆbD§*Uª¸œ?„8gp1üþé§Ÿª“ ®®fÍš©ø‡Þ‡KRƒ^Nœ8¡ÛaöI@̰Än/^ÔÍpAT‚ã ®!ˆDE tp©YûBì[ùXQ¼xq­ÈN)‹ãÑ£GuëÙ³gW™³€0Ñ >ÀMtþ¼ÏGp0íÞ½[ÅCü#yòäNÅ;¿ú'D+D‚ Ô…f¹¯ ¼A´¸V¬XQràíîô' ¶®n‡h 1Í üŽù¦þåØ1w5/îG$@$@$@$@$@$ðaÈ”=¡®ƒÒc‹fï3å×öÈJ³.UZãP‚8ó×´ÝÆ rXÝV¸éînÌ7¥ñÐ.nȧúï¦f@mà~0/<Å¿Õ8¸ÎýWÎÇAäAI«F±ÖpÁû(›'ÅÚeGtrNŸ9¾®×˜ð/¿ÚK•.®:…PV %ESî¥±Ž˜µÅ.ž¿cÖ]:«÷…>„(ZÆg µ¾Ý—ËòÍK;ä qÀd3n;”DÀãÿÖq‡sÍ8fÜEp’€{ä(áÍ qçÂŒc›Xg ‡ÓÂ?÷Ù6[Â$JnZãûž—§>ÖÉž¡ö…¹§xÉ̇ÕK}Ü{þÄ7¸sÖ˜¹·ØFSÞÐ1°‘ÛŒ8r邹f>3X·Ê¿¸f\`?wÊÉa5ð„e#ÿ=}S×Ãõ ×7\/²™²p=Â4gê?æÖ\—|îºëWóa¿î¤Ë×4´¹†5ÜŽ¼¬klYa ×(O¸w×9_ixâ³aß _9øÕw¬/§Þ¬‰»´„§³‡°~!>skÛêÚÕ{²Çˆ°×ÍOÿb®qÓ½…ÏæÜgqã»ö™uuœ>”ý>Œo 0š3fÌP' ^(yçpSÁ±dÖ|”ƒCàËÞ^¼« ¥Ýœ„ ˆ&™öìÙ£¢D"wÄŠ+ÔåµsçNuAÔíÕ«WOËâ5uêTu*!àÆBI?”Í«_¿¾lÞ¼ÙéiQ¦oèСê:ÂZJ#GŽT‘(aD¸~ýú©PøÅ9[Ì>ì9Bp‚c \ñB¿!^9 Zè#rÇ "›•‘"Eä‡~PC8Ç^{BÆŸÎĉã»Î.„1”SD /-öø‰>À•æîôÇÚ?ek¿¶C€D)KŒ~nذA ®”{`Ç<°yò8 I.ÏÀJ´¢Œì» ”€õë{Ü1Àöë]ö‡ç"    F÷QÚÿ¯”–«šdófîѬ^‘#¨ 7Ç ß¢ëJ}V;—[éÃwÊ”±ÛµÔ[™O3¹t<ÖR©\3»®«…››XÅ ”á»zÙ[Æ Û¬eöìãÛöEÅ+J-Á%j³.R1—Îçl'ÿrð«Ñª_ä¤Écj‰Bˆf΢ŠéÊŒ¶I~í±\E…%à‚iÓ¥„®C6ÖŒßÆÕx˜;”ÎZ_æÑrwËŒ U³anÉ‘'©[X’¥Œ©"ë9³¶ÑWf½WKŽAHÌj„3f7¯ß7¥,}Æ ‚gy³Ñv³Ò‚Y¯-lóÄgþs%ÌúVqX„µâ°Wx;g£_7/(î?•é&o”étßµ/¢ë‰ýÒu™Œ¼ÑÜ»t~Ô:‚JÃ]dàò„ÐC™›ï¯¯<æBgQvÍYù7 1»ÀÑ„rmöë@¶s"þúë/7nœ­‰cÇŽéºFëÖ­S eõ FÙ.º‡iiA¬[…€€õ©¬õˆ ª`Ý#”©ƒ› %ìN$¸` 9kî¥ZµjɦM›Ôá!êêÕ«RµjUÍ˱4cûX¿ 9Nš4Is°-Z¨; BænÊa(”׳”"Di<œ«{÷î¾¶9r؆õž¬‰Øç‡; ëH¡ü!Ö^²Ö´Âvg(£ ëi¡ì!Ä:\hëz9–)Ä~¥K—Váê“O>Ñœ ´]¹råµá"kß¾½r´«ÉK”#Öƒ ˾~Í»€úS¦LÓ§÷ù‡2D1äh­qå8§ìÏc¿Í1g°€ ,sæÌZþÏ*géjîþ¹»Ÿ1”\„ˆfÿÙr·¸?Æ¢6~2H ¤€Ô¡C8p Û]‚;kJâÚþ®ßñøþ èßSoÒ¯wÕ—w}üÛ¥€ño1ŒƒBÒæˆ-_4ÌØ&x\$нõB)œ+“|û¥çþŸ1káöò×ÚoB -v)°˜ŸÝÛ,ýû¶ =®ÏùrûAT0pе㉃Ÿyü…Ïz‡îDà<ÅîœûºD páÂ*`¬ZµJÝJp/5Ê­ò~Ž'Âÿx÷êÕK®]»öÚJ.%ev‚˜Q kNáÂÊâ!_¼'\apDÁ%†ÿ€Ó7âPî.R¤HNÿqŽ§Û±îöCíKBÂ:4(›Ë•ÿÁ@¾3f4‹VF‹¦î)”ìÃLWåø ZÁAæ_ |!úŒu¾|àN³ÜYgΜ¸¦pÓ¼à&B»Ž+V,=m¹zƒâú÷n" àþ#Ǩ?#1¶–på 9 4 ÖÿBéD8æ¬ð/wÿÆb)\eÖ:e®Œ%÷!Ùð€‡"à…Hçëúõëõa ˆäx`|Àñ ñûàš‡2·¸æÂÉ‹k8ÜêÕ«ëC x@ˆÀ9Œ5Ï;§kb D\Óì_|OãüøC»øÎƒXŽï5¸Šá:ÅwÀx ßQ"DЇ9œ9q]í¾ßpÍF»Ñ£G×ïcüÄù!äàüwïÞÕïÙ/¿üRSÇZ’þõ)dÏöŽH€H€H€H€H€H€H ¸ pDF "˰aÃdÀ€ú‚T¬X1u\6 jàæU–,YTD L`­¥þýûk™Aˆ,Vüøã2|øp©V­šÞ Ä98¨ \ÁÅÕ®];½ˆWƒ œ®sÕ²eKùõ×_eâĉz³ O¿YëIá†ÜUpíà_Ò¤I¥S§NvŽ0Üd„» ákРA*¹^^^ráÂ)X° æŽ~ÁuÕ±cGÍà ŒnDb2쇛Ÿpfíß¿_wÁÚRØŽ›’\P>åbS©R¥ô¦)r‹€"kÖ¬ê*ÃY:¸iŠuºFŒñÚ¡õ§M›6Ò·o_3̹¼yóªÓìMó UªT*6B`³Â¿ÜýsŒ=œm˜K  ¥K—ª“ßqxÐ×Zü‡ºvíjƒ„ï©/¾øBÿƵ‚Ö7Ü·oŸÀuÚ¸qcݡߕp\áü 7)ÂY9?<УG=/®ýp/C¹p΀úÄ™E$@$@$@$@$@$@$@A…« 0 ÿå× n<9 ÜL³/ˆ}ð¸U&Û;–¬6ð´·õ¤µ³vqÓ/+Û€˜±víÚ×…ÐÔ¥K§¹âIu¼ BŽç³D8¶¦M›Pz“Ñž n BìóKðÃMKÇ°ÊØA8Áë(ÛÇžbÇ{¸ù‡²ˆöœÐ„*¼ì%Ž\ýëD{Qο­vàÐÃË¿p¥?7Çï«{qÈÙœr6Wœål•ŸtÌѯÜýsÜ0†KBƒH€,ýñ‰R¯p*ãga×<È2D%WX3²@NÁƒøžƒèT¡BèîxÈaÁ‚úð„"{W-D*+Є&¸ŸøÎÇ9Õ~ᡜ¢ßcÇŽµ5…X‡ô!SZ&Ô'Î.        ÌaŒJO[7´Bj=Ù/ÜXÄÇäÉM p#Z!PDÁ-BJpƒ7^ƒã·9Ã|I 8€p7ʬBüGiTÇØ°aƒº¡àð…C6Q¢D¶]àHõ+°Ø*œQpjAøBi\Ç€ ¥ñÀÊÔÚ‡ãšK·ðr\/[8†+ýræ³oÇ~;~Çy]éSpæJ$@$@$@$@$@$@$r ÐqÇëdÁE„’o(çx£,vÙ#]Bi»zõêI“&Mt}ÊbÝ’lÙ²yäﲑÒÜÄ8pà»DÇs‘ x°®`”öCYÙÇKÊ”)µ\,B@yW,ÑÄ|8¯ü ìµöhßpGÁA ÑË1à ²õmÛ¶H n'”E¹>¬ouêÔ)ÍÉ1\鮋p%[ë#nÙ²E]ØVìܹS]hËPF›+} °ÜH€H€H€H€H€H€H€Þ Wïò»>nVáÅpŸ@:u¯!­?!e\Ø 7'0wî\Ù³g>`ÑÉZÓ " Ö%,R¤ˆ® ZÆ’i IDATgT·nÝÔ™…õý ¬møË/¿èÚ|xˆa̘1Ú6K5kÖ|í0¸±p<$òÑGÉ¿ÿþ`§àüƒ%È%!´9†+ýBYÚ† ê[pw¡oxÈŠرcë6ooo-)ˆÒ¼åúõëçoŸìw        w@€ÂÕ;€ÌS x–@­Zµ/ÇpìQNÐYŒ1Â×Û $ðUЯ㬃*Uª$xÙÿß×X´_gë'öïßß_®ö "œ_B\æÌ™_Ë#nܸZZ1¤dDzŒ!¥ì @È$ð}ÓÙ’ QtSÆ9”ܽóP¾h˜G2dIàVg¯\º+Fm•'LIhÓNÊ4±¥ÎWùÜj;ÿÜe©(’JŠ–Nïö±< xx“1þ{Ë™BYmD¬úMý^ ö}²â¹GÀ•1ÆÜxöì…=pYçIÖœ‰¥`±4¾N¸sËY¹yí¾œ;{K·ÇOÍ×ö€Ú¸së¡Ä‰%pàQ!†ÀEs=ÖgܹýÈ̹çÒé§r¾úÆkVˆjw„•Ǒ²Aš~þùg‰/ž|õÕWJðúõëR¾|yÙ±c‡ÛÇ?{öLþ÷¿ÿɾ}ûÄËËK¦L™bž¸áåÇm<€‚û÷ï ® wïÞ}gYa*¬ÕÄð<3gΈ···çöP‹/^¼k׮ɉ'dñâź¾XÁ‚=Ô:›!   wGë» lÚ훤÷K¤ÿèj¶“ç)Bð7l“T®™ÝÜô÷-RÅ6"À‘ƒ—ýM¶rìºý×Ëå›¶E^Ûwß®óòðáSÚg­¼xñRNšòoµç¡+æÝM·~&WÆsãž÷#™hÖKs6OdîÉm¥'ý¾UÖ¯<&EJ¥³åP1byiYJˆ§Œ—@¢$1¤ÅÅ„ù±C7Éwí‹Ú€ðšõá΀zÎ;Çòàö#GŽHĈ%EŠlÕ½¦–-[&eË–uï î,ܾ}[:$ rù®^½Z.]º$sçΕP¡B¹½˜gë"˜ÄÊÎ;ß9…ÆK•*UÞùy?„â‚íÛ·鮯ŒSâĉ#;v”B… é\™ D ’W8§åý;.C–øòÇðÍrîÌMIš"V@§pº}•Ywè‡Þe%‰Yw1yô6Ù¹õ¬ä/œ*Píñ  GàmŒqä(å-݉Â%ÒÈ̉;0ö ï¹.ïë3¹Õ÷1ŸÝJ€;¿7®Þ!úI“&Iéҥߛp…§©'OžLáêŽù»<Õš5käÔ©SAR¸:}ú´¤OŸ^„ábœïrNð\$ài?–Ö­[Küøñ¥wïÞ’$I‰;¶§OÃöÞ®]»¾‡³ò”$@$@$@$ðáüÓí4nÞÖûÚy‰¾Æ-œ?¤!B8S^°”Lú}›˜Šæÿ±CÉGù“KéJ™^ÙáÇ2¯½w÷ÎC-Ah‰VØÇ޶™ÂU™ŠîŒq”¨å»Åüìùö§åò…»òìés &´/—Œum`ný5m·ôl¿X"D §‡´ê\\×Vc|8°ŽÞ€ž+å¥qxÞ3k\5úÖù÷¼f}8sÂÕžR¸r•Ôî7lØ0Ù°aƒìß¿_F-Mš4‘bÅŠÉñãÇeРA‚õÂ… '¥J•Òmáÿ~G©7þ\Æ'pœ=}úTräÈ!:tжЯo¾ùFÚ·o/}ûöÕc±vÚŒ=ºòF_oÞ¼)X; ñTú£Ge«SäÈ> ªNŸ>]æÌ™£ç@Ÿ›5k&eÊøücÑ•ñƒ3i̘1šC„ ¥sçÎ’2eJ=þóÏ?×±[»v­žûÁƒÒ´iS)R¤ˆ¬\¹RçÎûÏ?ÿhù¤ï¾ûî5¦èCóæÍB|ì×Cñkþ8›þþµýþãÇ— nxïٳǖ³çòkLÞðcÈÃI€ÞÀ´iÓôû×3 €{úÿþª, {G¾Ú;YÊXÒå—ò:.\P{¯%zýþûïÒ£G='¿'K–LæÍ›§ý „'ó± ú—;wnSKù…´mÛVfÍš%õë×-%Ü…ð–3gNdÀ¢PÆ æ¿hÑ"]; Ç]½zUÛ„0‘Ê*ׄ38Pê ù" ’Íœ9SÐ{÷À’%KdêÔ©#F «ÀBD0,vÙ¦MÍÉÊ?{ö캘c-(l/Y²¤Íiäßøp#ÙÊÃY¥î ²8†%ÌXï£Ä ÚƒáØÞ•+W\êe@ùXØ—êƒ{)R¤H*Î<|øPňYVÀ)•(Q"Ûß±bù^<ûÉã,à Bé=ˆbˆVkü K0²¶ã\p%!à.‚Íþ)zˆap…!‡%Ù·oÏû 쟤”!D@¤‚{ Bž؆÷­ðoüãÑ£Geýúõ¶ýÑþ­[·ÅÏ',jo``åïÎüA®µŸe6¸r.¿ÆÄ¿v¹Hàí€`€ÀbÛ¶mzÍÄÃø‚‹öï¿ÿˆBˆóx×}ì»xñbuÊ"ànÆÃ"ÇŽS—+Àƒ$Öõ À¡ ¥YñÐ@áÂ…mÝv¶= L.[h Žcgç…ûÚq¿sçÎi™_|Ÿ#_ä0$@$@$@$@$@$@$@$ð! põG7ÿ6nÜè+¬œ‰VØÑOœ¥›\8Þ>ð·£8æW—Ê7ï(Ç‚ˆ*¬P^n¸Í˜1ÃVÚÎñ\þåo¿/ÚÂZ_X?ê“O>ÑM(ãçŸçØ÷‹/Š%Ô€ÖÒBÙ@ǰ7g\\ÍÇ¢„ãæÍ›uÍ2Ü…h‡µ¦ì# ñƒ«Ë~Í2wø¹’«½›Ì±mwæö h¬ýûh¹r.Wúó?¾<5 @ Wm¿~ýtM@|A´êÕ«—^Ÿ Áñ[ºtiuúæÏï³ð5ÊåâZ÷¬ëâÁˆ`·x!mÚ´R«V-ýÛz@Äê²³í8'ܤß~û­í ¿Îë¸Êï¢Ì+¾³ v¡¬­e_ƒz¦@$@$@$@$@$@$@$@oK¾5´¯7 \DVàém|¸äÍ›WËéAŒ@y<<¥n‰\8Ü5îŽÇ±(wˆ@Þ+V¬ð·™µk×ÊÉ“'up^ºt©­ #J(â)xË1Qɯ…îæjí¶qÓ¢žê{{·U@í"G¬—uäÈÛ®öó% ã1¾{ËQÐþŽÛš?öû¿éX»s.wûÁýI€>¹råRÑ k'nÀxPnª›7oê¶ýû÷«H…ï,¬±‡ï”=DXnÛôéÓÛJ½~ôÑG²k×.ucamGÇ5¯Ún€³ó:ŽrÁµ{èСš7D-¬WÈ        •Wïpä5j$ݺuÓ'ºá&ªP¡‚ 6L  /¬éT¬X1Áâ𠸉ڵk'?üðƒ–Àƒ€÷ŽåXÂÓãXO«zõê*2Mœ8Ñ×iP/ |àƒ˜„5¢pÓ¥ üñG[;ø7«U«¦,äТE ÛEWû…\Z·n­å’PÎç¬[·®Þ|ô+Ê—/¯7&!­ӦM›Ã Ç"š6mª¥qëIa=.O˜téÒEÇåkÖ¬)XóÊÕÀzY;w–Þ½{«« Ö0Óø®DÙ²euÝ.¬…µ¹à"p'š?Žm½ÉX»{.wúÁ}I€>(Wk®™p­â»Ã>°ào¿ý¦¥ñd­ûˆý(ÏŠÀƒpríÞ½[Æ€;«dÉ’¶fÚŽý:¯ãèà”à…àÆ        ¡põgD 8jìOx=Ú¥, j8„ûÀÍ5¼ü ¬ë—_P>p…A ÁËY`)ˆ7΢L™2‚—}Ô¯_ßÏ\ 6Y‚“µSÕªUýÜ7Û·oït;nF6hÐ@_Ž—”ýºTÖvGÞwîÜéëð† ÚþFùÄqãÆùÚ‘Яöð¾ãøÁõf¿ŽŠ}cŽsÛ6mÚdÛìý›Ko:9ù7ÖŽŒ[¶lùwÿæª_câçàs ÀORÀ!ágÀŒ5ªð†€h…’X+_¾|ꮂh„õ±&\O–C ®_\óáèƒ .ô%\ùµÇ`½-¬íè×y1Pöû!7<‚¼àZFà ûõ$?øÁ%       Š…«j¸ß¼³-C÷æg¸… œ[ÀÙs  7!€‡àÒíÓ§6áê›o¾Qg0Ö9„ â•ýZ‡ÕñA„TìJ™2¥ wïŸþ)áÇW÷+Óöá×öråÊézŒ©S§Vç—_çµßî`Yî'gúµk>âe…_çuÜŽ/”‰e °T 瀂rù6geðÜèw%    ð‡ÖŠe À» @ÇÕ» Ìs @0%0iÒ¤`š9Ó&    Ž(\ÇQcÎ$@$@$@$@$@$ðŽØ—A}G§äiH€H€H€H€H€>`¡?ླë$@$@$@$@$@$@$@$@$@$@$@$@Aˆ…« 4L…H€H€H€H€H€H€H€H€H€H€H€>d®Þáèþùç²oß¾·zFœcÏž=oõlœH€H€H€H€H€H€H€H€H€H€H€Þ Woƒ*Û$           p›…+·‘ñ            ·A ìÛh”mLàäɓҪU+éÕ«—äÌ™SŽ?.ýúõ“‹/Š———|÷ÝwR¤HiÔ¨‘ ü_ÅŠmöìÙSbĈ!-[¶ô÷DÏž=“ñãÇËÒ¥KåÑ£G’(Q"i×®d̘QÃ{¿þú«œ:uJnݺ%.\¨Q£Ê7ß|#5kÖ”©S§ê±/_¾”£GJ„4§ŸþYnß¾-ýû÷×Ò‡a„‘ *HãÆ%T¨P²|ùrÙ½{·$MšT&L˜ ýëÛ·¯Œ=Z–-[&Ož<Ѷ¾ýö[)Y²dÀ°¸ ÀA€ÂÕ{æ³gÏJ›6m¤{÷î*ê}ºDŒQ…2ˆ\            °°Tà;ž ×®]“Ö­[K§N$oÞ¼zö7J„ U´B¤L™RòäÉ#6l%J„®'Nè¶U«VIºté$EŠfѨI“&*Z! ( 9räPG.*ü  ¯ìÙ³«ó Ÿ2dPÑ mp‰!®_¿.[·n•fÍšIèСեõé§ŸÊš5kl9A¬‚£ "öAÿ¼½½Õqñ-f̘¶¼ìw           ø P¸zÇÃ2hÐ -—È        €œ9uCz´^ÐnÜþ¸rùŽ$MÇ£=ÆÂë–“Ã{®x´]6¼ œ>y] ûÜRõhðšæQœÁ¾±û÷û>xª®}*µjÕÒc áž^^^ráÂÎàœ‚ ×JB¸‚U¾|yÁzUxam-¬Í•8qbùñÇ5g¸Áà ñÈÏ/á %ᮺs玖?D)A´Å        €à†±§otΠº}ĸårðÈyÙ¯qPM1XçU¥|ž`¿'“¯Öh Ô¨ò±Ô¨ü±'›e[ÿøöËÒda\¸tSÊÖøEÖÎï.qbE%P®ÞáD˜3gŽílŠ&L˜`û;eÊ”ê¾ò+MBÉ”>i€9¿R¸vX€˜‚Å®‚ø0a ¬·uêÔ¼œEþüù/ @Ð!(AL³Þø³ “3 2fÍß*‡3BR"Yµþ€` ¥ß'¬’&õ‹ nèÇMÊ•È!“fnÐßsçH-KVî–§OŸIê…dÎÂí’ ^ )R “Ìœ·Åö{¬˜QÌÃíÏ"ÂÑ“¥~Oäô¿Wå£ì)eÌäÕÒ´AIٵ甄Z^¼x)Ò&’õ›I¡ütmºËWnËÜ%K²$±¥B©\A† -;ŽÊy³.Uã”Úsà¬Î³MÛŽHŒ‘åÀás&thÉ3•lßuBÏ•=…,Z±[¢Fލ"繋7lóÇ[s6]êDšÐÒÕ{äØÉK’ÿ£Ôò÷î“R¡LN™0}ƒT­Gçá™s×äú oiÓ¬‚´é:IJ˦óqðÈ9™oæÚý¥vµBë X*0H “         € T­WoØ2HÀ‘@– IåþýGòäésÛ¦/_È•kw$Nì¨ZöïžÙný¾ÿп5JD‰oĪK—oÁ)±Üõ~¨ÇÚÿŽ¿ÑFL#LDê%aŒ@•.UBù8O:IoĆôiÉ>ÓVôh^òðÑ=‚^ˆíÿœ2ųS´ !S6ïçÏ^ØÆÝzñò¥öîò•[R½r~I™,žíw¯HäÆMoIhD÷ËWo‹ý'N]6óï‘._cE´h‘dóö£RÀˆWYãÇ`ãà æÕ<þgÏ]—ËvIlãdo,¼ÇÙ“ C­»LÔW¸Ù_²HVY°t—DŒN× ÊžÅˆ·¼eÖ¼m‚Ò€ñãF—ðáÚ5^Ëô9[$­qœ=݈ ´Ì›õû“'Ome´ò}”ÆVÎ x¬Ò^ò¦†´˜²+>ÎîµÝàtB´ýö•KÏþ÷VFx°¢béWeü²eNî«-ÌQûhÚ°¤þÙ¢IYý ו_‘$Qliõu9WºÀ}‚¸šðrpPYו®Ž‚ø1œ:Jk}VÐÖJPZ2…ö1¨wý`@)ºB€Ž+W(q        ð ˆNåK攞ÈNãV(W2‡4ø¢ˆl2®J²ÁUðÀ¸^šÿÿUã2ûŸ”:fýˆSö¿Û—ÑJœ(–­œ—³Ò^ì ›"   påQœlìmؾ}»T¯^]>ýôS™3gÎÛ8Å[ióúõë’7o^—ÛþùçŸeìØ±.ïÿ6wlذ¡¬[·îmž‚m“ ÀM tèWe°òšµ€Vo8 ÓfoÒRX”×ÂZBÍš1°6m?"+Öî—-s ™hèì¼[9§k[1Hàmxôø‰ü½û¤– Bc´fÍ)P €±…GÔ¬ ,X°@„²t?•#GŽhŸR¤HáþÁæˆ_ýUÚµk§l^¼xaê¡Þ–C‡éßÁ=êׯ/ßÿ½dË–-¸w…ù“ ¸AÀ*qe•\kT»¨¯££Gó’*åóØÞkÙĵRköe´ìËyùUÚË”¹k'0òrѬT¹\nÉcÄÔÿõýSjTùXjTþXæ/Ý)ó–ü-½;×”Ä c…pìÞÛ&вÓ]ïêÓ²驾lù›¬ß]?~ªkTaMµ¡¿4|Ûi°ý L€ÂUœgϞɀ$wîÜ6á*GŽ/Þ«Z A$U·Ó˜4i’”.]:P¸œ={V2eʤç :´@à;uêTˆ®Ú¶m+iÓ¦u›)         O¸ëýP*¼rçH%‘½"Ê®½§dðoKÄûÞ#OžŠm}àŽ¿ k7ˆ¥X‡-}š„òë°²tÕ%ƒ¿6 WA`üŸ&Nœ(p!_ĶmÛ¤yóæòÇØ\SȵbÅŠòñÇ+¯™3gÊŒ3ŒÝõ‰$H@ºví*)S¦Ôã{õê%ݺuÓ±q ÿúä¸ïôéÓµŒ"¸„ Jš5k&eÊ”ÑÝ>ÿüs©]»¶¬]»V=zdjc?Pž¼7tèPY¿~½ûÉ'Ÿèd =Ûwk7îJÅÒ¹‚^rÌ(X€ ¢Õ½ûtÝ5ÇhÖ¨ÝVÁz„ƒNò?uùB]VˆåköêÏ£'.ÙìØ²rÐI–™¼\ãê½`÷}R@p[!ÆŒ£‚Ç€¸6lXƒþüóOŸ~øáùñÇU8éÑ£‡ >(§‡˜2eŠ@D‚ã D«ìà¬Y³ÔÙ5oÞj3JæeÍšUËåÅŠåSW6sæÌ*”\»vMÿÆúX7–(Q¢èþuêÔ±9£Ò¤I£.$ˆ0º’'O®¢˜clÙ²E-FˆÈ‘#«X4wî\‘ƒ€âZž`ß\é“}#(¥=zt} }ǘ޸qö œzÈ‘%Ku¶YódåÊ•*FŠI·Ã¡eŸ€Ér  xKðÀ ¾—ì̱N…ïa<ˆíp6;ÆåË—Õa ×ñ®]»ÞR†l–H€H€H€H€Bâ…³ÈGÙS½Ö©ZU[ ðÌ©(‘#újÓmå)ÂÁ»– |Gã‡*Ç׳eË–MËÿ¹\PÒÏ ”u‹-š¯f°å´Á ¸ì)C(+b ÊØ¡D`­Zµ¤fÍš¯‰:py¡DŸ}@$‚ ¥ =(‰‡Rw·oßÖæ ZA\q5ÐOˆA¸qeœO¬@™@g‚•µÝòçϯ‚úQ/_¾|*¡ ¼íöcd/¿½ʯ>ºÒ'ëXˆTpÐáÜVà–óïYâ¦}^(]ˆ@IFÇ1¦påêìã~$@$@o‹ÊßÂen­w‰¸øÎµà,Y²Dÿ„£iàß6VàK°Â¿»àLf L®«²5|–É@@È‚ Å OHœ0– <%Ö¶²S õ$åàÛ…«w4v(c‡—_ÊÓ1O";[? çÂͼ°.ÖØÂzNX›É>âÆ+pÿØD¸¸ð‚(f/`?!®Ä¬{…'©±¶â÷ß×õ¾\e…~ÂmæŸèŸhån`†²ȱdÉ’š&J ¢J3¶mÛÖ–z`ÇÕ•>Y'™üË#vìØ:föBŸ%"º:ŽÜH€H€”Q£FéSͬŅ›AX# §žp¶¸}àFrØy$K–L7C[±b…¿ ÐÎg­3‘ëH-[¶ÌöÚ¹sçŽK,ÝÍÂÕÁƒeÛ¶m¶ò„(Sá " uoîô ã ‡D+0ÁÚ_Ž7ïüËâÛĉuìË—/×q` Àû$€·õêÕSUÞ¼y}¹­ÌÁûØÞ Añòòò•nùòåõßø÷Êö2H€H€H€H ¤ÀÚ/Jù,¹À ·EëY¥O“P1²$ð¶ôî\S°†Ú­>}[§`»ÁWAhÐZ¶l)]ºtѵ†ú÷ïÿƙխ[WÛÀú(íwT¥J•¤Y³fºFSëÖ­Uà€h†òvØæ(Eˆ2= ÐnÁ]ôõ×_ë®ø®.ä}Q ¯P¡B²téR[SX?©[·nêR‚»ªB… ¶m8y`Ý,‹LÈ{ÿþý~ö¿lÙ²ºVÜa(yØ¡C-ÃÇÑàÁƒõélq½{÷¶­ûäLws@¹F«ô „;Ä*H¹råÒ~¼iÀýæjŸ°vøƒ+æJ>bÍ+Wc‰K(µˆñÄY–ûÍÕ6¸ ¼ ø·^Ζnk8‰ÿú믷‘Û$  xFþ±BÝŒW~è58 ¸4& oæ1 ¾%ÿìóy@›!ZÆÍ¾”Û‡Ì.4yžŠ¬…Û{ª©ÑNõ/‡ˆ~x¢û7öóD3ÁºPÆ ó20=@y1ûµ“ÓFp?fΜ92fÌ™={vpï ó'K`ôèÑêãÚ&¾‡xèС²gÏÁO ¸Bk B@X¸p¡+»s vàè¯Q£†ìܹSKb3H€HÀÓnܸ¡k7º½(aι{˜î&Rr Þ÷ºÕjè ºuë–|üñDz`j{I™,^ [‚pµxåA©U½j Ûà!À¦­;äáƒ2q„ç„«úߎ¯È±¥ÐÇyC0ö(ÐfÌž+åKfö¸pÕ±ÍwΉ†<ÿž¿ Óÿœ'o*\õ2_n?ˆ*zïžyïyáSéË ãÊZÜ—H€H€H€H€H€H€H€Hà½ðòŠ(Ò¥y/çæIƒ&#ÇNÈ9#\y:âĎŹæi¨Á¼=\ÞFðšö6¨²Í@€k\…„QdH€H€H€H€H€H€H€H€H€H€H€H  p‘]            @€ÂUHEöH€H€H€H€H€H€H€H€H€H€H€B W!`Ù            (\…„QdH€H€H€H€H€H€H€H€H€H€H€H  p‘]            @€ÂUHEöH€H€H€H€H€H€H€H€H€H€H€B W!`Ù…IàóÏ?—}ûö…ÌαW$@$@$@$@$@$@$@$@$@$@$à„…«>-êׯ¢Å#GŽÈ™3gÞx׬Y#=r»uëÖIÛ¶mÝ>Ž ¼N€ÂUŸUÒ¦Mb{9iÒ¤7®ž={& 'Ož¸Í)GŽòÕW_¹}            põ^çJ¿8p@…ŽB… ÜB·oß–®]»Ê§Ÿ~*U«V•±cÇÊË—/myþùçŸú~… ¤M›62qâDéß¿¿n?qâ„”(QÂWŸ¶oß.µk×¶½×«W/9zô¨þ½|ùréÓ§L:UJ•*%:tÐ÷W¯^-_|ñ…T¬XQš4i"§OŸ¶¿jÕ*©U«–TªTIs>|¸S†8oƒ ¤råʺßСCýdíŒÃóçÏeôèÑòÙgŸé¹ºuë&ÞÞÞÚ~vîÜÙÖv:uT¬6l˜lذAE'ôyíÚµºÿôéÓ¥Zµjº•*U´ßVüüóϲhÑ"éÙ³§|òÉ'2eÊi×®\¹rEûŽv._¾lÛãS°`A¹zõªí½/^hÿþûoA¿í™¸ËaöìÙÒ¼ysùòË/¥Q£F~ºãNž<©ã³{÷nÍãøñãòõ×_ë{5jÔõë×ëûÈ ¹!oÄ;wl¹úw¶AÀÀæÚ}ðàŸãÈ $@$@$@$@$@$@$@$@$@$@$àia=Ý ÛóŸÀ/¿ü¢BU† tÇÖ­[K²dÉdÞ¼yrÿþ}ùæ›o$~üø*Þ@7nœŒ3F’&M*‡VqÂB`"UùòåeáÂ…>|x9xð ŠY8GŠ)TÔ3sæL *”ôèÑC ž%J”HKéݼyÓé©'N,ÉЗ[·n©pT´hQÉ–-›Óý9À9…þâ§———ôîÝ[¨çŸ5k–DŒQ!§³gÏ Î×¢E Ù³gÔ«WOÏeEöìٵѣG—;v¨àW²dI &Œî2bÄåÞ¥Km¢]þüù•A´hÑ|å#F —.]ªÂâPذa%wîܲbÅ _û»ËaË–-2hÐ .œ-×ùóçK”(Qlí¢¿èC÷îÝ%gΜ*&AìêØ±£/^\…ÆÆËøñã%OžC@ü¼~ýº [p5mÚÔ¥ãÐ/¸¸ Z!  2H€H€H€H€H€H€H€H€H€H€Hà] ãê]Ò6ç²"œ¢Ä8†¬xúô©M‚h‘9sf_Æ‹OKº6 ‚Ù‹5È¥­Rsh‚\Sˆ‘#GÊŒ3´”$¸œìû`åã—,Y"Èá ‚‘_áØò€;È^‚ Z‘"ET4ƒ N"”.¬Y³¦SÑ ç„Ð‘Ë ”!´ÏÅYþþñ„ n%¸Ó A ‚ãÉY¸Ëb¥}ĉÇWYÂ!C†(Kˆ³æ \Vÿ¬€ÎêÂöý÷ßkE‹)ûwV $ìÔâq$@$@$@$@$@$@$@$@$@$@$ðÆ(\½1B÷° BAT€0d¹nì[ƒóÎû@©>«œŽu‡÷wÌÎÑa„PÎën9 œ .lGÉ@W(3hûöíÓÒ~(UÇ% ý Ç<àîA)¼9r8=,_¾|‚\\íÛ·WwÜaŽ1yòdÙ¼y³–ß‹9² iX£Ê>ÏíÌ9å¸?V‹/V!Ž-gn¤Àp°_; ç<þ¼`L¬@ÙÆ'OžèzdX ¥ qnäQÑY@è‚àÕ°aCý‰’†`ÐqÍ ^¹+ì¹÷ àÞ$@$@$@$@$@$@$@$@$@$@$à7€ë¤‘Þ[#%Þ .À1ƒ¸wïž\ºtI/Uª”–ЃS· ÊÄYw DS§Né[hû»(ƒ7gÎ9räˆí°ãÇëïp*a]-Äž,Y²èùî¬H‘" „Ä®]»äСCî¤!ÈBŒ%¼Ap:s挶Ÿà‚30?Ö¿cÅŠ%V¾øÇ#5/_¾”iÓ¦ùëüÂ1p˜ÁÑtòäI?s¶J8¢|£_kŒ†Üa·oßÖóB„0‰9aÊ.b D$ÖFƒP !ë^-[¶LûˆÀA‰G\iX— ®0÷óÏ?ëûW¡B]ßÊÊ¢šå sk0¹3 ’Wç©Ã~üñG>|¸T«VME!.p5a £?þXêׯ¯kA@H—.”-[Övj”σK©K—.ê‚k ‚ÅÒ¥K]N/kÖ¬Ò¹sguLA¨€‚óŒ1B‡¨vîÜ9-Õ‡Ü Š8‘U«VIÕªUÅËËK>Ÿ}ö™Ë9`ǺuëêþXÉÛÛ[hXÿ ë¡”!Öy‚h‘ ¥û° ѨQ#éÖ­›º¡àòÂÚXàbJ bÍ«€¢eË–zŽéß¿¿–´ŒGªT©TàcgX§ kŽApƒ§ÖùrŒ6mÚhÿÆŽ«8Ìì;xð`!æa ×­[§B_=´‰o¿ýVWXË â _ÇA¸Ã~˜g˜s>”رc˸q㜺âÉí$@$@$@$@$@$@$@$@$@$@$¡Œ[ÅDzáfশýzDn"v‡S •Ù³g¿³þ :T.¬_ÄÞ°FD7¿Ê#ïÞìG­ë“A„c¼"€kÉž={?$à ¸~!ò;–‹uåXîC$@ÁªÔ¨QCvîÜéôA¢àÐæH$´ ܸq#PkVG÷B¢„9¨Î…‰”\B…¨c=uª“àÁÜSÛKÊd¯Jã»ÛþÈ?VÈÚÍ'¥c›îÊýC0y‹–™®ËÄÍ<ÖËúߎ’dÉÒJ•Н÷Xãl(Øøuð0)Z µ|ûeiõ!káö2~Ô µÇ†‚?#ÇNHßA#dÿÆ~oÔ™>CæËíQeÀÀ÷?¿žyïyáSmÎ`©@whq_        p °qËviԬܾs×TFy!?õ"Ýzýj*›A.3”ib„ (5É       9þ=wÞ<ù¿\*W(+É’&ÑŽíÞ{@fÌž'wïÝ“\Ù³Êg•ÊIûn½¤jÅrR©|iuAMž>[úõöq<}öL¼Í¾ˆgæw+à’9f‚\¼tEbÄ0kUש!Ó§U§\.ÑÍú燗tiSI‹¦%tèÐæfótÙm¶å57œñ·³p̯QÝšrêÌ¿2høhiX·†Lœö§tjÛB’&Ir*ôdõúMf¼™ò™ÍÍÚô´Gó/—Uë6Jh#U(SÒÌ“h×^çï[JÚÔ)õ÷SgÎJ‡ÖßêþpâYsÍè`¶Ø³ÿ LŸ5Wnݹ#ÉÍ|ôH&L™%paáZTï‹Ïe×î}²iÛÖÿ' .œ´îØC²fÎ éÒ¤Òcîß a„–ûú‚ƒñ_mæìãÇO$WŽ¬Ò¸~m3ÎHÿ¡£¤XႲië=G‹¦$Aüxêâûýɦ­0’4±ókÒ%3!êž;AR$K*_7ª+±cÅT§iãúµdîÂ¥R¶T1)Y´pȨÖ:®BØ€²;$@$@$@$@$@$@$@$@ï‡ÀäsÄ+’—”/]Bx`nÒŽž0E²gË,Í¿n${ÍÞ‡š›¹©eÿ¡#ºÏó3YÒÄ'v,ý{ó¶¿¥eûnúúgï«rZ±bÆ”æFë÷-¿‘˜æFîŸsÚ:yúì9&jU¯"û1ç9¨¯-ÛwJÑÂ$|øðòâŋנ8ËçG…3ÿžSwÖµk7äñ“'¶›È؆êleJ•gÏŸ Ä8`¼½ï©hQÍ8b—½ø`̯ü&ˆ¯»Ô3 ¸ A‹Ä"8H:*wïzK´hQåèñ“šä”™¯*üܸqK²gͬb&œP¼ vYQ±lIÉ.­þ9jìÛûÀµdùj³ÿ3ÿé|²"eòdÚV¨P¡eݦ­rÛgŒpš>m)þIAIh\1{ ˜³ü.]¾ªûa~~ߪ™qsù#Œ CB$Üœ[n9”àzÂÜöûfM¿ç*X%N˜@÷Ûè°D‹U¯=™3¦3ë¨íÖÎ4ûª¾DöŠ,7nÞ”ñFX²Æ}Ç®Ý*Œ=}úT߃à)RDý½˜Ý!lÁw玷žëÜ… æšVQ æÏ+W͵pÝÆ-¾`aA8¶à02j¬ `¸^ZñI¡üR§ÆgA03ñ“…+N        ðNíZÈÐ߯É,ã†Êœ1ƒÞG|eÖ»BÉ5DĈå¥q@¸Z²|ŠYöâPÌèÑõ†/bÃæm6á «æ¦lÿŸ»«ƒ7{­gJs¡tnìZsc ˜Pÿ­9¡Á1üÊÏrÂÄŒI§•¦…Ç›ˆ7Žüж¹tìþ“Ì[¼Lêת®ã 'Êÿ:µ³9-8NF˜“eþÒ¤Jaă[šS’Ä ms óýMi¸Âò›v«IŸûÛæ±5ñ3TèÿÖúoŽ[¥(ý*IéW~r!rP´òø4ñHƒòå1BeföÛ*BåËSçKMÖ±ãÿì\TÛÇGQ Åîîîîî|vw?»»ë©ü ì~vwww£(¶"¶‚øŸ9x÷-Ë.,¥ÄoÞ‡°{î9s¾çÜûvïïÎŒµu,*/—-ãØÄ¦\Ù³©HOÍ$Ê4^Ü8¤][äõ»÷° ~‚:µmA9³e¡îý†ì5Ÿ˜²w~òb²Ý´kšñ½Æ-¹ˆleYH‹Ígo‹%ˆ/D¸ “Ð'€T¡Ï#€€€€€€€€Dró¿QÝZôüÅ+NùwžR¥HΩcªh«wï?ðMÝ{*í• Y2eàT~T:·É’HGê]‰0õàá#ºvãv€íE(“H©+$)䌥 4å_€£Á% 7ì%]de®uŒ£ùD ”²GŽŸ>§„)IùhÁí$òÏŠIÖ7w“µÎ´ ‰(!{ŃSE^¼|kª½ p®²×$rPöü¾CG¶7å_€£Á% {HRþI*ÒMÛv)aS¢ q¼Néõ®±wKí«‚ùòð^|EÎjN¤¦——Ð.Qƒû pžr}MÁi,åz*‘Zg/\ösŒ´_EØãòVE>tr°o4{ \…½5G        ᔀDKÉÏ–{8 *ªJ‘åòöM›=Ÿöì?¬$]à'NçgÎ ^A!é%m×*®£U¼HÁéH:7‰ŽÂß)QB[?ÇÄŠÓ¤€œ@ÕJåXÁõÎv*!´Q½Zª®ÙŒ¹ è:גȉf’ˆÙkúõ­L9/í«T(G—¯Þ \ÛJjœdÕ*—ãTq‰iݦí”!m£ÍMùPßx?lhÈ5Ï\ÞºÒác'©JŲT´pZ½~3Í]°”^»¸('3eH§ÒÊõ&7 ¦™ì Á¶ìÜÃ×¶*0 kÊuü¾¹»Ó–í{(/_ãŒYÛMÈ–kþ3o1­X·ÑWªÀ€úÇûa‡@GGGŸ8»@š­­­ ?̶iÓ&rpp 7Ff ˜;„i .¤[·nÑâŋôŸ¿Û¹Ù³gÓÕ«WIþ…€9=zD={ö¤;þ+mÎqh á…€““5jÔˆ.^¼È5Cb‡·á'€@8"àêꪢ!kV–ÞÛâi`Sí-b¦¦(Ñ}jý){ÿþ=-Z”¶¯îOiSÙÙÿ-ÙOGN=¤}z¹°v #GNM˜6›†õïEé͸aÖü þH$Ùӧ޴ܾKˆ¹Ó²ësZ[o÷@ÏWª@#à         tW®ßTÑWI“ØQ:®Ð àæö®Ý¼M‡Ž¢"óC´ ÈèSxõúŠò“”‘Ú46¤ 6Bt        æt_¾~¥Ž|ƒWÒ¹Á@ 4|üô‰–®ZGÉ“%¡úµ«…Æè{ŽU ¬ò¥KP!NQ à@ÄUp âxéF¢5š‚@Ð$M’˜Î^ʱ Œ£"Ò%Š’üÀ@ ¤ â*¤H¢`€p,|8@@@@@@@@@@@@ ¤@¸ )’è@@@@@@@@Â0k7ãÃG4zât£^ž»x…vî=H«þݤÚÁ@ (ÌÙGæ´ ÊØ8&rèš&4Ìi¹¨…Ù¢ÆUøX'x    8;;SýúõÀ'p"7óçÏSÔ¨x/rïÌ@@@8=~BX`ªT¾´Y@âØØMlk’ÚGÆ,ŽMlrww'///Õù‹WRçv-ÌbÎ>2§Yƒ¡Q„"Ò×4Ðu/BŒ@“pS]Úò9“Z“u¬¡;zðCà‘ó;}3D+ì 0M`üÔ(cútôãÇúøé“‰¾ÿNOŸ¿ ¦ ëR’ÄvtøØIzøÈ™¾º¹QŽlY¨B™’tøø)râ×âÅ‹Koߺª×K-Dg/\¦«×o‘¥e4Jl—ˆjT© æïÁ}îâè¨Ï_¾Ð‡©Q½ZtþÒ’H–VV=ztjÞ¸>YXü÷°GÁ|y(Aüx:¡kýæíì›§ò-Cº4T¥b9Jh›@½&í`a—ÀüÅ+ÔZy{ÿ¤¯^Qæ ééë·oôòåk*V¸åÏ›‹¾|ýJ+Öl +ÞïÞPÂÓ—/_UD]šT)Y¤ô (Q£¨}òŒ÷À¶]û)fÌôóçOjÓ¼±î3×õ[wèÖÝ{´jÝ&Õoê”)¸ÍjoÉ®\¾ eΘ^+Eòd¾ö‘ìaé¿A´ÿÐ1Õ®(ûˆ½v÷—¾gáåšvëÎ=ºzã5kTO¹/çHµJåÈðº>¨ÃKWØ    `&oooÕ2OŽ4/.ž@5š@ˆˆae©ú’sW!†€„0÷>R­j•(F +Z¸t¥L‘ŒòåÎI—¯Þ KW¯Sõʨ\éêGÄ­ñÓf+áJ,>‹VõkWW¿™4ƒ æÏC—¯Ý ÊåJSz–ôÍŠ…)%N°!Wß8RêÀáã4¤_OŠ% í9p„NŸ»@%‹Ö–«këöÍCõ[¿f¡È3× \…ëåƒó   zÆO‡ö3Àׯ_iûöíäààzƒG²ž[5)M%‹d¡¶=æS™Z£©JãI´kÿeEAêY͛֞Vo}ú„®¸÷/ßQ|ޤ‚€€€€€€€ø&€ˆ+ì£j×®­¢«?~ìçýråÊQΜ9A.j5ŸJ_¾|£ªáh       ›„«ˆ½¾˜€€‹€DUõêÕËW±bÅ¢Ž;«ßÈ|ðöUý#óô1wð—Rbƒ€€€˜$.]:ªY³¦¯÷%… ¤„€€€€€€€€@H€pÒDÑ€€D0Íš5#‰²K“& I A€€€€€€€€„W¡A}‚€€@" ÑU’2PLû7MSCPã* -\:Е+WÂ,ˆF…IßòåËG . “¾Á)ó @¸2ŸZ‚€€@¨øùó'•*UŠÊ”)êcE”Ž=JŸ?Ž(Ó‰Tóxòì-5í4‡ŠÈHÓF7ñ¹ß¼û”þê0›²eJNÿ.îâý›êðÓg7*^m$ŶŽAgöŽ¥?åÇo›0$á*a¢+ vvv”={öè*RôqûömWát¥==½è«›;}üäNg·A@@@@@@Bš„«&Šþ@@@@Ì">m:µ{ YE·4«}PEµø½e]£F‰TWCì¸Û÷žQŒÑ)]j»ë€€€€€€Àï áêwPÆ    ð ,]s”fÌÛE³:Ò¢•‡éÊõÇ”4q<öw=zôä 9¬8DîžT¶DvÙ¿EîóÑÛîÚ™ž¿|GÖ±¬¨tñl4 G-õ»–þî¯úÅÉÝý;í=t•llbÑß]«Sµ yÕñÝ,¦gïÒÌq-éŸ{¸Ÿ÷T´`Fš0¬ Åᶆ)ô´¿»·«L/^¿W}ÆŠiE k¡®m+©>½½½iîâ}´cïezíò8»¦Î¶­êoT¨‰aeISæl§Í;ÏSLsÕ)J]ÚTTÇIZ¿Yó÷Щó÷ÈõÝgJhkCë£6M˨÷5v2‡5›NÒµ›O(‰]\ú»[M*WÒ'2RR| ß ÛΪþ»´­HÑ¢ù/–½ÿð•&ÎÚJÇÏÜá¶”/Wس6%Oš@ÇeØßuéÇŸdÏómÞ°û\‰–¬9Bÿn9Co]}|-],+ î]‡¢ˆeËÖ£ÊårC¸úƒç†yo]ßÓ¶]ûB¾côn ܽÿ€¬c†¼ûÒ/öZÈs Ï=º¼}*îcŸ… ÖpÛ©Ë[×pë{H;á*¤‰¢?0 °vÓ):å¡p4›í°‡ÜܾӠ^µéó—o4vÚfºqç yzý ñbÓ¤M•èðã‡7-daI$yOŽ!}ê’Mì˜þglºXBùr§¥œÙRÒ¥k¨Ç ¥•cIeŠg§Ã'nÒö½—(OŽ4J(“TV,bU)Ÿ‡nÜ~¢„Ÿ861Yœª¡ë^æ–=K ÕÇš†Ž_Gò¤#»„qU–Y«DùýØé;4gÑ>Ês0e"L¥M•ˆ*–ɥīyKð¼ÓªZXâƒÃŠÃT¼pfêޮ͜¿›\ß¡ž«*1Θ]½ù˜î?|IéÓ&¦ë·žÐÿ–ì§<9SSÑ™X„‹A¯9Qª ©pþ tää-%B¥KcÇ¢P6]w}†­Pó’q¥Íà±khÿÆ¡7N,5oŠ¢F¢ú™n¿S­›6ˆ?}þ¾ cÇŽ¡Ö÷éów´aɵ¸Vm8IŸ¸¨nDÀ;ùÍœ·›âǵ¦Jes’Óã7äÌkd(ZÍš¿‹ŽžºE×n93»ýJ¤+_*'3xÁÞzüÔ…¢[FSkÒ¥uEP‰@@Â2dIâSŠdñé±óý°ì&|ûÍbÆ Êš)YˆŽ*ýݾÿ{-D©†ÿÎRòõG®C!iùr¥Ã> I ¤/Ù0"WØ    ð‡ ˆø#Òîuƒ”!ÂB ޼[¾î¹pŸÖ9ô¤Xé4jÊFš<{;Ò˜ü;ÎØ”Ò§IL gt¤o!U¬êpe5~hcArSê” UTÔ]ÇçºCGlÀbLTQtóÎSjÚi]¾þÈW×}µlnŠa¢YZp$Ô%ÚwøµhTJ×®c« Ô¡E9f\©Z“I´sße…+ Ní·lnWJ?6 6ÑiíæÓtåÆ#%\]gM¬u“ÒêïKìÏ–](K†¤*ÚɘyyyÓê=(MÊD4l¿´mÏEŽœrV•ŒµuÅßjžÍ%mD »Âýê WY2&£%³;«õ©×j:9:½R?"fIb½:U¥¶M˲(èD­»Ï3¹«^*Ñ*3û<¤wmÕÎÍ̓¿Iwî?§(,€‰‰h%‚Ü_õŠ1_K:xì†z=Iâ¸Ô‹_O’8¾ZCCëݹ:¯ÓcjŌʗʡÞq´SßEJ(ÁêËWwê?r•ŠæêÓ¥ú>0<€€L Nµ‚$?0mƒ}> íqÐ?,·ï &üÞ„ÿX?2¦OJ—®:ÑÙ‹Ž:ñDÒljmÝ}:¶,ÏQ91•¸Ò’¡¿ ÿŽ3†9{–”êexâpbÙ2§PÿjR_Y@ûþÝK dåꎥܥ(ÑJìëWŸ÷5K‘,­Ä²²¸#öìÅ;_m´:K)“ÛRlëJ4ùøÉ͘‹êµ”Él•h%–ÄÎ'ŠJóK‹ª:{ñ>§ üHRËI,÷mÊd\¤Ä$’JL|{É) {]A%ªäyT¢•ïÛw_Ýå`vZd“æ“ÖÇó_ó-Y$‹:&ît*¢É”I¤”ؽ/©bƒ êGD+±¯þKA") Eðf²ŠÌÄOš'`qëUûk2˜´^7“ƒýzCR6ëê IDAT ­Ä¤OÚ6l?Сx@@@@@@~+D\ýVÜ @@@ "è;|da@¬m³²Ô™Ó¯dúµ™$]œˆIRCiôÔ\Ϩ$5åÚQ"T½|ýÆÏÜB–¿„,9.:G6‰hâßqÆÆ뿈$M„±âH1ƒI´býqö‡Ó²°5vP#ŽÜéï«üt+—fžž?Ô¯¹¤oß=ÿkãÅéÅüK¥'QVš¦ÁkÁlŽŸ¹K‹WU?"õî\S úRÆÌ†E]äͤ™D°IôSÍÊù¨~ÍÂt‚kNI¿†fmm¥ç“ñq¢êA´°ð=ŽþŸÜ9RSûæå|u–5Srrqý¤^“”‘ú&i ÿ]Ô‹–¬>Bk¹Î•Dš‰p·qi_“s×ÞALÜ´×$ÕÉç/îô…£±D…€€€€€€@X á*,¬|×fŒý¯v•±‰XD‹êG¨q}÷YÕ-ÒLÒÞÉÏó—ï8h¹ªaU»jJœ(.ÜHÕx2f¦Ž .Ðû $&>H]§CÇ}ÒÔÚNÿ÷Š£–$mÔñK™<¡¯fùõª\'ëך’Ôv6\Ó)^ÜXô‚ ¬½ûð•k;½¦®m+q*¿¬”J¢¸‚!º8rí+±6MËPÆtIiÿ‘ëu‰’²$µ¦Î^zÀu´’¨tßÜ=9ªÉ'jÎÐ2¤K¢^’H­‚yÓs-Ÿ}ðŽkuI¤™&\'Qg²/$`s޼«ÓbšŠÚzË{)aý–ˆºc§îøj#¢¨D^‡_ aင+le ´håa%ÚH­¢×o>Б“·¨zÅ|jd'ç7dgk£ª$žÇwŸÚEõj¢9{iʨf”È6yrôÒS<$ýžÇwJiRù¤Ö[²æ(§~΢ÇmUÊÐ$rªa»YÊ7©ù$PËäôÕlý¶³tãήqå“BPD,‰& Љ¸ãÆiü6ïpE HzÔbá-¾¯6}8•ßîƒW8ê쇵úv­d÷¾¹g‘̆^±Ð#?šI §}†º_ñwèøutÿWäÕÒ9]¨MÏùêGÒ Jʾ-»Î“D  èY‹þåT~"䙲 C›¨zZ¹^Ù®ý—UZÀŠesù;®ˆ‰’RRêaEeáJ,akLlϵ±†Œ]KÛ÷^R©+kUÉOó§·WuË&ñOLP+”ÎI]ÚœÖ2P0Ð@@@@@@‚I Š£££Ï“@š­­-‰ŽÜº×¦M›ÈÁÁ6nÜHzh ð»,\¸nݺE‹/þ]C†‹qfÏžMW¯^%ù¶´oßž2dÈ@ 6 [Ž…ao6lØ@>TÿOmsvv¦úõëÓ‰£8Õß(—Ð?,õóîSú«ÃlUk×]2fÝ,Võ¨æNnÃiý²Û}I¡W©Áx*V0Ò„âs¤‘ˆymzÌS)óί„XÄ% bjS€ÏŸ?ä¨=Stœœœ¨Q£FtñâEŽ~ô]_,âÅÌ@~'WWWòòú¯î£¹c[YzSl‹§æ6÷ÕÎ"fjŠ=qŽ ©ƒÞ¿OE‹¥í«û)::¤ü@?   YLúg}p³¡é3fþñ){}ætüÞîö#r+OÆ…@@@@ô üüð3Pf41 jÔ¨QT­0‰dÚ¶çYE·¤;ŽÏÕßRë ¢•YÑ@@@@@@Â4Wazyà€€€€€FÀ6¾ MÑ”¯>Bö‹÷«tyIíâQÇ–åu)A @@@@@@@ |€p¾×Þƒ€€€À!#KJºqbª¿cÛOiâ¾U­—ä       1 D˜Ó¬@@@@@@@@@@@@ÂWámÅà/€€€€€€€€€€€DP®"èÂbZ            Þ@¸ o+A@@@@@@@@@@@ ‚€pAÓ?M`Íš5táÂ…?í]¼x1P>ܽ{—^¼x¨cþtc×O”»ô€?íÆ`€pl„è@@@ÀZµjQž~ü¨Ú¾}û–*V¬¨Ž14‰¨:wîœKúÿñ㇋lllhãÆjŒ"EŠÐСC©@ôòåKÕ_Ù²e©dÉ’ª;‰ˆÚ¹s§ú=I’$Ô¾}{Š-èQ>}úDj^b±cǦL™2™ ¼L™2J´’ŸcÇŽQŸ>}”ïþ™—ך<{;ÊׇbXE7{¬?ÑðÑ“7´lí1ª^1_†oP«ÏÑ2HÇå GoÐÃǯ©D‘,Fî|‚â“á1ù”1B£Ï øc@@@@@@@àwú]¡ßí)Æ’ºvíJ^^^JLêÒ¥ 5nܘ>|H6l AƒÑ¨aÆ”-[6Õ΂$Réëׯô÷ß«×ä_c¢•æÌ£GhÊ”)êXéoÓ¦MÔºuk_¾zzz’Õ­[W d¤9rP”(Q”h5lØ0%V-_¾\‰EåË—Ô\µÆG¥éÓ§+L3ñ=Nœ8f÷פI’4‡"úÝ»wOElÕ¬YS XÆìûw/ê=t9½zóZu›Ç¢ÐÜIméÊÍÇt骥J‘¯:Lùr§£™ãZÒª 'hý¶³äé饿߽}eªV!¯êzÌÔM?ž5=xôŠ>ùFï?¸Q•r¹©Së êýû_Ðø[ÈÅõ3yð¸Ó%á±ÚøqëÅ«÷4vÚ&züÔ…×Л2gHJ‡ÿEo\>Ò€ÑkÈ™_oÐf%ˆ›ÎèÈ‚£7-\qˆví¿Lž,ÂåË•††ô©K6±cúé{Õú”8Q\哤(lÞy.5®[”Î^t¤¯nÍ"*ØÒ¦²SÇÖj6…š5,A‡OÜRï‹H: {MÊ#z¿Ï°T(ozú«~qÝX-»ÚS«&¥Ù÷4oé~åÓÅ«©dѬԫcU]»‡ÌÉØ|ä¸EÌ|ç¾ËôÍÓR$OzÔ¦ìYR]Cá:eÎÅ+ºe4ªÌÌ»´®HÑ£û|T.Yc$-™Ý™y'UKÿyË¢S»GÓ™ ŽF} hÞAéÓìMŒ†       Ž @¸ Ç‹×A@@À?™3gVo‹ /^<]ÔQ‚ T4•˜D>ItV‰%Ôï)%ÑSß¾}Sï‹È$ûg3fT¢•XÞ¼yéðáÃF›kþDäw'''¡åââB“'OVǸ»»«©Àš&Î]ºtÉÏ¡ÖÖÖ´{÷î@u™3gN:sæŒî˜;vˆb-[¶ôÓˆ³'¶VBÆrû.Ç&–O››DŽ^§•óÓÞ CÈê—’'gªÉ¯Å‹Î^r¤—Rå²¹™¡ç-»ÎÓºE½È.a\%^Õj6•ŠÉL9²¤¤©swR½ê…¨vµ‚äííÍBË[^_öú&‚Tûå(?‹e"ué·˜þÝz†Ú7/GýY4šf¿ƒ6.í«;dùºc,Àܧu=)V,+5e£Š 7¤q€ÜD$óððR˜Ø’5GhÚÜd?¥îØ“gï)Í’E!™s÷AKiϺA¼Ö~…1ýE@ºÇ¢’›ÛwÔ«¶_Ò§Mbt>Ë~ÍgÕüî/®µJ5عŸmYÑ&°ñÕ0îÔw‘ê_ÆûòÕú\Eö‹÷QŸ.Õœ¿>†Ö¼t @@@ xþòõ²LÍìñõpPÔ¨Q¨K›ŠtìôªÃŸòåJè™·ï½€FóC7É“&Ð[­É$ÚÍŸU^óC?gm¥Yã[ºßÀðêõ{6q=-šÕ)°‡ª…¶¯ùz§CǯSÉçÐÐC&;aæªY%?åÌšJ7ÎŽ}—èÅË÷º¸üƒv†ŠÈúŸ£e8yý;¦—ÃÂò\OŸ¿GÅ ù|Õ·ó—P~hM{(,êº1i}¯ Æü3g?šÓ&(s×ßÁÃÔů?qŒ|>ù!)Q Ò×¥:-¦ÑÖ•ýþÄÔ0¦®°-@@@ ‚Hßô(‰z“H«qãÆÑ©S§hÔ¨QÔ£G%*ÅŸ&L˜ „,‰8òÏD|ÒL~—("cf¬ø!i %2,8&Ñe7nÜðÓE¢D‰TX`MRÚçÏŸéüùóêʒŪ¿»V÷%þÉÍ çË@?øÃ÷ÛwŸU“X…2¹”h%&O™3&£‡^«™Ò'¥Ý‡®R6Ž’èŸt©}¢š MRù‰h%&bQáüèÙ W“¾oÝ}#’j鄤–JQ³.sÍ®¤Ó¿êÓõ]€Ç]Ï"™¾Õ©V@ù!V$FJž$ã/°åKå OsoÛ}‘†ö­«D+1I3˜7gZÚsð µà¹éÛ µR&·U¢•XlëÔ«SUjÛs¾Y•>ýîy›Ëí@@@ <aI{ðF"Ç—Ûw#k~àFL„«Ð0»„qhØßõB£kô©G K›Jüð—ÿ4Ef`ûŽ\ãÏý‰u Â" ÉB±”Ó‘®¤>î´1-~«pŒL|Í ¡B¥OÿÖ(T …NoÝ}F'ÏÝÕ W¡0º C \…¡Å€+   ð» H„SŒ1T´Õ‡TÍ«4iÒ³³3?Þ¤¥ïçíÛ·U„V̘1•¦EVÎåòåËTµjUHŽiÖ¬™Š’ô€R÷*a„*K¢¼¤¯À˜DUÍŸ?Ÿf̘¡æ ™DsM›6¤nU`¬F~šwìØ‘Š/N­Z™ÿÄmêäò$ðk2ßµ›O«´zÚIÓ÷“_×,§ Ô7 ~’XRÓ‰‰¶e×ÎOãŠuk[I¥Ï34I}·jÃIN5øUóä™+åÊžÊ$‚—¯?Ðx~ºÓòWô–èšÑ--Tô‘9þ™<é¬ DÒ.*GŽyrzB}3L9˜ÐÖ†Ÿ`þd²Û_ºj`–ÌW[‰Klç#þi–,I|’yš´MbÏOÛÏ_Ü9]ä7£Qašðƒ™·¹}4&ÞÈJàà±´håaõ@P£ÚEHêrÊg¯© îøð•ŠXéÓ¥šz˜Å\{Í© µ((ù\5†S1þüȱ Ñƒª‡:ö]HåJæ  œ z?8óœ?[lÙy^¥:–tÍCûú_†íä§)á.í²gNÁ‘ñeUûŽrzÍŸY~ò3ƶ¤Ë׫ˆõ!½ë¨~v¸L÷¼ôõ€|Þœ»h]»åLŸ>¹©xreOMîߩ߈UüYý;¹¹§ýêSÖLÉI¢Qâó>ïxN’îº?8Õ™ÉíQS7Òs~à)A|ΆðÝ*SL%äÃǯtìÔJ”ІÓTûÔǕϽ»ùá¡1ƒ©÷K×MÛWõ§Ô)Ñôÿí¤Üìçñ3æGÌíäÔÖÆøš;–<°${Bæ!Ü$šN"í%Z¯"s5eUà¬$´ì+oïŸê³t¿n5u<ä3üÈÉèÅ«wÌÙ‹Zpj쪜þ[n¬Ïœ¿KõŸ"™-Ð@¥ë–}$âë÷ïžäúþ MÙL}†>wéj/Ù²dH懷£ÓKžïN}SùÒ½]%£ûDú2µGõ;ÝÆ¬mÝsQí»•ò‘|÷¸ÈéÍ%M¸˜<ÐöìÅ;êÚ¶¢ÑsÇÔÞ™·ìÏý)õ¼”×)FÅ ûD^íå‡îd_½š äIG­ÿ*CÛxüÛ¥N2©}Ú—³>ühèg“ºÅLîçwÌSÖìí»/dÁ.›ÛEmìš ÏBR¸®¯¤1—¹k)Âõ#zœœßP·‹éŸcò°ž±ì ÎÏ\¨g—xÏ{]2”}/flßjÑsAÙÒ§©½f¿ä€ŠF½yç©Ê¨¡=@h¸FE fTk|çþsòâýÜ”£+µö'S绩} ûYR½ËzJF‘Ó;èjÔRÃK$§\£%u¼áu1ztK?çö}ú_f;ìåHÌwüýö- å=kêÚgÌïýG®=ÏÕR~ɰý8 ‡¤ú·ä 6Æ®ýërÝ•ól ÿ¿ÂX ~?';^ðCÂ6€€DbR_J"•,--ÉÊÊJÕÁ’yò3`ÀõºD5oÞœ$Å 1K—.Í;W R’æOú0fò¾DwIí¨¿þúK¥/ëСƒœÄdÜ6mÚ˜¿ü[ª ¨ÚTò³páB5ŽØÚµk%\]¼x‘^½z¥*_¾|*-Y²dJÐ3f¦¢Ì¢ð}“§%mÝN›'‚¤ñ+Paˆ¯6QÈxÄš4’æõkV?òSÒå¬sèÅ⣥®¹á µ¶¤¦U™âÙÔëòe\R݈ÉMC“h¯1ƒ)½Ž9§6¶Öö)ß IœÈ§î˜ˆeu¦ooßý'jùÇÃÔ|ì¸ïW|ÃG«³%íäˇ|Y64¹á$7XôM.Y-•a4N…)7'4så/ÅúfÊGÿæÔ>Íá6    Èç±ÿMmÇ7·=©nËiJ¸ÚÃ7ÍÚÆ¡=k«›úr“tÿuK»ô_¬ÚÑLÿÿûÚkÿ,ÜMmþ*­n´=u›,;HÃY’Ï Ò~Ó2Ÿ4ÌÙ9J^±}’ˆ_‰ù!ývòÀJýÖ3Tºi7äoiwïÁsš>¶¹Ä¯>¢nv7®S”dlCn:~“Ú5+çk‰E”“ZžR;õ:‹‹VQ}Ç°Š®Ä/IÑvåÆ#Z·å´J‹(fÅúcùóŸ< U·åt%\‰ødß$šMà©É)« Í?¦RÇuåÿºùz¨IÒdK j±“çîQ!Î8pŠÿáêÊÇ<—²J¸2×Dd3ä«kÎXòoáŒêsõÂåÕZÖ¨”ŸyV•|¹t푺)>sþnŽòë£nÂËçv}{üä Hž\Õ综/ë8uîvõY_nTKzî}‡¯±XšÆGdà›àòúê'ÕÚŠP0~Æfr˜ÙQí‘%¼æ†&JéâYùslzõÙ^Äcû¤yÃ’jŸÛ£ZŸòÀÖ¡7UíVñµe·ÿÑN >‡EOMÈÛËþJ&ÿÖÙØÞ‘”ä§y]gOô]·Jù<$W“Y¨“IÉ!)Ì—²Òϸé›IƬÊíüó³,Ï]Ûϳì&GD™—öÝÌØ5AŸ¯<hj}íGù³‚Ï 9{^ÆûÄI—åBk/©Ü—³p&bQç~‹è®ãsÊ’1¹‡íÛ î ÿöÚ¾ÃWi1óÕÎuÍ7Ã5’ó]«es»*Q°]¯*¥£<ð§Y`÷ÁúmghDÿJ´“sBK‡oêü6¼.¶é1ÏÏù#‚°XÌÑ©Yƒªö±&"›ºöó{Ô€†FÏsCß>}vc¡u æör-QÔð¼’H\Sçú]Çjâï¿[O«J%¥-,ð \žŽ0O uëÖ¾|œ>}ºîoI8iÒ$õwƒ Ô¾ITµjÕTÝ+ù@|üøqÚºu+µmÛÖè¼EбÉÐ }h«F|ž8Ó7©‹%?!eM›6%‰˜±I|—ºWwïÞ¥,Y²˜5„_b"ÂIæDkÉòxqc‘£Ó+?_^ô}ëú‰ñÍùÂ,lW®?¡žà4×nß{¦ÒJ]«Œé’pdZtõW úO¸’/)•:eBÕ­8ò…Y¾È‹Ùrý+ù‚ MT¯F!šÃO¯MÕLù'_2žò“–¦Ršë¯ÖN¢ÌJrº>yTžž”§"%M¢XZþRsõ¦3É—m1¹‘!Oyj&þ^¿ýÄ×—Pýñϧ0Íç§>åŽÔ;sñ¾º¡=Iª|¾0ƒŸöÝÏ)X*q­1·oŠEýš…tÍÒ¦ö¹±"_:ÅÖñCŒùèÿ¼ƒÖg`Ù£=€€DÅ eRS•”Érƒ\D… WÒ}޶’;bü€vƒ^ŸË<¼ k\r»ÌŸ%´èm‘ä3ˆ˜7ÿ.Ÿ¥4qæÐñ,„ESµ‰>±$7ÜõÛ½yû‰ŒJ´Ón´gâˆ-etvnÜvV7ÀóçJGøf­ÜTvæãòGßä±§b}æŸ&U"Žèù¬~—§,ÎH¤Âׯê&µfZ=0ù\ieMñ’Șê}D7y€Gûü¨?–)¦Ò¦\‰ì¾D+m=âØÄP>c‘H2,\qˆêT/¨Ä8ýÈ}CæÆþ6ÆWÄ6sǺ÷à­ÙtŠ¢²åäüšª”Ë£xÊçϯnJh¡I¸W(ƒÅNjÙ¨¤J=­oRcM¸Jä‰ÜLÏÌk'-=|üZEjˆIÖ¤‰}Dùì®E]H´¿¬íÎ@ µieˆ•,šE‰!™±}"ǘڣZ"^:=~£1yàM¢‡reK¥"û2¤MLùoù°âßcFÏ9ÎØÞ Ègí}ùÜ/窈Vb"8íÜÉ—peÌOa+ÇÛÏò9]¢ Åô(4vMÐÆ•¶þ­¯±ùä‡à4!¦t±¬,?ñóݯP¾ôº'9‡µÞLíÛ î ¹†˜ÚkRXêÅdò ¤a%"w Ž’öú•ç»±}P§jc~gÍ”Ìèy®ßw´hQiИµêšžÏYSç•¿üÓØ)ÑJ¬hL4éŸm-Þ7A¶€€„c"Ìœ>íû&z… H¢‚jOŸ>¥Š+êRÜÉ—ûX±béÄ.­_IçW¬X1%(˜cæ¶3§¯€ÚĉGEqIô”D_ÉßæšDV5iÒ„$5``Ž“øÉ¬X,&ýc¢€w«Æ¥¹ÍjªX¼ä)KÃþù)OŽõ²\¥§‘yBU¾Xº° ¦™5ýºÕPOøÊ “Ôü¥ºeãRJüË”>AÏMµ¸ð¬DZ­]Ø“Z5)­ÞkÛc>}æ›+|C¾tôèPÅ\lþ¶«X:'É“Ì"ÜÙrÚ™¹qÆ7CÄš5(NÃ&¬§&íÿ¡¸,þ‰O¥øK»fÕù‰åœ¥RƒñT–Óðhir´÷ͧmÓ2J|“'™Åä†ýä6º:búÎ ¯ùÓÛ«''ñOLþB\Aüå!šõï^‹ÆñÓ°{^UÑmR X?¥£)ý›wPû ‘A'    Äøu£P›šD™ËÇÔέ+ðg ¿©•‹@6’ˆ™X1}êji&7²µ›”’¾J¢x–ÛwU7Ù»\b´¤‰6±/¢›¾i³«WÊK;÷]VBHÉ">)Ø }п!¯'iÀäá!$²cѪúÃ$ Ë×X¿¢òõëøD㹚L ÙhÇÌ›.^qâ´„•&Y¿üoú›Ÿ¶Qúò¯¹c‰¨$WòùpÙÚ£ºé•+™k¥ÝfAé U懙Ĥö–ˆÒn×+4i„OúC1¶æNnKWY4™f¿“É¥X‹*éÑôM¢¤üðþõJXp”¿9fjŸ˜Ú£ZŸ²v’Ö²/§×7YýG¯3ßd*2HÌ¿u6µwÌñ]K®ßÖ0{)?%ÅœÑýlâ먱k‚þ¸ÆÖ×°Ž•ˆ“ÆÌÔwÛ˜,é›´óoßy_p¿¦öšVû/ õ0º»$½œã›9]jË®ö*šK–G?§ˆ<@ ™þõS6±9ù79ÞØµÏ”߯Îsýþ%]«ˆ¶Ÿe?¯:ÏW!kŠÿwÏÿj€ËÃNv IDATá½6Z@û(4߇pštÑ7€€„2R¥J‘ü„¤IÖÒ¥KU)///%ÞHô”ÔÂ2´ .„äÐ!Þ—xñúõë$?êV/Dò£Yº4‰}=9(¯'IVpú}“|íšèïó¤ ¾ÙOi§ûóï®5¸Î•ßú[%uíØ];¢41J{QÒåh&imäG3ùÀß–E0ù Èô}4WŽÍÁéqmæ«›<9SS“Œ™G’ÊÆ”I¾ÿ¥sŒ§Ÿôo>]ùi^ù1Ç$’Ê¿1¤Äêù=|uÕ¶é¬Lùèß¼ƒÚ§9óAó¦WõA%’@RÃI (ùìËÏÑr3V‹7Ö—D•KÊb¹‘*© %‚ɘÙñƒ:R_JR·Itƒ±Ô„úÇIdƒˆ#i/Ñ=æš<Ü$)ùÄD” È$JCÒöI:?¹aõæcjE>8i¦rŒzɤ­‹ˆAæ|öÔÛ¾þ%"‚בϰÂ\¢`ʳ#&‘'uÿô¹«îó¶DâËÍéA½êPƒ63}q5‰V“›ôzÔäH C*·DmI–„l\·L„IefÊd¯¸rM6Y'ñé×3f"ŒÊ`Y@{4OŽÔjÚq=5ùü*¬9Z­pþŒ*]àKÞCûøÔR ì:[±*{Ú¨ÿüÀžÌÑ:V"•º[RVJÚ:9OD0+×w:oS~šÚÏ9³ñ÷N¡Y™ÎÓRjÄJÞ7¶¾¶ lTÆ IÑ(5Î>|tÓuu–ë7ɚʺ;}Çä÷ñÍÙ·úǘ³/䘽&ý®‘¬ûæ_8qyêü}ªUÅ÷°ÝÂT" 廨D× Ë„ b+á\L.¼yÇçJC35'}A\ÎŽ6 ÈLùmì<×ïK¾K6©Ùl¿d?õîTŒWþñÀYX$BPÈ<ÀuµreK»xßWÁØòTÄû÷ïiÁ‚Á臂„&I&7ßa æ”zC‡5뀂 ’üdS¦ü'ªÔïG<fåE¸‰GÖyG¸…Ä„@@Â-ybÞñáKjÒá~â? åä”hR›$(&70GOݨjÉÍq‰œ×hIú”þå^QÛžó”@Vº˜O½QCmôÀ*_úÊÁ©ê$’Þ”IŸ…9 àÄÙ{$¿˜k­ùæñð‰ÿ*QD"Æ£Ô_5ì§V•ü*õDJˆ°‘5“Ò>(LsçH¥D¼.¿*’¨©á¥¥3w>æðõo,á(5ï<—ëíZ«Ô‹šIê?3gL¦„ ±¾ÃW’'G‡H=Ö–}ÒZk&©Â†Œ[§Ò,J†Š>««µŸÂµœ&ÎÚª+‰Ô:Cñ9«€©}0¸wê1p)ÙØÄ¤ì™“Ä“°Ac×Ðé ÷i@÷š&q´G%5eg®µ#™d/È<¥Ö”D•Hº@IÓ–äWjÃÀ®³ôU¶D6jÞe®ÚËe9m¤f’6®ßˆ•*š«c« j ZuÿŸRr°Xª_ßJŽ1å§©ýÜKÎÍ)iù¿ÇÕkø~àÌ0cëûW½â4aÆÚéÖcsÊQýÔé"ºuã(J‰P+;Ùûל}«ï£\ÚÝkÒ¿áÕàHNY›všC<¦ÔÓÓO™”óÝaåaºrýI=ßtœzRÒÂ'K_]7ŒZ­²†HZMcfjNú×<9¿ç-ݯöðH#}jýšÚ¿ÆÎsc¾H¹Ž_湘:¯Lëâ¯D»º¼ýÈ×Qkš0´±¹—8´3 ÅÑÑѼü>ÚÚÚò†Üº×‰' ZE°SJ" ~üøÁ<|‡þG°iFºéäΛú÷ïéæíß„gÏžMW¯^%ù¶´oßž2dÈ@ úM†L`Æ ôðáCrpp¸q0[8;;SýúõéÄÎQÎÇÌ¡ÃíᵚM¡Qü…=°7&Âí„9YçÚë&é{äËõùóçuéLCjL'''U‡ORŒJ;€„4WWWÍX³²ô¦ØO{˜jo35E‰ž8HdžÔAòÀoÑ¢Eiûêþ”6•]Huéú‘¿",ôêX5ÒÍýwMxää *¥vÑ_õÂ~׸@à÷Íó\Ò‚Ÿ´fvü}21’ÔÖúàfCÓgøŽýŽy}¾Î!Ý=täVžË÷%K–$ùE-Z´ ¹iqàÀ®³7âL 3?D`ûêhä?;ld÷Ÿ¥ŽÑA@@ âضç" ù•Æ-âÎòÏÍLê¼Þ¹ÿœFè¥ÓþsÞ`dÐ ð;ÎóßYÛ;4…¥>!\…¥Õ€/”ÀÁƒI«Õ2dȲ··ÿ£þ`pÈMàè©Ûä°ò•á´ƒˆX ½à°â }«ÕlJú—ƒZt±'é/¤íôù{fwyþòúþÝËßöÏ_¾£GOÞ˜Ýçïj8aæºqç‰nË®óT¿õ 1i½¯áõç'û¡}ï¿Ë½PÇœs$ 6û_£å뎅ªŸu.{«Û€Å5£3ïS½VÓý´5eýÕq6­Ùx’ÌÙËþ d¸ÏƒsnߺûŒ´™AM:üC·Ÿ¥%kŽ8G­AHoæøoN³×k8tü:ºy÷)÷œ ˆEh]Cƒ2gt®‚ÎGFåË—§ÁƒÓÁƒiÒ¤I”$IŠ-š®DÐѪ^½z`¦˜€€€€€€„orÓ÷ä¹»tg/= ÜÙSÓrûnT0oz/!éÛŒq-(jÔ½e'"ÔÒµæ‹K×%O¯þ²Þwä9?}ûG×ÃØà]ÚT¢l™R¨·Vm8I«æw§1ƒùjjÎüÂÜÄÌpÈœ}P›ÒųQý…ÌíÏ7Ù±ï2E·ŒFwÿ«[ÿáãWºÿð­]Ø“š6(AÁ]ëÜçÛö^¤ÞªÑ:‡^T£r>jX«ˆÙCÊŸfvÄ"4®¡a—FÄõ,ZÄfˆ7.µjÕÊdCˆV3D mßÜ¿Ól‡½ô‚#1ž<{KCûÔ¥áES®dÚ°í, ë[—ž¿zO[vžW‚Kæ Iih_ŸQ·í¹Hk7R¿7®[”ŠȨs÷äÙ»´nËiš5¾?Èj¡{]¢füo'ý仼ñâZÓÈþõÉõÝgZ³é$½}÷…ÞóÍqM 1æ›Osí£k·œ9›‹ûR—r±à%Ñ.Sçî G¾±þÝÓ‹út©Fys¦õ…¯]Ïù´}õڱêòâù|s÷$›Ø1tc¶ìjÏÇ¥!×Oüó™ô¨IÓ%¥>ÃVP×¶Õïb9!}Í[v€nñœz^Jë£â…3«÷ü𦑓7ЋWïÈÝËZ4,AQ¢DQ~½š äIGMê£~#VÑ·oßÉ×aD¿úÌ**³¾@qâĤƒÇnи!¹ÿg4sþ.5ÇÉliÔ€¾8cswyû‰.^u¢!¼žb[w_ g/ÞQ3ÆLÛDŸ?#KK =¨!Ù%Œ«"§âóz¼ûð•^½ù@•Ê䢆µ}ßüêT+HWo<æ¾\Õ<šÖ/AE~­ûÞCW}ͯJ¹ÜäÁsÂÑ ¯_ Ÿüߌ±-Õº›SÓŽsÈ~j[²o£|–ˆŸåö]y}bª¿Ý=¾ûá•5Srå{ñB™iÉê#T³J~ª^1ŸÑ9êo‰É—+ ï¹Ïô’#ÃDô}ôîýuìGÞ[¼\Ô¿{-J“*‘Ÿs$±]<_{ËØ^µ_²ß—_qãÄâóŒ£[WP¢Ïæ(ûLλ6MËPë&¥iøÄy_z«È6Ùëƒ{ÕV{ÊØÞ6wÞçX–µ“ñ³dHà%Eærý¦3uáý.V–ŒÉÉí›Mÿß.zöÜUíõE²øZë–KõQÎ5¼ŽºC‰ÚÐÄáMÕøŽN/ýìó žÛGNÞ¢gîÐ=Ù$R1¡mz¯pîØw¡¯kÙ­{ÏÔyåíý“×;Õ®ZÀ á6oé~uÞZXD¥Ó;Ðú­gÔÛÕ/®þ•H§F|žäΑF }3çí"Cm¹¶¥O›ÄoSs¼÷à…â'ç²´‘ëflë*JÔØ>•þGMÝHÏùõ{‡> é5 "b‰hµŒ……V–,DýT¯‹Ý¹ÿœ­:LöSÚù­¤ÍÈIÈ~r[J’8‰Ð1{á^Á7xëV/¤»©¯Mߘo"JeÏ’‚º·¯L×YZ´êؚ͞öp_rÃz`ÏÚôžÅ¹a½aI“$ExY2»‹z¿×e*²DnЋQ®dvu#Úéñkš0s+-ú§“É~Ú7/G§ÏÝcÚøjó˜o {xxêÆðd¿å¦®“G6#ëXVª½9Ñ£Gcã‘úFlH¥‹gea+=•áa6uîvš3©p¦Ùï I9§CרÜWÎëFsXàÓn„ïåcô¨Eÿ,ÜMmþ*­Dš£§nÓ‚ei8 fbV¼Žc7R‚^Ý–ÓýWÚÛ6+«„0Ã9W)ŸÇ×ü$mÙ½ÏiúØæJ[ÌÂÒÎý—Õ~36§*åsóÜ®³Vœ8½¢dIâëD+;†Ut£¼ä½CÇoÒŽAÒÙ™š£6×÷Ÿ• T(_öñÙ/Þ¯öѬ»YøÊKY¸{Á‚mï¡Ëhýâ>~Îà al¯ú%"ŽØW7µ_×/î­Ö§A›™$ûè8‹/qãXÓ «D€™57•,š•v¸lro4ovÇÏØL3;’ˆm"îd"ìΟJóØÿ,ØC}»T§X1­¨ Y"òië¾iû9Ý^öÏÇõ,€¯ü_7%Xj&°þ>—׃zn—-‘D¼Q5_®´Š­f†×2F6.í£Ä(íœ4ôC;vý¶3|mj@éRÛ©¶²·ü3üòåNË¢yuu œøÏ6Z4Ë÷µÃÔÓ§IL gtP¢ÕÂåÕ¹)×\SûTö’ [¡*iVk6›êÇ5ÿ®‰úûÆÔšÈù»ïðUZ<»³º.éÛé ÷îUI#9Z{%’µšÚ5/kò&è÷ÿðÑ+?×Ì€ö*ÞWAㆣ@@@@@@@@þ o­êé¥4;ÉÂÌ¡ã7TDÔ@ùÄ7Jï?|©¢.D´“ÁbrS^"2DÒÄm*ò„½D7‰h%V©l.±“QŠ̤‘H¹±+&T’ÆLêîˆypD‚&Úë?ß4Õ,iâøõõEý•ç!BX:¾™üæíÇÀ¸§k›*EBŽæâ(*Ž¡&³‘H‰DYÀ7©¥ÝׯäÅÜ í GN=d­ßÈUê-7·ï$þê›±¹ËZåÊ–Š._LÒ&VDrþòµGJ˜qÒ6~l]WrÓ_L"䬬¢©›È"fÇ2ñ¼E´K“2ݸíÌLÏIĸAcÖ(áê ï·JesûÚ?^59=œ&,ø7G­CÙGrã\,)ïGmI4™D¾‰)áÌ:&½æ}TÓ÷KëCÄH‰˜1DÖ\óÛ‚E‹/_ÝU³/_ÜIÄ01S{[Þ hÞo\>qDNl%Z‰•,š….]sòw:"Š´mZ–b³Pš#†Î°H!çºæŸåXXÒ­LõRç¶~ÿ†×² ¥s°¨í@-• pNu8k0ïG‰<”ôƒ â‘ü>Q§ (×;C35G9Ç×ðƒrýqr~MUÊåQ‡šÚ§¹)‘…b²Nú×3mLs÷ë*c9³¦òÓÄÔ^•½,{X„«¯nîêúáß5L¿s®™þnB¼i6Wf£BC°B@n>j7Í%™DHÊ6¹ Ù}à妤Úú¥Uùr[¢Tjqº¶•NÐÞu|½gL˜ ìœÅ7}1ERŠÉ¿95X)Ž1Ǭ,}߆1Á§ŸŸJLÐÒj>ÎUÄ&ÿL¢«ærd™!ÓìwReéÔÝ‘Ô`%YH%I'Qj~ŒýIž4JSfÊLÍ]"‡ö½Î©è’©ÔÚºÍ×REКD~雤ö ®i¦ÖÂìÏœ„»Dî?s—NoïkxÿxYsô‰f²7MÍQk#ûHÖÈ—_ü‡±úcÆö¹¹\ôýÒŽ‘põk¢v½(qlð¯ó¤X¡L$Q>’–RÞÞªþÚ“¦÷v@ó~Å"¥&*Kgÿ¥í46jEø{Ëi2%ÝÛÇOßTjÍ€„+ÿÎ?c{ÍØØ!unë÷­-“×¥N›ˆïËÖåH¶+4i„OêBc&Ñn"mæ4©’BtÙÜ®êŒð‘è}Lr}Ó?g´k“¡?Æ®_"LKÄ•ˆÒâ›f¦ö©¼¯¿/£‰óoMŒíKc >ÐÚ˜Ú«rÍïÒo‘ŠŠ•H8«å|6v “ˆ.ýþ͹fš\,¼(![é1PC£1€€€€€€€€˜G@D*©‰d̤æ]¢8J,’|ò¤¿XNŽæ9Áu¬¤î˜DïˆeL—DÕ ÁëGjé›Ü¼”á"n‰Iº*-êÅ”§þù¦LÁ¼éiË® *ÊJìÓg7ó&oÐJnLŸ:wW½*éûDX³M`C"ˆ=~êÂu{|ú·b±GêBšD+ ¹ñ-u²Îr½±˜1™5×5“Só]±c§oëº9¿ýÕÆ.Q\Åv›ëæˆI?©7¤o¦æ^˜£?nÜ~¢Ò¨Uå4|bù¹¶–܈×,¨œüLø× úó3ÕÆ¿9U«GEž$L[E’è›)^†ãgŽÂRRå‰ÉzKta"NAiÎ>4§æë™ Ž´Ó÷‰p¢EºÉ~qãzg"ºI·$¿"ëÌÝÛÆæ-ç®ÔÓöܹ‹Ž:\’öïÚÍǾðíÚ…Zp½ªÍËÿæ”z}iëÊ~*âK‹Óo¬¿Öæúèëx½}nj¯ÈëAéÛTrîÈuhP¯:œ"ò¥j¦¾é'm%=g+®=–<©­Ú²/¥œ˜DÌÝäZdšÉµCÖULjù%¶ó‰4 ÈD,—u—}&{@"\2‰‚’Ô’b"¢_5XÇ r3ÅÂÐS{UêZI]=©e¦ õæ\äS×Lõ„5,ä â*äX¢'P";G*š·t¿ª_5’ëMé›T±Ñ¶ç<Šc‹J˦ޖ'é%`«®ÿ£(™Ñ¸NQ*ZÀ'M–˜ÔDéÄ)¹äÉ{ý4ac‡4Réà$šC^É5dü3ÿ|Ó?N¢‹9}a“ÿpDD%¬ û»^ ‰IÀÕ›Î\¿ë´Š¦5À‡Ç_õŠÓ®³‡kEÅæ6’vOL"„Ê–ÈFͻ̥v\ûI¢ Ä$=ÖqëTÊ=Óút®®^—´cýF¬TP­ù†øð‰ÿªÖE˜0«Ì)ò]CRGFj`MášXgmU5€$šHØJ*2ÍLÍ]¢5$] ¤!ÓDGOݨjMÉÍgIË'õÅBÊôç'‘wÆLö“©9U(“ª4š¨‹BÒ?Þ/Ã1‚3GY'Iï(âY´hQkñ×p¦HfëgjæîU90~7µÍ¿9{¸ïð•äÉçõ>'[6.iô|ÓúqXy˜®\DÑ8J-§ÛLËç|²¤ñÕ¹#õ›,--|¥•Tw’†qõÆ“*¤áµÔ”²¿šÔ-FÍ;Ï¥x¼/òè­‡©cäÜ’}*‘`’–/k¦~š…›þš à=éŸÛ«‰lmhÈøudÇ×4™K×6U==cç{ü¸±|uoìš)⪤\ÔŽ4gÑÆQƒOkkkËFè^Ø`‹ÀòåËi÷îÝôï¿ÿF¬‰a6 `@`öìÙtõ*Ðãaa‹@ûöí)C† Ô°¡ï¢¢aË˰å͆ èáÇäààêŽ9;;SýúõéÄÎQfå?u‡0D2ò4vÓNsèüùóª(tHš““5jÔˆ.^¼ÈONÿWG#$Ç@_ ‘›€««+G¡þil+KoŠmñ4Hð,b¦¦(ÑéØ:èýû÷T´hQÚ¾º?¥Må#¢À‚O f³)´cõ€àw„‚L ^«é´âÝHRêEDÛ䚊 ëÞ¾Š+·í¹HŸ¸Ð7ŽÒ+Q8³Jw)ÂÇØé›YàÌÉu©ÌKYaNa“ÀÒ ÷jÁO7“ÖÓåëþØôö¾FË×Sã×j6Eýûêõ{jß{A¨øäííMg/:êúž0s ݸó$TÆ’NÏ_~@ß¿{™ÝhÎݘÏ_¾£GOÞ¨·^»|¤ÞC—ëšš²·›E –¤–]íÉËë‡Ùó:~ݼûÔd{m­Íîêï[ýãô…_‹.ö$û"¤-°ko8¾á^5×?ýuܱï’Z?ýóK¿ý÷Íí?"·Ûºû-]s4"Os‹  \EÐ…Å´@@@@@@@@ 2eéZ¡(,XéâÙ¨~B¿Í•[wŸÑÉswuãuiS‰²eJjãËMpÏ@>âH”(QBÍÃŽ÷¹FÎOߪ—íÆ¡a×S¿øø•î?|Eÿ.îMZW éc[P´h!æ×Ï@öäß¾ cýágŒkAQ£†ümßàúe¸WÍE¦¿Žæó·›9î  €@D Â18@@@@@@@@~+‰Ö˜:wݹÿœ¼~xSÓúÅ©Z…¼4oٺő0=/¥ÆuŠ)Ÿ»A‹V¦·ï>S£ÚE¨A­"êõm}°uÏE%¼Ô¨”šÔ-F¡!¢Æ±Sw(QBêݹ: Ÿø/ýðò&7÷ï4aXJ›ÊNÿÿnÄ;;VP7l;SõQ¿fa:vú6:wrfKE/^¾WâH@&Ò_{ñKÛ_ß¾}W{xÌ †¼F1uûIŽ—¸1Ó6Ѭñ­üìÕÄvñÔý6ydSJš8>Mž½ÞóŸ4¢)}ùòÚöZ@³'´Rc-šÕ) SJ½ïäü†º XL>¹QÜéxWW¯;ÿõ;\µá„Ú7ÞÞ?y/§¢~Ýjš\?ÝÞäk‘ø™:e"ú»k Ý|òæLC²]\?Ó€5Õ¹&çpñB™iÉê#T³J~ª^1Ÿbóùó7²´´ ÑÌÏ–÷ÚÈÉèÅ«wäÎëÞ¢a ªT6·Ÿ×ªòµÏÔú¯ø÷8í>øöîÎæª àøc0cß³ïûšÝY ¥ì$¢ì»"Q¡"ÙC²$[JvE EY#û¾eOömlïyθÓìsguïÜßù|æƒ{Ïÿ,ßó¿÷}û?óœó§½G2ãìYÓ9åF%\I€À•+­cA@@@Q`Ùª?mÀjúøN殯¼nh—(šSÞhñŒl0£±·¶×iÆføLþº©wW^n9®NŸ½$«Ý%SÇv0A¡‡Ò²ó©f²£´|³è™9¡³}Ð;gÁzy¶rQ»oúóòú/[(q"o<Ò­Ëš`‹Öÿõ}6pµmÇQù_é|róÖ§VPÇðþ£ Šmôßú3`è|ùì“6’Ñž4ˆ1vÒò¾ ‚hññI(¾ÓØr^n9R™ Ü+ +Ê–í‡íCö Å>Ø6 &Ìúö7Yºr›´hT)Ôñ­0ý¥K›BÞîö’ ´ë5IæOíé_ÿùgKØmÇ>1°¤I|äoÔ™·ðw™fLul\ ?˜­k™zËC§åS|Їò3¿Y'_›±´oUÝ"˸¡­íøF|¶Dt›E} °hSÇv´/uï7]ö<%òfÕ©ÊÓ…LP-T5k«ÛìiI’ØÇ±4àç¸O}„6g L$OšHf|ÖÙ&ê¼2<ÌuÕà\©â¹lDƒ«º(Ì@KÐûÖÑxPcÃþC§L†X “A–R¾4A]G]wgüÞÛ'Úç)´I†4®}OÛõÓÏż…äëù¿IÇÖ5BlBë„v¯–-™ÇnïùbÔf®§ÍýýÀ·ï:.¥žü/pêÔËT:i̺Åïe•ÓeëŽ#6(Òçß<Ó¶¿_¶Y¾ÖÓ~Ü5ÛðŠ~|1²­­Ö{À×6ȪsÑ@ß3•ŠHqóýtäØ92z¡LùÔ/è¶zÝ.™=©››n]ÙºYð]³~ÝQòwÌ÷–ãž×q3[_}M¿+BZÿ‚ù³Ø •~ŸiFa×¾Ó\…·¼ï’®\rY € € € €@@-ÛØ +-‰|¼¥¢ÉPùsçQ ( êérùÕKh,úÐWë9v^:¼5ž§Y1úàYË3‹Ø ” xôè7Cþ1™ML6Uº4ɵ¯¦ÿ4Ù¯\»iÆSBæ›l#}¸üçÎcÒæ•j²î÷½N-œfi‘­´èÃòs&°¢¯iÐJKÍjOÚŒ2Gq<Ä×Ò>> ì¼Â*ùrg´A!-MæÅÎ=Çì¯Þu;==ËHËc¤„жÓÀ‚ZkÐJ‹7–®Ü,p•/O&´ÒR¹|!6n±Í¾:lê¿eøk¹yÓׂ–&sÅQôý‹ÿ^—ófÝÂrrjU mΚ]¤1Z4;,à8Bj_ÛO™À¥–B&x cŒ®’ßê4h¥%§ÉìÑutÖ/à½p<¡}žò›µr¶äəޭ´”/“_†š`]dJ™’¹eõÚ]Æ-«¹÷SKÓ¦]5 û¿Òy#ÜdY“ ¨!-U*’¿vŸ°AË>ÿWÕ«5ÁÚÉÒ²q%©øTÁpû-V(»òeó›u9aW^æ³\¤`6û^îœÌZýw/Ô1Y’ޱi°ûŒÉ¢Ó¢Aò´©“Ù “A5;K}¤ é5ýî éó³sÏI©dÆžÐdjÑ / àSp7Wî¶bŒ@@@ð@Í2 ZB;;)Ñ£‡éŽúº½šn½÷L¥¢Ò«“ß¶aŽrÜl±§9Ž¢Á‘Y_t•ŸMvVGäÒ­é4á(eK呟MÖ„>¿wC9tôœü±å <0¤L‘Äé•ÑŒ­ ã׌²°Š·wàGy:¯ÕT=è@ú \‹u0[jpÉ™âšHØçYùØt¦Û:2VBëÏ'¡_PÌ-Íuá993öÿÚ }Î< „„ÕvÀõPK-¡YGdŒ‰ŽYË¿€÷vÀ>Cû<Å ²~a=|ïþ÷™Ô5qÌ5à]^pUǤÛQ~úù Yg¶Û¬l‚.ýÍdQî0§×MFå·#Âì~qÜÛ!}þVÖóátkÃésÖˆf¤év…a­_À¬¬»÷îùž5J=g¨¼_“š,>GÑ- 5#2è7—ÛM \··|ί5c4èkœ iýÿ°5Ð÷ŠWüØ;_.Ò‹Ä…„ ý§ôÁŒ €qNàØ±c2{öì87/&„ € à>º ߪ5ÙëÃðõ›Hq³Å– æè¹Oá•Eu;®ÝrÅœ{£E·ÓÌA‹nõ§œõ\™ú/–³çú,ÚÎÞ§í™KšUñ©vû¶R΂ o,ú~ú'RÚqë†Z4ãBD_¹zËÿ5ÝVLÏÊ «hÆË“½‘’ÖŒûì£Lc'/˜3¾üL4[D·KÓ,+-WMVYÐ’8±éϜݣEϹҭÁ‰•f}4{&h9pèŒÖÉšßvË“…sØù߸yGö˜3¡´h ÅÙmÃrRÏ“Ðæ¬Ù4Žì9 ìmßuÌ6wÔlÛ¦gY-z'ý¾ù }YÏßÊÞ/C*4ë°îۀơÍ!*~ÚfhŸ§4&ëÇ‘‰¨õ4€â(AÇuèÈYÿº«~ùË®« ´]lÒLDÿëC¹Wµ~’$Þö3­g}=U&ŸÍúÓûБ1èÌZ:êü±õ½Ÿ´¬Ý°×lÙ—Ãl+þç_ï?½·úv¯g¶,<æúé›°Öà•ŽS?«Ú½ÖoÜgÿ®™ž™3Ï$Ô÷J›ÏÏ‚¥›ü§¦Ÿ7ý,éwføéÙX:—^ mý‹Ê&6ðŸÿFs½£h¦¨ãsOê"ð8ȸzêô‰ €€› 8WÍ›ÿw˜¯›Má"€ €¸¹@íš%m£yûqßËËœAUÞ>dÖR­baiÑq¼¼n¶ê ­è–|Ìù;m{N²çVipJÏf Z6˜‡ç3殓D‰ŠwÂø2¸Ó@Ut›Â ¼ÌÙ;9í륟Ì-{ÍY?zfQDŠn¿7èí†ÒgÐ,û ºhÁ¬Ò¿W}ù°_céûÁl;Fݾp€Éê «/š]&N[iç5àÑYXá£Yý§eȨïe…9S*™ÙJ1wŽôö’k””ƒ‡ÏHÓ¶Ÿš¼›xR¬pvy÷ÍúšÓsµÞz¦Ô¬ú¤´3çT55Û)¶ê2Á‹š`OÐó­ôb F ·ÄlówMR$O,ƒû5±Y!ÃÌYYY(¾öáÿCãÑ(Pv[XóÍé9pìûálûð¾O—àç~m3´9×}¾´Ý®­e§Ïl&nc§E·sdÒlK·sÓ³”ô,1݆ұ¡YkïÛjf»JG h¬ã©DÕ/¬ÏS9|Ô³‘’›µÒŸÐÆ¥™ˆÃÇ/1Ó+Æ(© éßÄVm×òY»Ýf&°ÉhxŽÌ ÷ªc›L½FÏ%Ó­ó*Í.,h¶É‹LÑ€jç·§ÚàY™â¹ýÏÉ éóïØROûéõÞL¹kÖî¾ Dµlâw\hë§ïåÉ•Aº§«×nI9ôulå©Û“ê6šs¾Û`·ÛØÇ ¥GûdÐðoí™eú ç[©C¿æÚ­@5 ֳËv[È ¯…µþz¾V‹ã%EŠÄ’=K:ÿn®Ø"åLƨc ÌÈØr ±%ïàÁƒaç‡2’´iÓš/iâ^±µPô;3fÌå˗˼yób§CzAà1 Œ;V¶o7‡üš?)8#°fÍ1b„,]ºÔ™êq¶ÎñãÇ¥AƒòëÒþg ÄÙÉ21\P@Ï Ð‡•›6m õ¬ÈûÈ‘#Ò¸qcÙ²e‹9Ç"Yd›á:@ T‹/šß€¿a!Ÿ„$Yü“¾N/ˆŸ8‡ÄóΩk£ë¢K—.Iùòåeñ¬Þ’+»_p„‚€; 1¶fõ+xüý|öÜ%yoè|™<º;/g¤Ç¾äÇ­rÆdKjð6h©óÊ0Y2«O¤ÛæB¢* çÍ]¾™\FŽÕ¦¢|ý½k&SúAÄ·û$òez@@@@@Oè×£ž'LÓ©9†´Õ¦SÆ‘J!ì4jgÆ©Rqd™ÆcàŒ«ÇÊOç € € € € € €2®¸@@@@@œȘ!µLÓÞéúq­bçB>{L繘mãÚr3ŸÇ @ÆÕc@§K@@@@@@ஸ+@@@@@@\B€À•K,ƒ@@@@@@ pÅ=€ € € € € € à®\b € € € € € €+î@@@@@@— påËÀ @p}___×$#D@@@pkn=z €±&@àJÄËËïw~º½3Ýÿï±¶t„rãæm«àø,B‚ € € ÷\Ž5eF € C)R¤¶mÛÆPë4‹ÎTªêL-ê € € €¸«+w]9Æ €±.2eJiß¾}¬÷K‡ € € € €€§pÆ•§¬4óD@@@@@\\€À•‹/ÃC@@@@@ € € €¸®@‚ ¤l™Ò²`Å^×$#C@ N $ %ò¹õŒ\¹õò1x@@@@Àu’&M*3¿žåºdd € àrœqårK€@@@@@@Ï på™ëά@@@@@@— pårK€@p=ooo×#B@@@ˆs®âÜ’2!@¢_€ÀUô›Ò" € € € €@pWÜ € € € € € €.!@àÊ%–A € € € € € €¸â@@@@@@p W.± @@@@@@€À÷ € € € € € €€K¸r‰e` € € € € € €®¸@@@@@@\B€À•K,ƒ@@@@@@ pÅ=€ € € € € € à®\b € € € € € €+î@@@@@@— påËÀ @@@@à>„@ð(WµÜL@@@ÜI ^¼xî4\ÆŠ €DY€ÀU” i@@@@@@ :\E‡"m € ྾¾0K¦ˆ € € € ð8<ÎÎé@ÜG€À•ÈùóçåÝþï˜E㬠÷¹siœˆç%“&M‰sÓbB €@\¸qã†thßÎLï~\"óB@Àå*<]E:vìèrãrv@®œ•¢ €x¼À;wdÛŸ;¤uóª’È'¡Ç{€@l œ;Y,ÛÛÝÒ €@îÞ½+›·l•fõŸ–T)“D¡%.E@g~ûcŸ"€ € € €! ¸â¾@@bY mêdòÑ;ÅÛ;A”zž>g­ÀÕ§“VÈØÉ?HáY¢4>.F@@@"*µ§%íú € €€¬X½]V¯Û%I“øÈé³—äò•’+{zÔ·‘$Nä-/]—oM‘k‡Yû÷Ȥ¯V˲•Ûäî½ûRêɜүçË’+ÉL†•ÖmÙ¸²¬Z»Ó^»î÷½’-k:©û|iû^"Ÿ„6hRùõ}’-KZ´Ò’,i"éÞ¾–Ì_ü‡õÛwîJ¯N/Jút)m{@@@@À•ȸr¥Õ`, €¸¨€···‹ŽÌu‡¥çX,^ñâÉÝ»÷Bð™s—eðèï%a‚øö}ÝÐ;a|›1uöüÉœ!µSÕm 3¦O¨n挩M&Öm¹n²±´˜aØm ) € € € €€+ ¸rÅUaL €¸˜«H,ˆ 9[2<‘R>x§±9Û*W°KžH›BÖ®ßãTSšEµvýÞ@u5(¦™WšÍuËd[ÅÓÈ@@@pQö‡qÑ…aX €oϳš8â >n±|»X†šŸÄæ<¬êUŠIÇ65 G@@@ÜB€À•[,ƒD@ vR¤H!µk×–¥K—Ú •þd˖ͳ¥]»v±;(ïí÷>ôa­gKˆþ,­›W ôï€[öÅï%m^©fB*E f“Ÿuö–¾¾xVŸ@¯Ì—E¦ëb;ºíàŽµÃœ–ú~s§ëR@@@ˆ¶ ŒEÚ@@  ôêÕËfU9ÊÉ“'ýÿž?~iÞœ Fd—ýô™%µÉ¤¢ € €ÄmÎ}¾”b•zË'Ï,ÛÌ@çȸrΉZ €xœ€f]iVÕ¨Q£‚Í]ƒZ”È Ôm1\®›íu@  € €€óüéÚwZ° æLî&š‰NA@¸!@à*n¬#³@@ F4«Jϱڶm›ûº…`™2eb¤?Ohtñ×½=ašÌ@ˆ1¤I|$wÎ þí'Iìc}Ñ0 €ľ«Ø7§G@ÜJ@³«Z´haǬ[’måVËÇ`@@8'P´P6™2¦} y›üƒLújµÞ äyóZ*sÞè‚¥›äóé«Mc줲yÛa©R¡°ü{ù†Œœ°L:õ™*óf–ŒRÉ–íGdÌçË]³÷À)™6gT6×Ä÷Šgÿ¾jÍ_!òlßyLú œ%7nÞ‘uÊI åín/I÷~ÓåãOɹóW$‰É¤z«Kmÿ z»¡xyyÉÇe×Þ“Ò¼ý8Ùö×Ñ@¼ÿàL×Á¶Î]¸"š@SÝçKKÿž/ËŽ]ǤEÇÏdïÁSÁ&5|` )Q,§”z2§¼?t¾,5™]5«VoƼuöº¤"6 IDATµîíjÉÿJå‘ZÕKHËNdñŠ-6ÈFA@WÜ €¸”À¢E‹äÎ;R¤H——F3­.^¼èrãÚ½{·$J”ˆÀ•Ë­ B@bF àWÞ ãûwòL¥"Rñä·ûíkÝÛ×’ôéRÚ¿ûš-üFNX*«Öî”.^5Á+¿ËnܸhÙ2§µA+-éŸð»¶pþ,öÏ O¤²Þ¼ålb¹s¦·¯ÌçW÷”Ù0¤räø9ûò»Cæzûô¹ë‡Ø/"€ ÇȸŠã Ìô@ÜO@ƒV5r¿?Æ>|ø1öN× € €±)ÒWÚÿ“-uÉlïç(ÿ\¼æÿ÷¯¾Y'³¿[/E d•û66[õÝ–7ßÿ:ذõ\+G‰÷è/‰ü_{í a²wL`L˽û÷ý´êƒ~môëQO2=:'Kÿ$ñýƦ%}!€ àŠœq劫˜@@@@"$0÷ûße÷þ¿¥¤Ù²/eŠ$2sþ:9tÄoî‡ÎØ?_ªUFž6YYñãGï#±-úý"•nc¨%GÖt!Ž=o®ŒöõÛwîJÕ§ ÛŸÊå Jþ<™"4W*#€ —ȸŠË«ËÜ@@@@8&°ÓœOÕäõ1þ³úø½f’2e?å‰gÒ¤úõ¬'[¶‘OÆ.–F-éã;IÎìOØúSg¯‘=NÉÚõ{¢5xõÞÇ߈ž_µïàiÛOhçU½ÑâY½n§Œþ|™9?ë¨x{'”¿v—ÖͪJ³ODZ•b: €DN z½$rcà*@@@@œ¸yóŽ >9~nÞö•‘Ÿ-•k×oK½ÊÚs¦šÔ+o³ž¶î8*‹Vl‘WV´ç_]¾rC6m;$ï½U_Ê–ÌãTáUÒì­>ÝêÊů‰ž¿Õ±u ©U½dˆ—)˜UÆÒF ›- ×o< k~Û-éÒ¦BÎÑ ¯/ÞG@Àȸò„UfŽ € € € €€› è¶z;â,†¼ÛLôÇQ&L K缨îÄoúwªOúÿ»hÁlÁÚ>¨…è£dÌ:Äþïß µk–’Æ/•6¶Ï†½ìµÊå ™í ¹ùj0|@bN€Œ«˜³¥e@@@@xøÐ&É@ˆ%W±M7 € € € € € €a °U w € € € €‘Ø´jH$®â@K€Œ+î@@@@@@— påËÀ @@@@@@\q € € € € € €¸„+—X € € € € € @àŠ{@@@@@@À%\¹Ä20@@@@@@ € € € € €€ó—¯Ü]{OJŧ :QjÎúö7™2ógYùm?I˜ð¿Çw{œ’Ư‘T)“ØÖxUÊ”Èí_ÿÏG¥×»3eÉì>’,i¢pÛ «B÷~ÓÅËËKFÔ2Pµ™ß¬“ÑŸ/—¤I|ì:$0s-S2¼Ó£ž¤K“ÜÖýfÑïòõ7¿Ê¹ WÄÇ;¡)˜U†¾ß\R¦øÏ9hßÚË$‘·Ä‹'’&U2©ý\)iߪºüµûx”Ö*¼ûç¥W‡Ë™³—ìnݾkÆœÀÌÝ Â”±·–ƒGÎØ9ÏÜMòçÉì?ô{÷îKÉj}eóOƒí¸C+áõ¥…І‹ÃßéRÈK-†Û^>¹}箼‡]:HzüZ='ó§õ”$‰}üGôÇ–ƒ2vò2û‹®¡ŽòБ³ÒºÛDcÛ]²˜ÏŒumÞ~¬´jZE^¬Q*fH¸+÷Y+FŠ € € € à«Öì”Ã&0«‡æ©ø¼…¿K¾<å§µ;¥Võ’f¬A+}H®åŽyxþ• Œt~{ª¬[2Ð|¥sŸ/eÐÛl èÞ½²{ßIJªZñ¢9åérd² ”½Ù©¶­wñÒ5Ûî̉ÃÔ¾ÿ}_T‹Òš5xÚ<à(ë7î—žï~el¹sf°ÕÎ!*Ë™,YbéÚöyñÙ™3©› ôøYF|¶Tºµ{>ÊA«³ç.Ëñ“ÿØÂ¹ó—%CúT†[¥Baÿ€–3ûVúÞ¾¶iÛ!?ùG™<¦È›Y4Èöמa­W}ºˆ»GOœ—.}¦JšÔɤñKå^«ÿc‚^IE–°îŸE3{ûW}¡éPا¡”+•×ÿ5 \iP®ÿày60¨êì:†Õ¿cýBj+´{ÍÙ~­Öø6­b›9{î’Ôh8DÿØö]s¯Œ6Çþ½ê;Û¥­—×|>^¥š¼ÿñ72åÓöö^ž|×à^æL©åÐѳQ¶ ©—_,+úàþ³.ö>øéO¹g ê½P6ÊýÍ3SÏ?[Üü”ù‹7†Ù^ª”Iå…ê%䨉 ¶Þ©3ÿšl·'lÐJ‹*˜aD‹®A…rùM¶“ŸŸ³k5aêJÙµïïÝ?ÎŒ­–±Hi2ó>Ÿþ“3Õ£­ÿ ÷ژϗÉ{&Ȱ,þa«4k76Êãr4ôóåLÃHÕÀ¶~Ž#ZZ6©,L°W?÷ûž’E+¶È»o† jóÏ¿×ä™—?”mõ‚´õ{‹‚€»qå.+Å8@@@@ÐàÄÌ ¥D±œ6`¥™F3殕¶-Ÿµ2«Öüe¶U+-?Ìïg·9»qóŽ´ï5Åd?¼,Õ«³Ûç½Úé3™e²Et ;ÍY8ó-»5—nýuÑ<ðÕÖušÉ´ßÃnÞô•¾Ý_ò_‘j• ›íÓ2Fy…æ,X/M^./ÿ3™.CLöŽÐn˰͸úvÉF)[2·ÉbJj³ytË< &õìð‚äÈö„Sãɘ!µ¼Ò°¢|jwí_«aƒ{‹¾ö˾Ùè´ þ]ˆíhÑQ¨yðà ðŠÝ0`Ñ€Òšõ{l@M×,&Šèút­k‚ó¤’ ’é–lCÞmêŸ}Ù>õþÒ@ÈœI]åÁý‡Ò¢Óx³]ß³¡fiÆÕ²UJÍjOÚ.+W($ãÌXÆOùQZ4ªh×'¢E3Ö4ËiÝïû¤_Ïzöò°Ö*¢í½œ¹þ–¹ç>ê×D½>ZªV,,Å ewæ²ëí?"÷ZãzäåV#ÍÚבä²Ê›@OƒÚå"=ž FÆ'‘ &0™jz?~?ãÍ`oa NïåÁý›J‹Žã%…™Ó€Þ ý3ô"b£Yqöm,ý>š#ßNí)ËL0÷úÛÒ®eõh³¡!bZ€ÀUL Ó> €*0{ölÉ—/Ÿ”-õßtôPB¦ € D žLþÍ&Ò€ÀØI?ø®š×Þìô¢Í6Ò²î÷½’)cj´Ò¢[Õý¯t^Yc3¯5«*¹s¤ éßõa¯ãl™ð® iaºµ­åõÒŒ'=ggÔ‡-mà¥ÑKOɼï—÷Þjàß¶fWU­ë·Uà¿—¯KáYåsn’½f°×휺¼=ÍfZéÜž©T$ܱé9Tu_&oš%[×ôhÆPXgó\º|Cê˜ë¦|ý³t0A/-cL&Ö挬k×oK|™Í™U%}º”æ gY9Jú'RHÀmêÂl eÍÙR…òg•6Ý>·”’ÅrE´‰`õ4Ùx¥L°Í1îÂù³Ûºq݆½v-î›À®Kíš%ÍyD•m[iS'—y_öi³×H}`ÑÀ]‡×ª‡„ 8G»$ò1gœ }¿Y l­ÐÖjæýv»B-HÓ¬ýœèùZzÞ—ßë¡ß?Πi6[Æ ©äín/ÙÀï7_v7ç{X†ÕNXýGô^+gÖ}éÛì¶”úÙÙi¶Å3¤•3ÓµN”}L°¶FÕ'mÀvèØÅ6È‘’Ù|_0ßs¨ÒlIG‰¨ÍÓÿ+`¿÷zœ%M¶ã׺ÛÒ3"ã¢.±-@à*¶Åé@¨[·®øøüw í㘶Ï&Ož,'N”‚;8»W¯^rãÆ 0`€dÎüßÃcüô‰ € X EòÀçõ¤K“BÎÿsÅ¿Rs–“#h¥/ž1gÑh–•då¦9ʱžž?ôõüßäU“á/w&éÙñû^x×ÅÔºÌ3[„i ¨Rí¶ Í¸yhþÔì)ÇYEzÏšÅìûwïÞ³Á9ÍÐøzb›a¥ŠVM«Ø=géÝ!óäêµ›án§™"o¼úŒL1g]5¨ó_ÖŠfü|bÀ‡V®]»e¶ŽK,/Øš¯G»Z6˜ ÍúxÂl}°D×WÛÔ3­ê¾2\F j-Ë3gÁ9lø;l÷ïß·Û1_Î6®ë®[‰jöbâÄÞÎ^&º}éó™Òµóùrÿ¬ÎÈ|Û4¯&ÏÖÿPÔIƒÜI€À•;­cE@ÀIéÓ§Ûÿ`¿~ýº\ºtIŠ)bn¾#/^”³gÏJóæÍm çÀ2gÎûľ¾¾Ò¥KÉ”)“|ùå—råÊ[÷Ÿþ‘5jØk‚–Í›7ËÆm_Ú¾þÇ\ûöí%yòäòí·ßÚ>žzê)éß¿¿”)SFΜ9cÛ«V­šTªTÉ6·eËYºt©ý{ÆŒå7Þ0ÿ‘µÿ‹¢mŽ=ZöïßoÛM‘"ð(‡Å¨u×­['xk×®4mÚ4B×;¹DTC@ˆ„À¹ ÿ©ôò¿O_ ”ÉÏ+^ V3<‘RŠÊ&SÆ´±·欞NmjÚL˜¹&PÑáÍ)ò£Ùf0¼ëâIà~"1•`—è¶dº5ݲ¹}mÿŽÒÇdLhÐCÎ- M6γ•‹™ì¦Õ²uÇÑ`[–3Û Ö7[§mØt ÜÀ•¶­YB üË#ƒÉ{5Ä)~fÎÂZ¿q¿LÛA2¤þ`\ƒ&E f3g묒Þ]êDS¨m¤M̾§‡¨–Ýæl( Dm\9Ø¿)Ýöð¹ÆCBݺQ&­ÌEzö“fÿdÏšÎÿZ]']‡åf+ÁÍ7på¸P3æÚš`â`³eäl³eaÀu i­":ïðîŸðÚ{×lÁÙ°Íh›Ñçä³Þµú~Ðþ_ªU&B÷šžýõñ˜{¢ëµtå6“ÝÔØ™n®Ÿ¤I|ì–•o ˜%ý{¾ìTŸ/]“OÆ-‘É£ÚÊéRØ­5kJ·ÿŒÌçpðèïM@¹œÉÜeά+c²³85*!à ~yÓ@ˆsHêÔ©“¼ýöÛ²dÉÉ“'ýw«V­dáÂ…v¾«V­’FÉÀåƒ>°A«}ûöÙŒ£7ß|S>þøc´ )hå;zô¨ VõìÙSŠ/.ß}|ÿû»wïJúôémÿ}ûö• Ø€ÚåË—mÐêÝwßµcHœ8±¬]»6ÒkqõêU4htèÐÁ?hU»víeNi ÊQ&Mš$¯¼òЬY³&ÒcâB@@è8yê¢}@­E³_¾œõ³1¯k 뺔)’È‹f›´UkwJ͆ƒ¥Z¥¢Ò¯G=YoHºU_Àsi""¥Á)Í® Zž*Ï¢þØzÐlÉ—$ÐEz¶R¦ ©epÿ&6p¥Á’ŸÌ ðiLšTÉä¥ʈ?b¢Ì ­ýœÆ¤q½ò2dÌ÷&3«£­¦ç•«Ñ/Ð%ßMS²eIk_kòƧ&§í¿¬¶÷ÞªïTÆXtÌQφҵÕm#ƒÍšjðÚ(»õŸÇYTŽà”žç5q„î ßdf‘Iæ>½xéºÉ,Š/Y3§•Ao7¶g’E¤hÖÿÞú9;©˜É(KêÔåC{´rÀ3œ‚Þ?N5¤’ž¥ô´ù\Î % ]ý‡v¯évˆã§ü(ºUdt”èöéüúsæ»á@¸CÓlËsç/Ëk²«U,"+~Únƒâý{Õµ 6$o¶pœ9±³Íj«útaùÕl):xÔ“Ö,ܱPWˆwðàA¿_7‰`I›6m”·ñ‰`—TG Æf̘!Ë—/—yóæÅx_t€Àã;v¬lß¾]ôOŠk è6yyóæµYPQ)ŒrlÓ§íhö”f"iPH34P4tèPÛ…§Ö¯_o3Ÿºví*9sæ”/¾øÂn+¨¬š5kÚ@SHE· Ü´i“tîÜÙ¾}îÜ9ùôÓOeÈ!6 æCŸ>}ì2dÈ`ë}öÙgR¾|y³'ÿ]Ùºu«ÍÄŠl™?¾½Ÿ:¬‰'žxÂÆ"Zt¾Žm^[¸paÙ³güºt ÓÿÁѾ©¡ ìÜsBš·g¿w ªGÔQƒé7¶Û—F6€Ñ>©ž% ÿßêÞ=¿lžˆŸ„$Yü“¹Ä¿nüÄ9$ž·ßÿÿz\Eÿ¿§þÿ¾Å³z;½=Zhc]a2’,Ý$“G·{\Ó¡_p1 ¯JífŸØ-> ê¹ØÑ.0ôÓErùfr9jt´·Ñï]ûKäÁíˆ^&d\E˜Œ @ˆ;šá”(Q"›m¥Ûöé™W¸:~ü¸ >>Ò±cGLÒÍ’Ò×3fÌ(-Z´°[ †TrçÎ-ãÇ·)ÍRÐ6B*úþG}$ׯ_—fÍšÙí µ´mÛVFeÿ®ýjp(´àWX“ÔÀUåÊ•m[Žmþ´¾žã¥çg9[NŸ>h»C“žñ¥ík@‚ € €€; hpÊÕT¡¹j0çÛi½Ü‘=Ä1ÇåíÚbë>i¶ù\¶êOy²pv{Ž“»”¸¼öÓ=\¹Ç:1J@"$ðÚk¯ª?räHÿ§NÚ›À† Šþ,šõ /Øs¯ô7Z×­[' .”6mÚ„8 hi°)h :ͶÒm¸‚=K¢£èÖ„šu¥ç]iKƒdœëÕ«—¤H‘©.fÏží_Oaz¾UæÌ™º–J € €1'PëÙ¢?@àÍNµE( 7\ÅÍueV €" A¥ 6šmõêÕ¥L™2‘8yò¤Ô¨QÃÿüÍ‚J’$‰°ËѰnçW¡BÜr¦8[Ï™¶Â«S·n]©Zµª bÍ;×n謉ÖÕ,3ÍÒröšðÆÃû € € € €Î ¸rΉZ €¸¤€féOtÍÀš6mš=gJ×L%ÍžÒ³°‚–Í›7Gg×ÑÚ–Ž[³¥t‹?g³­t#FŒˆPýh4!€ € € €.@àÊÃo¦ €@PÝn¯ÿþNÁ”-[Vô'¼2lذðªÄØû Zé "Z?ÆNà € € € €€ xyàœ™2 € € € € € €€ ¸rÁEaH € € € €€ Äæ9¡ˆ#€ €® @àÊV1 € € € €!Ä‹@@À£\yÔr3Y@@@@@@Àu\¹îÚ02@@@@@@À£\yÔr3Y@@@@@@Àu\¹îÚ02@@@@@@À£\yÔr3Y@@@@@@Àu\¹îÚ02@@@@@@À£\yÔr3Y@@@@@@Àu\¹îÚ02@@@@@@À£\yÔr3Y@@@@@@Àu\¹îÚ02@@@@@@À£\yÔr3Y@@@@@@Àu¸îÐ € € € €~§Îü+gÎ]‚Ã…Ê”ÈáÑ\½vK>áë¸ æòçÉ,)’'ŽP¬c„¸b¥r¦ ©%K¦4îkßÁSrýÆí_Ç1#,i")˜/KÌ4îF­¸r£Åb¨ € € € €€§ ,Z±E&N[å©ÓwÉyïüux„ǵÿÐiiÓíó_Ç1'0ul)[2bAHÖ1æÖ#²-wl]C:µ©áˇ~ºX¶î8áë¸ fJÏ-ÓÇwŒ™ÆÝ¨UWn´X @@@ðdè¹Æêoþóp”ƒO‘ z¹ÆìãÖ(ŠUê¥ ±ŽQâ‹¶‹_ë21JmE6è¥N¹8˜À„©+eÓ¶ÃÈWÜ €¸˜Àž={äÛo¿u±Q¹îpÔËÇÇÇuÈÈ@@@@œ på4@ˆy ÈþýûåàÁƒ²èž IDAT1ßYéÁÛÛ[Ô‚ € € € àþ®Ü ™ €@èÝ;jÛ4Ä! ¦‚ € € € à^8g¦Œ € € € € € à‚d\¹à¢0$@@@@èèùîWrÿþ‰ßK åË,íZUÞœhmûÎcrâÔE©û|i'jÿWå÷ÍäÛ%eä¯Ú9#o¾7S æË"׮ߒÆõÊKµŠEä›E¿Ë¯¿ï³uªW)&/Õ*¨ŸN½¿”ßk&)S$‘Í–õ›öKö/Dh,«rífŸH¾<™l÷µk–’g+õ¡L›½ÆºfË’6B}¼BÒ¥I!Í|§±9Ã9a„î¯ÇYY¿‹z½;SòæÎh‡¡÷mÎlOÄêîÞ½'#>[*ïô¨çt¿!}‡?yAvì>!ú=]¢XNI›:™4y¹¼txëKYüuoIšÄG.]¾!¦­”þ=_Ô×/¿í–CGÏIÛWŸ‘þ½&ÃÆ.–a_qz]ëÆˆ‡> ÕŸÐÊðñK¤w—:ÁÞ^µf§}íÆÍ;þã¯\¡¼Õ¹Žy€î+]ÞžfW+ùK¦Œijûàúzþ¯Òùõçdê¬_ä£þMbdž1Ñh–ÌidôG-Ãm:4Ãp/t¢BëæUC­¥«¯Ø"m^©¬Î©ÓÿÊÉ¿/ú®´BëæÕ¤êÓ…eÛ_Ge |¼P£¤x{'wŽÚ¾^÷ dþ´n— Zºãšºëz급¶='I«¦U¤âSeÂÔ•¢ŸI di)`‚ãÃÇ/–©c;ˆ—WØ›ž}dú½ôÅôŸl@YÛs§Råi¿ï¢°JXŸ¨Î5aÂa­~0ÁÞLRIñ¢¿{ƒ~‡:¾;;÷ùÒÿs¨®,SËD¬ kŽúý»pùfØš4ã'騦FT§åñ׸òø[@@@@ÀóîÝ»/­»~.eJæ–‹&èP¾l~Éo²ô7çïš÷š¼dßþ*É’&’‡EŽ˜ŒŠqC[K‚ñ彿‘ÎæÁdÆ ©-Ü›ïÏ´Y8·oß•&ã¢S›šrúì%9a©ÍžH“*©}èùÇ–ƒrôøyÉ•#½ iƒ€šåöÍ¢?LÐ!žý·fyiæT­gKج§{wMö^/ùÛ¬Û‡ýËWs×ɶGm›ƒW;÷ž°ë}៫röÜ%ÿ{ÆåêM›A•6MrÙµ÷¤\¾rÃ=ÃZœÍò|æ~+kê.ëéX“H“%•Ð|„T4[G¿4S²±ù.q¦\»qËÞî^ôsÖçC¿4Ûó¤ùì”(šC4ðôJÊrêÌ¿2eæÏ2 OCK ÷ÄüÅØïIýœumû¼ýûTóvàЛõÖÁ|wæÍ•QzœeƒMú]¬_w|ïš 6/ioÞŸo>Ç)S&‘ÿ½b¦fÀïÐ쫘@ñúûl¦«fN†V:¶®!>™oÿ· Wöô}ü®û0@@@@ØxwÈ<û°Y3^4.w|}¥{»Z&0õPº½3]j™À”Û@9v^õmdBê6mú°µ|™ü¢ÁGÐJÇ­¯Ao7²A.í㜠€èØ7;Õ–Ìö·õWÙÌš€%ý)íƒØÝûþ6ÛÆí°YPß/Ûl ªß6î—²%òHåò…ìCQGàj݆½ræÜe9l¶§ЧmZÇ0â³%öAp¿žõ‚¦â™­ÈÖ}Jú~8G¾ŸñflG[šµ¤Û­iiÕ¬Šý3,C HíØ}Ünñ¥ÁÿvϲA¡&PX²X®@ãºw÷¾Ý2QïƒoM±ª¯æ­“φµ±Ù2]ûN3ÃÀ[–|2§­7}Î{¶rI‘"q°Œ«Vïz/”±«•&s®e“ʶo j¦•Ë&ŽxCR›à¦<»ö.¥Kä’.æ~Ð iТ×éO±ÂÙmæ•w\Sw]OǸÿ}AêÕ*âéšÜ¿ÿPÞhñŒ4o?VjTy2ÌÏ~g\¿q[R™-< äõÛÓŠ~;Åù³Õ¡–°>Òúc·²ãýgØÀ•~jæaÀ¢ßŸúý÷é‹&Kt´oõ¬;qA†¾ßÜšõûh®ŒúÐo U-ú]< wó Iìwºù€òåò›ïÐÜÁ2®Bú Éý¾cÏŽ/ÊÑ M_¡g}êÖ­ú‹ Ž-AÝi ]q¬®\qU € € € €@´ 8¶äÒ†õiæŒilÐÑ Š EÏ‚rüæ|­ê%d˜ÙÊïÁƒ‡6Ë%`Ñm¾4h¥%K¦46³æŸ¯Ú •–¼¹2؇­ŽÛzÞKœØ[nÝö u®«ÖüeÏLù}ËÙ{à”\½vÓÖ ¸ÍUû^“eÆgm6ÂØ[ÛºÃÇ-±wƒÍZüÃÖ@c‰vèhP·h xF>üϰ Ù®Íq^P­ê%åÇ_v˜ÀÕEiôÒSF˜þ ¿, ½ôGK„ñý·xËdÖêü…«®É’Éomë—È'øcV „ýúÇ>“Yò½oôA»#påØ*PiYh³Áž*“ÏþŒŸò£,2Û6¨ó¿`’z]n“Á¥[—9Š;®©;®§ýܸûžk31sçÌltíõ{¡U“*2úóeö «ÐŠã{IךaÔ¤^…øÅ\“Žï¢€=„÷ù(n2­´hpV3˜€ÏŽ]ǤMí8Ó§óûlÚïÕó—m&kÎì~ghéw®f¤,šñªA+-´ºãøý€uCú II3n‹Ê.Ó§”å?ý*ä óž~·Ìþî7û9¦DM ì 6£Ö6W#€ € € € ಎ EÐú>zØù(†aßÖíø4¤ñ£É ¨QµX K4 qñÒ5ûšn'¥ÙX™Ì>dÕrèèYT XBêû®Éü Xt+,m[·µÓ€T—7ž—ŸÝ¨N"o¹o‚"ozÎNhlõA±· ÊÄ…žaÀ³…*™,»?ÿ:æ÷àÛl%°h0K‹®»¯ÙbLKÀ`æi³…™fw…µ~ñë³^‹fÙ½`žºvã?iã·å™É XôAûÕë·l¶—£-”-Ì`¦¶£=GOœ·—Ä•5uõõ ú™IžÌ¬Ýµ[a~”4ÛnßÁÓ&;Òï» ¬¢—«×n‡WÍ-Þº–A?^¾\_z¾Œ|ùõÏö—‚^§[¨j±ß«éSÙ —þ€=óOÌá•ø&kòöÐX!}‡†Ô¦f~MŸ³6Äîô»C·mѨ¢É†Ìa·œ¥DM€Œ«¨ùq5 € € € €€›¼=h–=³¨Pþ,ÒöÕgCu• …¥{¿éÒÍ<¤ Zôü#Í„qüF¿ã}ÍÌúbÆj¹téºÉ†É L£}«êòáÈ6#'…yÀ­g(i–PX%ƒùþ>欖o7”$‰}d݆=&»Ëo;8-ÏV.j·Æ*R0«yÏo{. lÕy®”=拏Ùf.iR›Ý£ÛÎÅ¥¢gÜt{gšR]ó Û‘átŽÃwß|9Ð[šñ’>]JÑ÷ƒ–Df+Á÷‡~c·ó{µ±ßV~¯™íu 3oï„ö|1­VÉc²nFOX&ã&ÿ`·€Ô¢[ŸéhŽ¢™QúZR³¶ºÝßÒ•Ûl]Ç×jÈv“m¢[Jê¼4à¦Û…UZ5­b·£Ü¿©[.³;®§B;¶ Ô³èÒ¤Jf¶ŸóË ­èZê–¡C?]jÝ*P¿—4ðùA_¿­öÜ©üb‚éš9¨E·Ô ©ü|”-™'P•¼f{ÖÃ&sÍ‘ðÍ3&ЬŸÍóæ³Ù¯G=û º%Ÿ~Oê9Ví[Õ—J³Ÿ†~ºÐþrÁ &óÒQ‚~‡ t‡Ô¨þB‚"/›­bƒ–¹ßoúµÿg?»¼ÒíEu{×°2í¸‡WˆwðàÁÿÂùÀH›6­‰æ÷ŠUÝ@`ÆŒ²|ùr™7ožŒ–!"y±cÇÊöíÛEÿ¤ €€óÇ— ȯK:u`²ó-Sœعç„9'`œlÚ´Éëg®s¦Î‘#G¤qãÆ²eËI–,™3—PˆÀÅ‹ÍÖt¡ÿÆwhù$| É⟌P_ŽÊñçxÞÁ·°ŠTc‘¼èÒ¥KR¾|yY<«w”«Ÿ0u¥lÚvX¦ïÉÑDý2=çJ3o4€°èY*c?~-ê¸I ›ÿ<,mº}.;áGåÚwä}ØÝ§[]I—&¹ÿ;ºedÐ ë욨öëÊ׫Ô[¦Ží Aƒ áùq®£cl¬ç«ôZ—‰R®TžH¬£rmx÷‰3ïß¾ã+½Þ)†¿¨ºúu AO:3*ºþwN¥—o&—‘£F;³1ZçÞµ¿DD<“ÈSŒ. #€ € € €ÄÁ£È•«·Ì¹QÍB˜N¤~/<ÆY¾š·NNØš®g‡l&—§•sælœŸ-•üy3 Z9ìÒç24º àl(u”2ÅsËsÏw™ñ=θãzªkü®Ù¸õL›³Æœæ—é´ø8çz…ïÖ˜_W1oL € € € €n.пWýPg g¹b ië-WgL)ƒ9gø !v£çCþ¨eL!Âíëy?ý{Þî0ÂÄÑ Üq=u)XÓà7äÿJçý ©èú㊅ïÖ˜_¯˜ï‚@@@@@@_€ÀUøFÔ@@@@@@ˆW±€L € € € € € €á ¸ ߈ € € € € € €± ú  @@@@¢,pýÆmÙ²ýp”Û¡¨ ì?t:j ˜«YÇ(ºD¬£K,ƒèwcTÊé³—øLF0š®Õu ø ¸âN@@@@p  ˜´îú¹[Œ•A†-À:Æ;„utu|¦R‘HfÑŠ-¢?”Ç/PºxîÇ?+X†€ € € € ¶@§65Eܽ|9ëy¶rQÉ™í wŸJ¤Æ_¶dÙùëðH]ëJ­Ý°GîÝ»oÖ²˜+ +ÖÆWÖqÛ_GåÄßÿH½ÊÆš«u4}|GWR„ÇsõÚMùzþoÒ¢QEI‘¸vý¶Lœ¶JôOJÜ p7Ö‘Y € € € € € àqÞÞl,×ÀU\[Qæƒ € € € € €€‡ø¸Šs+M(2Î-)B@˜ðòòûŸí»ŽIÒ$‰bªÚEPŽ?oßq|B@@ˆ{®âÞš2#@ˆ!Ø–»öC=Ð,8# ŸE‚WÎHQ@@p?Wî·fŒ@“@Ž9dË–-©wºUÓ§OKóæÍ¥Y³fÒ¾}ûXEÙºu«í¿N:±Ú/!€ € € àIœqåI«Í\@@79r¤\¿~]&OžlƒH±Y4h¹dÉ’Øì’¾@@@ð8W·äL@pO5kÖÈÚµký?hÐ ÷œ£F@@—ðöfS2—YŒ(äŽï½(¶Àå®"@àÊUV‚q € €„*píÚ5Ñl«€E·îÓ`@@ˆ¬€«ÈÒ¹ÜuwîÜu¹11 È ¸ŠœW!€ €Ä¢ÀìÙ³åÌ™3Áz8p hP‹‚ € € €qC€ÀUÜXGf €ÄYýû÷Û3­B*zÞÕ_|gçÎÄ@@@ð4Wž¶âÌ@p3G`*iÒ¤Ò´iSI“&4nÜXòåËgg2wî\9}ú´›ÍŠá"€ € € €@Hœ<Ç} € àÒeÊ”ý©S§Ž$Ož\V¯^mÿݧOÑs®¶lÙb_§ € € € €€û ¸rÿ5d € €@œhÞ¼y¨ó+]º´è@@@À3¼½ sĵ•g«À¸¶¢Ì@@@@@Häãí!3õœi¸òœµf¦ € € € € €îܹ‹¸ +\†„ € € € € ³w|ïÅl´Ž‘`óÇH±q € € € €@l ¬]¿Gvïÿ;6»Œ‘¾š7xZví=iܹè™2o´x&ÂS8uæ_YüÃÖ_çj¤I\Ò¤™8m•« -Âã©û|iÉ’)M„¯ã@ ¦\Å”,í"€ € € €D›Àš {äç_wK®ì飭MŠœÀµë·DP‘ \>{I&L])eòfŒ\ç\­[•2%r¸ŠVUC¨ ¸Šª ×#€ € € €ĸ@<‰'ÿW@÷oã}ÑAØ¿o> =úψÓŒ^µ£t=G@‘NS¢§!ZAÀ|}9³Ì–!Z†ÀWÑÂH# € € € € € ð¸8³ìqÉG¿®¢ß”@@@@@@"!@à*h\‚ € € € € àþ>Þ ÝÌ8&@à*Ž-(ÓA@@@@pNÀÛ;s©…±&@à*Ö¨é@@@ˆ˜ÀÇ#vµ@@7 påæ Èð@@@@ î Ä‹/îNŽ™!€ MÞ Éœ‹&J—h†À•K,ƒ@@@@@@ȰåcdÔ\÷®»6Œ ˆQ]»vI»víb´GbBàòåËòùçŸËܹsc¢ùPÛ)Ë6‘efž@ÀÝȸr÷dü € €€‡ <|øP¼¼ø¿±¶ìL@<\@MkwžWª–’¹3ÈêÇüE®ÝòµÁ©´ÉËôÕ;eû‘óràô¿2£gmúZUIa‚<9Ò§”ÄÞ %©OBû÷ñK·™ Sù®ßË6£jîº=þíµ±DFšŒ©úòKz˜jZ¹äÍ”JvOxC²¤Mî_oõàfòïµ[ÆR±ÏL)ÙmªLûé/ÿz g•ïÞyYzÔ-+Ÿ/ÿÓÿõ£ç¯È¢÷HñÜéeþoûäÏ#çdùÖ#2æêR,Wú`+¾÷ä?2eåùúÍ:R¿|~þÝF[ç¶ |<}I>hQÑÃï¦ïÉ>ÞäèÄ¥õç¿øãÒj2@@à1 têÔ)ÆGpþüyIŸ>øÈÇtÇ… ’S§‚o)ÓýÒ> € €€ÈêíÇäÆ{RkÀ7²~ïß²dãAÍ~Êš.…_ ‹ôž±Zê•É+^‰"Î#Ð-ë>›GÞ²ÎÔªgýºh_ÞªWJÚü³  éy_-_(îp- à®\bšè$ € ÿ«V­2A¨ÀÀ@;v¬ D:tHFŽ)¹rå2š0a‚ bébïzŠ IDAT¸Ò€U—.]$uêÔÒ¿9{ö¬lÚ´I† "éÓ§¢A\¸pA4ÓjÆŒ²qãFY²dÉÕ73FZ·nm2¯Ú´i# ÍÄ¢ € €Ä@Ú¾òÏ'íC4°Ú:_*¢’Ë lmœÐ&ÌÛ“;U·¿–ÙÊÌúÞ:wʱ”Λɜcºh l͇-ì//ÜÄþü+ÌVïíݨ¬ýõ/zÖµ?¿Eð9Tƒš—·¿Ö¿i9ûóqíªÙŸy-ìµoÖ-%úp,áõ9îfƒš@¸ p÷Æ´€ €8•ÀÞ½{eÒ¤I&uþüy˜²•téÒɆ Ì—­Zµ’N:IáÂ…¥}ûà R¦LiZíܹS4¸•$I¹}û¶y?Y²d&h¥Åßß_¤oß¾&[ËÛÛ[Þ}÷]Iš4©yÿù矗+WÂ?3 <°¢E‹š •ОVѬ«_ýÕï4€åÎ%K–,ÆZÇY½ú£yÜyÌŒ @@@àé ¸zús@@@xøñÇM&T™2eLÆP¶lÙì$íDâÄ~<Ô×{ï½'EŠ<Ú~Äñ + FiÔ½{÷LV•–›7oÊ¥K—$Mš4rìØ1iÚ´©d̘Ñz.\(+W®”† škuÛ?½÷qE3š´[@ìq×ÇõûÓ¾k9uê”y¸kÑÀ¤®çž{N¦Nê®Ãd\ € € €N$@àʉ&ƒ® € €q) ÛøéVwº•Ÿž5õ¸¢ÙWï¿ÿ¾ ni¶“-ëÊvß3Ï<#}úô1A%}hÑk5¨£™Tz–•¾>nÜ89wîœ j 4ÈÞl©R!·8y\œñ} béÃ]‹f˜5nÜX5j$«W¯&óÊ]'šq!€ € àAwÿ‹‘n0L+˜f‰ €ˆ¬[·Îlו Õ;wdéÒ¥2gγ nñ§Á'[¦‘zöèÑ# ëèѣü¦÷R\W P¡BfÛÈÙ³g¸rÝi¤ç € €¸½@`à]·£§ À•§Ì4ãD@8qâ„(P J>>>¢[ôMœ8Q>|(™3g– 2Dé^.r? ^mÞ¼Ùýƈ@@ÿøhÉIŸÂWZT Þ&{ÞÚÝòóÖÃ’)u2¹~+Hú7+'ÙÓ§áóÿ”K×oK •Ùñvƒ2R kÚ†¶ûÒYuÝðPF¶®"}g®‘Ñm«JÊdIdóþÓ²á¿SÒ«aì@" pÅÒ@@!ÀV¬@@@@ðH=Ïuý®rÜÚ&ðu&Õ;w¥Í‹ÁÁ%Û–ÇÏ]•¿ýSƵ«&å ú›Ç”¥[eÑßû¥I…‚aÜô>Íàšºl›ý½:Ïæ‘ÅH–´~éÌ ˆk$>^qÝõÇ£«xĦ)@@@@pm‡ÎH]ëìª.uK™N þêwÑó®KФ>¢ÛjK³§´Ë‘NN\¸á@²gH)¬ëœ¹"¹¬ VâD Å+1Ûv;ÏÌÓwðö&ÔáNsÊlºÓl2@@@@ˆ²ÀÏ[H˪…í××)GtëÀdVö†nù·tÓA´êR¯”l?|V>µ²¨’ûzKB+€õ~‹Š‘¶ÓÖÊÜš±r§|غJ”ûÃ… €"®X € € € € à‘ƒš—1îò…üEZZT-ÆdFº‘:9ÞS$GúA«O»ÖòHcg´™:Î>EôÏ\yà¤3d@@@@ˆ™€ž‰õáwÙ+ÉÒW:Õ~&f•rw¼ øp6R¼›Ó  põ8!ÞG@@@@B $L˜@Bgl„ s„1¯‚@@@@@@§'t÷é5N˱*@à*V9© @@@@@â[ (è^|7I{q$@à*Ž`©@@@@@@ z®¢çÅÕ € € € € € €q$@à*Ž`©@@@@@@ z®¢çÅÕ € € € € € €q$@à*Ž`©@@@@@â^ÀÛÛ+î¡…xHo-Ñ €|X† &uëÖuÉ1Ði-'N4*UªHéÒ¥MÓK–,‘ Ⱦ}û$S¦L®âkBh@7ðÏœFº¶«éò#š6s¥lÞ~HfMéâòca €€³ ¸r¶¡? €8@Ž9ÄÏÏÏޓĉŸüG†D‰9ÁˆèDG@³¬^}õU™?¾¬[·Î<´h椭ôéÓ':Ur- € € gÞœUg¶O£â'ÿêiô–6@@ ^Þyç)_¾|ˆ¶Z´h!û÷ï—·ß~[Z¶liÞ;qâ„4jÔÈ<×LŒtéÒÉ„ dÅŠ¢«—^zIR§»íÄŽ;ä“O>‘={öˆ¯¯¯/^\zôè!)S¦”:uêȽ{÷䫯¾’‚ šº/^,ÇY´h‘$H ^hOèÔ©“üôÓOáfa–*UŠl+O^Œ@@'ð!påd3³î$ŒÙíÜ €ž"ðòË/›¡®^½Ú>dÛó2eʘspfÏž- .”›7oš Ó²eËdëÖ­!ˆN:%o½õ–ìÞ½[jÕª%eË–•õë×Ë›o¾) &” *˜ëW®\i¿Ïö\a­Ñì‹^xAîܹ#çÎ3ïë×¶{Š)BãàÁƒæk ji•>lççœûì3¶t™Y££ € € €€ë ¸rݹ£ç €Ä»€fC¥M›VöîÝkßж}Ÿ¯Ò§O/çÏŸ7[þiDƒT{öì ÑÏüùó›`”n7X©R%û™Uº% ŸŸŸ¹VëÔÀÕ¿ÿþkÎÂÒ׫T©£ñŽ?^8£:Üåf[†›»Œ'ºãÈ—/ŸôéÓ'Ê·éz8qb”¯w× ,(3fÌ0O.Ó§O÷äá3v@@À­ƒî¹Õx<}0w=ÀmÆOàÊm¦’ € {äIš4©½BʹЯ%J$õêÕ=§jëÖ­’+W.)V¬˜ýºF™¬ŒÉ“'Ëš5kLpêÆ!:Ö²eKY¹r¥ nés "èƒÛ·o7VÚNž]rdK/3ç­•7L挩ZyøaøÎ!Ð9ºA/@@@@@çÐ-»¶ ÎÐÑröü59ñºýk ˆD´ýœsŽÈ3{¥ÛÖ¯ýèÜãc'ÎÛƒV*2b@sÏ„aÔ8™+'›ºƒ € € € €Î'ЪYey¶dî0Ó,ÍÈ¢¸†@¿õ%y²$a:ûBÅœoåSH/=@€À•L2CD@@@@˜ ôí^?L%š¥£Y×=§½Ö@ÙV®1õÒÇÛ˵@ïC¸bA € € € € €Q(”ß_^oò(»Š,( 9á%š=W of{ÏtH‚N8QÑè’·wâh\Í¥Î.@àÊÙgˆþ!€ € € € €€ÓhC3tôѯG§é‰ž€-{NXÈ¢ €€ó†tž¹ ' € € € €¸½À¢å›%àì—gKJÐÝ{²dÅV—‡§w¾¶5é񿃤³V¹<…ž¿Væ™<.?€€ ¸b € € € € €@¼ ü°|“œ¸ 2ùÅ[›qÕÐS§âªjê'ãçÏÆSKq×ÌÑCåáǮ⎘šãY€ÀU<ƒÓ € € € €ž.P­NyµmOg`üÄŠÀ·—ÄJ=T‚€³pÆ•³Ìý@@@@@@' º÷D÷q“ó ¸r¾9¡G € € € € €  À »Ñ¸šKY€À•3Ï}C@@@@@ÚõÞ¸qCÞxãhßçê7üùçŸ2qâÄ8ÆùóçeïÞ½qR·3Uº|ùr™6mZ´»ä©k.ÚPÜ€ € €8±+'žº† €®%pèÐ!Y±b…ku:{›mSŸ¿þúëR³fÍëÔ̯ùûûÛÇ^}:VÖœËý5I‡@§¸|å¦T~y¨|ûÅÛR(¿¼õ/.Û?u©ì=pZ>ŸÜY¶/¯¼\VZ5«oc£!@ài Ý{šÍÓv, ¸ö§±AU € €@L4¸¤ÁªE‹Ɉ#LUºM ~x?iÒ$¬zûí·eûöíRªT)9r¤¹.cÆŒæøO>ùÄ~ßãú¡A‹Â… K§NÌ¥üÒYüøôÓOÅ××װ»ßÖ®QfÏž-sçΕ¤I“šç¶¢mkÐàÿûŸ fèV€°Ð@–ÒÔaåÊ•&ÈâX4С[ÜÍ™3Ç;4x¥6´Ò—wt{= òi€ÎÊÕ«WeذafHø±­ üi€¦]»vÆ®eË–¢ÙZ HÕ¨Qà #2Ú¼y³4jÔHzõêeªéرc¸ëL×LöìÙÍû:/ºäÒ¥KM°JçHçA×s–,YL@VçlëÖ­2oÞ<ûXô¾o¾ùƼ·páBùüóÏ¥OŸ>2cÆ yñÅ¥jÕªréÒ%³FtÍiq\‡Žã\µj•\¾|YfÍšeÖÕ;ï¼c\…Wßwß}g¾¯XsîôÂX@¸˜8m™Ì^°N¿ÓXšÔÞÞ õcZ¤E@‡Žžµ_óL±œ2gÚ[aîéðötÙ¸õ $ññ’"³Ê˜!-$c†T‘Ö=ýËÕ²fý.Y`Ï´´èW@º¶«ùD °¹ÒÁq¸…@`Pðÿ)®/@àÊõç € €€ ”/_Þ­´äÌ™SôŒ" üìÛ·O†n^×ê}||¢<Š *˜Áõë×M¦Œfr=®N $iÐJKx÷;6¾k×.)[¶¬ ZiѬ XiÑLž;vH·nÝÌךi£Á ÍÞÑk4óKvš]åX4 R»vmäТ&ÿüóœ>}ÚdÑÌÇ ¢(ƒ8ù…šmd+™3g6kÀVJ”(ažjÀ¯xñâ&ã(&%mÚ´R­Z5SÅãÖ„­mÍôÓ•­´h0ÕvŽ–fƒi°Jš1¦sd+E‹µÏ§®) ÚÚÖÈþýûM¶ Í Ó{µ8®CÇqjf¢­ß &ý¾±9éš ]ŸýXs1Y)Ü‹xž€þ[¶ü×íòF‹ªòÓÊm!WÓf®”ÍÛKS+˜Õ»k=™=üÏ (%L˜@ÞëÙ@þ7¾ƒ8 ]ûΔÙS»Š¦ÔòÉç¿ÈüÿoïÄÒߺ¦FÕâU3œ4(Ö¾çtY³a·¼Ö¸‚Lž¾\¾_²QRúùʈÍ%gö Qš€þÃçɯ¿ï’”)’ÊðþÍåùgó™,ªÜ93˜YÆô)eúÄŽæçª·ÞýBÎ^1ï%Nüó–­Ü¼(Zצm‡¤@ÞÌ2axkÙ¶ó°é ¿¤ÖÏ|>òáÀW£Ô'.B@ >\Ň2m € €€Ç ضùsÐ@UÊ”)M–UdÅð²]£Û®iI—.ÉLùûï¿Mö‹n1§Á¥ÈêÔ-ãl%¼ûëׯo_?Øql[ ¶¢ï½úê«òòË/‡èºnïöÅ_˜ "ÝòO·ŒÓÀ‰ã}¡Çªui†Ñûï¿ïÖë#² äýû÷íc×çêѼ‡FÒl#ÍÖÒ¢ÃÆ›­måqëÌqmjÆ›cѹѢ™O£F’üùóË¿ÿþkæØVÂ뻾§÷ê6…¤ ]ûº=Çq;nQ}õêÕc͹õwƒCbW@ƒ6I“xK—¶5äÅÆ#¬ Ïe+3ÊÛ4Ræ™<òr­ÒÒ{ð\y½‰õËË·H•ò…¤G§:&{*Mêärýfð/odɘJ.^¾!Óg¯–YSºÈ?ÿ“÷Ç|g]_ؼ¿øç-æþgKä–ÚÕJÊžý§ä‹¯~“Å_½k‚Pã¦þ$SÇÿûm+ÿY×”®Öß|t÷žÉ¸ÒÒºyéÿvC1áG™ûíï&pe+«”ºÍG›l­«WoÉÅ+7äËOºÈØO–Z¿Xôèç ½~áÒfûÀµK†HÏVVý‚ßMF˜ë4³«Ö ÁA7  €Î"@þ°³Ìý@@—ÐŒ&Çlšˆ¤AÍ,Z¿~½ýÝN.tÑì™sçΙ—;&.\0Ï5ëE?ä/W®œ9IXQ­3¢ûÛÖLÍÒÀˆ­ßVž{î9³í›í==H³®Š)"gÏž5ç3…j齺=¢n!¨Á-ô(V¬˜9 J·&´½¦õyRÙ°aƒ®ž¦[ñéybŽó®ïé6Œ¶¢A0ÛšÐÀ“fCéCÏË ]¢³&"2×~e˖ͼ­Û=:íï­[·ÌK+V¬[—fëÙÎ5Ó÷Â[Û¡ÛÓl3[ý¨ú믿ì—DTkΪBB IDAT“¾S+ se«¶ÉÑç¥|!rõÚ-+ëj»½ÒgŠç’rÿ:sîŠôîRW¶ZI-:},ÇN>Ê”¶ÝpìäIfe)=[2·T([@®ß¸#—­À‘ €Õ¯]Údb¥N•LŽþYîÍwf˜ §«WÃþ¬“'gFùnV/óÐŒ(-׮ߒϿZ#zÏ»ŽÊ­ÛAöþ/œ]’úúHº´~Ö¿ÅA&Óª°uFWn«žÒVÀ,t9f[ÏÕªÿú8Ùg°4ð¦Å7‰—ty£†¹‚ €€3 qåL³A_žº@x¿åüÔ;E@\F @Ö‡Þæì)=3(²¢çQ;Öœ¤AÍšjÖ¬Yˆ[:wîlÎ'ÒmÜtÛ¾¼yóš÷·lÙ"_~ù¥Ù^PA={ö4¯G¥ÎÈî·5®™2¯½öštèÐÁdqi Ë–uU¥J9zô¨Ÿnû§=£J3d4ëGϬÒ,ÐEÏ<ÒmðôÜ$­KÏaÒÇ€¤oß¾æruÐ綈ËL| :ª¡®]»šàŽn¿¨A+ ÔhPÏÃÒà”ãö‰Õ«W7FºµÞ|`²ïbºÎ"»_ײ®í—nxäÈûåzNšžo¦}×u2dÈóž®ÛñãÇ›3»´äË—ï±Yuºvt J=KÀ¹rå²·Y}¬¹,>nEV+wŽàí½½ƒ³ ÿ°ú´vòú‡A2ú£Erî£LöD‰Bþzf+ ìMûDT;þ}ôoµmŠsXýÕqÍýô-ëgLJdóÏÇí[þzÐR`¨ àÆú÷ïéà߉¤¸+7˜D†€ €Î! Á›)S¦Ø;£ÙIŽåí·ƒÝÖ¢ç]M›6-ÒŽkF•>B—J•*‰>B—ˆêÔó°KD÷;^£ÛÎé#¼Ò¦MÑGèúu ¸Ø2oô—CºwïnŽEƒ!úp§RµjUч–ÐöŽk@ßoÞ¼¹ ô8]G£G—D·bÔà`xÅÑÛö~DkB³´lE3³¿®U«–èÃÖ?í£­8>× ™-èèØÝ0¼íC[8Þ£Ðþýƒ·H ]"ªO¯c͹ÓwcAâN`ýß{厼z¡ba³= —uÔÄO—ÉþCÖ/ß$4K_|ý›´³Î¿ÊhxúÖ J2gH½ýf0+de7õ²²²Þê7SR$÷5çO9“´Ž&õŸ“qÖ¶}ßÍì%]ߨ){}fe¬ß•6¯V±¶#¬øØÁ–(’ÃdT5h9N^¨T$Òë[¸ÕÖ6„m»}jm'˜WÎ_¼âú¦ ž7ªZM?4™`Ú_  €Î,@àÊ™g‡¾!€ € € €ÄH z•b²mÍ£_ ÉæŸVþ]?ÎÔ¹ý·1æÏ!ï¾bocƤNaÚË—;³ý}³]‹Ìñ|>ùÑ/£èÙT¶Ò¹muчc±µ¯A0Ç÷æMôK>¿|7 L?Í}ô IßÏêmÿÛ/ý‚”íEÇkÇœ íXj¾P"F®Üüôf|´^.œ»!ýG> °Þº(]ZÌ“±ÿ{E2fÎŒn‰:¢Ûfx×ùéŸrôàEé=¤º´m8[ÆöŠäΗ>6ª¦prθrò ¢{ € € € € àºÕ÷ÜÏþ–6 fI‹º_ÈS‚Ï<}Ò¢;8–¤É|ä½µ%]†äѪrù»DaZž´Žïæl•>¿··Û¯ë²àË-Ñê‡ãźÍ&‡<_)·ä/”QŠ”È"‡öŸ—ïæn“~ÃjI½WŠË´ñ묀Ö=3’»wï˔ٯʽç$a„2òã²sËI+`tÙ¼ëF¼?þ%I™ÚW»3ÌèOŸ¸"“g6—…3ÉJ+h¦å™²ÙeâŒ&òz‡²V»[­í÷Ò™~ÔiXD¦Ï¹­äœéKÚôÉÍõÇ]”‹ÿ3uhû]Þ©,ÍÛ–‘%ßýco÷ðþ Ò¼æ ó8°çœýõúÍJÈŒo[JÁ¢™å§ïƒ¯¿wï*šI†On בSV_W-Ûc‚WÃ?j >>^aÆ£ï=tAfþÐZ|’$¶Úóî§M «Q‹ÈhN¾<è^ ÿÿû&†Õp»qå“@@@@@@ÀýZ[ûiʱXYQ¾I½Lð(Yro³•àÕ«wÌ%y ¤—”©|%I/É?dÈäg^¼s×ü™5Gjs–U~+ ëıà`–cÉ‘;­µõŸ·u_r9súšykµüÙ±å„<| &Ë*²pâªÔx¹d±2¾4Àuúä«=?+Û*±(J'g®[u÷EK¶œ©å]+sJËÄa«ÍŸ7®ß‘¾Þ&çÎ\—«WnÛÇ ï´WZŸ–;·îš3»ô«lÖ¸ —°Î–Ûv*D÷Ôêê•;ң͓E–:m2󾱚·y6Ò±ð¦û ¸r›I&ãÊm¦’ € € € €DW`×Þ2ýËàØc»ü½å€|³ð+³ä¾ô4'¶«§>(R2‹lþó¨µ%ßQ9rð‚ìÝuÆ…n[A›Ý;O›mø4x•:MR3º„‰}|«A¯ÐåØáK&`µÇª'}Æàc }Ëå‹7åÇovHËÏIõzí—&NœP.]¸i·®†¸_ûößΰ:|à‚dÍž*¸_¡‚o¶›¼¼Yפ6}®eÇæ“֘ɠ1uM *DÿB}:­gsiF•¶§6¡Kæ¬)­ó»’ÉøéM¬L²fòÚÁÁªDN.¸,è2„ pÅ’@ÀAÀ××Wnß¾í–&_}õ•L›6-ÎÆ¶nݺpëÖv+V¬( 64-ZÄzÖ®]ëuFTáÕ«W套^ŠR{ׯ_—æÍ›Û¯4i’4iÒD†¥ûwQDæ»ïqï¯^½Z†îe#GŽ”+V<®ŠHß8p üùçŸ1ªƒ›@@@wXñëٹ먻 ‹ñ„¨U¿°T®žO¦ŒþÍ:Gj‘ìÜzÒd2µêô¼Œìÿ³ü¼h·të÷B”1y¬,¬Q~–›Ö–õ›¬·n)X¼”¿ŒºJ޹d!üñp…òZ}9e½¾2D{V0YP½;|gK•NjÕ/òØ6B_¿pFI•Æ×dIé™Y‘•õ Yç`%‘A=[<Ÿ0—Öz¹°ÉôêüÚW¦¾#/F»?Ü€Î/ÀVÎ?Gô0räÈ!ûöí‹ÇÝ£©ÈÒ¥K¥J•*áHƒU]»v“ÁÞ½{W¦N*zH¼³???{°Pû©FkÖ¬‰•n>Î<¦$ç·¸luFö^LÛå~@@@ >îÞ•#çKRßàƳfNcþœ9ï7Ù0@ôý7ÛV—‚ùüÍk˜óx÷i,œ•/¿Y+¾I¼¥Vµ’R­Ò£ò§~ñ‹œ9wUÎ_¼&ㇾéP†ŽýNîXÛ½i¦È{=Èw‹ÿ–”)“J½K7¤p;e©$¶Þ«ð\iX·Œ¼3d®dóOkîIáç+]ÛÕ”yßomÿÍÃéÞ±¶dÏš.D›šñµpéFóú±“¤‹5¦<¹2…;Îøp§ oïÄÒ¹Weóp,^+)úp,Ízô˰_ýÔÎþÖ¿½iž(2{ÉvíýÜ=ÓÛïiß½¢ýùÐ /ÛŸ¿Ù;ø³œŠÕòšGè:ôëñŸ5 ѯòUóˆ>´}¬ÿ4$а=[EÚÏfÍšG[z|:uŒ‡:jÑì¬6mÚÈwß}gŒôígÇŽM[¶¢æ^^^ÆüÆ¢¯Ñ£G›Ã‡ˈ#ÌX´÷Þ{OòæÍ©ùŽ;DÇ¥~šÙ”'Osýüùó%_¾|òý÷ß›:´^SÍ »víšÉ†kÔ¨‘é–wëÖÍôYû3jÔ(I•*x;€Í›7›:t|%J”˜¾9r$ܹ»té’ôïß_nÞ¼)iÒ¤1sKA@@ž¦À/kvJ¥ç š€Ð⟷ș³Wäì¹+rôøy=¤…ܸyGŒ˜/{7’CV jÔà²áï½²hùk§– iRÿy©\®P˜!¼Õ¾–yí«ïÖË®½'#¢nxúÌe™2ê ëÿà^æºreóK™’¹¥DÑœ2|üBéõf]pz³ÏçҠγrñÒuù _SIžÌÊFùpœ=U6m?dú˜>mŠÛÊ>¥ jí¶ú³Âw“—“„çÇ£Ú>Íé m@B¸bA à )S&©[·®ù?wîÜ‘<\n÷îݲdÉ0Ñíü,X`Z4¡-ï¼óŽüúë¯R°à£}Çž$IéСƒ ÎŒ?>\–yóæÉÊ•Á©æ¥J•’!C†˜`ÊöíÛÍ×ÑúS§N-Ý»w7õè\hq„ æk °eË–MÞzë-ë·ÜîI5äÕW_5}Ö¶mÛ jMë(S¦Œ¹NƒP—½´úàƒL@F·÷‹¨=[]ÄÑ>oܸQÊ•+'ê©6[ÐʱMÝ‚Rbÿýw„Û5jH× nٲŌáÝwß5Ûö.\Øí:wî,‹-ŠÔ|òäÉòþûï› ã—_~i Ô[‹Î¹¾–4ið^ÙØÓ5 1 ZÙWê²xñbѾëú˜2eŠ <ØÜ£Í3f˜ààÛo¿-«V­’š5kš÷Û»>úÈdãi`LdõêÕsõoú € €.$0÷ÛßeÛ?G¥YÃç¥Ü³ùMÏÎ^– e ˜ç9³¥3+ $åÌü‹ˆº{÷žõÚ%9b³†[(Aw%¯•­ÔúÕÊ2}öjY²b«tïPKrd ¾GƒQ3¿þMîÝ {œ’¬YÒJ’ÿJ…æJœ8‘thYMúøFŠÎ.m_«â’SV_¾ü&xKþûV}7­@Z²¤>¦_Zü­ ±3Öú¼õ’L™±B¼½Kï®õìdŽ•ùgJm¾ôõõ–Ûw‚§ M']EÂÐlFŠû0›î3—Œ$–4ÛG3vj×®m2x4 Ë–iKMÄ[57ÿpÛÓ€‘,̼Ö5´_ç˜1¥‡mÛ¶E¸ŠÊ`ÂÛ*°Aƒfë<í‡þ© ÞìÙ³G ôÿ?œß7Ù_¶¢-š)¦™;š ”.]Èmô )Í`zùå—EǯÁ°ˆŠf#é<Û2¦ôžY³fÙÏ¥ ¯=›™Ö©}Ö¾kàê矖úõëG…#ÜkÊ–-k=K–,rîÜ93>=sMƒVZ4p§=ÍlЬh JƒZ4ƒË1˜¦}¶­ôšÏ>ûL4#JM5 e+º4h¥åÅ_´5õk=³L‹fYif¢ Ÿ{î¹çnëÖ­&§%EŠòì³Á§:–½{÷š%î4ªYÍVô¤úï O;cE@<] U³Ê¢Ç’9C*9~ꂳ‚F$Ò’+Gùqùfóüæ­@ëÿ̉$KÆÔ’/w&òî+!î»s]9yú¢ÀúU†÷ofÞ[oedyY¤NmªÊô/W?–½l©¼¢Ö–…HKdýBåÀ{æ> 6µ·[Yþ?褯iØÅË×%mj?9p8@¿TV2ZÙTÃÞk&?.Û$«Öþ+%‹æ4©“‹_òàÿÏi ½Ý{xã|lg¹prWN>CÑë«èyqµh‘n}öú믛,š³gÏšÿÝ­è8‹fÏØŠ~¨íø\È ýƒn```ŒH^xá™4i’ܺuËdi†:kðóÏ?·îÈúl»AƒMº%Þ²eˤU«V&(¢l±û÷ïG:†Çµ§MÝbQ³•~ûí7yóÍà=¦Ÿ¤8èl÷kÆØ“Í|Òí5ø¤Ö¿òÊ£ÿ`%K–Ì^¥f³i&–fÕi±eÅésÇõ ÏmÁ½ˆÞÓí#š;½ßqýD–÷$ãå@@@ º5«•0Ûíý½å õÿ&@•*e2s¦Uß¡_›ìªÎmjHF+À•Û hõ4G¼¬,©¦ ž—Ó—ä/ëì(Í^jjmh+óe‘9 ~—ãÖYR¬ÿ°¾Ž¨Ü 2[j¦”žq•!]JyþÙ|2ú£E&8Õ±Õ‹2úãÅæýTÖ¹Wz5’”)’š@ÙåË7¬>e4A«‰Ó–Yçi]5¶þo74çb é2Ⱥá3º~\ €@\ ¸ŠK]êvi9rˆ><±¬[·Îd+iÀA3‰ô<*ÍpÒ³©4@¡A 6ÙŠfæh†PtŠjÊ—/o‚WšÁ£™Lš•2eJY»v­9ÏJ‹f=.ãMƒh<Ò@“Â4£Hƒ5zf”fizæ“fb9m÷Ç´g]-_¾\žþÑ:7ÍRÒœÚ48–mÎÓ-5C)"ó?þøÃÑVá5¬÷ëYaZ~ÿýwch+º}£ÍR3¡43ÎVÖ¯_oßRPÏK³e¶E4wzž—^§ç”é9Wš¥÷8õ‹(À-4.~¬€®SÝvÒÓŠž«GA@@@|“xË„a­Â`´x¥‚èñ´yµŠ´q|á™<ÒÀ:+tÉl¿fMéæu Hi™4¢µý½$>Þ2qø£¯õBùýeöÔ·ì×L¢Uëõ2À N9Ýб¤ðó•Ô©ý¢¶mk_p}ÞzÙ\Þ8YÎ-0 û"y³weÉž+éèØ÷ƒÿ£ŸÑhðµ[¿ªrçö]™6n$L”@îÝ—~ÃjIb+sбè}î?4ŸùdΚR^y½”|ôá4º®¹lÁ—[¤@‘ŒR²L¶Ç‚,ÿq—üñÛAI›>¹Ü¼(í»Wæm—[7ƒdï®3R°h&)ZR¸ dç–“ÒdmSçŸk™?ËW >ÖÀV޾(㇮’\ùÒ™újÕ/,e+äzl?¸ÜO€À•ûÍ)#B Æ2d0g(éÖ}º­›n§3zö“fòèvuzž“- G·²Ó@”f8iÀ¤B…?ä;žq¥A%ýZKÆ ͹Gz¾–­hžé4uêTsF•ný§Ùo‘­C¯Ñ³“4Øò×_™þhÐG·~Ü´i“Ù /tÑ €š5kf®×qè£S4£÷ëö’qQ4clèСöóºôì1oooÓ׈Ìuk@=GLÕ[·eTÇÐ¥]»v&˜7{öl“y¥Á1[Ñ€œžË¥gRéö~ŽA? 0éúЭ5 ¥×j‰hît›@ÍbÔ3³üüü¤D‰qAE € € €€› <Ú)%¢~Я©›xæð.œ»!)Sûš Qö\ŽèÞïñMê-ßÍÙ*»¶[¿ìzòª¼X§ ”©3R¨žª™û&X-·­ SÖ©äßm'%wþôòß?Ò¼mÈ-þoß ’Ç.KþBÃÔÛðÕ’R¦|NÙóo€¬^¶Gºõ þŒ`dÿåÒ÷ƒZ湸®_»#›ÿß·Õ^¶NDs§´ˆÖG¸ ¼ˆ € € FàãQo â¡ë= /7).‹æïWàæÍ@I•&©Ý“m›ŽË3e³…É´ }£f\ݹ}O’$õ2uO·ÖÊ´Êd²œBÍ~ÒÀXx+Ûµ˜JöQ¶_è:·()ógm‘⥳FiïÜ“ä)Bs¥¹ÜB€À•[L#ƒ@ vÏ7ŠÝšŸNm}ûö³†—.]*uëÖ5[:CÑí5PdÛ^Q³Îô,/ÝΑ‚ € € €®' A£ÍKXÙV©åð ’ÛÚJOË”1¿™mùü¬OÎ([<¦.ÛV§®Êçÿ!½Ÿ¥¶<”z‹Ê;¾—$¾^’1sŠp¯rÜ*pØ»ËdäÇ!Ïu‹ºWz¢@PÐ]O¶[Ž9¡[ŽŠA!€À èVpµk–I‰XÀßßßli¨=ÊYŠþ€©µiÓ¦™€šnѧÛR@@@Oè5hŽÜ¼h†:râ²iÛÁ( û›…ÈÚ?þ³_;mæJÙµ÷D”î }‘Þ7ýËÕÖYÅ÷Eû×eÖ¼µ²íŸ#1n¦GÿY1®ƒ b_`ÚƒÒsÀ‹Òd>¹Úw>L#ÉSøˆnçgÛAÇÇÇËÊjJcqöIåêùdíÊ}fjIœ8¡´JðÄH–ÜúåÙëÁß{/ïÄ¢°#ØòÐñ>퇩£ €€Ç qåqS΀@@@@÷X¸t£äÊžAÊ–ÊëÞetn/°o×Ykû¾Òöqæ/œAöí>c¾Ö-ÿ&H`mu_Þê[U~^´K¶o:aηʔ%…äʼ¥`xEƒHÏUÌ%ëVî·2´ Eê˜.ƒ_¸ÙVz“n¸nÕ8kÖúQ?#ª°\åܲü‡#lO· ¼xþ¦É.Ó­Cg€¹ý„3@0®X € € € €n#ðïžã²kÏ y¿o3¦€³—e씥’8QB©ð\I•2™œ ¸$¯7©hþü|îÉŸ's¸ã¿ÿ ñ•’È<>è×T>žñ³Ü»û@Y*'O]”ášY÷&#çKR_SOÖÌiBÔ7sÞoràP€•Ýò@÷i,ÿí;%lÚ''¬ûÛµ¨*Å‹ä0×:rF¦ÏþU‚îÞ“ e HÓÏË;CæJÎlé%ÐÚ+a‚„Ò»k=9|윌›²DÒ§K!ç/\“Eƒï×réò :ö;Iž,‰ Þ5oTNÆ|¼DF|Õ¼ÿVß/dêØöò׿ýòõ÷¬3›K¿îõ­l‘‰Ó–É¥+7ÄÏ:¨_n³&\y ¡Ïz½Ãsf8}?ò‡x0 IDATÈfXu³¶ ,épû~PËþ~ƒæ%íÏ É$úˆNÑm õ^8ª®ýåÐ×hæXxEϾš2'xR@À³\yöü3z@@@@À­>šþ³¼P¡°}L˜êõf]Éž5¼Ùçs™2ª­ÙÂOW+Ö씺5ž‘ƒ‡ÏÈŒ¹¿Ê?m4÷=~^*—/$‰¬`ט÷_7¯½=p¶•Ù¼õÚ3ÅsÊ ‹È—߬•»ŽË™sW¤Òó¥aÝ2²øç-ræì{ûgÏ_µRgeÔà²áï½²hù(;{îªLÙ&„}ž\™dìÐ×Í–o=úiW/]—÷ß}ERø%5¯éÖnÚî w‹¿ <êÛu=qÞ¬zu©g^¿výÑ9FŽÎ^ð»LÓÎŒÑvÝV-uªdÒý½Y¦ ÖQ\WàÔñ+²üÇGÙM…‹g– /8Oâ’ïvÊÙÓ×ìÀ­:=oο¢ €®X € €@< \»vM:tè ß~òÃ…¸lþüùórñâE)X°`\6gu/_¾\Ž=*]»v³6œ½â§±nÔdÆ R±bE§å騱£Œ;VR§N-ëׯ—J•*9m_é €@ü L³²‰4KêÈñs&€sêÌe+гÎtB3¨ïJ®LÖÒÎ]GMÆ“®:¶zQªþÀKϸÒrîÂU™gåme%ivÔÝÿ?È?sj󾯯·Ü¾d²º4CJKÎléB®NŸ¹dõå¼ ·ÐdM嵂SZ³¤lBÿì>&¿þ¾Kô|ŸÛ·ƒÌË)ü|MÐJ‹µ½[ <Ó¬* ZiÉaµçXJÏ%‡žµ‚\³¤YƒrV6Wöïkf•ok;9[Ð*¸¤&h¥%‰Õ¾¶Cà*Ë}áŸ=•tìé¼?#ÕoZÂåLé°ó x[?RÜG øW*( € €n)°fÍÙ»w¯[ŽAÅÀ¡C‡dÅŠq×@,ÔsÉH@@ nܸ!úÁùéÓ§­ÿðß—¦M›J½zÁÛ±´iÓÆdªœ:uJΞ=+U«V•æÍ›Û{wåÊiÛ¶­üðÃöÆ_yå™>}º¤Kü³še3pà@É;·lܸQ,É’%3&æ¾Þ½{K¡BÁ)¯[·NfÏžm>DÑóõÞË—/Ë÷ß/ÞÞÞòûï¿ËСC%EŠö~>|ØÔ§cñòò’Ï?ÿ\dôèÑòàÁë·|oK¿~ýL¶ÖøñãÍ5š½¥×ûùù™úBhÓ®];c1wî\iذ¡1Ñúô>í·fN•.]ZNž<n;Ú®¶µgÏëC¥äâïï¢Ïñ8ÅqÖÔgŸ}&ÿý·©¿xñâÒ½{wë·ÅʺÑ:CÏAݺu#\«á­___ùè£DƒWºÆZ·n-%K>:ÿàúõë2jÔ(9~ü¸µ…Ð=9r¤õ[á>áÎçÚµkeõêÕÖok'¶¶V ]ûÆ ³¯sí¯z¤L™Ò|œ9sF^zé%³nsæÌ)?þø£Y“… –]»v™>;wNæÌ™#ݺu“iÓ¦™ F[_Ë—//Mš41ß_ýµ1Κ5« 0Àô‚ à9ÙüÓZPùeÁ¢¿L&Õ蛬©T)“š V^+tÈÊJjݼr¤(ežÉ#ºõ`k›A¯H¶Í«Y­„ úpü½å õsZÑ`•­d´‚Z¹­ /ÝžPëÐíÿ"*%‹æ”~ÃæIÆô)Í#¢òÆkUdØø…’6Ÿ F9–]{OÈl+ÃLN{®t^“½•>­ŸL˜ö“É Ò3¾´´lZÑdeiÖÕ»Ýê{Îâ`¤ €N/ÀÿÞœ~Šè  €8£€™Š)"Ç—;wîHçÎMIMš¢ÝÊL?ØoРh`ÊVR¥J%ùóç—-[¶HÙ²eMFTÆŒC|˜¯×nÞ¼Y5j$½zõ þp¡eK4h $iP¬gÏž2oÞ<¹zõªL:U¾øâ PZ¹r¥Œ7N>üðC©Q£†dÈÁ‘B †½÷Þ{R¬X1óÛÈúáF–,YLÐB)[·n5õk A‹¶c{®­íÛ·K©R¥BT«û÷ï7÷i™ÐöuŽuìï5oXÞþ\3œB[Ú¼ZEBžf%òü³ùÂ\ß­C­0¯}<ê ûkã>hižçΙQ¦Z["†WŠÌ&¶ëlï÷ëÑ Ì¥UÊ}ØJxíÄl6¸@' põdnÜ… €. ¨kFK’$I¤J•*æCv \i±t4ËC3 4裢ۊf•èVlú!½š4+&tI›6­T«Vͼ¬÷k@ÂvV•Æ4 uìØ19qâ„iOZ4Xeû?¢iÒú4JƒVZl™SšE£A' Dhf•f]ÙŠfJÙJæÌ™EÏÏ ¯8é4[LYšA£E=´Þ .„ÛÎÎ;ícÖÀŽfÐDÔŽ+.Aõ¨^½º=Ó®víÚ&È¢«ØZ7ZãD´VÓ§Oíu£uk@SƒB¶¢kG×aDë¦D‰öµ¯ëF3ýKÑ¢Eeß¾}fÍiàªGfýjðLƒ^ºFÿüóO)W®\˜ UxkàŸþ1™¶€¯®aÇLCW\7ô@ v6n=(³¾Y+m“m»­R €DU€ÀUT¥¸@pЬ*Ç¢º; fEö¾d4CEƒQëׯ—öíÃþƬn h+š¹Q Ý—¨LTèþÚîÑ 'ÝN3Âþý÷_“Åe+º\TŠnñg+ÚŽfviÀ±èvŠáµcËü²]ënۻ鶒‘•ØX7Z¿ãD´VŸdÝhÝš ºD¶n7&ã ¦jpVvìØ!9rä0[]†Sd†º†Ê”)#ï¿ÿ~T–+× €x €nŸ§  €8§@Bçì½B@œ[@³?~þùgÓIý°]·6 ½m^d#Ðë+UªdÎêÉ—/Ÿ9¿*²¢ÙW8Ò -zžŸ¥ìk»šU£çbiY³fýL"½GÏ ]t»B탭>[@EÏ Ê–-›¹\Ï'ŠiÑŒ2=ƒÈV4ÓKKDíè™O¶v5ñ×_Å´ Nu¿®›_~ùÅüÑl; ²DµDwÝh½­ÕÈÖžsQ¦›fÞÙÖ¾Ö¯k'¦ëF׉nŸ¨ßZ*T¨`Î|{î¹ç¢D£ßƒÖ¢Z›6m2ß#¶þݼy3Jõp € €¸®@PPÄ¿ð麣òÌž“qå™óΨ@@ †]ºt‘‘#GJ»víÌ6{ºÕ› ¢S^~ùeÑÌ#ÛYA»WÛÓ,%;Ò3¨†j²QtË7Ý^­{÷î&¸¥[öïßßT§ÛÒõíÛWt ¾>ø Ä9ZúõøñãÍÙJZfΜiÎêСƒh LƒGŽy\·"}_ÏþÒ6ô|.-j¤™0µS³fM“i£Û0jð$W®\1jßÙnÖ³¾tëDw+Í.²mÕ¾FwÝD¶V#Z7z†•®-'Ít ékºõ<2 Z 80ÆëFë׳¸t­jÑ­7§M›&ï¾ûn”Xš6mjÖm­Zµ¤U«V2`À{]ÚG­W·,¤ € € €€û Ü ¹3ŠûŽÔýG–àÀ!÷µ‰â˜õà wÛº%ŠCç2@\Và?þnݺ™­é<­è‡íš)¢g)AAAÒ¨Q#Y´hQˆó¯œ¥îØÍâÑ3š>ûì³(O¯×u³eË–(ß—²nâR7âºc{è9ozÆÙêÕ«£tv×Ó5­"€@l \¼xÑüòIt‹×IžèDto3×'òÍ! ¼3>ѽ±uÓåË—Möߕ\Ù3ĨÚaãJ`Ð]9ðÕÕã,7ß½{OÆOýIú¿Ý0Z]êÑ–|<*ø¬Õ§UþÚ¼_Þ8[6®ùD]hÝmªä+™V^mõÌó'jˆ›ð!o/‘J¥ŠH×v5=dÄa‡¹hùf™6s¥¬ü~ ÇØ>ú£Årå–ŸL˜8é©[Ü»þµß|ðîÑ)l-®E@bQ@·[Ó //¯X¬•ªÜ]€uãî3Ìø@|¸iy̘1O¡4銬Wœ5úŒ àŽÏøY’%M"ÿì>&ÃÞk*¦-³¶Ï} ~É}彞 äÈñs2nÊRIŸ.…uc4oT^._¹)×oÜ–†uËÈ®½'ä÷?÷H§Ö/Ê»C¿–qC_—#æªreòÉóÏæ“±Öý‰%” Ï0÷¬Zû,Y±UÒ¤N.ç.ŸkJA@À]\¹ëÌ2.@pZÌ™3›ó{(DG€u-®E@ nRXAª>l+?¯Þ.%‹æ”Æ/••IŸ.“=ûOÉ7?ü!{7ÿÌidЇ ¢Ô‘VlÉWïZg`zÉðñ ¥×›u%{ÖtòfŸÏ¥AgåÛÅËô ÌÙªõ_¥:¹@W påª3G¿@@@@žŠ@ñ"ÙM»'N]”ÝûNÊ®='äÒ•ríúm“]¥A+-9³§Ó¿‡Ã7_0_´ÒrêÌeùò›uæ¹frݼyG’Xï%L|âGö¬iŸÊ˜ipfÛß¡ÎÜGúuWQ·âJ@@@@¬ R£Íʊʘ!•ÉŠ²•e«¶ÉÙsWÌë'O_”RÅs‰o/k‹¿«æ’Ó—ÃÚ‚Rú†¦ÔÒ¾e5Ébýi+Aw4ãêT8÷3% €€§ x[çRÜG€ÙtŸ¹d$ € € € €ñ(P£J1fmí·áï½&¨Ô¯g}iûZUóZ†t)åÒå¦7%‹å”…?m2çSݾ$)ü|#ìeÇV/Êè‹~›*eRЫ‘4®WVzøÒú:™$Ož$GHS €Ä¿«ø7§E@@@@èÑ±Ž½ç‰'’aï5 1’´©ýdêØöæµYóÖš?“úúÈ„a­ÂŒxÒˆÖæ5ÛŸúï*¼­Ø BÀ™·¸ìÛ=ìV—ÎÔWú5ÄQ»Œ«@@@@@˜ ´íö©lÝy8æQC¼Ôn6*^Ú¡‘˜ h ±k»šOT‰;}O¶ï9ý‰ œé¦Ò%rË—Ÿtq¦.Å{_\Å;9 "€ € € € à¹÷ˆTx¾ŒT,WÖs9±(0ÿûcTÛýû"¯5m(Ù³úǨn޹À†¿6ÉíÛc^‘‹×@àÊÅ'î#€ € € € àjéÒ¦‘‚ùóºZ·é/N)4i’÷KƒV|OƘ1ÆìÝPNœ$pÅW1^JT€ € € € € € ®bC‘:@@@@@@b,@à*Æ„T€ € € € € € ®bC‘:@@@@@@b,@à*Æ„T€ € € € € € ‰c£ê@@×H˜0¡Ü¾}[¦OŸî¦— àgΜ‘û÷ïówG,ÎÆ­[·Lm $ˆÅZ© @@p}W®?‡Œ@( ¤H‘BÊ”)#;vìˆò=\ˆ\¿~]>|Èß±¼ôïcooïX®•ê@@@× påÚóGï@ˆ–@‘"EdîܹѺ‡‹@­[·J›6møûƒ¥€ € € çœqçÄ4€ € € € € € WQQâ@@@@@@8 pçÄ4€ € € € € € WQQâ@@@@@@8 pçÄ4€ € € € € € WQQâ@@@@@@8 pçÄ4€ € € ðíÝx”ÅÖÀñ“JBÀÐKB¤÷" ‚ Wé(TŠ H"~T”¢BAAPE)Ò{‰EJhJo!B•@òíîÆMƒdC’-ÿ¹Ï>!»ï;sæ7/\ÈñÌ € € €$G€ÄUr”¸@@@@@@ ÍH\¥91 € € € € € €$G€ÄUr”¸@@@@@@ ÍH\¥91 € € € € € €$G€ÄUr”¸@@@@@ Ißwí–ŽÝûšÏ ýHV¬^+`û¼Îœ=WÆMšê°±ºc`$®ÜqÕ™3 € € € €8½À'ã&Êì¹ $&&F>ô¥|2n’ܾ¬y8ô—tèÖGnÜøÇ\?ç§Ÿedð„dÝ›ØEü¨Ùn<7¹qá’òÞðÑf¶KW®•>ÿ}W".E&kö<¯Ébr™‹2¹ÌL˜ € €ÜK—.ÉÀ¥AƒòÄOÄéóĉ2þ|‰ŒŒ4×Ð@@2^`Áâårüä)òß¾âåå)?Î_$ë6m‘ìÙ²I§v/ÉÌï’2¥KJëV-düäi–k¼¤AýG“ |Õº2Ñ2‰ŽŽ–¦O7ÆÿyÒTQÌŸOöí? ¹ræ”~½ºŠ?™>>âo©’ùDzÅ`ø…‹’%‹”+óx[úЭàl[b±X?oüÔR,(0㉠]._¹"… ÉŽßwI£†O$ú¼)\PŠ–M[BdÿÁÃò³Í,Iר?¯… ¬Y²H@@.9Îóš®+ºÁH\¥Î»@@pIöíÛ˼yó$444ÁüÚ¶m+eË–uÉy3)@@ÀÙªV® Ë•‘QŸ~nTºŸ‡‡‡ôêÚQòåÍ#.F˜J¬*•ÊËŠ5¤FµJ’)Ó¿?Ο7¯I>é¶‚ÚôÌ¡%+Î\L IDATÖHß]älX˜Ì²l3hm^ÿ«z±~ð@.sF–&ô¿%‹õOÎÄr¶Gí¾Ä[ _>éÙõ5ôþHY¹v½”y¨d‚çUÒ*Áy?/³<ù-I¬"¢g\iKÉóª¿lÏë}YÂté„­Ó…™A@@p>áÇ'º`Á‚¦‹† € à8eK—2ÛÿMŸù½.X@Z6}Z>7ɲ­Ú`ùÕrŽ”¶ºµjˆnÓV»Æ¿Û&6ƒ~¾RÖrÖÄ)_ÉÉ“§%“å<¬¤šnÁ¦Û¸›4U.F\JpYP‘‰Æâ8rD’úŒ=ÿLSsÎU¶¬Y}F¶<§×o\·<×Õï"ÏkF¬`ÚéqðàÁ{† ˆ“™·§îA@@Ç6l˜Ì˜1#6H=ßJϹ¢!€¤L <<\nݺ•²›,WûdŽ_¯ã)¾OoðÊ$Þùìº÷~ÝtñâE©]»¶,œÕ_ŠæMU·C?™kÙ²,J† jª~¸9õ[~; ½}-[W ³«³¶Ý'IP`IóÃjZú\¹rU.]!îÞ'#Þ˜>ƒ2Jº |OÖ \„À=¶nÛ)‡þ:rO'?ßìâ›=›äÉèµ÷úüžpÝT\ÙMÇ € € € € €@Æ Ü¾}[FO2•”3ç¤HÁr3*JNŸ='Ï·l*ùòæ‘y?/• #$âR¤tëÔNþ>r\ÖoúEòZ> —g›7’¬Y³Êô™³%k–¬R ^iÖ¨¡™ØÉSgdç®Ý¢ãÔ¬Ž\²ô±ÖR¡âi©­R©¼Ô®Y= þ#u,ýd‘U+Éì¹ ¤N­êX¸lüe«dÊ”Il?ÏX5FÏH‰S¾²R¾ìCòPÉåÁâEåëYsä…g›Kî€dÁâåràÐ_RêÁâæòrå4_³eËj¾>Qï™»`‰\Œˆ®¯µ‹íÓúyŠ'Ê v °U Ýt܈ € € € €d¼@®ÿý^HºUŸ6oïÌ–j”(S)õó’2Ñ2 ÝH¢¢nšÏ­×éôµK˜Ÿ/On7iªìÚ³/ÉIiu‹&­´ä²üÿR¢×¶T~µT€i’B“ Ö¤UÆkAF d³T÷iÒJ[nË3¤Ï“——§yVµm Ù&?.Xl©Ô ‘(KÂJ[Îÿ=׿ìGËKŸÃ&O5)ÓgÉÒ•k“œRDd¤IZi+dI¨ž±T"&Õ*–+c*½´R–±$®2ÖŸÑ@@@@@T ˜æ'Ñ´ÚI·ékÙôi)T$öªsaçͯOœ<[yRï‘ÚòFçWeùªu z‹Šº“@ðôð”èèhóëóá, °;U+‰µ²eJ™ -ÝÚ†€U F,åUI´kׯ›­µ°FµÊ6Ïk¸ùµ>‡Q–­0µéö˜o¼þªå>e©¾º{­>£Ökr[* õ95Ïú©ÓfëÌ¤Úæ_ý=°~Ó¯–$Úgœ–1l˜1 € € € €¤¹€žñ³|õ:9fI8ÅX¶î+hÆKÖ_Ǿt ÁÜÒÑr.–mÓj«ÄZP`aøV¯Ø¬¿?î¶þ9rHÏ.Í%U*•7/ZÆ ¸Ê8{FF@@@@\R`ÍúM²ý÷?¥Sû—\r~L*}î¶½`úFÂhé!@â*=”@@@@pr–³§ô•–í‰zˆ¾h¤VÀ¶ª0µ}q¿sx:G˜D‰ € € € € € àê$®\}…™ € € € € € à$$®œd¡@@@@@puW®¾ÂÌ@@ C¾ùæ©^½ºŒ;6CÆÏèA×­[gæß·oßT‡Ò»woÓ׆ RÝ € € €8¶@&Çè@@pLš5kJttt¢ÁM:Õ1ƒvá¨Ö¯_/—.]’æÍ›»ð,™ € € àú$®\™! € €@”*UÊ$®4YröìYÉ‘#‡äϟߌ”%K–Ø==Ýs“4PO¼K]‡?þX *Dâ*ÝÔ@@H÷üWtÚXÒ+ € €€ Ìœ9S¾ýö[éܹ³™u:uÌ÷ú*]ºt¬Ä?ÿü#o¾ù¦Ô­[Wž}öYÑÊ k»}û¶Lš4Iž~úiyä‘GäÕW_•íÛ·'ªøÅ_˜íòô«µuïÞݼ·mÛ6óÖ‘#GDß«_¿¾yil;wî4ŸEDDÈÀ¥^½zòä“Oš-üN:Û—Ž¯Ÿëõ-Z´®]»šÏ¾þúkiÖ¬™™Ÿ~ÕQLLL‚5×§OsÝsÏ='GMpMhh¨‰I-š4i"£F’7nÜ3öø©a5L<ÇŽ3¾š<ܱc‡ñhÓ¦¹EZê¥ci\/¿ürëvŽsæÌ‘.]ºÄ^³ÿ~7z’™* € € àX$®k=ˆ@@ÀÅæÎ+‡–‚ š$ËàÁƒ%22ÒÌrÆŒòå—_Jž>>&!gÛ®^½*=zô½{÷J£F$((HfÏž-ÁÁÁæ²»ÅníGÇ=pà€™KÖ¬Ye̘1âíí-™2ÝÙH" À8jÒÎ꫉«Ë—/KÉ’%Í:ôë×O~ûí·8±i2Î××W*T¨`ú2dˆ]þÜ„ € € z¶ L½!= € € ¤@L‚F“+­Zµ2I)M T©RÅ$®t+Á¡C‡šDŒn5øé§ŸÊêÕ«¥}ûö)V½pႹG“LZÝ¥Õ^ºm¡&°~ýõWÑí û÷ïo®¹v횬]»V´ ª|ùòæ=½ÿñÇ—·ß~[råÊ%kÖ¬1ïçË—OÞxã ŸµBÊ68­lZ²d‰yKVÚß”)Sdòäɱ—-Z´ÈT}©A‡ÌûúUï0`€;±ØmÇÑùh²McФՃ>h>V+Mši2Ì6é¤UÚ4y¥Up?þø£Œ9RfÍše*¶¬­V­Z2zôhS¡Õ²eK³>êb[9—âÅà@@@»¨¸²‹›@@Hž€&¨´I+‘ÍMZtñâEs>–&KžþyiÚ´©IZi;sæLò:w•nѧmĈf»>ë¶„ÿý·y_«‰t}iÒJÛéÓ§ãôÒ»woS¦‰6Mèè¹QºuÞ3ÏÝ$—49¤[æÌ™Ó$„l›V’=õÔS¦"«k×®&Q¶oß¾8×4kÖÌTnéYTšTÓ85ñ¦_?úè#Wb±Ûv¢[-ê<4fMBie™&Õô>­ðÒ- µ¢,sæÌ挭6mÚÈøñãccÚ³gé®]»vqbÓ8t»FM„éö‚šØÒóÀh € € €é/@ÅUú›3" € €Fॗ^’7ß|ÓT2­\¹Ò$­ôL,­ÆŠß*W®,ݺu3Ÿi‚¦N:Ò«W¯8—i5•V<-X°ÀT0½òÊ+ÒªU+sžO¥¿Öª¤¥K—š¤Õ“O>yו 2[êyXº=Ÿ&°Fe΂Šß4ôðÛ 2MpM˜0ÁTRY›n8qâD³Mà¡C‡LòIÏ·Òyh»[ìñÇ)mwÜh|-I«Ò% º½‰+·@@@@_ ý‹‰¾hÎ/ðß7[8ÿ$˜.$ÀïIZL™ g\¹ÈB2 @@ÜI U«Vòûï¿»Ó”™+ € € à$®Üb™™$ € €@Z Ô¯__:”¬aΟ?/7–›7o&ëú¤. •#GŽÄ~|¿úMUPxóš5käÆ!!€ € €Ä qÅ3 € €@: <ðÀòî»ïŠ··wªFþæ›oâ$®îW¿© ÊÁn¾uë–Œ=:ÕIB›á € € €.+ÀW.»´L @@ £–/_.ëÖ­“lÙ²ÉéÓ§åÒ¥K$C† ‘,Y²È… ¤gÏžbBŒˆˆQ£FÉ®]»ÄËËKš4i"¯½öšxxx˜ÏW­Z%Ó¦M“³gÏšû‡ &7n” 6ÈŸþ)_|ñ…tîÜY*T¨ ß±cÇÊöíÛ%::Z*Uª$ýúõ“€€Ó¯n·÷òË/ËÚµkMEÒµkפK—.R¯^½Déöïß/ÚŸV–ÅÄÄH§N¤uë֢ɡéÓ§ËÒ¥KM? 4ã”)SÆô3|øpñ÷÷—ÇKdd¤™ÿ믿.ÇŽ“­[·š{òçÏ/C‡•ìÙ³‹VŽéü[¶l)Û¶m3÷äÉ“G `¾Æo·oß–/¿üR–-[&QQQR¹reyûí·ÅÇÇGú÷ïoÜÔGmƒƒƒÍX«W¯–)S¦È•+W¤@2pà@)V¬XF=2Œ‹ € € ð?W<  € €¤ÀÊ•+åóÏ?—êÕ«›¤Qß¾}eΜ9Ò®]»£iõU`` ÌŸ?_®^½*]»v•|ùòI³fÍÌ9N#GŽ”O?ýTÊ–-k’\š¼ÒäŒ~Ö¶m[Ñm µiÂǶ <Ø$ÌæÍ›'žžž&Q£‰MòX“bš`ÓŠ$MòìÞ½[Þxã Ñ­õ4ÉcÛ._¾,=zôÞ½{K£FL“uû½™3gš”&×ræÌ)¿üò‹¹VçkM’-Y²DfÍše>×kuM^i,šëÓ§Ì;7ÖçäÉ“&æÏ>ûÌ„1yòdS¥6qâÄ~ÖñµM“…~ø¡IPéõ:·‡~ØÌ=GŽæÞ={öS}¯hÑ¢²hÑ"“hûþûï%sæÌið4Ð% € € €@rØ*0¹R\‡ € €@ J”(a’VÚ4S¥J•8ÛúY»ÒdÓ–-[¤[·næ:???iÞ¼¹IiÓ¤ŠViÒJ›&~4qu¯f*º4”)S&Ó·VH=zT´rÊÚ´oMZi+_¾¼©žÒ{ã·Í›7K‘"EL5˜ö¥1h,Ú~þùgSÑdý¾N:&±¦•gÖ¦U\ÖÏ+V¬hÞÖyjÓ$šV‹il¶Mc³6MÐé|4q¿-\¸ÐThùúúšØ^yåSQ•T[°`I jÒJ[Ó¦MMU\rÏ(»—=Ÿ#€ € €Ø/@Å•ýv܉ € €@’zÞ”mÓäŒV)ÅogΜ1‰MÌX›nw§ÛíiÓmî¬[[·(ŒŸäÒ**­äÒ>K—.mºKnœzn±—XÓ±´_Û¦Ûïéܬ-W®\±¿¶V{Y+ ôM·ü³mšÄ³6­¤Êš5«„‡‡Ç&À¬Ÿé8}ô‘IÐY›VNiõš5)gÛ¯^¯É»õë×Ǿ­ã_¼x1%Ä\‹ € € $®Ò•.@@°&gî%‘7o^“p™={v¢ÛÔé™Nš4JiÓ~µ:I·ó³VhibHû²M2%7Îܹs›3µkÖ­Lz&‡ªV­{yrDZíÿܹs±É2M*]¿~=vëAÛët>z~˜VyÅoñ“aú¹ÚhÕ—V Ñ@@@K€­k=ˆ@@ÀÍ4‰R£F 7n\ì™QW®\­bÒÖ¸qcsö•u»k×®Ån—§ÕRLTL«£t«Â &˜J&=GJÏ *T¨”*U*ÅÊuëÖ5[®ZµÊÜ«[ jbI›né§çEEFFšïõ «;wJƒ R<Ží Ó§O7±ëKçQ³fÍÕVz}‹-ÌÙWÖ3¾´bMcÕ¦•TþþþrøðáØ®õz=O+444ö½¤S5nF@@H±W)&ã@@î¯Àûï¿o3Ï=÷œÙN0{öìÒ³gOÑíö4ùÔ§O8p IXéÖw|ð©.êСƒ ùäs{ÇŽåÅ_”víÚ‰&‹^zé%ó¾&ÍÆŒc*›ìmz^U`` 9¯Jbºµ¡:%ÖÚ´icÞîÒ¥‹\¾|ÙT°éVzn˜¶^½zÉ AƒÌVƒ:=OK=?üðC“|Ó¤ž&ó>ûì3{Ãå>@@@û$àaù/ cìé+ ÎòöôÁ= € €®'bÛ¶ms½Éݧie&“´2‰–PŸ´*¶oßn}¶Ugi?*# àÞzFŸV«¦´ùdŽ_¯ã)½Í\ï•5H<¼ãžAhWG©¸I·z­]»¶,œÕ_ŠÚÿ4¤"nE@À­FŽ[ ×üdt𘠟÷­Ë»D¢o¤8Ž”ÿ§–)‚@@@ÀVàÔ©S‰ny‡Ò¿ZEC@@p?¶ t¿5gÆ € €( ÛêVÝ»wÏÀ(@@@Ç qå˜ëBT € €.*ðã?ºèÌîß´rçÎ-ºå$ @@@ÀýØ*ÐýÖœ#€ € € € € €)@Å•C. A!€ € € €Î/àéyç¿™ÞzB·_vþ 1@\àlX„øøæpð(ï‰+§^>‚G@@@W ::Ú7àƒï7H"C@ÀÅš4 rꑸrêå#x@@@@ÀqråÊ%¡¡¡Ž ‘!€ €€Ã pÆ•Ã- !€ € € € € €î)@âÊ=×Y#€ € € € € €'@âÊá–„€@@@@@@÷ qåžëά@xešÁAIDAT@@@@@‡ qåpKB@ € € € € € €€{ ¸rÏugÖ € € € € € €€Ã ¸r¸%! @@@@@@À=H\¹çº3k@@@@@@ÀáH\9Ü’ € € € € € àž™ÜsÚÌ@@ ­¦L™’ÖCÐ?Ø)pòäI;ïä6@@@ mH\¥­/½#€ €n'àçç'5jÔ;v¸ÝÜ™0Î$ ¿Oi € € €€£ ¸r´!@@ÀÉÊ”)#3fÌpòY> € € €d„g\e„:c"€ € € € € €$ qÅC € € € € € à$®b@@@@@@€ÄÏ € € € € € €€C¸rˆe @@@@@@W< € € € € € €!@âÊ!– @@@@@@H\ñ € € € € € €8„‰+‡X‚@@@@@@ qÅ3€ € € € € € à$®b@@@@@@€ÄÏ € € € € € €€C¸rˆe @@@@@@W< € € € € € €!@âÊ!– @@@@@@H\ñ € € € € € €8„‰+‡X‚@@@@@@ qÅ3€ € € € € € à$®b@@@@@@€ÄÏ € € € € € €€C¸rˆe @@@@@@Lö„‡‡Û{+÷!€ € € €¤±ÀíëGEôEC@œH€Š+'Z,BE@@@@@\Y€Ä•+¯.sC@@@@@œH€Ä•-¡"€ € € € € €®,@âÊ•W—¹!€ € € € € €N$@âʉ‹P@@@@@@W qåÊ«ËÜ@@@@@@' qåD‹E¨ € € € € € €€+ ¸råÕen € € € € € €€ ¸r¢Å"T@@@@@@À•H\¹òê27@@@@@@À‰H\9Ñb* € € € € € àÊ$®\yu™ € € € € € àD$®œh±@@@@@peLÞÞÞ±ó‹ŽŽOO÷ÊeÅÄĈ‡‡‡+¯q‚¹¹Ûœu¾ÚÜiõ÷²Î׿ìnϵ>Óî8gwüÿ)w›³>×úâï#®ýWwüÿfw³;ý]D×Þ¾}[¼¼¼\û7p¼Ù¹ãßGÜmÎîúç—#ý2S&Ë¿ï<ýÒôÏþ ™¦¼Ó¹»ýù¥ðîöï)wüY;þ’ÿov˜?VÓ4wü3ÛvÎÿÐÀ =IEND®B`‚input-remapper-2.0.1/readme/capabilities.md000066400000000000000000000162001450214210500206630ustar00rootroot00000000000000# 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.0.1/readme/coverage.svg000066400000000000000000000020201450214210500202170ustar00rootroot00000000000000 coverage coverage 93% 93% input-remapper-2.0.1/readme/development.md000066400000000000000000000125321450214210500205600ustar00rootroot00000000000000Development =========== 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. 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 --------------- ```bash pip install coverage --user # https://github.com/nedbat/coveragepy sudo pkill -f input-remapper sudo pip install . && coverage run tests/test.py coverage combine && coverage report -m ``` This assumes you are using your system's `pip`. If you are in a virtual env, a `sudo pip install` is not recommened. See [Scripts](#scripts) for alternatives. Single tests can be executed via `tests/test.py` using the full code path as argment. ``` python3 tests/test.py tests.unit.test_ipc.TestSocket.test_select ``` Also `python -m unittest` can be used, which provides more control over which tests to run and how to fail on errors. See `python -m unittest -h` for more. ``` python -m unittest tests.unit.test_ipc.TestPipe -k "test_pipe" -f ``` Don't use your computer during integration tests to avoid interacting with the gui, which might make tests fail. There is also a "run configuration" for PyCharm called "All Tests" included. 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. Releasing --------- ssh/login into a debian/ubuntu environment ```bash scripts/build.sh ``` This will generate `input-remapper/deb/input-remapper-2.0.1.deb` Badges ------ ```bash sudo pip install anybadge pylint sudo pkill -f input-remapper sudo pip install . # the source path in .coveragerc might be incorrect for your system ./scripts/badges.sh ``` New badges, if needed, will be created in `readme/` and they just need to be commited. Beware that coverage can suffer if old files reside in your python path. Remove the build folder and reinstall it. 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.0.1/readme/examples.md000066400000000000000000000144711450214210500200600ustar00rootroot00000000000000# Examples Examples for particular devices and/or use cases: ## Event Names - Alphanumeric `a` to `z` and `0` to `9` - Modifiers `Alt_L` `Control_L` `Control_R` `Shift_L` `Shift_R` - Mouse buttons `BTN_LEFT` `BTN_RIGHT` `BTN_MIDDLE` `BTN_SIDE` ... - Multimedia keys `KEY_NEXTSONG` `KEY_PLAYPAUSE` `XF86AudioMicMute` ... - Mouse scroll `wheel(down, 10)` `wheel(up, 10)` - Mouse move `mouse(left, 1)` `mouse(right, 1)` `mouse(up, 1)` `mouse(down, 1)` ## Quick Overview of Macros - `key(BTN_LEFT)` a single mouse-click - `key(1).key(2)` 1, 2 - `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)) ``` ## 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.0.1/readme/history.md000066400000000000000000000144031450214210500177360ustar00rootroot00000000000000# 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.0.1/readme/macros.md000066400000000000000000000125611450214210500175240ustar00rootroot00000000000000# 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. > > You don't have to use quotes around the symbol constants. > > ```c# > key(symbol: str) > ``` > > Examples: > > ```c# > 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. > > ```c# > key_down(symbol: str) > key_up(symbol: str) > ``` > > Examples: > > ```c# > key_down(KEY_A) > key_up(KEY_B) > ``` ### wait > Waits in milliseconds before continuing the macro > > ```c# > wait(time: int) > ``` > > Examples: > > ```c# > wait(time=100) > wait(500) > ``` ### repeat > Repeats the execution of the second parameter a few times > > ```c# > repeat(repeats: int, macro: Macro) > ``` > > Examples: > > ```c# > repeat(1, key(KEY_A)) > repeat(repeats=2, key(space)) > ``` ### modify > Holds a modifier while executing the second parameter > > ```c# > modify(modifier: str, macro: Macro) > ``` > > Examples: > > ```c# > modify(Control_L, k(a).k(x)) > ``` ### hold > Executes the child macro repeatedly as long as the key is pressed down. > > ```c# > hold(macro: Macro) > ``` > > Examples: > > ```c# > hold(key(space)) > ``` ### hold_keys > Holds down all the provided symbols like a combination. > > An arbitrary number of symbols can be provided. > > ```c# > hold_keys(*symbols: str) > ``` > > Examples: > > ```c# > hold_keys(KEY_LEFTCTRL, KEY_A) > hold_keys(Control_L, Alt_L, Delete) > set(foo, KEY_A).hold_keys($foo) > ``` ### mouse > Moves the mouse cursor > > ```c# > mouse(direction: str, speed: int) > ``` > > Examples: > > ```c# > mouse(up, 1) > mouse(left, 2) > ``` ### wheel > Injects scroll wheel events > > ```c# > wheel(direction: str, speed: int) > ``` > > Examples: > > ```c# > mouse(up, 10) > mouse(left, 20) > ``` ### event > Writes an event. Examples for `type`, `code` and `value` can be found via the > `sudo evtest` command > > ```c# > event(type: str | int, code: str | int, value: int) > ``` > > Examples: > > ```c# > event(EV_KEY, KEY_A, 1) > event(2, 8, 1) > ``` ### set > Set a variable to a value. This variable and its value is available in all injection > processes. > > Variables can be used in function arguments by adding a `$` in front of their name: > `repeat($foo, key(KEY_A))` > > Their values are available for other injections/devices as well, so you can make them > interact with each other. In other words, using `set` on a keyboard and `if_eq` with > the previously used variable name on a mouse will work. > > ```c# > set(variable: str, value: str | int) > ``` > > Examples: > > ```c# > set(foo, 1) > set(foo, "qux") > ``` ### add > Adds a number fo a variable. > > ```c# > add(variable: str, value: int) > ``` > > Examples: > > ```c# > 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. > > ```c# > if_eq(value_1: str | int, value_2: str | int, then: Macro | None, else: Macro | None) > ``` > > Examples: > > ```c# > set(a, 1).if_eq($a, 1, key(KEY_A), key(KEY_B)) > set(a, 1).set(b, 1).if_eq($a, $b, else=key(KEY_B).key(KEY_C)) > set(a, "foo").if_eq("foo", $a, key(KEY_A)) > set(a, 1).if_eq($a, 1, None, key(KEY_B)) > ``` ### if_tap > If the key is tapped quickly, run the `then` macro, otherwise the > second. The third param is the optional time in milliseconds and defaults to > 300ms > > ```c# > if_tap(then: Macro | None, else: Macro | None, timeout: int) > ``` > > Examples: > > ```c# > if_tap(key(KEY_A), key(KEY_B), timeout=500) > if_tap(then=key(KEY_A), else=key(KEY_B)) > ``` ### if_single > If the key that is mapped to the macro is pressed and released, run the `then` macro. > > If another key is pressed while the triggering key is held down, run the `else` macro. > > If a timeout number is provided, the macro will run `else` if no event arrives for > more than the configured number in milliseconds. > > ```c# > if_single(then: Macro | None, else: Macro | None, timeout: int | None) > ``` > > Examples: > > ```c# > if_single(key(KEY_A), key(KEY_B)) > if_single(None, key(KEY_B)) > if_single(then=key(KEY_A), else=key(KEY_B)) > if_single(key(KEY_A), key(KEY_B), timeout=1000) > ``` ## Syntax Multiple functions are chained using `.`. Unlike other programming languages, `qux(bar())` would not run `bar` and then `qux`. Instead, `cux` can decide to run `bar` during runtime depending on various other factors. Like `repeat` is running its parameter multiple times. Whitespaces, newlines and tabs don't have any meaning and are removed when the macro gets compiled, unless you wrap your strings in "quotes". Similar to python, arguments can be either positional or keyword arguments. `key(symbol=KEY_A)` is the same as `key(KEY_A)`. Using `$` resolves a variable during runtime. For example `set(a, $1)` and `if_eq($a, 1, key(KEY_A), key(KEY_B))`. Comments can be written with '#', like `key(KEY_A) # write an "a"` input-remapper-2.0.1/readme/plus.png000066400000000000000000000267621450214210500174170ustar00rootroot00000000000000‰PNG  IHDRôÆ$r˜oiCCPicc(‘u‘;KA…?£¢ø ˆ")T,‚‚Xj,l‚HT0j³»ÙM„l\v7ˆØ 6‚…hã«ðh+Ø*‚"ˆXù|5"ë$ É,³÷ãÌœËÌų†í5DÁÎùnb:YL.EšÞh¡Æ k†çLÎÎÆ©9¾¨Sõ~Xõª½¯êhM™žuÍÂc†ãúÂÂñußQ¼#Üed´”ð±ð+¾Qº^äWÅé"*vçSR=#é Ö+ØÈ¸¶ð pŸÍ¥ó¨›´™¹…9©=2{ñH0MŒ:yVÉâ3,5'™U÷E ¾ÖÄcÈßaWi2â5/]M©–è¦|Y6Tîÿóô¬Ñ‘b÷¶4¾ÁG?4íÁïnüœÁï)Ô?ÃU®ì_“œÆ¿Dß-k}GÞ‚‹ë²¦ïÃå6t?9š«¤z™!Ë‚÷shOBç´,³*­söó›òD·pp²?¼òýèh "¢)† pHYs  ÒÝ~ü IDATx^íxTÅׯO ¤zBIB ¡IoÒBï‚…^DÅŠ ‚(~€(*EDøK/R‚%HNBo¡†B ùæLØu7…ìfï¶»ïy’ÍÜ™9¿¹{ß™3çÞëžN0»&àl×½GçA@@$:NPº .€€€ç€€¨€]ƒ@@@‚Žs@@T@‚®‚A„   AÇ9  * AWÁ € ã€ «`က@Ðq€€€€ @ÐU0ˆp@@ è8@@@@ è*D¸  tœ    t "\:ÎPº .€€€ç€€¨€]ƒ@@@‚Žs@@T@‚®‚A„   à  Ö%P¨/ y­/5kÖ˜üüŠSJJ ]¸p‰V®\OK–®¦ôôôtúôÚµ;OÇr%Ú0¤( @äâã[ìs€°âÅ‹ÒêU¿P£FõÉǧ ݺu›\]])0° ED4¤à@Ú¹ÍèÎÕªU&OGwïÞ£¨¨=FoÈ–hÃ~  €@„Üq&€€ ŒþŽ\•߸q‹:vìEM#^ :u[шÇÊ^µk×’š7k¤íᮿ7Rôé½ÔXL4öÃßÈÏÞ~{°ü¨E‹&4uÊxùs¿~ÝåßÖ®ùŸü}Ë+éä‰ÝT¡B(-^ü#;E7,¥ ê(Ö†q¢ipht‡~8oM¼gñe›3g!EÇœÕvgõêßiß¾ƒò÷¶m[ÕÍ"E Ñ£GIò˜˜˜s´|ÅZÚ¼y»¶gZ¾l¹¹¹Ñ­ÛqD3¦O$__ƒÛÉ­ ƒ+BAÅ@ÐC‰Š@À8E‹&wyЉ“ÑY>~â´ü,  ´Q/]ú:tD³k×>úä“/húŒ¹zu¬]·‰^|±Ÿ˜,t¥ØØäåå)"­ nÇ6 ® A!AW#*Ó899e©À9›ÏLkå¿£÷?]ý'%%ÓÁƒâP¦”RÕ£+€ [:š&pëÖz”˜(aTª–Šæ³K—®d¦£ÿÞ¼Œêìâ¢=F3oÈ’KobFw €˜D‚n> y'––&’Ôþ’ Ø‹ÂÂÊi+{啈³ÈÙ6nÜ¢ý<þáCùséR%åÿ*¯\9óU–®-Z´Rf¶'$$Rxxy)غöõ7Ó(-581/:ú ½ñæGÚ2Jµa^¨@ gNáy{ ¨‚ؾ³æß~çcŠŒüïV6»sÈB+tœ à€œ(kV½b€Ë  *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%KúS‰ÅÈËË“\œ]€ lžÀå+Wéþý6ßOtб ¤§¥¦¦Rrr2%&&)‚®8Rû­ÐÍ-?U­R‘ (`¿N çIÀ§ 7Ý!GÞ¾œvr"Ê—ÏUþsww—çì“'Os!wÅPÚwEÎÎÎTµj%ˆ¹}#z `'\]]Èǧ ¢½… +ŠÓ~++YÒ xyÙ¯è9€ØuwÅz AW ¥}WÄ{æ0ËpssS¬Aºb(í»"N€ƒ€X–€««r©ltˎͶ†lv›t @@Å8QN)ƒ +Eõ€€€€ @ЭMƒ€€€R èJ‘D=   `Et+ÂGÓ    ºR$Q€Ø eKçÒß;7PDDC»èñ„/>¡õëQµj•í¢¿Öê¤rùòÖòÀFÛíÛ·Û3Ÿ4mÚO6ÚsÛíÖ!ý¨Fª¹V­ú]v40° }ôÑPâ[?fΜOÿþ{ÌvxÚ3üëúõ›iݺH½>¿þzª^½ŠülÆ-´fÍF›÷ 4@ÿ~Ýiðྲ¢ß~Û@ß|;ÝôJm¤S}+R¤0÷åÇ¥Âr&A7ÓÙ±F|!]2^lR»vuâgMoÙºÃL­9fµ¾¾>ô曯Šç"ç“@{óÌ#Å]Awww£Ê•Ãs@Üë6mšSºx{GRR25oÞ˜¦~7›?~¬ *¦ú6äõ„˜»I60ºÅÏûâµm¦ˆ7뤦zÐÝ»÷´ŸuëÖ™bcoÒŽ»´ŸyzzЫ¯ö¢ßß,VhUéôé3ò­gåË…È/öÙ³çiçßûäÛz4V«V5ªR%œ<ÄÌõÖ­;µs]¿~ÓâþZºAqs_ß‚´ÿ!Ú¸q«¶ ¼ZïСÕ­[ƒ¼½½)..ŽvîÜK[¶ì #Þ¡  2´uk-[ö›<†{;fÌGòçÏ>ûŠnܸ¥ç?ž±}ûŒú|||ä öî= Æé1i²ìøñ£ˆ_n3~üdâèLXX(ýøãÂgN2nÞ¼-Û.Q¢¸h3cÌ8¤È/nà>d~z_¹rÁÔªU”ÏÜ÷¢{÷î‹~”«|Í Ø÷víZȾúúúRBBÅÄœ£¹s•erêçñã'sõÑÒcìHíñ$®L™Rò;{ƒš6}ž5¬GÛ¶Gi1p˜œW©Ã>ø”Þ{w•/B·oÇÑ’¥«iÅŠµÚrÏ=W™zõzY^7øûÁ×…ÈÍÛiÞ¼Œs ;ãsn@ÿžòü*Z´ݹG›"·Òüù‹Åµ'ãzS¼x1úX|xw—æý¼˜:ujC•*V nÝÑ•+ײ­;7ßúôîJ•º'¾W=z ¦âÚ9P\ùZxûöêÕûuš7÷{*UÊŸF:¶oßImZ7£=_¢2¥KRJÊc:áýòËÚ³ç€#6Y|ꕆÿĉhñ… &~)ŠÆBCƒåkõ.^¼"?jܨÝ»'¿°[¶þE!!e©‘øLcuëÖ_¦0ŠÜ´,\NW®ÆÒ Ú’§§úŸúÖ¯_7n/M.\–_d]cAmÛ¶9=z”HQQ»ÅE,^z©£y¾°Õ®]M˾Nò³èè³YÄœ?ï'B¡,’üº âÂáåå!~o)/8ºÆÛ?*VØar €ÅôYvüø)ùç52Âëýª!'l'NœÎr(_ŒCB¥Ï,ä<ÎíÛ·¤-kËöïß]~Æî+O8x¢¡{!Ï®Ÿ†úøL‡ðÇ<àó•mçß{ÅäsüYó™n¥ÞÞhÆô‰b¢Z@ ¨¿ zÿ½×õö–Ÿ*ÿÆ«±ÝböÙL$±hDŠgâÛ·EѵØë² ÏNYLBB‚èèÑ&ôζå¨况ñÊD³‚àß ò¥:uªËÐåüùK¤¨ø`Œô'‚¼ªñÅÂr´yóŸ‚I!­/œ£Áõ³e÷¬hÝ~f03ÜGÛ>;ì¯wmjѼ‰ìøŽ»å¸¦¥¥Iñ,\ØW¬†ÿ‹ìq™ý‹HÓÿÉòóžF¼¨"^{|øpFþŸ?:öY>O.]ºBo¾ñª<Ï-^™¿¿µjÙT~>lØh:"®A"7eáÂYâzÓˆ~^$ÏG>^· GgÍœôLà†øÆ×»/&L«ðiÔ©c[ñ çJrm½ˆTîÞ½?Ký<é`KNN’ÛU|äòjÙž0å † ›BÏ„c“ÄJüܹ r†Ë‚ÎÏR/)„âo1CרNÓ5Ã:‰/(ÏžùËÊ+¯ˆf¨iÄóÚb|ñæp¬šÅœ#ìkçÎí¤€²È±•.íONOŸ¥8zô0= ,âü7¿óªE,11‰Š+¢þÌÜJ•*)?âr,ælåP'PZOÐ7nÜ"'e†¯ÄOž<-_[ˇŠ"tÉBË[¼-Ù8ÔÉ!QžPð~¢Æ4?óä‚ýKJJÒŠyÆ…/ë¾£n?K–ô7ÊGC|Cà ðD“¿³|^9“qîð$”soZ·jF‹—¬Ò«Œ'žšóCÍ,èºßy×÷êù²8‡DÇC{¬gïk ’ex%ÌbÎvA\“bÅBÃÜʇj· uËðÄ!!ᑼvåd†úvîÜEúeÁ4°7Êm‚ï¿ÿ1Ûj;Aݪ$¢ÓE´âtôúELÞÿÒÙ¾4œ¾ºJBЭ8žvo/B¹¼UN|)yß]wÿÛÉYÿ!¿¼—ËÆÚ¥­Í"¤ÆûLº–’’bE¯Ìß4 ÞêÕ¿Ëp`hhY¹ß¨ ¥kZONN‘{†™W>ýµ‹Z·Ž™ä‰‰‰²G7tWúšã8|™iž¿¬»eÂåøg¨ñ:tT :‹5¯²¸ßGŽˆR&Ag<¸\qñž9Ÿ;U«V¤6mši›ãÉ€¡¦ÛOc}4´ ”3Œ@[‘ ÇÆcÌ·’éZÛ¶-²z|üCm‘t1±×5^Y;Rž'|þów¥Áóuˆ÷©s²œ"IškŒ³‹ØÌáãuòy²«ßßtsFx’À“”ì¾OüúÖpqílE]»¾ '&Œ¦‰_Ok×n2 ºJKaÝŠ{ùòUJ'gÙ @ arMˆTÓ%MXYó{1‘¬Âá)þBó¿Ç" Æ!9ž5ëþË.\kE7ošrîˆ=¼U«Ö˺»ti/W¸lW¯^—«Ž^0#CjþqÈšùqrâ¿ÿ—ï!æ°&¯Ú³3#®ËrƒæÂ«›œ ušûÁ}âÐ;o¹9r\„7³NÈJ‹}E¾Hs‡9ZàÂZã$;6¾µ‡“ò4ÆBñ,3·†²pÄrüýÕœƒ<¼U£ùÇY9ò½l«öððÛY¿­Ù æ4"þüóu ŤÚrX¡[qhY(N‰”råƒe¸}›Ø×5?¿âT½Z:#.ÞÞ"!‹Oz5ñ*“í°Ø;ª#’¨øKpU$ÈäËŸOÌòýD(ÿb® YVt[±¦y¿‘Ã’,„œD3uêl™}û·¸ aúô®ÈÖä”)SR„·chñâŒðåŸî”+t¾@?I{ÁÊܹ{÷È}Í&Mˆú^#¾èò…ŒW/ÇŽ’û“¦¯@ø‚ª¹Hîß8Ûê4‘ÞnéÚµ³(“N7Ð+Ë{ùîgo¿=XôíªÈ( “˜ÆŒ™˜c7Íí£)|Ô~,o¡pd…÷É{ö¢—¼È™ÝœçÁ«t>G 1M>Na‘Áç?_cºˆm©gGøÖqä‰ñäÉã)F„ý9ÉÌYD9rÅ!m6¾-”óv¾›:AæžðW³ŠÏ®~C}ãI1gϳqVýÚu›hñ¢©v­êÔù…vR¸u­^½šôÁ°7åä—¹ñ5€ëݰB·òÀ¡Óàà âÕ'¾é¯,‹ˆ•'ï‡uìØš. ñÐÝcß»ï íÝ÷LVéÝûñÅm/Vû9ÞšbeWož/V ,•É0,ˆš;~ýu…\½³¸óm`|â¨_„4vêÔ¹‚g‹ŠÊÈ*ÎÉ–,Y-Cü¼:.[6@îIoÙòÍžý‹">ñ~)ïÓk2ß3W̉•+×É>7iR_öcÊ”Yrï_×f̘+oÉc‘æ°}þüùé€H¢ÊÍÌícní;êßY¬Ù¶Š»X2ßRÆêlœ°–Û*[Ã't|ĉ( 9ßRö´çzNœ'O™I³fÍ—ß™Šáåå¹ÈçÄ'£¿Ð2NÜ’ùÏ?ÿÊÜŽ͘1Gû·ìn‡3Ô··Þ$oÝä|ÎàÛB˜ù³¬{èÐ"RP\¯Û×DîšÈ7¨Y³š¼s…'·ü_¸p™£žFZ¿Ãõ7a‰åp"ȾýåÞ©Æ8{ùúõ"ü”5ËÓ=Œhj€TÊwú7Þ /\#FŒÍ6qL©¶Pù ݳæ'`j’KÁ̺ èVâÏIVž"¹£‘Øë=!öÅùV#˜ù ð“ã83ž“ê8g!sÞ‚ù{€@À> wd4nT_>™’Wê×Åýò+Ä6‡æa¶A!w+?ú•èÁ YÛ¶íÈrËTFÈý¦Ìø´„9ZÈÝLцå än9ÖhIyJ…Ü!èÊ]ÖA·ËaC§Ÿ€ ãT°gJ :²Üíù,@ßA@@à):NI -‡'¢€€€ù<}jµ" @ÐÁhÿ•<|hø#Kíß[x ¶A@÷uئö‚n*A•ó¦úß¡®’¡‚ *" äm³t¦¸ríÚ z˜Ëû»M©Ç‚€èàÇ2óí”2ºR$í¼~Ú?9þáor²s—Ð}°Y,æü˜[% –Q’¦×ů%ªoÚ´ŸŒ¨MÙ¢eJQXxyÚ¼y»²£6» A7b˜Öü¶œ]2^\R»vuò)èM[¶î0¢ó 4_å6Rs:¿ªÈF,=]¹*؈Kè€@6ªW¯BÕ«W5ˆÍ¡CGéС#•5G!ºTï?ˆ×–NIN¦ÔTº{÷žö³Â… QýúµÉ¯D1ÊŸ?ݾGíØE7oÞ–e^|±;v’¼½ PÍÏÑí;whÕªßÅkJ]¨Q£zT¾|(9‹§ œ=w®\¹FU*‡Ó²åk´õתUªT 'ñ–ž[·îPÔÎ=týúMjÞ¼1U«s¶òåC()1‰æÌýŸž)WÔϯ8Íž5‰Š-BÓ¦ÿDK–¬oÊG¯¾Ú‹Z·jJ… ùÊ>¯[IK—ý&ˆ‹¾³Ÿß}7[v„'' Ì”?wï1XÛ9__š9ó[ +GW¯^§©SgÑþ‡äßùíEú÷¤V­"dÛwîÄѦȭ4þbzü8U–yî¹ÊÔ«×ËT¾\ùú” #EDcÞ¼_µo<âð>‡Óûx‹F|jÖ|Ž>ýìK:u*†F Gþ~ïÞš9ëgJIIãœ_9x¨ @À& Ô0@Ô3Äü¨Uû=tñ''§ÐùóiÅÊu´`á2â @‹MôZàÙ^ !øëÖGÒöí˿խ[“BCÊRdä6Z²t5Ý»GM›>¯w—©T1Œ"7mu/§+Wcé…NmÉÓÓ“¶Š(AÌ™st:ú,MŸ>Ç*bΫgÜ©S&HAeÿYÌÙXûöéJñhÍšB<ÓièÐ4`@OZ)X±ñ¤„_ßÊÖ²ESù?9._¾ªåðÞ»CÈM(O¢‚‚ÊЄ £µ[ £F £~ýºË>œ<-'Mýûõ Ï>ûH{ü“'iT¹R<-…œË è߃ºwë¢Çºpa_š1ýkªW¯–œŒÜ¿O£?ù@NÖØÏÛ·ïÐðÞ/³Á|XÁ¯ª›$Àס¹bÒÿ¬•·!e,á]AÊ tâÄiùŽÛ„„Gtüø)*"Ví¼ט«‹+mܸ•bcoHaâ×–V­Z‘öî;H/^«¿ûtàŸÃ{íºö:^]îÚµ®Å^§‡Òž=è‰û†„)èAÞ«â>Nž4ŽÊ”))Wß³gÏ—•/^ŒZ¶l*E|üøIrUþõ7Óäß:uj#·,ØgæÄ«_¸óÿk×mÒëЦMÛèÕïPÏ^¯Ë¸§§‡¬ÛßßZ‰ÿÙ† Mo¼ñ!½öÚû²Íf䊟íèѓԡc9jMœø=-üß2ù¹¦]ÝÆxÅ?ô­áÔ4¢“èß=âèÛŽ¡Á¢î÷‡}¢€äްÁ>ð4"¨Ûg[sî– žME‹âPŠ+BùÄêÍÙEÌ—„`³Ø¥¥¥É–xÅ©»\ €—\Þ¸qS¯'·D¸¾TI?ù¯$ÝÜòSD³FB`þ[¹ó[zøx[°A{S``ºw—¾4CÛ¥ÐвrÒÂÏ+žÿót½®²ˆ;;; ᎔+xež•*åOññéÏ?3";t8#œ•˜˜H‡“«z.$?("GŽž?_“£X1ùá¿W[çÎ]”Q^=_«û9И§—g„ ,•m°•6î“f–ΓnÏVøÛÂ9€>€€š °pk®ãµjU—®Ú’˜s è îb_»K—}†–‹ý`¿—,éO/½ÔA¯…ԧ®ùPó`þÌ _š‡Ë± ²q;‡{u÷qmÁXÌ=J”+íÞbŸzþ/Kôº•(öõÇŽý&KWy{õêßå17ay¶M›¶Ê=j]Ó„äu™0·´´ìÔ4Üxb$ú7nìH9¹â=óýûQƒçëPŸÞ]³Å÷ þ¿| Í›kž<ÑoGwŒla Ðó8rä„ ¿k¢®sç-²j\forWhü‹/Jîîn´wÏ?RÌÙ8 >7‹O ´ÔT¹ï¬k…Ä^°Æxeø8å1ñÞ.¯ uÿ¥ˆÏÙÒExY#`¹µiŽ¿sÞû"ÜÍ¢7P¬Ö«U«,›9{ö¼ŒHxx¸Ó]ºÞµ[ûï¨Häò7oÞ’ ~¼Úmݺ™<Žs 2[ݺ5äG^bE͹lWE.A´Èàð:_µJEù9 ¸Ÿ_ ùó™˜óZ.XŠ9Gø É+yC÷À¹ 6Ÿ‚2søw€€cЈznûêÖ ‚ºBÔãyE'„ËÏ¿8]¸pY^ìkÔÈýV»£ÇNÉĸû"{úžØ ,-üOÌï9¦ IDATÆ+ÁÃ"Ü\§v JJJ¦«">ŸÈ¢ç½c%óÞ=¯(ÃÂÊˤ0^õòg–´ .É þŸEVù@‘Ñþù˜Ô¯ÿP±•p‹Ö¯ßL;¶¦)“¿y<ñàPüþ‡iòäd799®‰X¡û nÇEÂÙ³²tŸ“åB‚ËÊ-ΖçðüüIDòáš5D„¤½¨o¼L .(ÃùœkpZDM8߀­°8î]‘\ÇÜ»tng"Þ&áÐO"¾ÿNd¼ŸŽûòArÁmÀ@‹€µ³Ùs¢ºBçáÝ»÷)*j5ûÜ,h­ÅíSÛ¶GÑ“L!öìšÛ½{?]¾tEŠ^¿¾]¥ =–±¬1NšÛ»ï™ÀÕ»÷+BŒÚSY!úš00ï÷ÞIv=º¿H/¿ÔQ!¯Œ¯†oãÕ/ç|öé‡R¼¿ùv:ý0sž÷ÆëSÆuåÄä€{kìŸþÕÞ¸v­~2—áòÇ.‹*ä#ï&øxäX)æl“§Ì¤Y³æSœXó-|âç,ûOF!ÿsŽf̘Kq‚ y¥Š„°’ûâ†Ø˜Ï'ÒNE`ó+ÿ _L¦}ûr(Ê€€€E8†ÛÎÓ:,â²}4Ò Am±/!V®ë-Òa`kZãFõéË/?•Û »ô•‰o0püL % !w%(š¡ŽbÅŠŠÕ§a«G34o±*ÛµkAíÚ¶¤Êâ!:l¼Â‡˜[ ?Pº f‘Èõøñc™ÆInåDW@@iâGÍªÝ *$ïÃç§Ç-_±FÜ!°Ví.Ã?0 „Ü͂ոJYÀk‰û× úx‹G¿:‹Ä¸ûtèàQ™|e)³vÈÝR~¢°5¹ÛÚˆ˜ÐNØâ0È+d¹ç•Ž"A·¡Á°fWlèͤÖÄ€¶A@À¢”¼öBÐ-:t¶ÛXªxZ @@À²”¼öBÐ-;v6ÛZ²x¿; @@À²”¼öBÐ-;v6Û?‰-55ãp0óàk.?ÕR)ƒ +EÒÎëág›ó{Ü!êv>è>€€]àk-_s•4¤/•  —^D×®]×büçàíÏÎÎN4h`/êÒ¥-Rˆ®\‰¥_,£ßÖlÒ–áÕï†[)<¼µjÙD~¾ÿÀaÿÅT91ø|̇ôB§Öòó6m"ägÍ[¾"WÍËW¬#ÿ4 7ŠŽ>Gƒ^û€ iÓ–Æ<88Þ~k U©F^^žs޾új<cKÝD_@@,@À*‚Þ±C+Z¶|­ž˜göõÕ=©G÷ÎRœ£cÎSê•iäÇïГôtZ»6R[ü£ߤ9sQßþï¯oA?îc)rcÇM¦Ïÿï[òðp§´Ô45úK½&úö}…bcoˆUðhzpÿü›¡mZ`\ jâáÃúë¯]4yÊlJLL¤÷ß{ÆA/wÅ¿AQ@TDÀâ‚^ €+îÓ§ÏæˆÑÉÉI®œ¿ûþ'Úºm§,wùòU *C¯öï®'è'NF AÿU[׊ë©]»æ¹ïS4|=yòD–5¦Í\+ÏCš5ªÒ¤IŸË#󹺒›[~úsû*ùû¦MÛ諉ӳÔzóæm½ˆÅªÕè§Ù“(_>‹k<Æ!  J°ø•Ÿ…“-]¬´s²%ŠÉòÑ£§ôŠü{ä„úî”?>JIy,ÿ­?1x𠞼Ť!7Û³÷ V̹¬1mæVw^þÎyݺ‘‡6iRŸš7kDŸùZþž˜˜”m•åËÓ€Ý),,”ÜÝÝ¥ó¶A¾|ùòÒ  `Ç,žåÿXtË•+›#6 ¥SVÑOKKBüßç9‰]nc’’’¢WĘ6s«;/ç Ê·ä¿÷ã)%9å¿ß¯ÌæëëC?ý8‰î‹í‚¾ýÞ¡¶ízÒˆãóÒ4ŽP‹ :3Û²5Š^y¹#.ì›-BÞÛfѯZ%\ïïüû¥KW)55Õ`ôiiOÈÙ%w7•lÓàΙP°¢Hôññ¦fþB”üÄžúµk7hÊÔÙò65cì×E+©ŠXÙ/[ò#ݾG:öÉñp¥ÚÔm bÅò´bÙ½6?>–þزÃ7²”½pá2};i&ùt˜L苽~ƒÆŠÉΜ¿5©^   `ŸœÃsÎN3Â'O/o#J£¨¹ ðûЫÕhaîfP?€€€‰%dÍ“ÊK•VY¡ç¥£¶v gÚoÛ²"Ûn-^òÍøág[ë2ú   bô<.g¥7lüBÆa    ,ÜÓ¿•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~èIEND®B`‚input-remapper-2.0.1/readme/pylint.svg000066400000000000000000000020161450214210500177500ustar00rootroot00000000000000 pylint pylint 8.87 8.87 input-remapper-2.0.1/readme/screenshot.png000066400000000000000000001614471450214210500206110ustar00rootroot00000000000000‰PNG  IHDRɯp-˜éniCCPicc(‘u‘½KBQÆjQ”åPCDƒƒEƒBDÐR6¸Hˆdµèõ+ðÚå^%¤5hi¢–¾†þƒZƒÖ‚ (‚ˆ¶ö¾–Û{2PBÏåÜ÷ÇsÎóòÞç‚3œ×t«môBÑŒ†‚Þ¥ø²·ãNz™¦/¡YÆl$¦åúºÇ¡ê]@õj}¯éêN¥- “ša…eÂECñŽp¿–K¤„„ý¦ (|­ôd_gkü¡ØŒEçÀ©zz³ œl`-gê£Â>=_ÒþæQ_âN¤ÊÂ"Jˆ ^’”X#O‘€Ô‚dÖÜ7öë›g]<š¼ ʘâÈ’¯_Ô’tMK͈ž–'OYåþ?O+31^ëîBû³m¿CÇ.T+¶ý}lÛÕp=Áe¡î_—œ¦>E¯Ô5ß!x¶àüª®%÷àb„™ø•\²™ ¼AOún¡k¥–Õß9§Û”_tû0"÷=«?œh zÿ7 pHYsÄÄ•+ IDATx^ìÝ`TÅ€á“$ôЫJ‘¢t•"*bÃÞËÃÞ{¯ˆ½ ØEDE”&Ò¤ˆ :(" ½†„Hs6$lIv“-ÿøòÙ¹sg¾¹ËnΞ™&‡KdddXÃÆÍ»Tªšø^dTt³°°0ÇCü‰ € € € €@PäääHfFú²ä¤-÷¬\±ìgÉÌÌÑÙHX™¸øÈ–m:Γœì“²2Ó%+;[Ä@A@@@@ ¨LbXDx¸DDF›ÈXøâ?üÑîÀþ=™ášAÖ¬ÕÉ£3ÒROJO; YYYÈ‚jæ  € € € €@®€I Óø—ÆÁ4¦q11ñ±°º Oh\¹jµdŽq± € € € €„š€î8¶sGRãð²qåÎ'@jÓÏx@@@@T@w‹-[öÜ𘘘› A@@@@ Tbcbn 7¥y¨0n@@@@Â#"š‡Ã€ € € € €@¨ $ õ+€ñ#€ € € €Hdaafkÿê‰U¥bB‰Œ*´„(`®J~ÌÌLINN‘UkÖØ[ÍR@@@@À ~U¯VMÊ”)#k7lýåž’_ É ÇÉŠ•«`)D C»¶²jõ|òDFDJµjU¥Qòò_®.@@@üI Ðå–*&ÈÖmÛ ùÓlÑ—€ÈÌÊ”;vHåJ•zt@@@`(4“,*2R222‚qÌ^SNNŽ×Ú¢¡ÐÈ0Y™‘æ¹EA@@@ÿ`ã~ÿšzƒ € € € P ÎSZÈ”r>%ø”Â%Ë)@@@@ï 8 ’±˜ÐEŒÌûóA‹ € € € €¥ ÀrËR@ç” € € € €þ%à<“Œå„Ng‹ûýëb¦7 € € € €@QÈ$+ªÇ!€ € € €™dŘJ2ÉŠÇ¡ € € €©@tt”œßó<éÖ¥³4¨_ߎò¿µkeÚO3dÂÄI’žž¤#ìa9¿»e`Þ#€ € € €%*P¥ryõåeë¶­òégŸË?+WÚó79á¹ü²K¤Ç9gËSϼ I;“J´_Áx²Õ«Ë–­[ š³Ç ³¸å–C}"íÛµ ƹeLŨR¹²Œ=BâââlKýÞzMNjÕ²˜­r8 € € €î h™È~š1Cž}þ%Yòç_2qÜûµØ|ÿôs/ÊÌY¿È+}_­K)º@Í5ä‹AKë“[ÓˆþLÓ:ž¯/·ì÷ækrÚ©mÒ32dëÖm2ë—Ù2øËa²wï>OúV`Ý?(ËW¬XêX”>äõÙ³w¯¬[·^† ýZfÍþµØ6ž6pÂñÇIDD„ñüÇÓCK¬þsO?.mÛ´Îw¾©Ó~’÷>üÄe¾6\V¯^cë5lP_ÂÃÃeÕá¿»<˜  €~/pV×Îòàý÷ˆ~H²pÑb¹óÞK¤ÏO8Þ¾¦öú©Òt:õÛ—ýû÷Ëú åËa_›å?—Hÿ8  €”ž€.±Ô ²á#FÚ‰¯†›6•ózœ+c¿ÿ¡D:«Á¢Ÿ{JyâÙ½{wçLHH·^ë+Ï÷yE6oÙâµ~Ýy[oŒ‘þïèµ6µ!íãcO>+o¼ú’ >ö‘ù Úö[µl!¯¼ô‚ý™§ãðI&ÙÀA_HûÓ:K zIßWߦM‹f€ÅÇÇä9ódÏž=Ån§4PŸºÈ%W\+'O‘×_écÖ(×+ñ.]ÍUÒ¬i“?¯§'üæÛQÒëòkr¿Þÿèӛȑœ|?_´x‰ìÝw(0Ûëâ äøãyzjê#€ø±€ú¿÷‘lÞ¼¥ÄdÊq¯ŸŸ,íN=Sz^|¹|7jŒ¼ÜçyiÔ°kÒ5@ð†€îA6rÔØ|Mu=û<ѯ¼å»Q£¥{×.Þ8¥[mh°hÁÂÅòÑ{ý¤B…„cމ/_^Þë÷¦üõ÷2KÎ:p÷·I§ÓNµ+õ{oÍÔÓ@ÙË}ž³ 6 Ó Ù3&‹Ï4óäœNƒdš)åñ— TØÿ̱{öì TÜ÷УvSº®»:·=ìM4N&ý0Zî0‘۰0öÅgråå—æ;ï«}_‡Ì'ÅÚæè_É™§Ÿf¿¯T±¢¼ô³2}òxùyÚ$yżÕŸÖ¶#óë¶Þ7˸Q#ì¹µ½³ºuñxœŠì±zþ/;;ÛFoGŽk3ì4€ãŸß÷m™=cªœØ¬i‘ÇãÌáÁûî–®]δîßúF.íu‘½n¼aS” ;gmNN¶deeå~9ÚOL¬f"áOËð¡ŸËÀÞ“6'Ÿ”¯™O>ì/;´“[n¾AN;¥£\wíUòÙÇïËyçžmë%$Ä‹:hwø°ÁòÄ£I¥J•rÛÐãu^^zá5b˜hæ@ÀÿÆ|ûµÙ룻ý÷}ÖôÍ¿á_Iç3:åv\××þ¯† ’™Ó'ËÐÁí*GÑcòþ½|ùr2ï·™’X­š}?Òͼ~Þyû-v‰ÿe—\ìD3É~˜0Iv™×|LJ5+VWû¾(Ó&ÿ “DZmifš£hÿzö8Ǽ¾ –_~úѾ7Ò× '{Ø3uâ8¹çÎÛsëwlßN¾üüSÛ–~=pï]nÕ•…;}ÕÜ>èÿŽüúó4i~b3ÿ¿@è! €>¨o`V®ü7ß~š2Qô+oùÇÔ©ß ¾{rlÓdVúý*È(Ó÷:ï÷[æ/\$ÞÿÈk}Ò ²SOé wÝ÷ÜqÏýÒ¡}[¹ÿž#ïS¼u" ”éo(Ó™~_”™ö§D6î×Ù´é3lPæ½>¶¯õí#›6o6Ùf—ˆF,?ùp€$'§È÷?L zöo¾=”š¨“uúi§É½8êà·ïH’K¯ºVÒ¦I•*•¶=â»QҲʼnrñ…çË×Þ`ƒSº‘[FféÜUBSÏ=û,)S&V–üµ4÷úèûâ³òî€åñ§Ÿ“Ø ¸ +Wã)ÌX{§ÿûÆ¢¹}ã>jÌ÷öÜ­Z6÷WO ¨>þȃv¹ê½ß¨È(yð{ <ì³Á_Jxœn2&ý85·Î#Þ/)»v™ìƒDƒ–7ßx½¼ðÌrÿÃç.ç}øÁûäó/†Êko¼#ÓÒ\u‹Ç@üDàæßô‡J6nÚ,]ÐS^0ªœuî’™™i{¨?ôØ“’”´S®ºâRyçÍW哵¼ÛE¶zA¯ŸÎ†+çtï&‘‘‘fO’C¯õÎ^Ÿm]{õ•rÿCÙש>Ï?#Ÿú‘ øà#y£çÅR¿^]ûé癲lù »Y­fîÿ»jµÙw£º|m‚kºÃÂEKÜ«3 wúzôû?¹è €þ-`~§Í1¿‡–tÑ@™3Üu߃6E?ìòv€ìŽ[{ËéN³ÙþŽåwßÿ=—ʼ½ôÒ[Ž>YnYPç¶mß.LPHK­Z5íÆj|ü©d˜}Ëv&'›[ NÝ[d² bèþQú¥¥{·®²zÍšcö’ªS»¶ ò¼óî{’’²KRM0I÷üpÖ¶¶·Óâ4(Õë lNßXêä’,·Ü|£ý´uäð¡6ÃI/Ý»ÍQÆŸhßÜî3K«WO,ÔÊÙx\94^°)¨_W_y¹ý´ÞñÕÍ\'šEv\£†2ôë6Kqjª]Îân©ZµŠ ~1d˜¤¦ƒ&È:xÈP©S§v¾e™Ü;o¾m_ÿñ  €†Àè±ãìûýd¼yW¶¬}íp}\_{5höÕðoÍëÀAiçÅ9^ëg™LµË/í%·Þq}Ïáîëó³?Éö;ìû¤ŸgÎ’È·#GÛ×¢Õkþ3·_'õêÖ±ÃY·~ƒ¬üw•ý€g“YzªûqÖªYËí±fán_ó¾oáµ20žô@ÀwkÍk´;«´Ž&}”FÑ@Ù¯¿ýaV¿ß_æÌïÕ 2 aVj.ïþgš¨¤²6­OòjF™Ýƒ¬ï¡=Èté¥~ôÞæî:—H&™vF3¶ôÓP-U«T±oä<’Ƨ™@HÓÍìgÌüÅÞUƒhç{ŽŒ7þ˜ñT®\ɾQÜ‘”ÿ–©ÎÚÖF6nÜ$7ßz—Ütõ2vä76»h€ÉnÓ€TI•ÏÝ—¬°²6Ï¥¨ãqu\Açö›‚ú5|ÄwÇlz¨KR3Íü'›«£ìL:ò½«¹¬j²õúINIÉ­ª2]"¬KKE3( €'°Õ¼§p 4i‰Š:r)ý€ÍQô=É“™^ÕÜ  &a8^ë;Ÿyº<õØ#¹ȹûúœ´óÈx™™YöÁ¼ÛhpO³Ó´èÞ'_ØÓ|øWÞþ]—yDDùÔÕX {<ÉÉûµ¼ yß·x Å €™À´ŸfÈå—]bïdé¬è‡hSMÝÒ*oq”¼ß·?zƒ ‚åÍ ËÛ¦Êî}ðQ{ƒ­ëéÆúG÷ϱYÞû5P¦{¿e_² ’éRÝdªÉÊÑâl\sCïƒSßÿ0^žæIkþÔ;0ÞÿðcÇÌ“îí¡wfÔ`ÙÎG‚#®ÚÖ†þ[»ÖÜ­áe³'U‚ô1Ë/~à^y±ï«Å½¼w¼y³î(EfEi)ÌX+hï0¿·9 ³k×n‰4ó¯ûŠíÞ}èFññ‡~9(¨=Ödóˆ^?º™Ã866ƶ±mûŽxÓTÞ “c;G}OöÞüÙ|ð§{­ÞqÛ-òÆÛý\¾Ê=‡›Ë/t0½SÕíwß//[n×¥˜y‹³±j½Âwç=ˆ=Ož÷-¾œSÚF@ &Lœd“~t뽋eAE÷Ë®b>ŒÒu¥Y¼sŒCƒ^·Þy¯Óaiv™«:î¸hͱÙs§sGÑ=Êž~öE›Qvó-wzˆóérK Bé ôî æ çЯ†Û>ëò‡?Í\˜ýžßñR?YÕ=6´Ì_°È,È’Ì:Õé3~¶·O?ºh £ntwßÝwJ¹råì§ÃºüÎUÛº|ѱ‚£_?]ù¼Ýo€ôºè|ûšëÎ볫öò>^Å|X¸Ï¼OúwÕ*ûc½áO£† ó5ál¬Z±°Ç½ÝWOÆE]@U@·ÒãÏ<ãtssÁääV-¥ç…—د“Ojeo`xF§N¶Ž#Û=PÇZÚýÖ€œÁòÈ}ÒŸy ÓcÉô“AO¿L£·þï&™3{†Lü~¤ÉÒºÏîétSïÛÍR6“ñs¸½GŸxÚÔ̱w œ9m²|üÁ»‡n®w€ÔýC̆òºDá{]j™·ÚkMî1?{Øl¸«{ŒŒûL›ôƒÜ|ÃuöçÎÚN0K^å%SœL;R˜% ïø HãôØÆñIëáþx|žñ¹cål<δíA_ ‘“Zµ°wʺÚüà5/¢¬*ùîëܯ{î¼ÍfÁ½ñV?³çJ ó‰ù'2ÀܪV÷qÓ%“•o¿-Íš6–¯¾d7pÖòÚ›ý쟺òg¿g³Ò^yí­3ìJûÉÏù@ŽÐe†÷Þu»ÍÓ=5<)“~œ"ýß~]fL™`÷D}èÑ'í }´¼n^_NéØAF|5Än ¡Ë uoJGôÅ—G^?͇5®Ê*³G˜Þ(GïN©A·GŸ|Æbß™=Ë>þ ÿ1-Wm:Ÿmö™kömîˆùù§Ú»bNžrä&5ZÏÙX]=î;º;&ê!€ èI;“än³×Ü äi^§õ«÷M7ÈÜù ìcZ‡R|gË5‹²”3ìäö§¸ž¬eóf掋¿ÇAÚB+s—L| ŸÜ3Ì],ô—ʱšñøË¯¿Aƒ PJcÌ›ÔwßûPfþ2»”zPr§u5VW—\O9 € Pú>]nYúã € €À±&¡+dŠ«±ºz;G 4ì/Ï>ý„K{]lô@(‚d%ÂÌI@(ºÀ7_}i-[4/z#…©A²Š¤‚ù ˆ°µÊ—//wÞ~«Ô¨QÝëçs·Á ß¶c®[·Nî!Ú/ýÙ¯³~ÊýÙ '/Ÿ|ø¾ýÙ¬Ÿ¦Ê O?’.Ï´;Üô˜y¿Ï–'Œ“ž{ZªT©\`7ŽvŽŒŒ”aC>·ç¼ñúkÝízPÖ{ðþ{­Cûvmƒr|¾”?<—|56ÚE@ ’ã¬2&@ÜÈÎΖs{^$]»÷ÔÔT{Ô¹gw·A²ê‰‰n¶â~µ¬¬l÷+»¨Y¦LùøýrJÇö’º¿¤g¤K»¶m¤AýzùŽ\½fü·v­TªTI.¹ø"yãÕ—ÝêÃ÷Ým“?Ïœ%C†~åÖ1îVÊÉq·¦oêeg»ß ¤ö8çlÙ¾c‡Ì_°Ð7 ÒV}ù\ R2†… €@© $+U~NŽ €@ñ4 ìå—^Ù?O]Î÷Ñûý¥~½#¢Æ'œ #¿ùJæÿ1Ûd]½'×_{u¾¥v¿ý2CÍûÝ.…ìqî9¢Á!-C>hëvê)öïzžWûö‘_gN·Y[ï¼ùšT¯~$V±Béûâóö1íLj¯‡J³¦Mr˜‘‘!çõ8G¦No3¿´Ï±±±E8áøãlVزå+äìó.”³Îé)]r¹Œ36_›Ï¿ØW®¸ú:¹þ¦ÿÙŸ»“‘×ùÌ3ä†ë®•Í[¶È3Ï÷ÉmÏ™Áwß ³^ÝÏê–[ÿƒï²¾äâÜŸi`²«ÉvûiÊ$ëp÷·çkßÙ\vëÚE¾ü™k‡÷ô“Úµkå¯î¯½ü’Íøš4~¬o-M›4ν>þ`€˜¸—Û¥Më“íó§)¹Ç;2þêÕ­›¯+zî¡_ ’sºŸU ma6®®ë›o¼^&þ0Æ>4ñ±Grk,ΞKnãS@JT€ Y‰rs2@¼/ Aˆ zž'û÷§Ê† mPkÐÀ$>>ÞCÂLæT_Ñe‰;w&›`Guyèû íDBB¼¤¥¥ÛÇgý2[Fý^¶nÝfÿÞï­7¤çyçÊsæÊŒ™3¥K—ÎÒÿ·$<üÐÛ‰ï¾-^ÐSÒÒÓdå¿«lF׎I¹çŠ0Ë9Ÿ~â1Ù´i³D™¥ŒÚç®»¦È :-5kÔ0Á¸¦öûÿÖ®“Ý»÷Ó¦ž»Q£†öç6ntzÎD“A×÷Åç$33SyìIÙ³çH{Î ÆŒýÁ¶{V×ÎöϲeËJ‡ömEƒƒS§Y"ªsòô“Û~hÛoí-gžqº=ÆÙ\êã fN+›Œ¸Y³•å+þ‘3:&/÷y!ßx:vh'ýÞ~CjÕ¬){öîµóóÆk/Ûk %%Å,£­aö";´s5={œk«L˜8ÉUUûø§w’ûï½Ç^‹æÜzÍägg†sçÍ·mh`NËÉ'$ÓÒÌ’à vém\\YiÜø{M®[¿þ˜þ4o~¢<óÔ²~Ã)ksymµòÑ6ú3gýÑ`£.5-[Æ w&'ç[†ììXgÏ%· ©„ €%.Yâgä„ € à5ãM6•ŶmÛf³¨4 ðîÛoJ×.gš TùkéßÒ A}Ù¶}»\té&–&Ÿ}ò¡]–XPùfÄwr¶É„Ò ­Aƒ‡È¢ÅKl5ÍFÓÀÅŠVÊ;ïÊNªR¹ŠœÞéT «o³Ð4ûGW›óhÀN³‡8{ Öô}åuùaÂD¹ìÒ^òÜÓOJ Ô(jÙ¸i“ ÿæ[¹úª+ä롃m êwØì¯¼E÷suz6OfXAç~ìál€qÊÔé²ôïe¹U\L˜4É ï•ÓM(**J:vŠDGGËô?ç ´iì±'Ÿ–ßÿ˜c—µê— µßÎæò«á#dì¸làRÛÐ=Ó4£L3ã4˜••eûªËJµï¯½ù–¤$§H ó¸f]%%픋/»Ò,«= Ÿü87åÌ_ÇÑý¬®²fÍvîÝ)zÌÓϾ`çY3èžæ)i~b3·®£9sçɽwß)­O>I¾ør˜Û„ “l;-›7—”]»làMëTô1‡mï›o4Áº»­íÌY¿hãjNìq;’’ä½>²KNuNÝyNö\rÇ: € P:ÉJdz"€ à†&@¥åÏ¿–Ú™–?æÎµA²ãŽ;Îìãþ%´ßë¾R…É ëÔñÇ5²51Y<ºŒ/oÑ þ+&€[L IDAT,Z¼ØÈ´ä 9êÿöÇöÛÿþ[kÿŒ‹‹+–ëo¼% -’{îºCÎîÞÍ™nê}›ü³òH@Gmt¿µã5Í»Þd¯=þä3…ž·J•*6ûK 5l(º§™WZo†Ù¿L—ûiÆR×Îíq&N>æ\KMðR‹#PV³f q5—Z_3À®3ËekתiqŽcîLêØSN¦2 Ši©eÚÖ2áÂܹь-G¶–³ Ð ¨ ‡~5Ü£yrÌóZ“Ù—wž]þþû²Ïì/wR«–¢Ëi5slêôŸD—¿¶4AXGV_aA2=—ÃvþÂEöÜj›·äµqÕÍšÔŒ5Çuÿý¸ñ6X–žžîÖõà•@@ Ô’•úÐ@Š.à”äÝ^³Œ´h Çñ}ÞÇuaQËâ%š Ÿ¡ù_¾b…É(;Í­&÷íÛgë¹ÚÛ*Í!´”ËD+_®\î¸òžL³¦¦OŸa÷ŠÒ¬²{ï¹Sî¹ïÁÜ*o¼õŽ "êÒÆ)ÇÙMè~ö¹¬Z}(øutyü©gLä8»Tï©'•Þ·Ý™¯JaZiÌØq6HÖµK›I¶×ŒW—­]Â#-QuÌ~ïj.5«êÙ§Ÿ°ËIuŸ´õë7ȧ¿/—¼çØk–Y]rò\®üÇžwîᥖ“Ž ô9›pWó\˜a–Ùólá¢Åv©ã.¥Ë–/7‘Kí¾jŽ@ cYfA}ȵ=ü`Þqë ²)¬?Z÷êko”o¸N®¹úJ›©óp¹ÙãÎQœ]ÎŒx @üO€ ™ÿÍ =B@Àmþý×ÖmÕ²¹Äšl"Í&ëØ¾ý™ÞÑQ÷ÿÒ¢Ëu™˜fÀäÝL¿ 9–í•/_>÷áU«WÛï5kì·ßçäf¥é^Qš­¦ûOiÑ=¤4°¥Ù@ú˜=òîéåîÀtyßqf1ÝãL÷ÞÒ@G¯‹/<4®ÃYh4Ñ * Rèy~6Kê4H¦{QTbÌøu‰¢«¢û]ÍúåW¹ðüž6ãNƒj“~œb‚jÎ ´]Í<Òã5 M3°ti¤š]Ú¶i#Óš!§žÒÑ>´iófq5—º´VˬٳÍþX?ŠÎþ\•M›-?=©eK»,V÷œÓL-WEçQ÷J[b²7nÜ䪺[»c8wî|$;Ó,[Õëj׮ݶ7\­²Æd²51>-ºŒÏÕ\:nNpš™km¿“YZîÆm*ÿ2s嘛Ñß~#©Rí>r®ŠúkPm‚‡YdÎÚuÇб”²±±ùñÇ©¶9Í&³KzÍ—îõ嬨íjÐlÒäÐUÇ~è† WýéqNwéý¿›dÉ’¿$;'Û€u²d³×›î¿çêz(ì¹äÊžÇ@@ t¸»eé¸sV@<hР¾ 9¾YQ?ö„Œ5ÆdP••zõê‰.E»í®{lðHƒ6O<õ¬Í¾Ñ½©’Ì/øš¥EOݰ_3wâÊÅ™»a&šLžCûš=ôÈã2â»Q&PPÎfWÕ«W7÷®ÚÖ­wÜmƒi‘v¹¢gt£ü¢”eË–Kï[ï°û§EDDÚà„öé¾É]¾¨×¥Œz7M €m6ÙXo½Ó_¾6›ùç-ꦴkàkò”©r×½änrï¬oS§M·AÄjÕäŽÛnqiàhkÆÏ3í·:v]:xtÑ1}:pÔ©SÛ-?úd Ìþõ7[ÍÙ\Λ¿@†ûZ¢MöÜõf_2 Tê\¹*z èlº¼41±š šn–'ŸyÞÕa¢wµÔL¨)S¦¹¬ëIgב¶³ÒdG:6è_ú÷¡½Û–-_a—¦ê—³¥–šµ×ÀûözØo–º¾ÿáÇvß7gÅYÖ˜€ês£ƒn];ÛåŸ Ì>g÷›kб„ÓÕX {.yâE]@(9°f-[ç”Üé8 €”´€òîËôéGïÛŒ0ÝkK/ï <þèÃr­Ù¿J$ýßûÀ»ÓZ¡s~iï¨Ú¶c§—¸B‡ €¸`¹¥+!G@ À&ÿÞfçè>NzwG é20Íô¢xOàÊË/5{¨o÷Ó=Ù†•Ñæ½3Ñ’37V ˆ €(@Œ @ X¼d‰´o×Îlço7A×=»ú™%iºÉ?Å{UÌžiºßÛÒ¿—Éko¼åtsyï•–@@¼%ÀrKoIÒ € € € €@À °qÀNG@@@ð–A2oIÒ € € € €@À $ Ø©£ã € € € €Þ Hæ-IÚA@@@X‚d;ut@@@@À[ɼ%I; € € € €+@,`§ŽŽ#€ € € €xK€ ™·$i@@@@ `’ìÔÑq@@@@o $ó–$í € € € €¬A²€::Ž € € € à-‚dÞ’¤@@@@€ H°SGÇ@@@@¼%@Ì[’´ƒ € € € °Évêè8 € € € €€·’yK’v@@@@V€ YÀNG@@@ð–A2oIÒ € € € €@À DlÏé8 €@‘¤V­šR¥re‰ŠŠ*r;Xré’œ´S6nÞ,999ž8Ìü4>¡¼”‰-+%×9ÎT$¬¬,9pà€ìÙ³G žQž«E¢-µƒÜzžòïo©ÍOQNìΜ¥]ŽAðO‚dþ9/ô ð©@íš5¥\ùxY·a“dffúô\4îÈÈH©‘XMj™HØÆ› l4>>^¢£cd÷ž½’íÓŠÏÂÃÃ¥\¹8)oæMež«>ã÷IÃî©ÄÎ[Ô1LÎ<½SQç8‚Fà¬sÏÇ—êª+.“[þwSÐŒ/¢±¼_¥1fþM- uΉ à‰™džhQ"Ò\îã–ýý÷22ô+YPŒÌ²Þ7ß(×_s¥œwÑe’ššêõÒðKÓÊÓÚÆýÎú[«f i×¶TªTI²23e×îÝòÛïsdGR’T6? ·ß{ñ‡ëÏчé?NÈåÖ@™~ ׬²AƒÝš†† êËÍ7^/m[Ÿ,åãËKJrŠÌšý«¼ùN·Ž–J%=§WíÝv ]A?+Ì·ß›¯Éi§v´§gdÈÖ­ÛdÖ/³eð—ÃdïÞ}M‹»ÿ¦žpüq!ËWüãQûTF@ 8ÉŠ£Ç± €Å°Á²×^–¢Ë4Hrñ=eÕšÿ¤Ç9ÝeÔ˜ï‹Õö˜è9û¬n²`áb™8iŠDFEJ•Ê•eß¾C¿·jÑ\¶nÛA2ÿ™•C=évNÏÜ.iÐìj(Óâ*PÖ¬iùè½weâäåîû’mÛwHÍÕ¥bÅŠþ6Ä èÏ7å=‡« ÙÀA_È@³Ä¶|ùrÒ¨aC¹ý–›eÈ Oä¦[î”={öxt.w*_ÍU²øÏ¿’¹ƒE@¯ $ó% !€%PÒ™ ÎtÁ²¥KMfÙ°a60âN9½Ó©’²k— ÿôC™ñó,ùò«á¹í¿ÒçyÙ·¿¼òú[îtµäêÚÆý…ô766F"##åßU«$==Ý~­ß(Sð”Ží¥AƒúvN[µlnæs©,[¾BbËÄJÇöí¤v­š6eÓæ-6óÌ‘axåå—ʼ ä¤V-%>>^vÎLÛf‚mþ\üṚ·õÇÊÌ\ôy¡”>t¿Lûi†¼öæ;¹uVþ»*÷ûŠ+È÷Þ-Úµ•˜˜h™;¼õ΀Ü@¨³çëàÎsÔþ³âû'ê•{¶ZÛñÕÅñýÑ“ãì±¼umö×á¬Ú={öÊ¢ÅK侇•¡ƒ?“®»ZÞûàc[]çð±‡4 ­%Ód~ÿÃùdàç’}¨¹Ãÿ¦êøÕ}èþ{¤k—3¥£y®kû_nÿ}wÙ¾??‘é €@@°'Y@LDBC yófòæk¯È€wÞ’6­]ïYvù%½dôØqòÇœyW6ÎLZõ?³´ëáÇŸ2Ù/çËS§É;o¾* &@â(®×zß#šŒ5G)W®œœyF';îØ ÍCc¦|?JýE|»É4êÒùL©\¹R¾þþÇ\Ù¹3Ù¼Êð#m€LK7S7,,LFŒ-_}ó­“ /¹2÷OÚ/yΈ  É‚a ‚uëÔ–¦MËä)Sm–ÂØÆËe—\|Œ„ÑtÿÍjøjø¡ÀI»vmrë¹z\+jö‹ÖZŸÜÊ×½[Y¿acnp&ù}>dÍ4?i²lÙ²Õ.»¼´×EÒ°aƒBÏ[®\œÔ4{˜Í™;O2Ì/ï™™ò‡ù^3OªV©’{œîo´oß~{Íü¹ôo{]ÔªUÃçã Öt=ûué«£DEF™½çŽ Œêuàn]G[E9¦tT8+ €@ $ äÙ£ï €@xÓ!ëò¸ó{ö{|Äì3¶1Wá¹§Ÿ‹Ì²È/¾ü*÷gU«É"Òe[5ÌFáyïˆèêqGCšÁòõÐÁfÃêÒ¤ññòУO‰~ ó֙ýäV›4h¶˜É޾+fªYâ¥ó«1ÇdºÙll¬¹ûÞÞÜAÆå ˜é2Ìrfr_Ü5TK´‹É))vþzö8G–š»Ú]’vî´ûÈi Äñü,cö˜Óeš›·lqëùÌs´D§Ô>·t9óT³äRK²ÉÓrYfé¸ÁFa=r§îÑû¶¹sLÉ p6@``¹e0Î*cBD@ƒc>ñ”Ùú·7ëסӽ›]†7oþB›¥àøÒ_’{]tè]/Eÿž˜XÍþõúk¯¶¿ˆÏ™7ßíÇuxÍjzò±‡å'³‰ÿž<—á¨n&$ÄÛe( —*U*KÚµs³Rš,#G†‘Çöš=Ì6›k¢Cû¶öN˜‘Ò¾m›™”7›°I“$Î,ÍÔÒªE 7Çnܼ9 lJ£³?M™(úUœ¢Ë'/¾ð|¹÷®Û¥~½º6È¢Ë,kÔP6mÚ,ó.’{Í 8tÎ5~Ï·ËŠVÚàš;Ïgž£Å™÷M0Ë+Û›%ë½×O2Ìr塇oh¢KÐÿük©<òà}¹û>jÐSçúèâN]}Þ6>áx{¨þ›îÎ1 €,@&W ª%pwµÂh—š½ † ýÊÆI_÷«ËÃÌY¿Èc< §vh/³ûݶ=iòéÿÖë6P¶aã&yè‘'l@ÅQ\=Ö9rŒô{ûu³™ôÀcÎ]¤øä ßß5Ï»Ý.¸¿‘&˜ÙÞÜåP÷‚ ½ûöÊò+dÙ2ݤ?Gš€eg³üRï|·hñŸò×Ò¥‡6?¥ƒ\sÕ’m–îi uÊ´éæ29|g=säªU«¥‡Ù¬_—gêÍ~œ2MÒÓÒ¼;$o·VŠÏUJ×î=rGôÓÔIί}'}ýÝÜiôÞû–›ÌÍ4.6Áë²eÊØå”_››,è¼<ñÔsò° °Œ;ÒîiµÐÜåösÓ KwŸ¯ñÕK¸„Ÿ§ÎÎçf_nýßMrËÍ7Ú}ü6š æÏ3ÍÍ]'u9Gyô‰§åþ{ï’Q#†ITT´$íL’?(k×®;ò¬8|‡KWu}1DžæI™6ùüÅP³§äó¡Ší{ûùG{ €!%@,¤¦›Á"€ùO)i%þ)ƒ‡|%úgqÊõ7ßZàášÕpöyå{lÍÚµråu7z:g_|ùÕùŽ+WÖd3lf_²þ×m.íWa(¬¿Ifé–fV¶ïØ!ßš»Žæ-Óš,¿™N­tÿ¤ïFuÛÓ*úÛœÚ@Y!ÅU_ç™À¸~TvíÞ-Ͼð’SrWÏç@xŽê]9yûº‘PøÍ)ÜéË?îV—’Mö×ó}^)´nÞS]Õ]ú÷r¹üêòµåê·:I%@œ$ãò@(1wÑòf‡Âtã)'ÅÕãŽCuo¤»ï¸Íìw6Ìn8M)Xà‹¡ÃüœÆùõàç/õîuÉ“UVqö|å9ZðŒ\±ûÈžn¥1gœ@@ HH³E_@JEà‹Ï>1ûaÕ”ñ”±ãÆ—J8).Às”«@¼!@ÌŠ´ à—G/—<º“®wÔ¿é–Ûýr|tÊ3o¾éÙÔö+gÏWž£~5Ut@€àî–;ut@@@@À[ɼ%I; € € € €+@,`§ŽŽ#€ € € €xK€=ɼ%I; €@À p‡Æ€›2:¢Üyž2§Á7§5"z‹ @Œk@àý=;Û,ó⣒À¸:L %<<Âeð+Ç@5XÆ´úÿ´jXLƒca…=#à¹êÿs™ÛC7Ÿ§ÌiðÍiˆ®"€8`O2.@ D4Óˆl£à›| ¸œ“|c •ñ\ ¾™fNƒoN €@p°iIpÌ#£@@@@(†A²bàq( € € € €@p$ Žyd € € € €Å HV <E@@@6îŽyd €€GzG½ê‰Õ¤ZÕªãѱTF@ÀWiii²cûÙº}»½SoAE_ÃjÕª)U*W–¨¨(_uŶ›ž‘!ÉI;eãæÍ…öǧ q@ HV¢Üœ ðêÕªIl™X™3oޤLóNÑ @ˆ‰‘fM›IbbUÙºu{µkÖ”råãe݆M’™™éS³ÈÈH©a>Tªen¼qã&Ÿž‹Æ@J_€å–¥?ô(qªÕªÊòåË•¸<'Dp& ÜèëSµªÕ ­V©JeÙºm»ÏdÚ Âé¹*›¬5  €@ð IüsÌ@cbÌK2ȸ0@üQà  ”éëTa%Ú,±Ì0Ë Kªè’K='@ øÈ$ þ9f„ € € € €.È$ãA@@À(dSÿÀ½Eð7‚dþ6#ô@@œ |ßKÐ@(žA²âùq4 € €”¸a²'ç„ €@$ Ifˆ € €“«-ƒi6  à?lÜï?sAO@@@@JI€L²R‚ç´ € € P4©dÓ&wÙpZZš<û|Y¸x‰ËºT@ 2ÉBcž% € €!%pÖ¹ç‹~iÑ?/¿æú|ãâéçäÉ'•Ö'µ )‹ P¸™d\ Òçõ8GêÕ­k ÒÓÓe÷ž=²hÑY½fMH»0x@üZÀMÉòÖÑï:毿–Ê+¯¾.}û¼ =/¼Ä¯‡Kç@JF€ YÉ8sðcyóÈü %::Z6h ÝÏê*)#wIrr²÷š®!€ ºîÜÛ2oýþècôï ÿ)111Ç<º²Œm‚d¡=ÿŒòh&ÙŠþ‘ŽÚI•Ê•l¬L™2rz§S¥V­Z’“-ËWü#sçÍ7Fz«}íÕWÊ\dkÙ¢¹TªXQöïO•ßçÌ‘ÿþ[k¯S§¶thßNÊÅÅÙ¿¯üw•üöû¹ÇÎ_¸HNjÙBâããe×®Ý2ãç™Ò¬YSiÔ¨¡ýÄ[Ï÷Çœ¹¶¾«¾0™ € P°Àô'@ƒ €€K‚d.‰¨€¡")Ç×HÂ#"dËÖmvØgwï&{÷î•/¿&1±±rÑçKjjªüµôï\–6­O–É?N‘V˜°U IDATÝ»÷H³¦M¤[—Î2xÝ—’e‚jzìÏ3gÉÎÉR¾|y¹â²KdíÚu²yË{|«-dÂÄÉràÀéÖ­‹\zÉÅ6ˆ6Äœ¯B… r™ùûš5ÿÉö;ÜêK¨ÌãDmW÷«Ž£N׳Ïs‰åN{.¡ €@À °qÀO!@â ´mÓZn¿µ·ÜÚûfi~â‰2vì8ÜÒ쮚5j˜L®y6à¥Á±V®<”å•§,[¶Üféì&ó+**JâÊ•³5ôçII;íc{Ì~gÉÉ)¦Ýò¹G/[¾\öíßoÛ×ì³ôŒ Yú÷2É6×L¶””]’P!Áí¾ׂã@@@P “,Tgžq#€@®€îG¦_ Ô—ÎgœnƒaZâÊ–µ^Úë¢Üºááá6¨•·äý»»´D˜zZ5l(M›46Yh1vùdE³$SÛp”T“Aæ(ÙÙ9rðàÁÜO¾õçÙÙY¦­·û´"€ €À!(³×èãÇ:åp'Ë O@Ð H:sÍH@À…€frµh~¢´o×VfÍþU<Àúvä(IKK/ôè–h$&&J·®eì¸ñ²}ûv{üe—öÊ׎»Ë;Üí “Œ €ù „ý4e"T €ä`¹% Gà×ß~—¦f_±ªUªØ¥’[ÍÞdN=Ubͯ´ÄÅ••Šf¯0wJÙ²eDo°sçN[½ZµjR¹R%w=¦NqûR¤“r € € Bd’…Ðd3Tp- ì¯øg¥œqúi2ÚìM6yÊT9µc¹ÆÜÅR—I¦¦9sçIÊ®].[¿n½lh°I®¹êJs×Ëý²#i‡½»eQKqúRÔsr €~)pø.ÓNûæ¨ã¬®;uü€N!€øB ¬YËÖ9¾h˜6@üW ]Û62mÚTÿí =Ci³Îê.óæ/(Ð@_Öüuä.ÓUš1u’G~]º÷pZ¿U‹ íG'¢2 €€_ Iæ×ÓCç@@@ÀSWA/OÛ£> €@h°'YhÌ3£D@@@p"@ŒË@@@@ ä’…ü% € € € €A2®@@@@ Hò— € € € €ÜÝ’k@@L 'ÀúKw@A€L²@˜%úˆxY --Mbbc¼Ü*Í!€ P|Xóú¤¯S…•ôŒ ‰ŠŒÑ8Y |E›s¥g¤`´€ à÷Éü~Šè  à}í;¶K³¦ÍD¡ € à/úºÔ´I3Ù¾}{¡]JJJ’:µkJt” ”ù¸è9j›s%íØéã3Ñ< €€?„5kÙš\e˜ ú€” @XX˜$&V•jU«IL ²¤çT €N4ƒLd۶‚MÉÎÎ6A²ZR¥jUŸÊ4ƒ,i{’lØ´IÂÃÉ/àâE‚]€ Y°Ï0ãC@@ Ȳ²²$;;ëÐrK_–01Á±‰ˆˆðåYh@ÀOظßO&‚n € € €€{´"påžµ@Ü gØ}+j"€ € € €©A² X†… € € € à¾A2÷­¨‰ € € € ¤É‚tb € € € €€ûÉÜ·¢& € € € €@ $ Ò‰eX € € € €î $sߊš € € € €A*¤ãbX €EˆŠŽ–ФBB‚ÄÄÄH´ù{xø¡ÏS²³³%==]ÒÒÒd×îÝ’²k·d˜¿S@@@` H ³È@b ÄFÇH­Úµ$!!^öíO•½{÷IrÊ.ÉÈÈ”ìœÛzxX˜DEEšÀYŒT¨PAjת%»÷ì•6JZzZ1{Àá € € €@é „5kÙúÐo?@¨\¹’Ô«[Wv™Ì°ä”ÉÊÊvË!Âd˜Uª\ÑdÅËÚu$99٭㨄 € €ø£A2œú„”@š5$±Z5Ù¸i‹Øh?)i§Ä——ˆ0deeɺu륲Y&Z±b"¥/ € € Þÿ-&P àMk¯¾R4¨ïÍ&m[åââäÎÛo•˜˜hÚ®T¡‚Ù7l¯dffy%ƒ,oZ¦ díÝ·OÊ›Œ/_l´¯²MfYg]s7ΰ°0ÆMe@@@À™A2®9¶mZË-ÿ»I¢MF•§¥JåÊR­jUOó«úºdqïÞý>ëÓÞ}{%!¾¼è¾b¾(ºô23#ƒl2_àÒ& € €!,@,„'Ÿ¡#Šz×ĦM›Ø»$ÜqœtR+©Z-°ƒd1±1rðàA¯g‘92ÊL3ÈŸÉtÒvïÞ#•*Uôxþ8@@(L @ ”êÕ«k7«Ÿ¿`¡œÒ±½ü½lY¾áßÚûf?q’l1wSÔ¢KÿwÓ2tØ×ÒªUKiØ ¾Ù8¾¶´6Á²…‹–ØãË”)#§vì µk×’ÈÈHÙ¸q“Ìþí7Ù¿?õZWuµííÛÙ¥”ZVþ»J~ûýû½î#væé¤jÕ*’žžnο¸HS%æn”¾*™é“å–Ž>ïOM%Hæ« ¤]@@BT€ YˆN<ÃF TZœx¢,[¶\6lÜ(gDw’5ªçÄ\™üúÛïR=±š¬Xù¯üý÷‘àZ÷n]Eƒ6ÃG|gC§˜€YÏ=ä»Q£ ¹ª«{…ýÏþ}EÛ|?++Û¶é«õÃÃ#LÛ¾ËTSTÇ ”¤ € € €Þ Hæ-IÚA¿нš˜à“î7¶Ûdk9J×.mPʱ¿—fiå Œ•-[Öé¸48¦§¸¸²¹2 àÄÆÆš;HîÍw¬«º‰‰‰Ò­kg;n¼lß¾Ý{Ù¥½ìŸÄÓóä èé9ŠRÒu©¢Ù3L³Ñ|Q´mÝ—ÌU€±8çŽ3{¶¥8vÏ·â´É± € € Ú,· íùgô„ŒÀñÇ5’½{öʦM›eßþý¹_›ýÉš™»]jV™]î¨ðkÑŸµhÑ<ŸQêƒR¥råÜÇuIä¦Í›¥c‡6»Iƒq;´3K&“ìRɼÅU]ÍÓ%›;wî´‡U«VM*Wªd¿ßcú®Ë0Ûµim³ßô\­[ŸT¤ùKKK“Ø“©f—Czÿ+66ʎé¯;ÇQ@@(L ¢jbàA‚] ó™gÈòåÿÈŽ;ò uÏîÝr²¹SeJÊ.Ùm¾×@T«V-lp¬ñ ÇËæ-[¤zõDYj6ê× , Vµ4µk×Öî¶uÛ6Y·~ƒÔ­SGÎ8£“´h~¢Éú:(3fÎ4¢ {.­¿ÁÜñr×®]Nëî5·J&(vê)å¸F MÖX¬¤˜ Ý~ÔÛ±#Éã6I“ÆÍãlöÛâ?ÿ’úõêÉó§'ñë¦ÿåËÇËn3_”jf/¶½ûöÉAÍ~3=oÍ"K0wú\·n½·›¦=@@a°f-[³³r_ BO@ï ÙÂܰ`ùÊ•&¸–íUÍrkj‚‹ËÿYaö%˱YoÞ,ÚžîÛ¶ný:ؤ € € €Þ`¹¥·$iÝð>ÅdÍU«ZÅë=N4mî2mg˜,:ÝCÍ›EdõêÕµËQ yS–¶@@@@¼û ¦ €!°É,ÿ¬T©â1wà,NçuOµŠ•*ÈV³DU—Y:öy+N›ŽcË•‹³dzÓ… 7z£IÚ@@@ò pwK.@ Ò3Ò念ë¥~Ý:²jõ³ßZf±¢¢£l[ë×o´{·é’ÎâÍBÓ›ØýÇÌ&ý‘&èÆËâˆr, € €¸`O2WB<ޱ@¢¹)AÄDYkn>šz H#Õ 2 <˜&áaæ†e$ÂÜå³8%3+Ë,ÙL—Ô©ö.–º¼Ro”@A@@|%P¼ßb|Õ+ÚE(m[·IZZº4¨_W’SRdûönoæ¯{„é,+™%–«M6ÚNsgÐsçÌðpïnÖ_"œ@@B^€L²¿@D¢LæWíÚµ¤b… ²gÏ^Ùm¾¦¥IFf†d¾fx„Y%ebc$>>^âË—“d³Gغuë%=ý Y)a^Þ¬Ÿ¹A@@JJ€ YIIs@ÀßÌrÆèªX±‚Ù€¿¢ÄFÇH´ÙkL7á×’m–@¦›»VL;h–?¦ÈÎädI7Yh<‹Ðì±°0!ýC@@ `¹% €À!äÊÊÎ’¤¤$Ù¾c»dgg‹˜mÀrýßá:¦šùOÿçØ\Ÿà € €ƒA²`˜EÆ€xSÀËÂÃØ[Ì›¤´… € €þ/îÿ]¤‡ € € € €øV€ ™o}i@@@@ ’À$ÑE@@@@ß $ó­/­#€ € € €€A²˜$ºˆ € € € à[‚d¾õ¥u@@@@ H“D@@@@|+@Ì·¾´Ž € € € É`’è" € € € €€o’ùÖ—Ö@@@@@€ YL]D@@@ð­A2ßúÒ: € € € €@$ €I¢‹ € € € €¾ Hæ[_ZG@@@‚d0It@@@@À·É|ëKë € € € € @,&‰."€ € € €øV€ ™o}i@@@@ ’À$ÑE@@@@ß $ó­/­#€ € € €€A²˜$ºˆ € € € à[‚d¾õ¥u@@@@ H“D@@@@|+@Ì·¾´Ž € € € É`’è" € € € €€o’ùÖ—Ö@@@@@€ YL]D@@@ð­A2ßúÒ: € € € €@D¶kÛ&ºI@@@@ð@X³–­s|×<-#€ €¸Ð-çÍ_àª#€ €øP€å–>Ä¥i@@@@À HóD/@@@@|(@̇¸4 € € € Écžè% € € € €€’ù—¦@@@@C€ Y`̽D@@@ð¡A2âÒ4 € € € €@`$ Œy¢— € € € €> HæC\šF@@@ ‚d1Oô@@@@À‡É|ˆKÓ € € € €!@,0æ‰^"€ € € €øP€ ™qi@@@@ 0’Æ<ÑK@@@@ $ó!.M#€ € € €†A²À˜'z‰ € € € àC‚d>Ä¥i@@@@À HóD/@@@@|(@̇¸4 € € € Écžè% € € € €€’ù—¦@@@@C€ Y`̽D@@@ð¡A2âÒ4.ðÔãÊŸ çæ~}9ø³å*_¾¼Üyû­R£Fõ=/'C@JS ñ 'H¿·^—S'˼ßgËø±£äž»îؘ»åË÷S¾lÛãr„ŒA²™jŠ€ Œù~œüù×RÛ©7ÉgŸQ¢<÷ìî6HV=1±DÏËÉ@@Òh~b3úÅgÒ­k‰‹++;’vHíÚµä¶[þ'Ÿ|ô¾D„{öë¡/ßOù²íÒòç¼ àÿžý+èÿ㡇  ËWü#ã~˜`{ûûœ92ë—Ùöû9¿Î´Ÿn^wÍU2}ÊDùí—òjß>R¦L™Ü‘i¡_ ’sºŸ%?M™$¿ÎúIî¾óöÜÇ5CmÑü?rÿÞ±C{›±öñìÏzœ{Ž/Ëê5kÜ:–J € €@ $&V“°°0Ù»oß1þ»j•RÍš5Ýš«÷S¼Ws›’Š àGÉüh2è øíCË%×®=ÌŠ‹‹;†géÒ¿íÏæ/\tø] @@œh ìè&Çþ̈¼Wó†"m €@I $+ImÎ…n ì3ŸrjÉÎÎ.ô˜ðˆCÿ„9ÞÖåääX· ›Û¡" € [¶n}¯TÎ|ðX³Fþ?þ8;ÂM›6:Ò¢¼Ÿâ½Z\8  HbÎp&¶mÚØáthßÎþ¹ñð»ýûSí^fÕªVµ??ù¤–Ç ;++ËþLo/NA@‚]`÷î=²xÉŸv˜?xŸDGGÛïkÔP.»´—ý~ÆÏ³r¼ñ~Š÷jÁ~U1>‚O 2ø†Äˆ@ š6i,œh±S:t3:&³fÿêQ×_}¹¬^½Zš4ibûýöÏeË—K»¶mä½þïÈÿ­•sÎé~L»zS ®½øÜ3²nýzöõ72múOŸÊ € €@ ¼ñV?üÙÇÒý¬nrÆé§KJJŠ8ö*[¸h±Lœücîp¼ñ~Š÷jtuÐWP2ɸ@ Tz]t¡´:|‡¥ÚµkÉ-½oö¨éééÒÀûR»VmÙo–fêæý¿ÿ1ǶÑ÷Õ×í'¥ êדƗ'ž|FvíÚ¯}ݰÉŸI\¹8©^=Ñ<¾Ë£óS@4¿—-“ëoºE¦ÏøYö§î—J•*Ú»‰úÙçrÇ]÷æÛ梸ï§x¯hWýEkÖ²uÁ›øàƒø©Àœ_gJ™2e¤mÇN¢oÀ( €@  höë¼ù }ô°¼WãB@@ “,PgŽ~#€€¹9 € €þ*À{5ú…… $ãÚ@@@@y6îùKO Ãig^§é1 € "¼W ‘‰f˜¡™dA8© @@@@À3‚džyQ@@@@ ’á¤2$@@@@Ï’yæEm@@@@  H„“Ê@@@@< H晵@@@@‚P€ YN*CB@@@ðL€ ™g^ÔF@@@B‚dA8© @@@@À3‚džyQ@@@@ ’á¤2$@@@@Ï’yæEm@@@@  H„“Ê@@@@< H晵@@@@‚PàÿìÝxTE€áR€!E@¥‰âO•*½ b—¢Xé½*ˆXÞADE¬D¤7±J齆^Òþ™]$ÙÝlÉ–o|övï;óÎÝ={f† ™*]B@@@pL€ ™c^ € € € à‡ÉüpPé € € € €€cÉóâh@@@@?ñÃ>Ñ%@@|R ]ºtR @~É!ÁÁÁ>Ù €€'®ÆÄÈ©'åÀ¡C’à’K$s #• € € à¼@üù%<<\Ž?!qqqÎWH  à2¼yóÈÖí;]V!€€s!!!’;WNÉ$ràÀAç*»~6Ó-]ÂH% € €8/5k„DŸŽ&@æ<%5 €ø¹@ll¬9zL²gÏ’Iæ2å“f2 IDATJ*B@@À9=Å’ 2ç 9w ¸jJ—;ÛHÝ’€žrê².“Iæ2J*B@@@ðU2É|uäh7 € € €€g\´8¸gÍÕ@À^2Éì•â8pJ`ôÈaÒ²Eskaaa2qüXy¿o ýú?ÿe+—.”)ŸO’Ê•*&yM}ì²Åó%**ê¦×Ë=þ˜ÌšñƒSíÔ')\HŠ+êt=T€ € à_zÿ<px×=àÊŸ2É\©I] `—€^oeð€ääÉ“òn¿¬ÛõŽ?AJ”*+u<-s~ž+ $Yg|\¼¼Ó³›]×sô f/7•âÅŠ9zÇ#€ € €ø°A2<šŽ€/ ¤K—N>ì×Wââã¥WᆵþŸ¸èÅPÏœ9+ßý0]Ξ;'… J²›“&.%ÿ÷°Ô®Y#Y†¨lÙT0®¿¬X²P–,˜'m[·}ýòå—å‹HÎ;î0çf‹Œ”Åó‘*OT’®;JõjU̱ó~ž%/<÷Œ/2Óf@@7èßUy`À=à]÷€+ßê¬IæJMêBô´Êw{÷½½}»Ž]’ݽ+22«Ô©UK2fÌ(ë7lH²Î‹/I¿ûKßÞïÈ_kÖ¨¬´S·7x`9xðT©Q[""²Èä‰ãÍq_ûÌV™jï¿÷®¼Õº­ôy§§,Y¶L/¹öx¸ÄC2ë§9òý´éŒ( € € €@€$ ¦›xƒÀóÏ>mš±{ÏÞÛ2Èôóo6]^õ¹pñ¢lÞ¼Eš·h%‡I²é!!!& UµÊÒ»WOéйëMÇÝuW^)]ª¤tëù¶Ä¨mupì§Ù?KêUMlĨ12õËÏdŒZß,ož<Òów½ˆ6 € €x±€Î ¢ €€ÿ $óß±¥gxÀŸ«×È 0Á©öm[ËÐá#ojã§&‰^—Ì‘òñÀ!2ãû¯¥nZrêÔië©z*¥þ%æ«/>³>*G3¿zõª|þåWÒÿƒ~òþ‡ËåË—¹,Ç"€ € €ø™k’ùÙ€Ò¼Y`Ê ;§ÖëØ¹»è¬²ªOTvº¹º¾>ï½/Ý»t–Hµ¶˜¥X¦_>÷Bc©U·yT­QG5mf‰ˆˆömZË”©ß˜]7³g¿±S&ß:=,T€ € €€Ç¦÷•TªPÞã×µuÁ/&—GÊ”²u¯{™d^04@øo×.y÷½¤ß{}dç»dï¾}NüþÇ_òë‚…Ò¶Õ[kêÒu®ßðtïÖY f6ÐÙe™2…›éžïõy[þøó/óš^+íµ>Y«¶LöÙéÓ§¥h‘¦½Ðÿ­› 8ÕXNFHÆ/½`¾ Iªèu*8˜&íâ¢Î ôëÛ[¶oß!_}ý­©LgŽ ~Ç8vü¸ÊZeÝEÛù+9Wƒnç#eJK÷^½eƒú%q¹çž|2~ì(Ù¹ó?iÓ¾“sâl· ðeªkxõ+M½ u<+ÕR+®,jIy·½ïSÛî±j¶Ì–­ÛÝÖ.Wúz]d’ú@ÿH#Ôšùã,:x€dÈÁéVè©›·þÒÒ±s7Sïì™Óå•KeÒ„qrß}÷ÉsÏLþZ³ViêÓ%WΜҫ{yèÁ̬“1ê=ýv®òdÃçÔ1çoëhJí~ü±²ÒOÍTy©ék¦þÈȬòí”ÏÍ—òºÝºO#ÔÏ•e+~“7_Ut`­Þ­¤áSõÕ—%n«ãÃëïÑg^l¢¦u>£–T‰–CZß·½ÿ®œÑ÷é¥K—%ñ”áÄíÐÙÓöÀ½sè¹§òÝ}—YûwžÊèÔYŸ?Ξ#Ï>ýÔmÍÐA´}û˜cæÌg‚W¹rå4ÇíÝ·ß¼Oõ{C¿gõÏð¼yòÞT‡^J¥Lé’2Re é÷¤~ŸèL®‚ò›÷O^õ>zèÁâ¢× Óïiý^šøÙÉrØÓîÑãÆK6µ‘ØpµœŒþ¢eØÈ1·ÕwRÃ3fÌ ë?©~þd6¿'Nzz¸^ L·äö@@hÐÐS㊪N‡6ëVnN”E¦qô‡(KÑÌ.\¼ ájªŒ%ë@OÙ´½nÍyõ¡Œâ9õ*p9Z}>t¼ª2Pt@K—ìQQæƒô0•=b)¡*å¸úP£¦^.TY5D/µP½j“-xk¹CMwÔ÷È)•qb)æCµš.•-›õ¹ýê½=eµšòÕ¶MKó]¯©¦rêçjÖ¸ym&=µ«VÍ’9sf3}ìn”K—.Ø\S·Ç2ÕóÖk&n‡½m·§Ýƒ€+ôÏJý³söŒïMu:;,LMa:b” b[Ê‘cǬÖïU]ô¦ºT}¢²Êƪk‚ܺäW©àà›óôTJý>I€Ò²èè3’#{vuÝ`8Oüú‘£G“í¢=í¾z5Æd½õ{÷é?pÈMÿnX*֛ļڼ•4{¹±ü8í[Y´x© ä}"çÕ—0ï Hæã@+@@ÒP`“šîöü³OK” ªÜqGŽÛ‚%zwdKÑÙ@YTàB8.]¾džþ~ÚtÑk\RÒFà€ P?AÞÿ ¿ ” 6AÎU¿ÿaÖ=Ò¥U›&›äÖ2Wep=Rôî’÷Þw¯ôQ»oßZô§zj£Îʲ¦ô¦Cz]±›?TëÅl£ƒsuj×4Ù-z´[×¹*R¸°téÜQºõx[¶mÛn*5üÚ†C:8§Û£ƒeºm·—í°¿í¶ÛÍ8+ ×ß«W·¶´íØEöí»Tîóvi ¦@þåiÃz}Ç¤ŠžÊþ^Ÿ^Ò¢u{Ѿº|ùÙ§·zâäIó>ÑbËd:ƒK¯¦¿ ¹¨Ö3Ö:½n™%8§ßÓI{Û¡‚v­ßzSôF1zÝ1½¡žÎykÙ½g¼ÛïCÑkêuÌ:wh+著]ï`º¥wŒ­@@HC½ë˜žöR©By³ˆÿå+Wnjžž£cºèékA*[ìÀÁ&+AO…+ÿøãÖ5Êt@MO¹¡x^@Oæ2R:wl§6RÈc¦!ê€YËÍ­Y':Øu·š*«‹»›6I»6­Ô"þ¿'™ÍqøðÙ 2Õ^µ™Ù‘[/ÜÿšZ §Z›nÏž½©ê¤Œ=¦é×S<õ´³[KTT¤\TAX=ÝR—Â… ™l]t¦ØN5½ìõ×^½Žšþ ¯§v%UÜÑöTu˜“P5ÕZ|úž\½æo9ª2Å,=µ²aƒ'U¦¤íðDõþÕ™º–uÿ(VÔL‘¿µè÷þš¿×I[µ1€Žé@W›–-TÖðvó3Þò³¡Å¯™÷~ݲÓý­uÙÛîwÔκ©M4†Ž-ó-–wß¹¶>Y⢧ޣ²BuÑÿîìڽǴâ=¶ïBïi+-A@@Àm7nýf«Úò֢׿љ?¯¿úŠYoLO˳dŽY‚Ô.˜o¼ÖLeDÿl‚ÉÑ™©Ýéðرãòï¿Uðmc’Ó&ÿZ½V-æ½ÞìV9lÈ@©V¥²,^²ÌÚæ÷ÞÿPMY —)ª=?|;U^xá¹d±]Ýö´U/ºªšúª§¿òpÜ@¯=öã¬Ù·Ùé]„uúñ²\{M—ë»TZÍs ²R³ÿR» ÏP»E~öéX©«¦K›÷®eLÛ£WSÕœ§É¬éßš//ºtï%ñzcu|Ï·ß5ÁôysfÊwS¿PëIî¸~í›ûfO»ŸV›ºÿ>µ{æS÷h5…R¯¡ÖøÅçoêSV•m6à£÷eá/?ÉϪ]:À=\¯]Æ=åœ D{¨¤}yÁ.¼(U!€ €Ü(Sº”Y“ˆ’¶÷«éveJ—VSe¾¿)øÑX¿~SS÷R›5äH¯ô½xvGÎåØÔ T®XAš6i$o´h•ê Wê®ÌY¾& *þÝäkͦ½v ÈŸ_¾þr’”«\Ýl@ñj×PWýE&™ïŒ;-E@p“€Îb(ûHY¿aC’’[§Ì¸©Tëa½Q3µÐÿ´3 yØžË!Ö¥KþOräÈnš¡7hþz3•Á¹YZL_Ÿ…ûÓx¸< € €@Ú è]Ëôô9½VÍ–$¦Z¦m븺»F¨0sçÎ- ÕÚAó~½}]0w]—z@À;ô&zݰˆ¬júü³ö`¿®M­¦®Ó-wìé9 €x‰Ó-½d ¼ L·ô‚A  $#ÀtKn ¼S€é–Þ9.´ @@@@ÀGX“ÌGŽf#€ € € €¸N€ ™ë,© @@@@ÀGX¸ßGŽf#€ € €xZ ÁÓäz àA‚dÄæR € €¤$'ÁÁÁ¢ÿOA/ Fæ…ƒB“Y ,4T®Æ\uÓ-]FIE € €8'}æŒDFfU2~MwN’³@ü]@Èîº+œtcêCƒES@Ài~æ9MH €¤J€ YªØ8 Ô „ªS«††K¹ôrwºkoÁýñ±²2ö²,޹$1©¯š3=$pß½¥k§Ò«O_9yò”‡®Êe<)ðMæœI^î¥óÇ<Ù ®… € €`º¥±Ý}©¿Ìv÷%$gÎ;äÏ•K$KæÌI^ËÖëno —_ *(XÞÏ%¯¤Ï,÷‡Jú  óÐn–>‹¼žMô1¾Xeìu€l̈¡òp‰Íÿ³grépù𣝵בÁÒ±ÄGÎuÕ±Ó¿ûJ*U(ïªê¨|D@g’ñÀ€{€{€{€{€{À¾{À•ÿ¼“I怦·§¾©`KjÚøú«¯HÓF/HÏÊÅ‹S¹¾yºU“¾–­×ðöÔ¡©1KMÛtY·ŒYåžàäßv÷¤ •®"¤÷ÅSIf” ô±”{üQsù .ȾýäË©ßÈ¢ÅKSÓ$מãEcï®1µÈ"#³»ùï1²Ví:¦˜QæÒ÷˜kGÍùÚ<4îîÓä^@O±L)@féz~•UV%,—GÊU–z Ÿ—¦Ï”ßë# Hs9{ =|°ìÚ³ç&k({ÿÝÞÉú»ú=–æ}Küiܿ͒KyØ Ë{µö“ åƒþ¥h‘ÂòŤñaëÔT½Þ´Ñ‹R¬h‘TËI à{:Ó´üã¹´ái½êÏÙÉ.(*Cp›™dÐz:“Á¦Yu´Ê?.§££eÒä/¤]ë·dÚŒoºlÞ<¹¥G·Îò@Ñ¢röÜ9ù\eAèbIû´õzjúàés5Kmûʇ¦·ûÔò!dÞ• ·o2J®gñ?^fÿü‹´iÕBîWœÿþÛ%Ù²EJ·Î¥Lé’+³fÿ,ã'|&ñññ¦®ìQQÒ±}yüѲ,«Vý!={÷5çuhÛZÊ–)-éÓ‡É_kÖÊà¡#åø‰æ¼™ß-o¿ÛOZ¿õ¦šfø´hÝNNŸ>â½awgÝp «ÇTÈF©ÙðÑãä×ù ¥w¯îR¯N-ÓòÓ§£eÈð‘Éfq:óûêó‰fŒ¿ý~šUéãß“ãÇO¨kŽ’G)#­Þjn¦Aë2ï×2|ÔXë˜}:i²¼øü³*ã-¿ËQcÆÉÒå+mÞ )ÝCiõžwõ˜&uÛ½pöˆyú»ˆ;Åòç[KéµÄÇ&~¯ž={NÖ­ß í:u•)“'ÊËM^Rcñ‰9ÜÖ{V½Ýõ\s¥tl'õ¾®òD%yôÑGLý_~õùyn³~7¼ÿ¨´hüÒ É gÿh^ûð½wE÷3/6QÓnŸ1ŸCšç,åƒ÷zËw?L—šõž’­Û¶§xoØÛO_8Îd"ÃFŒ–_æÍ7Ç÷? k×­—èè3fªeJ?Ìyé gÝÚ5­LY²d– åÊ™à§.‡1Jz¼_³•4|ª¾”ü_ ëñ¯½ÒTz÷}_*W¯-_}ý­ôíó¶¹_tIî^Hé²õ3ÁÆ3-ÛxõjŒ,\´Ä©-ÅÖ{6q{S:v¨º?·ïØ)ãÆO”úO¿`ýÂÑúÓÒ†k#à¬À7ßý ¾šd.\”_,´þýàÁCÎVïÐù?\Bî¸þå…C'zÉÁ ñ::Ÿ6ÅŸ²“ÓF«"€¾)¬¾\ªY½ª”úßÃÉv@Ç*j׬a>Ϻ³IæN]7Ö½xþÜ$kOêù*5ê$yl¾»ï2Óºôèe>øÿ8{Ž<ûôS*àò¯9>Ê"Ó¯wîÖK®^½jz=cÏënì¾ßWÒ7Èo¨5äš5m"¡¡!æCqó·Ú˜`IÞ¼yL€D︣Ö@:yê”ü-©À—%Kª½Ï4l ááeöŒïÍË:Ã$,,L†Že‚(9Ô®}ú´L¹ÓÇ;vcꀭ×ÓÚÈÛ® .FíbfW³Ä'Ÿ>:QMÕkUV¿À÷êÖENœ8iê¼#GóÃbò„qÖk„†„ŠþP®‹Þ…Q§¥&Oý|®œ9Íó–zôs—.]6YRzʈ¥ìÙ»Ïúç@{K™6Ý—(¥¿áèЮµ´ißÙf:°³ï1=Åyɲæ“1Ÿ|*ujÕ4à,Eg >4,é IDATU¿®dÉ’Å<•_¥(ßH>r}ìõk:pªKhh¨ Ò$u/غ‡aÜízƒ:qPî;ï4SÜíyÏ&¾Œ­±IªI©9lj®q*^+pNý,]ºl¹Ù`Eÿ¼|þÙ§ešªqèðaÓæjUŸßÿøS}²@ý¬ŒU?WŸ”è3ÑòÅ”©æw#=•Yÿì´ý ¼®óKõÅaz“Õ]Ïl|ôïÆMòÛªßUp-§lݾC6©_ä=Qt@|Ô°Áf·颳H:d2#TŸÇ)§N6k™¾Öìek,³Ú­¼RÅòÒ¼e[kSkת!í;u“'OÊÓ*Cyè þòôsäÌÙ³&ó\?¯3ÏãÕï*íÛ´2™ç_yÝübÉpÞ±ó?É£‚Ž_O™l¾`³|y£³Ò‡+ÝßîcŸ}:Öü[Z«[Có;h?•ñLA<ýÅ–^ÖF—šÕ«™ÿ[e×d5L€l®šÙãΩ–úºÉïþ3=NŸ>½Ô«[[Úvì¢×2Mtéóv3…ïó/§ªL¤Óæ—Cý­¡%óȲ«Ÿ>ÖÖëJ›l·WÄ\¶;H¶"æ’M¾¥*xòÂsÏÈ[o¾!‡ S¿üž2ç4RÓ,õze·–è3gLjª–é –¢ÙÕÏëÔ–ZÆŒDµå„96ш@{A6lähÑ:wl+mÕš}:[à1µæ“=2W¼Ç4û,•áùî;=M¦§Þ½°}çnf8Š?PLÞëÓK­×Þší÷ågŸÞ4ì:Ã!©’ܽ`ë „q·ùÆsâ=EZ¶@M¹ÔÅ–wâKÙsì­ß¨ÙsŽÝáT|F@éc)gU G‹""²XÿÓKèõMtÑkèŒ[0Ó_êÇÚµëTvU>ëëyrç–/TöYœú«ƒcÛ¶o—{Uæ±’yºè/=ô.ËŸª/Ïæ]ÿp‘Riëö¤c»6&ó\®ª«¡Î8Þ¼e«µé3gͶ®§¦×6|¥I#)S¦”ùpb+óÜV†s f¥{úžàz €€¯ $”ÕªQÝš-V£ZUóoì/j–»dÚŽ5É|õr²Ý:æðá#²zÍß&ÓÈòÐÓ³6xÒ,8«×ðØ¦¾ }óõWÕ·{¡®¦4kÚØze[¯;ÙD¿;}QÌEÙ£²Él}Ì";‚dºž!j DÃõ¤p¡ûÍ/¹ÿü»Qºtl'Y¯ïž§_z †.ú[s=žíZ·ýͱþV\gKéq\ó÷:ÒÁ1ÜiÓ²…Ywì?µëiR%Æ^ W©Ì‚AC†K¯Þ½‹˜=2mæŠ÷˜®gúp'Ô·õ‹–,µN‰ÕY]zzÌŽ;Í=P¬¨Ü[° ­Û+Å{ÁÖ=ãn ƒeUbQ0Ç&:•|ÊõiN¶¼_Æžcõ—úgùÇ]ý ·ç»Âáø¤€þÙX¯NmyF-)¡3|£¢²™÷ˆ¥X²;õßu†½þ%]¯kf)ç}ñ”)üÚîÓºž¦_2‹?àöõQ’ƒ×êDf3ê‹0KIœEúÓŒïD?töœþw_¯øÓœŸÕÎæõÌáuk×K\,vËsúË´;Tf¹=™ç:ÃyÔ°AòùÄOÌCï¾8Ã9вÒ}ò C£@4°ÊÖoøÇd”y2ƒÌÒm‚dŽÜ:“Æ›º/v¶O¯=ö£þ¥è–ã—-_!:Ûáqµ6‡ÎBéùλ¢×Uš7ûG“2¯S õT<³Ûš×ím‹Û³5Ævš9ÛÎµî€ §S ”é™>&Vg%Õ.3Æ7Æy§šÎ0[­Ö³kgÑKìwí¡§)$ˆÞÂ}ÙÂyòɘá*pRÀZWçn=Õ á2çÇdá/³åÕ—›˜×zôêc”æü8MfMÿV²EFJ—îj­:½kˆ%ƒ,ÑuÓ|ì=1¦êU+W’hxÐ;­èõeZ¶Qk)s{îW¼Ç,ï³9j‡K=½v–žjyý¾XùÛïò×_kd†ÚyTOW©«2Þæý:ÿFÛn¹WnŒãµŸcÉÝ )ÝCnwOŒ©½ïõ”~–ÚûsV×üµfòçÊ%2wÖ4éÜ¡Y³¨Ùë-Dg²XÆÑÖ{Ö°\ïÙ:vÒç_¨Ýg”…ófËKjWS} [çØs/§ú[cÊëx@ —ZW¬j•Êf׿é*+júÌYj*å€Ò­M¸xñ’  éuÇ,Eyd)—._Ëôþ~ÚtðÖ=-S×EïbÜ÷ý¤oï^fC]g‘ê<ôC¯;Úì·Ìëú Q½æšþý HáûÍÎȉKõ[âbÉ4Oœyny=qæ¹%Ãùµ«¶¾–~$^®àÚϳ›$ÎN¶Ô—xÆBZxrM@´Ð25¶^­•®×K×¾uíQw¶2¨ØC%Ón wöÌÅu—)]J6üëù4zGº±dÁ/ò„ÚÅŽr³@‰Psk“dI‹q Q;FV S‹þ††ËÝé®ÍxÞ#+UöØÂ«—$–­lÞžÓ5¿/·¶Cgè´l«w±T2Š[<1¦ö6üû¬¹S<ôù3×Ö1¢¤,Ò˜bw»€þ÷(¹«ðrLàå&eÅo¿Énµpù¥R…ò&˜¥§häTëpêŒùj,=ÅP/ܯ³†õ±–¢3ÎôÎÞ¿ý¶J‚TÀLŸ_Hei~¦Ñzáþ† ê›`·^ìò•+’)S¸„…†Y׬­ÖÔÓ0õzW`GÖ÷Â?m¯g6S}I2D­#«¿4éÔ¾”*ù?yµyK³ÑÒÄOFË•)®wfÖk‰é@—n§%“køf#ƒCjVAŸ÷>°ö]×yU­aÙ®SWcðLÃúfY‡†Ï¾$zÌq£‡›çû¬>°$¨ìô·L&ókj‡åŠj3ƒÝ:« Üó&cM?ÿéØQ2hèp³ž¦®[o ´tùJs=m3eò3…F/o¢vöîÝ«»Y#®Jͺ*ƒûö¥#»8@À—,YßöÈ*^Ìe¿G±&™/ß5·´½ªú…‚âý:6ïÊEó x¯@éÇ*zoãh™[‚¹•—Êð¨À>µéÌþ¥Ñ‹/˜éêÇO7»[¦Tæ«ÝŒ+W¬ /«lëpÒAœBrm*³.óæ/Ç-+T€Mÿ¯³Ïô˜–i›kÕz±ÿWÕùëÖ­=eÄábïf×3MGŽ'T`¬{çòþGLiû¶­Lfy¨ à8yBÆ~2Á,½ Ë´i3e˜ ”?á¦ì.ÐÓŒ<Àì~}@ínÙ©kk°ªèÌóÎjYy®7„ù[í>fÉÓP&~ö¹G¦{xâwN½Cç[Í_“g^hâp¦›'í¹ €ö¸ò÷NÖ$³Gœc@@B ož5\4@@À9sÞa¦KêûcÕŽ´:@¶dé2w^Òcu>q¼Ü}W™3÷W³N@n HÆ € €Àuuë7ˆ~øciöF ì}B@ÀeL·t%!€ € € €øª™dœÞBˆâŒ+cêþ×#Þ§þ7¦ô@@ï Hfçx\UÛ\‡†„ší®)¾#¦ܽs5Ù3®¾3–––2¦¾7f¶Z̘Úò½×m©ïõˆ#€ €‚A2;Gùĉf¡Ó©  2;ÙÒô0ý!í.5f'ŽŸL¶Œkš‘ÃgL&óúS¯"‡hϘ:\)' pdÏÜÓa@¯ Hfç0ìß@Éòʽ÷ý€âý:ƒìı²ÿàAI—.éå÷WïÇÄ-dL}k¼ìi-cj’ocϘúVh-ž ËݳÞ\ @À·\=ƒ!¨ØC%ùªÊÎ{"..NâããD³S,  –ààฦñ89ryÆÔ-ß8–1õqr¤•vŽ©#U±eJ—’ÕkÖBWé£ ¼yóH–Ì™™½À‚ €€ Ë †sçÎËÁC‡\âE&™Œ:Øb+àâ@uê%Œ«— „ ›Á˜ºÓKªbL½d h¸]€,w·s@?pÇ ‚d~rsÐ @@ßÐKDìSË|ìÙ»—Ù ¾?œô@Àn˜Á@ÌFÝ € €8(@ö¬ƒ`Ž €€‹’^ÍÜE•S  € € € €¾ @ÌF‰6"€ € € €¸U€ ™[y©@@@@À’ùÂ(ÑF@@@@· °p¿[y©@ð¬À™èSž½ WC@ÒX kd”KZ@&™K©@@@@À—’ùòèÑv@@@@—$s #• € € € €ø²A2_=ÚŽ € € € à‚d.a¤@@@@_ HæË£GÛ@@@@\"@Ì%ŒT‚ € € € àËÉ|yôh; € € € €€K’¹„‘J@@@@|Y ¤LéR¾Ü~ÚŽ € à!½»vÊ=ïóÐÕ¸  € àY… xöŠ\ @@à&jÕª#‚ € ÆL·Lãàò € € € €i/@,íÇ€ € € € €¤±A²4. € € € öÉÒ~ h € € € €@ $Kãàò € €€· Lž4A:´oçmÍ¢= € €€[’¹•—Ê@@@@|A€ ™/ŒmD@@@p«A2·òR9 € € € €€/$ó…Q¢ € € € €n HæV^*G@@@ð‚d¾0J´@@@@À­!n­Ê@@|R $8XÒ§Oom{\\œOöƒF#€ €ö $³WŠã@@hÛ¦µè‡¥Ìžósõž®"€ €@ $ ÄQ§Ï € €@ ¯¾Þ<ÉWŸ¬W7@@Ào’ùíÐÒ1@@ iÐÐP™=kæm/ž?Až}þØ@@€ HÃN§@@ bbb¤VzL@ß@@ÛØÝ’›@@@@ à’ü- € `ŸÀ=ï³ï@ŽB@|P€ ™MFÀ&Oš Ú·³65,,L¾™:E AAA¢_ß»k§yü³n­ÌœþƒT¯V5É®%>vÇÖͲtÑBéÕ³‡DDDømD@@ð‚d>0H4_ –±£GÉñã'¤[ž’`º4lÄHÉïýRñ‰ª2cæòÉØ±rÿ}Ig)ècuC©GUuô‡,.sÔ¢Ó‘‘‘¾ÎCû@@@¼@€ ™ M@ÀŸÒ¥K'C’øø8éÔ¥‹úüMÝÕ³èèh™òÕT9{ö¬+V4E}Ì_«×ÈËÍ^“+W¯JË·ZXÏž=J† ,kþü]6nX§‚n£%W®\æõ)_|.íÛ¶±Ûûí^òßö­Öl´ 2ˆÎR+Z¤ˆ,_²HžjÐ@fÿ8S¶lüÇd®Õ¬QÝŸ‡‰¾!€ € €/@,àopŸ€žVùqÿ%2k¤´nÛ^bcã’¼X¶lÙ¤Ù+/KxxFY³f­] ºªd?ÿì?@Î?//^TþXMß¼_J”xH–-[.%KþÏ´¥ø˜ÀÙ7ß|gÍ«P¾œ ¤­°^{ê×ßÈîÝ{LæÛÓ¦KæL™$º@@@À?’ùç¸Ò+¼B`ÕªßMVWŽ9¤[×®·µiäè1r‘bò¿ÒeäåW_“¿×­s¨ÝyïÊ+'O2çäÉ“Ûdª;vÌZ‡”>}JrÞq‡É;ª^+S¦´ÔRYd -VER±BEIŸ>½T,_A–.[f=÷СÃÖ?ÇÄĘ?‡…†:Ô>F@@ð‚d¾3V´Ÿøo×.³ÎØ›-[I“FTÖV —õAg„5lP_–,Yjê[°`¡¼õ曲~ÃУ € € €¦A²Àwz€Çæ¨Eö¿ýî{?v¬YÌÑÒ¡][Ùµc›ü¹j¥ôíÓ[Vª¬¯O=-gΜ±VÕ²u[óç?~[!+—/•ìQQÒü­–Ö5ûí7“1¦cqq×6øuþ)R¤°Y³Œ‚ € € ¸AyóåOÜîÓs@@ íªU«.«íÜÝ7í[K @@ÿ “Ì?Ç•^!€ € € €8 @Ì,E@@@ðO‚dþ9®ô @p©@žÜ¹å®»îriT† €x“@ˆ75†¶ € €xŸ€E©ÍPtIˆ—ƒ‡y_#i € à¤A2'9@ðg Ëž=J¶nÛ®"d R¨p! J—N8àÏݦo € €L· ÀA§Ë € €€=‰d111+ÛU°,kÖ¦^ÚÈ1 € àSÉ|j¸h, € à[d–«(óŒ?WA@Ï $ó¼9WD@¼Z ¹2¯6‡ €N $sÓ@@° #PæO£M_@@ ±A2î@@#`o€Œ@7  €ø£A2Uú„ €8(àh€Œ@™ƒÀŽ €^/@Ì뇈"€ €îHm€Œ@™{Ç…Ú@@À³É<ëÍÕ@@¯p6@F Ì«†“Æ € €€AyóåOpâ|NE@Ü".].\DrÞ‘SBÃÂÜr@¯ôܹs*[·m—˜˜—p„†„H¡Â…$.6N²dÉì’:©äf«1±ròäI9xð $$ð«<÷ €® Hæ*IêA@— )ZL¶nÙìÒ:ý­²ú½'Ydä¨1²hÁ¯Rºìcrüøñ›º¹|É"ù°ÿÇòëüæù|ùòÉŠ¥‹å‘ÇÊÉÑ£GÍsáááòïúuR¯~Ù²u«$>çÖó“2œbäM/Ûº–Ì¥ÔîO>`‚AƒºõŸ’}ûöùÛ:ÕŸbW²CNÕÁÉ € pC€é–Ü  € à•:ƒŒrC @üR¾Üã’#{v“ýõ`ñâR©REY³öoÙùß²qÓ&éÕ£»ÊÞÊb^/R¤p’|:Ðôû¨c»™àX† ¤{·®²qãFÙ¶}{ªÉC‚ƒ%}úôÖGˆÊ(³u-[íÖA<ÜûaÚt?nŒ©›rC GŽ;à@@ $s!&U!€ €€ëÂÂB]W™Ô”1CFéѽ›,U™a›ÿÝ #†•©S¿–)_M5½kÞâ-Éœ9³ü±j¥lø{­´nÙ2Ù^·lÝÖ¼öÇo+dåò¥’=*Jš¿ÕRâããS-Õ¶MkÙ¾e“õ1|èS—­kÙÓî> W®\‘÷ûõMuûüñİÐì}B@ ͘n™fô\@R¨V­º,\xmŠ nÐï‘ÕkÖBƒ €.àë'AR  € à¯zúæìY3oëÞùóäÙç_ð×nÓ/@0‚d6àt@pT@/°_«N=GOãx@@À§X“̧†‹Æ"€ € € €¸C€ ™;T©@@@@À§’ùÔpÑX@@@@w$s‡*u"€ € € €ø”A2Ÿ.‹ € € € à‚dîP¥N@@@@Ÿ HæSÃEc@@@@Ü!@̪ԉ € € € àSÉ|j¸h, € € € €€;’¹C•:@@@@|J€ ™O E@@@p‡A2w¨R' € € € €€O $ó©á¢± € € € €î HæUêD@@@ð)‚d>5\4Ü);÷²w×N‰ˆˆpçe¨@@@À ’yá Ð$ü]`ò¤ Ò¡};k7ÃÂÂ䛩SdðÀ$úu¬ÒÖ­•™ÓêÕª&É’øXË9>þÈß é € € €€‹B\\Õ!€ „„ËØÑ£äøñÒ­GOIHH0ç1RFŒ%Y³f•'ëÕ•OÆŽ•ZuêÊŽ;o«ĨÑ2rÔ(ëóññ×êp´\¿´£§q< € € €€IæƒHðUtéÒÉÐÁƒ$>>N:ué¢þSWtÀ,::Z¦|5UΞ=+ÅŠM²«ú¼ØØ8ëÃRÏò%‹ä© dö3eËÆd颅R³Fukùî¾[¦|ñ¹ü»þoY±l‰T®TÑW)i7 € € €€“d’9 Èé :=­òãþJdÖHyýÍ&À•TÉ–-›4¨ÿ¤„‡g”5kÖ:|±¶mZɪþ½{÷É‹/rãX»Ãá € € €>.@ÌÇæ#à«ÿíÚeÖ{³e+iÒ¨‘Z+¬†[º’T–š¾Ðñ'$$$D²g²^7*Û?»¥1TŠ € ýY´ IDAT€ àµɼvhh!°cǵ«e2h€(?UÖè]2-ýw[eß¾}²ióf騾½Y,S¦piÕò-[§ñ: € € €€Ÿ Øþ$é§§[ à=s~ž+ß~÷½Œ;Ö¬Qæhiß¶ü·}›õÑÿ£lV¡wÎlÕ¦­ ̵ý!óæþ,?Íž-/^´y. € € €øŸ@PÞ|ùü¯[ô@|] Zµê²pá_ïíGÀmú=²:;?»­ATŒ €€ IæãHó@@@@œ Hæ¼!5 € € € €ø¸A2@š € € € à¼@ˆóUP € €¸B@o`S¸p!É–-ÊTw&:ZôNÐçΟwEõÔ €)IÆí € €€èYÙGÊJ¼ÉîÝ»Í#VíÆ\ªtéTíþì]¢  €ø”A2Ÿ.‹ € à¯:ƒìÔ™h9uâ„ÄÆÆš‡þó©èÓ*»¬ˆ¿v›~!€ à5ɼf(h € €@ è)–gÕôÊ[ËÙè3júed ÓÐw@<"@Ì#Ì\@\ÐÐP3j¤¬_»Z~_¹B2dȸ)ô<$$ÄdÝZôsú5  €¸W€ ™{}©@ð¨ÀäI¤CûvÖk†……É7S§Èà$((Hôë{wí4Ö­•™ÓêÕªÚlã­õÚï¼-uj×6¯éçš6iœêks" € à*7p•$õ € €^" §U~ÜÿC‰Ì)¯¿ÙB­s—d˲eË& ê?)ááeÍšµ©ný¸Ñ£eßþýRºìcêšYåûo¿‘ãjWÆÏ¿øR *aéÃ$J-Jß¹k7sé3fJ©’%å‡éÓå«©_§úºœˆ €¸R€L2WjR € àM7’ªUªHÆŒÕË›3ÈtóÚµi-;¶n–Å æK•Ê•å¥ÆMåà¡C©jy¾|ù¤lÙGdÀ Ác‚cÓf̺uꤪ>NB@ÒJ€L²´’çº € €€›V­ú]ÞîÝGf͘.ݺv•ú|Ó•FŽ#ÃÕºd®(wæÊiÖ:ûqÆ4kuaj7ËǸ¢zê@@<&@ÌcÔ\@ðŒÀ»v™uÆÞlÙÊÊÖ®ý[~?ß-×™cºÔªSOÎ;g÷5äÚ&@@À[˜né-#A;@@ ìØ±CíjÙC†   äwºöà`IŸ>½õ"»wï‘5*÷Þ»}$22Ò\#W®\roÁ‚)^ïäÉSRüÌ1–Eþn  € €N„þ§§s* € à&9︩âÀªvÎÏsåá%düرÒàégœê|[µ–™~XÊì9?K›ví¥EË–òvÏž²tÑ K/ÇŽ“ƒ‹ÎhK®Œ3F`vÄ=vœL˜8É©¶q2 € à¬@P…GÉuwV‘ó@@ÀåUláÂ.¯— ðVêÕ«Ë–-[’l^Ñ¢EeÁ‚›ßÕªU—ÕNìJê­´ @´`M²´’çº € €€ „ªÅögÏšy[‹ÎŸ¿ Ï>ÿ‚µ”¦ € €î HæWjE@|J &&Æ,¾OA@U€…ûuäé7 € € € €€U€ 7 € € € €@À $ ø[@@@@‚dÜ € € € €/@,ào@@@@’q € € € €¼A²€¿@@@@ HÆ=€ € € € ðÉþ@@@@€ ÷ € €^ +!!!·µD?§_£ € €€{’¹×—Ú@@°K úôi‰ˆÌzÛ±ú¹Ó§OÙU!€ €@ê’¥ÞŽ3@@p™ÀÎ;%*2›DåÈn2ÊôCÿY?·cÇN—]‡Š@@ i‚dÜ € €xÀ¹óçåÏ?ÿ”tê¿ ˜‡þe}õêÕráÂ/h!M@@À¿n_ôÀ¿ûKï@@ðZK—.Éúõë¼¶}4 @ “ÌŸG—¾!€ € € €Ø%@&™]L„Î ô/![£cä‹m—LU¡é‚dÀ£Yäè¥x¸î¼|¤^4W¨yí\L‚ì?'Sw\’UG®Þvi]WÑlÁòê’3rúJ¼õõGr†Jëâ™ä•ÅÑN5÷¾¬Á$²-:Ωz8@@@ÀwÈ$ó±¢¥ø€@õ-YN©× õç%ázÏ>ßvQªütRš,Š–ùû¯H¿2Yäž,IÇòãÕIÊä“ïÍ(…#¯ì( € € €!@,0Æ™^"à5*Lzþ/³Ä© Wÿ¿Ï‹v%.ú¯g¯ÆË¬=—å|L¼ÜœdÛ§î¸,F…H•¼é“í[dútòné,2«V6™Q3›¼^$£èë—Ugú¹®ýÌ–N¦×È&åï “Ö„KÅÝrI²ªL³þFÈ=™ƒe즋^aD#@@@ÒF€L²´q窤€ÎøöÏ[!«4/.Ÿl¾90õåö‹Ö…ýíµñ‚|V9Rež¥—èD‹øgOdÖ:S>ÂZ•Þ,àøåk ýëÀÙw;/ÉÛ%3ËPÕ¦ËÉ¥µÙÛŽC@@ði‚d>=|4ßاv¬<¯v®ì³úœ ”mRS)W¾}÷JGz¥ë ÿïS*³Œü÷FÐMO«Ôåegä‚:æÖ’EM¿l^4£Lûï’4+”QVªvXvÊL"ÉÍ‘&q, € € €€ 0ÝÒ&#àë{ÎÅÉ@Øêþpf¹KMut¶¬=#K]•×Ôbû–r@ä6žŠ•¶Å3‰^ãL½P¾ë×몮½öx¬ŒQÓ,—º¶>™^3M—hµqÀýY¯µK/ôOA@@ð‚dþ?Æô¯Xª‚Z?ï»,ýÊd1k”9[>QÁ®[óÅtÆš.SÔgsëDɰÇ#¤€Ú-óIµ6Ú½êÿzª¦.z}²œƒä¹û®Ù¾ÚvÉ윩wÀ|¶àÀ›³mä|@@@ïª0ãh2ûËyo£i €ø¿@Á9ïÈÂ… ü¿£ôT T«V]V¯Y›Ê³9 @n “Œ{@@@@ à’ü- € € € €A2î@@@@€ Hð· € € € €ɸ@@@@^€ YÀß € € € €$ã@@@@x‚d € €8/°|É"©Y£ºó%Sƒ­úçüô£T(_Μ*cF”õkWËï+WH† ÜÖ.*F@ÿ Hæ?cIO@@À êÖ©-®Z)»vl“o¦N±¶°ÜãËO3gÈŽ­›åßõË3¦I‰Y_/]ª”|1y’ ôlÛ¼Qüú‹´hþ†ÛìåäI¤Cûv6sÇáááÒQ]{ÅÒŲuÓ¿¦ïÝ»v‘télÿÚiË$¥ö" –˜˜9~â„L›1CêÖ©#3gý$…TpN?tÑíÙ¶m»lݺMR:/¹†èµÔôšdô{OFŒ%;vìL±Í)™XNüú›oeßþýæ¯ÓU^¦Lá&»Œ‚ €¸B€5É\¡H € €€ƒz½¬Q£ÇÈè1cMÆÖ KxÆp5ír±©)‡Ê(;|øÈmµæÌ™SÖ­[ïàÕ®~g®œ’`¦,ZJ˜ÊÄÒ×ÑëžÍûu¾4|ª ¢=Ýð)ùæ»ïlž—\C.UFÂÂÂLÐíã>”|jm²>êŸb»“3ùî‡Ìy‰=tO—Ð~MÕÍÀI € p›¿UpS € €i( ƒV .’9?Ï•råÚ±c‡Ô«[÷¶Eæ , E‹‘^ï\['ÌÑ¢3Çt©¥¦YêµÃn-ß}ÿƒ 4PǾ7Ó@›½úº9ÄÖyɵãêÕ«f=²¦M7ë„Ù[’2Ñç&$ÄÛ[ÅmÇ%HBªÏåD@ ¦[Æ8ÓK@ð"òKùrKŽìÙÍšZ/.•*U”5kÿ6­|_e\5{¹©´lñ¦äUÓ 3gÊ$=ú¨|úÉ8ùiöٰ᛽ Q»L¦OŸÞúQW»wï1×xïÝ>iêÈ•+—Ü[° ùóªß—˜Øy§W³¦—ÞT@[ç%nŒ^OM÷G_O—üùï‘çž{FV¯Y“b›m™Øì°ô¦ÅxÀ¥7*  € €· „ìêP@@Àë V«îumJMƒêÔ®%=ºw“\9sÉÔ)_Jã¦/KÆ Ísùóç—ôjJâþdêÔ¯­ Ê/[¶\š6{UÚ¶j%o©@™^ï¾}2MedM˜ô™]ÍhÛ¦µè‡¥Ìžó³´i×^Z´l)o÷ì©v‡\ ¦C¦7;k5Ý25¥I¡pyç¯sRÞ)YtðŠÊL‹ˆ°k¿.öU²Ëq òìühy}é©”'½x$‹LTaOäMïPgï¹,G/ÆI¬J>ûaç%¹¬2ÒJæ‘<™‚¥Dö™°å¢Ä¨ VÙi¿ªŒµÊ*P–¸ÌÝwEV¹*bDÅÓ( € €€O ïSÃEc@@䮨ÈÔ”íå+õxìÎ0éþp&ɨ’ÊtðêÖ’h¦¤õ¥è«7Ö/Ó1®“*–=C°dWS7õßÇ”°ªæZWÏ'.ûÕF@@ÀW’ùêÈÑn@@ ÐÒ]K…ªL°P$ÓÙa‰×ÓÁ¯[KŽ 7&è Z.µ^ÙÉËqrúêµ´°ÿ·w`RUgÇß]v–Þ$  HA,”¥-b, (Å’Dýò%jŠñ1yò$Ÿ%Ñ|)Öèc) °”¥‹"ei‹Ô¥÷]úîwÞƒ3ÎÎNŸÙ¹³ÿó8 ìÜ{Î{~çÎÕy÷œs”{ÄÎóWJ™=Æ5‰ €`¹¥ƒÐ@@—@‹¬jÒ£qu©o6Ø×Y^íêV“+›T—ÕfŸ2-ÛÌ,/}_‹&ÀF´)¿søEÒD§ž™rû%™bž Ë÷‘æÜÕÏÊ—×rïQ¦ µ–¦ÍhŠÖ_Ãüë•Æ™FÃɹ € ¥3É¢ät@@ tØ}jIóZ©¢{í—ÈôoNÉ4³‘¾–gW—_v©-×6«!'̪È9;OK±Ùs̳ÌÚqJžî][š˜Êv•Èo–“ãßÎûݲcò_jÊøumNŸžù¯üb›|‹´Œ3 З«Ì1 x|ÅñH«ã<@@ *”æ-[11>*BNF@Š8p ²"ª¦Nofד—×ËB³L“â 63~+Ë–¯pF°D‰ €€˜Iæ€A"D@@ 2|mæi»:Ûìåëê–;]g¯=¸àH¤Õr € Pa$É*Œ–Š@@ª+p¦¤T~4÷pÕ ç € à8’dŽ2F@b/0ö3Z±W¥F@p’ÏrÒh+ € € € €@…$«V*E@@@p’I2'±"€ € € €TˆI² a¥R@@@@' $sÒh+ € € € €@…$«V*E@@@p’I2'±"€ € € €TˆI² a¥R@@@@' $sÒh+ € € € €@…$«V*E@@@p’I2'±"€ € € €TˆI² a¥R@@@@' $sÒh+ € € € €@…¤UH­TŠ €Ä@ àg½bP U œmJÎŽÑ+@â$ÀL²8ÁÓ, € € € €@â$Kœ± @@@@8 $‹<Í"€ € € €$ŽI²Žˆg IDATÄ "A@@@ˆ“I²8ÁÓ, € € € €@â$Kœ± @@@@8 $‹<Í"€ € € €$ŽI²Ä "A@@@ˆ“I²8ÁÓ, € € € €@â$Kœ± @@@@8 $‹<Í"€ € € €$ŽI²Ä "A@@@ˆ“I²8ÁÓ, € € € €@â$Kœ± @@@@8 $‹<Í"€ € € €$ŽI²Ä "A@@@ˆ“I²8ÁÓ, € € € €@â$Kœ± @@@@8 $‹<Í"€ €@r ¼öê?åg=èîT5dâ[ãå/ÿû'III}kÁ¦2¯?=ýGyãµËýOŒ† ÈêU_JŸ«® ŠÔ®m[yþÙgdù’E²1­,Z0_ì÷eÎ{èŸÊš¯¿’¬ZµÊÕçŠËW[íÚµµñΘþ¾û<ýÐ<ßÓøgL{_®–S®Í@ñøë°·¯çq¡ÆäòW÷ys>+7®÷¯¹ºOPw@@äHK¾.Ñ#@@ ¾iiÕä¥_}ûöË#þZJKKm@Ͻð¢<ÿ îàJJJåâ6mlêµ×_—‚‚-ö½‡|@òòò$oÑ¢€éܹ“Lšð–¼óî{2zìRXX(-Z´FºÏ«V­šÜ>j”¬_¿^n¾ù&ÿæ[åêܰa£Œ¾ãörí3Zô=ïâ«®cžyîyyÖ¼jgeÉСCä…çž•›6¹ë %žHF/PL¾ÞÓÄ¥ùÇ–…órå÷=!³?›mÿ~î\I$!p €8\€™d@ÂG@ÄHMM•¿ýåÏRRrN~ñðÃæÿ¿K¸èŸÏž=ç~éß54iÊyäá_ÚŽ´iÓZFÞ2BžüãÓA;öäcɇ},¿ýÝïeÃÆrìøqY—Ÿ/ó,tŸ;0;[<`’s—qcÆø¬óƒ3d`öiР¾ûýÌÌL¹ñ†dºyÏ»øê‡÷1Ë”©ïȡÇ¥ýeíÊ'hÇ}(&_ï;÷Ý8hu¥¥ß+©Iœƒ €Î IæÜ±#r@H0ôôSzuëÉOxÈ&ÃB)Ï<û¼\Õ»·tëÚU}äycüxÙ¶m[ÀS5¡Õ¥Kg™øö¤€ÇÝ9n¬¼5a¢Ì›?_²jgIÏ+z”;~ÿ²0o‘Ü:b„û=M-_ñ…ìÛ»/”.”;F“l·ßv›TOK“+V¸ß%žˆä$@@ J’dQr: € àÐå‰Ùˆ&ˆtf’wù¹Ù³Ìs_²[GžOJ:tHžñïfo±¿I÷n]åÅ¿¿µQÃFö˜ÂÝ…~mݺ•tît¹L›þÑö¶I¨[~6™Î~›0q¢]–é*cFßa“kòí’DÏFüõCyð§?±{£­[½JÆ##o»]vìÜiO5ž ÷q@ ˜î¿÷G²|éb÷+gèÐHšà@Hr’dI>Àt@*O ÏÌÆêŸ=Èì ÖHùÕ¯Ê5¬ûu]Ôæ÷K—#ºÊëoŒ—ŒŒ y埯Êñ¢¢ AgN¸Õs< €T’dU`é" €TŽÀæ‚9zô¨Ü÷ãÿM <8ä†Ïž=+GŽ1{x é]"©›ê¸åfŸÇkÂMgªÝy÷=’3Ü$ˆÌkС²dé2e–AzÝ£kÒ”©vm6iÒä2û©…”ÇAŸÎœiöIÛ ÿâçö§áÆn{Ž/**6QØç~:u*–ÕS €$‰O·L’¤ € 8Í&ú<ú¨üõϲ‰¢-[¾±Áé²F}ò¥«èÓ-=7ö·O>õ”üûŸ¯Ø™gSL‚k—yºeƒúõ¥vÚÒ¥SgÙ¾c‡Ùk,¯Lµo™YU¿ùõ£òÿx¥\Ûšû|öL9sæŒdö¿$1Ô~üÏãOȇӧɤÉSìì´pãñöH3OêLOOwÿøüæûg+Ä6ܱàx@p¾3Éœ?†ô@P`ƇÉÛ&éô—^²{”iyèŸÊæ ëݯ§þødT‘çš%’cï¼[.ïØQÞ{gЬþj¥¼ÿîTé{íµv6˜¶ï]fΚmãé×·o¹÷vîÚ%K–,•¼E‹eÏž=~c µùùëe²IÞýá‰ÇíþdáÆãÀf¿³ ëÖ¸_Ïþí¯îCB)*pNF@¤HiÞ²UiR÷Î!€ €€#$³gÏrdì@eègdÙòïžZmÒ €É,ÀrËd]ú† €Ž¨^½º|0í½r}8~¼È<5ò»'Q:º“ß_•úš ãE@HF’dÉ8ªô @’B@÷:lxRô%X'ªR_ƒYð> €ÄG€=ÉâãN« € € €€£FޏY:vìP.æA³¥w¯+Õ‚E| $ãº@@@@¨ò,·¬ò— € € €@ìÆÜ1J–,].Ý»u•:ujËá#GdaÞ"),Ü»F¨ ¨f’U*U"€ € €UY@dò©¼öŸ7dã¦Í’3tˆd¤§WeúŽ Iæ€A"D@@@ÀIkÖ®“cÇ˹’Yµêk9{欴hÑÂI] V¨‚$ɪà Óe@@@ \ÒÒRówII)ÿ³“'Oº¨çIÍš™á6Éñ €@¥ $«TnC@@œ)pìØ1»Ç˜w©[§®•ùq­Z5ÝO1Y´ÚYYæ˜bgvœ¨@ Ê$«2CMG@@@ÈÖ寗ŽÚKëÖ­¤F’™™)Wôè.µkgÉæ‚-e*îÐþ2›ÓÒµKgIIM•;wDÞ8g"€• ÀÓ-+™&@@@§ lß¾C>Ÿ“+=ºw“ìþýD—QîÙ³W¦ÏøPŠ‹ËÎÛ°q“ Ë"Y&QväèQ»‰ÿ©S§N@ü ä$É’|€é € € +‚-[D_ÁÊÁC‡dÒ”w‚Æû €@B °Ü2¡†ƒ`@@@ç è>d@Ài$Éœ6bÄ‹ € € € s–[Æœ” @@ §OŸ‰E5Ô@Ò œ>Ãg$i×á{kâ$‡÷€ð@ ª 0“¬ªŽ<ýF@ ÁöìÝàñØ¿o| u@’L ¥yËV¥IÖ'ºƒ €I šš"mÛ]*M›\ 5jTO‚Ñb# 3È4A¶«°Ð>]‚ €± IGjA@"8p,[¾"âó9@@ z–[FoH  € € € € Iæð$|@@@@èH’EoH  € € € € Iæð$|@@@@èH’EoH  € € € € Iæð$|@@@@èH’EoH  € € € € Iæð$|@@@@èH’EoH  € € € € Iæð$|@@@@èH’EoH  € € € € Iæð$|@@@@èRSRÉ“EÏH  € €@dü·Xdnœ… €ÄZ µFôX×I} € €! ðßb!Bq € PÁi™¶‰Ó§OIiII7Gõ € €* 3È4Aæúo1T@@â+r×î-o´Ž € € € €@|R:tîN’,¾c@ë € € € €q`×þ8Í#€ € € €Ä_€$YüÇ€@@@@â,@’,Î@ó € € € €ñ Iÿ1 @@@@8 $‹óÐ< € € € €@üH’Å ˆ@@@@ Î$Éâ<4 € € € ’dñ"@@@@ˆ³I²8Í#€ € € €Ä_€$YüÇ€@@@@â,@’,Î@ó € € € €ñH 7„…óæ„{J™ã¯¾®Tçs2 € € € €±;I¦DšèŠ6ÁëÎS € € € €*õrËÞs·üøþûÊiúû9ì € € € €$š@ÔI²S§OÉØ1w”I”i‚ì÷Ü%ú¥òÆÜ1JZ·nUy Vñ–¢õŽöühøGޏYZ´hMœ[ Yµj™{ë½’ž^£Z«Ü&<¯ÿ@×c ­£ÕEUnà!¶–Ìc"‡!€ € à8ˆ–[zöòÍ·&Jzt›;}ú´}Kÿüï×^·¯X•ZµjÊcÇÈÞxS®îs•—E‹—ʰœ!²wï>Y¾â ÛTµjÕäúaCåøñã2gî<É:X.jÙÒ¾wÊÄwøÐaYù嗲园åBÓºš6i"oOž*'Nœp¿ß²å…¶Í‰oOŽª;6”ÔÔTÙ»oŸ­Ç_Ÿü5¢_ëÔ©ãóí>ü(ªØb}²w_cQEÔ‹¸bYG¸×„÷õêuµdé2Ù¿o,CXWU»JÃŒQC‰4&•}=ƈj@@@ É¢N’©Ç«¯ýÇÎs-»Ô¿Ç2A¦mœ8qRJKKåÔ©SròäI9q²ü,5M@ 4PŠ‹OØ™¯eÙò²â‹•&™WC.¾øb{Ì”©ïÊÁC‡Ê §žsÝ5W˧³fÇ|¨»ví"…»w»“d¡ôÉ3ˆ‰“¦¸ÿ:vô² /O¾ù6ÙçêkÌƒŽ°Bï¾FXM™Ó*¢Î¨ãúö‹ºžo+÷šðÕn(ulß¾#V!‡TOBŽ]H‘'ïA‰4&•}=&ï¨Ò3@@ˆF &I2 @g“¹ŠçŸ£ ÎóÜ’’9zô˜èÿk’ì¤Išy–””пŸ”š÷?Ÿ3× s£I¤“&Á¶fíZ¹²giذÏ$Ù+¿”îݺJÛK.–›6û ?33S®½¦4oÞܶ·.½,]¶\.lÑBf÷—IS¦š™nÅ’‘‘!£n!ó,”ï5k&mZ·’ /l!ÝM²ì‹•_ÙX¼û¤ýÐc_¯Y+_}µªLûÚwÏ¢}òþYí¬,qóR¯^=›X\¼t©l±§ù‹ÛW‚MíÓ»—]’—––&;vì´I9í—–{xÌøèc),Ümÿ®ËÁ~p÷]2þÍ Ò¥KgŸ}Õ~-YºÜúÖ©S[9" ó¹ëˆ¤N_¤†Wôè.—^ÚNªW¯.§Í¸ëL•.;Éfcñ¥‡ë Ùvdî¼ùÖ}¹I¦v5Ç錽ǘdk®tèÐÞ$WÛˆ¹¨ìX/^²ÔÝlݺuíg÷#G–éO0C_±»ÎCù<…R‡ö5oñ›dõeµisA™¦ôø¥&ÙܹÓåÒ ~}{,Z²D¶lùÆ}mù»^t¦¯kß³ý\ôº²§è5-6n’¼E‹Ëu7Ü1 ToÍš5¥ÏU½¥¥i[ìÛ¶o—™³>³×Á¬ÙŸKï^WJ³fÈûÓg˜ÏéÑ€Ÿ‡PûÌQ¯»¾×^#7²×¥Þü•`uºþb5&·!ùë×˪¯W»Ãb~ QT\, æÙû]¨ãª×£^OáxÚÔ7÷¼†_oÛ-زÅïýî–›¾ð>àïºöyw8÷_íW°ñ dîç$ÜØB¹q  € €NˆI’̵™Î Ó/v®e/ÿ㕘š|>w®û ô¹sgÝukR¤_ßë$#=]>þäÓr‰#ך´Òä—&NvïÞã3¶3gÎHîüÒßÔ·sW¡™•v>)äYÊ–cÇŽÉfégº©óƆÛã¾^½FÖoØ(úõ³ ¤~×]k—uê?}]д‰ä›÷ט˜«x÷IV[·m“Dd×¾ýeÆ`¦ýRßþ²KM,}e«‰áœI°ŠÛ»±AÙì]½¦1]ef×çäÈ”wÞ-—€ô>W_¾úªÇi‚LÇH½.7 —œ¡Cd„·m3P T§÷yMsãðöä)fæái©]»¶¹&ÎIªYŠÛ£[7w’L{­[]$ïOûÀ]E—NäÃ>±Ëm³MÂsÄ-7ÙdÍëf¬5ñ8Òü]“Ž®%³—¶kk/6Ç·¿ì²2ý‰ÔÐßuÎjþ¬|µÕ£{7ùäÓ™räÈQë›m’Ò¯m}Ã^[úÊØéçinî½N›¾¯6_ªuv–~>dö´«[¯®;ܵÆ[×÷uf zi¢1ôw‡cjþ¬|µµÖŒÎ®Ó¤i¾™Q§×V-s­DÓWW;ZïþýlÝzýàïºv è/CÅ,Ý{Î×þsþÆ7ÛP?'Áb«Œ{m € € (-·\8oŽDòªèNk²lnî|»W=%q‘¶«K7™Íû¯ìy…» ]>¤¿é¿¦O»šM”è¦ÑZto4=O—qéæç‹&ƒ´›ô5lhÿìú™¯Ø®3ËB;×Ì~Ñž<õ]{¸.?Ò1ÍÓõI•+Ì“ôï+÷Ü}§¬43<Ÿ²èª_“-ºÿ‘&üb]üÅí¹´ÌÕ¦>åOg_;ZJ̬1]Òªç»f'è캾}¯•Ö­[™}ÅÎØ„ .Ét}Õ=ƒt¿6×Ó u&—kF^¤uz;éEºñºÎÒºGÒ¼y ܇­^½ÖÆ O$¦¨…îMçê~QÖþ¸oÁ £iÛó\ÝÜ[_®¢c1kög!UÌ*¤JÌAÁúèÚßföÿÛÞz§Œ¾}”™W$ûöïsï-jû¾Ž V¯>„àš«ûȸ1£íé[·m÷묮öƒµ¨?z=Íœ=Û$p¯•»Æ±É¦…æ¡‘&̃Å«1±{Ô™=Üz^Ñ£ÌC0"±ˆÆ`±YfÓ7ˆþ’AŸJì~çï>àïºælì‚Åε‰m úc[8ýàX@@M ¥Cçî¾öº÷§.ÿyõ•—ÍÌZ%d’,Ñ€‰ç;1&©¨_úu j<‹>á´çW˜§Nú¤ÎxÆIÛ PqÜ*Ζš@@pª@Ø3ÉtÏ–ßþîqù×+/9µÏÄG@ËLà K—Ǹ¥ü¾U:›Íµéµ¯ú4É«dëþ?¡îÙN\±>6Ò~Æ:êC ™œvH&{ú‚ € €@" „$ÓÎlùf‹üõ™ç¹_Ä–äúäN}xB8EêP·n]»4l]þúpNÛ±‘ô3nÁÒ0pâ}À¬„ˆ € €@R„½Ü2)zM'@@@@ðˆèé–"€ € € € ¯ñf IDATL$É’i4é  € € € €@D$É"bã$@@@@d I–L£I_@@@@" I'!€ € € €$“I²dMú‚ € € € ‘I²ˆØ8 @@@@ ™H’%ÓhÒ@@@@ˆH’EÄÆI € € € €É$@’,™F“¾ € € € €D$ðÿ·äáœÐ…IEND®B`‚input-remapper-2.0.1/readme/screenshot_2.png000066400000000000000000002005041450214210500210160ustar00rootroot00000000000000‰PNG  IHDRɯp-˜éniCCPicc(‘u‘½KBQÆjQ”åPCDƒƒEƒBDÐR6¸Hˆdµèõ+ðÚå^%¤5hi¢–¾†þƒZƒÖ‚ (‚ˆ¶ö¾–Û{2PBÏåÜ÷ÇsÎóòÞç‚3œ×t«môBÑŒ†‚Þ¥ø²·ãNz™¦/¡YÆl$¦åúºÇ¡ê]@õj}¯éêN¥- “ša…eÂECñŽp¿–K¤„„ý¦ (|­ôd_gkü¡ØŒEçÀ©zz³ œl`-gê£Â>=_ÒþæQ_âN¤ÊÂ"Jˆ ^’”X#O‘€Ô‚dÖÜ7öë›g]<š¼ ʘâÈ’¯_Ô’tMK͈ž–'OYåþ?O+31^ëîBû³m¿CÇ.T+¶ý}lÛÕp=Áe¡î_—œ¦>E¯Ô5ß!x¶àüª®%÷àb„™ø•\²™ ¼AOún¡k¥–Õß9§Û”_tû0"÷=«?œh zÿ7 pHYsÄÄ•+ IDATx^ìÝ`TÅ€á“F‡!ô.* ‚Ò¥) *bÃŽ»Ø»Xž±+н!R,Xé‚ (H (ÒkHè%õÍؘ@²{7ÙMöîþãÛGÈÎ;ó»dsvfn”ä(ÇwbóʉÕÇÆ•h•ó)¾F@@@@ÀõYYY’ž–º 9iãuËþøí7Ï€l&¬tÙ QÍZ¶ûQ²2;d¤§JFf¦ˆ9€‚ € € € €@X ˜‰a1ÑÑ[B$*zöo ætÚ·ggVŒ²yëvÃ3ÒRÏÎHO3¹1’caxƒ € € € K@ó_é’™‘^·jšGm\»ztT݆ÇTOH¬º‘™c\- € € € €‘& ;ŽmÛšT#ºLÙr§“ ‹´ð3^@@@@ÐE•¥Ê”éS«ný÷Í&ýµ`A@@@@ Ìeu¢Mi‰ƒgÌ € € € €¨@tLL›h(@@@@ˆt’d‘~0~@@@@‰ÍÏÀìS&Õ«%J¥øŠ—o5ÈS Þ\7”Üééé’œœ"ËW®4·™Í€@@@BH ßìWõªU¥téÒ²jí:Ñ_î)¹ÓHþZ¶–|Ú¶n%ËW¬Ä'‡@lL¬T­š(G5l ËþáÚáâ@@@@ ”ò]nY±R¼lÚ¼…Y(E‹¾¸Z =#]¶nÝ* •+»zt@@@pÈw&Y\l¬¤¥¥…ã˜6¦¬¬¬€µEC‘!ffeÆš×@@@BK€ûC+ô@@@@ ¼Oia¦”÷àS —,§D@@@/à5IÆbB9²Àǃ@@@@ŠA€å–Å€Î)@@@@BKÀûL2–z÷‡ÖÅLo@@@@‚ 0“¬ r‡ € € € 6Ì$+D(™IV<E@@@ LJ”ˆ“³{ž%ݺœ* ê×·£üwÕ*™:mº|7~‚¤¦¦…éÈÝ=,ïw·t÷Øè= € € € P¤UªÈsÏ<%›6o’÷?üHþ^¶Ìž¿ñ1ÇÈÅ] =Î8]yìIIÚ–T¤ý Ǔը^]6nÚ”çм=—Ÿ…ë–[üž´iÝ2c˘ )P%!AÆŽ)eË–µ-½úòórbóf…l•Ã@@@p& 3È4A6mútùßOËâß~—ñcGÛǯæëGJfÌüQžð¤h]JÁjÖ¨!~WZœÔüˆFô{úœÖñ§|¹å«/=/Ú·³}HMK“M›6ËÌgÉa#d×®Ýþô-Ϻo¿÷,ýk™„ÂRÇ‚ô!§ÏÎ]»dõê52tø§2sÖìBÛøÛÀ1G7’˜˜ãù·¿‡YýÇ}HZµl‘ë|S¦N“7Þ~Ïg†øLV¬Xië5lP_¢££eù¡¿û<˜  €!/pZ×Såž»nýdá¢_¥ï÷IŸ=æhû3%¿ŸŸú!MÇö'Û¾ìÙ³GÖ¬]'ÃF|j–WüP$ýã$ € P|ºÄRg}6ò«|;ñÉg#å¸&Mä¬gʘo¾-’Îj²è©Ç‘ûû=&;vìÈóœñññòòóä‰þÏʆÖ¯¾7]o‚%eЛo¬MmHûøàÃÿ“Ÿ{Ú$ûËü mûÍ› Ï>ý¤ýž¿ãÊL²,m:œ*=Îé%ž{Qš4>VtX…  2gî<Ù¹sg¡Û)ÎÔ§mÇ.rÁ%WÈø‰“å…gû›5ÊõмKW]~™4mÒ¸ÈÏëï ?ÿâkéuñåÙ7ßy?Ï&²$+×÷ýºXví>˜˜íuþ9rt££ü=5õ@BX@“NƒÞxG6lØXd 2å¸ÒÁÏÏ÷‘ÖíO‘žç_,_~=Zžéÿ„Õ°AkÒ5@„€îAöÕ×cr5Õõô³D9Ë—_’î]»┎ÚÐdÑ‚…¿Ê;o¼*+ÆqL…òååW_’ßÿ\âwbÉ[n»å&騡½]¨_ºèL=M”=Óÿq;ÁFdš4{ÌÌâó$Íü9§×$™Î”òûaö?sìλDwÞû€Ý”îê+{g·§AÑÌÞ” ce·£ä“YŒŠŠ’(—^|a®ó>7àI¹×|R¬mŽù‰œÒ©ƒýºr¥Jòô“ÿ“ï'Ž“¦NgÍPý~~m{f~Ýtýµ2öë‘öÜÚÞiݺø=NEöÛF=ý—™™i³·_cgØiÇ3>M*¾ùÚ+2kú9®i“Ç›Ã=wÞ&]»œbÝ¿ùús¹°×yöº „MAfØy»h³²2%###ûái¿Zµª&þ¨|6ü#ùà7¤åI'æjæ½·I»¶­å†k¯–'·“+¯¸L>|÷M9ëÌÓm½øø ¢šÀýlÄé÷À½R¹råì6ôxËÓO>&_!:s€‚ ú£¿øÔìõÑÝþû>óûIæßðOäÔγ;®ÏëÏþO†–ßO”áC>°o¨{çeåk<ùëq½i,Ž·oÜ¿ý=wófLJŒ¯‹&Tºÿ»\õšë_”¸Ø8¹çîÛó<ìÃ!ä±I<~ofL˜4%»Îý÷Ü%)Û·›Ùw‹&-¯½æ*yò±~r×}e/ç½ïž;壇Ëó/”ýøêÏ#€„ˆÀuæßôûzDÖ­ß çÓSž4ªœvæ9’žžn{¨ÏßûàÃ’”´M.»äBøÒsr™µ¼ÃÇlõ¼~~zr©R¥äŒîÝ$66ÖìIrðg½·ŸÏž¶®è}©ÜuïƒöçTÿ'“ÞG^ëy±çùR¿^]››öà Y²ô/»Y­ÎÜÿgù ³ïFuùÔ$×t‡…‹;«7 '}=ü}Kˆ\t@Ð0¿Óf™ßC‹ºh¢L‹æn½ó;E?ì t‚ì–¯—N;ØÙþžå·Ýu¯=—&ʽô2PŽAYn™Wç6oÙ"MRHK­Z5íÆjo½û¾¤™}˶%'›[ NÝ[d¢IbèþQúÐÒ½[WY±rå{IÕ©]Û&y¾ö†¤¤l—½&™¤{~xk[ÛÛfqš”êuî96§o,õ rQ–®½Æ~ÚúÕgÃí '½htï6O;n¼}s»Û,¬^½Z¾VÞÆãË!¯ñ†‚M^ýê}éÅöÓzÏ£›¹NtY££ÊðOGÚYŠ{öîµËYœ–ÄÄ*6)øñвwï>Ùo’¬C†—:ujçZ–©ÉÝ_æÍ·íë?@wŒ3Ö¾/ÐAÆ™÷eË”±?;2Tt_²üʪ/”‚ŽÇ×qy;lòê×g#¿ßeŸÃáò ÝLïTuómwÉŸK–ÚÃu)fÎâm¬Z/¿ç¼±çÉñ¾%˜1¥m@Ü ðÝø vÒn w±Ì«è~ÙU̇Qº¢®8K “cžqhÒëÆ¾wx–Î.óUlj‹&Ù<{-0w:÷Ý£ìÑÿ=eg”]{C_¿qA]n©I(½ƒÞ=!ͼáþÉg¶Ïºüá7³×ýf¿§øCw¼ÔOVu -ó,2Ë2än³Nõûé?ØÛ§^t £ntwçm}¥\¹röÓa]~ç«m]¾èYž wÉ\ùï*»ü2TKAÇãë8¯~r왪‡ªMTT´MhyúË̦͛mìt)¦Æ¾´IÄzÛ_Å|X¸Û¼Oúgùrûm½áÏQ æjÂÛXµb~Ϻ¯þŒ‹º €¸U@·ÒãOéÜÉÜ\ðI9©y3éyîöqÒ‰Íí ;wìhëxf»»u¬ÅÝoMÈi,g‚ÌÓ'ýž¿ 2=Ö{’L?ô÷a½ñº>2wÖtÿÍWf–ÖvO§>×ßl–²™?‡Ú{ ß£¦f–½kàŒ©åÝ·^;xkt½¤îb6”×% ßèRËœ}Ð^ëäó½û̆»ºÇȸ1_ÊÔ ßʵW_i¿ï­íx³á…gŸ6õÇÊwc¾’fIÂk¯¿U qúmãù¤õPÿó<>ÇøœXy7m{ðÇCåÄæ'Ø;eõ6¿Ì&ÀŸ(ë†Ê£¿ü4ûq{ß›ì,¸_~Õì¹RÃ|bþž¼nnU«û¸é’ɼÊ_Ž’¦MŽ•O† ¶8kyþ¥Wퟺò‡ï¾ag¥=ûüËyΰ+î?çG8R@—ÞqëÍv˜î©áO™0i² zå™>ù;»'ê½W©bEðÔvyãÜÙ3dä§Ã¥i“Æö9ýûóÏñ¿GíL¤K/¾Pî¾ó6û½¡}`¿§³­r–ÔÔTyöù—ì·¼ï;;ëÎÛo•ŒŒ yæ¹}Fó‰§È%½¯”«úœ™Õì„ãõW“TÚMliјšèÌ>Mtå,ÿ,_!W]{ƒtîÒ].¿ªÜq÷A•Ç#ý”­IIrN¯‹äú›úʽô“1ß|{°OÍN£%šÐ;×<ç=÷Ûï_vÉÅyŽOã2üãÁÆó4™6yÂKW»uí"Æ|hû;{Æ÷vžÎTó”&•¯>ÿÄÆPg¯™¼—ãÒ²ÅI¢IÁ '‹&f}¯a_ןÆdÑü9Ù§m×¶“ŽÃS|]£Ï èo]ôu1ð¥çíx( € ^$ÉÂ+žŒ@‰•9sæJ»¶m%..Ίt=4‹ì§9ÿ% ò£zýµWäÜszÊÔ²ìŸå6¹¶ukRŽCkyõ•¥VÍš²s×.û}M œÓó,Ù³g¯¬]»Î&­ðŽMiBM—Ú•.UÚ&I¶%'çZ&éíØ,3YiË¡™Sóæ/Qc¾‘Ò¥KË©ö¼3œe¿·iÓæ#†ó³1˜<å{›túèƒw¥f2âÓÏmRÉI‰1ËO:ª¡­ºvݺìC¼õ÷ÃÁCdÃÆÒ±C{9¹]¹Ù,TƒoÆŽ“_ÿvÄi—,Yjwºôòävmíó?̘iÿlqÒ‰R¹R%7n¼tïÖMôBté©öKK“c±Λ·@2Lâi™¡¥ÉÁcÍ÷=q?ü„Çœ<öH?Y³v­”1Žºtó”ÎlµxÓÏ3snæ¬Ù²ô¯¿í,¼gú?iŸ‹ŽŽ–ŸFŽ9æhIII1ñ«aö";ß £­Ó³Ç™öÏïÆOptŒ¿×°·˜89¡¯kôÕ—_”žg)sæþ"ÓgÌ.]Nµ³øÔ…‚ €á#>Ca$ €  ºÿ³~’Óºu•V-O’Ÿçü"]Í/õšøHNNñФ3Æš›Jº|ïü /±I/MJíÛ·/û8]‚¨É§ç_zYRL{GÝÈ&Å6oÞ,ç]p±ì?p@^{å%sÎSL⬇Ybw0Á¦3¢Þxë»ä®D‰ƒ³Ö|;lÄ'¢3˜êÔ©-_}=Z&LšlëlfÉé>aƒ‡ •E¿.ÎwL/½òªtîÜÑ΄Óó¾óÞŽ.’C?Ê®§ãúßýõ÷“ÏFÊ‹/´ã´ßC6¸cÇN»T4¯¢³Åtï3-iiiòÚoÉÇÃFØ¿sôÑöÏnݺH½ºuí×çžÝSŽ=æyü©§%>>Þ~oÇÎöOM”íܹ˺èl@ïá%Æ$u|øQsM̕믽Fîºã6›Üœ1óG3ö[›pÔeŽš¤Òe:ƒN“rš\Ó>$%m“ó/ºÔ,ÍÝg:CÌWÑ„]÷ÓºÊÊ•ÿÊ_fO9'ÅŸkØ×5¤1ñU<–y]£ê­ãÔ¾|í`«$T1˙ۛr;>¿ IDAT}ÇIW_}ày@(~>þ*þÐ@* I‰gÏ–,3 «s§NR51ÑìÅÕD¦MÿÁçÌ—† .õ[ôë¯6A¦%g‚ÌÓQMiÂD3 M¢@Ëo¿ÿadZæüò‹ý³Q£FvöîÖØÌpÒ%š?ú°Ý?K‹¯c £É5Ï];K–()¥J•Ênòê+¯°‰}¼òâs¹N¥cÑ~ë«™}Ï®ºòrÇý6}†Ìþég©[·ŽÑ¥‰A]>˜WÉÈÈ´ûŒi"󀙦ùëS-åÌì2-éééÒãìóåÂK/·¾:ËO—zf1iœ/™Yù/iüã?mõù Ù?kÖ¬aÿÔ™az—S]N©7Ðä¨&ÈJš¤k­Cuæ/\h¯ =ç/óæ; &“t6'Áéä ®á@\CÞ®Q]ÒªÅsýê5¬cÒ’ß#œŒ‘: € z$ÉB/&ô@B hrGg¾è¾d]Nílg}?mºÏvcãœM2ßuh™¥6èYÚ—3W£çÓ¢³£´nï+®‘÷>l“<]ØK>x÷-Û'_Çúì°— :걇² '™™W¤ßƒ÷eQ¯^3Ó®…}èþ^9‹Î»©ïíröùšÙY;í¦óÌÒK'ýÕÙZ‰Uªd7—˜øßׇwW`ºÏØÍ·Þ.W\}­è±:»Kϳoÿ~[]“në7lÌÒW¥É1M|&›e«Z*šQ¦Çê]?uÏ/Q–_‰Ž9øöϳ¥˜úèŒ1½³¦.ï|ÌÌšë}eÙµ{÷MäLÈ9Ù[L8ëÌCK-'Lô+œN¯a'19üÄeË–Íõ-oר§¢.—½û¾r=–þõ—_c¢2 €„¶I²Ð޽C@ Àº_—n¼~å½í>aËWøÞ‹Këi9éĥܡD‚.ÝÓ™@ù•¿ÿùÇ>Õ¼ÙñÙ³¶Úµim¿§wˆÔYO{Ì3ßzç=»Á¼ÞT@÷¶ª\¹’ø:6¿sê>^Z4)”_ÑYºÙØo¿³‰ͦwÅÔ½¶´<ýÌóÒ¬Eûè~æÙy6SÒ, Õd›§8éïe—^lÇ·pѯ6Ax]Ÿ«sÝÄÀÓ–ÎBòÜ,A¿W¦ôÁÙuš‹ŽŽ2w³\jÿÞÁܽR¿W¶l©mîô©‰*½Ãåï‡f„µjÕÒ>ßÂ, ÔöV˜DšîM–_iÕ²¥}ªí¡­[¿Þ.{Õ2sÖ,›P´{–ší§ß_¿a£}þÄfͲû|øò:Ÿ^CºçÙâß~Ïó¦ùvòÐN®a'1ÑÙošDÔ䢖“NÌ}o×èò+ì1¯Ÿ~žk“–úX´h±]–LA@ðpöqqøŒ—‘ € 1?š ØucvÝKjèðOûW³¿—&“ô˜±£¿2ɘÍfóú£¤Ïõ7É’%÷¾:¼è §Ÿ~ž#íM2g̨/d÷®Ý6I¤ „±ßŽ3³°ºËõ×õ‘Å‹]¨‰ÝûI÷GÓ:ގͯӚÌÓ$ÏS?fû«òë²EOÑMúuìš{í7팶—^yMÞ4Ðn\p_­ƒËIó*O?ù¸¤¦¥š¤Tm›,ú÷ßU&á·Ún²ï­¿ •åÖ¾7Ûe¨ž}ÞîÕvß=wÉÃ=`g‹å,çŸ{Ž]Z¹Ò´­ýkÚ¤‰}zÒ”©öÆ¿ü2ßÎlj–ÊŽþòs)c’dže‹ºvÉÒ¿äÏ%KÌRÚ¦2Ö,“LHH°Ç=jŒ×X?÷LYa?¼c©Þ-Sg´iÑ„œºu4ýŽÎqûÊßÍòSÏu1ê‹Ïeï¾½v?._E÷SÓÄÝw~Î"ó´ëäöuýi[KL±µI&¾a⯱<Ã\“9‹¯kT—cê1¿øl¸]V\Ý,Á­e–º –‚ €á#ÀL²ð‰%#A@ —À.ÉÞ K÷#sR4¹sã-·Ù„SLlŒmöÓ‰n^ï­Ü÷`?»±¾Î†ªW¯žÝ¯ê&“Ò<šÚg6zïÖõT³ôóY`öºëžûíŒ(-ÞŽÍa¿ÎN*[®¬T¯^M¶¶ç×ÃÝo÷{ïý³gû謤Y³²õï¼½¯×ñ4hPßn¿ß,yœ8yŠÜzÇÝ6Aæ«¿šÓ;UŽ=ÆÎÜûôó/ì *½ÓåÝOËuNuÐ$X]³oÚñÇ55wÝ*™q=ñÔ[OcÑ÷ö;eÊÔï¥jÕªk`Ì 8O¹ï‡í&üºoš&ÎÞÿð#ùläùŽMg˜ zýM›üÛc–S¾ùö»öx½sèðŸJ ³ÿØUfæ¡&‚ÔØStiåC?fÇT­ZUY·~ƒ<üØ>/)½«¥ºMž<Õgݼ*8½†}]Cž{ÁÞ]TïÔzì±GK?3–íÛwdŸÒ×5zïýÉÈ/¿6 Þrvém½zuM\þKÊhp„ €!'Õ´Y‹#w{ ¹nÒ!@@ÂÌ=ÃnÆßª]G¯Ë1 sŽE@Ü,ÀL27G¾#€ €~ äXEéç‘TG@Â[€$YxÇ—Ñ!€ € € €8`¹¥$ª € € € €„·3ÉÂ;¾Œ@@@@ÀI2HTA@@@o’dá_F‡ € € € à@€$™$ª € € € €„·I²ðŽ/£C@@@p @’ÌU@@@@Â[€$YxÇ—Ñ!€ € € €8 Iæ‰* € € € €á-@’,¼ãËè@@@@$s€D@@@@ð IÞñet € € € €H’9@¢  € € € €@x $ ïø2:@@@@$É Q@@@@ ¼H’…w| € € € €€’d¨‚ € € € Þ$ÉÂ;¾Œ@@@@ÀI2HTA@@@oØð£CÈK **JjÕª)U$..$¤¦¥IrÒ6Y·aƒdeeåÙcâê‚@æè"1uW¼œô–˜:QrW'1u׈è- €€7’d\ €@ Ô®YSÊ•¯ «×®—ôôôpßccc¥FµªR+Jdݺõy€¸º+®ÄÔ]ñrÒ[bêDÉ]uœÄÔ]#¢· €ÞXnÉõD @å* ²iód.н&35f fö_~…¸º( ¦«ÄÔ]ñrÒ[bêDÉ]uœÄÔ]#¢· €Þ˜IÆõD @ ³Ä2Í,ߣ¸K@—ýhìò+ÄÕ]ñÔÞS÷ÅÌW‰©/!÷=ï+¦î=FÈO€™d\ € € € €/ÀL²ˆ¿@ˆÈgó÷ˆõ—×p‰äã ¦Ä4ü €!)@’,$ÃB§@à ä}ÄàŸ—3W€¸×·8Z'¦Å¡ÜsÓàúÒ: €`¹eAå8@@@@ l˜I6¡d  €€Y,áòÌ%µ‰«KåG7‰©X.©JL](º‰ qÌ$‹¸3`@@@@ØIÆ5D¨3Â3ðÄ5üâJL‰iø 0"@BS€$YhÆ…^!€a)0è•eèˆOeá¢_Ãr| JdêÄq~1œvæÙ~Õ§rÑ äÓÏ¿øJ>üèã¢ï g,Àgåªæy\ïÝ[ Ô!€ ®,· ×È2.@ Ž;®©¼øÜÑdY‹“N ÁæîÒ×#GÈ):†|?é ÁÐd¦ç¡çºì’‹ä†ëúû´´@Mˆå|°iÇMñoªc**"€“3ÉŠ žÓ"€Å-PœK¸<ɲ?ÿ\"C‡" 1³ìúk¯‘«.¿TÎ:ï"Ù»woÀY³Äü碛¸©¯,oýmݪ…ÜvËMÒ¨ÑQr`ÿY½f¼üêë²dé_rÌÑ$&&F–þõwÀcj †BL=}ø~ÒwÙ<š(ÓÇg:«lðGl Ô—k¯¹JZµ8IÊW(/)É)2sÖlyià GLJK¥¢Žée»6A—×÷òó}õ¥ç¥CûvöéÔ´4Ù´i³Ìüq– 6BvíÚíWXœþ›I¯q¿©Œ T’dAå¥q@o6Yöü3RÐdYtt´œNOY¾ò_éqFwùzô7€‡‰@…òå啞•÷>"·ÝuŸ”.]J{ŒlܸɎðªË/“_û="’d¡ÒngôÌî’&Íz›D™_‰²¦MË;o¼&ã'N21½W6oÙ*5kT—J•*…Úâ?Ÿ—¯æ×8|%Í>ü±|`–Ø–/_NŽjØPn¾áZ:ø=ésC_Ù¹s§_çrR™×¸%ê €Z€$Y Eip‰@QÏdp’,ûã3³lÄY°ÐÙže:¶—”íÛeð¡rçm·ÈW£Æä:Íè/>•·Þû@®½úJ©]»–¬Y³ÖÎDZl’+Z|=o&‘éT'ùèý·eú3eØ'Ÿe·ÿlÿ'd÷ž=òì /‡TÄC)®N`òëoÅŠñRªT)?a’ìÞ½Û>¶nM²MÞ{×íÒµË)Ò®]¹úÊÞ2lÄg6ö•*U”»ï¸MÚ¶n%%K–_æ/—¾.[“ç3ÞN:\ uB!¦9ûWl¢Ì¼^>üQ¾BÜ{—L6]ži`veÿ,Ïþº0ñòÁ;®yꀋ"¦—î<˜PY¡ºx¾><8ÞžËY×Îþ:4«vçÎ]²è×Årç½Èð!Ú×ào½k«k ¼ïÑY éééòÍ·ßÉ{|$™™™›;ôoªŽß[]o¯q¯íÃë“S"€„—{’…W<  àjão*/=ÿ¬¼>ðeiÙÂ÷že_ÐKF+sæÎ“²eÊJóf'1þëÌÒ®ûzDºq¶Lš2U¾ôœÄW¨]Ï×óZñ˯G˹fÆš§”+WNNéÜQÆŒõo“zW§ˆ;¿vÝzùý?åé'ÿg—Væ,½)š\yç½åÜ .ÍNŽ>óÔ%^v¥Y~{¡¤¤l—A_´ßó'ñ.⡆Íéz_zpFY^E“žÇ5mâõ5S˜øñ-úË(55M¦~?]Ú·k›}òçô—}ûöIs.+®¾^ºu9U.¾°WžóV7¿×¸?í½gD’dáEÆ€D @Ý:µ¥Iãceâä)v–˜oÇÉEœ„„&ÑtÿÕðÉg_Èþýû¥uë–Ùõ|=¯uö‹&ÖZœÔÜ×½[Y³vÝ‹iß;î±ûÕé²ËO‡}$ÝOëšïɪW¯fg¯¼þÖ;²ÇÌðÛ·o¿ zómÑ=°t™Ÿ§8‰wpFž­v=ý,ñ<¼°ò¡%•›·ä}7ÅÂÆ×hñ\_ÏŠññöäµjÕ´ÿF¾õîû’fö-Û–œ,ߟ(§u=õˆÎùS×spAŽ)Ί àf–[º9zô3–[^Øë<)S¦´|;ê «+%J”ƒÞ°3ˆ<%ç׺ÄG—ì%&$8~^+ꌉ±ã¾3ûŸ- -–ž=ΔÑß|fú¡7œÈG“!C‡K§íåñG’Òf æØqãèlµªU%##C’’¶e?§‰²íÛwH?ãzîï‘ÆAKb•*²yó‘‰²ÂÆ×hñ\#5ªW·KÞ=±Õcué«§ÄÅÆ™½çŽŒ·^NëzÚ*È1Å£ÂY@Ü,@’ÌÍÑ£ï €@˜ø“Ó!—,YRÎîÙCî¸ç~³Ïغl…Çí'ç™e‘û$û{‰‰U²¿Öew5ÌFáž=ªì/v>ž÷¬3>>ÄlXÝÀl ´Üû@¿0Ñýaè/ÓzÄ)fiWëV-m’ìð=’¶m³w»Ô_¤=ñÕÍþu™ß†_¡¯áÎ&§¤È sƒž=Î?Ì]m/ˆ¯Ñ¢½6tÏ@½aо.µ$›™cZ.7Ë,uAoÅIÝÃ_ãNŽ)ZΆ Ž,· Ǩ2&@À%š{ ß#fèûoÖ¯C;£{7{—ÃyóÚY ž‡þ’Üë¼sDïzé)ú÷jÕªÚ¿^uEo›H™;o¾ãç=×oØh7«~øÁûdšÙÄç®].Qvg7u9m³,V—éÅÅÅÙ;[žlö>úí÷ƒ7]ЂÇs´ýZã½~ý™¿p‘Üanà É1M¤ÞÞ÷fùëïe69ãôzp§Vð{=mòxÑGaŠ.=ÿܳåŽ[o–úõêÚ3è2ËFG5 Hüx&:Î7Ë+õµùίJšYÆ>üÐ Mt úo¿ÿ!÷ßsgö¾š´ÖX^œÔ=ü5îä磠& €y 0“Œ+ˆT3;§¸ÊfCö¡Ã?1‰±Eê‚î=6F—;6†3”ï¿[Ú·m#³~úÙ¶=aâdôò 6Q¦›Áß{?ÙeîÎæ)¾ž÷ÜMëõÕhyõ•̆ñqî $c\ 4œ|ú[Ê$¹î¸õ©cîJWÂÎ5zŒ|õµ¹ƒ©9fðÇCå‰Ç–©¿•!7ûÍ”~<.÷™_ÐÇùÊÐÜ%õ~sÓ†L³ Óq¼ 4ˆ TÌ1íÚ½Gö§M™àýÚ÷Òןž+wÜuŸô17Ó8ß$¯Ë”.m—Ã~úù²|ùŠ€ÄϯQÕ,ê˜z;ŸÃ¾Üx]¹áÚkìþŽëLRú‡掿æÎ²º §<ÐïQ¹ëŽ[åë‘#ìë6i[’¼ýî²jÕêÿ^$‡îpé«n^¯q_Çù•Hó € @’,‚Ì@¼Š#E¶ø·ßÌþRŸˆþY˜rÕµ7æy¸Îj8ý¬ór=·rÕ*¹ôÊ>ùžÎÛóç_Ü;×q¥Ë–1ö¯5›É/¶¹³P,¡Ú¯ü¬òëïßæî•Wö¹!_â?þ\*÷¾:×óÛwìÿ=ù´×°øºˆ©o›(˧øºþæ™Ä¸>ò*ˆŸ^£:v_N¾£à_‘ñ5 3=ðîûrtÂd3Ãó‰þÏæ[7ç¿©¾êæõ÷uŒ£NR @/$ɸ<@ŠLÀé/ZìP”DymÎ×óžƒuo«Ûn¹Éìw6âˆý°Ù_··Õ%Ǭ£P‹Óx‡bßC¡OÅ_oñã5š÷rÉŽÿöä …kˆ> € Ê$ÉB9:ô @ $>þð=³ì¯¦Œ?IÆŒ}¢ ðŸ¯Q®@„I²@(Ò €@H ¾\òðNúzÞS¿Ï 7‡äøè”Nãí_«Ô.*oñã5ZTQà< €„·w· ïø2:@@@@$É Q@@@@ ¼H’…w| € € € €€ö$s€D@ <²ÂsX?*â~—1%¦á'Àˆ@E’d¡ú„Y 5-Mâbã$ÍüIq@‰¸8IMKÍ·ÃÄÕ=±ôô”˜º/f¾zLL} ¹ïy_1u߈è1 €@~,·äÚ@"P ))IêÔ®)úÆŸâUm³¤­Ûòí0quG,s&Ȉ©»bæ«·¼N} ¹ïy'1uߨè1 €@~QM›µ`?× a™™™&IVKª$&’(sIìuYÒ–$Y»~½DGçýquI0u“˜º+^NzKL(¹«Ž“˜ºkDô@À›I2®@ B222$33C„JÜqD‰IŽÅHLLŒ×þWw„Óö’˜º(X»JLB¹¨šÃ˜ºhDt@À‹{’qy €* É_ —¥qõ°‰««Ã—gç‰)1 ?F„ €@h °'YhÆ…^!€ € € €¡I²"ÄæT € € € €¡)@’,4ãB¯@@@@ŠP€$Ybs*@@@@Ð`ãþÐŒ ½B‚++%;].¥[t—˜ U‚{.ZG@À¡@ÆŽ­²oÑ9ðãg"éy%µjÕ”* ç°å‚UKMK“ä¤m²nÃÉÊâvÐSä(@À=$ÉÜ+zŠL d§Þ]­®¬x­¤oß°vi@ÂÄV¬*‰—>,%:]&©?ŒÈ³©Ú5kJ¹òdõÚõ’žžw"­0}Èylll¬Ô¨VUjE‰¬[·>PÍÒ €@ˆ °Ü2DC·@` ”>©»$|žY0‘i@ÀoýàfëÏI™“NÏ÷ØÊUdÓæ-AOi4 §çJ0³Ö( €á/ÀL²ð1#DŽˆ‰O$AÆu ’é)[DNåWJ˜%–ifdQ]r©ç¤ €„¿3ÉÂ?ÆŒ@@@@À‡3ɸD@@@À]l¢ï®xÑ[@À%Ì$sI è& PÔ«W./êSr>@ è}&‹òá¨STBp½3É\B€ € €@¤ hŠŒ‚ €@`H’Ö“Ö@@@ Ȭ¶ 20Í#€*ÀrË <ÃF@@@øO€™d\  € € à*,SɦNçs<ÿ=Ñ_þºØg]* €D†3É"#ÎŒ@@ˆ8í̳EZôÏ‹/¿*×øû=ú¸<Üïiqbóˆra° €ù 0“Œ«"Zà¬gH½ºu­AjjªìعS-Z,+V®Œh €@H 8Ù”,gýú°c~ÿýyö¹d@ÿ'¥ç¹„ôpé €@Ñ$+g΂!,0oþ™¿`¡”(QB6h ÝOë*)_m—äääî5]C@ rœÜÛ2gýúðcôï ýMJ–,yÄs‘+ËÈ@È IÙñgô C@g’ýõ÷ßÒ®mk©’PÙ&ÉJ—.-:¶—ZµjIVf¦,ýëoùeÞ|óaôÁ·ÚWô¾T~1I¶f'/•+U’={öÊÏsçÊ¿ÿ®²ÏשS[Ú¶i-åÊ–µ_öÏrùéç9ÙÇÎ_¸HNlv‚T¨PA¶oß!Ó˜!M›6‘£Žjh?ñÖóÍ™û‹­ï«/@òø~ÒwÐ € àS€$™O"* €@¤ÄÆÆÊÑŽ’è˜Ù¸i³öéݻɮ]»dذR²T)9eïÞ½òûf³´lq’Lœ4YvìØ)M›4–n]N•!«‡I†Iªé±?̘)Û¶%Kùòåå’‹.U«VˆíñÍO8A¾?QöíÛ'ݺu‘ /8ß&цšóU¬XQ.2_¹ò_Ù²u«£¾DJ¬' €@d øÚ¸_uYÉ,ÉÔ6!€E$P£f ©Vµª¬[¿Qöïß_ ³–.]Rj׬)©fÖY¹²e ÔÆái¢.ÕÌNÛ»o¯¤$o·É»¬C3ÚrA@@ IÆ%D¨@åÊ•¥nÝ:²zÕIKO/”B\l¬Ô¯WW6lÚh—j¶è>hqqqR¶L»4Ö´¿vÝ:3[-¥°Ms< € € §w·äÂ@"P ¤Ù¿^½:&ñ´ARM‚L×Ýæ¡m¬Ý°Aj™™i1q1…Õ›è tÙ¿«Vˆ›¤nºR§NB·M € € €@^$ɸ.@¨]§¶¤¤ì}ûöÙeŒxìÝ»ÏÜñr§T«’ð¥‘{öì‘+WÚYe$Ê"ð‚eÈ € €I²"@æ €@( Ä™Yd*TmAØh?)i›T(_Ab¢ÿã%##CV¯^# f™h¥JC‰”¾ € € €@þ·˜0@a €@ ®è}©4hP?MÚ¶Ê•-+}o¾QJ–,áWÛ•+V4û†í’ôôŒ€Ì Ë9 -Ý$²víÞ-åÍŒ¯`l´¯‰²õfYg]s7Ψ¨(¿ÆMe@@@À›I2®ˆ8V-[È ×õ‘fF•¿¥JB‚TMLô÷°ª¯KwíÚ´>íÚ½Kâ+”ÝW,E—^¦§¥1›,¸´‰ € €@ $‹àà3t"Q@ïšØ¤Ic{—Ä£5ò›àÄ›KbUw'ÉJ–*)û÷ïø,2ÏŒ²}û˜dÉ %É4h;vì”Ê•+ù?@@@òˆ…ˆ$zõêÚÍêç/X('·k#.Y’kø7^­Œ?A6š»)jÑ¥Œ×õ¹F†øTš7o& Ô7Ç×–&Y¶pÑb{|éÒ¥¥}»¶R»v-‰•uëÖˬŸ~’={öAë«®¶Ý¶Mk»”R˲–ËO?ϱ_ë>b§tê(‰‰U$55Õœÿ×…®Dlœ¤™»Q«¤¥¥K\\lP–[zú¼gï^’dÁ í"€ € ¡$É"4ð H8á¸ãdÉ’¥²vÝ:é\¢£Ô¨Q=;!æËdöO?KõjUå¯eÿÈŸþ—\ëÞ­«hÒæ³‘_ÚÄÐÉ&aÖ³GùòëQG$Š|ÕÕ½Â~˜1S¶mK–òåËË%] «V­–›6ÉéÝ»™pÉ2~ø$“Œ‹‘®]ºøêržÏGÇĈîí¬¢mëŒ=3ø`BÒÌrK½@@”Ë-%I; ò+ÆK•* òÏò6yõ×_ËqM›ªßåË•“ZµjÊœ¹síì.MÞü8K‚—$ vßi@@"O€™d‘sFŒ@Ä 4mÒÄ,Œ“«®èm ôîˆ1fV•.Ô%˜y•(ñ~ÅråËÙ½·r.­ÔD™îùU¦Lé\M:©{TÆҤñ±¢û†éL¬J•*ÙYYÚÖáçÙ³»`›ïgddÚ6ƒµ±~ttŒi;x3ÕUã˜f’’@@@ P$É%I; ÒºWXc“|ÒýÆv˜ÙZžÒµË©6)åÙßKgiåLŒ•)SÆë¸49¦ §²eËd'Ê4SªT)sÉ]¹ŽõU·ZµjÒ­ë©2fì8Ù²e‹=ö¢ {Ù?5‰§çÉ™ÐÓs¤¤êRE³g˜ÎF FѶu_2_ ÆÂœ»¬Ù³mï¾#÷|+L›‹ € €‘-ÀrËÈŽ?£G bŽnt”ìÚ¹KÖ¯ß »÷ìÉ~üiö'kjîv©³Ê´èrGÝ€_‹~Ïe´wß~©’ý¼.‰\¿aƒ´kÛÖÎnÒd\»¶­Í’É$»T2gñUWg‹é’ÍmÛ¶ÙêV­* •+Û¯wš¾ë2ÌÖ-[ØÙoz®-N,Pü8 ¥Jš™jv9dà¥JÅÙqÀõÏÉAññŽðuru@@@üb«Õx@ ÜN=¥³,]ú·lݺ5×PwîØ!'™;U¦¤l—ækMD5o~‚MŽ{ÌѲaãF©^½šüa6ê×Xš¬jfžkݺ•Ý7lÓæÍ²zÍZ©[§ŽtîÜQN8þ83ëk¿LŸ1Ã$ŠÒì¹´þZsÇËíÛ·{­»Ë$Ü*›¤Xû“ÛI££šYc¥$Å$íö˜¤ÞÖ­I&·^{¬y¾­ýöëo¿Kýzõd±ùÓŸøuÓÿòå+È3–`”ªf/¶]»wË~ýfz.:‹,ÞÜésõê5nšö@@@ ‚¢š6kÁÎÊ|0tˆ<½+ä æ†K—-3ɵ̀è,·&&¹¸ôï¿Ì¾dYvÖ[ ‹¶§û¶­^³Ú&6) € € €@ Xn(IÚA\" Þ§˜YsU«¼ÇÕL›ÛMÛifî¡È¢ ²zõêÚå¨$È)K[ € €  ý S@W¬7Ë?+W®tÄ8 ÓyÝS­R劲É,QÕe–ž}Þ Ó¦çØråÊÚdzÓ…µëÖ¢IÚ@@@r pwK.@ RÓRåßUk¤~Ý:²|ÅJ³ßZz¡âJÄÙ¶Ö¬Yg÷nÓ%…): MoN`÷3›ôÇš¤K, #ʱ € € àK€=É| ñ< ÆÕÌM jT«&«ÌÍöîÝW ‘ê 2Míß@b¢£Ì JKŒ¹ËgaJzF†Y²™*{÷íµw±Ôå•z£  € € ,Âý¬^Ñ. €@‘lÞ´YH•õëJrJŠlÙ²ÕñfþºG˜Þɲ²Yb¹ÂÌFÛfî cîœØÍú‹‚“ € € €@Ä 0“,â/@‘83ó«víZR©bEÙ¹s—ì0ýHZzšdºftŒY'¥K•” *H…òå$Ùì¶zõIMÝo–DÆJT€7ë'6 € € €@Q $+*i΃„º€YÎc]•*U4ðW’R%JJ ³×˜n¯%Ó,L5w­Ü`¿Yþ˜"Û’“%ÕÌBÓäYŒÎ‹Š õÒ?@@@ _–[rq €0I®ŒÌ IJJ’-[·Hff¦ˆÙ,ëàÿªcª™ÿôžÍõIŽq!€ € €@8$ ‡(2@ &YÅÞb$¥-@@}èÐï"=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@?´œ7¯j< € D–[—¦@@@@Ü!@’Ìq¢— € € € €A ID\šF@@@p‡I2wĉ^"€ € € €Q€$Yqi@@@@À$ÉÜ'z‰ € € € D’dAÄ¥i@@@@w$sGœè% € € € €@H’—¦@@@@Ü!@’Ìq¢— € € € €A ID\šF@@@p‡I2wĉ^"€ € € €Q€$Yqi@@@@À$ÉÜ'z‰ € € € D’dAÄ¥i@@@@w$sGœè% € € € €@H’—¦@@@@Ü!@’Ìq¢— € € € €A ID\šF@@@p‡I2wĉ^"€ € € €Q€$Yqi@@@@À$ÉÜ'z‰ € € € D’dAÄ¥iÈ_à‘‡ßþ’ý6äÃ"å*_¾¼ô½ùF©Q£z‘ž—“!€ €Å)pì1ÇÈ«/¿ Ó§L”y?Ï’qc¾–Ûo½EJ•,éw·‚ù~*˜mû=P@ˆ I1¡f „–ÀèoÆÊo¿ÿa;µnÝzù𣋴ƒgžÞÝ&ɪW«V¤çåd € €@q \Sþñ‡Ò­k)[¶ŒlMÚ*µk×’›n¸NÞ{çM‰‰öï×Ã`¾Ÿ fÛÅåÏy@ ôüûW0ôÇC@À%Kÿú[Æ~ûííÏsçÊÌgÙ¯çΞa?ݼòòËäûÉãå§§ËsúKéÒ¥³G¦u†¶Nq_§é1 € !¼W‹@3LÂP€™daT†„ € € € àŸI2ÿ¼¨ € € € †$ÉÂ0¨ @@@@À?’dþyQ@@@@ H’…aP € € € €€$Éüó¢6 € € € €@ $ à2$@@@@ÿH’ùçEm@@@@0 I†AeH € € € €þ $óÏ‹Ú € € € €a(@’, ƒÊ@@@@ü I柵@@@@ÂP€$Y•!!€ € € €ø'@’Ì?/j#€ € € €„¡I²0 *CB@@@ðO€$™^ÔF@@@C’daT†„ € € € àŸI2ÿ¼¨ € € € †±a8&†„ € €€+¢¢¢¤V­šR%!Aâââ\9: €@QdddÈŽ;åßU«$333 §$IFA@@ ðµkÖ”rå+Èêµë%==½ð ÒL ñ1dýú k†@ p111R±b¼Ô¯_OV®ü·p:šå–a¤@@ /P¹J‚lÚ¼…Yá)i@ Ìt&ÙöíÛ¥b||ÀFÊL²€QÒ € €…(a–X¦¥¥®ŽF@ B222Eg”ª0“,P’´ƒ € € € àZ’d® GÃ>ÿd˜œÜ®W˜ï¾%;u@ÐÈÊá×@è]¡û¯ÆÿÙ»À¨Šµ Ào! „½¨ t±P¤ƒ¨(M¤‰ bC°¬W¥ˆÒDé ½#Ò{Sz =’@zrç›°KK²I6É–wþ¯a÷œ93Ïìn²ß~3ÖQ€`ÌB¬†¸%ðûô©èúüsyJrß½÷ zõj·]cÌ?á¿ÃG,rÝñ?ŽÆˆaCo«Ë·xqlùg=ªÿ E®ÁJ(@ P€¦Tˆ ¼Ñ€Ïë{ð‹°ÉìŒÙC Ø¥@¯ž=ð@õê·õmËÖmz `K”o¿…öÏ´C…òåÕ èßÛwìÄŽ»,q ÖA P€ (@ P€V$À ™ ›B{ ¬‘Ÿ}Œu«–cóÆuøþÛ¯P¢„îîŒ_§ ÷‹=nëú7_Ä‡ï ‡““ |+—þ…Ö¯L•|¼u+¼3äM´jÙƒ ÄŠ¥‹ÐåÙNú|y¼y³&úg??_|ýÅgØü÷ZlÛ´ß~ýżÕVÁ«W,AËÍïxL¶^¸ð/¼öê@ý˜Ëžn÷$¾ûa´½ûE P€V"ª¦ÙñF>¬ï9`%ol(‡ÜÝ2qY5(&ðõ—Ÿ#,,OwèŒä” yó5ü4vžíúfÍž‹—úõÆÔ_ÓÇz)‚fMš Wßþ¨U³:vxí;uAtt4J—*¥wüZ±ÀEdØ IDATj5jת‰E-Áì¹óL2úß7¸Šví;#.>%üýo;®L™ ü¢(@ äF@2ˆX(@ P€ÈN·Ìs^‘%P20ÉÉÉ 3ö;66‘‘W᯲»°@Mk”Œ1)Ï´{ s,Ô?Ÿ;w/¼ØUªTÆòÅ ðÑû#t¦YVÅßÏO_3$4Ô䡯„bÅŠA‚`†Ò·÷‹8°g‡¾MŸ:ÉxÿL•=æ^Ø¿þ6ׯßÈêÒ|œ (@ P€ lT€A286›¶" Á1ãdÒnÉ+^¼.\¼¨»1wÞ4oÚU*WÂ}÷Ý‹¥ËV»'kƒ½÷ÁÇxª}'àÝw†èÇ2û–=òêU}M?“Lã'ü‚÷?ü#?ý2íRÊä©¿¢f݇ô­gï~Æó’’’…«×nÔlÅží¤(@ P€ €# L™ô3<ú°ÕuùÇÑߣnÚV×.6èNÉø¬ òT@ÖðÚ¹k7Þ|}°ŽvwÇ*“ë¿ÃGpâÄI}íó.`÷ž}ø@-Ö¿víz”’"S5 »KÊ®•'UÀÌã°~,RM£¬¦jRœo+;}ú 9ª¯)™gnnn¨ZµŠ±Ÿ!j­²uëÿÆBµ¦Ùÿûîî…òÔ€•S€ @Á tïÚ_~ÉäÍð…IÁ·2­\°={ ¶ÿÍ—è§2 nn®®§>~0â]«ò”vnß´õêÖ¾cŒ+V¬ ûuòŽ¿o\a-ïöÐyOž?÷OxªÏ¶TrÚnYùøÍÏ>¶Ô_Gl+ƒdŽ8êì3òXÀÙÙ )jº£¡ :Lÿ¸zùb,_²Å}‹ã·ÞAŠZÄßPfÍžƒ:µkaÞÍ©–rQ|¯‚X×­Ö»PVªXÿ»¹»äÄISôñ²ëå ݺÞÑ£×ßzE¼¼°jùuÌjôëÓëŽcdÑþøøŒ64EX=(@ ´€LŸŸðËd}“éó²&¦áß.¤e6³Ø¾€d’ñùLjÀç_~“iæyAôV2ä;<ÓîŽKwjÿ4ä1 8‚€|Áýx›Ö8sæ,š7oj3]ÎM»wïÙ«7"c±~.ÜoýcÄRÀ¦dŠc…òpæì­…òeí¯áï˜i?¼T@K×—_ †räè1t|öy“ç<ô/ÚwîrÛcO>ÓÑøïË—¯àuˆËXÒ#S)eͳ¬J‡Î¦ÛÕy|œ ¬G ý3Ò*É:2Ü×I­‹)ÙÊûö06¸UËzÝÌ¿7þÉؾc—ž*ããã­§àoÞ²—.]ÖÇK¦tãF „Tõd3ïØ¹+Ç.ÜŸ½çÊ;Óÿçää¤Ö/®¾¨KÁGŸŽÔë“J‘%†yõ¬ ùÝ¿hñR ‚ÉÆaý†˜þûLã¿PK1ÈFC_|ý¿Û!uH&üÃõÔè;T–üÿ¾c\suÁì?ðÞGŸbЀþzî—½†Cÿþw[ÒÆUjGí^=º£hQãÚ¨²9PëV-ðÇŸsÔßÏ›GÔÆG¯ x %t=+V®Æ¨±?éŸý|}UÆü«hðÈÃpquÁ–-Û0\-Oaª²¼EfmÏž6¦@îyø!\Sï£3ÿœ¾}^Ä’¥Ëo«T¦LÎP¯ËöÏ lþ{­Z`–,[®>Lì4¥¸ZDð«Õ/¦9þ@aöÅx (@ P ƒÀĄvß}Æ{%R±ByVKŠÈ–¯X‰©Ó¦ë)3mo£—" ¾LW¿Çfϯ3Ÿeg–üÙ{ÃÞÑLG|ø‰1@&-øêóO!›µm×Ý{öE‹fMñl§˜£ÖD}ºÝ“ÆFQK44y¬‘^Ž!cùÉG:×éùðÄ3ôD£¿ÿFßg(ŸòfÍ™‡6Oµ×RS%""R-C±ížhk|¸zþ8pH}°¿í”K—/ël8iwßþ¯ ƒÊ6«[§–>æ›/?Óë¯vz¾;Ú>ÕAíØ}kÓ¡Œí0§íù7R¼€vO=¥ËWèí==*0…Ïo¾–åàáê=ãªZ˸[ÞêËõ·Ñ¢y³L§fÖnY^fíºõòæëú}A‚o[·m7ö «¦rmÛ´Â+ƒ_dzêýdøˆqðÐíu>G V€A²‚õçÕ)`7òÇg«¶íÐà±fùå×f÷ë÷éS±hþ¬Wk„Í_°Èìóx (@ PÀR'OžBaµ“qéR¥t••+UÒYéwIþ÷¿Ãúÿd•)vàÀA$%&©Í_ʨÌ2}Þ¶í;õc7nÜÀÑcÇPYmFÃ’’ بá£zÓg •Ö¥q?ODbb¢ž†)µlÞkÔZ ªO­Z4C°ÚY[ÖMM_J– ÔYhcTFÈu•e‡Ñ?þ¤‚¡tŠ¡üµd6nÚ¬2PÔó$ݲéë’éZóý…gž~ÊxwGüš¯vúNp“ϟñã'ôˆ.^‚K‘õŒez²<&³GΜ=«×2N_$S²:'M™¦ß—ãââð‹Êä*W®,î¹§*äu-¯ß©¿ÎP˯ÄëL²ßÿøó®ÃcN»§¨/QŠ©ìµO?þ@eÀ•ÅD•±š±\U¯YÉmÛ¦ Šñ•D¨÷%ëàtKë ¶„6/ ¿€²[º÷ìÝSx<(@ PÀ¢Ð8rô(äþ‹—.áÞ{î¹#P"° E>˜]¿qžžðòôÔwKÆP$"SöXòO@2R¾úö{Lýåg Øc~¯/.Ó©d¼¦þ’öo)n®núƒiBB"þZ²íÛ=…={÷ãɶcÁ¢Åw4:0 @½ÂÂnezI L–“ð÷»µ“vú¥&2ë¹L½>tê©ìDyžø/Ž-*ãäi•]“¾HÆ[û§Ÿ„···¾»‚Êntqq†ŸŸ¯nì nª¤o‡¹mÏ¿‘â•]@^g2ÍpúÔ´ìGW5]¸š ùóÄÉúË C ½õü–LÝ´cÓÂ2½]Ö4“ìOõGYørvv¹V^ûò:I€’÷ñkjƒ0yÍI&¦Ô›þñô×Ì8Næ´[ñsç/ÀзßÂXõ$Á·ŒE~Ǽ1d(º<Û Ó&OÄ?›¶è@žàY¬C€A2ë¶‚ (@ P þýïžëܾj­'ÉV©•é‹—WZ0LŠdüÈîɲ@l\¬¾oöÜyz3–‚ÀPtt †SÕÚ?†¿ÿ1~YJ†WÆ"\ü6UeVÄ}÷VÅ[ï¤m6”¾„©iòZ>tSò!¿X±¢:¨j,êú9E2ÝþZ¼LOŸ”/©@]Æ5ódºî'ŽPk›½Éb”2}ÊDý_YOÚ#Á2ÃÚJ·]7];Ìn»9 ç1È¥€¬ &ÓÓßSëç¥ß0å­7_SA¯V˜5{nº—Ó­ ¾Ò_ö¾{ïÕS/‡{GÕúÅRÆŽúEDFÞñ:‘ .ɽ|åŠÎ,“ ›¬[fÎy{« ›‰bn»%h×ûÅžX ÞWºw^¿·élÏŒ%Xe‰~ûÝ(½Æ¥¬c6 ?|wss²\ót pº¥Y(@ P€ €m D©ìYªIãFzÿ¸ 2-GcRd “Ê;á¼Î&’é>40®Q&5Yo“%ÿN>ƒÏ¾ø½7L/ø-Ó%`ö¶ú.Ž¥H°«Bùrúg™Æ(‹yKf×:5ý+ÊÄîsòa~—ÚXhð :Æ]­E÷êÀ—Uöá1ý\ÉI‘ÀXSµþ™Lñ\ô×Ò;ªðW0É2;~â„~LÖl’iÀRdGÀ£ÇŽãµAu&››ªÜezo^´='ýå9¦MSª½IŠœ ·ejjå·¾cʱ)5_ßb¸¡¾ 0ì{ï½÷è,ËŒE6VÙ¯¦Æ÷íÝKOo”@WŸ^=qB­))¯!y\¦3÷|¡›–ÉãÏvîdr Ìm·l¦!ï'²sòFµ¿a}²ô•Ê4Ð2j“)Ø—€™´Åz$³ž±`K(@ P€  PàZL-®-eɹX4ïO}ûÝj'Mµƒ¦!s+]LÖ£Û™6¶—Õô={÷éà[hhè­ëÞùüKãFa#ÕÆ¥J–ÄÌÓ0aü*è}Êd=æ´[¦cʦ-ã'¤e|Êúd’•Ü1ÝT|¹ß»ˆ7>PAüÙ3g`ƯSôi¿L¾sí²¬øxÞ 8U¯Y×¼¼à¼kk¦(@ P€-  dg,–‚¨Z¥²úö fΚ}ÛnËÝUðkóÖm:û ¯‹<öü7¯/ÃúÓ Èô¯/õA§./p—m>32¨UãþÛ¦ ’˾$`õÓØQxºÃ³wL¶¯žÚ_odcKýÅL2û{~°G (@ P€Ùé.?T_MÚo2P’qçÁlVÏíT@Ö4 ?~ûýOȬtŒØ, ä•€d²~e*u÷®Ïãà¡ Ë+p©— ÷ÛÈ@±™ (@ P€y# ;SÊ4Ycê°‰©–ysUÖZÐÓ&M@Ù2¥±dÙJ,ükIA7‡×§òY@Ö3“MdÙµå‡à‡Ñc󹼜µ pº¥µÛC P€ €Ã pº¥Ã ù];Ìé–|.PÀz8ÝÒzdž-slN·tìñgï)@ P€ (@ P€ ,,À5É, Êê(@ P€ (@ P€ lO€A2Û3¶˜ (@ P€ (@ PÀÂ\¸ß ¬Ž (@ P€¹HÍÝé<› (@ 0H–#6žD P€ (@Ë $$&ÂÍÕ ‰ê¿, (@ d.àââŒääd‹1qº¥Å(Y(@ P€ r'޲eJ£›[î*âÙ (@;Y±bÅpõê5‹õ”™d£dE (@ P€ÈÀ… ![ÙW®R‰²ÜQòl 䉀¼>Y(@ë 2 9{Öb bÌb”¬ˆ (@ P€¹HMMÅùóô… (@üàtËüõæÕ(@ P€ (@ P€ ¬P€A2+6‰ (@ P€ (@ P $Ë_o^ (@ P€ (@ PÀ ¸&™™ƒâää¤Qõ÷óƒw2S­`“-Ô#ÂÂqþâEÈú¦Š“ºÓ§¨7< {ÂÅÅ¥`Ì«g) 3ÆÆÆ"** ¦Gàk5KF«:À¬×)ß­j̲jŒ9cšU|œ (@ P€!À ™™êeJ—Foœ=wIIIfžÅà RÀÕÕ¥¤"aw[üÖÇÇ… ¹ãZT4RRR ²¹¼¶ÎÎÎ(RÄ ÞjÜ$Pfªðµj¤bÎë”cjEfFSÌS3ªá! (@ P€ÈwÉÌ$÷õ÷C0dfjYÇa̼|%eËÝ5HæááÁ™u —Y­@fLÌuõñ¾kŒ¯U³(­æ s^§S«.³bΘšU¢€ƒ 0#ÚAžÝ¦(@l äÅ É̆BjŠe¢š¾Çb[ò¢‘±»[‘)–É)ɶÕ)o­ŒWfScùZµ½'HV¯SŽ©ý©íõˆ-¦@þ 0{6ÿ¬y% P€°m¼˜ÁÀ…ûmû9ÁÖS€ (@ Ø‘€dÏJ&<—÷°£AeW(@ P O 3üÔÚñ–*Ì$ËŽä]ÏN<Ö î¶¼6•M2S€¯U3¡lè0Ž© ›J äF€Ù³¹Ñã¹ (àhYÍJÉ®ƒdÙc,%X<”(À×jâçÑ¥9¦yËj)@ P€ (@£ƒd|2P€ (@ PÀš˜=kM£Á¶P€ € 0H–ÁNå,ÙвCSÁÛ-óZÊתyN¶tÇÔ–F‹m¥r+À¿Lr+Èó)@ P€9àÂý9sãY (@ P€ (@ P€v$ÀL²l &3²eK‡òëZ[-³ÚÊתYL6uÇÔ¦†‹¥r)À÷¼\òt P€ @$Ë!O£@N$}³’‹Ê:» ¨SZ2çµÔ§$átrRrZ1ÏË7*•+á·ÞÀˆ?FxxD¾]—Ê?™EL^¬kLHþ5‚W¢(@ P€ òU€Ó-ó•;o/¶zùâ¼½€ª=  ¶oZï"EL^+«Çó¼V~''4wó@-—Bður œôM~®íâ®+ 9Æ‹——'úõy… ²Åæ›Ýf ý=jת¡ÿëççkö¹æhk¯![k¯9c`8FbéoÙ9×RÇΛ5M7²Tu¬‡°É$ã|ð9ÀçŸ|˜÷°ä¯wf’eCÓÚSßTp%'mìÛûEôèÖO<Ó7nÜÈ\äææ²Ø½Ékeõx6¼óëÐüZ¸ßEu¨¡«‡1{ÌTÿŠª`YWw¬OŒE²‰oÕeË–Ñ$&&âÚµ(ì?x§NŸÉ/.3®£Ÿf—w‡ääu`Nk ²bÅŠêÃ+V(¯e¯¼öf¦e}™ÓÐü<&Ÿ^óy5¦w£z>úÊ™ºïnçÿðíWhØàýp‚z­^¾|ÿÙ„©Óg ::&[#t×÷Û µÜSµ \\\pøÈÑlÕσ)@ P€ (@4Éü™àììŒöížÄ‰S§Ñ¶M+Ì[°(S‘¬6øÌêqGæ®èì–i€Ì`SLÊ*ªé˜'’MríÙ»»Õ­[!T¨PÍ›6AäÕkˆŒŒ,P^{{ ý8ê8uæ êÖ®e´–@Ùg} e¦Š¥_c:È&.nOãþ§w`¶x³ šý2y~™2 ÞÞEP¹R%¼Ü¯7~<½ú DTTT¶®eÎÁ=º=}2Hf¡(@ P€ € ɲñ´ÈïL†l4ÍxhvÛØ¸Q`¹ŠÉSÅkƒ`îü…·]6¨t) :÷W«†¨èhLSYR iŸY=ž“>äû9ù”ôTÎÅü—[9gWœHº3H&MÕÍUÿ“€cÇNà¡ú¯xqDFD¢°Ga4|ôȸ¤¤¤àÈ±ãØ½{¯1ëÏÃÃ>òÊ– ‚“ ž;wk×mÐç=òP}” *­3Q.\¼„-[·3 »<Û ë6üúÖCÉÀ@,Y¶±±±hÔ°Jø#>>ûöH{nÿ'ßG2ǯƒ¬Z*²±*@6êÇñX¹j >ñ.žzâq}ZdäU|7jÌ]³8só›1m/]Ž?gÏ56ñ«‘Ÿ 44L]s¬³W¼¤§AKY±r5FýIÿ¼`ö˜8y*ž®³Êx«€Ð°0Œ76nÒûùúâÍ×_EƒG†‹« ¶lÙ†á|ŒâÅ‹aè7ÕX×ERR-^Š ¿LÑÏ')õšÏî{[Vcjêñ.Q—õݳ|JÂðsÆã2{,ý±iù”i·QQÑØ»o?^{ëü6uz¾ÐUÅÏúð¬¼õ êæ´«ÌŽ}KeófMðˆz}KýÓgÌÔïçYÖŸ(žC+èÞµ |||L¶lñÒe8þB¾µÚßÏòIHhhޝ™ïy9nO¤(@ XX@>ƒJòÁ‰“§2­Y>—>sÉɦæ]Y¦Qæj·ÌõX‹• <Û±æ/ü Û¶ïÄðw† VÍد2¤ÈôÍ/?ÿD?QÚA¯5õé‡ï{ÕãVÖÕoŽÏÍEúÍiˆ9Ǻºº¢råŠúñËWÒoÙ¼™šÊ3gÁ½;Ú=ÙV³þý÷°~¼UËf¸~ýfÍdñôôÔ÷·PÙh7Ôqr¿üa.ÁY¸PeÞ "6oú˜~ž¬Y»^OžQˆ*{mƳt`­Y“ÆætÍæŽÑd£¿Ã£ÄÊÕkuû?ûâk”*U•+VÔd™½™çæ5&*CCL2’7lˆûö×í¸tù2>ÿò?q¥U{þøm*6nÚŒ={÷ëÇû¼ØCÞó.êñúX½~[>ÞNß7_~¦>À…¡ÓóÝ?}ÎWŸª‚¤Ѷ]Gøx{cÂOc¡°³æÌËò=Áæ7Ÿœ¨_?Ì2É2óÎØ¼ÌŽý^=?kÖx@UÓgg§þ|æàå(`Q™³æë{¡[WlÚ²gÔÑRò;àT[eËûsn‚dÅae (@+pQŸiÛ´j¢ê /™5eªH¬â±F 1aÒɬ|< ¤yëV-3y]S÷7oý„ÉcË©µ­ªÝw/Þ6Bg‰,\¼;¶7ÉJ«l$y|ÈÐ:kIn²žŽdÆHÉêñq€‹ÊT¿Ú5kªÀ”³^kñ’eˆ‰‰·7J• ÄÚõ’œ¢ƒcÇŽŸ@¥ŠtL¾a Àï3g#.6NKÉšfEŠxé±ü]ÖÕ‡x)ÛvìD¯ÝQÂß_g-I9ª²ÒΟÓ?˵$¨"A#‰âËm¯Ê$+W®¬]€ÈÆ…)¿þf I[·lŽ åËaàà72 åö5&™a¯¿:P¡¬;תEsœýè}L™8cÔÔúožl¯¿Ì™>e¢^.á¿ÃG0ò“®2ˆ_P¿Ã“Õ—0¯`ô÷ß û‹}³ü°Q³ÆýÊå)<×½§Þ|DÞËo. ÁŒÕ¼eÖO PÀþä³¥,k#¥Ú¬NŠ!P– k­dËV¬ÊÓ,2¹.ƒdöÿ|3ÙCwww<¥¦qÉ7†ÁÁi™&R>|o˜ž–5múï*‹$Rÿq(ß²M »úɱY=î ´wívpr|ÕÚOæ”³êØ¬Ê™3Á¸¿zuÑÃFj92é¤%^cRÏ.õá,))o¨oüe*­!3P²ºbT–àñ'´éýÕ«éÍ)’A!¯í× TÓm‹è Éœ“)—ÂÛo¾¦×"ÙH’ …¯ystï<¦¨ÊÜ{¨~=5u÷•y‘¤¦lÍÔeå¾&sŽ•/3î½§ª>MÞÃÍ9'g=âY°-yo|ꉶ褖” _ùÝ'¯C1dwÊ¿%ëZþHOŸîKCÖ¶ÔÓ£{W}«ñÀýú‹‡‚(’f(ò»BÞÒÌ%È'_xÊr òÁB~·J¬ZzAþ6L'8ï–z¿ôŠ.œû§ž¾/¿?Òg¬þ5ä&™zé3ï …פ(@Û0ÊdS8É(ËÏ 2ƒƒdÙyî¨iH²Ë˜ÕÞ¤/f¶OÖ[¨¾}ÌxüßÿÑD ÔÚ’…2üý ë*­X¼P/ .)òG•Þm-‹ÇÍmKž—åöŒÌÛÿ¦(³Í êÒLeò˜#Ǧ­˜Ÿñvó®›÷K¦È1ìi—RV­‘…åSÑåÙŽèÕó•-ø¸Î4Ô³rõR‘nÏ?‹žÝ»Ý l¥êÅÄ¥t{þ9tU»!z¨ç€Ô•ª§}¦ßþóf{TûÖ¬[¢E}ôZ(:´×ëaÉãzì-Y8k–/Foõ|‘ºß&›s¤bÞ¬ø{Í ü½°}Óz,[4CÞxM¯YÔ«ïËj·Ë(ã8eæöZº5ŽY;yÚ¯ê5]kV,Ö¯ã¬ÆÓœçq®ŽÉjLù8òA P­+&ësÉú[óÔŽ¯²±Eúå26A–š¬;f(†/žäß±qiYÛ³çÎÓo¹É´L©» ÊÝ2†3¶E‚iÈ“ –¡Hf˜|!dȨ“ßáé§ì6t1úÌ|ôéHt|®»º ycðm±Owì¹I¶Z¯~ ‚ƒ×¤(@;@™díS ʦ‚òsƵGó²›NÕkÖMÿ 8/¯eÓu×W™5ûæ}vÐÖ¯^Žf­Úfç‡8¶–ZGc§úãØT *]é×mÊgõÍl%W”wqƒaËk*œœˆSjšeÊmA©üh‘í]£¤úã\v`4U,õZݵu£±zùf~à`ÙÅRÈXòD ³×©¥ÆÔ܆Ï.Z*ÓCŸ»–¶ŽKæ™)íîçùÝ~WÑ+{=_èŽ6oÆiµpÅŠФq#Ì’LªõûC2æÿQ;Ë:]²¦—d ˱†"g²iÍæÍ[à¤frþ=*KsŠ DËÂýžyZ»eý±¸øxxyyª/  ×l«Ö”å ä‹GYÎ »‹ ËsáÀ¡¬×3“…ûeÊ䆛tÓeÊýûâÙ®=Œ}™6ég”oÇÿ8J÷ëËoþ§>l¤ªlâ: ¹OÿWô‡ùÂEžƒã'NÖí>tˆ¶’…ûœÔ®Ù…ôæò˜d"—*ˆaï}„I?ÿ¨wQ–] ¯) ĉə³ÁÙ8M P€H'`Èú6'@Vóêû;Šk’ÙÑÓ°E›[ ¶ÛQ·ì®+;¡brc±^}Ìzǖ婃`yÊËÊ)¯Á*Ps®â•)ÝEOW Õ»[fVV©››>Ö=U†m¢ZãSÖA¹iS™¥¬Xµ yÝT€Mþ€—ì3ÙÓ0ms÷ž½z±ÿÞ½zb¯ÚÆ^¦Œd»2I³:Ñ1¬3dŸ¦ûþ;]Fñ°ª56_SÄsõƲ ÐÛïªÎUðPÊ×ÿûï {Gïä)k—®Z»×U°O2J‹zûàµ1€LõOP;a‹áWß~gÌX}}ð+:ÙM ÃÂÃðÓÏ¿èÍX(@ P€90ü~Êéù9=™dfÊåw&ƒ™ÍâafX[&™Mæ!YäG&!¬)“,{n¿Wc&YöÆ–™dÙóÊÏ£}‹GçN0iÊ´|™îÁ¿9ósty- P€°KþÝÉ5ÉìáÁ>P€ (@ XD@–bé‚RdúzjéË—¯äK€Ì"`% (@ äX€Ó-sLÇ)@ P€ ìM  „ž.) öËbö [¿áo{ë&ûC P€ € Éø´ (@ P€ ÀM½ûöCn, (@ 8ž§[:Þ˜³Ç (@ P€ (@ P€˜I–­§DºÝ‚²u¦òW€¯ÕüõΫqLóC™× (@ P€ €# 0Hfæè'¨­²Ý\Ýô–Ù,¶#PH-¸›˜p×'«mÏ]ÔöíÉÉ)¶Ó)o©‹‹ŒWÚvõ¦ _«¶÷ÉêuÊ1µ¿1µ½±Å (@ P€Ž À ™™£†²eJãüù‹*èÂ@™™lz˜|ð.£Æ,,4ü®í¸~ã||¼Í@YŽ–y—™Œ—ŒÛÝ _«æYZËQæ¼N9¦Ö2ZæµÃœ15¯&EG`ö¬#>ûN P€'À ™™öçÎWA² T®R ò€Åú$ƒ,,$ ç.\€³Ê3U¢¢¢TОŋÃÅÅÅú;åà-” 2 ɸ999™ÔàkÕ¶ž$æ¼N9¦ö7¦¶Õ#¶–ù+ÀìÙüõæÕ(@ PÀ¶²š•’ÝÞ9U¯Y—_U™©&ÐSRÔ4/Š™)VÀ‡©г³K–Á¯Ô”¤¦¦rX x¸Ì¹¼„Å$8æt— §¡¾VÍÑ´’cÌ|rL­d¼Ìi†™cjNUŽtLýëaç®ÝŽÔeöõ.AA¥á]¤g/ðB P€ÈBÀ0ƒ!::.^´ˆ3ɲÁ(™FÌ6ʘ*Ó9I6Ò6ó¾VíïIÁ1µ¿1e(@ÓÌžå3ƒ (`ž€9³RÌ«éÖQ ’eWŒÇS€ (@ P d‰ˆ`µÌÇ™³g9{!ŒY-(@ ؉@Ì``ÌNžì(@ P€ €}0{Ö>Æ‘½ (@Û0½š¹íõƒ-¦(@ P€ (@ P€ @Ž$Ë1O¤(@ P€ (@ P€°Éìe$Ù P€ (@ P€ (@ 0H–c:žH P€ (@ P€ (`/\¸ß^F’ý (@ P€°y'''•†¿ŸÜÜÜl¾?ì(@ P ¯Žó/"55Õ"—aÌ"Œ¬„ (@ P€¹(Sº4Šxûàì¹ HJJÊ}…¬ (`§®®®(€ 'àüù é%§[Z„‘•P€ (@ P ÷¾þ~¸|%„²ÜS² P€°sù2I~gú©ìkKf’YJ’õP€ (@ P —…ÔËD5}„… (@¬dÊ¥üî´Ta&™¥$Y(@ P€ (@ P€ €Í 0“Ìf‡Ž §(@ P€°K ->l—6ì(@ P $ËC\VM P€ (@ì Xf®ì^•ÇS€ (Àé–|P€ (@ P€ (@ 8¼3Éþ)@ P€ (@kHåtKk¶… H€™d4Øì*(@ P€ (@ P€ €i×úÖ£ (@ P€ (`%Ì$³’`3(@ PÀá\׬Yípf‡)@ P€ €5 ´lÙÊššÃ¶P€ (@‡àtK‡vvš (@ PÀ;u@™2A–¨ŠuP€ (PÀ\¸¿€€—§(@ P€ö$P²d êÕ­ƒÀ€¸ººâZTŽ;Ž"%%Ŭ®úûùÁÙÙ!¡¡foÎAyQ§\wûŽ 3§ fcÎt˾ý <¢ë¼~ý:‚ÏÇôßgbíº ú¾ô.¼hñRŒüê[ý؇à—)ÓÌjÓèï¿Á… ñÍw£ŒÇ/V ógÿŽw†¿]»÷šU¢(@ ˜pqqAÅ åqâä©LªT®„ÓgÎ"999Ï $Ë3ZVL P€ K lÙ2hÛ¦5öì݇M›· >.þ%üѸaCøùúªÎz³@j×®…K—/[4H–uJgΩàTA•_&OÓ.///4kÒ#?ùPx8uê´nÒä©¿bÒÔ鯿™|3Õ—QcÆaú”‰˜5gΟӇôëó"vîÚÃYA >¯K PÀŽ\ÔcmZµ@QìVC˜*µjÖÀcb¤)y$sñ)Zìc;²eW(@ P€ €Í TªT/^²¹vglð·Æñ'uvU||<’Ô7½QQÑ* ékÜH”$ë饾½u,&&FWáî^ýûõÁ‘#GQ¿þƒ¸÷žª(Uª$jÜ_]ý!œ‚P•QÖ½kÄÆÆ¡e‹æxô‘‡Q©RED^½j¬#'u¦o¿øZ«?ÐRׯ]«&<==qîüy”+[:¶Ç±ãÇ‘˜˜ˆÂ… ë¶DEG㪺¾ü£útíÚ5ȆXÍ›5E½zuqµûT{c™­q *]WB²Î kÓº%Bð{Ï>$$$¨öÀ³jêç1•µ'ßÄËã’‰·k÷Á'7C,ý¹æ4NœýýýÔ6ÄêµëQ¾\Y¼ûÎ[:‹LÆ—… (ùýäëç‹F Å7pñÒåÛª«]«†þN~KvzÆÌôÀ€û;Š™d¹IžK P€ ( $¨T¼xq¬[ÿ÷"d €¢®„„d*¶yËV” ÀõGð¿ÿþwÛ±uëÔÆò+õÐÔxmoƒ?þøq* —YɬNÃyÑ*èµáïºÞÞÞx®sGœQYYÁçÎé?È›7mŠ%Ë–£écu¶ÖéÓgn»d jsuûsö LÐu¤¤äÝtô—À|/Ó[÷8”'ÏÈ “¦bÞ¬x@._ìÑsæ.ÐS0Y(@ P€¹ ×ÊUkt5mZµÔÿ•/¤d‡ÔßËV¬ÊÓ,2¹ƒd¹MžO P€ (€ÂîîZAX¦Êõ×uVnÊ¿ÿFôÍì3Yã¬VjÑü2*sêdnªÕç^½zÍXG”ZG-"">>ÞêÛìKض}:uh'Ú¶ÑÁ¯5&¦JÖ˜©ªÝw9 ºåué×ûEôêñÜÜ\u&ÙK^Õz†Ò_eìÉÍP>ù/]ž£fI¦Üd5uóó?Ðãøñ§#sTO¢(@ ˜H({¼u+cösë–-pðпX¾ružȤ] ’ñùI P€ (kظ8]‡d”ÉôÃŒÅËÓ !w™Fè'³®wór°LÍÀ›§§‡ÉsÍ­ÓpråJ•T€ë^¸VÁ>U·dÅÉæRdà½û÷£eóføûŸMHJJºãš×®EaÁ¿PG­§Ö½[WœTS·mß®³ÊòªLRkŽÉºdMÕzd#†¾°°ðÛ.5qòTý¸¥Êœy г{W̘ù§²7 µÔµX(@ 8ž€!Pæìä¤3Êä÷ð~õ¥˜d™»ùOnÕÒ~ó³P€ (@ P ’I©Öߪ\¹âµ+V~j­Ã"÷tJÄ’Àš9ÅËëÖqNêhï"EÔgiÁšœÖ)ç¢E󦨱k7æÍ_ˆy áªÊœ2É’{äá‡pàà!ÔWëÝ­½²þØÚõðÇÌY(RÄKí>ù¨9ÝÊõ1þþ'OŸÆ€þýr]WfHpPÖb“€ (@ P /$&YcûTpLdòs~Ȥ? ’åŨ²N P€ (à€[¶mGÐÙTÀ*T¨d!úÇÕb»² ¿a=2™ÚX¦L’`W µ¾XúrC-Ðïïçg|Üð˜¬ù%õJ‘ÅõÔ7Ìç/¤í.™Ó:å\ÉF“ÅïÃÃÓ2±ônœ†Ò´Éc8þdm3Y¿yÓ&ºÝé‹´K‚RdÓ™®éæê–oÏ‚ï~ƒÏ<¥7=0g'g¸¸¸o†Ì8yÜUÝ/ãc¸ÉTQ P€ €52Êòc ²ŒýåoCkx°  (@ PÀ‚ƒÏa‰ZóJد­en*ðrM­ï%»VJ–¡ü³i3š¨)‚+V@’Ú1RO²s¤¡ìÞ³WíÙ½{õÄ^µü¾ýôC²î–¬ VD¤¤^YÄß01§uJ½Ágƒq®ât{¾‹Þ}34,T_KJõêÕtÜì¹óõ¿e}²ÎjÉšj+úý7Û%÷»«l³–-šé,3ɸ ˆÀÆ›r6ªjº§YE»yì „\¼d†¿3½_ Oï×çE}3”E‹—âó/¾Öÿì£låf(«×¬Ãˆ>6ë²úšæ¶Ñ¼y(@ Pà6•u^Å)¨\3 DóxM P€ (`ÿ-[¶ÂN5ÕåîÝ»vÁæ­ÛôŽ“ö\ê?XûþkÏ]dß(@ P€¨Uã~‹ýÅL2‹ +£(@ P€È+ŒSóê:ŽX¯››¦O™xG×eÍ·~9" ûL P€(À ™:»L P€ (@ô2ݵkÞD¡(@ 8´ƒd=üì<(@ P€° ßÕŽ‘, (@ P /¸»e^ê²n P€ (@ P€ (@›`Ì&†‰¤(@ P€ (@ P€ÈKÉòR—uS€ (@ P€ (@ Ø„×$³‰ab#)@ P€ G ÕqºÊžR€ ¬H€A2+ 6…ö 0uò/hÞ¬™îJll,.]ºŒÅK—bô˜±HNN6«‹÷W¯gg}Fÿÿì® rrR_d³”.]JO‰ 1žyãÆ DFF  D ã}/^2þlX™ë¼d›‡S€ (@ P€pÉ`ÙE ”€Ly >wó.D‹æi;^J‘©ŽÎéRbnkbª‰ù%ï» WWÏõôôDñâ¾õ\ EÖc¡(@ P€ (@ d%À YVB|œȱ€dˆ•+[Ïvûöë9qò$7l¨ÿ-ÇôQ ë§/ááxàþûõ]†3Þ'Ó%·nÛ†ÆB‚c²pÿ»CßÁ¡C‡pôر·ùn'ºª žL5Ü\]9[ÝâȬ (@ P€ @ 0HV€ø¼4ìUàµWáø‘ÿômþÜÙ8~âÞþž±»|ø1š6i‚Õ+—cá¼¹ YÌõëÆÇÇŽ‡¬‡ý{v¡¯ZÛLŠ©û¬Û¶ùlÚ¸~¾¾xiÀ@¤¨Eü-]«>;ü¯ñ6êûï,} ÖG P€ (@ P€(àT®÷Í)Àà¥)@ P€ @Ë–­°s×nBP€  \@frø+??xyyq#‘6 ½@rr2®«‹ÐðpDFDB–ø±dá|!Kj². P€ (@ P€ € ¸¹¸¢JÕJˆÃñãÇ J°PÀZd÷gŸ¢(_¾<Õ†m'ŽŸDbr’ÅšÇ ™Å(Y(@ P€ (@ PÀ6$ƒLd—/_Á™3§m³lµÝ HÐ622Bß*V¬„*U*ãˆZ“ÚRe\“ÌîŸBì (@ P€ (@ P sâjŠ¥d1@ÆgŠ­œ>} qñq(^¼¨ÅšÌ ™Å(Y(@ P€ (@ PÀ6üýý|Ö6ÏV;¬@ðÙ³(áWÂbýgÌb”¬ˆ (@ P€ (`›^žžˆŠŽ²ÍƳÕ+uMo0a©Â5É,%Éz(@ P€ (ààøèƒ¨[§6n܈Ō™bÊÔ_3U)„o¾©w+{iÀ ã±Ïuî„÷G¼{ǹññ ¨ÿh#—f÷)`yW$'q‘~Ë˲ƼHRk”És×R…A2KI² P€ (@ 8¸ÀW_|¦Ö3:‹–mžD©R%ñóOcÕÎc'°qÓf“2-[4Ç»o¿‰}¢XÑÛ×”™7-^|Ûyû¿„²eË8¸2»O P€y%Àé–y%Ëz)@ P€ (à@¥K•BÚµ0vÜxĨ¬°ã'NbÖàh’& IDATì¹x¶sÇ»*ÄÆÆ¢ÿÀW±{÷ž;ŽINIdn… ¹ãÙg;aÒ”i¤Ê®R€ @~ 0H–ŸÚ¼(@ P€ ìT bÅ ˆ@Dd¤±‡GŽC¥ŠïÚãÍ[¶â´ÊrÔNÙ- P€(h¬t y} P€ (@ PÀê<<<sý¶vÆÄÄÀ××7×m÷òòD÷®Ïã—ÉSs]+ (@ ÜM€A2>7(@ P€ (@\ Dª 2 f¥/EÔŽc7boäºî®]žSkÁî={s]+ (@ 0HÆç(@ P€ (ggƒƒá[¼8Š»µÿ=÷TÅÉ“§ruMÉPëñB7f‘åJ‘'S€ €9Ì$3G‰ÇP€ (@ P€™ „……CÖ{eÀË(\¸0*V¬€.j¡ýÅK–Ï DÑ¢>Ù’”:ÂBÃîºCf¶*ãÁ òYÀÝÝþ~~ú&?³X·ƒdÖ=>l(@ P€ lFàÓÏ¿D™ Òظn&O¹óbñÒ[A²™¿MCËæÍÌîOaõòÅ/pGK³Åx (`m’ k(…­­ylOWŠP€ (@ P€°„@hX^ü†ÉªJ”€7Ö®ßpÇã3ÿœ ¹e,qññhÖêqK4uP€ÈwÉsuq1^×ÕÅUg“Å«÷6ë`&™uŽ [E P€ (@»¨S»–¯X…«W¯ÙU¿Ø P€wHŸEf8†ÙdÖý|a&™u[G P€ (@»X¹z äÆB PÀ YdIÉI*›,-ôbø™ÙdÖû `&™õŽ [F P€ (@ P€ € ¸¹¦Æbc㌭7ülxÌ»e÷Mf&™Ý1;H P€ (@ P€ @~ Ä\¿Žø„x$&&Á»H}iY‹,%%YßÇbÌ$³Îqa«(@ P€ (@ P€°aSÁ0Ȭ{@™IfÝãÃÖQ€ (@ P€ (@»p)R>=•b‡«k¦!5)±@ûÉ Yòóâ (@ P€ (@ PÀ±œÜÜQ´iWkÑÎ…½tç“c"µi^B0HV ü¼8(@ P€ (@ P€p''©Ó¾O†k±ÀÛ:y¹À$+ð!`(@ P€ (@ P€ €} ¸—¹~ÞBኵnëhâåS[4±‡·8ƒd>l(@ P€ @^ñòBºaÊ´_ÕŽb yq ÖI P€ @Ξ>ð}b¼´‡“³‹ñè¤èD.Ÿ€˜m‹‘ªvý´†Â ™5ŒÛ@ P€ ì\à‰¶mP¾\9Ý˵8ott NŸ9ƒ={÷2€eçcÏîQ€ €ƒ ¨©•Þ= ßv¯Á¥HÑ[ÉI¸ºa&®®žŠ”¸ëV…à ™U C P€ ìW`ç®Ýص{ÜÝ Á·¸/êׯ‡Î;`Þü…ˆ‹·ß޳g (@(XþÏ CáÊunëùÛ>ÿ$†ž³JɬrXØ( P€ (`¿2õñÒåËXºl:wê€:uja붺Ãhܨ‚‚‚ÔÔ‹>r;vîBjjª~¼lÙ2xø¡ú©”RŽ?-[·éŸ}||Фq#”(á„„•¥¶ÏˆˆÖ-›ÿ]¸pa•ÉvkÖ®Ó×lðÈÃ(S&®®®8þ6mÙ‚ë×oèã»wí‚*ÀW³Æ*¸W\ß¿uûvœ>}Ƭ6ÛïH²g (@ .®(ÞòEkÕN®nÆ#.!bþw¸~è«&c̪‡‡£(@ P€ö+œœŒS§N£RÅ Æ YëV-ÔTÌhLŸ>î*õL»§pãÆ <ô¯†Ç6ü½ááðööÆs;⌠vIÐMΈˆÀ²ßVª`— š7kfÄ»rå ~û}¦þwÑ¢>èØ¡=8¨ÿݪEs\Wט9kŽÆ=ªfO¶m‹9óæƒsõêÖÁŠ•«píZªW»-š5ÅÔ³Ó‘¬yYµÙ~G=£(@ Ü(T¶º~€B¥+ߺ3EM­\;‘jjejBœÕs1HfõCÄR€ (@ûˆ‰‰duI‘L°Ò¥Já×5ëtðI‚cGCåÊ•ŒA²«W¯1¢¢¢TP,Rç˜ë×QÂßËW¬„ßä&ëU(Ÿ¶š¡8;;£eófØ·o?BBCá]¤ˆÊZ+é3~×ÙgR¶nÛŽ>½zªŒ´ Ñ÷ý÷ßa®}De·=¦2Ö¼Ô¹R²j³ýŽ^ÎzV²T Ê•…Ÿ¯/< »ëJbãâtà3øü9\¹’fÎB P€6" ²Ç|ÛôCÑ=à¤~6”¸36ë $\:e#$³™¡bC)@ P€ €ý Q¦ØØ´o–½<=õ;uxÆØQ jIÌP*Wª„j÷Ý«²ÌTpEe}WÓåOO½!€aФ=æÎÅ€ë?X‰IIØ·ÿ€®²ˆw‘;ÎKLLDœ ÚH†’¾ À“⢮kðeÖfûµœõÈÇÛuëÕ»[!œ=w§ÏžÅØX]™§šòêïçÚ5k!>!»÷ìÑ›;°P€ €u HÖXÀ Ÿ¨ì±ªÆ†¦ÄÇ"béxDý3Gý®Nûi+…A2[)¶“ (@ Ø™€¬ÿuOÕ*8©¦\J‰K ˜Ìž;Ï䎗²®X‹æM±ð¯%Æ /YÓLŸ«‚-,“õÅäg)†–M2¾$À–~¥Õ䨂X2-R‚S§Ôî2íѰ˜t~ŪÕz§ÉnjGIÉðºq#VeíDäÕ«V–s/ Ûó]T ë:BÃBõî–R$ÓkÕš5jwËÆx±Gw]çfµëeùrik’U¯VMïˆÙ¸a}“rY­}µzÍZ¬Z½ <Š/tCŠZËìÂÅKºwËË8H™µÙ®ÔÌÎÕSS,%ƒ,T1Í-rìñ“'Q¯N]ØÜhîi<Ž òXÀ£êƒèù)\¼ýŒWJ8w!¿}Œ„3y|õ¼¯Þ)¨\…´ý´Y(@ P€  D eËVعkw\›¥@^ È"ýµ¨‰ ›6gžAfªj*OÓÆ U uŸZÌŸÓ.órœX7D@¾ÈX³f51ò@Àß/- v3:.‘÷U:9£xëÞ(Ö¦/œœ]Ò®§Ö»¶n—NÔ.–U,ùw3É jy] Ø¡ÀÔÉ¿ y³fºg²Ì¥K—±xéRŒ3Vï2fN¹¿zu8»8ãàÁCÆÃMÝgN]æ³qýZ”/_Þä¡Ý{ôDß>½}ŠV;°QYã'LÀÒeËÍ©žÇP€ òTÀÉÕ þO¼ Tk„¢yz­œT^aëÏ*ðhödúÃW*‚Õÿ•+UQÓjoe,ä¤<‡ö& D]‹Òëö™›õjoìOþ 8{ú  ÇÇð¬ÖÐxÑäèp•=öbíÌ¿†äÕ$Ëd^‚Ž$ðÃè1¥n²ÞLîÇøŸÆ!B}c2múof1¼Ü¿vìÜu[ÌÔ}fUfÆAÍZ¶†ú¢Z—ÍÿÆGŸ|†5k×è''§è ™¡OÞj¶Çoƒ±£Gáø‰8vì¸Wà! (@¼ðòe$–¯„}ëçnžyweój.^8'²1Í2c­²Î\ùreqôèQ=ý–…HpUk/–*URȸÁŸy) »W–ìó \ýË/{|B¦ ”Ù[áo{Qö‡V"§§mUëÁTª\ÉØªÃ‡èTnCñññÁÙS'ô/ùßO´m‹w†¼…Íÿü­Ö†énò>9×ÏÏ?|÷?ìÚ¾‡öïÅÏ?ýÙõÌP$C¬ý3Ï`ñÂknP¯6­[Ý¡#nIIi7)©*eØðïŒßÊI&ٵ㚬‹Sí¾jV"ÍfP€ €C ÜÛ GwXe€LÆE6L0ì6š“qº®2Óå‹7ùýÌB ÜHRƨu‹³¾ RŽ“ýxÕl‚Ò¯O¾ SAÙ««§áòø×ì2@&#ÇL2ûyþ²'°*ù¶·jÕ*jæzxçÝwÍjÛ§ŸD½ºu1gÞ<Ìøýã9¦î“l®P•^Þ¬E+$« ß> ¿NŒ¶O¶3¦œ~õôëÿ2Ϊ…žŸïò¾WAµZuê© XÎæË{xx¨ØÚÁMí|¶{7×2kPy(@ 8¼@ª!e;:Ù[}(SÿÏB P€ù(P´EOø=5j2}Õ”øýýc\?ðw>¶"ÿ/ÅL²ü7ç)`׃Й]Gÿ;„e‹ÿÂÌ?ÿÄæ-[-Úç Ò¥Õ.d 0ò˯!Ù]7nÜP?…ªUª¢V­šÆkýþÇLœVkˆÉ.j’&»š•Vçf·¼öê ?òŸÎHë¡vLëüÜó8áBv«áñ (@‹ ¤Ù÷{‚“»§Åë¶D…ññ ðT™`9-žê ª¸¸xãÒ9­‡çQÀÞäKÛ€€\»zÍÞºÆþ°€“šÊ[¢Ëøµd %…ÇÅúØ}€Lè™IVÀO@^žö&0nüÏzM2'õ­qÙ2eðݷߨ©”¥0ü½÷MvUŽËn)]º”žbæ€S€  \ 5)aýÈÍ ‹k`j׬…SgÎf‡Ë›_¸íÙ»W-±À@€/›D Ø‘€K‘¢(Ù4ÜËÝZ{9zû„ÎþRíh–³åjl‘‡Ó-mqÔØf Ø€€dˆ•+[Ïvûö[|âäI4n˜¶u°Ó§w¯Ûzî¿_ßgØÅ*ã}ÁÁÁغmF ªƒc² ï»CßÁ¡C‡pôر<ÕùøÓÏЭëóÆ6æéÅX9(@ PÀÆ®¨…ÅãâQ±|¹l÷¤’:'.6—/§}!ÆB P€y#àR´J½:á¶YäŠIýós‡ éÏ yCÌZ)@G0¬ß%kxÍŸ;ÇOœÀ»Ãß3r|ðáÇhÚ¤ V¯\Ž…óæB‚f1×omY?vÜ8<¨v¿Ü¿gúöé­Ï3ußÀAƒõcÛ6ÿƒM7ÀÏ×/ ¨×ËËräÈQÌž3#?ûTùX(@ P€È\`÷ªvº.áïg6•[¥reµSöN³4[R€Ⱦ€›o)½6…JVÔ'§¦$#löWˆ\ñKö3€³y«;Ã)¨\îcuÃÂQ€ (àH-[¶RÁîšëHcîh}•5C~è!W_ŽV»Nßm»JùJ²ÎªTª„Í*k<<<œ_J9Ú“…ý-0úê‹ê5kVØõíùÂþ~i_„©÷4k*®~A(=x<\‹¦5KM«¼òÛ‡¸¾o­553˶Xòï(®I–%7 (@ P€ r#Š ÿF½:uQ¾\Yµnéy„……몥Èò þþþj©† ½›åÚuëp=6–²Ü ó\ P€™HY©t²Ô¤\™< 7ovh7ÉzøÙy P€ (@ ä@ttŒ ”mTï”P;`—CùÚePØÃC_<6î† šE@é—5Èd'K.k?ã«P€Ž'àZ,%ýdÌ KMTë?þ2±Çv:F†3HæðOP€ (@ P ÿ®\ U»S‡ª—)ê¦V~‘Å_Ô2Ÿsrrædù7¼(à€zËcáæWZ÷ž²ÛŸ ’9à‹‚]¦(@ P€ @A H@Œ{àô(ðú €# 8»{ dÿÑ(X!­Ûj ²ËS†1ƒ,Ý“€A2GzE°¯ (@ P€ (@ 8œ€“šÆØëK¸—«¦û.»X†Lÿ±‡·8œEfv¦(@ P€ (@ P€ €ý ø?; Õ5v0lî7¸¾ýv8‡=c,‡p< (@ P€ (@ X»@±fÝáýÈÓÆfF®˜„è- ­½ÙÒ>É „¥(@ P€ (@ P€y+àY­!|Ÿ~Õx‘˜Ë¹rRÞ^Ô†kğM§(@ P€ (@ P€¦ÜJ”E@ÏOÔöÁi¡ŸØ“ûòçHµ ™l+ÌbJ€A2>/(@ P€ (@ P€ € ÈN–}¾³‡·îURäe„L}WïhÉrwÉøì (@ P€ (@ P€v$àßy( •ª¤{”š+“†"9æªõ0oºÂ YÞ¸²V P€ (@ P€ (ïÞ?…"õŸ0^Wv²Œ¿p4ßÛa‹dÌGm¦(@ P€ (@ P€d2¿ŽoïÞ¾ÑÛ—ÐÉLÉÌ„âa (@ P€ (@ PÀZœ\\ð§õȤ$\9ƒðùßYks­²] ’Yå°°Q (@ P€ (@ PÀ|b-{Á½|u}BjR"B¦€”øXó+à‘`ŒO P€ (@ P€ (`Ã…JWEñ6}Œ=ˆ\: ŽÙp ¦é ’Œ;¯J P€ (@ P€ r- Ó,Kt{pvÕuÅÚ«fæº^G¬€A2Guö™ (@ P€ (@»(Ú¤+ÜËܧû’šÐ??W?¤ØEßò» ’å·8¯G P€ (@ P€ , àZ¼$еég¬)rùD$†[ fǬ‚A2Çwöš (@ P€ @¾ à§±£°mÓ¬[µ}z¿˜éõkÖxS'MÐÇ®]µ ¾7…ÝÝçT­Z¿NùE×·lñty¶S¾ö‡£€5øurk7KµÙµ¿9Í27ã Ynôx.(@ P€ (@ ˜%ðÕŸáÊ•´ló$^~åUtïÚ5jx×sGÿ?¬Y»Í[·EûN]pÏ=UÑ»WO}|¡B…0þÇÑX¿áo4lÒƒ_‚AàñÖ­Ìj ¢€=xVk¯¥uEM¯ óR““í¡kÖÉ Œž¦(@ P€ (à¥K•BÚµ0vÜxÄ\¿Žã'NbÖì¹x¶sG“ÎÎÎð*â…ƒ‡þÕGGGãøñðôôÔÿ®U³\]]1mú $« ÀÉS§0iÊ4<Çl2ÇxB±—z‘~¿¯%¢·/Aü™´× KÎ$˹Ϥ(@ P€ (@3*V¬€ðˆDDF>rô*U¬hòì””|?j ¾øì´jÙBe‰½ŒêÕ«aúo¿ëã]]]Û¹ÁçΡ|ùrf´†‡PÀöŠ6î ·€òº#)±ÑˆXò“íwÊ zÀ ™ ›@ P€ (@ PÀž<<<sý¶.ÆÄÄÀ××÷®Ý^½f-ÂÂÃñÆàA裦YÎ_°¡aaúøÿ;//O´nÕNNN¨P¾<ô„{fdß( œ=}P¼M_£Fäª)H޹€&SÎ$˹Ϥ(@ P€ (@3"U™µÒ—"^^¸{ÃäÙÞEŠ`Úä‰X°è/<ùLG´ïÜížzR̤DEEaðCðB·®X¹ô/ŒöþÙ´!!¡f´†‡PÀ¶еè©eRCÏãÚÆÙ¶Ý!+j=ƒdV4l (@ P€ (@{8 ßâÅQ¬XQc÷d!þ“'O™ìnݺuššŠE-ÑŸ;w£ÆüˆöÏ´3¿wß~ôìÝ­Ÿh‡þ_E¹re±cç.{äcŸ(`p)€¢=güwÄ’q@r…,$À ™… Y (@ P€ (@ ˜ Çæ-[ñÊ€—Q¸paT¬X]Ô"û‹—,3žˆ¢EÓ²cNŸ9ƒ’ê߲ؿ777´}¼5þ;|äV°ÀÅEÿ,õ½üR_Ô©U ÌœÅ! €] oÓNnîºñgÿÃõëíº¿ùÝ9×ü¾ ¯G P€ (@ P€Ž'ðéç_â“ÞÇÆu«ôúd³æÌÅ⥷‚d3›†qãƼ‹|#>øÃß}ÅUö™J*ÃÞýûñá'Ÿᆿû6Ú´j…ø„xìܹ½úöÇÿÛ»øº«úÿãï콚QºwK tÒA)¥‹QŠ(K þü«?ÇODýñ*–MºÒ]:)Ý{ì½ó?çr›”¦ÉMnr×ëè}Øæ~¿g<Ï×&ùÜs>''77ø`qÐD¤vV☫)³ß7Éúíÿ9( Hæ1J*B@@hLÀ&Ý¿û?:åÛééJLLЂ>v½?ÁBÙWcå¡ßÿQöEA X’/¾Õdí¯ ã”ìX­’m«‚eèí6N¶[¶5 !€ € €œJÀn«üpö\åææ„§Oí¢„‘—¹ÞÉýð8µ+ÉÚ•*@@@š/0gÞ|ÙN-rÑ-'V‘™d%;×AÕ¬$kTªD@@@Gé;C"Hæ;sAO@ •³Þ}[Œ;ÿ´µ,úh&OšØÊ–¸@@h{Äq×*4*Æi¨ìàV•l^Ööq É‚xò:m%ðÎ[oê–›oj«êzÏ4Hçœsvƒ6yôÏú|ãi÷Û7Þ Å‹>V\\¬«¾.;kýšÏ¾Ò®G¤@@@ ž@Hd´Ç}Óõ•¼ù3ðic‚dm Lõ Ð6wÞ1]CnPù¢EŸšcÃs=Òà‹/½¬];wé~þ3W}ÿá÷zîùëóÏ7z¤ *A@@Hõ5Ùí–¶TfTÑúÀjc‚dm Lõ ¥¦vÐÿþùQ}¶b™6®_«§Ÿü›:vìèмýÖúÞ·7`úÛ_ÿOøÝC ÑÿøGZºx‘³‚Ën•üÚåÓôë{ïÑeS§êg?ùo-ùôÝôíûíû—\|‘óçô´4ýõ±ÿÕçëÖhÓçôÄãýÊTôéÝ[+–.ÖÔK/=å4ýì¿Ô—_®Ñ£Géúo]§ä¤$ýíÉ'™R@@@ MBBÔ4á[®6ò>~E5ÕUmÚ&•KµÇ#P@6xüÿÓñãÇ5qòEªª®Ö=¿ü…þýܳš:íkšñ‹úÁ÷¿¯§ÿñO§ ºø¢)ºæºoiÄðáºþºojòÅ—*??_]»tQyE…Þ›õ¾óÞÌ7ß”]ñuªò÷§ŸÔ‘#G5aò•––*##£ÁeÝ»w× 3ž×ï~DΞ}Ê:Ž=ªûîÿ}仩eó IDATЉŽÖu×ߨÊJ¾1µá£BÕ €€ß\sõ•Z¾b¥8è7}¦£ €þ#{öx…§uu:\]œ¯‚•³ü§óÍìi|\œ**+UVVÖÌ;Úþ2‚dmoL µ€Íãuþر9f¬ ‹ßýáa³Âk­† ¬Yï ûî¹ÇY­µÂü²qù´Ë´{÷mØð¹zô衘ØXÝ`Vq½üêk:p°y¿ˆôìÙà ¢:ï|eee;mÚ:ëJJJŠ^záßzìÿþªwÞ}÷´ó³`áGúÝoÔÎ]»œ@ 0ìæÃ† Q÷nÝï|“—›§«VéàÁCMrÅÊUÊ<žÙäu\€ €@K’&Þàº-oñ›ª.+iI5>{ODD¸¢ÍB„hÓØ˜h•””úD°Œ ™Ï>2t Àèܹ“³úêØ±c®+''[ééZW¾^¯ÍœiVŒ]çÉ®¹ú*½òê«Îµ{÷îÕ•W]£»ïúžò‘>øp¶`+((8-Ž­×¶iW‚ªüÏÏ~êßrrr\oÛ6ì×mùlõj]}íuΟõ‹ÿѼù œ€žÝrùŠ ÖQ@ü[ ƒù°äë_ÿš³ lÞü…ÊÍËSdD„ÒÓÓ”Ÿ—߬Áíß Y×q €@` $Œ¹B¡qÉn *ÒüîaK’ù=¨9%,&^ѽjó/×TV(ÿә͹ͯ®©¨¨tQØYxX¸ÌV¾,#HæWEÀÿŽ;®ðð0'Y]Ð*Ö|“HIé º_0^~ùÍþà}õïßOgŸu–n›~‡k ÛwìÐòSs}ŠûË£úͯïÓO~ösÕ˜ÿ4V²³sœ6ÓM°Ìnó<¹ü¯YA–••¥¿<ú'M»âÚ·oŸž|êiçU¿ØpS&OÖE—NUÿ~ýœ-¢öp€ƒ‡š^aà3E@à?~Ü—²®A———«°¨¨B·n]5zÔHÙUg¶lÛ¾CK—-wþ|ãõ×iéòÎJeû畟­Ö`sê² ÀkÙŠÚ÷( €@ ¤¥¦òð¾2¶ÐôîW2É“[4n»jÊÝR¹ñ#¥Dš»‚Àº.XæÍí—$îw÷ åzpKÀ –-_nVdý\68f—ÔÚ#7nܨ­Û¶9uíÛ¿_+W­ÔïzH³çÌQžùDß»U³wï^ÎŸí©•Û¶owê°Ån£´5[BCþS¶cçNmüâ g˜ÍqaVœyæW¿m°nÎÜyšùÆ›úûSO(**ê+c²Ÿdü鑇õ«{ïsV®­^³Foþçmçkö@  €€ D›ó;q†>7߇š*ößÿ?Y¤¿ð’Þzû] x¦:wêtÊÛF &»Eÿ™=¯õ6hòÄ ;éûSSíñ> €€o D¹ªÅ²¬¦FeËßjÑ­ÜÔ2V’µÌ»@à46heôו»¾ÿ³ì^-_ò©“óe¹ùäýöïÝ¥êz×Ì0¿€<÷ì?õèŸÿâº/99Ù¬û³³ýÅ~¿iófÝsï¯÷â '¡¾=õòoO>¥>ólƒÝ~ç÷ôÀý÷k¹9½2ÄügÁÂ…úÁÔàšß?üG½ñú«ú탿ÑÏÿç— Þ»ï¾{´Òä›YøÑ‰c–ùÓŸ4oÎl}ûÆô‹/ñ €ø¡@݇-ùù'¶îÛÕb¿ü0å€ÉG6ÁBgd¹&GY]±ÈØ•ʉ‰ :tøðWF¾iÓf×õ[¶lÕø Æ)Î|àbï£ €@óÎèÔQݺtSj‡æà¤Ú2KÌ!LöÒ}ö› 'Rx4¿V®l+L³;#XJXBªΞènÞb³²™yÂbbbjŸå÷òŠUæg)oËÚ€%¶‹ê¶[ÚAVVU:¹É¼Y’ySŸ¶@»Å±wŸÞÚµk·kt6÷×ü“ÓŽ6>>ÎÙ–²båJ×u_lÚäluÍ·[ÿO^½\'W«fÝE¬$ žçŠ‘¶^ 1!QÃG STD¤öšœ»MnÚâ/ ±&À–š¦¡ƒ‡¨¬¼ÌYá_PP{ öH¼àZ…„G8Í•îÞ ¬7mvÓuß[lÊJ­€MÜowïØRóæ6˺y!HÆŠ°ŸÌ¯0«¶ì§¯Ï|CK–.mv½:¤èg?ý‰“¬ÆüBA@ ­ì§øvEÊ€þý]ùÅìjeû²žÔ›Ksò¤ zûÝY®Ãg®¹úÊF»Å÷¯¶š1ê {è’]ѹmÇNíÙ·ß J×/6 f_6pÖ«GwM¡óÁê±Säž /ÆØþ¡Q1Jwµ«á¼^nÿNX‹6q©ùàªÂ|ïõ…àA²{ÀÞ°ŸÆ9œù%£BfKesË;o½©æ‡7ßú^}íõæÞÆu €´X`ñ’¥º|ÚTó{xµÙÊ¿E…æ—ï°ðÚ£èëJllŒ8«ûÔ?##ÃÙþEAÏ Ød6@¶výÏlb• žíÞ³W…æD¼Ñ£FéãEŸ°¢Ì³ÓAm$Œº\¡±‰Î»•™T¼qV8ùÀTÙê*XIÖjB*@:»ÑÝòõ«N|"ãî½\ €@KlN±ÿ˜Dü6Ùþ•_¿ÂYm?à),,:q¨ÌÞ}Úßë nøÖuf»}‘ùåý¸sº%<+0Âl±´+Èš ÕkÖ^»ÝÔ4bØp(#XáÙ¡¶“BBÔ4áz×—ó>~U5ÕU@¨A²X†… € иÀñÌLÍ6'7Vl^1{Zecå¥W^s½UÿÏu_|êïÿ„š°Iú#M2g‹¥›e· d÷èÞM;¦›dþÇݼ›Ëh¾@܉ OíâÜPU˜§ü•ï5ÿf®ô;‚d~7et@@Z›D:í²;¥ãT“ ~%ÐsÙÓÚ·wëWr5kfëå>“à¿Oï¾f+tj³n ¤‹l§ü¼|'/9Ûvf“¦Üìj ñëª)÷îé‹m;Zj'HÆ3€ € €€Ÿ ¤M»S=†ª|ÝBÕ”ùé(èv° ¤D—iGSyÈNƒ“iɶnÝÚèɳj¦NÎpd`Ðv³3ð)@Ì'§…N!€ € д@Me…2ß}R²/ ~&Þ1CCÑ®={Ý?áÒ,ëÖµ«Ö¬]«ãÇ ùÙÔû|w“§ÞáꣳŠ,ë Ï÷™zF€í–žq¤@@@7ŽšÄóeåeêÕ£»wÕ^ÚÛÜSZR¦#GŽº}/7 p:»‚,¦ïˆÚKÌ*²Ü¹ÏDÉ‚h²* € € àK«W¯U¿>½•ž–ÚìnÙkûöé£UŸ­ ºm–ÍFâ– ˜Š¦Ýíº7ù{ªÈ:Ô²º¸Ë/’ùå´Ñi@@@Àÿ ´bå* 2D½zöÐé²ð‡˜FosͰÁƒµdÙ2—ø?#ð)¸ÁÕcÓ§šŠ2åÌù—Oõδ½9ÉÚÞ˜@@@8vü¸>^ô‰F ®Ý»ißþÊÌÌRqq±sGll¬ÒÒÒÔ½[ç4Ë ª¨¤ÄÄÓB0EÀsaáêpù÷]õå-z]Uyœhé9`ÿ¨‰ ™Ì½D@@V   ÐÊ©cÇt“¿»z íªè˜g¼%¥Å&h–í$é·9ÈìI–ÈöQðÚÀ’ÆSé]ö«‹ó•»`†×úBÃÞïýØ ïµNË € €Ò¬{Q@0Gw^55ÕæUcö¼™/šc6(J2ž’6‹OQÊÅ·¹êΙó¬(£Ÿ+É‚oÎ1 € € àÓ6 ÆnJŸž¢€ê\‡ËïVhL‚3¦Šc{•÷é5>Ó|÷7ߊ+@@@@ €¢zž¥„Ñ—»F”õŸÿ3û-+h„ Å‚dîhq- € € €„@ˆÉo—~í/Ì~ÞÚÐHÑç‹T¼yI@ŒA´L€ YËܸ @@@üX éÂëÙ¥¿3‚ê²e½õg? ]÷„A2O(R € € €ø@DFw¥L½ÃÕßÜ9Ϩ2çˆßôŸŽ¶A²¶q¥V@@@ðE³½2ý[÷*$"Êé]Ù-Êûä_ì)}jg‚dí Ns € € € à=ä ×+º÷Ú˜$ýÇ_~H5UUÞë-ûŒA2Ÿ™ :‚ € € €m)`s¥L»ËÕDΜ©üÐö¶l’ºýH€ ™M]E@@@– „FÅ(ãæß*$<© lï&åξe•qW@ $ ÈieP € € € P_ õªŸ(²cOçKö4Ëc/þšm–<" ’ñ@ € € € €@@ $Œ¾\ £¿æcÖ[ªâøþ€3ƒs_€ ™ûfÜ € € €~"Õe€Ò®ù¹«·…«>PÁŠY~Ò{ºÙžÉÚS›¶@@@@ ÝÂâ“Õqú# ‰ˆrÚ,?¼K™o<ÒníÓ $ó¯ù¢· € € € аpeÜúG…§œá\]]R £ÿú¹“Œ‚À©’ñ\ € € € €@` „„(ã[÷(¦ÏÐÚqÕTëØŒûÉCX³ìñÑ$ó8)"€ €@  ôŒÑÃ#"•èCe| €ø¥@Ê%Ó?ò2Wß³ßý›Š7/ñ˱Ðéö HÖ~Ö´„ € `d ‹Ô ¤Pý~h²˜S†à32ôäãiùâµpíÖc=x¿6¬YÙà5hÐ@çž³‚äŠË§é­™¯jղŚûÁ{ºóöïúÆ@é´¹@ÂØo(åÒé®v –¿«Ü^jóviÀÿÂýŒ¨øû…ÉúǦ"­>^Ñ(É‹““õÄÆb-;Z à¶@]€,1¢vY׸Ú@Ù¯ÖU(·¬Æíú¸N<üûßjÏž½šrÉ4uêt†ž~òqíØ¾C‹ŸzåGzZšøíï4ëƒ]•”—×þ £#†éþ~«Í[¶ªgîzöŸOk÷ž=š;oì ÀqC&58ɲdó2eÎ|8€GÌÐ<)ÀJ2OjR8OŽOÒ•½¢ÛT£oR˜$‡5hã™ÍEÚžWå‘vÿ8&Q?<'®A]ÉQ¡šuY K‹ðHT‚þ%`dÔ¾¢†Á0(ûé þ]ð¯Ù¤·¾&йS' :D?ñ” ‹Ì÷ó;õÚëoèÚk®j´«i©©:tø°ÊÊÊ]¯ššÚÿ›ÙCú|㪬¬ÔŽ»´qã&õëÛ×׆N@Àƒ1ǪãÍ¿UHhíï eû6ëèó¿TM•g~Gð`W©ÊG’ùèÄÐ-8½À·úĘ YÃ_JW«P~yµGèžü¢XS»G©[ü‰@ÜwúÇhY¥¶6³ñ•jiœJ@ÀçêdÿÚQ©{×–ká‘?lç•×è™íü»às“F‡üJ W¯žÊÊÎVvNŽ«ß[¶nSï^½G\|œþðЃZ²h¡Þ{û Ýuçí køZÝÍÑÑÑ4ðL­]·Þ¯\è,4_ ¦ÿHq›Y1fN´´¥üèùÇ9ɲù„\iØnÉc€m.`W`Ý5(Vç¦G(*,D«MéñÏ‹”YZ­'.HÒ§‡ËõêŽÇ0ÿzD¼ +kô¿ë‹tË€]Ò-J1á!*¨¨Ñ³[J4Ȭ ß9R#3"t}ßhso©ÞÙS*»•òiÜZ|¤\)¦Íё2Mj…  =ðYAƒ±v7°GÏKÔß6i‘éCý²· Rî+Óô±ºU,³ý˜þI^›{Ñø–@]€ì™í•Zt´68ö×ÍJQ¸Ý·®\{ Ùjé[³FoüM &&F……E º]XX¨:4:”ën¸Iv{eyY™ è¯Gþð;Ù•dOÿã™÷Øüd÷Ýó -_±RK—-÷7ú‹Ípd·ÿY!QÎÕY‡t䩨ªŸÝ›ÁÇ%õXIÆã€m.pŸ z…š@ÕÍ suÍÜ“·§Z›íŒ6£ n]fVlÕ•8“ççü3"õþÞ2Õ!\ÓzD;©¯ÏÎÑO—hCV…ž0°ùUNÀìúù¹N'—ߎJ0Á±ÝdÚ¼Ú´ùì–â—tŽ Ó#cô÷ÍÅ_ Õ]øÜÖ M × ”pÝa‚|ÿÙ]ªCE,Õnó†ð!ºdoì­rÈl÷Æu S·ØgU2š0ºâ7ß5Iùë’îÏxîå˜dqq± ú§â’†ß¿ë_——¯’’UUWkÓæ-šñâ˺pü¸u„…†êÁûïSª ¶ÝÿàC~ãCG@ ù±Ï×wüÅ «Ì=ª#OÜ­ÊÜcͯ„+øR€ ´©@ÇØ0 79¼žÞT¬"³:¬´ªFO™ WÏ„0™®–+12DCL0Ê–‰£t ¨Z[s+•c’`G›d—›@Y¼ ž)®R–Y}ÖTéb`g™ÀÖf…˜ È•˜vžnÙö=/AÿÞV¢Ê­ÎnÝ|a{©îoV¯…ë¥í'V»5ÕÞGÿ¨[AfS,9ñoÏx ›Þ7\¿f™ÿO2#ðšÀ³Ïý[ƒ‡r^7ß:]{÷íS‡”%''¹úÔ¿?í4¹Äš[""Â\du%""B<ü;%$$èÿýø§fÕ‡ö4×’ë𸡓uÆôGétÙÈ=~—*²ûËè§ $ó± ¡;š@†ÙŽdâb ‚[6P–g‚W¢CUQ]£̶F»bÌ»¥qÖÞÚ•aͪ­ïš¯^& öò”ýdHœìJ³¦J‡¨Ú6ívÎS•ÛÍÊÄÈPÙT»L3[l°nÙÑr ÙWªF&˜ÕjyæV‰µûÃAƒøÀØŒP-6Éúû§…©s 9Èü`Êè¢ ÏÌÔÝ?øÑ)G‘ž®ÄÄ-øècç}{ æwÿW££}ÿƒÙ²/ ˜€9¹2ý›¿TÂèË]+Û·Ù9Å’$ý6×^Û-½O³²€ý‡¥~ÐêþÏ á¾~qŠ^™’¬d“쾕 ®y{O‰Î1‰úmÂþº’!=h‚Xï\šâÜÛ#!\O™Üf¶¼hVƒÙëí{×ôþêÖ‰{Mývkæë¥8×Üh¶Sž\lÒ~ûá9q<Œ Z!p÷€½<>Zß%Y+¹Ö :DΞ«Ü\Nªk½&5 àŸaqIê|×ã d%›—êðw óÏ)õÉ^‡\ðÖQ–OøäÔÐ)üSÀnq|aR²[cV5·Lê¥[Ä8'`òRsÕ¸E ÷¬{µê³Õ2Æ à‡#Ï¡ùóçùdÏ#Ïèeô?ªð´®®þ,{G™oüQ5U¾ú|ZjªÓï̬,Ÿôõ÷NM™r‘Ç~Žb»¥¿? ô°yÆfšÕ^v{ä‡ûËÜ %™›¦Œ19ÁJ ùÈ|Ò @@|A nð…J¿ñ7 Š­íNMµ²f=¥¼3|¡{ô!À’Ø„2¼%`O¬üæ¼ÙD÷öÄÊæ–'Ç'©Kl¨æ˜ÀÚû_žjÙÜ{¹@@S $4L)SïPò”ïÈcë ²º¬DÇ_º_E> ÌA3*¯ $óúÐG ¤²ùÁ±ºQßmN—¤ € € €ua ©Ê¸ùAÅô;×…R™y@Gþõs•Ú m&@¬Íh©@@@܈é?R7= («+Å›—èØ ¿Quq¾;Uq-n $s›Œ@@@@À£¡áJv§’&}Ûl¯4‰ŽM©©®Rîœg•3÷9'¶ HÖÖÂÔ € € € Dfô4«Ç~£Èn]×TdéØŒ_«dûgÈ!ÐnÉÚš†@@@@À%`ò'Ž»V©Wü—B"¢\_.Ù²\Ç^z@UÙ`!ЮÉÚ•›Æ@@¼%pÙÔKÔ£{÷ÍoÞ²U²È[]¢]@ hÂS»(ãú{Ýw¸Ë ¦²\Ùï=¡¼E¯™í•î ´˜ ÜcÉl¨”›—§%K—éðá#NGbbb4vÌhuíÚEááá:pà /]êÔEA8!Ù©·Ò®û•¢{žsâʪJå-xAÙsž‘ÌŸ)xS öÈ  € €A*°oÿ~mݶ]“&LPˆÉ3aüÚ½g¯ «+6@öáì9zîùÚ¾c§¦^z‰¢£jóç\4y’9‰-D¯¼6S3^|Y%¥¥š6uªS@Àüí¬ëú³ÈÊíÔÁǦ+ûƒ§ ñ ø„+É|bè € €@{Œùt±*+nù)5™ºb·l)66Fñ ñÎöÍú[++**d¯·ïSZ'˜¨á#†)*"°"R(IDATR{÷Ðî½{U\RâTk¶¹¦¥¦ièà!*+/3¹æÖ¨  6IAï D¤›ÀöU?VìÀót¦tçZe¾þ°Êîñ~'é' $ã‘@@z»urÌèQÚðùF1\{ÌvËââ9Åââb]FveB|¼³¯ÐÐPÙ÷ëeN^³‚‚‚ wm @†Y‰7zÔHm3Û[÷ìÛÿ•“îl@̾là¬Wîf›ì…&wÜJ;~¼5Ír/´R 4:NÉݪä ×Ka'BU…yæäÊ¿ª`åûœ\ÙJcno;r’µ-5#€ €ø˜€ pÙ VÝ«.oØ„ Ç; ÷mBþ;w™üd6È)6hà™N`Ì–¡C+ÄÔqààåççëà¡C&À6Z68f÷=R™™™ÊÎÎñ±ÑûOwì 2 [»~ƒöìÝwú_¨ÍÊ>›Cή=j”Ù.[;Oh_Ð0%Œý†ºÞó†’'ßä ÕTW)ñ›ÚÿûkT°b²öZsS€•dn‚q9 € €€ÿ œkV‰ÙW]Ù¼e«³ò(5µƒ^ã-çË6?Ù5W_©ÁƒÏÑz¤±eÛöN¾²x(Ë31›Ä¿¬¬6ÙÜy tþØótÓ·oPµÙ¶yðÐaÍž;Ov[&¥e#ÌK»‚ìxfV³+°×nß¹S#† ×Ç‹5û>.DÖ Ø-•©_ÿ/EœÑ»Ae¥»×+ëÍ¿¨ìÀ–Ö7B ´ƒ@ø®n‡fh@@ 1ÞS.§>øpN£­l2‰ùëŠÍOöÚëo4¸6;'G¯Í|ó”÷Ûüc NJôßà Ø&l’þH“ƒÌÙbéfÙmVõèÞM;¦›dþl»t“Ëp[ $,L¿ûˆbkpoeîQe¿û¸ ×Îgå˜ÛªÜàMV’ySŸ¶@@üB n[¦¯u6$)ÙW€”ðŽ:xˆvíÙëþ —fùX·®]µfíZ?Nð @ †´›Û-Ûš†@@@ )£&yYy™zõèÞÔ¥_y¿·¹§´¤LGŽuû^n@@€ Ï € €ø”ÀêÕkÕ¯Oo¥§¥6»_öÚ¾}úhÕg+ÙfÙl5.D¨/@Œç@@|J  °@+V®Ò°!CÔ«g. ˆÙbÙÛ\3lð`-Y¶LEÅ%>5:ƒ à?ä$󟹢§ € €À±ãÇõñ¢O4bØpõèÞMûöPff–Š‹‹ƒØØX¥¥¥©{·.Îi– .TQI‰‰§…EÿÈÌÊòŽÒK$ã!@@@Ÿ(((4²EêØ1Ý$äï®C»*:&ÆékIi± še;Iúm2{’%2ŸœF:…øA2¿™*:Š € €@p =z\öUSSm^5æÔKã`ŒÙ XHH(9È‚ó±`Ô €€Ç’yœ” @@@ -l@ŒÝ”m!K €V€Äý< € € € €A/@,è@@@@’ñ € € € €½A² @@@vªÊ*……‡;ã÷3ð°pUUUz¬×Éçlmüb“³Â¬¹%<<\¥¥¥§½ÜVWd<+Í 2[l .6&V5æ?%%%ÍmŠë@ðÔÔ-^ã|Xå‰ÂvKO(R € €@œš=g®òòò5hà™š×sÎQ×®]µcçÎVõ%))QMm΋ó\«ªêúánÅvû¢]%7ãÅ—T^^îܾlù ÝvËÍJ7+ÊŽ;æn•mr}FF†Š‹Š`@wlÒüæO$×o¬½S}Ýí5wlö:‚dîhq- € €@³ìöʺR·zÌžYWêo)´+´ŠŠ‹œNUBÒ¬6íEvÛaµY­Vd‚FM•¦êOˆÿJ]v›¥í{c}mªMO¾oU:vìè`N  € ÐöDª=_9Ý’Äý-y‚¸@@à´Måᪿ2Äl´«¶ê[6ÇVý–Í1ÖÜbXv ä©îq·^Û[Wý¾FDD(::Ú9¬À›ÅÈl^6Û? kÊÛ›}¥m@üE€ ™¿ÌýD@HÀæ)«;qèÁ 1Áž8#´Û#»víâüÙÐÎ19Ëê—b“ô?-5Õõ~ý÷ì™™Y:oô(“H?RvõšMÜ’zíÖP{rä˜Ñ£eƒc6©þ˜Ñ#Mý™ÊÎÎñÚlØd;w61™CŽ:F6Xf¿NA@– °Ý²åv܉ €µ@ŸÞ½5Ú£âÌJ¯+.Ÿ¦wg½ßl»Õⲩ—(Þ¬ Ë3Á(›Ä¿¬¬6ï×§‹—èBs2e¯^=Ui¶7îØ¹Ë9M²®¬^³V“&^¨[Mn°µk×iÝú ÚµŒ;¬nºñçë{÷í×¼ù ZTïÜy t¾9¡ó¦oß j³Âíà¡Ãš=wžWWnY³ºÕm fKh]±ÛL­@Z&Ò¥{ÏæŸIݲ6¸ @@à4S¦\¤UŸ­£¯¿NK–-מ={ƒfÌͨÝÊ™›ë½UjÍí'×!€ àIÖä$KNNqò…z¢°ÝÒŠÔ € à–€Ý"HA@¬Àv³ÂÜ A2_˜ú€ € € €©€¯lq$'Y>€ @ð–ÀK¯¼æ­¦i@hT€•d< € € € €A/@,è@@@@’ñ € € € €½A² @@@@ HÆ3€ € €€TWW+4”Ñ}d:è €€ „††È~ïôTá;°§$©@@V TUV(&&ÖÊBZY·#€ Øö{elLœª*+=6ÐpÕDE € € Ð**óaxXhµâãYQÖ*InFt»‚ÌȪªk<6T‚d£¤"@@Z+Pc~Ø—ªÊË[[÷#€ €€›l·tŒË@@@@O€ YàÍ)#B@@@pS€ ™›`\Ž € € € xÉoN € € € €€›ÉÜãr@@@@À Hxsʈ@@@@Ü Hæ&—#€ € € €žA²À›SF„ € € € à¦A27Á¸@@@@ ð’Þœ2"@@@@7’¹ Æå € € € €'@,ðæ”!€ € € €¸)@ÌM0.G@@@<‚d7§Œ@@@@ÀMÐPâdnšq9 € à1~ó%!€ €­ŒŒjUÜŒ € Ðr~k¹w"€ €žŽŽqê+//SMuµ'ë¦.@@Fì 2 «ûY (@@ï „|ç¶Ûk¼ÛZG@@@@À»!ƒ'HæÝ9 u@@@@/ µßË@ó € € € €Þ Hæý9  € € € €^ Hæå  y@@@@ï $óþÐ@@@@/ $óòÐ< € € € €€÷’yè € € € €€—’yyh@@@@Àûɼ?ô@@@@ÀËɼ<4 € € € à}‚dÞŸz€ € € € àe‚d^žšG@@@ð¾A2ïÏ=@@@@ð²A2/OÍ#€ € € €x_€ ™÷ç€ € € € €xYÀ'ƒd7^zõêéÐ\sõ•êÚµK‹˜âãât×·+**²E÷s € € € €@p´8Hvîˆáš~Û-ŠŒlÛÔŠ•«”y<3 g#-5UééŽñ²©—8Á¾®]¾,ìСƒóž &R@@@@Z&Т Yhh¨Ø÷çÍ_¨1£G©S§3ôö»³”ŸŸßhû!!!²«ë 诈ˆ•›Àž]·cç®Óöûü±ç©w¯žêÖ­«†›`Ùšµë¿âiaëfÞŽŽVii©3®ððpõ5AÊ >wmOµ_·NcÇŒv¶©Úk8¨ÅK—ª¨¨Ø¹ïöïÞªY|èš»õ¶[¾£^|YEÅÅ-GKž#îA@@@|E EA²sÎ:K›6mÖþ4>rœDª €Õ ÌÈ>œ=GÅ&èrö9gk꥗èå—_u­ kêýS]|ÑdhÆŒe‚E_ÿÚåNýŸoüB6HeßÏÎÎÖ/Ì1Á¡0Mš8ñ´ÎMõaÊä‰ZftsæÍSEE¥¾vùe¶ß±c†™•^¯¾>SeeåJHHPuu•Óþéúmƒ‡g˜{·lÛ®/¾hl¬ßy”Žf˜ @@@@öp{%Ù í„7Ýx½ÓK  s¶øÙK]©ÛhÿnƒiEÅEŠiöû'ÄÅÆ:_ºúʯ»Þ²¹Ñ ‹Šœ¿Ûº«Mð¬nK¡ýZQaí{•¦ú˜“›ëºµ©öóòòõŸ·ßu¶DÞxÃõÚi¶GÚ@US÷¹3Õûöï×x³´KçÎ&pWæ˜Û¯ 4«ËêJ|BüWl Ì޵¾ývCt"ÈÖãpgÌ\‹ € € €´‡€[A2»RÊn÷³ù¬òró\ý›4q‚¨©Ÿ,.®6¨e‹ ¤ÙNõXM½òàKJkp¯¿ñ¦³²ë+ï› šÕÖÙü]§+îô¡©öm;Ù&þ‚>vò†Mž4A6ßXIcývg’mpó³šÌlë´/»Š¯.[]=ÖØ:رÕyÛ ¦í“]Ýf‹]iW?0ûe²®Ž¶‡;cæZ@@@@ =ÜÚnis`äè ÉeWpÕ½ì¶E›«þ¶?ûw³eèÁ 1›¸ÆÔÔû'>×ålþ­qcÇ*:*ÊyÛ‚R’“?ç›~effi¤IžoW¶ÙÀÐðáCOkèNšjߎÕnEµÅ®ò²'F„G¨©ûìõÅ%¥JKMuî­oxªÎÛÀ˜Môß×l3µ³“‹=\àà¡CæÀÑŽ lŽ=ÒØd:}²ÅöÉ&õ¯kï“3®®´fíñÀÒ € € € Ðn­$;ë¬AÚdòb\öìÙãœúؽ[7íÝ·Ïy{Ûöºlê%N.¯<¸±Iüë¯kêýS vöÜyΩ7˜“'íj©ââ'™ÝiWTÍ?ßœny¾sÓN>®%Ë–«G÷ۇӵew6Ñ¿]•UYY©,s€À¢E‹¶OwŸ}õ𵿠uë-7k­9‘sÝú öÙæy;tè°³¬þʼú7Ì·ÀYÅvÓ·oPµ¹î ¹Þö¡nÕÙ§‹—è /pNĬ4+ÒìÉ™veš-­G[< Ô‰ € € €´‡@È ÁÃk<ÝÐ&ˆeT{LÒûS•¦Þ÷t|µí1NÚ@@@@hZÀ­í–MWw⊦¶ 6õ¾;mµôZ_èCKûÎ} € € € €žh³ ™çºHM € € € €´­@›l·lÛ.S; € € € €ž`%™g=© @@@@À’ùá¤Ñe@@@@Ï $ó¬'µ!€ € € €ø¡A2?œ4ºŒ € € € àY‚džõ¤6@@@@? H懓F—@@@@<+@̳žÔ† € € € à‡ÉüpÒè2 € € € €€g’yÖ“Ú@@@@üP€ ™N]F@@@ð¬Àÿ„m$Z÷)Ï`IEND®B`‚input-remapper-2.0.1/readme/usage.md000066400000000000000000000416471450214210500173530ustar00rootroot00000000000000# 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 text input field, 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 text input field. 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 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. 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. ## 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": "k(2).k(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` | **systemctl** Stopping the service will stop all ongoing injections ```bash sudo systemctl stop input-remapper sudo systemctl start input-remapper systemctl status input-remapper ``` ## Testing your Installation The following commands can be used to make sure it works: ```bash sudo input-remapper-service & input-remapper-control --command hello ``` should print `Daemon answered with "hello"`. And ```bash sudo input-remapper-control --list-devices ``` should print `Found "...", ...`. If anything looks wrong, feel free to [create an issue](https://github.com/sezanzeb/input-remapper/issues/new). input-remapper-2.0.1/readme/usage_1.png000066400000000000000000000434311450214210500177500ustar00rootroot00000000000000‰PNG  IHDRK‡ÍoiCCPicc(‘u‘;KA…?£¢ø ˆ")T,‚‚Xj,l‚HT0j³»ÙM„l\v7ˆØ 6‚…hã«ðh+Ø*‚"ˆXù|5"ë$ É,³÷ãÌœËÌų†í5DÁÎùnb:YL.EšÞh¡Æ k†çLÎÎÆ©9¾¨Sõ~Xõª½¯êhM™žuÍÂc†ãúÂÂñußQ¼#Üed´”ð±ð+¾Qº^äWÅé"*vçSR=#é Ö+ØÈ¸¶ð pŸÍ¥ó¨›´™¹…9©=2{ñH0MŒ:yVÉâ3,5'™U÷E ¾ÖÄcÈßaWi2â5/]M©–è¦|Y6Tîÿóô¬Ñ‘b÷¶4¾ÁG?4íÁïnüœÁï)Ô?ÃU®ì_“œÆ¿Dß-k}GÞ‚‹ë²¦ïÃå6t?9š«¤z™!Ë‚÷shOBç´,³*­söó›òD·pp²?¼òýèh "¢)† pHYs  ÒÝ~ü IDATx^í|•ÕùÇŸ,’„H°÷F@¦ ˆ`Q°ÖŠÚºª­Uëh­ÖQç¿U[[k[[µ.¬Ö½QQ–²‡€€²÷JÂNBHÈú?¿syãͺ¹7Üñ¾7¿ã'’Ü÷¼ç<ç{Þñ»ÏyÎ9ƒ‡Œ,ÏÊ:(L$@$@$@$@Õ D @í(–xu €K¼oò͘þ¶ >¸"ߢ…3Œ÷ÊÝcôÌ35ŸÝzëõ&ß¹çž-ÿøû#æ÷k®¹Üûxêkì? h0(–LW³¡áD 99I^í?‚wîÜ#­[gÈ÷ß!gžyF¥fFEEÊ»ïL‘ØØX9pð°têÔ^žþ÷ãÒ´i²×8š7O‘‚‚B“"îÝ÷>–Ï?ŸëõùÌH$@N'@±äô¤ý –À¢EËä.“‰“~*6l6 è[ÇÇŸ|&_|L˜p©ìߟ- eÒ¤ó¼æööÛɪUkL~Ôyß}Ê¿Ÿ~Éëó™‘H€œN€bÉé=Hû,Ùsæ›x&ülÙ²ÝpHJJ¬Æcù²•æ³ÂÂ"Y¹Ò%zÚ¶iÝ`¹±á$@$à+Š%_‰1? Ø„@î±Ü KÊÊÊjµ*2*ªâXD„ë×òª¹O}nWb‚MZH3H€HÀ(–ìÑ´‚F`äY®lñññ‚YmH;wî6ÿæå»‚Æ3[·2ÿbˆ®wïÕl))-=UF\ÀìdÁ$@$`WÑv5Œv‘@C&Ð¥KG¹îº+ ‚˜˜¹ãŽ_ÉóÏ¿Z1#Î6'Ž—®Ý:©Ç(QÒÓÓ$77O>ùd¦)b•ËuhßVn¿ý&éÕ»»œÑ¿LUÓöí;ÍG_«&  °?Š%û÷-$  !Š¥ÂgÕ$@$@$@ö'@±dÿ>¢…$@$@$@!$@±Bø¬šH€H€HÀþ(–ìßG´H€H€H „(–BŸU“ ØŸÅ’ýûˆ’ „ÅRá³j   û X²ÑB    X !|VM$@$@$`Köï#ZH$@$@$BK!„ϪI€H€H€ìO€bÉþ}D I€H€H€BH€b)„ðY5 €ý P,Ù¿h! @ P,…>«&  °?Š%û÷-$  !Š¥ÂgÕ$@$@$@ö'@±dÿ>¢…$@$@$@!$@±Bø¬šH€H€HÀþ¢ío"- g\0N²³ÈŠ«Ã¹™u¶mðàÒ·OO‰o/_ý$&&H^^¾,^¼¼ÎsC•aèÐAÒ¶MkyçÝ©¡2Á¯õŽ7Z"##eæÌ9~-—…‘ 8Ÿ=KÎïCG·`ûö]’••’6àE?~ü˜Ôí^iË–é2DÅÒ¬Ù_ÊK/½¦biµìܹ[öíË ¹mþ0 #£…\wíO%!¡±?Š Xè‡ìl׵سg7¹òŠÉ«Ë—‚[µÊË.»È—S˜—HÀÏèYò3Pçï¾ÛèÛ ~Ìݱc;?–Vÿ¢š5K‘' eÇŽÝ…lܸ¥þÚèLxÈÎ?ÿ\™5ë+9~¼ šeR^^4‹k«/.6Vš&7©îG“o¾Y4»5u Ô_úôé!ñqqràÀ!™¿`I…xˆŽŽ–Q£†IgíûÈÈÙ¼y›aQW:ûìášw»ñ”¡Œoü™¼÷ÞÇ2|Ø™’Ѫ¥öÅûrøðQIKknÊOOO3×Ó­{Ë–í¦øÔÔfrÖˆ!’‘ÑRJJKdõêu²|ù*s¬•–1B!Ïñüã²lÙ*Ù°q³9æ©>x»À>µyŠd©7iõªuR¦¢íàÁCæ<ôÛÔ©3L9±±ä—×_-|ø©ôïß[23[™!ÒÏ>›mlG‚WjÔ¨á*hR$[¯ç9z_\~Ùä¹ç_9m11yPERûöm(–êºàxœD€b)@`ñX¼\ >Þ}oªÄé uü¸1æ¥7{ÎüŠ—mß¾=U@-4/¼ÈFŸ=¼,Ö¯ßä5’ºêAA£T8,Yòµ¼õö‡’œœ$çŽ=["4ÞâÍ›4{öW£¥¬¬\>ÿ|n­§ 2Ј/U´ MMš4Ñ]ßî!.¼i¯§öÌŸ¿XŽ9jDæ”—ßðhGçNL<ͱc¹‚ßG!‡NÙâM›­¶òP¯Rkõ‚øšðÂw÷x -jsÎH=fDEq±Ú·¨;))Á¼¨!Üêo¥ÃY5%ôw;SÓ¦}^q"d¯­» %x(»véhî™*bàÕÚ°A‡Ouv$†r;vhW1æ^ÎCùïV™‰‡k ⩦úÐÎ.;–•|­‰8ד•Ìß:´f %|ž––j^Eyêa²Rtt”Äi=9neøÚGUó7Òòòu˜‘‰H 4(–BÃÝ‘µ»âjJðN Õ4ìP^VæÓp„§z*^p7ãžð‚rÕ_;Ú¨Sy¼…o½tkj“/íõ¦=žl:õî¯ÆÐñYS›KªˆV«ެ*ìNªG#.Î%ˆ­á/ËÖ²²ÚÅ/b\ÏMt¤§Ë7k*MÓ„!®×^×Ä*Y1g¨^IËqóBYu§¤$›_1”WqMhÞ¦ÉÉêitÕÑ¢†ú0dQí.<1l†˜1ËVœ·ú—×Ò”£biíÚõ7i’$qñqF\U”ç6£³×\vW—Þ^w5åKÖá_w–§SÏ%ð—ðϨb`ŠÔÓÔ²eeO_ÍÕm™_¹!îÄ=¥¥67uÀ$ÄÂàèžÌË×-•kì‹%j2.??_JT ZÃ0îy‚ÙÞ¼¼ãRªë©ÚÆJíÑX÷äM›kj'ÚR¬¼ï…ûÊÌÏ/0^AaWæ©B§–ÔX— Àì7Kü@p5ÑØ²ªËD ÐîB ŸYÇP^5Y62Ò%’‘¬ÙÛ·ï4¯¦ú¼TXäŠÕ‚èÅ0 ¼¤εγ„ ¼n7–ý§†qNK Ë©*G[j,Oã­0éÀ_NÔ‘ªìÝm¨< „ÅR@°6¼B!TC„õ‚ŒáÌ2ë×·— Üõw‚;£3ìÖJgI! øÛo7T õaÈAÐñññæEŒ!›Žj—{ÊÍË3Á¼0NHH¨f"††¾Õ8˜¡ú2í¤e!À^³½k×m0/u´/ï:“¯}ûÊíñ¦Í5õ„Ðjí»Ág0ë %«ç³Ëúè"™à‚¶‚­©_½&èÛ^:ƒ¯M›Vµv+¸Ÿ<óƒLðÎÀÃXÕs…Ùx………&Æ} A†¾DÂ1pÄ-%&&š©óXËÓ!@Ä 6ÈØƒ8¥1³†k ¯¶úŒ€QžÝ4˜½I“D ­C­ßqV=ÏŠ{²‚½Qwz‹T#¬ŠTpU-oüøÑ«Bí€Æ÷Õä‘D×µ?û‰©©®¿‘qc˜­ªõÈü}ï²<p"Ã9±×lj3má5r¸$jœ ¦¾c: Ö ÂôñæúBÇ /¥-T¾páÒ 2˜)7fL¼\yå%ƦœœC²HWÆȰÊ€—à'—_l¦Á¿òê[ÕÈ¢Ìââ“:-|¨4V¯Þ’¹s]³ÿ‚Ù^¬äÝH=eXRž–í;v©€úΈ'+yÓæÚ.¥ËVJ‘¹!¾lŒÎ²ƒà€wÅš¾àrˆŒ Î1Ãkø3±tAM  ñÊËEJJJ¤¨¨HNœ(ôºˆÈÈiÕ*CZ´H“„„Æåõ¹ÌX¥e¥’Ÿ_ 99dß¾ýRV¦ìeŠŠŒ”üèÊ{ÕKhAȆû´\ÿW^V&¥úãKâó×ZÁË[ßgoð,dMµð»XŠÔ‡nrr‰Žæ‹Ô®—^ˆ11Ñæ'..NŽËÕ—«ç‡qll#éÛ§§$&&ÚµY Ú.×ä&Iæ'£eº¬YûŠá“u2‰ŽŽˆ`&ûÀ} +Ú·e‘æ N]‰Ïߺ…öx}ž½¡µ˜µ[ü*–pcS(9ë₨EŸ9r´VÃñîÛ·—$&$H£F1ÒR_Æ{÷fI÷î]Ôs)ß~»AïÊßCÆa£téÒÁx wíÚk-„í×+¿ñèaŠQ¡A¡äˆ‚ÂÖ“`âó×]Ya¤7Ï^gµ(¼­XQRRZ«˜Ï_@Ú(«§g¯¯f¶oßVvìØåëiÌï¿xÇÅÆzQ%³Ø‘@¬‡¾CŒRJJSsàÀA %›ubqq±zi%KFFKc]‹éµZ‰%&瀗·¶Äç¯óú{zö:³Eáiµ_Ÿ˜Qê&fr&¸økKæFÊÈh¡ÿg|‹{øÐ¡ÃRXXdL´ú­&{M, “ãxê7>ׯ`OÏ^g¶(<­ö«Xâó×¹‰§¾Cð0ÒzƒƒÉ¾0üfŵDGÕ>É‚÷ª}ûГežú}~}êÌ…§Õ~Kቈ­Ä'!&‚Éþ233ìo$-$  XrPg…ÚTÌšc²?ÇØßHZH$@"@±ä Î µ©;wî µ ¬ß ˜ÇD$@$à? *"{á‚é†Üˆ³Î÷ÁTRÓ¦MÌ–HII‰rÎ9#¥OŸ’žîš}—#k×®—¹sH^^~ ª Ë21 GïRXvmÈÕ¬YŠLž|¡Œ>ĬæáùÝ»÷Ê‚…Kä½÷>‘ÜÜÀ<BÞpÐà 4(±¬ÞŽ—Y_¼oª»ìò_Èž=ûªUíMž`Ùëm=†8°Ÿ\}õef{÷Ô¶m¦àgìØQòê«oË×_ã­©ŽÈ‡)ÃO=õ˜±õþ¤[•øg½3' %L…ï§ ž"}·~“Ù‚Çé)Û„>7îl¹û®ßhìbåû´[·N‚ŸK'_$üã_eá¢eAíÂwÞ~IZ·ÎûxÌ|±b"@ Xò‚êÏ®¹\®¿þj“ó£¦Ë_Ÿø·g…_–@ ÃA(]ýU®mjIQȃT“`ºá†kdÀ€¾æ8¾é˜•¬gΜ+ëõÜÐR(‡áZêÚN®%&Dê2ð:09Ÿ„ÒCÞåñ>MJJ?ÿùAùÝ]Ë’%+jmô£Ü'£G¨¸_á­Þºu‡Ìøl¶|¦?ÁZìöŠ+.‘­[¶Ë’¥_;¿ƒØ‚€`Ì’ˆðƒ±æÆvðr`µdO©¼¼îM/½Éã…iAÍ‚a8& ½Á£äÍš?ÈsÕU—yÜ›.77ϬXÍ›{ôè*·Ýv½®8žéO“ýZV ^ ¡œ —¢Ã4HØÈ·iÓd¯úÖ¯PY˜ß `è %oîSxÕ|àN³š|]éNDÀ*óHøÒtÿ}¿•?ÿéÝ«0ð¯¥~ýz˯nºN† T—™5bö¾ûn7ìigyİøÛĉãeÈ’””¤q=‡eÁ‚¥2kÖW[H ÏùçŸkò4mÚTŽ?.›7o“—^zÝäÁ~N\à*#99ÙÄp-]ºB>ýô ]ß¨ÔØõÈ#¿×Uyé¿O1ؽ{gyþùÿ©»UðMÖ!‹.‚->üpšÙ–û3…j‹`Æi» NœÐë¿XšêìI\3G«hú¦­ÞO‰*”Á +—é‘ÈSžÅîÊKTìÕÏ­aIœÓ»WwS†êÐGXU¾±wcË×êåGõþÌÑ®-xzö즢9Êx,°]Ê,QÖ9ºúùd¦¼ýÎGRª}USªëœQ#‡ÉwüÊœzé䚟-[¶É5?»¥^õ…¢OX§ÀŽk¹¶´Çm»¨ÀKxÿ´)d¥L˜0ÖÔ½`áR}±.1¿[ŸYFÝß2lØ™Æû„…ïúÝ­ÕVeõ&OÈéeÅþ†ë£›½úš¼9›ŽâE‰äႾÃÞvóç/6ÞëƒÊJ?ûÙå*†Æ!„=–ðB‡ðPBºF‡d!¦°Y-Ž'$ÄëßãÌÝ=5i’$wÞy³ôîÝÝ\ ]×^ûý»‡f+7âŠ+&›Í‡ýB5 Ò±cy¬Ï¬6boA°A‹¶m›ÌJ x T­„-\Ž/¨°2 IDATèO³ã:Ü ‘„¾ÆC/==µJ,ÊÙ¥KGs¼¨_^b$Sc[Ü÷¯l×¶9/Øãz]àvy6\¢ 1s-U˜áÅ|èàaóyëV-Àò¶Mþîß`—‡`n+A<âå²iÓV¹õ¶{å–[ï1_ª®B=LÅ/ qNjˆî'+ýþÞßÈÕW]*y*`§NaîÙ›oþ¹ÞK?­µøºÎè:q·}ûNùdÚL™÷å"ów]çúÒ&æµ?„ de0?ø’W¤_†¬¿ñ¯{òïWZ›°±f½ÕfNmǫΒÃôܱg›b¾új±™…‡æ!Í7ÍÇJýÖ:hP“çŽ;’U«Ö˜]ÿyöoÕ{“Ç&è<šáïÙpii•_nÞ0°fÊÕ”wÈAfø /͸¸8ó-ž#$< >ü ÿûß·Œ`Z¹r4#GUoÕgÒ¼yJE_þë_/o"’µwSjj3-c€ùìŸÿ|^½Û÷ꡇ~g¼Š­ô%Š`þØØx”gDÀOƒ×Ï0<+ÂR,ù«‹ðÅ·P<á¦E öÌ3ÏP·ð9òæ[˜YHx(C(!áAŽÛúëMÙÈrü= g}{÷ÅfÕ:µ&ˆ9+® Bé±ÇþQá‰ÀØŠ¹¸ÿþßV*7^Œ;ÈSXXX!”ÑòNµnÝÊœ‡Ø5%$\Õ〡Xx!víÚ£¿·P1´A‡æ>3¢e¦¦67ù!Òð^6(Ëß+£‡bž8xÉ0LuB¿¡Y÷D0ð.AÄÀC‡äêr-áSª^»¨Sq*¥¥%Æ3…~Lј'ˆ'<ȰQ0b¡ðbîu>¯)ÖÅ}© ì™Ö–7‚ õ[öàoË‹ˆ«H˜zн[gó»•bTFDDzÕ¦J':ðªñ•°ÖþƒhNaáÉjÛ ••z¸QkaPÕËÚ¹ssï`„ö¿/WžTÓ\¯)\Oðº§úœc:ç:°[i²ðÜÁµŽgRXŠ¥ÚÖQòu¥ ØïAUoÔ„ ç±dîd·¬Å¿Òxº7y¼è¸Pgñ÷0â>æK:p ²çÀýÜ™3çèlš9òÿw·&'ç›oÖU*×)S^¯V%ú˺«Íž²²šc$¬‰|xY[AÛð.effj<Ó×úP³.=^9ø¿¶2}aR5o(†áàiEÂP×ýûT2 Ǫz|Ü3T t?¤±dFøêð›õÊ…h+œöíÛ³_1¾¤$''I‹SksUeá~–W”æÊåí^jx`îØ¹«Z—x ÐTðþé\õ=ÞÒ®];™ÓÑ.Ä(a"¼¸¸Ÿà¹»çÞ?Ê Ïÿ½¢Š={«/—RWý}û¸–¨úœÁŠ?üÁåžïW[ªÏ9VY§sn]mäqgÀóÃÿHa)–üÑxÀc¸ ÓŸÝÝu:´3C(x€à¡¯ ˜ªÞ¹Só·•¼Éã›]†¿‡áÖ¬ùÎg±„s<%xnÞygªüâWÊ•WêÔ`õÁË·wo–yÐãÛ(^°ÖÊÂð ¼ V@1^ÈÊÞ°Á5 ± ®k¼NÚÇÖ0†dÜ×Óš8q¢LŸ>Ýœ·xñÒŠ²1Ô¾}@ oþöw ö0†Ù¬8¤B}yº …xõþÄ+Oxi,/b‰7nl–x€§§ê†¿¹¹ùR¬Ãvං挄üJ8ž••m>«ï,MËx§ƒfy—Ðÿð.êK 1p°^0+¹btʽn“¿û8˜åÍ×XMK,ÁÓÏ®ùǽ_¹ËÝ÷üÁxÝ“ßé­øò‰XA¤éø„û˺ߎ=zjèÕU"†Õ- {Þžï&Rœ [ÉÛs½mó9ŸF°$þ¥Xª¥?Çc< ˆ¡øé7Tº1§¼ô”ŽÓwÖ`ásÍx÷ªUkåŒ3úÈSÿü“lظY:vljüÓåuÂK¶®‚= ‡/–€q­kõý îˆ ÅíUoƒ5Ÿo„<@ì[¡f+½õÖ‡æaŽîÚš—å¬Y_AæžP÷Î;u逫åòË'k]}ä™g¦ÑŒ„0ÚóÝwëjŠÏǃ= gÍx;jbŠ*ǪàË¥ñ¢Û¡3§Ž);¤Xe‹kª¦­l R ¢Ÿú#j¯ŠZx– ’0q‹.,hy |……!¤}bH§±ÆFáiÅF¡¬Ý*ˆP„¾,à¼äi-€ à5k¾5CoÖ0ŒSà#0 ?›6m’[n¹E=KÏè²O›¡>÷Kýݦ`ÃùÛ~–gOðþ\zÙufÁI¬£„5«àñÅÖ¡Ãb‘î³äìÙ ZEÞ@œ2fÓº–ÑÝ’gÉ;D 7W]ž%ˆ%÷©Ø —Tí-·<­ gÉþýʘ%û÷‘m,¬ï¬#Û4 Ȇ@8!Ãò0­^½Zxà¹ûî;t&\ëjkÓøË¼Pî ç¯6° °Š%;õ†Ím Ô0œÍ›}ZæYBÉM+W®TÏÒãrÏ=wš•ÆqÜ߉Ãpþ&ÊòH€:Š¥†~øÐþ@wûP½#³º{—,Á´téRöü¤<øàïÍŒ.÷5¼üÑHì{ÈrØÈ2H€HÀIüà­(¼]×I‚­ž¶)Õ•“£"£ÌZ(XHÐÓʹ •/mÄ”S&ü;cÆ ]n C¥Ó~ø~M0Ëé×´]‡/u!/â¤Z´HÕõ{œNÏ{ÕW²öÈïé>eŸÚ£|µÂSŸúZóŽ€_ž!cã9&çðäÝÈÏ/d³Z´Y\kž0yG ]»vº2ñòÆoèZLÏéB¦çëÜ}fÛˆ#ü4nœ ‹_V^Ù»Ò«ç—•ìl×R Ö2ý5•ˆÅ0ëk3Ï󞀧5Éøüõž£rúÛ³l§¶…“-~U6X bÉ™—‡µYlMÖçää±´oŸk{ ,¨h¶¤@¼3éÕluÇŽuEâ¹f+©S§ê*áoêêÝ¿ÕmqºëRÆ „„ YO׫„a7¬³ƒ% ¬mW²³sjÅ OW¤z ™œEÀÓÂ|þ:«/-k=={Ù¢ð´:ªuf»‡ñàöGÂÚ2xXŸîƒß¶° ï `;šVQ¶JÀõØìáÏEª.½¡pNïÞÝÍ>MFâïMuõç£rÑE“Œ0š7ož|ôÑT³Jp«V­t«”¯Ì^pcÇž£^Ÿ|³]Çñã'tÑÊžÆcwz iMu¿´× ÓùùºžÓV]§æëÀåYŠdl“÷·IÈs–iŸUÚ »ŠE|þ†¼‹|6 ®g¯¯baÅ£ºé4“ÿ øu%˜¡„YSØc‰Éþp³bí¤º¶2À´}úô”$]3ˆ©vÖ:Kûöí×a·çu?º%ºïXGyì±?˜•¼±ÞÒâÅKä¶ÛnW!aV‰õw‚PÂÞwÖF±žÊGýXÒŸÉÞ ”JtK–ºŸ¿u²ÏqoŸ½¾X̼}¡å[^¿z–P5¾±bipë›+n^}ûÖ)Î oÆÉ±Ï¼CÞì͆o­Øå âEkì’ë%ËÉ”Uû ¢óG?ºPŸ|Òlž›šš¦Ã•¥:D–ª×éºÕ—2hÐ Ã>'ç ßÄöFƒHÂjÊð(áAìM‚½.ïSïUo )ú÷fii™G’»9|þ©sêYM}ž½¾TEÏ’/´|ËëwÏ’oÕ37 „òò2™ðƒq2õãif…n+.¨mÛÖòÞ»¯éÞpë4f©‡Œ?o¢zôŽ3n(¼ºŸ­! g)pø)–Ç–%7Pf‡juÔ &ÈJøÆß¦M+IÕàø5k×é™zè¢1f¨^#l6 ‚ÅR ¨ºÊôÀDàleÉ$à5 Ob¶ÚîÝûÌ0™¹ñ(”Ñ—4’H€(–x @ @0EDpâC‘³* ð Fèú# !  WKáÚ³l €_P,ù# !  WKáÚ³l €_p6œ_0²  \t¸r_Ò³>×6[B$@$@$K€Ê"I€H€H€‡ÅRøô%[B$@$@$K€Ê"I€H€H€‡ÅRøô%[B$@$@$K€Ê"I€H€H€‡ÅRøô%[B$@$@$K€Ê"I€H€H€‡ÅRøô%[B$@$@$fïöíÛ hI$@$@$@Î'`ÄRÎ#Îo [@$@$@ ˜@zZJn}`›Îa¸Àòeé$@$@$@'@±äð¤ù$@$@$@%@±X¾,H€H€HÀá(–Þ4ŸH€H€HÀ{­[gH]½?Asšo&   ¨ví2%=-ÕœZ®?%Å%r,7WvïÞ'%%%õ)2¤ç4iÒDRSSdÛ¶vx-–âãã$66V¢££$"""¤ aå$@$@$îÊËËUl”JQQ‘œ8Qhëæ?^ Û¶ïT})ñq±’Ù¦•tìØN6mÚjk»k2.%¥IµëK‘‘‘’œœ¤"©Î¬ŽBƒI€H€HÀ®à˜ˆ‰‰6?q*@ŽË“²²2[š[¦Â®°°ÈØvâÄ QÕdÄRTT””––šÏ32ZJzzs£' N¨çi¯äçwµGóg´L—´ÔæÓ(FŠÕ;µ{×^9rô¨9Ö*£…똲(,:)YY9rðà¡ Ý»w‘œœƒêÔi$-µœ…²aãf‰ŒŒ’¶m[KJJS#G•bÞ®ºîdóÔf¦ÜfÍRŒglõêuž‡áÐQÉÉMŒ7‰‰H€H€H 4 0à¸8räXh ð±VxÅ0eD!N¨ys×ÐV‘ŠôôTéÚ¥“¬]·^…Q±´n!•*»T ˜‘,*¤V-[è±4Ù¹s·b‰‰‰Ò®m¦9æ.˜ ’Nž<)›7o¯þËÌ̦M›h½;Œ˜KIIØb•]µYÛwì’ȨHý^ÃAÉR(ùx…0; €Bbl=$§N–ø¸8õ"µ0ž ÍYb'//ßÙ³w¿¤¥57ŸI‹i²gÏ~9tè°9îÞÆ–ézlŸ–§^&M=Ð'ú¹»XŠŒŒ-[whà"§ŒR3uÀƒuìX®ù)80jæKò8¶eÇD$@$@$`x/ÛQ,%&&ÈÀýŒ' ¥lƒøAj¤ÃjÑê­i×®þ¸Ý\rìØ‘ò‹¯énÞŕʻà‚q²O÷h²6‡<íÊ|(`Ægs|Èͬ$à Øï Cq:v©fpjjZ¥ÏŠu¿3ˆ&&ç Xr^ŸÑâ (**lìXSúÛ_’¹óIîeäÈ¡‚àç_|)ï¼û±Ü¢^©Ágž!'NÊg3çÊ”—߬(çÍž³@ºuë$£G—Bͳ`Á2yþÅ×õÝñÛå¼ñ£Mþ1züر<9¨;q¯ûv£<óì+ÊIII–·Þø<ôOÈ’%_W3qÝÚõ2@=T°möìùÇ“““dÄð3å½÷§ÉøqgW|ŽM-¯»î'Ò]íjܸ±lÛ¾Sþýï)²yËv“§.»}ɳ~Ãfyñ¥7*Ê3w¡tîÜAÎ>{˜ùì9ô§^ж皿cbb䦯V#$2*R.\.ß|ó­LTÑwë¯ï •À*HÀ3ˆ%\§Uc–ªž1…¼LÎ$Àa8gö­1ÌnÙ²m§Üø«»åÙÿ¼*?¾øyæß–otwênºKž{árùe?”AƒúU²ô¦¯1;^ÿêæ{剿ýGÎ:k°Üxƒk¦Ìßžü|ùÕb€8oÂOäÒËi„STTTE9AùùÇeÅŠÕ5RˆÐo¹Ÿ1OΟ0¶Òñ󯑵*¤ªîÔ}¼ @/^!·ßñ°\sí¯eÿ¾l¹ëw7{m·•ÑSÛjë.¡ʯ}¿<ðàãº3x;ùùµ?©È~ÍÕ“å¬CäO~Jn¾å^ÃîÖ[~âÞgõ$ð=KA yúÁKνr(–œÛw´<€úôé!Ó§½VéÇ}îõë7ˇN7CZ³f%ÛT8mÛ¾K>þx¦ù |Ö¥sÇJVnPÏÊû|*{÷î—å*vþûÊ;2áçè°Ø÷± î'@8%$4–úT|½»K›ÌVæ<ÄIœ?á™öéú{åÛþàÁÃF”ÁîÇÈt.k×.Sã-¾w<{c·7yªvÙ¦ÍÛä7?”Ý{öÉ·êAûtú,éÖ½“Ɇv\8é<ùßkïɲå«eÏžýòÖÛ©§mC{žE“€oÊÊ\÷!Рð#tµÎò*/+—¯æ/1ë!Y Ç{tï"Ï=û¸[•«v›9„¡¸W^þ§Æïl1ÁáÞ¦è=ù’I&pº¦„8©gŸ{U~û›4NI$'çüCņæÜS]v#¯7y¼µÛʇ¥âãâä‘?Ümb»–,ýZ>™ö…‰Ûb"»ÀŽšÖYr·y¸Æ’]zÌw;"Yž›WsÜ@ZZsßKä$@5ÀzEˆ5ziÊ÷k/y‹ ×ï¼õœ¼öú2õãϼ=Í/ù¼±Û›<~1F ù¹® Õ³g7¹ã·ýU$Ë!zxY¿T5mš¢çŸ ²«µ¤r9zôˆ\û‹Ûë]—ubm 䦧¥ÈŽ•'‘œve,À g‰ ‘@}·:Àl6,!0ïË…A´öûª¼±Û›<þ0¾s§ãuÀE± 8mXv‹Éº¯…VS¡ˆmútúìÓ®„†ÅRh¸³VðŠ@rrIiÚÄÄaH «z7¤4iâx+”›¶Ê‰‚B³Ò7ú¼çÞG¶ÕÆÞÖ•ûñÃÞ(–»Ù:‡øÓ£÷J¦.,‰mM^xéu‡·Æwós¶å 8IDATsóä'—_$-ô›{¤ìÑ ïÇÿò´|½rï…ñ  ¨'Æ,ÕO#  P`ÌRð©s»“à3g$@$@$@"@±ä Î¢©$@$@$@Á'@±|欑H€H€HÀA(–ÔY4•H€H€H ø(–‚Ïœ5’ 8ˆÅ’ƒ:‹¦’ ŸÅRð™³F   ð(–¸éŸƒz’¦’ „=¾—CÓÅÅRIIih¬b­$@$@$@Õ𽚋£X*** U¬•H€H€H ¾—CsQxK……ERRRËX+ @¼Oœ($‘¨3f »œS0… gX% œ"€÷0ÞÇL¡!]WµeeeräÈ1‰“ØØX‰ŽŽ’ˆˆˆºNãq   Ó €`nÄ(aè¥Óé‡SëKVè(v–ˆ³   Gà:KŽê.K$@$@$lKÁ&ÎúH€H€H€E€bÉQÝEcI€H€H€‚M€b)ØÄY €£P,9ª»h, @° P,›8ë#  pŠ%Gu%  6Š¥`g}$@$@$@Ž"@±ä¨î¢±$@$@$@Á&@±lâ¬H€H€HÀQ(–Õ]4–H€H€H Ø(–‚Mœõ‘ 8ŠÅ’£º‹Æ’ ›ÅR°‰³>   G XrTwÑX   ` X 6qÖG$@$@$à(KŽê.K$@$@$lKÁ&ÎúH€H€H€E€bÉQÝEcI€H€H€‚M€b)ØÄY €£P,9ª»h, @° P,›8ë#  pŠ%Gu%  6Š¥`g}$@$@$@Ž"@±ä¨î¢±$@$@$@Á&@±lâ¬H€H€HÀQ(–Õ]4–H€H€H Ø(–‚Mœõ‘ 8ŠÅ’£º‹Æ’ ›ÅR°‰³>   G XrTwÑX   ` X 6qÖG$@$@$à(KŽê.K$@$@$lKÁ&ÎúH€H€H€E€bÉQÝEcI€H€H€‚M€b)ØÄY €£P,9ª»h, @° P,›8ë#  pŠ%Gu%  6Š¥`g}$@$@$@Ž"@±ä¨î¢±$@$@$@Á&@±lâ¬H€H€HÀQ(–Õ]4–H€H€H Ø(–‚Mœõ‘ 8ŠÅ’£º‹Æ’ ›ÅR°‰³>   G XrTwÑX   ` X 6qÖG$@$@$à(KŽê.K$@$@$lKÁ&ÎúH€H€H€E€bÉQÝEcI€H€H€‚M€b)ØÄY €£ü?â¸qq[IEND®B`‚input-remapper-2.0.1/readme/usage_2.png000066400000000000000000000334661450214210500177600ustar00rootroot00000000000000‰PNG  IHDRK‡ÍoiCCPicc(‘u‘;KA…?£¢ø ˆ")T,‚‚Xj,l‚HT0j³»ÙM„l\v7ˆØ 6‚…hã«ðh+Ø*‚"ˆXù|5"ë$ É,³÷ãÌœËÌų†í5DÁÎùnb:YL.EšÞh¡Æ k†çLÎÎÆ©9¾¨Sõ~Xõª½¯êhM™žuÍÂc†ãúÂÂñußQ¼#Üed´”ð±ð+¾Qº^äWÅé"*vçSR=#é Ö+ØÈ¸¶ð pŸÍ¥ó¨›´™¹…9©=2{ñH0MŒ:yVÉâ3,5'™U÷E ¾ÖÄcÈßaWi2â5/]M©–è¦|Y6Tîÿóô¬Ñ‘b÷¶4¾ÁG?4íÁïnüœÁï)Ô?ÃU®ì_“œÆ¿Dß-k}GÞ‚‹ë²¦ïÃå6t?9š«¤z™!Ë‚÷shOBç´,³*­söó›òD·pp²?¼òýèh "¢)† pHYs  ÒÝ~ü IDATx^íÝ|Eÿøñ/IB‘ŽôRD¥)ì½€õñ±?ŠŠÿŠ>VÔŸûclˆH•¢4¤÷Þ{I üç;ñÎK¿;.›ÝÛÏøÊKrÙÝ™yÏÞîwgfw‹Õ©Ûì°@@rHÀ@@ o‚%ö@@ ‚%v@@€`‰}@ˆN€ž¥èÜX @|"@°ä“†¦š € ÁRtn¬… €> XòICSM@ˆN€`):7ÖB@Ÿ,ù¤¡©& €D'@°k!€ €€O–|ÒÐT@¢ XŠÎµ@@À'K>ihª‰ €Ñ ,EçÆZ € à‚%Ÿ44ÕD@è–¢sc-@ð‰Á’Ošj"€ €@tKѹ± €øD€`É' M5@@ :¤zõêD·&k!€ €>HZ¹rµªI@À‹z1Ç1Ê‹-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¼è×,?íÚãêD¶¹›n¼F:w:9²•X@ T¨P^è§Œû•Ìùs²üþÛxùêË÷åŠË/”bÅŠE-áÄ98Vy$–+_åñ¨kÁŠ[¶l“Õ«IýúuäðáÃòλ˸q“äÀl%v‹žpBkô⓲cÇN™2å—Øm˜-!€@ÌÊ—/';wîŠÙöØD&Pµjeö͇ÒÉt,”+w´lÙ²U’’’¤nÝÚÒ­Û©Ò°A]3vbd5K;qŽeŽõ,-Y²\ÞÿS zðàAyá…×eÏž½ö÷ñ?|-¿Í/­Z'_ }Oþš;E&MüV®¹æÒ,  Ë-˜ÿ³{l#ùüó·ír£G •ŽO .7}ÚhÛ{ÚcôÆÏÛÏî¸ã&»\÷î]äå—Ú÷í{¹ýÛwÃ?‰¸±Y@xèÿR­ZUÙ´i‹ôî}•tívžœØáLéÿÀ¶Ú={ž!§ŸÖ)fç`·žç – ڙʕ++Ÿ~ò¦‰\ËʪUk¥fÍêòè#ý¤}û6YVMLL/¿x_J”(![¶n—† ëÉë¯=+znªT©‚ìߟj× î˯¾³½\$@ÈÐ$í\Ðô® Z¼dYfذ‘2cÆLû{Ý#"+èìÆó¼k‚%•ž>}†œÝã2éÕûJY¸p‰ÅoÛ¶UŽFønĹð¾¦.• 6IéÒ¥LÄ{VØ5tè·2kÖ»¼æùðÃOÉk¯¿öú,ˆ €@¼ T®\Q’“KÚjÎ_°8GuçÍ_d?«S§VDᜃÝvžwU°4aâ;ŸI–.]añË–-“£~û;šMMM“™33ƒž:µkFÔX,Œ €á ä6‘;á&w”«ÛÎó® –vïÚô;tèPž– ‰‰Á¿Úêpö¥C&è—-Sº váï € " 7fíOI±Ÿw\Ó6ÏV¯^›ÓíÏÁn;Ï»*X w/ítêIvÑääd;£^ÓªUkìÿ÷ìÍœ4^«f û¢kÑ¢YŽM§gdü½Ì.F €ü#aΓãøÉ~pã WIÓ¦ƒ¼ä’ó‚çßÑ£Ç?Õ9Ømçù$§vŒÆÈõ×_e³+^¼¸ôëw«¼ýöGÁ;â")G¯^gJ“cJÙ2e¤jÕ*²{÷1b¬ÝÄ,3,W¿^¹ûî[ä¸M¥Më–6`ÊžV¬Xe?ºðÂ^æquíÐßÕ×ÜI1X@¸xö¹WíVÕ«cï×»âJ•JN‘9òÑ)4«s°ÛÎóŽõ,w^ûLM:öyó¿úJ“& £ÚÉž{þUÉHÏ|¶xñR¹åÖûlÀ¤Ivô˜ &‘Óºu’%K—ËcžÉ‘ÏgŸ}mï€Û·/Eš5k\?ª± €q( ÏU:ÿ‚>öÑ?+W®±ÏZÒ4gÎnmo}þ‚κ¿ãÎdìXnõwk;Q.b%PÏô¯\¹:V›c; àr·žçëYŠeû“è¯Ër°-@ˆ½€ÛÎóž –bß,l@È]ÀSÃp4"øK€a8µ7µEÀ­ô,¹µe( €¸B€`ÉÍ@!@@À­KnmÊ… €® XrE3P@p«Á’[[†r!€ €€+–\Ñ @Ü*@°äÖ–¡\ € à ‚%W4…@@· ,¹µe( €¸B€`ÉÍ@!@@À­KnmÊ… €® XrE3P@p«Á’[[†r!€ €€+–\Ñ @Ü*@°äÖ–¡\ € à ‚%W4…@@· ,¹µe( €¸B€`ÉÍ@!@@À­KnmÊ… €®HrE)( €a$$“5ªË1ÇT‘Ò¥KIbBbk±G&@°td~¬ à@‰GI«–Í¥L™2åH6d ,±' €¸^ !!AZµ:NÊ”.íú²RÀø`ÎRüµ)5BâN FjJqתީÁ’wÚŠ’"€¾Ð9J$ŠJ€`©¨äÉ@ lÌMB ¨<,%&&Ê=÷Ü*‹þ"-Z4+*7òEpP€»ÞÄ&«žšà]½ú12hГ’²?UŠ+Fs"€ €….ਫ਼¥v펗ß~›-wÞõ`¡Ã €  žêYúþûq¦Èã¤T©dZ@pDÀS=KŽˆ  € "@°Äî€ €ä#à©a8Z@ 6}ú\&åÊçÆ^}õØd䣭Ü|s_iÛ¶•Œ;Q¾ùf¤­yݺµå¾ûn“¤¤$<øùóÏ¿\/¨‡T§¿Œ16K™ÿýïk¥M›–ö³Q£ÆËðá£]_§#- ÁÒ‘ ²> àAáߎ’ó(MíÛ·‘rG—•ñ&{°&î-rùòåäÖ[¯—âŋ˷ÆÛ RvM þBƒ¥’%Køò±=KîýžQ2@ ÐvíÞÜö´4IOO–;v?»ì²óeÆÍ2yòôàgzsÍõ×_%#GŽ3= ­dÑ¢¥¢OÖnÒ¸¡þRÖ®Û çÛÃÜÑÿOãîÛ÷23WKV®\#~8$Ë^ ÁJ§Ëþý)2eÊÏ&H8,]ÔÛP“&MµË¶oß:hâ‰míg‹/Ë(éç}û^n2eʘüVKéÒÉæ÷3l`šŽ6½‡÷Þ{›éjjƒ TòKóæ-´nÛ6sÈ-³\mm0<þ¢«j°Ó°a][g ’´Ï9ç éÞ½spÙk¯½Ü~¦A–Uƒ9 âÁ”.˜[9íc¾:‚?z²gIw°&Çv8‚j³* €@~K̉YŸÚµkšàh]T{´ pbÛhzfÍžkÿ¦½?ÿò»œÖíÔ`püñ-dÒÄ)²~ÃF»Ì/æïz¢nذžÌ;?n@{Ó*Uªhë÷ÕWß™^·zÚ*T(/'žØF>,|0ÄL3gαAŒz?øà@¹øâÞ6`8öØF²`ÁbÑíišjzå²§Ê•+šíeS¯¼ò¶íÝÓ8kO”>›P{†Ö¯ÏôפoÁxá…×eÅŠU’‘‘ÙÛ“WÒžÃSO=ÉnG{ÆJ—.-Íš56m·@8˜cµeËVJ¿~lÝ4ÈÖÞ¨ /ìeêÑXÆûјTÖEçÄéö5•(Q"ǶBË™i~ cÇòd°Tl@àTÓƒ´|ùJijNØ,é»Ùj˜“ð´i¿Ú¶u{2š)fN’eË–±½%ÚcÐí´NÒµÛ)ÁåôĨC4ñœ4PÒ8­ëùç÷´Á‰šjÕª|Å#Ü“…A$};…ÉiÏ“))©R¥J¥`P•Ý­fÍö#]N%M6l’­¦mt½:uje –FoÞp’ö -X°HZµ:N4(kÞüXlé°¢fO:¿I‡á4XÓ¹Mø·nZ¿ÔÔÔ` ¤Ë¨UöZÎ5ªGTÇpêé2K‘б< àŠ;Ç ïè•ÆfNç9…Î7*–õµS:wF“ÆWR7Éœ¸·e;pà@\ j01lØH3¿è>iÔ¨¾tízJpx-Pñ´´òþûŸæpÈÈÈŸ~š.gÕÍÞq–’’b—Ñ^¹ÐªÀЇeÎIÊžo FÕeöíÛ¶½¶á¬Ysm°¤PË–ÍM`s@æÌ™Ÿ#XÒ馛®±=J:GI÷V­šËÙgŸÌO­pSh9#­c¸yD²s–"ÑbY@ÀGkÖ¬“Tsr¬_¯®40Cga“A`¨)ð{•Ê•ìÝž={íÏA3TS±byÙ»w_–ŸÜ†pâ‰uûö²mÛóø€ïmµ.¸àÛ3£iݺ¶—I{ÝÔhöì¿‚?:Œ¥~:ÑþÏ?çIrrI3º]O{›rKÚFº=]VçŒiÒÀ%Ð6k×®?"Z-‡–I‡ãtvΜyf.g°[«V (í6µÞ=§½\‰‰YC 0®©¤™ì¯ÌIË›_*ì:†DÏR8J,ƒøP@O .–ÆMØ!¸‰fþQhªV­ª´iÝR–šcY3¹X‡tR°öŽhšmæ3h&§¦¦É:sÒ.~Tqs"¯f†÷V8¹8¸'OþÙ>–AƒŒk®¹T^~ù-sçÛ3”9Ãä®»nÎݪ]»†òZ"Ÿþ­ú?Nµ=K:d¹bÅjdmÈ•dçÎÝæŽÅŸ¥K—Žf{ÿ ,jÖÌêû믅²zuædüh“öðèÄò¦MÛMèûYsKG‚½ôÒóÍ"‡¥sçŽYÕ¹S:¨wÜq“)Û:37«Œä=`À³y±°ëŽ =Ká(±  àSNiРžh¯€NâMÚ+RÉô˜\uåÅÒ»÷Y²Êœ˜Cç4ý:c¦ü:ã;Aøê«/‘ Î?ÇôRÕÉrçS<³j°ùÑGCíc4ØÜ)øé§_Ù^' œôV|¯½m:™;.\j{ž4M™’sbw¨Û!Ãì°ŸöêÔ¯_ÇÎ?þ'yë­c«Ð5鼨ÀrÙ7¬AÚ×_°eîÒåd[Ž—^zÓε M¯¿þž},‚@:ï鍣ޒßÏ=s²ŽA«S·Yæ¬3 à2zæÄª·“ŠVàÆ®–¿Í´sUIïrÚ¸q“LŸþ›#…ëÖõTGòqK&DÝrËu6@éßÿ‰\'A»¥¬~(Ãp~heêˆD) 8Ê̯Y¼8·;¨²NðŽ2 V èØ±½œ|r{Û›§I,™ÛÝb 9+@°ä¬7¹!€žРå’“¥“™[3ßÌCÒÛ½I…/ OôÖ;èt‚¸ÎË>O¬ðK@¹ ,±_ €ä8÷ܳE¨“‹§MŸCú^ý!¹K€9KîjJƒ!ÌYbwømÎ-ï.î†sW{P@p™Á’˄⠀äÈÈãIÕX!à„Á’Êä pD{÷†ÿšŽ#ʈ•ÈE€`‰Ý@Àõ›7ov})`ü ,ÅoÛR3@ nÖ¯ß${÷í‹›úPo ,y«½(- àK}™«¾ÄuÏÞÌW€pR€`ÉImòBˆZ -í€Ìœù§,^²LvíÞ-é¿°7ê ²"a ðPÊ0¡X @ è:,ëÖm°?$œ gÉ)iòA@O ,y²Ù(4 €8%@°ä”4ù € €€'–<Ùl@œHªR¥’SyE”ÏáÃ"ééé’––&))©­›œ\RJ–,!‰‰IR¬XD«²p! Ц…ˆ[D›>’6-¢"“- ±€kï†Ó §xñ$ûS²dIÙµk·ès6òK R®ÜÑ’””1+¾mZøÆNçM›:]Fò‹?.ˆã¯MÝ^#O Ãið£AP~©˜9j(¹}wû§|´©wÚ*Ü’†Ó¦án‹åÈM@/ˆ+T(/eÊ”6ÅŒ°—8'à‰`I9ô@¬Wy%v£Gɹ'9Ѧ±Pt×6 jSw•–ÒxI€ b/µVü•Õ3Á’Ò—(Q"ï`)Ÿ¿Å_³ÅOhÓøiË@MòkÓø«-5rJ€ b§¤É'×^M/±h·k^)1Ÿ¿y©Ž~++m-ž_›Æ_m©‘S%¹ vŠš|rðTÏR~w¶q×›7÷oÚÔ›í–_©ù.Æ_›º¡F\»¡ü[OKþm&jŽø[€ Üßí_Ôµ'X*ê @pµÁ’«›‡Â!€ €@Q ¸ö¡”E Cþþ¨X±‚\rɹrJÇR£Fu9lI½fÍ:™:íùꫲ{÷n@PK@<bÚ³”œœ,Ӧ޲?µjÕð%{<Äctç<ãŒ.òÅÐ÷¥Ï5—IÆõÌs¼JH©R%åØcÊ ×_eþöž ¢Nt|?Ö|õ;Ô­Û©ŽçM† €9èY*`¯¸¶ïårÓM}ìRß~;Jžá5ö£8Ð@iÀc÷›wæýòÀ²eKË3Ï<&÷Ýÿ¸üòËïyÖú©K×®§Ø¿kÏ”¾šgÙ²•2zÌc~ô3'ÒUW],Ë–®_~ýÉìÈð@L{–ÎÿÝm^T=ûìÓíÉN_æ{úéÍ»êŠç[x4ˆ·:éÐ[ÿûÿ“o hd}½ÂcÞk^¯PªÀÝwÛö²xÉ2»\»vÇË#ß#Ïü÷QÑmv:þørë-×K‡“N(ì¬Ø>žp¢§Ö‰<"E×Hbµ|’¾¨öºk¯”3Ïì&•+W’m۶˘±äƒ>—ƒÓm>º#(üµ×Ý.=ø{"xô±ÿÊÂ…KÌIçNûûλeð›ÿ“ÈQG«òévZ´h&µk×”E‹–ʆ ›lïA§SO’‰“¦ËU¥J¥| >øßkÒ¸qyõµwdÈav½ªU+Ë7_hOÖW\ù/û®#íhÒ¸¡”/´lÙ²MÆŽ›$ï¿ÿiðåÁÚG]Vîé÷¨üç®›¥I“†²uëv2t˜™[ó]°<Ìõ5½ag™ö¬R¥²sóçŸódÀãÏÚmé߯7CLgÙÕæ»qãf1b¬ ýâ[ÉÈȰÛ)¨NEÚ(1È\ç(é›& „ûô½Í¾*gЋOš}þ üçîGì{¿ñ¼]Fÿ}Þ¹=äÓϾÎ7÷Ñ£ÇËàÁÿ³ËèðÝÀÉ©f¹ø¢ÞòÅ—ÃíçáøgϤ u:w:Yúõ»Õ®vé%çÙŸ¥K—Kßko*¿³‰"èÓç²|ߣùê«ïQÉDê˜ciÓfMdœ9¶‘ðš@ÂCÝcO®åË—“ KÙ²eäÚ¾WÈcÝ—¥.+–—×_{NN2W®zðÞµk¹rî''ŸÜÞžp¶nÝ&÷ßw‡}¹a¼¤=N·U™:íW™:õûïÀg:d0rÔ8»èé§u²œvZ'(Í»@V¯^k‚˜ iq\SY°`± ’´ ®»ö ¹ü² ²Pê篿ö¬ýûÚµë¥zõcäîÿü[Z·n\îá‡ï¶ëV®\Ñlo‘ xõ5(iÒ`·Ï5—Êž½ûdøðÑæóÃrÛm7Èu×]ÜFAuòzûêdî@ÒvÐ@iñâerÇÊíw< «V­É±ŸáÜ¥iÓgÈ03l«©gÏ3‚ù…ãŸÝ· u4èMII³«­X±JF|?V~üizØííõö¤üÿ 7ûÜ'Ÿ|iô"o£¹È ü®ÿ/ÊÔ AÝ¢ÌÞ‘¼r§2ñ6"N s™¤3Ïèj·Ï=Èœ¹ó¥^ÝÚòñÇoÊiÝ:Éÿ|&Ë—¯ 毽P·Ý~¿Ì›·Ðž¨O8¡µý[¿{ȬYs¤eËfòæà ³¼Žm[Âî§w±ùMžü³lÚ´Ùö¼tèÐN4pܾ}§Ô6“Ø 27îG¹íÖ¥yócY5ÓCµ18}?23Ò ©Wï+là¢C6@éŠöØ}öyÖÞŒß~Ÿ-ýûÿ?»Þÿ{U5j`Ü›ËìÙIµjUƒeîwïc2s滜ö jªZµŠ™ÔÜÕæ3pà‹²×L“~œ&o¼þœœ{îÙòU'Ç¡2Ò»ÞBÓKƒÊw=hçiÒ ÞO™^¡ÐT³fµˆK£'+MµjeæŽöLÂYgøw£íw¯ví¢ûÇ+¯¼u~W’\%°k÷ž`y¤¥Izz²ìر3ø™Aënµcª˜€â¶wú§ÉÓeóæ­v™ /ì%ýµÀ^µk{¼lݶM¾ùf¤$&&J§N'™íF’`.0–-_i/ØZšÞ÷@¯©®¯ÇCÝ“K–´=äSÌE¦ö^ë†æ¦WI“öЧši ï¾÷I‘Øéqò­7_´#)ÿüzouÙf¦ìZÏÀwK¿?lËù7롃¿ Íš6–uë6ÊË/¿i¾“³ìßÃÅÑáôpFü8ÒS$;KH¦¶HOš(iZi®ªõ„^³fu9Ö|1Bƒ¥>jOÊšj˜¿¦={öÚ@I“žôu[eÊ”.êzqþz`Ðzèð›ihÒà£}û6fë4ù|È7Ö¨ 쫽RzgÓé¦GiÂÄÉ6pÒ9PÍ¿5éðÞUW^,õêÕ1wce6šJ•Î9OF·¸zY±rµ –Þ Ô³=VûöíJº””»½FêÛ¿ëœf M•ÌA´D‰£ÂªÓãñ²_qéÐqjjfÏŒ¦ÔÔv8.4ʈ|’vbbÖ¹Jáø§¥È’o4ë6p$ëq‘}! èþ¥½zIOO—SNé Ý»w‘ÏB†˜Û´iiëÚCø^èEb£†õeìØ‰öý··¶™`+t™¦Ç6–±c&Én³¾M:|ý±éÑš0a² ôB­¨†áô¸©ÁÌË/=m¥>þ"85B{ou*ÊÒe+ìÅqûömm»ƒ¿þz„<òH?ðé0¦öҟѽ«­ö¬Ysí£FI§Hè9SÔzõjËÓO?"_r5ÓQíœÐc¿Žâ44ÇkÅ©S§–<úèí&£ :ub÷ž=Ò¥sG;R°ßÓC/œ#=z¤)0Ò£Ájš ’ãq¤§¾ao6×1³ÀB ÙöÚxÁô÷]DáÀçy/a—À¥ ö0»5išÞÆšzôènƒ%uØ<ëÄöì#Gý`ƒ%~ üíǧÊþý)¶'ïÉ'´=J:Gé·ßfIÇSN”k®¾4W=ˆÒasà MIæê/œ¤_Ö'žÈœšìµ0ëN>n]F¯ˆõêV“@uŽÒºuìÕ£žL–›«æ|RÞyû¥`Ö®[quZµ<ΞQ¼aIDAT®³jÕÚ,ëæëŸG.ѬØÔ‘¬q¥YÁÕûöí“ùó˨£^pŽí9 ›’“dôèîâÔóA«VÍeÚ´vˆZÓï̶=¦ù©züÒ^‘I§Èzs±­Iï mÑ¢©},ÇÜ¿/Æ‹G˨ó5ÀÐyšo½õ-NA½·]t­Ü~û¢”ÚÛ¯Çh œ4}7bL–*3QžyöÛ›?tÈ;R©REÛ›¯váŒâD2Êà§‘ž¢Üoy'i¤¯=­ÌPN`®Zµcìß—.Y‘gõä¢I'¿êIFçÛèÕ†þîõ¤Q»^%iÒ«½ ¤úõëÚ/¿žlÃ5øÕÜÊ­‘¾®³cç.»©‘#°ÿod&ë—Xï¤zÏKš:wéáh*m®†ôKýÇÚß5 ÓÃeæªIƒƒää’¦;mO` é¼ ú­STtÉJ:4–ôD W›zP{ú©GL[”þ&$`¢n¹?V't'ÒQ?ô¸aÿIôÒ`yóÍL`°ÃNÀÓFoqø‘§ ,ŸÞޏâ®nz£ž~jÌømfë¹}=éiš0á§Clz·š&½ÂÐ;ÿÂ5i‚M„2ÃrÏ’%Ëåõ×ß“ífŒ[ƒ¤ãÌ|¦»îzÈ^µD“î7“¿u"¢°ššI†z€Ôù¤Õ|cðûöÄݹóÉæÖöv^Âï¦k9­S4åsÃ:ú8…'Ÿ|>GÛæV6 4<þ\ðn³üʯa½`Ш^lèã5ô't˜6ÿìy„³Î°a£ä§Ÿ¦Ùï¯>ª"°ÿ„³®Ú„2¾€>²DïŒýõ—?l ¤©œyIAiÏž}’az×užOh í}Õýíàƒöæí= ý9`>פÓò{lAå8Ò¿ë„ò»ÍLú}¼Áô2î"ÎÞ;yÊÏø™k&»ëò›7o±“Õµ—í¬³N³EÑ9]ÙS‡míGz±¤ šôâX/\CGqôs ŽBGq²2èHO¸w—gÐíÇËHÏ‘¶{¬Ö/Öî„S#Ÿ¹«Ü£ØŽÞa‘[Òne’7ŠªMõ1úÀɼ†Ž5à|lÀ³¶·Ž™@^mÙVÌ ÅÜô°ÒÜÈ@Š\ «ÎמåÀ#,*T(gzG.±'ù•+רý¾×9gJÅJì³^èÝpz÷šö:‡¦NæY^:ý`ÜØI²ÓLV®[·–tíb&x›çòî†Ó^ÕÖ­[Ú@c™PÜÜm§½¬:áYçJ|ò 殉íiÕù‘úY$)Úc¼>£N‡Âyôi™4iª}Μ¾ÎH÷Ѿ×Þf'_?Ðÿ.Û㮹õ¸k9ujƒŽ¢hšgæ}ýë_w‹ÈC?Ðúê°¤–W‡ìt‚÷ns—â½ýn³£8šG`Gƒ/½°Ò»—õ"Go¼Ñ é/ͳó£ :/L§[n¹×æ—½>B¼öê³6@ÓÞ³ÀHÏÑeËÚ‘žË.¿ÑÞ½HŠ^ ±F:G¿ºókêŽ[ {8_"rpWÐÔL+øÃÜé«=(:ÿT{…BïðÒuׯßhçñécÚ˜çº6ÿé÷Gƒ 4­3ËèМöØè]Ãú]ë¤äÐyŸXÕ2AËÉ'µ·A¤!Ñã/1iÕúêÄ5ðÖ;Íô®1r¢à?ü(?ÿü›¤¤¦Ú»¼õ.¾:ujÚÇ*Œ2sKWý=Ä­wFëdx5}ï½OmoQ iÔ<ôÐ@³í6¦Ç¨Šbñ¤yD‹¦šô5Dê¯Û®o.´ÇN'škïozz†ÝÑG*44Ó"ZœGöÄ/Hôêw`äßšÉ^Ÿ@~1ÓN^×UÝŸîU9ÚÄúžV}p±l¤èèYŠÞŽ5c$PT=K1*>›ÉE€ž¥øß-:vloïþúëï©l´=K±*œ>)ÿ¿æõELžAŸà#Ybµ}¶ãnøyܶ») W™¯SŠnn¥— ô¦‰ž=Î0Ahf‹­¯ <»ÎKõ ¬G&@°td~¬Ľ€¾%@ç4éDg°­Ãhz×¥¾^%ÞSÅ ìs¦t.×—_ 7wþó.Îx¯;õûG€`‰½@ _T3Ÿçó|¦£Ë™ ÃÅÌ$ï]òƒy•Óê§WÇ+á'Ÿšwí™’¿<,™›òLú·Àó@üݤު=mê­ö §´ùµi8볌ûô'úCBÀ¯Y_^år…Ð'ig/j~syµ|]<Ú4þšŸïbüµ)5BÀïž –ôy¥üþæ÷FvsýiS7·Nteã»kå/@%{HQ x&XÒçPèüòJú ]†äÚÔ;mnI jÓp·Ãr0zÀ>à&OKzÖ§¬æ—ôi§º “›v¯¼ËB›z£")e8mÉöXPz,ÙŠRÀµ¼µËUç>è$¿¥P<}íóŽ5}ª¾íZ߫äï¢Ü½²æM›º§-bU’hÚ4Vy³ èè¾ë2))Ñ_§¶®HŠÕ“v]Q›¿ ¡ÁU¸–›ÊMYò MÙ;ð·@`ô@ßiGÀäï}¡(jï‰a¸¢€!O@w Fô•#ú2^&}»«}â¹4®†‹gtê† ½=ÍÑÛ±ftô,EçÆZ € à‚%Ÿ44ÕD@è–¢sc-@ð‰Á’Ošj"€ €@tKѹ± €øD€»á|ÒÐT¿ ”*]ÖïÔ¢ g)J8VC@,ù£©% €D)@°%«!€ €€?–üÑÎÔ@¢ XŠŽÕ@@ÀKþhgj‰ €Q ,E Çj € à‚%´3µD@(–¢„c5@ð‡@R½zuüQSj‰Ä@`öÌñÒºm÷l‰M €€W’6oÙᕲRNð™@Õ*|Vcª‹n`έB™@@À5K®i ‚ €n Xrc«P&@pÁ’kš‚‚ € €€–ÜØ*” @\#T¥J%׆‚ €@| >|XÒÓ3$--MRRR㻲ÔâF )njBE@ÀõÅŠ“âÅ“ìOÉ’%d×®=rèÐ!ו»AƒºrÇí7HËM¥téR²xÉryæ™WeÁÂ%®++BÂ`®ðÉrHJJ’råʺÒfïÞ}òÓOÓåºî–Þçõ‘µk×Ë“OôweY)¾ÁRá“ä! SrrI×ùlÞ¼U¾>FÖ¬Y'[·n—o†ímÒ1øO€o¾ÿÚœ#à*%J¸nþR“Æ äºë.—¦M™áÂ’6HJHÐ!Ä⮲£0 àŒ=KÎ8“ äÙ»”è*›òåËÉ;o¿hæSí–>}ï”=¯”þýºªŒœ XrÖ›Ü@ ›€NúvSjÞ¬±KõÆàeÏž½¶h5jVsS) 8,@°ä08Ù!€€»ÖoØ$úˆƒV­šÛ‚Ö®]S®ís©» Mé@ P˜³T¨¼l¼&°råyáÅÁ2àÑ{D{½6lÜ$O |IÞ}û¯U…ò"€@Œ–bÉf@ ~>ýìÑŸÐÔ¾Cø© 5Aˆ†‹ˆ‹…@@ÀoK~kqê‹ € ,EÄÅ € à7‚%¿µ8õE@ˆ–"âbað»@ë¶ÝýN@ýðwÃù®É©0ù Üòïë¤wÏ3%Ѽ·-{Ú¸q½Ìž=O^ú¿·aD|#@Ï’oššŠ"žÀ¹½ÎÎ5P ¬Ýºõqró¿® oc,…Ä=KqЈTX $$d^C­X¾$ËfëÕoü½ãI'Hjêù𣡱̚m!€® Xre³P(Š^ vúùâôn§ÈÞ½ûåëoF}a) Pˆž†KLL”믻BÆ"Mš4(D6€?:d+¾fõŠ,?úꤤâA}Úy½Ï°¯!!€ñ,ਫ਼¥*U*ÉÃÞeºÿÓ8@Çó^IÝŠT ##Ct(®~ƒÆ9ÊQ¹r•,ŸÄ»ÁR¼·0õCÀÅééé’’’êâR4@@„`‰½ŠD@¥]»öIÞdŠD"ÉÂ,‹‰€NæÖ9J:ôFÒ‘H².8)´eË6'ó#/@@ÀS Ãyª¹(, €8-@°ä´8ù!€ €€§–<Õ\@œ XrZœü@@ÀSKžj. ‹ €N ,9-N~ € à)‚%O5…E@§–œ'?@ð”Á’§š‹Â"€ €€ÓKN‹“ €xJ€`ÉSÍEa@@Ài‚%§ÅÉ@<%@°ä©æ¢° € à´Á’Óâä‡ €ž XòTsQX@pZ€`ÉiqòC@O ,yª¹(, €8-@°ä´8ù!€ €€§–<Õ\@œ XrZœü@@ÀSKžj. ‹ €N ,9-N~ € à)‚%O5…E@§–œ'?@ð”Á’§š‹Â"€ €€ÓKN‹“ €xJ€`ÉSÍEa@@Ài‚%§ÅÉ@<%@°ä©æ¢° € à´Á’Óâä‡ €ž XòTsQX@pZ€`ÉiqòC@O ,yª¹(, €8-@°ä´8ù!€ €€§–<Õ\@œ XrZœü@@ÀSKžj. ‹ €N ,9-N~ € à)‚%O5…E@§–œ'?@ð”Á’§š‹Â"€ €€ÓKN‹“ €xJ€`ÉSÍEa@@Ài‚%§ÅÉ@<%@°ä©æ¢° € à´Á’Óâä‡ €ž XòTsQX@pZ€`ÉiqòC@O ,yª¹(, €8-@°ä´8ù!€ €€§–<Õ\@œ XrZœü@@ÀSKžj. ‹ €N ,9-N~ € à)‚%O5…E@§–œ'?@ð”Á’§š‹Â"€ €€ÓÿÏ‹G× `ZúIEND®B`‚input-remapper-2.0.1/scripts/000077500000000000000000000000001450214210500161435ustar00rootroot00000000000000input-remapper-2.0.1/scripts/badges.sh000077500000000000000000000014351450214210500177320ustar00rootroot00000000000000#!/usr/bin/env bash # sudo pip install git+https://github.com/jongracecox/anybadge coverage_badge() { coverage run tests/test.py coverage combine rating=$(coverage report | tail -n 1 | ack "\d+%" -o | ack "\d+" -o) echo "coverage rating: $rating" rm readme/coverage.svg anybadge -l coverage -v $rating -f readme/coverage.svg coverage coverage report -m echo "coverage badge created" } pylint_badge() { pylint_output=$(pylint inputremapper --extension-pkg-whitelist=evdev) rating=$(echo $pylint_output | grep -Po "rated at .+?/" | grep -Po "\d+.\d+") rm readme/pylint.svg anybadge -l pylint -v $rating -f readme/pylint.svg pylint echo "pylint rating: $rating" echo "pylint badge created" } pylint_badge & coverage_badge & # wait for all badges to be created wait input-remapper-2.0.1/scripts/build.sh000077500000000000000000000007461450214210500176100ustar00rootroot00000000000000#!/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.0.1.deb || true dpkg-deb -Z gzip -b build/deb dist/input-remapper-2.0.1.deb } build_deb & # add more build targets here wait input-remapper-2.0.1/scripts/ci-install-deps.sh000077500000000000000000000010751450214210500214750ustar00rootroot00000000000000#!/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 python -m pip install --upgrade pip python -m pip install --upgrade --force-reinstall wheel setuptools # install test deps which aren't in setup.py python -m pip install psutil pylint-pydantic input-remapper-2.0.1/scripts/setup.sh000077500000000000000000000155601450214210500176510ustar00rootroot00000000000000#!/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.0.1/setup.py000066400000000000000000000106241450214210500161710ustar00rootroot00000000000000#!/usr/bin/python3 # -*- 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 . 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.0.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/polkit-1/actions/", ["data/input-remapper.policy"]), ("/usr/lib/systemd/system", ["data/input-remapper.service"]), ("/etc/dbus-1/system.d/", ["data/inputremapper.Control.conf"]), ("/etc/xdg/autostart/", ["data/input-remapper-autoload.desktop"]), ("/usr/lib/udev/rules.d", ["data/99-input-remapper.rules"]), ("/usr/bin/", ["bin/input-remapper-gtk"]), ("/usr/bin/", ["bin/input-remapper-service"]), ("/usr/bin/", ["bin/input-remapper-control"]), ("/usr/bin/", ["bin/input-remapper-reader-service"]), ], install_requires=["setuptools", "evdev", "pydbus", "pygobject", "pydantic"], cmdclass={ "install": Install, }, ) input-remapper-2.0.1/shell.nix000066400000000000000000000025161450214210500163070ustar00rootroot00000000000000# 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.0.1/tests/000077500000000000000000000000001450214210500156165ustar00rootroot00000000000000input-remapper-2.0.1/tests/__init__.py000066400000000000000000000000611450214210500177240ustar00rootroot00000000000000# make sure patches are loaded import tests.test input-remapper-2.0.1/tests/integration/000077500000000000000000000000001450214210500201415ustar00rootroot00000000000000input-remapper-2.0.1/tests/integration/__init__.py000066400000000000000000000003521450214210500222520ustar00rootroot00000000000000"""Tests that require a linux desktop environment to be running.""" import tests.test 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.0.1/tests/integration/test_components.py000066400000000000000000002074411450214210500237470ustar00rootroot00000000000000#!/usr/bin/python3 # -*- 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 . import unittest from typing import Optional, Tuple, Union from unittest.mock import MagicMock, call import time import evdev from evdev.ecodes import KEY_A, KEY_B, KEY_C 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") from gi.repository import Gtk, GLib, GtkSource, Gdk from tests.lib.cleanup import quick_cleanup from tests.lib.stuff import spy from tests.lib.logger import logger from inputremapper.gui.controller import Controller from inputremapper.configs.system_mapping 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 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() quick_cleanup() 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 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() 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.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() 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") 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()) 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="") 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") 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() gtk_iteration(5) def unfocus(): window.set_focus(None) gtk_iteration(5) # 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()) 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() 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") 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() 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() 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" ) 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() 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()) 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() 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) 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) 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() 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() 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() 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() 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) 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") 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.0.1/tests/integration/test_daemon.py000066400000000000000000000041571450214210500230240ustar00rootroot00000000000000#!/usr/bin/python3 # -*- 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 . from tests.test import is_service_running import os import multiprocessing import unittest import time import gi gi.require_version("Gtk", "3.0") from gi.repository import Gtk from inputremapper.daemon import Daemon, BUS_NAME def gtk_iteration(): """Iterate while events are pending.""" while Gtk.events_pending(): Gtk.main_iteration() class TestDBusDaemon(unittest.TestCase): def setUp(self): self.process = multiprocessing.Process( target=os.system, args=("input-remapper-service -d",) ) self.process.start() time.sleep(1) # should not use pkexec, but rather connect to the previously # spawned process self.interface = Daemon.connect() def tearDown(self): self.interface.stop_all() os.system("pkill -f input-remapper-service") for _ in range(10): time.sleep(0.1) if not is_service_running(): break self.assertFalse(is_service_running()) def test_can_connect(self): # it's a remote dbus object self.assertEqual(self.interface._bus_name, BUS_NAME) self.assertFalse(isinstance(self.interface, Daemon)) self.assertEqual(self.interface.hello("foo"), "foo") if __name__ == "__main__": unittest.main() input-remapper-2.0.1/tests/integration/test_data.py000066400000000000000000000036661450214210500224760ustar00rootroot00000000000000#!/usr/bin/python3 # -*- 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 . import unittest import os import pkg_resources from inputremapper.configs.data import get_data_path class TestData(unittest.TestCase): @classmethod def setUpClass(cls): cls.original_location = pkg_resources.require("input-remapper")[0].location def tearDown(self): pkg_resources.require("input-remapper")[0].location = self.original_location def test_data_editable(self): path = os.getcwd().replace("/tests/integration", "") pkg_resources.require("input-remapper")[0].location = path self.assertEqual(get_data_path(), path + "/data/") self.assertEqual(get_data_path("a"), path + "/data/a") def test_data_usr(self): path = "/usr/some/where/python3.8/dist-packages/" pkg_resources.require("input-remapper")[0].location = path self.assertTrue(get_data_path().startswith("/usr/")) self.assertTrue(get_data_path().endswith("input-remapper/")) self.assertTrue(get_data_path("a").startswith("/usr/")) self.assertTrue(get_data_path("a").endswith("input-remapper/a")) if __name__ == "__main__": unittest.main() input-remapper-2.0.1/tests/integration/test_gui.py000066400000000000000000002460271450214210500223510ustar00rootroot00000000000000#!/usr/bin/python3 # -*- 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 . import asyncio # the tests file needs to be imported first to make sure patches are loaded from contextlib import contextmanager from typing import Tuple, List, Optional, Iterable from inputremapper.gui.autocompletion import get_incomplete_parameter, _get_left_text from inputremapper.injection.global_uinputs import global_uinputs from tests.lib.global_uinputs import reset_global_uinputs_for_service from tests.test import get_project_root from tests.lib.cleanup import cleanup, quick_cleanup from tests.lib.stuff import spy from tests.lib.constants import EVENT_READ_TIMEOUT from tests.lib.fixtures import prepare_presets from tests.lib.logger import logger from tests.lib.fixtures import fixtures from tests.lib.pipes import push_event, push_events, uinput_write_history_pipe from tests.integration.test_components import FlowBoxTestUtils import sys import time import atexit import os import unittest import multiprocessing import evdev from evdev.ecodes import ( EV_KEY, EV_ABS, KEY_LEFTSHIFT, KEY_A, KEY_Q, EV_REL, ) from unittest.mock import patch, MagicMock, call from importlib.util import spec_from_loader, module_from_spec from importlib.machinery import SourceFileLoader from inputremapper.input_event import InputEvent import gi gi.require_version("Gdk", "3.0") gi.require_version("Gtk", "3.0") gi.require_version("GtkSource", "4") gi.require_version("GLib", "2.0") from gi.repository import Gtk, GLib, Gdk, GtkSource from inputremapper.configs.system_mapping import system_mapping from inputremapper.configs.mapping import Mapping from inputremapper.configs.paths import CONFIG_PATH, get_preset_path, get_config_path from inputremapper.configs.global_config import global_config 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 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 # iterate a few times when Gtk.main() is called, but don't block # there and just continue to the tests while the UI becomes # unresponsive Gtk.main = gtk_iteration # doesn't do much except avoid some Gtk assertion error, whatever: Gtk.main_quit = lambda: None def launch( argv=None, ) -> Tuple[UserInterface, Controller, DataManager, MessageBroker, DaemonProxy]: """Start input-remapper-gtk with the command line argument array argv.""" bin_path = os.path.join(get_project_root(), "bin", "input-remapper-gtk") if not argv: argv = ["-d"] with patch.object(sys, "argv", [""] + [str(arg) for arg in argv]): loader = SourceFileLoader("__main__", bin_path) spec = spec_from_loader("__main__", loader) module = module_from_spec(spec) spec.loader.exec_module(module) gtk_iteration() # otherwise a new handler is added with each call to launch, which # spams tons of garbage when all tests finish atexit.unregister(module.stop) return ( module.user_interface, module.controller, module.data_manager, module.message_broker, module.daemon, ) def start_reader_service(): def process(): reader_service = ReaderService(_Groups()) loop = asyncio.new_event_loop() loop.run_until_complete(reader_service.run()) multiprocessing.Process(target=process).start() @contextmanager def patch_launch(): """patch the launch function such that we don't connect to the dbus and don't use pkexec to start the reader-service""" original_connect = Daemon.connect original_os_system = os.system Daemon.connect = Daemon def os_system(cmd): # 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) os.system = os_system yield os.system = original_os_system Daemon.connect = original_connect def clean_up_integration(test): test.controller.stop_injecting() gtk_iteration() test.user_interface.on_gtk_close() test.user_interface.window.destroy() gtk_iteration() cleanup() # do this now, not when all tests are finished test.daemon.stop_all() if isinstance(test.daemon, Daemon): atexit.unregister(test.daemon.stop_all) class GtkKeyEvent: def __init__(self, keyval): self.keyval = keyval def get_keyval(self): return True, self.keyval class TestGroupsFromReaderService(unittest.TestCase): def setUp(self): # don't try to connect, return an object instance of it instead self.original_connect = Daemon.connect Daemon.connect = Daemon # 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.original_os_system = os.system self.reader_service_started = MagicMock() def os_system(cmd): # 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 self.original_os_system(cmd) os.system = os_system ( self.user_interface, self.controller, self.data_manager, self.message_broker, self.daemon, ) = launch() def tearDown(self): clean_up_integration(self) os.system = self.original_os_system Daemon.connect = self.original_connect def test_knows_devices(self): # verify that it is working as expected. The gui doesn't have knowledge # of groups until the root-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") class PatchedConfirmDelete: def __init__(self, user_interface: UserInterface, response=Gtk.ResponseType.ACCEPT): self.response = response self.user_interface = user_interface self._original_create_dialog = user_interface._create_dialog self.patch = None def _create_dialog_patch(self, *args, **kwargs): """A patch for the deletion confirmation that briefly shows the dialog.""" confirm_cancel_dialog = self._original_create_dialog(*args, **kwargs) # the emitted signal causes the dialog to close GLib.timeout_add( 100, lambda: confirm_cancel_dialog.emit("response", self.response), ) Gtk.MessageDialog.run(confirm_cancel_dialog) # don't recursively call the patch confirm_cancel_dialog.run = lambda: self.response return confirm_cancel_dialog def __enter__(self): self.patch = patch.object( self.user_interface, "_create_dialog", self._create_dialog_patch, ) self.patch.__enter__() def __exit__(self, *args, **kwargs): self.patch.__exit__(*args, **kwargs) class GuiTestBase(unittest.TestCase): def setUp(self): prepare_presets() with patch_launch(): ( self.user_interface, self.controller, self.data_manager, self.message_broker, self.daemon, ) = 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 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 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() 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.assertNotAlmostEquals(color.red, fallback.red, delta=0.01) self.assertNotAlmostEquals(color.green, fallback.blue, delta=0.01) self.assertNotAlmostEquals(color.blue, fallback.green, delta=0.01) self.assertNotAlmostEquals(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) 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 = 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 PatchedConfirmDelete(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"{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 PatchedConfirmDelete(self.user_interface): self.delete_preset_btn.clicked() gtk_iteration() self.assertFalse(os.path.exists(preset_path)) def test_check_for_unknown_symbols(self): status = self.user_interface.get("status_bar") error_icon = self.user_interface.get("error_status_icon") warning_icon = self.user_interface.get("warning_status_icon") self.controller.load_preset("preset1") self.throttle(20) self.controller.load_mapping(InputCombination([InputConfig(type=1, code=1)])) gtk_iteration() self.controller.update_mapping(output_symbol="foo") gtk_iteration() self.controller.load_mapping(InputCombination([InputConfig(type=1, code=2)])) gtk_iteration() self.controller.update_mapping(output_symbol="qux") gtk_iteration() tooltip = status.get_tooltip_text().lower() self.assertIn("qux", tooltip) self.assertTrue(error_icon.get_visible()) self.assertFalse(warning_icon.get_visible()) # it will still save it though with open(get_preset_path("Foo Device", "preset1")) as f: content = f.read() self.assertIn("qux", content) self.assertIn("foo", content) self.controller.update_mapping(output_symbol="a") gtk_iteration() tooltip = status.get_tooltip_text().lower() self.assertIn("foo", tooltip) self.assertTrue(error_icon.get_visible()) self.assertFalse(warning_icon.get_visible()) self.controller.load_mapping(InputCombination([InputConfig(type=1, code=1)])) 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_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("not grabbed", 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): # It's 2023 everyone! That means this test randomly stopped working because it # used FrontendUInputs instead of regular UInputs. I guess a fucking ghost # was fixing this for us during 2022, but it seems to have disappeared. reset_global_uinputs_for_service() 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(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): reset_global_uinputs_for_service() 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(get_preset_path("Foo Device", "preset3"))) with PatchedConfirmDelete(self.user_interface, Gtk.ResponseType.CANCEL): self.delete_preset_btn.clicked() gtk_iteration() self.assertTrue(os.path.exists(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 PatchedConfirmDelete(self.user_interface): self.delete_preset_btn.clicked() gtk_iteration() self.assertFalse(os.path.exists(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 PatchedConfirmDelete(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"{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"{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(), "") self.assertTrue(self.output_box.get_sensitive()) # disable it by deleting the mapping with PatchedConfirmDelete(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()) 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_autocomplete_names(self): autocompletion = self.user_interface.autocompletion def setup(text): self.set_focus(self.code_editor) 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) complete_key_name = "Test_Foo_Bar" system_mapping.clear() system_mapping._set(complete_key_name, 1) system_mapping._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.code_editor self.set_focus(source_view) incomplete = "key(KEY_A).\nepea" Gtk.TextView.do_insert_at_cursor(source_view, incomplete) time.sleep(0.11) gtk_iteration() autocompletion = self.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.code_editor self.set_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.code_editor self.set_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.code_editor self.set_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 ) 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.0.1/tests/integration/test_numlock.py000066400000000000000000000034701450214210500232260ustar00rootroot00000000000000#!/usr/bin/python3 # -*- 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 . from tests.lib.cleanup import quick_cleanup import unittest from inputremapper.injection.numlock import is_numlock_on, set_numlock, ensure_numlock class TestNumlock(unittest.TestCase): @classmethod def setUpClass(cls): quick_cleanup() def tearDown(self): quick_cleanup() 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.0.1/tests/integration/test_user_interface.py000066400000000000000000000077511450214210500245620ustar00rootroot00000000000000import 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 tests.lib.cleanup import quick_cleanup 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 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() quick_cleanup() 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.0.1/tests/lib/000077500000000000000000000000001450214210500163645ustar00rootroot00000000000000input-remapper-2.0.1/tests/lib/__init__.py000066400000000000000000000000001450214210500204630ustar00rootroot00000000000000input-remapper-2.0.1/tests/lib/cleanup.py000066400000000000000000000131351450214210500203700ustar00rootroot00000000000000#!/usr/bin/python3 # -*- 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 . from __future__ import annotations import os import shutil import time import asyncio import psutil from pickle import UnpicklingError # 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... from tests.lib.logger import logger from tests.lib.pipes import ( uinput_write_history_pipe, uinput_write_history, pending_events, setup_pipe, ) from tests.lib.constants import EVENT_READ_TIMEOUT from tests.lib.tmp import tmp from tests.lib.fixtures import fixtures from tests.lib.stuff import environ_copy from tests.lib.patches import uinputs 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.""" # 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.global_config import global_config from inputremapper.configs.system_mapping import system_mapping from inputremapper.gui.utils import debounce_manager from inputremapper.configs.paths import get_config_path from inputremapper.injection.global_uinputs import global_uinputs from tests.lib.global_uinputs import reset_global_uinputs_for_service 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) global_config.path = os.path.join(get_config_path(), "config.json") global_config.clear_config() global_config._save_config() system_mapping.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) for uinput in global_uinputs.devices.values(): uinput.write_count = 0 uinput.write_history = [] reset_global_uinputs_for_service() 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.0.1/tests/lib/constants.py000066400000000000000000000022251450214210500207530ustar00rootroot00000000000000#!/usr/bin/python3 # -*- 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 . # 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.0.1/tests/lib/fixtures.py000066400000000000000000000272701450214210500206170ustar00rootroot00000000000000#!/usr/bin/python3 # -*- 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 . from __future__ import annotations import dataclasses import json from hashlib import md5 from typing import Dict, Optional import time import evdev 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 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 """ from inputremapper.configs.preset import Preset from inputremapper.configs.mapping import Mapping from inputremapper.configs.paths import get_config_path, get_preset_path from inputremapper.configs.global_config import global_config from inputremapper.configs.input_config import InputCombination preset1 = Preset(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(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(get_preset_path("Foo Device", "preset3")) preset3.add(Mapping.from_combination(InputCombination.from_tuples((1, 5)))) preset3.save() with open(get_config_path("config.json"), "w") as file: json.dump({"autoload": {"Foo Device 2": "preset2"}}, file, indent=4) global_config.load_config() return preset1, preset2, preset3 input-remapper-2.0.1/tests/lib/global_uinputs.py000066400000000000000000000024021450214210500217630ustar00rootroot00000000000000#!/usr/bin/python3 # -*- 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 . import sys from unittest.mock import patch from inputremapper.injection.global_uinputs import global_uinputs def reset_global_uinputs_for_service(): with patch.object(sys, "argv", ["input-remapper-service"]): # patch argv for global_uinputs to think it is a service global_uinputs.reset() def reset_global_uinputs_for_gui(): with patch.object(sys, "argv", ["input-remapper-gtk"]): global_uinputs.reset() input-remapper-2.0.1/tests/lib/logger.py000066400000000000000000000032511450214210500202160ustar00rootroot00000000000000#!/usr/bin/python3 # -*- 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 . 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.logger import update_verbosity 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.0.1/tests/lib/patches.py000066400000000000000000000251761450214210500204000ustar00rootroot00000000000000#!/usr/bin/python3 # -*- 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 . from __future__ import annotations import asyncio import copy import os import subprocess import time from pickle import UnpicklingError 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 import user user.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 # 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 [] uinputs = {} class UInput: def __init__(self, events=None, name="unnamed", *args, **kwargs): self.fd = 0 self.write_count = 0 self.device = InputDevice("justdoit") self.name = name self.events = events self.write_history = [] global uinputs uinputs[name] = self def capabilities(self, 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, ) evdev.list_devices = list_devices evdev.InputDevice = InputDevice evdev.UInput = UInput evdev.InputEvent = PatchedInputEvent def patch_events(): # improve logging of stuff evdev.InputEvent.__str__ = lambda self: ( f"InputEvent{(self.type, self.code, self.value)}" ) def patch_os_system(): """Avoid running pkexec.""" original_system = os.system def system(command): if "pkexec" in command: # because it # - will open a window for user input # - has no knowledge of the fixtures and patches raise Exception("Write patches to avoid running pkexec stuff") return original_system(command) os.system = system def patch_check_output(): """Xmodmap -pke should always return a fixed set of symbols. On some installations the `xmodmap` command might be missig completely, which would break the tests. """ original_check_output = subprocess.check_output def check_output(command, *args, **kwargs): if "xmodmap" in command and "-pke" in command: return xmodmap return original_check_output(command, *args, **kwargs) subprocess.check_output = check_output def patch_regrab_timeout(): # no need for a high number in tests from inputremapper.injection.injector import Injector 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 setattr(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": [], } 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 input-remapper-2.0.1/tests/lib/pipes.py000066400000000000000000000056721450214210500200700ustar00rootroot00000000000000#!/usr/bin/python3 # -*- 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 . """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 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.0.1/tests/lib/stuff.py000066400000000000000000000021111450214210500200600ustar00rootroot00000000000000#!/usr/bin/python3 # -*- 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 . import os import copy from unittest.mock import patch def spy(obj, name): """Convenient wrapper for patch.object(..., ..., wraps=...).""" return patch.object(obj, name, wraps=obj.__getattribute__(name)) environ_copy = copy.deepcopy(os.environ) input-remapper-2.0.1/tests/lib/tmp.py000066400000000000000000000021541450214210500175400ustar00rootroot00000000000000#!/usr/bin/python3 # -*- 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 . 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.0.1/tests/lib/xmodmap.py000066400000000000000000000323121450214210500204040ustar00rootroot00000000000000#!/usr/bin/python3 # -*- 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 . 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.0.1/tests/test.py000066400000000000000000000127051450214210500171540ustar00rootroot00000000000000#!/usr/bin/python3 # -*- 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 . from __future__ import annotations import argparse import os import sys import tracemalloc tracemalloc.start() # ensure nothing has loaded if module := sys.modules.get("inputremapper"): imported = [m for m in module.__dict__ if not m.startswith("__")] raise AssertionError( f"The modules {imported} from inputremapper where already imported, this can " f"cause issues with the tests. Make sure to always import tests.test before any" f" inputremapper module." ) try: sys.modules.get("tests.test").main raise AssertionError( "test.py was already imported. " "Always use 'from tests.test import ...' " "not 'from test import ...' to import this" ) # have fun debugging infinitely blocking tests without this except AttributeError: pass def get_project_root(): """Find the projects root, i.e. the uppermost directory of the repo.""" # when tests are started in pycharm via the green arrow, the working directory # is not the project root. Go up until it is found. root = os.getcwd() for _ in range(10): if "setup.py" in os.listdir(root): return root root = os.path.dirname(root) raise Exception("Could not find project root") # make sure the "tests" module visible sys.path.append(get_project_root()) if __name__ == "__main__": # import this file to itself to make sure is not run twice and all global # variables end up in sys.modules # https://stackoverflow.com/questions/13181559/importing-modules-main-vs-import-as-module import tests.test tests.test.main() import unittest import subprocess os.environ["UNITTEST"] = "1" from tests.lib.fixtures import fixtures from tests.lib.pipes import setup_pipe from tests.lib.patches import ( patch_paths, patch_events, patch_os_system, patch_check_output, patch_regrab_timeout, patch_is_running, patch_evdev, ) from tests.lib.cleanup import cleanup from tests.lib.logger import update_inputremapper_verbosity 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 if is_service_running(): # let tests control daemon existance raise Exception("Expected the service not to be running already.") # 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) # applying patches before importing input-remappers modules is important, otherwise # input-remapper might use non-patched modules. Importing modules from inputremapper # just-in-time in the test-setup functions instead of globally helps. This way, # it is ensured that the patches on evdev and such are already applied, without having # to take care about ordering the files in a special way. patch_paths() patch_evdev() patch_events() patch_os_system() patch_check_output() patch_regrab_timeout() patch_is_running() # patch_warnings() update_inputremapper_verbosity() def main(): cleanup() # https://docs.python.org/3/library/argparse.html parser = argparse.ArgumentParser(description=__doc__) # repeated argument 0 or more times with modules parser.add_argument("modules", type=str, nargs="*") # start-dir value if not using modules, allows eg python tests/test.py --start-dir unit parser.add_argument("--start-dir", type=str, default=".") parsed_args = parser.parse_args() # takes from sys.argv by default modules = parsed_args.modules # discoverer is really convenient, but it can't find a specific test # in all of the available tests like unittest.main() does..., # so provide both options. if len(modules) > 0: # for example # `tests/test.py integration.test_gui.TestGui.test_can_start` # or `tests/test.py integration.test_gui integration.test_daemon` testsuite = unittest.defaultTestLoader.loadTestsFromNames(modules) else: # run all tests by default testsuite = unittest.defaultTestLoader.discover( parsed_args.start_dir, pattern="test_*.py" ) # add a newline to each "qux (foo.bar)..." output before each test, # because the first log will be on the same line otherwise original_start_test = unittest.TextTestResult.startTest def start_test(self, test): original_start_test(self, test) print() unittest.TextTestResult.startTest = start_test result = unittest.TextTestRunner(verbosity=2).run(testsuite) sys.exit(not result.wasSuccessful()) input-remapper-2.0.1/tests/unit/000077500000000000000000000000001450214210500165755ustar00rootroot00000000000000input-remapper-2.0.1/tests/unit/__init__.py000066400000000000000000000001141450214210500207020ustar00rootroot00000000000000"""Tests that don't require a complete linux desktop.""" import tests.test input-remapper-2.0.1/tests/unit/test_config.py000066400000000000000000000124031450214210500214530ustar00rootroot00000000000000#!/usr/bin/python3 # -*- 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 . from tests.lib.cleanup import quick_cleanup from tests.lib.tmp import tmp import os import unittest from inputremapper.configs.global_config import global_config from inputremapper.configs.paths import touch class TestConfig(unittest.TestCase): def tearDown(self): quick_cleanup() self.assertEqual(len(global_config.iterate_autoload_presets()), 0) def test_basic(self): 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): 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): # when loading for the first time, create a config file with # the default values os.remove(global_config.path) 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): 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") 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.0.1/tests/unit/test_context.py000066400000000000000000000074301450214210500216760ustar00rootroot00000000000000#!/usr/bin/python3 # -*- 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 . from inputremapper.input_event import InputEvent from tests.lib.cleanup import quick_cleanup from evdev.ecodes import ( EV_REL, EV_ABS, ABS_X, ABS_Y, REL_WHEEL_HI_RES, REL_HWHEEL_HI_RES, ) import unittest from inputremapper.injection.context import Context from inputremapper.configs.preset import Preset from inputremapper.configs.mapping import Mapping from inputremapper.configs.input_config import InputConfig, InputCombination class TestContext(unittest.TestCase): @classmethod def setUpClass(cls): quick_cleanup() def test_callbacks(self): 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", "k(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, {}, {}) 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)) if __name__ == "__main__": unittest.main() input-remapper-2.0.1/tests/unit/test_control.py000066400000000000000000000275131450214210500216760ustar00rootroot00000000000000#!/usr/bin/python3 # -*- 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 . """Testing the input-remapper-control command""" from tests.lib.cleanup import quick_cleanup from tests.lib.tmp import tmp import os import time import unittest from unittest import mock import collections from importlib.util import spec_from_loader, module_from_spec from importlib.machinery import SourceFileLoader from inputremapper.configs.global_config import global_config from inputremapper.daemon import Daemon from inputremapper.configs.preset import Preset from inputremapper.configs.paths import get_preset_path from inputremapper.groups import groups def import_control(): """Import the core function of the input-remapper-control command.""" bin_path = os.path.join( os.getcwd().replace("/tests", ""), "bin", "input-remapper-control", ) loader = SourceFileLoader("__not_main_idk__", bin_path) spec = spec_from_loader("__not_main_idk__", loader) module = module_from_spec(spec) spec.loader.exec_module(module) return module.communicate, module.utils, module.internals communicate, utils, internals = import_control() options = collections.namedtuple( "options", ["command", "config_dir", "preset", "device", "list_devices", "key_names", "debug"], ) class TestControl(unittest.TestCase): def tearDown(self): quick_cleanup() def test_autoload(self): device_keys = ["Foo Device 2", "Bar Device"] groups_ = [groups.find(key=key) for key in device_keys] presets = ["bar0", "bar", "bar2"] paths = [ get_preset_path(groups_[0].name, presets[0]), get_preset_path(groups_[1].name, presets[1]), get_preset_path(groups_[1].name, presets[2]), ] Preset(paths[0]).save() Preset(paths[1]).save() Preset(paths[2]).save() 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() daemon.start_injecting = start_injecting global_config.set_autoload_preset(groups_[0].key, presets[0]) global_config.set_autoload_preset(groups_[1].key, presets[1]) communicate(options("autoload", None, None, None, False, False, False), daemon) self.assertEqual(len(start_history), 2) self.assertEqual(start_history[0], (groups_[0].key, presets[0])) self.assertEqual(start_history[1], (groups_[1].key, presets[1])) self.assertIn(groups_[0].key, daemon.injectors) self.assertIn(groups_[1].key, daemon.injectors) self.assertFalse( daemon.autoload_history.may_autoload(groups_[0].key, presets[0]) ) self.assertFalse( daemon.autoload_history.may_autoload(groups_[1].key, presets[1]) ) # calling autoload again doesn't load redundantly communicate(options("autoload", None, None, None, False, False, False), daemon) self.assertEqual(len(start_history), 2) self.assertEqual(stop_counter, 0) self.assertFalse( daemon.autoload_history.may_autoload(groups_[0].key, presets[0]) ) self.assertFalse( daemon.autoload_history.may_autoload(groups_[1].key, presets[1]) ) # unless the injection in question ist stopped communicate( options("stop", None, None, groups_[0].key, False, False, False), daemon, ) self.assertEqual(stop_counter, 1) self.assertTrue( daemon.autoload_history.may_autoload(groups_[0].key, presets[0]) ) self.assertFalse( daemon.autoload_history.may_autoload(groups_[1].key, presets[1]) ) communicate(options("autoload", None, None, None, False, False, False), daemon) self.assertEqual(len(start_history), 3) self.assertEqual(start_history[2], (groups_[0].key, presets[0])) self.assertFalse( daemon.autoload_history.may_autoload(groups_[0].key, presets[0]) ) self.assertFalse( daemon.autoload_history.may_autoload(groups_[1].key, presets[1]) ) # if a device name is passed, will only start injecting for that one communicate(options("stop-all", None, None, None, False, False, False), daemon) self.assertTrue( daemon.autoload_history.may_autoload(groups_[0].key, presets[0]) ) self.assertTrue( daemon.autoload_history.may_autoload(groups_[1].key, presets[1]) ) self.assertEqual(stop_counter, 3) global_config.set_autoload_preset(groups_[1].key, presets[2]) communicate( options("autoload", None, None, groups_[1].key, False, False, False), daemon, ) self.assertEqual(len(start_history), 4) self.assertEqual(start_history[3], (groups_[1].key, presets[2])) self.assertTrue( daemon.autoload_history.may_autoload(groups_[0].key, presets[0]) ) self.assertFalse( daemon.autoload_history.may_autoload(groups_[1].key, presets[2]) ) # autoloading for the same device again redundantly will not autoload # again communicate( options("autoload", None, None, groups_[1].key, False, False, False), daemon, ) self.assertEqual(len(start_history), 4) self.assertEqual(stop_counter, 3) self.assertFalse( daemon.autoload_history.may_autoload(groups_[1].key, presets[2]) ) # any other arbitrary preset may be autoloaded self.assertTrue(daemon.autoload_history.may_autoload(groups_[1].key, "quuuux")) # after 15 seconds it may be autoloaded again daemon.autoload_history._autoload_history[groups_[1].key] = ( time.time() - 16, presets[2], ) self.assertTrue( daemon.autoload_history.may_autoload(groups_[1].key, presets[2]) ) def test_autoload_other_path(self): device_names = ["Foo Device", "Bar Device"] groups_ = [groups.find(name=name) for name in device_names] presets = ["bar123", "bar2"] config_dir = os.path.join(tmp, "qux", "quux") paths = [ os.path.join(config_dir, "presets", device_names[0], presets[0] + ".json"), os.path.join(config_dir, "presets", device_names[1], presets[1] + ".json"), ] Preset(paths[0]).save() Preset(paths[1]).save() daemon = Daemon() start_history = [] daemon.start_injecting = lambda *args: start_history.append(args) global_config.path = os.path.join(config_dir, "config.json") global_config.load_config() global_config.set_autoload_preset(device_names[0], presets[0]) global_config.set_autoload_preset(device_names[1], presets[1]) communicate( options("autoload", config_dir, None, None, False, False, False), daemon, ) self.assertEqual(len(start_history), 2) self.assertEqual(start_history[0], (groups_[0].key, presets[0])) self.assertEqual(start_history[1], (groups_[1].key, presets[1])) def test_start_stop(self): group = groups.find(key="Foo Device 2") preset = "preset9" daemon = Daemon() start_history = [] stop_history = [] stop_all_history = [] daemon.start_injecting = lambda *args: start_history.append(args) daemon.stop_injecting = lambda *args: stop_history.append(args) daemon.stop_all = lambda *args: stop_all_history.append(args) communicate( options("start", None, preset, group.paths[0], False, False, False), daemon, ) self.assertEqual(len(start_history), 1) self.assertEqual(start_history[0], (group.key, preset)) communicate( options("stop", None, None, group.paths[1], False, False, False), daemon, ) self.assertEqual(len(stop_history), 1) # provided any of the groups paths as --device argument, figures out # the correct group.key to use here self.assertEqual(stop_history[0], (group.key,)) communicate(options("stop-all", None, None, None, False, False, False), daemon) self.assertEqual(len(stop_all_history), 1) self.assertEqual(stop_all_history[0], ()) def test_config_not_found(self): key = "Foo Device 2" path = "~/a/preset.json" config_dir = "/foo/bar" daemon = Daemon() start_history = [] stop_history = [] daemon.start_injecting = lambda *args: start_history.append(args) daemon.stop_injecting = lambda *args: stop_history.append(args) options_1 = options("start", config_dir, path, key, False, False, False) self.assertRaises(SystemExit, lambda: communicate(options_1, daemon)) options_2 = options("stop", config_dir, None, key, False, False, False) self.assertRaises(SystemExit, lambda: communicate(options_2, daemon)) def test_autoload_config_dir(self): daemon = Daemon() path = os.path.join(tmp, "foo") os.makedirs(path) with open(os.path.join(path, "config.json"), "w") as file: file.write('{"foo":"bar"}') self.assertIsNone(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(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(global_config.get("foo"), "bar") daemon.set_config_dir(os.path.join(tmp, "qux")) self.assertEqual(global_config.get("foo"), "bar") def test_internals(self): with mock.patch("os.system") as os_system_patch: internals( options("start-reader-service", None, None, None, False, False, 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]) with mock.patch("os.system") as os_system_patch: internals(options("start-daemon", None, None, None, False, False, True)) os_system_patch.assert_called_once() self.assertIn("input-remapper-service", os_system_patch.call_args.args[0]) self.assertIn("-d", os_system_patch.call_args.args[0]) if __name__ == "__main__": unittest.main() input-remapper-2.0.1/tests/unit/test_controller.py000066400000000000000000001651171450214210500224040ustar00rootroot00000000000000#!/usr/bin/python3 # -*- 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 . 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.system_mapping import system_mapping from inputremapper.injection.injector import InjectorState from tests.lib.logger import logger 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 from inputremapper.configs.mapping import UIMapping, MappingData, MappingType, Mapping from tests.lib.cleanup import quick_cleanup from tests.lib.stuff 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 get_preset_path, CONFIG_PATH from inputremapper.configs.preset import Preset class TestController(unittest.TestCase): def setUp(self) -> None: super().setUp() self.message_broker = MessageBroker() uinputs = GlobalUInputs() uinputs.prepare_all() self.data_manager = DataManager( self.message_broker, GlobalConfig(), ReaderClient(self.message_broker, _Groups()), FakeDaemonProxy(), uinputs, system_mapping, ) self.user_interface = MagicMock() self.controller = Controller(self.message_broker, self.data_manager) self.controller.set_gui(self.user_interface) def tearDown(self) -> None: quick_cleanup() 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(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(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(get_preset_path("Foo Device", "preset2"))) def test_does_not_delete_preset_when_not_confirmed(self): prepare_presets() self.assertTrue(os.path.isfile(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(get_preset_path("Foo Device", "preset2"))) def test_copy_preset(self): prepare_presets() self.assertTrue(os.path.isfile(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(get_preset_path("Foo Device", "preset2"))) self.assertTrue(os.path.exists(get_preset_path("Foo Device", "preset2 copy"))) def test_copy_preset_should_add_number(self): prepare_presets() self.assertTrue(os.path.isfile(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(get_preset_path("Foo Device", "preset2"))) self.assertTrue(os.path.exists(get_preset_path("Foo Device", "preset2 copy"))) self.assertTrue(os.path.exists(get_preset_path("Foo Device", "preset2 copy 2"))) def test_copy_preset_should_increment_existing_number(self): prepare_presets() self.assertTrue(os.path.isfile(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(get_preset_path("Foo Device", "preset2"))) self.assertTrue(os.path.exists(get_preset_path("Foo Device", "preset2 copy"))) self.assertTrue(os.path.exists(get_preset_path("Foo Device", "preset2 copy 2"))) self.assertTrue(os.path.exists(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(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(get_preset_path("Foo Device", "preset2"))) self.assertTrue(os.path.exists(get_preset_path("Foo Device", "preset2 copy"))) self.assertTrue(os.path.exists(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(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(get_preset_path("Foo Device", "preset2"))) self.assertTrue(os.path.exists(get_preset_path("Foo Device", "preset2 copy"))) self.assertTrue(os.path.exists(get_preset_path("Foo Device", "preset2 copy 2"))) self.assertTrue(os.path.exists(get_preset_path("Foo Device", "preset2 copy 3"))) def test_rename_preset(self): prepare_presets() self.assertTrue(os.path.isfile(get_preset_path("Foo Device", "preset2"))) self.assertFalse(os.path.exists(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(get_preset_path("Foo Device", "preset2"))) self.assertTrue(os.path.exists(get_preset_path("Foo Device", "foo"))) def test_rename_preset_sanitized(self): Preset(get_preset_path("Qux/Device?", "bla")).save() self.assertTrue(os.path.isfile(get_preset_path("Qux/Device?", "bla"))) self.assertFalse(os.path.exists(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(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(get_preset_path("Qux/Device?", "foo:/bar"))) path = os.path.join(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(get_preset_path("Qux_Device_", "foo__bar"))) def test_rename_preset_should_pick_available_name(self): prepare_presets() self.assertTrue(os.path.exists(get_preset_path("Foo Device", "preset2"))) self.assertTrue(os.path.exists(get_preset_path("Foo Device", "preset3"))) self.assertFalse(os.path.exists(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(get_preset_path("Foo Device", "preset2"))) self.assertTrue(os.path.exists(get_preset_path("Foo Device", "preset3"))) self.assertTrue(os.path.exists(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(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(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(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(get_preset_path("Foo Device", "preset2"))) self.assertFalse(os.path.exists(get_preset_path("Foo Device", "preset2 2"))) def test_on_add_preset_uses_default_name(self): self.assertFalse( os.path.exists(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(get_preset_path("Foo Device", "new preset"))) def test_on_add_preset_uses_provided_name(self): self.assertFalse(os.path.exists(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(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(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(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(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(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.FAILED self.controller.start_injecting() gtk_iteration(50) self.assertEqual(calls[-1].msg, _("Failed to apply preset %s") % "preset2") mock.return_value = InjectorState.NO_GRAB self.controller.start_injecting() gtk_iteration(50) self.assertEqual(calls[-1].msg, "The device was not grabbed") 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.0.1/tests/unit/test_daemon.py000066400000000000000000000474621450214210500214660ustar00rootroot00000000000000#!/usr/bin/python3 # -*- 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 . from evdev._ecodes import EV_ABS from inputremapper.input_event import InputEvent from tests.test import is_service_running from tests.lib.logger import logger from tests.lib.cleanup import cleanup from tests.lib.fixtures import Fixture from tests.lib.pipes import push_events, uinput_write_history_pipe from tests.lib.tmp import tmp from tests.lib.fixtures import fixtures import os import unittest import time import subprocess import json import evdev from evdev.ecodes import EV_KEY, KEY_B, KEY_A, ABS_X, BTN_A, BTN_B from pydbus import SystemBus from inputremapper.configs.system_mapping import system_mapping from inputremapper.configs.mapping import Mapping from inputremapper.configs.global_config import global_config from inputremapper.groups import groups from inputremapper.configs.paths import get_config_path, mkdir, get_preset_path from inputremapper.configs.input_config import InputCombination, InputConfig from inputremapper.configs.preset import Preset from inputremapper.injection.injector import InjectorState from inputremapper.daemon import Daemon from inputremapper.injection.global_uinputs import global_uinputs check_output = subprocess.check_output os_system = os.system dbus_get = type(SystemBus()).get class TestDaemon(unittest.TestCase): new_fixture_path = "/dev/input/event9876" def setUp(self): self.grab = evdev.InputDevice.grab self.daemon = None mkdir(get_config_path()) global_config._save_config() # the daemon should be able to create them on demand: global_uinputs.devices = {} 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 evdev.InputDevice.grab = self.grab subprocess.check_output = check_output os.system = os_system type(SystemBus()).get = dbus_get cleanup() def test_connect(self): os_system_history = [] os.system = os_system_history.append self.assertFalse(is_service_running()) # no daemon runs, should try to run it via pkexec instead. # It fails due to the patch on os.system and therefore exits the process self.assertRaises(SystemExit, Daemon.connect) self.assertEqual(len(os_system_history), 1) self.assertIsNone(Daemon.connect(False)) # make the connect command work this time by acting like a connection is # available: set_config_dir_callcount = 0 class FakeConnection: def set_config_dir(self, *args, **kwargs): nonlocal set_config_dir_callcount set_config_dir_callcount += 1 type(SystemBus()).get = lambda *args, **kwargs: FakeConnection() self.assertIsInstance(Daemon.connect(), FakeConnection) self.assertEqual(set_config_dir_callcount, 1) self.assertIsInstance(Daemon.connect(False), FakeConnection) self.assertEqual(set_config_dir_callcount, 2) def test_daemon(self): # remove the existing system mapping to force our own into it if os.path.exists(get_config_path("xmodmap.json")): os.remove(get_config_path("xmodmap.json")) 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() 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.assertFalse(uinput_write_history_pipe[0].poll()) # has been cleanedUp in setUp self.assertNotIn("keyboard", 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", global_uinputs.devices) self.assertNotIn("gamepad", 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): global_config.set("foo", "bar") self.assertEqual(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.assertEqual(self.daemon.config_dir, get_config_path()) self.assertIsNone(global_config.get("foo")) def test_refresh_on_start(self): if os.path.exists(get_config_path("xmodmap.json")): os.remove(get_config_path("xmodmap.json")) 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) system_mapping.clear() system_mapping._set("a", KEY_A) preset = Preset(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(get_config_path("xmodmap.json"), "w") as file: json.dump(system_mapping._mapping, file, indent=4) system_mapping.clear() preset.save() global_config.set_autoload_preset(group_key, preset_name) self.daemon = Daemon() # 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() # 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() system_mapping.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") global_config.path = config_path 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.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.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.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)) 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() 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() global_config.set_autoload_preset(group.key, preset_name) # ignored, won't cause problems: 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() global_config.set_autoload_preset(group.key, preset_name) self.daemon = Daemon() groups.set_groups([]) # caused the bug self.assertIsNone(groups.find(key="Foo Device 2")) self.daemon.autoload() # it should try to refresh the groups because all the # group_keys are unknown at the moment history = self.daemon.autoload_history._autoload_history self.assertEqual(history[group.key][1], preset_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.0.1/tests/unit/test_data_manager.py000066400000000000000000001120001450214210500226030ustar00rootroot00000000000000#!/usr/bin/python3 # -*- 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 . 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 global_config from inputremapper.configs.mapping import UIMapping, MappingData from inputremapper.configs.system_mapping import system_mapping from inputremapper.configs.input_config import InputCombination, InputConfig from inputremapper.exceptions import DataManagementError from inputremapper.groups import _Groups 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 from tests.lib.cleanup import quick_cleanup from tests.lib.patches import FakeDaemonProxy from tests.lib.fixtures import prepare_presets from inputremapper.configs.paths import get_preset_path from inputremapper.configs.preset import Preset from inputremapper.gui.data_manager import DataManager, DEFAULT_PRESET_NAME class Listener: def __init__(self): self.calls: List = [] def __call__(self, data): self.calls.append(data) class TestDataManager(unittest.TestCase): def setUp(self) -> None: self.message_broker = MessageBroker() self.reader = ReaderClient(self.message_broker, _Groups()) self.uinputs = GlobalUInputs() self.uinputs.prepare_all() self.data_manager = DataManager( self.message_broker, global_config, self.reader, FakeDaemonProxy(), self.uinputs, system_mapping, ) def tearDown(self) -> None: quick_cleanup() 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(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(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(get_preset_path("Qux/Device?", "bla")).save() Preset(get_preset_path("Qux/Device?", "foo")).save() self.assertTrue(os.path.exists(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(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(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(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(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(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(get_preset_path("Foo Device", "preset 1")).save() time.sleep(0.01) Preset(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(get_preset_path("Foo Device", "preset 1")).save() time.sleep(0.01) Preset(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(get_preset_path("Foo Device", "preset 1")).save() time.sleep(0.01) Preset(get_preset_path("Bar Device", "preset 2")).save() # not a preset, ignore time.sleep(0.01) path = os.path.join(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(get_preset_path("Bar Device", "preset 1")).save() time.sleep(0.01) Preset(get_preset_path("Bar Device", "preset 2")).save() time.sleep(0.01) Preset(get_preset_path("Bar Device", "preset 3")).save() # not a preset, ignore time.sleep(0.01) path = os.path.join(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_unknon_groups(self): Preset(get_preset_path("Bar Device", "preset 1")).save() time.sleep(0.01) Preset(get_preset_path("unknown_group", "preset 2")).save() # not a known group 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(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(get_preset_path("Foo Device", "preset 1")).save() time.sleep(0.01) Preset(get_preset_path("Foo Device", "preset 3")).save() time.sleep(0.01) Preset(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(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(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_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(get_preset_path("Qux/Device?", DEFAULT_PRESET_NAME)).save() self.assertEqual( self.data_manager.get_available_preset_name(), f"{DEFAULT_PRESET_NAME} 2" ) Preset(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(get_preset_path("Foo Device", DEFAULT_PRESET_NAME)).save() Preset(get_preset_path("Foo Device", f"{DEFAULT_PRESET_NAME} 2")).save() Preset(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(get_preset_path("Foo Device", "foo")).save() Preset(get_preset_path("Foo Device", "foo 1")).save() Preset(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.0.1/tests/unit/test_event_pipeline/000077500000000000000000000000001450214210500226425ustar00rootroot00000000000000input-remapper-2.0.1/tests/unit/test_event_pipeline/__init__.py000066400000000000000000000000001450214210500247410ustar00rootroot00000000000000input-remapper-2.0.1/tests/unit/test_event_pipeline/test_axis_transformation.py000066400000000000000000000165541450214210500303600ustar00rootroot00000000000000#!/usr/bin/python3 # -*- 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 . import dataclasses import functools import unittest import itertools from typing import Iterable, List from inputremapper.injection.mapping_handlers.axis_transform import Transformation 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.0.1/tests/unit/test_event_pipeline/test_event_pipeline.py000066400000000000000000001657421450214210500273000ustar00rootroot00000000000000#!/usr/bin/python3 # -*- 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 . 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, BTN_TL, ) 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.system_mapping import system_mapping 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 global_uinputs 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 class EventPipelineTestBase(unittest.IsolatedAsyncioTestCase): """Test the event pipeline form event_reader to UInput.""" def setUp(self): global_uinputs.is_service = True global_uinputs.prepare_all() self.forward_uinput = evdev.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}, ) reader = EventReader( context, evdev.InputDevice(source.path), self.stop_event, ) asyncio.ensure_future(reader.run()) return reader class TestIdk(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 system_mapping.clear() code_w = 71 code_b = 72 code_c = 73 code_d = 74 code_a = 75 code_s = 76 system_mapping._set("w", code_w) system_mapping._set("d", code_d) system_mapping._set("a", code_a) system_mapping._set("s", code_s) system_mapping._set("b", code_b) system_mapping._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 = 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 = system_mapping.get("a") b = system_mapping.get("b") c = system_mapping.get("c") d = system_mapping.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 = 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 = 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 system_mapping._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 = 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 system_mapping._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 = 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 = system_mapping.get("a") b = system_mapping.get("b") c = system_mapping.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", ) 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 = global_uinputs.get_uinput("keyboard").write_history forwarded_history = self.forward_uinput.write_history 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 = 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_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 = system_mapping.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 = 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 = system_mapping.get("a") b = system_mapping.get("b") c = system_mapping.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 = 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 = 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 = 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 = 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 = 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 = system_mapping.get("a") b = system_mapping.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 = 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 = global_uinputs.get_uinput("keyboard").write_history self.assertIn((EV_KEY, b, 1), keyboard_history) len_a = len(global_uinputs.get_uinput("keyboard").write_history) await asyncio.sleep(0.05) len_b = len(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 = global_uinputs.get_uinput("keyboard").write_history self.assertEqual(keyboard_history[-1], (EV_KEY, b, 0)) await asyncio.sleep(0.05) len_c = len(global_uinputs.get_uinput("keyboard").write_history) await asyncio.sleep(0.05) len_d = len(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(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)) ) system_mapping.clear() system_mapping._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 = 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 = 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 = 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 = 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 = 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)), ], ) 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 = 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 = 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)), ], ) class TestRelToAbs(EventPipelineTestBase): async def test_rel_to_abs(self): timestamp = 0 def next_usec_time(): nonlocal timestamp timestamp += 1000000 / DEFAULT_REL_RATE return timestamp gain = 0.5 # left mouse x to abs x 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) 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 = 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 = 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 = 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 = 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_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 = global_uinputs.get_uinput("gamepad").write_history self.assertEqual( history, [ InputEvent.from_tuple((3, 0, MAX_ABS / 2)), InputEvent.from_tuple((3, 0, 0)), ], ) 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 = 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 = 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) 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) system_mapping.clear() code_b = 91 code_c = 92 system_mapping._set("b", code_b) system_mapping._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 = 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 = system_mapping.get("a") b = system_mapping.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 = 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 = 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 = 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)], ) 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 = system_mapping.get("a") b = system_mapping.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 = 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 = 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 = 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) 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 = 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 = 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 = 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.0.1/tests/unit/test_event_pipeline/test_mapping_handlers.py000066400000000000000000000422461450214210500275760ustar00rootroot00000000000000#!/usr/bin/python3 # -*- 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 . """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.injection.mapping_handlers.combination_handler import ( CombinationHandler, ) from inputremapper.injection.mapping_handlers.rel_to_btn_handler import RelToBtnHandler from inputremapper.configs.mapping import Mapping, DEFAULT_REL_RATE from inputremapper.configs.input_config import InputCombination, InputConfig from inputremapper.injection.global_uinputs import global_uinputs 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.rel_to_rel_handler import RelToRelHandler from inputremapper.injection.mapping_handlers.axis_switch_handler import ( AxisSwitchHandler, ) from inputremapper.injection.mapping_handlers.hierarchy_handler import HierarchyHandler from inputremapper.injection.mapping_handlers.key_handler import KeyHandler from inputremapper.injection.mapping_handlers.macro_handler import MacroHandler from inputremapper.injection.mapping_handlers.mapping_handler import MappingHandler from inputremapper.injection.mapping_handlers.rel_to_abs_handler import RelToAbsHandler from inputremapper.input_event import InputEvent, EventActions from tests.lib.cleanup import cleanup from tests.lib.logger import logger from tests.lib.patches import InputDevice from tests.lib.constants import MAX_ABS from tests.lib.fixtures import fixtures 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() class TestAxisSwitchHandler(BaseTests, unittest.IsolatedAsyncioTestCase): def setUp(self): input_combination = InputCombination( ( InputConfig(type=2, code=5), InputConfig(type=1, code=3), ) ) self.handler = AxisSwitchHandler( input_combination, Mapping( input_combination=input_combination.to_config(), target_uinput="mouse", output_type=2, output_code=1, ), MagicMock(), ) class TestAbsToBtnHandler(BaseTests, unittest.IsolatedAsyncioTestCase): def setUp(self): input_combination = InputCombination( [InputConfig(type=3, code=5, analog_threshold=10)] ) self.handler = AbsToBtnHandler( input_combination, Mapping( input_combination=input_combination.to_config(), target_uinput="mouse", output_symbol="BTN_LEFT", ), ) class TestAbsToAbsHandler(BaseTests, unittest.IsolatedAsyncioTestCase): def setUp(self): input_combination = InputCombination([InputConfig(type=EV_ABS, code=ABS_X)]) self.handler = AbsToAbsHandler( input_combination, Mapping( input_combination=input_combination.to_config(), target_uinput="gamepad", output_type=EV_ABS, output_code=ABS_X, ), ) 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 = global_uinputs.get_uinput("gamepad").write_history self.assertEqual( history, [InputEvent.from_tuple((3, 0, MAX_ABS)), InputEvent.from_tuple((3, 0, 0))], ) class TestRelToAbsHandler(BaseTests, unittest.IsolatedAsyncioTestCase): def setUp(self): input_combination = InputCombination([InputConfig(type=EV_REL, code=REL_X)]) self.handler = RelToAbsHandler( input_combination, Mapping( input_combination=input_combination.to_config(), target_uinput="gamepad", output_type=EV_ABS, output_code=ABS_X, ), ) 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 = 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) class TestAbsToRelHandler(BaseTests, unittest.IsolatedAsyncioTestCase): def setUp(self): input_combination = InputCombination([InputConfig(type=EV_ABS, code=ABS_X)]) self.handler = AbsToRelHandler( input_combination, Mapping( input_combination=input_combination.to_config(), target_uinput="mouse", output_type=EV_REL, output_code=REL_X, ), ) 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 = 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, global_uinputs.get_uinput("mouse").write_count) 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.handler = CombinationHandler( input_combination, Mapping( input_combination=input_combination.to_config(), target_uinput="mouse", output_symbol="BTN_LEFT", ), self.context_mock, ) 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. mock = MagicMock() self.handler.set_sub_handler(mock) # insert our own test-uinput to see what is being written to it 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: uinputs[origin_hash] # 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( uinputs[self.mouse_hash].write_history, [InputEvent.rel(self.input_combination[0].code, 0)], ) self.assertListEqual( uinputs[self.keyboard_hash].write_history, [InputEvent.key(self.input_combination[1].code, 0)], ) self.assertListEqual( 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 mock = MagicMock() self.handler.set_sub_handler(mock) # insert our own test-uinput to see what is being written to it uinputs = { self.mouse_hash: evdev.UInput(), self.keyboard_hash: evdev.UInput(), } self.context_mock.get_forward_uinput = lambda origin_hash: uinputs[origin_hash] # 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(uinputs[self.mouse_hash].write_history, []) self.assertListEqual(uinputs[self.keyboard_hash].write_history, []) class TestHierarchyHandler(BaseTests, unittest.IsolatedAsyncioTestCase): def setUp(self): self.mock1 = MagicMock() self.mock2 = MagicMock() self.mock3 = MagicMock() self.handler = HierarchyHandler( [self.mock1, self.mock2, self.mock3], InputConfig(type=EV_KEY, code=KEY_A), ) def test_reset(self): self.handler.reset() self.mock1.reset.assert_called() self.mock2.reset.assert_called() self.mock3.reset.assert_called() 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.handler = KeyHandler( input_combination, Mapping( input_combination=input_combination.to_config(), target_uinput="mouse", output_symbol="BTN_LEFT", ), ) 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 = 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 = global_uinputs.get_uinput("mouse").write_history self.assertEqual(history[1], InputEvent.key(BTN_LEFT, 0)) self.assertEqual(len(history), 2) class TestMacroHandler(BaseTests, unittest.IsolatedAsyncioTestCase): def setUp(self): input_combination = InputCombination( ( InputConfig(type=2, code=0, analog_threshold=10), InputConfig(type=1, code=3), ) ) self.context_mock = MagicMock() self.handler = MacroHandler( input_combination, Mapping( input_combination=input_combination.to_config(), target_uinput="mouse", output_symbol="hold_keys(BTN_LEFT, BTN_RIGHT)", ), context=self.context_mock, ) async def test_reset(self): 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 = global_uinputs.get_uinput("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 = global_uinputs.get_uinput("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) class TestRelToBtnHandler(BaseTests, unittest.IsolatedAsyncioTestCase): def setUp(self): input_combination = InputCombination( [InputConfig(type=2, code=0, analog_threshold=10)] ) self.handler = RelToBtnHandler( input_combination, Mapping( input_combination=input_combination.to_config(), target_uinput="mouse", output_symbol="BTN_LEFT", ), ) class TestRelToRelHanlder(BaseTests, unittest.IsolatedAsyncioTestCase): handler: RelToRelHandler def setUp(self): input_combination = InputCombination([InputConfig(type=EV_REL, code=REL_X)]) 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", ), ) 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.0.1/tests/unit/test_event_reader.py000066400000000000000000000211521450214210500226520ustar00rootroot00000000000000#!/usr/bin/python3 # -*- 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 . import asyncio import unittest import evdev from evdev.ecodes import ( EV_KEY, EV_ABS, 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.mapping import Mapping from inputremapper.configs.preset import Preset from inputremapper.configs.system_mapping import system_mapping 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 global_uinputs from inputremapper.input_event import InputEvent from inputremapper.utils import get_device_hash from tests.lib.fixtures import fixtures from tests.lib.cleanup import quick_cleanup class TestEventReader(unittest.IsolatedAsyncioTestCase): def setUp(self): self.gamepad_source = evdev.InputDevice(fixtures.gamepad.path) self.stop_event = asyncio.Event() self.preset = Preset() global_uinputs.is_service = True global_uinputs.prepare_all() def tearDown(self): quick_cleanup() async def setup(self, source, mapping): """Set a EventReader up for the test and run it in the background.""" context = Context(mapping, {}, {}) context.uinput = evdev.UInput() event_reader = EventReader(context, 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 = system_mapping.get("a") code_shift = system_mapping.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.gamepad_source, 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 = 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 = system_mapping.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.gamepad_source, 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 = global_uinputs.get_uinput("keyboard").write_history # the key that triggered if_single should be injected after # if_single had a chance to inject keys (if the macro is fast enough), # so that if_single can inject a modifier to e.g. capitalize the # triggering key. This is important for the space cadet shift self.assertListEqual( history, [ (EV_KEY, code_a, 1), (EV_KEY, code_a, 0), ], ) input-remapper-2.0.1/tests/unit/test_global_uinputs.py000066400000000000000000000055641450214210500232470ustar00rootroot00000000000000#!/usr/bin/python3 # -*- 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 . from inputremapper.input_event import InputEvent from tests.lib.cleanup import cleanup import sys import unittest import evdev from unittest.mock import patch from evdev.ecodes import ( EV_KEY, EV_ABS, KEY_A, ABS_X, ) from inputremapper.injection.global_uinputs import ( global_uinputs, FrontendUInput, GlobalUInputs, ) from inputremapper.exceptions import EventNotHandled, UinputNotAvailable class TestFrontendUinput(unittest.TestCase): def setUp(self) -> None: cleanup() def test_init(self): name = "foo" capabilities = {1: [1, 2, 3], 2: [4, 5, 6]} uinput_defaults = FrontendUInput() uinput_custom = FrontendUInput(name=name, events=capabilities) self.assertEqual(uinput_defaults.name, "py-evdev-uinput") self.assertIsNone(uinput_defaults.capabilities()) self.assertEqual(uinput_custom.name, name) self.assertEqual(uinput_custom.capabilities(), capabilities) class TestGlobalUinputs(unittest.TestCase): def setUp(self) -> None: cleanup() def test_iter(self): for uinput in global_uinputs: self.assertIsInstance(uinput, evdev.UInput) def test_write(self): """Test write and write failure implicitly tests get_uinput and UInput.can_emit """ ev_1 = 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() with patch.object(sys, "argv", ["foo"]): frontend_uinputs.prepare_all() uinput = frontend_uinputs.get_uinput("keyboard") self.assertIsInstance(uinput, FrontendUInput) input-remapper-2.0.1/tests/unit/test_groups.py000066400000000000000000000247671450214210500215450ustar00rootroot00000000000000#!/usr/bin/python3 # -*- 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 . from tests.lib.cleanup import quick_cleanup from tests.lib.fixtures import fixtures, keyboard_keys import os import unittest import json import evdev from evdev.ecodes import EV_KEY, KEY_A from inputremapper.configs.paths import CONFIG_PATH from inputremapper.groups import ( _FindGroups, groups, classify, DeviceType, _Group, ) class FakePipe: groups = None def send(self, groups): self.groups = groups class TestGroups(unittest.TestCase): def tearDown(self): quick_cleanup() def test_group(self): group = _Group( paths=["/dev/a", "/dev/b", "/dev/c"], names=["name_bar", "name_a", "name_foo"], types=[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(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): # could be anything, a lot of devices have ABS_X capabilities, # so it is not treated as gamepad joystick and since it also # doesn't have key capabilities, there is nothing to map. fixtures["/foo/bar"] = { "name": "qux", "phys": "abcd2", "info": evdev.DeviceInfo(1, 2, 3, 4), "capabilities": {evdev.ecodes.EV_ABS: [evdev.ecodes.ABS_X]}, } groups.refresh() self.assertIsNotNone(groups.find(name="gamepad")) self.assertIsNone(groups.find(name="qux")) # verify this test even works at all fixtures["/foo/bar"].capabilities[EV_KEY] = [KEY_A] groups.refresh() self.assertIsNotNone(groups.find(name="qux")) def test_duplicate_device(self): fixtures["/dev/input/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.0.1/tests/unit/test_injector.py000066400000000000000000000563551450214210500220410ustar00rootroot00000000000000#!/usr/bin/python3 # -*- 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 . try: from pydantic.v1 import ValidationError except ImportError: from pydantic import ValidationError from inputremapper.input_event import InputEvent from tests.lib.global_uinputs import ( reset_global_uinputs_for_service, reset_global_uinputs_for_gui, ) from tests.lib.patches import uinputs from tests.lib.cleanup import quick_cleanup from tests.lib.constants import EVENT_READ_TIMEOUT from tests.lib.fixtures import fixtures from tests.lib.pipes import uinput_write_history_pipe from tests.lib.pipes import read_write_history_pipe, push_events from tests.lib.fixtures import keyboard_keys import unittest from unittest import mock import time 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.injection.injector import ( Injector, is_in_capabilities, InjectorState, get_udev_name, ) from inputremapper.injection.numlock import is_numlock_on from inputremapper.configs.system_mapping import ( system_mapping, DISABLE_CODE, DISABLE_NAME, ) from inputremapper.configs.preset import Preset from inputremapper.configs.mapping import Mapping from inputremapper.configs.input_config import InputCombination, InputConfig from inputremapper.injection.macros.parse import parse from inputremapper.injection.context import Context from inputremapper.groups import groups, classify, DeviceType def wait_for_uinput_write(): start = time.time() if not uinput_write_history_pipe[0].poll(timeout=10): raise AssertionError("No event written within 10 seconds") return float(time.time() - start) class TestInjector(unittest.IsolatedAsyncioTestCase): new_gamepad_path = "/dev/input/event100" @classmethod def setUpClass(cls): cls.injector = None cls.grab = evdev.InputDevice.grab quick_cleanup() def setUp(self): self.failed = 0 self.make_it_fail = 2 def grab_fail_twice(_): if self.failed < self.make_it_fail: self.failed += 1 raise OSError() evdev.InputDevice.grab = grab_fail_twice def tearDown(self): if self.injector is not None and self.injector.is_alive(): self.injector.stop_injecting() time.sleep(0.2) self.assertIn( self.injector.get_state(), (InjectorState.STOPPED, InjectorState.FAILED, InjectorState.NO_GRAB), ) self.injector = None evdev.InputDevice.grab = self.grab quick_cleanup() def initialize_injector(self, group, preset: Preset): self.injector = Injector(group, preset) 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) # this test needs to pass around all other constraints of # _grab_device self.injector.context = Context(preset, {}, {}) 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) path = "/dev/input/event10" self.injector.context = Context(preset, {}, {}) 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.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, {}, {}) 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, {}, {}) # 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, {}, {}) 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()) 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.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 system_mapping.clear() code_a = 100 code_q = 101 code_w = 102 system_mapping._set("a", code_a) system_mapping._set("key_q", code_q) system_mapping._set("w", code_w) 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 system_mapping 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.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 InputEvent.key(8, 0), # releases macro InputEvent.key(9, 0), # forwarded ], ) 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 # 2 events for releasing the combination trigger (by combination handler) # 4 events for the macro # 1 release of the event that didn't release the macro # 2 for mapped keys # 3 for forwarded events self.assertEqual(len(history), 13) # the first bit is ordered properly self.assertEqual(history[0], (EV_KEY, 8, 1)) # forwarded del history[0] self.assertIn((EV_KEY, 8, 0), history[0:2]) # released by combination handler self.assertIn((EV_KEY, 9, 0), history[0:2]) # released by combination handler del history[0] 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. self.assertEqual(history[0], (EV_KEY, 9, 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)) class TestModifyCapabilities(unittest.TestCase): @classmethod def setUpClass(cls): quick_cleanup() def setUp(self): class FakeDevice: def __init__(self): self._capabilities = { evdev.ecodes.EV_SYN: [1, 2, 3], evdev.ecodes.EV_FF: [1, 2, 3], EV_ABS: [ ( 1, evdev.AbsInfo( value=None, min=None, max=1234, fuzz=None, flat=None, resolution=None, ), ), ( 2, evdev.AbsInfo( value=None, min=50, max=2345, fuzz=None, flat=None, resolution=None, ), ), 3, ], } def capabilities(self, absinfo=False): assert absinfo is True return self._capabilities 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 = 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 = system_mapping.get("a") self.shift_l = system_mapping.get("ShIfT_L") self.one = system_mapping.get(1) self.two = system_mapping.get("2") self.left = system_mapping.get("BtN_lEfT") self.fake_device = FakeDevice() self.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 tearDown(self): quick_cleanup() 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.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.0.1/tests/unit/test_input_config.py000066400000000000000000000473001450214210500226760ustar00rootroot00000000000000#!/usr/bin/python3 # -*- 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 . 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 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" 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.0.1/tests/unit/test_input_event.py000066400000000000000000000103451450214210500225510ustar00rootroot00000000000000#!/usr/bin/python3 # -*- 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 . import unittest import evdev from dataclasses import FrozenInstanceError from inputremapper.input_event import InputEvent 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.0.1/tests/unit/test_ipc.py000066400000000000000000000146231450214210500207670ustar00rootroot00000000000000#!/usr/bin/python3 # -*- 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 . import asyncio import multiprocessing from tests.lib.cleanup import quick_cleanup from tests.lib.tmp import tmp import unittest import select import time import os from inputremapper.ipc.pipe import Pipe from inputremapper.ipc.shared_dict import SharedDict from inputremapper.ipc.socket import Server, Client, Base class TestSharedDict(unittest.TestCase): def setUp(self): self.shared_dict = SharedDict() self.shared_dict.start() time.sleep(0.02) def tearDown(self): quick_cleanup() def test_returns_none(self): self.assertIsNone(self.shared_dict.get("a")) self.assertIsNone(self.shared_dict["a"]) def test_set_get(self): self.shared_dict["a"] = 3 self.assertEqual(self.shared_dict.get("a"), 3) self.assertEqual(self.shared_dict["a"], 3) class TestSocket(unittest.TestCase): def test_socket(self): def test(s1, s2): self.assertEqual(s2.recv(), None) s1.send(1) self.assertTrue(s2.poll()) self.assertEqual(s2.recv(), 1) self.assertFalse(s2.poll()) self.assertEqual(s2.recv(), None) s1.send(2) self.assertTrue(s2.poll()) s1.send(3) self.assertTrue(s2.poll()) self.assertEqual(s2.recv(), 2) self.assertTrue(s2.poll()) self.assertEqual(s2.recv(), 3) self.assertFalse(s2.poll()) self.assertEqual(s2.recv(), None) server = Server(os.path.join(tmp, "socket1")) client = Client(os.path.join(tmp, "socket1")) test(server, client) client = Client(os.path.join(tmp, "socket2")) server = Server(os.path.join(tmp, "socket2")) test(client, server) def test_not_connected_1(self): # client discards old message, because it might have had a purpose # for a different client and not for the current one server = Server(os.path.join(tmp, "socket3")) server.send(1) client = Client(os.path.join(tmp, "socket3")) server.send(2) self.assertTrue(client.poll()) self.assertEqual(client.recv(), 2) self.assertFalse(client.poll()) self.assertEqual(client.recv(), None) def test_not_connected_2(self): client = Client(os.path.join(tmp, "socket4")) client.send(1) server = Server(os.path.join(tmp, "socket4")) client.send(2) self.assertTrue(server.poll()) self.assertEqual(server.recv(), 2) self.assertFalse(server.poll()) self.assertEqual(server.recv(), None) def test_select(self): """Is compatible to select.select.""" server = Server(os.path.join(tmp, "socket6")) client = Client(os.path.join(tmp, "socket6")) server.send(1) ready = select.select([client], [], [], 0)[0][0] self.assertEqual(ready, client) client.send(2) ready = select.select([server], [], [], 0)[0][0] self.assertEqual(ready, server) def test_base_abstract(self): self.assertRaises(NotImplementedError, lambda: Base("foo")) self.assertRaises(NotImplementedError, lambda: Base.connect(None)) self.assertRaises(NotImplementedError, lambda: Base.reconnect(None)) self.assertRaises(NotImplementedError, lambda: Base.fileno(None)) class TestPipe(unittest.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.0.1/tests/unit/test_logger.py000066400000000000000000000107121450214210500214660ustar00rootroot00000000000000#!/usr/bin/python3 # -*- 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 . import evdev import os import shutil import unittest import logging from tests.lib.tmp import tmp from inputremapper.logger import logger, update_verbosity, log_info, ColorfulFormatter from inputremapper.configs.paths import remove def add_filehandler(log_path): """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()) logger.addHandler(file_handler) logger.info('Starting logging to "%s"', log_path) class TestLogger(unittest.TestCase): def tearDown(self): update_verbosity(debug=True) # remove the file handler logger.handlers = [ handler for handler in logger.handlers if not isinstance(logger.handlers, logging.FileHandler) ] path = os.path.join(tmp, "logger-test") remove(path) def test_write(self): uinput = evdev.UInput(name="foo") path = os.path.join(tmp, "logger-test") add_filehandler(path) 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): update_verbosity(debug=False) path = os.path.join(tmp, "logger-test") add_filehandler(path) log_info() with open(path, "r") as f: content = f.read().lower() self.assertIn("input-remapper", content) def test_makes_path(self): path = os.path.join(tmp, "logger-test") if os.path.exists(path): shutil.rmtree(path) new_path = os.path.join(tmp, "logger-test", "a", "b", "c") add_filehandler(new_path) self.assertTrue(os.path.exists(new_path)) def test_debug(self): path = os.path.join(tmp, "logger-test") add_filehandler(path) logger.error("abc") logger.warning("foo") logger.info("123") logger.debug("456") logger.debug("789") with open(path, "r") as f: content = f.read().lower() self.assertIn("logger.py", content) self.assertIn("error", content) self.assertIn("abc", content) self.assertIn("warn", content) self.assertIn("foo", content) self.assertIn("info", content) self.assertIn("123", content) self.assertIn("debug", content) self.assertIn("456", content) self.assertIn("debug", content) self.assertIn("789", content) def test_default(self): path = os.path.join(tmp, "logger-test") update_verbosity(debug=False) add_filehandler(path) logger.error("abc") logger.warning("foo") logger.info("123") logger.debug("456") logger.debug("789") with open(path, "r") as f: content = f.read().lower() self.assertNotIn("logger.py", content) self.assertNotIn("line", content) self.assertIn("error", content) self.assertIn("abc", content) self.assertIn("warn", content) self.assertIn("foo", content) self.assertNotIn("info", content) self.assertIn("123", content) self.assertNotIn("debug", content) self.assertNotIn("456", content) self.assertNotIn("debug", content) self.assertNotIn("789", content) if __name__ == "__main__": unittest.main() input-remapper-2.0.1/tests/unit/test_macros.py000066400000000000000000001646021450214210500215030ustar00rootroot00000000000000#!/usr/bin/python3 # -*- 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 . import asyncio import multiprocessing import re import time import unittest from unittest import mock from evdev.ecodes import ( EV_REL, EV_ABS, EV_KEY, ABS_Y, REL_Y, REL_HWHEEL, REL_HWHEEL_HI_RES, KEY_A, KEY_B, KEY_C, KEY_E, ) from inputremapper.configs.preset import Preset from inputremapper.configs.system_mapping import system_mapping from inputremapper.configs.validation_errors import ( MacroParsingError, SymbolNotAvailableInTargetError, ) from inputremapper.injection.context import Context from inputremapper.injection.macros.macro import ( Macro, _type_check, macro_variables, _type_check_variablename, _resolve, Variable, ) from inputremapper.injection.macros.parse import ( parse, _extract_args, is_this_a_macro, _parse_recurse, handle_plus_syntax, _count_brackets, _split_keyword_arg, remove_whitespaces, remove_comments, get_macro_argument_names, get_num_parameters, ) from inputremapper.input_event import InputEvent from tests.lib.logger import logger from tests.lib.cleanup import quick_cleanup class MacroTestBase(unittest.IsolatedAsyncioTestCase): def setUp(self): self.result = [] try: self.loop = asyncio.get_event_loop() except RuntimeError: # suddenly "There is no current event loop in thread 'MainThread'" # errors started to appear self.loop = asyncio.new_event_loop() asyncio.set_event_loop(self.loop) self.context = Context(Preset(), source_devices={}, forward_devices={}) def tearDown(self): self.result = [] quick_cleanup() 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) if macro.is_holding(): macro.release_trigger() class DummyMapping: macro_key_sleep_ms = 10 rel_rate = 60 target_uinput = "keyboard + mouse" class TestMacros(MacroTestBase): async def test_named_parameter(self): result = [] def patch(_, a, b, c, d=400): result.append((a, b, c, d)) functions = {"key": patch} with mock.patch( "inputremapper.injection.macros.parse.TASK_FACTORIES", functions ): await parse("key(1, d=4, b=2, c=3)", self.context, DummyMapping).run( self.handler ) await parse("key(1, b=2, c=3)", self.context, DummyMapping).run( self.handler ) self.assertListEqual(result, [(1, 2, 3, 4), (1, 2, 3, 400)]) def test_get_macro_argument_names(self): self.assertEqual( get_macro_argument_names(Macro.add_if_tap), ["then", "else", "timeout"], ) self.assertEqual( get_macro_argument_names(Macro.add_hold_keys), ["*symbols"], ) def test_get_num_parameters(self): self.assertEqual(get_num_parameters(Macro.add_if_tap), (0, 3)) self.assertEqual(get_num_parameters(Macro.add_key), (1, 1)) self.assertEqual(get_num_parameters(Macro.add_hold_keys), (0, float("inf"))) def test_remove_whitespaces(self): self.assertEqual(remove_whitespaces('foo"bar"foo'), 'foo"bar"foo') self.assertEqual(remove_whitespaces('foo" bar"foo'), 'foo" bar"foo') self.assertEqual(remove_whitespaces('foo" bar"fo" "o'), 'foo" bar"fo" "o') self.assertEqual(remove_whitespaces(' fo o"\nba r "f\noo'), 'foo"\nba r "foo') self.assertEqual(remove_whitespaces(' a " b " c " '), 'a" b "c" ') self.assertEqual(remove_whitespaces('"""""""""'), '"""""""""') self.assertEqual(remove_whitespaces('""""""""'), '""""""""') self.assertEqual(remove_whitespaces(" "), "") self.assertEqual(remove_whitespaces(' " '), '" ') self.assertEqual(remove_whitespaces(' " " '), '" "') self.assertEqual(remove_whitespaces("a# ##b", delimiter="##"), "a###b") self.assertEqual(remove_whitespaces("a###b", delimiter="##"), "a###b") self.assertEqual(remove_whitespaces("a## #b", delimiter="##"), "a## #b") self.assertEqual(remove_whitespaces("a## ##b", delimiter="##"), "a## ##b") def test_remove_comments(self): self.assertEqual(remove_comments("a#b"), "a") self.assertEqual(remove_comments('"a#b"'), '"a#b"') self.assertEqual(remove_comments('a"#"#b'), 'a"#"') self.assertEqual(remove_comments('a"#""#"#b'), 'a"#""#"') self.assertEqual(remove_comments('#a"#""#"#b'), "") self.assertEqual( re.sub( r"\s", "", remove_comments( """ # a b # c d """ ), ), "bd", ) async def test_count_brackets(self): self.assertEqual(_count_brackets(""), 0) self.assertEqual(_count_brackets("()"), 2) self.assertEqual(_count_brackets("a()"), 3) self.assertEqual(_count_brackets("a(b)"), 4) self.assertEqual(_count_brackets("a(b())"), 6) self.assertEqual(_count_brackets("a(b(c))"), 7) self.assertEqual(_count_brackets("a(b(c))d"), 7) self.assertEqual(_count_brackets("a(b(c))d()"), 7) def test_resolve(self): self.assertEqual(_resolve("a"), "a") self.assertEqual(_resolve(1), 1) self.assertEqual(_resolve(None), None) # $ is part of a custom string here self.assertEqual(_resolve('"$a"'), '"$a"') self.assertEqual(_resolve("'$a'"), "'$a'") # variables are expected to be of the Variable type here, not a $string self.assertEqual(_resolve("$a"), "$a") variable = Variable("a") self.assertEqual(_resolve(variable), None) macro_variables["a"] = 1 self.assertEqual(_resolve(variable), 1) def test_type_check(self): # allows params that can be cast to the target type self.assertEqual(_type_check(1, [str, None], "foo", 0), "1") self.assertEqual(_type_check("1", [int, None], "foo", 1), 1) self.assertEqual(_type_check(1.2, [str], "foo", 2), "1.2") self.assertRaises( MacroParsingError, lambda: _type_check("1.2", [int], "foo", 3), ) self.assertRaises(MacroParsingError, lambda: _type_check("a", [None], "foo", 0)) self.assertRaises(MacroParsingError, lambda: _type_check("a", [int], "foo", 1)) self.assertRaises( MacroParsingError, lambda: _type_check("a", [int, float], "foo", 2), ) self.assertRaises( MacroParsingError, lambda: _type_check("a", [int, None], "foo", 3), ) self.assertEqual(_type_check("a", [int, float, None, str], "foo", 4), "a") # variables are expected to be of the Variable type here, not a $string self.assertRaises(MacroParsingError, lambda: _type_check("$a", [int], "foo", 4)) variable = Variable("a") self.assertEqual(_type_check(variable, [int], "foo", 4), variable) self.assertRaises( MacroParsingError, lambda: _type_check("a", [Macro], "foo", 0), ) self.assertRaises(MacroParsingError, lambda: _type_check(1, [Macro], "foo", 0)) self.assertEqual(_type_check("1", [Macro, int], "foo", 4), 1) def test_type_check_variablename(self): self.assertRaises(MacroParsingError, lambda: _type_check_variablename("1a")) self.assertRaises(MacroParsingError, lambda: _type_check_variablename("$a")) self.assertRaises(MacroParsingError, lambda: _type_check_variablename("a()")) self.assertRaises(MacroParsingError, lambda: _type_check_variablename("1")) self.assertRaises(MacroParsingError, lambda: _type_check_variablename("+")) self.assertRaises(MacroParsingError, lambda: _type_check_variablename("-")) self.assertRaises(MacroParsingError, lambda: _type_check_variablename("*")) self.assertRaises(MacroParsingError, lambda: _type_check_variablename("a,b")) self.assertRaises(MacroParsingError, lambda: _type_check_variablename("a,b")) self.assertRaises(MacroParsingError, lambda: _type_check_variablename("#")) self.assertRaises(MacroParsingError, lambda: _type_check_variablename(1)) self.assertRaises(MacroParsingError, lambda: _type_check_variablename(None)) self.assertRaises(MacroParsingError, lambda: _type_check_variablename([])) self.assertRaises(MacroParsingError, lambda: _type_check_variablename(())) # doesn't raise _type_check_variablename("a") _type_check_variablename("_a") _type_check_variablename("_A") _type_check_variablename("A") _type_check_variablename("Abcd") _type_check_variablename("Abcd_") _type_check_variablename("Abcd_1234") _type_check_variablename("Abcd1234_") def test_split_keyword_arg(self): self.assertTupleEqual(_split_keyword_arg("_A=b"), ("_A", "b")) self.assertTupleEqual(_split_keyword_arg("a_=1"), ("a_", "1")) self.assertTupleEqual( _split_keyword_arg("a=repeat(2, KEY_A)"), ("a", "repeat(2, KEY_A)"), ) self.assertTupleEqual(_split_keyword_arg('a="=,#+."'), ("a", '"=,#+."')) def test_is_this_a_macro(self): self.assertTrue(is_this_a_macro("key(1)")) self.assertTrue(is_this_a_macro("key(1).key(2)")) self.assertTrue(is_this_a_macro("repeat(1, key(1).key(2))")) self.assertFalse(is_this_a_macro("1")) self.assertFalse(is_this_a_macro("key_kp1")) self.assertFalse(is_this_a_macro("btn_left")) self.assertFalse(is_this_a_macro("minus")) self.assertFalse(is_this_a_macro("k")) self.assertFalse(is_this_a_macro(1)) self.assertFalse(is_this_a_macro(None)) self.assertTrue(is_this_a_macro("a+b")) self.assertTrue(is_this_a_macro("a+b+c")) self.assertTrue(is_this_a_macro("a + b")) self.assertTrue(is_this_a_macro("a + b + c")) def test_handle_plus_syntax(self): self.assertEqual(handle_plus_syntax("a + b"), "hold_keys(a,b)") self.assertEqual(handle_plus_syntax("a + b + c"), "hold_keys(a,b,c)") self.assertEqual(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(MacroParsingError): logger.info(f'testing "%s"', string) handle_plus_syntax(string) self.assertEqual(handle_plus_syntax("a"), "a") self.assertEqual(handle_plus_syntax("key(a)"), "key(a)") self.assertEqual(handle_plus_syntax(""), "") def test_parse_plus_syntax(self): macro = parse("a + b") self.assertEqual(macro.code, "hold_keys(a,b)") # this is not erroneously recognized as "plus" syntax macro = parse("key(a) # a + b") self.assertEqual(macro.code, "key(a)") async def test_run_plus_syntax(self): macro = 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.is_holding()) # starting from the left, presses each one down self.assertEqual(self.result[0], (EV_KEY, system_mapping.get("a"), 1)) self.assertEqual(self.result[1], (EV_KEY, system_mapping.get("b"), 1)) self.assertEqual(self.result[2], (EV_KEY, system_mapping.get("c"), 1)) self.assertEqual(self.result[3], (EV_KEY, system_mapping.get("d"), 1)) # and then releases starting with the previously pressed key macro.release_trigger() await asyncio.sleep(0.2) self.assertFalse(macro.is_holding()) self.assertEqual(self.result[4], (EV_KEY, system_mapping.get("d"), 0)) self.assertEqual(self.result[5], (EV_KEY, system_mapping.get("c"), 0)) self.assertEqual(self.result[6], (EV_KEY, system_mapping.get("b"), 0)) self.assertEqual(self.result[7], (EV_KEY, system_mapping.get("a"), 0)) async def test_extract_params(self): # splits strings, doesn't try to understand their meaning yet def expect(raw, expectation): self.assertListEqual(_extract_args(raw), expectation) expect("a", ["a"]) expect("a,b", ["a", "b"]) expect("a,b,c", ["a", "b", "c"]) expect("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): self.assertEqual(_parse_recurse("", self.context, DummyMapping, True), None) # strings. If it is wrapped in quotes, don't parse the contents self.assertEqual( _parse_recurse('"foo"', self.context, DummyMapping, True), "foo" ) self.assertEqual( _parse_recurse('"\tf o o\n"', self.context, DummyMapping, True), "\tf o o\n", ) self.assertEqual( _parse_recurse('"foo(a,b)"', self.context, DummyMapping, True), "foo(a,b)", ) self.assertEqual( _parse_recurse('",,,()"', self.context, DummyMapping, 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(_parse_recurse("foo", self.context, DummyMapping, True), "foo") self.assertEqual(_parse_recurse("", self.context, DummyMapping, True), None) self.assertEqual(_parse_recurse("None", self.context, DummyMapping, True), None) self.assertEqual(_parse_recurse("5", self.context, DummyMapping, True), 5) self.assertEqual(_parse_recurse("5.2", self.context, DummyMapping, True), 5.2) self.assertIsInstance( _parse_recurse("$foo", self.context, DummyMapping, True), Variable, ) self.assertEqual( _parse_recurse("$foo", self.context, DummyMapping, True).name, "foo" ) async def test_0(self): macro = parse("key(1)", self.context, DummyMapping, True) one_code = system_mapping.get("1") await macro.run(self.handler) self.assertListEqual( self.result, [(EV_KEY, one_code, 1), (EV_KEY, one_code, 0)], ) self.assertEqual(len(macro.child_macros), 0) async def test_1(self): macro = parse('key(1).key("KEY_A").key(3)', self.context, DummyMapping) await macro.run(self.handler) self.assertListEqual( self.result, [ (EV_KEY, system_mapping.get("1"), 1), (EV_KEY, system_mapping.get("1"), 0), (EV_KEY, system_mapping.get("a"), 1), (EV_KEY, system_mapping.get("a"), 0), (EV_KEY, system_mapping.get("3"), 1), (EV_KEY, system_mapping.get("3"), 0), ], ) self.assertEqual(len(macro.child_macros), 0) async def test_raises_error(self): # passing a string parameter. This is not a macro, even though # it might look like it without the string quotes. self.assertRaises(MacroParsingError, parse, '"modify(a, b)"', self.context) parse("k(1).h(k(a)).k(3)", self.context) # No error with self.assertRaises(MacroParsingError) as cm: parse("k(1))", self.context) error = str(cm.exception) self.assertIn("bracket", error) with self.assertRaises(MacroParsingError) as cm: parse("key((1)", self.context) error = str(cm.exception) self.assertIn("bracket", error) self.assertRaises(MacroParsingError, parse, "k((1).k)", self.context) self.assertRaises(MacroParsingError, parse, "k()", self.context) parse("key(1)", self.context) # no error self.assertRaises(MacroParsingError, parse, "k(1, 1)", self.context) parse("key($a)", self.context) # no error self.assertRaises(MacroParsingError, parse, "h(1, 1)", self.context) self.assertRaises(MacroParsingError, parse, "h(hold(h(1, 1)))", self.context) self.assertRaises(MacroParsingError, parse, "r(1)", self.context) self.assertRaises(MacroParsingError, parse, "repeat(a, k(1))", self.context) parse("repeat($a, k(1))", self.context) # no error self.assertRaises(MacroParsingError, parse, "r(1, 1)", self.context) self.assertRaises(MacroParsingError, parse, "r(k(1), 1)", self.context) parse("r(1, macro=k(1))", self.context) # no error self.assertRaises(MacroParsingError, parse, "r(a=1, b=k(1))", self.context) self.assertRaises( MacroParsingError, parse, "r(repeats=1, macro=k(1), a=2)", self.context, ) self.assertRaises( MacroParsingError, parse, "r(repeats=1, macro=k(1), repeats=2)", self.context, ) self.assertRaises(MacroParsingError, parse, "modify(asdf, k(a))", self.context) parse("if_tap(, k(a), 1000)", self.context) # no error parse("if_tap(, k(a), timeout=1000)", self.context) # no error parse("if_tap(, k(a), $timeout)", self.context) # no error parse("if_tap(, k(a), timeout=$t)", self.context) # no error parse("if_tap(, key(a))", self.context) # no error parse("if_tap(k(a),)", self.context) # no error self.assertRaises(MacroParsingError, parse, "if_tap(k(a), b)", self.context) parse("if_single(k(a),)", self.context) # no error self.assertRaises(MacroParsingError, parse, "if_single(1,)", self.context) self.assertRaises(MacroParsingError, parse, "if_single(,1)", self.context) parse("mouse(up, 3)", self.context) # no error parse("mouse(up, speed=$a)", self.context) # no error self.assertRaises(MacroParsingError, parse, "mouse(3, up)", self.context) parse("wheel(left, 3)", self.context) # no error self.assertRaises(MacroParsingError, parse, "wheel(3, left)", self.context) parse("w(2)", self.context) # no error self.assertRaises(MacroParsingError, parse, "wait(a)", self.context) parse("ifeq(a, 2, k(a),)", self.context) # no error parse("ifeq(a, 2, , k(a))", self.context) # no error parse("ifeq(a, 2, None, k(a))", self.context) # no error self.assertRaises(MacroParsingError, parse, "ifeq(a, 2, 1,)", self.context) self.assertRaises(MacroParsingError, parse, "ifeq(a, 2, , 2)", self.context) parse("if_eq(2, $a, k(a),)", self.context) # no error parse("if_eq(2, $a, , else=k(a))", self.context) # no error self.assertRaises(MacroParsingError, parse, "if_eq(2, $a, 1,)", self.context) self.assertRaises(MacroParsingError, parse, "if_eq(2, $a, , 2)", self.context) with self.assertRaises(MacroParsingError) as cm: parse("foo(a)", self.context) error = str(cm.exception) self.assertIn("unknown", error.lower()) self.assertIn("foo", error) self.assertRaises(MacroParsingError, parse, "set($a, 1)", self.context) self.assertRaises(MacroParsingError, parse, "set(1, 2)", self.context) self.assertRaises(MacroParsingError, parse, "set(+, 2)", self.context) self.assertRaises(MacroParsingError, parse, "set(a(), 2)", self.context) self.assertRaises(MacroParsingError, parse, "set('b,c', 2)", self.context) self.assertRaises(MacroParsingError, parse, 'set("b,c", 2)', self.context) parse("set(A, 2)", self.context) # no error self.assertRaises(MacroParsingError, parse, "key(a)key(b)", self.context) self.assertRaises(MacroParsingError, parse, "hold(key(a)key(b))", self.context) parse("add(a, 1)", self.context) # no error self.assertRaises(MacroParsingError, parse, "add(a, b)", self.context) # wrong target for BTN_A self.assertRaises( SymbolNotAvailableInTargetError, parse, "key(BTN_A)", self.context, DummyMapping, ) async def test_key(self): code_a = system_mapping.get("a") code_b = system_mapping.get("b") macro = parse("set(foo, b).key($foo).key(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, 1), (EV_KEY, code_a, 0), ], ) async def test_key_down_up(self): code_a = system_mapping.get("a") code_b = system_mapping.get("b") macro = 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_modify(self): code_a = system_mapping.get("a") code_b = system_mapping.get("b") code_c = system_mapping.get("c") macro = 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_hold_variable(self): code_a = system_mapping.get("a") macro = 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_hold_keys(self): macro = 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 = system_mapping.get("a") code_b = system_mapping.get("b") code_c = system_mapping.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(self): # repeats key(a) as long as the key is held down macro = parse("key(1).hold(key(a)).key(3)", self.context, DummyMapping) """down""" macro.press_trigger() await asyncio.sleep(0.05) self.assertTrue(macro.is_holding()) macro.press_trigger() # redundantly calling doesn't break anything asyncio.ensure_future(macro.run(self.handler)) await asyncio.sleep(0.2) self.assertTrue(macro.is_holding()) self.assertGreater(len(self.result), 2) """up""" macro.release_trigger() await asyncio.sleep(0.05) self.assertFalse(macro.is_holding()) self.assertEqual(self.result[0], (EV_KEY, system_mapping.get("1"), 1)) self.assertEqual(self.result[-1], (EV_KEY, system_mapping.get("3"), 0)) code_a = system_mapping.get("a") self.assertGreater(self.result.count((EV_KEY, code_a, 1)), 2) self.assertEqual(len(macro.child_macros), 1) async def test_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 = 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 = 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.is_holding()) # press_trigger was never called, so the macro completes right away # and the child macro of hold is never called. self.assertEqual(len(self.result), 4) self.assertEqual(self.result[0], (EV_KEY, system_mapping.get("1"), 1)) self.assertEqual(self.result[-1], (EV_KEY, system_mapping.get("3"), 0)) self.assertEqual(len(macro.child_macros), 1) async def test_just_hold(self): macro = parse("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.is_holding()) self.assertEqual(len(self.result), 2) await asyncio.sleep(0.1) # doesn't do fancy stuff, is blocking until the release self.assertEqual(len(self.result), 2) """up""" macro.release_trigger() await asyncio.sleep(0.05) self.assertFalse(macro.is_holding()) self.assertEqual(len(self.result), 4) self.assertEqual(self.result[0], (EV_KEY, system_mapping.get("1"), 1)) self.assertEqual(self.result[-1], (EV_KEY, system_mapping.get("3"), 0)) self.assertEqual(len(macro.child_macros), 0) async def test_dont_just_hold(self): macro = parse("key(1).hold().key(3)", self.context, DummyMapping) asyncio.ensure_future(macro.run(self.handler)) await asyncio.sleep(0.1) self.assertFalse(macro.is_holding()) # since press_trigger was never called it just does the macro # completely self.assertEqual(len(self.result), 4) self.assertEqual(self.result[0], (EV_KEY, system_mapping.get("1"), 1)) self.assertEqual(self.result[-1], (EV_KEY, system_mapping.get("3"), 0)) self.assertEqual(len(macro.child_macros), 0) async def test_hold_down(self): # writes down and waits for the up event until the key is released macro = parse("hold(a)", self.context, DummyMapping) self.assertEqual(len(macro.child_macros), 0) """down""" macro.press_trigger() await asyncio.sleep(0.05) self.assertTrue(macro.is_holding()) asyncio.ensure_future(macro.run(self.handler)) macro.press_trigger() # redundantly calling doesn't break anything await asyncio.sleep(0.2) self.assertTrue(macro.is_holding()) self.assertEqual(len(self.result), 1) self.assertEqual(self.result[0], (EV_KEY, system_mapping.get("a"), 1)) """up""" macro.release_trigger() await asyncio.sleep(0.05) self.assertFalse(macro.is_holding()) self.assertEqual(len(self.result), 2) self.assertEqual(self.result[0], (EV_KEY, system_mapping.get("a"), 1)) self.assertEqual(self.result[1], (EV_KEY, system_mapping.get("a"), 0)) async def test_2(self): start = time.time() repeats = 20 macro = parse( f"repeat({repeats}, key(k)).repeat(1, key(k))", self.context, DummyMapping, ) k_code = system_mapping.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(len(macro.child_macros), 2) self.assertEqual(len(macro.child_macros[0].child_macros), 0) async def test_3(self): start = time.time() macro = parse("repeat(3, key(m).w(100))", self.context, DummyMapping) m_code = system_mapping.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(len(macro.child_macros), 1) self.assertEqual(len(macro.child_macros[0].child_macros), 0) async def test_4(self): macro = parse( " repeat(2,\nkey(\nr ).key(minus\n )).key(m) ", self.context, DummyMapping, ) r = system_mapping.get("r") minus = system_mapping.get("minus") m = system_mapping.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(len(macro.child_macros), 1) self.assertEqual(len(macro.child_macros[0].child_macros), 0) async def test_5(self): start = time.time() macro = parse( "w(200).repeat(2,modify(w,\nrepeat(2,\tkey(BtN_LeFt))).w(10).key(k))", self.context, DummyMapping, ) self.assertEqual(len(macro.child_macros), 1) self.assertEqual(len(macro.child_macros[0].child_macros), 1) w = system_mapping.get("w") left = system_mapping.get("bTn_lEfT") k = system_mapping.get("k") 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_6(self): # does nothing without .run macro = 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 = system_mapping.get("a") b = system_mapping.get("b") c = system_mapping.get("c") macro = parse("key(a).modify(b, hold()).key(c)", self.context, DummyMapping) asyncio.ensure_future(macro.run(self.handler)) self.assertFalse(macro.is_holding()) asyncio.ensure_future(macro.run(self.handler)) # ignored self.assertFalse(macro.is_holding()) macro.press_trigger() await asyncio.sleep(0.2) self.assertTrue(macro.is_holding()) asyncio.ensure_future(macro.run(self.handler)) # ignored self.assertTrue(macro.is_holding()) macro.release_trigger() await asyncio.sleep(0.2) self.assertFalse(macro.is_holding()) expected = [ (EV_KEY, a, 1), (EV_KEY, a, 0), (EV_KEY, b, 1), (EV_KEY, b, 0), (EV_KEY, c, 1), (EV_KEY, c, 0), ] self.assertListEqual(self.result, expected) """not ignored, since previous run is over""" asyncio.ensure_future(macro.run(self.handler)) macro.press_trigger() await asyncio.sleep(0.2) self.assertTrue(macro.is_holding()) macro.release_trigger() await asyncio.sleep(0.2) self.assertFalse(macro.is_holding()) expected = [ (EV_KEY, a, 1), (EV_KEY, a, 0), (EV_KEY, b, 1), (EV_KEY, b, 0), (EV_KEY, c, 1), (EV_KEY, c, 0), ] * 2 self.assertListEqual(self.result, expected) async def test_mouse(self): wheel_speed = 60 macro_1 = parse("mouse(up, 4)", self.context, DummyMapping) macro_2 = parse(f"wheel(left, {wheel_speed})", self.context, DummyMapping) macro_1.press_trigger() macro_2.press_trigger() asyncio.ensure_future(macro_1.run(self.handler)) asyncio.ensure_future(macro_2.run(self.handler)) sleep = 0.1 await asyncio.sleep(sleep) self.assertTrue(macro_1.is_holding()) self.assertTrue(macro_2.is_holding()) macro_1.release_trigger() macro_2.release_trigger() self.assertIn((EV_REL, REL_Y, -4), self.result) expected_wheel_hi_res_event_count = sleep * DummyMapping.rel_rate expected_wheel_event_count = int( expected_wheel_hi_res_event_count / 120 * wheel_speed ) actual_wheel_event_count = self.result.count((EV_REL, REL_HWHEEL, 1)) actual_wheel_hi_res_event_count = self.result.count( ( EV_REL, REL_HWHEEL_HI_RES, wheel_speed, ) ) # this seems to have a tendency of injecting less wheel events, # especially if the sleep is short self.assertGreater(actual_wheel_event_count, expected_wheel_event_count * 0.8) self.assertLess(actual_wheel_event_count, expected_wheel_event_count * 1.1) self.assertGreater( actual_wheel_hi_res_event_count, expected_wheel_hi_res_event_count * 0.8 ) self.assertLess( actual_wheel_hi_res_event_count, expected_wheel_hi_res_event_count * 1.1 ) async def test_event_1(self): macro = parse("e(EV_KEY, KEY_A, 1)", self.context, DummyMapping) a_code = system_mapping.get("a") await macro.run(self.handler) self.assertListEqual(self.result, [(EV_KEY, a_code, 1)]) self.assertEqual(len(macro.child_macros), 0) async def test_event_2(self): macro = parse( "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(len(macro.child_macros), 1) async def test_macro_breaks(self): # the first parameter for `repeat` requires an integer, not "foo", # which makes `repeat` throw macro = parse( 'set(a, "foo").repeat($a, key(KEY_A)).key(KEY_B)', self.context, DummyMapping, ) try: await macro.run(self.handler) except MacroParsingError 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_set(self): await parse('set(a, "foo")', self.context, DummyMapping).run(self.handler) self.assertEqual(macro_variables.get("a"), "foo") await parse('set( \t"b" \n, "1")', self.context, DummyMapping).run(self.handler) self.assertEqual(macro_variables.get("b"), "1") await parse("set(a, 1)", self.context, DummyMapping).run(self.handler) self.assertEqual(macro_variables.get("a"), 1) await parse("set(a, )", self.context, DummyMapping).run(self.handler) self.assertEqual(macro_variables.get("a"), None) async def test_add(self): await parse("set(a, 1).add(a, 1)", self.context, DummyMapping).run(self.handler) self.assertEqual(macro_variables.get("a"), 2) await parse("set(b, 1).add(b, -1)", self.context, DummyMapping).run( self.handler ) self.assertEqual(macro_variables.get("b"), 0) await parse("set(c, -1).add(c, 500)", self.context, DummyMapping).run( self.handler ) self.assertEqual(macro_variables.get("c"), 499) await parse("add(d, 500)", self.context, DummyMapping).run(self.handler) self.assertEqual(macro_variables.get("d"), 500) # for invalid input it should do nothing (except to log to the console) await parse('set(e, "foo").add(e, 1)', self.context, DummyMapping).run( self.handler ) self.assertEqual(macro_variables.get("e"), "foo") await parse('set(e, "2").add(e, 3)', self.context, DummyMapping).run( self.handler ) self.assertEqual(macro_variables.get("e"), "2") await parse('set(e, 2).add(e, "3")', self.context, DummyMapping).run( self.handler ) self.assertEqual(macro_variables.get("e"), 2) await parse('add(f, "3")', self.context, DummyMapping).run(self.handler) self.assertEqual(macro_variables.get("f"), 0) 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 = 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), ], ) class TestIfEq(MacroTestBase): async def test_ifeq_runs(self): # deprecated ifeq function, but kept for compatibility reasons macro = parse( "set(foo, 2).ifeq(foo, 2, key(a), key(b))", self.context, DummyMapping, ) code_a = system_mapping.get("a") code_b = system_mapping.get("b") await macro.run(self.handler) self.assertListEqual(self.result, [(EV_KEY, code_a, 1), (EV_KEY, code_a, 0)]) self.assertEqual(len(macro.child_macros), 2) async def test_ifeq_none(self): code_a = system_mapping.get("a") # first param None macro = parse( "set(foo, 2).ifeq(foo, 2, None, key(b))", self.context, DummyMapping ) self.assertEqual(len(macro.child_macros), 1) await macro.run(self.handler) self.assertListEqual(self.result, []) # second param None self.result = [] macro = parse( "set(foo, 2).ifeq(foo, 2, key(a), None)", self.context, DummyMapping ) self.assertEqual(len(macro.child_macros), 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 = parse("set(foo, 2).ifeq(foo, 2, , key(b))", self.context, DummyMapping) self.assertEqual(len(macro.child_macros), 1) await macro.run(self.handler) self.assertListEqual(self.result, []) # second param "" self.result = [] macro = parse("set(foo, 2).ifeq(foo, 2, key(a), )", self.context, DummyMapping) self.assertEqual(len(macro.child_macros), 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 = parse("ifeq(qux, 2, key(a), key(b))", self.context, DummyMapping) code_a = system_mapping.get("a") code_b = system_mapping.get("b") await macro.run(self.handler) self.assertListEqual(self.result, [(EV_KEY, code_b, 1), (EV_KEY, code_b, 0)]) self.assertEqual(len(macro.child_macros), 2) async def test_if_eq(self): """new version of ifeq""" code_a = system_mapping.get("a") code_b = system_mapping.get("b") a_press = [(EV_KEY, code_a, 1), (EV_KEY, code_a, 0)] b_press = [(EV_KEY, code_b, 1), (EV_KEY, code_b, 0)] async def test(macro, expected): """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 = 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 = parse("if_eq($foo, 3, key(a), key(b))", self.context, DummyMapping) code_a = system_mapping.get("a") code_b = system_mapping.get("b") self.assertEqual(len(macro.child_macros), 2) def set_foo(value): # will write foo = 2 into the shared dictionary of macros macro_2 = parse(f"set(foo, {value})", self.context, 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), ], ) class TestIfSingle(MacroTestBase): async def test_if_single(self): macro = parse("if_single(key(x), key(y))", self.context, DummyMapping) self.assertEqual(len(macro.child_macros), 2) a = system_mapping.get("a") x = system_mapping.get("x") y = system_mapping.get("y") 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 = parse( "if_single(key(x), else=key(y), timeout=100000)", self.context, DummyMapping, ) self.assertEqual(len(macro.child_macros), 2) a = system_mapping.get("a") b = system_mapping.get("b") x = system_mapping.get("x") y = system_mapping.get("y") # 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 = parse( "repeat(1, if_single(then=key(x), else=key(y)))", self.context, DummyMapping, ) self.assertEqual(len(macro.child_macros), 1) self.assertEqual(len(macro.child_macros[0].child_macros), 2) a = system_mapping.get("a") b = system_mapping.get("b") x = system_mapping.get("x") y = system_mapping.get("y") # 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 = parse("if_single(key(x),)", self.context, DummyMapping) self.assertEqual(len(macro.child_macros), 1) a = system_mapping.get("a") b = system_mapping.get("b") x = system_mapping.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 = parse( "set(t, 300).if_single(key(x), key(y), timeout=$t)", self.context, DummyMapping, ) self.assertEqual(len(macro.child_macros), 2) a = system_mapping.get("a") y = system_mapping.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 = parse("if_single(k(a), k(KEY_LEFTSHIFT))", self.context, DummyMapping) code_shift = system_mapping.get("KEY_LEFTSHIFT") code_a = system_mapping.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)]) class TestIfTap(MacroTestBase): async def test_if_tap(self): macro = parse("if_tap(key(x), key(y), 100)", self.context, DummyMapping) self.assertEqual(len(macro.child_macros), 2) x = system_mapping.get("x") y = system_mapping.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 = 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 = parse( "if_tap(if_tap(key(a), key(b), 100), key(c), 100)", self.context, DummyMapping, ) self.assertEqual(len(macro.child_macros), 2) self.assertEqual(len(macro.child_macros[0].child_macros), 2) asyncio.ensure_future(macro.run(self.handler)) # first tap macro.press_trigger() await asyncio.sleep(0.05) macro.release_trigger() # second tap await asyncio.sleep(0.04) macro.press_trigger() await asyncio.sleep(0.04) macro.release_trigger() await asyncio.sleep(0.05) self.assertListEqual(self.result, [(EV_KEY, KEY_A, 1), (EV_KEY, KEY_A, 0)]) self.assertFalse(macro.running) self.result.clear() """If the second tap takes too long, runs else there""" asyncio.ensure_future(macro.run(self.handler)) # first tap macro.press_trigger() await asyncio.sleep(0.05) macro.release_trigger() # second tap await asyncio.sleep(0.06) macro.press_trigger() await asyncio.sleep(0.06) macro.release_trigger() await asyncio.sleep(0.05) self.assertListEqual(self.result, [(EV_KEY, KEY_B, 1), (EV_KEY, KEY_B, 0)]) self.assertFalse(macro.running) self.result.clear() async def test_if_tap_none(self): # first param none macro = parse("if_tap(, key(y), 100)", self.context, DummyMapping) self.assertEqual(len(macro.child_macros), 1) y = system_mapping.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 = parse("if_tap(key(y), , 50)", self.context, DummyMapping) self.assertEqual(len(macro.child_macros), 1) y = system_mapping.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 = parse("if_tap(key(x), key(y), 50)", self.context, DummyMapping) self.assertEqual(len(macro.child_macros), 2) x = system_mapping.get("x") y = system_mapping.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 = parse("if_tap(key(x), key(y), timeout=50)", self.context, DummyMapping) self.assertEqual(len(macro.child_macros), 2) x = system_mapping.get("x") y = system_mapping.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) if __name__ == "__main__": unittest.main() input-remapper-2.0.1/tests/unit/test_mapping.py000066400000000000000000000371241450214210500216500ustar00rootroot00000000000000#!/usr/bin/python3 # -*- 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 . 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, ) try: from pydantic.v1 import ValidationError except ImportError: from pydantic import ValidationError from inputremapper.configs.mapping import Mapping, UIMapping from inputremapper.configs.system_mapping import system_mapping, DISABLE_NAME from inputremapper.configs.input_config import InputCombination, InputConfig from inputremapper.gui.messages.message_broker import MessageType 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 = system_mapping.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 = system_mapping.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"] = 1 test(**cfg) cfg["output_type"] = 1 Mapping(**cfg) # matching type, code and symbol a = system_mapping.get("a") cfg["output_code"] = a cfg["output_symbol"] = "a" cfg["output_type"] = EV_KEY Mapping(**cfg) # macro + type and code 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_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)", ) 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.0.1/tests/unit/test_message_broker.py000066400000000000000000000074541450214210500232100ustar00rootroot00000000000000#!/usr/bin/python3 # -*- 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 . import unittest from dataclasses import dataclass from inputremapper.gui.messages.message_broker import MessageBroker, MessageType, Signal class Listener: def __init__(self): self.calls = [] def __call__(self, data): self.calls.append(data) @dataclass class Message: message_type: MessageType msg: str 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:]) 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.0.1/tests/unit/test_migrations.py000066400000000000000000000610601450214210500223650ustar00rootroot00000000000000# # input-remapper is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # input-remapper is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General 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.cleanup import quick_cleanup from tests.lib.tmp import tmp import os import unittest import shutil import json import pkg_resources 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, KEY_A, ) from inputremapper.configs.mapping import UIMapping from inputremapper.configs.migrations import migrate, config_version from inputremapper.configs.preset import Preset from inputremapper.configs.global_config import global_config from inputremapper.configs.paths import ( touch, CONFIG_PATH, mkdir, get_preset_path, get_config_path, remove, ) from inputremapper.configs.input_config import InputCombination, InputConfig from inputremapper.user import HOME from inputremapper.logger import VERSION class TestMigrations(unittest.TestCase): def setUp(self): # some extra care to ensure those tests are not destroying actual presets self.assertTrue(HOME.startswith("/tmp")) self.assertTrue(CONFIG_PATH.startswith("/tmp")) self.assertTrue(get_preset_path().startswith("/tmp")) self.assertTrue(get_preset_path("foo", "bar").startswith("/tmp")) self.assertTrue(get_config_path().startswith("/tmp")) self.assertTrue(get_config_path("foo").startswith("/tmp")) self.v1_dir = os.path.join(HOME, ".config", "input-remapper") self.beta_dir = os.path.join( HOME, ".config", "input-remapper", "beta_1.6.0-beta" ) def tearDown(self): quick_cleanup() self.assertEqual(len(global_config.iterate_autoload_presets()), 0) def test_migrate_suffix(self): old = os.path.join(CONFIG_PATH, "config") new = os.path.join(CONFIG_PATH, "config.json") try: os.remove(new) except FileNotFoundError: pass touch(old) with open(old, "w") as f: f.write("{}") migrate() self.assertTrue(os.path.exists(new)) self.assertFalse(os.path.exists(old)) def test_rename_config(self): old = os.path.join(HOME, ".config", "key-mapper") new = CONFIG_PATH # we are not destroying our actual config files with this test self.assertTrue(new.startswith(tmp), f'Expected "{new}" to start with "{tmp}"') try: shutil.rmtree(new) except FileNotFoundError: pass old_config_json = os.path.join(old, "config.json") touch(old_config_json) with open(old_config_json, "w") as f: f.write('{"foo":"bar"}') migrate() self.assertTrue(os.path.exists(new)) self.assertFalse(os.path.exists(old)) new_config_json = os.path.join(new, "config.json") with open(new_config_json, "r") as f: moved_config = json.loads(f.read()) self.assertEqual(moved_config["foo"], "bar") def test_wont_migrate_suffix(self): old = os.path.join(CONFIG_PATH, "config") new = os.path.join(CONFIG_PATH, "config.json") touch(new) with open(new, "w") as f: f.write("{}") touch(old) with open(old, "w") as f: f.write("{}") migrate() self.assertTrue(os.path.exists(new)) self.assertTrue(os.path.exists(old)) def test_migrate_preset(self): if os.path.exists(CONFIG_PATH): shutil.rmtree(CONFIG_PATH) p1 = os.path.join(CONFIG_PATH, "foo1", "bar1.json") p2 = os.path.join(CONFIG_PATH, "foo2", "bar2.json") touch(p1) touch(p2) with open(p1, "w") as f: f.write("{}") with open(p2, "w") as f: f.write("{}") migrate() self.assertFalse(os.path.exists(os.path.join(CONFIG_PATH, "foo1", "bar1.json"))) self.assertFalse(os.path.exists(os.path.join(CONFIG_PATH, "foo2", "bar2.json"))) self.assertTrue( os.path.exists(os.path.join(CONFIG_PATH, "presets", "foo1", "bar1.json")), ) self.assertTrue( os.path.exists(os.path.join(CONFIG_PATH, "presets", "foo2", "bar2.json")), ) def test_wont_migrate_preset(self): if os.path.exists(CONFIG_PATH): shutil.rmtree(CONFIG_PATH) p1 = os.path.join(CONFIG_PATH, "foo1", "bar1.json") p2 = os.path.join(CONFIG_PATH, "foo2", "bar2.json") touch(p1) touch(p2) with open(p1, "w") as f: f.write("{}") with open(p2, "w") as f: f.write("{}") # already migrated mkdir(os.path.join(CONFIG_PATH, "presets")) migrate() self.assertTrue(os.path.exists(os.path.join(CONFIG_PATH, "foo1", "bar1.json"))) self.assertTrue(os.path.exists(os.path.join(CONFIG_PATH, "foo2", "bar2.json"))) self.assertFalse( os.path.exists(os.path.join(CONFIG_PATH, "presets", "foo1", "bar1.json")), ) self.assertFalse( os.path.exists(os.path.join(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(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, ) migrate() # use UIMapping to also load invalid mappings preset = Preset(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(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, ) migrate() preset = Preset(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(CONFIG_PATH, "config.json") os.makedirs(os.path.dirname(path), exist_ok=True) with open(path, "w") as file: file.write("{}") migrate() self.assertEqual(pkg_resources.parse_version(VERSION), config_version()) def test_update_version(self): path = os.path.join(CONFIG_PATH, "config.json") os.makedirs(os.path.dirname(path), exist_ok=True) with open(path, "w") as file: json.dump({"version": "0.1.0"}, file) migrate() self.assertEqual(pkg_resources.parse_version(VERSION), config_version()) def test_config_version(self): path = os.path.join(CONFIG_PATH, "config.json") with open(path, "w") as file: file.write("{}") self.assertEqual("0.0.0", config_version().public) try: os.remove(path) except FileNotFoundError: pass self.assertEqual("0.0.0", config_version().public) def test_migrate_left_and_right_purpose(self): path = os.path.join(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, ) migrate() preset = Preset(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(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, ) migrate() preset = Preset(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" 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 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 remove(get_config_path()) device_name = "device_name" self._create_v1_setup() self._create_beta_setup() self.assertFalse(os.path.exists(get_preset_path(device_name, "foo"))) self.assertFalse(os.path.exists(get_config_path("config.json"))) migrate() self.assertTrue(os.path.exists(get_preset_path(device_name, "foo"))) self.assertTrue(os.path.exists(get_config_path("config.json"))) self.assertFalse(os.path.exists(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(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(get_preset_path(device_name, "foo.json"), "r") as f: os.system(f'cat { 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. remove(get_config_path()) device_name = "device_name" self._create_beta_setup() self.assertFalse(os.path.exists(get_preset_path(device_name, "bar"))) self.assertFalse(os.path.exists(get_config_path("config.json"))) migrate() self.assertTrue(os.path.exists(get_preset_path(device_name, "bar"))) self.assertTrue(os.path.exists(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(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(get_preset_path(device_name, "bar.json"), "r") as f: os.system(f'cat { 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.0.1/tests/unit/test_paths.py000066400000000000000000000050111450214210500213220ustar00rootroot00000000000000#!/usr/bin/python3 # -*- 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 . from tests.lib.cleanup import quick_cleanup from tests.lib.tmp import tmp import os import unittest import tempfile from inputremapper.configs.paths import ( touch, mkdir, get_preset_path, get_config_path, split_all, ) def _raise(error): raise error class TestPaths(unittest.TestCase): def tearDown(self): quick_cleanup() def test_touch(self): with tempfile.TemporaryDirectory() as local_tmp: path_abcde = os.path.join(local_tmp, "a/b/c/d/e") touch(path_abcde) self.assertTrue(os.path.exists(path_abcde)) self.assertTrue(os.path.isfile(path_abcde)) self.assertRaises( ValueError, lambda: touch(os.path.join(local_tmp, "a/b/c/d/f/")), ) def test_mkdir(self): with tempfile.TemporaryDirectory() as local_tmp: path_bcde = os.path.join(local_tmp, "b/c/d/e") mkdir(path_bcde) self.assertTrue(os.path.exists(path_bcde)) self.assertTrue(os.path.isdir(path_bcde)) def test_get_preset_path(self): self.assertTrue(get_preset_path().startswith(get_config_path())) self.assertTrue(get_preset_path().endswith("presets")) self.assertTrue(get_preset_path("a").endswith("presets/a")) self.assertTrue(get_preset_path("a", "b").endswith("presets/a/b.json")) def test_get_config_path(self): # might end with /beta_XXX self.assertTrue(get_config_path().startswith(f"{tmp}/.config/input-remapper")) self.assertTrue(get_config_path("a", "b").endswith("a/b")) def test_split_all(self): self.assertListEqual(split_all("a/b/c/d"), ["a", "b", "c", "d"]) input-remapper-2.0.1/tests/unit/test_preset.py000066400000000000000000000430221450214210500215110ustar00rootroot00000000000000#!/usr/bin/python3 # -*- 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 . import os import unittest from unittest.mock import patch from evdev.ecodes import EV_KEY, EV_ABS from inputremapper.configs.mapping import Mapping from inputremapper.configs.mapping import UIMapping from inputremapper.configs.paths import get_preset_path, get_config_path, CONFIG_PATH from inputremapper.configs.preset import Preset from inputremapper.configs.input_config import InputCombination, InputConfig from tests.lib.cleanup import quick_cleanup class TestPreset(unittest.TestCase): def setUp(self): self.preset = Preset(get_preset_path("foo", "bar2")) self.assertFalse(self.preset.has_unsaved_changes()) def tearDown(self): quick_cleanup() 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 = 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 = 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 = 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 = 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 = 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 = get_preset_path("Foo Device", "test") self.preset.save() path = os.path.join(CONFIG_PATH, "presets", "Foo Device", "test.json") self.assertTrue(os.path.exists(path)) loaded = Preset(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(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 = get_preset_path("foo", "bar2") self.preset.add(Mapping.from_combination()) self.preset.save() with open(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 = get_config_path("test.json") self.preset.save() self.assertFalse(self.preset.has_unsaved_changes()) self.preset.empty() self.assertEqual(self.preset.path, 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 = 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(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(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 = 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.0.1/tests/unit/test_reader.py000066400000000000000000001106771450214210500214640ustar00rootroot00000000000000#!/usr/bin/python3 # -*- 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 . import asyncio import os import json import multiprocessing 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.input_event import InputEvent from tests.lib.fixtures import new_event from tests.lib.cleanup import quick_cleanup from tests.lib.constants import ( EVENT_READ_TIMEOUT, START_READING_DELAY, MAX_ABS, MIN_ABS, ) from tests.lib.pipes import push_event, push_events from tests.lib.fixtures import fixtures from tests.lib.stuff import spy 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 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): quick_cleanup() 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 self.reader_service = ReaderService(groups) 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( "inputremapper.gui.reader_service.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) class TestReaderMultiprocessing(unittest.TestCase): def setUp(self): self.reader_service_process = None self.groups = _Groups() self.message_broker = MessageBroker() self.reader_client = ReaderClient(self.message_broker, self.groups) def tearDown(self): quick_cleanup() try: self.reader_client.terminate() except (BrokenPipeError, OSError): pass if self.reader_service_process is not None: self.reader_service_process.join() 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(): reader_service = ReaderService(groups) # this is a new process, so create a new event loop, or something loop = asyncio.new_event_loop() asyncio.set_event_loop(loop) 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()) if __name__ == "__main__": unittest.main() input-remapper-2.0.1/tests/unit/test_system_mapping.py000066400000000000000000000140431450214210500232470ustar00rootroot00000000000000#!/usr/bin/python3 # -*- 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 . 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 CONFIG_PATH from inputremapper.configs.system_mapping import SystemMapping, XMODMAP_FILENAME from tests.lib.cleanup import quick_cleanup class TestSystemMapping(unittest.TestCase): def tearDown(self): quick_cleanup() def test_update(self): system_mapping = SystemMapping() system_mapping.update({"foo1": 101, "bar1": 102}) system_mapping.update({"foo2": 201, "bar2": 202}) self.assertEqual(system_mapping.get("foo1"), 101) self.assertEqual(system_mapping.get("bar2"), 202) def test_xmodmap_file(self): system_mapping = SystemMapping() path = os.path.join(CONFIG_PATH, XMODMAP_FILENAME) os.remove(path) system_mapping.populate() self.assertTrue(os.path.exists(path)) with open(path, "r") as file: content = json.load(file) self.assertEqual(content["a"], KEY_A) # only xmodmap stuff should be present self.assertNotIn("key_a", content) self.assertNotIn("KEY_A", content) self.assertNotIn("disable", content) def test_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): system_mapping = SystemMapping() path = os.path.join(CONFIG_PATH, XMODMAP_FILENAME) os.remove(path) system_mapping.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): system_mapping = SystemMapping() path = os.path.join(CONFIG_PATH, XMODMAP_FILENAME) os.remove(path) system_mapping.populate() self.assertFalse(os.path.exists(path)) def test_correct_case(self): system_mapping = SystemMapping() system_mapping.clear() system_mapping._set("A", 31) system_mapping._set("a", 32) system_mapping._set("abcd_B", 33) self.assertEqual(system_mapping.correct_case("a"), "a") self.assertEqual(system_mapping.correct_case("A"), "A") self.assertEqual(system_mapping.correct_case("ABCD_b"), "abcd_B") # unknown stuff is returned as is self.assertEqual(system_mapping.correct_case("FOo"), "FOo") self.assertEqual(system_mapping.get("A"), 31) self.assertEqual(system_mapping.get("a"), 32) self.assertEqual(system_mapping.get("ABCD_b"), 33) self.assertEqual(system_mapping.get("abcd_B"), 33) def test_system_mapping(self): system_mapping = SystemMapping() system_mapping.populate() self.assertGreater(len(system_mapping._mapping), 100) # this is case-insensitive self.assertEqual(system_mapping.get("1"), 2) self.assertEqual(system_mapping.get("KeY_1"), 2) self.assertEqual(system_mapping.get("AlT_L"), 56) self.assertEqual(system_mapping.get("KEy_LEFtALT"), 56) self.assertEqual(system_mapping.get("kEY_LeFTSHIFT"), 42) self.assertEqual(system_mapping.get("ShiFt_L"), 42) self.assertEqual(system_mapping.get("BTN_left"), 272) self.assertIsNotNone(system_mapping.get("KEY_KP4")) self.assertEqual(system_mapping.get("KP_Left"), system_mapping.get("KEY_KP4")) # this only lists the correct casing, # includes linux constants and xmodmap symbols names = system_mapping.list_names() self.assertIn("2", names) self.assertIn("c", names) self.assertIn("KEY_3", names) self.assertNotIn("key_3", names) self.assertIn("KP_Down", names) self.assertNotIn("kp_down", names) names = system_mapping._mapping.keys() self.assertIn("F4", names) self.assertNotIn("f4", names) self.assertIn("BTN_RIGHT", names) self.assertNotIn("btn_right", names) self.assertIn("KEY_KP7", names) self.assertIn("KP_Home", names) self.assertNotIn("kp_home", names) self.assertEqual(system_mapping.get("disable"), -1) def test_get_name_no_xmodmap(self): # if xmodmap is not installed, uses the linux constant names system_mapping = SystemMapping() def check_output(*args, **kwargs): raise FileNotFoundError with patch.object(subprocess, "check_output", check_output): system_mapping.populate() self.assertEqual(system_mapping.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(system_mapping.get_name(BTN_LEFT), "BTN_LEFT") if __name__ == "__main__": unittest.main() input-remapper-2.0.1/tests/unit/test_test.py000066400000000000000000000125511450214210500211710ustar00rootroot00000000000000#!/usr/bin/python3 # -*- 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 . import asyncio from tests.lib.cleanup import cleanup, quick_cleanup from tests.lib.constants import EVENT_READ_TIMEOUT, START_READING_DELAY from tests.lib.logger import logger from tests.lib.fixtures import fixtures from tests.lib.pipes import push_events from tests.lib.patches import InputDevice import os import unittest import time import multiprocessing import evdev from evdev.ecodes import EV_ABS, EV_KEY from inputremapper.groups import groups, _Groups from inputremapper.gui.reader_client import ReaderClient from inputremapper.gui.reader_service import ReaderService from inputremapper.input_event import InputEvent from inputremapper.utils import get_device_hash from inputremapper.gui.messages.message_broker import MessageBroker class TestTest(unittest.TestCase): def test_stubs(self): self.assertIsNotNone(groups.find(key="Foo Device 2")) def tearDown(self): quick_cleanup() def test_fake_capabilities(self): device = InputDevice("/dev/input/event30") capabilities = device.capabilities(absinfo=False) self.assertIsInstance(capabilities, dict) self.assertIsInstance(capabilities[EV_ABS], list) self.assertIsInstance(capabilities[EV_ABS][0], int) capabilities = device.capabilities() self.assertIsInstance(capabilities, dict) self.assertIsInstance(capabilities[EV_ABS], list) self.assertIsInstance(capabilities[EV_ABS][0], tuple) self.assertIsInstance(capabilities[EV_ABS][0][0], int) self.assertIsInstance(capabilities[EV_ABS][0][1], evdev.AbsInfo) self.assertIsInstance(capabilities[EV_ABS][0][1].max, int) self.assertIsInstance(capabilities, dict) self.assertIsInstance(capabilities[EV_KEY], list) self.assertIsInstance(capabilities[EV_KEY][0], int) def test_restore_fixtures(self): fixtures["/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(): # there is no point in using the global groups object # because the reader-service runs in a different process reader_service = ReaderService(_Groups()) 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.0.1/tests/unit/test_user.py000066400000000000000000000041621450214210500211670ustar00rootroot00000000000000#!/usr/bin/python3 # -*- 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 . from tests.lib.cleanup import quick_cleanup import os import unittest from unittest import mock from inputremapper.user import get_user, get_home def _raise(error): raise error class TestUser(unittest.TestCase): def tearDown(self): quick_cleanup() def test_get_user(self): with mock.patch("os.getlogin", lambda: "foo"): self.assertEqual(get_user(), "foo") with mock.patch("os.getlogin", lambda: "root"): self.assertEqual(get_user(), "root") property_mock = mock.Mock() property_mock.configure_mock(pw_name="quix") with mock.patch("os.getlogin", lambda: _raise(OSError())), mock.patch( "pwd.getpwuid", return_value=property_mock ): os.environ["USER"] = "root" os.environ["SUDO_USER"] = "qux" self.assertEqual(get_user(), "qux") os.environ["USER"] = "root" del os.environ["SUDO_USER"] os.environ["PKEXEC_UID"] = "1000" self.assertNotEqual(get_user(), "root") def test_get_home(self): property_mock = mock.Mock() property_mock.configure_mock(pw_dir="/custom/home/foo") with mock.patch("pwd.getpwnam", return_value=property_mock): self.assertEqual(get_home("foo"), "/custom/home/foo") input-remapper-2.0.1/tests/unit/test_util.py000066400000000000000000000032301450214210500211610ustar00rootroot00000000000000#!/usr/bin/python3 # -*- 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 . 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 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")