kmk_firmware/kmk/modules/combos.py

336 lines
11 KiB
Python

try:
from typing import Optional, Tuple, Union
except ImportError:
pass
from micropython import const
import kmk.handlers.stock as handlers
from kmk.keys import Key, make_key
from kmk.kmk_keyboard import KMKKeyboard
from kmk.modules import Module
from kmk.utils import Debug
debug = Debug(__name__)
class _ComboState:
RESET = const(0)
MATCHING = const(1)
ACTIVE = const(2)
IDLE = const(3)
class Combo:
fast_reset = False
per_key_timeout = False
timeout = 50
_remaining = []
_timeout = None
_state = _ComboState.IDLE
_match_coord = False
def __init__(
self,
match: Tuple[Union[Key, int], ...],
result: Key,
fast_reset=None,
per_key_timeout=None,
timeout=None,
match_coord=None,
):
'''
match: tuple of keys (KC.A, KC.B)
result: key KC.C
'''
self.match = match
self.result = result
if fast_reset is not None:
self.fast_reset = fast_reset
if per_key_timeout is not None:
self.per_key_timeout = per_key_timeout
if timeout is not None:
self.timeout = timeout
if match_coord is not None:
self._match_coord = match_coord
def __repr__(self):
return f'{self.__class__.__name__}({[k.code for k in self.match]})'
def matches(self, key: Key, int_coord: int):
raise NotImplementedError
def has_match(self, key: Key, int_coord: int):
return self._match_coord and int_coord in self.match or key in self.match
def insert(self, key: Key, int_coord: int):
if self._match_coord:
self._remaining.insert(0, int_coord)
else:
self._remaining.insert(0, key)
def reset(self):
self._remaining = list(self.match)
class Chord(Combo):
def matches(self, key: Key, int_coord: int):
if not self._match_coord and key in self._remaining:
self._remaining.remove(key)
return True
elif self._match_coord and int_coord in self._remaining:
self._remaining.remove(int_coord)
return True
else:
return False
class Sequence(Combo):
fast_reset = True
per_key_timeout = True
timeout = 1000
def matches(self, key: Key, int_coord: int):
if (
not self._match_coord and self._remaining and self._remaining[0] == key
) or (
self._match_coord and self._remaining and self._remaining[0] == int_coord
):
self._remaining.pop(0)
return True
else:
return False
class Combos(Module):
def __init__(self, combos=[]):
self.combos = combos
self._key_buffer = []
make_key(
names=('LEADER', 'LDR'),
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: Key, is_pressed, int_coord):
if is_pressed:
return self.on_press(keyboard, key, int_coord)
else:
return self.on_release(keyboard, key, int_coord)
def on_press(self, keyboard: KMKKeyboard, key: Key, int_coord: Optional[int]):
# refill potential matches from timed-out matches
if self.count_matching() == 0:
for combo in self.combos:
if combo._state == _ComboState.RESET:
combo._state = _ComboState.MATCHING
# filter potential matches
for combo in self.combos:
if combo._state != _ComboState.MATCHING:
continue
if combo.matches(key, int_coord):
continue
combo._state = _ComboState.IDLE
if combo._timeout:
keyboard.cancel_timeout(combo._timeout)
combo._timeout = keyboard.set_timeout(
combo.timeout, lambda c=combo: self.reset_combo(keyboard, c)
)
match_count = self.count_matching()
if match_count:
# At least one combo matches current key: append key to buffer.
self._key_buffer.append((int_coord, key, True))
key = None
for first_match in self.combos:
if first_match._state == _ComboState.MATCHING:
break
# Single match left: don't wait on timeout to activate
if match_count == 1 and not any(first_match._remaining):
combo = first_match
self.activate(keyboard, combo)
if combo._timeout:
keyboard.cancel_timeout(combo._timeout)
combo._timeout = None
self._key_buffer = []
self.reset(keyboard)
# Start or reset individual combo timeouts.
for combo in self.combos:
if combo._state != _ComboState.MATCHING:
continue
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
if self._key_buffer:
self._key_buffer.append((int_coord, key, True))
self.send_key_buffer(keyboard)
self._key_buffer = []
key = None
return key
def on_release(self, keyboard: KMKKeyboard, key: Key, int_coord: Optional[int]):
for combo in self.combos:
if combo._state != _ComboState.ACTIVE:
continue
if combo.has_match(key, int_coord):
# Deactivate combo if it matches current key.
self.deactivate(keyboard, combo)
if combo.fast_reset:
self.reset_combo(keyboard, combo)
self._key_buffer = []
else:
combo.insert(key, int_coord)
combo._state = _ComboState.MATCHING
key = None
break
else:
# Non-active but matching combos can either activate on key release
# if they're the only match, or "un-match" the released key but stay
# matching if they're a repeatable combo.
for combo in self.combos:
if combo._state != _ComboState.MATCHING:
continue
if not combo.has_match(key, int_coord):
continue
# Combo matches, but first key released before timeout.
elif not any(combo._remaining) and self.count_matching() == 1:
keyboard.cancel_timeout(combo._timeout)
self.activate(keyboard, combo)
self._key_buffer = []
keyboard._send_hid()
self.deactivate(keyboard, combo)
if combo.fast_reset:
self.reset_combo(keyboard, combo)
else:
combo.insert(key, int_coord)
combo._state = _ComboState.MATCHING
self.reset(keyboard)
elif not any(combo._remaining):
continue
# Skip combos that allow tapping.
elif combo.fast_reset:
continue
# This was the last key released of a repeatable combo.
elif len(combo._remaining) == len(combo.match) - 1:
self.reset_combo(keyboard, combo)
if not self.count_matching():
self._key_buffer.append((int_coord, key, False))
self.send_key_buffer(keyboard)
self._key_buffer = []
key = None
# Anything between first and last key released.
else:
combo.insert(key, int_coord)
# Don't propagate key-release events for keys that have been
# buffered. Append release events only if corresponding press is in
# buffer.
pressed = self._key_buffer.count((int_coord, key, True))
released = self._key_buffer.count((int_coord, key, False))
if (pressed - released) > 0:
self._key_buffer.append((int_coord, key, False))
key = None
# Reset on non-combo key up
if not self.count_matching():
self.reset(keyboard)
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
if not any(combo._remaining):
self.activate(keyboard, combo)
# check if the last buffered key event was a 'release'
if not self._key_buffer[-1][2]:
keyboard._send_hid()
self.deactivate(keyboard, combo)
self._key_buffer = []
self.reset(keyboard)
else:
if self.count_matching() == 1:
# 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 (int_coord, key, is_pressed) in self._key_buffer:
keyboard.resume_process_key(self, key, is_pressed, int_coord)
def activate(self, keyboard, combo):
if debug.enabled:
debug('activate', combo)
combo.result.on_press(keyboard)
combo._state = _ComboState.ACTIVE
def deactivate(self, keyboard, combo):
if debug.enabled:
debug('deactivate', combo)
combo.result.on_release(keyboard)
combo._state = _ComboState.IDLE
def reset_combo(self, keyboard, combo):
combo.reset()
if combo._timeout is not None:
keyboard.cancel_timeout(combo._timeout)
combo._timeout = None
combo._state = _ComboState.RESET
def reset(self, keyboard):
for combo in self.combos:
if combo._state != _ComboState.ACTIVE:
self.reset_combo(keyboard, combo)
def count_matching(self):
match_count = 0
for combo in self.combos:
if combo._state == _ComboState.MATCHING:
match_count += 1
return match_count