diff --git a/DEBIAN/control b/DEBIAN/control index 17c5b855..3972e575 100644 --- a/DEBIAN/control +++ b/DEBIAN/control @@ -2,7 +2,7 @@ Package: input-remapper Version: 1.3.0 Architecture: all Maintainer: Sezanzeb -Depends: build-essential, libpython3-dev, libdbus-1-dev, python3, python3-setuptools, python3-evdev, python3-pydbus, python3-gi, gettext, python3-cairo, libgtk-3-0 +Depends: build-essential, libpython3-dev, libdbus-1-dev, python3, python3-setuptools, python3-evdev, python3-pydbus, python3-gi, gettext, python3-cairo, libgtk-3-0, libgtksourceview-4-0 Description: A tool to change the mapping of your input device buttons Replaces: python3-key-mapper, key-mapper Conflicts: python3-key-mapper, key-mapper diff --git a/README.md b/README.md index 8c985e68..16a6a713 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,8 @@ input-remapper is now part of [Debian Unstable](https://packages.debian.org/sid/ ##### pip +Dependencies from your distros repo: `gtksourceview4` + ```bash sudo pip uninstall key-mapper sudo pip install --no-binary :all: git+https://github.com/sezanzeb/input-remapper.git diff --git a/bin/input-remapper-gtk b/bin/input-remapper-gtk index 25eadfdb..19e60618 100755 --- a/bin/input-remapper-gtk +++ b/bin/input-remapper-gtk @@ -33,6 +33,7 @@ from argparse import ArgumentParser import gi gi.require_version('Gtk', '3.0') gi.require_version('GLib', '2.0') +gi.require_version('GtkSource', '4') from gi.repository import Gtk APP_NAME = 'input-remapper' @@ -65,21 +66,20 @@ if __name__ == '__main__': logger.debug('Using locale directory: {}'.format(LOCALE_DIR)) # import input-remapper stuff after setting the log verbosity - from inputremapper.gui.window import Window + from inputremapper.gui.user_interface import UserInterface from inputremapper.daemon import Daemon from inputremapper.daemon import config config.load_config() - window = Window() + user_interface = UserInterface() def stop(): - if isinstance(window.dbus, Daemon): - # it created its own temporary daemon inside the process - # because none was running - window.dbus.stop_all() + if isinstance(user_interface.dbus, Daemon): + # have fun debugging completely unrelated tests if you remove this + user_interface.dbus.stop_all() - window.on_close() + user_interface.on_close() atexit.register(stop) diff --git a/bin/key-mapper-control b/bin/key-mapper-control index d67761f1..39b1ce51 100755 --- a/bin/key-mapper-control +++ b/bin/key-mapper-control @@ -18,7 +18,11 @@ # You should have received a copy of the GNU General Public License # along with input-remapper. If not, see . -print('key-mapper-control is deprecated, please use input-remapper-control instead') +print( + "\033[31m" + "key-mapper-control is deprecated, please use input-remapper-control instead" + "\033[0m" +) from importlib.util import spec_from_loader, module_from_spec from importlib.machinery import SourceFileLoader diff --git a/bin/key-mapper-gtk b/bin/key-mapper-gtk index dbebf201..29edc1f0 100755 --- a/bin/key-mapper-gtk +++ b/bin/key-mapper-gtk @@ -18,7 +18,11 @@ # You should have received a copy of the GNU General Public License # along with input-remapper. If not, see . -print('key-mapper-gtk is deprecated, please use input-remapper-gtk instead') +print( + "\033[31m" + "key-mapper-gtk is deprecated, please use input-remapper-gtk instead" + "\033[0m" +) from importlib.util import spec_from_loader, module_from_spec from importlib.machinery import SourceFileLoader diff --git a/bin/key-mapper-service b/bin/key-mapper-service index 2ccee66e..c1356f89 100755 --- a/bin/key-mapper-service +++ b/bin/key-mapper-service @@ -18,7 +18,11 @@ # You should have received a copy of the GNU General Public License # along with input-remapper. If not, see . -print('key-mapper-service is deprecated, please use input-remapper-service instead') +print( + "\033[31m" + "key-mapper-service is deprecated, please use input-remapper-service instead" + "\033[0m" +) from importlib.util import spec_from_loader, module_from_spec from importlib.machinery import SourceFileLoader diff --git a/data/input-remapper.desktop b/data/input-remapper.desktop index 26d0ac39..3255e6aa 100644 --- a/data/input-remapper.desktop +++ b/data/input-remapper.desktop @@ -1,6 +1,6 @@ [Desktop Entry] Type=Application -Name=input-remapper +Name=Input Remapper Icon=/usr/share/input-remapper/input-remapper.svg Exec=input-remapper-gtk Terminal=false diff --git a/data/input-remapper.glade b/data/input-remapper.glade index 60930a21..ca940424 100644 --- a/data/input-remapper.glade +++ b/data/input-remapper.glade @@ -2,6 +2,7 @@ + True False @@ -10,27 +11,120 @@ True False + 2 dialog-ok True False + 2 edit-copy True False + 2 edit-delete - + True False gtk-delete + + False + dialog + + + False + vertical + 1 + top + + + False + True + expand + + + Delete + False + True + True + True + delete-icon-1 + + + True + True + 1 + + + + + Cancel + True + True + True + True + True + + + True + True + 1 + + + + + True + True + 0 + + + + + True + False + 50 + 50 + 32 + + + False + True + 1 + + + + + + button1 + button2 + + + + True + False + input-keyboard + True False - gtk-redo + 2 + edit-undo + + + True + False + 2 + window-close + + + True + False + 2 + object-rotate-right 2 @@ -41,6 +135,7 @@ True False + 2 document-new @@ -49,13 +144,15 @@ document-save - 750 + 800 False Input Remapper + 1000 + 450 input-remapper.svg - - + + True @@ -117,12 +214,12 @@ - Restore Defaults + Stop Injection True True True Shortcut: ctrl + del -To give your keys back their original mapping. +Gives your keys back their original function end gtk-redo-icon True @@ -139,6 +236,7 @@ To give your keys back their original mapping. True True True + Help end about-icon True @@ -147,7 +245,7 @@ To give your keys back their original mapping. False False - 3 + 5 @@ -211,7 +309,7 @@ To give your keys back their original mapping. True True True - Don't hold down any keys while the injection starts. + Start injecting. Don't hold down any keys while the injection starts check-icon none True @@ -230,6 +328,7 @@ To give your keys back their original mapping. True True True + Duplicate this preset copy-icon none True @@ -248,6 +347,7 @@ To give your keys back their original mapping. True True True + Create a new preset new-icon none True @@ -267,6 +367,7 @@ To give your keys back their original mapping. True True True + Delete this preset delete-icon none True @@ -395,11 +496,11 @@ To give your keys back their original mapping. True False + To automatically apply the preset after your login or when it connects. True False - To automatically apply the preset after your login or when it connects. Autoload 0 @@ -695,18 +796,31 @@ To give your keys back their original mapping. False vertical - + True False - - 140 + + 160 True - False - Click on a cell below and hit a key on your device. Click the "Restore Defaults" button beforehand. - 5 - 5 - Key + True + + + True + False + none + + + True + False + browse + + + + + False @@ -715,74 +829,130 @@ To give your keys back their original mapping. - - True - False - 5 - 5 - Mapping - - - True - True - 1 - - - - - 50 + True False False True - 2 + 1 - - - - False - True - 0 - - - - - True - False - - - False - True - 1 - - - - - True - True - + True False + 18 + 18 + 18 + 18 + vertical + 18 - + True False - none + 12 + + + Change Key + 100 + True + True + True + Record a button of your device that should be remapped + image1 + none + True + + + False + True + 0 + + + + + True + False + + + True + True + 1 + + + + + Delete + 80 + True + True + True + Delete this entry + icon-delete-row + none + True + + + + False + True + 2 + + + + False + True + 0 + + + + + True + True + + + True + True + start + immediate + word + 10 + 10 + 10 + 10 + True + 2 + True + + + + + + True + True + 1 + + + True + True + 2 + True True - 2 + 0 @@ -1088,236 +1258,4 @@ Macros allow multiple characters to be written with a single key-press. Informat - - False - 4 - Input Remapper - True - input-remapper.svg - dialog - True - window - window - - - True - False - vertical - - - True - False - end - 6 - end - - - Go Back - True - True - True - - - True - True - 0 - - - - - Delete - False - True - True - True - False - gtk-delete-icon1 - - - False - False - 1 - - - - - False - True - end - 0 - - - - - True - False - - - True - False - 6 - 6 - 0 - dialog-warning - 6 - - - False - False - 0 - - - - - True - False - 6 - 6 - 6 - 6 - 6 - True - 0 - 0.5 - - - True - True - 1 - - - - - True - True - 2 - - - - - - go_ahead1 - go_back1 - - - - False - 4 - Input Remapper - True - input-remapper.svg - dialog - True - window - window - - - True - False - vertical - - - True - False - center - 6 - end - - - gtk-close - False - True - True - True - False - True - - - False - False - 0 - - - - - False - True - end - 0 - - - - - True - False - - - True - False - 6 - 0 - dialog-error - 6 - - - False - False - 0 - - - - - True - False - 6 - vertical - - - True - False - 6 - True - 0 - - - False - False - 0 - - - - - True - False - 6 - True - 0 - 0 - - - True - True - 1 - - - - - True - True - 1 - - - - - True - True - 2 - - - - - - close_error_dialog - - diff --git a/data/style.css b/data/style.css index 41050280..9f3a2736 100644 --- a/data/style.css +++ b/data/style.css @@ -29,7 +29,7 @@ list entry { box-shadow: none; } -list button:not(:focus) { +list.basic-editor button:not(:focus) { border-color: transparent; background: transparent; box-shadow: none; @@ -39,8 +39,39 @@ list button { border-color: transparent; } +.transparent { + background: transparent; +} + +.code-editor-text-view > * { + border-radius: 2px; +} + .copyright { font-size: 7pt; } -/* @theme_bg_color, @theme_fg_color */ \ No newline at end of file +.editor-key-list label { + padding: 11px; +} + +.autocompletion label { + padding: 11px; +} + +.autocompletion { + padding: 0px; + box-shadow: none; +} + +.no-border { + border: 0px; + box-shadow: none; +} + +.code-editor-text-view.multiline { + /* extra space between text editor and line numbers */ + padding-left: 18px; +} + +/* @theme_bg_color, @theme_fg_color */ diff --git a/inputremapper/config.py b/inputremapper/config.py index 28367a5c..c338c461 100644 --- a/inputremapper/config.py +++ b/inputremapper/config.py @@ -207,6 +207,8 @@ class GlobalConfig(ConfigBase): logger.info('Not injecting for "%s" automatically anmore', group_key) self.remove(["autoload", group_key]) + self._save_config() + def iterate_autoload_presets(self): """Get tuples of (device, preset).""" return self._config.get("autoload", {}).items() @@ -237,7 +239,7 @@ class GlobalConfig(ConfigBase): logger.debug('Config "%s" doesn\'t exist yet', self.path) self.clear_config() self._config = copy.deepcopy(INITIAL_CONFIG) - self.save_config() + self._save_config() return with open(self.path, "r") as file: @@ -253,7 +255,7 @@ class GlobalConfig(ConfigBase): # uses the default configuration when the config object # is empty automatically - def save_config(self): + def _save_config(self): """Save the config to the file system.""" if USER == "root": logger.debug("Skipping config file creation for the root user") @@ -266,5 +268,6 @@ class GlobalConfig(ConfigBase): logger.info("Saved config to %s", self.path) file.write("\n") + migrate() config = GlobalConfig() diff --git a/inputremapper/daemon.py b/inputremapper/daemon.py index 060f06da..808855e4 100644 --- a/inputremapper/daemon.py +++ b/inputremapper/daemon.py @@ -338,9 +338,8 @@ class Daemon: return if not isinstance(preset, str): - # might be broken due to a previous bug - config.remove(["autoload", group.key]) - config.save_config() + # maybe another dict or something, who knows. Broken config + logger.error("Expected a string for autoload, but got %s", preset) return logger.info('Autoloading for "%s"', group.key) diff --git a/inputremapper/gui/editor/__init__.py b/inputremapper/gui/editor/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/inputremapper/gui/editor/autocompletion.py b/inputremapper/gui/editor/autocompletion.py new file mode 100644 index 00000000..b9728312 --- /dev/null +++ b/inputremapper/gui/editor/autocompletion.py @@ -0,0 +1,407 @@ +#!/usr/bin/python3 +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2022 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + + +"""Autocompletion for the editor.""" + + +import re + +from gi.repository import Gdk, Gtk, GLib, GObject, GtkSource + +from inputremapper.system_mapping import system_mapping +from inputremapper.injection.macros.parse import ( + FUNCTIONS, + get_macro_argument_names, + remove_comments, +) +from inputremapper.logger import logger + + +# no shorthand names +FUNCTION_NAMES = [name for name in FUNCTIONS.keys() if len(name) > 1] +# no deprecated functions +FUNCTION_NAMES.remove("ifeq") + + +def _get_left_text(iter): + buffer = iter.get_buffer() + result = buffer.get_text(buffer.get_start_iter(), iter, True) + result = remove_comments(result) + result = result.replace("\n", " ") + return result.lower() + + +# regex to search for the beginning of a... +PARAMETER = r".*?[(,=+]\s*" +FUNCTION_CHAIN = r".*?\)\s*\.\s*" + + +def get_incomplete_function_name(iter): + """Get the word that is written left to the TextIter.""" + left_text = _get_left_text(iter) + + # match foo in: + # bar().foo + # bar()\n.foo + # bar().\nfoo + # bar(\nfoo + # bar(\nqux=foo + # bar(KEY_A,\nfoo + # foo + match = re.match(rf"(?:{FUNCTION_CHAIN}|{PARAMETER}|^)(\w+)$", left_text) + + if match is None: + return "" + + return match[1] + + +def get_incomplete_parameter(iter): + """Get the parameter that is written left to the TextIter.""" + left_text = _get_left_text(iter) + + # match foo in: + # bar(foo + # bar(a=foo + # bar(qux, foo + # foo + # bar + foo + match = re.match(rf"(?:{PARAMETER}|^)(\w+)$", left_text) + print("get_incomplete_parameter", left_text, match) + + if match is None: + return None + + return match[1] + + +def propose_symbols(text_iter): + """Find key names that match the input at the cursor.""" + incomplete_name = get_incomplete_parameter(text_iter) + + if incomplete_name is None or len(incomplete_name) <= 1: + return [] + + incomplete_name = incomplete_name.lower() + + return [ + (name, name) + for name in list(system_mapping.list_names()) + if incomplete_name in name.lower() and incomplete_name != name.lower() + ] + + +def propose_function_names(text_iter): + """Find function names that match the input at the cursor.""" + incomplete_name = get_incomplete_function_name(text_iter) + + if incomplete_name is None or len(incomplete_name) <= 1: + return [] + + incomplete_name = incomplete_name.lower() + + return [ + (name, f"{name}({', '.join(get_macro_argument_names(FUNCTIONS[name]))})") + for name in FUNCTION_NAMES + if incomplete_name in name.lower() and incomplete_name != name.lower() + ] + + +debounces = {} + + +def debounce(func): + """Debounce a function call to improve performance.""" + + def clear_debounce(self, *args): + debounces[func.__name__] = None + return func(self, *args) + + def wrapped(self, *args): + if debounces.get(func.__name__) is not None: + GLib.source_remove(debounces[func.__name__]) + + timeout = self.debounce_timeout + + debounces[func.__name__] = GLib.timeout_add( + timeout, lambda: clear_debounce(self, *args) + ) + + return wrapped + + +class SuggestionLabel(Gtk.Label): + """A label with some extra internal information.""" + + __gtype_name__ = "SuggestionLabel" + + def __init__(self, display_name, suggestion): + super().__init__(label=display_name) + self.suggestion = suggestion + + +class Autocompletion(Gtk.Popover): + """Provide keyboard-controllable beautiful autocompletions. + + The one provided via source_view.get_completion() is not very appealing + """ + + __gtype_name__ = "Autocompletion" + + def __init__(self, text_input): + """Create an autocompletion popover. + + It will remain hidden until there is something to autocomplete. + + Parameters + ---------- + text_input : Gtk.SourceView | Gtk.TextView + The widget that contains the text that should be autocompleted + """ + super().__init__( + # Don't switch the focus to the popover when it shows + modal=False, + # Always show the popover below the cursor, don't move it to a different + # position based on the location within the window + constrain_to=Gtk.PopoverConstraint.NONE, + ) + + self.debounce_timeout = 100 + + self.text_input = text_input + + self.scrolled_window = Gtk.ScrolledWindow( + min_content_width=200, + max_content_height=200, + propagate_natural_width=True, + propagate_natural_height=True, + ) + self.list_box = Gtk.ListBox() + self.list_box.get_style_context().add_class("transparent") + self.scrolled_window.add(self.list_box) + + # row-activated is on-click, + # row-selected is when scrolling through it + self.list_box.connect( + "row-activated", + self._on_suggestion_clicked, + ) + + self.add(self.scrolled_window) + + self.get_style_context().add_class("autocompletion") + + self.set_position(Gtk.PositionType.BOTTOM) + + text_input.connect("key-press-event", self.navigate) + + # add some delay, so that pressing the button in the completion works before + # the popover is hidden due to focus-out-event + text_input.connect("focus-out-event", self.on_text_input_unfocus) + + text_input.get_buffer().connect("changed", self.update) + + self.set_position(Gtk.PositionType.BOTTOM) + + self.visible = False + + self.show_all() + self.popdown() # hidden by default. this needs to happen after show_all! + + def on_text_input_unfocus(self, *_): + """The code editor was unfocused.""" + GLib.timeout_add(100, self.popdown) + # "(input-remapper-gtk:97611): Gtk-WARNING **: 16:33:56.464: GtkTextView - + # did not receive focus-out-event. If you connect a handler to this signal, + # it must return FALSE so the text view gets the event as well" + return False + + def navigate(self, _, event): + """Using the keyboard to select an autocompletion suggestion.""" + if not self.visible: + return + + if event.keyval == Gdk.KEY_Escape: + self.popdown() + return + + selected_row = self.list_box.get_selected_row() + + if event.keyval not in [Gdk.KEY_Down, Gdk.KEY_Up, Gdk.KEY_Return]: + # not one of the keys that controls the autocompletion. Deselect + # the row but keep it open + self.list_box.select_row(None) + return + + if event.keyval == Gdk.KEY_Return: + if selected_row is None: + # nothing selected, forward the event to the text editor + return + + # a row is selected and should be used for autocompletion + self.list_box.emit("row-activated", selected_row) + return Gdk.EVENT_STOP + + num_rows = len(self.list_box.get_children()) + + if selected_row is None: + # select the first row + if event.keyval == Gdk.KEY_Down: + new_selected_row = self.list_box.get_row_at_index(0) + + if event.keyval == Gdk.KEY_Up: + new_selected_row = self.list_box.get_row_at_index(num_rows - 1) + else: + # select the next row + selected_index = selected_row.get_index() + new_index = selected_index + + if event.keyval == Gdk.KEY_Down: + new_index += 1 + + if event.keyval == Gdk.KEY_Up: + new_index -= 1 + + if new_index < 0: + new_index = num_rows - 1 + + if new_index > num_rows - 1: + new_index = 0 + + new_selected_row = self.list_box.get_row_at_index(new_index) + + self.list_box.select_row(new_selected_row) + + self._scroll_to_row(new_selected_row) + + # don't change editor contents + return Gdk.EVENT_STOP + + def _scroll_to_row(self, row): + """Scroll up or down so that the row is visible.""" + # unfortunately, it seems that without focusing the row it won't happen + # automatically (or whatever the reason for this is, just a wild guess) + # (the focus should not leave the code editor, so that continuing + # to write code is possible), so here is a custom solution. + row_height = row.get_allocation().height + + if row: + y_offset = row.translate_coordinates(self.list_box, 0, 0)[1] + height = self.scrolled_window.get_max_content_height() + current_y_scroll = self.scrolled_window.get_vadjustment().get_value() + + vadjustment = self.scrolled_window.get_vadjustment() + + if y_offset > current_y_scroll + (height - row_height): + vadjustment.set_value(y_offset - (height - row_height)) + + if y_offset < current_y_scroll: + # scroll up because the element is not visible anymore + vadjustment.set_value(y_offset) + + def _get_text_iter_at_cursor(self): + """Get Gtk.TextIter at the current text cursor location.""" + cursor = self.text_input.get_cursor_locations()[0] + return self.text_input.get_iter_at_location(cursor.x, cursor.y)[1] + + def popup(self): + self.visible = True + super().popup() + + def popdown(self): + self.visible = False + super().popdown() + + @debounce + def update(self, *_): + """Find new autocompletion suggestions and display them. Hide if none.""" + if not self.text_input.is_focus(): + self.popdown() + return + + self.list_box.forall(self.list_box.remove) + + # move the autocompletion to the text cursor + cursor = self.text_input.get_cursor_locations()[0] + # convert it to window coords, because the cursor values will be very large + # when the TextView is in a scrolled down ScrolledWindow. + window_coords = self.text_input.buffer_to_window_coords( + Gtk.TextWindowType.TEXT, cursor.x, cursor.y + ) + cursor.x = window_coords.window_x + cursor.y = window_coords.window_y + cursor.y += 12 + + if self.text_input.get_show_line_numbers(): + cursor.x += 25 + + self.set_pointing_to(cursor) + + text_iter = self._get_text_iter_at_cursor() + suggested_names = propose_function_names(text_iter) + suggested_names += propose_symbols(text_iter) + + if len(suggested_names) == 0: + self.popdown() + return + + self.popup() # ffs was this hard to find + + # add visible autocompletion entries + for suggestion, display_name in suggested_names: + label = SuggestionLabel(display_name, suggestion) + self.list_box.insert(label, -1) + label.show_all() + + def _on_suggestion_clicked(self, _, selected_row): + """An autocompletion suggestion was selected and should be inserted.""" + selected_label = selected_row.get_children()[0] + suggestion = selected_label.suggestion + buffer = self.text_input.get_buffer() + + # make sure to replace the complete unfinished word. Look to the right and + # remove whatever there is + cursor_iter = self._get_text_iter_at_cursor() + right = buffer.get_text(cursor_iter, buffer.get_end_iter(), True) + match = re.match(r"^(\w+)", right) + right = match[1] if match else "" + Gtk.TextView.do_delete_from_cursor( + self.text_input, Gtk.DeleteType.CHARS, len(right) + ) + + # do the same to the left + cursor_iter = self._get_text_iter_at_cursor() + left = buffer.get_text(buffer.get_start_iter(), cursor_iter, True) + match = re.match(r".*?(\w+)$", re.sub("\n", " ", left)) + left = match[1] if match else "" + Gtk.TextView.do_delete_from_cursor( + self.text_input, Gtk.DeleteType.CHARS, -len(left) + ) + + # insert the autocompletion + Gtk.TextView.do_insert_at_cursor(self.text_input, suggestion) + + self.emit("suggestion-inserted") + + +GObject.signal_new( + "suggestion-inserted", Autocompletion, GObject.SignalFlags.RUN_FIRST, None, [] +) diff --git a/inputremapper/gui/editor/editor.py b/inputremapper/gui/editor/editor.py new file mode 100644 index 00000000..f451bb25 --- /dev/null +++ b/inputremapper/gui/editor/editor.py @@ -0,0 +1,566 @@ +#!/usr/bin/python3 +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2022 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + + +"""The editor with multiline code input, recording toggle and autocompletion.""" + + +import re + +from gi.repository import Gtk, GLib, GtkSource, Gdk + +from inputremapper.gui.editor.autocompletion import Autocompletion +from inputremapper.system_mapping import system_mapping +from inputremapper.gui.custom_mapping import custom_mapping +from inputremapper.key import Key +from inputremapper.logger import logger +from inputremapper.gui.reader import reader +from inputremapper.gui.utils import CTX_KEYCODE, CTX_WARNING + + +class SelectionLabel(Gtk.ListBoxRow): + """One label per mapping in the preset. + + This wrapper serves as a storage for the information the inherited label represents. + """ + + __gtype_name__ = "SelectionLabel" + + def __init__(self): + super().__init__() + self.key = None + self.symbol = "" + + label = Gtk.Label() + + # Make the child label widget break lines, important for + # long combinations + label.set_line_wrap(True) + label.set_line_wrap_mode(2) + label.set_justify(Gtk.Justification.CENTER) + + self.label = label + self.add(label) + + self.show_all() + + def set_key(self, key): + """Set the key this button represents + + Parameters + ---------- + key : Key + """ + self.key = key + if key: + self.label.set_label(key.beautify()) + else: + self.label.set_label("new entry") + + def get_key(self): + return self.key + + def set_label(self, label): + return self.label.set_label(label) + + def get_label(self): + return self.label.get_label() + + def __str__(self): + return f"SelectionLabel({str(self.key)})" + + def __repr__(self): + return self.__str__() + + +def ensure_everything_saved(func): + """Make sure the editor has written its changes to custom_mapping and save.""" + + def wrapped(self, *args, **kwargs): + if self.user_interface.preset_name: + self.gather_changes_and_save() + + return func(self, *args, **kwargs) + + return wrapped + + +SET_KEY_FIRST = "Set the key first" + + +class Editor: + """Maintains the widgets of the editor.""" + + def __init__(self, user_interface): + self.user_interface = user_interface + + self.autocompletion = None + + self._setup_source_view() + self._setup_recording_toggle() + + self.window = self.get("window") + self.timeout = GLib.timeout_add(100, self.check_add_new_key) + self.active_selection_label = None + + selection_label_listbox = self.get("selection_label_listbox") + selection_label_listbox.connect("row-selected", self.on_mapping_selected) + + self.device = user_interface.group + + # keys were not pressed yet + self._input_has_arrived = False + + toggle = self.get_recording_toggle() + toggle.connect("focus-out-event", self._reset_keycode_consumption) + toggle.connect("focus-out-event", lambda *_: toggle.set_active(False)) + toggle.connect("focus-in-event", self._on_recording_toggle_focus) + # Don't leave the input when using arrow keys or tab. wait for the + # window to consume the keycode from the reader. I.e. a tab input should + # be recorded, instead of causing the recording to stop. + toggle.connect("key-press-event", lambda *args: Gdk.EVENT_STOP) + + text_input = self.get_text_input() + text_input.connect("focus-out-event", self.on_text_input_unfocus) + + delete_button = self.get_delete_button() + delete_button.connect("clicked", self._on_delete_button_clicked) + + @ensure_everything_saved + def on_text_input_unfocus(self, *_): + """When unfocusing the text it saves. + + Input Remapper doesn't save the editor on change, because that would cause + an incredible amount of logs for every single input. The custom_mapping would + need to be changed, which causes two logs, then it has to be saved + to disk which is another two log messages. So every time a single character + is typed it writes 4 lines. + + Instead, it will save the preset when it is really needed, i.e. when a button + that requires a saved preset is pressed. For this there exists the + @ensure_everything_saved decorator. + + To avoid maybe forgetting to add this decorator somewhere, it will also save + when unfocusing the text input. + + If the scroll wheel is used to interact with gtk widgets it won't unfocus, + so this focus-out handler is not the solution to everything as well. + + One could debounce saving on text-change to avoid those logs, but that just + sounds like a huge source of race conditions and is also hard to test. + """ + pass + + def clear(self): + """Clear all inputs, labels, etc. Reset the state. + + This is really important to do before loading a different preset. + Otherwise the inputs will be read and then saved into the next preset. + """ + if self.active_selection_label: + self.set_key(None) + + self.set_symbol_input_text("") + self.disable_symbol_input() + self._reset_keycode_consumption() + + selection_label_listbox = self.get("selection_label_listbox") + selection_label_listbox.forall(selection_label_listbox.remove) + self.add_empty() + + selection_label_listbox.select_row(selection_label_listbox.get_children()[0]) + + def _setup_recording_toggle(self): + """Prepare the toggle button for recording key inputs.""" + toggle = self.get("key_recording_toggle") + toggle.connect( + "focus-out-event", + self._show_change_key, + ) + toggle.connect( + "focus-in-event", + self._show_press_key, + ) + toggle.connect( + "clicked", + lambda _: ( + self._show_press_key() + if toggle.get_active() + else self._show_change_key() + ), + ) + + def _show_press_key(self, *_): + """Show user friendly instructions.""" + self.get("key_recording_toggle").set_label("Press Key") + + def _show_change_key(self, *_): + """Show user friendly instructions.""" + self.get("key_recording_toggle").set_label("Change Key") + + def _setup_source_view(self): + """Prepare the code editor.""" + source_view = self.get("code_editor") + + # without this the wrapping ScrolledWindow acts weird when new lines are added, + # not offering enough space to the text editor so the whole thing is suddenly + # scrollable by a few pixels. + # Found this after making blind guesses with settings in glade, and then + # actually looking at the snaphot preview! In glades editor this didn have an + # effect. + source_view.set_resize_mode(Gtk.ResizeMode.IMMEDIATE) + + source_view.get_buffer().connect("changed", self.show_line_numbers_if_multiline) + + # Syntax Highlighting + # Thanks to https://github.com/wolfthefallen/py-GtkSourceCompletion-example + # language_manager = GtkSource.LanguageManager() + # fun fact: without saving LanguageManager into its own variable it doesn't work + # python = language_manager.get_language("python") + # source_view.get_buffer().set_language(python) + # TODO there are some similarities with python, but overall it's quite useless. + # commented out until there is proper highlighting for input-remappers syntax. + + autocompletion = Autocompletion(source_view) + autocompletion.set_relative_to(self.get("code_editor_container")) + autocompletion.connect("suggestion-inserted", self.gather_changes_and_save) + self.autocompletion = autocompletion + + def show_line_numbers_if_multiline(self, *_): + """Show line numbers if a macro is being edited.""" + code_editor = self.get("code_editor") + symbol = self.get_symbol_input_text() or "" + + if "\n" in symbol: + code_editor.set_show_line_numbers(True) + code_editor.set_monospace(True) + code_editor.get_style_context().add_class("multiline") + else: + code_editor.set_show_line_numbers(False) + code_editor.set_monospace(False) + code_editor.get_style_context().remove_class("multiline") + + def get_delete_button(self): + return self.get("delete-mapping") + + def check_add_new_key(self): + """If needed, add a new empty mapping to the list for the user to configure.""" + selection_label_listbox = self.get("selection_label_listbox") + + selection_label_listbox = selection_label_listbox.get_children() + + for selection_label in selection_label_listbox: + if selection_label.get_key() is None: + # unfinished row found + break + else: + self.add_empty() + + return True + + def disable_symbol_input(self): + """Display help information and dont allow entering a symbol yet. + + Without this, maybe a user enters a symbol or writes a macro, switches + presets accidentally before configuring the key and then it's gone. It can + only be saved to the preset if a key is configured. This avoids that pitfall. + """ + text_input = self.get_text_input() + text_input.set_sensitive(False) + text_input.set_opacity(0.5) + + if self.get_symbol_input_text() == "": + # don't overwrite user input + self.set_symbol_input_text(SET_KEY_FIRST) + + def enable_symbol_input(self): + """Don't display help information anymore and allow changing the symbol.""" + text_input = self.get_text_input() + text_input.set_sensitive(True) + text_input.set_opacity(1) + + buffer = text_input.get_buffer() + symbol = buffer.get_text(buffer.get_start_iter(), buffer.get_end_iter(), True) + if symbol == SET_KEY_FIRST: + # don't overwrite user input + self.set_symbol_input_text("") + + @ensure_everything_saved + def on_mapping_selected(self, _=None, selection_label=None): + """One of the buttons in the left "key" column was clicked. + + Load the information from that mapping entry into the editor. + """ + self.active_selection_label = selection_label + + if selection_label is None: + return + + key = selection_label.key + self.set_key(key) + + if key is None: + self.set_symbol_input_text("") + self.disable_symbol_input() + # symbol input disabled until a key is configured + else: + self.set_symbol_input_text(custom_mapping.get_symbol(key)) + self.enable_symbol_input() + + self.get("window").set_focus(self.get_text_input()) + + def add_empty(self): + """Add one empty row for a single mapped key.""" + selection_label_listbox = self.get("selection_label_listbox") + mapping_selection = SelectionLabel() + mapping_selection.set_label("new entry") + mapping_selection.show_all() + selection_label_listbox.insert(mapping_selection, -1) + + @ensure_everything_saved + def load_custom_mapping(self): + """Display the entries in custom_mapping.""" + self.set_symbol_input_text("") + + selection_label_listbox = self.get("selection_label_listbox") + + selection_label_listbox.forall(selection_label_listbox.remove) + + for key, output in custom_mapping: + selection_label = SelectionLabel() + selection_label.set_key(key) + selection_label_listbox.insert(selection_label, -1) + + self.check_add_new_key() + + # select the first entry + selection_labels = selection_label_listbox.get_children() + + if len(selection_labels) == 0: + self.add_empty() + selection_labels = selection_label_listbox.get_children() + + selection_label_listbox.select_row(selection_labels[0]) + + def get_recording_toggle(self): + return self.get("key_recording_toggle") + + def get_text_input(self): + return self.get("code_editor") + + def get_key(self): + """Get the Key object from the left column. + + Or None if no code is mapped on this row. + """ + if self.active_selection_label is None: + return None + + return self.active_selection_label.key + + def set_symbol_input_text(self, symbol): + self.get("code_editor").get_buffer().set_text(symbol or "") + # move cursor location to the beginning, like any code editor does + Gtk.TextView.do_move_cursor( + self.get("code_editor"), + Gtk.MovementStep.BUFFER_ENDS, + -1, + False, + ) + + def get_symbol_input_text(self): + """Get the assigned symbol from the text input. + + This might not be stored in custom_mapping yet, and might therefore also not + be part of the preset json file yet. + + If there is no symbol, this returns None. This is important for some other + logic down the road in custom_mapping or something. + """ + buffer = self.get("code_editor").get_buffer() + symbol = buffer.get_text(buffer.get_start_iter(), buffer.get_end_iter(), True) + + if symbol == SET_KEY_FIRST: + # not configured yet + return "" + + return symbol + + def set_key(self, key): + """Show what the user is currently pressing in the user interface.""" + self.active_selection_label.set_key(key) + + def get(self, name): + """Get a widget from the window""" + return self.user_interface.builder.get_object(name) + + def _on_recording_toggle_focus(self, *_): + """Refresh useful usage information.""" + self._reset_keycode_consumption() + reader.clear() + self.user_interface.can_modify_mapping() + + def _on_delete_button_clicked(self, *_): + """Destroy the row and remove it from the config.""" + accept = Gtk.ResponseType.ACCEPT + if ( + len(self.get_symbol_input_text()) > 0 + and self._show_confirm_delete() != accept + ): + return + + key = self.get_key() + if key is not None: + custom_mapping.clear(key) + + # make sure there is no outdated information lying around in memory + self.set_key(None) + + self.load_custom_mapping() + + def _show_confirm_delete(self): + """Blocks until the user decided about an action.""" + confirm_delete = self.get("confirm-delete") + + text = f"Are you sure to delete this mapping?" + self.get("confirm-delete-label").set_text(text) + + confirm_delete.show() + response = confirm_delete.run() + confirm_delete.hide() + return response + + def gather_changes_and_save(self, *_): + """Look into the ui if new changes should be written, and save the preset.""" + # correct case + symbol = self.get_symbol_input_text() + + if not symbol: + return + + correct_case = system_mapping.correct_case(symbol) + if symbol != correct_case: + self.get_text_input().get_buffer().set_text(correct_case) + + # make sure the custom_mapping is up to date + key = self.get_key() + if correct_case is not None and key is not None: + custom_mapping.change(key, correct_case) + + # save to disk if required + if custom_mapping.has_unsaved_changes(): + self.user_interface.save_preset() + + def is_waiting_for_input(self): + """Check if the user is interacting with the ToggleButton for key recording.""" + return self.get_recording_toggle().get_active() + + def consume_newest_keycode(self, key): + """To capture events from keyboards, mice and gamepads. + + Parameters + ---------- + key : Key or None + """ + self._switch_focus_if_complete() + + if key is None: + return + + if not self.is_waiting_for_input(): + return + + if not isinstance(key, Key): + raise TypeError("Expected new_key to be a Key object") + + # keycode is already set by some other row + existing = custom_mapping.get_symbol(key) + if existing is not None: + existing = re.sub(r"\s", "", existing) + msg = f'"{key.beautify()}" already mapped to "{existing}"' + logger.info("%s %s", key, msg) + self.user_interface.show_status(CTX_KEYCODE, msg) + return True + + if key.is_problematic(): + self.user_interface.show_status( + CTX_WARNING, + "ctrl, alt and shift may not combine properly", + "Your system might reinterpret combinations " + + "with those after they are injected, and by doing so " + + "break them.", + ) + + # the newest_keycode is populated since the ui regularly polls it + # in order to display it in the status bar. + previous_key = self.get_key() + + # it might end up being a key combination, wait for more + self._input_has_arrived = True + + # keycode didn't change, do nothing + if key == previous_key: + logger.debug("%s didn't change", previous_key) + return + + self.set_key(key) + + symbol = self.get_symbol_input_text() + + # the symbol is empty and therefore the mapping is not complete + if not symbol: + return + + # else, the keycode has changed, the symbol is set, all good + custom_mapping.change(new_key=key, symbol=symbol, previous_key=previous_key) + + def _switch_focus_if_complete(self): + """If keys are released, it will switch to the text_input. + + States: + 1. not doing anything, waiting for the user to start using it + 2. user focuses it, no keys pressed + 3. user presses keys + 4. user releases keys. no keys are pressed, just like in step 2, but this time + the focus needs to switch. + """ + if not self.is_waiting_for_input(): + self._reset_keycode_consumption() + return + + all_keys_released = reader.get_unreleased_keys() is None + if all_keys_released and self._input_has_arrived and self.get_key(): + # A key was pressed and then released. + # Switch to the symbol. idle_add this so that the + # keycode event won't write into the symbol input as well. + window = self.user_interface.window + self.enable_symbol_input() + GLib.idle_add(lambda: window.set_focus(self.get_text_input())) + + if not all_keys_released: + # currently the user is using the widget, and certain keys have already + # reached it. + self._input_has_arrived = True + return + + self._reset_keycode_consumption() + + def _reset_keycode_consumption(self, *_): + self._input_has_arrived = False diff --git a/inputremapper/gui/reader.py b/inputremapper/gui/reader.py index dd0240e3..3a669af2 100644 --- a/inputremapper/gui/reader.py +++ b/inputremapper/gui/reader.py @@ -61,7 +61,7 @@ class Reader: self.previous_result = None self._unreleased = {} self._debounce_remove = {} - self._devices_updated = False + self._groups_updated = False self._cleared_at = 0 self.group = None @@ -74,13 +74,13 @@ class Reader: self._results = Pipe(f"/tmp/input-remapper-{USER}/results") self._commands = Pipe(f"/tmp/input-remapper-{USER}/commands") - def are_new_devices_available(self): + def are_new_groups_available(self): """Check if groups contains new devices. The ui should then update its list. """ - outdated = self._devices_updated - self._devices_updated = False # assume the ui will react accordingly + outdated = self._groups_updated + self._groups_updated = False # assume the ui will react accordingly return outdated def _get_event(self, message): @@ -92,7 +92,7 @@ class Reader: if message_body != groups.dumps(): groups.loads(message_body) logger.debug("Received %d devices", len(groups)) - self._devices_updated = True + self._groups_updated = True return None if message_type == "event": diff --git a/inputremapper/gui/row.py b/inputremapper/gui/row.py deleted file mode 100644 index 7dccaf0f..00000000 --- a/inputremapper/gui/row.py +++ /dev/null @@ -1,409 +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 . - - -"""A single, configurable key mapping.""" - - -import evdev -from gi.repository import Gtk, GLib, Gdk - -from inputremapper.system_mapping import system_mapping -from inputremapper.gui.custom_mapping import custom_mapping -from inputremapper.logger import logger -from inputremapper.key import Key -from inputremapper.gui.reader import reader - - -CTX_KEYCODE = 2 - - -store = Gtk.ListStore(str) - - -def populate_store(): - """Fill the dropdown for key suggestions with values.""" - for name in system_mapping.list_names(): - store.append([name]) - - extra = [ - "mouse(up, 1)", - "mouse(down, 1)", - "mouse(left, 1)", - "mouse(right, 1)", - "wheel(up, 1)", - "wheel(down, 1)", - "wheel(left, 1)", - "wheel(right, 1)", - ] - - for key in extra: - # add some more keys to the dropdown list - store.append([key]) - - -populate_store() - - -def to_string(key): - """A nice to show description of the pressed key.""" - if isinstance(key, Key): - return " + ".join([to_string(sub_key) for sub_key in key]) - - if isinstance(key[0], tuple): - raise Exception("deprecated stuff") - - ev_type, code, value = key - - if ev_type not in evdev.ecodes.bytype: - logger.error("Unknown key type for %s", key) - return str(code) - - if code not in evdev.ecodes.bytype[ev_type]: - logger.error("Unknown key code for %s", key) - return str(code) - - key_name = None - - # first try to find the name in xmodmap to not display wrong - # names due to the keyboard layout - if ev_type == evdev.ecodes.EV_KEY: - key_name = system_mapping.get_name(code) - - if key_name is None: - # if no result, look in the linux key constants. On a german - # keyboard for example z and y are switched, which will therefore - # cause the wrong letter to be displayed. - key_name = evdev.ecodes.bytype[ev_type][code] - if isinstance(key_name, list): - key_name = key_name[0] - - if ev_type != evdev.ecodes.EV_KEY: - direction = { - # D-Pad - (evdev.ecodes.ABS_HAT0X, -1): "Left", - (evdev.ecodes.ABS_HAT0X, 1): "Right", - (evdev.ecodes.ABS_HAT0Y, -1): "Up", - (evdev.ecodes.ABS_HAT0Y, 1): "Down", - (evdev.ecodes.ABS_HAT1X, -1): "Left", - (evdev.ecodes.ABS_HAT1X, 1): "Right", - (evdev.ecodes.ABS_HAT1Y, -1): "Up", - (evdev.ecodes.ABS_HAT1Y, 1): "Down", - (evdev.ecodes.ABS_HAT2X, -1): "Left", - (evdev.ecodes.ABS_HAT2X, 1): "Right", - (evdev.ecodes.ABS_HAT2Y, -1): "Up", - (evdev.ecodes.ABS_HAT2Y, 1): "Down", - # joystick - (evdev.ecodes.ABS_X, 1): "Right", - (evdev.ecodes.ABS_X, -1): "Left", - (evdev.ecodes.ABS_Y, 1): "Down", - (evdev.ecodes.ABS_Y, -1): "Up", - (evdev.ecodes.ABS_RX, 1): "Right", - (evdev.ecodes.ABS_RX, -1): "Left", - (evdev.ecodes.ABS_RY, 1): "Down", - (evdev.ecodes.ABS_RY, -1): "Up", - # wheel - (evdev.ecodes.REL_WHEEL, -1): "Down", - (evdev.ecodes.REL_WHEEL, 1): "Up", - (evdev.ecodes.REL_HWHEEL, -1): "Left", - (evdev.ecodes.REL_HWHEEL, 1): "Right", - }.get((code, value)) - if direction is not None: - key_name += f" {direction}" - - key_name = key_name.replace("ABS_Z", "Trigger Left") - key_name = key_name.replace("ABS_RZ", "Trigger Right") - - key_name = key_name.replace("ABS_HAT0X", "DPad") - key_name = key_name.replace("ABS_HAT0Y", "DPad") - key_name = key_name.replace("ABS_HAT1X", "DPad 2") - key_name = key_name.replace("ABS_HAT1Y", "DPad 2") - key_name = key_name.replace("ABS_HAT2X", "DPad 3") - key_name = key_name.replace("ABS_HAT2Y", "DPad 3") - - key_name = key_name.replace("ABS_X", "Joystick") - key_name = key_name.replace("ABS_Y", "Joystick") - key_name = key_name.replace("ABS_RX", "Joystick 2") - key_name = key_name.replace("ABS_RY", "Joystick 2") - - key_name = key_name.replace("BTN_", "Button ") - key_name = key_name.replace("KEY_", "") - - key_name = key_name.replace("REL_", "") - key_name = key_name.replace("HWHEEL", "Wheel") - key_name = key_name.replace("WHEEL", "Wheel") - - key_name = key_name.replace("_", " ") - key_name = key_name.replace(" ", " ") - - return key_name - - -IDLE = 0 -HOLDING = 1 - - -class Row(Gtk.ListBoxRow): - """A single, configurable key mapping.""" - - __gtype_name__ = "ListBoxRow" - - def __init__(self, delete_callback, window, key=None, symbol=None): - """Construct a row widget. - - Parameters - ---------- - key : Key - """ - if key is not None and not isinstance(key, Key): - raise TypeError("Expected key to be a Key object") - - super().__init__() - self.device = window.group - self.window = window - self.delete_callback = delete_callback - - self.symbol_input = None - self.keycode_input = None - - self.key = key - - self.put_together(symbol) - - self._state = IDLE - - def refresh_state(self): - """Refresh the state. - - The state is needed to switch focus when no keys are held anymore, - but only if the row has been in the HOLDING state before. - """ - old_state = self._state - - if not self.keycode_input.is_focus(): - self._state = IDLE - return - - unreleased_keys = reader.get_unreleased_keys() - if unreleased_keys is None and old_state == HOLDING and self.key: - # A key was pressed and then released. - # Switch to the symbol. idle_add this so that the - # keycode event won't write into the symbol input as well. - window = self.window.window - GLib.idle_add(lambda: window.set_focus(self.symbol_input)) - - if unreleased_keys is not None: - self._state = HOLDING - return - - self._state = IDLE - - def get_key(self): - """Get the Key object from the left column. - - Or None if no code is mapped on this row. - """ - return self.key - - def get_symbol(self): - """Get the assigned symbol from the middle column.""" - symbol = self.symbol_input.get_text() - return symbol if symbol else None - - def set_new_key(self, new_key): - """Check if a keycode has been pressed and if so, display it. - - Parameters - ---------- - new_key : Key - """ - if new_key is not None and not isinstance(new_key, Key): - raise TypeError("Expected new_key to be a Key object") - - # the newest_keycode is populated since the ui regularly polls it - # in order to display it in the status bar. - previous_key = self.get_key() - - # no input - if new_key is None: - return - - # it might end up being a key combination - self._state = HOLDING - - # keycode didn't change, do nothing - if new_key == previous_key: - return - - # keycode is already set by some other row - existing = custom_mapping.get_symbol(new_key) - if existing is not None: - msg = f'"{to_string(new_key)}" already mapped to "{existing}"' - logger.info(msg) - self.window.show_status(CTX_KEYCODE, msg) - return - - # it's legal to display the keycode - - # always ask for get_child to set the label, otherwise line breaking - # has to be configured again. - self.set_keycode_input_label(to_string(new_key)) - - self.key = new_key - - symbol = self.get_symbol() - - # the symbol is empty and therefore the mapping is not complete - if symbol is None: - return - - # else, the keycode has changed, the symbol is set, all good - custom_mapping.change(new_key=new_key, symbol=symbol, previous_key=previous_key) - - def on_symbol_input_change(self, _): - """When the output symbol for that keycode is typed in.""" - key = self.get_key() - symbol = self.get_symbol() - - if symbol is None: - return - - if key is not None: - custom_mapping.change(new_key=key, symbol=symbol, previous_key=None) - - def match(self, _, key, tree_iter): - """Search the avilable names.""" - value = store.get_value(tree_iter, 0) - return key in value.lower() - - def show_click_here(self): - """Show 'click here' on the keycode input button.""" - if self.get_key() is not None: - return - - self.set_keycode_input_label("click here") - self.keycode_input.set_opacity(0.3) - - def show_press_key(self): - """Show 'press key' on the keycode input button.""" - if self.get_key() is not None: - return - - self.set_keycode_input_label("press key") - self.keycode_input.set_opacity(1) - - def on_keycode_input_focus(self, *_): - """Refresh useful usage information.""" - reader.clear() - self.show_press_key() - self.window.can_modify_mapping() - - def on_keycode_input_unfocus(self, *_): - """Refresh useful usage information and set some state stuff.""" - self.show_click_here() - self.keycode_input.set_active(False) - self._state = IDLE - self.window.save_preset() - - def set_keycode_input_label(self, label): - """Set the label of the keycode input.""" - self.keycode_input.set_label(label) - # make the child label widget break lines, important for - # long combinations - label = self.keycode_input.get_child() - label.set_line_wrap(True) - label.set_line_wrap_mode(2) - label.set_max_width_chars(13) - label.set_justify(Gtk.Justification.CENTER) - self.keycode_input.set_opacity(1) - - def on_symbol_input_unfocus(self, symbol_input, _): - """Save the preset and correct the input casing.""" - symbol = symbol_input.get_text() - correct_case = system_mapping.correct_case(symbol) - if symbol != correct_case: - symbol_input.set_text(correct_case) - self.window.save_preset() - - def put_together(self, symbol): - """Create all child GTK widgets and connect their signals.""" - delete_button = Gtk.EventBox() - delete_button.add( - Gtk.Image.new_from_icon_name("window-close", Gtk.IconSize.BUTTON) - ) - delete_button.connect("button-press-event", self.on_delete_button_clicked) - delete_button.set_size_request(50, -1) - - keycode_input = Gtk.ToggleButton() - self.keycode_input = keycode_input - keycode_input.set_size_request(140, -1) - - if self.key is not None: - self.set_keycode_input_label(to_string(self.key)) - else: - self.show_click_here() - - # make the togglebutton go back to its normal state when doing - # something else in the UI - keycode_input.connect("focus-in-event", self.on_keycode_input_focus) - keycode_input.connect("focus-out-event", self.on_keycode_input_unfocus) - # don't leave the input when using arrow keys or tab. wait for the - # window to consume the keycode from the reader - keycode_input.connect("key-press-event", lambda *args: Gdk.EVENT_STOP) - - symbol_input = Gtk.Entry() - self.symbol_input = symbol_input - symbol_input.set_alignment(0.5) - symbol_input.set_width_chars(4) - symbol_input.set_has_frame(False) - completion = Gtk.EntryCompletion() - completion.set_model(store) - completion.set_text_column(0) - completion.set_match_func(self.match) - symbol_input.set_completion(completion) - - if symbol is not None: - symbol_input.set_text(symbol) - - symbol_input.connect("changed", self.on_symbol_input_change) - symbol_input.connect("focus-out-event", self.on_symbol_input_unfocus) - - box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL) - box.set_homogeneous(False) - box.set_spacing(0) - box.pack_start(keycode_input, expand=False, fill=True, padding=0) - box.pack_start(symbol_input, expand=True, fill=True, padding=0) - box.pack_start(delete_button, expand=False, fill=True, padding=0) - box.show_all() - box.get_style_context().add_class("row-box") - - self.add(box) - self.show_all() - - def on_delete_button_clicked(self, *_): - """Destroy the row and remove it from the config.""" - key = self.get_key() - if key is not None: - custom_mapping.clear(key) - - self.symbol_input.set_text("") - self.set_keycode_input_label("") - self.key = None - self.delete_callback(self) diff --git a/inputremapper/gui/window.py b/inputremapper/gui/user_interface.py similarity index 77% rename from inputremapper/gui/window.py rename to inputremapper/gui/user_interface.py index 2bebe666..21605a1c 100644 --- a/inputremapper/gui/window.py +++ b/inputremapper/gui/user_interface.py @@ -24,14 +24,16 @@ import math import os +import re import sys -from gi.repository import Gtk, Gdk, GLib +from gi.repository import Gtk, GtkSource, Gdk, GLib, GObject from inputremapper.data import get_data_path from inputremapper.paths import get_config_path from inputremapper.system_mapping import system_mapping from inputremapper.gui.custom_mapping import custom_mapping +from inputremapper.gui.utils import HandlerDisabled from inputremapper.presets import ( find_newest_preset, get_presets, @@ -49,7 +51,7 @@ from inputremapper.groups import ( TOUCHPAD, MOUSE, ) -from inputremapper.gui.row import Row, to_string +from inputremapper.gui.editor.editor import Editor from inputremapper.key import Key from inputremapper.gui.reader import reader from inputremapper.gui.helper import is_helper_running @@ -57,19 +59,21 @@ from inputremapper.injection.injector import RUNNING, FAILED, NO_GRAB from inputremapper.daemon import Daemon from inputremapper.config import config from inputremapper.injection.macros.parse import is_this_a_macro, parse +from inputremapper.gui.utils import ( + CTX_ERROR, + CTX_MAPPING, + CTX_APPLY, + CTX_WARNING, + gtk_iteration, +) -def gtk_iteration(): - """Iterate while events are pending.""" - while Gtk.events_pending(): - Gtk.main_iteration() - +# TODO add to .deb and AUR dependencies +# https://cjenkins.wordpress.com/2012/05/08/use-gtksourceview-widget-in-glade/ +GObject.type_register(GtkSource.View) +# GtkSource.View() also works: +# https://stackoverflow.com/questions/60126579/gtk-builder-error-quark-invalid-object-type-webkitwebview -CTX_SAVE = 0 -CTX_APPLY = 1 -CTX_ERROR = 3 -CTX_WARNING = 4 -CTX_MAPPING = 5 CONTINUE = True GO_BACK = False @@ -87,55 +91,50 @@ ICON_NAMES = { ICON_PRIORITIES = [GRAPHICS_TABLET, TOUCHPAD, GAMEPAD, MOUSE, KEYBOARD, UNKNOWN] -def with_group(func): +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(window, *args): - if window.group is None: + def wrapped(self, *args, **kwargs): + if self.group is None: return True # work with timeout_add - return func(window, *args) + return func(self, *args, **kwargs) return wrapped -def with_preset_name(func): +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(window, *args): - if window.preset_name is None or window.group is None: + def wrapped(self, *args, **kwargs): + if self.preset_name is None or self.group is None: return True # work with timeout_add - return func(window, *args) + return func(self, *args, **kwargs) return wrapped -class HandlerDisabled: - """Safely modify a widget without causing handlers to be called. - - Use in a with statement. - """ +def on_close_about(about, _): + """Hide the about dialog without destroying it.""" + about.hide() + return True - def __init__(self, widget, handler): - self.widget = widget - self.handler = handler - def __enter__(self): - self.widget.handler_block_by_func(self.handler) +def ensure_everything_saved(func): + """Make sure the editor has written its changes to custom_mapping and save.""" - def __exit__(self, *_): - self.widget.handler_unblock_by_func(self.handler) + def wrapped(self, *args, **kwargs): + if self.preset_name: + self.editor.gather_changes_and_save() + return func(self, *args, **kwargs) -def on_close_about(about, _): - """Hide the about dialog without destroying it.""" - about.hide() - return True + return wrapped -class Window: - """User Interface.""" +class UserInterface: + """The key mapper gtk window.""" def __init__(self): self.dbus = None @@ -161,6 +160,8 @@ class Window: builder.connect_signals(self) self.builder = builder + self.editor = Editor(self) + # set up the device selection # https://python-gtk-3-tutorial.readthedocs.io/en/latest/treeview.html#the-view combobox = self.get("device_selection") @@ -183,7 +184,8 @@ class Window: 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}" + f"input-remapper {VERSION} {COMMIT_HASH[:7]}" + f"\npython-evdev {EVDEV_VERSION}" if EVDEV_VERSION else "" ) @@ -221,7 +223,6 @@ class Window: def setup_timeouts(self): """Setup all GLib timeouts.""" self.timeouts = [ - GLib.timeout_add(100, self.check_add_row), GLib.timeout_add(1000 / 30, self.consume_newest_keycode), ] @@ -250,13 +251,13 @@ class Window: self.confirm_delete.hide() return response - def key_press(self, _, event): + def on_key_press(self, _, event): """To execute shortcuts. This has nothing to do with the keycode reader. """ - _, focused = self.get_focused_row() - if isinstance(focused, Gtk.ToggleButton): + if self.editor.is_waiting_for_input(): + # don't perform shortcuts while keys are being recorded return gdk_keycode = event.get_keyval()[1] @@ -275,7 +276,7 @@ class Window: if gdk_keycode == Gdk.KEY_Delete: self.on_restore_defaults_clicked() - def key_release(self, _, event): + def on_key_release(self, _, event): """To execute shortcuts. This has nothing to do with the keycode reader. @@ -316,10 +317,10 @@ class Window: """Get a widget from the window""" return self.builder.get_object(name) + @ensure_everything_saved def on_close(self, *_): """Safely close the application.""" logger.debug("Closing window") - self.save_preset() self.window.hide() for timeout in self.timeouts: GLib.source_remove(timeout) @@ -327,46 +328,16 @@ class Window: reader.terminate() Gtk.main_quit() - def check_add_row(self): - """Ensure that one empty row is available at all times.""" - rows = self.get("key_list").get_children() - - # verify that all mappings are displayed. - # One of them is possibly the empty row - num_rows = len(rows) - num_maps = len(custom_mapping) - if num_rows < num_maps or num_rows > num_maps + 1: - logger.error( - "custom_mapping contains %d rows, but %d are displayed", - len(custom_mapping), - num_rows, - ) - logger.spam("Mapping %s", list(custom_mapping)) - logger.spam( - "Rows %s", [(row.get_key(), row.get_symbol()) for row in rows] - ) - - # iterating over that 10 times per second is a bit wasteful, - # but the old approach which involved just counting the number of - # mappings and rows didn't seem very robust. - for row in rows: - if row.get_key() is None or row.get_symbol() is None: - # unfinished row found - break - else: - self.add_empty() - - return True - + @ensure_everything_saved def select_newest_preset(self): """Find and select the newest preset (and its device).""" - device, preset = find_newest_preset() - group = groups.find(name=device) - if device is not None: - self.get("device_selection").set_active_id(group.key) + 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") @@ -385,7 +356,8 @@ class Window: self.select_newest_preset() - @with_group + @if_group_selected + @ensure_everything_saved def populate_presets(self): """Show the available presets for the selected device. @@ -410,15 +382,10 @@ class Window: for preset in presets: preset_selection.append(preset, preset) + # and select the newest one (on the top). triggers on_select_preset preset_selection.set_active(0) - def clear_mapping_table(self): - """Remove all rows from the mappings table.""" - key_list = self.get("key_list") - key_list.forall(key_list.remove) - custom_mapping.empty() - def can_modify_mapping(self, *_): """Show a message if changing the mapping is not possible.""" if self.dbus.get_state(self.group.key) != RUNNING: @@ -427,23 +394,7 @@ class Window: # because the device is in grab mode by the daemon and # therefore the original keycode inaccessible logger.info("Cannot change keycodes while injecting") - self.show_status(CTX_ERROR, 'Use "Restore Defaults" to stop before editing') - - def get_focused_row(self): - """Get the Row and its child that is currently in focus.""" - focused = self.window.get_focus() - if focused is None: - return None, None - - box = focused.get_parent() - if box is None: - return None, None - - row = box.get_parent() - if not isinstance(row, Row): - return None, None - - return row, focused + self.show_status(CTX_ERROR, 'Use "Stop Injection" to stop before editing') def consume_newest_keycode(self): """To capture events from keyboards, mice and gamepads.""" @@ -456,32 +407,14 @@ class Window: # they have already been read. key = reader.read() - if reader.are_new_devices_available(): + if reader.are_new_groups_available(): self.populate_devices() - # TODO highlight if a row for that key exists or something - - # inform the currently selected row about the new keycode - row, focused = self.get_focused_row() - if key is not None: - if isinstance(focused, Gtk.ToggleButton): - row.set_new_key(key) - - if key.is_problematic() and isinstance(focused, Gtk.ToggleButton): - 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.", - ) - - if row is not None: - row.refresh_state() + self.editor.consume_newest_keycode(key) return True - @with_group + @if_group_selected def on_restore_defaults_clicked(self, *_): """Stop injecting the mapping.""" self.dbus.stop_injecting(self.group.key) @@ -519,8 +452,9 @@ class Window: if context_id == CTX_WARNING: self.get("warning_status_icon").show() - if len(message) > 55: - message = message[:52] + "..." + 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) @@ -536,10 +470,11 @@ class Window: if error is None: continue - position = to_string(key) + position = key.beautify() msg = f"Syntax error at {position}, hover for info" self.show_status(CTX_MAPPING, msg, error) + @ensure_everything_saved def on_rename_button_clicked(self, _): """Rename the preset based on the contents of the name input.""" new_name = self.get("preset_name_input").get_text() @@ -547,8 +482,6 @@ class Window: if new_name in ["", self.preset_name]: return - self.save_preset() - new_name = rename_preset(self.group.name, self.preset_name, new_name) # if the old preset was being autoloaded, change the @@ -556,24 +489,26 @@ class Window: is_autoloaded = config.is_autoloaded(self.group.key, self.preset_name) if is_autoloaded: config.set_autoload_preset(self.group.key, new_name) - # TODO always save_config in set_autoload_preset? - config.save_config() self.get("preset_name_input").set_text("") self.populate_presets() - @with_preset_name - def on_delete_preset_clicked(self, _): + @if_preset_selected + def on_delete_preset_clicked(self, *_): """Delete a preset from the file system.""" accept = Gtk.ResponseType.ACCEPT if len(custom_mapping) > 0 and self.show_confirm_delete() != accept: return - custom_mapping.changed = False + # avoid having the text of the symbol input leak into the custom_mapping again + # via a gazillion hooks, causing the preset to be saved again after deleting. + self.editor.clear() + delete_preset(self.group.name, self.preset_name) + self.populate_presets() - @with_preset_name + @if_preset_selected def on_apply_preset_clicked(self, _): """Apply a preset without saving changes.""" self.save_preset() @@ -581,14 +516,7 @@ class Window: if custom_mapping.num_saved_keys == 0: logger.error("Cannot apply empty preset file") # also helpful for first time use - if custom_mapping.changed: - self.show_status( - CTX_ERROR, - "You need to save your changes first", - "No mappings are stored in the preset .json file yet", - ) - else: - self.show_status(CTX_ERROR, "You need to add keys and save first") + self.show_status(CTX_ERROR, "You need to add keys and save first") return preset = self.preset_name @@ -636,26 +564,22 @@ class Window: key = self.group.key preset = self.preset_name config.set_autoload_preset(key, preset if active else None) - config.save_config() # 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.""" - self.save_preset() - if self.group and dropdown.get_active_id() == self.group.key: return - # selecting a device will also automatically select a different - # preset. Prevent another unsaved-changes dialog to pop up - custom_mapping.changed = False - group_key = dropdown.get_active_id() if group_key is None: return + self.editor.clear() + logger.debug('Selecting device "%s"', group_key) self.group = groups.find(key=group_key) @@ -709,19 +633,19 @@ class Window: else: self.get("apply_system_layout").set_opacity(0.4) - @with_preset_name - def on_copy_preset_clicked(self, _): + @if_preset_selected + def on_copy_preset_clicked(self, *_): """Copy the current preset and select it.""" - self.create_preset(True) + self.create_preset(copy=True) - @with_group - def on_create_preset_clicked(self, _): - """Create a new preset and select it.""" + @if_group_selected + def on_create_preset_clicked(self, *_): + """Create a new empty preset and select it.""" self.create_preset() + @ensure_everything_saved def create_preset(self, copy=False): """Create a new preset and select it.""" - self.save_preset() name = self.group.name preset = self.preset_name @@ -730,57 +654,54 @@ class Window: new_preset = get_available_preset_name(name, preset, copy) else: new_preset = get_available_preset_name(name) + self.editor.clear() custom_mapping.empty() path = self.group.get_preset_path(new_preset) custom_mapping.save(path) self.get("preset_selection").append(new_preset, new_preset) + # triggers on_select_preset self.get("preset_selection").set_active_id(new_preset) + if self.get("preset_selection").get_active_id() != new_preset: + # for whatever reason I have to use set_active_id twice for this + # to work in tests all of the sudden + self.get("preset_selection").set_active_id(new_preset) except PermissionError as error: error = str(error) self.show_status(CTX_ERROR, "Permission denied!", error) logger.error(error) + @ensure_everything_saved def on_select_preset(self, dropdown): """Show the mappings of the preset.""" # beware in tests that this function won't be called at all if the # active_id stays the same - self.save_preset() - if dropdown.get_active_id() == self.preset_name: return - self.clear_mapping_table() - preset = dropdown.get_active_text() if preset is None: return logger.debug('Selecting preset "%s"', preset) + self.editor.clear() self.preset_name = preset custom_mapping.load(self.group.get_preset_path(preset)) - key_list = self.get("key_list") - for key, output in custom_mapping: - single_key_mapping = Row( - window=self, delete_callback=self.on_row_removed, key=key, symbol=output - ) - key_list.insert(single_key_mapping, -1) + self.editor.load_custom_mapping() autoload_switch = self.get("preset_autoload_switch") with HandlerDisabled(autoload_switch, self.on_autoload_switch): - autoload_switch.set_active( - config.is_autoloaded(self.group.key, self.preset_name) - ) + is_autoloaded = config.is_autoloaded(self.group.key, self.preset_name) + autoload_switch.set_active(is_autoloaded) self.get("preset_name_input").set_text("") - self.add_empty() self.initialize_gamepad_config() - custom_mapping.changed = False + custom_mapping.set_has_unsaved_changes(False) def on_left_joystick_changed(self, dropdown): """Set the purpose of the left joystick.""" @@ -799,34 +720,18 @@ class Window: speed = 2 ** gtk_range.get_value() custom_mapping.set("gamepad.joystick.pointer_speed", speed) - def add_empty(self): - """Add one empty row for a single mapped key.""" - empty = Row(window=self, delete_callback=self.on_row_removed) - key_list = self.get("key_list") - key_list.insert(empty, -1) - - def on_row_removed(self, single_key_mapping): - """Stuff to do when a row was removed - - Parameters - ---------- - single_key_mapping : Row - """ - key_list = self.get("key_list") - # https://stackoverflow.com/a/30329591/4417769 - key_list.remove(single_key_mapping) - def save_preset(self, *_): - """Write changes to presets to disk.""" - if not custom_mapping.changed: + """Write changes in the custom_mapping to disk.""" + if not custom_mapping.has_unsaved_changes(): + # optimization, and also avoids tons of redundant logs + logger.spam("Not saving because mapping did not change") return try: + assert self.preset_name is not None path = self.group.get_preset_path(self.preset_name) custom_mapping.save(path) - custom_mapping.changed = False - # after saving the config, its modification date will be the # newest, so populate_presets will automatically select the # right one again. @@ -837,11 +742,15 @@ class Window: logger.error(error) for _, symbol in custom_mapping: + if not symbol: + continue + if is_this_a_macro(symbol): continue if system_mapping.get(symbol) is None: - self.show_status(CTX_MAPPING, f'Unknown mapping "{symbol}"') + trimmed = re.sub(r"\s+", " ", symbol).strip() + self.show_status(CTX_MAPPING, f'Unknown mapping "{trimmed}"') break else: # no broken mappings found diff --git a/inputremapper/gui/utils.py b/inputremapper/gui/utils.py new file mode 100644 index 00000000..092ebe7f --- /dev/null +++ b/inputremapper/gui/utils.py @@ -0,0 +1,54 @@ +#!/usr/bin/python3 +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2022 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + + +from gi.repository import Gtk + + +# status ctx ids +CTX_SAVE = 0 +CTX_APPLY = 1 +CTX_KEYCODE = 2 +CTX_ERROR = 3 +CTX_WARNING = 4 +CTX_MAPPING = 5 + + +class HandlerDisabled: + """Safely modify a widget without causing handlers to be called. + + Use in a with statement. + """ + + def __init__(self, widget, handler): + self.widget = widget + self.handler = handler + + def __enter__(self): + self.widget.handler_block_by_func(self.handler) + + def __exit__(self, *_): + self.widget.handler_unblock_by_func(self.handler) + + +def gtk_iteration(): + """Iterate while events are pending.""" + while Gtk.events_pending(): + Gtk.main_iteration() diff --git a/inputremapper/injection/consumers/keycode_mapper.py b/inputremapper/injection/consumers/keycode_mapper.py index 1e281edb..9a386ed7 100644 --- a/inputremapper/injection/consumers/keycode_mapper.py +++ b/inputremapper/injection/consumers/keycode_mapper.py @@ -30,7 +30,7 @@ import evdev from evdev.ecodes import EV_KEY, EV_ABS from inputremapper.logger import logger -from inputremapper.mapping import DISABLE_CODE +from inputremapper.system_mapping import DISABLE_CODE from inputremapper import utils from inputremapper.injection.consumers.consumer import Consumer from inputremapper.utils import RELEASE diff --git a/inputremapper/injection/injector.py b/inputremapper/injection/injector.py index 02d6c569..17462cbe 100644 --- a/inputremapper/injection/injector.py +++ b/inputremapper/injection/injector.py @@ -31,7 +31,7 @@ from evdev.ecodes import EV_KEY, EV_REL from inputremapper.logger import logger from inputremapper.groups import classify, GAMEPAD -from inputremapper.mapping import DISABLE_CODE +from inputremapper.system_mapping import DISABLE_CODE from inputremapper.injection.context import Context from inputremapper.injection.numlock import set_numlock, is_numlock_on, ensure_numlock from inputremapper.injection.consumer_control import ConsumerControl diff --git a/inputremapper/injection/macros/macro.py b/inputremapper/injection/macros/macro.py index 922bec67..ad4b2b26 100644 --- a/inputremapper/injection/macros/macro.py +++ b/inputremapper/injection/macros/macro.py @@ -627,12 +627,12 @@ class Macro: self.child_macros.append(otherwise) async def task(handler): - mappable_event_1 = (self._newest_event.type, self._newest_event.code) + triggering_event = (self._newest_event.type, self._newest_event.code) def event_filter(event, action): - """Which event may wake if_tap up.""" + """Which event may wake if_single up.""" # release event of the actual key - if (event.type, event.code) == mappable_event_1: + if (event.type, event.code) == triggering_event: return True # press event of another key @@ -647,9 +647,12 @@ class Macro: else: await coroutine - mappable_event_2 = (self._newest_event.type, self._newest_event.code) - combined = mappable_event_1 != mappable_event_2 - if not combined: + newest_event = (self._newest_event.type, self._newest_event.code) + # if newest_event == triggering_event, then no other key was pressed. + # if it is !=, then a new key was pressed in the meantime. + new_key_pressed = triggering_event != newest_event + + if not new_key_pressed: # no timeout and not combined if then: await then.run(handler) diff --git a/inputremapper/injection/macros/parse.py b/inputremapper/injection/macros/parse.py index e0521a0a..30130044 100644 --- a/inputremapper/injection/macros/parse.py +++ b/inputremapper/injection/macros/parse.py @@ -83,6 +83,18 @@ def use_safe_argument_names(keyword_args): del keyword_args[built_in] +def get_macro_argument_names(function): + """Certain names, like "else" or "type" cannot be used as parameters in python. + + Removes the "_" in from of them for displaying them correctly. + """ + # don't include "self" + return [ + name[1:] if name.startswith("_") else name + for name in inspect.getfullargspec(function).args[1:] + ] + + def get_num_parameters(function): """Get the number of required parameters and the maximum number of parameters.""" fullargspec = inspect.getfullargspec(function) @@ -224,8 +236,10 @@ def _parse_recurse(code, context, macro_instance=None, depth=0): call = call_match[1] if call_match else None if call is not None: if macro_instance is None: + # start a new chain macro_instance = Macro(code, context) else: + # chain this call to the existing instance assert isinstance(macro_instance, Macro) function = FUNCTIONS.get(call) @@ -324,7 +338,7 @@ def handle_plus_syntax(macro): return output -def _remove_whitespaces(macro, delimiter='"'): +def remove_whitespaces(macro, delimiter='"'): """Remove whitespaces, tabs, newlines and such outside of string quotes.""" result = "" for i, chunk in enumerate(macro.split(delimiter)): @@ -339,7 +353,7 @@ def _remove_whitespaces(macro, delimiter='"'): return result[: -len(delimiter)] -def _remove_comments(macro): +def remove_comments(macro): """Remove comments from the macro and return the resulting code.""" # keep hashtags inside quotes intact result = "" @@ -364,6 +378,11 @@ def _remove_comments(macro): return result +def clean(code): + """Remove everything irrelevant for the macro.""" + return remove_whitespaces(remove_comments(code), '"') + + def parse(macro, context, return_errors=False): """parse and generate a Macro that can be run as often as you want. @@ -383,9 +402,7 @@ def parse(macro, context, return_errors=False): """ macro = handle_plus_syntax(macro) - macro = _remove_comments(macro) - - macro = _remove_whitespaces(macro, '"') + macro = clean(macro) if return_errors: logger.spam("checking the syntax of %s", macro) diff --git a/inputremapper/key.py b/inputremapper/key.py index 57811671..1285b2e8 100644 --- a/inputremapper/key.py +++ b/inputremapper/key.py @@ -24,8 +24,12 @@ import itertools +import evdev from evdev import ecodes +from inputremapper.system_mapping import system_mapping +from inputremapper.logger import logger + def verify(key): """Check if the key is an int 3-tuple of type, code, value""" @@ -162,3 +166,100 @@ class Key: permutations.append(Key(*permutation, self.keys[-1])) return permutations + + def beautify(self): + """Get a human readable string representation.""" + result = [] + + for sub_key in self: + if isinstance(sub_key[0], tuple): + raise Exception("deprecated stuff") + + ev_type, code, value = sub_key + + if ev_type not in evdev.ecodes.bytype: + logger.error("Unknown key type for %s", sub_key) + result.append(str(code)) + continue + + if code not in evdev.ecodes.bytype[ev_type]: + logger.error("Unknown key code for %s", sub_key) + result.append(str(code)) + continue + + key_name = None + + # first try to find the name in xmodmap to not display wrong + # names due to the keyboard layout + if ev_type == evdev.ecodes.EV_KEY: + key_name = system_mapping.get_name(code) + + if key_name is None: + # if no result, look in the linux key constants. On a german + # keyboard for example z and y are switched, which will therefore + # cause the wrong letter to be displayed. + key_name = evdev.ecodes.bytype[ev_type][code] + if isinstance(key_name, list): + key_name = key_name[0] + + if ev_type != evdev.ecodes.EV_KEY: + direction = { + # D-Pad + (evdev.ecodes.ABS_HAT0X, -1): "Left", + (evdev.ecodes.ABS_HAT0X, 1): "Right", + (evdev.ecodes.ABS_HAT0Y, -1): "Up", + (evdev.ecodes.ABS_HAT0Y, 1): "Down", + (evdev.ecodes.ABS_HAT1X, -1): "Left", + (evdev.ecodes.ABS_HAT1X, 1): "Right", + (evdev.ecodes.ABS_HAT1Y, -1): "Up", + (evdev.ecodes.ABS_HAT1Y, 1): "Down", + (evdev.ecodes.ABS_HAT2X, -1): "Left", + (evdev.ecodes.ABS_HAT2X, 1): "Right", + (evdev.ecodes.ABS_HAT2Y, -1): "Up", + (evdev.ecodes.ABS_HAT2Y, 1): "Down", + # joystick + (evdev.ecodes.ABS_X, 1): "Right", + (evdev.ecodes.ABS_X, -1): "Left", + (evdev.ecodes.ABS_Y, 1): "Down", + (evdev.ecodes.ABS_Y, -1): "Up", + (evdev.ecodes.ABS_RX, 1): "Right", + (evdev.ecodes.ABS_RX, -1): "Left", + (evdev.ecodes.ABS_RY, 1): "Down", + (evdev.ecodes.ABS_RY, -1): "Up", + # wheel + (evdev.ecodes.REL_WHEEL, -1): "Down", + (evdev.ecodes.REL_WHEEL, 1): "Up", + (evdev.ecodes.REL_HWHEEL, -1): "Left", + (evdev.ecodes.REL_HWHEEL, 1): "Right", + }.get((code, value)) + if direction is not None: + key_name += f" {direction}" + + key_name = key_name.replace("ABS_Z", "Trigger Left") + key_name = key_name.replace("ABS_RZ", "Trigger Right") + + key_name = key_name.replace("ABS_HAT0X", "DPad") + key_name = key_name.replace("ABS_HAT0Y", "DPad") + key_name = key_name.replace("ABS_HAT1X", "DPad 2") + key_name = key_name.replace("ABS_HAT1Y", "DPad 2") + key_name = key_name.replace("ABS_HAT2X", "DPad 3") + key_name = key_name.replace("ABS_HAT2Y", "DPad 3") + + key_name = key_name.replace("ABS_X", "Joystick") + key_name = key_name.replace("ABS_Y", "Joystick") + key_name = key_name.replace("ABS_RX", "Joystick 2") + key_name = key_name.replace("ABS_RY", "Joystick 2") + + key_name = key_name.replace("BTN_", "Button ") + key_name = key_name.replace("KEY_", "") + + key_name = key_name.replace("REL_", "") + key_name = key_name.replace("HWHEEL", "Wheel") + key_name = key_name.replace("WHEEL", "Wheel") + + key_name = key_name.replace("_", " ") + key_name = key_name.replace(" ", " ") + + result.append(key_name) + + return " + ".join(result) diff --git a/inputremapper/logger.py b/inputremapper/logger.py index 3bfe4385..38a6789e 100644 --- a/inputremapper/logger.py +++ b/inputremapper/logger.py @@ -162,7 +162,10 @@ except pkg_resources.DistributionNotFound as error: def log_info(name="input-remapper"): """Log version and name to the console.""" logger.info( - "%s %s %s https://github.com/sezanzeb/input-remapper", name, VERSION, COMMIT_HASH + "%s %s %s https://github.com/sezanzeb/input-remapper", + name, + VERSION, + COMMIT_HASH, ) if EVDEV_VERSION: diff --git a/inputremapper/mapping.py b/inputremapper/mapping.py index 33b224e7..50a00b00 100644 --- a/inputremapper/mapping.py +++ b/inputremapper/mapping.py @@ -32,11 +32,7 @@ from inputremapper.logger import logger from inputremapper.paths import touch from inputremapper.config import ConfigBase, config from inputremapper.key import Key - - -DISABLE_NAME = "disable" - -DISABLE_CODE = -1 +from inputremapper.injection.macros.parse import clean def split_key(key): @@ -62,7 +58,7 @@ class Mapping(ConfigBase): def __init__(self): self._mapping = {} # a mapping of Key objects to strings - self.changed = False + self._changed = False # are there actually any keys set in the mapping file? self.num_saved_keys = 0 @@ -78,12 +74,12 @@ class Mapping(ConfigBase): def set(self, *args): """Set a config value. See `ConfigBase.set`.""" - self.changed = True + self._changed = True return super().set(*args) def remove(self, *args): """Remove a config value. See `ConfigBase.remove`.""" - self.changed = True + self._changed = True return super().remove(*args) def change(self, new_key, symbol, previous_key=None): @@ -105,22 +101,40 @@ class Mapping(ConfigBase): if not isinstance(new_key, Key): raise TypeError(f"Expected {new_key} to be a Key object") - if symbol is None: - raise ValueError("Expected `symbol` not to be None") + if symbol is None or symbol.strip() == "": + raise ValueError("Expected `symbol` not to be empty") symbol = symbol.strip() - logger.debug('%s will map to "%s"', new_key, symbol) + + if previous_key is None and self._mapping.get(new_key): + # the key didn't change + previous_key = new_key + + key_changed = new_key != previous_key + if not key_changed and symbol == self._mapping.get(new_key): + # nothing was changed, no need to act + return + self.clear(new_key) # this also clears all equivalent keys + + logger.debug('changing %s to "%s"', new_key, clean(symbol)) + self._mapping[new_key] = symbol - if previous_key is not None: - code_changed = new_key != previous_key - if code_changed: - # clear previous mapping of that code, because the line - # representing that one will now represent a different one - self.clear(previous_key) + if key_changed and previous_key is not None: + # clear previous mapping of that code, because the line + # representing that one will now represent a different one + self.clear(previous_key) + + self._changed = True - self.changed = True + def has_unsaved_changes(self): + """Check if there are unsaved changed.""" + return self._changed + + def set_has_unsaved_changes(self, changed): + """Write down if there are unsaved changes, or if they have been saved.""" + self._changed = changed def clear(self, key): """Remove a keycode from the mapping. @@ -130,20 +144,20 @@ class Mapping(ConfigBase): key : Key """ if not isinstance(key, Key): - raise TypeError("Expected key to be a Key object") + raise TypeError(f"Expected key to be a Key object but got {key}") for permutation in key.get_permutations(): if permutation in self._mapping: - logger.debug("%s will be cleared", permutation) + logger.debug("%s cleared", permutation) del self._mapping[permutation] - self.changed = True + self._changed = True # there should be only one variation of the permutations # in the mapping actually def empty(self): """Remove all mappings and custom configs without saving.""" self._mapping = {} - self.changed = True + self._changed = True self.clear_config() def load(self, path): @@ -158,7 +172,8 @@ class Mapping(ConfigBase): if not os.path.exists(path): raise FileNotFoundError(f'Tried to load non-existing preset "{path}"') - self.clear_config() + self.empty() + self._changed = False with open(path, "r") as file: preset_dict = json.load(file) @@ -188,7 +203,7 @@ class Mapping(ConfigBase): if None in key: continue - logger.spam("%s maps to %s", key, symbol) + logger.spam("%s maps to %s", key, clean(symbol)) self._mapping[key] = symbol # add any metadata of the mapping @@ -197,14 +212,14 @@ class Mapping(ConfigBase): continue self._config[key] = preset_dict[key] - self.changed = False + self._changed = False self.num_saved_keys = len(self) def clone(self): """Create a copy of the mapping.""" mapping = Mapping() mapping._mapping = copy.deepcopy(self._mapping) - mapping.changed = self.changed + mapping.set_has_unsaved_changes(self._changed) return mapping def save(self, path): @@ -236,7 +251,7 @@ class Mapping(ConfigBase): json.dump(preset_dict, file, indent=4) file.write("\n") - self.changed = False + self._changed = False self.num_saved_keys = len(self) def get_symbol(self, key): @@ -247,7 +262,7 @@ class Mapping(ConfigBase): key : Key """ if not isinstance(key, Key): - raise TypeError("Expected key to be a Key object") + raise TypeError(f"Expected key to be a Key object but got {key}") for permutation in key.get_permutations(): existing = self._mapping.get(permutation) diff --git a/inputremapper/presets.py b/inputremapper/presets.py index e7177fd3..fd77e25d 100644 --- a/inputremapper/presets.py +++ b/inputremapper/presets.py @@ -131,7 +131,6 @@ def find_newest_preset(group_name=None): break if newest_path is None: - logger.debug("None of the configured devices is currently online") return get_any_preset() preset = os.path.splitext(preset)[0] diff --git a/inputremapper/system_mapping.py b/inputremapper/system_mapping.py index e9545c60..35bc030c 100644 --- a/inputremapper/system_mapping.py +++ b/inputremapper/system_mapping.py @@ -28,10 +28,12 @@ import subprocess import evdev from inputremapper.logger import logger -from inputremapper.mapping import DISABLE_NAME, DISABLE_CODE from inputremapper.paths import get_config_path, touch from inputremapper.utils import is_service +DISABLE_NAME = "disable" + +DISABLE_CODE = -1 # xkb uses keycodes that are 8 higher than those from evdev XKB_KEYCODE_OFFSET = 8 @@ -45,20 +47,25 @@ class SystemMapping: def __init__(self): """Construct the system_mapping.""" self._mapping = None - self._xmodmap = {} - self._case_insensitive_mapping = {} + self._xmodmap = None + self._case_insensitive_mapping = None - def __getattribute__(self, key): + def __getattribute__(self, wanted): """To lazy load system_mapping info only when needed. - For example, this helps to keep logs of input-remapper-control clear when it doesnt - need it the information. + For example, this helps to keep logs of input-remapper-control clear when it + doesnt need it the information. """ - if key == "_mapping" and object.__getattribute__(self, "_mapping") is None: - object.__setattr__(self, "_mapping", {}) - object.__getattribute__(self, "populate")() + lazy_loaded_attributes = ["_mapping", "_xmodmap", "_case_insensitive_mapping"] + for lazy_loaded_attribute in lazy_loaded_attributes: + if wanted != lazy_loaded_attribute: + continue + + if object.__getattribute__(self, lazy_loaded_attribute) is None: + object.__setattr__(self, lazy_loaded_attribute, {}) + object.__getattribute__(self, "populate")() - return object.__getattribute__(self, key) + return object.__getattribute__(self, wanted) def list_names(self): """Return an array of all possible names in the mapping.""" diff --git a/po/input-remapper.pot b/po/input-remapper.pot index f811bb08..0ae8964c 100644 --- a/po/input-remapper.pot +++ b/po/input-remapper.pot @@ -143,7 +143,7 @@ msgid "Rename" msgstr "" #: data/input-remapper.glade:120 -msgid "Restore Defaults" +msgid "Stop Injection" msgstr "" #: data/input-remapper.glade:509 diff --git a/po/it_IT.po b/po/it_IT.po index 9d80cfbf..e6986810 100644 --- a/po/it_IT.po +++ b/po/it_IT.po @@ -150,7 +150,7 @@ msgid "Rename" msgstr "Rinomina" #: data/input-remapper.glade:120 -msgid "Restore Defaults" +msgid "Stop Injection" msgstr "Ripristina impostazioni predefinite" #: data/input-remapper.glade:509 diff --git a/po/sk_SK.po b/po/sk_SK.po index 8e2b1821..7387e5cc 100644 --- a/po/sk_SK.po +++ b/po/sk_SK.po @@ -149,7 +149,7 @@ msgid "Rename" msgstr "Premenovať" #: data/input-remapper.glade:120 -msgid "Restore Defaults" +msgid "Stop Injection" msgstr "Obnoviť predvolené" #: data/input-remapper.glade:509 diff --git a/readme/development.md b/readme/development.md index ed12842e..c3ad3a70 100644 --- a/readme/development.md +++ b/readme/development.md @@ -39,7 +39,7 @@ be mostly compliant with pylint. - [x] map keys using a `modifier + modifier + ... + key` syntax - [x] inject in an additional device instead to avoid clashing capabilities - [x] don't run any GUI code as root for improved wayland compatibility -- [ ] macro editor with easier to read function names +- [x] advanced multiline editor - [ ] plugin support - [x] getting it into the official debian repo @@ -274,3 +274,4 @@ while input-remapper reads from multiple InputDevices it injects the mapped lett - [python-evdev](https://python-evdev.readthedocs.io/en/stable/) - [Python Unix Domain Sockets](https://pymotw.com/2/socket/uds.html) - [GNOME HIG](https://developer.gnome.org/hig/stable/) +- [GtkSource Example](https://github.com/wolfthefallen/py-GtkSourceCompletion-example) diff --git a/readme/pylint.svg b/readme/pylint.svg index 88f7e61b..8d31e6bf 100644 --- a/readme/pylint.svg +++ b/readme/pylint.svg @@ -17,7 +17,7 @@ pylint - 9.54 - 9.54 + 9.39 + 9.39 \ No newline at end of file diff --git a/readme/screenshot.png b/readme/screenshot.png index d2643d99..c58ffdd5 100644 Binary files a/readme/screenshot.png and b/readme/screenshot.png differ diff --git a/readme/screenshot_2.png b/readme/screenshot_2.png index 4c49e9d4..a5eb18b9 100644 Binary files a/readme/screenshot_2.png and b/readme/screenshot_2.png differ diff --git a/readme/usage.md b/readme/usage.md index 31b3add9..2b290155 100644 --- a/readme/usage.md +++ b/readme/usage.md @@ -19,7 +19,7 @@ can be found [below](#key-names-and-macros). Changes are saved automatically. Afterwards press the "Apply" button. -To change the mapping, you need to use the "Restore Defaults" button, so that +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. diff --git a/setup.py b/setup.py index fa1b6b72..88711a4a 100644 --- a/setup.py +++ b/setup.py @@ -93,7 +93,10 @@ lang_data = [] for po_file in glob.glob(PO_FILES): lang = splitext(basename(po_file))[0] lang_data.append( - (f"/usr/share/input-remapper/lang/{lang}/LC_MESSAGES", [f"mo/{lang}/input-remapper.mo"]) + ( + f"/usr/share/input-remapper/lang/{lang}/LC_MESSAGES", + [f"mo/{lang}/input-remapper.mo"], + ) ) diff --git a/tests/test.py b/tests/test.py index 95ed4a19..15da22f1 100644 --- a/tests/test.py +++ b/tests/test.py @@ -40,6 +40,7 @@ import gi gi.require_version("Gtk", "3.0") gi.require_version("GLib", "2.0") +gi.require_version("GtkSource", "4") from xmodmap import xmodmap @@ -590,13 +591,13 @@ def quick_cleanup(log=True): config.path = os.path.join(get_config_path(), "config.json") config.clear_config() - config.save_config() + config._save_config() system_mapping.populate() custom_mapping.empty() custom_mapping.clear_config() - custom_mapping.changed = False + custom_mapping.set_has_unsaved_changes(False) clear_write_history() @@ -656,7 +657,7 @@ def main(): # so provide both options. if len(modules) > 0: # for example - # `tests/test.py test_integration.TestIntegration.test_can_start` + # `tests/test.py test_integration.TestGui.test_can_start` # or `tests/test.py test_integration test_daemon` testsuite = unittest.defaultTestLoader.loadTestsFromNames( [f"testcases.{module}" for module in modules] diff --git a/tests/testcases/test_config.py b/tests/testcases/test_config.py index 55a09fe7..ff0ece85 100644 --- a/tests/testcases/test_config.py +++ b/tests/testcases/test_config.py @@ -22,9 +22,8 @@ import os import unittest -from inputremapper.config import config, GlobalConfig -from inputremapper.paths import touch, CONFIG_PATH -from inputremapper.logger import logger +from inputremapper.config import config +from inputremapper.paths import touch from tests.test import quick_cleanup, tmp @@ -84,7 +83,8 @@ class TestConfig(unittest.TestCase): self.assertTrue(config.is_autoloaded("d2.foo", "c")) self.assertEqual(config._config["autoload"]["d2.foo"], "c") self.assertListEqual( - list(config.iterate_autoload_presets()), [("d1", "a"), ("d2.foo", "c")] + list(config.iterate_autoload_presets()), + [("d1", "a"), ("d2.foo", "c")], ) config.set_autoload_preset("d2.foo", None) @@ -114,14 +114,11 @@ class TestConfig(unittest.TestCase): config.set_autoload_preset("d1", "a") config.set_autoload_preset("d2.foo", "b") - config.save_config() - - # ignored after load - config.set_autoload_preset("d3", "c") config.load_config() self.assertListEqual( - list(config.iterate_autoload_presets()), [("d1", "a"), ("d2.foo", "b")] + list(config.iterate_autoload_presets()), + [("d1", "a"), ("d2.foo", "b")], ) config_2 = os.path.join(tmp, "config_2.json") diff --git a/tests/testcases/test_control.py b/tests/testcases/test_control.py index 49d03ea0..707ee0df 100644 --- a/tests/testcases/test_control.py +++ b/tests/testcases/test_control.py @@ -100,7 +100,6 @@ class TestControl(unittest.TestCase): config.set_autoload_preset(groups_[0].key, presets[0]) config.set_autoload_preset(groups_[1].key, presets[1]) - config.save_config() communicate(options("autoload", None, None, None, False, False, False), daemon) self.assertEqual(len(start_history), 2) @@ -157,7 +156,6 @@ class TestControl(unittest.TestCase): ) self.assertEqual(stop_counter, 3) config.set_autoload_preset(groups_[1].key, presets[2]) - config.save_config() communicate( options("autoload", None, None, groups_[1].key, False, False, False), daemon ) @@ -215,10 +213,10 @@ class TestControl(unittest.TestCase): config.load_config() config.set_autoload_preset(device_names[0], presets[0]) config.set_autoload_preset(device_names[1], presets[1]) - config.save_config() communicate( - options("autoload", config_dir, None, None, False, False, False), daemon + options("autoload", config_dir, None, None, False, False, False), + daemon, ) self.assertEqual(len(start_history), 2) @@ -239,13 +237,15 @@ class TestControl(unittest.TestCase): daemon.stop_all = lambda *args: stop_all_history.append(args) communicate( - options("start", None, preset, group.paths[0], False, False, False), daemon + options("start", None, preset, group.paths[0], False, False, False), + daemon, ) self.assertEqual(len(start_history), 1) self.assertEqual(start_history[0], (group.key, preset)) communicate( - options("stop", None, None, group.paths[1], False, False, False), daemon + options("stop", None, None, group.paths[1], False, False, False), + daemon, ) self.assertEqual(len(stop_history), 1) # provided any of the groups paths as --device argument, figures out diff --git a/tests/testcases/test_daemon.py b/tests/testcases/test_daemon.py index 755d5c62..0ed0d7a4 100644 --- a/tests/testcases/test_daemon.py +++ b/tests/testcases/test_daemon.py @@ -100,7 +100,7 @@ class TestDaemon(unittest.TestCase): self.grab = evdev.InputDevice.grab self.daemon = None mkdir(get_config_path()) - config.save_config() + config._save_config() def tearDown(self): # avoid race conditions with other tests, daemon may run processes @@ -329,7 +329,7 @@ class TestDaemon(unittest.TestCase): # to use the directory config_path = os.path.join(config_dir, "config.json") config.path = config_path - config.save_config() + config._save_config() xmodmap_path = os.path.join(config_dir, "xmodmap.json") with open(xmodmap_path, "w") as file: @@ -418,7 +418,6 @@ class TestDaemon(unittest.TestCase): self.assertTrue(daemon.autoload_history.may_autoload(group.key, preset)) config.set_autoload_preset(group.key, preset) - config.save_config() len_before = len(self.daemon.autoload_history._autoload_history) # now autoloading is configured, so it will autoload self.daemon._autoload(group.key) @@ -483,7 +482,6 @@ class TestDaemon(unittest.TestCase): mapping.save(group.get_preset_path(preset)) config.set_autoload_preset(group.key, preset) - config.save_config() self.daemon = Daemon() groups.set_groups([]) # caused the bug diff --git a/tests/testcases/test_gui.py b/tests/testcases/test_gui.py new file mode 100644 index 00000000..6953bda2 --- /dev/null +++ b/tests/testcases/test_gui.py @@ -0,0 +1,1953 @@ +#!/usr/bin/python3 +# -*- coding: utf-8 -*- +# input-remapper - GUI for device specific keyboard mappings +# Copyright (C) 2022 sezanzeb +# +# This file is part of input-remapper. +# +# input-remapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# input-remapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with input-remapper. If not, see . + + +import sys +import time +import atexit +import os +import unittest +import multiprocessing +import evdev +from evdev.ecodes import ( + EV_KEY, + EV_ABS, + KEY_LEFTSHIFT, + KEY_A, + ABS_RX, + EV_REL, + REL_X, + ABS_X, +) +import json +from unittest.mock import patch +from importlib.util import spec_from_loader, module_from_spec +from importlib.machinery import SourceFileLoader + +import gi + +gi.require_version("Gtk", "3.0") +from gi.repository import Gtk, GLib, Gdk, GtkSource + +from inputremapper.system_mapping import system_mapping, XMODMAP_FILENAME +from inputremapper.gui.custom_mapping import custom_mapping +from inputremapper.paths import CONFIG_PATH, get_preset_path, get_config_path +from inputremapper.config import config, WHEEL, MOUSE, BUTTONS +from inputremapper.gui.reader import reader +from inputremapper.gui.helper import RootHelper +from inputremapper.gui.utils import gtk_iteration +from inputremapper.gui.user_interface import UserInterface +from inputremapper.gui.editor.editor import SET_KEY_FIRST +from inputremapper.injection.injector import RUNNING, FAILED, UNKNOWN +from inputremapper.key import Key +from inputremapper.daemon import Daemon +from inputremapper.groups import groups + +from tests.test import ( + tmp, + push_events, + new_event, + spy, + cleanup, + uinput_write_history_pipe, + MAX_ABS, + EVENT_READ_TIMEOUT, + send_event_to_reader, + MIN_ABS, +) + + +# iterate a few times when Gtk.main() is called, but don't block +# there and just continue to the tests while the UI becomes +# unresponsive +Gtk.main = gtk_iteration + +# doesn't do much except avoid some Gtk assertion error, whatever: +Gtk.main_quit = lambda: None + + +def launch(argv=None): + """Start input-remapper-gtk with the command line argument array argv.""" + bin_path = os.path.join(os.getcwd(), "bin", "input-remapper-gtk") + + if not argv: + argv = ["-d"] + + with patch( + "inputremapper.gui.user_interface.UserInterface.setup_timeouts", + lambda *args: None, + ): + with patch.object(sys, "argv", [""] + [str(arg) for arg in argv]): + loader = SourceFileLoader("__main__", bin_path) + spec = spec_from_loader("__main__", loader) + module = module_from_spec(spec) + spec.loader.exec_module(module) + + gtk_iteration() + + # otherwise a new handler is added with each call to launch, which + # spams tons of garbage when all tests finish + atexit.unregister(module.stop) + + # to avoid triggering any timeouts while the module loads, patch it and + # do it afterwards. Because some tests don't want them to be triggered + # yet and test the windows initial state. This is only a problem on + # slow computers that take long for the window import. + module.user_interface.setup_timeouts() + + return module.user_interface + + +class FakeDeviceDropdown(Gtk.ComboBoxText): + def __init__(self, group): + if type(group) == str: + group = groups.find(key=group) + + self.group = group + + def get_active_text(self): + return self.group.name + + def get_active_id(self): + return self.group.key + + def set_active_id(self, key): + self.group = groups.find(key=key) + + +class FakePresetDropdown(Gtk.ComboBoxText): + def __init__(self, name): + self.name = name + + def get_active_text(self): + return self.name + + def get_active_id(self): + return self.name + + def set_active_id(self, name): + self.name = name + + +def clean_up_integration(test): + test.user_interface.on_restore_defaults_clicked(None) + gtk_iteration() + test.user_interface.on_close() + test.user_interface.window.destroy() + gtk_iteration() + cleanup() + + # do this now, not when all tests are finished + test.user_interface.dbus.stop_all() + if isinstance(test.user_interface.dbus, Daemon): + atexit.unregister(test.user_interface.dbus.stop_all) + + +class GtkKeyEvent: + def __init__(self, keyval): + self.keyval = keyval + + def get_keyval(self): + return True, self.keyval + + +class TestGroupsFromHelper(unittest.TestCase): + def setUp(self): + self.injector = None + self.grab = evdev.InputDevice.grab + + # don't try to connect, return an object instance of it instead + self.original_connect = Daemon.connect + Daemon.connect = Daemon + + self.original_os_system = os.system + + def os_system(cmd): + # instead of running pkexec, fork instead. This will make + # the helper aware of all the test patches + if "pkexec input-remapper-control --command helper" in cmd: + # the forked process should get the initial groups + groups.refresh() + multiprocessing.Process(target=RootHelper).start() + # the gui an empty dict, because it doesn't know any devices + # without the help of the privileged helper + groups.set_groups([]) + assert len(groups) == 0 + return 0 + + return self.original_os_system(cmd) + + os.system = os_system + + self.user_interface = launch() + + def tearDown(self): + clean_up_integration(self) + os.system = self.original_os_system + Daemon.connect = self.original_connect + + def test_knows_devices(self): + # verify that it is working as expected. The gui doesn't have knowledge + # of groups until the root-helper provides them + gtk_iteration() + self.assertEqual(len(groups), 0) + + # perform some iterations so that the gui ends up running + # consume_newest_keycode, which will make it receive devices. + # Restore patch, otherwise gtk complains when disabling handlers + for _ in range(10): + time.sleep(0.02) + gtk_iteration() + + self.assertIsNotNone(groups.find(key="Foo Device 2")) + self.assertIsNotNone(groups.find(name="Bar Device")) + self.assertIsNotNone(groups.find(name="gamepad")) + self.assertEqual(self.user_interface.group.name, "Foo Device") + + +class PatchedConfirmDelete: + def __init__(self, user_interface, response=Gtk.ResponseType.ACCEPT): + self.response = response + self.user_interface = user_interface + self.patch = None + + def _confirm_delete_run_patch(self): + """A patch for the deletion confirmation that briefly shows the dialog.""" + confirm_delete = self.user_interface.confirm_delete + # the emitted signal causes the dialog to close + GLib.timeout_add( + 100, + lambda: confirm_delete.emit("response", self.response), + ) + Gtk.MessageDialog.run(confirm_delete) # don't recursively call the patch + return self.response + + def __enter__(self): + self.patch = patch.object( + self.user_interface.get("confirm-delete"), + "run", + self._confirm_delete_run_patch, + ) + self.patch.__enter__() + + def __exit__(self, *args, **kwargs): + self.patch.__exit__(*args, **kwargs) + + +class GuiTestBase: + @classmethod + def setUpClass(cls): + cls.injector = None + cls.grab = evdev.InputDevice.grab + cls.original_start_processes = UserInterface.start_processes + + def start_processes(self): + """Avoid running pkexec which requires user input, and fork in + order to pass the fixtures to the helper and daemon process. + """ + multiprocessing.Process(target=RootHelper).start() + self.dbus = Daemon() + + UserInterface.start_processes = start_processes + + def setUp(self): + self.user_interface = launch() + self.editor = self.user_interface.editor + self.toggle = self.editor.get_recording_toggle() + self.selection_label_listbox = self.user_interface.get( + "selection_label_listbox" + ) + self.window = self.user_interface.get("window") + + self.grab_fails = False + + def grab(_): + if self.grab_fails: + raise OSError() + + evdev.InputDevice.grab = grab + + config._save_config() + + def tearDown(self): + clean_up_integration(self) + + @classmethod + def tearDownClass(cls): + UserInterface.start_processes = cls.original_start_processes + + def set_focus(self, widget): + self.user_interface.window.set_focus(widget) + + # for whatever miraculous reason it suddenly takes 0.005s before gtk does + # anything, even for old code. + time.sleep(0.02) + gtk_iteration() + + def get_selection_labels(self): + return self.selection_label_listbox.get_children() + + def get_status_text(self): + status_bar = self.user_interface.get("status_bar") + return status_bar.get_message_area().get_children()[0].get_label() + + def get_unfiltered_symbol_input_text(self): + buffer = self.editor.get_text_input().get_buffer() + return buffer.get_text(buffer.get_start_iter(), buffer.get_end_iter(), True) + + def add_mapping_via_ui(self, key, symbol, expect_success=True): + """Modify the one empty mapping that always exists. + + Utility function for other tests. + + Parameters + ---------- + key : Key or None + expect_success : boolean + If the key can be stored in the selection label. False if this change + is going to cause a duplicate. + """ + self.assertIsNone(reader.get_unreleased_keys()) + + changed = custom_mapping.has_unsaved_changes() + + # wait for the window to create a new empty selection_label if needed + time.sleep(0.1) + gtk_iteration() + + # the empty selection_label is expected to be the last one + selection_label = self.get_selection_labels()[-1] + self.selection_label_listbox.select_row(selection_label) + self.assertIsNone(selection_label.get_key()) + self.assertFalse(self.editor._input_has_arrived) + + if self.toggle.get_active(): + self.assertEqual(self.toggle.get_label(), "Press Key") + else: + self.assertEqual(self.toggle.get_label(), "Change Key") + + # the recording toggle connects to focus events + self.set_focus(self.toggle) + self.toggle.set_active(True) + gtk_iteration() + gtk_iteration() + self.assertIsNone(selection_label.get_key()) + self.assertEqual(self.toggle.get_label(), "Press Key") + + if key: + # modifies the keycode in the selection_label not by writing into the input, + # but by sending an event. press down all the keys of a combination + for sub_key in key: + send_event_to_reader(new_event(*sub_key)) + # this will be consumed all at once, since no gtk_iteration + # is done + + # make the window consume the keycode + self.sleep(len(key)) + + # holding down + self.assertIsNotNone(reader.get_unreleased_keys()) + self.assertGreater(len(reader.get_unreleased_keys()), 0) + self.assertTrue(self.editor._input_has_arrived) + self.assertTrue(self.toggle.get_active()) + + # release all the keys + for sub_key in key: + send_event_to_reader(new_event(*sub_key[:2], 0)) + + # wait for the window to consume the keycode + self.sleep(len(key)) + + # released + self.assertIsNone(reader.get_unreleased_keys()) + self.assertFalse(self.editor._input_has_arrived) + + if expect_success: + self.assertEqual(self.editor.get_key(), key) + # the previously new entry, which has been edited now, is still the + # selected one + self.assertEqual(self.editor.active_selection_label, selection_label) + self.assertEqual( + self.editor.active_selection_label.get_label(), + key.beautify(), + ) + self.assertFalse(self.toggle.get_active()) + self.assertEqual(len(reader._unreleased), 0) + + if not expect_success: + self.assertIsNone(selection_label.get_key()) + self.assertEqual(self.editor.get_symbol_input_text(), "") + self.assertFalse(self.editor._input_has_arrived) + # it won't switch the focus to the symbol input + self.assertTrue(self.toggle.get_active()) + self.assertEqual(custom_mapping.has_unsaved_changes(), changed) + return selection_label + + if key is None: + self.assertEqual(self.get_unfiltered_symbol_input_text(), SET_KEY_FIRST) + self.assertEqual(self.editor.get_symbol_input_text(), "") + + # set the symbol to make the new selection_label complete + self.editor.set_symbol_input_text(symbol) + self.assertEqual(self.editor.get_symbol_input_text(), symbol) + + # unfocus them to trigger some final logic + self.set_focus(None) + correct_case = system_mapping.correct_case(symbol) + self.assertEqual(self.editor.get_symbol_input_text(), correct_case) + self.assertFalse(custom_mapping.has_unsaved_changes()) + + self.set_focus(self.editor.get_text_input()) + self.set_focus(None) + + return selection_label + + def sleep(self, num_events): + for _ in range(num_events * 2): + time.sleep(EVENT_READ_TIMEOUT) + gtk_iteration() + + time.sleep(1 / 30) # one window iteration + gtk_iteration() + + +class TestGui(GuiTestBase, unittest.TestCase): + """For tests that use the window. + + Try to modify the configuration only by calling functions of the window. + """ + + def test_can_start(self): + self.assertIsNotNone(self.user_interface) + self.assertTrue(self.user_interface.window.get_visible()) + + def test_gui_clean(self): + # check that the test is correctly set up so that the user interface is clean + selection_labels = self.selection_label_listbox.get_children() + self.assertEqual(len(selection_labels), 1) + self.assertEqual(self.editor.active_selection_label, selection_labels[0]) + self.assertEqual( + self.selection_label_listbox.get_selected_row(), + selection_labels[0], + ) + self.assertEqual(len(custom_mapping), 0) + self.assertEqual(selection_labels[0].get_label(), "new entry") + self.assertEqual(self.editor.get_symbol_input_text(), "") + preset_selection = self.user_interface.get("preset_selection") + self.assertEqual(preset_selection.get_active_id(), "new preset") + self.assertEqual(len(custom_mapping), 0) + self.assertEqual(self.editor.get_recording_toggle().get_label(), "Change Key") + self.assertEqual(self.get_unfiltered_symbol_input_text(), SET_KEY_FIRST) + + def test_ctrl_q(self): + closed = False + + def on_close(): + nonlocal closed + closed = True + + with patch.object(self.user_interface, "on_close", on_close): + self.user_interface.on_key_press( + self.user_interface, GtkKeyEvent(Gdk.KEY_Control_L) + ) + self.user_interface.on_key_press( + self.user_interface, GtkKeyEvent(Gdk.KEY_a) + ) + self.user_interface.on_key_release( + self.user_interface, GtkKeyEvent(Gdk.KEY_Control_L) + ) + self.user_interface.on_key_release( + self.user_interface, GtkKeyEvent(Gdk.KEY_a) + ) + self.user_interface.on_key_press( + self.user_interface, GtkKeyEvent(Gdk.KEY_b) + ) + self.user_interface.on_key_press( + self.user_interface, GtkKeyEvent(Gdk.KEY_q) + ) + self.user_interface.on_key_release( + self.user_interface, GtkKeyEvent(Gdk.KEY_q) + ) + self.user_interface.on_key_release( + self.user_interface, GtkKeyEvent(Gdk.KEY_b) + ) + self.assertFalse(closed) + + # while keys are being recorded no shortcut should work + self.toggle.set_active(True) + self.user_interface.on_key_press( + self.user_interface, GtkKeyEvent(Gdk.KEY_Control_L) + ) + self.user_interface.on_key_press( + self.user_interface, GtkKeyEvent(Gdk.KEY_q) + ) + self.assertFalse(closed) + + self.toggle.set_active(False) + self.user_interface.on_key_press( + self.user_interface, GtkKeyEvent(Gdk.KEY_Control_L) + ) + self.user_interface.on_key_press( + self.user_interface, GtkKeyEvent(Gdk.KEY_q) + ) + self.assertTrue(closed) + + self.user_interface.on_key_release( + self.user_interface, GtkKeyEvent(Gdk.KEY_Control_L) + ) + self.user_interface.on_key_release( + self.user_interface, GtkKeyEvent(Gdk.KEY_q) + ) + + def test_ctrl_r(self): + with patch.object(reader, "refresh_groups") as reader_get_devices_patch: + self.user_interface.on_key_press( + self.user_interface, GtkKeyEvent(Gdk.KEY_Control_L) + ) + self.user_interface.on_key_press( + self.user_interface, GtkKeyEvent(Gdk.KEY_r) + ) + reader_get_devices_patch.assert_called_once() + + def test_ctrl_del(self): + with patch.object(self.user_interface.dbus, "stop_injecting") as stop_injecting: + self.user_interface.on_key_press( + self.user_interface, GtkKeyEvent(Gdk.KEY_Control_L) + ) + self.user_interface.on_key_press( + self.user_interface, GtkKeyEvent(Gdk.KEY_Delete) + ) + stop_injecting.assert_called_once() + + def test_show_device_mapping_status(self): + # this function may not return True, otherwise the timeout + # runs forever + self.assertFalse(self.user_interface.show_device_mapping_status()) + + def test_autoload(self): + with spy(self.user_interface.dbus, "set_config_dir") as set_config_dir: + self.user_interface.on_autoload_switch(None, False) + set_config_dir.assert_called_once() + + self.assertFalse( + config.is_autoloaded( + self.user_interface.group.key, self.user_interface.preset_name + ) + ) + + self.user_interface.on_select_device(FakeDeviceDropdown("Foo Device 2")) + gtk_iteration() + self.assertFalse(self.user_interface.get("preset_autoload_switch").get_active()) + + # select a preset for the first device + self.user_interface.get("preset_autoload_switch").set_active(True) + gtk_iteration() + self.assertTrue(self.user_interface.get("preset_autoload_switch").get_active()) + self.assertEqual(self.user_interface.group.key, "Foo Device 2") + self.assertEqual(self.user_interface.group.name, "Foo Device") + self.assertTrue( + config.is_autoloaded(self.user_interface.group.key, "new preset") + ) + self.assertFalse(config.is_autoloaded("Bar Device", "new preset")) + self.assertListEqual( + list(config.iterate_autoload_presets()), [("Foo Device 2", "new preset")] + ) + + # create a new preset, the switch should be correctly off and the + # config not changed. + self.user_interface.on_create_preset_clicked() + gtk_iteration() + self.assertEqual(self.user_interface.preset_name, "new preset 2") + self.assertFalse(self.user_interface.get("preset_autoload_switch").get_active()) + self.assertTrue(config.is_autoloaded("Foo Device 2", "new preset")) + self.assertFalse(config.is_autoloaded("Foo Device", "new preset")) + self.assertFalse(config.is_autoloaded("Foo Device", "new preset 2")) + self.assertFalse(config.is_autoloaded("Foo Device 2", "new preset 2")) + + # select a preset for the second device + self.user_interface.on_select_device(FakeDeviceDropdown("Bar Device")) + self.user_interface.get("preset_autoload_switch").set_active(True) + gtk_iteration() + self.assertTrue(config.is_autoloaded("Foo Device 2", "new preset")) + self.assertFalse(config.is_autoloaded("Foo Device", "new preset")) + self.assertTrue(config.is_autoloaded("Bar Device", "new preset")) + self.assertListEqual( + list(config.iterate_autoload_presets()), + [("Foo Device 2", "new preset"), ("Bar Device", "new preset")], + ) + + # disable autoloading for the second device + self.user_interface.get("preset_autoload_switch").set_active(False) + gtk_iteration() + self.assertTrue(config.is_autoloaded("Foo Device 2", "new preset")) + self.assertFalse(config.is_autoloaded("Foo Device", "new preset")) + self.assertFalse(config.is_autoloaded("Bar Device", "new preset")) + self.assertListEqual( + list(config.iterate_autoload_presets()), + [("Foo Device 2", "new preset")], + ) + + def test_select_device(self): + # creates a new empty preset when no preset exists for the device + self.user_interface.on_select_device(FakeDeviceDropdown("Foo Device")) + custom_mapping.change(Key(EV_KEY, 50, 1), "q") + custom_mapping.change(Key(EV_KEY, 51, 1), "u") + custom_mapping.change(Key(EV_KEY, 52, 1), "x") + self.assertEqual(len(custom_mapping), 3) + self.user_interface.on_select_device(FakeDeviceDropdown("Bar Device")) + self.assertEqual(len(custom_mapping), 0) + # it creates the file for that right away. It may have been possible + # to write it such that it doesn't (its empty anyway), but it does, + # so use that to test it in more detail. + path = get_preset_path("Bar Device", "new preset") + self.assertTrue(os.path.exists(path)) + with open(path, "r") as file: + preset = json.load(file) + self.assertEqual(len(preset["mapping"]), 0) + + def test_permission_error_on_create_preset_clicked(self): + def save(_=None): + raise PermissionError + + with patch.object(custom_mapping, "save", save): + self.user_interface.on_create_preset_clicked() + status = self.get_status_text() + self.assertIn("Permission denied", status) + + def test_show_injection_result_failure(self): + def get_state(_=None): + return FAILED + + with patch.object(self.user_interface.dbus, "get_state", get_state): + self.user_interface.show_injection_result() + text = self.get_status_text() + self.assertIn("Failed", text) + + def test_editor_keycode_to_string(self): + # not an integration test, but I have all the selection_label tests here already + self.assertEqual(Key(EV_KEY, evdev.ecodes.KEY_A, 1).beautify(), "a") + self.assertEqual( + Key(EV_ABS, evdev.ecodes.ABS_HAT0X, -1).beautify(), "DPad Left" + ) + self.assertEqual(Key(EV_ABS, evdev.ecodes.ABS_HAT0Y, -1).beautify(), "DPad Up") + self.assertEqual(Key(EV_KEY, evdev.ecodes.BTN_A, 1).beautify(), "Button A") + self.assertEqual(Key(EV_KEY, 1234, 1).beautify(), "1234") + self.assertEqual( + Key(EV_ABS, evdev.ecodes.ABS_X, 1).beautify(), "Joystick Right" + ) + self.assertEqual( + Key(EV_ABS, evdev.ecodes.ABS_RY, 1).beautify(), "Joystick 2 Down" + ) + self.assertEqual( + Key(EV_REL, evdev.ecodes.REL_HWHEEL, 1).beautify(), "Wheel Right" + ) + self.assertEqual( + Key(EV_REL, evdev.ecodes.REL_WHEEL, -1).beautify(), "Wheel Down" + ) + + # combinations + self.assertEqual( + Key( + (EV_KEY, evdev.ecodes.BTN_A, 1), + (EV_KEY, evdev.ecodes.BTN_B, 1), + (EV_KEY, evdev.ecodes.BTN_C, 1), + ).beautify(), + "Button A + Button B + Button C", + ) + + def test_editor_simple(self): + self.assertEqual(self.toggle.get_label(), "Change Key") + + self.assertEqual(len(self.selection_label_listbox.get_children()), 1) + + selection_label = self.selection_label_listbox.get_children()[0] + self.set_focus(self.toggle) + self.toggle.set_active(True) + self.assertEqual(self.toggle.get_label(), "Press Key") + + self.editor.consume_newest_keycode(None) + # nothing happens + self.assertIsNone(selection_label.get_key()) + self.assertEqual(len(custom_mapping), 0) + self.assertEqual(self.toggle.get_label(), "Press Key") + + self.editor.consume_newest_keycode(Key(EV_KEY, 30, 1)) + # no symbol configured yet, so the custom_mapping remains empty + self.assertEqual(len(custom_mapping), 0) + self.assertEqual(selection_label.get_key(), (EV_KEY, 30, 1)) + # this is KEY_A in linux/input-event-codes.h, + # but KEY_ is removed from the text for display purposes + self.assertEqual(selection_label.get_label(), "a") + + # providing the same key again (Maybe this could happen for gamepads or + # something, idk) doesn't do any harm + self.editor.consume_newest_keycode(Key(EV_KEY, 30, 1)) + self.assertEqual(len(custom_mapping), 0) # not released yet + self.assertEqual(selection_label.get_key(), (EV_KEY, 30, 1)) + + time.sleep(0.11) + # new empty entry was added + gtk_iteration() + self.assertEqual( + len(self.selection_label_listbox.get_children()), + 2, + ) + + self.set_focus(self.editor.get_text_input()) + self.editor.set_symbol_input_text("Shift_L") + self.set_focus(None) + + self.assertEqual(len(custom_mapping), 1) + + time.sleep(0.1) + gtk_iteration() + self.assertEqual( + len(self.selection_label_listbox.get_children()), + 2, + ) + + self.assertEqual(custom_mapping.get_symbol(Key(EV_KEY, 30, 1)), "Shift_L") + self.assertEqual(self.editor.get_symbol_input_text(), "Shift_L") + self.assertEqual(selection_label.get_key(), (EV_KEY, 30, 1)) + + def test_editor_not_focused(self): + # focus anything that is not the selection_label, + # no keycode should be inserted into it + self.set_focus(self.user_interface.get("preset_name_input")) + send_event_to_reader(new_event(1, 61, 1)) + self.user_interface.consume_newest_keycode() + + selection_labels = self.get_selection_labels() + self.assertEqual(len(selection_labels), 1) + selection_label = selection_labels[0] + + # the empty selection_label has this key not set + self.assertIsNone(selection_label.get_key()) + + # focus the text input instead + self.set_focus(self.editor.get_text_input()) + send_event_to_reader(new_event(1, 61, 1)) + self.user_interface.consume_newest_keycode() + + # still nothing set + self.assertIsNone(selection_label.get_key()) + + def test_show_status(self): + self.user_interface.show_status(0, "a" * 100) + text = self.get_status_text() + self.assertIn("...", text) + + self.user_interface.show_status(0, "b") + text = self.get_status_text() + self.assertNotIn("...", text) + + def test_clears_unreleased_on_focus_change(self): + ev_1 = Key(EV_KEY, 41, 1) + + # focus + self.set_focus(self.toggle) + send_event_to_reader(new_event(*ev_1.keys[0])) + reader.read() + self.assertEqual(reader.get_unreleased_keys(), ev_1) + + # unfocus + # doesn't call reader.clear. Otherwise the super key cannot be mapped, + # because the start menu that opens up would unfocus the user interface + self.set_focus(None) + self.assertEqual(reader.get_unreleased_keys(), ev_1) + + # focus the toggle after selecting a different selection_label. + # It resets the reader + self.editor.add_empty() + self.selection_label_listbox.select_row( + self.selection_label_listbox.get_children()[-1] + ) + self.set_focus(self.toggle) + self.toggle.set_active(True) + + self.assertEqual(reader.get_unreleased_keys(), None) + + def test_editor(self): + """Comprehensive test for the editor.""" + system_mapping.clear() + system_mapping._set("Foo_BAR", 41) + system_mapping._set("B", 42) + system_mapping._set("c", 43) + system_mapping._set("d", 44) + + # how many selection_labels there should be in the end + num_selection_labels_target = 3 + + ev_1 = Key(EV_KEY, 10, 1) + ev_2 = Key(EV_ABS, evdev.ecodes.ABS_HAT0X, -1) + + """edit""" + + # add two selection_labels by modifiying the one empty selection_label that + # exists. Insert lowercase, it should be corrected to uppercase as stored + # in system_mapping + self.add_mapping_via_ui(ev_1, "foo_bar") + self.add_mapping_via_ui(ev_2, "k(b).k(c)") + + # one empty selection_label added automatically again + time.sleep(0.1) + gtk_iteration() + self.assertEqual(len(self.get_selection_labels()), num_selection_labels_target) + + self.assertEqual(custom_mapping.get_symbol(ev_1), "Foo_BAR") + self.assertEqual(custom_mapping.get_symbol(ev_2), "k(b).k(c)") + + """edit first selection_label""" + + self.selection_label_listbox.select_row( + self.selection_label_listbox.get_children()[0] + ) + self.assertEqual(self.editor.get_key(), ev_1) + self.set_focus(self.editor.get_text_input()) + self.editor.set_symbol_input_text("c") + self.set_focus(None) + + # after unfocusing, it stores the mapping. So loading it again will retain + # the mapping that was used + preset_name = self.user_interface.preset_name + preset_path = self.user_interface.group.get_preset_path(preset_name) + custom_mapping.load(preset_path) + + self.assertEqual(custom_mapping.get_symbol(ev_1), "c") + self.assertEqual(custom_mapping.get_symbol(ev_2), "k(b).k(c)") + + """add duplicate""" + + # try to add a duplicate keycode, it should be ignored + self.add_mapping_via_ui(ev_2, "d", expect_success=False) + self.assertEqual(custom_mapping.get_symbol(ev_2), "k(b).k(c)") + # and the number of selection_labels shouldn't change + self.assertEqual(len(self.get_selection_labels()), num_selection_labels_target) + + def test_hat0x(self): + # it should be possible to add all of them + ev_1 = Key(EV_ABS, evdev.ecodes.ABS_HAT0X, -1) + ev_2 = Key(EV_ABS, evdev.ecodes.ABS_HAT0X, 1) + ev_3 = Key(EV_ABS, evdev.ecodes.ABS_HAT0Y, -1) + ev_4 = Key(EV_ABS, evdev.ecodes.ABS_HAT0Y, 1) + + self.add_mapping_via_ui(ev_1, "a") + self.add_mapping_via_ui(ev_2, "b") + self.add_mapping_via_ui(ev_3, "c") + self.add_mapping_via_ui(ev_4, "d") + + self.assertEqual(custom_mapping.get_symbol(ev_1), "a") + self.assertEqual(custom_mapping.get_symbol(ev_2), "b") + self.assertEqual(custom_mapping.get_symbol(ev_3), "c") + self.assertEqual(custom_mapping.get_symbol(ev_4), "d") + + # and trying to add them as duplicate selection_labels will be ignored for each + # of them + self.add_mapping_via_ui(ev_1, "e", expect_success=False) + self.add_mapping_via_ui(ev_2, "f", expect_success=False) + self.add_mapping_via_ui(ev_3, "g", expect_success=False) + self.add_mapping_via_ui(ev_4, "h", expect_success=False) + + self.assertEqual(custom_mapping.get_symbol(ev_1), "a") + self.assertEqual(custom_mapping.get_symbol(ev_2), "b") + self.assertEqual(custom_mapping.get_symbol(ev_3), "c") + self.assertEqual(custom_mapping.get_symbol(ev_4), "d") + + def test_combination(self): + # it should be possible to write a key combination + ev_1 = Key(EV_KEY, evdev.ecodes.KEY_A, 1) + ev_2 = Key(EV_ABS, evdev.ecodes.ABS_HAT0X, 1) + ev_3 = Key(EV_KEY, evdev.ecodes.KEY_C, 1) + ev_4 = Key(EV_ABS, evdev.ecodes.ABS_HAT0X, -1) + combination_1 = Key(ev_1, ev_2, ev_3) + combination_2 = Key(ev_2, ev_1, ev_3) + + # same as 1, but different D-Pad direction + combination_3 = Key(ev_1, ev_4, ev_3) + combination_4 = Key(ev_4, ev_1, ev_3) + + # same as 1, but the last key is different + combination_5 = Key(ev_1, ev_3, ev_2) + combination_6 = Key(ev_3, ev_1, ev_2) + + self.add_mapping_via_ui(combination_1, "a") + self.assertEqual(custom_mapping.get_symbol(combination_1), "a") + self.assertEqual(custom_mapping.get_symbol(combination_2), "a") + self.assertIsNone(custom_mapping.get_symbol(combination_3)) + self.assertIsNone(custom_mapping.get_symbol(combination_4)) + self.assertIsNone(custom_mapping.get_symbol(combination_5)) + self.assertIsNone(custom_mapping.get_symbol(combination_6)) + + # it won't write the same combination again, even if the + # first two events are in a different order + self.add_mapping_via_ui(combination_2, "b", expect_success=False) + self.assertEqual(custom_mapping.get_symbol(combination_1), "a") + self.assertEqual(custom_mapping.get_symbol(combination_2), "a") + self.assertIsNone(custom_mapping.get_symbol(combination_3)) + self.assertIsNone(custom_mapping.get_symbol(combination_4)) + self.assertIsNone(custom_mapping.get_symbol(combination_5)) + self.assertIsNone(custom_mapping.get_symbol(combination_6)) + + self.add_mapping_via_ui(combination_3, "c") + self.assertEqual(custom_mapping.get_symbol(combination_1), "a") + self.assertEqual(custom_mapping.get_symbol(combination_2), "a") + self.assertEqual(custom_mapping.get_symbol(combination_3), "c") + self.assertEqual(custom_mapping.get_symbol(combination_4), "c") + self.assertIsNone(custom_mapping.get_symbol(combination_5)) + self.assertIsNone(custom_mapping.get_symbol(combination_6)) + + # same as with combination_2, the existing combination_3 blocks + # combination_4 because they have the same keys and end in the + # same key. + self.add_mapping_via_ui(combination_4, "d", expect_success=False) + self.assertEqual(custom_mapping.get_symbol(combination_1), "a") + self.assertEqual(custom_mapping.get_symbol(combination_2), "a") + self.assertEqual(custom_mapping.get_symbol(combination_3), "c") + self.assertEqual(custom_mapping.get_symbol(combination_4), "c") + self.assertIsNone(custom_mapping.get_symbol(combination_5)) + self.assertIsNone(custom_mapping.get_symbol(combination_6)) + + self.add_mapping_via_ui(combination_5, "e") + self.assertEqual(custom_mapping.get_symbol(combination_1), "a") + self.assertEqual(custom_mapping.get_symbol(combination_2), "a") + self.assertEqual(custom_mapping.get_symbol(combination_3), "c") + self.assertEqual(custom_mapping.get_symbol(combination_4), "c") + self.assertEqual(custom_mapping.get_symbol(combination_5), "e") + self.assertEqual(custom_mapping.get_symbol(combination_6), "e") + + error_icon = self.user_interface.get("error_status_icon") + warning_icon = self.user_interface.get("warning_status_icon") + + self.assertFalse(error_icon.get_visible()) + self.assertFalse(warning_icon.get_visible()) + + def test_remove_selection_label(self): + """Comprehensive test for selection_labels 2.""" + + def remove(selection_label, code, symbol, num_selection_labels_after): + """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 + """ + self.selection_label_listbox.select_row(selection_label) + + if code is not None and symbol is not None: + self.assertEqual( + custom_mapping.get_symbol(Key(EV_KEY, code, 1)), + symbol, + ) + + if symbol is not None: + self.assertEqual(self.editor.get_symbol_input_text(), symbol) + + if code is None: + self.assertIsNone(selection_label.get_key()) + else: + self.assertEqual(selection_label.get_key(), Key(EV_KEY, code, 1)) + + with PatchedConfirmDelete(self.user_interface): + self.editor._on_delete_button_clicked() + + time.sleep(0.2) + gtk_iteration() + + # if a reference to the selection_label is held somewhere and it is + # accidentally used again, make sure to not provide any outdated + # information that is supposed to be deleted + self.assertIsNone(selection_label.get_key()) + if code is not None: + self.assertIsNone(custom_mapping.get_symbol(Key(EV_KEY, code, 1))) + + self.assertEqual( + len(self.get_selection_labels()), + num_selection_labels_after, + ) + + # sleeps are added to be able to visually follow and debug the test. Add two + # selection_labels by modifiying the one empty selection_label that exists + selection_label_1 = self.add_mapping_via_ui(Key(EV_KEY, 10, 1), "a") + selection_label_2 = self.add_mapping_via_ui(Key(EV_KEY, 11, 1), "b") + + # no empty selection_label added because one is unfinished + time.sleep(0.2) + gtk_iteration() + self.assertEqual(len(self.get_selection_labels()), 3) + + self.assertEqual(custom_mapping.get_symbol(Key(EV_KEY, 11, 1)), "b") + + remove(selection_label_1, 10, "a", 2) + remove(selection_label_2, 11, "b", 1) + + # there is no empty selection_label at the moment, so after removing that one, + # which is the only selection_label, one empty selection_label will be there. + # So the number of selection_labels won't change. + remove(self.selection_label_listbox.get_children()[-1], None, None, 1) + + def test_problematic_combination(self): + combination = Key((EV_KEY, KEY_LEFTSHIFT, 1), (EV_KEY, 82, 1)) + self.add_mapping_via_ui(combination, "b") + text = self.get_status_text() + self.assertIn("shift", text) + + error_icon = self.user_interface.get("error_status_icon") + warning_icon = self.user_interface.get("warning_status_icon") + + self.assertFalse(error_icon.get_visible()) + self.assertTrue(warning_icon.get_visible()) + + def test_rename_and_save(self): + self.assertEqual(self.user_interface.group.name, "Foo Device") + self.assertFalse(config.is_autoloaded("Foo Device", "new preset")) + + custom_mapping.change(Key(EV_KEY, 14, 1), "a", None) + self.assertEqual(self.user_interface.preset_name, "new preset") + self.user_interface.save_preset() + self.assertEqual(custom_mapping.get_symbol(Key(EV_KEY, 14, 1)), "a") + config.set_autoload_preset("Foo Device", "new preset") + self.assertTrue(config.is_autoloaded("Foo Device", "new preset")) + + custom_mapping.change(Key(EV_KEY, 14, 1), "b", None) + self.user_interface.get("preset_name_input").set_text("asdf") + self.user_interface.save_preset() + self.user_interface.on_rename_button_clicked(None) + self.assertEqual(self.user_interface.preset_name, "asdf") + preset_path = f"{CONFIG_PATH}/presets/Foo Device/asdf.json" + self.assertTrue(os.path.exists(preset_path)) + self.assertEqual(custom_mapping.get_symbol(Key(EV_KEY, 14, 1)), "b") + + # after renaming the preset it is still set to autoload + self.assertTrue(config.is_autoloaded("Foo Device", "asdf")) + # ALSO IN THE ACTUAL CONFIG FILE! + config.load_config() + self.assertTrue(config.is_autoloaded("Foo Device", "asdf")) + + error_icon = self.user_interface.get("error_status_icon") + self.assertFalse(error_icon.get_visible()) + + # otherwise save won't do anything + custom_mapping.change(Key(EV_KEY, 14, 1), "c", None) + self.assertTrue(custom_mapping.has_unsaved_changes()) + + def save(_): + raise PermissionError + + with patch.object(custom_mapping, "save", save): + self.user_interface.save_preset() + status = self.get_status_text() + self.assertIn("Permission denied", status) + + with PatchedConfirmDelete(self.user_interface): + self.user_interface.on_delete_preset_clicked(None) + self.assertFalse(os.path.exists(preset_path)) + + def test_rename_create_switch(self): + # after renaming a preset and saving it, new presets + # start with "new preset" again + custom_mapping.change(Key(EV_KEY, 14, 1), "a", None) + self.user_interface.get("preset_name_input").set_text("asdf") + self.user_interface.save_preset() + self.user_interface.on_rename_button_clicked(None) + self.assertEqual(len(custom_mapping), 1) + self.assertEqual(self.user_interface.preset_name, "asdf") + + self.user_interface.on_create_preset_clicked() + self.assertEqual(self.user_interface.preset_name, "new preset") + self.assertEqual(len(self.selection_label_listbox.get_children()), 1) + self.assertEqual(len(custom_mapping), 0) + self.user_interface.save_preset() + + # symbol and code in the gui won't be carried over after selecting a preset + self.editor.set_key(Key(EV_KEY, 15, 1)) + self.editor.set_symbol_input_text("b") + + # selecting the first preset again loads the saved mapping, and saves + # the current changes in the gui + self.user_interface.on_select_preset(FakePresetDropdown("asdf")) + self.assertEqual(custom_mapping.get_symbol(Key(EV_KEY, 14, 1)), "a") + self.assertEqual(len(custom_mapping), 1) + self.assertEqual(len(self.selection_label_listbox.get_children()), 2) + config.set_autoload_preset("Foo Device", "new preset") + + # renaming a preset to an existing name appends a number + self.user_interface.on_select_preset(FakePresetDropdown("new preset")) + self.user_interface.get("preset_name_input").set_text("asdf") + self.user_interface.on_rename_button_clicked(None) + self.assertEqual(self.user_interface.preset_name, "asdf 2") + # and that added number is correctly used in the autoload + # configuration as well + self.assertTrue(config.is_autoloaded("Foo Device", "asdf 2")) + self.assertEqual(custom_mapping.get_symbol(Key(EV_KEY, 15, 1)), "b") + self.assertEqual(len(custom_mapping), 1) + self.assertEqual(len(self.selection_label_listbox.get_children()), 2) + + self.assertEqual(self.user_interface.get("preset_name_input").get_text(), "") + + # renaming the current preset to itself doesn't append a number and + # it doesn't do anything on the file system + def _raise(*_): + # should not get called + raise AssertionError + + with patch.object(os, "rename", _raise): + self.user_interface.get("preset_name_input").set_text("asdf 2") + self.user_interface.on_rename_button_clicked(None) + self.assertEqual(self.user_interface.preset_name, "asdf 2") + + self.user_interface.get("preset_name_input").set_text("") + self.user_interface.on_rename_button_clicked(None) + self.assertEqual(self.user_interface.preset_name, "asdf 2") + + def test_avoids_redundant_saves(self): + custom_mapping.change(Key(EV_KEY, 14, 1), "abcd", None) + + custom_mapping.set_has_unsaved_changes(False) + self.user_interface.save_preset() + + with open(get_preset_path("Foo Device", "new preset")) as f: + content = f.read() + self.assertNotIn("abcd", content) + + custom_mapping.set_has_unsaved_changes(True) + self.user_interface.save_preset() + + with open(get_preset_path("Foo Device", "new preset")) as f: + content = f.read() + self.assertIn("abcd", content) + + def test_check_for_unknown_symbols(self): + status = self.user_interface.get("status_bar") + error_icon = self.user_interface.get("error_status_icon") + warning_icon = self.user_interface.get("warning_status_icon") + + custom_mapping.change(Key(EV_KEY, 71, 1), "qux", None) + custom_mapping.change(Key(EV_KEY, 72, 1), "foo", None) + self.user_interface.save_preset() + tooltip = status.get_tooltip_text().lower() + self.assertIn("qux", tooltip) + self.assertTrue(error_icon.get_visible()) + self.assertFalse(warning_icon.get_visible()) + + # it will still save it though + with open(get_preset_path("Foo Device", "new preset")) as f: + content = f.read() + self.assertIn("qux", content) + self.assertIn("foo", content) + + custom_mapping.change(Key(EV_KEY, 71, 1), "a", None) + self.user_interface.save_preset() + tooltip = status.get_tooltip_text().lower() + self.assertIn("foo", tooltip) + self.assertTrue(error_icon.get_visible()) + self.assertFalse(warning_icon.get_visible()) + + custom_mapping.change(Key(EV_KEY, 72, 1), "b", None) + self.user_interface.save_preset() + tooltip = status.get_tooltip_text() + self.assertIsNone(tooltip) + self.assertFalse(error_icon.get_visible()) + self.assertFalse(warning_icon.get_visible()) + + def test_check_macro_syntax(self): + status = self.user_interface.get("status_bar") + error_icon = self.user_interface.get("error_status_icon") + warning_icon = self.user_interface.get("warning_status_icon") + + custom_mapping.change(Key(EV_KEY, 9, 1), "k(1))", None) + self.user_interface.save_preset() + tooltip = status.get_tooltip_text().lower() + self.assertIn("brackets", tooltip) + self.assertTrue(error_icon.get_visible()) + self.assertFalse(warning_icon.get_visible()) + + custom_mapping.change(Key(EV_KEY, 9, 1), "k(1)", None) + self.user_interface.save_preset() + tooltip = (status.get_tooltip_text() or "").lower() + self.assertNotIn("brackets", tooltip) + self.assertFalse(error_icon.get_visible()) + self.assertFalse(warning_icon.get_visible()) + + self.assertEqual(custom_mapping.get_symbol(Key(EV_KEY, 9, 1)), "k(1)") + + def test_select_device_and_preset(self): + foo_device_path = f"{CONFIG_PATH}/presets/Foo Device" + key_10 = Key(EV_KEY, 10, 1) + key_11 = Key(EV_KEY, 11, 1) + + # created on start because the first device is selected and some empty + # preset prepared. + self.assertTrue(os.path.exists(f"{foo_device_path}/new preset.json")) + self.assertEqual(self.user_interface.group.name, "Foo Device") + self.assertEqual(self.user_interface.preset_name, "new preset") + # change it to check if the gui loads presets correctly later + self.editor.set_key(key_10) + self.editor.set_symbol_input_text("a") + + # create another one + self.user_interface.on_create_preset_clicked() + gtk_iteration() + self.assertTrue(os.path.exists(f"{foo_device_path}/new preset.json")) + self.assertTrue(os.path.exists(f"{foo_device_path}/new preset 2.json")) + self.assertEqual(self.user_interface.preset_name, "new preset 2") + self.assertEqual(len(custom_mapping), 0) + # this should not be loaded when "new preset" is selected, because it belongs + # to "new preset 2": + self.editor.set_key(key_11) + self.editor.set_symbol_input_text("a") + + # select the first one again + self.user_interface.on_select_preset(FakePresetDropdown("new preset")) + gtk_iteration() + self.assertEqual(self.user_interface.preset_name, "new preset") + + self.assertEqual(len(custom_mapping), 1) + self.assertEqual(custom_mapping.get_symbol(key_10), "a") + + self.assertListEqual( + sorted(os.listdir(f"{foo_device_path}")), + sorted(["new preset.json", "new preset 2.json"]), + ) + + """now try to change the name""" + + self.user_interface.get("preset_name_input").set_text("abc 123") + gtk_iteration() + self.assertEqual(self.user_interface.preset_name, "new preset") + self.assertFalse(os.path.exists(f"{foo_device_path}/abc 123.json")) + + # putting new information into the editor does not lead to some weird + # problems. when doing the rename everything will be saved and then moved + # to the new path + self.editor.set_key(Key(EV_KEY, 10, 1)) + self.editor.set_symbol_input_text("1") + + self.assertEqual(self.user_interface.preset_name, "new preset") + self.user_interface.on_rename_button_clicked(None) + self.assertEqual(self.user_interface.preset_name, "abc 123") + + gtk_iteration() + self.assertEqual(self.user_interface.preset_name, "abc 123") + self.assertTrue(os.path.exists(f"{foo_device_path}/abc 123.json")) + self.assertListEqual( + sorted(os.listdir(os.path.join(CONFIG_PATH, "presets"))), + sorted(["Foo Device"]), + ) + self.assertListEqual( + sorted(os.listdir(f"{foo_device_path}")), + sorted(["abc 123.json", "new preset 2.json"]), + ) + + def test_copy_preset(self): + selection_labels = self.selection_label_listbox + self.add_mapping_via_ui(Key(EV_KEY, 81, 1), "a") + time.sleep(0.1) + gtk_iteration() + self.user_interface.save_preset() + # 2 selection_labels: the changed selection_label and an empty selection_label + self.assertEqual(len(selection_labels.get_children()), 2) + + # should be cleared when creating a new preset + custom_mapping.set("a.b", 3) + self.assertEqual(custom_mapping.get("a.b"), 3) + + self.user_interface.on_create_preset_clicked() + + # the preset should be empty, only one empty selection_label present + self.assertEqual(len(selection_labels.get_children()), 1) + self.assertIsNone(custom_mapping.get("a.b")) + + # add one new selection_label again and a setting + self.add_mapping_via_ui(Key(EV_KEY, 81, 1), "b") + time.sleep(0.1) + gtk_iteration() + self.user_interface.save_preset() + self.assertEqual(len(selection_labels.get_children()), 2) + custom_mapping.set(["foo", "bar"], 2) + + # this time it should be copied + self.user_interface.on_copy_preset_clicked() + self.assertEqual(self.user_interface.preset_name, "new preset 2 copy") + self.assertEqual(len(selection_labels.get_children()), 2) + self.assertEqual(self.editor.get_symbol_input_text(), "b") + self.assertEqual(custom_mapping.get(["foo", "bar"]), 2) + + # make another copy + self.user_interface.on_copy_preset_clicked() + self.assertEqual(self.user_interface.preset_name, "new preset 2 copy 2") + self.assertEqual(len(selection_labels.get_children()), 2) + self.assertEqual(self.editor.get_symbol_input_text(), "b") + self.assertEqual(len(custom_mapping), 1) + self.assertEqual(custom_mapping.get("foo.bar"), 2) + + def test_gamepad_config(self): + # set some stuff in the beginning, otherwise gtk fails to + # do handler_unblock_by_func, which makes no sense at all. + # but it ONLY fails on right_joystick_purpose for some reason, + # unblocking the left one works just fine. I should open a bug report + # on gtk or something probably. + self.user_interface.get("left_joystick_purpose").set_active_id(BUTTONS) + self.user_interface.get("right_joystick_purpose").set_active_id(BUTTONS) + self.user_interface.get("joystick_mouse_speed").set_value(1) + custom_mapping.set_has_unsaved_changes(False) + + # select a device that is not a gamepad + self.user_interface.on_select_device(FakeDeviceDropdown("Foo Device")) + self.assertFalse(self.user_interface.get("gamepad_config").is_visible()) + self.assertFalse(custom_mapping.has_unsaved_changes()) + + # select a gamepad + self.user_interface.on_select_device(FakeDeviceDropdown("gamepad")) + self.assertTrue(self.user_interface.get("gamepad_config").is_visible()) + self.assertFalse(custom_mapping.has_unsaved_changes()) + + # set stuff + gtk_iteration() + self.user_interface.get("left_joystick_purpose").set_active_id(WHEEL) + self.user_interface.get("right_joystick_purpose").set_active_id(WHEEL) + joystick_mouse_speed = 5 + self.user_interface.get("joystick_mouse_speed").set_value(joystick_mouse_speed) + + # it should be stored in custom_mapping, which overwrites the + # global config + config.set("gamepad.joystick.left_purpose", MOUSE) + config.set("gamepad.joystick.right_purpose", MOUSE) + config.set("gamepad.joystick.pointer_speed", 50) + self.assertTrue(custom_mapping.has_unsaved_changes()) + left_purpose = custom_mapping.get("gamepad.joystick.left_purpose") + right_purpose = custom_mapping.get("gamepad.joystick.right_purpose") + pointer_speed = custom_mapping.get("gamepad.joystick.pointer_speed") + self.assertEqual(left_purpose, WHEEL) + self.assertEqual(right_purpose, WHEEL) + self.assertEqual(pointer_speed, 2 ** joystick_mouse_speed) + + # select a device that is not a gamepad again + self.user_interface.on_select_device(FakeDeviceDropdown("Foo Device")) + self.assertFalse(self.user_interface.get("gamepad_config").is_visible()) + self.assertFalse(custom_mapping.has_unsaved_changes()) + + def test_wont_start(self): + error_icon = self.user_interface.get("error_status_icon") + preset_name = "foo preset" + group_name = "Bar Device" + self.user_interface.preset_name = preset_name + self.user_interface.group = groups.find(name=group_name) + + # empty + + custom_mapping.empty() + self.user_interface.save_preset() + self.user_interface.on_apply_preset_clicked(None) + text = self.get_status_text() + self.assertIn("add keys", text) + self.assertTrue(error_icon.get_visible()) + self.assertNotEqual( + self.user_interface.dbus.get_state(self.user_interface.group.key), RUNNING + ) + + # not empty, but keys are held down + + custom_mapping.change(Key(EV_KEY, KEY_A, 1), "a") + self.user_interface.save_preset() + send_event_to_reader(new_event(EV_KEY, KEY_A, 1)) + reader.read() + self.assertEqual(len(reader._unreleased), 1) + self.assertFalse(self.user_interface.unreleased_warn) + self.user_interface.on_apply_preset_clicked(None) + text = self.get_status_text() + self.assertIn("release", text) + self.assertTrue(error_icon.get_visible()) + self.assertNotEqual( + self.user_interface.dbus.get_state(self.user_interface.group.key), RUNNING + ) + self.assertTrue(self.user_interface.unreleased_warn) + self.assertEqual( + self.user_interface.get("apply_system_layout").get_opacity(), 0.4 + ) + + # device grabbing fails + + def wait(): + """Wait for the injector process to finish doing stuff.""" + for _ in range(10): + time.sleep(0.1) + gtk_iteration() + if "Starting" not in self.get_status_text(): + return + + for i in range(2): + # just pressing apply again will overwrite the previous error + self.grab_fails = True + self.user_interface.on_apply_preset_clicked(None) + self.assertFalse(self.user_interface.unreleased_warn) + text = self.get_status_text() + # it takes a little bit of time + self.assertIn("Starting injection", text) + self.assertFalse(error_icon.get_visible()) + wait() + text = self.get_status_text() + self.assertIn("not grabbed", text) + self.assertTrue(error_icon.get_visible()) + self.assertNotEqual( + self.user_interface.dbus.get_state(self.user_interface.group.key), + RUNNING, + ) + + # for the second try, release the key. that should also work + send_event_to_reader(new_event(EV_KEY, KEY_A, 0)) + reader.read() + self.assertEqual(len(reader._unreleased), 0) + + # this time work properly + + self.grab_fails = False + custom_mapping.save(get_preset_path(group_name, preset_name)) + self.user_interface.on_apply_preset_clicked(None) + text = self.get_status_text() + self.assertIn("Starting injection", text) + self.assertFalse(error_icon.get_visible()) + wait() + text = self.get_status_text() + self.assertIn("Applied", text) + text = self.get_status_text() + self.assertNotIn("CTRL + DEL", text) # only shown if btn_left mapped + self.assertFalse(error_icon.get_visible()) + self.assertEqual( + self.user_interface.dbus.get_state(self.user_interface.group.key), RUNNING + ) + + # because this test managed to reproduce some minor bug: + self.assertNotIn("mapping", custom_mapping._config) + + def test_wont_start_2(self): + preset_name = "foo preset" + group_name = "Bar Device" + self.user_interface.preset_name = preset_name + self.user_interface.group = groups.find(name=group_name) + + def wait(): + """Wait for the injector process to finish doing stuff.""" + for _ in range(10): + time.sleep(0.1) + gtk_iteration() + if "Starting" not in self.get_status_text(): + return + + # btn_left mapped + custom_mapping.change(Key.btn_left(), "a") + self.user_interface.save_preset() + + # and key held down + send_event_to_reader(new_event(EV_KEY, KEY_A, 1)) + reader.read() + self.assertEqual(len(reader._unreleased), 1) + self.assertFalse(self.user_interface.unreleased_warn) + + # first apply, shows btn_left warning + self.user_interface.on_apply_preset_clicked(None) + text = self.get_status_text() + self.assertIn("click", text) + self.assertEqual( + self.user_interface.dbus.get_state(self.user_interface.group.key), UNKNOWN + ) + + # second apply, shows unreleased warning + self.user_interface.on_apply_preset_clicked(None) + text = self.get_status_text() + self.assertIn("release", text) + self.assertEqual( + self.user_interface.dbus.get_state(self.user_interface.group.key), UNKNOWN + ) + + # third apply, overwrites both warnings + self.user_interface.on_apply_preset_clicked(None) + wait() + self.assertEqual( + self.user_interface.dbus.get_state(self.user_interface.group.key), RUNNING + ) + text = self.get_status_text() + # because btn_left is mapped, shows help on how to stop + # injecting via the keyboard + self.assertIn("CTRL + DEL", text) + + def test_can_modify_mapping(self): + preset_name = "foo preset" + group_name = "Bar Device" + self.user_interface.preset_name = preset_name + self.user_interface.group = groups.find(name=group_name) + + self.assertNotEqual( + self.user_interface.dbus.get_state(self.user_interface.group.key), RUNNING + ) + self.user_interface.can_modify_mapping() + text = self.get_status_text() + self.assertNotIn("Stop Injection", text) + + custom_mapping.change(Key(EV_KEY, KEY_A, 1), "b") + custom_mapping.save(get_preset_path(group_name, preset_name)) + self.user_interface.on_apply_preset_clicked(None) + + # wait for the injector to start + for _ in range(10): + time.sleep(0.1) + gtk_iteration() + if "Starting" not in self.get_status_text(): + return + + self.assertEqual( + self.user_interface.dbus.get_state(self.user_interface.group.key), RUNNING + ) + + # the mapping cannot be changed anymore + self.user_interface.can_modify_mapping() + text = self.get_status_text() + self.assertIn("Stop Injection", text) + + def test_start_injecting(self): + keycode_from = 9 + keycode_to = 200 + + self.add_mapping_via_ui(Key(EV_KEY, keycode_from, 1), "a") + system_mapping.clear() + system_mapping._set("a", keycode_to) + + push_events( + "Foo Device 2", + [ + new_event(evdev.events.EV_KEY, keycode_from, 1), + new_event(evdev.events.EV_KEY, keycode_from, 0), + ], + ) + + # injecting for group.key will look at paths containing group.name + custom_mapping.save(get_preset_path("Foo Device", "foo preset")) + + # use only the manipulated system_mapping + if os.path.exists(os.path.join(tmp, XMODMAP_FILENAME)): + os.remove(os.path.join(tmp, XMODMAP_FILENAME)) + + # select the second Foo device + self.user_interface.group = groups.find(key="Foo Device 2") + + with spy(self.user_interface.dbus, "set_config_dir") as spy1: + self.user_interface.preset_name = "foo preset" + + with spy(self.user_interface.dbus, "start_injecting") as spy2: + self.user_interface.on_apply_preset_clicked(None) + # correctly uses group.key, not group.name + spy2.assert_called_once_with("Foo Device 2", "foo preset") + + spy1.assert_called_once_with(get_config_path()) + + # the integration tests will cause the injection to be started as + # processes, as intended. Luckily, recv will block until the events + # are handled and pushed. + + # Note, that appending events to pending_events won't work anymore + # from here on because the injector processes memory cannot be + # modified from here. + + event = uinput_write_history_pipe[0].recv() + self.assertEqual(event.type, evdev.events.EV_KEY) + self.assertEqual(event.code, keycode_to) + self.assertEqual(event.value, 1) + + event = uinput_write_history_pipe[0].recv() + self.assertEqual(event.type, evdev.events.EV_KEY) + self.assertEqual(event.code, keycode_to) + self.assertEqual(event.value, 0) + + # the input-remapper device will not be shown + groups.refresh() + self.user_interface.populate_devices() + for entry in self.user_interface.device_store: + # whichever attribute contains "input-remapper" + self.assertNotIn("input-remapper", "".join(entry)) + + def test_gamepad_purpose_mouse_and_button(self): + self.user_interface.on_select_device(FakeDeviceDropdown("gamepad")) + self.user_interface.get("right_joystick_purpose").set_active_id(MOUSE) + self.user_interface.get("left_joystick_purpose").set_active_id(BUTTONS) + self.user_interface.get("joystick_mouse_speed").set_value(6) + gtk_iteration() + speed = custom_mapping.get("gamepad.joystick.pointer_speed") + custom_mapping.set("gamepad.joystick.non_linearity", 1) + self.assertEqual(speed, 2 ** 6) + + # don't consume the events in the reader, they are used to test + # the injection + reader.terminate() + time.sleep(0.1) + + push_events( + "gamepad", + [new_event(EV_ABS, ABS_RX, MIN_ABS), new_event(EV_ABS, ABS_X, MAX_ABS)] + * 100, + ) + + custom_mapping.change(Key(EV_ABS, ABS_X, 1), "a") + self.user_interface.save_preset() + + gtk_iteration() + + self.user_interface.on_apply_preset_clicked(None) + time.sleep(0.3) + + history = [] + while uinput_write_history_pipe[0].poll(): + history.append(uinput_write_history_pipe[0].recv().t) + + count_mouse = history.count((EV_REL, REL_X, -speed)) + count_button = history.count((EV_KEY, KEY_A, 1)) + self.assertGreater(count_mouse, 1) + self.assertEqual(count_button, 1) + self.assertEqual(count_button + count_mouse, len(history)) + + self.assertIn("gamepad", self.user_interface.dbus.injectors) + + def test_stop_injecting(self): + keycode_from = 16 + keycode_to = 90 + + self.add_mapping_via_ui(Key(EV_KEY, keycode_from, 1), "t") + system_mapping.clear() + system_mapping._set("t", keycode_to) + + # not all of those events should be processed, since that takes some + # time due to time.sleep in the fakes and the injection is stopped. + push_events("Bar Device", [new_event(1, keycode_from, 1)] * 100) + + custom_mapping.save(get_preset_path("Bar Device", "foo preset")) + + self.user_interface.group = groups.find(name="Bar Device") + self.user_interface.preset_name = "foo preset" + self.user_interface.on_apply_preset_clicked(None) + + pipe = uinput_write_history_pipe[0] + # block until the first event is available, indicating that + # the injector is ready + write_history = [pipe.recv()] + + # stop + self.user_interface.on_restore_defaults_clicked(None) + + # try to receive a few of the events + time.sleep(0.2) + while pipe.poll(): + write_history.append(pipe.recv()) + + len_before = len(write_history) + self.assertLess(len(write_history), 50) + + # since the injector should not be running anymore, no more events + # should be received after waiting even more time + time.sleep(0.2) + while pipe.poll(): + write_history.append(pipe.recv()) + self.assertEqual(len(write_history), len_before) + + def test_delete_preset(self): + self.editor.set_key(Key(EV_KEY, 71, 1)) + self.editor.set_symbol_input_text("a") + self.user_interface.get("preset_name_input").set_text("asdf") + self.user_interface.on_rename_button_clicked(None) + gtk_iteration() + self.assertEqual(self.user_interface.preset_name, "asdf") + self.assertEqual(len(custom_mapping), 1) + self.user_interface.save_preset() + self.assertTrue(os.path.exists(get_preset_path("Foo Device", "asdf"))) + + with PatchedConfirmDelete(self.user_interface, Gtk.ResponseType.CANCEL): + self.user_interface.on_delete_preset_clicked(None) + self.assertTrue(os.path.exists(get_preset_path("Foo Device", "asdf"))) + self.assertEqual(self.user_interface.preset_name, "asdf") + self.assertEqual(self.user_interface.group.name, "Foo Device") + + with PatchedConfirmDelete(self.user_interface): + self.user_interface.on_delete_preset_clicked(None) + self.assertFalse(os.path.exists(get_preset_path("Foo Device", "asdf"))) + self.assertEqual(self.user_interface.preset_name, "new preset") + self.assertEqual(self.user_interface.group.name, "Foo Device") + + def test_populate_devices(self): + preset_selection = self.user_interface.get("preset_selection") + + # create two presets + self.user_interface.get("preset_name_input").set_text("preset 1") + self.user_interface.on_rename_button_clicked(None) + self.assertEqual(preset_selection.get_active_id(), "preset 1") + + # to make sure the next preset has a slightly higher timestamp + time.sleep(0.1) + self.user_interface.on_create_preset_clicked() + self.user_interface.get("preset_name_input").set_text("preset 2") + self.user_interface.on_rename_button_clicked(None) + self.assertEqual(preset_selection.get_active_id(), "preset 2") + + # select the older one + preset_selection.set_active_id("preset 1") + self.assertEqual(self.user_interface.preset_name, "preset 1") + + # add a device that doesn't exist to the dropdown + unknown_key = "key-1234" + self.user_interface.device_store.insert(0, [unknown_key, None, "foo"]) + + self.user_interface.populate_devices() + # the newest preset should be selected + self.assertEqual(self.user_interface.preset_name, "preset 2") + + # the list contains correct entries + # and the non-existing entry should be removed + entries = [tuple(entry) for entry in self.user_interface.device_store] + keys = [entry[0] for entry in self.user_interface.device_store] + self.assertNotIn(unknown_key, keys) + self.assertIn("Foo Device", keys) + self.assertIn(("Foo Device", "input-keyboard", "Foo Device"), entries) + self.assertIn(("Foo Device 2", "input-mouse", "Foo Device 2"), entries) + self.assertIn(("Bar Device", "input-keyboard", "Bar Device"), entries) + self.assertIn(("gamepad", "input-gaming", "gamepad"), entries) + + # it won't crash due to "list index out of range" + # when `types` is an empty list. Won't show an icon + groups.find(key="Foo Device 2").types = [] + self.user_interface.populate_devices() + self.assertIn( + ("Foo Device 2", None, "Foo Device 2"), + [tuple(entry) for entry in self.user_interface.device_store], + ) + + def test_shared_presets(self): + # devices with the same name (but different key because the key is + # unique) share the same presets. + # Those devices would usually be of the same model of keyboard for example + + # 1. create a preset + self.user_interface.on_select_device(FakeDeviceDropdown("Foo Device 2")) + self.user_interface.on_create_preset_clicked() + self.add_mapping_via_ui(Key(3, 2, 1), "qux") + self.user_interface.get("preset_name_input").set_text("asdf") + self.user_interface.on_rename_button_clicked(None) + self.user_interface.save_preset() + self.assertIn("asdf.json", os.listdir(get_preset_path("Foo Device"))) + + # 2. switch to the different device, there should be no preset named asdf + self.user_interface.on_select_device(FakeDeviceDropdown("Bar Device")) + self.assertEqual(self.user_interface.preset_name, "new preset") + self.assertNotIn("asdf.json", os.listdir(get_preset_path("Bar Device"))) + self.assertEqual(self.editor.get_symbol_input_text(), "") + + # 3. switch to the device with the same name as the first one + self.user_interface.on_select_device(FakeDeviceDropdown("Foo Device")) + # the newest preset is asdf, it should be automatically selected + self.assertEqual(self.user_interface.preset_name, "asdf") + self.assertEqual(self.editor.get_symbol_input_text(), "qux") + + def test_delete_last_preset(self): + with PatchedConfirmDelete(self.user_interface): + # add some rows + for code in range(3): + self.add_mapping_via_ui(Key(1, code, 1), "qux") + + self.user_interface.on_delete_preset_clicked() + # the ui should be clear now + self.test_gui_clean() + device_path = f"{CONFIG_PATH}/presets/{self.user_interface.group.key}" + self.assertTrue(os.path.exists(f"{device_path}/new preset.json")) + + self.user_interface.on_delete_preset_clicked() + # deleting an empty preset als doesn't do weird stuff + self.test_gui_clean() + device_path = f"{CONFIG_PATH}/presets/{self.user_interface.group.key}" + self.assertTrue(os.path.exists(f"{device_path}/new preset.json")) + + def test_enable_disable_symbol_input(self): + self.editor.disable_symbol_input() + self.assertEqual(self.get_unfiltered_symbol_input_text(), SET_KEY_FIRST) + self.assertFalse(self.editor.get_text_input().get_sensitive()) + + self.editor.enable_symbol_input() + self.assertEqual(self.get_unfiltered_symbol_input_text(), "") + self.assertTrue(self.editor.get_text_input().get_sensitive()) + + # it wouldn't clear user input, if for whatever reason (a bug?) there is user + # input in there when enable_symbol_input is called. + self.editor.set_symbol_input_text("foo") + self.editor.enable_symbol_input() + self.assertEqual(self.get_unfiltered_symbol_input_text(), "foo") + + +class TestAutocompletion(GuiTestBase, unittest.TestCase): + def press_key(self, keyval): + event = Gdk.EventKey() + event.keyval = keyval + self.editor.autocompletion.navigate(None, event) + + def test_autocomplete_key(self): + self.add_mapping_via_ui(Key(1, 99, 1), "") + source_view = self.editor.get_text_input() + self.set_focus(source_view) + + complete_key_name = "Test_Foo_Bar" + + system_mapping.clear() + system_mapping._set(complete_key_name, 1) + + # it can autocomplete a key inbetween other things + incomplete = "qux_1\n + + qux_2" + Gtk.TextView.do_insert_at_cursor(source_view, incomplete) + Gtk.TextView.do_move_cursor( + source_view, + Gtk.MovementStep.VISUAL_POSITIONS, + -8, + False, + ) + Gtk.TextView.do_insert_at_cursor(source_view, "foo") + + time.sleep(0.11) + gtk_iteration() + + autocompletion = self.editor.autocompletion + self.assertTrue(autocompletion.visible) + + self.press_key(Gdk.KEY_Down) + self.press_key(Gdk.KEY_Return) + + # the first suggestion should have been selected + modified_symbol = self.editor.get_symbol_input_text().strip() + self.assertEqual(modified_symbol, f"qux_1\n + {complete_key_name} + qux_2") + + # try again, but a whitespace completes the word and so no autocompletion + # should be shown + Gtk.TextView.do_insert_at_cursor(source_view, " + foo ") + + time.sleep(0.11) + gtk_iteration() + + self.assertFalse(autocompletion.visible) + + def test_autocomplete_function(self): + self.add_mapping_via_ui(Key(1, 99, 1), "") + source_view = self.editor.get_text_input() + self.set_focus(source_view) + + incomplete = "key(KEY_A).\nepea" + Gtk.TextView.do_insert_at_cursor(source_view, incomplete) + + time.sleep(0.11) + gtk_iteration() + + autocompletion = self.editor.autocompletion + self.assertTrue(autocompletion.visible) + + self.press_key(Gdk.KEY_Down) + self.press_key(Gdk.KEY_Return) + + # the first suggestion should have been selected + modified_symbol = self.editor.get_symbol_input_text().strip() + self.assertEqual(modified_symbol, "key(KEY_A).\nrepeat") + + def test_close_autocompletion(self): + self.add_mapping_via_ui(Key(1, 99, 1), "") + source_view = self.editor.get_text_input() + self.set_focus(source_view) + + Gtk.TextView.do_insert_at_cursor(source_view, "KEY_") + + time.sleep(0.11) + gtk_iteration() + + autocompletion = self.editor.autocompletion + self.assertTrue(autocompletion.visible) + + self.press_key(Gdk.KEY_Down) + self.press_key(Gdk.KEY_Escape) + + self.assertFalse(autocompletion.visible) + + symbol = self.editor.get_symbol_input_text().strip() + self.assertEqual(symbol, "KEY_") + + def test_writing_still_works(self): + self.add_mapping_via_ui(Key(1, 99, 1), "") + source_view = self.editor.get_text_input() + self.set_focus(source_view) + + Gtk.TextView.do_insert_at_cursor(source_view, "KEY_") + + autocompletion = self.editor.autocompletion + + time.sleep(0.11) + gtk_iteration() + self.assertTrue(autocompletion.visible) + + # writing still works while an entry is selected + self.press_key(Gdk.KEY_Down) + + Gtk.TextView.do_insert_at_cursor(source_view, "A") + + time.sleep(0.11) + gtk_iteration() + self.assertTrue(autocompletion.visible) + + Gtk.TextView.do_insert_at_cursor(source_view, "1234foobar") + + time.sleep(0.11) + gtk_iteration() + # no key matches this completion, so it closes again + self.assertFalse(autocompletion.visible) + + def test_cycling(self): + self.add_mapping_via_ui(Key(1, 99, 1), "") + source_view = self.editor.get_text_input() + self.set_focus(source_view) + + Gtk.TextView.do_insert_at_cursor(source_view, "KEY_") + + autocompletion = self.editor.autocompletion + + time.sleep(0.11) + gtk_iteration() + self.assertTrue(autocompletion.visible) + + self.assertEqual( + autocompletion.scrolled_window.get_vadjustment().get_value(), 0 + ) + + # cycle to the end of the list because there is no element higher than index 0 + self.press_key(Gdk.KEY_Up) + self.assertGreater( + autocompletion.scrolled_window.get_vadjustment().get_value(), 0 + ) + + # go back to the start, because it can't go down further + self.press_key(Gdk.KEY_Down) + self.assertEqual( + autocompletion.scrolled_window.get_vadjustment().get_value(), 0 + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/testcases/test_injector.py b/tests/testcases/test_injector.py index 0a04e06f..f54d70ca 100644 --- a/tests/testcases/test_injector.py +++ b/tests/testcases/test_injector.py @@ -58,9 +58,9 @@ from inputremapper.injection.injector import ( get_udev_name, ) from inputremapper.injection.numlock import is_numlock_on, set_numlock, ensure_numlock -from inputremapper.system_mapping import system_mapping +from inputremapper.system_mapping import system_mapping, DISABLE_CODE, DISABLE_NAME from inputremapper.gui.custom_mapping import custom_mapping -from inputremapper.mapping import Mapping, DISABLE_CODE, DISABLE_NAME +from inputremapper.mapping import Mapping from inputremapper.config import config, NONE, MOUSE, WHEEL, BUTTONS from inputremapper.key import Key from inputremapper.injection.macros.parse import parse @@ -988,7 +988,7 @@ class TestModifyCapabilities(unittest.TestCase): def test_construct_capabilities(self): self.mapping.change(Key(EV_KEY, 60, 1), self.macro.code) - self.injector = Injector(groups.find(name="foo"), self.mapping) + self.injector = Injector(None, self.mapping) self.injector.context = Context(self.mapping) capabilities = self.injector._construct_capabilities(gamepad=False) @@ -1009,7 +1009,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(groups.find(name="foo"), self.mapping) + self.injector = Injector(None, self.mapping) self.fake_device._capabilities = { EV_ABS: [ABS_VOLUME, (ABS_X, evdev.AbsInfo(0, 0, 500, 0, 0, 0))], EV_KEY: [1, 2, 3], @@ -1032,7 +1032,7 @@ class TestModifyCapabilities(unittest.TestCase): config.set("gamepad.joystick.left_purpose", MOUSE) self.mapping.set("gamepad.joystick.right_purpose", WHEEL) - self.injector = Injector(groups.find(name="foo"), self.mapping) + self.injector = Injector(None, self.mapping) self.injector.context = Context(self.mapping) self.assertTrue(self.injector.context.maps_joystick()) self.assertTrue(self.injector.context.joystick_as_mouse()) @@ -1054,7 +1054,7 @@ class TestModifyCapabilities(unittest.TestCase): config.set("gamepad.joystick.left_purpose", NONE) self.mapping.set("gamepad.joystick.right_purpose", NONE) - self.injector = Injector(groups.find(name="foo"), self.mapping) + self.injector = Injector(None, self.mapping) self.injector.context = Context(self.mapping) self.assertFalse(self.injector.context.maps_joystick()) self.assertFalse(self.injector.context.joystick_as_mouse()) @@ -1071,7 +1071,7 @@ class TestModifyCapabilities(unittest.TestCase): config.set("gamepad.joystick.left_purpose", BUTTONS) self.mapping.set("gamepad.joystick.right_purpose", BUTTONS) - self.injector = Injector(groups.find(name="foo"), self.mapping) + self.injector = Injector(None, self.mapping) self.injector.context = Context(self.mapping) self.assertTrue(self.injector.context.maps_joystick()) self.assertFalse(self.injector.context.joystick_as_mouse()) @@ -1090,7 +1090,7 @@ class TestModifyCapabilities(unittest.TestCase): config.set("gamepad.joystick.left_purpose", BUTTONS) self.mapping.set("gamepad.joystick.right_purpose", BUTTONS) - self.injector = Injector(groups.find(name="foo"), self.mapping) + self.injector = Injector(None, self.mapping) self.injector.context = Context(self.mapping) capabilities = self.injector._construct_capabilities(gamepad=False) diff --git a/tests/testcases/test_integration.py b/tests/testcases/test_integration.py deleted file mode 100644 index 9211ff55..00000000 --- a/tests/testcases/test_integration.py +++ /dev/null @@ -1,1624 +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 . - - -import sys -import time -import atexit -import os -import unittest -import multiprocessing -import evdev -from evdev.ecodes import ( - EV_KEY, - EV_ABS, - KEY_LEFTSHIFT, - KEY_A, - ABS_RX, - EV_REL, - REL_X, - ABS_X, -) -import json -from unittest.mock import patch -from importlib.util import spec_from_loader, module_from_spec -from importlib.machinery import SourceFileLoader - -import gi - -gi.require_version("Gtk", "3.0") -from gi.repository import Gtk, Gdk - -from inputremapper.system_mapping import system_mapping, XMODMAP_FILENAME -from inputremapper.gui.custom_mapping import custom_mapping -from inputremapper.paths import CONFIG_PATH, get_preset_path, get_config_path -from inputremapper.config import config, WHEEL, MOUSE, BUTTONS -from inputremapper.gui.reader import reader -from inputremapper.injection.injector import RUNNING, FAILED, UNKNOWN -from inputremapper.gui.row import Row, to_string, HOLDING, IDLE -from inputremapper.gui.window import Window -from inputremapper.key import Key -from inputremapper.daemon import Daemon -from inputremapper.groups import groups -from inputremapper.gui.helper import RootHelper - -from tests.test import ( - tmp, - push_events, - new_event, - spy, - cleanup, - uinput_write_history_pipe, - MAX_ABS, - EVENT_READ_TIMEOUT, - send_event_to_reader, - MIN_ABS, -) - - -def gtk_iteration(): - """Iterate while events are pending.""" - while Gtk.events_pending(): - Gtk.main_iteration() - - -# iterate a few times when Gtk.main() is called, but don't block -# there and just continue to the tests while the UI becomes -# unresponsive -Gtk.main = gtk_iteration - -# doesn't do much except avoid some Gtk assertion error, whatever: -Gtk.main_quit = lambda: None - - -def launch(argv=None): - """Start input-remapper-gtk with the command line argument array argv.""" - bin_path = os.path.join(os.getcwd(), "bin", "input-remapper-gtk") - - if not argv: - argv = ["-d"] - - with patch("inputremapper.gui.window.Window.setup_timeouts", lambda *args: None): - with patch.object(sys, "argv", [""] + [str(arg) for arg in argv]): - loader = SourceFileLoader("__main__", bin_path) - spec = spec_from_loader("__main__", loader) - module = module_from_spec(spec) - spec.loader.exec_module(module) - - gtk_iteration() - - # otherwise a new handler is added with each call to launch, which - # spams tons of garbage when all tests finish - atexit.unregister(module.stop) - - # to avoid triggering any timeouts while the module loads, patch it and - # do it afterwards. Because some tests don't want them to be triggered - # yet and test the windows initial state. This is only a problem on - # slow computers that take long for the window import. - module.window.setup_timeouts() - - return module.window - - -class FakeDeviceDropdown(Gtk.ComboBoxText): - def __init__(self, group): - if type(group) == str: - group = groups.find(key=group) - - self.group = group - - def get_active_text(self): - return self.group.name - - def get_active_id(self): - return self.group.key - - def set_active_id(self, key): - self.group = groups.find(key=key) - - -class FakePresetDropdown(Gtk.ComboBoxText): - def __init__(self, name): - self.name = name - - def get_active_text(self): - return self.name - - def get_active_id(self): - return self.name - - def set_active_id(self, name): - self.name = name - - -def clean_up_integration(test): - if hasattr(test, "original_on_close"): - test.window.on_close = test.original_on_close - - test.window.on_restore_defaults_clicked(None) - gtk_iteration() - test.window.on_close() - test.window.window.destroy() - gtk_iteration() - cleanup() - - # do this now, not when all tests are finished - test.window.dbus.stop_all() - if isinstance(test.window.dbus, Daemon): - atexit.unregister(test.window.dbus.stop_all) - - -original_on_select_preset = Window.on_select_preset - - -class GtkKeyEvent: - def __init__(self, keyval): - self.keyval = keyval - - def get_keyval(self): - return True, self.keyval - - -class TestGroupsFromHelper(unittest.TestCase): - @classmethod - def setUpClass(cls): - cls.injector = None - cls.grab = evdev.InputDevice.grab - - # don't try to connect, return an object instance of it instead - cls.original_connect = Daemon.connect - Daemon.connect = Daemon - - cls.original_os_system = os.system - - def os_system(cmd): - # instead of running pkexec, fork instead. This will make - # the helper aware of all the test patches - if "pkexec input-remapper-control --command helper" in cmd: - # the forked process should get the initial groups - groups.refresh() - multiprocessing.Process(target=RootHelper).start() - # the gui an empty dict, because it doesn't know any devices - # without the help of the privileged helper - groups.set_groups({}) - return 0 - - return cls.original_os_system(cmd) - - os.system = os_system - - def setUp(self): - self.window = launch() - # verify that the ui doesn't have knowledge of any device yet - print("test self.assertIsNone(self.window.group)") - self.assertIsNone(self.window.group) - self.assertEqual(len(groups), 0) - - def tearDown(self): - clean_up_integration(self) - - @classmethod - def tearDownClass(cls): - os.system = cls.original_os_system - Daemon.connect = cls.original_connect - - @patch("inputremapper.gui.window.Window.on_select_preset") - def test_knows_devices(self, on_select_preset_patch): - # verify that it is working as expected - gtk_iteration() - self.assertIsNone(self.window.group) - self.assertIsNone(self.window.preset_name) - self.assertEqual(len(groups), 0) - on_select_preset_patch.assert_not_called() - - # 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 - Window.on_select_preset = original_on_select_preset - for _ in range(10): - time.sleep(0.01) - 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.window.group.name, "Foo Device") - - -class TestIntegration(unittest.TestCase): - """For tests that use the window. - - Try to modify the configuration only by calling functions of the window. - """ - - @classmethod - def setUpClass(cls): - cls.injector = None - cls.grab = evdev.InputDevice.grab - - 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() - - Window.start_processes = start_processes - - def setUp(self): - self.window = launch() - self.original_on_close = self.window.on_close - - self.grab_fails = False - - def grab(_): - if self.grab_fails: - raise OSError() - - evdev.InputDevice.grab = grab - - config.save_config() - - def tearDown(self): - clean_up_integration(self) - - def get_rows(self): - return self.window.get("key_list").get_children() - - def get_status_text(self): - status_bar = self.window.get("status_bar") - return status_bar.get_message_area().get_children()[0].get_label() - - def test_can_start(self): - self.assertIsNotNone(self.window) - self.assertTrue(self.window.window.get_visible()) - - def test_ctrl_q(self): - closed = False - - def on_close(): - nonlocal closed - closed = True - - self.window.on_close = on_close - - self.window.key_press(self.window, GtkKeyEvent(Gdk.KEY_Control_L)) - self.window.key_press(self.window, GtkKeyEvent(Gdk.KEY_a)) - self.window.key_release(self.window, GtkKeyEvent(Gdk.KEY_Control_L)) - self.window.key_release(self.window, GtkKeyEvent(Gdk.KEY_a)) - self.window.key_press(self.window, GtkKeyEvent(Gdk.KEY_b)) - self.window.key_press(self.window, GtkKeyEvent(Gdk.KEY_q)) - self.window.key_release(self.window, GtkKeyEvent(Gdk.KEY_q)) - self.window.key_release(self.window, GtkKeyEvent(Gdk.KEY_b)) - self.assertFalse(closed) - - # while keys are being recorded no shortcut should work - rows = self.get_rows() - row = rows[-1] - self.window.window.set_focus(row.keycode_input) - self.window.key_press(self.window, GtkKeyEvent(Gdk.KEY_Control_L)) - self.window.key_press(self.window, GtkKeyEvent(Gdk.KEY_q)) - self.assertFalse(closed) - - self.window.window.set_focus(None) - self.window.key_press(self.window, GtkKeyEvent(Gdk.KEY_Control_L)) - self.window.key_press(self.window, GtkKeyEvent(Gdk.KEY_q)) - self.assertTrue(closed) - - self.window.key_release(self.window, GtkKeyEvent(Gdk.KEY_Control_L)) - self.window.key_release(self.window, GtkKeyEvent(Gdk.KEY_q)) - - def test_ctrl_r(self): - with patch.object(reader, "refresh_groups") as reader_get_devices_patch: - self.window.key_press(self.window, GtkKeyEvent(Gdk.KEY_Control_L)) - self.window.key_press(self.window, GtkKeyEvent(Gdk.KEY_r)) - reader_get_devices_patch.assert_called_once() - - def test_ctrl_del(self): - with patch.object(self.window.dbus, "stop_injecting") as stop_injecting: - self.window.key_press(self.window, GtkKeyEvent(Gdk.KEY_Control_L)) - self.window.key_press(self.window, 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.window.show_device_mapping_status()) - - def test_autoload(self): - with spy(self.window.dbus, "set_config_dir") as set_config_dir: - self.window.on_autoload_switch(None, False) - set_config_dir.assert_called_once() - - self.assertFalse( - config.is_autoloaded(self.window.group.key, self.window.preset_name) - ) - - self.window.on_select_device(FakeDeviceDropdown("Foo Device 2")) - gtk_iteration() - self.assertFalse(self.window.get("preset_autoload_switch").get_active()) - - # select a preset for the first device - self.window.get("preset_autoload_switch").set_active(True) - gtk_iteration() - self.assertTrue(self.window.get("preset_autoload_switch").get_active()) - self.assertEqual(self.window.group.key, "Foo Device 2") - self.assertEqual(self.window.group.name, "Foo Device") - self.assertTrue(config.is_autoloaded(self.window.group.key, "new preset")) - self.assertFalse(config.is_autoloaded("Bar Device", "new preset")) - self.assertListEqual( - list(config.iterate_autoload_presets()), [("Foo Device 2", "new preset")] - ) - - # create a new preset, the switch should be correctly off and the - # config not changed. - self.window.on_create_preset_clicked(None) - gtk_iteration() - self.assertEqual(self.window.preset_name, "new preset 2") - self.assertFalse(self.window.get("preset_autoload_switch").get_active()) - self.assertTrue(config.is_autoloaded("Foo Device 2", "new preset")) - self.assertFalse(config.is_autoloaded("Foo Device", "new preset")) - self.assertFalse(config.is_autoloaded("Foo Device", "new preset 2")) - self.assertFalse(config.is_autoloaded("Foo Device 2", "new preset 2")) - - # select a preset for the second device - self.window.on_select_device(FakeDeviceDropdown("Bar Device")) - self.window.get("preset_autoload_switch").set_active(True) - gtk_iteration() - self.assertTrue(config.is_autoloaded("Foo Device 2", "new preset")) - self.assertFalse(config.is_autoloaded("Foo Device", "new preset")) - self.assertTrue(config.is_autoloaded("Bar Device", "new preset")) - self.assertListEqual( - list(config.iterate_autoload_presets()), - [("Foo Device 2", "new preset"), ("Bar Device", "new preset")], - ) - - # disable autoloading for the second device - self.window.get("preset_autoload_switch").set_active(False) - gtk_iteration() - self.assertTrue(config.is_autoloaded("Foo Device 2", "new preset")) - self.assertFalse(config.is_autoloaded("Foo Device", "new preset")) - self.assertFalse(config.is_autoloaded("Bar Device", "new preset")) - self.assertListEqual( - list(config.iterate_autoload_presets()), [("Foo Device 2", "new preset")] - ) - - def test_select_device(self): - # creates a new empty preset when no preset exists for the device - self.window.on_select_device(FakeDeviceDropdown("Foo Device")) - custom_mapping.change(Key(EV_KEY, 50, 1), "q") - custom_mapping.change(Key(EV_KEY, 51, 1), "u") - custom_mapping.change(Key(EV_KEY, 52, 1), "x") - self.assertEqual(len(custom_mapping), 3) - self.window.on_select_device(FakeDeviceDropdown("Bar Device")) - self.assertEqual(len(custom_mapping), 0) - # it creates the file for that right away. It may have been possible - # to write it such that it doesn't (its empty anyway), but it does, - # so use that to test it in more detail. - path = get_preset_path("Bar Device", "new preset") - self.assertTrue(os.path.exists(path)) - with open(path, "r") as file: - preset = json.load(file) - self.assertEqual(len(preset["mapping"]), 0) - - def test_permission_error_on_create_preset_clicked(self): - def save(_=None): - raise PermissionError - - with patch.object(custom_mapping, "save", save): - self.window.on_create_preset_clicked(None) - status = self.get_status_text() - self.assertIn("Permission denied", status) - - def test_show_injection_result_failure(self): - def get_state(_=None): - return FAILED - - with patch.object(self.window.dbus, "get_state", get_state): - self.window.show_injection_result() - text = self.get_status_text() - self.assertIn("Failed", text) - - def test_row_keycode_to_string(self): - # not an integration test, but I have all the row tests here already - self.assertEqual(to_string(Key(EV_KEY, evdev.ecodes.KEY_A, 1)), "a") - self.assertEqual( - to_string(Key(EV_ABS, evdev.ecodes.ABS_HAT0X, -1)), "DPad Left" - ) - self.assertEqual(to_string(Key(EV_ABS, evdev.ecodes.ABS_HAT0Y, -1)), "DPad Up") - self.assertEqual(to_string(Key(EV_KEY, evdev.ecodes.BTN_A, 1)), "Button A") - self.assertEqual(to_string(Key(EV_KEY, 1234, 1)), "1234") - self.assertEqual( - to_string(Key(EV_ABS, evdev.ecodes.ABS_X, 1)), "Joystick Right" - ) - self.assertEqual( - to_string(Key(EV_ABS, evdev.ecodes.ABS_RY, 1)), "Joystick 2 Down" - ) - self.assertEqual( - to_string(Key(EV_REL, evdev.ecodes.REL_HWHEEL, 1)), "Wheel Right" - ) - self.assertEqual( - to_string(Key(EV_REL, evdev.ecodes.REL_WHEEL, -1)), "Wheel Down" - ) - - # combinations - self.assertEqual( - to_string( - Key( - (EV_KEY, evdev.ecodes.BTN_A, 1), - (EV_KEY, evdev.ecodes.BTN_B, 1), - (EV_KEY, evdev.ecodes.BTN_C, 1), - ) - ), - "Button A + Button B + Button C", - ) - - def test_row_simple(self): - rows = self.window.get("key_list").get_children() - self.assertEqual(len(rows), 1) - - row = rows[0] - - row.set_new_key(None) - self.assertIsNone(row.get_key()) - self.assertEqual(len(custom_mapping), 0) - self.assertEqual(row.keycode_input.get_label(), "click here") - - row.set_new_key(Key(EV_KEY, 30, 1)) - self.assertEqual(len(custom_mapping), 0) - self.assertEqual(row.get_key(), (EV_KEY, 30, 1)) - # this is KEY_A in linux/input-event-codes.h, - # but KEY_ is removed from the text - self.assertEqual(row.keycode_input.get_label(), "a") - - row.set_new_key(Key(EV_KEY, 30, 1)) - self.assertEqual(len(custom_mapping), 0) - self.assertEqual(row.get_key(), (EV_KEY, 30, 1)) - - time.sleep(0.1) - gtk_iteration() - self.assertEqual(len(self.window.get("key_list").get_children()), 1) - - row.symbol_input.set_text("Shift_L") - self.assertEqual(len(custom_mapping), 1) - - time.sleep(0.1) - gtk_iteration() - self.assertEqual(len(self.window.get("key_list").get_children()), 2) - - self.assertEqual(custom_mapping.get_symbol(Key(EV_KEY, 30, 1)), "Shift_L") - self.assertEqual(row.get_symbol(), "Shift_L") - self.assertEqual(row.get_key(), (EV_KEY, 30, 1)) - - def sleep(self, num_events): - for _ in range(num_events * 2): - time.sleep(EVENT_READ_TIMEOUT) - gtk_iteration() - - time.sleep(1 / 30) # one window iteration - gtk_iteration() - - def test_row_not_focused(self): - # focus anything that is not the row, - # no keycode should be inserted into it - self.window.window.set_focus(self.window.get("preset_name_input")) - send_event_to_reader(new_event(1, 61, 1)) - self.window.consume_newest_keycode() - - rows = self.get_rows() - self.assertEqual(len(rows), 1) - row = rows[0] - - # the empty row has this key not set - self.assertIsNone(row.get_key()) - - # focus the text input instead - self.window.window.set_focus(row.symbol_input) - send_event_to_reader(new_event(1, 61, 1)) - self.window.consume_newest_keycode() - - # still nothing set - self.assertIsNone(row.get_key()) - - def test_show_status(self): - self.window.show_status(0, "a" * 100) - text = self.get_status_text() - self.assertIn("...", text) - - self.window.show_status(0, "b") - text = self.get_status_text() - self.assertNotIn("...", text) - - def change_empty_row(self, key, char, code_first=True, expect_success=True): - """Modify the one empty row that always exists. - - Utility function for other tests. - - Parameters - ---------- - key : Key or None - code_first : boolean - If True, the code is entered and then the symbol. - If False, the symbol is entered first. - expect_success : boolean - If this change on the empty row is going to result in a change - in the mapping eventually. False if this change is going to - cause a duplicate. - """ - self.assertIsNone(reader.get_unreleased_keys()) - - changed = custom_mapping.changed - - # wait for the window to create a new empty row if needed - time.sleep(0.1) - gtk_iteration() - - # find the empty row - rows = self.get_rows() - row = rows[-1] - self.assertIsNone(row.get_key()) - self.assertEqual(row.symbol_input.get_text(), "") - self.assertEqual(row._state, IDLE) - - if char and not code_first: - # set the symbol to make the new row complete - self.assertIsNone(row.get_symbol()) - row.symbol_input.set_text(char) - self.assertEqual(row.get_symbol(), char) - - if row.keycode_input.is_focus(): - self.assertEqual(row.keycode_input.get_label(), "press key") - else: - self.assertEqual(row.keycode_input.get_label(), "click here") - - self.window.window.set_focus(row.keycode_input) - gtk_iteration() - gtk_iteration() - self.assertIsNone(row.get_key()) - self.assertEqual(row.keycode_input.get_label(), "press key") - - if key: - # modifies the keycode in the row not by writing into the input, - # but by sending an event. press down all the keys of a combination - for sub_key in key: - - send_event_to_reader(new_event(*sub_key)) - # this will be consumed all at once, since no gt_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.assertEqual(row._state, HOLDING) - self.assertTrue(row.keycode_input.is_focus()) - - # release all the keys - for sub_key in key: - send_event_to_reader(new_event(*sub_key[:2], 0)) - - # wait for the window to consume the keycode - self.sleep(len(key)) - - # released - self.assertIsNone(reader.get_unreleased_keys()) - self.assertEqual(row._state, IDLE) - - if expect_success: - self.assertEqual(row.get_key(), key) - self.assertEqual(row.keycode_input.get_label(), to_string(key)) - self.assertFalse(row.keycode_input.is_focus()) - self.assertEqual(len(reader._unreleased), 0) - - if not expect_success: - self.assertIsNone(row.get_key()) - self.assertIsNone(row.get_symbol()) - self.assertEqual(row._state, IDLE) - # it won't switch the focus to the symbol input - self.assertTrue(row.keycode_input.is_focus()) - self.assertEqual(custom_mapping.changed, changed) - return row - - if char and code_first: - # set the symbol to make the new row complete - self.assertIsNone(row.get_symbol()) - row.symbol_input.set_text(char) - self.assertEqual(row.get_symbol(), char) - - # unfocus them to trigger some final logic - self.window.window.set_focus(None) - correct_case = system_mapping.correct_case(char) - self.assertEqual(row.get_symbol(), correct_case) - self.assertFalse(custom_mapping.changed) - - return row - - def test_clears_unreleased_on_focus_change(self): - ev_1 = Key(EV_KEY, 41, 1) - - # focus - self.window.window.set_focus(self.get_rows()[0].keycode_input) - send_event_to_reader(new_event(*ev_1.keys[0])) - reader.read() - self.assertEqual(reader.get_unreleased_keys(), ev_1) - - # unfocus - # doesn't call reader.clear - # because otherwise the super key cannot be mapped - self.window.window.set_focus(None) - self.assertEqual(reader.get_unreleased_keys(), ev_1) - - # focus different row - self.window.add_empty() - self.window.window.set_focus(self.get_rows()[1].keycode_input) - self.assertEqual(reader.get_unreleased_keys(), None) - - def test_rows(self): - """Comprehensive test for rows.""" - 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 rows there should be in the end - num_rows_target = 3 - - ev_1 = Key(EV_KEY, 10, 1) - ev_2 = Key(EV_ABS, evdev.ecodes.ABS_HAT0X, -1) - - """edit""" - - # add two rows by modifiying the one empty row that exists. - # Insert lowercase, it should be corrected to uppercase as stored - # in system_mapping - self.change_empty_row(ev_1, "foo_bar", code_first=False) - self.change_empty_row(ev_2, "k(b).k(c)") - - # one empty row added automatically again - time.sleep(0.1) - gtk_iteration() - self.assertEqual(len(self.get_rows()), num_rows_target) - - self.assertEqual(custom_mapping.get_symbol(ev_1), "Foo_BAR") - self.assertEqual(custom_mapping.get_symbol(ev_2), "k(b).k(c)") - - """edit first row""" - - row = self.get_rows()[0] - row.symbol_input.set_text("c") - - self.assertEqual(custom_mapping.get_symbol(ev_1), "c") - self.assertEqual(custom_mapping.get_symbol(ev_2), "k(b).k(c)") - self.assertTrue(custom_mapping.changed) - - """add duplicate""" - - # try to add a duplicate keycode, it should be ignored - self.change_empty_row(ev_2, "d", expect_success=False) - self.assertEqual(custom_mapping.get_symbol(ev_2), "k(b).k(c)") - # and the number of rows shouldn't change - self.assertEqual(len(self.get_rows()), num_rows_target) - - def test_hat0x(self): - # it should be possible to add all of them - ev_1 = Key(EV_ABS, evdev.ecodes.ABS_HAT0X, -1) - ev_2 = Key(EV_ABS, evdev.ecodes.ABS_HAT0X, 1) - ev_3 = Key(EV_ABS, evdev.ecodes.ABS_HAT0Y, -1) - ev_4 = Key(EV_ABS, evdev.ecodes.ABS_HAT0Y, 1) - - self.change_empty_row(ev_1, "a") - self.change_empty_row(ev_2, "b") - self.change_empty_row(ev_3, "c") - self.change_empty_row(ev_4, "d") - - self.assertEqual(custom_mapping.get_symbol(ev_1), "a") - self.assertEqual(custom_mapping.get_symbol(ev_2), "b") - self.assertEqual(custom_mapping.get_symbol(ev_3), "c") - self.assertEqual(custom_mapping.get_symbol(ev_4), "d") - - # and trying to add them as duplicate rows will be ignored for each - # of them - self.change_empty_row(ev_1, "e", expect_success=False) - self.change_empty_row(ev_2, "f", expect_success=False) - self.change_empty_row(ev_3, "g", expect_success=False) - self.change_empty_row(ev_4, "h", expect_success=False) - - self.assertEqual(custom_mapping.get_symbol(ev_1), "a") - self.assertEqual(custom_mapping.get_symbol(ev_2), "b") - self.assertEqual(custom_mapping.get_symbol(ev_3), "c") - self.assertEqual(custom_mapping.get_symbol(ev_4), "d") - - def test_combination(self): - # it should be possible to write a key combination - ev_1 = Key(EV_KEY, evdev.ecodes.KEY_A, 1) - ev_2 = Key(EV_ABS, evdev.ecodes.ABS_HAT0X, 1) - ev_3 = Key(EV_KEY, evdev.ecodes.KEY_C, 1) - ev_4 = Key(EV_ABS, evdev.ecodes.ABS_HAT0X, -1) - combination_1 = Key(ev_1, ev_2, ev_3) - combination_2 = Key(ev_2, ev_1, ev_3) - - # same as 1, but different D-Pad direction - combination_3 = Key(ev_1, ev_4, ev_3) - combination_4 = Key(ev_4, ev_1, ev_3) - - # same as 1, but the last key is different - combination_5 = Key(ev_1, ev_3, ev_2) - combination_6 = Key(ev_3, ev_1, ev_2) - - self.change_empty_row(combination_1, "a") - self.assertEqual(custom_mapping.get_symbol(combination_1), "a") - self.assertEqual(custom_mapping.get_symbol(combination_2), "a") - self.assertIsNone(custom_mapping.get_symbol(combination_3)) - self.assertIsNone(custom_mapping.get_symbol(combination_4)) - self.assertIsNone(custom_mapping.get_symbol(combination_5)) - self.assertIsNone(custom_mapping.get_symbol(combination_6)) - - # it won't write the same combination again, even if the - # first two events are in a different order - self.change_empty_row(combination_2, "b", expect_success=False) - self.assertEqual(custom_mapping.get_symbol(combination_1), "a") - self.assertEqual(custom_mapping.get_symbol(combination_2), "a") - self.assertIsNone(custom_mapping.get_symbol(combination_3)) - self.assertIsNone(custom_mapping.get_symbol(combination_4)) - self.assertIsNone(custom_mapping.get_symbol(combination_5)) - self.assertIsNone(custom_mapping.get_symbol(combination_6)) - - self.change_empty_row(combination_3, "c") - self.assertEqual(custom_mapping.get_symbol(combination_1), "a") - self.assertEqual(custom_mapping.get_symbol(combination_2), "a") - self.assertEqual(custom_mapping.get_symbol(combination_3), "c") - self.assertEqual(custom_mapping.get_symbol(combination_4), "c") - self.assertIsNone(custom_mapping.get_symbol(combination_5)) - self.assertIsNone(custom_mapping.get_symbol(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.change_empty_row(combination_4, "d", expect_success=False) - self.assertEqual(custom_mapping.get_symbol(combination_1), "a") - self.assertEqual(custom_mapping.get_symbol(combination_2), "a") - self.assertEqual(custom_mapping.get_symbol(combination_3), "c") - self.assertEqual(custom_mapping.get_symbol(combination_4), "c") - self.assertIsNone(custom_mapping.get_symbol(combination_5)) - self.assertIsNone(custom_mapping.get_symbol(combination_6)) - - self.change_empty_row(combination_5, "e") - self.assertEqual(custom_mapping.get_symbol(combination_1), "a") - self.assertEqual(custom_mapping.get_symbol(combination_2), "a") - self.assertEqual(custom_mapping.get_symbol(combination_3), "c") - self.assertEqual(custom_mapping.get_symbol(combination_4), "c") - self.assertEqual(custom_mapping.get_symbol(combination_5), "e") - self.assertEqual(custom_mapping.get_symbol(combination_6), "e") - - error_icon = self.window.get("error_status_icon") - warning_icon = self.window.get("warning_status_icon") - - self.assertFalse(error_icon.get_visible()) - self.assertFalse(warning_icon.get_visible()) - - def test_remove_row(self): - """Comprehensive test for rows 2.""" - # sleeps are added to be able to visually follow and debug the test. - # add two rows by modifiying the one empty row that exists - row_1 = self.change_empty_row(Key(EV_KEY, 10, 1), "a") - row_2 = self.change_empty_row(Key(EV_KEY, 11, 1), "b") - row_3 = self.change_empty_row(None, "c") - - # no empty row added because one is unfinished - time.sleep(0.2) - gtk_iteration() - self.assertEqual(len(self.get_rows()), 3) - - self.assertEqual(custom_mapping.get_symbol(Key(EV_KEY, 11, 1)), "b") - - def remove(row, code, char, num_rows_after): - """Remove a row by clicking the delete button. - - Parameters - ---------- - row : Row - code : int or None - keycode of the mapping that is displayed by this row - char : string or None - ouptut of the mapping that is displayed by this row - num_rows_after : int - after deleting, how many rows are expected to still be there - """ - if code is not None and char is not None: - self.assertEqual(custom_mapping.get_symbol(Key(EV_KEY, code, 1)), char) - - self.assertEqual(row.get_symbol(), char) - if code is None: - self.assertIsNone(row.get_key()) - else: - self.assertEqual(row.get_key(), Key(EV_KEY, code, 1)) - - row.on_delete_button_clicked() - time.sleep(0.2) - gtk_iteration() - - # if a reference to the row 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(row.get_key()) - self.assertIsNone(row.get_symbol()) - if code is not None: - self.assertIsNone(custom_mapping.get_symbol(Key(EV_KEY, code, 1))) - self.assertEqual(len(self.get_rows()), num_rows_after) - - remove(row_1, 10, "a", 2) - remove(row_2, 11, "b", 1) - # there is no empty row at the moment, so after removing that one, - # which is the only row, one empty row will be there. So the number - # of rows won't change. - remove(row_3, None, "c", 1) - - def test_problematic_combination(self): - combination = Key((EV_KEY, KEY_LEFTSHIFT, 1), (EV_KEY, 82, 1)) - self.change_empty_row(combination, "b") - text = self.get_status_text() - self.assertIn("shift", text) - - error_icon = self.window.get("error_status_icon") - warning_icon = self.window.get("warning_status_icon") - - self.assertFalse(error_icon.get_visible()) - self.assertTrue(warning_icon.get_visible()) - - def test_rename_and_save(self): - self.assertEqual(self.window.group.name, "Foo Device") - self.assertFalse(config.is_autoloaded("Foo Device", "new preset")) - - custom_mapping.change(Key(EV_KEY, 14, 1), "a", None) - self.assertEqual(self.window.preset_name, "new preset") - self.window.save_preset() - self.assertEqual(custom_mapping.get_symbol(Key(EV_KEY, 14, 1)), "a") - config.set_autoload_preset("Foo Device", "new preset") - self.assertTrue(config.is_autoloaded("Foo Device", "new preset")) - - custom_mapping.change(Key(EV_KEY, 14, 1), "b", None) - self.window.get("preset_name_input").set_text("asdf") - self.window.save_preset() - self.window.on_rename_button_clicked(None) - self.assertEqual(self.window.preset_name, "asdf") - preset_path = f"{CONFIG_PATH}/presets/Foo Device/asdf.json" - self.assertTrue(os.path.exists(preset_path)) - self.assertEqual(custom_mapping.get_symbol(Key(EV_KEY, 14, 1)), "b") - - # after renaming the preset it is still set to autoload - self.assertTrue(config.is_autoloaded("Foo Device", "asdf")) - # ALSO IN THE ACTUAL CONFIG FILE! - config.load_config() - self.assertTrue(config.is_autoloaded("Foo Device", "asdf")) - - error_icon = self.window.get("error_status_icon") - self.assertFalse(error_icon.get_visible()) - - # otherwise save won't do anything - custom_mapping.change(Key(EV_KEY, 14, 1), "c", None) - self.assertTrue(custom_mapping.changed) - - def save(_): - raise PermissionError - - with patch.object(custom_mapping, "save", save): - self.window.save_preset() - status = self.get_status_text() - self.assertIn("Permission denied", status) - - with patch.object( - self.window, "show_confirm_delete", lambda: Gtk.ResponseType.ACCEPT - ): - self.window.on_delete_preset_clicked(None) - self.assertFalse(os.path.exists(preset_path)) - - def test_rename_create_switch(self): - # after renaming a preset and saving it, new presets - # start with "new preset" again - custom_mapping.change(Key(EV_KEY, 14, 1), "a", None) - self.window.get("preset_name_input").set_text("asdf") - self.window.save_preset() - self.window.on_rename_button_clicked(None) - self.assertEqual(len(custom_mapping), 1) - self.assertEqual(self.window.preset_name, "asdf") - - self.window.on_create_preset_clicked(None) - self.assertEqual(self.window.preset_name, "new preset") - self.assertIsNone(custom_mapping.get_symbol(Key(EV_KEY, 14, 1))) - self.window.save_preset() - - # selecting the first one again loads the saved mapping - self.window.on_select_preset(FakePresetDropdown("asdf")) - self.assertEqual(custom_mapping.get_symbol(Key(EV_KEY, 14, 1)), "a") - config.set_autoload_preset("Foo Device", "new preset") - - # renaming a preset to an existing name appends a number - self.window.on_select_preset(FakePresetDropdown("new preset")) - self.window.get("preset_name_input").set_text("asdf") - self.window.on_rename_button_clicked(None) - self.assertEqual(self.window.preset_name, "asdf 2") - # and that added number is correctly used in the autoload - # configuration as well - self.assertTrue(config.is_autoloaded("Foo Device", "asdf 2")) - - self.assertEqual(self.window.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.window.get("preset_name_input").set_text("asdf 2") - self.window.on_rename_button_clicked(None) - self.assertEqual(self.window.preset_name, "asdf 2") - - self.window.get("preset_name_input").set_text("") - self.window.on_rename_button_clicked(None) - self.assertEqual(self.window.preset_name, "asdf 2") - - def test_avoids_redundant_saves(self): - custom_mapping.change(Key(EV_KEY, 14, 1), "abcd", None) - - custom_mapping.changed = False - self.window.save_preset() - - with open(get_preset_path("Foo Device", "new preset")) as f: - content = f.read() - self.assertNotIn("abcd", content) - - custom_mapping.changed = True - self.window.save_preset() - - with open(get_preset_path("Foo Device", "new preset")) as f: - content = f.read() - self.assertIn("abcd", content) - - def test_check_for_unknown_symbols(self): - status = self.window.get("status_bar") - error_icon = self.window.get("error_status_icon") - warning_icon = self.window.get("warning_status_icon") - - custom_mapping.change(Key(EV_KEY, 71, 1), "qux", None) - custom_mapping.change(Key(EV_KEY, 72, 1), "foo", None) - self.window.save_preset() - tooltip = status.get_tooltip_text().lower() - self.assertIn("qux", tooltip) - self.assertTrue(error_icon.get_visible()) - self.assertFalse(warning_icon.get_visible()) - - # it will still save it though - with open(get_preset_path("Foo Device", "new preset")) as f: - content = f.read() - self.assertIn("qux", content) - self.assertIn("foo", content) - - custom_mapping.change(Key(EV_KEY, 71, 1), "a", None) - self.window.save_preset() - tooltip = status.get_tooltip_text().lower() - self.assertIn("foo", tooltip) - self.assertTrue(error_icon.get_visible()) - self.assertFalse(warning_icon.get_visible()) - - custom_mapping.change(Key(EV_KEY, 72, 1), "b", None) - self.window.save_preset() - tooltip = status.get_tooltip_text() - self.assertIsNone(tooltip) - self.assertFalse(error_icon.get_visible()) - self.assertFalse(warning_icon.get_visible()) - - def test_check_macro_syntax(self): - status = self.window.get("status_bar") - error_icon = self.window.get("error_status_icon") - warning_icon = self.window.get("warning_status_icon") - - custom_mapping.change(Key(EV_KEY, 9, 1), "k(1))", None) - self.window.save_preset() - tooltip = status.get_tooltip_text().lower() - self.assertIn("brackets", tooltip) - self.assertTrue(error_icon.get_visible()) - self.assertFalse(warning_icon.get_visible()) - - custom_mapping.change(Key(EV_KEY, 9, 1), "k(1)", None) - self.window.save_preset() - tooltip = (status.get_tooltip_text() or "").lower() - self.assertNotIn("brackets", tooltip) - self.assertFalse(error_icon.get_visible()) - self.assertFalse(warning_icon.get_visible()) - - self.assertEqual(custom_mapping.get_symbol(Key(EV_KEY, 9, 1)), "k(1)") - - def test_select_device_and_preset(self): - # created on start because the first device is selected and some empty - # preset prepared. - self.assertTrue( - os.path.exists(f"{CONFIG_PATH}/presets/Foo Device/new preset.json") - ) - self.assertEqual(self.window.group.name, "Foo Device") - self.assertEqual(self.window.preset_name, "new preset") - - # create another one - self.window.on_create_preset_clicked(None) - gtk_iteration() - self.assertTrue( - os.path.exists(f"{CONFIG_PATH}/presets/Foo Device/new preset.json") - ) - self.assertTrue( - os.path.exists(f"{CONFIG_PATH}/presets/Foo Device/new preset 2.json") - ) - self.assertEqual(self.window.preset_name, "new preset 2") - - self.window.on_select_preset(FakePresetDropdown("new preset")) - gtk_iteration() - self.assertEqual(self.window.preset_name, "new preset") - - self.assertListEqual( - sorted(os.listdir(f"{CONFIG_PATH}/presets/Foo Device")), - sorted(["new preset.json", "new preset 2.json"]), - ) - - # now try to change the name - self.window.get("preset_name_input").set_text("abc 123") - gtk_iteration() - self.assertEqual(self.window.preset_name, "new preset") - self.assertFalse( - os.path.exists(f"{CONFIG_PATH}/presets/Foo Device/abc 123.json") - ) - custom_mapping.change(Key(EV_KEY, 10, 1), "1", None) - self.window.save_preset() - self.window.on_rename_button_clicked(None) - gtk_iteration() - self.assertEqual(self.window.preset_name, "abc 123") - self.assertTrue( - os.path.exists(f"{CONFIG_PATH}/presets/Foo Device/abc 123.json") - ) - self.assertListEqual( - sorted(os.listdir(os.path.join(CONFIG_PATH, "presets"))), - sorted(["Foo Device"]), - ) - self.assertListEqual( - sorted(os.listdir(f"{CONFIG_PATH}/presets/Foo Device")), - sorted(["abc 123.json", "new preset 2.json"]), - ) - - def test_copy_preset(self): - key_list = self.window.get("key_list") - self.change_empty_row(Key(EV_KEY, 81, 1), "a") - time.sleep(0.1) - gtk_iteration() - self.window.save_preset() - self.assertEqual(len(key_list.get_children()), 2) - - # should be cleared when creating a new preset - custom_mapping.set("a.b", 3) - self.assertEqual(custom_mapping.get("a.b"), 3) - - self.window.on_create_preset_clicked(None) - - # the preset should be empty, only one empty row present - self.assertEqual(len(key_list.get_children()), 1) - self.assertIsNone(custom_mapping.get("a.b"), 3) - - # add one new row again and a setting - self.change_empty_row(Key(EV_KEY, 81, 1), "b") - time.sleep(0.1) - gtk_iteration() - self.window.save_preset() - self.assertEqual(len(key_list.get_children()), 2) - custom_mapping.set(["foo", "bar"], 2) - - # this time it should be copied - self.window.on_copy_preset_clicked(None) - self.assertEqual(self.window.preset_name, "new preset 2 copy") - self.assertEqual(len(key_list.get_children()), 2) - self.assertEqual(key_list.get_children()[0].get_symbol(), "b") - self.assertEqual(custom_mapping.get(["foo", "bar"]), 2) - - # make another copy - self.window.on_copy_preset_clicked(None) - self.assertEqual(self.window.preset_name, "new preset 2 copy 2") - self.assertEqual(len(key_list.get_children()), 2) - self.assertEqual(key_list.get_children()[0].get_symbol(), "b") - self.assertEqual(len(custom_mapping), 1) - self.assertEqual(custom_mapping.get("foo.bar"), 2) - - def test_gamepad_config(self): - # set some stuff in the beginning, otherwise gtk fails to - # do handler_unblock_by_func, which makes no sense at all. - # but it ONLY fails on right_joystick_purpose for some reason, - # unblocking the left one works just fine. I should open a bug report - # on gtk or something probably. - self.window.get("left_joystick_purpose").set_active_id(BUTTONS) - self.window.get("right_joystick_purpose").set_active_id(BUTTONS) - self.window.get("joystick_mouse_speed").set_value(1) - custom_mapping.changed = False - - # select a device that is not a gamepad - self.window.on_select_device(FakeDeviceDropdown("Foo Device")) - self.assertFalse(self.window.get("gamepad_config").is_visible()) - self.assertFalse(custom_mapping.changed) - - # select a gamepad - self.window.on_select_device(FakeDeviceDropdown("gamepad")) - self.assertTrue(self.window.get("gamepad_config").is_visible()) - self.assertFalse(custom_mapping.changed) - - # set stuff - gtk_iteration() - self.window.get("left_joystick_purpose").set_active_id(WHEEL) - self.window.get("right_joystick_purpose").set_active_id(WHEEL) - joystick_mouse_speed = 5 - self.window.get("joystick_mouse_speed").set_value(joystick_mouse_speed) - - # it should be stored in custom_mapping, which overwrites the - # global config - config.set("gamepad.joystick.left_purpose", MOUSE) - config.set("gamepad.joystick.right_purpose", MOUSE) - config.set("gamepad.joystick.pointer_speed", 50) - self.assertTrue(custom_mapping.changed) - left_purpose = custom_mapping.get("gamepad.joystick.left_purpose") - right_purpose = custom_mapping.get("gamepad.joystick.right_purpose") - pointer_speed = custom_mapping.get("gamepad.joystick.pointer_speed") - self.assertEqual(left_purpose, WHEEL) - self.assertEqual(right_purpose, WHEEL) - self.assertEqual(pointer_speed, 2 ** joystick_mouse_speed) - - # select a device that is not a gamepad again - self.window.on_select_device(FakeDeviceDropdown("Foo Device")) - self.assertFalse(self.window.get("gamepad_config").is_visible()) - self.assertFalse(custom_mapping.changed) - - def test_wont_start(self): - error_icon = self.window.get("error_status_icon") - preset_name = "foo preset" - group_name = "Bar Device" - self.window.preset_name = preset_name - self.window.group = groups.find(name=group_name) - - # empty - - custom_mapping.empty() - self.window.save_preset() - self.window.on_apply_preset_clicked(None) - text = self.get_status_text() - self.assertIn("add keys", text) - self.assertTrue(error_icon.get_visible()) - self.assertNotEqual(self.window.dbus.get_state(self.window.group.key), RUNNING) - - # not empty, but keys are held down - - custom_mapping.change(Key(EV_KEY, KEY_A, 1), "a") - self.window.save_preset() - send_event_to_reader(new_event(EV_KEY, KEY_A, 1)) - reader.read() - self.assertEqual(len(reader._unreleased), 1) - self.assertFalse(self.window.unreleased_warn) - self.window.on_apply_preset_clicked(None) - text = self.get_status_text() - self.assertIn("release", text) - self.assertTrue(error_icon.get_visible()) - self.assertNotEqual(self.window.dbus.get_state(self.window.group.key), RUNNING) - self.assertTrue(self.window.unreleased_warn) - self.assertEqual(self.window.get("apply_system_layout").get_opacity(), 0.4) - - # device grabbing fails - - def wait(): - """Wait for the injector process to finish doing stuff.""" - for _ in range(10): - time.sleep(0.1) - gtk_iteration() - if "Starting" not in self.get_status_text(): - return - - for i in range(2): - # just pressing apply again will overwrite the previous error - self.grab_fails = True - self.window.on_apply_preset_clicked(None) - self.assertFalse(self.window.unreleased_warn) - text = self.get_status_text() - # it takes a little bit of time - self.assertIn("Starting injection", text) - self.assertFalse(error_icon.get_visible()) - wait() - text = self.get_status_text() - self.assertIn("not grabbed", text) - self.assertTrue(error_icon.get_visible()) - self.assertNotEqual( - self.window.dbus.get_state(self.window.group.key), RUNNING - ) - - # for the second try, release the key. that should also work - send_event_to_reader(new_event(EV_KEY, KEY_A, 0)) - reader.read() - self.assertEqual(len(reader._unreleased), 0) - - # this time work properly - - self.grab_fails = False - custom_mapping.save(get_preset_path(group_name, preset_name)) - self.window.on_apply_preset_clicked(None) - text = self.get_status_text() - self.assertIn("Starting injection", text) - self.assertFalse(error_icon.get_visible()) - wait() - text = self.get_status_text() - self.assertIn("Applied", text) - text = self.get_status_text() - self.assertNotIn("CTRL + DEL", text) # only shown if btn_left mapped - self.assertFalse(error_icon.get_visible()) - self.assertEqual(self.window.dbus.get_state(self.window.group.key), RUNNING) - - # because this test managed to reproduce some minor bug: - self.assertNotIn("mapping", custom_mapping._config) - - def test_wont_start_2(self): - preset_name = "foo preset" - group_name = "Bar Device" - self.window.preset_name = preset_name - self.window.group = groups.find(name=group_name) - - def wait(): - """Wait for the injector process to finish doing stuff.""" - for _ in range(10): - time.sleep(0.1) - gtk_iteration() - if "Starting" not in self.get_status_text(): - return - - # btn_left mapped - custom_mapping.change(Key.btn_left(), "a") - self.window.save_preset() - - # and key held down - send_event_to_reader(new_event(EV_KEY, KEY_A, 1)) - reader.read() - self.assertEqual(len(reader._unreleased), 1) - self.assertFalse(self.window.unreleased_warn) - - # first apply, shows btn_left warning - self.window.on_apply_preset_clicked(None) - text = self.get_status_text() - self.assertIn("click", text) - self.assertEqual(self.window.dbus.get_state(self.window.group.key), UNKNOWN) - - # second apply, shows unreleased warning - self.window.on_apply_preset_clicked(None) - text = self.get_status_text() - self.assertIn("release", text) - self.assertEqual(self.window.dbus.get_state(self.window.group.key), UNKNOWN) - - # third apply, overwrites both warnings - self.window.on_apply_preset_clicked(None) - wait() - self.assertEqual(self.window.dbus.get_state(self.window.group.key), RUNNING) - text = self.get_status_text() - # because btn_left is mapped, shows help on how to stop - # injecting via the keyboard - self.assertIn("CTRL + DEL", text) - - def test_can_modify_mapping(self): - preset_name = "foo preset" - group_name = "Bar Device" - self.window.preset_name = preset_name - self.window.group = groups.find(name=group_name) - - self.assertNotEqual(self.window.dbus.get_state(self.window.group.key), RUNNING) - self.window.can_modify_mapping() - text = self.get_status_text() - self.assertNotIn("Restore Defaults", text) - - custom_mapping.change(Key(EV_KEY, KEY_A, 1), "b") - custom_mapping.save(get_preset_path(group_name, preset_name)) - self.window.on_apply_preset_clicked(None) - - # wait for the injector to start - for _ in range(10): - time.sleep(0.1) - gtk_iteration() - if "Starting" not in self.get_status_text(): - return - - self.assertEqual(self.window.dbus.get_state(self.window.group.key), RUNNING) - - # the mapping cannot be changed anymore - self.window.can_modify_mapping() - text = self.get_status_text() - self.assertIn("Restore Defaults", text) - - def test_start_injecting(self): - keycode_from = 9 - keycode_to = 200 - - self.change_empty_row(Key(EV_KEY, keycode_from, 1), "a") - system_mapping.clear() - system_mapping._set("a", keycode_to) - - push_events( - "Foo Device 2", - [ - new_event(evdev.events.EV_KEY, keycode_from, 1), - new_event(evdev.events.EV_KEY, keycode_from, 0), - ], - ) - - # injecting for group.key will look at paths containing group.name - custom_mapping.save(get_preset_path("Foo Device", "foo preset")) - - # use only the manipulated system_mapping - if os.path.exists(os.path.join(tmp, XMODMAP_FILENAME)): - os.remove(os.path.join(tmp, XMODMAP_FILENAME)) - - # select the second Foo device - self.window.group = groups.find(key="Foo Device 2") - - with spy(self.window.dbus, "set_config_dir") as spy1: - self.window.preset_name = "foo preset" - - with spy(self.window.dbus, "start_injecting") as spy2: - self.window.on_apply_preset_clicked(None) - # correctly uses group.key, not group.name - spy2.assert_called_once_with("Foo Device 2", "foo preset") - - spy1.assert_called_once_with(get_config_path()) - - # the integration tests will cause the injection to be started as - # processes, as intended. Luckily, recv will block until the events - # are handled and pushed. - - # Note, that appending events to pending_events won't work anymore - # from here on because the injector processes memory cannot be - # modified from here. - - event = uinput_write_history_pipe[0].recv() - self.assertEqual(event.type, evdev.events.EV_KEY) - self.assertEqual(event.code, keycode_to) - self.assertEqual(event.value, 1) - - event = uinput_write_history_pipe[0].recv() - self.assertEqual(event.type, evdev.events.EV_KEY) - self.assertEqual(event.code, keycode_to) - self.assertEqual(event.value, 0) - - # the input-remapper device will not be shown - groups.refresh() - self.window.populate_devices() - for entry in self.window.device_store: - # whichever attribute contains "input-remapper" - self.assertNotIn("input-remapper", "".join(entry)) - - def test_gamepad_purpose_mouse_and_button(self): - self.window.on_select_device(FakeDeviceDropdown("gamepad")) - self.window.get("right_joystick_purpose").set_active_id(MOUSE) - self.window.get("left_joystick_purpose").set_active_id(BUTTONS) - self.window.get("joystick_mouse_speed").set_value(6) - gtk_iteration() - speed = custom_mapping.get("gamepad.joystick.pointer_speed") - custom_mapping.set("gamepad.joystick.non_linearity", 1) - self.assertEqual(speed, 2 ** 6) - - # don't consume the events in the reader, they are used to test - # the injection - reader.terminate() - time.sleep(0.1) - - push_events( - "gamepad", - [new_event(EV_ABS, ABS_RX, MIN_ABS), new_event(EV_ABS, ABS_X, MAX_ABS)] - * 100, - ) - - custom_mapping.change(Key(EV_ABS, ABS_X, 1), "a") - self.window.save_preset() - - gtk_iteration() - - self.window.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.window.dbus.injectors) - - def test_stop_injecting(self): - keycode_from = 16 - keycode_to = 90 - - self.change_empty_row(Key(EV_KEY, keycode_from, 1), "t") - system_mapping.clear() - system_mapping._set("t", keycode_to) - - # not all of those events should be processed, since that takes some - # time due to time.sleep in the fakes and the injection is stopped. - push_events("Bar Device", [new_event(1, keycode_from, 1)] * 100) - - custom_mapping.save(get_preset_path("Bar Device", "foo preset")) - - self.window.group = groups.find(name="Bar Device") - self.window.preset_name = "foo preset" - self.window.on_apply_preset_clicked(None) - - pipe = uinput_write_history_pipe[0] - # block until the first event is available, indicating that - # the injector is ready - write_history = [pipe.recv()] - - # stop - self.window.on_restore_defaults_clicked(None) - - # try to receive a few of the events - time.sleep(0.2) - while pipe.poll(): - write_history.append(pipe.recv()) - - len_before = len(write_history) - self.assertLess(len(write_history), 50) - - # since the injector should not be running anymore, no more events - # should be received after waiting even more time - time.sleep(0.2) - while pipe.poll(): - write_history.append(pipe.recv()) - self.assertEqual(len(write_history), len_before) - - def test_delete_preset(self): - custom_mapping.change(Key(EV_KEY, 71, 1), "a", None) - self.window.get("preset_name_input").set_text("asdf") - self.window.on_rename_button_clicked(None) - gtk_iteration() - self.assertEqual(self.window.preset_name, "asdf") - self.assertEqual(len(custom_mapping), 1) - self.window.save_preset() - self.assertTrue(os.path.exists(get_preset_path("Foo Device", "asdf"))) - - with patch.object( - self.window, "show_confirm_delete", lambda: Gtk.ResponseType.CANCEL - ): - self.window.on_delete_preset_clicked(None) - self.assertTrue(os.path.exists(get_preset_path("Foo Device", "asdf"))) - self.assertEqual(self.window.preset_name, "asdf") - self.assertEqual(self.window.group.name, "Foo Device") - - with patch.object( - self.window, "show_confirm_delete", lambda: Gtk.ResponseType.ACCEPT - ): - self.window.on_delete_preset_clicked(None) - self.assertFalse(os.path.exists(get_preset_path("Foo Device", "asdf"))) - self.assertEqual(self.window.preset_name, "new preset") - self.assertEqual(self.window.group.name, "Foo Device") - - def test_populate_devices(self): - preset_selection = self.window.get("preset_selection") - - # create two presets - self.window.get("preset_name_input").set_text("preset 1") - self.window.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.window.on_create_preset_clicked(None) - self.window.get("preset_name_input").set_text("preset 2") - self.window.on_rename_button_clicked(None) - self.assertEqual(preset_selection.get_active_id(), "preset 2") - - # select the older one - preset_selection.set_active_id("preset 1") - self.assertEqual(self.window.preset_name, "preset 1") - - # add a device that doesn't exist to the dropdown - unknown_key = "key-1234" - self.window.device_store.insert(0, [unknown_key, None, "foo"]) - - self.window.populate_devices() - # the newest preset should be selected - self.assertEqual(self.window.preset_name, "preset 2") - - # the list contains correct entries - # and the non-existing entry should be removed - entries = [tuple(entry) for entry in self.window.device_store] - keys = [entry[0] for entry in self.window.device_store] - self.assertNotIn(unknown_key, keys) - self.assertIn("Foo Device", keys) - self.assertIn(("Foo Device", "input-keyboard", "Foo Device"), entries) - self.assertIn(("Foo Device 2", "input-mouse", "Foo Device 2"), entries) - self.assertIn(("Bar Device", "input-keyboard", "Bar Device"), entries) - self.assertIn(("gamepad", "input-gaming", "gamepad"), entries) - - # it won't crash due to "list index out of range" - # when `types` is an empty list. Won't show an icon - groups.find(key="Foo Device 2").types = [] - self.window.populate_devices() - self.assertIn( - ("Foo Device 2", None, "Foo Device 2"), - [tuple(entry) for entry in self.window.device_store], - ) - - def test_screw_up_rows(self): - # add a row that is not present in custom_mapping - key_list = self.window.get("key_list") - key_list.forall(key_list.remove) - for i in range(5): - broken = Row(window=self.window, delete_callback=lambda: None) - broken.set_new_key(Key(1, i, 1)) - broken.symbol_input.set_text("a") - key_list.insert(broken, -1) - custom_mapping.empty() - - # the ui has 5 rows, the custom_mapping 0. mismatch - num_rows_before = len(key_list.get_children()) - self.assertEqual(len(custom_mapping), 0) - self.assertEqual(num_rows_before, 5) - - # it returns true to keep the glib timeout going - self.assertTrue(self.window.check_add_row()) - # it still adds a new empty row and won't break - num_rows_after = len(key_list.get_children()) - self.assertEqual(num_rows_after, num_rows_before + 1) - - rows = key_list.get_children() - self.assertEqual(rows[0].get_symbol(), "a") - self.assertEqual(rows[1].get_symbol(), "a") - self.assertEqual(rows[2].get_symbol(), "a") - self.assertEqual(rows[3].get_symbol(), "a") - self.assertEqual(rows[4].get_symbol(), "a") - self.assertEqual(rows[5].get_symbol(), None) - - def test_shared_presets(self): - # devices with the same name (but different key because the key is - # unique) share the same presets - key_list = self.window.get("key_list") - - # 1. create a preset - self.window.on_select_device(FakeDeviceDropdown("Foo Device 2")) - self.window.on_create_preset_clicked(None) - self.change_empty_row(Key(3, 2, 1), "qux") - self.window.get("preset_name_input").set_text("asdf") - self.window.on_rename_button_clicked(None) - self.window.save_preset() - self.assertIn("asdf.json", os.listdir(get_preset_path("Foo Device"))) - - # 2. switch to the other device, there should be no preset named asdf - self.window.on_select_device(FakeDeviceDropdown("Bar Device")) - self.assertEqual(self.window.preset_name, "new preset") - self.assertNotIn("asdf.json", os.listdir(get_preset_path("Bar Device"))) - self.assertIsNone(key_list.get_children()[0].get_symbol(), None) - - # 3. switch to the device with the same name - self.window.on_select_device(FakeDeviceDropdown("Foo Device")) - # the newest preset is asdf, it should be automatically selected - self.assertEqual(self.window.preset_name, "asdf") - self.assertEqual(key_list.get_children()[0].get_symbol(), "qux") - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/testcases/test_keycode_mapper.py b/tests/testcases/test_keycode_mapper.py index 2c8febb5..da324f1a 100644 --- a/tests/testcases/test_keycode_mapper.py +++ b/tests/testcases/test_keycode_mapper.py @@ -46,7 +46,8 @@ from inputremapper.injection.macros.parse import parse from inputremapper.injection.context import Context from inputremapper.utils import RELEASE, PRESS from inputremapper.config import config, BUTTONS -from inputremapper.mapping import Mapping, DISABLE_CODE +from inputremapper.mapping import Mapping +from inputremapper.system_mapping import DISABLE_CODE from tests.test import ( new_event, diff --git a/tests/testcases/test_macros.py b/tests/testcases/test_macros.py index 61a71f1a..657879f6 100644 --- a/tests/testcases/test_macros.py +++ b/tests/testcases/test_macros.py @@ -55,8 +55,8 @@ from inputremapper.injection.macros.parse import ( handle_plus_syntax, _count_brackets, _split_keyword_arg, - _remove_whitespaces, - _remove_comments, + remove_whitespaces, + remove_comments, ) from inputremapper.injection.context import Context from inputremapper.config import config @@ -105,37 +105,37 @@ class TestMacros(MacroTestBase): await parse("k(1, b=2, c=3)", self.context).run(self.handler) self.assertListEqual(result, [(1, 2, 3, 4), (1, 2, 3, 400)]) - def test_remove_whitespaces(self): - self.assertEqual(_remove_whitespaces('foo"bar"foo'), 'foo"bar"foo') - self.assertEqual(_remove_whitespaces('foo" bar"foo'), 'foo" bar"foo') - self.assertEqual(_remove_whitespaces('foo" bar"fo" "o'), 'foo" bar"fo" "o') - self.assertEqual(_remove_whitespaces(' fo o"\nba r "f\noo'), 'foo"\nba r "foo') - self.assertEqual(_remove_whitespaces(' a " b " c " '), 'a" b "c" ') + def testremove_whitespaces(self): + self.assertEqual(remove_whitespaces('foo"bar"foo'), 'foo"bar"foo') + self.assertEqual(remove_whitespaces('foo" bar"foo'), 'foo" bar"foo') + self.assertEqual(remove_whitespaces('foo" bar"fo" "o'), 'foo" bar"fo" "o') + self.assertEqual(remove_whitespaces(' fo o"\nba r "f\noo'), 'foo"\nba r "foo') + self.assertEqual(remove_whitespaces(' a " b " c " '), 'a" b "c" ') - self.assertEqual(_remove_whitespaces('"""""""""'), '"""""""""') - self.assertEqual(_remove_whitespaces('""""""""'), '""""""""') + self.assertEqual(remove_whitespaces('"""""""""'), '"""""""""') + self.assertEqual(remove_whitespaces('""""""""'), '""""""""') - self.assertEqual(_remove_whitespaces(" "), "") - self.assertEqual(_remove_whitespaces(' " '), '" ') - self.assertEqual(_remove_whitespaces(' " " '), '" "') + self.assertEqual(remove_whitespaces(" "), "") + self.assertEqual(remove_whitespaces(' " '), '" ') + self.assertEqual(remove_whitespaces(' " " '), '" "') - self.assertEqual(_remove_whitespaces("a# ##b", delimiter="##"), "a###b") - self.assertEqual(_remove_whitespaces("a###b", delimiter="##"), "a###b") - self.assertEqual(_remove_whitespaces("a## #b", delimiter="##"), "a## #b") - self.assertEqual(_remove_whitespaces("a## ##b", delimiter="##"), "a## ##b") + self.assertEqual(remove_whitespaces("a# ##b", delimiter="##"), "a###b") + self.assertEqual(remove_whitespaces("a###b", delimiter="##"), "a###b") + self.assertEqual(remove_whitespaces("a## #b", delimiter="##"), "a## #b") + self.assertEqual(remove_whitespaces("a## ##b", delimiter="##"), "a## ##b") - def test_remove_comments(self): - self.assertEqual(_remove_comments("a#b"), "a") - self.assertEqual(_remove_comments('"a#b"'), '"a#b"') - self.assertEqual(_remove_comments('a"#"#b'), 'a"#"') - self.assertEqual(_remove_comments('a"#""#"#b'), 'a"#""#"') - self.assertEqual(_remove_comments('#a"#""#"#b'), "") + def testremove_comments(self): + self.assertEqual(remove_comments("a#b"), "a") + self.assertEqual(remove_comments('"a#b"'), '"a#b"') + self.assertEqual(remove_comments('a"#"#b'), 'a"#"') + self.assertEqual(remove_comments('a"#""#"#b'), 'a"#""#"') + self.assertEqual(remove_comments('#a"#""#"#b'), "") self.assertEqual( re.sub( r"\s", "", - _remove_comments( + remove_comments( """ # a b @@ -179,7 +179,7 @@ class TestMacros(MacroTestBase): self.assertEqual(_type_check("1", [int, None], "foo", 1), 1) self.assertEqual(_type_check(1.2, [str], "foo", 2), "1.2") - self.assertRaises(TypeError, lambda: _type_check("1.2", [int], "foo", 3), 1.2) + self.assertRaises(TypeError, lambda: _type_check("1.2", [int], "foo", 3)) self.assertRaises(TypeError, lambda: _type_check("a", [None], "foo", 0)) self.assertRaises(TypeError, lambda: _type_check("a", [int], "foo", 1)) self.assertRaises(TypeError, lambda: _type_check("a", [int, float], "foo", 2)) diff --git a/tests/testcases/test_mapping.py b/tests/testcases/test_mapping.py index 59cd3d40..1f008e18 100644 --- a/tests/testcases/test_mapping.py +++ b/tests/testcases/test_mapping.py @@ -22,8 +22,9 @@ import os import unittest import json +from unittest.mock import patch -from evdev.ecodes import EV_KEY, EV_ABS, ABS_HAT0X, KEY_A +from evdev.ecodes import EV_KEY, EV_ABS, KEY_A from inputremapper.mapping import Mapping, split_key from inputremapper.system_mapping import SystemMapping, XMODMAP_FILENAME @@ -129,7 +130,7 @@ class TestSystemMapping(unittest.TestCase): class TestMapping(unittest.TestCase): def setUp(self): self.mapping = Mapping() - self.assertFalse(self.mapping.changed) + self.assertFalse(self.mapping.has_unsaved_changes()) def tearDown(self): quick_cleanup() @@ -139,11 +140,11 @@ class TestMapping(unittest.TestCase): self.assertEqual(self.mapping.get("a"), None) - self.assertFalse(self.mapping.changed) + self.assertFalse(self.mapping.has_unsaved_changes()) self.mapping.set("a", 1) self.assertEqual(self.mapping.get("a"), 1) - self.assertTrue(self.mapping.changed) + self.assertTrue(self.mapping.has_unsaved_changes()) self.mapping.remove("a") self.mapping.set("a.b", 2) @@ -162,15 +163,15 @@ class TestMapping(unittest.TestCase): self.assertEqual(self.mapping.num_saved_keys, 0) self.mapping.save(get_preset_path("foo", "bar")) self.assertEqual(self.mapping.num_saved_keys, len(self.mapping)) - self.assertFalse(self.mapping.changed) + self.assertFalse(self.mapping.has_unsaved_changes()) self.mapping.load(get_preset_path("foo", "bar")) self.assertEqual(self.mapping.get_symbol(Key(EV_KEY, 81, 1)), "a") self.assertIsNone(self.mapping.get("mapping.a")) - self.assertFalse(self.mapping.changed) + self.assertFalse(self.mapping.has_unsaved_changes()) # loading a different preset also removes the configs from memory self.mapping.remove("a") - self.assertTrue(self.mapping.changed) + self.assertTrue(self.mapping.has_unsaved_changes()) self.mapping.set("a.b.c", 6) self.mapping.load(get_preset_path("foo", "bar2")) self.assertIsNone(self.mapping.get("a.b.c")) @@ -233,7 +234,7 @@ class TestMapping(unittest.TestCase): # 1 is not assigned yet, ignore it self.mapping.change(ev_1, "a", ev_2) - self.assertTrue(self.mapping.changed) + self.assertTrue(self.mapping.has_unsaved_changes()) self.assertIsNone(self.mapping.get_symbol(ev_2)) self.assertEqual(self.mapping.get_symbol(ev_1), "a") self.assertEqual(len(self.mapping), 1) @@ -262,6 +263,26 @@ class TestMapping(unittest.TestCase): self.assertEqual(self.mapping.num_saved_keys, 0) + def test_rejects_empty(self): + key = Key(EV_KEY, 1, 111) + self.assertEqual(len(self.mapping), 0) + self.assertRaises(ValueError, lambda: self.mapping.change(key, " \n ")) + self.assertEqual(len(self.mapping), 0) + + def test_avoids_redundant_changes(self): + # to avoid logs that don't add any value + def clear(*_): + # should not be called + raise AssertionError + + key = Key(EV_KEY, 987, 1) + symbol = "foo" + + self.mapping.change(key, symbol) + with patch.object(self.mapping, "clear", clear): + self.mapping.change(key, symbol) + self.mapping.change(key, symbol, previous_key=key) + def test_combinations(self): ev_1 = Key(EV_KEY, 1, 111) ev_2 = Key(EV_KEY, 1, 222) @@ -297,17 +318,17 @@ class TestMapping(unittest.TestCase): ev_4 = Key(EV_KEY, 10, 1) self.mapping.clear(ev_1) - self.assertFalse(self.mapping.changed) + self.assertFalse(self.mapping.has_unsaved_changes()) self.assertEqual(len(self.mapping), 0) self.mapping._mapping[ev_1] = "b" self.assertEqual(len(self.mapping), 1) self.mapping.clear(ev_1) self.assertEqual(len(self.mapping), 0) - self.assertTrue(self.mapping.changed) + self.assertTrue(self.mapping.has_unsaved_changes()) self.mapping.change(ev_4, "KEY_KP1", None) - self.assertTrue(self.mapping.changed) + self.assertTrue(self.mapping.has_unsaved_changes()) self.mapping.change(ev_3, "KEY_KP2", None) self.mapping.change(ev_2, "KEY_KP3", None) self.assertEqual(len(self.mapping), 3) diff --git a/tests/testcases/test_migrations.py b/tests/testcases/test_migrations.py index 7ab9674c..aef80435 100644 --- a/tests/testcases/test_migrations.py +++ b/tests/testcases/test_migrations.py @@ -62,7 +62,7 @@ class TestMigrations(unittest.TestCase): self.assertTrue(new.startswith("/tmp")) try: - os.rmdir(new) + shutil.rmtree(new) except FileNotFoundError: pass diff --git a/tests/testcases/test_presets.py b/tests/testcases/test_presets.py index 21c9745a..929dbd0e 100644 --- a/tests/testcases/test_presets.py +++ b/tests/testcases/test_presets.py @@ -172,6 +172,7 @@ class TestFindPresets(unittest.TestCase): path = os.path.join(PRESETS, "Bar Device", "picture.png") os.mknod(path) + os.system("find /tmp/input-remapper-test/") self.assertEqual(find_newest_preset(), ("Bar Device", "preset 2")) def test_find_newest_preset_2(self): diff --git a/tests/testcases/test_reader.py b/tests/testcases/test_reader.py index 27b08185..22db901e 100644 --- a/tests/testcases/test_reader.py +++ b/tests/testcases/test_reader.py @@ -549,29 +549,29 @@ class TestReader(unittest.TestCase): time.sleep(EVENT_READ_TIMEOUT * 3) self.assertFalse(reader._results.poll()) - def test_are_new_devices_available(self): + def test_are_new_groups_available(self): self.create_helper() groups.set_groups({}) # read stuff from the helper, which includes the devices - self.assertFalse(reader.are_new_devices_available()) + self.assertFalse(reader.are_new_groups_available()) reader.read() - self.assertTrue(reader.are_new_devices_available()) + 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_devices_available()) + 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_devices_available()) + 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_devices_available()) - self.assertFalse(reader.are_new_devices_available()) + self.assertTrue(reader.are_new_groups_available()) + self.assertFalse(reader.are_new_groups_available()) if __name__ == "__main__":