diff --git a/README.md b/README.md index 5c5c8d36..4efa9939 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,7 @@ check the output of `xmodmap -pke` If you can't find what you need, consult [linux/input-event-codes.h](https://github.com/torvalds/linux/blob/master/include/uapi/linux/input-event-codes.h) +for KEY and BTN names - Mouse buttons `BTN_LEFT` `BTN_RIGHT` `BTN_MIDDLE` `BTN_SIDE` - Multimedia keys `KEY_NEXTSONG` `KEY_PLAYPAUSE` ... diff --git a/keymapper/dev/injector.py b/keymapper/dev/injector.py index 52306d21..8be8ad84 100644 --- a/keymapper/dev/injector.py +++ b/keymapper/dev/injector.py @@ -37,7 +37,7 @@ from keymapper.state import system_mapping from keymapper.dev.keycode_mapper import handle_keycode, \ should_map_event_as_btn from keymapper.dev.ev_abs_mapper import ev_abs_mapper, JOYSTICK -from keymapper.dev.macros import parse +from keymapper.dev.macros import parse, is_this_a_macro DEV_NAME = 'key-mapper' @@ -101,12 +101,38 @@ class KeycodeInjector: self.mapping = mapping self._process = None self._msg_pipe = multiprocessing.Pipe() + self._code_to_code = self._map_codes_to_codes() + self.stopped = False - # some EV_ABS mapping stuff + # when moving the joystick and then staying at a position, no + # events will be written anymore. Remember the last value the + # joystick reported, because it is still remaining at that + # position. self.abs_state = [0, 0, 0, 0] + def _map_codes_to_codes(self): + """To quickly get target keycodes during operation.""" + _code_to_code = {} + for (ev_type, keycode), output in self.mapping: + if is_this_a_macro(output): + continue + + target_keycode = system_mapping.get(output) + if target_keycode is None: + logger.error('Don\'t know what %s is', output) + continue + + _code_to_code[keycode] = target_keycode + return _code_to_code + def start_injecting(self): """Start injecting keycodes.""" + if self.stopped or self._process is not None: + # So that there is less concern about integrity when putting + # stuff into self. Each injector object can only be + # started once. + raise Exception('Please construct a new injector instead') + self._process = multiprocessing.Process(target=self._start_injecting) self._process.start() @@ -172,11 +198,16 @@ class KeycodeInjector: return device, abs_to_rel - def _modify_capabilities(self, input_device, abs_to_rel): - """Adds all keycode into a copy of a devices capabilities. + def _modify_capabilities(self, macros, input_device, abs_to_rel): + """Adds all used keycodes into a copy of a devices capabilities. + + A device with those capabilities can do exactly the stuff it needs + to perform all mappings and macros. Prameters --------- + macros : dict + maping of int to _Macro input_device : evdev.InputDevice abs_to_rel : bool if ABS capabilities should be removed in favor of REL @@ -187,20 +218,21 @@ class KeycodeInjector: # to act like the device. capabilities = input_device.capabilities(absinfo=False) + if len(self._code_to_code) > 0 or len(macros) > 0: + if capabilities.get(EV_KEY) is None: + capabilities[EV_KEY] = [] + # Furthermore, support all injected keycodes - if len(self.mapping) > 0 and capabilities.get(ecodes.EV_KEY) is None: - capabilities[ecodes.EV_KEY] = [] - - for (ev_type, keycode), character in self.mapping: - if not should_map_event_as_btn(ev_type, keycode): - continue - - keycode = system_mapping.get(character) - if keycode is not None: + for keycode in self._code_to_code.values(): + if keycode not in capabilities[EV_KEY]: capabilities[EV_KEY].append(keycode) + # and all keycodes that are injected by macros + for macro in macros.values(): + capabilities[EV_KEY] += list(macro.get_capabilities()) + if abs_to_rel: - del capabilities[ecodes.EV_ABS] + del capabilities[EV_ABS] # those are the requirements to recognize it as mouse # on my system. REL_X and REL_Y are of course required to # accept the events that the mouse-movement-mapper writes. @@ -209,11 +241,11 @@ class KeycodeInjector: evdev.ecodes.REL_Y, evdev.ecodes.REL_WHEEL, ] - if capabilities.get(ecodes.EV_KEY) is None: - capabilities[ecodes.EV_KEY] = [] + if capabilities.get(EV_KEY) is None: + capabilities[EV_KEY] = [] # for reasons I don't know, it is also required to have # any keyboard button in capabilities. - capabilities[ecodes.EV_KEY].append(ecodes.KEY_0) + capabilities[EV_KEY].append(ecodes.KEY_0) # just like what python-evdev does in from_device if ecodes.EV_SYN in capabilities: @@ -253,32 +285,44 @@ class KeycodeInjector: # Watch over each one of the potentially multiple devices per hardware for path in paths: - input_device, abs_to_rel = self._prepare_device(path) - if input_device is None: + source, abs_to_rel = self._prepare_device(path) + if source is None: continue + # each device parses the macros with a different handler + logger.debug('Parsing macros for %s', path) + macros = {} + for (ev_type, keycode), output in self.mapping: + if is_this_a_macro(output): + macros[keycode] = parse(output) + continue + # certain capabilities can have side effects apparently. with an # EV_ABS capability, EV_REL won't move the mouse pointer anymore. # so don't merge all InputDevices into one UInput device. uinput = evdev.UInput( name=f'{DEV_NAME} {self.device}', phys=DEV_NAME, - events=self._modify_capabilities(input_device, abs_to_rel) + events=self._modify_capabilities(macros, source, abs_to_rel) ) + def handler(*args, uinput=uinput): + # this ensures that the right uinput is used for macro_write, + # because this is within a loop + self._macro_write(*args, uinput) + + for macro in macros.values(): + macro.set_handler(handler) + # keycode injection - coroutine = self._keycode_loop(input_device, uinput, abs_to_rel) + coroutine = self._keycode_loop(macros, source, uinput, abs_to_rel) coroutines.append(coroutine) # mouse movement injection if abs_to_rel: self.abs_state[0] = 0 self.abs_state[1] = 0 - coroutine = ev_abs_mapper( - self.abs_state, - input_device, - uinput - ) + coroutine = ev_abs_mapper(self.abs_state, source, uinput) coroutines.append(coroutine) if len(coroutines) == 0: @@ -296,59 +340,34 @@ class KeycodeInjector: if len(coroutines) > 0: logger.debug('asyncio coroutines ended') - def _macro_write(self, character, value, uinput): + def _macro_write(self, code, value, uinput): """Handler for macros.""" - keycode = system_mapping[character] - logger.spam( - 'macro writes code:%s value:%d char:%s', - keycode, value, character - ) - uinput.write(EV_KEY, keycode, value) + logger.spam('macro writes code:%s value:%d', code, value) + uinput.write(EV_KEY, code, value) uinput.syn() - async def _keycode_loop(self, device, uinput, abs_to_rel): + async def _keycode_loop(self, macros, source, uinput, abs_to_rel): """Inject keycodes for one of the virtual devices. Can be stopped by stopping the asyncio loop. Parameters ---------- - device : evdev.InputDevice + macros : int -> _Macro + macro with a handler that writes to the provided uinput + source : evdev.InputDevice where to read keycodes from uinput : evdev.UInput where to write keycodes to abs_to_rel : bool if joystick events should be mapped to mouse movements """ - # efficiently figure out the target keycode without taking - # extra steps. - code_code_mapping = {} - - # Parse all macros beforehand - logger.debug('Parsing macros') - macros = {} - for (ev_type, keycode), output in self.mapping: - if '(' in output and ')' in output and len(output) >= 4: - # probably a macro - macros[keycode] = parse( - output, - lambda *args: self._macro_write(*args, uinput) - ) - continue - - target_keycode = system_mapping.get(output) - if target_keycode is None: - logger.error('Don\'t know what %s is', output) - continue - - code_code_mapping[keycode] = target_keycode - logger.debug( 'Started injecting into %s, fd %s', uinput.device.path, uinput.fd ) - async for event in device.async_read_loop(): + async for event in source.async_read_loop(): if abs_to_rel and event.type == EV_ABS and event.code in JOYSTICK: if event.code == evdev.ecodes.ABS_X: self.abs_state[0] = event.value @@ -361,7 +380,7 @@ class KeycodeInjector: continue if should_map_event_as_btn(event.type, event.code): - handle_keycode(code_code_mapping, macros, event, uinput) + handle_keycode(self._code_to_code, macros, event, uinput) continue # forward the rest @@ -369,9 +388,6 @@ class KeycodeInjector: # this already includes SYN events, so need to syn here again continue - # this should only ever happen in tests to avoid blocking them - # forever, as soon as all events are consumed. In normal operation - # there is no end to the events. logger.error( 'The injector for "%s" stopped early', uinput.device.path @@ -382,3 +398,4 @@ class KeycodeInjector: """Stop injecting keycodes.""" logger.info('Stopping injecting keycodes for device "%s"', self.device) self._msg_pipe[1].send(CLOSE) + self.stopped = True diff --git a/keymapper/dev/keycode_mapper.py b/keymapper/dev/keycode_mapper.py index 2dd8ad91..a212cc95 100644 --- a/keymapper/dev/keycode_mapper.py +++ b/keymapper/dev/keycode_mapper.py @@ -52,12 +52,12 @@ def should_map_event_as_btn(type, code): return False -def handle_keycode(code_code_mapping, macros, event, uinput): +def handle_keycode(_code_to_code, macros, event, uinput): """Write the mapped keycode or forward unmapped ones. Parameters ---------- - code_code_mapping : dict + _code_to_code : dict mapping of linux-keycode to linux-keycode. macros : dict mapping of linux-keycode to _Macro objects @@ -84,8 +84,8 @@ def handle_keycode(code_code_mapping, macros, event, uinput): asyncio.ensure_future(macro.run()) return - if input_keycode in code_code_mapping: - target_keycode = code_code_mapping[input_keycode] + if input_keycode in _code_to_code: + target_keycode = _code_to_code[input_keycode] target_type = evdev.events.EV_KEY logger.spam( 'got code:%s value:%s event:%s, maps to EV_KEY:%s', diff --git a/keymapper/dev/macros.py b/keymapper/dev/macros.py index 0b0fb426..72d8acae 100644 --- a/keymapper/dev/macros.py +++ b/keymapper/dev/macros.py @@ -40,9 +40,9 @@ import re from keymapper.logger import logger from keymapper.config import config +from keymapper.state import system_mapping -# for debugging purposes MODIFIER = 1 CHILD_MACRO = 2 SLEEP = 3 @@ -51,17 +51,19 @@ KEYSTROKE = 5 DEBUG = 6 +def is_this_a_macro(output): + """Figure out if this is a macro.""" + if '(' in output and ')' in output and len(output) >= 4: + return True + + class _Macro: """Supports chaining and preparing actions.""" - def __init__(self, handler, depth, code): + def __init__(self, depth, code): """Create a macro instance that can be populated with tasks. Parameters ---------- - handler : func - A function that accepts keycodes as the first parameter and the - key-press state as the second. 1 for down and 0 for up. The - macro will write to this function once executed with `.run()`. depth : int 0 for the outermost parent macro, 1 or greater for child macros, like the second argument of repeat. @@ -69,13 +71,42 @@ class _Macro: The original parsed code, for logging purposes. """ self.tasks = [] - self.handler = handler + self.handler = lambda *args: logger.error('No handler set') self.depth = depth self.code = code + # all required capabilities, without those of child macros + self.capabilities = set() + + def get_capabilities(self): + """Resolve all capabilities of the macro and those of its children.""" + capabilities = self.capabilities.copy() + for task_type, task in self.tasks: + if task_type == CHILD_MACRO: + capabilities.update(task.get_capabilities()) + return capabilities + + def set_handler(self, handler): + """Set the handler function. + + Parameters + ---------- + handler : func + A function that accepts keycodes as the first parameter and the + key-press state as the second. 1 for down and 0 for up. The + macro will write to this function once executed with `.run()`. + """ + self.handler = handler + for task_type, task in self.tasks: + if task_type == CHILD_MACRO: + task.set_handler(handler) + async def run(self): """Run the macro.""" - for _, task in self.tasks: + for task_type, task in self.tasks: + if task_type == CHILD_MACRO: + task = task.run + coroutine = task() if asyncio.iscoroutine(coroutine): await coroutine @@ -88,11 +119,19 @@ class _Macro: modifier : str macro : _Macro """ - self.tasks.append((MODIFIER, lambda: self.handler(modifier, 1))) + modifier = str(modifier) + code = system_mapping.get(modifier) + + if code is None: + raise KeyError(f'Unknown modifier "{modifier}"') + + self.capabilities.add(code) + + self.tasks.append((MODIFIER, lambda: self.handler(code, 1))) self.add_keycode_pause() - self.tasks.append((CHILD_MACRO, macro.run)) + self.tasks.append((CHILD_MACRO, macro)) self.add_keycode_pause() - self.tasks.append((MODIFIER, lambda: self.handler(modifier, 0))) + self.tasks.append((MODIFIER, lambda: self.handler(code, 0))) self.add_keycode_pause() return self @@ -104,8 +143,9 @@ class _Macro: repeats : int macro : _Macro """ + repeats = int(repeats) for _ in range(repeats): - self.tasks.append((CHILD_MACRO, macro.run)) + self.tasks.append((CHILD_MACRO, macro)) return self def add_keycode_pause(self): @@ -119,14 +159,23 @@ class _Macro: def keycode(self, character): """Write the character.""" - self.tasks.append((KEYSTROKE, lambda: self.handler(character, 1))) + character = str(character) + code = system_mapping.get(character) + + if code is None: + raise KeyError(f'Unknown key "{character}"') + + self.capabilities.add(code) + + self.tasks.append((KEYSTROKE, lambda: self.handler(code, 1))) self.add_keycode_pause() - self.tasks.append((KEYSTROKE, lambda: self.handler(character, 0))) + self.tasks.append((KEYSTROKE, lambda: self.handler(code, 0))) self.add_keycode_pause() return self def wait(self, sleeptime): """Wait time in milliseconds.""" + sleeptime = int(sleeptime) sleeptime /= 1000 async def sleep(): @@ -191,15 +240,13 @@ def _count_brackets(macro): return position -def _parse_recurse(macro, handler, macro_instance=None, depth=0): +def _parse_recurse(macro, macro_instance=None, depth=0): """Handle a subset of the macro, e.g. one parameter or function call. Parameters ---------- macro : string Just like parse - handler : function - passed to _Macro constructors macro_instance : _Macro or None A macro instance to add tasks to depth : int @@ -211,11 +258,10 @@ def _parse_recurse(macro, handler, macro_instance=None, depth=0): # If this gets more complicated than that I'd rather make a macro # editor GUI and store them as json. assert isinstance(macro, str) - assert callable(handler) assert isinstance(depth, int) if macro_instance is None: - macro_instance = _Macro(handler, depth, macro) + macro_instance = _Macro(depth, macro) else: assert isinstance(macro_instance, _Macro) @@ -247,7 +293,7 @@ def _parse_recurse(macro, handler, macro_instance=None, depth=0): logger.spam('%scalls %s with %s', space, call, string_params) # evaluate the params params = [ - _parse_recurse(param.strip(), handler, None, depth + 1) + _parse_recurse(param.strip(), None, depth + 1) for param in string_params ] @@ -258,7 +304,7 @@ def _parse_recurse(macro, handler, macro_instance=None, depth=0): if len(macro) > position and macro[position] == '.': chain = macro[position + 1:] logger.spam('%sfollowed by %s', space, chain) - _parse_recurse(chain, handler, macro_instance, depth) + _parse_recurse(chain, macro_instance, depth) return macro_instance @@ -271,7 +317,7 @@ def _parse_recurse(macro, handler, macro_instance=None, depth=0): return macro -def parse(macro, handler): +def parse(macro): """parse and generate a _Macro that can be run as often as you want. Parameters @@ -280,17 +326,13 @@ def parse(macro, handler): "r(3, k(a).w(10))" "r(2, k(a).k(-)).k(b)" "w(1000).m(Shift_L, r(2, k(a))).w(10, 20).k(b)" - handler : func - A function that accepts keycodes as the first parameter and the - key-press state as the second. 1 for down and 0 for up. The - macro will write to this function once executed with `.run()`. """ # whitespaces, tabs, newlines and such don't serve a purpose. make # the log output clearer and the parsing easier. macro = re.sub(r'\s', '', macro) logger.spam('preparing macro %s for later execution', macro) try: - return _parse_recurse(macro, handler) + return _parse_recurse(macro) except Exception as error: logger.error('Failed to parse macro "%s": %s', macro, error) return None diff --git a/keymapper/state.py b/keymapper/state.py index f2cf1bd6..455594a0 100644 --- a/keymapper/state.py +++ b/keymapper/state.py @@ -34,32 +34,52 @@ from keymapper.mapping import Mapping XKB_KEYCODE_OFFSET = 8 -def populate_system_mapping(): - """Get a mapping of all available names to their keycodes.""" - mapping = {} +class SystemMapping: + """Stores information about all available keycodes.""" + def __init__(self): + """Construct the system_mapping.""" + self._mapping = {} + self.populate() - xmodmap = subprocess.check_output(['xmodmap', '-pke']).decode() + '\n' - mappings = re.findall(r'(\d+) = (.+)\n', xmodmap) - for keycode, names in mappings: - # there might be multiple, like: - # keycode 64 = Alt_L Meta_L Alt_L Meta_L - # keycode 204 = NoSymbol Alt_L NoSymbol Alt_L - # only the first column is relevant. The others can be achieved - # by using a modifier button and the mapped key - name = names.split()[0] - mapping[name] = int(keycode) - XKB_KEYCODE_OFFSET + def populate(self): + """Get a mapping of all available names to their keycodes.""" + self.clear() + xmodmap = subprocess.check_output(['xmodmap', '-pke']).decode() + '\n' + mappings = re.findall(r'(\d+) = (.+)\n', xmodmap) + for keycode, names in mappings: + # there might be multiple, like: + # keycode 64 = Alt_L Meta_L Alt_L Meta_L + # keycode 204 = NoSymbol Alt_L NoSymbol Alt_L + # Alt_L should map to code 64. Writing code 204 only works + # if a modifier is applied at the same time. So take the first + # one. + name = names.split()[0] + self._set(name, int(keycode) - XKB_KEYCODE_OFFSET) - for name, ecode in evdev.ecodes.ecodes.items(): - mapping[name] = ecode + for keycode, names in mappings: + # but since KP may be mapped like KP_Home KP_7 KP_Home KP_7, + # make another pass and add all of them if they don't already + # exist. don't overwrite any keycodes. + for name in names.split(): + if self.get(name) is None: + self._set(name, int(keycode) - XKB_KEYCODE_OFFSET) - return mapping + for name, ecode in evdev.ecodes.ecodes.items(): + self._set(name, ecode) + def _set(self, name, code): + """Map name to code.""" + self._mapping[str(name).lower()] = code -def clear_system_mapping(): - """Remove all mapped keys. Only needed for tests.""" - keys = list(system_mapping.keys()) - for key in keys: - del system_mapping[key] + def get(self, name): + """Return the code mapped to the key.""" + return self._mapping.get(str(name).lower()) + + def clear(self): + """Remove all mapped keys. Only needed for tests.""" + keys = list(self._mapping.keys()) + for key in keys: + del self._mapping[key] # one mapping object for the whole application that holds all @@ -67,7 +87,7 @@ def clear_system_mapping(): custom_mapping = Mapping() # this mapping represents the xmodmap output, which stays constant -system_mapping = populate_system_mapping() +system_mapping = SystemMapping() # permissions for files created in /usr _PERMISSIONS = stat.S_IREAD | stat.S_IWRITE | stat.S_IRGRP | stat.S_IROTH diff --git a/tests/testcases/test_daemon.py b/tests/testcases/test_daemon.py index 7ff6b9d0..9d0bd7e5 100644 --- a/tests/testcases/test_daemon.py +++ b/tests/testcases/test_daemon.py @@ -30,8 +30,7 @@ import gi gi.require_version('Gtk', '3.0') from gi.repository import Gtk -from keymapper.state import custom_mapping, system_mapping, \ - clear_system_mapping +from keymapper.state import custom_mapping, system_mapping from keymapper.config import config from keymapper.daemon import Daemon, get_dbus_interface, BUS_NAME @@ -78,6 +77,7 @@ class TestDaemon(unittest.TestCase): self.daemon = None evdev.InputDevice.grab = self.grab config.clear_config() + system_mapping.populate() def test_daemon(self): keycode_from_1 = 9 @@ -87,9 +87,9 @@ class TestDaemon(unittest.TestCase): custom_mapping.change((EV_KEY, keycode_from_1), 'a') custom_mapping.change((EV_KEY, keycode_from_2), 'b') - clear_system_mapping() - system_mapping['a'] = keycode_to_1 - system_mapping['b'] = keycode_to_2 + system_mapping.clear() + system_mapping._set('a', keycode_to_1) + system_mapping._set('b', keycode_to_2) preset = 'foo' diff --git a/tests/testcases/test_injector.py b/tests/testcases/test_injector.py index e0427bfb..c64a90f3 100644 --- a/tests/testcases/test_injector.py +++ b/tests/testcases/test_injector.py @@ -29,8 +29,7 @@ from evdev.ecodes import EV_REL, EV_KEY, EV_ABS, ABS_HAT0X from keymapper.dev.injector import is_numlock_on, toggle_numlock, \ ensure_numlock, KeycodeInjector from keymapper.dev.keycode_mapper import handle_keycode -from keymapper.state import custom_mapping, system_mapping, \ - clear_system_mapping +from keymapper.state import custom_mapping, system_mapping from keymapper.mapping import Mapping from keymapper.config import config from keymapper.dev.macros import parse @@ -67,6 +66,7 @@ class TestInjector(unittest.TestCase): del pending_events[key] clear_write_history() custom_mapping.empty() + system_mapping.populate() def test_modify_capabilities(self): class FakeDevice: @@ -80,21 +80,34 @@ class TestInjector(unittest.TestCase): mapping = Mapping() mapping.change((EV_KEY, 80), 'a') - # going to be ignored + macro_code = 'r(2, m(sHiFt_l, r(2, k(1).k(2))))' + macro = parse(macro_code) + + mapping.change((EV_KEY, 60), macro_code) + + # going to be ignored, because EV_REL cannot be mapped, that's + # mouse movements. mapping.change((EV_REL, 1234), 'b') - maps_to = system_mapping['a'] + a = system_mapping.get('a') + shift_l = system_mapping.get('ShIfT_L') + one = system_mapping.get(1) + two = system_mapping.get('2') self.injector = KeycodeInjector('foo', mapping) fake_device = FakeDevice() capabilities = self.injector._modify_capabilities( + {60: macro}, fake_device, abs_to_rel=False ) self.assertIn(EV_KEY, capabilities) keys = capabilities[EV_KEY] - self.assertEqual(keys[0], maps_to) + self.assertIn(a, keys) + self.assertIn(one, keys) + self.assertIn(two, keys) + self.assertIn(shift_l, keys) self.assertNotIn(evdev.ecodes.EV_SYN, capabilities) self.assertNotIn(evdev.ecodes.EV_FF, capabilities) @@ -154,7 +167,11 @@ class TestInjector(unittest.TestCase): device, abs_to_rel = self.injector._prepare_device(path) self.assertTrue(abs_to_rel) - capabilities = self.injector._modify_capabilities(device, abs_to_rel) + capabilities = self.injector._modify_capabilities( + {}, + device, + abs_to_rel + ) self.assertNotIn(evdev.ecodes.EV_ABS, capabilities) self.assertIn(evdev.ecodes.EV_REL, capabilities) @@ -269,7 +286,7 @@ class TestInjector(unittest.TestCase): self.assertAlmostEqual(history[-2][2], -1) def test_handle_keycode(self): - code_code_mapping = { + _code_to_code = { 1: 101, 2: 102 } @@ -287,9 +304,9 @@ class TestInjector(unittest.TestCase): EV_KEY = evdev.ecodes.EV_KEY - handle_keycode(code_code_mapping, {}, Event(EV_KEY, 1, 1), uinput) - handle_keycode(code_code_mapping, {}, Event(EV_KEY, 3, 1), uinput) - handle_keycode(code_code_mapping, {}, Event(EV_KEY, 2, 1), uinput) + handle_keycode(_code_to_code, {}, Event(EV_KEY, 1, 1), uinput) + handle_keycode(_code_to_code, {}, Event(EV_KEY, 3, 1), uinput) + handle_keycode(_code_to_code, {}, Event(EV_KEY, 2, 1), uinput) self.assertEqual(len(history), 3) self.assertEqual(history[0], (EV_KEY, 101, 1)) @@ -299,18 +316,19 @@ class TestInjector(unittest.TestCase): def test_handle_keycode_macro(self): history = [] - macro_mapping = { - 1: parse('k(a)', lambda *args: history.append(args)), - 2: parse('r(5, k(b))', lambda *args: history.append(args)) - } - code_a = 100 code_b = 101 - system_mapping['a'] = code_a - system_mapping['b'] = code_b - clear_system_mapping() + system_mapping.clear() + system_mapping._set('a', code_a) + system_mapping._set('b', code_b) - EV_KEY = evdev.ecodes.EV_KEY + macro_mapping = { + 1: parse('k(a)'), + 2: parse('r(5, k(b))') + } + + macro_mapping[1].set_handler(lambda *args: history.append(args)) + macro_mapping[2].set_handler(lambda *args: history.append(args)) handle_keycode({}, macro_mapping, Event(EV_KEY, 1, 1), None) handle_keycode({}, macro_mapping, Event(EV_KEY, 2, 1), None) @@ -326,10 +344,10 @@ class TestInjector(unittest.TestCase): # 6 keycodes written, with down and up events self.assertEqual(len(history), 12) - self.assertIn(('a', 1), history) - self.assertIn(('a', 0), history) - self.assertIn(('b', 1), history) - self.assertIn(('b', 0), history) + self.assertIn((code_a, 1), history) + self.assertIn((code_a, 0), history) + self.assertIn((code_b, 1), history) + self.assertIn((code_b, 0), history) def test_injector(self): custom_mapping.change((EV_KEY, 8), 'k(KEY_Q).k(w)') @@ -338,13 +356,13 @@ class TestInjector(unittest.TestCase): input_b = 10 custom_mapping.change((EV_KEY, input_b), 'b') - clear_system_mapping() + system_mapping.clear() code_a = 100 code_q = 101 code_w = 102 - system_mapping['a'] = code_a - system_mapping['KEY_Q'] = code_q - system_mapping['w'] = code_w + system_mapping._set('a', code_a) + 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 diff --git a/tests/testcases/test_integration.py b/tests/testcases/test_integration.py index 5716fda7..52897806 100644 --- a/tests/testcases/test_integration.py +++ b/tests/testcases/test_integration.py @@ -35,8 +35,7 @@ import shutil gi.require_version('Gtk', '3.0') from gi.repository import Gtk -from keymapper.state import custom_mapping, system_mapping, \ - clear_system_mapping +from keymapper.state import custom_mapping, system_mapping from keymapper.paths import CONFIG, get_config_path from keymapper.config import config from keymapper.dev.reader import keycode_reader @@ -110,6 +109,7 @@ class TestIntegration(unittest.TestCase): gtk_iteration() shutil.rmtree('/tmp/key-mapper-test') clear_write_history() + system_mapping.populate() def get_rows(self): return self.window.get('key_list').get_children() @@ -428,8 +428,8 @@ class TestIntegration(unittest.TestCase): keycode_to = 200 self.change_empty_row(keycode_from, 'a') - clear_system_mapping() - system_mapping['a'] = keycode_to + system_mapping.clear() + system_mapping._set('a', keycode_to) pending_events['device 2'] = [ Event(evdev.events.EV_KEY, keycode_from, 1), @@ -465,8 +465,8 @@ class TestIntegration(unittest.TestCase): keycode_to = 90 self.change_empty_row(keycode_from, 't') - clear_system_mapping() - system_mapping['t'] = keycode_to + system_mapping.clear() + system_mapping._set('t', keycode_to) # not all of those events should be processed, since that takes some # time due to time.sleep in the fakes and the injection is stopped. diff --git a/tests/testcases/test_macros.py b/tests/testcases/test_macros.py index 7a6bcb2f..de476e10 100644 --- a/tests/testcases/test_macros.py +++ b/tests/testcases/test_macros.py @@ -25,6 +25,7 @@ import asyncio from keymapper.dev.macros import parse, _Macro from keymapper.config import config +from keymapper.state import system_mapping class TestMacros(unittest.TestCase): @@ -35,38 +36,70 @@ class TestMacros(unittest.TestCase): def tearDown(self): self.result = [] - def handler(self, char, value): - """Where macros should write characters to.""" - self.result.append((char, value)) + def handler(self, code, value): + """Where macros should write codes to.""" + self.result.append((code, value)) + + def test_set_handler(self): + macro = parse('r(1, r(1, k(1)))') + one_code = system_mapping.get('1') + self.assertSetEqual(macro.get_capabilities(), {one_code}) + + self.loop.run_until_complete(macro.run()) + self.assertListEqual(self.result, []) + + macro.set_handler(self.handler) + self.loop.run_until_complete(macro.run()) + self.assertListEqual(self.result, [(one_code, 1), (one_code, 0)]) def test_0(self): - self.loop.run_until_complete(parse('k(1)', self.handler).run()) - self.assertListEqual(self.result, [(1, 1), (1, 0)]) + macro = parse('k(1)') + macro.set_handler(self.handler) + one_code = system_mapping.get('1') + self.assertSetEqual(macro.get_capabilities(), {one_code}) + + self.loop.run_until_complete(macro.run()) + self.assertListEqual(self.result, [(one_code, 1), (one_code, 0)]) def test_1(self): - macro = 'k(1).k(a).k(3)' - self.loop.run_until_complete(parse(macro, self.handler).run()) + macro = parse('k(1).k(a).k(3)') + macro.set_handler(self.handler) + self.assertSetEqual(macro.get_capabilities(), { + system_mapping.get('1'), + system_mapping.get('a'), + system_mapping.get('3') + }) + + self.loop.run_until_complete(macro.run()) self.assertListEqual(self.result, [ - (1, 1), (1, 0), - ('a', 1), ('a', 0), - (3, 1), (3, 0), + (system_mapping.get('1'), 1), (system_mapping.get('1'), 0), + (system_mapping.get('a'), 1), (system_mapping.get('a'), 0), + (system_mapping.get('3'), 1), (system_mapping.get('3'), 0), ]) def test_2(self): start = time.time() repeats = 20 - macro = f'r({repeats}, k(k))' - self.loop.run_until_complete(parse(macro, self.handler).run()) + + macro = parse(f'r({repeats}, k(k))') + macro.set_handler(self.handler) + k_code = system_mapping.get('k') + self.assertSetEqual(macro.get_capabilities(), {k_code}) + + self.loop.run_until_complete(macro.run()) keystroke_sleep = config.get('macros.keystroke_sleep_ms') sleep_time = 2 * repeats * keystroke_sleep / 1000 self.assertGreater(time.time() - start, sleep_time * 0.9) self.assertLess(time.time() - start, sleep_time * 1.1) - self.assertListEqual(self.result, [('k', 1), ('k', 0)] * repeats) + self.assertListEqual(self.result, [(k_code, 1), (k_code, 0)] * repeats) def test_3(self): start = time.time() - macro = 'r(3, k(m).w(100))' - self.loop.run_until_complete(parse(macro, self.handler).run()) + macro = parse('r(3, k(m).w(100))') + macro.set_handler(self.handler) + m_code = system_mapping.get('m') + self.assertSetEqual(macro.get_capabilities(), {m_code}) + self.loop.run_until_complete(macro.run()) keystroke_time = 6 * config.get('macros.keystroke_sleep_ms') total_time = keystroke_time + 300 @@ -75,26 +108,42 @@ class TestMacros(unittest.TestCase): self.assertGreater(time.time() - start, total_time * 0.9) self.assertLess(time.time() - start, total_time * 1.1) self.assertListEqual(self.result, [ - ('m', 1), ('m', 0), - ('m', 1), ('m', 0), - ('m', 1), ('m', 0), + (m_code, 1), (m_code, 0), + (m_code, 1), (m_code, 0), + (m_code, 1), (m_code, 0), ]) def test_4(self): - macro = ' r(2,\nk(\nr ).k(-\n )).k(m) ' - self.loop.run_until_complete(parse(macro, self.handler).run()) + macro = parse(' r(2,\nk(\nr ).k(minus\n )).k(m) ') + macro.set_handler(self.handler) + + r = system_mapping.get('r') + minus = system_mapping.get('minus') + m = system_mapping.get('m') + + self.assertSetEqual(macro.get_capabilities(), {r, minus, m}) + + self.loop.run_until_complete(macro.run()) self.assertListEqual(self.result, [ - ('r', 1), ('r', 0), - ('-', 1), ('-', 0), - ('r', 1), ('r', 0), - ('-', 1), ('-', 0), - ('m', 1), ('m', 0), + (r, 1), (r, 0), + (minus, 1), (minus, 0), + (r, 1), (r, 0), + (minus, 1), (minus, 0), + (m, 1), (m, 0), ]) def test_5(self): start = time.time() - macro = 'w(200).r(2,m(w,\nr(2,\tk(r))).w(10).k(k))' - self.loop.run_until_complete(parse(macro, self.handler).run()) + macro = parse('w(200).r(2,m(w,\nr(2,\tk(BtN_LeFt))).w(10).k(k))') + macro.set_handler(self.handler) + + w = system_mapping.get('w') + left = system_mapping.get('bTn_lEfT') + k = system_mapping.get('k') + + self.assertSetEqual(macro.get_capabilities(), {w, left, k}) + + self.loop.run_until_complete(macro.run()) num_pauses = 8 + 6 + 4 keystroke_time = num_pauses * config.get('macros.keystroke_sleep_ms') @@ -103,17 +152,18 @@ class TestMacros(unittest.TestCase): self.assertLess(time.time() - start, total_time * 1.1) self.assertGreater(time.time() - start, total_time * 0.9) - expected = [('w', 1)] - expected += [('r', 1), ('r', 0)] * 2 - expected += [('w', 0)] - expected += [('k', 1), ('k', 0)] + expected = [(w, 1)] + expected += [(left, 1), (left, 0)] * 2 + expected += [(w, 0)] + expected += [(k, 1), (k, 0)] expected *= 2 self.assertListEqual(self.result, expected) def test_6(self): # does nothing without .run - ret = parse('k(a).r(3, k(b))', self.handler) - self.assertIsInstance(ret, _Macro) + macro = parse('k(a).r(3, k(b))') + macro.set_handler(self.handler) + self.assertIsInstance(macro, _Macro) self.assertListEqual(self.result, []) diff --git a/tests/testcases/test_mapping.py b/tests/testcases/test_mapping.py index b9ad39e1..7f22d65a 100644 --- a/tests/testcases/test_mapping.py +++ b/tests/testcases/test_mapping.py @@ -23,7 +23,7 @@ import unittest from evdev.events import EV_KEY, EV_ABS from keymapper.mapping import Mapping -from keymapper.state import populate_system_mapping +from keymapper.state import SystemMapping class TestMapping(unittest.TestCase): @@ -31,18 +31,28 @@ class TestMapping(unittest.TestCase): self.mapping = Mapping() self.assertFalse(self.mapping.changed) - def test_populate_system_mapping(self): - # not actually a mapping object, just a dict - mapping = populate_system_mapping() - self.assertGreater(len(mapping), 100) - self.assertEqual(mapping['1'], 2) - self.assertEqual(mapping['KEY_1'], 2) + def test_system_mapping(self): + system_mapping = SystemMapping() + self.assertGreater(len(system_mapping._mapping), 100) + self.assertEqual(system_mapping.get('1'), 2) + self.assertEqual(system_mapping.get('KeY_1'), 2) - self.assertEqual(mapping['Alt_L'], 56) - self.assertEqual(mapping['KEY_LEFTALT'], 56) + self.assertEqual(system_mapping.get('AlT_L'), 56) + self.assertEqual(system_mapping.get('KEy_LEFtALT'), 56) - self.assertEqual(mapping['KEY_LEFTSHIFT'], 42) - self.assertEqual(mapping['Shift_L'], 42) + self.assertEqual(system_mapping.get('kEY_LeFTSHIFT'), 42) + self.assertEqual(system_mapping.get('ShiFt_L'), 42) + + self.assertIsNotNone(system_mapping.get('kp_1')) + self.assertIsNotNone(system_mapping.get('KP_1')) + self.assertEqual( + system_mapping.get('KP_Left'), + system_mapping.get('KP_4') + ) + self.assertEqual( + system_mapping.get('KP_Left'), + system_mapping.get('KEY_KP4') + ) def test_clone(self): mapping1 = Mapping()