From 0ab77d2a64c89dca9b17ccc456b5b77b1c324d5f Mon Sep 17 00:00:00 2001 From: Blomios Date: Thu, 18 Jun 2026 00:47:12 +0200 Subject: [PATCH] chore(repo): baseline du fork input-remapper (upstream v2.2.1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le dépôt ne contenait que README.md ; ajout de l'intégralité du code fonctionnel du fork comme socle stable de la branche de release. L'état runtime d'orchestration (.ideai/) est exclu du suivi. Co-Authored-By: Claude Opus 4.8 --- .coveragerc | 17 + .gitignore | 142 ++ .mypy.ini | 3 + .pylintrc | 17 + .reviewdog.yml | 21 + DEBIAN/control | 8 + DEBIAN/copyright | 3 + DEBIAN/postinst | 17 + LICENSE | 674 ++++++ bin/input-remapper-control | 30 + bin/input-remapper-gtk | 26 + bin/input-remapper-reader-service | 28 + bin/input-remapper-service | 26 + data/69-input-remapper-forwarded.rules | 8 + data/99-input-remapper.rules | 10 + data/input-remapper-autoload.desktop | 6 + data/input-remapper-gtk.desktop | 8 + data/input-remapper-large.png | Bin 0 -> 11853 bytes data/input-remapper.glade | 1865 ++++++++++++++++ data/input-remapper.policy | 21 + data/input-remapper.service | 14 + data/input-remapper.svg | 216 ++ data/inputremapper.Control.conf | 9 + ...ithub.sezanzeb.input_remapper.metainfo.xml | 44 + data/style.css | 50 + inputremapper/__init__.py | 0 inputremapper/bin/__init__.py | 0 inputremapper/bin/input_remapper_control.py | 429 ++++ inputremapper/bin/input_remapper_gtk.py | 152 ++ .../bin/input_remapper_reader_service.py | 80 + inputremapper/bin/input_remapper_service.py | 71 + inputremapper/bin/process_utils.py | 39 + inputremapper/configs/__init__.py | 0 inputremapper/configs/data.py | 33 + inputremapper/configs/global_config.py | 142 ++ inputremapper/configs/input_config.py | 485 +++++ inputremapper/configs/keyboard_layout.py | 221 ++ inputremapper/configs/mapping.py | 525 +++++ inputremapper/configs/migrations.py | 516 +++++ inputremapper/configs/paths.py | 157 ++ inputremapper/configs/preset.py | 325 +++ inputremapper/configs/validation_errors.py | 137 ++ inputremapper/daemon.py | 548 +++++ inputremapper/exceptions.py | 65 + inputremapper/groups.py | 567 +++++ inputremapper/gui/__init__.py | 6 + inputremapper/gui/autocompletion.py | 457 ++++ inputremapper/gui/components/__init__.py | 0 inputremapper/gui/components/common.py | 174 ++ inputremapper/gui/components/device_groups.py | 115 + inputremapper/gui/components/editor.py | 1211 +++++++++++ inputremapper/gui/components/main.py | 134 ++ .../gui/components/output_type_names.py | 3 + inputremapper/gui/components/presets.py | 106 + inputremapper/gui/controller.py | 876 ++++++++ inputremapper/gui/data_manager.py | 609 ++++++ inputremapper/gui/forward_to_ui_handler.py | 125 ++ inputremapper/gui/gettext.py | 33 + inputremapper/gui/messages/__init__.py | 0 inputremapper/gui/messages/message_broker.py | 120 + inputremapper/gui/messages/message_data.py | 126 ++ inputremapper/gui/messages/message_types.py | 60 + inputremapper/gui/reader_client.py | 306 +++ inputremapper/gui/reader_service.py | 420 ++++ inputremapper/gui/user_interface.py | 417 ++++ inputremapper/gui/utils.py | 272 +++ inputremapper/injection/__init__.py | 0 inputremapper/injection/context.py | 132 ++ inputremapper/injection/event_reader.py | 205 ++ inputremapper/injection/global_uinputs.py | 192 ++ inputremapper/injection/injector.py | 511 +++++ inputremapper/injection/macros/__init__.py | 0 inputremapper/injection/macros/argument.py | 320 +++ inputremapper/injection/macros/macro.py | 126 ++ inputremapper/injection/macros/parse.py | 476 ++++ inputremapper/injection/macros/raw_value.py | 35 + inputremapper/injection/macros/task.py | 248 +++ .../injection/macros/tasks/__init__.py | 0 inputremapper/injection/macros/tasks/add.py | 72 + inputremapper/injection/macros/tasks/event.py | 64 + inputremapper/injection/macros/tasks/hold.py | 71 + .../injection/macros/tasks/hold_keys.py | 55 + inputremapper/injection/macros/tasks/if_eq.py | 66 + .../injection/macros/tasks/if_led.py | 71 + .../injection/macros/tasks/if_single.py | 96 + .../injection/macros/tasks/if_tap.py | 79 + inputremapper/injection/macros/tasks/ifeq.py | 72 + inputremapper/injection/macros/tasks/key.py | 49 + .../injection/macros/tasks/key_down.py | 45 + .../injection/macros/tasks/key_up.py | 45 + .../injection/macros/tasks/mod_tap.py | 140 ++ .../injection/macros/tasks/modify.py | 57 + inputremapper/injection/macros/tasks/mouse.py | 69 + .../injection/macros/tasks/mouse_xy.py | 99 + .../injection/macros/tasks/parallel.py | 45 + .../injection/macros/tasks/repeat.py | 49 + inputremapper/injection/macros/tasks/set.py | 49 + .../injection/macros/tasks/toggle.py | 56 + inputremapper/injection/macros/tasks/util.py | 45 + inputremapper/injection/macros/tasks/wait.py | 54 + inputremapper/injection/macros/tasks/wheel.py | 76 + inputremapper/injection/macros/variable.py | 90 + .../injection/mapping_handlers/__init__.py | 18 + .../mapping_handlers/abs_to_abs_handler.py | 152 ++ .../mapping_handlers/abs_to_btn_handler.py | 151 ++ .../mapping_handlers/abs_to_rel_handler.py | 245 +++ .../injection/mapping_handlers/abs_util.py | 72 + .../mapping_handlers/axis_switch_handler.py | 190 ++ .../mapping_handlers/axis_transform.py | 140 ++ .../mapping_handlers/combination_handler.py | 275 +++ .../mapping_handlers/hierarchy_handler.py | 108 + .../injection/mapping_handlers/key_handler.py | 90 + .../mapping_handlers/macro_handler.py | 121 ++ .../mapping_handlers/mapping_handler.py | 213 ++ .../mapping_handlers/mapping_parser.py | 360 +++ .../mapping_handlers/null_handler.py | 62 + .../mapping_handlers/rel_to_abs_handler.py | 248 +++ .../mapping_handlers/rel_to_btn_handler.py | 148 ++ .../mapping_handlers/rel_to_rel_handler.py | 277 +++ inputremapper/injection/numlock.py | 83 + inputremapper/input_event.py | 264 +++ inputremapper/installation_info.py | 30 + inputremapper/ipc/__init__.py | 24 + inputremapper/ipc/pipe.py | 183 ++ inputremapper/ipc/shared_dict.py | 122 ++ inputremapper/ipc/socket.py | 303 +++ inputremapper/logging/__init__.py | 0 inputremapper/logging/formatter.py | 143 ++ inputremapper/logging/logger.py | 172 ++ inputremapper/user.py | 71 + inputremapper/utils.py | 64 + install/__init__.py | 0 install/__main__.py | 116 + install/check_dependencies.py | 43 + install/data_files.py | 66 + install/language.py | 54 + install/module.py | 187 ++ install/uninstall.py | 77 + po/fr.po | 1 + po/fr_FR.po | 572 +++++ po/input-remapper.pot | 467 ++++ po/it.po | 1 + po/it_IT.po | 684 ++++++ po/pt.po | 1 + po/pt_BR.po | 525 +++++ po/ru.po | 1 + po/ru_RU.po | 504 +++++ po/sk.po | 1 + po/sk_SK.po | 724 +++++++ po/uk.po | 1 + po/uk_UA.po | 577 +++++ po/zh.po | 1 + po/zh_CN.po | 560 +++++ po/zh_TW.po | 505 +++++ pyproject.toml | 32 + readme/architecture.png | Bin 0 -> 256638 bytes readme/capabilities.md | 176 ++ readme/coverage.svg | 23 + readme/development.md | 156 ++ readme/examples.md | 190 ++ readme/macros.md | 431 ++++ readme/plus.png | Bin 0 -> 11762 bytes readme/pylint.svg | 23 + readme/screenshot.png | Bin 0 -> 58151 bytes readme/screenshot_2.png | Bin 0 -> 65860 bytes readme/usage.md | 355 +++ readme/usage_1.png | Bin 0 -> 18201 bytes readme/usage_2.png | Bin 0 -> 14134 bytes scripts/badges.sh | 32 + scripts/build-deb.sh | 17 + scripts/ci-install-deps.sh | 15 + tests/__init__.py | 0 tests/__main__.py | 4 + tests/lib/__init__.py | 0 tests/lib/cleanup.py | 172 ++ tests/lib/constants.py | 28 + tests/lib/fixture_pipes.py | 36 + tests/lib/fixtures.py | 420 ++++ tests/lib/is_service_running.py | 32 + tests/lib/logger.py | 54 + tests/lib/patches.py | 413 ++++ tests/lib/pipes.py | 96 + tests/lib/spy.py | 26 + tests/lib/test_setup.py | 109 + tests/lib/tmp.py | 30 + tests/lib/xmodmap.py | 128 ++ tests/system/__init__.py | 8 + tests/system/gui/__init__.py | 0 tests/system/gui/gui_test_base.py | 383 ++++ tests/system/gui/test_autocompletion.py | 259 +++ tests/system/gui/test_colors.py | 89 + tests/system/gui/test_components.py | 1925 +++++++++++++++++ tests/system/gui/test_debounce.py | 149 ++ tests/system/gui/test_groups.py | 137 ++ tests/system/gui/test_gui.py | 1619 ++++++++++++++ tests/system/gui/test_user_interface.py | 113 + tests/system/test_dbus.py | 85 + tests/system/test_numlockx.py | 56 + tests/unit/__init__.py | 1 + tests/unit/test_context.py | 142 ++ tests/unit/test_control.py | 407 ++++ tests/unit/test_controller.py | 1627 ++++++++++++++ tests/unit/test_daemon.py | 577 +++++ tests/unit/test_data_manager.py | 971 +++++++++ tests/unit/test_event_pipeline/__init__.py | 0 .../event_pipeline_test_base.py | 86 + .../test_event_pipeline/test_abs_to_abs.py | 145 ++ .../test_event_pipeline/test_abs_to_btn.py | 225 ++ .../test_event_pipeline/test_abs_to_rel.py | 202 ++ .../test_axis_transformation.py | 209 ++ .../test_event_pipeline/test_combination.py | 1497 +++++++++++++ .../test_mapping_handlers.py | 597 +++++ .../test_event_pipeline/test_rel_to_abs.py | 239 ++ .../test_event_pipeline/test_rel_to_btn.py | 180 ++ .../test_event_pipeline/test_rel_to_rel.py | 219 ++ tests/unit/test_event_reader.py | 300 +++ tests/unit/test_global_config.py | 106 + tests/unit/test_global_uinputs.py | 100 + tests/unit/test_groups.py | 417 ++++ tests/unit/test_injector.py | 711 ++++++ tests/unit/test_input_config.py | 559 +++++ tests/unit/test_input_event.py | 132 ++ tests/unit/test_ipc.py | 208 ++ tests/unit/test_logger.py | 151 ++ tests/unit/test_macros/__init__.py | 0 tests/unit/test_macros/macro_test_base.py | 120 + tests/unit/test_macros/test_add.py | 71 + tests/unit/test_macros/test_argument.py | 187 ++ tests/unit/test_macros/test_dynamic_types.py | 154 ++ tests/unit/test_macros/test_event.py | 67 + tests/unit/test_macros/test_hold.py | 191 ++ tests/unit/test_macros/test_hold_keys.py | 130 ++ tests/unit/test_macros/test_if_eq.py | 215 ++ tests/unit/test_macros/test_if_single.py | 192 ++ tests/unit/test_macros/test_if_tap.py | 190 ++ tests/unit/test_macros/test_key.py | 123 ++ tests/unit/test_macros/test_leds.py | 124 ++ tests/unit/test_macros/test_macros.py | 167 ++ tests/unit/test_macros/test_mod_tap.py | 385 ++++ tests/unit/test_macros/test_modify.py | 62 + tests/unit/test_macros/test_mouse.py | 246 +++ tests/unit/test_macros/test_parallel.py | 114 + tests/unit/test_macros/test_parsing.py | 346 +++ tests/unit/test_macros/test_repeat.py | 142 ++ tests/unit/test_macros/test_set.py | 101 + tests/unit/test_macros/test_toggle.py | 69 + tests/unit/test_macros/test_wait.py | 89 + tests/unit/test_mapping.py | 503 +++++ tests/unit/test_message_broker.py | 109 + tests/unit/test_migrations.py | 726 +++++++ tests/unit/test_paths.py | 73 + tests/unit/test_preset.py | 486 +++++ tests/unit/test_reader.py | 1052 +++++++++ tests/unit/test_system_mapping.py | 165 ++ tests/unit/test_test.py | 145 ++ tests/unit/test_user.py | 62 + tests/unit/test_util.py | 43 + 257 files changed, 51859 insertions(+) create mode 100644 .coveragerc create mode 100644 .gitignore create mode 100644 .mypy.ini create mode 100644 .pylintrc create mode 100644 .reviewdog.yml create mode 100644 DEBIAN/control create mode 100644 DEBIAN/copyright create mode 100755 DEBIAN/postinst create mode 100644 LICENSE create mode 100755 bin/input-remapper-control create mode 100755 bin/input-remapper-gtk create mode 100755 bin/input-remapper-reader-service create mode 100755 bin/input-remapper-service create mode 100644 data/69-input-remapper-forwarded.rules create mode 100644 data/99-input-remapper.rules create mode 100644 data/input-remapper-autoload.desktop create mode 100644 data/input-remapper-gtk.desktop create mode 100644 data/input-remapper-large.png create mode 100644 data/input-remapper.glade create mode 100644 data/input-remapper.policy create mode 100644 data/input-remapper.service create mode 100644 data/input-remapper.svg create mode 100644 data/inputremapper.Control.conf create mode 100644 data/io.github.sezanzeb.input_remapper.metainfo.xml create mode 100644 data/style.css create mode 100644 inputremapper/__init__.py create mode 100644 inputremapper/bin/__init__.py create mode 100755 inputremapper/bin/input_remapper_control.py create mode 100755 inputremapper/bin/input_remapper_gtk.py create mode 100755 inputremapper/bin/input_remapper_reader_service.py create mode 100755 inputremapper/bin/input_remapper_service.py create mode 100644 inputremapper/bin/process_utils.py create mode 100644 inputremapper/configs/__init__.py create mode 100644 inputremapper/configs/data.py create mode 100644 inputremapper/configs/global_config.py create mode 100644 inputremapper/configs/input_config.py create mode 100644 inputremapper/configs/keyboard_layout.py create mode 100644 inputremapper/configs/mapping.py create mode 100644 inputremapper/configs/migrations.py create mode 100644 inputremapper/configs/paths.py create mode 100644 inputremapper/configs/preset.py create mode 100644 inputremapper/configs/validation_errors.py create mode 100644 inputremapper/daemon.py create mode 100644 inputremapper/exceptions.py create mode 100644 inputremapper/groups.py create mode 100644 inputremapper/gui/__init__.py create mode 100644 inputremapper/gui/autocompletion.py create mode 100644 inputremapper/gui/components/__init__.py create mode 100644 inputremapper/gui/components/common.py create mode 100644 inputremapper/gui/components/device_groups.py create mode 100644 inputremapper/gui/components/editor.py create mode 100644 inputremapper/gui/components/main.py create mode 100644 inputremapper/gui/components/output_type_names.py create mode 100644 inputremapper/gui/components/presets.py create mode 100644 inputremapper/gui/controller.py create mode 100644 inputremapper/gui/data_manager.py create mode 100644 inputremapper/gui/forward_to_ui_handler.py create mode 100644 inputremapper/gui/gettext.py create mode 100644 inputremapper/gui/messages/__init__.py create mode 100644 inputremapper/gui/messages/message_broker.py create mode 100644 inputremapper/gui/messages/message_data.py create mode 100644 inputremapper/gui/messages/message_types.py create mode 100644 inputremapper/gui/reader_client.py create mode 100644 inputremapper/gui/reader_service.py create mode 100644 inputremapper/gui/user_interface.py create mode 100644 inputremapper/gui/utils.py create mode 100644 inputremapper/injection/__init__.py create mode 100644 inputremapper/injection/context.py create mode 100644 inputremapper/injection/event_reader.py create mode 100644 inputremapper/injection/global_uinputs.py create mode 100644 inputremapper/injection/injector.py create mode 100644 inputremapper/injection/macros/__init__.py create mode 100644 inputremapper/injection/macros/argument.py create mode 100644 inputremapper/injection/macros/macro.py create mode 100644 inputremapper/injection/macros/parse.py create mode 100644 inputremapper/injection/macros/raw_value.py create mode 100644 inputremapper/injection/macros/task.py create mode 100644 inputremapper/injection/macros/tasks/__init__.py create mode 100644 inputremapper/injection/macros/tasks/add.py create mode 100644 inputremapper/injection/macros/tasks/event.py create mode 100644 inputremapper/injection/macros/tasks/hold.py create mode 100644 inputremapper/injection/macros/tasks/hold_keys.py create mode 100644 inputremapper/injection/macros/tasks/if_eq.py create mode 100644 inputremapper/injection/macros/tasks/if_led.py create mode 100644 inputremapper/injection/macros/tasks/if_single.py create mode 100644 inputremapper/injection/macros/tasks/if_tap.py create mode 100644 inputremapper/injection/macros/tasks/ifeq.py create mode 100644 inputremapper/injection/macros/tasks/key.py create mode 100644 inputremapper/injection/macros/tasks/key_down.py create mode 100644 inputremapper/injection/macros/tasks/key_up.py create mode 100644 inputremapper/injection/macros/tasks/mod_tap.py create mode 100644 inputremapper/injection/macros/tasks/modify.py create mode 100644 inputremapper/injection/macros/tasks/mouse.py create mode 100644 inputremapper/injection/macros/tasks/mouse_xy.py create mode 100644 inputremapper/injection/macros/tasks/parallel.py create mode 100644 inputremapper/injection/macros/tasks/repeat.py create mode 100644 inputremapper/injection/macros/tasks/set.py create mode 100644 inputremapper/injection/macros/tasks/toggle.py create mode 100644 inputremapper/injection/macros/tasks/util.py create mode 100644 inputremapper/injection/macros/tasks/wait.py create mode 100644 inputremapper/injection/macros/tasks/wheel.py create mode 100644 inputremapper/injection/macros/variable.py create mode 100644 inputremapper/injection/mapping_handlers/__init__.py create mode 100644 inputremapper/injection/mapping_handlers/abs_to_abs_handler.py create mode 100644 inputremapper/injection/mapping_handlers/abs_to_btn_handler.py create mode 100644 inputremapper/injection/mapping_handlers/abs_to_rel_handler.py create mode 100644 inputremapper/injection/mapping_handlers/abs_util.py create mode 100644 inputremapper/injection/mapping_handlers/axis_switch_handler.py create mode 100644 inputremapper/injection/mapping_handlers/axis_transform.py create mode 100644 inputremapper/injection/mapping_handlers/combination_handler.py create mode 100644 inputremapper/injection/mapping_handlers/hierarchy_handler.py create mode 100644 inputremapper/injection/mapping_handlers/key_handler.py create mode 100644 inputremapper/injection/mapping_handlers/macro_handler.py create mode 100644 inputremapper/injection/mapping_handlers/mapping_handler.py create mode 100644 inputremapper/injection/mapping_handlers/mapping_parser.py create mode 100644 inputremapper/injection/mapping_handlers/null_handler.py create mode 100644 inputremapper/injection/mapping_handlers/rel_to_abs_handler.py create mode 100644 inputremapper/injection/mapping_handlers/rel_to_btn_handler.py create mode 100644 inputremapper/injection/mapping_handlers/rel_to_rel_handler.py create mode 100644 inputremapper/injection/numlock.py create mode 100644 inputremapper/input_event.py create mode 100644 inputremapper/installation_info.py create mode 100644 inputremapper/ipc/__init__.py create mode 100644 inputremapper/ipc/pipe.py create mode 100644 inputremapper/ipc/shared_dict.py create mode 100644 inputremapper/ipc/socket.py create mode 100644 inputremapper/logging/__init__.py create mode 100644 inputremapper/logging/formatter.py create mode 100644 inputremapper/logging/logger.py create mode 100644 inputremapper/user.py create mode 100644 inputremapper/utils.py create mode 100644 install/__init__.py create mode 100644 install/__main__.py create mode 100644 install/check_dependencies.py create mode 100644 install/data_files.py create mode 100644 install/language.py create mode 100644 install/module.py create mode 100644 install/uninstall.py create mode 120000 po/fr.po create mode 100644 po/fr_FR.po create mode 100644 po/input-remapper.pot create mode 120000 po/it.po create mode 100644 po/it_IT.po create mode 120000 po/pt.po create mode 100644 po/pt_BR.po create mode 120000 po/ru.po create mode 100644 po/ru_RU.po create mode 120000 po/sk.po create mode 100644 po/sk_SK.po create mode 120000 po/uk.po create mode 100644 po/uk_UA.po create mode 120000 po/zh.po create mode 100644 po/zh_CN.po create mode 100644 po/zh_TW.po create mode 100644 pyproject.toml create mode 100644 readme/architecture.png create mode 100644 readme/capabilities.md create mode 100644 readme/coverage.svg create mode 100644 readme/development.md create mode 100644 readme/examples.md create mode 100644 readme/macros.md create mode 100644 readme/plus.png create mode 100644 readme/pylint.svg create mode 100644 readme/screenshot.png create mode 100644 readme/screenshot_2.png create mode 100644 readme/usage.md create mode 100644 readme/usage_1.png create mode 100644 readme/usage_2.png create mode 100755 scripts/badges.sh create mode 100755 scripts/build-deb.sh create mode 100755 scripts/ci-install-deps.sh create mode 100644 tests/__init__.py create mode 100644 tests/__main__.py create mode 100644 tests/lib/__init__.py create mode 100644 tests/lib/cleanup.py create mode 100644 tests/lib/constants.py create mode 100644 tests/lib/fixture_pipes.py create mode 100644 tests/lib/fixtures.py create mode 100644 tests/lib/is_service_running.py create mode 100644 tests/lib/logger.py create mode 100644 tests/lib/patches.py create mode 100644 tests/lib/pipes.py create mode 100644 tests/lib/spy.py create mode 100644 tests/lib/test_setup.py create mode 100644 tests/lib/tmp.py create mode 100644 tests/lib/xmodmap.py create mode 100644 tests/system/__init__.py create mode 100644 tests/system/gui/__init__.py create mode 100644 tests/system/gui/gui_test_base.py create mode 100644 tests/system/gui/test_autocompletion.py create mode 100644 tests/system/gui/test_colors.py create mode 100644 tests/system/gui/test_components.py create mode 100644 tests/system/gui/test_debounce.py create mode 100644 tests/system/gui/test_groups.py create mode 100644 tests/system/gui/test_gui.py create mode 100644 tests/system/gui/test_user_interface.py create mode 100644 tests/system/test_dbus.py create mode 100644 tests/system/test_numlockx.py create mode 100644 tests/unit/__init__.py create mode 100644 tests/unit/test_context.py create mode 100644 tests/unit/test_control.py create mode 100644 tests/unit/test_controller.py create mode 100644 tests/unit/test_daemon.py create mode 100644 tests/unit/test_data_manager.py create mode 100644 tests/unit/test_event_pipeline/__init__.py create mode 100644 tests/unit/test_event_pipeline/event_pipeline_test_base.py create mode 100644 tests/unit/test_event_pipeline/test_abs_to_abs.py create mode 100644 tests/unit/test_event_pipeline/test_abs_to_btn.py create mode 100644 tests/unit/test_event_pipeline/test_abs_to_rel.py create mode 100644 tests/unit/test_event_pipeline/test_axis_transformation.py create mode 100644 tests/unit/test_event_pipeline/test_combination.py create mode 100644 tests/unit/test_event_pipeline/test_mapping_handlers.py create mode 100644 tests/unit/test_event_pipeline/test_rel_to_abs.py create mode 100644 tests/unit/test_event_pipeline/test_rel_to_btn.py create mode 100644 tests/unit/test_event_pipeline/test_rel_to_rel.py create mode 100644 tests/unit/test_event_reader.py create mode 100644 tests/unit/test_global_config.py create mode 100644 tests/unit/test_global_uinputs.py create mode 100644 tests/unit/test_groups.py create mode 100644 tests/unit/test_injector.py create mode 100644 tests/unit/test_input_config.py create mode 100644 tests/unit/test_input_event.py create mode 100644 tests/unit/test_ipc.py create mode 100644 tests/unit/test_logger.py create mode 100644 tests/unit/test_macros/__init__.py create mode 100644 tests/unit/test_macros/macro_test_base.py create mode 100644 tests/unit/test_macros/test_add.py create mode 100644 tests/unit/test_macros/test_argument.py create mode 100644 tests/unit/test_macros/test_dynamic_types.py create mode 100644 tests/unit/test_macros/test_event.py create mode 100644 tests/unit/test_macros/test_hold.py create mode 100644 tests/unit/test_macros/test_hold_keys.py create mode 100644 tests/unit/test_macros/test_if_eq.py create mode 100644 tests/unit/test_macros/test_if_single.py create mode 100644 tests/unit/test_macros/test_if_tap.py create mode 100644 tests/unit/test_macros/test_key.py create mode 100644 tests/unit/test_macros/test_leds.py create mode 100644 tests/unit/test_macros/test_macros.py create mode 100644 tests/unit/test_macros/test_mod_tap.py create mode 100644 tests/unit/test_macros/test_modify.py create mode 100644 tests/unit/test_macros/test_mouse.py create mode 100644 tests/unit/test_macros/test_parallel.py create mode 100644 tests/unit/test_macros/test_parsing.py create mode 100644 tests/unit/test_macros/test_repeat.py create mode 100644 tests/unit/test_macros/test_set.py create mode 100644 tests/unit/test_macros/test_toggle.py create mode 100644 tests/unit/test_macros/test_wait.py create mode 100644 tests/unit/test_mapping.py create mode 100644 tests/unit/test_message_broker.py create mode 100644 tests/unit/test_migrations.py create mode 100644 tests/unit/test_paths.py create mode 100644 tests/unit/test_preset.py create mode 100644 tests/unit/test_reader.py create mode 100644 tests/unit/test_system_mapping.py create mode 100644 tests/unit/test_test.py create mode 100644 tests/unit/test_user.py create mode 100644 tests/unit/test_util.py diff --git a/.coveragerc b/.coveragerc new file mode 100644 index 0000000..2c1bd00 --- /dev/null +++ b/.coveragerc @@ -0,0 +1,17 @@ +[run] +branch = True +source = ./inputremapper +concurrency = multiprocessing +debug = multiproc +omit = + # not used currently due to problems + ./inputremapper/ipc/socket.py + +[report] +exclude_lines = + pragma: no cover + + # Don't complain about abstract methods, they aren't run: + @(abc\.)?abstractmethod + # Don't cover Protocol classes + class .*\(.*Protocol.*\): diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..866472a --- /dev/null +++ b/.gitignore @@ -0,0 +1,142 @@ +mo + +*.glade~ +*.glade# +.idea +*.png~* +*.orig + +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +/lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +pip-wheel-metadata/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ + +# pyreverse graphs +*.dot + +# Translations +*.mo + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +.python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# IdeA orchestration runtime state +.ideai/ diff --git a/.mypy.ini b/.mypy.ini new file mode 100644 index 0000000..a2e213d --- /dev/null +++ b/.mypy.ini @@ -0,0 +1,3 @@ +[mypy] +plugins = pydantic.mypy +ignore_missing_imports = True diff --git a/.pylintrc b/.pylintrc new file mode 100644 index 0000000..e9b01fe --- /dev/null +++ b/.pylintrc @@ -0,0 +1,17 @@ +[_] + +max-line-length=88 # black + +extension-pkg-whitelist=evdev, pydantic +load-plugins=pylint_pydantic + +disable= + # that is the standard way to import GTK afaik + wrong-import-position, + + # using """ for comments highlights them in green for me and makes it + # a great way to separate stuff into multiple sections + pointless-string-statement + + # https://github.com/psf/black/blob/main/docs/compatible_configs/pylint/pylintrc + C0330, C0326 diff --git a/.reviewdog.yml b/.reviewdog.yml new file mode 100644 index 0000000..97aad1f --- /dev/null +++ b/.reviewdog.yml @@ -0,0 +1,21 @@ +--- +runner: + mypy: + name: mypy + cmd: mypy --show-column-numbers inputremapper tests install --ignore-missing-imports + errorformat: + - "%f:%l:%c: %m" + + pylint: + name: pylint + cmd: pylint inputremapper tests --extension-pkg-whitelist=evdev + errorformat: + - "%f:%l:%c: %t%n: %m" + + flake8: + cmd: flake8 inputremapper tests + format: flake8 + + black: + cmd: black --diff --quiet --check ./inputremapper ./tests + format: black diff --git a/DEBIAN/control b/DEBIAN/control new file mode 100644 index 0000000..485a90e --- /dev/null +++ b/DEBIAN/control @@ -0,0 +1,8 @@ +Package: input-remapper +Version: 2.2.1 +Architecture: all +Maintainer: Sezanzeb +Depends: build-essential, libpython3-dev, libdbus-1-dev, python3, python3-evdev, python3-dasbus, python3-gi, gettext, python3-cairo, libgtk-3-0, libgtksourceview-4-dev, python3-pydantic, python3-packaging, python3-psutil +Description: A tool to change the mapping of your input device buttons +Replaces: python3-key-mapper, key-mapper, input-remapper-gtk, input-remapper-daemon, python3-inputremapper +Conflicts: python3-key-mapper, key-mapper, input-remapper-gtk, input-remapper-daemon, python3-inputremapper diff --git a/DEBIAN/copyright b/DEBIAN/copyright new file mode 100644 index 0000000..f9556e4 --- /dev/null +++ b/DEBIAN/copyright @@ -0,0 +1,3 @@ +Files: * +Copyright: 2025 Sezanzeb +License: GPL-3+ diff --git a/DEBIAN/postinst b/DEBIAN/postinst new file mode 100755 index 0000000..cfaa22c --- /dev/null +++ b/DEBIAN/postinst @@ -0,0 +1,17 @@ +#!/bin/bash +if [ -d "/run/systemd/system/" ]; then + # old name, those lines should at some point be removed from postinst + pkill -f key-mapper-service + systemctl disable key-mapper 2> /dev/null || true + systemctl stop key-mapper 2> /dev/null || true + + # The ubuntu package creates those two symlinks that break when installing the .deb + # built from source. Either ubuntus packages need to be uninstalled with --purge first, + # or those files need to be unlinked manually. + unlink /etc/systemd/system/input-remapper.service || true + unlink /etc/systemd/system/default.target.wants/input-remapper-daemon.service || true + + pkill -f input-remapper-service # might have been started by the gui previously + systemctl enable input-remapper + systemctl start input-remapper +fi diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..f288702 --- /dev/null +++ b/LICENSE @@ -0,0 +1,674 @@ + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU General Public License is a free, copyleft license for +software and other kinds of works. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. We, the Free Software Foundation, use the +GNU General Public License for most of our software; it applies also to +any other work released this way by its authors. You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you have +certain responsibilities if you distribute copies of the software, or if +you modify it: responsibilities to respect the freedom of others. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights. + + Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. + + For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + + Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the manufacturer +can do so. This is fundamentally incompatible with the aim of +protecting users' freedom to change the software. The systematic +pattern of such abuse occurs in the area of products for individuals to +use, which is precisely where it is most unacceptable. Therefore, we +have designed this version of the GPL to prohibit the practice for those +products. If such problems arise substantially in other domains, we +stand ready to extend this provision to those domains in future versions +of the GPL, as needed to protect the freedom of users. + + Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish to +avoid the special danger that patents applied to a free program could +make it effectively proprietary. To prevent this, the GPL assures that +patents cannot be used to render the program non-free. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Use with the GNU Affero General Public License. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU Affero General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If the program does terminal interaction, make it output a short +notice like this when it starts in an interactive mode: + + Copyright (C) + This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, your program's commands +might be different; for a GUI interface, you would use an "about box". + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU GPL, see +. + + The GNU General Public License does not permit incorporating your program +into proprietary programs. If your program is a subroutine library, you +may consider it more useful to permit linking proprietary applications with +the library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. But first, please read +. diff --git a/bin/input-remapper-control b/bin/input-remapper-control new file mode 100755 index 0000000..f2f5f0e --- /dev/null +++ b/bin/input-remapper-control @@ -0,0 +1,30 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + +"""Control the dbus service from the command line.""" + +import os +import sys + +from inputremapper.bin.input_remapper_control import InputRemapperControlBin + + +if __name__ == "__main__": + InputRemapperControlBin.main(InputRemapperControlBin.parse_args()) diff --git a/bin/input-remapper-gtk b/bin/input-remapper-gtk new file mode 100755 index 0000000..726a71d --- /dev/null +++ b/bin/input-remapper-gtk @@ -0,0 +1,26 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + +"""Starts the user interface.""" + +from inputremapper.bin.input_remapper_gtk import InputRemapperGtkBin + +if __name__ == "__main__": + InputRemapperGtkBin.main() diff --git a/bin/input-remapper-reader-service b/bin/input-remapper-reader-service new file mode 100755 index 0000000..6f5a067 --- /dev/null +++ b/bin/input-remapper-reader-service @@ -0,0 +1,28 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + +"""Starts the root reader-service.""" + +from inputremapper.bin.input_remapper_reader_service import ( + InputRemapperReaderServiceBin, +) + +if __name__ == "__main__": + InputRemapperReaderServiceBin.main() diff --git a/bin/input-remapper-service b/bin/input-remapper-service new file mode 100755 index 0000000..9309600 --- /dev/null +++ b/bin/input-remapper-service @@ -0,0 +1,26 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + +"""Starts injecting keycodes based on the configuration.""" + +from inputremapper.bin.input_remapper_service import InputRemapperServiceBin + +if __name__ == "__main__": + InputRemapperServiceBin.main() diff --git a/data/69-input-remapper-forwarded.rules b/data/69-input-remapper-forwarded.rules new file mode 100644 index 0000000..101284c --- /dev/null +++ b/data/69-input-remapper-forwarded.rules @@ -0,0 +1,8 @@ +# helpful commands: +# udevadm monitor --property +# udevadm info --query=all --name=/dev/input/event3 +# +# Forwarded input-remapper devices are virtual, so systemd's persistent-input +# rules do not assign ID_BUS to them. Without ID_BUS, 70-mouse.rules will not +# query hwdb and properties such as MOUSE_DPI get lost, changing pointer feel. +ACTION=="add", SUBSYSTEM=="input", KERNEL=="event*", ATTRS{phys}=="input-remapper/*", ENV{ID_BUS}=="", ENV{ID_BUS}="usb" diff --git a/data/99-input-remapper.rules b/data/99-input-remapper.rules new file mode 100644 index 0000000..087abef --- /dev/null +++ b/data/99-input-remapper.rules @@ -0,0 +1,10 @@ +# helpful commands: +# udevadm monitor --property +# udevadm info --query=all --name=/dev/input/event3 +# to test changes: +# sudo udevadm control --log-priority=debug +# sudo udevadm control --reload-rules +# journalctl -f +# to get available variables: +# udevadm monitor --environment --udev --subsystem input +ACTION=="add", SUBSYSTEM=="input", KERNEL=="event*", ENV{ID_PATH}!="platform-sound", ATTRS{phys}!="input-remapper*", RUN+="/bin/input-remapper-control --command autoload --device $env{DEVNAME}" diff --git a/data/input-remapper-autoload.desktop b/data/input-remapper-autoload.desktop new file mode 100644 index 0000000..a647840 --- /dev/null +++ b/data/input-remapper-autoload.desktop @@ -0,0 +1,6 @@ +[Desktop Entry] +Type=Application +Exec=bash -c "input-remapper-control --command stop-all && input-remapper-control --command autoload" +Name=input-remapper-autoload +Icon=input-remapper +Comment=Starts injecting all presets that are set to automatically load for the user diff --git a/data/input-remapper-gtk.desktop b/data/input-remapper-gtk.desktop new file mode 100644 index 0000000..71785a3 --- /dev/null +++ b/data/input-remapper-gtk.desktop @@ -0,0 +1,8 @@ +[Desktop Entry] +Type=Application +Name=Input Remapper +Icon=input-remapper +Exec=input-remapper-gtk +Terminal=false +Categories=Settings +Comment=GUI for device specific key mappings diff --git a/data/input-remapper-large.png b/data/input-remapper-large.png new file mode 100644 index 0000000000000000000000000000000000000000..3bc3e7fd0e9bac3cfe0c59f00183e773b80f57a9 GIT binary patch literal 11853 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 Hu0mjfflq&w literal 0 HcmV?d00001 diff --git a/data/input-remapper.glade b/data/input-remapper.glade new file mode 100644 index 0000000..df11529 --- /dev/null +++ b/data/input-remapper.glade @@ -0,0 +1,1865 @@ + + + + + + + True + False + help-about + + + True + False + 2 + 2 + media-playback-start + + + True + False + 2 + 2 + edit-copy + + + 1 + 0.01 + 0.01 + + + True + False + 2 + 2 + edit-delete + + + -1 + 1 + 0.05 + 0.05 + + + -2 + 2 + 0.10 + 0.10 + + + True + False + 2 + 2 + media-playback-stop + + + True + False + 2 + 2 + media-playback-stop + + + True + False + 2 + 2 + edit-delete + + + True + False + edit + + + True + False + media-record + + + True + False + list-add + + + True + False + list-remove + + + True + False + 2 + 2 + document-new + + + True + False + document-save + + + 1000 + False + 450 + input-remapper.svg + + + + True + False + vertical + + + True + False + crossfade + + + True + True + + + True + False + none + + + True + False + start + 18 + 18 + 18 + 18 + True + 18 + 18 + 99 + none + + + + + + + Devices + Devices + + + + + True + False + vertical + + + True + False + 18 + 18 + Device Name + + + + + + False + True + 0 + + + + + True + False + center + 18 + 18 + 27 + 6 + True + + + New + True + True + True + Create a new preset + new-icon + True + + + False + True + 0 + + + + + Stop + True + True + True + Stops the Injection for the selected device, +gives your keys their original function back +Shortcut: ctrl + del + gtk-redo-icon + True + + + False + True + 1 + + + + + False + True + 1 + + + + + True + False + + + False + True + 2 + + + + + True + True + + + True + False + none + + + True + False + start + 18 + 18 + 18 + 18 + True + 18 + 18 + 99 + none + + + + + + + True + True + 3 + + + + + Presets + Presets + 1 + + + + + True + False + vertical + + + True + False + 18 + 18 + Preset Name + + + + + + False + True + 0 + + + + + + True + False + center + 106 + 112 + 27 + 6 + 12 + + + 463 + True + False + 6 + True + + + Apply + True + True + True + Start injecting. Don't hold down any keys while the injection starts + check-icon1 + True + + + False + True + 0 + + + + + Stop + True + True + True + Stops the Injection for the selected device, +gives your keys their original function back +Shortcut: ctrl + del + gtk-redo-icon1 + True + + + False + True + 1 + + + + + Copy + True + True + True + Duplicate this preset + copy-icon1 + True + + + False + True + 2 + + + + + Delete + True + True + True + Delete this preset + delete-icon1 + True + + + False + True + 3 + + + + + 1 + 0 + + + + + 100 + True + False + 0.5 + Rename + 13 + 1 + + + 0 + 1 + + + + + True + False + 0.5 + 6 + 7 + Autoload + 1 + + + 0 + 2 + + + + + True + True + Activate this to load the preset next time the device connects, or when the user logs in + start + center + + + 1 + 2 + + + + + True + False + 6 + + + True + True + + + True + True + 0 + + + + + True + True + True + Save the entered name + start + save-icon1 + + + False + True + 1 + + + + + 1 + 1 + + + + + + + + False + True + 1 + + + + + True + False + + + False + True + 5 + + + + + True + False + True + + + True + False + + + 200 + True + False + 18 + vertical + + + True + False + 18 + Input + + + + + + False + True + 0 + + + + + True + False + center + 18 + + + True + False + no input configured + True + + + False + True + 0 + + + + + False + 0.5 + (recording ...) + + + + + + False + True + 1 + + + + + False + False + 1 + + + + + 463 + True + False + center + 18 + 18 + 18 + 6 + True + + + Add + True + True + True + image3 + True + + + False + True + 1 + + + + + Record + True + True + True + Record a button of your device that should be remapped + image2 + True + + + False + True + 1 + + + + + Advanced + True + True + True + image1 + + + False + True + 2 + + + + + Delete + True + True + True + Delete this entry + icon-delete-row + True + + + + False + True + 4 + + + + + False + True + 2 + + + + + True + False + + + False + True + 3 + + + + + True + True + + + True + False + none + + + True + False + + + + + + + + True + True + 4 + + + + + True + True + 0 + + + + + True + False + + + False + True + 1 + + + + + False + True + 0 + + + + + True + False + 18 + 18 + 18 + 18 + vertical + + + True + False + 18 + Output + + + + + + False + True + 0 + + + + + True + False + 12 + + + 100 + True + False + 0.5 + Type + 1 + + + False + True + 0 + + + + + True + False + True + expand + + + Key or Macro + True + True + True + + + True + True + 0 + + + + + Analog Axis + True + True + True + + + True + True + end + 1 + + + + + True + True + 1 + + + + + False + True + 1 + + + + + True + False + The type of device this mapping is emulating. + 6 + 12 + + + 100 + True + False + 0.5 + Target + 1 + + + False + True + 0 + + + + + True + False + + + True + True + 1 + + + + + False + True + 2 + + + + + True + False + 6 + + + True + False + vertical + 6 + + + True + False + Available output axes are affected by the Target setting. + 12 + + + 100 + True + False + 0.5 + Output axis + 1 + + + False + True + 0 + + + + + True + False + + + True + True + 1 + + + + + False + True + 0 + + + + + True + False + + + True + False + vertical + True + + + True + False + 12 + + + True + True + deadzone-adjustment + 2 + 2 + + + True + True + end + 0 + + + + + 100 + True + False + 0.5 + Deadzone + 1 + + + False + True + 1 + + + + + False + True + 0 + + + + + True + False + 12 + + + True + True + gain-adjustment + 2 + 2 + + + True + True + end + 0 + + + + + 100 + True + False + 0.5 + Gain + 1 + + + False + True + 1 + + + + + True + True + 1 + + + + + True + False + 12 + + + True + True + expo-adjustment + 2 + 2 + + + True + True + end + 0 + + + + + 100 + True + False + 0.5 + Expo + 1 + + + False + True + 1 + + + + + True + True + 2 + + + + + True + True + 0 + + + + + True + False + 6 + vertical + + + 150 + 150 + True + False + center + center + 12 + 12 + 12 + 12 + + + False + True + 0 + + + + + + False + True + 1 + + + + + False + True + 1 + + + + + True + False + The Speed at which the Input is considered at maximum. +Only relevant when mapping relative inputs (e.g. mouse) to absolute outputs (e.g. gamepad) + 12 + + + 100 + True + False + 0.5 + Input cutoff + 1 + + + False + True + 0 + + + + + True + True + + + True + True + end + 1 + + + + + False + True + 4 + + + + + Analog Axis + Analog Axis + + + + + True + False + vertical + + + True + True + + + True + True + What should be written. For example KEY_A + start + immediate + word + 10 + 10 + 10 + 10 + True + 2 + True + + + + + + True + True + 0 + + + + + True + False + You can copy this text into the output + 0.5 + 12 + True + end + + + False + True + end + 1 + + + + + + Key or Macro + Key or Macro + 1 + + + + + False + False + 3 + + + + + False + True + 1 + + + + + True + True + 6 + + + + + Editor + Editor + 2 + + + + + True + True + 0 + + + + + True + False + + + False + True + 1 + + + + + True + False + 18 + + + False + 6 + 9 + dialog-warning + + + False + True + 0 + + + + + False + 6 + 9 + dialog-error + + + False + True + 1 + + + + + True + False + 0.5 + 7 + 7 + 7 + 7 + 6 + 6 + vertical + + + + True + True + 2 + + + + + False + True + 3 + + + + + + + True + False + Input Remapper + False + True + + + True + False + True + main_stack + + + + + True + True + True + Help + end + center + about-icon + True + + + + end + 1 + + + + + + + False + True + center-on-parent + input-remapper.svg + dialog + True + window + window + + + + True + False + + + True + False + center + 18 + 18 + vertical + 18 + + + True + False + input-remapper-large.png + + + False + True + 0 + + + + + True + False + Version unknown + center + + + False + True + 1 + + + + + True + True + 6 + 6 + 6 + 6 + You can find more information and report bugs at +<a href="https://github.com/sezanzeb/input-remapper">https://github.com/sezanzeb/input-remapper</a> + True + center + + + False + True + 2 + + + + + True + True + 0.5 + 6 + 6 + 6 + 6 + © 2025 Sezanzeb b8x45ygc9@mozmail.com +This program comes with absolutely no warranty. +See the <a href="https://www.gnu.org/licenses/gpl-3.0.html">GNU General Public License, version 3 or later</a> for details. + True + center + + + + False + True + 3 + + + + + About + About + + + + + 500 + 300 + True + True + + + True + False + + + True + False + 5 + 5 + 5 + 5 + 6 + vertical + 6 + + + True + False + See <a href="https://github.com/sezanzeb/input-remapper/blob/HEAD/readme/usage.md">usage.md</a> online on github for comprehensive information. + +A "key + key + ... + key" syntax can be used to trigger key combinations. For example "Control_L + a". + +Writing "disable" as a mapping disables a key. + +Macros allow multiple characters to be written with a single key-press. Information about programming them is available online on github. See <a href="https://github.com/sezanzeb/input-remapper/blob/HEAD/readme/macros.md">macros.md</a> and <a href="https://github.com/sezanzeb/input-remapper/blob/HEAD/readme/examples.md">examples.md</a> + True + True + 0 + + + False + True + 0 + + + + + + + + + Usage + Usage + 1 + + + + + True + False + 5 + 5 + 5 + 5 + 6 + vertical + 6 + + + True + False + Shortcuts only work while keys are not being recorded and the gui is in focus. + True + 0 + + + False + True + 0 + + + + + + True + False + 18 + + + True + False + ctrl + del + 0 + + + 0 + 0 + + + + + True + False + closes the application + 0 + + + 1 + 1 + + + + + True + False + ctrl + q + 0 + + + 0 + 1 + + + + + True + False + ctrl + r + 0 + + + 0 + 2 + + + + + True + False + refreshes the device list + 0 + + + 1 + 2 + + + + + True + False + stops the injection + 0 + + + 1 + 0 + + + + + False + False + 3 + + + + + Shortcuts + Shortcuts + 2 + + + + + + + True + False + True + + + True + False + stack1 + + + + + + + + + + 200 + 200 + False + Advanced + False + True + center-on-parent + True + window + window + + + True + False + + + 220 + True + True + + + True + False + none + + + True + False + browse + + + + + + + False + True + 0 + + + + + True + False + + + False + True + 1 + + + + + + True + False + start + 18 + 18 + 6 + 12 + True + + + True + False + 0.5 + 18 + Release timeout + 1 + + + 0 + 2 + + + + + True + True + 18 + 5 + 3 + + + 1 + 2 + + + + + True + False + Release all inputs which are part of the combination before the mapping is injected + 0.5 + 18 + Release input + 1 + + + 0 + 1 + + + + + True + True + start + center + True + + + 1 + 1 + + + + + True + False + General + + + 0 + 0 + 2 + + + + + True + False + center + 18 + 18 + + + 0 + 3 + 2 + + + + + True + False + Event Specific + + + 0 + 4 + 2 + + + + + True + False + 0.5 + 18 + Remove this input + 1 + + + 0 + 5 + + + + + True + True + True + start + image4 + + + 1 + 5 + + + + + True + False + Map this input to an Analog Axis. Only possible for analog inputs and mice. + 0.5 + 18 + True + Use as analog + 1 + + + 0 + 6 + + + + + True + True + start + center + + + 1 + 6 + + + + + True + False + 0.5 + 18 + Trigger threshold + 1 + + + 0 + 7 + + + + + True + True + 18 + 5 + number + 1 + True + True + + + 1 + 7 + + + + + False + True + 2 + + + + + + diff --git a/data/input-remapper.policy b/data/input-remapper.policy new file mode 100644 index 0000000..439e9f2 --- /dev/null +++ b/data/input-remapper.policy @@ -0,0 +1,21 @@ + + + + + Run Input Remapper as root + Authentication is required to discover and read devices. + Vyžaduje sa prihlásenie na objavenie a prístup k zariadeniam. + Потрібна автентифікація для виявлення та читання пристроїв. + Требуется аутентификация для обнаружения и чтения устройств. + + no + auth_admin_keep + auth_admin_keep + + /usr/bin/input-remapper-control + false + + diff --git a/data/input-remapper.service b/data/input-remapper.service new file mode 100644 index 0000000..1b02132 --- /dev/null +++ b/data/input-remapper.service @@ -0,0 +1,14 @@ +[Unit] +Description=Service to inject keycodes without the GUI application +# dbus is required for ipc between gui and input-remapper-control +Requires=dbus.service +After=dbus.service + +[Service] +Type=dbus +BusName=inputremapper.Control +ExecStart=/usr/bin/input-remapper-service + +[Install] +WantedBy=default.target +Alias=input-remapper.service diff --git a/data/input-remapper.svg b/data/input-remapper.svg new file mode 100644 index 0000000..c3e38f6 --- /dev/null +++ b/data/input-remapper.svg @@ -0,0 +1,216 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + diff --git a/data/inputremapper.Control.conf b/data/inputremapper.Control.conf new file mode 100644 index 0000000..901d088 --- /dev/null +++ b/data/inputremapper.Control.conf @@ -0,0 +1,9 @@ + + + + + + + + diff --git a/data/io.github.sezanzeb.input_remapper.metainfo.xml b/data/io.github.sezanzeb.input_remapper.metainfo.xml new file mode 100644 index 0000000..bdd5330 --- /dev/null +++ b/data/io.github.sezanzeb.input_remapper.metainfo.xml @@ -0,0 +1,44 @@ + + + + io.github.sezanzeb.input_remapper + + Input Remapper + An easy to use tool to change the mapping of your input device buttons + + CC0-1.0 + GPL-3.0-or-later + + + pointing + keyboard + gamepad + + + +

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

+
+ + input-remapper-gtk.desktop + + + + Defining a new mapping + https://raw.githubusercontent.com/sezanzeb/input-remapper/main/readme/screenshot.png + + + https://raw.githubusercontent.com/sezanzeb/input-remapper/main/readme/screenshot_2.png + + + + https://github.com/sezanzeb/input-remapper + + + + + + + +
diff --git a/data/style.css b/data/style.css new file mode 100644 index 0000000..cd716ce --- /dev/null +++ b/data/style.css @@ -0,0 +1,50 @@ +.status_bar frame { + /* the status bar is ugly in elementary os otherwise */ + border: 0px; +} + +.transparent { + background: transparent; +} + +.copyright { + font-size: 7pt; +} + +.autocompletion label { + padding: 11px; +} + +.autocompletion { + padding: 0px; + box-shadow: none; +} + +.no-v-padding { + padding-top: 0; + padding-bottom: 0; +} + +.transformation-draw-area { + border: 1px solid @borders; + border-radius: 6px; + background: @theme_base_color; +} + +.multiline > *:first-child { + /* source view suddenly started showing a white background behind line-numbers */ + /* solution found by furiously trying css rules out in the gtk inspector */ + background: @theme_bg_color; +} + +.opaque-text text { + /* found by roaming through /usr/share/themes, + and some experimentation in the gnome inspector */ + color: alpha(currentColor, 0.5); +} + +/* +@theme_bg_color +@theme_selected_bg_color +@theme_base_color +*/ diff --git a/inputremapper/__init__.py b/inputremapper/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/inputremapper/bin/__init__.py b/inputremapper/bin/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/inputremapper/bin/input_remapper_control.py b/inputremapper/bin/input_remapper_control.py new file mode 100755 index 0000000..74629cb --- /dev/null +++ b/inputremapper/bin/input_remapper_control.py @@ -0,0 +1,429 @@ +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + +"""Control the dbus service from the command line.""" + +import argparse +import logging +import os +import subprocess +import sys +from enum import Enum +from typing import Optional + +import gi + +gi.require_version("GLib", "2.0") +from gi.repository import GLib + +from inputremapper.configs.global_config import GlobalConfig +from inputremapper.configs.migrations import Migrations +from inputremapper.injection.global_uinputs import GlobalUInputs, FrontendUInput +from inputremapper.logging.logger import logger +from inputremapper.user import UserUtils + + +class Commands(Enum): + AUTOLOAD = "autoload" + START = "start" + STOP = "stop" + STOP_ALL = "stop-all" + HELLO = "hello" + QUIT = "quit" + + +class Internals(Enum): + # internal stuff that the gui uses + START_DAEMON = "start-daemon" + START_READER_SERVICE = "start-reader-service" + + +class Options: + command: str + config_dir: str + preset: str + device: str + list_devices: bool + key_names: str + debug: bool + version: str + + +class InputRemapperControlBin: + def __init__( + self, + global_config: GlobalConfig, + migrations: Migrations, + ): + self.global_config = global_config + self.migrations = migrations + + @staticmethod + def main(options: Options) -> None: + global_config = GlobalConfig() + global_uinputs = GlobalUInputs(FrontendUInput) + migrations = Migrations(global_uinputs) + input_remapper_control = InputRemapperControlBin( + global_config, + migrations, + ) + + if options.debug: + logger.update_verbosity(True) + + if options.version: + logger.log_info() + return + + logger.debug('Call for "%s"', sys.argv) + + boot_finished_ = input_remapper_control.boot_finished() + is_root = UserUtils.user == "root" + is_autoload = options.command == Commands.AUTOLOAD + config_dir_set = options.config_dir is not None + if is_autoload and not boot_finished_ and is_root and not config_dir_set: + # this is probably happening during boot time and got + # triggered by udev. There is no need to try to inject anything if the + # service doesn't know where to look for a config file. This avoids a lot + # of confusing service logs. And also avoids potential for problems when + # input-remapper-control stresses about evdev, dbus and multiprocessing already + # while the system hasn't even booted completely. + logger.warning("Skipping autoload command without a logged in user") + return + + if options.command is not None: + if options.command in [command.value for command in Internals]: + input_remapper_control.internals(options.command, options.debug) + elif options.command in [command.value for command in Commands]: + from inputremapper.daemon import Daemon + + daemon = Daemon.connect(fallback=False) + + input_remapper_control.set_daemon(daemon) + + input_remapper_control.communicate( + options.command, + options.device, + options.config_dir, + options.preset, + ) + else: + logger.error('Unknown command "%s"', options.command) + else: + if options.list_devices: + input_remapper_control.list_devices() + + if options.key_names: + input_remapper_control.list_key_names() + + if options.command: + logger.info("Done") + + def list_devices(self): + logger.setLevel(logging.ERROR) + from inputremapper.groups import groups + + for group in groups.get_groups(): + print(group.key) + + def list_key_names(self): + from inputremapper.configs.keyboard_layout import keyboard_layout + + print("\n".join(keyboard_layout.list_names())) + + def communicate( + self, + command: str, + device: str, + config_dir: Optional[str], + preset: str, + ) -> None: + """Commands that require a running daemon.""" + if self.daemon is None: + # probably broken tests + logger.error("Daemon missing") + sys.exit(5) + + if config_dir is not None: + self._load_config(config_dir) + + self.ensure_migrated() + + if command == Commands.AUTOLOAD.value: + self._autoload(device) + + if command == Commands.START.value: + self._start(device, preset) + + if command == Commands.STOP.value: + self._stop(device) + + if command == Commands.STOP_ALL.value: + self.daemon.stop_all() + + if command == Commands.HELLO.value: + self._hello() + + if command == Commands.QUIT.value: + self._quit() + + def _hello(self): + response = self.daemon.hello("hello") + logger.info('Daemon answered with "%s"', response) + + def _load_config(self, config_dir: str) -> None: + path = os.path.abspath( + os.path.expanduser(os.path.join(config_dir, "config.json")) + ) + if not os.path.exists(path): + logger.error('"%s" does not exist', path) + sys.exit(6) + + logger.info('Using config from "%s" instead', path) + self.global_config.load_config(path) + + def ensure_migrated(self) -> None: + # import stuff late to make sure the correct log level is applied + # before anything is logged + # TODO since imports shouldn't run any code, this is fixed by moving towards DI + from inputremapper.user import UserUtils + + if UserUtils.user != "root": + # Might be triggered by udev, so skip the root user. + # This will also refresh the config of the daemon if the user changed + # it in the meantime. + # config_dir is either the cli arg or the default path in home + config_dir = os.path.dirname(self.global_config.path) + self.daemon.set_config_dir(config_dir) + self.migrations.migrate() + + def _stop(self, device: str) -> None: + group = self._load_group(device) + self.daemon.stop_injecting(group.key) + + def _quit(self) -> None: + try: + self.daemon.quit() + except GLib.GError as error: + if "NoReply" in str(error): + # The daemon is expected to terminate, so there won't be a reply. + return + + raise + + def _start(self, device: str, preset: str) -> None: + group = self._load_group(device) + + logger.info( + 'Starting injection: "%s", "%s"', + device, + preset, + ) + + self.daemon.start_injecting(group.key, preset) + + def _load_group(self, device: str): + """Load groups, check if a group exists for the given device and return it. + Crash if it can't be found. + + device can be either a path like "/dev/input/event1" or a group key like + "USB Optical Mouse", + """ + + # import stuff late to make sure the correct log level is applied + # before anything is logged + # TODO since imports shouldn't run any code, this is fixed by moving towards DI + from inputremapper.groups import groups + + if device is None: + logger.error("--device missing") + sys.exit(3) + + # groups.find would also refresh it, but explicit is better than implicit + groups.refresh() + + if device.startswith("/dev"): + group = groups.find(path=device) + else: + group = groups.find(key=device) + + if group is None: + logger.error( + 'Device "%s" is unknown, not an appropriate input device or you are ' + "missing permissions", + device, + ) + sys.exit(4) + + return group + + def _autoload(self, device: str) -> None: + # if device was specified, autoload for that one. if None autoload + # for all devices. + if device is None: + logger.info("Autoloading all") + self.daemon.autoload(timeout=10000) + else: + group = self._load_group(device) + logger.info( + 'Asking daemon to autoload for device "%s", group "%s"', + device, + group.key, + ) + self.daemon.autoload_single(group.key, timeout=2000) + + def internals(self, command: str, debug: bool) -> None: + """Methods that are needed to get the gui to work and that require root. + + input-remapper-control should be started with sudo or pkexec for this. + """ + debug_flag = " -d" if debug else "" + + if command == Internals.START_READER_SERVICE.value: + cmd = f"input-remapper-reader-service{debug_flag}" + elif command == Internals.START_DAEMON.value: + cmd = f"input-remapper-service --hide-info{debug_flag}" + else: + return + + # daemonize + cmd = f"{cmd} &" + logger.debug(f"Running `{cmd}`") + os.system(cmd) + + def _num_logged_in_users(self) -> int: + """Check how many users are logged in.""" + who = subprocess.run(["who"], stdout=subprocess.PIPE).stdout.decode() + return len([user for user in who.split("\n") if user.strip() != ""]) + + def _is_systemd_finished(self) -> bool: + """Check if systemd finished booting.""" + try: + systemd_analyze = subprocess.run( + ["systemd-analyze"], stdout=subprocess.PIPE + ) + except FileNotFoundError: + # probably not systemd, lets assume true to not block input-remapper for good + # on certain installations + return True + + if "finished" in systemd_analyze.stdout.decode(): + # it writes into stderr otherwise or something + return True + + return False + + def boot_finished(self) -> bool: + """Check if booting is completed.""" + # Get as much information as needed to really safely determine if booting up is + # complete. + # - `who` returns an empty list on some system for security purposes + # - something might be broken and might make systemd_analyze fail: + # Bootup is not yet finished + # (org.freedesktop.systemd1.Manager.FinishTimestampMonotonic=0). + # Please try again later. + # Hint: Use 'systemctl list-jobs' to see active jobs + if self._is_systemd_finished(): + logger.debug("System is booted") + return True + + if self._num_logged_in_users() > 0: + logger.debug("User(s) logged in") + return True + + return False + + def set_daemon(self, daemon): + # TODO DI? + self.daemon = daemon + + @staticmethod + def parse_args() -> Options: + parser = argparse.ArgumentParser() + parser.add_argument( + "--command", + action="store", + dest="command", + help=( + "Communicate with the daemon. Available commands are " + f"{', '.join([command.value for command in Commands])}" + ), + default=None, + metavar="NAME", + ) + parser.add_argument( + "--config-dir", + action="store", + dest="config_dir", + help=( + "path to the config directory containing config.json, " + "xmodmap.json and the presets folder. " + "defaults to ~/.config/input-remapper/" + ), + default=None, + metavar="PATH", + ) + parser.add_argument( + "--preset", + action="store", + dest="preset", + help="The filename of the preset without the .json extension.", + default=None, + metavar="NAME", + ) + parser.add_argument( + "--device", + action="store", + dest="device", + help="One of the device keys from --list-devices", + default=None, + metavar="NAME", + ) + parser.add_argument( + "--list-devices", + action="store_true", + dest="list_devices", + help="List available device keys and exit", + default=False, + ) + parser.add_argument( + "--symbol-names", + action="store_true", + dest="key_names", + help="Print all available names for the preset", + default=False, + ) + parser.add_argument( + "-d", + "--debug", + action="store_true", + dest="debug", + help="Displays additional debug information", + default=False, + ) + parser.add_argument( + "-v", + "--version", + action="store_true", + dest="version", + help="Print the version and exit", + default=False, + ) + + return parser.parse_args(sys.argv[1:]) # type: ignore diff --git a/inputremapper/bin/input_remapper_gtk.py b/inputremapper/bin/input_remapper_gtk.py new file mode 100755 index 0000000..a602d0f --- /dev/null +++ b/inputremapper/bin/input_remapper_gtk.py @@ -0,0 +1,152 @@ +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + +"""Starts the user interface.""" + +from __future__ import annotations + +import atexit +import sys +from argparse import ArgumentParser +from typing import Tuple + +import gi + +from inputremapper.bin.process_utils import ProcessUtils + +gi.require_version("Gtk", "3.0") +gi.require_version("GLib", "2.0") +gi.require_version("GtkSource", "4") +from gi.repository import Gtk + +# https://github.com/Nuitka/Nuitka/issues/607#issuecomment-650217096 +Gtk.init() + +from inputremapper.gui.gettext import _, LOCALE_DIR +from inputremapper.gui.reader_service import ReaderService +from inputremapper.daemon import DaemonProxy, Daemon +from inputremapper.logging.logger import logger +from inputremapper.gui.messages.message_broker import MessageBroker, MessageType +from inputremapper.configs.keyboard_layout import keyboard_layout +from inputremapper.gui.data_manager import DataManager +from inputremapper.gui.user_interface import UserInterface +from inputremapper.gui.controller import Controller +from inputremapper.injection.global_uinputs import GlobalUInputs, FrontendUInput +from inputremapper.groups import _Groups +from inputremapper.gui.reader_client import ReaderClient +from inputremapper.configs.global_config import GlobalConfig +from inputremapper.configs.migrations import Migrations + + +class InputRemapperGtkBin: + @staticmethod + def main() -> Tuple[ + UserInterface, + Controller, + DataManager, + MessageBroker, + DaemonProxy, + GlobalConfig, + ]: + parser = ArgumentParser() + parser.add_argument( + "-d", + "--debug", + action="store_true", + dest="debug", + help=_("Displays additional debug information"), + default=False, + ) + + options = parser.parse_args(sys.argv[1:]) + logger.update_verbosity(options.debug) + logger.log_info("input-remapper-gtk") + logger.debug("Using locale directory: {}".format(LOCALE_DIR)) + + global_uinputs = GlobalUInputs(FrontendUInput) + + migrations = Migrations(global_uinputs) + migrations.migrate() + + message_broker = MessageBroker() + + global_config = GlobalConfig() + + # Create the ReaderClient before we start the reader-service, otherwise the + # privileged service creates and owns those pipes, and then they cannot be accessed + # by the user. + reader_client = ReaderClient(message_broker, _Groups()) + + if ProcessUtils.count_python_processes("input-remapper-gtk") >= 2: + logger.warning( + "Another input-remapper GUI is already running. " + "This can cause problems while recording keys" + ) + + InputRemapperGtkBin.start_reader_service() + + daemon = Daemon.connect() + + data_manager = DataManager( + message_broker, + global_config, + reader_client, + daemon, + global_uinputs, + keyboard_layout, + ) + controller = Controller(message_broker, data_manager) + user_interface = UserInterface(message_broker, controller) + controller.set_gui(user_interface) + + message_broker.signal(MessageType.init) + + atexit.register(lambda: InputRemapperGtkBin.stop(daemon, controller)) + + Gtk.main() + + # For tests: + return ( + user_interface, + controller, + data_manager, + message_broker, + daemon, + global_config, + ) + + @staticmethod + def start_reader_service(): + if ProcessUtils.count_python_processes("input-remapper-reader-service") >= 1: + logger.info("Found an input-remapper-reader-service to already be running") + return + + try: + ReaderService.pkexec_reader_service() + except Exception as e: + logger.error(e) + sys.exit(11) + + @staticmethod + def stop(daemon, controller): + if isinstance(daemon, Daemon): + # have fun debugging completely unrelated tests if you remove this + daemon.stop_all() + + controller.close() diff --git a/inputremapper/bin/input_remapper_reader_service.py b/inputremapper/bin/input_remapper_reader_service.py new file mode 100755 index 0000000..b0a298c --- /dev/null +++ b/inputremapper/bin/input_remapper_reader_service.py @@ -0,0 +1,80 @@ +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + +"""Starts the root reader-service.""" + +import asyncio +import atexit +import os +import signal +import sys +import time +from argparse import ArgumentParser + +from inputremapper.bin.process_utils import ProcessUtils +from inputremapper.groups import _Groups +from inputremapper.gui.reader_service import ReaderService +from inputremapper.injection.global_uinputs import GlobalUInputs, FrontendUInput +from inputremapper.logging.logger import logger + + +class InputRemapperReaderServiceBin: + @staticmethod + def main() -> None: + parser = ArgumentParser() + parser.add_argument( + "-d", + "--debug", + action="store_true", + dest="debug", + help="Displays additional debug information", + default=False, + ) + + options = parser.parse_args(sys.argv[1:]) + + logger.update_verbosity(options.debug) + + if ProcessUtils.count_python_processes("input-remapper-reader-service") >= 2: + logger.warning( + "Another input-remapper-reader-service process is already running. " + "This can cause problems while recording keys" + ) + + if os.getuid() != 0: + logger.warning("The reader-service usually needs elevated privileges") + + if not ReaderService.pipes_exist(): + logger.info("Waiting for pipes to be created by the GUI") + while not ReaderService.pipes_exist(): + time.sleep(0.5) + logger.debug("Waiting...") + + def on_exit(): + """Don't remain idle and alive when the GUI exits via ctrl+c.""" + # makes no sense to me, but after the keyboard interrupt it is still + # waiting for an event to complete (`S` in `ps ax`), even when using + # sys.exit + os.kill(os.getpid(), signal.SIGKILL) + + atexit.register(on_exit) + groups = _Groups() + global_uinputs = GlobalUInputs(FrontendUInput) + reader_service = ReaderService(groups, global_uinputs) + asyncio.run(reader_service.run()) diff --git a/inputremapper/bin/input_remapper_service.py b/inputremapper/bin/input_remapper_service.py new file mode 100755 index 0000000..586f51b --- /dev/null +++ b/inputremapper/bin/input_remapper_service.py @@ -0,0 +1,71 @@ +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + +"""Starts injecting keycodes based on the configuration.""" + +import sys +import multiprocessing +from argparse import ArgumentParser + +from inputremapper.configs.global_config import GlobalConfig +from inputremapper.injection.global_uinputs import GlobalUInputs, UInput +from inputremapper.injection.mapping_handlers.mapping_parser import MappingParser +from inputremapper.logging.logger import logger + + +class InputRemapperServiceBin: + @staticmethod + def main() -> None: + parser = ArgumentParser() + parser.add_argument( + "-d", + "--debug", + action="store_true", + dest="debug", + help="Displays additional debug information", + default=False, + ) + parser.add_argument( + "--hide-info", + action="store_true", + dest="hide_info", + help="Don't display version information", + default=False, + ) + + options = parser.parse_args(sys.argv[1:]) + + # Python 3.14 compatibility + multiprocessing.set_start_method("fork") + + logger.update_verbosity(options.debug) + + # import input-remapper stuff after setting the log verbosity + from inputremapper.daemon import Daemon + + if not options.hide_info: + logger.log_info("input-remapper-service") + + global_config = GlobalConfig() + global_uinputs = GlobalUInputs(UInput) + mapping_parser = MappingParser(global_uinputs) + + daemon = Daemon(global_config, global_uinputs, mapping_parser) + daemon.publish() + daemon.run() diff --git a/inputremapper/bin/process_utils.py b/inputremapper/bin/process_utils.py new file mode 100644 index 0000000..a0ac0db --- /dev/null +++ b/inputremapper/bin/process_utils.py @@ -0,0 +1,39 @@ +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + +import psutil + + +class ProcessUtils: + @staticmethod + def count_python_processes(name: str) -> int: + # This is somewhat complicated, because there might also be a "sudo " + # process. + count = 0 + pids = psutil.pids() + for pid in pids: + try: + process = psutil.Process(pid) + cmdline = process.cmdline() + if len(cmdline) >= 2 and "python" in cmdline[0] and name in cmdline[1]: + count += 1 + except Exception: + pass + + return count diff --git a/inputremapper/configs/__init__.py b/inputremapper/configs/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/inputremapper/configs/data.py b/inputremapper/configs/data.py new file mode 100644 index 0000000..5d7e345 --- /dev/null +++ b/inputremapper/configs/data.py @@ -0,0 +1,33 @@ +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + + +"""Get stuff from /usr/share/input-remapper, depending on the prefix.""" + + +import os + +from inputremapper.installation_info import DATA_DIR + + +def get_data_path(filename=""): + """Depending on the installation prefix, return the data dir.""" + # This was more complicated in the past. This wrapper is kept for now, but feel + # free to remove it at a later point. + return os.path.join(DATA_DIR, filename) diff --git a/inputremapper/configs/global_config.py b/inputremapper/configs/global_config.py new file mode 100644 index 0000000..6964134 --- /dev/null +++ b/inputremapper/configs/global_config.py @@ -0,0 +1,142 @@ +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . +"""Store which presets should be enabled for which device on login.""" + +from __future__ import annotations + +import copy +import json +import os +from typing import Optional + +from inputremapper.configs.paths import PathUtils +from inputremapper.logging.logger import logger, VERSION +from inputremapper.user import UserUtils + +MOUSE = "mouse" +WHEEL = "wheel" +BUTTONS = "buttons" +NONE = "none" + +INITIAL_CONFIG = { + "version": VERSION, + "autoload": {}, +} + + +class GlobalConfig: + """Configures stuff like autoloading in ~/.config/input-remapper-2/config.json.""" + + def __init__(self): + self.path = os.path.join(PathUtils.config_path(), "config.json") + self._config = copy.deepcopy(INITIAL_CONFIG) + + def get_dir(self) -> str: + """The folder containing this config.""" + return os.path.split(self.path)[0] + + def get_autoload_preset(self, group_key: str) -> Optional[str]: + # modifications are only allowed via the setter, because it needs to write + # the config file too. Therefore return a copy to prevent inconsistencies. + return copy.deepcopy(self._config["autoload"].get(group_key)) + + def set_autoload_preset(self, group_key: str, preset: Optional[str]): + """Set a preset to be automatically applied on start. + + Parameters + ---------- + group_key + the unique identifier of the group. This is used instead of the + name to enable autoloading two different presets when two similar + devices are connected. + preset + if None, don't autoload something for this device. + """ + if preset is not None: + self._config["autoload"][group_key] = preset + else: + logger.info('Not injecting for "%s" automatically anmore', group_key) + del self._config["autoload"][group_key] + + self._save_config() + + def iterate_autoload_presets(self): + """Get tuples of (device, preset).""" + return self._config.get("autoload", {}).items() + + def is_autoloaded(self, group_key: Optional[str], preset: Optional[str]): + """Should this preset be loaded automatically?""" + if group_key is None or preset is None: + raise ValueError("Expected group_key and preset to not be None") + + return self._config.get("autoload", {}).get(group_key) == preset + + def load_config(self, path: Optional[str] = None): + """Load the config from the file system. + Parameters + ---------- + path + If set, will change the path to load from and save to. + """ + if path is not None: + if not os.path.exists(path): + logger.error('Config at "%s" not found', path) + return + + self.path = path + + self._clear_config() + + if not os.path.exists(self.path): + # treated like an empty config + logger.debug('Config "%s" doesn\'t exist yet', self.path) + self._clear_config() + self._config = copy.deepcopy(INITIAL_CONFIG) + self._save_config() + return + + with open(self.path, "r") as file: + try: + self._config.update(json.load(file)) + logger.info('Loaded config from "%s"', self.path) + except json.decoder.JSONDecodeError as error: + logger.error( + 'Failed to parse config "%s": %s. Using defaults', + self.path, + str(error), + ) + # uses the default configuration when the config object + # is empty automatically + + def _save_config(self): + """Save the config to the file system.""" + if UserUtils.user == "root": + logger.debug("Skipping config file creation for the root user") + return + + PathUtils.touch(self.path) + + with open(self.path, "w") as file: + json.dump(self._config, file, indent=4) + logger.info("Saved config to %s", self.path) + file.write("\n") + + def _clear_config(self): + """Remove all configurations in memory.""" + self._config = {} diff --git a/inputremapper/configs/input_config.py b/inputremapper/configs/input_config.py new file mode 100644 index 0000000..a99f810 --- /dev/null +++ b/inputremapper/configs/input_config.py @@ -0,0 +1,485 @@ +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + +from __future__ import annotations + +import itertools +from typing import Tuple, Iterable, Union, List, Dict, Optional, Hashable + +from evdev import ecodes +from inputremapper.configs.paths import PathUtils +from inputremapper.input_event import InputEvent + +try: + from pydantic.v1 import BaseModel, root_validator, validator +except ImportError: + from pydantic import BaseModel, root_validator, validator + +from inputremapper.configs.keyboard_layout import keyboard_layout +from inputremapper.gui.messages.message_types import MessageType +from inputremapper.logging.logger import logger +from inputremapper.utils import get_evdev_constant_name, DeviceHash + +# having shift in combinations modifies the configured output, +# ctrl might not work at all +DIFFICULT_COMBINATIONS = [ + ecodes.KEY_LEFTSHIFT, + ecodes.KEY_RIGHTSHIFT, + ecodes.KEY_LEFTCTRL, + ecodes.KEY_RIGHTCTRL, + ecodes.KEY_LEFTALT, + ecodes.KEY_RIGHTALT, +] + +EMPTY_TYPE = 99 + +# "Button 11" (10 + 0x110 "BTN_LEFT") is the highest mouse button simulatable +# at time of writing using Piper 0.8 and a Logi G502X. +# Please update if a way to simulate higher button-presses is found. +MAX_BTN_MOUSE_ECODE = 0x11A + +# Beware, this is without sign! Movement to the left needs this to be negative +DEFAULT_ABS_ANALOG_THRESHOLD_MAGNITUDE = 30 + + +class InputConfig(BaseModel): + """Describes a single input within a combination, to configure mappings.""" + + message_type = MessageType.selected_event + + type: int + code: int + + # origin_hash is a hash to identify a specific /dev/input/eventXX device. + # This solves a number of bugs when multiple devices have overlapping capabilities. + # see utils.get_device_hash for the exact hashing function + origin_hash: Optional[DeviceHash] = None + + # At which point is an analog input treated as "pressed". In percent (-99 to 99) + # of the delta between a resting joystick and a maxed-out joystick. + # Should be None if no analog input is configured. + analog_threshold: Optional[int] = None + + def __str__(self): + return f"InputConfig {get_evdev_constant_name(self.type, self.code)}" + + def __repr__(self): + return ( + f"" + ) + + @property + def input_match_hash(self) -> Hashable: + """a Hashable object which is intended to match the InputConfig with a + InputEvent. + + InputConfig itself is hashable, but can not be used to match InputEvent's + because its hash includes the analog_threshold + """ + return self.type, self.code, self.origin_hash + + @property + def is_empty(self) -> bool: + return self.type == EMPTY_TYPE + + @property + def defines_analog_input(self) -> bool: + """Whether this defines an analog input.""" + return not self.analog_threshold and self.type != ecodes.EV_KEY + + @property + def type_and_code(self) -> Tuple[int, int]: + """Event type, code.""" + return self.type, self.code + + @classmethod + def btn_left(cls): + return cls(type=ecodes.EV_KEY, code=ecodes.BTN_LEFT) + + @classmethod + def from_input_event(cls, event: InputEvent) -> InputConfig: + """create an input confing from the given InputEvent, uses the value as + analog threshold""" + return cls( + type=event.type, + code=event.code, + origin_hash=event.origin_hash, + analog_threshold=event.value, + ) + + def description(self, exclude_threshold=False, exclude_direction=False) -> str: + """Get a human-readable description of the event.""" + return ( + f"{self._get_name()} " + f"{self._get_direction() if not exclude_direction else ''} " + f"{self._get_threshold_value() if not exclude_threshold else ''}".strip() + ) + + def _get_mouse_button_name(self) -> Optional[str]: + """Get a human-readable description of a mouse-button. Only the first 7 + mouse buttons are in evdev and they often have misleading names there + (eg it calls buttons 6 & 7 forward/back but usually that's buttons 5 & 4). + Returns None if not a mouse button.""" + + if self.type == ecodes.EV_KEY: + if ecodes.BTN_MOUSE <= self.code <= ecodes.BTN_MIDDLE: + # button is left/right/middle button + key_name: str = get_evdev_constant_name(self.type, self.code) + return key_name.replace( + "BTN_", "Mouse Button " + ) # eg "Mouse Button LEFT" + elif ecodes.BTN_MIDDLE < self.code <= MAX_BTN_MOUSE_ECODE: + # button is a higher-number mouse button like side-buttons. + # This calculation assumes left mouse button is button 1, so side buttons start at 4. + button_number: int = self.code - ecodes.BTN_MOUSE + 1 + return f"Mouse Button {button_number}" # eg "Mouse Button 7" + + return None + + def _get_name(self) -> Optional[str]: + """Human-readable name (e.g. KEY_A) of the specified input event.""" + + # prevent logging warnings for new/empty configs + if self.is_empty: + return None + + # must check if it's a mouse button *before* ecodes + # because not all mouse buttons are in ecodes. + mouse_button_name: Optional[str] = self._get_mouse_button_name() + if mouse_button_name is not None: + return mouse_button_name + + if self.type not in ecodes.bytype: + logger.warning("Unknown type for %s", self) + return f"unknown {self.type, self.code}" + + if self.code not in ecodes.bytype[self.type]: + logger.warning("Unknown code for %s", self) + return f"unknown {self.type, self.code}" + + key_name = None + + # first try to find the name in xmodmap to not display wrong + # names due to the keyboard layout + if self.type == ecodes.EV_KEY: + key_name = keyboard_layout.get_name(self.code) + + if key_name is None: + # if no result, look in the linux combination constants. On a german + # keyboard for example z and y are switched, which will therefore + # cause the wrong letter to be displayed. + key_name = get_evdev_constant_name(self.type, self.code) + + key_name = key_name.replace("ABS_Z", "Trigger Left") + key_name = key_name.replace("ABS_RZ", "Trigger Right") + + key_name = key_name.replace("ABS_HAT0X", "DPad-X") + key_name = key_name.replace("ABS_HAT0Y", "DPad-Y") + key_name = key_name.replace("ABS_HAT1X", "DPad-2-X") + key_name = key_name.replace("ABS_HAT1Y", "DPad-2-Y") + key_name = key_name.replace("ABS_HAT2X", "DPad-3-X") + key_name = key_name.replace("ABS_HAT2Y", "DPad-3-Y") + + key_name = key_name.replace("ABS_X", "Joystick-X") + key_name = key_name.replace("ABS_Y", "Joystick-Y") + key_name = key_name.replace("ABS_RX", "Joystick-RX") + key_name = key_name.replace("ABS_RY", "Joystick-RY") + + key_name = key_name.replace("BTN_", "Button ") + key_name = key_name.replace("KEY_", "") + + key_name = key_name.replace("REL_", "") + key_name = key_name.replace("HWHEEL", "Wheel") + key_name = key_name.replace("WHEEL", "Wheel") + + key_name = key_name.replace("_", " ") + key_name = key_name.replace(" ", " ") + return key_name + + def _get_direction(self) -> str: + """human-readable direction description for the analog_threshold""" + if self.type == ecodes.EV_KEY or self.defines_analog_input: + return "" + + assert self.analog_threshold + threshold_direction = self.analog_threshold // abs(self.analog_threshold) + return { + # D-Pad + (ecodes.ABS_HAT0X, -1): "Left", + (ecodes.ABS_HAT0X, 1): "Right", + (ecodes.ABS_HAT0Y, -1): "Up", + (ecodes.ABS_HAT0Y, 1): "Down", + (ecodes.ABS_HAT1X, -1): "Left", + (ecodes.ABS_HAT1X, 1): "Right", + (ecodes.ABS_HAT1Y, -1): "Up", + (ecodes.ABS_HAT1Y, 1): "Down", + (ecodes.ABS_HAT2X, -1): "Left", + (ecodes.ABS_HAT2X, 1): "Right", + (ecodes.ABS_HAT2Y, -1): "Up", + (ecodes.ABS_HAT2Y, 1): "Down", + # joystick + (ecodes.ABS_X, 1): "Right", + (ecodes.ABS_X, -1): "Left", + (ecodes.ABS_Y, 1): "Down", + (ecodes.ABS_Y, -1): "Up", + (ecodes.ABS_RX, 1): "Right", + (ecodes.ABS_RX, -1): "Left", + (ecodes.ABS_RY, 1): "Down", + (ecodes.ABS_RY, -1): "Up", + # wheel + (ecodes.REL_WHEEL, -1): "Down", + (ecodes.REL_WHEEL, 1): "Up", + (ecodes.REL_HWHEEL, -1): "Left", + (ecodes.REL_HWHEEL, 1): "Right", + }.get((self.code, threshold_direction)) or ( + "+" if threshold_direction > 0 else "-" + ) + + def _get_threshold_value(self) -> str: + """human-readable value of the analog_threshold e.g. '20%'""" + if self.analog_threshold is None: + return "" + return { + ecodes.EV_REL: f"{abs(self.analog_threshold)}", + ecodes.EV_ABS: f"{abs(self.analog_threshold)}%", + }.get(self.type) or "" + + def modify( + self, + type_: Optional[int] = None, + code: Optional[int] = None, + origin_hash: Optional[str] = None, + analog_threshold: Optional[int] = None, + ) -> InputConfig: + """Return a new modified event.""" + return InputConfig( + type=type_ if type_ is not None else self.type, + code=code if code is not None else self.code, + origin_hash=origin_hash if origin_hash is not None else self.origin_hash, + analog_threshold=( + analog_threshold + if analog_threshold is not None + else self.analog_threshold + ), + ) + + def __hash__(self): + return hash((self.type, self.code, self.origin_hash, self.analog_threshold)) + + @validator("analog_threshold") + def _ensure_analog_threshold_is_none(cls, analog_threshold): + """ensure the analog threshold is None, not zero.""" + if analog_threshold == 0 or analog_threshold is None: + # the sign of the threshold defines the direction the joystick has to move. + # 0 is invalid, it has no sign. And for triggers, what does a threshold of + # 0 mean? Would it always be active? + return None + + return analog_threshold + + @root_validator + def _remove_analog_threshold_for_key_input(cls, values): + """remove the analog threshold if the type is a EV_KEY""" + type_ = values.get("type") + if type_ == ecodes.EV_KEY: + values["analog_threshold"] = None + return values + + @root_validator(pre=True) + def validate_origin_hash(cls, values): + origin_hash = values.get("origin_hash") + if origin_hash is None: + # For new presets, origin_hash should be set. For old ones, it can + # be still missing. A lot of tests didn't set an origin_hash. + if values.get("type") != EMPTY_TYPE: + logger.warning("No origin_hash set for %s", values) + + return values + + values["origin_hash"] = origin_hash.lower() + return values + + class Config: + allow_mutation = False + underscore_attrs_are_private = True + + +InputCombinationInit = Union[ + Iterable[Dict[str, Union[str, int]]], + Iterable[InputConfig], +] + + +class InputCombination(Tuple[InputConfig, ...]): + """One or more InputConfigs used to trigger a mapping.""" + + # tuple is immutable, therefore we need to override __new__() + # https://jfine-python-classes.readthedocs.io/en/latest/subclass-tuple.html + def __new__(cls, configs: InputCombinationInit) -> InputCombination: + """Create a new InputCombination. + + Examples + -------- + InputCombination([InputConfig, ...]) + InputCombination([{type: ..., code: ..., value: ...}, ...]) + """ + if not isinstance(configs, Iterable): + raise TypeError("InputCombination requires a list of InputConfigs.") + + if isinstance(configs, InputConfig): + # wrap the argument in square brackets + raise TypeError("InputCombination requires a list of InputConfigs.") + + validated_configs = [] + for config in configs: + if isinstance(configs, InputEvent): + raise TypeError("InputCombinations require InputConfigs, not Events.") + + if isinstance(config, InputConfig): + validated_configs.append(config) + elif isinstance(config, dict): + validated_configs.append(InputConfig(**config)) + else: + raise TypeError(f'Can\'t handle "{config}"') + + if len(validated_configs) == 0: + raise ValueError(f"failed to create InputCombination with {configs = }") + + # mypy bug: https://github.com/python/mypy/issues/8957 + # https://github.com/python/mypy/issues/8541 + return super().__new__(cls, validated_configs) # type: ignore + + def __str__(self): + return f'Combination ({" + ".join(str(event) for event in self)})' + + def __repr__(self): + combination = ", ".join(repr(event) for event in self) + return f"" + + @classmethod + def __get_validators__(cls): + """Used by pydantic to create InputCombination objects.""" + yield cls.validate + + @classmethod + def validate(cls, init_arg) -> InputCombination: + """The only valid option is from_config""" + if isinstance(init_arg, InputCombination): + return init_arg + return cls(init_arg) + + def to_config(self) -> Tuple[Dict[str, int], ...]: + """Turn the object into a tuple of dicts.""" + return tuple(input_config.dict(exclude_defaults=True) for input_config in self) + + @classmethod + def empty_combination(cls) -> InputCombination: + """A combination that has default invalid (to evdev) values. + + Useful for the UI to indicate that this combination is not set + """ + return cls([{"type": EMPTY_TYPE, "code": 99, "analog_threshold": 99}]) + + @classmethod + def from_tuples(cls, *tuples): + """Construct an InputCombination from (type, code, analog_threshold) tuples.""" + dicts = [] + for tuple_ in tuples: + if len(tuple_) == 3: + dicts.append( + { + "type": tuple_[0], + "code": tuple_[1], + "analog_threshold": tuple_[2], + } + ) + elif len(tuple_) == 2: + dicts.append( + { + "type": tuple_[0], + "code": tuple_[1], + } + ) + else: + raise TypeError + + return cls(dicts) + + def is_problematic(self) -> bool: + """Is this combination going to work properly on all systems?""" + if len(self) <= 1: + return False + + for input_config in self: + if input_config.type != ecodes.EV_KEY: + continue + + if input_config.code in DIFFICULT_COMBINATIONS: + return True + + return False + + @property + def defines_analog_input(self) -> bool: + """Check if there is any analog input in self.""" + return True in tuple(i.defines_analog_input for i in self) + + def find_analog_input_config( + self, type_: Optional[int] = None + ) -> Optional[InputConfig]: + """Return the first event that defines an analog input.""" + for input_config in self: + if input_config.defines_analog_input and ( + type_ is None or input_config.type == type_ + ): + return input_config + return None + + def get_permutations(self) -> List[InputCombination]: + """Get a list of EventCombinations representing all possible permutations. + + combining a + b + c should have the same result as b + a + c. + Only the last combination remains the same in the returned result. + """ + if len(self) <= 2: + return [self] + + if len(self) > 6: + logger.warning( + "Your input combination has a length of %d. Long combinations might " + 'freeze the process. Edit the configuration files in "%s" to fix it.', + len(self), + PathUtils.get_config_path(), + ) + + permutations = [] + for permutation in itertools.permutations(self[:-1]): + permutations.append(InputCombination((*permutation, self[-1]))) + + return permutations + + def beautify(self) -> str: + """Get a human-readable string representation.""" + if self == InputCombination.empty_combination(): + return "empty_combination" + return " + ".join(event.description(exclude_threshold=True) for event in self) diff --git a/inputremapper/configs/keyboard_layout.py b/inputremapper/configs/keyboard_layout.py new file mode 100644 index 0000000..6fd0d1a --- /dev/null +++ b/inputremapper/configs/keyboard_layout.py @@ -0,0 +1,221 @@ +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . +"""Make the systems/environments mapping of keys and codes accessible.""" + +import json +import re +import subprocess +from typing import Optional, List, Iterable, Tuple + +import evdev + +from inputremapper.configs.paths import PathUtils +from inputremapper.logging.logger import logger +from inputremapper.utils import is_service + +DISABLE_NAME = "disable" + +DISABLE_CODE = -1 + +# xkb uses keycodes that are 8 higher than those from evdev +XKB_KEYCODE_OFFSET = 8 + +XMODMAP_FILENAME = "xmodmap.json" + +LAZY_LOAD = None + + +class KeyboardLayout: + """Stores information about all available keycodes.""" + + _mapping: Optional[dict] = LAZY_LOAD + _xmodmap: Optional[List[Tuple[str, str]]] = LAZY_LOAD + _case_insensitive_mapping: Optional[dict] = LAZY_LOAD + + def __getattribute__(self, wanted: str): + """To lazy load keyboard_layout info only when needed. + + For example, this helps to keep logs of input-remapper-control clear when it + doesn't need it the information. + """ + lazy_loaded_attributes = ["_mapping", "_xmodmap", "_case_insensitive_mapping"] + for lazy_loaded_attribute in lazy_loaded_attributes: + if wanted != lazy_loaded_attribute: + continue + + if object.__getattribute__(self, lazy_loaded_attribute) is LAZY_LOAD: + # initialize _mapping and such with an empty dict, for populate + # to write into + object.__setattr__(self, lazy_loaded_attribute, {}) + object.__getattribute__(self, "populate")() + + return object.__getattribute__(self, wanted) + + def list_names(self, codes: Optional[Iterable[int]] = None) -> List[str]: + """Get all possible names in the mapping, optionally filtered by codes. + + Parameters + ---------- + codes: list of event codes + """ + if not codes: + return self._mapping.keys() + + return [name for name, code in self._mapping.items() if code in codes] + + def correct_case(self, symbol: str): + """Return the correct casing for a symbol.""" + if symbol in self._mapping: + return symbol + # only if not e.g. both "a" and "A" are in the mapping + return self._case_insensitive_mapping.get(symbol.lower(), symbol) + + def _use_xmodmap_symbols(self): + """Look up xmodmap -pke, write xmodmap.json, and get the symbols.""" + try: + xmodmap = subprocess.check_output( + ["xmodmap", "-pke"], + stderr=subprocess.STDOUT, + ).decode() + except FileNotFoundError: + logger.info("Optional `xmodmap` command not found. This is not critical.") + return + except subprocess.CalledProcessError as e: + logger.error('Call to `xmodmap -pke` failed with "%s"', e) + return + + self._xmodmap = re.findall(r"(\d+) = (.+)\n", xmodmap + "\n") + xmodmap_dict = self._find_legit_mappings() + if len(xmodmap_dict) == 0: + logger.info("`xmodmap -pke` did not yield any symbol") + return + + # Write this stuff into the input-remapper config directory, because + # the systemd service won't know the user sessions xmodmap. + path = PathUtils.get_config_path(XMODMAP_FILENAME) + PathUtils.touch(path) + with open(path, "w") as file: + logger.debug('Writing "%s"', path) + json.dump(xmodmap_dict, file, indent=4) + + for name, code in xmodmap_dict.items(): + self._set(name, code) + + def _use_linux_evdev_symbols(self): + """Look up the evdev constant names and use them.""" + for name, ecode in evdev.ecodes.ecodes.items(): + if name.startswith("KEY") or name.startswith("BTN"): + self._set(name, ecode) + + def populate(self): + """Get a mapping of all available names to their keycodes.""" + logger.debug("Gathering available keycodes") + self.clear() + + if not is_service(): + # xmodmap is only available from within the login session. + # The service that runs via systemd can't use this. + self._use_xmodmap_symbols() + + self._use_linux_evdev_symbols() + + self._set(DISABLE_NAME, DISABLE_CODE) + + def update(self, mapping: dict): + """Update this with new keys. + + Parameters + ---------- + mapping + maps from name to code. Make sure your keys are lowercase. + """ + len_before = len(self._mapping) + for name, code in mapping.items(): + self._set(name, code) + + logger.debug( + "Updated keycodes with %d new ones", len(self._mapping) - len_before + ) + + def _set(self, name: str, code: int): + """Map name to code.""" + self._mapping[str(name)] = code + self._case_insensitive_mapping[str(name).lower()] = name + + def get(self, name: str) -> Optional[int]: + """Return the code mapped to the key.""" + # the correct casing should be shown when asking the keyboard_layout + # for stuff. indexing case insensitive to support old presets. + if name not in self._mapping: + # only if not e.g. both "a" and "A" are in the mapping + name = self._case_insensitive_mapping.get(str(name).lower()) + + return self._mapping.get(name) + + def clear(self): + """Remove all mapped keys. Only needed for tests.""" + keys = list(self._mapping.keys()) + for key in keys: + del self._mapping[key] + + def get_name(self, code: int): + """Get the first matching name for the code.""" + for entry in self._xmodmap: + if int(entry[0]) - XKB_KEYCODE_OFFSET == code: + return entry[1].split()[0] + + # Fall back to the linux constants + # This is especially important for BTN_LEFT and such + btn_name = evdev.ecodes.BTN.get(code, None) + if btn_name is not None: + if type(btn_name) in [list, tuple]: + # python-evdev >= 1.8.0 uses tuples + return btn_name[0] + + return btn_name + + key_name = evdev.ecodes.KEY.get(code, None) + if key_name is not None: + if type(key_name) in [list, tuple]: + # python-evdev >= 1.8.0 uses tuples + return key_name[0] + + return key_name + + return None + + def _find_legit_mappings(self) -> dict: + """From the parsed xmodmap list find usable symbols and their codes.""" + xmodmap_dict = {} + for keycode, names in self._xmodmap: + # there might be multiple, like: + # keycode 64 = Alt_L Meta_L Alt_L Meta_L + # keycode 204 = NoSymbol Alt_L NoSymbol Alt_L + # Alt_L should map to code 64. Writing code 204 only works + # if a modifier is applied at the same time. So take the first + # one. + name = names.split()[0] + xmodmap_dict[name] = int(keycode) - XKB_KEYCODE_OFFSET + + return xmodmap_dict + + +# TODO DI +# this mapping represents the xmodmap output, which stays constant +keyboard_layout = KeyboardLayout() diff --git a/inputremapper/configs/mapping.py b/inputremapper/configs/mapping.py new file mode 100644 index 0000000..c57f955 --- /dev/null +++ b/inputremapper/configs/mapping.py @@ -0,0 +1,525 @@ +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + +from __future__ import annotations + +import enum +from collections import namedtuple +from typing import Optional, Callable, Tuple, TypeVar, Union, Any, Dict + +from evdev.ecodes import ( + EV_KEY, + EV_ABS, + EV_REL, + REL_WHEEL, + REL_HWHEEL, + REL_HWHEEL_HI_RES, + REL_WHEEL_HI_RES, +) +from packaging import version + +from inputremapper.logging.logger import logger + +try: + from pydantic.v1 import ( + BaseModel, + PositiveInt, + confloat, + conint, + root_validator, + validator, + ValidationError, + PositiveFloat, + VERSION, + BaseConfig, + ) +except ImportError: + from pydantic import ( + BaseModel, + PositiveInt, + confloat, + conint, + root_validator, + validator, + ValidationError, + PositiveFloat, + VERSION, + BaseConfig, + ) + +from inputremapper.configs.input_config import InputCombination +from inputremapper.configs.keyboard_layout import keyboard_layout, DISABLE_NAME +from inputremapper.configs.validation_errors import ( + OutputSymbolUnknownError, + SymbolNotAvailableInTargetError, + OnlyOneAnalogInputError, + TriggerPointInRangeError, + OutputSymbolVariantError, + MacroButTypeOrCodeSetError, + SymbolAndCodeMismatchError, + WrongMappingTypeForKeyError, + MissingOutputAxisError, + MissingMacroOrKeyError, +) +from inputremapper.gui.gettext import _ +from inputremapper.gui.messages.message_types import MessageType +from inputremapper.injection.global_uinputs import GlobalUInputs +from inputremapper.injection.macros.parse import Parser +from inputremapper.utils import get_evdev_constant_name + +# TODO: remove pydantic VERSION check as soon as we no longer support +# Ubuntu 20.04 and with it the ancient pydantic 1.2 + +needs_workaround = version.parse(str(VERSION)) < version.parse("1.7.1") + + +EMPTY_MAPPING_NAME: str = _("Empty Mapping") + +# If `1` is the default speed for EV_REL, how much does this value needs to be scaled +# up to get reasonable speeds for various EV_REL events? +# Mouse injection rates vary wildly, and so do the values. +REL_XY_SCALING: float = 60 +WHEEL_SCALING: float = 1 +# WHEEL_HI_RES always generates events with 120 times higher values than WHEEL +# https://www.kernel.org/doc/html/latest/input/event-codes.html?highlight=wheel_hi_res#ev-rel +WHEEL_HI_RES_SCALING: float = 120 +# Those values are assuming a rate of 60hz +DEFAULT_REL_RATE: float = 60 + + +class KnownUinput(str, enum.Enum): + """The default targets.""" + + KEYBOARD = "keyboard" + MOUSE = "mouse" + GAMEPAD = "gamepad" + KEYBOARD_MOUSE = "keyboard + mouse" + + +class MappingType(str, enum.Enum): + """What kind of output the mapping produces.""" + + KEY_MACRO = "key_macro" + ANALOG = "analog" + + +CombinationChangedCallback = Optional[ + Callable[[InputCombination, InputCombination], None] +] +MappingModel = TypeVar("MappingModel", bound="UIMapping") + + +class Cfg(BaseConfig): + validate_assignment = True + use_enum_values = True + underscore_attrs_are_private = True + json_encoders = {InputCombination: lambda v: v.json_key()} + + +class ImmutableCfg(Cfg): + allow_mutation = False + + +class UIMapping(BaseModel): + """Holds all the data for mapping an input action to an output action. + + The Preset contains multiple UIMappings. + + This mapping does not validate the structure of the mapping or macros, only basic + values. It is meant to be used in the GUI where invalid mappings are expected. + """ + + if needs_workaround: + __slots__ = ("_combination_changed",) + + # Required attributes + # The InputEvent or InputEvent combination which is mapped + input_combination: InputCombination = InputCombination.empty_combination() + # The UInput to which the mapped event will be sent + target_uinput: Optional[Union[str, KnownUinput]] = None + + # Either `output_symbol` or `output_type` and `output_code` is required + # Only set if output is "Key or Macro": + output_symbol: Optional[str] = None # The symbol or macro string if applicable + # "Analog Axis" or if preset edited manually to inject a code instead of a symbol: + output_type: Optional[int] = None # The event type of the mapped event + output_code: Optional[int] = None # The event code of the mapped event + + name: Optional[str] = None + mapping_type: Optional[MappingType] = None + + # if release events will be sent to the forwarded device as soon as a combination + # triggers see also #229 + release_combination_keys: bool = True + + # macro settings + macro_key_sleep_ms: conint(ge=0) = 0 # type: ignore + + # Optional attributes for mapping Axis to Axis + # The deadzone of the input axis + deadzone: confloat(ge=0, le=1) = 0.1 # type: ignore + gain: float = 1.0 # The scale factor for the transformation + # The expo factor for the transformation + expo: confloat(ge=-1, le=1) = 0 # type: ignore + + # when mapping to relative axis + # The frequency [Hz] at which EV_REL events get generated + rel_rate: PositiveInt = 60 + + # when mapping from a relative axis: + # the relative value at which a EV_REL axis is considered at its maximum. Moving + # a mouse at 2x the regular speed would be considered max by default. + rel_to_abs_input_cutoff: PositiveInt = 2 + + # the time until a relative axis is considered stationary if no new events arrive + release_timeout: PositiveFloat = 0.05 + # don't release immediately when a relative axis drops below the speed threshold + # instead wait until it dropped for loger than release_timeout below the threshold + force_release_timeout: bool = False + + # callback which gets called if the input_combination is updated + if not needs_workaround: + _combination_changed: Optional[CombinationChangedCallback] = None + + # use type: ignore, looks like a mypy bug related to: + # https://github.com/samuelcolvin/pydantic/issues/2949 + def __init__(self, **kwargs): # type: ignore + super().__init__(**kwargs) + if needs_workaround: + object.__setattr__(self, "_combination_changed", None) + + def __setattr__(self, key: str, value: Any): + """Call the combination changed callback + if we are about to update the input_combination + """ + if key != "input_combination" or self._combination_changed is None: + if key == "_combination_changed" and needs_workaround: + object.__setattr__(self, "_combination_changed", value) + return + super().__setattr__(key, value) + return + + # the new combination is not yet validated + try: + new_combi = InputCombination.validate(value) + except (ValueError, TypeError) as exception: + raise ValidationError( + f"failed to Validate {value} as InputCombination", UIMapping + ) from exception + + if new_combi == self.input_combination: + return + + # raises a keyError if the combination or a permutation is already mapped + self._combination_changed(new_combi, self.input_combination) + super().__setattr__("input_combination", new_combi) + + def __str__(self): + return str( + self.dict( + exclude_defaults=True, include={"input_combination", "target_uinput"} + ) + ) + + if needs_workaround: + # https://github.com/samuelcolvin/pydantic/issues/1383 + def copy(self: MappingModel, *args, **kwargs) -> MappingModel: + kwargs["deep"] = True + copy = super().copy(*args, **kwargs) + object.__setattr__(copy, "_combination_changed", self._combination_changed) + return copy + + def format_name(self) -> str: + """Get the custom-name or a readable representation of the combination.""" + if self.name: + return self.name + + if ( + self.input_combination == InputCombination.empty_combination() + or self.input_combination is None + ): + return EMPTY_MAPPING_NAME + + return self.input_combination.beautify() + + def has_input_defined(self) -> bool: + """Whether this mapping defines an event-input.""" + return self.input_combination != InputCombination.empty_combination() + + def is_axis_mapping(self) -> bool: + """Whether this mapping specifies an output axis.""" + return self.output_type in [EV_ABS, EV_REL] + + def is_wheel_output(self) -> bool: + """Check if this maps to wheel output.""" + return self.output_code in ( + REL_WHEEL, + REL_HWHEEL, + ) + + def is_high_res_wheel_output(self) -> bool: + """Check if this maps to high-res wheel output.""" + return self.output_code in ( + REL_WHEEL_HI_RES, + REL_HWHEEL_HI_RES, + ) + + def is_analog_output(self): + return self.mapping_type == MappingType.ANALOG + + def set_combination_changed_callback(self, callback: CombinationChangedCallback): + self._combination_changed = callback + + def remove_combination_changed_callback(self): + self._combination_changed = None + + def get_output_type_code(self) -> Optional[Tuple[int, int]]: + """Returns the output_type and output_code if set, + otherwise looks the output_symbol up in the keyboard_layout + return None for unknown symbols and macros + """ + if self.output_code is not None and self.output_type is not None: + return self.output_type, self.output_code + + if self.output_symbol and not Parser.is_this_a_macro(self.output_symbol): + return EV_KEY, keyboard_layout.get(self.output_symbol) + + return None + + def get_output_name_constant(self) -> str: + """Get the evdev name costant for the output.""" + return get_evdev_constant_name(self.output_type, self.output_code) + + def is_valid(self) -> bool: + """If the mapping is valid.""" + return not self.get_error() + + def get_error(self) -> Optional[ValidationError]: + """The validation error or None.""" + try: + Mapping(**self.dict()) + except ValidationError as exception: + return exception + return None + + def get_bus_message(self) -> MappingData: + """Return an immutable copy for use in the message broker.""" + return MappingData(**self.dict()) + + @root_validator + def validate_mapping_type(cls, values): + """Overrides the mapping type if the output mapping type is obvious.""" + output_type = values.get("output_type") + output_code = values.get("output_code") + output_symbol = values.get("output_symbol") + + if output_type is not None and output_symbol is not None: + # This is currently only possible when someone edits the preset file by + # hand. A key-output mapping without an output_symbol, but type and code + # instead, is valid as well. + logger.debug("Both output_type and output_symbol are set") + + if output_type != EV_KEY and output_code is not None and not output_symbol: + values["mapping_type"] = MappingType.ANALOG.value + + if output_type is None and output_code is None and output_symbol: + values["mapping_type"] = MappingType.KEY_MACRO.value + + if output_type == EV_KEY: + values["mapping_type"] = MappingType.KEY_MACRO.value + + return values + + Config = Cfg + + +class Mapping(UIMapping): + """Holds all the data for mapping an input action to an output action. + + This implements the missing validations from UIMapping. + """ + + # Override Required attributes to enforce they are set + input_combination: InputCombination + target_uinput: KnownUinput + + @classmethod + def from_combination( + cls, + input_combination=None, + target_uinput="keyboard", + output_symbol="a", + ): + """Convenient function to get a valid mapping.""" + if not input_combination: + input_combination = [{"type": 99, "code": 99, "analog_threshold": 99}] + + return cls( + input_combination=input_combination, + target_uinput=target_uinput, + output_symbol=output_symbol, + ) + + def is_valid(self) -> bool: + """If the mapping is valid.""" + return True + + @root_validator(pre=True) + def validate_symbol(cls, values): + """Parse a macro to check for syntax errors.""" + symbol = values.get("output_symbol") + + if symbol == "": + values["output_symbol"] = None + return values + + if symbol is None: + return values + + symbol = symbol.strip() + values["output_symbol"] = symbol + + if symbol == DISABLE_NAME: + return values + + if Parser.is_this_a_macro(symbol): + mapping_mock = namedtuple("Mapping", values.keys())(**values) + # raises MacroError + Parser.parse(symbol, mapping=mapping_mock, verbose=False) + return values + + code = keyboard_layout.get(symbol) + if code is None: + raise OutputSymbolUnknownError(symbol) + + target = values.get("target_uinput") + if target is not None and not GlobalUInputs.can_default_uinput_emit( + target, EV_KEY, code + ): + raise SymbolNotAvailableInTargetError(symbol, target) + + return values + + @validator("input_combination") + def only_one_analog_input(cls, combination) -> InputCombination: + """Check that the input_combination specifies a maximum of one + analog to analog mapping + """ + analog_events = [event for event in combination if event.defines_analog_input] + if len(analog_events) > 1: + raise OnlyOneAnalogInputError(analog_events) + + return combination + + @validator("input_combination") + def trigger_point_in_range(cls, combination: InputCombination) -> InputCombination: + """Check if the trigger point for mapping analog axis to buttons is valid.""" + for input_config in combination: + if ( + input_config.type == EV_ABS + and input_config.analog_threshold + and abs(input_config.analog_threshold) >= 100 + ): + raise TriggerPointInRangeError(input_config) + return combination + + @root_validator + def validate_output_symbol_variant(cls, values): + """Validate that either type and code or symbol are set for key output.""" + o_symbol = values.get("output_symbol") + o_type = values.get("output_type") + o_code = values.get("output_code") + if o_symbol is None and (o_type is None or o_code is None): + raise OutputSymbolVariantError() + return values + + @root_validator + def validate_output_integrity(cls, values): + """Validate the output key configuration.""" + symbol = values.get("output_symbol") + type_ = values.get("output_type") + code = values.get("output_code") + if symbol is None: + # If symbol is "", then validate_symbol changes it to None + # type and code can be anything + return values + + if type_ is None and code is None: + # we have a symbol: no type and code is fine + return values + + if Parser.is_this_a_macro(symbol): + # disallow output type and code for macros + if type_ is not None or code is not None: + raise MacroButTypeOrCodeSetError() + + if code is not None and code != keyboard_layout.get(symbol) or type_ != EV_KEY: + raise SymbolAndCodeMismatchError(symbol, code) + return values + + @root_validator + def output_matches_input(cls, values: Dict[str, Any]) -> Dict[str, Any]: + """Validate that an output type is an axis if we have an input axis. + And vice versa.""" + assert isinstance(values.get("input_combination"), InputCombination) + combination: InputCombination = values["input_combination"] + + analog_input_config = combination.find_analog_input_config() + defines_analog_input = analog_input_config is not None + output_type = values.get("output_type") + output_code = values.get("output_code") + mapping_type = values.get("mapping_type") + output_symbol = values.get("output_symbol") + output_key_set = output_symbol or (output_type == EV_KEY and output_code) + + if mapping_type is None: + # Empty mapping most likely + return values + + if not defines_analog_input and mapping_type != MappingType.KEY_MACRO.value: + raise WrongMappingTypeForKeyError() + + if not defines_analog_input and not output_key_set: + raise MissingMacroOrKeyError() + + if ( + defines_analog_input + and output_type not in (EV_ABS, EV_REL) + and output_symbol != DISABLE_NAME + ): + raise MissingOutputAxisError(analog_input_config, output_type) + + return values + + +class MappingData(UIMapping): + """Like UIMapping, but can be sent over the message broker.""" + + Config = ImmutableCfg + message_type = MessageType.mapping # allow this to be sent over the MessageBroker + + def __str__(self): + return str(self.dict(exclude_defaults=True)) + + def dict(self, *args, **kwargs): + """Will not include the message_type.""" + dict_ = super().dict(*args, **kwargs) + if "message_type" in dict_: + del dict_["message_type"] + return dict_ diff --git a/inputremapper/configs/migrations.py b/inputremapper/configs/migrations.py new file mode 100644 index 0000000..c533136 --- /dev/null +++ b/inputremapper/configs/migrations.py @@ -0,0 +1,516 @@ +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + +"""Migration functions. + +Only write changes to disk, if there actually are changes. Otherwise, file-modification +dates are destroyed. +""" +from __future__ import annotations + +import copy +import json +import os +import re +import shutil +from pathlib import Path +from typing import Iterator, Tuple, Dict, List, Optional, TypedDict + +from evdev.ecodes import ( + EV_KEY, + EV_ABS, + EV_REL, + ABS_X, + ABS_Y, + ABS_RX, + ABS_RY, + REL_X, + REL_Y, + REL_WHEEL_HI_RES, + REL_HWHEEL_HI_RES, +) +from packaging import version + +from inputremapper.configs.input_config import InputCombination, InputConfig +from inputremapper.configs.keyboard_layout import keyboard_layout +from inputremapper.configs.mapping import Mapping, UIMapping +from inputremapper.configs.paths import PathUtils +from inputremapper.configs.preset import Preset +from inputremapper.injection.global_uinputs import GlobalUInputs +from inputremapper.injection.macros.parse import Parser +from inputremapper.logging.logger import logger, VERSION +from inputremapper.user import UserUtils + + +class Config(TypedDict): + input_combination: Optional[InputCombination] + target_uinput: str + output_type: int + output_code: Optional[int] + + +class Migrations: + def __init__(self, global_uinputs: GlobalUInputs): + self.global_uinputs = global_uinputs + + def migrate(self): + """Migrate config files to the current release.""" + + self._rename_to_input_remapper() + + self._copy_to_v2() + + v = self.config_version() + + if v < version.parse("0.4.0"): + self._config_suffix() + self._preset_path() + + if v < version.parse("1.2.2"): + self._mapping_keys() + + if v < version.parse("1.4.0"): + self.global_uinputs.prepare_all() + self._add_target() + + if v < version.parse("1.4.1"): + self._otherwise_to_else() + + if v < version.parse("1.5.0"): + self._remove_logs() + + if v < version.parse("1.6.0-beta"): + self._convert_to_individual_mappings() + + # add new migrations here + + if v < version.parse(VERSION): + self._update_version() + + def all_presets( + self, + ) -> Iterator[Tuple[os.PathLike, Dict | List]]: + """Get all presets for all groups as list.""" + if not os.path.exists(PathUtils.get_preset_path()): + return + + preset_path = Path(PathUtils.get_preset_path()) + for folder in preset_path.iterdir(): + if not folder.is_dir(): + continue + + for preset in folder.iterdir(): + if preset.suffix != ".json": + continue + + try: + with open(preset, "r") as f: + preset_structure = json.load(f) + yield preset, preset_structure + except json.decoder.JSONDecodeError: + logger.warning('Invalid json format in preset "%s"', preset) + continue + + def config_version(self): + """Get the version string in config.json as packaging.Version object.""" + config_path = os.path.join(PathUtils.config_path(), "config.json") + + if not os.path.exists(config_path): + return version.parse("0.0.0") + + with open(config_path, "r") as file: + config = json.load(file) + + if "version" in config.keys(): + return version.parse(config["version"]) + + return version.parse("0.0.0") + + def _config_suffix(self): + """Append the .json suffix to the config file.""" + deprecated_path = os.path.join(PathUtils.config_path(), "config") + config_path = os.path.join(PathUtils.config_path(), "config.json") + if os.path.exists(deprecated_path) and not os.path.exists(config_path): + logger.info('Moving "%s" to "%s"', deprecated_path, config_path) + os.rename(deprecated_path, config_path) + + def _preset_path(self): + """Migrate the folder structure from < 0.4.0. + + Move existing presets into the new subfolder 'presets' + """ + new_preset_folder = os.path.join(PathUtils.config_path(), "presets") + if os.path.exists(PathUtils.get_preset_path()) or not os.path.exists( + PathUtils.config_path() + ): + return + + logger.info("Migrating presets from < 0.4.0...") + group_config_files = os.listdir(PathUtils.config_path()) + PathUtils.mkdir(PathUtils.get_preset_path()) + for group in group_config_files: + path = os.path.join(PathUtils.config_path(), group) + if os.path.isdir(path): + target = path.replace(PathUtils.config_path(), new_preset_folder) + logger.info('Moving "%s" to "%s"', path, target) + os.rename(path, target) + + logger.info("done") + + def _mapping_keys(self): + """Update all preset mappings. + + Update all keys in preset to include value e.g.: '1,5'->'1,5,1' + """ + for preset, preset_structure in self.all_presets(): + if isinstance(preset_structure, list): + continue # the preset must be at least 1.6-beta version + + changes = 0 + if "mapping" in preset_structure.keys(): + mapping = copy.deepcopy(preset_structure["mapping"]) + for key in mapping.keys(): + if key.count(",") == 1: + preset_structure["mapping"][f"{key},1"] = preset_structure[ + "mapping" + ].pop(key) + changes += 1 + + if changes: + with open(preset, "w") as file: + logger.info('Updating mapping keys of "%s"', preset) + json.dump(preset_structure, file, indent=4) + file.write("\n") + + def _update_version(self): + """Write the current version to the config file.""" + config_file = os.path.join(PathUtils.config_path(), "config.json") + if not os.path.exists(config_file): + return + + with open(config_file, "r") as file: + config = json.load(file) + + config["version"] = VERSION + with open(config_file, "w") as file: + logger.info('Updating version in config to "%s"', VERSION) + json.dump(config, file, indent=4) + + def _rename_to_input_remapper(self): + """Rename .config/key-mapper to .config/input-remapper.""" + old_config_path = os.path.join(UserUtils.home, ".config/key-mapper") + if not os.path.exists(PathUtils.config_path()) and os.path.exists( + old_config_path + ): + logger.info("Moving %s to %s", old_config_path, PathUtils.config_path()) + shutil.move(old_config_path, PathUtils.config_path()) + + def _find_target(self, symbol): + """Try to find a uinput with the required capabilities for the symbol.""" + capabilities = {EV_KEY: set(), EV_REL: set()} + + if Parser.is_this_a_macro(symbol): + # deprecated mechanic, cannot figure this out anymore + # capabilities = parse(symbol).get_capabilities() + return None + + capabilities[EV_KEY] = {keyboard_layout.get(symbol)} + + if len(capabilities[EV_REL]) > 0: + return "mouse" + + for name, uinput in self.global_uinputs.devices.items(): + if capabilities[EV_KEY].issubset(uinput.capabilities()[EV_KEY]): + return name + + logger.info('could not find a suitable target UInput for "%s"', symbol) + return None + + def _add_target(self): + """Add the target field to each preset mapping.""" + for preset, preset_structure in self.all_presets(): + if isinstance(preset_structure, list): + continue + + if "mapping" not in preset_structure.keys(): + continue + + changed = False + for key, symbol in preset_structure["mapping"].copy().items(): + if isinstance(symbol, list): + continue + + target = self._find_target(symbol) + if target is None: + target = "keyboard" + symbol = ( + f"{symbol}\n" + "# Broken mapping:\n" + "# No target can handle all specified keycodes" + ) + + logger.info( + 'Changing target of mapping for "%s" in preset "%s" to "%s"', + key, + preset, + target, + ) + symbol = [symbol, target] + preset_structure["mapping"][key] = symbol + changed = True + + if not changed: + continue + + with open(preset, "w") as file: + logger.info('Adding targets for "%s"', preset) + json.dump(preset_structure, file, indent=4) + file.write("\n") + + def _otherwise_to_else(self): + """Conditional macros should use an "else" parameter instead of "otherwise".""" + for preset, preset_structure in self.all_presets(): + if isinstance(preset_structure, list): + continue + + if "mapping" not in preset_structure.keys(): + continue + + changed = False + for key, symbol in preset_structure["mapping"].copy().items(): + if not Parser.is_this_a_macro(symbol[0]): + continue + + symbol_before = symbol[0] + symbol[0] = re.sub(r"otherwise\s*=\s*", "else=", symbol[0]) + + if symbol_before == symbol[0]: + continue + + changed = changed or symbol_before != symbol[0] + + logger.info( + 'Changing mapping for "%s" in preset "%s" to "%s"', + key, + preset, + symbol[0], + ) + + preset_structure["mapping"][key] = symbol + + if not changed: + continue + + with open(preset, "w") as file: + logger.info('Changing otherwise to else for "%s"', preset) + json.dump(preset_structure, file, indent=4) + file.write("\n") + + def _input_combination_from_string( + self, combination_string: str + ) -> InputCombination: + configs = [] + for event_str in combination_string.split("+"): + type_, code, analog_threshold = event_str.split(",") + configs.append( + { + "type": int(type_), + "code": int(code), + "analog_threshold": int(analog_threshold), + } + ) + + return InputCombination(configs) + + def _convert_to_individual_mappings( + self, + ) -> None: + """Convert preset.json + from {key: [symbol, target]} + to [{input_combination: ..., output_symbol: symbol, ...}] + """ + + for old_preset_path, old_preset in self.all_presets(): + if isinstance(old_preset, list): + continue + + migrated_preset = Preset(old_preset_path, UIMapping) + if "mapping" in old_preset.keys(): + for combination, symbol_target in old_preset["mapping"].items(): + logger.info( + 'migrating from "%s: %s" to mapping dict', + combination, + symbol_target, + ) + try: + combination = self._input_combination_from_string(combination) + except ValueError: + logger.error( + "unable to migrate mapping with invalid combination %s", + combination, + ) + continue + + mapping = UIMapping( + input_combination=combination, + target_uinput=symbol_target[1], + output_symbol=symbol_target[0], + ) + migrated_preset.add(mapping) + + if ( + "gamepad" in old_preset.keys() + and "joystick" in old_preset["gamepad"].keys() + ): + joystick_dict = old_preset["gamepad"]["joystick"] + left_purpose = joystick_dict.get("left_purpose") + right_purpose = joystick_dict.get("right_purpose") + # TODO if pointer_speed is migrated, why is it in my config? + pointer_speed = joystick_dict.get("pointer_speed") + if pointer_speed: + pointer_speed /= 100 + # non_linearity = joystick_dict.get("non_linearity") # Todo + x_scroll_speed = joystick_dict.get("x_scroll_speed") + y_scroll_speed = joystick_dict.get("y_scroll_speed") + + cfg: Config = { + "input_combination": None, + "target_uinput": "mouse", + "output_type": EV_REL, + "output_code": None, + } + + if left_purpose == "mouse": + x_config = cfg.copy() + y_config = cfg.copy() + x_config["input_combination"] = InputCombination( + [InputConfig(type=EV_ABS, code=ABS_X)] + ) + y_config["input_combination"] = InputCombination( + [InputConfig(type=EV_ABS, code=ABS_Y)] + ) + x_config["output_code"] = REL_X + y_config["output_code"] = REL_Y + mapping_x = Mapping(**x_config) + mapping_y = Mapping(**y_config) + if pointer_speed: + mapping_x.gain = pointer_speed + mapping_y.gain = pointer_speed + migrated_preset.add(mapping_x) + migrated_preset.add(mapping_y) + + if right_purpose == "mouse": + x_config = cfg.copy() + y_config = cfg.copy() + x_config["input_combination"] = InputCombination( + [InputConfig(type=EV_ABS, code=ABS_RX)] + ) + y_config["input_combination"] = InputCombination( + [InputConfig(type=EV_ABS, code=ABS_RY)] + ) + x_config["output_code"] = REL_X + y_config["output_code"] = REL_Y + mapping_x = Mapping(**x_config) + mapping_y = Mapping(**y_config) + if pointer_speed: + mapping_x.gain = pointer_speed + mapping_y.gain = pointer_speed + migrated_preset.add(mapping_x) + migrated_preset.add(mapping_y) + + if left_purpose == "wheel": + x_config = cfg.copy() + y_config = cfg.copy() + x_config["input_combination"] = InputCombination( + [InputConfig(type=EV_ABS, code=ABS_X)] + ) + y_config["input_combination"] = InputCombination( + [InputConfig(type=EV_ABS, code=ABS_Y)] + ) + x_config["output_code"] = REL_HWHEEL_HI_RES + y_config["output_code"] = REL_WHEEL_HI_RES + mapping_x = Mapping(**x_config) + mapping_y = Mapping(**y_config) + if x_scroll_speed: + mapping_x.gain = x_scroll_speed + if y_scroll_speed: + mapping_y.gain = y_scroll_speed + migrated_preset.add(mapping_x) + migrated_preset.add(mapping_y) + + if right_purpose == "wheel": + x_config = cfg.copy() + y_config = cfg.copy() + x_config["input_combination"] = InputCombination( + [InputConfig(type=EV_ABS, code=ABS_RX)] + ) + y_config["input_combination"] = InputCombination( + [InputConfig(type=EV_ABS, code=ABS_RY)] + ) + x_config["output_code"] = REL_HWHEEL_HI_RES + y_config["output_code"] = REL_WHEEL_HI_RES + mapping_x = Mapping(**x_config) + mapping_y = Mapping(**y_config) + if x_scroll_speed: + mapping_x.gain = x_scroll_speed + if y_scroll_speed: + mapping_y.gain = y_scroll_speed + migrated_preset.add(mapping_x) + migrated_preset.add(mapping_y) + + migrated_preset.save() + + def _copy_to_v2(self): + """Move the beta config to the v2 path, or copy the v1 config to the v2 path.""" + # TODO test + if os.path.exists(PathUtils.config_path()): + # don't copy to already existing folder + # users should delete the input-remapper-2 folder if they need to + return + + # prioritize the v1 configs over beta configs + old_path = os.path.join(UserUtils.home, ".config/input-remapper") + if os.path.exists(os.path.join(old_path, "config.json")): + # no beta path, only old presets exist. COPY to v2 path, which will then be + # migrated by the various self. + logger.debug("copying all from %s to %s", old_path, PathUtils.config_path()) + shutil.copytree(old_path, PathUtils.config_path()) + return + + # if v1 configs don't exist, try to find beta configs. + beta_path = os.path.join( + UserUtils.home, ".config/input-remapper/beta_1.6.0-beta" + ) + if os.path.exists(beta_path): + # There has never been a different version than "1.6.0-beta" in beta, so we + # only need to check for that exact directory + # already migrated, possibly new presets in them, move to v2 path + logger.debug("moving %s to %s", beta_path, PathUtils.config_path()) + shutil.move(beta_path, PathUtils.config_path()) + + def _remove_logs(self): + """We will try to rely on journalctl for this in the future.""" + try: + PathUtils.remove(f"{UserUtils.home}/.log/input-remapper") + PathUtils.remove("/var/log/input-remapper") + PathUtils.remove("/var/log/input-remapper-control") + except Exception as error: + logger.debug("Failed to remove deprecated logfiles: %s", str(error)) + # this migration is not important. Continue + pass diff --git a/inputremapper/configs/paths.py b/inputremapper/configs/paths.py new file mode 100644 index 0000000..db961cc --- /dev/null +++ b/inputremapper/configs/paths.py @@ -0,0 +1,157 @@ +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + +# TODO: convert everything to use pathlib.Path + +"""Path constants to be used.""" + + +import os +import shutil +from typing import List, Union, Optional + +from inputremapper.logging.logger import logger +from inputremapper.user import UserUtils + + +# TODO maybe this could become, idk, ConfigService and PresetService +class PathUtils: + @staticmethod + def config_path() -> str: + # TODO when proper DI is being done, construct PathUtils and configure it + # in the constructor. Then there is no need to recompute the config_path + # each time. Tests might have overwritten UserUtils.home. + xdg_config_home = os.getenv( + "XDG_CONFIG_HOME", os.path.join(UserUtils.home, ".config") + ) + return os.path.join(xdg_config_home, "input-remapper-2") + + @staticmethod + def chown(path): + """Set the owner of a path to the user.""" + try: + logger.debug('Chown "%s", "%s"', path, UserUtils.user) + shutil.chown(path, user=UserUtils.user, group=UserUtils.user) + except LookupError: + # the users group was unknown in one case for whatever reason + shutil.chown(path, user=UserUtils.user) + + @staticmethod + def touch(path: Union[str, os.PathLike], log=True): + """Create an empty file and all its parent dirs, give it to the user.""" + if str(path).endswith("/"): + raise ValueError(f"Expected path to not end with a slash: {path}") + + if os.path.exists(path): + return + + if log: + logger.info('Creating file "%s"', path) + + PathUtils.mkdir(os.path.dirname(path), log=False) + + os.mknod(path) + PathUtils.chown(path) + + @staticmethod + def mkdir(path, log=True): + """Create a folder, give it to the user.""" + if path == "" or path is None: + return + + if os.path.exists(path): + return + + if log: + logger.info('Creating dir "%s"', path) + + # give all newly created folders to the user. + # e.g. if .config/input-remapper/mouse/ is created the latter two + base = os.path.split(path)[0] + PathUtils.mkdir(base, log=False) + + os.makedirs(path) + PathUtils.chown(path) + + @staticmethod + def split_all(path: Union[os.PathLike, str]) -> List[str]: + """Split the path into its segments.""" + parts = [] + while True: + path, tail = os.path.split(path) + parts.append(tail) + if path == os.path.sep: + # we arrived at the root '/' + parts.append(path) + break + if not path: + # arrived at start of relative path + break + + parts.reverse() + return parts + + @staticmethod + def remove(path): + """Remove whatever is at the path.""" + if not os.path.exists(path): + return + + if os.path.isdir(path): + shutil.rmtree(path) + else: + os.remove(path) + + @staticmethod + def sanitize_path_component(group_name: str) -> str: + """Replace characters listed in + https://en.wikipedia.org/wiki/Filename#Reserved_characters_and_words + with an underscore. + """ + for character in '/\\?%*:|"<>': + if character in group_name: + group_name = group_name.replace(character, "_") + return group_name + + @staticmethod + def get_preset_path(group_name: Optional[str] = None, preset: Optional[str] = None): + """Get a path to the stored preset, or to store a preset to.""" + presets_base = os.path.join(PathUtils.config_path(), "presets") + + if group_name is None: + return presets_base + + group_name = PathUtils.sanitize_path_component(group_name) + + if preset is not None: + # the extension of the preset should not be shown in the ui. + # if a .json extension arrives this place, it has not been + # stripped away properly prior to this. + if not preset.endswith(".json"): + preset = f"{preset}.json" + + if preset is None: + return os.path.join(presets_base, group_name) + + return os.path.join(presets_base, group_name, preset) + + @staticmethod + def get_config_path(*paths) -> str: + """Get a path in ~/.config/input-remapper/.""" + return os.path.join(PathUtils.config_path(), *paths) diff --git a/inputremapper/configs/preset.py b/inputremapper/configs/preset.py new file mode 100644 index 0000000..5122cba --- /dev/null +++ b/inputremapper/configs/preset.py @@ -0,0 +1,325 @@ +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + +"""Contains and manages mappings.""" + +from __future__ import annotations + +import json +import os +from typing import ( + Tuple, + Dict, + List, + Optional, + Iterator, + Type, + TypeVar, + Generic, + overload, +) + +from evdev import ecodes + +from inputremapper.configs.input_config import InputCombination, InputConfig +from inputremapper.configs.mapping import Mapping, UIMapping +from inputremapper.configs.paths import PathUtils +from inputremapper.logging.logger import logger + +MappingModel = TypeVar("MappingModel", bound=UIMapping) + + +class Preset(Generic[MappingModel]): + """Contains and manages mappings of a single preset.""" + + # workaround for typing: https://github.com/python/mypy/issues/4236 + @overload + def __init__(self: Preset[Mapping], path: Optional[os.PathLike] = None): ... + + @overload + def __init__( + self, + path: Optional[os.PathLike] = None, + mapping_factory: Type[MappingModel] = ..., + ): ... + + def __init__( + self, + path: Optional[os.PathLike] = None, + mapping_factory=Mapping, + ) -> None: + self._mappings: Dict[InputCombination, MappingModel] = {} + # a copy of mappings for keeping track of changes + self._saved_mappings: Dict[InputCombination, MappingModel] = {} + self._path: Optional[os.PathLike] = path + + # the mapping class which is used by load() + self._mapping_factory: Type[MappingModel] = mapping_factory + + def __iter__(self) -> Iterator[MappingModel]: + """Iterate over Mapping objects.""" + return iter(self._mappings.copy().values()) + + def __len__(self) -> int: + return len(self._mappings) + + def __bool__(self): + # otherwise __len__ will be used which results in False for a preset + # without mappings + return True + + def has_unsaved_changes(self) -> bool: + """Check if there are unsaved changed.""" + return self._mappings != self._saved_mappings + + def remove(self, combination: InputCombination) -> None: + """Remove a mapping from the preset by providing the InputCombination.""" + + if not isinstance(combination, InputCombination): + raise TypeError( + f"combination must by of type InputCombination, got {type(combination)}" + ) + + for permutation in combination.get_permutations(): + if permutation in self._mappings.keys(): + combination = permutation + break + try: + mapping = self._mappings.pop(combination) + mapping.remove_combination_changed_callback() + except KeyError: + logger.debug( + "unable to remove non-existing mapping with combination = %s", + combination, + ) + pass + + def add(self, mapping: MappingModel) -> None: + """Add a mapping to the preset.""" + for permutation in mapping.input_combination.get_permutations(): + if permutation in self._mappings: + raise KeyError( + "A mapping with this input_combination: " + f"{permutation} already exists", + ) + + mapping.set_combination_changed_callback(self._combination_changed_callback) + self._mappings[mapping.input_combination] = mapping + + def empty(self) -> None: + """Remove all mappings and custom configs without saving. + note: self.has_unsaved_changes() will report True + """ + for mapping in self._mappings.values(): + mapping.remove_combination_changed_callback() + self._mappings = {} + + def clear(self) -> None: + """Remove all mappings and also self.path.""" + self.empty() + self._saved_mappings = {} + self.path = None + + def load(self) -> None: + """Load from the mapping from the disc, clears all existing mappings.""" + logger.info('Loading preset from "%s"', self.path) + + if not self.path or not os.path.exists(self.path): + raise FileNotFoundError(f'Tried to load non-existing preset "{self.path}"') + + self._saved_mappings = self._get_mappings_from_disc() + self.empty() + for mapping in self._saved_mappings.values(): + # use the public add method to make sure + # the _combination_changed_callback is attached + self.add(mapping.copy()) + + def _is_mapped_multiple_times(self, input_combination: InputCombination) -> bool: + """Check if the event combination maps to multiple mappings.""" + all_input_combinations = {mapping.input_combination for mapping in self} + permutations = set(input_combination.get_permutations()) + union = permutations & all_input_combinations + # if there are more than one matches, then there is a duplicate + return len(union) > 1 + + def _has_valid_input_combination(self, mapping: UIMapping) -> bool: + """Check if the mapping has a valid input event combination.""" + is_a_combination = isinstance(mapping.input_combination, InputCombination) + is_empty = mapping.input_combination == InputCombination.empty_combination() + return is_a_combination and not is_empty + + def save(self) -> None: + """Dump as JSON to self.path.""" + + if not self.path: + logger.debug("unable to save preset without a path set Preset.path first") + return + + PathUtils.touch(self.path) + if not self.has_unsaved_changes(): + logger.debug("Not saving unchanged preset") + return + + logger.info("Saving preset to %s", self.path) + + preset_list = [] + saved_mappings = {} + for mapping in self: + if not mapping.is_valid(): + if not self._has_valid_input_combination(mapping): + # we save invalid mappings except for those with an invalid + # input_combination + logger.debug("Skipping invalid mapping %s", mapping) + continue + + if self._is_mapped_multiple_times(mapping.input_combination): + # todo: is this ever executed? it should not be possible to + # reach this + logger.debug( + "skipping mapping with duplicate event combination %s", + mapping, + ) + continue + + mapping_dict = mapping.dict(exclude_defaults=True) + mapping_dict["input_combination"] = mapping.input_combination.to_config() + combination = mapping.input_combination + preset_list.append(mapping_dict) + + saved_mappings[combination] = mapping.copy() + saved_mappings[combination].remove_combination_changed_callback() + + with open(self.path, "w") as file: + json.dump(preset_list, file, indent=4) + file.write("\n") + + self._saved_mappings = saved_mappings + + def is_valid(self) -> bool: + return False not in [mapping.is_valid() for mapping in self] + + def get_mapping( + self, combination: Optional[InputCombination] + ) -> Optional[MappingModel]: + """Return the Mapping that is mapped to this InputCombination.""" + if not combination: + return None + + if not isinstance(combination, InputCombination): + raise TypeError( + f"combination must by of type InputCombination, got {type(combination)}" + ) + + for permutation in combination.get_permutations(): + existing = self._mappings.get(permutation) + if existing is not None: + return existing + return None + + def dangerously_mapped_btn_left(self) -> bool: + """Return True if this mapping disables BTN_Left.""" + if (ecodes.EV_KEY, ecodes.BTN_LEFT) not in [ + m.input_combination[0].type_and_code for m in self + ]: + return False + + values: List[str | Tuple[int, int] | None] = [] + for mapping in self: + if mapping.output_symbol is None: + continue + values.append(mapping.output_symbol.lower()) + values.append(mapping.get_output_type_code()) + + return ( + "btn_left" not in values + or InputConfig.btn_left().type_and_code not in values + ) + + def _combination_changed_callback( + self, new: InputCombination, old: InputCombination + ) -> None: + for permutation in new.get_permutations(): + if permutation in self._mappings.keys() and permutation != old: + raise KeyError("combination already exists in the preset") + self._mappings[new] = self._mappings.pop(old) + + def _update_saved_mappings(self) -> None: + if self.path is None: + return + + if not os.path.exists(self.path): + self._saved_mappings = {} + return + self._saved_mappings = self._get_mappings_from_disc() + + def _get_mappings_from_disc(self) -> Dict[InputCombination, MappingModel]: + mappings: Dict[InputCombination, MappingModel] = {} + if not self.path: + logger.debug("unable to read preset without a path set Preset.path first") + return mappings + + if os.stat(self.path).st_size == 0: + logger.debug("got empty file") + return mappings + + with open(self.path, "r") as file: + try: + preset_list = json.load(file) + except json.JSONDecodeError: + logger.error("unable to decode json file: %s", self.path) + return mappings + + for mapping_dict in preset_list: + if not isinstance(mapping_dict, dict): + logger.error( + "Expected mapping to be a dict: %s %s", + type(mapping_dict), + mapping_dict, + ) + continue + + try: + mapping = self._mapping_factory(**mapping_dict) + except Exception as error: + logger.error( + "failed to Validate mapping for %s: %s", + mapping_dict.get("input_combination"), + error, + ) + continue + + mappings[mapping.input_combination] = mapping + return mappings + + @property + def path(self) -> Optional[os.PathLike]: + return self._path + + @path.setter + def path(self, path: Optional[os.PathLike]): + if path != self.path: + self._path = path + self._update_saved_mappings() + + @property + def name(self) -> Optional[str]: + """The name of the preset.""" + if self.path: + return os.path.basename(self.path).split(".")[0] + return None diff --git a/inputremapper/configs/validation_errors.py b/inputremapper/configs/validation_errors.py new file mode 100644 index 0000000..af9d213 --- /dev/null +++ b/inputremapper/configs/validation_errors.py @@ -0,0 +1,137 @@ +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + +"""Exceptions that are thrown when configurations are incorrect.""" + +# can't merge this with exceptions.py, because I want error constructors here to +# be intelligent to avoid redundant code, and they need imports, which would cause +# circular imports. + +# pydantic only catches ValueError, TypeError, and AssertionError + +from __future__ import annotations + +from typing import Optional + +from evdev.ecodes import EV_KEY + +from inputremapper.configs.keyboard_layout import keyboard_layout +from inputremapper.injection.global_uinputs import GlobalUInputs + + +class OutputSymbolVariantError(ValueError): + def __init__(self): + super().__init__( + "Missing Argument: Mapping must either contain " + "`output_symbol` or `output_type` and `output_code`" + ) + + +class TriggerPointInRangeError(ValueError): + def __init__(self, input_config): + super().__init__( + f"{input_config = } maps an absolute axis to a button, but the " + "trigger point (event.analog_threshold) is not between -100[%] " + "and 100[%]" + ) + + +class OnlyOneAnalogInputError(ValueError): + def __init__(self, analog_events): + super().__init__( + f"Cannot map a combination of multiple analog inputs: {analog_events}" + "add trigger points (event.value != 0) to map as a button" + ) + + +class SymbolNotAvailableInTargetError(ValueError): + def __init__(self, symbol, target): + code = keyboard_layout.get(symbol) + + fitting_targets = GlobalUInputs.find_fitting_default_uinputs(EV_KEY, code) + fitting_targets_string = '", "'.join(fitting_targets) + + super().__init__( + f'The output_symbol "{symbol}" is not available for the "{target}" ' + + f'target. Try "{fitting_targets_string}".' + ) + + +class OutputSymbolUnknownError(ValueError): + def __init__(self, symbol: str): + super().__init__( + f'The output_symbol "{symbol}" is not a macro and not a valid ' + + "keycode-name" + ) + + +class MacroButTypeOrCodeSetError(ValueError): + def __init__(self): + super().__init__( + "output_symbol is a macro: output_type " "and output_code must be None" + ) + + +class SymbolAndCodeMismatchError(ValueError): + def __init__(self, symbol, code): + super().__init__( + "output_symbol and output_code mismatch: " + f"output macro is {symbol} -> {keyboard_layout.get(symbol)} " + f"but output_code is {code} -> {keyboard_layout.get_name(code)} " + ) + + +class WrongMappingTypeForKeyError(ValueError): + def __init__(self): + super().__init__("Wrong mapping_type for key input") + + +class MissingMacroOrKeyError(ValueError): + def __init__(self): + super().__init__("Missing macro or key") + + +class MissingOutputAxisError(ValueError): + def __init__(self, analog_input_config, output_type): + super().__init__( + "Missing output axis: " + f'"{analog_input_config}" is used as analog input, ' + f"but the {output_type = } is not an axis " + ) + + +class MacroError(ValueError): + """Macro syntax errors.""" + + def __init__(self, symbol: Optional[str] = None, msg="Error while parsing a macro"): + self.symbol = symbol + super().__init__(msg) + + +def pydantify(error: type): + """Generate a string as it would appear IN pydantic error types. + + This does not include the base class name, which is transformed to snake case in + pydantic. Example pydantic error type: "value_error.foobar" for FooBarError. + """ + # See https://github.com/pydantic/pydantic/discussions/5112 + lower_classname = error.__name__.lower() + if lower_classname.endswith("error"): + return lower_classname[: -len("error")] + return lower_classname diff --git a/inputremapper/daemon.py b/inputremapper/daemon.py new file mode 100644 index 0000000..c229241 --- /dev/null +++ b/inputremapper/daemon.py @@ -0,0 +1,548 @@ +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + + +"""Starts injecting keycodes based on the configuration. + +https://github.com/dasbus-project/dasbus/tree/master/examples/03_helloworld +""" + +import atexit +import json +import os +import sys +import time +from pathlib import PurePath +from typing import Dict, Optional, Protocol + +import gi +from dasbus.error import DBusError +from dasbus.connection import SystemMessageBus +from dasbus.identifier import DBusServiceIdentifier +from dasbus.loop import EventLoop + +from inputremapper.configs.global_config import GlobalConfig +from inputremapper.configs.keyboard_layout import keyboard_layout +from inputremapper.configs.paths import PathUtils +from inputremapper.configs.preset import Preset +from inputremapper.groups import groups +from inputremapper.injection.global_uinputs import GlobalUInputs +from inputremapper.injection.injector import Injector, InjectorState +from inputremapper.injection.macros.macro import macro_variables +from inputremapper.injection.mapping_handlers.mapping_parser import MappingParser +from inputremapper.logging.logger import logger +from inputremapper.user import UserUtils + +gi.require_version("GLib", "2.0") + + +SYSTEM_BUS = SystemMessageBus() + +DAEMON = DBusServiceIdentifier( + namespace=("inputremapper", "Control"), + message_bus=SYSTEM_BUS, +) + +# timeout in milliseconds +BUS_TIMEOUT = 10000 + + +class AutoloadHistory: + """Contains the autoloading history and constraints.""" + + def __init__(self): + """Construct this with an empty history.""" + # preset of device -> (timestamp, preset) + self._autoload_history = {} + + def remember(self, group_key: str, preset: str): + """Remember when this preset was autoloaded for the device.""" + self._autoload_history[group_key] = (time.time(), preset) + + def forget(self, group_key: str): + """The injection was stopped or started by hand.""" + if group_key in self._autoload_history: + del self._autoload_history[group_key] + + def may_autoload(self, group_key: str, preset: str): + """Check if this autoload would be redundant. + + This is needed because udev triggers multiple times per hardware + device, and because it should be possible to stop the injection + by unplugging the device if the preset goes wrong or if input-remapper + has some bug that prevents the computer from being controlled. + + For that unplug and reconnect the device twice within a 15 seconds + timeframe which will then not ask for autoloading again. Wait 3 + seconds between replugging. + """ + if group_key not in self._autoload_history: + return True + + if self._autoload_history[group_key][1] != preset: + return True + + # bluetooth devices go to standby mode after some time. After a + # certain time of being disconnected it should be legit to autoload + # again. It takes 2.5 seconds for me when quickly replugging my usb + # mouse until the daemon is asked to autoload again. Redundant calls + # by udev to autoload for the device seem to happen within 0.2 + # seconds in my case. + now = time.time() + threshold = 15 # seconds + if self._autoload_history[group_key][0] < now - threshold: + return True + + return False + + +class DaemonProxy(Protocol): # pragma: no cover + """The interface provided over the dbus.""" + + def stop_injecting(self, group_key: str) -> None: ... + + def get_state(self, group_key: str) -> InjectorState: ... + + def start_injecting(self, group_key: str, preset: str) -> bool: ... + + def stop_all(self) -> None: ... + + def set_config_dir(self, config_dir: str) -> None: ... + + def autoload(self) -> None: ... + + def autoload_single(self, group_key: str) -> None: ... + + def hello(self, out: str) -> str: ... + + def quit(self) -> None: ... + + +class Daemon: + """Starts injecting keycodes based on the configuration. + + Can be talked to either over dbus or by instantiating it. + + The Daemon may not have any knowledge about the logged in user, so it + can't read any config files. It has to be told what to do and will + continue to do so afterwards, but it can't decide to start injecting + on its own. + """ + + # https://dbus.freedesktop.org/doc/dbus-specification.html#type-system + __dbus_xml__ = f""" + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + """ + + def __init__( + self, + global_config: GlobalConfig, + global_uinputs: GlobalUInputs, + mapping_parser: MappingParser, + ) -> None: + """Constructs the daemon.""" + logger.debug("Creating daemon") + + self.global_config = global_config + self.global_uinputs = global_uinputs + self.mapping_parser = mapping_parser + + self.injectors: Dict[str, Injector] = {} + + self.config_dir = None + + if UserUtils.user != "root": + # If started via sudo, UserUtils.user is the actual user, and the config + # path is valid. If running as a systemd service, it is root, and the path + # invalid. + self.set_config_dir(PathUtils.get_config_path()) + + # check privileges + if os.getuid() != 0: + logger.warning("The service usually needs elevated privileges") + + self.autoload_history = AutoloadHistory() + self.refreshed_devices_at = 0 + + atexit.register(self.stop_all) + + # initialize stuff that is needed alongside the daemon process + macro_variables.start() + + @classmethod + def connect(cls, fallback: bool = True) -> Optional[DaemonProxy]: + """Get a proxy to start and stop injecting keystrokes. + + Parameters + ---------- + fallback + If true, starts the daemon via pkexec if it cannot connect. + """ + try: + proxy = DAEMON.get_proxy() + # Calling proxy.Introspect to check if the proxy is running + # https://dbus.freedesktop.org/doc/dbus-tutorial.html#introspection + proxy.Introspect(timeout=BUS_TIMEOUT) + logger.info("Connected to the service") + except DBusError as error: + if not fallback: + logger.error("Service not running? %s", error) + return None + + logger.info("Starting the service") + # Blocks until pkexec is done asking for the password. + # Runs via input-remapper-control so that auth_admin_keep works + # for all pkexec calls of the gui + debug = " -d" if logger.is_debug() else "" + cmd = f"pkexec input-remapper-control --command start-daemon {debug}" + + # using pkexec will also cause the service to continue running in + # the background after the gui has been closed, which will keep + # the injections ongoing + + logger.debug("Running `%s`", cmd) + os.system(cmd) + time.sleep(0.2) + + # try a few times if the service was just started + for attempt in range(3): + try: + proxy = DAEMON.get_proxy() + proxy.Introspect(timeout=BUS_TIMEOUT) + break + except DBusError as error: + logger.debug("Attempt %d to reach the service failed:", attempt + 1) + logger.debug('"%s"', error) + time.sleep(0.2) + else: + logger.error("Failed to connect to the service") + sys.exit(8) + + if UserUtils.user != "root": + config_path = PathUtils.get_config_path() + logger.debug('Telling service about "%s"', config_path) + proxy.set_config_dir(PathUtils.get_config_path(), timeout=2000) + + return proxy + + def publish(self) -> None: + """Make the dbus interface available.""" + try: + SYSTEM_BUS.publish_object(DAEMON.object_path, self) + SYSTEM_BUS.register_service(DAEMON.service_name) + except ConnectionError as error: + logger.error("Is the service already running? (%s)", str(error)) + sys.exit(9) + + def run(self) -> None: + """Start the daemons loop. Blocks until the daemon stops.""" + loop = EventLoop() + logger.debug("Running daemon") + loop.run() + + def refresh(self, group_key: Optional[str] = None) -> None: + """Refresh groups if the specified group is unknown. + + Parameters + ---------- + group_key + unique identifier used by the groups object + """ + now = time.time() + if now - 10 > self.refreshed_devices_at: + logger.debug("Refreshing because last info is too old") + # it may take a bit of time until devices are visible after changes + time.sleep(0.1) + groups.refresh() + self.refreshed_devices_at = now + return + + if not groups.find(key=group_key): + logger.debug('Refreshing because "%s" is unknown', group_key) + time.sleep(0.1) + groups.refresh() + self.refreshed_devices_at = now + + def stop_injecting(self, group_key: str) -> None: + """Stop injecting the preset mappings for a single device.""" + if self.injectors.get(group_key) is None: + logger.debug( + 'Tried to stop injector, but none is running for group "%s"', + group_key, + ) + return + + self.injectors[group_key].stop_injecting() + self.autoload_history.forget(group_key) + + def get_state(self, group_key: str) -> InjectorState: + """Get the injectors state.""" + injector = self.injectors.get(group_key) + return injector.get_state() if injector else InjectorState.UNKNOWN + + def set_config_dir(self, config_dir: str) -> None: + """All future operations will use this config dir. + + Existing injections (possibly of the previous user) will be kept + alive, call stop_all to stop them. + + Parameters + ---------- + config_dir + This path contains config.json, xmodmap.json and the + presets directory + """ + config_path = PurePath(config_dir, "config.json") + if not os.path.exists(config_path): + logger.error('"%s" does not exist', config_path) + return + + self.config_dir = config_dir + self.global_config.load_config(str(config_path)) + + def _autoload(self, group_key: str) -> None: + """Check if autoloading is a good idea, and if so do it. + + Parameters + ---------- + group_key + unique identifier used by the groups object + """ + self.refresh(group_key) + + group = groups.find(key=group_key) + if group is None: + # even after groups.refresh, the device is unknown, so it's + # either not relevant for input-remapper, or not connected yet + return + + preset = self.global_config.get_autoload_preset(group.key) + + if preset is None: + # no autoloading is configured for this device + return + + if not isinstance(preset, str): + # maybe another dict or something, who knows. Broken config + logger.error("Expected a string for autoload, but got %s", preset) + return + + logger.info('Autoloading for "%s"', group.key) + + if not self.autoload_history.may_autoload(group.key, preset): + logger.info( + 'Not autoloading the same preset "%s" again for group "%s"', + preset, + group.key, + ) + return + + self.start_injecting(group.key, preset) + self.autoload_history.remember(group.key, preset) + + def autoload_single(self, group_key: str) -> None: + """Inject the configured autoload preset for the device. + + If the preset is already being injected, it won't autoload it again. + + Parameters + ---------- + group_key + unique identifier used by the groups object + """ + # avoid some confusing logs and filter obviously invalid requests + if group_key.startswith("input-remapper"): + return + + logger.info('Request to autoload for "%s"', group_key) + + if self.config_dir is None: + logger.error( + 'Request to autoload "%s" before a user told the service about their ' + "session using set_config_dir", + group_key, + ) + return + + self._autoload(group_key) + + def autoload(self) -> None: + """Load all autoloaded presets for the current config_dir. + + If the preset is already being injected, it won't autoload it again. + """ + if self.config_dir is None: + logger.error( + "Request to autoload all before a user told the service about their " + "session using set_config_dir", + ) + return + + autoload_presets = list(self.global_config.iterate_autoload_presets()) + + logger.info("Autoloading for all devices") + + if len(autoload_presets) == 0: + logger.error("No presets configured to autoload") + return + + for group_key, _ in autoload_presets: + self._autoload(group_key) + + def start_injecting(self, group_key: str, preset_name: str) -> bool: + """Start injecting the preset for the device. + + Returns True on success. If an injection is already ongoing for + the specified device it will stop it automatically first. + + Parameters + ---------- + group_key + The unique key of the group + preset_name + The name of the preset + """ + logger.info('Request to start injecting for "%s"', group_key) + + self.refresh(group_key) + + if self.config_dir is None: + logger.error( + "Request to start an injectoin before a user told the service about " + "their session using set_config_dir", + ) + return False + + group = groups.find(key=group_key) + + if group is None: + logger.error('Could not find group "%s"', group_key) + return False + + preset_path = PurePath( + self.config_dir, + "presets", + PathUtils.sanitize_path_component(group.name), + f"{preset_name}.json", + ) + + # Path to a dump of the xkb mappings, to provide more human + # readable keys in the correct keyboard layout to the service. + # The service cannot use `xmodmap -pke` because it's running via + # systemd. + xmodmap_path = os.path.join(self.config_dir, "xmodmap.json") + try: + with open(xmodmap_path, "r") as file: + # do this for each injection to make sure it is up to + # date when the system layout changes. + xmodmap = json.load(file) + logger.debug('Using keycodes from "%s"', xmodmap_path) + + # this creates the keyboard_layout._xmodmap, which we need to do now + # otherwise it might be created later which will override the changes + # we do here. + # Do we really need to lazyload in the keyboard_layout? + # this kind of bug is stupid to track down + keyboard_layout.get_name(0) + keyboard_layout.update(xmodmap) + # the service now has process wide knowledge of xmodmap + # keys of the users session + except FileNotFoundError: + logger.error('Could not find "%s"', xmodmap_path) + + preset = Preset(preset_path) + + try: + preset.load() + except FileNotFoundError as error: + logger.error(str(error)) + return False + + for mapping in preset: + # only create those uinputs that are required to avoid + # confusing the system. Seems to be especially important with + # gamepads, because some apps treat the first gamepad they found + # as the only gamepad they'll ever care about. + self.global_uinputs.prepare_single(mapping.target_uinput) + + if self.injectors.get(group_key) is not None: + self.stop_injecting(group_key) + + try: + injector = Injector( + group, + preset, + self.mapping_parser, + ) + injector.start() + self.injectors[group.key] = injector + except OSError: + # I think this will never happen, probably leftover from + # some earlier version + return False + + return True + + def stop_all(self) -> None: + """Stop all injections.""" + logger.info("Stopping all injections") + for group_key in list(self.injectors.keys()): + self.stop_injecting(group_key) + + def hello(self, out: str) -> str: + """Used for tests.""" + logger.info('Received "%s" from client', out) + return out + + def quit(self) -> None: + """Stop the process.""" + # Beware, that stop_all will also be called via atexit.register(self.stop_all) + logger.info("Got command to stop the daemon process") + sys.exit(0) diff --git a/inputremapper/exceptions.py b/inputremapper/exceptions.py new file mode 100644 index 0000000..a53e085 --- /dev/null +++ b/inputremapper/exceptions.py @@ -0,0 +1,65 @@ +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + + +"""Exceptions specific to inputremapper.""" + + +class Error(Exception): + """Base class for exceptions in inputremapper. + + We can catch all inputremapper exceptions with this. + """ + + +class UinputNotAvailable(Error): + """If an expected UInput is not found (anymore).""" + + def __init__(self, name: str): + super().__init__(f"{name} is not defined or unplugged") + + +class EventNotHandled(Error): + """For example mapping to BTN_LEFT on a keyboard target.""" + + def __init__(self, event): + super().__init__(f"Event {event} can not be handled by the configured target") + + +class MappingParsingError(Error): + """Anything that goes wrong during the creation of handlers from the mapping.""" + + def __init__(self, msg: str, *, mapping=None, mapping_handler=None): + self.mapping_handler = mapping_handler + self.mapping = mapping + super().__init__(msg) + + +class InputEventCreationError(Error): + """An input-event failed to be created due to broken factory/constructor calls.""" + + def __init__(self, msg: str): + super().__init__(msg) + + +class DataManagementError(Error): + """Any error that happens in the DataManager.""" + + def __init__(self, msg: str): + super().__init__(msg) diff --git a/inputremapper/groups.py b/inputremapper/groups.py new file mode 100644 index 0000000..13674b5 --- /dev/null +++ b/inputremapper/groups.py @@ -0,0 +1,567 @@ +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + +"""Find, classify and group devices. + +Because usually connected devices pop up multiple times in /dev/input, +in order to provide multiple types of input devices (e.g. a keyboard and a +graphics-tablet at the same time) + +Those groups are what is being displayed in the device dropdown, and +events are being read from all of the paths of an individual group in the gui +and the injector. +""" + +from __future__ import annotations + +import asyncio +import enum +import json +import multiprocessing +import os +import re +import threading +from typing import List, Optional + +import evdev +from evdev.ecodes import ( + EV_KEY, + EV_ABS, + KEY_CAMERA, + EV_REL, + BTN_STYLUS, + ABS_MT_POSITION_X, + REL_X, + KEY_A, + BTN_LEFT, + REL_Y, + REL_WHEEL, +) + +from inputremapper.configs.paths import PathUtils +from inputremapper.logging.logger import logger +from inputremapper.utils import get_device_hash + +TABLET_KEYS = [ + evdev.ecodes.BTN_STYLUS, + evdev.ecodes.BTN_TOOL_BRUSH, + evdev.ecodes.BTN_TOOL_PEN, + evdev.ecodes.BTN_TOOL_RUBBER, +] + + +class DeviceType(str, enum.Enum): + GAMEPAD = "gamepad" + KEYBOARD = "keyboard" + MOUSE = "mouse" + TOUCHPAD = "touchpad" + GRAPHICS_TABLET = "graphics-tablet" + CAMERA = "camera" + UNKNOWN = "unknown" + + +if not hasattr(evdev.InputDevice, "path"): + # for evdev < 1.0.0 patch the path property + @property + def path(device): + return device.fn + + evdev.InputDevice.path = path + + +def _is_gamepad(capabilities): + """Check if joystick movements are available for preset.""" + # A few buttons that indicate a gamepad + buttons = { + evdev.ecodes.BTN_BASE, + evdev.ecodes.BTN_A, + evdev.ecodes.BTN_THUMB, + evdev.ecodes.BTN_TOP, + evdev.ecodes.BTN_DPAD_DOWN, + evdev.ecodes.BTN_GAMEPAD, + } + if not buttons.intersection(capabilities.get(EV_KEY, [])): + # no button is in the key capabilities + return False + + # joysticks + abs_capabilities = capabilities.get(EV_ABS, []) + if evdev.ecodes.ABS_X not in abs_capabilities: + return False + if evdev.ecodes.ABS_Y not in abs_capabilities: + return False + + return True + + +def _is_mouse(capabilities): + """Check if the capabilities represent those of a mouse.""" + # Based on observation, those capabilities need to be present to get an + # UInput recognized as mouse + + # mouse movements + if REL_X not in capabilities.get(EV_REL, []): + return False + if REL_Y not in capabilities.get(EV_REL, []): + return False + + # at least the vertical mouse wheel + if REL_WHEEL not in capabilities.get(EV_REL, []): + return False + + # and a mouse click button + if BTN_LEFT not in capabilities.get(EV_KEY, []): + return False + + return True + + +def _is_graphics_tablet(capabilities): + """Check if the capabilities represent those of a graphics tablet.""" + if BTN_STYLUS in capabilities.get(EV_KEY, []): + return True + return False + + +def _is_touchpad(capabilities): + """Check if the capabilities represent those of a touchpad.""" + if ABS_MT_POSITION_X in capabilities.get(EV_ABS, []): + return True + return False + + +def _is_keyboard(capabilities): + """Check if the capabilities represent those of a keyboard.""" + if KEY_A in capabilities.get(EV_KEY, []): + return True + return False + + +def _is_camera(capabilities): + """Check if the capabilities represent those of a camera.""" + key_capa = capabilities.get(EV_KEY) + return key_capa and len(key_capa) == 1 and key_capa[0] == KEY_CAMERA + + +def classify(device) -> DeviceType: + """Figure out what kind of device this is. + + Use this instead of functions like _is_keyboard to avoid getting false + positives. + """ + capabilities = device.capabilities(absinfo=False) + + if _is_graphics_tablet(capabilities): + # check this before is_gamepad to avoid classifying abs_x + # as joysticks when they are actually stylus positions + return DeviceType.GRAPHICS_TABLET + + if _is_touchpad(capabilities): + return DeviceType.TOUCHPAD + + if _is_gamepad(capabilities): + return DeviceType.GAMEPAD + + if _is_mouse(capabilities): + return DeviceType.MOUSE + + if _is_camera(capabilities): + return DeviceType.CAMERA + + if _is_keyboard(capabilities): + # very low in the chain to avoid classifying most devices + # as keyboard, because there are many with ev_key capabilities + return DeviceType.KEYBOARD + + return DeviceType.UNKNOWN + + +DENYLIST = [".*Yubico.*YubiKey.*", "Eee PC WMI hotkeys"] + + +def is_inputremapper_device(device: evdev.InputDevice) -> bool: + """Return whether the device was created by input-remapper.""" + name = str(device.name or "") + phys = str(device.phys or "") + return name.startswith("input-remapper") or phys.startswith("input-remapper") + + +def is_denylisted(device: evdev.InputDevice): + """Check if a device should not be used in input-remapper. + + Parameters + ---------- + device + """ + for name in DENYLIST: + if re.match(name, str(device.name), re.IGNORECASE): + return True + + return False + + +def get_unique_key(device: evdev.InputDevice): + """Find a string key that is unique for a single hardware device. + + All InputDevices in /dev/input that originate from the same physical + hardware device should return the same key via this function. + """ + # Keys that should not be used: + # - device.phys is empty sometimes and varies across virtual + # subdevices + # - device.version varies across subdevices + return ( + # device.info bustype, vendor and product are unique for + # a product, but multiple similar device models would be grouped + # in the same group + f"{device.info.bustype}_" + f"{device.info.vendor}_" + f"{device.info.product}_" + # device.uniq is empty most of the time. It seems to be the only way to + # distinguish multiple connected bluetooth gamepads + f"{device.uniq}_" + # deivce.phys if "/input..." is removed from it, because the first + # chunk seems to be unique per hardware (if it's not completely empty) + f'{device.phys.split("/")[0] or "-"}' + ) + + +class _Group: + """Groups multiple devnodes together. + + For example, name could be "Logitech USB Keyboard", devices + might contain "Logitech USB Keyboard System Control" and "Logitech USB + Keyboard". paths is a list of files in /dev/input that belong to the + devices. + + They are grouped by usb port. + + Members + ------- + name : str + A human readable name, generated from .names, that should always + look the same for a device model. It is used to generate the + presets folder structure + """ + + def __init__( + self, + paths: List[os.PathLike], + names: List[str], + types: List[DeviceType | str], + key: str, + ): + """Specify a group + + Parameters + ---------- + paths + Paths in /dev/input of the grouped devices + names + Names of the grouped devices + types + Types of the grouped devices + key + Unique identifier of the group. + + It should be human readable and if possible equal to group.name. + To avoid multiple groups having the same key, a number starting + with 2 followed by a whitespace should be added to it: + "key", "key 2", "key 3", ... + + This is important for the autoloading configuration. If the key + changed over reboots, then autoloading would break. + """ + # There might be multiple groups with the same name here when two + # similar devices are connected to the computer. + self.name: str = sorted(names, key=len)[0] + + self.key = key + + self.paths = paths + self.names = names + self.types = [DeviceType(type_) for type_ in types] + + def get_preset_path(self, preset: Optional[str] = None): + """Get a path to the stored preset, or to store a preset to. + + This path is unique per device-model, not per group. Groups + of the same model share the same preset paths. + """ + return PathUtils.get_preset_path(self.name, preset) + + def get_devices(self) -> List[evdev.InputDevice]: + devices: List[evdev.InputDevice] = [] + for path in self.paths: + try: + devices.append(evdev.InputDevice(path)) + except (FileNotFoundError, OSError): + logger.error('Could not find "%s"', path) + continue + return devices + + def dumps(self): + """Return a string representing this object.""" + return json.dumps( + dict(paths=self.paths, names=self.names, types=self.types, key=self.key), + ) + + @classmethod + def loads(cls, serialized: str): + """Load a serialized representation.""" + group = cls(**json.loads(serialized)) + return group + + def __repr__(self): + return f"" + + +class _FindGroups(threading.Thread): + """Thread to get the devices that can be worked with. + + Since InputDevice destructors take quite some time, do this + asynchronously so that they can take as much time as they want without + slowing down the initialization. + """ + + def __init__(self, pipe: multiprocessing.Pipe): + """Construct the process. + + Parameters + ---------- + pipe + used to communicate the result + """ + self.pipe = pipe + super().__init__() + + def run(self): + """Do what get_groups describes.""" + # evdev needs asyncio to work + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + + logger.debug("Discovering device paths") + + # group them together by usb device because there could be stuff like + # "Logitech USB Keyboard" and "Logitech USB Keyboard Consumer Control" + grouped = {} + + # Sorting the paths is extremely important if two devices of the same make + # (two identical mice for example) are connected. Without sorting, the order + # depends on the order of plugging devices in. This causes the most recently + # plugged in device to get the first group. + # Without sorting: + # - mouse1 /input1 plugged in. group.key: "Mouse". Autoloads as configured. + # - mouse2 /input2 plugged in. group.key: "Mouse". mouse1 gets a different + # group.key: "Mouse 2", even though an injection is running there. Bug. + # - I believe this is what causes input-remapper to stop the injection for + # mouse1 and then start it for mouse2 instead. So plugging in a second + # device breaks autoloading. Sorting fixes it. + # With sorting, mouse1 always gets group.key "Mouse", and mouse2 always + # "Mouse 2" (Unless only mouse2 is plugged in, then it gets "Mouse") + for path in sorted(evdev.list_devices()): + try: + device = evdev.InputDevice(path) + except Exception as error: + # Observed exceptions in journalctl: + # - "SystemError: returned NULL + # without setting an error" + # - "FileNotFoundError: [Errno 2] No such file or directory: + # '/dev/input/event12'" + logger.error( + 'Failed to access path "%s": %s %s', + path, + error.__class__.__name__, + str(error), + ) + continue + + if device.name == "Power Button": + continue + + if is_inputremapper_device(device): + logger.debug('Skipping input-remapper device "%s"', device.name) + continue + + device_type = classify(device) + + if device_type == DeviceType.CAMERA: + continue + + # https://www.kernel.org/doc/html/latest/input/event-codes.html + capabilities = device.capabilities(absinfo=False) + + key_capa = capabilities.get(EV_KEY) + abs_capa = capabilities.get(EV_ABS) + rel_capa = capabilities.get(EV_REL) + + if key_capa is None and abs_capa is None and rel_capa is None: + # skip devices that don't provide buttons or axes that can be mapped + logger.debug('"%s" has no useful capabilities', device.name) + continue + + if is_denylisted(device): + logger.debug('"%s" is denylisted', device.name) + continue + + key = get_unique_key(device) + if grouped.get(key) is None: + grouped[key] = [] + + logger.debug( + 'Found %s "%s" at "%s", hash "%s", key "%s"', + device_type.value, + device.name, + path, + get_device_hash(device), + key, + ) + + grouped[key].append((device.name, path, device_type)) + + # now write down all the paths of that group + result = [] + used_keys = set() + for group in grouped.values(): + names = [entry[0] for entry in group] + devs = [entry[1] for entry in group] + + # generate a human readable key + shortest_name = sorted(names, key=len)[0] + key = shortest_name + i = 2 + while key in used_keys: + key = f"{shortest_name} {i}" + i += 1 + used_keys.add(key) + + logger.debug('Creating group with key "%s", paths "%s"', key, devs) + group = _Group( + key=key, + paths=devs, + names=names, + types=sorted( + list({item[2] for item in group if item[2] != DeviceType.UNKNOWN}) + ), + ) + + result.append(group.dumps()) + + self.pipe.send(json.dumps(result)) + loop.close() # avoid resource allocation warnings + # now that everything is sent via the pipe, the InputDevice + # destructors can go on and take ages to complete in the thread + # without blocking anything + + +class _Groups: + """Contains and manages all groups.""" + + def __init__(self): + self._groups: List[_Group] = None + + def refresh(self): + """Look for devices and group them together. + + Since this needs to do some stuff with /dev and spawn processes the + result is cached. Use refresh_groups if you need up to date + devices. + """ + pipe = multiprocessing.Pipe() + _FindGroups(pipe[1]).start() + # block until groups are available + message = pipe[0].recv() + self.loads(message) + + if len(self._groups) == 0: + logger.error( + "Did not find any input device, possibly due to missing permissions" + ) + else: + keys = [f'"{group.key}"' for group in self._groups] + logger.info("Found %s", ", ".join(keys)) + + def get_groups(self) -> List[_Group]: + """Load groups and return them.""" + if self._groups is None: + # To lazy load group info only when needed. + # For example, this helps to keep logs of input-remapper-control clear when + # it doesn't need it the information. + self.refresh() + + return list(self._groups) + + def set_groups(self, new_groups: List[_Group]): + """Overwrite all groups.""" + logger.debug("Overwriting groups with %s", new_groups) + self._groups = new_groups + + def list_group_names(self) -> List[str]: + """Return a list of all 'name' properties of the groups.""" + return [ + group.name + for group in self._groups + if not group.name.startswith("input-remapper") + ] + + def dumps(self): + """Create a deserializable string representation.""" + groups = self.get_groups() + return json.dumps([group.dumps() for group in groups]) + + def loads(self, dump: str): + """Load a serialized representation created via dumps.""" + self._groups = [_Group.loads(group) for group in json.loads(dump)] + + def find( + self, + name: Optional[str] = None, + key: Optional[str] = None, + path: Optional[str] = None, + ) -> Optional[_Group]: + """Find a group that matches the provided parameters. + + Parameters + ---------- + name + "USB Keyboard" + Not unique, will return the first group that matches. + key + "USB Keyboard", "USB Keyboard 2", ... + path + "/dev/input/event3" + """ + for group in self.get_groups(): + if name and group.name != name: + continue + + if key and group.key != key: + continue + + if path and path not in group.paths: + continue + + return group + + return None + + +# TODO global objects are bad practice +groups = _Groups() diff --git a/inputremapper/gui/__init__.py b/inputremapper/gui/__init__.py new file mode 100644 index 0000000..ee13662 --- /dev/null +++ b/inputremapper/gui/__init__.py @@ -0,0 +1,6 @@ +import gi + +gi.require_version("Gdk", "3.0") +gi.require_version("Gtk", "3.0") +gi.require_version("GLib", "2.0") +gi.require_version("GtkSource", "4") diff --git a/inputremapper/gui/autocompletion.py b/inputremapper/gui/autocompletion.py new file mode 100644 index 0000000..9ceeb6f --- /dev/null +++ b/inputremapper/gui/autocompletion.py @@ -0,0 +1,457 @@ +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + + +"""Autocompletion for the editor.""" + + +import re +from typing import Dict, Optional, List, Tuple + +from evdev.ecodes import EV_KEY +from gi.repository import Gdk, Gtk, GLib, GObject + +from inputremapper.configs.keyboard_layout import keyboard_layout, DISABLE_NAME +from inputremapper.configs.mapping import MappingData +from inputremapper.gui.components.editor import CodeEditor +from inputremapper.gui.controller import Controller +from inputremapper.gui.messages.message_broker import MessageBroker, MessageType +from inputremapper.gui.messages.message_data import UInputsData +from inputremapper.gui.utils import debounce +from inputremapper.injection.macros.parse import Parser +from inputremapper.logging.logger import logger + +# no deprecated shorthand function-names +FUNCTION_NAMES = [name for name in Parser.TASK_CLASSES.keys() if len(name) > 1] +# no deprecated functions +FUNCTION_NAMES.remove("ifeq") + +Capabilities = Dict[int, List] + + +def _get_left_text(iter_: Gtk.TextIter) -> str: + buffer = iter_.get_buffer() + result = buffer.get_text(buffer.get_start_iter(), iter_, True) + result = Parser.remove_comments(result) + result = result.replace("\n", " ") + return result.lower() + + +# regex to search for the beginning of a... +PARAMETER = r".*?[(,=+]\s*" +FUNCTION_CHAIN = r".*?\)\s*\.\s*" + + +def get_incomplete_function_name(iter_: Gtk.TextIter) -> str: + """Get the word that is written left to the TextIter.""" + left_text = _get_left_text(iter_) + + # match foo in: + # bar().foo + # bar()\n.foo + # bar().\nfoo + # bar(\nfoo + # bar(\nqux=foo + # bar(KEY_A,\nfoo + # foo + match = re.match(rf"(?:{FUNCTION_CHAIN}|{PARAMETER}|^)(\w+)$", left_text) + logger.debug('get_incomplete_function_name text: "%s" match: %s', left_text, match) + + if match is None: + return "" + + return match[1] + + +def get_incomplete_parameter(iter_: Gtk.TextIter) -> Optional[str]: + """Get the parameter that is written left to the TextIter.""" + left_text = _get_left_text(iter_) + + # match foo in: + # bar(foo + # bar(a=foo + # bar(qux, foo + # foo + # bar + foo + match = re.match(rf"(?:{PARAMETER}|^)(\w+)$", left_text) + logger.debug('get_incomplete_parameter text: "%s" match: %s', left_text, match) + + if match is None: + return None + + return match[1] + + +def propose_symbols(text_iter: Gtk.TextIter, codes: List[int]) -> List[Tuple[str, str]]: + """Find key names that match the input at the cursor and are mapped to the codes.""" + incomplete_name = get_incomplete_parameter(text_iter) + + if incomplete_name is None or len(incomplete_name) <= 1: + return [] + + incomplete_name = incomplete_name.lower() + + names = list(keyboard_layout.list_names(codes=codes)) + [DISABLE_NAME] + + return [ + (name, name) + for name in names + if incomplete_name in name.lower() and incomplete_name != name.lower() + ] + + +def propose_function_names(text_iter: Gtk.TextIter) -> List[Tuple[str, str]]: + """Find function names that match the input at the cursor.""" + incomplete_name = get_incomplete_function_name(text_iter) + + if incomplete_name is None or len(incomplete_name) <= 1: + return [] + + incomplete_name = incomplete_name.lower() + + # A list of + # - ("key", "key(symbol)") + # - ("repeat", "repeat(repeats, macro)") + # etc. + function_names: List[Tuple[str, str]] = [] + + for name in FUNCTION_NAMES: + if incomplete_name in name.lower() and incomplete_name != name.lower(): + task_class = Parser.TASK_CLASSES[name] + argument_names = task_class.get_macro_argument_names() + function_names.append((name, f"{name}({', '.join(argument_names)})")) + + return function_names + + +class SuggestionLabel(Gtk.Label): + """A label with some extra internal information.""" + + __gtype_name__ = "SuggestionLabel" + + def __init__(self, display_name: str, suggestion: str): + super().__init__(label=display_name) + self.suggestion = suggestion + + +class Autocompletion(Gtk.Popover): + """Provide keyboard-controllable beautiful autocompletions. + + The one provided via source_view.get_completion() is not very appealing + """ + + __gtype_name__ = "Autocompletion" + _target_uinput: Optional[str] = None + + def __init__( + self, + message_broker: MessageBroker, + controller: Controller, + code_editor: CodeEditor, + ): + """Create an autocompletion popover. + + It will remain hidden until there is something to autocomplete. + + Parameters + ---------- + code_editor + The widget that contains the text that should be autocompleted + """ + super().__init__( + # Don't switch the focus to the popover when it shows + modal=False, + # Always show the popover below the cursor, don't move it to a different + # position based on the location within the window + constrain_to=Gtk.PopoverConstraint.NONE, + ) + + self.code_editor = code_editor + self.controller = controller + self.message_broker = message_broker + self._uinputs: Optional[Dict[str, Capabilities]] = None + self._target_key_capabilities: List[int] = [] + + self.scrolled_window = Gtk.ScrolledWindow( + min_content_width=200, + max_content_height=200, + propagate_natural_width=True, + propagate_natural_height=True, + ) + self.list_box = Gtk.ListBox() + self.list_box.get_style_context().add_class("transparent") + self.scrolled_window.add(self.list_box) + + # row-activated is on-click, + # row-selected is when scrolling through it + self.list_box.connect( + "row-activated", + self._on_suggestion_clicked, + ) + + self.add(self.scrolled_window) + + self.get_style_context().add_class("autocompletion") + + self.set_position(Gtk.PositionType.BOTTOM) + + self.code_editor.gui.connect("key-press-event", self.navigate) + + # add some delay, so that pressing the button in the completion works before + # the popover is hidden due to focus-out-event + self.code_editor.gui.connect("focus-out-event", self.on_gtk_text_input_unfocus) + + self.code_editor.gui.get_buffer().connect("changed", self.update) + + self.set_position(Gtk.PositionType.BOTTOM) + + self.visible = False + + self.attach_to_events() + self.show_all() + self.popdown() # hidden by default. this needs to happen after show_all! + + def attach_to_events(self): + self.message_broker.subscribe(MessageType.mapping, self._on_mapping_changed) + self.message_broker.subscribe(MessageType.uinputs, self._on_uinputs_changed) + + def on_gtk_text_input_unfocus(self, *_): + """The code editor was unfocused.""" + GLib.timeout_add(100, self.popdown) + # "(input-remapper-gtk:97611): Gtk-WARNING **: 16:33:56.464: GtkTextView - + # did not receive focus-out-event. If you connect a handler to this signal, + # it must return FALSE so the text view gets the event as well" + return False + + def navigate(self, _, event: Gdk.EventKey): + """Using the keyboard to select an autocompletion suggestion.""" + if not self.visible: + return + + if event.keyval == Gdk.KEY_Escape: + self.popdown() + return + + selected_row = self.list_box.get_selected_row() + + if event.keyval not in [Gdk.KEY_Down, Gdk.KEY_Up, Gdk.KEY_Return]: + # not one of the keys that controls the autocompletion. Deselect + # the row but keep it open + self.list_box.select_row(None) + return + + if event.keyval == Gdk.KEY_Return: + if selected_row is None: + # nothing selected, forward the event to the text editor + return + + # a row is selected and should be used for autocompletion + self.list_box.emit("row-activated", selected_row) + return Gdk.EVENT_STOP + + num_rows = len(self.list_box.get_children()) + + if selected_row is None: + # select the first row + if event.keyval == Gdk.KEY_Down: + new_selected_row = self.list_box.get_row_at_index(0) + + if event.keyval == Gdk.KEY_Up: + new_selected_row = self.list_box.get_row_at_index(num_rows - 1) + else: + # select the next row + selected_index = selected_row.get_index() + new_index = selected_index + + if event.keyval == Gdk.KEY_Down: + new_index += 1 + + if event.keyval == Gdk.KEY_Up: + new_index -= 1 + + if new_index < 0: + new_index = num_rows - 1 + + if new_index > num_rows - 1: + new_index = 0 + + new_selected_row = self.list_box.get_row_at_index(new_index) + + self.list_box.select_row(new_selected_row) + + self._scroll_to_row(new_selected_row) + + # don't change editor contents + return Gdk.EVENT_STOP + + def _scroll_to_row(self, row: Gtk.ListBoxRow): + """Scroll up or down so that the row is visible.""" + # unfortunately, it seems that without focusing the row it won't happen + # automatically (or whatever the reason for this is, just a wild guess) + # (the focus should not leave the code editor, so that continuing + # to write code is possible), so here is a custom solution. + row_height = row.get_allocation().height + + list_box_height = self.list_box.get_allocated_height() + + if row: + # get coordinate relative to the list_box, + # measured from the top of the selected row to the top of the list_box + row_y_position = row.translate_coordinates(self.list_box, 0, 0)[1] + + # Depending on the theme, the y_offset will be > 0, even though it + # is the uppermost element, due to margins/paddings. + if row_y_position < row_height: + row_y_position = 0 + + # if the selected row sits lower than the second to last row, + # then scroll all the way down. otherwise it will only scroll down + # to the bottom edge of the selected-row, which might not actually be the + # bottom of the list-box due to paddings. + if row_y_position > list_box_height - row_height * 1.5: + # using a value that is too high doesn't hurt here. + row_y_position = list_box_height + + # the visible height of the scrolled_window. not the content. + height = self.scrolled_window.get_max_content_height() + + current_y_scroll = self.scrolled_window.get_vadjustment().get_value() + + vadjustment = self.scrolled_window.get_vadjustment() + + # for the selected row to still be visible, its y_offset has to be + # at height - row_height. If the y_offset is higher than that, then + # the autocompletion needs to scroll down to make it visible again. + if row_y_position > current_y_scroll + (height - row_height): + value = row_y_position - (height - row_height) + vadjustment.set_value(value) + + if row_y_position < current_y_scroll: + # the selected element is not visiable, so we need to scroll up. + vadjustment.set_value(row_y_position) + + def _get_text_iter_at_cursor(self): + """Get Gtk.TextIter at the current text cursor location.""" + cursor = self.code_editor.gui.get_cursor_locations()[0] + return self.code_editor.gui.get_iter_at_location(cursor.x, cursor.y)[1] + + def popup(self): + self.visible = True + super().popup() + + def popdown(self): + self.visible = False + super().popdown() + + @debounce(100) + def update(self, *_): + """Find new autocompletion suggestions and display them. Hide if none.""" + if len(self._target_key_capabilities) == 0: + logger.error("No target capabilities available") + return + + if not self.code_editor.gui.is_focus(): + self.popdown() + return + + self.list_box.forall(self.list_box.remove) + + # move the autocompletion to the text cursor + cursor = self.code_editor.gui.get_cursor_locations()[0] + # convert it to window coords, because the cursor values will be very large + # when the TextView is in a scrolled down ScrolledWindow. + window_coords = self.code_editor.gui.buffer_to_window_coords( + Gtk.TextWindowType.TEXT, cursor.x, cursor.y + ) + cursor.x = window_coords.window_x + cursor.y = window_coords.window_y + cursor.y += 12 + + if self.code_editor.gui.get_show_line_numbers(): + cursor.x += 48 + + self.set_pointing_to(cursor) + + text_iter = self._get_text_iter_at_cursor() + # get a list of (evdev/xmodmap symbol-name, display-name) + suggested_names = propose_function_names(text_iter) + suggested_names += propose_symbols(text_iter, self._target_key_capabilities) + + if len(suggested_names) == 0: + self.popdown() + return + + self.popup() # ffs was this hard to find + + # add visible autocompletion entries + for suggestion, display_name in suggested_names: + label = SuggestionLabel(display_name, suggestion) + self.list_box.insert(label, -1) + label.show_all() + + def _update_capabilities(self): + if self._target_uinput and self._uinputs: + self._target_key_capabilities = self._uinputs[self._target_uinput][EV_KEY] + + def _on_mapping_changed(self, mapping: MappingData): + self._target_uinput = mapping.target_uinput + self._update_capabilities() + + def _on_uinputs_changed(self, data: UInputsData): + self._uinputs = data.uinputs + self._update_capabilities() + + def _on_suggestion_clicked(self, _, selected_row): + """An autocompletion suggestion was selected and should be inserted.""" + selected_label = selected_row.get_children()[0] + suggestion = selected_label.suggestion + buffer = self.code_editor.gui.get_buffer() + + # make sure to replace the complete unfinished word. Look to the right and + # remove whatever there is + cursor_iter = self._get_text_iter_at_cursor() + right = buffer.get_text(cursor_iter, buffer.get_end_iter(), True) + match = re.match(r"^(\w+)", right) + right = match[1] if match else "" + Gtk.TextView.do_delete_from_cursor( + self.code_editor.gui, Gtk.DeleteType.CHARS, len(right) + ) + + # do the same to the left + cursor_iter = self._get_text_iter_at_cursor() + left = buffer.get_text(buffer.get_start_iter(), cursor_iter, True) + match = re.match(r".*?(\w+)$", re.sub("\n", " ", left)) + left = match[1] if match else "" + Gtk.TextView.do_delete_from_cursor( + self.code_editor.gui, Gtk.DeleteType.CHARS, -len(left) + ) + + # insert the autocompletion + Gtk.TextView.do_insert_at_cursor(self.code_editor.gui, suggestion) + + self.emit("suggestion-inserted") + + +GObject.signal_new( + "suggestion-inserted", + Autocompletion, + GObject.SignalFlags.RUN_FIRST, + None, + [], +) diff --git a/inputremapper/gui/components/__init__.py b/inputremapper/gui/components/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/inputremapper/gui/components/common.py b/inputremapper/gui/components/common.py new file mode 100644 index 0000000..fd4892e --- /dev/null +++ b/inputremapper/gui/components/common.py @@ -0,0 +1,174 @@ +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + + +"""Components used in multiple places.""" + + +from __future__ import annotations + + +from gi.repository import Gtk + +from typing import Optional + +from inputremapper.configs.mapping import MappingData + +from inputremapper.gui.controller import Controller +from inputremapper.gui.messages.message_broker import ( + MessageBroker, + MessageType, +) +from inputremapper.gui.messages.message_data import GroupData, PresetData +from inputremapper.gui.utils import HandlerDisabled + + +class FlowBoxEntry(Gtk.ToggleButton): + """A device that can be selected in the GUI. + + For example a keyboard or a mouse. + """ + + __gtype_name__ = "FlowBoxEntry" + + def __init__( + self, + message_broker: MessageBroker, + controller: Controller, + name: str, + icon_name: Optional[str] = None, + ): + super().__init__() + self.icon_name = icon_name + self.message_broker = message_broker + self._controller = controller + + box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) + + if icon_name: + icon = Gtk.Image.new_from_icon_name(icon_name, Gtk.IconSize.DIALOG) + box.add(icon) + + label = Gtk.Label() + label.set_label(name) + self.name = name + + # wrap very long names properly + label.set_line_wrap(True) + label.set_line_wrap_mode(2) + # this affeects how many device entries fit next to each other + label.set_width_chars(28) + label.set_max_width_chars(28) + + box.add(label) + + box.set_margin_top(18) + box.set_margin_bottom(18) + box.set_homogeneous(True) + box.set_spacing(12) + + # self.set_relief(Gtk.ReliefStyle.NONE) + + self.add(box) + + self.show_all() + + self.connect("toggled", self._on_gtk_toggle) + + def _on_gtk_toggle(self): + raise NotImplementedError + + def show_active(self, active): + """Show the active state without triggering anything.""" + with HandlerDisabled(self, self._on_gtk_toggle): + self.set_active(active) + + +class FlowBoxWrapper: + """A wrapper for a flowbox that contains FlowBoxEntry widgets.""" + + def __init__(self, flowbox: Gtk.FlowBox): + self._gui = flowbox + + def show_active_entry(self, name: Optional[str]): + """Activate the togglebutton that matches the name.""" + for child in self._gui.get_children(): + flow_box_entry: FlowBoxEntry = child.get_children()[0] + flow_box_entry.show_active(flow_box_entry.name == name) + + +class Breadcrumbs: + """Writes a breadcrumbs string into a given label.""" + + def __init__( + self, + message_broker: MessageBroker, + label: Gtk.Label, + show_device_group: bool = False, + show_preset: bool = False, + show_mapping: bool = False, + ): + self._message_broker = message_broker + self._gui = label + self._connect_message_listener() + + self.show_device_group = show_device_group + self.show_preset = show_preset + self.show_mapping = show_mapping + + self._group_key: str = "" + self._preset_name: str = "" + self._mapping_name: str = "" + + label.set_max_width_chars(50) + label.set_line_wrap(True) + label.set_line_wrap_mode(2) + + self._render() + + def _connect_message_listener(self): + self._message_broker.subscribe(MessageType.group, self._on_group_changed) + self._message_broker.subscribe(MessageType.preset, self._on_preset_changed) + self._message_broker.subscribe(MessageType.mapping, self._on_mapping_changed) + + def _on_preset_changed(self, data: PresetData): + self._preset_name = data.name or "" + self._render() + + def _on_group_changed(self, data: GroupData): + self._group_key = data.group_key + self._render() + + def _on_mapping_changed(self, mapping_data: MappingData): + self._mapping_name = mapping_data.format_name() + self._render() + + def _render(self): + label = [] + + if self.show_device_group: + label.append(self._group_key or "?") + + if self.show_preset: + label.append(self._preset_name or "?") + + if self.show_mapping: + label.append(self._mapping_name or "?") + + self._gui.set_label(" / ".join(label)) diff --git a/inputremapper/gui/components/device_groups.py b/inputremapper/gui/components/device_groups.py new file mode 100644 index 0000000..7627db7 --- /dev/null +++ b/inputremapper/gui/components/device_groups.py @@ -0,0 +1,115 @@ +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + +from __future__ import annotations + +from typing import Optional + +from gi.repository import Gtk + +from inputremapper.gui.components.common import FlowBoxEntry, FlowBoxWrapper +from inputremapper.gui.components.editor import ICON_PRIORITIES, ICON_NAMES +from inputremapper.gui.components.main import Stack +from inputremapper.gui.controller import Controller +from inputremapper.gui.messages.message_broker import ( + MessageBroker, + MessageType, +) +from inputremapper.gui.messages.message_data import ( + GroupsData, + GroupData, + DoStackSwitch, +) +from inputremapper.logging.logger import logger + + +class DeviceGroupEntry(FlowBoxEntry): + """A device that can be selected in the GUI. + + For example a keyboard or a mouse. + """ + + __gtype_name__ = "DeviceGroupEntry" + + def __init__( + self, + message_broker: MessageBroker, + controller: Controller, + icon_name: Optional[str], + group_key: str, + ): + super().__init__( + message_broker=message_broker, + controller=controller, + icon_name=icon_name, + name=group_key, + ) + self.group_key = group_key + + def _on_gtk_toggle(self, *_, **__): + logger.debug('Selecting device "%s"', self.group_key) + self._controller.load_group(self.group_key) + self.message_broker.publish(DoStackSwitch(Stack.presets_page)) + + +class DeviceGroupSelection(FlowBoxWrapper): + """A wrapper for the container with our groups. + + A group is a collection of devices. + """ + + def __init__( + self, + message_broker: MessageBroker, + controller: Controller, + flowbox: Gtk.FlowBox, + ): + super().__init__(flowbox) + + self._message_broker = message_broker + self._controller = controller + self._gui = flowbox + + self._message_broker.subscribe(MessageType.groups, self._on_groups_changed) + self._message_broker.subscribe(MessageType.group, self._on_group_changed) + + def _on_groups_changed(self, data: GroupsData): + self._gui.foreach(self._gui.remove) + + for group_key, types in data.groups.items(): + if len(types) > 0: + device_type = sorted(types, key=ICON_PRIORITIES.index)[0] + icon_name = ICON_NAMES[device_type] + else: + icon_name = None + + logger.debug("adding %s to device selection", group_key) + device_group_entry = DeviceGroupEntry( + self._message_broker, + self._controller, + icon_name, + group_key, + ) + self._gui.insert(device_group_entry, -1) + + if self._controller.data_manager.active_group: + self.show_active_entry(self._controller.data_manager.active_group.key) + + def _on_group_changed(self, data: GroupData): + self.show_active_entry(data.group_key) diff --git a/inputremapper/gui/components/editor.py b/inputremapper/gui/components/editor.py new file mode 100644 index 0000000..6b64d2e --- /dev/null +++ b/inputremapper/gui/components/editor.py @@ -0,0 +1,1211 @@ +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + + +"""All components that control a single preset.""" + + +from __future__ import annotations + +from collections import defaultdict +from typing import List, Optional, Dict, Union, Callable, Literal, Set + +import cairo +from evdev.ecodes import ( + EV_KEY, + EV_ABS, + EV_REL, + BTN_LEFT, + BTN_MIDDLE, + BTN_RIGHT, + BTN_EXTRA, + BTN_SIDE, +) +from gi.repository import Gtk, GtkSource, Gdk + +from inputremapper.configs.input_config import InputCombination, InputConfig +from inputremapper.configs.keyboard_layout import keyboard_layout, XKB_KEYCODE_OFFSET +from inputremapper.configs.mapping import MappingData, MappingType +from inputremapper.groups import DeviceType +from inputremapper.gui.components.output_type_names import OutputTypeNames +from inputremapper.gui.controller import Controller +from inputremapper.gui.gettext import _ +from inputremapper.gui.messages.message_broker import ( + MessageBroker, + MessageType, +) +from inputremapper.gui.messages.message_data import ( + UInputsData, + PresetData, + CombinationUpdate, +) +from inputremapper.gui.utils import HandlerDisabled, Colors +from inputremapper.injection.mapping_handlers.axis_transform import Transformation +from inputremapper.input_event import InputEvent +from inputremapper.utils import get_evdev_constant_name + +Capabilities = Dict[int, List] + +SET_KEY_FIRST = _("Record the input first") + +ICON_NAMES = { + DeviceType.GAMEPAD: "input-gaming", + DeviceType.MOUSE: "input-mouse", + DeviceType.KEYBOARD: "input-keyboard", + DeviceType.GRAPHICS_TABLET: "input-tablet", + DeviceType.TOUCHPAD: "input-touchpad", + DeviceType.UNKNOWN: None, +} + +# sort types that most devices would fall in easily to the right. +ICON_PRIORITIES = [ + DeviceType.GRAPHICS_TABLET, + DeviceType.TOUCHPAD, + DeviceType.GAMEPAD, + DeviceType.MOUSE, + DeviceType.KEYBOARD, + DeviceType.UNKNOWN, +] + + +class TargetSelection: + """The dropdown menu to select the targe_uinput of the active_mapping, + + For example "keyboard" or "gamepad". + """ + + _mapping: Optional[MappingData] = None + + def __init__( + self, + message_broker: MessageBroker, + controller: Controller, + combobox: Gtk.ComboBox, + ): + self._message_broker = message_broker + self._controller = controller + self._gui = combobox + + self._message_broker.subscribe(MessageType.uinputs, self._on_uinputs_changed) + self._message_broker.subscribe(MessageType.mapping, self._on_mapping_loaded) + self._gui.connect("changed", self._on_gtk_target_selected) + + def _select_current_target(self): + """Select the currently configured target.""" + if self._mapping is not None: + with HandlerDisabled(self._gui, self._on_gtk_target_selected): + self._gui.set_active_id(self._mapping.target_uinput) + + def _on_uinputs_changed(self, data: UInputsData): + target_store = Gtk.ListStore(str) + for uinput in data.uinputs.keys(): + target_store.append([uinput]) + + self._gui.set_model(target_store) + renderer_text = Gtk.CellRendererText() + self._gui.pack_start(renderer_text, False) + self._gui.add_attribute(renderer_text, "text", 0) + self._gui.set_id_column(0) + + self._select_current_target() + + def _on_mapping_loaded(self, mapping: MappingData): + self._mapping = mapping + self._select_current_target() + + def _on_gtk_target_selected(self, *_): + target = self._gui.get_active_id() + self._controller.update_mapping(target_uinput=target) + + +class MappingListBox: + """The listbox showing all available mapping in the active_preset.""" + + def __init__( + self, + message_broker: MessageBroker, + controller: Controller, + listbox: Gtk.ListBox, + ): + self._message_broker = message_broker + self._controller = controller + self._gui = listbox + self._gui.set_sort_func(self._sort_func) + + self._message_broker.subscribe(MessageType.preset, self._on_preset_changed) + self._message_broker.subscribe(MessageType.mapping, self._on_mapping_changed) + self._gui.connect("row-selected", self._on_gtk_mapping_selected) + + @staticmethod + def _sort_func(row1: MappingSelectionLabel, row2: MappingSelectionLabel) -> int: + """Sort alphanumerical by name.""" + if row1.combination == InputCombination.empty_combination(): + return 1 + if row2.combination == InputCombination.empty_combination(): + return 0 + + return 0 if row1.name < row2.name else 1 + + def _on_preset_changed(self, data: PresetData): + selection_labels = self._gui.get_children() + for selection_label in selection_labels: + selection_label.cleanup() + self._gui.remove(selection_label) + + if not data.mappings: + return + + for mapping in data.mappings: + selection_label = MappingSelectionLabel( + self._message_broker, + self._controller, + mapping.format_name(), + mapping.input_combination, + ) + self._gui.insert(selection_label, -1) + self._gui.invalidate_sort() + + def _on_mapping_changed(self, mapping: MappingData): + with HandlerDisabled(self._gui, self._on_gtk_mapping_selected): + combination = mapping.input_combination + + for row in self._gui.get_children(): + if row.combination == combination: + self._gui.select_row(row) + + def _on_gtk_mapping_selected(self, _, row: Optional[MappingSelectionLabel]): + if not row: + return + self._controller.load_mapping(row.combination) + + +class MappingSelectionLabel(Gtk.ListBoxRow): + """The ListBoxRow representing a mapping inside the MappingListBox.""" + + __gtype_name__ = "MappingSelectionLabel" + + def __init__( + self, + message_broker: MessageBroker, + controller: Controller, + name: Optional[str], + combination: InputCombination, + ): + super().__init__() + self._message_broker = message_broker + self._controller = controller + + if not name: + name = combination.beautify() + + self.name = name + self.combination = combination + # add hotkey handler + self.connect("key-press-event", self._on_key_press) + + # Make the child label widget break lines, important for + # long combinations + self.label = Gtk.Label() + self.label.set_line_wrap(True) + self.label.set_line_wrap_mode(Gtk.WrapMode.WORD) + self.label.set_justify(Gtk.Justification.CENTER) + # set the name or combination.beautify as label + self.label.set_label(self.name) + + self.label.set_margin_top(11) + self.label.set_margin_bottom(11) + + # button to edit the name of the mapping + self.edit_btn = Gtk.Button() + self.edit_btn.set_relief(Gtk.ReliefStyle.NONE) + self.edit_btn.set_image( + Gtk.Image.new_from_icon_name(Gtk.STOCK_EDIT, Gtk.IconSize.MENU) + ) + self.edit_btn.set_tooltip_text(_("Change Mapping Name") + " (F2)") + self.edit_btn.set_margin_top(4) + self.edit_btn.set_margin_bottom(4) + self.edit_btn.connect("clicked", self._set_edit_mode) + + self.name_input = Gtk.Entry() + self.name_input.set_text(self.name) + self.name_input.set_halign(Gtk.Align.FILL) + self.name_input.set_margin_top(4) + self.name_input.set_margin_bottom(4) + self.name_input.connect("activate", self._on_gtk_rename_finished) + self.name_input.connect("key-press-event", self._on_gtk_rename_abort) + + self._box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL) + self._box.set_center_widget(self.label) + self._box.add(self.edit_btn) + self._box.set_child_packing(self.edit_btn, False, False, 4, Gtk.PackType.END) + self._box.add(self.name_input) + self._box.set_child_packing(self.name_input, True, True, 4, Gtk.PackType.START) + + self.add(self._box) + self.show_all() + self._message_broker.subscribe(MessageType.mapping, self._on_mapping_changed) + self._message_broker.subscribe( + MessageType.combination_update, self._on_combination_update + ) + + self.edit_btn.hide() + self.name_input.hide() + + def __repr__(self): + return f"" + + def _set_not_selected(self): + self.edit_btn.hide() + self.name_input.hide() + self.label.show() + + def _set_selected(self): + self.label.set_label(self.name) + self.edit_btn.show() + self.name_input.hide() + self.label.show() + + def _set_edit_mode(self, *_): + self.name_input.set_text(self.name) + self.label.hide() + self.name_input.show() + self._controller.set_focus(self.name_input) + + def _on_key_press(self, widget, event): + """ "Mapping Selection Row Label key handler for hotkeys""" + if self.label.is_visible(): + # hotkeys are only meaningful when not already in edit-mapping-name-mode + if event.keyval == Gdk.KEY_F2: + self._set_edit_mode() + elif event.keyval == Gdk.KEY_Delete: + self._controller.delete_mapping() + + def _on_mapping_changed(self, mapping: MappingData): + if mapping.input_combination != self.combination: + self._set_not_selected() + return + self.name = mapping.format_name() + self._set_selected() + self.get_parent().invalidate_sort() + + def _on_combination_update(self, data: CombinationUpdate): + if data.old_combination == self.combination and self.is_selected(): + self.combination = data.new_combination + + def _on_gtk_rename_finished(self, *_): + name = self.name_input.get_text() + if name.lower().strip() == self.combination.beautify().lower(): + name = "" + self.name = name + self._set_selected() + self._controller.update_mapping(name=name) + + def _on_gtk_rename_abort(self, _, key_event: Gdk.EventKey): + if key_event.keyval == Gdk.KEY_Escape: + self._set_selected() + + def cleanup(self) -> None: + """Clean up message listeners. Execute before removing from gui!""" + self._message_broker.unsubscribe(self._on_mapping_changed) + self._message_broker.unsubscribe(self._on_combination_update) + + +class GdkEventRecorder: + """Records events delivered by GDK, similar to the ReaderService/ReaderClient.""" + + _combination: List[int] + _pressed: Set[int] + + __gtype_name__ = "GdkEventRecorder" + + def __init__(self, window: Gtk.Window, gui: Gtk.Label): + super().__init__() + self._combination = [] + self._pressed = set() + self._gui = gui + window.connect("event", self._on_gtk_event) + + def _get_button_code(self, event: Gdk.Event): + """Get the evdev code for the given event.""" + return { + Gdk.BUTTON_MIDDLE: BTN_MIDDLE, + Gdk.BUTTON_PRIMARY: BTN_LEFT, + Gdk.BUTTON_SECONDARY: BTN_RIGHT, + 9: BTN_EXTRA, + 8: BTN_SIDE, + }.get(event.get_button().button) + + def _reset(self, event: Gdk.Event): + """If a new combination is being typed, start from scratch.""" + gdk_event_type: int = event.type + + is_press = gdk_event_type in [ + Gdk.EventType.KEY_PRESS, + Gdk.EventType.BUTTON_PRESS, + ] + + if len(self._pressed) == 0 and is_press: + self._combination = [] + + def _press(self, event: Gdk.Event): + """Remember pressed keys, write down combinations.""" + gdk_event_type: int = event.type + + if gdk_event_type == Gdk.EventType.KEY_PRESS: + code = event.hardware_keycode - XKB_KEYCODE_OFFSET + if code not in self._combination: + self._combination.append(code) + + self._pressed.add(code) + + if gdk_event_type == Gdk.EventType.BUTTON_PRESS: + code = self._get_button_code(event) + if code not in self._combination: + self._combination.append(code) + + self._pressed.add(code) + + def _release(self, event: Gdk.Event): + """Clear pressed keys if this is a release event.""" + if event.type in [Gdk.EventType.KEY_RELEASE, Gdk.EventType.BUTTON_RELEASE]: + self._pressed = set() + + def _display(self, event): + """Show the recorded combination in the gui.""" + is_press = event.type in [ + Gdk.EventType.KEY_PRESS, + Gdk.EventType.BUTTON_PRESS, + ] + + if is_press and len(self._combination) > 0: + names = [ + keyboard_layout.get_name(code) + for code in self._combination + if code is not None and keyboard_layout.get_name(code) is not None + ] + self._gui.set_text(" + ".join(names)) + + def _on_gtk_event(self, _, event: Gdk.Event): + """For all sorts of input events that gtk cares about.""" + self._reset(event) + self._release(event) + self._press(event) + self._display(event) + + +class CodeEditor: + """The editor used to edit the output_symbol of the active_mapping.""" + + placeholder: str = _("Enter your output here") + + def __init__( + self, + message_broker: MessageBroker, + controller: Controller, + editor: GtkSource.View, + ): + self._message_broker = message_broker + self._controller = controller + self.gui = editor + + # without this the wrapping ScrolledWindow acts weird when new lines are added, + # not offering enough space to the text editor so the whole thing is suddenly + # scrollable by a few pixels. + # Found this after making blind guesses with settings in glade, and then + # actually looking at the snapshot preview! In glades editor this didn't have an + # effect. + self.gui.set_resize_mode(Gtk.ResizeMode.IMMEDIATE) + + # Syntax Highlighting + # TODO there are some similarities with python, but overall it's quite useless. + # commented out until there is proper highlighting for input-remappers syntax. + # Thanks to https://github.com/wolfthefallen/py-GtkSourceCompletion-example + # language_manager = GtkSource.LanguageManager() + # fun fact: without saving LanguageManager into its own variable it doesn't work + # python = language_manager.get_language("python") + # source_view.get_buffer().set_language(python) + + self._update_placeholder() + + self.gui.get_buffer().connect("changed", self._on_gtk_changed) + self.gui.connect("focus-in-event", self._update_placeholder) + self.gui.connect("focus-out-event", self._update_placeholder) + self._connect_message_listener() + + def _update_placeholder(self, *_): + buffer = self.gui.get_buffer() + code = buffer.get_text(buffer.get_start_iter(), buffer.get_end_iter(), True) + + # test for incorrect states and fix them, without causing side effects + with HandlerDisabled(buffer, self._on_gtk_changed): + if self.gui.has_focus() and code == self.placeholder: + # hide the placeholder + buffer.set_text("") + self.gui.get_style_context().remove_class("opaque-text") + elif code == "": + # show the placeholder instead + buffer.set_text(self.placeholder) + self.gui.get_style_context().add_class("opaque-text") + elif code != "": + # something is written, ensure the opacity is correct + self.gui.get_style_context().remove_class("opaque-text") + + def _shows_placeholder(self): + buffer = self.gui.get_buffer() + code = buffer.get_text(buffer.get_start_iter(), buffer.get_end_iter(), True) + return code == self.placeholder + + @property + def code(self) -> str: + """Get the user-defined macro code string.""" + if self._shows_placeholder(): + return "" + + buffer = self.gui.get_buffer() + return buffer.get_text(buffer.get_start_iter(), buffer.get_end_iter(), True) + + @code.setter + def code(self, code: str) -> None: + """Set the text without triggering any events.""" + buffer = self.gui.get_buffer() + with HandlerDisabled(buffer, self._on_gtk_changed): + buffer.set_text(code) + self._update_placeholder() + self.gui.do_move_cursor(self.gui, Gtk.MovementStep.BUFFER_ENDS, -1, False) + + def _connect_message_listener(self): + self._message_broker.subscribe( + MessageType.mapping, + self._on_mapping_loaded, + ) + self._message_broker.subscribe( + MessageType.recording_finished, + self._on_recording_finished, + ) + + def _toggle_line_numbers(self): + """Show line numbers if multiline, otherwise remove them.""" + if "\n" in self.code: + self.gui.set_show_line_numbers(True) + # adds a bit of space between numbers and text: + self.gui.set_show_line_marks(True) + self.gui.set_monospace(True) + self.gui.get_style_context().add_class("multiline") + else: + self.gui.set_show_line_numbers(False) + self.gui.set_show_line_marks(False) + self.gui.set_monospace(False) + self.gui.get_style_context().remove_class("multiline") + + def _on_gtk_changed(self, *_): + if self._shows_placeholder(): + return + + self._controller.update_mapping(output_symbol=self.code) + + def _on_mapping_loaded(self, mapping: MappingData): + code = SET_KEY_FIRST + if not self._controller.is_empty_mapping(): + code = mapping.output_symbol or "" + + if self.code.strip().lower() != code.strip().lower(): + self.code = code + + self._toggle_line_numbers() + + def _on_recording_finished(self, _): + self._controller.set_focus(self.gui) + + +class RequireActiveMapping: + """Disable the widget if no mapping is selected.""" + + def __init__( + self, + message_broker: MessageBroker, + widget: Gtk.ToggleButton, + require_recorded_input: bool, + ): + self._widget = widget + self._default_tooltip = self._widget.get_tooltip_text() + self._require_recorded_input = require_recorded_input + + self._active_preset: Optional[PresetData] = None + self._active_mapping: Optional[MappingData] = None + + message_broker.subscribe(MessageType.preset, self._on_preset) + message_broker.subscribe(MessageType.mapping, self._on_mapping) + + def _on_preset(self, preset_data: PresetData): + self._active_preset = preset_data + self._check() + + def _on_mapping(self, mapping_data: MappingData): + self._active_mapping = mapping_data + self._check() + + def _check(self, *__): + if not self._active_preset or len(self._active_preset.mappings) == 0: + self._disable() + self._widget.set_tooltip_text(_("Add a mapping first")) + return + + if ( + self._require_recorded_input + and self._active_mapping + and not self._active_mapping.has_input_defined() + ): + self._disable() + self._widget.set_tooltip_text(_("Record input first")) + return + + self._enable() + self._widget.set_tooltip_text(self._default_tooltip) + + def _enable(self): + self._widget.set_sensitive(True) + self._widget.set_opacity(1) + + def _disable(self): + self._widget.set_sensitive(False) + self._widget.set_opacity(0.5) + + +class RecordingToggle: + """The toggle that starts input recording for the active_mapping.""" + + def __init__( + self, + message_broker: MessageBroker, + controller: Controller, + toggle: Gtk.ToggleButton, + ): + self._message_broker = message_broker + self._controller = controller + self._gui = toggle + + toggle.connect("toggled", self._on_gtk_toggle) + # Don't leave the input when using arrow keys or tab. wait for the + # window to consume the keycode from the reader. I.e. a tab input should + # be recorded, instead of causing the recording to stop. + toggle.connect("key-press-event", lambda *args: Gdk.EVENT_STOP) + self._message_broker.subscribe( + MessageType.recording_finished, + self._on_recording_finished, + ) + + RequireActiveMapping( + message_broker, + toggle, + require_recorded_input=False, + ) + + def _on_gtk_toggle(self, *__): + if self._gui.get_active(): + self._controller.start_key_recording() + else: + self._controller.stop_key_recording() + + def _on_recording_finished(self, __): + with HandlerDisabled(self._gui, self._on_gtk_toggle): + self._gui.set_active(False) + + +class RecordingStatus: + """Displays if keys are being recorded for a mapping.""" + + def __init__( + self, + message_broker: MessageBroker, + label: Gtk.Label, + ): + self._gui = label + + message_broker.subscribe( + MessageType.recording_started, + self._on_recording_started, + ) + + message_broker.subscribe( + MessageType.recording_finished, + self._on_recording_finished, + ) + + def _on_recording_started(self, _): + self._gui.set_visible(True) + + def _on_recording_finished(self, _): + self._gui.set_visible(False) + + +class AutoloadSwitch: + """The switch used to toggle the autoload state of the active_preset.""" + + def __init__( + self, message_broker: MessageBroker, controller: Controller, switch: Gtk.Switch + ): + self._message_broker = message_broker + self._controller = controller + self._gui = switch + + self._gui.connect("state-set", self._on_gtk_toggle) + self._message_broker.subscribe(MessageType.preset, self._on_preset_changed) + + def _on_preset_changed(self, data: PresetData): + with HandlerDisabled(self._gui, self._on_gtk_toggle): + self._gui.set_active(data.autoload) + + def _on_gtk_toggle(self, *_): + self._controller.set_autoload(self._gui.get_active()) + + +class ReleaseCombinationSwitch: + """The switch used to set the active_mapping.release_combination_keys parameter.""" + + def __init__( + self, message_broker: MessageBroker, controller: Controller, switch: Gtk.Switch + ): + self._message_broker = message_broker + self._controller = controller + self._gui = switch + + self._gui.connect("state-set", self._on_gtk_toggle) + self._message_broker.subscribe(MessageType.mapping, self._on_mapping_changed) + + def _on_mapping_changed(self, data: MappingData): + with HandlerDisabled(self._gui, self._on_gtk_toggle): + self._gui.set_active(data.release_combination_keys) + + def _on_gtk_toggle(self, *_): + self._controller.update_mapping(release_combination_keys=self._gui.get_active()) + + +class InputConfigEntry(Gtk.ListBoxRow): + """The ListBoxRow representing a single input config inside the CombinationListBox.""" + + __gtype_name__ = "InputConfigEntry" + + def __init__(self, event: InputConfig, controller: Controller): + super().__init__() + + self.input_event = event + self._controller = controller + + hbox = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=4) + hbox.set_margin_start(12) + + label = Gtk.Label() + label.set_label(event.description()) + hbox.pack_start(label, False, False, 0) + + up_btn = Gtk.Button() + up_btn.set_halign(Gtk.Align.END) + up_btn.set_relief(Gtk.ReliefStyle.NONE) + up_btn.get_style_context().add_class("no-v-padding") + up_img = Gtk.Image.new_from_icon_name("go-up", Gtk.IconSize.BUTTON) + up_btn.add(up_img) + + down_btn = Gtk.Button() + down_btn.set_halign(Gtk.Align.END) + down_btn.set_relief(Gtk.ReliefStyle.NONE) + down_btn.get_style_context().add_class("no-v-padding") + down_img = Gtk.Image.new_from_icon_name("go-down", Gtk.IconSize.BUTTON) + down_btn.add(down_img) + + vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) + vbox.pack_start(up_btn, False, True, 0) + vbox.pack_end(down_btn, False, True, 0) + hbox.pack_end(vbox, False, False, 0) + + up_btn.connect( + "clicked", + lambda *_: self._controller.move_input_config_in_combination( + self.input_event, "up" + ), + ) + down_btn.connect( + "clicked", + lambda *_: self._controller.move_input_config_in_combination( + self.input_event, "down" + ), + ) + self.add(hbox) + self.show_all() + + # only used in testing + self._up_btn = up_btn + self._down_btn = down_btn + + +class CombinationListbox: + """The ListBox with all the events inside active_mapping.input_combination.""" + + def __init__( + self, + message_broker: MessageBroker, + controller: Controller, + listbox: Gtk.ListBox, + ): + self._message_broker = message_broker + self._controller = controller + self._gui = listbox + self._combination: Optional[InputCombination] = None + + self._message_broker.subscribe( + MessageType.mapping, + self._on_mapping_changed, + ) + self._message_broker.subscribe( + MessageType.selected_event, + self._on_event_changed, + ) + + self._gui.connect("row-selected", self._on_gtk_row_selected) + + def _select_row(self, event: InputEvent): + for row in self._gui.get_children(): + if row.input_event == event: + self._gui.select_row(row) + + def _on_mapping_changed(self, mapping: MappingData): + if self._combination == mapping.input_combination: + return + + event_entries = self._gui.get_children() + for event_entry in event_entries: + self._gui.remove(event_entry) + + if self._controller.is_empty_mapping(): + self._combination = None + else: + self._combination = mapping.input_combination + for event in self._combination: + self._gui.insert(InputConfigEntry(event, self._controller), -1) + + def _on_event_changed(self, event: InputEvent): + with HandlerDisabled(self._gui, self._on_gtk_row_selected): + self._select_row(event) + + def _on_gtk_row_selected(self, *_): + for row in self._gui.get_children(): + if row.is_selected(): + self._controller.load_input_config(row.input_event) + break + + +class AnalogInputSwitch: + """The switch that marks the active_input_config as analog input.""" + + def __init__( + self, + message_broker: MessageBroker, + controller: Controller, + gui: Gtk.Switch, + ): + self._message_broker = message_broker + self._controller = controller + self._gui = gui + self._input_config: Optional[InputConfig] = None + + self._gui.connect("state-set", self._on_gtk_toggle) + self._message_broker.subscribe(MessageType.selected_event, self._on_event) + + def _on_event(self, input_cfg: InputConfig): + with HandlerDisabled(self._gui, self._on_gtk_toggle): + self._gui.set_active(input_cfg.defines_analog_input) + self._input_config = input_cfg + + if input_cfg.type == EV_KEY: + self._gui.set_sensitive(False) + self._gui.set_opacity(0.5) + else: + self._gui.set_sensitive(True) + self._gui.set_opacity(1) + + def _on_gtk_toggle(self, *_): + self._controller.set_event_as_analog(self._gui.get_active()) + + +class TriggerThresholdInput: + """The number selection used to set the speed or position threshold of the + active_input_config when it is an ABS or REL event used as a key.""" + + def __init__( + self, + message_broker: MessageBroker, + controller: Controller, + gui: Gtk.SpinButton, + ): + self._message_broker = message_broker + self._controller = controller + self._gui = gui + self._input_config: Optional[InputConfig] = None + + self._gui.set_increments(1, 1) + self._gui.connect("value-changed", self._on_gtk_changed) + self._message_broker.subscribe(MessageType.selected_event, self._on_event) + + def _on_event(self, input_config: InputConfig): + if input_config.type == EV_KEY: + self._gui.set_sensitive(False) + self._gui.set_opacity(0.5) + elif input_config.type == EV_ABS: + self._gui.set_sensitive(True) + self._gui.set_opacity(1) + self._gui.set_range(-99, 99) + else: + self._gui.set_sensitive(True) + self._gui.set_opacity(1) + self._gui.set_range(-999, 999) + + with HandlerDisabled(self._gui, self._on_gtk_changed): + self._gui.set_value(input_config.analog_threshold or 0) + self._input_config = input_config + + def _on_gtk_changed(self, *_): + self._controller.update_input_config( + self._input_config.modify(analog_threshold=int(self._gui.get_value())) + ) + + +class ReleaseTimeoutInput: + """The number selector used to set the active_mapping.release_timeout parameter.""" + + def __init__( + self, + message_broker: MessageBroker, + controller: Controller, + gui: Gtk.SpinButton, + ): + self._message_broker = message_broker + self._controller = controller + self._gui = gui + + self._gui.set_increments(0.01, 0.01) + self._gui.set_range(0, 2) + self._gui.connect("value-changed", self._on_gtk_changed) + self._message_broker.subscribe(MessageType.mapping, self._on_mapping_message) + + def _on_mapping_message(self, mapping: MappingData): + if EV_REL in [event.type for event in mapping.input_combination]: + self._gui.set_sensitive(True) + self._gui.set_opacity(1) + else: + self._gui.set_sensitive(False) + self._gui.set_opacity(0.5) + + with HandlerDisabled(self._gui, self._on_gtk_changed): + self._gui.set_value(mapping.release_timeout) + + def _on_gtk_changed(self, *_): + self._controller.update_mapping(release_timeout=self._gui.get_value()) + + +class RelativeInputCutoffInput: + """The number selector to set active_mapping.rel_to_abs_input_cutoff.""" + + def __init__( + self, + message_broker: MessageBroker, + controller: Controller, + gui: Gtk.SpinButton, + ): + self._message_broker = message_broker + self._controller = controller + self._gui = gui + + self._gui.set_increments(1, 1) + self._gui.set_range(1, 1000) + self._gui.connect("value-changed", self._on_gtk_changed) + self._message_broker.subscribe(MessageType.mapping, self._on_mapping_message) + + def _on_mapping_message(self, mapping: MappingData): + if ( + EV_REL in [event.type for event in mapping.input_combination] + and mapping.output_type == EV_ABS + ): + self._gui.set_sensitive(True) + self._gui.set_opacity(1) + else: + self._gui.set_sensitive(False) + self._gui.set_opacity(0.5) + + with HandlerDisabled(self._gui, self._on_gtk_changed): + self._gui.set_value(mapping.rel_to_abs_input_cutoff) + + def _on_gtk_changed(self, *_): + self._controller.update_mapping(rel_xy_cutoff=self._gui.get_value()) + + +class OutputAxisSelector: + """The dropdown menu used to select the output axis if the active_mapping is a + mapping targeting an analog axis + + modifies the active_mapping.output_code and active_mapping.output_type parameters + """ + + def __init__( + self, + message_broker: MessageBroker, + controller: Controller, + gui: Gtk.ComboBox, + ): + self._message_broker = message_broker + self._controller = controller + self._gui = gui + self._uinputs: Dict[str, Capabilities] = {} + self.model = Gtk.ListStore(str, str) + + self._current_target: Optional[str] = None + + self._gui.set_model(self.model) + renderer_text = Gtk.CellRendererText() + self._gui.pack_start(renderer_text, False) + self._gui.add_attribute(renderer_text, "text", 1) + self._gui.set_id_column(0) + + self._gui.connect("changed", self._on_gtk_select_axis) + self._message_broker.subscribe(MessageType.mapping, self._on_mapping_message) + self._message_broker.subscribe(MessageType.uinputs, self._on_uinputs_message) + + def _set_model(self, target: Optional[str]): + if target == self._current_target: + return + + self.model.clear() + self.model.append(["None, None", _("No Axis")]) + + if target is not None: + capabilities = self._uinputs.get(target) or defaultdict(list) + types_codes = [ + (EV_ABS, code) for code, absinfo in capabilities.get(EV_ABS) or () + ] + types_codes.extend( + (EV_REL, code) for code in capabilities.get(EV_REL) or () + ) + for type_, code in types_codes: + key_name = get_evdev_constant_name(type_, code) + if isinstance(key_name, list): + key_name = key_name[0] + self.model.append([f"{type_}, {code}", key_name]) + + self._current_target = target + + def _on_mapping_message(self, mapping: MappingData): + with HandlerDisabled(self._gui, self._on_gtk_select_axis): + self._set_model(mapping.target_uinput) + self._gui.set_active_id(f"{mapping.output_type}, {mapping.output_code}") + + def _on_uinputs_message(self, uinputs: UInputsData): + self._uinputs = uinputs.uinputs + + def _on_gtk_select_axis(self, *_): + if self._gui.get_active_id() == "None, None": + type_code = (None, None) + else: + type_code = tuple(int(i) for i in self._gui.get_active_id().split(",")) + self._controller.update_mapping( + output_type=type_code[0], output_code=type_code[1] + ) + + +class KeyAxisStackSwitcher: + """The controls used to switch between the gui to modify a key-mapping or + an analog-axis mapping.""" + + def __init__( + self, + message_broker: MessageBroker, + controller: Controller, + stack: Gtk.Stack, + key_macro_toggle: Gtk.ToggleButton, + analog_toggle: Gtk.ToggleButton, + ): + self._message_broker = message_broker + self._controller = controller + self._stack = stack + self._key_macro_toggle = key_macro_toggle + self._analog_toggle = analog_toggle + + self._key_macro_toggle.connect("toggled", self._on_gtk_toggle) + self._analog_toggle.connect("toggled", self._on_gtk_toggle) + self._message_broker.subscribe(MessageType.mapping, self._on_mapping_message) + + def _set_active(self, mapping_type: Literal["key_macro", "analog"]): + if mapping_type == MappingType.ANALOG.value: + self._stack.set_visible_child_name(OutputTypeNames.analog_axis) + active = self._analog_toggle + inactive = self._key_macro_toggle + else: + self._stack.set_visible_child_name(OutputTypeNames.key_or_macro) + active = self._key_macro_toggle + inactive = self._analog_toggle + + with HandlerDisabled(active, self._on_gtk_toggle): + active.set_active(True) + with HandlerDisabled(inactive, self._on_gtk_toggle): + inactive.set_active(False) + + def _on_mapping_message(self, mapping: MappingData): + # fist check the actual mapping + if mapping.mapping_type == MappingType.ANALOG.value: + self._set_active(MappingType.ANALOG.value) + + if mapping.mapping_type == MappingType.KEY_MACRO.value: + self._set_active(MappingType.KEY_MACRO.value) + + def _on_gtk_toggle(self, btn: Gtk.ToggleButton): + # get_active returns the new toggle state already + was_active = not btn.get_active() + + if was_active: + # cannot deactivate manually + with HandlerDisabled(btn, self._on_gtk_toggle): + btn.set_active(True) + return + + if btn is self._key_macro_toggle: + self._controller.update_mapping(mapping_type=MappingType.KEY_MACRO.value) + else: + self._controller.update_mapping(mapping_type=MappingType.ANALOG.value) + + +class TransformationDrawArea: + """The graph which shows the relation between input- and output-axis.""" + + def __init__( + self, + message_broker: MessageBroker, + controller: Controller, + gui: Gtk.DrawingArea, + ): + self._message_broker = message_broker + self._controller = controller + self._gui = gui + + self._transformation: Callable[[Union[float, int]], float] = lambda x: x + + self._gui.connect("draw", self._on_gtk_draw) + self._message_broker.subscribe(MessageType.mapping, self._on_mapping_message) + + def _on_mapping_message(self, mapping: MappingData): + self._transformation = Transformation( + 100, -100, mapping.deadzone, mapping.gain, mapping.expo + ) + self._gui.queue_draw() + + def _on_gtk_draw(self, _, context: cairo.Context): + points = [ + (x / 200 + 0.5, -0.5 * self._transformation(x) + 0.5) + # leave some space left and right for the lineCap to be visible + for x in range(-97, 97) + ] + width = self._gui.get_allocated_width() + height = self._gui.get_allocated_height() + b = min((width, height)) + scaled_points = [(x * b, y * b) for x, y in points] + + # x arrow + context.move_to(0 * b, 0.5 * b) + context.line_to(1 * b, 0.5 * b) + context.line_to(0.96 * b, 0.52 * b) + context.move_to(1 * b, 0.5 * b) + context.line_to(0.96 * b, 0.48 * b) + + # y arrow + context.move_to(0.5 * b, 1 * b) + context.line_to(0.5 * b, 0) + context.line_to(0.48 * b, 0.04 * b) + context.move_to(0.5 * b, 0) + context.line_to(0.52 * b, 0.04 * b) + + context.set_line_width(2) + arrow_color = Gdk.RGBA(0.5, 0.5, 0.5, 0.2) + context.set_source_rgba( + arrow_color.red, + arrow_color.green, + arrow_color.blue, + arrow_color.alpha, + ) + context.stroke() + + # graph + context.move_to(*scaled_points[0]) + for scaled_point in scaled_points[1:]: + # Ploting point + context.line_to(*scaled_point) + + line_color = Colors.get_accent_color() + context.set_line_width(3) + context.set_line_cap(cairo.LineCap.ROUND) + # the default gtk adwaita highlight color: + context.set_source_rgba( + line_color.red, + line_color.green, + line_color.blue, + line_color.alpha, + ) + context.stroke() + + +class Sliders: + """The different sliders to modify the gain, deadzone and expo parameters of the + active_mapping.""" + + def __init__( + self, + message_broker: MessageBroker, + controller: Controller, + gain: Gtk.Range, + deadzone: Gtk.Range, + expo: Gtk.Range, + ): + self._message_broker = message_broker + self._controller = controller + self._gain = gain + self._deadzone = deadzone + self._expo = expo + + self._gain.set_range(-2, 2) + self._deadzone.set_range(0, 0.9) + self._expo.set_range(-1, 1) + + self._gain.connect("value-changed", self._on_gtk_gain_changed) + self._expo.connect("value-changed", self._on_gtk_expo_changed) + self._deadzone.connect("value-changed", self._on_gtk_deadzone_changed) + self._message_broker.subscribe(MessageType.mapping, self._on_mapping_message) + + def _on_mapping_message(self, mapping: MappingData): + with HandlerDisabled(self._gain, self._on_gtk_gain_changed): + self._gain.set_value(mapping.gain) + + with HandlerDisabled(self._expo, self._on_gtk_expo_changed): + self._expo.set_value(mapping.expo) + + with HandlerDisabled(self._deadzone, self._on_gtk_deadzone_changed): + self._deadzone.set_value(mapping.deadzone) + + def _on_gtk_gain_changed(self, *_): + self._controller.update_mapping(gain=self._gain.get_value()) + + def _on_gtk_deadzone_changed(self, *_): + self._controller.update_mapping(deadzone=self._deadzone.get_value()) + + def _on_gtk_expo_changed(self, *_): + self._controller.update_mapping(expo=self._expo.get_value()) diff --git a/inputremapper/gui/components/main.py b/inputremapper/gui/components/main.py new file mode 100644 index 0000000..3ada3d8 --- /dev/null +++ b/inputremapper/gui/components/main.py @@ -0,0 +1,134 @@ +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + + +"""Components that wrap everything.""" + + +from __future__ import annotations + +from gi.repository import Gtk, Pango + +from inputremapper.gui.controller import Controller +from inputremapper.gui.messages.message_broker import ( + MessageBroker, + MessageType, +) +from inputremapper.gui.messages.message_data import StatusData, DoStackSwitch +from inputremapper.gui.utils import CTX_ERROR, CTX_MAPPING, CTX_WARNING + + +class Stack: + """Wraps the Stack, which contains the main menu pages.""" + + devices_page = 0 + presets_page = 1 + editor_page = 2 + + def __init__( + self, + message_broker: MessageBroker, + controller: Controller, + stack: Gtk.Stack, + ): + self._message_broker = message_broker + self._controller = controller + self._gui = stack + + self._message_broker.subscribe( + MessageType.do_stack_switch, self._do_stack_switch + ) + + def _do_stack_switch(self, msg: DoStackSwitch): + self._gui.set_visible_child(self._gui.get_children()[msg.page_index]) + + +class StatusBar: + """The status bar on the bottom of the main window.""" + + def __init__( + self, + message_broker: MessageBroker, + controller: Controller, + status_bar: Gtk.Statusbar, + error_icon: Gtk.Image, + warning_icon: Gtk.Image, + ): + self._message_broker = message_broker + self._controller = controller + self._gui = status_bar + self._error_icon = error_icon + self._warning_icon = warning_icon + + label = self._gui.get_message_area().get_children()[0] + label.set_ellipsize(Pango.EllipsizeMode.END) + label.set_selectable(True) + + self._message_broker.subscribe(MessageType.status_msg, self._on_status_update) + + # keep track if there is an error or warning in the stack of statusbar + # unfortunately this is not exposed over the api + self._error = False + self._warning = False + + def _on_status_update(self, data: StatusData): + """Show a status message and set its tooltip. + + If message is None, it will remove the newest message of the + given context_id. + """ + context_id = data.ctx_id + message = data.msg + tooltip = data.tooltip + status_bar = self._gui + + if message is None: + status_bar.remove_all(context_id) + + if context_id in (CTX_ERROR, CTX_MAPPING): + self._error_icon.hide() + self._error = False + if self._warning: + self._warning_icon.show() + + if context_id == CTX_WARNING: + self._warning_icon.hide() + self._warning = False + if self._error: + self._error_icon.show() + + status_bar.set_tooltip_text("") + return + + if tooltip is None: + tooltip = message + + self._error_icon.hide() + self._warning_icon.hide() + + if context_id in (CTX_ERROR, CTX_MAPPING): + self._error_icon.show() + self._error = True + + if context_id == CTX_WARNING: + self._warning_icon.show() + self._warning = True + + status_bar.push(context_id, message) + status_bar.set_tooltip_text(tooltip) diff --git a/inputremapper/gui/components/output_type_names.py b/inputremapper/gui/components/output_type_names.py new file mode 100644 index 0000000..1407692 --- /dev/null +++ b/inputremapper/gui/components/output_type_names.py @@ -0,0 +1,3 @@ +class OutputTypeNames: + analog_axis = "Analog Axis" + key_or_macro = "Key or Macro" diff --git a/inputremapper/gui/components/presets.py b/inputremapper/gui/components/presets.py new file mode 100644 index 0000000..b0d6146 --- /dev/null +++ b/inputremapper/gui/components/presets.py @@ -0,0 +1,106 @@ +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + + +"""All components that are visible on the page that shows all the presets.""" + + +from __future__ import annotations + +from gi.repository import Gtk + +from inputremapper.gui.components.common import FlowBoxEntry, FlowBoxWrapper +from inputremapper.gui.components.main import Stack +from inputremapper.gui.controller import Controller +from inputremapper.gui.messages.message_broker import ( + MessageBroker, + MessageType, +) +from inputremapper.gui.messages.message_data import ( + GroupData, + PresetData, + DoStackSwitch, +) +from inputremapper.logging.logger import logger + + +class PresetEntry(FlowBoxEntry): + """A preset that can be selected in the GUI.""" + + __gtype_name__ = "PresetEntry" + + def __init__( + self, + message_broker: MessageBroker, + controller: Controller, + preset_name: str, + ): + super().__init__( + message_broker=message_broker, controller=controller, name=preset_name + ) + self.preset_name = preset_name + + def _on_gtk_toggle(self, *_, **__): + logger.debug('Selecting preset "%s"', self.preset_name) + self._controller.load_preset(self.preset_name) + self.message_broker.publish(DoStackSwitch(Stack.editor_page)) + + +class PresetSelection(FlowBoxWrapper): + """A wrapper for the container with our presets. + + Selectes the active_preset. + """ + + def __init__( + self, + message_broker: MessageBroker, + controller: Controller, + flowbox: Gtk.FlowBox, + ): + super().__init__(flowbox) + + self._message_broker = message_broker + self._controller = controller + self._gui = flowbox + self._connect_message_listener() + + def _connect_message_listener(self): + self._message_broker.subscribe(MessageType.group, self._on_group_changed) + self._message_broker.subscribe(MessageType.preset, self._on_preset_changed) + + def _on_group_changed(self, data: GroupData): + self._gui.foreach(self._gui.remove) + for preset_name in data.presets: + preset_entry = PresetEntry( + self._message_broker, + self._controller, + preset_name, + ) + self._gui.insert(preset_entry, -1) + + def _on_preset_changed(self, data: PresetData): + self.show_active_entry(data.name) + + def set_active_preset(self, preset_name: str): + """Change the currently selected preset.""" + # TODO might only be needed in tests + for child in self._gui.get_children(): + preset_entry: PresetEntry = child.get_children()[0] + preset_entry.set_active(preset_entry.preset_name == preset_name) diff --git a/inputremapper/gui/controller.py b/inputremapper/gui/controller.py new file mode 100644 index 0000000..93e051d --- /dev/null +++ b/inputremapper/gui/controller.py @@ -0,0 +1,876 @@ +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + +from __future__ import annotations # needed for the TYPE_CHECKING import + +import re +from functools import partial +from typing import ( + TYPE_CHECKING, + Optional, + Union, + Literal, + Sequence, + Dict, + Callable, + List, + Any, + Tuple, +) + +from evdev.ecodes import EV_KEY, EV_REL, EV_ABS +from gi.repository import Gtk + +from inputremapper.configs.input_config import InputCombination, InputConfig +from inputremapper.configs.mapping import ( + MappingData, + UIMapping, + MappingType, +) +from inputremapper.configs.paths import PathUtils +from inputremapper.configs.validation_errors import ( + pydantify, + MissingMacroOrKeyError, + MacroButTypeOrCodeSetError, + SymbolAndCodeMismatchError, + MissingOutputAxisError, + WrongMappingTypeForKeyError, + OutputSymbolVariantError, +) +from inputremapper.exceptions import DataManagementError +from inputremapper.gui.components.output_type_names import OutputTypeNames +from inputremapper.gui.data_manager import DataManager, DEFAULT_PRESET_NAME +from inputremapper.gui.gettext import _ +from inputremapper.gui.messages.message_broker import ( + MessageBroker, + MessageType, +) +from inputremapper.gui.messages.message_data import ( + PresetData, + StatusData, + CombinationRecorded, + UserConfirmRequest, + DoStackSwitch, +) +from inputremapper.gui.utils import CTX_APPLY, CTX_ERROR, CTX_WARNING, CTX_MAPPING +from inputremapper.injection.injector import ( + InjectorState, + InjectorStateMessage, +) +from inputremapper.logging.logger import logger + +if TYPE_CHECKING: + # avoids gtk import error in tests + from inputremapper.gui.user_interface import UserInterface + + +MAPPING_DEFAULTS = {"target_uinput": "keyboard"} + + +class Controller: + """Implements the behaviour of the gui.""" + + def __init__( + self, + message_broker: MessageBroker, + data_manager: DataManager, + ) -> None: + self.message_broker = message_broker + self.data_manager = data_manager + self.gui: Optional[UserInterface] = None + + self.button_left_warn = False + self._attach_to_events() + + def set_gui(self, gui: UserInterface): + """Let the Controller know about the user interface singleton..""" + self.gui = gui + + def _attach_to_events(self) -> None: + self.message_broker.subscribe(MessageType.groups, self._on_groups_changed) + self.message_broker.subscribe(MessageType.preset, self._on_preset_changed) + self.message_broker.subscribe(MessageType.init, self._on_init) + self.message_broker.subscribe( + MessageType.preset, self._publish_mapping_errors_as_status_msg + ) + self.message_broker.subscribe( + MessageType.mapping, self._publish_mapping_errors_as_status_msg + ) + + def _on_init(self, __): + """Initialize the gui and the data_manager.""" + # make sure we get a groups_changed event when everything is ready + # this might not be necessary if the reader-service takes longer to provide the + # initial groups + self.data_manager.publish_groups() + self.data_manager.publish_uinputs() + + def _on_groups_changed(self, _): + """Load the newest group as soon as everyone got notified + about the updated groups.""" + + if self.data_manager.active_group is not None: + # don't jump to a different group and preset suddenly, if the user + # is already looking at one + logger.debug("A group is already active") + return + + group_key = self.get_a_group() + if group_key is None: + logger.debug("Could not find a group") + return + + self.load_group(group_key) + + def _on_preset_changed(self, data: PresetData): + """Load a mapping as soon as everyone got notified about the new preset.""" + if data.mappings: + mappings = list(data.mappings) + mappings.sort( + key=lambda mapping: ( + mapping.format_name() or mapping.input_combination.beautify() + ) + ) + combination = mappings[0].input_combination + self.load_mapping(combination) + self.load_input_config(combination[0]) + else: + # send an empty mapping to make sure the ui is reset to default values + self.message_broker.publish(MappingData(**MAPPING_DEFAULTS)) + + def _on_combination_recorded(self, data: CombinationRecorded): + combination = self._auto_use_as_analog(data.combination) + self.update_combination(combination) + + def _format_status_bar_validation_errors(self) -> Optional[Tuple[str, str]]: + if not self.data_manager.active_preset: + return None + + if self.data_manager.active_preset.is_valid(): + self.message_broker.publish(StatusData(CTX_MAPPING)) + return None + + mappings = list(self.data_manager.active_preset) + + # Move the selected (active) mapping to the front, so that it is checked first. + active_mapping = self.data_manager.active_mapping + if active_mapping is not None: + mappings.remove(active_mapping) + mappings.insert(0, active_mapping) + + for mapping in mappings: + if not mapping.has_input_defined(): + # Empty mapping, nothing recorded yet so nothing can be configured, + # therefore there isn't anything to validate. + continue + + position = mapping.format_name() + error_strings = self._get_ui_error_strings(mapping) + + if len(error_strings) == 0: + continue + + if len(error_strings) > 1: + msg = _('%d Mapping errors at "%s", hover for info') % ( + len(error_strings), + position, + ) + tooltip = "– " + "\n– ".join(error_strings) + else: + msg = f'"{position}": {error_strings[0]}' + tooltip = error_strings[0] + + return msg.replace("\n", " "), tooltip + + return None + + def _publish_mapping_errors_as_status_msg(self, *__) -> None: + """Send mapping ValidationErrors to the MessageBroker.""" + validation_result = self._format_status_bar_validation_errors() + + if validation_result is None: + return + + self.show_status( + CTX_MAPPING, + validation_result[0], + validation_result[1], + ) + + @staticmethod + def format_error_message(mapping, error_type, error_message: str) -> str: + """Check all the different error messages which are not useful for the user.""" + # There is no more elegant way of comparing error_type with the base class. + # https://github.com/pydantic/pydantic/discussions/5112 + if ( + pydantify(MacroButTypeOrCodeSetError) in error_type + or pydantify(SymbolAndCodeMismatchError) in error_type + ) and mapping.input_combination.defines_analog_input: + return _( + "Remove the macro or key from the macro input field " + "when specifying an analog output" + ) + + if ( + pydantify(MacroButTypeOrCodeSetError) in error_type + or pydantify(SymbolAndCodeMismatchError) in error_type + ) and not mapping.input_combination.defines_analog_input: + return _( + "Remove the Analog Output Axis when specifying a macro or key output" + ) + + if pydantify(MissingOutputAxisError) in error_type: + error_message = _( + "The input specifies an analog axis, but no output axis is selected." + ) + if mapping.output_symbol is not None: + event = [ + event + for event in mapping.input_combination + if event.defines_analog_input + ][0] + error_message += _( + "\nIf you mean to create a key or macro mapping " + "go to the advanced input configuration" + ' and set a "Trigger Threshold" for ' + f'"{event.description()}"' + ) + return error_message + + if pydantify(WrongMappingTypeForKeyError) in error_type: + error_message = _( + "The input specifies a key, but the output type is not " + f'"{OutputTypeNames.key_or_macro}".' + ) + + if mapping.output_type in (EV_ABS, EV_REL): + error_message += _( + "\nIf you mean to create an analog axis mapping go to the " + 'advanced input configuration and set an input to "Use as Analog".' + ) + + return error_message + + if pydantify(MissingMacroOrKeyError) in error_type: + return _("Missing macro or key") + + return error_message + + @staticmethod + def _get_ui_error_strings(mapping: UIMapping) -> List[str]: + """Get a human readable error message from a mapping error.""" + validation_error = mapping.get_error() + + if validation_error is None: + return [] + + formatted_errors = [] + + for error in validation_error.errors(): + if pydantify(OutputSymbolVariantError) in error["type"]: + # this is rather internal, when this error appears in the gui, there is + # also always another more readable error at the same time that explains + # this problem. + continue + + error_string = f'"{mapping.format_name()}": ' + error_message = error["msg"] + error_location = error["loc"][0] + if error_location != "__root__": + error_string += f"{error_location}: " + + # check all the different error messages which are not useful for the user + formatted_errors.append( + Controller.format_error_message( + mapping, + error["type"], + error_message, + ) + ) + + return formatted_errors + + def get_a_preset(self) -> str: + """Attempts to get the newest preset in the current group + creates a new preset if that fails.""" + try: + return self.data_manager.get_newest_preset_name() + except FileNotFoundError: + pass + self.data_manager.create_preset(self.data_manager.get_available_preset_name()) + return self.data_manager.get_newest_preset_name() + + def get_a_group(self) -> Optional[str]: + """Attempts to get the group with the newest preset + returns any if that fails.""" + try: + return self.data_manager.get_newest_group_key() + except FileNotFoundError: + pass + + keys = self.data_manager.get_group_keys() + return keys[0] if keys else None + + def copy_preset(self): + """Create a copy of the active preset and name it `preset_name copy`.""" + name = self.data_manager.active_preset.name + match = re.search(r" copy *\d*$", name) + if match: + name = name[: match.start()] + + self.data_manager.copy_preset( + self.data_manager.get_available_preset_name(f"{name} copy") + ) + self.message_broker.publish(DoStackSwitch(1)) + + def _auto_use_as_analog(self, combination: InputCombination) -> InputCombination: + """If output is analog, set the first fitting input to analog.""" + if self.data_manager.active_mapping is None: + return combination + + if not self.data_manager.active_mapping.is_analog_output(): + return combination + + if combination.find_analog_input_config(): + # something is already set to do that + return combination + + for i, input_config in enumerate(combination): + # find the first analog input and set it to "use as analog" + if input_config.type in (EV_ABS, EV_REL): + logger.info("Using %s as analog input", input_config) + + # combinations and input_configs are immutable, a new combination + # is created to fit the needs instead + combination_list = list(combination) + combination_list[i] = input_config.modify(analog_threshold=0) + new_combination = InputCombination(combination_list) + return new_combination + + return combination + + def update_combination(self, combination: InputCombination): + """Update the input_combination of the active mapping.""" + combination = self._auto_use_as_analog(combination) + + try: + self.data_manager.update_mapping(input_combination=combination) + self.save() + except KeyError: + self.show_status( + CTX_MAPPING, + f'"{combination.beautify()}" already mapped to something else', + ) + return + + if combination.is_problematic(): + self.show_status( + CTX_WARNING, + _("ctrl, alt and shift may not combine properly"), + _( + "Your system might reinterpret combinations with those after they " + + "are injected, and by doing so break them. Play around with the " + + 'advanced "Release Input" toggle.' + ), + ) + + def move_input_config_in_combination( + self, + input_config: InputConfig, + direction: Union[Literal["up"], Literal["down"]], + ): + """Move the active_input_config up or down in the input_combination of the + active_mapping.""" + if ( + not self.data_manager.active_mapping + or len(self.data_manager.active_mapping.input_combination) == 1 + ): + return + combination: Sequence[InputConfig] = ( + self.data_manager.active_mapping.input_combination + ) + + i = combination.index(input_config) + if ( + i + 1 == len(combination) + and direction == "down" + or i == 0 + and direction == "up" + ): + return + + if direction == "up": + combination = ( + list(combination[: i - 1]) + + [input_config] + + [combination[i - 1]] + + list(combination[i + 1 :]) + ) + elif direction == "down": + combination = ( + list(combination[:i]) + + [combination[i + 1]] + + [input_config] + + list(combination[i + 2 :]) + ) + else: + raise ValueError(f"unknown direction: {direction}") + self.update_combination(InputCombination(combination)) + self.load_input_config(input_config) + + def load_input_config(self, input_config: InputConfig): + """Load an InputConfig form the active mapping input combination.""" + self.data_manager.load_input_config(input_config) + + def update_input_config(self, new_input_config: InputConfig): + """Modify the active input configuration.""" + try: + self.data_manager.update_input_config(new_input_config) + except KeyError: + # we need to synchronize the gui + self.data_manager.publish_mapping() + self.data_manager.publish_event() + + def remove_event(self): + """Remove the active InputEvent from the active mapping event combination.""" + if ( + not self.data_manager.active_mapping + or not self.data_manager.active_input_config + ): + return + + combination = list(self.data_manager.active_mapping.input_combination) + combination.remove(self.data_manager.active_input_config) + try: + self.data_manager.update_mapping( + input_combination=InputCombination(combination) + ) + self.load_input_config(combination[0]) + self.save() + except (KeyError, ValueError): + # we need to synchronize the gui + self.data_manager.publish_mapping() + self.data_manager.publish_event() + + def set_event_as_analog(self, analog: bool): + """Use the active event as an analog input.""" + assert self.data_manager.active_input_config is not None + event = self.data_manager.active_input_config + + if event.type != EV_KEY: + if analog: + try: + self.data_manager.update_input_config( + event.modify(analog_threshold=0) + ) + self.save() + return + except KeyError: + pass + else: + try_values = {EV_REL: [1, -1], EV_ABS: [10, -10]} + for value in try_values[event.type]: + try: + self.data_manager.update_input_config( + event.modify(analog_threshold=value) + ) + self.save() + return + except KeyError: + pass + + # didn't update successfully + # we need to synchronize the gui + self.data_manager.publish_mapping() + self.data_manager.publish_event() + + def load_groups(self): + """Refresh the groups.""" + self.data_manager.refresh_groups() + + def load_group(self, group_key: str): + """Load the group and then a preset of that group.""" + self.data_manager.load_group(group_key) + self.load_preset(self.get_a_preset()) + + def load_preset(self, name: str): + """Load the preset.""" + self.data_manager.load_preset(name) + # self.load_mapping(...) # not needed because we have on_preset_changed() + + def rename_preset(self, new_name: str): + """Rename the active_preset.""" + if ( + not self.data_manager.active_preset + or not new_name + or new_name == self.data_manager.active_preset.name + ): + return + + new_name = PathUtils.sanitize_path_component(new_name) + new_name = self.data_manager.get_available_preset_name(new_name) + self.data_manager.rename_preset(new_name) + + def add_preset(self, name: str = DEFAULT_PRESET_NAME): + """Create a new preset called `new preset n`, add it to the active_group.""" + name = self.data_manager.get_available_preset_name(name) + try: + self.data_manager.create_preset(name) + self.data_manager.load_preset(name) + except PermissionError as e: + self.show_status(CTX_ERROR, _("Permission denied!"), str(e)) + + def delete_preset(self): + """Delete the active_preset from the disc.""" + + def f(answer: bool): + if answer: + self.data_manager.delete_preset() + self.data_manager.load_preset(self.get_a_preset()) + self.message_broker.publish(DoStackSwitch(1)) + + if not self.data_manager.active_preset: + return + msg = ( + _('Are you sure you want to delete the preset "%s"?') + % self.data_manager.active_preset.name + ) + self.message_broker.publish(UserConfirmRequest(msg, f)) + + def load_mapping(self, input_combination: InputCombination): + """Load the mapping with the given input_combination form the active_preset.""" + self.data_manager.load_mapping(input_combination) + self.load_input_config(input_combination[0]) + + def update_mapping(self, **changes): + """Update the active_mapping with the given keywords and values.""" + if "mapping_type" in changes.keys(): + if not (changes := self._change_mapping_type(changes)): + # we need to synchronize the gui + self.data_manager.publish_mapping() + self.data_manager.publish_event() + return + + self.data_manager.update_mapping(**changes) + self.save() + + def create_mapping(self): + """Create a new empty mapping in the active_preset.""" + try: + self.data_manager.create_mapping() + except KeyError: + # there is already an empty mapping + return + + self.data_manager.load_mapping(combination=InputCombination.empty_combination()) + self.data_manager.update_mapping(**MAPPING_DEFAULTS) + + def delete_mapping(self): + """Remove the active_mapping form the active_preset.""" + + def get_answer(answer: bool): + if answer: + self.data_manager.delete_mapping() + self.save() + + if not self.data_manager.active_mapping: + return + self.message_broker.publish( + UserConfirmRequest( + _("Are you sure you want to delete this mapping?"), + get_answer, + ) + ) + + def set_autoload(self, autoload: bool): + """Set the autoload state for the active_preset and active_group.""" + self.data_manager.set_autoload(autoload) + self.data_manager.refresh_service_config_path() + + def save(self): + """Save all data to the disc.""" + try: + self.data_manager.save() + except PermissionError as e: + self.show_status(CTX_ERROR, _("Permission denied!"), str(e)) + + def start_key_recording(self): + """Record the input of the active_group + + Updates the active_mapping.input_combination with the recorded events. + """ + state = self.data_manager.get_state() + if state == InjectorState.RUNNING or state == InjectorState.STARTING: + self.data_manager.stop_combination_recording() + self.message_broker.signal(MessageType.recording_finished) + self.show_status(CTX_ERROR, _('Use "Stop" to stop before editing')) + return + + logger.debug("Recording Keys") + + def on_recording_finished(_): + self.message_broker.unsubscribe(on_recording_finished) + self.message_broker.unsubscribe(self._on_combination_recorded) + self.gui.connect_shortcuts() + + self.gui.disconnect_shortcuts() + self.message_broker.subscribe( + MessageType.combination_recorded, + self._on_combination_recorded, + ) + self.message_broker.subscribe( + MessageType.recording_finished, on_recording_finished + ) + self.data_manager.start_combination_recording() + + def stop_key_recording(self): + """Stop recording the input.""" + logger.debug("Stopping Recording Keys") + self.data_manager.stop_combination_recording() + + def start_injecting(self): + """Inject the active_preset for the active_group.""" + if len(self.data_manager.active_preset) == 0: + logger.error(_("Cannot apply empty preset file")) + # also helpful for first time use + self.show_status(CTX_ERROR, _("You need to add mappings first")) + return + + if not self.button_left_warn: + if self.data_manager.active_preset.dangerously_mapped_btn_left(): + self.show_status( + CTX_ERROR, + "This would disable your click button", + "Map a button to BTN_LEFT to avoid this.\n" + "To overwrite this warning, press apply again.", + ) + self.button_left_warn = True + return + + # todo: warn about unreleased keys + self.button_left_warn = False + self.message_broker.subscribe( + MessageType.injector_state, + self.show_injector_result, + ) + self.show_status(CTX_APPLY, _("Starting injection...")) + if not self.data_manager.start_injecting(): + self.message_broker.unsubscribe(self.show_injector_result) + self.show_status( + CTX_APPLY, + _('Failed to apply preset "%s"') % self.data_manager.active_preset.name, + ) + + def show_injector_result(self, msg: InjectorStateMessage) -> None: + """Show if the injection was successfully started.""" + self.message_broker.unsubscribe(self.show_injector_result) + state = msg.state + + def running() -> None: + assert self.data_manager.active_preset is not None + msg = _('Applied preset "%s"') % self.data_manager.active_preset.name + if self.data_manager.active_preset.dangerously_mapped_btn_left(): + msg += _(", CTRL + DEL to stop") + self.show_status(CTX_APPLY, msg) + logger.info( + 'Group "%s" is currently mapped', self.data_manager.active_group.key + ) + + def no_grab() -> None: + assert self.data_manager.active_preset is not None + msg = ( + _('Failed to apply preset "%s"') % self.data_manager.active_preset.name + ) + tooltip = ( + "Maybe your preset doesn't contain anything that is sent by the " + "device or another device is already grabbing it" + ) + + # InjectorState.NO_GRAB also happens when all mappings have validation + # errors. In that case, we can show something more useful. + validation_result = self._format_status_bar_validation_errors() + if validation_result is not None: + msg = f"{msg}. {validation_result[0]}" + tooltip = validation_result[1] + + self.show_status(CTX_ERROR, msg, tooltip) + + assert self.data_manager.active_preset # make mypy happy + state_calls: Dict[InjectorState, Callable] = { + InjectorState.RUNNING: running, + InjectorState.ERROR: partial( + self.show_status, + CTX_ERROR, + _('Error applying preset "%s"') % self.data_manager.active_preset.name, + ), + InjectorState.NO_GRAB: no_grab, + InjectorState.UPGRADE_EVDEV: partial( + self.show_status, + CTX_ERROR, + "Upgrade python-evdev", + "Your python-evdev version is too old.", + ), + } + + if state in state_calls: + state_calls[state]() + + def stop_injecting(self): + """Stop injecting any preset for the active_group.""" + + def show_result(msg: InjectorStateMessage): + self.message_broker.unsubscribe(show_result) + + if not msg.inactive(): + # some speculation: there might be unexpected additional status messages + # with a different state, or the status is wrong because something in + # the long pipeline of status messages is broken. + logger.error( + "Expected the injection to eventually stop, but got state %s", + msg.state, + ) + return + + self.show_status(CTX_APPLY, _("Stopped the injection")) + + try: + self.message_broker.subscribe(MessageType.injector_state, show_result) + self.data_manager.stop_injecting() + except DataManagementError: + self.message_broker.unsubscribe(show_result) + + def show_status( + self, + ctx_id: int, + msg: Optional[str] = None, + tooltip: Optional[str] = None, + ): + """Send a status message to the ui to show it in the status-bar.""" + self.message_broker.publish(StatusData(ctx_id, msg, tooltip)) + + def is_empty_mapping(self) -> bool: + """Check if the active_mapping is empty.""" + return ( + self.data_manager.active_mapping == UIMapping(**MAPPING_DEFAULTS) + or self.data_manager.active_mapping is None + ) + + def refresh_groups(self): + """Reload the connected devices and send them as a groups message. + + Runs asynchronously. + """ + self.data_manager.refresh_groups() + + def close(self): + """Safely close the application.""" + logger.debug("Closing Application") + self.save() + self.message_broker.signal(MessageType.terminate) + logger.debug("Quitting") + Gtk.main_quit() + + def set_focus(self, component): + """Focus the given component.""" + self.gui.window.set_focus(component) + + def _change_mapping_type(self, changes: Dict[str, Any]): + """Query the user to update the mapping in order to change the mapping type.""" + mapping = self.data_manager.active_mapping + + if mapping is None: + return changes + + if changes["mapping_type"] == mapping.mapping_type: + return changes + + if changes["mapping_type"] == MappingType.ANALOG.value: + msg = _("You are about to change the mapping to analog.") + if mapping.output_symbol: + msg += _('\nThis will remove "{}" ' "from the text input!").format( + mapping.output_symbol + ) + + if not [ + input_config + for input_config in mapping.input_combination + if input_config.defines_analog_input + ]: + # there is no analog input configured, let's try to autoconfigure it + inputs: List[InputConfig] = list(mapping.input_combination) + for i, input_config in enumerate(inputs): + if input_config.type in [EV_ABS, EV_REL]: + inputs[i] = input_config.modify(analog_threshold=0) + changes["input_combination"] = InputCombination(inputs) + msg += _( + '\nThe input "{}" will be used as analog input.' + ).format(input_config.description()) + break + else: + # not possible to autoconfigure inform the user + msg += _("\nYou need to record an analog input.") + + elif not mapping.output_symbol: + return changes + + answer = None + + def get_answer(answer_: bool): + nonlocal answer + answer = answer_ + + self.message_broker.publish(UserConfirmRequest(msg, get_answer)) + if answer: + changes["output_symbol"] = None + return changes + else: + return None + + if changes["mapping_type"] == MappingType.KEY_MACRO.value: + try: + analog_input = tuple( + filter(lambda i: i.defines_analog_input, mapping.input_combination) + )[0] + except IndexError: + changes["output_type"] = None + changes["output_code"] = None + return changes + + answer = None + + def get_answer(answer_: bool): + nonlocal answer + answer = answer_ + + self.message_broker.publish( + UserConfirmRequest( + f"You are about to change the mapping to a Key or Macro mapping!\n" + f"Go to the advanced input configuration and set a " + f'"Trigger Threshold" for "{analog_input.description()}".', + get_answer, + ) + ) + if answer: + changes["output_type"] = None + changes["output_code"] = None + return changes + else: + return None + + return changes diff --git a/inputremapper/gui/data_manager.py b/inputremapper/gui/data_manager.py new file mode 100644 index 0000000..b077928 --- /dev/null +++ b/inputremapper/gui/data_manager.py @@ -0,0 +1,609 @@ +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + +import glob +import os +import re +import time +from typing import Optional, List, Tuple, Set + +from gi.repository import GLib + +from inputremapper.configs.global_config import GlobalConfig +from inputremapper.configs.input_config import InputCombination, InputConfig +from inputremapper.configs.mapping import UIMapping, MappingData +from inputremapper.configs.paths import PathUtils +from inputremapper.configs.preset import Preset +from inputremapper.configs.keyboard_layout import KeyboardLayout +from inputremapper.daemon import DaemonProxy +from inputremapper.exceptions import DataManagementError +from inputremapper.groups import _Group +from inputremapper.gui.gettext import _ +from inputremapper.gui.messages.message_broker import ( + MessageBroker, +) +from inputremapper.gui.messages.message_data import ( + UInputsData, + GroupData, + PresetData, + CombinationUpdate, +) +from inputremapper.gui.reader_client import ReaderClient +from inputremapper.injection.global_uinputs import GlobalUInputs +from inputremapper.injection.injector import ( + InjectorState, + InjectorStateMessage, +) +from inputremapper.logging.logger import logger + +DEFAULT_PRESET_NAME = _("new preset") + +# useful type aliases +Name = str +GroupKey = str + + +class DataManager: + """DataManager provides an interface to create and modify configurations as well + as modify the state of the Service. + + Any state changes will be announced via the MessageBroker. + """ + + def __init__( + self, + message_broker: MessageBroker, + config: GlobalConfig, + reader_client: ReaderClient, + daemon: DaemonProxy, + uinputs: GlobalUInputs, + keyboard_layout: KeyboardLayout, + ): + self.message_broker = message_broker + self._reader_client = reader_client + self._daemon = daemon + self._uinputs = uinputs + self._keyboard_layout = keyboard_layout + uinputs.prepare_all() + + self._config = config + self._config.load_config() + + self._active_preset: Optional[Preset[UIMapping]] = None + self._active_mapping: Optional[UIMapping] = None + self._active_input_config: Optional[InputConfig] = None + + def publish_group(self): + """Send active group to the MessageBroker. + + This is internally called whenever the group changes. + It is usually not necessary to call this explicitly from + outside DataManager. + """ + self.message_broker.publish( + GroupData(self.active_group.key, self.get_preset_names()) + ) + + def publish_preset(self): + """Send active preset to the MessageBroker. + + This is internally called whenever the preset changes. + It is usually not necessary to call this explicitly from + outside DataManager. + """ + self.message_broker.publish( + PresetData( + self.active_preset.name, self.get_mappings(), self.get_autoload() + ) + ) + + def publish_mapping(self): + """Send active mapping to the MessageBroker + + This is internally called whenever the mapping changes. + It is usually not necessary to call this explicitly from + outside DataManager. + """ + if self.active_mapping: + self.message_broker.publish(self.active_mapping.get_bus_message()) + + def publish_event(self): + """Send active event to the MessageBroker. + + This is internally called whenever the event changes. + It is usually not necessary to call this explicitly from + outside DataManager + """ + if self.active_input_config: + assert self.active_input_config in self.active_mapping.input_combination + self.message_broker.publish(self.active_input_config) + + def publish_uinputs(self): + """Send the "uinputs" message on the MessageBroker.""" + self.message_broker.publish( + UInputsData( + { + name: uinput.capabilities() + for name, uinput in self._uinputs.devices.items() + } + ) + ) + + def publish_groups(self): + """Publish the "groups" message on the MessageBroker.""" + self._reader_client.publish_groups() + + def publish_injector_state(self): + """Publish the "injector_state" message for the active_group.""" + if not self.active_group: + return + + self.message_broker.publish(InjectorStateMessage(self.get_state())) + + @property + def active_group(self) -> Optional[_Group]: + """The currently loaded group.""" + return self._reader_client.group + + @property + def active_preset(self) -> Optional[Preset[UIMapping]]: + """The currently loaded preset.""" + return self._active_preset + + @property + def active_mapping(self) -> Optional[UIMapping]: + """The currently loaded mapping.""" + return self._active_mapping + + @property + def active_input_config(self) -> Optional[InputConfig]: + """The currently loaded event.""" + return self._active_input_config + + def get_group_keys(self) -> Tuple[GroupKey, ...]: + """Get all group keys (plugged devices).""" + return tuple(group.key for group in self._reader_client.groups.get_groups()) + + def get_preset_names(self) -> Tuple[Name, ...]: + """Get all preset names for active_group and current user sorted by age.""" + if not self.active_group: + raise DataManagementError("Cannot find presets: Group is not set") + device_folder = PathUtils.get_preset_path(self.active_group.name) + PathUtils.mkdir(device_folder) + + paths = glob.glob(os.path.join(glob.escape(device_folder), "*.json")) + presets = [ + os.path.splitext(os.path.basename(path))[0] + for path in sorted(paths, key=os.path.getmtime) + ] + # the highest timestamp to the front + presets.reverse() + return tuple(presets) + + def get_mappings(self) -> Optional[List[MappingData]]: + """All mappings from the active_preset.""" + if not self._active_preset: + return None + + return [mapping.get_bus_message() for mapping in self._active_preset] + + def get_autoload(self) -> bool: + """The autoload status of the active_preset.""" + if not self.active_preset or not self.active_group: + return False + return self._config.is_autoloaded( + self.active_group.key, self.active_preset.name + ) + + def set_autoload(self, status: bool): + """Set the autoload status of the active_preset. + + Will send "preset" message on the MessageBroker. + """ + if not self.active_preset or not self.active_group: + raise DataManagementError("Cannot set autoload status: Preset is not set") + + if status: + self._config.set_autoload_preset( + self.active_group.key, self.active_preset.name + ) + elif self.get_autoload(): + self._config.set_autoload_preset(self.active_group.key, None) + + self.publish_preset() + + def get_newest_group_key(self) -> GroupKey: + """group_key of the group with the most recently modified preset.""" + paths = [] + pattern = os.path.join( + glob.escape(PathUtils.get_preset_path()), + "*/*.json", + ) + for path in glob.glob(pattern): + if self._reader_client.groups.find(key=PathUtils.split_all(path)[-2]): + paths.append((path, os.path.getmtime(path))) + + if not paths: + raise FileNotFoundError() + + path, _ = max(paths, key=lambda x: x[1]) + return PathUtils.split_all(path)[-2] + + def get_newest_preset_name(self) -> Name: + """Preset name of the most recently modified preset in the active group.""" + if not self.active_group: + raise DataManagementError("Cannot find newest preset: Group is not set") + + pattern = os.path.join( + glob.escape(PathUtils.get_preset_path(self.active_group.name)), + "*.json", + ) + paths = [(path, os.path.getmtime(path)) for path in glob.glob(pattern)] + if not paths: + raise FileNotFoundError() + + path, _ = max(paths, key=lambda x: x[1]) + return os.path.split(path)[-1].split(".")[0] + + def get_available_preset_name(self, name=DEFAULT_PRESET_NAME) -> Name: + """The first available preset in the active group.""" + if not self.active_group: + raise DataManagementError("Unable find preset name. Group is not set") + + name = name.strip() + + # find a name that is not already taken + if os.path.exists(PathUtils.get_preset_path(self.active_group.name, name)): + # if there already is a trailing number, increment it instead of + # adding another one + match = re.match(r"^(.+) (\d+)$", name) + if match: + name = match[1] + i = int(match[2]) + 1 + else: + i = 2 + + while os.path.exists( + PathUtils.get_preset_path(self.active_group.name, f"{name} {i}") + ): + i += 1 + + return f"{name} {i}" + + return name + + def load_group(self, group_key: str): + """Load a group. will publish "groups" and "injector_state" messages. + + This will render the active_mapping and active_preset invalid. + """ + if group_key not in self.get_group_keys(): + raise DataManagementError("Unable to load non existing group") + + logger.info('Loading group "%s"', group_key) + + self._active_input_config = None + self._active_mapping = None + self._active_preset = None + group = self._reader_client.groups.find(key=group_key) + self._reader_client.set_group(group) + self.publish_group() + self.publish_injector_state() + + def load_preset(self, name: str): + """Load a preset. Will send "preset" message on the MessageBroker. + + This will render the active_mapping invalid. + """ + if not self.active_group: + raise DataManagementError("Unable to load preset. Group is not set") + + logger.info('Loading preset "%s"', name) + + preset_path = PathUtils.get_preset_path(self.active_group.name, name) + preset = Preset(preset_path, mapping_factory=UIMapping) + preset.load() + self._active_input_config = None + self._active_mapping = None + self._active_preset = preset + self.publish_preset() + + def load_mapping(self, combination: InputCombination): + """Load a mapping. Will send "mapping" message on the MessageBroker.""" + if not self._active_preset: + raise DataManagementError("Unable to load mapping. Preset is not set") + + mapping = self._active_preset.get_mapping(combination) + if not mapping: + msg = ( + f"the mapping with {combination = } does not " + f"exist in the {self._active_preset.path}" + ) + logger.error(msg) + raise KeyError(msg) + + self._active_input_config = None + self._active_mapping = mapping + self.publish_mapping() + + def load_input_config(self, input_config: InputConfig): + """Load a InputConfig from the combination in the active mapping. + + Will send "event" message on the MessageBroker, + """ + if not self.active_mapping: + raise DataManagementError("Unable to load event. Mapping is not set") + if input_config not in self.active_mapping.input_combination: + raise ValueError( + f"{input_config} is not member of active_mapping.input_combination: " + f"{self.active_mapping.input_combination}" + ) + self._active_input_config = input_config + self.publish_event() + + def rename_preset(self, new_name: str): + """Rename the current preset and move the correct file. + + Will send "group" and then "preset" message on the MessageBroker + """ + if not self.active_preset or not self.active_group: + raise DataManagementError("Unable rename preset: Preset is not set") + + if self.active_preset.path == PathUtils.get_preset_path( + self.active_group.name, new_name + ): + return + + old_path = self.active_preset.path + assert old_path is not None + old_name = os.path.basename(old_path).split(".")[0] + new_path = PathUtils.get_preset_path(self.active_group.name, new_name) + if os.path.exists(new_path): + raise ValueError( + f"cannot rename {old_name} to " f"{new_name}, preset already exists" + ) + + logger.info('Moving "%s" to "%s"', old_path, new_path) + os.rename(old_path, new_path) + now = time.time() + os.utime(new_path, (now, now)) + + if self._config.is_autoloaded(self.active_group.key, old_name): + self._config.set_autoload_preset(self.active_group.key, new_name) + + self.active_preset.path = PathUtils.get_preset_path( + self.active_group.name, new_name + ) + self.publish_group() + self.publish_preset() + + def copy_preset(self, name: str): + """Copy the current preset to the given name. + + Will send "group" and "preset" message to the MessageBroker and load the copy + """ + # todo: Do we want to load the copy here? or is this up to the controller? + if not self.active_preset or not self.active_group: + raise DataManagementError("Unable to copy preset: Preset is not set") + + if self.active_preset.path == PathUtils.get_preset_path( + self.active_group.name, name + ): + return + + if name in self.get_preset_names(): + raise ValueError(f"a preset with the name {name} already exits") + + new_path = PathUtils.get_preset_path(self.active_group.name, name) + logger.info('Copy "%s" to "%s"', self.active_preset.path, new_path) + self.active_preset.path = new_path + self.save() + self.publish_group() + self.publish_preset() + + def create_preset(self, name: str): + """Create empty preset in the active_group. + + Will send "group" message to the MessageBroker + """ + if not self.active_group: + raise DataManagementError("Unable to add preset. Group is not set") + + path = PathUtils.get_preset_path(self.active_group.name, name) + if os.path.exists(path): + raise DataManagementError("Unable to add preset. Preset exists") + + Preset(path).save() + self.publish_group() + + def delete_preset(self): + """Delete the active preset. + + Will send "group" message to the MessageBroker + this will invalidate the active mapping, + """ + preset_path = self._active_preset.path + logger.info('Removing "%s"', preset_path) + os.remove(preset_path) + self._active_mapping = None + self._active_preset = None + self.publish_group() + + def update_mapping(self, **kwargs): + """Update the active mapping with the given keywords and values. + + Will send "mapping" message to the MessageBroker. In case of a new + input_combination. This will first send a "combination_update" message. + """ + if not self._active_mapping: + raise DataManagementError("Cannot modify Mapping: Mapping is not set") + + if symbol := kwargs.get("output_symbol"): + kwargs["output_symbol"] = self._keyboard_layout.correct_case(symbol) + + combination = self.active_mapping.input_combination + for key, value in kwargs.items(): + setattr(self._active_mapping, key, value) + + if ( + "input_combination" in kwargs + and combination != self.active_mapping.input_combination + ): + self._active_input_config = None + self.message_broker.publish( + CombinationUpdate(combination, self._active_mapping.input_combination) + ) + + if "mapping_type" in kwargs: + # mapping_type must be the last update because it is automatically updated + # by a validation function + self._active_mapping.mapping_type = kwargs["mapping_type"] + + self.publish_mapping() + + def update_input_config(self, new_input_config: InputConfig): + """Update the active input configuration. + + Will send "combination_update", "mapping" and "event" messages to the + MessageBroker (in that order) + """ + if not self.active_mapping or not self.active_input_config: + raise DataManagementError("Cannot modify event: Event is not set") + + combination = list(self.active_mapping.input_combination) + combination[combination.index(self.active_input_config)] = new_input_config + self.update_mapping(input_combination=InputCombination(combination)) + self._active_input_config = new_input_config + self.publish_event() + + def create_mapping(self): + """Create empty mapping in the active preset. + + Will send "preset" message to the MessageBroker + """ + if not self._active_preset: + raise DataManagementError("Cannot create mapping: Preset is not set") + self._active_preset.add(UIMapping()) + self.publish_preset() + + def delete_mapping(self): + """Delete the active mapping. + + Will send "preset" message to the MessageBroker + """ + if not self._active_mapping: + raise DataManagementError( + "cannot delete active mapping: active mapping is not set" + ) + + self._active_preset.remove(self._active_mapping.input_combination) + self._active_mapping = None + self.publish_preset() + + def save(self): + """Save the active preset.""" + if self._active_preset: + self._active_preset.save() + + def refresh_groups(self): + """Refresh the groups (plugged devices). + + Should send "groups" message to MessageBroker this will not happen immediately + because the system might take a bit until the groups are available + """ + self._reader_client.refresh_groups() + + def start_combination_recording(self): + """Record user input. + + Will send "combination_recorded" messages as new input arrives. + Will eventually send a "recording_finished" message. + """ + self._reader_client.start_recorder() + + def stop_combination_recording(self): + """Stop recording user input. + + Will send a recording_finished signal if a recording is running. + """ + self._reader_client.stop_recorder() + + def stop_injecting(self) -> None: + """Stop injecting for the active group. + + Will send "injector_state" message once the injector has stopped.""" + if not self.active_group: + raise DataManagementError("Cannot stop injection: Group is not set") + self._daemon.stop_injecting(self.active_group.key) + self.do_when_injector_state( + {InjectorState.STOPPED}, self.publish_injector_state + ) + + def start_injecting(self) -> bool: + """Start injecting the active preset for the active group. + + returns if the startup was successfully initialized. + Will send "injector_state" message once the startup is complete. + """ + if not self.active_preset or not self.active_group: + raise DataManagementError("Cannot start injection: Preset is not set") + + self._daemon.set_config_dir(self._config.get_dir()) + assert self.active_preset.name is not None + if self._daemon.start_injecting(self.active_group.key, self.active_preset.name): + self.do_when_injector_state( + { + InjectorState.RUNNING, + InjectorState.ERROR, + InjectorState.NO_GRAB, + InjectorState.UPGRADE_EVDEV, + }, + self.publish_injector_state, + ) + return True + return False + + def get_state(self) -> InjectorState: + """The state of the injector.""" + if not self.active_group: + raise DataManagementError("Cannot read state: Group is not set") + return self._daemon.get_state(self.active_group.key) + + def refresh_service_config_path(self): + """Tell the service to refresh its config path.""" + self._daemon.set_config_dir(self._config.get_dir()) + + def do_when_injector_state(self, states: Set[InjectorState], callback): + """Run callback once the injector state is one of states.""" + start = time.time() + + def do(): + if time.time() - start > 3: + # something went wrong, there should have been a state long ago. + # the timeout prevents tons of GLib.timeouts to run forever, especially + # after spamming the "Stop" button. + logger.error("Timed out while waiting for injector state %s", states) + return False + + if self.get_state() in states: + callback() + return False + return True + + GLib.timeout_add(100, do) diff --git a/inputremapper/gui/forward_to_ui_handler.py b/inputremapper/gui/forward_to_ui_handler.py new file mode 100644 index 0000000..e046445 --- /dev/null +++ b/inputremapper/gui/forward_to_ui_handler.py @@ -0,0 +1,125 @@ +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2023 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + +"""Process that sends stuff to the GUI. + +It should be started via input-remapper-control and pkexec. + +GUIs should not run as root +https://wiki.archlinux.org/index.php/Running_GUI_applications_as_root + +The service shouldn't do that even though it has root rights, because that +would enable key-loggers to just ask input-remapper for all user-input. + +Instead, the ReaderService is used, which will be stopped when the gui closes. + +Whereas for the reader-service to start a password is needed and it stops whe +the ui closes. + +This uses the backend injection.event_reader and mapping_handlers to process all the +different input-events into simple on/off events and sends them to the gui. +""" + +from __future__ import annotations + + +import evdev +from evdev.ecodes import EV_ABS + +from inputremapper.configs.input_config import ( + DEFAULT_ABS_ANALOG_THRESHOLD_MAGNITUDE, +) +from inputremapper.input_event import InputEvent +from inputremapper.ipc.pipe import Pipe +from inputremapper.logging.logger import logger +from inputremapper.injection.mapping_handlers.mapping_handler import MappingHandler +from inputremapper.injection.mapping_handlers.abs_util import calculate_trigger_point + +# received by the reader-service +CMD_TERMINATE = "terminate" +CMD_STOP_READING = "stop-reading" +CMD_REFRESH_GROUPS = "refresh_groups" + +# sent by the reader-service to the reader +MSG_GROUPS = "groups" +MSG_EVENT = "event" +MSG_STATUS = "status" + + +class ForwardToUIHandler(MappingHandler): + """Implements the MappingHandler protocol. Sends all events into the pipe.""" + + def __init__(self, pipe: Pipe): + self.pipe = pipe + self._last_event = InputEvent.from_tuple((99, 99, 99)) + + def notify( + self, + event: InputEvent, + source: evdev.InputDevice, + suppress: bool = False, + ) -> bool: + """Filter duplicates and send into the pipe.""" + if event == self._last_event: + return True + + # These defaults work with EV_KEY and EV_REL + pressed = False if event.value == 0 else True + direction = 1 if event.value >= 0 else -1 + + # Because joysticks aren't as precise, they wiggle and their value might not be + # centered around 0, they need special treatment + if event.type == EV_ABS: + threshold, mid_point = calculate_trigger_point( + event, + DEFAULT_ABS_ANALOG_THRESHOLD_MAGNITUDE, + source, + ) + + # If within 30% (into each direction) of the mid_point, count as released + # A large threshold makes it significantly easier to not accidentally + # record both ABS_X and ABS_Y. + if abs(event.value - mid_point) < threshold: + pressed = False + + if event.value < mid_point: + direction = -1 + + self._last_event = event + + logger.debug("Sending %s to frontend", event) + self.pipe.send( + { + "type": MSG_EVENT, + "message": { + "sec": event.sec, + "usec": event.usec, + "type": event.type, + "code": event.code, + "value": event.value, + "pressed": pressed, + "direction": direction, + "origin_hash": event.origin_hash, + }, + } + ) + return True + + def reset(self): + pass diff --git a/inputremapper/gui/gettext.py b/inputremapper/gui/gettext.py new file mode 100644 index 0000000..891aadf --- /dev/null +++ b/inputremapper/gui/gettext.py @@ -0,0 +1,33 @@ +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + +import gettext +import locale +import os.path + +from inputremapper.configs.data import get_data_path + +APP_NAME = "input-remapper" +LOCALE_DIR = os.path.join(get_data_path(), "lang") + +locale.bindtextdomain(APP_NAME, LOCALE_DIR) +locale.textdomain(APP_NAME) + +translate = gettext.translation(APP_NAME, LOCALE_DIR, fallback=True) +_ = translate.gettext diff --git a/inputremapper/gui/messages/__init__.py b/inputremapper/gui/messages/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/inputremapper/gui/messages/message_broker.py b/inputremapper/gui/messages/message_broker.py new file mode 100644 index 0000000..62c9fcd --- /dev/null +++ b/inputremapper/gui/messages/message_broker.py @@ -0,0 +1,120 @@ +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + +import os.path +import re +import traceback +from collections import defaultdict, deque +from typing import ( + Callable, + Dict, + Set, + Protocol, + Tuple, + Deque, + Any, +) + +from inputremapper.gui.messages.message_types import MessageType +from inputremapper.logging.logger import logger + + +class Message(Protocol): + """The protocol any message must follow to be sent with the MessageBroker.""" + + @property + def message_type(self) -> MessageType: ... + + +# useful type aliases +MessageListener = Callable[[Any], None] + + +class MessageBroker: + shorten_path = re.compile(r"inputremapper/") + + def __init__(self): + self._listeners: Dict[MessageType, Set[MessageListener]] = defaultdict(set) + self._messages: Deque[Tuple[Message, str, int]] = deque() + self._publishing = False + + def publish(self, data: Message): + """Schedule a massage to be sent. + The message will be sent after all currently pending messages are sent.""" + self._messages.append((data, *self.get_caller())) + self._publish_all() + + def signal(self, signal: MessageType): + """Send a signal without any data payload.""" + # This is different from calling self.publish because self.get_caller() + # looks back at the current stack 3 frames + self._messages.append((Signal(signal), *self.get_caller())) + self._publish_all() + + def _publish(self, data: Message, file: str, line: int): + logger.debug( + "from %s:%d: Signal=%s: %s", file, line, data.message_type.name, data + ) + for listener in self._listeners[data.message_type].copy(): + listener(data) + + def _publish_all(self): + """Send all scheduled messages in order.""" + if self._publishing: + # don't run this twice, so we not mess up the order + return + + self._publishing = True + try: + while self._messages: + self._publish(*self._messages.popleft()) + finally: + self._publishing = False + + def subscribe(self, massage_type: MessageType, listener: MessageListener): + """Attach a listener to an event.""" + logger.debug("adding new Listener for %s: %s", massage_type, listener) + self._listeners[massage_type].add(listener) + return self + + @staticmethod + def get_caller(position: int = 3) -> Tuple[str, int]: + """Extract a file and line from current stack and format for logging.""" + tb = traceback.extract_stack(limit=position)[0] + return os.path.basename(tb.filename), tb.lineno or 0 + + def unsubscribe(self, listener: MessageListener) -> None: + for listeners in self._listeners.values(): + try: + listeners.remove(listener) + except KeyError: + pass + + +class Signal: + """Send a Message without any associated data over the MassageBus.""" + + def __init__(self, message_type: MessageType): + self.message_type: MessageType = message_type + + def __str__(self): + return f"Signal: {self.message_type}" + + def __eq__(self, other: Any): + return type(self) is type(other) and self.message_type == other.message_type diff --git a/inputremapper/gui/messages/message_data.py b/inputremapper/gui/messages/message_data.py new file mode 100644 index 0000000..94ca174 --- /dev/null +++ b/inputremapper/gui/messages/message_data.py @@ -0,0 +1,126 @@ +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + +import re +from dataclasses import dataclass +from typing import Dict, Tuple, Optional, Callable + +from inputremapper.configs.input_config import InputCombination +from inputremapper.configs.mapping import MappingData +from inputremapper.gui.messages.message_types import ( + MessageType, + Name, + Capabilities, + Key, + DeviceTypes, +) + + +@dataclass(frozen=True) +class UInputsData: + message_type = MessageType.uinputs + uinputs: Dict[Name, Capabilities] + + def __str__(self): + string = f"{self.__class__.__name__}(uinputs={self.uinputs})" + + # find all sequences of comma+space separated numbers, and shorten them + # to the first and last number + all_matches = list(re.finditer(r"(\d+, )+", string)) + all_matches.reverse() + for match in all_matches: + start = match.start() + end = match.end() + start += string[start:].find(",") + 2 + if start == end: + continue + string = f"{string[:start]}... {string[end:]}" + + return string + + +@dataclass(frozen=True) +class GroupsData: + """Message containing all available groups and their device types.""" + + message_type = MessageType.groups + groups: Dict[Key, DeviceTypes] + + +@dataclass(frozen=True) +class GroupData: + """Message with the active group and available presets for the group.""" + + message_type = MessageType.group + group_key: str + presets: Tuple[str, ...] + + +@dataclass(frozen=True) +class PresetData: + """Message with the active preset name and mapping names/combinations.""" + + message_type = MessageType.preset + name: Optional[Name] + mappings: Optional[Tuple[MappingData, ...]] + autoload: bool = False + + +@dataclass(frozen=True) +class StatusData: + """Message with the strings and id for the status bar.""" + + message_type = MessageType.status_msg + ctx_id: int + msg: Optional[str] = None + tooltip: Optional[str] = None + + +@dataclass(frozen=True) +class CombinationRecorded: + """Message with the latest recoded combination.""" + + message_type = MessageType.combination_recorded + combination: "InputCombination" + + +@dataclass(frozen=True) +class CombinationUpdate: + """Message with the old and new combination (hash for a mapping) when it changed.""" + + message_type = MessageType.combination_update + old_combination: "InputCombination" + new_combination: "InputCombination" + + +@dataclass(frozen=True) +class UserConfirmRequest: + """Message for requesting a user response (confirm/cancel) from the gui.""" + + message_type = MessageType.user_confirm_request + msg: str + respond: Callable[[bool], None] = lambda _: None + + +@dataclass(frozen=True) +class DoStackSwitch: + """Command the stack to switch to a different page.""" + + message_type = MessageType.do_stack_switch + page_index: int diff --git a/inputremapper/gui/messages/message_types.py b/inputremapper/gui/messages/message_types.py new file mode 100644 index 0000000..c11f30b --- /dev/null +++ b/inputremapper/gui/messages/message_types.py @@ -0,0 +1,60 @@ +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + +from enum import Enum +from typing import Dict, List + +from inputremapper.groups import DeviceType + +# useful type aliases +Capabilities = Dict[int, List] +Name = str +Key = str +DeviceTypes = List[DeviceType] + + +class MessageType(Enum): + reset_gui = "reset_gui" + terminate = "terminate" + init = "init" + + uinputs = "uinputs" + groups = "groups" + group = "group" + preset = "preset" + mapping = "mapping" + selected_event = "selected_event" + combination_recorded = "combination_recorded" + + # only the reader_client should send those messages: + recording_started = "recording_started" + recording_finished = "recording_finished" + + combination_update = "combination_update" + status_msg = "status_msg" + injector_state = "injector_state" + + gui_focus_request = "gui_focus_request" + user_confirm_request = "user_confirm_request" + + do_stack_switch = "do_stack_switch" + + # for unit tests: + test1 = "test1" + test2 = "test2" diff --git a/inputremapper/gui/reader_client.py b/inputremapper/gui/reader_client.py new file mode 100644 index 0000000..99ef8f1 --- /dev/null +++ b/inputremapper/gui/reader_client.py @@ -0,0 +1,306 @@ +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + + +"""Talking to the ReaderService that has root permissions. + +see gui.reader_service.ReaderService +""" + +import time +from typing import Optional, List, Generator, Dict, Set + +import evdev +from gi.repository import GLib + +from inputremapper.configs.input_config import ( + InputCombination, + DEFAULT_ABS_ANALOG_THRESHOLD_MAGNITUDE, +) +from inputremapper.groups import _Groups, _Group +from inputremapper.gui.gettext import _ +from inputremapper.gui.messages.message_broker import MessageBroker +from inputremapper.gui.messages.message_data import ( + GroupsData, + CombinationRecorded, + StatusData, +) +from inputremapper.gui.messages.message_types import MessageType +from inputremapper.gui.reader_service import ( + MSG_EVENT, + MSG_GROUPS, + CMD_TERMINATE, + CMD_REFRESH_GROUPS, + CMD_STOP_READING, + ReaderService, +) +from inputremapper.gui.utils import CTX_ERROR +from inputremapper.input_event import InputEvent +from inputremapper.ipc.pipe import Pipe +from inputremapper.logging.logger import logger + +BLACKLISTED_EVENTS = [(1, evdev.ecodes.BTN_TOOL_DOUBLETAP)] +RecordingGenerator = Generator[None, InputEvent, None] + + +class ReaderClient: + """Processes events from the reader-service for the GUI to use. + + Does not serve any purpose for the injection service. + + When a button was pressed, the newest keycode can be obtained from this object. + GTK has get_key for keyboard keys, but Reader also has knowledge of buttons like + the middle-mouse button. + """ + + # how long to wait for the reader-service at most + _timeout: int = 5 + + def __init__(self, message_broker: MessageBroker, groups: _Groups): + self.groups = groups + self.message_broker = message_broker + + self.group: Optional[_Group] = None + + self._recording_generator: Optional[RecordingGenerator] = None + self._results_pipe, self._commands_pipe = self.connect() + + self.attach_to_events() + + self._read_timeout = GLib.timeout_add(30, self._read) + + def ensure_reader_service_running(self): + if ReaderService.is_running(): + return + + logger.info("ReaderService not running anymore, restarting") + ReaderService.pkexec_reader_service() + + # wait until the ReaderService is up + + # wait no more than: + polling_period = 0.01 + # this will make the gui non-responsive for 0.4s or something. The pkexec + # password prompt will appear, so the user understands that the lag has to + # be connected to the authentication. I would actually prefer the frozen gui + # over a reactive one here, because the short lag shows that stuff is going on + # behind the scenes. + for __ in range(int(self._timeout / polling_period)): + if self._results_pipe.poll(): + logger.info("ReaderService started") + break + + time.sleep(polling_period) + else: + msg = "The reader-service did not start" + logger.error(msg) + self.message_broker.publish(StatusData(CTX_ERROR, _(msg))) + + def _send_command(self, command: str): + """Send a command to the ReaderService.""" + if command not in [CMD_TERMINATE, CMD_STOP_READING]: + self.ensure_reader_service_running() + + logger.debug('Sending "%s" to ReaderService', command) + self._commands_pipe.send(command) + + def connect(self): + """Connect to the reader-service.""" + results_pipe = Pipe(ReaderService.get_pipe_paths()[0]) + commands_pipe = Pipe(ReaderService.get_pipe_paths()[1]) + return results_pipe, commands_pipe + + def attach_to_events(self): + """Connect listeners to event_reader.""" + self.message_broker.subscribe( + MessageType.terminate, + lambda _: self.terminate(), + ) + + def _read(self): + """Read the messages from the reader-service and handle them.""" + while self._results_pipe.poll(): + message = self._results_pipe.recv() + + logger.debug("received %s", message) + + message_type = message["type"] + message_body = message["message"] + + if message_type == MSG_GROUPS: + self._update_groups(message_body) + + if message_type == MSG_EVENT: + # update the generator + try: + if self._recording_generator is not None: + self._recording_generator.send(InputEvent(**message_body)) + else: + # the ReaderService should only send events while the gui + # is recording, so this is unexpected. + logger.error("Got event, but recorder is not running.") + except StopIteration: + # the _recording_generator returned + logger.debug("Recorder finished.") + self.stop_recorder() + break + + return True + + def start_recorder(self) -> None: + """Record user input.""" + if self.group is None: + logger.error("No group set") + return + + logger.debug("Starting recorder.") + self._send_command(self.group.key) + + self._recording_generator = self._recorder() + next(self._recording_generator) + + self.message_broker.signal(MessageType.recording_started) + + def stop_recorder(self) -> None: + """Stop recording the input. + + Will send recording_finished signals. + """ + logger.debug("Stopping recorder.") + self._send_command(CMD_STOP_READING) + + if self._recording_generator: + self._recording_generator.close() + self._recording_generator = None + else: + # this would be unexpected. but this is not critical enough to + # show to the user without debug logs + logger.debug("No recording generator existed") + + self.message_broker.signal(MessageType.recording_finished) + + @staticmethod + def _input_event_to_config(event: InputEvent): + # This used to default to event.value, which was broken for joysticks because + # it resulted in a very low value (I tihnk 1). Which I think was because we + # overwrote the event.value with 1 in the handlers. + + # For joysticks the default uses DEFAULT_ABS_ANALOG_THRESHOLD_MAGNITUDE + # in percent now. This value would break moues movements, because mice don't + # have a maximum value that we could use for percent calculations. + + # So for EV_REL we just use 1 or -1 to keep it working the same way it used to + # work. + analog_threshold = event.direction + + if event.type == evdev.ecodes.EV_ABS: + analog_threshold = event.direction * DEFAULT_ABS_ANALOG_THRESHOLD_MAGNITUDE + + return { + "type": event.type, + "code": event.code, + "analog_threshold": analog_threshold, + "origin_hash": event.origin_hash, + } + + def _recorder(self) -> RecordingGenerator: + """Generator which receives InputEvents. + + It accumulates them into EventCombinations and sends those on the + message_broker. It will stop once all keys or inputs are released. + """ + active: Set = set() + accumulator: List[InputEvent] = [] + while True: + event: InputEvent = yield + if event.type_and_code in BLACKLISTED_EVENTS: + continue + + if not event.is_pressed(): + try: + active.remove(event.input_match_hash) + except KeyError: + # we haven't seen this before probably a key got released which + # was pressed before we started recording. ignore it. + continue + + if not active: + # all previously recorded events are released + return + continue + + active.add(event.input_match_hash) + accu_input_hashes = [e.input_match_hash for e in accumulator] + if event.input_match_hash in accu_input_hashes and event not in accumulator: + # the value has changed but the event is already in the accumulator + # update the event + i = accu_input_hashes.index(event.input_match_hash) + accumulator[i] = event + self.message_broker.publish( + CombinationRecorded( + InputCombination(map(self._input_event_to_config, accumulator)) + ) + ) + + if event not in accumulator: + accumulator.append(event) + self.message_broker.publish( + CombinationRecorded( + InputCombination(map(self._input_event_to_config, accumulator)) + ) + ) + + def set_group(self, group: Optional[_Group]): + """Set the group for which input events should be read later.""" + # TODO load the active_group from the controller instead? + self.group = group + + def terminate(self): + """Stop reading keycodes for good.""" + self._send_command(CMD_TERMINATE) + + self.stop_recorder() + + if self._read_timeout is not None: + GLib.source_remove(self._read_timeout) + self._read_timeout = None + + while self._results_pipe.poll(): + self._results_pipe.recv() + + def refresh_groups(self): + """Ask the ReaderService for new device groups.""" + self._send_command(CMD_REFRESH_GROUPS) + + def publish_groups(self): + """Announce all known groups.""" + groups: Dict[str, List[str]] = { + group.key: group.types or [] for group in self.groups.get_groups() + } + self.message_broker.publish(GroupsData(groups)) + + def _update_groups(self, dump: str): + if dump != self.groups.dumps(): + self.groups.loads(dump) + logger.debug("Received %d devices", len(self.groups.get_groups())) + self._groups_updated = True + + # send this even if the groups did not change, as the user expects the ui + # to respond in some form + self.publish_groups() diff --git a/inputremapper/gui/reader_service.py b/inputremapper/gui/reader_service.py new file mode 100644 index 0000000..4e2c87a --- /dev/null +++ b/inputremapper/gui/reader_service.py @@ -0,0 +1,420 @@ +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2023 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + +"""Process that sends stuff to the GUI. + +It should be started via input-remapper-control and pkexec. + +GUIs should not run as root +https://wiki.archlinux.org/index.php/Running_GUI_applications_as_root + +The service shouldn't do that even though it has root rights, because that +would enable key-loggers to just ask input-remapper for all user-input. + +Instead, the ReaderService is used, which will be stopped when the gui closes. + +Whereas for the reader-service to start a password is needed and it stops whe +the ui closes. + +This uses the backend injection.event_reader and mapping_handlers to process all the +different input-events into simple on/off events and sends them to the gui. +""" + +from __future__ import annotations + +import asyncio +import logging +import multiprocessing +import os +import subprocess +import sys +import time +from collections import defaultdict +from typing import Set, List, Tuple + +import evdev +from evdev.ecodes import EV_KEY, EV_ABS, EV_REL, REL_HWHEEL, REL_WHEEL + +from inputremapper.configs.input_config import InputCombination, InputConfig +from inputremapper.configs.mapping import Mapping, KnownUinput +from inputremapper.groups import _Groups, _Group +from inputremapper.injection.event_reader import EventReader +from inputremapper.injection.global_uinputs import GlobalUInputs +from inputremapper.injection.mapping_handlers.abs_to_btn_handler import AbsToBtnHandler +from inputremapper.injection.mapping_handlers.mapping_handler import ( + NotifyCallback, + MappingHandler, +) +from inputremapper.injection.mapping_handlers.rel_to_btn_handler import RelToBtnHandler +from inputremapper.input_event import InputEvent +from inputremapper.ipc.pipe import Pipe +from inputremapper.logging.logger import logger +from inputremapper.user import UserUtils +from inputremapper.utils import get_device_hash +from inputremapper.gui.forward_to_ui_handler import ForwardToUIHandler + +# received by the reader-service +CMD_TERMINATE = "terminate" +CMD_STOP_READING = "stop-reading" +CMD_REFRESH_GROUPS = "refresh_groups" + +# sent by the reader-service to the reader +MSG_GROUPS = "groups" +MSG_EVENT = "event" +MSG_STATUS = "status" + +RELEASE_TIMEOUT = 0.3 + + +class ReaderService: + """Service that only reads events and is supposed to run as root. + + Sends device information and keycodes to the GUI. + + Commands are either numbers for generic commands, + or strings to start listening on a specific device. + """ + + # the speed threshold at which relative axis are considered moving + # and will be sent as "pressed" to the frontend. + # We want to allow some mouse movement before we record it as an input + rel_xy_speed = defaultdict(lambda: 3) + # wheel events usually don't produce values higher than 1 + rel_xy_speed[REL_WHEEL] = 1 + rel_xy_speed[REL_HWHEEL] = 1 + + # Polkit won't ask for another password if the pid stays the same or something, and + # if the previous request was no more than 5 minutes ago. see + # https://unix.stackexchange.com/a/458260. + # If the user does something after 6 minutes they will get a prompt already if the + # reader timed out already, which sounds annoying. Instead, I'd rather have the + # password prompt appear at most every 15 minutes. + _maximum_lifetime: int = 60 * 15 + _timeout_tolerance: int = 60 + + def __init__(self, groups: _Groups, global_uinputs: GlobalUInputs) -> None: + """Construct the reader-service and initialize its communication pipes.""" + self._start_time = time.time() + self.groups = groups + self.global_uinputs = global_uinputs + self._results_pipe = Pipe(self.get_pipe_paths()[0]) + self._commands_pipe = Pipe(self.get_pipe_paths()[1]) + self._pipe = multiprocessing.Pipe() + + self._tasks: Set[asyncio.Task] = set() + self._stop_event = asyncio.Event() + + self._results_pipe.send({"type": MSG_STATUS, "message": "ready"}) + + @staticmethod + def get_pipe_paths() -> Tuple[str, str]: + """Get the path where the pipe can be found.""" + return ( + f"/tmp/input-remapper-{UserUtils.home}/reader-results", + f"/tmp/input-remapper-{UserUtils.home}/reader-commands", + ) + + @staticmethod + def pipes_exist() -> bool: + # Just checking for one of the 4 files (results, commands both read and write) + # should be enough I guess. + path = f"{ReaderService.get_pipe_paths()[0]}r" + # Use os.path.exists, not lexists or islink, because broken links are bad. + # New pipes and symlinks need to be made. + return os.path.exists(path) + + @staticmethod + def is_running() -> bool: + """Check if the reader-service is running.""" + try: + subprocess.check_output(["pgrep", "-f", "input-remapper-reader-service"]) + except subprocess.CalledProcessError: + return False + return True + + @staticmethod + def pkexec_reader_service() -> None: + """Start reader-service via pkexec to run in the background.""" + debug = " -d" if logger.level <= logging.DEBUG else "" + cmd = f"pkexec input-remapper-control --command start-reader-service{debug}" + + logger.debug("Running `%s`", cmd) + exit_code = os.system(cmd) + + if exit_code != 0: + raise Exception(f"Failed to pkexec the reader-service, code {exit_code}") + + async def run(self) -> None: + """Start doing stuff.""" + # the reader will check for new commands later, once it is running + # it keeps running for one device or another. + logger.debug("Discovering initial groups") + self.groups.refresh() + self._send_groups() + await asyncio.gather( + self._read_commands(), + self._timeout(), + self._stop_if_pipes_broken(), + ) + + def _send_groups(self) -> None: + """Send the groups to the gui.""" + logger.debug("Sending groups") + self._results_pipe.send({"type": MSG_GROUPS, "message": self.groups.dumps()}) + + async def _timeout(self) -> None: + """Stop automatically after some time.""" + # Prevents a permanent hole for key-loggers to exist, in case the gui crashes. + # If the ReaderService stops even though the gui needs it, it needs to restart + # it. This makes it also more comfortable to have debug mode running during + # development, because it won't keep writing inputs containing passwords and + # such to the terminal forever. + + await asyncio.sleep(self._maximum_lifetime) + + # if it is currently reading, wait a bit longer for the gui to complete + # what it is doing. + if self._is_reading(): + logger.debug("Waiting a bit longer for the gui to finish reading") + + for _ in range(self._timeout_tolerance): + if not self._is_reading(): + # once reading completes, it should terminate right away + break + + await asyncio.sleep(1) + + logger.debug("Maximum life-span reached, terminating") + sys.exit(1) + + async def _read_commands(self) -> None: + """Handle all unread commands. + this will run until it receives CMD_TERMINATE + """ + logger.debug("Waiting for commands") + async for cmd in self._commands_pipe: + logger.debug('Received command "%s"', cmd) + + if cmd == CMD_TERMINATE: + await self._stop_reading() + logger.debug("Terminating") + sys.exit(0) + + if cmd == CMD_REFRESH_GROUPS: + self.groups.refresh() + self._send_groups() + continue + + if cmd == CMD_STOP_READING: + await self._stop_reading() + continue + + group = self.groups.find(key=cmd) + if group is None: + # this will block for a bit maybe we want to do this async? + self.groups.refresh() + group = self.groups.find(key=cmd) + + if group is not None: + await self._stop_reading() + self._start_reading(group) + continue + + logger.error('Received unknown command "%s"', cmd) + + async def _stop_if_pipes_broken(self) -> None: + # The GUI probably exited, and failed to tell the reader-service to stop. + # Pipes are owned by the GUI process, because the non-privileged GUI process + # needs to be able to read them. Therefore, they are gone. + while True: + await asyncio.sleep(1) + if not self.pipes_exist(): + await self._stop_reading() + logger.debug("Pipes broken, exiting") + sys.exit(13) + + def _is_reading(self) -> bool: + """Check if the ReaderService is currently sending events to the GUI.""" + return len(self._tasks) > 0 + + def _start_reading(self, group: _Group) -> None: + """Find all devices of that group, filter interesting ones and send the events + to the gui.""" + sources = [] + for path in group.paths: + try: + device = evdev.InputDevice(path) + except (FileNotFoundError, OSError): + logger.error('Could not find "%s"', path) + return None + + capabilities = device.capabilities(absinfo=False) + if ( + EV_KEY in capabilities + or EV_ABS in capabilities + or EV_REL in capabilities + ): + sources.append(device) + + context = self._create_event_pipeline(sources) + # create the event reader and start it + for device in sources: + reader = EventReader(context, device, self._stop_event) + self._tasks.add(asyncio.create_task(reader.run())) + + async def _stop_reading(self) -> None: + """Stop the running event_reader.""" + self._stop_event.set() + if self._tasks: + await asyncio.gather(*self._tasks) + self._tasks = set() + self._stop_event.clear() + + def _create_event_pipeline(self, sources: List[evdev.InputDevice]) -> ContextDummy: + """Create a custom event pipeline for each event code in the capabilities. + + Instead of sending the events to an uinput they will be sent to the frontend. + """ + context_dummy = ContextDummy() + # create a context for each source + for device in sources: + device_hash = get_device_hash(device) + capabilities = device.capabilities(absinfo=False) + + for ev_code in capabilities.get(EV_KEY) or (): + input_config = InputConfig( + type=EV_KEY, + code=ev_code, + origin_hash=device_hash, + ) + context_dummy.add_handler( + input_config, + ForwardToUIHandler(self._results_pipe), + ) + + for ev_code in capabilities.get(EV_ABS) or (): + # positive direction + input_config = InputConfig( + type=EV_ABS, + code=ev_code, + analog_threshold=30, + origin_hash=device_hash, + ) + mapping = Mapping( + input_combination=InputCombination([input_config]), + target_uinput=KnownUinput.KEYBOARD, + output_symbol="KEY_A", + ) + handler: MappingHandler = AbsToBtnHandler( + InputCombination([input_config]), + mapping, + self.global_uinputs, + ) + handler.set_sub_handler(ForwardToUIHandler(self._results_pipe)) + context_dummy.add_handler(input_config, handler) + + # negative direction + input_config = input_config.modify(analog_threshold=-30) + mapping = Mapping( + input_combination=InputCombination([input_config]), + target_uinput=KnownUinput.KEYBOARD, + output_symbol="KEY_A", + ) + handler = AbsToBtnHandler( + InputCombination([input_config]), + mapping, + self.global_uinputs, + ) + handler.set_sub_handler(ForwardToUIHandler(self._results_pipe)) + context_dummy.add_handler(input_config, handler) + + for ev_code in capabilities.get(EV_REL) or (): + # positive direction + input_config = InputConfig( + type=EV_REL, + code=ev_code, + analog_threshold=self.rel_xy_speed[ev_code], + origin_hash=device_hash, + ) + mapping = Mapping( + input_combination=InputCombination([input_config]), + target_uinput=KnownUinput.KEYBOARD, + output_symbol="KEY_A", + release_timeout=RELEASE_TIMEOUT, + force_release_timeout=True, + ) + handler = RelToBtnHandler( + InputCombination([input_config]), + mapping, + self.global_uinputs, + ) + handler.set_sub_handler(ForwardToUIHandler(self._results_pipe)) + context_dummy.add_handler(input_config, handler) + + # negative direction + input_config = input_config.modify( + analog_threshold=-self.rel_xy_speed[ev_code] + ) + mapping = Mapping( + input_combination=InputCombination([input_config]), + target_uinput=KnownUinput.KEYBOARD, + output_symbol="KEY_A", + release_timeout=RELEASE_TIMEOUT, + force_release_timeout=True, + ) + handler = RelToBtnHandler( + InputCombination([input_config]), + mapping, + self.global_uinputs, + ) + handler.set_sub_handler(ForwardToUIHandler(self._results_pipe)) + context_dummy.add_handler(input_config, handler) + + return context_dummy + + +class ForwardDummy(evdev.UInput): + # You may add more attributes of evdev.UInput here for compatibility + name: str = "forward-dummy" + + @staticmethod + def write(*_): + pass + + +class ContextDummy: + """Used for the reader so that no events are actually written to any uinput.""" + + def __init__(self): + self.listeners = set() + self._notify_callbacks = defaultdict(list) + self.forward_dummy = ForwardDummy() + + def add_handler(self, input_config: InputConfig, handler: MappingHandler): + self._notify_callbacks[input_config.input_match_hash].append(handler.notify) + + def get_notify_callbacks(self, input_event: InputEvent) -> List[NotifyCallback]: + return self._notify_callbacks[input_event.input_match_hash] + + def reset(self): + pass + + def get_forward_uinput(self, origin_hash) -> evdev.UInput: + """Don't actually write anything.""" + return self.forward_dummy diff --git a/inputremapper/gui/user_interface.py b/inputremapper/gui/user_interface.py new file mode 100644 index 0000000..09043de --- /dev/null +++ b/inputremapper/gui/user_interface.py @@ -0,0 +1,417 @@ +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + + +"""User Interface.""" +from typing import Dict, Callable + +from gi.repository import Gtk, GtkSource, Gdk, GObject + +from inputremapper.configs.data import get_data_path +from inputremapper.configs.input_config import InputCombination +from inputremapper.configs.mapping import MappingData +from inputremapper.gui.autocompletion import Autocompletion +from inputremapper.gui.components.common import Breadcrumbs +from inputremapper.gui.components.device_groups import DeviceGroupSelection +from inputremapper.gui.components.editor import ( + MappingListBox, + TargetSelection, + CodeEditor, + RecordingToggle, + RecordingStatus, + AutoloadSwitch, + ReleaseCombinationSwitch, + CombinationListbox, + AnalogInputSwitch, + TriggerThresholdInput, + OutputAxisSelector, + ReleaseTimeoutInput, + TransformationDrawArea, + Sliders, + RelativeInputCutoffInput, + KeyAxisStackSwitcher, + RequireActiveMapping, + GdkEventRecorder, +) +from inputremapper.gui.components.main import Stack, StatusBar +from inputremapper.gui.components.presets import PresetSelection +from inputremapper.gui.controller import Controller +from inputremapper.gui.gettext import _ +from inputremapper.gui.messages.message_broker import ( + MessageBroker, + MessageType, +) +from inputremapper.gui.messages.message_data import UserConfirmRequest +from inputremapper.gui.utils import ( + gtk_iteration, +) +from inputremapper.injection.injector import InjectorStateMessage +from inputremapper.logging.logger import logger, COMMIT_HASH, VERSION, EVDEV_VERSION + +# https://cjenkins.wordpress.com/2012/05/08/use-gtksourceview-widget-in-glade/ +GObject.type_register(GtkSource.View) +# GtkSource.View() also works: +# https://stackoverflow.com/questions/60126579/gtk-builder-error-quark-invalid-object-type-webkitwebview + + +def on_close_about(about, _): + """Hide the about dialog without destroying it.""" + about.hide() + return True + + +class UserInterface: + """The input-remapper gtk window.""" + + def __init__( + self, + message_broker: MessageBroker, + controller: Controller, + ): + self.message_broker = message_broker + self.controller = controller + + # all shortcuts executed when ctrl+... + self.shortcuts: Dict[int, Callable] = { + Gdk.KEY_q: self.controller.close, + Gdk.KEY_r: self.controller.refresh_groups, + Gdk.KEY_Delete: self.controller.stop_injecting, + Gdk.KEY_n: self.controller.add_preset, + } + + # stores the ids for all the listeners attached to the gui + self.gtk_listeners: Dict[Callable, int] = {} + + self.message_broker.subscribe(MessageType.terminate, lambda _: self.close()) + + self.builder = Gtk.Builder() + self._build_ui() + self.window: Gtk.Window = self.get("window") + self.about: Gtk.Window = self.get("about-dialog") + self.combination_editor: Gtk.Dialog = self.get("combination-editor") + + self._create_dialogs() + self._create_components() + self._connect_gtk_signals() + self._connect_message_listener() + + self.window.show() + # hide everything until stuff is populated + self.get("vertical-wrapper").set_opacity(0) + # if any of the next steps take a bit to complete, have the window + # already visible (without content) to make it look more responsive. + gtk_iteration() + + # now show the proper finished content of the window + self.get("vertical-wrapper").set_opacity(1) + + def _build_ui(self): + """Build the window from stylesheet and gladefile.""" + css_provider = Gtk.CssProvider() + + with open(get_data_path("style.css"), "r") as file: + css_provider.load_from_data(bytes(file.read(), encoding="UTF-8")) + + Gtk.StyleContext.add_provider_for_screen( + Gdk.Screen.get_default(), + css_provider, + Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION, + ) + + gladefile = get_data_path("input-remapper.glade") + self.builder.add_from_file(gladefile) + self.builder.connect_signals(self) + + def _create_components(self): + """Setup all objects which manage individual components of the ui.""" + message_broker = self.message_broker + controller = self.controller + DeviceGroupSelection(message_broker, controller, self.get("device_selection")) + PresetSelection(message_broker, controller, self.get("preset_selection")) + MappingListBox(message_broker, controller, self.get("selection_label_listbox")) + TargetSelection(message_broker, controller, self.get("target-selector")) + + Breadcrumbs( + message_broker, + self.get("selected_device_name"), + show_device_group=True, + ) + Breadcrumbs( + message_broker, + self.get("selected_preset_name"), + show_device_group=True, + show_preset=True, + ) + + Stack(message_broker, controller, self.get("main_stack")) + RecordingToggle(message_broker, controller, self.get("key_recording_toggle")) + StatusBar( + message_broker, + controller, + self.get("status_bar"), + self.get("error_status_icon"), + self.get("warning_status_icon"), + ) + RecordingStatus(message_broker, self.get("recording_status")) + AutoloadSwitch(message_broker, controller, self.get("preset_autoload_switch")) + ReleaseCombinationSwitch( + message_broker, controller, self.get("release-combination-switch") + ) + CombinationListbox(message_broker, controller, self.get("combination-listbox")) + AnalogInputSwitch(message_broker, controller, self.get("analog-input-switch")) + TriggerThresholdInput( + message_broker, controller, self.get("trigger-threshold-spin-btn") + ) + RelativeInputCutoffInput( + message_broker, controller, self.get("input-cutoff-spin-btn") + ) + OutputAxisSelector(message_broker, controller, self.get("output-axis-selector")) + KeyAxisStackSwitcher( + message_broker, + controller, + self.get("editor-stack"), + self.get("key_macro_toggle_btn"), + self.get("analog_toggle_btn"), + ) + ReleaseTimeoutInput( + message_broker, controller, self.get("release-timeout-spin-button") + ) + TransformationDrawArea( + message_broker, controller, self.get("transformation-draw-area") + ) + Sliders( + message_broker, + controller, + self.get("gain-scale"), + self.get("deadzone-scale"), + self.get("expo-scale"), + ) + + GdkEventRecorder(self.window, self.get("gdk-event-recorder-label")) + + RequireActiveMapping( + message_broker, + self.get("edit-combination-btn"), + require_recorded_input=True, + ) + RequireActiveMapping( + message_broker, + self.get("output"), + require_recorded_input=True, + ) + RequireActiveMapping( + message_broker, + self.get("delete-mapping"), + require_recorded_input=False, + ) + + # code editor and autocompletion + code_editor = CodeEditor(message_broker, controller, self.get("code_editor")) + autocompletion = Autocompletion(message_broker, controller, code_editor) + autocompletion.set_relative_to(self.get("code_editor_container")) + self.autocompletion = autocompletion # only for testing + + def _create_dialogs(self): + """Setup different dialogs, such as the about page.""" + self.about.connect("delete-event", on_close_about) + # set_position needs to be done once initially, otherwise the + # dialog is not centered when it is opened for the first time + self.about.set_position(Gtk.WindowPosition.CENTER_ON_PARENT) + self.get("version-label").set_text( + f"input-remapper {VERSION} {COMMIT_HASH[:7]}" + f"\npython-evdev {EVDEV_VERSION}" + if EVDEV_VERSION + else "" + ) + + def _connect_gtk_signals(self): + self.get("delete_preset").connect( + "clicked", lambda *_: self.controller.delete_preset() + ) + self.get("copy_preset").connect( + "clicked", lambda *_: self.controller.copy_preset() + ) + self.get("create_preset").connect( + "clicked", lambda *_: self.controller.add_preset() + ) + self.get("apply_preset").connect( + "clicked", lambda *_: self.controller.start_injecting() + ) + self.get("stop_injection_preset_page").connect( + "clicked", lambda *_: self.controller.stop_injecting() + ) + self.get("stop_injection_editor_page").connect( + "clicked", lambda *_: self.controller.stop_injecting() + ) + self.get("rename-button").connect("clicked", self.on_gtk_rename_clicked) + self.get("preset_name_input").connect( + "key-release-event", self.on_gtk_preset_name_input_return + ) + self.get("create_mapping_button").connect( + "clicked", lambda *_: self.controller.create_mapping() + ) + self.get("delete-mapping").connect( + "clicked", lambda *_: self.controller.delete_mapping() + ) + self.combination_editor.connect( + # it only takes self as argument, but delete-events provides more + # probably a gtk bug + "delete-event", + lambda dialog, *_: Gtk.Widget.hide_on_delete(dialog), + ) + self.get("edit-combination-btn").connect( + "clicked", lambda *_: self.combination_editor.show() + ) + self.get("remove-event-btn").connect( + "clicked", lambda *_: self.controller.remove_event() + ) + self.connect_shortcuts() + + def _connect_message_listener(self): + self.message_broker.subscribe( + MessageType.mapping, self.update_combination_label + ) + self.message_broker.subscribe( + MessageType.injector_state, self.on_injector_state_msg + ) + self.message_broker.subscribe( + MessageType.user_confirm_request, self._on_user_confirm_request + ) + + def _create_dialog(self, primary: str, secondary: str) -> Gtk.MessageDialog: + """Create a message dialog with cancel and confirm buttons.""" + message_dialog = Gtk.MessageDialog( + self.window, + Gtk.DialogFlags.MODAL | Gtk.DialogFlags.DESTROY_WITH_PARENT, + Gtk.MessageType.QUESTION, + Gtk.ButtonsType.NONE, + primary, + ) + + if secondary: + message_dialog.format_secondary_text(secondary) + + message_dialog.add_button("Cancel", Gtk.ResponseType.CANCEL) + + confirm_button = message_dialog.add_button("Confirm", Gtk.ResponseType.ACCEPT) + confirm_button.get_style_context().add_class(Gtk.STYLE_CLASS_DESTRUCTIVE_ACTION) + + return message_dialog + + def _on_user_confirm_request(self, msg: UserConfirmRequest): + # if the message contains a line-break, use the first chunk for the primary + # message, and the rest for the secondary message. + chunks = msg.msg.split("\n") + primary = chunks[0] + secondary = " ".join(chunks[1:]) + + message_dialog = self._create_dialog(primary, secondary) + + response = message_dialog.run() + msg.respond(response == Gtk.ResponseType.ACCEPT) + + message_dialog.hide() + + def on_injector_state_msg(self, msg: InjectorStateMessage): + """Update the ui to reflect the status of the injector.""" + stop_injection_preset_page: Gtk.Button = self.get("stop_injection_preset_page") + stop_injection_editor_page: Gtk.Button = self.get("stop_injection_editor_page") + recording_toggle: Gtk.ToggleButton = self.get("key_recording_toggle") + + if msg.active(): + stop_injection_preset_page.set_opacity(1) + stop_injection_editor_page.set_opacity(1) + stop_injection_preset_page.set_sensitive(True) + stop_injection_editor_page.set_sensitive(True) + recording_toggle.set_opacity(0.5) + else: + stop_injection_preset_page.set_opacity(0.5) + stop_injection_editor_page.set_opacity(0.5) + stop_injection_preset_page.set_sensitive(True) + stop_injection_editor_page.set_sensitive(True) + recording_toggle.set_opacity(1) + + def disconnect_shortcuts(self): + """Stop listening for shortcuts. + + e.g. when recording key combinations + """ + try: + self.window.disconnect(self.gtk_listeners.pop(self.on_gtk_shortcut)) + except KeyError: + logger.debug("key listeners seem to be not connected") + + def connect_shortcuts(self): + """Start listening for shortcuts.""" + if not self.gtk_listeners.get(self.on_gtk_shortcut): + self.gtk_listeners[self.on_gtk_shortcut] = self.window.connect( + "key-press-event", self.on_gtk_shortcut + ) + + def get(self, name: str): + """Get a widget from the window.""" + return self.builder.get_object(name) + + def close(self): + """Close the window.""" + logger.debug("Closing window") + self.window.hide() + + def update_combination_label(self, mapping: MappingData): + """Listens for mapping and updates the combination label.""" + label: Gtk.Label = self.get("combination-label") + if mapping.input_combination.beautify() == label.get_label(): + return + if mapping.input_combination == InputCombination.empty_combination(): + label.set_opacity(0.5) + label.set_label(_("no input configured")) + return + + label.set_opacity(1) + label.set_label(mapping.input_combination.beautify()) + + def on_gtk_shortcut(self, _, event: Gdk.EventKey): + """Execute shortcuts.""" + if event.state & Gdk.ModifierType.CONTROL_MASK: + try: + self.shortcuts[event.keyval]() + except KeyError: + pass + + def on_gtk_close(self, *_): + self.controller.close() + + def on_gtk_about_clicked(self, _): + """Show the about/help dialog.""" + self.about.show() + + def on_gtk_about_key_press(self, _, event): + """Hide the about/help dialog.""" + gdk_keycode = event.get_keyval()[1] + if gdk_keycode == Gdk.KEY_Escape: + self.about.hide() + + def on_gtk_rename_clicked(self, *_): + name = self.get("preset_name_input").get_text() + self.controller.rename_preset(name) + self.get("preset_name_input").set_text("") + + def on_gtk_preset_name_input_return(self, _, event: Gdk.EventKey): + if event.keyval == Gdk.KEY_Return: + self.on_gtk_rename_clicked() diff --git a/inputremapper/gui/utils.py b/inputremapper/gui/utils.py new file mode 100644 index 0000000..d84e3cb --- /dev/null +++ b/inputremapper/gui/utils.py @@ -0,0 +1,272 @@ +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + +from __future__ import annotations + +import time +from dataclasses import dataclass +from typing import List, Callable, Dict, Optional + +from gi.repository import Gtk, GLib, Gdk + +from inputremapper.logging.logger import logger + +# status ctx ids + +CTX_SAVE = 0 +CTX_APPLY = 1 +CTX_KEYCODE = 2 +CTX_ERROR = 3 +CTX_WARNING = 4 +CTX_MAPPING = 5 + + +@dataclass() +class DebounceInfo: + # constant after register: + function: Optional[Callable] + other: object + key: int + + # can change when called again: + args: list + kwargs: dict + glib_timeout: Optional[int] + + +class DebounceManager: + """Stops all debounced functions if needed.""" + + debounce_infos: Dict[int, DebounceInfo] = {} + + def _register(self, other, function): + debounce_info = DebounceInfo( + function=function, + glib_timeout=None, + other=other, + args=[], + kwargs={}, + key=self._get_key(other, function), + ) + key = self._get_key(other, function) + self.debounce_infos[key] = debounce_info + return debounce_info + + def get(self, other: object, function: Callable) -> Optional[DebounceInfo]: + """Find the debounce_info that matches the given callable.""" + key = self._get_key(other, function) + return self.debounce_infos.get(key) + + def _get_key(self, other, function): + return f"{id(other)},{function.__name__}" + + def debounce(self, other, function, timeout_ms, *args, **kwargs): + """Call this function with the given args later.""" + debounce_info = self.get(other, function) + if debounce_info is None: + debounce_info = self._register(other, function) + + debounce_info.args = args + debounce_info.kwargs = kwargs + + glib_timeout = debounce_info.glib_timeout + if glib_timeout is not None: + GLib.source_remove(glib_timeout) + + def run(): + self.stop(other, function) + return function(other, *args, **kwargs) + + debounce_info.glib_timeout = GLib.timeout_add( + timeout_ms, + lambda: run(), + ) + + def stop(self, other: object, function: Callable): + """Stop the current debounce timeout of this function and don't call it. + + New calls to that function will be debounced again. + """ + debounce_info = self.get(other, function) + if debounce_info is None: + logger.debug("Tried to stop function that is not currently scheduled") + return + + if debounce_info.glib_timeout is not None: + GLib.source_remove(debounce_info.glib_timeout) + debounce_info.glib_timeout = None + + def stop_all(self): + """No debounced function should be called anymore after this. + + New calls to that function will be debounced again. + """ + for debounce_info in self.debounce_infos.values(): + self.stop(debounce_info.other, debounce_info.function) + + def run_all_now(self): + """Don't wait any longer.""" + for debounce_info in self.debounce_infos.values(): + if debounce_info.glib_timeout is None: + # nothing is currently waiting for this function to be called + continue + + self.stop(debounce_info.other, debounce_info.function) + try: + logger.warning( + 'Running "%s" now without waiting', + debounce_info.function.__name__, + ) + debounce_info.function( + debounce_info.other, + *debounce_info.args, + **debounce_info.kwargs, + ) + except Exception as exception: + # if individual functions fails, continue calling the others. + # also, don't raise this because there is nowhere this exception + # could be caught in a useful way + logger.error(exception) + + +def debounce(timeout): + """Debounce a method call to improve performance. + + Calling this with a millisecond value creates the decorator, so use something like + + @debounce(50) + def function(self): + ... + + In tests, run_all_now can be used to avoid waiting to speed them up. + """ + # the outside `debounce` function is needed to obtain the millisecond value + + def decorator(function): + # the regular decorator. + # @decorator + # def foo(): + # ... + def wrapped(self, *args, **kwargs): + # this is the function that will actually be called + debounce_manager.debounce(self, function, timeout, *args, **kwargs) + + wrapped.__name__ = function.__name__ + + return wrapped + + return decorator + + +debounce_manager = DebounceManager() + + +class HandlerDisabled: + """Safely modify a widget without causing handlers to be called. + + Use in a `with` statement. + """ + + def __init__(self, widget: Gtk.Widget, handler: Callable): + self.widget = widget + self.handler = handler + + def __enter__(self): + try: + self.widget.handler_block_by_func(self.handler) + except TypeError as error: + # if nothing is connected to the given signal, it is not critical + # at all + logger.warning('HandlerDisabled entry failed: "%s"', error) + + def __exit__(self, *_): + try: + self.widget.handler_unblock_by_func(self.handler) + except TypeError as error: + logger.warning('HandlerDisabled exit failed: "%s"', error) + + +def gtk_iteration(iterations=0): + """Iterate while events are pending.""" + while Gtk.events_pending(): + Gtk.main_iteration() + for _ in range(iterations): + time.sleep(0.002) + while Gtk.events_pending(): + Gtk.main_iteration() + + +class Colors: + """Looks up colors from the GTK theme. + + Defaults to libadwaita-light theme colors if the lookup fails. + """ + + fallback_accent = Gdk.RGBA(0.21, 0.52, 0.89, 1) + fallback_background = Gdk.RGBA(0.98, 0.98, 0.98, 1) + fallback_base = Gdk.RGBA(1, 1, 1, 1) + fallback_border = Gdk.RGBA(0.87, 0.87, 0.87, 1) + fallback_font = Gdk.RGBA(0.20, 0.20, 0.20, 1) + + @staticmethod + def get_color(names: List[str], fallback: Gdk.RGBA) -> Gdk.RGBA: + """Get theme colors. Provide multiple names for fallback purposes.""" + for name in names: + found, color = Gtk.StyleContext().lookup_color(name) + if found: + return color + + return fallback + + @staticmethod + def get_accent_color() -> Gdk.RGBA: + """Look up the accent color from the current theme.""" + return Colors.get_color( + ["accent_bg_color", "theme_selected_bg_color"], + Colors.fallback_accent, + ) + + @staticmethod + def get_background_color() -> Gdk.RGBA: + """Look up the background-color from the current theme.""" + return Colors.get_color( + ["theme_bg_color"], + Colors.fallback_background, + ) + + @staticmethod + def get_base_color() -> Gdk.RGBA: + """Look up the base-color from the current theme.""" + return Colors.get_color( + ["theme_base_color"], + Colors.fallback_base, + ) + + @staticmethod + def get_border_color() -> Gdk.RGBA: + """Look up the border from the current theme.""" + return Colors.get_color(["borders"], Colors.fallback_border) + + @staticmethod + def get_font_color() -> Gdk.RGBA: + """Look up the border from the current theme.""" + return Colors.get_color( + ["theme_fg_color"], + Colors.fallback_font, + ) diff --git a/inputremapper/injection/__init__.py b/inputremapper/injection/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/inputremapper/injection/context.py b/inputremapper/injection/context.py new file mode 100644 index 0000000..ea40e02 --- /dev/null +++ b/inputremapper/injection/context.py @@ -0,0 +1,132 @@ +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + + +"""Stores injection-process wide information.""" + +from __future__ import annotations + +from collections import defaultdict +from typing import List, Dict, Set, Hashable + +import evdev + +from inputremapper.configs.preset import Preset +from inputremapper.injection.mapping_handlers.mapping_handler import ( + EventListener, + NotifyCallback, +) +from inputremapper.injection.mapping_handlers.mapping_parser import ( + MappingParser, + EventPipelines, +) +from inputremapper.input_event import InputEvent +from inputremapper.logging.logger import logger +from inputremapper.utils import DeviceHash + + +class Context: + """Stores injection-process wide information. + + In some ways this is a wrapper for the preset that derives some + information that is specifically important to the injection. + + The information in the context does not change during the injection. + + One Context exists for each injection process, which is shared + with all coroutines and used objects. + + Benefits of the context: + - less redundant passing around of parameters + - easier to add new process wide information without having to adjust + all function calls in unittests + - makes the injection class shorter and more specific to a certain task, + which is actually spinning up the injection. + + Note, that for the reader_service a ContextDummy is used. + + Members + ------- + preset : Preset + The preset holds all Mappings for the injection process + listeners : Set[EventListener] + A set of callbacks which receive all events + callbacks : Dict[Tuple[int, int], List[NotifyCallback]] + All entry points to the event pipeline sorted by InputEvent.type_and_code + """ + + listeners: Set[EventListener] + _notify_callbacks: Dict[Hashable, List[NotifyCallback]] + _handlers: EventPipelines + _forward_devices: Dict[DeviceHash, evdev.UInput] + _source_devices: Dict[DeviceHash, evdev.InputDevice] + + def __init__( + self, + preset: Preset, + source_devices: Dict[DeviceHash, evdev.InputDevice], + forward_devices: Dict[DeviceHash, evdev.UInput], + mapping_parser: MappingParser, + ) -> None: + if len(forward_devices) == 0: + logger.warning("forward_devices not set") + + if len(source_devices) == 0: + logger.warning("source_devices not set") + + self.listeners = set() + self._source_devices = source_devices + self._forward_devices = forward_devices + self._notify_callbacks = defaultdict(list) + self._handlers = mapping_parser.parse_mappings(preset, self) + + self._create_callbacks() + + def reset(self) -> None: + """Call the reset method for each handler in the context.""" + for handlers in self._handlers.values(): + for handler in handlers: + handler.reset() + + def _create_callbacks(self) -> None: + """Add the notify method from all _handlers to self.callbacks.""" + for input_config, handler_list in self._handlers.items(): + input_match_hash = input_config.input_match_hash + logger.debug("Adding NotifyCallback for %s", input_match_hash) + self._notify_callbacks[input_match_hash].extend( + handler.notify for handler in handler_list + ) + + def get_notify_callbacks(self, input_event: InputEvent) -> List[NotifyCallback]: + input_match_hash = input_event.input_match_hash + return self._notify_callbacks[input_match_hash] + + def get_forward_uinput(self, origin_hash: DeviceHash) -> evdev.UInput: + """Get the "forward" uinput events from the given origin should go into.""" + return self._forward_devices[origin_hash] + + def get_source(self, key: DeviceHash) -> evdev.InputDevice: + return self._source_devices[key] + + def get_leds(self) -> Set[int]: + """Get a set of LED_* ecodes that are currently on.""" + leds = set() + for device in self._source_devices.values(): + leds.update(device.leds()) + return leds diff --git a/inputremapper/injection/event_reader.py b/inputremapper/injection/event_reader.py new file mode 100644 index 0000000..56ec821 --- /dev/null +++ b/inputremapper/injection/event_reader.py @@ -0,0 +1,205 @@ +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + + +"""Because multiple calls to async_read_loop won't work.""" + +import asyncio +import os +import traceback +from typing import AsyncIterator, Protocol, Set, List + +import evdev + +from inputremapper.injection.mapping_handlers.mapping_handler import ( + EventListener, + NotifyCallback, +) +from inputremapper.input_event import InputEvent +from inputremapper.logging.logger import logger +from inputremapper.utils import get_device_hash, DeviceHash + + +class Context(Protocol): + listeners: Set[EventListener] + + def reset(self): ... + + def get_notify_callbacks(self, input_event: InputEvent) -> List[NotifyCallback]: ... + + def get_forward_uinput(self, origin_hash: DeviceHash) -> evdev.UInput: ... + + +class EventReader: + """Reads input events from a single device and distributes them. + + There is one EventReader object for each source, which tells multiple + mapping_handlers that a new event is ready so that they can inject all sorts of + funny things. + + Other devnodes may be present for the hardware device, in which case this + needs to be created multiple times. + """ + + def __init__( + self, + context: Context, + source: evdev.InputDevice, + stop_event: asyncio.Event, + ) -> None: + """Initialize all mapping_handlers + + Parameters + ---------- + source + where to read keycodes from + """ + self._device_hash = get_device_hash(source) + self._source = source + self.context = context + self.stop_event = stop_event + + def stop(self): + """Stop the reader.""" + self.stop_event.set() + + async def read_loop(self) -> AsyncIterator[evdev.InputEvent]: + stop_task = asyncio.Task(self.stop_event.wait()) + loop = asyncio.get_running_loop() + events_ready = asyncio.Event() + loop.add_reader(self._source.fileno(), events_ready.set) + + while True: + _, pending = await asyncio.wait( + {stop_task, asyncio.Task(events_ready.wait())}, + return_when=asyncio.FIRST_COMPLETED, + ) + + fd_broken = os.stat(self._source.fileno()).st_nlink == 0 + if fd_broken: + # happens when the device is unplugged while reading, causing 100% cpu + # usage because events_ready.set is called repeatedly forever, + # while read_loop will hang at self._source.read_one(). + logger.error("fd broke, was the device unplugged?") + + if stop_task.done() or fd_broken: + for task in pending: + task.cancel() + loop.remove_reader(self._source.fileno()) + logger.debug("read loop stopped") + return + + events_ready.clear() + while event := self._source.read_one(): + yield event + + def send_to_handlers(self, event: InputEvent) -> bool: + """Send the event to the NotifyCallbacks. + + Return if anyone took care of the event. + """ + if event.type == evdev.ecodes.EV_MSC: + return False + + if event.type == evdev.ecodes.EV_SYN: + return False + + handled = False + notify_callbacks = self.context.get_notify_callbacks(event) + + if notify_callbacks: + for notify_callback in notify_callbacks: + handled = notify_callback(event, source=self._source) | handled + + return handled + + async def send_to_listeners(self, event: InputEvent) -> None: + """Send the event to listeners.""" + if event.type == evdev.ecodes.EV_MSC: + return + + if event.type == evdev.ecodes.EV_SYN: + return + + for listener in self.context.listeners.copy(): + # use a copy, since the listeners might remove themselves from the set + + await listener(event) + + # Running macros have priority, give them a head-start for processing the + # event. If if_single injects a modifier, this modifier should be active + # before the next handler injects an "a" or something, so that it is + # possible to capitalize it via if_single. + # 1. Event from keyboard arrives (e.g. an "a") + # 2. the listener for if_single is called + # 3. if_single decides runs then (e.g. injects shift_L) + # 4. The original event is forwarded (or whatever it is supposed to do) + # 5. Capitalized "A" is injected. + # So make sure to call the listeners before notifying the handlers. + for _ in range(5): + await asyncio.sleep(0) + + def forward(self, event: InputEvent) -> None: + """Forward an event, which injects it unmodified.""" + forward_to = self.context.get_forward_uinput(self._device_hash) + logger.write(event, forward_to) + forward_to.write(*event.event_tuple) + + async def handle(self, event: InputEvent) -> None: + if event.type == evdev.ecodes.EV_KEY and event.value == 2: + # button-hold event. Environments (gnome, etc.) create them on + # their own for the injection-fake-device if the release event + # won't appear, no need to forward or map them. + return + + await self.send_to_listeners(event) + + handled = self.send_to_handlers(event) + + if not handled: + # no handler took care of it, forward it + self.forward(event) + + async def run(self): + """Start doing things. + + Can be stopped by stopping the asyncio loop or by setting the stop_event. + This loop reads events from a single device only. + """ + logger.debug( + "Starting to listen for events from %s, fd %s", + self._source.path, + self._source.fd, + ) + + async for event in self.read_loop(): + try: + # Fire and forget, so that handlers and listeners can take their time, + # if they want to wait for something special to happen. + asyncio.ensure_future( + self.handle( + InputEvent.from_event(event, origin_hash=self._device_hash) + ) + ) + except Exception as e: + logger.error("Handling event %s failed with %s", event, type(e)) + traceback.print_exception(e) + + self.context.reset() + logger.info("read loop for %s stopped", self._source.path) diff --git a/inputremapper/injection/global_uinputs.py b/inputremapper/injection/global_uinputs.py new file mode 100644 index 0000000..2849700 --- /dev/null +++ b/inputremapper/injection/global_uinputs.py @@ -0,0 +1,192 @@ +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + +from typing import Dict, Union, Tuple, Optional, List, Type + +import evdev + +import inputremapper.exceptions +import inputremapper.utils +from inputremapper.logging.logger import logger + +MIN_ABS = -(2**15) # -32768 +MAX_ABS = 2**15 # 32768 +DEV_NAME = "input-remapper" +DEFAULT_UINPUTS = { + # for event codes see linux/input-event-codes.h + "keyboard": { + evdev.ecodes.EV_KEY: list(evdev.ecodes.KEY.keys() & evdev.ecodes.keys.keys()) + }, + "gamepad": { + evdev.ecodes.EV_KEY: [*range(0x130, 0x13F)], # BTN_SOUTH - BTN_THUMBR + evdev.ecodes.EV_ABS: [ + *( + (i, evdev.AbsInfo(0, MIN_ABS, MAX_ABS, 0, 0, 0)) + for i in range(0x00, 0x06) + ), + *((i, evdev.AbsInfo(0, -1, 1, 0, 0, 0)) for i in range(0x10, 0x12)), + ], # 6-axis and 1 hat switch + }, + "mouse": { + evdev.ecodes.EV_KEY: [*range(0x110, 0x118)], # BTN_LEFT - BTN_TASK + evdev.ecodes.EV_REL: [*range(0x00, 0x0D)], # all REL axis + }, +} +DEFAULT_UINPUTS["keyboard + mouse"] = { + evdev.ecodes.EV_KEY: [ + *DEFAULT_UINPUTS["keyboard"][evdev.ecodes.EV_KEY], + *DEFAULT_UINPUTS["mouse"][evdev.ecodes.EV_KEY], + ], + evdev.ecodes.EV_REL: [ + *DEFAULT_UINPUTS["mouse"][evdev.ecodes.EV_REL], + ], +} + + +class UInput(evdev.UInput): + _capabilities_cache: Optional[Dict] = None + + def __init__(self, *args, **kwargs): + name = kwargs["name"] + logger.debug('creating UInput device: "%s"', name) + super().__init__(*args, **kwargs) + + def can_emit(self, event: Tuple[int, int, int]): + """Check if an event can be emitted by the UIinput. + + Wrong events might be injected if the group mappings are wrong, + """ + # this will never change, so we cache it since evdev runs an expensive loop to + # gather the capabilities. (can_emit is called regularly) + if self._capabilities_cache is None: + self._capabilities_cache = self.capabilities(absinfo=False) + + return event[1] in self._capabilities_cache.get(event[0], []) + + +class FrontendUInput: + """Uinput which can not actually send events, for use in the frontend.""" + + def __init__(self, *_, events=None, name="py-evdev-uinput", **__): + # see https://python-evdev.readthedocs.io/en/latest/apidoc.html#module-evdev.uinput # noqa pylint: disable=line-too-long + self.events = events + self.name = name + + logger.debug('creating fake UInput device: "%s"', self.name) + + def capabilities(self): + return self.events + + +class GlobalUInputs: + """Manages all UInputs that are shared between all injection processes.""" + + def __init__( + self, + uinput_factory: Union[Type[UInput], Type[FrontendUInput]], + ): + self.devices: Dict[str, Union[UInput, FrontendUInput]] = {} + self._uinput_factory = uinput_factory + + def __iter__(self): + return iter(uinput for _, uinput in self.devices.items()) + + @staticmethod + def can_default_uinput_emit(target: str, type_: int, code: int) -> bool: + """Check if the uinput with the target name is capable of the event.""" + capabilities = DEFAULT_UINPUTS.get(target, {}).get(type_) + return capabilities is not None and code in capabilities + + @staticmethod + def find_fitting_default_uinputs(type_: int, code: int) -> List[str]: + """Find the names of default uinputs that are able to emit this event.""" + return [ + uinput + for uinput in DEFAULT_UINPUTS + if code in DEFAULT_UINPUTS[uinput].get(type_, []) + ] + + def reset(self): + self.devices = {} + self.prepare_all() + + def prepare_all(self): + """Generate UInputs.""" + for name, events in DEFAULT_UINPUTS.items(): + if name in self.devices.keys(): + continue + + self.devices[name] = self._uinput_factory( + name=f"{DEV_NAME} {name}", + phys=DEV_NAME, + events=events, + ) + + def prepare_single(self, name: str): + """Generate a single uinput. + + This has to be done in the main process before injections that use it start. + """ + if name not in DEFAULT_UINPUTS: + raise KeyError("Could not find a matching uinput to generate.") + + if name in self.devices: + logger.debug('Target "%s" already exists', name) + return + + self.devices[name] = self._uinput_factory( + name=f"{DEV_NAME} {name}", + phys=DEV_NAME, + events=DEFAULT_UINPUTS[name], + ) + + def write(self, event: Tuple[int, int, int], target_uinput): + """Write event to target uinput.""" + uinput = self.get_uinput(target_uinput) + if not uinput: + raise inputremapper.exceptions.UinputNotAvailable(target_uinput) + + if not uinput.can_emit(event): + raise inputremapper.exceptions.EventNotHandled(event) + + # Was bool once due to a bug during development + assert not isinstance(event[2], bool) and isinstance(event[2], int) + + logger.write(event, uinput) + uinput.write(*event) + uinput.syn() + + def get_uinput(self, name: str) -> Optional[evdev.UInput]: + """UInput with name + + Or None if there is no uinput with this name. + + Parameters + ---------- + name + uniqe name of the uinput device + """ + if name not in self.devices: + logger.error( + f'UInput "{name}" is unknown. ' + + f"Available: {list(self.devices.keys())}" + ) + return None + + return self.devices.get(name) diff --git a/inputremapper/injection/injector.py b/inputremapper/injection/injector.py new file mode 100644 index 0000000..fe7b41e --- /dev/null +++ b/inputremapper/injection/injector.py @@ -0,0 +1,511 @@ +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + + +"""Keeps injecting keycodes in the background based on the preset.""" +from __future__ import annotations + +import asyncio +import enum +import multiprocessing +import sys +import time +from collections import defaultdict +from dataclasses import dataclass +from multiprocessing.connection import Connection +from typing import Dict, List, Optional, Tuple, Union + +import evdev + +from inputremapper.configs.input_config import InputCombination, InputConfig +from inputremapper.configs.preset import Preset +from inputremapper.groups import ( + _Group, + classify, + DeviceType, +) +from inputremapper.gui.messages.message_broker import MessageType +from inputremapper.injection.context import Context +from inputremapper.injection.event_reader import EventReader +from inputremapper.injection.mapping_handlers.mapping_parser import MappingParser +from inputremapper.injection.numlock import set_numlock, is_numlock_on, ensure_numlock +from inputremapper.logging.logger import logger +from inputremapper.utils import get_device_hash, DeviceHash + +CapabilitiesDict = Dict[int, List[int]] + +DEV_NAME = "input-remapper" + + +# messages sent to the injector process +class InjectorCommand(str, enum.Enum): + CLOSE = "CLOSE" + + +# messages the injector process reports back to the service +class InjectorState(str, enum.Enum): + UNKNOWN = "UNKNOWN" + STARTING = "STARTING" + ERROR = "FAILED" + RUNNING = "RUNNING" + STOPPED = "STOPPED" + NO_GRAB = "NO_GRAB" + UPGRADE_EVDEV = "UPGRADE_EVDEV" + + +def is_in_capabilities( + combination: InputCombination, capabilities: CapabilitiesDict +) -> bool: + """Are this combination or one of its sub keys in the capabilities?""" + for event in combination: + if event.code in capabilities.get(event.type, []): + return True + + return False + + +def get_udev_name(name: str, suffix: str) -> str: + """Make sure the generated name is not longer than 80 chars.""" + max_len = 80 # based on error messages + remaining_len = max_len - len(DEV_NAME) - len(suffix) - 2 + middle = name[:remaining_len] + name = f"{DEV_NAME} {middle} {suffix}" + return name + + +def get_forward_name(name: str) -> str: + """Keep forwarded uinput names within evdev's 80 character limit.""" + return name[:80] + + +def get_forward_phys(source: evdev.InputDevice) -> str: + """Use a stable phys marker for forwarded devices. + + The original phys path must not be reused because it makes the forwarded + device look like the hardware device to our autoload rule. However, using a + dedicated input-remapper phys marker still allows us to identify and ignore + the forwarded device elsewhere. + """ + if source.phys: + return f"{DEV_NAME}/{source.phys}" + + return DEV_NAME + + +@dataclass(frozen=True) +class InjectorStateMessage: + message_type = MessageType.injector_state + state: Union[InjectorState] + + def active(self) -> bool: + return self.state in [InjectorState.RUNNING, InjectorState.STARTING] + + def inactive(self) -> bool: + return self.state in [InjectorState.STOPPED, InjectorState.NO_GRAB] + + +class Injector(multiprocessing.Process): + """Initializes, starts and stops injections. + + Is a process to make it non-blocking for the rest of the code and to + make running multiple injector easier. There is one process per + hardware-device that is being mapped. + """ + + group: _Group + preset: Preset + context: Optional[Context] + _devices: List[evdev.InputDevice] + _state: InjectorState + _msg_pipe: Tuple[Connection, Connection] + _event_readers: List[EventReader] + _stop_event: asyncio.Event + + regrab_timeout = 0.2 + + def __init__( + self, + group: _Group, + preset: Preset, + mapping_parser: MappingParser, + ) -> None: + """ + + Parameters + ---------- + group + the device group + """ + self.group = group + self.mapping_parser = mapping_parser + self._state = InjectorState.UNKNOWN + + # used to interact with the parts of this class that are running within + # the new process + self._msg_pipe = multiprocessing.Pipe() + + self.preset = preset + self.context = None # only needed inside the injection process + + self._event_readers = [] + + super().__init__(name=group.key) + + """Functions to interact with the running process.""" + + def get_state(self) -> InjectorState: + """Get the state of the injection. + + Can be safely called from the main process. + """ + # before we try to we try to guess anything lets check if there is a message + state = self._state + while self._msg_pipe[1].poll(): + state = self._msg_pipe[1].recv() + + # figure out what is going on step by step + alive = self.is_alive() + + # if `self.start()` has been called + started = state != InjectorState.UNKNOWN or alive + + if started: + if state == InjectorState.UNKNOWN and alive: + # if it is alive, it is definitely at least starting up. + state = InjectorState.STARTING + + if state in (InjectorState.STARTING, InjectorState.RUNNING) and not alive: + # we thought it is running (maybe it was when get_state was previously), + # but the process is not alive. It probably crashed + state = InjectorState.ERROR + logger.error("Injector was unexpectedly found stopped") + + logger.debug( + 'Injector state of "%s", "%s": %s', + self.group.key, + self.preset.name, + state, + ) + self._state = state + return self._state + + @ensure_numlock + def stop_injecting(self) -> None: + """Stop injecting keycodes. + + Can be safely called from the main procss. + """ + logger.info('Stopping injecting keycodes for group "%s"', self.group.key) + self._msg_pipe[1].send(InjectorCommand.CLOSE) + + """Process internal stuff.""" + + def _find_input_device( + self, input_config: InputConfig + ) -> Optional[evdev.InputDevice]: + """find the InputDevice specified by the InputConfig + + ensures the devices supports the type and code specified by the InputConfig""" + devices_by_hash = {get_device_hash(device): device for device in self._devices} + + # mypy thinks None is the wrong type for dict.get() + if device := devices_by_hash.get(input_config.origin_hash): # type: ignore + if input_config.code in device.capabilities(absinfo=False).get( + input_config.type, [] + ): + return device + return None + + def _find_input_device_fallback( + self, input_config: InputConfig + ) -> Optional[evdev.InputDevice]: + """find the InputDevice specified by the InputConfig fallback logic""" + ranking = [ + DeviceType.KEYBOARD, + DeviceType.GAMEPAD, + DeviceType.MOUSE, + DeviceType.TOUCHPAD, + DeviceType.GRAPHICS_TABLET, + DeviceType.CAMERA, + DeviceType.UNKNOWN, + ] + candidates: List[evdev.InputDevice] = [ + device + for device in self._devices + if input_config.code + in device.capabilities(absinfo=False).get(input_config.type, []) + ] + + if len(candidates) > 1: + # there is more than on input device which can be used for this + # event we choose only one determined by the ranking + return sorted(candidates, key=lambda d: ranking.index(classify(d)))[0] + if len(candidates) == 1: + return candidates.pop() + + logger.error(f"Could not find input for {input_config}") + return None + + def _grab_devices(self) -> Dict[DeviceHash, evdev.InputDevice]: + """Grab all InputDevices that match a mappings' origin_hash.""" + # use a dict because the InputDevice is not directly hashable + needed_devices = {} + input_configs = set() + + # find all unique input_config's + for mapping in self.preset: + for input_config in mapping.input_combination: + input_configs.add(input_config) + + # find all unique input_device's + for input_config in input_configs: + if not (device := self._find_input_device(input_config)): + # there is no point in trying the fallback because + # self._update_preset already did that. + continue + needed_devices[device.path] = device + + grabbed_devices = {} + for device in needed_devices.values(): + if device := self._grab_device(device): + grabbed_devices[get_device_hash(device)] = device + + return grabbed_devices + + def _update_preset(self): + """Update all InputConfigs in the preset to include correct origin_hash + information.""" + mappings_by_input = defaultdict(list) + for mapping in self.preset: + for input_config in mapping.input_combination: + mappings_by_input[input_config].append(mapping) + + for input_config in mappings_by_input: + if self._find_input_device(input_config): + continue + + if not (device := self._find_input_device_fallback(input_config)): + # fallback failed, this mapping will be ignored + continue + + for mapping in mappings_by_input[input_config]: + combination: List[InputConfig] = list(mapping.input_combination) + device_hash = get_device_hash(device) + idx = combination.index(input_config) + combination[idx] = combination[idx].modify(origin_hash=device_hash) + mapping.input_combination = combination + + def _grab_device(self, device: evdev.InputDevice) -> Optional[evdev.InputDevice]: + """Try to grab the device, return None if not possible. + + Without grab, original events from it would reach the display server + even though they are mapped. + """ + error = None + for attempt in range(10): + try: + device.grab() + logger.debug("Grab %s", device.path) + return device + except IOError as err: + # it might take a little time until the device is free if + # it was previously grabbed. + error = err + logger.debug("Failed attempts to grab %s: %d", device.path, attempt + 1) + time.sleep(self.regrab_timeout) + + logger.error("Cannot grab %s, it is possibly in use", device.path) + logger.error(str(error)) + return None + + @staticmethod + def _copy_capabilities(input_device: evdev.InputDevice) -> CapabilitiesDict: + """Copy capabilities for a new device.""" + ecodes = evdev.ecodes + + # copy the capabilities because the uinput is going + # to act like the device. + capabilities = input_device.capabilities(absinfo=True) + + # just like what python-evdev does in from_device + if ecodes.EV_SYN in capabilities: + del capabilities[ecodes.EV_SYN] + if ecodes.EV_FF in capabilities: + del capabilities[ecodes.EV_FF] + + if ecodes.ABS_VOLUME in capabilities.get(ecodes.EV_ABS, []): + # For some reason an ABS_VOLUME capability likes to appear + # for some users. It prevents mice from moving around and + # keyboards from writing symbols + capabilities[ecodes.EV_ABS].remove(ecodes.ABS_VOLUME) + + return capabilities + + async def _msg_listener(self) -> None: + """Wait for messages from the main process to do special stuff.""" + loop = asyncio.get_event_loop() + while True: + frame_available = asyncio.Event() + loop.add_reader(self._msg_pipe[0].fileno(), frame_available.set) + await frame_available.wait() + frame_available.clear() + msg = self._msg_pipe[0].recv() + + if msg == InjectorCommand.CLOSE: + await self._close() + return + + async def _close(self): + logger.debug("Received close signal") + self._stop_event.set() + # give the event pipeline some time to reset devices + # before shutting the loop down + await asyncio.sleep(0.1) + + # stop the event loop and cause the process to reach its end + # cleanly. Using .terminate prevents coverage from working. + loop = asyncio.get_event_loop() + loop.stop() + + self._msg_pipe[0].send(InjectorState.STOPPED) + + def _create_forwarding_device(self, source: evdev.InputDevice) -> evdev.UInput: + # copy as much information as possible, because libinput uses the extra + # information to enable certain features like "Disable touchpad while + # typing" + try: + forward_to = evdev.UInput( + # Keep the original name as far as possible so system hwdb rules + # can still match the virtual device and restore properties such + # as MOUSE_DPI. + name=get_forward_name(source.name), + events=self._copy_capabilities(source), + # Reusing source.phys causes our autoload rule to treat the + # forwarded device as hardware. Prefix it so it stays + # distinguishable while still carrying some source identity. + phys=get_forward_phys(source), + vendor=source.info.vendor, + product=source.info.product, + version=source.info.version, + bustype=source.info.bustype, + input_props=source.input_props(), + ) + except TypeError as e: + if "input_props" in str(e): + # UInput constructor doesn't support input_props and + # source.input_props doesn't exist with old python-evdev versions. + logger.error("Please upgrade your python-evdev version. Exiting") + self._msg_pipe[0].send(InjectorState.UPGRADE_EVDEV) + sys.exit(12) + + raise e + return forward_to + + def run(self) -> None: + """The injection worker that keeps injecting until terminated. + + Stuff is non-blocking by using asyncio in order to do multiple things + somewhat concurrently. + + Use this function as starting point in a process. It creates + the loops needed to read and map events and keeps running them. + """ + logger.info('Starting injecting the preset for "%s"', self.group.key) + + # create a new event loop, because somehow running an infinite loop + # that sleeps on iterations (joystick_to_mouse) in one process causes + # another injection process to screw up reading from the grabbed + # device. + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + + self._devices = self.group.get_devices() + + # InputConfigs may not contain the origin_hash information, this will try to + # make a good guess if the origin_hash information is missing or invalid. + self._update_preset() + + # grab devices as early as possible. If events appear that won't get + # released anymore before the grab they appear to be held down forever + sources = self._grab_devices() + forward_devices = {} + for device_hash, device in sources.items(): + forward_devices[device_hash] = self._create_forwarding_device(device) + + # create this within the process after the event loop creation, + # so that the macros use the correct loop + self.context = Context( + self.preset, + sources, + forward_devices, + self.mapping_parser, + ) + self._stop_event = asyncio.Event() + + if len(sources) == 0: + # maybe the preset was empty or something + logger.error("Did not grab any device") + self._msg_pipe[0].send(InjectorState.NO_GRAB) + return + + numlock_state = is_numlock_on() + coroutines = [] + + for device_hash in sources: + # actually doing things + event_reader = EventReader( + self.context, + sources[device_hash], + self._stop_event, + ) + coroutines.append(event_reader.run()) + self._event_readers.append(event_reader) + + coroutines.append(self._msg_listener()) + + # set the numlock state to what it was before injecting, because + # grabbing devices screws this up + set_numlock(numlock_state) + + self._msg_pipe[0].send(InjectorState.RUNNING) + + try: + loop.run_until_complete(asyncio.gather(*coroutines)) + except RuntimeError as error: + # the loop might have been stopped via a `CLOSE` message, + # which causes the error message below. This is expected behavior + if str(error) != "Event loop stopped before Future completed.": + raise error + except OSError as error: + logger.error("Failed to run injector coroutines: %s", str(error)) + + if len(coroutines) > 0: + # expected when stop_injecting is called, + # during normal operation as well as tests this point is not + # reached otherwise. + logger.debug("Injector coroutines ended") + + for source in sources.values(): + # ungrab at the end to make the next injection process not fail + # its grabs + try: + source.ungrab() + except OSError as error: + # it might have disappeared + logger.debug("OSError for ungrab on %s: %s", source.path, str(error)) diff --git a/inputremapper/injection/macros/__init__.py b/inputremapper/injection/macros/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/inputremapper/injection/macros/argument.py b/inputremapper/injection/macros/argument.py new file mode 100644 index 0000000..d01c13c --- /dev/null +++ b/inputremapper/injection/macros/argument.py @@ -0,0 +1,320 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + +from __future__ import annotations + +from dataclasses import dataclass +from enum import Enum +from typing import Optional, Any, Union, List, Literal, Type, TYPE_CHECKING + +from evdev._ecodes import EV_KEY + +from inputremapper.configs.keyboard_layout import keyboard_layout +from inputremapper.configs.validation_errors import ( + MacroError, + SymbolNotAvailableInTargetError, +) +from inputremapper.injection.global_uinputs import GlobalUInputs +from inputremapper.injection.macros.macro import Macro +from inputremapper.injection.macros.variable import Variable + +if TYPE_CHECKING: + from inputremapper.injection.macros.raw_value import RawValue + from inputremapper.configs.mapping import Mapping + + +class ArgumentFlags(Enum): + # No default value is set, and the user has to provide one when using the macro + required = "required" + + # If used, acts like foo(*bar) + spread = "spread" + + +@dataclass +class ArgumentConfig: + """Definition what kind of arguments a task may take.""" + + position: Union[int, Literal[ArgumentFlags.spread]] + name: str + types: List[Optional[Type]] + is_symbol: bool = False + default: Any = ArgumentFlags.required + + # If True, then the value (which should be a string), is the name of a non-constant + # variable. Tasks that overwrite their value need this, like `set`. The specified + # types are those that the current value of that variable may have. For `set` this + # doesn't matter, but something like `add` requires them to be numbers. + is_variable_name: bool = False + + def is_required(self) -> bool: + return self.default == ArgumentFlags.required + + def is_spread(self): + """Does this Argument store all remaining Variables of a Task as a list?""" + return self.position == ArgumentFlags.spread + + +class Argument(ArgumentConfig): + """Validation of variables and access to their value for Tasks during runtime.""" + + _variable: Optional[Variable] = None + + # If the position is set to ArgumentFlags.spread, then _variables will be filled + # with all remaining positional arguments that were passed to a task. + _variables: List[Variable] + + _mapping: Optional[Mapping] = None + + def __init__( + self, + argument_config: ArgumentConfig, + mapping: Mapping, + ) -> None: + # If a default of None is specified, but None is not an allowed type, then + # input-remapper has a bug here. Add "None" to your ArgumentConfig.types + assert not ( + argument_config.default is None and None not in argument_config.types + ) + + self.position = argument_config.position + self.name = argument_config.name + self.types = argument_config.types + self.is_symbol = argument_config.is_symbol + self.default = argument_config.default + self.is_variable_name = argument_config.is_variable_name + + self._mapping = mapping + self._variables = [] + + def initialize_variables(self, raw_values: List[RawValue]) -> None: + """If the macro is supposed to contain multiple variables, set them. + Should be done during parsing.""" + assert len(self._variables) == 0 + assert self._variable is None + assert self.is_spread() + + for raw_value in raw_values: + variable = self._parse_raw_value(raw_value) + self._variables.append(variable) + + def initialize_variable(self, raw_value: RawValue) -> None: + """Set the Arguments Variable. Done during parsing.""" + assert len(self._variables) == 0 + assert self._variable is None + assert not self.is_spread() + + variable = self._parse_raw_value(raw_value) + self._variable = variable + + def initialize_default(self) -> None: + """Set the Arguments to its default value. Done during parsing.""" + assert len(self._variables) == 0 + assert self._variable is None + assert not self.is_spread() + + variable = Variable(value=self.default, const=True) + self._variable = variable + + def get_value(self) -> Any: + """To ask for the current value of the variable during runtime.""" + assert not self.is_spread(), f"Use .{self.get_values.__name__}()" + # If a user passed None as value, it should be a Variable(None, const=True) here. + # If not, a test or input-remapper is broken. + assert self._variable is not None + + value = self._variable.get_value() + + if not self._variable.const: + # Dynamic value. Hasn't been validated yet + value = self._validate_dynamic_value(self._variable) + + return value + + def get_values(self) -> List[Any]: + """To ask for the current values of the variables during runtime.""" + assert self.is_spread(), f"Use .{self.get_value.__name__}()" + + values = [] + for variable in self._variables: + if not variable.const: + values.append(self._validate_dynamic_value(variable)) + else: + values.append(variable.get_value()) + + return values + + def get_variable_name(self) -> str: + """If the variable is not const, return its name.""" + assert self._variable is not None + return self._variable.get_name() + + def contains_macro(self) -> bool: + """Does the underlying Variable contain another child-macro?""" + assert self._variable is not None + return isinstance(self._variable.get_value(), Macro) + + def set_value(self, value: Any) -> Any: + """To set the value of the underlying Variable during runtime. + Fails for constants.""" + assert self._variable is not None + if self._variable.const: + raise Exception("Can't set value of a constant") + + self._variable.set_value(value) + + def assert_is_symbol(self, symbol: str) -> None: + """Checks if the key/symbol-name is valid. Like "KEY_A" or "escape". + + Using `is_symbol` on the ArgumentConfig is prefered, which causes it to + automatically do this for you. But some macros may be a bit more flexible, + and there we want to assert this ourselves only in certain cases.""" + symbol = str(symbol) + code = keyboard_layout.get(symbol) + + if code is None: + raise MacroError(msg=f'Unknown key "{symbol}"') + + if self._mapping is not None: + target = self._mapping.target_uinput + if target is not None and not GlobalUInputs.can_default_uinput_emit( + target, EV_KEY, code + ): + raise SymbolNotAvailableInTargetError(symbol, target) + + def _parse_raw_value(self, raw_value: RawValue) -> Variable: + """Validate and parse.""" + value = raw_value.value + + # The order of steps below matters. + + if isinstance(value, Macro): + return Variable(value=value, const=True) + + if self.is_variable_name: + # Treat this as a non-constant variable, + # even without a `$` in front of its name + if value.startswith('"'): + # Remove quotes from the string + value = value[1:-1] + return Variable(value=value, const=False) + + if value.startswith("$"): + # Will be resolved during the macros runtime + return Variable(value=value[1:], const=False) + + if self.is_symbol: + if value.startswith('"'): + value = value[1:-1] + self.assert_is_symbol(value) + return Variable(value=value, const=True) + + if (value == "" or value == "None") and None in self.types: + # I think "" is the deprecated alternative to "None" + return Variable(value=None, const=True) + + if value.startswith('"') and str in self.types: + # Something with explicit quotes should never be parsed as a number. + # Treat it as a string no matter the content. + value = value[1:-1] + return Variable(value=value, const=True) + + if float in self.types and "." in value: + try: + return Variable(value=float(value), const=True) + except (ValueError, TypeError): + pass + + if int in self.types: + try: + return Variable(value=int(value), const=True) + except (ValueError, TypeError): + pass + + if not value.startswith('"') and ("(" in value or ")" in value): + # Looks like something that should have been a macro. It is not explicitly + # wrapped in quotes. Most likely an error. If it was a valid macro, the + # parser would have parsed it as such. + raise MacroError( + msg=f"A broken macro was passed as parameter to {self.name}" + ) + + if str in self.types: + # Treat as a string. Something like KEY_A in key(KEY_A) + return Variable(value=value, const=True) + + raise self._type_error_factory(value) + + def _validate_dynamic_value(self, variable: Variable) -> Any: + """To make sure the value of a non-const variable, asked for at runtime, is + fitting for the given ArgumentConfig.""" + # Most of the stuff has already been taken care of when, for example, + # the "1" of set(foo, 1), or the '"bar"' or set(foo, "bar") was parsed the + # first time. In the first case we get a number 1, and in the second a string + # `bar` without quotes + assert not variable.const + value = variable.get_value() + + if self.is_symbol: + # value might be int `1`, which is a valid symbol for `key(1)` + value = str(value) + self.assert_is_symbol(value) + return value + + if None in self.types and value is None: + return value + + if type(value) in self.types: + return value + + if type(value) not in self.types and str in self.types: + # `set` cannot make predictions where the variable will be used. Make sure + # the type is compatible, and turn numbers back into strings if need be. + return str(value) + + # If the value is "1", we don't attempt to parse it as a number. This being a + # string means that something like `set(foo, "1")` was used, which enforces a + # string datatype. Otherwise, `set` would have already turned it into an int. + + raise self._type_error_factory(value) + + def _is_numeric_string(self, value: str) -> bool: + """Check if the value can be turned into a number.""" + try: + float(value) + return True + except ValueError: + return False + + def _type_error_factory(self, value: Any) -> MacroError: + formatted_types: List[str] = [] + + for type_ in self.types: + if type_ is None: + formatted_types.append("None") + else: + formatted_types.append(type_.__name__) + + return MacroError( + msg=( + f'Expected "{self.name}" to be one of {formatted_types}, but got ' + f'{type(value).__name__} "{value}"' + ) + ) diff --git a/inputremapper/injection/macros/macro.py b/inputremapper/injection/macros/macro.py new file mode 100644 index 0000000..1ef5cf1 --- /dev/null +++ b/inputremapper/injection/macros/macro.py @@ -0,0 +1,126 @@ +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + +"""Executes more complex patterns of keystrokes.""" + +from __future__ import annotations + +import asyncio +from typing import List, Callable, Optional, TYPE_CHECKING + +from inputremapper.ipc.shared_dict import SharedDict +from inputremapper.logging.logger import logger + +if TYPE_CHECKING: + from inputremapper.injection.macros.task import Task + from inputremapper.injection.context import Context + from inputremapper.configs.mapping import Mapping + +InjectEventCallback = Callable[[int, int, int], None] + +macro_variables = SharedDict() + + +class Macro: + """Chains tasks (like `modify` or `repeat`). + + Tasks may have child_macros. Running a Macro runs Tasks, which in turn may run + their child_macros based on certain conditions (depending on the Task). + """ + + def __init__( + self, + code: Optional[str], + context: Optional[Context] = None, + mapping: Optional[Mapping] = None, + ): + """Create a macro instance that can be populated with tasks. + + Parameters + ---------- + code + The original parsed code, for logging purposes. + context : Context + mapping : UIMapping + """ + self.code = code + self.context = context + self.mapping = mapping + + # List of coroutines that will be called sequentially. + # This is the compiled code + self.tasks: List[Task] = [] + + self.running = False + + self.keystroke_sleep_ms = None + + async def run(self, callback: InjectEventCallback): + """Run the macro. + + Parameters + ---------- + callback + Will receive int type, code and value for an event to write + """ + if not callable(callback): + raise ValueError("handler is not callable") + + if self.running: + logger.error('Tried to run already running macro "%s"', self.code) + return + + self.keystroke_sleep_ms = self.mapping.macro_key_sleep_ms + + self.running = True + + try: + for task in self.tasks: + coroutine = task.run(callback) + if asyncio.iscoroutine(coroutine): + await coroutine + except Exception: + raise + finally: + # done + self.running = False + + def press_trigger(self): + """The user pressed the trigger key down.""" + for task in self.tasks: + task.press_trigger() + + def release_trigger(self): + """The user released the trigger key.""" + for task in self.tasks: + task.release_trigger() + + async def _keycode_pause(self, _=None): + """To add a pause between keystrokes. + + This was needed at some point because it appeared that injecting keys too + fast will prevent them from working. It probably depends on the environment. + """ + await asyncio.sleep(self.keystroke_sleep_ms / 1000) + + def __repr__(self): + return f'' + + def add_task(self, task): + self.tasks.append(task) diff --git a/inputremapper/injection/macros/parse.py b/inputremapper/injection/macros/parse.py new file mode 100644 index 0000000..60ae91d --- /dev/null +++ b/inputremapper/injection/macros/parse.py @@ -0,0 +1,476 @@ +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + + +"""Parse macro code""" + +from __future__ import annotations + +import re +from typing import Optional, Any, Type, TYPE_CHECKING, Dict, List + +from inputremapper.configs.validation_errors import MacroError +from inputremapper.injection.macros.macro import Macro +from inputremapper.injection.macros.raw_value import RawValue +from inputremapper.injection.macros.task import Task +from inputremapper.injection.macros.tasks.add import AddTask +from inputremapper.injection.macros.tasks.event import EventTask +from inputremapper.injection.macros.tasks.hold import HoldTask +from inputremapper.injection.macros.tasks.hold_keys import HoldKeysTask +from inputremapper.injection.macros.tasks.if_eq import IfEqTask +from inputremapper.injection.macros.tasks.if_led import IfNumlockTask, IfCapslockTask +from inputremapper.injection.macros.tasks.if_single import IfSingleTask +from inputremapper.injection.macros.tasks.if_tap import IfTapTask +from inputremapper.injection.macros.tasks.ifeq import DeprecatedIfEqTask +from inputremapper.injection.macros.tasks.key import KeyTask +from inputremapper.injection.macros.tasks.key_down import KeyDownTask +from inputremapper.injection.macros.tasks.key_up import KeyUpTask +from inputremapper.injection.macros.tasks.mod_tap import ModTapTask +from inputremapper.injection.macros.tasks.modify import ModifyTask +from inputremapper.injection.macros.tasks.mouse import MouseTask +from inputremapper.injection.macros.tasks.parallel import ParallelTask +from inputremapper.injection.macros.tasks.mouse_xy import MouseXYTask +from inputremapper.injection.macros.tasks.repeat import RepeatTask +from inputremapper.injection.macros.tasks.set import SetTask +from inputremapper.injection.macros.tasks.toggle import ToggleTask +from inputremapper.injection.macros.tasks.wait import WaitTask +from inputremapper.injection.macros.tasks.wheel import WheelTask +from inputremapper.logging.logger import logger + +if TYPE_CHECKING: + from inputremapper.injection.context import Context + from inputremapper.configs.mapping import Mapping + + +class Parser: + TASK_CLASSES: dict[str, type[Task]] = { + "modify": ModifyTask, + "repeat": RepeatTask, + "toggle": ToggleTask, + "key": KeyTask, + "key_down": KeyDownTask, + "key_up": KeyUpTask, + "event": EventTask, + "wait": WaitTask, + "hold": HoldTask, + "hold_keys": HoldKeysTask, + "mouse": MouseTask, + "mouse_xy": MouseXYTask, + "wheel": WheelTask, + "if_eq": IfEqTask, + "if_numlock": IfNumlockTask, + "if_capslock": IfCapslockTask, + "set": SetTask, + "if_tap": IfTapTask, + "if_single": IfSingleTask, + "add": AddTask, + "mod_tap": ModTapTask, + "parallel": ParallelTask, + # Those are only kept for backwards compatibility with old macros. The space for + # writing macro was very constrained in the past, so shorthands were introduced: + "m": ModifyTask, + "r": RepeatTask, + "k": KeyTask, + "e": EventTask, + "w": WaitTask, + "h": HoldTask, + # It was not possible to adjust ifeq to support variables without breaking old + # macros, so this function is deprecated and if_eq introduced. Kept for backwards + # compatibility: + "ifeq": DeprecatedIfEqTask, + } + + @staticmethod + def is_this_a_macro(output: Any): + """Figure out if this is a macro.""" + if not isinstance(output, str): + return False + + if "+" in output.strip(): + # for example "a + b" + return True + + return "(" in output and ")" in output and len(output) >= 4 + + @staticmethod + def _extract_args(inner: str): + """Extract parameters from the inner contents of a call. + + This does not parse them. + + Parameters + ---------- + inner + for example '1, r, r(2, k(a))' should result in ['1', 'r', 'r(2, k(a))'] + """ + inner = inner.strip() + brackets = 0 + params = [] + start = 0 + string = False + for position, char in enumerate(inner): + # ignore anything between string quotes + if char == '"': + string = not string + if string: + continue + + # ignore commas inside child macros + if char == "(": + brackets += 1 + if char == ")": + brackets -= 1 + if char == "," and brackets == 0: + # , potentially starts another parameter, but only if + # the current brackets are all closed. + params.append(inner[start:position].strip()) + # skip the comma + start = position + 1 + + # one last parameter + params.append(inner[start:].strip()) + + return params + + @staticmethod + def _count_brackets(macro): + """Find where the first opening bracket closes.""" + openings = macro.count("(") + closings = macro.count(")") + if openings != closings: + raise MacroError( + macro, f"Found {openings} opening and {closings} closing brackets" + ) + + brackets = 0 + position = 0 + for char in macro: + position += 1 + if char == "(": + brackets += 1 + continue + + if char == ")": + brackets -= 1 + if brackets == 0: + # the closing bracket of the call + break + + return position + + @staticmethod + def _split_keyword_arg(param): + """Split "foo=bar" into "foo" and "bar". + + If not a keyward param, return None and the param. + """ + if re.match(r"[a-zA-Z_][a-zA-Z_\d]*=.+", param): + split = param.split("=", 1) + return split[0], split[1] + + return None, param + + @staticmethod + def _validate_keyword_argument_names( + keyword_args: Dict[str, Any], + task_class: Type[Task], + ) -> None: + for keyword_arg in keyword_args: + for argument in task_class.argument_configs: + if argument.name == keyword_arg: + break + else: + raise MacroError(msg=f"Unknown keyword argument {keyword_arg}") + + @staticmethod + def _parse_recurse( + code: str, + context: Optional[Context], + mapping: Mapping, + verbose: bool, + macro_instance: Optional[Macro] = None, + depth: int = 0, + ) -> RawValue: + """Handle a subset of the macro, e.g. one parameter or function call. + + Not using eval for security reasons. + + Parameters + ---------- + code + Just like parse. A single parameter or the complete macro as string. + Comments and redundant whitespace characters are expected to be removed already. + Example: + - "parallel(key(a),key(b).key($foo))" + - "key(a)" + - "a" + - "key(b).key($foo)" + - "b" + - "key($foo)" + - "$foo" + context : Context + macro_instance + A macro instance to add tasks to. This is the output of the parser, and is + organized like a tree. + depth + For logging porposes + """ + assert isinstance(code, str) + assert isinstance(depth, int) + + def debug(*args, **kwargs): + if verbose: + logger.debug(*args, **kwargs) + + space = " " * depth + + code = code.strip() + + # is it another macro? + task_call_match = re.match(r"^(\w+)\(", code) + task_name = task_call_match[1] if task_call_match else None + + if task_name is None: + # It is probably either a key name like KEY_A or a variable name as in `set(var,1)`, + # both won't contain special characters that can break macro syntax so they don't + # have to be wrapped in quotes. The argument configuration of the tasks will + # detemrine how to parse it. + debug("%svalue %s", space, code) + return RawValue(value=code) + + if macro_instance is None: + # start a new chain + macro_instance = Macro(code, context, mapping) + else: + # chain this call to the existing instance + assert isinstance(macro_instance, Macro) + + task_class = Parser.TASK_CLASSES.get(task_name) + if task_class is None: + raise MacroError(code, f"Unknown function {task_name}") + + # get all the stuff inbetween + closing_bracket_position = Parser._count_brackets(code) - 1 + inner = code[code.index("(") + 1 : closing_bracket_position] + debug("%scalls %s with %s", space, task_name, inner) + + # split "3, foo=a(2, k(a).w(10))" into arguments + raw_string_args = Parser._extract_args(inner) + + # parse and sort the params + positional_args: List[RawValue] = [] + keyword_args: Dict[str, RawValue] = {} + for param in raw_string_args: + key, value = Parser._split_keyword_arg(param) + parsed = Parser._parse_recurse( + value.strip(), + context, + mapping, + verbose, + None, + depth + 1, + ) + if key is None: + if len(keyword_args) > 0: + msg = f'Positional argument "{key}" follows keyword argument' + raise MacroError(code, msg) + positional_args.append(parsed) + else: + if key in keyword_args: + raise MacroError(code, f'The "{key}" argument was specified twice') + keyword_args[key] = parsed + + debug( + "%sadd call to %s with %s, %s", + space, + task_name, + positional_args, + keyword_args, + ) + + Parser._validate_keyword_argument_names( + keyword_args, + task_class, + ) + Parser._validate_num_args( + code, + task_name, + task_class, + raw_string_args, + ) + + try: + task = task_class( + positional_args, + keyword_args, + context, + mapping, + ) + macro_instance.add_task(task) + except TypeError as exception: + raise MacroError(msg=str(exception)) from exception + + # is after this another call? Chain it to the macro_instance + more_code_exists = len(code) > closing_bracket_position + 1 + if more_code_exists: + next_char = code[closing_bracket_position + 1] + statement_closed = next_char == "." + + if statement_closed: + # skip over the ")." + chain = code[closing_bracket_position + 2 :] + debug("%sfollowed by %s", space, chain) + Parser._parse_recurse( + chain, + context, + mapping, + verbose, + macro_instance, + depth, + ) + elif re.match(r"[a-zA-Z_]", next_char): + # something like foo()bar + raise MacroError( + code, + f'Expected a "." to follow after ' + f"{code[:closing_bracket_position + 1]}", + ) + + return RawValue(value=macro_instance) + + @staticmethod + def _validate_num_args( + code: str, + task_name: str, + task_class: Type[Task], + raw_string_args: List[str], + ) -> None: + min_args, max_args = task_class.get_num_parameters() + num_provided_args = len(raw_string_args) + if num_provided_args < min_args or num_provided_args > max_args: + if min_args != max_args: + msg = ( + f"{task_name} takes between {min_args} and {max_args}, " + f"not {num_provided_args} parameters" + ) + else: + msg = ( + f"{task_name} takes {min_args}, not {num_provided_args} parameters" + ) + + raise MacroError(code, msg) + + @staticmethod + def handle_plus_syntax(macro): + """Transform a + b + c to hold_keys(a,b,c).""" + if "+" not in macro: + return macro + + if "(" in macro or ")" in macro: + raise MacroError(macro, f'Mixing "+" and macros is unsupported: "{ macro}"') + + chunks = [chunk.strip() for chunk in macro.split("+")] + + if "" in chunks: + raise MacroError(f'Invalid syntax for "{macro}"') + + output = f"hold_keys({','.join(chunks)})" + + logger.debug('Transformed "%s" to "%s"', macro, output) + return output + + @staticmethod + def remove_whitespaces(macro, delimiter='"'): + """Remove whitespaces, tabs, newlines and such outside of string quotes.""" + result = "" + for i, chunk in enumerate(macro.split(delimiter)): + # every second chunk is inside string quotes + if i % 2 == 0: + result += re.sub(r"\s", "", chunk) + else: + result += chunk + result += delimiter + + # one extra delimiter was added + return result[: -len(delimiter)] + + @staticmethod + def remove_comments(macro): + """Remove comments from the macro and return the resulting code.""" + # keep hashtags inside quotes intact + result = "" + + for i, line in enumerate(macro.split("\n")): + for j, chunk in enumerate(line.split('"')): + if j > 0: + # add back the string quote + chunk = f'"{chunk}' + + # every second chunk is inside string quotes + if j % 2 == 0 and "#" in chunk: + # everything from now on is a comment and can be ignored + result += chunk.split("#")[0] + break + else: + result += chunk + + if i < macro.count("\n"): + result += "\n" + + return result + + @staticmethod + def clean(code): + """Remove everything irrelevant for the macro.""" + return Parser.remove_whitespaces( + Parser.remove_comments(code), + '"', + ) + + @staticmethod + def parse(macro: str, context=None, mapping=None, verbose: bool = True) -> Macro: + """Parse and generate a Macro that can be run as often as you want. + + Parameters + ---------- + macro + "repeat(3, key(a).wait(10))" + "repeat(2, key(a).key(KEY_A)).key(b)" + "wait(1000).modify(Shift_L, repeat(2, k(a))).wait(10, 20).key(b)" + context : Context, or None for use in Frontend + mapping + the mapping for the macro, or None for use in Frontend + verbose + log the parsing True by default + """ + # TODO pass mapping in frontend and do the target check for keys? + logger.debug("parsing macro %s", macro.replace("\n", "")) + macro = Parser.clean(macro) + macro = Parser.handle_plus_syntax(macro) + + macro_obj = Parser._parse_recurse( + macro, + context, + mapping, + verbose, + ).value + if not isinstance(macro_obj, Macro): + raise MacroError(macro, "The provided code was not a macro") + + return macro_obj diff --git a/inputremapper/injection/macros/raw_value.py b/inputremapper/injection/macros/raw_value.py new file mode 100644 index 0000000..44209df --- /dev/null +++ b/inputremapper/injection/macros/raw_value.py @@ -0,0 +1,35 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + +from dataclasses import dataclass +from typing import Union + +from inputremapper.injection.macros.macro import Macro + + +@dataclass +class RawValue: + """Store a value exactly as it is in the macro-code. Values still have to be + validated according to the ArgumentConfig of the Tasks. + + Child-macros are passed as Macro objects though. + """ + + value: Union[str, Macro] diff --git a/inputremapper/injection/macros/task.py b/inputremapper/injection/macros/task.py new file mode 100644 index 0000000..986cce5 --- /dev/null +++ b/inputremapper/injection/macros/task.py @@ -0,0 +1,248 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + +from __future__ import annotations + +import asyncio +from itertools import chain +from typing import List, Dict, TYPE_CHECKING, Optional, Tuple, Union + +from inputremapper.configs.validation_errors import MacroError +from inputremapper.injection.macros.argument import ( + Argument, + ArgumentConfig, + ArgumentFlags, +) +from inputremapper.injection.macros.macro import Macro, InjectEventCallback +from inputremapper.logging.logger import logger + +if TYPE_CHECKING: + from inputremapper.injection.mapping_handlers.mapping_handler import EventListener + from inputremapper.injection.macros.raw_value import RawValue + from inputremapper.injection.context import Context + from inputremapper.configs.mapping import Mapping + + +class Task: + """Base Class for functions like `if_eq` or `key` + + A macro like `key(a).key(b)` will contain two instances of this class. + """ + + argument_configs: List[ArgumentConfig] + arguments: Dict[str, Argument] + mapping: Mapping + + # The context is None during frontend-parsing/validation I believe + context: Optional[Context] + + child_macros: List[Macro] + + def __init__( + self, + positional_args: List[RawValue], + keyword_args: Dict[str, RawValue], + context: Optional[Context], + mapping: Mapping, + ) -> None: + self.context = context + self.mapping = mapping + self.child_macros = [] + + self._validate_argument_configs() + + self.arguments = { + argument_config.name: Argument(argument_config, mapping) + for argument_config in self.argument_configs + } + + self._setup_asyncio_events() + + for argument in self.arguments.values(): + self._initialize_argument(argument, keyword_args, positional_args) + + self._initialize_spread_arg(positional_args) + + for raw_value in chain(keyword_args.values(), positional_args): + if isinstance(raw_value.value, Macro): + self.child_macros.append(raw_value.value) + + async def run(self, callback: InjectEventCallback) -> None: + """Macro logic goes here. + + Call the callback with the type, code and value that should be injected. + """ + raise NotImplementedError() + + def add_event_listener(self, listener: EventListener) -> None: + """Listeners get each event from the source device. + + After all listeners are done, the event will go into the mapping handlers. + + Make sure to remove your event_listener once you are done. + """ + # The context will be there when the macro is parsed by the service + assert self.context is not None + self.context.listeners.add(listener) + + def remove_event_listener(self, listener: EventListener) -> None: + assert self.context is not None + self.context.listeners.remove(listener) + + @classmethod + def get_macro_argument_names(cls): + return [argument_config.name for argument_config in cls.argument_configs] + + @classmethod + def get_num_parameters(cls) -> Tuple[int, Union[int, float]]: + """Get the number of required parameters and the maximum number of parameters.""" + min_num_args = 0 + argument_configs = cls.argument_configs + max_num_args: Union[int, float] = len(argument_configs) + for argument_config in argument_configs: + if argument_config.position == ArgumentFlags.spread: + # 0 or more + max_num_args = float("inf") + continue + + if argument_config.is_required(): + min_num_args += 1 + + return min_num_args, max_num_args + + def get_argument(self, argument_name) -> Argument: + return self.arguments[argument_name] + + def press_trigger(self) -> None: + """The user pressed the trigger key down.""" + for macro in self.child_macros: + macro.press_trigger() + + if self.is_holding(): + logger.error("Already holding") + return + + self._trigger_release_event.clear() + self._trigger_press_event.set() + + def release_trigger(self) -> None: + """The user released the trigger key.""" + if not self.is_holding(): + return + + self._trigger_release_event.set() + self._trigger_press_event.clear() + + for macro in self.child_macros: + macro.release_trigger() + + def is_holding(self) -> bool: + """Check if the macro is waiting for a key to be released.""" + return not self._trigger_release_event.is_set() + + async def keycode_pause(self, _=None) -> None: + """To add a pause between keystrokes. + + This was needed at some point because it appeared that injecting keys too + fast will prevent them from working. It probably depends on the environment. + """ + await asyncio.sleep(self.mapping.macro_key_sleep_ms / 1000) + + def _initialize_spread_arg( + self, + positional_args: List[RawValue], + ) -> None: + """Put all positional arguments that aren't used into the spread argument.""" + spread_argument: Optional[Argument] = None + for argument in self.arguments.values(): + if argument.position == ArgumentFlags.spread: + spread_argument = argument + break + + if spread_argument is None: + return + + remaining_positional_args = [*positional_args] + + for argument in self.arguments.values(): + if argument.position != ArgumentFlags.spread and argument.position < len( + remaining_positional_args + ): + del remaining_positional_args[argument.position] + + spread_argument.initialize_variables(remaining_positional_args) + + def _find_argument_by_position(self, position: int) -> Optional[Argument]: + for argument in self.arguments.values(): + if argument.position == position: + return argument + + return None + + def _setup_asyncio_events(self) -> None: + # Can be used to wait for the press and release of the input event/key, that is + # configured as the trigger of the macro, via asyncio. + self._trigger_release_event = asyncio.Event() + self._trigger_press_event = asyncio.Event() + # released by default + self._trigger_release_event.set() + self._trigger_press_event.clear() + + def _initialize_argument( + self, + argument: Argument, + keyword_args: Dict[str, RawValue], + positional_args: List[RawValue], + ) -> None: + if argument.position == ArgumentFlags.spread: + # Will get all the remaining positional arguments afterward. + return + + for name, value in keyword_args.items(): + if argument.name == name: + argument.initialize_variable(value) + return + + if argument.position < len(positional_args): + argument.initialize_variable(positional_args[argument.position]) + return + + if not argument.is_required(): + argument.initialize_default() + return + + # This shouldn't be possible, the parser should have ensured things are valid + # already. + raise MacroError(f"Could not initialize argument {argument.name}") + + def _validate_argument_configs(self): + # Might help during development + positions = set() + names = set() + for argument_config in self.argument_configs: + position = argument_config.position + if position in positions: + raise MacroError(f"Duplicate position {positions} in ArgumentConfig") + positions.add(position) + + name = argument_config.name + if name in names: + raise MacroError(f"Duplicate name {name} in ArgumentConfig") + names.add(name) diff --git a/inputremapper/injection/macros/tasks/__init__.py b/inputremapper/injection/macros/tasks/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/inputremapper/injection/macros/tasks/add.py b/inputremapper/injection/macros/tasks/add.py new file mode 100644 index 0000000..5da9040 --- /dev/null +++ b/inputremapper/injection/macros/tasks/add.py @@ -0,0 +1,72 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + +from __future__ import annotations + +from inputremapper.configs.validation_errors import MacroError +from inputremapper.injection.macros.argument import ArgumentConfig +from inputremapper.injection.macros.task import Task +from inputremapper.logging.logger import logger + + +class AddTask(Task): + """Add a number to a variable.""" + + argument_configs = [ + ArgumentConfig( + name="variable", + position=0, + types=[int, float, None], + is_variable_name=True, + ), + ArgumentConfig( + name="value", + position=1, + types=[int, float], + ), + ] + + async def run(self, callback) -> None: + argument = self.get_argument("variable") + try: + current = argument.get_value() + except MacroError: + return + + if current is None: + logger.debug( + '"%s" initialized with 0', + self.arguments["variable"].get_variable_name(), + ) + argument.set_value(0) + current = 0 + + addend = self.get_argument("value").get_value() + + if not isinstance(current, (int, float)): + logger.error( + 'Expected variable "%s" to contain a number, but got "%s"', + argument.get_value(), + current, + ) + return + + logger.debug("%s += %s", current, addend) + argument.set_value(current + addend) diff --git a/inputremapper/injection/macros/tasks/event.py b/inputremapper/injection/macros/tasks/event.py new file mode 100644 index 0000000..ab31014 --- /dev/null +++ b/inputremapper/injection/macros/tasks/event.py @@ -0,0 +1,64 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + +from __future__ import annotations + +from evdev.ecodes import ecodes + +from inputremapper.injection.macros.argument import ArgumentConfig +from inputremapper.injection.macros.task import Task + + +class EventTask(Task): + """Write any event. + + For example event(EV_KEY, KEY_A, 1) + """ + + argument_configs = [ + ArgumentConfig( + name="type", + position=0, + types=[str, int], + ), + ArgumentConfig( + name="code", + position=1, + types=[str, int], + ), + ArgumentConfig( + name="value", + position=2, + types=[int], + ), + ] + + async def run(self, callback) -> None: + type_ = self.get_argument("type").get_value() + code = self.get_argument("code").get_value() + value = self.get_argument("value").get_value() + + if isinstance(type_, str): + type_ = ecodes[type_.upper()] + if isinstance(code, str): + code = ecodes[code.upper()] + + callback(type_, code, value) + await self.keycode_pause() diff --git a/inputremapper/injection/macros/tasks/hold.py b/inputremapper/injection/macros/tasks/hold.py new file mode 100644 index 0000000..9de6418 --- /dev/null +++ b/inputremapper/injection/macros/tasks/hold.py @@ -0,0 +1,71 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + +from __future__ import annotations + +import asyncio + +from evdev.ecodes import EV_KEY + +from inputremapper.configs.keyboard_layout import keyboard_layout +from inputremapper.injection.macros.argument import ArgumentConfig +from inputremapper.injection.macros.macro import Macro +from inputremapper.injection.macros.task import Task + + +class HoldTask(Task): + """Loop the macro until the trigger-key is released.""" + + argument_configs = [ + ArgumentConfig( + name="macro", + position=0, + types=[Macro, str, None], + ) + ] + + async def run(self, callback) -> None: + macro = self.get_argument("macro").get_value() + + if macro is None: + await self._trigger_release_event.wait() + return + + if isinstance(macro, str): + # if macro is a key name, hold down the key while the + # keyboard key is physically held down + symbol = macro + self.get_argument("macro").assert_is_symbol(symbol) + + code = keyboard_layout.get(symbol) + callback(EV_KEY, code, 1) + await self._trigger_release_event.wait() + callback(EV_KEY, code, 0) + + if isinstance(macro, Macro): + # repeat the macro forever while the key is held down + while self.is_holding(): + # run the child macro completely to avoid + # not-releasing any key + await macro.run(callback) + # prevent input-remapper from freezing up and making the system hard + # to control, by adding 1ms of asyncio.sleep during which the event + # loop can do other things + await asyncio.sleep(1 / 1000) diff --git a/inputremapper/injection/macros/tasks/hold_keys.py b/inputremapper/injection/macros/tasks/hold_keys.py new file mode 100644 index 0000000..9cddf0d --- /dev/null +++ b/inputremapper/injection/macros/tasks/hold_keys.py @@ -0,0 +1,55 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + +from __future__ import annotations + +from evdev.ecodes import EV_KEY + +from inputremapper.configs.keyboard_layout import keyboard_layout +from inputremapper.injection.macros.argument import ArgumentConfig, ArgumentFlags +from inputremapper.injection.macros.task import Task + + +class HoldKeysTask(Task): + """Hold down multiple keys, equivalent to `a + b + c + ...`.""" + + argument_configs = [ + ArgumentConfig( + name="*symbols", + position=ArgumentFlags.spread, + types=[str], + is_symbol=True, + ) + ] + + async def run(self, callback) -> None: + symbols = self.get_argument("*symbols").get_values() + + codes = [keyboard_layout.get(symbol) for symbol in symbols] + + for code in codes: + callback(EV_KEY, code, 1) + await self.keycode_pause() + + await self._trigger_release_event.wait() + + for code in codes[::-1]: + callback(EV_KEY, code, 0) + await self.keycode_pause() diff --git a/inputremapper/injection/macros/tasks/if_eq.py b/inputremapper/injection/macros/tasks/if_eq.py new file mode 100644 index 0000000..5e6ad69 --- /dev/null +++ b/inputremapper/injection/macros/tasks/if_eq.py @@ -0,0 +1,66 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + +from __future__ import annotations + +from inputremapper.injection.macros.argument import ArgumentConfig +from inputremapper.injection.macros.macro import Macro +from inputremapper.injection.macros.task import Task + + +class IfEqTask(Task): + """Compare two values.""" + + argument_configs = [ + ArgumentConfig( + name="value_1", + position=0, + types=[str, int, float], + ), + ArgumentConfig( + name="value_2", + position=1, + types=[str, int, float], + ), + ArgumentConfig( + name="then", + position=2, + types=[Macro, None], + default=None, + ), + ArgumentConfig( + name="else", + position=3, + types=[Macro, None], + default=None, + ), + ] + + async def run(self, callback) -> None: + value_1 = self.get_argument("value_1").get_value() + value_2 = self.get_argument("value_2").get_value() + then = self.get_argument("then").get_value() + else_ = self.get_argument("else").get_value() + + if value_1 == value_2: + if then is not None: + await then.run(callback) + elif else_ is not None: + await else_.run(callback) diff --git a/inputremapper/injection/macros/tasks/if_led.py b/inputremapper/injection/macros/tasks/if_led.py new file mode 100644 index 0000000..740b744 --- /dev/null +++ b/inputremapper/injection/macros/tasks/if_led.py @@ -0,0 +1,71 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + +from __future__ import annotations + +from evdev.ecodes import ( + LED_NUML, + LED_CAPSL, +) + +from inputremapper.injection.macros.argument import ArgumentConfig +from inputremapper.injection.macros.macro import Macro +from inputremapper.injection.macros.task import Task + + +class IfLedTask(Task): + argument_configs = [ + ArgumentConfig( + name="then", + position=0, + types=[Macro, None], + default=None, + ), + ArgumentConfig( + name="else", + position=1, + types=[Macro, None], + default=None, + ), + ] + + led_code = None + + async def run(self, callback) -> None: + then = self.get_argument("then").get_value() + else_ = self.get_argument("else").get_value() + + # self.context is only None when the frontend is parsing the macro + assert self.context is not None + led_on = self.led_code in self.context.get_leds() + + if led_on: + if then is not None: + await then.run(callback) + elif else_ is not None: + await else_.run(callback) + + +class IfNumlockTask(IfLedTask): + led_code = LED_NUML + + +class IfCapslockTask(IfLedTask): + led_code = LED_CAPSL diff --git a/inputremapper/injection/macros/tasks/if_single.py b/inputremapper/injection/macros/tasks/if_single.py new file mode 100644 index 0000000..c731541 --- /dev/null +++ b/inputremapper/injection/macros/tasks/if_single.py @@ -0,0 +1,96 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + +from __future__ import annotations + +import asyncio + +from evdev.ecodes import EV_KEY + +from inputremapper.injection.macros.argument import ArgumentConfig +from inputremapper.injection.macros.macro import Macro +from inputremapper.injection.macros.task import Task +from inputremapper.input_event import InputEvent + + +class IfSingleTask(Task): + """If a key was pressed without combining it.""" + + argument_configs = [ + ArgumentConfig( + name="then", + position=0, + types=[Macro, None], + ), + ArgumentConfig( + name="else", + position=1, + types=[Macro, None], + ), + ArgumentConfig( + name="timeout", + position=2, + types=[int, float, None], + default=None, + ), + ] + + async def run(self, callback) -> None: + assert self.context is not None + another_key_pressed_event = asyncio.Event() + then = self.get_argument("then").get_value() + else_ = self.get_argument("else").get_value() + + async def listener(event: InputEvent) -> None: + if event.type != EV_KEY: + # Ignore anything that is not a key + return + + if event.is_pressed(): + # Another key was pressed + another_key_pressed_event.set() + return + + self.add_event_listener(listener) + + timeout = self.get_argument("timeout").get_value() + + # Wait for anything of importance to happen, that would determine the + # outcome of the if_single macro. + await asyncio.wait( + [ + asyncio.Task(another_key_pressed_event.wait()), + asyncio.Task(self._trigger_release_event.wait()), + ], + timeout=timeout / 1000 if timeout else None, + return_when=asyncio.FIRST_COMPLETED, + ) + + self.remove_event_listener(listener) + + if not self.is_holding(): + if then: + await then.run(callback) + else: + # If the trigger has not been released, then `await asyncio.wait` above + # could only have finished waiting due to a timeout, or because another + # key was pressed. + if else_: + await else_.run(callback) diff --git a/inputremapper/injection/macros/tasks/if_tap.py b/inputremapper/injection/macros/tasks/if_tap.py new file mode 100644 index 0000000..9c08c76 --- /dev/null +++ b/inputremapper/injection/macros/tasks/if_tap.py @@ -0,0 +1,79 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + +from __future__ import annotations + +import asyncio + +from inputremapper.injection.macros.argument import ArgumentConfig +from inputremapper.injection.macros.macro import Macro +from inputremapper.injection.macros.task import Task + + +class IfTapTask(Task): + """If a key was pressed quickly. + + macro key pressed -> if_tap starts -> key released -> then + + macro key pressed -> released (does other stuff in the meantime) + -> if_tap starts -> pressed -> released -> then + """ + + argument_configs = [ + ArgumentConfig( + name="then", + position=0, + types=[Macro, None], + default=None, + ), + ArgumentConfig( + name="else", + position=1, + types=[Macro, None], + default=None, + ), + ArgumentConfig( + name="timeout", + position=2, + types=[int, float], + default=300, + ), + ] + + async def run(self, callback) -> None: + then = self.get_argument("then").get_value() + else_ = self.get_argument("else").get_value() + timeout = self.get_argument("timeout").get_value() / 1000 + + try: + await asyncio.wait_for(self._wait(), timeout) + if then: + await then.run(callback) + except asyncio.TimeoutError: + if else_: + await else_.run(callback) + + async def _wait(self): + """Wait for a release, or if nothing pressed yet, a press and release.""" + if self.is_holding(): + await self._trigger_release_event.wait() + else: + await self._trigger_press_event.wait() + await self._trigger_release_event.wait() diff --git a/inputremapper/injection/macros/tasks/ifeq.py b/inputremapper/injection/macros/tasks/ifeq.py new file mode 100644 index 0000000..6e164af --- /dev/null +++ b/inputremapper/injection/macros/tasks/ifeq.py @@ -0,0 +1,72 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + +from __future__ import annotations + +from inputremapper.injection.macros.argument import ArgumentConfig +from inputremapper.injection.macros.macro import Macro +from inputremapper.injection.macros.task import Task + + +class DeprecatedIfEqTask(Task): + """Old version of if_eq, kept for compatibility reasons. + + This can't support a comparison like ifeq("foo", $blub) with blub containing + "foo" without breaking old functionality, because "foo" is treated as a + variable name. + """ + + argument_configs = [ + ArgumentConfig( + name="variable", + position=0, + types=[str, float, int, None], + is_variable_name=True, + ), + ArgumentConfig( + name="value", + position=1, + types=[str, float, int, None], + ), + ArgumentConfig( + name="then", + position=2, + types=[Macro, None], + ), + ArgumentConfig( + name="else", + position=3, + types=[Macro, None], + ), + ] + + async def run(self, callback) -> None: + actual_value = self.get_argument("variable").get_value() + value = self.get_argument("value").get_value() + then = self.get_argument("then").get_value() + else_ = self.get_argument("else").get_value() + + # The old ifeq function became somewhat incompatible with the new macro code. + # I need to compare them as strings to keep this working. + if str(actual_value) == str(value): + if then is not None: + await then.run(callback) + elif else_ is not None: + await else_.run(callback) diff --git a/inputremapper/injection/macros/tasks/key.py b/inputremapper/injection/macros/tasks/key.py new file mode 100644 index 0000000..e04cfb8 --- /dev/null +++ b/inputremapper/injection/macros/tasks/key.py @@ -0,0 +1,49 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + +from __future__ import annotations + +from evdev.ecodes import EV_KEY + +from inputremapper.configs.keyboard_layout import keyboard_layout +from inputremapper.injection.macros.argument import ArgumentConfig +from inputremapper.injection.macros.task import Task + + +class KeyTask(Task): + """Write the symbol.""" + + argument_configs = [ + ArgumentConfig( + name="symbol", + position=0, + types=[str], + is_symbol=True, + ) + ] + + async def run(self, callback) -> None: + symbol = self.get_argument("symbol").get_value() + code = keyboard_layout.get(symbol) + + callback(EV_KEY, code, 1) + await self.keycode_pause() + callback(EV_KEY, code, 0) + await self.keycode_pause() diff --git a/inputremapper/injection/macros/tasks/key_down.py b/inputremapper/injection/macros/tasks/key_down.py new file mode 100644 index 0000000..4efd9e9 --- /dev/null +++ b/inputremapper/injection/macros/tasks/key_down.py @@ -0,0 +1,45 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + +from __future__ import annotations + +from evdev.ecodes import EV_KEY + +from inputremapper.configs.keyboard_layout import keyboard_layout +from inputremapper.injection.macros.argument import ArgumentConfig +from inputremapper.injection.macros.task import Task + + +class KeyDownTask(Task): + """Press the symbol.""" + + argument_configs = [ + ArgumentConfig( + name="symbol", + position=0, + types=[str], + is_symbol=True, + ) + ] + + async def run(self, callback) -> None: + symbol = self.get_argument("symbol").get_value() + code = keyboard_layout.get(symbol) + callback(EV_KEY, code, 1) diff --git a/inputremapper/injection/macros/tasks/key_up.py b/inputremapper/injection/macros/tasks/key_up.py new file mode 100644 index 0000000..ea11419 --- /dev/null +++ b/inputremapper/injection/macros/tasks/key_up.py @@ -0,0 +1,45 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + +from __future__ import annotations + +from evdev.ecodes import EV_KEY + +from inputremapper.configs.keyboard_layout import keyboard_layout +from inputremapper.injection.macros.argument import ArgumentConfig +from inputremapper.injection.macros.task import Task + + +class KeyUpTask(Task): + """Release the symbol.""" + + argument_configs = [ + ArgumentConfig( + name="symbol", + position=0, + types=[str], + is_symbol=True, + ) + ] + + async def run(self, callback) -> None: + symbol = self.get_argument("symbol").get_value() + code = keyboard_layout.get(symbol) + callback(EV_KEY, code, 0) diff --git a/inputremapper/injection/macros/tasks/mod_tap.py b/inputremapper/injection/macros/tasks/mod_tap.py new file mode 100644 index 0000000..5b7a0a5 --- /dev/null +++ b/inputremapper/injection/macros/tasks/mod_tap.py @@ -0,0 +1,140 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + +from __future__ import annotations + +import asyncio +from collections import deque +from typing import Deque + +from evdev.ecodes import EV_KEY + +from inputremapper.configs.keyboard_layout import keyboard_layout +from inputremapper.injection.macros.argument import ArgumentConfig +from inputremapper.injection.macros.task import Task +from inputremapper.input_event import InputEvent +from inputremapper.logging.logger import logger + + +class ModTapTask(Task): + """If pressed long enough in combination with other keys, it turns into a modifier. + + Can be used to make home-row-modifiers. + + Works similar to the default of + https://github.com/qmk/qmk_firmware/blob/78a0adfbb4d2c4e12f93f2a62ded0020d406243e/docs/tap_hold.md#comparison-comparison + """ + + argument_configs = [ + ArgumentConfig( + name="default", + position=0, + types=[str], + is_symbol=True, + ), + ArgumentConfig( + name="modifier", + position=1, + types=[str], + is_symbol=True, + ), + ArgumentConfig( + name="tapping_term", + position=2, + types=[int, float], + default=200, + ), + ] + + async def run(self, callback) -> None: + tapping_term = self.get_argument("tapping_term").get_value() / 1000 + jamming_asyncio_events: Deque[asyncio.Event] = deque() + + async def listener(event: InputEvent) -> None: + trigger = self.mapping.input_combination[-1] + if event.type_and_code == trigger.type_and_code: + # We don't block the event that would set _trigger_release_event. + return + + if event.type != EV_KEY: + return + + asyncio_event = asyncio.Event() + jamming_asyncio_events.append(asyncio_event) + # Make the EventReader wait until the mod_tap macro allows it to continue + # processing the event. Because we want to wait until mod_tap injected the + # modifier. + await asyncio_event.wait() + + self.add_event_listener(listener) + + timeout = asyncio.Task(asyncio.sleep(tapping_term)) + await asyncio.wait( + [asyncio.Task(self._trigger_release_event.wait()), timeout], + return_when=asyncio.FIRST_COMPLETED, + ) + has_timed_out = timeout.done() + + if has_timed_out: + # The timeout happened before the trigger got released. + # We therefore modify stuff. + symbol = self.get_argument("modifier").get_value() + logger.debug("Modifying with %s", symbol) + else: + # The trigger got released before the timeout. + # We therefore do not modify stuff. + symbol = self.get_argument("default").get_value() + logger.debug("Writing default %s", symbol) + + code = keyboard_layout.get(symbol) + callback(EV_KEY, code, 1) + await self.keycode_pause() + + # Now that we know if the key was pressed with the intention of modifying other + # keys, we can let the jammed keys go on their journey through the handlers. + # Those other handlers may map them to other keys and stuff. + while len(jamming_asyncio_events) > 0: + asyncio_event = jamming_asyncio_events.popleft() + asyncio_event.set() + await self.keycode_pause() + await self.throttle() + # While we are emptying the queue, more events might still arrive and add + # to the queue. + + # We remove this as late as possible, because if more keys are pressed while + # jamming_asyncio_events is still being taken care of, they should wait until + # all is done. This ensures the order of all events that are pressed, until + # mod_tap is completely finished. + self.remove_event_listener(listener) + + # Keep the modifier pressed until the input/trigger is released + await self._trigger_release_event.wait() + callback(EV_KEY, code, 0) + + await self.keycode_pause() + + async def throttle(self) -> None: + # In case the keycode_pause ist set to 0ms, we need to give the event handlers + # a chance to inject the withheld events, before we go on. This ensures the + # correct order of injections. Since we are using asyncio, something like + # `callback(EV_KEY, code, 0)` might be faster than the event handlers, even if + # it is the last step of the macro. + if self.mapping.macro_key_sleep_ms == 0: + await asyncio.sleep(0.01) diff --git a/inputremapper/injection/macros/tasks/modify.py b/inputremapper/injection/macros/tasks/modify.py new file mode 100644 index 0000000..7d5f939 --- /dev/null +++ b/inputremapper/injection/macros/tasks/modify.py @@ -0,0 +1,57 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + +from __future__ import annotations + +from evdev.ecodes import EV_KEY + +from inputremapper.configs.keyboard_layout import keyboard_layout +from inputremapper.injection.macros.argument import ArgumentConfig +from inputremapper.injection.macros.macro import Macro +from inputremapper.injection.macros.task import Task + + +class ModifyTask(Task): + """Do stuff while a modifier is activated.""" + + argument_configs = [ + ArgumentConfig( + name="modifier", + position=0, + types=[str], + is_symbol=True, + ), + ArgumentConfig( + name="macro", + position=1, + types=[Macro], + ), + ] + + async def run(self, callback) -> None: + modifier = self.get_argument("modifier").get_value() + code = keyboard_layout.get(modifier) + macro = self.get_argument("macro").get_value() + + callback(EV_KEY, code, 1) + await self.keycode_pause() + await macro.run(callback) + callback(EV_KEY, code, 0) + await self.keycode_pause() diff --git a/inputremapper/injection/macros/tasks/mouse.py b/inputremapper/injection/macros/tasks/mouse.py new file mode 100644 index 0000000..801f850 --- /dev/null +++ b/inputremapper/injection/macros/tasks/mouse.py @@ -0,0 +1,69 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + +from __future__ import annotations + +from evdev._ecodes import REL_Y, REL_X + +from inputremapper.injection.macros.argument import ArgumentConfig +from inputremapper.injection.macros.macro import InjectEventCallback +from inputremapper.injection.macros.tasks.mouse_xy import MouseXYTask + + +class MouseTask(MouseXYTask): + """Move the mouse cursor.""" + + argument_configs = [ + ArgumentConfig( + name="direction", + position=0, + types=[str], + ), + ArgumentConfig( + name="speed", + position=1, + types=[int, float], + ), + ArgumentConfig( + name="acceleration", + position=2, + types=[int, float], + default=1, + ), + ] + + async def run(self, callback: InjectEventCallback) -> None: + direction = self.get_argument("direction").get_value() + speed = self.get_argument("speed").get_value() + acceleration = self.get_argument("acceleration").get_value() + + code, direction = { + "up": (REL_Y, -1), + "down": (REL_Y, 1), + "left": (REL_X, -1), + "right": (REL_X, 1), + }[direction.lower()] + + await self.axis( + code, + direction * speed, + acceleration, + callback, + ) diff --git a/inputremapper/injection/macros/tasks/mouse_xy.py b/inputremapper/injection/macros/tasks/mouse_xy.py new file mode 100644 index 0000000..a499efa --- /dev/null +++ b/inputremapper/injection/macros/tasks/mouse_xy.py @@ -0,0 +1,99 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + +from __future__ import annotations + +import asyncio +from typing import Union + +from evdev._ecodes import REL_Y, REL_X +from evdev.ecodes import EV_REL + +from inputremapper.injection.macros.argument import ArgumentConfig +from inputremapper.injection.macros.macro import InjectEventCallback +from inputremapper.injection.macros.task import Task +from inputremapper.injection.macros.tasks.util import precise_iteration_frequency + + +class MouseXYTask(Task): + """Move the mouse cursor.""" + + argument_configs = [ + ArgumentConfig( + name="x", + position=0, + types=[int, float], + default=0, + ), + ArgumentConfig( + name="y", + position=1, + types=[int, float], + default=0, + ), + ArgumentConfig( + name="acceleration", + position=2, + types=[int, float], + default=1, + ), + ] + + async def run(self, callback: InjectEventCallback) -> None: + x = self.get_argument("x").get_value() + y = self.get_argument("y").get_value() + acceleration = self.get_argument("acceleration").get_value() + await asyncio.gather( + self.axis(REL_X, x, acceleration, callback), + self.axis(REL_Y, y, acceleration, callback), + ) + + async def axis( + self, + code: int, + speed: Union[int, float], + fractional_acceleration: Union[int, float], + callback: InjectEventCallback, + ) -> None: + acceleration = speed * fractional_acceleration + direction = -1 if speed < 0 else 1 + current_speed = 0.0 + displacement_accumulator = 0.0 + displacement = 0 + if acceleration <= 0: + displacement = int(speed) + + async for _ in precise_iteration_frequency(self.mapping.rel_rate): + if not self.is_holding(): + return + + # Cursors can only move by integers. To get smooth acceleration for + # small acceleration values, the cursor needs to move by a pixel every + # few iterations. This can be achieved by remembering the decimal + # places that were cast away, and using them for the next iteration. + if acceleration: + current_speed += acceleration + current_speed = direction * min(abs(current_speed), abs(speed)) + displacement_accumulator += current_speed + displacement = int(displacement_accumulator) + displacement_accumulator -= displacement + + if displacement != 0: + callback(EV_REL, code, displacement) diff --git a/inputremapper/injection/macros/tasks/parallel.py b/inputremapper/injection/macros/tasks/parallel.py new file mode 100644 index 0000000..d875474 --- /dev/null +++ b/inputremapper/injection/macros/tasks/parallel.py @@ -0,0 +1,45 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + +from __future__ import annotations + +import asyncio +from typing import List + +from inputremapper.injection.macros.argument import ArgumentConfig, ArgumentFlags +from inputremapper.injection.macros.macro import Macro, InjectEventCallback +from inputremapper.injection.macros.task import Task + + +class ParallelTask(Task): + """Run all provided macros in parallel.""" + + argument_configs = [ + ArgumentConfig( + name="*macros", + position=ArgumentFlags.spread, + types=[Macro], + ), + ] + + async def run(self, callback: InjectEventCallback) -> None: + macros: List[Macro] = self.get_argument("*macros").get_values() + coroutines = [macro.run(callback) for macro in macros] + await asyncio.gather(*coroutines) diff --git a/inputremapper/injection/macros/tasks/repeat.py b/inputremapper/injection/macros/tasks/repeat.py new file mode 100644 index 0000000..f031a1b --- /dev/null +++ b/inputremapper/injection/macros/tasks/repeat.py @@ -0,0 +1,49 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + +from __future__ import annotations + +from inputremapper.injection.macros.argument import ArgumentConfig +from inputremapper.injection.macros.macro import Macro +from inputremapper.injection.macros.task import Task + + +class RepeatTask(Task): + """Repeat macros.""" + + argument_configs = [ + ArgumentConfig( + name="repeats", + position=0, + types=[int], + ), + ArgumentConfig( + name="macro", + position=1, + types=[Macro], + ), + ] + + async def run(self, callback) -> None: + repeats = self.get_argument("repeats").get_value() + macro = self.get_argument("macro").get_value() + + for _ in range(repeats): + await macro.run(callback) diff --git a/inputremapper/injection/macros/tasks/set.py b/inputremapper/injection/macros/tasks/set.py new file mode 100644 index 0000000..382b3aa --- /dev/null +++ b/inputremapper/injection/macros/tasks/set.py @@ -0,0 +1,49 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + +from __future__ import annotations + +from inputremapper.injection.macros.argument import ArgumentConfig +from inputremapper.injection.macros.macro import macro_variables +from inputremapper.injection.macros.task import Task + + +class SetTask(Task): + """Set a variable to a certain value.""" + + argument_configs = [ + ArgumentConfig( + name="variable", + position=0, + types=[str, float, int, None], + is_variable_name=True, + ), + ArgumentConfig( + name="value", + position=1, + types=[str, float, int, None], + default=None, + ), + ] + + async def run(self, callback) -> None: + value = self.get_argument("value").get_value() + assert macro_variables.is_alive() + self.arguments["variable"].set_value(value) diff --git a/inputremapper/injection/macros/tasks/toggle.py b/inputremapper/injection/macros/tasks/toggle.py new file mode 100644 index 0000000..8f21f4a --- /dev/null +++ b/inputremapper/injection/macros/tasks/toggle.py @@ -0,0 +1,56 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + +from __future__ import annotations + +import asyncio + +from inputremapper.injection.macros.argument import ArgumentConfig +from inputremapper.injection.macros.macro import Macro +from inputremapper.injection.macros.task import Task + + +class ToggleTask(Task): + """Toggle macros.""" + + argument_configs = [ + ArgumentConfig( + name="macro", + position=0, + types=[Macro], + ), + ] + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.trigger_count = 0 + + def press_trigger(self) -> None: + Task.press_trigger(self) + self.trigger_count += 1 + + async def run(self, callback) -> None: + macro = self.get_argument("macro").get_value() + + while self.trigger_count <= 1: + await macro.run(callback) + await asyncio.sleep(1 / 1000) + + self.trigger_count = 0 diff --git a/inputremapper/injection/macros/tasks/util.py b/inputremapper/injection/macros/tasks/util.py new file mode 100644 index 0000000..4061bfa --- /dev/null +++ b/inputremapper/injection/macros/tasks/util.py @@ -0,0 +1,45 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + +from __future__ import annotations + +import asyncio +import time +from typing import AsyncIterator + + +async def precise_iteration_frequency(frequency: float) -> AsyncIterator[None]: + """A generator to iterate over in a fixed frequency. + + asyncio.sleep might end up sleeping too long, for whatever reason. Maybe there are + other async function calls that take longer than expected in the background. + """ + sleep = 1 / frequency + corrected_sleep = sleep + error = 0.0 + + while True: + start = time.time() + + yield + + corrected_sleep -= error + await asyncio.sleep(corrected_sleep) + error = (time.time() - start) - sleep diff --git a/inputremapper/injection/macros/tasks/wait.py b/inputremapper/injection/macros/tasks/wait.py new file mode 100644 index 0000000..b51507a --- /dev/null +++ b/inputremapper/injection/macros/tasks/wait.py @@ -0,0 +1,54 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + +from __future__ import annotations + +import asyncio +import random + +from inputremapper.injection.macros.argument import ArgumentConfig +from inputremapper.injection.macros.task import Task + + +class WaitTask(Task): + """Wait time in milliseconds.""" + + argument_configs = [ + ArgumentConfig( + name="time", + position=0, + types=[float, int], + ), + ArgumentConfig( + name="max_time", + position=1, + types=[float, int, None], + default=None, + ), + ] + + async def run(self, callback) -> None: + time = self.get_argument("time").get_value() + max_time = self.get_argument("max_time").get_value() + + if max_time is not None and max_time > time: + time = random.uniform(time, max_time) + + await asyncio.sleep(time / 1000) diff --git a/inputremapper/injection/macros/tasks/wheel.py b/inputremapper/injection/macros/tasks/wheel.py new file mode 100644 index 0000000..f56bd9d --- /dev/null +++ b/inputremapper/injection/macros/tasks/wheel.py @@ -0,0 +1,76 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + +from __future__ import annotations + +import math + +from evdev.ecodes import ( + EV_REL, + REL_WHEEL_HI_RES, + REL_HWHEEL_HI_RES, + REL_WHEEL, + REL_HWHEEL, +) + +from inputremapper.injection.macros.argument import ArgumentConfig +from inputremapper.injection.macros.task import Task +from inputremapper.injection.macros.tasks.util import precise_iteration_frequency + + +class WheelTask(Task): + """Move the scroll wheel.""" + + argument_configs = [ + ArgumentConfig( + name="direction", + position=0, + types=[str], + ), + ArgumentConfig( + name="speed", + position=1, + types=[int, float], + ), + ] + + async def run(self, callback) -> None: + direction = self.get_argument("direction").get_value() + + # 120, see https://www.kernel.org/doc/html/latest/input/event-codes.html#ev-rel + code, value = { + "up": ([REL_WHEEL, REL_WHEEL_HI_RES], [1 / 120, 1]), + "down": ([REL_WHEEL, REL_WHEEL_HI_RES], [-1 / 120, -1]), + "left": ([REL_HWHEEL, REL_HWHEEL_HI_RES], [1 / 120, 1]), + "right": ([REL_HWHEEL, REL_HWHEEL_HI_RES], [-1 / 120, -1]), + }[direction.lower()] + + speed = self.get_argument("speed").get_value() + remainder = [0.0, 0.0] + + async for _ in precise_iteration_frequency(self.mapping.rel_rate): + if not self.is_holding(): + return + + for i in range(0, 2): + float_value = value[i] * speed + remainder[i] + remainder[i] = math.fmod(float_value, 1) + if abs(float_value) >= 1: + callback(EV_REL, code[i], int(float_value)) diff --git a/inputremapper/injection/macros/variable.py b/inputremapper/injection/macros/variable.py new file mode 100644 index 0000000..a4dffd4 --- /dev/null +++ b/inputremapper/injection/macros/variable.py @@ -0,0 +1,90 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + +import re +from typing import Any + +from inputremapper.configs.validation_errors import MacroError +from inputremapper.injection.macros.macro import macro_variables + + +class Variable: + """Something that the user passed into a macro function as parameter. + + The value should already be parsed and validated, if const=True, according to the + argument_configs of a given Task. + + Examples: + - const string "KEY_A" in `key(KEY_A)` + - non-const string "foo" in `repeat($foo, key(KEY_A))` (`value` is the name here) + - const Macro `key(a)` in `repeat(1, key(a))` + - const int 1 in `repeat(1, key(a)) + """ + + def __init__(self, value: Any, const: bool) -> None: + if not const and not isinstance(value, str): + raise MacroError(f"Variables require a string name, not {value}") + + self.value = value + self.const = const + + if not const: + self.validate_variable_name() + + def get_name(self) -> str: + """If the variable is not const, return its name.""" + assert not self.const + assert isinstance(self.value, str) + return self.value + + def get_value(self) -> Any: + """Get the variables value from the common variable storage process.""" + if self.const: + return self.value + + return macro_variables.get(self.value) + + def set_value(self, value: Any) -> None: + """Set the variables value across all macros.""" + assert not self.const + macro_variables[self.value] = value + + def validate_variable_name(self) -> None: + """Check if this is a legit variable name. + + Because they could clash with language features. If the macro can be + parsed at all due to a problematic choice of a variable name. + + Allowed examples: "foo", "Foo1234_", "_foo_1234" + Not allowed: "1_foo", "foo=blub", "$foo", "foo,1234", "foo()" + """ + if not isinstance(self.value, str) or not re.match( + r"^[A-Za-z_][A-Za-z_0-9]*$", self.value + ): + raise MacroError(msg=f'"{self.value}" is not a legit variable name') + + def __repr__(self) -> str: + return f'' + + def __eq__(self, other) -> bool: + if not isinstance(other, Variable): + return False + + return self.const == other.const and self.value == other.value diff --git a/inputremapper/injection/mapping_handlers/__init__.py b/inputremapper/injection/mapping_handlers/__init__.py new file mode 100644 index 0000000..0006fb4 --- /dev/null +++ b/inputremapper/injection/mapping_handlers/__init__.py @@ -0,0 +1,18 @@ +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . diff --git a/inputremapper/injection/mapping_handlers/abs_to_abs_handler.py b/inputremapper/injection/mapping_handlers/abs_to_abs_handler.py new file mode 100644 index 0000000..836db6c --- /dev/null +++ b/inputremapper/injection/mapping_handlers/abs_to_abs_handler.py @@ -0,0 +1,152 @@ +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + +from typing import Tuple, Optional, Dict, List + +import evdev +from evdev.ecodes import EV_ABS + +from inputremapper import exceptions +from inputremapper.configs.input_config import InputCombination, InputConfig +from inputremapper.configs.mapping import Mapping +from inputremapper.injection.global_uinputs import GlobalUInputs +from inputremapper.injection.mapping_handlers.axis_transform import Transformation +from inputremapper.injection.mapping_handlers.mapping_handler import ( + HandlerEnums, + MappingHandler, +) +from inputremapper.input_event import InputEvent, EventActions +from inputremapper.logging.logger import logger +from inputremapper.utils import get_evdev_constant_name + + +class AbsToAbsHandler(MappingHandler): + """Handler which transforms EV_ABS to EV_ABS events.""" + + _map_axis: InputConfig # the InputConfig for the axis we map + _output_axis: Tuple[int, int] # the (type, code) of the output axis + _transform: Optional[Transformation] + _target_absinfo: evdev.AbsInfo + + def __init__( + self, + combination: InputCombination, + mapping: Mapping, + global_uinputs: GlobalUInputs, + **_, + ) -> None: + super().__init__(combination, mapping, global_uinputs) + + # find the input event we are supposed to map. If the input combination is + # BTN_A + ABS_X + BTN_B, then use the value of ABS_X for the transformation + assert (map_axis := combination.find_analog_input_config(type_=EV_ABS)) + self._map_axis = map_axis + + assert mapping.output_code is not None + assert mapping.output_type == EV_ABS + self._output_axis = (mapping.output_type, mapping.output_code) + + target_uinput = global_uinputs.get_uinput(mapping.target_uinput) + assert target_uinput is not None + abs_capabilities = target_uinput.capabilities(absinfo=True)[EV_ABS] + self._target_absinfo = dict(abs_capabilities)[mapping.output_code] + + self._transform = None + + def __str__(self): + name = get_evdev_constant_name(*self._map_axis.type_and_code) + return ( + f'AbsToAbsHandler for "{name}" {self._map_axis} ' + f"maps {self._map_axis} to: {self.mapping.get_output_name_constant()} " + f"{self.mapping.get_output_type_code()} at " + f"{self.mapping.target_uinput}" + ) + + def __repr__(self): + return f"<{str(self)} at {hex(id(self))}>" + + def get_children(self) -> List[MappingHandler]: + return [] + + def notify( + self, + event: InputEvent, + source: evdev.InputDevice, + suppress: bool = False, + ) -> bool: + if event.input_match_hash != self._map_axis.input_match_hash: + return False + + if EventActions.recenter in event.actions: + self._write(self._scale_to_target(0)) + return True + + if not self._transform: + absinfo = dict(source.capabilities(absinfo=True)[EV_ABS])[event.code] # type: ignore + self._transform = Transformation( + max_=absinfo.max, + min_=absinfo.min, + deadzone=self.mapping.deadzone, + gain=self.mapping.gain, + expo=self.mapping.expo, + ) + + try: + self._write(self._scale_to_target(self._transform(event.value))) + return True + except (exceptions.UinputNotAvailable, exceptions.EventNotHandled): + return False + + def reset(self) -> None: + self._write(self._scale_to_target(0)) + + def _scale_to_target(self, x: float) -> int: + """Scales an x value between -1 and 1 to an integer between + target_absinfo.min and target_absinfo.max + + input values above 1 or below -1 are clamped to the extreme values + """ + factor = (self._target_absinfo.max - self._target_absinfo.min) / 2 + offset = self._target_absinfo.min + factor + y = factor * x + offset + if y > offset: + return int(min(self._target_absinfo.max, y)) + else: + return int(max(self._target_absinfo.min, y)) + + def _write(self, value: int): + """Inject.""" + try: + self.global_uinputs.write( + (*self._output_axis, value), self.mapping.target_uinput + ) + except OverflowError: + # screwed up the calculation of the event value + logger.error("OverflowError (%s, %s, %s)", *self._output_axis, value) + + def needs_wrapping(self) -> bool: + return len(self.input_configs) > 1 + + def set_sub_handler(self, handler: MappingHandler) -> None: + assert False # cannot have a sub-handler + + def wrap_with(self) -> Dict[InputCombination, HandlerEnums]: + if self.needs_wrapping(): + return {InputCombination(self.input_configs): HandlerEnums.axisswitch} + return {} diff --git a/inputremapper/injection/mapping_handlers/abs_to_btn_handler.py b/inputremapper/injection/mapping_handlers/abs_to_btn_handler.py new file mode 100644 index 0000000..b826d3a --- /dev/null +++ b/inputremapper/injection/mapping_handlers/abs_to_btn_handler.py @@ -0,0 +1,151 @@ +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + +from typing import List + +import evdev + +from inputremapper.configs.input_config import InputCombination, InputConfig +from inputremapper.configs.mapping import Mapping +from inputremapper.injection.global_uinputs import GlobalUInputs +from inputremapper.injection.mapping_handlers.abs_util import calculate_trigger_point +from inputremapper.injection.mapping_handlers.mapping_handler import ( + MappingHandler, +) +from inputremapper.input_event import InputEvent, EventActions +from inputremapper.utils import get_evdev_constant_name + + +class AbsToBtnHandler(MappingHandler): + """Handler which transforms an EV_ABS to a button event.""" + + _input_config: InputConfig + _configured_direction_was_pressed: bool + _sub_handler: MappingHandler + + def __init__( + self, + combination: InputCombination, + mapping: Mapping, + global_uinputs: GlobalUInputs, + **_, + ) -> None: + super().__init__(combination, mapping, global_uinputs) + + self._configured_direction_was_pressed = False + self._input_config = combination[0] + assert self._input_config.analog_threshold + assert len(combination) == 1 + + def __str__(self): + name = get_evdev_constant_name(*self._input_config.type_and_code) + return f'AbsToBtnHandler for "{name}" ' f"{self._input_config.type_and_code}" + + def __repr__(self): + return f"<{str(self)} at {hex(id(self))}>" + + def get_children(self) -> List[MappingHandler]: + return [self._sub_handler] + + def notify( + self, + event: InputEvent, + source: evdev.InputDevice, + suppress: bool = False, + ) -> bool: + if event.input_match_hash != self._input_config.input_match_hash: + return False + + analog_threshold = self._input_config.analog_threshold + assert analog_threshold is not None + + threshold, mid_point = calculate_trigger_point( + event, + analog_threshold, + source, + ) + + value = event.value + + direction = 1 if value > mid_point else -1 + + want_positive = analog_threshold > 0 + want_negative = analog_threshold < 0 + + # For dpads, the threshold is 1, but so is the max value. So <= and >= it is. + # If this is dumb, change the threhsold to be a float. + pressed = value >= threshold if want_positive else value <= threshold + + '''print(f"""abs_to_btn + {pressed=} + {value=} + {want_positive=} + {want_negative=} + {direction=} + {threshold=} + {mid_point=} + {analog_threshold=} + {self._configured_direction_was_pressed=}""" + )''' + + # dpad-right to a: + # dpad moves right: a down + # dpad returns: a up + # dpad goes left: dpad -1 + # dpad returns: dpad 0 + # There are two "dpad returns" cases that have different outcomes + + # joystick-right to a: + # joystick moves to +1234: ignore (If the architecture could do it, forward 0) + # joystick moves over threshold: a down + # joystick returns below threshold: a up + # joystick moves -1234: forward -1234 + # joystick goes to 0: forward 0 + # (In many cases it won't exactly return to 0, but to +1 or something, because + # they aren't 100% precise. But the positive direction is mapped, so turn + # this into 0. Unfortunately there is currently no way to do this in our + # architecture.) + + if not self._configured_direction_was_pressed: + # these needs to be <= and >= mid point, to forward the dpad release for + # the unmapped direction + if want_positive and value <= mid_point: + return False + if want_negative and value >= mid_point: + return False + + # if it was pressed, then we first need to deal with releasing the sub-handler. + + self._configured_direction_was_pressed = pressed + + event = event.modify( + pressed=pressed, + direction=direction, + actions=(EventActions.as_key,), + ) + + return self._sub_handler.notify( + event, + source=source, + suppress=suppress, + ) + + def reset(self) -> None: + self._configured_direction_was_pressed = False + self._sub_handler.reset() diff --git a/inputremapper/injection/mapping_handlers/abs_to_rel_handler.py b/inputremapper/injection/mapping_handlers/abs_to_rel_handler.py new file mode 100644 index 0000000..88a2d7e --- /dev/null +++ b/inputremapper/injection/mapping_handlers/abs_to_rel_handler.py @@ -0,0 +1,245 @@ +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + +import asyncio +import math +import time +from functools import partial +from typing import Dict, Tuple, Optional, List + +import evdev +from evdev.ecodes import ( + EV_REL, + EV_ABS, + REL_WHEEL, + REL_HWHEEL, + REL_WHEEL_HI_RES, + REL_HWHEEL_HI_RES, +) + +from inputremapper.configs.input_config import InputCombination, InputConfig +from inputremapper.configs.mapping import ( + Mapping, + REL_XY_SCALING, + WHEEL_SCALING, + WHEEL_HI_RES_SCALING, + DEFAULT_REL_RATE, +) +from inputremapper.injection.global_uinputs import GlobalUInputs +from inputremapper.injection.mapping_handlers.axis_transform import Transformation +from inputremapper.injection.mapping_handlers.mapping_handler import ( + HandlerEnums, + MappingHandler, +) +from inputremapper.input_event import InputEvent, EventActions +from inputremapper.logging.logger import logger +from inputremapper.utils import get_evdev_constant_name + + +class AbsToRelHandler(MappingHandler): + """Handler which transforms an EV_ABS to EV_REL events.""" + + _map_axis: InputConfig # the InputConfig for the axis we map + _value: float # the current output value + _running: bool # if the run method is active + _stop: bool # if the run loop should return + _transform: Optional[Transformation] + + def __init__( + self, + combination: InputCombination, + mapping: Mapping, + global_uinputs: GlobalUInputs, + **_, + ) -> None: + super().__init__(combination, mapping, global_uinputs) + + # find the input event we are supposed to map + assert (map_axis := combination.find_analog_input_config(type_=EV_ABS)) + self._map_axis = map_axis + + self._value = 0 + self._running = False + self._stop = True + self._transform = None + + # bind the correct run method + if self.mapping.output_code in ( + REL_WHEEL, + REL_HWHEEL, + REL_WHEEL_HI_RES, + REL_HWHEEL_HI_RES, + ): + if self.mapping.output_code in (REL_WHEEL, REL_WHEEL_HI_RES): + codes = (REL_WHEEL, REL_WHEEL_HI_RES) + else: + codes = (REL_HWHEEL, REL_HWHEEL_HI_RES) + + self._run = partial(self._run_wheel_output, codes=codes) + + else: + self._run = partial(self._run_normal_output) + + def __str__(self): + name = get_evdev_constant_name(*self._map_axis.type_and_code) + return ( + f'AbsToRelHandler for "{name}" {self._map_axis}: ' + f"maps to {self.mapping.get_output_name_constant()} " + f"{self.mapping.get_output_type_code()} at " + f"{self.mapping.target_uinput}" + ) + + def __repr__(self): + return f"<{str(self)} at {hex(id(self))}>" + + def get_children(self) -> List[MappingHandler]: + return [] + + def notify( + self, + event: InputEvent, + source: evdev.InputDevice, + suppress: bool = False, + ) -> bool: + if event.input_match_hash != self._map_axis.input_match_hash: + return False + + if EventActions.recenter in event.actions: + self._stop = True + return True + + if not self._transform: + absinfo = { + entry[0]: entry[1] # type: ignore + for entry in source.capabilities(absinfo=True)[EV_ABS] + } + self._transform = Transformation( + max_=absinfo[event.code].max, + min_=absinfo[event.code].min, + deadzone=self.mapping.deadzone, + gain=self.mapping.gain, + expo=self.mapping.expo, + ) + + transformed = self._transform(event.value) + + self._value = transformed + + if transformed == 0: + self._stop = True + return True + + if not self._running: + asyncio.ensure_future(self._run()) + return True + + def reset(self) -> None: + self._stop = True + + def _write(self, type_, keycode, value): + """Inject.""" + # if the mouse won't move even though correct stuff is written here, + # the capabilities are probably wrong + if value == 0: + # rel 0 does not make sense. We don't need to tell linux that the mouse + # should not be moved this time. + return + + try: + self.global_uinputs.write( + (type_, keycode, value), + self.mapping.target_uinput, + ) + except OverflowError: + # screwed up the calculation of mouse movements + logger.error("OverflowError (%s, %s, %s)", type_, keycode, value) + + def needs_wrapping(self) -> bool: + return len(self.input_configs) > 1 + + def set_sub_handler(self, handler: MappingHandler) -> None: + assert False # cannot have a sub-handler + + def wrap_with(self) -> Dict[InputCombination, HandlerEnums]: + if self.needs_wrapping(): + return {InputCombination(self.input_configs): HandlerEnums.axisswitch} + return {} + + def _calculate_output(self, value, weight, remainder): + # self._value is between 0 and 1, scale up with weight + scaled = value * weight + remainder + # float_value % 1 will result in wrong calculations for negative values + remainder = math.fmod(scaled, 1) + return int(scaled), remainder + + async def _run_normal_output(self) -> None: + """Start injecting events.""" + self._running = True + self._stop = False + remainder = 0.0 + start = time.time() + + # if the rate is configured to be slower than the default, increase the value, so + # that the overall speed stays the same. + rate_compensation = DEFAULT_REL_RATE / self.mapping.rel_rate + weight = REL_XY_SCALING * rate_compensation + + while not self._stop: + value, remainder = self._calculate_output( + self._value, + weight, + remainder, + ) + + self._write(EV_REL, self.mapping.output_code, value) + + time_taken = time.time() - start + sleep = max(0.0, (1 / self.mapping.rel_rate) - time_taken) + await asyncio.sleep(sleep) + start = time.time() + + self._running = False + + async def _run_wheel_output(self, codes: Tuple[int, int]) -> None: + """Start injecting wheel events. + + made to inject both REL_WHEEL and REL_WHEEL_HI_RES events, because otherwise + wheel output doesn't work for some people. See issue #354 + """ + weights = (WHEEL_SCALING, WHEEL_HI_RES_SCALING) + + self._running = True + self._stop = False + remainder = [0.0, 0.0] + start = time.time() + while not self._stop: + for i in range(len(codes)): + value, remainder[i] = self._calculate_output( + self._value, + weights[i], + remainder[i], + ) + + self._write(EV_REL, codes[i], value) + + time_taken = time.time() - start + await asyncio.sleep(max(0.0, (1 / self.mapping.rel_rate) - time_taken)) + start = time.time() + + self._running = False diff --git a/inputremapper/injection/mapping_handlers/abs_util.py b/inputremapper/injection/mapping_handlers/abs_util.py new file mode 100644 index 0000000..7b674fd --- /dev/null +++ b/inputremapper/injection/mapping_handlers/abs_util.py @@ -0,0 +1,72 @@ +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + +from typing import Tuple + +import evdev +from evdev.ecodes import EV_ABS, ABS_GAS, ABS_BRAKE, ABS_Z, ABS_RZ + +from inputremapper.input_event import InputEvent + + +# TODO: potentially cache this function { cache_key_from_args: previous_result } +def calculate_trigger_point( + event: InputEvent, + analog_threshold: int, + source: evdev.InputDevice, +) -> Tuple[float, float]: + """Calculate the threshold and resting-point of the axis. + + If an EV_ABS events value suprasses the threshold, it should be considered pressed. + + The threshold is the offset from the resting-point/middle in both directions. + + The resting point might be the middle value for a joystick: 0, *128*, 256 or + -128, *0*, 128. Or it might be the minimum value of the shoulder triggers: *0* 256. + """ + absinfo = dict(source.capabilities(absinfo=True)[EV_ABS]) # type: ignore + abs_min = absinfo[event.code].min + abs_max = absinfo[event.code].max + + assert analog_threshold + if abs_min == -1 and abs_max == 1: + # this is a hat switch + # return +-1 + return ( + analog_threshold // abs(analog_threshold), + 0, + ) + + if event.code in [ABS_GAS, ABS_BRAKE, ABS_Z, ABS_RZ]: + threshold = abs_max * analog_threshold / 100 + # For the L/R triggers, there is only one direction, and the resting + # position is the same as the min_abs. + middle = abs_min + return threshold, middle + + half_range = (abs_max - abs_min) / 2 + middle = half_range + abs_min + trigger_offset = half_range * analog_threshold / 100 + # Examples for threshold of +50: + # -128 to 128. half_range is 128. middle is 0. trigger_offset is 64 (and above) + # 0 to 128. half_range is 64. middle is 64. trigger_offset is 96 (and above) + + # threshold, middle + threshold = middle + trigger_offset + return threshold, middle diff --git a/inputremapper/injection/mapping_handlers/axis_switch_handler.py b/inputremapper/injection/mapping_handlers/axis_switch_handler.py new file mode 100644 index 0000000..a2ac18f --- /dev/null +++ b/inputremapper/injection/mapping_handlers/axis_switch_handler.py @@ -0,0 +1,190 @@ +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + +from typing import Dict, Tuple, Hashable, List + +import evdev + +from inputremapper.configs.input_config import InputCombination +from inputremapper.configs.input_config import InputConfig +from inputremapper.configs.mapping import Mapping +from inputremapper.injection.global_uinputs import GlobalUInputs +from inputremapper.injection.mapping_handlers.mapping_handler import ( + HandlerEnums, + MappingHandler, + ContextProtocol, +) +from inputremapper.input_event import InputEvent, EventActions +from inputremapper.logging.logger import logger +from inputremapper.utils import get_device_hash + + +class AxisSwitchHandler(MappingHandler): + """Enables or disables an axis. + + This is used when a combination involving an analog input (rel or abs) is mapped + to another analog output (rel or abs). I think. + + Generally, if multiple events are mapped to something in a combination, all of + them need to be triggered in order to map to the output. + + If an analog input is combined with a key input, then the same thing should happen. + The key needs to be pressed and the joystick needs to be moved in order to generate + output. + """ + + _map_axis: InputConfig # the InputConfig for the axis we switch on or off + _trigger_keys: Tuple[Hashable, ...] # all events that can switch the axis + _active: bool # whether the axis is on or off + _last_value: int # the value of the last axis event that arrived + _axis_source: evdev.InputDevice # the cached source of the axis input events + _forward_device: evdev.UInput # the cached forward uinput + _sub_handler: MappingHandler + + def __init__( + self, + combination: InputCombination, + mapping: Mapping, + context: ContextProtocol, + global_uinputs: GlobalUInputs, + **_, + ): + super().__init__(combination, mapping, global_uinputs) + trigger_keys = tuple( + event.input_match_hash + for event in combination + if not event.defines_analog_input + ) + assert len(trigger_keys) >= 1 + assert (map_axis := combination.find_analog_input_config()) + self._map_axis = map_axis + self._trigger_keys = trigger_keys + self._active = False + + self._last_value = 0 + self._axis_source = None + self._forward_device = None + + self.context = context + + def __str__(self): + return f"AxisSwitchHandler for {self._map_axis.type_and_code}" + + def __repr__(self): + return f"<{str(self)} at {hex(id(self))}>" + + def get_children(self) -> List[MappingHandler]: + return [self._sub_handler] + + def _handle_key_input(self, event: InputEvent): + """If a key is pressed, allow mapping analog events in subhandlers. + + Analog events (e.g. ABS_X, REL_Y) that have gone through Handlers that + transform them to buttons also count as keys. + """ + key_is_pressed = event.is_pressed() + if self._active == key_is_pressed: + # nothing changed + return False + + self._active = key_is_pressed + + if self._axis_source is None: + return True + + if not key_is_pressed: + # recenter the axis + logger.debug("Stopping axis for %s", self.mapping.input_combination) + event = InputEvent( + 0, + 0, + *self._map_axis.type_and_code, + 0, + actions=(EventActions.recenter,), + origin_hash=self._map_axis.origin_hash, + ) + self._sub_handler.notify(event, self._axis_source) + return True + + if self._map_axis.type == evdev.ecodes.EV_ABS: + # send the last cached value so that the abs axis + # is at the correct position + logger.debug("Starting axis for %s", self.mapping.input_combination) + event = InputEvent( + 0, + 0, + *self._map_axis.type_and_code, + self._last_value, + origin_hash=self._map_axis.origin_hash, + ) + self._sub_handler.notify(event, self._axis_source) + return True + + return True + + def _should_map(self, event: InputEvent): + return ( + event.input_match_hash in self._trigger_keys + or event.input_match_hash == self._map_axis.input_match_hash + ) + + def notify( + self, + event: InputEvent, + source: evdev.InputDevice, + suppress: bool = False, + ) -> bool: + if not self._should_map(event): + return False + + if event.is_key_event: + # A key or an analog even that is being treated as a key (on/off) due to + # previous handlers. + return self._handle_key_input(event) + + # do some caching so that we can generate the + # recenter event and an initial abs event + if self._axis_source is None: + self._axis_source = source + + if self._forward_device is None: + device_hash = get_device_hash(source) + self._forward_device = self.context.get_forward_uinput(device_hash) + + # always cache the value + self._last_value = event.value + + if self._active: + return self._sub_handler.notify(event, source, suppress) + + return False + + def reset(self) -> None: + self._last_value = 0 + self._active = False + self._sub_handler.reset() + + def needs_wrapping(self) -> bool: + return True + + def wrap_with(self) -> Dict[InputCombination, HandlerEnums]: + combination = [ + config for config in self.input_configs if not config.defines_analog_input + ] + return {InputCombination(combination): HandlerEnums.combination} diff --git a/inputremapper/injection/mapping_handlers/axis_transform.py b/inputremapper/injection/mapping_handlers/axis_transform.py new file mode 100644 index 0000000..fb5f8e3 --- /dev/null +++ b/inputremapper/injection/mapping_handlers/axis_transform.py @@ -0,0 +1,140 @@ +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + +import math +from typing import Dict, Union + + +class Transformation: + """Callable that returns the axis transformation at x.""" + + def __init__( + self, + # if input values are > max_, the return value will be > 1 + max_: Union[int, float], + min_: Union[int, float], + deadzone: float, + gain: float = 1, + expo: float = 0, + ) -> None: + self._max = max_ + self._min = min_ + self._deadzone = deadzone + self._gain = gain + self._expo = expo + self._cache: Dict[float, float] = {} + + def __call__(self, /, x: Union[int, float]) -> float: + if x not in self._cache: + y = ( + self._calc_qubic(self._flatten_deadzone(self._normalize(x))) + * self._gain + ) + self._cache[x] = y + + return self._cache[x] + + def set_range(self, min_, max_): + # TODO docstring + if min_ != self._min or max_ != self._max: + self._cache = {} + + self._min = min_ + self._max = max_ + + def _normalize(self, x: Union[int, float]) -> float: + """Move and scale x to be between -1 and 1 + return: x + """ + if self._min == -1 and self._max == 1: + return x + + half_range = (self._max - self._min) / 2 + middle = half_range + self._min + return (x - middle) / half_range + + def _flatten_deadzone(self, x: float) -> float: + """ + y ^ y ^ + | | + 1 | / 1 | / + | / | / + | / ==> | --- + | / | / + -1 | / -1 | / + |------------> |------------> + -1 1 x -1 1 x + """ + if abs(x) <= self._deadzone: + return 0 + + return (x - self._deadzone * x / abs(x)) / (1 - self._deadzone) + + def _calc_qubic(self, x: float) -> float: + """Transforms an x value by applying a qubic function + + k = 0 : will yield no transformation f(x) = x + 1 > k > 0 : will yield low sensitivity for low x values + and high sensitivity for high x values + -1 < k < 0 : will yield high sensitivity for low x values + and low sensitivity for high x values + + see also: https://www.geogebra.org/calculator/mkdqueky + + Mathematical definition: + f(x,d) = d * x + (1 - d) * x ** 3 | d = 1 - k | k ∈ [0,1] + the function is designed such that if follows these constraints: + f'(0, d) = d and f(1, d) = 1 and f(-x,d) = -f(x,d) + + for k ∈ [-1,0) the above function is mirrored at y = x + and d = 1 + k + """ + k = self._expo + + if k == 0 or x == 0: + return x + + if 0 < k <= 1: + d = 1 - k + return d * x + (1 - d) * x**3 + + if -1 <= k < 0: + # calculate return value with the real inverse solution + # of y = b * x + a * x ** 3 + # LaTeX for better readability: + # + # y=\frac{{{\left( \sqrt{27 {{x}^{2}}+\frac{4 {{b}^{3}}}{a}} + # +{{3}^{\frac{3}{2}}} x\right) }^{\frac{1}{3}}}} + # {{{2}^{\frac{1}{3}}} \sqrt{3} {{a}^{\frac{1}{3}}}} + # -\frac{{{2}^{\frac{1}{3}}} b} + # {\sqrt{3} {{a}^{\frac{2}{3}}} + # {{\left( \sqrt{27 {{x}^{2}}+\frac{4 {{b}^{3}}}{a}} + # +{{3}^{\frac{3}{2}}} x\right) }^{\frac{1}{3}}}} + sign = x / abs(x) + x = math.fabs(x) + d = 1 + k + a = 1 - d + b = d + c = (math.sqrt(27 * x**2 + (4 * b**3) / a) + 3 ** (3 / 2) * x) ** (1 / 3) + y = c / (2 ** (1 / 3) * math.sqrt(3) * a ** (1 / 3)) - ( + 2 ** (1 / 3) * b + ) / (math.sqrt(3) * a ** (2 / 3) * c) + return y * sign + + raise ValueError("k must be between -1 and 1") diff --git a/inputremapper/injection/mapping_handlers/combination_handler.py b/inputremapper/injection/mapping_handlers/combination_handler.py new file mode 100644 index 0000000..fe21de4 --- /dev/null +++ b/inputremapper/injection/mapping_handlers/combination_handler.py @@ -0,0 +1,275 @@ +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + +from __future__ import annotations # needed for the TYPE_CHECKING import + +from typing import TYPE_CHECKING, Dict, Hashable, Tuple, List + +import evdev +from evdev.ecodes import EV_ABS, EV_REL + +from inputremapper.configs.input_config import InputCombination +from inputremapper.configs.mapping import Mapping +from inputremapper.injection.global_uinputs import GlobalUInputs +from inputremapper.injection.mapping_handlers.mapping_handler import ( + MappingHandler, + HandlerEnums, +) +from inputremapper.input_event import InputEvent +from inputremapper.logging.logger import logger + +if TYPE_CHECKING: + from inputremapper.injection.context import Context + + +class CombinationHandler(MappingHandler): + """Keeps track of a combination and notifies a sub handler.""" + + # map of InputEvent.input_match_hash -> bool , keep track of the combination state + _pressed_keys: Dict[Hashable, bool] + # the last update we sent to a sub-handler. If this is true, the output key is + # still being held down. + _output_previously_active: bool + _sub_handler: MappingHandler + _handled_input_hashes: list[Hashable] + _requires_a_release: Dict[Tuple[int, int], bool] + + def __init__( + self, + combination: InputCombination, + mapping: Mapping, + context: Context, + global_uinputs: GlobalUInputs, + **_, + ) -> None: + logger.debug(str(mapping)) + super().__init__(combination, mapping, global_uinputs) + self._pressed_keys = {} + self._output_previously_active = False + self._context = context + self._requires_a_release = {} + + # prepare a key map for all events with non-zero value + for input_config in combination: + assert not input_config.defines_analog_input + self._pressed_keys[input_config.input_match_hash] = False + + self._handled_input_hashes = [ + input_config.input_match_hash for input_config in combination + ] + + assert len(self._pressed_keys) > 0 # no combination handler without a key + + def __str__(self): + return ( + f'CombinationHandler for "{str(self.mapping.input_combination)}" ' + f"{tuple(t for t in self._pressed_keys.keys())}" + ) + + def __repr__(self): + description = ( + f'CombinationHandler for "{repr(self.mapping.input_combination)}" ' + f"{tuple(t for t in self._pressed_keys.keys())}" + ) + return f"<{description} at {hex(id(self))}>" + + def get_children(self) -> List[MappingHandler]: + return [self._sub_handler] + + def notify( + self, + event: InputEvent, + source: evdev.InputDevice, + suppress: bool = False, + ) -> bool: + if event.input_match_hash not in self._handled_input_hashes: + # we are not responsible for the event + return False + + # update the state + # The value of non-key input should have been changed to either 0 or 1 at this + # point by other handlers. + is_pressed = event.is_pressed() + self._pressed_keys[event.input_match_hash] = is_pressed + # maybe this changes the activation status (triggered/not-triggered) + changed = self._is_activated() != self._output_previously_active + + if changed: + if is_pressed: + return self._handle_freshly_activated(suppress, event, source) + else: + return self._handle_freshly_deactivated(event, source) + else: + if is_pressed: + return self._handle_no_change_press(event) + else: + return self._handle_no_change_release(event) + + def _handle_no_change_press(self, event: InputEvent) -> bool: + """A key was pressed, but this doesn't change the combinations activation state. + Can only happen if either the combination wasn't already active, or a duplicate + key-down event arrived (EV_ABS?) + """ + # self._output_previously_active is negated, because if the output is active, a + # key-down event triggered it, which then did not get forwarded, therefore + # it doesn't require a release. + self._require_release_later(not self._output_previously_active, event) + # output is active: consume the event + # output inactive: forward the event + return self._output_previously_active + + def _handle_no_change_release(self, event: InputEvent) -> bool: + """One of the combinations keys was released, but it didn't untrigger the + combination yet.""" + # Negate: `False` means that the event-reader will forward the release. + return not self._should_release_event(event) + + def _handle_freshly_activated( + self, + suppress: bool, + event: InputEvent, + source: evdev.InputDevice, + ) -> bool: + """The combination was deactivated, but is activated now.""" + if suppress: + return False + + # Send key up events to the forwarded uinput if configured to do so. + self._forward_release() + + logger.debug( + "Sending %s to sub-handler %s", + repr(event), + repr(self._sub_handler), + ) + self._output_previously_active = event.is_pressed() + sub_handler_result = self._sub_handler.notify(event, source, suppress) + + # Only if the sub-handler return False, we need a release-event later. + # If it handled the event, the user never sees this key-down event. + self._require_release_later(not sub_handler_result, event) + return sub_handler_result + + def _handle_freshly_deactivated( + self, + event: InputEvent, + source: evdev.InputDevice, + ) -> bool: + """The combination was activated, but is deactivated now.""" + # We ignore the `suppress` argument for release events. Otherwise, we + # might end up with stuck keys (test_event_pipeline.test_combination). + # In the case of output axis, this will enable us to activate multiple + # axis with the same button. + + logger.debug( + "Sending %s to sub-handler %s", + repr(event), + repr(self._sub_handler), + ) + self._output_previously_active = event.is_pressed() + self._sub_handler.notify(event, source, suppress=False) + + # Negate: `False` means that the event-reader will forward the release. + return not self._should_release_event(event) + + def _should_release_event(self, event: InputEvent) -> bool: + """Check if the key-up event should be forwarded by the event-reader. + + After this, the release event needs to be injected by someone, otherwise the + dictionary was modified erroneously. If there is no entry, we assume that there + was no key-down event to release. Maybe a duplicate event arrived. + """ + # Ensure that all injected key-down events will get their release event + # injected eventually. + # If a key-up event arrives that will inactivate the combination, but + # for which previously a key-down event was injected (because it was + # an earlier key in the combination chain), then we need to ensure that its + # release is injected as well. So we get two release events in that case: + # one for the key, and one for the output. + assert event.is_pressed() == 0, f"expected {event.is_pressed()} to be 0" + return self._requires_a_release.pop(event.type_and_code, False) + + def _require_release_later(self, require: bool, event: InputEvent) -> None: + """Remember if this key-down event will need a release event later on.""" + assert event.is_pressed() == 1 + self._requires_a_release[event.type_and_code] = require + + def reset(self) -> None: + self._sub_handler.reset() + for key in self._pressed_keys: + self._pressed_keys[key] = False + self._requires_a_release = {} + self._output_previously_active = False + + def _is_activated(self) -> bool: + """Return if all keys in the keymap are set to True.""" + return False not in self._pressed_keys.values() + + def _forward_release(self) -> None: + """Forward a button release for all keys if this is a combination. + + This might cause duplicate key-up events but those are ignored by evdev anyway + """ + if len(self._pressed_keys) == 1 or not self.mapping.release_combination_keys: + return + + keys_to_release = filter( + lambda cfg: self._pressed_keys.get(cfg.input_match_hash), + self.mapping.input_combination, + ) + + logger.debug("Forwarding release for %s", self.mapping.input_combination) + + for input_config in keys_to_release: + if not self._requires_a_release.get(input_config.type_and_code): + continue + + origin_hash = input_config.origin_hash + if origin_hash is None: + logger.error( + f"Can't forward due to missing origin_hash in {repr(input_config)}" + ) + continue + + forward_to = self._context.get_forward_uinput(origin_hash) + logger.write(input_config, forward_to) + forward_to.write(*input_config.type_and_code, 0) + forward_to.syn() + + # We are done with this key, forget about it + del self._requires_a_release[input_config.type_and_code] + + def needs_ranking(self) -> bool: + return bool(self.input_configs) + + def rank_by(self) -> InputCombination: + return InputCombination( + [event for event in self.input_configs if not event.defines_analog_input] + ) + + def wrap_with(self) -> Dict[InputCombination, HandlerEnums]: + return_dict = {} + for config in self.input_configs: + if config.type == EV_ABS and not config.defines_analog_input: + return_dict[InputCombination([config])] = HandlerEnums.abs2btn + + if config.type == EV_REL and not config.defines_analog_input: + return_dict[InputCombination([config])] = HandlerEnums.rel2btn + + return return_dict diff --git a/inputremapper/injection/mapping_handlers/hierarchy_handler.py b/inputremapper/injection/mapping_handlers/hierarchy_handler.py new file mode 100644 index 0000000..a53f22a --- /dev/null +++ b/inputremapper/injection/mapping_handlers/hierarchy_handler.py @@ -0,0 +1,108 @@ +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + +from typing import List, Dict + +import evdev +from evdev.ecodes import EV_ABS, EV_REL + +from inputremapper.configs.input_config import InputCombination, InputConfig +from inputremapper.injection.global_uinputs import GlobalUInputs +from inputremapper.injection.mapping_handlers.mapping_handler import ( + MappingHandler, + HandlerEnums, +) +from inputremapper.input_event import InputEvent + + +class HierarchyHandler(MappingHandler): + """Handler consisting of an ordered list of MappingHandler + + only the first handler which successfully handles the event will execute it, + all other handlers will be notified, but suppressed + """ + + _input_config: InputConfig + + def __init__( + self, + handlers: List[MappingHandler], + input_config: InputConfig, + global_uinputs: GlobalUInputs, + ) -> None: + self.handlers = handlers + self._input_config = input_config + combination = InputCombination([input_config]) + # use the mapping from the first child TODO: find a better solution + mapping = handlers[0].mapping + super().__init__(combination, mapping, global_uinputs) + + def __str__(self): + return f"HierarchyHandler for {self._input_config}" + + def __repr__(self): + return f"<{str(self)} at {hex(id(self))}>" + + def get_children(self) -> List[MappingHandler]: + return self.handlers + + def notify( + self, + event: InputEvent, + source: evdev.InputDevice, + suppress: bool = False, + ) -> bool: + if event.input_match_hash != self._input_config.input_match_hash: + return False + + handled = False + for handler in self.handlers: + if handled: + # To allow an arbitrary number of output axes to be activated at the + # same time, we don't suppress them. + handler.notify( + event, + source, + suppress=not handler.mapping.input_combination.defines_analog_input, + ) + continue + + handled = handler.notify(event, source) + + return handled + + def reset(self) -> None: + for sub_handler in self.handlers: + sub_handler.reset() + + def wrap_with(self) -> Dict[InputCombination, HandlerEnums]: + if ( + self._input_config.type == EV_ABS + and not self._input_config.defines_analog_input + ): + return {InputCombination([self._input_config]): HandlerEnums.abs2btn} + if ( + self._input_config.type == EV_REL + and not self._input_config.defines_analog_input + ): + return {InputCombination([self._input_config]): HandlerEnums.rel2btn} + return {} + + def set_sub_handler(self, handler: MappingHandler) -> None: + assert False diff --git a/inputremapper/injection/mapping_handlers/key_handler.py b/inputremapper/injection/mapping_handlers/key_handler.py new file mode 100644 index 0000000..fa64436 --- /dev/null +++ b/inputremapper/injection/mapping_handlers/key_handler.py @@ -0,0 +1,90 @@ +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + +from typing import Tuple, Dict, List + +from inputremapper import exceptions +from inputremapper.configs.input_config import InputCombination +from inputremapper.configs.mapping import Mapping +from inputremapper.exceptions import MappingParsingError +from inputremapper.injection.global_uinputs import GlobalUInputs +from inputremapper.injection.mapping_handlers.mapping_handler import ( + HandlerEnums, + MappingHandler, +) +from inputremapper.input_event import InputEvent +from inputremapper.logging.logger import logger +from inputremapper.utils import get_evdev_constant_name + + +class KeyHandler(MappingHandler): + """Injects the target key if notified.""" + + _active: bool + _maps_to: Tuple[int, int] + + def __init__( + self, + combination: InputCombination, + mapping: Mapping, + global_uinputs: GlobalUInputs, + **_, + ): + super().__init__(combination, mapping, global_uinputs) + maps_to = mapping.get_output_type_code() + if not maps_to: + raise MappingParsingError( + "Unable to create key handler from mapping", mapping=mapping + ) + + self._maps_to = maps_to + self._active = False + + def __str__(self): + name = get_evdev_constant_name(*self._maps_to) + return f"KeyHandler to {name} {self._maps_to} on {self.mapping.target_uinput}" + + def __repr__(self): + return f"<{str(self)} at {hex(id(self))}>" + + def get_children(self) -> List[MappingHandler]: + return [] + + def notify(self, event: InputEvent, *_, **__) -> bool: + """Inject the correct value to the target uinput.""" + event_tuple = (*self._maps_to, 1 if event.is_pressed() else 0) + try: + self.global_uinputs.write(event_tuple, self.mapping.target_uinput) + self._active = bool(event.is_pressed()) + return True + except exceptions.Error: + return False + + def reset(self) -> None: + logger.debug("resetting key_handler") + if self._active: + event_tuple = (*self._maps_to, 0) + self.global_uinputs.write(event_tuple, self.mapping.target_uinput) + self._active = False + + def needs_wrapping(self) -> bool: + return True + + def wrap_with(self) -> Dict[InputCombination, HandlerEnums]: + return {InputCombination(self.input_configs): HandlerEnums.combination} diff --git a/inputremapper/injection/mapping_handlers/macro_handler.py b/inputremapper/injection/mapping_handlers/macro_handler.py new file mode 100644 index 0000000..38ed4f3 --- /dev/null +++ b/inputremapper/injection/mapping_handlers/macro_handler.py @@ -0,0 +1,121 @@ +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + +import asyncio +import traceback +from typing import Dict, Callable, Tuple, List + +from inputremapper.configs.input_config import InputCombination +from inputremapper.configs.mapping import Mapping +from inputremapper.injection.global_uinputs import GlobalUInputs +from inputremapper.injection.macros.macro import Macro +from inputremapper.injection.macros.parse import Parser +from inputremapper.injection.mapping_handlers.mapping_handler import ( + ContextProtocol, + HandlerEnums, + MappingHandler, +) +from inputremapper.input_event import InputEvent +from inputremapper.logging.logger import logger + + +class MacroHandler(MappingHandler): + """Runs the target macro if notified.""" + + # TODO: replace this by the macro itself + _macro: Macro + _active: bool + + def __init__( + self, + combination: InputCombination, + mapping: Mapping, + global_uinputs: GlobalUInputs, + *, + context: ContextProtocol, + ): + super().__init__(combination, mapping, global_uinputs) + self._pressed_keys: Dict[Tuple[int, int], int] = {} + self._active = False + assert self.mapping.output_symbol is not None + self._macro = Parser.parse(self.mapping.output_symbol, context, mapping) + + def __str__(self): + return f"MacroHandler maps to {self._macro} on {self.mapping.target_uinput}" + + def __repr__(self): + return f"<{str(self)} at {hex(id(self))}>" + + def get_children(self) -> List[MappingHandler]: + return [] + + async def run_macro(self, handler: Callable): + """Run the macro with the provided function.""" + try: + await self._macro.run(handler) + except Exception as exception: + logger.error('Macro "%s" failed with %s', self._macro.code, type(exception)) + traceback.print_exc() + + def notify(self, event: InputEvent, *_, **__) -> bool: + if event.is_pressed(): + self._active = True + self._macro.press_trigger() + if self._macro.running: + return True + + def handler(type_, code, value) -> None: + """Handler for macros.""" + self._remember_pressed_keys((type_, code, value)) + + self.global_uinputs.write( + (type_, code, value), + self.mapping.target_uinput, + ) + + asyncio.ensure_future(self.run_macro(handler)) + return True + else: + self._active = False + self._macro.release_trigger() + + return True + + def reset(self) -> None: + self._active = False + + # To avoid a key hanging forever. Can be pretty annoying, especially if it is + # a modifier that makes you unable to interact with your system. + for (type, code), value in self._pressed_keys.items(): + if value == 1: + logger.debug("Releasing key %s", (type, code, value)) + self.global_uinputs.write( + (type, code, 0), + self.mapping.target_uinput, + ) + + def needs_wrapping(self) -> bool: + return True + + def wrap_with(self) -> Dict[InputCombination, HandlerEnums]: + return {InputCombination(self.input_configs): HandlerEnums.combination} + + def _remember_pressed_keys(self, event: Tuple[int, int, int]) -> None: + type, code, value = event + self._pressed_keys[(type, code)] = value diff --git a/inputremapper/injection/mapping_handlers/mapping_handler.py b/inputremapper/injection/mapping_handlers/mapping_handler.py new file mode 100644 index 0000000..e9d5306 --- /dev/null +++ b/inputremapper/injection/mapping_handlers/mapping_handler.py @@ -0,0 +1,213 @@ +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . +"""Provides protocols for mapping handlers + +*** The architecture behind mapping handlers *** + +Handling an InputEvent is done in 3 steps: + 1. Input Event Handling + A MappingHandler that does Input event handling receives Input Events directly + from the EventReader. + To do so it must implement the MappingHandler protocol. + An MappingHandler may handle multiple events (InputEvent.type_and_code) + + 2. Event Transformation + The event gets transformed as described by the mapping. + e.g.: combining multiple events to a single one + transforming EV_ABS to EV_REL + macros + ... + Multiple transformations may get chained + + 3. Event Injection + The transformed event gets injected to a global_uinput + +MappingHandlers can implement one or more of these steps. + +Overview of implemented handlers and the steps they implement: + +Step 1: + - HierarchyHandler + +Step 1 and 2: + - CombinationHandler + - AbsToBtnHandler + - RelToBtnHandler + +Step 1, 2 and 3: + - AbsToRelHandler + - NullHandler + +Step 2 and 3: + - KeyHandler + - MacroHandler +""" +from __future__ import annotations + +import enum +from typing import Dict, Protocol, Set, Optional, List + +import evdev + +from inputremapper.configs.input_config import InputCombination, InputConfig +from inputremapper.configs.mapping import Mapping +from inputremapper.exceptions import MappingParsingError +from inputremapper.injection.global_uinputs import GlobalUInputs +from inputremapper.input_event import InputEvent +from inputremapper.logging.logger import logger + + +class EventListener(Protocol): + async def __call__(self, event: evdev.InputEvent) -> None: ... + + +class ContextProtocol(Protocol): + """The parts from context needed for handlers.""" + + listeners: Set[EventListener] + + def get_forward_uinput(self, origin_hash) -> evdev.UInput: + pass + + +class NotifyCallback(Protocol): + """Type signature of MappingHandler.notify + + return True if the event was actually taken care of + """ + + def __call__( + self, + event: InputEvent, + source: evdev.InputDevice, + suppress: bool = False, + ) -> bool: ... + + +class HandlerEnums(enum.Enum): + # converting to btn + abs2btn = enum.auto() + rel2btn = enum.auto() + + macro = enum.auto() + key = enum.auto() + + # converting to "analog" + btn2rel = enum.auto() + rel2rel = enum.auto() + abs2rel = enum.auto() + + btn2abs = enum.auto() + rel2abs = enum.auto() + abs2abs = enum.auto() + + # special handlers + combination = enum.auto() + hierarchy = enum.auto() + axisswitch = enum.auto() + disable = enum.auto() + + +class MappingHandler: + mapping: Mapping + # all input events this handler cares about + # should always be a subset of mapping.input_combination + input_configs: List[InputConfig] + _sub_handler: Optional[MappingHandler] + + # https://bugs.python.org/issue44807 + def __init__( + self, + combination: InputCombination, + mapping: Mapping, + global_uinputs: GlobalUInputs, + **_, + ) -> None: + """Initialize the handler + + Parameters + ---------- + combination + the combination from sub_handler.wrap_with() + mapping + """ + self.mapping = mapping + self.input_configs = list(combination) + self._sub_handler = None + self.global_uinputs = global_uinputs + + def notify( + self, + event: InputEvent, + source: evdev.InputDevice, + suppress: bool = False, + ) -> bool: + """Notify this handler about an incoming event. + + Parameters + ---------- + event + The newest event that came from `source`, and that should be mapped to + something else + source + Where `event` comes from + """ + raise NotImplementedError + + def reset(self) -> None: + """Reset the state of the handler e.g. release any buttons.""" + raise NotImplementedError + + def needs_wrapping(self) -> bool: + """If this handler needs to be wrapped in another MappingHandler.""" + return len(self.wrap_with()) > 0 + + def needs_ranking(self) -> bool: + """If this handler needs ranking and wrapping with a HierarchyHandler.""" + return False + + def rank_by(self) -> Optional[InputCombination]: + """The combination for which this handler needs ranking.""" + + def wrap_with(self) -> Dict[InputCombination, HandlerEnums]: + """A dict of InputCombination -> HandlerEnums. + + for each InputCombination this handler should be wrapped + with the given MappingHandler. + """ + return {} + + def set_sub_handler(self, handler: MappingHandler) -> None: + """Give this handler a sub_handler.""" + self._sub_handler = handler + + def occlude_input_event(self, input_config: InputConfig) -> None: + """Remove the config from self.input_configs.""" + if not self.input_configs: + logger.debug_mapping_handler(self) + raise MappingParsingError( + "Cannot remove a non existing config", mapping_handler=self + ) + + # should be called for each event a wrapping-handler + # has in its input_configs InputCombination + self.input_configs.remove(input_config) + + def get_children(self) -> List[MappingHandler]: + return [] diff --git a/inputremapper/injection/mapping_handlers/mapping_parser.py b/inputremapper/injection/mapping_handlers/mapping_parser.py new file mode 100644 index 0000000..59a4501 --- /dev/null +++ b/inputremapper/injection/mapping_handlers/mapping_parser.py @@ -0,0 +1,360 @@ +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + +"""Functions to assemble the mapping handler tree.""" + +from collections import defaultdict +from typing import Dict, List, Type, Optional, Set, Iterable, Sized, Tuple, Sequence + +from evdev.ecodes import EV_KEY, EV_ABS, EV_REL + +from inputremapper.configs.input_config import InputCombination, InputConfig +from inputremapper.configs.keyboard_layout import DISABLE_CODE, DISABLE_NAME +from inputremapper.configs.mapping import Mapping +from inputremapper.configs.preset import Preset +from inputremapper.exceptions import MappingParsingError +from inputremapper.injection.global_uinputs import GlobalUInputs +from inputremapper.injection.macros.parse import Parser +from inputremapper.injection.mapping_handlers.abs_to_abs_handler import AbsToAbsHandler +from inputremapper.injection.mapping_handlers.abs_to_btn_handler import AbsToBtnHandler +from inputremapper.injection.mapping_handlers.abs_to_rel_handler import AbsToRelHandler +from inputremapper.injection.mapping_handlers.axis_switch_handler import ( + AxisSwitchHandler, +) +from inputremapper.injection.mapping_handlers.combination_handler import ( + CombinationHandler, +) +from inputremapper.injection.mapping_handlers.hierarchy_handler import HierarchyHandler +from inputremapper.injection.mapping_handlers.key_handler import KeyHandler +from inputremapper.injection.mapping_handlers.macro_handler import MacroHandler +from inputremapper.injection.mapping_handlers.mapping_handler import ( + HandlerEnums, + ContextProtocol, + MappingHandler, +) +from inputremapper.injection.mapping_handlers.null_handler import NullHandler +from inputremapper.injection.mapping_handlers.rel_to_abs_handler import RelToAbsHandler +from inputremapper.injection.mapping_handlers.rel_to_btn_handler import RelToBtnHandler +from inputremapper.injection.mapping_handlers.rel_to_rel_handler import RelToRelHandler +from inputremapper.logging.logger import logger +from inputremapper.utils import get_evdev_constant_name + +EventPipelines = Dict[InputConfig, Set[MappingHandler]] + +mapping_handler_classes: Dict[HandlerEnums, Optional[Type[MappingHandler]]] = { + # all available mapping_handlers + HandlerEnums.abs2btn: AbsToBtnHandler, + HandlerEnums.rel2btn: RelToBtnHandler, + HandlerEnums.macro: MacroHandler, + HandlerEnums.key: KeyHandler, + HandlerEnums.btn2rel: None, # can be a macro + HandlerEnums.rel2rel: RelToRelHandler, + HandlerEnums.abs2rel: AbsToRelHandler, + HandlerEnums.btn2abs: None, # can be a macro + HandlerEnums.rel2abs: RelToAbsHandler, + HandlerEnums.abs2abs: AbsToAbsHandler, + HandlerEnums.combination: CombinationHandler, + HandlerEnums.hierarchy: HierarchyHandler, + HandlerEnums.axisswitch: AxisSwitchHandler, + HandlerEnums.disable: NullHandler, +} + + +class MappingParser: + def __init__( + self, + global_uinputs: GlobalUInputs, + ) -> None: + self.global_uinputs = global_uinputs + + def parse_mappings( + self, + preset: Preset, + context: ContextProtocol, + ) -> EventPipelines: + """Create a dict with a list of MappingHandler for each InputEvent.""" + handlers = [] + for mapping in preset: + # start with the last handler in the chain, each mapping only has one output, + # but may have multiple inputs, therefore the last handler is a good starting + # point to assemble the pipeline + handler_enum = self._get_output_handler(mapping) + constructor = mapping_handler_classes[handler_enum] + if not constructor: + logger.warning( + "a mapping handler '%s' for %s is not implemented", + handler_enum, + mapping.format_name(), + ) + continue + + output_handler = constructor( + mapping.input_combination, + mapping, + context=context, + global_uinputs=self.global_uinputs, + ) + + # layer other handlers on top until the outer handler needs ranking or can + # directly handle a input event + handlers.extend(self._create_event_pipeline(output_handler, context)) + + # figure out which handlers need ranking and wrap them with hierarchy_handlers + need_ranking = defaultdict(set) + for handler in handlers.copy(): + if handler.needs_ranking(): + combination = handler.rank_by() + if not combination: + raise MappingParsingError( + f"{type(handler).__name__} claims to need ranking but does not " + f"return a combination to rank by", + mapping_handler=handler, + ) + + need_ranking[combination].add(handler) + handlers.remove(handler) + + # the HierarchyHandler's might not be the starting point of the event pipeline, + # layer other handlers on top again. + ranked_handlers = self._create_hierarchy_handlers(need_ranking) + for handler in ranked_handlers: + handlers.extend( + self._create_event_pipeline(handler, context, ignore_ranking=True) + ) + + # group all handlers by the input events they take care of. One handler might end + # up in multiple groups if it takes care of multiple InputEvents + event_pipelines: EventPipelines = defaultdict(set) + for handler in handlers: + assert handler.input_configs + for input_config in handler.input_configs: + logger.debug( + "event-pipeline with entry point: %s %s", + get_evdev_constant_name(*input_config.type_and_code), + input_config.input_match_hash, + ) + logger.debug_mapping_handler(handler) + event_pipelines[input_config].add(handler) + + return event_pipelines + + def _create_event_pipeline( + self, + handler: MappingHandler, + context: ContextProtocol, + ignore_ranking=False, + ) -> List[MappingHandler]: + """Recursively wrap a handler with other handlers until the + outer handler needs ranking or is finished wrapping. + """ + if not handler.needs_wrapping() or ( + handler.needs_ranking() and not ignore_ranking + ): + return [handler] + + handlers = [] + for combination, handler_enum in handler.wrap_with().items(): + constructor = mapping_handler_classes[handler_enum] + if not constructor: + raise NotImplementedError( + f"mapping handler {handler_enum} is not implemented" + ) + + super_handler = constructor( + combination, + handler.mapping, + context=context, + global_uinputs=self.global_uinputs, + ) + super_handler.set_sub_handler(handler) + for event in combination: + # the handler now has a super_handler which takes care about the events. + # so we need to hide them on the handler + handler.occlude_input_event(event) + + handlers.extend(self._create_event_pipeline(super_handler, context)) + + if handler.input_configs: + # the handler was only partially wrapped, + # we need to return it as a toplevel handler + handlers.append(handler) + + return handlers + + def _get_output_handler(self, mapping: Mapping) -> HandlerEnums: + """Determine the correct output handler. + + this is used as a starting point for the mapping parser + """ + if mapping.output_code == DISABLE_CODE or mapping.output_symbol == DISABLE_NAME: + return HandlerEnums.disable + + if mapping.output_symbol: + if Parser.is_this_a_macro(mapping.output_symbol): + return HandlerEnums.macro + + return HandlerEnums.key + + if mapping.output_type == EV_KEY: + return HandlerEnums.key + + input_event = self._maps_axis(mapping.input_combination) + if not input_event: + raise MappingParsingError( + f"This {mapping = } does not map to an axis, key or macro", + mapping=Mapping, + ) + + if mapping.output_type == EV_REL: + if input_event.type == EV_KEY: + return HandlerEnums.btn2rel + if input_event.type == EV_REL: + return HandlerEnums.rel2rel + if input_event.type == EV_ABS: + return HandlerEnums.abs2rel + + if mapping.output_type == EV_ABS: + if input_event.type == EV_KEY: + return HandlerEnums.btn2abs + if input_event.type == EV_REL: + return HandlerEnums.rel2abs + if input_event.type == EV_ABS: + return HandlerEnums.abs2abs + + raise MappingParsingError( + f"the output of {mapping = } is unknown", mapping=Mapping + ) + + def _maps_axis(self, combination: InputCombination) -> Optional[InputConfig]: + """Whether this InputCombination contains an InputEvent that is treated as + an axis and not a binary (key or button) event. + """ + for event in combination: + if event.defines_analog_input: + return event + return None + + def _create_hierarchy_handlers( + self, + handlers: Dict[InputCombination, Set[MappingHandler]], + ) -> Set[MappingHandler]: + """Sort handlers by input events and create Hierarchy handlers.""" + sorted_handlers = set() + all_combinations = handlers.keys() + events = set() + + # gather all InputEvents from all handlers + for combination in all_combinations: + for event in combination: + events.add(event) + + # create a ranking for each event + for event in events: + # find all combinations (from handlers) which contain the event + combinations_with_event = [ + combination for combination in all_combinations if event in combination + ] + + if len(combinations_with_event) == 1: + # there was only one handler containing that event return it as is + sorted_handlers.update(handlers[combinations_with_event[0]]) + continue + + # there are multiple handler with the same event. + # rank them and create the HierarchyHandler + sorted_combinations = self._order_combinations( + combinations_with_event, + event, + ) + sub_handlers: List[MappingHandler] = [] + for combination in sorted_combinations: + sub_handlers.extend(handlers[combination]) + + sorted_handlers.add( + HierarchyHandler( + sub_handlers, + event, + self.global_uinputs, + ) + ) + for handler in sub_handlers: + # the handler now has a HierarchyHandler which takes care about this event. + # so we hide need to hide it on the handler + handler.occlude_input_event(event) + + return sorted_handlers + + def _order_combinations( + self, + combinations: List[InputCombination], + common_config: InputConfig, + ) -> List[InputCombination]: + """Reorder the keys according to some rules. + + such that a combination a+b+c is in front of a+b which is in front of b + for a+b+c vs. b+d+e: a+b+c would be in front of b+d+e, because the common key b + has the higher index in the a+b+c (1), than in the b+c+d (0) list + in this example b would be the common key + as for combinations like a+b+c and e+d+c with the common key c: ¯\\_(ツ)_/¯ + + Parameters + ---------- + combinations + the list which needs ordering + common_config + the InputConfig all InputCombination's in combinations have in common + """ + combinations.sort(key=len) + + for start, end in self._ranges_with_constant_length(combinations.copy()): + sub_list = combinations[start:end] + sub_list.sort(key=lambda x: x.index(common_config)) + combinations[start:end] = sub_list + + combinations.reverse() + return combinations + + def _ranges_with_constant_length( + self, + x: Sequence[Sized], + ) -> Iterable[Tuple[int, int]]: + """Get all ranges of x for which the elements have constant length + + Parameters + ---------- + x: Sequence[Sized] + l must be ordered by increasing length of elements + """ + start_idx = 0 + last_len = 0 + for idx, y in enumerate(x): + if len(y) > last_len and idx - start_idx > 1: + yield start_idx, idx + + if len(y) == last_len and idx + 1 == len(x): + yield start_idx, idx + 1 + + if len(y) > last_len: + start_idx = idx + + if len(y) < last_len: + raise MappingParsingError( + "ranges_with_constant_length was called with an unordered list" + ) + last_len = len(y) diff --git a/inputremapper/injection/mapping_handlers/null_handler.py b/inputremapper/injection/mapping_handlers/null_handler.py new file mode 100644 index 0000000..3d12b6d --- /dev/null +++ b/inputremapper/injection/mapping_handlers/null_handler.py @@ -0,0 +1,62 @@ +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + +from typing import Dict, List + +import evdev + +from inputremapper.configs.input_config import InputCombination +from inputremapper.injection.mapping_handlers.mapping_handler import ( + HandlerEnums, + MappingHandler, +) +from inputremapper.input_event import InputEvent + + +class NullHandler(MappingHandler): + """Handler which consumes the event and does nothing.""" + + def __str__(self): + return f"NullHandler for {self.mapping.input_combination}<{id(self)}>" + + def get_children(self) -> List[MappingHandler]: + return [] + + def needs_wrapping(self) -> bool: + return False in [ + input_.defines_analog_input for input_ in self.mapping.input_combination + ] + + def wrap_with(self) -> Dict[InputCombination, HandlerEnums]: + if not self.mapping.input_combination.defines_analog_input: + return {self.mapping.input_combination: HandlerEnums.combination} + + assert len(self.mapping.input_combination) > 1, "nees_wrapping ensures this!" + return {self.mapping.input_combination: HandlerEnums.axisswitch} + + def notify( + self, + event: InputEvent, + source: evdev.InputDevice, + suppress: bool = False, + ) -> bool: + return True + + def reset(self) -> None: + pass diff --git a/inputremapper/injection/mapping_handlers/rel_to_abs_handler.py b/inputremapper/injection/mapping_handlers/rel_to_abs_handler.py new file mode 100644 index 0000000..b6d92a4 --- /dev/null +++ b/inputremapper/injection/mapping_handlers/rel_to_abs_handler.py @@ -0,0 +1,248 @@ +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + +import asyncio +from typing import Tuple, Dict, Optional, List + +import evdev +from evdev.ecodes import ( + EV_ABS, + EV_REL, + REL_WHEEL, + REL_HWHEEL, + REL_HWHEEL_HI_RES, + REL_WHEEL_HI_RES, +) + +from inputremapper import exceptions +from inputremapper.configs.input_config import InputCombination, InputConfig +from inputremapper.configs.mapping import ( + Mapping, + WHEEL_SCALING, + WHEEL_HI_RES_SCALING, + REL_XY_SCALING, + DEFAULT_REL_RATE, +) +from inputremapper.injection.global_uinputs import GlobalUInputs +from inputremapper.injection.mapping_handlers.axis_transform import Transformation +from inputremapper.injection.mapping_handlers.mapping_handler import ( + HandlerEnums, + MappingHandler, +) +from inputremapper.input_event import InputEvent, EventActions +from inputremapper.logging.logger import logger + + +class RelToAbsHandler(MappingHandler): + """Handler which transforms EV_REL to EV_ABS events. + + High EV_REL input results in high EV_ABS output. + If no new EV_REL events are seen, the EV_ABS output is set to 0 after + release_timeout. + """ + + _map_axis: InputConfig # InputConfig for the relative movement we map + _output_axis: Tuple[int, int] # the (type, code) of the output axis + _transform: Transformation + _target_absinfo: evdev.AbsInfo + + # infinite loop which centers the output when input stops + _recenter_loop: Optional[asyncio.Task] + _moving: asyncio.Event # event to notify the _recenter_loop + + _previous_event: Optional[InputEvent] + _observed_rate: float # input events per second + + def __init__( + self, + combination: InputCombination, + mapping: Mapping, + global_uinputs: GlobalUInputs, + **_, + ) -> None: + super().__init__(combination, mapping, global_uinputs) + + # find the input event we are supposed to map. If the input combination is + # BTN_A + REL_X + BTN_B, then use the value of REL_X for the transformation + assert (map_axis := combination.find_analog_input_config(type_=EV_REL)) + self._map_axis = map_axis + + assert mapping.output_code is not None + assert mapping.output_type == EV_ABS + self._output_axis = (mapping.output_type, mapping.output_code) + + target_uinput = global_uinputs.get_uinput(mapping.target_uinput) + assert target_uinput is not None + abs_capabilities = target_uinput.capabilities(absinfo=True)[EV_ABS] + self._target_absinfo = dict(abs_capabilities)[mapping.output_code] + + max_ = self._get_default_cutoff() + self._transform = Transformation( + min_=-max(1, int(max_)), + max_=max(1, int(max_)), + deadzone=mapping.deadzone, + gain=mapping.gain, + expo=mapping.expo, + ) + self._moving = asyncio.Event() + self._recenter_loop = None + + self._previous_event = None + self._observed_rate = DEFAULT_REL_RATE + + def __str__(self): + return ( + f"RelToAbsHandler for {self._map_axis} " + f"maps to: {self.mapping.get_output_name_constant()} " + f"{self.mapping.get_output_type_code()} at " + f"{self.mapping.target_uinput}" + ) + + def __repr__(self): + return f"<{str(self)} at {hex(id(self))}>" + + def get_children(self) -> List[MappingHandler]: + return [] + + def _observe_rate(self, event: InputEvent): + """Watch incoming events and remember how many events appear per second.""" + if self._previous_event is not None: + delta_time = event.timestamp() - self._previous_event.timestamp() + if delta_time == 0: + logger.error("Observed two events with the same timestamp") + return + + rate = 1 / delta_time + # mice seem to have a constant rate. wheel events are jaggy and the + # rate depends on how fast it is turned. + if rate > self._observed_rate: + logger.debug("Updating rate to %s", rate) + self._observed_rate = rate + self._calculate_cutoff() + + self._previous_event = event + + def _get_default_cutoff(self): + """Get the cutoff value assuming the default input rate.""" + if self._map_axis.code in [REL_WHEEL, REL_HWHEEL]: + return self.mapping.rel_to_abs_input_cutoff * WHEEL_SCALING + + if self._map_axis.code in [REL_WHEEL_HI_RES, REL_HWHEEL_HI_RES]: + return self.mapping.rel_to_abs_input_cutoff * WHEEL_HI_RES_SCALING + + return self.mapping.rel_to_abs_input_cutoff * REL_XY_SCALING + + def _calculate_cutoff(self): + """Correct the default cutoff with the observed input rate, and set it.""" + # Mice that have very high input rates report low values at the same time. + # If the rate is high, use a lower cutoff-value. If the rate is low, use a + # higher cutoff-value. + cutoff = self._get_default_cutoff() + cutoff *= DEFAULT_REL_RATE / self._observed_rate + + self._transform.set_range(-max(1, int(cutoff)), max(1, int(cutoff))) + + def notify( + self, + event: InputEvent, + source: evdev.InputDevice, + suppress: bool = False, + ) -> bool: + self._observe_rate(event) + + if event.input_match_hash != self._map_axis.input_match_hash: + return False + + if EventActions.recenter in event.actions: + if self._recenter_loop: + self._recenter_loop.cancel() + self._recenter() + return True + + if not self._recenter_loop or self._recenter_loop.cancelled(): + self._recenter_loop = asyncio.create_task(self._create_recenter_loop()) + + self._moving.set() # notify the _recenter_loop + try: + self._write(self._scale_to_target(self._transform(event.value))) + return True + except (exceptions.UinputNotAvailable, exceptions.EventNotHandled): + return False + + def reset(self) -> None: + if self._recenter_loop: + self._recenter_loop.cancel() + self._recenter() + + def _recenter(self) -> None: + """Recenter the output.""" + self._write(self._scale_to_target(0)) + + async def _create_recenter_loop(self) -> None: + """Coroutine which waits for the input to start moving, + then waits until the input stops moving, centers the output and repeat. + + Runs forever. + """ + while True: + await self._moving.wait() # input moving started + while ( + await asyncio.wait( + (asyncio.create_task(self._moving.wait()),), + timeout=self.mapping.release_timeout, + ) + )[0]: + self._moving.clear() # still moving + self._recenter() # input moving stopped + + def _scale_to_target(self, x: float) -> int: + """Scales a x value between -1 and 1 to an integer between + target_absinfo.min and target_absinfo.max + + input values above 1 or below -1 are clamped to the extreme values + """ + factor = (self._target_absinfo.max - self._target_absinfo.min) / 2 + offset = self._target_absinfo.min + factor + y = factor * x + offset + if y > offset: + return int(min(self._target_absinfo.max, y)) + else: + return int(max(self._target_absinfo.min, y)) + + def _write(self, value: int) -> None: + """Inject.""" + try: + self.global_uinputs.write( + (*self._output_axis, value), + self.mapping.target_uinput, + ) + except OverflowError: + # screwed up the calculation of the event value + logger.error("OverflowError (%s, %s, %s)", *self._output_axis, value) + + def needs_wrapping(self) -> bool: + return len(self.input_configs) > 1 + + def set_sub_handler(self, handler: MappingHandler) -> None: + assert False # cannot have a sub-handler + + def wrap_with(self) -> Dict[InputCombination, HandlerEnums]: + if self.needs_wrapping(): + return {InputCombination(self.input_configs): HandlerEnums.axisswitch} + return {} diff --git a/inputremapper/injection/mapping_handlers/rel_to_btn_handler.py b/inputremapper/injection/mapping_handlers/rel_to_btn_handler.py new file mode 100644 index 0000000..7be6ba0 --- /dev/null +++ b/inputremapper/injection/mapping_handlers/rel_to_btn_handler.py @@ -0,0 +1,148 @@ +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + +import asyncio +import time +from typing import List + +import evdev +from evdev.ecodes import EV_REL + +from inputremapper.configs.input_config import InputCombination, InputConfig +from inputremapper.configs.mapping import Mapping +from inputremapper.injection.global_uinputs import GlobalUInputs +from inputremapper.injection.mapping_handlers.mapping_handler import ( + MappingHandler, +) +from inputremapper.input_event import InputEvent, EventActions +from inputremapper.logging.logger import logger + + +class RelToBtnHandler(MappingHandler): + """Handler which transforms an EV_REL to a button event + and sends that to a sub_handler + + adheres to the MappingHandler protocol + """ + + _active: bool + _input_config: InputConfig + _last_activation: float + _sub_handler: MappingHandler + + def __init__( + self, + combination: InputCombination, + mapping: Mapping, + global_uinputs: GlobalUInputs, + **_, + ) -> None: + super().__init__(combination, mapping, global_uinputs) + + self._active = False + self._input_config = combination[0] + self._last_activation = time.time() + self._abort_release = False + assert self._input_config.analog_threshold != 0 + assert len(combination) == 1 + + def __str__(self): + return f'RelToBtnHandler for "{self._input_config}"' + + def __repr__(self): + return f"<{str(self)} at {hex(id(self))}>" + + def get_children(self) -> List[MappingHandler]: + return [self._sub_handler] + + async def _stage_release( + self, + source: evdev.InputDevice, + suppress: bool, + ): + while time.time() < self._last_activation + self.mapping.release_timeout: + await asyncio.sleep(1 / self.mapping.rel_rate) + + if self._abort_release: + self._abort_release = False + return + + event = InputEvent( + 0, + 0, + *self._input_config.type_and_code, + value=0, + actions=(EventActions.as_key,), + origin_hash=self._input_config.origin_hash, + ) + logger.debug("Sending %s to sub_handler", event) + self._sub_handler.notify(event, source, suppress) + self._active = False + + def notify( + self, + event: InputEvent, + source: evdev.InputDevice, + suppress: bool = False, + ) -> bool: + assert event.type == EV_REL + if event.input_match_hash != self._input_config.input_match_hash: + return False + + assert (threshold := self._input_config.analog_threshold) + value = event.value + if (value < threshold > 0) or (value > threshold < 0): + if self._active: + # the axis is below the threshold and the stage_release + # function is running + if self.mapping.force_release_timeout: + # consume the event + return True + event = event.modify(pressed=False, actions=(EventActions.as_key,)) + logger.debug("Sending %s to sub_handler", event) + self._abort_release = True + else: + # don't consume the event. + # We could return True to consume events + return False + else: + # the axis is above the threshold + if not self._active: + asyncio.ensure_future(self._stage_release(source, suppress)) + if value >= threshold > 0: + direction = 1 + else: + direction = -1 + self._last_activation = time.time() + event = event.modify( + pressed=True, + direction=direction, + actions=(EventActions.as_key,), + ) + + self._active = event.is_pressed() + # logger.debug("Sending %s to sub_handler", event) + return self._sub_handler.notify(event, source=source, suppress=suppress) + + def reset(self) -> None: + if self._active: + self._abort_release = True + + self._active = False + self._sub_handler.reset() diff --git a/inputremapper/injection/mapping_handlers/rel_to_rel_handler.py b/inputremapper/injection/mapping_handlers/rel_to_rel_handler.py new file mode 100644 index 0000000..f61bd1a --- /dev/null +++ b/inputremapper/injection/mapping_handlers/rel_to_rel_handler.py @@ -0,0 +1,277 @@ +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + +import math +from typing import Dict, List + +import evdev +from evdev.ecodes import ( + EV_REL, + REL_WHEEL, + REL_HWHEEL, + REL_WHEEL_HI_RES, + REL_HWHEEL_HI_RES, +) + +from inputremapper import exceptions +from inputremapper.configs.input_config import InputCombination, InputConfig +from inputremapper.configs.mapping import ( + Mapping, + REL_XY_SCALING, + WHEEL_SCALING, + WHEEL_HI_RES_SCALING, +) +from inputremapper.injection.global_uinputs import GlobalUInputs +from inputremapper.injection.mapping_handlers.axis_transform import Transformation +from inputremapper.injection.mapping_handlers.mapping_handler import ( + HandlerEnums, + MappingHandler, +) +from inputremapper.input_event import InputEvent +from inputremapper.logging.logger import logger + + +def is_wheel(event) -> bool: + return event.type == EV_REL and event.code in (REL_WHEEL, REL_HWHEEL) + + +def is_high_res_wheel(event) -> bool: + return event.type == EV_REL and event.code in (REL_WHEEL_HI_RES, REL_HWHEEL_HI_RES) + + +class Remainder: + _scale: float + _remainder: float + + def __init__(self, scale: float): + self._scale = scale + self._remainder = 0 + + def input(self, value: float) -> int: + # if the mouse moves very slow, it might not move at all because of the + # int-conversion (which is required when writing). store the remainder + # (the decimal places) and add it up, until the mouse moves a little. + scaled = value * self._scale + self._remainder + self._remainder = math.fmod(scaled, 1) + + return int(scaled) + + +class RelToRelHandler(MappingHandler): + """Handler which transforms EV_REL to EV_REL events.""" + + _input_config: InputConfig # the relative movement we map + + _max_observed_input: float + + _transform: Transformation + + _remainder: Remainder + _wheel_remainder: Remainder + _wheel_hi_res_remainder: Remainder + + def __init__( + self, + combination: InputCombination, + mapping: Mapping, + global_uinputs: GlobalUInputs, + **_, + ) -> None: + super().__init__(combination, mapping, global_uinputs) + + assert self.mapping.output_code is not None + + # find the input event we are supposed to map. If the input combination is + # BTN_A + REL_X + BTN_B, then use the value of REL_X for the transformation + input_config = combination.find_analog_input_config(type_=EV_REL) + assert input_config is not None + self._input_config = input_config + + self._max_observed_input = 1 + + self._remainder = Remainder(REL_XY_SCALING) + self._wheel_remainder = Remainder(WHEEL_SCALING) + self._wheel_hi_res_remainder = Remainder(WHEEL_HI_RES_SCALING) + + self._transform = Transformation( + max_=1, + min_=-1, + deadzone=self.mapping.deadzone, + gain=self.mapping.gain, + expo=self.mapping.expo, + ) + + def __str__(self): + return ( + f"RelToRelHandler for {self._input_config} " + f"maps to: {self.mapping.output_code} at {self.mapping.target_uinput}" + ) + + def __repr__(self): + return f"<{str(self)} at {hex(id(self))}>" + + def get_children(self) -> List[MappingHandler]: + return [] + + def _should_map(self, event: InputEvent): + """Check if this input event is relevant for this handler.""" + return event.input_match_hash == self._input_config.input_match_hash + + def notify( + self, + event: InputEvent, + source: evdev.InputDevice, + suppress: bool = False, + ) -> bool: + if not self._should_map(event): + return False + """ + There was the idea to define speed as "movemnt per second". + There are deprecated mapping variables in this explanation. + + rel2rel example: + - input every 0.1s (`input_rate` of 10 events/s), value of 200 + - input speed is 2000, because in 1 second a value of 2000 acumulates + - `input_rel_speed` is a const defined as 4000 px/s, how fast mice usually move + - `transformed = Transformation(input.value, max=input_rel_speed / input_rate)` + - get 0.5 because the expo is 0 + - `abs_to_rel_speed` is 5000 + - inject 2500 therefore per second, making it a bit faster + - divide 2500 by the rate of 10 to inject a value of 250 each time input occurs + + ``` + output_value = Transformation( + input.value, + max=input_rel_speed / input_rate + ) * abs_to_rel_speed / input_rate + ``` + + The input_rel_speed could be used here instead of abs_to_rel_speed, because the + gain already controls the speed. In that case it would be a 1:1 ratio of + input-to-output value if the gain is 1. + + for wheel and wheel_hi_res, different input speed constants must be set. + + abs2rel needs a base value for the output, so `abs_to_rel_speed` is still + required. + `abs_to_rel_speed / rel_rate * transform(input.value, max=absinfo.max)` + is the output value. Both abs_to_rel_speed and the transformation-gain control + speed. + + if abs_to_rel_speed controls speed in the abs2rel output, it should also do so + in other handlers that have EV_REL output. + + unfortunately input_rate needs to be determined during runtime, which screws + the overall speed up when slowly moving the input device in the beginning, + because slow input is thought to be the regular input. + + --- + + transforming from rate based to rate based speed values won't work well. + + better to use fractional speed values. + REL_X of 40 = REL_WHEEL of 1 = REL_WHEE_HI_RES of 1/120 + + this is why abs_to_rel_speed does not affect the rel_to_rel handler. + + The expo calculation will be wrong in the beginning, because it is based on + the highest observed value. The overall gain will be fine though. + """ + + input_value = float(event.value) + + # scale down now, the remainder calculation scales up by the same factor later + # depending on what kind of event this becomes. + if event.is_wheel_event: + input_value /= WHEEL_SCALING + elif event.is_wheel_hi_res_event: + input_value /= WHEEL_HI_RES_SCALING + else: + # even though the input rate is unknown we can apply REL_XY_SCALING, which + # is based on 60hz or something, because the un-scaling also uses values + # based on 60hz. So the rate cancels out + input_value /= REL_XY_SCALING + + if abs(input_value) > self._max_observed_input: + self._max_observed_input = abs(input_value) + + # If _max_observed_input is wrong when the injection starts and the correct + # value learned during runtime, results can be weird at the beginning. + # If expo and deadzone are not set, then it is linear and doesn't matter. + transformed = self._transform(input_value / self._max_observed_input) + transformed *= self._max_observed_input + + is_wheel_output = self.mapping.is_wheel_output() + is_hi_res_wheel_output = self.mapping.is_high_res_wheel_output() + + horizontal = self.mapping.output_code in ( + REL_HWHEEL_HI_RES, + REL_HWHEEL, + ) + + try: + if is_wheel_output or is_hi_res_wheel_output: + # inject both kinds of wheels, otherwise wheels don't work for some + # people. See issue #354 + self._write( + REL_HWHEEL if horizontal else REL_WHEEL, + self._wheel_remainder.input(transformed), + ) + self._write( + REL_HWHEEL_HI_RES if horizontal else REL_WHEEL_HI_RES, + self._wheel_hi_res_remainder.input(transformed), + ) + else: + self._write( + self.mapping.output_code, + self._remainder.input(transformed), + ) + + return True + except OverflowError: + # screwed up the calculation of the event value + logger.error("OverflowError while handling %s", event) + return True + except (exceptions.UinputNotAvailable, exceptions.EventNotHandled): + return False + + def reset(self) -> None: + pass + + def _write(self, code: int, value: int): + if value == 0: + # rel 0 does not make sense. We don't need to tell linux that the mouse + # should not be moved this time. + return + + self.global_uinputs.write( + (EV_REL, code, value), + self.mapping.target_uinput, + ) + + def needs_wrapping(self) -> bool: + return len(self.input_configs) > 1 + + def set_sub_handler(self, handler: MappingHandler) -> None: + assert False # cannot have a sub-handler + + def wrap_with(self) -> Dict[InputCombination, HandlerEnums]: + if self.needs_wrapping(): + return {InputCombination(self.input_configs): HandlerEnums.axisswitch} + return {} diff --git a/inputremapper/injection/numlock.py b/inputremapper/injection/numlock.py new file mode 100644 index 0000000..c425744 --- /dev/null +++ b/inputremapper/injection/numlock.py @@ -0,0 +1,83 @@ +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + + +"""Functions to handle numlocks. + +For unknown reasons the numlock status can change when starting injections, +which is why these functions exist. +""" + + +import re +import subprocess + +from inputremapper.logging.logger import logger + + +def is_numlock_on(): + """Get the current state of the numlock.""" + try: + xset_q = subprocess.check_output( + ["xset", "q"], + stderr=subprocess.STDOUT, + ).decode() + num_lock_status = re.search(r"Num Lock:\s+(.+?)\s", xset_q) + + if num_lock_status is not None: + return num_lock_status[1] == "on" + + return False + except (FileNotFoundError, subprocess.CalledProcessError): + # tty + return None + + +def set_numlock(state): + """Set the numlock to a given state of True or False.""" + if state is None: + return + + value = {True: "on", False: "off"}[state] + + try: + subprocess.check_output(["numlockx", value]) + except subprocess.CalledProcessError: + # might be in a tty + pass + except FileNotFoundError: + # doesn't seem to be installed everywhere + logger.debug("numlockx not found") + + +def ensure_numlock(func): + """Decorator to reset the numlock to its initial state afterwards.""" + + def wrapped(*args, **kwargs): + # for some reason, grabbing a device can modify the num lock state. + # remember it and apply back later + numlock_before = is_numlock_on() + + result = func(*args, **kwargs) + + set_numlock(numlock_before) + + return result + + return wrapped diff --git a/inputremapper/input_event.py b/inputremapper/input_event.py new file mode 100644 index 0000000..0f3f886 --- /dev/null +++ b/inputremapper/input_event.py @@ -0,0 +1,264 @@ +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + +from __future__ import annotations + +import enum +from dataclasses import dataclass +from typing import Tuple, Optional, Hashable, Literal + +import evdev +from evdev import ecodes + +from inputremapper.utils import get_evdev_constant_name, DeviceHash + + +class EventActions(enum.Enum): + """Additional information an InputEvent can send through the event pipeline.""" + + as_key = enum.auto() # treat this event as a key event + recenter = enum.auto() # recenter the axis when receiving this + none = enum.auto() + + +# Todo: add slots=True as soon as python 3.10 is in common distros +@dataclass(frozen=True) +class InputEvent: + """Events that are generated during runtime. + + Is a drop-in replacement for evdev.InputEvent + """ + + sec: int + usec: int + type: int + code: int + value: int + + # Our own custom attributes + # (They need types for the dataclass to allow them in the constructor) + pressed: bool | None = None # haven't figured out yet, depends on threshold config + direction: int = 1 # -1 for joystick left, +1 for joystick right and buttons + actions: Tuple[EventActions, ...] = () + origin_hash: Optional[DeviceHash] = None + + def __eq__(self, other: InputEvent | evdev.InputEvent | Tuple[int, int, int]): + # useful in tests + if isinstance(other, InputEvent) or isinstance(other, evdev.InputEvent): + return self.event_tuple == (other.type, other.code, other.value) + if isinstance(other, tuple): + return self.event_tuple == other + raise TypeError(f"cannot compare {type(other)} with InputEvent") + + def is_pressed(self) -> bool: + """Get if the event is representing a pressed button, joystick, trigger, + a scrolled wheel, a moved mouse, etc. + + It might depend on stuff like the analog_threshold. + """ + if self.pressed is not None: + return self.pressed + + # As long as we haven't checked it using the analog thresholds and such, + # assume that anything != 0 means it is pressed.' + + if self.value == 0: + return False + + return True + + @staticmethod + def validate_event(event): + """Test if the event is valid.""" + if not isinstance(event.type, int): + raise TypeError(f"Expected type to be an int, but got {event.type}") + + if not isinstance(event.code, int): + raise TypeError(f"Expected code to be an int, but got {event.code}") + + if not isinstance(event.value, int): + # this happened to me because I screwed stuff up + raise TypeError(f"Expected value to be an int, but got {event.value}") + + return event + + @property + def input_match_hash(self) -> Hashable: + """a Hashable object which is intended to match the InputEvent with a + InputConfig. + """ + return self.type, self.code, self.origin_hash + + @classmethod + def from_event( + cls, + event: evdev.InputEvent, + origin_hash: Optional[DeviceHash] = None, + ) -> InputEvent: + """Create a InputEvent from another InputEvent or evdev.InputEvent.""" + try: + return cls( + event.sec, + event.usec, + event.type, + event.code, + event.value, + origin_hash=origin_hash, + ) + except AttributeError as exception: + raise TypeError( + f"Failed to create InputEvent from {event = }" + ) from exception + + @classmethod + def from_tuple( + cls, + event_tuple: Tuple[int, int, int], + origin_hash: Optional[DeviceHash] = None, + ) -> InputEvent: + """Create a InputEvent from a (type, code, value) tuple.""" + # use this as rarely as possible. Construct objects early on and pass them + # around instead of passing around integers + if len(event_tuple) != 3: + raise TypeError( + f"failed to create InputEvent {event_tuple = } must have length 3" + ) + + return cls.validate_event( + cls( + 0, + 0, + int(event_tuple[0]), + int(event_tuple[1]), + int(event_tuple[2]), + origin_hash=origin_hash, + ) + ) + + @classmethod + def abs(cls, code: int, value: int, origin_hash: Optional[DeviceHash] = None): + """Create an abs event, like joystick movements.""" + return cls.validate_event( + cls( + 0, + 0, + ecodes.EV_ABS, + code, + value, + origin_hash=origin_hash, + ) + ) + + @classmethod + def rel(cls, code: int, value: int, origin_hash: Optional[str] = None): + """Create a rel event, like mouse movements.""" + return cls.validate_event( + cls( + 0, + 0, + ecodes.EV_REL, + code, + value, + origin_hash=origin_hash, + ) + ) + + @classmethod + def key(cls, code: int, value: Literal[0, 1], origin_hash: Optional[str] = None): + """Create a key event, like keyboard keys or gamepad buttons. + + A value of 1 means "press", a value of 0 means "release". + """ + return cls.validate_event( + cls( + 0, + 0, + ecodes.EV_KEY, + code, + value, + origin_hash=origin_hash, + ) + ) + + @property + def type_and_code(self) -> Tuple[int, int]: + """Event type, code.""" + return self.type, self.code + + @property + def event_tuple(self) -> Tuple[int, int, int]: + """Event type, code, value.""" + return self.type, self.code, self.value + + @property + def is_key_event(self) -> bool: + """Whether this is interpreted as a key event.""" + return self.type == evdev.ecodes.EV_KEY or EventActions.as_key in self.actions + + @property + def is_wheel_event(self) -> bool: + """Whether this is interpreted as a key event.""" + return self.type == evdev.ecodes.EV_REL and self.code in [ + ecodes.REL_WHEEL, + ecodes.REL_HWHEEL, + ] + + @property + def is_wheel_hi_res_event(self) -> bool: + """Whether this is interpreted as a key event.""" + return self.type == evdev.ecodes.EV_REL and self.code in [ + ecodes.REL_WHEEL_HI_RES, + ecodes.REL_HWHEEL_HI_RES, + ] + + def __str__(self): + name = get_evdev_constant_name(self.type, self.code) + return f"InputEvent for {self.event_tuple} {name}" + + def __repr__(self): + return f"<{str(self)} at {hex(id(self))}>" + + def timestamp(self): + """Return the unix timestamp of when the event was seen.""" + return self.sec + self.usec / 1000000 + + def modify( + self, + sec: Optional[int] = None, + usec: Optional[int] = None, + type_: Optional[int] = None, + code: Optional[int] = None, + value: Optional[int] = None, + pressed: Optional[bool] = None, + direction: Optional[int] = None, + actions: Optional[Tuple[EventActions, ...]] = None, + origin_hash: Optional[str] = None, + ) -> InputEvent: + """Return a new modified event.""" + return InputEvent( + sec if sec is not None else self.sec, + usec if usec is not None else self.usec, + type_ if type_ is not None else self.type, + code if code is not None else self.code, + value if value is not None else self.value, + pressed if pressed is not None else self.is_pressed(), + direction if direction is not None else self.direction, + actions if actions is not None else self.actions, + origin_hash=origin_hash if origin_hash is not None else self.origin_hash, # type: ignore + ) diff --git a/inputremapper/installation_info.py b/inputremapper/installation_info.py new file mode 100644 index 0000000..339e394 --- /dev/null +++ b/inputremapper/installation_info.py @@ -0,0 +1,30 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + +"""Information about the input-remapper installation. + +These defaults might be overwritten by package maintainers. +""" + +COMMIT_HASH = "unknown" +VERSION = "2.2.1" +# depending on where this file is installed to, make sure to use the proper +# prefix path for data. +DATA_DIR = "/usr/share/input-remapper" diff --git a/inputremapper/ipc/__init__.py b/inputremapper/ipc/__init__.py new file mode 100644 index 0000000..be84d2d --- /dev/null +++ b/inputremapper/ipc/__init__.py @@ -0,0 +1,24 @@ +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + +"""Since I'm not forking, I can't use multiprocessing.Pipe. + +Processes that need privileges are spawned with pkexec, which connect to +known pipe paths to communicate with the non-privileged parent process. +""" diff --git a/inputremapper/ipc/pipe.py b/inputremapper/ipc/pipe.py new file mode 100644 index 0000000..520dd02 --- /dev/null +++ b/inputremapper/ipc/pipe.py @@ -0,0 +1,183 @@ +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + + +"""Named bidirectional non-blocking pipes. + +>>> p1 = Pipe('foo') +>>> p2 = Pipe('foo') + +>>> p1.send(1) +>>> p2.poll() +>>> p2.recv() + +>>> p2.send(2) +>>> p1.poll() +>>> p1.recv() + +Beware that pipes read any available messages, +even those written by themselves. +""" + +import asyncio +import json +import os +import time +from typing import Optional, AsyncIterator, Union + +from inputremapper.configs.paths import PathUtils +from inputremapper.logging.logger import logger + + +class Pipe: + """Pipe object. + + This is not for secure communication. If pipes already exist, they will be used, + but existing pipes might have open permissions! Only use this for stuff that + non-privileged users would be allowed to read. + """ + + def __init__(self, path): + """Create a pipe, or open it if it already exists.""" + self._path = path + self._unread = [] + self._created_at = time.time() + + self._transport: Optional[asyncio.ReadTransport] = None + self._async_iterator: Optional[AsyncIterator] = None + + paths = (f"{path}r", f"{path}w") + + PathUtils.mkdir(os.path.dirname(path)) + + if not os.path.exists(paths[0]): + logger.debug("Creating new pipes %s", paths) + # The fd the link points to is closed, or none ever existed + # If there is a link, remove it. + if os.path.islink(paths[0]): + os.remove(paths[0]) + if os.path.islink(paths[1]): + os.remove(paths[1]) + + self._fds = os.pipe() + fds_dir = f"/proc/{os.getpid()}/fd/" + PathUtils.chown(f"{fds_dir}{self._fds[0]}") + PathUtils.chown(f"{fds_dir}{self._fds[1]}") + + # to make it accessible by path constants, create symlinks + os.symlink(f"{fds_dir}{self._fds[0]}", paths[0]) + os.symlink(f"{fds_dir}{self._fds[1]}", paths[1]) + else: + logger.debug("Using existing pipes %s", paths) + + # thanks to os.O_NONBLOCK, readline will return b'' when there + # is nothing to read + self._fds = ( + os.open(paths[0], os.O_RDONLY | os.O_NONBLOCK), + os.open(paths[1], os.O_WRONLY | os.O_NONBLOCK), + ) + + self._handles = (open(self._fds[0], "r"), open(self._fds[1], "w")) + + # clear the pipe of any contents, to avoid leftover messages from breaking + # the reader-client or reader-service + while self.poll(): + leftover = self.recv() + logger.debug('Cleared leftover message "%s"', leftover) + + def __del__(self): + if self._transport: + logger.debug("closing transport") + self._transport.close() + for file in self._handles: + file.close() + + def recv(self): + """Read an object from the pipe or None if nothing available. + + Doesn't transmit pickles, to avoid injection attacks on the + privileged reader-service. Only messages that can be converted to json + are allowed. + """ + if len(self._unread) > 0: + return self._unread.pop(0) + + line = self._handles[0].readline() + if len(line) == 0: + return None + + return self._get_msg(line) + + def _get_msg(self, line: str): + parsed = json.loads(line) + if parsed[0] < self._created_at and os.environ.get("UNITTEST"): + # important to avoid race conditions between multiple unittests, + # for example old terminate messages reaching a new instance of + # the reader-service. + logger.debug("Ignoring old message %s", parsed) + return None + + return parsed[1] + + def send(self, message: Union[str, int, float, dict, list, tuple]): + """Write a serializable object to the pipe.""" + dump = json.dumps((time.time(), message)) + # there aren't any newlines supposed to be, + # but if there are it breaks readline(). + self._handles[1].write(dump.replace("\n", "")) + self._handles[1].write("\n") + self._handles[1].flush() + + def poll(self): + """Check if there is anything that can be read.""" + if len(self._unread) > 0: + return True + + # using select.select apparently won't mark the pipe as ready + # anymore when there are multiple lines to read but only a single + # line is retreived. Using read instead. + msg = self.recv() + if msg is not None: + self._unread.append(msg) + + return len(self._unread) > 0 + + def fileno(self): + """Compatibility to select.select.""" + return self._handles[0].fileno() + + def __aiter__(self): + return self + + async def __anext__(self): + if not self._async_iterator: + loop = asyncio.get_running_loop() + reader = asyncio.StreamReader() + + self._transport, _ = await loop.connect_read_pipe( + lambda: asyncio.StreamReaderProtocol(reader), self._handles[0] + ) + self._async_iterator = reader.__aiter__() + + return self._get_msg(await self._async_iterator.__anext__()) + + async def recv_async(self): + """Read the next line with async. Do not use this when using + the async for loop.""" + return await self.__aiter__().__anext__() diff --git a/inputremapper/ipc/shared_dict.py b/inputremapper/ipc/shared_dict.py new file mode 100644 index 0000000..cf34655 --- /dev/null +++ b/inputremapper/ipc/shared_dict.py @@ -0,0 +1,122 @@ +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + + +"""Share a dictionary across processes.""" + + +import atexit +import multiprocessing +import select +from typing import Optional, Any + +from inputremapper.logging.logger import logger + + +class SharedDict: + """Share a dictionary across processes.""" + + # because unittests terminate all child processes in cleanup I can't use + # multiprocessing.Manager + def __init__(self): + """Create a shared dictionary.""" + super().__init__() + + # To avoid blocking forever if something goes wrong. The maximum + # observed time communication takes was 0.001 for me on a slow pc + self._timeout = 0.02 + + self.pipe = multiprocessing.Pipe() + self.process = None + atexit.register(self._stop) + + def start(self): + """Ensure the process to manage the dictionary is running.""" + if self.process is not None and self.process.is_alive(): + logger.debug("SharedDict process already running") + return + + # if the manager has already been running in the past but stopped + # for some reason, the dictionary contents are lost. + logger.debug("Starting SharedDict process") + self.process = multiprocessing.Process(target=self.manage) + self.process.start() + + def manage(self): + """Manage the dictionary, handle read and write requests.""" + logger.debug("SharedDict process started") + shared_dict = {} + while True: + message = self.pipe[0].recv() + logger.debug("SharedDict got %s", message) + + if message[0] == "stop": + return + + if message[0] == "set": + shared_dict[message[1]] = message[2] + + if message[0] == "clear": + shared_dict.clear() + + if message[0] == "get": + self.pipe[0].send(shared_dict.get(message[1])) + + if message[0] == "ping": + self.pipe[0].send("pong") + + def _stop(self): + """Stop the managing process.""" + self.pipe[1].send(("stop",)) + + def _clear(self): + """Clears the memory.""" + self.pipe[1].send(("clear",)) + + def get(self, key: str): + """Get a value from the dictionary. + + If it doesn't exist, returns None. + """ + return self[key] + + def is_alive(self, timeout: Optional[int] = None): + """Check if the manager process is running.""" + self.pipe[1].send(("ping",)) + select.select([self.pipe[1]], [], [], timeout or self._timeout) + if self.pipe[1].poll(): + return self.pipe[1].recv() == "pong" + + return False + + def __setitem__(self, key: str, value: Any): + self.pipe[1].send(("set", key, value)) + + def __getitem__(self, key: str): + self.pipe[1].send(("get", key)) + + select.select([self.pipe[1]], [], [], self._timeout) + if self.pipe[1].poll(): + return self.pipe[1].recv() + + logger.error("select.select timed out") + return None + + def __del__(self): + self._stop() diff --git a/inputremapper/ipc/socket.py b/inputremapper/ipc/socket.py new file mode 100644 index 0000000..7142459 --- /dev/null +++ b/inputremapper/ipc/socket.py @@ -0,0 +1,303 @@ +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + + +"""Non-blocking abstraction of unix domain sockets. + +>>> server = Server('foo') +>>> client = Client('foo') + +>>> server.send(1) +>>> client.poll() +>>> client.recv() + +>>> client.send(2) +>>> server.poll() +>>> server.recv() + +I seems harder to sniff on a socket than using pipes for other non-root +processes, but it doesn't guarantee security. As long as the GUI is open +and not running as root user, it is most likely possible to somehow log +keycodes by looking into the memory of the gui process (just like with most +other applications because they end up receiving keyboard input as well). +It still appears to be a bit overkill to use a socket considering pipes +are much easier to handle. +""" + + +# Issues: +# - Tests don't pass with Server and Client instead of Pipe for reader-client +# and service communication or something +# - Had one case of a test that was blocking forever, seems very rare. +# - Hard to debug, generally very problematic compared to Pipes +# The tool works fine, it's just the tests. BrokenPipe errors reported +# by _Server all the time. + + +import json +import os +import select +import socket +import time +from typing import Union + +from inputremapper.configs.paths import PathUtils +from inputremapper.logging.logger import logger + +# something funny that most likely won't appear in messages. +# also add some ones so that 01 in the payload won't offset +# a match by 2 bits +END = b"\x55\x55\xff\x55" # should be 01010101 01010101 11111111 01010101 + +ENCODING = "utf8" + + +# reusing existing objects makes tests easier, no headaches about closing +# and reopening anymore. The ui also only runs only one instance of each all +# the time. +existing_servers = {} +existing_clients = {} + + +class Base: + """Abstract base class for Socket and Client.""" + + def __init__(self, path): + self._path = path + self._unread = [] + self.unsent = [] + PathUtils.mkdir(os.path.dirname(path)) + self.connection = None + self.socket = None + self._created_at = 0 + self.reset() + + def reset(self): + """Ignore older messages than now.""" + # ensure it is connected + self.connect() + self._created_at = time.time() + + def connect(self): + """Returns True if connected, and if not attempts to connect.""" + raise NotImplementedError + + def fileno(self): + """For compatibility with select.select.""" + raise NotImplementedError + + def reconnect(self): + """Try to make a new connection.""" + raise NotImplementedError + + def _receive_new_messages(self): + if not self.connect(): + logger.debug("Not connected") + return + + messages = b"" + attempts = 0 + while True: + try: + chunk = self.connection.recvmsg(4096)[0] + messages += chunk + + if len(chunk) == 0: + # select keeps telling me the socket has messages + # ready to be received, and I keep getting empty + # buffers. Happened during a test that ran two reader-service + # processes without stopping the first one. + attempts += 1 + if attempts == 2 or not self.reconnect(): + return + + except (socket.timeout, BlockingIOError): + break + + split = messages.split(END) + for message in split: + if len(message) > 0: + parsed = json.loads(message.decode(ENCODING)) + if parsed[0] < self._created_at: + # important to avoid race conditions between multiple + # unittests, for example old terminate messages reaching + # a new instance of the reader-service. + logger.debug("Ignoring old message %s", parsed) + continue + + self._unread.append(parsed[1]) + + def recv(self): + """Get the next message or None if nothing to read. + + Doesn't transmit pickles, to avoid injection attacks on the + privileged reader-service. Only messages that can be converted to json + are allowed. + """ + self._receive_new_messages() + + if len(self._unread) == 0: + return None + + return self._unread.pop(0) + + def poll(self): + """Check if a message to read is available.""" + if len(self._unread) > 0: + return True + + self._receive_new_messages() + return len(self._unread) > 0 + + def send(self, message: Union[str, int, float, dict, list, tuple]): + """Send json-serializable messages.""" + dump = bytes(json.dumps((time.time(), message)), ENCODING) + self.unsent.append(dump) + + if not self.connect(): + logger.debug("Not connected") + return + + def send_all(): + while len(self.unsent) > 0: + unsent = self.unsent[0] + self.connection.sendall(unsent + END) + # sending worked, remove message + self.unsent.pop(0) + + # attempt sending twice in case it fails + try: + send_all() + except BrokenPipeError: + if not self.reconnect(): + logger.error( + '%s: The other side of "%s" disappeared', + type(self).__name__, + self._path, + ) + return + + try: + send_all() + except BrokenPipeError as error: + logger.error( + '%s: Failed to send via "%s": %s', + type(self).__name__, + self._path, + error, + ) + + +class _Client(Base): + """A socket that can be written to and read from.""" + + def connect(self): + if self.socket is not None: + return True + + try: + _socket = socket.socket(socket.AF_UNIX) + _socket.connect(self._path) + logger.debug('Connected to socket: "%s"', self._path) + _socket.setblocking(False) + except Exception as error: + logger.debug('Failed to connect to "%s": "%s"', self._path, error) + return False + + self.socket = _socket + self.connection = _socket + existing_clients[self._path] = self + return True + + def fileno(self): + """For compatibility with select.select.""" + self.connect() + return self.socket.fileno() + + def reconnect(self): + self.connection = None + self.socket = None + return self.connect() + + +def Client(path): + if path in existing_clients: + # ensure it is running, might have been closed + existing_clients[path].reset() + return existing_clients[path] + + return _Client(path) + + +class _Server(Base): + """A socket that can be written to and read from. + + It accepts one connection at a time, and drops old connections if + a new one is in sight. + """ + + def connect(self): + if self.socket is None: + if os.path.exists(self._path): + # leftover from the previous execution + os.remove(self._path) + + _socket = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + _socket.bind(self._path) + _socket.listen(1) + PathUtils.chown(self._path) + logger.debug('Created socket: "%s"', self._path) + self.socket = _socket + self.socket.setblocking(False) + existing_servers[self._path] = self + + incoming = len(select.select([self.socket], [], [], 0)[0]) != 0 + if not incoming and self.connection is None: + # no existing connection, no client attempting to connect + return False + + if not incoming and self.connection is not None: + # old connection + return True + + if incoming: + logger.debug('Incoming connection: "%s"', self._path) + connection = self.socket.accept()[0] + self.connection = connection + self.connection.setblocking(False) + + return True + + def fileno(self): + """For compatibility with select.select.""" + self.connect() + return self.connection.fileno() + + def reconnect(self): + self.connection = None + return self.connect() + + +def Server(path): + if path in existing_servers: + # ensure it is running, might have been closed + existing_servers[path].reset() + return existing_servers[path] + + return _Server(path) diff --git a/inputremapper/logging/__init__.py b/inputremapper/logging/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/inputremapper/logging/formatter.py b/inputremapper/logging/formatter.py new file mode 100644 index 0000000..3a7ab95 --- /dev/null +++ b/inputremapper/logging/formatter.py @@ -0,0 +1,143 @@ +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + +"""Logging setup for input-remapper.""" + +import logging +import os +import sys +from datetime import datetime +from typing import Dict + + +class ColorfulFormatter(logging.Formatter): + """Overwritten Formatter to print nicer logs. + + It colors all logs from the same filename in the same color to visually group them + together. It also adds process name, process id, file, line-number and time. + + If debug mode is not active, it will not do any of this. + """ + + def __init__(self, debug_mode: bool = False): + super().__init__() + + self.debug_mode = debug_mode + self.file_color_mapping: Dict[str, int] = {} + + # see https://en.wikipedia.org/wiki/ANSI_escape_code#8-bit + self.allowed_colors = [] + for r in range(0, 6): + for g in range(0, 6): + for b in range(0, 6): + # https://stackoverflow.com/a/596243 + brightness = 0.2126 * r + 0.7152 * g + 0.0722 * b + if brightness < 1: + # prefer light colors, because most people have a dark + # terminal background + continue + + if g + b <= 1: + # red makes it look like it's an error + continue + + if abs(g - b) < 2 and abs(b - r) < 2 and abs(r - g) < 2: + # no colors that are too grey + continue + + self.allowed_colors.append(self._get_ansi_code(r, g, b)) + + self.level_based_colors = { + logging.WARNING: 11, + logging.ERROR: 9, + logging.FATAL: 9, + } + + def _get_ansi_code(self, r: int, g: int, b: int) -> int: + return 16 + b + (6 * g) + (36 * r) + + def _word_to_color(self, word: str) -> int: + """Convert a word to a 8bit ansi color code.""" + digit_sum = sum([ord(char) for char in word]) + index = digit_sum % len(self.allowed_colors) + return self.allowed_colors[index] + + def _allocate_debug_log_color(self, record: logging.LogRecord): + """Get the color that represents the source file of the log.""" + if self.file_color_mapping.get(record.filename) is not None: + return self.file_color_mapping[record.filename] + + color = self._word_to_color(record.filename) + + if self.file_color_mapping.get(record.filename) is None: + # calculate the color for each file only once + self.file_color_mapping[record.filename] = color + + return color + + def _get_process_name(self): + """Generate a beaitiful to read name for this process.""" + process_path = sys.argv[0] + process_name = process_path.split("/")[-1] + + if "input-remapper-" in process_name: + process_name = process_name.replace("input-remapper-", "") + + if process_name == "gtk": + process_name = "GUI" + + return process_name + + def _get_format(self, record: logging.LogRecord): + """Generate a message format string.""" + if record.levelno == logging.INFO and not self.debug_mode: + # if not launched with --debug, then don't print "INFO:" + return "%(message)s" + + if not self.debug_mode: + color = self.level_based_colors.get(record.levelno, 9) + return f"\033[38;5;{color}m%(levelname)s\033[0m: %(message)s" + + color = self._allocate_debug_log_color(record) + if record.levelno in [logging.ERROR, logging.WARNING, logging.FATAL]: + # underline + style = f"\033[4;38;5;{color}m" + else: + style = f"\033[38;5;{color}m" + + process_color = self._word_to_color(f"{os.getpid()}{sys.argv[0]}") + + return ( # noqa + f'{datetime.now().strftime("%H:%M:%S.%f")} ' + f"\033[38;5;{process_color}m" # color + f"{os.getpid()} " + f"{self._get_process_name()} " + "\033[0m" # end style + f"{style}" + f"%(levelname)s " + f"%(filename)s:%(lineno)d: " + "%(message)s" + "\033[0m" # end style + ).replace(" ", " ") + + def format(self, record: logging.LogRecord): + """Overwritten format function.""" + # pylint: disable=protected-access + self._style._fmt = self._get_format(record) + return super().format(record) diff --git a/inputremapper/logging/logger.py b/inputremapper/logging/logger.py new file mode 100644 index 0000000..43993f9 --- /dev/null +++ b/inputremapper/logging/logger.py @@ -0,0 +1,172 @@ +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + +"""Logging setup for input-remapper.""" + +from __future__ import annotations # needed for the TYPE_CHECKING import + +import logging +import time +from typing import cast, Type, TYPE_CHECKING, List, Tuple +from evdev.ecodes import EV_ABS, EV_KEY, EV_REL, ABS_HAT0X, ABS_HAT0Y + +from inputremapper.input_event import InputEvent +from inputremapper.logging.formatter import ColorfulFormatter +from inputremapper.installation_info import VERSION, COMMIT_HASH + +if TYPE_CHECKING: + from inputremapper.injection.mapping_handler import MappingHandler + + +start = time.time() + + +class Logger(logging.Logger): + previous_abs_rel_log_time = 0.0 + previous_write_debug_log = None + analog_log_threshold = 0.1 # s + + def debug_mapping_handler(self, mapping_handler: MappingHandler) -> None: + """Parse the structure of a mapping_handler and log it.""" + if not self.isEnabledFor(logging.DEBUG): + return + + lines_and_indent = self._build_mapping_handler_description_tree(mapping_handler) + for line in lines_and_indent: + indent = " " + msg = indent * line[1] + line[0] + self._log(logging.DEBUG, msg, args=()) + + def write(self, key, uinput) -> None: + """Log that an event is being written + + Parameters + ---------- + key + anything that can be string formatted, but usually a tuple of + (type, code, value) tuples + """ + # pylint: disable=protected-access + if not self.isEnabledFor(logging.DEBUG): + return + + if isinstance(key, InputEvent): + if key.type not in [EV_ABS, EV_REL, EV_KEY]: + return + + # Avoid spaming the terminal with tons of high-resolution logs + now = time.time() + if key.type in [EV_ABS, EV_REL] and key.code not in [ABS_HAT0X, ABS_HAT0Y]: + if now - self.previous_abs_rel_log_time < self.analog_log_threshold: + return + + self.previous_abs_rel_log_time = now + + str_key = repr(key) + str_key = str_key.replace(",)", ")") + + msg = f'Writing {str_key} to "{uinput.name}"' + + if msg == self.previous_write_debug_log: + # avoid some spam + return + + self.previous_write_debug_log = msg + + self._log(logging.DEBUG, msg, args=(), stacklevel=2) + + def _build_mapping_handler_description_tree( + self, + mapping_handler: MappingHandler, + indent=0, + ) -> List[Tuple[str, int]]: + lines_and_indent = [ + (str(mapping_handler), indent), + ] + + mapping_handlers = mapping_handler.get_children() + for sub_handler in mapping_handlers: + sub_list = self._build_mapping_handler_description_tree( + sub_handler, indent + 1 + ) + lines_and_indent.extend(sub_list) + + return lines_and_indent + + def is_debug(self) -> bool: + """True, if the logger is currently in DEBUG mode.""" + return self.level <= logging.DEBUG + + def log_info(self, name: str = "input-remapper") -> None: + """Log version and name to the console.""" + logger.info( + "%s %s %s https://github.com/sezanzeb/input-remapper", + name, + VERSION, + COMMIT_HASH, + ) + + if EVDEV_VERSION: + logger.info("python-evdev %s", EVDEV_VERSION) + + if self.is_debug(): + logger.warning( + "Debug level will log all your keystrokes! Do not post this " + "output in the internet if you typed in sensitive or private " + "information with your device!" + ) + + def update_verbosity(self, debug: bool) -> None: + """Set the logging verbosity according to the settings object.""" + if debug: + self.setLevel(logging.DEBUG) + else: + self.setLevel(logging.INFO) + + for handler in self.handlers: + handler.setFormatter(ColorfulFormatter(debug)) + + @classmethod + def bootstrap_logger(cls: Type[Logger]) -> Logger: + # https://github.com/python/typeshed/issues/1801 + logging.setLoggerClass(cls) + logger = cast(Logger, logging.getLogger("input-remapper")) + + handler = logging.StreamHandler() + handler.setFormatter(ColorfulFormatter(False)) + logger.addHandler(handler) + logger.setLevel(logging.INFO) + logging.getLogger("asyncio").setLevel(logging.WARNING) + return logger + + +logger = Logger.bootstrap_logger() + + +EVDEV_VERSION = None +try: + from importlib.metadata import version + + EVDEV_VERSION = version("evdev") +except Exception as error: + logger.info("Could not figure out the evdev version") + logger.debug(error) + +# check if the version is something like 1.5.0-beta or 1.5.0-beta.5 +IS_BETA = "beta" in VERSION diff --git a/inputremapper/user.py b/inputremapper/user.py new file mode 100644 index 0000000..5a86210 --- /dev/null +++ b/inputremapper/user.py @@ -0,0 +1,71 @@ +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + + +"""Figure out the user.""" + + +import getpass +import os +import pwd + + +class UserUtils: + @staticmethod + def get_user(): + """Try to find the user who called sudo/pkexec.""" + try: + return os.getlogin() + except OSError: + # failed in some ubuntu installations and in systemd services + pass + + try: + user = os.environ["USER"] + except KeyError: + # possibly the systemd service. no sudo was used + return getpass.getuser() + + if user == "root": + try: + return os.environ["SUDO_USER"] + except KeyError: + # no sudo was used + pass + + try: + pkexec_uid = int(os.environ["PKEXEC_UID"]) + return pwd.getpwuid(pkexec_uid).pw_name + except KeyError: + # no pkexec was used or the uid is unknown + pass + + return user + + @staticmethod + def get_home(user): + """Try to find the user's home directory.""" + return pwd.getpwnam(user).pw_dir + + # An odd construct, but it can't really be helped because this was done as an + # afterthought, after I learned about proper object-oriented software architecture. + # TODO Eventually, UserUtils should be constructed and injected as a UserService, + # which then initializes stuff. + user = get_user() + home = get_home(user) diff --git a/inputremapper/utils.py b/inputremapper/utils.py new file mode 100644 index 0000000..8a10f13 --- /dev/null +++ b/inputremapper/utils.py @@ -0,0 +1,64 @@ +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + + +"""Utility functions.""" + +import sys +from hashlib import md5 +from typing import Optional, NewType + +import evdev + +DeviceHash = NewType("DeviceHash", str) + + +def is_service() -> bool: + return sys.argv[0].endswith("input-remapper-service") + + +def get_device_hash(device: evdev.InputDevice) -> DeviceHash: + """get a unique hash for the given device.""" + # The builtin hash() function can not be used because it is randomly + # seeded at python startup. + # A non-cryptographic hash would be faster but there is none in the standard lib + # This hash needs to stay the same across reboots, and even stay the same when + # moving the config to a new computer. + s = str(device.capabilities(absinfo=False)) + device.name + return DeviceHash(md5(s.encode()).hexdigest().lower()) + + +def get_evdev_constant_name(type_: Optional[int], code: Optional[int], *_) -> str: + """Handy function to get the evdev constant name for display purposes. + + Returns "unknown" for unknown events. + """ + # using this function is more readable than + # type_, code = event.type_and_code + # name = evdev.ecodes.bytype[type_][code] + name = evdev.ecodes.bytype.get(type_, {}).get(code) + + if type(name) in [list, tuple]: + # python-evdev >= 1.8.0 uses tuples + name = name[0] + + if name is None: + return "unknown" + + return name diff --git a/install/__init__.py b/install/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/install/__main__.py b/install/__main__.py new file mode 100644 index 0000000..902414a --- /dev/null +++ b/install/__main__.py @@ -0,0 +1,116 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + +"""Build input-remapper including its translations and system-files. + +pip, in many cases, fails to install data files, which need to go into system paths, +and instead puts them (despite them being absolute paths) into +/usr/lib/python3/inputremapper/usr/share/... + +python3 setup.py install is deprecated + +meson fails to install the module into a path that can actually be imported, and +its python features require one to specify each individual file of the module. + +So instead, input-remapper uses a custom python solution. Hopefulls this works well +enough to prevent all ModuleNotFoundErrors in the future. +""" + +import shutil +import os +import sys +from enum import Enum + +from install.check_dependencies import check_dependencies +from install.data_files import build_data_files +from install.module import build_input_remapper_module +from install.language import make_lang + +import argparse + + +class Components(str, Enum): + data_files = "data_files" + python_module = "python_module" + + +def parse_args(): + parser = argparse.ArgumentParser(description="Tool to install input-remapper with.") + parser.add_argument( + "--root", + type=str, + help=( + "Where to install input-remapper to. For example ./build to prepare a " + "package archive or for debugging, or / to install it to the system." + ), + required=True, + ) + parser.add_argument( + "--components", + type=str, nargs='+', + help=( + f'A list of components to install. Default: --components ' + f'{Components.python_module.value} {Components.data_files.value}' + ), + default=[Components.python_module, Components.data_files], + metavar="COMPONENT" + ) + args = parser.parse_args() + return args + + +def ask(msg) -> bool: + answer = "" + while answer not in ["y", "n"]: + answer = input(f"{msg} [y/n] ").lower() + return answer == "y" + + +def print_headline(message: str) -> None: + print(f"\033[7m{message}\033[0m") + + +def main() -> None: + args = parse_args() + + if os.path.exists(args.root) and not args.root.startswith("/"): + delete = ask(f"{args.root} already exists. Delete?") + if delete: + shutil.rmtree(args.root) + else: + sys.exit(3) + + for component in args.components: + if component not in [Components.data_files, Components.python_module]: + raise ValueError(f"Unknown component {component}") + + if Components.data_files in args.components: + print_headline(f'Installing component "{Components.data_files.value}"') + build_data_files(args.root) + make_lang(args.root) + + if Components.python_module in args.components: + print_headline(f'Installing component "{Components.python_module.value}"') + check_dependencies() + build_input_remapper_module(args.root) + + +if __name__ == "__main__": + main() diff --git a/install/check_dependencies.py b/install/check_dependencies.py new file mode 100644 index 0000000..83f8ae4 --- /dev/null +++ b/install/check_dependencies.py @@ -0,0 +1,43 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + + +def check_dependencies() -> None: + print("Checking dependencies") + try: + import gi + + gi.require_version("Gdk", "3.0") + gi.require_version("GLib", "2.0") + gi.require_version("Gst", "1.0") + gi.require_version("Gtk", "3.0") + gi.require_version("GtkSource", "4") + from gi.repository import GObject, Gtk, Gst, Gdk, GLib, Pango, Gio, GtkSource + import evdev + import psutil + import dasbus + import pygobject + import pydantic + + print("All required Python modules found") + except ImportError as e: + print(f"\033[93mMissing Python module: {e}\033[0m") + except Exception as e: + print(f"\033[93mException while checking dependencies: {e}\033[0m") diff --git a/install/data_files.py b/install/data_files.py new file mode 100644 index 0000000..d4bf331 --- /dev/null +++ b/install/data_files.py @@ -0,0 +1,66 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + +"""Copy input-remappers system-files around.""" + +import glob +import os +import shutil + + +DATA_DIR = "usr/share/input-remapper" + + +def get_data_files() -> list[tuple[str, list[str]]]: + return [ + # see development.md#files + (DATA_DIR, glob.glob("data/*")), + ("usr/share/applications/", ["data/input-remapper-gtk.desktop"]), + ( + "usr/share/metainfo/", + ["data/io.github.sezanzeb.input_remapper.metainfo.xml"], + ), + ("usr/share/icons/hicolor/scalable/apps/", ["data/input-remapper.svg"]), + ("usr/share/polkit-1/actions/", ["data/input-remapper.policy"]), + ("usr/lib/systemd/system", ["data/input-remapper.service"]), + # Fun fact: At some point during development and testing on arch, I ended up + # with an empty inputremapper.Control.conf file, causing dbus to fail to start, + # which rendered the whole operating system unusable. + ("usr/share/dbus-1/system.d/", ["data/inputremapper.Control.conf"]), + ("etc/xdg/autostart/", ["data/input-remapper-autoload.desktop"]), + ("usr/lib/udev/rules.d", ["data/69-input-remapper-forwarded.rules"]), + ("usr/lib/udev/rules.d", ["data/99-input-remapper.rules"]), + ("usr/bin/", ["bin/input-remapper-gtk"]), + ("usr/bin/", ["bin/input-remapper-service"]), + ("usr/bin/", ["bin/input-remapper-control"]), + ("usr/bin/", ["bin/input-remapper-reader-service"]), + ] + + +def build_data_files(root: str) -> None: + for target_dir, files in get_data_files(): + # We specify the root via argv instead. Argparse would ignore the first + # arguments with a leading slash. + assert not target_dir.startswith("/") + for file_ in files: + destination_dir = os.path.join(root, target_dir) + print("Copying", file_, "to", destination_dir) + os.makedirs(destination_dir, exist_ok=True) + shutil.copy(file_, os.path.join(destination_dir, os.path.basename(file_))) diff --git a/install/language.py b/install/language.py new file mode 100644 index 0000000..93dbac8 --- /dev/null +++ b/install/language.py @@ -0,0 +1,54 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + +"""Build language files and copy them to the target.""" + +import glob +import os +import subprocess +from os.path import basename, splitext, join, dirname + + +def make_lang(root: str) -> None: + """Build po files as mo files into the expected directory.""" + os.makedirs("mo", exist_ok=True) + for po_file in glob.glob("po/*.po"): + lang = splitext(basename(po_file))[0] + target = join( + root, + "usr", + "share", + "input-remapper", + "lang", + lang, + "LC_MESSAGES", + "input-remapper.mo", + ) + os.makedirs(dirname(target), exist_ok=True) + print(f"Generating translation {target}") + subprocess.run( + [ + "msgfmt", + "-o", + target, + str(po_file), + ], + check=True, + ) diff --git a/install/module.py b/install/module.py new file mode 100644 index 0000000..a1db5c6 --- /dev/null +++ b/install/module.py @@ -0,0 +1,187 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + +"""Figure out where the python module needs to be placed in order to reliable import it. + +Dealing with varying sys.paths is frustrating. The sys.paths in /usr are not mirrored +in /usr/local consistently. Meson uses /usr/local/python3, which ubuntus python3 does +not import from. /usr/local is ignored by python within udev. Arch does not import +from /usr/local. When in doubt, do not install into /usr/local. I do not want to deal +with ModuleNotFoundErrors. I don't care if it should be in /usr/local by convention. +I don't know how much this varies across ubuntu versions and other debian based +distributions. I want the .deb to install reliably. + +sys.path samples: +endeavouros user: ['', '/usr/lib/python313.zip', '/usr/lib/python3.13', '/usr/lib/python3.13/lib-dynload', '/usr/lib/python3.13/site-packages'] +endeavouros root: ['', '/usr/lib/python313.zip', '/usr/lib/python3.13', '/usr/lib/python3.13/lib-dynload', '/usr/lib/python3.13/site-packages'] +endeavouros udev: ['/usr/bin', '/lib/python313.zip', '/lib/python3.13', '/lib/python3.13/lib-dynload', '/lib/python3.13/site-packages'] +ubuntu 25.04 user: ['', '/usr/lib/python313.zip', '/usr/lib/python3.13', '/usr/lib/python3.13/lib-dynload', '/home/user/.local/lib/python3.13/site-packages', '/usr/local/lib/python3.13/dist-packages', '/usr/lib/python3/dist-packages'] +ubuntu 25.04 root: ['', '/usr/lib/python313.zip', '/usr/lib/python3.13', '/usr/lib/python3.13/lib-dynload', '/usr/local/lib/python3.13/dist-packages', '/usr/lib/python3/dist-packages'] +ubuntu 25.04 udev: ['/usr/bin', '/lib/python313.zip', '/lib/python3.13', '/lib/python3.13/lib-dynload', '/lib/python3/dist-packages'] +""" + +import sys +import os +import subprocess +import shutil +import re +import tomllib + +from install.data_files import DATA_DIR + + +def _key(path) -> int: + favorability = 0 + + # Good + if re.match(r".*/lib/python3/.+-packages", path): + # Independent of the installed python version, so it probably just continues + # to be importable after a python version upgrade. + favorability = 5 + elif re.match(r".*/lib/python3.+?/.+-packages", path): + favorability = 4 + + # Not meant for python code, but it probably works flawlessly + elif re.match(r".*/lib/python3.+?/lib-dynload", path): + favorability = 3 + + # pip refuses to uninstall from standard-library directories once installed there + elif re.match(r".*/lib/python3", path): + favorability = 2 + elif re.match(r".*/lib/python3.+?", path): + favorability = 1 + + # Check the prefix + # (beware, udev imports from /lib, which I think is equivalent to /usr/lib, so + # don't require /usr as a prefix) + if path.startswith("/usr/local"): + # udev does not import from /usr/local + favorability -= 5 + elif path.startswith("/home"): + # Something like /home/user/.local/lib/python3.13/site-packages + # The python package needs to be installed system-wide + favorability -= 50 + + if not os.path.exists(path): + # If it doesn't exist yet, pip will apparently create it + favorability -= 2 + elif not os.path.isdir(path): + # If it exists but is not a dir, do not use it + favorability -= 99 + + return -favorability + + +def _get_packages_dir() -> str: + """Where to install the input-remapper module to. + + For example "/usr/lib/python3.13 + """ + packages_dirs = sorted(sys.path, key=_key) + packages_dir = packages_dirs[0] + print(f'Picked "{packages_dir}" from {packages_dirs}') + return packages_dir + + +def _get_commit_hash() -> str: + git_call = subprocess.check_output(["git", "rev-parse", "HEAD"]) + commit = git_call.decode().strip() + return commit + + +def _set_variables(target: str) -> None: + path = os.path.join(target, "inputremapper", "installation_info.py") + assert os.path.exists(path) + + with open(path, "r") as f: + contents = f.read() + + with open("pyproject.toml", "rb") as f: + version = tomllib.load(f)["project"]["version"] + + values = { + "COMMIT_HASH": _get_commit_hash(), + "VERSION": version, + "DATA_DIR": f"/{DATA_DIR}", + } + + print("Setting", values, "in", path) + + with open(path, "w") as f: + for variable_name, value in values.items(): + contents = re.sub( + rf"{variable_name}\s*=.+", + f"{variable_name} = '{value}'", + contents, + ) + + f.write(contents) + + +def build_input_remapper_module(root: str) -> None: + # I'd use --prefix and --root, but + # `pip install . --root ./build --prefix usr` + # makes it end up in ./build/usr/local + + # if root is not /, then input-remapper is built into some other directory for the + # purpose of merging it into / later. So regardless of root, we use the same + # package_dir. + package_dir = _get_packages_dir() + if package_dir.startswith("/"): + package_dir = package_dir[1:] + + target = os.path.join(root, package_dir) + + # Make sure we don't install input-remapper twice on that system into two different + # paths, which commonly causes unexpected behavior. Also, we need to specifically + # tell pip to replace an existing installation, or uninstall it beforehand. + if root == "/": + print("Uninstalling existing input-remapper installation") + os.system("pip uninstall input-remapper --break-system-packages -y") + # If root is not "/", it is probably part of a packaging script for a distro, + # which will take care of uninstalling the old python-package for us. + + command = [ + sys.executable, + "-m", + "pip", + "install", + ".", + "--target", + target, + "--no-deps", + ] + + print("Running", " ".join(command)) + + # Fix the stdout ordering in github workflows + sys.stdout.flush() + + subprocess.check_call(command) + + # pip puts its own leftovers into ./build that we don't need. + # This only happens, when root is set to "build". + if "build" in root: + if os.path.exists("./build/lib/"): + shutil.rmtree("./build/lib/") + if os.path.exists("./build/bdist.linux-x86_64/"): + shutil.rmtree("./build/bdist.linux-x86_64/") + + _set_variables(target) diff --git a/install/uninstall.py b/install/uninstall.py new file mode 100644 index 0000000..ff38f4b --- /dev/null +++ b/install/uninstall.py @@ -0,0 +1,77 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + +"""Remove a system-wide input-remapper installation.""" + +import os +import sys +import shutil +import subprocess + +from install.data_files import get_data_files + + +def uninstall() -> None: + # remove data files + for directory, files in get_data_files(): + for file_ in files: + filename = os.path.basename(file_) + path = os.path.join("/", directory, filename) + + # Removing files from system directories is risky. Low propability, very + # high damage. To avoid accidentally removing system directories due to + # a bug, assert that this is an input-remapper path. + # If this happens, urgently create a new issue on github! + assert "input" in path and "remapper" in path + + try: + os.unlink(path) + print("Removed", path) + except FileNotFoundError: + print(path, "not found") + + # language files are not in data_files + data_path = "/usr/share/input-remapper/" + try: + shutil.rmtree(data_path) + print("Removed", data_path) + except FileNotFoundError: + print(data_path, "not found") + + # remove pip module + command = [ + sys.executable, + "-m", + "pip", + "uninstall", + "input-remapper", + "--break-system-packages", + ] + print("Running", " ".join(command)) + # Fix the stdout ordering in github workflows + sys.stdout.flush() + subprocess.check_call(command) + + os.system("sudo systemctl stop input-remapper") + os.system("sudo systemctl daemon-reload") + + +if __name__ == "__main__": + uninstall() diff --git a/po/fr.po b/po/fr.po new file mode 120000 index 0000000..65af04b --- /dev/null +++ b/po/fr.po @@ -0,0 +1 @@ +fr_FR.po \ No newline at end of file diff --git a/po/fr_FR.po b/po/fr_FR.po new file mode 100644 index 0000000..31cf593 --- /dev/null +++ b/po/fr_FR.po @@ -0,0 +1,572 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +msgid "" +msgstr "" +"Project-Id-Version: \n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2023-05-16 08:30-0300\n" +"PO-Revision-Date: 2023-02-03 09:09+0100\n" +"Last-Translator: \n" +"Language-Team: \n" +"Language: fr_FR\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n > 1);\n" +"X-Generator: Poedit 3.2.2\n" + +#: inputremapper/gui/controller.py:222 +msgid "" +"\n" +"If you mean to create a key or macro mapping go to the advanced input " +"configuration and set a \"Trigger Threshold\" for " +msgstr "" + +#: inputremapper/gui/controller.py:239 +msgid "" +"\n" +"If you mean to create an analog axis mapping go to the advanced input " +"configuration and set an input to \"Use as Analog\"." +msgstr "" + +#: inputremapper/gui/controller.py:779 +msgid "" +"\n" +"The input \"{}\" will be used as analog input." +msgstr "" + +#: inputremapper/gui/controller.py:763 +msgid "" +"\n" +"This will remove \"{}\" from the text input!" +msgstr "" + +#: inputremapper/gui/controller.py:784 +msgid "" +"\n" +"You need to record an analog input." +msgstr "" + +#: data/input-remapper.glade:544 +msgid " (recording ...)" +msgstr "" + +#: inputremapper/gui/controller.py:174 +#, fuzzy, python-format +msgid "%d Mapping errors at \"%s\", hover for info" +msgstr "Erreur de syntaxe à %s, survoller pour plus d'informations" + +#: inputremapper/gui/controller.py:661 +msgid ", CTRL + DEL to stop" +msgstr ", CTRL + SUPPR pour arrêter" + +#: data/input-remapper.glade:1421 +msgid "About" +msgstr "À propos" + +#: data/input-remapper.glade:422 +msgid "" +"Activate this to load the preset next time the device connects, or when the " +"user logs in" +msgstr "" + +#: data/input-remapper.glade:575 +msgid "Add" +msgstr "" + +#: inputremapper/gui/components/editor.py:554 +msgid "Add a mapping first" +msgstr "" + +#: data/input-remapper.glade:606 data/input-remapper.glade:1618 +msgid "Advanced" +msgstr "" + +#: data/input-remapper.glade:773 data/input-remapper.glade:1118 +msgid "Analog Axis" +msgstr "" + +#: inputremapper/gui/controller.py:657 +#, python-format +msgid "Applied preset %s" +msgstr "Préréglage %s appliqué" + +#: data/input-remapper.glade:318 +msgid "Apply" +msgstr "Appliquer" + +#: inputremapper/gui/controller.py:522 +#, fuzzy, python-format +msgid "Are you sure you want to delete the preset \"%s\"?" +msgstr "Êtes-vous sûr de vouloir supprimer le préréglage %s ?" + +#: inputremapper/gui/controller.py:567 +#, fuzzy +msgid "Are you sure you want to delete this mapping?" +msgstr "Êtes-vous sûr de vouloir supprimer ce mapping ?" + +#: data/input-remapper.glade:410 +msgid "Autoload" +msgstr "Charger automatiquement" + +#: data/input-remapper.glade:854 +msgid "Available output axes are affected by the Target setting." +msgstr "" + +#: inputremapper/gui/controller.py:621 +msgid "Cannot apply empty preset file" +msgstr "Impossible d'appliquer un fichier de préréglage vide" + +#: inputremapper/gui/components/editor.py:238 +msgid "Change Mapping Name" +msgstr "" + +#: data/input-remapper.glade:352 +msgid "Copy" +msgstr "Copier" + +#: data/input-remapper.glade:189 +msgid "Create a new preset" +msgstr "Créer un nouveau préréglage" + +#: data/input-remapper.glade:925 +msgid "Deadzone" +msgstr "" + +#: data/input-remapper.glade:368 data/input-remapper.glade:620 +msgid "Delete" +msgstr "Supprimer" + +#: data/input-remapper.glade:624 +msgid "Delete this entry" +msgstr "Supprimer ce mapping" + +#: data/input-remapper.glade:372 +msgid "Delete this preset" +msgstr "Supprimer ce préréglage" + +#: data/input-remapper.glade:162 +#, fuzzy +msgid "Device Name" +msgstr "Périphérique" + +#: data/input-remapper.glade:148 +#, fuzzy +msgid "Devices" +msgstr "Périphérique" + +#: data/input-remapper.glade:356 +msgid "Duplicate this preset" +msgstr "Dupliquer ce préréglage" + +#: data/input-remapper.glade:1208 +msgid "Editor" +msgstr "" + +#: inputremapper/configs/mapping.py:77 +msgid "Empty Mapping" +msgstr "" + +#: inputremapper/gui/components/editor.py:404 +msgid "Enter your output here" +msgstr "" + +#: data/input-remapper.glade:1762 +msgid "Event Specific" +msgstr "" + +#: data/input-remapper.glade:1009 +msgid "Expo" +msgstr "" + +#: inputremapper/gui/controller.py:648 inputremapper/gui/controller.py:673 +#, python-format +msgid "Failed to apply preset %s" +msgstr "Échec de l'application du préréglage %s" + +#: data/input-remapper.glade:967 +msgid "Gain" +msgstr "" + +#: data/input-remapper.glade:1736 +msgid "General" +msgstr "" + +#: data/input-remapper.glade:1312 +msgid "Help" +msgstr "Aide" + +#: data/input-remapper.glade:510 +msgid "Input" +msgstr "" + +#: data/input-remapper.glade:1296 +msgid "Input Remapper" +msgstr "Input Remapper" + +#: data/input-remapper.glade:1087 +msgid "Input cutoff" +msgstr "" + +#: data/input-remapper.glade:760 data/input-remapper.glade:1180 +msgid "Key or Macro" +msgstr "" + +#: data/input-remapper.glade:1801 +msgid "Map this input to an Analog Axis" +msgstr "" + +#: data/input-remapper.glade:185 +msgid "New" +msgstr "Nouveau" + +#: inputremapper/gui/components/editor.py:980 +msgid "No Axis" +msgstr "" + +#: data/input-remapper.glade:721 +msgid "Output" +msgstr "" + +#: data/input-remapper.glade:862 +msgid "Output axis" +msgstr "" + +#: inputremapper/gui/controller.py:508 inputremapper/gui/controller.py:582 +msgid "Permission denied!" +msgstr "Autorisation refusée !" + +#: data/input-remapper.glade:287 +#, fuzzy +msgid "Preset Name" +msgstr "Préréglage" + +#: data/input-remapper.glade:272 +#, fuzzy +msgid "Presets" +msgstr "Préréglage" + +#: data/input-remapper.glade:590 +msgid "Record" +msgstr "" + +#: data/input-remapper.glade:594 +msgid "Record a button of your device that should be remapped" +msgstr "Enregistrer un bouton de votre périphérique qui devrait être remappé" + +#: inputremapper/gui/components/editor.py:563 +msgid "Record input first" +msgstr "" + +#: inputremapper/gui/components/editor.py:65 +#, fuzzy +msgid "Record the input first" +msgstr "Set the key first" + +#: data/input-remapper.glade:1708 +msgid "" +"Release all inputs which are part of the combination before the mapping is " +"injected" +msgstr "" + +#: data/input-remapper.glade:1711 +msgid "Release input" +msgstr "" + +#: data/input-remapper.glade:1683 +msgid "Release timeout" +msgstr "" + +#: inputremapper/gui/controller.py:208 +msgid "Remove the Analog Output Axis when specifying a macro or key output" +msgstr "" + +#: inputremapper/gui/controller.py:199 +msgid "" +"Remove the macro or key from the macro input field when specifying an analog " +"output" +msgstr "" + +#: data/input-remapper.glade:1776 +msgid "Remove this input" +msgstr "" + +#: data/input-remapper.glade:394 +msgid "Rename" +msgstr "Renommer" + +#: data/input-remapper.glade:452 +msgid "Save the entered name" +msgstr "Enregistrer le nom saisi" + +#: data/input-remapper.glade:1449 +msgid "" +"See usage.md online on github for comprehensive information.\n" +"\n" +"A \"key + key + ... + key\" syntax can be used to trigger key combinations. " +"For example \"Control_L + a\".\n" +"\n" +"Writing \"disable\" as a mapping disables a key.\n" +"\n" +"Macros allow multiple characters to be written with a single key-press. " +"Information about programming them is available online on github. See macros.md and examples.md" +msgstr "" +"Voir usage.md en ligne sur github pour des informations " +"complètes.\n" +"La syntaxe \"touche + touche + ... + touche\" peut être utilisée pour " +"déclencher des combinaisons de touches.\n" +"Par exemple \"Control_L + a\".\n" +"Écrire \"disable\" comme mapping désactive une touche.\n" +"Les macros permettent d'écrire plusieurs caractères avec un seul appui de " +"touche. Des informations sur leur programmation sont disponibles en ligne " +"sur github. See macros.md et examples.md" + +#: data/input-remapper.glade:1590 +msgid "Shortcuts" +msgstr "Raccourcis" + +#: data/input-remapper.glade:1492 +msgid "" +"Shortcuts only work while keys are not being recorded and the gui is in " +"focus." +msgstr "" +"Les raccourcis fonctionnent uniquement lorsque l'enregistrement des touches " +"n'est pas en cours et l'interface graphique a le focus." + +#: data/input-remapper.glade:322 +msgid "Start injecting. Don't hold down any keys while the injection starts" +msgstr "" +"Démarrer l'injection. Ne maintenir aucune touche pendant que l'injection " +"démarre" + +#: inputremapper/gui/controller.py:643 +msgid "Starting injection..." +msgstr "Injection en cours..." + +#: data/input-remapper.glade:201 data/input-remapper.glade:334 +msgid "Stop" +msgstr "" + +#: inputremapper/gui/controller.py:710 +#, fuzzy +msgid "Stopped the injection" +msgstr "arrête l'injection" + +#: data/input-remapper.glade:205 data/input-remapper.glade:338 +msgid "" +"Stops the Injection for the selected device,\n" +"gives your keys their original function back\n" +"Shortcut: ctrl + del" +msgstr "" + +#: data/input-remapper.glade:812 +msgid "Target" +msgstr "" + +#: data/input-remapper.glade:1078 +msgid "" +"The Speed at which the Input is considered at maximum.\n" +"Only relevant when mapping relative inputs (e.g. mouse) to absolute outputs " +"(e.g. gamepad)" +msgstr "" + +#: inputremapper/gui/controller.py:234 +msgid "" +"The input specifies a key or macro input, but no macro or key is programmed." +msgstr "" + +#: inputremapper/gui/controller.py:213 +msgid "The input specifies an analog axis, but no output axis is selected." +msgstr "" + +#: data/input-remapper.glade:803 +msgid "The type of device this mapping is emulating." +msgstr "Le type de périphérique émulé par ce mapping." + +#: data/input-remapper.glade:1831 +msgid "Trigger threshold" +msgstr "" + +#: data/input-remapper.glade:743 +msgid "Type" +msgstr "" + +#: data/input-remapper.glade:1473 +msgid "Usage" +msgstr "Utilisation" + +#: inputremapper/gui/controller.py:593 +#, fuzzy +msgid "Use \"Stop\" to stop before editing" +msgstr "Utiliser \"Arrêter l'injection\" pour arrêter avant l'édition" + +#: data/input-remapper.glade:1805 +msgid "Use as analog" +msgstr "" + +#: data/input-remapper.glade:1366 +msgid "Version unknown" +msgstr "Version inconnue" + +#: data/input-remapper.glade:1134 +msgid "What should be written. For example KEY_A" +msgstr "" + +#: inputremapper/gui/controller.py:761 +msgid "You are about to change the mapping to analog." +msgstr "" + +#: data/input-remapper.glade:1161 +msgid "You can copy this text into the output" +msgstr "" + +#: data/input-remapper.glade:1383 +msgid "" +"You can find more information and report bugs at\n" +"https://github.com/" +"sezanzeb/input-remapper" +msgstr "" +"Vous pouvez trouver plus d'informations ou signaler un bug sur\n" +"https://github.com/" +"sezanzeb/input-remapper" + +#: inputremapper/gui/controller.py:623 +#, fuzzy +msgid "You need to add mappings first" +msgstr "Vous devez d'abord ajouter des touches et sauvegarder" + +#: inputremapper/gui/controller.py:358 +#, fuzzy +msgid "" +"Your system might reinterpret combinations with those after they are " +"injected, and by doing so break them." +msgstr "avec celles-ci après leur injection, et ce faisant " + +#: data/input-remapper.glade:1524 +msgid "closes the application" +msgstr "ferme l'application" + +#: data/input-remapper.glade:1512 +msgid "ctrl + del" +msgstr "ctrl + del" + +#: data/input-remapper.glade:1536 +msgid "ctrl + q" +msgstr "ctrl + q" + +#: data/input-remapper.glade:1548 +msgid "ctrl + r" +msgstr "ctrl + r" + +#: inputremapper/gui/controller.py:356 +msgid "ctrl, alt and shift may not combine properly" +msgstr "ctrl, alt et shift peuvent ne pas se combiner correctement" + +#: inputremapper/gui/data_manager.py:56 +#, fuzzy +msgid "new preset" +msgstr "Créer un nouveau préréglage" + +#: data/input-remapper.glade:531 inputremapper/gui/user_interface.py:385 +msgid "no input configured" +msgstr "" + +#: data/input-remapper.glade:1560 +msgid "refreshes the device list" +msgstr "rafraîchit la liste des périphériques" + +#: data/input-remapper.glade:1572 +msgid "stops the injection" +msgstr "arrête l'injection" + +#: data/input-remapper.glade:1403 +#, fuzzy +msgid "" +"© 2025 Sezanzeb b8x45ygc9@mozmail.com\n" +"This program comes with absolutely no warranty.\n" +"See the GNU General " +"Public License, version 3 or later for details." +msgstr "" +"© 2021 Sezanzeb b8x45ygc9@mozmail.com\n" +"Ce programme est fourni sans aucune garantie.\n" +"Voir la Licence GNU " +"General Public, version 3 ou ultérieure pour plus de détails." + +#, python-format +#~ msgid "\"%s\" already mapped to \"%s\"" +#~ msgstr "\\\"%s\\\" déjà mappé sur \\\"%s\\\"" + +#~ msgid "Applied the system default" +#~ msgstr "Réglage système restauré" + +#~ msgid "Buttons" +#~ msgstr "Boutons" + +#~ msgid "Cancel" +#~ msgstr "Annuler" + +#~ msgid "Change Key" +#~ msgstr "Changer la touche" + +#~ msgid "Joystick" +#~ msgstr "Joystick" + +#~ msgid "Left joystick" +#~ msgstr "Joystick gauche" + +#~ msgid "Mouse" +#~ msgstr "Souris" + +#~ msgid "Mouse speed" +#~ msgstr "Vitesse de la souris" + +#~ msgid "Press Key" +#~ msgstr "Appuyer sur une touche" + +#~ msgid "Right joystick" +#~ msgstr "Joystick droite" + +#~ msgid "" +#~ "Shortcut: ctrl + del\n" +#~ "Gives your keys back their original function" +#~ msgstr "" +#~ "Raccourci : ctrl + del\n" +#~ "Réinitialise vos touches à leur fonctionnalité d'origine" + +#~ msgid "Stop Injection" +#~ msgstr "Arrêter l'injection" + +#~ msgid "The helper did not start" +#~ msgstr "Le helper n'a pas démarré" + +#~ msgid "" +#~ "To automatically apply the preset after your login or when it connects." +#~ msgstr "" +#~ "Pour appliquer automatiquement le préréglage après votre login ou à la " +#~ "connexion." + +#, python-format +#~ msgid "Unknown mapping %s" +#~ msgstr "Mapping inconnu %s" + +#~ msgid "Wheel" +#~ msgstr "Roulette" + +#~ msgid "Your system might reinterpret combinations " +#~ msgstr "Votre système peut réinterpréter les combinaisons " + +#~ msgid "break them." +#~ msgstr "les casser." + +#~ msgid "new entry" +#~ msgstr "nouveau mapping" diff --git a/po/input-remapper.pot b/po/input-remapper.pot new file mode 100644 index 0000000..4d5909e --- /dev/null +++ b/po/input-remapper.pot @@ -0,0 +1,467 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2023-05-16 08:30-0300\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" + +#: inputremapper/gui/controller.py:222 +msgid "" +"\n" +"If you mean to create a key or macro mapping go to the advanced input " +"configuration and set a \"Trigger Threshold\" for " +msgstr "" + +#: inputremapper/gui/controller.py:239 +msgid "" +"\n" +"If you mean to create an analog axis mapping go to the advanced input " +"configuration and set an input to \"Use as Analog\"." +msgstr "" + +#: inputremapper/gui/controller.py:779 +msgid "" +"\n" +"The input \"{}\" will be used as analog input." +msgstr "" + +#: inputremapper/gui/controller.py:763 +msgid "" +"\n" +"This will remove \"{}\" from the text input!" +msgstr "" + +#: inputremapper/gui/controller.py:784 +msgid "" +"\n" +"You need to record an analog input." +msgstr "" + +#: data/input-remapper.glade:544 +msgid " (recording ...)" +msgstr "" + +#: inputremapper/gui/controller.py:174 +#, python-format +msgid "%d Mapping errors at \"%s\", hover for info" +msgstr "" + +#: inputremapper/gui/controller.py:661 +msgid ", CTRL + DEL to stop" +msgstr "" + +#: data/input-remapper.glade:1421 +msgid "About" +msgstr "" + +#: data/input-remapper.glade:422 +msgid "" +"Activate this to load the preset next time the device connects, or when the " +"user logs in" +msgstr "" + +#: data/input-remapper.glade:575 +msgid "Add" +msgstr "" + +#: inputremapper/gui/components/editor.py:554 +msgid "Add a mapping first" +msgstr "" + +#: data/input-remapper.glade:606 data/input-remapper.glade:1618 +msgid "Advanced" +msgstr "" + +#: data/input-remapper.glade:773 data/input-remapper.glade:1118 +msgid "Analog Axis" +msgstr "" + +#: inputremapper/gui/controller.py:657 +#, python-format +msgid "Applied preset %s" +msgstr "" + +#: data/input-remapper.glade:318 +msgid "Apply" +msgstr "" + +#: inputremapper/gui/controller.py:522 +#, python-format +msgid "Are you sure you want to delete the preset \"%s\"?" +msgstr "" + +#: inputremapper/gui/controller.py:567 +msgid "Are you sure you want to delete this mapping?" +msgstr "" + +#: data/input-remapper.glade:410 +msgid "Autoload" +msgstr "" + +#: data/input-remapper.glade:854 +msgid "Available output axes are affected by the Target setting." +msgstr "" + +#: inputremapper/gui/controller.py:621 +msgid "Cannot apply empty preset file" +msgstr "" + +#: inputremapper/gui/components/editor.py:238 +msgid "Change Mapping Name" +msgstr "" + +#: data/input-remapper.glade:352 +msgid "Copy" +msgstr "" + +#: data/input-remapper.glade:189 +msgid "Create a new preset" +msgstr "" + +#: data/input-remapper.glade:925 +msgid "Deadzone" +msgstr "" + +#: data/input-remapper.glade:368 data/input-remapper.glade:620 +msgid "Delete" +msgstr "" + +#: data/input-remapper.glade:624 +msgid "Delete this entry" +msgstr "" + +#: data/input-remapper.glade:372 +msgid "Delete this preset" +msgstr "" + +#: data/input-remapper.glade:162 +msgid "Device Name" +msgstr "" + +#: data/input-remapper.glade:148 +msgid "Devices" +msgstr "" + +#: data/input-remapper.glade:356 +msgid "Duplicate this preset" +msgstr "" + +#: data/input-remapper.glade:1208 +msgid "Editor" +msgstr "" + +#: inputremapper/configs/mapping.py:77 +msgid "Empty Mapping" +msgstr "" + +#: inputremapper/gui/components/editor.py:404 +msgid "Enter your output here" +msgstr "" + +#: data/input-remapper.glade:1762 +msgid "Event Specific" +msgstr "" + +#: data/input-remapper.glade:1009 +msgid "Expo" +msgstr "" + +#: inputremapper/gui/controller.py:648 inputremapper/gui/controller.py:673 +#, python-format +msgid "Failed to apply preset %s" +msgstr "" + +#: data/input-remapper.glade:967 +msgid "Gain" +msgstr "" + +#: data/input-remapper.glade:1736 +msgid "General" +msgstr "" + +#: data/input-remapper.glade:1312 +msgid "Help" +msgstr "" + +#: data/input-remapper.glade:510 +msgid "Input" +msgstr "" + +#: data/input-remapper.glade:1296 +msgid "Input Remapper" +msgstr "" + +#: data/input-remapper.glade:1087 +msgid "Input cutoff" +msgstr "" + +#: data/input-remapper.glade:760 data/input-remapper.glade:1180 +msgid "Key or Macro" +msgstr "" + +#: data/input-remapper.glade:1801 +msgid "Map this input to an Analog Axis" +msgstr "" + +#: data/input-remapper.glade:185 +msgid "New" +msgstr "" + +#: inputremapper/gui/components/editor.py:980 +msgid "No Axis" +msgstr "" + +#: data/input-remapper.glade:721 +msgid "Output" +msgstr "" + +#: data/input-remapper.glade:862 +msgid "Output axis" +msgstr "" + +#: inputremapper/gui/controller.py:508 inputremapper/gui/controller.py:582 +msgid "Permission denied!" +msgstr "" + +#: data/input-remapper.glade:287 +msgid "Preset Name" +msgstr "" + +#: data/input-remapper.glade:272 +msgid "Presets" +msgstr "" + +#: data/input-remapper.glade:590 +msgid "Record" +msgstr "" + +#: data/input-remapper.glade:594 +msgid "Record a button of your device that should be remapped" +msgstr "" + +#: inputremapper/gui/components/editor.py:563 +msgid "Record input first" +msgstr "" + +#: inputremapper/gui/components/editor.py:65 +msgid "Record the input first" +msgstr "" + +#: data/input-remapper.glade:1708 +msgid "" +"Release all inputs which are part of the combination before the mapping is " +"injected" +msgstr "" + +#: data/input-remapper.glade:1711 +msgid "Release input" +msgstr "" + +#: data/input-remapper.glade:1683 +msgid "Release timeout" +msgstr "" + +#: inputremapper/gui/controller.py:208 +msgid "Remove the Analog Output Axis when specifying a macro or key output" +msgstr "" + +#: inputremapper/gui/controller.py:199 +msgid "" +"Remove the macro or key from the macro input field when specifying an analog " +"output" +msgstr "" + +#: data/input-remapper.glade:1776 +msgid "Remove this input" +msgstr "" + +#: data/input-remapper.glade:394 +msgid "Rename" +msgstr "" + +#: data/input-remapper.glade:452 +msgid "Save the entered name" +msgstr "" + +#: data/input-remapper.glade:1449 +msgid "" +"See usage.md online on github for comprehensive information.\n" +"\n" +"A \"key + key + ... + key\" syntax can be used to trigger key combinations. " +"For example \"Control_L + a\".\n" +"\n" +"Writing \"disable\" as a mapping disables a key.\n" +"\n" +"Macros allow multiple characters to be written with a single key-press. " +"Information about programming them is available online on github. See macros.md and examples.md" +msgstr "" + +#: data/input-remapper.glade:1590 +msgid "Shortcuts" +msgstr "" + +#: data/input-remapper.glade:1492 +msgid "" +"Shortcuts only work while keys are not being recorded and the gui is in " +"focus." +msgstr "" + +#: data/input-remapper.glade:322 +msgid "Start injecting. Don't hold down any keys while the injection starts" +msgstr "" + +#: inputremapper/gui/controller.py:643 +msgid "Starting injection..." +msgstr "" + +#: data/input-remapper.glade:201 data/input-remapper.glade:334 +msgid "Stop" +msgstr "" + +#: inputremapper/gui/controller.py:710 +msgid "Stopped the injection" +msgstr "" + +#: data/input-remapper.glade:205 data/input-remapper.glade:338 +msgid "" +"Stops the Injection for the selected device,\n" +"gives your keys their original function back\n" +"Shortcut: ctrl + del" +msgstr "" + +#: data/input-remapper.glade:812 +msgid "Target" +msgstr "" + +#: data/input-remapper.glade:1078 +msgid "" +"The Speed at which the Input is considered at maximum.\n" +"Only relevant when mapping relative inputs (e.g. mouse) to absolute outputs " +"(e.g. gamepad)" +msgstr "" + +#: inputremapper/gui/controller.py:234 +msgid "" +"The input specifies a key or macro input, but no macro or key is programmed." +msgstr "" + +#: inputremapper/gui/controller.py:213 +msgid "The input specifies an analog axis, but no output axis is selected." +msgstr "" + +#: data/input-remapper.glade:803 +msgid "The type of device this mapping is emulating." +msgstr "" + +#: data/input-remapper.glade:1831 +msgid "Trigger threshold" +msgstr "" + +#: data/input-remapper.glade:743 +msgid "Type" +msgstr "" + +#: data/input-remapper.glade:1473 +msgid "Usage" +msgstr "" + +#: inputremapper/gui/controller.py:593 +msgid "Use \"Stop\" to stop before editing" +msgstr "" + +#: data/input-remapper.glade:1805 +msgid "Use as analog" +msgstr "" + +#: data/input-remapper.glade:1366 +msgid "Version unknown" +msgstr "" + +#: data/input-remapper.glade:1134 +msgid "What should be written. For example KEY_A" +msgstr "" + +#: inputremapper/gui/controller.py:761 +msgid "You are about to change the mapping to analog." +msgstr "" + +#: data/input-remapper.glade:1161 +msgid "You can copy this text into the output" +msgstr "" + +#: data/input-remapper.glade:1383 +msgid "" +"You can find more information and report bugs at\n" +"https://github.com/" +"sezanzeb/input-remapper" +msgstr "" + +#: inputremapper/gui/controller.py:623 +msgid "You need to add mappings first" +msgstr "" + +#: inputremapper/gui/controller.py:358 +msgid "" +"Your system might reinterpret combinations with those after they are " +"injected, and by doing so break them." +msgstr "" + +#: data/input-remapper.glade:1524 +msgid "closes the application" +msgstr "" + +#: data/input-remapper.glade:1512 +msgid "ctrl + del" +msgstr "" + +#: data/input-remapper.glade:1536 +msgid "ctrl + q" +msgstr "" + +#: data/input-remapper.glade:1548 +msgid "ctrl + r" +msgstr "" + +#: inputremapper/gui/controller.py:356 +msgid "ctrl, alt and shift may not combine properly" +msgstr "" + +#: inputremapper/gui/data_manager.py:56 +msgid "new preset" +msgstr "" + +#: data/input-remapper.glade:531 inputremapper/gui/user_interface.py:385 +msgid "no input configured" +msgstr "" + +#: data/input-remapper.glade:1560 +msgid "refreshes the device list" +msgstr "" + +#: data/input-remapper.glade:1572 +msgid "stops the injection" +msgstr "" + +#: data/input-remapper.glade:1403 +msgid "" +"© 2025 Sezanzeb b8x45ygc9@mozmail.com\n" +"This program comes with absolutely no warranty.\n" +"See the GNU General " +"Public License, version 3 or later for details." +msgstr "" diff --git a/po/it.po b/po/it.po new file mode 120000 index 0000000..015154a --- /dev/null +++ b/po/it.po @@ -0,0 +1 @@ +./it_IT.po \ No newline at end of file diff --git a/po/it_IT.po b/po/it_IT.po new file mode 100644 index 0000000..3d6dfad --- /dev/null +++ b/po/it_IT.po @@ -0,0 +1,684 @@ +# ITALIAN TRANSLATION FOR INPUT-REMAPPER. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# ALBANO BATTISTELLA , 2021,2023. +# +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2023-05-16 08:30-0300\n" +"PO-Revision-Date: 2023-11-02 20:00+0200\n" +"Last-Translator: Albano Battistella \n" +"Language-Team: ITALIAN \n" +"Language: it\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" + +#: inputremapper/gui/controller.py:222 +msgid "" +"\n" +"If you mean to create a key or macro mapping go to the advanced input " +"configuration and set a \"Trigger Threshold\" for " +msgstr "" +"\n" +"Se intendi creare una mappatura di tasti o macro vai all'input avanzato " +"e impostare una \"Soglia di attivazione\" per " + +#: inputremapper/gui/controller.py:239 +msgid "" +"\n" +"If you mean to create an analog axis mapping go to the advanced input " +"configuration and set an input to \"Use as Analog\"." +msgstr "" +"\n" +"Se intendi creare una mappatura degli assi analogici vai all'input avanzato " +"configurazione e impostare un ingresso su \"Utilizza come analogico\"." + +#: inputremapper/gui/controller.py:779 +msgid "" +"\n" +"The input \"{}\" will be used as analog input." +msgstr "" +"\n" +"L'ingresso \"{}\" verrà utilizzato come ingresso analogico." + +#: inputremapper/gui/controller.py:763 +msgid "" +"\n" +"This will remove \"{}\" from the text input!" +msgstr "" +"\n" +"Questo rimuoverà \"{}\" dal testo inserito!" + +#: inputremapper/gui/controller.py:784 +msgid "" +"\n" +"You need to record an analog input." +msgstr "" +"\n" +"Devi registrare un ingresso analogico." + +#: data/input-remapper.glade:544 +msgid " (recording ...)" +msgstr " (registrazione ...)" + +#: inputremapper/gui/controller.py:174 +#, python-format +msgid "%d Mapping errors at \"%s\", hover for info" +msgstr "%d Errori di mappatura in \"%s\", passa il mouse per informazioni" + +#: inputremapper/gui/controller.py:661 +msgid ", CTRL + DEL to stop" +msgstr ",CTRL + CANC per interrompere" + +#: data/input-remapper.glade:1421 +msgid "About" +msgstr "Informazioni" + +#: data/input-remapper.glade:422 +msgid "" +"Activate this to load the preset next time the device connects, or when the " +"user logs in" +msgstr "" +"Attivarlo per caricare la preimpostazione la prossima volta che il dispositivo si connette o quando " +"l'utente accede" + +#: data/input-remapper.glade:575 +msgid "Add" +msgstr "Aggiungi" + +#: inputremapper/gui/components/editor.py:554 +msgid "Add a mapping first" +msgstr "Aggiungi prima una mappatura" + +#: data/input-remapper.glade:606 data/input-remapper.glade:1618 +msgid "Advanced" +msgstr "Avanzato" + +#: data/input-remapper.glade:773 data/input-remapper.glade:1118 +msgid "Analog Axis" +msgstr "Asse analogico" + +#: inputremapper/gui/controller.py:657 +#, python-format +msgid "Applied preset %s" +msgstr "Preimpostazione applicata %s" + +#: data/input-remapper.glade:318 +msgid "Apply" +msgstr "Applica" + +#: inputremapper/gui/controller.py:522 +#, python-format +msgid "Are you sure you want to delete the preset \"%s\"?" +msgstr "Sei sicuro di voler eliminare la preimpostazione \"%s\"?" + +#: inputremapper/gui/controller.py:567 +msgid "Are you sure you want to delete this mapping?" +msgstr "Sei sicuro di voler eliminare questa mappatura?" + +#: data/input-remapper.glade:410 +msgid "Autoload" +msgstr "Caricamento automatico" + +#: data/input-remapper.glade:854 +msgid "Available output axes are affected by the Target setting." +msgstr "Gli assi di uscita disponibili sono influenzati dall'impostazione Target." + +#: inputremapper/gui/controller.py:621 +msgid "Cannot apply empty preset file" +msgstr "Impossibile applicare un file preimpostato vuoto" + +#: inputremapper/gui/components/editor.py:238 +msgid "Change Mapping Name" +msgstr "Modifica nome mappatura" + +#: data/input-remapper.glade:352 +msgid "Copy" +msgstr "Copia" + +#: data/input-remapper.glade:189 +msgid "Create a new preset" +msgstr "Crea una nuova preimpostazione" + +#: data/input-remapper.glade:925 +msgid "Deadzone" +msgstr "Zona morta" + +#: data/input-remapper.glade:368 data/input-remapper.glade:620 +msgid "Delete" +msgstr "Cancella" + +#: data/input-remapper.glade:624 +msgid "Delete this entry" +msgstr "Elimina questa voce" + +#: data/input-remapper.glade:372 +msgid "Delete this preset" +msgstr "Elimina questa preimpostazione" + +#: data/input-remapper.glade:162 +msgid "Device Name" +msgstr "Nome dispositivo" + +#: data/input-remapper.glade:148 +msgid "Devices" +msgstr "Dispositivi" + +#: data/input-remapper.glade:356 +msgid "Duplicate this preset" +msgstr "Duplica questa preimpostazione" + +#: data/input-remapper.glade:1208 +msgid "Editor" +msgstr "" + +#: inputremapper/configs/mapping.py:77 +msgid "Empty Mapping" +msgstr "Mappatura vuota" + +#: inputremapper/gui/components/editor.py:404 +msgid "Enter your output here" +msgstr "Inserisci il tuo output qui" + +#: data/input-remapper.glade:1762 +msgid "Event Specific" +msgstr "Evento specifico" + +#: data/input-remapper.glade:1009 +msgid "Expo" +msgstr "" + +#: inputremapper/gui/controller.py:648 inputremapper/gui/controller.py:673 +#, python-format +msgid "Failed to apply preset %s" +msgstr "Impossibile applicare la preimpostazione %s" + +#: data/input-remapper.glade:967 +msgid "Gain" +msgstr "Guadagno" + +#: data/input-remapper.glade:1736 +msgid "General" +msgstr "Generale" + +#: data/input-remapper.glade:1312 +msgid "Help" +msgstr "Aiuto" + +#: data/input-remapper.glade:510 +msgid "Input" +msgstr "Input" + +#: data/input-remapper.glade:1296 +msgid "Input Remapper" +msgstr "Input Remapper" + +#: data/input-remapper.glade:1087 +msgid "Input cutoff" +msgstr "" + +#: data/input-remapper.glade:760 data/input-remapper.glade:1180 +msgid "Key or Macro" +msgstr "Tasto o macro" + +#: data/input-remapper.glade:1801 +msgid "Map this input to an Analog Axis" +msgstr "Mappa questo ingresso su un asse analogico" + +#: data/input-remapper.glade:185 +msgid "New" +msgstr "Nuovo" + +#: inputremapper/gui/components/editor.py:980 +msgid "No Axis" +msgstr "Nessun asse" + +#: data/input-remapper.glade:721 +msgid "Output" +msgstr "Output" + +#: data/input-remapper.glade:862 +msgid "Output axis" +msgstr "Asse di uscita" + +#: inputremapper/gui/controller.py:508 inputremapper/gui/controller.py:582 +msgid "Permission denied!" +msgstr "Permesso negato!" + +#: data/input-remapper.glade:287 +msgid "Preset Name" +msgstr "Nome preimpostazione" + +#: data/input-remapper.glade:272 +msgid "Presets" +msgstr "Preimpostazioni" + +#: data/input-remapper.glade:590 +msgid "Record" +msgstr "Registra" + +#: data/input-remapper.glade:594 +msgid "Record a button of your device that should be remapped" +msgstr "Registra un pulsante del tuo dispositivo che dovrebbe essere rimappato" + +#: inputremapper/gui/components/editor.py:563 +msgid "Record input first" +msgstr "Registrare prima l'input" + +#: inputremapper/gui/components/editor.py:65 +msgid "Record the input first" +msgstr "Registrare prima l'input" + +#: data/input-remapper.glade:1708 +msgid "" +"Release all inputs which are part of the combination before the mapping is " +"injected" +msgstr "" +"Rilascia tutti gli input che fanno parte della combinazione prima che la mappatura sia " +"iniettata" + +#: data/input-remapper.glade:1711 +msgid "Release input" +msgstr "Rilascia l'input" + +#: data/input-remapper.glade:1683 +msgid "Release timeout" +msgstr "Rilascia l'input" + +#: inputremapper/gui/controller.py:208 +msgid "Remove the Analog Output Axis when specifying a macro or key output" +msgstr "Rimuovere l'asse di uscita analogica quando si specifica una macro o un tasto d'uscita" + +#: inputremapper/gui/controller.py:199 +msgid "" +"Remove the macro or key from the macro input field when specifying an analog " +"output" +msgstr "" +"Rimuovere la macro o il tasto dal campo di immissione macro quando si specifica un output " +"analogico" + +#: data/input-remapper.glade:1776 +msgid "Remove this input" +msgstr "Rimuovi questo input" + +#: data/input-remapper.glade:394 +msgid "Rename" +msgstr "Rinomina" + +#: data/input-remapper.glade:452 +msgid "Save the entered name" +msgstr "Salva il nome inserito" + +#: data/input-remapper.glade:1449 +msgid "" +"See usage.md online on github for comprehensive information.\n" +"\n" +"A \"key + key + ... + key\" syntax can be used to trigger key combinations. " +"For example \"Control_L + a\".\n" +"\n" +"Writing \"disable\" as a mapping disables a key.\n" +"\n" +"Macros allow multiple characters to be written with a single key-press. " +"Information about programming them is available online on github. See macros.md and examples.md" +msgstr "" +"Vedi usage.md online su github per informazioni complete.\n" +"\n" +"Una sintassi \"tasto + tasto + ... + tasto\" può essere utilizzata per attivare combinazioni di tasti. " +"Ad esempio \"Control_L + a\".\n" +"\n" +"Scrivere \"disabilita\" come mappatura disabilitata di un tasto.\n" +"\n" +"Le macro consentono di scrivere più caratteri premendo un solo tasto. " +"Le informazioni su come programmarli sono disponibili online su github. Vedi macros.md e examples.md" + +#: data/input-remapper.glade:1590 +msgid "Shortcuts" +msgstr "Scorciatoie" + +#: data/input-remapper.glade:1492 +msgid "" +"Shortcuts only work while keys are not being recorded and the gui is in " +"focus." +msgstr "" +"Le scorciatoie funzionano solo mentre i tasti non vengono registrati e la " +"GUI è in messa a fuoco." + +#: data/input-remapper.glade:322 +msgid "Start injecting. Don't hold down any keys while the injection starts" +msgstr "Inizia a iniettare. Non tenere premuto alcun tasto durante l'avvio dell'iniezione" + +#: inputremapper/gui/controller.py:643 +msgid "Starting injection..." +msgstr "Avvio dell'iniezione..." + +#: data/input-remapper.glade:201 data/input-remapper.glade:334 +msgid "Stop" +msgstr "Ferma" + +#: inputremapper/gui/controller.py:710 +msgid "Stopped the injection" +msgstr "Fermata l'iniezione" + +#: data/input-remapper.glade:205 data/input-remapper.glade:338 +msgid "" +"Stops the Injection for the selected device,\n" +"gives your keys their original function back\n" +"Shortcut: ctrl + del" +msgstr"" +"Interrompe l'iniezione per il dispositivo selezionato,\n" +"ridona ai tuoi tasti la loro funzione originale\n" +"Scorciatoia: ctrl + canc" + +#: data/input-remapper.glade:812 +msgid "Target" +msgstr "" + +#: data/input-remapper.glade:1078 +msgid "" +"The Speed at which the Input is considered at maximum.\n" +"Only relevant when mapping relative inputs (e.g. mouse) to absolute outputs " +"(e.g. gamepad)" +msgstr "" +"La velocità alla quale l'input è considerato massimo.\n" +"Rilevante solo quando si mappano input relativi (ad esempio mouse) su output assoluti" +"(ad esempio gamepad)" + +#: inputremapper/gui/controller.py:234 +msgid "" +"The input specifies a key or macro input, but no macro or key is programmed." +msgstr "" +"L'ingresso specifico ad un tasto o un ingresso macro, ma non è programmata alcuna macro o tasto." + +#: inputremapper/gui/controller.py:213 +msgid "The input specifies an analog axis, but no output axis is selected." +msgstr "L'ingresso specifica un asse analogico, ma nessun asse di uscita è selezionato." + +#: data/input-remapper.glade:803 +msgid "The type of device this mapping is emulating." +msgstr "Il tipo di dispositivo che questa mappatura sta emulando." + +#: data/input-remapper.glade:1831 +msgid "Trigger threshold" +msgstr "Soglia di attivazione" + +#: data/input-remapper.glade:743 +msgid "Type" +msgstr "Tipo" + +#: data/input-remapper.glade:1473 +msgid "Usage" +msgstr "Uso" + +#: inputremapper/gui/controller.py:593 +msgid "Use \"Stop\" to stop before editing" +msgstr "Utilizza \"Ferma\" per interrompere prima della modifica" + +#: data/input-remapper.glade:1805 +msgid "Use as analog" +msgstr "Usa come analogico" + +#: data/input-remapper.glade:1366 +msgid "Version unknown" +msgstr "Versione sconosciuta" + +#: data/input-remapper.glade:1134 +msgid "What should be written. For example KEY_A" +msgstr "Cosa dovrebbe essere scritto. Ad esempio KEY_A" + +#: inputremapper/gui/controller.py:761 +msgid "You are about to change the mapping to analog." +msgstr "Stai per cambiare la mappatura in analogica." + +#: data/input-remapper.glade:1161 +msgid "You can copy this text into the output" +msgstr "Puoi copiare questo testo nell'output" + +#: data/input-remapper.glade:1383 +msgid "" +"You can find more information and report bugs at\n" +"https://github.com/" +"sezanzeb/input-remapper" +msgstr "" +"Puoi trovare maggiori informazioni e segnalare bug su\n" +"https://github.com/" +"sezanzeb/input-remapper" + +#: inputremapper/gui/controller.py:623 +msgid "You need to add mappings first" +msgstr "È necessario prima aggiungere le mappature" + +#: inputremapper/gui/controller.py:358 +msgid "" +"Your system might reinterpret combinations with those after they are " +"injected, and by doing so break them." +msgstr "" +"Il tuo sistema potrebbe reinterpretare le combinazioni con quelle successive " +"iniettati, e così facendo li romperete." + +#: data/input-remapper.glade:1524 +msgid "closes the application" +msgstr "chiude l'applicazione" + +#: data/input-remapper.glade:1512 +msgid "ctrl + del" +msgstr "ctrl + del" + +#: data/input-remapper.glade:1536 +msgid "ctrl + q" +msgstr "ctrl + q" + +#: data/input-remapper.glade:1548 +msgid "ctrl + r" +msgstr "ctrl + r" + +#: inputremapper/gui/controller.py:356 +msgid "ctrl, alt and shift may not combine properly" +msgstr "ctrl, alt e shift potrebbero non combinarsi correttamente" + +#: inputremapper/gui/data_manager.py:56 +msgid "new preset" +msgstr "Nuova preinpostazione" + +#: data/input-remapper.glade:531 inputremapper/gui/user_interface.py:385 +msgid "no input configured" +msgstr "nessun imput configurato" + +#: data/input-remapper.glade:1560 +msgid "refreshes the device list" +msgstr "aggiorna l'elenco dei dispositivi" + +#: data/input-remapper.glade:1572 +msgid "stops the injection" +msgstr "ferma l'iniezione" + +#: data/input-remapper.glade:1403 +msgid "" +"© 2025 Sezanzeb b8x45ygc9@mozmail.com\n" +"This program comes with absolutely no warranty.\n" +"See the GNU General " +"Public License, version 3 or later for details." +msgstr "" +"© 2025 Sezanzeb b8x45ygc9@mozmail.com\n" +"Questo programma non ha assolutamente alcuna garanzia.\n" +"Vedi il Generale GNU " +"Licenza pubblica, versione 3 o successiva per i dettagli." + +#~ msgid "." +#~ msgstr "." + +#~ msgid "1, 2" +#~ msgstr "1, 2" + +#~ msgid "" +#~ "A \"key + key + ... + key\" syntax can be used to trigger key " +#~ "combinations. For example \"control_l + a\".\n" +#~ "\n" +#~ "\"disable\" disables a key." +#~ msgstr "" +#~ "Sintassi\"tasti + tasti + ... + tasti\"puoi usare per entrare nella " +#~ "combinazioni di tasti. Ad esempio \"control_l + a\".\n" +#~ "\n" +#~ "\"disabilita\"disabilitato la mappatura dei tasti." + +#~ msgid "" +#~ "Between calls to k, key down and key up events, macros will sleep for " +#~ "10ms by default, which can be configured in ~/.config/input-remapper/" +#~ "config" +#~ msgstr "" +#~ "Tra chiamate a k, battitura ed eventi rilasciati,le macro dormiranno per " +#~ "10 ms per impostazione predefinita, che può essere configurato in ~/." +#~ "config/input-remapper/config" + +#~ msgid "Buttons" +#~ msgstr "Pulsanti" + +#~ msgid "CTRL + a, CTRL + x" +#~ msgstr "CTRL + a, CTRL + x" + +#~ msgid "" +#~ "Click on a cell below and hit a key on your device. Click the \"Restore " +#~ "Defaults\" button beforehand." +#~ msgstr "" +#~ "Fai clic su una cella in basso e premi un tasto sul dispositivo. Fai clic " +#~ "su \"Ripristina Pulsante \"Predefiniti\" in anticipo." + +#~ msgid "Displays additional debug information" +#~ msgstr "Visualizza ulteriori informazioni di debug" + +#~ msgid "Examples" +#~ msgstr "Esempi" + +#~ msgid "Go Back" +#~ msgstr "Indietro" + +#~ msgid "Joystick" +#~ msgstr "Joystick" + +#~ msgid "Key" +#~ msgstr "Tasto" + +#~ msgid "Left joystick" +#~ msgstr "Joystick sinistro" + +#~ msgid "Macros" +#~ msgstr "Macro" + +#~ msgid "" +#~ "Macros allow multiple characters to be written with a single key-press." +#~ msgstr "" +#~ "Le macro consentono di scrivere più caratteri premendo un solo tasto." + +#~ msgid "Mouse" +#~ msgstr "Mouse" + +#~ msgid "Mouse speed" +#~ msgstr "Velocità mouse" + +#~ msgid "Right joystick" +#~ msgstr "Joystick destro" + +#~ msgid "" +#~ "Shortcut: ctrl + del\n" +#~ "To give your keys back their original mapping." +#~ msgstr "" +#~ "Scorciatoia: ctrl + del\n" +#~ "Per restituire alle tue chiavi la loro mappatura originale." + +#~ msgid "" +#~ "To automatically apply the preset after your login or when it connects." +#~ msgstr "" +#~ "Per applicare automaticamente il predefinito dopo il login o quando si " +#~ "connette." + +#~ msgid "a, a, a with 500ms pause" +#~ msgstr "a, a, a con pausa di 500 ms" + +#~ msgid "e" +#~ msgstr "e" + +#~ msgid "e(EV_REL, REL_X, 10)" +#~ msgstr "e(EV_REL, REL_X, 10)" + +#~ msgid "executes the parameter as long as the key is pressed down" +#~ msgstr "esegue il parametro fintanto che si tiene premuto il tasto" + +#~ msgid "executes two actions behind each other" +#~ msgstr "esegue due azioni una dietro l'altra" + +#~ msgid "h" +#~ msgstr "h" + +#~ msgid "holds a modifier while executing the second parameter" +#~ msgstr "contiene un modificatore durante l'esecuzione del secondo parametro" + +#~ msgid "k" +#~ msgstr "k" + +#~ msgid "k(1).h(k(2)).k(3)" +#~ msgstr "k(1).h(k(2)).k(3)" + +#~ msgid "k(1).k(2)" +#~ msgstr "k(1).k(2)" + +#~ msgid "keeps scrolling down while held" +#~ msgstr "continua a scorrere verso il basso mentre si tiene premuto" + +#~ msgid "m" +#~ msgstr "m" + +#~ msgid "m(Control_L, k(a).k(x))" +#~ msgstr "m(Control_L, k(a).k(x))" + +#~ msgid "mouse" +#~ msgstr "mouse" + +#~ msgid "mouse(right, 4)" +#~ msgstr "mouse(destra, 4)" + +#~ msgid "moves the mouse cursor 10px to the right" +#~ msgstr "sposta il cursore del mouse di 10 pixel a destra" + +#~ msgid "r" +#~ msgstr "r" + +#~ msgid "r(3, k(a).w(500))" +#~ msgstr "r(3, k(a).w(500))" + +#~ msgid "repeats the execution of the second parameter" +#~ msgstr "ripete l'esecuzione del secondo parametro" + +#~ msgid "same as mouse" +#~ msgstr "come al mouse" + +#~ msgid "takes direction (up, left, ...) and speed as parameters" +#~ msgstr "" +#~ "prende la direzione (su, sinistra, ...) e la velocità come parametri" + +#~ msgid "w" +#~ msgstr "w" + +#~ msgid "waits in milliseconds" +#~ msgstr "in attesa (in millisecondi)" + +#~ msgid "which keeps moving the mouse while pressed" +#~ msgstr "che continua a muovere il mouse mentre viene premuto" + +#~ msgid "writes 1 2 2 ... 2 2 3 while the key is pressed" +#~ msgstr "scrive 1 2 2 ... 2 2 3 mentre il tasto è premuto" + +#~ msgid "writes a single keystroke" +#~ msgstr "scrive una singola sequenza di tasti" + +#~ msgid "writes an event" +#~ msgstr "scrive un evento" diff --git a/po/pt.po b/po/pt.po new file mode 120000 index 0000000..c733dd6 --- /dev/null +++ b/po/pt.po @@ -0,0 +1 @@ +pt_BR.po \ No newline at end of file diff --git a/po/pt_BR.po b/po/pt_BR.po new file mode 100644 index 0000000..f08ab96 --- /dev/null +++ b/po/pt_BR.po @@ -0,0 +1,525 @@ +# Portuguese translations for input-mapper package +# Traduções em português brasileiro para o pacote input-mapper. +# Copyright (C) 2023 THE input-mapper'S COPYRIGHT HOLDER +# This file is distributed under the same license as the input-mapper package. +# Rafael Fontenelle , 2023. +# +msgid "" +msgstr "" +"Project-Id-Version: input-mapper\n" +"Report-Msgid-Bugs-To: https://github.com/sezanzeb/input-remapper/issues\n" +"POT-Creation-Date: 2023-05-16 08:30-0300\n" +"PO-Revision-Date: 2023-05-16 08:56-0300\n" +"Last-Translator: Rafael Fontenelle \n" +"Language-Team: Brazilian Portuguese\n" +"Language: pt_BR\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n > 1)\n" +"X-Generator: Gtranslator 42.0\n" + +#: inputremapper/gui/controller.py:222 +msgid "" +"\n" +"If you mean to create a key or macro mapping go to the advanced input " +"configuration and set a \"Trigger Threshold\" for " +msgstr "" +"\n" +"Se você pretende criar um mapeamento de macro ou tecla, vá para a " +"configuração avançada de entrada e defina um \"Limiar de acionamento\"" + +#: inputremapper/gui/controller.py:239 +msgid "" +"\n" +"If you mean to create an analog axis mapping go to the advanced input " +"configuration and set an input to \"Use as Analog\"." +msgstr "" +"\n" +"Se você deseja criar um mapeamento de eixo analógico, vá para a configuração " +"avançada de entrada e defina uma entrada para \"Usar como analógico\"." + +#: inputremapper/gui/controller.py:779 +msgid "" +"\n" +"The input \"{}\" will be used as analog input." +msgstr "" +"\n" +"A entrada \"{}\" será usada como entrada analógica." + +#: inputremapper/gui/controller.py:763 +msgid "" +"\n" +"This will remove \"{}\" from the text input!" +msgstr "" +"\n" +"Isso removerá \"{}\" da entrada de texto!" + +#: inputremapper/gui/controller.py:784 +msgid "" +"\n" +"You need to record an analog input." +msgstr "" +"\n" +"Você precisa gravar uma entrada analógica." + +#: data/input-remapper.glade:544 +msgid " (recording ...)" +msgstr " (gravando ...)" + +#: inputremapper/gui/controller.py:174 +#, python-format +msgid "%d Mapping errors at \"%s\", hover for info" +msgstr "%d erros de mapeamento em \"%s\", passe o mouse para obter informações" + +#: inputremapper/gui/controller.py:661 +msgid ", CTRL + DEL to stop" +msgstr ", CTRL + DEL para interromper" + +#: data/input-remapper.glade:1421 +msgid "About" +msgstr "Sobre" + +#: data/input-remapper.glade:422 +msgid "" +"Activate this to load the preset next time the device connects, or when the " +"user logs in" +msgstr "" +"Ative para carregar a predefinição na próxima vez que o dispositivo se " +"conectar ou quando o usuário fizer login" + +#: data/input-remapper.glade:575 +msgid "Add" +msgstr "Adicionar" + +#: inputremapper/gui/components/editor.py:554 +msgid "Add a mapping first" +msgstr "Adicione um mapeamento primeiro" + +#: data/input-remapper.glade:606 data/input-remapper.glade:1618 +msgid "Advanced" +msgstr "Avançado" + +#: data/input-remapper.glade:773 data/input-remapper.glade:1118 +msgid "Analog Axis" +msgstr "Eixo analógico" + +#: inputremapper/gui/controller.py:657 +#, python-format +msgid "Applied preset %s" +msgstr "Aplicada a predefinição %s" + +#: data/input-remapper.glade:318 +msgid "Apply" +msgstr "Aplicar" + +#: inputremapper/gui/controller.py:522 +#, python-format +msgid "Are you sure you want to delete the preset \"%s\"?" +msgstr "Tem certeza que deseja excluir a predefinição \"%s\"?" + +#: inputremapper/gui/controller.py:567 +msgid "Are you sure you want to delete this mapping?" +msgstr "Tem certeza que deseja excluir este mapeamento?" + +#: data/input-remapper.glade:410 +msgid "Autoload" +msgstr "Autocarregamento" + +#: data/input-remapper.glade:854 +msgid "Available output axes are affected by the Target setting." +msgstr "Os eixos de saída disponíveis são afetados pela configuração de Alvo." + +#: inputremapper/gui/controller.py:621 +msgid "Cannot apply empty preset file" +msgstr "Não é possível aplicar um arquivo de predefinição vazio" + +#: inputremapper/gui/components/editor.py:238 +msgid "Change Mapping Name" +msgstr "Altera o nome do mapeamento" + +#: data/input-remapper.glade:352 +msgid "Copy" +msgstr "Copiar" + +#: data/input-remapper.glade:189 +msgid "Create a new preset" +msgstr "Criar uma nova predefinição" + +#: data/input-remapper.glade:925 +msgid "Deadzone" +msgstr "Zona morta" + +#: data/input-remapper.glade:368 data/input-remapper.glade:620 +msgid "Delete" +msgstr "Excluir" + +#: data/input-remapper.glade:624 +msgid "Delete this entry" +msgstr "Excluir esta entrada" + +#: data/input-remapper.glade:372 +msgid "Delete this preset" +msgstr "Exclui esta predefinição" + +#: data/input-remapper.glade:162 +msgid "Device Name" +msgstr "Nome do dispositivo" + +#: data/input-remapper.glade:148 +msgid "Devices" +msgstr "Dispositivos" + +#: data/input-remapper.glade:356 +msgid "Duplicate this preset" +msgstr "Duplica esta predefinição" + +#: data/input-remapper.glade:1208 +msgid "Editor" +msgstr "Editor" + +#: inputremapper/configs/mapping.py:77 +msgid "Empty Mapping" +msgstr "Mapeamento vazio" + +#: inputremapper/gui/components/editor.py:404 +msgid "Enter your output here" +msgstr "Insira sua saída aqui" + +#: data/input-remapper.glade:1762 +msgid "Event Specific" +msgstr "Específico de evento" + +#: data/input-remapper.glade:1009 +msgid "Expo" +msgstr "Expo" + +#: inputremapper/gui/controller.py:648 inputremapper/gui/controller.py:673 +#, python-format +msgid "Failed to apply preset %s" +msgstr "Falha ao aplicar a predefinição %s" + +#: data/input-remapper.glade:967 +msgid "Gain" +msgstr "Ganho" + +#: data/input-remapper.glade:1736 +msgid "General" +msgstr "Geral" + +#: data/input-remapper.glade:1312 +msgid "Help" +msgstr "Ajuda" + +#: data/input-remapper.glade:510 +msgid "Input" +msgstr "Entrada" + +#: data/input-remapper.glade:1296 +msgid "Input Remapper" +msgstr "Remapeador de entrada" + +#: data/input-remapper.glade:1087 +msgid "Input cutoff" +msgstr "Corte da entrada" + +#: data/input-remapper.glade:760 data/input-remapper.glade:1180 +msgid "Key or Macro" +msgstr "Chave ou macro" + +#: data/input-remapper.glade:1801 +msgid "Map this input to an Analog Axis" +msgstr "Mapear esta entrada para um eixo analógico" + +# Nova predefinição -- Rafael +#: data/input-remapper.glade:185 +msgid "New" +msgstr "Nova" + +#: inputremapper/gui/components/editor.py:980 +msgid "No Axis" +msgstr "Nenhum eixo" + +#: data/input-remapper.glade:721 +msgid "Output" +msgstr "Saída" + +#: data/input-remapper.glade:862 +msgid "Output axis" +msgstr "Eixo de saída" + +#: inputremapper/gui/controller.py:508 inputremapper/gui/controller.py:582 +msgid "Permission denied!" +msgstr "Permissão negada!" + +#: data/input-remapper.glade:287 +msgid "Preset Name" +msgstr "Nome da predefinição" + +#: data/input-remapper.glade:272 +msgid "Presets" +msgstr "Predefinições" + +#: data/input-remapper.glade:590 +msgid "Record" +msgstr "Gravar" + +#: data/input-remapper.glade:594 +msgid "Record a button of your device that should be remapped" +msgstr "Grava um botão de seu dispositivo que deve ser remapeado" + +#: inputremapper/gui/components/editor.py:563 +msgid "Record input first" +msgstr "Gravar primeira entrada" + +#: inputremapper/gui/components/editor.py:65 +msgid "Record the input first" +msgstr "Grava a primeira entrada" + +#: data/input-remapper.glade:1708 +msgid "" +"Release all inputs which are part of the combination before the mapping is " +"injected" +msgstr "" +"Libere todas as entradas que fazem parte da combinação antes que o " +"mapeamento seja injetado" + +#: data/input-remapper.glade:1711 +msgid "Release input" +msgstr "Liberar entrada" + +#: data/input-remapper.glade:1683 +msgid "Release timeout" +msgstr "Tempo limite para liberar" + +#: inputremapper/gui/controller.py:208 +msgid "Remove the Analog Output Axis when specifying a macro or key output" +msgstr "" +"Remove o eixo de saída analógica ao especificar uma saída de macro ou tecla" + +#: inputremapper/gui/controller.py:199 +msgid "" +"Remove the macro or key from the macro input field when specifying an analog " +"output" +msgstr "" +"Remova a macro ou chave do campo de entrada de macro ao especificar uma " +"saída analógica" + +#: data/input-remapper.glade:1776 +msgid "Remove this input" +msgstr "Remove esta entrada" + +#: data/input-remapper.glade:394 +msgid "Rename" +msgstr "Renomear" + +#: data/input-remapper.glade:452 +msgid "Save the entered name" +msgstr "Salva o nome inserido" + +#: data/input-remapper.glade:1449 +msgid "" +"See usage.md online on github for comprehensive information.\n" +"\n" +"A \"key + key + ... + key\" syntax can be used to trigger key combinations. " +"For example \"Control_L + a\".\n" +"\n" +"Writing \"disable\" as a mapping disables a key.\n" +"\n" +"Macros allow multiple characters to be written with a single key-press. " +"Information about programming them is available online on github. See macros.md and examples.md" +msgstr "" +"Consulte usage.md online no github para obter informações " +"abrangentes.\n" +"\n" +"Uma sintaxe \"tecla + tecla + ... + tecla\" pode ser usada para acionar " +"combinações de teclas. Por exemplo, \"Control_L + a\".\n" +"\n" +"Escrever \"disable\" como mapeamento desativa uma tecla.\n" +"\n" +"As macros permitem que vários caracteres sejam escritos com um único " +"pressionamento de tecla. Informações sobre como programá-los estão " +"disponíveis online no github. Consulte macros.md e examples.md" + +#: data/input-remapper.glade:1590 +msgid "Shortcuts" +msgstr "Atalhos" + +#: data/input-remapper.glade:1492 +msgid "" +"Shortcuts only work while keys are not being recorded and the gui is in " +"focus." +msgstr "" +"Os atalhos funcionam apenas enquanto as teclas não estão sendo gravadas e a " +"interface gráfica está em foco." + +#: data/input-remapper.glade:322 +msgid "Start injecting. Don't hold down any keys while the injection starts" +msgstr "Começa a injeção. Não segure nenhuma tecla enquanto a injeção começa" + +#: inputremapper/gui/controller.py:643 +msgid "Starting injection..." +msgstr "Iniciando a injeção..." + +#: data/input-remapper.glade:201 data/input-remapper.glade:334 +msgid "Stop" +msgstr "Parar" + +#: inputremapper/gui/controller.py:710 +msgid "Stopped the injection" +msgstr "Parou a injeção" + +#: data/input-remapper.glade:205 data/input-remapper.glade:338 +msgid "" +"Stops the Injection for the selected device,\n" +"gives your keys their original function back\n" +"Shortcut: ctrl + del" +msgstr "" +"Interrompe a injeção para o dispositivo selecionado,\n" +"devolve às suas teclas a função original\n" +"Atalho: ctrl + del" + +#: data/input-remapper.glade:812 +msgid "Target" +msgstr "Alvo" + +#: data/input-remapper.glade:1078 +msgid "" +"The Speed at which the Input is considered at maximum.\n" +"Only relevant when mapping relative inputs (e.g. mouse) to absolute outputs " +"(e.g. gamepad)" +msgstr "" +"A velocidade na qual a entrada é considerada no máximo.\n" +"Relevante apenas ao mapear entradas relativas (por exemplo, mouse) para " +"saídas absolutas (por exemplo, gamepad)" + +#: inputremapper/gui/controller.py:234 +msgid "" +"The input specifies a key or macro input, but no macro or key is programmed." +msgstr "" +"A entrada especifica uma entrada de tecla ou macro, mas nenhuma macro ou " +"chave está programada." + +#: inputremapper/gui/controller.py:213 +msgid "The input specifies an analog axis, but no output axis is selected." +msgstr "" +"A entrada especifica um eixo analógico, mas nenhum eixo de saída está " +"selecionado." + +#: data/input-remapper.glade:803 +msgid "The type of device this mapping is emulating." +msgstr "O tipo de dispositivo que este mapeamento está emulando." + +#: data/input-remapper.glade:1831 +msgid "Trigger threshold" +msgstr "Limiar de acionamento" + +#: data/input-remapper.glade:743 +msgid "Type" +msgstr "Tipo" + +#: data/input-remapper.glade:1473 +msgid "Usage" +msgstr "Uso" + +#: inputremapper/gui/controller.py:593 +msgid "Use \"Stop\" to stop before editing" +msgstr "Use \"Parar\" para parar antes de editar" + +#: data/input-remapper.glade:1805 +msgid "Use as analog" +msgstr "Usa como analógico" + +#: data/input-remapper.glade:1366 +msgid "Version unknown" +msgstr "Versão desconhecida" + +#: data/input-remapper.glade:1134 +msgid "What should be written. For example KEY_A" +msgstr "O que deve ser escrito. Por exemplo, KEY_A" + +#: inputremapper/gui/controller.py:761 +msgid "You are about to change the mapping to analog." +msgstr "Você está prestes a alterar o mapeamento para analógico." + +#: data/input-remapper.glade:1161 +msgid "You can copy this text into the output" +msgstr "Você pode copiar este texto na saída" + +#: data/input-remapper.glade:1383 +msgid "" +"You can find more information and report bugs at\n" +"https://github.com/" +"sezanzeb/input-remapper" +msgstr "" +"Você pode encontrar mais informações e relatar bugs em\n" +"https://github.com/" +"sezanzeb/input-remapper" + +#: inputremapper/gui/controller.py:623 +msgid "You need to add mappings first" +msgstr "Você precisa adicionar mapeamentos primeiro" + +#: inputremapper/gui/controller.py:358 +msgid "" +"Your system might reinterpret combinations with those after they are " +"injected, and by doing so break them." +msgstr "" +"Seu sistema pode reinterpretar combinações com aquelas após serem injetadas " +"e, ao fazê-lo, quebrá-las." + +#: data/input-remapper.glade:1524 +msgid "closes the application" +msgstr "fecha o aplicativo" + +#: data/input-remapper.glade:1512 +msgid "ctrl + del" +msgstr "ctrl + del" + +#: data/input-remapper.glade:1536 +msgid "ctrl + q" +msgstr "ctrl + q" + +#: data/input-remapper.glade:1548 +msgid "ctrl + r" +msgstr "ctrl + r" + +#: inputremapper/gui/controller.py:356 +msgid "ctrl, alt and shift may not combine properly" +msgstr "ctrl, alt e shift podem não combinar adequadamente" + +#: inputremapper/gui/data_manager.py:56 +msgid "new preset" +msgstr "nova predefinição" + +#: data/input-remapper.glade:531 inputremapper/gui/user_interface.py:385 +msgid "no input configured" +msgstr "nenhuma entrada configurada" + +#: data/input-remapper.glade:1560 +msgid "refreshes the device list" +msgstr "atualiza a lista de dispositivos" + +#: data/input-remapper.glade:1572 +msgid "stops the injection" +msgstr "para a injeção" + +#: data/input-remapper.glade:1403 +msgid "" +"© 2025 Sezanzeb b8x45ygc9@mozmail.com\n" +"This program comes with absolutely no warranty.\n" +"See the GNU General " +"Public License, version 3 or later for details." +msgstr "" +"© 2025 Sezanzeb b8x45ygc9@mozmail.com\n" +"Este programa vem com absolutamente nenhuma garantia.\n" +"Veja a Licença Pública " +"Geral GNU, versão 3 ou posterior para detalhes." diff --git a/po/ru.po b/po/ru.po new file mode 120000 index 0000000..b5d03f7 --- /dev/null +++ b/po/ru.po @@ -0,0 +1 @@ +./ru_RU.po \ No newline at end of file diff --git a/po/ru_RU.po b/po/ru_RU.po new file mode 100644 index 0000000..626b641 --- /dev/null +++ b/po/ru_RU.po @@ -0,0 +1,504 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +msgid "" +msgstr "" +"Project-Id-Version: \n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2023-05-16 08:30-0300\n" +"PO-Revision-Date: 2023-01-11 21:48+0100\n" +"Last-Translator: Sviatoslav Vorona \n" +"Language-Team: \n" +"Language: ru_RU\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"X-Generator: Poedit 3.0\n" +"Plural-Forms: nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && " +"n%10<=4 && (n%100<12 || n%100>14) ? 1 : 2);\n" + +#: inputremapper/gui/controller.py:222 +msgid "" +"\n" +"If you mean to create a key or macro mapping go to the advanced input " +"configuration and set a \"Trigger Threshold\" for " +msgstr "" + +#: inputremapper/gui/controller.py:239 +msgid "" +"\n" +"If you mean to create an analog axis mapping go to the advanced input " +"configuration and set an input to \"Use as Analog\"." +msgstr "" + +#: inputremapper/gui/controller.py:779 +msgid "" +"\n" +"The input \"{}\" will be used as analog input." +msgstr "" + +#: inputremapper/gui/controller.py:763 +msgid "" +"\n" +"This will remove \"{}\" from the text input!" +msgstr "" + +#: inputremapper/gui/controller.py:784 +msgid "" +"\n" +"You need to record an analog input." +msgstr "" + +#: data/input-remapper.glade:544 +msgid " (recording ...)" +msgstr " (запись ...)" + +#: inputremapper/gui/controller.py:174 +#, python-format +msgid "%d Mapping errors at \"%s\", hover for info" +msgstr "" + +#: inputremapper/gui/controller.py:661 +msgid ", CTRL + DEL to stop" +msgstr "" + +#: data/input-remapper.glade:1421 +msgid "About" +msgstr "О программе" + +#: data/input-remapper.glade:422 +msgid "" +"Activate this to load the preset next time the device connects, or when the " +"user logs in" +msgstr "" + +#: data/input-remapper.glade:575 +msgid "Add" +msgstr "Добавить" + +#: inputremapper/gui/components/editor.py:554 +msgid "Add a mapping first" +msgstr "" + +#: data/input-remapper.glade:606 data/input-remapper.glade:1618 +msgid "Advanced" +msgstr "Дополнительные" + +#: data/input-remapper.glade:773 data/input-remapper.glade:1118 +msgid "Analog Axis" +msgstr "Аналоговая Ось" + +#: inputremapper/gui/controller.py:657 +#, python-format +msgid "Applied preset %s" +msgstr "" + +#: data/input-remapper.glade:318 +msgid "Apply" +msgstr "Применить" + +#: inputremapper/gui/controller.py:522 +#, python-format +msgid "Are you sure you want to delete the preset \"%s\"?" +msgstr "" + +#: inputremapper/gui/controller.py:567 +msgid "Are you sure you want to delete this mapping?" +msgstr "" + +#: data/input-remapper.glade:410 +msgid "Autoload" +msgstr "Автозагрузка" + +#: data/input-remapper.glade:854 +msgid "Available output axes are affected by the Target setting." +msgstr "Доступные оси вывода затрагиваются настройками Цели." + +#: inputremapper/gui/controller.py:621 +msgid "Cannot apply empty preset file" +msgstr "" + +#: inputremapper/gui/components/editor.py:238 +msgid "Change Mapping Name" +msgstr "" + +#: data/input-remapper.glade:352 +msgid "Copy" +msgstr "Копировать" + +#: data/input-remapper.glade:189 +msgid "Create a new preset" +msgstr "Создать новую предустановку" + +#: data/input-remapper.glade:925 +msgid "Deadzone" +msgstr "Мёртвая зона" + +#: data/input-remapper.glade:368 data/input-remapper.glade:620 +msgid "Delete" +msgstr "Удалить" + +#: data/input-remapper.glade:624 +msgid "Delete this entry" +msgstr "Удалить эту запись" + +#: data/input-remapper.glade:372 +msgid "Delete this preset" +msgstr "Удалить эту предустановку" + +#: data/input-remapper.glade:162 +msgid "Device Name" +msgstr "Имя устройства" + +#: data/input-remapper.glade:148 +msgid "Devices" +msgstr "Устройства" + +#: data/input-remapper.glade:356 +msgid "Duplicate this preset" +msgstr "Скопировать эту предустановку" + +#: data/input-remapper.glade:1208 +msgid "Editor" +msgstr "Редактор" + +#: inputremapper/configs/mapping.py:77 +msgid "Empty Mapping" +msgstr "" + +#: inputremapper/gui/components/editor.py:404 +msgid "Enter your output here" +msgstr "" + +#: data/input-remapper.glade:1762 +msgid "Event Specific" +msgstr "Специфичные для событий" + +#: data/input-remapper.glade:1009 +msgid "Expo" +msgstr "Expo" + +#: inputremapper/gui/controller.py:648 inputremapper/gui/controller.py:673 +#, python-format +msgid "Failed to apply preset %s" +msgstr "" + +#: data/input-remapper.glade:967 +msgid "Gain" +msgstr "Усиление" + +#: data/input-remapper.glade:1736 +msgid "General" +msgstr "Общие" + +#: data/input-remapper.glade:1312 +msgid "Help" +msgstr "Помощь" + +#: data/input-remapper.glade:510 +msgid "Input" +msgstr "Ввод" + +#: data/input-remapper.glade:1296 +msgid "Input Remapper" +msgstr "Input Remapper" + +#: data/input-remapper.glade:1087 +msgid "Input cutoff" +msgstr "Входная отсечка" + +#: data/input-remapper.glade:760 data/input-remapper.glade:1180 +msgid "Key or Macro" +msgstr "Клавиша или Макрос" + +#: data/input-remapper.glade:1801 +msgid "Map this input to an Analog Axis" +msgstr "" + +#: data/input-remapper.glade:185 +msgid "New" +msgstr "Новая" + +#: inputremapper/gui/components/editor.py:980 +#, fuzzy +msgid "No Axis" +msgstr "Аналоговая Ось" + +#: data/input-remapper.glade:721 +msgid "Output" +msgstr "Вывод" + +#: data/input-remapper.glade:862 +msgid "Output axis" +msgstr "Ось вывода" + +#: inputremapper/gui/controller.py:508 inputremapper/gui/controller.py:582 +msgid "Permission denied!" +msgstr "" + +#: data/input-remapper.glade:287 +msgid "Preset Name" +msgstr "Имя предустановки" + +#: data/input-remapper.glade:272 +msgid "Presets" +msgstr "Предустановки" + +#: data/input-remapper.glade:590 +msgid "Record" +msgstr "Запись" + +#: data/input-remapper.glade:594 +msgid "Record a button of your device that should be remapped" +msgstr "Записать клавишу вашего устройства которая должна быть привязана" + +#: inputremapper/gui/components/editor.py:563 +msgid "Record input first" +msgstr "" + +#: inputremapper/gui/components/editor.py:65 +#, fuzzy +msgid "Record the input first" +msgstr "Удалить этот ввод" + +#: data/input-remapper.glade:1708 +msgid "" +"Release all inputs which are part of the combination before the mapping is " +"injected" +msgstr "" + +#: data/input-remapper.glade:1711 +msgid "Release input" +msgstr "Освободить" + +#: data/input-remapper.glade:1683 +msgid "Release timeout" +msgstr "Таймаут освобождения" + +#: inputremapper/gui/controller.py:208 +msgid "Remove the Analog Output Axis when specifying a macro or key output" +msgstr "" + +#: inputremapper/gui/controller.py:199 +msgid "" +"Remove the macro or key from the macro input field when specifying an analog " +"output" +msgstr "" + +#: data/input-remapper.glade:1776 +msgid "Remove this input" +msgstr "Удалить этот ввод" + +#: data/input-remapper.glade:394 +msgid "Rename" +msgstr "Переименовать" + +#: data/input-remapper.glade:452 +msgid "Save the entered name" +msgstr "Сохранить введённое имя" + +#: data/input-remapper.glade:1449 +msgid "" +"See usage.md online on github for comprehensive information.\n" +"\n" +"A \"key + key + ... + key\" syntax can be used to trigger key combinations. " +"For example \"Control_L + a\".\n" +"\n" +"Writing \"disable\" as a mapping disables a key.\n" +"\n" +"Macros allow multiple characters to be written with a single key-press. " +"Information about programming them is available online on github. See macros.md and examples.md" +msgstr "" +"Смотрите usage.md в сети на github для исчерпывающей " +"информации.\n" +"\n" +"A \"key + key + ... + key\" синтаксис может быть использован чтобы вызвать " +"комбинацию клавиш. Например \"Control_L + a\".\n" +"\n" +"Написание \"disable\" как привязки отключает клавишу.\n" +"\n" +"Макросы позволяют написать множество символов нажатием одной клавиши. " +"Информация об их программировании доступна в сети на github. Смотрите macros.md and examples.md" + +#: data/input-remapper.glade:1590 +msgid "Shortcuts" +msgstr "Комбинации" + +#: data/input-remapper.glade:1492 +msgid "" +"Shortcuts only work while keys are not being recorded and the gui is in " +"focus." +msgstr "" +"Комбинации работают только пока клавиши не записываются и интерфейс " +"приложения в фокусе." + +#: data/input-remapper.glade:322 +msgid "Start injecting. Don't hold down any keys while the injection starts" +msgstr "Начать ввод. Не зажимайте никакие клавиши пока ввод запускается" + +#: inputremapper/gui/controller.py:643 +#, fuzzy +msgid "Starting injection..." +msgstr "останавливает ввод" + +#: data/input-remapper.glade:201 data/input-remapper.glade:334 +msgid "Stop" +msgstr "Остановить" + +#: inputremapper/gui/controller.py:710 +#, fuzzy +msgid "Stopped the injection" +msgstr "останавливает ввод" + +#: data/input-remapper.glade:205 data/input-remapper.glade:338 +msgid "" +"Stops the Injection for the selected device,\n" +"gives your keys their original function back\n" +"Shortcut: ctrl + del" +msgstr "" +"Останавливает Ввод для выбранного устройства,\n" +"возвращает функции ваших клавиш назад\n" +"Комбинация: ctrl + del" + +#: data/input-remapper.glade:812 +msgid "Target" +msgstr "Цель" + +#: data/input-remapper.glade:1078 +#, fuzzy +msgid "" +"The Speed at which the Input is considered at maximum.\n" +"Only relevant when mapping relative inputs (e.g. mouse) to absolute outputs " +"(e.g. gamepad)" +msgstr "" +"Скорость при которой Ввод считается максимальным.\n" +"Актуально только при эмуляции относительных устройств ввода (например, мыши) " +"как абсолютных устройств вывода (например, геймпада)." + +#: inputremapper/gui/controller.py:234 +msgid "" +"The input specifies a key or macro input, but no macro or key is programmed." +msgstr "" + +#: inputremapper/gui/controller.py:213 +msgid "The input specifies an analog axis, but no output axis is selected." +msgstr "" + +#: data/input-remapper.glade:803 +msgid "The type of device this mapping is emulating." +msgstr "Тип устройства которое эмулируется этой привязкой." + +#: data/input-remapper.glade:1831 +msgid "Trigger threshold" +msgstr "Порог срабатывания" + +#: data/input-remapper.glade:743 +msgid "Type" +msgstr "Тип" + +#: data/input-remapper.glade:1473 +msgid "Usage" +msgstr "Использование" + +#: inputremapper/gui/controller.py:593 +msgid "Use \"Stop\" to stop before editing" +msgstr "" + +#: data/input-remapper.glade:1805 +msgid "Use as analog" +msgstr "Использовать как аналоговое" + +#: data/input-remapper.glade:1366 +msgid "Version unknown" +msgstr "Неизвестная версия" + +#: data/input-remapper.glade:1134 +msgid "What should be written. For example KEY_A" +msgstr "Что должно быть написано. Например KEY_A" + +#: inputremapper/gui/controller.py:761 +msgid "You are about to change the mapping to analog." +msgstr "" + +#: data/input-remapper.glade:1161 +msgid "You can copy this text into the output" +msgstr "" + +#: data/input-remapper.glade:1383 +msgid "" +"You can find more information and report bugs at\n" +"https://github.com/" +"sezanzeb/input-remapper" +msgstr "" +"Вы можете найти больше информации и сообщить о проблемах по адресу\n" +"https://github.com/" +"sezanzeb/input-remapper" + +#: inputremapper/gui/controller.py:623 +msgid "You need to add mappings first" +msgstr "" + +#: inputremapper/gui/controller.py:358 +msgid "" +"Your system might reinterpret combinations with those after they are " +"injected, and by doing so break them." +msgstr "" + +#: data/input-remapper.glade:1524 +msgid "closes the application" +msgstr "закрывает приложение" + +#: data/input-remapper.glade:1512 +msgid "ctrl + del" +msgstr "ctrl + del" + +#: data/input-remapper.glade:1536 +msgid "ctrl + q" +msgstr "ctrl + q" + +#: data/input-remapper.glade:1548 +msgid "ctrl + r" +msgstr "ctrl + r" + +#: inputremapper/gui/controller.py:356 +msgid "ctrl, alt and shift may not combine properly" +msgstr "" + +#: inputremapper/gui/data_manager.py:56 +#, fuzzy +msgid "new preset" +msgstr "Создать новую предустановку" + +#: data/input-remapper.glade:531 inputremapper/gui/user_interface.py:385 +msgid "no input configured" +msgstr "ввод не сконфигурирован" + +#: data/input-remapper.glade:1560 +msgid "refreshes the device list" +msgstr "обновляет список устройств" + +#: data/input-remapper.glade:1572 +msgid "stops the injection" +msgstr "останавливает ввод" + +#: data/input-remapper.glade:1403 +msgid "" +"© 2025 Sezanzeb b8x45ygc9@mozmail.com\n" +"This program comes with absolutely no warranty.\n" +"See the GNU General " +"Public License, version 3 or later for details." +msgstr "" +"© 2025 Sezanzeb b8x45ygc9@mozmail.com\n" +"Программа распространяется без какой либо гарантии.\n" +"Смотрите подробности по ссылке GNU General Public License, версия 3 или новее." diff --git a/po/sk.po b/po/sk.po new file mode 120000 index 0000000..e48f8ad --- /dev/null +++ b/po/sk.po @@ -0,0 +1 @@ +./sk_SK.po \ No newline at end of file diff --git a/po/sk_SK.po b/po/sk_SK.po new file mode 100644 index 0000000..7274106 --- /dev/null +++ b/po/sk_SK.po @@ -0,0 +1,724 @@ +# Slovak translation of input-remapper. +# Copyright (C) 2023. +# This file is distributed under the same license as the input-remapper package. +# Jose Riha , 2021, 2025. +# +msgid "" +msgstr "" +"Project-Id-Version: \n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2023-05-16 08:30-0300\n" +"PO-Revision-Date: 2025-03-28 23:47+0100\n" +"Last-Translator: Jose Riha \n" +"Language-Team: \n" +"Language: sk_SK\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"X-Generator: Poedit 3.0\n" +"Plural-Forms: nplurals=3; plural=(n==1 ? 0 : n>=2 && n<=4 ? 1 : 2);\n" + +#: inputremapper/gui/controller.py:222 +msgid "" +"\n" +"If you mean to create a key or macro mapping go to the advanced input " +"configuration and set a \"Trigger Threshold\" for " +msgstr "" +"\n" +"Ak chcete vytvoriť mapovanie klávesu alebo makra, prejdite do pokročilej " +"konfigurácie vstupu a nastavte „Prah aktivácie“ pre " + +#: inputremapper/gui/controller.py:239 +msgid "" +"\n" +"If you mean to create an analog axis mapping go to the advanced input " +"configuration and set an input to \"Use as Analog\"." +msgstr "" +"\n" +"Ak chcete vytvoriť mapovanie analógovej osi, prejdite do pokročilej " +"konfigurácie vstupu a nastavte vstup na „Použiť ako analógový“." + +#: inputremapper/gui/controller.py:779 +msgid "" +"\n" +"The input \"{}\" will be used as analog input." +msgstr "" +"\n" +"Vstup „{}“ sa použije ako analógový vstup." + +#: inputremapper/gui/controller.py:763 +msgid "" +"\n" +"This will remove \"{}\" from the text input!" +msgstr "" +"\n" +"Toto odstráni „{}“ z textového vstupu!" + +#: inputremapper/gui/controller.py:784 +msgid "" +"\n" +"You need to record an analog input." +msgstr "" +"\n" +"Analógový vstup musíte nahrať." + +#: data/input-remapper.glade:544 +msgid " (recording ...)" +msgstr " (nahrávam…)" + +#: inputremapper/gui/controller.py:174 +#, python-format +msgid "%d Mapping errors at \"%s\", hover for info" +msgstr "%d chýb mapovania na „%s“, prejdite myšou pre viac informácií" + +#: inputremapper/gui/controller.py:661 +msgid ", CTRL + DEL to stop" +msgstr ", zastavíte stlačením CTRL + DEL" + +#: data/input-remapper.glade:1421 +msgid "About" +msgstr "O programe" + +#: data/input-remapper.glade:422 +msgid "" +"Activate this to load the preset next time the device connects, or when the " +"user logs in" +msgstr "" +"Aktivujte túto možnosť pre načítanie prednastavenia pri ďalšom pripojení " +"zariadenia alebo pri prihlásení používateľa" + +#: data/input-remapper.glade:575 +msgid "Add" +msgstr "Pridať" + +#: inputremapper/gui/components/editor.py:554 +msgid "Add a mapping first" +msgstr "Pridajte najprv mapovanie" + +#: data/input-remapper.glade:606 data/input-remapper.glade:1618 +msgid "Advanced" +msgstr "Pokročilé" + +#: data/input-remapper.glade:773 data/input-remapper.glade:1118 +msgid "Analog Axis" +msgstr "Analógová os" + +#: inputremapper/gui/controller.py:657 +#, python-format +msgid "Applied preset %s" +msgstr "Použité prednastavenie %s" + +#: data/input-remapper.glade:318 +msgid "Apply" +msgstr "Použiť" + +#: inputremapper/gui/controller.py:522 +#, python-format +msgid "Are you sure you want to delete the preset \"%s\"?" +msgstr "Naozaj chcete odstrániť prednastavenie „%s“?" + +#: inputremapper/gui/controller.py:567 +msgid "Are you sure you want to delete this mapping?" +msgstr "Naozaj chcete odstrániť toto mapovanie?" + +#: data/input-remapper.glade:410 +msgid "Autoload" +msgstr "Automaticky načítať" + +#: data/input-remapper.glade:854 +msgid "Available output axes are affected by the Target setting." +msgstr "Dostupné osy výstupu sú ovplyvnené nastavením cieľa." + +#: inputremapper/gui/controller.py:621 +msgid "Cannot apply empty preset file" +msgstr "Nemôžete použiť prázdny súbor s prednastavením" + +#: inputremapper/gui/components/editor.py:238 +msgid "Change Mapping Name" +msgstr "Zmeniť názov mapovania" + +#: data/input-remapper.glade:352 +msgid "Copy" +msgstr "Kopírovať" + +#: data/input-remapper.glade:189 +msgid "Create a new preset" +msgstr "Vytvoriť nové prednastavenie" + +#: data/input-remapper.glade:925 +msgid "Deadzone" +msgstr "Mŕtva zóna" + +#: data/input-remapper.glade:368 data/input-remapper.glade:620 +msgid "Delete" +msgstr "Odstrániť" + +#: data/input-remapper.glade:624 +msgid "Delete this entry" +msgstr "Odstrániť túto položku" + +#: data/input-remapper.glade:372 +msgid "Delete this preset" +msgstr "Odstrániť toto prednastavenie" + +#: data/input-remapper.glade:162 +msgid "Device Name" +msgstr "Názov zariadenia" + +#: data/input-remapper.glade:148 +msgid "Devices" +msgstr "Zariadenia" + +#: data/input-remapper.glade:356 +msgid "Duplicate this preset" +msgstr "Duplikovať toto prednastavenie" + +#: data/input-remapper.glade:1208 +msgid "Editor" +msgstr "Editor" + +#: inputremapper/configs/mapping.py:77 +msgid "Empty Mapping" +msgstr "Prázdne mapovanie" + +#: inputremapper/gui/components/editor.py:404 +msgid "Enter your output here" +msgstr "Sem zadajte vstup" + +#: data/input-remapper.glade:1762 +msgid "Event Specific" +msgstr "Špecifické pre udalosť" + +#: data/input-remapper.glade:1009 +msgid "Expo" +msgstr "Exponent" + +#: inputremapper/gui/controller.py:648 inputremapper/gui/controller.py:673 +#, python-format +msgid "Failed to apply preset %s" +msgstr "Nepodarilo sa načítať prednastavenie %s" + +#: data/input-remapper.glade:967 +msgid "Gain" +msgstr "Zisk" + +#: data/input-remapper.glade:1736 +msgid "General" +msgstr "Všeobecné" + +#: data/input-remapper.glade:1312 +msgid "Help" +msgstr "Pomocník" + +#: data/input-remapper.glade:510 +msgid "Input" +msgstr "Vstup" + +#: data/input-remapper.glade:1296 +msgid "Input Remapper" +msgstr "Mapovač vstupu" + +#: data/input-remapper.glade:1087 +msgid "Input cutoff" +msgstr "Orezanie vstupu" + +#: data/input-remapper.glade:760 data/input-remapper.glade:1180 +msgid "Key or Macro" +msgstr "Kláves alebo makro" + +#: data/input-remapper.glade:1801 +msgid "Map this input to an Analog Axis" +msgstr "Mapovať tento vstup na analógovú os" + +#: data/input-remapper.glade:185 +msgid "New" +msgstr "Nové" + +#: inputremapper/gui/components/editor.py:980 +msgid "No Axis" +msgstr "Žiadna os" + +#: data/input-remapper.glade:721 +msgid "Output" +msgstr "Výstup" + +#: data/input-remapper.glade:862 +msgid "Output axis" +msgstr "Výstupná os" + +#: inputremapper/gui/controller.py:508 inputremapper/gui/controller.py:582 +msgid "Permission denied!" +msgstr "Odopretý prístup!" + +#: data/input-remapper.glade:287 +msgid "Preset Name" +msgstr "Názov prednastavenia" + +#: data/input-remapper.glade:272 +msgid "Presets" +msgstr "Prednastavenia" + +#: data/input-remapper.glade:590 +msgid "Record" +msgstr "Nahrať" + +#: data/input-remapper.glade:594 +msgid "Record a button of your device that should be remapped" +msgstr "Zaznamenajte tlačidlo zariadenia, ktoré sa má premapovať" + +#: inputremapper/gui/components/editor.py:563 +msgid "Record input first" +msgstr "Najprv nahrajte vstup" + +#: inputremapper/gui/components/editor.py:65 +msgid "Record the input first" +msgstr "Najprv nahrajte kláves" + +#: data/input-remapper.glade:1708 +msgid "" +"Release all inputs which are part of the combination before the mapping is " +"injected" +msgstr "" +"Uvoľniť všetky vstupy, ktoré sú súčasťou kombinácie pred tým, ako sa spustí " +"injektáž" + +#: data/input-remapper.glade:1711 +msgid "Release input" +msgstr "Uvoľniť vstup" + +#: data/input-remapper.glade:1683 +msgid "Release timeout" +msgstr "Časový limit uvoľnenia" + +#: inputremapper/gui/controller.py:208 +msgid "Remove the Analog Output Axis when specifying a macro or key output" +msgstr "Odstráňte výstupnú analógovú os pri definovaní výstupného makra alebo klávesu" + +#: inputremapper/gui/controller.py:199 +msgid "" +"Remove the macro or key from the macro input field when specifying an analog " +"output" +msgstr "" +"Odstráňte makro alebo kláves z poľa vstupu makra, ak definujete analógový výstup" + +#: data/input-remapper.glade:1776 +msgid "Remove this input" +msgstr "Odstrániť tento vstup" + +#: data/input-remapper.glade:394 +msgid "Rename" +msgstr "Premenovať" + +#: data/input-remapper.glade:452 +msgid "Save the entered name" +msgstr "Uložiť pod týmto názvom" + +#: data/input-remapper.glade:1449 +msgid "" +"See usage.md online on github for comprehensive information.\n" +"\n" +"A \"key + key + ... + key\" syntax can be used to trigger key combinations. " +"For example \"Control_L + a\".\n" +"\n" +"Writing \"disable\" as a mapping disables a key.\n" +"\n" +"Macros allow multiple characters to be written with a single key-press. " +"Information about programming them is available online on github. See macros.md and examples.md" +msgstr "" +"Vyčerpávajúce informácie nájdete na online stránke usage.md na " +"githube.\n" +"\n" +"Syntax \"key + key + ... + key\" môžete použit na aktivovanie kombinácie " +"klávesov. Napríklad \"Control_L + a\".\n" +"\n" +"Ak použijete \"disable\" ako mapovanie, mapovanie klávesu deaktivujete.\n" +"\n" +"Makrá vám umožnia aktivovať viacero znakov pomocou stlačenia jedného " +"klávesu. Informácie o možnostiach programovania nájdete online na githube. " +"Pozrite macros.md a examples.md" + +#: data/input-remapper.glade:1590 +msgid "Shortcuts" +msgstr "Skratky" + +#: data/input-remapper.glade:1492 +msgid "" +"Shortcuts only work while keys are not being recorded and the gui is in " +"focus." +msgstr "" +"Skratky fungujú iba vtedy, ak sa tlačidlá nenahrávaju a okno programu je " +"aktívne." + +#: data/input-remapper.glade:322 +msgid "Start injecting. Don't hold down any keys while the injection starts" +msgstr "" +"Spustiť injektáž. Uistite sa, že pri spúšťaní injektáže nie sú stlačené " +"žiadne tlačidlá" + +#: inputremapper/gui/controller.py:643 +msgid "Starting injection..." +msgstr "Spúšťam injektáž…" + +#: data/input-remapper.glade:201 data/input-remapper.glade:334 +msgid "Stop" +msgstr "Zastaviť injektáž" + +#: inputremapper/gui/controller.py:710 +msgid "Stopped the injection" +msgstr "Injektáž bola zastavená" + +#: data/input-remapper.glade:205 data/input-remapper.glade:338 +msgid "" +"Stops the Injection for the selected device,\n" +"gives your keys their original function back\n" +"Shortcut: ctrl + del" +msgstr "" +"Zastaví injektáž pre vybrané zariadenie,\n" +"vráti klávesom ich pôvodnú funkciu\n" +"Klávesová skratka: ctrl + del" + +#: data/input-remapper.glade:812 +msgid "Target" +msgstr "Cieľ" + +#: data/input-remapper.glade:1078 +msgid "" +"The Speed at which the Input is considered at maximum.\n" +"Only relevant when mapping relative inputs (e.g. mouse) to absolute outputs " +"(e.g. gamepad)" +msgstr "" +"Rýchlosť, pri ktorej sa vstup považuje byť na maxime.\n" +"Má význam iba pri mapovaní relatívnych vstup (napr. myši) na absolútne výstupy " +"(napr. gamepad)" + +#: inputremapper/gui/controller.py:234 +msgid "" +"The input specifies a key or macro input, but no macro or key is programmed." +msgstr "" +"Vstup definuje vstup ako kláves alebo makro, ale žiaden kláves ani makro nie " +"je naprogramované." + +#: inputremapper/gui/controller.py:213 +msgid "The input specifies an analog axis, but no output axis is selected." +msgstr "Vstup definuje analógovú os, ale nevybrali ste výstupnú os." + +#: data/input-remapper.glade:803 +msgid "The type of device this mapping is emulating." +msgstr "Typ zariadenia, ktoré toto mapovanie emuluje." + +#: data/input-remapper.glade:1831 +msgid "Trigger threshold" +msgstr "Prah aktivácie" + +#: data/input-remapper.glade:743 +msgid "Type" +msgstr "Typ" + +#: data/input-remapper.glade:1473 +msgid "Usage" +msgstr "Použitie" + +#: inputremapper/gui/controller.py:593 +msgid "Use \"Stop\" to stop before editing" +msgstr "Pred editovaním použite „Zastaviť injektáž“" + +#: data/input-remapper.glade:1805 +msgid "Use as analog" +msgstr "Použiť ako analógový" + +#: data/input-remapper.glade:1366 +msgid "Version unknown" +msgstr "Neznáma verzia" + +#: data/input-remapper.glade:1134 +msgid "What should be written. For example KEY_A" +msgstr "Čo by sa malo napísať. Napríklad KEY_A" + +#: inputremapper/gui/controller.py:761 +msgid "You are about to change the mapping to analog." +msgstr "Chystáte sa zmeniť mapovanie na analógové." + +#: data/input-remapper.glade:1161 +msgid "You can copy this text into the output" +msgstr "Tento text môžete skopírovať do výstupu" + +#: data/input-remapper.glade:1383 +msgid "" +"You can find more information and report bugs at\n" +"https://github.com/" +"sezanzeb/input-remapper" +msgstr "" +"Viac informácií a hlásenia chýb nájdete na\n" +"https://github.com/" +"sezanzeb/input-remapper" + +#: inputremapper/gui/controller.py:623 +msgid "You need to add mappings first" +msgstr "Najprv musíte pridať mapovania" + +#: inputremapper/gui/controller.py:358 +msgid "" +"Your system might reinterpret combinations with those after they are " +"injected, and by doing so break them." +msgstr "" +"Váš systém by mohol opätovne interpretovať kombinácie po tom, čo dôjde " +"k ich injektáži, čo by mohlo mať nežiadúci efekt." + +#: data/input-remapper.glade:1524 +msgid "closes the application" +msgstr "zavrie aplikáciu" + +#: data/input-remapper.glade:1512 +msgid "ctrl + del" +msgstr "ctrl + del" + +#: data/input-remapper.glade:1536 +msgid "ctrl + q" +msgstr "ctrl + q" + +#: data/input-remapper.glade:1548 +msgid "ctrl + r" +msgstr "ctrl + r" + +#: inputremapper/gui/controller.py:356 +msgid "ctrl, alt and shift may not combine properly" +msgstr "ctrl, alt a shift nemusia v kombinácii fungovať správne" + +#: inputremapper/gui/data_manager.py:56 +msgid "new preset" +msgstr "nové prednastavenie" + +#: data/input-remapper.glade:531 inputremapper/gui/user_interface.py:385 +msgid "no input configured" +msgstr "nie je nastavený žiaden vstup" + +#: data/input-remapper.glade:1560 +msgid "refreshes the device list" +msgstr "aktualizuje zoznam zariadení" + +#: data/input-remapper.glade:1572 +msgid "stops the injection" +msgstr "zastaví injektáž" + +#: data/input-remapper.glade:1403 +msgid "" +"© 2025 Sezanzeb b8x45ygc9@mozmail.com\n" +"This program comes with absolutely no warranty.\n" +"See the GNU General " +"Public License, version 3 or later for details." +msgstr "" +"© 2025 Sezanzeb b8x45ygc9@mozmail.com\n" +"Tento program je poskytovaný bez akýchkoľvek záruk.\n" +"Podrobnosti nájdete v podmienkach licencie GNU General Public License, version 3 alebo novšej." + +#, python-format +#~ msgid "\"%s\" already mapped to \"%s\"" +#~ msgstr "\"%s\" je už namapované na \"%s\"" + +#~ msgid "Applied the system default" +#~ msgstr "Použité systémové nastavenia" + +#~ msgid "Buttons" +#~ msgstr "Tlačidlá" + +#~ msgid "Cancel" +#~ msgstr "Zrušiť" + +#~ msgid "Change Key" +#~ msgstr "Zmeniť kláves" + +#~ msgid "Joystick" +#~ msgstr "Joystick" + +#~ msgid "Left joystick" +#~ msgstr "Ľavý joystick" + +#~ msgid "Mouse" +#~ msgstr "Myš" + +#~ msgid "Mouse speed" +#~ msgstr "Citlivosť myši" + +#~ msgid "Right joystick" +#~ msgstr "Pravý joystick" + +#~ msgid "" +#~ "Shortcut: ctrl + del\n" +#~ "Gives your keys back their original function" +#~ msgstr "" +#~ "Skratka: ctrl + del\n" +#~ "Vráti vašim klávesom pôvodnú funkciu" + +#~ msgid "The helper did not start" +#~ msgstr "Pomocná aplikácia sa nespustila" + +#~ msgid "" +#~ "To automatically apply the preset after your login or when it connects." +#~ msgstr "" +#~ "Na automatické načítanie prednastavenia po prihlásení alebo pripojení " +#~ "zariadenia." + +#, python-format +#~ msgid "Unknown mapping %s" +#~ msgstr "Neznáme mapovanie %s" + +#~ msgid "Wheel" +#~ msgstr "Koliesko" + +#~ msgid "Your system might reinterpret combinations " +#~ msgstr "Váš systém by mohol zle interpretovať " + +#~ msgid "break them." +#~ msgstr "ich rozbiť." + +#~ msgid "new entry" +#~ msgstr "nová položka" + +#~ msgid "." +#~ msgstr "." + +#~ msgid "1, 2" +#~ msgstr "1, 2" + +#~ msgid "" +#~ "A \"key + key + ... + key\" syntax can be used to trigger key " +#~ "combinations. For example \"control_l + a\".\n" +#~ "\n" +#~ "\"disable\" disables a key." +#~ msgstr "" +#~ "Syntax \"kláves + kláves + ... + kláves\" môžete použiť na zadávanie " +#~ "kombinácie klávesov. Napríklad \"control_l + a\".\n" +#~ "\n" +#~ "\"disable\" deaktivuje mapovanie klávesu." + +#~ msgid "" +#~ "Between calls to k, key down and key up events, macros will sleep for " +#~ "10ms by default, which can be configured in ~/.config/input-remapper/" +#~ "config" +#~ msgstr "" +#~ "Medzi volaniami k, udalosťami stlačeného a uvoľneného klávesu štandardne " +#~ "čakajú makrá 10 ms. Toto nastavenie môžete zmeniť v ~/.config/input-" +#~ "remapper/config" + +#~ msgid "CTRL + a, CTRL + x" +#~ msgstr "CTRL + a, CTRL + x" + +#~ msgid "" +#~ "Click on a cell below and hit a key on your device. Click the \"Restore " +#~ "Defaults\" button beforehand." +#~ msgstr "" +#~ "Kliknite do poľa nižšie a stlačte tlačidlo na vašom zariadení. Predtým " +#~ "kliknite na tlačidlo \"Obnoviť predvolené\"." + +#~ msgid "Examples" +#~ msgstr "Príklady" + +#~ msgid "Go Back" +#~ msgstr "Prejsť späť" + +#~ msgid "Key" +#~ msgstr "Kláves" + +#~ msgid "Macros" +#~ msgstr "Makrá" + +#~ msgid "" +#~ "Macros allow multiple characters to be written with a single key-press." +#~ msgstr "" +#~ "Makrá vám umožnia zapísať po stlačení jedného klávesu viacero znakov." + +#~ msgid "a, a, a with 500ms pause" +#~ msgstr "a, a, a s 500ms oneskorením" + +#~ msgid "e" +#~ msgstr "e" + +#~ msgid "e(EV_REL, REL_X, 10)" +#~ msgstr "e(EV_REL, REL_X, 10)" + +#~ msgid "executes the parameter as long as the key is pressed down" +#~ msgstr "vykoná parameter, kým je kláves stlačený" + +#~ msgid "executes two actions behind each other" +#~ msgstr "vykoná dve akcie za sebou" + +#~ msgid "h" +#~ msgstr "h" + +#~ msgid "holds a modifier while executing the second parameter" +#~ msgstr "počas vykonávania druhého parametra je stlačený modifikátor" + +#~ msgid "k" +#~ msgstr "k" + +#~ msgid "k(1).h(k(2)).k(3)" +#~ msgstr "k(1).h(k(2)).k(3)" + +#~ msgid "k(1).k(2)" +#~ msgstr "k(1).k(2)" + +#~ msgid "keeps scrolling down while held" +#~ msgstr "kým je držaný aktivuje sa skrolovanie smerom nadol" + +#~ msgid "m" +#~ msgstr "m" + +#~ msgid "m(Control_L, k(a).k(x))" +#~ msgstr "m(Control_L, k(a).k(x))" + +#~ msgid "mouse" +#~ msgstr "mouse" + +#~ msgid "mouse(right, 4)" +#~ msgstr "mouse(right, 4)" + +#~ msgid "moves the mouse cursor 10px to the right" +#~ msgstr "posunie kurzor myši o 10 pixelov doprava" + +#~ msgid "r" +#~ msgstr "r" + +#~ msgid "r(3, k(a).w(500))" +#~ msgstr "r(3, k(a).w(500))" + +#~ msgid "repeats the execution of the second parameter" +#~ msgstr "zopakuje spustenie druhého parametra" + +#~ msgid "same as mouse" +#~ msgstr "tie isté ako pri myši" + +#~ msgid "takes direction (up, left, ...) and speed as parameters" +#~ msgstr "prijíma smer (hore, vľavo, ...) a rýchlosť ako parametre" + +#~ msgid "w" +#~ msgstr "w" + +#~ msgid "waits in milliseconds" +#~ msgstr "čakanie (v milisekundách)" + +#~ msgid "wheel" +#~ msgstr "wheel" + +#~ msgid "wheel(down, 1)" +#~ msgstr "wheel(down, 1)" + +#~ msgid "which keeps moving the mouse while pressed" +#~ msgstr "kým je stlačený, kurzor myši sa bude posúvať" + +#~ msgid "writes 1 2 2 ... 2 2 3 while the key is pressed" +#~ msgstr "zapíše 1 2 2 ... 2 2 3, kým je kláves stlačený" + +#~ msgid "writes a single keystroke" +#~ msgstr "zapíše jedno stlačenie klávesu" + +#~ msgid "writes an event" +#~ msgstr "zapíše udalosť" diff --git a/po/uk.po b/po/uk.po new file mode 120000 index 0000000..e07f33f --- /dev/null +++ b/po/uk.po @@ -0,0 +1 @@ +uk_UA.po \ No newline at end of file diff --git a/po/uk_UA.po b/po/uk_UA.po new file mode 100644 index 0000000..1ec98d1 --- /dev/null +++ b/po/uk_UA.po @@ -0,0 +1,577 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +msgid "" +msgstr "" +"Project-Id-Version: \n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2023-05-16 08:30-0300\n" +"PO-Revision-Date: 2025-08-14 22:20+0200\n" +"Last-Translator: Vsevolod «Damglador» Stopchanskyi " +"\n" +"Language-Team: coffebar, Vsevolod «Damglador» Stopchanskyi\n" +"Language: uk_UA\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && " +"n%10<=4 && (n%100<12 || n%100>14) ? 1 : 2);\n" +"X-Generator: Poedit 3.6\n" + +#: inputremapper/gui/controller.py:222 +msgid "" +"\n" +"If you mean to create a key or macro mapping go to the advanced input " +"configuration and set a \"Trigger Threshold\" for " +msgstr "" + +#: inputremapper/gui/controller.py:239 +msgid "" +"\n" +"If you mean to create an analog axis mapping go to the advanced input " +"configuration and set an input to \"Use as Analog\"." +msgstr "" + +#: inputremapper/gui/controller.py:779 +msgid "" +"\n" +"The input \"{}\" will be used as analog input." +msgstr "" +"\n" +"Уведення «{}» використовуватиметься як аналогове." + +#: inputremapper/gui/controller.py:763 +msgid "" +"\n" +"This will remove \"{}\" from the text input!" +msgstr "" +"\n" +"Прибере «{}» з текстового введення." + +#: inputremapper/gui/controller.py:784 +msgid "" +"\n" +"You need to record an analog input." +msgstr "" +"\n" +"Необхідно записати аналогове введення." + +#: data/input-remapper.glade:544 +msgid " (recording ...)" +msgstr " (Записуємо...)" + +#: inputremapper/gui/controller.py:174 +#, python-format +msgid "%d Mapping errors at \"%s\", hover for info" +msgstr "%d синтаксичних помилок в %s, наведіть курсор для подробиць" + +#: inputremapper/gui/controller.py:661 +msgid ", CTRL + DEL to stop" +msgstr ", CTRL + DEL щоб зупинити" + +#: data/input-remapper.glade:1421 +msgid "About" +msgstr "Про програму" + +#: data/input-remapper.glade:422 +msgid "" +"Activate this to load the preset next time the device connects, or when the " +"user logs in" +msgstr "" + +#: data/input-remapper.glade:575 +msgid "Add" +msgstr "Додати" + +#: inputremapper/gui/components/editor.py:554 +msgid "Add a mapping first" +msgstr "Спочатку додайте прив'язку" + +#: data/input-remapper.glade:606 data/input-remapper.glade:1618 +msgid "Advanced" +msgstr "Розширені" + +#: data/input-remapper.glade:773 data/input-remapper.glade:1118 +msgid "Analog Axis" +msgstr "Аналоговий" + +#: inputremapper/gui/controller.py:657 +#, python-format +msgid "Applied preset %s" +msgstr "Застосовано пресет %s" + +#: data/input-remapper.glade:318 +msgid "Apply" +msgstr "Застосувати" + +#: inputremapper/gui/controller.py:522 +#, python-format +msgid "Are you sure you want to delete the preset \"%s\"?" +msgstr "Ви впевнені що хочете видалити пресет %s?" + +#: inputremapper/gui/controller.py:567 +msgid "Are you sure you want to delete this mapping?" +msgstr "Ви впевнені, що хочете видалити цю прив'язку?" + +#: data/input-remapper.glade:410 +msgid "Autoload" +msgstr "Автозавантаження" + +#: data/input-remapper.glade:854 +msgid "Available output axes are affected by the Target setting." +msgstr "Доступні вихідні осі залежать від параметра Ціль." + +#: inputremapper/gui/controller.py:621 +msgid "Cannot apply empty preset file" +msgstr "Неможливо застосувати порожній файл попереднього налаштування" + +#: inputremapper/gui/components/editor.py:238 +msgid "Change Mapping Name" +msgstr "Змінити назву прив'язки" + +#: data/input-remapper.glade:352 +msgid "Copy" +msgstr "Копіювати" + +#: data/input-remapper.glade:189 +msgid "Create a new preset" +msgstr "Стваорити новий пресет" + +#: data/input-remapper.glade:925 +msgid "Deadzone" +msgstr "Мертва зона" + +#: data/input-remapper.glade:368 data/input-remapper.glade:620 +msgid "Delete" +msgstr "Видалити" + +#: data/input-remapper.glade:624 +msgid "Delete this entry" +msgstr "Видалити запис" + +#: data/input-remapper.glade:372 +msgid "Delete this preset" +msgstr "Видалити пресет" + +#: data/input-remapper.glade:162 +msgid "Device Name" +msgstr "Назва пристрою" + +#: data/input-remapper.glade:148 +msgid "Devices" +msgstr "Пристрої" + +#: data/input-remapper.glade:356 +msgid "Duplicate this preset" +msgstr "Копіювати пресет" + +#: data/input-remapper.glade:1208 +msgid "Editor" +msgstr "Редактор" + +#: inputremapper/configs/mapping.py:77 +msgid "Empty Mapping" +msgstr "Порожня прив'язка" + +#: inputremapper/gui/components/editor.py:404 +msgid "Enter your output here" +msgstr "" + +#: data/input-remapper.glade:1762 +msgid "Event Specific" +msgstr "Залежно від події" + +#: data/input-remapper.glade:1009 +msgid "Expo" +msgstr "Експо" + +#: inputremapper/gui/controller.py:648 inputremapper/gui/controller.py:673 +#, python-format +msgid "Failed to apply preset %s" +msgstr "Не вдалося застосувати пресет %s" + +#: data/input-remapper.glade:967 +msgid "Gain" +msgstr "Посилення" + +#: data/input-remapper.glade:1736 +msgid "General" +msgstr "Загалні" + +#: data/input-remapper.glade:1312 +msgid "Help" +msgstr "Допомога" + +#: data/input-remapper.glade:510 +msgid "Input" +msgstr "Уведення" + +#: data/input-remapper.glade:1296 +msgid "Input Remapper" +msgstr "Input Remapper" + +#: data/input-remapper.glade:1087 +msgid "Input cutoff" +msgstr "Відсічення введення" + +#: data/input-remapper.glade:760 data/input-remapper.glade:1180 +msgid "Key or Macro" +msgstr "Клавіша або макрос" + +#: data/input-remapper.glade:1801 +msgid "Map this input to an Analog Axis" +msgstr "Призначити це введення на аналогову вісь" + +#: data/input-remapper.glade:185 +msgid "New" +msgstr "Нове" + +#: inputremapper/gui/components/editor.py:980 +#, fuzzy +msgid "No Axis" +msgstr "Аналоговий" + +#: data/input-remapper.glade:721 +msgid "Output" +msgstr "Виведення" + +#: data/input-remapper.glade:862 +msgid "Output axis" +msgstr "Вихідна вісь" + +#: inputremapper/gui/controller.py:508 inputremapper/gui/controller.py:582 +msgid "Permission denied!" +msgstr "Доступ відсутній!" + +#: data/input-remapper.glade:287 +msgid "Preset Name" +msgstr "Назва пресету" + +#: data/input-remapper.glade:272 +msgid "Presets" +msgstr "Пресети" + +#: data/input-remapper.glade:590 +msgid "Record" +msgstr "Запис" + +#: data/input-remapper.glade:594 +msgid "Record a button of your device that should be remapped" +msgstr "Записати клавішу яку потрібно перепризначити" + +#: inputremapper/gui/components/editor.py:563 +msgid "Record input first" +msgstr "" + +#: inputremapper/gui/components/editor.py:65 +#, fuzzy +msgid "Record the input first" +msgstr "Спочатку задайте клавішу" + +#: data/input-remapper.glade:1708 +msgid "" +"Release all inputs which are part of the combination before the mapping is " +"injected" +msgstr "" + +#: data/input-remapper.glade:1711 +msgid "Release input" +msgstr "Звільнити введення" + +#: data/input-remapper.glade:1683 +msgid "Release timeout" +msgstr "Таймаут звільнення" + +#: inputremapper/gui/controller.py:208 +msgid "Remove the Analog Output Axis when specifying a macro or key output" +msgstr "" + +#: inputremapper/gui/controller.py:199 +msgid "" +"Remove the macro or key from the macro input field when specifying an analog " +"output" +msgstr "" + +#: data/input-remapper.glade:1776 +msgid "Remove this input" +msgstr "Видалити це введення" + +#: data/input-remapper.glade:394 +msgid "Rename" +msgstr "Перейменувати" + +#: data/input-remapper.glade:452 +msgid "Save the entered name" +msgstr "Зберігти вказану назву" + +#: data/input-remapper.glade:1449 +msgid "" +"See usage.md online on github for comprehensive information.\n" +"\n" +"A \"key + key + ... + key\" syntax can be used to trigger key combinations. " +"For example \"Control_L + a\".\n" +"\n" +"Writing \"disable\" as a mapping disables a key.\n" +"\n" +"Macros allow multiple characters to be written with a single key-press. " +"Information about programming them is available online on github. See macros.md and examples.md" +msgstr "" +"Дивіться usage.md в Інтернеті на github для вичерпної " +"інформації.\n" +"\n" +"Ви можете використовувати синтаксис \"key + key + ... + key\" щоб викликати " +"комбінацію клавіш. Приклад: \"Control_L + a\".\n" +"\n" +"Слово «disable» в полі прив'язки відключає клавішу.\n" +"\n" +"Макроси дозволяють вводити декілька символів одним натисканням клавіші. " +"Інформація про їх програмування доступна в Інтернеті на github. Дивіться macros.md та examples.md" + +#: data/input-remapper.glade:1590 +msgid "Shortcuts" +msgstr "Комбінації" + +#: data/input-remapper.glade:1492 +msgid "" +"Shortcuts only work while keys are not being recorded and the gui is in " +"focus." +msgstr "" +"Комбінації працюють лише тоді, коли клавіші не записуються, а графічний " +"інтерфейс у фокусі." + +#: data/input-remapper.glade:322 +msgid "Start injecting. Don't hold down any keys while the injection starts" +msgstr "" +"Запуск перехоплення. Не натискайте нічого поки перехоплення запускається" + +#: inputremapper/gui/controller.py:643 +msgid "Starting injection..." +msgstr "Запуск перехоплення..." + +#: data/input-remapper.glade:201 data/input-remapper.glade:334 +msgid "Stop" +msgstr "Зупинити" + +#: inputremapper/gui/controller.py:710 +msgid "Stopped the injection" +msgstr "Зупиняє перехоплення" + +#: data/input-remapper.glade:205 data/input-remapper.glade:338 +msgid "" +"Stops the Injection for the selected device,\n" +"gives your keys their original function back\n" +"Shortcut: ctrl + del" +msgstr "" +"Зупиняє ін’єкцію для вибраного пристрою,\n" +"повертає вашим клавішам їх початкову функцію\n" +"Комбінація клавіш: ctrl + del" + +#: data/input-remapper.glade:812 +msgid "Target" +msgstr "Ціль" + +#: data/input-remapper.glade:1078 +msgid "" +"The Speed at which the Input is considered at maximum.\n" +"Only relevant when mapping relative inputs (e.g. mouse) to absolute outputs " +"(e.g. gamepad)" +msgstr "" +"Швидкість, з якою введення вважається максимальним.\n" +"Доречно лише під час зіставлення відносних входів (наприклад, миші) з " +"абсолютними виходами (наприклад, контролер)" + +#: inputremapper/gui/controller.py:234 +msgid "" +"The input specifies a key or macro input, but no macro or key is programmed." +msgstr "" + +#: inputremapper/gui/controller.py:213 +msgid "The input specifies an analog axis, but no output axis is selected." +msgstr "" + +#: data/input-remapper.glade:803 +msgid "The type of device this mapping is emulating." +msgstr "Тип пристрою, який емулює цей мапінг." + +#: data/input-remapper.glade:1831 +msgid "Trigger threshold" +msgstr "Поріг спрацьовування" + +#: data/input-remapper.glade:743 +msgid "Type" +msgstr "Тип" + +#: data/input-remapper.glade:1473 +msgid "Usage" +msgstr "Використання" + +#: inputremapper/gui/controller.py:593 +msgid "Use \"Stop\" to stop before editing" +msgstr "Натисніть «Зупинити» перед початком редагування" + +#: data/input-remapper.glade:1805 +msgid "Use as analog" +msgstr "Використовувати як аналоговий" + +#: data/input-remapper.glade:1366 +msgid "Version unknown" +msgstr "Невідома версія" + +#: data/input-remapper.glade:1134 +msgid "What should be written. For example KEY_A" +msgstr "Що треба написати. Наприклад KEY_A" + +#: inputremapper/gui/controller.py:761 +msgid "You are about to change the mapping to analog." +msgstr "" + +#: data/input-remapper.glade:1161 +msgid "You can copy this text into the output" +msgstr "Ви можете скопіювати цей текст у вивід" + +#: data/input-remapper.glade:1383 +msgid "" +"You can find more information and report bugs at\n" +"https://github.com/" +"sezanzeb/input-remapper" +msgstr "" +"Ви можете знайти більше інформації та повідомити про помилки на\n" +"https://github.com/" +"sezanzeb/input-remapper" + +#: inputremapper/gui/controller.py:623 +msgid "You need to add mappings first" +msgstr "Необхідно спочатку додати прив'язки" + +#: inputremapper/gui/controller.py:358 +msgid "" +"Your system might reinterpret combinations with those after they are " +"injected, and by doing so break them." +msgstr "" + +#: data/input-remapper.glade:1524 +msgid "closes the application" +msgstr "закриває програму" + +#: data/input-remapper.glade:1512 +msgid "ctrl + del" +msgstr "" + +#: data/input-remapper.glade:1536 +msgid "ctrl + q" +msgstr "" + +#: data/input-remapper.glade:1548 +msgid "ctrl + r" +msgstr "" + +#: inputremapper/gui/controller.py:356 +msgid "ctrl, alt and shift may not combine properly" +msgstr "ctrl, alt та shift можуть не поєднуватися належним чином" + +#: inputremapper/gui/data_manager.py:56 +msgid "new preset" +msgstr "Створити новий пресет" + +#: data/input-remapper.glade:531 inputremapper/gui/user_interface.py:385 +msgid "no input configured" +msgstr "уведення не налаштовано" + +#: data/input-remapper.glade:1560 +msgid "refreshes the device list" +msgstr "оновлює список пристроїв" + +#: data/input-remapper.glade:1572 +msgid "stops the injection" +msgstr "зупиняє перехоплення" + +#: data/input-remapper.glade:1403 +msgid "" +"© 2025 Sezanzeb b8x45ygc9@mozmail.com\n" +"This program comes with absolutely no warranty.\n" +"See the GNU General " +"Public License, version 3 or later for details." +msgstr "" +"© 2025 Sezanzeb b8x45ygc9@mozmail.com\n" +"Ця програма постачається без жодних гарантій.\n" +"Дивіться GNU General " +"Public License, версії 3 або новіше щоб дізнатись більше." + +#, python-format +#~ msgid "\"%s\" already mapped to \"%s\"" +#~ msgstr "\"%s\" вже призначено до \"%s\"" + +#~ msgid "Applied the system default" +#~ msgstr "Застосовано за замовченням" + +#~ msgid "Buttons" +#~ msgstr "Кнопки" + +#~ msgid "Cancel" +#~ msgstr "Скасувати" + +#~ msgid "Change Key" +#~ msgstr "Змінити клавішу" + +#~ msgid "Joystick" +#~ msgstr "Джойстик" + +#~ msgid "Left joystick" +#~ msgstr "Лівий джойстик" + +#~ msgid "Mouse" +#~ msgstr "Миша" + +#~ msgid "Mouse speed" +#~ msgstr "Швидкість миши" + +#~ msgid "Press Key" +#~ msgstr "Натисніть клавішу" + +#~ msgid "Right joystick" +#~ msgstr "Правий джойстик" + +#~ msgid "" +#~ "Shortcut: ctrl + del\n" +#~ "Gives your keys back their original function" +#~ msgstr "" +#~ "Комбінація: ctrl + del\n" +#~ "Повертає вашим клавішам їх початкову функцію" + +#~ msgid "The helper did not start" +#~ msgstr "Помічник не запущено" + +#~ msgid "" +#~ "To automatically apply the preset after your login or when it connects." +#~ msgstr "" +#~ "Для автоматичного застосування попереднього налаштування після вашого " +#~ "входу або під час підключення." + +#, python-format +#~ msgid "Unknown mapping %s" +#~ msgstr "Невідомий мапінг %s" + +#~ msgid "Wheel" +#~ msgstr "Колесо" + +#~ msgid "Your system might reinterpret combinations " +#~ msgstr "Ваша система може повторно інтерпретувати комбінації " + +#~ msgid "break them." +#~ msgstr "порушуючи їх." + +#~ msgid "new entry" +#~ msgstr "новий запис" + +#~ msgid "Stop Injection" +#~ msgstr "Зупинити перехоплення" diff --git a/po/zh.po b/po/zh.po new file mode 120000 index 0000000..347ce65 --- /dev/null +++ b/po/zh.po @@ -0,0 +1 @@ +./zh_CN.po \ No newline at end of file diff --git a/po/zh_CN.po b/po/zh_CN.po new file mode 100644 index 0000000..3bea57c --- /dev/null +++ b/po/zh_CN.po @@ -0,0 +1,560 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +msgid "" +msgstr "" +"Project-Id-Version: \n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2023-05-16 08:30-0300\n" +"PO-Revision-Date: 2022-04-09 16:04+0800\n" +"Last-Translator: \n" +"Language-Team: \n" +"Language: zh_CN\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=1; plural=0;\n" +"X-Generator: Poedit 3.0.1\n" + +#: inputremapper/gui/controller.py:222 +msgid "" +"\n" +"If you mean to create a key or macro mapping go to the advanced input " +"configuration and set a \"Trigger Threshold\" for " +msgstr "" + +#: inputremapper/gui/controller.py:239 +msgid "" +"\n" +"If you mean to create an analog axis mapping go to the advanced input " +"configuration and set an input to \"Use as Analog\"." +msgstr "" + +#: inputremapper/gui/controller.py:779 +msgid "" +"\n" +"The input \"{}\" will be used as analog input." +msgstr "" + +#: inputremapper/gui/controller.py:763 +msgid "" +"\n" +"This will remove \"{}\" from the text input!" +msgstr "" + +#: inputremapper/gui/controller.py:784 +msgid "" +"\n" +"You need to record an analog input." +msgstr "" + +#: data/input-remapper.glade:544 +msgid " (recording ...)" +msgstr "" + +#: inputremapper/gui/controller.py:174 +#, fuzzy, python-format +msgid "%d Mapping errors at \"%s\", hover for info" +msgstr "%s有语法错误,请悬停以获取信息" + +#: inputremapper/gui/controller.py:661 +msgid ", CTRL + DEL to stop" +msgstr ", CTRL + DEL 停止" + +#: data/input-remapper.glade:1421 +msgid "About" +msgstr "关于" + +#: data/input-remapper.glade:422 +msgid "" +"Activate this to load the preset next time the device connects, or when the " +"user logs in" +msgstr "" + +#: data/input-remapper.glade:575 +msgid "Add" +msgstr "" + +#: inputremapper/gui/components/editor.py:554 +msgid "Add a mapping first" +msgstr "" + +#: data/input-remapper.glade:606 data/input-remapper.glade:1618 +msgid "Advanced" +msgstr "" + +#: data/input-remapper.glade:773 data/input-remapper.glade:1118 +msgid "Analog Axis" +msgstr "" + +#: inputremapper/gui/controller.py:657 +#, python-format +msgid "Applied preset %s" +msgstr "应用预设%s" + +#: data/input-remapper.glade:318 +msgid "Apply" +msgstr "应用" + +#: inputremapper/gui/controller.py:522 +#, fuzzy, python-format +msgid "Are you sure you want to delete the preset \"%s\"?" +msgstr "确定要删除预设%s吗?" + +#: inputremapper/gui/controller.py:567 +#, fuzzy +msgid "Are you sure you want to delete this mapping?" +msgstr "确定要删除此映射吗?" + +#: data/input-remapper.glade:410 +msgid "Autoload" +msgstr "自动加载" + +#: data/input-remapper.glade:854 +msgid "Available output axes are affected by the Target setting." +msgstr "" + +#: inputremapper/gui/controller.py:621 +msgid "Cannot apply empty preset file" +msgstr "无法应用空的预设文件" + +#: inputremapper/gui/components/editor.py:238 +msgid "Change Mapping Name" +msgstr "" + +#: data/input-remapper.glade:352 +msgid "Copy" +msgstr "复制" + +#: data/input-remapper.glade:189 +msgid "Create a new preset" +msgstr "创建新预设" + +#: data/input-remapper.glade:925 +msgid "Deadzone" +msgstr "" + +#: data/input-remapper.glade:368 data/input-remapper.glade:620 +msgid "Delete" +msgstr "删除" + +#: data/input-remapper.glade:624 +msgid "Delete this entry" +msgstr "删除此条目" + +#: data/input-remapper.glade:372 +msgid "Delete this preset" +msgstr "删除此预设" + +#: data/input-remapper.glade:162 +#, fuzzy +msgid "Device Name" +msgstr "设备" + +#: data/input-remapper.glade:148 +#, fuzzy +msgid "Devices" +msgstr "设备" + +#: data/input-remapper.glade:356 +msgid "Duplicate this preset" +msgstr "复制此预设" + +#: data/input-remapper.glade:1208 +msgid "Editor" +msgstr "" + +#: inputremapper/configs/mapping.py:77 +msgid "Empty Mapping" +msgstr "" + +#: inputremapper/gui/components/editor.py:404 +msgid "Enter your output here" +msgstr "" + +#: data/input-remapper.glade:1762 +msgid "Event Specific" +msgstr "" + +#: data/input-remapper.glade:1009 +msgid "Expo" +msgstr "" + +#: inputremapper/gui/controller.py:648 inputremapper/gui/controller.py:673 +#, python-format +msgid "Failed to apply preset %s" +msgstr "应用预设%s失败" + +#: data/input-remapper.glade:967 +msgid "Gain" +msgstr "" + +#: data/input-remapper.glade:1736 +msgid "General" +msgstr "" + +#: data/input-remapper.glade:1312 +msgid "Help" +msgstr "帮助" + +#: data/input-remapper.glade:510 +msgid "Input" +msgstr "" + +#: data/input-remapper.glade:1296 +msgid "Input Remapper" +msgstr "Input Remapper" + +#: data/input-remapper.glade:1087 +msgid "Input cutoff" +msgstr "" + +#: data/input-remapper.glade:760 data/input-remapper.glade:1180 +msgid "Key or Macro" +msgstr "" + +#: data/input-remapper.glade:1801 +msgid "Map this input to an Analog Axis" +msgstr "" + +#: data/input-remapper.glade:185 +msgid "New" +msgstr "新建" + +#: inputremapper/gui/components/editor.py:980 +msgid "No Axis" +msgstr "" + +#: data/input-remapper.glade:721 +msgid "Output" +msgstr "" + +#: data/input-remapper.glade:862 +msgid "Output axis" +msgstr "" + +#: inputremapper/gui/controller.py:508 inputremapper/gui/controller.py:582 +msgid "Permission denied!" +msgstr "权限不足!" + +#: data/input-remapper.glade:287 +#, fuzzy +msgid "Preset Name" +msgstr "预设" + +#: data/input-remapper.glade:272 +#, fuzzy +msgid "Presets" +msgstr "预设" + +#: data/input-remapper.glade:590 +msgid "Record" +msgstr "" + +#: data/input-remapper.glade:594 +msgid "Record a button of your device that should be remapped" +msgstr "记录你设备上应重新映射的按钮" + +#: inputremapper/gui/components/editor.py:563 +msgid "Record input first" +msgstr "" + +#: inputremapper/gui/components/editor.py:65 +#, fuzzy +msgid "Record the input first" +msgstr "先设置按键" + +#: data/input-remapper.glade:1708 +msgid "" +"Release all inputs which are part of the combination before the mapping is " +"injected" +msgstr "" + +#: data/input-remapper.glade:1711 +msgid "Release input" +msgstr "" + +#: data/input-remapper.glade:1683 +msgid "Release timeout" +msgstr "" + +#: inputremapper/gui/controller.py:208 +msgid "Remove the Analog Output Axis when specifying a macro or key output" +msgstr "" + +#: inputremapper/gui/controller.py:199 +msgid "" +"Remove the macro or key from the macro input field when specifying an analog " +"output" +msgstr "" + +#: data/input-remapper.glade:1776 +msgid "Remove this input" +msgstr "" + +#: data/input-remapper.glade:394 +msgid "Rename" +msgstr "重命名" + +#: data/input-remapper.glade:452 +msgid "Save the entered name" +msgstr "以输入的名字保存" + +#: data/input-remapper.glade:1449 +msgid "" +"See usage.md online on github for comprehensive information.\n" +"\n" +"A \"key + key + ... + key\" syntax can be used to trigger key combinations. " +"For example \"Control_L + a\".\n" +"\n" +"Writing \"disable\" as a mapping disables a key.\n" +"\n" +"Macros allow multiple characters to be written with a single key-press. " +"Information about programming them is available online on github. See macros.md and examples.md" +msgstr "" +"更全面的信息,参见 github 在线文档 usage.md。\n" +"\n" +"“键 + 键 + …… + 键”语言可用于触发组合键。例如“Control_L + a” 。\n" +"\n" +"输入“disable”作为映射值来禁用此按键。\n" +"\n" +"“宏”可以做到按一次键写入多个字符。有关编程的信息可以在 github 上在线获得。 查" +"阅 macros.mdexamples.md" + +#: data/input-remapper.glade:1590 +msgid "Shortcuts" +msgstr "快捷键" + +#: data/input-remapper.glade:1492 +msgid "" +"Shortcuts only work while keys are not being recorded and the gui is in " +"focus." +msgstr "快捷键仅在按键未被录制且应用界面处于焦点时有效。" + +#: data/input-remapper.glade:322 +msgid "Start injecting. Don't hold down any keys while the injection starts" +msgstr "启动注入。注入启动时不要按任何键" + +#: inputremapper/gui/controller.py:643 +msgid "Starting injection..." +msgstr "注入启动中……" + +#: data/input-remapper.glade:201 data/input-remapper.glade:334 +msgid "Stop" +msgstr "停止注入" + +#: inputremapper/gui/controller.py:710 +#, fuzzy +msgid "Stopped the injection" +msgstr "停止此注入" + +#: data/input-remapper.glade:205 data/input-remapper.glade:338 +msgid "" +"Stops the Injection for the selected device,\n" +"gives your keys their original function back\n" +"Shortcut: ctrl + del" +msgstr "" + +#: data/input-remapper.glade:812 +msgid "Target" +msgstr "" + +#: data/input-remapper.glade:1078 +msgid "" +"The Speed at which the Input is considered at maximum.\n" +"Only relevant when mapping relative inputs (e.g. mouse) to absolute outputs " +"(e.g. gamepad)" +msgstr "" + +#: inputremapper/gui/controller.py:234 +msgid "" +"The input specifies a key or macro input, but no macro or key is programmed." +msgstr "" + +#: inputremapper/gui/controller.py:213 +msgid "The input specifies an analog axis, but no output axis is selected." +msgstr "" + +#: data/input-remapper.glade:803 +msgid "The type of device this mapping is emulating." +msgstr "该映射正在模拟的设备类型。" + +#: data/input-remapper.glade:1831 +msgid "Trigger threshold" +msgstr "" + +#: data/input-remapper.glade:743 +msgid "Type" +msgstr "" + +#: data/input-remapper.glade:1473 +msgid "Usage" +msgstr "用法" + +#: inputremapper/gui/controller.py:593 +msgid "Use \"Stop\" to stop before editing" +msgstr "使用“停止注入”停止后再编辑" + +#: data/input-remapper.glade:1805 +msgid "Use as analog" +msgstr "" + +#: data/input-remapper.glade:1366 +msgid "Version unknown" +msgstr "版本未知" + +#: data/input-remapper.glade:1134 +msgid "What should be written. For example KEY_A" +msgstr "" + +#: inputremapper/gui/controller.py:761 +msgid "You are about to change the mapping to analog." +msgstr "" + +#: data/input-remapper.glade:1161 +msgid "You can copy this text into the output" +msgstr "" + +#: data/input-remapper.glade:1383 +msgid "" +"You can find more information and report bugs at\n" +"https://github.com/" +"sezanzeb/input-remapper" +msgstr "" +"你可以在此找到更多信息并报告 bug\n" +"https://github.com/" +"sezanzeb/input-remapper" + +#: inputremapper/gui/controller.py:623 +#, fuzzy +msgid "You need to add mappings first" +msgstr "你需要先添加键并保存" + +#: inputremapper/gui/controller.py:358 +#, fuzzy +msgid "" +"Your system might reinterpret combinations with those after they are " +"injected, and by doing so break them." +msgstr "系统可能会重新解释此组合 " + +#: data/input-remapper.glade:1524 +msgid "closes the application" +msgstr "关闭应用" + +#: data/input-remapper.glade:1512 +msgid "ctrl + del" +msgstr "ctrl + del" + +#: data/input-remapper.glade:1536 +msgid "ctrl + q" +msgstr "ctrl + q" + +#: data/input-remapper.glade:1548 +msgid "ctrl + r" +msgstr "ctrl + r" + +#: inputremapper/gui/controller.py:356 +msgid "ctrl, alt and shift may not combine properly" +msgstr "ctrl、alt 和 shift 键可能无法正确组合" + +#: inputremapper/gui/data_manager.py:56 +#, fuzzy +msgid "new preset" +msgstr "创建新预设" + +#: data/input-remapper.glade:531 inputremapper/gui/user_interface.py:385 +msgid "no input configured" +msgstr "" + +#: data/input-remapper.glade:1560 +msgid "refreshes the device list" +msgstr "刷新设备列表" + +#: data/input-remapper.glade:1572 +msgid "stops the injection" +msgstr "停止此注入" + +#: data/input-remapper.glade:1403 +msgid "" +"© 2025 Sezanzeb b8x45ygc9@mozmail.com\n" +"This program comes with absolutely no warranty.\n" +"See the GNU General " +"Public License, version 3 or later for details." +msgstr "" +"© 2025 Sezanzeb b8x45ygc9@mozmail.com\n" +"此程序毫无担保。\n" +"详情参见 GNU General " +"Public License, version 3 or later。" + +#, python-format +#~ msgid "\"%s\" already mapped to \"%s\"" +#~ msgstr "“%s”已映射到“%s”" + +#~ msgid "Applied the system default" +#~ msgstr "系统默认设置已应用" + +#~ msgid "Buttons" +#~ msgstr "按键" + +#~ msgid "Cancel" +#~ msgstr "取消" + +#~ msgid "Change Key" +#~ msgstr "修改键位" + +#~ msgid "Joystick" +#~ msgstr "摇杆" + +#~ msgid "Left joystick" +#~ msgstr "左摇杆" + +#~ msgid "Mouse" +#~ msgstr "鼠标" + +#~ msgid "Mouse speed" +#~ msgstr "鼠标速度" + +#~ msgid "Press Key" +#~ msgstr "按按键" + +#~ msgid "Right joystick" +#~ msgstr "右摇杆" + +#~ msgid "" +#~ "Shortcut: ctrl + del\n" +#~ "Gives your keys back their original function" +#~ msgstr "" +#~ "快捷键: ctrl + del\n" +#~ "一键恢复到按键的原有功能" + +#~ msgid "The helper did not start" +#~ msgstr "辅助程序未启动" + +#~ msgid "" +#~ "To automatically apply the preset after your login or when it connects." +#~ msgstr "用于在登录后或连接时自动应用该预设。" + +#, python-format +#~ msgid "Unknown mapping %s" +#~ msgstr "未知映射 %s" + +#~ msgid "Wheel" +#~ msgstr "滚轮" + +#~ msgid "Your system might reinterpret combinations " +#~ msgstr "在这些组合键位被注入后 " + +#~ msgid "break them." +#~ msgstr "从而破坏这些组合键。" + +#~ msgid "new entry" +#~ msgstr "新条目" diff --git a/po/zh_TW.po b/po/zh_TW.po new file mode 100644 index 0000000..5f63646 --- /dev/null +++ b/po/zh_TW.po @@ -0,0 +1,505 @@ +# Traditional Chinese (Taiwan) translation for input-remapper. +# Copyright (C) 2026 THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the input-remapper package. +# Peter Dave Hello , 2026. +# +msgid "" +msgstr "" +"Project-Id-Version: input-remapper\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2023-05-16 08:30-0300\n" +"PO-Revision-Date: 2026-05-28 02:54+0800\n" +"Last-Translator: Peter Dave Hello \n" +"Language-Team: \n" +"Language: zh_TW\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=1; plural=0;\n" + +#: inputremapper/gui/controller.py:222 +msgid "" +"\n" +"If you mean to create a key or macro mapping go to the advanced input " +"configuration and set a \"Trigger Threshold\" for " +msgstr "" +"\n" +"如果想建立按鍵或巨集對應,請前往進階輸入設定,並將「觸發臨界值」設定給 " + +#: inputremapper/gui/controller.py:239 +msgid "" +"\n" +"If you mean to create an analog axis mapping go to the advanced input " +"configuration and set an input to \"Use as Analog\"." +msgstr "" +"\n" +"若要建立類比軸對應,請前往進階輸入設定,並將輸入設為「以類比方式使用」。" + +#: inputremapper/gui/controller.py:779 +msgid "" +"\n" +"The input \"{}\" will be used as analog input." +msgstr "" +"\n" +"系統會將輸入「{}」當作類比輸入使用。" + +#: inputremapper/gui/controller.py:763 +msgid "" +"\n" +"This will remove \"{}\" from the text input!" +msgstr "" +"\n" +"這會將「{}」從文字輸入中移除!" + +#: inputremapper/gui/controller.py:784 +msgid "" +"\n" +"You need to record an analog input." +msgstr "" +"\n" +"請錄製一個類比輸入。" + +#: data/input-remapper.glade:544 +msgid " (recording ...)" +msgstr "(錄製中……)" + +#: inputremapper/gui/controller.py:174 +#, python-format +msgid "%d Mapping errors at \"%s\", hover for info" +msgstr "%d 個對應錯誤位於「%s」,將游標停在上方檢視資訊" + +#: inputremapper/gui/controller.py:661 +msgid ", CTRL + DEL to stop" +msgstr ",按 CTRL + DEL 停止" + +#: data/input-remapper.glade:1421 +msgid "About" +msgstr "關於" + +#: data/input-remapper.glade:422 +msgid "" +"Activate this to load the preset next time the device connects, or when the " +"user logs in" +msgstr "" +"啟用此選項後,裝置下次連線時或使用者登入時將自動載入預設組合" + +#: data/input-remapper.glade:575 +msgid "Add" +msgstr "新增" + +#: inputremapper/gui/components/editor.py:554 +msgid "Add a mapping first" +msgstr "請先新增一個對應" + +#: data/input-remapper.glade:606 data/input-remapper.glade:1618 +msgid "Advanced" +msgstr "進階" + +#: data/input-remapper.glade:773 data/input-remapper.glade:1118 +msgid "Analog Axis" +msgstr "類比軸" + +#: inputremapper/gui/controller.py:657 +#, python-format +msgid "Applied preset %s" +msgstr "已套用預設組合 %s" + +#: data/input-remapper.glade:318 +msgid "Apply" +msgstr "套用" + +#: inputremapper/gui/controller.py:522 +#, python-format +msgid "Are you sure you want to delete the preset \"%s\"?" +msgstr "確定要刪除預設組合「%s」嗎?" + +#: inputremapper/gui/controller.py:567 +msgid "Are you sure you want to delete this mapping?" +msgstr "確定要刪除此對應嗎?" + +#: data/input-remapper.glade:410 +msgid "Autoload" +msgstr "自動載入" + +#: data/input-remapper.glade:854 +msgid "Available output axes are affected by the Target setting." +msgstr "目標設定會影響可用的輸出軸。" + +#: inputremapper/gui/controller.py:621 +msgid "Cannot apply empty preset file" +msgstr "無法套用空白的預設組合檔案" + +#: inputremapper/gui/components/editor.py:238 +msgid "Change Mapping Name" +msgstr "變更對應名稱" + +#: data/input-remapper.glade:352 +msgid "Copy" +msgstr "複製" + +#: data/input-remapper.glade:189 +msgid "Create a new preset" +msgstr "建立新的預設組合" + +#: data/input-remapper.glade:925 +msgid "Deadzone" +msgstr "死區" + +#: data/input-remapper.glade:368 data/input-remapper.glade:620 +msgid "Delete" +msgstr "刪除" + +#: data/input-remapper.glade:624 +msgid "Delete this entry" +msgstr "刪除此項" + +#: data/input-remapper.glade:372 +msgid "Delete this preset" +msgstr "刪除此預設組合" + +#: data/input-remapper.glade:162 +msgid "Device Name" +msgstr "裝置名稱" + +#: data/input-remapper.glade:148 +msgid "Devices" +msgstr "裝置" + +#: data/input-remapper.glade:356 +msgid "Duplicate this preset" +msgstr "複製此預設組合" + +#: data/input-remapper.glade:1208 +msgid "Editor" +msgstr "編輯器" + +#: inputremapper/configs/mapping.py:77 +msgid "Empty Mapping" +msgstr "空白對應" + +#: inputremapper/gui/components/editor.py:404 +msgid "Enter your output here" +msgstr "請在此輸入輸出內容" + +#: data/input-remapper.glade:1762 +msgid "Event Specific" +msgstr "事件專屬" + +#: data/input-remapper.glade:1009 +msgid "Expo" +msgstr "指數曲線" + +#: inputremapper/gui/controller.py:648 inputremapper/gui/controller.py:673 +#, python-format +msgid "Failed to apply preset %s" +msgstr "套用預設組合 %s 失敗" + +#: data/input-remapper.glade:967 +msgid "Gain" +msgstr "增益" + +#: data/input-remapper.glade:1736 +msgid "General" +msgstr "一般" + +#: data/input-remapper.glade:1312 +msgid "Help" +msgstr "說明" + +#: data/input-remapper.glade:510 +msgid "Input" +msgstr "輸入" + +#: data/input-remapper.glade:1296 +msgid "Input Remapper" +msgstr "Input Remapper" + +#: data/input-remapper.glade:1087 +msgid "Input cutoff" +msgstr "輸入截止值" + +#: data/input-remapper.glade:760 data/input-remapper.glade:1180 +msgid "Key or Macro" +msgstr "按鍵或巨集" + +#: data/input-remapper.glade:1801 +msgid "Map this input to an Analog Axis" +msgstr "將此輸入對應至類比軸" + +#: data/input-remapper.glade:185 +msgid "New" +msgstr "新增" + +#: inputremapper/gui/components/editor.py:980 +msgid "No Axis" +msgstr "無軸" + +#: data/input-remapper.glade:721 +msgid "Output" +msgstr "輸出" + +#: data/input-remapper.glade:862 +msgid "Output axis" +msgstr "輸出軸" + +#: inputremapper/gui/controller.py:508 inputremapper/gui/controller.py:582 +msgid "Permission denied!" +msgstr "權限不足!" + +#: data/input-remapper.glade:287 +msgid "Preset Name" +msgstr "預設組合名稱" + +#: data/input-remapper.glade:272 +msgid "Presets" +msgstr "預設組合" + +#: data/input-remapper.glade:590 +msgid "Record" +msgstr "錄製" + +#: data/input-remapper.glade:594 +msgid "Record a button of your device that should be remapped" +msgstr "錄製裝置上要重新對應的按鈕" + +#: inputremapper/gui/components/editor.py:563 +msgid "Record input first" +msgstr "請先錄製輸入" + +#: inputremapper/gui/components/editor.py:65 +msgid "Record the input first" +msgstr "請先錄製輸入" + +#: data/input-remapper.glade:1708 +msgid "" +"Release all inputs which are part of the combination before the mapping is " +"injected" +msgstr "" +"在注入對應前,先釋放屬於該組合的所有輸入" + +#: data/input-remapper.glade:1711 +msgid "Release input" +msgstr "釋放輸入" + +#: data/input-remapper.glade:1683 +msgid "Release timeout" +msgstr "釋放逾時" + +#: inputremapper/gui/controller.py:208 +msgid "Remove the Analog Output Axis when specifying a macro or key output" +msgstr "指定巨集或按鍵輸出時,請移除類比輸出軸" + +#: inputremapper/gui/controller.py:199 +msgid "" +"Remove the macro or key from the macro input field when specifying an analog " +"output" +msgstr "" +"指定類比輸出時,請從巨集輸入欄位中移除巨集或按鍵" + +#: data/input-remapper.glade:1776 +msgid "Remove this input" +msgstr "移除此輸入" + +#: data/input-remapper.glade:394 +msgid "Rename" +msgstr "重新命名" + +#: data/input-remapper.glade:452 +msgid "Save the entered name" +msgstr "儲存輸入的名稱" + +#: data/input-remapper.glade:1449 +msgid "" +"See usage.md online on github for comprehensive information.\n" +"\n" +"A \"key + key + ... + key\" syntax can be used to trigger key combinations. " +"For example \"Control_L + a\".\n" +"\n" +"Writing \"disable\" as a mapping disables a key.\n" +"\n" +"Macros allow multiple characters to be written with a single key-press. " +"Information about programming them is available online on github. See macros.md and examples.md" +msgstr "" +"完整資訊請參閱 GitHub 上的線上文件 usage.md。\n" +"\n" +"可使用「按鍵 + 按鍵 + … + 按鍵」語法觸發組合鍵,例如「Control_L + a」。\n" +"\n" +"將對應值設為「disable」可停用該按鍵。\n" +"\n" +"巨集可讓單次按鍵輸入多個字元。巨集編寫相關資訊請參閱 GitHub 上的線上文件:" +"macros.mdexamples.md" + +#: data/input-remapper.glade:1590 +msgid "Shortcuts" +msgstr "快速鍵" + +#: data/input-remapper.glade:1492 +msgid "" +"Shortcuts only work while keys are not being recorded and the gui is in " +"focus." +msgstr "快速鍵僅在未錄製按鍵且應用程式視窗為焦點時才有作用。" + +#: data/input-remapper.glade:322 +msgid "Start injecting. Don't hold down any keys while the injection starts" +msgstr "開始注入。注入啟動時請勿按住任何按鍵" + +#: inputremapper/gui/controller.py:643 +msgid "Starting injection..." +msgstr "正在啟動注入……" + +#: data/input-remapper.glade:201 data/input-remapper.glade:334 +msgid "Stop" +msgstr "停止" + +#: inputremapper/gui/controller.py:710 +msgid "Stopped the injection" +msgstr "已停止注入" + +#: data/input-remapper.glade:205 data/input-remapper.glade:338 +msgid "" +"Stops the Injection for the selected device,\n" +"gives your keys their original function back\n" +"Shortcut: ctrl + del" +msgstr "" +"停止所選裝置的注入,\n" +"還原按鍵原有功能\n" +"快速鍵:ctrl + del" + +#: data/input-remapper.glade:812 +msgid "Target" +msgstr "目標" + +#: data/input-remapper.glade:1078 +msgid "" +"The Speed at which the Input is considered at maximum.\n" +"Only relevant when mapping relative inputs (e.g. mouse) to absolute outputs " +"(e.g. gamepad)" +msgstr "" +"系統將輸入視為最大值時的速度。\n" +"僅在將相對輸入(例如滑鼠)對應至絕對輸出(例如遊戲手把)時適用" + +#: inputremapper/gui/controller.py:234 +msgid "" +"The input specifies a key or macro input, but no macro or key is programmed." +msgstr "" +"輸入指定為按鍵或巨集輸入,但尚未設定任何巨集或按鍵。" + +#: inputremapper/gui/controller.py:213 +msgid "The input specifies an analog axis, but no output axis is selected." +msgstr "輸入指定為類比軸,但尚未選擇輸出軸。" + +#: data/input-remapper.glade:803 +msgid "The type of device this mapping is emulating." +msgstr "此對應所仿效的裝置類型。" + +#: data/input-remapper.glade:1831 +msgid "Trigger threshold" +msgstr "觸發臨界值" + +#: data/input-remapper.glade:743 +msgid "Type" +msgstr "類型" + +#: data/input-remapper.glade:1473 +msgid "Usage" +msgstr "使用方式" + +#: inputremapper/gui/controller.py:593 +msgid "Use \"Stop\" to stop before editing" +msgstr "請先按 [停止] 停止注入再進行編輯" + +#: data/input-remapper.glade:1805 +msgid "Use as analog" +msgstr "以類比方式使用" + +#: data/input-remapper.glade:1366 +msgid "Version unknown" +msgstr "版本未知" + +#: data/input-remapper.glade:1134 +msgid "What should be written. For example KEY_A" +msgstr "要輸出的內容,例如 KEY_A" + +#: inputremapper/gui/controller.py:761 +msgid "You are about to change the mapping to analog." +msgstr "即將把對應變更為類比模式。" + +#: data/input-remapper.glade:1161 +msgid "You can copy this text into the output" +msgstr "可將此文字複製到輸出欄位" + +#: data/input-remapper.glade:1383 +msgid "" +"You can find more information and report bugs at\n" +"https://github.com/" +"sezanzeb/input-remapper" +msgstr "" +"可在此找到更多資訊及回報錯誤\n" +"https://github.com/" +"sezanzeb/input-remapper" + +#: inputremapper/gui/controller.py:623 +msgid "You need to add mappings first" +msgstr "請先新增對應" + +#: inputremapper/gui/controller.py:358 +msgid "" +"Your system might reinterpret combinations with those after they are " +"injected, and by doing so break them." +msgstr "" +"系統可能會在注入後重新解讀含有這些鍵的組合,導致組合鍵無法正常運作。" + +#: data/input-remapper.glade:1524 +msgid "closes the application" +msgstr "關閉應用程式" + +#: data/input-remapper.glade:1512 +msgid "ctrl + del" +msgstr "ctrl + del" + +#: data/input-remapper.glade:1536 +msgid "ctrl + q" +msgstr "ctrl + q" + +#: data/input-remapper.glade:1548 +msgid "ctrl + r" +msgstr "ctrl + r" + +#: inputremapper/gui/controller.py:356 +msgid "ctrl, alt and shift may not combine properly" +msgstr "ctrl、alt 和 shift 鍵可能無法正確組合" + +#: inputremapper/gui/data_manager.py:56 +msgid "new preset" +msgstr "新預設組合" + +#: data/input-remapper.glade:531 inputremapper/gui/user_interface.py:385 +msgid "no input configured" +msgstr "尚未設定輸入" + +#: data/input-remapper.glade:1560 +msgid "refreshes the device list" +msgstr "重新整理裝置清單" + +#: data/input-remapper.glade:1572 +msgid "stops the injection" +msgstr "停止注入" + +#: data/input-remapper.glade:1403 +msgid "" +"© 2025 Sezanzeb b8x45ygc9@mozmail.com\n" +"This program comes with absolutely no warranty.\n" +"See the GNU General " +"Public License, version 3 or later for details." +msgstr "" +"© 2025 Sezanzeb b8x45ygc9@mozmail.com\n" +"本程式不附帶任何擔保。\n" +"詳細資訊請參閱 GNU General " +"Public License 第 3 版或更新版本。" diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..5baedad --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,32 @@ +[project] +name = "input-remapper" +version = "2.2.1" +description = "A tool to change the mapping of your input device buttons" +authors = [ + { name = "Sezanzeb", email = "b8x45ygc9@mozmail.com" }, +] +license = { file = "LICENSE" } +readme = "README.md" +requires-python = ">=3.10" +dependencies = [ + "evdev", + "psutil", + "dasbus", + "pycairo", + "PyGObject", + "pydantic", + "packaging", +] + +[tool.setuptools.packages.find] +where = ["."] +include = ["inputremapper*"] + +[project.urls] +Homepage = "https://github.com/sezanzeb/input-remapper" + +[tool.ruff.lint] +# Because of gi.require_version we get this a lot +ignore = ["E402"] +# Lots of unused imports in +exclude = ["install/check_dependencies.py"] diff --git a/readme/architecture.png b/readme/architecture.png new file mode 100644 index 0000000000000000000000000000000000000000..15f814676dd8e25063ff69f237a8f548f6c3ea96 GIT binary patch literal 256638 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,S|)JnHU*s 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 + + + + + + + + + + + + + + + coverage + coverage + + + 88% + 88% + + diff --git a/readme/development.md b/readme/development.md new file mode 100644 index 0000000..d3ecb3b --- /dev/null +++ b/readme/development.md @@ -0,0 +1,156 @@ +Development +=========== + +Contributions are very welcome, I will gladly review and discuss any merge requests. +If you have questions about the code and architecture, feel free to +[open an issue](https://github.com/sezanzeb/input-remapper/issues). +This file should give an overview about some internals of input-remapper. + +All pull requests will at some point require unittests (see below for more info). +The code coverage may only be improved, not decreased. It also has to be mostly +compliant with pylint. + +Running +------- + +To quickly restart input-remapper without pkexec prompts, you can use + +```bash +sudo pkill -f input-remapper && sudo input-remapper-reader-service -d & sudo input-remapper-service -d & input-remapper-gtk -d +``` + +Linting +------- + +```bash +mypy inputremapper # find typing issues +black . # auto-format all code in-place +pip install pylint-pydantic --user # https://github.com/fcfangcc/pylint-pydantic +pylint inputremapper # get a code quality rating from pylint +``` + +Pylint gives lots of great advice on how to write better python code and even detects +errors. Mypy checks for typing errors. Use black to format it. + +Automated tests +--------------- + +You should be able to use your IDEs built in python unittest features to run tests. +But you can also run them from your console: + +```bash +pip install psutil # https://github.com/giampaolo/psutil +sudo pkill -f input-remapper +python3 -m unittest discover -s ./tests/ +python3 -m unittest tests/unit/test_daemon.py +python3 -m unittest tests.unit.test_ipc.TestPipe -k "test_pipe" -f +# See `python -m unittest -h` for more. +``` + +Don't use your computer during integration tests to avoid interacting with the gui, +which might make tests fail. + +To read events for manual testing, `evtest` is very helpful. +Add `-d` to `input-remapper-gtk` to get debug output. + +Writing Tests +------------- + +Tests are in https://github.com/sezanzeb/input-remapper/tree/main/tests + +Make sure to use the `@test_setup` decorator. Look for other tests that did something +vaguely similar for inspiration. For example, copy one of the macro test files +and modify it if you write a new macro. + +If you have difficulty running your tests locally, github will run them for you when +you create a new pull request. + +Scripts +------- + +To automate some of the development tasks, you can use the +[setup.sh](/scripts/setup.sh) script. The script avoids using `pip` for installation. +Instead, it uses either your local `python3` in your virtual env, or using +`/usr/bin/python3` explicitly. For more information run + +``` +scripts/setup.sh help +``` + +Advice +------ + +Do not use GTKs `foreach` methods, because when the function fails it just freezes up +completely. Use `get_children()` and iterate over it with regular python `for` loops. +Use `gtk_iteration()` in tests when interacting with GTK methods to trigger events to +be emitted. + +Do not do `from evdev import list_devices; list_devices()`, and instead do +`import evdev; evdev.list_devices()`. The first variant cannot be easily patched in +tests (there are ways, but as far as I can tell it has to be configured individually +for each source-file/module). The second option allows for patches to be defiend in +one central places. Importing `KEY_*`, `BTN_*`, etc. constants via `from evdev` is +fine. + +Releasing +--------- + +ssh/login into a debian/ubuntu environment + +```bash +scripts/build-deb.sh +``` + +This will generate `dist/input-remapper-2.2.1.deb` + +Badges +------ + +```bash +# https://github.com/nedbat/coveragepy https://github.com/giampaolo/psutil +pip install coverage anybadge pylint psutil +sudo pkill -f input-remapper +# Make sure input-remapper is uninstalled, then install it editable (without sudo +# should be fine), so that the path for the coverage collection is correct. +# Use `find /usr/ -iname "*inputremapper*"` to check if it is uninstalled. +pip install -e . +./scripts/badges.sh +``` + +New badges, if needed, will be created in `readme/` and they just need to be commited. + +Translations +------------ + +To regenerate the `po/input-remapper.pot` file, run + +```bash +xgettext -k --keyword=translatable --sort-output -o po/input-remapper.pot data/input-remapper.glade +xgettext --keyword=_ -L Python --sort-output -jo po/input-remapper.pot inputremapper/configs/mapping.py inputremapper/gui/*.py inputremapper/gui/components/*.py +``` + +This is the template file that you can copy to fill in the translations. Also create a +corresponding symlink, like `ln -s it_IT.po it.po`, because some environments expect +different names, apparently. See https://github.com/sezanzeb/input-remapper/tree/main/po +for examples. + +Architecture +------------ + +There is a miro board describing input-remappers architecture: + +https://miro.com/app/board/uXjVPLa8ilM=/?share_link_id=272180986764 + +![architecture.png](./architecture.png) + +Resources +--------- + +- [Guidelines for device capabilities](https://www.kernel.org/doc/Documentation/input/event-codes.txt) +- [PyGObject API Reference](https://lazka.github.io/pgi-docs/) +- [python-evdev](https://python-evdev.readthedocs.io/en/stable/) +- [Python Unix Domain Sockets](https://pymotw.com/2/socket/uds.html) +- [GNOME HIG](https://developer.gnome.org/hig/stable/) +- [GtkSource Example](https://github.com/wolfthefallen/py-GtkSourceCompletion-example) +- [linux/input-event-codes.h](https://github.com/torvalds/linux/blob/master/include/uapi/linux/input-event-codes.h) +- [Screenshot Guidelines](https://www.freedesktop.org/software/appstream/docs/chap-Quickstart.html) diff --git a/readme/examples.md b/readme/examples.md new file mode 100644 index 0000000..3edea37 --- /dev/null +++ b/readme/examples.md @@ -0,0 +1,190 @@ +# Examples + +Examples for particular devices and/or use cases: + +## Event Names + +- Alphanumeric `a` to `z` and `0` to `9` +- Modifiers `Alt_L` `Control_L` `Control_R` `Shift_L` `Shift_R` +- Mouse buttons `BTN_LEFT` `BTN_RIGHT` `BTN_MIDDLE` `BTN_SIDE` ... +- Multimedia keys `KEY_NEXTSONG` `KEY_PLAYPAUSE` `XF86AudioMicMute` ... + +Mouse movements have to be performed by macros. See below. + +## Short Macro Examples + +- `key(BTN_LEFT)` a single mouse-click +- `key(1).key(2)` 1, 2 +- `wheel(down, 10)` `wheel(up, 10)` Scroll while the input is pressed. +- `mouse(left, 5)` `mouse(right, 2)` `mouse(up, 1)` `mouse(down, 3)` Move the cursor while the input is pressed. +- `repeat(3, key(a).w(500))` a, a, a with 500ms pause +- `modify(Control_L, key(a).key(x))` CTRL + a, CTRL + x +- `key(1).hold(key(2)).key(3)` writes 1 2 2 ... 2 2 3 while the key is pressed +- `event(EV_REL, REL_X, 10)` moves the mouse cursor 10px to the right +- `mouse(right, 4)` which keeps moving the mouse while pressed +- `wheel(down, 1)` keeps scrolling down while held +- `set(foo, 1)` set ["foo"](https://en.wikipedia.org/wiki/Metasyntactic_variable) to 1 +- `if_eq($foo, 1, key(x), key(y))` if "foo" is 1, write x, otherwise y +- `hold()` does nothing as long as your key is held down +- `hold_keys(a)` holds down "a" as long as the key is pressed, just like a regular non-macro mapping +- `if_tap(key(a), key(b))` writes a if the key is tapped, otherwise b +- `if_tap(key(a), key(b), 1000)` writes a if the key is released within a second, otherwise b +- `if_single(key(a), key(b))` writes b if another key is pressed, or a if the key is released + and no other key was pressed in the meantime. +- `if_tap(if_tap(key(a), key(b)), key(c))` "a" if tapped twice, "b" if tapped once and "c" if + held down long enough +- `key_up(a).wait(1000).key_down(a)` keeps a pressed for one second +- `hold_keys(Control_L, a)` holds down those two keys +- `key(BTN_LEFT).wait(100).key(BTN_LEFT)` a double-click + +## Double Tap + +``` +if_tap( + if_tap( + key(a), + key(c) + ), + key(b) +) +``` + +- press twice: a +- press and hold: b +- press and release: c + +## Combinations Spanning Multiple Devices + +For regular combinations on only single devices it is not required to +configure macros. See [readme/usage.md](usage.md#combinations). + +**Keyboard** `space` `set(foo, 1).hold_keys(space).set(foo, 0)` + +**Mouse** `middle` `if_eq($foo, 1, hold_keys(a), hold_keys(BTN_MIDDLE))` + +Apply both presets. If you press space on your keyboard, it will write a +space exactly like it used to. If you hold down space and press the middle +button of your mouse, it will write "a" instead. If you just press the +middle button of your mouse it behaves like a regular middle mouse button. + +**Explanation** + +`hold_keys(space)` makes your key work exactly like if it was mapped to "space". +It will inject a key-down event if you press it, does nothing as long you +hold your key down, and injects a key-up event after releasing. +`set(foo, 1).set(foo, 0)` sets "foo" to 1 and then sets "foo" to 0. +`set` and `if_eq` work on shared memory, so all injections will see your +variables. Combine both to get a key that works like a normal key, but that also +works as a modifier for other keys of other devices. `if_eq($foo, 1, ..., ...)` +runs the first param if foo is 1, or the second one if foo is not 1. + + +## Scroll and Click on a Keyboard + +Seldom used PrintScreen, ScrollLock and Pause keys on keyboards with TKL (ten key +less) layout are easily accessible by the right hand thanks to the missing +numeric block, so they can be mapped to mouse scroll and click events: + +- Print: `wheel(up, 1)` +- Pause: `wheel(down, 1)` +- Scroll Lock: `BTN_LEFT` +- Menu: `BTN_RIGHT` +- F12: `KEY_LEFTCTRL + w` + +In contrast to libinput's `ScrollMethod` `button` which requires the scroll +button to belong to the same (mouse) device, clicking and scrolling events mapped +to a keyboard key can fully cooperate with events from a real mouse, e.g. +drag'n'drop by holding a (mapped) keyboard key and moving the cursor by mouse. + +Mapping the scrolling to a keyboard key is also useful for trackballs without +a scroll ring. + +In contrast to a real scroll wheel, holding a key which has mouse wheel event +mapped produces linear auto-repeat, without any acceleration. Using a PageDown +key for fast scrolling requires only a small adjustment of the right hand +position. + +## Scroll on a 3-Button Mouse + +Cheap 3-button mouse without a scroll wheel can scroll using the middle button: + +- Button MIDDLE: `wheel(down, 1)` + +## Click on Lower Buttons of Trackball + +Trackball with 4 buttons (e.g. Kensington Wireless Expert Mouse) with lower 2 +buttons by default assigned to middle and side button can be remapped to provide +left and right click on both the upper and lower pairs of buttons to avoid +readjusting a hand after moving the cursor down: + +- Button MIDDLE: BTN_LEFT +- Button SIDE: BTN_RIGHT + +## Scroll on Foot Pedals + +While Kinesis Savant Elite 2 foot pedals can be programmed to emit key press or +mouse click events, they cannot emit scroll events themselves. Using the pedals +for scrolling while standing at a standing desk is possible thanks to remapping: + +- Button LEFT: `wheel(up, 1)` +- Button RIGHT: `wheel(down, 1)` + +## Gamepads + +Joystick movements will be translated to mouse movements, while the second +joystick acts as a mouse wheel. You can swap this in the user interface. +All buttons, triggers and D-Pads can be mapped to keycodes and macros. + +The D-Pad can be mapped to W, A, S, D for example, to run around in games, +while the joystick turns the view (depending on the game). + +Tested with the XBOX 360 Gamepad. On Ubuntu, gamepads worked better in +Wayland than with X11. + +## Sequence of Keys with Modifiers + +Alt+TAB, Enter, Alt+TAB: + +``` +modify(Alt_L, key(tab)).wait(250). +key(KP_Enter).key(key_UP).wait(150). +modify(Alt_L, key(tab)) +``` + +## Home Row Mods + +See https://precondition.github.io/home-row-mods#home-row-mods-order + +- a: `mod_tap(a, Super_L)` +- s: `mod_tap(s, Alt_L)` +- d: `mod_tap(d, Shift_L)` +- f: `mod_tap(f, Control_L)` + +## Emitting Unavailable Symbols + +For example Japanese letters without overwriting any existing key +of your system-layout. Only works in X11. + +``` +xmodmap -pke > keyboard_layout +mousepad keyboard_layout & +``` + +Find a code that is not mapped to anything, for example `keycode 93 = `, +and map it like `keycode 93 = kana_YA`. See [this gist](https://gist.github.com/sezanzeb/e29bae637b8a799ccf2490b8537487df) +for available symbols. + +``` +xmodmap keyboard_layout +input-remapper-gtk +``` + +"kana_YA" should be in the dropdown of available symbols now. Map it +to a key and press apply. Now run + +``` +xmodmap keyboard_layout +``` + +again for the injection to use that xmodmap as well. It should be possible +to write "ヤ" now when pressing the key. diff --git a/readme/macros.md b/readme/macros.md new file mode 100644 index 0000000..4ed488b --- /dev/null +++ b/readme/macros.md @@ -0,0 +1,431 @@ +# Macros + +input-remapper comes with an optional custom macro language with support for cross-device +variables, conditions and named parameters. + +Syntax errors are shown in the UI on save. Each `key` function adds a short delay of 10ms +between key-down, key-up and at the end. See [usage.md](usage.md#configuration-files) +for more info. + +Macros are written into the same text field, that would usually contain the output symbol. + +Bear in mind that anti-cheat software might detect macros in games. + +### key + +> Acts like a pressed key. All names that are available in regular mappings can be used +> here. +> +> ```ts +> key(symbol: str) +> ``` +> +> Examples: +> +> ```ts +> key(symbol=KEY_A) +> key(b).key(space) +> ``` + +### key_down and key_up + +> Inject the press/down/1 and release/up/0 events individually with those macros. +> +> ```ts +> key_down(symbol: str) +> key_up(symbol: str) +> ``` +> +> Examples: +> +> ```ts +> key_down(KEY_A) +> key_up(KEY_B) +> ``` + +### wait + +> Waits in milliseconds before continuing the macro. If the max_time argument is +> provided, it will randomize the time between time and max_time. +> +> ```ts +> wait(time: int, max_time: int | None) +> ``` +> +> Examples: +> +> ```ts +> wait(time=100) +> wait(500) +> wait(10, 1000) +> ``` + +### repeat + +> Repeats the execution of the second parameter a few times +> +> ```ts +> repeat(repeats: int, macro: Macro) +> ``` +> +> Examples: +> +> ```ts +> repeat(1, key(KEY_A)) +> repeat(repeats=2, key(space)) +> ``` + +### toggle + +> Repeats the execution of the parameter until activated again +> +> ```ts +> toggle(macro: Macro) +> ``` +> +> Examples: +> +> ```ts +> toggle(key(KEY_A)) +> ``` + +### modify + +> Holds a modifier while executing the second parameter +> +> ```ts +> modify(modifier: str, macro: Macro) +> ``` +> +> Examples: +> +> ```ts +> modify(Control_L, key(a).key(x)) +> ``` + +### mod_tap + +> If an input is held down long enough, then it turns into a modifier for all keys +> that came and come afterwards. +> +> You can use this to create home row mods for example. +> +> Behaves similar to the Mod-Tap feature of QMK. +> +> ```ts +> mod_tap( +> default: str, +> modifier: str, +> tapping_term: int +> ) +> ``` +> +> Examples: +> +> ```ts +> mod_tap(a, Shift_L) +> mod_tap(s, Control_L, 300) +> ``` + +### hold_keys + +> Holds down all the provided symbols like a combination, and releases them when the +> actual key on your keyboard is released. +> +> An arbitrary number of symbols can be provided. +> +> When provided with a single key, it will behave just like a regular keyboard key. +> +> ```ts +> hold_keys(*symbols: str) +> ``` +> +> Examples: +> +> ```ts +> hold_keys(KEY_B) +> hold_keys(KEY_LEFTCTRL, KEY_A) +> hold_keys(Control_L, Alt_L, Delete) +> set(foo, KEY_A).hold_keys($foo) +> ``` + +### hold + +> Runs the child macro repeatedly as long as the input is pressed. +> +> ```ts +> hold(macro: Macro) +> ``` +> +> Examples: +> +> ```ts +> hold(key(space)) +> ``` + +### mouse + +> Moves the mouse cursor +> +> If the fractional `acceleration` value is provided then the cursor will accelerate +> from zero to a maximum speed of `speed`. +> +> ```ts +> mouse( +> direction: up | down | left | right, +> speed: int, +> acceleration: int | float +> ) +> ``` +> +> Examples: +> +> ```ts +> mouse(up, 1) +> mouse(left, 2) +> mouse(down, 10, 0.05) +> ``` + +### mouse_xy + +> Moves the mouse cursor in both x and y direction. +> +> If the fractional `acceleration` value is provided then the cursor will accelerate +> from zero to the maximum specified x and y speeds. +> +> ```ts +> mouse( +> x: int | float, +> y: int | float, +> acceleration: int | float +> ) +> ``` +> +> Examples: +> +> ```ts +> mouse_xy(x=10, y=20) +> mouse_xy(-5, -1, 0.01) +> mouse_xy(x=10, acceleration=0.05) +> ``` + +### wheel + +> Injects scroll wheel events +> +> ```ts +> wheel( +> direction: up | down | left | right, +> speed: int +> ) +> ``` +> +> Examples: +> +> ```ts +> mouse(up, 10) +> mouse(left, 20) +> ``` + +### event + +> Writes an event. Examples for `type`, `code` and `value` can be found via the +> `sudo evtest` command. Also check out [input-event-codes.h](https://github.com/torvalds/linux/blob/master/include/uapi/linux/input-event-codes.h). +> `EV_KEY` for keys, `EV_REL` for mouse movements, `EV_ABS` for gamepad events among +> others. +> +> ```ts +> event( +> type: str | int, +> code: str | int, +> value: int +> ) +> ``` +> +> Examples: +> +> ```ts +> event(EV_KEY, KEY_A, 1) +> event(EV_REL, REL_X, -10) +> event(2, 8, 1) +> ``` + +### set + +> Set a variable to a value. This variable and its value is available in all injection +> processes. +> +> Variables can be used in function arguments by adding a `$` in front of their name: +> `repeat($foo, key(KEY_A))` +> +> Their values are available for other injections/devices as well, so you can make them +> interact with each other. In other words, using `set` on a keyboard and `if_eq` with +> the previously used variable name on a mouse will work. +> +> ```ts +> set(variable: str, value: str | int) +> ``` +> +> Examples: +> +> ```ts +> set(foo, 1) +> set(foo, "qux") +> ``` + +### add + +> Adds a number fo a variable. +> +> ```ts +> add(variable: str, value: int) +> ``` +> +> Examples: +> +> ```ts +> set(a, 1).add(a, 2).if_eq($a, 3, key(x), key(y)) +> ``` + +### if_eq + +> Compare two values and run different macros depending on the outcome. +> +> ```ts +> if_eq( +> value_1: str | int, +> value_2: str | int, +> then: Macro | None, +> else: Macro | None +> ) +> ``` +> +> Examples: +> +> ```ts +> set(a, 1).if_eq($a, 1, key(KEY_A), key(KEY_B)) +> set(a, 1).set(b, 1).if_eq($a, $b, else=key(KEY_B).key(KEY_C)) +> set(a, "foo").if_eq("foo", $a, key(KEY_A)) +> set(a, 1).if_eq($a, 1, None, key(KEY_B)) +> ``` + +### if_capslock + +> Run the first macro if your capslock is on, otherwise the second. +> +> ```ts +> if_capslock( +> then: Macro | None, +> else: Macro | None +> ) +> ``` +> +> Examples: +> +> ```ts +> if_capslock( +> then=hold_keys(KEY_3), +> else=hold_keys(KEY_4) +> ) +> ``` + +### if_numlock + +> Run the first macro if your numlock is on, otherwise the second. +> +> ```ts +> if_numlock( +> then: Macro | None, +> else: Macro | None +> ) +> ``` +> +> Examples: +> +> ```ts +> if_numlock(hold_keys(KEY_3), hold_keys(KEY_4)) +> ``` + +### if_tap + +> If the key is tapped quickly, run the `then` macro, otherwise the +> second. The third param is the optional time in milliseconds and defaults to +> 300ms +> +> ```ts +> if_tap( +> then: Macro | None, +> else: Macro | None, +> timeout: int +> ) +> ``` +> +> Examples: +> +> ```ts +> if_tap(key(KEY_A), key(KEY_B), timeout=500) +> if_tap(then=key(KEY_A), else=key(KEY_B)) +> ``` + +### if_single + +> If the key that is mapped to the macro is pressed and released, run the `then` macro. +> +> If another key is pressed while the triggering key is held down, run the `else` macro. +> +> If a timeout number is provided, the macro will run `else` if no event arrives for +> more than the configured number in milliseconds. +> +> ```ts +> if_single( +> then: Macro | None, +> else: Macro | None, +> timeout: int | None +> ) +> ``` +> +> Examples: +> +> ```ts +> if_single(key(KEY_A), key(KEY_B)) +> if_single(None, key(KEY_B)) +> if_single(then=key(KEY_A), else=key(KEY_B)) +> if_single(key(KEY_A), key(KEY_B), timeout=1000) +> ``` + +### parallel + +> Run all provided macros in parallel. +> +> ```ts +> parallel(*macros: Macro) +> ``` +> +> Examples: +> +> ```ts +> parallel( +> mouse(up, 10), +> hold_keys(a), +> wheel(down, 10) +> ) +> ``` + +## Syntax + +Multiple functions are chained using `.`. + +Unlike other programming languages, `qux(bar())` would not run `bar` and then +`qux`. Instead, `cux` can decide to run `bar` during runtime depending on various +other factors. Like `repeat` is running its parameter multiple times. + +Whitespaces, newlines and tabs don't have any meaning and are removed when the macro +gets compiled, unless you wrap your strings in "quotes". + +Similar to python, arguments can be either positional or keyword arguments. +`key(symbol=KEY_A)` is the same as `key(KEY_A)`. + +Using `$` resolves a variable during runtime. For example `set(a, $1)` and +`if_eq($a, 1, key(KEY_A), key(KEY_B))`. + +Comments can be written with '#', like `key(KEY_A) # write an "a"` diff --git a/readme/plus.png b/readme/plus.png new file mode 100644 index 0000000000000000000000000000000000000000..bc0ca483f3214062051458ca330147e61750af61 GIT binary patch literal 11762 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�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 D*g`VY literal 0 HcmV?d00001 diff --git a/readme/pylint.svg b/readme/pylint.svg new file mode 100644 index 0000000..48c01e8 --- /dev/null +++ b/readme/pylint.svg @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + pylint + pylint + + + 9.21 + 9.21 + + diff --git a/readme/screenshot.png b/readme/screenshot.png new file mode 100644 index 0000000000000000000000000000000000000000..a8a75da8b699d9b59cd6ba0e1443756b390c68a1 GIT binary patch literal 58151 zcmeFZWmuG5)IW-dNQj`+L!)#{4P6S--7V7H-J)V3T|;+=4BaS_L-)|qjdTq0zX$bw zpYWUy=eo{yzMK!v<#5N|d#$xs{MK*JdnE-)EDT}{BqSs(X(@3PBqWqBBqZcgG*sX( z4lMEONJzKrET277vNSeELZXjwjcAm8s{B{Gzm;(0vqNIQIuIjicHhjvPL+NS%^7KiEbKk-K?CLAd zq;Z!*Thzm+gCTEU&1RoMsu%GI6}-SXZ0XwVHq;Mume1=t(l_))kF0!4*EbZhS+Gcx zzeXh(5xg-a{z@Y8Y%kQIIaW67b=d36oO|>>#fK)J4DrmZFVZJMBvxDUy}Zh?l5AeE zm~VW|{3mc;S zG=0Y~E-6P?qI86N?vY2;{6#?t&B^>r5>)a}ufeA=dXQxBy@q5lMbdM-bnEQc(K)_c zY@DfRLzmm|yH6gU2o;LlNwT=-b&bOVC;;6-O4}I;>Haswzgsa(_eqeDD3PSa#ne0u zwr1|WP-`Oy_#Q=$MtwL?C=-$4f*_5{7R`WU`V28KLSyE;32gGr?k4}`pYPjFy!M|q+Oju3So%AD%?(y`G`1trl z%0D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

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

+ +

+ +## UI Shortcuts + +- `ctrl` + `del` stops the injection (only works while the gui is in focus) +- `ctrl` + `q` closes the application +- `ctrl` + `r` refreshes the device list + +## Key Names + +Check the autocompletion of the GUI for possible values. You can also +obtain a complete list of possiblities using `input-remapper-control --symbol-names`. + +Input-remapper only recognizes symbol names, but not the symbols themselves. So for +example, input-remapper might (depending on the system layout) know what a `minus` is, but +it doesn't know `-`. + +Key names that start with `KEY_` are keyboard layout independent constants that might +not result in the expected output. For example using `KEY_Y` would result in "z" +if the layout of the environment is set to german. Using `y` on the other hand would +correctly result in "y" to be written. + +It is also possible to map a key to `disable` to stop it from doing anything. + +## Limitations + +**If your fingers can't type it on your keyboard, input-remapper can't inject it.** + +The available symbols depend on the environments keyboard layout, and only those that +don't require a combination to be pressed can be used without workarounds (so most +special characters need some extra steps to use them). Furthermore, if your configured +keyboard layout doesn't support the special character at all (not even via a +combination), then it also won't be possible for input-remapper to map that character at +all. + +For example, mapping a key to an exclamation mark is not possible if the keyboard +layout is set to german. However, it is possible to mimic the combination that would +be required to write it, by writing `Shift_L + 1` into the mapping. + +This is because input-remapper creates a new virtual keyboard and injects numeric keycodes, +and it won't be able to inject anything a usb keyboard wouldn't been able to. This has +the benefit of being compatible to all display servers, but means the environment will +ultimately decide which character to write. + +## Analog Axis + +It is possible to map analog inputs to analog outputs. E.g. use a gamepad as a mouse. +For this you need to create a mapping and record the input axis. Then click on +`Advanced` and select `Use as Analog`. Make sure to select a target +which supports analog axis and switch to the `Analog Axis` tab. +There you can select an output axis and use the different sliders to configure the +sensitivity, non-linearity and other parameters as you like. + +It is also possible to use an analog output with an input combination. +This will result in the analog axis to be only injected if the combination is pressed + +## Wheels + +When mapping wheels, you need to be aware that there are both `WHEEL` and `WHEEL_HI_RES` +events. This can cause your wheel to scroll, despite being mapped to something. +By fiddling around with the advanced settings when editing one of your inputs, you can +map the "Hi Res" inputs to `disable`. + +# External tools + +Repositories listed here are made by input-remappers users. Feel free to extend. Beware, +that I can't review their code, so use them at your own risk (just like everything). + +- input-remapper-xautopresets: https://github.com/DreadPirateLynx/input-remapper-xautopresets + +# Advanced + +## Configuration Files + +If you don't have a graphical user interface, you'll need to edit the +configuration files. All configuration files need to be valid json files, otherwise the +parser refuses to work. + +The default configuration is stored at `~/.config/input-remapper-2/config.json`, +which doesn't include any mappings, but rather other parameters that +are interesting for injections. The config might look something like this: + +```json +{ + "version": "2.2.1", + "autoload": { + "USB Optical Mouse": "preset name" + } +} +``` + +`preset name` refers to `~/.config/input-remapper/presets/device name/preset name.json`. +The device name can be found with `sudo input-remapper-control --list-devices`. + +### Preset + +The preset files are a collection of mappings. +Here is an example configuration for preset "a" for the "gamepad" device: +`~/.config/input-remapper-2/presets/gamepad/a.json` + +```json +[ + { + "input_combination": [ + {"type": 1, "code": 307} + ], + "target_uinput": "keyboard", + "output_symbol": "key(2).key(3)", + "macro_key_sleep_ms": 100 + }, + { + "input_combination": [ + {"type": 1, "code": 315, "origin_hash": "07f543a6d19f00769e7300c2b1033b7a"}, + {"type": 3, "code": 1, "analog_threshold": 10} + ], + "target_uinput": "keyboard", + "output_symbol": "1" + }, + { + "input_combination": [ + {"type": 3, "code": 1} + ], + "target_uinput": "mouse", + "output_type": 2, + "output_code": 1, + "gain": 0.5 + } +] +``` + +This preset consists of three mappings. + + * The first maps the key event with code 307 to a macro and sets the time between + injected events of macros to 100 ms. The macro injects its events to the virtual keyboard. + * The second mapping is a combination of a key event with the code 315 and a + analog input of the axis 1 (y-Axis). + * The third maps the y-Axis of a joystick to the y-Axis on the virtual mouse. + +### Mapping + +As shown above, the mapping is part of the preset. It consists of the input-combination, +which is a list of input-configurations and the mapping parameters. + +``` +{ + "input_combination": [ + , + + ] + : , + : +} +``` + +#### Input Combination and Configuration + +The input-combination is a list of one or more input configurations. To trigger a +mapping, all input configurations must trigger. + +A input configuration is a dictionary with some or all of the following parameters: + +| Parameter | Default | Type | Description | +|------------------|---------|------------------------|---------------------------------------------------------------------| +| type | - | int | Input Event Type | +| code | - | int | Input Evnet Code | +| origin_hash | None | hex (string formatted) | A unique identifier for the device which emits the described event. | +| analog_threshold | None | int | The threshold above which a input axis triggers the mapping. | + +##### type, code + +The `type` and `code` parameters are always needed. Use the program `evtest` to find +Available types and codes. See also the [evdev documentation](https://www.kernel.org/doc/html/latest/input/event-codes.html#input-event-codes) + +##### origin_hash + +The origin_hash is an internally computed hash. It is used associate the input with a +specific `/dev/input/eventXX` device. This is useful when a single pyhsical device +creates multiple `/dev/input/eventXX` devices wihth similar capabilities. +See also: [Issue#435](https://github.com/sezanzeb/input-remapper/issues/435) + +##### analog_threshold + +Setting the `analog_threshold` to zero or omitting it means that the input will be +mapped to an axis. There can only be one axis input with a threshold of 0 in a mapping. +If the `type` is 1 (EV_KEY) the `analog_threshold` has no effect. + +The `analog_threshold` is needend when the input is a analog axis which should be +treated as a key input. If the event type is `3 (EV_ABS)` (as in: map a joystick axis to +a key or macro) the threshold can be between `-100 [%]` and `100 [%]`. The mapping will +be triggered once the joystick reaches the position described by the value. + +If the event type is `2 (EV_REL)` (as in: map a relative axis (e.g. mouse wheel) to a +key or macro) the threshold can be anything. The mapping will be triggered once the +speed and direction of the axis is higher than described by the threshold. + +#### Mapping Parameters + +The following table contains all possible parameters and their default values: + +| Parameter | Default | Type | Description | +|--------------------------|---------|-----------------|-------------------------------------------------------------------------------------------------------------------------| +| input_combination | | list | see [above](#input-combination-and-configuration) | +| target_uinput | | string | The UInput to which the mapped event will be sent | +| output_symbol | | string | The symbol or macro string if applicable | +| output_type | | int | The event type of the mapped event | +| output_code | | int | The event code of the mapped event | +| release_combination_keys | true | bool | If release events will be sent to the forwarded device as soon as a combination triggers see also #229 | +| **Macro settings** | | | | +| macro_key_sleep_ms | 0 | positive int | | +| **Axis settings** | | | | +| deadzone | 0.1 | float ∈ (0, 1) | The deadzone of the input axis | +| gain | 1.0 | float | Scale factor when mapping an axis to an axis | +| expo | 0 | float ∈ (-1, 1) | Non liniarity factor see also [GeoGebra](https://www.geogebra.org/calculator/mkdqueky) | +| **EV_REL output** | | | | +| rel_rate | 60 | positive int | The frequency `[Hz]` at which `EV_REL` events get generated (also effects mouse macro) | +| **EV_REL as input** | | | | +| rel_to_abs_input_cutoff | 2 | positive float | The value relative to a predefined base-speed, at which `EV_REL` input (cursor and wheel) is considered at its maximum. | +| release_timeout | 0.05 | positive float | The time `[s]` until a relative axis is considered stationary if no new events arrive | + + +## CLI + +**input-remapper-control** + +`--command` requires the service to be running. You can start it via +`systemctl start input-remapper` or `sudo input-remapper-service` if it isn't already +running (or without sudo if your user has the appropriate permissions). + +Examples: + +| Description | Command | +|---------------------------------------------------------------------------------------------------------|-------------------------------------------------------------------------------------------| +| Load all configured presets for all devices | `input-remapper-control --command autoload` | +| If you are running as root user, provide information about the whereabouts of the input-remapper config | `input-remapper-control --command autoload --config-dir "~/.config/input-remapper/"` | +| List available device names for the `--device` parameter | `sudo input-remapper-control --list-devices` | +| Stop injecting | `input-remapper-control --command stop --device "Razer Razer Naga Trinity"` | +| Load `~/.config/input-remapper/presets/Razer Razer Naga Trinity/a.json` | `input-remapper-control --command start --device "Razer Razer Naga Trinity" --preset "a"` | +| Loads the configured preset for whatever device is using this /dev path | `/bin/input-remapper-control --command autoload --device /dev/input/event5` | +| Make the input-remapper-service process exit | `/bin/input-remapper-control --command quit` | + +**systemctl** + +Stopping the service will stop all ongoing injections + +```bash +sudo systemctl stop input-remapper +sudo systemctl start input-remapper +systemctl status input-remapper +``` + +## Testing your Installation + +The following commands can be used to make sure it works: + +```bash +sudo input-remapper-service & +input-remapper-control --command hello +``` + +should print `Daemon answered with "hello"`. And + +```bash +sudo input-remapper-control --list-devices +``` + +should print `Found "...", ...`. If anything looks wrong, feel free to [create +an issue](https://github.com/sezanzeb/input-remapper/issues/new). + +## Migrating beta configs to version 2 + +By default, Input Remapper will not migrate configurations from the beta. +If you want to use those you will need to copy them manually. + +```bash +rm ~/.config/input-remapper-2 -r +cp ~/.config/input-remapper/beta_1.6.0-beta ~/.config/input-remapper-2 -r +``` + +Then start input-remapper diff --git a/readme/usage_1.png b/readme/usage_1.png new file mode 100644 index 0000000000000000000000000000000000000000..cceb4e82d653d6edb1fc847147a6f57e9e4058a7 GIT binary patch literal 18201 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

IGcGGL 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 literal 0 HcmV?d00001 diff --git a/readme/usage_2.png b/readme/usage_2.png new file mode 100644 index 0000000000000000000000000000000000000000..4a0605ec9af1de96ed0ba72819df6d0cdede55e9 GIT binary patch literal 14134 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

2*ZOtfLrlI-68^D(*OFD{schA zwGPK#oHC`)@Vkv-WfZ6Z%>2D7P=$(-uZz$6nt1Rx#zl+}E`!=X`VmE1>|H&H#YSo` zA^^<-$@XR=JuYW;M`>;nSQ`Ju2z-;}LO+IIBe8di!I* z+X)58WN*A3eGRZhnh=ZpZElVQ%=v*bGV6w^7JLB6>raI+{wEFljRG)Ql#Y^pb-SJq zAaAX&8{k8}zz(o+U#;?sPGO%j8aTtP|c z;0-=2Kpp$h2qd_sx~E6dZ9g$kQ^NT8&E<#c{D3~7cR(8hoMVIx2s78H5lI4`w?J)6 zh9Q=u`#UEpo~grwRaVDE6h#l^Vus@sD!GBA!xmd@2^VBJVC1>k6R1rC<9>%vQ3EUI z%`SwwfW%ZSe0p2(@&QkIK4wVrAH@~Oab$o|_0ukH%ZvtK1ehH+?rjQDK^c`xCGgg$ zY=99|1<2pu8Z{qfR5BLHTcaibM$Lyte!n&92p~@-5<+)tRDfpKtxBFdkFl2ho<{0-JA7X^Z|LcuKTz3UKKSf{-5pt k&*}f`g8tt-|25q}r2GWEss0)8*H~y^SrwVm7p9^A3jiuvumAu6 literal 0 HcmV?d00001 diff --git a/scripts/badges.sh b/scripts/badges.sh new file mode 100755 index 0000000..7c960ea --- /dev/null +++ b/scripts/badges.sh @@ -0,0 +1,32 @@ +#!/usr/bin/env bash + +# pip install git+https://github.com/jongracecox/anybadge + +coverage_badge() { + python3 -m coverage erase + python3 -m coverage run -m unittest discover -s ./tests/ + python3 -m coverage combine + rating=$(python3 -m coverage report | tail -n 1 | ack "\d+%" -o | ack "\d+" -o) + echo "coverage rating: $rating" + rm readme/coverage.svg + python3 -m anybadge -l coverage -v $rating -f readme/coverage.svg coverage + + python3 -m coverage report -m + echo "coverage badge created" +} + +pylint_badge() { + pylint_output=$(python3 -m pylint inputremapper --extension-pkg-whitelist=evdev) + rating=$(echo $pylint_output | grep -Po "rated at .+?/" | grep -Po "\d+.\d+") + rm readme/pylint.svg + python3 -m anybadge -l pylint -v $rating -f readme/pylint.svg pylint + + echo "pylint rating: $rating" + echo "pylint badge created" +} + +pylint_badge & +coverage_badge & + +# wait for all badges to be created +wait diff --git a/scripts/build-deb.sh b/scripts/build-deb.sh new file mode 100755 index 0000000..6ff94f4 --- /dev/null +++ b/scripts/build-deb.sh @@ -0,0 +1,17 @@ +#!/usr/bin/env bash + +build_deb() { + # https://www.devdungeon.com/content/debian-package-tutorial-dpkgdeb + # that was really easy actually + rm build -r + mkdir dist | true + python3 -m install --root build/deb + + find ./build -name "*.pyc" -delete + find ./build -empty -delete + + cp ./DEBIAN build/deb -r + dpkg-deb -Z gzip -b build/deb dist/input-remapper-2.2.1.deb +} + +build_deb diff --git a/scripts/ci-install-deps.sh b/scripts/ci-install-deps.sh new file mode 100755 index 0000000..b134b72 --- /dev/null +++ b/scripts/ci-install-deps.sh @@ -0,0 +1,15 @@ +#!/usr/bin/env bash +# Called from multiple CI pipelines in .github/workflows +set -xeuo pipefail + +sudo apt update + +# native deps +# gettext required to generate translations, others are python deps +sudo apt-get install -y gettext python3-evdev python3-dasbus python3-pydantic python3-gi gir1.2-gtk-3.0 gir1.2-gtksource-4 + +# ensure pip and setuptools/wheel up to date so can install all pip modules +sudo apt-get install python3-pip python3-wheel + +# install test deps which aren't in pyproject.toml +python -m pip install psutil pylint-pydantic diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/__main__.py b/tests/__main__.py new file mode 100644 index 0000000..fba0404 --- /dev/null +++ b/tests/__main__.py @@ -0,0 +1,4 @@ +import unittest + +if __name__ == "__main__": + unittest.main() diff --git a/tests/lib/__init__.py b/tests/lib/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/lib/cleanup.py b/tests/lib/cleanup.py new file mode 100644 index 0000000..1edd6af --- /dev/null +++ b/tests/lib/cleanup.py @@ -0,0 +1,172 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + +from __future__ import annotations + +import asyncio +import copy +import os +import shutil +import time +from pickle import UnpicklingError + +import psutil + +from tests.lib.constants import EVENT_READ_TIMEOUT +from tests.lib.fixtures import fixtures +from tests.lib.logger import logger +from tests.lib.patches import uinputs +from tests.lib.pipes import ( + uinput_write_history_pipe, + uinput_write_history, + pending_events, + setup_pipe, +) +from tests.lib.tmp import tmp + +# TODO on it. You don't need a framework for this by the way: +# don't import anything from input_remapper gloablly here, because some files execute +# code when imported, which can screw up patches. I wish we had a dependency injection +# framework that patches together the dependencies during runtime... + + +environ_copy = copy.deepcopy(os.environ) + + +def join_children(): + """Wait for child processes to exit. Stop them if it takes too long.""" + this = psutil.Process(os.getpid()) + + i = 0 + time.sleep(EVENT_READ_TIMEOUT) + children = this.children(recursive=True) + while len([c for c in children if c.status() != "zombie"]) > 0: + for child in children: + if i > 10: + child.kill() + logger.info("Killed pid %s because it didn't finish in time", child.pid) + + children = this.children(recursive=True) + time.sleep(EVENT_READ_TIMEOUT) + i += 1 + + +def clear_write_history(): + """Empty the history in preparation for the next test.""" + while len(uinput_write_history) > 0: + uinput_write_history.pop() + while uinput_write_history_pipe[0].poll(): + uinput_write_history_pipe[0].recv() + + +def quick_cleanup(log=True): + """Reset the applications state.""" + # TODO no: + # Reminder: before patches are applied in test.py, no inputremapper module + # may be imported. So tests.lib imports them just-in-time in functions instead. + from inputremapper.injection.macros.macro import macro_variables + from inputremapper.configs.keyboard_layout import keyboard_layout + from inputremapper.gui.utils import debounce_manager + + if log: + logger.info("Quick cleanup...") + + debounce_manager.stop_all() + + for device in list(pending_events.keys()): + try: + while pending_events[device][1].poll(): + pending_events[device][1].recv() + except (UnpicklingError, EOFError): + pass + + # setup new pipes for the next test + pending_events[device][1].close() + pending_events[device][0].close() + del pending_events[device] + setup_pipe(device) + + try: + if asyncio.get_event_loop().is_running(): + for task in asyncio.all_tasks(): + task.cancel() + except RuntimeError: + # happens when the event loop disappears for magical reasons + # create a fresh event loop + asyncio.set_event_loop(asyncio.new_event_loop()) + + if macro_variables.process is not None and not macro_variables.process.is_alive(): + # nothing should stop the process during runtime, if it has been started by + # the injector once + raise AssertionError("the SharedDict manager is not running anymore") + + if macro_variables.process is not None: + macro_variables._stop() + + join_children() + + macro_variables.start() + + if os.path.exists(tmp): + shutil.rmtree(tmp) + + keyboard_layout.populate() + + clear_write_history() + + for name in list(uinputs.keys()): + del uinputs[name] + + # for device in list(active_macros.keys()): + # del active_macros[device] + # for device in list(unreleased.keys()): + # del unreleased[device] + fixtures.reset() + os.environ.update(environ_copy) + for device in list(os.environ.keys()): + if device not in environ_copy: + del os.environ[device] + + for _, pipe in pending_events.values(): + assert not pipe.poll() + + assert macro_variables.is_alive(1) + + if log: + logger.info("Quick cleanup done") + + +def cleanup(): + """Reset the applications state. + + Using this is slower, usually quick_cleanup() is sufficient. + """ + from inputremapper.groups import groups + + logger.info("Cleanup...") + + os.system("pkill -f input-remapper-service") + os.system("pkill -f input-remapper-control") + time.sleep(0.05) + + quick_cleanup(log=False) + groups.refresh() + + logger.info("Cleanup done") diff --git a/tests/lib/constants.py b/tests/lib/constants.py new file mode 100644 index 0000000..e970cfc --- /dev/null +++ b/tests/lib/constants.py @@ -0,0 +1,28 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + +# give tests some time to test stuff while the process +# is still running +EVENT_READ_TIMEOUT = 0.01 + +# based on experience how much time passes at most until +# the reader-service starts receiving previously pushed events after a +# call to start_reading +START_READING_DELAY = 0.05 diff --git a/tests/lib/fixture_pipes.py b/tests/lib/fixture_pipes.py new file mode 100644 index 0000000..359aa13 --- /dev/null +++ b/tests/lib/fixture_pipes.py @@ -0,0 +1,36 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + +from __future__ import annotations + +from tests.lib.fixtures import fixtures +from tests.lib.pipes import setup_pipe, close_pipe + + +def create_fixture_pipes(): + # make sure those pipes exist before any process (the reader-service) gets forked, + # so that events can be pushed after the fork. + for _fixture in fixtures: + setup_pipe(_fixture) + + +def remove_fixture_pipes(): + for _fixture in fixtures: + close_pipe(_fixture) diff --git a/tests/lib/fixtures.py b/tests/lib/fixtures.py new file mode 100644 index 0000000..19bc9aa --- /dev/null +++ b/tests/lib/fixtures.py @@ -0,0 +1,420 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + +from __future__ import annotations + +import dataclasses +import json +import time +from hashlib import md5 +from typing import Dict, Optional + +import evdev + +from inputremapper.configs.input_config import InputCombination +from inputremapper.configs.mapping import Mapping +from inputremapper.configs.paths import PathUtils +from inputremapper.configs.preset import Preset +from tests.lib.logger import logger + +# input-remapper is only interested in devices that have EV_KEY, add some +# random other stuff to test that they are ignored. +phys_foo = "usb-0000:03:00.0-1/input2" +info_foo = evdev.device.DeviceInfo(1, 1, 1, 1) + +keyboard_keys = sorted(evdev.ecodes.keys.keys())[:255] + + +@dataclasses.dataclass(frozen=True) +class Fixture: + path: str + capabilities: Dict = dataclasses.field(default_factory=dict) + name: str = "unset" + info: evdev.device.DeviceInfo = evdev.device.DeviceInfo(None, None, None, None) + phys: str = "unset" + group_key: Optional[str] = None + + # for joysticks and such + min_abs: int = -(2**15) + max_abs: int = 2**15 + + # uniq is typically empty + uniq: str = "" + + def __hash__(self): + return hash(self.path) + + def get_device_hash(self): + s = str(self.capabilities) + self.name + device_hash = md5(s.encode()).hexdigest() + logger.info( + 'Hash for fixture "%s" "%s": "%s"', + self.path, + self.name, + device_hash, + ) + return device_hash + + +class _Fixtures: + """contains all predefined Fixtures. + Can be extended with new Fixtures during runtime""" + + dev_input_event1 = Fixture( + capabilities={ + evdev.ecodes.EV_KEY: [evdev.ecodes.KEY_A], + }, + phys="usb-0000:03:00.0-0/input1", + info=info_foo, + name="Foo Device", + path="/dev/input/event1", + ) + # Another "Foo Device", which will get an incremented key. + # If possible write tests using this one, because name != key here and + # that would be important to test as well. Otherwise, the tests can't + # see if the groups correct attribute is used in functions and paths. + dev_input_event11 = Fixture( + capabilities={ + evdev.ecodes.EV_KEY: [ + evdev.ecodes.BTN_LEFT, + evdev.ecodes.BTN_TOOL_DOUBLETAP, + ], + evdev.ecodes.EV_REL: [ + evdev.ecodes.REL_X, + evdev.ecodes.REL_Y, + evdev.ecodes.REL_WHEEL, + evdev.ecodes.REL_HWHEEL, + ], + }, + phys=f"{phys_foo}/input2", + info=info_foo, + name="Foo Device foo", + group_key="Foo Device 2", # expected key + path="/dev/input/event11", + ) + dev_input_event10 = Fixture( + capabilities={evdev.ecodes.EV_KEY: keyboard_keys}, + phys=f"{phys_foo}/input3", + info=info_foo, + name="Foo Device", + group_key="Foo Device 2", + path="/dev/input/event10", + ) + dev_input_event13 = Fixture( + capabilities={evdev.ecodes.EV_KEY: [], evdev.ecodes.EV_SYN: []}, + phys=f"{phys_foo}/input1", + info=info_foo, + name="Foo Device", + group_key="Foo Device 2", + path="/dev/input/event13", + ) + dev_input_event14 = Fixture( + capabilities={evdev.ecodes.EV_SYN: []}, + phys=f"{phys_foo}/input0", + info=info_foo, + name="Foo Device qux", + group_key="Foo Device 2", + path="/dev/input/event14", + ) + dev_input_event15 = Fixture( + capabilities={ + evdev.ecodes.EV_SYN: [], + evdev.ecodes.EV_ABS: [ + evdev.ecodes.ABS_X, + evdev.ecodes.ABS_Y, + evdev.ecodes.ABS_RX, + evdev.ecodes.ABS_RY, + evdev.ecodes.ABS_Z, + evdev.ecodes.ABS_RZ, + evdev.ecodes.ABS_HAT0X, + evdev.ecodes.ABS_HAT0Y, + ], + evdev.ecodes.EV_KEY: [evdev.ecodes.BTN_A], + }, + phys=f"{phys_foo}/input4", + info=info_foo, + name="Foo Device bar", + group_key="Foo Device 2", + path="/dev/input/event15", + ) + # Bar Device + dev_input_event20 = Fixture( + capabilities={evdev.ecodes.EV_KEY: keyboard_keys}, + phys="usb-0000:03:00.0-2/input1", + info=evdev.device.DeviceInfo(2, 1, 2, 1), + name="Bar Device", + path="/dev/input/event20", + ) + dev_input_event30 = Fixture( + capabilities={ + evdev.ecodes.EV_SYN: [], + evdev.ecodes.EV_ABS: [ + evdev.ecodes.ABS_X, + evdev.ecodes.ABS_Y, + evdev.ecodes.ABS_RX, + evdev.ecodes.ABS_RY, + evdev.ecodes.ABS_Z, + evdev.ecodes.ABS_RZ, + evdev.ecodes.ABS_HAT0X, + evdev.ecodes.ABS_HAT0Y, + ], + evdev.ecodes.EV_KEY: [ + evdev.ecodes.BTN_A, + evdev.ecodes.BTN_B, + evdev.ecodes.BTN_X, + evdev.ecodes.BTN_Y, + ], + }, + phys="", # this is empty sometimes + info=evdev.device.DeviceInfo(3, 1, 3, 1), + name="gamepad", + path="/dev/input/event30", + ) + # device that is completely ignored + dev_input_event31 = Fixture( + capabilities={evdev.ecodes.EV_SYN: []}, + phys="usb-0000:03:00.0-4/input1", + info=evdev.device.DeviceInfo(4, 1, 4, 1), + name="Power Button", + path="/dev/input/event31", + ) + # input-remapper devices are not displayed in the ui, some instance + # of input-remapper started injecting, apparently. + dev_input_event40 = Fixture( + capabilities={evdev.ecodes.EV_KEY: keyboard_keys}, + phys="input-remapper/input1", + info=evdev.device.DeviceInfo(5, 1, 5, 1), + name="input-remapper Bar Device", + path="/dev/input/event40", + ) + # denylisted + dev_input_event51 = Fixture( + capabilities={evdev.ecodes.EV_KEY: keyboard_keys}, + phys="usb-0000:03:00.0-5/input1", + info=evdev.device.DeviceInfo(6, 1, 6, 1), + name="YuBiCofooYuBiKeYbar", + path="/dev/input/event51", + ) + # name requires sanitation + dev_input_event52 = Fixture( + capabilities={evdev.ecodes.EV_KEY: keyboard_keys}, + phys="usb-0000:03:00.0-3/input1", + info=evdev.device.DeviceInfo(2, 1, 2, 1), + name="Qux/[Device]?", + path="/dev/input/event52", + ) + dev_input_event32 = Fixture( + capabilities={ + evdev.ecodes.EV_SYN: [], + evdev.ecodes.EV_ABS: [ + evdev.ecodes.ABS_X, + evdev.ecodes.ABS_Y, + evdev.ecodes.ABS_RX, + evdev.ecodes.ABS_RY, + evdev.ecodes.ABS_Z, + evdev.ecodes.ABS_RZ, + evdev.ecodes.ABS_HAT0X, + evdev.ecodes.ABS_HAT0Y, + ], + evdev.ecodes.EV_KEY: [ + evdev.ecodes.BTN_A, + evdev.ecodes.BTN_B, + evdev.ecodes.BTN_X, + evdev.ecodes.BTN_Y, + ], + }, + phys="", # this is empty sometimes + info=evdev.device.DeviceInfo(3, 1, 3, 1), + name="gamepad abs 0 to 256", + path="/dev/input/event32", + min_abs=0, + max_abs=256, + ) + + def __init__(self): + self.reset() + + def reset(self) -> None: + self._iter = [ + self.dev_input_event1, + self.dev_input_event11, + self.dev_input_event10, + self.dev_input_event13, + self.dev_input_event14, + self.dev_input_event15, + self.dev_input_event20, + self.dev_input_event30, + self.dev_input_event31, + self.dev_input_event40, + self.dev_input_event51, + self.dev_input_event52, + self.dev_input_event32, + ] + + def get_fixture(self, path: str) -> Fixture: + """get a Fixture by it's unique /dev/input/eventX path""" + for fixture in self._iter: + if fixture.path == path: + return fixture + + raise KeyError(f"Could not find fixture with path {path}") + + def add_fixture(self, value: Fixture | dict) -> None: + if isinstance(value, Fixture): + value = value.__dict__ + + key = value["path"] + if isinstance(value, Fixture): + self._iter.append(value) + elif isinstance(value, dict): + self._iter.append(Fixture(**value)) + + def remove_fixture(self, path: str) -> None: + index = 0 + for i, fixture in enumerate(self._iter): + if fixture.path == path: + index = i + + del self._iter[index] + + def __iter__(self): + return iter(self._iter) + + def get_paths(self): + """Get a list of all available device paths.""" + paths = [] + for fixture in self._iter: + paths.append(fixture.path) + + return paths + + def get(self, item) -> Optional[Fixture]: + try: + return self.get_fixture(item) + except KeyError: + return None + + @property + def foo_device_1_1(self): + return self.get_fixture("/dev/input/event1") + + @property + def foo_device_2_mouse(self): + return self.get_fixture("/dev/input/event11") + + @property + def foo_device_2_keyboard(self): + return self.get_fixture("/dev/input/event10") + + @property + def foo_device_2_13(self): + return self.get_fixture("/dev/input/event13") + + @property + def foo_device_2_qux(self): + return self.get_fixture("/dev/input/event14") + + @property + def foo_device_2_gamepad(self): + return self.get_fixture("/dev/input/event15") + + @property + def bar_device(self): + return self.get_fixture("/dev/input/event20") + + @property + def gamepad(self): + return self.get_fixture("/dev/input/event30") + + @property + def power_button(self): + return self.get_fixture("/dev/input/event31") + + @property + def input_remapper_bar_device(self): + return self.get_fixture("/dev/input/event40") + + @property + def YuBiCofooYuBiKeYbar(self): + return self.get_fixture("/dev/input/event51") + + @property + def QuxSlashDeviceQuestionmark(self): + return self.get_fixture("/dev/input/event52") + + @property + def gamepad_abs_0_to_256(self): + return self.get_fixture("/dev/input/event32") + + +fixtures = _Fixtures() + + +def new_event(type, code, value, timestamp): + """Create a new InputEvent. + + Handy because of the annoying sec and usec arguments of the regular + evdev.InputEvent constructor. + + Prefer using `InputEvent.key()`, `InputEvent.abs()`, `InputEvent.rel()` or just + `InputEvent(0, 0, 1234, 2345, 3456)`. + """ + from inputremapper.input_event import InputEvent + + if timestamp is None: + timestamp = time.time() + + sec = int(timestamp) + usec = timestamp % 1 * 1000000 + event = InputEvent(sec, usec, type, code, value) + return event + + +def prepare_presets(): + """prepare a few presets for use in tests + "Foo Device 2/preset3" is the newest and "Foo Device 2/preset2" is set to autoload + """ + preset1 = Preset(PathUtils.get_preset_path("Foo Device", "preset1")) + preset1.add( + Mapping.from_combination( + InputCombination.from_tuples((1, 1)), + output_symbol="b", + ) + ) + preset1.add(Mapping.from_combination(InputCombination.from_tuples((1, 2)))) + preset1.save() + + time.sleep(0.1) + preset2 = Preset(PathUtils.get_preset_path("Foo Device", "preset2")) + preset2.add(Mapping.from_combination(InputCombination.from_tuples((1, 3)))) + preset2.add(Mapping.from_combination(InputCombination.from_tuples((1, 4)))) + preset2.save() + + # make sure the timestamp of preset 3 is the newest, + # so that it will be automatically loaded by the GUI + time.sleep(0.1) + preset3 = Preset(PathUtils.get_preset_path("Foo Device", "preset3")) + preset3.add(Mapping.from_combination(InputCombination.from_tuples((1, 5)))) + preset3.save() + + with open(PathUtils.get_config_path("config.json"), "w") as file: + json.dump({"autoload": {"Foo Device 2": "preset2"}}, file, indent=4) + + return preset1, preset2, preset3 diff --git a/tests/lib/is_service_running.py b/tests/lib/is_service_running.py new file mode 100644 index 0000000..e969e46 --- /dev/null +++ b/tests/lib/is_service_running.py @@ -0,0 +1,32 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + +from __future__ import annotations + +import subprocess + + +def is_service_running(): + """Check if the daemon is running.""" + try: + subprocess.check_output(["pgrep", "-f", "input-remapper-service"]) + return True + except subprocess.CalledProcessError: + return False diff --git a/tests/lib/logger.py b/tests/lib/logger.py new file mode 100644 index 0000000..9e542a4 --- /dev/null +++ b/tests/lib/logger.py @@ -0,0 +1,54 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + +from __future__ import annotations + +import sys +import traceback +import tracemalloc +import warnings +import logging + + +tracemalloc.start() + +logger = logging.getLogger("input-remapper-test") +handler = logging.StreamHandler() +handler.setFormatter(logging.Formatter("\033[90mTest: %(message)s\033[0m")) +logger.addHandler(handler) +logger.setLevel(logging.INFO) + + +def update_inputremapper_verbosity(): + from inputremapper.logging.logger import logger + + logger.update_verbosity(True) + + +def warn_with_traceback(message, category, filename, lineno, file=None, line=None): + log = file if hasattr(file, "write") else sys.stderr + traceback.print_stack(file=log) + log.write(warnings.formatwarning(message, category, filename, lineno, line)) + + +def patch_warnings(): + # show traceback + warnings.showwarning = warn_with_traceback + warnings.simplefilter("always") diff --git a/tests/lib/patches.py b/tests/lib/patches.py new file mode 100644 index 0000000..55fd990 --- /dev/null +++ b/tests/lib/patches.py @@ -0,0 +1,413 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + +from __future__ import annotations + +import asyncio +import copy +import os +import subprocess +import time +from pickle import UnpicklingError +from unittest.mock import patch +import atexit + +import evdev + +from inputremapper.utils import get_evdev_constant_name +from tests.lib.constants import EVENT_READ_TIMEOUT +from tests.lib.fixtures import Fixture, fixtures, new_event +from tests.lib.pipes import ( + setup_pipe, + push_events, + uinput_write_history, + uinput_write_history_pipe, + pending_events, +) +from tests.lib.xmodmap import xmodmap +from tests.lib.tmp import tmp +from tests.lib.logger import logger + + +def patch_paths(): + from inputremapper.user import UserUtils + + return [ + patch.object(UserUtils, "home", tmp), + patch.dict(os.environ, {"XDG_CONFIG_HOME": os.path.join(tmp, ".config")}), + ] + + +class InputDevice: + # expose as existing attribute, otherwise the patch for + # evdev < 1.0.0 will crash the test + path = None + + def __init__(self, path): + if path != "justdoit" and not fixtures.get(path): + # beware that fixtures keys and the path attribute of a fixture can + # theoretically be different. I don't know if this is the case right now + logger.error( + 'path "%s" was not found in fixtures. available: %s', + path, + list(fixtures.get_paths()), + ) + raise FileNotFoundError() + if path == "justdoit": + self._fixture = Fixture(path="justdoit") + else: + self._fixture = fixtures.get_fixture(path) + + self.path = path + self.phys = self._fixture.phys + self.info = self._fixture.info + self.name = self._fixture.name + self.uniq = self._fixture.uniq + + # this property exists only for test purposes and is not part of + # the original evdev.InputDevice class + self.group_key = self._fixture.group_key or self._fixture.name + + # ensure a pipe exists to make this object act like + # it is reading events from a device + setup_pipe(self._fixture) + + self.fd = pending_events[self._fixture][1].fileno() + + def push_events(self, events): + push_events(self._fixture, events) + + def fileno(self): + """Compatibility to select.select.""" + return self.fd + + def log(self, key, msg): + logger.info('%s "%s" "%s" %s', msg, self.name, self.path, key) + + def absinfo(self, *args): + raise Exception("Ubuntus version of evdev doesn't support .absinfo") + + def grab(self): + logger.info("grab %s %s", self.name, self.path) + + def ungrab(self): + logger.info("ungrab %s %s", self.name, self.path) + + async def async_read_loop(self): + logger.info("starting read loop for %s", self.path) + new_frame = asyncio.Event() + asyncio.get_running_loop().add_reader(self.fd, new_frame.set) + while True: + await new_frame.wait() + new_frame.clear() + if not pending_events[self._fixture][1].poll(): + # todo: why? why do we need this? + # sometimes this happens, as if a other process calls recv on + # the pipe + continue + + event = pending_events[self._fixture][1].recv() + logger.info("got %s at %s", event, self.path) + yield event + + def read(self): + # the patched fake InputDevice objects read anything pending from + # that group. + # To be realistic it would have to check if the provided + # element is in its capabilities. + if self.group_key not in pending_events: + self.log("no events to read", self.group_key) + return + + # consume all of them + while pending_events[self._fixture][1].poll(): + event = pending_events[self._fixture][1].recv() + self.log(event, "read") + yield event + time.sleep(EVENT_READ_TIMEOUT) + + def read_loop(self): + """Endless loop that yields events.""" + while True: + event = pending_events[self._fixture][1].recv() + if event is not None: + self.log(event, "read_loop") + yield event + time.sleep(EVENT_READ_TIMEOUT) + + def read_one(self): + """Read one event or none if nothing available.""" + if not pending_events.get(self._fixture): + return None + + if not pending_events[self._fixture][1].poll(): + return None + + try: + event = pending_events[self._fixture][1].recv() + except (UnpicklingError, EOFError): + # failed in tests sometimes + return None + + self.log(event, "read_one") + return event + + def capabilities(self, absinfo=True, verbose=False): + result = copy.deepcopy(self._fixture.capabilities) + + if absinfo and evdev.ecodes.EV_ABS in result: + absinfo_obj = evdev.AbsInfo( + value=None, + min=self._fixture.min_abs, + fuzz=None, + flat=None, + resolution=None, + max=self._fixture.max_abs, + ) + + ev_abs = [] + for ev_code in result[evdev.ecodes.EV_ABS]: + if ev_code in range(0x10, 0x18): # ABS_HAT0X - ABS_HAT3Y + absinfo_obj = evdev.AbsInfo( + value=None, + min=-1, + fuzz=None, + flat=None, + resolution=None, + max=1, + ) + ev_abs.append((ev_code, absinfo_obj)) + + result[evdev.ecodes.EV_ABS] = ev_abs + + return result + + def input_props(self): + return [] + + def leds(self): + return [] + + +uinputs = {} + + +class UInputMock: + def __init__( + self, events=None, name="unnamed", phys="py-evdev-uinput", *args, **kwargs + ): + self.fd = 0 + self.write_count = 0 + self.device = InputDevice("justdoit") + self.name = name + self.events = events + self.phys = phys + self.write_history = [] + + global uinputs + uinputs[name] = self + + def capabilities(self, verbose=False, absinfo=True): + if absinfo or 3 not in self.events: + return self.events + else: + events = self.events.copy() + events[3] = [code for code, _ in self.events[3]] + return events + + def write(self, type, code, value): + self.write_count += 1 + event = new_event(type, code, value, time.time()) + uinput_write_history.append(event) + uinput_write_history_pipe[1].send(event) + self.write_history.append(event) + logger.info( + '%s %s written to "%s"', + (type, code, value), + get_evdev_constant_name(type, code), + self.name, + ) + + def syn(self): + pass + + +def patch_evdev(): + def list_devices(): + return [fixture_.path for fixture_ in fixtures] + + class PatchedInputEvent(evdev.InputEvent): + def __init__(self, sec, usec, type, code, value): + self.t = (type, code, value) + super().__init__(sec, usec, type, code, value) + + def copy(self): + return PatchedInputEvent( + self.sec, + self.usec, + self.type, + self.code, + self.value, + ) + + return [ + patch.object(evdev, "list_devices", list_devices), + patch.object(evdev, "InputDevice", InputDevice), + patch.object(evdev.UInput, "capabilities", UInputMock.capabilities), + patch.object(evdev.UInput, "write", UInputMock.write), + patch.object(evdev.UInput, "syn", UInputMock.syn), + patch.object(evdev.UInput, "__init__", UInputMock.__init__), + patch.object(evdev, "InputEvent", PatchedInputEvent), + ] + + +def patch_events(): + # improve logging of stuff + return patch.object( + evdev.InputEvent, + "__str__", + lambda self: (f"InputEvent{(self.type, self.code, self.value)}"), + ) + + +def patch_os_system(): + """Avoid running pkexec.""" + original_system = os.system + + def system(command): + if "pkexec" in command: + # because it + # - will open a window for user input + # - has no knowledge of the fixtures and patches + raise Exception("Write patches to avoid running pkexec stuff") + return original_system(command) + + return patch.object(os, "system", system) + + +def patch_atexit_register(): + """Avoid adding tons of redundant atexit handlers that we don't need anyway. + Otherwise we get lots of logs at the end of gui tests that bury the test result. + """ + return patch.object(atexit, "register") + + +def patch_check_output(): + """Xmodmap -pke should always return a fixed set of symbols. + + On some installations the `xmodmap` command might be missig completely, + which would break the tests. + """ + original_check_output = subprocess.check_output + + def check_output(command, *args, **kwargs): + if "xmodmap" in command and "-pke" in command: + return xmodmap + return original_check_output(command, *args, **kwargs) + + return patch.object(subprocess, "check_output", check_output) + + +def patch_regrab_timeout(): + # no need for a high number in tests + from inputremapper.injection.injector import Injector + + return patch.object(Injector, "regrab_timeout", 0.05) + + +def is_running_patch(): + logger.info("is_running is patched to always return True") + return True + + +def patch_is_running(): + from inputremapper.gui.reader_service import ReaderService + + return patch.object(ReaderService, "is_running", is_running_patch) + + +def patch_enable_all_logs(): + from inputremapper.logging.logger import Logger + + return patch.object(Logger, "analog_log_threshold", 0) + + +class FakeDaemonProxy: + def __init__(self): + self.calls = { + "stop_injecting": [], + "get_state": [], + "start_injecting": [], + "stop_all": 0, + "set_config_dir": [], + "autoload": 0, + "autoload_single": [], + "hello": [], + "quit": 0, + } + + def stop_injecting(self, group_key: str) -> None: + self.calls["stop_injecting"].append(group_key) + + def get_state(self, group_key: str): + from inputremapper.injection.injector import InjectorState + + self.calls["get_state"].append(group_key) + return InjectorState.STOPPED + + def start_injecting(self, group_key: str, preset: str) -> bool: + self.calls["start_injecting"].append((group_key, preset)) + return True + + def stop_all(self) -> None: + self.calls["stop_all"] += 1 + + def set_config_dir(self, config_dir: str) -> None: + self.calls["set_config_dir"].append(config_dir) + + def autoload(self) -> None: + self.calls["autoload"] += 1 + + def autoload_single(self, group_key: str) -> None: + self.calls["autoload_single"].append(group_key) + + def hello(self, out: str) -> str: + self.calls["hello"].append(out) + return out + + def quit(self): + self.calls["quit"] += 1 + + +def create_patches(): + return [ + # Sketchy, they only work because the whole modules are imported, instead of + # importing `check_output` and `system` from the module. + *patch_evdev(), + patch_os_system(), + patch_atexit_register(), + patch_check_output(), + # Those are comfortably wrapped in a class, and are therefore easy to patch + *patch_paths(), + patch_regrab_timeout(), + patch_is_running(), + patch_events(), + patch_enable_all_logs(), + ] diff --git a/tests/lib/pipes.py b/tests/lib/pipes.py new file mode 100644 index 0000000..4bf2189 --- /dev/null +++ b/tests/lib/pipes.py @@ -0,0 +1,96 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + +"""Reading events from fixtures, making fixtures act like they are sending events.""" + +from __future__ import annotations + +import multiprocessing +from multiprocessing.connection import Connection +from typing import Dict, Tuple + +from tests.lib.fixtures import Fixture +from tests.lib.logger import logger + +uinput_write_history = [] +# for tests that makes the injector create its processes +uinput_write_history_pipe = multiprocessing.Pipe() +pending_events: Dict[Fixture, Tuple[Connection, Connection]] = {} + + +def read_write_history_pipe(): + """Convert the write history from the pipe to some easier to manage list.""" + history = [] + while uinput_write_history_pipe[0].poll(): + event = uinput_write_history_pipe[0].recv() + history.append((event.type, event.code, event.value)) + return history + + +def setup_pipe(fixture: Fixture): + """Create a pipe that can be used to send events to the reader-service, + which in turn will be sent to the reader-client + """ + if pending_events.get(fixture) is None: + pending_events[fixture] = multiprocessing.Pipe() + + +def close_pipe(fixture: Fixture): + if fixture in pending_events: + pipe1, pipe2 = pending_events[fixture] + pipe1.close() + pipe2.close() + del pending_events[fixture] + + +def get_events(): + """Get all events written by the injector.""" + return uinput_write_history + + +def push_event(fixture: Fixture, event, force: bool = False): + """Make a device act like it is reading events from evdev. + + push_event is like hitting a key on a keyboard for stuff that reads from + evdev.InputDevice (which is patched in test.py to work that way) + + Parameters + ---------- + fixture + For example 'Foo Device' + event + The InputEvent to send + force + don't check if the event is in fixture.capabilities + """ + setup_pipe(fixture) + if not force and ( + not fixture.capabilities.get(event.type) + or event.code not in fixture.capabilities[event.type] + ): + raise AssertionError(f"Fixture {fixture.path} cannot send {event}") + logger.info("Simulating %s for %s", event, fixture.path) + pending_events[fixture][0].send(event) + + +def push_events(fixture: Fixture, events, force=False): + """Push multiple events.""" + for event in events: + push_event(fixture, event, force) diff --git a/tests/lib/spy.py b/tests/lib/spy.py new file mode 100644 index 0000000..002983a --- /dev/null +++ b/tests/lib/spy.py @@ -0,0 +1,26 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + +from unittest.mock import patch + + +def spy(obj, name): + """Convenient wrapper for patch.object(..., ..., wraps=...).""" + return patch.object(obj, name, wraps=obj.__getattribute__(name)) diff --git a/tests/lib/test_setup.py b/tests/lib/test_setup.py new file mode 100644 index 0000000..a6b5032 --- /dev/null +++ b/tests/lib/test_setup.py @@ -0,0 +1,109 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + +from __future__ import annotations + +import os +import tracemalloc + +from tests.lib.cleanup import cleanup, quick_cleanup +from tests.lib.fixture_pipes import create_fixture_pipes, remove_fixture_pipes +from tests.lib.is_service_running import is_service_running +from tests.lib.logger import update_inputremapper_verbosity, logger +from tests.lib.patches import create_patches + + +def test_setup(cls): + """A class decorator to + - apply the patches to all tests + - check if the deamon is already running + - create pipes to send events to the reader service + - reset stuff automatically + """ + original_setUp = cls.setUp + original_tearDown = cls.tearDown + original_setUpClass = cls.setUpClass + original_tearDownClass = cls.tearDownClass + + tracemalloc.start() + os.environ["UNITTEST"] = "1" + update_inputremapper_verbosity() + + patches = create_patches() + + def resetPatches(): + # In case some patches carry a state (I don't remember, idk), stop and start + # them from scratch + for patch in patches: + patch.stop() + + for patch in patches: + patch.start() + + def setUpClass(): + logger.info("setUpClass %s", cls) + + if is_service_running(): + # let tests control daemon existance + raise Exception("Expected the service not to be running already.") + + create_fixture_pipes() + + # I don't know. Somehow tearDownClass is called before the test, so lets + # make sure the patches are started already when the class is set up, so that + # an unpatched `prepare_all` doesn't take ages to finish, and doesn't do funky + # stuff with the real evdev. + resetPatches() + + original_setUpClass() + + def tearDownClass(): + logger.info("tearDownClass %s", cls) + original_tearDownClass() + + remove_fixture_pipes() + + # Do the more thorough cleanup only after all tests of classes, because it + # slows tests down. If this is required after each test, call it in your + # tearDown method. + cleanup() + + def setUp(self): + logger.info("setUp %s", cls) + + resetPatches() + + original_setUp(self) + + def tearDown(self): + logger.info("tearDown %s", cls) + + original_tearDown(self) + + quick_cleanup() + + resetPatches() + + cls.setUp = setUp + cls.tearDown = tearDown + cls.setUpClass = setUpClass + cls.tearDownClass = tearDownClass + + return cls diff --git a/tests/lib/tmp.py b/tests/lib/tmp.py new file mode 100644 index 0000000..c8a828f --- /dev/null +++ b/tests/lib/tmp.py @@ -0,0 +1,30 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + +from __future__ import annotations + +import tempfile +from tests.lib.logger import logger + +# When it gets garbage collected it cleans up the temporary directory so it needs to +# stay reachable while the tests are ran. +temporary_directory = tempfile.TemporaryDirectory(prefix="input-remapper-test") +tmp = temporary_directory.name +logger.info('tmp at "%s"', tmp) diff --git a/tests/lib/xmodmap.py b/tests/lib/xmodmap.py new file mode 100644 index 0000000..1c3349c --- /dev/null +++ b/tests/lib/xmodmap.py @@ -0,0 +1,128 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + +xmodmap = ( + b"keycode 8 =\nkeycode 9 = Escape NoSymbol Escape\nkeycode 10 = 1 exclam 1 exclam onesuperior exclamdown ones" + b"uperior\nkeycode 11 = 2 quotedbl 2 quotedbl twosuperior oneeighth twosuperior\nkeycode 12 = 3 section 3 sectio" + b"n threesuperior sterling threesuperior\nkeycode 13 = 4 dollar 4 dollar onequarter currency onequarter\nkeycode " + b" 14 = 5 percent 5 percent onehalf threeeighths onehalf\nkeycode 15 = 6 ampersand 6 ampersand notsign fiveeighth" + b"s notsign\nkeycode 16 = 7 slash 7 slash braceleft seveneighths braceleft\nkeycode 17 = 8 parenleft 8 parenleft" + b" bracketleft trademark bracketleft\nkeycode 18 = 9 parenright 9 parenright bracketright plusminus bracketright" + b"\nkeycode 19 = 0 equal 0 equal braceright degree braceright\nkeycode 20 = ssharp question ssharp question back" + b"slash questiondown U1E9E\nkeycode 21 = dead_acute dead_grave dead_acute dead_grave dead_cedilla dead_ogonek dea" + b"d_cedilla\nkeycode 22 = BackSpace BackSpace BackSpace BackSpace\nkeycode 23 = Tab ISO_Left_Tab Tab ISO_Left_Ta" + b"b\nkeycode 24 = q Q q Q at Greek_OMEGA at\nkeycode 25 = w W w W lstroke Lstroke lstroke\nkeycode 26 = e E e E" + b" EuroSign EuroSign EuroSign\nkeycode 27 = r R r R paragraph registered paragraph\nkeycode 28 = t T t T tslash " + b"Tslash tslash\nkeycode 29 = z Z z Z leftarrow yen leftarrow\nkeycode 30 = u U u U downarrow uparrow downarrow" + b"\nkeycode 31 = i I i I rightarrow idotless rightarrow\nkeycode 32 = o O o O oslash Oslash oslash\nkeycode 33 " + b"= p P p P thorn THORN thorn\nkeycode 34 = udiaeresis Udiaeresis udiaeresis Udiaeresis dead_diaeresis dead_above" + b"ring dead_diaeresis\nkeycode 35 = plus asterisk plus asterisk asciitilde macron asciitilde\nkeycode 36 = Retur" + b"n NoSymbol Return\nkeycode 37 = Control_L NoSymbol Control_L\nkeycode 38 = a A a A ae AE ae\nkeycode 39 = s S" + b" s S U017F U1E9E U017F\nkeycode 40 = d D d D eth ETH eth\nkeycode 41 = f F f F dstroke ordfeminine dstroke\nke" + b"ycode 42 = g G g G eng ENG eng\nkeycode 43 = h H h H hstroke Hstroke hstroke\nkeycode 44 = j J j J dead_below" + b"dot dead_abovedot dead_belowdot\nkeycode 45 = k K k K kra ampersand kra\nkeycode 46 = l L l L lstroke Lstroke " + b"lstroke\nkeycode 47 = odiaeresis Odiaeresis odiaeresis Odiaeresis dead_doubleacute dead_belowdot dead_doubleacu" + b"te\nkeycode 48 = adiaeresis Adiaeresis adiaeresis Adiaeresis dead_circumflex dead_caron dead_circumflex\nkeycod" + b"e 49 = dead_circumflex degree dead_circumflex degree U2032 U2033 U2032\nkeycode 50 = Shift_L NoSymbol Shift_L" + b"\nkeycode 51 = numbersign apostrophe numbersign apostrophe rightsinglequotemark dead_breve rightsinglequotemark" + b"\nkeycode 52 = y Y y Y guillemotright U203A guillemotright\nkeycode 53 = x X x X guillemotleft U2039 guillemot" + b"left\nkeycode 54 = c C c C cent copyright cent\nkeycode 55 = v V v V doublelowquotemark singlelowquotemark dou" + b"blelowquotemark\nkeycode 56 = b B b B leftdoublequotemark leftsinglequotemark leftdoublequotemark\nkeycode 57 " + b"= n N n N rightdoublequotemark rightsinglequotemark rightdoublequotemark\nkeycode 58 = m M m M mu masculine mu" + b"\nkeycode 59 = comma semicolon comma semicolon periodcentered multiply periodcentered\nkeycode 60 = period col" + b"on period colon U2026 division U2026\nkeycode 61 = minus underscore minus underscore endash emdash endash\nkeyc" + b"ode 62 = Shift_R NoSymbol Shift_R\nkeycode 63 = KP_Multiply KP_Multiply KP_Multiply KP_Multiply KP_Multiply KP" + b"_Multiply XF86ClearGrab\nkeycode 64 = Alt_L Meta_L Alt_L Meta_L\nkeycode 65 = space NoSymbol space\nkeycode 6" + b"6 = Caps_Lock NoSymbol Caps_Lock\nkeycode 67 = F1 F1 F1 F1 F1 F1 XF86Switch_VT_1\nkeycode 68 = F2 F2 F2 F2 F2 " + b"F2 XF86Switch_VT_2\nkeycode 69 = F3 F3 F3 F3 F3 F3 XF86Switch_VT_3\nkeycode 70 = F4 F4 F4 F4 F4 F4 XF86Switch_" + b"VT_4\nkeycode 71 = F5 F5 F5 F5 F5 F5 XF86Switch_VT_5\nkeycode 72 = F6 F6 F6 F6 F6 F6 XF86Switch_VT_6\nkeycode " + b" 73 = F7 F7 F7 F7 F7 F7 XF86Switch_VT_7\nkeycode 74 = F8 F8 F8 F8 F8 F8 XF86Switch_VT_8\nkeycode 75 = F9 F9 F9" + b" F9 F9 F9 XF86Switch_VT_9\nkeycode 76 = F10 F10 F10 F10 F10 F10 XF86Switch_VT_10\nkeycode 77 = Num_Lock NoSymb" + b"ol Num_Lock\nkeycode 78 = Scroll_Lock NoSymbol Scroll_Lock\nkeycode 79 = KP_Home KP_7 KP_Home KP_7\nkeycode 8" + b"0 = KP_Up KP_8 KP_Up KP_8\nkeycode 81 = KP_Prior KP_9 KP_Prior KP_9\nkeycode 82 = KP_Subtract KP_Subtract KP_S" + b"ubtract KP_Subtract KP_Subtract KP_Subtract XF86Prev_VMode\nkeycode 83 = KP_Left KP_4 KP_Left KP_4\nkeycode 84" + b" = KP_Begin KP_5 KP_Begin KP_5\nkeycode 85 = KP_Right KP_6 KP_Right KP_6\nkeycode 86 = KP_Add KP_Add KP_Add KP" + b"_Add KP_Add KP_Add XF86Next_VMode\nkeycode 87 = KP_End KP_1 KP_End KP_1\nkeycode 88 = KP_Down KP_2 KP_Down KP_" + b"2\nkeycode 89 = KP_Next KP_3 KP_Next KP_3\nkeycode 90 = KP_Insert KP_0 KP_Insert KP_0\nkeycode 91 = KP_Delete" + b" KP_Separator KP_Delete KP_Separator\nkeycode 92 = ISO_Level3_Shift NoSymbol ISO_Level3_Shift\nkeycode 93 =\nk" + b"eycode 94 = less greater less greater bar dead_belowmacron bar\nkeycode 95 = F11 F11 F11 F11 F11 F11 XF86Switc" + b"h_VT_11\nkeycode 96 = F12 F12 F12 F12 F12 F12 XF86Switch_VT_12\nkeycode 97 =\nkeycode 98 = Katakana NoSymbol " + b"Katakana\nkeycode 99 = Hiragana NoSymbol Hiragana\nkeycode 100 = Henkan_Mode NoSymbol Henkan_Mode\nkeycode 101 " + b"= Hiragana_Katakana NoSymbol Hiragana_Katakana\nkeycode 102 = Muhenkan NoSymbol Muhenkan\nkeycode 103 =\nkeycode" + b" 104 = KP_Enter NoSymbol KP_Enter\nkeycode 105 = Control_R NoSymbol Control_R\nkeycode 106 = KP_Divide KP_Divide" + b" KP_Divide KP_Divide KP_Divide KP_Divide XF86Ungrab\nkeycode 107 = Print Sys_Req Print Sys_Req\nkeycode 108 = IS" + b"O_Level3_Shift NoSymbol ISO_Level3_Shift\nkeycode 109 = Linefeed NoSymbol Linefeed\nkeycode 110 = Home NoSymbol " + b"Home\nkeycode 111 = Up NoSymbol Up\nkeycode 112 = Prior NoSymbol Prior\nkeycode 113 = Left NoSymbol Left\nkeycod" + b"e 114 = Right NoSymbol Right\nkeycode 115 = End NoSymbol End\nkeycode 116 = Down NoSymbol Down\nkeycode 117 = Ne" + b"xt NoSymbol Next\nkeycode 118 = Insert NoSymbol Insert\nkeycode 119 = Delete NoSymbol Delete\nkeycode 120 =\nkey" + b"code 121 = XF86AudioMute NoSymbol XF86AudioMute\nkeycode 122 = XF86AudioLowerVolume NoSymbol XF86AudioLowerVolum" + b"e\nkeycode 123 = XF86AudioRaiseVolume NoSymbol XF86AudioRaiseVolume\nkeycode 124 = XF86PowerOff NoSymbol XF86Pow" + b"erOff\nkeycode 125 = KP_Equal NoSymbol KP_Equal\nkeycode 126 = plusminus NoSymbol plusminus\nkeycode 127 = Pause" + b" Break Pause Break\nkeycode 128 = XF86LaunchA NoSymbol XF86LaunchA\nkeycode 129 = KP_Decimal KP_Decimal KP_Decim" + b"al KP_Decimal\nkeycode 130 = Hangul NoSymbol Hangul\nkeycode 131 = Hangul_Hanja NoSymbol Hangul_Hanja\nkeycode 1" + b"32 =\nkeycode 133 = Super_L NoSymbol Super_L\nkeycode 134 = Super_R NoSymbol Super_R\nkeycode 135 = Menu NoSymbo" + b"l Menu\nkeycode 136 = Cancel NoSymbol Cancel\nkeycode 137 = Redo NoSymbol Redo\nkeycode 138 = SunProps NoSymbol " + b"SunProps\nkeycode 139 = Undo NoSymbol Undo\nkeycode 140 = SunFront NoSymbol SunFront\nkeycode 141 = XF86Copy NoS" + b"ymbol XF86Copy\nkeycode 142 = XF86Open NoSymbol XF86Open\nkeycode 143 = XF86Paste NoSymbol XF86Paste\nkeycode 14" + b"4 = Find NoSymbol Find\nkeycode 145 = XF86Cut NoSymbol XF86Cut\nkeycode 146 = Help NoSymbol Help\nkeycode 147 = " + b"XF86MenuKB NoSymbol XF86MenuKB\nkeycode 148 = XF86Calculator NoSymbol XF86Calculator\nkeycode 149 =\nkeycode 150" + b" = XF86Sleep NoSymbol XF86Sleep\nkeycode 151 = XF86WakeUp NoSymbol XF86WakeUp\nkeycode 152 = XF86Explorer NoSymb" + b"ol XF86Explorer\nkeycode 153 = XF86Send NoSymbol XF86Send\nkeycode 154 =\nkeycode 155 = XF86Xfer NoSymbol XF86Xf" + b"er\nkeycode 156 = XF86Launch1 NoSymbol XF86Launch1\nkeycode 157 = XF86Launch2 NoSymbol XF86Launch2\nkeycode 158 " + b"= XF86WWW NoSymbol XF86WWW\nkeycode 159 = XF86DOS NoSymbol XF86DOS\nkeycode 160 = XF86ScreenSaver NoSymbol XF86S" + b"creenSaver\nkeycode 161 = XF86RotateWindows NoSymbol XF86RotateWindows\nkeycode 162 = XF86TaskPane NoSymbol XF86" + b"TaskPane\nkeycode 163 = XF86Mail NoSymbol XF86Mail\nkeycode 164 = XF86Favorites NoSymbol XF86Favorites\nkeycode " + b"165 = XF86MyComputer NoSymbol XF86MyComputer\nkeycode 166 = XF86Back NoSymbol XF86Back\nkeycode 167 = XF86Forwar" + b"d NoSymbol XF86Forward\nkeycode 168 =\nkeycode 169 = XF86Eject NoSymbol XF86Eject\nkeycode 170 = XF86Eject XF86E" + b"ject XF86Eject XF86Eject\nkeycode 171 = XF86AudioNext NoSymbol XF86AudioNext\nkeycode 172 = XF86AudioPlay XF86Au" + b"dioPause XF86AudioPlay XF86AudioPause\nkeycode 173 = XF86AudioPrev NoSymbol XF86AudioPrev\nkeycode 174 = XF86Aud" + b"ioStop XF86Eject XF86AudioStop XF86Eject\nkeycode 175 = XF86AudioRecord NoSymbol XF86AudioRecord\nkeycode 176 = " + b"XF86AudioRewind NoSymbol XF86AudioRewind\nkeycode 177 = XF86Phone NoSymbol XF86Phone\nkeycode 178 =\nkeycode 179" + b" = XF86Tools NoSymbol XF86Tools\nkeycode 180 = XF86HomePage NoSymbol XF86HomePage\nkeycode 181 = XF86Reload NoSy" + b"mbol XF86Reload\nkeycode 182 = XF86Close NoSymbol XF86Close\nkeycode 183 =\nkeycode 184 =\nkeycode 185 = XF86Scr" + b"ollUp NoSymbol XF86ScrollUp\nkeycode 186 = XF86ScrollDown NoSymbol XF86ScrollDown\nkeycode 187 = parenleft NoSym" + b"bol parenleft\nkeycode 188 = parenright NoSymbol parenright\nkeycode 189 = XF86New NoSymbol XF86New\nkeycode 190" + b" = Redo NoSymbol Redo\nkeycode 191 = XF86Tools NoSymbol XF86Tools\nkeycode 192 = XF86Launch5 NoSymbol XF86Launch" + b"5\nkeycode 193 = XF86Launch6 NoSymbol XF86Launch6\nkeycode 194 = XF86Launch7 NoSymbol XF86Launch7\nkeycode 195 =" + b" XF86Launch8 NoSymbol XF86Launch8\nkeycode 196 = XF86Launch9 NoSymbol XF86Launch9\nkeycode 197 =\nkeycode 198 = " + b"XF86AudioMicMute NoSymbol XF86AudioMicMute\nkeycode 199 = XF86TouchpadToggle NoSymbol XF86TouchpadToggle\nkeycod" + b"e 200 = XF86TouchpadOn NoSymbol XF86TouchpadOn\nkeycode 201 = XF86TouchpadOff NoSymbol XF86TouchpadOff\nkeycode " + b"202 =\nkeycode 203 = Mode_switch NoSymbol Mode_switch\nkeycode 204 = NoSymbol Alt_L NoSymbol Alt_L\nkeycode 205 " + b"= NoSymbol Meta_L NoSymbol Meta_L\nkeycode 206 = NoSymbol Super_L NoSymbol Super_L\nkeycode 207 = NoSymbol Hyper" + b"_L NoSymbol Hyper_L\nkeycode 208 = XF86AudioPlay NoSymbol XF86AudioPlay\nkeycode 209 = XF86AudioPause NoSymbol X" + b"F86AudioPause\nkeycode 210 = XF86Launch3 NoSymbol XF86Launch3\nkeycode 211 = XF86Launch4 NoSymbol XF86Launch4\nk" + b"eycode 212 = XF86LaunchB NoSymbol XF86LaunchB\nkeycode 213 = XF86Suspend NoSymbol XF86Suspend\nkeycode 214 = XF8" + b"6Close NoSymbol XF86Close\nkeycode 215 = XF86AudioPlay NoSymbol XF86AudioPlay\nkeycode 216 = XF86AudioForward No" + b"Symbol XF86AudioForward\nkeycode 217 =\nkeycode 218 = Print NoSymbol Print\nkeycode 219 =\nkeycode 220 = XF86Web" + b"Cam NoSymbol XF86WebCam\nkeycode 221 = XF86AudioPreset NoSymbol XF86AudioPreset\nkeycode 222 =\nkeycode 223 = XF" + b"86Mail NoSymbol XF86Mail\nkeycode 224 = XF86Messenger NoSymbol XF86Messenger\nkeycode 225 = XF86Search NoSymbol " + b"XF86Search\nkeycode 226 = XF86Go NoSymbol XF86Go\nkeycode 227 = XF86Finance NoSymbol XF86Finance\nkeycode 228 = " + b"XF86Game NoSymbol XF86Game\nkeycode 229 = XF86Shop NoSymbol XF86Shop\nkeycode 230 =\nkeycode 231 = Cancel NoSymb" + b"ol Cancel\nkeycode 232 = XF86MonBrightnessDown NoSymbol XF86MonBrightnessDown\nkeycode 233 = XF86MonBrightnessUp" + b" NoSymbol XF86MonBrightnessUp\nkeycode 234 = XF86AudioMedia NoSymbol XF86AudioMedia\nkeycode 235 = XF86Display N" + b"oSymbol XF86Display\nkeycode 236 = XF86KbdLightOnOff NoSymbol XF86KbdLightOnOff\nkeycode 237 = XF86KbdBrightness" + b"Down NoSymbol XF86KbdBrightnessDown\nkeycode 238 = XF86KbdBrightnessUp NoSymbol XF86KbdBrightnessUp\nkeycode 239" + b" = XF86Send NoSymbol XF86Send\nkeycode 240 = XF86Reply NoSymbol XF86Reply\nkeycode 241 = XF86MailForward NoSymbo" + b"l XF86MailForward\nkeycode 242 = XF86Save NoSymbol XF86Save\nkeycode 243 = XF86Documents NoSymbol XF86Documents" + b"\nkeycode 244 = XF86Battery NoSymbol XF86Battery\nkeycode 245 = XF86Bluetooth NoSymbol XF86Bluetooth\nkeycode 24" + b"6 = XF86WLAN NoSymbol XF86WLAN\nkeycode 247 =\nkeycode 248 =\nkeycode 249 =\nkeycode 250 =\nkeycode 251 = XF86Mo" + b"nBrightnessCycle NoSymbol XF86MonBrightnessCycle\nkeycode 252 =\nkeycode 253 =\nkeycode 254 = XF86WWAN NoSymbol " + b"XF86WWAN\nkeycode 255 = XF86RFKill NoSymbol XF86RFKill\n" +) diff --git a/tests/system/__init__.py b/tests/system/__init__.py new file mode 100644 index 0000000..71782b9 --- /dev/null +++ b/tests/system/__init__.py @@ -0,0 +1,8 @@ +"""Tests that require linux system components to be running, that might not be.""" + +import gi + +gi.require_version("Gdk", "3.0") +gi.require_version("Gtk", "3.0") +gi.require_version("GLib", "2.0") +gi.require_version("GtkSource", "4") diff --git a/tests/system/gui/__init__.py b/tests/system/gui/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/system/gui/gui_test_base.py b/tests/system/gui/gui_test_base.py new file mode 100644 index 0000000..fde2600 --- /dev/null +++ b/tests/system/gui/gui_test_base.py @@ -0,0 +1,383 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + +import asyncio +import atexit +import multiprocessing +import os +import time +import unittest +from contextlib import contextmanager +from typing import Tuple, List, Optional +from unittest.mock import patch + +import evdev +import gi +import sys + +from inputremapper.injection.global_uinputs import GlobalUInputs, FrontendUInput, UInput +from inputremapper.injection.mapping_handlers.mapping_parser import MappingParser +from tests.lib.cleanup import cleanup +from tests.lib.constants import EVENT_READ_TIMEOUT +from tests.lib.fixtures import prepare_presets +from tests.lib.logger import logger + +gi.require_version("Gdk", "3.0") +gi.require_version("Gtk", "3.0") +gi.require_version("GtkSource", "4") +gi.require_version("GLib", "2.0") +from gi.repository import Gtk, GLib, GtkSource + +from inputremapper.configs.mapping import Mapping +from inputremapper.configs.global_config import GlobalConfig +from inputremapper.groups import _Groups +from inputremapper.gui.data_manager import DataManager +from inputremapper.gui.messages.message_broker import ( + MessageBroker, +) +from inputremapper.gui.components.editor import ( + MappingSelectionLabel, +) +from inputremapper.gui.controller import Controller +from inputremapper.gui.reader_service import ReaderService +from inputremapper.gui.utils import gtk_iteration +from inputremapper.gui.user_interface import UserInterface +from inputremapper.configs.input_config import InputCombination, InputConfig +from inputremapper.daemon import Daemon, DaemonProxy +from inputremapper.bin.input_remapper_gtk import InputRemapperGtkBin + + +# iterate a few times when Gtk.main() is called, but don't block +# there and just continue to the tests while the UI becomes +# unresponsive +Gtk.main = gtk_iteration + +# doesn't do much except avoid some Gtk assertion error, whatever: +Gtk.main_quit = lambda: None + + +def launch() -> Tuple[ + UserInterface, + Controller, + DataManager, + MessageBroker, + DaemonProxy, + GlobalConfig, +]: + """Start input-remapper-gtk.""" + with patch.object(sys, "argv", ["/usr/bin/input-remapper-gtk", "-d"]): + return_ = InputRemapperGtkBin.main() + + gtk_iteration() + # otherwise a new handler is added with each call to launch, which + # spams tons of garbage when all tests finish + atexit.unregister(InputRemapperGtkBin.stop) + return return_ + + +def start_reader_service(): + def process(): + global_uinputs = GlobalUInputs(FrontendUInput) + reader_service = ReaderService(_Groups(), global_uinputs) + loop = asyncio.new_event_loop() + loop.run_until_complete(reader_service.run()) + + multiprocessing.Process(target=process).start() + + +def os_system_patch(cmd, original_os_system=os.system): + # instead of running pkexec, fork instead. This will make + # the reader-service aware of all the test patches + if "pkexec input-remapper-control --command start-reader-service" in cmd: + logger.info("pkexec-patch starting ReaderService process") + start_reader_service() + return 0 + + return original_os_system(cmd) + + +@contextmanager +def patch_services(): + """Don't connect to the dbus and don't use pkexec to start the reader-service""" + + def bootstrap_daemon(): + # The daemon gets fresh instances of everything, because as far as I remember + # it runs in a separate process. + global_config = GlobalConfig() + global_uinputs = GlobalUInputs(UInput) + mapping_parser = MappingParser(global_uinputs) + + return Daemon( + global_config, + global_uinputs, + mapping_parser, + ) + + with ( + patch.object( + os, + "system", + os_system_patch, + ), + patch.object(Daemon, "connect", bootstrap_daemon), + ): + yield + + +def clean_up_gui_test(test): + logger.info("clean_up_gui_test") + test.controller.stop_injecting() + gtk_iteration() + test.user_interface.on_gtk_close() + test.user_interface.window.destroy() + gtk_iteration() + cleanup() + + # do this now, not when all tests are finished + test.daemon.stop_all() + if isinstance(test.daemon, Daemon): + atexit.unregister(test.daemon.stop_all) + + +class GtkKeyEvent: + def __init__(self, keyval): + self.keyval = keyval + + def get_keyval(self): + return True, self.keyval + + +@contextmanager +def patch_confirm_delete( + user_interface: UserInterface, + response=Gtk.ResponseType.ACCEPT, +): + original_create_dialog = user_interface._create_dialog + + def _create_dialog_patch(*args, **kwargs): + """A patch for the deletion confirmation that briefly shows the dialog.""" + confirm_cancel_dialog = original_create_dialog(*args, **kwargs) + + # the emitted signal causes the dialog to close + GLib.timeout_add( + 100, + lambda: confirm_cancel_dialog.emit("response", response), + ) + + # don't recursively call the patch + Gtk.MessageDialog.run(confirm_cancel_dialog) + + confirm_cancel_dialog.run = lambda: response + + return confirm_cancel_dialog + + with patch.object( + user_interface, + "_create_dialog", + _create_dialog_patch, + ): + # Tests are run during `yield` + yield + + +class GuiTestBase(unittest.TestCase): + def setUp(self): + prepare_presets() + with patch_services(): + ( + self.user_interface, + self.controller, + self.data_manager, + self.message_broker, + self.daemon, + self.global_config, + ) = launch() + + get = self.user_interface.get + self.device_selection: Gtk.FlowBox = get("device_selection") + self.preset_selection: Gtk.ComboBoxText = get("preset_selection") + self.selection_label_listbox: Gtk.ListBox = get("selection_label_listbox") + self.target_selection: Gtk.ComboBox = get("target-selector") + self.recording_toggle: Gtk.ToggleButton = get("key_recording_toggle") + self.recording_status: Gtk.ToggleButton = get("recording_status") + self.status_bar: Gtk.Statusbar = get("status_bar") + self.autoload_toggle: Gtk.Switch = get("preset_autoload_switch") + self.code_editor: GtkSource.View = get("code_editor") + self.output_box: GtkSource.View = get("output") + + self.delete_preset_btn: Gtk.Button = get("delete_preset") + self.copy_preset_btn: Gtk.Button = get("copy_preset") + self.create_preset_btn: Gtk.Button = get("create_preset") + self.start_injector_btn: Gtk.Button = get("apply_preset") + self.stop_injector_btn: Gtk.Button = get("stop_injection_preset_page") + self.rename_btn: Gtk.Button = get("rename-button") + self.rename_input: Gtk.Entry = get("preset_name_input") + self.create_mapping_btn: Gtk.Button = get("create_mapping_button") + self.delete_mapping_btn: Gtk.Button = get("delete-mapping") + + self._test_initial_state() + + self.grab_fails = False + + def grab(_): + if self.grab_fails: + raise OSError() + + evdev.InputDevice.grab = grab + + self.global_config._save_config() + + self.throttle(20) + + self.assertIsNotNone(self.data_manager.active_group) + self.assertIsNotNone(self.data_manager.active_preset) + + def tearDown(self): + clean_up_gui_test(self) + + # this is important, otherwise it keeps breaking things in the background + self.assertIsNone(self.data_manager._reader_client._read_timeout) + + self.throttle(20) + + def get_code_input(self): + buffer = self.code_editor.get_buffer() + return buffer.get_text( + buffer.get_start_iter(), + buffer.get_end_iter(), + True, + ) + + def _test_initial_state(self): + # make sure each test deals with the same initial state + self.assertEqual(self.controller.data_manager, self.data_manager) + self.assertEqual(self.data_manager.active_group.key, "Foo Device") + # if the modification-date from `prepare_presets` is not destroyed, preset3 + # should be selected as the newest one + self.assertEqual(self.data_manager.active_preset.name, "preset3") + self.assertEqual(self.data_manager.active_mapping.target_uinput, "keyboard") + self.assertEqual(self.target_selection.get_active_id(), "keyboard") + self.assertEqual( + self.data_manager.active_mapping.input_combination, + InputCombination([InputConfig(type=1, code=5)]), + ) + self.assertEqual( + self.data_manager.active_input_config, InputConfig(type=1, code=5) + ) + self.assertGreater( + len(self.user_interface.autocompletion._target_key_capabilities), 0 + ) + + def _callTestMethod(self, method): + """Retry all tests if they fail. + + GUI tests suddenly started to lag a lot and fail randomly, and even + though that improved drastically, sometimes they still do. + """ + attempts = 0 + while True: + attempts += 1 + try: + method() + break + except Exception as e: + if attempts == 2: + raise e + + # try again + print("Test failed, trying again...") + self.tearDown() + self.setUp() + + def throttle(self, time_=10): + """Give GTK some time in ms to process everything.""" + # tests suddenly started to freeze my computer up completely and tests started + # to fail. By using this (and by optimizing some redundant calls in the gui) it + # worked again. EDIT: Might have been caused by my broken/bloated ssd. I'll + # keep it in some places, since it did make the tests more reliable after all. + for _ in range(time_ // 2): + gtk_iteration() + time.sleep(0.002) + + def set_focus(self, widget): + logger.info("Focusing %s", widget) + + self.user_interface.window.set_focus(widget) + + self.throttle(20) + + def focus_source_view(self): + # despite the focus and gtk_iterations, gtk never runs the event handlers for + # the focus-in-event (_update_placeholder), which would clear the placeholder + # text. Remove it manually, it can't be helped. Fun fact: when the + # window gets destroyed, gtk runs the handler 10 times for good measure. + # Lost one hour of my life on GTK again. It's gone! Forever! Use qt next time. + source_view = self.code_editor + self.set_focus(source_view) + self.code_editor.get_buffer().set_text("") + return source_view + + def get_selection_labels(self) -> List[MappingSelectionLabel]: + return self.selection_label_listbox.get_children() + + def get_status_text(self): + status_bar = self.user_interface.get("status_bar") + return status_bar.get_message_area().get_children()[0].get_label() + + def get_unfiltered_symbol_input_text(self): + buffer = self.code_editor.get_buffer() + return buffer.get_text(buffer.get_start_iter(), buffer.get_end_iter(), True) + + def select_mapping(self, i: int): + """Select one of the mappings of a preset. + + Parameters + ---------- + i + if -1, will select the last row, + 0 will select the uppermost row. + 1 will select the second row, and so on + """ + selection_label = self.get_selection_labels()[i] + self.selection_label_listbox.select_row(selection_label) + logger.info( + 'Selecting mapping %s "%s"', + selection_label.combination, + selection_label.name, + ) + gtk_iteration() + return selection_label + + def add_mapping(self, mapping: Optional[Mapping] = None): + self.controller.create_mapping() + self.controller.load_mapping(InputCombination.empty_combination()) + gtk_iteration() + if mapping: + self.controller.update_mapping(**mapping.dict(exclude_defaults=True)) + gtk_iteration() + + def sleep(self, num_events): + for _ in range(num_events * 2): + time.sleep(EVENT_READ_TIMEOUT) + gtk_iteration() + + time.sleep(1 / 30) # one window iteration + + gtk_iteration() diff --git a/tests/system/gui/test_autocompletion.py b/tests/system/gui/test_autocompletion.py new file mode 100644 index 0000000..dbef04e --- /dev/null +++ b/tests/system/gui/test_autocompletion.py @@ -0,0 +1,259 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + +import time +import unittest + +import gi + +from inputremapper.gui.autocompletion import ( + get_incomplete_parameter, + get_incomplete_function_name, +) + +gi.require_version("Gdk", "3.0") +gi.require_version("Gtk", "3.0") +gi.require_version("GtkSource", "4") +gi.require_version("GLib", "2.0") +from gi.repository import Gtk, Gdk + +from inputremapper.configs.keyboard_layout import keyboard_layout +from inputremapper.gui.utils import gtk_iteration + +from tests.lib.test_setup import test_setup +from tests.system.gui.gui_test_base import GuiTestBase + + +@test_setup +class TestAutocompletion(GuiTestBase): + def press_key(self, keyval): + event = Gdk.EventKey() + event.keyval = keyval + self.user_interface.autocompletion.navigate(None, event) + + def get_suggestions(self, autocompletion): + return [ + row.get_children()[0].get_text() + for row in autocompletion.list_box.get_children() + ] + + def test_get_incomplete_parameter(self): + def test(text, expected): + text_view = Gtk.TextView() + Gtk.TextView.do_insert_at_cursor(text_view, text) + text_iter = text_view.get_iter_at_location(0, 0)[1] + text_iter.set_offset(len(text)) + self.assertEqual(get_incomplete_parameter(text_iter), expected) + + test("bar(foo", "foo") + test("bar(a=foo", "foo") + test("bar(qux, foo", "foo") + test("foo", "foo") + test("bar + foo", "foo") + + def test_get_incomplete_function_name(self): + def test(text, expected): + text_view = Gtk.TextView() + Gtk.TextView.do_insert_at_cursor(text_view, text) + text_iter = text_view.get_iter_at_location(0, 0)[1] + text_iter.set_offset(len(text)) + self.assertEqual(get_incomplete_function_name(text_iter), expected) + + test("bar().foo", "foo") + test("bar()\n.foo", "foo") + test("bar().\nfoo", "foo") + test("bar(\nfoo", "foo") + test("bar(\nqux=foo", "foo") + test("bar(KEY_A,\nfoo", "foo") + test("foo", "foo") + + def test_autocomplete_names(self): + autocompletion = self.user_interface.autocompletion + + def setup(text): + self.set_focus(self.code_editor) + self.code_editor.get_buffer().set_text("") + Gtk.TextView.do_insert_at_cursor(self.code_editor, text) + self.throttle(200) + text_iter = self.code_editor.get_iter_at_location(0, 0)[1] + text_iter.set_offset(len(text)) + + setup("disa") + self.assertNotIn("KEY_A", self.get_suggestions(autocompletion)) + self.assertIn("disable", self.get_suggestions(autocompletion)) + + setup(" + _A") + self.assertIn("KEY_A", self.get_suggestions(autocompletion)) + self.assertNotIn("disable", self.get_suggestions(autocompletion)) + + def test_autocomplete_key(self): + self.controller.update_mapping(output_symbol="") + gtk_iteration() + + self.set_focus(self.code_editor) + self.code_editor.get_buffer().set_text("") + + complete_key_name = "Test_Foo_Bar" + + keyboard_layout.clear() + keyboard_layout._set(complete_key_name, 1) + keyboard_layout._set("KEY_A", 30) # we need this for the UIMapping to work + + # it can autocomplete a combination inbetween other things + incomplete = "qux_1\n + + qux_2" + Gtk.TextView.do_insert_at_cursor(self.code_editor, incomplete) + Gtk.TextView.do_move_cursor( + self.code_editor, + Gtk.MovementStep.VISUAL_POSITIONS, + -8, + False, + ) + + Gtk.TextView.do_insert_at_cursor(self.code_editor, "foo") + self.throttle(200) + gtk_iteration() + + autocompletion = self.user_interface.autocompletion + self.assertTrue(autocompletion.visible) + + self.press_key(Gdk.KEY_Down) + self.press_key(Gdk.KEY_Return) + self.throttle(200) + gtk_iteration() + + # the first suggestion should have been selected + + modified_symbol = self.get_code_input() + self.assertEqual(modified_symbol, f"qux_1\n + {complete_key_name} + qux_2") + + # try again, but a whitespace completes the word and so no autocompletion + # should be shown + Gtk.TextView.do_insert_at_cursor(self.code_editor, " + foo ") + + time.sleep(0.11) + gtk_iteration() + + self.assertFalse(autocompletion.visible) + + def test_autocomplete_function(self): + self.controller.update_mapping(output_symbol="") + gtk_iteration() + + source_view = self.focus_source_view() + + incomplete = "key(KEY_A).\nepea" + Gtk.TextView.do_insert_at_cursor(source_view, incomplete) + + time.sleep(0.11) + gtk_iteration() + + autocompletion = self.user_interface.autocompletion + self.assertTrue(autocompletion.visible) + + self.press_key(Gdk.KEY_Down) + self.press_key(Gdk.KEY_Return) + + # the first suggestion should have been selected + modified_symbol = self.get_code_input() + self.assertEqual(modified_symbol, "key(KEY_A).\nrepeat") + + def test_close_autocompletion(self): + self.controller.update_mapping(output_symbol="") + gtk_iteration() + + source_view = self.focus_source_view() + + Gtk.TextView.do_insert_at_cursor(source_view, "KEY_") + + time.sleep(0.11) + gtk_iteration() + + autocompletion = self.user_interface.autocompletion + self.assertTrue(autocompletion.visible) + + self.press_key(Gdk.KEY_Down) + self.press_key(Gdk.KEY_Escape) + + self.assertFalse(autocompletion.visible) + + symbol = self.get_code_input() + self.assertEqual(symbol, "KEY_") + + def test_writing_still_works(self): + self.controller.update_mapping(output_symbol="") + gtk_iteration() + source_view = self.focus_source_view() + + Gtk.TextView.do_insert_at_cursor(source_view, "KEY_") + + autocompletion = self.user_interface.autocompletion + + time.sleep(0.11) + gtk_iteration() + self.assertTrue(autocompletion.visible) + + # writing still works while an entry is selected + self.press_key(Gdk.KEY_Down) + + Gtk.TextView.do_insert_at_cursor(source_view, "A") + + time.sleep(0.11) + gtk_iteration() + self.assertTrue(autocompletion.visible) + + Gtk.TextView.do_insert_at_cursor(source_view, "1234foobar") + + time.sleep(0.11) + gtk_iteration() + # no key matches this completion, so it closes again + self.assertFalse(autocompletion.visible) + + def test_cycling(self): + self.controller.update_mapping(output_symbol="") + gtk_iteration() + source_view = self.focus_source_view() + + Gtk.TextView.do_insert_at_cursor(source_view, "KEY_") + + autocompletion = self.user_interface.autocompletion + + time.sleep(0.11) + gtk_iteration() + self.assertTrue(autocompletion.visible) + + self.assertEqual( + autocompletion.scrolled_window.get_vadjustment().get_value(), 0 + ) + + # cycle to the end of the list because there is no element higher than index 0 + self.press_key(Gdk.KEY_Up) + self.assertGreater( + autocompletion.scrolled_window.get_vadjustment().get_value(), 0 + ) + + # go back to the start, because it can't go down further + self.press_key(Gdk.KEY_Down) + self.assertEqual( + autocompletion.scrolled_window.get_vadjustment().get_value(), 0 + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/system/gui/test_colors.py b/tests/system/gui/test_colors.py new file mode 100644 index 0000000..f591297 --- /dev/null +++ b/tests/system/gui/test_colors.py @@ -0,0 +1,89 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + +import unittest + +import gi + + +gi.require_version("Gdk", "3.0") +gi.require_version("Gtk", "3.0") +gi.require_version("GtkSource", "4") +gi.require_version("GLib", "2.0") +from gi.repository import Gdk + +from inputremapper.gui.utils import Colors + +from tests.lib.test_setup import test_setup +from tests.system.gui.gui_test_base import GuiTestBase + + +@test_setup +class TestColors(GuiTestBase): + # requires a running ui, otherwise fails with segmentation faults + def test_get_color_falls_back(self): + fallback = Gdk.RGBA(0, 0.5, 1, 0.8) + + color = Colors.get_color(["doesnt_exist_1234"], fallback) + + self.assertIsInstance(color, Gdk.RGBA) + self.assertAlmostEqual(color.red, fallback.red, delta=0.01) + self.assertAlmostEqual(color.green, fallback.green, delta=0.01) + self.assertAlmostEqual(color.blue, fallback.blue, delta=0.01) + self.assertAlmostEqual(color.alpha, fallback.alpha, delta=0.01) + + def test_get_color_works(self): + fallback = Gdk.RGBA(1, 0, 1, 0.1) + + color = Colors.get_color( + ["accent_bg_color", "theme_selected_bg_color"], fallback + ) + + self.assertIsInstance(color, Gdk.RGBA) + self.assertNotAlmostEqual(color.red, fallback.red, delta=0.01) + self.assertNotAlmostEqual(color.green, fallback.blue, delta=0.01) + self.assertNotAlmostEqual(color.blue, fallback.green, delta=0.01) + self.assertNotAlmostEqual(color.alpha, fallback.alpha, delta=0.01) + + def _test_color_wont_fallback(self, get_color, fallback): + color = get_color() + self.assertIsInstance(color, Gdk.RGBA) + if ( + (abs(color.green - fallback.green) < 0.01) + and (abs(color.red - fallback.red) < 0.01) + and (abs(color.blue - fallback.blue) < 0.01) + and (abs(color.alpha - fallback.alpha) < 0.01) + ): + raise AssertionError( + f"Color {color.to_string()} is similar to {fallback.toString()}" + ) + + def test_get_colors(self): + self._test_color_wont_fallback(Colors.get_accent_color, Colors.fallback_accent) + self._test_color_wont_fallback(Colors.get_border_color, Colors.fallback_border) + self._test_color_wont_fallback( + Colors.get_background_color, Colors.fallback_background + ) + self._test_color_wont_fallback(Colors.get_base_color, Colors.fallback_base) + self._test_color_wont_fallback(Colors.get_font_color, Colors.fallback_font) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/system/gui/test_components.py b/tests/system/gui/test_components.py new file mode 100644 index 0000000..f606698 --- /dev/null +++ b/tests/system/gui/test_components.py @@ -0,0 +1,1925 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + +import time +import unittest +from typing import Optional, Tuple, Union +from unittest.mock import MagicMock, call + +import evdev +import gi +from evdev.ecodes import KEY_A, KEY_B, KEY_C + +gi.require_version("Gdk", "3.0") +gi.require_version("Gtk", "3.0") +gi.require_version("GLib", "2.0") +gi.require_version("GtkSource", "4") +from gi.repository import Gtk, GLib, GtkSource, Gdk + +from tests.lib.spy import spy +from tests.lib.logger import logger + +from inputremapper.gui.controller import Controller +from inputremapper.configs.keyboard_layout import XKB_KEYCODE_OFFSET +from inputremapper.gui.utils import CTX_ERROR, CTX_WARNING, gtk_iteration +from inputremapper.gui.messages.message_broker import ( + MessageBroker, + MessageType, +) +from inputremapper.gui.messages.message_data import ( + UInputsData, + GroupsData, + GroupData, + PresetData, + StatusData, + CombinationUpdate, + DoStackSwitch, +) +from inputremapper.groups import DeviceType +from inputremapper.gui.components.editor import ( + TargetSelection, + MappingListBox, + MappingSelectionLabel, + CodeEditor, + RecordingToggle, + AutoloadSwitch, + ReleaseCombinationSwitch, + CombinationListbox, + InputConfigEntry, + AnalogInputSwitch, + TriggerThresholdInput, + ReleaseTimeoutInput, + OutputAxisSelector, + KeyAxisStackSwitcher, + Sliders, + TransformationDrawArea, + RelativeInputCutoffInput, + RecordingStatus, + RequireActiveMapping, + GdkEventRecorder, +) +from inputremapper.gui.components.main import Stack, StatusBar +from inputremapper.gui.components.common import FlowBoxEntry, Breadcrumbs +from inputremapper.gui.components.presets import PresetSelection +from inputremapper.gui.components.device_groups import ( + DeviceGroupEntry, + DeviceGroupSelection, +) +from inputremapper.configs.mapping import MappingData +from inputremapper.configs.input_config import InputCombination, InputConfig +from tests.lib.test_setup import test_setup + + +class ComponentBaseTest(unittest.TestCase): + """Test a gui component.""" + + def setUp(self) -> None: + self.message_broker = MessageBroker() + self.controller_mock: Controller = MagicMock() + + def destroy_all_member_widgets(self): + # destroy all Gtk Widgets that are stored in self + # TODO why is this necessary? + for attribute in dir(self): + stuff = getattr(self, attribute, None) + if isinstance(stuff, Gtk.Widget): + logger.info('destroying member "%s" %s', attribute, stuff) + GLib.timeout_add(0, stuff.destroy) + setattr(self, attribute, None) + + def tearDown(self) -> None: + super().tearDown() + self.message_broker.signal(MessageType.terminate) + + # Shut down the gui properly + self.destroy_all_member_widgets() + GLib.timeout_add(0, Gtk.main_quit) + + # Gtk.main() will start the Gtk event loop and process all pending events. + # So the gui will do whatever is queued up this ensures that the next tests + # starts without pending events. + Gtk.main() + + +class FlowBoxTestUtils: + """Methods to test the FlowBoxes that contain presets and devices. + + Those are only used in tests, so I moved them here instead. + """ + + @staticmethod + def set_active(flow_box: Gtk.FlowBox, name: str): + """Change the currently selected group.""" + for child in flow_box.get_children(): + flow_box_entry: FlowBoxEntry = child.get_children()[0] + flow_box_entry.set_active(flow_box_entry.name == name) + + @staticmethod + def get_active_entry(flow_box: Gtk.FlowBox) -> Union[DeviceGroupEntry, None]: + """Find the currently selected DeviceGroupEntry.""" + children = flow_box.get_children() + + if len(children) == 0: + return None + + for child in children: + flow_box_entry: FlowBoxEntry = child.get_children()[0] + + if flow_box_entry.get_active(): + return flow_box_entry + + raise AssertionError("Expected one entry to be selected.") + + @staticmethod + def get_child_names(flow_box: Gtk.FlowBox): + names = [] + for child in flow_box.get_children(): + flow_box_entry: FlowBoxEntry = child.get_children()[0] + names.append(flow_box_entry.name) + + return names + + @staticmethod + def get_child_icons(flow_box: Gtk.FlowBox): + icon_names = [] + for child in flow_box.get_children(): + flow_box_entry: FlowBoxEntry = child.get_children()[0] + icon_names.append(flow_box_entry.icon_name) + + return icon_names + + +@test_setup +class TestDeviceGroupSelection(ComponentBaseTest): + def setUp(self) -> None: + super().setUp() + self.gui = Gtk.FlowBox() + self.selection = DeviceGroupSelection( + self.message_broker, + self.controller_mock, + self.gui, + ) + self.message_broker.publish( + GroupsData( + { + "foo": [DeviceType.GAMEPAD, DeviceType.KEYBOARD], + "bar": [], + "baz": [DeviceType.GRAPHICS_TABLET], + } + ) + ) + + def get_displayed_group_keys_and_icons(self): + """Get a list of all group_keys and icons of the displayed groups.""" + group_keys = [] + icons = [] + for child in self.gui.get_children(): + device_group_entry = child.get_children()[0] + group_keys.append(device_group_entry.group_key) + icons.append(device_group_entry.icon_name) + + return group_keys, icons + + def test_populates_devices(self): + # tests that all devices sent via the broker end up in the gui + group_keys, icons = self.get_displayed_group_keys_and_icons() + self.assertEqual(group_keys, ["foo", "bar", "baz"]) + self.assertEqual(icons, ["input-gaming", None, "input-tablet"]) + + self.message_broker.publish( + GroupsData( + { + "kuu": [DeviceType.KEYBOARD], + "qux": [DeviceType.GAMEPAD], + } + ) + ) + + group_keys, icons = self.get_displayed_group_keys_and_icons() + self.assertEqual(group_keys, ["kuu", "qux"]) + self.assertEqual(icons, ["input-keyboard", "input-gaming"]) + + def test_selects_correct_device(self): + self.message_broker.publish(GroupData("bar", ())) + self.assertEqual(FlowBoxTestUtils.get_active_entry(self.gui).group_key, "bar") + self.message_broker.publish(GroupData("baz", ())) + self.assertEqual(FlowBoxTestUtils.get_active_entry(self.gui).group_key, "baz") + + def test_loads_group(self): + FlowBoxTestUtils.set_active(self.gui, "bar") + self.controller_mock.load_group.assert_called_once_with("bar") + + def test_avoids_infinite_recursion(self): + self.message_broker.publish(GroupData("bar", ())) + self.controller_mock.load_group.assert_not_called() + + +@test_setup +class TestTargetSelection(ComponentBaseTest): + def setUp(self) -> None: + super().setUp() + self.gui = Gtk.ComboBox() + self.selection = TargetSelection( + self.message_broker, self.controller_mock, self.gui + ) + self.message_broker.publish( + UInputsData( + { + "foo": {}, + "bar": {}, + "baz": {}, + } + ) + ) + + def test_populates_devices(self): + names = [row[0] for row in self.gui.get_model()] + self.assertEqual(names, ["foo", "bar", "baz"]) + + self.message_broker.publish( + UInputsData( + { + "kuu": {}, + "qux": {}, + } + ) + ) + names = [row[0] for row in self.gui.get_model()] + self.assertEqual(names, ["kuu", "qux"]) + + def test_updates_mapping(self): + self.gui.set_active_id("baz") + self.controller_mock.update_mapping.assert_called_once_with(target_uinput="baz") + + def test_selects_correct_target(self): + self.message_broker.publish(MappingData(target_uinput="baz")) + self.assertEqual(self.gui.get_active_id(), "baz") + self.message_broker.publish(MappingData(target_uinput="bar")) + self.assertEqual(self.gui.get_active_id(), "bar") + + def test_avoids_infinite_recursion(self): + self.message_broker.publish(MappingData(target_uinput="baz")) + self.controller_mock.update_mapping.assert_not_called() + + +@test_setup +class TestPresetSelection(ComponentBaseTest): + def setUp(self) -> None: + super().setUp() + self.gui = Gtk.FlowBox() + self.selection = PresetSelection( + self.message_broker, self.controller_mock, self.gui + ) + self.message_broker.publish(GroupData("foo", ("preset1", "preset2"))) + + def test_populates_presets(self): + names = FlowBoxTestUtils.get_child_names(self.gui) + self.assertEqual(names, ["preset1", "preset2"]) + + self.message_broker.publish(GroupData("foo", ("preset3", "preset4"))) + names = FlowBoxTestUtils.get_child_names(self.gui) + self.assertEqual(names, ["preset3", "preset4"]) + + def test_selects_preset(self): + self.message_broker.publish( + PresetData( + "preset2", + ( + MappingData( + name="m1", + input_combination=InputCombination( + [InputConfig(type=1, code=2)] + ), + ), + ), + ) + ) + self.assertEqual(FlowBoxTestUtils.get_active_entry(self.gui).name, "preset2") + + self.message_broker.publish( + PresetData( + "preset1", + ( + MappingData( + name="m1", + input_combination=InputCombination( + [InputConfig(type=1, code=2)] + ), + ), + ), + ) + ) + self.assertEqual(FlowBoxTestUtils.get_active_entry(self.gui).name, "preset1") + + def test_avoids_infinite_recursion(self): + self.message_broker.publish( + PresetData( + "preset2", + ( + MappingData( + name="m1", + input_combination=InputCombination( + [InputConfig(type=1, code=2)] + ), + ), + ), + ) + ) + self.controller_mock.load_preset.assert_not_called() + + def test_loads_preset(self): + FlowBoxTestUtils.set_active(self.gui, "preset2") + self.controller_mock.load_preset.assert_called_once_with("preset2") + + +@test_setup +class TestMappingListbox(ComponentBaseTest): + def setUp(self) -> None: + super().setUp() + self.gui = Gtk.ListBox() + self.listbox = MappingListBox( + self.message_broker, self.controller_mock, self.gui + ) + + self.message_broker.publish( + PresetData( + "preset1", + ( + MappingData( + name="mapping1", + input_combination=InputCombination( + [InputConfig(type=1, code=KEY_C)] + ), + ), + MappingData( + name="", + input_combination=InputCombination( + [ + InputConfig(type=1, code=KEY_A), + InputConfig(type=1, code=KEY_B), + ] + ), + ), + MappingData( + name="mapping2", + input_combination=InputCombination( + [InputConfig(type=1, code=KEY_B)] + ), + ), + ), + ) + ) + + def get_selected_row(self) -> MappingSelectionLabel: + for label in self.gui.get_children(): + if label.is_selected(): + return label + + raise Exception("Expected one MappingSelectionLabel to be selected") + + def select_row(self, combination: InputCombination): + def select(label_: MappingSelectionLabel): + if label_.combination == combination: + self.gui.select_row(label_) + + for label in self.gui.get_children(): + select(label) + + def test_populates_listbox(self): + labels = {row.name for row in self.gui.get_children()} + self.assertEqual(labels, {"mapping1", "mapping2", "a + b"}) + + def test_alphanumerically_sorted(self): + labels = [row.name for row in self.gui.get_children()] + self.assertEqual(labels, ["a + b", "mapping1", "mapping2"]) + + def test_activates_correct_row(self): + self.message_broker.publish( + MappingData( + name="mapping1", + input_combination=InputCombination([InputConfig(type=1, code=KEY_C)]), + ) + ) + selected = self.get_selected_row() + self.assertEqual(selected.name, "mapping1") + self.assertEqual( + selected.combination, + InputCombination([InputConfig(type=1, code=KEY_C)]), + ) + + def test_loads_mapping(self): + self.select_row(InputCombination([InputConfig(type=1, code=KEY_B)])) + self.controller_mock.load_mapping.assert_called_once_with( + InputCombination([InputConfig(type=1, code=KEY_B)]) + ) + + def test_avoids_infinite_recursion(self): + self.message_broker.publish( + MappingData( + name="mapping1", + input_combination=InputCombination([InputConfig(type=1, code=KEY_C)]), + ) + ) + self.controller_mock.load_mapping.assert_not_called() + + def test_sorts_empty_mapping_to_bottom(self): + self.message_broker.publish( + PresetData( + "preset1", + ( + MappingData( + name="qux", + input_combination=InputCombination( + [InputConfig(type=1, code=KEY_C)] + ), + ), + MappingData( + name="foo", + input_combination=InputCombination.empty_combination(), + ), + MappingData( + name="bar", + input_combination=InputCombination( + [InputConfig(type=1, code=KEY_B)] + ), + ), + ), + ) + ) + bottom_row: MappingSelectionLabel = self.gui.get_row_at_index(2) + self.assertEqual(bottom_row.combination, InputCombination.empty_combination()) + self.message_broker.publish( + PresetData( + "preset1", + ( + MappingData( + name="foo", + input_combination=InputCombination.empty_combination(), + ), + MappingData( + name="qux", + input_combination=InputCombination( + [InputConfig(type=1, code=KEY_C)] + ), + ), + MappingData( + name="bar", + input_combination=InputCombination( + [InputConfig(type=1, code=KEY_B)] + ), + ), + ), + ) + ) + bottom_row: MappingSelectionLabel = self.gui.get_row_at_index(2) + self.assertEqual(bottom_row.combination, InputCombination.empty_combination()) + + +@test_setup +class TestMappingSelectionLabel(ComponentBaseTest): + def setUp(self) -> None: + super().setUp() + self.gui = Gtk.ListBox() + self.mapping_selection_label = MappingSelectionLabel( + self.message_broker, + self.controller_mock, + "", + InputCombination( + [ + InputConfig(type=1, code=KEY_A), + InputConfig(type=1, code=KEY_B), + ] + ), + ) + self.gui.insert(self.mapping_selection_label, -1) + + def assert_edit_mode(self): + self.assertTrue(self.mapping_selection_label.name_input.get_visible()) + self.assertFalse(self.mapping_selection_label.label.get_visible()) + + def assert_selected(self): + self.assertTrue(self.mapping_selection_label.label.get_visible()) + self.assertFalse(self.mapping_selection_label.name_input.get_visible()) + + def test_repr(self): + self.mapping_selection_label.name = "name" + self.assertIn("name", repr(self.mapping_selection_label)) + self.assertIn("KEY_A", repr(self.mapping_selection_label)) + self.assertIn("KEY_B", repr(self.mapping_selection_label)) + + def test_shows_combination_without_name(self): + self.assertEqual(self.mapping_selection_label.label.get_label(), "a + b") + + def test_shows_name_when_given(self): + self.gui = MappingSelectionLabel( + self.message_broker, + self.controller_mock, + "foo", + InputCombination( + ( + InputConfig(type=1, code=KEY_A), + InputConfig(type=1, code=KEY_B), + ) + ), + ) + self.assertEqual(self.gui.label.get_label(), "foo") + + def test_updates_combination_when_selected(self): + self.gui.select_row(self.mapping_selection_label) + self.assertEqual( + self.mapping_selection_label.combination, + InputCombination( + ( + InputConfig(type=1, code=KEY_A), + InputConfig(type=1, code=KEY_B), + ) + ), + ) + self.message_broker.publish( + CombinationUpdate( + InputCombination( + ( + InputConfig(type=1, code=KEY_A), + InputConfig(type=1, code=KEY_B), + ) + ), + InputCombination([InputConfig(type=1, code=KEY_A)]), + ) + ) + self.assertEqual( + self.mapping_selection_label.combination, + InputCombination([InputConfig(type=1, code=KEY_A)]), + ) + + def test_doesnt_update_combination_when_not_selected(self): + self.assertEqual( + self.mapping_selection_label.combination, + InputCombination( + ( + InputConfig(type=1, code=KEY_A), + InputConfig(type=1, code=KEY_B), + ) + ), + ) + self.message_broker.publish( + CombinationUpdate( + InputCombination( + ( + InputConfig(type=1, code=KEY_A), + InputConfig(type=1, code=KEY_B), + ) + ), + InputCombination([InputConfig(type=1, code=KEY_A)]), + ) + ) + self.assertEqual( + self.mapping_selection_label.combination, + InputCombination( + ( + InputConfig(type=1, code=KEY_A), + InputConfig(type=1, code=KEY_B), + ) + ), + ) + + def test_updates_name_when_mapping_changed_and_combination_matches(self): + self.message_broker.publish( + MappingData( + input_combination=InputCombination( + ( + InputConfig(type=1, code=KEY_A), + InputConfig(type=1, code=KEY_B), + ) + ), + name="foo", + ) + ) + self.assertEqual(self.mapping_selection_label.label.get_label(), "foo") + + def test_ignores_mapping_when_combination_does_not_match(self): + self.message_broker.publish( + MappingData( + input_combination=InputCombination( + ( + InputConfig(type=1, code=KEY_A), + InputConfig(type=1, code=KEY_C), + ) + ), + name="foo", + ) + ) + self.assertEqual(self.mapping_selection_label.label.get_label(), "a + b") + + def test_edit_button_visibility(self): + # start off invisible + self.assertFalse(self.mapping_selection_label.edit_btn.get_visible()) + + # load the mapping associated with the ListBoxRow + self.message_broker.publish( + MappingData( + input_combination=InputCombination( + ( + InputConfig(type=1, code=KEY_A), + InputConfig(type=1, code=KEY_B), + ) + ), + ) + ) + self.assertTrue(self.mapping_selection_label.edit_btn.get_visible()) + + # load a different row + self.message_broker.publish( + MappingData( + input_combination=InputCombination( + ( + InputConfig(type=1, code=KEY_A), + InputConfig(type=1, code=KEY_C), + ) + ), + ) + ) + self.assertFalse(self.mapping_selection_label.edit_btn.get_visible()) + + def test_enter_edit_mode_focuses_name_input(self): + self.message_broker.publish( + MappingData( + input_combination=InputCombination( + ( + InputConfig(type=1, code=KEY_A), + InputConfig(type=1, code=KEY_B), + ) + ), + ) + ) + self.mapping_selection_label.edit_btn.clicked() + self.controller_mock.set_focus.assert_called_once_with( + self.mapping_selection_label.name_input + ) + + def test_enter_edit_mode_updates_visibility(self): + self.message_broker.publish( + MappingData( + input_combination=InputCombination( + ( + InputConfig(type=1, code=KEY_A), + InputConfig(type=1, code=KEY_B), + ) + ), + ) + ) + self.assert_selected() + self.mapping_selection_label.edit_btn.clicked() + self.assert_edit_mode() + self.mapping_selection_label.name_input.activate() # aka hit the return key + self.assert_selected() + + def test_leaves_edit_mode_on_esc(self): + self.message_broker.publish( + MappingData( + input_combination=InputCombination( + ( + InputConfig(type=1, code=KEY_A), + InputConfig(type=1, code=KEY_B), + ) + ), + ) + ) + self.mapping_selection_label.edit_btn.clicked() + self.assert_edit_mode() + self.mapping_selection_label.name_input.set_text("foo") + + event = Gdk.Event() + event.key.keyval = Gdk.KEY_Escape + # send the "key-press-event" + self.mapping_selection_label._on_gtk_rename_abort(None, event.key) + self.assert_selected() + self.assertEqual(self.mapping_selection_label.label.get_text(), "a + b") + self.controller_mock.update_mapping.assert_not_called() + + def test_update_name(self): + self.message_broker.publish( + MappingData( + input_combination=InputCombination( + ( + InputConfig(type=1, code=KEY_A), + InputConfig(type=1, code=KEY_B), + ) + ), + ) + ) + self.mapping_selection_label.edit_btn.clicked() + + self.mapping_selection_label.name_input.set_text("foo") + self.mapping_selection_label.name_input.activate() + self.controller_mock.update_mapping.assert_called_once_with(name="foo") + + def test_name_input_contains_combination_when_name_not_set(self): + self.message_broker.publish( + MappingData( + input_combination=InputCombination( + ( + InputConfig(type=1, code=KEY_A), + InputConfig(type=1, code=KEY_B), + ) + ), + ) + ) + self.mapping_selection_label.edit_btn.clicked() + self.assertEqual(self.mapping_selection_label.name_input.get_text(), "a + b") + + def test_name_input_contains_name(self): + self.message_broker.publish( + MappingData( + input_combination=InputCombination( + ( + InputConfig(type=1, code=KEY_A), + InputConfig(type=1, code=KEY_B), + ) + ), + name="foo", + ) + ) + self.mapping_selection_label.edit_btn.clicked() + self.assertEqual(self.mapping_selection_label.name_input.get_text(), "foo") + + def test_removes_name_when_name_matches_combination(self): + self.message_broker.publish( + MappingData( + input_combination=InputCombination( + ( + InputConfig(type=1, code=KEY_A), + InputConfig(type=1, code=KEY_B), + ) + ), + name="foo", + ) + ) + self.mapping_selection_label.edit_btn.clicked() + self.mapping_selection_label.name_input.set_text("a + b") + self.mapping_selection_label.name_input.activate() + self.controller_mock.update_mapping.assert_called_once_with(name="") + + +@test_setup +class TestGdkEventRecorder(ComponentBaseTest): + def _emit_key(self, window, code, type_): + event = Gdk.Event() + event.type = type_ + event.hardware_keycode = code + XKB_KEYCODE_OFFSET + window.emit("event", event) + gtk_iteration() + + def _emit_button(self, window, button, type_): + event = Gdk.Event() + event.type = type_ + event.button = button + window.emit("event", event) + gtk_iteration() + + def test_records_combinations(self): + label = Gtk.Label() + window = Gtk.Window() + GdkEventRecorder(window, label) + + self._emit_key(window, KEY_A, Gdk.EventType.KEY_PRESS) + self._emit_key(window, KEY_B, Gdk.EventType.KEY_PRESS) + self.assertEqual(label.get_text(), "a + b") + + self._emit_key(window, KEY_A, Gdk.EventType.KEY_RELEASE) + self._emit_key(window, KEY_B, Gdk.EventType.KEY_RELEASE) + self.assertEqual(label.get_text(), "a + b") + + self._emit_key(window, KEY_C, Gdk.EventType.KEY_PRESS) + self.assertEqual(label.get_text(), "c") + + # buttons + self._emit_button(window, Gdk.BUTTON_PRIMARY, Gdk.EventType.BUTTON_PRESS) + self._emit_button(window, Gdk.BUTTON_SECONDARY, Gdk.EventType.BUTTON_PRESS) + self._emit_button(window, Gdk.BUTTON_MIDDLE, Gdk.EventType.BUTTON_PRESS) + # no constants seem to exist, but this is the value that was observed during + # usage: + self._emit_button(window, 8, Gdk.EventType.BUTTON_PRESS) + self._emit_button(window, 9, Gdk.EventType.BUTTON_PRESS) + self.assertEqual( + label.get_text(), + "c + BTN_LEFT + BTN_RIGHT + BTN_MIDDLE + BTN_SIDE + BTN_EXTRA", + ) + + # releasing anything resets the combination + self._emit_button(window, 9, Gdk.EventType.BUTTON_RELEASE) + self._emit_key(window, KEY_A, Gdk.EventType.KEY_PRESS) + self.assertEqual(label.get_text(), "a") + + +@test_setup +class TestCodeEditor(ComponentBaseTest): + def setUp(self) -> None: + super().setUp() + self.gui = GtkSource.View() + self.editor = CodeEditor(self.message_broker, self.controller_mock, self.gui) + # TODO why is mocking this to False needed? + self.controller_mock.is_empty_mapping.return_value = False + + def get_text(self) -> str: + buffer = self.gui.get_buffer() + return buffer.get_text(buffer.get_start_iter(), buffer.get_end_iter(), True) + + def test_shows_output_symbol(self): + self.message_broker.publish(MappingData(output_symbol="foo")) + self.assertEqual(self.get_text(), "foo") + + def test_shows_record_input_first_message_when_mapping_is_empty(self): + self.controller_mock.is_empty_mapping.return_value = True + self.message_broker.publish(MappingData(output_symbol="foo")) + self.assertEqual(self.get_text(), "Record the input first") + + def test_active_when_mapping_is_not_empty(self): + self.message_broker.publish(MappingData(output_symbol="foo")) + self.assertTrue(self.gui.get_sensitive()) + self.assertEqual(self.gui.get_opacity(), 1) + + def test_expands_to_multiline(self): + self.message_broker.publish(MappingData(output_symbol="foo\nbar")) + self.assertIn("multiline", self.gui.get_style_context().list_classes()) + + def test_shows_line_numbers_when_multiline(self): + self.message_broker.publish(MappingData(output_symbol="foo\nbar")) + self.assertTrue(self.gui.get_show_line_numbers()) + + def test_no_multiline_when_macro_not_multiline(self): + self.message_broker.publish(MappingData(output_symbol="foo")) + self.assertNotIn("multiline", self.gui.get_style_context().list_classes()) + + def test_no_line_numbers_macro_not_multiline(self): + self.message_broker.publish(MappingData(output_symbol="foo")) + self.assertFalse(self.gui.get_show_line_numbers()) + + def test_shows_placeholder_when_mapping_has_no_output_symbol(self): + self.message_broker.publish(MappingData()) + self.assertEqual(self.get_text(), self.editor.placeholder) + + # there are no side-effects because the placeholder is inserted: + self.controller_mock.update_mapping.assert_not_called() + + def test_updates_mapping(self): + self.message_broker.publish(MappingData()) + buffer = self.gui.get_buffer() + self.controller_mock.update_mapping.assert_not_called() + buffer.set_text("foo") + call_args_list = self.controller_mock.update_mapping.call_args_list + # this test emits 2 events for whatever reason, the first one with an empty + # symbol. this doesn't actually seem to happen when using it. + self.assertEqual(call_args_list[-1], call(output_symbol="foo")) + + def test_avoids_infinite_recursion_when_loading_mapping(self): + self.message_broker.publish(MappingData(output_symbol="foo")) + self.controller_mock.update_mapping.assert_not_called() + + def test_gets_focus_when_input_recording_finises(self): + self.message_broker.signal(MessageType.recording_finished) + self.controller_mock.set_focus.assert_called_once_with(self.gui) + + def test_placeholder(self): + self.assertEqual(self.get_text(), self.editor.placeholder) + + window = Gtk.Window() + window.add(self.gui) + window.show_all() + + def focus(): + self.gui.grab_focus() + # Do as many iterations as needed to make it work. + gtk_iteration(15) + + def unfocus(): + window.set_focus(None) + gtk_iteration(15) + + # clears the input when we enter the editor widget + focus() + self.assertEqual(self.get_text(), "") + self.assertNotIn("opaque-text", self.gui.get_style_context().list_classes()) + + # adds the placeholder back when we leave it + unfocus() + self.assertEqual(self.get_text(), self.editor.placeholder) + self.assertIn("opaque-text", self.gui.get_style_context().list_classes()) + + # if we enter text and then leave, it won't show the placeholder + focus() + self.assertEqual(self.get_text(), "") + buffer = self.gui.get_buffer() + buffer.set_text("foo") + self.assertNotIn("opaque-text", self.gui.get_style_context().list_classes()) + unfocus() + self.assertEqual(self.get_text(), "foo") + self.assertNotIn("opaque-text", self.gui.get_style_context().list_classes()) + + +@test_setup +class TestRecordingToggle(ComponentBaseTest): + def setUp(self) -> None: + super().setUp() + + self.toggle_button = Gtk.ToggleButton() + self.recording_toggle = RecordingToggle( + self.message_broker, + self.controller_mock, + self.toggle_button, + ) + + self.label = Gtk.Label() + self.recording_status = RecordingStatus(self.message_broker, self.label) + + def assert_not_recording(self): + self.assertFalse(self.label.get_visible()) + self.assertFalse(self.toggle_button.get_active()) + + def test_starts_recording(self): + self.toggle_button.set_active(True) + self.controller_mock.start_key_recording.assert_called_once() + + def test_stops_recording_when_clicked(self): + self.toggle_button.set_active(True) + self.toggle_button.set_active(False) + self.controller_mock.stop_key_recording.assert_called_once() + + def test_not_recording_initially(self): + self.assert_not_recording() + + def test_shows_recording_when_message_sent(self): + self.assertFalse(self.label.get_visible()) + self.message_broker.signal(MessageType.recording_started) + self.assertTrue(self.label.get_visible()) + + def test_shows_not_recording_after_toggle(self): + self.toggle_button.set_active(True) + self.toggle_button.set_active(False) + self.assert_not_recording() + + def test_shows_not_recording_when_recording_finished(self): + self.toggle_button.set_active(True) + self.message_broker.signal(MessageType.recording_finished) + self.assert_not_recording() + + +@test_setup +class TestStatusBar(ComponentBaseTest): + def setUp(self) -> None: + super().setUp() + self.gui = Gtk.Statusbar() + self.err_icon = Gtk.Image() + self.warn_icon = Gtk.Image() + self.statusbar = StatusBar( + self.message_broker, + self.controller_mock, + self.gui, + self.err_icon, + self.warn_icon, + ) + self.message_broker.signal(MessageType.init) + + def assert_empty(self): + self.assertFalse(self.err_icon.get_visible()) + self.assertFalse(self.warn_icon.get_visible()) + self.assertEqual(self.get_text(), "") + self.assertIsNone(self.get_tooltip()) + + def assert_error_status(self): + self.assertTrue(self.err_icon.get_visible()) + self.assertFalse(self.warn_icon.get_visible()) + + def assert_warning_status(self): + self.assertFalse(self.err_icon.get_visible()) + self.assertTrue(self.warn_icon.get_visible()) + + def get_text(self) -> str: + return self.gui.get_message_area().get_children()[0].get_text() + + def get_tooltip(self) -> Optional[str]: + return self.gui.get_tooltip_text() + + def test_starts_empty(self): + self.assert_empty() + + def test_shows_error_status(self): + self.message_broker.publish(StatusData(CTX_ERROR, "msg", "tooltip")) + self.assertEqual(self.get_text(), "msg") + self.assertEqual(self.get_tooltip(), "tooltip") + self.assert_error_status() + + def test_shows_warning_status(self): + self.message_broker.publish(StatusData(CTX_WARNING, "msg", "tooltip")) + self.assertEqual(self.get_text(), "msg") + self.assertEqual(self.get_tooltip(), "tooltip") + self.assert_warning_status() + + def test_shows_newest_message(self): + self.message_broker.publish(StatusData(CTX_ERROR, "msg", "tooltip")) + self.message_broker.publish(StatusData(CTX_WARNING, "msg2", "tooltip2")) + self.assertEqual(self.get_text(), "msg2") + self.assertEqual(self.get_tooltip(), "tooltip2") + self.assert_warning_status() + + def test_data_without_message_removes_messages(self): + self.message_broker.publish(StatusData(CTX_WARNING, "msg", "tooltip")) + self.message_broker.publish(StatusData(CTX_WARNING, "msg2", "tooltip2")) + self.message_broker.publish(StatusData(CTX_WARNING)) + self.assert_empty() + + def test_restores_message_from_not_removed_ctx_id(self): + self.message_broker.publish(StatusData(CTX_ERROR, "msg", "tooltip")) + self.message_broker.publish(StatusData(CTX_WARNING, "msg2", "tooltip2")) + self.message_broker.publish(StatusData(CTX_WARNING)) + self.assertEqual(self.get_text(), "msg") + self.assert_error_status() + + # works also the other way round + self.message_broker.publish(StatusData(CTX_ERROR)) + self.message_broker.publish(StatusData(CTX_WARNING, "msg", "tooltip")) + self.message_broker.publish(StatusData(CTX_ERROR, "msg2", "tooltip2")) + self.message_broker.publish(StatusData(CTX_ERROR)) + self.assertEqual(self.get_text(), "msg") + self.assert_warning_status() + + def test_sets_msg_as_tooltip_if_tooltip_is_none(self): + self.message_broker.publish(StatusData(CTX_ERROR, "msg")) + self.assertEqual(self.get_tooltip(), "msg") + + +@test_setup +class TestAutoloadSwitch(ComponentBaseTest): + def setUp(self) -> None: + super().setUp() + self.gui = Gtk.Switch() + self.switch = AutoloadSwitch( + self.message_broker, self.controller_mock, self.gui + ) + + def test_sets_autoload(self): + self.gui.set_active(True) + self.controller_mock.set_autoload.assert_called_once_with(True) + self.controller_mock.reset_mock() + self.gui.set_active(False) + self.controller_mock.set_autoload.assert_called_once_with(False) + + def test_updates_state(self): + self.message_broker.publish(PresetData(None, None, autoload=True)) + self.assertTrue(self.gui.get_active()) + self.message_broker.publish(PresetData(None, None, autoload=False)) + self.assertFalse(self.gui.get_active()) + + def test_avoids_infinite_recursion(self): + self.message_broker.publish(PresetData(None, None, autoload=True)) + self.message_broker.publish(PresetData(None, None, autoload=False)) + self.controller_mock.set_autoload.assert_not_called() + + +@test_setup +class TestReleaseCombinationSwitch(ComponentBaseTest): + def setUp(self) -> None: + super().setUp() + self.gui = Gtk.Switch() + self.switch = ReleaseCombinationSwitch( + self.message_broker, self.controller_mock, self.gui + ) + + def test_updates_mapping(self): + self.gui.set_active(True) + self.controller_mock.update_mapping.assert_called_once_with( + release_combination_keys=True + ) + self.controller_mock.reset_mock() + self.gui.set_active(False) + self.controller_mock.update_mapping.assert_called_once_with( + release_combination_keys=False + ) + + def test_updates_state(self): + self.message_broker.publish(MappingData(release_combination_keys=True)) + self.assertTrue(self.gui.get_active()) + self.message_broker.publish(MappingData(release_combination_keys=False)) + self.assertFalse(self.gui.get_active()) + + def test_avoids_infinite_recursion(self): + self.message_broker.publish(MappingData(release_combination_keys=True)) + self.message_broker.publish(MappingData(release_combination_keys=False)) + self.controller_mock.update_mapping.assert_not_called() + + +@test_setup +class TestEventEntry(ComponentBaseTest): + def setUp(self) -> None: + super().setUp() + self.gui = InputConfigEntry( + InputConfig(type=3, code=0, analog_threshold=1), self.controller_mock + ) + + def test_move_event(self): + self.gui._up_btn.clicked() + self.controller_mock.move_input_config_in_combination.assert_called_once_with( + InputConfig(type=3, code=0, analog_threshold=1), "up" + ) + self.controller_mock.reset_mock() + + self.gui._down_btn.clicked() + self.controller_mock.move_input_config_in_combination.assert_called_once_with( + InputConfig(type=3, code=0, analog_threshold=1), "down" + ) + + +@test_setup +class TestCombinationListbox(ComponentBaseTest): + def setUp(self) -> None: + super().setUp() + self.gui = Gtk.ListBox() + self.listbox = CombinationListbox( + self.message_broker, self.controller_mock, self.gui + ) + self.controller_mock.is_empty_mapping.return_value = False + combination = InputCombination( + ( + InputConfig(type=1, code=1), + InputConfig(type=3, code=0, analog_threshold=1), + InputConfig(type=1, code=2), + ) + ) + self.message_broker.publish( + MappingData( + input_combination=combination.to_config(), target_uinput="keyboard" + ) + ) + + def get_selected_row(self) -> InputConfigEntry: + for entry in self.gui.get_children(): + if entry.is_selected(): + return entry + + raise Exception("Expected one InputConfigEntry to be selected") + + def select_row(self, input_cfg: InputConfig): + for entry in self.gui.get_children(): + if entry.input_event == input_cfg: + self.gui.select_row(entry) + + def test_loads_selected_row(self): + self.select_row(InputConfig(type=1, code=2)) + self.controller_mock.load_input_config.assert_called_once_with( + InputConfig(type=1, code=2) + ) + + def test_does_not_create_rows_when_mapping_is_empty(self): + self.controller_mock.is_empty_mapping.return_value = True + combination = InputCombination( + ( + InputConfig(type=1, code=1), + InputConfig(type=3, code=0, analog_threshold=1), + ) + ) + self.message_broker.publish(MappingData(input_combination=combination)) + self.assertEqual(len(self.gui.get_children()), 0) + + def test_selects_row_when_selected_event_message_arrives(self): + self.message_broker.publish(InputConfig(type=3, code=0, analog_threshold=1)) + self.assertEqual( + self.get_selected_row().input_event, + InputConfig(type=3, code=0, analog_threshold=1), + ) + + def test_avoids_infinite_recursion(self): + self.message_broker.publish(InputConfig(type=3, code=0, analog_threshold=1)) + self.controller_mock.load_event.assert_not_called() + + +@test_setup +class TestAnalogInputSwitch(ComponentBaseTest): + def setUp(self) -> None: + super().setUp() + self.gui = Gtk.Switch() + self.switch = AnalogInputSwitch( + self.message_broker, self.controller_mock, self.gui + ) + + def test_updates_event_as_analog(self): + self.gui.set_active(True) + self.controller_mock.set_event_as_analog.assert_called_once_with(True) + self.controller_mock.reset_mock() + self.gui.set_active(False) + self.controller_mock.set_event_as_analog.assert_called_once_with(False) + + def test_updates_state(self): + self.message_broker.publish(InputConfig(type=3, code=0)) + self.assertTrue(self.gui.get_active()) + self.message_broker.publish(InputConfig(type=3, code=0, analog_threshold=10)) + self.assertFalse(self.gui.get_active()) + + def test_avoids_infinite_recursion(self): + self.message_broker.publish(InputConfig(type=3, code=0)) + self.message_broker.publish(InputConfig(type=3, code=0, analog_threshold=-10)) + self.controller_mock.set_event_as_analog.assert_not_called() + + def test_disables_switch_when_key_event(self): + self.message_broker.publish(InputConfig(type=1, code=1)) + self.assertLess(self.gui.get_opacity(), 0.6) + self.assertFalse(self.gui.get_sensitive()) + + def test_enables_switch_when_axis_event(self): + self.message_broker.publish(InputConfig(type=1, code=1)) + self.message_broker.publish(InputConfig(type=3, code=0, analog_threshold=10)) + self.assertEqual(self.gui.get_opacity(), 1) + self.assertTrue(self.gui.get_sensitive()) + + self.message_broker.publish(InputConfig(type=1, code=1)) + self.message_broker.publish(InputConfig(type=2, code=0, analog_threshold=10)) + self.assertEqual(self.gui.get_opacity(), 1) + self.assertTrue(self.gui.get_sensitive()) + + +@test_setup +class TestTriggerThresholdInput(ComponentBaseTest): + def setUp(self) -> None: + super().setUp() + self.gui = Gtk.SpinButton() + self.input = TriggerThresholdInput( + self.message_broker, self.controller_mock, self.gui + ) + self.message_broker.publish(InputConfig(type=3, code=0, analog_threshold=-10)) + + def assert_abs_event_config(self): + self.assertEqual(self.gui.get_range(), (-99, 99)) + self.assertTrue(self.gui.get_sensitive()) + self.assertEqual(self.gui.get_opacity(), 1) + + def assert_rel_event_config(self): + self.assertEqual(self.gui.get_range(), (-999, 999)) + self.assertTrue(self.gui.get_sensitive()) + self.assertEqual(self.gui.get_opacity(), 1) + + def assert_key_event_config(self): + self.assertFalse(self.gui.get_sensitive()) + self.assertLess(self.gui.get_opacity(), 0.6) + + def test_updates_event(self): + self.gui.set_value(15) + self.controller_mock.update_input_config.assert_called_once_with( + InputConfig(type=3, code=0, analog_threshold=15) + ) + + def test_sets_value_on_selected_event_message(self): + self.message_broker.publish(InputConfig(type=3, code=0, analog_threshold=10)) + self.assertEqual(self.gui.get_value(), 10) + + def test_avoids_infinite_recursion(self): + self.message_broker.publish(InputConfig(type=3, code=0, analog_threshold=10)) + self.controller_mock.update_input_config.assert_not_called() + + def test_updates_configuration_according_to_selected_event(self): + self.assert_abs_event_config() + self.message_broker.publish(InputConfig(type=2, code=0, analog_threshold=-10)) + self.assert_rel_event_config() + self.message_broker.publish(InputConfig(type=1, code=1)) + self.assert_key_event_config() + + +@test_setup +class TestReleaseTimeoutInput(ComponentBaseTest): + def setUp(self) -> None: + super().setUp() + self.gui = Gtk.SpinButton() + self.input = ReleaseTimeoutInput( + self.message_broker, self.controller_mock, self.gui + ) + self.message_broker.publish( + MappingData( + input_combination=InputCombination( + [InputConfig(type=2, code=0, analog_threshold=1)] + ), + target_uinput="keyboard", + ) + ) + + def test_updates_timeout_on_mapping_message(self): + self.message_broker.publish( + MappingData( + input_combination=InputCombination( + [InputConfig(type=2, code=0, analog_threshold=1)] + ), + release_timeout=1, + ) + ) + self.assertEqual(self.gui.get_value(), 1) + + def test_updates_mapping(self): + self.gui.set_value(0.5) + self.controller_mock.update_mapping.assert_called_once_with(release_timeout=0.5) + + def test_avoids_infinite_recursion(self): + self.message_broker.publish( + MappingData( + input_combination=InputCombination( + [InputConfig(type=2, code=0, analog_threshold=1)] + ), + release_timeout=1, + ) + ) + self.controller_mock.update_mapping.assert_not_called() + + def test_disables_input_based_on_input_combination(self): + self.message_broker.publish( + MappingData( + input_combination=InputCombination( + ( + InputConfig(type=2, code=0, analog_threshold=1), + InputConfig(type=1, code=1), + ) + ) + ) + ) + self.assertTrue(self.gui.get_sensitive()) + self.assertEqual(self.gui.get_opacity(), 1) + + self.message_broker.publish( + MappingData( + input_combination=InputCombination( + ( + InputConfig(type=1, code=1), + InputConfig(type=1, code=2), + ) + ) + ) + ) + self.assertFalse(self.gui.get_sensitive()) + self.assertLess(self.gui.get_opacity(), 0.6) + + self.message_broker.publish( + MappingData( + input_combination=InputCombination( + ( + InputConfig(type=2, code=0, analog_threshold=1), + InputConfig(type=1, code=1), + ) + ) + ) + ) + self.message_broker.publish( + MappingData( + input_combination=InputCombination( + ( + InputConfig(type=3, code=0, analog_threshold=1), + InputConfig( + type=1, + code=2, + ), + ) + ) + ) + ) + self.assertFalse(self.gui.get_sensitive()) + self.assertLess(self.gui.get_opacity(), 0.6) + + +@test_setup +class TestOutputAxisSelector(ComponentBaseTest): + def setUp(self) -> None: + super().setUp() + self.gui = Gtk.ComboBox() + self.selection = OutputAxisSelector( + self.message_broker, self.controller_mock, self.gui + ) + absinfo = evdev.AbsInfo(0, -10, 10, 0, 0, 0) + self.message_broker.publish( + UInputsData( + { + "mouse": {1: [1, 2, 3, 4], 2: [0, 1, 2, 3]}, + "keyboard": {1: [1, 2, 3, 4]}, + "gamepad": { + 2: [0, 1, 2, 3], + 3: [(0, absinfo), (1, absinfo), (2, absinfo), (3, absinfo)], + }, + } + ) + ) + self.message_broker.publish( + MappingData( + target_uinput="mouse", + input_combination=InputCombination([InputConfig(type=1, code=1)]), + ) + ) + + def set_active_selection(self, selection: Tuple): + self.gui.set_active_id(f"{selection[0]}, {selection[1]}") + + def get_active_selection(self) -> Tuple[int, int]: + return tuple(int(i) for i in self.gui.get_active_id().split(",")) # type: ignore + + def test_updates_mapping(self): + self.set_active_selection((2, 0)) + self.controller_mock.update_mapping.assert_called_once_with( + output_type=2, output_code=0 + ) + + def test_updates_mapping_with_none(self): + self.set_active_selection((2, 0)) + self.controller_mock.reset_mock() + self.set_active_selection((None, None)) + self.controller_mock.update_mapping.assert_called_once_with( + output_type=None, output_code=None + ) + + def test_selects_correct_entry(self): + self.assertEqual(self.gui.get_active_id(), "None, None") + self.message_broker.publish( + MappingData(target_uinput="mouse", output_type=2, output_code=3) + ) + self.assertEqual(self.get_active_selection(), (2, 3)) + + def test_avoids_infinite_recursion(self): + self.message_broker.publish( + MappingData(target_uinput="mouse", output_type=2, output_code=3) + ) + self.controller_mock.update_mapping.assert_not_called() + + def test_updates_dropdown_model(self): + self.assertEqual(len(self.gui.get_model()), 5) + self.message_broker.publish(MappingData(target_uinput="keyboard")) + self.assertEqual(len(self.gui.get_model()), 1) + self.message_broker.publish(MappingData(target_uinput="gamepad")) + self.assertEqual(len(self.gui.get_model()), 9) + + +@test_setup +class TestKeyAxisStackSwitcher(ComponentBaseTest): + def setUp(self) -> None: + super().setUp() + self.gui = Gtk.Box() + self.gtk_stack = Gtk.Stack() + self.analog_toggle = Gtk.ToggleButton() + self.key_toggle = Gtk.ToggleButton() + + self.gui.add(self.gtk_stack) + self.gui.add(self.analog_toggle) + self.gui.add(self.key_toggle) + self.gtk_stack.add_named(Gtk.Box(), "Analog Axis") + self.gtk_stack.add_named(Gtk.Box(), "Key or Macro") + + self.stack = KeyAxisStackSwitcher( + self.message_broker, + self.controller_mock, + self.gtk_stack, + self.key_toggle, + self.analog_toggle, + ) + + self.gui.show_all() + self.gtk_stack.set_visible_child_name("Key or Macro") + + def assert_key_macro_active(self): + self.assertEqual(self.gtk_stack.get_visible_child_name(), "Key or Macro") + self.assertTrue(self.key_toggle.get_active()) + self.assertFalse(self.analog_toggle.get_active()) + + def assert_analog_active(self): + self.assertEqual(self.gtk_stack.get_visible_child_name(), "Analog Axis") + self.assertFalse(self.key_toggle.get_active()) + self.assertTrue(self.analog_toggle.get_active()) + + def test_switches_to_axis(self): + self.message_broker.publish(MappingData(mapping_type="analog")) + self.assert_analog_active() + + def test_switches_to_key_macro(self): + self.message_broker.publish(MappingData(mapping_type="analog")) + self.message_broker.publish(MappingData(mapping_type="key_macro")) + self.assert_key_macro_active() + + def test_updates_mapping_type(self): + self.key_toggle.set_active(True) + self.controller_mock.update_mapping.assert_called_once_with( + mapping_type="key_macro" + ) + self.controller_mock.update_mapping.reset_mock() + + self.analog_toggle.set_active(True) + self.controller_mock.update_mapping.assert_called_once_with( + mapping_type="analog" + ) + + def test_avoids_infinite_recursion(self): + self.message_broker.publish(MappingData(mapping_type="analog")) + self.message_broker.publish(MappingData(mapping_type="key_macro")) + self.controller_mock.update_mapping.assert_not_called() + + +@test_setup +class TestTransformationDrawArea(ComponentBaseTest): + def setUp(self) -> None: + super().setUp() + self.gui = Gtk.Window() + self.draw_area = Gtk.DrawingArea() + self.gui.add(self.draw_area) + self.transform_draw_area = TransformationDrawArea( + self.message_broker, + self.controller_mock, + self.draw_area, + ) + + def test_draws_transform(self): + with spy(self.transform_draw_area, "_transformation") as mock: + # show the window, it takes some time and iterations until it pops up + self.gui.show_all() + for _ in range(5): + gtk_iteration() + time.sleep(0.01) + + mock.assert_called() + + def test_updates_transform_when_mapping_updates(self): + old_tf = self.transform_draw_area._transformation + self.message_broker.publish(MappingData(gain=2)) + self.assertIsNot(old_tf, self.transform_draw_area._transformation) + + def test_redraws_when_mapping_updates(self): + self.gui.show_all() + gtk_iteration(20) + mock = MagicMock() + self.draw_area.connect("draw", mock) + self.message_broker.publish(MappingData(gain=2)) + gtk_iteration(20) + mock.assert_called() + + +@test_setup +class TestSliders(ComponentBaseTest): + def setUp(self) -> None: + super().setUp() + self.gui = Gtk.Box() + self.gain = Gtk.Scale() + self.deadzone = Gtk.Scale() + self.expo = Gtk.Scale() + + # add everything to a box: it will be cleand up properly + self.gui.add(self.gain) + self.gui.add(self.deadzone) + self.gui.add(self.expo) + + self.sliders = Sliders( + self.message_broker, + self.controller_mock, + self.gain, + self.deadzone, + self.expo, + ) + self.message_broker.publish( + MappingData( + input_combination=InputCombination([InputConfig(type=3, code=0)]), + target_uinput="mouse", + ) + ) + + @staticmethod + def get_range(range: Gtk.Range) -> Tuple[int, int]: + """the Gtk.Range, has no get_range method. this is a workaround""" + v = range.get_value() + range.set_value(-(2**16)) + min_ = range.get_value() + range.set_value(2**16) + max_ = range.get_value() + range.set_value(v) + return min_, max_ + + def test_slider_ranges(self): + self.assertEqual(self.get_range(self.gain), (-2, 2)) + self.assertEqual(self.get_range(self.deadzone), (0, 0.9)) + self.assertEqual(self.get_range(self.expo), (-1, 1)) + + def test_updates_value(self): + self.message_broker.publish( + MappingData( + gain=0.5, + deadzone=0.6, + expo=0.3, + ) + ) + self.assertEqual(self.gain.get_value(), 0.5) + self.assertEqual(self.expo.get_value(), 0.3) + self.assertEqual(self.deadzone.get_value(), 0.6) + + def test_gain_updates_mapping(self): + self.gain.set_value(0.5) + self.controller_mock.update_mapping.assert_called_once_with(gain=0.5) + + def test_expo_updates_mapping(self): + self.expo.set_value(0.5) + self.controller_mock.update_mapping.assert_called_once_with(expo=0.5) + + def test_deadzone_updates_mapping(self): + self.deadzone.set_value(0.5) + self.controller_mock.update_mapping.assert_called_once_with(deadzone=0.5) + + def test_avoids_recursion(self): + self.message_broker.publish(MappingData(gain=0.5)) + self.controller_mock.update_mapping.assert_not_called() + self.message_broker.publish(MappingData(expo=0.5)) + self.controller_mock.update_mapping.assert_not_called() + self.message_broker.publish(MappingData(deadzone=0.5)) + self.controller_mock.update_mapping.assert_not_called() + + +@test_setup +class TestRelativeInputCutoffInput(ComponentBaseTest): + def setUp(self) -> None: + super().setUp() + self.gui = Gtk.SpinButton() + self.input = RelativeInputCutoffInput( + self.message_broker, self.controller_mock, self.gui + ) + self.message_broker.publish( + MappingData( + target_uinput="mouse", + input_combination=InputCombination([InputConfig(type=2, code=0)]), + rel_to_abs_input_cutoff=1, + output_type=3, + output_code=0, + ) + ) + + def assert_active(self): + self.assertTrue(self.gui.get_sensitive()) + self.assertEqual(self.gui.get_opacity(), 1) + + def assert_inactive(self): + self.assertFalse(self.gui.get_sensitive()) + self.assertLess(self.gui.get_opacity(), 0.6) + + def test_avoids_infinite_recursion(self): + self.message_broker.publish( + MappingData( + target_uinput="mouse", + input_combination=InputCombination([InputConfig(type=2, code=0)]), + rel_to_abs_input_cutoff=3, + output_type=3, + output_code=0, + ) + ) + self.controller_mock.update_mapping.assert_not_called() + + def test_updates_value(self): + rel_to_abs_input_cutoff = 3 + self.message_broker.publish( + MappingData( + target_uinput="mouse", + input_combination=InputCombination([InputConfig(type=2, code=0)]), + rel_to_abs_input_cutoff=rel_to_abs_input_cutoff, + output_type=3, + output_code=0, + ) + ) + self.assertEqual(self.gui.get_value(), rel_to_abs_input_cutoff) + + def test_updates_mapping(self): + self.gui.set_value(300) + self.controller_mock.update_mapping.assert_called_once_with(rel_xy_cutoff=300) + + def test_disables_input_when_no_rel_axis_input(self): + self.assert_active() + self.message_broker.publish( + MappingData( + target_uinput="mouse", + input_combination=InputCombination([InputConfig(type=3, code=0)]), + output_type=3, + output_code=0, + ) + ) + self.assert_inactive() + + def test_disables_input_when_no_abs_axis_output(self): + self.assert_active() + self.message_broker.publish( + MappingData( + target_uinput="mouse", + input_combination=InputCombination([InputConfig(type=2, code=0)]), + rel_to_abs_input_cutoff=3, + output_type=2, + output_code=0, + ) + ) + self.assert_inactive() + + def test_enables_input(self): + self.message_broker.publish( + MappingData( + target_uinput="mouse", + input_combination=InputCombination([InputConfig(type=3, code=0)]), + output_type=3, + output_code=0, + ) + ) + self.assert_inactive() + self.message_broker.publish( + MappingData( + target_uinput="mouse", + input_combination=InputCombination([InputConfig(type=2, code=0)]), + rel_to_abs_input_cutoff=1, + output_type=3, + output_code=0, + ) + ) + self.assert_active() + + +@test_setup +class TestRequireActiveMapping(ComponentBaseTest): + def test_no_reqorded_input_required(self): + self.box = Gtk.Box() + RequireActiveMapping( + self.message_broker, + self.box, + require_recorded_input=False, + ) + combination = InputCombination([InputConfig(type=1, code=KEY_A)]) + + self.message_broker.publish(MappingData()) + self.assert_inactive(self.box) + + self.message_broker.publish(PresetData(name="preset", mappings=())) + self.assert_inactive(self.box) + + # a mapping is available, that is all the widget needs to be activated. one + # mapping is always selected, so there is no need to check the mapping message + self.message_broker.publish(PresetData(name="preset", mappings=(combination,))) + self.assert_active(self.box) + + self.message_broker.publish(MappingData(input_combination=combination)) + self.assert_active(self.box) + + self.message_broker.publish(MappingData()) + self.assert_active(self.box) + + def test_recorded_input_required(self): + self.box = Gtk.Box() + RequireActiveMapping( + self.message_broker, + self.box, + require_recorded_input=True, + ) + combination = InputCombination([InputConfig(type=1, code=KEY_A)]) + + self.message_broker.publish(MappingData()) + self.assert_inactive(self.box) + + self.message_broker.publish(PresetData(name="preset", mappings=())) + self.assert_inactive(self.box) + + self.message_broker.publish(PresetData(name="preset", mappings=(combination,))) + self.assert_inactive(self.box) + + # the widget will be enabled once a mapping with recorded input is selected + self.message_broker.publish(MappingData(input_combination=combination)) + self.assert_active(self.box) + + # this mapping doesn't have input recorded, so the box is disabled + self.message_broker.publish(MappingData()) + self.assert_inactive(self.box) + + def assert_inactive(self, widget: Gtk.Widget): + self.assertFalse(widget.get_sensitive()) + self.assertLess(widget.get_opacity(), 0.6) + self.assertGreater(widget.get_opacity(), 0.4) + + def assert_active(self, widget: Gtk.Widget): + self.assertTrue(widget.get_sensitive()) + self.assertEqual(widget.get_opacity(), 1) + + +@test_setup +class TestStack(ComponentBaseTest): + def test_switches_pages(self): + self.stack = Gtk.Stack() + self.stack.add_named(Gtk.Label(), "Devices") + self.stack.add_named(Gtk.Label(), "Presets") + self.stack.add_named(Gtk.Label(), "Editor") + self.stack.show_all() + stack_wrapper = Stack(self.message_broker, self.controller_mock, self.stack) + + self.message_broker.publish(DoStackSwitch(Stack.devices_page)) + self.assertEqual(self.stack.get_visible_child_name(), "Devices") + + self.message_broker.publish(DoStackSwitch(Stack.presets_page)) + self.assertEqual(self.stack.get_visible_child_name(), "Presets") + + self.message_broker.publish(DoStackSwitch(Stack.editor_page)) + self.assertEqual(self.stack.get_visible_child_name(), "Editor") + + +@test_setup +class TestBreadcrumbs(ComponentBaseTest): + def test_breadcrumbs(self): + self.label_1 = Gtk.Label() + self.label_2 = Gtk.Label() + self.label_3 = Gtk.Label() + self.label_4 = Gtk.Label() + self.label_5 = Gtk.Label() + + Breadcrumbs( + self.message_broker, + self.label_1, + show_device_group=False, + show_preset=False, + show_mapping=False, + ) + Breadcrumbs( + self.message_broker, + self.label_2, + show_device_group=True, + show_preset=False, + show_mapping=False, + ) + Breadcrumbs( + self.message_broker, + self.label_3, + show_device_group=True, + show_preset=True, + show_mapping=False, + ) + Breadcrumbs( + self.message_broker, + self.label_4, + show_device_group=True, + show_preset=True, + show_mapping=True, + ) + Breadcrumbs( + self.message_broker, + self.label_5, + show_device_group=False, + show_preset=False, + show_mapping=True, + ) + + self.assertEqual(self.label_1.get_text(), "") + self.assertEqual(self.label_2.get_text(), "?") + self.assertEqual(self.label_3.get_text(), "? / ?") + self.assertEqual(self.label_4.get_text(), "? / ? / ?") + self.assertEqual(self.label_5.get_text(), "?") + + self.message_broker.publish(PresetData("preset", None)) + + self.assertEqual(self.label_1.get_text(), "") + self.assertEqual(self.label_2.get_text(), "?") + self.assertEqual(self.label_3.get_text(), "? / preset") + self.assertEqual(self.label_4.get_text(), "? / preset / ?") + self.assertEqual(self.label_5.get_text(), "?") + + self.message_broker.publish(GroupData("group", ())) + + self.assertEqual(self.label_1.get_text(), "") + self.assertEqual(self.label_2.get_text(), "group") + self.assertEqual(self.label_3.get_text(), "group / preset") + self.assertEqual(self.label_4.get_text(), "group / preset / ?") + self.assertEqual(self.label_5.get_text(), "?") + + self.message_broker.publish(MappingData()) + + self.assertEqual(self.label_1.get_text(), "") + self.assertEqual(self.label_2.get_text(), "group") + self.assertEqual(self.label_3.get_text(), "group / preset") + self.assertEqual(self.label_4.get_text(), "group / preset / Empty Mapping") + self.assertEqual(self.label_5.get_text(), "Empty Mapping") + + self.message_broker.publish(MappingData(name="mapping")) + self.assertEqual(self.label_4.get_text(), "group / preset / mapping") + self.assertEqual(self.label_5.get_text(), "mapping") + + combination = InputCombination( + ( + InputConfig(type=1, code=KEY_A), + InputConfig(type=1, code=KEY_B), + ) + ) + self.message_broker.publish(MappingData(input_combination=combination)) + self.assertEqual(self.label_4.get_text(), "group / preset / a + b") + self.assertEqual(self.label_5.get_text(), "a + b") + + combination = InputCombination([InputConfig(type=1, code=KEY_A)]) + self.message_broker.publish( + MappingData(name="qux", input_combination=combination) + ) + self.assertEqual(self.label_4.get_text(), "group / preset / qux") + self.assertEqual(self.label_5.get_text(), "qux") diff --git a/tests/system/gui/test_debounce.py b/tests/system/gui/test_debounce.py new file mode 100644 index 0000000..0f71568 --- /dev/null +++ b/tests/system/gui/test_debounce.py @@ -0,0 +1,149 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + +import time +import unittest + +import gi + + +gi.require_version("Gdk", "3.0") +gi.require_version("Gtk", "3.0") +gi.require_version("GtkSource", "4") +gi.require_version("GLib", "2.0") + +from inputremapper.gui.utils import gtk_iteration, debounce, debounce_manager + +from tests.lib.test_setup import test_setup + + +@test_setup +class TestDebounce(unittest.TestCase): + def test_debounce(self): + calls = 0 + + class A: + @debounce(20) + def foo(self): + nonlocal calls + calls += 1 + + # two methods with the same name don't confuse debounce + class B: + @debounce(20) + def foo(self): + nonlocal calls + calls += 1 + + a = A() + b = B() + + self.assertEqual(calls, 0) + + a.foo() + gtk_iteration() + self.assertEqual(calls, 0) + + b.foo() + gtk_iteration() + self.assertEqual(calls, 0) + + time.sleep(0.021) + gtk_iteration() + self.assertEqual(calls, 2) + + a.foo() + b.foo() + a.foo() + b.foo() + gtk_iteration() + self.assertEqual(calls, 2) + + time.sleep(0.021) + gtk_iteration() + self.assertEqual(calls, 4) + + def test_run_all_now(self): + calls = 0 + + class A: + @debounce(20) + def foo(self): + nonlocal calls + calls += 1 + + a = A() + a.foo() + gtk_iteration() + self.assertEqual(calls, 0) + + debounce_manager.run_all_now() + self.assertEqual(calls, 1) + + # waiting for some time will not call it again + time.sleep(0.021) + gtk_iteration() + self.assertEqual(calls, 1) + + def test_stop_all(self): + calls = 0 + + class A: + @debounce(20) + def foo(self): + nonlocal calls + calls += 1 + + a = A() + a.foo() + gtk_iteration() + self.assertEqual(calls, 0) + + debounce_manager.stop_all() + + # waiting for some time will not call it + time.sleep(0.021) + gtk_iteration() + self.assertEqual(calls, 0) + + def test_stop(self): + calls = 0 + + class A: + @debounce(20) + def foo(self): + nonlocal calls + calls += 1 + + a = A() + a.foo() + gtk_iteration() + self.assertEqual(calls, 0) + + debounce_manager.stop(a, a.foo) + + # waiting for some time will not call it + time.sleep(0.021) + gtk_iteration() + self.assertEqual(calls, 0) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/system/gui/test_groups.py b/tests/system/gui/test_groups.py new file mode 100644 index 0000000..3082dfd --- /dev/null +++ b/tests/system/gui/test_groups.py @@ -0,0 +1,137 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + +import os +import time +import unittest +from unittest.mock import patch, MagicMock + +import gi + +from inputremapper.injection.global_uinputs import GlobalUInputs, UInput +from inputremapper.injection.mapping_handlers.mapping_parser import MappingParser + +gi.require_version("Gdk", "3.0") +gi.require_version("Gtk", "3.0") +gi.require_version("GtkSource", "4") +gi.require_version("GLib", "2.0") + +from inputremapper.configs.global_config import GlobalConfig +from inputremapper.gui.utils import gtk_iteration +from inputremapper.daemon import Daemon + +from tests.lib.test_setup import test_setup +from tests.system.gui.gui_test_base import ( + launch, + start_reader_service, + clean_up_gui_test, +) + + +@test_setup +class TestGroupsFromReaderService(unittest.TestCase): + def patch_os_system(self): + def os_system(cmd, original_os_system=os.system): + # instead of running pkexec, fork instead. This will make + # the reader-service aware of all the test patches + if "pkexec input-remapper-control --command start-reader-service" in cmd: + # don't start the reader-service just log that it was. + self.reader_service_started() + return 0 + + return original_os_system(cmd) + + self.os_system_patch = patch.object( + os, + "system", + os_system, + ) + + # this is already part of the test. we need a bit of patching and hacking + # because we want to discover the groups as early a possible, to reduce startup + # time for the application + self.os_system_patch.start() + + def bootstrap_daemon(self): + # The daemon gets fresh instances of everything, because as far as I remember + # it runs in a separate process. + global_config = GlobalConfig() + global_uinputs = GlobalUInputs(UInput) + mapping_parser = MappingParser(global_uinputs) + + return Daemon( + global_config, + global_uinputs, + mapping_parser, + ) + + def patch_daemon(self): + # don't try to connect, return an object instance of it instead + self.daemon_connect_patch = patch.object( + Daemon, + "connect", + lambda: self.bootstrap_daemon(), + ) + self.daemon_connect_patch.start() + + def setUp(self): + self.reader_service_started = MagicMock() + self.patch_os_system() + self.patch_daemon() + + ( + self.user_interface, + self.controller, + self.data_manager, + self.message_broker, + self.daemon, + self.global_config, + ) = launch() + + def tearDown(self): + clean_up_gui_test(self) + self.os_system_patch.stop() + self.daemon_connect_patch.stop() + + def test_knows_devices(self): + # verify that it is working as expected. The gui doesn't have knowledge + # of groups until the root-reader-service provides them + self.data_manager._reader_client.groups.set_groups([]) + gtk_iteration() + self.reader_service_started.assert_called() + self.assertEqual(len(self.data_manager.get_group_keys()), 0) + + # start the reader-service delayed + start_reader_service() + # perform some iterations so that the reader ends up reading from the pipes + # which will make it receive devices. + for _ in range(10): + time.sleep(0.02) + gtk_iteration() + + self.assertIn("Foo Device 2", self.data_manager.get_group_keys()) + self.assertIn("Foo Device 2", self.data_manager.get_group_keys()) + self.assertIn("Bar Device", self.data_manager.get_group_keys()) + self.assertIn("gamepad", self.data_manager.get_group_keys()) + self.assertEqual(self.data_manager.active_group.name, "Foo Device") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/system/gui/test_gui.py b/tests/system/gui/test_gui.py new file mode 100644 index 0000000..0389d1a --- /dev/null +++ b/tests/system/gui/test_gui.py @@ -0,0 +1,1619 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + +import os +import time +import unittest +from typing import Tuple, Iterable +from unittest.mock import patch, MagicMock, call + +import evdev +import gi +from evdev.ecodes import ( + EV_KEY, + EV_ABS, + KEY_LEFTSHIFT, + KEY_A, + KEY_Q, + EV_REL, +) + +from inputremapper.input_event import InputEvent +from tests.system.gui.test_components import FlowBoxTestUtils +from tests.lib.fixtures import fixtures +from tests.lib.logger import logger +from tests.lib.pipes import push_event, push_events, uinput_write_history_pipe +from tests.lib.spy import spy + +gi.require_version("Gdk", "3.0") +gi.require_version("Gtk", "3.0") +gi.require_version("GtkSource", "4") +gi.require_version("GLib", "2.0") +from gi.repository import Gtk + +from inputremapper.configs.mapping import Mapping +from inputremapper.configs.paths import PathUtils +from inputremapper.gui.messages.message_broker import ( + MessageType, +) +from inputremapper.gui.messages.message_data import StatusData, CombinationRecorded +from inputremapper.gui.components.editor import ( + MappingSelectionLabel, + SET_KEY_FIRST, + CodeEditor, +) +from inputremapper.gui.components.device_groups import DeviceGroupEntry +from inputremapper.gui.utils import gtk_iteration +from inputremapper.injection.injector import InjectorState +from inputremapper.configs.input_config import InputCombination, InputConfig + +from tests.lib.test_setup import test_setup +from tests.system.gui.gui_test_base import GuiTestBase, patch_confirm_delete + + +@test_setup +class TestGui(GuiTestBase): + """For tests that use the window. + + It is intentional that there is no access to the Components. + Try to modify the configuration only by calling functions of the window. + For example by simulating clicks on buttons. Get the widget to interact with + by going through the windows children. (See click_on_group for inspiration) + """ + + def click_on_group(self, group_key: str): + for child in self.device_selection.get_children(): + device_group_entry = child.get_children()[0] + + if device_group_entry.group_key == group_key: + device_group_entry.set_active(True) + + def test_can_start(self): + self.assertIsNotNone(self.user_interface) + self.assertTrue(self.user_interface.window.get_visible()) + + def assert_gui_clean(self): + selection_labels = self.selection_label_listbox.get_children() + self.assertEqual(len(selection_labels), 0) + self.assertEqual(len(self.data_manager.active_preset), 0) + self.assertEqual( + FlowBoxTestUtils.get_active_entry(self.preset_selection).name, "new preset" + ) + self.assertEqual(self.recording_toggle.get_label(), "Record") + self.assertEqual(self.get_unfiltered_symbol_input_text(), SET_KEY_FIRST) + + def test_initial_state(self): + self.assertEqual(self.data_manager.active_group.key, "Foo Device") + self.assertEqual( + FlowBoxTestUtils.get_active_entry(self.device_selection).name, "Foo Device" + ) + self.assertEqual(self.data_manager.active_preset.name, "preset3") + self.assertEqual( + FlowBoxTestUtils.get_active_entry(self.preset_selection).name, "preset3" + ) + self.assertFalse(self.data_manager.get_autoload()) + self.assertFalse(self.autoload_toggle.get_active()) + self.assertEqual( + self.selection_label_listbox.get_selected_row().combination, + InputCombination([InputConfig(type=1, code=5)]), + ) + self.assertEqual( + self.data_manager.active_mapping.input_combination, + InputCombination( + [ + InputConfig(type=1, code=5), + ] + ), + ) + self.assertEqual(self.selection_label_listbox.get_selected_row().name, "4") + self.assertIsNone(self.data_manager.active_mapping.name) + self.assertTrue(self.data_manager.active_mapping.is_valid()) + self.assertTrue(self.data_manager.active_preset.is_valid()) + # todo + + def test_set_autoload_refreshes_service_config(self): + self.assertFalse(self.data_manager.get_autoload()) + with spy(self.daemon, "set_config_dir") as set_config_dir: + self.autoload_toggle.set_active(True) + gtk_iteration() + set_config_dir.assert_called_once() + self.assertTrue(self.data_manager.get_autoload()) + + def test_autoload_sets_correctly(self): + self.assertFalse(self.data_manager.get_autoload()) + self.assertFalse(self.autoload_toggle.get_active()) + + self.autoload_toggle.set_active(True) + gtk_iteration() + self.assertTrue(self.data_manager.get_autoload()) + self.assertTrue(self.autoload_toggle.get_active()) + + self.autoload_toggle.set_active(False) + gtk_iteration() + self.assertFalse(self.data_manager.get_autoload()) + self.assertFalse(self.autoload_toggle.get_active()) + + def test_autoload_is_set_when_changing_preset(self): + self.assertFalse(self.data_manager.get_autoload()) + self.assertFalse(self.autoload_toggle.get_active()) + + self.click_on_group("Foo Device 2") + FlowBoxTestUtils.set_active(self.preset_selection, "preset2") + + gtk_iteration() + self.assertTrue(self.data_manager.get_autoload()) + self.assertTrue(self.autoload_toggle.get_active()) + + def test_only_one_autoload_per_group(self): + self.assertFalse(self.data_manager.get_autoload()) + self.assertFalse(self.autoload_toggle.get_active()) + + self.click_on_group("Foo Device 2") + FlowBoxTestUtils.set_active(self.preset_selection, "preset2") + gtk_iteration() + self.assertTrue(self.data_manager.get_autoload()) + self.assertTrue(self.autoload_toggle.get_active()) + + FlowBoxTestUtils.set_active(self.preset_selection, "preset3") + gtk_iteration() + self.autoload_toggle.set_active(True) + gtk_iteration() + FlowBoxTestUtils.set_active(self.preset_selection, "preset2") + gtk_iteration() + self.assertFalse(self.data_manager.get_autoload()) + self.assertFalse(self.autoload_toggle.get_active()) + + def test_each_device_can_have_autoload(self): + self.autoload_toggle.set_active(True) + gtk_iteration() + self.assertTrue(self.data_manager.get_autoload()) + self.assertTrue(self.autoload_toggle.get_active()) + + self.click_on_group("Foo Device 2") + gtk_iteration() + self.autoload_toggle.set_active(True) + gtk_iteration() + self.assertTrue(self.data_manager.get_autoload()) + self.assertTrue(self.autoload_toggle.get_active()) + + self.click_on_group("Foo Device") + gtk_iteration() + self.assertTrue(self.data_manager.get_autoload()) + self.assertTrue(self.autoload_toggle.get_active()) + + def test_select_device_without_preset(self): + # creates a new empty preset when no preset exists for the device + self.click_on_group("Bar Device") + self.assertEqual( + FlowBoxTestUtils.get_active_entry(self.preset_selection).name, "new preset" + ) + self.assertEqual(len(self.data_manager.active_preset), 0) + + # it creates the file for that right away. It may have been possible + # to write it such that it doesn't (its empty anyway), but it does, + # so use that to test it in more detail. + path = PathUtils.get_preset_path("Bar Device", "new preset") + self.assertTrue(os.path.exists(path)) + with open(path, "r") as file: + self.assertEqual(file.read(), "") + + def test_recording_toggle_labels(self): + self.assertFalse(self.recording_status.get_visible()) + + self.recording_toggle.set_active(True) + gtk_iteration() + self.assertTrue(self.recording_status.get_visible()) + + self.recording_toggle.set_active(False) + gtk_iteration() + self.assertFalse(self.recording_status.get_visible()) + + def test_recording_label_updates_on_recording_finished(self): + self.assertFalse(self.recording_status.get_visible()) + + self.recording_toggle.set_active(True) + gtk_iteration() + self.assertTrue(self.recording_status.get_visible()) + + self.message_broker.signal(MessageType.recording_finished) + gtk_iteration() + self.assertFalse(self.recording_status.get_visible()) + self.assertFalse(self.recording_toggle.get_active()) + + def test_events_from_reader_service_arrive(self): + # load a device with more capabilities + self.controller.load_group("Foo Device 2") + gtk_iteration() + mock1 = MagicMock() + mock2 = MagicMock() + mock3 = MagicMock() + self.message_broker.subscribe(MessageType.combination_recorded, mock1) + self.message_broker.subscribe(MessageType.recording_finished, mock2) + self.message_broker.subscribe(MessageType.recording_started, mock3) + self.recording_toggle.set_active(True) + mock3.assert_called_once() + gtk_iteration() + + push_events( + fixtures.foo_device_2_keyboard, + [ + InputEvent(0, 0, 1, 30, 1), + InputEvent(0, 0, 1, 31, 1), + ], + ) + self.throttle(60) + origin = fixtures.foo_device_2_keyboard.get_device_hash() + mock1.assert_has_calls( + ( + call( + CombinationRecorded( + InputCombination( + [InputConfig(type=1, code=30, origin_hash=origin)] + ) + ) + ), + call( + CombinationRecorded( + InputCombination( + [ + InputConfig(type=1, code=30, origin_hash=origin), + InputConfig(type=1, code=31, origin_hash=origin), + ] + ) + ) + ), + ), + any_order=False, + ) + self.assertEqual(mock1.call_count, 2) + mock2.assert_not_called() + + push_events(fixtures.foo_device_2_keyboard, [InputEvent(0, 0, 1, 31, 0)]) + self.throttle(60) + self.assertEqual(mock1.call_count, 2) + mock2.assert_not_called() + + push_events(fixtures.foo_device_2_keyboard, [InputEvent(0, 0, 1, 30, 0)]) + self.throttle(60) + self.assertEqual(mock1.call_count, 2) + mock2.assert_called_once() + + self.assertFalse(self.recording_toggle.get_active()) + mock3.assert_called_once() + + def test_cannot_create_duplicate_input_combination(self): + # load a device with more capabilities + self.controller.load_group("Foo Device 2") + gtk_iteration() + + # update the combination of the active mapping + self.controller.start_key_recording() + push_events( + fixtures.foo_device_2_keyboard, + [InputEvent(0, 0, 1, 30, 1), InputEvent(0, 0, 1, 30, 0)], + ) + self.throttle(60) + + # if this fails with : this is the initial + # mapping or something, so it was never overwritten. + origin = fixtures.foo_device_2_keyboard.get_device_hash() + self.assertEqual( + self.data_manager.active_mapping.input_combination, + InputCombination([InputConfig(type=1, code=30, origin_hash=origin)]), + ) + + # create a new mapping + self.controller.create_mapping() + gtk_iteration() + self.assertEqual( + self.data_manager.active_mapping.input_combination, + InputCombination.empty_combination(), + ) + + # try to record the same combination + self.controller.start_key_recording() + push_events( + fixtures.foo_device_2_keyboard, + [InputEvent(0, 0, 1, 30, 1), InputEvent(0, 0, 1, 30, 0)], + ) + self.throttle(60) + # should still be the empty mapping + self.assertEqual( + self.data_manager.active_mapping.input_combination, + InputCombination.empty_combination(), + ) + + # try to record a different combination + self.controller.start_key_recording() + push_events(fixtures.foo_device_2_keyboard, [InputEvent(0, 0, 1, 30, 1)]) + self.throttle(60) + # nothing changed yet, as we got the duplicate combination + self.assertEqual( + self.data_manager.active_mapping.input_combination, + InputCombination.empty_combination(), + ) + push_events(fixtures.foo_device_2_keyboard, [InputEvent(0, 0, 1, 31, 1)]) + self.throttle(60) + # now the combination is different + self.assertEqual( + self.data_manager.active_mapping.input_combination, + InputCombination( + [ + InputConfig(type=1, code=30, origin_hash=origin), + InputConfig(type=1, code=31, origin_hash=origin), + ] + ), + ) + + # let's make the combination even longer + push_events(fixtures.foo_device_2_keyboard, [InputEvent(0, 0, 1, 32, 1)]) + self.throttle(60) + self.assertEqual( + self.data_manager.active_mapping.input_combination, + InputCombination( + [ + InputConfig(type=1, code=30, origin_hash=origin), + InputConfig(type=1, code=31, origin_hash=origin), + InputConfig(type=1, code=32, origin_hash=origin), + ] + ), + ) + + # make sure we stop recording by releasing all keys + push_events( + fixtures.foo_device_2_keyboard, + [ + InputEvent(0, 0, 1, 31, 0), + InputEvent(0, 0, 1, 30, 0), + InputEvent(0, 0, 1, 32, 0), + ], + ) + self.throttle(60) + + # sending a combination update now should not do anything + self.message_broker.publish( + CombinationRecorded(InputCombination([InputConfig(type=1, code=35)])) + ) + gtk_iteration() + self.assertEqual( + self.data_manager.active_mapping.input_combination, + InputCombination( + [ + InputConfig(type=1, code=30, origin_hash=origin), + InputConfig(type=1, code=31, origin_hash=origin), + InputConfig(type=1, code=32, origin_hash=origin), + ] + ), + ) + + def test_create_simple_mapping(self): + self.click_on_group("Foo Device 2") + # 1. create a mapping + self.create_mapping_btn.clicked() + gtk_iteration() + + self.assertEqual( + self.selection_label_listbox.get_selected_row().combination, + InputCombination.empty_combination(), + ) + self.assertEqual( + self.data_manager.active_mapping.input_combination, + InputCombination.empty_combination(), + ) + self.assertEqual( + self.selection_label_listbox.get_selected_row().name, "Empty Mapping" + ) + self.assertIsNone(self.data_manager.active_mapping.name) + + # there are now 2 mappings + self.assertEqual(len(self.selection_label_listbox.get_children()), 2) + self.assertEqual(len(self.data_manager.active_preset), 2) + + # 2. record a combination for that mapping + self.recording_toggle.set_active(True) + gtk_iteration() + push_events(fixtures.foo_device_2_keyboard, [InputEvent(0, 0, 1, 30, 1)]) + self.throttle(60) + push_events(fixtures.foo_device_2_keyboard, [InputEvent(0, 0, 1, 30, 0)]) + self.throttle(60) + + # check the input_combination + origin = fixtures.foo_device_2_keyboard.get_device_hash() + self.assertEqual( + self.selection_label_listbox.get_selected_row().combination, + InputCombination([InputConfig(type=1, code=30, origin_hash=origin)]), + ) + self.assertEqual( + self.data_manager.active_mapping.input_combination, + InputCombination([InputConfig(type=1, code=30, origin_hash=origin)]), + ) + self.assertEqual(self.selection_label_listbox.get_selected_row().name, "a") + self.assertIsNone(self.data_manager.active_mapping.name) + + # 3. set the output symbol + self.code_editor.get_buffer().set_text("Shift_L") + gtk_iteration() + + # the mapping and preset should be valid by now + self.assertTrue(self.data_manager.active_mapping.is_valid()) + self.assertTrue(self.data_manager.active_preset.is_valid()) + + self.assertEqual( + self.data_manager.active_mapping, + Mapping( + input_combination=InputCombination( + [InputConfig(type=1, code=30, origin_hash=origin)] + ), + output_symbol="Shift_L", + target_uinput="keyboard", + ), + ) + self.assertEqual(self.target_selection.get_active_id(), "keyboard") + buffer = self.code_editor.get_buffer() + self.assertEqual( + buffer.get_text(buffer.get_start_iter(), buffer.get_end_iter(), True), + "Shift_L", + ) + self.assertEqual( + self.selection_label_listbox.get_selected_row().combination, + InputCombination([InputConfig(type=1, code=30, origin_hash=origin)]), + ) + + # 4. update target + self.target_selection.set_active_id("keyboard + mouse") + gtk_iteration() + self.assertEqual( + self.data_manager.active_mapping, + Mapping( + input_combination=InputCombination( + [InputConfig(type=1, code=30, origin_hash=origin)] + ), + output_symbol="Shift_L", + target_uinput="keyboard + mouse", + ), + ) + + def test_show_status(self): + self.message_broker.publish(StatusData(0, "a")) + text = self.get_status_text() + self.assertEqual("a", text) + + def test_hat_switch(self): + # load a device with more capabilities + self.controller.load_group("Foo Device 2") + gtk_iteration() + + # it should be possible to add all of them + ev_1 = InputEvent.abs(evdev.ecodes.ABS_HAT0X, -1) + ev_2 = InputEvent.abs(evdev.ecodes.ABS_HAT0X, 1) + ev_3 = InputEvent.abs(evdev.ecodes.ABS_HAT0Y, -1) + ev_4 = InputEvent.abs(evdev.ecodes.ABS_HAT0Y, 1) + + def add_mapping(event, symbol) -> InputCombination: + """adds mapping and returns the expected input combination""" + self.controller.create_mapping() + gtk_iteration() + self.controller.start_key_recording() + push_events(fixtures.foo_device_2_gamepad, [event, event.modify(value=0)]) + self.throttle(60) + gtk_iteration() + self.code_editor.get_buffer().set_text(symbol) + gtk_iteration() + return InputCombination( + [ + InputConfig.from_input_event(event).modify( + origin_hash=fixtures.foo_device_2_gamepad.get_device_hash() + ) + ] + ) + + config_1 = add_mapping(ev_1, "a") + config_2 = add_mapping(ev_2, "b") + config_3 = add_mapping(ev_3, "c") + config_4 = add_mapping(ev_4, "d") + + self.assertEqual( + self.data_manager.active_preset.get_mapping( + InputCombination(config_1) + ).output_symbol, + "a", + ) + self.assertEqual( + self.data_manager.active_preset.get_mapping( + InputCombination(config_2) + ).output_symbol, + "b", + ) + self.assertEqual( + self.data_manager.active_preset.get_mapping( + InputCombination(config_3) + ).output_symbol, + "c", + ) + self.assertEqual( + self.data_manager.active_preset.get_mapping( + InputCombination(config_4) + ).output_symbol, + "d", + ) + + def test_combination(self): + # if this test freezes, try waiting a few minutes and then look for + # stack traces in the console + + # load a device with more capabilities + self.controller.load_group("Foo Device 2") + gtk_iteration() + + # it should be possible to write a combination + ev_1 = InputEvent.key( + evdev.ecodes.KEY_A, + 1, + origin_hash=fixtures.foo_device_2_keyboard.get_device_hash(), + ) + ev_2 = InputEvent.abs( + evdev.ecodes.ABS_HAT0X, + 1, + origin_hash=fixtures.foo_device_2_gamepad.get_device_hash(), + ) + ev_3 = InputEvent.key( + evdev.ecodes.KEY_C, + 1, + origin_hash=fixtures.foo_device_2_keyboard.get_device_hash(), + ) + ev_4 = InputEvent.abs( + evdev.ecodes.ABS_HAT0X, + -1, + origin_hash=fixtures.foo_device_2_gamepad.get_device_hash(), + ) + combination_1 = (ev_1, ev_2, ev_3) + combination_2 = (ev_2, ev_1, ev_3) + + # same as 1, but different D-Pad direction + combination_3 = (ev_1, ev_4, ev_3) + combination_4 = (ev_4, ev_1, ev_3) + + # same as 1, but the last combination is different + combination_5 = (ev_1, ev_3, ev_2) + combination_6 = (ev_3, ev_1, ev_2) + + def get_combination(combi: Iterable[InputEvent]) -> InputCombination: + """Create an InputCombination from a list of events. + + Ensures the origin_hash is set correctly. + """ + configs = [] + for event in combi: + config = InputConfig.from_input_event(event) + configs.append(config) + return InputCombination(configs) + + def add_mapping(combi: Iterable[InputEvent], symbol): + logger.info("add_mapping %s", combi) + self.controller.create_mapping() + gtk_iteration() + self.controller.start_key_recording() + for event in combi: + if event.type == EV_KEY: + push_event(fixtures.foo_device_2_keyboard, event) + if event.type == EV_ABS: + push_event(fixtures.foo_device_2_gamepad, event) + if event.type == EV_REL: + push_event(fixtures.foo_device_2_mouse, event) + + # avoid race condition if we switch fixture in push_event. The order + # of events needs to be correct. + self.throttle(20) + + for event in combi: + if event.type == EV_KEY: + push_event(fixtures.foo_device_2_keyboard, event.modify(value=0)) + if event.type == EV_ABS: + push_event(fixtures.foo_device_2_gamepad, event.modify(value=0)) + if event.type == EV_REL: + pass + + self.throttle(60) + gtk_iteration() + self.code_editor.get_buffer().set_text(symbol) + gtk_iteration() + + add_mapping(combination_1, "a") + + self.assertEqual( + self.data_manager.active_preset.get_mapping( + get_combination(combination_1) + ).output_symbol, + "a", + ) + self.assertEqual( + self.data_manager.active_preset.get_mapping( + get_combination(combination_2) + ).output_symbol, + "a", + ) + self.assertIsNone( + self.data_manager.active_preset.get_mapping(get_combination(combination_3)) + ) + self.assertIsNone( + self.data_manager.active_preset.get_mapping(get_combination(combination_4)) + ) + self.assertIsNone( + self.data_manager.active_preset.get_mapping(get_combination(combination_5)) + ) + self.assertIsNone( + self.data_manager.active_preset.get_mapping(get_combination(combination_6)) + ) + + # it won't write the same combination again, even if the + # first two events are in a different order + add_mapping(combination_2, "b") + self.assertEqual( + self.data_manager.active_preset.get_mapping( + get_combination(combination_1) + ).output_symbol, + "a", + ) + self.assertEqual( + self.data_manager.active_preset.get_mapping( + get_combination(combination_2) + ).output_symbol, + "a", + ) + self.assertIsNone( + self.data_manager.active_preset.get_mapping(get_combination(combination_3)) + ) + self.assertIsNone( + self.data_manager.active_preset.get_mapping(get_combination(combination_4)) + ) + self.assertIsNone( + self.data_manager.active_preset.get_mapping(get_combination(combination_5)) + ) + self.assertIsNone( + self.data_manager.active_preset.get_mapping(get_combination(combination_6)) + ) + + add_mapping(combination_3, "c") + self.assertEqual( + self.data_manager.active_preset.get_mapping( + get_combination(combination_1) + ).output_symbol, + "a", + ) + self.assertEqual( + self.data_manager.active_preset.get_mapping( + get_combination(combination_2) + ).output_symbol, + "a", + ) + self.assertEqual( + self.data_manager.active_preset.get_mapping( + get_combination(combination_3) + ).output_symbol, + "c", + ) + self.assertEqual( + self.data_manager.active_preset.get_mapping( + get_combination(combination_4) + ).output_symbol, + "c", + ) + self.assertIsNone( + self.data_manager.active_preset.get_mapping(get_combination(combination_5)) + ) + self.assertIsNone( + self.data_manager.active_preset.get_mapping(get_combination(combination_6)) + ) + + # same as with combination_2, the existing combination_3 blocks + # combination_4 because they have the same keys and end in the + # same key. + add_mapping(combination_4, "d") + self.assertEqual( + self.data_manager.active_preset.get_mapping( + get_combination(combination_1) + ).output_symbol, + "a", + ) + self.assertEqual( + self.data_manager.active_preset.get_mapping( + get_combination(combination_2) + ).output_symbol, + "a", + ) + self.assertEqual( + self.data_manager.active_preset.get_mapping( + get_combination(combination_3) + ).output_symbol, + "c", + ) + self.assertEqual( + self.data_manager.active_preset.get_mapping( + get_combination(combination_4) + ).output_symbol, + "c", + ) + self.assertIsNone( + self.data_manager.active_preset.get_mapping(get_combination(combination_5)) + ) + self.assertIsNone( + self.data_manager.active_preset.get_mapping(get_combination(combination_6)) + ) + + add_mapping(combination_5, "e") + self.assertEqual( + self.data_manager.active_preset.get_mapping( + get_combination(combination_1) + ).output_symbol, + "a", + ) + self.assertEqual( + self.data_manager.active_preset.get_mapping( + get_combination(combination_2) + ).output_symbol, + "a", + ) + self.assertEqual( + self.data_manager.active_preset.get_mapping( + get_combination(combination_3) + ).output_symbol, + "c", + ) + self.assertEqual( + self.data_manager.active_preset.get_mapping( + get_combination(combination_4) + ).output_symbol, + "c", + ) + self.assertEqual( + self.data_manager.active_preset.get_mapping( + get_combination(combination_5) + ).output_symbol, + "e", + ) + self.assertEqual( + self.data_manager.active_preset.get_mapping( + get_combination(combination_6) + ).output_symbol, + "e", + ) + + error_icon = self.user_interface.get("error_status_icon") + warning_icon = self.user_interface.get("warning_status_icon") + + self.assertFalse(error_icon.get_visible()) + self.assertFalse(warning_icon.get_visible()) + + def test_only_one_empty_mapping_possible(self): + self.assertEqual( + self.selection_label_listbox.get_selected_row().combination, + InputCombination([InputConfig(type=1, code=5)]), + ) + self.assertEqual(len(self.selection_label_listbox.get_children()), 1) + self.assertEqual(len(self.data_manager.active_preset), 1) + + self.create_mapping_btn.clicked() + gtk_iteration() + self.assertEqual( + self.selection_label_listbox.get_selected_row().combination, + InputCombination.empty_combination(), + ) + self.assertEqual(len(self.selection_label_listbox.get_children()), 2) + self.assertEqual(len(self.data_manager.active_preset), 2) + + self.create_mapping_btn.clicked() + gtk_iteration() + self.assertEqual(len(self.selection_label_listbox.get_children()), 2) + self.assertEqual(len(self.data_manager.active_preset), 2) + + def test_selection_labels_sort_alphabetically(self): + self.controller.load_preset("preset1") + # contains two mappings (1,1,1 -> b) and (1,2,1 -> a) + gtk_iteration() + # we expect (1,2,1 -> a) to be selected because "1" < "Escape" + self.assertEqual(self.data_manager.active_mapping.output_symbol, "a") + self.assertIs( + self.selection_label_listbox.get_row_at_index(0), + self.selection_label_listbox.get_selected_row(), + ) + + self.recording_toggle.set_active(True) + gtk_iteration() + self.message_broker.publish( + CombinationRecorded( + InputCombination([InputConfig(type=EV_KEY, code=KEY_Q)]) + ) + ) + gtk_iteration() + self.message_broker.signal(MessageType.recording_finished) + gtk_iteration() + # the combination and the order changed "Escape" < "q" + self.assertEqual(self.data_manager.active_mapping.output_symbol, "a") + self.assertIs( + self.selection_label_listbox.get_row_at_index(1), + self.selection_label_listbox.get_selected_row(), + ) + + def test_selection_labels_sort_empty_mapping_to_the_bottom(self): + # make sure we have a mapping which would sort to the bottom only + # considering alphanumeric sorting: "q" > "Empty Mapping" + self.controller.load_preset("preset1") + gtk_iteration() + self.recording_toggle.set_active(True) + gtk_iteration() + self.message_broker.publish( + CombinationRecorded( + InputCombination([InputConfig(type=EV_KEY, code=KEY_Q)]) + ) + ) + gtk_iteration() + self.message_broker.signal(MessageType.recording_finished) + gtk_iteration() + + self.controller.create_mapping() + gtk_iteration() + row: MappingSelectionLabel = self.selection_label_listbox.get_selected_row() + self.assertEqual(row.combination, InputCombination.empty_combination()) + self.assertEqual(row.label.get_text(), "Empty Mapping") + self.assertIs(self.selection_label_listbox.get_row_at_index(2), row) + + def test_select_mapping(self): + self.controller.load_preset("preset1") + # contains two mappings (1,1,1 -> b) and (1,2,1 -> a) + gtk_iteration() + # we expect (1,2,1 -> a) to be selected because "1" < "Escape" + self.assertEqual(self.data_manager.active_mapping.output_symbol, "a") + + # select the second entry in the listbox + row = self.selection_label_listbox.get_row_at_index(1) + self.selection_label_listbox.select_row(row) + gtk_iteration() + self.assertEqual(self.data_manager.active_mapping.output_symbol, "b") + + def test_selection_label_uses_name_if_available(self): + self.controller.load_preset("preset1") + gtk_iteration() + row: MappingSelectionLabel = self.selection_label_listbox.get_selected_row() + self.assertEqual(row.label.get_text(), "1") + self.assertIs(row, self.selection_label_listbox.get_row_at_index(0)) + + self.controller.update_mapping(name="foo") + gtk_iteration() + self.assertEqual(row.label.get_text(), "foo") + self.assertIs(row, self.selection_label_listbox.get_row_at_index(1)) + + # Empty Mapping still sorts to the bottom + self.controller.create_mapping() + gtk_iteration() + row = self.selection_label_listbox.get_selected_row() + self.assertEqual(row.combination, InputCombination.empty_combination()) + self.assertEqual(row.label.get_text(), "Empty Mapping") + self.assertIs(self.selection_label_listbox.get_row_at_index(2), row) + + def test_fake_empty_mapping_does_not_sort_to_bottom(self): + """If someone chooses to name a mapping "Empty Mapping" + it is not sorted to the bottom""" + self.controller.load_preset("preset1") + gtk_iteration() + + self.controller.update_mapping(name="Empty Mapping") + self.throttle(20) # sorting seems to take a bit + + # "Empty Mapping" < "Escape" so we still expect this to be the first row + row = self.selection_label_listbox.get_selected_row() + self.assertIs(row, self.selection_label_listbox.get_row_at_index(0)) + + # now create a real empty mapping + self.controller.create_mapping() + self.throttle(20) + + # for some reason we no longer can use assertIs maybe a gtk bug? + # self.assertIs(row, self.selection_label_listbox.get_row_at_index(0)) + + # we expect the fake empty mapping in row 0 and the real one in row 2 + self.selection_label_listbox.select_row( + self.selection_label_listbox.get_row_at_index(0) + ) + gtk_iteration() + self.assertEqual(self.data_manager.active_mapping.name, "Empty Mapping") + self.assertEqual(self.data_manager.active_mapping.output_symbol, "a") + + self.selection_label_listbox.select_row( + self.selection_label_listbox.get_row_at_index(2) + ) + self.assertIsNone(self.data_manager.active_mapping.name) + self.assertEqual( + self.data_manager.active_mapping.input_combination, + InputCombination.empty_combination(), + ) + + def test_remove_mapping(self): + self.controller.load_preset("preset1") + gtk_iteration() + self.assertEqual(len(self.data_manager.active_preset), 2) + self.assertEqual(len(self.selection_label_listbox.get_children()), 2) + + with patch_confirm_delete(self.user_interface): + self.delete_mapping_btn.clicked() + gtk_iteration() + + self.assertEqual(len(self.data_manager.active_preset), 1) + self.assertEqual(len(self.selection_label_listbox.get_children()), 1) + + def test_problematic_combination(self): + # load a device with more capabilities + self.controller.load_group("Foo Device 2") + gtk_iteration() + + def add_mapping(combi: Iterable[Tuple[int, int, int]], symbol): + combi = [InputEvent(0, 0, *t) for t in combi] + self.controller.create_mapping() + gtk_iteration() + self.controller.start_key_recording() + push_events(fixtures.foo_device_2_keyboard, combi) + push_events( + fixtures.foo_device_2_keyboard, + [event.modify(value=0) for event in combi], + ) + self.throttle(60) + gtk_iteration() + self.code_editor.get_buffer().set_text(symbol) + gtk_iteration() + + combination = [(EV_KEY, KEY_LEFTSHIFT, 1), (EV_KEY, 82, 1)] + + add_mapping(combination, "b") + text = self.get_status_text() + self.assertIn("shift", text) + + error_icon = self.user_interface.get("error_status_icon") + warning_icon = self.user_interface.get("warning_status_icon") + + self.assertFalse(error_icon.get_visible()) + self.assertTrue(warning_icon.get_visible()) + + def test_rename_and_save(self): + # only a basic test, TestController and TestDataManager go more in detail + self.rename_input.set_text("foo") + self.rename_btn.clicked() + gtk_iteration() + + preset_path = f"{PathUtils.config_path()}/presets/Foo Device/foo.json" + self.assertTrue(os.path.exists(preset_path)) + error_icon = self.user_interface.get("error_status_icon") + self.assertFalse(error_icon.get_visible()) + + def save(): + raise PermissionError + + with patch.object(self.data_manager.active_preset, "save", save): + self.code_editor.get_buffer().set_text("f") + gtk_iteration() + status = self.get_status_text() + self.assertIn("Permission denied", status) + + with patch_confirm_delete(self.user_interface): + self.delete_preset_btn.clicked() + gtk_iteration() + self.assertFalse(os.path.exists(preset_path)) + + def test_check_for_unknown_symbols(self): + first_input = InputCombination([InputConfig(type=1, code=1)]) + second_input = InputCombination([InputConfig(type=1, code=2)]) + status = self.user_interface.get("status_bar") + error_icon = self.user_interface.get("error_status_icon") + warning_icon = self.user_interface.get("warning_status_icon") + + self.controller.load_preset("preset1") + self.throttle(20) + + # Switch to the first mapping, and change it + self.controller.load_mapping(first_input) + gtk_iteration() + self.controller.update_mapping(output_symbol="foo") + gtk_iteration() + + # Switch to the second mapping, and change it + self.controller.load_mapping(second_input) + gtk_iteration() + self.controller.update_mapping(output_symbol="qux") + gtk_iteration() + + # The tooltip should show the error of the currently selected mapping + tooltip = status.get_tooltip_text().lower() + self.assertIn("qux", tooltip) + self.assertTrue(error_icon.get_visible()) + self.assertFalse(warning_icon.get_visible()) + + # So switching to the other mapping changes the tooltip + self.controller.load_mapping(first_input) + gtk_iteration() + tooltip = status.get_tooltip_text().lower() + self.assertIn("foo", tooltip) + + # It will still save it though + with open(PathUtils.get_preset_path("Foo Device", "preset1")) as f: + content = f.read() + self.assertIn("qux", content) + self.assertIn("foo", content) + + # Fix the current active mapping. + # It should show the error of the other mapping now. + self.controller.update_mapping(output_symbol="a") + gtk_iteration() + tooltip = status.get_tooltip_text().lower() + self.assertIn("qux", tooltip) + self.assertTrue(error_icon.get_visible()) + self.assertFalse(warning_icon.get_visible()) + + # Fix the other mapping as well. No tooltip should be shown afterward. + self.controller.load_mapping(second_input) + gtk_iteration() + self.controller.update_mapping(output_symbol="b") + gtk_iteration() + tooltip = status.get_tooltip_text() + self.assertIsNone(tooltip) + self.assertFalse(error_icon.get_visible()) + self.assertFalse(warning_icon.get_visible()) + + def test_no_validation_tooltip_for_empty_mappings(self): + self.controller.load_preset("preset1") + self.throttle(20) + + status = self.user_interface.get("status_bar") + self.assertIsNone(status.get_tooltip_text()) + + self.controller.create_mapping() + gtk_iteration() + self.assertTrue(self.controller.is_empty_mapping()) + self.assertIsNone(status.get_tooltip_text()) + + def test_check_macro_syntax(self): + status = self.status_bar + error_icon = self.user_interface.get("error_status_icon") + warning_icon = self.user_interface.get("warning_status_icon") + + self.code_editor.get_buffer().set_text("k(1))") + tooltip = status.get_tooltip_text().lower() + self.assertIn("brackets", tooltip) + self.assertTrue(error_icon.get_visible()) + self.assertFalse(warning_icon.get_visible()) + + self.code_editor.get_buffer().set_text("k(1)") + tooltip = (status.get_tooltip_text() or "").lower() + self.assertNotIn("brackets", tooltip) + self.assertFalse(error_icon.get_visible()) + self.assertFalse(warning_icon.get_visible()) + + self.assertEqual( + self.data_manager.active_mapping.output_symbol, + "k(1)", + ) + + def test_check_on_typing(self): + status = self.user_interface.get("status_bar") + error_icon = self.user_interface.get("error_status_icon") + warning_icon = self.user_interface.get("warning_status_icon") + + tooltip = status.get_tooltip_text() + # nothing wrong yet + self.assertIsNone(tooltip) + + # now change the mapping by typing into the field + buffer = self.code_editor.get_buffer() + buffer.set_text("sdfgkj()") + gtk_iteration() + + # the mapping is validated + tooltip = status.get_tooltip_text() + self.assertIn("Unknown function sdfgkj", tooltip) + self.assertTrue(error_icon.get_visible()) + self.assertFalse(warning_icon.get_visible()) + + self.assertEqual(self.data_manager.active_mapping.output_symbol, "sdfgkj()") + + def test_select_device(self): + # simple test to make sure we can switch between devices + # more detailed tests in TestController and TestDataManager + self.click_on_group("Bar Device") + gtk_iteration() + + entries = {*FlowBoxTestUtils.get_child_names(self.preset_selection)} + self.assertEqual(entries, {"new preset"}) + + self.click_on_group("Foo Device") + gtk_iteration() + + entries = {*FlowBoxTestUtils.get_child_names(self.preset_selection)} + self.assertEqual(entries, {"preset1", "preset2", "preset3"}) + + # make sure a preset and mapping was loaded + self.assertIsNotNone(self.data_manager.active_preset) + self.assertEqual( + self.data_manager.active_preset.name, + FlowBoxTestUtils.get_active_entry(self.preset_selection).name, + ) + self.assertIsNotNone(self.data_manager.active_mapping) + self.assertEqual( + self.data_manager.active_mapping.input_combination, + self.selection_label_listbox.get_selected_row().combination, + ) + + def test_select_preset(self): + # simple test to make sure we can switch between presets + # more detailed tests in TestController and TestDataManager + self.click_on_group("Foo Device 2") + gtk_iteration() + FlowBoxTestUtils.set_active(self.preset_selection, "preset1") + gtk_iteration() + + mappings = { + row.combination for row in self.selection_label_listbox.get_children() + } + self.assertEqual( + mappings, + { + InputCombination([InputConfig(type=1, code=1)]), + InputCombination([InputConfig(type=1, code=2)]), + }, + ) + self.assertFalse(self.autoload_toggle.get_active()) + + FlowBoxTestUtils.set_active(self.preset_selection, "preset2") + gtk_iteration() + + mappings = { + row.combination for row in self.selection_label_listbox.get_children() + } + self.assertEqual( + mappings, + { + InputCombination([InputConfig(type=1, code=3)]), + InputCombination([InputConfig(type=1, code=4)]), + }, + ) + self.assertTrue(self.autoload_toggle.get_active()) + + def test_copy_preset(self): + # simple tests to ensure it works + # more detailed tests in TestController and TestDataManager + + # check the initial state + entries = {*FlowBoxTestUtils.get_child_names(self.preset_selection)} + self.assertEqual(entries, {"preset1", "preset2", "preset3"}) + self.assertEqual( + FlowBoxTestUtils.get_active_entry(self.preset_selection).name, "preset3" + ) + + self.copy_preset_btn.clicked() + gtk_iteration() + entries = {*FlowBoxTestUtils.get_child_names(self.preset_selection)} + self.assertEqual(entries, {"preset1", "preset2", "preset3", "preset3 copy"}) + self.assertEqual( + FlowBoxTestUtils.get_active_entry(self.preset_selection).name, + "preset3 copy", + ) + + self.copy_preset_btn.clicked() + gtk_iteration() + + entries = {*FlowBoxTestUtils.get_child_names(self.preset_selection)} + self.assertEqual( + entries, {"preset1", "preset2", "preset3", "preset3 copy", "preset3 copy 2"} + ) + + def test_wont_start(self): + def wait(): + """Wait for the injector process to finish doing stuff.""" + for _ in range(10): + time.sleep(0.1) + gtk_iteration() + if "Starting" not in self.get_status_text(): + return + + error_icon = self.user_interface.get("error_status_icon") + self.controller.load_group("Bar Device") + + # empty + self.start_injector_btn.clicked() + gtk_iteration() + wait() + text = self.get_status_text() + self.assertIn("add mappings", text) + self.assertTrue(error_icon.get_visible()) + self.assertNotEqual(self.daemon.get_state("Bar Device"), InjectorState.RUNNING) + + # device grabbing fails + self.controller.load_group("Foo Device 2") + gtk_iteration() + + for i in range(2): + # just pressing apply again will overwrite the previous error + self.grab_fails = True + self.start_injector_btn.clicked() + gtk_iteration() + + text = self.get_status_text() + # it takes a little bit of time + self.assertIn("Starting injection", text) + self.assertFalse(error_icon.get_visible()) + wait() + text = self.get_status_text() + self.assertIn("Failed to apply preset", text) + self.assertTrue(error_icon.get_visible()) + self.assertNotEqual( + self.daemon.get_state("Foo Device 2"), InjectorState.RUNNING + ) + + # this time work properly + + self.grab_fails = False + self.start_injector_btn.clicked() + gtk_iteration() + text = self.get_status_text() + self.assertIn("Starting injection", text) + self.assertFalse(error_icon.get_visible()) + wait() + text = self.get_status_text() + self.assertIn("Applied", text) + text = self.get_status_text() + self.assertNotIn("CTRL + DEL", text) # only shown if btn_left mapped + self.assertFalse(error_icon.get_visible()) + self.assertEqual(self.daemon.get_state("Foo Device 2"), InjectorState.RUNNING) + + def test_start_with_btn_left(self): + self.controller.load_group("Foo Device 2") + gtk_iteration() + + self.controller.create_mapping() + gtk_iteration() + self.controller.update_mapping( + input_combination=InputCombination([InputConfig.btn_left()]), + output_symbol="a", + ) + gtk_iteration() + + def wait(): + """Wait for the injector process to finish doing stuff.""" + for _ in range(10): + time.sleep(0.1) + gtk_iteration() + if "Starting" not in self.get_status_text(): + return + + # first apply, shows btn_left warning + self.start_injector_btn.clicked() + gtk_iteration() + text = self.get_status_text() + self.assertIn("click", text) + self.assertEqual(self.daemon.get_state("Foo Device 2"), InjectorState.UNKNOWN) + + # second apply, overwrites + self.start_injector_btn.clicked() + gtk_iteration() + wait() + self.assertEqual(self.daemon.get_state("Foo Device 2"), InjectorState.RUNNING) + text = self.get_status_text() + # because btn_left is mapped, shows help on how to stop + # injecting via the keyboard + self.assertIn("CTRL + DEL", text) + + def test_cannot_record_keys(self): + self.controller.load_group("Foo Device 2") + self.assertNotEqual(self.data_manager.get_state(), InjectorState.RUNNING) + self.assertNotIn("Stop", self.get_status_text()) + + self.recording_toggle.set_active(True) + gtk_iteration() + self.assertTrue(self.recording_toggle.get_active()) + self.controller.stop_key_recording() + gtk_iteration() + self.assertFalse(self.recording_toggle.get_active()) + + self.start_injector_btn.clicked() + gtk_iteration() + # wait for the injector to start + for _ in range(10): + time.sleep(0.1) + gtk_iteration() + if "Starting" not in self.get_status_text(): + break + + self.assertEqual(self.data_manager.get_state(), InjectorState.RUNNING) + + # the toggle button should reset itself shortly + self.recording_toggle.set_active(True) + gtk_iteration() + self.assertFalse(self.recording_toggle.get_active()) + text = self.get_status_text() + self.assertIn("Stop", text) + + def test_start_injecting(self): + self.controller.load_group("Foo Device 2") + + with spy(self.daemon, "set_config_dir") as spy1: + with spy(self.daemon, "start_injecting") as spy2: + self.start_injector_btn.clicked() + gtk_iteration() + # correctly uses group.key, not group.name + spy2.assert_called_once_with("Foo Device 2", "preset3") + + spy1.assert_called_once_with(PathUtils.get_config_path()) + + for _ in range(10): + time.sleep(0.1) + gtk_iteration() + if self.data_manager.get_state() == InjectorState.RUNNING: + break + + # fail here so we don't block forever + self.assertEqual(self.data_manager.get_state(), InjectorState.RUNNING) + + # this is a stupid workaround for the bad test fixtures + # by switching the group we make sure that the reader-service no longer + # listens for events on "Foo Device 2" otherwise we would have two processes + # (reader-service and injector) reading the same pipe which can block this test + # indefinitely + self.controller.load_group("Foo Device") + gtk_iteration() + + push_events( + fixtures.foo_device_2_keyboard, + [ + InputEvent.key(5, 1), + InputEvent.key(5, 0), + ], + ) + + event = uinput_write_history_pipe[0].recv() + self.assertEqual(event.type, evdev.events.EV_KEY) + self.assertEqual(event.code, KEY_A) + self.assertEqual(event.value, 1) + + event = uinput_write_history_pipe[0].recv() + self.assertEqual(event.type, evdev.events.EV_KEY) + self.assertEqual(event.code, KEY_A) + self.assertEqual(event.value, 0) + + # the input-remapper device will not be shown + self.controller.refresh_groups() + gtk_iteration() + for child in self.device_selection.get_children(): + device_group_entry = child.get_children()[0] + self.assertNotIn("input-remapper", device_group_entry.name) + + def test_stop_injecting(self): + self.controller.load_group("Foo Device 2") + self.start_injector_btn.clicked() + gtk_iteration() + + for _ in range(10): + time.sleep(0.1) + gtk_iteration() + if self.data_manager.get_state() == InjectorState.RUNNING: + break + + # fail here so we don't block forever + self.assertEqual(self.data_manager.get_state(), InjectorState.RUNNING) + + # stupid fixture workaround + self.controller.load_group("Foo Device") + gtk_iteration() + + pipe = uinput_write_history_pipe[0] + self.assertFalse(pipe.poll()) + + push_events( + fixtures.foo_device_2_keyboard, + [ + InputEvent.key(5, 1), + InputEvent.key(5, 0), + ], + ) + + time.sleep(0.2) + self.assertTrue(pipe.poll()) + while pipe.poll(): + pipe.recv() + + self.controller.load_group("Foo Device 2") + self.controller.stop_injecting() + gtk_iteration() + + for _ in range(10): + time.sleep(0.1) + gtk_iteration() + if self.data_manager.get_state() == InjectorState.STOPPED: + break + self.assertEqual(self.data_manager.get_state(), InjectorState.STOPPED) + + push_events( + fixtures.foo_device_2_keyboard, + [ + InputEvent.key(5, 1), + InputEvent.key(5, 0), + ], + ) + time.sleep(0.2) + self.assertFalse(pipe.poll()) + + def test_delete_preset(self): + # as per test_initial_state we already have preset3 loaded + self.assertTrue( + os.path.exists(PathUtils.get_preset_path("Foo Device", "preset3")) + ) + + with patch_confirm_delete(self.user_interface, Gtk.ResponseType.CANCEL): + self.delete_preset_btn.clicked() + gtk_iteration() + self.assertTrue( + os.path.exists(PathUtils.get_preset_path("Foo Device", "preset3")) + ) + self.assertEqual(self.data_manager.active_preset.name, "preset3") + self.assertEqual(self.data_manager.active_group.name, "Foo Device") + + with patch_confirm_delete(self.user_interface): + self.delete_preset_btn.clicked() + gtk_iteration() + self.assertFalse( + os.path.exists(PathUtils.get_preset_path("Foo Device", "preset3")) + ) + self.assertEqual(self.data_manager.active_preset.name, "preset2") + self.assertEqual(self.data_manager.active_group.name, "Foo Device") + + def test_refresh_groups(self): + # sanity check: preset3 should be the newest + self.assertEqual( + FlowBoxTestUtils.get_active_entry(self.preset_selection).name, "preset3" + ) + + # select the older one + FlowBoxTestUtils.set_active(self.preset_selection, "preset1") + gtk_iteration() + self.assertEqual(self.data_manager.active_preset.name, "preset1") + + # add a device that doesn't exist to the dropdown + unknown_key = "key-1234" + self.device_selection.insert( + DeviceGroupEntry(self.message_broker, self.controller, None, unknown_key), + 0, + # 0, [unknown_key, None, "foo"] + ) + + self.controller.refresh_groups() + gtk_iteration() + self.throttle(200) + # the gui should not jump to a different preset suddenly + self.assertEqual(self.data_manager.active_preset.name, "preset1") + + # just to verify that the mtime still tells us that preset3 is the newest one + self.assertEqual(self.controller.get_a_preset(), "preset3") + + # the list contains correct entries + # and the non-existing entry should be removed + names = FlowBoxTestUtils.get_child_names(self.device_selection) + icons = FlowBoxTestUtils.get_child_icons(self.device_selection) + self.assertNotIn(unknown_key, names) + + self.assertIn("Foo Device", names) + self.assertIn("Foo Device 2", names) + self.assertIn("Bar Device", names) + self.assertIn("gamepad", names) + + self.assertIn("input-keyboard", icons) + self.assertIn("input-gaming", icons) + self.assertIn("input-keyboard", icons) + self.assertIn("input-gaming", icons) + + # it won't crash due to "list index out of range" + # when `types` is an empty list. Won't show an icon + self.data_manager._reader_client.groups.find(key="Foo Device 2").types = [] + self.data_manager._reader_client.publish_groups() + gtk_iteration() + self.assertIn( + "Foo Device 2", + FlowBoxTestUtils.get_child_names(self.device_selection), + ) + + def test_shared_presets(self): + # devices with the same name (but different key because the key is + # unique) share the same presets. + # Those devices would usually be of the same model of keyboard for example + # Todo: move this to unit tests, there is no point in having the ui around + self.controller.load_group("Foo Device") + presets1 = self.data_manager.get_preset_names() + self.controller.load_group("Foo Device 2") + gtk_iteration() + presets2 = self.data_manager.get_preset_names() + self.controller.load_group("Bar Device") + gtk_iteration() + presets3 = self.data_manager.get_preset_names() + + self.assertEqual(presets1, presets2) + self.assertNotEqual(presets1, presets3) + + def test_delete_last_preset(self): + with patch_confirm_delete(self.user_interface): + # as per test_initial_state we already have preset3 loaded + self.assertEqual(self.data_manager.active_preset.name, "preset3") + + self.delete_preset_btn.clicked() + gtk_iteration() + # the next newest preset should be loaded + self.assertEqual(self.data_manager.active_preset.name, "preset2") + self.delete_preset_btn.clicked() + gtk_iteration() + self.delete_preset_btn.clicked() + # the ui should be clean + self.assert_gui_clean() + device_path = f"{PathUtils.config_path()}/presets/{self.data_manager.active_group.name}" + self.assertTrue(os.path.exists(f"{device_path}/new preset.json")) + + self.delete_preset_btn.clicked() + gtk_iteration() + # deleting an empty preset als doesn't do weird stuff + self.assert_gui_clean() + device_path = f"{PathUtils.config_path()}/presets/{self.data_manager.active_group.name}" + self.assertTrue(os.path.exists(f"{device_path}/new preset.json")) + + def test_enable_disable_output(self): + # load a group without any presets + self.controller.load_group("Bar Device") + + # should be disabled by default since no key is recorded yet + self.assertEqual(self.get_unfiltered_symbol_input_text(), SET_KEY_FIRST) + self.assertFalse(self.output_box.get_sensitive()) + + # create a mapping + self.controller.create_mapping() + gtk_iteration() + + # should still be disabled + self.assertEqual(self.get_unfiltered_symbol_input_text(), SET_KEY_FIRST) + self.assertFalse(self.output_box.get_sensitive()) + + # enable it by sending a combination + self.controller.start_key_recording() + gtk_iteration() + push_events( + fixtures.bar_device, + [ + InputEvent(0, 0, 1, 30, 1), + InputEvent(0, 0, 1, 30, 0), + ], + ) + self.throttle(100) # give time for the input to arrive + + self.assertEqual( + self.get_unfiltered_symbol_input_text(), CodeEditor.placeholder + ) + self.assertTrue(self.output_box.get_sensitive()) + + # disable it by deleting the mapping + with patch_confirm_delete(self.user_interface): + self.delete_mapping_btn.clicked() + gtk_iteration() + + self.assertEqual(self.get_unfiltered_symbol_input_text(), SET_KEY_FIRST) + self.assertFalse(self.output_box.get_sensitive()) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/system/gui/test_user_interface.py b/tests/system/gui/test_user_interface.py new file mode 100644 index 0000000..37365d1 --- /dev/null +++ b/tests/system/gui/test_user_interface.py @@ -0,0 +1,113 @@ +import unittest +from unittest.mock import MagicMock + +import gi +from evdev.ecodes import EV_KEY, KEY_A + +gi.require_version("Gdk", "3.0") +gi.require_version("Gtk", "3.0") +gi.require_version("GLib", "2.0") +gi.require_version("GtkSource", "4") +from gi.repository import Gtk, Gdk, GLib + +from inputremapper.gui.utils import gtk_iteration +from inputremapper.gui.messages.message_broker import MessageBroker, MessageType +from inputremapper.gui.user_interface import UserInterface +from inputremapper.configs.mapping import MappingData +from inputremapper.configs.input_config import InputCombination, InputConfig +from tests.lib.test_setup import test_setup + + +@test_setup +class TestUserInterface(unittest.TestCase): + def setUp(self) -> None: + self.message_broker = MessageBroker() + self.controller_mock = MagicMock() + self.user_interface = UserInterface(self.message_broker, self.controller_mock) + + def tearDown(self) -> None: + super().tearDown() + self.message_broker.signal(MessageType.terminate) + GLib.timeout_add(0, self.user_interface.window.destroy) + GLib.timeout_add(0, Gtk.main_quit) + Gtk.main() + + def test_shortcut(self): + mock = MagicMock() + self.user_interface.shortcuts[Gdk.KEY_x] = mock + + event = Gdk.Event() + event.key.keyval = Gdk.KEY_x + event.key.state = Gdk.ModifierType.SHIFT_MASK + self.user_interface.window.emit("key-press-event", event) + gtk_iteration() + mock.assert_not_called() + + event.key.state = Gdk.ModifierType.CONTROL_MASK + self.user_interface.window.emit("key-press-event", event) + gtk_iteration() + mock.assert_called_once() + + mock.reset_mock() + event.key.keyval = Gdk.KEY_y + self.user_interface.window.emit("key-press-event", event) + gtk_iteration() + mock.assert_not_called() + + def test_connected_shortcuts(self): + should_be_connected = {Gdk.KEY_q, Gdk.KEY_r, Gdk.KEY_Delete, Gdk.KEY_n} + connected = set(self.user_interface.shortcuts.keys()) + self.assertEqual(connected, should_be_connected) + + self.assertIs( + self.user_interface.shortcuts[Gdk.KEY_q], self.controller_mock.close + ) + self.assertIs( + self.user_interface.shortcuts[Gdk.KEY_r], + self.controller_mock.refresh_groups, + ) + self.assertIs( + self.user_interface.shortcuts[Gdk.KEY_Delete], + self.controller_mock.stop_injecting, + ) + + def test_connect_disconnect_shortcuts(self): + mock = MagicMock() + self.user_interface.shortcuts[Gdk.KEY_x] = mock + + event = Gdk.Event() + event.key.keyval = Gdk.KEY_x + event.key.state = Gdk.ModifierType.CONTROL_MASK + self.user_interface.disconnect_shortcuts() + self.user_interface.window.emit("key-press-event", event) + gtk_iteration() + mock.assert_not_called() + + self.user_interface.connect_shortcuts() + gtk_iteration() + self.user_interface.window.emit("key-press-event", event) + gtk_iteration() + mock.assert_called_once() + + def test_combination_label_shows_combination(self): + self.message_broker.publish( + MappingData( + input_combination=InputCombination( + [InputConfig(type=EV_KEY, code=KEY_A)] + ), + name="foo", + ) + ) + gtk_iteration() + label: Gtk.Label = self.user_interface.get("combination-label") + self.assertEqual(label.get_text(), "a") + self.assertEqual(label.get_opacity(), 1) + + def test_combination_label_shows_text_when_empty_mapping(self): + self.message_broker.publish(MappingData()) + gtk_iteration() + label: Gtk.Label = self.user_interface.get("combination-label") + self.assertEqual(label.get_text(), "no input configured") + + # 0.5 != 0.501960..., for whatever reason this number is all screwed up + self.assertAlmostEqual(label.get_opacity(), 0.5, delta=0.1) diff --git a/tests/system/test_dbus.py b/tests/system/test_dbus.py new file mode 100644 index 0000000..81dadc8 --- /dev/null +++ b/tests/system/test_dbus.py @@ -0,0 +1,85 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + +import multiprocessing +import os +import time +import unittest +from xml.etree import ElementTree as ET + +import gi + +from tests.lib.test_setup import is_service_running + +gi.require_version("Gtk", "3.0") +from gi.repository import Gtk + +from inputremapper.daemon import Daemon, DAEMON +from tests.lib.test_setup import test_setup + + +def gtk_iteration(): + """Iterate while events are pending.""" + while Gtk.events_pending(): + Gtk.main_iteration() + + +@test_setup +class TestDBusDaemon(unittest.TestCase): + def setUp(self): + # You need to install input-remapper into your system in order for this test + # to work. + self.process = multiprocessing.Process( + target=os.system, args=("input-remapper-service -d",) + ) + self.process.start() + time.sleep(1) + + # should not use pkexec, but rather connect to the previously + # spawned process + self.interface = Daemon.connect() + + def tearDown(self): + self.interface.stop_all() + os.system("pkill -f input-remapper-service") + + for _ in range(10): + time.sleep(0.1) + if not is_service_running(): + break + + self.assertFalse(is_service_running()) + + def test_can_connect(self): + # Seriously what does this test even do? It checks if the correct interface + # name is set, apparently, which does not sound like "can_connect" at all. + + # it's a remote dbus object + introspection_xml = self.interface.Introspect() + root = ET.fromstring(introspection_xml) + interfaces = [node.get("name") for node in root.findall(".//interface")] + + self.assertIn(DAEMON.interface_name, interfaces) + self.assertFalse(isinstance(self.interface, Daemon)) + self.assertEqual(self.interface.hello("foo"), "foo") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/system/test_numlockx.py b/tests/system/test_numlockx.py new file mode 100644 index 0000000..59f9cd5 --- /dev/null +++ b/tests/system/test_numlockx.py @@ -0,0 +1,56 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + + +import unittest + +from inputremapper.injection.numlock import is_numlock_on, set_numlock, ensure_numlock +from tests.lib.test_setup import test_setup + + +@test_setup +class TestNumlock(unittest.TestCase): + def test_numlock(self): + before = is_numlock_on() + + set_numlock(not before) # should change + self.assertEqual(not before, is_numlock_on()) + + @ensure_numlock + def wrapped_1(): + set_numlock(not is_numlock_on()) + + @ensure_numlock + def wrapped_2(): + pass + + # should not change + wrapped_1() + self.assertEqual(not before, is_numlock_on()) + wrapped_2() + self.assertEqual(not before, is_numlock_on()) + + # toggle one more time to restore the previous configuration + set_numlock(before) + self.assertEqual(before, is_numlock_on()) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/__init__.py b/tests/unit/__init__.py new file mode 100644 index 0000000..8a6637a --- /dev/null +++ b/tests/unit/__init__.py @@ -0,0 +1 @@ +"""Tests that don't require a complete linux desktop.""" diff --git a/tests/unit/test_context.py b/tests/unit/test_context.py new file mode 100644 index 0000000..db01145 --- /dev/null +++ b/tests/unit/test_context.py @@ -0,0 +1,142 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + +import unittest +from unittest.mock import patch + +from evdev.ecodes import ( + EV_REL, + EV_ABS, + ABS_X, + ABS_Y, + REL_WHEEL_HI_RES, + REL_HWHEEL_HI_RES, +) + +from inputremapper.configs.input_config import InputCombination +from inputremapper.configs.mapping import Mapping +from inputremapper.configs.preset import Preset +from inputremapper.injection.context import Context +from inputremapper.injection.global_uinputs import GlobalUInputs, UInput +from inputremapper.injection.mapping_handlers.macro_handler import MacroHandler +from inputremapper.injection.mapping_handlers.mapping_parser import MappingParser +from inputremapper.input_event import InputEvent +from tests.lib.test_setup import test_setup + + +@test_setup +class TestContext(unittest.TestCase): + def test_callbacks(self): + global_uinputs = GlobalUInputs(UInput) + mapping_parser = MappingParser(global_uinputs) + + preset = Preset() + cfg = { + "input_combination": InputCombination.from_tuples((EV_ABS, ABS_X)), + "target_uinput": "mouse", + "output_type": EV_REL, + "output_code": REL_HWHEEL_HI_RES, + } + preset.add(Mapping(**cfg)) # abs x -> wheel + cfg["input_combination"] = InputCombination.from_tuples((EV_ABS, ABS_Y)) + cfg["output_code"] = REL_WHEEL_HI_RES + preset.add(Mapping(**cfg)) # abs y -> wheel + + preset.add( + Mapping.from_combination( + InputCombination.from_tuples((1, 31)), "keyboard", "key(a)" + ) + ) + preset.add( + Mapping.from_combination( + InputCombination.from_tuples((1, 32)), "keyboard", "b" + ) + ) + + # overlapping combination for (1, 32, 1) + preset.add( + Mapping.from_combination( + InputCombination.from_tuples((1, 32), (1, 33), (1, 34)), + "keyboard", + "c", + ) + ) + + # map abs x to key "b" + preset.add( + Mapping.from_combination( + InputCombination.from_tuples((EV_ABS, ABS_X, 20)), + "keyboard", + "d", + ), + ) + + context = Context(preset, {}, {}, mapping_parser) + + expected_num_callbacks = { + # ABS_X -> "d" and ABS_X -> wheel have the same type and code + InputEvent.abs(ABS_X, 1): 2, + InputEvent.abs(ABS_Y, 1): 1, + InputEvent.key(31, 1): 1, + # even though we have 2 mappings with this type and code, we only expect + # one callback because they both map to keys. We don't want to trigger two + # mappings with the same key press + InputEvent.key(32, 1): 1, + InputEvent.key(33, 1): 1, + InputEvent.key(34, 1): 1, + } + + self.assertEqual( + set([event.input_match_hash for event in expected_num_callbacks.keys()]), + set(context._notify_callbacks.keys()), + ) + for input_event, num_callbacks in expected_num_callbacks.items(): + self.assertEqual( + num_callbacks, + len(context.get_notify_callbacks(input_event)), + ) + + # 7 unique input events in the preset + self.assertEqual(7, len(context._handlers)) + + def test_reset(self): + global_uinputs = GlobalUInputs(UInput) + mapping_parser = MappingParser(global_uinputs) + + preset = Preset() + preset.add( + Mapping.from_combination( + InputCombination.from_tuples((1, 31)), + "keyboard", + "key(a)", + ) + ) + + context = Context(preset, {}, {}, mapping_parser) + + self.assertEqual(1, len(context._handlers)) + + with patch.object(MacroHandler, "reset") as reset_mock: + context.reset() + reset_mock.assert_called_once() + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/test_control.py b/tests/unit/test_control.py new file mode 100644 index 0000000..f6442c5 --- /dev/null +++ b/tests/unit/test_control.py @@ -0,0 +1,407 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + +"""Testing the input-remapper-control command""" + +import collections +import os +import time +import unittest +from unittest.mock import patch, MagicMock + +from inputremapper.bin.input_remapper_control import InputRemapperControlBin +from inputremapper.configs.global_config import GlobalConfig +from inputremapper.configs.migrations import Migrations +from inputremapper.configs.paths import PathUtils +from inputremapper.configs.preset import Preset +from inputremapper.daemon import Daemon +from inputremapper.groups import groups +from inputremapper.injection.global_uinputs import GlobalUInputs, FrontendUInput +from inputremapper.injection.mapping_handlers.mapping_parser import MappingParser +from tests.lib.test_setup import test_setup +from tests.lib.tmp import tmp + +options = collections.namedtuple( + "options", + ["command", "config_dir", "preset", "device", "list_devices", "key_names", "debug"], +) + + +def remove_timeout_from_calls(method: str): + # Remove the timeout argument, which is used by dasbus but not actually + # passed to the Daemon class methods. Since we create the Daemon object directly + # for this test it would raise an exception otherwise, for example: + # TypeError: Daemon.autoload_single() got an unexpected keyword argument 'timeout' + def decorator(func): + def wrapped(*args, **kwargs): + original_method = getattr(Daemon, method) + with patch.object( + Daemon, + method, + lambda *args, timeout=0, **kwargs: original_method(*args, **kwargs), + ): + return func(*args, **kwargs) + + return wrapped + + return decorator + + +@test_setup +class TestControl(unittest.TestCase): + def setUp(self): + self.global_config = GlobalConfig() + self.global_uinputs = GlobalUInputs(FrontendUInput) + self.migrations = Migrations(self.global_uinputs) + self.mapping_parser = MappingParser(self.global_uinputs) + self.input_remapper_control = InputRemapperControlBin( + self.global_config, self.migrations + ) + + @remove_timeout_from_calls("autoload") + @remove_timeout_from_calls("autoload_single") + def test_autoload(self): + device_keys = ["Foo Device 2", "Bar Device"] + groups_ = [groups.find(key=key) for key in device_keys] + presets = ["bar0", "bar", "bar2"] + paths = [ + PathUtils.get_preset_path(groups_[0].name, presets[0]), + PathUtils.get_preset_path(groups_[1].name, presets[1]), + PathUtils.get_preset_path(groups_[1].name, presets[2]), + ] + + Preset(paths[0]).save() + Preset(paths[1]).save() + Preset(paths[2]).save() + + daemon = Daemon(self.global_config, self.global_uinputs, self.mapping_parser) + + self.input_remapper_control.set_daemon(daemon) + + start_history = [] + stop_counter = 0 + + # using an actual injector is not within the scope of this test + class Injector: + def stop_injecting(self, *args, **kwargs): + nonlocal stop_counter + stop_counter += 1 + + def start_injecting(device: str, preset: str): + print(f'\033[90mstart_injecting "{device}" "{preset}"\033[0m') + start_history.append((device, preset)) + daemon.injectors[device] = Injector() + + patch.object(daemon, "start_injecting", start_injecting).start() + + self.global_config.set_autoload_preset(groups_[0].key, presets[0]) + self.global_config.set_autoload_preset(groups_[1].key, presets[1]) + + self.input_remapper_control.communicate( + command="autoload", + config_dir=None, + preset=None, + device=None, + ) + + self.assertEqual(len(start_history), 2) + self.assertEqual(start_history[0], (groups_[0].key, presets[0])) + self.assertEqual(start_history[1], (groups_[1].key, presets[1])) + self.assertIn(groups_[0].key, daemon.injectors) + self.assertIn(groups_[1].key, daemon.injectors) + self.assertFalse( + daemon.autoload_history.may_autoload(groups_[0].key, presets[0]) + ) + self.assertFalse( + daemon.autoload_history.may_autoload(groups_[1].key, presets[1]) + ) + + # calling autoload again doesn't load redundantly + self.input_remapper_control.communicate( + command="autoload", + config_dir=None, + preset=None, + device=None, + ) + self.assertEqual(len(start_history), 2) + self.assertEqual(stop_counter, 0) + self.assertFalse( + daemon.autoload_history.may_autoload(groups_[0].key, presets[0]) + ) + self.assertFalse( + daemon.autoload_history.may_autoload(groups_[1].key, presets[1]) + ) + + # unless the injection in question ist stopped + self.input_remapper_control.communicate( + command="stop", + config_dir=None, + preset=None, + device=groups_[0].key, + ) + self.assertEqual(stop_counter, 1) + self.assertTrue( + daemon.autoload_history.may_autoload(groups_[0].key, presets[0]) + ) + self.assertFalse( + daemon.autoload_history.may_autoload(groups_[1].key, presets[1]) + ) + self.input_remapper_control.communicate( + command="autoload", + config_dir=None, + preset=None, + device=None, + ) + self.assertEqual(len(start_history), 3) + self.assertEqual(start_history[2], (groups_[0].key, presets[0])) + self.assertFalse( + daemon.autoload_history.may_autoload(groups_[0].key, presets[0]) + ) + self.assertFalse( + daemon.autoload_history.may_autoload(groups_[1].key, presets[1]) + ) + + # if a device name is passed, will only start injecting for that one + self.input_remapper_control.communicate( + command="stop-all", + config_dir=None, + preset=None, + device=None, + ) + self.assertTrue( + daemon.autoload_history.may_autoload(groups_[0].key, presets[0]) + ) + self.assertTrue( + daemon.autoload_history.may_autoload(groups_[1].key, presets[1]) + ) + self.assertEqual(stop_counter, 3) + self.global_config.set_autoload_preset(groups_[1].key, presets[2]) + self.input_remapper_control.communicate( + command="autoload", + config_dir=None, + preset=None, + device=groups_[1].key, + ) + self.assertEqual(len(start_history), 4) + self.assertEqual(start_history[3], (groups_[1].key, presets[2])) + self.assertTrue( + daemon.autoload_history.may_autoload(groups_[0].key, presets[0]) + ) + self.assertFalse( + daemon.autoload_history.may_autoload(groups_[1].key, presets[2]) + ) + + # autoloading for the same device again redundantly will not autoload + # again + self.input_remapper_control.communicate( + command="autoload", + config_dir=None, + preset=None, + device=groups_[1].key, + ) + self.assertEqual(len(start_history), 4) + self.assertEqual(stop_counter, 3) + self.assertFalse( + daemon.autoload_history.may_autoload(groups_[1].key, presets[2]) + ) + + # any other arbitrary preset may be autoloaded + self.assertTrue(daemon.autoload_history.may_autoload(groups_[1].key, "quuuux")) + + # after 15 seconds it may be autoloaded again + daemon.autoload_history._autoload_history[groups_[1].key] = ( + time.time() - 16, + presets[2], + ) + self.assertTrue( + daemon.autoload_history.may_autoload(groups_[1].key, presets[2]) + ) + + @remove_timeout_from_calls("autoload") + def test_autoload_other_path(self): + device_names = ["Foo Device", "Bar Device"] + groups_ = [groups.find(name=name) for name in device_names] + presets = ["bar123", "bar2"] + config_dir = os.path.join(tmp, "qux", "quux") + paths = [ + os.path.join(config_dir, "presets", device_names[0], presets[0] + ".json"), + os.path.join(config_dir, "presets", device_names[1], presets[1] + ".json"), + ] + + Preset(paths[0]).save() + Preset(paths[1]).save() + + daemon = Daemon(self.global_config, self.global_uinputs, self.mapping_parser) + self.input_remapper_control.set_daemon(daemon) + + start_history = [] + daemon.start_injecting = lambda *args: start_history.append(args) + + self.global_config.path = os.path.join(config_dir, "config.json") + self.global_config.load_config() + self.global_config.set_autoload_preset(device_names[0], presets[0]) + self.global_config.set_autoload_preset(device_names[1], presets[1]) + + self.input_remapper_control.communicate( + command="autoload", + config_dir=config_dir, + preset=None, + device=None, + ) + + self.assertEqual(len(start_history), 2) + self.assertEqual(start_history[0], (groups_[0].key, presets[0])) + self.assertEqual(start_history[1], (groups_[1].key, presets[1])) + + def test_start_stop(self): + group = groups.find(key="Foo Device 2") + preset = "preset9" + + daemon = Daemon(self.global_config, self.global_uinputs, self.mapping_parser) + self.input_remapper_control.set_daemon(daemon) + + start_history = [] + stop_history = [] + stop_all_history = [] + daemon.start_injecting = lambda *args: start_history.append(args) + daemon.stop_injecting = lambda *args: stop_history.append(args) + daemon.stop_all = lambda *args: stop_all_history.append(args) + + self.input_remapper_control.communicate( + command="start", + config_dir=None, + preset=preset, + device=group.paths[0], + ) + self.assertEqual(len(start_history), 1) + self.assertEqual(start_history[0], (group.key, preset)) + + self.input_remapper_control.communicate( + command="stop", + config_dir=None, + preset=None, + device=group.paths[1], + ) + self.assertEqual(len(stop_history), 1) + # provided any of the groups paths as --device argument, figures out + # the correct group.key to use here + self.assertEqual(stop_history[0], (group.key,)) + + self.input_remapper_control.communicate( + command="stop-all", + config_dir=None, + preset=None, + device=None, + ) + self.assertEqual(len(stop_all_history), 1) + self.assertEqual(stop_all_history[0], ()) + + @patch.object(Daemon, "quit") + def test_quit(self, quit_mock: MagicMock) -> None: + group = groups.find(key="Foo Device 2") + assert group is not None + preset = "preset9" + + daemon = Daemon(self.global_config, self.global_uinputs, self.mapping_parser) + self.input_remapper_control.set_daemon(daemon) + + self.input_remapper_control.communicate( + command="quit", + config_dir=None, + preset=preset, + device=group.paths[0], + ) + + quit_mock.assert_called_once() + + def test_config_not_found(self): + key = "Foo Device 2" + path = "~/a/preset.json" + config_dir = "/foo/bar" + + daemon = Daemon(self.global_config, self.global_uinputs, self.mapping_parser) + self.input_remapper_control.set_daemon(daemon) + + start_history = [] + stop_history = [] + daemon.start_injecting = lambda *args: start_history.append(args) + daemon.stop_injecting = lambda *args: stop_history.append(args) + + self.assertRaises( + SystemExit, + lambda: self.input_remapper_control.communicate( + command="start", + config_dir=config_dir, + preset=path, + device=key, + ), + ) + + self.assertRaises( + SystemExit, + lambda: self.input_remapper_control.communicate( + command="stop", + config_dir=config_dir, + preset=None, + device=key, + ), + ) + + def test_autoload_config_dir(self): + daemon = Daemon(self.global_config, self.global_uinputs, self.mapping_parser) + + path = os.path.join(tmp, "foo") + os.makedirs(path) + with open(os.path.join(path, "config.json"), "w") as file: + file.write('{"autoload":{"foo": "bar"}}') + + self.assertIsNone(self.global_config.get_autoload_preset("foo")) + daemon.set_config_dir(path) + # since daemon and this test share the same memory, the global_config + # object that this test can access will be modified + self.assertEqual(self.global_config.get_autoload_preset("foo"), "bar") + + # passing a path that doesn't exist or a path that doesn't contain + # a config.json file won't do anything + os.makedirs(os.path.join(tmp, "bar")) + daemon.set_config_dir(os.path.join(tmp, "bar")) + self.assertEqual(self.global_config.get_autoload_preset("foo"), "bar") + daemon.set_config_dir(os.path.join(tmp, "qux")) + self.assertEqual(self.global_config.get_autoload_preset("foo"), "bar") + + def test_internals_reader(self): + with patch.object(os, "system") as os_system_patch: + self.input_remapper_control.internals("start-reader-service", False) + os_system_patch.assert_called_once() + self.assertIn( + "input-remapper-reader-service", os_system_patch.call_args.args[0] + ) + self.assertNotIn("-d", os_system_patch.call_args.args[0]) + + def test_internals_daemon(self): + with patch.object(os, "system") as os_system_patch: + self.input_remapper_control.internals("start-daemon", True) + os_system_patch.assert_called_once() + self.assertIn("input-remapper-service", os_system_patch.call_args.args[0]) + self.assertIn("-d", os_system_patch.call_args.args[0]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/test_controller.py b/tests/unit/test_controller.py new file mode 100644 index 0000000..c328833 --- /dev/null +++ b/tests/unit/test_controller.py @@ -0,0 +1,1627 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + +import os.path +import unittest +from typing import List +from unittest.mock import patch, MagicMock, call + +import gi +from evdev.ecodes import EV_ABS, ABS_X, ABS_Y, ABS_RX + +from inputremapper.configs.keyboard_layout import keyboard_layout +from inputremapper.injection.injector import InjectorState + +gi.require_version("Gtk", "3.0") +from gi.repository import Gtk + +from inputremapper.configs.input_config import InputCombination, InputConfig +from inputremapper.groups import _Groups +from inputremapper.gui.messages.message_broker import ( + MessageBroker, + MessageType, +) +from inputremapper.gui.messages.message_data import ( + GroupsData, + GroupData, + PresetData, + StatusData, + CombinationRecorded, + CombinationUpdate, + UserConfirmRequest, +) +from inputremapper.gui.reader_client import ReaderClient +from inputremapper.gui.utils import CTX_ERROR, CTX_APPLY, gtk_iteration +from inputremapper.gui.gettext import _ +from inputremapper.injection.global_uinputs import GlobalUInputs, FrontendUInput +from inputremapper.configs.mapping import UIMapping, MappingData, Mapping +from tests.lib.spy import spy +from tests.lib.patches import FakeDaemonProxy +from tests.lib.fixtures import fixtures, prepare_presets +from inputremapper.configs.global_config import GlobalConfig +from inputremapper.gui.controller import Controller, MAPPING_DEFAULTS +from inputremapper.gui.data_manager import DataManager, DEFAULT_PRESET_NAME +from inputremapper.configs.paths import PathUtils +from inputremapper.configs.preset import Preset +from tests.lib.test_setup import test_setup + + +@test_setup +class TestController(unittest.TestCase): + def setUp(self) -> None: + super().setUp() + self.message_broker = MessageBroker() + uinputs = GlobalUInputs(FrontendUInput) + uinputs.prepare_all() + self.data_manager = DataManager( + self.message_broker, + GlobalConfig(), + ReaderClient(self.message_broker, _Groups()), + FakeDaemonProxy(), + uinputs, + keyboard_layout, + ) + self.user_interface = MagicMock() + self.controller = Controller(self.message_broker, self.data_manager) + self.controller.set_gui(self.user_interface) + + def test_should_get_newest_group(self): + """get_a_group should the newest group.""" + with patch.object( + self.data_manager, "get_newest_group_key", MagicMock(return_value="foo") + ): + self.assertEqual(self.controller.get_a_group(), "foo") + + def test_should_get_any_group(self): + """get_a_group should return a valid group.""" + with patch.object( + self.data_manager, + "get_newest_group_key", + MagicMock(side_effect=FileNotFoundError), + ): + fixture_keys = [fixture.group_key or fixture.name for fixture in fixtures] + self.assertIn(self.controller.get_a_group(), fixture_keys) + + def test_should_get_newest_preset(self): + """get_a_group should the newest group.""" + with patch.object( + self.data_manager, "get_newest_preset_name", MagicMock(return_value="bar") + ): + self.data_manager.load_group("Foo Device") + self.assertEqual(self.controller.get_a_preset(), "bar") + + def test_should_get_any_preset(self): + """get_a_preset should return a new preset if none exist.""" + self.data_manager.load_group("Foo Device") + # the default name + self.assertEqual(self.controller.get_a_preset(), "new preset") + + def test_on_init_should_provide_uinputs(self): + calls = [] + + def f(data): + calls.append(data) + + self.message_broker.subscribe(MessageType.uinputs, f) + self.message_broker.signal(MessageType.init) + self.assertEqual( + ["keyboard", "gamepad", "mouse", "keyboard + mouse"], + list(calls[-1].uinputs.keys()), + ) + + def test_on_init_should_provide_groups(self): + calls: List[GroupsData] = [] + + def f(groups): + calls.append(groups) + + self.message_broker.subscribe(MessageType.groups, f) + self.message_broker.signal(MessageType.init) + self.assertEqual( + ["Foo Device", "Foo Device 2", "Bar Device", "gamepad", "Qux/[Device]?"], + list(calls[-1].groups.keys()), + ) + + def test_on_init_should_provide_a_group(self): + calls: List[GroupData] = [] + + def f(data): + calls.append(data) + + self.message_broker.subscribe(MessageType.group, f) + self.message_broker.signal(MessageType.init) + self.assertGreaterEqual(len(calls), 1) + + def test_on_init_should_provide_a_preset(self): + calls: List[PresetData] = [] + + def f(data): + calls.append(data) + + self.message_broker.subscribe(MessageType.preset, f) + self.message_broker.signal(MessageType.init) + self.assertGreaterEqual(len(calls), 1) + + def test_on_init_should_provide_a_mapping(self): + """Only if there is one.""" + prepare_presets() + calls: List[MappingData] = [] + + def f(data): + calls.append(data) + + self.message_broker.subscribe(MessageType.mapping, f) + self.message_broker.signal(MessageType.init) + self.assertTrue(calls[-1].is_valid()) + + def test_on_init_should_provide_a_default_mapping(self): + """If there is no real preset available""" + calls: List[MappingData] = [] + + def f(data): + calls.append(data) + + self.message_broker.subscribe(MessageType.mapping, f) + self.message_broker.signal(MessageType.init) + for m in calls: + self.assertEqual(m, UIMapping(**MAPPING_DEFAULTS)) + + def test_on_load_group_should_provide_preset(self): + with patch.object(self.data_manager, "load_preset") as mock: + self.controller.load_group("Foo Device") + mock.assert_called_once() + + def test_on_load_group_should_provide_mapping(self): + """If there is one""" + prepare_presets() + calls: List[MappingData] = [] + + def f(data): + calls.append(data) + + self.message_broker.subscribe(MessageType.mapping, f) + self.controller.load_group(group_key="Foo Device 2") + self.assertTrue(calls[-1].is_valid()) + + def test_on_load_group_should_provide_default_mapping(self): + """If there is none.""" + calls: List[MappingData] = [] + + def f(data): + calls.append(data) + + self.message_broker.subscribe(MessageType.mapping, f) + + self.controller.load_group(group_key="Foo Device") + for m in calls: + self.assertEqual(m, UIMapping(**MAPPING_DEFAULTS)) + + def test_on_load_preset_should_provide_mapping(self): + """If there is one.""" + prepare_presets() + self.data_manager.load_group("Foo Device 2") + calls: List[MappingData] = [] + + def f(data): + calls.append(data) + + self.message_broker.subscribe(MessageType.mapping, f) + self.controller.load_preset(name="preset2") + self.assertTrue(calls[-1].is_valid()) + + def test_on_load_preset_should_provide_default_mapping(self): + """If there is none.""" + Preset(PathUtils.get_preset_path("Foo Device", "bar")).save() + self.data_manager.load_group("Foo Device 2") + calls: List[MappingData] = [] + + def f(data): + calls.append(data) + + self.message_broker.subscribe(MessageType.mapping, f) + self.controller.load_preset(name="bar") + for m in calls: + self.assertEqual(m, UIMapping(**MAPPING_DEFAULTS)) + + def test_on_delete_preset_asks_for_confirmation(self): + prepare_presets() + self.message_broker.signal(MessageType.init) + mock = MagicMock() + self.message_broker.subscribe(MessageType.user_confirm_request, mock) + self.controller.delete_preset() + mock.assert_called_once() + + def test_deletes_preset_when_confirmed(self): + prepare_presets() + self.assertTrue( + os.path.isfile(PathUtils.get_preset_path("Foo Device", "preset2")) + ) + + self.data_manager.load_group("Foo Device 2") + self.data_manager.load_preset("preset2") + self.message_broker.subscribe( + MessageType.user_confirm_request, lambda msg: msg.respond(True) + ) + self.controller.delete_preset() + self.assertFalse( + os.path.exists(PathUtils.get_preset_path("Foo Device", "preset2")) + ) + + def test_does_not_delete_preset_when_not_confirmed(self): + prepare_presets() + self.assertTrue( + os.path.isfile(PathUtils.get_preset_path("Foo Device", "preset2")) + ) + self.data_manager.load_group("Foo Device 2") + self.data_manager.load_preset("preset2") + self.user_interface.confirm_delete.configure_mock( + return_value=Gtk.ResponseType.CANCEL + ) + self.controller.delete_preset() + self.assertTrue( + os.path.isfile(PathUtils.get_preset_path("Foo Device", "preset2")) + ) + + def test_copy_preset(self): + prepare_presets() + self.assertTrue( + os.path.isfile(PathUtils.get_preset_path("Foo Device", "preset2")) + ) + self.data_manager.load_group("Foo Device 2") + self.data_manager.load_preset("preset2") + self.controller.copy_preset() + + self.assertTrue( + os.path.exists(PathUtils.get_preset_path("Foo Device", "preset2")) + ) + self.assertTrue( + os.path.exists(PathUtils.get_preset_path("Foo Device", "preset2 copy")) + ) + + def test_copy_preset_should_add_number(self): + prepare_presets() + self.assertTrue( + os.path.isfile(PathUtils.get_preset_path("Foo Device", "preset2")) + ) + + self.data_manager.load_group("Foo Device 2") + self.data_manager.load_preset("preset2") + self.controller.copy_preset() # creates "preset2 copy" + self.data_manager.load_preset("preset2") + self.controller.copy_preset() # creates "preset2 copy 2" + + self.assertTrue( + os.path.exists(PathUtils.get_preset_path("Foo Device", "preset2")) + ) + self.assertTrue( + os.path.exists(PathUtils.get_preset_path("Foo Device", "preset2 copy")) + ) + self.assertTrue( + os.path.exists(PathUtils.get_preset_path("Foo Device", "preset2 copy 2")) + ) + + def test_copy_preset_should_increment_existing_number(self): + prepare_presets() + self.assertTrue( + os.path.isfile(PathUtils.get_preset_path("Foo Device", "preset2")) + ) + + self.data_manager.load_group("Foo Device 2") + self.data_manager.load_preset("preset2") + self.controller.copy_preset() # creates "preset2 copy" + self.data_manager.load_preset("preset2") + self.controller.copy_preset() # creates "preset2 copy 2" + self.data_manager.load_preset("preset2") + self.controller.copy_preset() # creates "preset2 copy 3" + + self.assertTrue( + os.path.exists(PathUtils.get_preset_path("Foo Device", "preset2")) + ) + self.assertTrue( + os.path.exists(PathUtils.get_preset_path("Foo Device", "preset2 copy")) + ) + self.assertTrue( + os.path.exists(PathUtils.get_preset_path("Foo Device", "preset2 copy 2")) + ) + self.assertTrue( + os.path.exists(PathUtils.get_preset_path("Foo Device", "preset2 copy 3")) + ) + + def test_copy_preset_should_not_append_copy_twice(self): + prepare_presets() + self.assertTrue( + os.path.isfile(PathUtils.get_preset_path("Foo Device", "preset2")) + ) + + self.data_manager.load_group("Foo Device 2") + self.data_manager.load_preset("preset2") + self.controller.copy_preset() # creates "preset2 copy" + self.controller.copy_preset() # creates "preset2 copy 2" not "preset2 copy copy" + + self.assertTrue( + os.path.exists(PathUtils.get_preset_path("Foo Device", "preset2")) + ) + self.assertTrue( + os.path.exists(PathUtils.get_preset_path("Foo Device", "preset2 copy")) + ) + self.assertTrue( + os.path.exists(PathUtils.get_preset_path("Foo Device", "preset2 copy 2")) + ) + + def test_copy_preset_should_not_append_copy_to_copy_with_number(self): + prepare_presets() + self.assertTrue( + os.path.isfile(PathUtils.get_preset_path("Foo Device", "preset2")) + ) + + self.data_manager.load_group("Foo Device 2") + self.data_manager.load_preset("preset2") + self.controller.copy_preset() # creates "preset2 copy" + self.data_manager.load_preset("preset2") + self.controller.copy_preset() # creates "preset2 copy 2" + self.controller.copy_preset() # creates "preset2 copy 3" not "preset2 copy 2 copy" + + self.assertTrue( + os.path.exists(PathUtils.get_preset_path("Foo Device", "preset2")) + ) + self.assertTrue( + os.path.exists(PathUtils.get_preset_path("Foo Device", "preset2 copy")) + ) + self.assertTrue( + os.path.exists(PathUtils.get_preset_path("Foo Device", "preset2 copy 2")) + ) + self.assertTrue( + os.path.exists(PathUtils.get_preset_path("Foo Device", "preset2 copy 3")) + ) + + def test_rename_preset(self): + prepare_presets() + self.assertTrue( + os.path.isfile(PathUtils.get_preset_path("Foo Device", "preset2")) + ) + self.assertFalse(os.path.exists(PathUtils.get_preset_path("Foo Device", "foo"))) + + self.data_manager.load_group("Foo Device 2") + self.data_manager.load_preset("preset2") + self.controller.rename_preset(new_name="foo") + + self.assertFalse( + os.path.exists(PathUtils.get_preset_path("Foo Device", "preset2")) + ) + self.assertTrue(os.path.exists(PathUtils.get_preset_path("Foo Device", "foo"))) + + def test_rename_preset_sanitized(self): + Preset(PathUtils.get_preset_path("Qux/[Device]?", "bla")).save() + + self.assertTrue( + os.path.isfile(PathUtils.get_preset_path("Qux/[Device]?", "bla")) + ) + self.assertFalse( + os.path.exists(PathUtils.get_preset_path("Qux/[Device]?", "blubb")) + ) + + self.data_manager.load_group("Qux/[Device]?") + self.data_manager.load_preset("bla") + self.controller.rename_preset(new_name="foo:/bar") + + # all functions expect the true name, which is also shown to the user, but on + # the file system it always uses sanitized names. + self.assertTrue( + os.path.exists(PathUtils.get_preset_path("Qux/[Device]?", "foo__bar")) + ) + + # since the name is never stored in an un-sanitized way, this can't work + self.assertFalse( + os.path.exists(PathUtils.get_preset_path("Qux/[Device]?", "foo:/bar")) + ) + + path = os.path.join( + PathUtils.config_path(), "presets", "Qux_[Device]_", "foo__bar.json" + ) + self.assertTrue(os.path.exists(path)) + + # using the sanitized name in function calls works as well + self.assertTrue( + os.path.isfile(PathUtils.get_preset_path("Qux_[Device]_", "foo__bar")) + ) + + def test_rename_preset_should_pick_available_name(self): + prepare_presets() + self.assertTrue( + os.path.exists(PathUtils.get_preset_path("Foo Device", "preset2")) + ) + self.assertTrue( + os.path.exists(PathUtils.get_preset_path("Foo Device", "preset3")) + ) + self.assertFalse( + os.path.exists(PathUtils.get_preset_path("Foo Device", "preset3 2")) + ) + + self.data_manager.load_group("Foo Device") + self.data_manager.load_preset("preset2") + self.controller.rename_preset(new_name="preset3") + + self.assertFalse( + os.path.exists(PathUtils.get_preset_path("Foo Device", "preset2")) + ) + self.assertTrue( + os.path.exists(PathUtils.get_preset_path("Foo Device", "preset3")) + ) + self.assertTrue( + os.path.exists(PathUtils.get_preset_path("Foo Device", "preset3 2")) + ) + + def test_rename_preset_should_not_rename_to_empty_name(self): + prepare_presets() + self.assertTrue( + os.path.exists(PathUtils.get_preset_path("Foo Device", "preset2")) + ) + + self.data_manager.load_group("Foo Device") + self.data_manager.load_preset("preset2") + self.controller.rename_preset(new_name="") + + self.assertTrue( + os.path.exists(PathUtils.get_preset_path("Foo Device", "preset2")) + ) + + def test_rename_preset_should_not_update_same_name(self): + """When the new name is the same as the current name.""" + prepare_presets() + self.assertTrue( + os.path.exists(PathUtils.get_preset_path("Foo Device", "preset2")) + ) + + self.data_manager.load_group("Foo Device 2") + self.data_manager.load_preset("preset2") + self.controller.rename_preset(new_name="preset2") + + self.assertTrue( + os.path.exists(PathUtils.get_preset_path("Foo Device", "preset2")) + ) + self.assertFalse( + os.path.exists(PathUtils.get_preset_path("Foo Device", "preset2 2")) + ) + + def test_on_add_preset_uses_default_name(self): + self.assertFalse( + os.path.exists(PathUtils.get_preset_path("Foo Device", DEFAULT_PRESET_NAME)) + ) + + self.data_manager.load_group("Foo Device 2") + + self.controller.add_preset() + self.assertTrue( + os.path.exists(PathUtils.get_preset_path("Foo Device", "new preset")) + ) + + def test_on_add_preset_uses_provided_name(self): + self.assertFalse(os.path.exists(PathUtils.get_preset_path("Foo Device", "foo"))) + + self.data_manager.load_group("Foo Device 2") + + self.controller.add_preset(name="foo") + self.assertTrue(os.path.exists(PathUtils.get_preset_path("Foo Device", "foo"))) + + def test_on_add_preset_shows_permission_error_status(self): + self.data_manager.load_group("Foo Device 2") + + msg = None + + def f(data): + nonlocal msg + msg = data + + self.message_broker.subscribe(MessageType.status_msg, f) + mock = MagicMock(side_effect=PermissionError) + with patch("inputremapper.configs.preset.Preset.save", mock): + self.controller.add_preset("foo") + + mock.assert_called() + self.assertIsNotNone(msg) + self.assertIn("Permission denied", msg.msg) + + def test_on_update_mapping(self): + """Update_mapping should call data_manager.update_mapping. + + This ensures mapping_changed is emitted. + """ + prepare_presets() + self.data_manager.load_group("Foo Device 2") + self.data_manager.load_preset("preset2") + self.data_manager.load_mapping( + combination=InputCombination([InputConfig(type=1, code=4)]) + ) + + with patch.object(self.data_manager, "update_mapping") as mock: + self.controller.update_mapping( + name="foo", + output_symbol="f", + release_timeout=0.3, + ) + mock.assert_called_once() + + def test_create_mapping_will_load_the_created_mapping(self): + prepare_presets() + self.data_manager.load_group("Foo Device 2") + self.data_manager.load_preset("preset2") + + calls: List[MappingData] = [] + + def f(data): + calls.append(data) + + self.message_broker.subscribe(MessageType.mapping, f) + self.controller.create_mapping() + + self.assertEqual(calls[-1], UIMapping(**MAPPING_DEFAULTS)) + + def test_create_mapping_should_not_create_multiple_empty_mappings(self): + prepare_presets() + self.data_manager.load_group("Foo Device 2") + self.data_manager.load_preset("preset2") + self.controller.create_mapping() # create a first empty mapping + + calls = [] + + def f(data): + calls.append(data) + + self.message_broker.subscribe(MessageType.mapping, f) + self.message_broker.subscribe(MessageType.preset, f) + + self.controller.create_mapping() # try to create a second one + self.assertEqual(len(calls), 0) + + def test_delete_mapping_asks_for_confirmation(self): + prepare_presets() + self.message_broker.signal(MessageType.init) + mock = MagicMock() + self.message_broker.subscribe(MessageType.user_confirm_request, mock) + self.controller.delete_mapping() + mock.assert_called_once() + + def test_deletes_mapping_when_confirmed(self): + prepare_presets() + self.assertTrue( + os.path.isfile(PathUtils.get_preset_path("Foo Device", "preset2")) + ) + + self.data_manager.load_group("Foo Device 2") + self.data_manager.load_preset("preset2") + self.data_manager.load_mapping(InputCombination([InputConfig(type=1, code=3)])) + self.message_broker.subscribe( + MessageType.user_confirm_request, lambda msg: msg.respond(True) + ) + self.controller.delete_mapping() + self.controller.save() + + preset = Preset(PathUtils.get_preset_path("Foo Device", "preset2")) + preset.load() + self.assertIsNone( + preset.get_mapping(InputCombination([InputConfig(type=1, code=3)])) + ) + + def test_does_not_delete_mapping_when_not_confirmed(self): + prepare_presets() + self.assertTrue( + os.path.isfile(PathUtils.get_preset_path("Foo Device", "preset2")) + ) + + self.data_manager.load_group("Foo Device 2") + self.data_manager.load_preset("preset2") + self.data_manager.load_mapping(InputCombination([InputConfig(type=1, code=3)])) + self.user_interface.confirm_delete.configure_mock( + return_value=Gtk.ResponseType.CANCEL + ) + + self.controller.delete_mapping() + self.controller.save() + + preset = Preset(PathUtils.get_preset_path("Foo Device", "preset2")) + preset.load() + self.assertIsNotNone( + preset.get_mapping(InputCombination([InputConfig(type=1, code=3)])) + ) + + def test_should_update_combination(self): + """When combination is free.""" + prepare_presets() + self.data_manager.load_group("Foo Device 2") + self.data_manager.load_preset("preset2") + self.data_manager.load_mapping(InputCombination([InputConfig(type=1, code=3)])) + + calls: List[CombinationUpdate] = [] + + def f(data): + calls.append(data) + + self.message_broker.subscribe(MessageType.combination_update, f) + self.controller.update_combination( + InputCombination([InputConfig(type=1, code=10)]) + ) + self.assertEqual( + calls[0], + CombinationUpdate( + InputCombination([InputConfig(type=1, code=3)]), + InputCombination([InputConfig(type=1, code=10)]), + ), + ) + + def test_should_not_update_combination(self): + """When combination is already used.""" + prepare_presets() + self.data_manager.load_group("Foo Device 2") + self.data_manager.load_preset("preset2") + self.data_manager.load_mapping(InputCombination([InputConfig(type=1, code=3)])) + + calls: List[CombinationUpdate] = [] + + def f(data): + calls.append(data) + + self.message_broker.subscribe(MessageType.combination_update, f) + self.controller.update_combination( + InputCombination([InputConfig(type=1, code=4)]) + ) + self.assertEqual(len(calls), 0) + + def test_sets_input_to_analog(self): + prepare_presets() + + input_config = InputConfig(type=EV_ABS, code=ABS_RX) + + self.data_manager.load_group("Foo Device 2") + self.data_manager.load_preset("preset2") + self.data_manager.active_preset.add( + Mapping( + input_combination=InputCombination([input_config]), + output_type=EV_ABS, + output_code=ABS_X, + target_uinput="gamepad", + ) + ) + self.data_manager.load_mapping(InputCombination([input_config])) + + self.controller.start_key_recording() + self.message_broker.publish( + CombinationRecorded( + InputCombination( + [ + InputConfig( + type=EV_ABS, + code=ABS_Y, + analog_threshold=50, + ), + InputConfig( + type=EV_ABS, + code=ABS_RX, + analog_threshold=60, + ), + ] + ) + ) + ) + + # the analog_threshold is removed automatically, otherwise the mapping doesn't + # make sense because only analog inputs can map to analog outputs. + # This is indicated by is_analog_output being true. + self.assertTrue(self.controller.data_manager.active_mapping.is_analog_output()) + + # only the first input is modified + active_mapping = self.controller.data_manager.active_mapping + self.assertEqual(active_mapping.input_combination[0].analog_threshold, None) + self.assertEqual(active_mapping.input_combination[1].analog_threshold, 60) + + def test_key_recording_disables_gui_shortcuts(self): + self.message_broker.signal(MessageType.init) + self.user_interface.disconnect_shortcuts.assert_not_called() + self.controller.start_key_recording() + self.user_interface.disconnect_shortcuts.assert_called_once() + + def test_key_recording_enables_gui_shortcuts_when_finished(self): + self.message_broker.signal(MessageType.init) + self.controller.start_key_recording() + + self.user_interface.connect_shortcuts.assert_not_called() + self.message_broker.signal(MessageType.recording_finished) + self.user_interface.connect_shortcuts.assert_called_once() + + def test_key_recording_enables_gui_shortcuts_when_stopped(self): + self.message_broker.signal(MessageType.init) + self.controller.start_key_recording() + + self.user_interface.connect_shortcuts.assert_not_called() + self.controller.stop_key_recording() + self.user_interface.connect_shortcuts.assert_called_once() + + def test_recording_messages(self): + mock1 = MagicMock() + mock2 = MagicMock() + self.message_broker.subscribe(MessageType.recording_started, mock1) + self.message_broker.subscribe(MessageType.recording_finished, mock2) + + self.message_broker.signal(MessageType.init) + self.controller.start_key_recording() + + mock1.assert_called_once() + mock2.assert_not_called() + + self.controller.stop_key_recording() + + mock1.assert_called_once() + mock2.assert_called_once() + + def test_key_recording_updates_mapping_combination(self): + prepare_presets() + self.data_manager.load_group("Foo Device 2") + self.data_manager.load_preset("preset2") + self.data_manager.load_mapping(InputCombination([InputConfig(type=1, code=3)])) + + calls: List[CombinationUpdate] = [] + + def f(data): + calls.append(data) + + self.message_broker.subscribe(MessageType.combination_update, f) + + self.controller.start_key_recording() + self.message_broker.publish( + CombinationRecorded(InputCombination([InputConfig(type=1, code=10)])) + ) + self.assertEqual( + calls[0], + CombinationUpdate( + InputCombination([InputConfig(type=1, code=3)]), + InputCombination([InputConfig(type=1, code=10)]), + ), + ) + self.message_broker.publish( + CombinationRecorded( + InputCombination(InputCombination.from_tuples((1, 10), (1, 3))) + ) + ) + self.assertEqual( + calls[1], + CombinationUpdate( + InputCombination([InputConfig(type=1, code=10)]), + InputCombination(InputCombination.from_tuples((1, 10), (1, 3))), + ), + ) + + def test_no_key_recording_when_not_started(self): + prepare_presets() + self.data_manager.load_group("Foo Device 2") + self.data_manager.load_preset("preset2") + self.data_manager.load_mapping(InputCombination([InputConfig(type=1, code=3)])) + + calls: List[CombinationUpdate] = [] + + def f(data): + calls.append(data) + + self.message_broker.subscribe(MessageType.combination_update, f) + + self.message_broker.publish( + CombinationRecorded(InputCombination([InputConfig(type=1, code=10)])) + ) + self.assertEqual(len(calls), 0) + + def test_key_recording_stops_when_finished(self): + prepare_presets() + self.data_manager.load_group("Foo Device 2") + self.data_manager.load_preset("preset2") + self.data_manager.load_mapping(InputCombination([InputConfig(type=1, code=3)])) + + calls: List[CombinationUpdate] = [] + + def f(data): + calls.append(data) + + self.message_broker.subscribe(MessageType.combination_update, f) + + self.controller.start_key_recording() + self.message_broker.publish( + CombinationRecorded(InputCombination([InputConfig(type=1, code=10)])) + ) + self.message_broker.signal(MessageType.recording_finished) + self.message_broker.publish( + CombinationRecorded( + InputCombination(InputCombination.from_tuples((1, 10), (1, 3))) + ) + ) + + self.assertEqual(len(calls), 1) # only the first was processed + + def test_key_recording_stops_when_stopped(self): + prepare_presets() + self.data_manager.load_group("Foo Device 2") + self.data_manager.load_preset("preset2") + self.data_manager.load_mapping(InputCombination([InputConfig(type=1, code=3)])) + + calls: List[CombinationUpdate] = [] + + def f(data): + calls.append(data) + + self.message_broker.subscribe(MessageType.combination_update, f) + + self.controller.start_key_recording() + self.message_broker.publish( + CombinationRecorded(InputCombination([InputConfig(type=1, code=10)])) + ) + self.controller.stop_key_recording() + self.message_broker.publish( + CombinationRecorded( + InputCombination(InputCombination.from_tuples((1, 10), (1, 3))) + ) + ) + + self.assertEqual(len(calls), 1) # only the first was processed + + def test_start_injecting_shows_status_when_preset_empty(self): + self.data_manager.load_group("Foo Device 2") + self.data_manager.create_preset("foo") + self.data_manager.load_preset("foo") + calls: List[StatusData] = [] + + def f(data): + calls.append(data) + + self.message_broker.subscribe(MessageType.status_msg, f) + + def f2(): + raise AssertionError("Injection started unexpectedly") + + self.data_manager.start_injecting = f2 + self.controller.start_injecting() + + self.assertEqual( + calls[-1], StatusData(CTX_ERROR, _("You need to add mappings first")) + ) + + def test_start_injecting_warns_about_btn_left(self): + self.data_manager.load_group("Foo Device 2") + self.data_manager.create_preset("foo") + self.data_manager.load_preset("foo") + self.data_manager.create_mapping() + self.data_manager.update_mapping( + input_combination=InputCombination([InputConfig.btn_left()]), + target_uinput="keyboard", + output_symbol="a", + ) + calls: List[StatusData] = [] + + def f(data): + calls.append(data) + + self.message_broker.subscribe(MessageType.status_msg, f) + + def f2(): + raise AssertionError("Injection started unexpectedly") + + self.data_manager.start_injecting = f2 + self.controller.start_injecting() + + self.assertEqual(calls[-1].ctx_id, CTX_ERROR) + self.assertIn("BTN_LEFT", calls[-1].tooltip) + + def test_start_injecting_starts_with_btn_left_on_second_try(self): + self.data_manager.load_group("Foo Device 2") + self.data_manager.create_preset("foo") + self.data_manager.load_preset("foo") + self.data_manager.create_mapping() + self.data_manager.update_mapping( + input_combination=InputCombination([InputConfig.btn_left()]), + target_uinput="keyboard", + output_symbol="a", + ) + + with patch.object(self.data_manager, "start_injecting") as mock: + self.controller.start_injecting() + mock.assert_not_called() + self.controller.start_injecting() + mock.assert_called_once() + + def test_start_injecting_starts_with_btn_left_when_mapped_to_other_button(self): + self.data_manager.load_group("Foo Device 2") + self.data_manager.create_preset("foo") + self.data_manager.load_preset("foo") + self.data_manager.create_mapping() + self.data_manager.update_mapping( + input_combination=InputCombination([InputConfig.btn_left()]), + target_uinput="keyboard", + output_symbol="a", + ) + self.data_manager.create_mapping() + self.data_manager.load_mapping(InputCombination.empty_combination()) + self.data_manager.update_mapping( + input_combination=InputCombination([InputConfig(type=1, code=5)]), + target_uinput="mouse", + output_symbol="BTN_LEFT", + ) + + mock = MagicMock(return_value=True) + self.data_manager.start_injecting = mock + self.controller.start_injecting() + mock.assert_called() + + def test_start_injecting_shows_status(self): + prepare_presets() + self.data_manager.load_group("Foo Device 2") + self.data_manager.load_preset("preset2") + calls: List[StatusData] = [] + + def f(data): + calls.append(data) + + self.message_broker.subscribe(MessageType.status_msg, f) + mock = MagicMock(return_value=True) + self.data_manager.start_injecting = mock + self.controller.start_injecting() + + mock.assert_called() + self.assertEqual(calls[0], StatusData(CTX_APPLY, _("Starting injection..."))) + + def test_start_injecting_shows_failure_status(self): + prepare_presets() + self.data_manager.load_group("Foo Device 2") + self.data_manager.load_preset("preset2") + calls: List[StatusData] = [] + + def f(data): + calls.append(data) + + self.message_broker.subscribe(MessageType.status_msg, f) + mock = MagicMock(return_value=False) + self.data_manager.start_injecting = mock + self.controller.start_injecting() + + mock.assert_called() + self.assertEqual( + calls[-1], + StatusData( + CTX_APPLY, + _('Failed to apply preset "%s"') % self.data_manager.active_preset.name, + ), + ) + + def test_start_injecting_adds_listener_to_update_injector_status(self): + prepare_presets() + self.data_manager.load_group("Foo Device 2") + self.data_manager.load_preset("preset2") + + with patch.object(self.message_broker, "subscribe") as mock: + self.controller.start_injecting() + mock.assert_called_once_with( + MessageType.injector_state, self.controller.show_injector_result + ) + + def test_stop_injecting_shows_status(self): + prepare_presets() + self.data_manager.load_group("Foo Device 2") + self.data_manager.load_preset("preset2") + calls: List[StatusData] = [] + + def f(data): + calls.append(data) + + self.message_broker.subscribe(MessageType.status_msg, f) + mock = MagicMock(return_value=InjectorState.STOPPED) + self.data_manager.get_state = mock + self.controller.stop_injecting() + gtk_iteration(50) + + mock.assert_called() + self.assertEqual(calls[-1], StatusData(CTX_APPLY, _("Stopped the injection"))) + + def test_show_injection_result(self): + prepare_presets() + self.data_manager.load_group("Foo Device 2") + self.data_manager.load_preset("preset2") + + mock = MagicMock(return_value=InjectorState.RUNNING) + self.data_manager.get_state = mock + calls: List[StatusData] = [] + + def f(data): + calls.append(data) + + self.message_broker.subscribe(MessageType.status_msg, f) + + self.controller.start_injecting() + gtk_iteration(50) + self.assertEqual(calls[-1].msg, _('Applied preset "%s"') % "preset2") + + mock.return_value = InjectorState.ERROR + self.controller.start_injecting() + gtk_iteration(50) + self.assertEqual(calls[-1].msg, _('Error applying preset "%s"') % "preset2") + + mock.return_value = InjectorState.NO_GRAB + self.controller.start_injecting() + gtk_iteration(50) + self.assertEqual(calls[-1].msg, _('Failed to apply preset "%s"') % "preset2") + + mock.return_value = InjectorState.UPGRADE_EVDEV + self.controller.start_injecting() + gtk_iteration(50) + self.assertEqual(calls[-1].msg, "Upgrade python-evdev") + + def test_close(self): + mock_save = MagicMock() + listener = MagicMock() + self.message_broker.subscribe(MessageType.terminate, listener) + self.data_manager.save = mock_save + + self.controller.close() + mock_save.assert_called() + listener.assert_called() + + def test_set_autoload_refreshes_service_config(self): + prepare_presets() + self.data_manager.load_group("Foo Device 2") + self.data_manager.load_preset("preset2") + + with patch.object(self.data_manager, "refresh_service_config_path") as mock: + self.controller.set_autoload(True) + mock.assert_called() + + def test_move_event_up(self): + prepare_presets() + self.data_manager.load_group("Foo Device 2") + self.data_manager.load_preset("preset2") + self.data_manager.load_mapping( + InputCombination(InputCombination.from_tuples((1, 3))) + ) + self.data_manager.update_mapping( + input_combination=InputCombination( + InputCombination.from_tuples((1, 1), (1, 2), (1, 3)) + ) + ) + + self.controller.move_input_config_in_combination( + InputConfig(type=1, code=2), "up" + ) + self.assertEqual( + self.data_manager.active_mapping.input_combination, + InputCombination(InputCombination.from_tuples((1, 2), (1, 1), (1, 3))), + ) + # now nothing changes + self.controller.move_input_config_in_combination( + InputConfig(type=1, code=2), "up" + ) + self.assertEqual( + self.data_manager.active_mapping.input_combination, + InputCombination(InputCombination.from_tuples((1, 2), (1, 1), (1, 3))), + ) + + def test_move_event_down(self): + prepare_presets() + self.data_manager.load_group("Foo Device 2") + self.data_manager.load_preset("preset2") + self.data_manager.load_mapping( + InputCombination(InputCombination.from_tuples((1, 3))) + ) + self.data_manager.update_mapping( + input_combination=InputCombination( + InputCombination.from_tuples((1, 1), (1, 2), (1, 3)) + ) + ) + + self.controller.move_input_config_in_combination( + InputConfig(type=1, code=2), "down" + ) + self.assertEqual( + self.data_manager.active_mapping.input_combination, + InputCombination(InputCombination.from_tuples((1, 1), (1, 3), (1, 2))), + ) + # now nothing changes + self.controller.move_input_config_in_combination( + InputConfig(type=1, code=2), "down" + ) + self.assertEqual( + self.data_manager.active_mapping.input_combination, + InputCombination(InputCombination.from_tuples((1, 1), (1, 3), (1, 2))), + ) + + def test_move_event_in_combination_of_len_1(self): + prepare_presets() + self.data_manager.load_group("Foo Device 2") + self.data_manager.load_preset("preset2") + self.data_manager.load_mapping( + InputCombination(InputCombination.from_tuples((1, 3))) + ) + self.controller.move_input_config_in_combination( + InputConfig(type=1, code=3), "down" + ) + self.assertEqual( + self.data_manager.active_mapping.input_combination, + InputCombination(InputCombination.from_tuples((1, 3))), + ) + + def test_move_event_loads_it_again(self): + prepare_presets() + self.data_manager.load_group("Foo Device 2") + self.data_manager.load_preset("preset2") + self.data_manager.load_mapping( + InputCombination(InputCombination.from_tuples((1, 3))) + ) + self.data_manager.update_mapping( + input_combination=InputCombination( + InputCombination.from_tuples((1, 1), (1, 2), (1, 3)) + ) + ) + mock = MagicMock() + self.message_broker.subscribe(MessageType.selected_event, mock) + self.controller.move_input_config_in_combination( + InputConfig(type=1, code=2), "down" + ) + mock.assert_called_once_with(InputConfig(type=1, code=2)) + + def test_update_event(self): + prepare_presets() + self.data_manager.load_group("Foo Device 2") + self.data_manager.load_preset("preset2") + self.data_manager.load_mapping( + InputCombination(InputCombination.from_tuples((1, 3))) + ) + self.data_manager.load_input_config(InputConfig(type=1, code=3)) + mock = MagicMock() + self.message_broker.subscribe(MessageType.selected_event, mock) + self.controller.update_input_config(InputConfig(type=1, code=10)) + mock.assert_called_once_with(InputConfig(type=1, code=10)) + + def test_update_event_reloads_mapping_and_event_when_update_fails(self): + prepare_presets() + self.data_manager.load_group("Foo Device 2") + self.data_manager.load_preset("preset2") + self.data_manager.load_mapping( + InputCombination(InputCombination.from_tuples((1, 3))) + ) + self.data_manager.load_input_config(InputConfig(type=1, code=3)) + mock = MagicMock() + self.message_broker.subscribe(MessageType.selected_event, mock) + self.message_broker.subscribe(MessageType.mapping, mock) + calls = [ + call(self.data_manager.active_mapping.get_bus_message()), + call(InputConfig(type=1, code=3)), + ] + self.controller.update_input_config( + InputConfig(type=1, code=4) + ) # already exists + mock.assert_has_calls(calls, any_order=False) + + def test_remove_event_does_nothing_when_mapping_not_loaded(self): + with spy(self.data_manager, "update_mapping") as mock: + self.controller.remove_event() + mock.assert_not_called() + + def test_remove_event_removes_active_event(self): + prepare_presets() + self.data_manager.load_group("Foo Device 2") + self.data_manager.load_preset("preset2") + self.data_manager.load_mapping( + InputCombination(InputCombination.from_tuples((1, 3))) + ) + self.data_manager.update_mapping( + input_combination=InputCombination.from_tuples((1, 3), (1, 4)) + ) + self.assertEqual( + self.data_manager.active_mapping.input_combination, + InputCombination(InputCombination.from_tuples((1, 3), (1, 4))), + ) + self.data_manager.load_input_config(InputConfig(type=1, code=4)) + + self.controller.remove_event() + self.assertEqual( + self.data_manager.active_mapping.input_combination, + InputCombination(InputCombination.from_tuples((1, 3))), + ) + + def test_remove_event_loads_a_event(self): + prepare_presets() + self.data_manager.load_group("Foo Device 2") + self.data_manager.load_preset("preset2") + self.data_manager.load_mapping( + InputCombination(InputCombination.from_tuples((1, 3))) + ) + self.data_manager.update_mapping( + input_combination=InputCombination.from_tuples((1, 3), (1, 4)) + ) + self.assertEqual( + self.data_manager.active_mapping.input_combination, + InputCombination(InputCombination.from_tuples((1, 3), (1, 4))), + ) + self.data_manager.load_input_config(InputConfig(type=1, code=4)) + + mock = MagicMock() + self.message_broker.subscribe(MessageType.selected_event, mock) + self.controller.remove_event() + mock.assert_called_once_with(InputConfig(type=1, code=3)) + + def test_remove_event_reloads_mapping_and_event_when_update_fails(self): + prepare_presets() + self.data_manager.load_group("Foo Device 2") + self.data_manager.load_preset("preset2") + self.data_manager.load_mapping( + InputCombination(InputCombination.from_tuples((1, 3))) + ) + self.data_manager.update_mapping( + input_combination=InputCombination.from_tuples((1, 3), (1, 4)) + ) + self.data_manager.load_input_config(InputConfig(type=1, code=3)) + + # removing "1,3,1" will throw a key error because a mapping with combination + # "1,4,1" already exists in preset + mock = MagicMock() + self.message_broker.subscribe(MessageType.selected_event, mock) + self.message_broker.subscribe(MessageType.mapping, mock) + calls = [ + call(self.data_manager.active_mapping.get_bus_message()), + call(InputConfig(type=1, code=3)), + ] + self.controller.remove_event() + mock.assert_has_calls(calls, any_order=False) + + def test_set_event_as_analog_saves(self): + prepare_presets() + self.data_manager.load_group("Foo Device 2") + self.data_manager.load_preset("preset2") + self.data_manager.update_mapping( + input_combination=InputCombination.from_tuples((3, 0, 10)) + ) + self.data_manager.load_mapping( + InputCombination(InputCombination.from_tuples((3, 0, 10))) + ) + self.data_manager.load_input_config( + InputConfig(type=3, code=0, analog_threshold=10) + ) + + with patch.object(self.data_manager, "save") as mock: + self.controller.set_event_as_analog(False) + mock.assert_called_once() + + with patch.object(self.data_manager, "save") as mock: + self.controller.set_event_as_analog(True) + mock.assert_called_once() + + def test_set_event_as_analog_sets_input_to_analog(self): + prepare_presets() + self.data_manager.load_group("Foo Device 2") + self.data_manager.load_preset("preset2") + self.data_manager.load_mapping( + InputCombination(InputCombination.from_tuples((1, 3))) + ) + self.data_manager.update_mapping( + input_combination=InputCombination.from_tuples((3, 0, 10)) + ) + self.data_manager.load_input_config( + InputConfig(type=3, code=0, analog_threshold=10) + ) + + self.controller.set_event_as_analog(True) + self.assertEqual( + self.data_manager.active_mapping.input_combination, + InputCombination(InputCombination.from_tuples((3, 0))), + ) + + def test_set_event_as_analog_adds_rel_threshold(self): + prepare_presets() + self.data_manager.load_group("Foo Device 2") + self.data_manager.load_preset("preset2") + self.data_manager.load_mapping( + InputCombination(InputCombination.from_tuples((1, 3))) + ) + self.data_manager.update_mapping( + input_combination=InputCombination.from_tuples((2, 0)) + ) + self.data_manager.load_input_config(InputConfig(type=2, code=0)) + + self.controller.set_event_as_analog(False) + combinations = [ + InputCombination(InputCombination.from_tuples((2, 0, 1))), + InputCombination(InputCombination.from_tuples((2, 0, -1))), + ] + self.assertIn(self.data_manager.active_mapping.input_combination, combinations) + + def test_set_event_as_analog_adds_abs_threshold(self): + prepare_presets() + self.data_manager.load_group("Foo Device 2") + self.data_manager.load_preset("preset2") + self.data_manager.load_mapping( + InputCombination(InputCombination.from_tuples((1, 3))) + ) + self.data_manager.update_mapping( + input_combination=InputCombination.from_tuples((3, 0)) + ) + self.data_manager.load_input_config(InputConfig(type=3, code=0)) + + self.controller.set_event_as_analog(False) + combinations = [ + InputCombination(InputCombination.from_tuples((3, 0, 10))), + InputCombination(InputCombination.from_tuples((3, 0, -10))), + ] + self.assertIn(self.data_manager.active_mapping.input_combination, combinations) + + def test_set_event_as_analog_reloads_mapping_and_event_when_key_event(self): + prepare_presets() + self.data_manager.load_group("Foo Device 2") + self.data_manager.load_preset("preset2") + self.data_manager.load_mapping( + InputCombination(InputCombination.from_tuples((1, 3))) + ) + self.data_manager.load_input_config(InputConfig(type=1, code=3)) + + mock = MagicMock() + self.message_broker.subscribe(MessageType.selected_event, mock) + self.message_broker.subscribe(MessageType.mapping, mock) + calls = [ + call(self.data_manager.active_mapping.get_bus_message()), + call(InputConfig(type=1, code=3)), + ] + self.controller.set_event_as_analog(True) + mock.assert_has_calls(calls, any_order=False) + + def test_set_event_as_analog_reloads_when_setting_to_analog_fails(self): + prepare_presets() + self.data_manager.load_group("Foo Device 2") + self.data_manager.load_preset("preset2") + self.data_manager.load_mapping( + InputCombination(InputCombination.from_tuples((1, 3))) + ) + self.data_manager.update_mapping( + input_combination=InputCombination.from_tuples((3, 0, 10)) + ) + self.data_manager.load_input_config( + InputConfig(type=3, code=0, analog_threshold=10) + ) + + mock = MagicMock() + self.message_broker.subscribe(MessageType.selected_event, mock) + self.message_broker.subscribe(MessageType.mapping, mock) + calls = [ + call(self.data_manager.active_mapping.get_bus_message()), + call(InputConfig(type=3, code=0, analog_threshold=10)), + ] + with patch.object(self.data_manager, "update_mapping", side_effect=KeyError): + self.controller.set_event_as_analog(True) + mock.assert_has_calls(calls, any_order=False) + + def test_set_event_as_analog_reloads_when_setting_to_key_fails(self): + prepare_presets() + self.data_manager.load_group("Foo Device 2") + self.data_manager.load_preset("preset2") + self.data_manager.load_mapping( + InputCombination(InputCombination.from_tuples((1, 3))) + ) + self.data_manager.update_mapping( + input_combination=InputCombination.from_tuples((3, 0)) + ) + self.data_manager.load_input_config(InputConfig(type=3, code=0)) + + mock = MagicMock() + self.message_broker.subscribe(MessageType.selected_event, mock) + self.message_broker.subscribe(MessageType.mapping, mock) + calls = [ + call(self.data_manager.active_mapping.get_bus_message()), + call(InputConfig(type=3, code=0)), + ] + with patch.object(self.data_manager, "update_mapping", side_effect=KeyError): + self.controller.set_event_as_analog(False) + mock.assert_has_calls(calls, any_order=False) + + def test_update_mapping_type_will_ask_user_when_output_symbol_is_set(self): + prepare_presets() + self.data_manager.load_group("Foo Device 2") + self.data_manager.load_preset("preset2") + self.data_manager.load_mapping( + InputCombination(InputCombination.from_tuples((1, 3))) + ) + request: UserConfirmRequest = None + + def f(r: UserConfirmRequest): + nonlocal request + request = r + + self.message_broker.subscribe(MessageType.user_confirm_request, f) + self.controller.update_mapping(mapping_type="analog") + self.assertIn('This will remove "a" from the text input', request.msg) + + def test_update_mapping_type_will_notify_user_to_recorde_analog_input(self): + prepare_presets() + self.data_manager.load_group("Foo Device 2") + self.data_manager.load_preset("preset2") + self.data_manager.load_mapping( + InputCombination(InputCombination.from_tuples((1, 3))) + ) + self.data_manager.update_mapping(output_symbol=None) + request: UserConfirmRequest = None + + def f(r: UserConfirmRequest): + nonlocal request + request = r + + self.message_broker.subscribe(MessageType.user_confirm_request, f) + self.controller.update_mapping(mapping_type="analog") + self.assertIn("You need to record an analog input.", request.msg) + + def test_update_mapping_type_will_tell_user_which_input_is_used_as_analog(self): + prepare_presets() + self.data_manager.load_group("Foo Device 2") + self.data_manager.load_preset("preset2") + self.data_manager.load_mapping( + InputCombination(InputCombination.from_tuples((1, 3))) + ) + self.data_manager.update_mapping( + input_combination=InputCombination.from_tuples((1, 3), (2, 1, 1)), + output_symbol=None, + ) + request: UserConfirmRequest = None + + def f(r: UserConfirmRequest): + nonlocal request + request = r + + self.message_broker.subscribe(MessageType.user_confirm_request, f) + self.controller.update_mapping(mapping_type="analog") + self.assertIn('The input "Y Down 1" will be used as analog input.', request.msg) + + def test_update_mapping_type_will_will_autoconfigure_the_input(self): + prepare_presets() + self.data_manager.load_group("Foo Device 2") + self.data_manager.load_preset("preset2") + self.data_manager.load_mapping( + InputCombination(InputCombination.from_tuples((1, 3))) + ) + self.data_manager.update_mapping( + input_combination=InputCombination.from_tuples((1, 3), (2, 1, 1)), + output_symbol=None, + ) + + self.message_broker.subscribe( + MessageType.user_confirm_request, lambda r: r.respond(True) + ) + with patch.object(self.data_manager, "update_mapping") as mock: + self.controller.update_mapping(mapping_type="analog") + mock.assert_called_once_with( + mapping_type="analog", + output_symbol=None, + input_combination=InputCombination( + InputCombination.from_tuples((1, 3), (2, 1)) + ), + ) + + def test_update_mapping_type_will_abort_when_user_denys(self): + prepare_presets() + self.data_manager.load_group("Foo Device 2") + self.data_manager.load_preset("preset2") + self.data_manager.load_mapping( + InputCombination(InputCombination.from_tuples((1, 3))) + ) + + self.message_broker.subscribe( + MessageType.user_confirm_request, lambda r: r.respond(False) + ) + with patch.object(self.data_manager, "update_mapping") as mock: + self.controller.update_mapping(mapping_type="analog") + mock.assert_not_called() + + self.data_manager.update_mapping( + input_combination=InputCombination.from_tuples((1, 3), (2, 1)), + output_symbol=None, + mapping_type="analog", + ) + with patch.object(self.data_manager, "update_mapping") as mock: + self.controller.update_mapping(mapping_type="key_macro") + mock.assert_not_called() + + def test_update_mapping_type_will_delete_output_symbol_when_user_confirms(self): + prepare_presets() + self.data_manager.load_group("Foo Device 2") + self.data_manager.load_preset("preset2") + self.data_manager.load_mapping( + InputCombination(InputCombination.from_tuples((1, 3))) + ) + + self.message_broker.subscribe( + MessageType.user_confirm_request, lambda r: r.respond(True) + ) + with patch.object(self.data_manager, "update_mapping") as mock: + self.controller.update_mapping(mapping_type="analog") + mock.assert_called_once_with(mapping_type="analog", output_symbol=None) + + def test_update_mapping_will_ask_user_to_set_trigger_threshold(self): + prepare_presets() + self.data_manager.load_group("Foo Device 2") + self.data_manager.load_preset("preset2") + self.data_manager.load_mapping( + InputCombination(InputCombination.from_tuples((1, 3))) + ) + self.data_manager.update_mapping( + input_combination=InputCombination.from_tuples((1, 3), (2, 1)), + output_symbol=None, + mapping_type="analog", + ) + request: UserConfirmRequest = None + + def f(r: UserConfirmRequest): + nonlocal request + request = r + + self.message_broker.subscribe(MessageType.user_confirm_request, f) + self.controller.update_mapping(mapping_type="key_macro") + self.assertIn('and set a "Trigger Threshold" for "Y".', request.msg) + + def test_update_mapping_update_to_analog_without_asking(self): + prepare_presets() + self.data_manager.load_group("Foo Device 2") + self.data_manager.load_preset("preset2") + self.data_manager.load_mapping( + InputCombination(InputCombination.from_tuples((1, 3))) + ) + self.data_manager.update_mapping( + input_combination=InputCombination.from_tuples((1, 3), (2, 1)), + output_symbol=None, + ) + mock = MagicMock() + self.message_broker.subscribe(MessageType.user_confirm_request, mock) + self.controller.update_mapping(mapping_type="analog") + mock.assert_not_called() + + def test_update_mapping_update_to_key_macro_without_asking(self): + prepare_presets() + self.data_manager.load_group("Foo Device 2") + self.data_manager.load_preset("preset2") + self.data_manager.load_mapping( + InputCombination(InputCombination.from_tuples((1, 3))) + ) + self.data_manager.update_mapping( + input_combination=InputCombination.from_tuples((1, 3), (2, 1, 1)), + mapping_type="analog", + output_symbol=None, + ) + mock = MagicMock() + self.message_broker.subscribe(MessageType.user_confirm_request, mock) + self.controller.update_mapping(mapping_type="key_macro") + mock.assert_not_called() + + def test_update_mapping_will_remove_output_type_and_code(self): + prepare_presets() + self.data_manager.load_group("Foo Device 2") + self.data_manager.load_preset("preset2") + self.data_manager.load_mapping( + InputCombination(InputCombination.from_tuples((1, 3))) + ) + self.data_manager.update_mapping( + input_combination=InputCombination.from_tuples((1, 3), (2, 1)), + output_symbol=None, + mapping_type="analog", + ) + self.message_broker.subscribe( + MessageType.user_confirm_request, lambda r: r.respond(True) + ) + with patch.object(self.data_manager, "update_mapping") as mock: + self.controller.update_mapping(mapping_type="key_macro") + mock.assert_called_once_with( + mapping_type="key_macro", + output_type=None, + output_code=None, + ) diff --git a/tests/unit/test_daemon.py b/tests/unit/test_daemon.py new file mode 100644 index 0000000..eb230da --- /dev/null +++ b/tests/unit/test_daemon.py @@ -0,0 +1,577 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + +import json +import os +import time +import unittest +from unittest.mock import patch, MagicMock + +import evdev +from evdev._ecodes import EV_ABS +from evdev.ecodes import EV_KEY, KEY_B, KEY_A, ABS_X, BTN_A, BTN_B + +from inputremapper.configs.global_config import GlobalConfig +from inputremapper.configs.input_config import InputCombination, InputConfig +from inputremapper.configs.mapping import Mapping +from inputremapper.configs.paths import PathUtils +from inputremapper.configs.preset import Preset +from inputremapper.configs.keyboard_layout import keyboard_layout +from inputremapper.daemon import Daemon, DAEMON +from inputremapper.groups import groups +from inputremapper.injection.global_uinputs import GlobalUInputs, UInput +from inputremapper.injection.injector import InjectorState +from inputremapper.injection.mapping_handlers.mapping_parser import MappingParser +from inputremapper.input_event import InputEvent +from tests.lib.cleanup import cleanup +from tests.lib.fixtures import Fixture +from tests.lib.fixtures import fixtures +from tests.lib.logger import logger +from tests.lib.pipes import push_events, uinput_write_history_pipe +from tests.lib.test_setup import test_setup, is_service_running +from tests.lib.tmp import tmp + + +@test_setup +class TestDaemon(unittest.TestCase): + new_fixture_path = "/dev/input/event9876" + + def setUp(self): + self.daemon = None + self.global_config = GlobalConfig() + self.global_uinputs = GlobalUInputs(UInput) + PathUtils.mkdir(PathUtils.get_config_path()) + self.global_config._save_config() + self.mapping_parser = MappingParser(self.global_uinputs) + + # the daemon should be able to create them on demand: + self.global_uinputs.devices = {} + self.global_uinputs.is_service = True + + def tearDown(self): + # avoid race conditions with other tests, daemon may run processes + if self.daemon is not None: + self.daemon.stop_all() + self.daemon = None + + cleanup() + + @patch.object(os, "system") + def test_connect(self, os_system_mock: MagicMock): + self.assertFalse(is_service_running()) + + # no daemon runs, should try to run it via pkexec instead. + # It fails due to the patch on os.system and therefore exits the process + self.assertRaises(SystemExit, Daemon.connect) + os_system_mock.assert_called_once() + self.assertIsNone(Daemon.connect(False)) + + # make the connect command work this time by acting like a connection is + # available: + + set_config_dir_callcount = 0 + + class FakeConnection: + def set_config_dir(self, *_, **__): + nonlocal set_config_dir_callcount + set_config_dir_callcount += 1 + + def Introspect(self, timeout=0): + return "" + + with patch.object(DAEMON, "get_proxy") as get_mock: + get_mock.return_value = FakeConnection() + self.assertIsInstance(Daemon.connect(), FakeConnection) + self.assertEqual(set_config_dir_callcount, 1) + + self.assertIsInstance(Daemon.connect(False), FakeConnection) + self.assertEqual(set_config_dir_callcount, 2) + + def test_daemon(self): + # remove the existing system mapping to force our own into it + if os.path.exists(PathUtils.get_config_path("xmodmap.json")): + os.remove(PathUtils.get_config_path("xmodmap.json")) + + preset_name = "foo" + + group = groups.find(name="gamepad") + + # unrelated group that shouldn't be affected at all + group2 = groups.find(name="Bar Device") + + preset = Preset(group.get_preset_path(preset_name)) + preset.add( + Mapping.from_combination( + input_combination=InputCombination( + [InputConfig(type=EV_KEY, code=BTN_A)] + ), + target_uinput="keyboard", + output_symbol="a", + ) + ) + preset.add( + Mapping.from_combination( + input_combination=InputCombination( + [InputConfig(type=EV_ABS, code=ABS_X, analog_threshold=-1)] + ), + target_uinput="keyboard", + output_symbol="b", + ) + ) + preset.save() + self.global_config.set_autoload_preset(group.key, preset_name) + + """Injection 1""" + + # should forward the event unchanged + push_events( + fixtures.gamepad, + [InputEvent.key(BTN_B, 1, fixtures.gamepad.get_device_hash())], + ) + + self.daemon = Daemon( + self.global_config, + self.global_uinputs, + self.mapping_parser, + ) + + self.assertFalse(uinput_write_history_pipe[0].poll()) + + # has been cleanedUp in setUp + self.assertNotIn("keyboard", self.global_uinputs.devices) + + logger.info(f"start injector for {group.key}") + self.daemon.start_injecting(group.key, preset_name) + + # created on demand + self.assertIn("keyboard", self.global_uinputs.devices) + self.assertNotIn("gamepad", self.global_uinputs.devices) + + self.assertEqual(self.daemon.get_state(group.key), InjectorState.STARTING) + self.assertEqual(self.daemon.get_state(group2.key), InjectorState.UNKNOWN) + + event = uinput_write_history_pipe[0].recv() + self.assertEqual(self.daemon.get_state(group.key), InjectorState.RUNNING) + self.assertEqual(event.type, EV_KEY) + self.assertEqual(event.code, BTN_B) + self.assertEqual(event.value, 1) + + logger.info(f"stopping injector for {group.key}") + self.daemon.stop_injecting(group.key) + time.sleep(0.2) + self.assertEqual(self.daemon.get_state(group.key), InjectorState.STOPPED) + + try: + self.assertFalse(uinput_write_history_pipe[0].poll()) + except AssertionError: + print("Unexpected", uinput_write_history_pipe[0].recv()) + # possibly a duplicate write! + raise + + """Injection 2""" + logger.info(f"start injector for {group.key}") + self.daemon.start_injecting(group.key, preset_name) + + time.sleep(0.1) + # -1234 will be classified as -1 by the injector + push_events( + fixtures.gamepad, + [InputEvent.abs(ABS_X, -1234, fixtures.gamepad.get_device_hash())], + ) + time.sleep(0.1) + + self.assertTrue(uinput_write_history_pipe[0].poll()) + + # the written key is a key-down event, not the original + # event value of -1234 + event = uinput_write_history_pipe[0].recv() + + self.assertEqual(event.type, EV_KEY) + self.assertEqual(event.code, KEY_B) + self.assertEqual(event.value, 1) + + def test_refresh_on_start(self): + if os.path.exists(PathUtils.get_config_path("xmodmap.json")): + os.remove(PathUtils.get_config_path("xmodmap.json")) + + preset_name = "foo" + key_code = 9 + group_name = "9876 name" + + # expected key of the group + group_key = group_name + + group = groups.find(name=group_name) + # this test only makes sense if this device is unknown yet + self.assertIsNone(group) + + keyboard_layout.clear() + keyboard_layout._set("a", KEY_A) + + preset = Preset(PathUtils.get_preset_path(group_name, preset_name)) + preset.add( + Mapping.from_combination( + InputCombination([InputConfig(type=EV_KEY, code=key_code)]), + "keyboard", + "a", + ) + ) + + # make the daemon load the file instead + with open(PathUtils.get_config_path("xmodmap.json"), "w") as file: + json.dump(keyboard_layout._mapping, file, indent=4) + keyboard_layout.clear() + + preset.save() + self.global_config.set_autoload_preset(group_key, preset_name) + self.daemon = Daemon( + self.global_config, + self.global_uinputs, + self.mapping_parser, + ) + + # make sure the devices are populated + groups.refresh() + + # the daemon is supposed to find this device by calling refresh + fixture = Fixture( + capabilities={evdev.ecodes.EV_KEY: [key_code]}, + phys="9876 phys", + info=evdev.device.DeviceInfo(4, 5, 6, 7), + name=group_name, + path=self.new_fixture_path, + ) + fixtures.add_fixture(fixture) + push_events(fixture, [InputEvent.key(key_code, 1, fixture.get_device_hash())]) + self.daemon.start_injecting(group_key, preset_name) + + # test if the injector called groups.refresh successfully + group = groups.find(key=group_key) + self.assertEqual(group.name, group_name) + self.assertEqual(group.key, group_key) + + time.sleep(0.1) + self.assertTrue(uinput_write_history_pipe[0].poll()) + + event = uinput_write_history_pipe[0].recv() + self.assertEqual(event, (EV_KEY, KEY_A, 1)) + + self.daemon.stop_injecting(group_key) + time.sleep(0.2) + self.assertEqual(self.daemon.get_state(group_key), InjectorState.STOPPED) + + def test_refresh_for_unknown_key(self): + device_9876 = "9876 name" + # this test only makes sense if this device is unknown yet + self.assertIsNone(groups.find(name=device_9876)) + + self.daemon = Daemon( + self.global_config, + self.global_uinputs, + self.mapping_parser, + ) + + # make sure the devices are populated + groups.refresh() + + self.daemon.refresh() + + fixtures.add_fixture( + Fixture( + capabilities={evdev.ecodes.EV_KEY: [evdev.ecodes.KEY_A]}, + phys="9876 phys", + info=evdev.device.DeviceInfo(4, 5, 6, 7), + name=device_9876, + path=self.new_fixture_path, + ) + ) + + self.daemon._autoload("25v7j9q4vtj") + # this is unknown, so the daemon will scan the devices again + + # test if the injector called groups.refresh successfully + self.assertIsNotNone(groups.find(name=device_9876)) + + def test_xmodmap_file(self): + """Create a custom xmodmap file, expect the daemon to read keycodes from it.""" + from_keycode = evdev.ecodes.KEY_A + target = "keyboard" + to_name = "q" + to_keycode = 100 + + name = "Bar Device" + preset_name = "foo" + group = groups.find(name=name) + + config_dir = os.path.join(tmp, "foo") + + path = os.path.join(config_dir, "presets", name, f"{preset_name}.json") + + preset = Preset(path) + preset.add( + Mapping.from_combination( + InputCombination([InputConfig(type=EV_KEY, code=from_keycode)]), + target, + to_name, + ) + ) + preset.save() + + keyboard_layout.clear() + + push_events( + fixtures.bar_device, + [ + InputEvent.key( + from_keycode, + 1, + origin_hash=fixtures.bar_device.get_device_hash(), + ) + ], + ) + + # an existing config file is needed otherwise set_config_dir refuses + # to use the directory + config_path = os.path.join(config_dir, "config.json") + self.global_config.path = config_path + self.global_config._save_config() + + # finally, create the xmodmap file + xmodmap_path = os.path.join(config_dir, "xmodmap.json") + with open(xmodmap_path, "w") as file: + file.write(f'{{"{to_name}":{to_keycode}}}') + + # test setup complete + + self.daemon = Daemon( + self.global_config, + self.global_uinputs, + self.mapping_parser, + ) + self.daemon.set_config_dir(config_dir) + + self.daemon.start_injecting(group.key, preset_name) + + time.sleep(0.1) + self.assertTrue(uinput_write_history_pipe[0].poll()) + + event = uinput_write_history_pipe[0].recv() + self.assertEqual(event.type, EV_KEY) + self.assertEqual(event.code, to_keycode) + self.assertEqual(event.value, 1) + + def test_start_stop(self): + group_key = "Qux/[Device]?" + group = groups.find(key=group_key) + preset_name = "preset8" + + daemon = Daemon( + self.global_config, + self.global_uinputs, + self.mapping_parser, + ) + self.daemon = daemon + + pereset = Preset(group.get_preset_path(preset_name)) + pereset.add( + Mapping.from_combination( + InputCombination([InputConfig(type=EV_KEY, code=KEY_A)]), + "keyboard", + "a", + ) + ) + pereset.save() + + # start + daemon.start_injecting(group_key, preset_name) + # explicit start, not autoload, so the history stays empty + self.assertNotIn(group_key, daemon.autoload_history._autoload_history) + self.assertTrue(daemon.autoload_history.may_autoload(group_key, preset_name)) + # path got translated to the device name + self.assertIn(group_key, daemon.injectors) + + # start again + previous_injector = daemon.injectors[group_key] + self.assertNotEqual(previous_injector.get_state(), InjectorState.STOPPED) + daemon.start_injecting(group_key, preset_name) + self.assertNotIn(group_key, daemon.autoload_history._autoload_history) + self.assertTrue(daemon.autoload_history.may_autoload(group_key, preset_name)) + self.assertIn(group_key, daemon.injectors) + time.sleep(0.2) + self.assertEqual(previous_injector.get_state(), InjectorState.STOPPED) + # a different injetor is now running + self.assertNotEqual(previous_injector, daemon.injectors[group_key]) + self.assertNotEqual( + daemon.injectors[group_key].get_state(), InjectorState.STOPPED + ) + + # trying to inject a non existing preset keeps the previous inejction + # alive + injector = daemon.injectors[group_key] + daemon.start_injecting(group_key, "qux") + self.assertEqual(injector, daemon.injectors[group_key]) + self.assertNotEqual( + daemon.injectors[group_key].get_state(), InjectorState.STOPPED + ) + + # trying to start injecting for an unknown device also just does + # nothing + daemon.start_injecting("quux", "qux") + self.assertNotEqual( + daemon.injectors[group_key].get_state(), InjectorState.STOPPED + ) + + # after all that stuff autoload_history is still unharmed + self.assertNotIn(group_key, daemon.autoload_history._autoload_history) + self.assertTrue(daemon.autoload_history.may_autoload(group_key, preset_name)) + + # stop + daemon.stop_injecting(group_key) + time.sleep(0.2) + self.assertNotIn(group_key, daemon.autoload_history._autoload_history) + self.assertEqual(daemon.injectors[group_key].get_state(), InjectorState.STOPPED) + self.assertTrue(daemon.autoload_history.may_autoload(group_key, preset_name)) + + def test_autoload(self): + preset_name = "preset7" + group_key = "Qux/[Device]?" + group = groups.find(key=group_key) + + daemon = Daemon(self.global_config, self.global_uinputs, self.mapping_parser) + self.daemon = daemon + + preset = Preset(group.get_preset_path(preset_name)) + preset.add( + Mapping.from_combination( + InputCombination([InputConfig(type=EV_KEY, code=KEY_A)]), + "keyboard", + "a", + ) + ) + preset.save() + + # no autoloading is configured yet + self.daemon._autoload(group_key) + self.assertNotIn(group_key, daemon.autoload_history._autoload_history) + self.assertTrue(daemon.autoload_history.may_autoload(group_key, preset_name)) + + self.global_config.set_autoload_preset(group_key, preset_name) + len_before = len(self.daemon.autoload_history._autoload_history) + # now autoloading is configured, so it will autoload + self.daemon._autoload(group_key) + len_after = len(self.daemon.autoload_history._autoload_history) + self.assertEqual( + daemon.autoload_history._autoload_history[group_key][1], + preset_name, + ) + self.assertFalse(daemon.autoload_history.may_autoload(group_key, preset_name)) + injector = daemon.injectors[group_key] + self.assertEqual(len_before + 1, len_after) + + # calling duplicate get_autoload does nothing + self.daemon._autoload(group_key) + self.assertEqual( + daemon.autoload_history._autoload_history[group_key][1], + preset_name, + ) + self.assertEqual(injector, daemon.injectors[group_key]) + self.assertFalse(daemon.autoload_history.may_autoload(group_key, preset_name)) + + # explicit start_injecting clears the autoload history + self.daemon.start_injecting(group_key, preset_name) + self.assertTrue(daemon.autoload_history.may_autoload(group_key, preset_name)) + + # calling autoload for (yet) unknown devices does nothing + len_before = len(self.daemon.autoload_history._autoload_history) + self.daemon._autoload("unknown-key-1234") + len_after = len(self.daemon.autoload_history._autoload_history) + self.assertEqual(len_before, len_after) + + # autoloading input-remapper devices does nothing + len_before = len(self.daemon.autoload_history._autoload_history) + self.daemon.autoload_single("Bar Device") + len_after = len(self.daemon.autoload_history._autoload_history) + self.assertEqual(len_before, len_after) + + def test_autoload_2(self): + self.daemon = Daemon( + self.global_config, + self.global_uinputs, + self.mapping_parser, + ) + history = self.daemon.autoload_history._autoload_history + + # existing device + preset_name = "preset7" + group = groups.find(key="Foo Device 2") + preset = Preset(group.get_preset_path(preset_name)) + preset.add( + Mapping.from_combination( + InputCombination([InputConfig(type=3, code=2, analog_threshold=1)]), + "keyboard", + "a", + ) + ) + preset.save() + self.global_config.set_autoload_preset(group.key, preset_name) + + # ignored, won't cause problems: + self.global_config.set_autoload_preset("non-existant-key", "foo") + + self.daemon.autoload() + self.assertEqual(len(history), 1) + self.assertEqual(history[group.key][1], preset_name) + + def test_autoload_3(self): + # based on a bug + preset_name = "preset7" + group = groups.find(key="Foo Device 2") + + preset = Preset(group.get_preset_path(preset_name)) + preset.add( + Mapping.from_combination( + InputCombination([InputConfig(type=3, code=2, analog_threshold=1)]), + "keyboard", + "a", + ) + ) + preset.save() + + self.global_config.set_autoload_preset(group.key, preset_name) + + self.daemon = Daemon( + self.global_config, + self.global_uinputs, + self.mapping_parser, + ) + groups.set_groups([]) # caused the bug + self.assertIsNone(groups.find(key="Foo Device 2")) + self.daemon.autoload() + + # it should try to refresh the groups because all the + # group_keys are unknown at the moment + history = self.daemon.autoload_history._autoload_history + self.assertEqual(history[group.key][1], preset_name) + self.assertEqual(self.daemon.get_state(group.key), InjectorState.STARTING) + self.assertIsNotNone(groups.find(key="Foo Device 2")) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/test_data_manager.py b/tests/unit/test_data_manager.py new file mode 100644 index 0000000..fefbcde --- /dev/null +++ b/tests/unit/test_data_manager.py @@ -0,0 +1,971 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + +import os +import time +import unittest +from itertools import permutations +from typing import List +from unittest.mock import MagicMock, call + +from inputremapper.configs.global_config import GlobalConfig +from inputremapper.configs.input_config import InputCombination, InputConfig +from inputremapper.configs.mapping import UIMapping, MappingData +from inputremapper.configs.paths import PathUtils +from inputremapper.configs.preset import Preset +from inputremapper.configs.keyboard_layout import keyboard_layout +from inputremapper.exceptions import DataManagementError +from inputremapper.groups import _Groups +from inputremapper.gui.data_manager import DataManager, DEFAULT_PRESET_NAME +from inputremapper.gui.messages.message_broker import ( + MessageBroker, + MessageType, +) +from inputremapper.gui.messages.message_data import ( + GroupData, + CombinationUpdate, +) +from inputremapper.gui.reader_client import ReaderClient +from inputremapper.injection.global_uinputs import GlobalUInputs, FrontendUInput +from tests.lib.fixtures import prepare_presets +from tests.lib.patches import FakeDaemonProxy +from tests.lib.test_setup import test_setup + + +class Listener: + def __init__(self): + self.calls: List = [] + + def __call__(self, data): + self.calls.append(data) + + +@test_setup +class TestDataManager(unittest.TestCase): + def setUp(self) -> None: + self.message_broker = MessageBroker() + self.reader = ReaderClient(self.message_broker, _Groups()) + self.uinputs = GlobalUInputs(FrontendUInput) + self.uinputs.prepare_all() + self.global_config = GlobalConfig() + self.data_manager = DataManager( + self.message_broker, + self.global_config, + self.reader, + FakeDaemonProxy(), + self.uinputs, + keyboard_layout, + ) + + def test_load_group_provides_presets(self): + """we should get all preset of a group, when loading it""" + prepare_presets() + response: List[GroupData] = [] + + def listener(data: GroupData): + response.append(data) + + self.message_broker.subscribe(MessageType.group, listener) + self.data_manager.load_group("Foo Device 2") + + for preset_name in response[0].presets: + self.assertIn( + preset_name, + ( + "preset1", + "preset2", + "preset3", + ), + ) + + self.assertEqual(response[0].group_key, "Foo Device 2") + + def test_load_group_without_presets_provides_none(self): + """We should get no presets when loading a group without presets.""" + response: List[GroupData] = [] + + def listener(data: GroupData): + response.append(data) + + self.message_broker.subscribe(MessageType.group, listener) + + self.data_manager.load_group(group_key="Foo Device 2") + self.assertEqual(len(response[0].presets), 0) + + def test_load_non_existing_group(self): + """we should not be able to load an unknown group""" + with self.assertRaises(DataManagementError): + self.data_manager.load_group(group_key="Some Unknown Device") + + def test_cannot_load_preset_without_group(self): + """Loading a preset without a loaded group raises a DataManagementError.""" + prepare_presets() + self.assertRaises( + DataManagementError, + self.data_manager.load_preset, + name="preset1", + ) + + def test_load_preset(self): + """loading an existing preset should be possible""" + prepare_presets() + + self.data_manager.load_group(group_key="Foo Device") + listener = Listener() + self.message_broker.subscribe(MessageType.preset, listener) + self.data_manager.load_preset(name="preset1") + mappings = listener.calls[0].mappings + preset_name = listener.calls[0].name + + expected_preset = Preset(PathUtils.get_preset_path("Foo Device", "preset1")) + expected_preset.load() + expected_mappings = list(expected_preset) + + self.assertEqual(preset_name, "preset1") + for mapping in expected_mappings: + self.assertIn(mapping, mappings) + + def test_cannot_load_non_existing_preset(self): + """Loading a non-existing preset should raise a KeyError.""" + prepare_presets() + + self.data_manager.load_group(group_key="Foo Device") + self.assertRaises( + FileNotFoundError, + self.data_manager.load_preset, + name="unknownPreset", + ) + + def test_save_preset(self): + """Modified preses should be saved to the disc.""" + prepare_presets() + # make sure the correct preset is loaded + self.data_manager.load_group(group_key="Foo Device") + self.data_manager.load_preset(name="preset1") + listener = Listener() + self.message_broker.subscribe(MessageType.mapping, listener) + self.data_manager.load_mapping( + combination=InputCombination([InputConfig(type=1, code=1)]) + ) + + mapping: MappingData = listener.calls[0] + control_preset = Preset(PathUtils.get_preset_path("Foo Device", "preset1")) + control_preset.load() + self.assertEqual( + control_preset.get_mapping( + InputCombination([InputConfig(type=1, code=1)]) + ).output_symbol, + mapping.output_symbol, + ) + + # change the mapping provided with the mapping_changed event and save + self.data_manager.update_mapping(output_symbol="key(a)") + self.data_manager.save() + + # reload the control_preset + control_preset.empty() + control_preset.load() + self.assertEqual( + control_preset.get_mapping( + InputCombination([InputConfig(type=1, code=1)]) + ).output_symbol, + "key(a)", + ) + + def test_copy_preset(self): + prepare_presets() + self.data_manager.load_group(group_key="Foo Device 2") + self.data_manager.load_preset(name="preset2") + listener = Listener() + self.message_broker.subscribe(MessageType.group, listener) + self.message_broker.subscribe(MessageType.preset, listener) + + self.data_manager.copy_preset("foo") + + # we expect the first data to be group data and the second + # one a preset data of the new copy + presets_in_group = [preset for preset in listener.calls[0].presets] + self.assertIn("preset2", presets_in_group) + self.assertIn("foo", presets_in_group) + self.assertEqual(listener.calls[1].name, "foo") + + # this should pass without error: + self.data_manager.load_preset("preset2") + self.data_manager.copy_preset("preset2") + + def test_cannot_copy_preset(self): + prepare_presets() + + self.assertRaises( + DataManagementError, + self.data_manager.copy_preset, + "foo", + ) + self.data_manager.load_group("Foo Device 2") + self.assertRaises( + DataManagementError, + self.data_manager.copy_preset, + "foo", + ) + + def test_copy_preset_to_existing_name_raises_error(self): + prepare_presets() + self.data_manager.load_group(group_key="Foo Device 2") + self.data_manager.load_preset(name="preset2") + + self.assertRaises( + ValueError, + self.data_manager.copy_preset, + "preset3", + ) + + def test_rename_preset(self): + """should be able to rename a preset""" + prepare_presets() + self.data_manager.load_group(group_key="Foo Device 2") + self.data_manager.load_preset(name="preset2") + listener = Listener() + self.message_broker.subscribe(MessageType.group, listener) + self.message_broker.subscribe(MessageType.preset, listener) + + self.data_manager.rename_preset(new_name="new preset") + + # we expect the first data to be group data and the second + # one a preset data + presets_in_group = [preset for preset in listener.calls[0].presets] + self.assertNotIn("preset2", presets_in_group) + self.assertIn("new preset", presets_in_group) + self.assertEqual(listener.calls[1].name, "new preset") + + # this should pass without error: + self.data_manager.load_preset(name="new preset") + self.data_manager.rename_preset(new_name="new preset") + + def test_rename_preset_sets_autoload_correct(self): + """when renaming a preset the autoload status should still be set correctly""" + prepare_presets() + self.data_manager.load_group(group_key="Foo Device 2") + listener = Listener() + self.message_broker.subscribe(MessageType.preset, listener) + self.data_manager.load_preset(name="preset2") # sends PresetData + # sends PresetData with updated name, e. e. should be equal + self.data_manager.rename_preset(new_name="foo") + self.assertEqual(listener.calls[0].autoload, listener.calls[1].autoload) + + def test_cannot_rename_preset(self): + """rename preset should raise a DataManagementError if a preset + with the new name already exists in the current group""" + prepare_presets() + self.data_manager.load_group(group_key="Foo Device 2") + self.data_manager.load_preset(name="preset2") + + self.assertRaises( + ValueError, + self.data_manager.rename_preset, + new_name="preset3", + ) + + def test_cannot_rename_preset_without_preset(self): + prepare_presets() + + self.assertRaises( + DataManagementError, + self.data_manager.rename_preset, + new_name="foo", + ) + self.data_manager.load_group(group_key="Foo Device 2") + self.assertRaises( + DataManagementError, + self.data_manager.rename_preset, + new_name="foo", + ) + + def test_add_preset(self): + """should be able to add a preset""" + prepare_presets() + self.data_manager.load_group(group_key="Foo Device 2") + listener = Listener() + self.message_broker.subscribe(MessageType.group, listener) + + # should emit group_changed + self.data_manager.create_preset(name="new preset") + + presets_in_group = [preset for preset in listener.calls[0].presets] + self.assertIn("preset2", presets_in_group) + self.assertIn("preset3", presets_in_group) + self.assertIn("new preset", presets_in_group) + + def test_cannot_add_preset(self): + """adding a preset with the same name as an already existing + preset (of the current group) should raise a DataManagementError""" + prepare_presets() + self.data_manager.load_group(group_key="Foo Device 2") + self.data_manager.load_preset(name="preset2") + + self.assertRaises( + DataManagementError, + self.data_manager.create_preset, + name="preset3", + ) + + def test_cannot_add_preset_without_group(self): + self.assertRaises( + DataManagementError, + self.data_manager.create_preset, + name="foo", + ) + + def test_delete_preset(self): + """should be able to delete the current preset""" + prepare_presets() + self.data_manager.load_group(group_key="Foo Device 2") + self.data_manager.load_preset(name="preset2") + listener = Listener() + self.message_broker.subscribe(MessageType.group, listener) + self.message_broker.subscribe(MessageType.preset, listener) + self.message_broker.subscribe(MessageType.mapping, listener) + + # should emit only group_changed + self.data_manager.delete_preset() + + presets_in_group = [preset for preset in listener.calls[0].presets] + self.assertEqual(len(presets_in_group), 2) + self.assertNotIn("preset2", presets_in_group) + self.assertEqual(len(listener.calls), 1) + + def test_delete_preset_sanitized(self): + """should be able to delete the current preset""" + Preset(PathUtils.get_preset_path("Qux/[Device]?", "bla")).save() + Preset(PathUtils.get_preset_path("Qux/[Device]?", "foo")).save() + self.assertTrue( + os.path.exists(PathUtils.get_preset_path("Qux/[Device]?", "bla")) + ) + + self.data_manager.load_group(group_key="Qux/[Device]?") + self.data_manager.load_preset(name="bla") + listener = Listener() + self.message_broker.subscribe(MessageType.group, listener) + self.message_broker.subscribe(MessageType.preset, listener) + self.message_broker.subscribe(MessageType.mapping, listener) + + # should emit only group_changed + self.data_manager.delete_preset() + + presets_in_group = [preset for preset in listener.calls[0].presets] + self.assertEqual(len(presets_in_group), 1) + self.assertNotIn("bla", presets_in_group) + self.assertIn("foo", presets_in_group) + self.assertEqual(len(listener.calls), 1) + + self.assertFalse( + os.path.exists(PathUtils.get_preset_path("Qux/[Device]?", "bla")) + ) + + def test_load_mapping(self): + """should be able to load a mapping""" + preset, _, _ = prepare_presets() + expected_mapping = preset.get_mapping( + InputCombination([InputConfig(type=1, code=1)]) + ) + + self.data_manager.load_group(group_key="Foo Device") + self.data_manager.load_preset(name="preset1") + listener = Listener() + self.message_broker.subscribe(MessageType.mapping, listener) + self.data_manager.load_mapping( + combination=InputCombination([InputConfig(type=1, code=1)]) + ) + mapping = listener.calls[0] + + self.assertEqual(mapping, expected_mapping) + + def test_cannot_load_non_existing_mapping(self): + """loading a mapping tha is not present in the preset should raise a KeyError""" + prepare_presets() + + self.data_manager.load_group(group_key="Foo Device 2") + self.data_manager.load_preset(name="preset2") + self.assertRaises( + KeyError, + self.data_manager.load_mapping, + combination=InputCombination([InputConfig(type=1, code=1)]), + ) + + def test_cannot_load_mapping_without_preset(self): + """loading a mapping if no preset is loaded + should raise an DataManagementError""" + prepare_presets() + + self.assertRaises( + DataManagementError, + self.data_manager.load_mapping, + combination=InputCombination([InputConfig(type=1, code=1)]), + ) + self.data_manager.load_group("Foo Device") + self.assertRaises( + DataManagementError, + self.data_manager.load_mapping, + combination=InputCombination([InputConfig(type=1, code=1)]), + ) + + def test_load_event(self): + prepare_presets() + mock = MagicMock() + self.message_broker.subscribe(MessageType.selected_event, mock) + self.data_manager.load_group("Foo Device") + self.data_manager.load_preset("preset1") + self.data_manager.load_mapping(InputCombination([InputConfig(type=1, code=1)])) + self.data_manager.load_input_config(InputConfig(type=1, code=1)) + mock.assert_called_once_with(InputConfig(type=1, code=1)) + self.assertEqual( + self.data_manager.active_input_config, InputConfig(type=1, code=1) + ) + + def test_cannot_load_event_when_mapping_not_set(self): + prepare_presets() + self.data_manager.load_group("Foo Device") + self.data_manager.load_preset("preset1") + with self.assertRaises(DataManagementError): + self.data_manager.load_input_config(InputConfig(type=1, code=1)) + + def test_cannot_load_event_when_not_in_mapping_combination(self): + prepare_presets() + self.data_manager.load_group("Foo Device") + self.data_manager.load_preset("preset1") + self.data_manager.load_mapping(InputCombination([InputConfig(type=1, code=1)])) + with self.assertRaises(ValueError): + self.data_manager.load_input_config(InputConfig(type=1, code=5)) + + def test_update_event(self): + prepare_presets() + self.data_manager.load_group("Foo Device") + self.data_manager.load_preset("preset1") + self.data_manager.load_mapping(InputCombination([InputConfig(type=1, code=1)])) + self.data_manager.load_input_config(InputConfig(type=1, code=1)) + self.data_manager.update_input_config(InputConfig(type=1, code=5)) + self.assertEqual( + self.data_manager.active_input_config, InputConfig(type=1, code=5) + ) + + def test_update_event_sends_messages(self): + prepare_presets() + self.data_manager.load_group("Foo Device") + self.data_manager.load_preset("preset1") + self.data_manager.load_mapping(InputCombination([InputConfig(type=1, code=1)])) + self.data_manager.load_input_config(InputConfig(type=1, code=1)) + + mock = MagicMock() + self.message_broker.subscribe(MessageType.selected_event, mock) + self.message_broker.subscribe(MessageType.combination_update, mock) + self.message_broker.subscribe(MessageType.mapping, mock) + self.data_manager.update_input_config(InputConfig(type=1, code=5)) + expected = [ + call( + CombinationUpdate( + InputCombination([InputConfig(type=1, code=1)]), + InputCombination([InputConfig(type=1, code=5)]), + ) + ), + call(self.data_manager.active_mapping.get_bus_message()), + call(InputConfig(type=1, code=5)), + ] + mock.assert_has_calls(expected, any_order=False) + + def test_cannot_update_event_when_resulting_combination_exists(self): + prepare_presets() + self.data_manager.load_group("Foo Device") + self.data_manager.load_preset("preset1") + self.data_manager.load_mapping(InputCombination([InputConfig(type=1, code=1)])) + self.data_manager.load_input_config(InputConfig(type=1, code=1)) + with self.assertRaises(KeyError): + self.data_manager.update_input_config(InputConfig(type=1, code=2)) + + def test_cannot_update_event_when_not_loaded(self): + prepare_presets() + self.data_manager.load_group("Foo Device") + self.data_manager.load_preset("preset1") + self.data_manager.load_mapping(InputCombination([InputConfig(type=1, code=1)])) + with self.assertRaises(DataManagementError): + self.data_manager.update_input_config(InputConfig(type=1, code=2)) + + def test_update_mapping_emits_mapping_changed(self): + """update mapping should emit a mapping_changed event""" + prepare_presets() + self.data_manager.load_group(group_key="Foo Device 2") + self.data_manager.load_preset(name="preset2") + self.data_manager.load_mapping( + combination=InputCombination([InputConfig(type=1, code=4)]) + ) + + listener = Listener() + self.message_broker.subscribe(MessageType.mapping, listener) + self.data_manager.update_mapping( + name="foo", + output_symbol="f", + release_timeout=0.3, + ) + + response = listener.calls[0] + self.assertEqual(response.name, "foo") + self.assertEqual(response.output_symbol, "f") + self.assertEqual(response.release_timeout, 0.3) + + def test_updated_mapping_can_be_saved(self): + """make sure that updated changes can be saved""" + prepare_presets() + self.data_manager.load_group(group_key="Foo Device 2") + self.data_manager.load_preset(name="preset2") + self.data_manager.load_mapping( + combination=InputCombination([InputConfig(type=1, code=4)]) + ) + + self.data_manager.update_mapping( + name="foo", + output_symbol="f", + release_timeout=0.3, + ) + self.data_manager.save() + + preset = Preset(PathUtils.get_preset_path("Foo Device", "preset2"), UIMapping) + preset.load() + mapping = preset.get_mapping(InputCombination([InputConfig(type=1, code=4)])) + self.assertEqual(mapping.format_name(), "foo") + self.assertEqual(mapping.output_symbol, "f") + self.assertEqual(mapping.release_timeout, 0.3) + + def test_updated_mapping_saves_invalid_mapping(self): + """make sure that updated changes can be saved even if they are not valid""" + prepare_presets() + self.data_manager.load_group(group_key="Foo Device 2") + self.data_manager.load_preset(name="preset2") + self.data_manager.load_mapping( + combination=InputCombination([InputConfig(type=1, code=4)]) + ) + + self.data_manager.update_mapping( + output_symbol="bar", # not a macro and not a valid symbol + ) + self.data_manager.save() + + preset = Preset(PathUtils.get_preset_path("Foo Device", "preset2"), UIMapping) + preset.load() + mapping = preset.get_mapping(InputCombination([InputConfig(type=1, code=4)])) + self.assertIsNotNone(mapping.get_error()) + self.assertEqual(mapping.output_symbol, "bar") + + def test_update_mapping_combination_sends_massage(self): + prepare_presets() + + self.data_manager.load_group(group_key="Foo Device 2") + self.data_manager.load_preset(name="preset2") + self.data_manager.load_mapping( + combination=InputCombination([InputConfig(type=1, code=4)]) + ) + listener = Listener() + self.message_broker.subscribe(MessageType.mapping, listener) + self.message_broker.subscribe(MessageType.combination_update, listener) + + # we expect a message for combination update first, and then for mapping + self.data_manager.update_mapping( + input_combination=InputCombination( + InputCombination.from_tuples((1, 5), (1, 6)) + ) + ) + self.assertEqual(listener.calls[0].message_type, MessageType.combination_update) + self.assertEqual( + listener.calls[0].old_combination, + InputCombination([InputConfig(type=1, code=4)]), + ) + self.assertEqual( + listener.calls[0].new_combination, + InputCombination(InputCombination.from_tuples((1, 5), (1, 6))), + ) + self.assertEqual(listener.calls[1].message_type, MessageType.mapping) + self.assertEqual( + listener.calls[1].input_combination, + InputCombination(InputCombination.from_tuples((1, 5), (1, 6))), + ) + + def test_cannot_update_mapping_combination(self): + """updating a mapping with an already existing combination + should raise a KeyError""" + prepare_presets() + self.data_manager.load_group(group_key="Foo Device 2") + self.data_manager.load_preset(name="preset2") + self.data_manager.load_mapping( + combination=InputCombination([InputConfig(type=1, code=4)]) + ) + + self.assertRaises( + KeyError, + self.data_manager.update_mapping, + input_combination=InputCombination([InputConfig(type=1, code=3)]), + ) + + def test_cannot_update_mapping(self): + """updating a mapping should not be possible if the mapping was not loaded""" + prepare_presets() + self.assertRaises( + DataManagementError, + self.data_manager.update_mapping, + name="foo", + ) + self.data_manager.load_group(group_key="Foo Device 2") + self.assertRaises( + DataManagementError, + self.data_manager.update_mapping, + name="foo", + ) + self.data_manager.load_preset("preset2") + self.assertRaises( + DataManagementError, + self.data_manager.update_mapping, + name="foo", + ) + + def test_create_mapping(self): + """should be able to add a mapping to the current preset""" + prepare_presets() + + self.data_manager.load_group(group_key="Foo Device 2") + self.data_manager.load_preset(name="preset2") + listener = Listener() + self.message_broker.subscribe(MessageType.mapping, listener) + self.message_broker.subscribe(MessageType.preset, listener) + self.data_manager.create_mapping() # emits preset_changed + + self.data_manager.load_mapping(combination=InputCombination.empty_combination()) + + self.assertEqual(listener.calls[0].name, "preset2") + self.assertEqual(len(listener.calls[0].mappings), 3) + self.assertEqual(listener.calls[1], UIMapping()) + + def test_cannot_create_mapping_without_preset(self): + """adding a mapping if not preset is loaded + should raise an DataManagementError""" + prepare_presets() + + self.assertRaises(DataManagementError, self.data_manager.create_mapping) + self.data_manager.load_group(group_key="Foo Device 2") + self.assertRaises(DataManagementError, self.data_manager.create_mapping) + + def test_delete_mapping(self): + """should be able to delete a mapping""" + prepare_presets() + + old_preset = Preset(PathUtils.get_preset_path("Foo Device", "preset2")) + old_preset.load() + + self.data_manager.load_group(group_key="Foo Device 2") + self.data_manager.load_preset(name="preset2") + self.data_manager.load_mapping( + combination=InputCombination([InputConfig(type=1, code=3)]) + ) + + listener = Listener() + self.message_broker.subscribe(MessageType.preset, listener) + self.message_broker.subscribe(MessageType.mapping, listener) + + self.data_manager.delete_mapping() # emits preset + self.data_manager.save() + + deleted_mapping = old_preset.get_mapping( + InputCombination([InputConfig(type=1, code=3)]) + ) + mappings = listener.calls[0].mappings + preset_name = listener.calls[0].name + expected_preset = Preset(PathUtils.get_preset_path("Foo Device", "preset2")) + expected_preset.load() + expected_mappings = list(expected_preset) + + self.assertEqual(preset_name, "preset2") + for mapping in expected_mappings: + self.assertIn(mapping, mappings) + + self.assertNotIn(deleted_mapping, mappings) + + def test_cannot_delete_mapping(self): + """deleting a mapping should not be possible if the mapping was not loaded""" + prepare_presets() + self.assertRaises(DataManagementError, self.data_manager.delete_mapping) + self.data_manager.load_group(group_key="Foo Device 2") + self.assertRaises(DataManagementError, self.data_manager.delete_mapping) + self.data_manager.load_preset(name="preset2") + self.assertRaises(DataManagementError, self.data_manager.delete_mapping) + + def test_set_autoload(self): + """should be able to set the autoload status""" + prepare_presets() + self.data_manager.load_group(group_key="Foo Device") + + listener = Listener() + self.message_broker.subscribe(MessageType.preset, listener) + self.data_manager.load_preset(name="preset1") # sends updated preset data + self.data_manager.set_autoload(True) # sends updated preset data + self.data_manager.set_autoload(False) # sends updated preset data + + self.assertFalse(listener.calls[0].autoload) + self.assertTrue(listener.calls[1].autoload) + self.assertFalse(listener.calls[2].autoload) + + def test_each_device_can_have_autoload(self): + prepare_presets() + self.data_manager.load_group("Foo Device 2") + self.data_manager.load_preset("preset1") + self.data_manager.set_autoload(True) + + # switch to another device + self.data_manager.load_group("Foo Device") + self.data_manager.load_preset("preset1") + self.data_manager.set_autoload(True) + + # now check that both are set to autoload + self.data_manager.load_group("Foo Device 2") + self.data_manager.load_preset("preset1") + self.assertTrue(self.data_manager.get_autoload()) + + self.data_manager.load_group("Foo Device") + self.data_manager.load_preset("preset1") + self.assertTrue(self.data_manager.get_autoload()) + + def test_cannot_set_autoload_without_preset(self): + prepare_presets() + self.assertRaises( + DataManagementError, + self.data_manager.set_autoload, + True, + ) + self.data_manager.load_group(group_key="Foo Device 2") + self.assertRaises( + DataManagementError, + self.data_manager.set_autoload, + True, + ) + + def test_finds_newest_group(self): + Preset(PathUtils.get_preset_path("Foo Device", "preset 1")).save() + time.sleep(0.01) + Preset(PathUtils.get_preset_path("Bar Device", "preset 2")).save() + self.assertEqual(self.data_manager.get_newest_group_key(), "Bar Device") + + def test_finds_newest_preset(self): + Preset(PathUtils.get_preset_path("Foo Device", "preset 1")).save() + time.sleep(0.01) + Preset(PathUtils.get_preset_path("Foo Device", "preset 2")).save() + self.data_manager.load_group("Foo Device") + self.assertEqual(self.data_manager.get_newest_preset_name(), "preset 2") + + def test_newest_group_ignores_unknown_filetypes(self): + Preset(PathUtils.get_preset_path("Foo Device", "preset 1")).save() + time.sleep(0.01) + Preset(PathUtils.get_preset_path("Bar Device", "preset 2")).save() + + # not a preset, ignore + time.sleep(0.01) + path = os.path.join(PathUtils.get_preset_path("Foo Device"), "picture.png") + os.mknod(path) + + self.assertEqual(self.data_manager.get_newest_group_key(), "Bar Device") + + def test_newest_preset_ignores_unknown_filetypes(self): + Preset(PathUtils.get_preset_path("Bar Device", "preset 1")).save() + time.sleep(0.01) + Preset(PathUtils.get_preset_path("Bar Device", "preset 2")).save() + time.sleep(0.01) + Preset(PathUtils.get_preset_path("Bar Device", "preset 3")).save() + + # not a preset, ignore + time.sleep(0.01) + path = os.path.join(PathUtils.get_preset_path("Bar Device"), "picture.png") + os.mknod(path) + + self.data_manager.load_group("Bar Device") + + self.assertEqual(self.data_manager.get_newest_preset_name(), "preset 3") + + def test_newest_group_ignores_unknown_groups(self): + Preset(PathUtils.get_preset_path("Bar Device", "preset 1")).save() + time.sleep(0.01) + + # not a known group + Preset(PathUtils.get_preset_path("unknown_group", "preset 2")).save() + + self.assertEqual(self.data_manager.get_newest_group_key(), "Bar Device") + + def test_newest_group_and_preset_raises_file_not_found(self): + """should raise file not found error when all preset folders are empty""" + self.assertRaises(FileNotFoundError, self.data_manager.get_newest_group_key) + os.makedirs(PathUtils.get_preset_path("Bar Device")) + self.assertRaises(FileNotFoundError, self.data_manager.get_newest_group_key) + self.data_manager.load_group("Bar Device") + self.assertRaises(FileNotFoundError, self.data_manager.get_newest_preset_name) + + def test_newest_preset_raises_data_management_error(self): + """should raise data management error without an active group""" + self.assertRaises(DataManagementError, self.data_manager.get_newest_preset_name) + + def test_newest_preset_only_searches_active_group(self): + Preset(PathUtils.get_preset_path("Foo Device", "preset 1")).save() + time.sleep(0.01) + Preset(PathUtils.get_preset_path("Foo Device", "preset 3")).save() + time.sleep(0.01) + Preset(PathUtils.get_preset_path("Bar Device", "preset 2")).save() + + self.data_manager.load_group("Foo Device") + self.assertEqual(self.data_manager.get_newest_preset_name(), "preset 3") + + def test_available_preset_name_default(self): + self.data_manager.load_group("Foo Device") + self.assertEqual( + self.data_manager.get_available_preset_name(), DEFAULT_PRESET_NAME + ) + + def test_available_preset_name_adds_number_to_default(self): + Preset(PathUtils.get_preset_path("Foo Device", DEFAULT_PRESET_NAME)).save() + self.data_manager.load_group("Foo Device") + self.assertEqual( + self.data_manager.get_available_preset_name(), f"{DEFAULT_PRESET_NAME} 2" + ) + + def test_available_preset_name_returns_provided_name(self): + self.data_manager.load_group("Foo Device") + self.assertEqual(self.data_manager.get_available_preset_name("bar"), "bar") + + def test_available_preset_name__adds_number_to_provided_name(self): + Preset(PathUtils.get_preset_path("Foo Device", "bar")).save() + self.data_manager.load_group("Foo Device") + self.assertEqual(self.data_manager.get_available_preset_name("bar"), "bar 2") + + def test_available_preset_name_raises_data_management_error(self): + """should raise DataManagementError when group is not set""" + self.assertRaises( + DataManagementError, self.data_manager.get_available_preset_name + ) + + def test_get_preset_names(self): + self.data_manager.load_group("Qux/[Device]?") + Preset(PathUtils.get_preset_path("Qux/[Device]?", "new preset")).save() + # get_preset_names uses glob, the special characters in the device name + # don't break it. + self.assertEqual(self.data_manager.get_preset_names(), ("new preset",)) + + def test_available_preset_name_sanitized(self): + self.data_manager.load_group("Qux/[Device]?") + self.assertEqual( + self.data_manager.get_available_preset_name(), DEFAULT_PRESET_NAME + ) + + Preset(PathUtils.get_preset_path("Qux/[Device]?", DEFAULT_PRESET_NAME)).save() + self.assertEqual( + self.data_manager.get_available_preset_name(), f"{DEFAULT_PRESET_NAME} 2" + ) + + Preset(PathUtils.get_preset_path("Qux/[Device]?", "foo")).save() + self.assertEqual(self.data_manager.get_available_preset_name("foo"), "foo 2") + + def test_available_preset_name_increments_default(self): + Preset(PathUtils.get_preset_path("Foo Device", DEFAULT_PRESET_NAME)).save() + Preset( + PathUtils.get_preset_path("Foo Device", f"{DEFAULT_PRESET_NAME} 2") + ).save() + Preset( + PathUtils.get_preset_path("Foo Device", f"{DEFAULT_PRESET_NAME} 3") + ).save() + self.data_manager.load_group("Foo Device") + self.assertEqual( + self.data_manager.get_available_preset_name(), f"{DEFAULT_PRESET_NAME} 4" + ) + + def test_available_preset_name_increments_provided_name(self): + Preset(PathUtils.get_preset_path("Foo Device", "foo")).save() + Preset(PathUtils.get_preset_path("Foo Device", "foo 1")).save() + Preset(PathUtils.get_preset_path("Foo Device", "foo 2")).save() + self.data_manager.load_group("Foo Device") + self.assertEqual(self.data_manager.get_available_preset_name("foo 1"), "foo 3") + + def test_should_publish_groups(self): + listener = Listener() + self.message_broker.subscribe(MessageType.groups, listener) + + self.data_manager.publish_groups() + data = listener.calls[0] + + # we expect a list of tuples with the group key and their device types + self.assertEqual( + data.groups, + { + "Foo Device": ["keyboard"], + "Foo Device 2": ["gamepad", "keyboard", "mouse"], + "Bar Device": ["keyboard"], + "gamepad": ["gamepad"], + "Qux/[Device]?": ["keyboard"], + }, + ) + + def test_should_load_group(self): + prepare_presets() + listener = Listener() + self.message_broker.subscribe(MessageType.group, listener) + + self.data_manager.load_group("Foo Device 2") + + self.assertEqual(self.data_manager.active_group.key, "Foo Device 2") + data = ( + GroupData("Foo Device 2", (p1, p2, p3)) + for p1, p2, p3 in permutations(("preset3", "preset2", "preset1")) + ) + self.assertIn(listener.calls[0], data) + + def test_should_start_reading_active_group(self): + def f(*_): + raise AssertionError() + + self.reader.set_group = f + self.assertRaises(AssertionError, self.data_manager.load_group, "Foo Device") + + def test_should_send_uinputs(self): + listener = Listener() + self.message_broker.subscribe(MessageType.uinputs, listener) + + self.data_manager.publish_uinputs() + data = listener.calls[0] + + # we expect a list of tuples with the group key and their device types + self.assertEqual( + data.uinputs, + { + "gamepad": self.uinputs.get_uinput("gamepad").capabilities(), + "keyboard": self.uinputs.get_uinput("keyboard").capabilities(), + "mouse": self.uinputs.get_uinput("mouse").capabilities(), + "keyboard + mouse": self.uinputs.get_uinput( + "keyboard + mouse" + ).capabilities(), + }, + ) + + def test_cannot_stop_injecting_without_group(self): + self.assertRaises(DataManagementError, self.data_manager.stop_injecting) + + def test_cannot_start_injecting_without_preset(self): + self.data_manager.load_group("Foo Device") + self.assertRaises(DataManagementError, self.data_manager.start_injecting) + + def test_cannot_get_injector_state_without_group(self): + self.assertRaises(DataManagementError, self.data_manager.get_state) diff --git a/tests/unit/test_event_pipeline/__init__.py b/tests/unit/test_event_pipeline/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/unit/test_event_pipeline/event_pipeline_test_base.py b/tests/unit/test_event_pipeline/event_pipeline_test_base.py new file mode 100644 index 0000000..7ff8998 --- /dev/null +++ b/tests/unit/test_event_pipeline/event_pipeline_test_base.py @@ -0,0 +1,86 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + +import asyncio +import unittest +from typing import Iterable + +import evdev + +from inputremapper.configs.preset import Preset +from inputremapper.injection.context import Context +from inputremapper.injection.event_reader import EventReader +from inputremapper.injection.global_uinputs import GlobalUInputs, UInput +from inputremapper.injection.mapping_handlers.mapping_parser import MappingParser +from inputremapper.input_event import InputEvent +from tests.lib.cleanup import cleanup +from tests.lib.logger import logger +from tests.lib.fixtures import Fixture + + +class EventPipelineTestBase(unittest.IsolatedAsyncioTestCase): + """Test the event pipeline form event_reader to UInput.""" + + def setUp(self): + self.global_uinputs = GlobalUInputs(UInput) + self.global_uinputs.prepare_all() + self.mapping_parser = MappingParser(self.global_uinputs) + self.global_uinputs.is_service = True + self.global_uinputs.prepare_all() + self.forward_uinput = evdev.UInput(name="test-forward-uinput") + self.stop_event = asyncio.Event() + + def tearDown(self) -> None: + cleanup() + + async def asyncTearDown(self) -> None: + logger.info("setting stop_event for the reader") + self.stop_event.set() + await asyncio.sleep(0.5) + + @staticmethod + async def send_events(events: Iterable[InputEvent], event_reader: EventReader): + for event in events: + logger.info("sending into event_pipeline: %s", event) + await event_reader.handle(event) + + def create_event_reader( + self, + preset: Preset, + source: Fixture, + ) -> EventReader: + """Create and start an EventReader.""" + context = Context( + preset, + source_devices={}, + forward_devices={source.get_device_hash(): self.forward_uinput}, + mapping_parser=self.mapping_parser, + ) + reader = EventReader( + context, + evdev.InputDevice(source.path), + self.stop_event, + ) + asyncio.ensure_future(reader.run()) + return reader + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/test_event_pipeline/test_abs_to_abs.py b/tests/unit/test_event_pipeline/test_abs_to_abs.py new file mode 100644 index 0000000..74f1a26 --- /dev/null +++ b/tests/unit/test_event_pipeline/test_abs_to_abs.py @@ -0,0 +1,145 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + +import asyncio +import unittest + +from evdev.ecodes import ( + EV_ABS, + ABS_X, + ABS_Y, +) + +from inputremapper.configs.mapping import ( + Mapping, +) +from inputremapper.configs.preset import Preset +from inputremapper.configs.input_config import InputCombination, InputConfig +from inputremapper.input_event import InputEvent +from tests.lib.fixtures import fixtures +from tests.lib.test_setup import test_setup +from tests.unit.test_event_pipeline.event_pipeline_test_base import ( + EventPipelineTestBase, +) + + +@test_setup +class TestAbsToAbs(EventPipelineTestBase): + async def test_abs_to_abs(self): + gain = 0.5 + # left x to mouse x + input_config = InputConfig(type=EV_ABS, code=ABS_X) + mapping_config = { + "input_combination": InputCombination([input_config]).to_config(), + "target_uinput": "gamepad", + "output_type": EV_ABS, + "output_code": ABS_X, + "gain": gain, + "deadzone": 0, + } + mapping_1 = Mapping(**mapping_config) + preset = Preset() + preset.add(mapping_1) + input_config = InputConfig(type=EV_ABS, code=ABS_Y) + mapping_config["input_combination"] = InputCombination( + [input_config] + ).to_config() + mapping_config["output_code"] = ABS_Y + mapping_2 = Mapping(**mapping_config) + preset.add(mapping_2) + + x = fixtures.gamepad.max_abs + y = fixtures.gamepad.max_abs + + event_reader = self.create_event_reader(preset, fixtures.gamepad) + + await self.send_events( + [ + InputEvent.abs(ABS_X, -x), + InputEvent.abs(ABS_Y, y), + ], + event_reader, + ) + + await asyncio.sleep(0.2) + + history = self.global_uinputs.get_uinput("gamepad").write_history + self.assertEqual( + history, + [ + InputEvent.from_tuple((3, 0, fixtures.gamepad.min_abs / 2)), + InputEvent.from_tuple((3, 1, fixtures.gamepad.max_abs / 2)), + ], + ) + + async def test_abs_to_abs_with_input_switch(self): + gain = 0.5 + input_combination = InputCombination( + ( + InputConfig(type=EV_ABS, code=0), + InputConfig(type=EV_ABS, code=1, analog_threshold=10), + ) + ) + # left x to mouse x + mapping_config = { + "input_combination": input_combination.to_config(), + "target_uinput": "gamepad", + "output_type": EV_ABS, + "output_code": ABS_X, + "gain": gain, + "deadzone": 0, + } + mapping_1 = Mapping(**mapping_config) + preset = Preset() + preset.add(mapping_1) + + x = fixtures.gamepad.max_abs + y = fixtures.gamepad.max_abs + + event_reader = self.create_event_reader(preset, fixtures.gamepad) + + await self.send_events( + [ + InputEvent.abs(ABS_X, -x // 5), # will not map + InputEvent.abs(ABS_X, -x), # will map later + # switch axis on sends initial position (previous event) + InputEvent.abs(ABS_Y, y), + InputEvent.abs(ABS_X, x), # normally mapped + InputEvent.abs(ABS_Y, y // 15), # off, re-centers axis + InputEvent.abs(ABS_X, -x // 5), # will not map + ], + event_reader, + ) + + await asyncio.sleep(0.2) + + history = self.global_uinputs.get_uinput("gamepad").write_history + self.assertEqual( + history, + [ + InputEvent.from_tuple((3, 0, fixtures.gamepad.min_abs / 2)), + InputEvent.from_tuple((3, 0, fixtures.gamepad.max_abs / 2)), + InputEvent.from_tuple((3, 0, 0)), + ], + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/test_event_pipeline/test_abs_to_btn.py b/tests/unit/test_event_pipeline/test_abs_to_btn.py new file mode 100644 index 0000000..5a72887 --- /dev/null +++ b/tests/unit/test_event_pipeline/test_abs_to_btn.py @@ -0,0 +1,225 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + +import unittest + +from evdev.ecodes import EV_KEY, EV_ABS, ABS_X, ABS_Z + +from inputremapper.configs.mapping import ( + Mapping, +) +from inputremapper.configs.preset import Preset +from inputremapper.configs.keyboard_layout import keyboard_layout +from inputremapper.configs.input_config import InputCombination, InputConfig +from inputremapper.input_event import InputEvent +from tests.lib.logger import logger +from tests.lib.fixtures import fixtures +from tests.lib.test_setup import test_setup +from tests.unit.test_event_pipeline.event_pipeline_test_base import ( + EventPipelineTestBase, +) + + +@test_setup +class TestAbsToBtn(EventPipelineTestBase): + async def test_abs_trigger_threshold_simple(self): + # at 30% map to a + mapping_1 = Mapping.from_combination( + InputCombination( + [InputConfig(type=EV_ABS, code=ABS_X, analog_threshold=30)] + ), + output_symbol="a", + ) + preset = Preset() + preset.add(mapping_1) + a_code = keyboard_layout.get("a") + event_reader = self.create_event_reader(preset, fixtures.gamepad) + + # 50%, trigger a + await self.send_events( + [InputEvent.abs(ABS_X, fixtures.gamepad.max_abs // 2)], + event_reader, + ) + + keyboard_history = self.global_uinputs.get_uinput("keyboard").write_history + self.assertEqual(len(keyboard_history), 1) + self.assertEqual(keyboard_history[0], (EV_KEY, a_code, 1)) + self.assertNotIn((EV_KEY, a_code, 0), keyboard_history) + + async def test_abs_z(self): + # Shoulder triggers (ABS_Z, ABS_RZ, ABS_GAS, ABS_BRAKE). Their center point + # is equal to the fully released point. They only have one direction. + + # Triggers go from 0 to whatever value, so use the appropriate fixture. + # Yes this test setup is stupid, it should have different absinfos depending + # on the event. + fixture = fixtures.gamepad_abs_0_to_256 + + # at 30% map to a + mapping_1 = Mapping.from_combination( + InputCombination( + [InputConfig(type=EV_ABS, code=ABS_Z, analog_threshold=30)] + ), + output_symbol="a", + ) + + # This mapping is impossible. There is no negative direction. + mapping_2 = Mapping.from_combination( + InputCombination( + [InputConfig(type=EV_ABS, code=ABS_Z, analog_threshold=-30)] + ), + output_symbol="b", + ) + + preset = Preset() + preset.add(mapping_1) + preset.add(mapping_2) + a_code = keyboard_layout.get("a") + b_code = keyboard_layout.get("b") + event_reader = self.create_event_reader(preset, fixture) + max_abs = fixture.max_abs + + # 50%, trigger a + await self.send_events( + [InputEvent.abs(ABS_Z, max_abs // 2)], + event_reader, + ) + + keyboard_history = self.global_uinputs.get_uinput("keyboard").write_history + self.assertEqual(keyboard_history, [(EV_KEY, a_code, 1)]) + + # Lets slowly move it all the way into the other direction + assert fixture.min_abs == 0 # just for clarification here + await self.send_events( + [ + InputEvent.abs(ABS_Z, max_abs // 3), + InputEvent.abs(ABS_Z, max_abs // 5), + InputEvent.abs(ABS_Z, max_abs // 10), + InputEvent.abs(ABS_Z, 0), + ], + event_reader, + ) + + # The negative mapping (mapping_2, to b) was not triggered. It just released + # the a. + self.assertEqual( + keyboard_history, + [ + (EV_KEY, a_code, 1), + (EV_KEY, a_code, 0), + ], + ) + + async def test_abs_trigger_threshold(self): + """Test that different activation points for abs_to_btn work correctly.""" + forwarded_history = self.forward_uinput.write_history + + # at 30% map to a + mapping_1 = Mapping.from_combination( + InputCombination( + [InputConfig(type=EV_ABS, code=ABS_X, analog_threshold=30)] + ), + output_symbol="a", + ) + # at 70% map to b + mapping_2 = Mapping.from_combination( + InputCombination( + [InputConfig(type=EV_ABS, code=ABS_X, analog_threshold=70)] + ), + output_symbol="b", + ) + preset = Preset() + preset.add(mapping_1) + preset.add(mapping_2) + + a = keyboard_layout.get("a") + b = keyboard_layout.get("b") + + event_reader = self.create_event_reader(preset, fixtures.gamepad) + + logger.info("do nothing, then trigger a") + await self.send_events( + [ + # -10%, do nothing + InputEvent.abs(ABS_X, fixtures.gamepad.min_abs // 10), + # 0%, do noting + InputEvent.abs(ABS_X, 0), + # 10%, do nothing + InputEvent.abs(ABS_X, fixtures.gamepad.max_abs // 10), + # 50%, trigger a + InputEvent.abs(ABS_X, fixtures.gamepad.max_abs // 2), + ], + event_reader, + ) + keyboard_history = self.global_uinputs.get_uinput("keyboard").write_history + + self.assertEqual(keyboard_history.count((EV_KEY, a, 1)), 1) + self.assertNotIn((EV_KEY, a, 0), keyboard_history) + self.assertNotIn((EV_KEY, b, 1), keyboard_history) + # the negative movements are not mapped, so the one event at -10% and its + # release should be forwarded instead + self.assertEqual( + forwarded_history, + [ + (EV_ABS, ABS_X, fixtures.gamepad.min_abs // 10), + (EV_ABS, ABS_X, 0), + ], + ) + + logger.info("trigger b, then release b") + await self.send_events( + [ + # 80%, trigger b + InputEvent.abs(ABS_X, int(fixtures.gamepad.max_abs * 0.8)), + # 50%, release b + InputEvent.abs(ABS_X, fixtures.gamepad.max_abs // 2), + ], + event_reader, + ) + keyboard_history = self.global_uinputs.get_uinput("keyboard").write_history + self.assertEqual( + keyboard_history, + [ + (EV_KEY, a, 1), + (EV_KEY, b, 1), + (EV_KEY, b, 0), + ], + ) + + # 0% release a + await event_reader.handle(InputEvent.abs(ABS_X, 0)) + keyboard_history = self.global_uinputs.get_uinput("keyboard").write_history + self.assertEqual( + keyboard_history, + [ + (EV_KEY, a, 1), + (EV_KEY, b, 1), + (EV_KEY, b, 0), + (EV_KEY, a, 0), + ], + ) + + # This didn't change. ABS_X of 0 should not be forwarded, because the joystick + # came from the mapped direction to 0. Instead, it maps to release a. + self.assertEqual(len(forwarded_history), 2) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/test_event_pipeline/test_abs_to_rel.py b/tests/unit/test_event_pipeline/test_abs_to_rel.py new file mode 100644 index 0000000..b6ebb98 --- /dev/null +++ b/tests/unit/test_event_pipeline/test_abs_to_rel.py @@ -0,0 +1,202 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + +import asyncio +import unittest + +from evdev.ecodes import ( + EV_ABS, + EV_REL, + ABS_X, + ABS_Y, + REL_X, + REL_Y, + REL_HWHEEL, + REL_WHEEL, + REL_WHEEL_HI_RES, + REL_HWHEEL_HI_RES, +) + +from inputremapper.configs.mapping import ( + Mapping, + REL_XY_SCALING, + DEFAULT_REL_RATE, +) +from inputremapper.configs.preset import Preset +from inputremapper.configs.input_config import InputCombination, InputConfig +from inputremapper.input_event import InputEvent +from tests.lib.fixtures import fixtures +from tests.lib.test_setup import test_setup +from tests.unit.test_event_pipeline.event_pipeline_test_base import ( + EventPipelineTestBase, +) + + +@test_setup +class TestAbsToRel(EventPipelineTestBase): + async def test_abs_to_rel(self): + """Map gamepad EV_ABS events to EV_REL events.""" + + rel_rate = 60 # rate [Hz] at which events are produced + gain = 0.5 # halve the speed of the rel axis + # left x to mouse x + input_config = InputConfig(type=EV_ABS, code=ABS_X) + mapping_config = { + "input_combination": InputCombination([input_config]).to_config(), + "target_uinput": "mouse", + "output_type": EV_REL, + "output_code": REL_X, + "rel_rate": rel_rate, + "gain": gain, + "deadzone": 0, + } + mapping_1 = Mapping(**mapping_config) + preset = Preset() + preset.add(mapping_1) + # left y to mouse y + input_config = InputConfig(type=EV_ABS, code=ABS_Y) + mapping_config["input_combination"] = InputCombination( + [input_config] + ).to_config() + mapping_config["output_code"] = REL_Y + mapping_2 = Mapping(**mapping_config) + preset.add(mapping_2) + + # set input axis to 100% in order to move + # (gain * REL_XY_SCALING) pixel per event + x = fixtures.gamepad.max_abs + y = fixtures.gamepad.max_abs + + event_reader = self.create_event_reader(preset, fixtures.gamepad) + + await self.send_events( + [ + InputEvent.abs(ABS_X, -x), + InputEvent.abs(ABS_Y, -y), + ], + event_reader, + ) + # wait a bit more for it to sum up + sleep = 0.5 + await asyncio.sleep(sleep) + # stop it + await self.send_events( + [ + InputEvent.abs(ABS_X, 0), + InputEvent.abs(ABS_Y, 0), + ], + event_reader, + ) + + mouse_history = self.global_uinputs.get_uinput("mouse").write_history + + if mouse_history[0].type == EV_ABS: + raise AssertionError( + "The injector probably just forwarded them unchanged" + # possibly in addition to writing mouse events + ) + + # This varies quite a lot depending on the machines performance. + # Face it, python is a bad choice for this. + self.assertAlmostEqual(len(mouse_history), rel_rate * sleep * 2, delta=10) + + # those may be in arbitrary order + expected_value = -gain * REL_XY_SCALING * (rel_rate / DEFAULT_REL_RATE) + count_x = mouse_history.count((EV_REL, REL_X, expected_value)) + count_y = mouse_history.count((EV_REL, REL_Y, expected_value)) + self.assertGreater(count_x, 1) + self.assertGreater(count_y, 1) + # only those two types of events were written + self.assertEqual(len(mouse_history), count_x + count_y) + + async def test_abs_to_wheel_hi_res_quirk(self): + """When mapping to wheel events we always expect to see both, + REL_WHEEL and REL_WHEEL_HI_RES events with an accumulative value ratio of 1/120 + """ + rel_rate = 60 # rate [Hz] at which events are produced + gain = 1 + # left x to mouse x + input_config = InputConfig(type=EV_ABS, code=ABS_X) + mapping_config = { + "input_combination": InputCombination([input_config]).to_config(), + "target_uinput": "mouse", + "output_type": EV_REL, + "output_code": REL_WHEEL, + "rel_rate": rel_rate, + "gain": gain, + "deadzone": 0, + } + mapping_1 = Mapping(**mapping_config) + + preset = Preset() + preset.add(mapping_1) + # left y to mouse y + input_config = InputConfig(type=EV_ABS, code=ABS_Y) + mapping_config["input_combination"] = InputCombination( + [input_config] + ).to_config() + mapping_config["output_code"] = REL_HWHEEL_HI_RES + mapping_2 = Mapping(**mapping_config) + preset.add(mapping_2) + + # set input axis to 100% in order to move + # speed*gain*rate=1*0.5*60 pixel per second + x = fixtures.gamepad.max_abs + y = fixtures.gamepad.max_abs + + event_reader = self.create_event_reader(preset, fixtures.gamepad) + + await self.send_events( + [ + InputEvent.abs(ABS_X, x), + InputEvent.abs(ABS_Y, -y), + ], + event_reader, + ) + # wait a bit more for it to sum up + sleep = 0.8 + await asyncio.sleep(sleep) + # stop it + await self.send_events( + [ + InputEvent.abs(ABS_X, 0), + InputEvent.abs(ABS_Y, 0), + ], + event_reader, + ) + m_history = self.global_uinputs.get_uinput("mouse").write_history + + rel_wheel = sum([event.value for event in m_history if event.code == REL_WHEEL]) + rel_wheel_hi_res = sum( + [event.value for event in m_history if event.code == REL_WHEEL_HI_RES] + ) + rel_hwheel = sum( + [event.value for event in m_history if event.code == REL_HWHEEL] + ) + rel_hwheel_hi_res = sum( + [event.value for event in m_history if event.code == REL_HWHEEL_HI_RES] + ) + + self.assertAlmostEqual(rel_wheel, rel_wheel_hi_res / 120, places=0) + self.assertAlmostEqual(rel_hwheel, rel_hwheel_hi_res / 120, places=0) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/test_event_pipeline/test_axis_transformation.py b/tests/unit/test_event_pipeline/test_axis_transformation.py new file mode 100644 index 0000000..51b2269 --- /dev/null +++ b/tests/unit/test_event_pipeline/test_axis_transformation.py @@ -0,0 +1,209 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + +import dataclasses +import functools +import itertools +import unittest +from typing import Iterable, List + +from inputremapper.injection.mapping_handlers.axis_transform import Transformation +from tests.lib.test_setup import test_setup + + +@test_setup +class TestAxisTransformation(unittest.TestCase): + @dataclasses.dataclass + class InitArgs: + max_: int + min_: int + deadzone: float + gain: float + expo: float + + def values(self): + return self.__dict__.values() + + def get_init_args( + self, + max_=(255, 1000, 2**15), + min_=(50, 0, -255), + deadzone=(0, 0.5), + gain=(0.5, 1, 2), + expo=(-0.9, 0, 0.3), + ) -> Iterable[InitArgs]: + for args in itertools.product(max_, min_, deadzone, gain, expo): + yield self.InitArgs(*args) + + @staticmethod + def scale_to_range(min_, max_, x=(-1, -0.2, 0, 0.6, 1)) -> List[float]: + """Scale values between -1 and 1 up, such that they are between min and max.""" + half_range = (max_ - min_) / 2 + return [float_x * half_range + min_ + half_range for float_x in x] + + def test_scale_to_range(self): + """Make sure scale_to_range will actually return the min and max values + (avoid "off by one" errors)""" + max_ = (255, 1000, 2**15) + min_ = (50, 0, -255) + + for x1, x2 in itertools.product(min_, max_): + scaled = self.scale_to_range(x1, x2, (-1, 1)) + self.assertEqual(scaled, [x1, x2]) + + def test_expo_symmetry(self): + """Test that the transformation is symmetric for expo parameter + x = f(g(x)), if f._expo == - g._expo + + with the following constraints: + min = -1, max = 1 + gain = 1 + deadzone = 0 + + we can remove the constraints for min, max and gain, + by scaling the values appropriately after each transformation + """ + + for init_args in self.get_init_args(deadzone=(0,)): + f = Transformation(*init_args.values()) + init_args.expo = -init_args.expo + g = Transformation(*init_args.values()) + + scale = functools.partial( + self.scale_to_range, + init_args.min_, + init_args.max_, + ) + for x in scale(): + y1 = g(x) + y1 = y1 / init_args.gain # remove the gain + y1 = scale((y1,))[0] # remove the min/max constraint + + y2 = f(y1) + y2 = y2 / init_args.gain # remove the gain + y2 = scale((y2,))[0] # remove the min/max constraint + self.assertAlmostEqual(x, y2, msg=f"test expo symmetry for {init_args}") + + def test_origin_symmetry(self): + """Test that the transformation is symmetric to the origin_hash + f(x) = - f(-x) + within the constraints: min = -max + """ + + for init_args in self.get_init_args(): + init_args.min_ = -init_args.max_ + f = Transformation(*init_args.values()) + for x in self.scale_to_range(init_args.min_, init_args.max_): + self.assertAlmostEqual( + f(x), + -f(-x), + msg=f"test origin_hash symmetry at {x=} for {init_args}", + ) + + def test_gain(self): + """Test that f(max) = gain and f(min) = -gain.""" + for init_args in self.get_init_args(): + f = Transformation(*init_args.values()) + self.assertAlmostEqual( + f(init_args.max_), + init_args.gain, + msg=f"test gain for {init_args}", + ) + self.assertAlmostEqual( + f(init_args.min_), + -init_args.gain, + msg=f"test gain for {init_args}", + ) + + def test_deadzone(self): + """Test the Transfomation returns exactly 0 in the range of the deadzone.""" + + for init_args in self.get_init_args(deadzone=(0.1, 0.2, 0.9)): + f = Transformation(*init_args.values()) + for x in self.scale_to_range( + init_args.min_, + init_args.max_, + x=( + init_args.deadzone * 0.999, + -init_args.deadzone * 0.999, + 0.3 * init_args.deadzone, + 0, + ), + ): + self.assertEqual(f(x), 0, msg=f"test deadzone at {x=} for {init_args}") + + def test_continuity_near_deadzone(self): + """Test that the Transfomation is continues (no sudden jump) next to the + deadzone""" + + for init_args in self.get_init_args(deadzone=(0.1, 0.2, 0.9)): + f = Transformation(*init_args.values()) + scale = functools.partial( + self.scale_to_range, + init_args.min_, + init_args.max_, + ) + x = ( + init_args.deadzone * 1.00001, + init_args.deadzone * 1.001, + -init_args.deadzone * 1.00001, + -init_args.deadzone * 1.001, + ) + scaled_x = scale(x=x) + + p1 = (x[0], f(scaled_x[0])) # first point right of deadzone + p2 = (x[1], f(scaled_x[1])) # second point right of deadzone + + # calculate a linear function y = m * x + b from p1 and p2 + m = (p1[1] - p2[1]) / (p1[0] - p2[0]) + b = p1[1] - m * p1[0] + + # the zero intersection of that function must be close to the + # edge of the deadzone + self.assertAlmostEqual( + -b / m, + init_args.deadzone, + places=5, + msg=f"test continuity at {init_args.deadzone} for {init_args}", + ) + + # same thing on the other side + p1 = (x[2], f(scaled_x[2])) + p2 = (x[3], f(scaled_x[3])) + m = (p1[1] - p2[1]) / (p1[0] - p2[0]) + b = p1[1] - m * p1[0] + self.assertAlmostEqual( + -b / m, + -init_args.deadzone, + places=5, + msg=f"test continuity at {- init_args.deadzone} for {init_args}", + ) + + def test_expo_out_of_range(self): + f = Transformation(deadzone=0.1, min_=-20, max_=5, expo=1.3) + self.assertRaises(ValueError, f, 0) + f = Transformation(deadzone=0.1, min_=-20, max_=5, expo=-1.3) + self.assertRaises(ValueError, f, 0) + + def test_returns_one_for_range_between_minus_and_plus_one(self): + for init_args in self.get_init_args(max_=(1,), min_=(-1,), gain=(1,)): + f = Transformation(*init_args.values()) + self.assertEqual(f(1), 1) + self.assertEqual(f(-1), -1) diff --git a/tests/unit/test_event_pipeline/test_combination.py b/tests/unit/test_event_pipeline/test_combination.py new file mode 100644 index 0000000..837ccc5 --- /dev/null +++ b/tests/unit/test_event_pipeline/test_combination.py @@ -0,0 +1,1497 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + +import asyncio +import unittest + +from evdev.ecodes import ( + EV_KEY, + EV_ABS, + EV_REL, + ABS_X, + ABS_Y, + REL_X, + REL_Y, + BTN_A, + ABS_HAT0X, + BTN_LEFT, + BTN_B, + KEY_A, + ABS_HAT0Y, + KEY_B, + KEY_C, + KEY_D, + BTN_TL, + KEY_1, +) + +from inputremapper.configs.mapping import ( + Mapping, + REL_XY_SCALING, +) +from inputremapper.configs.preset import Preset +from inputremapper.configs.keyboard_layout import keyboard_layout +from inputremapper.configs.input_config import InputCombination, InputConfig +from inputremapper.input_event import InputEvent +from tests.lib.logger import logger +from tests.lib.fixtures import fixtures +from tests.lib.pipes import uinput_write_history +from tests.lib.test_setup import test_setup +from tests.unit.test_event_pipeline.event_pipeline_test_base import ( + EventPipelineTestBase, +) + + +@test_setup +class TestCombination(EventPipelineTestBase): + + # ----------------- + # | Test Template | + # ----------------- + + async def test_releases_then_triggers(self): + origin = fixtures.foo_device_2_keyboard + origin_hash = origin.get_device_hash() + + input_combination = InputCombination( + [ + InputConfig( + type=EV_KEY, + code=KEY_A, + origin_hash=origin_hash, + ), + InputConfig( + type=EV_KEY, + code=KEY_B, + origin_hash=origin_hash, + ), + ] + ) + + mapping = Mapping( + input_combination=input_combination.to_config(), + target_uinput="keyboard", + output_symbol="1", + release_combination_keys=True, + ) + + preset = Preset() + preset.add(mapping) + + event_reader = self.create_event_reader(preset, origin) + + await self.send_events( + [ + InputEvent.key(KEY_A, 1, origin_hash), + InputEvent.key(KEY_B, 1, origin_hash), + ], + event_reader, + ) + + # Other tests check forwarded_history and keyboard_history individually, + # so here is one that looks at the order in uinput_write_history + self.assertListEqual( + uinput_write_history, + [ + (EV_KEY, KEY_A, 1), + (EV_KEY, KEY_A, 0), + (EV_KEY, KEY_1, 1), + ], + ) + + # -------------------------- + # | More complicated tests | + # -------------------------- + + async def test_combine_hat_right_and_forward_hat_left(self): + origin = fixtures.gamepad + origin_hash = origin.get_device_hash() + + input_combination = InputCombination( + [ + InputConfig( + type=EV_ABS, + code=ABS_HAT0X, + origin_hash=origin_hash, + analog_threshold=30, + ), + InputConfig( + type=EV_KEY, + code=BTN_B, + origin_hash=origin_hash, + ), + ] + ) + + mapping = Mapping( + input_combination=input_combination.to_config(), + target_uinput="keyboard", + output_symbol="1", + release_combination_keys=True, + ) + + preset = Preset() + preset.add(mapping) + + event_reader = self.create_event_reader(preset, origin) + + # 1. dpad-Left works + # ------------------ + + await self.send_events( + [ + InputEvent.abs(ABS_HAT0X, -1, origin_hash), + InputEvent.abs(ABS_HAT0X, 0, origin_hash), + ], + event_reader, + ) + + # This used to be empty due to a bug + self.assertListEqual( + uinput_write_history, + [ + (EV_ABS, ABS_HAT0X, -1), + (EV_ABS, ABS_HAT0X, 0), + ], + ) + + # 2. Pressing dpad-right without combining it should forward it + # ------------------ + + await self.send_events( + [ + InputEvent.abs(ABS_HAT0X, 1, origin_hash), + InputEvent.abs(ABS_HAT0X, 0, origin_hash), + ], + event_reader, + ) + + self.assertListEqual( + uinput_write_history, + [ + # 1 + (EV_ABS, ABS_HAT0X, -1), + (EV_ABS, ABS_HAT0X, 0), + # 2 + (EV_ABS, ABS_HAT0X, 1), + (EV_ABS, ABS_HAT0X, 0), + ], + ) + + # 3. The combintaion can be triggered + # ------------------ + + await self.send_events( + [ + InputEvent.abs(ABS_HAT0X, 1, origin_hash), + InputEvent.key(BTN_B, 1, origin_hash), + InputEvent.key(BTN_B, 0, origin_hash), + InputEvent.abs(ABS_HAT0X, 0, origin_hash), + ], + event_reader, + ) + + self.assertListEqual( + uinput_write_history, + [ + # 1 + (EV_ABS, ABS_HAT0X, -1), + (EV_ABS, ABS_HAT0X, 0), + # 2 + (EV_ABS, ABS_HAT0X, 1), + (EV_ABS, ABS_HAT0X, 0), + # 3 + (EV_ABS, ABS_HAT0X, 1), + (EV_ABS, ABS_HAT0X, 0), + (EV_KEY, KEY_1, 1), + (EV_KEY, KEY_1, 0), + ], + ) + + async def test_abs_combination_0_to_256(self): + b = keyboard_layout.get("b") + origin = fixtures.gamepad_abs_0_to_256 + origin_hash = origin.get_device_hash() + mapping_1 = Mapping.from_combination( + InputCombination( + [ + InputConfig( + type=EV_ABS, + code=ABS_X, + # This value is relative in percent from half_range + # Positive: should trigger from 192 to 256 + # Negative: it would be from 0 to 64 + analog_threshold=50, + origin_hash=origin_hash, + ) + ] + ), + output_symbol="b", + ) + assert mapping_1.release_combination_keys + preset = Preset() + preset.add(mapping_1) + event_reader = self.create_event_reader(preset, origin) + + # don't trigger anything + await self.send_events( + [ + # Because the abs range is from 0 to 256, 128 is the centerpoint and + # releases both directions. + InputEvent.abs(ABS_X, 128, origin_hash), + # Wrong direction, should not trigger anything + InputEvent.abs(ABS_X, 0, origin_hash), + ], + event_reader, + ) + keyboard_history = self.global_uinputs.get_uinput("keyboard").write_history + self.assertEqual(len(keyboard_history), 0) + + # trigger b + await self.send_events( + [InputEvent.abs(ABS_X, 256, origin_hash)], + event_reader, + ) + self.assertEqual(keyboard_history.count((EV_KEY, b, 1)), 1) + self.assertEqual(len(keyboard_history), 1) + + # release b + await self.send_events( + [InputEvent.abs(ABS_X, 128, origin_hash)], + event_reader, + ) + keyboard_history = self.global_uinputs.get_uinput("keyboard").write_history + self.assertEqual(keyboard_history.count((EV_KEY, b, 0)), 1) + self.assertEqual(len(keyboard_history), 2) + + async def test_abs_combination_0_to_256_negative(self): + b = keyboard_layout.get("b") + origin = fixtures.gamepad_abs_0_to_256 + origin_hash = origin.get_device_hash() + mapping_1 = Mapping.from_combination( + InputCombination( + [ + InputConfig( + type=EV_ABS, + code=ABS_X, + # This value is relative in percent from half_range + # Positive: should trigger from 192 to 256 + # Negative: it would be from 0 to 64 + analog_threshold=-50, + origin_hash=origin_hash, + ) + ] + ), + output_symbol="b", + ) + assert mapping_1.release_combination_keys + preset = Preset() + preset.add(mapping_1) + event_reader = self.create_event_reader(preset, origin) + + logger.info("don't trigger anything") + await self.send_events( + [ + # Because the abs range is from 0 to 256, 128 is the centerpoint and + # releases both directions. + InputEvent.abs(ABS_X, 128, origin_hash), + # Wrong direction, should not trigger anything + InputEvent.abs(ABS_X, 256, origin_hash), + ], + event_reader, + ) + keyboard_history = self.global_uinputs.get_uinput("keyboard").write_history + self.assertEqual(len(keyboard_history), 0) + + logger.info("trigger b") + await self.send_events( + [InputEvent.abs(ABS_X, 0, origin_hash)], + event_reader, + ) + self.assertEqual(keyboard_history.count((EV_KEY, b, 1)), 1) + self.assertEqual(len(keyboard_history), 1) + + logger.info("release b") + await self.send_events( + [InputEvent.abs(ABS_X, 128, origin_hash)], + event_reader, + ) + keyboard_history = self.global_uinputs.get_uinput("keyboard").write_history + self.assertEqual(keyboard_history.count((EV_KEY, b, 0)), 1) + self.assertEqual(len(keyboard_history), 2) + + async def test_any_event_as_button(self): + """As long as there is an event handler and a mapping we should be able + to map anything to a button""" + + # value needs to be higher than 10% below center of axis (absinfo) + w_down = (EV_ABS, ABS_Y, -12345) + w_up = (EV_ABS, ABS_Y, 0) + + s_down = (EV_ABS, ABS_Y, 12345) + s_up = (EV_ABS, ABS_Y, 0) + + d_down = (EV_REL, REL_X, 100) + d_up = (EV_REL, REL_X, 0) + + a_down = (EV_REL, REL_X, -100) + a_up = (EV_REL, REL_X, 0) + + b_down = (EV_ABS, ABS_HAT0X, 1) + b_up = (EV_ABS, ABS_HAT0X, 0) + + c_down = (EV_ABS, ABS_HAT0X, -1) + c_up = (EV_ABS, ABS_HAT0X, 0) + + # first change the system mapping because Mapping will validate against it + keyboard_layout.clear() + code_w = 71 + code_b = 72 + code_c = 73 + code_d = 74 + code_a = 75 + code_s = 76 + keyboard_layout._set("w", code_w) + keyboard_layout._set("d", code_d) + keyboard_layout._set("a", code_a) + keyboard_layout._set("s", code_s) + keyboard_layout._set("b", code_b) + keyboard_layout._set("c", code_c) + + preset = Preset() + preset.add( + Mapping.from_combination( + InputCombination(InputCombination.from_tuples(b_down)), "keyboard", "b" + ) + ) + preset.add( + Mapping.from_combination( + InputCombination(InputCombination.from_tuples(c_down)), "keyboard", "c" + ) + ) + preset.add( + Mapping.from_combination( + InputCombination(InputCombination.from_tuples((*w_down[:2], -10))), + "keyboard", + "w", + ) + ) + preset.add( + Mapping.from_combination( + InputCombination(InputCombination.from_tuples((*d_down[:2], 10))), + "keyboard", + "k(d)", + ) + ) + preset.add( + Mapping.from_combination( + InputCombination(InputCombination.from_tuples((*s_down[:2], 10))), + "keyboard", + "s", + ) + ) + preset.add( + Mapping.from_combination( + InputCombination(InputCombination.from_tuples((*a_down[:2], -10))), + "keyboard", + "a", + ) + ) + + # gamepad fixture + event_reader = self.create_event_reader(preset, fixtures.gamepad) + + await self.send_events( + [ + InputEvent.from_tuple(b_down), + InputEvent.from_tuple(c_down), + InputEvent.from_tuple(w_down), + InputEvent.from_tuple(d_down), + InputEvent.from_tuple(s_down), + InputEvent.from_tuple(a_down), + InputEvent.from_tuple(b_up), + InputEvent.from_tuple(c_up), + InputEvent.from_tuple(w_up), + InputEvent.from_tuple(d_up), + InputEvent.from_tuple(s_up), + InputEvent.from_tuple(a_up), + ], + event_reader, + ) + # wait a bit for the rel_to_btn handler to send the key up + await asyncio.sleep(0.1) + + history = self.global_uinputs.get_uinput("keyboard").write_history + + self.assertEqual(history.count((EV_KEY, code_b, 1)), 1) + self.assertEqual(history.count((EV_KEY, code_c, 1)), 1) + self.assertEqual(history.count((EV_KEY, code_w, 1)), 1) + self.assertEqual(history.count((EV_KEY, code_d, 1)), 1) + self.assertEqual(history.count((EV_KEY, code_a, 1)), 1) + self.assertEqual(history.count((EV_KEY, code_s, 1)), 1) + self.assertEqual(history.count((EV_KEY, code_b, 0)), 1) + self.assertEqual(history.count((EV_KEY, code_c, 0)), 1) + self.assertEqual(history.count((EV_KEY, code_w, 0)), 1) + self.assertEqual(history.count((EV_KEY, code_d, 0)), 1) + self.assertEqual(history.count((EV_KEY, code_a, 0)), 1) + self.assertEqual(history.count((EV_KEY, code_s, 0)), 1) + + async def test_reset_releases_keys(self): + """Make sure that macros and keys are releases when the stop event is set.""" + preset = Preset() + input_cfg = InputCombination([InputConfig(type=EV_KEY, code=1)]).to_config() + preset.add( + Mapping.from_combination( + input_combination=input_cfg, output_symbol="hold(a)" + ) + ) + + input_cfg = InputCombination([InputConfig(type=EV_KEY, code=2)]).to_config() + preset.add( + Mapping.from_combination(input_combination=input_cfg, output_symbol="b") + ) + + input_cfg = InputCombination([InputConfig(type=EV_KEY, code=3)]).to_config() + preset.add( + Mapping.from_combination( + input_combination=input_cfg, output_symbol="modify(c,hold(d))" + ), + ) + event_reader = self.create_event_reader(preset, fixtures.foo_device_2_keyboard) + + a = keyboard_layout.get("a") + b = keyboard_layout.get("b") + c = keyboard_layout.get("c") + d = keyboard_layout.get("d") + + await self.send_events( + [ + InputEvent.key(1, 1), + InputEvent.key(2, 1), + InputEvent.key(3, 1), + ], + event_reader, + ) + await asyncio.sleep(0.1) + + forwarded_history = self.forward_uinput.write_history + keyboard_history = self.global_uinputs.get_uinput("keyboard").write_history + + self.assertEqual(len(forwarded_history), 0) + # a down, b down, c down, d down + self.assertEqual(len(keyboard_history), 4) + + event_reader.context.reset() + await asyncio.sleep(0.1) + + forwarded_history = self.forward_uinput.write_history + keyboard_history = self.global_uinputs.get_uinput("keyboard").write_history + + self.assertEqual(len(forwarded_history), 0) + # all a, b, c, d down+up + self.assertEqual(len(keyboard_history), 8) + keyboard_history = keyboard_history[-4:] + self.assertIn((EV_KEY, a, 0), keyboard_history) + self.assertIn((EV_KEY, b, 0), keyboard_history) + self.assertIn((EV_KEY, c, 0), keyboard_history) + self.assertIn((EV_KEY, d, 0), keyboard_history) + + async def test_forward_abs(self): + """Test if EV_ABS events are forwarded when other events of the same input are not.""" + preset = Preset() + # BTN_A -> 77 + keyboard_layout._set("b", 77) + preset.add( + Mapping.from_combination( + InputCombination([InputConfig(type=EV_KEY, code=BTN_A)]), + "keyboard", + "b", + ) + ) + event_reader = self.create_event_reader(preset, fixtures.gamepad) + + # should forward them unmodified + await self.send_events( + [ + InputEvent.abs(ABS_X, 10), + InputEvent.abs(ABS_Y, 20), + InputEvent.abs(ABS_X, -30), + InputEvent.abs(ABS_Y, -40), + # send them to keyboard 77 + InputEvent.key(BTN_A, 1), + InputEvent.key(BTN_A, 0), + ], + event_reader, + ) + + history = self.forward_uinput.write_history + keyboard_history = self.global_uinputs.get_uinput("keyboard").write_history + + self.assertEqual(history.count((EV_ABS, ABS_X, 10)), 1) + self.assertEqual(history.count((EV_ABS, ABS_Y, 20)), 1) + self.assertEqual(history.count((EV_ABS, ABS_X, -30)), 1) + self.assertEqual(history.count((EV_ABS, ABS_Y, -40)), 1) + self.assertEqual(keyboard_history.count((EV_KEY, 77, 1)), 1) + self.assertEqual(keyboard_history.count((EV_KEY, 77, 0)), 1) + + async def test_forward_rel(self): + """Test if EV_REL events are forwarded when other events of the same input are not.""" + preset = Preset() + # BTN_A -> 77 + keyboard_layout._set("b", 77) + preset.add( + Mapping.from_combination( + InputCombination([InputConfig(type=EV_KEY, code=BTN_LEFT)]), + "keyboard", + "b", + ) + ) + event_reader = self.create_event_reader(preset, fixtures.gamepad) + + # should forward them unmodified + await self.send_events( + [ + InputEvent.rel(REL_X, 10), + InputEvent.rel(REL_Y, 20), + InputEvent.rel(REL_X, -30), + InputEvent.rel(REL_Y, -40), + # send them to keyboard 77 + InputEvent.key(BTN_LEFT, 1), + InputEvent.key(BTN_LEFT, 0), + ], + event_reader, + ) + await asyncio.sleep(0.1) + + history = self.forward_uinput.write_history + keyboard_history = self.global_uinputs.get_uinput("keyboard").write_history + + self.assertEqual(history.count((EV_REL, REL_X, 10)), 1) + self.assertEqual(history.count((EV_REL, REL_Y, 20)), 1) + self.assertEqual(history.count((EV_REL, REL_X, -30)), 1) + self.assertEqual(history.count((EV_REL, REL_Y, -40)), 1) + self.assertEqual(keyboard_history.count((EV_KEY, 77, 1)), 1) + self.assertEqual(keyboard_history.count((EV_KEY, 77, 0)), 1) + + async def test_combination(self): + """Test if combinations map to keys properly.""" + a = keyboard_layout.get("a") + b = keyboard_layout.get("b") + c = keyboard_layout.get("c") + + origin = fixtures.gamepad + origin_hash = origin.get_device_hash() + + mapping_1 = Mapping.from_combination( + InputCombination( + [ + InputConfig( + type=EV_ABS, + code=ABS_X, + analog_threshold=1, + origin_hash=origin_hash, + ) + ] + ), + output_symbol="a", + ) + + mapping_2 = Mapping.from_combination( + InputCombination( + [ + InputConfig( + type=EV_ABS, + code=ABS_X, + analog_threshold=1, + origin_hash=origin_hash, + ), + InputConfig(type=EV_KEY, code=BTN_A, origin_hash=origin_hash), + ] + ), + output_symbol="b", + ) + + mapping_3 = Mapping.from_combination( + InputCombination( + [ + InputConfig( + type=EV_ABS, + code=ABS_X, + analog_threshold=1, + origin_hash=origin_hash, + ), + InputConfig(type=EV_KEY, code=BTN_A, origin_hash=origin_hash), + InputConfig(type=EV_KEY, code=BTN_B, origin_hash=origin_hash), + ] + ), + output_symbol="c", + ) + + assert mapping_1.release_combination_keys + assert mapping_2.release_combination_keys + assert mapping_3.release_combination_keys + + preset = Preset() + preset.add(mapping_1) + preset.add(mapping_2) + preset.add(mapping_3) + + event_reader = self.create_event_reader(preset, origin) + + # send_events awaits the event_reader to do its thing + await self.send_events( + [ + # forwarded + InputEvent.key(BTN_A, 1, origin_hash), + # triggers b, releases BTN_A, ABS_X + InputEvent.abs(ABS_X, 1234, origin_hash), + # triggers c, releases BTN_A, ABS_X, BTN_B + InputEvent.key(BTN_B, 1, origin_hash), + ], + event_reader, + ) + + keyboard_history = self.global_uinputs.get_uinput("keyboard").write_history + + forwarded_history = self.forward_uinput.write_history + + # I don't remember the specifics. I guess if there is a combination ongoing, + # it shouldn't trigger ABS_X -> a? + self.assertNotIn((EV_KEY, a, 1), keyboard_history) + + # c and b should have been written, because the input from send_events + # should trigger the combination + self.assertEqual(keyboard_history.count((EV_KEY, c, 1)), 1) + self.assertEqual(keyboard_history.count((EV_KEY, b, 1)), 1) + + self.assertEqual(forwarded_history.count((EV_KEY, BTN_A, 1)), 1) + self.assertIn((EV_KEY, BTN_A, 0), forwarded_history) + self.assertNotIn((EV_ABS, ABS_X, 1234), forwarded_history) + self.assertNotIn((EV_KEY, BTN_B, 1), forwarded_history) + + # release b and c) + await self.send_events( + [InputEvent.abs(ABS_X, 0, origin_hash)], + event_reader, + ) + + keyboard_history = self.global_uinputs.get_uinput("keyboard").write_history + + self.assertNotIn((EV_KEY, a, 1), keyboard_history) + self.assertNotIn((EV_KEY, a, 0), keyboard_history) + self.assertEqual(keyboard_history.count((EV_KEY, c, 0)), 1) + self.assertEqual(keyboard_history.count((EV_KEY, b, 0)), 1) + + async def test_combination_manual_release_in_press_order(self): + """Test if combinations map to keys properly.""" + # release_combination_keys is off + # press 5, then 6, then release 5, then release 6 + in_1 = keyboard_layout.get("7") + in_2 = keyboard_layout.get("8") + out = keyboard_layout.get("a") + + origin = fixtures.foo_device_2_keyboard + origin_hash = origin.get_device_hash() + + input_combination = InputCombination( + [ + InputConfig( + type=EV_KEY, + code=in_1, + origin_hash=origin_hash, + ), + InputConfig( + type=EV_KEY, + code=in_2, + origin_hash=origin_hash, + ), + ] + ) + + mapping = Mapping( + input_combination=input_combination.to_config(), + target_uinput="keyboard", + output_symbol="a", + release_combination_keys=False, + ) + + assert not mapping.release_combination_keys + + preset = Preset() + preset.add(mapping) + + event_reader = self.create_event_reader(preset, origin) + + keyboard_history = self.global_uinputs.get_uinput("keyboard").write_history + forwarded_history = self.forward_uinput.write_history + + # press the first key of the combination + await self.send_events([InputEvent.key(in_1, 1, origin_hash)], event_reader) + self.assertListEqual(forwarded_history, [(EV_KEY, in_1, 1)]) + + # then the second, it should trigger the combination + await self.send_events([InputEvent.key(in_2, 1, origin_hash)], event_reader) + self.assertListEqual(forwarded_history, [(EV_KEY, in_1, 1)]) + self.assertListEqual(keyboard_history, [(EV_KEY, out, 1)]) + + # release the first key. A key-down event was injected for it previously, so + # now we find a key-up event here as well. + await self.send_events([InputEvent.key(in_1, 0, origin_hash)], event_reader) + self.assertListEqual(forwarded_history, [(EV_KEY, in_1, 1), (EV_KEY, in_1, 0)]) + self.assertListEqual(keyboard_history, [(EV_KEY, out, 1), (EV_KEY, out, 0)]) + + # release the second key. No key-down event was injected, so we don't have a + # key-up event here either. + await self.send_events([InputEvent.key(in_2, 0, origin_hash)], event_reader) + self.assertListEqual(forwarded_history, [(EV_KEY, in_1, 1), (EV_KEY, in_1, 0)]) + self.assertListEqual(keyboard_history, [(EV_KEY, out, 1), (EV_KEY, out, 0)]) + + async def test_suppressed_doesnt_forward_releases(self): + origin = fixtures.foo_device_2_keyboard + origin_hash = origin.get_device_hash() + + input_combination_1 = InputCombination( + [ + InputConfig( + type=EV_KEY, + code=KEY_A, + origin_hash=origin_hash, + ), + InputConfig( + type=EV_KEY, + code=KEY_B, + origin_hash=origin_hash, + ), + InputConfig( + type=EV_KEY, + code=KEY_C, + origin_hash=origin_hash, + ), + ] + ) + + mapping_1 = Mapping( + input_combination=input_combination_1.to_config(), + target_uinput="keyboard", + output_symbol="1", + release_combination_keys=False, + ) + + input_combination_2 = InputCombination( + [ + InputConfig( + type=EV_KEY, + code=KEY_A, + origin_hash=origin_hash, + ), + InputConfig( + type=EV_KEY, + code=KEY_C, + origin_hash=origin_hash, + ), + ] + ) + + mapping_2 = Mapping( + input_combination=input_combination_2.to_config(), + target_uinput="keyboard", + output_symbol="2", + release_combination_keys=True, + ) + + preset = Preset() + preset.add(mapping_1) + preset.add(mapping_2) + + event_reader = self.create_event_reader(preset, origin) + # Trigger mapping_1, mapping_2 should be suppressed + await self.send_events( + [ + InputEvent.key(KEY_A, 1, origin_hash), + InputEvent.key(KEY_B, 1, origin_hash), + InputEvent.key(KEY_C, 1, origin_hash), + ], + event_reader, + ) + + keyboard_history = self.global_uinputs.get_uinput("keyboard").write_history + forwarded_history = self.forward_uinput.write_history + # There used to be an incorrect KEY_A release, caused by + # `release_combination_keys` on the suppressed mapping. + self.assertListEqual( + forwarded_history, + [ + (EV_KEY, KEY_A, 1), + (EV_KEY, KEY_B, 1), + ], + ) + self.assertListEqual( + keyboard_history, + [ + (EV_KEY, KEY_1, 1), + ], + ) + + async def test_no_stuck_key(self): + origin = fixtures.foo_device_2_keyboard + origin_hash = origin.get_device_hash() + + input_combination_1 = InputCombination( + [ + InputConfig( + type=EV_KEY, + code=KEY_A, + origin_hash=origin_hash, + ), + InputConfig( + type=EV_KEY, + code=KEY_B, + origin_hash=origin_hash, + ), + InputConfig( + type=EV_KEY, + code=KEY_D, + origin_hash=origin_hash, + ), + ] + ) + + mapping_1 = Mapping( + input_combination=input_combination_1.to_config(), + target_uinput="keyboard", + output_symbol="1", + release_combination_keys=False, + ) + + input_combination_2 = InputCombination( + [ + InputConfig( + type=EV_KEY, + code=KEY_C, + origin_hash=origin_hash, + ), + InputConfig( + type=EV_KEY, + code=KEY_D, + origin_hash=origin_hash, + ), + ] + ) + + mapping_2 = Mapping( + input_combination=input_combination_2.to_config(), + target_uinput="keyboard", + output_symbol="2", + release_combination_keys=False, + ) + + preset = Preset() + preset.add(mapping_1) + preset.add(mapping_2) + + event_reader = self.create_event_reader(preset, origin) + # Trigger mapping_1, mapping_2 should be suppressed + await self.send_events( + [ + InputEvent.key(KEY_A, 1, origin_hash), + InputEvent.key(KEY_B, 1, origin_hash), + InputEvent.key(KEY_C, 1, origin_hash), + InputEvent.key(KEY_D, 1, origin_hash), + # Release c -> mapping_2 transitions from suppressed to passive. + # The release of c should be forwarded. + InputEvent.key(KEY_C, 0, origin_hash), + ], + event_reader, + ) + + keyboard_history = self.global_uinputs.get_uinput("keyboard").write_history + forwarded_history = self.forward_uinput.write_history + self.assertListEqual( + forwarded_history, + [ + (EV_KEY, KEY_A, 1), + (EV_KEY, KEY_B, 1), + (EV_KEY, KEY_C, 1), + (EV_KEY, KEY_C, 0), + ], + ) + self.assertListEqual( + keyboard_history, + [ + (EV_KEY, KEY_1, 1), + ], + ) + + async def test_ignore_hold(self): + # hold as in event-value 2, not in macro-hold. + # linux will generate events with value 2 after input-remapper injected + # the key-press, so input-remapper doesn't need to forward them. That + # would cause duplicate events of those values otherwise. + ev_1 = InputEvent.key(KEY_A, 1) + ev_2 = InputEvent.key(KEY_A, 2) + ev_3 = InputEvent.key(KEY_A, 0) + + preset = Preset() + preset.add( + Mapping.from_combination( + input_combination=InputCombination( + [InputConfig(type=EV_KEY, code=KEY_A)] + ), + output_symbol="a", + ) + ) + a = keyboard_layout.get("a") + + event_reader = self.create_event_reader(preset, fixtures.gamepad) + await self.send_events( + [ev_1, ev_2, ev_3], + event_reader, + ) + + keyboard_history = self.global_uinputs.get_uinput("keyboard").write_history + forwarded_history = self.forward_uinput.write_history + self.assertEqual(len(keyboard_history), 2) + self.assertEqual(len(forwarded_history), 0) + self.assertNotIn((EV_KEY, a, 2), keyboard_history) + + async def test_ignore_disabled(self): + origin = fixtures.gamepad + origin_hash = origin.get_device_hash() + + ev_1 = InputEvent.abs(ABS_HAT0Y, 1, origin_hash) + ev_2 = InputEvent.abs(ABS_HAT0Y, 0, origin_hash) + + ev_3 = InputEvent.abs(ABS_HAT0X, 1, origin_hash) # disabled + ev_4 = InputEvent.abs(ABS_HAT0X, 0, origin_hash) + + ev_5 = InputEvent.key(KEY_A, 1, origin_hash) + ev_6 = InputEvent.key(KEY_A, 0, origin_hash) + + combi_1 = (ev_5, ev_3) + combi_2 = (ev_3, ev_5) + + preset = Preset() + preset.add( + Mapping.from_combination( + input_combination=InputCombination( + [ + InputConfig.from_input_event(ev_1), + ] + ), + output_symbol="a", + ) + ) + preset.add( + Mapping.from_combination( + input_combination=InputCombination( + [ + InputConfig.from_input_event(ev_3), + ] + ), + output_symbol="disable", + ) + ) + preset.add( + Mapping.from_combination( + input_combination=InputCombination( + ( + InputConfig.from_input_event(combi_1[0]), + InputConfig.from_input_event(combi_1[1]), + ) + ), + output_symbol="b", + ) + ) + preset.add( + Mapping.from_combination( + input_combination=InputCombination( + ( + InputConfig.from_input_event(combi_2[0]), + InputConfig.from_input_event(combi_2[1]), + ) + ), + output_symbol="c", + ) + ) + + a = keyboard_layout.get("a") + b = keyboard_layout.get("b") + c = keyboard_layout.get("c") + + event_reader = self.create_event_reader(preset, origin) + + """Single keys""" + await self.send_events( + [ + ev_1, # press a + ev_3, # disabled + ev_2, # release a + ev_4, # disabled + ], + event_reader, + ) + keyboard_history = self.global_uinputs.get_uinput("keyboard").write_history + forwarded_history = self.forward_uinput.write_history + self.assertIn((EV_KEY, a, 1), keyboard_history) + self.assertIn((EV_KEY, a, 0), keyboard_history) + self.assertEqual(len(keyboard_history), 2) + self.assertEqual(len(forwarded_history), 0) + + """A combination that ends in a disabled key""" + # ev_5 should be forwarded and the combination triggered + await self.send_events(combi_1, event_reader) + keyboard_history = self.global_uinputs.get_uinput("keyboard").write_history + forwarded_history = self.forward_uinput.write_history + self.assertIn((EV_KEY, b, 1), keyboard_history) + self.assertEqual(len(keyboard_history), 3) + self.assertEqual(forwarded_history.count(ev_3), 0) + self.assertEqual(forwarded_history.count(ev_5), 1) + self.assertTrue(forwarded_history.count(ev_6) >= 1) + + # release what the combination maps to + await self.send_events([ev_4, ev_6], event_reader) + keyboard_history = self.global_uinputs.get_uinput("keyboard").write_history + forwarded_history = self.forward_uinput.write_history + self.assertIn((EV_KEY, b, 0), keyboard_history) + self.assertEqual(len(keyboard_history), 4) + self.assertEqual(forwarded_history.count(ev_3), 0) + self.assertTrue(forwarded_history.count(ev_6) >= 1) + + """A combination that starts with a disabled key""" + # only the combination should get triggered + await self.send_events(combi_2, event_reader) + keyboard_history = self.global_uinputs.get_uinput("keyboard").write_history + forwarded_history = self.forward_uinput.write_history + self.assertIn((EV_KEY, c, 1), keyboard_history) + self.assertEqual(len(keyboard_history), 5) + self.assertEqual(forwarded_history.count(ev_3), 0) + self.assertEqual(forwarded_history.count(ev_5), 1) + self.assertTrue(forwarded_history.count(ev_6) >= 1) + + # release what the combination maps to + await self.send_events([ev_4, ev_6], event_reader) + keyboard_history = self.global_uinputs.get_uinput("keyboard").write_history + forwarded_history = self.forward_uinput.write_history + for event in keyboard_history: + print(event.event_tuple) + self.assertIn((EV_KEY, c, 0), keyboard_history) + self.assertEqual(len(keyboard_history), 6) + self.assertEqual(forwarded_history.count(ev_3), 0) + self.assertTrue(forwarded_history.count(ev_6) >= 1) + + async def test_combination_keycode_macro_mix(self): + """Ev_1 triggers macro, ev_1 + ev_2 triggers key while the macro is + still running""" + + down_1 = (EV_ABS, ABS_HAT0X, 1) + down_2 = (EV_ABS, ABS_HAT0Y, -1) + up_1 = (EV_ABS, ABS_HAT0X, 0) + up_2 = (EV_ABS, ABS_HAT0Y, 0) + + a = keyboard_layout.get("a") + b = keyboard_layout.get("b") + + preset = Preset() + preset.add( + Mapping.from_combination( + InputCombination(InputCombination.from_tuples(down_1)), + output_symbol="h(k(a))", + ) + ) + preset.add( + Mapping.from_combination( + InputCombination(InputCombination.from_tuples(down_1, down_2)), + output_symbol="b", + ) + ) + + event_reader = self.create_event_reader(preset, fixtures.gamepad) + # macro starts + await self.send_events([InputEvent.from_tuple(down_1)], event_reader) + await asyncio.sleep(0.05) + keyboard_history = self.global_uinputs.get_uinput("keyboard").write_history + forwarded_history = self.forward_uinput.write_history + self.assertEqual(len(forwarded_history), 0) + self.assertGreater(len(keyboard_history), 1) + self.assertNotIn((EV_KEY, b, 1), keyboard_history) + self.assertIn((EV_KEY, a, 1), keyboard_history) + self.assertIn((EV_KEY, a, 0), keyboard_history) + + # combination triggered + await self.send_events([InputEvent.from_tuple(down_2)], event_reader) + await asyncio.sleep(0) + keyboard_history = self.global_uinputs.get_uinput("keyboard").write_history + self.assertIn((EV_KEY, b, 1), keyboard_history) + + len_a = len(self.global_uinputs.get_uinput("keyboard").write_history) + await asyncio.sleep(0.05) + len_b = len(self.global_uinputs.get_uinput("keyboard").write_history) + # still running + self.assertGreater(len_b, len_a) + + # release + await self.send_events([InputEvent.from_tuple(up_1)], event_reader) + keyboard_history = self.global_uinputs.get_uinput("keyboard").write_history + self.assertEqual(keyboard_history[-1], (EV_KEY, b, 0)) + await asyncio.sleep(0.05) + len_c = len(self.global_uinputs.get_uinput("keyboard").write_history) + await asyncio.sleep(0.05) + len_d = len(self.global_uinputs.get_uinput("keyboard").write_history) + # not running anymore + self.assertEqual(len_c, len_d) + + await self.send_events([InputEvent.from_tuple(up_2)], event_reader) + await asyncio.sleep(0.05) + len_e = len(self.global_uinputs.get_uinput("keyboard").write_history) + self.assertEqual(len_e, len_d) + + async def test_wheel_combination_release_failure(self): + # test based on a bug that once occurred + # 1 | 22.6698, ((1, 276, 1)) -------------- forwarding + # 2 | 22.9904, ((1, 276, 1), (2, 8, -1)) -- maps to 30 + # 3 | 23.0103, ((1, 276, 1), (2, 8, -1)) -- duplicate key down + # 4 | ... 34 more duplicate key downs (scrolling) + # 5 | 23.7104, ((1, 276, 1), (2, 8, -1)) -- duplicate key down + # 6 | 23.7283, ((1, 276, 0)) -------------- forwarding release + # 7 | 23.7303, ((2, 8, -1)) --------------- forwarding + # 8 | 23.7865, ((2, 8, 0)) ---------------- not forwarding release + # line 7 should have been "duplicate key down" as well + # line 8 should have released 30, instead it was never released + # + # Note: the test was modified for the new Event pipeline: + # line 6 now releases the combination + # line 7 get forwarded + # line 8 get forwarded + + scroll = InputEvent.from_tuple((2, 8, -1)) + scroll_release = InputEvent.from_tuple((2, 8, 0)) + btn_down = InputEvent.key(276, 1) + btn_up = InputEvent.key(276, 0) + combination = InputCombination( + InputCombination.from_tuples((1, 276, 1), (2, 8, -1)) + ) + + keyboard_layout.clear() + keyboard_layout._set("a", 30) + a = 30 + + m = Mapping.from_combination(combination, output_symbol="a") + m.release_timeout = 0.1 # a higher release timeout to give time for assertions + + preset = Preset() + preset.add(m) + + event_reader = self.create_event_reader(preset, fixtures.foo_device_2_mouse) + + await self.send_events([btn_down], event_reader) + forwarded_history = self.forward_uinput.write_history + self.assertEqual(forwarded_history[0], btn_down) + + await self.send_events([scroll], event_reader) + # "maps to 30" + keyboard_history = self.global_uinputs.get_uinput("keyboard").write_history + self.assertEqual(keyboard_history[0], (EV_KEY, a, 1)) + + await self.send_events([scroll] * 5, event_reader) + + # nothing new since all of them were duplicate key downs + keyboard_history = self.global_uinputs.get_uinput("keyboard").write_history + self.assertEqual(len(keyboard_history), 1) + + await self.send_events([btn_up], event_reader) + # releasing the combination + keyboard_history = self.global_uinputs.get_uinput("keyboard").write_history + self.assertEqual(keyboard_history[1], (EV_KEY, a, 0)) + + # more scroll events + # it should be ignored as duplicate key-down + await self.send_events([scroll] * 5, event_reader) + forwarded_history = self.forward_uinput.write_history + self.assertEqual(forwarded_history.count(scroll), 5) + + await self.send_events([scroll_release], event_reader) + forwarded_history = self.forward_uinput.write_history + self.assertEqual(forwarded_history[-1], scroll_release) + + async def test_can_not_map(self): + """Inject events to wrong or invalid uinput.""" + ev_1 = InputEvent.key(KEY_A, 1) + ev_2 = InputEvent.key(KEY_B, 1) + ev_3 = InputEvent.key(KEY_C, 1) + + ev_4 = InputEvent.key(KEY_A, 0) + ev_5 = InputEvent.key(KEY_B, 0) + ev_6 = InputEvent.key(KEY_C, 0) + + mapping_1 = Mapping( + input_combination=InputCombination([InputConfig.from_input_event(ev_2)]), + target_uinput="keyboard", + output_type=EV_KEY, + output_code=BTN_TL, + ) + mapping_2 = Mapping( + input_combination=InputCombination([InputConfig.from_input_event(ev_3)]), + target_uinput="keyboard", + output_type=EV_KEY, + output_code=KEY_A, + ) + + preset = Preset() + preset.add(mapping_1) + preset.add(mapping_2) + + event_reader = self.create_event_reader(preset, fixtures.foo_device_2_mouse) + # send key-down and up + await self.send_events( + [ + ev_1, + ev_2, + ev_3, + ev_4, + ev_5, + ev_6, + ], + event_reader, + ) + + keyboard_history = self.global_uinputs.get_uinput("keyboard").write_history + forwarded_history = self.forward_uinput.write_history + + self.assertEqual(len(forwarded_history), 4) + self.assertEqual(len(keyboard_history), 2) + self.assertIn(ev_1, forwarded_history) + self.assertIn(ev_2, forwarded_history) + self.assertIn(ev_4, forwarded_history) + self.assertIn(ev_5, forwarded_history) + self.assertNotIn(ev_3, forwarded_history) + self.assertNotIn(ev_6, forwarded_history) + + self.assertIn((EV_KEY, KEY_A, 1), keyboard_history) + self.assertIn((EV_KEY, KEY_A, 0), keyboard_history) + + async def test_axis_switch(self): + """Test a mapping for an axis that can be switched on or off.""" + + rel_rate = 60 # rate [Hz] at which events are produced + gain = 0.5 # halve the speed of the rel axis + preset = Preset() + mouse = self.global_uinputs.get_uinput("mouse") + forward_history = self.forward_uinput.write_history + mouse_history = mouse.write_history + + # ABS_X to REL_Y if ABS_Y is above 10% + combination = InputCombination( + [ + # Apparently, setting the threshold to None declares this as a fully + # analog input. The ABS_X movement will probably be mapped to x mouse + # movement. + InputConfig(type=EV_ABS, code=ABS_X, analog_threshold=None), + InputConfig(type=EV_ABS, code=ABS_Y, analog_threshold=10), + ] + ) + + cfg = { + "input_combination": combination.to_config(), + "target_uinput": "mouse", + "output_type": EV_REL, + "output_code": REL_X, + "rel_rate": rel_rate, + "gain": gain, + "deadzone": 0, + } + m1 = Mapping(**cfg) + preset.add(m1) + + event_reader = self.create_event_reader(preset, fixtures.gamepad) + + max_abs = fixtures.gamepad.max_abs + + logger.info("set ABS_X input to 100%% (%s)", max_abs) + await event_reader.handle(InputEvent.abs(ABS_X, max_abs)) + + # wait a bit more for nothing to sum up, because ABS_Y is still 0. + await asyncio.sleep(0.2) + self.assertEqual( + # There is one forwarded event from maxing out abs_x + InputEvent.from_event(forward_history[0]), + (EV_ABS, ABS_X, max_abs), + ) + self.assertEqual(len(mouse_history), 0) + self.assertEqual(len(forward_history), 1) + + logger.info("slowly move ABS_Y above 10%% (> %s)", max_abs * 0.10) + await self.send_events( + ( + InputEvent.abs(ABS_Y, int(max_abs * 0.05)), + InputEvent.abs(ABS_Y, int(max_abs * 0.11)), + InputEvent.abs(ABS_Y, int(max_abs * 0.5)), + ), + event_reader, + ) + + # wait a bit more for it to sum up + sleep = 0.5 + await asyncio.sleep(sleep) + + self.assertAlmostEqual(len(mouse_history), rel_rate * sleep, delta=3) + self.assertEqual(len(forward_history), 1) + + logger.info("send some more x events") + await self.send_events( + ( + InputEvent.abs(ABS_X, max_abs), + InputEvent.abs(ABS_X, int(max_abs * 0.9)), + ), + event_reader, + ) + + # stop it + await event_reader.handle(InputEvent.abs(ABS_Y, int(max_abs * 0.05))) + + await asyncio.sleep(0.2) # wait a bit more for nothing to sum up + if mouse_history[0].type == EV_ABS: + raise AssertionError( + "The injector probably just forwarded them unchanged" + # possibly in addition to writing mouse events + ) + + # does not contain anything else + expected_rel_event = (EV_REL, REL_X, int(gain * REL_XY_SCALING)) + count_x = mouse_history.count(expected_rel_event) + self.assertEqual(len(mouse_history), count_x) + + self.assertAlmostEqual(len(mouse_history), rel_rate * sleep, delta=3) + + async def test_key_axis_combination_to_disable(self): + combination = InputCombination( + [ + InputConfig(type=EV_ABS, code=ABS_X), + InputConfig(type=EV_ABS, code=ABS_Y, analog_threshold=5), + ] + ) + preset = Preset() + forward_history = self.forward_uinput.write_history + + mapping = Mapping( + input_combination=combination, + output_symbol="disable", + target_uinput="keyboard", + ) + preset.add(mapping) + + event_reader = self.create_event_reader(preset, fixtures.gamepad) + + max_abs = fixtures.gamepad.max_abs + await self.send_events( + [ + InputEvent.from_tuple((EV_ABS, ABS_X, 10)), # forwarded + InputEvent.from_tuple((EV_ABS, ABS_Y, int(0.1 * max_abs))), + InputEvent.from_tuple((EV_ABS, ABS_X, 20)), # disabled + InputEvent.from_tuple((EV_ABS, ABS_Y, int(0.02 * max_abs))), + InputEvent.from_tuple((EV_ABS, ABS_X, 30)), # forwarded + ], + event_reader, + ) + self.assertEqual( + forward_history, + [ + InputEvent.from_tuple((EV_ABS, ABS_X, 10)), + InputEvent.from_tuple((EV_ABS, ABS_X, 30)), + ], + ) + + async def test_multiple_axis_in_hierarchy_handler(self): + preset = Preset() + + # Add two mappings that map EV_REL to EV_ABS. We want to test that they don't + # suppress each other when they are part of a hierarchy handler. So having at + # least two of them is important for this test. + cutoff = 2 + for in_, out in [(REL_X, ABS_X), (REL_Y, ABS_Y)]: + input_combination = InputCombination( + [ + InputConfig(type=EV_KEY, code=KEY_A), + InputConfig(type=EV_REL, code=in_), + ] + ) + mapping = Mapping( + input_combination=input_combination.to_config(), + target_uinput="gamepad", + output_type=EV_ABS, + output_code=out, + gain=0.5, + rel_to_abs_input_cutoff=cutoff, + release_timeout=0.1, + deadzone=0, + ) + preset.add(mapping) + + # Add a non-analog mapping, to make sure a HierarchyHandler exists + key_input = InputCombination( + ( + InputConfig(type=EV_KEY, code=KEY_A), + InputConfig(type=EV_KEY, code=KEY_B), + ) + ) + key_mapping = Mapping( + input_combination=key_input.to_config(), + target_uinput="keyboard", + output_type=EV_KEY, + output_code=KEY_C, + ) + preset.add(key_mapping) + + event_reader = self.create_event_reader(preset, fixtures.gamepad) + + # Trigger all of them at the same time + value = int(REL_XY_SCALING * cutoff) + await asyncio.sleep(0.1) + await self.send_events( + [ + InputEvent(0, 0, EV_KEY, KEY_A, 1), + InputEvent(0, 0, EV_REL, REL_X, value), + InputEvent(0, 0, EV_REL, REL_Y, value), + InputEvent(0, 0, EV_KEY, KEY_B, 1), + InputEvent(0, 0, EV_KEY, KEY_B, 0), + ], + event_reader, + ) + + await asyncio.sleep(0.4) + + # We expect all of it to be present. No mapping was suppressed. + # So we can trigger combinations that inject keys while a joystick is being + # simulated in multiple directions. + max_abs = fixtures.gamepad.max_abs + self.assertEqual( + uinput_write_history, + [ + InputEvent.from_tuple((EV_ABS, ABS_X, max_abs / 2)), + InputEvent.from_tuple((EV_ABS, ABS_Y, max_abs / 2)), + InputEvent.from_tuple((EV_KEY, KEY_C, 1)), + InputEvent.from_tuple((EV_KEY, KEY_C, 0)), + InputEvent.from_tuple((EV_ABS, ABS_X, 0)), + InputEvent.from_tuple((EV_ABS, ABS_Y, 0)), + ], + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/test_event_pipeline/test_mapping_handlers.py b/tests/unit/test_event_pipeline/test_mapping_handlers.py new file mode 100644 index 0000000..9661f3b --- /dev/null +++ b/tests/unit/test_event_pipeline/test_mapping_handlers.py @@ -0,0 +1,597 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + + +"""See TestEventPipeline for more tests.""" + +import asyncio +import unittest +from unittest.mock import MagicMock + +import evdev +from evdev.ecodes import ( + EV_KEY, + EV_ABS, + EV_REL, + ABS_X, + REL_X, + BTN_LEFT, + BTN_RIGHT, + KEY_A, + REL_Y, + REL_WHEEL, +) + +from inputremapper.configs.input_config import InputCombination, InputConfig +from inputremapper.configs.mapping import Mapping, DEFAULT_REL_RATE, KnownUinput +from inputremapper.injection.global_uinputs import GlobalUInputs, UInput +from inputremapper.injection.mapping_handlers.abs_to_abs_handler import AbsToAbsHandler +from inputremapper.injection.mapping_handlers.abs_to_btn_handler import AbsToBtnHandler +from inputremapper.injection.mapping_handlers.abs_to_rel_handler import AbsToRelHandler +from inputremapper.injection.mapping_handlers.axis_switch_handler import ( + AxisSwitchHandler, +) +from inputremapper.injection.mapping_handlers.combination_handler import ( + CombinationHandler, +) +from inputremapper.injection.mapping_handlers.hierarchy_handler import HierarchyHandler +from inputremapper.injection.mapping_handlers.key_handler import KeyHandler +from inputremapper.injection.mapping_handlers.macro_handler import MacroHandler +from inputremapper.injection.mapping_handlers.mapping_handler import ( + MappingHandler, +) +from inputremapper.injection.mapping_handlers.rel_to_abs_handler import RelToAbsHandler +from inputremapper.injection.mapping_handlers.rel_to_btn_handler import RelToBtnHandler +from inputremapper.injection.mapping_handlers.rel_to_rel_handler import RelToRelHandler +from inputremapper.input_event import InputEvent, EventActions +from tests.lib.cleanup import cleanup +from tests.lib.fixtures import fixtures +from tests.lib.patches import InputDevice +from tests.lib.test_setup import test_setup + + +class BaseTests: + """implements test that should pass on most mapping handlers + in special cases override specific tests. + """ + + handler: MappingHandler + + def setUp(self): + raise NotImplementedError + + def tearDown(self) -> None: + cleanup() + + def test_reset(self): + mock = MagicMock() + self.handler.set_sub_handler(mock) + self.handler.reset() + mock.reset.assert_called() + + +@test_setup +class TestAxisSwitchHandler(BaseTests, unittest.IsolatedAsyncioTestCase): + def setUp(self): + input_combination = InputCombination( + ( + InputConfig(type=2, code=5), + InputConfig(type=1, code=3), + ) + ) + self.global_uinputs = GlobalUInputs(UInput) + self.global_uinputs.prepare_all() + self.handler = AxisSwitchHandler( + input_combination, + Mapping( + input_combination=input_combination.to_config(), + target_uinput="mouse", + output_type=2, + output_code=1, + ), + MagicMock(), + self.global_uinputs, + ) + + +@test_setup +class TestAbsToBtnHandler(BaseTests, unittest.IsolatedAsyncioTestCase): + def setUp(self): + input_combination = InputCombination( + [InputConfig(type=3, code=5, analog_threshold=10)] + ) + self.global_uinputs = GlobalUInputs(UInput) + self.global_uinputs.prepare_all() + self.handler = AbsToBtnHandler( + input_combination, + Mapping( + input_combination=input_combination.to_config(), + target_uinput="mouse", + output_symbol="BTN_LEFT", + ), + global_uinputs=self.global_uinputs, + ) + + +@test_setup +class TestAbsToAbsHandler(BaseTests, unittest.IsolatedAsyncioTestCase): + def setUp(self): + input_combination = InputCombination([InputConfig(type=EV_ABS, code=ABS_X)]) + self.global_uinputs = GlobalUInputs(UInput) + self.global_uinputs.prepare_all() + self.handler = AbsToAbsHandler( + input_combination, + Mapping( + input_combination=input_combination.to_config(), + target_uinput="gamepad", + output_type=EV_ABS, + output_code=ABS_X, + ), + global_uinputs=self.global_uinputs, + ) + + async def test_reset(self): + self.handler.notify( + InputEvent(0, 0, EV_ABS, ABS_X, fixtures.foo_device_2_gamepad.max_abs), + source=InputDevice("/dev/input/event15"), + ) + self.handler.reset() + history = self.global_uinputs.get_uinput("gamepad").write_history + self.assertEqual( + history, + [ + InputEvent.from_tuple((3, 0, fixtures.foo_device_2_gamepad.max_abs)), + InputEvent.from_tuple((3, 0, 0)), + ], + ) + + +@test_setup +class TestRelToAbsHandler(BaseTests, unittest.IsolatedAsyncioTestCase): + def setUp(self): + input_combination = InputCombination([InputConfig(type=EV_REL, code=REL_X)]) + self.global_uinputs = GlobalUInputs(UInput) + self.global_uinputs.prepare_all() + self.handler = RelToAbsHandler( + input_combination, + Mapping( + input_combination=input_combination.to_config(), + target_uinput="gamepad", + output_type=EV_ABS, + output_code=ABS_X, + ), + self.global_uinputs, + ) + + async def test_reset(self): + self.handler.notify( + InputEvent(0, 0, EV_REL, REL_X, 123), + source=InputDevice("/dev/input/event15"), + ) + self.handler.reset() + history = self.global_uinputs.get_uinput("gamepad").write_history + self.assertEqual(len(history), 2) + + # something large, doesn't matter + self.assertGreater(history[0].value, fixtures.foo_device_2_gamepad.max_abs / 10) + + # 0, because of the reset + self.assertEqual(history[1].value, 0) + + async def test_rate_changes(self): + expected_rate = 100 + + # delta in usec + delta = 1000000 / expected_rate + + self.handler.notify( + InputEvent(0, delta, EV_REL, REL_X, 100), + source=InputDevice("/dev/input/event15"), + ) + + self.handler.notify( + InputEvent(0, delta * 2, EV_REL, REL_X, 100), + source=InputDevice("/dev/input/event15"), + ) + + self.assertEqual(self.handler._observed_rate, expected_rate) + + async def test_rate_stays(self): + # if two timestamps are equal, the rate stays at its previous value, + # in this case the default + + self.handler.notify( + InputEvent(0, 50, EV_REL, REL_X, 100), + source=InputDevice("/dev/input/event15"), + ) + + self.handler.notify( + InputEvent(0, 50, EV_REL, REL_X, 100), + source=InputDevice("/dev/input/event15"), + ) + + self.assertEqual(self.handler._observed_rate, DEFAULT_REL_RATE) + + +@test_setup +class TestAbsToRelHandler(BaseTests, unittest.IsolatedAsyncioTestCase): + def setUp(self): + input_combination = InputCombination([InputConfig(type=EV_ABS, code=ABS_X)]) + self.global_uinputs = GlobalUInputs(UInput) + self.global_uinputs.prepare_all() + self.handler = AbsToRelHandler( + input_combination, + Mapping( + input_combination=input_combination.to_config(), + target_uinput="mouse", + output_type=EV_REL, + output_code=REL_X, + ), + self.global_uinputs, + ) + + async def test_reset(self): + self.handler.notify( + InputEvent(0, 0, EV_ABS, ABS_X, fixtures.foo_device_2_gamepad.max_abs), + source=InputDevice("/dev/input/event15"), + ) + await asyncio.sleep(0.2) + self.handler.reset() + await asyncio.sleep(0.05) + + count = self.global_uinputs.get_uinput("mouse").write_count + self.assertGreater(count, 6) # count should be 60*0.2 = 12 + await asyncio.sleep(0.2) + self.assertEqual(count, self.global_uinputs.get_uinput("mouse").write_count) + + +@test_setup +class TestCombinationHandler(BaseTests, unittest.IsolatedAsyncioTestCase): + handler: CombinationHandler + + def setUp(self): + mouse = fixtures.foo_device_2_mouse + self.mouse_hash = mouse.get_device_hash() + + keyboard = fixtures.foo_device_2_keyboard + self.keyboard_hash = keyboard.get_device_hash() + + gamepad = fixtures.gamepad + self.gamepad_hash = gamepad.get_device_hash() + + input_combination = InputCombination( + ( + InputConfig( + type=EV_REL, + code=5, + analog_threshold=10, + origin_hash=self.mouse_hash, + ), + InputConfig( + type=EV_KEY, + code=3, + origin_hash=self.keyboard_hash, + ), + InputConfig( + type=EV_KEY, + code=4, + origin_hash=self.gamepad_hash, + ), + ) + ) + + self.input_combination = input_combination + + self.context_mock = MagicMock() + + self.global_uinputs = GlobalUInputs(UInput) + self.global_uinputs.prepare_all() + self.handler = CombinationHandler( + input_combination, + Mapping( + input_combination=input_combination.to_config(), + target_uinput="mouse", + output_symbol="BTN_LEFT", + release_combination_keys=True, + ), + self.context_mock, + global_uinputs=self.global_uinputs, + ) + + sub_handler_mock = MagicMock(MappingHandler) + self.handler.set_sub_handler(sub_handler_mock) + + # insert our own test-uinput to see what is being written to it + self.uinputs = { + self.mouse_hash: evdev.UInput(), + self.keyboard_hash: evdev.UInput(), + self.gamepad_hash: evdev.UInput(), + } + self.context_mock.get_forward_uinput = lambda origin_hash: self.uinputs[ + origin_hash + ] + + def test_forward_correctly(self): + # In the past, if a mapping has inputs from two different sub devices, it + # always failed to send the release events to the correct one. + # Nowadays, self._context.get_forward_uinput(origin_hash) is used to + # release them correctly. + # 1. trigger the combination + self.handler.notify( + InputEvent.rel( + code=self.input_combination[0].code, + value=1, + origin_hash=self.input_combination[0].origin_hash, + ), + source=fixtures.foo_device_2_mouse, + ) + self.handler.notify( + InputEvent.key( + code=self.input_combination[1].code, + value=1, + origin_hash=self.input_combination[1].origin_hash, + ), + source=fixtures.foo_device_2_keyboard, + ) + self.handler.notify( + InputEvent.key( + code=self.input_combination[2].code, + value=1, + origin_hash=self.input_combination[2].origin_hash, + ), + source=fixtures.gamepad, + ) + + # 2. expect release events to be written to the correct devices, as indicated + # by the origin_hash of the InputConfigs + self.assertListEqual( + self.uinputs[self.mouse_hash].write_history, + [InputEvent.rel(self.input_combination[0].code, 0)], + ) + self.assertListEqual( + self.uinputs[self.keyboard_hash].write_history, + [InputEvent.key(self.input_combination[1].code, 0)], + ) + + # We do not expect a release event for this, because there was no key-down + # event when the combination triggered. + # self.assertListEqual( + # self.uinputs[self.gamepad_hash].write_history, + # [InputEvent.key(self.input_combination[2].code, 0)], + # ) + + def test_no_forwards(self): + # if a combination is not triggered, nothing is released + # 1. inject any two events + self.handler.notify( + InputEvent.rel( + code=self.input_combination[0].code, + value=1, + origin_hash=self.input_combination[0].origin_hash, + ), + source=fixtures.foo_device_2_mouse, + ) + self.handler.notify( + InputEvent.key( + code=self.input_combination[1].code, + value=1, + origin_hash=self.input_combination[1].origin_hash, + ), + source=fixtures.foo_device_2_keyboard, + ) + + # 2. expect no release events to be written + self.assertListEqual(self.uinputs[self.mouse_hash].write_history, []) + self.assertListEqual(self.uinputs[self.keyboard_hash].write_history, []) + + +@test_setup +class TestHierarchyHandler(BaseTests, unittest.IsolatedAsyncioTestCase): + def setUp(self): + self.mock1 = MagicMock() + self.mock2 = MagicMock() + self.mock3 = MagicMock() + self.global_uinputs = GlobalUInputs(UInput) + self.global_uinputs.prepare_all() + self.handler = HierarchyHandler( + [self.mock1, self.mock2, self.mock3], + InputConfig(type=EV_KEY, code=KEY_A), + self.global_uinputs, + ) + + def test_reset(self): + self.handler.reset() + self.mock1.reset.assert_called() + self.mock2.reset.assert_called() + self.mock3.reset.assert_called() + + +@test_setup +class TestKeyHandler(BaseTests, unittest.IsolatedAsyncioTestCase): + def setUp(self): + input_combination = InputCombination( + ( + InputConfig(type=2, code=0, analog_threshold=10), + InputConfig(type=1, code=3), + ) + ) + self.global_uinputs = GlobalUInputs(UInput) + self.global_uinputs.prepare_all() + self.handler = KeyHandler( + input_combination, + Mapping( + input_combination=input_combination.to_config(), + target_uinput="mouse", + output_symbol="BTN_LEFT", + ), + self.global_uinputs, + ) + + def test_reset(self): + self.handler.notify( + InputEvent(0, 0, EV_REL, REL_X, 1, actions=(EventActions.as_key,)), + source=InputDevice("/dev/input/event11"), + ) + history = self.global_uinputs.get_uinput("mouse").write_history + self.assertEqual(history[0], InputEvent.key(BTN_LEFT, 1)) + self.assertEqual(len(history), 1) + + self.handler.reset() + history = self.global_uinputs.get_uinput("mouse").write_history + self.assertEqual(history[1], InputEvent.key(BTN_LEFT, 0)) + self.assertEqual(len(history), 2) + + +@test_setup +class TestMacroHandler(BaseTests, unittest.IsolatedAsyncioTestCase): + def setUp(self): + self.input_combination = InputCombination( + ( + InputConfig(type=2, code=0, analog_threshold=10), + InputConfig(type=1, code=3), + ) + ) + self.context_mock = MagicMock() + self.global_uinputs = GlobalUInputs(UInput) + self.global_uinputs.prepare_all() + self.set_handler(KnownUinput.KEYBOARD, "key(a)") + + def set_handler(self, target_uinput: KnownUinput, macro: str): + self.handler = MacroHandler( + self.input_combination, + Mapping( + input_combination=self.input_combination.to_config(), + target_uinput=target_uinput, + output_symbol=macro, + ), + context=self.context_mock, + global_uinputs=self.global_uinputs, + ) + + async def test_reset(self): + self.set_handler(KnownUinput.MOUSE, "hold_keys(BTN_LEFT, BTN_RIGHT)") + + self.handler.notify( + InputEvent(0, 0, EV_REL, REL_X, 1, actions=(EventActions.as_key,)), + source=InputDevice("/dev/input/event11"), + ) + + await asyncio.sleep(0.1) + history = self.global_uinputs.get_uinput(KnownUinput.MOUSE).write_history + self.assertIn(InputEvent.key(BTN_LEFT, 1), history) + self.assertIn(InputEvent.key(BTN_RIGHT, 1), history) + self.assertEqual(len(history), 2) + + self.handler.reset() + await asyncio.sleep(0.1) + history = self.global_uinputs.get_uinput(KnownUinput.MOUSE).write_history + self.assertIn(InputEvent.key(BTN_LEFT, 0), history[-2:]) + self.assertIn(InputEvent.key(BTN_RIGHT, 0), history[-2:]) + self.assertEqual(len(history), 4) + + async def test_reset_output(self): + self.set_handler(KnownUinput.KEYBOARD, "key_down(a)") + + history = self.global_uinputs.get_uinput(KnownUinput.KEYBOARD).write_history + + self.handler.reset() + await asyncio.sleep(0.1) + self.assertEqual(len(history), 0) + + self.handler.notify( + InputEvent(0, 0, EV_REL, REL_X, 1, actions=(EventActions.as_key,)), + source=InputDevice("/dev/input/event11"), + ) + await asyncio.sleep(0.1) + self.assertEqual(len(history), 1) + self.assertIn(InputEvent.key(KEY_A, 1), history) + + self.handler.reset() + await asyncio.sleep(0.1) + self.assertEqual(len(history), 2) + self.assertIn(InputEvent.key(KEY_A, 0), history) + + +@test_setup +class TestRelToBtnHandler(BaseTests, unittest.IsolatedAsyncioTestCase): + def setUp(self): + input_combination = InputCombination( + [InputConfig(type=2, code=0, analog_threshold=10)] + ) + self.global_uinputs = GlobalUInputs(UInput) + self.global_uinputs.prepare_all() + self.handler = RelToBtnHandler( + input_combination, + Mapping( + input_combination=input_combination.to_config(), + target_uinput="mouse", + output_symbol="BTN_LEFT", + ), + self.global_uinputs, + ) + + +@test_setup +class TestRelToRelHanlder(BaseTests, unittest.IsolatedAsyncioTestCase): + handler: RelToRelHandler + + def setUp(self): + input_combination = InputCombination([InputConfig(type=EV_REL, code=REL_X)]) + self.global_uinputs = GlobalUInputs(UInput) + self.global_uinputs.prepare_all() + self.handler = RelToRelHandler( + input_combination, + Mapping( + input_combination=input_combination.to_config(), + output_type=EV_REL, + output_code=REL_Y, + output_value=20, + target_uinput="mouse", + ), + self.global_uinputs, + ) + + def test_should_map(self): + self.assertTrue( + self.handler._should_map( + InputEvent( + 0, + 0, + EV_REL, + REL_X, + 0, + ) + ) + ) + self.assertFalse( + self.handler._should_map( + InputEvent( + 0, + 0, + EV_REL, + REL_WHEEL, + 1, + ) + ) + ) + + def test_reset(self): + # nothing special has to happen here + pass diff --git a/tests/unit/test_event_pipeline/test_rel_to_abs.py b/tests/unit/test_event_pipeline/test_rel_to_abs.py new file mode 100644 index 0000000..cffe0af --- /dev/null +++ b/tests/unit/test_event_pipeline/test_rel_to_abs.py @@ -0,0 +1,239 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + +import asyncio +import unittest + +from evdev.ecodes import ( + EV_KEY, + EV_ABS, + EV_REL, + ABS_X, + ABS_Y, + REL_X, + REL_Y, + KEY_A, +) + +from inputremapper.configs.mapping import ( + Mapping, + REL_XY_SCALING, + DEFAULT_REL_RATE, +) +from inputremapper.configs.preset import Preset +from inputremapper.configs.input_config import InputCombination, InputConfig +from inputremapper.input_event import InputEvent +from tests.lib.fixtures import fixtures +from tests.lib.test_setup import test_setup +from tests.unit.test_event_pipeline.event_pipeline_test_base import ( + EventPipelineTestBase, +) + + +@test_setup +class TestRelToAbs(EventPipelineTestBase): + def setUp(self): + self.timestamp = 0 + super().setUp() + + def next_usec_time(self): + self.timestamp += 1000000 / DEFAULT_REL_RATE + return self.timestamp + + async def test_rel_to_abs(self): + # first mapping + # left mouse x to abs x + gain = 0.5 + cutoff = 2 + input_combination = InputCombination([InputConfig(type=EV_REL, code=REL_X)]) + mapping_config = { + "input_combination": input_combination.to_config(), + "target_uinput": "gamepad", + "output_type": EV_ABS, + "output_code": ABS_X, + "gain": gain, + "rel_to_abs_input_cutoff": cutoff, + "release_timeout": 0.5, + "deadzone": 0, + } + mapping_1 = Mapping(**mapping_config) + preset = Preset() + preset.add(mapping_1) + + # second mapping + input_combination = InputCombination([InputConfig(type=EV_REL, code=REL_Y)]) + mapping_config["input_combination"] = input_combination.to_config() + mapping_config["output_code"] = ABS_Y + mapping_2 = Mapping(**mapping_config) + preset.add(mapping_2) + + event_reader = self.create_event_reader(preset, fixtures.gamepad) + + next_time = self.next_usec_time() + await self.send_events( + [ + InputEvent(0, next_time, EV_REL, REL_X, -int(REL_XY_SCALING * cutoff)), + InputEvent(0, next_time, EV_REL, REL_Y, int(REL_XY_SCALING * cutoff)), + ], + event_reader, + ) + + await asyncio.sleep(0.1) + + history = self.global_uinputs.get_uinput("gamepad").write_history + self.assertEqual( + history, + [ + InputEvent.from_tuple((3, 0, fixtures.gamepad.min_abs / 2)), + InputEvent.from_tuple((3, 1, fixtures.gamepad.max_abs / 2)), + ], + ) + + # send more events, then wait until the release timeout + next_time = self.next_usec_time() + await self.send_events( + [ + InputEvent(0, next_time, EV_REL, REL_X, -int(REL_XY_SCALING)), + InputEvent(0, next_time, EV_REL, REL_Y, int(REL_XY_SCALING)), + ], + event_reader, + ) + await asyncio.sleep(0.7) + history = self.global_uinputs.get_uinput("gamepad").write_history + self.assertEqual( + history, + [ + InputEvent.from_tuple((3, 0, fixtures.gamepad.min_abs / 2)), + InputEvent.from_tuple((3, 1, fixtures.gamepad.max_abs / 2)), + InputEvent.from_tuple((3, 0, fixtures.gamepad.min_abs / 4)), + InputEvent.from_tuple((3, 1, fixtures.gamepad.max_abs / 4)), + InputEvent.from_tuple((3, 0, 0)), + InputEvent.from_tuple((3, 1, 0)), + ], + ) + + async def test_rel_to_abs_reset_multiple(self): + # Recenters correctly when triggering the mapping a second time. + # Could only be reproduced if a key input is part of the combination, that is + # released and pressed again. + + # left mouse x to abs x + gain = 0.5 + cutoff = 2 + input_combination = InputCombination( + [ + InputConfig(type=EV_KEY, code=KEY_A), + InputConfig(type=EV_REL, code=REL_X), + ] + ) + mapping_config = { + "input_combination": input_combination.to_config(), + "target_uinput": "gamepad", + "output_type": EV_ABS, + "output_code": ABS_X, + "gain": gain, + "rel_to_abs_input_cutoff": cutoff, + "release_timeout": 0.1, + "deadzone": 0, + } + mapping_1 = Mapping(**mapping_config) + preset = Preset() + preset.add(mapping_1) + + event_reader = self.create_event_reader(preset, fixtures.gamepad) + + for _ in range(3): + next_time = self.next_usec_time() + value = int(REL_XY_SCALING * cutoff) + await event_reader.handle(InputEvent(0, next_time, EV_KEY, KEY_A, 1)) + await event_reader.handle(InputEvent(0, next_time, EV_REL, REL_X, value)) + await asyncio.sleep(0.2) + + history = self.global_uinputs.get_uinput("gamepad").write_history + self.assertIn( + InputEvent.from_tuple((3, 0, 0)), + history, + ) + + await event_reader.handle(InputEvent(0, next_time, EV_KEY, KEY_A, 0)) + await asyncio.sleep(0.05) + + self.global_uinputs.get_uinput("gamepad").write_history = [] + + async def test_rel_to_abs_with_input_switch(self): + # use 0 everywhere, because that will cause the handler to not update the rate, + # and we are able to test things without worrying about that at all + timestamp = 0 + + gain = 0.5 + cutoff = 1 + input_combination = InputCombination( + ( + InputConfig(type=EV_REL, code=REL_X), + InputConfig(type=EV_REL, code=REL_Y, analog_threshold=10), + ) + ) + # left mouse x to x + mapping_config = { + "input_combination": input_combination.to_config(), + "target_uinput": "gamepad", + "output_type": EV_ABS, + "output_code": ABS_X, + "gain": gain, + "rel_to_abs_input_cutoff": cutoff, + "deadzone": 0, + } + mapping_1 = Mapping(**mapping_config) + preset = Preset() + preset.add(mapping_1) + + event_reader = self.create_event_reader(preset, fixtures.gamepad) + + # if the cutoff is higher, the test sends higher values to overcome the cutoff + await self.send_events( + [ + # will not map + InputEvent(0, timestamp, EV_REL, REL_X, -REL_XY_SCALING / 4 * cutoff), + # switch axis on + InputEvent(0, timestamp, EV_REL, REL_Y, REL_XY_SCALING / 5 * cutoff), + # normally mapped + InputEvent(0, timestamp, EV_REL, REL_X, REL_XY_SCALING * cutoff), + # off, re-centers axis + InputEvent(0, timestamp, EV_REL, REL_Y, REL_XY_SCALING / 20 * cutoff), + # will not map + InputEvent(0, timestamp, EV_REL, REL_X, REL_XY_SCALING / 2 * cutoff), + ], + event_reader, + ) + + await asyncio.sleep(0.2) + + history = self.global_uinputs.get_uinput("gamepad").write_history + self.assertEqual( + history, + [ + InputEvent.from_tuple((3, 0, fixtures.gamepad.max_abs / 2)), + InputEvent.from_tuple((3, 0, 0)), + ], + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/test_event_pipeline/test_rel_to_btn.py b/tests/unit/test_event_pipeline/test_rel_to_btn.py new file mode 100644 index 0000000..0d7aa85 --- /dev/null +++ b/tests/unit/test_event_pipeline/test_rel_to_btn.py @@ -0,0 +1,180 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + +import asyncio +import unittest + +from evdev.ecodes import ( + EV_KEY, + EV_REL, + REL_X, + REL_HWHEEL, + REL_WHEEL, +) + +from inputremapper.configs.mapping import ( + Mapping, +) +from inputremapper.configs.preset import Preset +from inputremapper.configs.keyboard_layout import keyboard_layout +from inputremapper.configs.input_config import InputCombination, InputConfig +from inputremapper.input_event import InputEvent +from tests.lib.fixtures import fixtures +from tests.lib.test_setup import test_setup +from tests.unit.test_event_pipeline.event_pipeline_test_base import ( + EventPipelineTestBase, +) + + +@test_setup +class TestRelToBtn(EventPipelineTestBase): + async def test_rel_to_btn(self): + """Rel axis mapped to buttons are automatically released if no new rel event arrives.""" + + # map those two to stuff + w_up = (EV_REL, REL_WHEEL, -1) + hw_right = (EV_REL, REL_HWHEEL, 1) + + # should be forwarded and present in the capabilities + hw_left = (EV_REL, REL_HWHEEL, -1) + + keyboard_layout.clear() + code_b = 91 + code_c = 92 + keyboard_layout._set("b", code_b) + keyboard_layout._set("c", code_c) + + # set a high release timeout to make sure the tests pass + release_timeout = 0.2 + mapping_1 = Mapping.from_combination( + InputCombination(InputCombination.from_tuples(hw_right)), "keyboard", "k(b)" + ) + mapping_2 = Mapping.from_combination( + InputCombination(InputCombination.from_tuples(w_up)), "keyboard", "c" + ) + mapping_1.release_timeout = release_timeout + mapping_2.release_timeout = release_timeout + + preset = Preset() + preset.add(mapping_1) + preset.add(mapping_2) + + event_reader = self.create_event_reader(preset, fixtures.foo_device_2_mouse) + + await self.send_events( + [InputEvent.from_tuple(hw_right), InputEvent.from_tuple(w_up)] * 5, + event_reader, + ) + # wait less than the release timeout and send more events + await asyncio.sleep(release_timeout / 5) + await self.send_events( + [InputEvent.from_tuple(hw_right), InputEvent.from_tuple(w_up)] * 5 + + [InputEvent.from_tuple(hw_left)] + * 3, # one event will release hw_right, the others are forwarded + event_reader, + ) + # wait more than the release_timeout to make sure all handlers finish + await asyncio.sleep(release_timeout * 1.2) + + keyboard_history = self.global_uinputs.get_uinput("keyboard").write_history + forwarded_history = self.forward_uinput.write_history + self.assertEqual(keyboard_history.count((EV_KEY, code_b, 1)), 1) + self.assertEqual(keyboard_history.count((EV_KEY, code_c, 1)), 1) + self.assertEqual(keyboard_history.count((EV_KEY, code_b, 0)), 1) + self.assertEqual(keyboard_history.count((EV_KEY, code_c, 0)), 1) + # the unmapped wheel direction + self.assertEqual(forwarded_history.count(hw_left), 2) + + # the unmapped wheel won't get a debounced release command, it's + # forwarded as is + self.assertNotIn((EV_REL, REL_HWHEEL, 0), forwarded_history) + + async def test_rel_trigger_threshold(self): + """Test that different activation points for rel_to_btn work correctly.""" + + # at 5 map to a + mapping_1 = Mapping.from_combination( + InputCombination( + [InputConfig(type=EV_REL, code=REL_X, analog_threshold=5)] + ), + output_symbol="a", + ) + # at 15 map to b + mapping_2 = Mapping.from_combination( + InputCombination( + [InputConfig(type=EV_REL, code=REL_X, analog_threshold=15)] + ), + output_symbol="b", + ) + release_timeout = 0.2 # give some time to do assertions before the release + mapping_1.release_timeout = release_timeout + mapping_2.release_timeout = release_timeout + preset = Preset() + preset.add(mapping_1) + preset.add(mapping_2) + + a = keyboard_layout.get("a") + b = keyboard_layout.get("b") + + event_reader = self.create_event_reader(preset, fixtures.foo_device_2_mouse) + + await self.send_events( + [ + InputEvent.rel(REL_X, -5), # forward + InputEvent.rel(REL_X, 0), # forward + InputEvent.rel(REL_X, 3), # forward + InputEvent.rel(REL_X, 10), # trigger a + ], + event_reader, + ) + await asyncio.sleep(release_timeout * 1.5) # release a + keyboard_history = self.global_uinputs.get_uinput("keyboard").write_history + + self.assertEqual(keyboard_history, [(EV_KEY, a, 1), (EV_KEY, a, 0)]) + self.assertEqual(keyboard_history.count((EV_KEY, a, 1)), 1) + self.assertEqual(keyboard_history.count((EV_KEY, a, 0)), 1) + self.assertNotIn((EV_KEY, b, 1), keyboard_history) + + await self.send_events( + [ + InputEvent.rel(REL_X, 10), # trigger a + InputEvent.rel(REL_X, 20), # trigger b + InputEvent.rel(REL_X, 10), # release b + ], + event_reader, + ) + keyboard_history = self.global_uinputs.get_uinput("keyboard").write_history + self.assertEqual(keyboard_history.count((EV_KEY, a, 1)), 2) + self.assertEqual(keyboard_history.count((EV_KEY, b, 1)), 1) + self.assertEqual(keyboard_history.count((EV_KEY, b, 0)), 1) + self.assertEqual(keyboard_history.count((EV_KEY, a, 0)), 1) + + await asyncio.sleep(release_timeout * 1.5) # release a + keyboard_history = self.global_uinputs.get_uinput("keyboard").write_history + forwarded_history = self.forward_uinput.write_history + self.assertEqual(keyboard_history.count((EV_KEY, a, 0)), 2) + self.assertEqual( + forwarded_history, + [(EV_REL, REL_X, -5), (EV_REL, REL_X, 0), (EV_REL, REL_X, 3)], + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/test_event_pipeline/test_rel_to_rel.py b/tests/unit/test_event_pipeline/test_rel_to_rel.py new file mode 100644 index 0000000..18cfb40 --- /dev/null +++ b/tests/unit/test_event_pipeline/test_rel_to_rel.py @@ -0,0 +1,219 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + +import unittest + +from evdev.ecodes import ( + EV_REL, + REL_X, + REL_Y, + REL_HWHEEL, + REL_WHEEL, + REL_WHEEL_HI_RES, + REL_HWHEEL_HI_RES, +) + +from inputremapper.configs.mapping import ( + Mapping, + REL_XY_SCALING, + WHEEL_SCALING, + WHEEL_HI_RES_SCALING, +) +from inputremapper.configs.preset import Preset +from inputremapper.configs.input_config import InputCombination, InputConfig +from inputremapper.input_event import InputEvent +from tests.lib.fixtures import fixtures +from tests.lib.test_setup import test_setup +from tests.unit.test_event_pipeline.event_pipeline_test_base import ( + EventPipelineTestBase, +) + + +@test_setup +class TestRelToRel(EventPipelineTestBase): + async def _test(self, input_code, input_value, output_code, output_value, gain=1): + preset = Preset() + + input_config = InputConfig(type=EV_REL, code=input_code) + mapping = Mapping( + input_combination=InputCombination([input_config]).to_config(), + target_uinput="mouse", + output_type=EV_REL, + output_code=output_code, + deadzone=0, + gain=gain, + ) + preset.add(mapping) + + event_reader = self.create_event_reader(preset, fixtures.gamepad) + + await self.send_events( + [InputEvent(0, 0, EV_REL, input_code, input_value)], + event_reader, + ) + + history = self.global_uinputs.get_uinput("mouse").write_history + + self.assertEqual(len(history), 1) + self.assertEqual( + history[0], + InputEvent(0, 0, EV_REL, output_code, output_value), + ) + + async def test_wheel_to_y(self): + await self._test( + input_code=REL_WHEEL, + input_value=2 * WHEEL_SCALING, + output_code=REL_Y, + output_value=2 * REL_XY_SCALING, + ) + + async def test_hi_res_wheel_to_y(self): + await self._test( + input_code=REL_WHEEL_HI_RES, + input_value=3 * WHEEL_HI_RES_SCALING, + output_code=REL_Y, + output_value=3 * REL_XY_SCALING, + ) + + async def test_x_to_hwheel(self): + # injects both hi_res and regular wheel events at the same time + + input_code = REL_X + input_value = 100 + output_code = REL_HWHEEL + gain = 2 + + output_value = int(input_value / REL_XY_SCALING * WHEEL_SCALING * gain) + output_value_hi_res = int( + input_value / REL_XY_SCALING * WHEEL_HI_RES_SCALING * gain + ) + + preset = Preset() + + input_config = InputConfig(type=EV_REL, code=input_code) + mapping = Mapping( + input_combination=InputCombination([input_config]).to_config(), + target_uinput="mouse", + output_type=EV_REL, + output_code=output_code, + deadzone=0, + gain=gain, + ) + preset.add(mapping) + + event_reader = self.create_event_reader(preset, fixtures.gamepad) + + await self.send_events( + [InputEvent(0, 0, EV_REL, input_code, input_value)], + event_reader, + ) + + history = self.global_uinputs.get_uinput("mouse").write_history + # injects both REL_WHEEL and REL_WHEEL_HI_RES events + self.assertEqual(len(history), 2) + self.assertEqual( + history[0], + InputEvent( + 0, + 0, + EV_REL, + REL_HWHEEL, + output_value, + ), + ) + + self.assertEqual( + history[1], + InputEvent( + 0, + 0, + EV_REL, + REL_HWHEEL_HI_RES, + output_value_hi_res, + ), + ) + + async def test_remainder(self): + preset = Preset() + history = self.global_uinputs.get_uinput("mouse").write_history + + # REL_WHEEL_HI_RES to REL_Y + input_config = InputConfig(type=EV_REL, code=REL_WHEEL_HI_RES) + gain = 0.01 + mapping = Mapping( + input_combination=InputCombination([input_config]).to_config(), + target_uinput="mouse", + output_type=EV_REL, + output_code=REL_Y, + deadzone=0, + gain=gain, + ) + preset.add(mapping) + + event_reader = self.create_event_reader(preset, fixtures.gamepad) + + events_until_one_rel_y_written = int( + WHEEL_HI_RES_SCALING / REL_XY_SCALING / gain + ) + # due to the low gain and low input value, it needs to be sent many times + # until one REL_Y event is written + await self.send_events( + [InputEvent(0, 0, EV_REL, REL_WHEEL_HI_RES, 1)] + * (events_until_one_rel_y_written - 1), + event_reader, + ) + self.assertEqual(len(history), 0) + + # write the final event that causes the input to accumulate to 1 + # plus one extra event because of floating-point math + await self.send_events( + [InputEvent(0, 0, EV_REL, REL_WHEEL_HI_RES, 1)], + event_reader, + ) + self.assertEqual(len(history), 1) + self.assertEqual( + history[0], + InputEvent(0, 0, EV_REL, REL_Y, 1), + ) + + # repeat it one more time to see if the remainder is reset correctly + await self.send_events( + [InputEvent(0, 0, EV_REL, REL_WHEEL_HI_RES, 1)] + * (events_until_one_rel_y_written - 1), + event_reader, + ) + self.assertEqual(len(history), 1) + + # the event that causes the second REL_Y to be written + # this should never need the one extra if the remainder is reset correctly + await self.send_events( + [InputEvent(0, 0, EV_REL, REL_WHEEL_HI_RES, 1)], + event_reader, + ) + self.assertEqual(len(history), 2) + self.assertEqual( + history[1], + InputEvent(0, 0, EV_REL, REL_Y, 1), + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/test_event_reader.py b/tests/unit/test_event_reader.py new file mode 100644 index 0000000..16c575c --- /dev/null +++ b/tests/unit/test_event_reader.py @@ -0,0 +1,300 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + +import asyncio +import os +import unittest +from unittest.mock import MagicMock, patch + +import evdev +from evdev.ecodes import ( + EV_KEY, + EV_ABS, + BTN_A, + ABS_X, + ABS_Y, + ABS_RX, + ABS_RY, + EV_REL, + REL_X, + REL_Y, + REL_HWHEEL_HI_RES, + REL_WHEEL_HI_RES, +) + +from inputremapper.configs.input_config import InputCombination, InputConfig +from inputremapper.configs.keyboard_layout import keyboard_layout +from inputremapper.configs.mapping import Mapping +from inputremapper.configs.preset import Preset +from inputremapper.injection.context import Context +from inputremapper.injection.event_reader import EventReader +from inputremapper.injection.global_uinputs import GlobalUInputs, UInput +from inputremapper.injection.mapping_handlers.mapping_parser import MappingParser +from inputremapper.input_event import InputEvent +from inputremapper.utils import get_device_hash +from tests.lib.fixtures import fixtures +from tests.lib.test_setup import test_setup + + +@test_setup +class TestEventReader(unittest.IsolatedAsyncioTestCase): + def setUp(self): + self.gamepad_source = evdev.InputDevice(fixtures.gamepad.path) + self.source_hash = fixtures.gamepad.get_device_hash() + self.global_uinputs = GlobalUInputs(UInput) + self.mapping_parser = MappingParser(self.global_uinputs) + self.stop_event = asyncio.Event() + self.preset = Preset() + self.forward_uinput = evdev.UInput(name="test-forward-uinput") + + self.global_uinputs.is_service = True + self.global_uinputs.prepare_all() + + async def setup(self, mapping): + """Set a EventReader up for the test and run it in the background.""" + context = Context( + mapping, + {}, + {self.source_hash: self.forward_uinput}, + self.mapping_parser, + ) + context.uinput = evdev.UInput() + event_reader = EventReader( + context, + self.gamepad_source, + self.stop_event, + ) + asyncio.ensure_future(event_reader.run()) + await asyncio.sleep(0.1) + return context, event_reader + + async def test_if_single_joystick_then(self): + # TODO: Move this somewhere more sensible + # Integration test style for if_single. + # won't care about the event, because the purpose is not set to BUTTON + code_a = keyboard_layout.get("a") + code_shift = keyboard_layout.get("KEY_LEFTSHIFT") + trigger = evdev.ecodes.BTN_A + + self.preset.add( + Mapping.from_combination( + InputCombination( + [ + InputConfig( + type=EV_KEY, + code=trigger, + origin_hash=fixtures.gamepad.get_device_hash(), + ) + ] + ), + "keyboard", + "if_single(key(a), key(KEY_LEFTSHIFT))", + ) + ) + self.preset.add( + Mapping.from_combination( + InputCombination( + [ + InputConfig( + type=EV_ABS, + code=ABS_Y, + analog_threshold=1, + origin_hash=fixtures.gamepad.get_device_hash(), + ) + ] + ), + "keyboard", + "b", + ), + ) + + # left x to mouse x + config = { + "input_combination": [ + InputConfig( + type=EV_ABS, + code=ABS_X, + origin_hash=fixtures.gamepad.get_device_hash(), + ) + ], + "target_uinput": "mouse", + "output_type": EV_REL, + "output_code": REL_X, + } + self.preset.add(Mapping(**config)) + + # left y to mouse y + config["input_combination"] = [ + InputConfig( + type=EV_ABS, + code=ABS_Y, + origin_hash=fixtures.gamepad.get_device_hash(), + ) + ] + config["output_code"] = REL_Y + self.preset.add(Mapping(**config)) + + # right x to wheel x + config["input_combination"] = [ + InputConfig( + type=EV_ABS, + code=ABS_RX, + origin_hash=fixtures.gamepad.get_device_hash(), + ) + ] + config["output_code"] = REL_HWHEEL_HI_RES + self.preset.add(Mapping(**config)) + + # right y to wheel y + config["input_combination"] = [ + InputConfig( + type=EV_ABS, + code=ABS_RY, + origin_hash=fixtures.gamepad.get_device_hash(), + ) + ] + config["output_code"] = REL_WHEEL_HI_RES + self.preset.add(Mapping(**config)) + + context, _ = await self.setup(self.preset) + + gamepad_hash = get_device_hash(self.gamepad_source) + self.gamepad_source.push_events( + [ + InputEvent.key(evdev.ecodes.BTN_Y, 0, gamepad_hash), # start the macro + InputEvent.key(trigger, 1, gamepad_hash), # start the macro + InputEvent.abs(ABS_Y, 10, gamepad_hash), # ignored + InputEvent.key(evdev.ecodes.BTN_B, 2, gamepad_hash), # ignored + InputEvent.key(evdev.ecodes.BTN_B, 0, gamepad_hash), # ignored + # release the trigger, which runs `then` of if_single + InputEvent.key(trigger, 0, gamepad_hash), + ] + ) + + await asyncio.sleep(0.1) + self.stop_event.set() # stop the reader + + history = self.global_uinputs.get_uinput("keyboard").write_history + self.assertIn((EV_KEY, code_a, 1), history) + self.assertIn((EV_KEY, code_a, 0), history) + self.assertNotIn((EV_KEY, code_shift, 1), history) + self.assertNotIn((EV_KEY, code_shift, 0), history) + + # after if_single takes an action, the listener should have been removed + self.assertSetEqual(context.listeners, set()) + + async def test_if_single_joystick_under_threshold(self): + """Triggers then because the joystick events value is too low.""" + # TODO: Move this somewhere more sensible + code_a = keyboard_layout.get("a") + trigger = evdev.ecodes.BTN_A + self.preset.add( + Mapping.from_combination( + InputCombination( + [ + InputConfig( + type=EV_KEY, + code=trigger, + origin_hash=fixtures.gamepad.get_device_hash(), + ) + ] + ), + "keyboard", + "if_single(k(a), k(KEY_LEFTSHIFT))", + ) + ) + self.preset.add( + Mapping.from_combination( + InputCombination( + [ + InputConfig( + type=EV_ABS, + code=ABS_Y, + analog_threshold=1, + origin_hash=fixtures.gamepad.get_device_hash(), + ) + ] + ), + "keyboard", + "b", + ), + ) + + # self.preset.set("gamepad.joystick.left_purpose", BUTTONS) + # self.preset.set("gamepad.joystick.right_purpose", BUTTONS) + context, _ = await self.setup(self.preset) + + self.gamepad_source.push_events( + [ + InputEvent.key(trigger, 1), # start the macro + InputEvent.abs(ABS_Y, 1), # ignored because value too low + InputEvent.key(trigger, 0), # stop, only way to trigger `then` + ] + ) + await asyncio.sleep(0.1) + self.assertEqual(len(context.listeners), 0) + history = self.global_uinputs.get_uinput("keyboard").write_history + + # the key that triggered if_single should be injected after + # if_single had a chance to inject keys (if the macro is fast enough), + # so that if_single can inject a modifier to e.g. capitalize the + # triggering key. This is important for the space cadet shift + self.assertListEqual( + history, + [ + (EV_KEY, code_a, 1), + (EV_KEY, code_a, 0), + ], + ) + + @patch.object(Context, "reset") + async def test_reset_handlers_on_stop(self, reset_mock: MagicMock) -> None: + await self.setup(self.preset) + + self.stop_event.set() + await asyncio.sleep(0.1) + + reset_mock.assert_called_once() + + @patch.object(Context, "reset") + @patch.object(os, "stat") + async def test_reset_handlers_after_unplugging( + self, + stat_mock: MagicMock, + reset_mock: MagicMock, + ) -> None: + await self.setup(self.preset) + + self.gamepad_source.push_events([InputEvent.key(BTN_A, 1)]) + await asyncio.sleep(0.1) + reset_mock.assert_not_called() + + # unplug the device + stat_mock().st_nlink = 0 + + # It seems that once a device is unplugged, asyncio stops waiting for new input + # from _source.fileno() or something. I didn't manage to replicate this + # behavior in tests using the fd of pending_events[fixtures.gamepad][0].fileno() + # and/or pending_events[fixtures.gamepad][1].fileno(). So instead, I push + # another event, so that the EventReader makes another iteration. + self.gamepad_source.push_events([InputEvent.key(BTN_A, 1)]) + await asyncio.sleep(0.1) + + reset_mock.assert_called_once() diff --git a/tests/unit/test_global_config.py b/tests/unit/test_global_config.py new file mode 100644 index 0000000..719c185 --- /dev/null +++ b/tests/unit/test_global_config.py @@ -0,0 +1,106 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + + +import os +import unittest + +from inputremapper.configs.global_config import GlobalConfig +from tests.lib.test_setup import test_setup + + +@test_setup +class TestGlobalConfig(unittest.TestCase): + def test_autoload(self): + global_config = GlobalConfig() + self.assertEqual(len(global_config.iterate_autoload_presets()), 0) + self.assertFalse(global_config.is_autoloaded("d1", "a")) + self.assertFalse(global_config.is_autoloaded("d2", "b")) + self.assertEqual(global_config.get_autoload_preset("d1"), None) + self.assertEqual(global_config.get_autoload_preset("d2"), None) + + global_config.set_autoload_preset("d1", "a") + self.assertEqual(len(global_config.iterate_autoload_presets()), 1) + self.assertTrue(global_config.is_autoloaded("d1", "a")) + self.assertFalse(global_config.is_autoloaded("d2", "b")) + + global_config.set_autoload_preset("d2", "b") + self.assertEqual(len(global_config.iterate_autoload_presets()), 2) + self.assertTrue(global_config.is_autoloaded("d1", "a")) + self.assertTrue(global_config.is_autoloaded("d2", "b")) + self.assertEqual(global_config.get_autoload_preset("d1"), "a") + self.assertEqual(global_config.get_autoload_preset("d2"), "b") + + global_config.set_autoload_preset("d2", "c") + self.assertEqual(len(global_config.iterate_autoload_presets()), 2) + self.assertTrue(global_config.is_autoloaded("d1", "a")) + self.assertFalse(global_config.is_autoloaded("d2", "b")) + self.assertTrue(global_config.is_autoloaded("d2", "c")) + self.assertEqual(global_config._config["autoload"]["d2"], "c") + self.assertListEqual( + list(global_config.iterate_autoload_presets()), + [("d1", "a"), ("d2", "c")], + ) + + global_config.set_autoload_preset("d2", None) + self.assertTrue(global_config.is_autoloaded("d1", "a")) + self.assertFalse(global_config.is_autoloaded("d2", "b")) + self.assertFalse(global_config.is_autoloaded("d2", "c")) + self.assertListEqual( + list(global_config.iterate_autoload_presets()), + [("d1", "a")], + ) + self.assertEqual(global_config.get_autoload_preset("d1"), "a") + + self.assertRaises(ValueError, global_config.is_autoloaded, "d1", None) + self.assertRaises(ValueError, global_config.is_autoloaded, None, "a") + + def test_initial(self): + global_config = GlobalConfig() + # when loading for the first time, create a config file with + # the default values + self.assertFalse(os.path.exists(global_config.path)) + global_config.load_config() + self.assertTrue(os.path.exists(global_config.path)) + + with open(global_config.path, "r") as file: + contents = file.read() + self.assertIn('"autoload": {}', contents) + + def test_save_load(self): + global_config = GlobalConfig() + self.assertEqual(len(global_config.iterate_autoload_presets()), 0) + + global_config.load_config() + self.assertEqual(len(global_config.iterate_autoload_presets()), 0) + + global_config.set_autoload_preset("d1", "a") + global_config.set_autoload_preset("d2", "b") + + global_config.load_config() + + self.assertListEqual( + list(global_config.iterate_autoload_presets()), + [("d1", "a"), ("d2", "b")], + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/test_global_uinputs.py b/tests/unit/test_global_uinputs.py new file mode 100644 index 0000000..8ecd800 --- /dev/null +++ b/tests/unit/test_global_uinputs.py @@ -0,0 +1,100 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + +import unittest + +import evdev +from evdev.ecodes import ( + KEY_A, + ABS_X, +) + +from inputremapper.exceptions import EventNotHandled, UinputNotAvailable +from inputremapper.injection.global_uinputs import ( + FrontendUInput, + GlobalUInputs, + UInput, +) +from inputremapper.input_event import InputEvent +from tests.lib.cleanup import cleanup +from tests.lib.test_setup import test_setup + + +@test_setup +class TestFrontendUinput(unittest.TestCase): + def setUp(self) -> None: + cleanup() + + def test_init(self): + name = "foo" + capabilities = {1: [1, 2, 3], 2: [4, 5, 6]} + uinput_defaults = FrontendUInput() + uinput_custom = FrontendUInput(name=name, events=capabilities) + + self.assertEqual(uinput_defaults.name, "py-evdev-uinput") + self.assertIsNone(uinput_defaults.capabilities()) + + self.assertEqual(uinput_custom.name, name) + self.assertEqual(uinput_custom.capabilities(), capabilities) + + +@test_setup +class TestGlobalUInputs(unittest.TestCase): + def setUp(self) -> None: + cleanup() + + def test_iter(self): + global_uinputs = GlobalUInputs(FrontendUInput) + for uinput in global_uinputs: + self.assertIsInstance(uinput, evdev.UInput) + + def test_write(self): + """Test write and write failure + + implicitly tests get_uinput and UInput.can_emit + """ + global_uinputs = GlobalUInputs(UInput) + global_uinputs.prepare_all() + + ev_1 = InputEvent.key(KEY_A, 1) + ev_2 = InputEvent.abs(ABS_X, 10) + + keyboard = global_uinputs.get_uinput("keyboard") + + global_uinputs.write(ev_1.event_tuple, "keyboard") + self.assertEqual(keyboard.write_count, 1) + + with self.assertRaises(EventNotHandled): + global_uinputs.write(ev_2.event_tuple, "keyboard") + + with self.assertRaises(UinputNotAvailable): + global_uinputs.write(ev_1.event_tuple, "foo") + + def test_creates_frontend_uinputs(self): + frontend_uinputs = GlobalUInputs(FrontendUInput) + frontend_uinputs.prepare_all() + uinput = frontend_uinputs.get_uinput("keyboard") + self.assertIsInstance(uinput, FrontendUInput) + + def test_creates_backend_service_uinputs(self): + frontend_uinputs = GlobalUInputs(UInput) + frontend_uinputs.prepare_all() + uinput = frontend_uinputs.get_uinput("keyboard") + self.assertIsInstance(uinput, UInput) diff --git a/tests/unit/test_groups.py b/tests/unit/test_groups.py new file mode 100644 index 0000000..01963ba --- /dev/null +++ b/tests/unit/test_groups.py @@ -0,0 +1,417 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + +import json +import os +import unittest + +import evdev + +from inputremapper.configs.paths import PathUtils +from inputremapper.groups import ( + _FindGroups, + groups, + classify, + DeviceType, + _Group, + is_inputremapper_device, +) +from tests.lib.fixtures import fixtures, keyboard_keys, Fixture +from tests.lib.test_setup import test_setup + + +class FakePipe: + groups = None + + def send(self, groups): + self.groups = groups + + +@test_setup +class TestGroups(unittest.TestCase): + def test_group(self): + group = _Group( + paths=["/dev/a", "/dev/b", "/dev/c"], + names=["name_bar", "name_a", "name_foo"], + types=[DeviceType.MOUSE, DeviceType.KEYBOARD, DeviceType.UNKNOWN], + key="key", + ) + self.assertEqual(group.name, "name_a") + self.assertEqual(group.key, "key") + self.assertEqual( + group.get_preset_path("preset1234"), + os.path.join( + PathUtils.config_path(), + "presets", + group.name, + "preset1234.json", + ), + ) + + def test_find_groups(self): + pipe = FakePipe() + _FindGroups(pipe).run() + self.assertIsInstance(pipe.groups, str) + + groups.loads(pipe.groups) + self.maxDiff = None + dump1 = groups.dumps() + dump2 = json.dumps( + [ + json.dumps( + { + "paths": [ + "/dev/input/event1", + ], + "names": ["Foo Device"], + "types": [DeviceType.KEYBOARD], + "key": "Foo Device", + } + ), + json.dumps( + { + "paths": [ + "/dev/input/event10", + "/dev/input/event11", + "/dev/input/event13", + "/dev/input/event15", + ], + "names": [ + "Foo Device", + "Foo Device foo", + "Foo Device", + "Foo Device bar", + ], + "types": [ + DeviceType.GAMEPAD, + DeviceType.KEYBOARD, + DeviceType.MOUSE, + ], + "key": "Foo Device 2", + } + ), + json.dumps( + { + "paths": ["/dev/input/event20"], + "names": ["Bar Device"], + "types": [DeviceType.KEYBOARD], + "key": "Bar Device", + } + ), + json.dumps( + { + "paths": [ + "/dev/input/event30", + "/dev/input/event32", + ], + "names": [ + "gamepad", + "gamepad abs 0 to 256", + ], + "types": [DeviceType.GAMEPAD], + "key": "gamepad", + } + ), + json.dumps( + { + "paths": ["/dev/input/event52"], + "names": ["Qux/[Device]?"], + "types": [DeviceType.KEYBOARD], + "key": "Qux/[Device]?", + } + ), + ] + ) + + self.assertEqual(dump1, dump2) + + groups2 = json.dumps([group.dumps() for group in groups.get_groups()]) + self.assertEqual(pipe.groups, groups2) + + def test_list_group_names(self): + self.assertListEqual( + groups.list_group_names(), + [ + "Foo Device", + "Foo Device", + "Bar Device", + "gamepad", + "Qux/[Device]?", + ], + ) + + def test_get_groups(self): + # by default no input-remapper devices are present + filtered = groups.get_groups() + keys = [group.key for group in filtered] + self.assertIn("Foo Device 2", keys) + self.assertNotIn("input-remapper Bar Device", keys) + + def test_skip_inputremapper_phys_devices(self): + fixtures.add_fixture( + { + "name": "Logitech G Pro", + "phys": "input-remapper/usb-0000:0f:00.3-4.2/input2:1", + "info": evdev.DeviceInfo(3, 0x046D, 0x4079, 0x0111), + "capabilities": { + evdev.ecodes.EV_KEY: [evdev.ecodes.BTN_LEFT], + evdev.ecodes.EV_REL: [ + evdev.ecodes.REL_X, + evdev.ecodes.REL_Y, + evdev.ecodes.REL_WHEEL, + ], + }, + "path": "/foo/bar", + } + ) + + groups.refresh() + self.assertIsNone(groups.find(path="/foo/bar")) + + def test_is_inputremapper_device(self): + device = evdev.InputDevice("/dev/input/event40") + self.assertTrue(is_inputremapper_device(device)) + + def test_skip_camera(self): + fixtures.add_fixture( + { + "name": "camera", + "phys": "abcd1", + "info": evdev.DeviceInfo(1, 2, 3, 4), + "capabilities": {evdev.ecodes.EV_KEY: [evdev.ecodes.KEY_CAMERA]}, + "path": "/foo/bar", + } + ) + + groups.refresh() + self.assertIsNone(groups.find(name="camera")) + self.assertIsNotNone(groups.find(name="gamepad")) + + def test_device_with_only_ev_abs(self): + # As Input Mapper can now map axes to buttons, + # a single EV_ABS device is valid for mapping. + fixtures.add_fixture( + { + "name": "qux", + "phys": "abcd2", + "info": evdev.DeviceInfo(1, 2, 3, 4), + "capabilities": {evdev.ecodes.EV_ABS: [evdev.ecodes.ABS_X]}, + "path": "/foo/bar", + } + ) + + groups.refresh() + self.assertIsNotNone(groups.find(name="gamepad")) + self.assertIsNotNone(groups.find(name="qux")) + + def test_device_with_no_capabilities(self): + fixtures.add_fixture( + { + "name": "nulcap", + "phys": "abcd3", + "info": evdev.DeviceInfo(1, 2, 3, 4), + "capabilities": {}, + "path": "/foo/bar", + } + ) + + groups.refresh() + self.assertIsNotNone(groups.find(name="gamepad")) + self.assertIsNone(groups.find(name="nulcap")) + + def test_duplicate_device(self): + fixtures.add_fixture( + { + "capabilities": {evdev.ecodes.EV_KEY: keyboard_keys}, + "phys": "usb-0000:03:00.0-3/input1", + "info": evdev.device.DeviceInfo(2, 1, 2, 1), + "name": "Foo Device", + "path": "/dev/input/event100", + } + ) + + groups.refresh() + group1 = groups.find(key="Foo Device") + group2 = groups.find(key="Foo Device 2") + group3 = groups.find(key="Foo Device 3") + self.assertIn("/dev/input/event1", group1.paths) + self.assertIn("/dev/input/event10", group2.paths) + self.assertIn("/dev/input/event100", group3.paths) + self.assertEqual(group1.key, "Foo Device") + self.assertEqual(group2.key, "Foo Device 2") + self.assertEqual(group3.key, "Foo Device 3") + self.assertEqual(group1.name, "Foo Device") + self.assertEqual(group2.name, "Foo Device") + self.assertEqual(group3.name, "Foo Device") + + # Bug reproduction: Unplugging a device and plugging it back in makes it appear + # earlier in the detected devices, giving it the first group, causing it to + # steal other devices injections on autoload. + + # Make sure the first fixture is earlier in the list than the third fixture. + # This is the starting situation, everything is still fine. + paths = fixtures.get_paths() + self.assertLess( + paths.index("/dev/input/event1"), + paths.index("/dev/input/event100"), + ) + + # Remove the first group, and add it back in + backup = fixtures.get_fixture("/dev/input/event1") + fixtures.remove_fixture("/dev/input/event1") + fixtures.add_fixture(backup) + # Now the order should be messed up (This is acceptable here, as long as the + # groups end up in the correct original order. This check only exists to make + # sure the test would still be able to reproduce the bug. The order being + # messed up is a prerequisite to reproduce the bug) + paths = fixtures.get_paths() + self.assertGreater( + paths.index("/dev/input/event1"), + paths.index("/dev/input/event100"), + ) + + # refreshing groups should still end up giving each device the same group as + # before. + groups.refresh() + group1 = groups.find(key="Foo Device") + group2 = groups.find(key="Foo Device 2") + group3 = groups.find(key="Foo Device 3") + self.assertIn("/dev/input/event1", group1.paths) + self.assertIn("/dev/input/event10", group2.paths) + self.assertIn("/dev/input/event100", group3.paths) + self.assertEqual(group1.key, "Foo Device") + self.assertEqual(group2.key, "Foo Device 2") + self.assertEqual(group3.key, "Foo Device 3") + self.assertEqual(group1.name, "Foo Device") + self.assertEqual(group2.name, "Foo Device") + self.assertEqual(group3.name, "Foo Device") + + def test_classify(self): + # properly detects if the device is a gamepad + EV_ABS = evdev.ecodes.EV_ABS + EV_KEY = evdev.ecodes.EV_KEY + EV_REL = evdev.ecodes.EV_REL + + class FakeDevice: + def __init__(self, capabilities): + self.c = capabilities + + def capabilities(self, absinfo): + assert not absinfo + return self.c + + """Gamepads""" + + self.assertEqual( + classify( + FakeDevice( + { + EV_ABS: [evdev.ecodes.ABS_X, evdev.ecodes.ABS_Y], + EV_KEY: [evdev.ecodes.BTN_A], + } + ) + ), + DeviceType.GAMEPAD, + ) + + """Mice""" + + self.assertEqual( + classify( + FakeDevice( + { + EV_REL: [ + evdev.ecodes.REL_X, + evdev.ecodes.REL_Y, + evdev.ecodes.REL_WHEEL, + ], + EV_KEY: [evdev.ecodes.BTN_LEFT], + } + ) + ), + DeviceType.MOUSE, + ) + + """Keyboard""" + + self.assertEqual( + classify(FakeDevice({EV_KEY: [evdev.ecodes.KEY_A]})), DeviceType.KEYBOARD + ) + + """Touchpads""" + + self.assertEqual( + classify( + FakeDevice( + { + EV_KEY: [evdev.ecodes.KEY_A], + EV_ABS: [evdev.ecodes.ABS_MT_POSITION_X], + } + ) + ), + DeviceType.TOUCHPAD, + ) + + """Graphics tablets""" + + self.assertEqual( + classify( + FakeDevice( + { + EV_ABS: [evdev.ecodes.ABS_X, evdev.ecodes.ABS_Y], + EV_KEY: [evdev.ecodes.BTN_STYLUS], + } + ) + ), + DeviceType.GRAPHICS_TABLET, + ) + + """Weird combos""" + + self.assertEqual( + classify( + FakeDevice( + { + EV_ABS: [evdev.ecodes.ABS_X, evdev.ecodes.ABS_Y], + EV_KEY: [evdev.ecodes.KEY_1], + } + ) + ), + DeviceType.UNKNOWN, + ) + + self.assertEqual( + classify( + FakeDevice({EV_ABS: [evdev.ecodes.ABS_X], EV_KEY: [evdev.ecodes.BTN_A]}) + ), + DeviceType.UNKNOWN, + ) + + self.assertEqual( + classify(FakeDevice({EV_KEY: [evdev.ecodes.BTN_A]})), DeviceType.UNKNOWN + ) + + self.assertEqual( + classify(FakeDevice({EV_ABS: [evdev.ecodes.ABS_X]})), DeviceType.UNKNOWN + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/test_injector.py b/tests/unit/test_injector.py new file mode 100644 index 0000000..af41b35 --- /dev/null +++ b/tests/unit/test_injector.py @@ -0,0 +1,711 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . +from inputremapper.injection.global_uinputs import GlobalUInputs, UInput +from inputremapper.injection.macros.parse import Parser +from inputremapper.injection.mapping_handlers.mapping_parser import MappingParser + +try: + from pydantic.v1 import ValidationError +except ImportError: + from pydantic import ValidationError + +import time +import unittest +from unittest import mock + +import evdev +from evdev.ecodes import ( + EV_REL, + EV_KEY, + EV_ABS, + ABS_HAT0X, + KEY_A, + REL_HWHEEL, + BTN_A, + ABS_X, + ABS_VOLUME, +) + +from inputremapper.configs.input_config import InputCombination, InputConfig +from inputremapper.configs.mapping import Mapping +from inputremapper.configs.preset import Preset +from inputremapper.configs.keyboard_layout import ( + keyboard_layout, + DISABLE_CODE, + DISABLE_NAME, +) +from inputremapper.groups import groups, classify, DeviceType +from inputremapper.injection.context import Context +from inputremapper.injection.injector import ( + Injector, + is_in_capabilities, + InjectorState, + get_udev_name, + get_forward_name, + get_forward_phys, +) +from inputremapper.injection.numlock import is_numlock_on +from inputremapper.input_event import InputEvent + +from tests.lib.constants import EVENT_READ_TIMEOUT +from tests.lib.fixtures import fixtures +from tests.lib.fixtures import keyboard_keys +from tests.lib.patches import uinputs +from tests.lib.pipes import read_write_history_pipe, push_events +from tests.lib.pipes import uinput_write_history_pipe +from tests.lib.test_setup import test_setup + + +def wait_for_uinput_write(): + start = time.time() + if not uinput_write_history_pipe[0].poll(timeout=10): + raise AssertionError("No event written within 10 seconds") + return float(time.time() - start) + + +@test_setup +class TestInjector(unittest.IsolatedAsyncioTestCase): + new_gamepad_path = "/dev/input/event100" + + @classmethod + def setUpClass(cls): + cls.injector = None + cls.grab = evdev.InputDevice.grab + + def setUp(self): + self.failed = 0 + self.make_it_fail = 2 + self.global_uinputs = GlobalUInputs(UInput) + self.global_uinputs.prepare_all() + self.mapping_parser = MappingParser(self.global_uinputs) + + def grab_fail_twice(_): + if self.failed < self.make_it_fail: + self.failed += 1 + raise OSError() + + evdev.InputDevice.grab = grab_fail_twice + + def tearDown(self): + if self.injector is not None and self.injector.is_alive(): + self.injector.stop_injecting() + time.sleep(0.2) + self.assertIn( + self.injector.get_state(), + (InjectorState.STOPPED, InjectorState.ERROR, InjectorState.NO_GRAB), + ) + self.injector = None + evdev.InputDevice.grab = self.grab + + def initialize_injector(self, group, preset: Preset): + self.injector = Injector(group, preset, self.mapping_parser) + self.injector._devices = self.injector.group.get_devices() + self.injector._update_preset() + + def test_grab(self): + # path is from the fixtures + path = "/dev/input/event10" + preset = Preset() + preset.add( + Mapping.from_combination( + InputCombination([InputConfig(type=EV_KEY, code=10)]), + "keyboard", + "a", + ) + ) + + self.injector = Injector( + groups.find(key="Foo Device 2"), + preset, + self.mapping_parser, + ) + # this test needs to pass around all other constraints of + # _grab_device + self.injector.context = Context(preset, {}, {}, self.mapping_parser) + device = self.injector._grab_device(evdev.InputDevice(path)) + gamepad = classify(device) == DeviceType.GAMEPAD + self.assertFalse(gamepad) + self.assertEqual(self.failed, 2) + # success on the third try + self.assertEqual(device.name, fixtures.get_fixture(path).name) + + def test_fail_grab(self): + self.make_it_fail = 999 + preset = Preset() + preset.add( + Mapping.from_combination( + InputCombination([InputConfig(type=EV_KEY, code=10)]), + "keyboard", + "a", + ) + ) + + self.injector = Injector( + groups.find(key="Foo Device 2"), + preset, + self.mapping_parser, + ) + path = "/dev/input/event10" + self.injector.context = Context(preset, {}, {}, self.mapping_parser) + device = self.injector._grab_device(evdev.InputDevice(path)) + self.assertIsNone(device) + self.assertGreaterEqual(self.failed, 1) + + self.assertEqual(self.injector.get_state(), InjectorState.UNKNOWN) + self.injector.start() + self.assertEqual(self.injector.get_state(), InjectorState.STARTING) + # since none can be grabbed, the process will terminate. But that + # actually takes quite some time. + time.sleep(self.injector.regrab_timeout * 12) + self.assertFalse(self.injector.is_alive()) + self.assertEqual(self.injector.get_state(), InjectorState.NO_GRAB) + + def test_grab_device_1(self): + device_hash = fixtures.gamepad.get_device_hash() + + preset = Preset() + preset.add( + Mapping.from_combination( + InputCombination( + [ + InputConfig( + type=EV_ABS, + code=ABS_HAT0X, + analog_threshold=1, + origin_hash=device_hash, + ) + ] + ), + "keyboard", + "a", + ), + ) + self.initialize_injector(groups.find(name="gamepad"), preset) + self.injector.context = Context(preset, {}, {}, self.mapping_parser) + self.injector.group.paths = [ + "/dev/input/event10", + "/dev/input/event30", + "/dev/input/event1234", + ] + + grabbed = self.injector._grab_devices() + self.assertEqual(len(grabbed), 1) + self.assertEqual(grabbed[device_hash].path, "/dev/input/event30") + + def test_forward_gamepad_events(self): + device_hash = fixtures.gamepad.get_device_hash() + + # forward abs joystick events + preset = Preset() + preset.add( + Mapping.from_combination( + input_combination=InputCombination( + [InputConfig(type=EV_KEY, code=BTN_A, origin_hash=device_hash)] + ), + target_uinput="keyboard", + output_symbol="a", + ), + ) + + self.initialize_injector(groups.find(name="gamepad"), preset) + self.injector.context = Context(preset, {}, {}, self.mapping_parser) + + path = "/dev/input/event30" + devices = self.injector._grab_devices() + self.assertEqual(len(devices), 1) + self.assertEqual(devices[device_hash].path, path) + gamepad = classify(devices[device_hash]) == DeviceType.GAMEPAD + self.assertTrue(gamepad) + + def test_skip_unused_device(self): + # skips a device because its capabilities are not used in the preset + preset = Preset() + preset.add( + Mapping.from_combination( + InputCombination([InputConfig(type=EV_KEY, code=10)]), + "keyboard", + "a", + ) + ) + self.initialize_injector(groups.find(key="Foo Device 2"), preset) + self.injector.context = Context(preset, {}, {}, self.mapping_parser) + + # grabs only one device even though the group has 4 devices + devices = self.injector._grab_devices() + self.assertEqual(len(devices), 1) + self.assertEqual(self.failed, 2) + + def test_skip_unknown_device(self): + preset = Preset() + preset.add( + Mapping.from_combination( + InputCombination([InputConfig(type=EV_KEY, code=1234)]), + "keyboard", + "a", + ) + ) + + # skips a device because its capabilities are not used in the preset + self.initialize_injector(groups.find(key="Foo Device 2"), preset) + self.injector.context = Context(preset, {}, {}, self.mapping_parser) + devices = self.injector._grab_devices() + + # skips the device alltogether, so no grab attempts fail + self.assertEqual(self.failed, 0) + self.assertEqual(devices, {}) + + def test_get_udev_name(self): + self.injector = Injector( + groups.find(key="Foo Device 2"), + Preset(), + self.mapping_parser, + ) + suffix = "mapped" + prefix = "input-remapper" + expected = f'{prefix} {"a" * (80 - len(suffix) - len(prefix) - 2)} {suffix}' + self.assertEqual(len(expected), 80) + self.assertEqual(get_udev_name("a" * 100, suffix), expected) + + self.injector.device = "abcd" + self.assertEqual( + get_udev_name("abcd", "forwarded"), + "input-remapper abcd forwarded", + ) + + def test_get_forward_phys(self): + path = "/dev/input/event11" + device = evdev.InputDevice(path) + self.assertEqual( + get_forward_phys(device), + "input-remapper/usb-0000:03:00.0-1/input2/input2", + ) + + def test_get_forward_name(self): + self.assertEqual(get_forward_name("a" * 100), "a" * 80) + self.assertEqual(get_forward_name("abcd"), "abcd") + + @mock.patch("evdev.InputDevice.ungrab") + def test_capabilities_and_uinput_presence(self, ungrab_patch): + preset = Preset() + m1 = Mapping.from_combination( + InputCombination( + [ + InputConfig( + type=EV_KEY, + code=KEY_A, + origin_hash=fixtures.foo_device_2_keyboard.get_device_hash(), + ) + ] + ), + "keyboard", + "c", + ) + m2 = Mapping.from_combination( + InputCombination( + [ + InputConfig( + type=EV_REL, + code=REL_HWHEEL, + analog_threshold=1, + origin_hash=fixtures.foo_device_2_mouse.get_device_hash(), + ) + ] + ), + "keyboard", + "key(b)", + ) + preset.add(m1) + preset.add(m2) + self.injector = Injector( + groups.find(key="Foo Device 2"), + preset, + self.mapping_parser, + ) + self.injector.stop_injecting() + self.injector.run() + + self.assertEqual( + self.injector.preset.get_mapping( + InputCombination( + [ + InputConfig( + type=EV_KEY, + code=KEY_A, + origin_hash=fixtures.foo_device_2_keyboard.get_device_hash(), + ) + ] + ) + ), + m1, + ) + self.assertEqual( + self.injector.preset.get_mapping( + InputCombination( + [ + InputConfig( + type=EV_REL, + code=REL_HWHEEL, + analog_threshold=1, + origin_hash=fixtures.foo_device_2_mouse.get_device_hash(), + ) + ] + ) + ), + m2, + ) + + # reading and preventing original events from reaching the + # display server + forwarded_foo = uinputs.get("Foo Device foo") + forwarded = uinputs.get("Foo Device") + self.assertIsNotNone(forwarded_foo) + self.assertIsNotNone(forwarded) + + # copies capabilities for all other forwarded devices + self.assertIn(EV_REL, forwarded_foo.capabilities()) + self.assertIn(EV_KEY, forwarded.capabilities()) + self.assertEqual(sorted(forwarded.capabilities()[EV_KEY]), keyboard_keys) + self.assertEqual( + forwarded_foo.phys, "input-remapper/usb-0000:03:00.0-1/input2/input2" + ) + self.assertEqual( + forwarded.phys, "input-remapper/usb-0000:03:00.0-1/input2/input3" + ) + + self.assertEqual(ungrab_patch.call_count, 2) + + def test_injector(self): + numlock_before = is_numlock_on() + + # stuff the preset outputs + keyboard_layout.clear() + code_a = 100 + code_q = 101 + code_w = 102 + keyboard_layout._set("a", code_a) + keyboard_layout._set("key_q", code_q) + keyboard_layout._set("w", code_w) + + preset = Preset() + preset.add( + Mapping.from_combination( + InputCombination( + [ + InputConfig( + type=EV_KEY, + code=8, + origin_hash=fixtures.foo_device_2_keyboard.get_device_hash(), + ), + InputConfig( + type=EV_KEY, + code=9, + origin_hash=fixtures.foo_device_2_keyboard.get_device_hash(), + ), + ] + ), + "keyboard", + "k(KEY_Q).k(w)", + ) + ) + preset.add( + Mapping.from_combination( + InputCombination( + [ + InputConfig( + type=EV_ABS, + code=ABS_HAT0X, + analog_threshold=-1, + origin_hash=fixtures.foo_device_2_gamepad.get_device_hash(), + ) + ] + ), + "keyboard", + "a", + ) + ) + # one mapping that is unknown in the keyboard_layout on purpose + input_b = 10 + with self.assertRaises(ValidationError): + preset.add( + Mapping.from_combination( + InputCombination( + [ + InputConfig( + type=EV_KEY, + code=input_b, + origin_hash=fixtures.foo_device_2_keyboard.get_device_hash(), + ) + ] + ), + "keyboard", + "b", + ) + ) + + self.injector = Injector( + groups.find(key="Foo Device 2"), + preset, + self.mapping_parser, + ) + self.assertEqual(self.injector.get_state(), InjectorState.UNKNOWN) + self.injector.start() + self.assertEqual(self.injector.get_state(), InjectorState.STARTING) + + uinput_write_history_pipe[0].poll(timeout=1) + self.assertEqual(self.injector.get_state(), InjectorState.RUNNING) + time.sleep(EVENT_READ_TIMEOUT * 10) + + push_events( + fixtures.foo_device_2_keyboard, + [ + # should execute a macro... + InputEvent.key(8, 1), # forwarded + InputEvent.key(9, 1), # triggers macro, not forwarding + # macro runs now and injects a few more keys + InputEvent.key(8, 0), # releases macro (needs to be forwarded as well) + InputEvent.key(9, 0), # not forwarded, just like the down-event + ], + ) + + time.sleep(0.1) # give a chance that everything arrives in order + push_events( + fixtures.foo_device_2_gamepad, + [ + # gamepad stuff. trigger a combination + InputEvent.abs(ABS_HAT0X, -1), + InputEvent.abs(ABS_HAT0X, 0), + ], + ) + + time.sleep(0.1) + push_events( + fixtures.foo_device_2_keyboard, + [ + # just pass those over without modifying + InputEvent.key(10, 1), + InputEvent.key(10, 0), + InputEvent(0, 0, 3124, 3564, 6542), + ], + force=True, + ) + + # the injector needs time to process this + time.sleep(0.1) + + # sending anything arbitrary does not stop the process + # (is_alive checked later after some time) + self.injector._msg_pipe[1].send(1234) + + # convert the write history to some easier to manage list + history = read_write_history_pipe() + + # 1 event before the combination was triggered + # 4 events for the macro + # 1 event for releasing the previous key-down event + # 2 for mapped keys + # 3 for forwarded events + self.assertEqual(len(history), 11) + + # the first bit is ordered properly + self.assertEqual(history[0], (EV_KEY, 8, 1)) # forwarded + del history[0] + + # since the macro takes a little bit of time to execute, its + # keystrokes are all over the place. + # just check if they are there and if so, remove them from the list. + # the macro itself + self.assertIn((EV_KEY, code_q, 1), history) + self.assertIn((EV_KEY, code_q, 0), history) + self.assertIn((EV_KEY, code_w, 1), history) + self.assertIn((EV_KEY, code_w, 0), history) + index_q_1 = history.index((EV_KEY, code_q, 1)) + index_q_0 = history.index((EV_KEY, code_q, 0)) + index_w_1 = history.index((EV_KEY, code_w, 1)) + index_w_0 = history.index((EV_KEY, code_w, 0)) + self.assertGreater(index_q_0, index_q_1) + self.assertGreater(index_w_1, index_q_0) + self.assertGreater(index_w_0, index_w_1) + del history[index_w_0] + del history[index_w_1] + del history[index_q_0] + del history[index_q_1] + + # The rest should be in order now. + # First the released combination key which did not release the macro. + # The combination key which released the macro won't appear here, because + # it also didn't have a key-down event and therefore doesn't need to be + # released itself. + self.assertEqual(history[0], (EV_KEY, 8, 0)) + # value should be 1, even if the input event was -1. + # Injected keycodes should always be either 0 or 1 + self.assertEqual(history[1], (EV_KEY, code_a, 1)) + self.assertEqual(history[2], (EV_KEY, code_a, 0)) + self.assertEqual(history[3], (EV_KEY, input_b, 1)) + self.assertEqual(history[4], (EV_KEY, input_b, 0)) + self.assertEqual(history[5], (3124, 3564, 6542)) + + time.sleep(0.1) + self.assertTrue(self.injector.is_alive()) + + numlock_after = is_numlock_on() + self.assertEqual(numlock_before, numlock_after) + self.assertEqual(self.injector.get_state(), InjectorState.RUNNING) + + def test_is_in_capabilities(self): + key = InputCombination(InputCombination.from_tuples((1, 2, 1))) + capabilities = {1: [9, 2, 5]} + self.assertTrue(is_in_capabilities(key, capabilities)) + + key = InputCombination(InputCombination.from_tuples((1, 2, 1), (1, 3, 1))) + capabilities = {1: [9, 2, 5]} + # only one of the codes of the combination is required. + # The goal is to make combinations= across those sub-devices possible, + # that make up one hardware device + self.assertTrue(is_in_capabilities(key, capabilities)) + + key = InputCombination(InputCombination.from_tuples((1, 2, 1), (1, 5, 1))) + capabilities = {1: [9, 2, 5]} + self.assertTrue(is_in_capabilities(key, capabilities)) + + +@test_setup +class TestModifyCapabilities(unittest.TestCase): + def setUp(self): + self.global_uinputs = GlobalUInputs(UInput) + self.global_uinputs.prepare_all() + self.mapping_parser = MappingParser(self.global_uinputs) + + class FakeDevice: + def __init__(self): + self._capabilities = { + evdev.ecodes.EV_SYN: [1, 2, 3], + evdev.ecodes.EV_FF: [1, 2, 3], + EV_ABS: [ + ( + 1, + evdev.AbsInfo( + value=None, + min=None, + max=1234, + fuzz=None, + flat=None, + resolution=None, + ), + ), + ( + 2, + evdev.AbsInfo( + value=None, + min=50, + max=2345, + fuzz=None, + flat=None, + resolution=None, + ), + ), + 3, + ], + } + + def capabilities(self, absinfo=False): + assert absinfo is True + return self._capabilities + + preset = Preset() + preset.add( + Mapping.from_combination( + InputCombination([InputConfig(type=EV_KEY, code=80)]), + "keyboard", + "a", + ) + ) + preset.add( + Mapping.from_combination( + InputCombination([InputConfig(type=EV_KEY, code=81)]), + "keyboard", + DISABLE_NAME, + ), + ) + + macro_code = "r(2, m(sHiFt_l, r(2, k(1).k(2))))" + macro = Parser.parse(macro_code, preset) + + preset.add( + Mapping.from_combination( + InputCombination([InputConfig(type=EV_KEY, code=60)]), + "keyboard", + macro_code, + ), + ) + + # going to be ignored, because EV_REL cannot be mapped, that's + # mouse movements. + preset.add( + Mapping.from_combination( + InputCombination( + [InputConfig(type=EV_REL, code=1234, analog_threshold=3)] + ), + "keyboard", + "b", + ), + ) + + self.a = keyboard_layout.get("a") + self.shift_l = keyboard_layout.get("ShIfT_L") + self.one = keyboard_layout.get(1) + self.two = keyboard_layout.get("2") + self.left = keyboard_layout.get("BtN_lEfT") + self.fake_device = FakeDevice() + self.preset = preset + self.macro = macro + + def check_keys(self, capabilities): + """No matter the configuration, EV_KEY will be mapped to EV_KEY.""" + self.assertIn(EV_KEY, capabilities) + keys = capabilities[EV_KEY] + self.assertIn(self.a, keys) + self.assertIn(self.one, keys) + self.assertIn(self.two, keys) + self.assertIn(self.shift_l, keys) + self.assertNotIn(DISABLE_CODE, keys) + + def test_copy_capabilities(self): + # I don't know what ABS_VOLUME is, for now I would like to just always + # remove it until somebody complains, since its presence broke stuff + self.injector = Injector(mock.Mock(), self.preset, self.mapping_parser) + self.fake_device._capabilities = { + EV_ABS: [ABS_VOLUME, (ABS_X, evdev.AbsInfo(0, 0, 500, 0, 0, 0))], + EV_KEY: [1, 2, 3], + EV_REL: [11, 12, 13], + evdev.ecodes.EV_SYN: [1], + evdev.ecodes.EV_FF: [2], + } + + capabilities = self.injector._copy_capabilities(self.fake_device) + self.assertNotIn(ABS_VOLUME, capabilities[EV_ABS]) + self.assertNotIn(evdev.ecodes.EV_SYN, capabilities) + self.assertNotIn(evdev.ecodes.EV_FF, capabilities) + self.assertListEqual(capabilities[EV_KEY], [1, 2, 3]) + self.assertListEqual(capabilities[EV_REL], [11, 12, 13]) + self.assertEqual(capabilities[EV_ABS][0][1].max, 500) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/test_input_config.py b/tests/unit/test_input_config.py new file mode 100644 index 0000000..141ffd9 --- /dev/null +++ b/tests/unit/test_input_config.py @@ -0,0 +1,559 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + + +import unittest + +from evdev.ecodes import ( + EV_KEY, + EV_ABS, + EV_REL, + BTN_C, + BTN_B, + BTN_A, + BTN_LEFT, + BTN_MIDDLE, + BTN_RIGHT, + BTN_SIDE, + BTN_EXTRA, + BTN_FORWARD, + BTN_BACK, + BTN_TASK, + REL_X, + REL_Y, + REL_WHEEL, + REL_HWHEEL, + ABS_RY, + ABS_X, + ABS_HAT0Y, + ABS_HAT0X, + KEY_A, + KEY_LEFTSHIFT, + KEY_RIGHTALT, + KEY_LEFTCTRL, +) + +from inputremapper.configs.input_config import InputCombination, InputConfig +from tests.lib.test_setup import test_setup + + +@test_setup +class TestInputConfig(unittest.TestCase): + def test_input_config(self): + test_cases = [ + # basic test, nothing fancy here + { + "input": { + "type": EV_KEY, + "code": KEY_A, + "origin_hash": "foo", + }, + "properties": { + "type": EV_KEY, + "code": KEY_A, + "origin_hash": "foo", + "input_match_hash": (EV_KEY, KEY_A, "foo"), + "defines_analog_input": False, + "type_and_code": (EV_KEY, KEY_A), + }, + "methods": [ + { + "name": "description", + "args": (), + "kwargs": {}, + "return": "a", + }, + { + "name": "__hash__", + "args": (), + "kwargs": {}, + "return": hash((EV_KEY, KEY_A, "foo", None)), + }, + ], + }, + # removes analog_threshold + { + "input": { + "type": EV_KEY, + "code": KEY_A, + "origin_hash": "foo", + "analog_threshold": 10, + }, + "properties": { + "type": EV_KEY, + "code": KEY_A, + "origin_hash": "foo", + "analog_threshold": None, + "input_match_hash": (EV_KEY, KEY_A, "foo"), + "defines_analog_input": False, + "type_and_code": (EV_KEY, KEY_A), + }, + "methods": [ + { + "name": "description", + "args": (), + "kwargs": {}, + "return": "a", + }, + { + "name": "__hash__", + "args": (), + "kwargs": {}, + "return": hash((EV_KEY, KEY_A, "foo", None)), + }, + ], + }, + # abs to btn + { + "input": { + "type": EV_ABS, + "code": ABS_X, + "origin_hash": "foo", + "analog_threshold": 10, + }, + "properties": { + "type": EV_ABS, + "code": ABS_X, + "origin_hash": "foo", + "analog_threshold": 10, + "input_match_hash": (EV_ABS, ABS_X, "foo"), + "defines_analog_input": False, + "type_and_code": (EV_ABS, ABS_X), + }, + "methods": [ + { + "name": "description", + "args": (), + "kwargs": {}, + "return": "Joystick-X Right 10%", + }, + { + "name": "description", + "args": (), + "kwargs": {"exclude_threshold": True}, + "return": "Joystick-X Right", + }, + { + "name": "description", + "args": (), + "kwargs": { + "exclude_threshold": True, + "exclude_direction": True, + }, + "return": "Joystick-X", + }, + { + "name": "__hash__", + "args": (), + "kwargs": {}, + "return": hash((EV_ABS, ABS_X, "foo", 10)), + }, + ], + }, + # abs to btn with d-pad + { + "input": { + "type": EV_ABS, + "code": ABS_HAT0Y, + "origin_hash": "foo", + "analog_threshold": 10, + }, + "properties": { + "type": EV_ABS, + "code": ABS_HAT0Y, + "origin_hash": "foo", + "analog_threshold": 10, + "input_match_hash": (EV_ABS, ABS_HAT0Y, "foo"), + "defines_analog_input": False, + "type_and_code": (EV_ABS, ABS_HAT0Y), + }, + "methods": [ + { + "name": "description", + "args": (), + "kwargs": {}, + "return": "DPad-Y Down 10%", + }, + { + "name": "__hash__", + "args": (), + "kwargs": {}, + "return": hash((EV_ABS, ABS_HAT0Y, "foo", 10)), + }, + ], + }, + # rel to btn + { + "input": { + "type": EV_REL, + "code": REL_Y, + "origin_hash": "foo", + "analog_threshold": 10, + }, + "properties": { + "type": EV_REL, + "code": REL_Y, + "origin_hash": "foo", + "analog_threshold": 10, + "input_match_hash": (EV_REL, REL_Y, "foo"), + "defines_analog_input": False, + "type_and_code": (EV_REL, REL_Y), + }, + "methods": [ + { + "name": "description", + "args": (), + "kwargs": {}, + "return": "Y Down 10", + }, + { + "name": "__hash__", + "args": (), + "kwargs": {}, + "return": hash((EV_REL, REL_Y, "foo", 10)), + }, + ], + }, + # abs as axis + { + "input": { + "type": EV_ABS, + "code": ABS_X, + "origin_hash": "foo", + "analog_threshold": 0, + }, + "properties": { + "type": EV_ABS, + "code": ABS_X, + "origin_hash": "foo", + "analog_threshold": None, + "input_match_hash": (EV_ABS, ABS_X, "foo"), + "defines_analog_input": True, + "type_and_code": (EV_ABS, ABS_X), + }, + "methods": [ + { + "name": "description", + "args": (), + "kwargs": {}, + "return": "Joystick-X", + }, + { + "name": "description", + "args": (), + "kwargs": { + "exclude_threshold": True, + "exclude_direction": True, + }, + "return": "Joystick-X", + }, + { + "name": "__hash__", + "args": (), + "kwargs": {}, + "return": hash((EV_ABS, ABS_X, "foo", None)), + }, + ], + }, + # rel as axis + { + "input": { + "type": EV_REL, + "code": REL_WHEEL, + "origin_hash": "foo", + }, + "properties": { + "type": EV_REL, + "code": REL_WHEEL, + "origin_hash": "foo", + "analog_threshold": None, + "input_match_hash": (EV_REL, REL_WHEEL, "foo"), + "defines_analog_input": True, + "type_and_code": (EV_REL, REL_WHEEL), + }, + "methods": [ + { + "name": "description", + "args": (), + "kwargs": {}, + "return": "Wheel", + }, + { + "name": "__hash__", + "args": (), + "kwargs": {}, + "return": hash((EV_REL, REL_WHEEL, "foo", None)), + }, + ], + }, + ] + for test_case in test_cases: + input_config = InputConfig(**test_case["input"]) + for property_, value in test_case["properties"].items(): + self.assertEqual( + value, + getattr(input_config, property_), + f"property mismatch for input: {test_case['input']} " + f"property: {property_} expected value: {value}", + ) + for method in test_case["methods"]: + self.assertEqual( + method["return"], + getattr(input_config, method["name"])( + *method["args"], **method["kwargs"] + ), + f"wrong method return for input: {test_case['input']} " + f"method: {method}", + ) + + def test_is_immutable(self): + input_config = InputConfig(type=1, code=2) + with self.assertRaises(TypeError): + input_config.origin_hash = "foo" + + +@test_setup +class TestInputCombination(unittest.TestCase): + def test_eq(self): + a = InputCombination( + [ + InputConfig(type=EV_REL, code=REL_X, value=1, origin_hash="1234"), + InputConfig(type=EV_KEY, code=KEY_A, value=1, origin_hash="abcd"), + ] + ) + b = InputCombination( + [ + InputConfig(type=EV_REL, code=REL_X, value=1, origin_hash="1234"), + InputConfig(type=EV_KEY, code=KEY_A, value=1, origin_hash="abcd"), + ] + ) + self.assertEqual(a, b) + + def test_not_eq(self): + a = InputCombination( + [ + InputConfig(type=EV_REL, code=REL_X, value=1, origin_hash="2345"), + InputConfig(type=EV_KEY, code=KEY_A, value=1, origin_hash="bcde"), + ] + ) + b = InputCombination( + [ + InputConfig(type=EV_REL, code=REL_X, value=1, origin_hash="1234"), + InputConfig(type=EV_KEY, code=KEY_A, value=1, origin_hash="abcd"), + ] + ) + self.assertNotEqual(a, b) + + def test_can_be_used_as_dict_key(self): + dict_ = { + InputCombination( + [ + InputConfig(type=EV_REL, code=REL_X, value=1, origin_hash="1234"), + InputConfig(type=EV_KEY, code=KEY_A, value=1, origin_hash="abcd"), + ] + ): "foo" + } + key = InputCombination( + [ + InputConfig(type=EV_REL, code=REL_X, value=1, origin_hash="1234"), + InputConfig(type=EV_KEY, code=KEY_A, value=1, origin_hash="abcd"), + ] + ) + self.assertEqual(dict_.get(key), "foo") + + def test_get_permutations(self): + key_1 = InputCombination(InputCombination.from_tuples((1, 3, 1))) + self.assertEqual(len(key_1.get_permutations()), 1) + self.assertEqual(key_1.get_permutations()[0], key_1) + + key_2 = InputCombination(InputCombination.from_tuples((1, 3, 1), (1, 5, 1))) + self.assertEqual(len(key_2.get_permutations()), 1) + self.assertEqual(key_2.get_permutations()[0], key_2) + + key_3 = InputCombination( + InputCombination.from_tuples((1, 3, 1), (1, 5, 1), (1, 7, 1)) + ) + self.assertEqual(len(key_3.get_permutations()), 2) + self.assertEqual( + key_3.get_permutations()[0], + InputCombination( + InputCombination.from_tuples((1, 3, 1), (1, 5, 1), (1, 7, 1)) + ), + ) + self.assertEqual( + key_3.get_permutations()[1], + InputCombination( + InputCombination.from_tuples((1, 5, 1), (1, 3, 1), (1, 7, 1)) + ), + ) + + def test_is_problematic(self): + key_1 = InputCombination( + InputCombination.from_tuples((1, KEY_LEFTSHIFT, 1), (1, 5, 1)) + ) + self.assertTrue(key_1.is_problematic()) + + key_2 = InputCombination( + InputCombination.from_tuples((1, KEY_RIGHTALT, 1), (1, 5, 1)) + ) + self.assertTrue(key_2.is_problematic()) + + key_3 = InputCombination( + InputCombination.from_tuples((1, 3, 1), (1, KEY_LEFTCTRL, 1)) + ) + self.assertTrue(key_3.is_problematic()) + + key_4 = InputCombination(InputCombination.from_tuples((1, 3, 1))) + self.assertFalse(key_4.is_problematic()) + + key_5 = InputCombination(InputCombination.from_tuples((1, 3, 1), (1, 5, 1))) + self.assertFalse(key_5.is_problematic()) + + def test_init(self): + self.assertRaises(TypeError, lambda: InputCombination(1)) + self.assertRaises(TypeError, lambda: InputCombination(None)) + self.assertRaises(TypeError, lambda: InputCombination([1])) + self.assertRaises(TypeError, lambda: InputCombination((1,))) + self.assertRaises(TypeError, lambda: InputCombination((1, 2))) + self.assertRaises(TypeError, lambda: InputCombination("1")) + self.assertRaises(TypeError, lambda: InputCombination("(1,2,3)")) + self.assertRaises( + TypeError, + lambda: InputCombination(((1, 2, 3), (1, 2, 3), None)), + ) + + # those don't raise errors + InputCombination(({"type": 1, "code": 2}, {"type": 1, "code": 1})) + InputCombination(({"type": 1, "code": 2},)) + InputCombination(({"type": "1", "code": "2"},)) + InputCombination([InputConfig(type=1, code=2, analog_threshold=3)]) + InputCombination( + ( + {"type": 1, "code": 2}, + {"type": "1", "code": "2"}, + InputConfig(type=1, code=2), + ) + ) + + def test_to_config(self): + c1 = InputCombination([InputConfig(type=1, code=2, analog_threshold=3)]) + c2 = InputCombination( + ( + InputConfig(type=1, code=2, analog_threshold=3), + InputConfig(type=4, code=5, analog_threshold=6), + ) + ) + # analog_threshold is removed for key events + self.assertEqual(c1.to_config(), ({"type": 1, "code": 2},)) + self.assertEqual( + c2.to_config(), + ({"type": 1, "code": 2}, {"type": 4, "code": 5, "analog_threshold": 6}), + ) + + def test_beautify(self): + # not an integration test, but I have all the selection_label tests here already + self.assert_beautify_single(EV_KEY, KEY_A, 1, "a") + self.assert_beautify_single(EV_KEY, KEY_A, 1, "a") + self.assert_beautify_single(EV_ABS, ABS_HAT0Y, -1, "DPad-Y Up") + self.assert_beautify_single(EV_KEY, BTN_A, 1, "Button A") + self.assert_beautify_single(EV_KEY, 1234, 1, "unknown (1, 1234)") + self.assert_beautify_single(EV_ABS, ABS_HAT0X, -1, "DPad-X Left") + self.assert_beautify_single(EV_ABS, ABS_HAT0Y, -1, "DPad-Y Up") + self.assert_beautify_single(EV_KEY, BTN_A, 1, "Button A") + self.assert_beautify_single(EV_ABS, ABS_X, 1, "Joystick-X Right") + self.assert_beautify_single(EV_ABS, ABS_RY, 1, "Joystick-RY Down") + self.assert_beautify_single(EV_REL, REL_HWHEEL, 1, "Wheel Right") + self.assert_beautify_single(EV_REL, REL_WHEEL, -1, "Wheel Down") + + # region "mouse buttons" + self.assert_beautify_single(EV_KEY, BTN_LEFT, 1, "Mouse Button LEFT") + self.assert_beautify_single(EV_KEY, BTN_MIDDLE, 1, "Mouse Button MIDDLE") + self.assert_beautify_single(EV_KEY, BTN_RIGHT, 1, "Mouse Button RIGHT") + self.assert_beautify_single(EV_KEY, BTN_SIDE, 1, "Mouse Button 4") + self.assert_beautify_single(EV_KEY, BTN_EXTRA, 1, "Mouse Button 5") + self.assert_beautify_single(EV_KEY, BTN_FORWARD, 1, "Mouse Button 6") + self.assert_beautify_single(EV_KEY, BTN_BACK, 1, "Mouse Button 7") + self.assert_beautify_single(EV_KEY, BTN_TASK, 1, "Mouse Button 8") + + # Mouse buttons 9+ do not have an evdev name, and so must be expressed + # as an offset from BTN_LEFT AKA "Button 1". Subtract 1 for base index + # so that `btn_mouse_base + 1` would be "Button 1". + btn_mouse_base: int = BTN_LEFT - 1 + + self.assert_beautify_single(EV_KEY, btn_mouse_base + 9, 1, "Mouse Button 9") + self.assert_beautify_single(EV_KEY, btn_mouse_base + 10, 1, "Mouse Button 10") + # endregion "mouse buttons" + + # combinations + self.assertEqual( + InputCombination( + InputCombination.from_tuples( + (EV_KEY, BTN_A, 1), + (EV_KEY, BTN_B, 1), + (EV_KEY, BTN_C, 1), + ), + ).beautify(), + "Button A + Button B + Button C", + ) + + def test_find_analog_input_config(self): + analog_input = InputConfig(type=EV_REL, code=REL_X) + + combination = InputCombination( + ( + InputConfig(type=EV_KEY, code=BTN_MIDDLE), + InputConfig(type=EV_REL, code=REL_Y, analog_threshold=1), + analog_input, + ) + ) + self.assertIsNone(combination.find_analog_input_config(type_=EV_ABS)) + self.assertEqual( + combination.find_analog_input_config(type_=EV_REL), analog_input + ) + self.assertEqual(combination.find_analog_input_config(), analog_input) + + combination = InputCombination( + ( + InputConfig(type=EV_REL, code=REL_X, analog_threshold=1), + InputConfig(type=EV_KEY, code=BTN_MIDDLE), + ) + ) + self.assertIsNone(combination.find_analog_input_config(type_=EV_ABS)) + self.assertIsNone(combination.find_analog_input_config(type_=EV_REL)) + self.assertIsNone(combination.find_analog_input_config()) + + # region helper methods + def assert_beautify_single(self, type_, code, direction, expected_beautified_name): + """ + Assert that the beautified name of the event tuple `(type_, code, + direction)` matches the `expected_beautified_name`. + """ + self.assertEqual( + InputCombination( + InputCombination.from_tuples((type_, code, direction)) + ).beautify(), + expected_beautified_name, + ) + + # endregion + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/test_input_event.py b/tests/unit/test_input_event.py new file mode 100644 index 0000000..5da1264 --- /dev/null +++ b/tests/unit/test_input_event.py @@ -0,0 +1,132 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + +import unittest + +import evdev +from dataclasses import FrozenInstanceError +from inputremapper.input_event import InputEvent +from tests.lib.test_setup import test_setup + + +@test_setup +class TestInputEvent(unittest.TestCase): + def test_from_event(self): + e1 = InputEvent.from_event(evdev.InputEvent(1, 2, 3, 4, 5)) + e2 = InputEvent.from_event(e1) + + self.assertEqual(e1, e2) + self.assertEqual(e1.sec, 1) + self.assertEqual(e1.usec, 2) + self.assertEqual(e1.type, 3) + self.assertEqual(e1.code, 4) + self.assertEqual(e1.value, 5) + + self.assertEqual(e1.sec, e2.sec) + self.assertEqual(e1.usec, e2.usec) + self.assertEqual(e1.type, e2.type) + self.assertEqual(e1.code, e2.code) + self.assertEqual(e1.value, e2.value) + + self.assertRaises(TypeError, InputEvent.from_event, "1,2,3") + + def test_from_event_tuple(self): + t1 = (1, 2, 3) + t2 = (1, "2", 3) + t3 = (1, 2, 3, 4, 5) + t4 = (1, "b", 3) + + e1 = InputEvent.from_tuple(t1) + e2 = InputEvent.from_tuple(t2) + self.assertEqual(e1, e2) + self.assertEqual(e1.sec, 0) + self.assertEqual(e1.usec, 0) + self.assertEqual(e1.type, 1) + self.assertEqual(e1.code, 2) + self.assertEqual(e1.value, 3) + + def test_properties(self): + e1 = InputEvent.from_tuple((evdev.ecodes.EV_KEY, evdev.ecodes.BTN_LEFT, 1)) + self.assertEqual( + e1.event_tuple, + (evdev.ecodes.EV_KEY, evdev.ecodes.BTN_LEFT, 1), + ) + self.assertEqual(e1.type_and_code, (evdev.ecodes.EV_KEY, evdev.ecodes.BTN_LEFT)) + + with self.assertRaises( + FrozenInstanceError + ): # would be TypeError on a slotted class + e1.event_tuple = (1, 2, 3) + + with self.assertRaises( + FrozenInstanceError + ): # would be TypeError on a slotted class + e1.type_and_code = (1, 2) + + with self.assertRaises(FrozenInstanceError): + e1.value = 5 + + def test_modify(self): + e1 = InputEvent(1, 2, 3, 4, 5) + e2 = e1.modify(value=6) + e3 = e1.modify(sec=0, usec=0, type_=0, code=0, value=0) + + self.assertNotEqual(e1, e2) + self.assertEqual(e1.sec, e2.sec) + self.assertEqual(e1.usec, e2.usec) + self.assertEqual(e1.type, e2.type) + self.assertEqual(e1.code, e2.code) + self.assertNotEqual(e1.value, e2.value) + self.assertEqual(e3.sec, 0) + self.assertEqual(e3.usec, 0) + self.assertEqual(e3.type, 0) + self.assertEqual(e3.code, 0) + self.assertEqual(e3.value, 0) + + def test_is_wheel_event(self): + input_event_x = InputEvent( + 0, + 0, + evdev.ecodes.EV_REL, + evdev.ecodes.REL_X, + 1, + ) + self.assertFalse(input_event_x.is_wheel_event) + self.assertFalse(input_event_x.is_wheel_hi_res_event) + + input_event_wheel = InputEvent( + 0, + 0, + evdev.ecodes.EV_REL, + evdev.ecodes.REL_WHEEL, + 1, + ) + self.assertTrue(input_event_wheel.is_wheel_event) + self.assertFalse(input_event_wheel.is_wheel_hi_res_event) + + input_event_wheel_hi_res = InputEvent( + 0, + 0, + evdev.ecodes.EV_REL, + evdev.ecodes.REL_WHEEL_HI_RES, + 1, + ) + self.assertFalse(input_event_wheel_hi_res.is_wheel_event) + self.assertTrue(input_event_wheel_hi_res.is_wheel_hi_res_event) diff --git a/tests/unit/test_ipc.py b/tests/unit/test_ipc.py new file mode 100644 index 0000000..16e6ec1 --- /dev/null +++ b/tests/unit/test_ipc.py @@ -0,0 +1,208 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + +import asyncio +import multiprocessing +import os +import select +import time +import unittest + +from inputremapper.ipc.pipe import Pipe +from inputremapper.ipc.shared_dict import SharedDict +from inputremapper.ipc.socket import Server, Client, Base +from tests.lib.test_setup import test_setup +from tests.lib.tmp import tmp + + +@test_setup +class TestSharedDict(unittest.TestCase): + def setUp(self): + self.shared_dict = SharedDict() + self.shared_dict.start() + time.sleep(0.02) + + def test_returns_none(self): + self.assertIsNone(self.shared_dict.get("a")) + self.assertIsNone(self.shared_dict["a"]) + + def test_set_get(self): + self.shared_dict["a"] = 3 + self.assertEqual(self.shared_dict.get("a"), 3) + self.assertEqual(self.shared_dict["a"], 3) + + +@test_setup +class TestSocket(unittest.TestCase): + def test_socket(self): + def test(s1, s2): + self.assertEqual(s2.recv(), None) + + s1.send(1) + self.assertTrue(s2.poll()) + self.assertEqual(s2.recv(), 1) + self.assertFalse(s2.poll()) + self.assertEqual(s2.recv(), None) + + s1.send(2) + self.assertTrue(s2.poll()) + s1.send(3) + self.assertTrue(s2.poll()) + self.assertEqual(s2.recv(), 2) + self.assertTrue(s2.poll()) + self.assertEqual(s2.recv(), 3) + self.assertFalse(s2.poll()) + self.assertEqual(s2.recv(), None) + + server = Server(os.path.join(tmp, "socket1")) + client = Client(os.path.join(tmp, "socket1")) + test(server, client) + + client = Client(os.path.join(tmp, "socket2")) + server = Server(os.path.join(tmp, "socket2")) + test(client, server) + + def test_not_connected_1(self): + # client discards old message, because it might have had a purpose + # for a different client and not for the current one + server = Server(os.path.join(tmp, "socket3")) + server.send(1) + + client = Client(os.path.join(tmp, "socket3")) + server.send(2) + + self.assertTrue(client.poll()) + self.assertEqual(client.recv(), 2) + self.assertFalse(client.poll()) + self.assertEqual(client.recv(), None) + + def test_not_connected_2(self): + client = Client(os.path.join(tmp, "socket4")) + client.send(1) + + server = Server(os.path.join(tmp, "socket4")) + client.send(2) + + self.assertTrue(server.poll()) + self.assertEqual(server.recv(), 2) + self.assertFalse(server.poll()) + self.assertEqual(server.recv(), None) + + def test_select(self): + """Is compatible to select.select.""" + server = Server(os.path.join(tmp, "socket6")) + client = Client(os.path.join(tmp, "socket6")) + + server.send(1) + ready = select.select([client], [], [], 0)[0][0] + self.assertEqual(ready, client) + + client.send(2) + ready = select.select([server], [], [], 0)[0][0] + self.assertEqual(ready, server) + + def test_base_abstract(self): + self.assertRaises(NotImplementedError, lambda: Base("foo")) + self.assertRaises(NotImplementedError, lambda: Base.connect(None)) + self.assertRaises(NotImplementedError, lambda: Base.reconnect(None)) + self.assertRaises(NotImplementedError, lambda: Base.fileno(None)) + + +@test_setup +class TestPipe(unittest.IsolatedAsyncioTestCase): + def test_pipe_single(self): + p1 = Pipe(os.path.join(tmp, "pipe")) + self.assertEqual(p1.recv(), None) + + p1.send(1) + self.assertTrue(p1.poll()) + self.assertEqual(p1.recv(), 1) + self.assertFalse(p1.poll()) + self.assertEqual(p1.recv(), None) + + p1.send(2) + self.assertTrue(p1.poll()) + p1.send(3) + self.assertTrue(p1.poll()) + self.assertEqual(p1.recv(), 2) + self.assertTrue(p1.poll()) + self.assertEqual(p1.recv(), 3) + self.assertFalse(p1.poll()) + self.assertEqual(p1.recv(), None) + + def test_pipe_duo(self): + p1 = Pipe(os.path.join(tmp, "pipe")) + p2 = Pipe(os.path.join(tmp, "pipe")) + self.assertEqual(p2.recv(), None) + + p1.send(1) + self.assertEqual(p2.recv(), 1) + self.assertEqual(p2.recv(), None) + + p1.send(2) + p1.send(3) + self.assertEqual(p2.recv(), 2) + self.assertEqual(p2.recv(), 3) + self.assertEqual(p2.recv(), None) + + async def test_async_for_loop(self): + p1 = Pipe(os.path.join(tmp, "pipe")) + iterator = p1.__aiter__() + p1.send(1) + + self.assertEqual(await iterator.__anext__(), 1) + + read_task = asyncio.Task(iterator.__anext__()) + timeout_task = asyncio.Task(asyncio.sleep(1)) + + done, pending = await asyncio.wait( + (read_task, timeout_task), return_when=asyncio.FIRST_COMPLETED + ) + self.assertIn(timeout_task, done) + self.assertIn(read_task, pending) + read_task.cancel() + + async def test_async_for_loop_duo(self): + def writer(): + p = Pipe(os.path.join(tmp, "pipe")) + for i in range(3): + p.send(i) + time.sleep(0.5) + for i in range(3): + p.send(i) + time.sleep(0.1) + p.send("stop now") + + p1 = Pipe(os.path.join(tmp, "pipe")) + + w_process = multiprocessing.Process(target=writer) + w_process.start() + + messages = [] + async for msg in p1: + messages.append(msg) + if msg == "stop now": + break + + self.assertEqual(messages, [0, 1, 2, 0, 1, 2, "stop now"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/test_logger.py b/tests/unit/test_logger.py new file mode 100644 index 0000000..52066ce --- /dev/null +++ b/tests/unit/test_logger.py @@ -0,0 +1,151 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + +from tests.lib.tmp import tmp + +import logging +import os +import shutil +import unittest + +import evdev + +from inputremapper.configs.paths import PathUtils +from inputremapper.logging.logger import ( + logger, + ColorfulFormatter, +) +from tests.lib.test_setup import test_setup + + +def add_filehandler(log_path: str, debug: bool) -> None: + """Start logging to a file.""" + log_path = os.path.expanduser(log_path) + os.makedirs(os.path.dirname(log_path), exist_ok=True) + file_handler = logging.FileHandler(log_path) + file_handler.setFormatter(ColorfulFormatter(debug)) + logger.addHandler(file_handler) + logger.info('Starting logging to "%s"', log_path) + + +@test_setup +class TestLogger(unittest.TestCase): + def tearDown(self): + logger.update_verbosity(debug=True) + + # remove the file handler + logger.handlers = [ + handler + for handler in logger.handlers + if not isinstance(logger.handlers, logging.FileHandler) + ] + path = os.path.join(tmp, "logger-test") + PathUtils.remove(path) + + def test_write(self): + uinput = evdev.UInput(name="foo") + path = os.path.join(tmp, "logger-test") + add_filehandler(path, False) + logger.write((evdev.ecodes.EV_KEY, evdev.ecodes.KEY_A, 1), uinput) + with open(path, "r") as f: + content = f.read() + self.assertIn( + 'Writing (1, 30, 1) to "foo"', + content, + ) + + def test_log_info(self): + logger.update_verbosity(debug=False) + path = os.path.join(tmp, "logger-test") + add_filehandler(path, False) + logger.log_info() + with open(path, "r") as f: + content = f.read().lower() + self.assertIn("input-remapper", content) + + def test_makes_path(self): + path = os.path.join(tmp, "logger-test") + if os.path.exists(path): + shutil.rmtree(path) + + new_path = os.path.join(tmp, "logger-test", "a", "b", "c") + add_filehandler(new_path, False) + self.assertTrue(os.path.exists(new_path)) + + def test_debug(self): + path = os.path.join(tmp, "logger-test") + logger.update_verbosity(True) + add_filehandler(path, True) + logger.error("abc") + logger.warning("foo") + logger.info("123") + logger.debug("456") + logger.debug("789") + with open(path, "r") as f: + content = f.read().lower() + self.assertIn("logger.py", content) + + self.assertIn("error", content) + self.assertIn("abc", content) + + self.assertIn("warn", content) + self.assertIn("foo", content) + + self.assertIn("info", content) + self.assertIn("123", content) + + self.assertIn("debug", content) + self.assertIn("456", content) + + self.assertIn("debug", content) + self.assertIn("789", content) + + def test_default(self): + path = os.path.join(tmp, "logger-test") + logger.update_verbosity(debug=False) + add_filehandler(path, False) + logger.error("abc") + logger.warning("foo") + logger.info("123") + logger.debug("456") + logger.debug("789") + with open(path, "r") as f: + content = f.read().lower() + self.assertNotIn("logger.py", content) + self.assertNotIn("line", content) + + self.assertIn("error", content) + self.assertIn("abc", content) + + self.assertIn("warn", content) + self.assertIn("foo", content) + + self.assertNotIn("info", content) + self.assertIn("123", content) + + self.assertNotIn("debug", content) + self.assertNotIn("456", content) + + self.assertNotIn("debug", content) + self.assertNotIn("789", content) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/test_macros/__init__.py b/tests/unit/test_macros/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/unit/test_macros/macro_test_base.py b/tests/unit/test_macros/macro_test_base.py new file mode 100644 index 0000000..82ffbd7 --- /dev/null +++ b/tests/unit/test_macros/macro_test_base.py @@ -0,0 +1,120 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + + +import asyncio +import unittest + +from inputremapper.configs.preset import Preset +from inputremapper.configs.validation_errors import MacroError +from inputremapper.injection.context import Context +from inputremapper.injection.global_uinputs import GlobalUInputs, UInput +from inputremapper.injection.macros.macro import Macro, macro_variables +from inputremapper.injection.macros.parse import Parser +from inputremapper.injection.mapping_handlers.mapping_parser import MappingParser +from tests.lib.fixtures import fixtures +from tests.lib.logger import logger +from tests.lib.patches import InputDevice + + +class MacroTestBase(unittest.IsolatedAsyncioTestCase): + @classmethod + def setUpClass(cls): + macro_variables.start() + + def setUp(self): + self.result = [] + self.global_uinputs = GlobalUInputs(UInput) + self.mapping_parser = MappingParser(self.global_uinputs) + + try: + self.loop = asyncio.get_event_loop() + except RuntimeError: + # suddenly "There is no current event loop in thread 'MainThread'" + # errors started to appear + self.loop = asyncio.new_event_loop() + asyncio.set_event_loop(self.loop) + + self.source_device = InputDevice(fixtures.bar_device.path) + + self.context = Context( + Preset(), + source_devices={fixtures.bar_device.get_device_hash(): self.source_device}, + forward_devices={}, + mapping_parser=self.mapping_parser, + ) + + def tearDown(self): + self.result = [] + + def handler(self, type_: int, code: int, value: int): + """Where macros should write codes to.""" + logger.info(f"macro wrote{(type_, code, value)}") + self.result.append((type_, code, value)) + + async def trigger_sequence(self, macro: Macro, event): + for listener in self.context.listeners: + asyncio.ensure_future(listener(event)) + # this still might cause race conditions and the test to fail + await asyncio.sleep(0) + + macro.press_trigger() + if macro.running: + return + asyncio.ensure_future(macro.run(self.handler)) + + async def release_sequence(self, macro: Macro, event): + for listener in self.context.listeners: + asyncio.ensure_future(listener(event)) + # this still might cause race conditions and the test to fail + await asyncio.sleep(0) + + macro.release_trigger() + + def count_child_macros(self, macro) -> int: + count = 0 + for task in macro.tasks: + count += len(task.child_macros) + for child_macro in task.child_macros: + count += self.count_child_macros(child_macro) + return count + + def count_tasks(self, macro) -> int: + count = len(macro.tasks) + for task in macro.tasks: + for child_macro in task.child_macros: + count += self.count_tasks(child_macro) + return count + + def expect_string_in_error(self, string: str, macro: str): + with self.assertRaises(MacroError) as cm: + Parser.parse(macro, self.context) + error = str(cm.exception) + self.assertIn(string, error) + + +class DummyMapping: + macro_key_sleep_ms = 10 + rel_rate = 60 + target_uinput = "keyboard + mouse" + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/test_macros/test_add.py b/tests/unit/test_macros/test_add.py new file mode 100644 index 0000000..2f31c17 --- /dev/null +++ b/tests/unit/test_macros/test_add.py @@ -0,0 +1,71 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + + +import unittest + +from inputremapper.configs.validation_errors import MacroError +from inputremapper.injection.macros.macro import macro_variables +from inputremapper.injection.macros.parse import Parser +from tests.lib.test_setup import test_setup +from tests.unit.test_macros.macro_test_base import DummyMapping, MacroTestBase + + +@test_setup +class TestAdd(MacroTestBase): + async def test_add(self): + await Parser.parse("set(a, 1).add(a, 1)", self.context, DummyMapping).run( + self.handler + ) + self.assertEqual(macro_variables.get("a"), 2) + + await Parser.parse("set(b, 1).add(b, -1)", self.context, DummyMapping).run( + self.handler + ) + self.assertEqual(macro_variables.get("b"), 0) + + await Parser.parse("set(c, -1).add(c, 500)", self.context, DummyMapping).run( + self.handler + ) + self.assertEqual(macro_variables.get("c"), 499) + + await Parser.parse("add(d, 500)", self.context, DummyMapping).run(self.handler) + self.assertEqual(macro_variables.get("d"), 500) + + async def test_add_invalid(self): + # For invalid input it should do nothing (except to log to the console) + await Parser.parse('set(e, "foo").add(e, 1)', self.context, DummyMapping).run( + self.handler + ) + self.assertEqual(macro_variables.get("e"), "foo") + + await Parser.parse('set(e, "2").add(e, 3)', self.context, DummyMapping).run( + self.handler + ) + self.assertEqual(macro_variables.get("e"), "2") + + async def test_raises_error(self): + Parser.parse("add(a, 1)", self.context) # no error + self.assertRaises(MacroError, Parser.parse, "add(a, b)", self.context) + self.assertRaises(MacroError, Parser.parse, 'add(a, "1")', self.context) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/test_macros/test_argument.py b/tests/unit/test_macros/test_argument.py new file mode 100644 index 0000000..aef5733 --- /dev/null +++ b/tests/unit/test_macros/test_argument.py @@ -0,0 +1,187 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + + +import unittest + +from inputremapper.configs.validation_errors import ( + MacroError, +) +from inputremapper.injection.macros.argument import Argument, ArgumentConfig +from inputremapper.injection.macros.macro import Macro, macro_variables +from inputremapper.injection.macros.raw_value import RawValue +from inputremapper.injection.macros.variable import Variable +from tests.lib.test_setup import test_setup +from tests.unit.test_macros.macro_test_base import DummyMapping, MacroTestBase + + +@test_setup +class TestArgument(MacroTestBase): + def test_resolve(self): + self.assertEqual(Variable("a", const=True).get_value(), "a") + self.assertEqual(Variable(1, const=True).get_value(), 1) + self.assertEqual(Variable(None, const=True).get_value(), None) + + # $ is part of a custom string here + self.assertEqual(Variable('"$a"', const=True).get_value(), '"$a"') + self.assertEqual(Variable("'$a'", const=True).get_value(), "'$a'") + self.assertEqual(Variable("$a", const=True).get_value(), "$a") + + variable = Variable("a", const=False) + self.assertEqual(variable.get_value(), None) + macro_variables["a"] = 1 + self.assertEqual(variable.get_value(), 1) + + def test_type_check(self): + def test(value, types, name, position): + argument = Argument( + ArgumentConfig( + types=types, + name=name, + position=position, + ), + DummyMapping(), + ) + argument.initialize_variable(RawValue(value=value)) + return argument.get_value() + + def test_variable(variable, types, name, position): + argument = Argument( + ArgumentConfig( + types=types, + name=name, + position=position, + ), + DummyMapping(), + ) + argument._variable = variable + return argument.get_value() + + # allows params that can be cast to the target type + self.assertEqual(test("1", [str, None], "foo", 0), "1") + self.assertEqual(test("1.2", [str], "foo", 2), "1.2") + + self.assertRaises( + MacroError, + lambda: test("1.2", [int], "foo", 3), + ) + self.assertRaises(MacroError, lambda: test("a", [None], "foo", 0)) + self.assertRaises(MacroError, lambda: test("a", [int], "foo", 1)) + self.assertRaises( + MacroError, + lambda: test("a", [int, float], "foo", 2), + ) + self.assertRaises( + MacroError, + lambda: test("a", [int, None], "foo", 3), + ) + self.assertEqual(test("a", [int, float, None, str], "foo", 4), "a") + + # variables are expected to be of the Variable type here, not a $string + self.assertRaises( + MacroError, + lambda: test("$a", [int], "foo", 4), + ) + + # We don't cast values that were explicitly set as strings back into numbers. + variable = Variable("a", const=False) + variable.set_value("5") + self.assertRaises( + MacroError, + lambda: test_variable(variable, [int], "foo", 4), + ) + + self.assertRaises( + MacroError, + lambda: test("a", [Macro], "foo", 0), + ) + self.assertRaises(MacroError, lambda: test("1", [Macro], "foo", 0)) + + def test_validate_variable_name(self): + self.assertRaises( + MacroError, + lambda: Variable("1a", const=False).validate_variable_name(), + ) + self.assertRaises( + MacroError, + lambda: Variable("$a", const=False).validate_variable_name(), + ) + self.assertRaises( + MacroError, + lambda: Variable("a()", const=False).validate_variable_name(), + ) + self.assertRaises( + MacroError, + lambda: Variable("1", const=False).validate_variable_name(), + ) + self.assertRaises( + MacroError, + lambda: Variable("+", const=False).validate_variable_name(), + ) + self.assertRaises( + MacroError, + lambda: Variable("-", const=False).validate_variable_name(), + ) + self.assertRaises( + MacroError, + lambda: Variable("*", const=False).validate_variable_name(), + ) + self.assertRaises( + MacroError, + lambda: Variable("a,b", const=False).validate_variable_name(), + ) + self.assertRaises( + MacroError, + lambda: Variable("a,b", const=False).validate_variable_name(), + ) + self.assertRaises( + MacroError, + lambda: Variable("#", const=False).validate_variable_name(), + ) + self.assertRaises( + MacroError, + lambda: Variable(1, const=False).validate_variable_name(), + ) + self.assertRaises( + MacroError, + lambda: Variable(None, const=False).validate_variable_name(), + ) + self.assertRaises( + MacroError, + lambda: Variable([], const=False).validate_variable_name(), + ) + self.assertRaises( + MacroError, + lambda: Variable((), const=False).validate_variable_name(), + ) + + # doesn't raise + Variable("a", const=False).validate_variable_name() + Variable("_a", const=False).validate_variable_name() + Variable("_A", const=False).validate_variable_name() + Variable("A", const=False).validate_variable_name() + Variable("Abcd", const=False).validate_variable_name() + Variable("Abcd_", const=False).validate_variable_name() + Variable("Abcd_1234", const=False).validate_variable_name() + Variable("Abcd1234_", const=False).validate_variable_name() + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/test_macros/test_dynamic_types.py b/tests/unit/test_macros/test_dynamic_types.py new file mode 100644 index 0000000..ecd7acd --- /dev/null +++ b/tests/unit/test_macros/test_dynamic_types.py @@ -0,0 +1,154 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + + +import unittest + +from inputremapper.configs.validation_errors import ( + MacroError, +) +from inputremapper.injection.macros.argument import ArgumentConfig +from inputremapper.injection.macros.macro import macro_variables +from inputremapper.injection.macros.parse import Parser +from inputremapper.injection.macros.raw_value import RawValue +from inputremapper.injection.macros.task import Task +from tests.unit.test_macros.macro_test_base import DummyMapping, MacroTestBase + + +class TestDynamicTypes(MacroTestBase): + # "Dynamic" meaning const=False + async def test_set_type_int(self): + await Parser.parse( + "set(a, 1)", + self.context, + DummyMapping, + True, + ).run(lambda *_, **__: None) + self.assertEqual(macro_variables.get("a"), 1) + # assertEqual(1.0, 1) passes, so check for the type to be sure: + self.assertIsInstance(macro_variables.get("a"), int) + + async def test_set_type_float(self): + await Parser.parse( + "set(a, 2.2)", + self.context, + DummyMapping, + True, + ).run(lambda *_, **__: None) + self.assertEqual(macro_variables.get("a"), 2.2) + + async def test_set_type_str(self): + await Parser.parse( + 'set(a, "3")', + self.context, + DummyMapping, + True, + ).run(lambda *_, **__: None) + self.assertEqual(macro_variables.get("a"), "3") + + def make_test_task(self, types): + # Make a new test task, with a different types array each time. + class TestTask(Task): + argument_configs = [ + ArgumentConfig( + name="testvalue", + position=0, + types=types, + ) + ] + + return TestTask( + [RawValue("$a")], + {}, + self.context, + DummyMapping, + ) + + async def test_dynamic_int_parsing(self): + # set(a, 4) was used. Could be meant as an integer, or as a string + # (just like how key(KEY_A) doesn't require string quotes to be a string) + macro_variables["a"] = 4 + + test_task = self.make_test_task([str, int]) + self.assertEqual(test_task.get_argument("testvalue").get_value(), 4) + + test_task = self.make_test_task([int]) + self.assertEqual(test_task.get_argument("testvalue").get_value(), 4) + + # Now that ints are not allowed, it will be used as a string + test_task = self.make_test_task([str]) + self.assertEqual(test_task.get_argument("testvalue").get_value(), "4") + + async def test_dynamic_float_parsing(self): + # set(a, 5.5) was used. + macro_variables["a"] = 5.5 + + test_task = self.make_test_task([str, float]) + self.assertEqual(test_task.get_argument("testvalue").get_value(), 5.5) + + test_task = self.make_test_task([float]) + self.assertEqual(test_task.get_argument("testvalue").get_value(), 5.5) + + test_task = self.make_test_task([str]) + self.assertEqual(test_task.get_argument("testvalue").get_value(), "5.5") + + async def test_no_float_allowed(self): + # set(a, 6.6) was used. + macro_variables["a"] = 6.6 + + test_task = self.make_test_task([str, int]) + self.assertEqual(test_task.get_argument("testvalue").get_value(), "6.6") + + test_task = self.make_test_task([int]) + self.assertRaises( + MacroError, + lambda: test_task.get_argument("testvalue").get_value(), + ) + + async def test_force_string_float(self): + # set(a, "7.7") was used. Since quotes are explicitly added, the variable is + # not intended to be used as a float. + macro_variables["a"] = "7.7" + + test_task = self.make_test_task([str, float]) + self.assertEqual(test_task.get_argument("testvalue").get_value(), "7.7") + + test_task = self.make_test_task([float]) + self.assertRaises( + MacroError, + lambda: test_task.get_argument("testvalue").get_value(), + ) + + async def test_force_string_int(self): + # set(a, "8") was used. + macro_variables["a"] = "8" + + test_task = self.make_test_task([int, str]) + self.assertEqual(test_task.get_argument("testvalue").get_value(), "8") + + test_task = self.make_test_task([int]) + self.assertRaises( + MacroError, + lambda: test_task.get_argument("testvalue").get_value(), + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/test_macros/test_event.py b/tests/unit/test_macros/test_event.py new file mode 100644 index 0000000..4084a94 --- /dev/null +++ b/tests/unit/test_macros/test_event.py @@ -0,0 +1,67 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + + +import unittest + +from evdev.ecodes import ( + EV_REL, + EV_KEY, + REL_X, +) + +from inputremapper.configs.keyboard_layout import keyboard_layout +from inputremapper.injection.macros.parse import Parser +from tests.lib.test_setup import test_setup +from tests.unit.test_macros.macro_test_base import MacroTestBase, DummyMapping + + +@test_setup +class TestEvent(MacroTestBase): + async def test_event_1(self): + macro = Parser.parse("e(EV_KEY, KEY_A, 1)", self.context, DummyMapping) + a_code = keyboard_layout.get("a") + + await macro.run(self.handler) + self.assertListEqual(self.result, [(EV_KEY, a_code, 1)]) + self.assertEqual(self.count_child_macros(macro), 0) + + async def test_event_2(self): + macro = Parser.parse( + "repeat(1, event(type=5421, code=324, value=154))", + self.context, + DummyMapping, + ) + code = 324 + + await macro.run(self.handler) + self.assertListEqual(self.result, [(5421, code, 154)]) + self.assertEqual(self.count_child_macros(macro), 1) + + async def test_event_mouse(self): + macro = Parser.parse("e(EV_REL, REL_X, 10)", self.context, DummyMapping) + + await macro.run(self.handler) + self.assertListEqual(self.result, [(EV_REL, REL_X, 10)]) + self.assertEqual(self.count_child_macros(macro), 0) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/test_macros/test_hold.py b/tests/unit/test_macros/test_hold.py new file mode 100644 index 0000000..37e8aaf --- /dev/null +++ b/tests/unit/test_macros/test_hold.py @@ -0,0 +1,191 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + + +import asyncio +import unittest + +from evdev.ecodes import EV_KEY + +from inputremapper.configs.keyboard_layout import keyboard_layout +from inputremapper.configs.validation_errors import MacroError +from inputremapper.injection.macros.parse import Parser +from tests.lib.test_setup import test_setup +from tests.unit.test_macros.macro_test_base import MacroTestBase, DummyMapping + + +@test_setup +class TestHold(MacroTestBase): + async def test_hold(self): + # repeats key(a) as long as the key is held down + macro = Parser.parse("key(1).hold(key(a)).key(3)", self.context, DummyMapping) + + """down""" + + macro.press_trigger() + await asyncio.sleep(0.05) + self.assertTrue(macro.tasks[1].is_holding()) + + macro.press_trigger() # redundantly calling doesn't break anything + asyncio.ensure_future(macro.run(self.handler)) + await asyncio.sleep(0.2) + self.assertTrue(macro.tasks[1].is_holding()) + self.assertGreater(len(self.result), 2) + + """up""" + + macro.release_trigger() + await asyncio.sleep(0.05) + self.assertFalse(macro.tasks[1].is_holding()) + + self.assertEqual(self.result[0], (EV_KEY, keyboard_layout.get("1"), 1)) + self.assertEqual(self.result[-1], (EV_KEY, keyboard_layout.get("3"), 0)) + + code_a = keyboard_layout.get("a") + self.assertGreater(self.result.count((EV_KEY, code_a, 1)), 2) + + self.assertEqual(self.count_child_macros(macro), 1) + self.assertEqual(self.count_tasks(macro), 4) + + async def test_hold_failing_child(self): + # if a child macro fails, hold will not try to run it again. + # The exception is properly propagated through both `hold`s and the macro + # stops. If the code is broken, this test might enter an infinite loop. + macro = Parser.parse("hold(hold(key(a)))", self.context, DummyMapping) + + class MyException(Exception): + pass + + def f(*_): + raise MyException("foo") + + macro.press_trigger() + with self.assertRaises(MyException): + await macro.run(f) + + await asyncio.sleep(0.1) + self.assertFalse(macro.running) + + async def test_dont_hold(self): + macro = Parser.parse("key(1).hold(key(a)).key(3)", self.context, DummyMapping) + + asyncio.ensure_future(macro.run(self.handler)) + await asyncio.sleep(0.2) + self.assertFalse(macro.tasks[1].is_holding()) + # press_trigger was never called, so the macro completes right away + # and the child macro of hold is never called. + self.assertEqual(len(self.result), 4) + + self.assertEqual(self.result[0], (EV_KEY, keyboard_layout.get("1"), 1)) + self.assertEqual(self.result[-1], (EV_KEY, keyboard_layout.get("3"), 0)) + + self.assertEqual(self.count_child_macros(macro), 1) + self.assertEqual(self.count_tasks(macro), 4) + + async def test_just_hold(self): + macro = Parser.parse("key(1).hold().key(3)", self.context, DummyMapping) + + """down""" + + macro.press_trigger() + asyncio.ensure_future(macro.run(self.handler)) + await asyncio.sleep(0.1) + self.assertTrue(macro.tasks[1].is_holding()) + self.assertEqual(len(self.result), 2) + await asyncio.sleep(0.1) + # doesn't do fancy stuff, is blocking until the release + self.assertEqual(len(self.result), 2) + + """up""" + + macro.release_trigger() + await asyncio.sleep(0.05) + self.assertFalse(macro.tasks[1].is_holding()) + self.assertEqual(len(self.result), 4) + + self.assertEqual(self.result[0], (EV_KEY, keyboard_layout.get("1"), 1)) + self.assertEqual(self.result[-1], (EV_KEY, keyboard_layout.get("3"), 0)) + + self.assertEqual(self.count_child_macros(macro), 0) + self.assertEqual(self.count_tasks(macro), 3) + + async def test_dont_just_hold(self): + macro = Parser.parse("key(1).hold().key(3)", self.context, DummyMapping) + + asyncio.ensure_future(macro.run(self.handler)) + await asyncio.sleep(0.1) + self.assertFalse(macro.tasks[1].is_holding()) + # since press_trigger was never called it just does the macro + # completely + self.assertEqual(len(self.result), 4) + + self.assertEqual(self.result[0], (EV_KEY, keyboard_layout.get("1"), 1)) + self.assertEqual(self.result[-1], (EV_KEY, keyboard_layout.get("3"), 0)) + + self.assertEqual(self.count_child_macros(macro), 0) + + async def test_hold_down(self): + # writes down and waits for the up event until the key is released + macro = Parser.parse("hold(a)", self.context, DummyMapping) + self.assertEqual(self.count_child_macros(macro), 0) + + """down""" + + macro.press_trigger() + await asyncio.sleep(0.05) + self.assertTrue(macro.tasks[0].is_holding()) + + asyncio.ensure_future(macro.run(self.handler)) + macro.press_trigger() # redundantly calling doesn't break anything + await asyncio.sleep(0.2) + self.assertTrue(macro.tasks[0].is_holding()) + self.assertEqual(len(self.result), 1) + self.assertEqual(self.result[0], (EV_KEY, keyboard_layout.get("a"), 1)) + + """up""" + + macro.release_trigger() + await asyncio.sleep(0.05) + self.assertFalse(macro.tasks[0].is_holding()) + + self.assertEqual(len(self.result), 2) + self.assertEqual(self.result[0], (EV_KEY, keyboard_layout.get("a"), 1)) + self.assertEqual(self.result[1], (EV_KEY, keyboard_layout.get("a"), 0)) + + async def test_hold_variable(self): + code_a = keyboard_layout.get("a") + macro = Parser.parse("set(foo, a).hold($foo)", self.context, DummyMapping) + await macro.run(self.handler) + self.assertListEqual( + self.result, + [ + (EV_KEY, code_a, 1), + (EV_KEY, code_a, 0), + ], + ) + + async def test_raises_error(self): + self.assertRaises(MacroError, Parser.parse, "h(1, 1)", self.context) + self.assertRaises(MacroError, Parser.parse, "h(hold(h(1, 1)))", self.context) + self.assertRaises(MacroError, Parser.parse, "hold(key(a)key(b))", self.context) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/test_macros/test_hold_keys.py b/tests/unit/test_macros/test_hold_keys.py new file mode 100644 index 0000000..18ca0e4 --- /dev/null +++ b/tests/unit/test_macros/test_hold_keys.py @@ -0,0 +1,130 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + + +import asyncio +import unittest + +from evdev.ecodes import EV_KEY + +from inputremapper.configs.keyboard_layout import keyboard_layout +from inputremapper.configs.validation_errors import MacroError +from inputremapper.injection.macros.parse import Parser +from tests.lib.test_setup import test_setup +from tests.unit.test_macros.macro_test_base import MacroTestBase, DummyMapping + + +@test_setup +class TestHoldKeys(MacroTestBase): + async def test_hold_keys(self): + macro = Parser.parse( + "set(foo, b).hold_keys(a, $foo, c)", self.context, DummyMapping + ) + # press first + macro.press_trigger() + # then run, just like how it is going to happen during runtime + asyncio.ensure_future(macro.run(self.handler)) + + code_a = keyboard_layout.get("a") + code_b = keyboard_layout.get("b") + code_c = keyboard_layout.get("c") + + await asyncio.sleep(0.2) + self.assertListEqual( + self.result, + [ + (EV_KEY, code_a, 1), + (EV_KEY, code_b, 1), + (EV_KEY, code_c, 1), + ], + ) + + macro.release_trigger() + + await asyncio.sleep(0.2) + self.assertListEqual( + self.result, + [ + (EV_KEY, code_a, 1), + (EV_KEY, code_b, 1), + (EV_KEY, code_c, 1), + (EV_KEY, code_c, 0), + (EV_KEY, code_b, 0), + (EV_KEY, code_a, 0), + ], + ) + + async def test_hold_keys_broken(self): + # Won't run any of the keys when one of them is invalid + macro = Parser.parse( + "set(foo, broken).hold_keys(a, $foo, c)", self.context, DummyMapping + ) + # press first + macro.press_trigger() + # then run, just like how it is going to happen during runtime + asyncio.ensure_future(macro.run(self.handler)) + await asyncio.sleep(0.2) + self.assertListEqual(self.result, []) + macro.release_trigger() + await asyncio.sleep(0.2) + self.assertListEqual(self.result, []) + + async def test_aldjfakl(self): + repeats = 5 + + macro = Parser.parse( + f"repeat({repeats}, key(k))", + self.context, + DummyMapping, + ) + + self.assertEqual(self.count_child_macros(macro), 1) + + async def test_run_plus_syntax(self): + macro = Parser.parse("a + b + c + d", self.context, DummyMapping) + + macro.press_trigger() + asyncio.ensure_future(macro.run(self.handler)) + await asyncio.sleep(0.2) + self.assertTrue(macro.tasks[0].is_holding()) + + # starting from the left, presses each one down + self.assertEqual(self.result[0], (EV_KEY, keyboard_layout.get("a"), 1)) + self.assertEqual(self.result[1], (EV_KEY, keyboard_layout.get("b"), 1)) + self.assertEqual(self.result[2], (EV_KEY, keyboard_layout.get("c"), 1)) + self.assertEqual(self.result[3], (EV_KEY, keyboard_layout.get("d"), 1)) + + # and then releases starting with the previously pressed key + macro.release_trigger() + await asyncio.sleep(0.2) + self.assertFalse(macro.tasks[0].is_holding()) + self.assertEqual(self.result[4], (EV_KEY, keyboard_layout.get("d"), 0)) + self.assertEqual(self.result[5], (EV_KEY, keyboard_layout.get("c"), 0)) + self.assertEqual(self.result[6], (EV_KEY, keyboard_layout.get("b"), 0)) + self.assertEqual(self.result[7], (EV_KEY, keyboard_layout.get("a"), 0)) + + async def test_raises_error(self): + self.assertRaises( + MacroError, Parser.parse, "hold_keys(a, broken, b)", self.context + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/test_macros/test_if_eq.py b/tests/unit/test_macros/test_if_eq.py new file mode 100644 index 0000000..bd8b036 --- /dev/null +++ b/tests/unit/test_macros/test_if_eq.py @@ -0,0 +1,215 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + + +import asyncio +import multiprocessing +import unittest + +from evdev.ecodes import ( + EV_KEY, +) + +from inputremapper.configs.keyboard_layout import keyboard_layout +from inputremapper.configs.validation_errors import MacroError +from inputremapper.injection.macros.macro import macro_variables +from inputremapper.injection.macros.parse import Parser +from tests.lib.logger import logger +from tests.lib.test_setup import test_setup +from tests.unit.test_macros.macro_test_base import DummyMapping, MacroTestBase + + +@test_setup +class TestIfEq(MacroTestBase): + async def test_if_eq(self): + """new version of ifeq""" + code_a = keyboard_layout.get("a") + code_b = keyboard_layout.get("b") + a_press = [(EV_KEY, code_a, 1), (EV_KEY, code_a, 0)] + b_press = [(EV_KEY, code_b, 1), (EV_KEY, code_b, 0)] + + async def test(macro, expected): + """Run the macro and compare the injections with an expectation.""" + logger.info("Testing %s", macro) + # cleanup + macro_variables._clear() + self.assertIsNone(macro_variables.get("a")) + self.result.clear() + + # test + macro = Parser.parse(macro, self.context, DummyMapping) + await macro.run(self.handler) + self.assertListEqual(self.result, expected) + + await test("if_eq(1, 1, key(a), key(b))", a_press) + await test("if_eq(1, 2, key(a), key(b))", b_press) + await test("if_eq(value_1=1, value_2=1, then=key(a), else=key(b))", a_press) + await test('set(a, "foo").if_eq($a, "foo", key(a), key(b))', a_press) + await test('set(a, "foo").if_eq("foo", $a, key(a), key(b))', a_press) + await test('set(a, "foo").if_eq("foo", $a, , key(b))', []) + await test('set(a, "foo").if_eq("foo", $a, None, key(b))', []) + await test('set(a, "qux").if_eq("foo", $a, key(a), key(b))', b_press) + await test('set(a, "qux").if_eq($a, "foo", key(a), key(b))', b_press) + await test('set(a, "qux").if_eq($a, "foo", key(a), )', []) + await test('set(a, "x").set(b, "y").if_eq($b, $a, key(a), key(b))', b_press) + await test('set(a, "x").set(b, "y").if_eq($b, $a, key(a), )', []) + await test('set(a, "x").set(b, "y").if_eq($b, $a, key(a), None)', []) + await test('set(a, "x").set(b, "y").if_eq($b, $a, key(a), else=None)', []) + await test('set(a, "x").set(b, "x").if_eq($b, $a, key(a), key(b))', a_press) + await test('set(a, "x").set(b, "x").if_eq($b, $a, , key(b))', []) + await test("if_eq($q, $w, key(a), else=key(b))", a_press) # both None + await test("set(q, 1).if_eq($q, $w, key(a), else=key(b))", b_press) + await test("set(q, 1).set(w, 1).if_eq($q, $w, key(a), else=key(b))", a_press) + await test('set(q, " a b ").if_eq($q, " a b ", key(a), key(b))', a_press) + await test('if_eq("\t", "\n", key(a), key(b))', b_press) + + # treats values in quotes as strings, not as code + await test('set(q, "$a").if_eq($q, "$a", key(a), key(b))', a_press) + await test('set(q, "a,b").if_eq("a,b", $q, key(a), key(b))', a_press) + await test('set(q, "c(1, 2)").if_eq("c(1, 2)", $q, key(a), key(b))', a_press) + await test('set(q, "c(1, 2)").if_eq("c(1, 2)", "$q", key(a), key(b))', b_press) + await test('if_eq("value_1=1", 1, key(a), key(b))', b_press) + + # won't compare strings and int, be similar to python + await test('set(a, "1").if_eq($a, 1, key(a), key(b))', b_press) + await test('set(a, 1).if_eq($a, "1", key(a), key(b))', b_press) + + async def test_if_eq_runs_multiprocessed(self): + """ifeq on variables that have been set in other processes works.""" + macro = Parser.parse( + "if_eq($foo, 3, key(a), key(b))", self.context, DummyMapping + ) + code_a = keyboard_layout.get("a") + code_b = keyboard_layout.get("b") + + self.assertEqual(self.count_child_macros(macro), 2) + + def set_foo(value): + # will write foo = 2 into the shared dictionary of macros + macro_2 = Parser.parse(f"set(foo, {value})", self.context, DummyMapping) + loop = asyncio.new_event_loop() + loop.run_until_complete(macro_2.run(lambda: None)) + + """foo is not 3""" + + process = multiprocessing.Process(target=set_foo, args=(2,)) + process.start() + process.join() + await macro.run(self.handler) + self.assertListEqual(self.result, [(EV_KEY, code_b, 1), (EV_KEY, code_b, 0)]) + + """foo is 3""" + + process = multiprocessing.Process(target=set_foo, args=(3,)) + process.start() + process.join() + await macro.run(self.handler) + self.assertListEqual( + self.result, + [ + (EV_KEY, code_b, 1), + (EV_KEY, code_b, 0), + (EV_KEY, code_a, 1), + (EV_KEY, code_a, 0), + ], + ) + + async def test_raises_error(self): + Parser.parse("if_eq(2, $a, k(a),)", self.context) # no error + Parser.parse("if_eq(2, $a, , else=k(a))", self.context) # no error + self.assertRaises(MacroError, Parser.parse, "if_eq(2, $a, 1,)", self.context) + self.assertRaises(MacroError, Parser.parse, "if_eq(2, $a, , 2)", self.context) + self.expect_string_in_error("blub", "if_eq(2, $a, key(a), blub=a)") + + +class TestIfEqDeprecated(MacroTestBase): + async def test_ifeq_runs(self): + # deprecated ifeq function, but kept for compatibility reasons + macro = Parser.parse( + "set(foo, 2).ifeq(foo, 2, key(a), key(b))", + self.context, + DummyMapping, + ) + code_a = keyboard_layout.get("a") + code_b = keyboard_layout.get("b") + + await macro.run(self.handler) + self.assertListEqual(self.result, [(EV_KEY, code_a, 1), (EV_KEY, code_a, 0)]) + self.assertEqual(self.count_child_macros(macro), 2) + + async def test_ifeq_none(self): + code_a = keyboard_layout.get("a") + + # first param None + macro = Parser.parse( + "set(foo, 2).ifeq(foo, 2, None, key(b))", self.context, DummyMapping + ) + self.assertEqual(self.count_child_macros(macro), 1) + await macro.run(self.handler) + self.assertListEqual(self.result, []) + + # second param None + self.result = [] + macro = Parser.parse( + "set(foo, 2).ifeq(foo, 2, key(a), None)", self.context, DummyMapping + ) + self.assertEqual(self.count_child_macros(macro), 1) + await macro.run(self.handler) + self.assertListEqual(self.result, [(EV_KEY, code_a, 1), (EV_KEY, code_a, 0)]) + + """Old syntax, use None instead""" + + # first param "" + self.result = [] + macro = Parser.parse( + "set(foo, 2).ifeq(foo, 2, , key(b))", self.context, DummyMapping + ) + self.assertEqual(self.count_child_macros(macro), 1) + await macro.run(self.handler) + self.assertListEqual(self.result, []) + + # second param "" + self.result = [] + macro = Parser.parse( + "set(foo, 2).ifeq(foo, 2, key(a), )", self.context, DummyMapping + ) + self.assertEqual(self.count_child_macros(macro), 1) + await macro.run(self.handler) + self.assertListEqual(self.result, [(EV_KEY, code_a, 1), (EV_KEY, code_a, 0)]) + + async def test_ifeq_unknown_key(self): + macro = Parser.parse("ifeq(qux, 2, key(a), key(b))", self.context, DummyMapping) + code_a = keyboard_layout.get("a") + code_b = keyboard_layout.get("b") + + await macro.run(self.handler) + self.assertListEqual(self.result, [(EV_KEY, code_b, 1), (EV_KEY, code_b, 0)]) + self.assertEqual(self.count_child_macros(macro), 2) + + async def test_raises_error(self): + Parser.parse("ifeq(a, 2, k(a),)", self.context) # no error + Parser.parse("ifeq(a, 2, , k(a))", self.context) # no error + Parser.parse("ifeq(a, 2, None, k(a))", self.context) # no error + self.assertRaises(MacroError, Parser.parse, "ifeq(a, 2, 1,)", self.context) + self.assertRaises(MacroError, Parser.parse, "ifeq(a, 2, , 2)", self.context) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/test_macros/test_if_single.py b/tests/unit/test_macros/test_if_single.py new file mode 100644 index 0000000..f439e2f --- /dev/null +++ b/tests/unit/test_macros/test_if_single.py @@ -0,0 +1,192 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + + +import asyncio +import unittest + +from evdev.ecodes import ( + EV_KEY, + ABS_Y, +) + +from inputremapper.configs.keyboard_layout import keyboard_layout +from inputremapper.configs.validation_errors import MacroError +from inputremapper.injection.macros.parse import Parser +from inputremapper.input_event import InputEvent +from tests.lib.test_setup import test_setup +from tests.unit.test_macros.macro_test_base import DummyMapping, MacroTestBase + + +@test_setup +class TestIfSingle(MacroTestBase): + async def test_if_single(self): + macro = Parser.parse("if_single(key(x), key(y))", self.context, DummyMapping) + self.assertEqual(self.count_child_macros(macro), 2) + + a = keyboard_layout.get("a") + x = keyboard_layout.get("x") + + await self.trigger_sequence(macro, InputEvent.key(a, 1)) + await asyncio.sleep(0.1) + await self.release_sequence(macro, InputEvent.key(a, 0)) + # the key that triggered the macro is released + await asyncio.sleep(0.1) + + self.assertListEqual(self.result, [(EV_KEY, x, 1), (EV_KEY, x, 0)]) + self.assertFalse(macro.running) + + async def test_if_single_ignores_releases(self): + # the timeout won't break the macro, everything happens well within that + # timeframe. + macro = Parser.parse( + "if_single(key(x), else=key(y), timeout=100000)", + self.context, + DummyMapping, + ) + self.assertEqual(self.count_child_macros(macro), 2) + + a = keyboard_layout.get("a") + b = keyboard_layout.get("b") + + x = keyboard_layout.get("x") + y = keyboard_layout.get("y") + + # pressing the macro key + await self.trigger_sequence(macro, InputEvent.key(a, 1)) + await asyncio.sleep(0.05) + + # if_single only looks out for newly pressed keys, + # it doesn't care if keys were released that have been + # pressed before if_single. This was decided because it is a lot + # less tricky and more fluently to use if you type fast + for listener in self.context.listeners: + asyncio.ensure_future(listener(InputEvent.key(b, 0))) + await asyncio.sleep(0.05) + self.assertListEqual(self.result, []) + + # releasing the actual key triggers if_single + await asyncio.sleep(0.05) + await self.release_sequence(macro, InputEvent.key(a, 0)) + await asyncio.sleep(0.05) + self.assertListEqual(self.result, [(EV_KEY, x, 1), (EV_KEY, x, 0)]) + self.assertFalse(macro.running) + + async def test_if_not_single(self): + # Will run the `else` macro if another key is pressed. + # Also works if if_single is a child macro, i.e. the event is passed to it + # from the outside macro correctly. + macro = Parser.parse( + "repeat(1, if_single(then=key(x), else=key(y)))", + self.context, + DummyMapping, + ) + self.assertEqual(self.count_child_macros(macro), 3) + self.assertEqual(self.count_tasks(macro), 4) + + a = keyboard_layout.get("a") + b = keyboard_layout.get("b") + + x = keyboard_layout.get("x") + y = keyboard_layout.get("y") + + # press the trigger key + await self.trigger_sequence(macro, InputEvent.key(a, 1)) + await asyncio.sleep(0.1) + # press another key + for listener in self.context.listeners: + asyncio.ensure_future(listener(InputEvent.key(b, 1))) + await asyncio.sleep(0.1) + + self.assertListEqual(self.result, [(EV_KEY, y, 1), (EV_KEY, y, 0)]) + self.assertFalse(macro.running) + + async def test_if_not_single_none(self): + macro = Parser.parse("if_single(key(x),)", self.context, DummyMapping) + self.assertEqual(self.count_child_macros(macro), 1) + + a = keyboard_layout.get("a") + b = keyboard_layout.get("b") + + x = keyboard_layout.get("x") + + # press trigger key + await self.trigger_sequence(macro, InputEvent.key(a, 1)) + await asyncio.sleep(0.1) + # press another key + for listener in self.context.listeners: + asyncio.ensure_future(listener(InputEvent.key(b, 1))) + await asyncio.sleep(0.1) + + self.assertListEqual(self.result, []) + self.assertFalse(macro.running) + + async def test_if_single_times_out(self): + macro = Parser.parse( + "set(t, 300).if_single(key(x), key(y), timeout=$t)", + self.context, + DummyMapping, + ) + self.assertEqual(self.count_child_macros(macro), 2) + + a = keyboard_layout.get("a") + y = keyboard_layout.get("y") + + await self.trigger_sequence(macro, InputEvent.key(a, 1)) + + # no timeout yet + await asyncio.sleep(0.2) + self.assertListEqual(self.result, []) + self.assertTrue(macro.running) + + # times out now + await asyncio.sleep(0.2) + self.assertListEqual(self.result, [(EV_KEY, y, 1), (EV_KEY, y, 0)]) + self.assertFalse(macro.running) + + async def test_if_single_ignores_joystick(self): + """Triggers else + delayed_handle_keycode.""" + # Integration test style for if_single. + # If a joystick that is mapped to a button is moved, if_single stops + macro = Parser.parse( + "if_single(k(a), k(KEY_LEFTSHIFT))", self.context, DummyMapping + ) + code_shift = keyboard_layout.get("KEY_LEFTSHIFT") + code_a = keyboard_layout.get("a") + trigger = 1 + + await self.trigger_sequence(macro, InputEvent.key(trigger, 1)) + await asyncio.sleep(0.1) + for listener in self.context.listeners: + asyncio.ensure_future(listener(InputEvent.abs(ABS_Y, 10))) + await asyncio.sleep(0.1) + await self.release_sequence(macro, InputEvent.key(trigger, 0)) + await asyncio.sleep(0.1) + self.assertFalse(macro.running) + self.assertListEqual(self.result, [(EV_KEY, code_a, 1), (EV_KEY, code_a, 0)]) + + async def test_raises_error(self): + Parser.parse("if_single(k(a),)", self.context) # no error + self.assertRaises(MacroError, Parser.parse, "if_single(1,)", self.context) + self.assertRaises(MacroError, Parser.parse, "if_single(,1)", self.context) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/test_macros/test_if_tap.py b/tests/unit/test_macros/test_if_tap.py new file mode 100644 index 0000000..92330ab --- /dev/null +++ b/tests/unit/test_macros/test_if_tap.py @@ -0,0 +1,190 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + + +import asyncio +import unittest + +from evdev.ecodes import ( + EV_KEY, + KEY_A, + KEY_B, +) + +from inputremapper.configs.keyboard_layout import keyboard_layout +from inputremapper.configs.validation_errors import MacroError +from inputremapper.injection.macros.parse import Parser +from tests.lib.test_setup import test_setup +from tests.unit.test_macros.macro_test_base import DummyMapping, MacroTestBase + + +@test_setup +class TestIfTap(MacroTestBase): + async def test_if_tap(self): + macro = Parser.parse("if_tap(key(x), key(y), 100)", self.context, DummyMapping) + self.assertEqual(self.count_child_macros(macro), 2) + + x = keyboard_layout.get("x") + y = keyboard_layout.get("y") + + # this is the regular routine of how a macro is started. the tigger is pressed + # already when the macro runs, and released during if_tap within the timeout. + macro.press_trigger() + asyncio.ensure_future(macro.run(self.handler)) + await asyncio.sleep(0.05) + macro.release_trigger() + await asyncio.sleep(0.05) + + self.assertListEqual(self.result, [(EV_KEY, x, 1), (EV_KEY, x, 0)]) + self.assertFalse(macro.running) + + async def test_if_tap_2(self): + # when the press arrives shortly after run. + # a tap will happen within the timeout even if the tigger is not pressed when + # it does into if_tap + macro = Parser.parse("if_tap(key(a), key(b), 100)", self.context, DummyMapping) + asyncio.ensure_future(macro.run(self.handler)) + + await asyncio.sleep(0.01) + macro.press_trigger() + await asyncio.sleep(0.01) + macro.release_trigger() + await asyncio.sleep(0.2) + self.assertListEqual(self.result, [(EV_KEY, KEY_A, 1), (EV_KEY, KEY_A, 0)]) + self.assertFalse(macro.running) + self.result.clear() + + async def test_if_double_tap(self): + macro = Parser.parse( + "if_tap(if_tap(key(a), key(b), 100), key(c), 100)", + self.context, + DummyMapping, + ) + self.assertEqual(self.count_child_macros(macro), 4) + self.assertEqual(self.count_tasks(macro), 5) + + asyncio.ensure_future(macro.run(self.handler)) + + # first tap + macro.press_trigger() + await asyncio.sleep(0.05) + macro.release_trigger() + + # second tap + await asyncio.sleep(0.04) + macro.press_trigger() + await asyncio.sleep(0.04) + macro.release_trigger() + + await asyncio.sleep(0.05) + self.assertListEqual(self.result, [(EV_KEY, KEY_A, 1), (EV_KEY, KEY_A, 0)]) + self.assertFalse(macro.running) + self.result.clear() + + """If the second tap takes too long, runs else there""" + + asyncio.ensure_future(macro.run(self.handler)) + + # first tap + macro.press_trigger() + await asyncio.sleep(0.05) + macro.release_trigger() + + # second tap + await asyncio.sleep(0.06) + macro.press_trigger() + await asyncio.sleep(0.06) + macro.release_trigger() + + await asyncio.sleep(0.05) + self.assertListEqual(self.result, [(EV_KEY, KEY_B, 1), (EV_KEY, KEY_B, 0)]) + self.assertFalse(macro.running) + self.result.clear() + + async def test_if_tap_none(self): + # first param none + macro = Parser.parse("if_tap(, key(y), 100)", self.context, DummyMapping) + self.assertEqual(self.count_child_macros(macro), 1) + y = keyboard_layout.get("y") + macro.press_trigger() + asyncio.ensure_future(macro.run(self.handler)) + await asyncio.sleep(0.05) + macro.release_trigger() + await asyncio.sleep(0.05) + self.assertListEqual(self.result, []) + + # second param none + macro = Parser.parse("if_tap(key(y), , 50)", self.context, DummyMapping) + self.assertEqual(self.count_child_macros(macro), 1) + y = keyboard_layout.get("y") + macro.press_trigger() + asyncio.ensure_future(macro.run(self.handler)) + await asyncio.sleep(0.1) + macro.release_trigger() + await asyncio.sleep(0.05) + self.assertListEqual(self.result, []) + + self.assertFalse(macro.running) + + async def test_if_not_tap(self): + macro = Parser.parse("if_tap(key(x), key(y), 50)", self.context, DummyMapping) + self.assertEqual(self.count_child_macros(macro), 2) + + y = keyboard_layout.get("y") + + macro.press_trigger() + asyncio.ensure_future(macro.run(self.handler)) + await asyncio.sleep(0.1) + macro.release_trigger() + await asyncio.sleep(0.05) + + self.assertListEqual(self.result, [(EV_KEY, y, 1), (EV_KEY, y, 0)]) + self.assertFalse(macro.running) + + async def test_if_not_tap_named(self): + macro = Parser.parse( + "if_tap(key(x), key(y), timeout=50)", self.context, DummyMapping + ) + self.assertEqual(self.count_child_macros(macro), 2) + + x = keyboard_layout.get("x") + y = keyboard_layout.get("y") + + macro.press_trigger() + asyncio.ensure_future(macro.run(self.handler)) + await asyncio.sleep(0.1) + macro.release_trigger() + await asyncio.sleep(0.05) + + self.assertListEqual(self.result, [(EV_KEY, y, 1), (EV_KEY, y, 0)]) + self.assertFalse(macro.running) + + async def test_raises_error(self): + Parser.parse("if_tap(, k(a), 1000)", self.context) # no error + Parser.parse("if_tap(, k(a), timeout=1000)", self.context) # no error + Parser.parse("if_tap(, k(a), $timeout)", self.context) # no error + Parser.parse("if_tap(, k(a), timeout=$t)", self.context) # no error + Parser.parse("if_tap(, key(a))", self.context) # no error + Parser.parse("if_tap(k(a),)", self.context) # no error + self.assertRaises(MacroError, Parser.parse, "if_tap(k(a), b)", self.context) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/test_macros/test_key.py b/tests/unit/test_macros/test_key.py new file mode 100644 index 0000000..8504d08 --- /dev/null +++ b/tests/unit/test_macros/test_key.py @@ -0,0 +1,123 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + + +import unittest + +from evdev.ecodes import EV_KEY + +from inputremapper.configs.keyboard_layout import keyboard_layout +from inputremapper.configs.validation_errors import ( + MacroError, + SymbolNotAvailableInTargetError, +) +from inputremapper.injection.macros.parse import Parser +from tests.lib.test_setup import test_setup +from tests.unit.test_macros.macro_test_base import DummyMapping, MacroTestBase + + +@test_setup +class TestKey(MacroTestBase): + async def test_1(self): + macro = Parser.parse("key(1)", self.context, DummyMapping, True) + one_code = keyboard_layout.get("1") + + await macro.run(self.handler) + self.assertListEqual( + self.result, + [(EV_KEY, one_code, 1), (EV_KEY, one_code, 0)], + ) + self.assertEqual(self.count_child_macros(macro), 0) + + async def test_named_parameter(self): + macro = Parser.parse("key(symbol=1)", self.context, DummyMapping, True) + one_code = keyboard_layout.get("1") + + await macro.run(self.handler) + self.assertListEqual( + self.result, + [(EV_KEY, one_code, 1), (EV_KEY, one_code, 0)], + ) + self.assertEqual(self.count_child_macros(macro), 0) + + async def test_2(self): + macro = Parser.parse('key(1).key("KEY_A").key(3)', self.context, DummyMapping) + + await macro.run(self.handler) + self.assertListEqual( + self.result, + [ + (EV_KEY, keyboard_layout.get("1"), 1), + (EV_KEY, keyboard_layout.get("1"), 0), + (EV_KEY, keyboard_layout.get("a"), 1), + (EV_KEY, keyboard_layout.get("a"), 0), + (EV_KEY, keyboard_layout.get("3"), 1), + (EV_KEY, keyboard_layout.get("3"), 0), + ], + ) + self.assertEqual(self.count_child_macros(macro), 0) + + async def test_key_down_up(self): + code_a = keyboard_layout.get("a") + code_b = keyboard_layout.get("b") + macro = Parser.parse( + "set(foo, b).key_down($foo).key_up($foo).key_up(a).key_down(a)", + self.context, + DummyMapping, + ) + await macro.run(self.handler) + self.assertListEqual( + self.result, + [ + (EV_KEY, code_b, 1), + (EV_KEY, code_b, 0), + (EV_KEY, code_a, 0), + (EV_KEY, code_a, 1), + ], + ) + + async def test_raises_error(self): + Parser.parse("k(1).h(k(a)).k(3)", self.context) # No error + self.expect_string_in_error("bracket", "key((1)") + self.expect_string_in_error("bracket", "k(1))") + self.assertRaises(MacroError, Parser.parse, "k((1).k)", self.context) + self.assertRaises(MacroError, Parser.parse, "key(foo=a)", self.context) + self.assertRaises( + MacroError, Parser.parse, "key(symbol=a, foo=b)", self.context + ) + self.assertRaises(MacroError, Parser.parse, "k()", self.context) + self.assertRaises(MacroError, Parser.parse, "key(invalidkey)", self.context) + self.assertRaises(MacroError, Parser.parse, 'key("invalidkey")', self.context) + Parser.parse("key(1)", self.context) # no error + self.assertRaises(MacroError, Parser.parse, "k(1, 1)", self.context) + Parser.parse("key($a)", self.context) # no error + self.assertRaises(MacroError, Parser.parse, "key(a)key(b)", self.context) + # wrong target for BTN_A + self.assertRaises( + SymbolNotAvailableInTargetError, + Parser.parse, + "key(BTN_A)", + self.context, + DummyMapping, + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/test_macros/test_leds.py b/tests/unit/test_macros/test_leds.py new file mode 100644 index 0000000..ec99edd --- /dev/null +++ b/tests/unit/test_macros/test_leds.py @@ -0,0 +1,124 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + + +import unittest +from unittest.mock import patch + +from evdev.ecodes import ( + EV_KEY, + KEY_1, + KEY_2, + LED_CAPSL, + LED_NUML, +) + +from inputremapper.injection.macros.parse import Parser +from tests.lib.test_setup import test_setup +from tests.unit.test_macros.macro_test_base import DummyMapping, MacroTestBase + + +@test_setup +class TestLeds(MacroTestBase): + async def test_if_capslock(self): + macro = Parser.parse( + "if_capslock(key(KEY_1), key(KEY_2))", + self.context, + DummyMapping, + True, + ) + self.assertEqual(self.count_child_macros(macro), 2) + + with patch.object(self.source_device, "leds", side_effect=lambda: [LED_NUML]): + await macro.run(self.handler) + self.assertListEqual(self.result, [(EV_KEY, KEY_2, 1), (EV_KEY, KEY_2, 0)]) + + with patch.object(self.source_device, "leds", side_effect=lambda: [LED_CAPSL]): + self.result = [] + await macro.run(self.handler) + self.assertListEqual(self.result, [(EV_KEY, KEY_1, 1), (EV_KEY, KEY_1, 0)]) + + async def test_if_numlock(self): + macro = Parser.parse( + "if_numlock(key(KEY_1), key(KEY_2))", + self.context, + DummyMapping, + True, + ) + self.assertEqual(self.count_child_macros(macro), 2) + + with patch.object(self.source_device, "leds", side_effect=lambda: [LED_NUML]): + await macro.run(self.handler) + self.assertListEqual(self.result, [(EV_KEY, KEY_1, 1), (EV_KEY, KEY_1, 0)]) + + with patch.object(self.source_device, "leds", side_effect=lambda: [LED_CAPSL]): + self.result = [] + await macro.run(self.handler) + self.assertListEqual(self.result, [(EV_KEY, KEY_2, 1), (EV_KEY, KEY_2, 0)]) + + async def test_if_numlock_no_else(self): + macro = Parser.parse( + "if_numlock(key(KEY_1))", + self.context, + DummyMapping, + True, + ) + self.assertEqual(self.count_child_macros(macro), 1) + + with patch.object(self.source_device, "leds", side_effect=lambda: [LED_CAPSL]): + await macro.run(self.handler) + self.assertListEqual(self.result, []) + + with patch.object(self.source_device, "leds", side_effect=lambda: [LED_NUML]): + self.result = [] + await macro.run(self.handler) + self.assertListEqual(self.result, [(EV_KEY, KEY_1, 1), (EV_KEY, KEY_1, 0)]) + + async def test_if_capslock_no_then(self): + macro = Parser.parse( + "if_capslock(None, key(KEY_1))", + self.context, + DummyMapping, + True, + ) + self.assertEqual(self.count_child_macros(macro), 1) + + with patch.object(self.source_device, "leds", side_effect=lambda: [LED_CAPSL]): + await macro.run(self.handler) + self.assertListEqual(self.result, []) + + with patch.object(self.source_device, "leds", side_effect=lambda: [LED_NUML]): + self.result = [] + await macro.run(self.handler) + self.assertListEqual(self.result, [(EV_KEY, KEY_1, 1), (EV_KEY, KEY_1, 0)]) + + async def test_raises_error(self): + Parser.parse("if_capslock(else=key(KEY_A))", self.context) # no error + Parser.parse("if_capslock(key(KEY_A), None)", self.context) # no error + Parser.parse("if_capslock(key(KEY_A))", self.context) # no error + Parser.parse("if_capslock(then=key(KEY_A))", self.context) # no error + Parser.parse("if_numlock(else=key(KEY_A))", self.context) # no error + Parser.parse("if_numlock(key(KEY_A), None)", self.context) # no error + Parser.parse("if_numlock(key(KEY_A))", self.context) # no error + Parser.parse("if_numlock(then=key(KEY_A))", self.context) # no error + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/test_macros/test_macros.py b/tests/unit/test_macros/test_macros.py new file mode 100644 index 0000000..d7a98f8 --- /dev/null +++ b/tests/unit/test_macros/test_macros.py @@ -0,0 +1,167 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + + +import asyncio +import time +import unittest + +from evdev.ecodes import ( + EV_KEY, +) + +from inputremapper.configs.keyboard_layout import keyboard_layout +from inputremapper.injection.macros.macro import Macro +from inputremapper.injection.macros.parse import Parser +from tests.lib.test_setup import test_setup +from tests.unit.test_macros.macro_test_base import MacroTestBase, DummyMapping + + +@test_setup +class TestMacros(MacroTestBase): + async def test_newlines(self): + macro = Parser.parse( + " repeat(2,\nkey(\nr ).key(minus\n )).key(m) ", + self.context, + DummyMapping, + ) + + r = keyboard_layout.get("r") + minus = keyboard_layout.get("minus") + m = keyboard_layout.get("m") + + await macro.run(self.handler) + self.assertListEqual( + self.result, + [ + (EV_KEY, r, 1), + (EV_KEY, r, 0), + (EV_KEY, minus, 1), + (EV_KEY, minus, 0), + (EV_KEY, r, 1), + (EV_KEY, r, 0), + (EV_KEY, minus, 1), + (EV_KEY, minus, 0), + (EV_KEY, m, 1), + (EV_KEY, m, 0), + ], + ) + self.assertEqual(self.count_child_macros(macro), 1) + self.assertEqual(self.count_tasks(macro), 4) + + async def test_various(self): + start = time.time() + macro = Parser.parse( + "w(200).repeat(2,modify(w,\nrepeat(2,\tkey(BtN_LeFt))).w(10).key(k))", + self.context, + DummyMapping, + ) + + self.assertEqual(self.count_child_macros(macro), 3) + self.assertEqual(self.count_tasks(macro), 7) + + w = keyboard_layout.get("w") + left = keyboard_layout.get("bTn_lEfT") + k = keyboard_layout.get("k") + + await macro.run(self.handler) + + num_pauses = 8 + 6 + 4 + keystroke_time = num_pauses * DummyMapping.macro_key_sleep_ms + wait_time = 220 + total_time = (keystroke_time + wait_time) / 1000 + + self.assertLess(time.time() - start, total_time * 1.2) + self.assertGreater(time.time() - start, total_time * 0.9) + expected = [(EV_KEY, w, 1)] + expected += [(EV_KEY, left, 1), (EV_KEY, left, 0)] * 2 + expected += [(EV_KEY, w, 0)] + expected += [(EV_KEY, k, 1), (EV_KEY, k, 0)] + expected *= 2 + self.assertListEqual(self.result, expected) + + async def test_not_run(self): + # does nothing without .run + macro = Parser.parse("key(a).repeat(3, key(b))", self.context) + self.assertIsInstance(macro, Macro) + self.assertListEqual(self.result, []) + + async def test_duplicate_run(self): + # it won't restart the macro, because that may screw up the + # internal state (in particular the _trigger_release_event). + # I actually don't know at all what kind of bugs that might produce, + # lets just avoid it. It might cause it to be held down forever. + a = keyboard_layout.get("a") + b = keyboard_layout.get("b") + c = keyboard_layout.get("c") + + macro = Parser.parse( + "key(a).modify(b, hold()).key(c)", self.context, DummyMapping + ) + asyncio.ensure_future(macro.run(self.handler)) + self.assertFalse(macro.tasks[1].child_macros[0].tasks[0].is_holding()) + + asyncio.ensure_future(macro.run(self.handler)) # ignored + self.assertFalse(macro.tasks[1].child_macros[0].tasks[0].is_holding()) + + macro.press_trigger() + await asyncio.sleep(0.2) + self.assertTrue(macro.tasks[1].child_macros[0].tasks[0].is_holding()) + + asyncio.ensure_future(macro.run(self.handler)) # ignored + self.assertTrue(macro.tasks[1].child_macros[0].tasks[0].is_holding()) + + macro.release_trigger() + await asyncio.sleep(0.2) + self.assertFalse(macro.tasks[1].child_macros[0].tasks[0].is_holding()) + + expected = [ + (EV_KEY, a, 1), + (EV_KEY, a, 0), + (EV_KEY, b, 1), + (EV_KEY, b, 0), + (EV_KEY, c, 1), + (EV_KEY, c, 0), + ] + self.assertListEqual(self.result, expected) + + """not ignored, since previous run is over""" + + asyncio.ensure_future(macro.run(self.handler)) + macro.press_trigger() + await asyncio.sleep(0.2) + self.assertTrue(macro.tasks[1].child_macros[0].tasks[0].is_holding()) + macro.release_trigger() + await asyncio.sleep(0.2) + self.assertFalse(macro.tasks[1].child_macros[0].tasks[0].is_holding()) + + expected = [ + (EV_KEY, a, 1), + (EV_KEY, a, 0), + (EV_KEY, b, 1), + (EV_KEY, b, 0), + (EV_KEY, c, 1), + (EV_KEY, c, 0), + ] * 2 + self.assertListEqual(self.result, expected) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/test_macros/test_mod_tap.py b/tests/unit/test_macros/test_mod_tap.py new file mode 100644 index 0000000..43b00f3 --- /dev/null +++ b/tests/unit/test_macros/test_mod_tap.py @@ -0,0 +1,385 @@ +import asyncio +import time +import unittest +from unittest.mock import patch + +import evdev +from evdev.ecodes import KEY_A, EV_KEY, KEY_B, KEY_LEFTSHIFT, KEY_C + +from inputremapper.configs.input_config import InputConfig +from inputremapper.configs.mapping import Mapping +from inputremapper.configs.preset import Preset +from inputremapper.injection.context import Context +from inputremapper.injection.event_reader import EventReader +from inputremapper.injection.global_uinputs import GlobalUInputs, UInput +from inputremapper.injection.macros.parse import Parser +from inputremapper.injection.mapping_handlers.mapping_parser import MappingParser +from inputremapper.input_event import InputEvent +from tests.lib.fixtures import fixtures +from tests.lib.patches import InputDevice +from tests.lib.pipes import uinput_write_history +from tests.lib.test_setup import test_setup +from tests.unit.test_macros.macro_test_base import MacroTestBase, DummyMapping + + +@test_setup +class TestModTapIntegration(unittest.IsolatedAsyncioTestCase): + # Testcases are from https://github.com/qmk/qmk_firmware/blob/78a0adfbb4d2c4e12f93f2a62ded0020d406243e/docs/tap_hold.md#nested-tap-abba-nested-tap + # This test-setup is a bit more involved, because I needed to modify the + # EventReader as well for this to work. + + def setUp(self): + self.origin_hash = fixtures.bar_device.get_device_hash() + self.forward_uinput = evdev.UInput(name="test-forward-uinput") + self.source_device = InputDevice(fixtures.bar_device.path) + self.stop_event = asyncio.Event() + self.global_uinputs = GlobalUInputs(UInput) + self.global_uinputs.prepare_all() + self.target_uinput = self.global_uinputs.get_uinput("keyboard") + self.mapping_parser = MappingParser(self.global_uinputs) + + self.mapping = Mapping.from_combination( + input_combination=[ + InputConfig( + type=EV_KEY, + code=KEY_A, + origin_hash=self.origin_hash, + ) + ], + output_symbol="mod_tap(a, Shift_L)", + ) + + self.preset = Preset() + self.preset.add(self.mapping) + + self.bootstrap_event_reader() + + self.loop = asyncio.new_event_loop() + asyncio.set_event_loop(self.loop) + + def bootstrap_event_reader(self): + self.context = Context( + self.preset, + source_devices={self.origin_hash: self.source_device}, + forward_devices={self.origin_hash: self.forward_uinput}, + mapping_parser=self.mapping_parser, + ) + + self.event_reader = EventReader( + self.context, + self.source_device, + self.stop_event, + ) + + def write(self, type_, code, value): + self.target_uinput.write_event(InputEvent.from_tuple((type_, code, value))) + + async def input(self, type_, code, value): + asyncio.ensure_future( + self.event_reader.handle( + InputEvent.from_tuple( + ( + type_, + code, + value, + ), + origin_hash=self.origin_hash, + ) + ) + ) + # Make the main_loop iterate a bit for the event_reader to do its thing. + await asyncio.sleep(0) + + async def test_distinct_taps_1(self): + await self.input(EV_KEY, KEY_A, 1) + await asyncio.sleep(0.190) + await self.input(EV_KEY, KEY_A, 0) + await asyncio.sleep(0.020) # exceeds tapping_term here + await self.input(EV_KEY, KEY_B, 1) + await asyncio.sleep(0.020) + await self.input(EV_KEY, KEY_B, 0) + + self.assertEqual( + uinput_write_history, + [ + InputEvent.from_tuple((EV_KEY, KEY_A, 1)), + InputEvent.from_tuple((EV_KEY, KEY_A, 0)), + InputEvent.from_tuple((EV_KEY, KEY_B, 1)), + InputEvent.from_tuple((EV_KEY, KEY_B, 0)), + ], + ) + self.assertEqual( + self.target_uinput.write_history, + [ + InputEvent.from_tuple((EV_KEY, KEY_A, 1)), + InputEvent.from_tuple((EV_KEY, KEY_A, 0)), + ], + ) + self.assertEqual( + self.forward_uinput.write_history, + [ + InputEvent.from_tuple((EV_KEY, KEY_B, 1)), + InputEvent.from_tuple((EV_KEY, KEY_B, 0)), + ], + ) + + async def test_distinct_taps_2(self): + await self.input(EV_KEY, KEY_A, 1) + await asyncio.sleep(0.220) # exceeds tapping_term here + await self.input(EV_KEY, KEY_A, 0) + await asyncio.sleep(0.020) + await self.input(EV_KEY, KEY_B, 1) + await asyncio.sleep(0.020) + await self.input(EV_KEY, KEY_B, 0) + + self.assertEqual( + uinput_write_history, + [ + InputEvent.from_tuple((EV_KEY, KEY_LEFTSHIFT, 1)), + InputEvent.from_tuple((EV_KEY, KEY_LEFTSHIFT, 0)), + InputEvent.from_tuple((EV_KEY, KEY_B, 1)), + InputEvent.from_tuple((EV_KEY, KEY_B, 0)), + ], + ) + self.assertEqual( + self.target_uinput.write_history, + [ + InputEvent.from_tuple((EV_KEY, KEY_LEFTSHIFT, 1)), + InputEvent.from_tuple((EV_KEY, KEY_LEFTSHIFT, 0)), + ], + ) + self.assertEqual( + self.forward_uinput.write_history, + [ + InputEvent.from_tuple((EV_KEY, KEY_B, 1)), + InputEvent.from_tuple((EV_KEY, KEY_B, 0)), + ], + ) + + async def test_nested_tap_1(self): + await self.input(EV_KEY, KEY_A, 1) + await asyncio.sleep(0.100) + self.assertEqual(uinput_write_history, []) + + await self.input(EV_KEY, KEY_B, 1) + await asyncio.sleep(0.020) + self.assertEqual(uinput_write_history, []) + + await self.input(EV_KEY, KEY_B, 0) + await asyncio.sleep(0.050) + self.assertEqual(uinput_write_history, []) + + await self.input(EV_KEY, KEY_A, 0) + + # everything happened within the tapping_term, so the modifier is not activated. + # "ab" should be written, in the exact order of the input. + await asyncio.sleep(0.040) + self.assertEqual( + uinput_write_history, + [ + InputEvent.from_tuple((EV_KEY, KEY_A, 1)), + InputEvent.from_tuple((EV_KEY, KEY_B, 1)), + InputEvent.from_tuple((EV_KEY, KEY_B, 0)), + InputEvent.from_tuple((EV_KEY, KEY_A, 0)), + ], + ) + + async def test_nested_tap_2(self): + await self.input(EV_KEY, KEY_A, 1) + await asyncio.sleep(0.100) + await self.input(EV_KEY, KEY_B, 1) + await asyncio.sleep(0.020) + await self.input(EV_KEY, KEY_B, 0) + await asyncio.sleep(0.100) # exceeds tapping_term here + await self.input(EV_KEY, KEY_A, 0) + + await asyncio.sleep(0.020) + self.assertEqual( + uinput_write_history, + [ + InputEvent.from_tuple((EV_KEY, KEY_LEFTSHIFT, 1)), + InputEvent.from_tuple((EV_KEY, KEY_B, 1)), + InputEvent.from_tuple((EV_KEY, KEY_B, 0)), + InputEvent.from_tuple((EV_KEY, KEY_LEFTSHIFT, 0)), + ], + ) + + async def test_nested_tap_3(self): + await self.input(EV_KEY, KEY_A, 1) + await asyncio.sleep(0.220) # exceeds tapping_term here + await self.input(EV_KEY, KEY_B, 1) + await asyncio.sleep(0.020) + await self.input(EV_KEY, KEY_B, 0) + await asyncio.sleep(0.020) + await self.input(EV_KEY, KEY_A, 0) + + await asyncio.sleep(0.020) + self.assertEqual( + uinput_write_history, + [ + InputEvent.from_tuple((EV_KEY, KEY_LEFTSHIFT, 1)), + InputEvent.from_tuple((EV_KEY, KEY_B, 1)), + InputEvent.from_tuple((EV_KEY, KEY_B, 0)), + InputEvent.from_tuple((EV_KEY, KEY_LEFTSHIFT, 0)), + ], + ) + + async def test_rolling_keys_1(self): + await self.input(EV_KEY, KEY_A, 1) + await asyncio.sleep(0.100) + await self.input(EV_KEY, KEY_B, 1) + await asyncio.sleep(0.020) + await self.input(EV_KEY, KEY_A, 0) + await asyncio.sleep(0.020) + await self.input(EV_KEY, KEY_B, 0) + + # everything happened within the tapping_term, so the modifier is not activated. + # "ab" should be written, in the exact order of the input. + await asyncio.sleep(0.100) + self.assertEqual( + uinput_write_history, + [ + InputEvent.from_tuple((EV_KEY, KEY_A, 1)), + InputEvent.from_tuple((EV_KEY, KEY_B, 1)), + InputEvent.from_tuple((EV_KEY, KEY_A, 0)), + InputEvent.from_tuple((EV_KEY, KEY_B, 0)), + ], + ) + + async def test_rolling_keys_2(self): + await self.input(EV_KEY, KEY_A, 1) + await asyncio.sleep(0.100) + await self.input(EV_KEY, KEY_B, 1) + await asyncio.sleep(0.100) # exceeds tapping_term here + await self.input(EV_KEY, KEY_A, 0) + await asyncio.sleep(0.020) + await self.input(EV_KEY, KEY_B, 0) + + await asyncio.sleep(0.020) + self.assertEqual( + uinput_write_history, + [ + InputEvent.from_tuple((EV_KEY, KEY_LEFTSHIFT, 1)), + InputEvent.from_tuple((EV_KEY, KEY_B, 1)), + InputEvent.from_tuple((EV_KEY, KEY_LEFTSHIFT, 0)), + InputEvent.from_tuple((EV_KEY, KEY_B, 0)), + ], + ) + + async def test_many_keys_correct_order_without_sleep(self): + self.mapping.macro_key_sleep_ms = 0 + await self.many_keys_correct_order() + + async def test_many_keys_correct_order_with_sleep(self): + self.mapping.macro_key_sleep_ms = 20 + await self.many_keys_correct_order() + + async def many_keys_correct_order(self): + await self.input(EV_KEY, KEY_A, 1) + + # Send many events to the listener. It has to make all of them wait. + for i in range(30): + await self.input(EV_KEY, i, 1) + + # exceed tapping_term. mod_tap will inject the modifier and replay all the + # previous events. + await asyncio.sleep(0.201) + + # mod_tap is busy replaying events. While it does that, inject this + await self.input(EV_KEY, 100, 1) + + start = time.time() + timeout = 2 + while len(uinput_write_history) < 32 and (time.time() - start) < timeout: + # Wait for it to complete + await asyncio.sleep(0.1) + + self.assertEqual(len(uinput_write_history), 32) + + # Expect it to cleanly handle all events before injecting 100. Expect + # everything to be in the correct order. + self.assertEqual( + uinput_write_history, + [ + InputEvent.from_tuple((EV_KEY, KEY_LEFTSHIFT, 1)), + *[InputEvent.from_tuple((EV_KEY, i, 1)) for i in range(30)], + InputEvent.from_tuple((EV_KEY, 100, 1)), + ], + ) + + async def test_mapped_second_key(self): + # Map b to c. + # While mod_tap is waiting for the timeout to happen, press b. + # We expect c to be written, because b goes through the handlers and + # gets mapped. + # The event_reader has to wait for listeners to complete for mod_tap to work, so + # that it hands them over to the other handlers when the time comes. + # That means however, that the event_readers loop blocks. Therefore, it was turned + # into a fire-and-forget kind of thing. When an event arrives, it just schedules + # asyncio to do that stuff later, and continues reading. + + self.preset.add( + Mapping.from_combination( + input_combination=[ + InputConfig( + type=EV_KEY, + code=KEY_B, + origin_hash=self.origin_hash, + ) + ], + output_symbol="c", + ), + ) + + self.bootstrap_event_reader() + + async def async_generator(): + events = [ + InputEvent(0, 0, EV_KEY, KEY_A, 1), + InputEvent(0, 0, EV_KEY, KEY_B, 1), + InputEvent(0, 0, EV_KEY, KEY_A, 0), + InputEvent(0, 0, EV_KEY, KEY_B, 0), + ] + for event in events: + yield event + # Wait a bit. During runtime, events don't come in that quickly + # and the mod_tap macro needs some loop iterations until it adds + # the listener to the context. + await asyncio.sleep(0.010) + + with patch.object(self.event_reader, "read_loop", async_generator): + await self.event_reader.run() + + await asyncio.sleep(0.020) + self.assertIn(InputEvent(0, 0, EV_KEY, KEY_C, 1), uinput_write_history) + self.assertIn(InputEvent(0, 0, EV_KEY, KEY_C, 0), uinput_write_history) + self.assertIn(InputEvent(0, 0, EV_KEY, KEY_A, 1), uinput_write_history) + self.assertIn(InputEvent(0, 0, EV_KEY, KEY_A, 0), uinput_write_history) + self.assertNotIn(InputEvent(0, 0, EV_KEY, KEY_B, 1), uinput_write_history) + self.assertNotIn(InputEvent(0, 0, EV_KEY, KEY_B, 0), uinput_write_history) + + +@test_setup +class TestModTapUnit(MacroTestBase): + async def wait_for_timeout(self, macro): + macro = Parser.parse(macro, self.context, DummyMapping, True) + + start = time.time() + # Awaiting macro.run will cause it to wait for the tapping_term. + # When it injects the modifier, release the trigger. + macro.press_trigger() + await macro.run(lambda *_, **__: macro.release_trigger()) + return time.time() - start + + async def test_tapping_term_configuration_default(self): + time_ = await self.wait_for_timeout("mod_tap(a, b)") + # + 2 times 10ms of keycode_pause + self.assertAlmostEqual(time_, 0.22, delta=0.02) + + async def test_tapping_term_configuration_100(self): + time_ = await self.wait_for_timeout("mod_tap(a, b, 100)") + self.assertAlmostEqual(time_, 0.12, delta=0.02) + + async def test_tapping_term_configuration_100_kwarg(self): + time_ = await self.wait_for_timeout("mod_tap(a, b, tapping_term=100)") + self.assertAlmostEqual(time_, 0.12, delta=0.02) diff --git a/tests/unit/test_macros/test_modify.py b/tests/unit/test_macros/test_modify.py new file mode 100644 index 0000000..b42f97c --- /dev/null +++ b/tests/unit/test_macros/test_modify.py @@ -0,0 +1,62 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + + +import unittest + +from evdev.ecodes import EV_KEY + +from inputremapper.configs.keyboard_layout import keyboard_layout +from inputremapper.configs.validation_errors import MacroError +from inputremapper.injection.macros.parse import Parser +from tests.lib.test_setup import test_setup +from tests.unit.test_macros.macro_test_base import MacroTestBase, DummyMapping + + +@test_setup +class TestModify(MacroTestBase): + async def test_modify(self): + code_a = keyboard_layout.get("a") + code_b = keyboard_layout.get("b") + code_c = keyboard_layout.get("c") + macro = Parser.parse( + "set(foo, b).modify($foo, modify(a, key(c)))", + self.context, + DummyMapping, + ) + await macro.run(self.handler) + self.assertListEqual( + self.result, + [ + (EV_KEY, code_b, 1), + (EV_KEY, code_a, 1), + (EV_KEY, code_c, 1), + (EV_KEY, code_c, 0), + (EV_KEY, code_a, 0), + (EV_KEY, code_b, 0), + ], + ) + + async def test_raises_error(self): + self.assertRaises(MacroError, Parser.parse, "modify(asdf, k(a))", self.context) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/test_macros/test_mouse.py b/tests/unit/test_macros/test_mouse.py new file mode 100644 index 0000000..7136965 --- /dev/null +++ b/tests/unit/test_macros/test_mouse.py @@ -0,0 +1,246 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + +import asyncio +import unittest + +from evdev._ecodes import ( + REL_Y, + EV_REL, + REL_HWHEEL, + REL_HWHEEL_HI_RES, + REL_X, + REL_WHEEL, + REL_WHEEL_HI_RES, + KEY_A, + EV_KEY, +) + +from inputremapper.configs.validation_errors import MacroError +from inputremapper.injection.macros.parse import Parser +from tests.lib.test_setup import test_setup +from tests.unit.test_macros.macro_test_base import DummyMapping, MacroTestBase + + +@test_setup +class TestMouse(MacroTestBase): + async def test_mouse_acceleration(self): + # There is a tiny float-rounding error that can break the test, therefore I use + # 0.09001 to make it more robust. + await self._run_macro("mouse(up, 10, 0.09001)", 0.1) + self.assertEqual( + [ + (EV_REL, REL_Y, -2), + (EV_REL, REL_Y, -3), + (EV_REL, REL_Y, -4), + (EV_REL, REL_Y, -4), + (EV_REL, REL_Y, -5), + ], + self.result, + ) + + async def test_rate(self): + # It should move 200 times per second by 1px, for 0.2 seconds. + rel_rate = 200 + time = 0.2 + speed = 1 + expected_movement = time * rel_rate * speed + + await self._run_macro(f"mouse(down, {speed})", time, rel_rate) + total_movement = sum(event[2] for event in self.result) + self.assertAlmostEqual(float(total_movement), expected_movement, delta=1) + + async def test_slow_movement(self): + await self._run_macro("mouse(down, 0.1)", 0.2, 200) + total_movement = sum(event[2] for event in self.result) + self.assertAlmostEqual(total_movement, 4, delta=1) + + async def test_mouse_xy_acceleration_1(self): + await self._run_macro("mouse_xy(2, -10, 0.09001)", 0.1) + self.assertEqual( + [ + (EV_REL, REL_Y, -2), + (EV_REL, REL_Y, -3), + (EV_REL, REL_Y, -4), + (EV_REL, REL_Y, -4), + (EV_REL, REL_Y, -5), + ], + self._get_y_movement(), + ) + self.assertEqual( + [ + (EV_REL, REL_X, 1), + (EV_REL, REL_X, 1), + (EV_REL, REL_X, 1), + ], + self._get_x_movement(), + ) + + async def test_mouse_xy_acceleration_2(self): + await self._run_macro("mouse_xy(10, -2, 0.09001)", 0.1) + self.assertEqual( + [ + (EV_REL, REL_Y, -1), + (EV_REL, REL_Y, -1), + (EV_REL, REL_Y, -1), + ], + self._get_y_movement(), + ) + self.assertEqual( + [ + (EV_REL, REL_X, 2), + (EV_REL, REL_X, 3), + (EV_REL, REL_X, 4), + (EV_REL, REL_X, 4), + (EV_REL, REL_X, 5), + ], + self._get_x_movement(), + ) + + async def test_mouse_xy_only_x(self): + await self._run_macro("mouse_xy(x=10, acceleration=1)", 0.1) + self.assertEqual([], self._get_y_movement()) + self.assertEqual( + [ + (EV_REL, REL_X, 10), + (EV_REL, REL_X, 10), + (EV_REL, REL_X, 10), + (EV_REL, REL_X, 10), + (EV_REL, REL_X, 10), + (EV_REL, REL_X, 10), + ], + self._get_x_movement(), + ) + + async def test_mouse_xy_only_y(self): + await self._run_macro("mouse_xy(y=10)", 0.1) + self.assertEqual([], self._get_x_movement()) + self.assertEqual( + [ + (EV_REL, REL_Y, 10), + (EV_REL, REL_Y, 10), + (EV_REL, REL_Y, 10), + (EV_REL, REL_Y, 10), + (EV_REL, REL_Y, 10), + (EV_REL, REL_Y, 10), + ], + self._get_y_movement(), + ) + + async def test_wheel_left(self): + wheel_speed = 60 + sleep = 0.1 + await self._run_macro(f"wheel(left, {wheel_speed})", sleep) + + self.assertIn((EV_REL, REL_HWHEEL, 1), self.result) + self.assertIn((EV_REL, REL_HWHEEL_HI_RES, 60), self.result) + + expected_num_hires_events = sleep * DummyMapping.rel_rate + expected_num_wheel_events = int(expected_num_hires_events / 120 * wheel_speed) + actual_num_wheel_events = self.result.count((EV_REL, REL_HWHEEL, 1)) + actual_num_hires_events = self.result.count( + ( + EV_REL, + REL_HWHEEL_HI_RES, + wheel_speed, + ) + ) + + self.assertGreater( + actual_num_wheel_events, + expected_num_wheel_events * 0.9, + ) + self.assertLess( + actual_num_wheel_events, + expected_num_wheel_events * 1.1, + ) + self.assertGreater( + actual_num_hires_events, + expected_num_hires_events * 0.9, + ) + self.assertLess( + actual_num_hires_events, + expected_num_hires_events * 1.1, + ) + + async def test_wheel_up(self): + await self._run_macro("wheel(up, 60)", 0.1) + self.assertIn((EV_REL, REL_WHEEL, 1), self.result) + self.assertIn((EV_REL, REL_WHEEL_HI_RES, 60), self.result) + + async def test_wheel_down(self): + await self._run_macro("wheel(down, 60)", 0.1) + self.assertIn((EV_REL, REL_WHEEL, -1), self.result) + self.assertIn((EV_REL, REL_WHEEL_HI_RES, -60), self.result) + + async def test_wheel_right(self): + await self._run_macro("wheel(right, 60)", 0.1) + self.assertIn((EV_REL, REL_HWHEEL, -1), self.result) + self.assertIn((EV_REL, REL_HWHEEL_HI_RES, -60), self.result) + + async def test_mouse_releases(self): + await self._run_macro("mouse(down, 1).key(a)", 0.1) + self.assertEqual(self.result[-2:], [(EV_KEY, KEY_A, 1), (EV_KEY, KEY_A, 0)]) + + async def test_mouse_xy_releases(self): + await self._run_macro("mouse_xy(1, 1, 1).key(a)", 0.1) + self.assertEqual(self.result[-2:], [(EV_KEY, KEY_A, 1), (EV_KEY, KEY_A, 0)]) + + async def test_wheel_releases(self): + await self._run_macro("wheel(down, 1).key(a)", 0.1) + self.assertEqual(self.result[-2:], [(EV_KEY, KEY_A, 1), (EV_KEY, KEY_A, 0)]) + + async def test_raises_error(self): + Parser.parse("mouse(up, 3)", self.context) # no error + Parser.parse("mouse(up, speed=$a)", self.context) # no error + self.assertRaises(MacroError, Parser.parse, "mouse(3, up)", self.context) + Parser.parse("wheel(left, 3)", self.context) # no error + self.assertRaises(MacroError, Parser.parse, "wheel(3, left)", self.context) + + def _get_x_movement(self): + return [event for event in self.result if event[1] == REL_X] + + def _get_y_movement(self): + return [event for event in self.result if event[1] == REL_Y] + + async def _run_macro( + self, + code: str, + time: float, + rel_rate: int = DummyMapping.rel_rate, + ): + dummy_mapping = DummyMapping() + dummy_mapping.rel_rate = rel_rate + macro = Parser.parse( + code, + self.context, + dummy_mapping, + ) + macro.press_trigger() + asyncio.ensure_future(macro.run(self.handler)) + + await asyncio.sleep(time) + self.assertTrue(macro.tasks[0].is_holding()) + macro.release_trigger() + await asyncio.sleep(0.05) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/test_macros/test_parallel.py b/tests/unit/test_macros/test_parallel.py new file mode 100644 index 0000000..c80f2dc --- /dev/null +++ b/tests/unit/test_macros/test_parallel.py @@ -0,0 +1,114 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + +import asyncio +import unittest + +from evdev.ecodes import ( + EV_KEY, + KEY_A, + KEY_B, + KEY_C, + KEY_D, +) + +from inputremapper.injection.macros.parse import Parser +from tests.lib.test_setup import test_setup +from tests.unit.test_macros.macro_test_base import DummyMapping, MacroTestBase + + +@test_setup +class TestParallel(MacroTestBase): + async def test_1_child_macro(self): + macro = Parser.parse( + "parallel(key(a))", + self.context, + DummyMapping(), + True, + ) + self.assertEqual(len(macro.tasks[0].child_macros), 1) + await macro.run(self.handler) + self.assertEqual(self.result, [(EV_KEY, KEY_A, 1), (EV_KEY, KEY_A, 0)]) + + async def test_4_child_macros(self): + macro = Parser.parse( + "parallel(key(a), key(b), key(c), key(d))", + self.context, + DummyMapping(), + True, + ) + self.assertEqual(len(macro.tasks[0].child_macros), 4) + await macro.run(self.handler) + self.assertIn((EV_KEY, KEY_A, 0), self.result) + self.assertIn((EV_KEY, KEY_B, 0), self.result) + self.assertIn((EV_KEY, KEY_C, 0), self.result) + self.assertIn((EV_KEY, KEY_D, 0), self.result) + + async def test_one_wait_takes_longer(self): + mapping = DummyMapping() + mapping.macro_key_sleep_ms = 0 + macro = Parser.parse( + "parallel(wait(100), wait(10).key(b)).key(c)", + self.context, + mapping, + True, + ) + + asyncio.ensure_future(macro.run(self.handler)) + await asyncio.sleep(0.06) + # The wait(10).key(b) macro is already done, but KEY_C is not yet injected + self.assertEqual(len(self.result), 2) + self.assertIn((EV_KEY, KEY_B, 1), self.result) + self.assertIn((EV_KEY, KEY_B, 0), self.result) + + # Both need to complete for it to continue to key(c) + await asyncio.sleep(0.06) + self.assertEqual(len(self.result), 4) + self.assertIn((EV_KEY, KEY_C, 1), self.result) + self.assertIn((EV_KEY, KEY_C, 0), self.result) + + async def test_parallel_hold(self): + mapping = DummyMapping() + mapping.macro_key_sleep_ms = 0 + macro = Parser.parse( + "parallel(hold_keys(a), hold_keys(b)).key(c)", + self.context, + mapping, + True, + ) + + macro.press_trigger() + asyncio.ensure_future(macro.run(self.handler)) + await asyncio.sleep(0.05) + self.assertIn((EV_KEY, KEY_A, 1), self.result) + self.assertIn((EV_KEY, KEY_B, 1), self.result) + self.assertEqual(len(self.result), 2) + + macro.release_trigger() + await asyncio.sleep(0.05) + self.assertIn((EV_KEY, KEY_A, 0), self.result) + self.assertIn((EV_KEY, KEY_B, 0), self.result) + self.assertIn((EV_KEY, KEY_C, 1), self.result) + self.assertIn((EV_KEY, KEY_C, 0), self.result) + self.assertEqual(len(self.result), 6) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/test_macros/test_parsing.py b/tests/unit/test_macros/test_parsing.py new file mode 100644 index 0000000..c76ddf7 --- /dev/null +++ b/tests/unit/test_macros/test_parsing.py @@ -0,0 +1,346 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + + +import re +import unittest + +from evdev.ecodes import EV_KEY, KEY_A, KEY_B, KEY_C, KEY_E + +from inputremapper.configs.validation_errors import ( + MacroError, +) +from inputremapper.injection.macros.argument import Argument, ArgumentConfig +from inputremapper.injection.macros.parse import Parser +from inputremapper.injection.macros.raw_value import RawValue +from inputremapper.injection.macros.tasks.hold_keys import HoldKeysTask +from inputremapper.injection.macros.tasks.if_tap import IfTapTask +from inputremapper.injection.macros.tasks.key import KeyTask +from inputremapper.injection.macros.variable import Variable +from tests.lib.logger import logger +from tests.lib.test_setup import test_setup +from tests.unit.test_macros.macro_test_base import DummyMapping, MacroTestBase + + +@test_setup +class TestParsing(MacroTestBase): + def test_get_macro_argument_names(self): + self.assertEqual( + IfTapTask.get_macro_argument_names(), + ["then", "else", "timeout"], + ) + + self.assertEqual( + HoldKeysTask.get_macro_argument_names(), + ["*symbols"], + ) + + def test_get_num_parameters(self): + self.assertEqual(IfTapTask.get_num_parameters(), (0, 3)) + self.assertEqual(KeyTask.get_num_parameters(), (1, 1)) + self.assertEqual(HoldKeysTask.get_num_parameters(), (0, float("inf"))) + + def test_remove_whitespaces(self): + self.assertEqual(Parser.remove_whitespaces('foo"bar"foo'), 'foo"bar"foo') + self.assertEqual(Parser.remove_whitespaces('foo" bar"foo'), 'foo" bar"foo') + self.assertEqual( + Parser.remove_whitespaces('foo" bar"fo" "o'), 'foo" bar"fo" "o' + ) + self.assertEqual( + Parser.remove_whitespaces(' fo o"\nba r "f\noo'), 'foo"\nba r "foo' + ) + self.assertEqual(Parser.remove_whitespaces(' a " b " c " '), 'a" b "c" ') + + self.assertEqual(Parser.remove_whitespaces('"""""""""'), '"""""""""') + self.assertEqual(Parser.remove_whitespaces('""""""""'), '""""""""') + + self.assertEqual(Parser.remove_whitespaces(" "), "") + self.assertEqual(Parser.remove_whitespaces(' " '), '" ') + self.assertEqual(Parser.remove_whitespaces(' " " '), '" "') + + self.assertEqual(Parser.remove_whitespaces("a# ##b", delimiter="##"), "a###b") + self.assertEqual(Parser.remove_whitespaces("a###b", delimiter="##"), "a###b") + self.assertEqual(Parser.remove_whitespaces("a## #b", delimiter="##"), "a## #b") + self.assertEqual( + Parser.remove_whitespaces("a## ##b", delimiter="##"), "a## ##b" + ) + + def test_remove_comments(self): + self.assertEqual(Parser.remove_comments("a#b"), "a") + self.assertEqual(Parser.remove_comments('"a#b"'), '"a#b"') + self.assertEqual(Parser.remove_comments('a"#"#b'), 'a"#"') + self.assertEqual(Parser.remove_comments('a"#""#"#b'), 'a"#""#"') + self.assertEqual(Parser.remove_comments('#a"#""#"#b'), "") + + self.assertEqual( + re.sub( + r"\s", + "", + Parser.remove_comments( + """ + # a + b + # c + d + """ + ), + ), + "bd", + ) + + async def test_count_brackets(self): + self.assertEqual(Parser._count_brackets(""), 0) + self.assertEqual(Parser._count_brackets("()"), 2) + self.assertEqual(Parser._count_brackets("a()"), 3) + self.assertEqual(Parser._count_brackets("a(b)"), 4) + self.assertEqual(Parser._count_brackets("a(b())"), 6) + self.assertEqual(Parser._count_brackets("a(b(c))"), 7) + self.assertEqual(Parser._count_brackets("a(b(c))d"), 7) + self.assertEqual(Parser._count_brackets("a(b(c))d()"), 7) + + def test_split_keyword_arg(self): + self.assertTupleEqual(Parser._split_keyword_arg("_A=b"), ("_A", "b")) + self.assertTupleEqual(Parser._split_keyword_arg("a_=1"), ("a_", "1")) + self.assertTupleEqual( + Parser._split_keyword_arg("a=repeat(2, KEY_A)"), + ("a", "repeat(2, KEY_A)"), + ) + self.assertTupleEqual(Parser._split_keyword_arg('a="=,#+."'), ("a", '"=,#+."')) + + def test_is_this_a_macro(self): + self.assertTrue(Parser.is_this_a_macro("key(1)")) + self.assertTrue(Parser.is_this_a_macro("key(1).key(2)")) + self.assertTrue(Parser.is_this_a_macro("repeat(1, key(1).key(2))")) + + self.assertFalse(Parser.is_this_a_macro("1")) + self.assertFalse(Parser.is_this_a_macro("key_kp1")) + self.assertFalse(Parser.is_this_a_macro("btn_left")) + self.assertFalse(Parser.is_this_a_macro("minus")) + self.assertFalse(Parser.is_this_a_macro("k")) + self.assertFalse(Parser.is_this_a_macro(1)) + self.assertFalse(Parser.is_this_a_macro(None)) + + self.assertTrue(Parser.is_this_a_macro("a+b")) + self.assertTrue(Parser.is_this_a_macro("a+b+c")) + self.assertTrue(Parser.is_this_a_macro("a + b")) + self.assertTrue(Parser.is_this_a_macro("a + b + c")) + + def test_handle_plus_syntax(self): + self.assertEqual(Parser.handle_plus_syntax("a + b"), "hold_keys(a,b)") + self.assertEqual(Parser.handle_plus_syntax("a + b + c"), "hold_keys(a,b,c)") + self.assertEqual(Parser.handle_plus_syntax(" a+b+c "), "hold_keys(a,b,c)") + + # invalid. The last one with `key` should not have been a parameter + # of this function to begin with. + strings = ["+", "a+", "+b", "a\n+\n+\nb", "key(a + b)"] + for string in strings: + with self.assertRaises(MacroError): + logger.info('testing "%s"', string) + Parser.handle_plus_syntax(string) + + self.assertEqual(Parser.handle_plus_syntax("a"), "a") + self.assertEqual(Parser.handle_plus_syntax("key(a)"), "key(a)") + self.assertEqual(Parser.handle_plus_syntax(""), "") + + def test_parse_plus_syntax(self): + macro = Parser.parse("a + b") + self.assertEqual(macro.code, "hold_keys(a,b)") + + # this is not erroneously recognized as "plus" syntax + macro = Parser.parse("key(a) # a + b") + self.assertEqual(macro.code, "key(a)") + + async def test_extract_params(self): + # splits strings, doesn't try to understand their meaning yet + def expect(raw, expectation): + self.assertListEqual(Parser._extract_args(raw), expectation) + + expect("a", ["a"]) + expect("a,b", ["a", "b"]) + expect("a,b,c", ["a", "b", "c"]) + + expect("key(a)", ["key(a)"]) + expect("key(a).key(b), key(a)", ["key(a).key(b)", "key(a)"]) + expect("key(a), key(a).key(b)", ["key(a)", "key(a).key(b)"]) + + expect( + 'a("foo(1,2,3)", ",,,,,, "), , ""', + ['a("foo(1,2,3)", ",,,,,, ")', "", '""'], + ) + + expect( + ",1, ,b,x(,a(),).y().z(),,", + ["", "1", "", "b", "x(,a(),).y().z()", "", ""], + ) + + expect("repeat(1, key(a))", ["repeat(1, key(a))"]) + expect( + "repeat(1, key(a)), repeat(1, key(b))", + ["repeat(1, key(a))", "repeat(1, key(b))"], + ) + expect( + "repeat(1, key(a)), repeat(1, key(b)), repeat(1, key(c))", + ["repeat(1, key(a))", "repeat(1, key(b))", "repeat(1, key(c))"], + ) + + # will be parsed as None + expect("", [""]) + expect(",", ["", ""]) + expect(",,", ["", "", ""]) + + async def test_parse_params(self): + def test(value, types): + argument = Argument( + ArgumentConfig(position=0, name="test", types=types), + DummyMapping, + ) + argument.initialize_variable(RawValue(value=value)) + return argument._variable + + self.assertEqual( + test("", [None, int, float]), + Variable(None, const=True), + ) + + # strings. If it is wrapped in quotes, don't parse the contents + self.assertEqual( + test('"foo"', [str]), + Variable("foo", const=True), + ) + self.assertEqual( + test('"\tf o o\n"', [str]), + Variable("\tf o o\n", const=True), + ) + self.assertEqual( + test('"foo(a,b)"', [str]), + Variable("foo(a,b)", const=True), + ) + self.assertEqual( + test('",,,()"', [str]), + Variable(",,,()", const=True), + ) + + # strings without quotes only work as long as there is no function call or + # anything. This is only really acceptable for constants like KEY_A and for + # variable names, which are not allowed to contain special characters that may + # have a meaning in the macro syntax. + self.assertEqual( + test("foo", [str]), + Variable("foo", const=True), + ) + + self.assertEqual( + test("", [str, None]), + Variable(None, const=True), + ) + self.assertEqual( + test("", [str]), + Variable("", const=True), + ) + self.assertEqual( + test("", [None]), + Variable(None, const=True), + ) + self.assertEqual( + test("None", [None]), + Variable(None, const=True), + ) + self.assertEqual( + test('"None"', [str]), + Variable("None", const=True), + ) + + self.assertEqual( + test("5", [int]), + Variable(5, const=True), + ) + self.assertEqual( + test("5", [float, int]), + Variable(5, const=True), + ) + self.assertEqual( + test("5.2", [int, float]), + Variable(5.2, const=True), + ) + self.assertIsInstance( + test("$foo", [str]), + Variable, + ) + self.assertEqual( + test("$foo", [str]), + Variable("foo", const=False), + ) + + async def test_string_not_a_macro(self): + # passing a string parameter. This is not a macro, even though + # it might look like it without the string quotes. Everything with + # explicit quotes around it has to be treated as a string. + self.assertRaises(MacroError, Parser.parse, '"modify(a, b)"', self.context) + + async def test_multiline_macro_and_comments(self): + # the parser is not confused by the code in the comments and can use hashtags + # in strings in the actual code + comment = '# repeat(1,key(KEY_D)).set(a,"#b")' + macro = Parser.parse( + f""" + {comment} + key(KEY_A).{comment} + key(KEY_B). {comment} + repeat({comment} + 1, {comment} + key(KEY_C){comment} + ). {comment} + {comment} + set(a, "#").{comment} + if_eq($a, "#", key(KEY_E), key(KEY_F)) {comment} + {comment} + """, + self.context, + DummyMapping, + ) + await macro.run(self.handler) + self.assertListEqual( + self.result, + [ + (EV_KEY, KEY_A, 1), + (EV_KEY, KEY_A, 0), + (EV_KEY, KEY_B, 1), + (EV_KEY, KEY_B, 0), + (EV_KEY, KEY_C, 1), + (EV_KEY, KEY_C, 0), + (EV_KEY, KEY_E, 1), + (EV_KEY, KEY_E, 0), + ], + ) + + async def test_child_macro_count(self): + # It correctly keeps track of child-macros for both positional and keyword-args + macro = Parser.parse( + "hold(macro=hold(hold())).repeat(1, macro=repeat(1, hold()))", + self.context, + DummyMapping, + True, + ) + self.assertEqual(self.count_child_macros(macro), 4) + self.assertEqual(self.count_tasks(macro), 6) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/test_macros/test_repeat.py b/tests/unit/test_macros/test_repeat.py new file mode 100644 index 0000000..e410413 --- /dev/null +++ b/tests/unit/test_macros/test_repeat.py @@ -0,0 +1,142 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . +import time +import unittest + +from evdev.ecodes import EV_KEY + +from inputremapper.configs.keyboard_layout import keyboard_layout +from inputremapper.configs.validation_errors import MacroError +from inputremapper.injection.macros.parse import Parser +from tests.lib.test_setup import test_setup +from tests.unit.test_macros.macro_test_base import MacroTestBase, DummyMapping + + +@test_setup +class TestRepeat(MacroTestBase): + async def test_1(self): + start = time.time() + repeats = 20 + + macro = Parser.parse( + f"repeat({repeats}, key(k)).repeat(1, key(k))", + self.context, + DummyMapping, + ) + k_code = keyboard_layout.get("k") + + await macro.run(self.handler) + keystroke_sleep = DummyMapping.macro_key_sleep_ms + sleep_time = 2 * repeats * keystroke_sleep / 1000 + self.assertGreater(time.time() - start, sleep_time * 0.9) + + # This test is rather lax, timing in github actions is slow + self.assertLess(time.time() - start, sleep_time * 1.4) + + self.assertListEqual( + self.result, + [(EV_KEY, k_code, 1), (EV_KEY, k_code, 0)] * (repeats + 1), + ) + + self.assertEqual(self.count_child_macros(macro), 2) + + self.assertEqual(len(macro.tasks[0].child_macros), 1) + self.assertEqual(len(macro.tasks[0].child_macros[0].tasks), 1) + self.assertEqual(len(macro.tasks[0].child_macros[0].tasks[0].child_macros), 0) + + self.assertEqual(len(macro.tasks[1].child_macros), 1) + self.assertEqual(len(macro.tasks[1].child_macros[0].tasks), 1) + self.assertEqual(len(macro.tasks[1].child_macros[0].tasks[0].child_macros), 0) + + async def test_2(self): + start = time.time() + macro = Parser.parse("repeat(3, key(m).w(100))", self.context, DummyMapping) + m_code = keyboard_layout.get("m") + await macro.run(self.handler) + + keystroke_time = 6 * DummyMapping.macro_key_sleep_ms + total_time = keystroke_time + 300 + total_time /= 1000 + + self.assertGreater(time.time() - start, total_time * 0.9) + self.assertLess(time.time() - start, total_time * 1.2) + self.assertListEqual( + self.result, + [ + (EV_KEY, m_code, 1), + (EV_KEY, m_code, 0), + (EV_KEY, m_code, 1), + (EV_KEY, m_code, 0), + (EV_KEY, m_code, 1), + (EV_KEY, m_code, 0), + ], + ) + self.assertEqual(self.count_child_macros(macro), 1) + self.assertEqual(len(macro.tasks), 1) + self.assertEqual(len(macro.tasks[0].child_macros), 1) + self.assertEqual(len(macro.tasks[0].child_macros[0].tasks), 2) + self.assertEqual(len(macro.tasks[0].child_macros[0].tasks[0].child_macros), 0) + self.assertEqual(len(macro.tasks[0].child_macros[0].tasks[1].child_macros), 0) + + async def test_not_an_int(self): + # the first parameter for `repeat` requires an integer, not "foo", + # which makes `repeat` throw + macro = Parser.parse( + 'set(a, "foo").repeat($a, key(KEY_A)).key(KEY_B)', + self.context, + DummyMapping, + ) + + try: + await macro.run(self.handler) + except MacroError as e: + self.assertIn("foo", str(e)) + + self.assertFalse(macro.running) + + # key(KEY_B) is not executed, the macro stops + self.assertListEqual(self.result, []) + + async def test_raises_error(self): + self.assertRaises(MacroError, Parser.parse, "r(1)", self.context) + self.assertRaises(MacroError, Parser.parse, "repeat(a, k(1))", self.context) + Parser.parse("repeat($a, k(1))", self.context) # no error + Parser.parse("repeat(2, k(1))", self.context) # no error + self.assertRaises(MacroError, Parser.parse, 'repeat("2", k(1))', self.context) + self.assertRaises(MacroError, Parser.parse, "r(1, 1)", self.context) + self.assertRaises(MacroError, Parser.parse, "r(k(1), 1)", self.context) + Parser.parse("r(1, macro=k(1))", self.context) # no error + self.assertRaises(MacroError, Parser.parse, "r(a=1, b=k(1))", self.context) + self.assertRaises( + MacroError, + Parser.parse, + "r(repeats=1, macro=k(1), a=2)", + self.context, + ) + self.assertRaises( + MacroError, + Parser.parse, + "r(repeats=1, macro=k(1), repeats=2)", + self.context, + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/test_macros/test_set.py b/tests/unit/test_macros/test_set.py new file mode 100644 index 0000000..f37401a --- /dev/null +++ b/tests/unit/test_macros/test_set.py @@ -0,0 +1,101 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + + +import unittest + +from evdev.ecodes import EV_KEY + +from inputremapper.configs.keyboard_layout import keyboard_layout +from inputremapper.configs.validation_errors import MacroError +from inputremapper.injection.macros.macro import macro_variables +from inputremapper.injection.macros.parse import Parser +from tests.lib.test_setup import test_setup +from tests.unit.test_macros.macro_test_base import MacroTestBase, DummyMapping + + +@test_setup +class TestSet(MacroTestBase): + async def test_set_key(self): + code_b = keyboard_layout.get("b") + macro = Parser.parse("set(foo, b).key($foo)", self.context, DummyMapping) + await macro.run(self.handler) + self.assertListEqual( + self.result, + [ + (EV_KEY, code_b, 1), + (EV_KEY, code_b, 0), + ], + ) + + async def test_int_is_explicit_string(self): + await Parser.parse( + 'set( \t"b" \n, "1")', + self.context, + DummyMapping, + ).run(self.handler) + self.assertEqual(macro_variables.get("b"), "1") + + async def test_int_is_int(self): + await Parser.parse( + "set(a, 1)", + self.context, + DummyMapping, + ).run(self.handler) + self.assertEqual(macro_variables.get("a"), 1) + + async def test_none(self): + await Parser.parse( + "set(a, )", + self.context, + DummyMapping, + ).run(self.handler) + self.assertEqual(macro_variables.get("a"), None) + + async def test_set_case_sensitive_1(self): + await Parser.parse( + 'set(a, "foo")', + self.context, + DummyMapping, + ).run(self.handler) + self.assertEqual(macro_variables.get("a"), "foo") + self.assertEqual(macro_variables.get("A"), None) + + async def test_set_case_sensitive_2(self): + await Parser.parse( + 'set(A, "foo")', + self.context, + DummyMapping, + ).run(self.handler) + self.assertEqual(macro_variables.get("A"), "foo") + self.assertEqual(macro_variables.get("a"), None) + + async def test_raises_error(self): + self.assertRaises(MacroError, Parser.parse, "set($a, 1)", self.context) + self.assertRaises(MacroError, Parser.parse, "set(1, 2)", self.context) + self.assertRaises(MacroError, Parser.parse, "set(+, 2)", self.context) + self.assertRaises(MacroError, Parser.parse, "set(a(), 2)", self.context) + self.assertRaises(MacroError, Parser.parse, "set('b,c', 2)", self.context) + self.assertRaises(MacroError, Parser.parse, 'set("b,c", 2)', self.context) + Parser.parse("set(A, 2)", self.context) # no error + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/test_macros/test_toggle.py b/tests/unit/test_macros/test_toggle.py new file mode 100644 index 0000000..bfa01db --- /dev/null +++ b/tests/unit/test_macros/test_toggle.py @@ -0,0 +1,69 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + + +import asyncio +import unittest + +from evdev.ecodes import EV_KEY + +from inputremapper.configs.keyboard_layout import keyboard_layout +from inputremapper.injection.macros.parse import Parser +from tests.lib.test_setup import test_setup +from tests.unit.test_macros.macro_test_base import MacroTestBase, DummyMapping + + +@test_setup +class TestToggle(MacroTestBase): + async def test_toggle(self): + # repeats key(a) as long as macro is toggled + macro = Parser.parse("toggle(key(a))", self.context, DummyMapping) + code_a = keyboard_layout.get("a") + + self.assertEqual(self.count_child_macros(macro), 1) + self.assertEqual(self.count_tasks(macro), 2) + + # Start + macro.press_trigger() + macro.release_trigger() + asyncio.ensure_future(macro.run(self.handler)) + + await asyncio.sleep(0.1) + count_1 = self.result.count((EV_KEY, code_a, 1)) + self.assertGreater(count_1, 2) + + # Keeps writing more + await asyncio.sleep(0.1) + count_2 = self.result.count((EV_KEY, code_a, 1)) + self.assertGreater(count_2, count_1) + + # Stop + macro.press_trigger() + macro.release_trigger() + + count_3 = self.result.count((EV_KEY, code_a, 1)) + await asyncio.sleep(0.1) + count_4 = self.result.count((EV_KEY, code_a, 1)) + # ensure that the macro has stopped + self.assertEqual(count_3, count_4) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/test_macros/test_wait.py b/tests/unit/test_macros/test_wait.py new file mode 100644 index 0000000..c7a54c4 --- /dev/null +++ b/tests/unit/test_macros/test_wait.py @@ -0,0 +1,89 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + + +import time +import unittest + +from inputremapper.configs.validation_errors import MacroError +from inputremapper.injection.macros.macro import Macro +from inputremapper.injection.macros.parse import Parser +from tests.lib.test_setup import test_setup +from tests.unit.test_macros.macro_test_base import DummyMapping, MacroTestBase + + +@test_setup +class TestWait(MacroTestBase): + async def assert_time_randomized( + self, + macro: Macro, + min_: float, + max_: float, + ): + for _ in range(100): + start = time.time() + await macro.run(self.handler) + time_taken = time.time() - start + + # Any of the runs should be within the defined range, to prove that they + # are indeed random. + if min_ < time_taken < max_: + return + + raise AssertionError("`wait` was not randomized") + + async def test_wait_1_core(self): + mapping = DummyMapping() + mapping.macro_key_sleep_ms = 0 + macro = Parser.parse("repeat(5, wait(50))", self.context, mapping, True) + + start = time.time() + await macro.run(self.handler) + time_per_iteration = (time.time() - start) / 5 + + self.assertLess(abs(time_per_iteration - 0.05), 0.005) + + async def test_wait_2_ranged(self): + mapping = DummyMapping() + mapping.macro_key_sleep_ms = 0 + macro = Parser.parse("wait(1, 100)", self.context, mapping, True) + await self.assert_time_randomized(macro, 0.02, 0.08) + + async def test_wait_3_ranged_single_get(self): + mapping = DummyMapping() + mapping.macro_key_sleep_ms = 0 + macro = Parser.parse("set(a, 100).wait(1, $a)", self.context, mapping, True) + await self.assert_time_randomized(macro, 0.02, 0.08) + + async def test_wait_4_ranged_double_get(self): + mapping = DummyMapping() + mapping.macro_key_sleep_ms = 0 + macro = Parser.parse( + "set(a, 1).set(b, 100).wait($a, $b)", self.context, mapping, True + ) + await self.assert_time_randomized(macro, 0.02, 0.08) + + async def test_raises_error(self): + Parser.parse("w(2)", self.context) # no error + self.assertRaises(MacroError, Parser.parse, "wait(a)", self.context) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/test_mapping.py b/tests/unit/test_mapping.py new file mode 100644 index 0000000..fb53f45 --- /dev/null +++ b/tests/unit/test_mapping.py @@ -0,0 +1,503 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + +import unittest +from functools import partial + +from evdev.ecodes import ( + EV_REL, + REL_X, + EV_KEY, + REL_Y, + REL_WHEEL, + REL_WHEEL_HI_RES, + KEY_1, + KEY_ESC, +) + +try: + from pydantic.v1 import ValidationError +except ImportError: + from pydantic import ValidationError + +from inputremapper.configs.mapping import Mapping, UIMapping, MappingType +from inputremapper.configs.keyboard_layout import keyboard_layout, DISABLE_NAME +from inputremapper.configs.input_config import InputCombination, InputConfig +from inputremapper.gui.messages.message_broker import MessageType +from tests.lib.test_setup import test_setup + + +@test_setup +class TestMapping(unittest.IsolatedAsyncioTestCase): + def test_init(self): + """Test init and that defaults are set.""" + cfg = { + "input_combination": [{"type": 1, "code": 2}], + "target_uinput": "keyboard", + "output_symbol": "a", + } + m = Mapping(**cfg) + self.assertEqual( + m.input_combination, InputCombination([InputConfig(type=1, code=2)]) + ) + self.assertEqual(m.target_uinput, "keyboard") + self.assertEqual(m.output_symbol, "a") + + self.assertIsNone(m.output_code) + self.assertIsNone(m.output_type) + + self.assertEqual(m.macro_key_sleep_ms, 0) + self.assertEqual(m.deadzone, 0.1) + self.assertEqual(m.gain, 1) + self.assertEqual(m.expo, 0) + self.assertEqual(m.rel_rate, 60) + self.assertEqual(m.rel_to_abs_input_cutoff, 2) + self.assertEqual(m.release_timeout, 0.05) + + def test_is_wheel_output(self): + mapping = Mapping( + input_combination=InputCombination([InputConfig(type=EV_REL, code=REL_X)]), + target_uinput="keyboard", + output_type=EV_REL, + output_code=REL_Y, + ) + self.assertFalse(mapping.is_wheel_output()) + self.assertFalse(mapping.is_high_res_wheel_output()) + + mapping = Mapping( + input_combination=InputCombination([InputConfig(type=EV_REL, code=REL_X)]), + target_uinput="keyboard", + output_type=EV_REL, + output_code=REL_WHEEL, + ) + self.assertTrue(mapping.is_wheel_output()) + self.assertFalse(mapping.is_high_res_wheel_output()) + + mapping = Mapping( + input_combination=InputCombination([InputConfig(type=EV_REL, code=REL_X)]), + target_uinput="keyboard", + output_type=EV_REL, + output_code=REL_WHEEL_HI_RES, + ) + self.assertFalse(mapping.is_wheel_output()) + self.assertTrue(mapping.is_high_res_wheel_output()) + + def test_get_output_type_code(self): + cfg = { + "input_combination": [{"type": 1, "code": 2}], + "target_uinput": "keyboard", + "output_symbol": "a", + } + m = Mapping(**cfg) + a = keyboard_layout.get("a") + self.assertEqual(m.get_output_type_code(), (EV_KEY, a)) + + m.output_symbol = "key(a)" + self.assertIsNone(m.get_output_type_code()) + + cfg = { + "input_combination": [{"type": 1, "code": 2}, {"type": 3, "code": 1}], + "target_uinput": "keyboard", + "output_type": 2, + "output_code": 3, + } + m = Mapping(**cfg) + self.assertEqual(m.get_output_type_code(), (2, 3)) + + def test_strips_output_symbol(self): + cfg = { + "input_combination": [{"type": 1, "code": 2}], + "target_uinput": "keyboard", + "output_symbol": "\t a \n", + } + m = Mapping(**cfg) + a = keyboard_layout.get("a") + self.assertEqual(m.get_output_type_code(), (EV_KEY, a)) + + def test_combination_changed_callback(self): + cfg = { + "input_combination": [{"type": 1, "code": 1}], + "target_uinput": "keyboard", + "output_symbol": "a", + } + m = Mapping(**cfg) + arguments = [] + + def callback(*args): + arguments.append(tuple(args)) + + m.set_combination_changed_callback(callback) + m.input_combination = [{"type": 1, "code": 2}] + m.input_combination = [{"type": 1, "code": 3}] + + # make sure a copy works as expected and keeps the callback + m2 = m.copy() + m2.input_combination = [{"type": 1, "code": 4}] + m2.remove_combination_changed_callback() + m.remove_combination_changed_callback() + m.input_combination = [{"type": 1, "code": 5}] + m2.input_combination = [{"type": 1, "code": 6}] + self.assertEqual( + arguments, + [ + ( + InputCombination([{"type": 1, "code": 2}]), + InputCombination([{"type": 1, "code": 1}]), + ), + ( + InputCombination([{"type": 1, "code": 3}]), + InputCombination([{"type": 1, "code": 2}]), + ), + ( + InputCombination([{"type": 1, "code": 4}]), + InputCombination([{"type": 1, "code": 3}]), + ), + ], + ) + m.remove_combination_changed_callback() + + def test_init_fails(self): + """Test that the init fails with invalid data.""" + test = partial(self.assertRaises, ValidationError, Mapping) + cfg = { + "input_combination": [{"type": 1, "code": 2}], + "target_uinput": "keyboard", + "output_symbol": "a", + } + Mapping(**cfg) + + # missing output symbol + del cfg["output_symbol"] + test(**cfg) + cfg["output_code"] = KEY_ESC + test(**cfg) + cfg["output_type"] = EV_KEY + Mapping(**cfg) + + # matching type, code and symbol. This cannot be done via the ui, and requires + # manual editing of the preset file. + a = keyboard_layout.get("a") + cfg["output_code"] = a + cfg["output_symbol"] = "a" + cfg["output_type"] = EV_KEY + Mapping(**cfg) + + # macro + cfg["output_symbol"] = "key(a)" + test(**cfg) + cfg["output_symbol"] = "a" + Mapping(**cfg) + + # mismatching type, code and symbol + cfg["output_symbol"] = "b" + test(**cfg) + del cfg["output_type"] + del cfg["output_code"] + Mapping(**cfg) # no error + + # empty symbol string without type and code + cfg["output_symbol"] = "" + test(**cfg) + cfg["output_symbol"] = "a" + + # missing target + del cfg["target_uinput"] + test(**cfg) + + # unknown target + cfg["target_uinput"] = "foo" + test(**cfg) + cfg["target_uinput"] = "keyboard" + Mapping(**cfg) + + # missing input_combination + del cfg["input_combination"] + test(**cfg) + cfg["input_combination"] = [{"type": 1, "code": 2}] + Mapping(**cfg) + + # no macro and not a known symbol + cfg["output_symbol"] = "qux" + test(**cfg) + cfg["output_symbol"] = "key(a)" + Mapping(**cfg) + + # invalid macro + cfg["output_symbol"] = "key('a')" + test(**cfg) + cfg["output_symbol"] = "a" + Mapping(**cfg) + + # map axis but no output type and code given + cfg["input_combination"] = [{"type": 3, "code": 0}] + test(**cfg) + # output symbol=disable is allowed + cfg["output_symbol"] = DISABLE_NAME + Mapping(**cfg) + del cfg["output_symbol"] + cfg["output_code"] = 1 + cfg["output_type"] = 3 + Mapping(**cfg) + + # empty symbol string is allowed when type and code is set + cfg["output_symbol"] = "" + Mapping(**cfg) + del cfg["output_symbol"] + + # multiple axis as axis in event combination + cfg["input_combination"] = [{"type": 3, "code": 0}, {"type": 3, "code": 1}] + test(**cfg) + cfg["input_combination"] = [{"type": 3, "code": 0}] + Mapping(**cfg) + + del cfg["output_type"] + del cfg["output_code"] + cfg["input_combination"] = [{"type": 1, "code": 2}] + cfg["output_symbol"] = "a" + Mapping(**cfg) + + # map EV_ABS as key with trigger point out of range + cfg["input_combination"] = [{"type": 3, "code": 0, "analog_threshold": 100}] + test(**cfg) + cfg["input_combination"] = [{"type": 3, "code": 0, "analog_threshold": 99}] + Mapping(**cfg) + cfg["input_combination"] = [{"type": 3, "code": 0, "analog_threshold": -100}] + test(**cfg) + cfg["input_combination"] = [{"type": 3, "code": 0, "analog_threshold": -99}] + Mapping(**cfg) + + cfg["input_combination"] = [{"type": 1, "code": 2}] + Mapping(**cfg) + + # deadzone out of range + test(**cfg, deadzone=1.01) + test(**cfg, deadzone=-0.01) + Mapping(**cfg, deadzone=1) + Mapping(**cfg, deadzone=0) + + # expo out of range + test(**cfg, expo=1.01) + test(**cfg, expo=-1.01) + Mapping(**cfg, expo=1) + Mapping(**cfg, expo=-1) + + # negative rate + test(**cfg, rel_rate=-1) + test(**cfg, rel_rate=0) + + Mapping(**cfg, rel_rate=1) + Mapping(**cfg, rel_rate=200) + + # negative rel_to_abs_input_cutoff + test(**cfg, rel_to_abs_input_cutoff=-1) + test(**cfg, rel_to_abs_input_cutoff=0) + Mapping(**cfg, rel_to_abs_input_cutoff=1) + Mapping(**cfg, rel_to_abs_input_cutoff=3) + + # negative release timeout + test(**cfg, release_timeout=-0.1) + test(**cfg, release_timeout=0) + Mapping(**cfg, release_timeout=0.05) + Mapping(**cfg, release_timeout=0.3) + + # analog output but no analog input + cfg = { + "input_combination": [{"type": 3, "code": 1, "analog_threshold": -1}], + "target_uinput": "gamepad", + "output_type": 3, + "output_code": 1, + } + test(**cfg) + cfg["input_combination"] = [{"type": 2, "code": 1, "analog_threshold": -1}] + test(**cfg) + cfg["output_type"] = 2 + test(**cfg) + cfg["input_combination"] = [{"type": 3, "code": 1, "analog_threshold": -1}] + test(**cfg) + + def test_automatically_detects_mapping_type(self): + cfg = { + "input_combination": [{"type": 1, "code": 2}], + "target_uinput": "keyboard", + "output_symbol": "a", + } + self.assertEqual(Mapping(**cfg).mapping_type, MappingType.KEY_MACRO.value) + + cfg = { + "input_combination": [{"type": 1, "code": 2}], + "target_uinput": "keyboard", + "output_type": EV_KEY, + "output_code": KEY_ESC, + } + self.assertEqual(Mapping(**cfg).mapping_type, MappingType.KEY_MACRO.value) + + cfg = { + "input_combination": [{"type": EV_REL, "code": REL_X}], + "target_uinput": "keyboard", + "output_type": EV_REL, + "output_code": REL_X, + } + self.assertEqual(Mapping(**cfg).mapping_type, MappingType.ANALOG.value) + + def test_revalidate_at_assignment(self): + cfg = { + "input_combination": [{"type": 1, "code": 1}], + "target_uinput": "keyboard", + "output_symbol": "a", + } + m = Mapping(**cfg) + test = partial(self.assertRaises, ValidationError, m.__setattr__) + + # invalid input event + test("input_combination", "1,2,3,4") + + # unknown target + test("target_uinput", "foo") + + # invalid macro + test("output_symbol", "key()") + + # we could do a lot more tests here but since pydantic uses the same validation + # code as for the initialization we only need to make sure that the + # assignment validation is active + + def test_set_invalid_combination_with_callback(self): + cfg = { + "input_combination": [{"type": 1, "code": 1}], + "target_uinput": "keyboard", + "output_symbol": "a", + } + m = Mapping(**cfg) + m.set_combination_changed_callback(lambda *args: None) + self.assertRaises(ValidationError, m.__setattr__, "input_combination", "1,2") + m.input_combination = [{"type": 1, "code": 2}] + m.input_combination = [{"type": 1, "code": 2}] + + def test_is_valid(self): + cfg = { + "input_combination": [{"type": 1, "code": 1}], + "target_uinput": "keyboard", + "output_symbol": "a", + } + m = Mapping(**cfg) + self.assertTrue(m.is_valid()) + + def test_wrong_target(self): + mapping = Mapping( + input_combination=[{"type": EV_KEY, "code": KEY_1}], + target_uinput="keyboard", + output_symbol="a", + ) + mapping.set_combination_changed_callback(lambda *args: None) + self.assertRaisesRegex( + ValidationError, + # the error should mention + # - the symbol + # - the current incorrect target + # - the target that works for this symbol + ".*BTN_A.*keyboard.*gamepad", + mapping.__setattr__, + "output_symbol", + "BTN_A", + ) + + def test_wrong_target_for_macro(self): + mapping = Mapping( + input_combination=[{"type": EV_KEY, "code": KEY_1}], + target_uinput="keyboard", + output_symbol="key(a)", + ) + mapping.set_combination_changed_callback(lambda *args: None) + self.assertRaisesRegex( + ValidationError, + # the error should mention + # - the symbol + # - the current incorrect target + # - the target that works for this symbol + ".*BTN_A.*keyboard.*gamepad", + mapping.__setattr__, + "output_symbol", + "key(BTN_A)", + ) + + +@test_setup +class TestUIMapping(unittest.IsolatedAsyncioTestCase): + def test_init(self): + """Should be able to initialize without throwing errors.""" + UIMapping() + + def test_is_valid(self): + """Should be invalid at first and become valid once all data is provided.""" + mapping = UIMapping() + self.assertFalse(mapping.is_valid()) + + mapping.input_combination = [{"type": EV_KEY, "code": KEY_1}] + mapping.output_symbol = "a" + self.assertFalse(mapping.is_valid()) + mapping.target_uinput = "keyboard" + self.assertTrue(mapping.is_valid()) + + def test_updates_validation_error(self): + mapping = UIMapping() + self.assertGreaterEqual(len(mapping.get_error().errors()), 2) + mapping.input_combination = [{"type": EV_KEY, "code": KEY_1}] + mapping.output_symbol = "a" + self.assertIn( + "1 validation error for Mapping\ntarget_uinput", + str(mapping.get_error()), + ) + mapping.target_uinput = "keyboard" + self.assertTrue(mapping.is_valid()) + self.assertIsNone(mapping.get_error()) + + def test_copy_returns_ui_mapping(self): + """Copy should also be a UIMapping with all the invalid data.""" + mapping = UIMapping() + mapping_2 = mapping.copy() + self.assertIsInstance(mapping_2, UIMapping) + self.assertEqual( + mapping_2.input_combination, InputCombination.empty_combination() + ) + self.assertIsNone(mapping_2.output_symbol) + + def test_get_bus_massage(self): + mapping = UIMapping() + mapping_2 = mapping.get_bus_message() + self.assertEqual(mapping_2.message_type, MessageType.mapping) + + with self.assertRaises(TypeError): + # the massage should be immutable + mapping_2.output_symbol = "a" + self.assertIsNone(mapping_2.output_symbol) + + # the original should be not immutable + mapping.output_symbol = "a" + self.assertEqual(mapping.output_symbol, "a") + + def test_has_input_defined(self): + mapping = UIMapping() + self.assertFalse(mapping.has_input_defined()) + mapping.input_combination = InputCombination([InputConfig(type=EV_KEY, code=1)]) + self.assertTrue(mapping.has_input_defined()) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/test_message_broker.py b/tests/unit/test_message_broker.py new file mode 100644 index 0000000..f1ebf1c --- /dev/null +++ b/tests/unit/test_message_broker.py @@ -0,0 +1,109 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + +import unittest +from dataclasses import dataclass + +from inputremapper.gui.messages.message_broker import MessageBroker, MessageType, Signal +from tests.lib.test_setup import test_setup + + +class Listener: + def __init__(self): + self.calls = [] + + def __call__(self, data): + self.calls.append(data) + + +@dataclass +class Message: + message_type: MessageType + msg: str + + +@test_setup +class TestMessageBroker(unittest.TestCase): + def test_calls_listeners(self): + """The correct Listeners get called""" + message_broker = MessageBroker() + listener = Listener() + message_broker.subscribe(MessageType.test1, listener) + message_broker.publish(Message(MessageType.test1, "foo")) + message_broker.publish(Message(MessageType.test2, "bar")) + self.assertEqual(listener.calls[0], Message(MessageType.test1, "foo")) + + def test_unsubscribe(self): + message_broker = MessageBroker() + listener = Listener() + message_broker.subscribe(MessageType.test1, listener) + message_broker.publish(Message(MessageType.test1, "a")) + message_broker.unsubscribe(listener) + message_broker.publish(Message(MessageType.test1, "b")) + self.assertEqual(len(listener.calls), 1) + self.assertEqual(listener.calls[0], Message(MessageType.test1, "a")) + + def test_unsubscribe_unknown_listener(self): + """nothing happens if we unsubscribe an unknown listener""" + message_broker = MessageBroker() + listener1 = Listener() + listener2 = Listener() + message_broker.subscribe(MessageType.test1, listener1) + message_broker.unsubscribe(listener2) + message_broker.publish(Message(MessageType.test1, "a")) + self.assertEqual(listener1.calls[0], Message(MessageType.test1, "a")) + + def test_preserves_order(self): + message_broker = MessageBroker() + calls = [] + + def listener1(_): + message_broker.publish(Message(MessageType.test2, "f")) + calls.append(1) + + def listener2(_): + message_broker.publish(Message(MessageType.test2, "f")) + calls.append(2) + + def listener3(_): + message_broker.publish(Message(MessageType.test2, "f")) + calls.append(3) + + def listener4(_): + calls.append(4) + + message_broker.subscribe(MessageType.test1, listener1) + message_broker.subscribe(MessageType.test1, listener2) + message_broker.subscribe(MessageType.test1, listener3) + message_broker.subscribe(MessageType.test2, listener4) + message_broker.publish(Message(MessageType.test1, "")) + + first = calls[:3] + first.sort() + self.assertEqual([1, 2, 3], first) + self.assertEqual([4, 4, 4], calls[3:]) + + +@test_setup +class TestSignal(unittest.TestCase): + def test_eq(self): + self.assertEqual(Signal(MessageType.uinputs), Signal(MessageType.uinputs)) + self.assertNotEqual(Signal(MessageType.uinputs), Signal(MessageType.groups)) + self.assertNotEqual(Signal(MessageType.uinputs), "Signal: MessageType.uinputs") diff --git a/tests/unit/test_migrations.py b/tests/unit/test_migrations.py new file mode 100644 index 0000000..8bc6f11 --- /dev/null +++ b/tests/unit/test_migrations.py @@ -0,0 +1,726 @@ +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + +import json +import os +import shutil +import unittest +from packaging import version + +from evdev.ecodes import ( + EV_KEY, + EV_ABS, + ABS_HAT0X, + ABS_X, + ABS_Y, + ABS_RX, + ABS_RY, + EV_REL, + REL_X, + REL_Y, + REL_WHEEL_HI_RES, + REL_HWHEEL_HI_RES, +) + +from inputremapper.configs.input_config import InputCombination, InputConfig +from inputremapper.configs.mapping import UIMapping +from inputremapper.configs.migrations import Migrations +from inputremapper.configs.paths import PathUtils +from inputremapper.configs.preset import Preset +from inputremapper.injection.global_uinputs import GlobalUInputs, UInput +from inputremapper.logging.logger import VERSION +from inputremapper.user import UserUtils +from tests.lib.test_setup import test_setup +from tests.lib.tmp import tmp + + +@test_setup +class TestMigrations(unittest.TestCase): + def setUp(self): + # some extra care to ensure those tests are not destroying actual presets + self.assertTrue(UserUtils.home.startswith("/tmp")) + self.assertTrue(PathUtils.config_path().startswith("/tmp")) + self.assertTrue(PathUtils.get_preset_path().startswith("/tmp")) + self.assertTrue(PathUtils.get_preset_path("foo", "bar").startswith("/tmp")) + self.assertTrue(PathUtils.get_config_path().startswith("/tmp")) + self.assertTrue(PathUtils.get_config_path("foo").startswith("/tmp")) + + self.v1_dir = os.path.join(UserUtils.home, ".config", "input-remapper") + self.beta_dir = os.path.join( + UserUtils.home, ".config", "input-remapper", "beta_1.6.0-beta" + ) + + global_uinputs = GlobalUInputs(UInput) + global_uinputs.prepare_all() + self.migrations = Migrations(global_uinputs) + + def test_migrate_suffix(self): + old = os.path.join(PathUtils.config_path(), "config") + new = os.path.join(PathUtils.config_path(), "config.json") + + try: + os.remove(new) + except FileNotFoundError: + pass + + PathUtils.touch(old) + with open(old, "w") as f: + f.write("{}") + + self.migrations.migrate() + self.assertTrue(os.path.exists(new)) + self.assertFalse(os.path.exists(old)) + + def test_rename_config(self): + old = os.path.join(UserUtils.home, ".config", "key-mapper") + new = PathUtils.config_path() + + # we are not destroying our actual config files with this test + self.assertTrue(new.startswith(tmp), f'Expected "{new}" to start with "{tmp}"') + + try: + shutil.rmtree(new) + except FileNotFoundError: + pass + + old_config_json = os.path.join(old, "config.json") + PathUtils.touch(old_config_json) + with open(old_config_json, "w") as f: + f.write('{"foo":"bar"}') + + self.migrations.migrate() + + self.assertTrue(os.path.exists(new)) + self.assertFalse(os.path.exists(old)) + + new_config_json = os.path.join(new, "config.json") + with open(new_config_json, "r") as f: + moved_config = json.loads(f.read()) + self.assertEqual(moved_config["foo"], "bar") + + def test_wont_migrate_suffix(self): + old = os.path.join(PathUtils.config_path(), "config") + new = os.path.join(PathUtils.config_path(), "config.json") + + PathUtils.touch(new) + with open(new, "w") as f: + f.write("{}") + + PathUtils.touch(old) + with open(old, "w") as f: + f.write("{}") + + self.migrations.migrate() + self.assertTrue(os.path.exists(new)) + self.assertTrue(os.path.exists(old)) + + def test_migrate_preset(self): + if os.path.exists(PathUtils.config_path()): + shutil.rmtree(PathUtils.config_path()) + + p1 = os.path.join(PathUtils.config_path(), "foo1", "bar1.json") + p2 = os.path.join(PathUtils.config_path(), "foo2", "bar2.json") + PathUtils.touch(p1) + PathUtils.touch(p2) + + with open(p1, "w") as f: + f.write("{}") + + with open(p2, "w") as f: + f.write("{}") + + self.migrations.migrate() + + self.assertFalse( + os.path.exists(os.path.join(PathUtils.config_path(), "foo1", "bar1.json")) + ) + self.assertFalse( + os.path.exists(os.path.join(PathUtils.config_path(), "foo2", "bar2.json")) + ) + + self.assertTrue( + os.path.exists( + os.path.join(PathUtils.config_path(), "presets", "foo1", "bar1.json") + ), + ) + self.assertTrue( + os.path.exists( + os.path.join(PathUtils.config_path(), "presets", "foo2", "bar2.json") + ), + ) + + def test_wont_migrate_preset(self): + if os.path.exists(PathUtils.config_path()): + shutil.rmtree(PathUtils.config_path()) + + p1 = os.path.join(PathUtils.config_path(), "foo1", "bar1.json") + p2 = os.path.join(PathUtils.config_path(), "foo2", "bar2.json") + PathUtils.touch(p1) + PathUtils.touch(p2) + + with open(p1, "w") as f: + f.write("{}") + + with open(p2, "w") as f: + f.write("{}") + + # already migrated + PathUtils.mkdir(os.path.join(PathUtils.config_path(), "presets")) + + self.migrations.migrate() + + self.assertTrue( + os.path.exists(os.path.join(PathUtils.config_path(), "foo1", "bar1.json")) + ) + self.assertTrue( + os.path.exists(os.path.join(PathUtils.config_path(), "foo2", "bar2.json")) + ) + + self.assertFalse( + os.path.exists( + os.path.join(PathUtils.config_path(), "presets", "foo1", "bar1.json") + ), + ) + self.assertFalse( + os.path.exists( + os.path.join(PathUtils.config_path(), "presets", "foo2", "bar2.json") + ), + ) + + def test_migrate_mappings(self): + """Test if mappings are migrated correctly + + mappings like + {(type, code): symbol} or {(type, code, value): symbol} should migrate + to {InputCombination: {target: target, symbol: symbol, ...}} + """ + path = os.path.join( + PathUtils.config_path(), "presets", "Foo Device", "test.json" + ) + os.makedirs(os.path.dirname(path), exist_ok=True) + with open(path, "w") as file: + json.dump( + { + "mapping": { + f"{EV_KEY},1": "a", + f"{EV_KEY}, 2, 1": "BTN_B", # can be mapped to "gamepad" + f"{EV_KEY}, 3, 1": "BTN_1", # can not be mapped + f"{EV_ABS},{ABS_HAT0X},-1": "b", + f"{EV_ABS},1,1+{EV_ABS},2,-1+{EV_ABS},3,1": "c", + f"{EV_KEY}, 4, 1": ("d", "keyboard"), + f"{EV_KEY}, 5, 1": ("e", "foo"), # unknown target + f"{EV_KEY}, 6, 1": ("key(a, b)", "keyboard"), # broken macro + # ignored because broken + "3,1,1,2": "e", + "3": "e", + ",,+3,1,2": "g", + "": "h", + } + }, + file, + ) + self.migrations.migrate() + # use UIMapping to also load invalid mappings + preset = Preset(PathUtils.get_preset_path("Foo Device", "test"), UIMapping) + preset.load() + + self.assertEqual( + preset.get_mapping(InputCombination([InputConfig(type=EV_KEY, code=1)])), + UIMapping( + input_combination=InputCombination([InputConfig(type=EV_KEY, code=1)]), + target_uinput="keyboard", + output_symbol="a", + ), + ) + self.assertEqual( + preset.get_mapping(InputCombination([InputConfig(type=EV_KEY, code=2)])), + UIMapping( + input_combination=InputCombination([InputConfig(type=EV_KEY, code=2)]), + target_uinput="gamepad", + output_symbol="BTN_B", + ), + ) + self.assertEqual( + preset.get_mapping(InputCombination([InputConfig(type=EV_KEY, code=3)])), + UIMapping( + input_combination=InputCombination([InputConfig(type=EV_KEY, code=3)]), + target_uinput="keyboard", + output_symbol="BTN_1\n# Broken mapping:\n# No target can handle all specified keycodes", + ), + ) + self.assertEqual( + preset.get_mapping(InputCombination([InputConfig(type=EV_KEY, code=4)])), + UIMapping( + input_combination=InputCombination([InputConfig(type=EV_KEY, code=4)]), + target_uinput="keyboard", + output_symbol="d", + ), + ) + self.assertEqual( + preset.get_mapping( + InputCombination( + [InputConfig(type=EV_ABS, code=ABS_HAT0X, analog_threshold=-1)] + ) + ), + UIMapping( + input_combination=InputCombination( + [InputConfig(type=EV_ABS, code=ABS_HAT0X, analog_threshold=-1)] + ), + target_uinput="keyboard", + output_symbol="b", + ), + ) + self.assertEqual( + preset.get_mapping( + InputCombination( + InputCombination.from_tuples( + (EV_ABS, 1, 1), (EV_ABS, 2, -1), (EV_ABS, 3, 1) + ) + ), + ), + UIMapping( + input_combination=InputCombination( + InputCombination.from_tuples( + (EV_ABS, 1, 1), (EV_ABS, 2, -1), (EV_ABS, 3, 1) + ), + ), + target_uinput="keyboard", + output_symbol="c", + ), + ) + self.assertEqual( + preset.get_mapping(InputCombination([InputConfig(type=EV_KEY, code=5)])), + UIMapping( + input_combination=InputCombination([InputConfig(type=EV_KEY, code=5)]), + target_uinput="foo", + output_symbol="e", + ), + ) + self.assertEqual( + preset.get_mapping(InputCombination([InputConfig(type=EV_KEY, code=6)])), + UIMapping( + input_combination=InputCombination([InputConfig(type=EV_KEY, code=6)]), + target_uinput="keyboard", + output_symbol="key(a, b)", + ), + ) + + self.assertEqual(8, len(preset)) + + def test_migrate_otherwise(self): + path = os.path.join( + PathUtils.config_path(), "presets", "Foo Device", "test.json" + ) + os.makedirs(os.path.dirname(path), exist_ok=True) + with open(path, "w") as file: + json.dump( + { + "mapping": { + f"{EV_KEY},1,1": ("otherwise + otherwise", "keyboard"), + f"{EV_KEY},2,1": ("bar($otherwise)", "keyboard"), + f"{EV_KEY},3,1": ("foo(otherwise=qux)", "keyboard"), + f"{EV_KEY},4,1": ("qux(otherwise).bar(otherwise = 1)", "foo"), + f"{EV_KEY},5,1": ("foo(otherwise1=2qux)", "keyboard"), + } + }, + file, + ) + + self.migrations.migrate() + + preset = Preset(PathUtils.get_preset_path("Foo Device", "test"), UIMapping) + preset.load() + + self.assertEqual( + preset.get_mapping(InputCombination([InputConfig(type=EV_KEY, code=1)])), + UIMapping( + input_combination=InputCombination([InputConfig(type=EV_KEY, code=1)]), + target_uinput="keyboard", + output_symbol="otherwise + otherwise", + ), + ) + self.assertEqual( + preset.get_mapping(InputCombination([InputConfig(type=EV_KEY, code=2)])), + UIMapping( + input_combination=InputCombination([InputConfig(type=EV_KEY, code=2)]), + target_uinput="keyboard", + output_symbol="bar($otherwise)", + ), + ) + self.assertEqual( + preset.get_mapping(InputCombination([InputConfig(type=EV_KEY, code=3)])), + UIMapping( + input_combination=InputCombination([InputConfig(type=EV_KEY, code=3)]), + target_uinput="keyboard", + output_symbol="foo(else=qux)", + ), + ) + self.assertEqual( + preset.get_mapping(InputCombination([InputConfig(type=EV_KEY, code=4)])), + UIMapping( + input_combination=InputCombination([InputConfig(type=EV_KEY, code=4)]), + target_uinput="foo", + output_symbol="qux(otherwise).bar(else=1)", + ), + ) + self.assertEqual( + preset.get_mapping(InputCombination([InputConfig(type=EV_KEY, code=5)])), + UIMapping( + input_combination=InputCombination([InputConfig(type=EV_KEY, code=5)]), + target_uinput="keyboard", + output_symbol="foo(otherwise1=2qux)", + ), + ) + + def test_add_version(self): + path = os.path.join(PathUtils.config_path(), "config.json") + os.makedirs(os.path.dirname(path), exist_ok=True) + with open(path, "w") as file: + file.write("{}") + + self.migrations.migrate() + self.assertEqual( + version.parse(VERSION), + self.migrations.config_version(), + ) + + def test_update_version(self): + path = os.path.join(PathUtils.config_path(), "config.json") + os.makedirs(os.path.dirname(path), exist_ok=True) + with open(path, "w") as file: + json.dump({"version": "0.1.0"}, file) + + self.migrations.migrate() + self.assertEqual( + version.parse(VERSION), + self.migrations.config_version(), + ) + + def test_config_version(self): + path = os.path.join(PathUtils.config_path(), "config.json") + with open(path, "w") as file: + file.write("{}") + + self.assertEqual("0.0.0", self.migrations.config_version().public) + + try: + os.remove(path) + except FileNotFoundError: + pass + + self.assertEqual("0.0.0", self.migrations.config_version().public) + + def test_migrate_left_and_right_purpose(self): + path = os.path.join( + PathUtils.config_path(), "presets", "Foo Device", "test.json" + ) + os.makedirs(os.path.dirname(path), exist_ok=True) + with open(path, "w") as file: + json.dump( + { + "gamepad": { + "joystick": { + "left_purpose": "mouse", + "right_purpose": "wheel", + "pointer_speed": 50, + "x_scroll_speed": 10, + "y_scroll_speed": 20, + } + } + }, + file, + ) + self.migrations.migrate() + + preset = Preset(PathUtils.get_preset_path("Foo Device", "test"), UIMapping) + preset.load() + # 2 mappings for mouse + # 2 mappings for wheel + self.assertEqual(len(preset), 4) + self.assertEqual( + preset.get_mapping( + InputCombination([InputConfig(type=EV_ABS, code=ABS_X)]) + ), + UIMapping( + input_combination=InputCombination( + [InputConfig(type=EV_ABS, code=ABS_X)] + ), + target_uinput="mouse", + output_type=EV_REL, + output_code=REL_X, + gain=50 / 100, + ), + ) + self.assertEqual( + preset.get_mapping( + InputCombination([InputConfig(type=EV_ABS, code=ABS_Y)]) + ), + UIMapping( + input_combination=InputCombination( + [InputConfig(type=EV_ABS, code=ABS_Y)] + ), + target_uinput="mouse", + output_type=EV_REL, + output_code=REL_Y, + gain=50 / 100, + ), + ) + self.assertEqual( + preset.get_mapping( + InputCombination([InputConfig(type=EV_ABS, code=ABS_RX)]) + ), + UIMapping( + input_combination=InputCombination( + [InputConfig(type=EV_ABS, code=ABS_RX)] + ), + target_uinput="mouse", + output_type=EV_REL, + output_code=REL_HWHEEL_HI_RES, + gain=10, + ), + ) + self.assertEqual( + preset.get_mapping( + InputCombination([InputConfig(type=EV_ABS, code=ABS_RY)]) + ), + UIMapping( + input_combination=InputCombination( + [InputConfig(type=EV_ABS, code=ABS_RY)] + ), + target_uinput="mouse", + output_type=EV_REL, + output_code=REL_WHEEL_HI_RES, + gain=20, + ), + ) + + def test_migrate_left_and_right_purpose2(self): + # same as above, but left and right is swapped + + path = os.path.join( + PathUtils.config_path(), "presets", "Foo Device", "test.json" + ) + os.makedirs(os.path.dirname(path), exist_ok=True) + with open(path, "w") as file: + json.dump( + { + "gamepad": { + "joystick": { + "right_purpose": "mouse", + "left_purpose": "wheel", + "pointer_speed": 50, + "x_scroll_speed": 10, + "y_scroll_speed": 20, + } + } + }, + file, + ) + self.migrations.migrate() + + preset = Preset(PathUtils.get_preset_path("Foo Device", "test"), UIMapping) + preset.load() + # 2 mappings for mouse + # 2 mappings for wheel + self.assertEqual(len(preset), 4) + self.assertEqual( + preset.get_mapping( + InputCombination([InputConfig(type=EV_ABS, code=ABS_RX)]) + ), + UIMapping( + input_combination=InputCombination( + [InputConfig(type=EV_ABS, code=ABS_RX)] + ), + target_uinput="mouse", + output_type=EV_REL, + output_code=REL_X, + gain=50 / 100, + ), + ) + self.assertEqual( + preset.get_mapping( + InputCombination([InputConfig(type=EV_ABS, code=ABS_RY)]) + ), + UIMapping( + input_combination=InputCombination( + [InputConfig(type=EV_ABS, code=ABS_RY)] + ), + target_uinput="mouse", + output_type=EV_REL, + output_code=REL_Y, + gain=50 / 100, + ), + ) + self.assertEqual( + preset.get_mapping( + InputCombination([InputConfig(type=EV_ABS, code=ABS_X)]) + ), + UIMapping( + input_combination=InputCombination( + [InputConfig(type=EV_ABS, code=ABS_X)] + ), + target_uinput="mouse", + output_type=EV_REL, + output_code=REL_HWHEEL_HI_RES, + gain=10, + ), + ) + self.assertEqual( + preset.get_mapping( + InputCombination([InputConfig(type=EV_ABS, code=ABS_Y)]) + ), + UIMapping( + input_combination=InputCombination( + [InputConfig(type=EV_ABS, code=ABS_Y)] + ), + target_uinput="mouse", + output_type=EV_REL, + output_code=REL_WHEEL_HI_RES, + gain=20, + ), + ) + + def _create_v1_setup(self): + """Create all files needed to mimic an outdated v1 configuration.""" + device_name = "device_name" + + PathUtils.mkdir(os.path.join(self.v1_dir, "presets", device_name)) + v1_config = {"autoload": {device_name: "foo"}, "version": "1.0"} + with open(os.path.join(self.v1_dir, "config.json"), "w") as file: + json.dump(v1_config, file) + # insert something outdated that will be migrated, to ensure the files are + # first copied and then migrated. + with open( + os.path.join(self.v1_dir, "presets", device_name, "foo.json"), "w" + ) as file: + json.dump({"mapping": {f"{EV_KEY},1": "a"}}, file) + + def _create_beta_setup(self): + """Create all files needed to mimic a beta configuration.""" + device_name = "device_name" + + # same here, but a different contents to tell the difference + PathUtils.mkdir(os.path.join(self.beta_dir, "presets", device_name)) + beta_config = {"autoload": {device_name: "bar"}, "version": "1.6"} + with open(os.path.join(self.beta_dir, "config.json"), "w") as file: + json.dump(beta_config, file) + with open( + os.path.join(self.beta_dir, "presets", device_name, "bar.json"), "w" + ) as file: + json.dump( + [ + { + "input_combination": [ + {"type": EV_KEY, "code": 1}, + ], + "target_uinput": "keyboard", + "output_symbol": "b", + "mapping_type": "key_macro", + } + ], + file, + ) + + def test_prioritize_v1_over_beta_configs(self): + # if both v1 and beta presets and config exist, migrate v1 + PathUtils.remove(PathUtils.get_config_path()) + + device_name = "device_name" + self._create_v1_setup() + self._create_beta_setup() + + self.assertFalse(os.path.exists(PathUtils.get_preset_path(device_name, "foo"))) + self.assertFalse(os.path.exists(PathUtils.get_config_path("config.json"))) + + self.migrations.migrate() + + self.assertTrue(os.path.exists(PathUtils.get_preset_path(device_name, "foo"))) + self.assertTrue(os.path.exists(PathUtils.get_config_path("config.json"))) + self.assertFalse(os.path.exists(PathUtils.get_preset_path(device_name, "bar"))) + + # expect all original files to still exist + self.assertTrue(os.path.join(self.v1_dir, "config.json")) + self.assertTrue(os.path.join(self.v1_dir, "presets", "foo.json")) + self.assertTrue(os.path.join(self.beta_dir, "config.json")) + self.assertTrue(os.path.join(self.beta_dir, "presets", "bar.json")) + + # v1 configs should be in the v2 dir now, and migrated + with open(PathUtils.get_config_path("config.json"), "r") as f: + config_json = json.load(f) + self.assertDictEqual( + config_json, {"autoload": {device_name: "foo"}, "version": VERSION} + ) + with open(PathUtils.get_preset_path(device_name, "foo.json"), "r") as f: + os.system(f'cat { PathUtils.get_preset_path(device_name, "foo.json") }') + preset_foo_json = json.load(f) + self.assertEqual( + preset_foo_json, + [ + { + "input_combination": [ + {"type": EV_KEY, "code": 1}, + ], + "target_uinput": "keyboard", + "output_symbol": "a", + "mapping_type": "key_macro", + } + ], + ) + + def test_copy_over_beta_configs(self): + # same as test_prioritize_v1_over_beta_configs, but only create the beta + # directory without any v1 presets. + PathUtils.remove(PathUtils.get_config_path()) + + device_name = "device_name" + self._create_beta_setup() + + self.assertFalse(os.path.exists(PathUtils.get_preset_path(device_name, "bar"))) + self.assertFalse(os.path.exists(PathUtils.get_config_path("config.json"))) + + self.migrations.migrate() + + self.assertTrue(os.path.exists(PathUtils.get_preset_path(device_name, "bar"))) + self.assertTrue(os.path.exists(PathUtils.get_config_path("config.json"))) + + # expect all original files to still exist + self.assertTrue(os.path.join(self.beta_dir, "config.json")) + self.assertTrue(os.path.join(self.beta_dir, "presets", "bar.json")) + + # beta configs should be in the v2 dir now + with open(PathUtils.get_config_path("config.json"), "r") as f: + config_json = json.load(f) + self.assertDictEqual( + config_json, {"autoload": {device_name: "bar"}, "version": VERSION} + ) + with open(PathUtils.get_preset_path(device_name, "bar.json"), "r") as f: + os.system(f'cat { PathUtils.get_preset_path(device_name, "bar.json") }') + preset_foo_json = json.load(f) + self.assertEqual( + preset_foo_json, + [ + { + "input_combination": [ + {"type": EV_KEY, "code": 1}, + ], + "target_uinput": "keyboard", + "output_symbol": "b", + "mapping_type": "key_macro", + } + ], + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/test_paths.py b/tests/unit/test_paths.py new file mode 100644 index 0000000..49b898d --- /dev/null +++ b/tests/unit/test_paths.py @@ -0,0 +1,73 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + + +import os +import tempfile +import unittest + +from inputremapper.configs.paths import PathUtils +from tests.lib.test_setup import test_setup +from tests.lib.tmp import tmp + + +def _raise(error): + raise error + + +@test_setup +class TestPaths(unittest.TestCase): + def test_touch(self): + with tempfile.TemporaryDirectory() as local_tmp: + path_abcde = os.path.join(local_tmp, "a/b/c/d/e") + PathUtils.touch(path_abcde) + self.assertTrue(os.path.exists(path_abcde)) + self.assertTrue(os.path.isfile(path_abcde)) + self.assertRaises( + ValueError, + lambda: PathUtils.touch(os.path.join(local_tmp, "a/b/c/d/f/")), + ) + + def test_mkdir(self): + with tempfile.TemporaryDirectory() as local_tmp: + path_bcde = os.path.join(local_tmp, "b/c/d/e") + PathUtils.mkdir(path_bcde) + self.assertTrue(os.path.exists(path_bcde)) + self.assertTrue(os.path.isdir(path_bcde)) + + def test_get_preset_path(self): + self.assertTrue( + PathUtils.get_preset_path().startswith(PathUtils.get_config_path()) + ) + self.assertTrue(PathUtils.get_preset_path().endswith("presets")) + self.assertTrue(PathUtils.get_preset_path("a").endswith("presets/a")) + self.assertTrue( + PathUtils.get_preset_path("a", "b").endswith("presets/a/b.json") + ) + + def test_get_config_path(self): + # might end with /beta_XXX + self.assertTrue( + PathUtils.get_config_path().startswith(f"{tmp}/.config/input-remapper") + ) + self.assertTrue(PathUtils.get_config_path("a", "b").endswith("a/b")) + + def test_split_all(self): + self.assertListEqual(PathUtils.split_all("a/b/c/d"), ["a", "b", "c", "d"]) diff --git a/tests/unit/test_preset.py b/tests/unit/test_preset.py new file mode 100644 index 0000000..e3db558 --- /dev/null +++ b/tests/unit/test_preset.py @@ -0,0 +1,486 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + +import os +import unittest +from unittest.mock import patch + +from evdev.ecodes import EV_KEY, EV_ABS + +from inputremapper.configs.input_config import InputCombination, InputConfig +from inputremapper.configs.mapping import Mapping +from inputremapper.configs.mapping import UIMapping +from inputremapper.configs.paths import PathUtils +from inputremapper.configs.preset import Preset +from tests.lib.test_setup import test_setup + + +@test_setup +class TestPreset(unittest.TestCase): + def setUp(self): + self.preset = Preset(PathUtils.get_preset_path("foo", "bar2")) + self.assertFalse(self.preset.has_unsaved_changes()) + + def test_is_mapped_multiple_times(self): + combination = InputCombination( + InputCombination.from_tuples((1, 1, 1), (2, 2, 2), (3, 3, 3), (4, 4, 4)) + ) + permutations = combination.get_permutations() + self.assertEqual(len(permutations), 6) + + self.preset._mappings[permutations[0]] = Mapping( + input_combination=permutations[0], + target_uinput="keyboard", + output_symbol="a", + ) + self.assertFalse(self.preset._is_mapped_multiple_times(permutations[2])) + + self.preset._mappings[permutations[1]] = Mapping( + input_combination=permutations[1], + target_uinput="keyboard", + output_symbol="a", + ) + self.assertTrue(self.preset._is_mapped_multiple_times(permutations[2])) + + def test_has_unsaved_changes(self): + self.preset.path = PathUtils.get_preset_path("foo", "bar2") + self.preset.add(Mapping.from_combination()) + self.assertTrue(self.preset.has_unsaved_changes()) + self.preset.save() + self.assertFalse(self.preset.has_unsaved_changes()) + + self.preset.empty() + self.assertEqual(len(self.preset), 0) + # empty preset but non-empty file + self.assertTrue(self.preset.has_unsaved_changes()) + + # load again from the disc + self.preset.load() + self.assertEqual( + self.preset.get_mapping(InputCombination.empty_combination()), + Mapping.from_combination(), + ) + self.assertFalse(self.preset.has_unsaved_changes()) + + # change the path to a non exiting file + self.preset.path = PathUtils.get_preset_path("bar", "foo") + # the preset has a mapping, the file has not + self.assertTrue(self.preset.has_unsaved_changes()) + + # change back to the original path + self.preset.path = PathUtils.get_preset_path("foo", "bar2") + # no difference between file and memory + self.assertFalse(self.preset.has_unsaved_changes()) + + # modify the mapping + mapping = self.preset.get_mapping(InputCombination.empty_combination()) + mapping.gain = 0.5 + self.assertTrue(self.preset.has_unsaved_changes()) + self.preset.load() + + self.preset.path = PathUtils.get_preset_path("bar", "foo") + self.preset.remove(Mapping.from_combination().input_combination) + # empty preset and empty file + self.assertFalse(self.preset.has_unsaved_changes()) + + self.preset.path = PathUtils.get_preset_path("foo", "bar2") + # empty preset, but non-empty file + self.assertTrue(self.preset.has_unsaved_changes()) + self.preset.load() + self.assertEqual(len(self.preset), 1) + self.assertFalse(self.preset.has_unsaved_changes()) + + # delete the preset from the system: + self.preset.empty() + self.preset.save() + self.preset.load() + self.assertFalse(self.preset.has_unsaved_changes()) + self.assertEqual(len(self.preset), 0) + + def test_save_load(self): + one = InputConfig(type=EV_KEY, code=10) + two = InputConfig(type=EV_KEY, code=11) + three = InputConfig(type=EV_KEY, code=12) + + self.preset.add( + Mapping.from_combination(InputCombination([one]), "keyboard", "1") + ) + self.preset.add( + Mapping.from_combination(InputCombination([two]), "keyboard", "2") + ) + self.preset.add( + Mapping.from_combination(InputCombination((two, three)), "keyboard", "3"), + ) + self.preset.path = PathUtils.get_preset_path("Foo Device", "test") + self.preset.save() + + path = os.path.join( + PathUtils.config_path(), "presets", "Foo Device", "test.json" + ) + self.assertTrue(os.path.exists(path)) + + loaded = Preset(PathUtils.get_preset_path("Foo Device", "test")) + self.assertEqual(len(loaded), 0) + loaded.load() + + self.assertEqual(len(loaded), 3) + self.assertRaises(TypeError, loaded.get_mapping, one) + self.assertEqual( + loaded.get_mapping(InputCombination([one])), + Mapping.from_combination(InputCombination([one]), "keyboard", "1"), + ) + self.assertEqual( + loaded.get_mapping(InputCombination([two])), + Mapping.from_combination(InputCombination([two]), "keyboard", "2"), + ) + self.assertEqual( + loaded.get_mapping(InputCombination([two, three])), + Mapping.from_combination(InputCombination([two, three]), "keyboard", "3"), + ) + + # load missing file + preset = Preset(PathUtils.get_config_path("missing_file.json")) + self.assertRaises(FileNotFoundError, preset.load) + + def test_modify_mapping(self): + ev_1 = InputCombination([InputConfig(type=EV_KEY, code=1)]) + ev_3 = InputCombination([InputConfig(type=EV_KEY, code=2)]) + # only values between -99 and 99 are allowed as mapping for EV_ABS or EV_REL + ev_4 = InputCombination([InputConfig(type=EV_ABS, code=1, analog_threshold=99)]) + + # add the first mapping + self.preset.add(Mapping.from_combination(ev_1, "keyboard", "a")) + self.assertTrue(self.preset.has_unsaved_changes()) + self.assertEqual(len(self.preset), 1) + + # change ev_1 to ev_3 and change a to b + mapping = self.preset.get_mapping(ev_1) + mapping.input_combination = ev_3 + mapping.output_symbol = "b" + self.assertIsNone(self.preset.get_mapping(ev_1)) + self.assertEqual( + self.preset.get_mapping(ev_3), + Mapping.from_combination(ev_3, "keyboard", "b"), + ) + self.assertEqual(len(self.preset), 1) + + # add 4 + self.preset.add(Mapping.from_combination(ev_4, "keyboard", "c")) + self.assertEqual( + self.preset.get_mapping(ev_3), + Mapping.from_combination(ev_3, "keyboard", "b"), + ) + self.assertEqual( + self.preset.get_mapping(ev_4), + Mapping.from_combination(ev_4, "keyboard", "c"), + ) + self.assertEqual(len(self.preset), 2) + + # change the preset of 4 to d + mapping = self.preset.get_mapping(ev_4) + mapping.output_symbol = "d" + self.assertEqual( + self.preset.get_mapping(ev_4), + Mapping.from_combination(ev_4, "keyboard", "d"), + ) + self.assertEqual(len(self.preset), 2) + + # try to change combination of 4 to 3 + mapping = self.preset.get_mapping(ev_4) + with self.assertRaises(KeyError): + mapping.input_combination = ev_3 + + self.assertEqual( + self.preset.get_mapping(ev_3), + Mapping.from_combination(ev_3, "keyboard", "b"), + ) + self.assertEqual( + self.preset.get_mapping(ev_4), + Mapping.from_combination(ev_4, "keyboard", "d"), + ) + self.assertEqual(len(self.preset), 2) + + def test_avoids_redundant_saves(self): + with patch.object(self.preset, "has_unsaved_changes", lambda: False): + self.preset.path = PathUtils.get_preset_path("foo", "bar2") + self.preset.add(Mapping.from_combination()) + self.preset.save() + + with open(PathUtils.get_preset_path("foo", "bar2"), "r") as f: + content = f.read() + + self.assertFalse(content) + + def test_combinations(self): + ev_1 = InputConfig(type=EV_KEY, code=1, analog_threshold=111) + ev_2 = InputConfig(type=EV_KEY, code=1, analog_threshold=222) + ev_3 = InputConfig(type=EV_KEY, code=2, analog_threshold=111) + ev_4 = InputConfig(type=EV_ABS, code=1, analog_threshold=99) + combi_1 = InputCombination((ev_1, ev_2, ev_3)) + combi_2 = InputCombination((ev_2, ev_1, ev_3)) + combi_3 = InputCombination((ev_1, ev_2, ev_4)) + + self.preset.add(Mapping.from_combination(combi_1, "keyboard", "a")) + self.assertEqual( + self.preset.get_mapping(combi_1), + Mapping.from_combination(combi_1, "keyboard", "a"), + ) + self.assertEqual( + self.preset.get_mapping(combi_2), + Mapping.from_combination(combi_1, "keyboard", "a"), + ) + # since combi_1 and combi_2 are equivalent, this raises a KeyError + self.assertRaises( + KeyError, + self.preset.add, + Mapping.from_combination(combi_2, "keyboard", "b"), + ) + self.assertEqual( + self.preset.get_mapping(combi_1), + Mapping.from_combination(combi_1, "keyboard", "a"), + ) + self.assertEqual( + self.preset.get_mapping(combi_2), + Mapping.from_combination(combi_1, "keyboard", "a"), + ) + + self.preset.add(Mapping.from_combination(combi_3, "keyboard", "c")) + self.assertEqual( + self.preset.get_mapping(combi_1), + Mapping.from_combination(combi_1, "keyboard", "a"), + ) + self.assertEqual( + self.preset.get_mapping(combi_2), + Mapping.from_combination(combi_1, "keyboard", "a"), + ) + self.assertEqual( + self.preset.get_mapping(combi_3), + Mapping.from_combination(combi_3, "keyboard", "c"), + ) + + mapping = self.preset.get_mapping(combi_1) + mapping.output_symbol = "c" + with self.assertRaises(KeyError): + mapping.input_combination = combi_3 + + self.assertEqual( + self.preset.get_mapping(combi_1), + Mapping.from_combination(combi_1, "keyboard", "c"), + ) + self.assertEqual( + self.preset.get_mapping(combi_2), + Mapping.from_combination(combi_1, "keyboard", "c"), + ) + self.assertEqual( + self.preset.get_mapping(combi_3), + Mapping.from_combination(combi_3, "keyboard", "c"), + ) + + def test_remove(self): + # does nothing + ev_1 = InputCombination([InputConfig(type=EV_KEY, code=40)]) + ev_2 = InputCombination([InputConfig(type=EV_KEY, code=30)]) + ev_3 = InputCombination([InputConfig(type=EV_KEY, code=20)]) + ev_4 = InputCombination([InputConfig(type=EV_KEY, code=10)]) + + self.assertRaises(TypeError, self.preset.remove, (EV_KEY, 10, 1)) + self.preset.remove(ev_1) + self.assertFalse(self.preset.has_unsaved_changes()) + self.assertEqual(len(self.preset), 0) + + self.preset.add(Mapping.from_combination(input_combination=ev_1)) + self.assertEqual(len(self.preset), 1) + self.preset.remove(ev_1) + self.assertEqual(len(self.preset), 0) + + self.preset.add(Mapping.from_combination(ev_4, "keyboard", "KEY_KP1")) + self.assertTrue(self.preset.has_unsaved_changes()) + self.preset.add(Mapping.from_combination(ev_3, "keyboard", "KEY_KP2")) + self.preset.add(Mapping.from_combination(ev_2, "keyboard", "KEY_KP3")) + self.assertEqual(len(self.preset), 3) + self.preset.remove(ev_3) + self.assertEqual(len(self.preset), 2) + self.assertEqual( + self.preset.get_mapping(ev_4), + Mapping.from_combination(ev_4, "keyboard", "KEY_KP1"), + ) + self.assertIsNone(self.preset.get_mapping(ev_3)) + self.assertEqual( + self.preset.get_mapping(ev_2), + Mapping.from_combination(ev_2, "keyboard", "KEY_KP3"), + ) + + def test_empty(self): + self.preset.add( + Mapping.from_combination( + InputCombination([InputConfig(type=EV_KEY, code=10)]), + "keyboard", + "1", + ), + ) + self.preset.add( + Mapping.from_combination( + InputCombination([InputConfig(type=EV_KEY, code=11)]), + "keyboard", + "2", + ), + ) + self.preset.add( + Mapping.from_combination( + InputCombination([InputConfig(type=EV_KEY, code=12)]), + "keyboard", + "3", + ), + ) + self.assertEqual(len(self.preset), 3) + self.preset.path = PathUtils.get_config_path("test.json") + self.preset.save() + self.assertFalse(self.preset.has_unsaved_changes()) + + self.preset.empty() + self.assertEqual(self.preset.path, PathUtils.get_config_path("test.json")) + self.assertTrue(self.preset.has_unsaved_changes()) + self.assertEqual(len(self.preset), 0) + + def test_clear(self): + self.preset.add( + Mapping.from_combination( + InputCombination([InputConfig(type=EV_KEY, code=10)]), + "keyboard", + "1", + ), + ) + self.preset.add( + Mapping.from_combination( + InputCombination([InputConfig(type=EV_KEY, code=11)]), + "keyboard", + "2", + ), + ) + self.preset.add( + Mapping.from_combination( + InputCombination([InputConfig(type=EV_KEY, code=12)]), + "keyboard", + "3", + ), + ) + self.assertEqual(len(self.preset), 3) + self.preset.path = PathUtils.get_config_path("test.json") + self.preset.save() + self.assertFalse(self.preset.has_unsaved_changes()) + + self.preset.clear() + self.assertFalse(self.preset.has_unsaved_changes()) + self.assertIsNone(self.preset.path) + self.assertEqual(len(self.preset), 0) + + def test_dangerously_mapped_btn_left(self): + # btn left is mapped + self.preset.add( + Mapping.from_combination( + InputCombination([InputConfig.btn_left()]), + "keyboard", + "1", + ) + ) + self.assertTrue(self.preset.dangerously_mapped_btn_left()) + self.preset.add( + Mapping.from_combination( + InputCombination([InputConfig(type=EV_KEY, code=41)]), + "keyboard", + "2", + ) + ) + self.assertTrue(self.preset.dangerously_mapped_btn_left()) + + # another mapping maps to btn_left + self.preset.add( + Mapping.from_combination( + InputCombination([InputConfig(type=EV_KEY, code=42)]), + "mouse", + "btn_left", + ) + ) + self.assertFalse(self.preset.dangerously_mapped_btn_left()) + + mapping = self.preset.get_mapping( + InputCombination([InputConfig(type=EV_KEY, code=42)]) + ) + mapping.output_symbol = "BTN_Left" + self.assertFalse(self.preset.dangerously_mapped_btn_left()) + + mapping.target_uinput = "keyboard + mouse" + mapping.output_symbol = "3" + self.assertTrue(self.preset.dangerously_mapped_btn_left()) + + # btn_left is not mapped + self.preset.remove(InputCombination([InputConfig.btn_left()])) + self.assertFalse(self.preset.dangerously_mapped_btn_left()) + + def test_save_load_with_invalid_mappings(self): + ui_preset = Preset( + PathUtils.get_config_path("test.json"), mapping_factory=UIMapping + ) + + ui_preset.add(UIMapping()) + self.assertFalse(ui_preset.is_valid()) + + # make the mapping valid + m = ui_preset.get_mapping(InputCombination.empty_combination()) + m.output_symbol = "a" + m.target_uinput = "keyboard" + self.assertTrue(ui_preset.is_valid()) + + m2 = UIMapping( + input_combination=InputCombination([InputConfig(type=1, code=2)]) + ) + ui_preset.add(m2) + self.assertFalse(ui_preset.is_valid()) + ui_preset.save() + + # only the valid preset is loaded + preset = Preset(PathUtils.get_config_path("test.json")) + preset.load() + self.assertEqual(len(preset), 1) + + a = preset.get_mapping(m.input_combination).dict() + b = m.dict() + a.pop("mapping_type") + b.pop("mapping_type") + self.assertEqual(a, b) + # self.assertEqual(preset.get_mapping(m.input_combination), m) + + # both presets load + ui_preset.clear() + ui_preset.path = PathUtils.get_config_path("test.json") + ui_preset.load() + self.assertEqual(len(ui_preset), 2) + + a = ui_preset.get_mapping(m.input_combination).dict() + b = m.dict() + a.pop("mapping_type") + b.pop("mapping_type") + self.assertEqual(a, b) + # self.assertEqual(ui_preset.get_mapping(m.input_combination), m) + self.assertEqual(ui_preset.get_mapping(m2.input_combination), m2) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/test_reader.py b/tests/unit/test_reader.py new file mode 100644 index 0000000..b099990 --- /dev/null +++ b/tests/unit/test_reader.py @@ -0,0 +1,1052 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + +import asyncio +import json +import multiprocessing +import os +import time +import unittest +from typing import List, Optional +from unittest import mock +from unittest.mock import patch, MagicMock + +from evdev.ecodes import ( + EV_KEY, + EV_ABS, + ABS_HAT0X, + KEY_COMMA, + BTN_TOOL_DOUBLETAP, + KEY_A, + REL_WHEEL, + REL_X, + ABS_X, + REL_HWHEEL, + BTN_LEFT, + EV_REL, +) + +from inputremapper.configs.input_config import ( + InputCombination, + InputConfig, + DEFAULT_ABS_ANALOG_THRESHOLD_MAGNITUDE, +) +from inputremapper.groups import _Groups, DeviceType +from inputremapper.gui.messages.message_broker import ( + MessageBroker, + Signal, +) +from inputremapper.gui.messages.message_data import CombinationRecorded +from inputremapper.gui.messages.message_types import MessageType +from inputremapper.gui.reader_client import ReaderClient +from inputremapper.gui.reader_service import ( + ReaderService, + ContextDummy, + RELEASE_TIMEOUT, +) +from inputremapper.injection.global_uinputs import GlobalUInputs, UInput, FrontendUInput +from inputremapper.input_event import InputEvent +from tests.lib.constants import EVENT_READ_TIMEOUT, START_READING_DELAY +from tests.lib.fixtures import fixtures +from tests.lib.fixtures import new_event +from tests.lib.pipes import push_event, push_events +from tests.lib.spy import spy +from tests.lib.test_setup import test_setup + +CODE_1 = 100 +CODE_2 = 101 +CODE_3 = 102 + + +class Listener: + def __init__(self): + self.calls: List = [] + + def __call__(self, data): + self.calls.append(data) + + +def wait(func, timeout=1.0): + """Wait for func to return True.""" + iterations = 0 + sleepytime = 0.1 + while not func(): + time.sleep(sleepytime) + iterations += 1 + if iterations * sleepytime > timeout: + break + + +@test_setup +class TestReaderAsyncio(unittest.IsolatedAsyncioTestCase): + def setUp(self): + self.reader_service = None + self.groups = _Groups() + self.message_broker = MessageBroker() + self.reader_client = ReaderClient(self.message_broker, self.groups) + + def tearDown(self): + try: + self.reader_client.terminate() + except (BrokenPipeError, OSError): + pass + + async def create_reader_service(self, groups: Optional[_Groups] = None): + # this will cause pending events to be copied over to the reader-service + # process + if not groups: + groups = self.groups + + global_uinputs = GlobalUInputs(UInput) + assert groups is not None + self.reader_service = ReaderService(groups, global_uinputs) + asyncio.ensure_future(self.reader_service.run()) + + async def test_should_forward_to_dummy(self): + # It forwards to a ForwardDummy, because the gui process + # 1. can't inject and + # 2. is not even supposed to inject anything + # thanks to not using multiprocessing as opposed to the other tests, we can + # access this stuff + context = None + original_create_event_pipeline = ReaderService._create_event_pipeline + + def remember_context(*args, **kwargs): + nonlocal context + context = original_create_event_pipeline(*args, **kwargs) + return context + + with mock.patch.object( + ReaderService, + "_create_event_pipeline", + remember_context, + ): + await self.create_reader_service() + + listener = Listener() + self.message_broker.subscribe(MessageType.combination_recorded, listener) + + self.reader_client.set_group(self.groups.find(key="Foo Device 2")) + self.reader_client.start_recorder() + + await asyncio.sleep(0.1) + self.assertIsInstance(context, ContextDummy) + + with spy( + context.forward_dummy, + "write", + ) as write_spy: + events = [InputEvent.rel(REL_X, -1)] + push_events(fixtures.foo_device_2_mouse, events) + await asyncio.sleep(0.1) + self.reader_client._read() + self.assertEqual(0, len(listener.calls)) + + # we want `write` to be called on the forward_dummy, because we want + # those events to just disappear. + self.assertEqual(write_spy.call_count, len(events)) + self.assertEqual([call[0] for call in write_spy.call_args_list], events) + + +@test_setup +class TestReaderMultiprocessing(unittest.TestCase): + def setUp(self): + self.reader_service_process = None + self.groups = _Groups() + self.message_broker = MessageBroker() + self.global_uinputs = GlobalUInputs(UInput) + self.reader_client = ReaderClient(self.message_broker, self.groups) + + def tearDown(self): + try: + self.reader_client.terminate() + except (BrokenPipeError, OSError): + pass + + if self.reader_service_process is not None: + self.reader_service_process.join(timeout=1) + if self.reader_service_process.is_alive(): + self.reader_service_process.terminate() + + def create_reader_service(self, groups: Optional[_Groups] = None): + # this will cause pending events to be copied over to the reader-service + # process + if not groups: + groups = self.groups + + def start_reader_service(): + # this is a new process, so create a new event loop, and all dependencies + # from scratch, or something + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + global_uinputs = GlobalUInputs(FrontendUInput) + global_uinputs.reset() + reader_service = ReaderService(groups, global_uinputs) + loop.run_until_complete(reader_service.run()) + + self.reader_service_process = multiprocessing.Process( + target=start_reader_service + ) + self.reader_service_process.start() + time.sleep(0.1) + + def test_reading(self): + l1 = Listener() + l2 = Listener() + self.message_broker.subscribe(MessageType.combination_recorded, l1) + self.message_broker.subscribe(MessageType.recording_finished, l2) + self.create_reader_service() + self.reader_client.set_group(self.groups.find(key="Foo Device 2")) + self.reader_client.start_recorder() + + push_events(fixtures.foo_device_2_gamepad, [InputEvent.abs(ABS_HAT0X, 1)]) + # we need to sleep because we have two different fixtures, + # which will lead to race conditions + time.sleep(0.1) + + # relative axis events should be released automagically after 0.3s + push_events(fixtures.foo_device_2_mouse, [InputEvent.rel(REL_X, 5)]) + time.sleep(0.1) + # read all pending events. Having a glib mainloop would be better, + # as it would call read automatically periodically + self.reader_client._read() + + expected = [ + CombinationRecorded( + InputCombination( + [ + InputConfig( + type=EV_ABS, + code=16, + analog_threshold=DEFAULT_ABS_ANALOG_THRESHOLD_MAGNITUDE, + origin_hash=fixtures.foo_device_2_gamepad.get_device_hash(), + ) + ] + ) + ), + CombinationRecorded( + InputCombination( + [ + InputConfig( + type=EV_ABS, + code=16, + analog_threshold=DEFAULT_ABS_ANALOG_THRESHOLD_MAGNITUDE, + origin_hash=fixtures.foo_device_2_gamepad.get_device_hash(), + ), + InputConfig( + type=EV_REL, + code=0, + analog_threshold=1, + origin_hash=fixtures.foo_device_2_mouse.get_device_hash(), + ), + ] + ) + ), + ] + + self.assertEqual( + expected, + l1.calls, + ) + + # release the hat switch should emit the recording finished event + # as both the hat and relative axis are released by now + push_events(fixtures.foo_device_2_gamepad, [InputEvent.abs(ABS_HAT0X, 0)]) + time.sleep(RELEASE_TIMEOUT + 0.1) + self.reader_client._read() + self.assertEqual([Signal(MessageType.recording_finished)], l2.calls) + + def test_should_release_relative_axis(self): + l1 = Listener() + l2 = Listener() + self.message_broker.subscribe(MessageType.combination_recorded, l1) + self.message_broker.subscribe(MessageType.recording_finished, l2) + self.create_reader_service() + self.reader_client.set_group(self.groups.find(key="Foo Device 2")) + self.reader_client.start_recorder() + + push_events(fixtures.foo_device_2_mouse, [InputEvent.rel(REL_X, -5)]) + time.sleep(0.1) + self.reader_client._read() + + self.assertEqual( + [ + CombinationRecorded( + InputCombination( + [ + InputConfig( + type=EV_REL, + code=0, + analog_threshold=-1, + origin_hash=fixtures.foo_device_2_mouse.get_device_hash(), + ) + ] + ) + ) + ], + l1.calls, + ) + self.assertEqual([], l2.calls) # no stop recording yet + + time.sleep(RELEASE_TIMEOUT + 0.1) + self.reader_client._read() + self.assertEqual([Signal(MessageType.recording_finished)], l2.calls) + + def test_should_not_trigger_at_low_speed_for_rel_axis(self): + l1 = Listener() + self.message_broker.subscribe(MessageType.combination_recorded, l1) + self.create_reader_service() + self.reader_client.set_group(self.groups.find(key="Foo Device 2")) + self.reader_client.start_recorder() + + push_events(fixtures.foo_device_2_mouse, [InputEvent.rel(REL_X, -1)]) + time.sleep(0.1) + self.reader_client._read() + self.assertEqual(0, len(l1.calls)) + + def test_should_trigger_wheel_at_low_speed(self): + l1 = Listener() + self.message_broker.subscribe(MessageType.combination_recorded, l1) + self.create_reader_service() + self.reader_client.set_group(self.groups.find(key="Foo Device 2")) + self.reader_client.start_recorder() + + push_events( + fixtures.foo_device_2_mouse, + [InputEvent.rel(REL_WHEEL, -1), InputEvent.rel(REL_HWHEEL, 1)], + ) + time.sleep(0.1) + self.reader_client._read() + + self.assertEqual( + [ + CombinationRecorded( + InputCombination( + [ + InputConfig( + type=EV_REL, + code=8, + analog_threshold=-1, + origin_hash=fixtures.foo_device_2_mouse.get_device_hash(), + ) + ] + ) + ), + CombinationRecorded( + InputCombination( + [ + InputConfig( + type=EV_REL, + code=8, + analog_threshold=-1, + origin_hash=fixtures.foo_device_2_mouse.get_device_hash(), + ), + InputConfig( + type=EV_REL, + code=6, + analog_threshold=1, + origin_hash=fixtures.foo_device_2_mouse.get_device_hash(), + ), + ] + ) + ), + ], + l1.calls, + ) + + def test_wont_emit_the_same_combination_twice(self): + l1 = Listener() + self.message_broker.subscribe(MessageType.combination_recorded, l1) + self.create_reader_service() + self.reader_client.set_group(self.groups.find(key="Foo Device 2")) + self.reader_client.start_recorder() + + push_events(fixtures.foo_device_2_keyboard, [InputEvent.key(KEY_A, 1)]) + time.sleep(0.1) + self.reader_client._read() + # the duplicate event should be ignored + push_events(fixtures.foo_device_2_keyboard, [InputEvent.key(KEY_A, 1)]) + time.sleep(0.1) + self.reader_client._read() + + self.assertEqual( + [ + CombinationRecorded( + InputCombination( + [ + InputConfig( + type=EV_KEY, + code=30, + origin_hash=fixtures.foo_device_2_keyboard.get_device_hash(), + ) + ] + ) + ) + ], + l1.calls, + ) + + def test_should_read_absolut_axis(self): + l1 = Listener() + l2 = Listener() + self.message_broker.subscribe(MessageType.combination_recorded, l1) + self.message_broker.subscribe(MessageType.recording_finished, l2) + self.create_reader_service() + self.reader_client.set_group(self.groups.find(key="Foo Device 2")) + self.reader_client.start_recorder() + + # over 30% should trigger + push_events( + fixtures.foo_device_2_gamepad, + [InputEvent.abs(ABS_X, int(fixtures.foo_device_2_gamepad.max_abs * 0.4))], + ) + time.sleep(0.1) + self.reader_client._read() + self.assertEqual( + [ + CombinationRecorded( + InputCombination( + [ + InputConfig( + type=EV_ABS, + code=0, + analog_threshold=DEFAULT_ABS_ANALOG_THRESHOLD_MAGNITUDE, + origin_hash=fixtures.foo_device_2_gamepad.get_device_hash(), + ) + ] + ) + ) + ], + l1.calls, + ) + self.assertEqual([], l2.calls) # no stop recording yet + + # less the 30% should release + push_events( + fixtures.foo_device_2_gamepad, + [InputEvent.abs(ABS_X, int(fixtures.foo_device_2_gamepad.max_abs * 0.2))], + ) + time.sleep(0.1) + self.reader_client._read() + self.assertEqual( + [ + CombinationRecorded( + InputCombination( + [ + InputConfig( + type=EV_ABS, + code=0, + analog_threshold=DEFAULT_ABS_ANALOG_THRESHOLD_MAGNITUDE, + origin_hash=fixtures.foo_device_2_gamepad.get_device_hash(), + ) + ] + ) + ) + ], + l1.calls, + ) + self.assertEqual([Signal(MessageType.recording_finished)], l2.calls) + + def test_should_change_direction(self): + l1 = Listener() + self.message_broker.subscribe(MessageType.combination_recorded, l1) + self.create_reader_service() + self.reader_client.set_group(self.groups.find(key="Foo Device 2")) + self.reader_client.start_recorder() + + push_event(fixtures.foo_device_2_keyboard, InputEvent.key(KEY_A, 1)) + time.sleep(0.1) + push_event( + fixtures.foo_device_2_gamepad, + InputEvent.abs(ABS_X, int(fixtures.foo_device_2_gamepad.max_abs * 0.4)), + ) + time.sleep(0.1) + push_event(fixtures.foo_device_2_keyboard, InputEvent.key(KEY_COMMA, 1)) + time.sleep(0.1) + push_events( + fixtures.foo_device_2_gamepad, + [ + InputEvent.abs(ABS_X, int(fixtures.foo_device_2_gamepad.max_abs * 0.1)), + InputEvent.abs(ABS_X, int(fixtures.foo_device_2_gamepad.min_abs * 0.4)), + ], + ) + time.sleep(0.1) + self.reader_client._read() + self.assertEqual( + [ + CombinationRecorded( + InputCombination( + [ + InputConfig( + type=EV_KEY, + code=KEY_A, + origin_hash=fixtures.foo_device_2_keyboard.get_device_hash(), + ) + ] + ) + ), + CombinationRecorded( + InputCombination( + [ + InputConfig( + type=EV_KEY, + code=KEY_A, + origin_hash=fixtures.foo_device_2_keyboard.get_device_hash(), + ), + InputConfig( + type=EV_ABS, + code=ABS_X, + analog_threshold=DEFAULT_ABS_ANALOG_THRESHOLD_MAGNITUDE, + origin_hash=fixtures.foo_device_2_gamepad.get_device_hash(), + ), + ] + ) + ), + CombinationRecorded( + InputCombination( + [ + InputConfig( + type=EV_KEY, + code=KEY_A, + origin_hash=fixtures.foo_device_2_keyboard.get_device_hash(), + ), + InputConfig( + type=EV_ABS, + code=ABS_X, + analog_threshold=DEFAULT_ABS_ANALOG_THRESHOLD_MAGNITUDE, + origin_hash=fixtures.foo_device_2_gamepad.get_device_hash(), + ), + InputConfig( + type=EV_KEY, + code=KEY_COMMA, + origin_hash=fixtures.foo_device_2_keyboard.get_device_hash(), + ), + ] + ) + ), + CombinationRecorded( + InputCombination( + [ + InputConfig( + type=EV_KEY, + code=KEY_A, + origin_hash=fixtures.foo_device_2_keyboard.get_device_hash(), + ), + InputConfig( + type=EV_ABS, + code=ABS_X, + analog_threshold=-DEFAULT_ABS_ANALOG_THRESHOLD_MAGNITUDE, + origin_hash=fixtures.foo_device_2_gamepad.get_device_hash(), + ), + InputConfig( + type=EV_KEY, + code=KEY_COMMA, + origin_hash=fixtures.foo_device_2_keyboard.get_device_hash(), + ), + ] + ) + ), + ], + l1.calls, + ) + + def test_change_device(self): + l1 = Listener() + self.message_broker.subscribe(MessageType.combination_recorded, l1) + + push_events( + fixtures.foo_device_2_keyboard, + [ + InputEvent.key(1, 1), + ] + * 10, + ) + + push_events( + fixtures.bar_device, + [ + InputEvent.key(2, 1), + InputEvent.key(2, 0), + ] + * 3, + ) + + self.create_reader_service() + self.reader_client.set_group(self.groups.find(key="Foo Device 2")) + self.reader_client.start_recorder() + time.sleep(0.1) + self.reader_client._read() + self.assertEqual( + l1.calls[0].combination, + InputCombination( + [ + InputConfig( + type=EV_KEY, + code=1, + origin_hash=fixtures.foo_device_2_keyboard.get_device_hash(), + ) + ] + ), + ) + + self.reader_client.set_group(self.groups.find(name="Bar Device")) + time.sleep(0.1) + self.reader_client._read() + + # we did not get the event from the "Bar Device" because the group change + # stopped the recording + self.assertEqual(len(l1.calls), 1) + + self.reader_client.start_recorder() + push_events(fixtures.bar_device, [InputEvent.key(2, 1)]) + time.sleep(0.1) + self.reader_client._read() + self.assertEqual( + l1.calls[1].combination, + InputCombination( + [ + InputConfig( + type=EV_KEY, + code=2, + origin_hash=fixtures.bar_device.get_device_hash(), + ) + ] + ), + ) + + def test_reading_2(self): + l1 = Listener() + self.message_broker.subscribe(MessageType.combination_recorded, l1) + # a combination of events + push_events( + fixtures.foo_device_2_keyboard, + [ + new_event(EV_KEY, CODE_1, 1, 10000.1234), + new_event(EV_KEY, CODE_3, 1, 10001.1234), + ], + ) + + pipe = multiprocessing.Pipe() + + groups = _Groups() + + def refresh(): + # from within the reader-service process notify this test that + # refresh was called as expected + pipe[1].send("refreshed") + # call original method + return _Groups.refresh(groups) + + groups.refresh = refresh + self.create_reader_service(groups) + + self.reader_client.set_group(self.groups.find(key="Foo Device 2")) + self.reader_client.start_recorder() + + # sending anything arbitrary does not stop the reader-service + self.reader_client._commands_pipe.send(856794) + time.sleep(0.2) + push_events( + fixtures.foo_device_2_gamepad, + [new_event(EV_ABS, ABS_HAT0X, -1, 10002.1234)], + ) + time.sleep(0.1) + # but it makes it look for new devices because maybe its list of + # self.groups is not up-to-date + self.assertTrue(pipe[0].poll()) + self.assertEqual(pipe[0].recv(), "refreshed") + + self.reader_client._read() + + expected = InputCombination( + [ + InputConfig( + type=EV_KEY, + code=CODE_1, + origin_hash=fixtures.foo_device_2_keyboard.get_device_hash(), + ), + InputConfig( + type=EV_KEY, + code=CODE_3, + origin_hash=fixtures.foo_device_2_keyboard.get_device_hash(), + ), + InputConfig( + type=EV_ABS, + code=ABS_HAT0X, + analog_threshold=-DEFAULT_ABS_ANALOG_THRESHOLD_MAGNITUDE, + origin_hash=fixtures.foo_device_2_gamepad.get_device_hash(), + ), + ] + ) + + self.assertEqual( + l1.calls[-1].combination, + expected, + ) + + def test_blacklisted_events(self): + l1 = Listener() + self.message_broker.subscribe(MessageType.combination_recorded, l1) + + push_events( + fixtures.foo_device_2_mouse, + [ + InputEvent.key(BTN_TOOL_DOUBLETAP, 1), + InputEvent.key(BTN_LEFT, 1), + InputEvent.key(BTN_TOOL_DOUBLETAP, 1), + ], + force=True, + ) + self.create_reader_service() + self.reader_client.set_group(self.groups.find(key="Foo Device 2")) + self.reader_client.start_recorder() + time.sleep(0.1) + self.reader_client._read() + self.assertEqual( + l1.calls[-1].combination, + InputCombination( + [ + InputConfig( + type=EV_KEY, + code=BTN_LEFT, + origin_hash=fixtures.foo_device_2_mouse.get_device_hash(), + ) + ] + ), + ) + + def test_ignore_value_2(self): + l1 = Listener() + self.message_broker.subscribe(MessageType.combination_recorded, l1) + # this is not a combination, because (EV_KEY CODE_3, 2) is ignored + push_events( + fixtures.foo_device_2_gamepad, + [InputEvent.abs(ABS_HAT0X, 1), InputEvent.key(CODE_3, 2)], + force=True, + ) + self.create_reader_service() + self.reader_client.set_group(self.groups.find(key="Foo Device 2")) + self.reader_client.start_recorder() + time.sleep(0.2) + self.reader_client._read() + self.assertEqual( + l1.calls[-1].combination, + InputCombination( + [ + InputConfig( + type=EV_ABS, + code=ABS_HAT0X, + analog_threshold=DEFAULT_ABS_ANALOG_THRESHOLD_MAGNITUDE, + origin_hash=fixtures.foo_device_2_gamepad.get_device_hash(), + ) + ] + ), + ) + + def test_reading_ignore_up(self): + l1 = Listener() + self.message_broker.subscribe(MessageType.combination_recorded, l1) + push_events( + fixtures.foo_device_2_keyboard, + [ + new_event(EV_KEY, CODE_1, 0, 10), + new_event(EV_KEY, CODE_2, 1, 11), + new_event(EV_KEY, CODE_3, 0, 12), + ], + ) + self.create_reader_service() + self.reader_client.set_group(self.groups.find(key="Foo Device 2")) + self.reader_client.start_recorder() + time.sleep(0.1) + self.reader_client._read() + self.assertEqual( + l1.calls[-1].combination, + InputCombination( + [ + InputConfig( + type=EV_KEY, + code=CODE_2, + origin_hash=fixtures.foo_device_2_keyboard.get_device_hash(), + ) + ] + ), + ) + + def test_wrong_device(self): + l1 = Listener() + self.message_broker.subscribe(MessageType.combination_recorded, l1) + + push_events( + fixtures.foo_device_2_keyboard, + [ + InputEvent.key(CODE_1, 1), + InputEvent.key(CODE_2, 1), + InputEvent.key(CODE_3, 1), + ], + ) + self.create_reader_service() + self.reader_client.set_group(self.groups.find(name="Bar Device")) + self.reader_client.start_recorder() + time.sleep(EVENT_READ_TIMEOUT * 5) + self.reader_client._read() + self.assertEqual(len(l1.calls), 0) + + def test_inputremapper_devices(self): + # Don't read from inputremapper devices, their keycodes are not + # representative for the original key. As long as this is not + # intentionally programmed it won't even do that. But it was at some + # point. + l1 = Listener() + self.message_broker.subscribe(MessageType.combination_recorded, l1) + push_events( + fixtures.input_remapper_bar_device, + [ + InputEvent.key(CODE_1, 1), + InputEvent.key(CODE_2, 1), + InputEvent.key(CODE_3, 1), + ], + ) + self.create_reader_service() + self.reader_client.set_group(self.groups.find(name="Bar Device")) + self.reader_client.start_recorder() + time.sleep(EVENT_READ_TIMEOUT * 5) + self.reader_client._read() + self.assertEqual(len(l1.calls), 0) + + def test_terminate(self): + self.create_reader_service() + self.reader_client.set_group(self.groups.find(key="Foo Device 2")) + + push_events(fixtures.foo_device_2_keyboard, [InputEvent.key(CODE_3, 1)]) + time.sleep(START_READING_DELAY + EVENT_READ_TIMEOUT) + self.assertTrue(self.reader_client._results_pipe.poll()) + + self.reader_client.terminate() + time.sleep(EVENT_READ_TIMEOUT) + self.assertFalse(self.reader_client._results_pipe.poll()) + + # no new events arrive after terminating + push_events(fixtures.foo_device_2_keyboard, [InputEvent.key(CODE_3, 1)]) + time.sleep(EVENT_READ_TIMEOUT * 3) + self.assertFalse(self.reader_client._results_pipe.poll()) + + def test_are_new_groups_available(self): + l1 = Listener() + self.message_broker.subscribe(MessageType.groups, l1) + self.create_reader_service() + self.reader_client.groups.set_groups([]) + + time.sleep(0.1) # let the reader-service send the groups + # read stuff from the reader-service, which includes the devices + self.assertEqual("[]", self.reader_client.groups.dumps()) + self.reader_client._read() + + self.assertEqual( + self.reader_client.groups.dumps(), + json.dumps( + [ + json.dumps( + { + "paths": [ + "/dev/input/event1", + ], + "names": ["Foo Device"], + "types": [DeviceType.KEYBOARD], + "key": "Foo Device", + } + ), + json.dumps( + { + "paths": [ + "/dev/input/event10", + "/dev/input/event11", + "/dev/input/event13", + "/dev/input/event15", + ], + "names": [ + "Foo Device", + "Foo Device foo", + "Foo Device", + "Foo Device bar", + ], + "types": [ + DeviceType.GAMEPAD, + DeviceType.KEYBOARD, + DeviceType.MOUSE, + ], + "key": "Foo Device 2", + } + ), + json.dumps( + { + "paths": ["/dev/input/event20"], + "names": ["Bar Device"], + "types": [DeviceType.KEYBOARD], + "key": "Bar Device", + } + ), + json.dumps( + { + "paths": [ + "/dev/input/event30", + "/dev/input/event32", + ], + "names": [ + "gamepad", + "gamepad abs 0 to 256", + ], + "types": [DeviceType.GAMEPAD], + "key": "gamepad", + } + ), + json.dumps( + { + "paths": ["/dev/input/event52"], + "names": ["Qux/[Device]?"], + "types": [DeviceType.KEYBOARD], + "key": "Qux/[Device]?", + } + ), + ] + ), + ) + + self.assertEqual(len(l1.calls), 1) # ensure we got the event + + def test_starts_the_service(self): + # if ReaderClient can't see the ReaderService, a new ReaderService should + # be started via pkexec + with patch.object(ReaderService, "is_running", lambda: False): + os_system_mock = MagicMock(return_value=0) + with patch.object(os, "system", os_system_mock): + # the status message enables the reader-client to see, that the + # reader-service has started + self.reader_client._results_pipe.send( + {"type": "status", "message": "ready"} + ) + self.reader_client._send_command("foo") + os_system_mock.assert_called_once_with( + "pkexec input-remapper-control --command start-reader-service -d" + ) + + def test_wont_start_the_service(self): + # already running, no call to os.system + with patch.object(ReaderService, "is_running", lambda: True): + mock = MagicMock(return_value=0) + with patch.object(os, "system", mock): + self.reader_client._send_command("foo") + mock.assert_not_called() + + def test_reader_service_wont_start(self): + # test for the "The reader-service did not start" message + + expected_msg = "The reader-service did not start" + subscribe_mock = MagicMock() + self.message_broker.subscribe(MessageType.status_msg, subscribe_mock) + + with patch.object(ReaderClient, "_timeout", 1): + with patch.object(ReaderService, "is_running", lambda: False): + os_system_mock = MagicMock(return_value=0) + with patch.object(os, "system", os_system_mock): + self.reader_client._send_command("foo") + # no message is sent into _results_pipe, so the reader-client will + # think the reader-service didn't manage to start + os_system_mock.assert_called_once_with( + "pkexec input-remapper-control " + "--command start-reader-service -d" + ) + + subscribe_mock.assert_called_once() + status = subscribe_mock.call_args[0][0] + self.assertEqual(status.msg, expected_msg) + + def test_reader_service_times_out(self): + # after some time the reader-service just stops, to avoid leaving a hole + # that exposes user-input forever + with patch.object(ReaderService, "_maximum_lifetime", 1): + self.create_reader_service() + self.assertTrue(self.reader_service_process.is_alive()) + time.sleep(0.5) + self.assertTrue(self.reader_service_process.is_alive()) + time.sleep(1) + self.assertFalse(self.reader_service_process.is_alive()) + + def test_reader_service_waits_for_client_to_finish(self): + # if the client is currently reading, it waits a bit longer until the + # client finishes reading + with patch.object(ReaderService, "_maximum_lifetime", 1): + self.create_reader_service() + self.assertTrue(self.reader_service_process.is_alive()) + + self.reader_client.set_group(self.groups.find(key="Foo Device 2")) + self.reader_client.start_recorder() + + time.sleep(2) + # still alive, without start_recorder it should have already exited + self.assertTrue(self.reader_service_process.is_alive()) + + self.reader_client.stop_recorder() + + time.sleep(1) + self.assertFalse(self.reader_service_process.is_alive()) + + def test_reader_service_wont_wait_forever(self): + # if the client is reading forever, stop it after another timeout + with patch.object(ReaderService, "_maximum_lifetime", 1): + with patch.object(ReaderService, "_timeout_tolerance", 1): + self.create_reader_service() + self.assertTrue(self.reader_service_process.is_alive()) + + self.reader_client.set_group(self.groups.find(key="Foo Device 2")) + self.reader_client.start_recorder() + + time.sleep(1.5) + # still alive, without start_recorder it should have already exited + self.assertTrue(self.reader_service_process.is_alive()) + + time.sleep(1) + # now it stopped, even though the reader is still reading + self.assertFalse(self.reader_service_process.is_alive()) + + def test_reader_service_stops_with_broken_pipes(self): + with patch.object(ReaderService, "pipes_exist", side_effect=lambda: False): + self.create_reader_service() + self.assertTrue(self.reader_service_process.is_alive()) + + time.sleep(0.5) + # still alive + self.assertTrue(self.reader_service_process.is_alive()) + + time.sleep(1) + # now it stopped + self.assertFalse(self.reader_service_process.is_alive()) + + # It will not stop if pipes are ok + with patch.object(ReaderService, "pipes_exist", side_effect=lambda: True): + self.create_reader_service() + self.assertTrue(self.reader_service_process.is_alive()) + + time.sleep(1.5) + # still alive + self.assertTrue(self.reader_service_process.is_alive()) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/test_system_mapping.py b/tests/unit/test_system_mapping.py new file mode 100644 index 0000000..6d7cfab --- /dev/null +++ b/tests/unit/test_system_mapping.py @@ -0,0 +1,165 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + +import json +import os +import subprocess +import unittest +from unittest.mock import patch + +from evdev.ecodes import BTN_LEFT, KEY_A + +from inputremapper.configs.paths import PathUtils +from inputremapper.configs.keyboard_layout import KeyboardLayout, XMODMAP_FILENAME +from tests.lib.test_setup import test_setup + + +@test_setup +class TestSystemMapping(unittest.TestCase): + def test_update(self): + keyboard_layout = KeyboardLayout() + keyboard_layout.update({"foo1": 101, "bar1": 102}) + keyboard_layout.update({"foo2": 201, "bar2": 202}) + self.assertEqual(keyboard_layout.get("foo1"), 101) + self.assertEqual(keyboard_layout.get("bar2"), 202) + + def test_xmodmap_file(self): + keyboard_layout = KeyboardLayout() + path = os.path.join(PathUtils.config_path(), XMODMAP_FILENAME) + os.remove(path) + + keyboard_layout.populate() + self.assertTrue(os.path.exists(path)) + with open(path, "r") as file: + content = json.load(file) + self.assertEqual(content["a"], KEY_A) + # only xmodmap stuff should be present + self.assertNotIn("key_a", content) + self.assertNotIn("KEY_A", content) + self.assertNotIn("disable", content) + + def test_empty_xmodmap(self): + # if xmodmap returns nothing, don't write the file + empty_xmodmap = "" + + class SubprocessMock: + def decode(self): + return empty_xmodmap + + def check_output(*args, **kwargs): + return SubprocessMock() + + with patch.object(subprocess, "check_output", check_output): + keyboard_layout = KeyboardLayout() + path = os.path.join(PathUtils.config_path(), XMODMAP_FILENAME) + os.remove(path) + + keyboard_layout.populate() + self.assertFalse(os.path.exists(path)) + + def test_xmodmap_command_missing(self): + # if xmodmap is not installed, don't write the file + def check_output(*args, **kwargs): + raise FileNotFoundError + + with patch.object(subprocess, "check_output", check_output): + keyboard_layout = KeyboardLayout() + path = os.path.join(PathUtils.config_path(), XMODMAP_FILENAME) + os.remove(path) + + keyboard_layout.populate() + self.assertFalse(os.path.exists(path)) + + def test_correct_case(self): + keyboard_layout = KeyboardLayout() + keyboard_layout.clear() + keyboard_layout._set("A", 31) + keyboard_layout._set("a", 32) + keyboard_layout._set("abcd_B", 33) + + self.assertEqual(keyboard_layout.correct_case("a"), "a") + self.assertEqual(keyboard_layout.correct_case("A"), "A") + self.assertEqual(keyboard_layout.correct_case("ABCD_b"), "abcd_B") + # unknown stuff is returned as is + self.assertEqual(keyboard_layout.correct_case("FOo"), "FOo") + + self.assertEqual(keyboard_layout.get("A"), 31) + self.assertEqual(keyboard_layout.get("a"), 32) + self.assertEqual(keyboard_layout.get("ABCD_b"), 33) + self.assertEqual(keyboard_layout.get("abcd_B"), 33) + + def test_keyboard_layout(self): + keyboard_layout = KeyboardLayout() + keyboard_layout.populate() + self.assertGreater(len(keyboard_layout._mapping), 100) + + # this is case-insensitive + self.assertEqual(keyboard_layout.get("1"), 2) + self.assertEqual(keyboard_layout.get("KeY_1"), 2) + + self.assertEqual(keyboard_layout.get("AlT_L"), 56) + self.assertEqual(keyboard_layout.get("KEy_LEFtALT"), 56) + + self.assertEqual(keyboard_layout.get("kEY_LeFTSHIFT"), 42) + self.assertEqual(keyboard_layout.get("ShiFt_L"), 42) + + self.assertEqual(keyboard_layout.get("BTN_left"), 272) + + self.assertIsNotNone(keyboard_layout.get("KEY_KP4")) + self.assertEqual(keyboard_layout.get("KP_Left"), keyboard_layout.get("KEY_KP4")) + + # this only lists the correct casing, + # includes linux constants and xmodmap symbols + names = keyboard_layout.list_names() + self.assertIn("2", names) + self.assertIn("c", names) + self.assertIn("KEY_3", names) + self.assertNotIn("key_3", names) + self.assertIn("KP_Down", names) + self.assertNotIn("kp_down", names) + names = keyboard_layout._mapping.keys() + self.assertIn("F4", names) + self.assertNotIn("f4", names) + self.assertIn("BTN_RIGHT", names) + self.assertNotIn("btn_right", names) + self.assertIn("KEY_KP7", names) + self.assertIn("KP_Home", names) + self.assertNotIn("kp_home", names) + + self.assertEqual(keyboard_layout.get("disable"), -1) + + def test_get_name_no_xmodmap(self): + # if xmodmap is not installed, uses the linux constant names + keyboard_layout = KeyboardLayout() + + def check_output(*args, **kwargs): + raise FileNotFoundError + + with patch.object(subprocess, "check_output", check_output): + keyboard_layout.populate() + self.assertEqual(keyboard_layout.get_name(KEY_A), "KEY_A") + + # testing for BTN_LEFT is especially important, because + # `evdev.ecodes.BTN.get(code)` returns an array of ['BTN_LEFT', 'BTN_MOUSE'] + self.assertEqual(keyboard_layout.get_name(BTN_LEFT), "BTN_LEFT") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/test_test.py b/tests/unit/test_test.py new file mode 100644 index 0000000..81b7f81 --- /dev/null +++ b/tests/unit/test_test.py @@ -0,0 +1,145 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + +import asyncio +import multiprocessing +import os +import time +import unittest + +import evdev +from evdev.ecodes import EV_ABS, EV_KEY + +from inputremapper.groups import groups, _Groups +from inputremapper.gui.messages.message_broker import MessageBroker +from inputremapper.gui.reader_client import ReaderClient +from inputremapper.gui.reader_service import ReaderService +from inputremapper.injection.global_uinputs import UInput, GlobalUInputs +from inputremapper.input_event import InputEvent +from inputremapper.utils import get_device_hash +from tests.lib.cleanup import cleanup +from tests.lib.constants import EVENT_READ_TIMEOUT, START_READING_DELAY +from tests.lib.fixtures import fixtures +from tests.lib.logger import logger +from tests.lib.patches import InputDevice +from tests.lib.pipes import push_events +from tests.lib.test_setup import test_setup + + +@test_setup +class TestTest(unittest.TestCase): + def test_stubs(self): + self.assertIsNotNone(groups.find(key="Foo Device 2")) + + def test_fake_capabilities(self): + device = InputDevice("/dev/input/event30") + capabilities = device.capabilities(absinfo=False) + self.assertIsInstance(capabilities, dict) + self.assertIsInstance(capabilities[EV_ABS], list) + self.assertIsInstance(capabilities[EV_ABS][0], int) + + capabilities = device.capabilities() + self.assertIsInstance(capabilities, dict) + self.assertIsInstance(capabilities[EV_ABS], list) + self.assertIsInstance(capabilities[EV_ABS][0], tuple) + self.assertIsInstance(capabilities[EV_ABS][0][0], int) + self.assertIsInstance(capabilities[EV_ABS][0][1], evdev.AbsInfo) + self.assertIsInstance(capabilities[EV_ABS][0][1].max, int) + self.assertIsInstance(capabilities, dict) + self.assertIsInstance(capabilities[EV_KEY], list) + self.assertIsInstance(capabilities[EV_KEY][0], int) + + def test_restore_fixtures(self): + fixtures.add_fixture({"name": "bla", "path": "/bar/dev"}) + cleanup() + self.assertIsNone(fixtures.get("/bar/dev")) + self.assertIsNotNone(fixtures.get("/dev/input/event11")) + + def test_restore_os_environ(self): + os.environ["foo"] = "bar" + del os.environ["USER"] + environ = os.environ + cleanup() + self.assertIn("USER", environ) + self.assertNotIn("foo", environ) + + def test_push_events(self): + """Test that push_event works properly between reader service and client. + + Using push_events after the reader-service is already started should work, + as well as using push_event twice + """ + reader_client = ReaderClient(MessageBroker(), groups) + + def create_reader_service(): + # this will cause pending events to be copied over to the reader-service + # process + def start_reader_service(): + # Create dependencies from scratch, because the reader-service runs + # in a different process + global_uinputs = GlobalUInputs(UInput) + reader_service = ReaderService(_Groups(), global_uinputs) + loop = asyncio.new_event_loop() + loop.run_until_complete(reader_service.run()) + + self.reader_service = multiprocessing.Process(target=start_reader_service) + self.reader_service.start() + time.sleep(0.1) + + def wait_for_results(): + # wait for the reader-service to send stuff + for _ in range(10): + time.sleep(EVENT_READ_TIMEOUT) + if reader_client._results_pipe.poll(): + break + + create_reader_service() + reader_client.set_group(groups.find(key="Foo Device 2")) + reader_client.start_recorder() + time.sleep(START_READING_DELAY) + + event = InputEvent.key(102, 1) + push_events(fixtures.foo_device_2_keyboard, [event]) + wait_for_results() + self.assertTrue(reader_client._results_pipe.poll()) + + reader_client._read() + self.assertFalse(reader_client._results_pipe.poll()) + + # can push more events to the reader-service that is inside a separate + # process, which end up being sent to the reader + event = InputEvent.key(102, 0) + logger.info("push_events") + push_events(fixtures.foo_device_2_keyboard, [event]) + wait_for_results() + logger.info("assert") + self.assertTrue(reader_client._results_pipe.poll()) + + reader_client.terminate() + + def test_device_hash_from_fixture_is_correct(self): + for fixture in fixtures: + self.assertEqual( + fixture.get_device_hash(), get_device_hash(InputDevice(fixture.path)) + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/test_user.py b/tests/unit/test_user.py new file mode 100644 index 0000000..e1c71a0 --- /dev/null +++ b/tests/unit/test_user.py @@ -0,0 +1,62 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + + +import os +import unittest +from unittest import mock + +from inputremapper.user import UserUtils +from tests.lib.test_setup import test_setup + + +def _raise(error): + raise error + + +@test_setup +class TestUser(unittest.TestCase): + def test_get_user(self): + with mock.patch("os.getlogin", lambda: "foo"): + self.assertEqual(UserUtils.get_user(), "foo") + + with mock.patch("os.getlogin", lambda: "root"): + self.assertEqual(UserUtils.get_user(), "root") + + property_mock = mock.Mock() + property_mock.configure_mock(pw_name="quix") + with ( + mock.patch("os.getlogin", lambda: _raise(OSError())), + mock.patch("pwd.getpwuid", return_value=property_mock), + ): + os.environ["USER"] = "root" + os.environ["SUDO_USER"] = "qux" + self.assertEqual(UserUtils.get_user(), "qux") + + os.environ["USER"] = "root" + del os.environ["SUDO_USER"] + os.environ["PKEXEC_UID"] = "1000" + self.assertNotEqual(UserUtils.get_user(), "root") + + def test_get_home(self): + property_mock = mock.Mock() + property_mock.configure_mock(pw_dir="/custom/home/foo") + with mock.patch("pwd.getpwnam", return_value=property_mock): + self.assertEqual(UserUtils.get_home("foo"), "/custom/home/foo") diff --git a/tests/unit/test_util.py b/tests/unit/test_util.py new file mode 100644 index 0000000..f753db6 --- /dev/null +++ b/tests/unit/test_util.py @@ -0,0 +1,43 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2025 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + + +import unittest + +from evdev._ecodes import EV_ABS, ABS_X, BTN_WEST, BTN_Y, EV_KEY, KEY_A + +from inputremapper.utils import get_evdev_constant_name +from tests.lib.test_setup import test_setup + + +@test_setup +class TestUtil(unittest.TestCase): + def test_get_evdev_constant_name(self): + # BTN_WEST and BTN_Y both are code 308. I don't care which one is chosen + # in the return value, but it should return one of them without crashing. + self.assertEqual(get_evdev_constant_name(EV_KEY, BTN_Y), "BTN_WEST") + self.assertEqual(get_evdev_constant_name(EV_KEY, BTN_WEST), "BTN_WEST") + + self.assertEqual(get_evdev_constant_name(123, KEY_A), "unknown") + self.assertEqual(get_evdev_constant_name(EV_KEY, 9999), "unknown") + + self.assertEqual(get_evdev_constant_name(EV_KEY, KEY_A), "KEY_A") + + self.assertEqual(get_evdev_constant_name(EV_ABS, ABS_X), "ABS_X")

9+?ANl{sNimN8>6Swf%ZA1=xIE@7`Gvu9 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 NB<1g>-FfKwzW{+iRTlsN literal 0 HcmV?d00001 diff --git a/readme/screenshot_2.png b/readme/screenshot_2.png new file mode 100644 index 0000000000000000000000000000000000000000..0bed2c09441ccd7e0aed1d22f38a1c537a495005 GIT binary patch literal 65860 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�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±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ΞfPqr#4LQyyd28;kZ;0^g)=7Ef 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 pnh`+=S*~y!)>N#NCWzD@yf + + +