diff --git a/data/key-mapper-128.png b/data/key-mapper-128.png new file mode 100644 index 00000000..a37af0f3 Binary files /dev/null and b/data/key-mapper-128.png differ 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__':