implement combo/chord/sequence module

This commit is contained in:
xs5871 2022-03-09 01:27:56 +00:00 committed by Kyle Brown
parent a8e7f43e59
commit 5c33fd3a9f
6 changed files with 617 additions and 1 deletions

View File

@ -22,6 +22,7 @@
## [Modules](modules.md)
- [Combos](combos.md): Adds chords and sequences
- [Layers](layers.md): Adds layer support (Fn key) to allow many more keys to be put on your keyboard
- [ModTap](modtap.md): Adds support for augmented modifier keys to act as one key when tapped, and modifier when held.
- [Mouse keys](mouse_keys.md): Adds mouse keycodes

45
docs/combos.md Normal file
View File

@ -0,0 +1,45 @@
# Combos
Combos allow you to assign special functionality to combinations of key presses.
The two default behaviors are:
* Chords: match keys in random order, all pressed within 50ms.
* Sequences: match keys in order, pressed within 1s of one another.
You can define combos to listen to any valid KMK key, even internal or
functional keys, like HoldTap. When using internal KMK keys, be aware that the
order of modules matters.
The result of a combo is another key being pressed/released; if the desired
action isn't covered by KMK keys: create your own with `make_key` and attach
corresponding handlers.
Combos may overlap, i.e. share match keys amongst each other.
The optional arguments `timeout` and `per_key_timeout` define the time window
within which the match has to happen and wether the timeout is renewed after
each key press, respectively. These can be customized for every combo
individually.
## Keycodes
|New Keycode |Description |
|------------|----------------------------------------------------|
|`KC.LEADER` | a dummy / convenience key for leader key sequences |
## Example Code
```python
from kmk.keys import KC, make_key
from kmk.modules.combos import Combos, Chord, Sequence
combos = Combos()
keyboard.modules.append(combos)
make_key(
names=('MYKEY',),
on_press=lambda: print('I pressed MYKEY'),
)
combos.combos = [
Chord((KC.A, KC.B), KC.LSFT)
Chord((KC.A, KC.B, KC.C), KC.LALT)
Sequence((KC.LEADER, KC.A, KC.B), KC.C)
Sequence((KC.E, KC.F) KC.MYKEY, timeout=500, per_key_timeout=False)
]
```

View File

@ -7,6 +7,7 @@ sandbox, and can make massive changes to normal operation.
These modules are provided in all builds and can be enabled. Currently offered
modules are
- [Combos](combos.md): Adds chords and sequences
- [Layers](layers.md): Adds layer support (Fn key) to allow many more keys to be
put on your keyboard.
- [ModTap](modtap.md): Adds support for augmented modifier keys to act as one key

View File

@ -235,7 +235,7 @@ class KMKKeyboard:
for k, v in timeouts:
if k <= current_time:
v()
del self._timeouts[k]
self.cancel_timeout(k)
return self

196
kmk/modules/combos.py Normal file
View File

@ -0,0 +1,196 @@
import kmk.handlers.stock as handlers
from kmk.keys import make_key
from kmk.modules import Module
class Combo:
timeout = 50
per_key_timeout = False
_timeout = None
_remaining = []
def __init__(self, match, result, timeout=None, per_key_timeout=None):
'''
match: tuple of keys (KC.A, KC.B)
result: key KC.C
'''
self.match = match
self.result = result
if timeout:
self.timeout = timeout
if per_key_timeout:
self.per_key_timeout = per_key_timeout
def matches(self, key):
raise NotImplementedError
def reset(self):
self._remaining = list(self.match)
class Chord(Combo):
def matches(self, key):
try:
self._remaining.remove(key)
return True
except ValueError:
return False
class Sequence(Combo):
timeout = 1000
per_key_timeout = True
def matches(self, key):
try:
return key == self._remaining.pop(0)
except IndexError:
return False
class Combos(Module):
def __init__(self, combos=[]):
self.combos = combos
self._active = []
self._matching = []
self._reset = set()
self._key_buffer = []
make_key(
names=('LEADER',),
on_press=handlers.passthrough,
on_release=handlers.passthrough,
)
def during_bootup(self, keyboard):
self.reset(keyboard)
def before_matrix_scan(self, keyboard):
return
def after_matrix_scan(self, keyboard):
return
def before_hid_send(self, keyboard):
return
def after_hid_send(self, keyboard):
return
def on_powersave_enable(self, keyboard):
return
def on_powersave_disable(self, keyboard):
return
def process_key(self, keyboard, key, is_pressed, int_coord):
if is_pressed:
return self.on_press(keyboard, key)
else:
return self.on_release(keyboard, key)
def on_press(self, keyboard, key):
# refill potential matches from timed-out matches
if not self._matching:
self._matching = list(self._reset)
self._reset = set()
# filter potential matches
for combo in self._matching.copy():
if combo.matches(key):
continue
self._matching.remove(combo)
if combo._timeout:
keyboard.cancel_timeout(combo._timeout)
combo._timeout = keyboard.set_timeout(
combo.timeout, lambda c=combo: self.reset_combo(keyboard, c)
)
if self._matching:
# At least one combo matches current key: append key to buffer.
self._key_buffer.append((key, True))
key = None
# Start or reset individual combo timeouts.
for combo in self._matching:
if combo._timeout:
if combo.per_key_timeout:
keyboard.cancel_timeout(combo._timeout)
else:
continue
combo._timeout = keyboard.set_timeout(
combo.timeout, lambda c=combo: self.on_timeout(keyboard, c)
)
else:
# There's no matching combo: send and reset key buffer
self.send_key_buffer(keyboard)
self._key_buffer = []
return key
def on_release(self, keyboard, key):
for combo in self._active:
if key in combo.match:
# Deactivate combo if it matches current key.
self.deactivate(keyboard, combo)
self.reset_combo(keyboard, combo)
key = combo.result
break
# Don't propagate key-release events for keys that have been buffered.
# Append release events only if corresponding press is in buffer.
else:
pressed = self._key_buffer.count((key, True))
released = self._key_buffer.count((key, False))
if (pressed - released) > 0:
self._key_buffer.append((key, False))
key = None
return key
def on_timeout(self, keyboard, combo):
# If combo reaches timeout and has no remaining keys, activate it;
# else, drop it from the match list.
combo._timeout = None
self._matching.remove(combo)
if not combo._remaining:
self.activate(keyboard, combo)
if any([not pressed for (key, pressed) in self._key_buffer]):
# At least one of the combo keys has already been released:
# "tap" the combo result.
keyboard._send_hid()
self.deactivate(keyboard, combo)
self.reset(keyboard)
self._key_buffer = []
else:
if not self._matching:
# This was the last pending combo: flush key buffer.
self.send_key_buffer(keyboard)
self._key_buffer = []
self.reset_combo(keyboard, combo)
def send_key_buffer(self, keyboard):
for (key, is_pressed) in self._key_buffer:
keyboard.process_key(key, is_pressed)
keyboard._send_hid()
def activate(self, keyboard, combo):
combo.result.on_press(keyboard)
self._active.append(combo)
def deactivate(self, keyboard, combo):
combo.result.on_release(keyboard)
self._active.remove(combo)
def reset_combo(self, keyboard, combo):
combo.reset()
if combo._timeout is not None:
keyboard.cancel_timeout(combo._timeout)
combo._timeout = None
self._reset.add(combo)
def reset(self, keyboard):
self._matching = []
for combo in self.combos:
self.reset_combo(keyboard, combo)

373
tests/test_combos.py Normal file
View File

@ -0,0 +1,373 @@
import unittest
from kmk.keys import KC
from kmk.modules.combos import Chord, Combos, Sequence
from tests.keyboard_test import KeyboardTest
class TestCombo(unittest.TestCase):
def test_basic_kmk_keyboard(self):
combos = Combos()
combos.combos = [
Chord((KC.A, KC.B, KC.C), KC.Y),
Chord((KC.A, KC.B), KC.X),
Chord((KC.C, KC.D), KC.Z, timeout=80),
Sequence((KC.N1, KC.N2, KC.N3), KC.Y, timeout=50),
Sequence((KC.N1, KC.N2), KC.X, timeout=50),
Sequence((KC.N3, KC.N4), KC.Z, timeout=100),
Sequence((KC.N1, KC.N1, KC.N1), KC.W, timeout=50),
Sequence((KC.LEADER, KC.N1), KC.V, timeout=50),
]
keyboard = KeyboardTest(
[combos],
[
[KC.A, KC.B, KC.C, KC.D, KC.E, KC.F],
[KC.N1, KC.N2, KC.N3, KC.N4, KC.N5, KC.LEADER],
],
debug_enabled=False,
)
t_within = 40
t_after = 60
# test combos
keyboard.test(
'match: 2 combo, within timeout',
[(0, True), t_within, (1, True), (0, False), (1, False), t_after],
[{KC.X}, {}],
)
keyboard.test(
'match: 3 combo, within timout, shuffled',
[
(0, True),
(2, True),
(1, True),
t_within,
(1, False),
(0, False),
(2, False),
t_after,
],
[{KC.Y}, {}],
)
keyboard.test(
'match: 2 combo + overlap, after timeout',
[
(0, True),
(1, True),
(0, False),
(1, False),
t_after,
(2, True),
(2, False),
2 * t_after,
],
[{KC.X}, {}, {KC.C}, {}],
)
keyboard.test(
'match: 2 combo + overlap, interleaved, after timeout',
[
(0, True),
(1, True),
t_after,
(2, True),
2 * t_after,
(0, False),
(2, False),
(1, False),
t_after,
],
[{KC.X}, {KC.X, KC.C}, {KC.C}, {}],
)
keyboard.test(
'match: 2 combo hold + other, interleaved, after timeout',
[
(0, True),
(1, True),
t_after,
(4, True),
(4, False),
(0, False),
(1, False),
t_after,
],
[{KC.X}, {KC.X, KC.E}, {KC.X}, {}],
)
keyboard.test(
'match: 2 combo hold + overlap, interleaved, after timeout',
[
(0, True),
(1, True),
t_after,
(2, True),
(2, False),
2 * t_after,
(0, False),
(1, False),
t_after,
],
[{KC.X}, {KC.X, KC.C}, {KC.X}, {}],
)
keyboard.test(
'match: other + 2 combo, after timeout',
[
(4, True),
t_after,
(0, True),
(1, True),
t_after,
(1, False),
(4, False),
(0, False),
t_after,
],
[{KC.E}, {KC.E, KC.X}, {KC.E}, {}],
)
keyboard.test(
'match: 2 combo + other, after timeout',
[
(0, True),
(1, True),
t_after,
(4, True),
(1, False),
(4, False),
(0, False),
t_after,
],
[{KC.X}, {KC.E, KC.X}, {KC.E}, {}],
)
#
keyboard.test(
'match: 2 combo + 2 combo, after timeout',
[
(0, True),
(1, True),
t_after,
(2, True),
(3, True),
2 * t_after,
(0, False),
(1, False),
(2, False),
(3, False),
t_after,
],
[{KC.X}, {KC.X, KC.Z}, {KC.Z}, {}],
)
keyboard.test(
'match: 2 combo hold + 2 combo, after timeout',
[
(0, True),
(1, True),
t_after,
(2, True),
(3, True),
2 * t_after,
(2, False),
(3, False),
t_after,
(0, False),
(1, False),
t_after,
],
[{KC.X}, {KC.X, KC.Z}, {KC.X}, {}],
)
keyboard.test(
'no match: partial combor, after timeout',
[(0, True), (0, False), t_after],
[{KC.A}, {}],
)
keyboard.test(
'no match: partial combo, repeated',
[
(0, True),
(0, False),
t_within,
(0, True),
(0, False),
t_within,
(0, True),
(0, False),
t_after,
],
[{KC.A}, {}, {KC.A}, {}, {KC.A}, {}],
)
keyboard.test(
'no match: partial combo, repeated',
[
t_after,
(0, True),
(0, False),
(1, True),
(1, False),
t_within,
(0, True),
(0, False),
t_after,
],
[{KC.A}, {}, {KC.B}, {}, {KC.A}, {}],
)
keyboard.test(
'no match: 3 combo after timout',
[
(0, True),
(2, True),
t_after,
(1, True),
t_after,
(1, False),
(0, False),
(2, False),
t_after,
],
[{KC.A}, {KC.A, KC.C}, {KC.A, KC.C, KC.B}, {KC.A, KC.C}, {KC.C}, {}],
)
keyboard.test(
'no match: other + 2 combo within timeout',
[
(4, True),
t_within,
(0, True),
(1, True),
t_after,
(1, False),
(4, False),
(0, False),
t_after,
],
[{KC.E}, {KC.E, KC.A}, {KC.E, KC.A, KC.B}, {KC.E, KC.A}, {KC.A}, {}],
)
keyboard.test(
'no match: 2 + other combo within timeout',
[
(0, True),
t_within,
(1, True),
(4, True),
t_after,
(1, False),
(4, False),
(0, False),
t_after,
],
[{KC.A}, {KC.A, KC.B}, {KC.A, KC.B, KC.E}, {KC.A, KC.E}, {KC.A}, {}],
)
keyboard.test(
'no match: 2 Combo after timeout',
[(0, True), (0, False), t_after, (1, True), (1, False), t_after],
[{KC.A}, {}, {KC.B}, {}],
)
keyboard.test(
'no match: Combo + other, within timeout',
[
(0, True),
(1, True),
(4, True),
(0, False),
(1, False),
(4, False),
t_after,
],
[{KC.A}, {KC.A, KC.B}, {KC.A, KC.B, KC.E}, {KC.B, KC.E}, {KC.E}, {}],
)
keyboard.test(
'no match: Combo + other, within timeout',
[
(0, True),
(4, True),
(1, True),
(0, False),
(1, False),
(4, False),
t_after,
],
[{KC.A}, {KC.A, KC.E}, {KC.A, KC.B, KC.E}, {KC.B, KC.E}, {KC.E}, {}],
)
# test sequences
keyboard.keyboard.active_layers = [1]
keyboard.test(
'match: leader sequence, within timeout',
[(5, True), (5, False), t_within, (0, True), (0, False), t_after],
[{KC.V}, {}],
)
keyboard.test(
'match: 2 sequence, within timeout',
[(0, True), (0, False), t_within, (1, True), (1, False), t_after],
[{KC.X}, {}],
)
keyboard.test(
'match: 2 sequence, within long timeout',
[(2, True), (2, False), 2 * t_within, (3, True), (3, False), 2 * t_after],
[{KC.Z}, {}],
)
keyboard.test(
'match: 3 sequence, within timeout',
[
(0, True),
(0, False),
t_within,
(1, True),
(1, False),
t_within,
(2, True),
(2, False),
t_after,
],
[{KC.Y}, {}],
)
keyboard.test(
'match: 3 sequence, same key, within timeout',
[
(0, True),
(0, False),
t_within,
(0, True),
(0, False),
t_within,
(0, True),
(0, False),
t_within,
t_after,
],
[{KC.W}, {}],
)
keyboard.test(
'no match: 2 sequence, after timeout',
[(0, True), (0, False), t_after, (1, True), (1, False), t_after],
[{KC.N1}, {}, {KC.N2}, {}],
)
keyboard.test(
'no match: 2 sequence, out of order',
[(1, True), (1, False), t_within, (0, True), (0, False), t_after],
[{KC.N2}, {}, {KC.N1}, {}],
)
if __name__ == '__main__':
unittest.main()