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:
co-authored by
Jatin K Malik
parent
a2a5c569c5
commit
b3605bf3c6
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user