Skip to content

Commit

Permalink
Refactor sticky/oneshot keys
Browse files Browse the repository at this point in the history
  • Loading branch information
xs5871 committed Jun 7, 2024
1 parent e4d41fb commit 084e27e
Show file tree
Hide file tree
Showing 6 changed files with 686 additions and 3 deletions.
2 changes: 1 addition & 1 deletion docs/en/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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!
Expand Down
2 changes: 1 addition & 1 deletion docs/en/modules.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
58 changes: 58 additions & 0 deletions docs/en/sticky_keys.md
Original file line number Diff line number Diff line change
@@ -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]]
```
2 changes: 1 addition & 1 deletion docs/en/tapdance.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
149 changes: 149 additions & 0 deletions kmk/modules/sticky_keys.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
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:
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)
Loading

0 comments on commit 084e27e

Please sign in to comment.