diff --git a/docs/en/macros.md b/docs/en/macros.md index f087b23c1..dd85178f9 100644 --- a/docs/en/macros.md +++ b/docs/en/macros.md @@ -25,6 +25,39 @@ This will enable a new type of keycode: `KC.MACRO()` |`KC.UC_MODE_MACOS` |Switch Unicode mode to macOS. | |`KC.UC_MODE_WINC` |Switch Unicode mode to Windows Compose. | +Full macro signature, all arguments optional: + +```python +KC.MACRO( + on_press=None, + on_hold=None, + on_release=None, + blocking=True, +) +``` + +### `on_press` +This sequence is run once at the beginning, just after the macro key has been +pressed. +`KC.MACRO(macro)` is actually a short-hand for `KC.MACRO(on_press=macro)`. + +### `on_hold` +This sequence is run in a loop while the macro key is pressed (or "held"). +If the key is released before the `on_press` sequence is finished, the `on_hold` +sequence will be skipped. + +### `on_release` +This sequence is run once at the end, after the macro key has been released and +the previous sequence has finished. + +### `blocking` +By default, all key events will be intercepted while a macro is running and +replayed after all blocking macros have finished. +This is to avoid side effects and can be disabled with `blocking=False` if +undesired. +(And yes, technically multiple blocking macros can run simultaneously, the +achievement of which is left as an exercise to the reader.) + ## Sending strings The most basic sequence is an ASCII string. It can be used to send any standard @@ -224,3 +257,43 @@ COUNTDOWN_TO_PASTE = KC.MACRO( countdown(3, 1000), Tap(KC.LCTL(KC.V)), ) +``` + +### Example 3 + +A high productivity replacement for the common space key: +This macro ensures that you make good use of your time by measuring how long +you've been holding the space key for, printing the result to the debug +console, all the while reminding you that you're still holding the space key. + +```python +from supervisor import ticks_ms +from kmk.utils import Debug + +debug = Debug(__name__) + +def make_timer(): + ticks = 0 + def _(): + nonlocal ticks + return (ticks := ticks_ms() - ticks) + return _ + +space_timer = make_timer() + +SPACETIME = KC.MACRO( + on_press=( + lambda _: space_timer() and None, + Press(KC.SPACE), + lambda _: debug('start holding space...'), + ), + on_hold=( + lambda _: debug('..still holding space..'), + ), + on_release=( + Release(KC.SPACE), + lambda _: debug('...end holding space after ', space_timer(), 'ms'), + ), + blocking=False, +) +``` diff --git a/kmk/modules/macros.py b/kmk/modules/macros.py index 47f5f96f0..483ea2ffe 100644 --- a/kmk/modules/macros.py +++ b/kmk/modules/macros.py @@ -1,3 +1,5 @@ +from micropython import const + from kmk.keys import KC, make_argumented_key, make_key from kmk.modules import Module from kmk.scheduler import create_task @@ -5,10 +7,31 @@ debug = Debug(__name__) +_IDLE = const(0) +_ON_PRESS = const(1) +_ON_HOLD = const(2) +_RELEASE = const(3) +_ON_RELEASE = const(4) + class MacroMeta: - def __init__(self, *macro, **kwargs): - self.macro = macro + def __init__( + self, + *args, + on_press=None, + on_hold=None, + on_release=None, + blocking=True, + ): + if on_press is not None: + self.on_press = on_press + else: + self.on_press = args + self.on_hold = on_hold + self.on_release = on_release + self.blocking = blocking + self.state = _IDLE + self._task = None def Delay(delay): @@ -127,7 +150,7 @@ def MacroIter(keyboard, macro, unicode_mode): class Macros(Module): def __init__(self, unicode_mode=UnicodeModeIBus, delay=10): - self._active = False + self._active = [] self.key_buffer = [] self.unicode_mode = unicode_mode self.delay = delay @@ -136,6 +159,7 @@ def __init__(self, unicode_mode=UnicodeModeIBus, delay=10): validator=MacroMeta, names=('MACRO',), on_press=self.on_press_macro, + on_release=self.on_release_macro, ) make_key( names=('UC_MODE_IBUS',), @@ -163,7 +187,7 @@ def after_matrix_scan(self, keyboard): return def process_key(self, keyboard, key, is_pressed, int_coord): - if not self._active: + if not self._active or key in self._active: return key self.key_buffer.append((int_coord, key, is_pressed)) @@ -184,27 +208,84 @@ def on_press_unicode_mode(self, key, keyboard, *args, **kwargs): self.unicode_mode = key.meta def on_press_macro(self, key, keyboard, *args, **kwargs): - self._active = True - - _iter = MacroIter(keyboard, key.meta.macro, self.unicode_mode) - - def process_macro_async(): - delay = self.delay - try: - # any not None value the iterator yields is a delay value in ms. - ret = next(_iter) - if ret is not None: - delay = ret - keyboard._send_hid() - create_task(process_macro_async, after_ms=delay) - except StopIteration: + key.meta.state = _ON_PRESS + self.process_macro_async(keyboard, key) + + def on_release_macro(self, key, keyboard, *args, **kwargs): + key.meta.state = _RELEASE + if key.meta._task is None: + self.process_macro_async(keyboard, key) + + def process_macro_async(self, keyboard, key, _iter=None): + # There's no active macro iterator: select the next one. + if _iter is None: + key.meta._task = None + + if key.meta.state == _ON_PRESS: + if key.meta.blocking: + self._active.append(key) + if (macro := key.meta.on_press) is None: + key.meta.state = _ON_HOLD + elif debug.enabled: + debug('on_press') + + if key.meta.state == _ON_HOLD: + if (macro := key.meta.on_hold) is None: + return + elif debug.enabled: + debug('on_hold') + + if key.meta.state == _RELEASE: + key.meta.state = _ON_RELEASE + + if key.meta.state == _ON_RELEASE: + if (macro := key.meta.on_release) is None: + macro = () + elif debug.enabled: + debug('on_release') + + _iter = MacroIter(keyboard, macro, self.unicode_mode) + + # Run one step in the macro sequence. + delay = self.delay + try: + # any not None value the iterator yields is a delay value in ms. + ret = next(_iter) + if ret is not None: + delay = ret + keyboard._send_hid() + + # The sequence has reached its end: advance the macro state. + except StopIteration: + _iter = None + delay = 0 + key.meta._task = None + + if key.meta.state == _ON_PRESS: + key.meta.state = _ON_HOLD + + elif key.meta.state == _ON_RELEASE: + if debug.enabled: + debug('deactivate') + key.meta.state = _IDLE + if key.meta.blocking: + self._active.remove(key) self.send_key_buffer(keyboard) + return + + # Schedule the next step. + # Reuse existing task objects and save a couple of bytes and cycles for the gc. + if key.meta._task: + task = key.meta._task + else: + + def task(): + self.process_macro_async(keyboard, key, _iter) - process_macro_async() + key.meta._task = create_task(task, after_ms=delay) def send_key_buffer(self, keyboard): - self._active = False - if not self.key_buffer: + if not self.key_buffer or self._active: return for int_coord, key, is_pressed in self.key_buffer: diff --git a/tests/test_macros.py b/tests/test_macros.py index 3632d76e3..15057eb74 100644 --- a/tests/test_macros.py +++ b/tests/test_macros.py @@ -29,6 +29,11 @@ def setUp(self): KC.MACRO('Foo1'), KC.MACRO(Press(KC.LCTL), 'Foo1', Release(KC.LCTL)), KC.MACRO('🍺!'), + KC.MACRO(on_press='p'), + KC.MACRO(on_hold='h'), + KC.MACRO(on_release='r'), + KC.MACRO(on_press='p', on_hold='h', on_release='r'), + KC.MACRO('bar', blocking=False), ] ], debug_enabled=False, @@ -166,7 +171,7 @@ def test_7_ralt(self): ], ) - def test_8_winc(self): + def test_7_winc(self): self.macros.unicode_mode = UnicodeModeWinC self.kb.test( '', @@ -191,6 +196,41 @@ def test_8_winc(self): ], ) + def test_8(self): + self.kb.test( + '', + [(8, True), (8, False)], + [{KC.P}, {}], + ) + + def test_9(self): + self.kb.test( + '', + [(9, True), 15 * self.kb.loop_delay_ms, (9, False)], + [{KC.H}, {}, {KC.H}, {}], + ) + + def test_10(self): + self.kb.test( + '', + [(10, True), (10, False)], + [{KC.R}, {}], + ) + + def test_11(self): + self.kb.test( + '', + [(11, True), 30 * self.kb.loop_delay_ms, (11, False)], + [{KC.P}, {}, {KC.H}, {}, {KC.H}, {}, {KC.R}, {}], + ) + + def test_12(self): + self.kb.test( + '', + [(12, True), (12, False), (4, True), (4, False)], + [{KC.B}, {KC.B, KC.Y}, {KC.B}, {}, {KC.A}, {}, {KC.R}, {}], + ) + class TestUnicodeModeKeys(unittest.TestCase): def setUp(self):