From d09f02d5738de0988eb2ceb8267d77cbd48430c5 Mon Sep 17 00:00:00 2001 From: sezanzeb Date: Thu, 31 Dec 2020 21:46:57 +0100 Subject: [PATCH] key combinations --- README.md | 3 - keymapper/daemon.py | 3 +- keymapper/dev/injector.py | 57 ++++++++- keymapper/dev/keycode_mapper.py | 69 +++++++--- keymapper/dev/reader.py | 38 +++++- keymapper/gtk/row.py | 72 ++++++++--- keymapper/gtk/window.py | 17 ++- keymapper/mapping.py | 105 +++++++++++---- keymapper/paths.py | 2 +- readme/development.md | 3 +- readme/usage.md | 16 ++- tests/test.py | 11 +- tests/testcases/test_injector.py | 169 +++++++++++++++++++++---- tests/testcases/test_integration.py | 128 +++++++++++++++++-- tests/testcases/test_keycode_mapper.py | 162 ++++++++++++++++++++++-- tests/testcases/test_mapping.py | 68 +++++++++- tests/testcases/test_reader.py | 77 ++++++++--- 17 files changed, 835 insertions(+), 165 deletions(-) diff --git a/README.md b/README.md index bb3a8d57..1b41e7d4 100644 --- a/README.md +++ b/README.md @@ -21,9 +21,6 @@ pacaur -S key-mapper-git ##### Ubuntu/Debian -Download a release .deb file from [/releases](https://github.com/sezanzeb/key-mapper/releases) -or install from source and dpkg using the following commands: - ```bash sudo apt install git python3-setuptools git clone https://github.com/sezanzeb/key-mapper.git diff --git a/keymapper/daemon.py b/keymapper/daemon.py index ec048764..98df5748 100644 --- a/keymapper/daemon.py +++ b/keymapper/daemon.py @@ -62,7 +62,8 @@ def get_dbus_interface(fallback=True): """ msg = ( 'The daemon "key-mapper-service" is not running, mapping keys ' - 'only works as long as the window is open.' + 'only works as long as the window is open. ' + 'Try `sudo systemctl start key-mapper`' ) if not is_service_running(): diff --git a/keymapper/dev/injector.py b/keymapper/dev/injector.py index 9fc450f9..e1c7ce5f 100644 --- a/keymapper/dev/injector.py +++ b/keymapper/dev/injector.py @@ -27,6 +27,7 @@ import asyncio import time import subprocess import multiprocessing +import itertools import evdev from evdev.ecodes import EV_KEY, EV_ABS, EV_REL @@ -97,6 +98,45 @@ def ensure_numlock(func): return wrapped +def store_permutations(target, combination, value): + """Store permutations for key combinations. + + Store every permutation of combination, while the last + element needs to remain the last one. It is the finishing + key. E.g. a + b is something different than b + a, but + a + b + c is the same as b + a + c + + a, b and c are tuples of (type, code, value) + + If combination is not a tuple of 3-tuples, it just uses it as key without + permutating anything. + """ + if not isinstance(combination, tuple): + logger.error('Expected a tuple, but got "%s"', combination) + return + + if isinstance(combination[0], tuple): + for permutation in itertools.permutations(combination[:-1]): + target[(*permutation, combination[-1])] = value + else: + target[combination] = value + + +def is_in_capabilities(key, capabilities): + """Are this key or all of its sub keys in the capabilities?""" + if isinstance(key[0], tuple): + # it's a key combination + for sub_key in key: + if is_in_capabilities(sub_key, capabilities): + return True + else: + ev_type, code, _ = key + if code in capabilities.get(ev_type, []): + return True + + return False + + class KeycodeInjector: """Keeps injecting keycodes in the background based on the mapping. @@ -139,7 +179,7 @@ class KeycodeInjector: logger.error('Don\'t know what %s is', output) continue - key_to_code[key] = target_code + store_permutations(key_to_code, key, target_code) return key_to_code @@ -173,8 +213,8 @@ class KeycodeInjector: capabilities = device.capabilities(absinfo=False) needed = False - for (ev_type, code, _), _ in self.mapping: - if code in capabilities.get(ev_type, []): + for key, _ in self.mapping: + if is_in_capabilities(key, capabilities): needed = True break @@ -319,7 +359,7 @@ class KeycodeInjector: if source is None: continue - # each device parses the macros with a different handler + # each device needs own macro instances to add a custom handler logger.debug('Parsing macros for %s', path) macros = {} for key, output in self.mapping: @@ -328,7 +368,7 @@ class KeycodeInjector: if macro is None: continue - macros[key] = macro + store_permutations(macros, key, macro) if len(macros) == 0: logger.debug('No macros configured') @@ -432,7 +472,12 @@ class KeycodeInjector: continue if should_map_event_as_btn(event.type, event.code): - handle_keycode(self._key_to_code, macros, event, uinput) + handle_keycode( + self._key_to_code, + macros, + event, + uinput + ) continue # forward the rest diff --git a/keymapper/dev/keycode_mapper.py b/keymapper/dev/keycode_mapper.py index cab056a8..e1824a25 100644 --- a/keymapper/dev/keycode_mapper.py +++ b/keymapper/dev/keycode_mapper.py @@ -39,11 +39,17 @@ from keymapper.dev.ev_abs_mapper import JOYSTICK # once, one for each direction. Only sequentially. active_macros = {} -# mapping of input (type, code) to the output keycode that has not yet -# been released. This is needed in order to release the correct event -# mapped on a D-Pad. Both directions on each axis report the same type, -# code and value (0) when releasing, but the correct release-event for -# the mapped output needs to be triggered. +# mapping of future up event (type, code) to (output code, input event) +# This is needed in order to release the correct event mapped on a +# D-Pad. Each direction on one D-Pad axis reports the same type and +# code, but different values. There cannot be both at the same time, +# as pressing one side of a D-Pad forces the other side to go up. +# "I have got this release event, what was this for?" +# It maps to (output_code, input_event) with input_event being the +# same as the key, but with the value of e.g. -1 or 1. The complete +# 3-tuple output event is used to track if a combined button press was done. +# A combination might be desired for D-Pad left, but not D-Pad right. +# (what_will_be_released, what_caused_the_key_down) unreleased = {} @@ -81,6 +87,10 @@ def is_key_up(event): return event.value == 0 +COMBINATION_INCOMPLETE = 1 # not all keys of the combination are pressed +NOT_COMBINED = 2 # this key is not part of a combination + + def handle_keycode(key_to_code, macros, event, uinput): """Write mapped keycodes, forward unmapped ones and manage macros. @@ -88,8 +98,12 @@ def handle_keycode(key_to_code, macros, event, uinput): ---------- key_to_code : dict mapping of (type, code, value) to linux-keycode + or multiple of those like ((...), (...), ...) for combinations + combinations need to be present in every possible valid ordering. + e.g. shift + alt + a and alt + shift + a macros : dict - mapping of (type, code, value) to _Macro objects + mapping of (type, code, value) to _Macro objects. + Combinations work similar as in key_to_code event : evdev.InputEvent """ if event.type == EV_KEY and event.value == 2: @@ -101,10 +115,22 @@ def handle_keycode(key_to_code, macros, event, uinput): # normalize event numbers to one of -1, 0, +1. Otherwise mapping # trigger values that are between 1 and 255 is not possible, because # they might skip the 1 when pressed fast enough. + # The key used to index the mappings key = (event.type, event.code, sign(event.value)) - short = (event.type, event.code) - existing_macro = active_macros.get(short) + # the tuple of the actual input event. Used to forward the event if it is + # not mapped, and to index unreleased and active_macros + event_tuple = (event.type, event.code, sign(event.value)) + type_code = (event.type, event.code) + + # the finishing key has to be the last element in combination, all + # others can have any arbitrary order. By checking all unreleased keys, + # a + b + c takes priority over b + c, if both mappings exist. + combination = tuple([value[1] for value in unreleased.values()] + [key]) + if combination in macros or combination in key_to_code: + key = combination + + existing_macro = active_macros.get(type_code) if existing_macro is not None: if is_key_up(event) and not existing_macro.running: # key was released, but macro already stopped @@ -125,36 +151,41 @@ def handle_keycode(key_to_code, macros, event, uinput): if key in macros: macro = macros[key] - active_macros[short] = macro + active_macros[type_code] = macro macro.press_key() logger.spam('got %s, maps to macro %s', key, macro.code) asyncio.ensure_future(macro.run()) return - if is_key_down(event) and short in unreleased: + if is_key_down(event) and type_code in unreleased: # duplicate key-down. skip this event. Avoid writing millions of # key-down events when a continuous value is reported, for example # for gamepad triggers logger.spam('%s, duplicate key down', key) return - if is_key_up(event) and short in unreleased: - target_type = EV_KEY + if is_key_up(event) and type_code in unreleased: + target_type, target_code = unreleased[type_code][0] target_value = 0 - target_code = unreleased[short] - del unreleased[short] logger.spam('%s, releasing %s', key, target_code) elif key in key_to_code and is_key_down(event): target_type = EV_KEY - target_value = 1 target_code = key_to_code[key] - unreleased[short] = target_code + target_value = 1 logger.spam('%s, maps to %s', key, target_code) else: - target_type = key[0] - target_code = key[1] - target_value = key[2] + target_type = event_tuple[0] + target_code = event_tuple[1] + target_value = event_tuple[2] logger.spam('%s, unmapped', key) + if is_key_down(event): + # for a combination, the last key that was pressed is also the + # key that releases it, so type_code is used to index this. + unreleased[type_code] = ((target_type, target_code), event_tuple) + + if is_key_up(event) and type_code in unreleased: + del unreleased[type_code] + uinput.write(target_type, target_code, target_value) uinput.syn() diff --git a/keymapper/dev/reader.py b/keymapper/dev/reader.py index 6fa232e0..09728e84 100644 --- a/keymapper/dev/reader.py +++ b/keymapper/dev/reader.py @@ -69,6 +69,11 @@ class _KeycodeReader: self._process = None self.fail_counter = 0 self.newest_event = None + # to keep track of combinations. + # "I have got this release event, what was this for?" + # A release event for a D-Pad axis might be any direction, hence + # this maps from release to input in order to remember it. + self._unreleased = {} def __del__(self): self.stop_reading() @@ -84,6 +89,7 @@ class _KeycodeReader: """Next time when reading don't return the previous keycode.""" # just call read to clear the pipe self.read() + self._unreleased = {} def start_reading(self, device_name): """Tell the evdev lib to start looking for keycodes. @@ -136,6 +142,10 @@ class _KeycodeReader: evdev.ecodes.BTN_TOOL_DOUBLETAP ] + if event.type == EV_KEY and event.value == 2: + # ignore hold-down events + return + if event.type == EV_KEY and event.code in click_events: # disable mapping the left mouse button because it would break # the mouse. Also it is emitted right when focusing the row @@ -182,6 +192,10 @@ class _KeycodeReader: ) del rlist[fd] + def are_keys_pressed(self): + """Check if any keys currently pressed down.""" + return len(self._unreleased) > 0 + def read(self): """Get the newest tuple of event type, keycode or None. @@ -203,10 +217,20 @@ class _KeycodeReader: while self._pipe[0].poll(): event = self._pipe[0].recv() + without_value = (event.type, event.code) if event.value == 0: + if without_value in self._unreleased: + del self._unreleased[without_value] + continue + self._unreleased[without_value] = ( + event.type, + event.code, + sign(event.value) + ) + time = event.sec + event.usec / 1000000 delta = time - newest_time @@ -229,11 +253,15 @@ class _KeycodeReader: self.newest_event = newest_event - return (None if newest_event is None else ( - newest_event.type, - newest_event.code, - sign(newest_event.value) - )) + if len(self._unreleased) > 1: + # a combination + return tuple(self._unreleased.values()) + elif len(self._unreleased) == 1: + # a single key + return list(self._unreleased.values())[0] + else: + # nothing + return None keycode_reader = _KeycodeReader() diff --git a/keymapper/gtk/row.py b/keymapper/gtk/row.py index f16f7186..bd89a859 100644 --- a/keymapper/gtk/row.py +++ b/keymapper/gtk/row.py @@ -23,7 +23,6 @@ import evdev - from gi.repository import Gtk, GLib, Gdk from keymapper.state import custom_mapping, system_mapping @@ -38,8 +37,13 @@ for name in system_mapping.list_names(): store.append([name]) -def to_string(ev_type, code, value): +def to_string(key): """A nice to show description of the pressed key.""" + if isinstance(key[0], tuple): + return ' + '.join([to_string(sub_key) for sub_key in key]) + + ev_type, code, value = key + try: key_name = evdev.ecodes.bytype[ev_type][code] if isinstance(key_name, list): @@ -68,6 +72,10 @@ def to_string(ev_type, code, value): return 'unknown' +IDLE = 0 +HOLDING = 1 + + class Row(Gtk.ListBoxRow): """A single, configurable key mapping.""" __gtype_name__ = 'ListBoxRow' @@ -92,6 +100,17 @@ class Row(Gtk.ListBoxRow): self.put_together(character) + self.state = IDLE + + def release(self): + """Tell the row that no keys are currently pressed down.""" + if self.state == HOLDING: + # A key was pressed and then released. + # Switch to the character. idle_add this so that the + # keycode event won't write into the character input as well. + window = self.window.window + GLib.idle_add(lambda: window.set_focus(self.character_input)) + def get_keycode(self): """Get a tuple of type, code and value from the left column. @@ -114,6 +133,9 @@ class Row(Gtk.ListBoxRow): 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 @@ -121,21 +143,20 @@ class Row(Gtk.ListBoxRow): # keycode is already set by some other row existing = custom_mapping.get_character(new_key) if existing is not None: - msg = f'"{to_string(*new_key)}" already mapped to "{existing}"' + msg = f'"{to_string(new_key)}" already mapped to "{existing}"' logger.info(msg) - self.window.get('status_bar').push(CTX_KEYCODE, msg) + self.window.show_status(CTX_KEYCODE, msg) return # it's legal to display the keycode self.window.get('status_bar').remove_all(CTX_KEYCODE) - self.keycode_input.set_label(to_string(*new_key)) + + # 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 - # switch to the character, don't require mouse input because - # that would overwrite the key with the mouse-button key if - # the current device is a mouse. idle_add this so that the - # keycode event won't write into the character input as well. - window = self.window.window - GLib.idle_add(lambda: window.set_focus(self.character_input)) + self.highlight() character = self.get_character() @@ -186,7 +207,7 @@ class Row(Gtk.ListBoxRow): if self.get_keycode() is not None: return - self.keycode_input.set_label('click here') + self.set_keycode_input_label('click here') self.keycode_input.set_opacity(0.3) def show_press_key(self): @@ -194,18 +215,29 @@ class Row(Gtk.ListBoxRow): if self.get_keycode() is not None: return - self.keycode_input.set_label('press key') + self.set_keycode_input_label('press key') self.keycode_input.set_opacity(1) - def keycode_input_focus(self, *args): + def on_keycode_input_focus(self, *args): """Refresh useful usage information.""" self.show_press_key() self.window.can_modify_mapping() - def keycode_input_unfocus(self, *args): - """Refresh useful usage information.""" + def on_keycode_input_unfocus(self, *args): + """Refresh useful usage information and set some state stuff.""" self.show_click_here() self.keycode_input.set_active(False) + self.state = IDLE + + 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 + self.keycode_input.get_child().set_line_wrap(True) + self.keycode_input.get_child().set_line_wrap_mode(2) + self.keycode_input.get_child().set_max_width_chars(15) + self.keycode_input.get_child().set_justify(Gtk.Justification.CENTER) def put_together(self, character): """Create all child GTK widgets and connect their signals.""" @@ -225,7 +257,7 @@ class Row(Gtk.ListBoxRow): keycode_input.set_size_request(140, -1) if self.key is not None: - keycode_input.set_label(to_string(*self.key)) + self.set_keycode_input_label(to_string(self.key)) else: self.show_click_here() @@ -233,11 +265,11 @@ class Row(Gtk.ListBoxRow): # something else in the UI keycode_input.connect( 'focus-in-event', - self.keycode_input_focus + self.on_keycode_input_focus ) keycode_input.connect( 'focus-out-event', - self.keycode_input_unfocus + 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 @@ -284,6 +316,6 @@ class Row(Gtk.ListBoxRow): custom_mapping.clear(key) self.character_input.set_text('') - self.keycode_input.set_label('') + self.set_keycode_input_label('') self.key = None self.delete_callback(self) diff --git a/keymapper/gtk/window.py b/keymapper/gtk/window.py index cdd46649..22491983 100755 --- a/keymapper/gtk/window.py +++ b/keymapper/gtk/window.py @@ -343,15 +343,23 @@ class Window: @with_selected_device def consume_newest_keycode(self): """To capture events from keyboards, mice and gamepads.""" + row, focused = self.get_focused_row() + # the "event" event of Gtk.Window wouldn't trigger on gamepad # events, so it became a GLib timeout to periodically check kernel # events. key = keycode_reader.read() + if isinstance(focused, Gtk.ToggleButton): + if not keycode_reader.are_keys_pressed(): + row.release() + return True + if key is None: return True - self.get('keycode').set_text(to_string(*key)) + # only show the latest key, becomes too long otherwise + self.get('keycode').set_text(to_string(key).split('+')[-1].strip()) # inform the currently selected row about the new keycode row, focused = self.get_focused_row() @@ -362,7 +370,7 @@ class Window: @with_selected_device def on_apply_system_layout_clicked(self, _): - """Load the mapping.""" + """Stop injecting the mapping.""" self.dbus.stop_injecting(self.selected_device) self.show_status(CTX_APPLY, 'Applied the system default') logger.info('Applied system default for "%s"', self.selected_preset) @@ -382,6 +390,9 @@ class Window: if context_id == CTX_WARNING: self.get('warning_status_icon').show() + if len(message) > 40: + message = message[:37] + '...' + status_bar = self.get('status_bar') status_bar.push(context_id, message) status_bar.set_tooltip_text(tooltip) @@ -396,7 +407,7 @@ class Window: if error is None: continue - position = to_string(*key) + position = to_string(key) msg = f'Syntax error at {position}, hover for info' self.show_status(CTX_ERROR, msg, error) diff --git a/keymapper/mapping.py b/keymapper/mapping.py index 6dfb5f01..ce18a1f5 100644 --- a/keymapper/mapping.py +++ b/keymapper/mapping.py @@ -24,6 +24,7 @@ import os import json +import itertools import copy from keymapper.logger import logger @@ -32,12 +33,47 @@ from keymapper.config import ConfigBase, config def verify_key(key): - """Check if the key describes a tuple of (type, code, value) ints.""" - if len(key) != 3: + """Check if the key describes a tuple or tuples of (type, code, value). + + For combinations it could be e.g. ((1, 2, 1), (1, 3, 1)). + """ + if not isinstance(key, tuple): raise ValueError(f'Expected keys to be a 3-tuple, but got {key}') - if sum([not isinstance(value, int) for value in key]) != 0: - raise ValueError(f'Can only use numbers in the tuples, but got {key}') + if isinstance(key[0], tuple): + for sub_key in key: + verify_key(sub_key) + else: + if len(key) != 3: + raise ValueError(f'Expected key to be a 3-tuple, but got {key}') + if sum([not isinstance(value, int) for value in key]) != 0: + raise ValueError(f'Can only use numbers, but got {key}') + + +def split_key(key): + """Take a key like "1,2,3" and return a 3-tuple of ints.""" + if ',' not in key: + logger.error('Found invalid key: "%s"', key) + return None + + if key.count(',') == 1: + # support for legacy mapping objects that didn't include + # the value in the key + ev_type, code = key.split(',') + value = 1 + elif key.count(',') == 2: + ev_type, code, value = key.split(',') + else: + logger.error('Found more than two commas in the key: "%s"', key) + return None + + try: + key = (int(ev_type), int(code), int(value)) + except ValueError: + logger.error('Found non-int in: "%s"', key) + return None + + return key class Mapping(ConfigBase): @@ -49,7 +85,7 @@ class Mapping(ConfigBase): def __iter__(self): """Iterate over tuples of unique keycodes and their character.""" - return iter(sorted(self._mapping.items())) + return iter(self._mapping.items()) def __len__(self): return len(self._mapping) @@ -100,6 +136,7 @@ class Mapping(ConfigBase): '%s will map to %s, replacing %s', new_key, character, previous_key ) + self.clear(new_key) # this also clears all equivalent keys self._mapping[new_key] = character if previous_key is not None: @@ -126,6 +163,14 @@ class Mapping(ConfigBase): """ verify_key(key) + if isinstance(key[0], tuple): + for permutation in itertools.permutations(key[:-1]): + permutation += (key[-1],) + if permutation in self._mapping: + logger.debug('%s will be cleared', permutation) + del self._mapping[permutation] + return + if self._mapping.get(key) is not None: logger.debug('%s will be cleared', key) del self._mapping[key] @@ -163,24 +208,15 @@ class Mapping(ConfigBase): return for key, character in preset_dict['mapping'].items(): - if ',' not in key: - logger.error('Found invalid key: "%s"', key) - continue - - if key.count(',') == 1: - # support for legacy mapping objects that didn't include - # the value in the key - ev_type, code = key.split(',') - value = 1 - - if key.count(',') == 2: - ev_type, code, value = key.split(',') - - try: - key = (int(ev_type), int(code), int(value)) - except ValueError: - logger.error('Found non-int in: "%s"', key) - continue + if '+' in key: + chunks = key.split('+') + key = tuple([split_key(chunk) for chunk in chunks]) + if None in key: + continue + else: + key = split_key(key) + if key is None: + continue logger.spam('%s maps to %s', key, character) self._mapping[key] = character @@ -218,7 +254,17 @@ class Mapping(ConfigBase): json_ready_mapping = {} # tuple keys are not possible in json, encode them as string for key, value in self._mapping.items(): - new_key = ','.join([str(value) for value in key]) + if isinstance(key[0], tuple): + # combinations to "1,2,1+1,3,1" + new_key = '+'.join([ + ','.join([ + str(value) + for value in sub_key + ]) + for sub_key in key + ]) + else: + new_key = ','.join([str(value) for value in key]) json_ready_mapping[new_key] = value preset_dict['mapping'] = json_ready_mapping @@ -239,5 +285,16 @@ class Mapping(ConfigBase): event types. value : int event value. Usually you want 1 (down) + + Or a tuple of multiple of those. Checks any possible permutation + with the last key being always at the end, to work well with + combinations. """ + if isinstance(key[0], tuple): + for permutation in itertools.permutations(key[:-1]): + permutation += (key[-1],) + existing = self._mapping.get(permutation) + if existing is not None: + return existing + return self._mapping.get(key) diff --git a/keymapper/paths.py b/keymapper/paths.py index 455c3fd5..4e6b73d4 100644 --- a/keymapper/paths.py +++ b/keymapper/paths.py @@ -31,7 +31,7 @@ from keymapper.logger import logger def get_user(): - """Try to find the user who called sudo.""" + """Try to find the user who called sudo/pkexec.""" try: return os.getlogin() except OSError: diff --git a/readme/development.md b/readme/development.md index ccf21dae..90828dfc 100644 --- a/readme/development.md +++ b/readme/development.md @@ -25,11 +25,12 @@ requests. - [x] configure joystick purpose and speed via the GUI - [x] support for non-GUI TTY environments with a command to stop and start - [x] start the daemon in such a way to not require usermod +- [x] mapping a combined button press to a key - [ ] mapping joystick directions as buttons, making it act like a D-Pad - [ ] automatically load presets when devices get plugged in after login (udev) -- [ ] mapping a combined button press to a key - [ ] configure locale for preset to provide a different set of possible keys - [ ] user-friendly way to map btn_left +- [ ] add "disable" as mapping option ## Tests diff --git a/readme/usage.md b/readme/usage.md index 20486982..5a4f0928 100644 --- a/readme/usage.md +++ b/readme/usage.md @@ -114,19 +114,21 @@ Here is an example configuration for preset "a" for the "gamepad" device: "keystroke_sleep_ms": 100 }, "mapping": { - "1,315,1": "1", + "1,315,1+1,16,-1": "1", "1,307,1": "k(2).k(3)" } } ``` Both need to be valid json files, otherwise the parser refuses to work. This -preset maps the EV_KEY down event with code 315 to '1', code 307 to a macro -and sets the time between injected events of macros to 100 ms. Note that -a complete keystroke consists of two events: down and up. Other than that, -it inherits all configurations from `~/.config/key-mapper/config.json`. -If config.json is missing some stuff, it will query the hardcoded default -values. +preset maps the EV_KEY down event with code 307 to a macro and sets the time +between injected events of macros to 100 ms. The other mapping is a key +combination, chained using `+`. + +Note that a complete keystroke consists of two events: down and up. Other +than that, it inherits all configurations from +`~/.config/key-mapper/config.json`. If config.json is missing some stuff, +it will query the hardcoded default values. The event codes can be read using `evtest`. Available names in the mapping can be listed with `key-mapper-control --key-names`. diff --git a/tests/test.py b/tests/test.py index f2081ec9..37bf420f 100644 --- a/tests/test.py +++ b/tests/test.py @@ -352,9 +352,10 @@ patch_select() from keymapper.logger import update_verbosity from keymapper.dev.injector import KeycodeInjector from keymapper.config import config +from keymapper.dev.reader import keycode_reader from keymapper.getdevices import refresh_devices from keymapper.state import system_mapping, custom_mapping -from keymapper.dev.keycode_mapper import active_macros +from keymapper.dev.keycode_mapper import active_macros, unreleased # no need for a high number in tests KeycodeInjector.regrab_timeout = 0.15 @@ -365,6 +366,11 @@ _fixture_copy = copy.deepcopy(fixtures) def cleanup(): """Reset the applications state.""" + keycode_reader.stop_reading() + keycode_reader.clear() + keycode_reader.newest_event = None + keycode_reader._unreleased = {} + for task in asyncio.Task.all_tasks(): task.cancel() @@ -383,6 +389,9 @@ def cleanup(): for key in list(active_macros.keys()): del active_macros[key] + for key in list(unreleased.keys()): + del unreleased[key] + for key in list(pending_events.keys()): del pending_events[key] diff --git a/tests/testcases/test_injector.py b/tests/testcases/test_injector.py index e6026c6e..28b2f12b 100644 --- a/tests/testcases/test_injector.py +++ b/tests/testcases/test_injector.py @@ -24,10 +24,10 @@ import time import copy import evdev -from evdev.ecodes import EV_REL, EV_KEY, EV_ABS, ABS_HAT0X +from evdev.ecodes import EV_REL, EV_KEY, EV_ABS, ABS_HAT0X, BTN_A from keymapper.dev.injector import is_numlock_on, set_numlock, \ - ensure_numlock, KeycodeInjector + ensure_numlock, KeycodeInjector, store_permutations, is_in_capabilities from keymapper.state import custom_mapping, system_mapping from keymapper.mapping import Mapping from keymapper.config import config @@ -328,14 +328,18 @@ class TestInjector(unittest.TestCase): self.assertAlmostEqual(history[-2][2], -1) def test_injector(self): + # the tests in test_keycode_mapper.py test this stuff in detail + numlock_before = is_numlock_on() - custom_mapping.change((EV_KEY, 8, 1), 'k(KEY_Q).k(w)') + combination = ((EV_KEY, 8, 1), (EV_KEY, 9, 1)) + custom_mapping.change(combination, 'k(KEY_Q).k(w)') custom_mapping.change((EV_ABS, ABS_HAT0X, -1), 'a') # one mapping that is unknown in the system_mapping on purpose input_b = 10 custom_mapping.change((EV_KEY, input_b, 1), 'b') + # stuff the custom_mapping outputs (except for the unknown b) system_mapping.clear() code_a = 100 code_q = 101 @@ -344,13 +348,13 @@ class TestInjector(unittest.TestCase): system_mapping._set('key_q', code_q) system_mapping._set('w', code_w) - # the second arg of those event objects is 8 lower than the - # keycode used in X and in the mappings pending_events['device 2'] = [ - # should execute a macro + # should execute a macro... InputEvent(EV_KEY, 8, 1), + InputEvent(EV_KEY, 9, 1), # ...now InputEvent(EV_KEY, 8, 0), - # gamepad stuff + InputEvent(EV_KEY, 9, 0), + # gamepad stuff. trigger a combination InputEvent(EV_ABS, ABS_HAT0X, -1), InputEvent(EV_ABS, ABS_HAT0X, 0), # just pass those over without modifying @@ -375,41 +379,49 @@ class TestInjector(unittest.TestCase): event = uinput_write_history_pipe[0].recv() history.append((event.type, event.code, event.value)) + # 1 event before the combination was triggered (+1 for release) # 4 events for the macro # 2 for mapped keys # 3 for forwarded events - self.assertEqual(len(history), 9) + self.assertEqual(len(history), 11) # since the macro takes a little bit of time to execute, its # keystrokes are all over the place. # just check if they are there and if so, remove them from the list. - ev_key = EV_KEY - self.assertIn((ev_key, code_q, 1), history) - self.assertIn((ev_key, code_q, 0), history) - self.assertIn((ev_key, code_w, 1), history) - self.assertIn((ev_key, code_w, 0), history) - index_q_1 = history.index((ev_key, code_q, 1)) - index_q_0 = history.index((ev_key, code_q, 0)) - index_w_1 = history.index((ev_key, code_w, 1)) - index_w_0 = history.index((ev_key, code_w, 0)) + self.assertIn((EV_KEY, 8, 1), history) + self.assertIn((EV_KEY, code_q, 1), history) + self.assertIn((EV_KEY, code_q, 1), history) + self.assertIn((EV_KEY, code_q, 0), history) + self.assertIn((EV_KEY, code_w, 1), history) + self.assertIn((EV_KEY, code_w, 0), history) + index_q_1 = history.index((EV_KEY, code_q, 1)) + index_q_0 = history.index((EV_KEY, code_q, 0)) + index_w_1 = history.index((EV_KEY, code_w, 1)) + index_w_0 = history.index((EV_KEY, code_w, 0)) self.assertGreater(index_q_0, index_q_1) self.assertGreater(index_w_1, index_q_0) self.assertGreater(index_w_0, index_w_1) del history[index_q_1] - index_q_0 = history.index((ev_key, code_q, 0)) + index_q_0 = history.index((EV_KEY, code_q, 0)) del history[index_q_0] - index_w_1 = history.index((ev_key, code_w, 1)) + index_w_1 = history.index((EV_KEY, code_w, 1)) del history[index_w_1] - index_w_0 = history.index((ev_key, code_w, 0)) + index_w_0 = history.index((EV_KEY, code_w, 0)) del history[index_w_0] # the rest should be in order. - # this should be 1. injected keycodes should always be either 0 or 1 - self.assertEqual(history[0], (ev_key, code_a, 1)) - self.assertEqual(history[1], (ev_key, code_a, 0)) - self.assertEqual(history[2], (ev_key, input_b, 1)) - self.assertEqual(history[3], (ev_key, input_b, 0)) - self.assertEqual(history[4], (3124, 3564, 6542)) + # first the incomplete combination key that wasn't mapped to anything + # and just forwarded. The input event that triggered the macro + # won't appear here. + self.assertEqual(history[0], (EV_KEY, 8, 1)) + self.assertEqual(history[1], (EV_KEY, 8, 0)) + # value should be 1, even if the input event was -1. + # Injected keycodes should always be either 0 or 1 + self.assertEqual(history[2], (EV_KEY, code_a, 1)) + self.assertEqual(history[3], (EV_KEY, code_a, 0)) + self.assertEqual(history[4], (EV_KEY, input_b, 1)) + self.assertEqual(history[5], (EV_KEY, input_b, 0)) + self.assertEqual(history[6], (3124, 3564, 6542)) time.sleep(0.1) self.assertTrue(self.injector._process.is_alive()) @@ -417,6 +429,111 @@ class TestInjector(unittest.TestCase): numlock_after = is_numlock_on() self.assertEqual(numlock_before, numlock_after) + def test_store_permutations(self): + target = {} + + store_permutations(target, ((1,), (2,), (3,), (4,)), 1234) + self.assertEqual(len(target), 6) + self.assertEqual(target[((1,), (2,), (3,), (4,))], 1234) + self.assertEqual(target[((1,), (3,), (2,), (4,))], 1234) + self.assertEqual(target[((2,), (1,), (3,), (4,))], 1234) + self.assertEqual(target[((2,), (3,), (1,), (4,))], 1234) + self.assertEqual(target[((3,), (1,), (2,), (4,))], 1234) + self.assertEqual(target[((3,), (2,), (1,), (4,))], 1234) + + store_permutations(target, ((1,), (2,)), 5678) + self.assertEqual(len(target), 7) + self.assertEqual(target[((1,), (2,))], 5678) + + store_permutations(target, ((1,),), 3456) + self.assertEqual(len(target), 8) + self.assertEqual(target[((1,),)], 3456) + + store_permutations(target, (1,), 7890) + self.assertEqual(len(target), 9) + self.assertEqual(target[(1,)], 7890) + + # only accepts tuples, because key-mapper always uses tuples + # for this stuff + store_permutations(target, 1, 1357) + self.assertEqual(len(target), 9) + + def test_store_permutations_for_macros(self): + mapping = Mapping() + ev_1 = (EV_KEY, 41, 1) + ev_2 = (EV_KEY, 42, 1) + ev_3 = (EV_KEY, 43, 1) + # a combination + mapping.change((ev_1, ev_2, ev_3), 'k(a)') + self.injector = KeycodeInjector('device 1', mapping) + + history = [] + + class Stop(Exception): + pass + + def _modify_capabilities(*args): + history.append(args) + # avoid going into any mainloop + raise Stop() + + self.injector._modify_capabilities = _modify_capabilities + try: + self.injector._start_injecting() + except Stop: + pass + + # one call + self.assertEqual(len(history), 1) + # first argument of the first call + self.assertEqual(len(history[0][0]), 2) + self.assertEqual(history[0][0][(ev_1, ev_2, ev_3)].code, 'k(a)') + self.assertEqual(history[0][0][(ev_2, ev_1, ev_3)].code, 'k(a)') + + def test_key_to_code(self): + mapping = Mapping() + ev_1 = (EV_KEY, 41, 1) + ev_2 = (EV_KEY, 42, 1) + ev_3 = (EV_KEY, 43, 1) + ev_4 = (EV_KEY, 44, 1) + mapping.change(ev_1, 'a') + # a combination + mapping.change((ev_2, ev_3, ev_4), 'b') + self.assertEqual(mapping.get_character((ev_2, ev_3, ev_4)), 'b') + + system_mapping.clear() + system_mapping._set('a', 51) + system_mapping._set('b', 52) + + injector = KeycodeInjector('device 1', mapping) + self.assertEqual(injector._key_to_code.get(ev_1), 51) + # permutations to make matching combinations easier + self.assertEqual(injector._key_to_code.get((ev_2, ev_3, ev_4)), 52) + self.assertEqual(injector._key_to_code.get((ev_3, ev_2, ev_4)), 52) + self.assertEqual(len(injector._key_to_code), 3) + + def test_is_in_capabilities(self): + key = (1, 2, 1) + capabilities = { + 1: [9, 2, 5] + } + self.assertTrue(is_in_capabilities(key, capabilities)) + + key = ((1, 2, 1), (1, 3, 1)) + capabilities = { + 1: [9, 2, 5] + } + # only one of the codes of the combination is required. + # The goal is to make combinations across those sub-devices possible, + # that make up one hardware device + self.assertTrue(is_in_capabilities(key, capabilities)) + + key = ((1, 2, 1), (1, 5, 1)) + capabilities = { + 1: [9, 2, 5] + } + self.assertTrue(is_in_capabilities(key, capabilities)) + if __name__ == "__main__": unittest.main() diff --git a/tests/testcases/test_integration.py b/tests/testcases/test_integration.py index efe3494b..3b5d4bde 100644 --- a/tests/testcases/test_integration.py +++ b/tests/testcases/test_integration.py @@ -40,7 +40,7 @@ from keymapper.state import custom_mapping, system_mapping, XMODMAP_FILENAME from keymapper.paths import CONFIG_PATH, get_preset_path from keymapper.config import config, WHEEL, MOUSE from keymapper.dev.reader import keycode_reader -from keymapper.gtk.row import to_string +from keymapper.gtk.row import to_string, HOLDING, IDLE from keymapper.dev import permissions from tests.test import tmp, pending_events, InputEvent, \ @@ -227,11 +227,18 @@ class TestIntegration(unittest.TestCase): def test_row_keycode_to_string(self): # not an integration test, but I have all the row tests here already - self.assertEqual(to_string(EV_KEY, evdev.ecodes.KEY_9, 1), '9') - self.assertEqual(to_string(EV_KEY, evdev.ecodes.KEY_SEMICOLON, 1), 'SEMICOLON') - self.assertEqual(to_string(EV_ABS, evdev.ecodes.ABS_HAT0X, -1), 'ABS_HAT0X L') - self.assertEqual(to_string(EV_ABS, evdev.ecodes.ABS_HAT0X, 1), 'ABS_HAT0X R') - self.assertEqual(to_string(EV_KEY, evdev.ecodes.BTN_A, 1), 'BTN_A') + self.assertEqual(to_string((EV_KEY, evdev.ecodes.KEY_9, 1)), '9') + self.assertEqual(to_string((EV_KEY, evdev.ecodes.KEY_SEMICOLON, 1)), 'SEMICOLON') + self.assertEqual(to_string((EV_ABS, evdev.ecodes.ABS_HAT0X, -1)), 'ABS_HAT0X L') + self.assertEqual(to_string((EV_ABS, evdev.ecodes.ABS_HAT0X, 1)), 'ABS_HAT0X R') + self.assertEqual(to_string((EV_KEY, evdev.ecodes.BTN_A, 1)), 'BTN_A') + + # combinations + self.assertEqual(to_string(( + (EV_KEY, evdev.ecodes.BTN_A, 1), + (EV_KEY, evdev.ecodes.BTN_B, 1), + (EV_KEY, evdev.ecodes.BTN_C, 1) + )), 'BTN_A + BTN_B + BTN_C') def test_row_simple(self): rows = self.window.get('key_list').get_children() @@ -278,7 +285,8 @@ class TestIntegration(unittest.TestCase): Parameters ---------- key : int, int, int - type, code, value + type, code, value, + or a tuple of multiple of those code_first : boolean If True, the code is entered and then the character. If False, the character is entered first. @@ -287,6 +295,8 @@ class TestIntegration(unittest.TestCase): in the mapping eventually. False if this change is going to cause a duplicate. """ + self.assertFalse(keycode_reader.are_keys_pressed()) + # wait for the window to create a new empty row if needed time.sleep(0.1) gtk_iteration() @@ -297,6 +307,7 @@ class TestIntegration(unittest.TestCase): self.assertIsNone(row.get_keycode()) 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: # set the character to make the new row complete @@ -316,21 +327,52 @@ class TestIntegration(unittest.TestCase): if key: # modifies the keycode in the row not by writing into the input, - # but by sending an event - keycode_reader._pipe[1].send(InputEvent(*key)) - time.sleep(0.1) + # but by sending an event. Events should be consumed 30 times + # per second, so sleep a bit more than 0.033ms each time + # press down all the keys of a combination + if isinstance(key[0], tuple): + for sub_key in key: + keycode_reader._pipe[1].send(InputEvent(*sub_key)) + else: + keycode_reader._pipe[1].send(InputEvent(*key)) + + # make the window consume the keycode + time.sleep(0.05) gtk_iteration() + # holding down + self.assertTrue(keycode_reader.are_keys_pressed()) + self.assertEqual(row.state, HOLDING) + self.assertTrue(row.keycode_input.is_focus()) + + # release all the keys + if isinstance(key[0], tuple): + for sub_key in key: + keycode_reader._pipe[1].send(InputEvent(*sub_key[:2], 0)) + else: + keycode_reader._pipe[1].send(InputEvent(*key[:2], 0)) + + # make the window consume the keycode + time.sleep(0.05) + gtk_iteration() + + # released + self.assertFalse(keycode_reader.are_keys_pressed()) + self.assertEqual(row.state, IDLE) + if expect_success: self.assertEqual(row.get_keycode(), 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.assertEqual(row.keycode_input.get_label(), to_string(key)) + self.assertFalse(row.keycode_input.is_focus()) if not expect_success: self.assertIsNone(row.get_keycode()) self.assertIsNone(row.get_character()) - self.assertNotIn('changed', row.get_style_context().list_classes()) + css_classes = row.get_style_context().list_classes() + self.assertNotIn('changed', css_classes) + self.assertEqual(row.state, IDLE) return row if char and code_first: @@ -427,6 +469,68 @@ class TestIntegration(unittest.TestCase): 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 + ev_1 = (EV_KEY, evdev.ecodes.KEY_A, 1) + ev_2 = (EV_ABS, evdev.ecodes.ABS_HAT0X, 1) + ev_3 = (EV_KEY, evdev.ecodes.KEY_C, 1) + ev_4 = (EV_ABS, evdev.ecodes.ABS_HAT0X, -1) + combination_1 = (ev_1, ev_2, ev_3) + combination_2 = (ev_2, ev_1, ev_3) + + # same as 1, but different D-Pad direction + combination_3 = (ev_1, ev_4, ev_3) + combination_4 = (ev_4, ev_1, ev_3) + + # same as 1, but the last key is different + combination_5 = (ev_1, ev_3, ev_2) + combination_6 = (ev_3, ev_1, ev_2) + + self.change_empty_row(combination_1, 'a') + self.assertEqual(custom_mapping.get_character(combination_1), 'a') + self.assertEqual(custom_mapping.get_character(combination_2), 'a') + self.assertIsNone(custom_mapping.get_character(combination_3)) + self.assertIsNone(custom_mapping.get_character(combination_4)) + self.assertIsNone(custom_mapping.get_character(combination_5)) + self.assertIsNone(custom_mapping.get_character(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_character(combination_1), 'a') + self.assertEqual(custom_mapping.get_character(combination_2), 'a') + self.assertIsNone(custom_mapping.get_character(combination_3)) + self.assertIsNone(custom_mapping.get_character(combination_4)) + self.assertIsNone(custom_mapping.get_character(combination_5)) + self.assertIsNone(custom_mapping.get_character(combination_6)) + + self.change_empty_row(combination_3, 'c') + self.assertEqual(custom_mapping.get_character(combination_1), 'a') + self.assertEqual(custom_mapping.get_character(combination_2), 'a') + self.assertEqual(custom_mapping.get_character(combination_3), 'c') + self.assertEqual(custom_mapping.get_character(combination_4), 'c') + self.assertIsNone(custom_mapping.get_character(combination_5)) + self.assertIsNone(custom_mapping.get_character(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_character(combination_1), 'a') + self.assertEqual(custom_mapping.get_character(combination_2), 'a') + self.assertEqual(custom_mapping.get_character(combination_3), 'c') + self.assertEqual(custom_mapping.get_character(combination_4), 'c') + self.assertIsNone(custom_mapping.get_character(combination_5)) + self.assertIsNone(custom_mapping.get_character(combination_6)) + + self.change_empty_row(combination_5, 'e') + self.assertEqual(custom_mapping.get_character(combination_1), 'a') + self.assertEqual(custom_mapping.get_character(combination_2), 'a') + self.assertEqual(custom_mapping.get_character(combination_3), 'c') + self.assertEqual(custom_mapping.get_character(combination_4), 'c') + self.assertEqual(custom_mapping.get_character(combination_5), 'e') + self.assertEqual(custom_mapping.get_character(combination_6), 'e') + def test_remove_row(self): """Comprehensive test for rows 2.""" # sleeps are added to be able to visually follow and debug the test. diff --git a/tests/testcases/test_keycode_mapper.py b/tests/testcases/test_keycode_mapper.py index ca864f05..717ff0cd 100644 --- a/tests/testcases/test_keycode_mapper.py +++ b/tests/testcases/test_keycode_mapper.py @@ -28,7 +28,7 @@ from evdev.ecodes import EV_KEY, EV_ABS, ABS_HAT0X, ABS_HAT0Y, KEY_A, ABS_X, \ EV_REL, REL_X, BTN_TL from keymapper.dev.keycode_mapper import should_map_event_as_btn, \ - active_macros, handle_keycode + active_macros, handle_keycode, unreleased from keymapper.state import system_mapping from keymapper.dev.macros import parse from keymapper.config import config @@ -106,18 +106,26 @@ class TestKeycodeMapper(unittest.TestCase): # a bunch of d-pad key down events at once handle_keycode(_key_to_code, {}, InputEvent(*ev_1), uinput) handle_keycode(_key_to_code, {}, InputEvent(*ev_4), uinput) + self.assertEqual(len(unreleased), 2) + self.assertEqual(unreleased.get(ev_1[:2]), ((EV_KEY, _key_to_code[ev_1]), ev_1)) + self.assertEqual(unreleased.get(ev_4[:2]), ((EV_KEY, _key_to_code[ev_4]), ev_4)) # release all of them handle_keycode(_key_to_code, {}, InputEvent(*ev_3), uinput) handle_keycode(_key_to_code, {}, InputEvent(*ev_6), uinput) + self.assertEqual(len(unreleased), 0) # repeat with other values handle_keycode(_key_to_code, {}, InputEvent(*ev_2), uinput) handle_keycode(_key_to_code, {}, InputEvent(*ev_5), uinput) + self.assertEqual(len(unreleased), 2) + self.assertEqual(unreleased.get(ev_2[:2]), ((EV_KEY, _key_to_code[ev_2]), ev_2)) + self.assertEqual(unreleased.get(ev_5[:2]), ((EV_KEY, _key_to_code[ev_5]), ev_5)) # release all of them again handle_keycode(_key_to_code, {}, InputEvent(*ev_3), uinput) handle_keycode(_key_to_code, {}, InputEvent(*ev_6), uinput) + self.assertEqual(len(unreleased), 0) self.assertEqual(len(uinput_write_history), 8) @@ -133,6 +141,41 @@ class TestKeycodeMapper(unittest.TestCase): self.assertEqual(uinput_write_history[6].t, (EV_KEY, 52, 0)) self.assertEqual(uinput_write_history[7].t, (EV_KEY, 55, 0)) + def test_d_pad_combination(self): + ev_1 = (EV_ABS, ABS_HAT0X, 1) + ev_2 = (EV_ABS, ABS_HAT0Y, -1) + + ev_3 = (EV_ABS, ABS_HAT0X, 0) + ev_4 = (EV_ABS, ABS_HAT0Y, 0) + + _key_to_code = { + (ev_1, ev_2): 51, + ev_2: 52, + } + + uinput = UInput() + # a bunch of d-pad key down events at once + handle_keycode(_key_to_code, {}, InputEvent(*ev_1), uinput) + handle_keycode(_key_to_code, {}, InputEvent(*ev_2), uinput) + # (what_will_be_released, what_caused_the_key_down) + self.assertEqual(unreleased.get(ev_1[:2]), ((EV_ABS, ABS_HAT0X), ev_1)) + self.assertEqual(unreleased.get(ev_2[:2]), ((EV_KEY, 51), ev_2)) + self.assertEqual(len(unreleased), 2) + + # ev_1 is unmapped and the other is the triggered combination + self.assertEqual(len(uinput_write_history), 2) + self.assertEqual(uinput_write_history[0].t, ev_1) + self.assertEqual(uinput_write_history[1].t, (EV_KEY, 51, 1)) + + # release all of them + handle_keycode(_key_to_code, {}, InputEvent(*ev_3), uinput) + handle_keycode(_key_to_code, {}, InputEvent(*ev_4), uinput) + self.assertEqual(len(unreleased), 0) + + self.assertEqual(len(uinput_write_history), 4) + self.assertEqual(uinput_write_history[2].t, ev_3) + self.assertEqual(uinput_write_history[3].t, (EV_KEY, 51, 0)) + def test_should_map_event_as_btn(self): self.assertTrue(should_map_event_as_btn(EV_ABS, ABS_HAT0X)) self.assertTrue(should_map_event_as_btn(EV_KEY, KEY_A)) @@ -159,6 +202,89 @@ class TestKeycodeMapper(unittest.TestCase): self.assertEqual(uinput_write_history[1].t, (EV_KEY, 3, 1)) self.assertEqual(uinput_write_history[2].t, (EV_KEY, 102, 1)) + def test_combination_keycode(self): + combination = ((EV_KEY, 1, 1), (EV_KEY, 2, 1)) + _key_to_code = { + combination: 101 + } + + uinput = UInput() + handle_keycode(_key_to_code, {}, InputEvent(*combination[0]), uinput) + handle_keycode(_key_to_code, {}, InputEvent(*combination[1]), uinput) + + self.assertEqual(len(uinput_write_history), 2) + # the first event is written and then the triggered combination + self.assertEqual(uinput_write_history[0].t, (EV_KEY, 1, 1)) + self.assertEqual(uinput_write_history[1].t, (EV_KEY, 101, 1)) + + # release them + handle_keycode(_key_to_code, {}, InputEvent(*combination[0][:2], 0), uinput) + handle_keycode(_key_to_code, {}, InputEvent(*combination[1][:2], 0), uinput) + # the first key writes its release event. The second key is hidden + # behind the executed combination. The result of the combination is + # also released, because it acts like a key. + self.assertEqual(len(uinput_write_history), 4) + self.assertEqual(uinput_write_history[2].t, (EV_KEY, 1, 0)) + self.assertEqual(uinput_write_history[3].t, (EV_KEY, 101, 0)) + + # press them in the wrong order (the wrong key at the end, the order + # of all other keys won't matter). no combination should be triggered + handle_keycode(_key_to_code, {}, InputEvent(*combination[1]), uinput) + handle_keycode(_key_to_code, {}, InputEvent(*combination[0]), uinput) + self.assertEqual(len(uinput_write_history), 6) + self.assertEqual(uinput_write_history[4].t, (EV_KEY, 2, 1)) + self.assertEqual(uinput_write_history[5].t, (EV_KEY, 1, 1)) + + def test_combination_keycode_2(self): + combination_1 = ( + (EV_KEY, 1, 1), + (EV_KEY, 2, 1), + (EV_KEY, 3, 1), + (EV_KEY, 4, 1) + ) + combination_2 = ( + (EV_KEY, 2, 1), + (EV_KEY, 3, 1), + (EV_KEY, 4, 1) + ) + + down_5 = (EV_KEY, 5, 1) + up_5 = (EV_KEY, 5, 0) + up_4 = (EV_KEY, 4, 0) + + _key_to_code = { + combination_1: 101, + combination_2: 102, + down_5: 103 + } + + uinput = UInput() + handle_keycode(_key_to_code, {}, InputEvent(*combination_1[0]), uinput) + handle_keycode(_key_to_code, {}, InputEvent(*combination_1[1]), uinput) + handle_keycode(_key_to_code, {}, InputEvent(*combination_1[2]), uinput) + handle_keycode(_key_to_code, {}, InputEvent(*combination_1[3]), uinput) + + self.assertEqual(len(uinput_write_history), 4) + # the first event is written and then the triggered combination + self.assertEqual(uinput_write_history[0].t, (EV_KEY, 1, 1)) + self.assertEqual(uinput_write_history[1].t, (EV_KEY, 2, 1)) + self.assertEqual(uinput_write_history[2].t, (EV_KEY, 3, 1)) + self.assertEqual(uinput_write_history[3].t, (EV_KEY, 101, 1)) + + # while the combination is down, another unrelated key can be used + handle_keycode(_key_to_code, {}, InputEvent(*down_5), uinput) + self.assertEqual(len(uinput_write_history), 5) + self.assertEqual(uinput_write_history[4].t, (EV_KEY, 103, 1)) + + # release the combination by releasing the last key, and release + # the unrelated key + handle_keycode(_key_to_code, {}, InputEvent(*up_4), uinput) + handle_keycode(_key_to_code, {}, InputEvent(*up_5), uinput) + self.assertEqual(len(uinput_write_history), 7) + + self.assertEqual(uinput_write_history[5].t, (EV_KEY, 101, 0)) + self.assertEqual(uinput_write_history[6].t, (EV_KEY, 103, 0)) + def test_handle_keycode_macro(self): history = [] @@ -435,7 +561,8 @@ class TestKeycodeMapper(unittest.TestCase): self.assertEqual(count_before, count_after) def test_hold_two(self): - # holding two macros at the same time + # holding two macros at the same time, + # the first one is triggered by a combination history = [] code_1 = 100 @@ -452,37 +579,46 @@ class TestKeycodeMapper(unittest.TestCase): system_mapping._set('b', code_b) system_mapping._set('c', code_c) - key_1 = (EV_KEY, 1) + key_0 = (EV_KEY, 10) + key_1 = (EV_KEY, 11) key_2 = (EV_ABS, ABS_HAT0X) + down_0 = (*key_0, 1) down_1 = (*key_1, 1) down_2 = (*key_2, -1) + up_0 = (*key_0, 0) up_1 = (*key_1, 0) up_2 = (*key_2, 0) macro_mapping = { - down_1: parse('k(1).h(k(2)).k(3)', self.mapping), + (down_0, down_1): parse('k(1).h(k(2)).k(3)', self.mapping), down_2: parse('k(a).h(k(b)).k(c)', self.mapping) } def handler(*args): history.append(args) - macro_mapping[down_1].set_handler(handler) + macro_mapping[(down_0, down_1)].set_handler(handler) macro_mapping[down_2].set_handler(handler) loop = asyncio.get_event_loop() + macros_uinput = UInput() + keys_uinput = UInput() + # key up won't do anything - uinput = UInput() - handle_keycode({}, macro_mapping, InputEvent(*up_1), uinput) - handle_keycode({}, macro_mapping, InputEvent(*up_2), uinput) + handle_keycode({}, macro_mapping, InputEvent(*up_0), macros_uinput) + handle_keycode({}, macro_mapping, InputEvent(*up_1), macros_uinput) + handle_keycode({}, macro_mapping, InputEvent(*up_2), macros_uinput) loop.run_until_complete(asyncio.sleep(0.1)) self.assertEqual(len(active_macros), 0) """start macros""" - handle_keycode({}, macro_mapping, InputEvent(*down_1), None) - handle_keycode({}, macro_mapping, InputEvent(*down_2), None) + handle_keycode({}, macro_mapping, InputEvent(*down_0), keys_uinput) + self.assertEqual(keys_uinput.write_count, 1) + handle_keycode({}, macro_mapping, InputEvent(*down_1), keys_uinput) + handle_keycode({}, macro_mapping, InputEvent(*down_2), keys_uinput) + self.assertEqual(keys_uinput.write_count, 1) # let the mainloop run for some time so that the macro does its stuff sleeptime = 500 @@ -497,6 +633,7 @@ class TestKeycodeMapper(unittest.TestCase): """stop macros""" + # releasing the last key of a combination releases the whole macro handle_keycode({}, macro_mapping, InputEvent(*up_1), None) handle_keycode({}, macro_mapping, InputEvent(*up_2), None) @@ -640,6 +777,9 @@ class TestKeycodeMapper(unittest.TestCase): self.assertEqual(uinput_write_history[3].t, (EV_KEY, 52, 0)) def test_ignore_hold(self): + # hold as in event-value 2, not in macro-hold. + # linux will generate events with value 2 after key-mapper injected + # the key-press, so key-mapper doesn't need to forward them. key = (EV_KEY, KEY_A) ev_1 = (*key, 1) ev_2 = (*key, 2) @@ -658,8 +798,6 @@ class TestKeycodeMapper(unittest.TestCase): self.assertEqual(len(uinput_write_history), 2) self.assertEqual(uinput_write_history[0].t, (EV_KEY, 21, 1)) self.assertEqual(uinput_write_history[1].t, (EV_KEY, 21, 0)) - # linux will generate events with value 2 after key-mapper injected - # the key-press if __name__ == "__main__": diff --git a/tests/testcases/test_mapping.py b/tests/testcases/test_mapping.py index 991fd012..14816680 100644 --- a/tests/testcases/test_mapping.py +++ b/tests/testcases/test_mapping.py @@ -24,7 +24,7 @@ import unittest import json from evdev.ecodes import EV_KEY, EV_ABS, ABS_HAT0X, KEY_A -from keymapper.mapping import Mapping +from keymapper.mapping import Mapping, verify_key from keymapper.state import SystemMapping, XMODMAP_FILENAME from keymapper.config import config from keymapper.paths import get_preset_path @@ -180,7 +180,7 @@ class TestMapping(unittest.TestCase): self.mapping.change(one, '1') self.mapping.change(two, '2') - self.mapping.change(three, '3') + self.mapping.change((two, three), '3') self.mapping._config['foo'] = 'bar' self.mapping.save(get_preset_path('device 1', 'test')) @@ -194,11 +194,12 @@ class TestMapping(unittest.TestCase): self.assertEqual(len(loaded), 3) self.assertEqual(loaded.get_character(one), '1') self.assertEqual(loaded.get_character(two), '2') - self.assertEqual(loaded.get_character(three), '3') + self.assertEqual(loaded.get_character((two, three)), '3') self.assertEqual(loaded._config['foo'], 'bar') def test_save_load_2(self): - # loads mappings with only (type, code) as the key + # loads mappings with only (type, code) as the key by using 1 as value, + # loads combinations chained with + path = os.path.join(tmp, 'presets', 'device 1', 'test.json') os.makedirs(os.path.dirname(path), exist_ok=True) with open(path, 'w') as file: @@ -206,17 +207,29 @@ class TestMapping(unittest.TestCase): 'mapping': { f'{EV_KEY},3': 'a', f'{EV_ABS},{ABS_HAT0X},-1': 'b', - f'{EV_ABS},{ABS_HAT0X},1': 'c', + f'{EV_ABS},1,1+{EV_ABS},2,-1+{EV_ABS},3,1': 'c', + # ignored because broken + f'3,1,1+': 'd', + f'3,1,1,2': 'e', + f'3': 'e', + f'+3,1,2': 'f', + f',,+3,1,2': 'g', + f'': 'h', } }, file) loaded = Mapping() loaded.load(get_preset_path('device 1', 'test')) + self.assertEqual(len(loaded), 3) self.assertEqual(loaded.get_character((EV_KEY, 3, 1)), 'a') self.assertEqual(loaded.get_character((EV_ABS, ABS_HAT0X, -1)), 'b') - self.assertEqual(loaded.get_character((EV_ABS, ABS_HAT0X, 1)), 'c') + self.assertEqual(loaded.get_character( + ((EV_ABS, 1, 1), (EV_ABS, 2, -1), (EV_ABS, 3, 1)) + ), 'c') def test_change(self): + # the reader would not report values like 111 or 222, only 1 or -1. + # the mapping just does what it is told, so it accepts them. ev_1 = (EV_KEY, 1, 111) ev_2 = (EV_KEY, 1, 222) ev_3 = (EV_KEY, 2, 111) @@ -251,6 +264,33 @@ class TestMapping(unittest.TestCase): self.assertEqual(self.mapping.get_character(ev_4), 'e') self.assertEqual(len(self.mapping), 2) + def test_combinations(self): + ev_1 = (EV_KEY, 1, 111) + ev_2 = (EV_KEY, 1, 222) + ev_3 = (EV_KEY, 2, 111) + ev_4 = (EV_ABS, 1, 111) + combi_1 = (ev_1, ev_2, ev_3) + combi_2 = (ev_2, ev_1, ev_3) + combi_3 = (ev_1, ev_2, ev_4) + + self.mapping.change(combi_1, 'a') + self.assertEqual(self.mapping.get_character(combi_1), 'a') + self.assertEqual(self.mapping.get_character(combi_2), 'a') + # since combi_1 and combi_2 are equivalent, a changes to b + self.mapping.change(combi_2, 'b') + self.assertEqual(self.mapping.get_character(combi_1), 'b') + self.assertEqual(self.mapping.get_character(combi_2), 'b') + + self.mapping.change(combi_3, 'c') + self.assertEqual(self.mapping.get_character(combi_1), 'b') + self.assertEqual(self.mapping.get_character(combi_2), 'b') + self.assertEqual(self.mapping.get_character(combi_3), 'c') + + self.mapping.change(combi_3, 'c', combi_1) + self.assertIsNone(self.mapping.get_character(combi_1)) + self.assertIsNone(self.mapping.get_character(combi_2)) + self.assertEqual(self.mapping.get_character(combi_3), 'c') + def test_clear(self): # does nothing self.mapping.clear((EV_KEY, 40, 1)) @@ -282,6 +322,22 @@ class TestMapping(unittest.TestCase): self.mapping.empty() self.assertEqual(len(self.mapping), 0) + def test_verify_key(self): + self.assertRaises(ValueError, lambda: verify_key(1)) + self.assertRaises(ValueError, lambda: verify_key(None)) + self.assertRaises(ValueError, lambda: verify_key([1])) + self.assertRaises(ValueError, lambda: verify_key((1,))) + self.assertRaises(ValueError, lambda: verify_key((1, 2))) + self.assertRaises(ValueError, lambda: verify_key(('1', '2', '3'))) + self.assertRaises(ValueError, lambda: verify_key('1')) + self.assertRaises(ValueError, lambda: verify_key('(1,2,3)')) + self.assertRaises(ValueError, lambda: verify_key(((1, 2, 3), (1, 2, '3')))) + self.assertRaises(ValueError, lambda: verify_key(((1, 2, 3), (1, 2, 3), None))) + + # those don't raise errors + verify_key(((1, 2, 3), (1, 2, 3))) + verify_key((1, 2, 3)) + if __name__ == "__main__": unittest.main() diff --git a/tests/testcases/test_reader.py b/tests/testcases/test_reader.py index b3a8bc87..11b42796 100644 --- a/tests/testcases/test_reader.py +++ b/tests/testcases/test_reader.py @@ -22,12 +22,12 @@ import unittest import time -from evdev.ecodes import EV_KEY, EV_ABS, ABS_HAT0X, KEY_COMMA, BTN_LEFT, \ - BTN_TOOL_DOUBLETAP +from evdev.ecodes import EV_KEY, EV_ABS, ABS_HAT0X, ABS_HAT0Y, KEY_COMMA, \ + BTN_LEFT, BTN_TOOL_DOUBLETAP from keymapper.dev.reader import keycode_reader -from tests.test import InputEvent, pending_events, EVENT_READ_TIMEOUT +from tests.test import InputEvent, pending_events, EVENT_READ_TIMEOUT, cleanup CODE_1 = 100 @@ -52,13 +52,21 @@ class TestReader(unittest.TestCase): self.assertEqual(keycode_reader.read(), None) def tearDown(self): - keycode_reader.stop_reading() - keys = list(pending_events.keys()) - for key in keys: - del pending_events[key] - keycode_reader.newest_event = None + cleanup() def test_reading_1(self): + # a single event + pending_events['device 1'] = [ + InputEvent(EV_ABS, ABS_HAT0X, 1) + ] + keycode_reader.start_reading('device 1') + wait(keycode_reader._pipe[0].poll, 0.5) + self.assertEqual(keycode_reader.read(), (EV_ABS, ABS_HAT0X, 1)) + self.assertEqual(keycode_reader.read(), None) + self.assertEqual(len(keycode_reader._unreleased), 1) + + def test_reading_2(self): + # a combination of events pending_events['device 1'] = [ InputEvent(EV_KEY, CODE_1, 1, 10000.1234), InputEvent(EV_KEY, CODE_3, 1, 10001.1234), @@ -71,15 +79,13 @@ class TestReader(unittest.TestCase): wait(keycode_reader._pipe[0].poll, 0.5) - self.assertEqual(keycode_reader.read(), (EV_ABS, ABS_HAT0X, -1)) - self.assertEqual(keycode_reader.read(), None) - - def test_reading_2(self): - pending_events['device 1'] = [InputEvent(EV_ABS, ABS_HAT0X, 1)] - keycode_reader.start_reading('device 1') - wait(keycode_reader._pipe[0].poll, 0.5) - self.assertEqual(keycode_reader.read(), (EV_ABS, ABS_HAT0X, 1)) + self.assertEqual(keycode_reader.read(), ( + (EV_KEY, CODE_1, 1), + (EV_KEY, CODE_3, 1), + (EV_ABS, ABS_HAT0X, -1) + )) self.assertEqual(keycode_reader.read(), None) + self.assertEqual(len(keycode_reader._unreleased), 3) def test_ignore_btn_left(self): # click events are ignored because overwriting them would render the @@ -95,6 +101,19 @@ class TestReader(unittest.TestCase): time.sleep(0.1) self.assertEqual(keycode_reader.read(), (EV_KEY, CODE_2, 1)) self.assertEqual(keycode_reader.read(), None) + self.assertEqual(len(keycode_reader._unreleased), 1) + + def test_ignore_value_2(self): + # this is not a combination, because (EV_KEY CODE_3, 2) is ignored + pending_events['device 1'] = [ + InputEvent(EV_ABS, ABS_HAT0X, 1), + InputEvent(EV_KEY, CODE_3, 2) + ] + keycode_reader.start_reading('device 1') + wait(keycode_reader._pipe[0].poll, 0.5) + self.assertEqual(keycode_reader.read(), (EV_ABS, ABS_HAT0X, 1)) + self.assertEqual(keycode_reader.read(), None) + self.assertEqual(len(keycode_reader._unreleased), 1) def test_reading_ignore_up(self): pending_events['device 1'] = [ @@ -106,6 +125,7 @@ class TestReader(unittest.TestCase): time.sleep(0.1) self.assertEqual(keycode_reader.read(), (EV_KEY, CODE_2, 1)) self.assertEqual(keycode_reader.read(), None) + self.assertEqual(len(keycode_reader._unreleased), 1) def test_wrong_device(self): pending_events['device 1'] = [ @@ -116,6 +136,7 @@ class TestReader(unittest.TestCase): keycode_reader.start_reading('device 2') time.sleep(EVENT_READ_TIMEOUT * 5) self.assertEqual(keycode_reader.read(), None) + self.assertEqual(len(keycode_reader._unreleased), 0) def test_keymapper_devices(self): # Don't read from keymapper devices, their keycodes are not @@ -130,6 +151,7 @@ class TestReader(unittest.TestCase): keycode_reader.start_reading('device 2') time.sleep(EVENT_READ_TIMEOUT * 5) self.assertEqual(keycode_reader.read(), None) + self.assertEqual(len(keycode_reader._unreleased), 0) def test_clear(self): pending_events['device 1'] = [ @@ -141,6 +163,7 @@ class TestReader(unittest.TestCase): time.sleep(EVENT_READ_TIMEOUT * 5) keycode_reader.clear() self.assertEqual(keycode_reader.read(), None) + self.assertEqual(len(keycode_reader._unreleased), 0) def test_switch_device(self): pending_events['device 2'] = [InputEvent(EV_KEY, CODE_1, 1)] @@ -161,15 +184,24 @@ class TestReader(unittest.TestCase): # based on the event type pending_events['device 1'] = [ InputEvent(EV_ABS, ABS_HAT0X, 1, 1234.0000), + InputEvent(EV_ABS, ABS_HAT0X, 0, 1234.0001), + InputEvent(EV_ABS, ABS_HAT0X, 1, 1235.0000), # ignored + InputEvent(EV_ABS, ABS_HAT0X, 0, 1235.0001), + InputEvent(EV_KEY, KEY_COMMA, 1, 1235.0010), + InputEvent(EV_KEY, KEY_COMMA, 0, 1235.0011), + InputEvent(EV_ABS, ABS_HAT0X, 1, 1235.0020), # ignored + InputEvent(EV_ABS, ABS_HAT0X, 0, 1235.0021), # ignored + InputEvent(EV_ABS, ABS_HAT0X, 1, 1236.0000) ] keycode_reader.start_reading('device 1') wait(keycode_reader._pipe[0].poll, 0.5) self.assertEqual(keycode_reader.read(), (EV_ABS, ABS_HAT0X, 1)) self.assertEqual(keycode_reader.read(), None) + self.assertEqual(len(keycode_reader._unreleased), 1) def test_prioritizing_2_normalize(self): # furthermore, 1234 is 1 in the reader, because it probably is some @@ -180,22 +212,31 @@ class TestReader(unittest.TestCase): InputEvent(EV_KEY, KEY_COMMA, 1234, 1235.0010), InputEvent(EV_ABS, ABS_HAT0X, 1, 1235.0020), # ignored InputEvent(EV_ABS, ABS_HAT0X, 1, 1235.0030) # ignored + # this time, don't release anything. the combination should + # ignore stuff as well. ] keycode_reader.start_reading('device 1') wait(keycode_reader._pipe[0].poll, 0.5) - self.assertEqual(keycode_reader.read(), (EV_KEY, KEY_COMMA, 1)) + self.assertEqual(keycode_reader.read(), ( + (EV_ABS, ABS_HAT0X, 1), + (EV_KEY, KEY_COMMA, 1) + )) self.assertEqual(keycode_reader.read(), None) + self.assertEqual(len(keycode_reader._unreleased), 2) def test_prioritizing_3_normalize(self): # take the sign of -1234, just like in test_prioritizing_2_normalize pending_events['device 1'] = [ InputEvent(EV_ABS, ABS_HAT0X, -1234, 1234.0000), - InputEvent(EV_ABS, ABS_HAT0X, 0, 1234.0030) # ignored + InputEvent(EV_ABS, ABS_HAT0Y, 0, 1234.0030) # ignored + # this time don't release anything as well, but it's not + # a combination because only one event is accepted ] keycode_reader.start_reading('device 1') wait(keycode_reader._pipe[0].poll, 0.5) self.assertEqual(keycode_reader.read(), (EV_ABS, ABS_HAT0X, -1)) self.assertEqual(keycode_reader.read(), None) + self.assertEqual(len(keycode_reader._unreleased), 1) if __name__ == "__main__":