* feat(shortcuts): configurable modifier+key hotkeys (e.g. Alt+R) Generalize the shortcut system beyond single-modifier double-tap/hold to support modifier+key combos (alt+r, ctrl+alt+r, super+space) in both toggle (press once) and push-to-talk (hold) modes. - base.py: ShortcutSpec + parse_shortcut_spec/is_valid_shortcut/format label; parse_shortcut kept for back-compat (bare modifiers still rejected). - evdev + pynput backends: combo detection with global (cross-device) modifier tracking so split keyboards work; legacy gestures unchanged. - keyboard_shortcuts: validate via is_valid_shortcut instead of a fixed allowlist. - settings_dialog: additive Custom Shortcut row (typed entry + live Record capture). - tests: 34 new tests (parser, back-compat, evdev + pynput combo, split keyboard). * fix(shortcuts): apply saved shortcut (not stale preset) on mode change When a custom shortcut (e.g. alt+r) is active, the preset combo still points at a default/previous preset. _on_shortcut_mode_changed read the combo, so toggling Toggle<->Push-to-Talk restarted the live listener on the wrong shortcut while config still said alt+r. Read the saved shortcut from config instead. Addresses Codex P2 review on PR #493. * style: apply black formatting to configurable-hotkeys files * test: cover _gdk_keyname_to_token shortcut-capture mapping * fix(shortcuts): reset combo state after dropped/lost evdev events SYN_DROPPED and device-disconnect recovery cleared key_pressed_devices but not the combo state. A dropped modifier *release* left _combo_pressed thinking the modifier was held, so the main key alone could falsely toggle/start dictation, and a push-to-talk hold could stay stuck. Add _reset_combo_state() (clears the pressed set and ends any live hold) and call it from both recovery paths. Addresses maintainer review on PR #493. * fix(settings): combo-aware toggle label (single press, not double-tap) _update_shortcut_ui_for_mode always said 'Double-tap the key' for toggle mode, but a modifier+key combo (e.g. alt+r) toggles on a single press. Read the saved shortcut and use get_shortcut_display_name so a combo shows 'Press Alt+R'. Addresses maintainer review on PR #493. --------- Co-authored-by: Jatin K Malik <jatinkrmalik@gmail.com>
336 lines
13 KiB
Python
336 lines
13 KiB
Python
"""Tests for configurable modifier+key hotkeys (e.g. Alt+R).
|
|
|
|
Covers the generalized parser/validator in base.py plus combo detection in the
|
|
evdev and pynput backends. Legacy pure-modifier gestures must keep working.
|
|
"""
|
|
|
|
import threading
|
|
import types
|
|
|
|
import pytest
|
|
|
|
from vocalinux.ui.keyboard_backends.base import (
|
|
ShortcutSpec,
|
|
format_shortcut_label,
|
|
get_shortcut_display_name,
|
|
is_valid_shortcut,
|
|
parse_shortcut,
|
|
parse_shortcut_spec,
|
|
)
|
|
|
|
|
|
class TestParseShortcutSpec:
|
|
def test_legacy_pure_modifier(self):
|
|
spec = parse_shortcut_spec("ctrl+ctrl")
|
|
assert spec.modifiers == ("ctrl",)
|
|
assert spec.key is None
|
|
assert spec.is_combo is False
|
|
assert spec.primary_modifier == "ctrl"
|
|
|
|
def test_legacy_side_specific(self):
|
|
spec = parse_shortcut_spec("left_shift+left_shift")
|
|
assert spec.modifiers == ("left_shift",)
|
|
assert spec.is_combo is False
|
|
|
|
def test_simple_combo(self):
|
|
spec = parse_shortcut_spec("alt+r")
|
|
assert spec.modifiers == ("alt",)
|
|
assert spec.key == "r"
|
|
assert spec.is_combo is True
|
|
|
|
def test_multi_modifier_combo(self):
|
|
spec = parse_shortcut_spec("ctrl+alt+r")
|
|
assert spec.modifiers == ("ctrl", "alt")
|
|
assert spec.key == "r"
|
|
|
|
def test_function_key_combo(self):
|
|
assert parse_shortcut_spec("alt+f5").key == "f5"
|
|
|
|
def test_named_key_combo(self):
|
|
assert parse_shortcut_spec("super+space").key == "space"
|
|
|
|
def test_case_insensitive_and_whitespace(self):
|
|
spec = parse_shortcut_spec(" ALT + R ")
|
|
assert spec.modifiers == ("alt",)
|
|
assert spec.key == "r"
|
|
|
|
@pytest.mark.parametrize(
|
|
"bad",
|
|
["", " ", "ctrl", "ctrl+alt", "alt+r+t", "invalid_shortcut", "alt+", "+r"],
|
|
)
|
|
def test_invalid(self, bad):
|
|
with pytest.raises(ValueError):
|
|
parse_shortcut_spec(bad)
|
|
|
|
def test_canonical_round_trips(self):
|
|
for s in ["ctrl+ctrl", "alt+r", "ctrl+alt+r", "super+space", "alt+f5"]:
|
|
assert parse_shortcut_spec(parse_shortcut_spec(s).canonical()).canonical() == (
|
|
parse_shortcut_spec(s).canonical()
|
|
)
|
|
|
|
|
|
class TestBackwardCompatibility:
|
|
def test_parse_shortcut_legacy_unchanged(self):
|
|
assert parse_shortcut("ctrl+ctrl") == "ctrl"
|
|
assert parse_shortcut("right_alt+right_alt") == "right_alt"
|
|
|
|
def test_parse_shortcut_combo_returns_primary_modifier(self):
|
|
assert parse_shortcut("alt+r") == "alt"
|
|
assert parse_shortcut("ctrl+alt+r") == "ctrl"
|
|
|
|
def test_parse_shortcut_invalid_raises_with_message(self):
|
|
with pytest.raises(ValueError) as e:
|
|
parse_shortcut("bogus")
|
|
assert "Unsupported shortcut" in str(e.value)
|
|
|
|
def test_parse_shortcut_bare_modifier_still_invalid(self):
|
|
with pytest.raises(ValueError):
|
|
parse_shortcut("ctrl")
|
|
|
|
def test_legacy_display_names_unchanged(self):
|
|
assert get_shortcut_display_name("ctrl+ctrl") == "Ctrl (either side)"
|
|
assert get_shortcut_display_name("left_shift+left_shift") == "Left Shift"
|
|
assert get_shortcut_display_name("alt+alt", "toggle") == "Double-tap Alt"
|
|
assert get_shortcut_display_name("alt+alt", "push_to_talk") == "Hold Alt"
|
|
|
|
|
|
class TestValidationAndLabels:
|
|
def test_is_valid_shortcut(self):
|
|
assert is_valid_shortcut("alt+r") is True
|
|
assert is_valid_shortcut("ctrl+ctrl") is True
|
|
assert is_valid_shortcut("ctrl") is False
|
|
assert is_valid_shortcut("") is False
|
|
assert is_valid_shortcut("nope") is False
|
|
|
|
def test_combo_labels(self):
|
|
assert format_shortcut_label(parse_shortcut_spec("alt+r")) == "Alt+R"
|
|
assert format_shortcut_label(parse_shortcut_spec("ctrl+alt+r")) == "Ctrl+Alt+R"
|
|
assert format_shortcut_label(parse_shortcut_spec("super+space")) == "Super+Space"
|
|
|
|
def test_combo_display_names_by_mode(self):
|
|
assert get_shortcut_display_name("alt+r", "toggle") == "Press Alt+R"
|
|
assert get_shortcut_display_name("alt+r", "push_to_talk") == "Hold Alt+R"
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# evdev combo detection
|
|
# --------------------------------------------------------------------------
|
|
evdev_backend = pytest.importorskip("vocalinux.ui.keyboard_backends.evdev_backend")
|
|
|
|
|
|
@pytest.mark.skipif(not evdev_backend.EVDEV_AVAILABLE, reason="evdev not available")
|
|
class TestEvdevComboDetection:
|
|
def _event(self, code, value):
|
|
return types.SimpleNamespace(code=code, value=value, type=1)
|
|
|
|
def _wait(self, flag, timeout=1.0):
|
|
return flag.wait(timeout)
|
|
|
|
def _codes(self):
|
|
from vocalinux.ui.keyboard_backends.evdev_backend import (
|
|
KEY_LEFTALT,
|
|
KEY_RIGHTALT,
|
|
evdev_code_for_key,
|
|
)
|
|
|
|
return KEY_LEFTALT, KEY_RIGHTALT, evdev_code_for_key("r")
|
|
|
|
def test_toggle_fires_on_combo_press(self):
|
|
alt_l, _, key_r = self._codes()
|
|
backend = evdev_backend.EvdevKeyboardBackend(shortcut="alt+r", mode="toggle")
|
|
fired = threading.Event()
|
|
backend.register_toggle_callback(fired.set)
|
|
|
|
# Alt down, then R down -> toggle fires.
|
|
backend._handle_key_event(self._event(alt_l, 1), None)
|
|
backend._handle_key_event(self._event(key_r, 1), None)
|
|
assert self._wait(fired)
|
|
|
|
def test_toggle_does_not_fire_without_modifier(self):
|
|
_, _, key_r = self._codes()
|
|
backend = evdev_backend.EvdevKeyboardBackend(shortcut="alt+r", mode="toggle")
|
|
fired = threading.Event()
|
|
backend.register_toggle_callback(fired.set)
|
|
|
|
# R alone (no Alt held) must not trigger.
|
|
backend._handle_key_event(self._event(key_r, 1), None)
|
|
assert not self._wait(fired, timeout=0.2)
|
|
|
|
def test_push_to_talk_press_and_release(self):
|
|
alt_l, _, key_r = self._codes()
|
|
backend = evdev_backend.EvdevKeyboardBackend(shortcut="alt+r", mode="push_to_talk")
|
|
pressed = threading.Event()
|
|
released = threading.Event()
|
|
backend.register_press_callback(pressed.set)
|
|
backend.register_release_callback(released.set)
|
|
|
|
backend._handle_key_event(self._event(alt_l, 1), None)
|
|
backend._handle_key_event(self._event(key_r, 1), None)
|
|
assert self._wait(pressed)
|
|
backend._handle_key_event(self._event(key_r, 0), None)
|
|
assert self._wait(released)
|
|
|
|
def test_ptt_release_when_modifier_released_first(self):
|
|
alt_l, _, key_r = self._codes()
|
|
backend = evdev_backend.EvdevKeyboardBackend(shortcut="alt+r", mode="push_to_talk")
|
|
pressed = threading.Event()
|
|
released = threading.Event()
|
|
backend.register_press_callback(pressed.set)
|
|
backend.register_release_callback(released.set)
|
|
|
|
backend._handle_key_event(self._event(alt_l, 1), None)
|
|
backend._handle_key_event(self._event(key_r, 1), None)
|
|
assert self._wait(pressed)
|
|
# Release Alt while R still held -> hold ends.
|
|
backend._handle_key_event(self._event(alt_l, 0), None)
|
|
assert self._wait(released)
|
|
|
|
def test_split_keyboard_either_alt_variant(self):
|
|
_, alt_r, key_r = self._codes()
|
|
backend = evdev_backend.EvdevKeyboardBackend(shortcut="alt+r", mode="toggle")
|
|
fired = threading.Event()
|
|
backend.register_toggle_callback(fired.set)
|
|
# Right Alt (other keyboard half) + R still satisfies "alt".
|
|
backend._handle_key_event(self._event(alt_r, 1), None)
|
|
backend._handle_key_event(self._event(key_r, 1), None)
|
|
assert self._wait(fired)
|
|
|
|
def test_legacy_double_tap_still_works(self):
|
|
from vocalinux.ui.keyboard_backends.evdev_backend import KEY_LEFTCTRL
|
|
|
|
backend = evdev_backend.EvdevKeyboardBackend(shortcut="ctrl+ctrl", mode="toggle")
|
|
fired = threading.Event()
|
|
backend.register_toggle_callback(fired.set)
|
|
backend._handle_key_event(self._event(KEY_LEFTCTRL, 1), None)
|
|
backend._handle_key_event(self._event(KEY_LEFTCTRL, 0), None)
|
|
backend._handle_key_event(self._event(KEY_LEFTCTRL, 1), None)
|
|
assert self._wait(fired)
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# pynput combo detection
|
|
# --------------------------------------------------------------------------
|
|
pynput_backend = pytest.importorskip("vocalinux.ui.keyboard_backends.pynput_backend")
|
|
|
|
|
|
@pytest.mark.skipif(not pynput_backend.PYNPUT_AVAILABLE, reason="pynput not available")
|
|
class TestPynputComboDetection:
|
|
def _kb(self):
|
|
"""The keyboard module the backend actually imported.
|
|
|
|
Using the backend's own reference (rather than a fresh ``from pynput
|
|
import keyboard``) keeps us consistent with the module-level
|
|
``ALL_MODIFIER_KEYS`` set. If another test has replaced ``pynput`` with a
|
|
mock in ``sys.modules`` and left it there, ``KeyCode.from_char`` yields a
|
|
mock; detect that and skip rather than assert against a broken stub.
|
|
"""
|
|
kb = pynput_backend.keyboard
|
|
try:
|
|
if kb.KeyCode.from_char("r").char != "r":
|
|
pytest.skip("pynput is mocked by another test module")
|
|
except Exception:
|
|
pytest.skip("pynput is mocked by another test module")
|
|
return kb
|
|
|
|
def _wait(self, flag, timeout=1.0):
|
|
return flag.wait(timeout)
|
|
|
|
def test_toggle_fires_on_combo(self):
|
|
kb = self._kb()
|
|
backend = pynput_backend.PynputKeyboardBackend(shortcut="alt+r", mode="toggle")
|
|
fired = threading.Event()
|
|
backend.register_toggle_callback(fired.set)
|
|
backend._on_press(kb.Key.alt_l)
|
|
backend._on_press(kb.KeyCode.from_char("r"))
|
|
assert self._wait(fired)
|
|
|
|
def test_no_fire_without_modifier(self):
|
|
kb = self._kb()
|
|
backend = pynput_backend.PynputKeyboardBackend(shortcut="alt+r", mode="toggle")
|
|
fired = threading.Event()
|
|
backend.register_toggle_callback(fired.set)
|
|
backend._on_press(kb.KeyCode.from_char("r"))
|
|
assert not self._wait(fired, timeout=0.2)
|
|
|
|
def test_push_to_talk_press_release(self):
|
|
kb = self._kb()
|
|
backend = pynput_backend.PynputKeyboardBackend(shortcut="alt+r", mode="push_to_talk")
|
|
pressed = threading.Event()
|
|
released = threading.Event()
|
|
backend.register_press_callback(pressed.set)
|
|
backend.register_release_callback(released.set)
|
|
backend._on_press(kb.Key.alt_l)
|
|
backend._on_press(kb.KeyCode.from_char("r"))
|
|
assert self._wait(pressed)
|
|
backend._on_release(kb.KeyCode.from_char("r"))
|
|
assert self._wait(released)
|
|
|
|
def test_ctrl_letter_control_char_matches(self):
|
|
kb = self._kb()
|
|
# Ctrl+R may arrive as control char \x12; token resolver must map it back.
|
|
backend = pynput_backend.PynputKeyboardBackend(shortcut="ctrl+r", mode="toggle")
|
|
fired = threading.Event()
|
|
backend.register_toggle_callback(fired.set)
|
|
backend._on_press(kb.Key.ctrl_l)
|
|
backend._on_press(kb.KeyCode.from_char("\x12"))
|
|
assert self._wait(fired)
|
|
|
|
|
|
@pytest.mark.skipif(not evdev_backend.EVDEV_AVAILABLE, reason="evdev not available")
|
|
class TestEvdevComboRecovery:
|
|
"""Combo state must be reconciled after lost events (SYN_DROPPED / disconnect)."""
|
|
|
|
def _event(self, code, value):
|
|
return types.SimpleNamespace(code=code, value=value, type=1)
|
|
|
|
def _codes(self):
|
|
from vocalinux.ui.keyboard_backends.evdev_backend import (
|
|
KEY_LEFTALT,
|
|
evdev_code_for_key,
|
|
)
|
|
|
|
return KEY_LEFTALT, evdev_code_for_key("r")
|
|
|
|
def test_dropped_modifier_release_does_not_false_trigger(self):
|
|
alt_l, key_r = self._codes()
|
|
b = evdev_backend.EvdevKeyboardBackend(shortcut="alt+r", mode="toggle")
|
|
fired = threading.Event()
|
|
b.register_toggle_callback(fired.set)
|
|
|
|
# Alt goes down; its release is then LOST (SYN_DROPPED). Recovery must
|
|
# clear the stale "Alt held" state so R alone can't toggle.
|
|
b._handle_key_event(self._event(alt_l, 1), None)
|
|
assert alt_l in b._combo_pressed
|
|
b._reset_combo_state()
|
|
assert b._combo_pressed == set()
|
|
|
|
b._handle_key_event(self._event(key_r, 1), None)
|
|
assert not fired.wait(0.2)
|
|
|
|
def test_reset_ends_stuck_push_to_talk_hold(self):
|
|
alt_l, key_r = self._codes()
|
|
b = evdev_backend.EvdevKeyboardBackend(shortcut="alt+r", mode="push_to_talk")
|
|
pressed = threading.Event()
|
|
released = threading.Event()
|
|
b.register_press_callback(pressed.set)
|
|
b.register_release_callback(released.set)
|
|
|
|
b._handle_key_event(self._event(alt_l, 1), None)
|
|
b._handle_key_event(self._event(key_r, 1), None)
|
|
assert pressed.wait(1.0)
|
|
assert b._combo_active
|
|
|
|
b._reset_combo_state() # e.g. a device disconnects mid-hold
|
|
assert released.wait(1.0)
|
|
assert not b._combo_active
|
|
|
|
def test_device_disconnect_resets_combo_state(self):
|
|
alt_l, _ = self._codes()
|
|
b = evdev_backend.EvdevKeyboardBackend(shortcut="alt+r", mode="toggle")
|
|
b._handle_key_event(self._event(alt_l, 1), None)
|
|
assert alt_l in b._combo_pressed
|
|
|
|
fake_device = types.SimpleNamespace(close=lambda: None, name="fake")
|
|
b._remove_keyboard_device(1234, fake_device)
|
|
assert b._combo_pressed == set()
|