From 60c8367bf4c7b8725f510fd285e42f0c68b04d3d Mon Sep 17 00:00:00 2001 From: sezanzeb Date: Sun, 21 Mar 2021 14:17:34 +0100 Subject: [PATCH] #65 UI improvements --- data/key-mapper-128.png | Bin 0 -> 8905 bytes data/key-mapper.glade | 753 ++++++++++++++++++++++++++-- keymapper/gui/row.py | 17 +- keymapper/gui/window.py | 254 +++++----- keymapper/injection/injector.py | 7 +- keymapper/injection/macros.py | 2 - keymapper/logger.py | 29 +- keymapper/presets.py | 3 +- readme/usage.md | 1 - tests/testcases/test_integration.py | 138 +++-- tests/testcases/test_macros.py | 33 +- 11 files changed, 1001 insertions(+), 236 deletions(-) create mode 100644 data/key-mapper-128.png diff --git a/data/key-mapper-128.png b/data/key-mapper-128.png new file mode 100644 index 0000000000000000000000000000000000000000..a37af0f33277cc53e1db3e5e1efe170cf0c4d8ae GIT binary patch literal 8905 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 XeZJ}0?A#E$00000NkvXXu0mjf&0q^j literal 0 HcmV?d00001 diff --git a/data/key-mapper.glade b/data/key-mapper.glade index cb038f9c..d04d9f4f 100644 --- a/data/key-mapper.glade +++ b/data/key-mapper.glade @@ -2,11 +2,21 @@ + + True + False + help-about + True False dialog-ok + + True + False + edit-copy + True False @@ -131,12 +141,12 @@ close_error_dialog - + True False gtk-delete - + False 4 Key Mapper @@ -154,18 +164,18 @@ True False - center + end 10 end - - Continue + + Delete False True True True False - gtk-delete-icon + gtk-delete-icon1 False @@ -174,7 +184,7 @@ - + Go Back True True @@ -199,9 +209,10 @@ True False - + True False + 10 10 0 dialog-warning @@ -214,13 +225,15 @@ - + True False + 10 + 10 10 10 6 - You have got unsaved changes! + Are you sure to delete your preset? True 0 0.5 @@ -241,8 +254,8 @@ - go_back - go_ahead + go_back1 + go_ahead1 @@ -353,6 +366,23 @@ To give your keys back their original mapping. 2 + + + True + True + True + end + about-icon + True + + + + + False + False + 3 + + False @@ -426,15 +456,15 @@ Don't hold down any keys while the injection starts. - - Save + + Copy 80 True True True - save-icon + copy-icon True - + True @@ -449,7 +479,6 @@ Don't hold down any keys while the injection starts. True True True - Hold down ctrl and click here to copy the current preset new-icon True @@ -544,9 +573,36 @@ Don't hold down any keys while the injection starts. - + True - True + False + + + True + True + + + True + True + 0 + + + + + True + True + True + Save the entered name + 10 + save-icon + + + + False + True + 1 + + True @@ -738,6 +794,7 @@ Don't hold down any keys while the injection starts. mouse_speed_adjustment 1 False + @@ -872,7 +929,7 @@ Don't hold down any keys while the injection starts. 140 True False - Click on a cell below and hit a key on your device. Click the "Restore Defaults" beforehand. + Click on a cell below and hit a key on your device. Click the "Restore Defaults" button beforehand. 5 5 Key @@ -887,32 +944,6 @@ Don't hold down any keys while the injection starts. True False - "disable" disables the key outside of combinations. -Useful for turning a key into a modifier without any side effects. - -Macro help: -- `r` repeats the execution of the second parameter -- `w` waits in milliseconds -- `k` writes a single keystroke -- `e` writes an event -- `m` holds a modifier while executing the second parameter -- `h` executes the parameter as long as the key is pressed down -- `.` executes two actions behind each other -- `mouse` and `wheel` take direction and speed as parameters - -Macro examples: -- `k(1).k(2)` 1, 2 -- `r(3, k(a).w(500))` a, a, a with 500ms pause -- `m(Control_L, k(a).k(x))` CTRL + a, CTRL + x -- `k(1).h(k(2)).k(3)` writes 1 2 2 ... 2 2 3 while the key is pressed -- `e(EV_REL, REL_X, 10)` moves the mouse cursor 10px to the right -- `mouse(right, 4)` which keeps moving the mouse while pressed -- `wheel(down, 1)` keeps scrolling down while held - -Combine keycodes with `+`, for example: `control_l + a`, to write combinations - -Between calls to k, key down and key up events, macros will sleep for 10ms by -default. This can be configured in ~/.config/key-mapper/config 5 5 Mapping @@ -997,4 +1028,634 @@ default. This can be configured in ~/.config/key-mapper/config + + False + key-mapper.svg + window + window + + + True + False + + + True + False + center + 20 + 20 + vertical + 20 + + + True + False + key-mapper-128.png + + + False + True + 0 + + + + + True + False + Version unknown + center + + + False + True + 1 + + + + + True + True + 10 + 10 + 10 + 10 + You can find more information and the latest version on github +<a href="https://github.com/sezanzeb/key-mapper">https://github.com/sezanzeb/key-mapper</a> + True + center + + + False + True + 2 + + + + + About + About + + + + + 500 + 300 + True + True + + + True + False + + + True + False + 5 + 5 + 5 + 5 + 10 + vertical + 10 + + + True + False + A "key + key + ... + key" syntax can be used to trigger key combinations. For example "control_l + a". + +"disable" disables a key. + True + 0 + + + False + True + 0 + + + + + True + False + center + 10 + 10 + 10 + 10 + 6 + Macros + True + True + 0 + 0.5 + + + False + True + 1 + + + + + True + False + Macros allow multiple characters to be written with a single key-press. + True + 0 + + + False + True + 2 + + + + + + True + False + 20 + + + True + False + r + 0 + + + 0 + 0 + + + + + True + False + waits in milliseconds + 0 + + + 1 + 1 + + + + + True + False + w + 0 + + + 0 + 1 + + + + + True + False + k + 0 + + + 0 + 2 + + + + + True + False + writes a single keystroke + 0 + + + 1 + 2 + + + + + True + False + e + 0 + + + 0 + 3 + + + + + True + False + holds a modifier while executing the second parameter + 0 + + + 1 + 4 + + + + + True + False + writes an event + 0 + + + 1 + 3 + + + + + True + False + m + 0 + + + 0 + 4 + + + + + True + False + repeats the execution of the second parameter + 0 + + + 1 + 0 + + + + + True + False + executes the parameter as long as the key is pressed down + 0 + + + 1 + 5 + + + + + True + False + h + 0 + + + 0 + 5 + + + + + True + False + . + 0 + + + 0 + 6 + + + + + True + False + executes two actions behind each other + 0 + + + 1 + 6 + + + + + True + False + mouse + 0 + + + 0 + 7 + + + + + True + False + wheel + 0 + + + 0 + 8 + + + + + True + False + takes direction (up, left, ...) and speed as parameters + 0 + + + 1 + 7 + + + + + True + False + same as mouse + 0 + + + 1 + 8 + + + + + False + False + 3 + + + + + True + False + center + 10 + 10 + 10 + 10 + 6 + Examples + True + True + 0 + 0.5 + + + False + True + 4 + + + + + + True + False + 20 + + + True + False + k(1).k(2) + True + 0 + + + 0 + 0 + + + + + True + False + a, a, a with 500ms pause + 0 + + + 1 + 1 + + + + + True + False + r(3, k(a).w(500)) + True + 0 + + + 0 + 1 + + + + + True + False + m(Control_L, k(a).k(x)) + True + 0 + + + 0 + 2 + + + + + True + False + CTRL + a, CTRL + x + 0 + + + 1 + 2 + + + + + True + False + k(1).h(k(2)).k(3) + True + 0 + + + 0 + 3 + + + + + True + False + moves the mouse cursor 10px to the right + 0 + + + 1 + 4 + + + + + True + False + writes 1 2 2 ... 2 2 3 while the key is pressed + 0 + + + 1 + 3 + + + + + True + False + e(EV_REL, REL_X, 10) + True + 0 + + + 0 + 4 + + + + + True + False + 1, 2 + 0 + + + 1 + 0 + + + + + True + False + which keeps moving the mouse while pressed + 0 + + + 1 + 5 + + + + + True + False + mouse(right, 4) + True + 0 + + + 0 + 5 + + + + + True + False + wheel(down, 1) + True + 0 + + + 0 + 6 + + + + + True + False + keeps scrolling down while held + 0 + + + 1 + 6 + + + + + False + False + 5 + + + + + True + False + 10 + 10 + 6 + Between calls to k, key down and key up events, macros will sleep for 10ms by default, which can be configured in ~/.config/key-mapper/config + True + True + 0 + 0.5 + + + False + True + 6 + + + + + + + + + Usage + Usage + 1 + + + + + + + True + False + True + + + True + False + stack1 + + + + + + + + diff --git a/keymapper/gui/row.py b/keymapper/gui/row.py index 4eb51ab7..f1bdafe2 100644 --- a/keymapper/gui/row.py +++ b/keymapper/gui/row.py @@ -208,8 +208,6 @@ class Row(Gtk.ListBoxRow): self.key = new_key - self.highlight() - character = self.get_character() # the character is empty and therefore the mapping is not complete @@ -223,14 +221,6 @@ class Row(Gtk.ListBoxRow): previous_key=previous_key ) - def highlight(self): - """Mark this row as changed.""" - self.get_style_context().add_class('changed') - - def unhighlight(self): - """Mark this row as unchanged.""" - self.get_style_context().remove_class('changed') - def on_character_input_change(self, _): """When the output character for that keycode is typed in.""" key = self.get_key() @@ -239,8 +229,6 @@ class Row(Gtk.ListBoxRow): if character is None: return - self.highlight() - if key is not None: custom_mapping.change( new_key=key, @@ -281,6 +269,7 @@ class Row(Gtk.ListBoxRow): self.keycode_input.set_active(False) self._state = IDLE keycode_reader.clear() + self.window.save_preset() def set_keycode_input_label(self, label): """Set the label of the keycode input.""" @@ -350,6 +339,10 @@ class Row(Gtk.ListBoxRow): 'changed', self.on_character_input_change ) + character_input.connect( + 'focus-out-event', + self.window.save_preset + ) box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL) box.set_homogeneous(False) diff --git a/keymapper/gui/window.py b/keymapper/gui/window.py index 33eef20e..e17abe56 100755 --- a/keymapper/gui/window.py +++ b/keymapper/gui/window.py @@ -28,10 +28,10 @@ from gi.repository import Gtk, Gdk, GLib from keymapper.data import get_data_path from keymapper.paths import get_config_path, get_preset_path -from keymapper.state import custom_mapping +from keymapper.state import custom_mapping, system_mapping from keymapper.presets import get_presets, find_newest_preset, \ delete_preset, rename_preset, get_available_preset_name -from keymapper.logger import logger +from keymapper.logger import logger, COMMIT_HASH, version, evdev_version from keymapper.getdevices import get_devices from keymapper.gui.row import Row, to_string from keymapper.gui.reader import keycode_reader @@ -52,29 +52,12 @@ CTX_SAVE = 0 CTX_APPLY = 1 CTX_ERROR = 3 CTX_WARNING = 4 +CTX_MAPPING = 5 CONTINUE = True GO_BACK = False -def get_selected_row_bg(): - """Get the background color that a row is going to have when selected.""" - # ListBoxRows can be selected, but either they are always selectable - # via mouse clicks and via code, or not at all. I just want to controll - # it over code. So I have to add a class and change the background color - # to act like it's selected. For this I need the right color, but - # @selected_bg_color doesn't work for every theme. So get it from - # some widget (which is deprecated according to the docs, but it works...) - row = Gtk.ListBoxRow() - row.show_all() - context = row.get_style_context() - color = context.get_background_color(Gtk.StateFlags.SELECTED) - # but this way it can be made only slightly highlighted, which is nice - color.alpha /= 4 - row.destroy() - return color.to_string() - - def with_selected_device(func): """Decorate a function to only execute if a device is selected.""" # this should only happen if no device was found at all @@ -115,6 +98,12 @@ class HandlerDisabled: self.widget.handler_unblock_by_func(self.handler) +def on_close_about(about, _): + """Hide the about dialog without destroying it.""" + about.hide() + return True + + class Window: """User Interface.""" def __init__(self): @@ -125,13 +114,7 @@ class Window: css_provider = Gtk.CssProvider() with open(get_data_path('style.css'), 'r') as file: - data = ( - file.read() + - '\n.changed{background-color:' + - get_selected_row_bg() + - ';}\n' - ) - css_provider.load_from_data(bytes(data, encoding='UTF-8')) + css_provider.load_from_data(bytes(file.read(), encoding='UTF-8')) Gtk.StyleContext.add_provider_for_screen( Gdk.Screen.get_default(), @@ -145,7 +128,14 @@ class Window: builder.connect_signals(self) self.builder = builder - self.unsaved_changes = builder.get_object('unsaved_changes') + self.confirm_delete = builder.get_object('confirm-delete') + self.about = builder.get_object('about-dialog') + self.about.connect('delete-event', on_close_about) + + self.get('version-label').set_text( + f'key-mapper {version} {COMMIT_HASH[:7]}' + f'\npython-evdev {evdev_version}' if evdev_version else '' + ) window = self.get('window') window.show() @@ -188,16 +178,12 @@ class Window: self.ctrl = False self.unreleased_warn = 0 - def unsaved_changes_dialog(self): + def show_confirm_delete(self): """Blocks until the user decided about an action.""" - self.unsaved_changes.show() - response = self.unsaved_changes.run() - self.unsaved_changes.hide() - - if response == Gtk.ResponseType.ACCEPT: - return CONTINUE - - return GO_BACK + self.confirm_delete.show() + response = self.confirm_delete.run() + self.confirm_delete.hide() + return response def key_press(self, _, event): """To execute shortcuts. @@ -257,6 +243,7 @@ class Window: 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) @@ -274,8 +261,9 @@ class Window: num_maps = len(custom_mapping) if num_rows < num_maps or num_rows > num_maps + 1: logger.error( - f'custom_mapping contains {len(custom_mapping)} rows, ' - f'but {num_rows} are displayed' + 'custom_mapping contains %d rows, ' + 'but %d are displayed', + len(custom_mapping), num_rows ) logger.spam( 'Mapping %s', @@ -318,8 +306,6 @@ class Window: This will destroy unsaved changes in the custom_mapping. """ - self.get('preset_name_input').set_text('') - device = self.selected_device presets = get_presets(device) @@ -341,7 +327,7 @@ class Window: for preset in presets: preset_selection.append(preset, preset) - # and select the newest one (on the top) + # and select the newest one (on the top). triggers on_select_preset preset_selection.set_active(0) def clear_mapping_table(self): @@ -350,11 +336,6 @@ class Window: key_list.forall(key_list.remove) custom_mapping.empty() - def unhighlight_all_rows(self): - """Remove all rows from the mappings table.""" - key_list = self.get('key_list') - key_list.forall(lambda row: row.unhighlight()) - def can_modify_mapping(self, *_): """Show a message if changing the mapping is not possible.""" if self.dbus.get_state(self.selected_device) != RUNNING: @@ -396,6 +377,8 @@ class Window: # they have already been read. key = keycode_reader.read() + # 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: @@ -424,28 +407,45 @@ class Window: GLib.timeout_add(100, self.show_device_mapping_status) def show_status(self, context_id, message, tooltip=None): - """Show a status message and set its tooltip.""" - if tooltip is None: - tooltip = message + """Show a status message and set its tooltip. - self.get('error_status_icon').hide() - self.get('warning_status_icon').hide() + If message is None, it will remove the newest message of the + given context_id. + """ + status_bar = self.get('status_bar') - if context_id == CTX_ERROR: - self.get('error_status_icon').show() + if message is None: + status_bar.remove_all(context_id) - if context_id == CTX_WARNING: - self.get('warning_status_icon').show() + if context_id in (CTX_ERROR, CTX_MAPPING): + self.get('error_status_icon').hide() - if len(message) > 55: - message = message[:52] + '...' + if context_id == CTX_WARNING: + self.get('warning_status_icon').hide() - status_bar = self.get('status_bar') - status_bar.push(context_id, message) - status_bar.set_tooltip_text(tooltip) + status_bar.set_tooltip_text('') + else: + if tooltip is None: + tooltip = message + + self.get('error_status_icon').hide() + self.get('warning_status_icon').hide() + + if context_id in (CTX_ERROR, CTX_MAPPING): + self.get('error_status_icon').show() + + if context_id == CTX_WARNING: + self.get('warning_status_icon').show() + + if len(message) > 55: + message = message[:52] + '...' + + status_bar.push(context_id, message) + status_bar.set_tooltip_text(tooltip) def check_macro_syntax(self): """Check if the programmed macros are allright.""" + self.show_status(CTX_MAPPING, None) for key, output in custom_mapping: if not is_this_a_macro(output): continue @@ -456,48 +456,43 @@ class Window: position = to_string(key) msg = f'Syntax error at {position}, hover for info' - self.show_status(CTX_ERROR, msg, error) + self.show_status(CTX_MAPPING, msg, error) - @with_selected_preset - def on_save_preset_clicked(self, _): - """Save changes to a preset to the file system.""" + 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() - try: - self.save_preset() - if new_name not in ['', self.selected_preset]: - # if a new name is entered - rename_preset( - self.selected_device, - self.selected_preset, - new_name - ) - # if the old preset was being autoloaded, change the - # name there as well - is_autoloaded = config.is_autoloaded( - self.selected_device, - self.selected_preset - ) - if is_autoloaded: - config.set_autoload_preset( - self.selected_device, - new_name - ) - # after saving the config, its modification date will be the - # newest, so populate_presets will automatically select the - # right one again. - self.populate_presets() - self.show_status(CTX_SAVE, f'Saved "{self.selected_preset}"') - self.check_macro_syntax() + if new_name in ['', self.selected_preset]: + return - except PermissionError as error: - error = str(error) - self.show_status(CTX_ERROR, 'Permission denied!', error) - logger.error(error) + self.save_preset() + + new_name = rename_preset( + self.selected_device, + self.selected_preset, + new_name + ) + + # if the old preset was being autoloaded, change the + # name there as well + is_autoloaded = config.is_autoloaded( + self.selected_device, + self.selected_preset + ) + if is_autoloaded: + config.set_autoload_preset(self.selected_device, new_name) + + self.get('preset_name_input').set_text('') + self.populate_presets() @with_selected_preset 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 delete_preset(self.selected_device, self.selected_preset) self.populate_presets() @@ -565,11 +560,9 @@ class Window: def on_select_device(self, dropdown): """List all presets, create one if none exist yet.""" - if dropdown.get_active_id() == self.selected_device: - return + self.save_preset() - if custom_mapping.changed and self.unsaved_changes_dialog() == GO_BACK: - dropdown.set_active_id(self.selected_device) + if dropdown.get_active_id() == self.selected_device: return # selecting a device will also automatically select a different @@ -637,13 +630,19 @@ class Window: else: self.get('apply_system_layout').set_opacity(0.4) + @with_selected_preset + def on_copy_preset_clicked(self, _): + """Copy the current preset and select it.""" + self.create_preset(True) + @with_selected_device def on_create_preset_clicked(self, _): """Create a new preset and select it.""" - if custom_mapping.changed and self.unsaved_changes_dialog() == GO_BACK: - return + self.create_preset() - copy = self.ctrl + def create_preset(self, copy=False): + """Create a new preset and select it.""" + self.save_preset() try: if copy: @@ -672,10 +671,6 @@ class Window: if dropdown.get_active_id() == self.selected_preset: return - if custom_mapping.changed and self.unsaved_changes_dialog() == GO_BACK: - dropdown.set_active_id(self.selected_preset) - return - self.clear_mapping_table() preset = dropdown.get_active_text() @@ -713,11 +708,13 @@ class Window: """Set the purpose of the left joystick.""" purpose = dropdown.get_active_id() custom_mapping.set('gamepad.joystick.left_purpose', purpose) + self.save_preset() def on_right_joystick_changed(self, dropdown): """Set the purpose of the right joystick.""" purpose = dropdown.get_active_id() custom_mapping.set('gamepad.joystick.right_purpose', purpose) + self.save_preset() def on_joystick_mouse_speed_changed(self, gtk_range): """Set how fast the joystick moves the mouse.""" @@ -744,16 +741,41 @@ class Window: # https://stackoverflow.com/a/30329591/4417769 key_list.remove(single_key_mapping) - def save_preset(self): + def save_preset(self, *_): """Write changes to presets to disk.""" - logger.info( - 'Updating configs for "%s", "%s"', - self.selected_device, - self.selected_preset - ) + if not custom_mapping.changed: + return + + try: + path = get_preset_path(self.selected_device, self.selected_preset) + custom_mapping.save(path) - path = get_preset_path(self.selected_device, self.selected_preset) - custom_mapping.save(path) + custom_mapping.changed = False - custom_mapping.changed = False - self.unhighlight_all_rows() + # after saving the config, its modification date will be the + # newest, so populate_presets will automatically select the + # right one again. + self.populate_presets() + except PermissionError as error: + error = str(error) + self.show_status(CTX_ERROR, 'Permission denied!', error) + logger.error(error) + + for _, character in custom_mapping: + if is_this_a_macro(character): + continue + + if system_mapping.get(character) is None: + self.show_status(CTX_MAPPING, f'Unknown mapping "{character}"') + break + else: + # no broken mappings found + self.show_status(CTX_MAPPING, None) + + # checking macros is probably a bit more expensive, do that if + # the regular mappings are allright + self.check_macro_syntax() + + def on_about_clicked(self, _): + """Show the about/help dialog.""" + self.about.show() diff --git a/keymapper/injection/injector.py b/keymapper/injection/injector.py index 843cbb5a..287ba61b 100644 --- a/keymapper/injection/injector.py +++ b/keymapper/injection/injector.py @@ -295,13 +295,12 @@ class Injector(multiprocessing.Process): loop.stop() return - def get_udef_name(self, name, prefix): + def get_udef_name(self, name, suffix): """Make sure the generated name is not longer than 80 chars.""" max_len = 80 # based on error messages - suffix = 'key-mapper' - remaining_len = max_len - len(suffix) - len(prefix) - 2 + remaining_len = max_len - len(DEV_NAME) - len(suffix) - 2 middle = name[:remaining_len] - name = f'{suffix} {middle} {prefix}' + name = f'{DEV_NAME} {middle} {suffix}' return name def run(self): diff --git a/keymapper/injection/macros.py b/keymapper/injection/macros.py index 27087b52..45ed226b 100644 --- a/keymapper/injection/macros.py +++ b/keymapper/injection/macros.py @@ -267,8 +267,6 @@ class _Macro: if code is None: raise KeyError(f'Unknown key "{character}"') - if EV_KEY not in self.capabilities: - self.capabilities[EV_KEY] = set() self.capabilities[EV_KEY].add(code) self.tasks.append(lambda handler: handler(EV_KEY, code, 1)) diff --git a/keymapper/logger.py b/keymapper/logger.py index 266c3f81..17e46d78 100644 --- a/keymapper/logger.py +++ b/keymapper/logger.py @@ -34,7 +34,7 @@ start = time.time() previous_key_spam = None -COMMIT_HASH = '' # overwritten in setup.py +COMMIT_HASH = '12ff3df22e47a2e8b7be2811b300512c2d597725' # overwritten in setup.py def spam(self, message, *args, **kwargs): @@ -140,6 +140,17 @@ logger.setLevel(logging.INFO) logging.getLogger('asyncio').setLevel(logging.WARNING) logger.main_pid = os.getpid() +try: + name = pkg_resources.require('key-mapper')[0].project_name + version = pkg_resources.require('key-mapper')[0].version + evdev_version = pkg_resources.require('evdev')[0].version +except pkg_resources.DistributionNotFound as error: + name = 'key-mapper' + version = '' + evdev_version = None + logger.info('Could not figure out the version') + logger.debug(error) + def is_debug(): """True, if the logger is currently in DEBUG or SPAM mode.""" @@ -149,19 +160,13 @@ def is_debug(): def log_info(): """Log version and name to the console""" # read values from setup.py - try: - name = pkg_resources.require('key-mapper')[0].project_name - version = pkg_resources.require('key-mapper')[0].version - logger.info( - '%s %s %s https://github.com/sezanzeb/key-mapper', - name, version, COMMIT_HASH - ) + logger.info( + '%s %s %s https://github.com/sezanzeb/key-mapper', + name, version, COMMIT_HASH + ) - evdev_version = pkg_resources.require('evdev')[0].version + if evdev_version: logger.info('python-evdev %s', evdev_version) - except pkg_resources.DistributionNotFound as error: - logger.info('Could not figure out the version') - logger.debug(error) if is_debug(): logger.warning( diff --git a/keymapper/presets.py b/keymapper/presets.py index 104f7008..c7ae719c 100644 --- a/keymapper/presets.py +++ b/keymapper/presets.py @@ -178,7 +178,7 @@ def delete_preset(device, preset): def rename_preset(device, old_preset_name, new_preset_name): """Rename one of the users presets while avoiding name conflicts.""" if new_preset_name == old_preset_name: - return + return None new_preset_name = get_available_preset_name(device, new_preset_name) logger.info('Moving "%s" to "%s"', old_preset_name, new_preset_name) @@ -189,3 +189,4 @@ def rename_preset(device, old_preset_name, new_preset_name): # set the modification date to now now = time.time() os.utime(get_preset_path(device, new_preset_name), (now, now)) + return new_preset_name diff --git a/readme/usage.md b/readme/usage.md index 56dc15a7..002cc3c4 100644 --- a/readme/usage.md +++ b/readme/usage.md @@ -100,7 +100,6 @@ Bear in mind that anti-cheat software might detect macros in games. ## UI Shortcuts -- Hold down `ctrl` and click on "new" to copy the current preset - `shift` + `del` stops the injection (only works while the gui is in focus) - `ctrl` + `q` closes the application diff --git a/tests/testcases/test_integration.py b/tests/testcases/test_integration.py index 0b40a34a..ebccd6c6 100644 --- a/tests/testcases/test_integration.py +++ b/tests/testcases/test_integration.py @@ -80,8 +80,6 @@ def launch(argv=None): gtk_iteration() - module.window.unsaved_changes.run = lambda: Gtk.ResponseType.ACCEPT - return module.window @@ -340,7 +338,6 @@ class TestIntegration(unittest.TestCase): row = rows[-1] self.assertIsNone(row.get_key()) self.assertEqual(row.character_input.get_text(), '') - self.assertNotIn('changed', row.get_style_context().list_classes()) self.assertEqual(row._state, IDLE) if char and not code_first: @@ -391,8 +388,6 @@ class TestIntegration(unittest.TestCase): if expect_success: self.assertEqual(row.get_key(), key) - css_classes = row.get_style_context().list_classes() - self.assertIn('changed', css_classes) self.assertEqual(row.keycode_input.get_label(), to_string(key)) self.assertFalse(row.keycode_input.is_focus()) self.assertEqual(len(keycode_reader._unreleased), 0) @@ -400,8 +395,6 @@ class TestIntegration(unittest.TestCase): if not expect_success: self.assertIsNone(row.get_key()) self.assertIsNone(row.get_character()) - css_classes = row.get_style_context().list_classes() - self.assertNotIn('changed', css_classes) self.assertEqual(row._state, IDLE) # it won't switch the focus to the character input self.assertTrue(row.keycode_input.is_focus()) @@ -463,23 +456,14 @@ class TestIntegration(unittest.TestCase): """save""" - self.window.on_save_preset_clicked(None) - for row in self.get_rows(): - css_classes = row.get_style_context().list_classes() - self.assertNotIn('changed', css_classes) - + # unfocusing the row triggers saving the preset + self.window.window.set_focus(None) self.assertFalse(custom_mapping.changed) """edit first row""" - # now change the first row and it should turn blue, - # but the other should remain unhighlighted row = self.get_rows()[0] row.character_input.set_text('c') - self.assertIn('changed', row.get_style_context().list_classes()) - for row in self.get_rows()[1:]: - css_classes = row.get_style_context().list_classes() - self.assertNotIn('changed', css_classes) self.assertEqual(custom_mapping.get_character(ev_1), 'c') self.assertEqual(custom_mapping.get_character(ev_2), 'k(b).k(c)') @@ -509,7 +493,6 @@ class TestIntegration(unittest.TestCase): self.assertEqual(custom_mapping.get_character(ev_2), 'b') self.assertEqual(custom_mapping.get_character(ev_3), 'c') self.assertEqual(custom_mapping.get_character(ev_4), 'd') - self.assertTrue(custom_mapping.changed) # and trying to add them as duplicate rows will be ignored for each # of them @@ -522,7 +505,6 @@ class TestIntegration(unittest.TestCase): self.assertEqual(custom_mapping.get_character(ev_2), 'b') self.assertEqual(custom_mapping.get_character(ev_3), 'c') self.assertEqual(custom_mapping.get_character(ev_4), 'd') - self.assertTrue(custom_mapping.changed) def test_combination(self): # it should be possible to write a key combination @@ -667,14 +649,15 @@ class TestIntegration(unittest.TestCase): custom_mapping.change(Key(EV_KEY, 14, 1), 'a', None) self.assertEqual(self.window.selected_preset, 'new preset') - self.window.on_save_preset_clicked(None) + self.window.save_preset() self.assertEqual(custom_mapping.get_character(Key(EV_KEY, 14, 1)), 'a') config.set_autoload_preset('device 1', 'new preset') self.assertTrue(config.is_autoloaded('device 1', 'new preset')) custom_mapping.change(Key(EV_KEY, 14, 1), 'b', None) self.window.get('preset_name_input').set_text('asdf') - self.window.on_save_preset_clicked(None) + self.window.save_preset() + self.window.on_rename_button_clicked(None) self.assertEqual(self.window.selected_preset, 'asdf') self.assertTrue(os.path.exists(f'{CONFIG_PATH}/presets/device 1/asdf.json')) self.assertEqual(custom_mapping.get_character(Key(EV_KEY, 14, 1)), 'b') @@ -682,9 +665,6 @@ class TestIntegration(unittest.TestCase): self.assertTrue(config.is_autoloaded('device 1', 'asdf')) error_icon = self.window.get('error_status_icon') - status = self.window.get('status_bar') - tooltip = status.get_tooltip_text().lower() - self.assertIn('saved', tooltip) self.assertFalse(error_icon.get_visible()) def test_rename_and_create(self): @@ -692,12 +672,73 @@ class TestIntegration(unittest.TestCase): # 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.on_save_preset_clicked(None) + self.window.save_preset() + self.window.on_rename_button_clicked(None) + self.assertEqual(len(custom_mapping), 1) self.assertEqual(self.window.selected_preset, 'asdf') self.window.on_create_preset_clicked(None) self.assertEqual(self.window.selected_preset, 'new preset') self.assertIsNone(custom_mapping.get_character(Key(EV_KEY, 14, 1))) + config.set_autoload_preset('device 1', 'new preset') + + # renaming another preset to an existing name appends a number + self.window.get('preset_name_input').set_text('asdf') + self.window.on_rename_button_clicked(None) + self.assertEqual(self.window.selected_preset, 'asdf 2') + # and that added number is correctly used in the autoload + # configuration as well + self.assertTrue(config.is_autoloaded('device 1', '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('device 1', 'new preset')) as f: + content = f.read() + self.assertNotIn('abcd', content) + + custom_mapping.changed = True + self.window.save_preset() + + with open(get_preset_path('device 1', 'new preset')) as f: + content = f.read() + self.assertIn('abcd', content) + + def test_check_for_unknown_characters(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('device 1', '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') @@ -705,17 +746,16 @@ class TestIntegration(unittest.TestCase): warning_icon = self.window.get('warning_status_icon') custom_mapping.change(Key(EV_KEY, 9, 1), 'k(1))', None) - self.window.on_save_preset_clicked(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.on_save_preset_clicked(None) - tooltip = status.get_tooltip_text().lower() + self.window.save_preset() + tooltip = (status.get_tooltip_text() or '').lower() self.assertNotIn('brackets', tooltip) - self.assertIn('saved', tooltip) self.assertFalse(error_icon.get_visible()) self.assertFalse(warning_icon.get_visible()) @@ -750,7 +790,8 @@ class TestIntegration(unittest.TestCase): self.assertEqual(self.window.selected_preset, 'new preset') self.assertFalse(os.path.exists(f'{CONFIG_PATH}/presets/device 1/abc 123.json')) custom_mapping.change(Key(EV_KEY, 10, 1), '1', None) - self.window.on_save_preset_clicked(None) + self.window.save_preset() + self.window.on_rename_button_clicked(None) gtk_iteration() self.assertEqual(self.window.selected_preset, 'abc 123') self.assertTrue(os.path.exists(f'{CONFIG_PATH}/presets/device 1/abc 123.json')) @@ -768,10 +809,9 @@ class TestIntegration(unittest.TestCase): self.change_empty_row(Key(EV_KEY, 81, 1), 'a') time.sleep(0.1) gtk_iteration() - self.window.on_save_preset_clicked(None) + self.window.save_preset() self.assertEqual(len(key_list.get_children()), 2) - self.window.ctrl = False self.window.on_create_preset_clicked(None) # the preset should be empty, only one empty row present @@ -781,21 +821,21 @@ class TestIntegration(unittest.TestCase): self.change_empty_row(Key(EV_KEY, 81, 1), 'b') time.sleep(0.1) gtk_iteration() - self.window.on_save_preset_clicked(None) + self.window.save_preset() self.assertEqual(len(key_list.get_children()), 2) # this time it should be copied - self.window.ctrl = True - self.window.on_create_preset_clicked(None) + self.window.on_copy_preset_clicked(None) self.assertEqual(self.window.selected_preset, 'new preset 2 copy') self.assertEqual(len(key_list.get_children()), 2) self.assertEqual(key_list.get_children()[0].get_character(), 'b') # make another copy - self.window.on_create_preset_clicked(None) + self.window.on_copy_preset_clicked(None) self.assertEqual(self.window.selected_preset, 'new preset 2 copy 2') self.assertEqual(len(key_list.get_children()), 2) self.assertEqual(key_list.get_children()[0].get_character(), 'b') + self.assertEqual(len(custom_mapping), 1) def test_gamepad_config(self): # set some stuff in the beginning, otherwise gtk fails to @@ -1016,7 +1056,7 @@ class TestIntegration(unittest.TestCase): ] * 100 custom_mapping.change(Key(EV_ABS, ABS_X, 1), 'a') - self.window.on_save_preset_clicked(None) + self.window.save_preset() gtk_iteration() @@ -1076,6 +1116,28 @@ class TestIntegration(unittest.TestCase): 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.selected_preset, 'asdf') + self.assertEqual(len(custom_mapping), 1) + self.window.save_preset() + self.assertTrue(os.path.exists(get_preset_path('device 1', '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('device 1', 'asdf'))) + self.assertEqual(self.window.selected_preset, 'asdf') + self.assertEqual(self.window.selected_device, 'device 1') + + 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('device 1', 'asdf'))) + self.assertEqual(self.window.selected_preset, 'new preset') + self.assertEqual(self.window.selected_device, 'device 1') + original_access = os.access original_getgrnam = grp.getgrnam diff --git a/tests/testcases/test_macros.py b/tests/testcases/test_macros.py index f64b21c5..02e9dc88 100644 --- a/tests/testcases/test_macros.py +++ b/tests/testcases/test_macros.py @@ -26,7 +26,7 @@ import asyncio from evdev.ecodes import EV_REL, EV_KEY, REL_Y, REL_X, REL_WHEEL, REL_HWHEEL from keymapper.injection.macros import parse, _Macro, _extract_params, \ - is_this_a_macro, _parse_recurse, handle_plus_syntax + is_this_a_macro, _parse_recurse, handle_plus_syntax, _count_brackets from keymapper.config import config from keymapper.mapping import Mapping from keymapper.state import system_mapping @@ -60,6 +60,8 @@ class TestMacros(unittest.TestCase): self.assertFalse(is_this_a_macro('btn_left')) self.assertFalse(is_this_a_macro('minus')) self.assertFalse(is_this_a_macro('k')) + self.assertFalse(is_this_a_macro(1)) + self.assertFalse(is_this_a_macro(None)) self.assertTrue(is_this_a_macro('a+b')) self.assertTrue(is_this_a_macro('a+b+c')) @@ -153,7 +155,8 @@ class TestMacros(unittest.TestCase): self.assertEqual(len(macro.child_macros), 0) def test_1(self): - macro = parse('k(1).k(a).k(3)', self.mapping) + # quotation marks are removed automatically and don't do any harm + macro = parse('k(1).k("a").k(3)', self.mapping) self.assertSetEqual(macro.get_capabilities()[EV_KEY], { system_mapping.get('1'), system_mapping.get('a'), @@ -197,6 +200,14 @@ class TestMacros(unittest.TestCase): self.assertIsNotNone(error) error = parse('r(1, k(1))', self.mapping, return_errors=True) self.assertIsNone(error) + error = parse('m(asdf, k(a))', self.mapping, return_errors=True) + self.assertIsNotNone(error) + error = parse('h(a)', self.mapping, return_errors=True) + self.assertIn('macro', error) + self.assertIn('a', error) + error = parse('foo(a)', self.mapping, return_errors=True) + self.assertIn('unknown', error.lower()) + self.assertIn('foo', error) def test_hold(self): macro = parse('k(1).h(k(a)).k(3)', self.mapping) @@ -207,6 +218,10 @@ class TestMacros(unittest.TestCase): }) macro.press_key() + self.loop.run_until_complete(asyncio.sleep(0.05)) + self.assertTrue(macro.is_holding()) + + macro.press_key() # redundantly calling doesn't break anything asyncio.ensure_future(macro.run(self.handler)) self.loop.run_until_complete(asyncio.sleep(0.2)) self.assertTrue(macro.is_holding()) @@ -493,7 +508,7 @@ class TestMacros(unittest.TestCase): self.assertEqual(len(macro.child_macros), 0) def test_event_2(self): - macro = parse('e(5421, 324, 154)', self.mapping) + macro = parse('r(1, e(5421, 324, 154))', self.mapping) code = 324 self.assertSetEqual(macro.get_capabilities()[5421], {324}) self.assertSetEqual(macro.get_capabilities()[EV_REL], set()) @@ -501,7 +516,17 @@ class TestMacros(unittest.TestCase): self.loop.run_until_complete(macro.run(self.handler)) self.assertListEqual(self.result, [(5421, code, 154)]) - self.assertEqual(len(macro.child_macros), 0) + self.assertEqual(len(macro.child_macros), 1) + + def test_count_brackets(self): + self.assertEqual(_count_brackets(''), 0) + self.assertEqual(_count_brackets('()'), 2) + self.assertEqual(_count_brackets('a()'), 3) + self.assertEqual(_count_brackets('a(b)'), 4) + self.assertEqual(_count_brackets('a(b())'), 6) + self.assertEqual(_count_brackets('a(b(c))'), 7) + self.assertEqual(_count_brackets('a(b(c))d'), 7) + self.assertEqual(_count_brackets('a(b(c))d()'), 7) if __name__ == '__main__':