diff --git a/keymapper/dev/macros.py b/keymapper/dev/macros.py index 852ce6d7..47d082ee 100644 --- a/keymapper/dev/macros.py +++ b/keymapper/dev/macros.py @@ -55,6 +55,10 @@ def is_this_a_macro(output): if not isinstance(output, str): return False + if '+' in output.strip(): + # for example "a + b" + return True + return '(' in output and ')' in output and len(output) >= 4 @@ -441,6 +445,34 @@ def _parse_recurse(macro, mapping, macro_instance=None, depth=0): return macro +def handle_plus_syntax(macro): + """transform a + b + c to m(a, m(b, m(c, h())))""" + if '+' not in macro: + return macro + + if '(' in macro or ')' in macro: + logger.error('Mixing "+" and macros is unsupported: "%s"', macro) + return macro + + chunks = [chunk.strip() for chunk in macro.split('+')] + output = '' + depth = 0 + for chunk in chunks: + if chunk == '': + # invalid syntax + logger.error('Invalid syntax for "%s"', macro) + return macro + + depth += 1 + output += f'm({chunk},' + + output += 'h()' + output += depth * ')' + + logger.debug('Transformed "%s" to "%s"', macro, output) + return output + + def parse(macro, mapping, return_errors=False): """parse and generate a _Macro that can be run as often as you want. @@ -460,6 +492,8 @@ def parse(macro, mapping, return_errors=False): If True, returns errors as a string or None if parsing worked. If False, returns the parsed macro. """ + macro = handle_plus_syntax(macro) + # 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) diff --git a/readme/development.md b/readme/development.md index c43e80f7..e104e38d 100644 --- a/readme/development.md +++ b/readme/development.md @@ -30,7 +30,7 @@ requests. - [x] mapping joystick directions as buttons, making it act like a D-Pad - [x] mapping mouse wheel events to buttons - [x] automatically load presets when devices get plugged in after login (udev) -- [ ] map keys using a `modifier + modifier + ... + key` syntax +- [x] map keys using a `modifier + modifier + ... + key` syntax - [ ] injecting keys that aren't available in the systems keyboard layout ## Tests diff --git a/readme/plus.png b/readme/plus.png new file mode 100644 index 00000000..2d44daa3 Binary files /dev/null and b/readme/plus.png differ diff --git a/readme/screenshot.png b/readme/screenshot.png index f7edc586..61b5b42f 100644 Binary files a/readme/screenshot.png and b/readme/screenshot.png differ diff --git a/readme/usage.md b/readme/usage.md index 3e5b6047..25a35268 100644 --- a/readme/usage.md +++ b/readme/usage.md @@ -60,6 +60,16 @@ your focused application.
+## Writing Combinations + +You can write `control_l + a` as mapping, which will inject those two +keycodes into your system on a single key press. An arbitrary number of +names can be chained using ` + `. + ++ +
+ ## Macros It is possible to write timed macros into the center column: diff --git a/tests/test.py b/tests/test.py index a1b6341f..3a798ab7 100644 --- a/tests/test.py +++ b/tests/test.py @@ -411,9 +411,11 @@ _fixture_copy = copy.deepcopy(fixtures) environ_copy = copy.deepcopy(os.environ) -def cleanup(): +def quick_cleanup(log=True): """Reset the applications state.""" - print('cleanup') + if log: + print('quick cleanup') + keycode_reader.stop_reading() keycode_reader.__init__() @@ -421,10 +423,6 @@ def cleanup(): for task in asyncio.all_tasks(): task.cancel() - os.system('pkill -f key-mapper-service') - - time.sleep(0.05) - if os.path.exists(tmp): shutil.rmtree(tmp) @@ -459,6 +457,20 @@ def cleanup(): if key not in environ_copy: del os.environ[key] + +def cleanup(): + """Reset the applications state. + + Using this is very slow, usually quick_cleanup() is sufficient. + """ + print('cleanup') + + os.system('pkill -f key-mapper-service') + + time.sleep(0.05) + + quick_cleanup(log=False) + refresh_devices() diff --git a/tests/testcases/test_config.py b/tests/testcases/test_config.py index 848970aa..052834b6 100644 --- a/tests/testcases/test_config.py +++ b/tests/testcases/test_config.py @@ -25,12 +25,12 @@ import unittest from keymapper.config import config, GlobalConfig from keymapper.paths import touch, CONFIG_PATH -from tests.test import cleanup, tmp +from tests.test import quick_cleanup, tmp class TestConfig(unittest.TestCase): def tearDown(self): - cleanup() + quick_cleanup() self.assertEqual(len(config.iterate_autoload_presets()), 0) def test_migrate(self): diff --git a/tests/testcases/test_control.py b/tests/testcases/test_control.py index a38fe13c..216d9cdf 100644 --- a/tests/testcases/test_control.py +++ b/tests/testcases/test_control.py @@ -35,7 +35,7 @@ from keymapper.daemon import Daemon from keymapper.mapping import Mapping from keymapper.paths import get_preset_path -from tests.test import cleanup, tmp +from tests.test import quick_cleanup, tmp def import_control(): @@ -63,7 +63,7 @@ options = collections.namedtuple( class TestControl(unittest.TestCase): def tearDown(self): - cleanup() + quick_cleanup() def test_autoload(self): devices = ['device 1', 'device 2'] diff --git a/tests/testcases/test_event_producer.py b/tests/testcases/test_event_producer.py index 9dd518d4..85f84371 100644 --- a/tests/testcases/test_event_producer.py +++ b/tests/testcases/test_event_producer.py @@ -30,7 +30,7 @@ from keymapper.mapping import Mapping from keymapper.dev.event_producer import EventProducer, MOUSE, WHEEL from tests.test import InputDevice, UInput, MAX_ABS, clear_write_history, \ - uinput_write_history, cleanup, new_event + uinput_write_history, quick_cleanup, new_event abs_state = [0, 0, 0, 0] @@ -55,7 +55,7 @@ class TestEventProducer(unittest.TestCase): config.set('gamepad.joystick.y_scroll_speed', 1) def tearDown(self): - cleanup() + quick_cleanup() def test_debounce_1(self): loop = asyncio.get_event_loop() diff --git a/tests/testcases/test_injector.py b/tests/testcases/test_injector.py index 510bbf93..d605a7b0 100644 --- a/tests/testcases/test_injector.py +++ b/tests/testcases/test_injector.py @@ -41,7 +41,7 @@ from keymapper.getdevices import get_devices, is_gamepad from tests.test import new_event, pending_events, fixtures, \ EVENT_READ_TIMEOUT, uinput_write_history_pipe, \ - MAX_ABS, cleanup, read_write_history_pipe, InputDevice + MAX_ABS, quick_cleanup, read_write_history_pipe, InputDevice original_smeab = utils.should_map_event_as_btn @@ -75,7 +75,7 @@ class TestInjector(unittest.TestCase): self.injector = None evdev.InputDevice.grab = self.grab - cleanup() + quick_cleanup() def test_grab(self): # path is from the fixtures @@ -827,7 +827,7 @@ class TestModifyCapabilities(unittest.TestCase): self.assertNotIn(DISABLE_CODE, keys) def tearDown(self): - cleanup() + quick_cleanup() def test_modify_capabilities(self): self.injector = Injector('foo', self.mapping) diff --git a/tests/testcases/test_keycode_mapper.py b/tests/testcases/test_keycode_mapper.py index 9cc2cd9f..f15a2c39 100644 --- a/tests/testcases/test_keycode_mapper.py +++ b/tests/testcases/test_keycode_mapper.py @@ -34,7 +34,7 @@ from keymapper.config import config, BUTTONS from keymapper.mapping import Mapping, DISABLE_CODE from tests.test import new_event, UInput, uinput_write_history, \ - cleanup, InputDevice, MAX_ABS + quick_cleanup, InputDevice, MAX_ABS def wait(func, timeout=1.0): @@ -84,7 +84,7 @@ class TestKeycodeMapper(unittest.TestCase): self.assertFalse(macro.is_holding()) self.assertFalse(macro.running) - cleanup() + quick_cleanup() def test_subsets(self): a = subsets(((1,), (2,), (3,))) diff --git a/tests/testcases/test_macros.py b/tests/testcases/test_macros.py index 1656e84b..eff79620 100644 --- a/tests/testcases/test_macros.py +++ b/tests/testcases/test_macros.py @@ -24,11 +24,13 @@ import unittest import asyncio from keymapper.dev.macros import parse, _Macro, _extract_params, \ - is_this_a_macro, _parse_recurse + is_this_a_macro, _parse_recurse, handle_plus_syntax from keymapper.config import config from keymapper.mapping import Mapping from keymapper.state import system_mapping +from tests.test import quick_cleanup + class TestMacros(unittest.TestCase): def setUp(self): @@ -39,6 +41,7 @@ class TestMacros(unittest.TestCase): def tearDown(self): self.result = [] self.mapping.clear_config() + quick_cleanup() def handler(self, code, value): """Where macros should write codes to.""" @@ -55,6 +58,58 @@ class TestMacros(unittest.TestCase): self.assertFalse(is_this_a_macro('minus')) self.assertFalse(is_this_a_macro('k')) + self.assertTrue(is_this_a_macro('a+b')) + self.assertTrue(is_this_a_macro('a+b+c')) + self.assertTrue(is_this_a_macro('a + b')) + self.assertTrue(is_this_a_macro('a + b + c')) + + def test_handle_plus_syntax(self): + self.assertEqual(handle_plus_syntax('a + b'), 'm(a,m(b,h()))') + self.assertEqual(handle_plus_syntax('a + b + c'), 'm(a,m(b,m(c,h())))') + self.assertEqual(handle_plus_syntax(' a+b+c '), 'm(a,m(b,m(c,h())))') + + # invalid + self.assertEqual(handle_plus_syntax('+'), '+') + self.assertEqual(handle_plus_syntax('a+'), 'a+') + self.assertEqual(handle_plus_syntax('+b'), '+b') + self.assertEqual(handle_plus_syntax('k(a + b)'), 'k(a + b)') + self.assertEqual(handle_plus_syntax('a'), 'a') + self.assertEqual(handle_plus_syntax('k(a)'), 'k(a)') + self.assertEqual(handle_plus_syntax(''), '') + + def test_run_plus_syntax(self): + macro = parse('a + b + c + d', self.mapping) + macro.set_handler(self.handler) + self.assertSetEqual(macro.get_capabilities(), { + system_mapping.get('a'), + system_mapping.get('b'), + system_mapping.get('c'), + system_mapping.get('d') + }) + + macro.press_key() + asyncio.ensure_future(macro.run()) + self.loop.run_until_complete(asyncio.sleep(0.2)) + self.assertTrue(macro.is_holding()) + print(self.mapping.get('macros.keystroke_sleep_ms')) + print(self.result) + + # starting from the left, presses each one down + self.assertEqual(self.result[0], (system_mapping.get('a'), 1)) + self.assertEqual(self.result[1], (system_mapping.get('b'), 1)) + self.assertEqual(self.result[2], (system_mapping.get('c'), 1)) + self.assertEqual(self.result[3], (system_mapping.get('d'), 1)) + + # and then releases starting with the previously pressed key + macro.release_key() + self.loop.run_until_complete(asyncio.sleep(0.2)) + self.assertFalse(macro.is_holding()) + print(self.result) + self.assertEqual(self.result[4], (system_mapping.get('d'), 0)) + self.assertEqual(self.result[5], (system_mapping.get('c'), 0)) + self.assertEqual(self.result[6], (system_mapping.get('b'), 0)) + self.assertEqual(self.result[7], (system_mapping.get('a'), 0)) + def test_extract_params(self): def expect(raw, expectation): self.assertListEqual(_extract_params(raw), expectation) diff --git a/tests/testcases/test_mapping.py b/tests/testcases/test_mapping.py index 318a0e28..b3e4e82a 100644 --- a/tests/testcases/test_mapping.py +++ b/tests/testcases/test_mapping.py @@ -31,12 +31,12 @@ from keymapper.config import config from keymapper.paths import get_preset_path from keymapper.key import Key -from tests.test import tmp, cleanup +from tests.test import tmp, quick_cleanup class TestSystemMapping(unittest.TestCase): def tearDown(self): - cleanup() + quick_cleanup() def test_update(self): system_mapping = SystemMapping() @@ -113,7 +113,7 @@ class TestMapping(unittest.TestCase): self.assertFalse(self.mapping.changed) def tearDown(self): - cleanup() + quick_cleanup() def test_config(self): self.mapping.save(get_preset_path('foo', 'bar2')) diff --git a/tests/testcases/test_paths.py b/tests/testcases/test_paths.py index f6d226cb..4a355822 100644 --- a/tests/testcases/test_paths.py +++ b/tests/testcases/test_paths.py @@ -25,7 +25,7 @@ import unittest from keymapper.paths import get_user, touch, mkdir, \ get_preset_path, get_config_path -from tests.test import cleanup, tmp +from tests.test import quick_cleanup, tmp original_getlogin = os.getlogin() @@ -37,7 +37,7 @@ def _raise(error): class TestPaths(unittest.TestCase): def tearDown(self): - cleanup() + quick_cleanup() os.getlogin = original_getlogin def test_get_user(self): diff --git a/tests/testcases/test_reader.py b/tests/testcases/test_reader.py index a66c217e..d312b5ec 100644 --- a/tests/testcases/test_reader.py +++ b/tests/testcases/test_reader.py @@ -34,7 +34,7 @@ from keymapper.config import BUTTONS, MOUSE from keymapper.key import Key from tests.test import new_event, pending_events, EVENT_READ_TIMEOUT, \ - cleanup, MAX_ABS + quick_cleanup, MAX_ABS CODE_1 = 100 @@ -59,7 +59,7 @@ class TestReader(unittest.TestCase): self.assertEqual(keycode_reader.read(), None) def tearDown(self): - cleanup() + quick_cleanup() def test_will_report_up(self): self.assertFalse(will_report_up(EV_REL))