From 3637204bffe9f10e7db87f7200c25c26ac19af98 Mon Sep 17 00:00:00 2001 From: jonasBoss Date: Sat, 23 Jul 2022 10:53:41 +0200 Subject: [PATCH] Frontend Refactor (#375) * Tests for the GuiEventHandler * Implement GuiEventHandler * tests for data manager * Implemented data_manager * Remove Ellipsis from type hint * workaround for old pydantic version * workaround for old pydantic version * some more tests for data_manager * Updated Data Manager * move DeviceSelection to its own class * Data Manager no longer listens for events * Moved PresetSelection to its own class * MappingListBox and SelectionLable Listen to the EventHandler * DataManager no longer creates its own data objects in the init * removed global reader object * Changed UI startup * created backend Interface * event_handler debug logs show function which emit a event * some cleanup * added target selector to components * created code editor component * adapted autocompletion & some cleanup * black * connected some buttons to the event_handler * tests for data_manager newest_preset and group * cleanup presets and test_presets * migrated confirm delete dialog * backend tests * controller tests * add python3-gi to ci * more dependencies * and more ... * Github-Actions workaround remove this commit * not so many permission denyed errors in test.yml * Fix #404 (hopefully) * revert Github-Actions workaround * More tests * event_handler allows for event supression * more tests * WIP Implement Key recording * Start and Stop Injection * context no longer stores preset * restructured the RelToBtnHandler * Simplified read_loop * Implement async iterator for ipc.pipe * multiple event actions * helper now implements mapping handlers to read inputs all with async * updated and simplified reader the helper uses the mapping handlers, so the reader now can be much simpler * Fixed race condition in tests * implemented DataBus * Fixed a UIMapping bug where the last_error would not be deleted * added a immutable variant of the UIMapping * updated data_manager to use data_bus * Uptdated tests to use the DataBus * Gui uses DataBus * removed EventHandler * Renamed controller methods * Implemented recording toggle * implemented StatusBar * Sending validation errors to status bar * sending injection status to status bar * proper preset renaming * implemented copy preset in the data manager * implemented copy_preset in controller * fixed a bug where a wron selection lable would update * no longer send invalid data over the bus, if the preset or group changes * Implement create and delete mapping * Allow for frontend specific mapping defaults * implemented autoload toggle * cleanup user_interface * removed editor * Docstings renaming and ordering of methods * more simplifications to user_interface * integrated backend into data_manager * removed active preset * transformation tests * controller tests * fix missing uinputs in gui * moved some tests and implemented basic tests for mapping handlers * docstring reformatting Co-authored-by: Tobi * allow for empty groups * docstring * fixed TestGroupFromHelper * some work on integration tests * test for annoying import error in tests * testing if test_user_interface works * I feel lucky * not so lucky * some more tests * fixed but where the group_key was used as folder name * Fixed a bug where state=NO_GRAB would never be read from the injector * allow to stop the recorder * working on integration tests * integration tests * fixed more integration tests * updated coveragerc * no longer attempt to record keys when injecting * event_reader cleans up not finished tasks * More integration tests * All tests pass * renamed data_bus * WIP fixing typing issues * more typing fixes * added keyboard+mouse device to tests * cleanup imports * new read loop because the evdev async read loop can not be cancelled * Added field to modify mapping name * created tests for components * even more component tests * do component tests need a screen? * apparently they do :_( * created release_input switch * Don't record relative axis when movement is slow * show delete dialog above main window * wip basic dialog to edit combination * some gui changes to the combination-editor * Simple implementation of CombinationListbox * renamed attach_to_events method and mark as private * shorter str() for UInputsData * moved logic to generate readable event string from combination to event * new mapping parameter force release timeout this helps with the helper when recording multiple relative axis at once * make it possible to rearange the event_combination * more work on the combination editor * tests for DataManager.load_event * simplyfied test_controller * more controller tests * Implement input threshold in gui * greater range for time dependent unit test * implemented a output-axis selector * data_manager now provides injector state * black * mypy * Updated confirm cancel dialog * created release timeout input * implemented transformation graph * Added sliders for gain, expo and deadzone * fix bug where the system_mapping was overridden in each injector thread * updated slider settings * removed debug statement * explicitly checking output code against None (0 is a valid code) * usage * Allow for multiple axis to be activated by same button * readme * only warn about not implemented mapping-handler don't fail to create event-pipelines * More accurate event names * Allow removal of single events from the input-combination * rename callback to notify_callback * rename event message to selected_event * made read_continuisly private * typing for autocompletion * docstrings for message_broker messages * make components methods and propreties private * gui spacings * removed eval * make some controller functions private * move status message generation from data_manager to controller * parse mapping errors in controller for more helpful messages * remove system_mapping from code editor * More component tests * more tests * mypy * make grab_devices less greedy (partial mitigation for #435) only grab one device if there are multiple which can satisfy the same mapping * accumulate more values in test * docstrings * Updated status messages * comments, docstrings, imports Co-authored-by: Tobi --- .coveragerc | 9 + .github/workflows/test.yml | 2 +- README.md | 7 +- bin/input-remapper-gtk | 60 +- bin/input-remapper-helper | 5 +- data/input-remapper.glade | 1167 ++++++-- data/style.css | 1 - inputremapper/configs/data.py | 4 +- inputremapper/configs/global_config.py | 10 +- inputremapper/configs/mapping.py | 89 +- inputremapper/configs/migrations.py | 25 +- inputremapper/configs/paths.py | 26 +- inputremapper/configs/preset.py | 237 +- inputremapper/configs/system_mapping.py | 8 +- inputremapper/daemon.py | 55 +- inputremapper/event_combination.py | 116 +- inputremapper/exceptions.py | 5 + inputremapper/groups.py | 78 +- inputremapper/gui/active_preset.py | 29 - .../gui/{editor => }/autocompletion.py | 85 +- inputremapper/gui/components.py | 1116 +++++++ inputremapper/gui/controller.py | 557 ++++ inputremapper/gui/data_manager.py | 564 ++++ inputremapper/gui/editor/editor.py | 748 ----- inputremapper/gui/gettext.py | 4 +- inputremapper/gui/helper.py | 345 ++- inputremapper/gui/message_broker.py | 238 ++ inputremapper/gui/reader.py | 294 +- inputremapper/gui/user_interface.py | 872 ++---- inputremapper/gui/utils.py | 9 +- inputremapper/injection/context.py | 45 +- inputremapper/injection/event_reader.py | 69 +- inputremapper/injection/global_uinputs.py | 7 +- inputremapper/injection/injector.py | 174 +- inputremapper/injection/macros/macro.py | 8 +- inputremapper/injection/macros/parse.py | 7 +- .../mapping_handlers/abs_to_btn_handler.py | 18 +- .../mapping_handlers/abs_to_rel_handler.py | 19 +- .../mapping_handlers/axis_switch_handler.py | 9 +- .../mapping_handlers/combination_handler.py | 26 +- .../mapping_handlers/hierarchy_handler.py | 8 +- .../injection/mapping_handlers/key_handler.py | 7 +- .../mapping_handlers/macro_handler.py | 9 +- .../mapping_handlers/mapping_handler.py | 50 +- .../mapping_handlers/mapping_parser.py | 84 +- .../mapping_handlers/null_handler.py | 7 +- .../mapping_handlers/rel_to_btn_handler.py | 48 +- inputremapper/input_event.py | 141 +- inputremapper/ipc/pipe.py | 41 +- inputremapper/ipc/shared_dict.py | 2 +- inputremapper/ipc/socket.py | 7 +- inputremapper/logger.py | 8 +- inputremapper/user.py | 2 +- inputremapper/utils.py | 2 - readme/plus.png | Bin 4754 -> 7848 bytes readme/screenshot.png | Bin 44200 -> 60798 bytes readme/screenshot_2.png | Bin 33878 -> 63652 bytes readme/usage.md | 67 +- readme/usage_1.png | Bin 8470 -> 26076 bytes readme/usage_2.png | Bin 7196 -> 15111 bytes scripts/ci-install-deps.sh | 2 +- tests/integration/test_components.py | 1142 +++++++ tests/integration/test_gui.py | 2611 +++++++---------- tests/integration/test_user_interface.py | 107 + tests/test.py | 196 +- tests/unit/test_context.py | 4 +- tests/unit/test_control.py | 2 - tests/unit/test_controller.py | 1189 ++++++++ tests/unit/test_daemon.py | 13 +- tests/unit/test_data_manager.py | 893 ++++++ tests/unit/test_event_combination.py | 79 +- .../unit/test_event_pipeline}/__init__.py | 0 .../test_axis_transformation.py | 12 + .../test_event_pipeline.py | 6 +- .../test_mapping_handlers.py | 261 ++ tests/unit/test_event_reader.py | 19 +- tests/unit/test_groups.py | 55 +- tests/unit/test_injector.py | 64 +- tests/unit/test_ipc.py | 46 +- tests/unit/test_macros.py | 2 +- tests/unit/test_mapping.py | 69 +- tests/unit/test_message_broker.py | 79 + tests/unit/test_preset.py | 7 +- tests/unit/test_presets.py | 218 -- tests/unit/test_reader.py | 704 +++-- tests/unit/test_test.py | 21 +- 86 files changed, 10441 insertions(+), 4990 deletions(-) delete mode 100644 inputremapper/gui/active_preset.py rename inputremapper/gui/{editor => }/autocompletion.py (81%) create mode 100644 inputremapper/gui/components.py create mode 100644 inputremapper/gui/controller.py create mode 100644 inputremapper/gui/data_manager.py delete mode 100644 inputremapper/gui/editor/editor.py create mode 100644 inputremapper/gui/message_broker.py create mode 100644 tests/integration/test_components.py create mode 100644 tests/integration/test_user_interface.py create mode 100644 tests/unit/test_controller.py create mode 100644 tests/unit/test_data_manager.py rename {inputremapper/gui/editor => tests/unit/test_event_pipeline}/__init__.py (100%) rename tests/unit/{ => test_event_pipeline}/test_axis_transformation.py (92%) rename tests/unit/{ => test_event_pipeline}/test_event_pipeline.py (99%) create mode 100644 tests/unit/test_event_pipeline/test_mapping_handlers.py create mode 100644 tests/unit/test_message_broker.py delete mode 100644 tests/unit/test_presets.py diff --git a/.coveragerc b/.coveragerc index 99c060bf..46fc78a9 100644 --- a/.coveragerc +++ b/.coveragerc @@ -6,3 +6,12 @@ debug = multiproc omit = # not used currently due to problems /usr/lib/python3.9/site-packages/inputremapper/ipc/socket.py + +[report] +exclude_lines = + pragma: no cover + + # Don't complain about abstract methods, they aren't run: + @(abc\.)?abstractmethod + # Don't cover Protocol classes + class .*\(.*Protocol.*\): \ No newline at end of file diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 1c73fd75..2d4b7ebf 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -20,7 +20,7 @@ jobs: run: | # Install deps as root since we will run tests as root sudo scripts/ci-install-deps.sh - sudo pip install . + sudo pip install --no-binary :all: . - name: Run tests run: | # FIXME: Had some permissions issues, currently worked around by running tests as root diff --git a/README.md b/README.md index 0062501e..27d5e9f2 100644 --- a/README.md +++ b/README.md @@ -16,10 +16,9 @@ #### Known Issues (Beta Branch) - * The GUI is currently is not adapted to reflect all new features and might behave strange. - Mapping a gamepad to mouse is currently not possible in the GUI. - Also mapping joystick or mouse axis to buttons might not work. - Those are only limitations of the GUI, when editing the preset manually all those features are still available. + * Mapping relative axis to relative axis (mouse to mouse) is not possible + * Mapping relative axis to absolute axis (mouse to gamepad) is not possible + * Mapping absolute axis to absolute axis (gamepad to gamepad) is not possible ## Installation diff --git a/bin/input-remapper-gtk b/bin/input-remapper-gtk index f48adb60..e70809ee 100755 --- a/bin/input-remapper-gtk +++ b/bin/input-remapper-gtk @@ -18,13 +18,15 @@ # 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 os import sys import atexit +import logging from argparse import ArgumentParser + from inputremapper.gui.gettext import _, LOCALE_DIR import gi @@ -38,7 +40,24 @@ from gi.repository import Gtk Gtk.init() from inputremapper.logger import logger, update_verbosity, log_info -from inputremapper.configs.migrations import migrate + + +def start_processes() -> DaemonProxy: + """Start helper and daemon via pkexec to run in the background.""" + # this function is overwritten in tests + daemon = Daemon.connect() + + debug = " -d" if logger.level <= logging.DEBUG else "" + cmd = f"pkexec input-remapper-control --command helper {debug}" + + logger.debug("Running `%s`", cmd) + exit_code = os.system(cmd) + + if exit_code != 0: + logger.error("Failed to pkexec the helper, code %d", exit_code) + sys.exit(11) + + return daemon if __name__ == '__main__': @@ -55,21 +74,42 @@ if __name__ == '__main__': logger.debug('Using locale directory: {}'.format(LOCALE_DIR)) # import input-remapper stuff after setting the log verbosity + from inputremapper.gui.message_broker import MessageBroker, MessageType + from inputremapper.configs.system_mapping import system_mapping + from inputremapper.gui.data_manager import DataManager from inputremapper.gui.user_interface import UserInterface - from inputremapper.daemon import Daemon - from inputremapper.configs.global_config import global_config + from inputremapper.gui.controller import Controller + from inputremapper.injection.global_uinputs import GlobalUInputs + from inputremapper.groups import _Groups + from inputremapper.gui.reader import Reader + from inputremapper.daemon import Daemon, DaemonProxy + from inputremapper.configs.global_config import GlobalConfig + from inputremapper.configs.migrations import migrate migrate() - global_config.load_config() - user_interface = UserInterface() + message_broker = MessageBroker() + + # create the reader before we start the helper (start_processes) otherwise it + # can come to race conditions with the creation of pipes + reader = Reader(message_broker, _Groups()) + daemon = start_processes() + + data_manager = DataManager( + message_broker, GlobalConfig(), reader, daemon, GlobalUInputs(), system_mapping + ) + controller = Controller(message_broker, data_manager) + user_interface = UserInterface(message_broker, controller) + controller.set_gui(user_interface) + + message_broker.signal(MessageType.init) def stop(): - if isinstance(user_interface.dbus, Daemon): + if isinstance(daemon, Daemon): # have fun debugging completely unrelated tests if you remove this - user_interface.dbus.stop_all() + daemon.stop_all() - user_interface.on_close() + controller.close() atexit.register(stop) diff --git a/bin/input-remapper-helper b/bin/input-remapper-helper index badf944d..4881729c 100755 --- a/bin/input-remapper-helper +++ b/bin/input-remapper-helper @@ -29,6 +29,7 @@ import signal from argparse import ArgumentParser from inputremapper.logger import update_verbosity +from inputremapper.groups import _Groups if __name__ == '__main__': @@ -53,6 +54,6 @@ if __name__ == '__main__': os.kill(os.getpid(), signal.SIGKILL) atexit.register(on_exit) - - helper = RootHelper() + groups = _Groups() + helper = RootHelper(groups) helper.run() diff --git a/data/input-remapper.glade b/data/input-remapper.glade index ae7868d4..9cd5e21e 100644 --- a/data/input-remapper.glade +++ b/data/input-remapper.glade @@ -22,6 +22,11 @@ 2 edit-copy + + 1 + 0.01 + 0.01 + True False @@ -29,87 +34,17 @@ 2 edit-delete - - True - False - gtk-delete - - - False - dialog - - - False - vertical - 1 - top - - - False - True - expand - - - Delete - False - True - True - True - delete-icon-1 - - - True - True - 1 - - - - - Cancel - True - True - True - True - True - - - True - True - 1 - - - - - True - True - 0 - - - - - True - False - 50 - 50 - 32 - - - False - True - 1 - - - - - - button1 - button2 - + + -1 + 1 + 0.05 + 0.05 - - True - False - input-keyboard + + -2 + 2 + 0.10 + 0.10 True @@ -122,23 +57,37 @@ False 2 2 - window-close + edit-delete True False - gtk-add + edit True False media-record - - 2 - 9 - 1 - 6 + + True + False + list-add + + + True + False + list-remove + + + True + False + dialog-cancel + + + True + False + dialog-ok True @@ -159,23 +108,18 @@ 1000 450 input-remapper.svg - - - + True False - 10 - 10 vertical - 1 True False - 4 - 4 + 18 + 18 vertical @@ -196,7 +140,7 @@ False 18 0 - 6 + 12 160 @@ -216,7 +160,6 @@ True False - True @@ -235,7 +178,6 @@ Gives your keys back their original function end gtk-redo-icon True - False @@ -253,7 +195,7 @@ Gives your keys back their original function center about-icon True - + False @@ -293,18 +235,18 @@ Gives your keys back their original function True False - 4 - 4 + 18 + 18 18 18 - 4 + 6 True False - 4 - 4 + 6 + 12 True @@ -314,7 +256,6 @@ Gives your keys back their original function True True - True @@ -332,7 +273,6 @@ Gives your keys back their original function 6 save-icon none - False @@ -352,7 +292,6 @@ Gives your keys back their original function True False True - 1 @@ -400,8 +339,8 @@ Gives your keys back their original function True False 18 - 4 - 27 + 6 + 30 Delete @@ -412,7 +351,6 @@ Gives your keys back their original function delete-icon none True - 0 @@ -429,7 +367,6 @@ Gives your keys back their original function copy-icon none True - 1 @@ -446,7 +383,6 @@ Gives your keys back their original function new-icon none True - @@ -464,7 +400,6 @@ Gives your keys back their original function check-icon none True - 2 @@ -477,7 +412,6 @@ Gives your keys back their original function True start center - 1 @@ -528,32 +462,59 @@ Gives your keys back their original function True False - 4 - 4 - 18 - 4 - 18 + 18 + 18 - - 160 + True - True + False + 9 + 18 + 18 + vertical + 6 - + + 200 True - False - none + True - + True False - browse - + none + + + True + False + + + + + True + True + 0 + + + + + Add Mapping + True + True + True + image3 + none + + + False + True + 1 + @@ -574,108 +535,43 @@ Gives your keys back their original function - - + + 260 True False - True - True - 4 - 18 + 18 + 18 + vertical + 6 - + True False - True - True - - - True - True - - - True - True - start - immediate - word - 10 - 10 - 10 - 10 - True - 2 - True - - - - - - Key or Macro - Key or Macro - - - - - - True - False - immediate - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - Analog Axis - Analog Axis - 1 - - + center + 9 + 9 + editor-stack - 1 - 0 + False + True + 0 - 260 True False - vertical - 4 + 9 + 9 + 6 - + + 60 True False - center - editor-stack + Target: + 0 False @@ -684,113 +580,100 @@ Gives your keys back their original function - + True False - 4 - - - True - False - Target - 0 - - - False - True - 0 - - - - - True - False - The type of device this mapping is emulating. - - - True - True - 1 - - + The type of device this mapping is emulating. - False + True True - 2 + 1 + + + False + True + 1 + + + + + True + False + 3 + 3 + + + False + True + 2 + + + + + True + False + 9 + 9 + 6 - - Change Key + + 60 True - True - True - Record a button of your device that should be remapped - image2 - none - True + False + Input: + 0 False True - 2 + 0 - + True - True - - - True - False - none - - - True - False - none - False - - - - + False + 7 + 8 + no input configured + True True True - 4 + 1 - 0 - 0 + False + True + 3 - - Delete Mapping + + Record Input True True True - Delete this entry - icon-delete-row + Record a button of your device that should be remapped + image2 none True - - 1 - 1 + False + True + 4 - - Add axis as button + + Advanced Input Configuration True True True @@ -798,14 +681,320 @@ Gives your keys back their original function none - 0 - 1 + False + True + 5 False True + 2 + + + + + True + False + + + False + True + 3 + + + + + True + False + 9 + 9 + 18 + 18 + vertical + 6 + + + True + False + True + True + + + True + True + + + True + True + start + immediate + word + 10 + 10 + 10 + 10 + True + 2 + True + + + + + + Key or Macro + Key or Macro + + + + + True + False + 6 + + + True + False + 9 + 9 + vertical + 6 + + + True + False + 12 + + + True + False + Output Axis: + + + False + True + 0 + + + + + 100 + True + False + + + True + True + 1 + + + + + False + True + 0 + + + + + True + False + 12 + + + 150 + True + True + deadzone-adjustment + 2 + 2 + + + False + True + end + 0 + + + + + True + False + Deadzone: + + + False + True + 1 + + + + + False + True + 1 + + + + + True + False + 12 + + + 150 + True + True + gain-adjustment + 1 + 2 + + + False + True + end + 0 + + + + + True + False + Gain: + + + False + True + 1 + + + + + False + True + 2 + + + + + True + False + 12 + + + 150 + True + True + expo-adjustment + 1 + 2 + + + False + True + end + 0 + + + + + True + False + Expo: + + + False + True + 1 + + + + + False + True + 3 + + + + + + + + False + True + 0 + + + + + True + False + vertical + + + 150 + 150 + True + False + + + False + True + 0 + + + + + + + + False + True + end + 1 + + + + + Analog Axis + Analog Axis + 1 + + + + + False + True + 0 + + + + + Delete Mapping + True + True + True + Delete this entry + icon-delete-row + none + True + + + + False + True + 1 + + + + + True + True 5 @@ -831,6 +1020,8 @@ Gives your keys back their original function True False + 18 + 18 False @@ -891,12 +1082,13 @@ Gives your keys back their original function False True + center-on-parent input-remapper.svg dialog True window window - + True @@ -1174,4 +1366,403 @@ Macros allow multiple characters to be written with a single key-press. Informat + + 200 + 200 + False + True + center-on-parent + True + window + window + + + True + False + 18 + 18 + 18 + 18 + 12 + + + 200 + True + True + in + + + True + False + + + True + False + 8 + 8 + browse + + + + + + + True + True + 0 + + + + + True + False + vertical + 6 + + + True + False + General + + + False + True + 0 + + + + + True + False + Release all inputs before the mapping is executed. +This can help when mapping a combination like Shift+A to map to a lowercase letter, because it relesases the Shift key before executing the mapping. + 12 + + + True + False + Release Input: + + + False + True + 0 + + + + + True + True + True + + + False + True + end + 1 + + + + + False + True + 1 + + + + + True + False + 12 + Event Specific + + + False + True + 2 + + + + + True + False + Remove this input form the input-combination + 12 + + + True + False + Remove this Input: + + + False + True + 0 + + + + + True + True + True + image4 + none + + + True + True + end + 1 + + + + + False + True + 3 + + + + + True + False + Use this input for mapping to an analog axis. +This can not be used with key or macro mappings. + 12 + + + True + False + True + Use as Analog: + 0 + + + False + True + 0 + + + + + True + True + + + False + True + end + 1 + + + + + False + True + 4 + + + + + True + False + The threshold at which this axis is considered active. + 12 + + + True + False + Trigger +threshold: + 0 + + + False + True + 0 + + + + + True + True + 5 + number + 1 + True + True + + + False + True + end + 1 + + + + + False + True + 5 + + + + + True + False + The time [s] until a relative axis (e.g. mouse) is concidered stationary and will be released. + 12 + + + True + False + Release +Timeout: + + + False + True + 0 + + + + + True + True + 5 + 3 + + + False + True + end + 1 + + + + + False + True + 6 + + + + + False + True + 1 + + + + + + + 300 + 150 + False + False + True + center-on-parent + dialog + True + True + False + window + window + + + False + vertical + 2 + + + False + end + expand + + + Cancel + True + True + True + True + True + image5 + + + True + True + 0 + + + + + Confirm + True + True + True + image6 + + + True + True + 1 + + + + + False + True + end + 0 + + + + + True + False + 32 + 32 + 8 + 8 + 8 + + + True + False + dialog-warning + 6 + + + False + True + 0 + + + + + True + False + center + + + True + True + 1 + + + + + True + True + 1 + + + + + + button3 + button4 + + diff --git a/data/style.css b/data/style.css index e3050b1f..2c5eba6c 100644 --- a/data/style.css +++ b/data/style.css @@ -23,7 +23,6 @@ which is perfect. */ } list entry { - background-color: transparent; border-radius: 4px; border: 0px; box-shadow: none; diff --git a/inputremapper/configs/data.py b/inputremapper/configs/data.py index ec08117f..7cb7a94e 100644 --- a/inputremapper/configs/data.py +++ b/inputremapper/configs/data.py @@ -22,14 +22,14 @@ """Get stuff from /usr/share/input-remapper, depending on the prefix.""" -import sys import os import site +import sys + import pkg_resources from inputremapper.logger import logger - logged = False diff --git a/inputremapper/configs/global_config.py b/inputremapper/configs/global_config.py index b589ca4b..d32dbeab 100644 --- a/inputremapper/configs/global_config.py +++ b/inputremapper/configs/global_config.py @@ -19,13 +19,13 @@ # along with input-remapper. If not, see . """Store which presets should be enabled for which device on login.""" -import os -import json import copy +import json +import os +from inputremapper.configs.base_config import ConfigBase, INITIAL_CONFIG from inputremapper.configs.paths import CONFIG_PATH, USER, touch from inputremapper.logger import logger -from inputremapper.configs.base_config import ConfigBase, INITIAL_CONFIG MOUSE = "mouse" WHEEL = "wheel" @@ -45,6 +45,10 @@ class GlobalConfig(ConfigBase): self.path = os.path.join(CONFIG_PATH, "config.json") super().__init__() + def get_dir(self) -> str: + """the folder containing this config""" + return os.path.split(self.path)[0] + def set_autoload_preset(self, group_key, preset): """Set a preset to be automatically applied on start. Parameters diff --git a/inputremapper/configs/mapping.py b/inputremapper/configs/mapping.py index e80abc27..4e38a760 100644 --- a/inputremapper/configs/mapping.py +++ b/inputremapper/configs/mapping.py @@ -18,45 +18,62 @@ # 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 typing import Optional, Callable, Tuple, Dict, Any, TypeVar + +import pkg_resources from evdev.ecodes import EV_KEY, EV_ABS, EV_REL from pydantic import ( BaseModel, PositiveInt, confloat, + conint, root_validator, validator, ValidationError, PositiveFloat, VERSION, + BaseConfig, ) -from typing import Optional, Callable, Tuple, Dict, Union, Any -import pkg_resources - -from inputremapper.event_combination import EventCombination from inputremapper.configs.system_mapping import system_mapping +from inputremapper.event_combination import EventCombination from inputremapper.exceptions import MacroParsingError +from inputremapper.gui.message_broker import MessageType from inputremapper.injection.macros.parse import is_this_a_macro, parse from inputremapper.input_event import EventActions # TODO: remove pydantic VERSION check as soon as we no longer support # Ubuntu 20.04 and with it the ainchant pydantic 1.2 + needs_workaround = pkg_resources.parse_version( str(VERSION) ) < pkg_resources.parse_version("1.7.1") -# TODO: in python 3.11 inherit enum.StrEnum class KnownUinput(str, enum.Enum): keyboard = "keyboard" mouse = "mouse" gamepad = "gamepad" + keyboard_mouse = "keyboard + mouse" CombinationChangedCallback = Optional[ Callable[[EventCombination, EventCombination], None] ] +MappingModel = TypeVar("MappingModel", bound="Mapping") + + +class Cfg(BaseConfig): + validate_assignment = True + use_enum_values = True + underscore_attrs_are_private = True + json_encoders = {EventCombination: lambda v: v.json_str()} + + +class ImmutableCfg(Cfg): + allow_mutation = False class Mapping(BaseModel): @@ -75,12 +92,14 @@ class Mapping(BaseModel): 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 + # 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: PositiveInt = 0 + macro_key_sleep_ms: conint(ge=0) = 0 # type: ignore # Optional attributes for mapping Axis to Axis # The deadzone of the input axis @@ -99,6 +118,13 @@ class Mapping(BaseModel): rel_input_cutoff: PositiveInt = 100 # 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 + + def is_axis_mapping(self) -> bool: + """whether this mapping specifies an output axis""" + return self.output_type == EV_ABS or self.output_type == EV_REL # callback which gets called if the event_combination is updated if not needs_workaround: @@ -142,8 +168,9 @@ class Mapping(BaseModel): if needs_workaround: # https://github.com/samuelcolvin/pydantic/issues/1383 - def copy(self, *args, **kwargs) -> Mapping: - copy = super(Mapping, self).copy(*args, deep=True, **kwargs) + def copy(self: MappingModel, *args, **kwargs) -> MappingModel: + kwargs["deep"] = True + copy = super(Mapping, self).copy(*args, **kwargs) object.__setattr__(copy, "_combination_changed", self._combination_changed) return copy @@ -218,11 +245,11 @@ class Mapping(BaseModel): @validator("event_combination") def set_event_actions(cls, combination): - """Sets the correct action for each event.""" + """Sets the correct actions for each event.""" new_combination = [] for event in combination: if event.value != 0: - event = event.modify(action=EventActions.as_key) + event = event.modify(actions=(EventActions.as_key,)) new_combination.append(event) return EventCombination(new_combination) @@ -267,7 +294,7 @@ class Mapping(BaseModel): @root_validator def output_axis_given(cls, values): """Validate that an output type is an axis if we have an input axis.""" - combination = values.get("event_combination") + combination: EventCombination = values.get("event_combination") output_type = values.get("output_type") event_values = [event.value for event in combination] if 0 not in event_values: @@ -275,18 +302,14 @@ class Mapping(BaseModel): if output_type not in (EV_ABS, EV_REL): raise ValueError( + f"missing output axis: " f"the {combination = } specifies a input axis, " f"but the {output_type = } is not an axis " ) return values - class Config: - validate_assignment = True - use_enum_values = True - underscore_attrs_are_private = True - - json_encoders = {EventCombination: lambda v: v.json_str()} + Config = Cfg class UIMapping(Mapping): @@ -308,12 +331,11 @@ class UIMapping(Mapping): def __init__(self, **data): # type: ignore object.__setattr__(self, "_last_error", None) super().__init__( - event_combination="99,99,99", + event_combination=EventCombination.empty_combination(), target_uinput="keyboard", output_symbol="KEY_A", ) cache = { - "event_combination": None, "target_uinput": None, "output_symbol": None, } @@ -330,6 +352,7 @@ class UIMapping(Mapping): super(UIMapping, self).__setattr__(key, value) if key in self._cache: del self._cache[key] + self._last_error = None except ValidationError as error: # cache the value @@ -369,10 +392,23 @@ class UIMapping(Mapping): return dict_ + def copy(self: MappingModel, *args, **kwargs) -> MappingModel: + # we always need a deep copy otherwise the _cache of the copy will + # point to the same address + kwargs["deep"] = True + # seems to be related: https://github.com/python/mypy/issues/9282 + copy = super().copy(*args, **kwargs) # type: ignore + object.__setattr__(copy, "_combination_changed", self._combination_changed) + return copy + def get_error(self) -> Optional[ValidationError]: """The validation error or None.""" return self._last_error + def get_bus_message(self) -> MappingData: + """return a immutable copy for use in the""" + return MappingData(**self.dict()) + def _validate(self) -> None: """Try to validate the mapping.""" if self.is_valid(): @@ -397,3 +433,18 @@ class UIMapping(Mapping): self._cache["event_combination"] = EventCombination.validate( self._cache["event_combination"] ) + + +class MappingData(UIMapping): + 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(MappingData, self).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 index 20e45fe3..3317fec8 100644 --- a/inputremapper/configs/migrations.py +++ b/inputremapper/configs/migrations.py @@ -21,15 +21,15 @@ """Migration functions""" +import copy +import json import os import re -import json -import copy import shutil -import pkg_resources - -from typing import List from pathlib import Path +from typing import Iterator, Tuple, Dict + +import pkg_resources from evdev.ecodes import ( EV_KEY, EV_ABS, @@ -44,24 +44,23 @@ from evdev.ecodes import ( REL_HWHEEL_HI_RES, ) -from inputremapper.configs.preset import Preset from inputremapper.configs.mapping import Mapping, UIMapping -from inputremapper.event_combination import EventCombination -from inputremapper.logger import logger, VERSION, IS_BETA -from inputremapper.user import HOME from inputremapper.configs.paths import get_preset_path, mkdir, CONFIG_PATH, remove +from inputremapper.configs.preset import Preset from inputremapper.configs.system_mapping import system_mapping +from inputremapper.event_combination import EventCombination from inputremapper.injection.global_uinputs import global_uinputs from inputremapper.injection.macros.parse import is_this_a_macro +from inputremapper.logger import logger, VERSION, IS_BETA +from inputremapper.user import HOME -def all_presets() -> List[os.PathLike]: +def all_presets() -> Iterator[Tuple[os.PathLike, Dict]]: """Get all presets for all groups as list.""" if not os.path.exists(get_preset_path()): - return [] + return preset_path = Path(get_preset_path()) - presets = [] for folder in preset_path.iterdir(): if not folder.is_dir(): continue @@ -78,8 +77,6 @@ def all_presets() -> List[os.PathLike]: logger.warning('Invalid json format in preset "%s"', preset) continue - return presets - def config_version(): """Get the version string in config.json as packaging.Version object.""" diff --git a/inputremapper/configs/paths.py b/inputremapper/configs/paths.py index 33b0fd20..00f35d6e 100644 --- a/inputremapper/configs/paths.py +++ b/inputremapper/configs/paths.py @@ -25,6 +25,7 @@ import os import shutil +from typing import List, Union from inputremapper.logger import logger, VERSION, IS_BETA from inputremapper.user import USER, HOME @@ -44,9 +45,9 @@ def chown(path): shutil.chown(path, user=USER) -def touch(path, log=True): +def touch(path: os.PathLike, log=True): """Create an empty file and all its parent dirs, give it to the user.""" - if path.endswith("/"): + if str(path).endswith("/"): raise ValueError(f"Expected path to not end with a slash: {path}") if os.path.exists(path): @@ -81,6 +82,23 @@ def mkdir(path, log=True): chown(path) +def split_all(path: Union[os.PathLike, str]) -> List[str]: + parts = [] + while True: + path, tail = os.path.split(path) + parts.append(tail) + if path == os.path.sep: + # we arrived at the root '/' + parts.append(path) + break + if not path: + # arrived at start of relative path + break + + parts.reverse() + return parts + + def remove(path): """Remove whatever is at the path.""" if not os.path.exists(path): @@ -103,8 +121,8 @@ def get_preset_path(group_name=None, preset=None): # the extension of the preset should not be shown in the ui. # if a .json extension arrives this place, it has not been # stripped away properly prior to this. - assert not preset.endswith(".json") - preset = f"{preset}.json" + if not preset.endswith(".json"): + preset = f"{preset}.json" if preset is None: return os.path.join(presets_base, group_name) diff --git a/inputremapper/configs/preset.py b/inputremapper/configs/preset.py index 7caf9866..fdce2f7d 100644 --- a/inputremapper/configs/preset.py +++ b/inputremapper/configs/preset.py @@ -22,21 +22,28 @@ from __future__ import annotations """Contains and manages mappings.""" import os -import re import json -import glob -import time -from typing import Tuple, Dict, List, Optional, Iterator, Type, Iterable, Any, Union +from typing import ( + Tuple, + Dict, + List, + Optional, + Iterator, + Type, + Iterable, + TypeVar, + Generic, + overload, +) from pydantic import ValidationError from inputremapper.logger import logger from inputremapper.configs.mapping import Mapping -from inputremapper.configs.paths import touch, get_preset_path, mkdir +from inputremapper.configs.paths import touch from inputremapper.input_event import InputEvent from inputremapper.event_combination import EventCombination -from inputremapper.groups import groups def common_data(list1: Iterable, list2: Iterable) -> List: @@ -52,32 +59,50 @@ def common_data(list1: Iterable, list2: Iterable) -> List: return common -class Preset: +MappingModel = TypeVar("MappingModel", bound=Mapping) + + +class Preset(Generic[MappingModel]): """Contains and manages mappings of a single preset.""" - _mappings: Dict[EventCombination, Mapping] - # a copy of mappings for keeping track of changes - _saved_mappings: Dict[EventCombination, Mapping] - _path: Optional[os.PathLike] - _mapping_factpry: Type[Mapping] # the mapping class which is used by load() + # 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[Mapping] = Mapping, + mapping_factory: Type[MappingModel] = ..., + ): + ... + + def __init__( + self, + path: Optional[os.PathLike] = None, + mapping_factory=Mapping, ) -> None: - self._mappings = {} - self._saved_mappings = {} - self._path = path - self._mapping_factory = mapping_factory + self._mappings: Dict[EventCombination, MappingModel] = {} + # a copy of mappings for keeping track of changes + self._saved_mappings: Dict[EventCombination, 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[Mapping]: + def __iter__(self) -> Iterator[MappingModel]: """Iterate over Mapping objects.""" return iter(self._mappings.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 @@ -101,7 +126,7 @@ class Preset: logger.debug(f"unable to remove non-existing mapping with {combination = }") pass - def add(self, mapping: Mapping) -> None: + def add(self, mapping: MappingModel) -> None: """Add a mapping to the preset.""" for permutation in mapping.event_combination.get_permutations(): if permutation in self._mappings: @@ -137,7 +162,7 @@ class Preset: self._saved_mappings = self._get_mappings_from_disc() self.empty() for mapping in self._saved_mappings.values(): - # use the external add method to make sure + # use the public add method to make sure # the _combination_changed_callback is attached self.add(mapping.copy()) @@ -148,8 +173,9 @@ class Preset: logger.debug("unable to save preset without a path set Preset.path first") return - touch(str(self.path)) # touch expects a string, not a Posix path + touch(self.path) if not self.has_unsaved_changes(): + logger.debug("Not saving unchanged preset") return logger.info("Saving preset to %s", self.path) @@ -158,7 +184,10 @@ class Preset: saved_mappings = {} for mapping in self: if not mapping.is_valid(): - if not isinstance(mapping.event_combination, EventCombination): + if ( + not isinstance(mapping.event_combination, EventCombination) + or mapping.event_combination == EventCombination.empty_combination() + ): # we save invalid mapping except for those with # invalid event_combination logger.debug("skipping invalid mapping %s", mapping) @@ -189,7 +218,9 @@ class Preset: def is_valid(self) -> bool: return False not in [mapping.is_valid() for mapping in self] - def get_mapping(self, combination: Optional[EventCombination]) -> Optional[Mapping]: + def get_mapping( + self, combination: Optional[EventCombination] + ) -> Optional[MappingModel]: """Return the Mapping that is mapped to this EventCombination. Parameters ---------- @@ -246,12 +277,16 @@ class Preset: return self._saved_mappings = self._get_mappings_from_disc() - def _get_mappings_from_disc(self) -> Dict[EventCombination, Mapping]: - mappings: Dict[EventCombination, Mapping] = {} + def _get_mappings_from_disc(self) -> Dict[EventCombination, MappingModel]: + mappings: Dict[EventCombination, 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_dict = json.load(file) @@ -285,150 +320,8 @@ class Preset: self._path = path self._update_saved_mappings() - -########################################################################### -# Method from previously presets.py -# TODO: See what can be implemented as classmethod or -# member function of Preset -########################################################################### - - -def get_available_preset_name(group_name, preset="new preset", copy=False): - """Increment the preset name until it is available.""" - if group_name is None: - # endless loop otherwise - raise ValueError("group_name may not be None") - - preset = preset.strip() - - if copy and not re.match(r"^.+\scopy( \d+)?$", preset): - preset = f"{preset} copy" - - # find a name that is not already taken - if os.path.exists(get_preset_path(group_name, preset)): - # if there already is a trailing number, increment it instead of - # adding another one - match = re.match(r"^(.+) (\d+)$", preset) - if match: - preset = match[1] - i = int(match[2]) + 1 - else: - i = 2 - - while os.path.exists(get_preset_path(group_name, f"{preset} {i}")): - i += 1 - - return f"{preset} {i}" - - return preset - - -def get_presets(group_name: str) -> List[str]: - """Get all preset filenames for the device and user, starting with the newest. - - Parameters - ---------- - group_name : string - """ - device_folder = get_preset_path(group_name) - mkdir(device_folder) - - paths = glob.glob(os.path.join(device_folder, "*.json")) - presets = [ - os.path.splitext(os.path.basename(path))[0] - for path in sorted(paths, key=os.path.getmtime) - ] - # the highest timestamp to the front - presets.reverse() - return presets - - -def get_any_preset() -> Tuple[str | None, str | None]: - """Return the first found tuple of (device, preset).""" - group_names = groups.list_group_names() - if len(group_names) == 0: - return None, None - any_device = list(group_names)[0] - any_preset = get_presets(any_device) - return any_device, any_preset[0] if any_preset else None - - -def find_newest_preset(group_name=None): - """Get a tuple of (device, preset) that was most recently modified - in the users home directory. - - If no device has been configured yet, return an arbitrary device. - - Parameters - ---------- - group_name : string - If set, will return the newest preset for the device or None - """ - # sort the oldest files to the front in order to use pop to get the newest - if group_name is None: - paths = sorted( - glob.glob(os.path.join(get_preset_path(), "*/*.json")), - key=os.path.getmtime, - ) - else: - paths = sorted( - glob.glob(os.path.join(get_preset_path(group_name), "*.json")), - key=os.path.getmtime, - ) - - if len(paths) == 0: - logger.debug("No presets found") - return get_any_preset() - - group_names = groups.list_group_names() - - newest_path = None - while len(paths) > 0: - # take the newest path - path = paths.pop() - preset = os.path.split(path)[1] - group_name = os.path.split(os.path.split(path)[0])[1] - if group_name in group_names: - newest_path = path - break - - if newest_path is None: - return get_any_preset() - - preset = os.path.splitext(preset)[0] - logger.debug('The newest preset is "%s", "%s"', group_name, preset) - - return group_name, preset - - -def delete_preset(group_name, preset): - """Delete one of the users presets.""" - preset_path = get_preset_path(group_name, preset) - if not os.path.exists(preset_path): - logger.debug('Cannot remove non existing path "%s"', preset_path) - return - - logger.info('Removing "%s"', preset_path) - os.remove(preset_path) - - device_path = get_preset_path(group_name) - if os.path.exists(device_path) and len(os.listdir(device_path)) == 0: - logger.debug('Removing empty dir "%s"', device_path) - os.rmdir(device_path) - - -def rename_preset(group_name, old_preset_name, new_preset_name): - """Rename one of the users presets while avoiding name conflicts.""" - if new_preset_name == old_preset_name: - return old_preset_name - - new_preset_name = get_available_preset_name(group_name, new_preset_name) - logger.info('Moving "%s" to "%s"', old_preset_name, new_preset_name) - os.rename( - get_preset_path(group_name, old_preset_name), - get_preset_path(group_name, new_preset_name), - ) - # set the modification date to now - now = time.time() - os.utime(get_preset_path(group_name, new_preset_name), (now, now)) - return new_preset_name + @property + def name(self) -> Optional[str]: + if self.path: + return os.path.basename(self.path).split(".")[0] + return None diff --git a/inputremapper/configs/system_mapping.py b/inputremapper/configs/system_mapping.py index 81866de6..6e1f58c3 100644 --- a/inputremapper/configs/system_mapping.py +++ b/inputremapper/configs/system_mapping.py @@ -19,13 +19,15 @@ # along with input-remapper. If not, see . """Make the systems/environments mapping of keys and codes accessible.""" -import re import json +import re import subprocess +from typing import Optional, List, Iterable + import evdev -from inputremapper.logger import logger from inputremapper.configs.paths import get_config_path, touch +from inputremapper.logger import logger from inputremapper.utils import is_service DISABLE_NAME = "disable" @@ -64,7 +66,7 @@ class SystemMapping: return object.__getattribute__(self, wanted) - def list_names(self, codes=None): + def list_names(self, codes: Optional[Iterable[int]] = None) -> List[str]: """Return a list of all possible names in the mapping, optionally filtered by codes. Parameters diff --git a/inputremapper/daemon.py b/inputremapper/daemon.py index b65c64a2..abbac091 100644 --- a/inputremapper/daemon.py +++ b/inputremapper/daemon.py @@ -25,14 +25,16 @@ https://github.com/LEW21/pydbus/tree/cc407c8b1d25b7e28a6d661a29f9e661b1c9b964/ex """ +import atexit +import json import os import sys -import json import time -import atexit +from pathlib import PurePath +from typing import Protocol, Dict -from pydbus import SystemBus import gi +from pydbus import SystemBus gi.require_version("GLib", "2.0") from gi.repository import GLib @@ -116,6 +118,34 @@ def remove_timeout(func): return wrapped +class DaemonProxy(Protocol): # pragma: no cover + """the interface provided over the dbus""" + + def stop_injecting(self, group_key: str) -> None: + ... + + def get_state(self, group_key: str) -> int: + ... + + def start_injecting(self, group_key: str, preset: str) -> bool: + ... + + def stop_all(self) -> None: + ... + + def set_config_dir(self, config_dir: str) -> None: + ... + + def autoload(self) -> None: + ... + + def autoload_single(self, group_key: str) -> None: + ... + + def hello(self, out: str) -> str: + ... + + class Daemon: """Starts injecting keycodes based on the configuration. @@ -164,7 +194,7 @@ class Daemon: def __init__(self): """Constructs the daemon.""" logger.debug("Creating daemon") - self.injectors = {} + self.injectors: Dict[str, Injector] = {} self.config_dir = None @@ -184,7 +214,7 @@ class Daemon: macro_variables.start() @classmethod - def connect(cls, fallback=True): + def connect(cls, fallback=True) -> DaemonProxy: """Get an interface to start and stop injecting keystrokes. Parameters @@ -193,8 +223,8 @@ class Daemon: If true, returns an instance of the daemon instead if it cannot connect """ + bus = SystemBus() try: - bus = SystemBus() interface = bus.get(BUS_NAME, timeout=BUS_TIMEOUT) logger.info("Connected to the service") except GLib.GError as error: @@ -306,7 +336,7 @@ class Daemon: This path contains config.json, xmodmap.json and the presets directory """ - config_path = os.path.join(config_dir, "config.json") + config_path = PurePath(config_dir, "config.json") if not os.path.exists(config_path): logger.error('"%s" does not exist', config_path) return @@ -405,7 +435,7 @@ class Daemon: for group_key, _ in autoload_presets: self._autoload(group_key) - def start_injecting(self, group_key, preset): + def start_injecting(self, group_key, preset) -> bool: """Start injecting the preset for the device. Returns True on success. If an injection is already ongoing for @@ -435,7 +465,7 @@ class Daemon: logger.error('Could not find group "%s"', group_key) return False - preset_path = os.path.join( + preset_path = PurePath( self.config_dir, "presets", group.name, @@ -453,6 +483,13 @@ class Daemon: # date when the system layout changes. xmodmap = json.load(file) logger.debug('Using keycodes from "%s"', xmodmap_path) + + # this creates the system_mapping._xmodmap, which we need to do now + # otherwise it might be created later which will override the changes + # we do here. + # Do we really need to lazyload in the system_mapping? + # this kind of bug is stupid to track down + system_mapping.get_name(0) system_mapping.update(xmodmap) # the service now has process wide knowledge of xmodmap # keys of the users session diff --git a/inputremapper/event_combination.py b/inputremapper/event_combination.py index 972380f2..21635b94 100644 --- a/inputremapper/event_combination.py +++ b/inputremapper/event_combination.py @@ -20,14 +20,11 @@ from __future__ import annotations - import itertools -from typing import Tuple, Iterable, Union, List, Callable, Sequence +from typing import Tuple, Iterable, Union, Callable, Sequence from evdev import ecodes -from inputremapper.logger import logger -from inputremapper.configs.system_mapping import system_mapping from inputremapper.input_event import InputEvent, InputEventValidationType # having shift in combinations modifies the configured output, @@ -64,12 +61,17 @@ class EventCombination(Tuple[InputEvent]): for event in events: validated_events.append(InputEvent.validate(event)) + if len(validated_events) == 0: + raise ValueError(f"failed to create EventCombination with {events = }") + # mypy bug: https://github.com/python/mypy/issues/8957 # https://github.com/python/mypy/issues/8541 return super().__new__(cls, validated_events) # type: ignore def __str__(self): - # only used in tests and logging + return " + ".join(event.description(exclude_threshold=True) for event in self) + + def __repr__(self): return f"" @classmethod @@ -105,7 +107,13 @@ class EventCombination(Tuple[InputEvent]): except AttributeError: raise ValueError(f"failed to create EventCombination from {init_string = }") - def is_problematic(self): + @classmethod + def empty_combination(cls) -> EventCombination: + """a combination that has default invalid (to evdev) values useful for the + UI to indicate that this combination is not set""" + return cls("99,99,99") + + def is_problematic(self) -> bool: """Is this combination going to work properly on all systems?""" if len(self) <= 1: return False @@ -119,6 +127,9 @@ class EventCombination(Tuple[InputEvent]): return False + def has_input_axis(self) -> bool: + return False in (event.is_key_event for event in self) + def get_permutations(self): """Get a list of EventCombination objects representing all possible permutations. @@ -139,93 +150,6 @@ class EventCombination(Tuple[InputEvent]): def beautify(self) -> str: """Get a human readable string representation.""" - result = [] - - for event in self: - - if event.type not in ecodes.bytype: - logger.error("Unknown type for %s", event) - result.append(str(event.code)) - continue - - if event.code not in ecodes.bytype[event.type]: - logger.error("Unknown combination code for %s", event) - result.append(str(event.code)) - continue - - key_name = None - - # first try to find the name in xmodmap to not display wrong - # names due to the keyboard layout - if event.type == ecodes.EV_KEY: - key_name = system_mapping.get_name(event.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 = ecodes.bytype[event.type][event.code] - if isinstance(key_name, list): - key_name = key_name[0] - - if event.type != ecodes.EV_KEY: - direction = { - # 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((event.code, event.value)) - if direction is not None: - key_name += f" {direction}" - - key_name = key_name.replace("ABS_Z", "Trigger Left") - key_name = key_name.replace("ABS_RZ", "Trigger Right") - - key_name = key_name.replace("ABS_HAT0X", "DPad") - key_name = key_name.replace("ABS_HAT0Y", "DPad") - key_name = key_name.replace("ABS_HAT1X", "DPad 2") - key_name = key_name.replace("ABS_HAT1Y", "DPad 2") - key_name = key_name.replace("ABS_HAT2X", "DPad 3") - key_name = key_name.replace("ABS_HAT2Y", "DPad 3") - - key_name = key_name.replace("ABS_X", "Joystick") - key_name = key_name.replace("ABS_Y", "Joystick") - key_name = key_name.replace("ABS_RX", "Joystick 2") - key_name = key_name.replace("ABS_RY", "Joystick 2") - - key_name = key_name.replace("BTN_", "Button ") - key_name = key_name.replace("KEY_", "") - - key_name = key_name.replace("REL_", "") - key_name = key_name.replace("HWHEEL", "Wheel") - key_name = key_name.replace("WHEEL", "Wheel") - - key_name = key_name.replace("_", " ") - key_name = key_name.replace(" ", " ") - - result.append(key_name) - - return " + ".join(result) + if self == EventCombination.empty_combination(): + return "empty_combination" + return " + ".join(event.description(exclude_threshold=True) for event in self) diff --git a/inputremapper/exceptions.py b/inputremapper/exceptions.py index ef895e3c..69edafaf 100644 --- a/inputremapper/exceptions.py +++ b/inputremapper/exceptions.py @@ -57,3 +57,8 @@ class MappingParsingError(Error): class InputEventCreationError(Error): def __init__(self, msg): super().__init__(msg) + + +class DataManagementError(Error): + def __init__(self, msg): + super().__init__(msg) diff --git a/inputremapper/groups.py b/inputremapper/groups.py index 1dc7df06..737cd014 100644 --- a/inputremapper/groups.py +++ b/inputremapper/groups.py @@ -29,14 +29,15 @@ Those groups are what is being displayed in the device dropdown, and events are being read from all of the paths of an individual group in the gui and the injector. """ - - -import re -import multiprocessing -import threading +from __future__ import annotations import asyncio +import enum import json -from typing import List +import multiprocessing +import os +import re +import threading +from typing import List, Optional import evdev from evdev.ecodes import ( @@ -53,9 +54,8 @@ from evdev.ecodes import ( REL_WHEEL, ) -from inputremapper.logger import logger from inputremapper.configs.paths import get_preset_path - +from inputremapper.logger import logger TABLET_KEYS = [ evdev.ecodes.BTN_STYLUS, @@ -64,13 +64,15 @@ TABLET_KEYS = [ evdev.ecodes.BTN_TOOL_RUBBER, ] -GAMEPAD = "gamepad" -KEYBOARD = "keyboard" -MOUSE = "mouse" -TOUCHPAD = "touchpad" -GRAPHICS_TABLET = "graphics-tablet" -CAMERA = "camera" -UNKNOWN = "unknown" + +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"): @@ -156,7 +158,7 @@ def _is_camera(capabilities): return key_capa and len(key_capa) == 1 and key_capa[0] == KEY_CAMERA -def classify(device): +def classify(device) -> DeviceType: """Figure out what kind of device this is. Use this instead of functions like _is_keyboard to avoid getting false @@ -167,26 +169,26 @@ def classify(device): if _is_graphics_tablet(capabilities): # check this before is_gamepad to avoid classifying abs_x # as joysticks when they are actually stylus positions - return GRAPHICS_TABLET + return DeviceType.GRAPHICS_TABLET if _is_touchpad(capabilities): - return TOUCHPAD + return DeviceType.TOUCHPAD if _is_gamepad(capabilities): - return GAMEPAD + return DeviceType.GAMEPAD if _is_mouse(capabilities): - return MOUSE + return DeviceType.MOUSE if _is_camera(capabilities): - return CAMERA + 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 KEYBOARD + return DeviceType.KEYBOARD - return UNKNOWN + return DeviceType.UNKNOWN DENYLIST = [".*Yubico.*YubiKey.*", "Eee PC WMI hotkeys"] @@ -253,7 +255,13 @@ class _Group: presets folder structure """ - def __init__(self, paths: List[str], names: List[str], types: List[str], key: str): + def __init__( + self, + paths: List[os.PathLike], + names: List[str], + types: List[DeviceType | str], + key: str, + ): """Specify a group Parameters @@ -262,7 +270,7 @@ class _Group: Paths in /dev/input of the grouped devices names : str[] Names of the grouped devices - types : str[] + types : list[DeviceType] Types of the grouped devices key : str Unique identifier of the group. @@ -283,7 +291,7 @@ class _Group: self.paths = paths self.names = names - self.types = types + self.types = [DeviceType(type_) for type_ in types] def get_preset_path(self, preset=None): """Get a path to the stored preset, or to store a preset to. @@ -356,7 +364,7 @@ class _FindGroups(threading.Thread): device_type = classify(device) - if device_type == CAMERA: + if device_type == DeviceType.CAMERA: continue # https://www.kernel.org/doc/html/latest/input/event-codes.html @@ -364,7 +372,7 @@ class _FindGroups(threading.Thread): key_capa = capabilities.get(EV_KEY) - if key_capa is None and device_type != GAMEPAD: + if key_capa is None and device_type != DeviceType.GAMEPAD: # skip devices that don't provide buttons that can be mapped continue @@ -405,14 +413,17 @@ class _FindGroups(threading.Thread): key=key, paths=devs, names=names, - types=sorted(list({item[2] for item in group if item[2] != UNKNOWN})), + 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 an take ages to complete in the thread + # destructors can go on and take ages to complete in the thread # without blocking anything @@ -429,7 +440,7 @@ class _Groups: need it the information. """ if key == "_groups" and object.__getattribute__(self, "_groups") is None: - object.__setattr__(self, "_groups", {}) + object.__setattr__(self, "_groups", []) object.__getattribute__(self, "refresh")() return object.__getattribute__(self, key) @@ -452,7 +463,7 @@ class _Groups: keys = [f'"{group.key}"' for group in self._groups] logger.info("Found %s", ", ".join(keys)) - def filter(self, include_inputremapper=False): + def filter(self, include_inputremapper=False) -> List[_Group]: """Filter groups.""" result = [] for group in self._groups: @@ -466,6 +477,7 @@ class _Groups: def set_groups(self, new_groups): """Overwrite all groups.""" + logger.debug("overwriting groups with %s", new_groups) self._groups = new_groups def list_group_names(self) -> List[str]: @@ -496,7 +508,7 @@ class _Groups: key: str = None, path: str = None, include_inputremapper: bool = False, - ) -> _Group: + ) -> Optional[_Group]: """Find a group that matches the provided parameters. Parameters diff --git a/inputremapper/gui/active_preset.py b/inputremapper/gui/active_preset.py deleted file mode 100644 index 6f2adc0a..00000000 --- a/inputremapper/gui/active_preset.py +++ /dev/null @@ -1,29 +0,0 @@ -#!/usr/bin/python3 -# -*- coding: utf-8 -*- -# input-remapper - GUI for device specific keyboard mappings -# Copyright (C) 2022 sezanzeb -# -# This file is part of input-remapper. -# -# input-remapper is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# input-remapper is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with input-remapper. If not, see . - - -"""One preset object for the GUI application.""" - - -from inputremapper.configs.preset import Preset -from inputremapper.configs.mapping import UIMapping - - -active_preset = Preset(mapping_factory=UIMapping) diff --git a/inputremapper/gui/editor/autocompletion.py b/inputremapper/gui/autocompletion.py similarity index 81% rename from inputremapper/gui/editor/autocompletion.py rename to inputremapper/gui/autocompletion.py index 53473f36..87049893 100644 --- a/inputremapper/gui/editor/autocompletion.py +++ b/inputremapper/gui/autocompletion.py @@ -23,30 +23,34 @@ import re +from typing import Dict, Optional, List, Tuple -from gi.repository import Gdk, Gtk, GLib, GObject from evdev.ecodes import EV_KEY +from gi.repository import Gdk, Gtk, GLib, GObject +from inputremapper.configs.mapping import MappingData from inputremapper.configs.system_mapping import system_mapping +from inputremapper.gui.components import CodeEditor +from inputremapper.gui.message_broker import MessageBroker, MessageType, UInputsData +from inputremapper.gui.utils import debounce from inputremapper.injection.macros.parse import ( FUNCTIONS, get_macro_argument_names, remove_comments, ) -from inputremapper.injection.global_uinputs import global_uinputs from inputremapper.logger import logger -from inputremapper.gui.utils import debounce - # no deprecated shorthand function-names FUNCTION_NAMES = [name for name in FUNCTIONS.keys() if len(name) > 1] # no deprecated functions FUNCTION_NAMES.remove("ifeq") +Capabilities = Dict[int, List] + -def _get_left_text(iter): - buffer = iter.get_buffer() - result = buffer.get_text(buffer.get_start_iter(), iter, True) +def _get_left_text(iter_: Gtk.TextIter) -> str: + buffer = iter_.get_buffer() + result = buffer.get_text(buffer.get_start_iter(), iter_, True) result = remove_comments(result) result = result.replace("\n", " ") return result.lower() @@ -57,9 +61,9 @@ PARAMETER = r".*?[(,=+]\s*" FUNCTION_CHAIN = r".*?\)\s*\.\s*" -def get_incomplete_function_name(iter): +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) + left_text = _get_left_text(iter_) # match foo in: # bar().foo @@ -77,9 +81,9 @@ def get_incomplete_function_name(iter): return match[1] -def get_incomplete_parameter(iter): +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) + left_text = _get_left_text(iter_) # match foo in: # bar(foo @@ -96,7 +100,7 @@ def get_incomplete_parameter(iter): return match[1] -def propose_symbols(text_iter, codes): +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) @@ -112,7 +116,7 @@ def propose_symbols(text_iter, codes): ] -def propose_function_names(text_iter): +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) @@ -146,7 +150,7 @@ class Autocompletion(Gtk.Popover): __gtype_name__ = "Autocompletion" - def __init__(self, text_input, target_selector): + def __init__(self, message_broker: MessageBroker, code_editor: CodeEditor): """Create an autocompletion popover. It will remain hidden until there is something to autocomplete. @@ -164,10 +168,10 @@ class Autocompletion(Gtk.Popover): constrain_to=Gtk.PopoverConstraint.NONE, ) - self.text_input = text_input - self.target_selector = target_selector - self._target_key_capabilities = [] - target_selector.connect("changed", self._update_target_key_capabilities) + self.code_editor = code_editor + 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, @@ -192,22 +196,27 @@ class Autocompletion(Gtk.Popover): self.set_position(Gtk.PositionType.BOTTOM) - text_input.connect("key-press-event", self.navigate) + 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 - text_input.connect("focus-out-event", self.on_text_input_unfocus) + self.code_editor.gui.connect("focus-out-event", self.on_gtk_text_input_unfocus) - text_input.get_buffer().connect("changed", self.update) + 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 on_text_input_unfocus(self, *_): + def attach_to_events(self): + self.message_broker.subscribe(MessageType.mapping, self._on_mapping_loaded) + 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 - @@ -300,8 +309,8 @@ class Autocompletion(Gtk.Popover): def _get_text_iter_at_cursor(self): """Get Gtk.TextIter at the current text cursor location.""" - cursor = self.text_input.get_cursor_locations()[0] - return self.text_input.get_iter_at_location(cursor.x, cursor.y)[1] + 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 @@ -314,24 +323,24 @@ class Autocompletion(Gtk.Popover): @debounce(100) def update(self, *_): """Find new autocompletion suggestions and display them. Hide if none.""" - if not self.text_input.is_focus(): + 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.text_input.get_cursor_locations()[0] + 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.text_input.buffer_to_window_coords( + 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.text_input.get_show_line_numbers(): + if self.code_editor.gui.get_show_line_numbers(): cursor.x += 25 self.set_pointing_to(cursor) @@ -352,17 +361,19 @@ class Autocompletion(Gtk.Popover): self.list_box.insert(label, -1) label.show_all() - def _update_target_key_capabilities(self, *_): - target = self.target_selector.get_active_id() - self._target_key_capabilities = global_uinputs.get_uinput( - target - ).capabilities()[EV_KEY] + def _on_mapping_loaded(self, mapping: MappingData): + if mapping and self._uinputs: + target = mapping.target_uinput or "keyboard" + self._target_key_capabilities = self._uinputs[target][EV_KEY] + + def _on_uinputs_changed(self, data: UInputsData): + self._uinputs = data.uinputs def _on_suggestion_clicked(self, _, selected_row): """An autocompletion suggestion was selected and should be inserted.""" selected_label = selected_row.get_children()[0] suggestion = selected_label.suggestion - buffer = self.text_input.get_buffer() + buffer = self.code_editor.gui.get_buffer() # make sure to replace the complete unfinished word. Look to the right and # remove whatever there is @@ -371,7 +382,7 @@ class Autocompletion(Gtk.Popover): match = re.match(r"^(\w+)", right) right = match[1] if match else "" Gtk.TextView.do_delete_from_cursor( - self.text_input, Gtk.DeleteType.CHARS, len(right) + self.code_editor.gui, Gtk.DeleteType.CHARS, len(right) ) # do the same to the left @@ -380,11 +391,11 @@ class Autocompletion(Gtk.Popover): match = re.match(r".*?(\w+)$", re.sub("\n", " ", left)) left = match[1] if match else "" Gtk.TextView.do_delete_from_cursor( - self.text_input, Gtk.DeleteType.CHARS, -len(left) + self.code_editor.gui, Gtk.DeleteType.CHARS, -len(left) ) # insert the autocompletion - Gtk.TextView.do_insert_at_cursor(self.text_input, suggestion) + Gtk.TextView.do_insert_at_cursor(self.code_editor.gui, suggestion) self.emit("suggestion-inserted") diff --git a/inputremapper/gui/components.py b/inputremapper/gui/components.py new file mode 100644 index 00000000..f6adbb06 --- /dev/null +++ b/inputremapper/gui/components.py @@ -0,0 +1,1116 @@ +#!/usr/bin/python3 +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2022 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + +from __future__ import annotations + +from collections import defaultdict +from typing import List, Optional, Dict, Union, Callable + +import cairo +from evdev.ecodes import EV_KEY, EV_ABS, EV_REL, bytype +from gi.repository import Gtk, GtkSource, Gdk + +from inputremapper.configs.mapping import MappingData +from inputremapper.event_combination import EventCombination +from inputremapper.groups import DeviceType +from inputremapper.gui.controller import Controller +from inputremapper.gui.gettext import _ +from inputremapper.gui.message_broker import ( + MessageBroker, + MessageType, + GroupsData, + GroupData, + UInputsData, + PresetData, + StatusData, + CombinationUpdate, + UserConfirmRequest, +) +from inputremapper.gui.utils import HandlerDisabled, CTX_ERROR, CTX_MAPPING, CTX_WARNING +from inputremapper.injection.mapping_handlers.axis_transform import Transformation +from inputremapper.input_event import InputEvent +from inputremapper.logger import logger + +Capabilities = Dict[int, List] + +SET_KEY_FIRST = _("Record the input first") +EMPTY_MAPPING_NAME = _("Empty Mapping") + +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 DeviceSelection: + """the dropdown menu to select the active_group""" + + def __init__( + self, + message_broker: MessageBroker, + controller: Controller, + combobox: Gtk.ComboBox, + ): + self._message_broker = message_broker + self._controller = controller + self._device_store = Gtk.ListStore(str, str, str) + self._gui = combobox + + # https://python-gtk-3-tutorial.readthedocs.io/en/latest/treeview.html#the-view + combobox.set_model(self._device_store) + renderer_icon = Gtk.CellRendererPixbuf() + renderer_text = Gtk.CellRendererText() + renderer_text.set_padding(5, 0) + combobox.pack_start(renderer_icon, False) + combobox.pack_start(renderer_text, False) + combobox.add_attribute(renderer_icon, "icon-name", 1) + combobox.add_attribute(renderer_text, "text", 2) + combobox.set_id_column(0) + + self._message_broker.subscribe(MessageType.groups, self._on_groups_changed) + self._message_broker.subscribe(MessageType.group, self._on_group_changed) + combobox.connect("changed", self._on_gtk_select_device) + + def _on_groups_changed(self, data: GroupsData): + with HandlerDisabled(self._gui, self._on_gtk_select_device): + self._device_store.clear() + 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(f"adding {group_key} to device dropdown ") + self._device_store.append([group_key, icon_name, group_key]) + + def _on_group_changed(self, data: GroupData): + with HandlerDisabled(self._gui, self._on_gtk_select_device): + self._gui.set_active_id(data.group_key) + + def _on_gtk_select_device(self, *_, **__): + group_key = self._gui.get_active_id() + logger.debug('Selecting device "%s"', group_key) + self._controller.load_group(group_key) + + +class TargetSelection: + """the dropdown menu to select the targe_uinput of the active_mapping""" + + 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 _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) + + def _on_mapping_loaded(self, mapping: MappingData): + if not self._controller.is_empty_mapping(): + self._enable() + else: + self._disable() + + with HandlerDisabled(self._gui, self._on_gtk_target_selected): + self._gui.set_active_id(mapping.target_uinput) + + def _enable(self): + self._gui.set_sensitive(True) + self._gui.set_opacity(1) + + def _disable(self): + self._gui.set_sensitive(False) + self._gui.set_opacity(0.5) + + def _on_gtk_target_selected(self, *_): + target = self._gui.get_active_id() + self._controller.update_mapping(target_uinput=target) + + +class PresetSelection: + """the dropdown menu to select the active_preset""" + + def __init__( + self, + message_broker: MessageBroker, + controller: Controller, + combobox: Gtk.ComboBoxText, + ): + self._message_broker = message_broker + self._controller = controller + self._gui = combobox + + self._connect_message_listener() + combobox.connect("changed", self._on_gtk_select_preset) + + 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): + with HandlerDisabled(self._gui, self._on_gtk_select_preset): + self._gui.remove_all() + for preset in data.presets: + self._gui.append(preset, preset) + + def _on_preset_changed(self, data: PresetData): + with HandlerDisabled(self._gui, self._on_gtk_select_preset): + self._gui.set_active_id(data.name) + + def _on_gtk_select_preset(self, *_, **__): + name = self._gui.get_active_id() + logger.debug('Selecting preset "%s"', name) + self._controller.load_preset(name) + + +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: SelectionLabel, row2: SelectionLabel) -> int: + """sort alphanumerical by name""" + if row1.combination == EventCombination.empty_combination(): + return 1 + if row2.combination == EventCombination.empty_combination(): + return 0 + + return 0 if row1.name < row2.name else 1 + + def _on_preset_changed(self, data: PresetData): + self._gui.foreach(lambda label: (label.cleanup(), self._gui.remove(label))) + if not data.mappings: + return + + for name, combination in data.mappings: + selection_label = SelectionLabel( + self._message_broker, self._controller, name, 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.event_combination + + def set_active(row: SelectionLabel): + if row.combination == combination: + self._gui.select_row(row) + + self._gui.foreach(set_active) + + def _on_gtk_mapping_selected(self, _, row: Optional[SelectionLabel]): + if not row: + return + self._controller.load_mapping(row.combination) + + +class SelectionLabel(Gtk.ListBoxRow): + """the ListBoxRow representing a mapping inside the MappingListBox""" + + __gtype_name__ = "SelectionLabel" + + def __init__( + self, + message_broker: MessageBroker, + controller: Controller, + name: Optional[str], + combination: EventCombination, + ): + super().__init__() + self._message_broker = message_broker + self._controller = controller + self._name = name + self.combination = combination + + # Make the child label widget break lines, important for + # long combinations + self.label = Gtk.Label() + self.label.set_line_wrap(True) + self.label.set_line_wrap_mode(Gtk.WrapMode.WORD) + self.label.set_justify(Gtk.Justification.CENTER) + # set the name or combination.beautify as label + self.label.set_label(self.name) + + # 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_stock(Gtk.STOCK_EDIT, Gtk.IconSize.MENU) + ) + self.edit_btn.set_tooltip_text(_("Change Mapping Name")) + self.edit_btn.set_margin_top(4) + self.edit_btn.set_margin_bottom(4) + self.edit_btn.connect("clicked", self._set_edit_mode) + + self.name_input = Gtk.Entry() + self.name_input.set_text(self.name) + self.name_input.set_width_chars(12) + 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._box = Gtk.Box(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, False, True, 4, Gtk.PackType.START) + + self.add(self._box) + self._connect_message_listener() + self.show_all() + + self.edit_btn.hide() + self.name_input.hide() + + def __repr__(self): + return f"SelectionLabel for {self.combination} as {self.name}" + + @property + def name(self) -> str: + if ( + self.combination == EventCombination.empty_combination() + or self.combination is None + ): + return EMPTY_MAPPING_NAME + return self._name or self.combination.beautify() + + def _connect_message_listener(self): + self._message_broker.subscribe(MessageType.mapping, self._on_mapping_changed) + self._message_broker.subscribe( + MessageType.combination_update, self._on_combination_update + ) + + def _set_not_selected(self): + self.edit_btn.hide() + self.name_input.hide() + self.label.show() + + def _set_selected(self): + self.label.set_label(self.name) + self.edit_btn.show() + self.name_input.hide() + self.label.show() + + def _set_edit_mode(self, *_): + self.name_input.set_text(self.name) + self.label.hide() + self.name_input.show() + self._controller.set_focus(self.name_input) + + def _on_mapping_changed(self, mapping: MappingData): + if mapping.event_combination != self.combination: + self._set_not_selected() + return + self._name = mapping.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 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 CodeEditor: + """the editor used to edit the output_symbol of the active_mapping""" + + def __init__( + self, + message_broker: MessageBroker, + controller: Controller, + editor: GtkSource.View, + ): + self._message_broker = message_broker + self._controller = controller + self.gui = editor + + # without this the wrapping ScrolledWindow acts weird when new lines are added, + # not offering enough space to the text editor so the whole thing is suddenly + # scrollable by a few pixels. + # Found this after making blind guesses with settings in glade, and then + # actually looking at the snapshot preview! In glades editor this didn't have an + # effect. + self.gui.set_resize_mode(Gtk.ResizeMode.IMMEDIATE) + # Syntax Highlighting + # Thanks to https://github.com/wolfthefallen/py-GtkSourceCompletion-example + # language_manager = GtkSource.LanguageManager() + # fun fact: without saving LanguageManager into its own variable it doesn't work + # python = language_manager.get_language("python") + # source_view.get_buffer().set_language(python) + # TODO there are some similarities with python, but overall it's quite useless. + # commented out until there is proper highlighting for input-remappers syntax. + + # todo: setup autocompletion here + + self.gui.connect("focus-out-event", self._on_gtk_focus_out) + self.gui.get_buffer().connect("changed", self._on_gtk_changed) + self._connect_message_listener() + + @property + def code(self) -> str: + 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: + buffer = self.gui.get_buffer() + with HandlerDisabled(buffer, self._on_gtk_changed): + buffer.set_text(code) + 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) + self.gui.set_monospace(True) + self.gui.get_style_context().add_class("multiline") + else: + self.gui.set_show_line_numbers(False) + self.gui.set_monospace(False) + self.gui.get_style_context().remove_class("multiline") + + def _enable(self): + logger.debug("Enabling the code editor") + self.gui.set_sensitive(True) + self.gui.set_opacity(1) + + def _disable(self): + logger.debug("Disabling the code editor") + + # beware that this also appeared to disable event listeners like + # focus-out-event: + self.gui.set_sensitive(False) + self.gui.set_opacity(0.5) + + def _on_gtk_focus_out(self, *_): + self._controller.save() + + def _on_gtk_changed(self, *_): + 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 "" + self._enable() + else: + self._disable() + + 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 RecordingToggle: + """the toggle used to record the input form the active_group in order to update the + event_combination of 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 + ) + self._update_label(_("Record Input")) + + def _update_label(self, msg: str): + self._gui.set_label(msg) + + def _on_gtk_toggle(self, *__): + if self._gui.get_active(): + self._update_label(_("Recording ...")) + self._controller.start_key_recording() + else: + self._update_label(_("Record Input")) + self._controller.stop_key_recording() + + def _on_recording_finished(self, __): + logger.debug("finished recording") + with HandlerDisabled(self._gui, self._on_gtk_toggle): + self._gui.set_active(False) + self._update_label(_("Record Input")) + + +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 + + self._message_broker.subscribe(MessageType.status_msg, self._on_status_update) + self._message_broker.subscribe(MessageType.init, self._on_init) + + # 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_init(self, _): + self._error_icon.hide() + self._warning_icon.hide() + + 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("") + else: + 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 + + max_length = 45 + if len(message) > max_length: + message = message[: max_length - 3] + "..." + + status_bar.push(context_id, message) + status_bar.set_tooltip_text(tooltip) + + +class AutoloadSwitch: + """the switch used to toggle the autoload state of the active_preset for + the acive_group""" + + 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 EventEntry(Gtk.ListBoxRow): + """the ListBoxRow representing a single event inside the CombinationListBox""" + + __gtype_name__ = "EventEntry" + + def __init__(self, event: InputEvent, controller: Controller): + super().__init__() + + self.input_event = event + self._controller = controller + hbox = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=4) + + 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_event_in_combination( + self.input_event, "up" + ), + ) + down_btn.connect( + "clicked", + lambda *_: self._controller.move_event_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 + + def cleanup(self): + """remove any message listeners we are about to get destroyed""" + pass + + +class CombinationListbox: + """the ListBox with all the events inside active_mapping.event_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[EventCombination] = 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): + def select(r: EventEntry): + if r.input_event == event: + self._gui.select_row(r) + + self._gui.foreach(select) + + def _on_mapping_changed(self, mapping: MappingData): + if self._combination == mapping.event_combination: + return + + self._gui.foreach(lambda label: (label.cleanup(), self._gui.remove(label))) + if self._controller.is_empty_mapping(): + self._combination = None + else: + self._combination = mapping.event_combination + for event in self._combination: + self._gui.insert(EventEntry(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, *_): + row: Optional[EventEntry] = None + + def find_row(r: EventEntry): + nonlocal row + if r.is_selected(): + row = r + + self._gui.foreach(find_row) + if row: + self._controller.load_event(row.input_event) + + +class AnalogInputSwitch: + """the switch used to modify the active_event in order to be + marked as an 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._event: Optional[InputEvent] = None + + self._gui.connect("state-set", self._on_gtk_toggle) + self._message_broker.subscribe(MessageType.selected_event, self._on_event) + + def _on_event(self, event: InputEvent): + with HandlerDisabled(self._gui, self._on_gtk_toggle): + self._gui.set_active(event.value == 0) + self._event = event + + if event.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_event 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._event: Optional[InputEvent] = 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, event: InputEvent): + if event.type == EV_KEY: + self._gui.set_sensitive(False) + self._gui.set_opacity(0.5) + elif event.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(event.value) + self._event = event + + def _on_gtk_changed(self, *_): + self._controller.update_event( + self._event.modify(value=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.event_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 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: str): + if target == self._current_target: + return + + capabilities = self._uinputs.get(target) or defaultdict(list) + types_codes = [(EV_ABS, code) for code in capabilities.get(EV_ABS) or ()] + types_codes.extend((EV_REL, code) for code in capabilities.get(EV_REL) or ()) + self.model.clear() + self.model.append([f"None, None", _("No Axis")]) + for type_code in types_codes: + key_name = bytype[type_code[0]][type_code[1]] + if isinstance(key_name, list): + key_name = key_name[0] + self.model.append([f"{type_code[0]}, {type_code[1]}", 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() == f"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 ConfirmCancelDialog: + """the dialog shown to the user to query a confirm or cancel action form the user""" + + def __init__( + self, + message_broker: MessageBroker, + controller: Controller, + gui: Gtk.Dialog, + label: Gtk.Label, + ): + self._message_broker = message_broker + self._controller = controller + self._gui = gui + self._label = label + + self._message_broker.subscribe( + MessageType.user_confirm_request, self._on_user_confirm_request + ) + + def _on_user_confirm_request(self, msg: UserConfirmRequest): + self._label.set_label(msg.msg) + self._gui.show() + response = self._gui.run() + self._gui.hide() + msg.respond(response == Gtk.ResponseType.ACCEPT) + + +class KeyAxisStack: + """the stack used to show either the gui to modify a key-mapping or the gui to + modify a analog-axis mapping""" + + def __init__( + self, + message_broker: MessageBroker, + controller: Controller, + gui: Gtk.Stack, + ): + self._message_broker = message_broker + self._controller = controller + self._gui = gui + + self._message_broker.subscribe(MessageType.mapping, self._on_mapping_message) + + def _on_mapping_message(self, mapping: MappingData): + if ( + mapping.output_type + and mapping.output_code is not None + and not mapping.output_symbol + ): + self._gui.set_visible_child_name("Analog Axis") + elif mapping.output_symbol and not ( + mapping.output_code is not None or mapping.output_type + ): + self._gui.set_visible_child_name("Key or Macro") + + +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) + for x in range(-100, 100) + ] + w = self._gui.get_allocated_width() + h = self._gui.get_allocated_height() + b = min((w, h)) + scaled_points = [(x * b, y * b) for x, y in points] + + # box + context.move_to(0, 0) + context.line_to(0, b) + context.line_to(b, b) + context.line_to(b, 0) + context.line_to(0, 0) + context.set_line_width(1) + context.set_source_rgb(0.7, 0.7, 0.7) + context.stroke() + + # 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) + context.set_source_rgb(0.5, 0.5, 0.5) + context.stroke() + + # graph + context.move_to(*scaled_points[0]) + for p in scaled_points[1:]: + # Ploting point + context.line_to(*p) + + context.set_line_width(2) + context.set_source_rgb(0.2, 0.2, 1) + 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/controller.py b/inputremapper/gui/controller.py new file mode 100644 index 00000000..8cff0e83 --- /dev/null +++ b/inputremapper/gui/controller.py @@ -0,0 +1,557 @@ +#!/usr/bin/python3 +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2022 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . +from __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 + +from evdev.ecodes import EV_KEY, EV_REL, EV_ABS +from gi.repository import Gtk + +from inputremapper.configs.mapping import MappingData, UIMapping +from inputremapper.event_combination import EventCombination +from inputremapper.exceptions import DataManagementError +from inputremapper.gui.data_manager import DataManager, DEFAULT_PRESET_NAME +from inputremapper.gui.gettext import _ +from inputremapper.gui.helper import is_helper_running +from inputremapper.gui.utils import CTX_APPLY, CTX_ERROR, CTX_WARNING, CTX_MAPPING +from inputremapper.injection.injector import ( + RUNNING, + FAILED, + NO_GRAB, + UPGRADE_EVDEV, + STARTING, + STOPPED, + InjectorState, +) +from inputremapper.input_event import InputEvent +from inputremapper.logger import logger +from inputremapper.gui.message_broker import ( + MessageBroker, + MessageType, + PresetData, + StatusData, + CombinationRecorded, + UserConfirmRequest, +) + +if TYPE_CHECKING: + # avoids gtk import error in tests + from inputremapper.gui.user_interface import UserInterface + + +MAPPING_DEFAULTS = {"target_uinput": "keyboard"} + + +class Controller: + """implements the behaviour of the gui""" + + def __init__(self, message_broker: MessageBroker, data_manager: DataManager): + self.message_broker = message_broker + self.data_manager = data_manager + self.gui: Optional[UserInterface] = None + + self.button_left_warn = False + self._attach_to_events() + + def set_gui(self, gui: UserInterface): + 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._send_mapping_errors_as_status_msg + ) + self.message_broker.subscribe( + MessageType.mapping, self._send_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 helper takes longer to provide the + # initial groups + self.data_manager.send_groups() + self.data_manager.send_uinputs() + if not is_helper_running(): + self.show_status(CTX_ERROR, _("The helper did not start")) + + def _on_groups_changed(self, _): + """load the newest group as soon as everyone got notified + about the updated groups""" + group_key = self.get_a_group() + if group_key: + self.load_group(self.get_a_group()) + + 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 t: t[0] or t[1].beautify()) + combination = mappings[0][1] + self.load_mapping(combination) + self.load_event(combination[0]) + else: + # send an empty mapping to make sure the ui is reset to default values + self.message_broker.send(MappingData(**MAPPING_DEFAULTS)) + + def _on_combination_recorded(self, data: CombinationRecorded): + self.update_combination(data.combination) + + def _send_mapping_errors_as_status_msg(self, *__): + """send mapping ValidationErrors to the MessageBroker.""" + if not self.data_manager.active_preset: + return + if self.data_manager.active_preset.is_valid(): + self.message_broker.send(StatusData(CTX_MAPPING)) + return + + for mapping in self.data_manager.active_preset: + if not mapping.get_error(): + continue + + position = mapping.name or mapping.event_combination.beautify() + msg = _("Mapping error at %s, hover for info") % position + self.show_status(CTX_MAPPING, msg, self._get_ui_error_string(mapping)) + + @staticmethod + def _get_ui_error_string(mapping: UIMapping) -> str: + """get a human readable error message from a mapping error""" + error_string = str(mapping.get_error()) + + # check all the different error messages which are not useful for the user + if ( + "output_symbol is a macro:" in error_string + or "output_symbol and output_code mismatch:" in error_string + ) and mapping.event_combination.has_input_axis(): + return _( + "Remove the macro or key from the macro input field " + "when specifying an analog output" + ) + elif ( + "output_symbol is a macro:" in error_string + or "output_symbol and output_code mismatch:" in error_string + ) and not mapping.event_combination.has_input_axis(): + return _( + "Remove the Analog Output Axis " + "when specifying an macro or key output" + ) + if "missing output axis:" in error_string: + return _( + "The input specifies a analog axis, but no output axis is selected" + ) + + return error_string + + def get_a_preset(self) -> str: + """attempts to get the newest preset in the current group + creates a new preset if that fails""" + try: + return self.data_manager.get_newest_preset_name() + except FileNotFoundError: + pass + self.data_manager.create_preset(self.data_manager.get_available_preset_name()) + return self.data_manager.get_newest_preset_name() + + def get_a_group(self) -> Optional[str]: + """attempts to get the group with the newest preset + returns any if that fails""" + try: + return self.data_manager.get_newest_group_key() + except FileNotFoundError: + pass + + keys = self.data_manager.get_group_keys() + return keys[0] if keys else None + + def copy_preset(self): + """create a copy of the active preset and name it `preset_name copy`""" + name = self.data_manager.active_preset.name + match = re.search(" copy *\d*$", name) + if match: + name = name[: match.start()] + + self.data_manager.copy_preset( + self.data_manager.get_available_preset_name(f"{name} copy") + ) + + def update_combination(self, combination: EventCombination): + """update the event_combination of the active mapping""" + try: + self.data_manager.update_mapping(event_combination=combination) + self.save() + except KeyError: + # the combination was a duplicate + return + + if combination.is_problematic(): + self.show_status( + CTX_WARNING, + _("ctrl, alt and shift may not combine properly"), + _("Your system might reinterpret combinations ") + + _("with those after they are injected, and by doing so ") + + _("break them."), + ) + + def move_event_in_combination( + self, event: InputEvent, direction: Union[Literal["up"], Literal["down"]] + ): + """move the active_event up or down in the event_combination of the + active_mapping""" + if ( + not self.data_manager.active_mapping + or len(self.data_manager.active_mapping.event_combination) == 1 + ): + return + combination: Sequence[ + InputEvent + ] = self.data_manager.active_mapping.event_combination + + i = combination.index(event) + if ( + i + 1 == len(combination) + and direction == "down" + or i == 0 + and direction == "up" + ): + return + + if direction == "up": + combination = ( + list(combination[: i - 1]) + + [event] + + [combination[i - 1]] + + list(combination[i + 1 :]) + ) + elif direction == "down": + combination = ( + list(combination[:i]) + + [combination[i + 1]] + + [event] + + list(combination[i + 2 :]) + ) + else: + raise ValueError(f"unknown direction: {direction}") + self.update_combination(EventCombination(combination)) + self.load_event(event) + + def load_event(self, event: InputEvent): + """load an InputEvent form the active mapping event combination""" + self.data_manager.load_event(event) + + def update_event(self, new_event: InputEvent): + """modify the active event""" + try: + self.data_manager.update_event(new_event) + except KeyError: + # we need to synchronize the gui + self.data_manager.send_mapping() + self.data_manager.send_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_event: + return + + combination = list(self.data_manager.active_mapping.event_combination) + combination.remove(self.data_manager.active_event) + try: + self.data_manager.update_mapping( + event_combination=EventCombination(combination) + ) + self.load_event(combination[0]) + except (KeyError, ValueError): + # we need to synchronize the gui + self.data_manager.send_mapping() + self.data_manager.send_event() + + def set_event_as_analog(self, analog: bool): + """use the active event as an analog input""" + assert self.data_manager.active_event is not None + event = self.data_manager.active_event + if event.type == EV_KEY: + pass + + elif analog: + try: + self.data_manager.update_event(event.modify(value=0)) + 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_event(event.modify(value=value)) + return + except KeyError: + pass + + # didn't update successfully + # we need to synchronize the gui + self.data_manager.send_mapping() + self.data_manager.send_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 + name = self.data_manager.get_available_preset_name(new_name) + self.data_manager.rename_preset(name) + + def add_preset(self, name: str = DEFAULT_PRESET_NAME): + """create a new preset, add it to the active_group and name it `new preset n`""" + 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()) + + if not self.data_manager.active_preset: + return + msg = ( + _("Are you sure you want to delete the \npreset: '%s' ?") + % self.data_manager.active_preset.name + ) + self.message_broker.send(UserConfirmRequest(msg, f)) + + def load_mapping(self, event_combination: EventCombination): + """load the mapping with the given event_combination form the active_preset""" + self.data_manager.load_mapping(event_combination) + self.load_event(event_combination[0]) + + def update_mapping(self, **kwargs): + """update the active_mapping with the given keywords and values""" + self.data_manager.update_mapping(**kwargs) + 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=EventCombination.empty_combination()) + self.data_manager.update_mapping(**MAPPING_DEFAULTS) + + def delete_mapping(self): + """remove the active_mapping form the active_preset""" + + def f(answer: bool): + if answer: + self.data_manager.delete_mapping() + self.save() + + if not self.data_manager.active_mapping: + return + self.message_broker.send( + UserConfirmRequest(_("Are you sure you want to delete \nthis mapping?"), f) + ) + + 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): + """recorde the input of the active_group and update the + active_mapping.event_combination with the recorded events""" + state = self.data_manager.get_state() + if state == RUNNING or state == STARTING: + self.message_broker.signal(MessageType.recording_finished) + self.show_status( + CTX_ERROR, _('Use "Stop Injection" to stop before editing') + ) + return + + logger.debug("Recording Keys") + + def f(_): + self.message_broker.unsubscribe(f) + 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, f) + self.data_manager.start_combination_recording() + + def stop_key_recording(self): + """stop recording the input""" + logger.debug("Stopping Key recording") + 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 keys and save 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: InjectorState): + """Show if the injection was successfully started.""" + self.message_broker.unsubscribe(self.show_injector_result) + state = msg.state + + def running(): + msg = _("Applied preset %s") % self.data_manager.active_preset.name + if self.data_manager.active_preset.get_mapping( + EventCombination(InputEvent.btn_left()) + ): + msg += _(", CTRL + DEL to stop") + self.show_status(CTX_APPLY, msg) + logger.info( + 'Group "%s" is currently mapped', self.data_manager.active_group.key + ) + + assert self.data_manager.active_preset # make mypy happy + state_calls: Dict[int, Callable] = { + RUNNING: running, + FAILED: partial( + self.show_status, + CTX_ERROR, + _("Failed to apply preset %s") % self.data_manager.active_preset.name, + ), + NO_GRAB: partial( + self.show_status, + CTX_ERROR, + "The device was not grabbed", + "Either another application is already grabbing it or " + "your preset doesn't contain anything that is sent by the " + "device.", + ), + UPGRADE_EVDEV: partial( + self.show_status, + CTX_ERROR, + "Upgrade python-evdev", + "Your python-evdev version is too old.", + ), + } + state_calls[state]() + + def stop_injecting(self): + """stop injecting any preset for the active_group""" + + def show_result(msg: InjectorState): + self.message_broker.unsubscribe(show_result) + assert msg.state == STOPPED + self.show_status(CTX_APPLY, _("Applied the system default")) + + 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.send(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) diff --git a/inputremapper/gui/data_manager.py b/inputremapper/gui/data_manager.py new file mode 100644 index 00000000..cada6cc8 --- /dev/null +++ b/inputremapper/gui/data_manager.py @@ -0,0 +1,564 @@ +#!/usr/bin/python3 +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2022 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . +import glob +import os +import re +import time +from typing import Optional, List, Tuple, Set + +from gi.repository import GLib + +from inputremapper.configs.global_config import GlobalConfig +from inputremapper.configs.mapping import UIMapping +from inputremapper.configs.paths import get_preset_path, mkdir, split_all +from inputremapper.configs.preset import Preset +from inputremapper.configs.system_mapping import SystemMapping +from inputremapper.daemon import DaemonProxy +from inputremapper.event_combination import EventCombination +from inputremapper.exceptions import DataManagementError +from inputremapper.groups import _Group +from inputremapper.gui.message_broker import ( + MessageBroker, + GroupData, + PresetData, + CombinationUpdate, + UInputsData, +) +from inputremapper.gui.reader import Reader +from inputremapper.injection.global_uinputs import GlobalUInputs +from inputremapper.injection.injector import ( + STOPPED, + RUNNING, + FAILED, + UPGRADE_EVDEV, + NO_GRAB, + InjectorState, +) +from inputremapper.input_event import InputEvent +from inputremapper.logger import logger + +DEFAULT_PRESET_NAME = "new preset" + +# useful type aliases +Name = str +GroupKey = str + + +class DataManager: + """DataManager provides an interface to create and modify configurations as well + as modify the state of the Service. + + Any state changes will be announced via the MessageBroker. + """ + + def __init__( + self, + message_broker: MessageBroker, + config: GlobalConfig, + reader: Reader, + daemon: DaemonProxy, + uinputs: GlobalUInputs, + system_mapping: SystemMapping, + ): + self.message_broker = message_broker + self._reader = reader + self._daemon = daemon + self._uinputs = uinputs + self._system_mapping = system_mapping + uinputs.prepare_all() + + self._config = config + self._config.load_config() + + self._active_preset: Optional[Preset[UIMapping]] = None + self._active_mapping: Optional[UIMapping] = None + self._active_event: Optional[InputEvent] = None + + def send_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.send( + GroupData(self.active_group.key, self.get_preset_names()) + ) + + def send_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.send( + PresetData( + self.active_preset.name, self.get_mappings(), self.get_autoload() + ) + ) + + def send_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.send(self.active_mapping.get_bus_message()) + + def send_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_event: + assert self.active_event in self.active_mapping.event_combination + self.message_broker.send(self.active_event) + + def send_uinputs(self): + """send the "uinputs" message on the MessageBroker""" + self.message_broker.send( + UInputsData( + { + name: uinput.capabilities() + for name, uinput in self._uinputs.devices.items() + } + ) + ) + + def send_groups(self): + """send the "groups" message on the MessageBroker""" + self._reader.send_groups() + + def send_injector_state(self): + """send the "injector_state" message with the state of the injector + for the active_group""" + if not self.active_group: + return + self.message_broker.send(InjectorState(self.get_state())) + + @property + def active_group(self) -> Optional[_Group]: + """the currently loaded group""" + return self._reader.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_event(self) -> Optional[InputEvent]: + """the currently loaded event""" + return self._active_event + + def get_group_keys(self) -> Tuple[GroupKey, ...]: + """Get all group keys (plugged devices)""" + return tuple(group.key for group in self._reader.groups.filter()) + + def get_preset_names(self) -> Tuple[Name, ...]: + """Get all preset names for active_group and current user, + starting with the newest.""" + if not self.active_group: + raise DataManagementError("cannot find presets: Group is not set") + device_folder = get_preset_path(self.active_group.name) + mkdir(device_folder) + + paths = glob.glob(os.path.join(device_folder, "*.json")) + presets = [ + os.path.splitext(os.path.basename(path))[0] + for path in sorted(paths, key=os.path.getmtime) + ] + # the highest timestamp to the front + presets.reverse() + return tuple(presets) + + def get_mappings(self) -> Optional[List[Tuple[Optional[Name], EventCombination]]]: + """all mapping names and their combination from the active_preset""" + if not self._active_preset: + return None + return [ + (mapping.name, mapping.event_combination) 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.send_preset() + + def get_newest_group_key(self) -> GroupKey: + """group_key of the group with the most recently modified preset""" + paths = [] + for path in glob.glob(os.path.join(get_preset_path(), "*/*.json")): + if self._reader.groups.find(key=split_all(path)[-2]): + paths.append((path, os.path.getmtime(path))) + + if not paths: + raise FileNotFoundError() + + path, _ = max(paths, key=lambda x: x[1]) + return split_all(path)[-2] + + def get_newest_preset_name(self) -> Name: + """preset name of the most recently modified preset in the active group""" + if not self.active_group: + raise DataManagementError("cannot find newest preset: Group is not set") + + paths = [ + (path, os.path.getmtime(path)) + for path in glob.glob( + os.path.join(get_preset_path(self.active_group.name), "*.json") + ) + ] + if not paths: + raise FileNotFoundError() + + path, _ = max(paths, key=lambda x: x[1]) + return os.path.split(path)[-1].split(".")[0] + + def get_available_preset_name(self, name=DEFAULT_PRESET_NAME) -> Name: + """the first available preset in the active group""" + if not self.active_group: + raise DataManagementError("unable find preset name. Group is not set") + + name = name.strip() + + # find a name that is not already taken + if os.path.exists(get_preset_path(self.active_group.name, name)): + # if there already is a trailing number, increment it instead of + # adding another one + match = re.match(r"^(.+) (\d+)$", name) + if match: + name = match[1] + i = int(match[2]) + 1 + else: + i = 2 + + while os.path.exists( + get_preset_path(self.active_group.name, f"{name} {i}") + ): + i += 1 + + return f"{name} {i}" + + return name + + def load_group(self, group_key: str): + """Load a group. will send "groups" and "injector_state" + messages on the MessageBroker. + + 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") + + self._active_event = None + self._active_mapping = None + self._active_preset = None + group = self._reader.groups.find(key=group_key) + self._reader.set_group(group) + self.send_group() + self.send_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") + + preset_path = get_preset_path(self.active_group.name, name) + preset = Preset(preset_path, mapping_factory=UIMapping) + preset.load() + self._active_event = None + self._active_mapping = None + self._active_preset = preset + self.send_preset() + + def load_mapping(self, combination: EventCombination): + """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: + raise KeyError( + f"the mapping with {combination = } does not " + f"exist in the {self._active_preset.path}" + ) + self._active_event = None + self._active_mapping = mapping + self.send_mapping() + + def load_event(self, event: InputEvent): + """Load a InputEvent 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 event not in self.active_mapping.event_combination: + raise ValueError( + f"{event} is not member of active_mapping.event_combination: " + f"{self.active_mapping.event_combination}" + ) + self._active_event = event + self.send_event() + + def rename_preset(self, new_name: str): + """rename the current preset and move the correct file + + Will send "group" and then "preset" message on the MessageBroker + """ + if not self.active_preset or not self.active_group: + raise DataManagementError("Unable rename preset: Preset is not set") + + if self.active_preset.path == get_preset_path(self.active_group.name, new_name): + return + + old_path = self.active_preset.path + assert old_path is not None + old_name = os.path.basename(old_path).split(".")[0] + new_path = get_preset_path(self.active_group.name, new_name) + if os.path.exists(new_path): + raise ValueError( + f"cannot rename {old_name} to " f"{new_name}, preset already exists" + ) + + logger.info('Moving "%s" to "%s"', old_path, new_path) + os.rename(old_path, new_path) + now = time.time() + os.utime(new_path, (now, now)) + + if self._config.is_autoloaded(self.active_group.key, old_name): + self._config.set_autoload_preset(self.active_group.key, new_name) + + self.active_preset.path = get_preset_path(self.active_group.name, new_name) + self.send_group() + self.send_preset() + + def copy_preset(self, name: str): + """copy the current preset to the given name. + Will send "group" and "preset" message to the MessageBroker and load the copy + """ + # todo: Do we want to load the copy here? or is this up to the controller? + if not self.active_preset or not self.active_group: + raise DataManagementError("Unable to copy preset: Preset is not set") + + if self.active_preset.path == get_preset_path(self.active_group.name, name): + return + + if name in self.get_preset_names(): + raise ValueError(f"a preset with the name {name} already exits") + + new_path = get_preset_path(self.active_group.name, name) + logger.info('Copy "%s" to "%s"', self.active_preset.path, new_path) + self.active_preset.path = new_path + self.save() + self.send_group() + self.send_preset() + + def create_preset(self, name: str): + """create empty preset in the active_group. + Will send "group" message to the MessageBroker + """ + if not self.active_group: + raise DataManagementError("Unable to add preset. Group is not set") + + path = get_preset_path(self.active_group.name, name) + if os.path.exists(path): + raise DataManagementError("Unable to add preset. Preset exists") + + Preset(path).save() + self.send_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.send_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 event_combination + this will first send a "combination_update" message + """ + if not self._active_mapping: + raise DataManagementError("Cannot modify Mapping: mapping is not set") + + if symbol := kwargs.get("output_symbol"): + kwargs["output_symbol"] = self._system_mapping.correct_case(symbol) + + combination = self.active_mapping.event_combination + for key, value in kwargs.items(): + setattr(self._active_mapping, key, value) + + if ( + "event_combination" in kwargs + and combination != self.active_mapping.event_combination + ): + self._active_event = None + self.message_broker.send( + CombinationUpdate(combination, self._active_mapping.event_combination) + ) + self.send_mapping() + + def update_event(self, new_event: InputEvent): + """update the active event. + + Will send "combination_update", "mapping" and "event" messages to the + MessageBroker (in that order) + """ + if not self.active_mapping or not self.active_event: + raise DataManagementError("Cannot modify event: event is not set") + + combination = list(self.active_mapping.event_combination) + combination[combination.index(self.active_event)] = new_event + self.update_mapping(event_combination=EventCombination(combination)) + self._active_event = new_event + self.send_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.send_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.event_combination) + self._active_mapping = None + self.send_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.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.start_recorder() + + def stop_combination_recording(self): + """Stop recording user input. + + Will send RecordingFinished message if a recording is running. + """ + self._reader.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({STOPPED}, self.send_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( + {RUNNING, FAILED, NO_GRAB, UPGRADE_EVDEV}, self.send_injector_state + ) + return True + return False + + def get_state(self) -> int: + """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[int], callback): + """run callback once the injector state is one of states""" + + def do(): + if self.get_state() in states: + callback() + return False + return True + + GLib.timeout_add(100, do) diff --git a/inputremapper/gui/editor/editor.py b/inputremapper/gui/editor/editor.py deleted file mode 100644 index 4ab55f9b..00000000 --- a/inputremapper/gui/editor/editor.py +++ /dev/null @@ -1,748 +0,0 @@ -#!/usr/bin/python3 -# -*- coding: utf-8 -*- -# input-remapper - GUI for device specific keyboard mappings -# Copyright (C) 2022 sezanzeb -# -# This file is part of input-remapper. -# -# input-remapper is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# input-remapper is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with input-remapper. If not, see . - - -"""The editor with multiline code input, recording toggle and autocompletion.""" - - -import re -import locale -import gettext -import os -import time -from typing import Optional - -from inputremapper.configs.data import get_data_path -from inputremapper.configs.mapping import UIMapping -from inputremapper.gui.gettext import _ - -from gi.repository import Gtk, GLib, Gdk, GtkSource -from inputremapper.gui.gettext import _ -from inputremapper.gui.editor.autocompletion import Autocompletion -from inputremapper.configs.system_mapping import system_mapping -from inputremapper.gui.active_preset import active_preset -from inputremapper.event_combination import EventCombination -from inputremapper.input_event import InputEvent -from inputremapper.logger import logger -from inputremapper.gui.reader import reader -from inputremapper.gui.utils import CTX_KEYCODE, CTX_WARNING, CTX_ERROR -from inputremapper.injection.global_uinputs import global_uinputs - - -class SelectionLabel(Gtk.ListBoxRow): - """One label per mapping in the preset. - - This wrapper serves as a storage for the information the inherited label represents. - """ - - __gtype_name__ = "SelectionLabel" - - def __init__(self): - super().__init__() - self.combination = None - self.symbol = "" - - label = Gtk.Label() - - # Make the child label widget break lines, important for - # long combinations - label.set_line_wrap(True) - label.set_line_wrap_mode(Gtk.WrapMode.WORD) - label.set_justify(Gtk.Justification.CENTER) - - self.label = label - self.add(label) - - self.show_all() - - def set_combination(self, combination: EventCombination): - """Set the combination this button represents - - Parameters - ---------- - combination : EventCombination - """ - self.combination = combination - if combination: - self.label.set_label(combination.beautify()) - else: - self.label.set_label(_("new entry")) - - def get_combination(self) -> EventCombination: - return self.combination - - def set_label(self, label): - return self.label.set_label(label) - - def get_label(self): - return self.label.get_label() - - def __str__(self): - return f"SelectionLabel({str(self.combination)})" - - def __repr__(self): - return self.__str__() - - -class CombinationEntry(Gtk.ListBoxRow): - """One row per InputEvent in the EventCombination.""" - - __gtype_name__ = "CombinationEntry" - - def __init__(self, event: InputEvent): - super().__init__() - - self.event = event - hbox = Gtk.Box(Gtk.Orientation.HORIZONTAL, spacing=4) - - label = Gtk.Label() - label.set_label(event.json_str()) - 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) - - self.add(hbox) - self.show_all() - - -def ensure_everything_saved(func): - """Make sure the editor has written its changes to active_preset and save.""" - - def wrapped(self, *args, **kwargs): - if self.user_interface.preset_name: - self.gather_changes_and_save() - - return func(self, *args, **kwargs) - - return wrapped - - -SET_KEY_FIRST = _("Set the key first") - -RECORD_ALL = float("inf") -RECORD_NONE = 0 - - -class Editor: - """Maintains the widgets of the editor.""" - - def __init__(self, user_interface): - self.user_interface = user_interface - - self.autocompletion = None - - self.active_mapping: Optional[UIMapping] = None - - self._setup_target_selector() - self._setup_source_view() - self._setup_recording_toggle() - - self.window = self.get("window") - self.timeouts = [ - GLib.timeout_add(100, self.check_add_new_key), - GLib.timeout_add(1000, self.update_toggle_opacity), - ] - self.active_selection_label: Optional[SelectionLabel] = None - - selection_label_listbox = self.get("selection_label_listbox") - selection_label_listbox.connect("row-selected", self.on_mapping_selected) - - self.device = user_interface.group - - # keys were not pressed yet - self._input_has_arrived = False - - self.record_events_until = RECORD_NONE - - code_editor = self.get_code_editor() - code_editor.connect("focus-out-event", self.on_text_input_unfocus) - code_editor.get_buffer().connect("changed", self.on_text_input_changed) - - delete_button = self.get_delete_button() - delete_button.connect("clicked", self._on_delete_button_clicked) - - target_selector = self.get_target_selector() - target_selector.connect("changed", self._on_target_input_changed) - - def __del__(self): - for timeout in self.timeouts: - GLib.source_remove(timeout) - self.timeouts = [] - - def _on_toggle_clicked(self, toggle, event=None): - if toggle.get_active(): - self._show_press_key() - else: - self._show_change_key() - - @ensure_everything_saved - def _on_toggle_unfocus(self, toggle, event=None): - toggle.set_active(False) - - @ensure_everything_saved - def on_text_input_unfocus(self, *_): - """When unfocusing the text it saves. - - Input Remapper doesn't save the editor on change, because that would cause - an incredible amount of logs for every single input. The active_preset would - need to be changed, which causes two logs, then it has to be saved - to disk which is another two log messages. So every time a single character - is typed it writes 4 lines. - - Instead, it will save the preset when it is really needed, i.e. when a button - that requires a saved preset is pressed. For this there exists the - @ensure_everything_saved decorator. - - To avoid maybe forgetting to add this decorator somewhere, it will also save - when unfocusing the text input. - - If the scroll wheel is used to interact with gtk widgets it won't unfocus, - so this focus-out handler is not the solution to everything as well. - - One could debounce saving on text-change to avoid those logs, but that just - sounds like a huge source of race conditions and is also hard to test. - """ - pass # the decorator will be triggered - - def on_text_input_changed(self, *_): - # correct case - symbol = self.get_symbol_input_text() - correct_case = system_mapping.correct_case(symbol) - if symbol != correct_case: - self.get_code_editor().get_buffer().set_text(correct_case) - - if self.active_mapping: - # might be None if the empty mapping was selected, and the text input cleared - self.active_mapping.output_symbol = correct_case - - def _on_target_input_changed(self, *_): - """Save when target changed.""" - self.active_mapping.target_uinput = self.get_target_selection() - self.gather_changes_and_save() - - def clear(self): - """Clear all inputs, labels, etc. Reset the state. - - This is really important to do before loading a different preset. - Otherwise the inputs will be read and then saved into the next preset. - """ - if self.active_selection_label: - self.set_combination(None) - - self.disable_symbol_input(clear=True) - self.set_target_selection("keyboard") # sane default - self.disable_target_selector() - self._reset_keycode_consumption() - - self.clear_mapping_list() - - def clear_mapping_list(self): - """Clear the labels from the mapping selection and add an empty one.""" - selection_label_listbox = self.get("selection_label_listbox") - selection_label_listbox.forall(selection_label_listbox.remove) - self.add_empty() - selection_label_listbox.select_row(selection_label_listbox.get_children()[0]) - - def _setup_target_selector(self): - """Prepare the target selector combobox.""" - target_store = Gtk.ListStore(str) - for uinput in global_uinputs.devices: - target_store.append([uinput]) - - target_input = self.get_target_selector() - target_input.set_model(target_store) - renderer_text = Gtk.CellRendererText() - target_input.pack_start(renderer_text, False) - target_input.add_attribute(renderer_text, "text", 0) - target_input.set_id_column(0) - - def _setup_recording_toggle(self): - """Prepare the toggle button for recording key inputs.""" - toggle = self.get_recording_toggle() - toggle.connect("focus-out-event", self._show_change_key) - toggle.connect("focus-in-event", self._show_press_key) - toggle.connect("clicked", self._on_toggle_clicked) - toggle.connect("focus-out-event", self._reset_keycode_consumption) - toggle.connect("focus-out-event", self._on_toggle_unfocus) - toggle.connect("toggled", self._on_recording_toggle_toggle) - # Don't leave the input when using arrow keys or tab. wait for the - # window to consume the keycode from the reader. I.e. a tab input should - # be recorded, instead of causing the recording to stop. - toggle.connect("key-press-event", lambda *args: Gdk.EVENT_STOP) - - def _show_press_key(self, *args): - """Show user friendly instructions.""" - self.get_recording_toggle().set_label(_("Press Key")) - - def _show_change_key(self, *args): - """Show user friendly instructions.""" - self.get_recording_toggle().set_label(_("Change Key")) - - def _setup_source_view(self): - """Prepare the code editor.""" - source_view = self.get_code_editor() - - # without this the wrapping ScrolledWindow acts weird when new lines are added, - # not offering enough space to the text editor so the whole thing is suddenly - # scrollable by a few pixels. - # Found this after making blind guesses with settings in glade, and then - # actually looking at the snaphot preview! In glades editor this didn have an - # effect. - source_view.set_resize_mode(Gtk.ResizeMode.IMMEDIATE) - - source_view.get_buffer().connect("changed", self.show_line_numbers_if_multiline) - - # Syntax Highlighting - # Thanks to https://github.com/wolfthefallen/py-GtkSourceCompletion-example - # language_manager = GtkSource.LanguageManager() - # fun fact: without saving LanguageManager into its own variable it doesn't work - # python = language_manager.get_language("python") - # source_view.get_buffer().set_language(python) - # TODO there are some similarities with python, but overall it's quite useless. - # commented out until there is proper highlighting for input-remappers syntax. - - autocompletion = Autocompletion(source_view, self.get_target_selector()) - autocompletion.set_relative_to(self.get("code_editor_container")) - autocompletion.connect("suggestion-inserted", self.gather_changes_and_save) - self.autocompletion = autocompletion - - def show_line_numbers_if_multiline(self, *_): - """Show line numbers if a macro is being edited.""" - code_editor = self.get_code_editor() - symbol = self.get_symbol_input_text() or "" - - if "\n" in symbol: - code_editor.set_show_line_numbers(True) - code_editor.set_monospace(True) - code_editor.get_style_context().add_class("multiline") - else: - code_editor.set_show_line_numbers(False) - code_editor.set_monospace(False) - code_editor.get_style_context().remove_class("multiline") - - def check_add_new_key(self): - """If needed, add a new empty mapping to the list for the user to configure.""" - selection_label_listbox = self.get("selection_label_listbox") - - selection_label_listbox = selection_label_listbox.get_children() - - for selection_label in selection_label_listbox: - combination = selection_label.get_combination() - if ( - combination is None - or active_preset.get_mapping(combination) is None - or not active_preset.get_mapping(combination).is_valid() - ): - # unfinished row found - break - else: - self.add_empty() - - return True - - def disable_symbol_input(self, clear=False): - """Display help information and dont allow entering a symbol. - - Without this, maybe a user enters a symbol or writes a macro, switches - presets accidentally before configuring the key and then it's gone. It can - only be saved to the preset if a key is configured. This avoids that pitfall. - """ - logger.debug("Disabling the code editor") - text_input = self.get_code_editor() - - # beware that this also appeared to disable event listeners like - # focus-out-event: - text_input.set_sensitive(False) - text_input.set_opacity(0.5) - - if clear or self.get_symbol_input_text() == "": - # don't overwrite user input - self.set_symbol_input_text(SET_KEY_FIRST) - - def enable_symbol_input(self): - """Don't display help information anymore and allow changing the symbol.""" - logger.debug("Enabling the code editor") - text_input = self.get_code_editor() - text_input.set_sensitive(True) - text_input.set_opacity(1) - - buffer = text_input.get_buffer() - symbol = buffer.get_text(buffer.get_start_iter(), buffer.get_end_iter(), True) - if symbol == SET_KEY_FIRST: - # don't overwrite user input - self.set_symbol_input_text("") - - def disable_target_selector(self): - """Don't allow any selection.""" - selector = self.get_target_selector() - selector.set_sensitive(False) - selector.set_opacity(0.5) - - def enable_target_selector(self): - selector = self.get_target_selector() - selector.set_sensitive(True) - selector.set_opacity(1) - - @ensure_everything_saved - def on_mapping_selected(self, _=None, selection_label=None): - """One of the buttons in the left "combination" column was clicked. - - Load the information from that mapping entry into the editor. - """ - self.active_selection_label = selection_label - - if selection_label is None: - return - - combination = selection_label.combination - self.set_combination(combination) - - if combination is None: - # the empty mapping was selected - self.active_mapping = UIMapping() - # active_preset.add(self.active_mapping) - self.disable_symbol_input(clear=True) - # default target should fit in most cases - self.set_target_selection("keyboard") - self.active_mapping.target_uinput = "keyboard" - # target input disabled until a combination is configured - self.disable_target_selector() - # symbol input disabled until a combination is configured - else: - mapping = active_preset.get_mapping(combination) - if mapping is not None: - self.active_mapping = mapping - self.set_symbol_input_text(mapping.output_symbol) - self.set_target_selection(mapping.target_uinput) - self.enable_symbol_input() - self.enable_target_selector() - - self.get("window").set_focus(self.get_code_editor()) - - def add_empty(self): - """Add one empty row for a single mapped key.""" - selection_label_listbox = self.get("selection_label_listbox") - mapping_selection = SelectionLabel() - mapping_selection.set_label(_("new entry")) - mapping_selection.show_all() - selection_label_listbox.insert(mapping_selection, -1) - - @ensure_everything_saved - def load_custom_mapping(self): - """Display the entries in active_preset.""" - selection_label_listbox = self.get("selection_label_listbox") - - selection_label_listbox.forall(selection_label_listbox.remove) - - for mapping in active_preset: - selection_label = SelectionLabel() - selection_label.set_combination(mapping.event_combination) - selection_label_listbox.insert(selection_label, -1) - - self.check_add_new_key() - - # select the first entry - selection_labels = selection_label_listbox.get_children() - - if len(selection_labels) == 0: - self.add_empty() - selection_labels = selection_label_listbox.get_children() - - selection_label_listbox.select_row(selection_labels[0]) - - def get_recording_toggle(self) -> Gtk.ToggleButton: - return self.get("key_recording_toggle") - - def get_code_editor(self) -> GtkSource.View: - return self.get("code_editor") - - def get_target_selector(self) -> Gtk.ComboBox: - return self.get("target-selector") - - def get_combination_listbox(self) -> Gtk.ListBox: - return self.get("combination-listbox") - - def get_add_axis_btn(self) -> Gtk.Button: - return self.get("add-axis-as-btn") - - def get_delete_button(self) -> Gtk.Button: - return self.get("delete-mapping") - - def set_combination(self, combination): - """Show what the user is currently pressing in the user interface.""" - self.active_selection_label.set_combination(combination) - listbox = self.get_combination_listbox() - listbox.forall(listbox.remove) - - if combination: - for event in combination: - listbox.insert(CombinationEntry(event), -1) - - if combination and len(combination) > 0: - self.enable_symbol_input() - else: - self.disable_symbol_input() - - def get_combination(self): - """Get the EventCombination object from the left column. - - Or None if no code is mapped on this row. - """ - if self.active_selection_label is None: - return None - - return self.active_selection_label.combination - - def set_symbol_input_text(self, symbol): - code_editor = self.get_code_editor() - code_editor.get_buffer().set_text(symbol or "") - # move cursor location to the beginning, like any code editor does - Gtk.TextView.do_move_cursor( - code_editor, - Gtk.MovementStep.BUFFER_ENDS, - -1, - False, - ) - - def get_symbol_input_text(self): - """Get the assigned symbol from the text input. - - This might not be stored in active_preset yet, and might therefore also not - be part of the preset json file yet. - - If there is no symbol, this returns None. This is important for some other - logic down the road in active_preset or something. - """ - buffer = self.get_code_editor().get_buffer() - symbol = buffer.get_text(buffer.get_start_iter(), buffer.get_end_iter(), True) - - if symbol == SET_KEY_FIRST: - # not configured yet - return "" - - return symbol - - def set_target_selection(self, target): - selector = self.get_target_selector() - selector.set_active_id(target) - - def get_target_selection(self): - return self.get_target_selector().get_active_id() - - def get(self, name): - """Get a widget from the window.""" - return self.user_interface.builder.get_object(name) - - def update_toggle_opacity(self): - """If the key can't be mapped, grey it out. - - During injection, when the device is grabbed and weird things are being - done, it is not possible. - """ - toggle = self.get_recording_toggle() - if not self.user_interface.can_modify_preset(): - toggle.set_opacity(0.4) - else: - toggle.set_opacity(1) - - return True - - def _on_recording_toggle_toggle(self, toggle): - """Refresh useful usage information.""" - if not toggle.get_active(): - # if more events arrive from the time when the toggle was still on, - # use them. - self.record_events_until = time.time() - return - - self.record_events_until = RECORD_ALL - - self._reset_keycode_consumption() - reader.clear() - if not self.user_interface.can_modify_preset(): - # because the device is in grab mode by the daemon and - # therefore the original keycode inaccessible - logger.info("Cannot change keycodes while injecting") - self.user_interface.show_status( - CTX_ERROR, _('Use "Stop Injection" to stop before editing') - ) - toggle.set_active(False) - - def _on_delete_button_clicked(self, *_): - """Destroy the row and remove it from the config.""" - accept = Gtk.ResponseType.ACCEPT - if ( - len(self.get_symbol_input_text()) > 0 - and self._show_confirm_delete() != accept - ): - return - - combination = self.get_combination() - if combination is not None: - active_preset.remove(combination) - - # make sure there is no outdated information lying around in memory - self.set_combination(None) - - self.load_custom_mapping() - - def _show_confirm_delete(self): - """Blocks until the user decided about an action.""" - confirm_delete = self.get("confirm-delete") - - text = _("Are you sure to delete this mapping?") - self.get("confirm-delete-label").set_text(text) - - confirm_delete.show() - response = confirm_delete.run() - confirm_delete.hide() - return response - - def gather_changes_and_save(self, *_): - """Look into the ui if new changes should be written, and save the preset.""" - # correct case - symbol = self.get_symbol_input_text() - target = self.get_target_selection() - - if not symbol or not target: - return - - # save to disk if required - if active_preset.is_valid(): - self.user_interface.save_preset() - - def is_waiting_for_input(self): - """Check if the user is trying to record buttons.""" - return self.get_recording_toggle().get_active() - - def should_record_combination(self, combination): - """Check if the combination was written when the toggle was active.""" - # At this point the toggle might already be off, because some keys that are - # used while the toggle was still on might cause the focus of the toggle to - # be lost, like multimedia keys. This causes the toggle to be disabled. - # Yet, this event should be mapped. - timestamp = max([event.timestamp() for event in combination]) - return timestamp < self.record_events_until - - def consume_newest_keycode(self, combination: EventCombination): - """To capture events from keyboards, mice and gamepads.""" - self._switch_focus_if_complete() - - if combination is None: - return - - if not self.should_record_combination(combination): - # the event arrived after the toggle has been deactivated - logger.debug("Recording toggle is not on") - return - - if not isinstance(combination, EventCombination): - raise TypeError("Expected new_key to be a EventCombination object") - - # keycode is already set by some other row - existing = active_preset.get_mapping(combination) - if existing is not None: - msg = _('"%s" already mapped to "%s"') % ( - combination.beautify(), - existing.event_combination.beautify(), - ) - logger.info("%s %s", combination, msg) - self.user_interface.show_status(CTX_KEYCODE, msg) - return - - if combination.is_problematic(): - self.user_interface.show_status( - CTX_WARNING, - _("ctrl, alt and shift may not combine properly"), - _("Your system might reinterpret combinations ") - + _("with those after they are injected, and by doing so ") - + _("break them."), - ) - - # the newest_keycode is populated since the ui regularly polls it - # in order to display it in the status bar. - previous_key = self.get_combination() - - # it might end up being a key combination, wait for more - self._input_has_arrived = True - - # keycode didn't change, do nothing - if combination == previous_key: - logger.debug("%s didn't change", previous_key) - return - - self.set_combination(combination) - self.active_mapping.event_combination = combination - if previous_key is None and combination is not None: - logger.debug(f"adding new mapping to preset\n{self.active_mapping}") - active_preset.add(self.active_mapping) - - def _switch_focus_if_complete(self): - """If keys are released, it will switch to the text_input. - - States: - 1. not doing anything, waiting for the user to start using it - 2. user focuses it, no keys pressed - 3. user presses keys - 4. user releases keys. no keys are pressed, just like in step 2, but this time - the focus needs to switch. - """ - if not self.is_waiting_for_input(): - self._reset_keycode_consumption() - return - - all_keys_released = reader.get_unreleased_keys() is None - if all_keys_released and self._input_has_arrived and self.get_combination(): - logger.debug("Recording complete") - # A key was pressed and then released. - # Switch to the symbol. idle_add this so that the - # keycode event won't write into the symbol input as well. - window = self.user_interface.window - self.enable_symbol_input() - self.enable_target_selector() - GLib.idle_add(lambda: window.set_focus(self.get_code_editor())) - - if not all_keys_released: - # currently the user is using the widget, and certain keys have already - # reached it. - self._input_has_arrived = True - return - - self._reset_keycode_consumption() - - def _reset_keycode_consumption(self, *_): - self._input_has_arrived = False diff --git a/inputremapper/gui/gettext.py b/inputremapper/gui/gettext.py index 922f2bc9..7f359136 100644 --- a/inputremapper/gui/gettext.py +++ b/inputremapper/gui/gettext.py @@ -18,11 +18,11 @@ # You should have received a copy of the GNU General Public License # along with input-remapper. If not, see . -import os.path import gettext import locale +import os.path + from inputremapper.configs.data import get_data_path -from argparse import ArgumentParser APP_NAME = "input-remapper" LOCALE_DIR = os.path.join(get_data_path(), "lang") diff --git a/inputremapper/gui/helper.py b/inputremapper/gui/helper.py index 71eea443..360a0543 100644 --- a/inputremapper/gui/helper.py +++ b/inputremapper/gui/helper.py @@ -30,25 +30,34 @@ The service shouldn't do that even though it has root rights, because that would provide a key-logger that can be accessed by any user at all times, whereas for the helper to start a password is needed and it stops when the ui closes. -""" +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 sys -import select +import asyncio import multiprocessing import subprocess -import time +import sys +from collections import defaultdict +from typing import Set, List import evdev -from evdev.ecodes import EV_KEY, EV_ABS - +from evdev.ecodes import EV_KEY, EV_ABS, EV_REL, REL_HWHEEL, REL_WHEEL + +from inputremapper.configs.mapping import UIMapping +from inputremapper.event_combination import EventCombination +from inputremapper.groups import _Groups, _Group +from inputremapper.injection.event_reader import EventReader +from inputremapper.injection.mapping_handlers.abs_to_btn_handler import AbsToBtnHandler +from inputremapper.injection.mapping_handlers.mapping_handler import MappingHandler +from inputremapper.injection.mapping_handlers.rel_to_btn_handler import RelToBtnHandler +from inputremapper.input_event import InputEvent, EventActions from inputremapper.ipc.pipe import Pipe from inputremapper.logger import logger -from inputremapper.groups import groups -from inputremapper import utils from inputremapper.user import USER - # received by the helper CMD_TERMINATE = "terminate" CMD_REFRESH_GROUPS = "refresh_groups" @@ -76,160 +85,226 @@ class RootHelper: or strings to start listening on a specific device. """ - def __init__(self): + # 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_speed = defaultdict(lambda: 3) + # wheel events usually don't produce values higher than 1 + rel_speed[REL_WHEEL] = 1 + rel_speed[REL_HWHEEL] = 1 + + def __init__(self, groups: _Groups): """Construct the helper and initialize its sockets.""" + self.groups = groups self._results = Pipe(f"/tmp/input-remapper-{USER}/results") self._commands = Pipe(f"/tmp/input-remapper-{USER}/commands") - - self._send_groups() - - self.group = None self._pipe = multiprocessing.Pipe() + self._tasks: Set[asyncio.Task] = set() + self._stop_event = asyncio.Event() + def run(self): """Start doing stuff. Blocks.""" - logger.debug("Waiting for the first command") # the reader will check for new commands later, once it is running # it keeps running for one device or another. - select.select([self._commands], [], []) - - # possibly an alternative to select: - """while True: - if self._commands.poll(): - break - - time.sleep(0.1)""" - - logger.debug("Starting mainloop") - while True: - self._read_commands() - self._start_reading() + loop = asyncio.get_event_loop() + logger.debug("Discovering initial groups") + self.groups.refresh() + self._send_groups() + logger.debug("Waiting commands") + loop.run_until_complete(self._read_commands()) + logger.debug("Helper terminates") + sys.exit(0) def _send_groups(self): """Send the groups to the gui.""" logger.debug("Sending groups") - self._results.send({"type": MSG_GROUPS, "message": groups.dumps()}) + self._results.send({"type": MSG_GROUPS, "message": self.groups.dumps()}) - def _read_commands(self): - """Handle all unread commands.""" - while self._commands.poll(): - cmd = self._commands.recv() + async def _read_commands(self): + """Handle all unread commands. + this will run until it receives CMD_TERMINATE + """ + async for cmd in self._commands: logger.debug('Received command "%s"', cmd) if cmd == CMD_TERMINATE: - logger.debug("Helper terminates") - sys.exit(0) + await self._stop_reading() + return if cmd == CMD_REFRESH_GROUPS: - groups.refresh() + self.groups.refresh() self._send_groups() continue - group = groups.find(key=cmd) + group = self.groups.find(key=cmd) if group is None: - groups.refresh() - group = groups.find(key=cmd) + # 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: - self.group = group + await self._stop_reading() + self._start_reading(group) continue logger.error('Received unknown command "%s"', cmd) - logger.debug("No more commands in pipe") - - def _start_reading(self): - """Tell the evdev lib to start looking for keycodes. - - If read is called without prior start_reading, no keycodes - will be available. - - This blocks forever until it discovers a new command on the socket. - """ - rlist = {} - - if self.group is None: - logger.error("group is None") - return - - virtual_devices = [] - # Watch over each one of the potentially multiple devices per - # hardware - for path in self.group.paths: + def _start_reading(self, group: _Group): + """find all devices of that group, filter interesting ones and send the events + to the gui""" + sources = [] + for path in group.paths: try: device = evdev.InputDevice(path) - except FileNotFoundError: - continue - - if evdev.ecodes.EV_KEY in device.capabilities(): - virtual_devices.append(device) - - if len(virtual_devices) == 0: - logger.debug('No interesting device for "%s"', self.group.key) - return - - for device in virtual_devices: - rlist[device.fd] = device - - logger.debug( - 'Starting reading keycodes from "%s"', - '", "'.join([device.name for device in virtual_devices]), - ) - - rlist[self._commands] = self._commands - - while True: - ready_fds = select.select(rlist, [], []) - if len(ready_fds[0]) == 0: - # happens with sockets sometimes. Sockets are not stable and - # not used, so nothing to worry about now. - continue - - for fd in ready_fds[0]: - if rlist[fd] == self._commands: - # all commands will cause the reader to start over - # (possibly for a different device). - # _read_commands will check what is going on - logger.debug("Stops reading due to new command") - return - - device = rlist[fd] - - try: - event = device.read_one() - if event: - self._send_event(event, device) - except OSError: - logger.debug('Device "%s" disappeared', device.path) - return - - def _send_event(self, event, device): - """Write the event into the pipe to the main process. - - Parameters - ---------- - event : evdev.InputEvent - device : evdev.InputDevice - """ - # value: 1 for down, 0 for up, 2 for hold. - if event.type == EV_KEY and event.value == 2: - # ignore hold-down events - return - - blacklisted_keys = [evdev.ecodes.BTN_TOOL_DOUBLETAP] - - if event.type == EV_KEY and event.code in blacklisted_keys: - return - - if event.type == EV_ABS: - abs_range = utils.get_abs_range(device, event.code) - event.value = utils.classify_action(event, abs_range) - else: - event.value = utils.classify_action(event) - - self._results.send( - { - "type": MSG_EVENT, - "message": (event.sec, event.usec, event.type, event.code, event.value), - } - ) + 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, ForwardDummy, self._stop_event) + self._tasks.add(asyncio.create_task(reader.run())) + + async def _stop_reading(self): + """stop the running event_reader""" + self._stop_event.set() + if self._tasks: + await asyncio.gather(*self._tasks) + self._tasks = set() + self._stop_event.clear() + + def _create_event_pipeline(self, sources: List[evdev.InputDevice]) -> ContextDummy: + """create a custom event pipeline for each event code in the + device capabilities. + Instead of sending the events to a uinput they will be sent to the frontend""" + context = ContextDummy() + # create a context for each source + for device in sources: + capabilities = device.capabilities(absinfo=False) + + for ev_code in capabilities.get(EV_KEY) or (): + context.notify_callbacks[(EV_KEY, ev_code)].append( + ForwardToUIHandler(self._results).notify + ) + + for ev_code in capabilities.get(EV_ABS) or (): + # positive direction + mapping = UIMapping( + event_combination=EventCombination((EV_ABS, ev_code, 30)), + target_uinput="keyboard", + ) + handler: MappingHandler = AbsToBtnHandler( + EventCombination((EV_ABS, ev_code, 30)), mapping + ) + handler.set_sub_handler(ForwardToUIHandler(self._results)) + context.notify_callbacks[(EV_ABS, ev_code)].append(handler.notify) + + # negative direction + mapping = UIMapping( + event_combination=EventCombination((EV_ABS, ev_code, -30)), + target_uinput="keyboard", + ) + handler = AbsToBtnHandler( + EventCombination((EV_ABS, ev_code, -30)), mapping + ) + handler.set_sub_handler(ForwardToUIHandler(self._results)) + context.notify_callbacks[(EV_ABS, ev_code)].append(handler.notify) + + for ev_code in capabilities.get(EV_REL) or (): + # positive direction + mapping = UIMapping( + event_combination=EventCombination( + (EV_REL, ev_code, self.rel_speed[ev_code]) + ), + target_uinput="keyboard", + release_timeout=0.3, + force_release_timeout=True, + ) + handler = RelToBtnHandler( + EventCombination((EV_REL, ev_code, self.rel_speed[ev_code])), + mapping, + ) + handler.set_sub_handler(ForwardToUIHandler(self._results)) + context.notify_callbacks[(EV_REL, ev_code)].append(handler.notify) + + # negative direction + mapping = UIMapping( + event_combination=EventCombination( + (EV_REL, ev_code, -self.rel_speed[ev_code]) + ), + target_uinput="keyboard", + release_timeout=0.3, + force_release_timeout=True, + ) + handler = RelToBtnHandler( + EventCombination((EV_REL, ev_code, -self.rel_speed[ev_code])), + mapping, + ) + handler.set_sub_handler(ForwardToUIHandler(self._results)) + context.notify_callbacks[(EV_REL, ev_code)].append(handler.notify) + + return context + + +class ContextDummy: + def __init__(self): + self.listeners = set() + self.notify_callbacks = defaultdict(list) + + def reset(self): + pass + + +class ForwardDummy: + @staticmethod + def write(*_): + pass + + +class ForwardToUIHandler: + """implements the InputEventHandler protocol. Sends all events into the pipe""" + + def __init__(self, pipe: Pipe): + self.pipe = pipe + self._last_event = InputEvent.from_tuple((99, 99, 99)) + + def notify( + self, + event: InputEvent, + source: evdev.InputDevice, + forward: evdev.UInput, + supress: bool = False, + ) -> bool: + """filter duplicates and send into the pipe""" + if event != self._last_event: + self._last_event = event + if EventActions.negative_trigger in event.actions: + event = event.modify(value=-1) + + logger.debug_key(event, f"to frontend:") + self.pipe.send( + { + "type": MSG_EVENT, + "message": ( + event.sec, + event.usec, + event.type, + event.code, + event.value, + ), + } + ) + return True + + def reset(self): + pass diff --git a/inputremapper/gui/message_broker.py b/inputremapper/gui/message_broker.py new file mode 100644 index 00000000..c63ed7e5 --- /dev/null +++ b/inputremapper/gui/message_broker.py @@ -0,0 +1,238 @@ +#!/usr/bin/python3 +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2022 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + +import os.path +import re +import traceback +from collections import defaultdict, deque +from dataclasses import dataclass +from enum import Enum +from typing import ( + Callable, + Dict, + Set, + Protocol, + Tuple, + Deque, + Optional, + List, + Any, + TYPE_CHECKING, +) + +from inputremapper.groups import DeviceType +from inputremapper.logger import logger + +if TYPE_CHECKING: + from inputremapper.event_combination import EventCombination + + +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" + 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" + + # for unit tests: + test1 = "test1" + test2 = "test2" + + +class Message(Protocol): + """the protocol any message must follow to be sent with the MessageBroker""" + + message_type: MessageType + + +# useful type aliases +MessageListener = Callable[[Any], None] +Capabilities = Dict[int, List] +Name = str +Key = str +DeviceTypes = List[DeviceType] + + +class MessageBroker: + shorten_path = re.compile("inputremapper/") + + def __init__(self): + self._listeners: Dict[MessageType, Set[MessageListener]] = defaultdict(set) + self._messages: Deque[Tuple[Message, str, int]] = deque() + self._sending = False + + def send(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._send_all() + + def signal(self, signal: MessageType): + """send a signal without any data payload""" + self.send(Signal(signal)) + + def _send(self, data: Message, file: str, line: int): + logger.debug(f"from {file}:{line}: Signal={data.message_type.name}: {data}") + for listener in self._listeners[data.message_type].copy(): + listener(data) + + def _send_all(self): + """send all scheduled messages in order""" + if self._sending: + # don't run this twice, so we not mess up the order + return + + self._sending = True + try: + while self._messages: + self._send(*self._messages.popleft()) + finally: + self._sending = False + + def subscribe(self, massage_type: MessageType, listener: MessageListener): + """attach a listener to an event""" + logger.debug("adding new Listener: %s", 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 + + +@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 = [m for m in re.finditer("(\d+, )+", string)] + all_matches.reverse() + for match in all_matches: + start = match.start() + end = match.end() + start += string[start:].find(",") + 2 + if start == end: + continue + string = f"{string[:start]}... {string[end:]}" + + return string + + +@dataclass(frozen=True) +class GroupsData: + """Message containing all available groups and their device types""" + + message_type = MessageType.groups + groups: Dict[Key, DeviceTypes] + + +@dataclass(frozen=True) +class GroupData: + """Message with the active group and available presets for the group""" + + message_type = MessageType.group + group_key: str + presets: Tuple[str, ...] + + +@dataclass(frozen=True) +class PresetData: + """Message with the active preset name and mapping names/combinations""" + + message_type = MessageType.preset + name: Optional[Name] + mappings: Optional[Tuple[Tuple[Name, "EventCombination"], ...]] + 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: "EventCombination" + + +@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: "EventCombination" + new_combination: "EventCombination" + + +@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 + + +class Signal(Message): + """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): + return str(self) == str(other) diff --git a/inputremapper/gui/reader.py b/inputremapper/gui/reader.py index 3a41a305..2c9d4d70 100644 --- a/inputremapper/gui/reader.py +++ b/inputremapper/gui/reader.py @@ -23,32 +23,32 @@ see gui.helper.helper """ +from typing import Optional, List, Generator, Dict, Tuple, Set -from typing import Optional -from evdev.ecodes import EV_REL -from inputremapper.input_event import InputEvent +import evdev +from gi.repository import GLib -from inputremapper.logger import logger from inputremapper.event_combination import EventCombination -from inputremapper.groups import groups, GAMEPAD -from inputremapper.ipc.pipe import Pipe +from inputremapper.groups import _Groups, _Group from inputremapper.gui.helper import ( MSG_EVENT, MSG_GROUPS, CMD_TERMINATE, CMD_REFRESH_GROUPS, ) -from inputremapper import utils -from inputremapper.gui.active_preset import active_preset +from inputremapper.gui.message_broker import ( + MessageBroker, + GroupsData, + MessageType, + CombinationRecorded, +) +from inputremapper.input_event import InputEvent +from inputremapper.ipc.pipe import Pipe +from inputremapper.logger import logger from inputremapper.user import USER - -DEBOUNCE_TICKS = 3 - - -def will_report_up(ev_type): - """Check if this event will ever report a key up (wheels).""" - return ev_type != EV_REL +BLACKLISTED_EVENTS = [(1, evdev.ecodes.BTN_TOOL_DOUBLETAP)] +RecordingGenerator = Generator[None, InputEvent, None] class Reader: @@ -61,192 +61,150 @@ class Reader: has knowledge of buttons like the middle-mouse button. """ - def __init__(self): - self.previous_event = None - self.previous_result = None - self._unreleased = {} - self._debounce_remove = {} - self._groups_updated = False - self._cleared_at = 0 - self.group = None + def __init__(self, message_broker: MessageBroker, groups: _Groups): + self.groups = groups + self.message_broker = message_broker + self.group: Optional[_Group] = None + self.read_timeout: Optional[int] = None + + self._recording_generator: Optional[RecordingGenerator] = None self._results = None self._commands = None + self.connect() + self.attach_to_events() + self._read_continuously() def connect(self): """Connect to the helper.""" self._results = Pipe(f"/tmp/input-remapper-{USER}/results") self._commands = Pipe(f"/tmp/input-remapper-{USER}/commands") - def are_new_groups_available(self): - """Check if groups contains new devices. + def attach_to_events(self): + """connect listeners to event_reader""" + self.message_broker.subscribe(MessageType.terminate, lambda _: self.terminate()) - The ui should then update its list. - """ - outdated = self._groups_updated - self._groups_updated = False # assume the ui will react accordingly - return outdated + def _read_continuously(self): + """poll the result pipe in regular intervals""" + self.read_timeout = GLib.timeout_add(30, self._read) - def _get_event(self, message) -> Optional[InputEvent]: - """Return an InputEvent if the message contains one. None otherwise.""" - message_type = message["type"] - message_body = message["message"] - - if message_type == MSG_GROUPS: - if message_body != groups.dumps(): - groups.loads(message_body) - logger.debug("Received %d devices", len(groups)) - self._groups_updated = True - return None - - if message_type == MSG_EVENT: - return InputEvent(*message_body) - - logger.error('Received unknown message "%s"', message) - return None - - def read(self): - """Get the newest key/combination as EventCombination object. - - Only reports keys from down-events. + def _read(self): + """Read the messages from the helper and handle them""" + while self._results.poll(): + message = self._results.recv() - On key-down events the pipe returns changed combinations. Release - events won't cause that and the reader will return None as in - "nothing new to report". So In order to change a combination, one - of its keys has to be released and then a different one pressed. + message_type = message["type"] + message_body = message["message"] + if message_type == MSG_GROUPS: + self._update_groups(message_body) + continue - Otherwise making combinations wouldn't be possible. Because at - some point the keys have to be released, and that shouldn't cause - the combination to get trimmed. + if message_type == MSG_EVENT: + if not self._recording_generator: + continue + # update the generator + try: + self._recording_generator.send(InputEvent(*message_body)) + except StopIteration: + self.message_broker.signal(MessageType.recording_finished) + self._recording_generator = None + return True + + def start_recorder(self) -> None: + """recorde user input""" + self._recording_generator = self._recorder() + next(self._recording_generator) + + def stop_recorder(self) -> None: + """Stop recording the input. + + Will send RecordingFinished message. """ - # this is in some ways similar to the keycode_mapper and - # joystick_to_mouse, but its much simpler because it doesn't - # have to trigger anything, manage any macros and only - # reports key-down events. This function is called periodically - # by the window. - - # remember the previous down-event from the pipe in order to - # be able to tell if the reader should return the updated combination - previous_event = self.previous_event - key_down_received = False + if self._recording_generator: + self._recording_generator.close() + self._recording_generator = None + self.message_broker.signal(MessageType.recording_finished) - self._debounce_tick() - - while self._results.poll(): - message = self._results.recv() - event = self._get_event(message) - if event is None: - continue + def _recorder(self) -> RecordingGenerator: + """Generator which receives InputEvents. - gamepad = GAMEPAD in self.group.types - if not utils.should_map_as_btn(event, active_preset, gamepad): + it accumulates them into EventCombinations and sends those on the message_broker. + it will stop once all keys or inputs are released. + """ + active: Set[Tuple[int, int]] = set() + accumulator: List[InputEvent] = [] + while True: + event: InputEvent = yield + if event.type_and_code in BLACKLISTED_EVENTS: continue if event.value == 0: - logger.debug_key(event, "release") - self._release(event.type_and_code) - continue - - if self._unreleased.get(event.type_and_code) == event: - logger.debug_key(event, "duplicate key down") - self._debounce_start(event.event_tuple) + try: + active.remove((event.type, event.code)) + 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 - # to keep track of combinations. - # "I have got this release event, what was this for?" A release - # event for a D-Pad axis might be any direction, hence this maps - # from release to input in order to remember it. Since all release - # events have value 0, the value is not used in the combination. - key_down_received = True - logger.debug_key(event, "down") - self._unreleased[event.type_and_code] = event - self._debounce_start(event.event_tuple) - previous_event = event - - if not key_down_received: - # This prevents writing a subset of the combination into - # result after keys were released. In order to control the gui, - # they have to be released. - return None - - self.previous_event = previous_event - - if len(self._unreleased) > 0: - result = EventCombination(self._unreleased.values()) - if result == self.previous_result: - # don't return the same stuff twice - return None - - self.previous_result = result - logger.debug_key(result, "read result") - - return result - - return None - - def start_reading(self, group): + active.add(event.type_and_code) + accu_type_code = [e.type_and_code for e in accumulator] + if event.type_and_code in accu_type_code and event not in accumulator: + # the value has changed but the event is already in the accumulator + # update the event + i = accu_type_code.index(event.type_and_code) + accumulator[i] = event + self.message_broker.send( + CombinationRecorded(EventCombination(accumulator)) + ) + + if event not in accumulator: + accumulator.append(event) + self.message_broker.send( + CombinationRecorded(EventCombination(accumulator)) + ) + + def set_group(self, group): """Start reading keycodes for a device.""" logger.debug('Sending start msg to helper for "%s"', group.key) + if self._recording_generator: + self._recording_generator.close() + self._recording_generator = None self._commands.send(group.key) self.group = group - self.clear() def terminate(self): """Stop reading keycodes for good.""" logger.debug("Sending close msg to helper") self._commands.send(CMD_TERMINATE) + if self.read_timeout: + GLib.source_remove(self.read_timeout) + while self._results.poll(): + self._results.recv() def refresh_groups(self): """Ask the helper for new device groups.""" self._commands.send(CMD_REFRESH_GROUPS) - def clear(self): - """Next time when reading don't return the previous keycode.""" - logger.debug("Clearing reader") - while self._results.poll(): - # clear the results pipe and handle any non-event messages, - # otherwise a 'groups' message might get lost - message = self._results.recv() - self._get_event(message) - - self._unreleased = {} - self.previous_event = None - self.previous_result = None - - def get_unreleased_keys(self): - """Get a EventCombination object of the current keyboard state.""" - unreleased = list(self._unreleased.values()) - - if len(unreleased) == 0: - return None - - return EventCombination(unreleased) - - def _release(self, type_code): - """Modify the state to recognize the releasing of the key.""" - if type_code in self._unreleased: - del self._unreleased[type_code] - if type_code in self._debounce_remove: - del self._debounce_remove[type_code] - - def _debounce_start(self, event_tuple): - """Act like the key was released if no new event arrives in time.""" - if not will_report_up(event_tuple[0]): - self._debounce_remove[event_tuple[:2]] = DEBOUNCE_TICKS - - def _debounce_tick(self): - """If the counter reaches 0, the key is not considered held down.""" - for type_code in list(self._debounce_remove.keys()): - if type_code not in self._unreleased: - continue - - # clear wheel events from unreleased after some time - if self._debounce_remove[type_code] == 0: - logger.debug_key(self._unreleased[type_code], "Considered as released") - self._release(type_code) - else: - self._debounce_remove[type_code] -= 1 - - -reader = Reader() + def send_groups(self): + """announce all known groups""" + groups: Dict[str, List[str]] = { + group.key: group.types or [] + for group in self.groups.filter(include_inputremapper=False) + } + self.message_broker.send(GroupsData(groups)) + + def _update_groups(self, dump): + if dump != self.groups.dumps(): + self.groups.loads(dump) + logger.debug("Received %d devices", len(self.groups)) + self._groups_updated = True + + # send this even if the groups did not change, as the user expects the ui + # to respond in some form + self.send_groups() diff --git a/inputremapper/gui/user_interface.py b/inputremapper/gui/user_interface.py index 4491a479..ff31d13c 100644 --- a/inputremapper/gui/user_interface.py +++ b/inputremapper/gui/user_interface.py @@ -20,59 +20,42 @@ """User Interface.""" +from typing import Dict, Callable - -import math -import os -import re -import sys -from inputremapper.gui.gettext import _ - -from evdev.ecodes import EV_KEY -from gi.repository import Gtk, GtkSource, Gdk, GLib, GObject -from inputremapper.input_event import InputEvent +from gi.repository import Gtk, GtkSource, Gdk, GObject from inputremapper.configs.data import get_data_path -from inputremapper.exceptions import MacroParsingError -from inputremapper.configs.paths import get_config_path, get_preset_path -from inputremapper.configs.system_mapping import system_mapping -from inputremapper.gui.active_preset import active_preset -from inputremapper.gui.utils import HandlerDisabled -from inputremapper.configs.preset import ( - find_newest_preset, - get_presets, - delete_preset, - rename_preset, - get_available_preset_name, -) -from inputremapper.logger import logger, COMMIT_HASH, VERSION, EVDEV_VERSION, is_debug -from inputremapper.groups import ( - groups, - GAMEPAD, - KEYBOARD, - UNKNOWN, - GRAPHICS_TABLET, - TOUCHPAD, - MOUSE, -) -from inputremapper.gui.editor.editor import Editor +from inputremapper.configs.mapping import MappingData from inputremapper.event_combination import EventCombination -from inputremapper.gui.reader import reader -from inputremapper.gui.helper import is_helper_running -from inputremapper.injection.injector import RUNNING, FAILED, NO_GRAB, UPGRADE_EVDEV -from inputremapper.daemon import Daemon -from inputremapper.configs.global_config import global_config -from inputremapper.injection.macros.parse import is_this_a_macro, parse -from inputremapper.injection.global_uinputs import global_uinputs +from inputremapper.gui.autocompletion import Autocompletion +from inputremapper.gui.components import ( + DeviceSelection, + PresetSelection, + MappingListBox, + TargetSelection, + CodeEditor, + RecordingToggle, + StatusBar, + AutoloadSwitch, + ReleaseCombinationSwitch, + CombinationListbox, + AnalogInputSwitch, + TriggerThresholdInput, + OutputAxisSelector, + ConfirmCancelDialog, + KeyAxisStack, + ReleaseTimeoutInput, + TransformationDrawArea, + Sliders, +) +from inputremapper.gui.controller import Controller +from inputremapper.gui.message_broker import MessageBroker, MessageType from inputremapper.gui.utils import ( - CTX_ERROR, - CTX_MAPPING, - CTX_APPLY, - CTX_WARNING, gtk_iteration, - debounce, ) - +from inputremapper.injection.injector import InjectorState +from inputremapper.logger import logger, COMMIT_HASH, VERSION, EVDEV_VERSION +from inputremapper.gui.gettext import _ # TODO add to .deb and AUR dependencies # https://cjenkins.wordpress.com/2012/05/08/use-gtksourceview-widget-in-glade/ @@ -81,77 +64,61 @@ GObject.type_register(GtkSource.View) # https://stackoverflow.com/questions/60126579/gtk-builder-error-quark-invalid-object-type-webkitwebview -CONTINUE = True -GO_BACK = False - -ICON_NAMES = { - GAMEPAD: "input-gaming", - MOUSE: "input-mouse", - KEYBOARD: "input-keyboard", - GRAPHICS_TABLET: "input-tablet", - TOUCHPAD: "input-touchpad", - UNKNOWN: None, -} - -# sort types that most devices would fall in easily to the right. -ICON_PRIORITIES = [GRAPHICS_TABLET, TOUCHPAD, GAMEPAD, MOUSE, KEYBOARD, UNKNOWN] - - -def if_group_selected(func): - """Decorate a function to only execute if a device is selected.""" - # this should only happen if no device was found at all - def wrapped(self, *args, **kwargs): - if self.group is None: - return True # work with timeout_add - - return func(self, *args, **kwargs) - - return wrapped - - -def if_preset_selected(func): - """Decorate a function to only execute if a preset is selected.""" - # this should only happen if no device was found at all - def wrapped(self, *args, **kwargs): - if self.preset_name is None or self.group is None: - return True # work with timeout_add - - return func(self, *args, **kwargs) - - return wrapped - - -def on_close_about(about, event): +def on_close_about(about, _): """Hide the about dialog without destroying it.""" about.hide() return True -def ensure_everything_saved(func): - """Make sure the editor has written its changes to active_preset and save.""" - - def wrapped(self, *args, **kwargs): - if self.preset_name: - self.editor.gather_changes_and_save() - - return func(self, *args, **kwargs) - - return wrapped - - class UserInterface: """The input-remapper gtk window.""" - def __init__(self): - self.dbus = None - - self.start_processes() + 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, + } + + # 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.confirm_cancel_dialog: Gtk.MessageDialog = self.get("confirm-cancel") + 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() - self.group = None - self.preset_name = None + # now show the proper finished content of the window + self.get("vertical-wrapper").set_opacity(1) - global_uinputs.prepare_all() + 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")) @@ -162,34 +129,68 @@ class UserInterface: ) gladefile = get_data_path("input-remapper.glade") - builder = Gtk.Builder() - builder.add_from_file(gladefile) - builder.connect_signals(self) - self.builder = builder - - self.editor = Editor(self) - - # set up the device selection - # https://python-gtk-3-tutorial.readthedocs.io/en/latest/treeview.html#the-view - combobox: Gtk.ComboBox = self.get("device_selection") - self.device_store = Gtk.ListStore(str, str, str) - combobox.set_model(self.device_store) - renderer_icon = Gtk.CellRendererPixbuf() - renderer_text = Gtk.CellRendererText() - renderer_text.set_padding(5, 0) - combobox.pack_start(renderer_icon, False) - combobox.pack_start(renderer_text, False) - combobox.add_attribute(renderer_icon, "icon-name", 1) - combobox.add_attribute(renderer_text, "text", 2) - combobox.set_id_column(0) - - self.confirm_delete = builder.get_object("confirm-delete") - self.about = builder.get_object("about-dialog") + self.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 + DeviceSelection(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")) + 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"), + ) + 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") + ) + OutputAxisSelector(message_broker, controller, self.get("output-axis-selector")) + ConfirmCancelDialog( + message_broker, + controller, + self.get("confirm-cancel"), + self.get("confirm-cancel-label"), + ) + KeyAxisStack(message_broker, controller, self.get("editor-stack")) + 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"), + ) + + # code editor and autocompletion + code_editor = CodeEditor(message_broker, controller, self.get("code_editor")) + autocompletion = Autocompletion(message_broker, 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}" @@ -197,537 +198,132 @@ class UserInterface: else "" ) - window = self.get("window") - window.show() - # hide everything until stuff is populated - self.get("vertical-wrapper").set_opacity(0) - self.window = window - - source_view = self.get("code_editor") - source_view.get_buffer().connect("changed", self.check_on_typing) - - # 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() - self.populate_devices() - - self.timeouts = [] - self.setup_timeouts() - - # now show the proper finished content of the window - self.get("vertical-wrapper").set_opacity(1) - - self.ctrl = False - self.unreleased_warn = False - self.button_left_warn = False - - if not is_helper_running(): - self.show_status(CTX_ERROR, _("The helper did not start")) - - def setup_timeouts(self): - """Setup all GLib timeouts.""" - self.timeouts = [ - GLib.timeout_add(1000 / 30, self.consume_newest_keycode), - ] - - def start_processes(self): - """Start helper and daemon via pkexec to run in the background.""" - # this function is overwritten in tests - self.dbus = Daemon.connect() - - debug = " -d" if is_debug() else "" - cmd = f"pkexec input-remapper-control --command helper {debug}" - - logger.debug("Running `%s`", cmd) - exit_code = os.system(cmd) - - if exit_code != 0: - logger.error("Failed to pkexec the helper, code %d", exit_code) - sys.exit(11) - - def show_confirm_delete(self): - """Blocks until the user decided about an action.""" - text = _("Are you sure to delete preset %s?") % self.preset_name - self.get("confirm-delete-label").set_text(text) - - self.confirm_delete.show() - response = self.confirm_delete.run() - self.confirm_delete.hide() - return response - - def on_key_press(self, window, event): - """To execute shortcuts. - - This has nothing to do with the keycode reader. - """ - if self.editor.is_waiting_for_input(): - # don't perform shortcuts while keys are being recorded - return - - gdk_keycode = event.get_keyval()[1] - - if gdk_keycode in [Gdk.KEY_Control_L, Gdk.KEY_Control_R]: - self.ctrl = True - - if self.ctrl: - # shortcuts - if gdk_keycode == Gdk.KEY_q: - self.on_close() + 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("apply_system_layout").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() - if gdk_keycode == Gdk.KEY_r: - reader.refresh_groups() + 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 + ) - if gdk_keycode == Gdk.KEY_Delete: - self.on_stop_injecting_clicked() + def on_injector_state_msg(self, msg: InjectorState): + """update the ui to reflect the status of the injector""" + stop_injection_btn: Gtk.Button = self.get("apply_system_layout") + recording_toggle: Gtk.ToggleButton = self.get("key_recording_toggle") + if msg.active(): + stop_injection_btn.set_opacity(1) + stop_injection_btn.set_sensitive(True) + recording_toggle.set_opacity(0.4) + else: + stop_injection_btn.set_opacity(0.4) + stop_injection_btn.set_sensitive(True) + recording_toggle.set_opacity(1) - def on_key_release(self, window, event): - """To execute shortcuts. + def disconnect_shortcuts(self): + """stop listening for shortcuts - This has nothing to do with the keycode reader. + e.g. when recording key combinations """ - gdk_keycode = event.get_keyval()[1] - - if gdk_keycode in [Gdk.KEY_Control_L, Gdk.KEY_Control_R]: - self.ctrl = False + 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): + """stop 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): """Get a widget from the window""" return self.builder.get_object(name) - @ensure_everything_saved - def on_close(self, *args): - """Safely close the application.""" + def close(self): + """Close the window""" logger.debug("Closing window") self.window.hide() - for timeout in self.timeouts: - GLib.source_remove(timeout) - self.timeouts = [] - reader.terminate() - Gtk.main_quit() - - @ensure_everything_saved - def select_newest_preset(self): - """Find and select the newest preset (and its device).""" - group_name, preset = find_newest_preset() - if group_name is not None: - self.get("device_selection").set_active_id(group_name) - if preset is not None: - self.get("preset_selection").set_active_id(preset) - - @ensure_everything_saved - def populate_devices(self): - """Make the devices selectable.""" - device_selection = self.get("device_selection") - - with HandlerDisabled(device_selection, self.on_select_device): - self.device_store.clear() - for group in groups.filter(include_inputremapper=False): - types = group.types - if len(types) > 0: - device_type = sorted(types, key=ICON_PRIORITIES.index)[0] - icon_name = ICON_NAMES[device_type] - else: - icon_name = None - - self.device_store.append([group.key, icon_name, group.key]) - - self.select_newest_preset() - - @if_group_selected - @ensure_everything_saved - def populate_presets(self): - """Show the available presets for the selected device. - - This will destroy unsaved changes in the active_preset. - """ - presets = get_presets(self.group.name) - - if len(presets) == 0: - new_preset = get_available_preset_name(self.group.name) - active_preset.clear() - path = self.group.get_preset_path(new_preset) - active_preset.path = path - active_preset.save() - presets = [new_preset] - else: - logger.debug('"%s" presets: "%s"', self.group.name, '", "'.join(presets)) - - preset_selection = self.get("preset_selection") - - with HandlerDisabled(preset_selection, self.on_select_preset): - # otherwise the handler is called with None for each preset - preset_selection.remove_all() - for preset in presets: - preset_selection.append(preset, preset) - - # and select the newest one (on the top). triggers on_select_preset - preset_selection.set_active(0) - - @if_group_selected - def can_modify_preset(self, *args) -> bool: - """if changing the preset is possible.""" - return self.dbus.get_state(self.group.key) != RUNNING - - def consume_newest_keycode(self): - """To capture events from keyboards, mice and gamepads.""" - # the "event" event of Gtk.Window wouldn't trigger on gamepad - # events, so it became a GLib timeout to periodically check kernel - # events. - - # letting go of one of the keys of a combination won't just make - # it return the leftover key, it will continue to return None because - # they have already been read. - combination = reader.read() - - if reader.are_new_groups_available(): - self.populate_devices() - - # giving editor its own interval and making it call reader.read itself causes - # incredibly frustrating and miraculous problems. Do not do it. Observations: - # - test_autocomplete_key fails if the gui has been launched and closed by a - # previous test already - # Maybe it has something to do with the order of editor.consume_newest_keycode - # and user_interface.populate_devices. - self.editor.consume_newest_keycode(combination) - - return True - - @if_group_selected - def on_stop_injecting_clicked(self, *args): - """Stop injecting the preset.""" - self.dbus.stop_injecting(self.group.key) - self.show_status(CTX_APPLY, _("Applied the system default")) - GLib.timeout_add(100, self.show_device_mapping_status) - - def show_status(self, context_id, message, tooltip=None): - """Show a status message and set its tooltip. - - If message is None, it will remove the newest message of the - given context_id. - """ - status_bar = self.get("status_bar") - - if message is None: - status_bar.remove_all(context_id) - - if context_id in (CTX_ERROR, CTX_MAPPING): - self.get("error_status_icon").hide() - - if context_id == CTX_WARNING: - self.get("warning_status_icon").hide() - - status_bar.set_tooltip_text("") - else: - if tooltip is None: - tooltip = message - - self.get("error_status_icon").hide() - self.get("warning_status_icon").hide() - - if context_id in (CTX_ERROR, CTX_MAPPING): - self.get("error_status_icon").show() - - if context_id == CTX_WARNING: - self.get("warning_status_icon").show() - - max_length = 45 - if len(message) > max_length: - message = message[: max_length - 3] + "..." - - status_bar.push(context_id, message) - status_bar.set_tooltip_text(tooltip) - - @debounce(500) - def check_on_typing(self, *_): - """To save latest input from code editor and call syntax check.""" - self.editor.gather_changes_and_save() - self.check_macro_syntax() - - def check_macro_syntax(self): - """Check if the programmed macros are allright.""" - # this is totally redundant as the mapping itself has already checked for - # validity but will be reworked anyway. - self.show_status(CTX_MAPPING, None) - for mapping in active_preset: - if not is_this_a_macro(mapping.output_symbol): - continue - - try: - parse(mapping.output_symbol) - except MacroParsingError as error: - position = mapping.event_combination.beautify() - msg = _("Syntax error at %s, hover for info") % position - self.show_status(CTX_MAPPING, msg, error) - - @ensure_everything_saved - def on_rename_button_clicked(self, button): - """Rename the preset based on the contents of the name input.""" - new_name = self.get("preset_name_input").get_text() - - if new_name in ["", self.preset_name]: - return - - new_name = rename_preset(self.group.name, self.preset_name, new_name) - active_preset.path = get_preset_path(self.group.name, new_name) - - # if the old preset was being autoloaded, change the - # name there as well - is_autoloaded = global_config.is_autoloaded(self.group.key, self.preset_name) - if is_autoloaded: - global_config.set_autoload_preset(self.group.key, new_name) - - self.get("preset_name_input").set_text("") - self.populate_presets() - - @if_preset_selected - def on_delete_preset_clicked(self, *args): - """Delete a preset from the file system.""" - accept = Gtk.ResponseType.ACCEPT - if len(active_preset) > 0 and self.show_confirm_delete() != accept: - return - - # avoid having the text of the symbol input leak into the active_preset again - # via a gazillion hooks, causing the preset to be saved again after deleting. - self.editor.clear() - - delete_preset(self.group.name, self.preset_name) - - self.populate_presets() - - @if_preset_selected - def on_apply_preset_clicked(self, button): - """Apply a preset without saving changes.""" - self.save_preset() - - if len(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 keys and save first")) - return - preset = self.preset_name - logger.info('Applying preset "%s" for "%s"', preset, self.group.key) - - if not self.button_left_warn: - if 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 - - if not self.unreleased_warn: - unreleased = reader.get_unreleased_keys() - if unreleased is not None and unreleased != EventCombination( - InputEvent.btn_left() - ): - # it's super annoying if that happens and may break the user - # input in such a way to prevent disabling the preset - logger.error( - "Tried to apply a preset while keys were held down: %s", unreleased - ) - self.show_status( - CTX_ERROR, - "Please release your pressed keys first", - "X11 will think they are held down forever otherwise.\n" - "To overwrite this warning, press apply again.", - ) - self.unreleased_warn = True - return - - self.unreleased_warn = False - self.button_left_warn = False - self.dbus.set_config_dir(get_config_path()) - self.dbus.start_injecting(self.group.key, preset) - - self.show_status(CTX_APPLY, _("Starting injection...")) - - GLib.timeout_add(100, self.show_injection_result) - - def on_autoload_switch(self, switch, active): - """Load the preset automatically next time the user logs in.""" - key = self.group.key - preset = self.preset_name - global_config.set_autoload_preset(key, preset if active else None) - # tell the service to refresh its config - self.dbus.set_config_dir(get_config_path()) - - @ensure_everything_saved - def on_select_device(self, dropdown): - """List all presets, create one if none exist yet.""" - if self.group and dropdown.get_active_id() == self.group.key: + def update_combination_label(self, mapping: MappingData): + """listens for mapping and updates the combination label""" + label: Gtk.Label = self.get("combination-label") + if mapping.event_combination.beautify() == label.get_label(): return - - group_key = dropdown.get_active_id() - - if group_key is None: - return - - logger.debug('Selecting device "%s"', group_key) - - self.group = groups.find(key=group_key) - self.preset_name = None - - self.populate_presets() - - reader.start_reading(groups.find(key=group_key)) - - self.show_device_mapping_status() - - def show_injection_result(self): - """Show if the injection was successfully started.""" - state = self.dbus.get_state(self.group.key) - - if state == RUNNING: - msg = _("Applied preset %s") % self.preset_name - - if active_preset.get_mapping(EventCombination(InputEvent.btn_left())): - msg += _(", CTRL + DEL to stop") - - self.show_status(CTX_APPLY, msg) - - self.show_device_mapping_status() - return False - - if state == FAILED: - self.show_status( - CTX_ERROR, _("Failed to apply preset %s") % self.preset_name - ) - return False - - if state == NO_GRAB: - self.show_status( - CTX_ERROR, - "The device was not grabbed", - "Either another application is already grabbing it or " - "your preset doesn't contain anything that is sent by the " - "device.", - ) - return False - - if state == UPGRADE_EVDEV: - self.show_status( - CTX_ERROR, - "Upgrade python-evdev", - "Your python-evdev version is too old.", - ) - return False - - # keep the timeout running until a relevant state is found - return True - - def show_device_mapping_status(self): - """Figure out if this device is currently under inputremappers control.""" - self.editor.update_toggle_opacity() - group_key = self.group.key - state = self.dbus.get_state(group_key) - if state == RUNNING: - logger.info('Group "%s" is currently mapped', group_key) - self.get("apply_system_layout").set_opacity(1) - else: - self.get("apply_system_layout").set_opacity(0.4) - - @if_preset_selected - def on_copy_preset_clicked(self, *args): - """Copy the current preset and select it.""" - self.create_preset(copy=True) - - @if_group_selected - def on_create_preset_clicked(self, *args): - """Create a new empty preset and select it.""" - self.create_preset() - - @ensure_everything_saved - def create_preset(self, copy=False): - """Create a new preset and select it.""" - name = self.group.name - preset = self.preset_name - - try: - if copy: - new_preset = get_available_preset_name(name, preset, copy) - else: - new_preset = get_available_preset_name(name) - self.editor.clear() - active_preset.clear() - - path = self.group.get_preset_path(new_preset) - active_preset.path = path - active_preset.save() - self.get("preset_selection").append(new_preset, new_preset) - # triggers on_select_preset - self.get("preset_selection").set_active_id(new_preset) - if self.get("preset_selection").get_active_id() != new_preset: - # for whatever reason I have to use set_active_id twice for this - # to work in tests all of the sudden - self.get("preset_selection").set_active_id(new_preset) - except PermissionError as error: - error = str(error) - self.show_status(CTX_ERROR, _("Permission denied!"), error) - logger.error(error) - - @ensure_everything_saved - def on_select_preset(self, dropdown): - """Show the mappings of the preset.""" - # beware in tests that this function won't be called at all if the - # active_id stays the same - if dropdown.get_active_id() == self.preset_name: - return - - preset = dropdown.get_active_text() - if preset is None: - return - - logger.debug('Selecting preset "%s"', preset) - self.editor.clear_mapping_list() - self.preset_name = preset - active_preset.clear() - active_preset.path = self.group.get_preset_path(preset) - active_preset.load() - - self.editor.load_custom_mapping() - - autoload_switch = self.get("preset_autoload_switch") - - with HandlerDisabled(autoload_switch, self.on_autoload_switch): - is_autoloaded = global_config.is_autoloaded( - self.group.key, self.preset_name - ) - autoload_switch.set_active(is_autoloaded) - - self.get("preset_name_input").set_text("") - - def save_preset(self, *args): - """Write changes in the active_preset to disk.""" - if not active_preset.has_unsaved_changes(): - # optimization, and also avoids tons of redundant logs - logger.debug("Not saving because preset did not change") + if mapping.event_combination == EventCombination.empty_combination(): + label.set_opacity(0.4) + label.set_label(_("no input configured")) return - try: - assert self.preset_name is not None - active_preset.save() + label.set_opacity(1) + label.set_label(mapping.event_combination.beautify()) - # after saving the preset, its modification date will be the - # newest, so populate_presets will automatically select the - # right one again. - self.populate_presets() - except PermissionError as error: - error = str(error) - self.show_status(CTX_ERROR, _("Permission denied!"), error) - logger.error(error) + 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 - self.show_status(CTX_MAPPING, None) + def on_gtk_close(self, *_): + self.controller.close() - def on_about_clicked(self, button): + def on_gtk_about_clicked(self, _): """Show the about/help dialog.""" self.about.show() - def on_about_key_press(self, window, event): + 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 index 32333d45..392a1759 100644 --- a/inputremapper/gui/utils.py +++ b/inputremapper/gui/utils.py @@ -17,12 +17,13 @@ # # You should have received a copy of the GNU General Public License # along with input-remapper. If not, see . - +import time from gi.repository import Gtk, GLib # status ctx ids + CTX_SAVE = 0 CTX_APPLY = 1 CTX_KEYCODE = 2 @@ -78,7 +79,11 @@ class HandlerDisabled: self.widget.handler_unblock_by_func(self.handler) -def gtk_iteration(): +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() diff --git a/inputremapper/injection/context.py b/inputremapper/injection/context.py index fbe34fb7..bbc2d471 100644 --- a/inputremapper/injection/context.py +++ b/inputremapper/injection/context.py @@ -20,34 +20,17 @@ """Stores injection-process wide information.""" -import asyncio -from typing import Awaitable, List, Dict, Tuple, Protocol, Set - -import evdev +from collections import defaultdict +from typing import List, Dict, Tuple, Set from inputremapper.configs.preset import Preset -from inputremapper.input_event import InputEvent -from inputremapper.injection.mapping_handlers.mapping_parser import parse_mappings from inputremapper.injection.mapping_handlers.mapping_handler import ( InputEventHandler, EventListener, + NotifyCallback, ) - - -class NotifyCallback(Protocol): - """Type signature of MappingHandler.notify - - return True if the event was actually taken care of - """ - - def __call__( - self, - event: evdev.InputEvent, - source: evdev.InputDevice = None, - forward: evdev.UInput = None, - supress: bool = False, - ) -> bool: - ... +from inputremapper.injection.mapping_handlers.mapping_parser import parse_mappings +from inputremapper.input_event import InputEvent class Context: @@ -78,15 +61,13 @@ class Context: all entry points to the event pipeline sorted by InputEvent.type_and_code """ - preset: Preset listeners: Set[EventListener] - callbacks: Dict[Tuple[int, int], List[NotifyCallback]] - _handlers: Dict[InputEvent, List[InputEventHandler]] + notify_callbacks: Dict[Tuple[int, int], List[NotifyCallback]] + _handlers: Dict[InputEvent, Set[InputEventHandler]] def __init__(self, preset: Preset): - self.preset = preset self.listeners = set() - self.callbacks = {} + self.notify_callbacks = defaultdict(list) self._handlers = parse_mappings(preset, self) self._create_callbacks() @@ -94,12 +75,12 @@ class Context: def reset(self) -> None: """Call the reset method for each handler in the context.""" for handlers in self._handlers.values(): - [handler.reset() for handler in handlers] + for handler in handlers: + handler.reset() def _create_callbacks(self) -> None: """Add the notify method from all _handlers to self.callbacks.""" for event, handler_list in self._handlers.items(): - if event.type_and_code not in self.callbacks.keys(): - self.callbacks[event.type_and_code] = [] - for handler in handler_list: - self.callbacks[event.type_and_code].append(handler.notify) + self.notify_callbacks[event.type_and_code].extend( + handler.notify for handler in handler_list + ) diff --git a/inputremapper/injection/event_reader.py b/inputremapper/injection/event_reader.py index 93ef99c1..a1523a77 100644 --- a/inputremapper/injection/event_reader.py +++ b/inputremapper/injection/event_reader.py @@ -21,38 +21,24 @@ """Because multiple calls to async_read_loop won't work.""" import asyncio -import evdev -from inputremapper.logger import logger -from inputremapper.input_event import InputEvent, EventActions -from inputremapper.injection.context import Context - - -class _ReadLoop: - def __init__(self, device: evdev.InputDevice, stop_event: asyncio.Event): - self.iterator = device.async_read_loop().__aiter__() - self.stop_event = stop_event - self.wait_for_stop = asyncio.Task(stop_event.wait()) +from typing import AsyncIterator, Protocol, Set, Dict, Tuple, List - def __aiter__(self): - return self +import evdev - def __anext__(self): - if self.stop_event.is_set(): - raise StopAsyncIteration +from inputremapper.injection.mapping_handlers.mapping_handler import ( + EventListener, + NotifyCallback, +) +from inputremapper.input_event import InputEvent +from inputremapper.logger import logger - return self.future() - async def future(self): - ev_task = asyncio.Task(self.iterator.__anext__()) - stop_task = self.wait_for_stop - done, pending = await asyncio.wait( - {ev_task, stop_task}, - return_when=asyncio.FIRST_COMPLETED, - ) - if stop_task in done: - raise StopAsyncIteration +class Context(Protocol): + listeners: Set[EventListener] + notify_callbacks: Dict[Tuple[int, int], List[NotifyCallback]] - return done.pop().result() + def reset(self): + ... class EventReader: @@ -89,6 +75,27 @@ class EventReader: self.context = context self.stop_event = stop_event + 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, events_ready.wait()}, + return_when=asyncio.FIRST_COMPLETED, + ) + if stop_task.done(): + 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 callback.""" if event.type == evdev.ecodes.EV_MSC: @@ -98,7 +105,7 @@ class EventReader: return False results = set() - for callback in self.context.callbacks.get(event.type_and_code) or (): + for callback in self.context.notify_callbacks.get(event.type_and_code) or (): results.add(callback(event, source=self._source, forward=self._forward_to)) return True in results @@ -153,8 +160,8 @@ class EventReader: async def run(self): """Start doing things. - Can be stopped by stopping the asyncio loop. This loop - reads events from a single device only. + 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", @@ -162,7 +169,7 @@ class EventReader: self._source.fd, ) - async for event in _ReadLoop(self._source, self.stop_event): + async for event in self.read_loop(): await self.handle(InputEvent.from_event(event)) self.context.reset() diff --git a/inputremapper/injection/global_uinputs.py b/inputremapper/injection/global_uinputs.py index 2c2eb28e..3a455dbe 100644 --- a/inputremapper/injection/global_uinputs.py +++ b/inputremapper/injection/global_uinputs.py @@ -17,15 +17,14 @@ # # You should have received a copy of the GNU General Public License # along with input-remapper. If not, see . - +from typing import Dict, Union import evdev -import inputremapper.utils import inputremapper.exceptions +import inputremapper.utils from inputremapper.logger import logger - DEV_NAME = "input-remapper" DEFAULT_UINPUTS = { # for event codes see linux/input-event-codes.h @@ -87,7 +86,7 @@ class GlobalUInputs: """Manages all uinputs that are shared between all injection processes.""" def __init__(self): - self.devices = {} + self.devices: Dict[str, Union[UInput, FrontendUInput]] = {} self._uinput_factory = None self.is_service = inputremapper.utils.is_service() diff --git a/inputremapper/injection/injector.py b/inputremapper/injection/injector.py index 8565d63c..3cce45d4 100644 --- a/inputremapper/injection/injector.py +++ b/inputremapper/injection/injector.py @@ -20,26 +20,29 @@ """Keeps injecting keycodes in the background based on the preset.""" - -import os -import sys +from __future__ import annotations import asyncio -import time import multiprocessing +import sys +import time +from dataclasses import dataclass +from multiprocessing.connection import Connection +from typing import Dict, List, Optional, Tuple import evdev -from typing import Dict, List, Optional - from inputremapper.configs.preset import Preset - -from inputremapper.logger import logger -from inputremapper.groups import classify, GAMEPAD, _Group +from inputremapper.event_combination import EventCombination +from inputremapper.groups import ( + _Group, + classify, + DeviceType, +) +from inputremapper.gui.message_broker import MessageType from inputremapper.injection.context import Context -from inputremapper.injection.numlock import set_numlock, is_numlock_on, ensure_numlock from inputremapper.injection.event_reader import EventReader -from inputremapper.event_combination import EventCombination - +from inputremapper.injection.numlock import set_numlock, is_numlock_on, ensure_numlock +from inputremapper.logger import logger CapabilitiesDict = Dict[int, List[int]] GroupSources = List[evdev.InputDevice] @@ -81,6 +84,15 @@ def get_udev_name(name: str, suffix: str) -> str: return name +@dataclass(frozen=True) +class InjectorState: + message_type = MessageType.injector_state + state: int + + def active(self) -> bool: + return self.state == RUNNING or self.state == STARTING or self.state == NO_GRAB + + class Injector(multiprocessing.Process): """Initializes, starts and stops injections. @@ -93,7 +105,7 @@ class Injector(multiprocessing.Process): preset: Preset context: Optional[Context] _state: int - _msg_pipe: multiprocessing.Pipe + _msg_pipe: Tuple[Connection, Connection] _consumer_controls: List[EventReader] _stop_event: asyncio.Event @@ -119,9 +131,8 @@ class Injector(multiprocessing.Process): self.context = None # only needed inside the injection process self._consumer_controls = [] - self._stop_event = None - super().__init__(name=group) + super().__init__(name=group.key) """Functions to interact with the running process""" @@ -130,29 +141,30 @@ class Injector(multiprocessing.Process): 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._state == UNKNOWN and not alive: + if state == UNKNOWN and not alive: # `self.start()` has not been called yet + self._state = state return self._state - if self._state == UNKNOWN and alive: + if state == UNKNOWN and alive: # if it is alive, it is definitely at least starting up. - self._state = STARTING + state = STARTING - if self._state == STARTING and self._msg_pipe[1].poll(): - # if there is a message available, it might have finished starting up - # and the injector has the real status for us - msg = self._msg_pipe[1].recv() - self._state = msg - - if self._state in [STARTING, RUNNING] and not alive: + if state in (STARTING, 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 - self._state = FAILED + state = FAILED logger.error("Injector was unexpectedly found stopped") + self._state = state return self._state @ensure_numlock @@ -163,75 +175,80 @@ class Injector(multiprocessing.Process): """ logger.info('Stopping injecting keycodes for group "%s"', self.group.key) self._msg_pipe[1].send(CLOSE) - self._state = STOPPED """Process internal stuff""" def _grab_devices(self) -> GroupSources: - """Grab all devices that are needed for the injection.""" - sources = [] + ranking = [ + DeviceType.KEYBOARD, + DeviceType.GAMEPAD, + DeviceType.MOUSE, + DeviceType.TOUCHPAD, + DeviceType.GRAPHICS_TABLET, + DeviceType.CAMERA, + DeviceType.UNKNOWN, + ] + + # query all devices for their capabilities, and type + devices: List[evdev.InputDevice] = [] for path in self.group.paths: - source = self._grab_device(path) - if source is None: - # this path doesn't need to be grabbed for injection, because - # it doesn't provide the events needed to execute the preset + try: + devices.append(evdev.InputDevice(path)) + except (FileNotFoundError, OSError): + logger.error('Could not find "%s"', path) continue - sources.append(source) - return sources + # find all devices which have an associated mapping + needed_devices = ( + {} + ) # use a dict because the InputDevice is not directly hashable + for mapping in self.preset: + candidates: List[evdev.InputDevice] = [ + device + for device in devices + if is_in_capabilities( + mapping.event_combination, device.capabilities(absinfo=False) + ) + ] + if len(candidates) > 1: + # there is more than on input device which can be used for this mapping + # we choose only one determined by the ranking + device = sorted(candidates, key=lambda d: ranking.index(classify(d)))[0] + elif len(candidates) == 1: + device = candidates.pop() + else: + logger.error("Could not find input for %s", mapping) + continue + needed_devices[device.path] = device - def _grab_device(self, path: os.PathLike) -> Optional[evdev.InputDevice]: - """Try to grab the device, return None if not needed/possible. + grabbed_devices = [] + for device in needed_devices.values(): + if device := self._grab_device(device): + grabbed_devices.append(device) + return grabbed_devices + + 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. """ - try: - device = evdev.InputDevice(path) - except (FileNotFoundError, OSError): - logger.error('Could not find "%s"', path) - return None - - capabilities = device.capabilities(absinfo=False) - - needed = False - for mapping in self.context.preset: - if is_in_capabilities(mapping.event_combination, capabilities): - logger.debug( - 'Grabbing "%s" because of "%s"', - path, - mapping.event_combination, - ) - needed = True - break - - if not needed: - # skipping reading and checking on events from those devices - # may be beneficial for performance. - logger.debug("No need to grab %s", path) - return None - - attempts = 0 - while True: + error = None + for attempt in range(10): try: device.grab() - logger.debug("Grab %s", path) - break - except IOError as error: - attempts += 1 - + 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. - logger.debug("Failed attempts to grab %s: %d", path, attempts) - - if attempts >= 10: - logger.error("Cannot grab %s, it is possibly in use", path) - logger.error(str(error)) - return None - - time.sleep(self.regrab_timeout) + error = err + logger.debug("Failed attempts to grab %s: %d", device.path, attempt + 1) + time.sleep(self.regrab_timeout) - return device + logger.error("Cannot grab %s, it is possibly in use", device.path) + logger.error(str(error)) + return None def _copy_capabilities(self, input_device: evdev.InputDevice) -> CapabilitiesDict: """Copy capabilities for a new device.""" @@ -274,6 +291,7 @@ class Injector(multiprocessing.Process): # stop the event loop and cause the process to reach its end # cleanly. Using .terminate prevents coverage from working. loop.stop() + self._msg_pipe[0].send(STOPPED) return def run(self) -> None: diff --git a/inputremapper/injection/macros/macro.py b/inputremapper/injection/macros/macro.py index af69305f..407e64d4 100644 --- a/inputremapper/injection/macros/macro.py +++ b/inputremapper/injection/macros/macro.py @@ -39,9 +39,8 @@ import asyncio import copy import math import re -from typing import Optional, List, Callable, Awaitable, Tuple +from typing import List, Callable, Awaitable, Tuple -import evdev from evdev.ecodes import ( ecodes, EV_KEY, @@ -53,10 +52,11 @@ from evdev.ecodes import ( REL_WHEEL, REL_HWHEEL, ) -from inputremapper.logger import logger + from inputremapper.configs.system_mapping import system_mapping -from inputremapper.ipc.shared_dict import SharedDict from inputremapper.exceptions import MacroParsingError +from inputremapper.ipc.shared_dict import SharedDict +from inputremapper.logger import logger Handler = Callable[[Tuple[int, int, int]], None] MacroTask = Callable[[Handler], Awaitable] diff --git a/inputremapper/injection/macros/parse.py b/inputremapper/injection/macros/parse.py index 734956d4..87328f99 100644 --- a/inputremapper/injection/macros/parse.py +++ b/inputremapper/injection/macros/parse.py @@ -22,13 +22,12 @@ """Parse macro code""" -import re -import traceback import inspect +import re -from inputremapper.logger import logger -from inputremapper.injection.macros.macro import Macro, Variable from inputremapper.exceptions import MacroParsingError +from inputremapper.injection.macros.macro import Macro, Variable +from inputremapper.logger import logger def is_this_a_macro(output): diff --git a/inputremapper/injection/mapping_handlers/abs_to_btn_handler.py b/inputremapper/injection/mapping_handlers/abs_to_btn_handler.py index 52c08de3..17b3e96e 100644 --- a/inputremapper/injection/mapping_handlers/abs_to_btn_handler.py +++ b/inputremapper/injection/mapping_handlers/abs_to_btn_handler.py @@ -19,19 +19,18 @@ # along with input-remapper. If not, see . -import evdev from typing import Tuple +import evdev from evdev.ecodes import EV_ABS from inputremapper.configs.mapping import Mapping from inputremapper.event_combination import EventCombination -from inputremapper.logger import logger -from inputremapper.input_event import InputEvent, EventActions from inputremapper.injection.mapping_handlers.mapping_handler import ( MappingHandler, InputEventHandler, ) +from inputremapper.input_event import InputEvent, EventActions class AbsToBtnHandler(MappingHandler): @@ -100,21 +99,20 @@ class AbsToBtnHandler(MappingHandler): value = event.value if (value < threshold > mid_point) or (value > threshold < mid_point): if self._active: - event = event.modify(value=0, action=EventActions.as_key) + event = event.modify(value=0, actions=(EventActions.as_key,)) else: # consume the event. # We could return False to forward events return True else: - if not self._active: - event = event.modify(value=1, action=EventActions.as_key) + if value >= threshold > mid_point: + direction = EventActions.positive_trigger else: - # consume the event. - # We could return False to forward events - return True + direction = EventActions.negative_trigger + event = event.modify(value=1, actions=(EventActions.as_key, direction)) self._active = bool(event.value) - logger.debug_key(event.event_tuple, "sending to sub_handler") + # logger.debug_key(event.event_tuple, "sending to sub_handler") return self._sub_handler.notify( event, source=source, diff --git a/inputremapper/injection/mapping_handlers/abs_to_rel_handler.py b/inputremapper/injection/mapping_handlers/abs_to_rel_handler.py index c9e080ed..1ab6c018 100644 --- a/inputremapper/injection/mapping_handlers/abs_to_rel_handler.py +++ b/inputremapper/injection/mapping_handlers/abs_to_rel_handler.py @@ -17,14 +17,13 @@ # # You should have received a copy of the GNU General Public License # along with input-remapper. If not, see . -from functools import partial - -import evdev -import time import asyncio import math +import time +from functools import partial +from typing import Dict, Tuple, Optional -from typing import Dict, Tuple, Optional, List, Union +import evdev from evdev.ecodes import ( EV_REL, EV_ABS, @@ -35,16 +34,16 @@ from evdev.ecodes import ( ) from inputremapper.configs.mapping import Mapping +from inputremapper.event_combination import EventCombination +from inputremapper.injection.global_uinputs import global_uinputs +from inputremapper.injection.mapping_handlers.axis_transform import Transformation from inputremapper.injection.mapping_handlers.mapping_handler import ( MappingHandler, HandlerEnums, InputEventHandler, ) -from inputremapper.injection.mapping_handlers.axis_transform import Transformation -from inputremapper.logger import logger -from inputremapper.event_combination import EventCombination from inputremapper.input_event import InputEvent, EventActions -from inputremapper.injection.global_uinputs import global_uinputs +from inputremapper.logger import logger async def _run_normal(self) -> None: @@ -166,7 +165,7 @@ class AbsToRelHandler(MappingHandler): if event.type_and_code != self._map_axis: return False - if event.action == EventActions.recenter: + if EventActions.recenter in event.actions: self._stop = True return True diff --git a/inputremapper/injection/mapping_handlers/axis_switch_handler.py b/inputremapper/injection/mapping_handlers/axis_switch_handler.py index 7e7c0d4f..6deab9f1 100644 --- a/inputremapper/injection/mapping_handlers/axis_switch_handler.py +++ b/inputremapper/injection/mapping_handlers/axis_switch_handler.py @@ -21,16 +21,15 @@ from typing import Dict, Tuple import evdev -from inputremapper.logger import logger from inputremapper.configs.mapping import Mapping from inputremapper.event_combination import EventCombination from inputremapper.injection.mapping_handlers.mapping_handler import ( MappingHandler, - ContextProtocol, HandlerEnums, InputEventHandler, ) from inputremapper.input_event import InputEvent, EventActions +from inputremapper.logger import logger class AxisSwitchHandler(MappingHandler): @@ -97,7 +96,7 @@ class AxisSwitchHandler(MappingHandler): return False self._active = bool(event.value) - if not self._active: + if not self._active and self._axis_source: # recenter the axis logger.debug_key(self.mapping.event_combination, "stopping axis") event = InputEvent( @@ -105,10 +104,10 @@ class AxisSwitchHandler(MappingHandler): 0, *self._map_axis, 0, - action=EventActions.recenter, + actions=(EventActions.recenter,), ) self._sub_handler.notify(event, self._axis_source, self._forward_device) - elif self._map_axis[0] == evdev.ecodes.EV_ABS: + elif self._map_axis[0] == evdev.ecodes.EV_ABS and self._axis_source: # send the last cached value so that the abs axis # is at the correct position logger.debug_key(self.mapping.event_combination, "starting axis") diff --git a/inputremapper/injection/mapping_handlers/combination_handler.py b/inputremapper/injection/mapping_handlers/combination_handler.py index ccee67c9..2437bed4 100644 --- a/inputremapper/injection/mapping_handlers/combination_handler.py +++ b/inputremapper/injection/mapping_handlers/combination_handler.py @@ -17,23 +17,21 @@ # # You should have received a copy of the GNU General Public License # along with input-remapper. If not, see . -import asyncio -import evdev +from typing import Dict, Tuple -from typing import Dict, Tuple, Optional, List -from evdev.ecodes import EV_ABS, EV_REL, EV_KEY +import evdev +from evdev.ecodes import EV_ABS, EV_REL from inputremapper.configs.mapping import Mapping -from inputremapper.input_event import InputEvent, EventActions from inputremapper.event_combination import EventCombination -from inputremapper.logger import logger from inputremapper.injection.mapping_handlers.mapping_handler import ( - ContextProtocol, MappingHandler, InputEventHandler, HandlerEnums, ) +from inputremapper.input_event import InputEvent +from inputremapper.logger import logger class CombinationHandler(MappingHandler): @@ -100,10 +98,13 @@ class CombinationHandler(MappingHandler): self.forward_release(forward) event = event.modify(value=1) else: - if self._output_state: + if self._output_state or self.mapping.is_axis_mapping(): # we ignore the supress argument for release events # otherwise we might end up with stuck keys # (test_event_pipeline.test_combination) + + # we also ignore it if the mapping specifies an output axis + # this will enable us to activate multiple axis with the same button supress = False event = event.modify(value=0) @@ -131,13 +132,10 @@ class CombinationHandler(MappingHandler): this might cause duplicate key-up events but those are ignored by evdev anyway """ - if ( - len(self.mapping.event_combination) == 1 - or not self.mapping.release_combination_keys - ): + if len(self._pressed_keys) == 1 or not self.mapping.release_combination_keys: return - for event in self.mapping.event_combination: - forward.write(*event.type_and_code, 0) + for type_and_code in self._pressed_keys: + forward.write(*type_and_code, 0) forward.syn() def needs_ranking(self) -> bool: diff --git a/inputremapper/injection/mapping_handlers/hierarchy_handler.py b/inputremapper/injection/mapping_handlers/hierarchy_handler.py index a8737782..1ef0f470 100644 --- a/inputremapper/injection/mapping_handlers/hierarchy_handler.py +++ b/inputremapper/injection/mapping_handlers/hierarchy_handler.py @@ -17,20 +17,18 @@ # # You should have received a copy of the GNU General Public License # along with input-remapper. If not, see . -import asyncio -import evdev +from typing import List, Dict +import evdev from evdev.ecodes import EV_ABS, EV_REL -from typing import List, Dict from inputremapper.event_combination import EventCombination - -from inputremapper.input_event import InputEvent from inputremapper.injection.mapping_handlers.mapping_handler import ( MappingHandler, InputEventHandler, HandlerEnums, ) +from inputremapper.input_event import InputEvent class HierarchyHandler(MappingHandler): diff --git a/inputremapper/injection/mapping_handlers/key_handler.py b/inputremapper/injection/mapping_handlers/key_handler.py index 062517bc..cf7599fe 100644 --- a/inputremapper/injection/mapping_handlers/key_handler.py +++ b/inputremapper/injection/mapping_handlers/key_handler.py @@ -18,20 +18,19 @@ # You should have received a copy of the GNU General Public License # along with input-remapper. If not, see . -from typing import Tuple, Dict, Optional +from typing import Tuple, Dict from inputremapper import exceptions from inputremapper.configs.mapping import Mapping from inputremapper.event_combination import EventCombination from inputremapper.exceptions import MappingParsingError +from inputremapper.injection.global_uinputs import global_uinputs from inputremapper.injection.mapping_handlers.mapping_handler import ( MappingHandler, - ContextProtocol, HandlerEnums, ) -from inputremapper.logger import logger from inputremapper.input_event import InputEvent -from inputremapper.injection.global_uinputs import global_uinputs +from inputremapper.logger import logger class KeyHandler(MappingHandler): diff --git a/inputremapper/injection/mapping_handlers/macro_handler.py b/inputremapper/injection/mapping_handlers/macro_handler.py index 42761781..5f71ba23 100644 --- a/inputremapper/injection/mapping_handlers/macro_handler.py +++ b/inputremapper/injection/mapping_handlers/macro_handler.py @@ -18,21 +18,20 @@ # You should have received a copy of the GNU General Public License # along with input-remapper. If not, see . import asyncio - -from typing import Dict, Optional +from typing import Dict from inputremapper.configs.mapping import Mapping from inputremapper.event_combination import EventCombination -from inputremapper.logger import logger -from inputremapper.input_event import InputEvent from inputremapper.injection.global_uinputs import global_uinputs -from inputremapper.injection.macros.parse import parse from inputremapper.injection.macros.macro import Macro +from inputremapper.injection.macros.parse import parse from inputremapper.injection.mapping_handlers.mapping_handler import ( ContextProtocol, MappingHandler, HandlerEnums, ) +from inputremapper.input_event import InputEvent +from inputremapper.logger import logger class MacroHandler(MappingHandler): diff --git a/inputremapper/injection/mapping_handlers/mapping_handler.py b/inputremapper/injection/mapping_handlers/mapping_handler.py index 520767eb..1b79eb41 100644 --- a/inputremapper/injection/mapping_handlers/mapping_handler.py +++ b/inputremapper/injection/mapping_handlers/mapping_handler.py @@ -19,7 +19,6 @@ # 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: @@ -53,6 +52,7 @@ Step 1 and 2: Step 1, 2 and 3: - AbsToRelHandler + - NullHandler Step 2 and 3: - KeyHandler @@ -61,15 +61,14 @@ Step 2 and 3: from __future__ import annotations import enum +from typing import Dict, Protocol, Set, Optional, List import evdev -from typing import Dict, Protocol, Set, Optional, List from inputremapper.configs.mapping import Mapping -from inputremapper.configs.preset import Preset +from inputremapper.event_combination import EventCombination from inputremapper.exceptions import MappingParsingError from inputremapper.input_event import InputEvent, EventActions -from inputremapper.event_combination import EventCombination from inputremapper.logger import logger @@ -81,10 +80,25 @@ class EventListener(Protocol): class ContextProtocol(Protocol): """The parts from context needed for macros.""" - preset: Preset listeners: Set[EventListener] +class NotifyCallback(Protocol): + """Type signature of InputEventHandler.notify + + return True if the event was actually taken care of + """ + + def __call__( + self, + event: InputEvent, + source: evdev.InputDevice, + forward: evdev.UInput, + supress: bool = False, + ) -> bool: + ... + + class InputEventHandler(Protocol): """The protocol any handler, which can be part of an event pipeline, must follow.""" @@ -126,7 +140,7 @@ class HandlerEnums(enum.Enum): disable = enum.auto() -class MappingHandler(InputEventHandler): +class MappingHandler: """The protocol an InputEventHandler must follow if it should be dynamically integrated in an event-pipeline by the mapping parser """ @@ -155,13 +169,27 @@ class MappingHandler(InputEventHandler): new_combination = [] for event in combination: if event.value != 0: - event = event.modify(action=EventActions.as_key) + event = event.modify(actions=(EventActions.as_key,)) new_combination.append(event) self.mapping = mapping self.input_events = new_combination self._sub_handler = None + def notify( + self, + event: InputEvent, + source: evdev.InputDevice, + forward: evdev.UInput, + supress: bool = False, + ) -> bool: + """notify this handler about an incoming event""" + 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 @@ -175,10 +203,10 @@ class MappingHandler(InputEventHandler): pass def wrap_with(self) -> Dict[EventCombination, HandlerEnums]: - """A dict of EventCombination -> HandlerEnums.""" - # this handler should be wrapped with the MappingHandler corresponding - # to the HandlerEnums, and the EventCombination as first argument - # TODO: better explanation + """A dict of EventCombination -> HandlerEnums. + + for each EventCombination this handler should be wrapped + with the given MappingHandler""" return {} def set_sub_handler(self, handler: InputEventHandler) -> None: diff --git a/inputremapper/injection/mapping_handlers/mapping_parser.py b/inputremapper/injection/mapping_handlers/mapping_parser.py index 73b0cc11..ee01a604 100644 --- a/inputremapper/injection/mapping_handlers/mapping_parser.py +++ b/inputremapper/injection/mapping_handlers/mapping_parser.py @@ -18,55 +18,57 @@ # You should have received a copy of the GNU General Public License # along with input-remapper. If not, see . """Functions to assemble the mapping handlers""" - +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.exceptions import MappingParsingError -from inputremapper.logger import logger +from inputremapper.configs.mapping import Mapping +from inputremapper.configs.preset import Preset +from inputremapper.configs.system_mapping import DISABLE_CODE, DISABLE_NAME from inputremapper.event_combination import EventCombination -from inputremapper.input_event import InputEvent -from inputremapper.injection.mapping_handlers.mapping_handler import ( - HandlerEnums, - MappingHandler, - ContextProtocol, +from inputremapper.exceptions import MappingParsingError +from inputremapper.injection.macros.parse import is_this_a_macro +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.abs_to_btn_handler import AbsToBtnHandler -from inputremapper.injection.mapping_handlers.rel_to_btn_handler import RelToBtnHandler -from inputremapper.injection.mapping_handlers.abs_to_rel_handler import AbsToRelHandler -from inputremapper.injection.mapping_handlers.macro_handler import MacroHandler from inputremapper.injection.mapping_handlers.key_handler import KeyHandler -from inputremapper.injection.mapping_handlers.axis_switch_handler import ( - AxisSwitchHandler, +from inputremapper.injection.mapping_handlers.macro_handler import MacroHandler +from inputremapper.injection.mapping_handlers.mapping_handler import ( + HandlerEnums, + MappingHandler, + ContextProtocol, + InputEventHandler, ) from inputremapper.injection.mapping_handlers.null_handler import NullHandler -from inputremapper.injection.macros.parse import is_this_a_macro -from inputremapper.configs.preset import Preset -from inputremapper.configs.mapping import Mapping -from inputremapper.configs.system_mapping import DISABLE_CODE, DISABLE_NAME +from inputremapper.injection.mapping_handlers.rel_to_btn_handler import RelToBtnHandler +from inputremapper.input_event import InputEvent +from inputremapper.logger import logger -EventPipelines = Dict[InputEvent, List[MappingHandler]] +EventPipelines = Dict[InputEvent, Set[InputEventHandler]] -mapping_handler_classes: Dict[HandlerEnums, Type[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, # type: ignore - HandlerEnums.rel2rel: None, # type: ignore + HandlerEnums.btn2rel: None, # can be a macro + HandlerEnums.rel2rel: None, HandlerEnums.abs2rel: AbsToRelHandler, - HandlerEnums.btn2abs: None, # type: ignore - HandlerEnums.rel2abs: None, # type: ignore - HandlerEnums.abs2abs: None, # type: ignore + HandlerEnums.btn2abs: None, # can be a macro + HandlerEnums.rel2abs: None, + HandlerEnums.abs2abs: None, HandlerEnums.combination: CombinationHandler, HandlerEnums.hierarchy: HierarchyHandler, HandlerEnums.axisswitch: AxisSwitchHandler, @@ -84,9 +86,12 @@ def parse_mappings(preset: Preset, context: ContextProtocol) -> EventPipelines: handler_enum = _get_output_handler(mapping) constructor = mapping_handler_classes[handler_enum] if not constructor: - raise NotImplementedError( - f"mapping handler {handler_enum} is not implemented" + logger.warning( + "a mapping handler '%s' for %s is not implemented", + handler_enum, + mapping.name or mapping.event_combination.beautify(), ) + continue output_handler = constructor( mapping.event_combination, @@ -99,7 +104,7 @@ def parse_mappings(preset: Preset, context: ContextProtocol) -> EventPipelines: handlers.extend(_create_event_pipeline(output_handler, context)) # figure out which handlers need ranking and wrap them with hierarchy_handlers - need_ranking = {} + need_ranking = defaultdict(set) for handler in handlers.copy(): if handler.needs_ranking(): combination = handler.rank_by() @@ -109,7 +114,7 @@ def parse_mappings(preset: Preset, context: ContextProtocol) -> EventPipelines: f"return a combination to rank by", mapping_handler=handler, ) - need_ranking[combination] = handler + need_ranking[combination].add(handler) handlers.remove(handler) # the HierarchyHandler's might not be the starting point of the event pipeline @@ -120,18 +125,13 @@ def parse_mappings(preset: Preset, context: ContextProtocol) -> EventPipelines: # 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 = {} + event_pipelines: EventPipelines = defaultdict(set) for handler in handlers: assert handler.input_events for event in handler.input_events: - if event in event_pipelines.keys(): - logger.debug("event-pipeline with entry point: %s", event.type_and_code) - logger.debug_mapping_handler(handler) - event_pipelines[event].append(handler) - else: - logger.debug("event-pipeline with entry point: %s", event.type_and_code) - logger.debug_mapping_handler(handler) - event_pipelines[event] = [handler] + logger.debug("event-pipeline with entry point: %s", event.type_and_code) + logger.debug_mapping_handler(handler) + event_pipelines[event].add(handler) return event_pipelines @@ -223,7 +223,7 @@ def _maps_axis(combination: EventCombination) -> Optional[InputEvent]: def _create_hierarchy_handlers( - handlers: Dict[EventCombination, MappingHandler] + handlers: Dict[EventCombination, Set[MappingHandler]] ) -> Set[MappingHandler]: """Sort handlers by input events and create Hierarchy handlers.""" sorted_handlers = set() @@ -244,15 +244,15 @@ def _create_hierarchy_handlers( if len(combinations_with_event) == 1: # there was only one handler containing that event return it as is - sorted_handlers.add(handlers[combinations_with_event[0]]) + sorted_handlers.update(handlers[combinations_with_event[0]]) continue # there are multiple handler with the same event. # rank them and create the HierarchyHandler sorted_combinations = _order_combinations(combinations_with_event, event) - sub_handlers = [] + sub_handlers: List[MappingHandler] = [] for combination in sorted_combinations: - sub_handlers.append(handlers[combination]) + sub_handlers.append(*handlers[combination]) sorted_handlers.add(HierarchyHandler(sub_handlers, event)) for handler in sub_handlers: diff --git a/inputremapper/injection/mapping_handlers/null_handler.py b/inputremapper/injection/mapping_handlers/null_handler.py index 93efaa32..0670bb4c 100644 --- a/inputremapper/injection/mapping_handlers/null_handler.py +++ b/inputremapper/injection/mapping_handlers/null_handler.py @@ -18,17 +18,16 @@ # You should have received a copy of the GNU General Public License # along with input-remapper. If not, see . -import evdev -from typing import Optional, Dict +from typing import Dict -from evdev.ecodes import EV_KEY +import evdev from inputremapper.event_combination import EventCombination -from inputremapper.input_event import InputEvent from inputremapper.injection.mapping_handlers.mapping_handler import ( MappingHandler, HandlerEnums, ) +from inputremapper.input_event import InputEvent class NullHandler(MappingHandler): diff --git a/inputremapper/injection/mapping_handlers/rel_to_btn_handler.py b/inputremapper/injection/mapping_handlers/rel_to_btn_handler.py index 50a5ee63..d23fb83f 100644 --- a/inputremapper/injection/mapping_handlers/rel_to_btn_handler.py +++ b/inputremapper/injection/mapping_handlers/rel_to_btn_handler.py @@ -18,23 +18,20 @@ # You should have received a copy of the GNU General Public License # along with input-remapper. If not, see . -import evdev -import time import asyncio +import time -from typing import Optional, Dict +import evdev from evdev.ecodes import EV_REL from inputremapper.configs.mapping import Mapping -from inputremapper.logger import logger -from inputremapper.input_event import InputEvent, EventActions from inputremapper.event_combination import EventCombination from inputremapper.injection.mapping_handlers.mapping_handler import ( MappingHandler, - ContextProtocol, - HandlerEnums, InputEventHandler, ) +from inputremapper.input_event import InputEvent, EventActions +from inputremapper.logger import logger class RelToBtnHandler(MappingHandler): @@ -82,7 +79,7 @@ class RelToBtnHandler(MappingHandler): self._abort_release = False return - event = self._input_event.modify(value=0, action=EventActions.as_key) + event = self._input_event.modify(value=0, actions=(EventActions.as_key,)) logger.debug_key(event.event_tuple, "sending to sub_handler") self._sub_handler.notify(event, source, forward, supress) self._active = False @@ -103,25 +100,34 @@ class RelToBtnHandler(MappingHandler): 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 - event = event.modify(value=0, action=EventActions.as_key) + # the axis is below the threshold and the stage_release + # function is running + if self.mapping.force_release_timeout: + # consume the event + return True + event = event.modify(value=0, actions=(EventActions.as_key,)) logger.debug_key(event.event_tuple, "sending to sub_handler") self._abort_release = True - self._active = False - return self._sub_handler.notify(event, source, forward, supress) else: # don't consume the event. # We could return True to consume events return False - - # the axis is above the threshold - event = event.modify(value=1, action=EventActions.as_key) - self._last_activation = time.time() - if not self._active: - logger.debug_key(event.event_tuple, "sending to sub_handler") - asyncio.ensure_future(self._stage_release(source, forward, supress)) - self._active = True - return self._sub_handler.notify(event, source, forward, supress) + else: + # the axis is above the threshold + if not self._active: + asyncio.ensure_future(self._stage_release(source, forward, supress)) + if value >= threshold > 0: + direction = EventActions.positive_trigger + else: + direction = EventActions.negative_trigger + self._last_activation = time.time() + event = event.modify(value=1, actions=(EventActions.as_key, direction)) + + self._active = bool(event.value) + # logger.debug_key(event.event_tuple, "sending to sub_handler") + return self._sub_handler.notify( + event, source=source, forward=forward, supress=supress + ) def reset(self) -> None: if self._active: diff --git a/inputremapper/input_event.py b/inputremapper/input_event.py index afef0805..ee0d6bc7 100644 --- a/inputremapper/input_event.py +++ b/inputremapper/input_event.py @@ -20,14 +20,16 @@ from __future__ import annotations import enum +from dataclasses import dataclass +from typing import Tuple, Union, Sequence, Callable, Optional import evdev +from evdev import ecodes -from dataclasses import dataclass -from typing import Tuple, Union, Sequence, Callable - +from inputremapper.configs.system_mapping import system_mapping from inputremapper.exceptions import InputEventCreationError - +from inputremapper.gui.message_broker import MessageType +from inputremapper.logger import logger InputEventValidationType = Union[ str, @@ -39,10 +41,14 @@ InputEventValidationType = Union[ class EventActions(enum.Enum): """Additional information a InputEvent can send through the event pipeline""" - as_key = enum.auto() - recenter = enum.auto() + as_key = enum.auto() # treat this event as a key event + recenter = enum.auto() # recenter the axis when receiving this none = enum.auto() + # used in combination with as_key, for originally abs or rel events + positive_trigger = enum.auto() # original event was positive direction + negative_trigger = enum.auto() # original event was negative direction + # Todo: add slots=True as soon as python 3.10 is in common distros @dataclass(frozen=True) @@ -52,12 +58,14 @@ class InputEvent: as a drop in replacement for evdev.InputEvent """ + message_type = MessageType.selected_event + sec: int usec: int type: int code: int value: int - action: EventActions = EventActions.none + actions: Tuple[EventActions, ...] = () def __hash__(self): return hash((self.type, self.code, self.value)) @@ -161,15 +169,18 @@ class InputEvent: @property def is_key_event(self) -> bool: """Whether this is interpreted as a key event.""" - return self.type == evdev.ecodes.EV_KEY or self.action == EventActions.as_key + return self.type == evdev.ecodes.EV_KEY or EventActions.as_key in self.actions def __str__(self): - if self.type == evdev.ecodes.EV_KEY: - key_name = evdev.ecodes.bytype[self.type].get(self.code, "unknown") - action = "down" if self.value == 1 else "up" - return f"" - - return f"" + return f"InputEvent{self.event_tuple}" + + 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() if not exclude_threshold else ''}".strip() + ) def timestamp(self): """Return the unix timestamp of when the event was seen.""" @@ -182,7 +193,7 @@ class InputEvent: type: int = None, code: int = None, value: int = None, - action: EventActions = EventActions.none, + actions: Tuple[EventActions, ...] = None, ) -> InputEvent: """Return a new modified event.""" return InputEvent( @@ -191,8 +202,106 @@ class InputEvent: 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, - action if action is not EventActions.none else self.action, + actions if actions is not None else self.actions, ) def json_str(self) -> str: return ",".join([str(self.type), str(self.code), str(self.value)]) + + def get_name(self) -> Optional[str]: + """human-readable name""" + if self.type not in ecodes.bytype: + logger.error("Unknown type for %s", self) + return "unknown" + + if self.code not in ecodes.bytype[self.type]: + logger.error("Unknown code for %s", self) + return "unknown" + + key_name = None + + # first try to find the name in xmodmap to not display wrong + # names due to the keyboard layout + if self.type == ecodes.EV_KEY: + key_name = system_mapping.get_name(self.code) + + if key_name is None: + # if no result, look in the linux combination constants. On a german + # keyboard for example z and y are switched, which will therefore + # cause the wrong letter to be displayed. + key_name = ecodes.bytype[self.type][self.code] + if isinstance(key_name, list): + key_name = key_name[0] + + key_name = key_name.replace("ABS_Z", "Trigger Left") + key_name = key_name.replace("ABS_RZ", "Trigger Right") + + key_name = key_name.replace("ABS_HAT0X", "DPad-X") + key_name = key_name.replace("ABS_HAT0Y", "DPad-Y") + key_name = key_name.replace("ABS_HAT1X", "DPad-2-X") + key_name = key_name.replace("ABS_HAT1Y", "DPad-2-Y") + key_name = key_name.replace("ABS_HAT2X", "DPad-3-X") + key_name = key_name.replace("ABS_HAT2Y", "DPad-3-Y") + + key_name = key_name.replace("ABS_X", "Joystick-X") + key_name = key_name.replace("ABS_Y", "Joystick-Y") + key_name = key_name.replace("ABS_RX", "Joystick-RX") + key_name = key_name.replace("ABS_RY", "Joystick-RY") + + key_name = key_name.replace("BTN_", "Button ") + key_name = key_name.replace("KEY_", "") + + key_name = key_name.replace("REL_", "") + key_name = key_name.replace("HWHEEL", "Wheel") + key_name = key_name.replace("WHEEL", "Wheel") + + key_name = key_name.replace("_", " ") + key_name = key_name.replace(" ", " ") + return key_name + + def get_direction(self) -> str: + if self.type == ecodes.EV_KEY: + return "" + + try: + event = self.modify(value=self.value // abs(self.value)) + except ZeroDivisionError: + return "" + + 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((event.code, event.value)) or ("+" if event.value > 0 else "-") + + def get_threshold(self) -> str: + if self.value == 0: + return "" + return { + ecodes.EV_REL: f"{abs(self.value)}", + ecodes.EV_ABS: f"{abs(self.value)}%", + }.get(self.type) or "" diff --git a/inputremapper/ipc/pipe.py b/inputremapper/ipc/pipe.py index 83645e68..17e7662e 100644 --- a/inputremapper/ipc/pipe.py +++ b/inputremapper/ipc/pipe.py @@ -35,14 +35,14 @@ Beware that pipes read any available messages, even those written by themselves. """ - - +import asyncio +import json import os import time -import json +from typing import Optional, AsyncIterator -from inputremapper.logger import logger from inputremapper.configs.paths import mkdir, chown +from inputremapper.logger import logger class Pipe: @@ -54,6 +54,9 @@ class Pipe: self._unread = [] self._created_at = time.time() + self._transport: Optional[asyncio.ReadTransport] = None + self._async_iterator: Optional[AsyncIterator] = None + paths = (f"{path}r", f"{path}w") mkdir(os.path.dirname(path)) @@ -93,6 +96,13 @@ class Pipe: 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. @@ -107,6 +117,9 @@ class Pipe: if len(line) == 0: return None + return self._get_msg(line) + + def _get_msg(self, line): parsed = json.loads(line) if parsed[0] < self._created_at and os.environ.get("UNITTEST"): # important to avoid race conditions between multiple unittests, @@ -143,3 +156,23 @@ class Pipe: 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 index 3b0b186c..46f87f43 100644 --- a/inputremapper/ipc/shared_dict.py +++ b/inputremapper/ipc/shared_dict.py @@ -22,8 +22,8 @@ """Share a dictionary across processes.""" -import multiprocessing import atexit +import multiprocessing import select from inputremapper.logger import logger diff --git a/inputremapper/ipc/socket.py b/inputremapper/ipc/socket.py index 35ab52ad..4df8deea 100644 --- a/inputremapper/ipc/socket.py +++ b/inputremapper/ipc/socket.py @@ -50,15 +50,14 @@ are much easier to handle. # by _Server all the time. +import json +import os import select import socket -import os import time -import json -from inputremapper.logger import logger from inputremapper.configs.paths import mkdir, chown - +from inputremapper.logger import logger # something funny that most likely won't appear in messages. # also add some ones so that 01 in the payload won't offset diff --git a/inputremapper/logger.py b/inputremapper/logger.py index 9ae88300..e8f6718e 100644 --- a/inputremapper/logger.py +++ b/inputremapper/logger.py @@ -22,18 +22,14 @@ """Logging setup for input-remapper.""" +import logging import os -import re import sys -import shutil import time -import logging +from datetime import datetime from typing import cast import pkg_resources -from datetime import datetime - -from inputremapper.user import HOME try: from inputremapper.commit_hash import COMMIT_HASH diff --git a/inputremapper/user.py b/inputremapper/user.py index 1b4e0582..a2b48a56 100644 --- a/inputremapper/user.py +++ b/inputremapper/user.py @@ -22,8 +22,8 @@ """Figure out the user.""" -import os import getpass +import os import pwd diff --git a/inputremapper/utils.py b/inputremapper/utils.py index 572fc587..f9cb06dc 100644 --- a/inputremapper/utils.py +++ b/inputremapper/utils.py @@ -39,8 +39,6 @@ from evdev.ecodes import ( ) from inputremapper.logger import logger -from inputremapper.configs.global_config import BUTTONS - # other events for ABS include buttons JOYSTICK = [ diff --git a/readme/plus.png b/readme/plus.png index 52cfe657d8b01a6bca6b9444a7dbeccb6e469a79..b580681f5ff8554b7a700eacc998f6097131c862 100644 GIT binary patch literal 7848 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 Nc^PHt3aPK({|kk1g*N~I literal 4754 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 a9KU;dxrCYOqOVIG0CnXjkE;})zWg7Kui?1> diff --git a/readme/screenshot.png b/readme/screenshot.png index c58ffdd5e9f07dd932103f2b6334feedd2323461..d41099832112c9de4236c207d8378b2ad3420e58 100644 GIT binary patch literal 60798 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 F@LxpyoOb{K literal 44200 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⩔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 Jf7bDje*losw^slF diff --git a/readme/screenshot_2.png b/readme/screenshot_2.png index a5eb18b951685e5e41f6a7b8780404a4839b06aa..bd5e71a3adb1568c6f1e0478a433f737a7488ac3 100644 GIT binary patch literal 63652 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 AAYq1y3v=y9G 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 MCmM>Cf0>8@~ literal 33878 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

y|eo{%BUO7H{J;wn=PFJ^df<{4pBQrbG z^f-yqqNNVo{kO}ORrpS`?$&2pD2p+q&^Nb8pbfsy23Q`cpnhsIHal^~ag z3^anV6F+Jg0qJjiuBD|D{{2$PzJB)@czHELiI}URxC#!}z6`kDC_z3ACUla%HC_z& z&MAt}l$HY2g$w#H!M2)CY}8G>ED6tR&cJ<@>j9;Bv%}AHR%)AIfpV3h_EzR}lbHAi z)5|!r-~NbAWOYpcLvn75dy*E=!Nl&H?7n?Li_0!}l`aB$<4dbx9TzqQ@hEBk1Xaw_ zts9rYm-s}M{w70$6Pba5-|F;y z@s{ET5h{{9`3~Hv2F5;f$f|!XO&WYmY_EOj`=&QzH)G|$NWPalA&?C+T4EayAIqKr{9R=7dteKBpcCeIBQPTtcP$cH*v zwP(!^%@@gtrd05T`ELF;34pr*5cp|MNe}@tn*1}xxBg8y z=CB$;E`v!%{FStdrIwJ}EAo?zytq>W8eDQ4cqM7}Bgp$9J|X$Sw(vfk%f9_Xtm+gk=sE1tYfp6I8CCxNAesdP|~n`V;&Ne0!|TSf?eF~MIqt)Yl1XJtxMB)iZ;qG;(31K ztDus6lKvr@X8THGFI>C+;epQ(`ffO>DB7)Clk9U8PTGWtl}zUXm>IDc8W@A~+cq+z z#Qz2hg5LUuYl{+mm-G++gQWdlczOZ;#&z1_bu!0mU^PjH(YHcw; z*90i1n*4#D|BQnJtOyyE9ma2q^B|ygtaEL-}Wjdw_k1^SS1h75{q<=sAF| z$8+Sy^%r4tKo|suj34_u`0;yGB$RmqAV`rT*!RoP{dWj5^v^ZCpauN@jtZhm0vrnxf;QVtVzhbf3pn9m*|dXPYs*Me`W>{7>o6}rnbQk z9rv#(Q`s5b|9fIA!~hrF?meLRiwU5G_DBED^4GvREWm>A<2-%!*IfTN3ICaCjSj%R zWpXjs|9l5LF_;v1>-K+dwXFqKx|=^}_iwWCx!eC*WU4uPfN!xbc=P{-(2)QRfBI`U z5Rj@wZOww9`tci0{O4hk(~nU9qQs{g*dRJ^VgJCtp7Xhe?e5={{67Z%;>Z6V2DWPL z0f9Rf+^D}7*>{bw^kFrRzULukzdf)g(jH>&V%5rz=Wzh84FHJc-zm(B^vxwo`weyc zvwS}FcK{$_We4Xe-+wm}2t;jtALXxa{;%8%eHjG|NJuDJQewg?|L*JynpYY z|MQZ`_mP_p#gqRbnFg5N+H)CCe{)0XcmD6L3_?eas$p9udLSVF`JXL)D}wN{P$+=Q zD^|_^(nDgO0U+vGE(D$MzjE%|tup4jNLKr=h6SNvwC;Y?Mmr)(|0{MpjG@7sm~+z% zxja%E+T3(H^oCKYsLz&K1Yeo8aFh6M-I%bX{jCkqlYD*R`7HR{jIZC{RL?6PSVfs? gQhHu@0&jl5k?eC~w2!XtM*{w&#O1|`Mc#h+U&h$1fdBvi diff --git a/readme/usage.md b/readme/usage.md index 00f39dd0..5ae3db70 100644 --- a/readme/usage.md +++ b/readme/usage.md @@ -10,17 +10,19 @@ You can also start it via `input-remapper-gtk`.

-First, select your device (like your keyboard) from the large dropdown on the top. -Then you can already edit your keys, as shown in the screenshots. +First, select your device (like your keyboard) from the large dropdown on the top, +and add a mapping. +Then you can already edit your inputs, as shown in the screenshots. In the text input field, type the key to which you would like to map this key. More information about the possible mappings can be found [below](#key-names). -Changes are saved automatically. Afterwards press the "Apply" button. +Changes are saved automatically. +Press the "Apply" button to activate (inject) the mapping you created. -To change the mapping, you need to use the "Stop Injection" button, so that -the application can read the original keycode. It would otherwise be -invisible since the daemon maps it independently of the GUI. +If you later want to modify the Input of your mapping you need to use the +"Stop Injection" 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 @@ -35,31 +37,22 @@ No injection should be running anymore. ## Combinations -Change the key of your mapping (`Change Key` - Button) and hold a few of your -device keys down. Releasing them will make your text cursor jump into the -mapping column to type in what you want to map it to. +You can use combinations of different inputs to trigger a mapping: While you recorde +the input (`Recorde Input` - Button) press multiple keys and/or move axis at once. +The mapping will be triggered as soon as all the recorded inputs are pressed. -Combinations involving Modifiers might not work. Configuring a combination -of two keys to output a single key will require you to push down the first -key, which of course ends up injecting that first key. Then the second key -will trigger the mapping, because the combination is complete. This is -not a bug. Otherwise every combination would have to automatically disable -all keys that are involved in it. +If you use an axis an input you can modify the threshold at which the mapping is +activated in the `Advanced Input Configuration`. -For example a combination of `LEFTSHIFT + a` for `b` would write "B" instead, -because shift will be activated before you hit the "a". Therefore the -environment will see shift and a "b", which will then be capitalized. - -Consider using a different key for the combination than shift. You could use -`KP1 + a` and map `KP1` to `disable`. - -The second option is to release the modifier in your combination by writing -the modifier one more time. This will write lowercase "b" characters. To make -this work shift has to be injected via key-mappers devices though, which just -means it has to be forwarded. So the complete mapping for this would look like: - -- `Shift L + a` -> `key(Shift_L).hold(b)` -- `Shift L` -> `Shift_L` +A mapping with an input combination is only injected once all combination keys +are pressed. This means all the input keys you press before the combination is complete +will be injected unmodified. In some cases this can be desirable, in others not. +In the `Advanced Input Configuration` is the `Release Input` toggle. +This will release all inputs which are part of the combination before the mapping is +injected. Consider a mapping `Shift+1 -> a` this will inject a lowercase `a` if the +toggle is on and an uppercase `A` if it is off. The exact behaviour if the toggle is off +is dependent on keys (are modifiers involved?), the order in which they are pressed and +on your environment (X11/Wayland). By default the toggle is on. ## Writing Combinations @@ -111,6 +104,18 @@ and it won't be able to inject anything a usb keyboard wouldn't been able to. Th 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 recorde the input axis. Then go to +`Advanced Input Configuration` 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 + # External tools Repositories listed here are made by input-remappers users. Feel free to extend. Beware, @@ -131,7 +136,7 @@ Note for the Beta branch: All configuration files are copied to: The default configuration is stored at `~/.config/input-remapper/config.json`, which doesn't include any mappings, but rather other parameters that -are interesting for injections. The current default configuration as of 1.5 +are interesting for injections. The current default configuration as of 1.6 looks like, with an example autoload entry: ```json @@ -139,7 +144,7 @@ looks like, with an example autoload entry: "autoload": { "Logitech USB Keyboard": "preset name" }, - "version": "1.5" + "version": "1.6" } ``` diff --git a/readme/usage_1.png b/readme/usage_1.png index 1eac2f9cd155b1f74a49ebcff0dd9f8cd07859b9..72e28260e6e0f9790eeccfe3890a9761f3d25aef 100644 GIT binary patch literal 26076 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 literal 8470 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 diff --git a/readme/usage_2.png b/readme/usage_2.png index c10427caeb31de81be11ca18ffd1e25deb65bf6e..037667feefc1a976333e264668f4eac7a5c6e008 100644 GIT binary patch literal 15111 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 literal 7196 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 M85QXtuS`Dv4*+Q&rvLx| diff --git a/scripts/ci-install-deps.sh b/scripts/ci-install-deps.sh index 6bb2a311..6ebb4528 100755 --- a/scripts/ci-install-deps.sh +++ b/scripts/ci-install-deps.sh @@ -4,7 +4,7 @@ set -xeuo pipefail # native deps # gettext required to generate translations, others are python deps -sudo apt-get install -y gettext python3-evdev python3-pydbus python3-pydantic +sudo apt-get install -y gettext python3-evdev python3-pydbus python3-pydantic python3-gi gir1.2-gtk-3.0 gir1.2-gtksource-4 # ensure pip and setuptools/wheel up to date so can install all pip modules python -m pip install --upgrade pip diff --git a/tests/integration/test_components.py b/tests/integration/test_components.py new file mode 100644 index 00000000..5ff56b0c --- /dev/null +++ b/tests/integration/test_components.py @@ -0,0 +1,1142 @@ +import unittest +from typing import Optional, Tuple +from unittest.mock import MagicMock, patch +from evdev.ecodes import EV_KEY, KEY_A, KEY_B, KEY_C + +import gi + +from inputremapper.input_event import InputEvent + +gi.require_version("Gtk", "3.0") +gi.require_version("GLib", "2.0") +gi.require_version("GtkSource", "4") +from gi.repository import Gtk, GLib, GtkSource + + +from tests.test import quick_cleanup, spy +from inputremapper.gui.utils import CTX_ERROR, CTX_WARNING, gtk_iteration +from inputremapper.gui.message_broker import ( + MessageBroker, + MessageType, + GroupData, + GroupsData, + UInputsData, + PresetData, + CombinationUpdate, + StatusData, +) +from inputremapper.groups import DeviceType +from inputremapper.gui.components import ( + DeviceSelection, + TargetSelection, + PresetSelection, + MappingListBox, + SelectionLabel, + CodeEditor, + RecordingToggle, + StatusBar, + AutoloadSwitch, + ReleaseCombinationSwitch, + CombinationListbox, + EventEntry, + AnalogInputSwitch, + TriggerThresholdInput, + ReleaseTimeoutInput, + OutputAxisSelector, + KeyAxisStack, + Sliders, + TransformationDrawArea, +) +from inputremapper.configs.mapping import MappingData +from inputremapper.event_combination import EventCombination + + +class ComponentBaseTest(unittest.TestCase): + """test a gui component + + ensures to tearDown self.gui + all gtk objects must be a child of self.gui in order to ensure proper cleanup""" + + def setUp(self) -> None: + self.message_broker = MessageBroker() + self.controller_mock = MagicMock() + self.gui = MagicMock() + + def tearDown(self) -> None: + super().tearDown() + self.message_broker.signal(MessageType.terminate) + GLib.timeout_add(0, self.gui.destroy) + GLib.timeout_add(0, Gtk.main_quit) + Gtk.main() + quick_cleanup() + + +class TestDeviceSelection(ComponentBaseTest): + def setUp(self) -> None: + super(TestDeviceSelection, self).setUp() + self.gui = Gtk.ComboBox() + self.selection = DeviceSelection( + self.message_broker, self.controller_mock, self.gui + ) + self.message_broker.send( + GroupsData( + { + "foo": [DeviceType.GAMEPAD, DeviceType.KEYBOARD], + "bar": [], + "baz": [DeviceType.GRAPHICS_TABLET], + } + ) + ) + + def test_populates_devices(self): + names = [row[0] for row in self.gui.get_model()] + self.assertEqual(names, ["foo", "bar", "baz"]) + icons = [row[1] for row in self.gui.get_model()] + self.assertEqual(icons, ["input-gaming", None, "input-tablet"]) + + self.message_broker.send( + GroupsData( + { + "kuu": [DeviceType.KEYBOARD], + "qux": [DeviceType.GAMEPAD], + } + ) + ) + names = [row[0] for row in self.gui.get_model()] + self.assertEqual(names, ["kuu", "qux"]) + icons = [row[1] for row in self.gui.get_model()] + self.assertEqual(icons, ["input-keyboard", "input-gaming"]) + + def test_selects_correct_device(self): + self.message_broker.send(GroupData("bar", ())) + self.assertEqual(self.gui.get_active_id(), "bar") + self.message_broker.send(GroupData("baz", ())) + self.assertEqual(self.gui.get_active_id(), "baz") + + def test_loads_group(self): + self.gui.set_active_id("bar") + self.controller_mock.load_group.assert_called_once_with("bar") + + def test_avoids_infinite_recursion(self): + self.message_broker.send(GroupData("bar", ())) + self.controller_mock.load_group.assert_not_called() + + +class TestTargetSelection(ComponentBaseTest): + def setUp(self) -> None: + super(TestTargetSelection, self).setUp() + self.gui = Gtk.ComboBox() + self.selection = TargetSelection( + self.message_broker, self.controller_mock, self.gui + ) + self.message_broker.send( + 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.send( + UInputsData( + { + "kuu": {}, + "qux": {}, + } + ) + ) + names = [row[0] for row in self.gui.get_model()] + self.assertEqual(names, ["kuu", "qux"]) + + def test_updates_mapping(self): + self.gui.set_active_id("baz") + self.controller_mock.update_mapping.called_once_with(target_uinput="baz") + + def test_selects_correct_target(self): + self.message_broker.send(MappingData(target_uinput="baz")) + self.assertEqual(self.gui.get_active_id(), "baz") + self.message_broker.send(MappingData(target_uinput="bar")) + self.assertEqual(self.gui.get_active_id(), "bar") + + def test_avoids_infinite_recursion(self): + self.message_broker.send(MappingData(target_uinput="baz")) + self.controller_mock.update_mapping.assert_not_called() + + def test_disabled_with_invalid_mapping(self): + self.controller_mock.is_empty_mapping.return_value = True + self.message_broker.send(MappingData()) + self.assertFalse(self.gui.get_sensitive()) + self.assertLess(self.gui.get_opacity(), 0.8) + + def test_enabled_with_valid_mapping(self): + self.controller_mock.is_empty_mapping.return_value = False + self.message_broker.send(MappingData()) + self.assertTrue(self.gui.get_sensitive()) + self.assertEqual(self.gui.get_opacity(), 1) + + +class TestPresetSelection(ComponentBaseTest): + def setUp(self) -> None: + super().setUp() + self.gui = Gtk.ComboBoxText() + self.selection = PresetSelection( + self.message_broker, self.controller_mock, self.gui + ) + self.message_broker.send(GroupData("foo", ("preset1", "preset2"))) + + def test_populates_presets(self): + names = [row[0] for row in self.gui.get_model()] + self.assertEqual(names, ["preset1", "preset2"]) + self.message_broker.send(GroupData("foo", ("preset3", "preset4"))) + names = [row[0] for row in self.gui.get_model()] + self.assertEqual(names, ["preset3", "preset4"]) + + def test_selects_preset(self): + self.message_broker.send( + PresetData("preset2", (("m1", EventCombination((1, 2, 3))),)) + ) + self.assertEqual(self.gui.get_active_id(), "preset2") + self.message_broker.send( + PresetData("preset1", (("m1", EventCombination((1, 2, 3))),)) + ) + self.assertEqual(self.gui.get_active_id(), "preset1") + + def test_avoids_infinite_recursion(self): + self.message_broker.send( + PresetData("preset2", (("m1", EventCombination((1, 2, 3))),)) + ) + self.controller_mock.load_preset.assert_not_called() + + def test_loads_preset(self): + self.gui.set_active_id("preset2") + self.controller_mock.load_preset.assert_called_once_with("preset2") + + +class TestMappingListbox(ComponentBaseTest): + def setUp(self) -> None: + super().setUp() + self.gui = Gtk.ListBox() + self.listbox = MappingListBox( + self.message_broker, self.controller_mock, self.gui + ) + + self.message_broker.send( + PresetData( + "preset1", + ( + ("mapping1", EventCombination((1, KEY_C, 1))), + ("", EventCombination([(1, KEY_A, 1), (1, KEY_B, 1)])), + ("mapping2", EventCombination((1, KEY_B, 1))), + ), + ) + ) + + def get_selected_row(self) -> SelectionLabel: + row = None + + def find_row(r: SelectionLabel): + nonlocal row + if r.is_selected(): + row = r + + self.gui.foreach(find_row) + assert row is not None + return row + + def select_row(self, combination: EventCombination): + def select(row: SelectionLabel): + if row.combination == combination: + self.gui.select_row(row) + + self.gui.foreach(select) + + 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.send( + MappingData( + name="mapping1", event_combination=EventCombination((1, KEY_C, 1)) + ) + ) + selected = self.get_selected_row() + self.assertEqual(selected.name, "mapping1") + self.assertEqual(selected.combination, EventCombination((1, KEY_C, 1))) + + def test_loads_mapping(self): + self.select_row(EventCombination((1, KEY_B, 1))) + self.controller_mock.load_mapping.assert_called_once_with( + EventCombination((1, KEY_B, 1)) + ) + + def test_avoids_infinite_recursion(self): + self.message_broker.send( + MappingData( + name="mapping1", event_combination=EventCombination((1, KEY_C, 1)) + ) + ) + self.controller_mock.load_mapping.assert_not_called() + + def test_sorts_empty_mapping_to_bottom(self): + self.message_broker.send( + PresetData( + "preset1", + ( + ("qux", EventCombination((1, KEY_C, 1))), + ("foo", EventCombination.empty_combination()), + ("bar", EventCombination((1, KEY_B, 1))), + ), + ) + ) + bottom_row: SelectionLabel = self.gui.get_row_at_index(2) + self.assertEqual(bottom_row.combination, EventCombination.empty_combination()) + self.message_broker.send( + PresetData( + "preset1", + ( + ("foo", EventCombination.empty_combination()), + ("qux", EventCombination((1, KEY_C, 1))), + ("bar", EventCombination((1, KEY_B, 1))), + ), + ) + ) + bottom_row: SelectionLabel = self.gui.get_row_at_index(2) + self.assertEqual(bottom_row.combination, EventCombination.empty_combination()) + + +class TestSelectionLabel(ComponentBaseTest): + def setUp(self) -> None: + super().setUp() + self.gui = Gtk.ListBox() + self.label = SelectionLabel( + self.message_broker, + self.controller_mock, + "", + EventCombination([(1, KEY_A, 1), (1, KEY_B, 1)]), + ) + self.gui.insert(self.label, -1) + + def test_shows_combination_without_name(self): + self.assertEqual(self.label.label.get_label(), "a + b") + + def test_shows_name_when_given(self): + self.gui = SelectionLabel( + self.message_broker, + self.controller_mock, + "foo", + EventCombination([(1, KEY_A, 1), (1, KEY_B, 1)]), + ) + self.assertEqual(self.gui.label.get_label(), "foo") + + def test_updates_combination_when_selected(self): + self.gui.select_row(self.label) + self.assertEqual( + self.label.combination, EventCombination([(1, KEY_A, 1), (1, KEY_B, 1)]) + ) + self.message_broker.send( + CombinationUpdate( + EventCombination([(1, KEY_A, 1), (1, KEY_B, 1)]), + EventCombination((1, KEY_A, 1)), + ) + ) + self.assertEqual(self.label.combination, EventCombination((1, KEY_A, 1))) + + def test_doesnt_update_combination_when_not_selected(self): + self.assertEqual( + self.label.combination, EventCombination([(1, KEY_A, 1), (1, KEY_B, 1)]) + ) + self.message_broker.send( + CombinationUpdate( + EventCombination([(1, KEY_A, 1), (1, KEY_B, 1)]), + EventCombination((1, KEY_A, 1)), + ) + ) + self.assertEqual( + self.label.combination, EventCombination([(1, KEY_A, 1), (1, KEY_B, 1)]) + ) + + def test_updates_name_when_mapping_changed_and_combination_matches(self): + self.message_broker.send( + MappingData( + event_combination=EventCombination([(1, KEY_A, 1), (1, KEY_B, 1)]), + name="foo", + ) + ) + self.assertEqual(self.label.label.get_label(), "foo") + + def test_ignores_mapping_when_combination_does_not_match(self): + self.message_broker.send( + MappingData( + event_combination=EventCombination([(1, KEY_A, 1), (1, KEY_C, 1)]), + name="foo", + ) + ) + self.assertEqual(self.label.label.get_label(), "a + b") + + def test_edit_button_visibility(self): + # start off invisible + self.assertFalse(self.label.edit_btn.get_visible()) + + # load the mapping associated with the ListBoxRow + self.message_broker.send( + MappingData( + event_combination=EventCombination([(1, KEY_A, 1), (1, KEY_B, 1)]), + ) + ) + self.assertTrue(self.label.edit_btn.get_visible()) + + # load a different row + self.message_broker.send( + MappingData( + event_combination=EventCombination([(1, KEY_A, 1), (1, KEY_C, 1)]), + ) + ) + self.assertFalse(self.label.edit_btn.get_visible()) + + def test_enter_edit_mode_focuses_name_input(self): + self.message_broker.send( + MappingData( + event_combination=EventCombination([(1, KEY_A, 1), (1, KEY_B, 1)]), + ) + ) + self.label.edit_btn.clicked() + self.controller_mock.set_focus.assert_called_once_with(self.label.name_input) + + def test_enter_edit_mode_updates_visibility(self): + self.message_broker.send( + MappingData( + event_combination=EventCombination([(1, KEY_A, 1), (1, KEY_B, 1)]), + ) + ) + + self.assertTrue(self.label.label.get_visible()) + self.assertFalse(self.label.name_input.get_visible()) + + self.label.edit_btn.clicked() + self.assertTrue(self.label.name_input.get_visible()) + self.assertFalse(self.label.label.get_visible()) + + self.label.name_input.activate() # aka hit the return key + self.assertTrue(self.label.label.get_visible()) + self.assertFalse(self.label.name_input.get_visible()) + + def test_update_name(self): + self.message_broker.send( + MappingData( + event_combination=EventCombination([(1, KEY_A, 1), (1, KEY_B, 1)]), + ) + ) + self.label.edit_btn.clicked() + + self.label.name_input.set_text("foo") + self.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.send( + MappingData( + event_combination=EventCombination([(1, KEY_A, 1), (1, KEY_B, 1)]), + ) + ) + self.label.edit_btn.clicked() + self.assertEqual(self.label.name_input.get_text(), "a + b") + + def test_name_input_contains_name(self): + self.message_broker.send( + MappingData( + event_combination=EventCombination([(1, KEY_A, 1), (1, KEY_B, 1)]), + name="foo", + ) + ) + self.label.edit_btn.clicked() + self.assertEqual(self.label.name_input.get_text(), "foo") + + def test_removes_name_when_name_matches_combination(self): + self.message_broker.send( + MappingData( + event_combination=EventCombination([(1, KEY_A, 1), (1, KEY_B, 1)]), + name="foo", + ) + ) + self.label.edit_btn.clicked() + self.label.name_input.set_text("a + b") + self.label.name_input.activate() + self.controller_mock.update_mapping.assert_called_once_with(name="") + + +class TestCodeEditor(ComponentBaseTest): + def setUp(self) -> None: + super(TestCodeEditor, self).setUp() + self.gui = GtkSource.View() + self.editor = CodeEditor(self.message_broker, self.controller_mock, self.gui) + 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.send(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.send(MappingData(output_symbol="foo")) + self.assertEqual(self.get_text(), "Record the input first") + + def test_inactive_when_mapping_is_empty(self): + self.controller_mock.is_empty_mapping.return_value = True + self.message_broker.send(MappingData(output_symbol="foo")) + self.assertFalse(self.gui.get_sensitive()) + self.assertLess(self.gui.get_opacity(), 0.6) + + def test_active_when_mapping_is_not_empty(self): + self.message_broker.send(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.send(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.send(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.send(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.send(MappingData(output_symbol="foo")) + self.assertFalse(self.gui.get_show_line_numbers()) + + def test_is_empty_when_mapping_has_no_output_symbol(self): + self.message_broker.send(MappingData()) + self.assertEqual(self.get_text(), "") + + def test_updates_mapping(self): + self.message_broker.send(MappingData()) + buffer = self.gui.get_buffer() + buffer.set_text("foo") + self.controller_mock.update_mapping.assert_called_once_with(output_symbol="foo") + + def test_avoids_infinite_recursion_when_loading_mapping(self): + self.message_broker.send(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) + + +class TestRecordingToggle(ComponentBaseTest): + def setUp(self) -> None: + super(TestRecordingToggle, self).setUp() + self.gui = Gtk.ToggleButton() + self.toggle = RecordingToggle( + self.message_broker, self.controller_mock, self.gui + ) + + def assert_recording(self): + self.assertEqual(self.gui.get_label(), "Recording ...") + self.assertTrue(self.gui.get_active()) + + def assert_not_recording(self): + self.assertEqual(self.gui.get_label(), "Record Input") + self.assertFalse(self.gui.get_active()) + + def test_starts_recording(self): + self.gui.set_active(True) + self.controller_mock.start_key_recording.assert_called_once() + + def test_stops_recording_when_clicked(self): + self.gui.set_active(True) + self.gui.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_toggled(self): + self.gui.set_active(True) + self.assert_recording() + + def test_shows_not_recording_after_toggle(self): + self.gui.set_active(True) + self.gui.set_active(False) + self.assert_not_recording() + + def test_shows_not_recording_when_recording_finished(self): + self.gui.set_active(True) + self.message_broker.signal(MessageType.recording_finished) + self.assert_not_recording() + + +class TestStatusBar(ComponentBaseTest): + def setUp(self) -> None: + super(TestStatusBar, self).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.send(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.send(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.send(StatusData(CTX_ERROR, "msg", "tooltip")) + self.message_broker.send(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.send(StatusData(CTX_WARNING, "msg", "tooltip")) + self.message_broker.send(StatusData(CTX_WARNING, "msg2", "tooltip2")) + self.message_broker.send(StatusData(CTX_WARNING)) + self.assert_empty() + + def test_restores_message_from_not_removed_ctx_id(self): + self.message_broker.send(StatusData(CTX_ERROR, "msg", "tooltip")) + self.message_broker.send(StatusData(CTX_WARNING, "msg2", "tooltip2")) + self.message_broker.send(StatusData(CTX_WARNING)) + self.assertEqual(self.get_text(), "msg") + self.assert_error_status() + + # works also the other way round + self.message_broker.send(StatusData(CTX_ERROR)) + self.message_broker.send(StatusData(CTX_WARNING, "msg", "tooltip")) + self.message_broker.send(StatusData(CTX_ERROR, "msg2", "tooltip2")) + self.message_broker.send(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.send(StatusData(CTX_ERROR, "msg")) + self.assertEqual(self.get_tooltip(), "msg") + + +class TestAutoloadSwitch(ComponentBaseTest): + def setUp(self) -> None: + super(TestAutoloadSwitch, self).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.send(PresetData(None, None, autoload=True)) + self.assertTrue(self.gui.get_active()) + self.message_broker.send(PresetData(None, None, autoload=False)) + self.assertFalse(self.gui.get_active()) + + def test_avoids_infinite_recursion(self): + self.message_broker.send(PresetData(None, None, autoload=True)) + self.message_broker.send(PresetData(None, None, autoload=False)) + self.controller_mock.set_autoload.assert_not_called() + + +class TestReleaseCombinationSwitch(ComponentBaseTest): + def setUp(self) -> None: + super(TestReleaseCombinationSwitch, self).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.send(MappingData(release_combination_keys=True)) + self.assertTrue(self.gui.get_active()) + self.message_broker.send(MappingData(release_combination_keys=False)) + self.assertFalse(self.gui.get_active()) + + def test_avoids_infinite_recursion(self): + self.message_broker.send(MappingData(release_combination_keys=True)) + self.message_broker.send(MappingData(release_combination_keys=False)) + self.controller_mock.update_mapping.assert_not_called() + + +class TestEventEntry(ComponentBaseTest): + def setUp(self) -> None: + super(TestEventEntry, self).setUp() + self.gui = EventEntry(InputEvent.from_string("3,0,1"), self.controller_mock) + + def test_move_event(self): + self.gui._up_btn.clicked() + self.controller_mock.move_event_in_combination.assert_called_once_with( + InputEvent.from_string("3,0,1"), "up" + ) + self.controller_mock.reset_mock() + + self.gui._down_btn.clicked() + self.controller_mock.move_event_in_combination.assert_called_once_with( + InputEvent.from_string("3,0,1"), "down" + ) + + +class TestCombinationListbox(ComponentBaseTest): + def setUp(self) -> None: + super(TestCombinationListbox, self).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 + self.message_broker.send( + MappingData(event_combination="1,1,1+3,0,1+1,2,1", target_uinput="keyboard") + ) + + def get_selected_row(self) -> EventEntry: + row = None + + def find_row(r: EventEntry): + nonlocal row + if r.is_selected(): + row = r + + self.gui.foreach(find_row) + assert row is not None + return row + + def select_row(self, event: InputEvent): + def select(row: EventEntry): + if row.input_event == event: + self.gui.select_row(row) + + self.gui.foreach(select) + + def test_loads_selected_row(self): + self.select_row(InputEvent.from_string("1,2,1")) + self.controller_mock.load_event.assert_called_once_with( + InputEvent.from_string("1,2,1") + ) + + def test_does_not_create_rows_when_mapping_is_empty(self): + self.controller_mock.is_empty_mapping.return_value = True + self.message_broker.send(MappingData(event_combination="1,1,1+3,0,1")) + self.assertEqual(len(self.gui.get_children()), 0) + + def test_selects_row_when_selected_event_message_arrives(self): + self.message_broker.send(InputEvent.from_string("3,0,1")) + self.assertEqual( + self.get_selected_row().input_event, InputEvent.from_string("3,0,1") + ) + + def test_avoids_infinite_recursion(self): + self.message_broker.send(InputEvent.from_string("3,0,1")) + self.controller_mock.load_event.assert_not_called() + + +class TestAnalogInputSwitch(ComponentBaseTest): + def setUp(self) -> None: + super(TestAnalogInputSwitch, self).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.send(InputEvent.from_string("3,0,0")) + self.assertTrue(self.gui.get_active()) + self.message_broker.send(InputEvent.from_string("3,0,10")) + self.assertFalse(self.gui.get_active()) + + def test_avoids_infinite_recursion(self): + self.message_broker.send(InputEvent.from_string("3,0,0")) + self.message_broker.send(InputEvent.from_string("3,0,-10")) + self.controller_mock.set_event_as_analog.assert_not_called() + + def test_disables_switch_when_key_event(self): + self.message_broker.send(InputEvent.from_string("1,1,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.send(InputEvent.from_string("1,1,1")) + self.message_broker.send(InputEvent.from_string("3,0,10")) + self.assertEqual(self.gui.get_opacity(), 1) + self.assertTrue(self.gui.get_sensitive()) + + self.message_broker.send(InputEvent.from_string("1,1,1")) + self.message_broker.send(InputEvent.from_string("2,0,10")) + self.assertEqual(self.gui.get_opacity(), 1) + self.assertTrue(self.gui.get_sensitive()) + + +class TestTriggerThresholdInput(ComponentBaseTest): + def setUp(self) -> None: + super(TestTriggerThresholdInput, self).setUp() + self.gui = Gtk.SpinButton() + self.input = TriggerThresholdInput( + self.message_broker, self.controller_mock, self.gui + ) + self.message_broker.send(InputEvent.from_string("3,0,-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_event.assert_called_once_with( + InputEvent.from_string("3,0,15") + ) + + def test_sets_value_on_selected_event_message(self): + self.message_broker.send(InputEvent.from_string("3,0,10")) + self.assertEqual(self.gui.get_value(), 10) + + def test_avoids_infinite_recursion(self): + self.message_broker.send(InputEvent.from_string("3,0,10")) + self.controller_mock.update_event.assert_not_called() + + def test_updates_configuration_according_to_selected_event(self): + self.assert_abs_event_config() + self.message_broker.send(InputEvent.from_string("2,0,-10")) + self.assert_rel_event_config() + self.message_broker.send(InputEvent.from_string("1,1,1")) + self.assert_key_event_config() + + +class TestReleaseTimeoutInput(ComponentBaseTest): + def setUp(self) -> None: + super(TestReleaseTimeoutInput, self).setUp() + self.gui = Gtk.SpinButton() + self.input = ReleaseTimeoutInput( + self.message_broker, self.controller_mock, self.gui + ) + self.message_broker.send( + MappingData( + event_combination=EventCombination("2,0,1"), target_uinput="keyboard" + ) + ) + + def test_updates_timeout_on_mapping_message(self): + self.message_broker.send( + MappingData(event_combination=EventCombination("2,0,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.send( + MappingData(event_combination=EventCombination("2,0,1"), release_timeout=1) + ) + self.controller_mock.update_mapping.assert_not_called() + + def test_disables_input_based_on_input_combination(self): + self.message_broker.send( + MappingData(event_combination=EventCombination.from_string("2,0,1+1,1,1")) + ) + self.assertTrue(self.gui.get_sensitive()) + self.assertEqual(self.gui.get_opacity(), 1) + + self.message_broker.send( + MappingData(event_combination=EventCombination.from_string("1,1,1+1,2,1")) + ) + self.assertFalse(self.gui.get_sensitive()) + self.assertLess(self.gui.get_opacity(), 0.6) + + self.message_broker.send( + MappingData(event_combination=EventCombination.from_string("2,0,1+1,1,1")) + ) + self.message_broker.send( + MappingData(event_combination=EventCombination.from_string("3,0,1+1,2,1")) + ) + self.assertFalse(self.gui.get_sensitive()) + self.assertLess(self.gui.get_opacity(), 0.6) + + +class TestOutputAxisSelector(ComponentBaseTest): + def setUp(self) -> None: + super(TestOutputAxisSelector, self).setUp() + self.gui = Gtk.ComboBox() + self.selection = OutputAxisSelector( + self.message_broker, self.controller_mock, self.gui + ) + + self.message_broker.send( + 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, 1, 2, 3]}, + } + ) + ) + self.message_broker.send( + MappingData(target_uinput="mouse", event_combination="1,1,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.send( + 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.send( + 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.send(MappingData(target_uinput="keyboard")) + self.assertEqual(len(self.gui.get_model()), 1) + self.message_broker.send(MappingData(target_uinput="gamepad")) + self.assertEqual(len(self.gui.get_model()), 9) + + +class TestKeyAxisStack(ComponentBaseTest): + def setUp(self) -> None: + super(TestKeyAxisStack, self).setUp() + self.gui = Gtk.Stack() + self.gui.add_named(Gtk.Box(), "Analog Axis") + self.gui.add_named(Gtk.Box(), "Key or Macro") + self.stack = KeyAxisStack(self.message_broker, self.controller_mock, self.gui) + self.gui.show_all() + self.gui.set_visible_child_name("Key or Macro") + + def test_switches_to_axis_when_mapping_has_output_type_code_but_not_symbol(self): + self.message_broker.send(MappingData(output_type=2, output_code=0)) + self.assertEqual(self.gui.get_visible_child_name(), "Analog Axis") + + def test_switches_to_key_when_mapping_has_output_symbol_but_type_code(self): + self.gui.set_visible_child_name("Analog Axis") + self.message_broker.send(MappingData(output_symbol="a")) + self.assertEqual(self.gui.get_visible_child_name(), "Key or Macro") + + def test_does_not_switch_when_mapping_is_ambiguous(self): + self.message_broker.send( + MappingData(output_type=2, output_code=0, output_symbol="a") + ) + self.assertEqual(self.gui.get_visible_child_name(), "Key or Macro") + self.message_broker.send(MappingData(output_type=2, output_symbol="a")) + self.assertEqual(self.gui.get_visible_child_name(), "Key or Macro") + self.message_broker.send(MappingData(output_code=0, output_symbol="a")) + self.assertEqual(self.gui.get_visible_child_name(), "Key or Macro") + + self.gui.set_visible_child_name("Analog Axis") + self.message_broker.send( + MappingData(output_type=2, output_code=0, output_symbol="a") + ) + self.assertEqual(self.gui.get_visible_child_name(), "Analog Axis") + self.message_broker.send(MappingData(output_type=2, output_symbol="a")) + self.assertEqual(self.gui.get_visible_child_name(), "Analog Axis") + self.message_broker.send(MappingData(output_code=0, output_symbol="a")) + self.assertEqual(self.gui.get_visible_child_name(), "Analog Axis") + + +class TestTransformationDrawArea(ComponentBaseTest): + def setUp(self) -> None: + super(TestTransformationDrawArea, self).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: + self.gui.show_all() + gtk_iteration() + mock.assert_called() + + def test_updates_transform_when_mapping_updates(self): + old_tf = self.transform_draw_area._transformation + self.message_broker.send(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.send(MappingData(gain=2)) + gtk_iteration(20) + mock.assert_called() + + +class TestSliders(ComponentBaseTest): + def setUp(self) -> None: + super(TestSliders, self).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.send( + MappingData(event_combination="3,0,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.send( + 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.send(MappingData(gain=0.5)) + self.controller_mock.update_mapping.assert_not_called() + self.message_broker.send(MappingData(expo=0.5)) + self.controller_mock.update_mapping.assert_not_called() + self.message_broker.send(MappingData(deadzone=0.5)) + self.controller_mock.update_mapping.assert_not_called() diff --git a/tests/integration/test_gui.py b/tests/integration/test_gui.py index dc9db15f..6dcb74b5 100644 --- a/tests/integration/test_gui.py +++ b/tests/integration/test_gui.py @@ -20,6 +20,10 @@ # the tests file needs to be imported first to make sure patches are loaded +from contextlib import contextmanager +from typing import Tuple, List + +from inputremapper.exceptions import DataManagementError from tests.test import ( get_project_root, logger, @@ -31,9 +35,9 @@ from tests.test import ( uinput_write_history_pipe, MAX_ABS, EVENT_READ_TIMEOUT, - send_event_to_reader, MIN_ABS, get_ui_mapping, + prepare_presets, ) import sys @@ -48,13 +52,14 @@ from evdev.ecodes import ( EV_ABS, KEY_LEFTSHIFT, KEY_A, + KEY_Q, ABS_RX, EV_REL, REL_X, ABS_X, ) import json -from unittest.mock import patch +from unittest.mock import patch, MagicMock, call from importlib.util import spec_from_loader, module_from_spec from importlib.machinery import SourceFileLoader @@ -62,22 +67,31 @@ import gi from inputremapper.input_event import InputEvent gi.require_version("Gtk", "3.0") -from gi.repository import Gtk, GLib, Gdk +gi.require_version("GLib", "2.0") +gi.require_version("GtkSource", "4") +from gi.repository import Gtk, GLib, Gdk, GtkSource from inputremapper.configs.system_mapping import system_mapping, XMODMAP_FILENAME -from inputremapper.configs.mapping import UIMapping -from inputremapper.gui.active_preset import active_preset +from inputremapper.configs.mapping import UIMapping, Mapping from inputremapper.configs.paths import CONFIG_PATH, get_preset_path, get_config_path from inputremapper.configs.global_config import global_config, WHEEL, MOUSE, BUTTONS -from inputremapper.gui.reader import reader +from inputremapper.groups import _Groups +from inputremapper.gui.data_manager import DataManager +from inputremapper.gui.message_broker import ( + MessageBroker, + MessageType, + StatusData, + CombinationRecorded, +) +from inputremapper.gui.components import SelectionLabel, SET_KEY_FIRST +from inputremapper.gui.reader import Reader +from inputremapper.gui.controller import Controller from inputremapper.gui.helper import RootHelper from inputremapper.gui.utils import gtk_iteration from inputremapper.gui.user_interface import UserInterface -from inputremapper.gui.editor.editor import SET_KEY_FIRST -from inputremapper.injection.injector import RUNNING, FAILED, UNKNOWN +from inputremapper.injection.injector import RUNNING, FAILED, UNKNOWN, STOPPED from inputremapper.event_combination import EventCombination -from inputremapper.daemon import Daemon -from inputremapper.groups import groups +from inputremapper.daemon import Daemon, DaemonProxy # iterate a few times when Gtk.main() is called, but don't block @@ -89,22 +103,20 @@ Gtk.main = gtk_iteration Gtk.main_quit = lambda: None -def launch(argv=None) -> UserInterface: +def launch( + argv=None, +) -> Tuple[UserInterface, Controller, DataManager, MessageBroker, DaemonProxy]: """Start input-remapper-gtk with the command line argument array argv.""" bin_path = os.path.join(get_project_root(), "bin", "input-remapper-gtk") if not argv: argv = ["-d"] - with patch( - "inputremapper.gui.user_interface.UserInterface.setup_timeouts", - lambda *args: None, - ): - with patch.object(sys, "argv", [""] + [str(arg) for arg in argv]): - loader = SourceFileLoader("__main__", bin_path) - spec = spec_from_loader("__main__", loader) - module = module_from_spec(spec) - spec.loader.exec_module(module) + with patch.object(sys, "argv", [""] + [str(arg) for arg in argv]): + loader = SourceFileLoader("__main__", bin_path) + spec = spec_from_loader("__main__", loader) + module = module_from_spec(spec) + spec.loader.exec_module(module) gtk_iteration() @@ -112,58 +124,50 @@ def launch(argv=None) -> UserInterface: # spams tons of garbage when all tests finish atexit.unregister(module.stop) - # to avoid triggering any timeouts while the module loads, patch it and - # do it afterwards. Because some tests don't want them to be triggered - # yet and test the windows initial state. This is only a problem on - # slow computers that take long for the window import. - module.user_interface.setup_timeouts() - - return module.user_interface - - -class FakeDeviceDropdown(Gtk.ComboBoxText): - def __init__(self, group): - if type(group) == str: - group = groups.find(key=group) - - self.group = group - - def get_active_text(self): - return self.group.name - - def get_active_id(self): - return self.group.key - - def set_active_id(self, key): - self.group = groups.find(key=key) + return ( + module.user_interface, + module.controller, + module.data_manager, + module.message_broker, + module.daemon, + ) -class FakePresetDropdown(Gtk.ComboBoxText): - def __init__(self, name): - self.name = name +@contextmanager +def patch_launch(): + """patch the launch function such that we don't connect to + the dbus and don't use pkexec to start the helper""" + original_connect = Daemon.connect + original_os_system = os.system + Daemon.connect = Daemon - def get_active_text(self): - return self.name + def os_system(cmd): + # instead of running pkexec, fork instead. This will make + # the helper aware of all the test patches + if "pkexec input-remapper-control --command helper" in cmd: + multiprocessing.Process(target=RootHelper(_Groups()).run).start() + return 0 - def get_active_id(self): - return self.name + return original_os_system(cmd) - def set_active_id(self, name): - self.name = name + os.system = os_system + yield + os.system = original_os_system + Daemon.connect = original_connect def clean_up_integration(test): - test.user_interface.on_stop_injecting_clicked(None) + test.controller.stop_injecting() gtk_iteration() - test.user_interface.on_close() + test.user_interface.on_gtk_close() test.user_interface.window.destroy() gtk_iteration() cleanup() # do this now, not when all tests are finished - test.user_interface.dbus.stop_all() - if isinstance(test.user_interface.dbus, Daemon): - atexit.unregister(test.user_interface.dbus.stop_all) + test.daemon.stop_all() + if isinstance(test.daemon, Daemon): + atexit.unregister(test.daemon.stop_all) class GtkKeyEvent: @@ -176,33 +180,33 @@ class GtkKeyEvent: class TestGroupsFromHelper(unittest.TestCase): def setUp(self): - self.injector = None - self.grab = evdev.InputDevice.grab - # don't try to connect, return an object instance of it instead self.original_connect = Daemon.connect Daemon.connect = Daemon + # this is already part of the test. we need a bit of patching and hacking + # because we want to discover the groups as early a possible, to reduce startup + # time for the application self.original_os_system = os.system + self.helper_started = MagicMock() def os_system(cmd): # instead of running pkexec, fork instead. This will make # the helper aware of all the test patches if "pkexec input-remapper-control --command helper" in cmd: - # the forked process should get the initial groups - groups.refresh() - multiprocessing.Process(target=RootHelper).start() - # the gui an empty dict, because it doesn't know any devices - # without the help of the privileged helper - groups.set_groups([]) - assert len(groups) == 0 + self.helper_started() # don't start the helper just log that it was. return 0 return self.original_os_system(cmd) os.system = os_system - - self.user_interface = launch() + ( + self.user_interface, + self.controller, + self.data_manager, + self.message_broker, + self.daemon, + ) = launch() def tearDown(self): clean_up_integration(self) @@ -212,42 +216,46 @@ class TestGroupsFromHelper(unittest.TestCase): def test_knows_devices(self): # verify that it is working as expected. The gui doesn't have knowledge # of groups until the root-helper provides them + self.data_manager._reader.groups.set_groups([]) gtk_iteration() - self.assertEqual(len(groups), 0) + self.helper_started.assert_called() + self.assertEqual(len(self.data_manager.get_group_keys()), 0) - # perform some iterations so that the gui ends up running - # consume_newest_keycode, which will make it receive devices. - # Restore patch, otherwise gtk complains when disabling handlers + # start the helper delayed + multiprocessing.Process(target=RootHelper(_Groups()).run).start() + # 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.assertIsNotNone(groups.find(key="Foo Device 2")) - self.assertIsNotNone(groups.find(name="Bar Device")) - self.assertIsNotNone(groups.find(name="gamepad")) - self.assertEqual(self.user_interface.group.name, "Foo Device") + self.assertIn("Foo Device 2", self.data_manager.get_group_keys()) + self.assertIn("Foo Device 2", self.data_manager.get_group_keys()) + self.assertIn("Bar Device", self.data_manager.get_group_keys()) + self.assertIn("gamepad", self.data_manager.get_group_keys()) + self.assertEqual(self.data_manager.active_group.name, "Foo Device") class PatchedConfirmDelete: - def __init__(self, user_interface, response=Gtk.ResponseType.ACCEPT): + def __init__(self, user_interface: UserInterface, response=Gtk.ResponseType.ACCEPT): self.response = response self.user_interface = user_interface self.patch = None def _confirm_delete_run_patch(self): """A patch for the deletion confirmation that briefly shows the dialog.""" - confirm_delete = self.user_interface.confirm_delete + confirm_cancel_dialog = self.user_interface.confirm_cancel_dialog # the emitted signal causes the dialog to close GLib.timeout_add( 100, - lambda: confirm_delete.emit("response", self.response), + lambda: confirm_cancel_dialog.emit("response", self.response), ) - Gtk.MessageDialog.run(confirm_delete) # don't recursively call the patch + Gtk.MessageDialog.run(confirm_cancel_dialog) # don't recursively call the patch return self.response def __enter__(self): self.patch = patch.object( - self.user_interface.get("confirm-delete"), + self.user_interface.get("confirm-cancel"), "run", self._confirm_delete_run_patch, ) @@ -258,20 +266,56 @@ class PatchedConfirmDelete: class GuiTestBase(unittest.TestCase): - @classmethod - def setUpClass(cls): - cls.injector = None - cls.grab = evdev.InputDevice.grab - cls.original_start_processes = UserInterface.start_processes + def setUp(self): + prepare_presets() + with patch_launch(): + ( + self.user_interface, + self.controller, + self.data_manager, + self.message_broker, + self.daemon, + ) = launch() + + get = self.user_interface.get + self.device_selection: Gtk.ComboBox = 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.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.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("apply_system_layout") + 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.grab_fails = False + + def grab(_): + if self.grab_fails: + raise OSError() - def start_processes(self): - """Avoid running pkexec which requires user input, and fork in - order to pass the fixtures to the helper and daemon process. - """ - multiprocessing.Process(target=RootHelper).start() - self.dbus = Daemon() + evdev.InputDevice.grab = grab + + global_config._save_config() + + self.throttle() + + self.assertIsNotNone(self.data_manager.active_group) + self.assertIsNotNone(self.data_manager.active_preset) + + def tearDown(self): + clean_up_integration(self) - UserInterface.start_processes = start_processes + self.throttle() def _callTestMethod(self, method): """Retry all tests if they fail. @@ -294,60 +338,27 @@ class GuiTestBase(unittest.TestCase): self.tearDown() self.setUp() - def setUp(self): - self.user_interface = launch() - self.editor = self.user_interface.editor - self.toggle = self.editor.get_recording_toggle() - self.selection_label_listbox = self.user_interface.get( - "selection_label_listbox" - ) - self.window = self.user_interface.get("window") - - self.grab_fails = False - - def grab(_): - if self.grab_fails: - raise OSError() - - evdev.InputDevice.grab = grab - - global_config._save_config() - - self.throttle() - - self.assertIsNotNone(self.user_interface.group) - self.assertIsNotNone(self.user_interface.group.key) - self.assertIsNotNone(self.user_interface.preset_name) - - def tearDown(self): - clean_up_integration(self) - - self.throttle() - - def throttle(self): + def throttle(self, iterations=10): """Give GTK some time 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(10): + for _ in range(iterations): gtk_iteration() time.sleep(0.002) - @classmethod - def tearDownClass(cls): - UserInterface.start_processes = cls.original_start_processes - def activate_recording_toggle(self): logger.info("Activating the recording toggle") - self.set_focus(self.toggle) - self.toggle.set_active(True) + self.recording_toggle.set_active(True) + gtk_iteration() def disable_recording_toggle(self): logger.info("Deactivating the recording toggle") - self.set_focus(None) + self.recording_toggle.set_active(False) + gtk_iteration() # should happen automatically: - self.assertFalse(self.toggle.get_active()) + self.assertFalse(self.recording_toggle.get_active()) def set_focus(self, widget): logger.info("Focusing %s", widget) @@ -356,7 +367,7 @@ class GuiTestBase(unittest.TestCase): self.throttle() - def get_selection_labels(self): + def get_selection_labels(self) -> List[SelectionLabel]: return self.selection_label_listbox.get_children() def get_status_text(self): @@ -364,7 +375,7 @@ class GuiTestBase(unittest.TestCase): return status_bar.get_message_area().get_children()[0].get_label() def get_unfiltered_symbol_input_text(self): - buffer = self.editor.get_code_editor().get_buffer() + 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): @@ -373,7 +384,7 @@ class GuiTestBase(unittest.TestCase): Parameters ---------- i - if -1, will select the "empty row", + if -1, will select the last row, 0 will select the uppermost row. 1 will select the second row, and so on """ @@ -381,130 +392,19 @@ class GuiTestBase(unittest.TestCase): self.selection_label_listbox.select_row(selection_label) logger.info( 'Selecting mapping %s "%s"', - selection_label.get_combination(), - selection_label.get_label(), + selection_label.combination, + selection_label.name, ) + gtk_iteration() return selection_label - def add_mapping_via_ui(self, key, symbol, expect_success=True, target=None): - """Modify the one empty mapping that always exists. - - Utility function for other tests. - - Parameters - ---------- - key : EventCombination or None - expect_success : boolean - If the key can be stored in the selection label. False if this change - is going to cause a duplicate. - target : str - the target selection - """ - logger.info( - 'Adding mapping %s, "%s", expecting to %s', - key, - symbol, - "work" if expect_success else "fail", - ) - - self.assertIsNone(reader.get_unreleased_keys()) - - changed = active_preset.has_unsaved_changes() - - # wait for the window to create a new empty selection_label if needed - time.sleep(0.1) - gtk_iteration() - - # the empty selection_label is expected to be the last one - selection_label = self.select_mapping(-1) - self.assertIsNone(selection_label.get_combination()) - self.assertFalse(self.editor._input_has_arrived) - - if self.toggle.get_active(): - self.assertEqual(self.toggle.get_label(), "Press Key") - else: - self.assertEqual(self.toggle.get_label(), "Change Key") - - # the recording toggle connects to focus events - self.set_focus(self.toggle) - self.toggle.set_active(True) - self.assertIsNone(selection_label.get_combination()) - self.assertEqual(self.toggle.get_label(), "Press Key") - - if key: - # modifies the keycode in the selection_label not by writing into the input, - # but by sending an event. press down all the keys of a combination - for sub_key in key: - send_event_to_reader(new_event(*sub_key.event_tuple)) - # this will be consumed all at once, since no gtk_iteration - # is done - - # make the window consume the keycode - self.sleep(len(key)) - - # holding down - self.assertIsNotNone(reader.get_unreleased_keys()) - self.assertGreater(len(reader.get_unreleased_keys()), 0) - self.assertTrue(self.editor._input_has_arrived) - self.assertTrue(self.toggle.get_active()) - - # release all the keys - for sub_key in key: - send_event_to_reader(new_event(*sub_key.type_and_code, 0)) - - # wait for the window to consume the keycode - self.sleep(len(key)) - - # released - self.assertIsNone(reader.get_unreleased_keys()) - self.assertFalse(self.editor._input_has_arrived) - - if expect_success: - self.assertEqual(self.editor.get_combination(), key) - # the previously new entry, which has been edited now, is still the - # selected one - self.assertEqual(self.editor.active_selection_label, selection_label) - self.assertEqual( - self.editor.active_selection_label.get_label(), - key.beautify(), - ) - self.assertFalse(self.toggle.get_active()) - self.assertEqual(len(reader._unreleased), 0) - - if not expect_success: - self.assertIsNone(selection_label.get_combination()) - self.assertEqual(self.editor.get_symbol_input_text(), "") - self.assertFalse(self.editor._input_has_arrived) - # it won't switch the focus to the symbol input - self.assertTrue(self.toggle.get_active()) - self.assertEqual(active_preset.has_unsaved_changes(), changed) - return selection_label - - if key is None: - self.assertEqual(self.get_unfiltered_symbol_input_text(), SET_KEY_FIRST) - self.assertEqual(self.editor.get_symbol_input_text(), "") - - # set the target selection - if target: - self.editor.set_target_selection(target) - self.assertEqual(self.editor.get_target_selection(), target) - else: - self.assertEqual(self.editor.get_target_selection(), "keyboard") - - # set the symbol to make the new selection_label complete - self.editor.set_symbol_input_text(symbol) - self.assertEqual(self.editor.get_symbol_input_text(), symbol) - - # unfocus them to trigger some final logic - self.set_focus(None) - correct_case = system_mapping.correct_case(symbol) - self.assertEqual(self.editor.get_symbol_input_text(), correct_case) - self.assertFalse(active_preset.has_unsaved_changes()) - - self.set_focus(self.editor.get_code_editor()) - self.set_focus(None) - - return selection_label + def add_mapping(self, mapping: Mapping = None): + self.controller.create_mapping() + self.controller.load_mapping(EventCombination.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): @@ -515,20 +415,6 @@ class GuiTestBase(unittest.TestCase): gtk_iteration() - def set_combination(self, combination: EventCombination) -> None: - """Partial implementation of editor.consume_newest_keycode - simplifies setting combination without going through the add mapping via ui function - """ - previous_key = self.editor.get_combination() - # keycode didn't change, do nothing - if combination == previous_key: - return - - self.editor.set_combination(combination) - self.editor.active_mapping.event_combination = combination - if previous_key is None and combination is not None: - active_preset.add(self.editor.active_mapping) - class TestGui(GuiTestBase): """For tests that use the window. @@ -540,203 +426,108 @@ class TestGui(GuiTestBase): self.assertIsNotNone(self.user_interface) self.assertTrue(self.user_interface.window.get_visible()) - def test_gui_clean(self): - # check that the test is correctly set up so that the user interface is clean + def assert_gui_clean(self): selection_labels = self.selection_label_listbox.get_children() - self.assertEqual(len(selection_labels), 1) - self.assertEqual(self.editor.active_selection_label, selection_labels[0]) - self.assertEqual( - self.selection_label_listbox.get_selected_row(), - selection_labels[0], - ) - self.assertEqual(len(active_preset), 0) - self.assertEqual(selection_labels[0].get_label(), "new entry") - self.assertEqual(self.editor.get_symbol_input_text(), "") - preset_selection = self.user_interface.get("preset_selection") - self.assertEqual(preset_selection.get_active_id(), "new preset") - self.assertEqual(len(active_preset), 0) - self.assertEqual(self.editor.get_recording_toggle().get_label(), "Change Key") + self.assertEqual(len(selection_labels), 0) + self.assertEqual(len(self.data_manager.active_preset), 0) + self.assertEqual(self.preset_selection.get_active_id(), "new preset") + self.assertEqual(self.recording_toggle.get_label(), "Record Input") self.assertEqual(self.get_unfiltered_symbol_input_text(), SET_KEY_FIRST) - def test_ctrl_q(self): - closed = False - - def on_close(): - nonlocal closed - closed = True - - with patch.object(self.user_interface, "on_close", on_close): - self.user_interface.on_key_press( - self.user_interface, GtkKeyEvent(Gdk.KEY_Control_L) - ) - self.user_interface.on_key_press( - self.user_interface, GtkKeyEvent(Gdk.KEY_a) - ) - self.user_interface.on_key_release( - self.user_interface, GtkKeyEvent(Gdk.KEY_Control_L) - ) - self.user_interface.on_key_release( - self.user_interface, GtkKeyEvent(Gdk.KEY_a) - ) - self.user_interface.on_key_press( - self.user_interface, GtkKeyEvent(Gdk.KEY_b) - ) - self.user_interface.on_key_press( - self.user_interface, GtkKeyEvent(Gdk.KEY_q) - ) - self.user_interface.on_key_release( - self.user_interface, GtkKeyEvent(Gdk.KEY_q) - ) - self.user_interface.on_key_release( - self.user_interface, GtkKeyEvent(Gdk.KEY_b) - ) - self.assertFalse(closed) - - # while keys are being recorded no shortcut should work - self.toggle.set_active(True) - self.user_interface.on_key_press( - self.user_interface, GtkKeyEvent(Gdk.KEY_Control_L) - ) - self.user_interface.on_key_press( - self.user_interface, GtkKeyEvent(Gdk.KEY_q) - ) - self.assertFalse(closed) - - self.toggle.set_active(False) - self.user_interface.on_key_press( - self.user_interface, GtkKeyEvent(Gdk.KEY_Control_L) - ) - self.user_interface.on_key_press( - self.user_interface, GtkKeyEvent(Gdk.KEY_q) - ) - self.assertTrue(closed) - - self.user_interface.on_key_release( - self.user_interface, GtkKeyEvent(Gdk.KEY_Control_L) - ) - self.user_interface.on_key_release( - self.user_interface, GtkKeyEvent(Gdk.KEY_q) - ) - - def test_ctrl_r(self): - with patch.object(reader, "refresh_groups") as reader_get_devices_patch: - self.user_interface.on_key_press( - self.user_interface, GtkKeyEvent(Gdk.KEY_Control_L) - ) - self.user_interface.on_key_press( - self.user_interface, GtkKeyEvent(Gdk.KEY_r) - ) - reader_get_devices_patch.assert_called_once() - - def test_ctrl_del(self): - with patch.object(self.user_interface.dbus, "stop_injecting") as stop_injecting: - self.user_interface.on_key_press( - self.user_interface, GtkKeyEvent(Gdk.KEY_Control_L) - ) - self.user_interface.on_key_press( - self.user_interface, GtkKeyEvent(Gdk.KEY_Delete) - ) - stop_injecting.assert_called_once() - - def test_show_device_mapping_status(self): - # this function may not return True, otherwise the timeout - # runs forever - self.assertFalse(self.user_interface.show_device_mapping_status()) - - def test_autoload(self): - self.assertFalse( - global_config.is_autoloaded( - self.user_interface.group.key, self.user_interface.preset_name - ) - ) - - with spy(self.user_interface.dbus, "set_config_dir") as set_config_dir: - self.user_interface.on_autoload_switch(None, False) + def test_initial_state(self): + self.assertEqual(self.data_manager.active_group.key, "Foo Device") + self.assertEqual(self.device_selection.get_active_id(), "Foo Device") + self.assertEqual(self.data_manager.active_preset.name, "preset3") + self.assertEqual(self.preset_selection.get_active_id(), "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, ((1, 5, 1),) + ) + self.assertEqual( + self.data_manager.active_mapping.event_combination, ((1, 5, 1),) + ) + 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()) - self.assertFalse( - global_config.is_autoloaded( - self.user_interface.group.key, self.user_interface.preset_name - ) - ) + def test_autoload_sets_correctly(self): + self.assertFalse(self.data_manager.get_autoload()) + self.assertFalse(self.autoload_toggle.get_active()) - self.user_interface.on_select_device(FakeDeviceDropdown("Foo Device 2")) + self.autoload_toggle.set_active(True) gtk_iteration() - self.assertFalse(self.user_interface.get("preset_autoload_switch").get_active()) + self.assertTrue(self.data_manager.get_autoload()) + self.assertTrue(self.autoload_toggle.get_active()) - # select a preset for the first device - self.user_interface.get("preset_autoload_switch").set_active(True) + self.autoload_toggle.set_active(False) gtk_iteration() - self.assertTrue(self.user_interface.get("preset_autoload_switch").get_active()) - self.assertEqual(self.user_interface.group.key, "Foo Device 2") - self.assertEqual(self.user_interface.group.name, "Foo Device") - self.assertTrue( - global_config.is_autoloaded(self.user_interface.group.key, "new preset") - ) - self.assertFalse(global_config.is_autoloaded("Bar Device", "new preset")) - self.assertListEqual( - list(global_config.iterate_autoload_presets()), - [("Foo Device 2", "new preset")], - ) + 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()) - # create a new preset, the switch should be correctly off and the - # global_config not changed. - self.user_interface.on_create_preset_clicked() + self.device_selection.set_active_id("Foo Device 2") + self.preset_selection.set_active_id("preset2") gtk_iteration() - self.assertEqual(self.user_interface.preset_name, "new preset 2") - self.assertFalse(self.user_interface.get("preset_autoload_switch").get_active()) - self.assertTrue(global_config.is_autoloaded("Foo Device 2", "new preset")) - self.assertFalse(global_config.is_autoloaded("Foo Device", "new preset")) - self.assertFalse(global_config.is_autoloaded("Foo Device", "new preset 2")) - self.assertFalse(global_config.is_autoloaded("Foo Device 2", "new preset 2")) + self.assertTrue(self.data_manager.get_autoload()) + self.assertTrue(self.autoload_toggle.get_active()) - # select a preset for the second device - self.user_interface.on_select_device(FakeDeviceDropdown("Bar Device")) - self.user_interface.get("preset_autoload_switch").set_active(True) + def test_only_one_autoload_per_group(self): + self.assertFalse(self.data_manager.get_autoload()) + self.assertFalse(self.autoload_toggle.get_active()) + + self.device_selection.set_active_id("Foo Device 2") + self.preset_selection.set_active_id("preset2") gtk_iteration() - self.assertTrue(global_config.is_autoloaded("Foo Device 2", "new preset")) - self.assertFalse(global_config.is_autoloaded("Foo Device", "new preset")) - self.assertTrue(global_config.is_autoloaded("Bar Device", "new preset")) - self.assertListEqual( - list(global_config.iterate_autoload_presets()), - [("Foo Device 2", "new preset"), ("Bar Device", "new preset")], - ) + self.assertTrue(self.data_manager.get_autoload()) + self.assertTrue(self.autoload_toggle.get_active()) - # disable autoloading for the second device - self.user_interface.get("preset_autoload_switch").set_active(False) + self.preset_selection.set_active_id("preset3") gtk_iteration() - self.assertTrue(global_config.is_autoloaded("Foo Device 2", "new preset")) - self.assertFalse(global_config.is_autoloaded("Foo Device", "new preset")) - self.assertFalse(global_config.is_autoloaded("Bar Device", "new preset")) - self.assertListEqual( - list(global_config.iterate_autoload_presets()), - [("Foo Device 2", "new preset")], - ) + self.autoload_toggle.set_active(True) + gtk_iteration() + self.preset_selection.set_active_id("preset2") + gtk_iteration() + self.assertFalse(self.data_manager.get_autoload()) + self.assertFalse(self.autoload_toggle.get_active()) - def test_select_device(self): + 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.device_selection.set_active_id("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.device_selection.set_active_id("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.user_interface.on_select_device(FakeDeviceDropdown("Foo Device")) - m1 = UIMapping( - event_combination="1,50,1", - output_symbol="q", - target_uinput="keyboard", - ) - m2 = UIMapping( - event_combination="1,51,1", - output_symbol="u", - target_uinput="keyboard", - ) - m3 = UIMapping( - event_combination="1,52,1", - output_symbol="x", - target_uinput="keyboard", - ) - active_preset.add(m1) - active_preset.add(m2) - active_preset.add(m3) - self.assertEqual(len(active_preset), 3) - self.user_interface.on_select_device(FakeDeviceDropdown("Bar Device")) - self.assertEqual(len(active_preset), 0) + self.device_selection.set_active_id("Bar Device") + self.assertEqual(self.preset_selection.get_active_id(), "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. @@ -745,479 +536,600 @@ class TestGui(GuiTestBase): with open(path, "r") as file: self.assertEqual(file.read(), "") - def test_permission_error_on_create_preset_clicked(self): - def save(_=None): - raise PermissionError + def test_recording_toggle_labels(self): + self.assertEqual(self.recording_toggle.get_label(), "Record Input") + self.recording_toggle.set_active(True) + gtk_iteration() + self.assertEqual(self.recording_toggle.get_label(), "Recording ...") + self.recording_toggle.set_active(False) + gtk_iteration() + self.assertEqual(self.recording_toggle.get_label(), "Record Input") - with patch.object(active_preset, "save", save): - self.user_interface.on_create_preset_clicked() - status = self.get_status_text() - self.assertIn("Permission denied", status) + def test_recording_label_updates_on_recording_finished(self): + self.assertEqual(self.recording_toggle.get_label(), "Record Input") + self.recording_toggle.set_active(True) + gtk_iteration() + self.assertEqual(self.recording_toggle.get_label(), "Recording ...") + self.message_broker.signal(MessageType.recording_finished) + gtk_iteration() + self.assertEqual(self.recording_toggle.get_label(), "Record Input") + self.assertFalse(self.recording_toggle.get_active()) - def test_show_injection_result_failure(self): - def get_state(_=None): - return FAILED + def test_events_from_helper_arrive(self): + # load a device with more capabilities + self.controller.load_group("Foo Device 2") + gtk_iteration() + mock1 = MagicMock() + mock2 = MagicMock() + self.message_broker.subscribe(MessageType.combination_recorded, mock1) + self.message_broker.subscribe(MessageType.recording_finished, mock2) + self.recording_toggle.set_active(True) + gtk_iteration() - with patch.object(self.user_interface.dbus, "get_state", get_state): - self.user_interface.show_injection_result() - text = self.get_status_text() - self.assertIn("Failed", text) + push_events( + "Foo Device 2", + [InputEvent.from_string("1,30,1"), InputEvent.from_string("1,31,1")], + ) + self.throttle(20) + mock1.assert_has_calls( + ( + call(CombinationRecorded(EventCombination.from_string("1,30,1"))), + call( + CombinationRecorded(EventCombination.from_string("1,30,1+1,31,1")) + ), + ), + any_order=False, + ) + self.assertEqual(mock1.call_count, 2) + mock2.assert_not_called() + + push_events("Foo Device 2", [InputEvent.from_string("1,31,0")]) + self.throttle(20) + self.assertEqual(mock1.call_count, 2) + mock2.assert_not_called() + + push_events("Foo Device 2", [InputEvent.from_string("1,30,0")]) + self.throttle(20) + self.assertEqual(mock1.call_count, 2) + mock2.assert_called_once() + + self.assertFalse(self.recording_toggle.get_active()) + + def test_cannot_create_duplicate_event_combination(self): + # load a device with more capabilities + self.controller.load_group("Foo Device 2") + gtk_iteration() - def test_editor_keycode_to_string(self): - # not an integration test, but I have all the selection_label tests here already - self.assertEqual( - EventCombination((EV_KEY, evdev.ecodes.KEY_A, 1)).beautify(), - "a", + # update the combination of the active mapping + self.controller.start_key_recording() + push_events( + "Foo Device 2", + [InputEvent.from_string("1,30,1"), InputEvent.from_string("1,30,0")], ) + self.throttle(20) + self.assertEqual( - EventCombination([EV_KEY, evdev.ecodes.KEY_A, 1]).beautify(), - "a", + self.data_manager.active_mapping.event_combination, + EventCombination.from_string("1,30,1"), ) + + # create a new mapping + self.controller.create_mapping() + gtk_iteration() self.assertEqual( - EventCombination((EV_ABS, evdev.ecodes.ABS_HAT0Y, -1)).beautify(), - "DPad Up", + self.data_manager.active_mapping.event_combination, + EventCombination.empty_combination(), ) - self.assertEqual( - EventCombination((EV_KEY, evdev.ecodes.BTN_A, 1)).beautify(), - "Button A", + + # try to recorde the same combination + self.controller.start_key_recording() + push_events( + "Foo Device 2", + [InputEvent.from_string("1,30,1"), InputEvent.from_string("1,30,0")], ) - self.assertEqual(EventCombination((EV_KEY, 1234, 1)).beautify(), "1234") + self.throttle(20) + # should still be the empty mapping self.assertEqual( - EventCombination([EV_ABS, evdev.ecodes.ABS_HAT0X, -1]).beautify(), - "DPad Left", + self.data_manager.active_mapping.event_combination, + EventCombination.empty_combination(), ) + + # try to recorde a different combination + self.controller.start_key_recording() + push_events("Foo Device 2", [InputEvent.from_string("1,30,1")]) + self.throttle(20) + # nothing changed yet, as we got the duplicate combination self.assertEqual( - EventCombination([EV_ABS, evdev.ecodes.ABS_HAT0Y, -1]).beautify(), - "DPad Up", + self.data_manager.active_mapping.event_combination, + EventCombination.empty_combination(), ) + push_events("Foo Device 2", [InputEvent.from_string("1,31,1")]) + self.throttle(20) + # now the combination is different self.assertEqual( - EventCombination([EV_KEY, evdev.ecodes.BTN_A, 1]).beautify(), - "Button A", + self.data_manager.active_mapping.event_combination, + EventCombination.from_string("1,30,1+1,31,1"), ) - self.assertEqual(EventCombination([EV_KEY, 1234, 1]).beautify(), "1234") + + # let's make the combination even longer + push_events("Foo Device 2", [InputEvent.from_string("1,32,1")]) + self.throttle(20) self.assertEqual( - EventCombination([EV_ABS, evdev.ecodes.ABS_X, 1]).beautify(), - "Joystick Right", + self.data_manager.active_mapping.event_combination, + EventCombination.from_string("1,30,1+1,31,1+1,32,1"), + ) + + # make sure we stop recording by releasing all keys + push_events( + "Foo Device 2", + [ + InputEvent.from_string("1,31,0"), + InputEvent.from_string("1,30,0"), + InputEvent.from_string("1,32,0"), + ], ) + self.throttle(20) + + # sending a combination update now should not do anything + self.message_broker.send( + CombinationRecorded(EventCombination.from_string("1,35,1")) + ) + gtk_iteration() self.assertEqual( - EventCombination([EV_ABS, evdev.ecodes.ABS_RY, 1]).beautify(), - "Joystick 2 Down", + self.data_manager.active_mapping.event_combination, + EventCombination.from_string("1,30,1+1,31,1+1,32,1"), ) + + def test_create_simple_mapping(self): + # 1. create a mapping + self.create_mapping_btn.clicked() + gtk_iteration() + self.assertEqual( - EventCombination([EV_REL, evdev.ecodes.REL_HWHEEL, 1]).beautify(), - "Wheel Right", + self.selection_label_listbox.get_selected_row().combination, + EventCombination.empty_combination(), ) self.assertEqual( - EventCombination([EV_REL, evdev.ecodes.REL_WHEEL, -1]).beautify(), - "Wheel Down", + self.data_manager.active_mapping.event_combination, + EventCombination.empty_combination(), ) - - # combinations self.assertEqual( - EventCombination( - (EV_KEY, evdev.ecodes.BTN_A, 1), - (EV_KEY, evdev.ecodes.BTN_B, 1), - (EV_KEY, evdev.ecodes.BTN_C, 1), - ).beautify(), - "Button A + Button B + Button C", + self.selection_label_listbox.get_selected_row().name, "Empty Mapping" ) + self.assertIsNone(self.data_manager.active_mapping.name) - def test_is_waiting_for_input(self): - self.activate_recording_toggle() - self.assertTrue(self.editor.is_waiting_for_input()) - - self.disable_recording_toggle() - self.assertFalse(self.editor.is_waiting_for_input()) - - def test_editor_simple(self): - self.assertEqual(self.toggle.get_label(), "Change Key") - - self.assertEqual(len(self.selection_label_listbox.get_children()), 1) - - selection_label = self.selection_label_listbox.get_children()[0] - self.activate_recording_toggle() - self.assertTrue(self.editor.is_waiting_for_input()) - self.assertEqual(self.toggle.get_label(), "Press Key") - - self.user_interface.consume_newest_keycode() - # nothing happens - self.assertIsNone(selection_label.get_combination()) - self.assertEqual(len(active_preset), 0) - self.assertEqual(self.toggle.get_label(), "Press Key") - - send_event_to_reader(InputEvent.from_tuple((EV_KEY, 30, 1))) - self.user_interface.consume_newest_keycode() - # no symbol configured yet, so the active_preset remains empty - self.assertEqual(len(active_preset), 0) - self.assertEqual(len(selection_label.get_combination()), 1) - self.assertEqual(selection_label.get_combination()[0], (EV_KEY, 30, 1)) - # this is KEY_A in linux/input-event-codes.h, - # but KEY_ is removed from the text for display purposes - self.assertEqual(selection_label.get_label(), "a") - - # providing the same key again doesn't do any harm - # (Maybe this could happen for gamepads or something, idk) - send_event_to_reader(InputEvent.from_tuple((EV_KEY, 30, 1))) - self.user_interface.consume_newest_keycode() - self.assertEqual(len(active_preset), 0) # not released yet - self.assertEqual(len(selection_label.get_combination()), 1) - self.assertEqual(selection_label.get_combination()[0], (EV_KEY, 30, 1)) + # there are now 2 mappings + self.assertEqual(len(self.selection_label_listbox.get_children()), 2) + self.assertEqual(len(self.data_manager.active_preset), 2) - time.sleep(0.11) - # new empty entry was added + # 2. recorde a combination for that mapping + self.recording_toggle.set_active(True) gtk_iteration() + push_events("Foo Device", [InputEvent.from_string("1,30,1")]) + self.throttle(20) + push_events("Foo Device", [InputEvent.from_string("1,30,0")]) + self.throttle(20) + + # check the event_combination self.assertEqual( - len(self.selection_label_listbox.get_children()), - 2, + self.selection_label_listbox.get_selected_row().combination, + EventCombination.from_string("1,30,1"), ) + self.assertEqual( + self.data_manager.active_mapping.event_combination, + EventCombination.from_string("1,30,1"), + ) + self.assertEqual(self.selection_label_listbox.get_selected_row().name, "a") + self.assertIsNone(self.data_manager.active_mapping.name) - self.disable_recording_toggle() - self.set_focus(self.editor.get_code_editor()) - self.assertFalse(self.editor.is_waiting_for_input()) - - self.editor.set_symbol_input_text("Shift_L") - - self.set_focus(None) - self.assertFalse(self.editor.is_waiting_for_input()) + # 3. set the output symbol + self.code_editor.get_buffer().set_text("Shift_L") + gtk_iteration() - num_mappings = len(active_preset) - self.assertEqual(num_mappings, 1) + # 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()) - time.sleep(0.1) - gtk_iteration() self.assertEqual( - len(self.selection_label_listbox.get_children()), - 2, + self.data_manager.active_mapping, + Mapping( + event_combination="1,30,1", + 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( - active_preset.get_mapping(EventCombination([EV_KEY, 30, 1])), - ("Shift_L", "keyboard"), + self.selection_label_listbox.get_selected_row().combination, + EventCombination.from_string("1,30,1"), ) - self.assertEqual(self.editor.get_target_selection(), "keyboard") - self.assertEqual(self.editor.get_symbol_input_text(), "Shift_L") - self.assertEqual(len(selection_label.get_combination()), 1) - self.assertEqual(selection_label.get_combination()[0], (EV_KEY, 30, 1)) - self.editor.set_target_selection("mouse") - time.sleep(0.1) + # 4. update target to mouse + self.target_selection.set_active_id("mouse") gtk_iteration() self.assertEqual( - len(self.selection_label_listbox.get_children()), - 2, + self.data_manager.active_mapping, + Mapping( + event_combination="1,30,1", + output_symbol="Shift_L", + target_uinput="mouse", + ), ) - self.assertEqual( - active_preset.get_mapping(EventCombination([EV_KEY, 30, 1])), - ("Shift_L", "mouse"), - ) - self.assertEqual(self.editor.get_target_selection(), "mouse") - self.assertEqual(self.editor.get_symbol_input_text(), "Shift_L") - self.assertEqual(selection_label.get_combination()[0], (EV_KEY, 30, 1)) - - def test_editor_not_focused(self): - # focus anything that is not the selection_label, - # no keycode should be inserted into it - self.set_focus(self.user_interface.get("preset_name_input")) - send_event_to_reader(new_event(1, 61, 1)) - self.user_interface.consume_newest_keycode() - - selection_labels = self.get_selection_labels() - self.assertEqual(len(selection_labels), 1) - selection_label = selection_labels[0] - - # the empty selection_label has this combination not set - self.assertIsNone(selection_label.get_combination()) - - # focus the text input instead - self.set_focus(self.editor.get_code_editor()) - send_event_to_reader(new_event(1, 61, 1)) - self.user_interface.consume_newest_keycode() - - # still nothing set - self.assertIsNone(selection_label.get_combination()) def test_show_status(self): - self.user_interface.show_status(0, "a" * 100) + self.message_broker.send(StatusData(0, "a" * 100)) + gtk_iteration() text = self.get_status_text() self.assertIn("...", text) - self.user_interface.show_status(0, "b") + self.message_broker.send(StatusData(0, "b")) + gtk_iteration() text = self.get_status_text() self.assertNotIn("...", text) - def test_clears_unreleased_on_focus_change(self): - ev_1 = EventCombination([EV_KEY, 41, 1]) - - # focus - self.set_focus(self.toggle) - send_event_to_reader(new_event(*ev_1[0].event_tuple)) - reader.read() - self.assertEqual(reader.get_unreleased_keys(), ev_1) - - # unfocus - # doesn't call reader.clear. Otherwise the super key cannot be mapped, - # because the start menu that opens up would unfocus the user interface - self.set_focus(None) - self.assertEqual(reader.get_unreleased_keys(), ev_1) - - # focus the toggle after selecting a different selection_label. - # It resets the reader - self.editor.add_empty() - self.select_mapping(-1) - self.set_focus(self.toggle) - self.toggle.set_active(True) - - self.assertEqual(reader.get_unreleased_keys(), None) - - def test_editor(self): - """Comprehensive test for the editor.""" - system_mapping.clear() - system_mapping._set("Foo_BAR", 41) - system_mapping._set("B", 42) - system_mapping._set("c", 43) - system_mapping._set("d", 44) - - # how many selection_labels there should be in the end - num_selection_labels_target = 3 - - ev_1 = EventCombination([EV_KEY, 10, 1]) - ev_2 = EventCombination([EV_ABS, evdev.ecodes.ABS_HAT0X, -1]) - - """edit""" - - # add two selection_labels by modifiying the one empty selection_label that - # exists. Insert lowercase, it should be corrected to uppercase as stored - # in system_mapping - self.add_mapping_via_ui(ev_1, "foo_bar", target="mouse") - self.add_mapping_via_ui(ev_2, "k(b).k(c)") - - # one empty selection_label added automatically again - time.sleep(0.1) + def test_hat_switch(self): + # load a device with more capabilities + self.controller.load_group("Foo Device 2") gtk_iteration() - self.assertEqual(len(self.get_selection_labels()), num_selection_labels_target) - - self.assertEqual(active_preset.get_mapping(ev_1), ("Foo_BAR", "mouse")) - self.assertEqual(active_preset.get_mapping(ev_2), ("k(b).k(c)", "keyboard")) - """edit first selection_label""" - - self.select_mapping(0) - self.assertEqual(self.editor.get_combination(), ev_1) - self.set_focus(self.editor.get_code_editor()) - self.editor.set_symbol_input_text("c") - self.set_focus(None) - - # after unfocusing, it stores the mapping. So loading it again will retain - # the mapping that was used - preset_name = self.user_interface.preset_name - preset_path = self.user_interface.group.get_preset_path(preset_name) - active_preset.load(preset_path) - - self.assertEqual(active_preset.get_mapping(ev_1), ("c", "mouse")) - self.assertEqual(active_preset.get_mapping(ev_2), ("k(b).k(c)", "keyboard")) + # it should be possible to add all of them + ev_1 = InputEvent.from_tuple((EV_ABS, evdev.ecodes.ABS_HAT0X, -1)) + ev_2 = InputEvent.from_tuple((EV_ABS, evdev.ecodes.ABS_HAT0X, 1)) + ev_3 = InputEvent.from_tuple((EV_ABS, evdev.ecodes.ABS_HAT0Y, -1)) + ev_4 = InputEvent.from_tuple((EV_ABS, evdev.ecodes.ABS_HAT0Y, 1)) - """add duplicate""" + def add_mapping(event, symbol): + self.controller.create_mapping() + gtk_iteration() + self.controller.start_key_recording() + push_events("Foo Device 2", [event, event.modify(value=0)]) + self.throttle(20) + gtk_iteration() + self.code_editor.get_buffer().set_text(symbol) + gtk_iteration() - # try to add a duplicate keycode, it should be ignored - self.add_mapping_via_ui(ev_2, "d", expect_success=False) - self.assertEqual(active_preset.get_mapping(ev_2), ("k(b).k(c)", "keyboard")) - # and the number of selection_labels shouldn't change - self.assertEqual(len(self.get_selection_labels()), num_selection_labels_target) + add_mapping(ev_1, "a") + add_mapping(ev_2, "b") + add_mapping(ev_3, "c") + add_mapping(ev_4, "d") - def test_hat0x(self): - # it should be possible to add all of them - ev_1 = EventCombination([EV_ABS, evdev.ecodes.ABS_HAT0X, -1]) - ev_2 = EventCombination([EV_ABS, evdev.ecodes.ABS_HAT0X, 1]) - ev_3 = EventCombination([EV_ABS, evdev.ecodes.ABS_HAT0Y, -1]) - ev_4 = EventCombination([EV_ABS, evdev.ecodes.ABS_HAT0Y, 1]) - - self.add_mapping_via_ui(ev_1, "a") - self.add_mapping_via_ui(ev_2, "b") - self.add_mapping_via_ui(ev_3, "c") - self.add_mapping_via_ui(ev_4, "d") - - self.assertEqual(active_preset.get_mapping(ev_1), ("a", "keyboard")) - self.assertEqual(active_preset.get_mapping(ev_2), ("b", "keyboard")) - self.assertEqual(active_preset.get_mapping(ev_3), ("c", "keyboard")) - self.assertEqual(active_preset.get_mapping(ev_4), ("d", "keyboard")) - - # and trying to add them as duplicate selection_labels will be ignored for each - # of them - self.add_mapping_via_ui(ev_1, "e", expect_success=False) - self.add_mapping_via_ui(ev_2, "f", expect_success=False) - self.add_mapping_via_ui(ev_3, "g", expect_success=False) - self.add_mapping_via_ui(ev_4, "h", expect_success=False) - - self.assertEqual(active_preset.get_mapping(ev_1), ("a", "keyboard")) - self.assertEqual(active_preset.get_mapping(ev_2), ("b", "keyboard")) - self.assertEqual(active_preset.get_mapping(ev_3), ("c", "keyboard")) - self.assertEqual(active_preset.get_mapping(ev_4), ("d", "keyboard")) + self.assertEqual( + self.data_manager.active_preset.get_mapping( + EventCombination(ev_1) + ).output_symbol, + "a", + ) + self.assertEqual( + self.data_manager.active_preset.get_mapping( + EventCombination(ev_2) + ).output_symbol, + "b", + ) + self.assertEqual( + self.data_manager.active_preset.get_mapping( + EventCombination(ev_3) + ).output_symbol, + "c", + ) + self.assertEqual( + self.data_manager.active_preset.get_mapping( + EventCombination(ev_4) + ).output_symbol, + "d", + ) def test_combination(self): + # load a device with more capabilities + self.controller.load_group("Foo Device 2") + gtk_iteration() + # it should be possible to write a combination combination ev_1 = InputEvent.from_tuple((EV_KEY, evdev.ecodes.KEY_A, 1)) ev_2 = InputEvent.from_tuple((EV_ABS, evdev.ecodes.ABS_HAT0X, 1)) ev_3 = InputEvent.from_tuple((EV_KEY, evdev.ecodes.KEY_C, 1)) ev_4 = InputEvent.from_tuple((EV_ABS, evdev.ecodes.ABS_HAT0X, -1)) - combination_1 = EventCombination(ev_1, ev_2, ev_3) - combination_2 = EventCombination(ev_2, ev_1, ev_3) + combination_1 = EventCombination((ev_1, ev_2, ev_3)) + combination_2 = EventCombination((ev_2, ev_1, ev_3)) # same as 1, but different D-Pad direction - combination_3 = EventCombination(ev_1, ev_4, ev_3) - combination_4 = EventCombination(ev_4, ev_1, ev_3) + combination_3 = EventCombination((ev_1, ev_4, ev_3)) + combination_4 = EventCombination((ev_4, ev_1, ev_3)) # same as 1, but the last combination is different - combination_5 = EventCombination(ev_1, ev_3, ev_2) - combination_6 = EventCombination(ev_3, ev_1, ev_2) + combination_5 = EventCombination((ev_1, ev_3, ev_2)) + combination_6 = EventCombination((ev_3, ev_1, ev_2)) + + def add_mapping(combi: EventCombination, symbol): + self.controller.create_mapping() + gtk_iteration() + self.controller.start_key_recording() + push_events("Foo Device 2", [event for event in combi]) + push_events("Foo Device 2", [event.modify(value=0) for event in combi]) + self.throttle(20) + gtk_iteration() + self.code_editor.get_buffer().set_text(symbol) + gtk_iteration() - self.add_mapping_via_ui(combination_1, "a") - self.assertEqual(active_preset.get_mapping(combination_1), ("a", "keyboard")) - self.assertEqual(active_preset.get_mapping(combination_2), ("a", "keyboard")) - self.assertIsNone(active_preset.get_mapping(combination_3)) - self.assertIsNone(active_preset.get_mapping(combination_4)) - self.assertIsNone(active_preset.get_mapping(combination_5)) - self.assertIsNone(active_preset.get_mapping(combination_6)) + add_mapping(combination_1, "a") + self.assertEqual( + self.data_manager.active_preset.get_mapping(combination_1).output_symbol, + "a", + ) + self.assertEqual( + self.data_manager.active_preset.get_mapping(combination_2).output_symbol, + "a", + ) + self.assertIsNone(self.data_manager.active_preset.get_mapping(combination_3)) + self.assertIsNone(self.data_manager.active_preset.get_mapping(combination_4)) + self.assertIsNone(self.data_manager.active_preset.get_mapping(combination_5)) + self.assertIsNone(self.data_manager.active_preset.get_mapping(combination_6)) # it won't write the same combination again, even if the # first two events are in a different order - self.add_mapping_via_ui(combination_2, "b", expect_success=False) - self.assertEqual(active_preset.get_mapping(combination_1), ("a", "keyboard")) - self.assertEqual(active_preset.get_mapping(combination_2), ("a", "keyboard")) - self.assertIsNone(active_preset.get_mapping(combination_3)) - self.assertIsNone(active_preset.get_mapping(combination_4)) - self.assertIsNone(active_preset.get_mapping(combination_5)) - self.assertIsNone(active_preset.get_mapping(combination_6)) - - self.add_mapping_via_ui(combination_3, "c") - self.assertEqual(active_preset.get_mapping(combination_1), ("a", "keyboard")) - self.assertEqual(active_preset.get_mapping(combination_2), ("a", "keyboard")) - self.assertEqual(active_preset.get_mapping(combination_3), ("c", "keyboard")) - self.assertEqual(active_preset.get_mapping(combination_4), ("c", "keyboard")) - self.assertIsNone(active_preset.get_mapping(combination_5)) - self.assertIsNone(active_preset.get_mapping(combination_6)) + add_mapping(combination_2, "b") + self.assertEqual( + self.data_manager.active_preset.get_mapping(combination_1).output_symbol, + "a", + ) + self.assertEqual( + self.data_manager.active_preset.get_mapping(combination_2).output_symbol, + "a", + ) + self.assertIsNone(self.data_manager.active_preset.get_mapping(combination_3)) + self.assertIsNone(self.data_manager.active_preset.get_mapping(combination_4)) + self.assertIsNone(self.data_manager.active_preset.get_mapping(combination_5)) + self.assertIsNone(self.data_manager.active_preset.get_mapping(combination_6)) + + add_mapping(combination_3, "c") + self.assertEqual( + self.data_manager.active_preset.get_mapping(combination_1).output_symbol, + "a", + ) + self.assertEqual( + self.data_manager.active_preset.get_mapping(combination_2).output_symbol, + "a", + ) + self.assertEqual( + self.data_manager.active_preset.get_mapping(combination_3).output_symbol, + "c", + ) + self.assertEqual( + self.data_manager.active_preset.get_mapping(combination_4).output_symbol, + "c", + ) + self.assertIsNone(self.data_manager.active_preset.get_mapping(combination_5)) + self.assertIsNone(self.data_manager.active_preset.get_mapping(combination_6)) # same as with combination_2, the existing combination_3 blocks # combination_4 because they have the same keys and end in the # same key. - self.add_mapping_via_ui(combination_4, "d", expect_success=False) - self.assertEqual(active_preset.get_mapping(combination_1), ("a", "keyboard")) - self.assertEqual(active_preset.get_mapping(combination_2), ("a", "keyboard")) - self.assertEqual(active_preset.get_mapping(combination_3), ("c", "keyboard")) - self.assertEqual(active_preset.get_mapping(combination_4), ("c", "keyboard")) - self.assertIsNone(active_preset.get_mapping(combination_5)) - self.assertIsNone(active_preset.get_mapping(combination_6)) - - self.add_mapping_via_ui(combination_5, "e") - self.assertEqual(active_preset.get_mapping(combination_1), ("a", "keyboard")) - self.assertEqual(active_preset.get_mapping(combination_2), ("a", "keyboard")) - self.assertEqual(active_preset.get_mapping(combination_3), ("c", "keyboard")) - self.assertEqual(active_preset.get_mapping(combination_4), ("c", "keyboard")) - self.assertEqual(active_preset.get_mapping(combination_5), ("e", "keyboard")) - self.assertEqual(active_preset.get_mapping(combination_6), ("e", "keyboard")) + add_mapping(combination_4, "d") + self.assertEqual( + self.data_manager.active_preset.get_mapping(combination_1).output_symbol, + "a", + ) + self.assertEqual( + self.data_manager.active_preset.get_mapping(combination_2).output_symbol, + "a", + ) + self.assertEqual( + self.data_manager.active_preset.get_mapping(combination_3).output_symbol, + "c", + ) + self.assertEqual( + self.data_manager.active_preset.get_mapping(combination_4).output_symbol, + "c", + ) + self.assertIsNone(self.data_manager.active_preset.get_mapping(combination_5)) + self.assertIsNone(self.data_manager.active_preset.get_mapping(combination_6)) + + add_mapping(combination_5, "e") + self.assertEqual( + self.data_manager.active_preset.get_mapping(combination_1).output_symbol, + "a", + ) + self.assertEqual( + self.data_manager.active_preset.get_mapping(combination_2).output_symbol, + "a", + ) + self.assertEqual( + self.data_manager.active_preset.get_mapping(combination_3).output_symbol, + "c", + ) + self.assertEqual( + self.data_manager.active_preset.get_mapping(combination_4).output_symbol, + "c", + ) + self.assertEqual( + self.data_manager.active_preset.get_mapping(combination_5).output_symbol, + "e", + ) + self.assertEqual( + self.data_manager.active_preset.get_mapping(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()) + 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, + EventCombination.from_string("1,5,1"), + ) + 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, + EventCombination.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.send( + CombinationRecorded(EventCombination((EV_KEY, KEY_Q, 1))) + ) + 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.send( + CombinationRecorded(EventCombination((EV_KEY, KEY_Q, 1))) + ) + gtk_iteration() + self.message_broker.signal(MessageType.recording_finished) + gtk_iteration() + + self.controller.create_mapping() + gtk_iteration() + row: SelectionLabel = self.selection_label_listbox.get_selected_row() + self.assertEqual(row.combination, EventCombination.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: SelectionLabel = 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, EventCombination.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() # 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() - def test_remove_selection_label(self): - """Comprehensive test for selection_labels 2.""" - - def remove( - selection_label, - code, - symbol, - num_selection_labels_after, - target="keyboard", - ): - """Remove a selection_label by clicking the delete button. - - Parameters - ---------- - selection_label : SelectionLabel - code : int or None - keycode of the mapping that is associated with this selection_label - symbol : string - ouptut of the mapping that is associated with this selection_label - num_selection_labels_after : int - after deleting, how many selection_labels are expected to still be there - target : - selected target in target_selector - """ - self.selection_label_listbox.select_row(selection_label) - - if code is not None and symbol is not None: - self.assertEqual( - active_preset.get_mapping(EventCombination([EV_KEY, code, 1])), - (symbol, target), - ) - - if symbol is not None: - self.assertEqual(self.editor.get_symbol_input_text(), symbol) - - self.assertEqual(self.editor.get_target_selection(), target) - - if code is None: - self.assertIsNone(selection_label.get_combination()) - else: - self.assertEqual( - selection_label.get_combination(), - EventCombination([EV_KEY, code, 1]), - ) - - with PatchedConfirmDelete(self.user_interface): - self.editor._on_delete_button_clicked() - - time.sleep(0.2) - gtk_iteration() + # 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)) - # if a reference to the selection_label is held somewhere and it is - # accidentally used again, make sure to not provide any outdated - # information that is supposed to be deleted - self.assertIsNone(selection_label.get_combination()) - if code is not None: - self.assertIsNone( - active_preset.get_mapping(EventCombination([EV_KEY, code, 1])), - ) - - self.assertEqual( - len(self.get_selection_labels()), - num_selection_labels_after, - ) - - # sleeps are added to be able to visually follow and debug the test. Add two - # selection_labels by modifiying the one empty selection_label that exists - selection_label_1 = self.add_mapping_via_ui( - EventCombination([EV_KEY, 10, 1]), - "a", - ) - selection_label_2 = self.add_mapping_via_ui( - EventCombination([EV_KEY, 11, 1]), - "b", + # 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) ) - - # no empty selection_label added because one is unfinished - time.sleep(0.2) gtk_iteration() - self.assertEqual(len(self.get_selection_labels()), 3) + self.assertEqual(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( - active_preset.get_mapping(EventCombination([EV_KEY, 11, 1])), - ("b", "keyboard"), + self.data_manager.active_mapping.event_combination, + EventCombination.empty_combination(), ) - remove(selection_label_1, 10, "a", 2) - remove(selection_label_2, 11, "b", 1) + def test_remove_mapping(self): + self.controller.load_preset("preset1") + gtk_iteration() + self.assertEqual(len(self.data_manager.active_preset), 2) + self.assertEqual(len(self.selection_label_listbox.get_children()), 2) + + with PatchedConfirmDelete(self.user_interface): + self.delete_mapping_btn.clicked() + gtk_iteration() - # there is no empty selection_label at the moment, so after removing that one, - # which is the only selection_label, one empty selection_label will be there. - # So the number of selection_labels won't change. - remove(self.selection_label_listbox.get_children()[-1], None, None, 1) + self.assertEqual(len(self.data_manager.active_preset), 1) + self.assertEqual(len(self.selection_label_listbox.get_children()), 1) def test_problematic_combination(self): - combination = EventCombination((EV_KEY, KEY_LEFTSHIFT, 1), (EV_KEY, 82, 1)) - self.add_mapping_via_ui(combination, "b") + # load a device with more capabilities + self.controller.load_group("Foo Device 2") + gtk_iteration() + + def add_mapping(combi: EventCombination, symbol): + self.controller.create_mapping() + gtk_iteration() + self.controller.start_key_recording() + push_events("Foo Device 2", [event for event in combi]) + push_events("Foo Device 2", [event.modify(value=0) for event in combi]) + self.throttle(20) + gtk_iteration() + self.code_editor.get_buffer().set_text(symbol) + gtk_iteration() + + combination = EventCombination(((EV_KEY, KEY_LEFTSHIFT, 1), (EV_KEY, 82, 1))) + add_mapping(combination, "b") text = self.get_status_text() self.assertIn("shift", text) @@ -1228,421 +1140,204 @@ class TestGui(GuiTestBase): self.assertTrue(warning_icon.get_visible()) def test_rename_and_save(self): - self.assertEqual(self.user_interface.group.name, "Foo Device") - self.assertFalse(global_config.is_autoloaded("Foo Device", "new preset")) + # only a basic test, TestController and TestDataManager go more in detail + self.rename_input.set_text("foo") + self.rename_btn.clicked() + gtk_iteration() - m1 = get_ui_mapping() - active_preset.add(m1) - self.assertEqual(self.user_interface.preset_name, "new preset") - self.user_interface.save_preset() - self.assertEqual( - active_preset.get_mapping(EventCombination([99, 99, 99])), - m1, - ) - global_config.set_autoload_preset("Foo Device", "new preset") - self.assertTrue(global_config.is_autoloaded("Foo Device", "new preset")) - - m2 = get_ui_mapping() - m2.output_symbol = "b" - active_preset.get_mapping(EventCombination([99, 99, 99])).output_symbol = "b" - self.user_interface.get("preset_name_input").set_text("asdf") - self.user_interface.save_preset() - self.user_interface.on_rename_button_clicked(None) - self.assertEqual(self.user_interface.preset_name, "asdf") - preset_path = f"{CONFIG_PATH}/presets/Foo Device/asdf.json" + preset_path = f"{CONFIG_PATH}/presets/Foo Device/foo.json" self.assertTrue(os.path.exists(preset_path)) - self.assertEqual( - active_preset.get_mapping(EventCombination([99, 99, 99])), - m2, - ) - - # after renaming the preset it is still set to autoload - self.assertTrue(global_config.is_autoloaded("Foo Device", "asdf")) - # ALSO IN THE ACTUAL CONFIG FILE! - global_config.load_config() - self.assertTrue(global_config.is_autoloaded("Foo Device", "asdf")) - error_icon = self.user_interface.get("error_status_icon") self.assertFalse(error_icon.get_visible()) - # otherwise save won't do anything - m2.output_symbol = "c" - active_preset.get_mapping(EventCombination([99, 99, 99])).output_symbol = "c" - self.assertTrue(active_preset.has_unsaved_changes()) - def save(): raise PermissionError - with patch.object(active_preset, "save", save): - self.user_interface.save_preset() - status = self.get_status_text() - self.assertIn("Permission denied", status) + with patch.object(self.data_manager.active_preset, "save", save): + self.code_editor.get_buffer().set_text("f") + gtk_iteration() + status = self.get_status_text() + self.assertIn("Permission denied", status) with PatchedConfirmDelete(self.user_interface): - self.user_interface.on_delete_preset_clicked(None) - self.assertFalse(os.path.exists(preset_path)) - - def test_rename_create_switch(self): - # after renaming a preset and saving it, new presets - # start with "new preset" again - m1 = get_ui_mapping() - active_preset.add(m1) - self.user_interface.get("preset_name_input").set_text("asdf") - self.user_interface.save_preset() - self.user_interface.on_rename_button_clicked(None) - self.assertEqual(len(active_preset), 1) - self.assertEqual(self.user_interface.preset_name, "asdf") - - self.user_interface.on_create_preset_clicked() - self.assertEqual(self.user_interface.preset_name, "new preset") - self.assertEqual(len(self.selection_label_listbox.get_children()), 1) - self.assertEqual(len(active_preset), 0) - self.user_interface.save_preset() - - # symbol and code in the gui won't be carried over after selecting a preset - combination = EventCombination([EV_KEY, 15, 1]) - self.set_combination(combination) - self.editor.set_symbol_input_text("b") - - # selecting the first preset again loads the saved mapping, and saves - # the current changes in the gui - self.user_interface.on_select_preset(FakePresetDropdown("asdf")) - self.assertEqual( - active_preset.get_mapping(EventCombination([99, 99, 99])), - m1, - ) - self.assertEqual(len(active_preset), 1) - self.assertEqual(len(self.selection_label_listbox.get_children()), 2) - global_config.set_autoload_preset("Foo Device", "new preset") - - # renaming a preset to an existing name appends a number - self.user_interface.on_select_preset(FakePresetDropdown("new preset")) - self.user_interface.get("preset_name_input").set_text("asdf") - self.user_interface.on_rename_button_clicked(None) - self.assertEqual(self.user_interface.preset_name, "asdf 2") - # and that added number is correctly used in the autoload - # configuration as well - self.assertTrue(global_config.is_autoloaded("Foo Device", "asdf 2")) - m2 = get_ui_mapping() - m2.event_combination = "1,15,1" - m2.output_symbol = "b" - self.assertEqual( - active_preset.get_mapping(EventCombination([EV_KEY, 15, 1])).dict(), - m2.dict(), - ) - self.assertEqual(len(active_preset), 1) - self.assertEqual(len(self.selection_label_listbox.get_children()), 2) - - self.assertEqual(self.user_interface.get("preset_name_input").get_text(), "") - - # renaming the current preset to itself doesn't append a number and - # it doesn't do anything on the file system - def _raise(*_): - # should not get called - raise AssertionError - - with patch.object(os, "rename", _raise): - self.user_interface.get("preset_name_input").set_text("asdf 2") - self.user_interface.on_rename_button_clicked(None) - self.assertEqual(self.user_interface.preset_name, "asdf 2") - - self.user_interface.get("preset_name_input").set_text("") - self.user_interface.on_rename_button_clicked(None) - self.assertEqual(self.user_interface.preset_name, "asdf 2") + self.delete_preset_btn.clicked() + gtk_iteration() + self.assertFalse(os.path.exists(preset_path)) def test_check_for_unknown_symbols(self): status = self.user_interface.get("status_bar") error_icon = self.user_interface.get("error_status_icon") warning_icon = self.user_interface.get("warning_status_icon") - active_preset.change(EventCombination([EV_KEY, 71, 1]), "keyboard", "qux", None) - active_preset.change(EventCombination([EV_KEY, 72, 1]), "keyboard", "foo", None) - self.user_interface.save_preset() + self.controller.load_preset("preset1") + self.throttle() + self.controller.load_mapping(EventCombination.from_string("1,1,1")) + gtk_iteration() + self.controller.update_mapping(output_symbol="foo") + gtk_iteration() + self.controller.load_mapping(EventCombination.from_string("1,2,1")) + gtk_iteration() + self.controller.update_mapping(output_symbol="qux") + gtk_iteration() + tooltip = status.get_tooltip_text().lower() self.assertIn("qux", tooltip) self.assertTrue(error_icon.get_visible()) self.assertFalse(warning_icon.get_visible()) # it will still save it though - with open(get_preset_path("Foo Device", "new preset")) as f: + with open(get_preset_path("Foo Device", "preset1")) as f: content = f.read() self.assertIn("qux", content) self.assertIn("foo", content) - active_preset.change(EventCombination([EV_KEY, 71, 1]), "keyboard", "a", None) - self.user_interface.save_preset() + self.controller.update_mapping(output_symbol="a") + gtk_iteration() tooltip = status.get_tooltip_text().lower() self.assertIn("foo", tooltip) self.assertTrue(error_icon.get_visible()) self.assertFalse(warning_icon.get_visible()) - active_preset.change(EventCombination([EV_KEY, 72, 1]), "keyboard", "b", None) - self.user_interface.save_preset() + self.controller.load_mapping(EventCombination.from_string("1,1,1")) + gtk_iteration() + self.controller.update_mapping(output_symbol="b") + gtk_iteration() tooltip = status.get_tooltip_text() self.assertIsNone(tooltip) self.assertFalse(error_icon.get_visible()) self.assertFalse(warning_icon.get_visible()) def test_check_macro_syntax(self): - status = self.user_interface.get("status_bar") + status = self.status_bar error_icon = self.user_interface.get("error_status_icon") warning_icon = self.user_interface.get("warning_status_icon") - active_preset.change( - EventCombination([EV_KEY, 9, 1]), - "keyboard", - "k(1))", - None, - ) - self.user_interface.save_preset() + self.code_editor.get_buffer().set_text("k(1))") + gtk_iteration() tooltip = status.get_tooltip_text().lower() self.assertIn("brackets", tooltip) self.assertTrue(error_icon.get_visible()) self.assertFalse(warning_icon.get_visible()) - active_preset.change(EventCombination([EV_KEY, 9, 1]), "keyboard", "k(1)", None) - self.user_interface.save_preset() + self.code_editor.get_buffer().set_text("k(1)") + gtk_iteration() 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( - active_preset.get_mapping(EventCombination([EV_KEY, 9, 1])), - ("k(1)", "keyboard"), + self.data_manager.active_mapping.output_symbol, + "k(1)", ) - def test_debounce_check_on_typing(self): + def test_check_on_typing(self): status = self.user_interface.get("status_bar") - status.set_tooltip_text(None) error_icon = self.user_interface.get("error_status_icon") warning_icon = self.user_interface.get("warning_status_icon") - self.add_mapping_via_ui(EventCombination([EV_KEY, 10, 1]), "") - gtk_iteration() tooltip = status.get_tooltip_text() # nothing wrong yet self.assertIsNone(tooltip) # now change the mapping by typing into the field - buffer = self.editor.get_text_input().get_buffer() + buffer = self.code_editor.get_buffer() buffer.set_text("sdfgkj()") - self.throttle() - # debouncing, still nothing shown - tooltip = status.get_tooltip_text() - self.assertIsNone(tooltip) - - # after 510 ms the debouncing should have been triggered a syntax check - time.sleep(0.51) gtk_iteration() + # the mapping is immediately 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.editor.get_symbol_input_text(), "sdfgkj()") - - def test_select_device_and_preset(self): - foo_device_path = f"{CONFIG_PATH}/presets/Foo Device" - key_10 = EventCombination([EV_KEY, 10, 1]) - key_11 = EventCombination([EV_KEY, 11, 1]) - - # created on start because the first device is selected and some empty - # preset prepared. - self.assertTrue(os.path.exists(f"{foo_device_path}/new preset.json")) - self.assertEqual(self.user_interface.group.name, "Foo Device") - self.assertEqual(self.user_interface.preset_name, "new preset") - # change it to check if the gui loads presets correctly later - self.editor.set_combination(key_10) - self.editor.set_symbol_input_text("a") + self.assertEqual(self.data_manager.active_mapping.output_symbol, "sdfgkj()") - # create another one - self.user_interface.on_create_preset_clicked() + def test_select_device(self): + # simple test to make sure we can switch between devices + # more detailed tests in TestController and TestDataManager + self.device_selection.set_active_id("Bar Device") gtk_iteration() - self.assertTrue(os.path.exists(f"{foo_device_path}/new preset.json")) - self.assertTrue(os.path.exists(f"{foo_device_path}/new preset 2.json")) - self.assertEqual(self.user_interface.preset_name, "new preset 2") - self.assertEqual(len(active_preset), 0) - # this should not be loaded when "new preset" is selected, because it belongs - # to "new preset 2": - self.editor.set_combination(key_11) - self.editor.set_symbol_input_text("a") - # select the first one again - self.user_interface.on_select_preset(FakePresetDropdown("new preset")) + entries = {entry[0] for entry in self.preset_selection.get_child().get_model()} + self.assertEqual(entries, {"new preset"}) + + self.device_selection.set_active_id("Foo Device") gtk_iteration() - self.assertEqual(self.user_interface.preset_name, "new preset") - self.assertEqual(len(active_preset), 1) - self.assertEqual(active_preset.get_mapping(key_10), ("a", "keyboard")) + entries = {entry[0] for entry in self.preset_selection.get_child().get_model()} + self.assertEqual(entries, {"preset1", "preset2", "preset3"}) - self.assertListEqual( - sorted(os.listdir(f"{foo_device_path}")), - sorted(["new preset.json", "new preset 2.json"]), + # make sure a preset and mapping was loaded + self.assertIsNotNone(self.data_manager.active_preset) + self.assertEqual( + self.data_manager.active_preset.name, self.preset_selection.get_active_id() + ) + self.assertIsNotNone(self.data_manager.active_mapping) + self.assertEqual( + self.data_manager.active_mapping.event_combination, + self.selection_label_listbox.get_selected_row().combination, ) - """now try to change the name""" - - self.user_interface.get("preset_name_input").set_text("abc 123") + def test_select_preset(self): + # simple test to make sure we can switch between presets + # more detailed tests in TestController and TestDataManager + self.device_selection.set_active_id("Foo Device 2") + gtk_iteration() + self.preset_selection.set_active_id("preset1") gtk_iteration() - self.assertEqual(self.user_interface.preset_name, "new preset") - self.assertFalse(os.path.exists(f"{foo_device_path}/abc 123.json")) - - # putting new information into the editor does not lead to some weird - # problems. when doing the rename everything will be saved and then moved - # to the new path - self.editor.set_combination(EventCombination([EV_KEY, 10, 1])) - self.editor.set_symbol_input_text("1") - self.assertEqual(self.user_interface.preset_name, "new preset") - self.user_interface.on_rename_button_clicked(None) - self.assertEqual(self.user_interface.preset_name, "abc 123") + mappings = { + row.combination for row in self.selection_label_listbox.get_children() + } + self.assertEqual( + mappings, + { + EventCombination.from_string("1,1,1"), + EventCombination.from_string("1,2,1"), + }, + ) + self.assertFalse(self.autoload_toggle.get_active()) + self.preset_selection.set_active_id("preset2") gtk_iteration() - self.assertEqual(self.user_interface.preset_name, "abc 123") - self.assertTrue(os.path.exists(f"{foo_device_path}/abc 123.json")) - self.assertListEqual( - sorted(os.listdir(os.path.join(CONFIG_PATH, "presets"))), - sorted(["Foo Device"]), - ) - self.assertListEqual( - sorted(os.listdir(f"{foo_device_path}")), - sorted(["abc 123.json", "new preset 2.json"]), + + mappings = { + row.combination for row in self.selection_label_listbox.get_children() + } + self.assertEqual( + mappings, + { + EventCombination.from_string("1,3,1"), + EventCombination.from_string("1,4,1"), + }, ) + self.assertTrue(self.autoload_toggle.get_active()) def test_copy_preset(self): - selection_labels = self.selection_label_listbox - self.add_mapping_via_ui(EventCombination([EV_KEY, 81, 1]), "a") - time.sleep(0.1) - gtk_iteration() - self.user_interface.save_preset() - # 2 selection_labels: the changed selection_label and an empty selection_label - self.assertEqual(len(selection_labels.get_children()), 2) - - # should be cleared when creating a new preset - active_preset.set("a.b", 3) - self.assertEqual(active_preset.get("a.b"), 3) - - self.user_interface.on_create_preset_clicked() - - # the preset should be empty, only one empty selection_label present - self.assertEqual(len(selection_labels.get_children()), 1) - self.assertIsNone(active_preset.get("a.b")) - - # add one new selection_label again and a setting - self.add_mapping_via_ui(EventCombination([EV_KEY, 81, 1]), "b") - time.sleep(0.1) - gtk_iteration() - self.user_interface.save_preset() - self.assertEqual(len(selection_labels.get_children()), 2) - active_preset.set(["foo", "bar"], 2) - - # this time it should be copied - self.user_interface.on_copy_preset_clicked() - self.assertEqual(self.user_interface.preset_name, "new preset 2 copy") - self.assertEqual(len(selection_labels.get_children()), 2) - self.assertEqual(self.editor.get_symbol_input_text(), "b") - self.assertEqual(active_preset.get(["foo", "bar"]), 2) - - # make another copy - self.user_interface.on_copy_preset_clicked() - self.assertEqual(self.user_interface.preset_name, "new preset 2 copy 2") - self.assertEqual(len(selection_labels.get_children()), 2) - self.assertEqual(self.editor.get_symbol_input_text(), "b") - self.assertEqual(len(active_preset), 1) - self.assertEqual(active_preset.get("foo.bar"), 2) - - def test_gamepad_config(self): - # set some stuff in the beginning, otherwise gtk fails to - # do handler_unblock_by_func, which makes no sense at all. - # but it ONLY fails on right_joystick_purpose for some reason, - # unblocking the left one works just fine. I should open a bug report - # on gtk or something probably. - self.user_interface.get("left_joystick_purpose").set_active_id(BUTTONS) - self.user_interface.get("right_joystick_purpose").set_active_id(BUTTONS) - self.user_interface.get("joystick_mouse_speed").set_value(1) - active_preset.set_has_unsaved_changes(False) - - # select a device that is not a gamepad - self.user_interface.on_select_device(FakeDeviceDropdown("Foo Device")) - self.assertFalse(self.user_interface.get("gamepad_config").is_visible()) - self.assertFalse(active_preset.has_unsaved_changes()) - - # select a gamepad - self.user_interface.on_select_device(FakeDeviceDropdown("gamepad")) - self.assertTrue(self.user_interface.get("gamepad_config").is_visible()) - self.assertFalse(active_preset.has_unsaved_changes()) - - # set stuff - gtk_iteration() - self.user_interface.get("left_joystick_purpose").set_active_id(WHEEL) - self.user_interface.get("right_joystick_purpose").set_active_id(WHEEL) - joystick_mouse_speed = 5 - self.user_interface.get("joystick_mouse_speed").set_value(joystick_mouse_speed) - - # it should be stored in active_preset, which overwrites the - # global_config - global_config.set("gamepad.joystick.left_purpose", MOUSE) - global_config.set("gamepad.joystick.right_purpose", MOUSE) - global_config.set("gamepad.joystick.pointer_speed", 50) - self.assertTrue(active_preset.has_unsaved_changes()) - left_purpose = active_preset.get("gamepad.joystick.left_purpose") - right_purpose = active_preset.get("gamepad.joystick.right_purpose") - pointer_speed = active_preset.get("gamepad.joystick.pointer_speed") - self.assertEqual(left_purpose, WHEEL) - self.assertEqual(right_purpose, WHEEL) - self.assertEqual(pointer_speed, 2**joystick_mouse_speed) - - # select a device that is not a gamepad again - self.user_interface.on_select_device(FakeDeviceDropdown("Foo Device")) - self.assertFalse(self.user_interface.get("gamepad_config").is_visible()) - self.assertFalse(active_preset.has_unsaved_changes()) - - def test_wont_start(self): - error_icon = self.user_interface.get("error_status_icon") - preset_name = "foo preset" - group_name = "Bar Device" - self.user_interface.preset_name = preset_name - self.user_interface.group = groups.find(name=group_name) + # simple tests to ensure it works + # more detailed tests in TestController and TestDataManager - # empty + # check the initial state + entries = {entry[0] for entry in self.preset_selection.get_child().get_model()} + self.assertEqual(entries, {"preset1", "preset2", "preset3"}) + self.assertEqual(self.preset_selection.get_active_id(), "preset3") - active_preset.empty() - self.user_interface.save_preset() - self.user_interface.on_apply_preset_clicked(None) - text = self.get_status_text() - self.assertIn("add keys", text) - self.assertTrue(error_icon.get_visible()) - self.assertNotEqual( - self.user_interface.dbus.get_state(self.user_interface.group.key), RUNNING - ) + self.copy_preset_btn.clicked() + gtk_iteration() + entries = {entry[0] for entry in self.preset_selection.get_child().get_model()} + self.assertEqual(entries, {"preset1", "preset2", "preset3", "preset3 copy"}) + self.assertEqual(self.preset_selection.get_active_id(), "preset3 copy") - # not empty, but keys are held down + self.copy_preset_btn.clicked() + gtk_iteration() - active_preset.change(EventCombination([EV_KEY, KEY_A, 1]), "keyboard", "a") - self.user_interface.save_preset() - send_event_to_reader(new_event(EV_KEY, KEY_A, 1)) - reader.read() - self.assertEqual(len(reader._unreleased), 1) - self.assertFalse(self.user_interface.unreleased_warn) - self.user_interface.on_apply_preset_clicked(None) - text = self.get_status_text() - self.assertIn("release", text) - self.assertTrue(error_icon.get_visible()) - self.assertNotEqual( - self.user_interface.dbus.get_state(self.user_interface.group.key), RUNNING - ) - self.assertTrue(self.user_interface.unreleased_warn) - self.assertEqual( - self.user_interface.get("apply_system_layout").get_opacity(), 0.4 - ) + entries = {entry[0] for entry in self.preset_selection.get_child().get_model()} self.assertEqual( - self.user_interface.get("key_recording_toggle").get_opacity(), 1 + entries, {"preset1", "preset2", "preset3", "preset3 copy", "preset3 copy 2"} ) - # device grabbing fails - + def test_wont_start(self): def wait(): """Wait for the injector process to finish doing stuff.""" for _ in range(10): @@ -1651,11 +1346,28 @@ class TestGui(GuiTestBase): 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 keys", text) + self.assertTrue(error_icon.get_visible()) + self.assertNotEqual(self.daemon.get_state("Bar Device"), 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.user_interface.on_apply_preset_clicked(None) - self.assertFalse(self.user_interface.unreleased_warn) + self.start_injector_btn.clicked() + gtk_iteration() + text = self.get_status_text() # it takes a little bit of time self.assertIn("Starting injection", text) @@ -1664,21 +1376,13 @@ class TestGui(GuiTestBase): text = self.get_status_text() self.assertIn("not grabbed", text) self.assertTrue(error_icon.get_visible()) - self.assertNotEqual( - self.user_interface.dbus.get_state(self.user_interface.group.key), - RUNNING, - ) - - # for the second try, release the key. that should also work - send_event_to_reader(new_event(EV_KEY, KEY_A, 0)) - reader.read() - self.assertEqual(len(reader._unreleased), 0) + self.assertNotEqual(self.daemon.get_state("Foo Device 2"), RUNNING) # this time work properly self.grab_fails = False - active_preset.save(get_preset_path(group_name, preset_name)) - self.user_interface.on_apply_preset_clicked(None) + self.start_injector_btn.clicked() + gtk_iteration() text = self.get_status_text() self.assertIn("Starting injection", text) self.assertFalse(error_icon.get_visible()) @@ -1688,27 +1392,19 @@ class TestGui(GuiTestBase): text = self.get_status_text() self.assertNotIn("CTRL + DEL", text) # only shown if btn_left mapped self.assertFalse(error_icon.get_visible()) - self.assertEqual( - self.user_interface.dbus.get_state(self.user_interface.group.key), RUNNING - ) - - self.assertEqual( - self.user_interface.get("apply_system_layout").get_opacity(), 1 - ) - self.assertEqual( - self.user_interface.get("key_recording_toggle").get_opacity(), 0.4 - ) + self.assertEqual(self.daemon.get_state("Foo Device 2"), RUNNING) - # because this test managed to reproduce some minor bug: - # The mapping is supposed to be in active_preset._mapping, not in _config. - # For reasons I don't remember. - self.assertNotIn("mapping", active_preset._config) + def test_start_with_btn_left(self): + self.controller.load_group("Foo Device 2") + gtk_iteration() - def test_wont_start_2(self): - preset_name = "foo preset" - group_name = "Bar Device" - self.user_interface.preset_name = preset_name - self.user_interface.group = groups.find(name=group_name) + self.controller.create_mapping() + gtk_iteration() + self.controller.update_mapping( + event_combination=EventCombination(InputEvent.btn_left()), + output_symbol="a", + ) + gtk_iteration() def wait(): """Wait for the injector process to finish doing stuff.""" @@ -1718,62 +1414,37 @@ class TestGui(GuiTestBase): if "Starting" not in self.get_status_text(): return - # btn_left mapped - active_preset.change(EventCombination(InputEvent.btn_left()), "keyboard", "a") - self.user_interface.save_preset() - - # and combination held down - send_event_to_reader(new_event(EV_KEY, KEY_A, 1)) - reader.read() - self.assertEqual(len(reader._unreleased), 1) - self.assertFalse(self.user_interface.unreleased_warn) - # first apply, shows btn_left warning - self.user_interface.on_apply_preset_clicked(None) + self.start_injector_btn.clicked() + gtk_iteration() text = self.get_status_text() self.assertIn("click", text) - self.assertEqual( - self.user_interface.dbus.get_state(self.user_interface.group.key), UNKNOWN - ) - - # second apply, shows unreleased warning - self.user_interface.on_apply_preset_clicked(None) - text = self.get_status_text() - self.assertIn("release", text) - self.assertEqual( - self.user_interface.dbus.get_state(self.user_interface.group.key), UNKNOWN - ) + self.assertEqual(self.daemon.get_state("Foo Device 2"), UNKNOWN) - # third apply, overwrites both warnings - self.user_interface.on_apply_preset_clicked(None) + # second apply, overwrites + self.start_injector_btn.clicked() + gtk_iteration() wait() - self.assertEqual( - self.user_interface.dbus.get_state(self.user_interface.group.key), RUNNING - ) + self.assertEqual(self.daemon.get_state("Foo Device 2"), RUNNING) text = self.get_status_text() # because btn_left is mapped, shows help on how to stop # injecting via the keyboard self.assertIn("CTRL + DEL", text) - def test_can_modify_mapping(self): - preset_name = "foo preset" - group_name = "Bar Device" - self.user_interface.preset_name = preset_name - self.user_interface.group = groups.find(name=group_name) + def test_cannot_record_keys(self): + self.controller.load_group("Foo Device 2") + self.assertNotEqual(self.data_manager.get_state(), RUNNING) + self.assertNotIn("Stop Injection", self.get_status_text()) - self.assertNotEqual( - self.user_interface.dbus.get_state(self.user_interface.group.key), RUNNING - ) - self.user_interface.can_modify_preset() - text = self.get_status_text() - self.assertNotIn("Stop Injection", text) - active_preset.path = get_preset_path(group_name, preset_name) - active_preset.add( - get_ui_mapping(EventCombination([EV_KEY, KEY_A, 1]), "keyboard", "b"), - ) - active_preset.save() - self.user_interface.on_apply_preset_clicked(None) + 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) @@ -1781,376 +1452,314 @@ class TestGui(GuiTestBase): if "Starting" not in self.get_status_text(): break - self.assertEqual( - self.user_interface.dbus.get_state(self.user_interface.group.key), RUNNING - ) - - # the preset cannot be changed anymore - self.assertFalse(self.user_interface.can_modify_preset()) + self.assertEqual(self.data_manager.get_state(), RUNNING) # the toggle button should reset itself shortly - self.user_interface.editor.get_recording_toggle().set_active(True) + self.recording_toggle.set_active(True) + gtk_iteration() + self.assertFalse(self.recording_toggle.get_active()) + text = self.get_status_text() + self.assertIn("Stop Injection", 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(get_config_path()) + for _ in range(10): time.sleep(0.1) gtk_iteration() - if not self.user_interface.editor.get_recording_toggle().get_active(): + if self.data_manager.get_state() == RUNNING: break - self.assertFalse(self.user_interface.editor.get_recording_toggle().get_active()) - text = self.get_status_text() - self.assertIn("Stop Injection", text) - - def test_start_injecting(self): - keycode_from = 9 - keycode_to = 200 + # fail here so we don't block forever + self.assertEqual(self.data_manager.get_state(), RUNNING) - self.add_mapping_via_ui(EventCombination([EV_KEY, keycode_from, 1]), "a") - system_mapping.clear() - system_mapping._set("a", keycode_to) + # this is a stupid workaround for the bad test fixtures + # by switching the group we make sure that the helper no longer listens for + # events on "Foo Device 2" otherwise we would have two processes + # (helper and injector) reading the same pipe which can block this test + # indefinitely + self.controller.load_group("Foo Device") + gtk_iteration() push_events( "Foo Device 2", [ - new_event(evdev.events.EV_KEY, keycode_from, 1), - new_event(evdev.events.EV_KEY, keycode_from, 0), + new_event(evdev.events.EV_KEY, 5, 1), + new_event(evdev.events.EV_KEY, 5, 0), ], ) - # injecting for group.key will look at paths containing group.name - active_preset.save(get_preset_path("Foo Device", "foo preset")) - - # use only the manipulated system_mapping - if os.path.exists(os.path.join(tmp, XMODMAP_FILENAME)): - os.remove(os.path.join(tmp, XMODMAP_FILENAME)) - - # select the second Foo device - self.user_interface.group = groups.find(key="Foo Device 2") - - with spy(self.user_interface.dbus, "set_config_dir") as spy1: - self.user_interface.preset_name = "foo preset" - - with spy(self.user_interface.dbus, "start_injecting") as spy2: - self.user_interface.on_apply_preset_clicked(None) - # correctly uses group.key, not group.name - spy2.assert_called_once_with("Foo Device 2", "foo preset") - - spy1.assert_called_once_with(get_config_path()) - - # the integration tests will cause the injection to be started as - # processes, as intended. Luckily, recv will block until the events - # are handled and pushed. - - # Note, that appending events to pending_events won't work anymore - # from here on because the injector processes memory cannot be - # modified from here. - event = uinput_write_history_pipe[0].recv() self.assertEqual(event.type, evdev.events.EV_KEY) - self.assertEqual(event.code, keycode_to) + self.assertEqual(event.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, keycode_to) + self.assertEqual(event.code, KEY_A) self.assertEqual(event.value, 0) # the input-remapper device will not be shown - groups.refresh() - self.user_interface.populate_devices() - for entry in self.user_interface.device_store: + self.controller.refresh_groups() + gtk_iteration() + + for entry in self.device_selection.get_child().get_model(): # whichever attribute contains "input-remapper" self.assertNotIn("input-remapper", "".join(entry)) - def test_gamepad_purpose_mouse_and_button(self): - self.user_interface.on_select_device(FakeDeviceDropdown("gamepad")) - self.user_interface.get("right_joystick_purpose").set_active_id(MOUSE) - self.user_interface.get("left_joystick_purpose").set_active_id(BUTTONS) - self.user_interface.get("joystick_mouse_speed").set_value(6) + def test_stop_injecting(self): + self.controller.load_group("Foo Device 2") + self.start_injector_btn.clicked() gtk_iteration() - speed = active_preset.get("gamepad.joystick.pointer_speed") - active_preset.set("gamepad.joystick.non_linearity", 1) - self.assertEqual(speed, 2**6) - - # don't consume the events in the reader, they are used to test - # the injection - reader.terminate() - time.sleep(0.1) - - push_events( - "gamepad", - [new_event(EV_ABS, ABS_RX, MIN_ABS), new_event(EV_ABS, ABS_X, MAX_ABS)] - * 100, - ) - active_preset.change(EventCombination([EV_ABS, ABS_X, 1]), "keyboard", "a") - self.user_interface.save_preset() + for _ in range(10): + time.sleep(0.1) + gtk_iteration() + if self.data_manager.get_state() == RUNNING: + break + # fail here so we don't block forever + self.assertEqual(self.data_manager.get_state(), RUNNING) + # stupid fixture workaround + self.controller.load_group("Foo Device") gtk_iteration() - self.user_interface.on_apply_preset_clicked(None) - time.sleep(0.3) - - history = [] - while uinput_write_history_pipe[0].poll(): - history.append(uinput_write_history_pipe[0].recv().t) - - count_mouse = history.count((EV_REL, REL_X, -speed)) - count_button = history.count((EV_KEY, KEY_A, 1)) - self.assertGreater(count_mouse, 1) - self.assertEqual(count_button, 1) - self.assertEqual(count_button + count_mouse, len(history)) - - self.assertIn("gamepad", self.user_interface.dbus.injectors) - - def test_stop_injecting(self): - keycode_from = 16 - keycode_to = 90 - - self.add_mapping_via_ui(EventCombination([EV_KEY, keycode_from, 1]), "t") - system_mapping.clear() - system_mapping._set("t", keycode_to) - - # not all of those events should be processed, since that takes some - # time due to time.sleep in the fakes and the injection is stopped. - push_events("Bar Device", [new_event(1, keycode_from, 1)] * 100) - - active_preset.save(get_preset_path("Bar Device", "foo preset")) - - self.user_interface.group = groups.find(name="Bar Device") - self.user_interface.preset_name = "foo preset" - self.user_interface.on_apply_preset_clicked(None) - pipe = uinput_write_history_pipe[0] - # block until the first event is available, indicating that - # the injector is ready - write_history = [pipe.recv()] + self.assertFalse(pipe.poll()) - # stop - self.user_interface.on_stop_injecting_clicked(None) + push_events( + "Foo Device 2", + [ + new_event(evdev.events.EV_KEY, 5, 1), + new_event(evdev.events.EV_KEY, 5, 0), + ], + ) - # try to receive a few of the events time.sleep(0.2) + self.assertTrue(pipe.poll()) while pipe.poll(): - write_history.append(pipe.recv()) + pipe.recv() + + self.controller.load_group("Foo Device 2") + self.controller.stop_injecting() + gtk_iteration() - len_before = len(write_history) - self.assertLess(len(write_history), 50) + for _ in range(10): + time.sleep(0.1) + gtk_iteration() + if self.data_manager.get_state() == STOPPED: + break + self.assertEqual(self.data_manager.get_state(), STOPPED) - # since the injector should not be running anymore, no more events - # should be received after waiting even more time + push_events( + "Foo Device 2", + [ + new_event(evdev.events.EV_KEY, 5, 1), + new_event(evdev.events.EV_KEY, 5, 0), + ], + ) time.sleep(0.2) - while pipe.poll(): - write_history.append(pipe.recv()) - self.assertEqual(len(write_history), len_before) + self.assertFalse(pipe.poll()) def test_delete_preset(self): - self.editor.set_combination(EventCombination([EV_KEY, 71, 1])) - self.editor.set_symbol_input_text("a") - self.user_interface.get("preset_name_input").set_text("asdf") - self.user_interface.on_rename_button_clicked(None) - gtk_iteration() - self.assertEqual(self.user_interface.preset_name, "asdf") - self.assertEqual(len(active_preset), 1) - self.user_interface.save_preset() - self.assertTrue(os.path.exists(get_preset_path("Foo Device", "asdf"))) + # as per test_initial_state we already have preset3 loaded + + self.assertTrue(os.path.exists(get_preset_path("Foo Device", "preset3"))) with PatchedConfirmDelete(self.user_interface, Gtk.ResponseType.CANCEL): - self.user_interface.on_delete_preset_clicked(None) - self.assertTrue(os.path.exists(get_preset_path("Foo Device", "asdf"))) - self.assertEqual(self.user_interface.preset_name, "asdf") - self.assertEqual(self.user_interface.group.name, "Foo Device") - - with PatchedConfirmDelete(self.user_interface): - self.user_interface.on_delete_preset_clicked(None) - self.assertFalse(os.path.exists(get_preset_path("Foo Device", "asdf"))) - self.assertEqual(self.user_interface.preset_name, "new preset") - self.assertEqual(self.user_interface.group.name, "Foo Device") - - def test_populate_devices(self): - preset_selection = self.user_interface.get("preset_selection") - - # create two presets - self.user_interface.get("preset_name_input").set_text("preset 1") - self.user_interface.on_rename_button_clicked(None) - self.assertEqual(preset_selection.get_active_id(), "preset 1") - - # to make sure the next preset has a slightly higher timestamp - time.sleep(0.1) - self.user_interface.on_create_preset_clicked() - self.user_interface.get("preset_name_input").set_text("preset 2") - self.user_interface.on_rename_button_clicked(None) - self.assertEqual(preset_selection.get_active_id(), "preset 2") + self.delete_preset_btn.clicked() + gtk_iteration() + self.assertTrue(os.path.exists(get_preset_path("Foo Device", "preset3"))) + self.assertEqual(self.data_manager.active_preset.name, "preset3") + self.assertEqual(self.data_manager.active_group.name, "Foo Device") + + with PatchedConfirmDelete(self.user_interface): + self.delete_preset_btn.clicked() + gtk_iteration() + self.assertFalse(os.path.exists(get_preset_path("Foo Device", "preset3"))) + self.assertEqual(self.data_manager.active_preset.name, "preset2") + self.assertEqual(self.data_manager.active_group.name, "Foo Device") + + def test_refresh_groups(self): + # sanity check: preset3 should be the newest + self.assertEqual(self.preset_selection.get_active_id(), "preset3") # select the older one - preset_selection.set_active_id("preset 1") - self.assertEqual(self.user_interface.preset_name, "preset 1") + self.preset_selection.set_active_id("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.user_interface.device_store.insert(0, [unknown_key, None, "foo"]) + self.device_selection.get_child().get_model().insert( + 0, [unknown_key, None, "foo"] + ) - self.user_interface.populate_devices() + self.controller.refresh_groups() + gtk_iteration() + self.throttle(100) # the newest preset should be selected - self.assertEqual(self.user_interface.preset_name, "preset 2") + self.assertEqual(self.controller.get_a_preset(), "preset3") + self.assertEqual(self.data_manager.active_preset.name, "preset3") # the list contains correct entries # and the non-existing entry should be removed - entries = [tuple(entry) for entry in self.user_interface.device_store] - keys = [entry[0] for entry in self.user_interface.device_store] + entries = [ + tuple(entry) for entry in self.device_selection.get_child().get_model() + ] + keys = [entry[0] for entry in self.device_selection.get_child().get_model()] self.assertNotIn(unknown_key, keys) + self.assertIn("Foo Device", keys) self.assertIn(("Foo Device", "input-keyboard", "Foo Device"), entries) - self.assertIn(("Foo Device 2", "input-mouse", "Foo Device 2"), entries) + self.assertIn(("Foo Device 2", "input-gaming", "Foo Device 2"), entries) self.assertIn(("Bar Device", "input-keyboard", "Bar Device"), entries) self.assertIn(("gamepad", "input-gaming", "gamepad"), entries) # it won't crash due to "list index out of range" # when `types` is an empty list. Won't show an icon - groups.find(key="Foo Device 2").types = [] - self.user_interface.populate_devices() + self.data_manager._reader.groups.find(key="Foo Device 2").types = [] + self.data_manager._reader.send_groups() + gtk_iteration() self.assertIn( ("Foo Device 2", None, "Foo Device 2"), - [tuple(entry) for entry in self.user_interface.device_store], + [tuple(entry) for entry in self.device_selection.get_child().get_model()], ) 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() - # 1. create a preset - self.user_interface.on_select_device(FakeDeviceDropdown("Foo Device 2")) - self.user_interface.on_create_preset_clicked() - self.add_mapping_via_ui(EventCombination([3, 2, 1]), "qux") - self.user_interface.get("preset_name_input").set_text("asdf") - self.user_interface.on_rename_button_clicked(None) - self.user_interface.save_preset() - self.assertIn("asdf.json", os.listdir(get_preset_path("Foo Device"))) - - # 2. switch to the different device, there should be no preset named asdf - self.user_interface.on_select_device(FakeDeviceDropdown("Bar Device")) - self.assertEqual(self.user_interface.preset_name, "new preset") - self.assertNotIn("asdf.json", os.listdir(get_preset_path("Bar Device"))) - self.assertEqual(self.editor.get_symbol_input_text(), "") - - # 3. switch to the device with the same name as the first one - self.user_interface.on_select_device(FakeDeviceDropdown("Foo Device")) - # the newest preset is asdf, it should be automatically selected - self.assertEqual(self.user_interface.preset_name, "asdf") - self.assertEqual(self.editor.get_symbol_input_text(), "qux") + self.assertEqual(presets1, presets2) + self.assertNotEqual(presets1, presets3) def test_delete_last_preset(self): with PatchedConfirmDelete(self.user_interface): - # add some rows - for code in range(3): - self.add_mapping_via_ui(EventCombination([1, code, 1]), "qux") - - self.user_interface.on_delete_preset_clicked() - # the ui should be clear now - self.test_gui_clean() - device_path = f"{CONFIG_PATH}/presets/{self.user_interface.group.key}" + # as per test_initial_state we already have preset3 loaded + + self.delete_preset_btn.clicked() + gtk_iteration() + # the next newest preset should be loaded + self.assertEqual(self.data_manager.active_preset.name, "preset2") + self.delete_preset_btn.clicked() + gtk_iteration() + self.delete_preset_btn.clicked() + # the ui should be clean + self.assert_gui_clean() + device_path = f"{CONFIG_PATH}/presets/{self.data_manager.active_group.name}" self.assertTrue(os.path.exists(f"{device_path}/new preset.json")) - self.user_interface.on_delete_preset_clicked() + self.delete_preset_btn.clicked() + gtk_iteration() # deleting an empty preset als doesn't do weird stuff - self.test_gui_clean() - device_path = f"{CONFIG_PATH}/presets/{self.user_interface.group.key}" + self.assert_gui_clean() + device_path = f"{CONFIG_PATH}/presets/{self.data_manager.active_group.name}" self.assertTrue(os.path.exists(f"{device_path}/new preset.json")) def test_enable_disable_symbol_input(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.editor.get_code_editor().get_sensitive()) + self.assertFalse(self.code_editor.get_sensitive()) - self.editor.enable_symbol_input() - self.assertEqual(self.get_unfiltered_symbol_input_text(), "") - self.assertTrue(self.editor.get_text_input().get_sensitive()) - - # disable it - self.editor.disable_symbol_input() - self.assertFalse(self.editor.get_text_input().get_sensitive()) + # create a mapping + self.controller.create_mapping() + gtk_iteration() - # try to enable it by providing a key via set_combination - self.editor.set_combination(EventCombination((1, 201, 1))) - self.assertEqual(self.get_unfiltered_symbol_input_text(), "") - self.assertTrue(self.editor.get_text_input().get_sensitive()) + # should still be disabled + self.assertEqual(self.get_unfiltered_symbol_input_text(), SET_KEY_FIRST) + self.assertFalse(self.code_editor.get_sensitive()) - # disable it again - self.editor.set_combination(None) - self.assertFalse(self.editor.get_text_input().get_sensitive()) + # enable it by sending a combination + self.controller.start_key_recording() + gtk_iteration() + push_events( + "Bar Device", + [ + InputEvent.from_string("1,30,1"), + InputEvent.from_string("1,30,0"), + ], + ) + self.throttle(50) # give time for the input to arrive - # try to enable it via the reader - self.activate_recording_toggle() - send_event_to_reader(InputEvent.from_tuple((EV_KEY, 101, 1))) - self.user_interface.consume_newest_keycode() self.assertEqual(self.get_unfiltered_symbol_input_text(), "") - self.assertTrue(self.editor.get_code_editor().get_sensitive()) - - # it wouldn't clear user input, if for whatever reason (a bug?) there is user - # input in there when enable_symbol_input is called. - self.editor.set_symbol_input_text("foo") - self.editor.enable_symbol_input() - self.assertEqual(self.get_unfiltered_symbol_input_text(), "foo") - - def test_whitespace_symbol(self): - # test how the editor behaves when the text of a mapping is a whitespace. - # Caused an "Expected `symbol` not to be empty" error in the past, because - # the symbol was not stripped of whitespaces and logic was performed that - # resulted in a call to actually changing the mapping. - self.add_mapping_via_ui(EventCombination([1, 201, 1]), "a") - self.add_mapping_via_ui(EventCombination([1, 202, 1]), "b") + self.assertTrue(self.code_editor.get_sensitive()) - self.select_mapping(1) - self.assertEqual(self.editor.get_symbol_input_text(), "b") - self.editor.set_symbol_input_text(" ") + # disable it by deleting the mapping + with PatchedConfirmDelete(self.user_interface): + self.delete_mapping_btn.clicked() + gtk_iteration() - self.select_mapping(0) - self.assertEqual(self.editor.get_symbol_input_text(), "a") + self.assertEqual(self.get_unfiltered_symbol_input_text(), SET_KEY_FIRST) + self.assertFalse(self.code_editor.get_sensitive()) class TestAutocompletion(GuiTestBase): def press_key(self, keyval): event = Gdk.EventKey() event.keyval = keyval - self.editor.autocompletion.navigate(None, event) + self.user_interface.autocompletion.navigate(None, event) def test_autocomplete_key(self): - self.add_mapping_via_ui(EventCombination([1, 99, 1]), "") - source_view = self.editor.get_code_editor() - self.set_focus(source_view) + self.controller.update_mapping(output_symbol="") + gtk_iteration() + + self.set_focus(self.code_editor) complete_key_name = "Test_Foo_Bar" system_mapping.clear() system_mapping._set(complete_key_name, 1) + system_mapping._set("KEY_A", 30) # we need this for the UIMapping to work # it can autocomplete a combination inbetween other things incomplete = "qux_1\n + + qux_2" - Gtk.TextView.do_insert_at_cursor(source_view, incomplete) + Gtk.TextView.do_insert_at_cursor(self.code_editor, incomplete) Gtk.TextView.do_move_cursor( - source_view, + self.code_editor, Gtk.MovementStep.VISUAL_POSITIONS, -8, False, ) - Gtk.TextView.do_insert_at_cursor(source_view, "foo") - time.sleep(0.11) - gtk_iteration() + Gtk.TextView.do_insert_at_cursor(self.code_editor, "foo") + self.throttle(100) - autocompletion = self.editor.autocompletion + autocompletion = self.user_interface.autocompletion self.assertTrue(autocompletion.visible) self.press_key(Gdk.KEY_Down) self.press_key(Gdk.KEY_Return) + gtk_iteration() # the first suggestion should have been selected - modified_symbol = self.editor.get_symbol_input_text().strip() + modified_symbol = self.data_manager.active_mapping.output_symbol self.assertEqual(modified_symbol, f"qux_1\n + {complete_key_name} + qux_2") # try again, but a whitespace completes the word and so no autocompletion # should be shown - Gtk.TextView.do_insert_at_cursor(source_view, " + foo ") + Gtk.TextView.do_insert_at_cursor(self.code_editor, " + foo ") time.sleep(0.11) gtk_iteration() @@ -2158,8 +1767,10 @@ class TestAutocompletion(GuiTestBase): self.assertFalse(autocompletion.visible) def test_autocomplete_function(self): - self.add_mapping_via_ui(EventCombination([1, 99, 1]), "") - source_view = self.editor.get_code_editor() + self.controller.update_mapping(output_symbol="") + gtk_iteration() + + source_view = self.code_editor self.set_focus(source_view) incomplete = "key(KEY_A).\nepea" @@ -2168,19 +1779,21 @@ class TestAutocompletion(GuiTestBase): time.sleep(0.11) gtk_iteration() - autocompletion = self.editor.autocompletion + 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.editor.get_symbol_input_text().strip() + modified_symbol = self.data_manager.active_mapping.output_symbol self.assertEqual(modified_symbol, "key(KEY_A).\nrepeat") def test_close_autocompletion(self): - self.add_mapping_via_ui(EventCombination([1, 99, 1]), "") - source_view = self.editor.get_code_editor() + self.controller.update_mapping(output_symbol="") + gtk_iteration() + + source_view = self.code_editor self.set_focus(source_view) Gtk.TextView.do_insert_at_cursor(source_view, "KEY_") @@ -2188,7 +1801,7 @@ class TestAutocompletion(GuiTestBase): time.sleep(0.11) gtk_iteration() - autocompletion = self.editor.autocompletion + autocompletion = self.user_interface.autocompletion self.assertTrue(autocompletion.visible) self.press_key(Gdk.KEY_Down) @@ -2196,17 +1809,18 @@ class TestAutocompletion(GuiTestBase): self.assertFalse(autocompletion.visible) - symbol = self.editor.get_symbol_input_text().strip() + symbol = self.data_manager.active_mapping.output_symbol self.assertEqual(symbol, "KEY_") def test_writing_still_works(self): - self.add_mapping_via_ui(EventCombination([1, 99, 1]), "") - source_view = self.editor.get_code_editor() + self.controller.update_mapping(output_symbol="") + gtk_iteration() + source_view = self.code_editor self.set_focus(source_view) Gtk.TextView.do_insert_at_cursor(source_view, "KEY_") - autocompletion = self.editor.autocompletion + autocompletion = self.user_interface.autocompletion time.sleep(0.11) gtk_iteration() @@ -2229,13 +1843,14 @@ class TestAutocompletion(GuiTestBase): self.assertFalse(autocompletion.visible) def test_cycling(self): - self.add_mapping_via_ui(EventCombination([1, 99, 1]), "") - source_view = self.editor.get_code_editor() + self.controller.update_mapping(output_symbol="") + gtk_iteration() + source_view = self.code_editor self.set_focus(source_view) Gtk.TextView.do_insert_at_cursor(source_view, "KEY_") - autocompletion = self.editor.autocompletion + autocompletion = self.user_interface.autocompletion time.sleep(0.11) gtk_iteration() diff --git a/tests/integration/test_user_interface.py b/tests/integration/test_user_interface.py new file mode 100644 index 00000000..10e39889 --- /dev/null +++ b/tests/integration/test_user_interface.py @@ -0,0 +1,107 @@ +import unittest +from unittest.mock import MagicMock, patch +from evdev.ecodes import EV_KEY, KEY_A + +import gi + +gi.require_version("Gtk", "3.0") +gi.require_version("GLib", "2.0") +gi.require_version("GtkSource", "4") +from gi.repository import Gtk, GtkSource, Gdk, GObject, GLib + +from inputremapper.gui.utils import gtk_iteration +from tests.test import quick_cleanup +from inputremapper.gui.message_broker import MessageBroker, MessageType +from inputremapper.gui.user_interface import UserInterface +from inputremapper.configs.mapping import MappingData +from inputremapper.event_combination import EventCombination + + +class TestUserInterface(unittest.TestCase): + def setUp(self) -> None: + self.message_broker = MessageBroker() + self.controller_mock = MagicMock() + self.user_interface = UserInterface(self.message_broker, self.controller_mock) + + def tearDown(self) -> None: + super().tearDown() + self.message_broker.signal(MessageType.terminate) + GLib.timeout_add(0, self.user_interface.window.destroy) + GLib.timeout_add(0, Gtk.main_quit) + Gtk.main() + quick_cleanup() + + def test_shortcut(self): + mock = MagicMock() + self.user_interface.shortcuts[Gdk.KEY_x] = mock + + event = Gdk.Event() + event.key.keyval = Gdk.KEY_x + event.key.state = Gdk.ModifierType.SHIFT_MASK + self.user_interface.window.emit("key-press-event", event) + gtk_iteration() + mock.assert_not_called() + + event.key.state = Gdk.ModifierType.CONTROL_MASK + self.user_interface.window.emit("key-press-event", event) + gtk_iteration() + mock.assert_called_once() + + mock.reset_mock() + event.key.keyval = Gdk.KEY_y + self.user_interface.window.emit("key-press-event", event) + gtk_iteration() + mock.assert_not_called() + + def test_connected_shortcuts(self): + should_be_connected = {Gdk.KEY_q, Gdk.KEY_r, Gdk.KEY_Delete} + 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.send( + MappingData( + event_combination=EventCombination((EV_KEY, KEY_A, 1)), 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.send(MappingData()) + gtk_iteration() + label: Gtk.Label = self.user_interface.get("combination-label") + self.assertEqual(label.get_text(), "no input configured") + self.assertEqual(label.get_opacity(), 0.4) diff --git a/tests/test.py b/tests/test.py index d014477a..1df300f9 100644 --- a/tests/test.py +++ b/tests/test.py @@ -25,9 +25,28 @@ This module needs to be imported first in test files. """ import argparse +import json import os import sys import tempfile +import traceback +import warnings +from multiprocessing.connection import Connection +from typing import Dict, Tuple +import tracemalloc + +tracemalloc.start() + +try: + sys.modules.get("tests.test").main + raise AssertionError( + "test.py was already imported. " + "Always use 'from tests.test import ...' " + "not 'from test import ...' to import this" + ) + # have fun debugging infinitely blocking tests without this +except AttributeError: + pass def get_project_root(): @@ -132,7 +151,7 @@ tmp = temporary_directory.name uinput_write_history = [] # for tests that makes the injector create its processes uinput_write_history_pipe = multiprocessing.Pipe() -pending_events = {} +pending_events: Dict[str, Tuple[Connection, Connection]] = {} def read_write_history_pipe(): @@ -166,7 +185,10 @@ fixtures = { # see if the groups correct attribute is used in functions and paths. "/dev/input/event11": { "capabilities": { - evdev.ecodes.EV_KEY: [evdev.ecodes.BTN_LEFT], + evdev.ecodes.EV_KEY: [ + evdev.ecodes.BTN_LEFT, + evdev.ecodes.BTN_TOOL_DOUBLETAP, + ], evdev.ecodes.EV_REL: [ evdev.ecodes.REL_X, evdev.ecodes.REL_Y, @@ -200,6 +222,26 @@ fixtures = { "name": "Foo Device qux", "group_key": "Foo Device 2", }, + "/dev/input/event15": { + "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", + }, # Bar Device "/dev/input/event20": { "capabilities": {evdev.ecodes.EV_KEY: keyboard_keys}, @@ -256,6 +298,7 @@ def setup_pipe(group_key): which in turn will be sent to the reader """ if pending_events.get(group_key) is None: + logger.info("creating Pipe for %s", group_key) pending_events[group_key] = multiprocessing.Pipe() @@ -284,6 +327,7 @@ def push_event(group_key, event): event : InputEvent """ setup_pipe(group_key) + logger.info("Simulating %s for %s", event, group_key) pending_events[group_key][0].send(event) @@ -355,19 +399,21 @@ class InputDevice: logger.info("ungrab %s %s", self.name, self.path) async def async_read_loop(self): - if pending_events.get(self.group_key) is None: - self.log("no events to read", self.group_key) - return - - # consume all of them - while pending_events[self.group_key][1].poll(): - result = pending_events[self.group_key][1].recv() - self.log(result, "async_read_loop") - yield result - await asyncio.sleep(0.01) + 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.group_key][1].poll(): + # todo: why? why do we need this? + # sometimes this happens, as if a other process calls recv on + # the pipe + continue - # doesn't loop endlessly in order to run tests for the injector in - # the main process + event = pending_events[self.group_key][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 @@ -396,13 +442,12 @@ class InputDevice: def read_one(self): """Read one event or none if nothing available.""" - if pending_events.get(self.group_key) is None: + if not pending_events.get(self.group_key): return None - if len(pending_events[self.group_key]) == 0: + if not pending_events[self.group_key][1].poll(): return None - time.sleep(EVENT_READ_TIMEOUT) try: event = pending_events[self.group_key][1].recv() except (UnpicklingError, EOFError): @@ -541,6 +586,19 @@ def clear_write_history(): uinput_write_history_pipe[0].recv() +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") + + # quickly fake some stuff before any other file gets a chance to import # the original versions patch_paths() @@ -548,23 +606,26 @@ patch_evdev() patch_events() patch_os_system() patch_check_output() +# patch_warnings() from inputremapper.logger import update_verbosity update_verbosity(True) +from inputremapper.daemon import DaemonProxy from inputremapper.input_event import InputEvent as InternalInputEvent -from inputremapper.injection.injector import Injector +from inputremapper.injection.injector import Injector, RUNNING, STOPPED +from inputremapper.injection.macros.macro import macro_variables +from inputremapper.injection.global_uinputs import GlobalUInputs from inputremapper.configs.global_config import global_config from inputremapper.configs.mapping import Mapping, UIMapping -from inputremapper.gui.reader import reader -from inputremapper.groups import groups +from inputremapper.groups import groups, _Groups from inputremapper.configs.system_mapping import system_mapping -from inputremapper.gui.active_preset import active_preset -from inputremapper.configs.paths import get_config_path -from inputremapper.injection.macros.macro import macro_variables +from inputremapper.gui.message_broker import MessageBroker +from inputremapper.gui.reader import Reader +from inputremapper.configs.paths import get_config_path, get_preset_path +from inputremapper.configs.preset import Preset -# from inputremapper.injection.mapping_handlers.keycode_mapper import active_macros, unreleased from inputremapper.injection.global_uinputs import global_uinputs # no need for a high number in tests @@ -602,16 +663,6 @@ def get_ui_mapping( ) -def send_event_to_reader(event): - """Act like the helper and send input events to the reader.""" - reader._results._unread.append( - { - "type": "event", - "message": (event.sec, event.usec, event.type, event.code, event.value), - } - ) - - def quick_cleanup(log=True): """Reset the applications state.""" if log: @@ -628,11 +679,6 @@ def quick_cleanup(log=True): pending_events[device] = None setup_pipe(device) - try: - reader.terminate() - except (BrokenPipeError, OSError): - pass - try: if asyncio.get_event_loop().is_running(): for task in asyncio.all_tasks(): @@ -662,7 +708,6 @@ def quick_cleanup(log=True): global_config._save_config() system_mapping.populate() - active_preset.empty() clear_write_history() @@ -685,8 +730,6 @@ def quick_cleanup(log=True): if device not in environ_copy: del os.environ[device] - reader.clear() - for _, pipe in pending_events.values(): assert not pipe.poll() @@ -725,6 +768,75 @@ def spy(obj, name): return patch.object(obj, name, wraps=obj.__getattribute__(name)) +class FakeDaemonProxy: + def __init__(self): + self.calls = { + "stop_injecting": [], + "get_state": [], + "start_injecting": [], + "stop_all": 0, + "set_config_dir": [], + "autoload": 0, + "autoload_single": [], + "hello": [], + } + + def stop_injecting(self, group_key: str) -> None: + self.calls["stop_injecting"].append(group_key) + + def get_state(self, group_key: str) -> int: + self.calls["get_state"].append(group_key) + return 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 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(get_preset_path("Foo Device", "preset1")) + preset1.add(get_key_mapping(combination="1,1,1", output_symbol="b")) + preset1.add(get_key_mapping(combination="1,2,1")) + preset1.save() + + time.sleep(0.05) + preset2 = Preset(get_preset_path("Foo Device", "preset2")) + preset2.add(get_key_mapping(combination="1,3,1")) + preset2.add(get_key_mapping(combination="1,4,1")) + preset2.save() + + time.sleep(0.05) # make sure the timestamp of preset 3 is the newest + preset3 = Preset(get_preset_path("Foo Device", "preset3")) + preset3.add(get_key_mapping(combination="1,5,1")) + preset3.save() + + with open(get_config_path("config.json"), "w") as file: + json.dump({"autoload": {"Foo Device 2": "preset2"}}, file, indent=4) + + global_config.load_config() + + return preset1, preset2, preset3 + + cleanup() diff --git a/tests/unit/test_context.py b/tests/unit/test_context.py index 2eee09c9..1c496d92 100644 --- a/tests/unit/test_context.py +++ b/tests/unit/test_context.py @@ -85,9 +85,9 @@ class TestContext(unittest.TestCase): (1, 33): 1, (1, 34): 1, } - self.assertEqual(set(callbacks.keys()), set(context.callbacks.keys())) + self.assertEqual(set(callbacks.keys()), set(context.notify_callbacks.keys())) for key, val in callbacks.items(): - self.assertEqual(val, len(context.callbacks[key])) + self.assertEqual(val, len(context.notify_callbacks[key])) self.assertEqual( 7, len(context._handlers) diff --git a/tests/unit/test_control.py b/tests/unit/test_control.py index fd0edb0c..bebd2253 100644 --- a/tests/unit/test_control.py +++ b/tests/unit/test_control.py @@ -32,7 +32,6 @@ import collections from importlib.util import spec_from_loader, module_from_spec from importlib.machinery import SourceFileLoader -from inputremapper.gui.active_preset import active_preset from inputremapper.configs.global_config import global_config from inputremapper.daemon import Daemon from inputremapper.configs.preset import Preset @@ -42,7 +41,6 @@ from inputremapper.groups import groups def import_control(): """Import the core function of the input-remapper-control command.""" - active_preset.empty() bin_path = os.path.join(os.getcwd(), "bin", "input-remapper-control") diff --git a/tests/unit/test_controller.py b/tests/unit/test_controller.py new file mode 100644 index 00000000..825520cd --- /dev/null +++ b/tests/unit/test_controller.py @@ -0,0 +1,1189 @@ +#!/usr/bin/python3 +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2022 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . +import builtins +import json +import os.path +import time +import unittest +from dataclasses import dataclass +from unittest.mock import patch, MagicMock, call +from typing import Tuple, List, Any + +import gi + +from inputremapper.configs.system_mapping import system_mapping +from inputremapper.injection.injector import ( + RUNNING, + FAILED, + NO_GRAB, + UPGRADE_EVDEV, + UNKNOWN, + STOPPED, +) +from inputremapper.input_event import InputEvent + +gi.require_version("Gtk", "3.0") +gi.require_version("GLib", "2.0") +gi.require_version("GtkSource", "4") +from gi.repository import Gtk + +# from inputremapper.gui.helper import is_helper_running +from inputremapper.event_combination import EventCombination +from inputremapper.groups import _Groups +from inputremapper.gui.message_broker import ( + MessageBroker, + MessageType, + Signal, + UInputsData, + GroupsData, + GroupData, + PresetData, + StatusData, + CombinationUpdate, + CombinationRecorded, + UserConfirmRequest, +) +from inputremapper.gui.reader import Reader +from inputremapper.gui.utils import CTX_ERROR, CTX_APPLY, gtk_iteration +from inputremapper.gui.gettext import _ +from inputremapper.injection.global_uinputs import GlobalUInputs +from inputremapper.configs.mapping import Mapping, UIMapping, MappingData +from tests.test import ( + quick_cleanup, + get_key_mapping, + FakeDaemonProxy, + fixtures, + prepare_presets, + spy, +) +from inputremapper.configs.global_config import global_config, GlobalConfig +from inputremapper.gui.controller import Controller, MAPPING_DEFAULTS +from inputremapper.gui.data_manager import DataManager, DEFAULT_PRESET_NAME +from inputremapper.configs.paths import get_preset_path, get_config_path +from inputremapper.configs.preset import Preset + + +class TestController(unittest.TestCase): + def setUp(self) -> None: + super(TestController, self).setUp() + self.message_broker = MessageBroker() + uinputs = GlobalUInputs() + uinputs.prepare_all() + self.data_manager = DataManager( + self.message_broker, + GlobalConfig(), + Reader(self.message_broker, _Groups()), + FakeDaemonProxy(), + uinputs, + system_mapping, + ) + self.user_interface = MagicMock() + self.controller = Controller(self.message_broker, self.data_manager) + self.controller.set_gui(self.user_interface) + + def tearDown(self) -> None: + quick_cleanup() + + def test_should_get_newest_group(self): + """get_a_group should the newest group""" + with patch.object( + self.data_manager, "get_newest_group_key", MagicMock(return_value="foo") + ): + self.assertEqual(self.controller.get_a_group(), "foo") + + def test_should_get_any_group(self): + """get_a_group should return a valid group""" + with patch.object( + self.data_manager, + "get_newest_group_key", + MagicMock(side_effect=FileNotFoundError), + ): + fixture_keys = [ + fixture.get("group_key") or fixture.get("name") + for fixture in fixtures.values() + ] + 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") + self.assertEqual( + self.controller.get_a_preset(), "new preset" + ) # the default name + + 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"], + 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_init_should_provide_status_if_helper_is_not_running(self): + calls: List[StatusData] = [] + + def f(data): + calls.append(data) + + self.message_broker.subscribe(MessageType.status_msg, f) + with patch("inputremapper.gui.controller.is_helper_running", lambda: False): + self.message_broker.signal(MessageType.init) + self.assertIn(StatusData(CTX_ERROR, _("The helper did not start")), calls) + + def test_on_init_should_not_provide_status_if_helper_is_running(self): + calls: List[StatusData] = [] + + def f(data): + calls.append(data) + + self.message_broker.subscribe(MessageType.status_msg, f) + with patch("inputremapper.gui.controller.is_helper_running", lambda: True): + self.message_broker.signal(MessageType.init) + + self.assertNotIn(StatusData(CTX_ERROR, _("The helper did not start")), calls) + + def test_on_load_group_should_provide_preset(self): + with patch.object(self.data_manager, "load_preset") as mock: + self.controller.load_group("Foo Device") + mock.assert_called_once() + + def test_on_load_group_should_provide_mapping(self): + """if there is one""" + prepare_presets() + calls: List[MappingData] = [] + + def f(data): + calls.append(data) + + self.message_broker.subscribe(MessageType.mapping, f) + self.controller.load_group(group_key="Foo Device 2") + self.assertTrue(calls[-1].is_valid()) + + def test_on_load_group_should_provide_default_mapping(self): + """if there is none""" + calls: List[MappingData] = [] + + def f(data): + calls.append(data) + + self.message_broker.subscribe(MessageType.mapping, f) + + self.controller.load_group(group_key="Foo Device") + for m in calls: + self.assertEqual(m, UIMapping(**MAPPING_DEFAULTS)) + + def test_on_load_preset_should_provide_mapping(self): + """if there is one""" + prepare_presets() + self.data_manager.load_group("Foo Device 2") + calls: List[MappingData] = [] + + def f(data): + calls.append(data) + + self.message_broker.subscribe(MessageType.mapping, f) + self.controller.load_preset(name="preset2") + self.assertTrue(calls[-1].is_valid()) + + def test_on_load_preset_should_provide_default_mapping(self): + """if there is none""" + Preset(get_preset_path("Foo Device", "bar")).save() + self.data_manager.load_group("Foo Device 2") + calls: List[MappingData] = [] + + def f(data): + calls.append(data) + + self.message_broker.subscribe(MessageType.mapping, f) + self.controller.load_preset(name="bar") + for m in calls: + self.assertEqual(m, UIMapping(**MAPPING_DEFAULTS)) + + def test_on_delete_preset_asks_for_confirmation(self): + prepare_presets() + self.message_broker.signal(MessageType.init) + mock = MagicMock() + self.message_broker.subscribe(MessageType.user_confirm_request, mock) + self.controller.delete_preset() + mock.assert_called_once() + + def test_deletes_preset_when_confirmed(self): + prepare_presets() + self.assertTrue(os.path.isfile(get_preset_path("Foo Device", "preset2"))) + + self.data_manager.load_group("Foo Device 2") + self.data_manager.load_preset("preset2") + self.message_broker.subscribe( + MessageType.user_confirm_request, lambda msg: msg.respond(True) + ) + self.controller.delete_preset() + self.assertFalse(os.path.exists(get_preset_path("Foo Device", "preset2"))) + + def test_does_not_delete_preset_when_not_confirmed(self): + prepare_presets() + self.assertTrue(os.path.isfile(get_preset_path("Foo Device", "preset2"))) + self.data_manager.load_group("Foo Device 2") + self.data_manager.load_preset("preset2") + self.user_interface.confirm_delete.configure_mock( + return_value=Gtk.ResponseType.CANCEL + ) + self.controller.delete_preset() + self.assertTrue(os.path.isfile(get_preset_path("Foo Device", "preset2"))) + + def test_copy_preset(self): + prepare_presets() + self.assertTrue(os.path.isfile(get_preset_path("Foo Device", "preset2"))) + self.data_manager.load_group("Foo Device 2") + self.data_manager.load_preset("preset2") + self.controller.copy_preset() + + self.assertTrue(os.path.exists(get_preset_path("Foo Device", "preset2"))) + self.assertTrue(os.path.exists(get_preset_path("Foo Device", "preset2 copy"))) + + def test_copy_preset_should_add_number(self): + prepare_presets() + self.assertTrue(os.path.isfile(get_preset_path("Foo Device", "preset2"))) + + self.data_manager.load_group("Foo Device 2") + self.data_manager.load_preset("preset2") + self.controller.copy_preset() # creates "preset2 copy" + self.data_manager.load_preset("preset2") + self.controller.copy_preset() # creates "preset2 copy 2" + + self.assertTrue(os.path.exists(get_preset_path("Foo Device", "preset2"))) + self.assertTrue(os.path.exists(get_preset_path("Foo Device", "preset2 copy"))) + self.assertTrue(os.path.exists(get_preset_path("Foo Device", "preset2 copy 2"))) + + def test_copy_preset_should_increment_existing_number(self): + prepare_presets() + self.assertTrue(os.path.isfile(get_preset_path("Foo Device", "preset2"))) + + self.data_manager.load_group("Foo Device 2") + self.data_manager.load_preset("preset2") + self.controller.copy_preset() # creates "preset2 copy" + self.data_manager.load_preset("preset2") + self.controller.copy_preset() # creates "preset2 copy 2" + self.data_manager.load_preset("preset2") + self.controller.copy_preset() # creates "preset2 copy 3" + + self.assertTrue(os.path.exists(get_preset_path("Foo Device", "preset2"))) + self.assertTrue(os.path.exists(get_preset_path("Foo Device", "preset2 copy"))) + self.assertTrue(os.path.exists(get_preset_path("Foo Device", "preset2 copy 2"))) + self.assertTrue(os.path.exists(get_preset_path("Foo Device", "preset2 copy 3"))) + + def test_copy_preset_should_not_append_copy_twice(self): + prepare_presets() + self.assertTrue(os.path.isfile(get_preset_path("Foo Device", "preset2"))) + + self.data_manager.load_group("Foo Device 2") + self.data_manager.load_preset("preset2") + self.controller.copy_preset() # creates "preset2 copy" + self.controller.copy_preset() # creates "preset2 copy 2" not "preset2 copy copy" + + self.assertTrue(os.path.exists(get_preset_path("Foo Device", "preset2"))) + self.assertTrue(os.path.exists(get_preset_path("Foo Device", "preset2 copy"))) + self.assertTrue(os.path.exists(get_preset_path("Foo Device", "preset2 copy 2"))) + + def test_copy_preset_should_not_append_copy_to_copy_with_number(self): + prepare_presets() + self.assertTrue(os.path.isfile(get_preset_path("Foo Device", "preset2"))) + + self.data_manager.load_group("Foo Device 2") + self.data_manager.load_preset("preset2") + self.controller.copy_preset() # creates "preset2 copy" + self.data_manager.load_preset("preset2") + self.controller.copy_preset() # creates "preset2 copy 2" + self.controller.copy_preset() # creates "preset2 copy 3" not "preset2 copy 2 copy" + + self.assertTrue(os.path.exists(get_preset_path("Foo Device", "preset2"))) + self.assertTrue(os.path.exists(get_preset_path("Foo Device", "preset2 copy"))) + self.assertTrue(os.path.exists(get_preset_path("Foo Device", "preset2 copy 2"))) + self.assertTrue(os.path.exists(get_preset_path("Foo Device", "preset2 copy 3"))) + + def test_rename_preset(self): + prepare_presets() + self.assertTrue(os.path.isfile(get_preset_path("Foo Device", "preset2"))) + self.assertFalse(os.path.exists(get_preset_path("Foo Device", "foo"))) + + self.data_manager.load_group("Foo Device 2") + self.data_manager.load_preset("preset2") + self.controller.rename_preset(new_name="foo") + + self.assertFalse(os.path.exists(get_preset_path("Foo Device", "preset2"))) + self.assertTrue(os.path.exists(get_preset_path("Foo Device", "foo"))) + + def test_rename_preset_should_pick_available_name(self): + prepare_presets() + self.assertTrue(os.path.exists(get_preset_path("Foo Device", "preset2"))) + self.assertTrue(os.path.exists(get_preset_path("Foo Device", "preset3"))) + self.assertFalse(os.path.exists(get_preset_path("Foo Device", "preset3 2"))) + + self.data_manager.load_group("Foo Device") + self.data_manager.load_preset("preset2") + self.controller.rename_preset(new_name="preset3") + + self.assertFalse(os.path.exists(get_preset_path("Foo Device", "preset2"))) + self.assertTrue(os.path.exists(get_preset_path("Foo Device", "preset3"))) + self.assertTrue(os.path.exists(get_preset_path("Foo Device", "preset3 2"))) + + def test_rename_preset_should_not_rename_to_empty_name(self): + prepare_presets() + self.assertTrue(os.path.exists(get_preset_path("Foo Device", "preset2"))) + + self.data_manager.load_group("Foo Device") + self.data_manager.load_preset("preset2") + self.controller.rename_preset(new_name="") + + self.assertTrue(os.path.exists(get_preset_path("Foo Device", "preset2"))) + + def test_rename_preset_should_not_update_same_name(self): + """when the new name is the same as the current name""" + prepare_presets() + self.assertTrue(os.path.exists(get_preset_path("Foo Device", "preset2"))) + + self.data_manager.load_group("Foo Device 2") + self.data_manager.load_preset("preset2") + self.controller.rename_preset(new_name="preset2") + + self.assertTrue(os.path.exists(get_preset_path("Foo Device", "preset2"))) + self.assertFalse(os.path.exists(get_preset_path("Foo Device", "preset2 2"))) + + def test_on_add_preset_uses_default_name(self): + self.assertFalse( + os.path.exists(get_preset_path("Foo Device", DEFAULT_PRESET_NAME)) + ) + + self.data_manager.load_group("Foo Device 2") + + self.controller.add_preset() + self.assertTrue(os.path.exists(get_preset_path("Foo Device", "new preset"))) + + def test_on_add_preset_uses_provided_name(self): + self.assertFalse(os.path.exists(get_preset_path("Foo Device", "foo"))) + + self.data_manager.load_group("Foo Device 2") + + self.controller.add_preset(name="foo") + self.assertTrue(os.path.exists(get_preset_path("Foo Device", "foo"))) + + def test_on_add_preset_shows_permission_error_status(self): + self.data_manager.load_group("Foo Device 2") + + msg = None + + def f(data): + nonlocal msg + msg = data + + self.message_broker.subscribe(MessageType.status_msg, f) + mock = MagicMock(side_effect=PermissionError) + with patch("inputremapper.configs.preset.Preset.save", mock): + self.controller.add_preset("foo") + + mock.assert_called() + self.assertIsNotNone(msg) + self.assertIn("Permission denied", msg.msg) + + def test_on_update_mapping(self): + """update_mapping should call data_manager.update_mapping + this ensures mapping_changed is emitted + """ + prepare_presets() + self.data_manager.load_group("Foo Device 2") + self.data_manager.load_preset("preset2") + self.data_manager.load_mapping(combination=EventCombination("1,4,1")) + + with patch.object(self.data_manager, "update_mapping") as mock: + self.controller.update_mapping( + name="foo", + output_symbol="f", + release_timeout=0.3, + ) + mock.assert_called_once() + + def test_create_mapping_will_load_the_created_mapping(self): + prepare_presets() + self.data_manager.load_group("Foo Device 2") + self.data_manager.load_preset("preset2") + + calls: List[MappingData] = [] + + def f(data): + calls.append(data) + + self.message_broker.subscribe(MessageType.mapping, f) + self.controller.create_mapping() + + self.assertEqual(calls[-1], UIMapping(**MAPPING_DEFAULTS)) + + def test_create_mapping_should_not_create_multiple_empty_mappings(self): + prepare_presets() + self.data_manager.load_group("Foo Device 2") + self.data_manager.load_preset("preset2") + self.controller.create_mapping() # create a first empty mapping + + calls = [] + + def f(data): + calls.append(data) + + self.message_broker.subscribe(MessageType.mapping, f) + self.message_broker.subscribe(MessageType.preset, f) + + self.controller.create_mapping() # try to create a second one + self.assertEqual(len(calls), 0) + + def test_delete_mapping_asks_for_confirmation(self): + prepare_presets() + self.message_broker.signal(MessageType.init) + mock = MagicMock() + self.message_broker.subscribe(MessageType.user_confirm_request, mock) + self.controller.delete_mapping() + mock.assert_called_once() + + def test_deletes_mapping_when_confirmed(self): + prepare_presets() + self.assertTrue(os.path.isfile(get_preset_path("Foo Device", "preset2"))) + + self.data_manager.load_group("Foo Device 2") + self.data_manager.load_preset("preset2") + self.data_manager.load_mapping(EventCombination("1,3,1")) + self.message_broker.subscribe( + MessageType.user_confirm_request, lambda msg: msg.respond(True) + ) + self.controller.delete_mapping() + self.controller.save() + + preset = Preset(get_preset_path("Foo Device", "preset2")) + preset.load() + self.assertIsNone(preset.get_mapping(EventCombination("1,3,1"))) + + def test_does_not_delete_mapping_when_not_confirmed(self): + prepare_presets() + self.assertTrue(os.path.isfile(get_preset_path("Foo Device", "preset2"))) + + self.data_manager.load_group("Foo Device 2") + self.data_manager.load_preset("preset2") + self.data_manager.load_mapping(EventCombination("1,3,1")) + self.user_interface.confirm_delete.configure_mock( + return_value=Gtk.ResponseType.CANCEL + ) + + self.controller.delete_mapping() + self.controller.save() + + preset = Preset(get_preset_path("Foo Device", "preset2")) + preset.load() + self.assertIsNotNone(preset.get_mapping(EventCombination("1,3,1"))) + + 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(EventCombination.from_string("1,3,1")) + + calls: List[CombinationUpdate] = [] + + def f(data): + calls.append(data) + + self.message_broker.subscribe(MessageType.combination_update, f) + self.controller.update_combination(EventCombination.from_string("1,10,1")) + self.assertEqual( + calls[0], + CombinationUpdate( + EventCombination.from_string("1,3,1"), + EventCombination.from_string("1,10,1"), + ), + ) + + 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(EventCombination.from_string("1,3,1")) + + calls: List[CombinationUpdate] = [] + + def f(data): + calls.append(data) + + self.message_broker.subscribe(MessageType.combination_update, f) + self.controller.update_combination(EventCombination.from_string("1,4,1")) + self.assertEqual(len(calls), 0) + + 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_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(EventCombination.from_string("1,3,1")) + + 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.send( + CombinationRecorded(EventCombination.from_string("1,10,1")) + ) + self.assertEqual( + calls[0], + CombinationUpdate( + EventCombination.from_string("1,3,1"), + EventCombination.from_string("1,10,1"), + ), + ) + self.message_broker.send( + CombinationRecorded(EventCombination.from_string("1,10,1+1,3,1")) + ) + self.assertEqual( + calls[1], + CombinationUpdate( + EventCombination.from_string("1,10,1"), + EventCombination.from_string("1,10,1+1,3,1"), + ), + ) + + 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(EventCombination.from_string("1,3,1")) + + calls: List[CombinationUpdate] = [] + + def f(data): + calls.append(data) + + self.message_broker.subscribe(MessageType.combination_update, f) + + self.message_broker.send( + CombinationRecorded(EventCombination.from_string("1,10,1")) + ) + 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(EventCombination.from_string("1,3,1")) + + 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.send( + CombinationRecorded(EventCombination.from_string("1,10,1")) + ) + self.message_broker.signal(MessageType.recording_finished) + self.message_broker.send( + CombinationRecorded(EventCombination.from_string("1,10,1+1,3,1")) + ) + + 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(EventCombination.from_string("1,3,1")) + + 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.send( + CombinationRecorded(EventCombination.from_string("1,10,1")) + ) + self.controller.stop_key_recording() + self.message_broker.send( + CombinationRecorded(EventCombination.from_string("1,10,1+1,3,1")) + ) + + 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 keys and save 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( + event_combination=EventCombination(InputEvent.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( + event_combination=EventCombination(InputEvent.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( + event_combination=EventCombination(InputEvent.btn_left()), + target_uinput="keyboard", + output_symbol="a", + ) + self.data_manager.create_mapping() + self.data_manager.load_mapping(EventCombination.empty_combination()) + self.data_manager.update_mapping( + event_combination=EventCombination.from_string("1,5,1"), + 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=STOPPED) + self.data_manager.get_state = mock + self.controller.stop_injecting() + gtk_iteration(50) + + mock.assert_called() + self.assertEqual( + calls[-1], StatusData(CTX_APPLY, _("Applied the system default")) + ) + + 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=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 = FAILED + self.controller.start_injecting() + gtk_iteration(50) + self.assertEqual(calls[-1].msg, _("Failed to apply preset %s") % "preset2") + + mock.return_value = NO_GRAB + self.controller.start_injecting() + gtk_iteration(50) + self.assertEqual(calls[-1].msg, "The device was not grabbed") + + mock.return_value = 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(EventCombination("1,3,1")) + self.data_manager.update_mapping( + event_combination=EventCombination.from_string("1,1,1+1,2,1+1,3,1") + ) + + self.controller.move_event_in_combination(InputEvent.from_string("1,2,1"), "up") + self.assertEqual( + self.data_manager.active_mapping.event_combination, + EventCombination.from_string("1,2,1+1,1,1+1,3,1"), + ) + # now nothing changes + self.controller.move_event_in_combination(InputEvent.from_string("1,2,1"), "up") + self.assertEqual( + self.data_manager.active_mapping.event_combination, + EventCombination.from_string("1,2,1+1,1,1+1,3,1"), + ) + + 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(EventCombination("1,3,1")) + self.data_manager.update_mapping( + event_combination=EventCombination.from_string("1,1,1+1,2,1+1,3,1") + ) + + self.controller.move_event_in_combination( + InputEvent.from_string("1,2,1"), "down" + ) + self.assertEqual( + self.data_manager.active_mapping.event_combination, + EventCombination.from_string("1,1,1+1,3,1+1,2,1"), + ) + # now nothing changes + self.controller.move_event_in_combination( + InputEvent.from_string("1,2,1"), "down" + ) + self.assertEqual( + self.data_manager.active_mapping.event_combination, + EventCombination.from_string("1,1,1+1,3,1+1,2,1"), + ) + + 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(EventCombination("1,3,1")) + self.controller.move_event_in_combination( + InputEvent.from_string("1,3,1"), "down" + ) + self.assertEqual( + self.data_manager.active_mapping.event_combination, + EventCombination.from_string("1,3,1"), + ) + + 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(EventCombination("1,3,1")) + self.data_manager.update_mapping( + event_combination=EventCombination.from_string("1,1,1+1,2,1+1,3,1") + ) + mock = MagicMock() + self.message_broker.subscribe(MessageType.selected_event, mock) + self.controller.move_event_in_combination( + InputEvent.from_string("1,2,1"), "down" + ) + mock.assert_called_once_with(InputEvent.from_string("1,2,1")) + + 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(EventCombination("1,3,1")) + self.data_manager.load_event(InputEvent.from_string("1,3,1")) + mock = MagicMock() + self.message_broker.subscribe(MessageType.selected_event, mock) + self.controller.update_event(InputEvent.from_string("1,10,1")) + mock.assert_called_once_with(InputEvent.from_string("1,10,1")) + + 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(EventCombination("1,3,1")) + self.data_manager.load_event(InputEvent.from_string("1,3,1")) + 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(InputEvent.from_string("1,3,1")), + ] + self.controller.update_event(InputEvent.from_string("1,4,1")) # 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(EventCombination("1,3,1")) + self.data_manager.update_mapping(event_combination="1,3,1+1,4,1") + self.assertEqual( + self.data_manager.active_mapping.event_combination, + EventCombination.from_string("1,3,1+1,4,1"), + ) + self.data_manager.load_event(InputEvent.from_string("1,4,1")) + + self.controller.remove_event() + self.assertEqual( + self.data_manager.active_mapping.event_combination, + EventCombination.from_string("1,3,1"), + ) + + 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(EventCombination("1,3,1")) + self.data_manager.update_mapping(event_combination="1,3,1+1,4,1") + self.assertEqual( + self.data_manager.active_mapping.event_combination, + EventCombination.from_string("1,3,1+1,4,1"), + ) + self.data_manager.load_event(InputEvent.from_string("1,4,1")) + + mock = MagicMock() + self.message_broker.subscribe(MessageType.selected_event, mock) + self.controller.remove_event() + mock.assert_called_once_with(InputEvent.from_string("1,3,1")) + + 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(EventCombination("1,3,1")) + self.data_manager.update_mapping(event_combination="1,3,1+1,4,1") + self.data_manager.load_event(InputEvent.from_string("1,3,1")) + + # 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(InputEvent.from_string("1,3,1")), + ] + self.controller.remove_event() + mock.assert_has_calls(calls, any_order=False) + + 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(EventCombination("1,3,1")) + self.data_manager.update_mapping(event_combination="3,0,10") + self.data_manager.load_event(InputEvent.from_string("3,0,10")) + + self.controller.set_event_as_analog(True) + self.assertEqual( + self.data_manager.active_mapping.event_combination, + EventCombination.from_string("3,0,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(EventCombination("1,3,1")) + self.data_manager.update_mapping(event_combination="2,0,0") + self.data_manager.load_event(InputEvent.from_string("2,0,0")) + + self.controller.set_event_as_analog(False) + combinations = [EventCombination("2,0,1"), EventCombination("2,0,-1")] + self.assertIn(self.data_manager.active_mapping.event_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(EventCombination("1,3,1")) + self.data_manager.update_mapping(event_combination="3,0,0") + self.data_manager.load_event(InputEvent.from_string("3,0,0")) + + self.controller.set_event_as_analog(False) + combinations = [EventCombination("3,0,10"), EventCombination("3,0,-10")] + self.assertIn(self.data_manager.active_mapping.event_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(EventCombination("1,3,1")) + self.data_manager.load_event(InputEvent.from_string("1,3,1")) + + 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(InputEvent.from_string("1,3,1")), + ] + 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(EventCombination("1,3,1")) + self.data_manager.update_mapping(event_combination="3,0,10") + self.data_manager.load_event(InputEvent.from_string("3,0,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(InputEvent.from_string("3,0,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(EventCombination("1,3,1")) + self.data_manager.update_mapping(event_combination="3,0,0") + self.data_manager.load_event(InputEvent.from_string("3,0,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(InputEvent.from_string("3,0,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) diff --git a/tests/unit/test_daemon.py b/tests/unit/test_daemon.py index 3521d1fd..92b92faf 100644 --- a/tests/unit/test_daemon.py +++ b/tests/unit/test_daemon.py @@ -160,9 +160,9 @@ class TestDaemon(unittest.TestCase): self.assertEqual(event.value, 1) self.daemon.stop_injecting(group.key) + time.sleep(0.2) self.assertEqual(self.daemon.get_state(group.key), STOPPED) - time.sleep(0.1) try: self.assertFalse(uinput_write_history_pipe[0].poll()) except AssertionError: @@ -171,13 +171,13 @@ class TestDaemon(unittest.TestCase): raise """Injection 2""" + self.daemon.start_injecting(group.key, preset_name) + time.sleep(0.1) # -1234 will be classified as -1 by the injector push_events(group.key, [new_event(*ev_2, -1234)]) - - self.daemon.start_injecting(group.key, preset_name) - time.sleep(0.1) + self.assertTrue(uinput_write_history_pipe[0].poll()) # the written key is a key-down event, not the original @@ -255,6 +255,7 @@ class TestDaemon(unittest.TestCase): self.assertEqual(event.t, (EV_KEY, KEY_A, 1)) self.daemon.stop_injecting(group_key) + time.sleep(0.2) self.assertEqual(self.daemon.get_state(group_key), STOPPED) def test_refresh_for_unknown_key(self): @@ -354,6 +355,7 @@ class TestDaemon(unittest.TestCase): 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(), STOPPED) # a different injetor is now running self.assertNotEqual(previous_injector, daemon.injectors[group.key]) @@ -377,6 +379,7 @@ class TestDaemon(unittest.TestCase): # 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(), STOPPED) self.assertTrue(daemon.autoload_history.may_autoload(group.key, preset_name)) @@ -409,7 +412,7 @@ class TestDaemon(unittest.TestCase): injector = daemon.injectors[group.key] self.assertEqual(len_before + 1, len_after) - # calling duplicate _autoload does nothing + # calling duplicate get_autoload does nothing self.daemon._autoload(group.key) self.assertEqual( daemon.autoload_history._autoload_history[group.key][1], preset_name diff --git a/tests/unit/test_data_manager.py b/tests/unit/test_data_manager.py new file mode 100644 index 00000000..5476e541 --- /dev/null +++ b/tests/unit/test_data_manager.py @@ -0,0 +1,893 @@ +#!/usr/bin/python3 +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2022 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . +import json +import os +import time +import unittest +from itertools import permutations +from typing import List, Dict, Any +from unittest.mock import MagicMock, call + +from inputremapper.configs.global_config import global_config +from inputremapper.configs.mapping import UIMapping, MappingData +from inputremapper.configs.system_mapping import system_mapping +from inputremapper.event_combination import EventCombination +from inputremapper.exceptions import DataManagementError +from inputremapper.groups import _Groups +from inputremapper.gui.message_broker import ( + MessageBroker, + MessageType, + GroupData, + PresetData, + CombinationUpdate, +) +from inputremapper.gui.reader import Reader +from inputremapper.injection.global_uinputs import GlobalUInputs +from inputremapper.input_event import InputEvent +from tests.test import get_key_mapping, quick_cleanup, FakeDaemonProxy, prepare_presets + +from inputremapper.configs.paths import get_preset_path, get_config_path +from inputremapper.configs.preset import Preset +from inputremapper.gui.data_manager import DataManager, DEFAULT_PRESET_NAME + + +class Listener: + def __init__(self): + self.calls: List = [] + + def __call__(self, data): + self.calls.append(data) + + +class TestDataManager(unittest.TestCase): + def setUp(self) -> None: + self.message_broker = MessageBroker() + self.reader = Reader(self.message_broker, _Groups()) + self.uinputs = GlobalUInputs() + self.uinputs.prepare_all() + self.data_manager = DataManager( + self.message_broker, + global_config, + self.reader, + FakeDaemonProxy(), + self.uinputs, + system_mapping, + ) + + def tearDown(self) -> None: + quick_cleanup() + + def test_load_group_provides_presets(self): + """we should get all preset of a group, when loading it""" + prepare_presets() + response: List[GroupData] = [] + + def listener(data: GroupData): + response.append(data) + + self.message_broker.subscribe(MessageType.group, listener) + self.data_manager.load_group("Foo Device 2") + + for preset_name in response[0].presets: + self.assertIn( + preset_name, + ( + "preset1", + "preset2", + "preset3", + ), + ) + + self.assertEqual(response[0].group_key, "Foo Device 2") + + def test_load_group_without_presets_provides_none(self): + """we should get no presets when loading a group without presets""" + response: List[GroupData] = [] + + def listener(data: GroupData): + response.append(data) + + self.message_broker.subscribe(MessageType.group, listener) + + self.data_manager.load_group(group_key="Foo Device 2") + self.assertEqual(len(response[0].presets), 0) + + def test_load_non_existing_group(self): + """we should not be able to load an unknown group""" + with self.assertRaises(DataManagementError): + self.data_manager.load_group(group_key="Some Unknown Device") + + def test_cannot_load_preset_without_group(self): + """loading a preset without a loaded group should + raise a DataManagementError""" + prepare_presets() + self.assertRaises( + DataManagementError, + self.data_manager.load_preset, + name="preset1", + ) + + def test_load_preset(self): + """loading an existing preset should be possible""" + prepare_presets() + + self.data_manager.load_group(group_key="Foo Device") + listener = Listener() + self.message_broker.subscribe(MessageType.preset, listener) + self.data_manager.load_preset(name="preset1") + mappings = listener.calls[0].mappings + preset_name = listener.calls[0].name + + expected_preset = Preset(get_preset_path("Foo Device", "preset1")) + expected_preset.load() + expected_mappings = [ + (mapping.name, mapping.event_combination) for mapping in 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 an 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=EventCombination("1,1,1")) + + mapping: MappingData = listener.calls[0] + control_preset = Preset(get_preset_path("Foo Device", "preset1")) + control_preset.load() + self.assertEqual( + control_preset.get_mapping(EventCombination("1,1,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(EventCombination("1,1,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_load_mapping(self): + """should be able to load a mapping""" + preset, _, _ = prepare_presets() + expected_mapping = preset.get_mapping(EventCombination("1,1,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=EventCombination("1,1,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=EventCombination("1,1,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=EventCombination("1,1,1"), + ) + self.data_manager.load_group("Foo Device") + self.assertRaises( + DataManagementError, + self.data_manager.load_mapping, + combination=EventCombination("1,1,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(EventCombination("1,1,1")) + self.data_manager.load_event(InputEvent.from_string("1,1,1")) + mock.assert_called_once_with(InputEvent.from_string("1,1,1")) + self.assertEqual( + self.data_manager.active_event, InputEvent.from_string("1,1,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_event(InputEvent.from_string("1,1,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(EventCombination("1,1,1")) + with self.assertRaises(ValueError): + self.data_manager.load_event(InputEvent.from_string("1,5,1")) + + 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(EventCombination("1,1,1")) + self.data_manager.load_event(InputEvent.from_string("1,1,1")) + self.data_manager.update_event(InputEvent.from_string("1,5,1")) + self.assertEqual( + self.data_manager.active_event, InputEvent.from_string("1,5,1") + ) + + 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(EventCombination("1,1,1")) + self.data_manager.load_event(InputEvent.from_string("1,1,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_event(InputEvent.from_string("1,5,1")) + expected = [ + call( + CombinationUpdate(EventCombination("1,1,1"), EventCombination("1,5,1")) + ), + call(self.data_manager.active_mapping.get_bus_message()), + call(InputEvent.from_string("1,5,1")), + ] + 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(EventCombination("1,1,1")) + self.data_manager.load_event(InputEvent.from_string("1,1,1")) + with self.assertRaises(KeyError): + self.data_manager.update_event(InputEvent.from_string("1,2,1")) + + 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(EventCombination("1,1,1")) + with self.assertRaises(DataManagementError): + self.data_manager.update_event(InputEvent.from_string("1,2,1")) + + 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=EventCombination("1,4,1")) + + 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=EventCombination("1,4,1")) + + self.data_manager.update_mapping( + name="foo", + output_symbol="f", + release_timeout=0.3, + ) + self.data_manager.save() + + preset = Preset(get_preset_path("Foo Device", "preset2"), UIMapping) + preset.load() + mapping = preset.get_mapping(EventCombination("1,4,1")) + self.assertEqual(mapping.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=EventCombination("1,4,1")) + + self.data_manager.update_mapping( + output_symbol="bar", # not a macro and not a valid symbol + ) + self.data_manager.save() + + preset = Preset(get_preset_path("Foo Device", "preset2"), UIMapping) + preset.load() + mapping = preset.get_mapping(EventCombination("1,4,1")) + 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=EventCombination("1,4,1")) + 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( + event_combination=EventCombination.from_string("1,5,1+1,6,1") + ) + self.assertEqual(listener.calls[0].message_type, MessageType.combination_update) + self.assertEqual( + listener.calls[0].old_combination, + EventCombination.from_string("1,4,1"), + ) + self.assertEqual( + listener.calls[0].new_combination, + EventCombination.from_string("1,5,1+1,6,1"), + ) + self.assertEqual(listener.calls[1].message_type, MessageType.mapping) + self.assertEqual( + listener.calls[1].event_combination, + EventCombination.from_string("1,5,1+1,6,1"), + ) + + 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=EventCombination("1,4,1")) + + self.assertRaises( + KeyError, + self.data_manager.update_mapping, + event_combination=EventCombination("1,3,1"), + ) + + 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=EventCombination.empty_combination()) + + self.assertEqual(listener.calls[0].name, "preset2") + self.assertEqual(len(listener.calls[0].mappings), 3) + self.assertEqual(listener.calls[1], UIMapping()) + + def test_cannot_create_mapping_without_preset(self): + """adding a mapping if not preset is loaded + should raise an DataManagementError""" + prepare_presets() + + self.assertRaises(DataManagementError, self.data_manager.create_mapping) + self.data_manager.load_group(group_key="Foo Device 2") + self.assertRaises(DataManagementError, self.data_manager.create_mapping) + + def test_delete_mapping(self): + """should be able to delete a mapping""" + prepare_presets() + + old_preset = Preset(get_preset_path("Foo Device", "preset2")) + old_preset.load() + + self.data_manager.load_group(group_key="Foo Device 2") + self.data_manager.load_preset(name="preset2") + self.data_manager.load_mapping(combination=EventCombination("1,3,1")) + + 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(EventCombination("1,3,1")) + mappings = listener.calls[0].mappings + preset_name = listener.calls[0].name + expected_preset = Preset(get_preset_path("Foo Device", "preset2")) + expected_preset.load() + expected_mappings = [ + (mapping.name, mapping.event_combination) for mapping in expected_preset + ] + + self.assertEqual(preset_name, "preset2") + for mapping in expected_mappings: + self.assertIn(mapping, mappings) + + self.assertNotIn( + (deleted_mapping.name, deleted_mapping.event_combination), mappings + ) + + def test_cannot_delete_mapping(self): + """deleting a mapping should not be possible if the mapping was not loaded""" + prepare_presets() + self.assertRaises(DataManagementError, self.data_manager.delete_mapping) + self.data_manager.load_group(group_key="Foo Device 2") + self.assertRaises(DataManagementError, self.data_manager.delete_mapping) + self.data_manager.load_preset(name="preset2") + self.assertRaises(DataManagementError, self.data_manager.delete_mapping) + + def test_set_autoload(self): + """should be able to set the autoload status""" + prepare_presets() + self.data_manager.load_group(group_key="Foo Device") + + listener = Listener() + self.message_broker.subscribe(MessageType.preset, listener) + self.data_manager.load_preset(name="preset1") # sends updated preset data + self.data_manager.set_autoload(True) # sends updated preset data + self.data_manager.set_autoload(False) # sends updated preset data + + self.assertFalse(listener.calls[0].autoload) + self.assertTrue(listener.calls[1].autoload) + self.assertFalse(listener.calls[2].autoload) + + def test_each_device_can_have_autoload(self): + prepare_presets() + self.data_manager.load_group("Foo Device 2") + self.data_manager.load_preset("preset1") + self.data_manager.set_autoload(True) + + # switch to another device + self.data_manager.load_group("Foo Device") + self.data_manager.load_preset("preset1") + self.data_manager.set_autoload(True) + + # now check that both are set to autoload + self.data_manager.load_group("Foo Device 2") + self.data_manager.load_preset("preset1") + self.assertTrue(self.data_manager.get_autoload()) + + self.data_manager.load_group("Foo Device") + self.data_manager.load_preset("preset1") + self.assertTrue(self.data_manager.get_autoload()) + + def test_cannot_set_autoload_without_preset(self): + prepare_presets() + self.assertRaises( + DataManagementError, + self.data_manager.set_autoload, + True, + ) + self.data_manager.load_group(group_key="Foo Device 2") + self.assertRaises( + DataManagementError, + self.data_manager.set_autoload, + True, + ) + + def test_finds_newest_group(self): + Preset(get_preset_path("Foo Device", "preset 1")).save() + time.sleep(0.01) + Preset(get_preset_path("Bar Device", "preset 2")).save() + self.assertEqual(self.data_manager.get_newest_group_key(), "Bar Device") + + def test_finds_newest_preset(self): + Preset(get_preset_path("Foo Device", "preset 1")).save() + time.sleep(0.01) + Preset(get_preset_path("Foo Device", "preset 2")).save() + self.data_manager.load_group("Foo Device") + self.assertEqual(self.data_manager.get_newest_preset_name(), "preset 2") + + def test_newest_group_ignores_unknown_filetypes(self): + Preset(get_preset_path("Foo Device", "preset 1")).save() + time.sleep(0.01) + Preset(get_preset_path("Bar Device", "preset 2")).save() + + # not a preset, ignore + time.sleep(0.01) + path = os.path.join(get_preset_path("Foo Device"), "picture.png") + os.mknod(path) + + self.assertEqual(self.data_manager.get_newest_group_key(), "Bar Device") + + def test_newest_preset_ignores_unknown_filetypes(self): + Preset(get_preset_path("Bar Device", "preset 1")).save() + time.sleep(0.01) + Preset(get_preset_path("Bar Device", "preset 2")).save() + time.sleep(0.01) + Preset(get_preset_path("Bar Device", "preset 3")).save() + + # not a preset, ignore + time.sleep(0.01) + path = os.path.join(get_preset_path("Bar Device"), "picture.png") + os.mknod(path) + + self.data_manager.load_group("Bar Device") + + self.assertEqual(self.data_manager.get_newest_preset_name(), "preset 3") + + def test_newest_group_ignores_unknon_groups(self): + Preset(get_preset_path("Bar Device", "preset 1")).save() + time.sleep(0.01) + Preset(get_preset_path("unknown_group", "preset 2")).save() # not a known group + + self.assertEqual(self.data_manager.get_newest_group_key(), "Bar Device") + + def test_newest_group_and_preset_raises_file_not_found(self): + """should raise file not found error when all preset folders are empty""" + self.assertRaises(FileNotFoundError, self.data_manager.get_newest_group_key) + os.makedirs(get_preset_path("Bar Device")) + self.assertRaises(FileNotFoundError, self.data_manager.get_newest_group_key) + self.data_manager.load_group("Bar Device") + self.assertRaises(FileNotFoundError, self.data_manager.get_newest_preset_name) + + def test_newest_preset_raises_data_management_error(self): + """should raise data management error without a active group""" + self.assertRaises(DataManagementError, self.data_manager.get_newest_preset_name) + + def test_newest_preset_only_searches_active_group(self): + Preset(get_preset_path("Foo Device", "preset 1")).save() + time.sleep(0.01) + Preset(get_preset_path("Foo Device", "preset 3")).save() + time.sleep(0.01) + Preset(get_preset_path("Bar Device", "preset 2")).save() + + self.data_manager.load_group("Foo Device") + self.assertEqual(self.data_manager.get_newest_preset_name(), "preset 3") + + def test_available_preset_name_default(self): + self.data_manager.load_group("Foo Device") + self.assertEqual( + self.data_manager.get_available_preset_name(), DEFAULT_PRESET_NAME + ) + + def test_available_preset_name_adds_number_to_default(self): + Preset(get_preset_path("Foo Device", DEFAULT_PRESET_NAME)).save() + self.data_manager.load_group("Foo Device") + self.assertEqual( + self.data_manager.get_available_preset_name(), f"{DEFAULT_PRESET_NAME} 2" + ) + + def test_available_preset_name_returns_provided_name(self): + self.data_manager.load_group("Foo Device") + self.assertEqual(self.data_manager.get_available_preset_name("bar"), "bar") + + def test_available_preset_name__adds_number_to_provided_name(self): + Preset(get_preset_path("Foo Device", "bar")).save() + self.data_manager.load_group("Foo Device") + self.assertEqual(self.data_manager.get_available_preset_name("bar"), "bar 2") + + def test_available_preset_name_raises_data_management_error(self): + """should raise DataManagementError when group is not set""" + self.assertRaises( + DataManagementError, self.data_manager.get_available_preset_name + ) + + def test_available_preset_name_increments_default(self): + Preset(get_preset_path("Foo Device", DEFAULT_PRESET_NAME)).save() + Preset(get_preset_path("Foo Device", f"{DEFAULT_PRESET_NAME} 2")).save() + Preset(get_preset_path("Foo Device", f"{DEFAULT_PRESET_NAME} 3")).save() + self.data_manager.load_group("Foo Device") + self.assertEqual( + self.data_manager.get_available_preset_name(), f"{DEFAULT_PRESET_NAME} 4" + ) + + def test_available_preset_name_increments_provided_name(self): + Preset(get_preset_path("Foo Device", "foo")).save() + Preset(get_preset_path("Foo Device", "foo 1")).save() + Preset(get_preset_path("Foo Device", "foo 2")).save() + self.data_manager.load_group("Foo Device") + self.assertEqual(self.data_manager.get_available_preset_name("foo 1"), "foo 3") + + def test_should_send_groups(self): + listener = Listener() + self.message_broker.subscribe(MessageType.groups, listener) + + self.data_manager.send_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"], + }, + ) + + 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.send_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_combination.py b/tests/unit/test_event_combination.py index dadf4d80..8c6a1876 100644 --- a/tests/unit/test_event_combination.py +++ b/tests/unit/test_event_combination.py @@ -21,7 +21,24 @@ import unittest -from evdev.ecodes import KEY_LEFTSHIFT, KEY_RIGHTALT, KEY_LEFTCTRL +from evdev.ecodes import ( + EV_KEY, + EV_ABS, + EV_REL, + BTN_C, + BTN_B, + BTN_A, + REL_WHEEL, + REL_HWHEEL, + ABS_RY, + ABS_X, + ABS_HAT0Y, + ABS_HAT0X, + KEY_A, + KEY_LEFTSHIFT, + KEY_RIGHTALT, + KEY_LEFTCTRL, +) from inputremapper.event_combination import EventCombination from inputremapper.input_event import InputEvent @@ -120,6 +137,66 @@ class TestKey(unittest.TestCase): self.assertEqual(c1.json_str(), "1,2,3") self.assertEqual(c2.json_str(), "1,2,3+4,5,6") + def test_beautify(self): + # not an integration test, but I have all the selection_label tests here already + self.assertEqual( + EventCombination((EV_KEY, KEY_A, 1)).beautify(), + "a", + ) + self.assertEqual( + EventCombination([EV_KEY, KEY_A, 1]).beautify(), + "a", + ) + self.assertEqual( + EventCombination((EV_ABS, ABS_HAT0Y, -1)).beautify(), + "DPad-Y Up", + ) + self.assertEqual( + EventCombination((EV_KEY, BTN_A, 1)).beautify(), + "Button A", + ) + self.assertEqual(EventCombination((EV_KEY, 1234, 1)).beautify(), "unknown") + self.assertEqual( + EventCombination([EV_ABS, ABS_HAT0X, -1]).beautify(), + "DPad-X Left", + ) + self.assertEqual( + EventCombination([EV_ABS, ABS_HAT0Y, -1]).beautify(), + "DPad-Y Up", + ) + self.assertEqual( + EventCombination([EV_KEY, BTN_A, 1]).beautify(), + "Button A", + ) + self.assertEqual( + EventCombination([EV_ABS, ABS_X, 1]).beautify(), + "Joystick-X Right", + ) + self.assertEqual( + EventCombination([EV_ABS, ABS_RY, 1]).beautify(), + "Joystick-RY Down", + ) + self.assertEqual( + EventCombination([EV_REL, REL_HWHEEL, 1]).beautify(), + "Wheel Right", + ) + self.assertEqual( + EventCombination([EV_REL, REL_WHEEL, -1]).beautify(), + "Wheel Down", + ) + + # combinations + self.assertEqual( + EventCombination( + ( + (EV_KEY, BTN_A, 1), + (EV_KEY, BTN_B, 1), + (EV_KEY, BTN_C, 1), + ), + ).beautify(), + "Button A + Button B + Button C", + ) + if __name__ == "__main__": unittest.main() diff --git a/inputremapper/gui/editor/__init__.py b/tests/unit/test_event_pipeline/__init__.py similarity index 100% rename from inputremapper/gui/editor/__init__.py rename to tests/unit/test_event_pipeline/__init__.py diff --git a/tests/unit/test_axis_transformation.py b/tests/unit/test_event_pipeline/test_axis_transformation.py similarity index 92% rename from tests/unit/test_axis_transformation.py rename to tests/unit/test_event_pipeline/test_axis_transformation.py index ecaf7c3c..2bf45436 100644 --- a/tests/unit/test_axis_transformation.py +++ b/tests/unit/test_event_pipeline/test_axis_transformation.py @@ -192,3 +192,15 @@ class TestAxisTransformation(unittest.TestCase): 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.py b/tests/unit/test_event_pipeline/test_event_pipeline.py similarity index 99% rename from tests/unit/test_event_pipeline.py rename to tests/unit/test_event_pipeline/test_event_pipeline.py index 339db603..f8dcc5d4 100644 --- a/tests/unit/test_event_pipeline.py +++ b/tests/unit/test_event_pipeline/test_event_pipeline.py @@ -307,8 +307,8 @@ class TestEventPipeline(unittest.IsolatedAsyncioTestCase): ) # each axis writes speed*gain*rate*sleep=1*0.5*60 events - self.assertGreater(len(history), speed * gain * rate * sleep * 0.9 * 2) - self.assertLess(len(history), speed * gain * rate * sleep * 1.1 * 2) + self.assertGreater(len(history), speed * gain * rate * sleep * 0.8 * 2) + self.assertLess(len(history), speed * gain * rate * sleep * 1.2 * 2) # those may be in arbitrary order count_x = history.count((EV_REL, REL_X, -1)) @@ -362,7 +362,7 @@ class TestEventPipeline(unittest.IsolatedAsyncioTestCase): event_reader, ) # wait a bit more for it to sum up - sleep = 0.5 + sleep = 0.8 await asyncio.sleep(sleep) # stop it await self.send_events( 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 00000000..6413ffb5 --- /dev/null +++ b/tests/unit/test_event_pipeline/test_mapping_handlers.py @@ -0,0 +1,261 @@ +#!/usr/bin/python3 +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2022 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + + +import asyncio +import unittest +from typing import Iterable +from unittest.mock import MagicMock + +import evdev +from evdev.ecodes import ( + EV_KEY, + EV_ABS, + EV_REL, + ABS_X, + ABS_Y, + REL_X, + REL_Y, + BTN_A, + REL_HWHEEL, + REL_WHEEL, + REL_WHEEL_HI_RES, + REL_HWHEEL_HI_RES, + ABS_HAT0X, + BTN_LEFT, + BTN_RIGHT, + BTN_B, + KEY_A, + ABS_HAT0Y, + KEY_B, + KEY_C, + BTN_TL, +) + +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.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.logger import logger +from inputremapper.configs.mapping import Mapping +from inputremapper.injection.context import Context +from inputremapper.injection.event_reader import EventReader +from tests.test import ( + get_key_mapping, + InputDevice, + cleanup, + convert_to_internal_events, + MAX_ABS, + MIN_ABS, +) + +from inputremapper.input_event import InputEvent, EventActions +from inputremapper.event_combination import EventCombination +from inputremapper.configs.system_mapping import system_mapping +from inputremapper.configs.preset import Preset +from inputremapper.injection.global_uinputs import global_uinputs + + +class BaseTests: + """implements test that should pass on most mapping handlers + in special cases override specific tests. + """ + + handler: MappingHandler + + def setUp(self): + raise NotImplementedError + + def tearDown(self) -> None: + cleanup() + + def test_reset(self): + mock = MagicMock() + self.handler.set_sub_handler(mock) + self.handler.reset() + mock.reset.assert_called() + + +class TestAxisSwitchHandler(BaseTests, unittest.IsolatedAsyncioTestCase): + def setUp(self): + self.handler = AxisSwitchHandler( + EventCombination.from_string("2,5,0+1,3,1"), + Mapping( + event_combination="2,5,0+1,3,1", + target_uinput="mouse", + output_type=2, + output_code=1, + ), + ) + + +class TestAbsToBtnHandler(BaseTests, unittest.IsolatedAsyncioTestCase): + def setUp(self): + self.handler = AbsToBtnHandler( + EventCombination.from_string("3,5,10"), + Mapping( + event_combination="3,5,10", + target_uinput="mouse", + output_symbol="BTN_LEFT", + ), + ) + + +class TestAbsToRelHandler(BaseTests, unittest.IsolatedAsyncioTestCase): + def setUp(self): + self.handler = AbsToRelHandler( + EventCombination((EV_ABS, ABS_X, 0)), + Mapping( + event_combination=f"{EV_ABS},{ABS_X},0", + target_uinput="mouse", + output_type=EV_REL, + output_code=REL_X, + ), + ) + + async def test_reset(self): + self.handler.notify( + InputEvent(0, 0, EV_ABS, ABS_X, MAX_ABS), + source=InputDevice("/dev/input/event15"), + forward=evdev.UInput(), + ) + await asyncio.sleep(0.2) + self.handler.reset() + await asyncio.sleep(0.05) + + count = global_uinputs.get_uinput("mouse").write_count + self.assertGreater(count, 6) # count should be 60*0.2 = 12 + await asyncio.sleep(0.2) + self.assertEqual(count, global_uinputs.get_uinput("mouse").write_count) + + +class TestCombinationHandler(BaseTests, unittest.IsolatedAsyncioTestCase): + def setUp(self): + self.handler = AxisSwitchHandler( + EventCombination.from_string("2,0,10+1,3,1"), + Mapping( + event_combination="2,0,10+1,3,1", + target_uinput="mouse", + output_symbol="BTN_LEFT", + ), + ) + + +class TestHierarchyHandler(BaseTests, unittest.IsolatedAsyncioTestCase): + def setUp(self): + self.mock1 = MagicMock() + self.mock2 = MagicMock() + self.mock3 = MagicMock() + self.handler = HierarchyHandler( + [self.mock1, self.mock2, self.mock3], + InputEvent.from_tuple((EV_KEY, KEY_A, 1)), + ) + + def test_reset(self): + self.handler.reset() + self.mock1.reset.assert_called() + self.mock2.reset.assert_called() + self.mock3.reset.assert_called() + + +class TestKeyHandler(BaseTests, unittest.IsolatedAsyncioTestCase): + def setUp(self): + self.handler = KeyHandler( + EventCombination.from_string("2,0,10+1,3,1"), + Mapping( + event_combination="2,0,10+1,3,1", + target_uinput="mouse", + output_symbol="BTN_LEFT", + ), + ) + + def test_reset(self): + self.handler.notify( + InputEvent(0, 0, EV_REL, REL_X, 1, actions=(EventActions.as_key,)), + source=InputDevice("/dev/input/event11"), + forward=evdev.UInput(), + ) + history = convert_to_internal_events( + global_uinputs.get_uinput("mouse").write_history + ) + self.assertEqual(history[0], InputEvent.from_tuple((EV_KEY, BTN_LEFT, 1))) + self.assertEqual(len(history), 1) + + self.handler.reset() + history = convert_to_internal_events( + global_uinputs.get_uinput("mouse").write_history + ) + self.assertEqual(history[1], InputEvent.from_tuple((EV_KEY, BTN_LEFT, 0))) + self.assertEqual(len(history), 2) + + +class TestMacroHandler(BaseTests, unittest.IsolatedAsyncioTestCase): + def setUp(self): + self.context_mock = MagicMock() + self.handler = MacroHandler( + EventCombination.from_string("2,0,10+1,3,1"), + Mapping( + event_combination="2,0,10+1,3,1", + target_uinput="mouse", + output_symbol="hold_keys(BTN_LEFT, BTN_RIGHT)", + ), + context=self.context_mock, + ) + + async def test_reset(self): + self.handler.notify( + InputEvent(0, 0, EV_REL, REL_X, 1, actions=(EventActions.as_key,)), + source=InputDevice("/dev/input/event11"), + forward=evdev.UInput(), + ) + + await asyncio.sleep(0.1) + history = convert_to_internal_events( + global_uinputs.get_uinput("mouse").write_history + ) + self.assertIn(InputEvent.from_tuple((EV_KEY, BTN_LEFT, 1)), history) + self.assertIn(InputEvent.from_tuple((EV_KEY, BTN_RIGHT, 1)), history) + self.assertEqual(len(history), 2) + + self.handler.reset() + await asyncio.sleep(0.1) + history = convert_to_internal_events( + global_uinputs.get_uinput("mouse").write_history + ) + self.assertIn(InputEvent.from_tuple((EV_KEY, BTN_LEFT, 0)), history[-2:]) + self.assertIn(InputEvent.from_tuple((EV_KEY, BTN_RIGHT, 0)), history[-2:]) + self.assertEqual(len(history), 4) + + +class TestRelToBtnHanlder(BaseTests, unittest.IsolatedAsyncioTestCase): + def setUp(self): + self.handler = AxisSwitchHandler( + EventCombination.from_string("2,0,10+1,3,1"), + Mapping( + event_combination="2,0,10+1,3,1", + target_uinput="mouse", + output_symbol="BTN_LEFT", + ), + ) diff --git a/tests/unit/test_event_reader.py b/tests/unit/test_event_reader.py index d623b451..69cf51f6 100644 --- a/tests/unit/test_event_reader.py +++ b/tests/unit/test_event_reader.py @@ -58,16 +58,15 @@ class TestEventReader(unittest.IsolatedAsyncioTestCase): def tearDown(self): quick_cleanup() - def setup(self, source, mapping): - """Set a a EventReader up for the test and run it in the background.""" + async def setup(self, source, mapping): + """Set a EventReader up for the test and run it in the background.""" forward_to = evdev.UInput() context = Context(mapping) context.uinput = evdev.UInput() - consumer_control = EventReader(context, source, forward_to, self.stop_event) - # for consumer in consumer_control._consumers: - # consumer._abs_range = (-10, 10) - asyncio.ensure_future(consumer_control.run()) - return context, consumer_control + event_reader = EventReader(context, source, forward_to, 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 @@ -112,7 +111,7 @@ class TestEventReader(unittest.IsolatedAsyncioTestCase): cfg["output_code"] = REL_WHEEL_HI_RES self.preset.add(Mapping(**cfg)) - context, _ = self.setup(self.gamepad_source, self.preset) + context, _ = await self.setup(self.gamepad_source, self.preset) self.gamepad_source.push_events( [ @@ -125,7 +124,9 @@ class TestEventReader(unittest.IsolatedAsyncioTestCase): new_event(EV_KEY, trigger, 0), ] ) + await asyncio.sleep(0.1) + self.stop_event.set() # stop the reader self.assertEqual(len(context.listeners), 0) history = [a.t for a in global_uinputs.get_uinput("keyboard").write_history] self.assertIn((EV_KEY, code_a, 1), history) @@ -151,7 +152,7 @@ class TestEventReader(unittest.IsolatedAsyncioTestCase): # self.preset.set("gamepad.joystick.left_purpose", BUTTONS) # self.preset.set("gamepad.joystick.right_purpose", BUTTONS) - context, _ = self.setup(self.gamepad_source, self.preset) + context, _ = await self.setup(self.gamepad_source, self.preset) self.gamepad_source.push_events( [ diff --git a/tests/unit/test_groups.py b/tests/unit/test_groups.py index b9a15308..ef133703 100644 --- a/tests/unit/test_groups.py +++ b/tests/unit/test_groups.py @@ -33,12 +33,7 @@ from inputremapper.groups import ( _FindGroups, groups, classify, - GAMEPAD, - MOUSE, - UNKNOWN, - GRAPHICS_TABLET, - TOUCHPAD, - KEYBOARD, + DeviceType, _Group, ) @@ -58,7 +53,7 @@ class TestGroups(unittest.TestCase): group = _Group( paths=["/dev/a", "/dev/b", "/dev/c"], names=["name_bar", "name_a", "name_foo"], - types=[MOUSE, KEYBOARD, UNKNOWN], + types=[DeviceType.MOUSE, DeviceType.KEYBOARD, DeviceType.UNKNOWN], key="key", ) self.assertEqual(group.name, "name_a") @@ -85,7 +80,7 @@ class TestGroups(unittest.TestCase): "/dev/input/event1", ], "names": ["Foo Device"], - "types": [KEYBOARD], + "types": [DeviceType.KEYBOARD], "key": "Foo Device", } ), @@ -95,9 +90,19 @@ class TestGroups(unittest.TestCase): "/dev/input/event11", "/dev/input/event10", "/dev/input/event13", + "/dev/input/event15", + ], + "names": [ + "Foo Device foo", + "Foo Device", + "Foo Device", + "Foo Device bar", + ], + "types": [ + DeviceType.GAMEPAD, + DeviceType.KEYBOARD, + DeviceType.MOUSE, ], - "names": ["Foo Device foo", "Foo Device", "Foo Device"], - "types": [KEYBOARD, MOUSE], "key": "Foo Device 2", } ), @@ -105,7 +110,7 @@ class TestGroups(unittest.TestCase): { "paths": ["/dev/input/event20"], "names": ["Bar Device"], - "types": [KEYBOARD], + "types": [DeviceType.KEYBOARD], "key": "Bar Device", } ), @@ -113,7 +118,7 @@ class TestGroups(unittest.TestCase): { "paths": ["/dev/input/event30"], "names": ["gamepad"], - "types": [GAMEPAD], + "types": [DeviceType.GAMEPAD], "key": "gamepad", } ), @@ -121,7 +126,7 @@ class TestGroups(unittest.TestCase): { "paths": ["/dev/input/event40"], "names": ["input-remapper Bar Device"], - "types": [KEYBOARD], + "types": [DeviceType.KEYBOARD], "key": "input-remapper Bar Device", } ), @@ -229,7 +234,7 @@ class TestGroups(unittest.TestCase): } ) ), - GAMEPAD, + DeviceType.GAMEPAD, ) """Mice""" @@ -247,12 +252,14 @@ class TestGroups(unittest.TestCase): } ) ), - MOUSE, + DeviceType.MOUSE, ) """Keyboard""" - self.assertEqual(classify(FakeDevice({EV_KEY: [evdev.ecodes.KEY_A]})), KEYBOARD) + self.assertEqual( + classify(FakeDevice({EV_KEY: [evdev.ecodes.KEY_A]})), DeviceType.KEYBOARD + ) """Touchpads""" @@ -265,7 +272,7 @@ class TestGroups(unittest.TestCase): } ) ), - TOUCHPAD, + DeviceType.TOUCHPAD, ) """Graphics tablets""" @@ -279,7 +286,7 @@ class TestGroups(unittest.TestCase): } ) ), - GRAPHICS_TABLET, + DeviceType.GRAPHICS_TABLET, ) """Weird combos""" @@ -293,19 +300,23 @@ class TestGroups(unittest.TestCase): } ) ), - UNKNOWN, + DeviceType.UNKNOWN, ) self.assertEqual( classify( FakeDevice({EV_ABS: [evdev.ecodes.ABS_X], EV_KEY: [evdev.ecodes.BTN_A]}) ), - UNKNOWN, + DeviceType.UNKNOWN, ) - self.assertEqual(classify(FakeDevice({EV_KEY: [evdev.ecodes.BTN_A]})), UNKNOWN) + self.assertEqual( + classify(FakeDevice({EV_KEY: [evdev.ecodes.BTN_A]})), DeviceType.UNKNOWN + ) - self.assertEqual(classify(FakeDevice({EV_ABS: [evdev.ecodes.ABS_X]})), UNKNOWN) + self.assertEqual( + classify(FakeDevice({EV_ABS: [evdev.ecodes.ABS_X]})), DeviceType.UNKNOWN + ) if __name__ == "__main__": diff --git a/tests/unit/test_injector.py b/tests/unit/test_injector.py index a3f4f8f7..6fb5c9e1 100644 --- a/tests/unit/test_injector.py +++ b/tests/unit/test_injector.py @@ -58,6 +58,7 @@ from inputremapper.injection.injector import ( NO_GRAB, UNKNOWN, get_udev_name, + FAILED, ) from inputremapper.injection.numlock import is_numlock_on from inputremapper.configs.system_mapping import ( @@ -65,12 +66,11 @@ from inputremapper.configs.system_mapping import ( DISABLE_CODE, DISABLE_NAME, ) -from inputremapper.gui.active_preset import active_preset from inputremapper.configs.preset import Preset from inputremapper.event_combination import EventCombination from inputremapper.injection.macros.parse import parse from inputremapper.injection.context import Context -from inputremapper.groups import groups, classify, GAMEPAD +from inputremapper.groups import groups, classify, DeviceType def wait_for_uinput_write(): @@ -101,9 +101,10 @@ class TestInjector(unittest.IsolatedAsyncioTestCase): evdev.InputDevice.grab = grab_fail_twice def tearDown(self): - if self.injector is not None: + if self.injector is not None and self.injector.is_alive(): self.injector.stop_injecting() - self.assertEqual(self.injector.get_state(), STOPPED) + time.sleep(0.2) + self.assertIn(self.injector.get_state(), (STOPPED, FAILED, NO_GRAB)) self.injector = None evdev.InputDevice.grab = self.grab @@ -119,8 +120,8 @@ class TestInjector(unittest.IsolatedAsyncioTestCase): # this test needs to pass around all other constraints of # _grab_device self.injector.context = Context(preset) - device = self.injector._grab_device(path) - gamepad = classify(device) == GAMEPAD + 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 @@ -134,7 +135,7 @@ class TestInjector(unittest.IsolatedAsyncioTestCase): self.injector = Injector(groups.find(key="Foo Device 2"), preset) path = "/dev/input/event10" self.injector.context = Context(preset) - device = self.injector._grab_device(path) + device = self.injector._grab_device(evdev.InputDevice(path)) self.assertIsNone(device) self.assertGreaterEqual(self.failed, 1) @@ -154,14 +155,15 @@ class TestInjector(unittest.IsolatedAsyncioTestCase): ) self.injector = Injector(groups.find(name="gamepad"), preset) self.injector.context = Context(preset) + self.injector.group.paths = [ + "/dev/input/event10", + "/dev/input/event30", + "/dev/input/event1234", + ] - _grab_device = self.injector._grab_device - # doesn't have the required capability - self.assertIsNone(_grab_device("/dev/input/event10")) - # according to the fixtures, /dev/input/event30 can do ABS_HAT0X - self.assertIsNotNone(_grab_device("/dev/input/event30")) - # this doesn't exist - self.assertIsNone(_grab_device("/dev/input/event1234")) + grabbed = self.injector._grab_devices() + self.assertEqual(len(grabbed), 1) + self.assertEqual(grabbed[0].path, "/dev/input/event30") def test_forward_gamepad_events(self): # forward abs joystick events @@ -170,15 +172,16 @@ class TestInjector(unittest.IsolatedAsyncioTestCase): self.injector.context = Context(preset) path = "/dev/input/event30" - device = self.injector._grab_device(path) - self.assertIsNone(device) # no capability is used, so it won't grab + device = self.injector._grab_devices() + self.assertEqual(device, []) # no capability is used, so it won't grab preset.add( get_key_mapping(EventCombination([EV_KEY, BTN_A, 1]), "keyboard", "a"), ) - device = self.injector._grab_device(path) - self.assertIsNotNone(device) - gamepad = classify(device) == GAMEPAD + devices = self.injector._grab_devices() + self.assertEqual(len(devices), 1) + self.assertEqual(devices[0].path, path) + gamepad = classify(devices[0]) == DeviceType.GAMEPAD self.assertTrue(gamepad) def test_skip_unused_device(self): @@ -188,8 +191,9 @@ class TestInjector(unittest.IsolatedAsyncioTestCase): self.injector = Injector(groups.find(key="Foo Device 2"), preset) self.injector.context = Context(preset) path = "/dev/input/event11" - device = self.injector._grab_device(path) - self.assertIsNone(device) + self.injector.group.paths = [path] + devices = self.injector._grab_devices() + self.assertEqual(devices, []) self.assertEqual(self.failed, 0) def test_skip_unknown_device(self): @@ -199,15 +203,17 @@ class TestInjector(unittest.IsolatedAsyncioTestCase): # skips a device because its capabilities are not used in the preset self.injector = Injector(groups.find(key="Foo Device 2"), preset) self.injector.context = Context(preset) + path = "/dev/input/event11" - device = self.injector._grab_device(path) + self.injector.group.paths = [path] + devices = self.injector._grab_devices() # skips the device alltogether, so no grab attempts fail self.assertEqual(self.failed, 0) - self.assertIsNone(device) + self.assertEqual(devices, []) def test_get_udev_name(self): - self.injector = Injector(groups.find(key="Foo Device 2"), active_preset) + self.injector = Injector(groups.find(key="Foo Device 2"), Preset()) suffix = "mapped" prefix = "input-remapper" expected = f'{prefix} {"a" * (80 - len(suffix) - len(prefix) - 2)} {suffix}' @@ -236,15 +242,11 @@ class TestInjector(unittest.IsolatedAsyncioTestCase): self.injector.run() self.assertEqual( - self.injector.context.preset.get_mapping( - EventCombination([EV_KEY, KEY_A, 1]) - ), + self.injector.preset.get_mapping(EventCombination([EV_KEY, KEY_A, 1])), m1, ) self.assertEqual( - self.injector.context.preset.get_mapping( - EventCombination([EV_REL, REL_HWHEEL, 1]) - ), + self.injector.preset.get_mapping(EventCombination([EV_REL, REL_HWHEEL, 1])), m2, ) @@ -516,7 +518,7 @@ class TestModifyCapabilities(unittest.TestCase): # I don't know what ABS_VOLUME is, for now I would like to just always # remove it until somebody complains, since its presence broke stuff - self.injector = Injector(None, self.preset) + self.injector = Injector(mock.Mock(), self.preset) self.fake_device._capabilities = { EV_ABS: [ABS_VOLUME, (ABS_X, evdev.AbsInfo(0, 0, 500, 0, 0, 0))], EV_KEY: [1, 2, 3], diff --git a/tests/unit/test_ipc.py b/tests/unit/test_ipc.py index a7c0adb3..80e60091 100644 --- a/tests/unit/test_ipc.py +++ b/tests/unit/test_ipc.py @@ -17,7 +17,8 @@ # # You should have received a copy of the GNU General Public License # along with input-remapper. If not, see . - +import asyncio +import multiprocessing from tests.test import quick_cleanup, tmp @@ -125,7 +126,7 @@ class TestSocket(unittest.TestCase): self.assertRaises(NotImplementedError, lambda: Base.fileno(None)) -class TestPipe(unittest.TestCase): +class TestPipe(unittest.IsolatedAsyncioTestCase): def test_pipe_single(self): p1 = Pipe(os.path.join(tmp, "pipe")) self.assertEqual(p1.recv(), None) @@ -161,6 +162,47 @@ class TestPipe(unittest.TestCase): 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_macros.py b/tests/unit/test_macros.py index ddd96623..83ff3821 100644 --- a/tests/unit/test_macros.py +++ b/tests/unit/test_macros.py @@ -792,7 +792,7 @@ class TestMacros(MacroTestBase): keystroke_sleep = DummyMapping.macro_key_sleep_ms sleep_time = 2 * repeats * keystroke_sleep / 1000 self.assertGreater(time.time() - start, sleep_time * 0.9) - self.assertLess(time.time() - start, sleep_time * 1.2) + self.assertLess(time.time() - start, sleep_time * 1.3) self.assertListEqual( self.result, diff --git a/tests/unit/test_mapping.py b/tests/unit/test_mapping.py index 391b9848..5e581db4 100644 --- a/tests/unit/test_mapping.py +++ b/tests/unit/test_mapping.py @@ -24,8 +24,9 @@ from functools import partial from evdev.ecodes import EV_KEY from pydantic import ValidationError -from inputremapper.configs.mapping import Mapping +from inputremapper.configs.mapping import Mapping, UIMapping from inputremapper.configs.system_mapping import system_mapping +from inputremapper.gui.message_broker import MessageType from inputremapper.input_event import EventActions from inputremapper.event_combination import EventCombination @@ -84,20 +85,20 @@ class TestMapping(unittest.IsolatedAsyncioTestCase): "output_code": 3, } m = Mapping(**cfg) - expected_actions = [EventActions.as_key, EventActions.as_key, EventActions.none] - actions = [event.action for event in m.event_combination] + expected_actions = [(EventActions.as_key,), (EventActions.as_key,), ()] + actions = [event.actions for event in m.event_combination] self.assertEqual(expected_actions, actions) - # copy keeps the event action + # copy keeps the event actions m2 = m.copy() - actions = [event.action for event in m2.event_combination] + actions = [event.actions for event in m2.event_combination] self.assertEqual(expected_actions, actions) - # changing the combination sets the action + # changing the combination sets the actions m3 = m.copy() m3.event_combination = "1,2,1+2,1,0+3,1,10" - expected_actions = [EventActions.as_key, EventActions.none, EventActions.as_key] - actions = [event.action for event in m3.event_combination] + expected_actions = [(EventActions.as_key,), (), (EventActions.as_key,)] + actions = [event.actions for event in m3.event_combination] self.assertEqual(expected_actions, actions) def test_combination_changed_callback(self): @@ -331,5 +332,57 @@ class TestMapping(unittest.IsolatedAsyncioTestCase): self.assertTrue(m.is_valid()) +class TestUIMapping(unittest.IsolatedAsyncioTestCase): + def test_init(self): + """should be able to initialize without an error""" + UIMapping() + + def test_is_valid(self): + """should be invalid at first + and become valid once all data is provided""" + m = UIMapping() + self.assertFalse(m.is_valid()) + + m.event_combination = "1,2,3" + m.output_symbol = "a" + self.assertFalse(m.is_valid()) + m.target_uinput = "keyboard" + self.assertTrue(m.is_valid()) + + def test_updates_validation_error(self): + m = UIMapping() + self.assertIn("2 validation errors for UIMapping", str(m.get_error())) + m.event_combination = "1,2,3" + m.output_symbol = "a" + self.assertIn( + "1 validation error for UIMapping\ntarget_uinput", str(m.get_error()) + ) + m.target_uinput = "keyboard" + self.assertTrue(m.is_valid()) + self.assertIsNone(m.get_error()) + + def test_copy_returns_ui_mapping(self): + """copy should also be a UIMapping with all the invalid data""" + m = UIMapping() + m2 = m.copy() + self.assertIsInstance(m2, UIMapping) + self.assertEqual(m2.event_combination, EventCombination.empty_combination()) + self.assertIsNone(m2.target_uinput) + + def test_get_bus_massage(self): + m = UIMapping() + m2 = m.get_bus_message() + self.assertEqual(m2.message_type, MessageType.mapping) + + with self.assertRaises(TypeError): + # the massage should be immutable + m2.output_symbol = "a" + self.assertIsNone(m2.output_symbol) + + # the original should be not immutable + m.output_symbol = "a" + self.assertEqual(m.output_symbol, "a") + + 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 00000000..42c8963c --- /dev/null +++ b/tests/unit/test_message_broker.py @@ -0,0 +1,79 @@ +import unittest +from dataclasses import dataclass + +from inputremapper.gui.message_broker import MessageBroker, MessageType + + +class Listener: + def __init__(self): + self.calls = [] + + def __call__(self, data): + self.calls.append(data) + + +@dataclass +class Message: + message_type: MessageType + msg: str + + +class TestMessageBroker(unittest.TestCase): + def test_calls_listeners(self): + """The correct Listeners get called""" + message_broker = MessageBroker() + listener = Listener() + message_broker.subscribe(MessageType.test1, listener) + message_broker.send(Message(MessageType.test1, "foo")) + message_broker.send(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.send(Message(MessageType.test1, "a")) + message_broker.unsubscribe(listener) + message_broker.send(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.send(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.send(Message(MessageType.test2, "f")) + calls.append(1) + + def listener2(_): + message_broker.send(Message(MessageType.test2, "f")) + calls.append(2) + + def listener3(_): + message_broker.send(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.send(Message(MessageType.test1, "")) + + first = calls[:3] + first.sort() + self.assertEqual([1, 2, 3], first) + self.assertEqual([4, 4, 4], calls[3:]) diff --git a/tests/unit/test_preset.py b/tests/unit/test_preset.py index 70c26852..e906099c 100644 --- a/tests/unit/test_preset.py +++ b/tests/unit/test_preset.py @@ -481,14 +481,11 @@ class TestPreset(unittest.TestCase): def test_save_load_with_invalid_mappings(self): ui_preset = Preset(get_config_path("test.json"), mapping_factory=UIMapping) - # cannot add a mapping without a valid combination - self.assertRaises(Exception, ui_preset.add, UIMapping()) - - ui_preset.add(UIMapping(event_combination="1,1,1")) + ui_preset.add(UIMapping()) self.assertFalse(ui_preset.is_valid()) # make the mapping valid - m = ui_preset.get_mapping(EventCombination.from_string("1,1,1")) + m = ui_preset.get_mapping(EventCombination.empty_combination()) m.output_symbol = "a" m.target_uinput = "keyboard" self.assertTrue(ui_preset.is_valid()) diff --git a/tests/unit/test_presets.py b/tests/unit/test_presets.py deleted file mode 100644 index 1b889d2c..00000000 --- a/tests/unit/test_presets.py +++ /dev/null @@ -1,218 +0,0 @@ -#!/usr/bin/python3 -# -*- coding: utf-8 -*- -# input-remapper - GUI for device specific keyboard mappings -# Copyright (C) 2022 sezanzeb -# -# This file is part of input-remapper. -# -# input-remapper is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# input-remapper is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with input-remapper. If not, see . - - -from tests.test import tmp - -import os -import unittest -import shutil -import time - -from inputremapper.configs.preset import ( - find_newest_preset, - rename_preset, - get_any_preset, - delete_preset, - get_available_preset_name, - get_presets, -) -from inputremapper.configs.paths import CONFIG_PATH, get_preset_path, touch -from inputremapper.gui.active_preset import active_preset - - -def create_preset(group_name, name="new preset"): - name = get_available_preset_name(group_name, name) - active_preset.clear() - active_preset.path = get_preset_path(group_name, name) - active_preset.save() - - -PRESETS = os.path.join(CONFIG_PATH, "presets") - - -class TestPresets(unittest.TestCase): - def test_get_available_preset_name(self): - # no filename conflict - self.assertEqual(get_available_preset_name("_", "qux 2"), "qux 2") - - touch(get_preset_path("_", "qux 5")) - self.assertEqual(get_available_preset_name("_", "qux 5"), "qux 6") - touch(get_preset_path("_", "qux")) - self.assertEqual(get_available_preset_name("_", "qux"), "qux 2") - touch(get_preset_path("_", "qux1")) - self.assertEqual(get_available_preset_name("_", "qux1"), "qux1 2") - touch(get_preset_path("_", "qux 2 3")) - self.assertEqual(get_available_preset_name("_", "qux 2 3"), "qux 2 4") - - touch(get_preset_path("_", "qux 5")) - self.assertEqual(get_available_preset_name("_", "qux 5", True), "qux 5 copy") - touch(get_preset_path("_", "qux 5 copy")) - self.assertEqual(get_available_preset_name("_", "qux 5", True), "qux 5 copy 2") - touch(get_preset_path("_", "qux 5 copy 2")) - self.assertEqual(get_available_preset_name("_", "qux 5", True), "qux 5 copy 3") - - touch(get_preset_path("_", "qux 5copy")) - self.assertEqual( - get_available_preset_name("_", "qux 5copy", True), - "qux 5copy copy", - ) - touch(get_preset_path("_", "qux 5copy 2")) - self.assertEqual( - get_available_preset_name("_", "qux 5copy 2", True), - "qux 5copy 2 copy", - ) - touch(get_preset_path("_", "qux 5copy 2 copy")) - self.assertEqual( - get_available_preset_name("_", "qux 5copy 2 copy", True), - "qux 5copy 2 copy 2", - ) - - -class TestCreatePreset(unittest.TestCase): - def tearDown(self): - if os.path.exists(tmp): - shutil.rmtree(tmp) - - def test_create_preset_1(self): - self.assertEqual(get_any_preset(), ("Foo Device", None)) - create_preset("Foo Device") - self.assertEqual(get_any_preset(), ("Foo Device", "new preset")) - self.assertTrue(os.path.exists(f"{PRESETS}/Foo Device/new preset.json")) - - def test_create_preset_2(self): - create_preset("Foo Device") - create_preset("Foo Device") - self.assertTrue(os.path.exists(f"{PRESETS}/Foo Device/new preset.json")) - self.assertTrue(os.path.exists(f"{PRESETS}/Foo Device/new preset 2.json")) - - def test_create_preset_3(self): - create_preset("Foo Device", "pre set") - create_preset("Foo Device", "pre set") - create_preset("Foo Device", "pre set") - self.assertTrue(os.path.exists(f"{PRESETS}/Foo Device/pre set.json")) - self.assertTrue(os.path.exists(f"{PRESETS}/Foo Device/pre set 2.json")) - self.assertTrue(os.path.exists(f"{PRESETS}/Foo Device/pre set 3.json")) - - -class TestDeletePreset(unittest.TestCase): - def tearDown(self): - if os.path.exists(tmp): - shutil.rmtree(tmp) - - def test_delete_preset(self): - create_preset("Foo Device") - create_preset("Foo Device") - self.assertTrue(os.path.exists(f"{PRESETS}/Foo Device/new preset.json")) - delete_preset("Foo Device", "new preset") - self.assertFalse(os.path.exists(f"{PRESETS}/Foo Device/new preset.json")) - self.assertTrue(os.path.exists(f"{PRESETS}/Foo Device")) - delete_preset("Foo Device", "new preset 2") - self.assertFalse(os.path.exists(f"{PRESETS}/Foo Device/new preset.json")) - self.assertFalse(os.path.exists(f"{PRESETS}/Foo Device/new preset 2.json")) - # if no preset in the directory, remove the directory - self.assertFalse(os.path.exists(f"{PRESETS}/Foo Device")) - - -class TestRenamePreset(unittest.TestCase): - def tearDown(self): - if os.path.exists(tmp): - shutil.rmtree(tmp) - - def test_rename_preset(self): - create_preset("Foo Device", "preset 1") - create_preset("Foo Device", "preset 2") - create_preset("Foo Device", "foobar") - rename_preset("Foo Device", "preset 1", "foobar") - rename_preset("Foo Device", "preset 2", "foobar") - self.assertFalse(os.path.exists(f"{PRESETS}/Foo Device/preset 1.json")) - self.assertTrue(os.path.exists(f"{PRESETS}/Foo Device/foobar.json")) - self.assertTrue(os.path.exists(f"{PRESETS}/Foo Device/foobar 2.json")) - self.assertTrue(os.path.exists(f"{PRESETS}/Foo Device/foobar 3.json")) - - -class TestFindPresets(unittest.TestCase): - def tearDown(self): - if os.path.exists(tmp): - shutil.rmtree(tmp) - - def test_get_presets(self): - os.makedirs(os.path.join(PRESETS, "1234")) - - os.mknod(os.path.join(PRESETS, "1234", "picture.png")) - self.assertEqual(len(get_presets("1234")), 0) - - os.mknod(os.path.join(PRESETS, "1234", "foo bar 1.json")) - time.sleep(0.01) - os.mknod(os.path.join(PRESETS, "1234", "foo bar 2.json")) - # the newest to the front - self.assertListEqual(get_presets("1234"), ["foo bar 2", "foo bar 1"]) - - def test_find_newest_preset_1(self): - create_preset("Foo Device", "preset 1") - time.sleep(0.01) - create_preset("Bar Device", "preset 2") - - # not a preset, ignore - time.sleep(0.01) - path = os.path.join(PRESETS, "Bar Device", "picture.png") - os.mknod(path) - - self.assertEqual(find_newest_preset(), ("Bar Device", "preset 2")) - - def test_find_newest_preset_2(self): - os.makedirs(f"{PRESETS}/Foo Device") - time.sleep(0.01) - os.makedirs(f"{PRESETS}/device_2") - # takes the first one that the test-fake returns - self.assertEqual(find_newest_preset(), ("Foo Device", None)) - - def test_find_newest_preset_3(self): - os.makedirs(f"{PRESETS}/Foo Device") - self.assertEqual(find_newest_preset(), ("Foo Device", None)) - - def test_find_newest_preset_4(self): - create_preset("Foo Device", "preset 1") - self.assertEqual(find_newest_preset(), ("Foo Device", "preset 1")) - - def test_find_newest_preset_5(self): - create_preset("Foo Device", "preset 1") - time.sleep(0.01) - create_preset("unknown device 3", "preset 3") - self.assertEqual(find_newest_preset(), ("Foo Device", "preset 1")) - - def test_find_newest_preset_6(self): - # takes the first one that the test-fake returns - self.assertEqual(find_newest_preset(), ("Foo Device", None)) - - def test_find_newest_preset_7(self): - self.assertEqual(find_newest_preset("Foo Device"), ("Foo Device", None)) - - def test_find_newest_preset_8(self): - create_preset("Foo Device", "preset 1") - time.sleep(0.01) - create_preset("Foo Device", "preset 3") - time.sleep(0.01) - create_preset("Bar Device", "preset 2") - self.assertEqual(find_newest_preset("Foo Device"), ("Foo Device", "preset 3")) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/unit/test_reader.py b/tests/unit/test_reader.py index d802c20e..3621a199 100644 --- a/tests/unit/test_reader.py +++ b/tests/unit/test_reader.py @@ -17,20 +17,26 @@ # # You should have received a copy of the GNU General Public License # along with input-remapper. If not, see . - - +import json +from typing import List + +from inputremapper.gui.message_broker import ( + MessageBroker, + MessageType, + CombinationRecorded, + Signal, +) from tests.test import ( new_event, push_events, - send_event_to_reader, EVENT_READ_TIMEOUT, START_READING_DELAY, quick_cleanup, MAX_ABS, + MIN_ABS, ) import unittest -from unittest import mock import time import multiprocessing @@ -48,21 +54,27 @@ from evdev.ecodes import ( REL_X, ABS_X, ABS_RZ, + REL_HWHEEL, ) -from inputremapper.gui.reader import reader, will_report_up -from inputremapper.gui.active_preset import active_preset -from inputremapper.configs.global_config import BUTTONS, MOUSE +from inputremapper.gui.reader import Reader from inputremapper.event_combination import EventCombination from inputremapper.gui.helper import RootHelper -from inputremapper.groups import groups - +from inputremapper.groups import _Groups, DeviceType 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 @@ -77,189 +89,257 @@ def wait(func, timeout=1.0): class TestReader(unittest.TestCase): def setUp(self): self.helper = None + self.groups = _Groups() + self.message_broker = MessageBroker() + self.reader = Reader(self.message_broker, self.groups) def tearDown(self): quick_cleanup() + try: + self.reader.terminate() + except (BrokenPipeError, OSError): + pass + if self.helper is not None: self.helper.join() - groups.refresh() - def create_helper(self): + def create_helper(self, groups: _Groups = None): # this will cause pending events to be copied over to the helper # process + if not groups: + groups = self.groups + def start_helper(): - helper = RootHelper() + helper = RootHelper(groups) helper.run() self.helper = multiprocessing.Process(target=start_helper) self.helper.start() time.sleep(0.1) - def test_will_report_up(self): - self.assertFalse(will_report_up(EV_REL)) - self.assertTrue(will_report_up(EV_ABS)) - self.assertTrue(will_report_up(EV_KEY)) + def test_reading(self): + l1 = Listener() + l2 = Listener() + self.message_broker.subscribe(MessageType.combination_recorded, l1) + self.message_broker.subscribe(MessageType.recording_finished, l2) + self.create_helper() + self.reader.set_group(self.groups.find(key="Foo Device 2")) + self.reader.start_recorder() - def test_reading_1(self): - # a single event push_events("Foo Device 2", [new_event(EV_ABS, ABS_HAT0X, 1)]) + + # relative axis events should be released automagically after 0.3s + push_events("Foo Device 2", [new_event(EV_REL, REL_X, 5)]) + time.sleep(0.2) + # read all pending events. Having a glib mainloop would be better, + # as it would call read automatically periodically + self.reader._read() + self.assertEqual( + [ + CombinationRecorded(EventCombination.from_string("3,16,1")), + CombinationRecorded(EventCombination.from_string("3,16,1+2,0,1")), + ], + 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("Foo Device 2", [new_event(EV_ABS, ABS_HAT0X, 0)]) + time.sleep(0.3) + self.reader._read() + self.assertEqual([Signal(MessageType.recording_finished)], l2.calls) + + def test_should_release_relative_axis(self): + # the timeout is set to 0.3s + l1 = Listener() + l2 = Listener() + self.message_broker.subscribe(MessageType.combination_recorded, l1) + self.message_broker.subscribe(MessageType.recording_finished, l2) + self.create_helper() + self.reader.set_group(self.groups.find(key="Foo Device 2")) + self.reader.start_recorder() + + push_events("Foo Device 2", [new_event(EV_REL, REL_X, -5)]) + time.sleep(0.1) + self.reader._read() + + self.assertEqual( + [CombinationRecorded(EventCombination.from_string("2,0,-1"))], + l1.calls, + ) + self.assertEqual([], l2.calls) # no stop recording yet + + time.sleep(0.3) + self.reader._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_helper() + self.reader.set_group(self.groups.find(key="Foo Device 2")) + self.reader.start_recorder() + + push_events("Foo Device 2", [new_event(EV_REL, REL_X, -1)]) + time.sleep(0.1) + self.reader._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_helper() + self.reader.set_group(self.groups.find(key="Foo Device 2")) + self.reader.start_recorder() + push_events( "Foo Device 2", - [new_event(EV_ABS, REL_X, 1)], - ) # mouse movements are ignored - self.create_helper() - reader.start_reading(groups.find(key="Foo Device 2")) - time.sleep(0.2) - self.assertEqual(reader.read(), EventCombination((EV_ABS, ABS_HAT0X, 1))) - self.assertEqual(reader.read(), None) - self.assertEqual(len(reader._unreleased), 1) + [new_event(EV_REL, REL_WHEEL, -1), new_event(EV_REL, REL_HWHEEL, 1)], + ) + time.sleep(0.1) + self.reader._read() - def test_reading_wheel(self): - # will be treated as released automatically at some point + self.assertEqual( + [ + CombinationRecorded(EventCombination.from_string("2,8,-1")), + CombinationRecorded(EventCombination.from_string("2,8,-1+2,6,1")), + ], + l1.calls, + ) + + def test_wont_emit_the_same_combination_twice(self): + l1 = Listener() + self.message_broker.subscribe(MessageType.combination_recorded, l1) self.create_helper() - reader.start_reading(groups.find(key="Foo Device 2")) - - send_event_to_reader(new_event(EV_REL, REL_WHEEL, 0)) - self.assertIsNone(reader.read()) - - send_event_to_reader(new_event(EV_REL, REL_WHEEL, 1)) - result = reader.read() - self.assertIsInstance(result, EventCombination) - self.assertIsInstance(result, tuple) - self.assertEqual(result, EventCombination((EV_REL, REL_WHEEL, 1))) - self.assertEqual(result, ((EV_REL, REL_WHEEL, 1),)) - self.assertNotEqual( - result, - EventCombination(((EV_REL, REL_WHEEL, 1), (1, 1, 1))), + self.reader.set_group(self.groups.find(key="Foo Device 2")) + self.reader.start_recorder() + + push_events("Foo Device 2", [new_event(EV_KEY, KEY_A, 1)]) + time.sleep(0.1) + self.reader._read() + # the duplicate event should be ignored + push_events("Foo Device 2", [new_event(EV_KEY, KEY_A, 1)]) + time.sleep(0.1) + self.reader._read() + + self.assertEqual( + [CombinationRecorded(EventCombination.from_string("1,30,1"))], + l1.calls, ) - # it won't return the same event twice - self.assertEqual(reader.read(), None) + 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_helper() + self.reader.set_group(self.groups.find(key="Foo Device 2")) + self.reader.start_recorder() - # but it is still remembered unreleased - self.assertEqual(len(reader._unreleased), 1) + # over 30% should trigger + push_events("Foo Device 2", [new_event(EV_ABS, ABS_X, int(MAX_ABS * 0.4))]) + time.sleep(0.1) + self.reader._read() self.assertEqual( - reader.get_unreleased_keys(), - EventCombination((EV_REL, REL_WHEEL, 1)), + [CombinationRecorded(EventCombination.from_string("3,0,1"))], + l1.calls, ) - self.assertIsInstance(reader.get_unreleased_keys(), EventCombination) - - # as long as new wheel events arrive, it is considered unreleased - for _ in range(10): - send_event_to_reader(new_event(EV_REL, REL_WHEEL, 1)) - self.assertEqual(reader.read(), None) - self.assertEqual(len(reader._unreleased), 1) - - # read a few more times, at some point it is treated as unreleased - for _ in range(4): - self.assertEqual(reader.read(), None) - self.assertEqual(len(reader._unreleased), 0) - self.assertIsNone(reader.get_unreleased_keys()) - - """Combinations""" - - send_event_to_reader(new_event(EV_REL, REL_WHEEL, 1, 1000)) - send_event_to_reader(new_event(EV_KEY, KEY_COMMA, 1, 1001)) - combi_1 = EventCombination(((EV_REL, REL_WHEEL, 1), (EV_KEY, KEY_COMMA, 1))) - combi_2 = EventCombination(((EV_KEY, KEY_COMMA, 1), (EV_KEY, KEY_A, 1))) - read = reader.read() - self.assertEqual(read, combi_1) - self.assertEqual(reader.read(), None) - self.assertEqual(len(reader._unreleased), 2) - self.assertEqual(reader.get_unreleased_keys(), combi_1) - - # don't send new wheel down events, it should get released again - i = 0 - while len(reader._unreleased) == 2: - read = reader.read() - if i == 100: - raise AssertionError("Did not release the wheel") - i += 1 - # and only the comma remains. However, a changed combination is - # only returned when a new key is pressed. Only then the pressed - # down keys are collected in a new Key object. - self.assertEqual(read, None) - self.assertEqual(reader.read(), None) - self.assertEqual(len(reader._unreleased), 1) - self.assertEqual(reader.get_unreleased_keys(), EventCombination(combi_1[1])) - - # press down a new key, now it will return a different combination - send_event_to_reader(new_event(EV_KEY, KEY_A, 1, 1002)) - self.assertEqual(reader.read(), combi_2) - self.assertEqual(len(reader._unreleased), 2) - - # release all of them - send_event_to_reader(new_event(EV_KEY, KEY_COMMA, 0)) - send_event_to_reader(new_event(EV_KEY, KEY_A, 0)) - self.assertEqual(reader.read(), None) - self.assertEqual(len(reader._unreleased), 0) - self.assertEqual(reader.get_unreleased_keys(), None) - - def test_change_wheel_direction(self): - # not just wheel, anything that suddenly reports a different value. - # as long as type and code are equal its the same key, so there is no - # way both directions can be held down. - self.assertEqual(reader.read(), None) + self.assertEqual([], l2.calls) # no stop recording yet + + # less the 30% should release + push_events("Foo Device 2", [new_event(EV_ABS, ABS_X, int(MAX_ABS * 0.2))]) + time.sleep(0.1) + self.reader._read() + self.assertEqual( + [CombinationRecorded(EventCombination.from_string("3,0,1"))], + 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_helper() - self.assertEqual(reader.read(), None) - reader.start_reading(groups.find(key="Foo Device 2")) - self.assertEqual(reader.read(), None) - - send_event_to_reader(new_event(EV_REL, REL_WHEEL, 1)) - self.assertEqual(reader.read(), EventCombination((EV_REL, REL_WHEEL, 1))) - self.assertEqual(len(reader._unreleased), 1) - self.assertEqual(reader.read(), None) - - send_event_to_reader(new_event(EV_REL, REL_WHEEL, -1)) - self.assertEqual(reader.read(), EventCombination((EV_REL, REL_WHEEL, -1))) - # notice that this is no combination of two sides, the previous - # entry in unreleased has to get overwritten. So there is still only - # one element in it. - self.assertEqual(len(reader._unreleased), 1) - self.assertEqual(reader.read(), None) + self.reader.set_group(self.groups.find(key="Foo Device 2")) + self.reader.start_recorder() + + push_events( + "Foo Device 2", + [ + new_event(EV_KEY, KEY_A, 1), + new_event(EV_ABS, ABS_X, int(MAX_ABS * 0.4)), + new_event(EV_KEY, KEY_COMMA, 1), + new_event(EV_ABS, ABS_X, int(MAX_ABS * 0.1)), + new_event(EV_ABS, ABS_X, int(MIN_ABS * 0.4)), + ], + ) + time.sleep(0.1) + self.reader._read() + self.assertEqual( + [ + CombinationRecorded(EventCombination.from_string("1,30,1")), + CombinationRecorded(EventCombination.from_string("1,30,1+3,0,1")), + CombinationRecorded( + EventCombination.from_string("1,30,1+3,0,1+1,51,1") + ), + CombinationRecorded( + EventCombination.from_string("1,30,1+3,0,-1+1,51,1") + ), + ], + l1.calls, + ) def test_change_device(self): + l1 = Listener() + self.message_broker.subscribe(MessageType.combination_recorded, l1) + push_events( "Foo Device 2", [ new_event(EV_KEY, 1, 1), ] - * 100, + * 10, ) push_events( "Bar Device", [ new_event(EV_KEY, 2, 1), + new_event(EV_KEY, 2, 0), ] - * 100, + * 3, ) self.create_helper() - - reader.start_reading(groups.find(key="Foo Device 2")) + self.reader.set_group(self.groups.find(key="Foo Device 2")) + self.reader.start_recorder() time.sleep(0.1) - self.assertEqual(reader.read(), EventCombination((EV_KEY, 1, 1))) - - reader.start_reading(groups.find(name="Bar Device")) + self.reader._read() + self.assertEqual(l1.calls[0].combination, EventCombination((EV_KEY, 1, 1))) - # it's plausible that right after sending the new read command more - # events from the old device might still appear. Give the helper - # some time to handle the new command. + self.reader.set_group(self.groups.find(name="Bar Device")) time.sleep(0.1) - reader.clear() + self.reader._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.start_recorder() + push_events("Bar Device", [new_event(EV_KEY, 2, 1)]) time.sleep(0.1) - self.assertEqual(reader.read(), EventCombination((EV_KEY, 2, 1))) + self.reader._read() + self.assertEqual(l1.calls[1].combination, EventCombination((EV_KEY, 2, 1))) def test_reading_2(self): + l1 = Listener() + self.message_broker.subscribe(MessageType.combination_recorded, l1) # a combination of events push_events( "Foo Device 2", [ new_event(EV_KEY, CODE_1, 1, 10000.1234), new_event(EV_KEY, CODE_3, 1, 10001.1234), - new_event(EV_ABS, ABS_HAT0X, -1, 10002.1234), ], ) @@ -270,131 +350,33 @@ class TestReader(unittest.TestCase): # refresh was called as expected pipe[1].send("refreshed") - with mock.patch.object(groups, "refresh", refresh): - self.create_helper() + groups = _Groups() + groups.refresh = refresh + self.create_helper(groups) - reader.start_reading(groups.find(key="Foo Device 2")) + self.reader.set_group(self.groups.find(key="Foo Device 2")) + self.reader.start_recorder() # sending anything arbitrary does not stop the helper - reader._commands.send(856794) + self.reader._commands.send(856794) time.sleep(0.2) + push_events("Foo Device 2", [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 - # groups is not up-to-date + # self.groups is not up-to-date self.assertTrue(pipe[0].poll()) self.assertEqual(pipe[0].recv(), "refreshed") + self.reader._read() self.assertEqual( - reader.read(), + l1.calls[-1].combination, ((EV_KEY, CODE_1, 1), (EV_KEY, CODE_3, 1), (EV_ABS, ABS_HAT0X, -1)), ) - self.assertEqual(reader.read(), None) - self.assertEqual(len(reader._unreleased), 3) - - def test_reading_3(self): - self.create_helper() - # a combination of events via Socket with reads inbetween - reader.start_reading(groups.find(name="gamepad")) - - send_event_to_reader(new_event(EV_KEY, CODE_1, 1, 1001)) - self.assertEqual(reader.read(), EventCombination((EV_KEY, CODE_1, 1))) - - # active_preset.set("gamepad.joystick.left_purpose", BUTTONS) - send_event_to_reader(new_event(EV_ABS, ABS_Y, 1, 1002)) - self.assertEqual( - reader.read(), - EventCombination(((EV_KEY, CODE_1, 1), (EV_ABS, ABS_Y, 1))), - ) - - send_event_to_reader(new_event(EV_ABS, ABS_HAT0X, -1, 1003)) - self.assertEqual( - reader.read(), - EventCombination( - ((EV_KEY, CODE_1, 1), (EV_ABS, ABS_Y, 1), (EV_ABS, ABS_HAT0X, -1)), - ), - ) - - # adding duplicate down events won't report a different combination. - # import for triggers, as they keep reporting more down-events before - # they are released - send_event_to_reader(new_event(EV_ABS, ABS_Y, 1, 1005)) - self.assertEqual(reader.read(), None) - send_event_to_reader(new_event(EV_ABS, ABS_HAT0X, -1, 1006)) - self.assertEqual(reader.read(), None) - - send_event_to_reader(new_event(EV_KEY, CODE_1, 0, 1004)) - read = reader.read() - self.assertEqual(read, None) - - send_event_to_reader(new_event(EV_ABS, ABS_Y, 0, 1007)) - self.assertEqual(reader.read(), None) - - send_event_to_reader(new_event(EV_KEY, ABS_HAT0X, 0, 1008)) - self.assertEqual(reader.read(), None) - - def test_reads_joysticks(self): - # if their purpose is "buttons" - # active_preset.set("gamepad.joystick.left_purpose", BUTTONS) - push_events( - "gamepad", - [ - new_event(EV_ABS, ABS_Y, MAX_ABS), - # the value of that one is interpreted as release, because - # it is too small - new_event(EV_ABS, ABS_X, MAX_ABS // 10), - ], - ) - self.create_helper() - - reader.start_reading(groups.find(name="gamepad")) - time.sleep(0.2) - self.assertEqual(reader.read(), EventCombination((EV_ABS, ABS_Y, 1))) - self.assertEqual(reader.read(), None) - self.assertEqual(len(reader._unreleased), 1) - - reader._unreleased = {} - # active_preset.set("gamepad.joystick.left_purpose", MOUSE) - push_events("gamepad", [new_event(EV_ABS, ABS_Y, MAX_ABS)]) - self.create_helper() - - reader.start_reading(groups.find(name="gamepad")) - time.sleep(0.1) - self.assertEqual(reader.read(), None) - self.assertEqual(len(reader._unreleased), 0) - - def test_combine_triggers(self): - reader.start_reading(groups.find(key="Foo Device 2")) - - i = 0 - - def next_timestamp(): - nonlocal i - i += 1 - return time.time() + i - - # based on an observed bug - send_event_to_reader(new_event(3, 1, 0, next_timestamp())) - send_event_to_reader(new_event(3, 0, 0, next_timestamp())) - send_event_to_reader(new_event(3, 2, 1, next_timestamp())) - self.assertEqual(reader.read(), EventCombination((EV_ABS, ABS_Z, 1))) - send_event_to_reader(new_event(3, 0, 0, next_timestamp())) - send_event_to_reader(new_event(3, 5, 1, next_timestamp())) - self.assertEqual( - reader.read(), - EventCombination(((EV_ABS, ABS_Z, 1), (EV_ABS, ABS_RZ, 1))), - ) - send_event_to_reader(new_event(3, 5, 0, next_timestamp())) - send_event_to_reader(new_event(3, 0, 0, next_timestamp())) - send_event_to_reader(new_event(3, 1, 0, next_timestamp())) - self.assertEqual(reader.read(), None) - send_event_to_reader(new_event(3, 2, 1, next_timestamp())) - send_event_to_reader(new_event(3, 1, 0, next_timestamp())) - send_event_to_reader(new_event(3, 0, 0, next_timestamp())) - # due to not properly handling the duplicate down event it cleared - # the combination and returned it. Instead it should report None - # and by doing that keep the previous combination. - self.assertEqual(reader.read(), None) def test_blacklisted_events(self): + l1 = Listener() + self.message_broker.subscribe(MessageType.combination_recorded, l1) + push_events( "Foo Device 2", [ @@ -404,26 +386,34 @@ class TestReader(unittest.TestCase): ], ) self.create_helper() - reader.start_reading(groups.find(key="Foo Device 2")) + self.reader.set_group(self.groups.find(key="Foo Device 2")) + self.reader.start_recorder() time.sleep(0.1) - self.assertEqual(reader.read(), EventCombination((EV_KEY, CODE_2, 1))) - self.assertEqual(reader.read(), None) - self.assertEqual(len(reader._unreleased), 1) + self.reader._read() + self.assertEqual( + l1.calls[-1].combination, EventCombination((EV_KEY, CODE_2, 1)) + ) 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( "Foo Device 2", [new_event(EV_ABS, ABS_HAT0X, 1), new_event(EV_KEY, CODE_3, 2)], ) self.create_helper() - reader.start_reading(groups.find(key="Foo Device 2")) + self.reader.set_group(self.groups.find(key="Foo Device 2")) + self.reader.start_recorder() time.sleep(0.2) - self.assertEqual(reader.read(), EventCombination((EV_ABS, ABS_HAT0X, 1))) - self.assertEqual(reader.read(), None) - self.assertEqual(len(reader._unreleased), 1) + self.reader._read() + self.assertEqual( + l1.calls[-1].combination, EventCombination((EV_ABS, ABS_HAT0X, 1)) + ) def test_reading_ignore_up(self): + l1 = Listener() + self.message_broker.subscribe(MessageType.combination_recorded, l1) push_events( "Foo Device 2", [ @@ -433,32 +423,18 @@ class TestReader(unittest.TestCase): ], ) self.create_helper() - reader.start_reading(groups.find(key="Foo Device 2")) + self.reader.set_group(self.groups.find(key="Foo Device 2")) + self.reader.start_recorder() time.sleep(0.1) - self.assertEqual(reader.read(), EventCombination((EV_KEY, CODE_2, 1))) - self.assertEqual(reader.read(), None) - self.assertEqual(len(reader._unreleased), 1) - - def test_reading_ignore_duplicate_down(self): - send_event_to_reader(new_event(EV_ABS, ABS_Z, 1, 10)) - - self.assertEqual(reader.read(), EventCombination((EV_ABS, ABS_Z, 1))) - self.assertEqual(reader.read(), None) - - # duplicate - send_event_to_reader(new_event(EV_ABS, ABS_Z, 1, 10)) - self.assertEqual(reader.read(), None) - self.assertEqual(len(reader._unreleased), 1) - self.assertEqual(len(reader.get_unreleased_keys()), 1) - self.assertIsInstance(reader.get_unreleased_keys(), EventCombination) - - # release - send_event_to_reader(new_event(EV_ABS, ABS_Z, 0, 10)) - self.assertEqual(reader.read(), None) - self.assertEqual(len(reader._unreleased), 0) - self.assertIsNone(reader.get_unreleased_keys()) + self.reader._read() + self.assertEqual( + l1.calls[-1].combination, EventCombination((EV_KEY, CODE_2, 1)) + ) def test_wrong_device(self): + l1 = Listener() + self.message_broker.subscribe(MessageType.combination_recorded, l1) + push_events( "Foo Device 2", [ @@ -468,16 +444,19 @@ class TestReader(unittest.TestCase): ], ) self.create_helper() - reader.start_reading(groups.find(name="Bar Device")) + self.reader.set_group(self.groups.find(name="Bar Device")) + self.reader.start_recorder() time.sleep(EVENT_READ_TIMEOUT * 5) - self.assertEqual(reader.read(), None) - self.assertEqual(len(reader._unreleased), 0) + self.reader._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( "input-remapper Bar Device", [ @@ -487,106 +466,105 @@ class TestReader(unittest.TestCase): ], ) self.create_helper() - reader.start_reading(groups.find(name="Bar Device")) + self.reader.set_group(self.groups.find(name="Bar Device")) + self.reader.start_recorder() time.sleep(EVENT_READ_TIMEOUT * 5) - self.assertEqual(reader.read(), None) - self.assertEqual(len(reader._unreleased), 0) - - def test_clear(self): - push_events( - "Foo Device 2", - [ - new_event(EV_KEY, CODE_1, 1), - new_event(EV_KEY, CODE_2, 1), - new_event(EV_KEY, CODE_3, 1), - ] - * 15, - ) - - self.create_helper() - reader.start_reading(groups.find(key="Foo Device 2")) - time.sleep(START_READING_DELAY + EVENT_READ_TIMEOUT * 3) - - reader.read() - self.assertEqual(len(reader._unreleased), 3) - self.assertIsNotNone(reader.previous_event) - self.assertIsNotNone(reader.previous_result) - - # make the helper send more events to the reader - time.sleep(EVENT_READ_TIMEOUT * 2) - self.assertTrue(reader._results.poll()) - reader.clear() - - self.assertFalse(reader._results.poll()) - self.assertEqual(reader.read(), None) - self.assertEqual(len(reader._unreleased), 0) - self.assertIsNone(reader.get_unreleased_keys()) - self.assertIsNone(reader.previous_event) - self.assertIsNone(reader.previous_result) - self.tearDown() - - def test_switch_device(self): - push_events("Bar Device", [new_event(EV_KEY, CODE_1, 1)]) - push_events("Foo Device 2", [new_event(EV_KEY, CODE_3, 1)]) - self.create_helper() - - reader.start_reading(groups.find(name="Bar Device")) - self.assertFalse(reader._results.poll()) - self.assertEqual(reader.group.name, "Bar Device") - time.sleep(EVENT_READ_TIMEOUT * 5) - - self.assertTrue(reader._results.poll()) - reader.start_reading(groups.find(key="Foo Device 2")) - self.assertEqual(reader.group.name, "Foo Device") - self.assertFalse(reader._results.poll()) # pipe resets - - time.sleep(EVENT_READ_TIMEOUT * 5) - self.assertTrue(reader._results.poll()) - - self.assertEqual(reader.read(), EventCombination((EV_KEY, CODE_3, 1))) - self.assertEqual(reader.read(), None) - self.assertEqual(len(reader._unreleased), 1) + self.reader._read() + self.assertEqual(len(l1.calls), 0) def test_terminate(self): self.create_helper() - reader.start_reading(groups.find(key="Foo Device 2")) + self.reader.set_group(self.groups.find(key="Foo Device 2")) push_events("Foo Device 2", [new_event(EV_KEY, CODE_3, 1)]) time.sleep(START_READING_DELAY + EVENT_READ_TIMEOUT) - self.assertTrue(reader._results.poll()) + self.assertTrue(self.reader._results.poll()) - reader.terminate() - reader.clear() + self.reader.terminate() time.sleep(EVENT_READ_TIMEOUT) + self.assertFalse(self.reader._results.poll()) # no new events arrive after terminating push_events("Foo Device 2", [new_event(EV_KEY, CODE_3, 1)]) time.sleep(EVENT_READ_TIMEOUT * 3) - self.assertFalse(reader._results.poll()) + self.assertFalse(self.reader._results.poll()) def test_are_new_groups_available(self): + l1 = Listener() + self.message_broker.subscribe(MessageType.groups, l1) self.create_helper() - groups.set_groups({}) + self.reader.groups.set_groups({}) + time.sleep(0.1) # let the helper send the groups # read stuff from the helper, which includes the devices - self.assertFalse(reader.are_new_groups_available()) - reader.read() - - self.assertTrue(reader.are_new_groups_available()) - # a bit weird, but it assumes the gui handled that and returns - # false afterwards - self.assertFalse(reader.are_new_groups_available()) - - # send the same devices again - reader._get_event({"type": "groups", "message": groups.dumps()}) - self.assertFalse(reader.are_new_groups_available()) - - # send changed devices - message = groups.dumps() - message = message.replace("Foo Device", "foo_device") - reader._get_event({"type": "groups", "message": message}) - self.assertTrue(reader.are_new_groups_available()) - self.assertFalse(reader.are_new_groups_available()) + self.assertEqual("[]", self.reader.groups.dumps()) + self.reader._read() + + self.assertEqual( + self.reader.groups.dumps(), + json.dumps( + [ + json.dumps( + { + "paths": [ + "/dev/input/event1", + ], + "names": ["Foo Device"], + "types": [DeviceType.KEYBOARD], + "key": "Foo Device", + } + ), + json.dumps( + { + "paths": [ + "/dev/input/event11", + "/dev/input/event10", + "/dev/input/event13", + "/dev/input/event15", + ], + "names": [ + "Foo Device foo", + "Foo Device", + "Foo Device", + "Foo Device bar", + ], + "types": [ + DeviceType.GAMEPAD, + DeviceType.KEYBOARD, + DeviceType.MOUSE, + ], + "key": "Foo Device 2", + } + ), + json.dumps( + { + "paths": ["/dev/input/event20"], + "names": ["Bar Device"], + "types": [DeviceType.KEYBOARD], + "key": "Bar Device", + } + ), + json.dumps( + { + "paths": ["/dev/input/event30"], + "names": ["gamepad"], + "types": [DeviceType.GAMEPAD], + "key": "gamepad", + } + ), + json.dumps( + { + "paths": ["/dev/input/event40"], + "names": ["input-remapper Bar Device"], + "types": [DeviceType.KEYBOARD], + "key": "input-remapper Bar Device", + } + ), + ] + ), + ) + + self.assertEqual(len(l1.calls), 1) # ensure we got the event if __name__ == "__main__": diff --git a/tests/unit/test_test.py b/tests/unit/test_test.py index 7529f1a8..5d7ee1eb 100644 --- a/tests/unit/test_test.py +++ b/tests/unit/test_test.py @@ -17,8 +17,7 @@ # # You should have received a copy of the GNU General Public License # along with input-remapper. If not, see . - - +from inputremapper.gui.message_broker import MessageBroker from tests.test import ( InputDevice, quick_cleanup, @@ -38,8 +37,8 @@ import multiprocessing import evdev from evdev.ecodes import EV_ABS, EV_KEY -from inputremapper.groups import groups -from inputremapper.gui.reader import reader +from inputremapper.groups import groups, _Groups +from inputremapper.gui.reader import Reader from inputremapper.gui.helper import RootHelper @@ -89,12 +88,15 @@ class TestTest(unittest.TestCase): Using push_events after the helper is already forked should work, as well as using push_event twice """ + reader = Reader(MessageBroker(), groups) def create_helper(): # this will cause pending events to be copied over to the helper # process def start_helper(): - helper = RootHelper() + # there is no point in using the global groups object + # because the helper runs in a different process + helper = RootHelper(_Groups()) helper.run() self.helper = multiprocessing.Process(target=start_helper) @@ -108,24 +110,27 @@ class TestTest(unittest.TestCase): if reader._results.poll(): break - event = new_event(EV_KEY, 102, 1) create_helper() - reader.start_reading(groups.find(key="Foo Device 2")) + reader.set_group(groups.find(key="Foo Device 2")) time.sleep(START_READING_DELAY) + event = new_event(EV_KEY, 102, 1) push_events("Foo Device 2", [event]) wait_for_results() self.assertTrue(reader._results.poll()) - reader.clear() + reader._read() self.assertFalse(reader._results.poll()) # can push more events to the helper that is inside a separate # process, which end up being sent to the reader + event = new_event(EV_KEY, 102, 0) push_events("Foo Device 2", [event]) wait_for_results() self.assertTrue(reader._results.poll()) + reader.terminate() + if __name__ == "__main__": unittest.main()