Skip to content

Commit

Permalink
Extend macros with hold and release handlers
Browse files Browse the repository at this point in the history
  • Loading branch information
xs5871 committed Jun 6, 2024
1 parent 812750b commit ac3892c
Show file tree
Hide file tree
Showing 3 changed files with 216 additions and 22 deletions.
73 changes: 73 additions & 0 deletions docs/en/macros.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
)
```
123 changes: 102 additions & 21 deletions kmk/modules/macros.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,37 @@
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
from kmk.utils import Debug

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):
Expand Down Expand Up @@ -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
Expand All @@ -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',),
Expand Down Expand Up @@ -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))
Expand All @@ -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:
Expand Down
42 changes: 41 additions & 1 deletion tests/test_macros.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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(
'',
Expand All @@ -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):
Expand Down

0 comments on commit ac3892c

Please sign in to comment.