From b3605bf3c6083f24a9f009e18387d51c46ed8107 Mon Sep 17 00:00:00 2001 From: Ben Williams Date: Mon, 13 Jul 2026 22:43:36 +0100 Subject: [PATCH] feat(shortcuts): configurable modifier+key hotkeys (e.g. Alt+R) (#493) * 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 --- src/vocalinux/ui/config_manager.py | 6 +- .../ui/keyboard_backends/__init__.py | 8 + src/vocalinux/ui/keyboard_backends/base.py | 276 ++++++++++++++- .../ui/keyboard_backends/evdev_backend.py | 187 ++++++++++ .../ui/keyboard_backends/pynput_backend.py | 166 +++++++++ src/vocalinux/ui/keyboard_shortcuts.py | 14 +- src/vocalinux/ui/settings_dialog.py | 243 ++++++++++++- tests/test_configurable_hotkeys.py | 335 ++++++++++++++++++ tests/test_settings_mode_change.py | 57 +++ tests/test_shortcut_capture.py | 37 ++ 10 files changed, 1300 insertions(+), 29 deletions(-) create mode 100644 tests/test_configurable_hotkeys.py create mode 100644 tests/test_settings_mode_change.py create mode 100644 tests/test_shortcut_capture.py diff --git a/src/vocalinux/ui/config_manager.py b/src/vocalinux/ui/config_manager.py index f17658b..aa0a39e 100644 --- a/src/vocalinux/ui/config_manager.py +++ b/src/vocalinux/ui/config_manager.py @@ -43,8 +43,10 @@ DEFAULT_CONFIG = { "shortcuts": { "toggle_recognition": "ctrl+ctrl", # Double-tap modifier key "mode": "toggle", # "toggle" or "push_to_talk" - # Supported values: "ctrl+ctrl", "alt+alt", "shift+shift" - # These represent double-tap shortcuts for the respective modifier keys + # Pure-modifier gestures: "ctrl+ctrl", "alt+alt", "shift+shift" (and + # left_/right_ variants) — double-tap (toggle) or hold (push_to_talk). + # Modifier+key combos are also supported, e.g. "alt+r", "ctrl+alt+r", + # "super+space" — press (toggle) or hold (push_to_talk). }, "ui": { "start_minimized": False, diff --git a/src/vocalinux/ui/keyboard_backends/__init__.py b/src/vocalinux/ui/keyboard_backends/__init__.py index 5491955..37791eb 100644 --- a/src/vocalinux/ui/keyboard_backends/__init__.py +++ b/src/vocalinux/ui/keyboard_backends/__init__.py @@ -18,8 +18,12 @@ from .base import ( SHORTCUT_MODES, SUPPORTED_SHORTCUTS, KeyboardBackend, + ShortcutSpec, + format_shortcut_label, get_shortcut_display_name, + is_valid_shortcut, parse_shortcut, + parse_shortcut_spec, ) logger = logging.getLogger(__name__) @@ -167,4 +171,8 @@ __all__ = [ "DEFAULT_SHORTCUT", "DEFAULT_SHORTCUT_MODE", "parse_shortcut", + "parse_shortcut_spec", + "is_valid_shortcut", + "format_shortcut_label", + "ShortcutSpec", ] diff --git a/src/vocalinux/ui/keyboard_backends/base.py b/src/vocalinux/ui/keyboard_backends/base.py index 8761196..6935782 100644 --- a/src/vocalinux/ui/keyboard_backends/base.py +++ b/src/vocalinux/ui/keyboard_backends/base.py @@ -5,8 +5,10 @@ All keyboard backends must inherit from this class and implement the required methods. """ +import re from abc import ABC, abstractmethod -from typing import Callable, Optional +from dataclasses import dataclass +from typing import Callable, Optional, Tuple Callback = Callable[[], None] @@ -99,6 +101,232 @@ SHORTCUT_MODES = { DEFAULT_SHORTCUT_MODE = "toggle" +# --------------------------------------------------------------------------- +# Generalized shortcut parsing (modifier-only gestures AND modifier+key combos) +# +# The legacy model only allowed a single modifier ("ctrl+ctrl") used as a +# double-tap / hold gesture. The parser below additionally accepts combos such +# as "alt+r" or "ctrl+alt+r": one or more modifiers plus exactly one ordinary +# key. Backends resolve the canonical key tokens below to their own key codes. +# --------------------------------------------------------------------------- + +# Canonical modifier tokens accepted in a shortcut string. +MODIFIER_NAMES = { + "ctrl", + "alt", + "shift", + "super", + "left_ctrl", + "left_alt", + "left_shift", + "right_ctrl", + "right_alt", + "right_shift", +} + +# Named (non-alphanumeric) main keys accepted in a combo. Single letters/digits +# and function keys (f1-f24) are accepted by rule and need not be listed here. +_NAMED_MAIN_KEYS = { + "space", + "tab", + "enter", + "return", + "esc", + "escape", + "backspace", + "delete", + "insert", + "home", + "end", + "pageup", + "pagedown", + "up", + "down", + "left", + "right", + "comma", + "period", + "slash", + "semicolon", + "apostrophe", + "grave", + "minus", + "equal", + "leftbracket", + "rightbracket", + "backslash", +} + +_FUNCTION_KEY_RE = re.compile(r"f([1-9]|1[0-9]|2[0-4])$") + +_MODIFIER_LABELS = { + "ctrl": "Ctrl", + "alt": "Alt", + "shift": "Shift", + "super": "Super", + "left_ctrl": "Left Ctrl", + "left_alt": "Left Alt", + "left_shift": "Left Shift", + "right_ctrl": "Right Ctrl", + "right_alt": "Right Alt", + "right_shift": "Right Shift", +} + +_NAMED_KEY_LABELS = { + "space": "Space", + "tab": "Tab", + "enter": "Enter", + "return": "Enter", + "esc": "Esc", + "escape": "Esc", + "backspace": "Backspace", + "delete": "Delete", + "insert": "Insert", + "home": "Home", + "end": "End", + "pageup": "PageUp", + "pagedown": "PageDown", + "up": "Up", + "down": "Down", + "left": "Left", + "right": "Right", + "comma": ",", + "period": ".", + "slash": "/", + "semicolon": ";", + "apostrophe": "'", + "grave": "`", + "minus": "-", + "equal": "=", + "leftbracket": "[", + "rightbracket": "]", + "backslash": "\\", +} + + +def is_modifier_token(token: str) -> bool: + """Return True if a token names a modifier key.""" + return token in MODIFIER_NAMES + + +def is_main_key_token(token: str) -> bool: + """Return True if a token names a valid non-modifier (main) key.""" + if len(token) == 1 and token.isalnum(): + return True + if _FUNCTION_KEY_RE.fullmatch(token): + return True + return token in _NAMED_MAIN_KEYS + + +@dataclass(frozen=True) +class ShortcutSpec: + """Structured representation of a parsed shortcut. + + - Pure-modifier gesture (legacy): ``modifiers=("ctrl",), key=None`` parsed + from "ctrl+ctrl". The double-tap / hold gesture applies to that modifier. + - Combo: ``modifiers=("alt",), key="r"`` parsed from "alt+r"; may carry + several modifiers, e.g. ``modifiers=("ctrl", "alt"), key="r"``. + """ + + modifiers: Tuple[str, ...] + key: Optional[str] = None + + @property + def is_combo(self) -> bool: + """True if this shortcut is a modifier+key combo (not a bare modifier).""" + return self.key is not None + + @property + def primary_modifier(self) -> str: + """The first modifier, used for backward-compatible single-modifier APIs.""" + return self.modifiers[0] if self.modifiers else "" + + def canonical(self) -> str: + """Canonical shortcut string (round-trips through parse_shortcut_spec).""" + if self.key is None: + return f"{self.modifiers[0]}+{self.modifiers[0]}" + return "+".join((*self.modifiers, self.key)) + + +def parse_shortcut_spec(shortcut_string: str) -> ShortcutSpec: + """Parse a shortcut string into a :class:`ShortcutSpec`. + + Accepts legacy pure-modifier forms ("ctrl+ctrl", "left_shift+left_shift") + and modifier+key combos ("alt+r", "ctrl+alt+r"). + + Raises: + ValueError: if the string is empty, malformed, contains an unknown key, + has more than one non-modifier key, or is a bare single modifier + (a pure-modifier gesture must use the doubled form, e.g. "ctrl+ctrl"). + """ + if not shortcut_string or not shortcut_string.strip(): + raise ValueError("Empty shortcut string") + + tokens = [t.strip() for t in shortcut_string.lower().strip().split("+")] + if any(t == "" for t in tokens): + raise ValueError(f"Malformed shortcut: {shortcut_string}") + + modifiers = [] + main_key = None + for token in tokens: + if is_modifier_token(token): + modifiers.append(token) + elif is_main_key_token(token): + if main_key is not None: + raise ValueError( + f"Shortcut may contain only one non-modifier key: {shortcut_string}" + ) + main_key = token + else: + raise ValueError(f"Unknown key in shortcut: {token!r}") + + if not modifiers: + raise ValueError(f"Shortcut needs at least one modifier: {shortcut_string}") + + # Deduplicate modifiers while preserving order ("ctrl+ctrl" -> ("ctrl",)). + unique_modifiers = tuple(dict.fromkeys(modifiers)) + + if main_key is None: + # Pure-modifier gesture. Require the legacy doubled form (two identical + # modifier tokens) so a bare "ctrl" stays invalid and "ctrl+alt" (an + # ambiguous gesture target) is rejected. + if len(modifiers) < 2 or len(unique_modifiers) != 1: + raise ValueError( + f"Modifier-only shortcut must double a single modifier: {shortcut_string}" + ) + return ShortcutSpec(modifiers=unique_modifiers, key=None) + + return ShortcutSpec(modifiers=unique_modifiers, key=main_key) + + +def is_valid_shortcut(shortcut_string: str) -> bool: + """Return True if the string parses as a valid shortcut.""" + try: + parse_shortcut_spec(shortcut_string) + return True + except (ValueError, AttributeError): + return False + + +def _main_key_label(token: str) -> str: + """Human-readable label for a main-key token (e.g. 'r' -> 'R').""" + if token in _NAMED_KEY_LABELS: + return _NAMED_KEY_LABELS[token] + return token.upper() + + +def format_shortcut_label(spec: ShortcutSpec) -> str: + """Human-readable label for a spec, e.g. 'Alt+R' or 'Ctrl (either side)'.""" + if not spec.is_combo: + legacy_id = spec.canonical() + if legacy_id in SHORTCUT_DISPLAY_NAMES: + return SHORTCUT_DISPLAY_NAMES[legacy_id] + return _MODIFIER_LABELS.get(spec.primary_modifier, spec.primary_modifier) + parts = [_MODIFIER_LABELS.get(m, m) for m in spec.modifiers] + parts.append(_main_key_label(spec.key)) + return "+".join(parts) + + def get_shortcut_display_name(shortcut: str, mode: Optional[str] = None) -> str: """ Get a human-readable display name for a shortcut. @@ -110,33 +338,52 @@ def get_shortcut_display_name(shortcut: str, mode: Optional[str] = None) -> str: Returns: A human-readable display name for the shortcut """ + # Legacy shortcuts keep their exact curated wording. if mode and shortcut in SHORTCUT_MODE_DISPLAY_NAMES: return SHORTCUT_MODE_DISPLAY_NAMES[shortcut].get( mode, SHORTCUT_DISPLAY_NAMES.get(shortcut, shortcut) ) - return SHORTCUT_DISPLAY_NAMES.get(shortcut, shortcut) + if shortcut in SHORTCUT_DISPLAY_NAMES: + return SHORTCUT_DISPLAY_NAMES[shortcut] + + # Generated names for combos (and any non-legacy shortcut). + try: + spec = parse_shortcut_spec(shortcut) + except ValueError: + return shortcut + label = format_shortcut_label(spec) + if mode == "toggle": + return f"Press {label}" if spec.is_combo else f"Double-tap {label}" + if mode == "push_to_talk": + return f"Hold {label}" + return label def parse_shortcut(shortcut_string: str) -> str: """ - Parse a shortcut string and return the modifier key name. + Parse a shortcut string and return its primary modifier key name. + + Retained for backward compatibility. Accepts both legacy pure-modifier + shortcuts ("ctrl+ctrl") and modifier+key combos ("alt+r"); for a combo the + first (primary) modifier is returned. Args: - shortcut_string: The shortcut string (e.g., "ctrl+ctrl", "alt+alt") + shortcut_string: The shortcut string (e.g., "ctrl+ctrl", "alt+r") Returns: - The modifier key name (e.g., "ctrl", "alt") + The primary modifier key name (e.g., "ctrl", "alt") Raises: ValueError: If the shortcut string is not recognized """ - shortcut_lower = shortcut_string.lower().strip() - if shortcut_lower not in SUPPORTED_SHORTCUTS: + try: + spec = parse_shortcut_spec(shortcut_string) + except ValueError: raise ValueError( f"Unsupported shortcut: {shortcut_string}. " f"Supported shortcuts: {', '.join(SUPPORTED_SHORTCUTS.keys())}" ) - return SUPPORTED_SHORTCUTS[shortcut_lower] + return spec.primary_modifier class KeyboardBackend(ABC): @@ -161,7 +408,13 @@ class KeyboardBackend(ABC): self.key_release_callback: Optional[Callback] = None self._shortcut = shortcut self._mode = mode - self._modifier_key = parse_shortcut(shortcut) + self._spec = parse_shortcut_spec(shortcut) + self._modifier_key = self._spec.primary_modifier + + @property + def spec(self) -> ShortcutSpec: + """Get the parsed shortcut specification.""" + return self._spec @property def shortcut(self) -> str: @@ -194,9 +447,10 @@ class KeyboardBackend(ABC): Update the shortcut to listen for. Args: - shortcut: The new shortcut string (e.g., "ctrl+ctrl", "alt+alt") + shortcut: The new shortcut string (e.g., "ctrl+ctrl", "alt+r") """ - self._modifier_key = parse_shortcut(shortcut) + self._spec = parse_shortcut_spec(shortcut) + self._modifier_key = self._spec.primary_modifier self._shortcut = shortcut @abstractmethod diff --git a/src/vocalinux/ui/keyboard_backends/evdev_backend.py b/src/vocalinux/ui/keyboard_backends/evdev_backend.py index a17f83c..4a48d58 100644 --- a/src/vocalinux/ui/keyboard_backends/evdev_backend.py +++ b/src/vocalinux/ui/keyboard_backends/evdev_backend.py @@ -8,6 +8,7 @@ input devices, which works on both X11 and Wayland (with proper permissions). import errno import logging import os +import re import select import threading import time @@ -55,6 +56,55 @@ MODIFIER_KEY_CODES: dict[str, set[int]] = { "right_shift": {KEY_RIGHTSHIFT}, } +# Named main-key tokens -> evdev ecodes attribute name. Single letters/digits +# and function keys are resolved by rule (KEY_), so only irregular names +# are listed here. +_NAMED_EVDEV_KEYS = { + "space": "KEY_SPACE", + "tab": "KEY_TAB", + "enter": "KEY_ENTER", + "return": "KEY_ENTER", + "esc": "KEY_ESC", + "escape": "KEY_ESC", + "backspace": "KEY_BACKSPACE", + "delete": "KEY_DELETE", + "insert": "KEY_INSERT", + "home": "KEY_HOME", + "end": "KEY_END", + "pageup": "KEY_PAGEUP", + "pagedown": "KEY_PAGEDOWN", + "up": "KEY_UP", + "down": "KEY_DOWN", + "left": "KEY_LEFT", + "right": "KEY_RIGHT", + "comma": "KEY_COMMA", + "period": "KEY_DOT", + "slash": "KEY_SLASH", + "semicolon": "KEY_SEMICOLON", + "apostrophe": "KEY_APOSTROPHE", + "grave": "KEY_GRAVE", + "minus": "KEY_MINUS", + "equal": "KEY_EQUAL", + "leftbracket": "KEY_LEFTBRACE", + "rightbracket": "KEY_RIGHTBRACE", + "backslash": "KEY_BACKSLASH", +} + + +def evdev_code_for_key(token: str) -> Optional[int]: + """Resolve a canonical main-key token (e.g. "r", "f5", "space") to an evdev code.""" + if not EVDEV_AVAILABLE or not token: + return None + name = _NAMED_EVDEV_KEYS.get(token) + if name is None: + if len(token) == 1 and token.isalnum(): + name = f"KEY_{token.upper()}" + elif re.fullmatch(r"f\d+", token): + name = f"KEY_{token.upper()}" + if name is None: + return None + return getattr(ecodes, name, None) + def find_keyboard_devices() -> list[str]: """ @@ -161,6 +211,14 @@ class EvdevKeyboardBackend(KeyboardBackend): self.double_tap_threshold = 0.3 # seconds self.key_pressed_devices: set[int] = set() + # Combo (modifier+key) state. Populated by _resolve_combo_targets(). + self._combo_main_code: Optional[int] = None + self._combo_modifier_sets: list[set[int]] = [] + self._combo_all_codes: set[int] = set() + self._combo_pressed: set[int] = set() # currently-held combo-relevant codes + self._combo_active = False # True while a push-to-talk combo hold is live + self._resolve_combo_targets() + self._devices_lock = threading.Lock() self._dropped_devices: set[int] = set() # fds with SYN_DROPPED pending self._device_paths_by_fd: dict[int, str] = {} @@ -172,6 +230,62 @@ class EvdevKeyboardBackend(KeyboardBackend): """Get the evdev key codes for the configured modifier.""" return MODIFIER_KEY_CODES.get(self._modifier_key, set()) + def set_shortcut(self, shortcut: str) -> None: + """Update the shortcut and recompute combo key targets.""" + super().set_shortcut(shortcut) + self._resolve_combo_targets() + + def _resolve_combo_targets(self) -> None: + """Resolve the spec's combo modifiers and main key to evdev codes.""" + self._combo_main_code = None + self._combo_modifier_sets = [] + self._combo_all_codes = set() + self._combo_pressed = set() + self._combo_active = False + + spec = getattr(self, "_spec", None) + if spec is None or not spec.is_combo: + return + + main_code = evdev_code_for_key(spec.key) + if main_code is None: + logger.error( + f"Combo shortcut '{self._shortcut}': cannot resolve key '{spec.key}' " + "to an evdev code; combo will not trigger" + ) + return + + modifier_sets = [] + for modifier in spec.modifiers: + codes = MODIFIER_KEY_CODES.get(modifier, set()) + if not codes: + logger.error(f"Combo shortcut '{self._shortcut}': unknown modifier '{modifier}'") + return + modifier_sets.append(set(codes)) + + self._combo_main_code = main_code + self._combo_modifier_sets = modifier_sets + self._combo_all_codes = set().union(*modifier_sets) | {main_code} + + def _required_modifiers_held(self) -> bool: + """True if at least one key code for every required modifier is held.""" + return all(bool(codes & self._combo_pressed) for codes in self._combo_modifier_sets) + + def _reset_combo_state(self) -> None: + """Drop cached combo key state after lost events (SYN_DROPPED / disconnect). + + When the kernel drops events or a device disconnects, a modifier *release* + may be among the lost events. If we kept the stale "modifier held" state, + the main key pressed alone could falsely toggle/start dictation, and a + push-to-talk hold could stay stuck on. Clearing the pressed set fails + safe (the user simply re-presses the modifier), and ending any live hold + prevents a stuck session. + """ + self._combo_pressed = set() + # Ends an active push-to-talk hold (fires the release callback); no-op + # for toggle mode or when no hold is active. + self._combo_released() + def is_available(self) -> bool: """Check if evdev is available and we can access a keyboard device with the modifier key.""" if not EVDEV_AVAILABLE: @@ -255,6 +369,9 @@ class EvdevKeyboardBackend(KeyboardBackend): self._dropped_devices = set() self._device_paths_by_fd = {} + # Refresh combo targets and clear any stale held-key state. + self._resolve_combo_targets() + for device_path in device_paths: self._open_keyboard_device(device_path) @@ -369,6 +486,9 @@ class EvdevKeyboardBackend(KeyboardBackend): self.device_paths.discard(device_path) self._dropped_devices.discard(fd) self.key_pressed_devices.discard(id(device)) + # A disconnect can swallow the modifier release (e.g. a wireless + # split half dropping mid-hold); don't leave the combo logically held. + self._reset_combo_state() def _monitor_devices(self) -> None: """Monitor keyboard devices for events.""" @@ -420,6 +540,9 @@ class EvdevKeyboardBackend(KeyboardBackend): # End of dropped sequence — clear stale state self._dropped_devices.discard(fd) self.key_pressed_devices.discard(id(device)) + # A dropped modifier release must not leave + # the combo logically held. + self._reset_combo_state() continue if fd in self._dropped_devices: continue @@ -445,6 +568,70 @@ class EvdevKeyboardBackend(KeyboardBackend): def _handle_key_event(self, event, device) -> None: """Handle a key event from evdev.""" + if getattr(self, "_spec", None) is not None and self._spec.is_combo: + self._handle_combo_key_event(event) + return + self._handle_modifier_key_event(event, device) + + def _handle_combo_key_event(self, event) -> None: + """Handle a key event for a modifier+key combo (e.g. Alt+R). + + Combo state is tracked globally across all keyboard devices, which is + required for split keyboards where the modifier and the main key can + live on different physical halves (separate evdev devices). + """ + try: + code = event.code + value = event.value # 0 = release, 1 = press, 2 = autorepeat + + if self._combo_main_code is None: + return + if value == 2: # ignore autorepeat; press/release drive the gesture + return + + if code in self._combo_all_codes: + if value == 1: + self._combo_pressed.add(code) + elif value == 0: + self._combo_pressed.discard(code) + + if code == self._combo_main_code: + if value == 1 and self._required_modifiers_held(): + self._combo_triggered() + elif value == 0: + self._combo_released() + elif value == 0 and code in self._combo_all_codes: + # A required modifier was released: end an active push-to-talk hold. + if not self._required_modifiers_held(): + self._combo_released() + except Exception as e: + logger.error(f"Error handling combo key event: {e}") + + def _combo_triggered(self) -> None: + """Fire the appropriate callback when a combo is engaged.""" + if self._mode == "toggle": + current_time = time.time() + # Debounce so a single press can't double-fire. + if self.double_tap_callback is not None and current_time - self.last_trigger_time > 0.5: + logger.debug(f"Combo {self._shortcut} toggled (evdev)") + self.last_trigger_time = current_time + threading.Thread(target=self.double_tap_callback, daemon=True).start() + elif self._mode == "push_to_talk": + if not self._combo_active and self.key_press_callback is not None: + self._combo_active = True + logger.debug(f"Combo {self._shortcut} pressed (evdev)") + threading.Thread(target=self.key_press_callback, daemon=True).start() + + def _combo_released(self) -> None: + """Fire the release callback when a live push-to-talk combo ends.""" + if self._mode == "push_to_talk" and self._combo_active: + self._combo_active = False + if self.key_release_callback is not None: + logger.debug(f"Combo {self._shortcut} released (evdev)") + threading.Thread(target=self.key_release_callback, daemon=True).start() + + def _handle_modifier_key_event(self, event, device) -> None: + """Handle a key event for a single-modifier gesture (double-tap / hold).""" try: code = event.code value = event.value # 0 = release, 1 = press, 2 = repeat diff --git a/src/vocalinux/ui/keyboard_backends/pynput_backend.py b/src/vocalinux/ui/keyboard_backends/pynput_backend.py index 2b835e7..693f1c9 100644 --- a/src/vocalinux/ui/keyboard_backends/pynput_backend.py +++ b/src/vocalinux/ui/keyboard_backends/pynput_backend.py @@ -89,6 +89,78 @@ if PYNPUT_AVAILABLE: keyboard.Key.cmd_r: keyboard.Key.cmd, } +# All pynput keys that count as modifiers (used to classify combo events). +ALL_MODIFIER_KEYS = set() +if PYNPUT_AVAILABLE: + for _variant_set in MODIFIER_KEY_VARIANTS.values(): + ALL_MODIFIER_KEYS |= _variant_set + +# pynput Key -> canonical named-key token (for combo main keys). +_PYNPUT_NAMED_TOKENS = {} +# Canonical punctuation char -> token. +_PUNCT_TOKENS = { + ",": "comma", + ".": "period", + "/": "slash", + ";": "semicolon", + "'": "apostrophe", + "`": "grave", + "-": "minus", + "=": "equal", + "[": "leftbracket", + "]": "rightbracket", + "\\": "backslash", +} +if PYNPUT_AVAILABLE: + _named_pairs = { + "space": "space", + "tab": "tab", + "enter": "enter", + "esc": "esc", + "backspace": "backspace", + "delete": "delete", + "insert": "insert", + "home": "home", + "end": "end", + "page_up": "pageup", + "page_down": "pagedown", + "up": "up", + "down": "down", + "left": "left", + "right": "right", + } + for _attr, _token in _named_pairs.items(): + _key_obj = getattr(keyboard.Key, _attr, None) + if _key_obj is not None: + _PYNPUT_NAMED_TOKENS[_key_obj] = _token + for _n in range(1, 25): + _fkey = getattr(keyboard.Key, f"f{_n}", None) + if _fkey is not None: + _PYNPUT_NAMED_TOKENS[_fkey] = f"f{_n}" + + +def pynput_key_token(key) -> Optional[str]: + """Resolve a pynput key event to a canonical main-key token, or None. + + Handles letters/digits (incl. Ctrl+letter control-char remapping), + punctuation, named keys, and function keys. + """ + if not PYNPUT_AVAILABLE: + return None + token = _PYNPUT_NAMED_TOKENS.get(key) + if token is not None: + return token + char = getattr(key, "char", None) + if char: + lowered = char.lower() + if len(lowered) == 1 and lowered.isalnum(): + return lowered + code = ord(char) + if 1 <= code <= 26: # Ctrl+letter arrives as a control character + return chr(code + 96) + return _PUNCT_TOKENS.get(char) + return None + class PynputKeyboardBackend(KeyboardBackend): """ @@ -113,6 +185,13 @@ class PynputKeyboardBackend(KeyboardBackend): self.double_tap_threshold = 0.3 # seconds self.current_keys = set() + # Combo (modifier+key) state. + self._combo_required_modifier_keys: list = [] + self._combo_key_token: Optional[str] = None + self._combo_held_keys: set = set() + self._combo_active = False + self._resolve_combo_targets() + if not PYNPUT_AVAILABLE: logger.error("pynput library not available") @@ -120,6 +199,39 @@ class PynputKeyboardBackend(KeyboardBackend): """Get the pynput Key object for the configured modifier.""" return MODIFIER_KEY_MAP.get(self._modifier_key) + def set_shortcut(self, shortcut: str) -> None: + """Update the shortcut and recompute combo key targets.""" + super().set_shortcut(shortcut) + self._resolve_combo_targets() + + def _resolve_combo_targets(self) -> None: + """Resolve the spec's combo modifiers and main key to pynput matchers.""" + self._combo_required_modifier_keys = [] + self._combo_key_token = None + self._combo_held_keys = set() + self._combo_active = False + + spec = getattr(self, "_spec", None) + if spec is None or not spec.is_combo or not PYNPUT_AVAILABLE: + return + + modifier_key_sets = [] + for modifier in spec.modifiers: + variants = MODIFIER_KEY_VARIANTS.get(modifier, set()) + if not variants: + logger.error(f"Combo shortcut '{self._shortcut}': unknown modifier '{modifier}'") + return + modifier_key_sets.append(set(variants)) + + self._combo_required_modifier_keys = modifier_key_sets + self._combo_key_token = spec.key + + def _combo_modifiers_held(self) -> bool: + """True if at least one variant of every required modifier is held.""" + return all( + bool(keys & self._combo_held_keys) for keys in self._combo_required_modifier_keys + ) + def _matches_configured_modifier(self, key) -> bool: key_variants = self._get_key_variants(self._modifier_key) if key_variants: @@ -200,6 +312,57 @@ class PynputKeyboardBackend(KeyboardBackend): def _on_press(self, key) -> None: """Handle key press events.""" + if getattr(self, "_spec", None) is not None and self._spec.is_combo: + self._on_combo_press(key) + return + self._on_modifier_press(key) + + def _on_combo_press(self, key) -> None: + """Handle a key press for a modifier+key combo (e.g. Alt+R).""" + try: + if key in ALL_MODIFIER_KEYS: + self._combo_held_keys.add(key) + return + + if pynput_key_token(key) == self._combo_key_token and self._combo_modifiers_held(): + if self._mode == "toggle": + current_time = time.time() + if ( + self.double_tap_callback is not None + and current_time - self.last_trigger_time > 0.5 + ): + logger.debug(f"Combo {self._shortcut} toggled (pynput)") + self.last_trigger_time = current_time + threading.Thread(target=self.double_tap_callback, daemon=True).start() + elif self._mode == "push_to_talk": + if not self._combo_active and self.key_press_callback is not None: + self._combo_active = True + logger.debug(f"Combo {self._shortcut} pressed (pynput)") + threading.Thread(target=self.key_press_callback, daemon=True).start() + except Exception as e: + logger.error(f"Error in pynput combo press handling: {e}") + + def _on_combo_release(self, key) -> None: + """Handle a key release for a modifier+key combo.""" + try: + is_modifier = key in ALL_MODIFIER_KEYS + if is_modifier: + self._combo_held_keys.discard(key) + + is_main_key = pynput_key_token(key) == self._combo_key_token + + if self._mode == "push_to_talk" and self._combo_active: + # End the hold when the main key or a required modifier is released. + if is_main_key or (is_modifier and not self._combo_modifiers_held()): + self._combo_active = False + if self.key_release_callback is not None: + logger.debug(f"Combo {self._shortcut} released (pynput)") + threading.Thread(target=self.key_release_callback, daemon=True).start() + except Exception as e: + logger.error(f"Error in pynput combo release handling: {e}") + + def _on_modifier_press(self, key) -> None: + """Handle a key press for a single-modifier gesture (double-tap / hold).""" try: matched = self._matches_configured_modifier(key) @@ -228,6 +391,9 @@ class PynputKeyboardBackend(KeyboardBackend): def _on_release(self, key) -> None: """Handle key release events.""" + if getattr(self, "_spec", None) is not None and self._spec.is_combo: + self._on_combo_release(key) + return try: normalized_key = self._normalize_modifier_key(key) diff --git a/src/vocalinux/ui/keyboard_shortcuts.py b/src/vocalinux/ui/keyboard_shortcuts.py index a3ba61a..5e71776 100644 --- a/src/vocalinux/ui/keyboard_shortcuts.py +++ b/src/vocalinux/ui/keyboard_shortcuts.py @@ -23,6 +23,8 @@ from .keyboard_backends import ( SUPPORTED_SHORTCUTS, DesktopEnvironment, create_backend, + get_shortcut_display_name, + is_valid_shortcut, ) logger = logging.getLogger(__name__) @@ -148,7 +150,7 @@ class KeyboardShortcutManager: @property def shortcut_display_name(self) -> str: """Get the human-readable name for the current shortcut.""" - return SHORTCUT_DISPLAY_NAMES.get(self._shortcut, self._shortcut) + return get_shortcut_display_name(self._shortcut) def set_shortcut(self, shortcut: str) -> bool: """ @@ -163,7 +165,7 @@ class KeyboardShortcutManager: Returns: True if successful, False if the shortcut is invalid """ - if shortcut not in SUPPORTED_SHORTCUTS: + if not is_valid_shortcut(shortcut): logger.error(f"Invalid shortcut: {shortcut}") return False @@ -171,7 +173,7 @@ class KeyboardShortcutManager: if self.backend_instance: self.backend_instance.set_shortcut(shortcut) - logger.info(f"Shortcut updated to: {SHORTCUT_DISPLAY_NAMES.get(shortcut, shortcut)}") + logger.info(f"Shortcut updated to: {get_shortcut_display_name(shortcut)}") return True @@ -191,7 +193,7 @@ class KeyboardShortcutManager: True if the listener was successfully restarted with the new shortcut, False if the shortcut is invalid or restart failed """ - if shortcut not in SUPPORTED_SHORTCUTS: + if not is_valid_shortcut(shortcut): logger.error(f"Invalid shortcut: {shortcut}") return False @@ -249,13 +251,13 @@ class KeyboardShortcutManager: if success: logger.info( f"Listener restarted with new shortcut: " - f"{SHORTCUT_DISPLAY_NAMES.get(shortcut, shortcut)} (mode: {self._mode})" + f"{get_shortcut_display_name(shortcut)} (mode: {self._mode})" ) else: logger.error(f"Failed to restart listener with shortcut: {shortcut}") return success - logger.info(f"Shortcut updated to: {SHORTCUT_DISPLAY_NAMES.get(shortcut, shortcut)}") + logger.info(f"Shortcut updated to: {get_shortcut_display_name(shortcut)}") return True def start(self) -> bool: diff --git a/src/vocalinux/ui/settings_dialog.py b/src/vocalinux/ui/settings_dialog.py index 305277a..4fbc26a 100644 --- a/src/vocalinux/ui/settings_dialog.py +++ b/src/vocalinux/ui/settings_dialog.py @@ -16,6 +16,7 @@ UX Design Notes: import logging import os +import re import threading import time from typing import TYPE_CHECKING, Optional @@ -47,6 +48,9 @@ from .keyboard_backends import ( # noqa: E402 SHORTCUT_GROUPS, SHORTCUT_MODES, SUPPORTED_SHORTCUTS, + get_shortcut_display_name, + is_valid_shortcut, + parse_shortcut_spec, ) # Avoid circular imports for type checking @@ -692,6 +696,69 @@ def _get_recommended_vosk_model() -> tuple: return "small", "Default recommendation" +# GDK key-symbol names that are themselves modifiers (skipped while recording). +_GDK_MODIFIER_KEYNAMES = { + "Control_L", + "Control_R", + "Alt_L", + "Alt_R", + "Shift_L", + "Shift_R", + "Super_L", + "Super_R", + "Meta_L", + "Meta_R", + "Hyper_L", + "Hyper_R", + "ISO_Level3_Shift", +} + +# GDK key-symbol names -> canonical main-key tokens (irregular names only; +# single letters/digits and function keys are handled by rule). +_GDK_KEYNAME_TOKENS = { + "space": "space", + "Return": "enter", + "KP_Enter": "enter", + "Tab": "tab", + "Escape": "esc", + "BackSpace": "backspace", + "Delete": "delete", + "Insert": "insert", + "Home": "home", + "End": "end", + "Page_Up": "pageup", + "Page_Down": "pagedown", + "Up": "up", + "Down": "down", + "Left": "left", + "Right": "right", + "comma": "comma", + "period": "period", + "slash": "slash", + "semicolon": "semicolon", + "apostrophe": "apostrophe", + "grave": "grave", + "minus": "minus", + "equal": "equal", + "bracketleft": "leftbracket", + "bracketright": "rightbracket", + "backslash": "backslash", +} + + +def _gdk_keyname_to_token(name: Optional[str]) -> Optional[str]: + """Map a GDK keyval name to a canonical main-key token, or None.""" + if not name: + return None + if name in _GDK_KEYNAME_TOKENS: + return _GDK_KEYNAME_TOKENS[name] + if len(name) == 1 and name.isalnum(): + return name.lower() + if re.fullmatch(r"[Ff]([1-9]|1[0-9]|2[0-4])", name): + return name.lower() + return None + + class PreferencesGroup(Gtk.Box): """A card-style group of preferences, similar to libadwaita's AdwPreferencesGroup.""" @@ -1597,6 +1664,43 @@ class SettingsDialog(Gtk.Dialog): ) group.add_row(self.shortcut_row) + # Custom shortcut: modifier+key combos (e.g. Alt+R) for users who want + # something the preset modifiers can't express (split keyboards, etc.). + custom_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6) + self.custom_shortcut_entry = Gtk.Entry() + self.custom_shortcut_entry.set_placeholder_text("e.g. alt+r") + self.custom_shortcut_entry.set_width_chars(12) + self.custom_shortcut_entry.set_tooltip_text( + "A modifier plus a key, e.g. alt+r, ctrl+alt+r, super+space" + ) + self.custom_shortcut_entry.connect("activate", self._on_custom_shortcut_apply) + custom_box.pack_start(self.custom_shortcut_entry, False, False, 0) + + self.record_shortcut_button = Gtk.Button(label="Record") + self.record_shortcut_button.set_tooltip_text("Click, then press your desired key combo") + self.record_shortcut_button.connect("clicked", self._on_record_shortcut_clicked) + custom_box.pack_start(self.record_shortcut_button, False, False, 0) + + self.set_custom_shortcut_button = Gtk.Button(label="Set") + self.set_custom_shortcut_button.set_tooltip_text("Apply the typed shortcut") + self.set_custom_shortcut_button.connect("clicked", self._on_custom_shortcut_apply) + custom_box.pack_start(self.set_custom_shortcut_button, False, False, 0) + + self.custom_shortcut_row = PreferenceRow( + title="Custom Shortcut", + subtitle="Modifier + key combo (great for split keyboards)", + widget=custom_box, + ) + group.add_row(self.custom_shortcut_row) + + # Reflect a non-preset active shortcut in the custom entry. + if current_shortcut not in SUPPORTED_SHORTCUTS: + self.custom_shortcut_entry.set_text(current_shortcut) + + # Key-capture state for the Record button. + self._recording_shortcut = False + self.connect("key-press-event", self._on_shortcut_key_press) + self.shortcuts_tab.pack_start(group, False, False, 0) # Info box about the shortcut @@ -1626,17 +1730,130 @@ class SettingsDialog(Gtk.Dialog): # Update UI based on initial mode self._update_shortcut_ui_for_mode(current_mode) - def _update_shortcut_ui_for_mode(self, mode: str): - """Update the shortcut UI based on the selected mode.""" - if mode == "toggle": - self.shortcut_row.set_subtitle("Double-tap this key to start/stop voice typing") - self.shortcut_info_label.set_text( - "In Toggle mode: Double-tap the key to start voice typing, double-tap again to stop." + def _apply_custom_shortcut(self, shortcut: str) -> None: + """Validate, persist, and live-apply a custom shortcut string.""" + shortcut = shortcut.strip().lower() + if not is_valid_shortcut(shortcut): + self.shortcut_info_label.set_markup( + f"Invalid shortcut: " + f"{GLib.markup_escape_text(shortcut or '(empty)')}. " + "Try a modifier + key, e.g. alt+r." ) + return + + self.config_manager.set("shortcuts", "toggle_recognition", shortcut) + self.config_manager.save_settings() + + mode_id = self.shortcut_mode_combo.get_active_id() + display_name = get_shortcut_display_name(shortcut, mode_id) + logger.info(f"Keyboard shortcut changed to custom: {display_name}") + + applied = False + if self.shortcut_update_callback: + applied = self.shortcut_update_callback(shortcut, mode_id) + if applied: + self.shortcut_info_label.set_markup( + f"Shortcut updated to {display_name}. " + "Active now!" + ) + else: + self.shortcut_info_label.set_markup( + f"Shortcut updated to {display_name}. " + "Restart the app for the change to take full effect." + ) + + def _on_custom_shortcut_apply(self, widget): + """Handle Set button / Entry activation for a typed custom shortcut.""" + if self._initializing: + return + self._apply_custom_shortcut(self.custom_shortcut_entry.get_text()) + + def _on_record_shortcut_clicked(self, widget): + """Begin capturing the next key combo pressed in the dialog.""" + self._recording_shortcut = True + self.record_shortcut_button.set_label("Press keys…") + self.shortcut_info_label.set_markup( + "Press a modifier + key (e.g. Alt+R). Press Esc to cancel." + ) + + def _stop_recording_shortcut(self): + """Exit key-capture mode and restore the Record button.""" + self._recording_shortcut = False + self.record_shortcut_button.set_label("Record") + + def _gdk_event_to_shortcut(self, event) -> Optional[str]: + """Build a canonical shortcut string from a GDK key-press event.""" + modifiers = [] + state = event.state + if state & Gdk.ModifierType.CONTROL_MASK: + modifiers.append("ctrl") + if state & Gdk.ModifierType.MOD1_MASK: + modifiers.append("alt") + if state & Gdk.ModifierType.SHIFT_MASK: + modifiers.append("shift") + if state & Gdk.ModifierType.SUPER_MASK: + modifiers.append("super") + token = _gdk_keyname_to_token(Gdk.keyval_name(event.keyval)) + if not modifiers or token is None: + return None + return "+".join(modifiers + [token]) + + def _on_shortcut_key_press(self, widget, event): + """Capture a pressed combo while recording; otherwise pass through.""" + if not getattr(self, "_recording_shortcut", False): + return False # not recording: let normal event handling proceed + + keyname = Gdk.keyval_name(event.keyval) or "" + if keyname in _GDK_MODIFIER_KEYNAMES: + return True # wait for the non-modifier key + + shortcut = self._gdk_event_to_shortcut(event) + if keyname == "Escape" and not shortcut: + self._stop_recording_shortcut() + self.shortcut_info_label.set_text("Recording cancelled.") + return True + + if shortcut and is_valid_shortcut(shortcut): + self.custom_shortcut_entry.set_text(shortcut) + self._stop_recording_shortcut() + self._apply_custom_shortcut(shortcut) + else: + self.shortcut_info_label.set_markup( + "Need a modifier + key. " + "Try again or press Esc to cancel." + ) + return True + + def _update_shortcut_ui_for_mode(self, mode: str): + """Update the shortcut UI text to match both the mode and the shortcut. + + A modifier+key combo (e.g. "alt+r") toggles on a single press, not a + double-tap, so the wording must reflect the active shortcut instead of + assuming a bare-modifier gesture. Reads the saved shortcut from config + (the single source of truth for both preset and custom shortcuts). + """ + shortcut = self.config_manager.get_str("shortcuts", "toggle_recognition", "ctrl+ctrl") + is_combo = is_valid_shortcut(shortcut) and parse_shortcut_spec(shortcut).is_combo + # e.g. "Press Alt+R" (combo toggle), "Double-tap Ctrl", "Hold Alt+R". + action = get_shortcut_display_name(shortcut, mode) + + if mode == "toggle": + if is_combo: + self.shortcut_row.set_subtitle(f"{action} to start/stop voice typing") + self.shortcut_info_label.set_text( + f"In Toggle mode: {action} to start voice typing, {action.lower()} " + "again to stop." + ) + else: + self.shortcut_row.set_subtitle("Double-tap this key to start/stop voice typing") + self.shortcut_info_label.set_text( + "In Toggle mode: Double-tap the key to start voice typing, " + "double-tap again to stop." + ) elif mode == "push_to_talk": - self.shortcut_row.set_subtitle("Hold this key to speak, release to stop") + self.shortcut_row.set_subtitle("Hold this shortcut to speak, release to stop") self.shortcut_info_label.set_text( - "In Push-to-Talk mode: Hold the key down to speak, release to stop recording." + "In Push-to-Talk mode: Hold the shortcut down to speak, release to stop recording." ) def _on_shortcut_mode_changed(self, widget): @@ -1658,9 +1875,15 @@ class SettingsDialog(Gtk.Dialog): # Update UI to reflect new mode self._update_shortcut_ui_for_mode(mode_id) - # Try to apply the mode change live + # Try to apply the mode change live. Read the active shortcut from the + # saved config rather than the preset combo: a custom shortcut (e.g. + # "alt+r") is stored in config but leaves the preset combo pointing at a + # default/previous preset, so using the combo here would restart the + # listener on the wrong shortcut. if self.shortcut_update_callback: - shortcut_id = self.shortcut_combo.get_active_id() + shortcut_id = self.config_manager.get_str( + "shortcuts", "toggle_recognition", "ctrl+ctrl" + ) success = self.shortcut_update_callback(shortcut_id, mode_id) if success: self.shortcut_info_label.set_markup( diff --git a/tests/test_configurable_hotkeys.py b/tests/test_configurable_hotkeys.py new file mode 100644 index 0000000..5102adf --- /dev/null +++ b/tests/test_configurable_hotkeys.py @@ -0,0 +1,335 @@ +"""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() diff --git a/tests/test_settings_mode_change.py b/tests/test_settings_mode_change.py new file mode 100644 index 0000000..2ecf4c0 --- /dev/null +++ b/tests/test_settings_mode_change.py @@ -0,0 +1,57 @@ +"""Regression test: switching shortcut mode must use the saved shortcut. + +A custom shortcut (e.g. "alt+r") is stored in config but leaves the preset +combo pointing at a default/previous preset. When the user toggles between +Toggle and Push-to-Talk, the live listener must be restarted on the *saved* +shortcut, not the stale preset shown in the combo. (Codex P2 on PR #493.) + +The settings dialog can't be instantiated under the mocked-GTK test harness, so +(like test_settings_shortcuts.py) this asserts against the source of the +_on_shortcut_mode_changed method. +""" + +import inspect +import re + +from vocalinux.ui import settings_dialog + + +def _method_source(name: str) -> str: + src = inspect.getsource(settings_dialog) + # Grab from `def (` up to the next method definition. + match = re.search(rf"\n def {name}\(.*?(?=\n def )", src, re.DOTALL) + assert match, f"could not locate method {name}" + return match.group(0) + + +def test_mode_change_reads_saved_shortcut_from_config(): + body = _method_source("_on_shortcut_mode_changed") + assert "self.config_manager.get_str(" in body + assert '"toggle_recognition"' in body + + +def test_mode_change_does_not_use_stale_preset_combo(): + body = _method_source("_on_shortcut_mode_changed") + # The stale preset combo must not drive the live mode-change apply. + assert "self.shortcut_combo.get_active_id()" not in body + + +def test_mode_ui_toggle_is_combo_aware(): + # A combo (alt+r) toggles on a single press, so the toggle label must branch + # on whether the shortcut is a combo rather than always saying "Double-tap". + body = _method_source("_update_shortcut_ui_for_mode") + assert "is_combo" in body + assert "get_shortcut_display_name(" in body + assert "self.config_manager.get_str(" in body + + +def test_mode_ui_double_tap_text_is_guarded_for_non_combo(): + body = _method_source("_update_shortcut_ui_for_mode") + # The "Double-tap" wording must sit inside the non-combo branch, not fire + # unconditionally for every toggle shortcut. + idx_if = body.find("if is_combo:") + # The user-facing "Double-tap this key" subtitle (not the example in a + # comment) must sit inside the non-combo branch. + idx_double_tap = body.find("Double-tap this key") + assert idx_if != -1 + assert idx_double_tap > idx_if diff --git a/tests/test_shortcut_capture.py b/tests/test_shortcut_capture.py new file mode 100644 index 0000000..ec9fdcf --- /dev/null +++ b/tests/test_shortcut_capture.py @@ -0,0 +1,37 @@ +"""Tests for the shortcut-capture keyname mapping in the settings dialog. + +Covers the pure `_gdk_keyname_to_token` helper that turns a GDK key-symbol name +(from the "Record shortcut" capture) into a canonical main-key token. The GTK +dialog itself can't be instantiated under the mocked-GTK test harness, but this +module-level helper is pure and directly testable. +""" + +import pytest + +from vocalinux.ui.settings_dialog import _gdk_keyname_to_token + + +@pytest.mark.parametrize( + "name,expected", + [ + ("r", "r"), + ("A", "a"), # capture may report a shifted/upper name + ("5", "5"), + ("F5", "f5"), + ("f12", "f12"), + ("space", "space"), + ("Return", "enter"), + ("Escape", "esc"), + ("Page_Up", "pageup"), + ("comma", "comma"), + ("bracketleft", "leftbracket"), + ], +) +def test_maps_known_keys(name, expected): + assert _gdk_keyname_to_token(name) == expected + + +@pytest.mark.parametrize("name", ["Control_L", "Alt_R", "Shift_L", "Super_L", "", None, "F25"]) +def test_rejects_modifiers_and_unknown(name): + # Modifiers, empty/None, and out-of-range function keys are not main keys. + assert _gdk_keyname_to_token(name) is None