diff --git a/docs/README.md b/docs/README.md index 9c6fa42..8dae7e5 100644 --- a/docs/README.md +++ b/docs/README.md @@ -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 diff --git a/docs/combos.md b/docs/combos.md new file mode 100644 index 0000000..73b5af0 --- /dev/null +++ b/docs/combos.md @@ -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) +] +``` diff --git a/docs/modules.md b/docs/modules.md index 52719f1..499a8c1 100644 --- a/docs/modules.md +++ b/docs/modules.md @@ -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 diff --git a/kmk/kmk_keyboard.py b/kmk/kmk_keyboard.py index 0240d7f..a3e34a8 100644 --- a/kmk/kmk_keyboard.py +++ b/kmk/kmk_keyboard.py @@ -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 diff --git a/kmk/modules/combos.py b/kmk/modules/combos.py new file mode 100644 index 0000000..81c52f4 --- /dev/null +++ b/kmk/modules/combos.py @@ -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) diff --git a/tests/test_combos.py b/tests/test_combos.py new file mode 100644 index 0000000..178fa89 --- /dev/null +++ b/tests/test_combos.py @@ -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()