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 <jatinkrmalik@gmail.com>
This commit is contained in:
Ben Williams
2026-07-13 14:43:36 -07:00
committed by GitHub
co-authored by Jatin K Malik
parent a2a5c569c5
commit b3605bf3c6
10 changed files with 1300 additions and 29 deletions
+4 -2
View File
@@ -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,
@@ -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",
]
+265 -11
View File
@@ -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
@@ -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_<UPPER>), 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
@@ -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)
+8 -6
View File
@@ -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:
+233 -10
View File
@@ -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"<span foreground='#e01b24'>Invalid shortcut: "
f"<b>{GLib.markup_escape_text(shortcut or '(empty)')}</b>. "
"Try a modifier + key, e.g. alt+r.</span>"
)
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"<span foreground='#26a269'>Shortcut updated to <b>{display_name}</b>. "
"Active now!</span>"
)
else:
self.shortcut_info_label.set_markup(
f"<i>Shortcut updated to <b>{display_name}</b>. "
"Restart the app for the change to take full effect.</i>"
)
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(
"<i>Press a modifier + key (e.g. Alt+R). Press Esc to cancel.</i>"
)
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(
"<span foreground='#e01b24'>Need a modifier + key. "
"Try again or press Esc to cancel.</span>"
)
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(
+335
View File
@@ -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()
+57
View File
@@ -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 <name>(` 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
+37
View File
@@ -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