Skip to content

Commit a847dcb

Browse files
committed
Extend macros with hold and release handlers
1 parent 812750b commit a847dcb

File tree

3 files changed

+214
-22
lines changed

3 files changed

+214
-22
lines changed

docs/en/macros.md

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,39 @@ This will enable a new type of keycode: `KC.MACRO()`
2525
|`KC.UC_MODE_MACOS` |Switch Unicode mode to macOS. |
2626
|`KC.UC_MODE_WINC` |Switch Unicode mode to Windows Compose. |
2727

28+
Full macro signature, all arguments optional:
29+
30+
```python
31+
KC.MACRO(
32+
on_press=None,
33+
on_hold=None,
34+
on_release=None,
35+
blocking=True,
36+
)
37+
```
38+
39+
### `on_press`
40+
This sequence is run once at the beginning, just after the macro key has been
41+
pressed.
42+
`KC.MACRO(macro)` is actually a short-hand for `KC.MACRO(on_press=macro)`.
43+
44+
### `on_hold`
45+
This sequence is run in a loop while the macro key is pressed (or "held").
46+
If the key is released before the `on_press` sequence is finished, the `on_hold`
47+
sequence will be skipped.
48+
49+
### `on_release`
50+
This sequence is run once at the end, after the macro key has been released and
51+
the previous sequence has finished.
52+
53+
### `blocking`
54+
By default, all key events will be intercepted while a macro is running and
55+
replayed after all blocking macros have finished.
56+
This is to avoid side effects and can be disabled with `blocking=False` if
57+
undesired.
58+
(And yes, technically multiple blocking macros can run simultaneously, the
59+
achievement of which is left as an excercise to the reader.)
60+
2861
## Sending strings
2962

3063
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(
224257
countdown(3, 1000),
225258
Tap(KC.LCTL(KC.V)),
226259
)
260+
```
261+
262+
### Example 3
263+
264+
A high productivity replacement for the common space key:
265+
This macro ensures that you make good use of your time by measuring how long
266+
you've been holding the space key for, printing the result to the debug
267+
console, all the while reminding you that you're still holding the space key.
268+
269+
```python
270+
from supervisor import ticks_ms
271+
from kmk.utils import Debug
272+
273+
debug = Debug(__name__)
274+
275+
def make_timer():
276+
ticks = 0
277+
def _():
278+
nonlocal ticks
279+
return (ticks := ticks_ms() - ticks)
280+
return _
281+
282+
space_timer = make_timer()
283+
284+
SPACETIME = KC.MACRO(
285+
on_press=(
286+
lambda _: space_timer() and None,
287+
Press(KC.SPACE),
288+
lambda _: debug('start holding space...'),
289+
),
290+
on_hold=(
291+
lambda _: debug('..still holding space..'),
292+
),
293+
on_release=(
294+
Release(KC.SPACE),
295+
lambda _: debug('...end holding space after ', space_timer(), 'ms'),
296+
),
297+
blocking=False,
298+
)
299+
```

kmk/modules/macros.py

Lines changed: 100 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,37 @@
1+
from micropython import const
2+
13
from kmk.keys import KC, make_argumented_key, make_key
24
from kmk.modules import Module
35
from kmk.scheduler import create_task
46
from kmk.utils import Debug
57

68
debug = Debug(__name__)
79

10+
_IDLE = const(0)
11+
_ON_PRESS = const(1)
12+
_ON_HOLD = const(2)
13+
_RELEASE = const(3)
14+
_ON_RELEASE = const(4)
15+
816

917
class MacroMeta:
10-
def __init__(self, *macro, **kwargs):
11-
self.macro = macro
18+
def __init__(
19+
self,
20+
*args,
21+
on_press=None,
22+
on_hold=None,
23+
on_release=None,
24+
blocking=True,
25+
):
26+
if on_press is not None:
27+
self.on_press = on_press
28+
else:
29+
self.on_press = args
30+
self.on_hold = on_hold
31+
self.on_release = on_release
32+
self.blocking = blocking
33+
self.state = _IDLE
34+
self._task = None
1235

1336

1437
def Delay(delay):
@@ -127,7 +150,7 @@ def MacroIter(keyboard, macro, unicode_mode):
127150

128151
class Macros(Module):
129152
def __init__(self, unicode_mode=UnicodeModeIBus, delay=10):
130-
self._active = False
153+
self._active = []
131154
self.key_buffer = []
132155
self.unicode_mode = unicode_mode
133156
self.delay = delay
@@ -136,6 +159,7 @@ def __init__(self, unicode_mode=UnicodeModeIBus, delay=10):
136159
validator=MacroMeta,
137160
names=('MACRO',),
138161
on_press=self.on_press_macro,
162+
on_release=self.on_release_macro,
139163
)
140164
make_key(
141165
names=('UC_MODE_IBUS',),
@@ -163,7 +187,7 @@ def after_matrix_scan(self, keyboard):
163187
return
164188

165189
def process_key(self, keyboard, key, is_pressed, int_coord):
166-
if not self._active:
190+
if not self._active or key in self._active:
167191
return key
168192

169193
self.key_buffer.append((int_coord, key, is_pressed))
@@ -184,27 +208,82 @@ def on_press_unicode_mode(self, key, keyboard, *args, **kwargs):
184208
self.unicode_mode = key.meta
185209

186210
def on_press_macro(self, key, keyboard, *args, **kwargs):
187-
self._active = True
188-
189-
_iter = MacroIter(keyboard, key.meta.macro, self.unicode_mode)
190-
191-
def process_macro_async():
192-
delay = self.delay
193-
try:
194-
# any not None value the iterator yields is a delay value in ms.
195-
ret = next(_iter)
196-
if ret is not None:
197-
delay = ret
198-
keyboard._send_hid()
199-
create_task(process_macro_async, after_ms=delay)
200-
except StopIteration:
211+
key.meta.state = _ON_PRESS
212+
self.process_macro_async(keyboard, key)
213+
214+
def on_release_macro(self, key, keyboard, *args, **kwargs):
215+
key.meta.state = _RELEASE
216+
if key.meta._task is None:
217+
self.process_macro_async(keyboard, key)
218+
219+
def process_macro_async(self, keyboard, key, _iter=None):
220+
# There's no active macro iterator: select the next one.
221+
if _iter is None:
222+
key.meta._task = None
223+
224+
if key.meta.state == _ON_PRESS:
225+
if key.meta.blocking:
226+
self._active.append(key)
227+
if (macro := key.meta.on_press) is None:
228+
key.meta.state = _ON_HOLD
229+
elif debug.enabled:
230+
debug('on_press')
231+
232+
if key.meta.state == _ON_HOLD:
233+
if (macro := key.meta.on_hold) is None:
234+
return
235+
elif debug.enabled:
236+
debug('on_hold')
237+
238+
if key.meta.state == _RELEASE:
239+
key.meta.state = _ON_RELEASE
240+
241+
if key.meta.state == _ON_RELEASE:
242+
if (macro := key.meta.on_release) is None:
243+
macro = ()
244+
elif debug.enabled:
245+
debug('on_release')
246+
247+
_iter = MacroIter(keyboard, macro, self.unicode_mode)
248+
249+
# Run one step in the macro sequence.
250+
delay = self.delay
251+
try:
252+
# any not None value the iterator yields is a delay value in ms.
253+
ret = next(_iter)
254+
if ret is not None:
255+
delay = ret
256+
keyboard._send_hid()
257+
258+
# The sequence has reached its end: advance the macro state.
259+
except StopIteration:
260+
_iter = None
261+
delay = 0
262+
key.meta._task = None
263+
264+
if key.meta.state == _ON_PRESS:
265+
key.meta.state = _ON_HOLD
266+
267+
elif key.meta.state == _ON_RELEASE:
268+
if debug.enabled:
269+
debug('deactivate')
270+
key.meta.state = _IDLE
271+
if key.meta.blocking:
272+
self._active.remove(key)
201273
self.send_key_buffer(keyboard)
274+
return
202275

203-
process_macro_async()
276+
# Schedule the next step.
277+
# Reuse existing task objects and save a couple of bytes and cycles for the gc.
278+
if key.meta._task:
279+
task = key.meta._task
280+
else:
281+
def task():
282+
self.process_macro_async(keyboard, key, _iter)
283+
key.meta._task = create_task(task, after_ms=delay)
204284

205285
def send_key_buffer(self, keyboard):
206-
self._active = False
207-
if not self.key_buffer:
286+
if not self.key_buffer or self._active:
208287
return
209288

210289
for int_coord, key, is_pressed in self.key_buffer:

tests/test_macros.py

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,11 @@ def setUp(self):
2929
KC.MACRO('Foo1'),
3030
KC.MACRO(Press(KC.LCTL), 'Foo1', Release(KC.LCTL)),
3131
KC.MACRO('🍺!'),
32+
KC.MACRO(on_press='p'),
33+
KC.MACRO(on_hold='h'),
34+
KC.MACRO(on_release='r'),
35+
KC.MACRO(on_press='p', on_hold='h', on_release='r'),
36+
KC.MACRO('bar', blocking=False),
3237
]
3338
],
3439
debug_enabled=False,
@@ -166,7 +171,7 @@ def test_7_ralt(self):
166171
],
167172
)
168173

169-
def test_8_winc(self):
174+
def test_7_winc(self):
170175
self.macros.unicode_mode = UnicodeModeWinC
171176
self.kb.test(
172177
'',
@@ -191,6 +196,41 @@ def test_8_winc(self):
191196
],
192197
)
193198

199+
def test_8(self):
200+
self.kb.test(
201+
'',
202+
[(8, True), (8, False)],
203+
[{KC.P}, {}],
204+
)
205+
206+
def test_9(self):
207+
self.kb.test(
208+
'',
209+
[(9, True), 15 * self.kb.loop_delay_ms, (9, False)],
210+
[{KC.H}, {}, {KC.H}, {}],
211+
)
212+
213+
def test_10(self):
214+
self.kb.test(
215+
'',
216+
[(10, True), (10, False)],
217+
[{KC.R}, {}],
218+
)
219+
220+
def test_11(self):
221+
self.kb.test(
222+
'',
223+
[(11, True), 30 * self.kb.loop_delay_ms, (11, False)],
224+
[{KC.P}, {}, {KC.H}, {}, {KC.H}, {}, {KC.R}, {}],
225+
)
226+
227+
def test_12(self):
228+
self.kb.test(
229+
'',
230+
[(12, True), (12, False), (4, True), (4, False)],
231+
[{KC.B}, {KC.B, KC.Y}, {KC.B}, {}, {KC.A}, {}, {KC.R}, {}],
232+
)
233+
194234

195235
class TestUnicodeModeKeys(unittest.TestCase):
196236
def setUp(self):

0 commit comments

Comments
 (0)