From 41693432349b5a021cbc3c89cd32b63edc90bdc4 Mon Sep 17 00:00:00 2001 From: xs5871 Date: Fri, 7 Jun 2024 17:09:58 +0000 Subject: [PATCH] Refactor sticky/oneshot keys --- docs/en/README.md | 2 +- docs/en/modules.md | 2 +- docs/en/sticky_keys.md | 58 +++++ docs/en/tapdance.md | 2 +- kmk/modules/sticky_keys.py | 150 ++++++++++++ tests/test_sticky_keys.py | 475 +++++++++++++++++++++++++++++++++++++ 6 files changed, 686 insertions(+), 3 deletions(-) create mode 100644 docs/en/sticky_keys.md create mode 100644 kmk/modules/sticky_keys.py create mode 100644 tests/test_sticky_keys.py diff --git a/docs/en/README.md b/docs/en/README.md index d422a290a..fc4bb7c2f 100644 --- a/docs/en/README.md +++ b/docs/en/README.md @@ -28,7 +28,7 @@ Before you look further, you probably want to start with our [getting started gu - [HoldTap](holdtap.md): Adds support for augmented modifier keys to act as one key when tapped, and modifier when held. - [Macros](macros.md): Adds macros. - [Mouse keys](mouse_keys.md): Adds mouse keycodes -- [OneShot](oneshot.md): Adds support for oneshot/sticky keys. +- [Sticky keys](sticky_keys.md): Adds support for sticky keys. - [Power](power.md): Power saving features. This is mostly useful when on battery power. - [SerialACE](serialace.md): [DANGER - _see module README_] Arbitrary Code Execution over the data serial. - [Split](split_keyboards.md): Keyboards split in two. Seems ergonomic! diff --git a/docs/en/modules.md b/docs/en/modules.md index fc6631677..e1c731026 100644 --- a/docs/en/modules.md +++ b/docs/en/modules.md @@ -14,7 +14,7 @@ put on your keyboard. when tapped, and modifier when held. - [Macros](macros.md): Adds macros. - [Mouse keys](mouse_keys.md): Adds mouse keycodes. -- [OneShot](oneshot.md): Adds support for oneshot/sticky keys. +- [Sticky keys](sticky_keys.md): Adds support for sticky keys. - [Power](power.md): Power saving features. This is mostly useful when on battery power. - [Split](split_keyboards.md): Keyboards split in two. Seems ergonomic! - [SerialACE](serialace.md): [DANGER - _see module README_] Arbitrary Code Execution over the data serial. diff --git a/docs/en/sticky_keys.md b/docs/en/sticky_keys.md new file mode 100644 index 000000000..304e8dcdc --- /dev/null +++ b/docs/en/sticky_keys.md @@ -0,0 +1,58 @@ +# Sticky Keys + +Sticky keys enable you to have keys that stay pressed for a certain time or +until another key is pressed and released. +If the timeout expires or other keys are pressed, and the sticky key wasn't +released, it is handled as a regular key being held. + +## Enable Sticky Keys + +```python +from kmk.modules.sticky_keys import StickyKeys +sticky_keys = StickyKeys() +# optional: set a custom release timeout in ms (default: 1000ms) +# sticky_keys = StickyKeys(release_after=5000) +keyboard.modules.append(sticky_keys) +``` + +## Keycodes + +|Keycode | Aliases |Description | +|-----------------|--------------|----------------------------------| +|`KC.SK(KC.ANY)` | `KC.STICKY` |make a sticky version of `KC.ANY` | + +`KC.STICKY` accepts any valid key code as argument, including modifiers and KMK +internal keys like momentary layer shifts. + +## Custom Sticky Behavior + +The full sticky key signature is as follows: + +```python +KC.SK( + KC.ANY, # the key to made sticky + defer_release=False # when to release the key +) +``` + +### `defer_release` + +If `False` (default): release sticky key after the first interrupting key +releases. +If `True`: stay sticky until all keys are released. Useful when combined with +non-sticky modifiers, layer keys, etc... + +## Sticky Stacks + +Sticky keys can be stacked, i.e. tapping a sticky key within the release timeout +of another will reset the timeout off all previously tapped sticky keys and +"stack" their effects. +In this example if you tap `SK_LCTL` and then `SK_LSFT` followed by `KC.TAB`, +the output will be `ctrl+shift+tab`. + +```python +SK_LCTL = KC.SK(KC.LCTL) +SK_LSFT = KC.SK(KC.LSFT) + +keyboard.keymap = [[SK_LSFT, SK_LCTL, KC.TAB]] +``` diff --git a/docs/en/tapdance.md b/docs/en/tapdance.md index 953d06ef2..fefbbcad5 100644 --- a/docs/en/tapdance.md +++ b/docs/en/tapdance.md @@ -24,7 +24,7 @@ KC.SOMETHING_ELSE, MAYBE_THIS_IS_A_MACRO, WHATEVER_YO)`, and place it in your keymap somewhere. The only limits on how many keys can go in the sequence are, theoretically, the amount of RAM your MCU/board has. -Tap dance supports all `HoldTap` based keys, like mod tap, layer tap, oneshot... +Tap dance supports all `HoldTap` based keys, like mod tap, layer tap... it will even honor every option set for those keys. Individual timeouts and prefer hold behavior for every tap in the sequence? Not a problem. diff --git a/kmk/modules/sticky_keys.py b/kmk/modules/sticky_keys.py new file mode 100644 index 000000000..e2105975c --- /dev/null +++ b/kmk/modules/sticky_keys.py @@ -0,0 +1,150 @@ +from micropython import const + +from kmk.keys import make_argumented_key +from kmk.utils import Debug + +debug = Debug(__name__) + + +_SK_IDLE = const(0) +_SK_PRESSED = const(1) +_SK_RELEASED = const(2) +_SK_HOLD = const(3) +_SK_STICKY = const(4) + + +class StickyKeyMeta: + def __init__(self, key, defer_release=False): + self.key = key + self.defer_release = defer_release + self.timeout = None + self.state = _SK_IDLE + + +class StickyKeys: + def __init__(self, release_after=1000): + self.active_keys = [] + self.release_after = release_after + + make_argumented_key( + validator=StickyKeyMeta, + names=('SK', 'STICKY'), + on_press=self.on_press, + on_release=self.on_release, + ) + + def during_bootup(self, keyboard): + return + + 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, current_key, is_pressed, int_coord): + delay_current = False + + for key in self.active_keys.copy(): + # Ignore keys that will resolve to and emit a different key + # eventually, potentially triggering twice. + # Handle interactions among sticky keys (stacking) in `on_press` + # instead of `process_key` to avoid race conditions / causal + # reordering when resetting timeouts. + if ( + isinstance(current_key.meta, StickyKeyMeta) + or current_key.meta.__class__.__name__ == 'TapDanceKeyMeta' + or current_key.meta.__class__.__name__ == 'HoldTapKeyMeta' + ): + continue + + meta = key.meta + + if meta.state == _SK_PRESSED and is_pressed: + meta.state = _SK_HOLD + elif meta.state == _SK_RELEASED and is_pressed: + meta.state = _SK_STICKY + elif meta.state == _SK_STICKY: + # Defer sticky release until last other key is released. + if meta.defer_release: + if not is_pressed and len(keyboard._coordkeys_pressed) <= 1: + self.deactivate(keyboard, key) + # Release sticky key; if it's a new key pressed: delay + # propagation until after the sticky release. + else: + self.deactivate(keyboard, key) + delay_current = is_pressed + + if delay_current: + keyboard.resume_process_key(self, current_key, is_pressed, int_coord, False) + else: + return current_key + + def set_timeout(self, keyboard, key): + key.meta.timeout = keyboard.set_timeout( + self.release_after, + lambda: self.on_release_after(keyboard, key), + ) + + def on_press(self, key, keyboard, *args, **kwargs): + # Let sticky keys stack by renewing timeouts. + for sk in self.active_keys: + keyboard.cancel_timeout(sk.meta.timeout) + + # Reset on repeated taps. + if key.meta.state != _SK_IDLE: + # self.active_keys.remove(key) + key.meta.state = _SK_PRESSED + else: + self.activate(keyboard, key) + + for sk in self.active_keys: + self.set_timeout(keyboard, sk) + + def on_release(self, key, keyboard, *args, **kwargs): + # No interrupt or timeout happend, mark key as RELEASED, ready to get + # STICKY. + if key.meta.state == _SK_PRESSED: + key.meta.state = _SK_RELEASED + # Key in HOLD state is handled like a regular release. + elif key.meta.state == _SK_HOLD: + for sk in self.active_keys.copy(): + keyboard.cancel_timeout(sk.meta.timeout) + self.deactivate(keyboard, sk) + + def on_release_after(self, keyboard, key): + # Key is still pressed but nothing else happend: set to HOLD. + if key.meta.state == _SK_PRESSED: + for sk in self.active_keys: + key.meta.state = _SK_HOLD + keyboard.cancel_timeout(sk.meta.timeout) + # Key got released but nothing else happend: deactivate. + elif key.meta.state == _SK_RELEASED: + for sk in self.active_keys.copy(): + self.deactivate(keyboard, sk) + + def activate(self, keyboard, key): + if debug.enabled: + debug('activate') + key.meta.state = _SK_PRESSED + self.active_keys.insert(0, key) + keyboard.resume_process_key(self, key.meta.key, True) + + def deactivate(self, keyboard, key): + if debug.enabled: + debug('deactivate') + key.meta.state = _SK_IDLE + self.active_keys.remove(key) + keyboard.resume_process_key(self, key.meta.key, False) diff --git a/tests/test_sticky_keys.py b/tests/test_sticky_keys.py new file mode 100644 index 000000000..f2e3dd9f3 --- /dev/null +++ b/tests/test_sticky_keys.py @@ -0,0 +1,475 @@ +import unittest + +from kmk.keys import KC +from kmk.modules.holdtap import HoldTap +from kmk.modules.layers import Layers +from kmk.modules.sticky_keys import StickyKeys +from kmk.modules.tapdance import TapDance +from tests.keyboard_test import KeyboardTest + +t_within = 2 * KeyboardTest.loop_delay_ms +t_holdtap = 4 * KeyboardTest.loop_delay_ms +t_sticky = 6 * KeyboardTest.loop_delay_ms +t_after = 11 * KeyboardTest.loop_delay_ms + + +class TestStickyKey(unittest.TestCase): + @classmethod + def setUpClass(cls): + sticky_keys = StickyKeys(release_after=t_sticky) + + tapdance = TapDance() + tapdance.tap_time = t_holdtap + + holdtap = HoldTap() + holdtap.tap_time = t_holdtap + + cls.keyboard = KeyboardTest( + [Layers(), holdtap, tapdance, sticky_keys], + [ + [ + KC.SK(KC.N0), + KC.SK(KC.N1), + KC.N2, + KC.N3, + ], + [ + KC.SK(KC.MO(4)), + KC.MO(4), + KC.N2, + KC.SK(KC.N3), + ], + [ + KC.SK(KC.N0, defer_release=True), + KC.SK(KC.N1, defer_release=True), + KC.N2, + KC.N3, + ], + [ + KC.TD( + KC.SK(KC.N0), + KC.SK(KC.A), + ), + KC.HT(KC.X, KC.Y), + KC.N2, + KC.N3, + ], + [ + KC.SK(KC.A), + KC.B, + KC.C, + KC.D, + ], + ], + debug_enabled=False, + ) + + def test_sticky_key(self): + self.keyboard.keyboard.active_layers = [0] + keyboard = self.keyboard + + keyboard.test( + 'no stick, release after timeout', + [(0, True), (0, False), t_after, (2, True), (2, False)], + [{KC.N0}, {}, {KC.N2}, {}], + ) + + keyboard.test( + 'hold', + [(0, True), t_after, (0, False)], + [{KC.N0}, {}], + ) + + keyboard.test( + 'double tap', + [(0, True), (0, False), (0, True), (0, False)], + [{KC.N0}, {}], + ) + + keyboard.test( + 'stick within timeout', + [(0, True), (0, False), t_within, (2, True), (2, False)], + [{KC.N0}, {KC.N0, KC.N2}, {KC.N0}, {}], + ) + + keyboard.test( + 'stick, release other after timeout', + [(0, True), (0, False), (2, True), t_after, (2, False)], + [{KC.N0}, {KC.N0, KC.N2}, {KC.N0}, {}], + ) + + keyboard.test( + 'stick, multiple consecutive other', + [(0, True), (0, False), (2, True), (2, False), (3, True), (3, False)], + [{KC.N0}, {KC.N0, KC.N2}, {KC.N0}, {}, {KC.N3}, {}], + ) + + keyboard.test( + 'stick, multiple nested other', + [(0, True), (0, False), (2, True), (3, True), (3, False), (2, False)], + [{KC.N0}, {KC.N0, KC.N2}, {KC.N2}, {KC.N2, KC.N3}, {KC.N2}, {}], + ) + + keyboard.test( + 'stick, multiple interleaved other', + [(0, True), (0, False), (2, True), (3, True), (2, False), (3, False)], + [{KC.N0}, {KC.N0, KC.N2}, {KC.N2}, {KC.N2, KC.N3}, {KC.N3}, {}], + ) + + keyboard.test( + 'stick w prev active, release prev after SK', + [(3, True), (0, True), (0, False), (3, False), (2, True), (2, False)], + [{KC.N3}, {KC.N0, KC.N3}, {KC.N0}, {KC.N0, KC.N2}, {KC.N0}, {}], + ) + + keyboard.test( + 'stick w prev active, release prev during SK', + [(3, True), (0, True), (3, False), (0, False), (2, True), (2, False)], + [{KC.N3}, {KC.N0, KC.N3}, {KC.N0}, {KC.N0, KC.N2}, {KC.N0}, {}], + ) + + keyboard.test( + 'stick w prev active, release prev after other press', + [(3, True), (0, True), (0, False), (2, True), (3, False), (2, False)], + [ + {KC.N3}, + {KC.N0, KC.N3}, + {KC.N0, KC.N2, KC.N3}, + {KC.N0, KC.N2}, + {KC.N2}, + {}, + ], + ) + + keyboard.test( + 'hold after timeout, nested other', + [(0, True), t_after, (2, True), (2, False), (0, False)], + [{KC.N0}, {KC.N0, KC.N2}, {KC.N0}, {}], + ) + + keyboard.test( + 'hold after timeout, interleaved other', + [(0, True), t_after, (2, True), (0, False), (2, False)], + [{KC.N0}, {KC.N0, KC.N2}, {KC.N2}, {}], + ) + + keyboard.test( + 'hold within timeout, interleaved other', + [(0, True), (2, True), (0, False), t_after, (2, False)], + [{KC.N0}, {KC.N0, KC.N2}, {KC.N2}, {}], + ) + + keyboard.test( + 'hold within timeout, nested other', + [(0, True), (2, True), (2, False), t_after, (0, False)], + [{KC.N0}, {KC.N0, KC.N2}, {KC.N0}, {}], + ) + + keyboard.test( + 'hold with multiple interrupt keys', + [(0, True), (2, True), (2, False), (3, True), (3, False), (0, False)], + [{KC.N0}, {KC.N0, KC.N2}, {KC.N0}, {KC.N0, KC.N3}, {KC.N0}, {}], + ) + + def test_sticky_key_stack(self): + self.keyboard.keyboard.active_layers = [0] + keyboard = self.keyboard + + keyboard.test( + 'no stack after timeout', + [ + (0, True), + (0, False), + t_after, + (1, True), + (1, False), + ], + [{KC.N0}, {}, {KC.N1}, {}], + ) + + keyboard.test( + 'stack release after timeout', + [(0, True), (0, False), t_within, (1, True), (1, False)], + [{KC.N0}, {KC.N0, KC.N1}, {KC.N0}, {}], + ) + + keyboard.test( + 'stack within timeout, reset first SK', + [ + (0, True), + (0, False), + t_within, + (1, True), + (1, False), + t_within, + (2, True), + (2, False), + ], + [ + {KC.N0}, + {KC.N0, KC.N1}, + {KC.N0, KC.N1, KC.N2}, + {KC.N0, KC.N1}, + {KC.N0}, + {}, + ], + ) + + keyboard.test( + 'stack, after timeout', + [ + (0, True), + (0, False), + (1, True), + (1, False), + t_after, + (2, True), + (2, False), + ], + [{KC.N0}, {KC.N0, KC.N1}, {KC.N0}, {}, {KC.N2}, {}], + ) + + def test_sticky_layer(self): + keyboard = self.keyboard + self.keyboard.keyboard.active_layers = [1] + + keyboard.test( + 'sticky layer', + [(0, True), (0, False), (1, True), (1, False), (2, True), (2, False)], + [{KC.B}, {}, {KC.N2}, {}], + ) + + keyboard.test( + 'hold layer', + [(0, True), (1, True), (1, False), (0, False), (2, True), (2, False)], + [{KC.B}, {}, {KC.N2}, {}], + ) + + keyboard.test( + 'stick from other layer', + [(1, True), (0, True), (0, False), (1, False), (2, True), (2, False)], + [{KC.A}, {KC.A, KC.N2}, {KC.A}, {}], + ) + + keyboard.test( + 'stack with SK on other layer', + [(0, True), (0, False), (0, True), (0, False), (1, True), (1, False)], + [{KC.A}, {KC.A, KC.B}, {KC.A}, {}], + ) + + keyboard.test( + 'stack with layer change', + [ + (1, True), + (0, True), + (0, False), + (1, False), + (3, True), + (3, False), + (2, True), + (2, False), + ], + [ + {KC.A}, + {KC.A, KC.N3}, + {KC.A, KC.N2, KC.N3}, + {KC.A, KC.N3}, + {KC.A}, + {}, + ], + ) + + def test_sticky_key_deferred(self): + self.keyboard.keyboard.active_layers = [2] + keyboard = self.keyboard + + keyboard.test( + 'release after timeout', + [(0, True), (0, False), t_after], + [{KC.N0}, {}], + ) + + keyboard.test( + 'stick within timeout', + [(0, True), (0, False), (2, True), (2, False)], + [{KC.N0}, {KC.N0, KC.N2}, {KC.N0}, {}], + ) + + keyboard.test( + 'hold, release interleaved', + [(0, True), (2, True), (0, False), (2, False)], + [{KC.N0}, {KC.N0, KC.N2}, {KC.N2}, {}], + ) + + keyboard.test( + 'stick, multiple consecutive other', + [(0, True), (0, False), (2, True), (2, False), (3, True), (3, False)], + [{KC.N0}, {KC.N0, KC.N2}, {KC.N0}, {}, {KC.N3}, {}], + ) + + keyboard.test( + 'stick, multiple nested other', + [(0, True), (0, False), (2, True), (3, True), (3, False), (2, False)], + [ + {KC.N0}, + {KC.N0, KC.N2}, + {KC.N0, KC.N2, KC.N3}, + {KC.N0, KC.N2}, + {KC.N0}, + {}, + ], + ) + + keyboard.test( + 'stick, multiple interleaved other', + [(0, True), (0, False), (2, True), (3, True), (2, False), (3, False)], + [ + {KC.N0}, + {KC.N0, KC.N2}, + {KC.N0, KC.N2, KC.N3}, + {KC.N0, KC.N3}, + {KC.N0}, + {}, + ], + ) + + keyboard.test( + 'stick, multiple interleaved other after timeout', + [ + (0, True), + (0, False), + (2, True), + t_after, + (3, True), + (2, False), + (3, False), + ], + [ + {KC.N0}, + {KC.N0, KC.N2}, + {KC.N0, KC.N2, KC.N3}, + {KC.N0, KC.N3}, + {KC.N0}, + {}, + ], + ) + + keyboard.test( + 'stick stack, multiple interleaved other', + [ + (0, True), + (0, False), + (1, True), + (1, False), + (2, True), + (3, True), + (2, False), + (3, False), + ], + [ + {KC.N0}, + {KC.N0, KC.N1}, + {KC.N0, KC.N1, KC.N2}, + {KC.N0, KC.N1, KC.N2, KC.N3}, + {KC.N0, KC.N1, KC.N3}, + {KC.N0, KC.N1}, + {KC.N0}, + {}, + ], + ) + + keyboard.test( + 'hold stack, interleave hold release', + [ + (0, True), + (0, False), + (1, True), + (2, True), + (3, True), + (1, False), + (2, False), + (3, False), + ], + [ + {KC.N0}, + {KC.N0, KC.N1}, + {KC.N0, KC.N1, KC.N2}, + {KC.N0, KC.N1, KC.N2, KC.N3}, + {KC.N0, KC.N2, KC.N3}, + {KC.N2, KC.N3}, + {KC.N3}, + {}, + ], + ) + + def test_sticky_key_in_tapdance(self): + self.keyboard.keyboard.active_layers = [3] + keyboard = self.keyboard + + keyboard.test( + 'tap 1x, no stick', + [(0, True), (0, False), t_after, (2, True), (2, False)], + [{KC.N0}, {}, {KC.N2}, {}], + ) + + keyboard.test( + 'tap 1x, stick', + [(0, True), (0, False), t_within, (2, True), (2, False)], + [{KC.N0}, {KC.N0, KC.N2}, {KC.N0}, {}], + ) + + keyboard.test( + 'hold 1x, stick', + [(0, True), t_holdtap, (0, False), (2, True), (2, False)], + [{KC.N0}, {KC.N0, KC.N2}, {KC.N0}, {}], + ) + + keyboard.test( + 'hold 1x after timeout', + [(0, True), t_after, (0, False), (2, True), (2, False)], + [{KC.N0}, {}, {KC.N2}, {}], + ) + + keyboard.test( + 'hold 1x, interleaved', + [(0, True), (2, True), (0, False), (2, False)], + [{KC.N0}, {KC.N0, KC.N2}, {KC.N2}, {}], + ) + + keyboard.test( + 'tap 2x, stick', + [(0, True), (0, False), (0, True), (0, False), (2, True), (2, False)], + [{KC.A}, {KC.A, KC.N2}, {KC.A}, {}], + ) + + keyboard.test( + 'stick stack from same tapdance', + [ + (0, True), + (0, False), + t_holdtap, + (0, True), + (0, False), + (0, True), + (0, False), + (2, True), + (2, False), + ], + [{KC.N0}, {KC.N0, KC.A}, {KC.N0, KC.A, KC.N2}, {KC.N0, KC.A}, {KC.N0}, {}], + ) + + def test_sticky_key_w_holdtap(self): + self.keyboard.keyboard.active_layers = [3] + keyboard = self.keyboard + + keyboard.test( + 'stick, tap', + [(0, True), (0, False), (1, True), (1, False)], + [{KC.N0}, {KC.N0, KC.X}, {KC.N0}, {}], + ) + + keyboard.test( + 'stick, hold', + [(0, True), (0, False), (1, True), t_after, (1, False)], + [{KC.N0}, {KC.N0, KC.Y}, {KC.N0}, {}], + )