Skip to content

Commit df36e21

Browse files
committed
Refactor sticky/oneshot keys
1 parent e4d41fb commit df36e21

File tree

6 files changed

+686
-3
lines changed

6 files changed

+686
-3
lines changed

docs/en/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ Before you look further, you probably want to start with our [getting started gu
2828
- [HoldTap](holdtap.md): Adds support for augmented modifier keys to act as one key when tapped, and modifier when held.
2929
- [Macros](macros.md): Adds macros.
3030
- [Mouse keys](mouse_keys.md): Adds mouse keycodes
31-
- [OneShot](oneshot.md): Adds support for oneshot/sticky keys.
31+
- [Sticky keys](sticky_keys.md): Adds support for sticky keys.
3232
- [Power](power.md): Power saving features. This is mostly useful when on battery power.
3333
- [SerialACE](serialace.md): [DANGER - _see module README_] Arbitrary Code Execution over the data serial.
3434
- [Split](split_keyboards.md): Keyboards split in two. Seems ergonomic!

docs/en/modules.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ put on your keyboard.
1414
when tapped, and modifier when held.
1515
- [Macros](macros.md): Adds macros.
1616
- [Mouse keys](mouse_keys.md): Adds mouse keycodes.
17-
- [OneShot](oneshot.md): Adds support for oneshot/sticky keys.
17+
- [Sticky keys](sticky_keys.md): Adds support for sticky keys.
1818
- [Power](power.md): Power saving features. This is mostly useful when on battery power.
1919
- [Split](split_keyboards.md): Keyboards split in two. Seems ergonomic!
2020
- [SerialACE](serialace.md): [DANGER - _see module README_] Arbitrary Code Execution over the data serial.

docs/en/sticky_keys.md

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
# Sticky Keys
2+
3+
Sticky keys enable you to have keys that stay pressed for a certain time or
4+
until another key is pressed and released.
5+
If the timeout expires or other keys are pressed, and the sticky key wasn't
6+
released, it is handled as a regular key being held.
7+
8+
## Enable Sticky Keys
9+
10+
```python
11+
from kmk.modules.sticky_keys import StickyKeys
12+
sticky_keys = StickyKeys()
13+
# optional: set a custom release timeout in ms (default: 1000ms)
14+
# sticky_keys = StickyKeys(release_after=5000)
15+
keyboard.modules.append(sticky_keys)
16+
```
17+
18+
## Keycodes
19+
20+
|Keycode | Aliases |Description |
21+
|-----------------|--------------|----------------------------------|
22+
|`KC.SK(KC.ANY)` | `KC.STICKY` |make a sticky version of `KC.ANY` |
23+
24+
`KC.STICKY` accepts any valid key code as argument, including modifiers and KMK
25+
internal keys like momentary layer shifts.
26+
27+
## Custom Sticky Behavior
28+
29+
The full sticky key signature is as follows:
30+
31+
```python
32+
KC.SK(
33+
KC.ANY, # the key to made sticky
34+
defer_release=False # when to release the key
35+
)
36+
```
37+
38+
### `defer_release`
39+
40+
If `False` (default): release sticky key after the first interrupting key
41+
releases.
42+
If `True`: stay sticky until all keys are released. Useful when combined with
43+
non-sticky modifiers, layer keys, etc...
44+
45+
## Sticky Stacks
46+
47+
Sticky keys can be stacked, i.e. tapping a sticky key within the release timeout
48+
of another will reset the timeout off all previously tapped sticky keys and
49+
"stack" their effects.
50+
In this example if you tap `SK_LCTL` and then `SK_LSFT` followed by `KC.TAB`,
51+
the output will be `ctrl+shift+tab`.
52+
53+
```python
54+
SK_LCTL = KC.SK(KC.LCTL)
55+
SK_LSFT = KC.SK(KC.LSFT)
56+
57+
keyboard.keymap = [[SK_LSFT, SK_LCTL, KC.TAB]]
58+
```

docs/en/tapdance.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ KC.SOMETHING_ELSE, MAYBE_THIS_IS_A_MACRO, WHATEVER_YO)`, and place it in your
2424
keymap somewhere. The only limits on how many keys can go in the sequence are,
2525
theoretically, the amount of RAM your MCU/board has.
2626

27-
Tap dance supports all `HoldTap` based keys, like mod tap, layer tap, oneshot...
27+
Tap dance supports all `HoldTap` based keys, like mod tap, layer tap...
2828
it will even honor every option set for those keys.
2929
Individual timeouts and prefer hold behavior for every tap in the sequence?
3030
Not a problem.

kmk/modules/sticky_keys.py

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
from micropython import const
2+
3+
from kmk.keys import make_argumented_key
4+
from kmk.utils import Debug
5+
6+
debug = Debug(__name__)
7+
8+
9+
_SK_IDLE = const(0)
10+
_SK_PRESSED = const(1)
11+
_SK_RELEASED = const(2)
12+
_SK_HOLD = const(3)
13+
_SK_STICKY = const(4)
14+
15+
16+
class StickyKeyMeta:
17+
def __init__(self, key, defer_release=False):
18+
self.key = key
19+
self.defer_release = defer_release
20+
self.timeout = None
21+
self.state = _SK_IDLE
22+
23+
24+
class StickyKeys:
25+
def __init__(self, release_after=1000):
26+
self.active_keys = []
27+
self.release_after = release_after
28+
29+
make_argumented_key(
30+
validator=StickyKeyMeta,
31+
names=('SK', 'STICKY'),
32+
on_press=self.on_press,
33+
on_release=self.on_release,
34+
)
35+
36+
def during_bootup(self, keyboard):
37+
return
38+
39+
def before_matrix_scan(self, keyboard):
40+
return
41+
42+
def after_matrix_scan(self, keyboard):
43+
return
44+
45+
def before_hid_send(self, keyboard):
46+
return
47+
48+
def after_hid_send(self, keyboard):
49+
return
50+
51+
def on_powersave_enable(self, keyboard):
52+
return
53+
54+
def on_powersave_disable(self, keyboard):
55+
return
56+
57+
def process_key(self, keyboard, current_key, is_pressed, int_coord):
58+
delay_current = False
59+
60+
for key in self.active_keys.copy():
61+
# Ignore keys that will resolve to and emit a different key
62+
# eventually, potentially triggering twice.
63+
# Handle interactions among sticky keys (stacking) in `on_press`
64+
# instead of `process_key` to avoid race conditions / causal
65+
# reordering when resetting timeouts.
66+
if (
67+
isinstance(current_key.meta, StickyKeyMeta)
68+
or current_key.meta.__class__.__name__ == 'TapDanceKeyMeta'
69+
or current_key.meta.__class__.__name__ == 'HoldTapKeyMeta'
70+
):
71+
continue
72+
73+
meta = key.meta
74+
75+
if meta.state == _SK_PRESSED and is_pressed:
76+
meta.state = _SK_HOLD
77+
elif meta.state == _SK_RELEASED and is_pressed:
78+
meta.state = _SK_STICKY
79+
elif meta.state == _SK_STICKY:
80+
# Defer sticky release until last other key is released.
81+
if meta.defer_release:
82+
if not is_pressed and len(keyboard._coordkeys_pressed) <= 1:
83+
self.deactivate(keyboard, key)
84+
# Release sticky key; if it's a new key pressed: delay
85+
# propagation until after the sticky release.
86+
else:
87+
self.deactivate(keyboard, key)
88+
delay_current = is_pressed
89+
90+
if delay_current:
91+
keyboard.resume_process_key(self, current_key, is_pressed, int_coord, False)
92+
else:
93+
return current_key
94+
95+
def set_timeout(self, keyboard, key):
96+
key.meta.timeout = keyboard.set_timeout(
97+
self.release_after,
98+
lambda: self.on_release_after(keyboard, key),
99+
)
100+
101+
def on_press(self, key, keyboard, *args, **kwargs):
102+
# Let sticky keys stack by renewing timeouts.
103+
for sk in self.active_keys:
104+
keyboard.cancel_timeout(sk.meta.timeout)
105+
106+
# Reset on repeated taps.
107+
if key.meta.state != _SK_IDLE:
108+
# self.active_keys.remove(key)
109+
key.meta.state = _SK_PRESSED
110+
else:
111+
self.activate(keyboard, key)
112+
113+
for sk in self.active_keys:
114+
self.set_timeout(keyboard, sk)
115+
116+
def on_release(self, key, keyboard, *args, **kwargs):
117+
# No interrupt or timeout happend, mark key as RELEASED, ready to get
118+
# STICKY.
119+
if key.meta.state == _SK_PRESSED:
120+
key.meta.state = _SK_RELEASED
121+
# Key in HOLD state is handled like a regular release.
122+
elif key.meta.state == _SK_HOLD:
123+
for sk in self.active_keys.copy():
124+
keyboard.cancel_timeout(sk.meta.timeout)
125+
self.deactivate(keyboard, sk)
126+
127+
def on_release_after(self, keyboard, key):
128+
# Key is still pressed but nothing else happend: set to HOLD.
129+
if key.meta.state == _SK_PRESSED:
130+
for sk in self.active_keys:
131+
key.meta.state = _SK_HOLD
132+
keyboard.cancel_timeout(sk.meta.timeout)
133+
# Key got released but nothing else happend: deactivate.
134+
elif key.meta.state == _SK_RELEASED:
135+
for sk in self.active_keys.copy():
136+
self.deactivate(keyboard, sk)
137+
138+
def activate(self, keyboard, key):
139+
if debug.enabled:
140+
debug('activate')
141+
key.meta.state = _SK_PRESSED
142+
self.active_keys.insert(0, key)
143+
keyboard.resume_process_key(self, key.meta.key, True)
144+
145+
def deactivate(self, keyboard, key):
146+
if debug.enabled:
147+
debug('deactivate')
148+
key.meta.state = _SK_IDLE
149+
self.active_keys.remove(key)
150+
keyboard.resume_process_key(self, key.meta.key, False)

0 commit comments

Comments
 (0)