feat(shortcuts): add customizable keyboard shortcuts via GUI (#137)
* feat(shortcuts): add customizable keyboard shortcuts via GUI Implement configurable keyboard shortcuts for toggling voice recognition, allowing users to choose between Ctrl+Ctrl, Alt+Alt, Shift+Shift, or Super+Super (Windows key) double-tap shortcuts. Changes: - Add shortcuts configuration section to settings dialog with dropdown - Update keyboard backends (pynput and evdev) to support configurable modifier keys for double-tap detection - Add SUPPORTED_SHORTCUTS, SHORTCUT_DISPLAY_NAMES constants in base module - Update KeyboardShortcutManager to accept and pass shortcut configuration - TrayIndicator now reads shortcut from config on startup - Add comprehensive tests for shortcut configuration and parsing The default shortcut remains Ctrl+Ctrl for backward compatibility. Changes take effect after app restart (noted in UI). Closes #16 * feat(shortcuts): enable live shortcut switching without app restart Add restart_with_shortcut() method to KeyboardShortcutManager that stops the listener, updates the shortcut configuration, and restarts with the new shortcut. The settings dialog now applies shortcut changes immediately with visual feedback indicating success. - Add restart_with_shortcut() to KeyboardShortcutManager - Add update_shortcut() to TrayIndicator for exposing live updates - Update SettingsDialog to use callback for immediate shortcut changes - Show green success message when live switching succeeds - Add 7 new tests for restart_with_shortcut functionality * fix: use Python 3.8 compatible context manager syntax Convert parenthesized context manager (Python 3.10+) to nested with statements for Python 3.8 compatibility in test_keyboard_shortcuts.py
This commit is contained in:
@@ -31,7 +31,9 @@ DEFAULT_CONFIG = {
|
||||
"device_name": None, # Saved device name for display/reference
|
||||
},
|
||||
"shortcuts": {
|
||||
"toggle_recognition": "ctrl+ctrl", # Double-tap Ctrl
|
||||
"toggle_recognition": "ctrl+ctrl", # Double-tap modifier key
|
||||
# Supported values: "ctrl+ctrl", "alt+alt", "shift+shift", "super+super"
|
||||
# These represent double-tap shortcuts for the respective modifier keys
|
||||
},
|
||||
"ui": {
|
||||
"start_minimized": False,
|
||||
|
||||
@@ -9,7 +9,13 @@ import logging
|
||||
import os
|
||||
from typing import Optional
|
||||
|
||||
from .base import KeyboardBackend
|
||||
from .base import (
|
||||
DEFAULT_SHORTCUT,
|
||||
SHORTCUT_DISPLAY_NAMES,
|
||||
SUPPORTED_SHORTCUTS,
|
||||
KeyboardBackend,
|
||||
parse_shortcut,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -62,25 +68,29 @@ class DesktopEnvironment:
|
||||
return DesktopEnvironment.UNKNOWN
|
||||
|
||||
|
||||
def create_backend(preferred_backend: Optional[str] = None) -> Optional[KeyboardBackend]:
|
||||
def create_backend(
|
||||
preferred_backend: Optional[str] = None, shortcut: str = DEFAULT_SHORTCUT
|
||||
) -> Optional[KeyboardBackend]:
|
||||
"""
|
||||
Create a keyboard backend based on availability and environment.
|
||||
|
||||
Args:
|
||||
preferred_backend: Optional backend name to force ('pynput' or 'evdev')
|
||||
shortcut: The shortcut string to listen for (e.g., "ctrl+ctrl", "alt+alt")
|
||||
|
||||
Returns:
|
||||
A KeyboardBackend instance, or None if no backend is available
|
||||
"""
|
||||
env = DesktopEnvironment.detect()
|
||||
logger.info(f"Detected desktop environment: {env}")
|
||||
logger.info(f"Configured shortcut: {shortcut}")
|
||||
|
||||
# If a specific backend is requested, try to use it
|
||||
if preferred_backend:
|
||||
if preferred_backend == "evdev":
|
||||
if EVDEV_AVAILABLE:
|
||||
logger.info("Using evdev backend (preferred)")
|
||||
backend = EvdevKeyboardBackend() # type: ignore
|
||||
backend = EvdevKeyboardBackend(shortcut=shortcut) # type: ignore
|
||||
if backend.is_available():
|
||||
return backend
|
||||
hint = backend.get_permission_hint()
|
||||
@@ -92,7 +102,7 @@ def create_backend(preferred_backend: Optional[str] = None) -> Optional[Keyboard
|
||||
elif preferred_backend == "pynput":
|
||||
if PYNPUT_AVAILABLE:
|
||||
logger.info("Using pynput backend (preferred)")
|
||||
return PynputKeyboardBackend() # type: ignore
|
||||
return PynputKeyboardBackend(shortcut=shortcut) # type: ignore
|
||||
logger.warning("Pynput backend not available")
|
||||
else:
|
||||
logger.warning(f"Unknown preferred backend: '{preferred_backend}'")
|
||||
@@ -102,7 +112,7 @@ def create_backend(preferred_backend: Optional[str] = None) -> Optional[Keyboard
|
||||
# On Wayland, prefer evdev (if available) as pynput doesn't work
|
||||
if EVDEV_AVAILABLE:
|
||||
logger.info("Using evdev backend for Wayland")
|
||||
backend = EvdevKeyboardBackend() # type: ignore
|
||||
backend = EvdevKeyboardBackend(shortcut=shortcut) # type: ignore
|
||||
if backend.is_available():
|
||||
return backend
|
||||
hint = backend.get_permission_hint()
|
||||
@@ -120,11 +130,11 @@ def create_backend(preferred_backend: Optional[str] = None) -> Optional[Keyboard
|
||||
# Default to pynput for X11 or unknown
|
||||
if PYNPUT_AVAILABLE:
|
||||
logger.info("Using pynput backend")
|
||||
return PynputKeyboardBackend() # type: ignore
|
||||
return PynputKeyboardBackend(shortcut=shortcut) # type: ignore
|
||||
|
||||
if EVDEV_AVAILABLE:
|
||||
logger.warning("Pynput not available, falling back to evdev backend")
|
||||
backend = EvdevKeyboardBackend() # type: ignore
|
||||
backend = EvdevKeyboardBackend(shortcut=shortcut) # type: ignore
|
||||
if backend.is_available():
|
||||
return backend
|
||||
hint = backend.get_permission_hint()
|
||||
@@ -145,4 +155,8 @@ __all__ = [
|
||||
"EvdevKeyboardBackend",
|
||||
"EVDEV_AVAILABLE",
|
||||
"PYNPUT_AVAILABLE",
|
||||
"SUPPORTED_SHORTCUTS",
|
||||
"SHORTCUT_DISPLAY_NAMES",
|
||||
"DEFAULT_SHORTCUT",
|
||||
"parse_shortcut",
|
||||
]
|
||||
|
||||
@@ -8,6 +8,46 @@ the required methods.
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Callable, Optional
|
||||
|
||||
# Supported double-tap shortcut keys
|
||||
SUPPORTED_SHORTCUTS = {
|
||||
"ctrl+ctrl": "ctrl",
|
||||
"alt+alt": "alt",
|
||||
"shift+shift": "shift",
|
||||
"super+super": "super",
|
||||
}
|
||||
|
||||
# Human-readable names for shortcuts
|
||||
SHORTCUT_DISPLAY_NAMES = {
|
||||
"ctrl+ctrl": "Double-tap Ctrl",
|
||||
"alt+alt": "Double-tap Alt",
|
||||
"shift+shift": "Double-tap Shift",
|
||||
"super+super": "Double-tap Super (Windows key)",
|
||||
}
|
||||
|
||||
DEFAULT_SHORTCUT = "ctrl+ctrl"
|
||||
|
||||
|
||||
def parse_shortcut(shortcut_string: str) -> str:
|
||||
"""
|
||||
Parse a shortcut string and return the modifier key name.
|
||||
|
||||
Args:
|
||||
shortcut_string: The shortcut string (e.g., "ctrl+ctrl", "alt+alt")
|
||||
|
||||
Returns:
|
||||
The 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:
|
||||
raise ValueError(
|
||||
f"Unsupported shortcut: {shortcut_string}. "
|
||||
f"Supported shortcuts: {', '.join(SUPPORTED_SHORTCUTS.keys())}"
|
||||
)
|
||||
return SUPPORTED_SHORTCUTS[shortcut_lower]
|
||||
|
||||
|
||||
class KeyboardBackend(ABC):
|
||||
"""
|
||||
@@ -17,10 +57,37 @@ class KeyboardBackend(ABC):
|
||||
event listening and registering callbacks for specific shortcuts.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize the keyboard backend."""
|
||||
def __init__(self, shortcut: str = DEFAULT_SHORTCUT):
|
||||
"""
|
||||
Initialize the keyboard backend.
|
||||
|
||||
Args:
|
||||
shortcut: The shortcut string to listen for (e.g., "ctrl+ctrl")
|
||||
"""
|
||||
self.active = False
|
||||
self.double_tap_callback: Optional[Callable] = None
|
||||
self._shortcut = shortcut
|
||||
self._modifier_key = parse_shortcut(shortcut)
|
||||
|
||||
@property
|
||||
def shortcut(self) -> str:
|
||||
"""Get the current shortcut string."""
|
||||
return self._shortcut
|
||||
|
||||
@property
|
||||
def modifier_key(self) -> str:
|
||||
"""Get the modifier key being watched for double-tap."""
|
||||
return self._modifier_key
|
||||
|
||||
def set_shortcut(self, shortcut: str) -> None:
|
||||
"""
|
||||
Update the shortcut to listen for.
|
||||
|
||||
Args:
|
||||
shortcut: The new shortcut string (e.g., "ctrl+ctrl", "alt+alt")
|
||||
"""
|
||||
self._modifier_key = parse_shortcut(shortcut)
|
||||
self._shortcut = shortcut
|
||||
|
||||
@abstractmethod
|
||||
def start(self) -> bool:
|
||||
@@ -59,9 +126,9 @@ class KeyboardBackend(ABC):
|
||||
|
||||
def register_toggle_callback(self, callback: Callable) -> None:
|
||||
"""
|
||||
Register a callback for the double-tap Ctrl shortcut.
|
||||
Register a callback for the double-tap shortcut.
|
||||
|
||||
Args:
|
||||
callback: Function to call when double-tap Ctrl is detected
|
||||
callback: Function to call when double-tap is detected
|
||||
"""
|
||||
self.double_tap_callback = callback
|
||||
|
||||
@@ -11,7 +11,7 @@ import os
|
||||
import select
|
||||
import threading
|
||||
import time
|
||||
from typing import List, Optional, Set
|
||||
from typing import Dict, List, Optional, Set
|
||||
|
||||
# Try to import evdev
|
||||
try:
|
||||
@@ -25,15 +25,28 @@ except ImportError:
|
||||
ecodes = None # type: ignore
|
||||
EVDEV_AVAILABLE = False
|
||||
|
||||
from .base import KeyboardBackend
|
||||
from .base import DEFAULT_SHORTCUT, KeyboardBackend
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# Key code for Ctrl (left and right)
|
||||
# Key codes for modifier keys (left and right variants)
|
||||
KEY_LEFTCTRL = 29
|
||||
KEY_RIGHTCTRL = 97
|
||||
KEY_CTRL = {KEY_LEFTCTRL, KEY_RIGHTCTRL}
|
||||
KEY_LEFTALT = 56
|
||||
KEY_RIGHTALT = 100
|
||||
KEY_LEFTSHIFT = 42
|
||||
KEY_RIGHTSHIFT = 54
|
||||
KEY_LEFTMETA = 125 # Super/Windows key
|
||||
KEY_RIGHTMETA = 126
|
||||
|
||||
# Map modifier key names to evdev key codes
|
||||
MODIFIER_KEY_CODES: Dict[str, Set[int]] = {
|
||||
"ctrl": {KEY_LEFTCTRL, KEY_RIGHTCTRL},
|
||||
"alt": {KEY_LEFTALT, KEY_RIGHTALT},
|
||||
"shift": {KEY_LEFTSHIFT, KEY_RIGHTSHIFT},
|
||||
"super": {KEY_LEFTMETA, KEY_RIGHTMETA},
|
||||
}
|
||||
|
||||
|
||||
def find_keyboard_devices() -> List[str]:
|
||||
@@ -76,30 +89,36 @@ def find_keyboard_devices() -> List[str]:
|
||||
return keyboard_devices
|
||||
|
||||
|
||||
def device_has_ctrl_key(device_path: str) -> bool:
|
||||
def device_has_modifier_key(device_path: str, modifier: str = "ctrl") -> bool:
|
||||
"""
|
||||
Check if a device has Ctrl key capability.
|
||||
Check if a device has a specific modifier key capability.
|
||||
|
||||
Args:
|
||||
device_path: Path to the input device
|
||||
modifier: The modifier key name ("ctrl", "alt", "shift", "super")
|
||||
|
||||
Returns:
|
||||
True if the device can send Ctrl key events
|
||||
True if the device can send the specified modifier key events
|
||||
"""
|
||||
if not EVDEV_AVAILABLE:
|
||||
return False
|
||||
|
||||
key_codes = MODIFIER_KEY_CODES.get(modifier, set())
|
||||
if not key_codes:
|
||||
return False
|
||||
|
||||
try:
|
||||
device = InputDevice(device_path)
|
||||
capabilities = device.capabilities()
|
||||
device.close()
|
||||
|
||||
# Check if device has EV_KEY capability and supports Ctrl keys
|
||||
# Check if device has EV_KEY capability and supports the modifier keys
|
||||
if ecodes.EV_KEY in capabilities:
|
||||
key_caps = capabilities[ecodes.EV_KEY]
|
||||
# Check for left or right Ctrl key
|
||||
if KEY_LEFTCTRL in key_caps or KEY_RIGHTCTRL in key_caps:
|
||||
return True
|
||||
# Check for left or right variant of the modifier
|
||||
for key_code in key_codes:
|
||||
if key_code in key_caps:
|
||||
return True
|
||||
except (OSError, IOError):
|
||||
pass
|
||||
|
||||
@@ -115,39 +134,48 @@ class EvdevKeyboardBackend(KeyboardBackend):
|
||||
to read from /dev/input/event* devices (member of 'input' group).
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize the evdev keyboard backend."""
|
||||
super().__init__()
|
||||
def __init__(self, shortcut: str = DEFAULT_SHORTCUT):
|
||||
"""
|
||||
Initialize the evdev keyboard backend.
|
||||
|
||||
Args:
|
||||
shortcut: The shortcut string to listen for (e.g., "ctrl+ctrl")
|
||||
"""
|
||||
super().__init__(shortcut)
|
||||
self.devices: List[InputDevice] = []
|
||||
self.device_fds: List[int] = []
|
||||
self.running = False
|
||||
self.monitor_thread: Optional[threading.Thread] = None
|
||||
|
||||
self.last_trigger_time = 0
|
||||
self.last_ctrl_press_time = 0
|
||||
self.last_key_press_time = 0
|
||||
self.double_tap_threshold = 0.3 # seconds
|
||||
self.ctrl_pressed_devices: Set[int] = set()
|
||||
self.key_pressed_devices: Set[int] = set()
|
||||
|
||||
if not EVDEV_AVAILABLE:
|
||||
logger.error("python-evdev not available")
|
||||
|
||||
def _get_target_key_codes(self) -> Set[int]:
|
||||
"""Get the evdev key codes for the configured modifier."""
|
||||
return MODIFIER_KEY_CODES.get(self._modifier_key, set())
|
||||
|
||||
def is_available(self) -> bool:
|
||||
"""Check if evdev is available and we can access a keyboard device with Ctrl key."""
|
||||
"""Check if evdev is available and we can access a keyboard device with the modifier key."""
|
||||
if not EVDEV_AVAILABLE:
|
||||
return False
|
||||
|
||||
# Check if we can access at least one keyboard device with Ctrl key capability
|
||||
# Check if we can access at least one keyboard device with the modifier key capability
|
||||
try:
|
||||
devices = find_keyboard_devices()
|
||||
if not devices:
|
||||
return False
|
||||
|
||||
# Try to find at least one device with Ctrl key that we can open
|
||||
# Try to find at least one device with the modifier key that we can open
|
||||
for device_path in devices:
|
||||
if device_has_ctrl_key(device_path):
|
||||
if device_has_modifier_key(device_path, self._modifier_key):
|
||||
return True
|
||||
|
||||
# Could not find any accessible device with Ctrl key
|
||||
# Could not find any accessible device with the modifier key
|
||||
return False
|
||||
except Exception:
|
||||
return False
|
||||
@@ -204,11 +232,12 @@ class EvdevKeyboardBackend(KeyboardBackend):
|
||||
return False
|
||||
|
||||
logger.info(f"Found {len(device_paths)} keyboard device(s)")
|
||||
logger.info(f"Listening for shortcut: {self._shortcut}")
|
||||
|
||||
# Open devices
|
||||
self.devices = []
|
||||
self.device_fds = []
|
||||
self.ctrl_pressed_devices = set()
|
||||
self.key_pressed_devices = set()
|
||||
|
||||
for device_path in device_paths:
|
||||
try:
|
||||
@@ -303,28 +332,30 @@ class EvdevKeyboardBackend(KeyboardBackend):
|
||||
code = event.code
|
||||
value = event.value # 0 = release, 1 = press, 2 = repeat
|
||||
|
||||
# Check if this is a Ctrl key
|
||||
if code in KEY_CTRL:
|
||||
target_codes = self._get_target_key_codes()
|
||||
|
||||
# Check if this is our target modifier key
|
||||
if code in target_codes:
|
||||
device_id = id(device)
|
||||
|
||||
if value == 1: # Key press
|
||||
self.ctrl_pressed_devices.add(device_id)
|
||||
self.key_pressed_devices.add(device_id)
|
||||
current_time = time.time()
|
||||
|
||||
# Check for double-tap
|
||||
if (
|
||||
current_time - self.last_ctrl_press_time < self.double_tap_threshold
|
||||
current_time - self.last_key_press_time < self.double_tap_threshold
|
||||
and self.double_tap_callback is not None
|
||||
and current_time - self.last_trigger_time > 0.5
|
||||
):
|
||||
logger.debug("Double-tap Ctrl detected (evdev)")
|
||||
logger.debug(f"Double-tap {self._modifier_key} detected (evdev)")
|
||||
self.last_trigger_time = current_time
|
||||
threading.Thread(target=self.double_tap_callback, daemon=True).start()
|
||||
|
||||
self.last_ctrl_press_time = current_time
|
||||
self.last_key_press_time = current_time
|
||||
|
||||
elif value == 0: # Key release
|
||||
self.ctrl_pressed_devices.discard(device_id)
|
||||
self.key_pressed_devices.discard(device_id)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error handling key event: {e}")
|
||||
@@ -335,5 +366,5 @@ __all__ = [
|
||||
"EvdevKeyboardBackend",
|
||||
"EVDEV_AVAILABLE",
|
||||
"find_keyboard_devices",
|
||||
"device_has_ctrl_key",
|
||||
"device_has_modifier_key",
|
||||
]
|
||||
|
||||
@@ -19,11 +19,34 @@ except ImportError:
|
||||
keyboard = None # type: ignore
|
||||
PYNPUT_AVAILABLE = False
|
||||
|
||||
from .base import KeyboardBackend
|
||||
from .base import DEFAULT_SHORTCUT, KeyboardBackend
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# Map modifier key names to pynput Key objects
|
||||
MODIFIER_KEY_MAP = {
|
||||
"ctrl": keyboard.Key.ctrl if PYNPUT_AVAILABLE else None,
|
||||
"alt": keyboard.Key.alt if PYNPUT_AVAILABLE else None,
|
||||
"shift": keyboard.Key.shift if PYNPUT_AVAILABLE else None,
|
||||
"super": keyboard.Key.cmd if PYNPUT_AVAILABLE else None, # cmd is Super/Windows key
|
||||
}
|
||||
|
||||
# Map for normalizing left/right variants
|
||||
MODIFIER_NORMALIZE_MAP = {}
|
||||
if PYNPUT_AVAILABLE:
|
||||
MODIFIER_NORMALIZE_MAP = {
|
||||
keyboard.Key.ctrl_l: keyboard.Key.ctrl,
|
||||
keyboard.Key.ctrl_r: keyboard.Key.ctrl,
|
||||
keyboard.Key.alt_l: keyboard.Key.alt,
|
||||
keyboard.Key.alt_r: keyboard.Key.alt,
|
||||
keyboard.Key.shift_l: keyboard.Key.shift,
|
||||
keyboard.Key.shift_r: keyboard.Key.shift,
|
||||
keyboard.Key.cmd_l: keyboard.Key.cmd,
|
||||
keyboard.Key.cmd_r: keyboard.Key.cmd,
|
||||
}
|
||||
|
||||
|
||||
class PynputKeyboardBackend(KeyboardBackend):
|
||||
"""
|
||||
Keyboard backend using pynput library.
|
||||
@@ -32,18 +55,27 @@ class PynputKeyboardBackend(KeyboardBackend):
|
||||
due to Wayland's security restrictions.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize the pynput keyboard backend."""
|
||||
super().__init__()
|
||||
def __init__(self, shortcut: str = DEFAULT_SHORTCUT):
|
||||
"""
|
||||
Initialize the pynput keyboard backend.
|
||||
|
||||
Args:
|
||||
shortcut: The shortcut string to listen for (e.g., "ctrl+ctrl")
|
||||
"""
|
||||
super().__init__(shortcut)
|
||||
self.listener = None
|
||||
self.last_trigger_time = 0
|
||||
self.last_ctrl_press_time = 0
|
||||
self.last_key_press_time = 0
|
||||
self.double_tap_threshold = 0.3 # seconds
|
||||
self.current_keys = set()
|
||||
|
||||
if not PYNPUT_AVAILABLE:
|
||||
logger.error("pynput library not available")
|
||||
|
||||
def _get_target_key(self):
|
||||
"""Get the pynput Key object for the configured modifier."""
|
||||
return MODIFIER_KEY_MAP.get(self._modifier_key)
|
||||
|
||||
def is_available(self) -> bool:
|
||||
"""Check if pynput is available."""
|
||||
return PYNPUT_AVAILABLE
|
||||
@@ -71,7 +103,7 @@ class PynputKeyboardBackend(KeyboardBackend):
|
||||
if self.active:
|
||||
return True
|
||||
|
||||
logger.info("Starting pynput keyboard listener")
|
||||
logger.info(f"Starting pynput keyboard listener for shortcut: {self._shortcut}")
|
||||
self.current_keys = set()
|
||||
|
||||
try:
|
||||
@@ -112,20 +144,22 @@ class PynputKeyboardBackend(KeyboardBackend):
|
||||
"""Handle key press events."""
|
||||
try:
|
||||
normalized_key = self._normalize_modifier_key(key)
|
||||
if normalized_key == keyboard.Key.ctrl:
|
||||
target_key = self._get_target_key()
|
||||
|
||||
if normalized_key == target_key:
|
||||
current_time = time.time()
|
||||
if (
|
||||
current_time - self.last_ctrl_press_time < self.double_tap_threshold
|
||||
current_time - self.last_key_press_time < self.double_tap_threshold
|
||||
and self.double_tap_callback is not None
|
||||
and current_time - self.last_trigger_time > 0.5
|
||||
):
|
||||
logger.debug("Double-tap Ctrl detected (pynput)")
|
||||
logger.debug(f"Double-tap {self._modifier_key} detected (pynput)")
|
||||
self.last_trigger_time = current_time
|
||||
threading.Thread(target=self.double_tap_callback, daemon=True).start()
|
||||
self.last_ctrl_press_time = current_time
|
||||
self.last_key_press_time = current_time
|
||||
|
||||
if key in {keyboard.Key.ctrl, keyboard.Key.ctrl_l, keyboard.Key.ctrl_r}:
|
||||
normalized_key = self._normalize_modifier_key(key)
|
||||
# Track current keys (using target key for reference)
|
||||
if target_key and key in self._get_key_variants(self._modifier_key):
|
||||
self.current_keys.add(normalized_key)
|
||||
|
||||
except Exception as e:
|
||||
@@ -141,17 +175,20 @@ class PynputKeyboardBackend(KeyboardBackend):
|
||||
|
||||
def _normalize_modifier_key(self, key):
|
||||
"""Normalize left/right variants of modifier keys."""
|
||||
key_mapping = {
|
||||
keyboard.Key.alt_l: keyboard.Key.alt,
|
||||
keyboard.Key.alt_r: keyboard.Key.alt,
|
||||
keyboard.Key.shift_l: keyboard.Key.shift,
|
||||
keyboard.Key.shift_r: keyboard.Key.shift,
|
||||
keyboard.Key.ctrl_l: keyboard.Key.ctrl,
|
||||
keyboard.Key.ctrl_r: keyboard.Key.ctrl,
|
||||
keyboard.Key.cmd_l: keyboard.Key.cmd,
|
||||
keyboard.Key.cmd_r: keyboard.Key.cmd,
|
||||
return MODIFIER_NORMALIZE_MAP.get(key, key)
|
||||
|
||||
def _get_key_variants(self, modifier_name: str):
|
||||
"""Get all key variants for a modifier name."""
|
||||
if not PYNPUT_AVAILABLE:
|
||||
return set()
|
||||
|
||||
variants = {
|
||||
"ctrl": {keyboard.Key.ctrl, keyboard.Key.ctrl_l, keyboard.Key.ctrl_r},
|
||||
"alt": {keyboard.Key.alt, keyboard.Key.alt_l, keyboard.Key.alt_r},
|
||||
"shift": {keyboard.Key.shift, keyboard.Key.shift_l, keyboard.Key.shift_r},
|
||||
"super": {keyboard.Key.cmd, keyboard.Key.cmd_l, keyboard.Key.cmd_r},
|
||||
}
|
||||
return key_mapping.get(key, key)
|
||||
return variants.get(modifier_name, set())
|
||||
|
||||
|
||||
# Export availability
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
Keyboard shortcut manager for Vocalinux.
|
||||
|
||||
This module provides global keyboard shortcut functionality to
|
||||
start/stop speech recognition with a double-tap of the Ctrl key.
|
||||
start/stop speech recognition with configurable double-tap shortcuts.
|
||||
|
||||
Supports multiple backends:
|
||||
- pynput: Works on X11/XWayland
|
||||
@@ -13,7 +13,15 @@ import logging
|
||||
from typing import Callable, Optional
|
||||
|
||||
# Import the backend system
|
||||
from .keyboard_backends import EVDEV_AVAILABLE, PYNPUT_AVAILABLE, DesktopEnvironment, create_backend
|
||||
from .keyboard_backends import (
|
||||
DEFAULT_SHORTCUT,
|
||||
EVDEV_AVAILABLE,
|
||||
PYNPUT_AVAILABLE,
|
||||
SHORTCUT_DISPLAY_NAMES,
|
||||
SUPPORTED_SHORTCUTS,
|
||||
DesktopEnvironment,
|
||||
create_backend,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -41,26 +49,28 @@ class KeyboardShortcutManager:
|
||||
"""
|
||||
Manages global keyboard shortcuts for the application.
|
||||
|
||||
This class allows registering the double-tap Ctrl shortcut to
|
||||
This class allows registering configurable double-tap shortcuts to
|
||||
toggle voice typing on and off across the desktop environment.
|
||||
|
||||
Automatically selects the appropriate backend based on the
|
||||
desktop environment (X11, Wayland) and available dependencies.
|
||||
"""
|
||||
|
||||
def __init__(self, backend: Optional[str] = None):
|
||||
def __init__(self, backend: Optional[str] = None, shortcut: str = DEFAULT_SHORTCUT):
|
||||
"""
|
||||
Initialize the keyboard shortcut manager.
|
||||
|
||||
Args:
|
||||
backend: Optional backend name to force ('pynput' or 'evdev')
|
||||
If not specified, auto-detects based on environment.
|
||||
shortcut: The shortcut to listen for (e.g., "ctrl+ctrl", "alt+alt")
|
||||
"""
|
||||
self.backend_instance = None
|
||||
self.active = False
|
||||
self._shortcut = shortcut
|
||||
|
||||
# Create the appropriate backend
|
||||
self.backend_instance = create_backend(preferred_backend=backend)
|
||||
self.backend_instance = create_backend(preferred_backend=backend, shortcut=shortcut)
|
||||
|
||||
if self.backend_instance is None:
|
||||
logger.error("No keyboard backend available. Shortcuts will not work.")
|
||||
@@ -85,6 +95,102 @@ class KeyboardShortcutManager:
|
||||
logger.warning("Keyboard shortcuts require pynput or evdev:")
|
||||
logger.warning(" pip install pynput evdev")
|
||||
|
||||
@property
|
||||
def shortcut(self) -> str:
|
||||
"""Get the current shortcut string."""
|
||||
return self._shortcut
|
||||
|
||||
@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)
|
||||
|
||||
def set_shortcut(self, shortcut: str) -> bool:
|
||||
"""
|
||||
Update the shortcut to listen for.
|
||||
|
||||
Note: This only updates the internal state. Use restart_with_shortcut()
|
||||
to apply changes to an active listener.
|
||||
|
||||
Args:
|
||||
shortcut: The new shortcut string (e.g., "ctrl+ctrl", "alt+alt")
|
||||
|
||||
Returns:
|
||||
True if successful, False if the shortcut is invalid
|
||||
"""
|
||||
if shortcut not in SUPPORTED_SHORTCUTS:
|
||||
logger.error(f"Invalid shortcut: {shortcut}")
|
||||
return False
|
||||
|
||||
self._shortcut = shortcut
|
||||
|
||||
if self.backend_instance:
|
||||
self.backend_instance.set_shortcut(shortcut)
|
||||
logger.info(f"Shortcut updated to: {SHORTCUT_DISPLAY_NAMES.get(shortcut, shortcut)}")
|
||||
|
||||
return True
|
||||
|
||||
def restart_with_shortcut(self, shortcut: str) -> bool:
|
||||
"""
|
||||
Restart the keyboard listener with a new shortcut.
|
||||
|
||||
This stops the current listener, updates the shortcut configuration,
|
||||
and starts a new listener with the new shortcut. This enables live
|
||||
shortcut switching without requiring an application restart.
|
||||
|
||||
Args:
|
||||
shortcut: The new shortcut string (e.g., "ctrl+ctrl", "alt+alt")
|
||||
|
||||
Returns:
|
||||
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:
|
||||
logger.error(f"Invalid shortcut: {shortcut}")
|
||||
return False
|
||||
|
||||
if shortcut == self._shortcut:
|
||||
logger.debug(f"Shortcut already set to {shortcut}, no restart needed")
|
||||
return True
|
||||
|
||||
was_active = self.active
|
||||
callback = None
|
||||
|
||||
# Save the current callback before stopping
|
||||
if self.backend_instance:
|
||||
callback = self.backend_instance.double_tap_callback
|
||||
|
||||
# Stop the current listener
|
||||
if was_active:
|
||||
logger.info(f"Stopping listener for shortcut change: {self._shortcut} -> {shortcut}")
|
||||
self.stop()
|
||||
|
||||
# Update the shortcut
|
||||
self._shortcut = shortcut
|
||||
|
||||
# Update backend shortcut
|
||||
if self.backend_instance:
|
||||
self.backend_instance.set_shortcut(shortcut)
|
||||
|
||||
# Restart if it was active
|
||||
if was_active:
|
||||
# Re-register the callback
|
||||
if callback:
|
||||
self.register_toggle_callback(callback)
|
||||
|
||||
success = self.start()
|
||||
if success:
|
||||
logger.info(
|
||||
f"Listener restarted with new shortcut: "
|
||||
f"{SHORTCUT_DISPLAY_NAMES.get(shortcut, shortcut)}"
|
||||
)
|
||||
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)}")
|
||||
return True
|
||||
|
||||
def start(self) -> bool:
|
||||
"""
|
||||
Start listening for keyboard shortcuts.
|
||||
@@ -98,7 +204,7 @@ class KeyboardShortcutManager:
|
||||
if self.active:
|
||||
return True
|
||||
|
||||
logger.info("Starting keyboard shortcut listener")
|
||||
logger.info(f"Starting keyboard shortcut listener for: {self.shortcut_display_name}")
|
||||
self.active = self.backend_instance.start()
|
||||
|
||||
if not self.active:
|
||||
@@ -119,17 +225,17 @@ class KeyboardShortcutManager:
|
||||
|
||||
def register_toggle_callback(self, callback: Callable):
|
||||
"""
|
||||
Register a callback for the double-tap Ctrl shortcut.
|
||||
Register a callback for the double-tap shortcut.
|
||||
|
||||
Args:
|
||||
callback: Function to call when the double-tap Ctrl is pressed
|
||||
callback: Function to call when the double-tap shortcut is pressed
|
||||
"""
|
||||
if self.backend_instance is None:
|
||||
logger.warning("Cannot register callback: no backend available")
|
||||
return
|
||||
|
||||
self.backend_instance.register_toggle_callback(callback)
|
||||
logger.info("Registered shortcut: Double-tap Ctrl")
|
||||
logger.info(f"Registered shortcut: {self.shortcut_display_name}")
|
||||
|
||||
@property
|
||||
def listener(self):
|
||||
@@ -173,4 +279,7 @@ __all__ = [
|
||||
"DesktopEnvironment",
|
||||
"EVDEV_AVAILABLE",
|
||||
"PYNPUT_AVAILABLE",
|
||||
"SUPPORTED_SHORTCUTS",
|
||||
"SHORTCUT_DISPLAY_NAMES",
|
||||
"DEFAULT_SHORTCUT",
|
||||
]
|
||||
|
||||
@@ -29,6 +29,7 @@ from gi.repository import Gdk, GLib, Gtk, Pango # noqa: E402
|
||||
|
||||
from ..common_types import RecognitionState # noqa: E402
|
||||
from ..utils.vosk_model_info import SUPPORTED_LANGUAGES, VOSK_MODEL_INFO # noqa: E402
|
||||
from .keyboard_backends import SHORTCUT_DISPLAY_NAMES, SUPPORTED_SHORTCUTS # noqa: E402
|
||||
|
||||
# Avoid circular imports for type checking
|
||||
if TYPE_CHECKING:
|
||||
@@ -588,10 +589,12 @@ class SettingsDialog(Gtk.Dialog):
|
||||
parent: Gtk.Window,
|
||||
config_manager: "ConfigManager",
|
||||
speech_engine: "SpeechRecognitionManager",
|
||||
shortcut_update_callback: callable = None,
|
||||
):
|
||||
super().__init__(title="Vocalinux Settings", transient_for=parent, flags=0)
|
||||
self.config_manager = config_manager
|
||||
self.speech_engine = speech_engine
|
||||
self.shortcut_update_callback = shortcut_update_callback
|
||||
self._test_active = False
|
||||
self._test_result = ""
|
||||
self._initializing = True # Flag to prevent auto-apply during initialization
|
||||
@@ -628,6 +631,7 @@ class SettingsDialog(Gtk.Dialog):
|
||||
self._build_audio_section()
|
||||
self._build_engine_section()
|
||||
self._build_recognition_section()
|
||||
self._build_shortcuts_section()
|
||||
self._build_test_section()
|
||||
|
||||
# Load settings and populate UI
|
||||
@@ -820,6 +824,95 @@ class SettingsDialog(Gtk.Dialog):
|
||||
self.vad_spin.connect("value-changed", self._on_vad_changed)
|
||||
self.silence_spin.connect("value-changed", self._on_silence_changed)
|
||||
|
||||
def _build_shortcuts_section(self):
|
||||
"""Build the Keyboard Shortcuts section."""
|
||||
group = PreferencesGroup(
|
||||
title="Keyboard Shortcuts",
|
||||
description="Configure the shortcut to toggle voice recognition",
|
||||
)
|
||||
|
||||
# Shortcut selection combo
|
||||
self.shortcut_combo = Gtk.ComboBoxText()
|
||||
self.shortcut_combo.set_size_request(200, -1)
|
||||
self.shortcut_combo.set_tooltip_text("Select the keyboard shortcut to toggle voice typing")
|
||||
|
||||
# Populate shortcut options
|
||||
for shortcut_id, display_name in SHORTCUT_DISPLAY_NAMES.items():
|
||||
self.shortcut_combo.append(shortcut_id, display_name)
|
||||
|
||||
# Load current shortcut from config
|
||||
current_shortcut = self.config_manager.get("shortcuts", "toggle_recognition", "ctrl+ctrl")
|
||||
if not self.shortcut_combo.set_active_id(current_shortcut):
|
||||
self.shortcut_combo.set_active_id("ctrl+ctrl")
|
||||
|
||||
shortcut_row = PreferenceRow(
|
||||
title="Toggle Recognition",
|
||||
subtitle="Press this shortcut twice quickly to start/stop",
|
||||
widget=self.shortcut_combo,
|
||||
)
|
||||
group.add_row(shortcut_row)
|
||||
|
||||
self.content_box.pack_start(group, False, False, 0)
|
||||
|
||||
# Info box about the shortcut
|
||||
info_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
|
||||
info_box.get_style_context().add_class("info-box")
|
||||
info_box.set_margin_start(4)
|
||||
info_box.set_margin_end(4)
|
||||
info_box.set_margin_top(4)
|
||||
|
||||
info_icon = Gtk.Image.new_from_icon_name("dialog-information-symbolic", Gtk.IconSize.MENU)
|
||||
info_box.pack_start(info_icon, False, False, 0)
|
||||
|
||||
self.shortcut_info_label = Gtk.Label(
|
||||
label="Changes take effect immediately. Double-tap the key to toggle voice typing.",
|
||||
xalign=0,
|
||||
wrap=True,
|
||||
)
|
||||
self.shortcut_info_label.get_style_context().add_class("tip-label")
|
||||
info_box.pack_start(self.shortcut_info_label, True, True, 0)
|
||||
|
||||
self.content_box.pack_start(info_box, False, False, 0)
|
||||
|
||||
# Connect signal
|
||||
self.shortcut_combo.connect("changed", self._on_shortcut_changed)
|
||||
|
||||
def _on_shortcut_changed(self, widget):
|
||||
"""Handle shortcut selection change."""
|
||||
if self._initializing:
|
||||
return
|
||||
|
||||
shortcut_id = self.shortcut_combo.get_active_id()
|
||||
if not shortcut_id:
|
||||
return
|
||||
|
||||
# Save to config
|
||||
self.config_manager.set("shortcuts", "toggle_recognition", shortcut_id)
|
||||
self.config_manager.save_settings()
|
||||
|
||||
display_name = SHORTCUT_DISPLAY_NAMES.get(shortcut_id, shortcut_id)
|
||||
logger.info(f"Keyboard shortcut changed to: {display_name}")
|
||||
|
||||
# Try to apply the shortcut change live
|
||||
if self.shortcut_update_callback:
|
||||
success = self.shortcut_update_callback(shortcut_id)
|
||||
if success:
|
||||
self.shortcut_info_label.set_markup(
|
||||
f"<span foreground='#26a269'>Shortcut updated to <b>{display_name}</b>. "
|
||||
f"Active now!</span>"
|
||||
)
|
||||
else:
|
||||
self.shortcut_info_label.set_markup(
|
||||
f"<i>Shortcut updated to <b>{display_name}</b>. "
|
||||
f"Restart the app for the change to take full effect.</i>"
|
||||
)
|
||||
else:
|
||||
# No callback provided, fall back to restart message
|
||||
self.shortcut_info_label.set_markup(
|
||||
f"<i>Shortcut updated to <b>{display_name}</b>. "
|
||||
f"Restart the app for the change to take full effect.</i>"
|
||||
)
|
||||
|
||||
def _build_test_section(self):
|
||||
"""Build the Test Recognition section."""
|
||||
group = PreferencesGroup(title="Test Recognition")
|
||||
|
||||
@@ -78,10 +78,11 @@ class TrayIndicator:
|
||||
self.text_injector = text_injector
|
||||
self.config_manager = ConfigManager() # Added: Initialize ConfigManager
|
||||
|
||||
# Initialize keyboard shortcut manager
|
||||
self.shortcut_manager = (
|
||||
KeyboardShortcutManager()
|
||||
) # Pass config_manager - Removed config_manager argument
|
||||
# Get configured shortcut from config
|
||||
shortcut = self.config_manager.get("shortcuts", "toggle_recognition", "ctrl+ctrl")
|
||||
|
||||
# Initialize keyboard shortcut manager with configured shortcut
|
||||
self.shortcut_manager = KeyboardShortcutManager(shortcut=shortcut)
|
||||
|
||||
# Ensure icon directory exists
|
||||
os.makedirs(ICON_DIR, exist_ok=True)
|
||||
@@ -278,6 +279,7 @@ class TrayIndicator:
|
||||
parent=None, # Or get the main window if available
|
||||
config_manager=self.config_manager,
|
||||
speech_engine=self.speech_engine,
|
||||
shortcut_update_callback=self.update_shortcut,
|
||||
)
|
||||
|
||||
# Connect to the response signal
|
||||
@@ -304,6 +306,20 @@ class TrayIndicator:
|
||||
logger.info("Settings dialog closed.")
|
||||
dialog.destroy()
|
||||
|
||||
def update_shortcut(self, shortcut: str) -> bool:
|
||||
"""
|
||||
Update the keyboard shortcut for toggling voice recognition.
|
||||
|
||||
This performs a live shortcut switch without requiring an app restart.
|
||||
|
||||
Args:
|
||||
shortcut: The new shortcut string (e.g., "ctrl+ctrl", "alt+alt")
|
||||
|
||||
Returns:
|
||||
True if the shortcut was updated successfully, False otherwise
|
||||
"""
|
||||
return self.shortcut_manager.restart_with_shortcut(shortcut)
|
||||
|
||||
def _on_about_clicked(self, widget):
|
||||
"""Handle click on the About menu item."""
|
||||
from .about_dialog import show_about_dialog
|
||||
|
||||
@@ -6,7 +6,12 @@ import unittest
|
||||
from unittest.mock import MagicMock, Mock, patch
|
||||
|
||||
# Update import to use the new package structure
|
||||
from vocalinux.ui.keyboard_shortcuts import KeyboardShortcutManager
|
||||
from vocalinux.ui.keyboard_shortcuts import (
|
||||
DEFAULT_SHORTCUT,
|
||||
SHORTCUT_DISPLAY_NAMES,
|
||||
SUPPORTED_SHORTCUTS,
|
||||
KeyboardShortcutManager,
|
||||
)
|
||||
|
||||
|
||||
class TestKeyboardShortcuts(unittest.TestCase):
|
||||
@@ -23,6 +28,7 @@ class TestKeyboardShortcuts(unittest.TestCase):
|
||||
self.mock_backend.active = False
|
||||
self.mock_backend.double_tap_callback = None
|
||||
self.mock_backend.start.return_value = True
|
||||
self.mock_backend.shortcut = "ctrl+ctrl"
|
||||
self.mock_create_backend.return_value = self.mock_backend
|
||||
|
||||
# Create a new KSM for each test
|
||||
@@ -38,6 +44,134 @@ class TestKeyboardShortcuts(unittest.TestCase):
|
||||
self.mock_create_backend.assert_called_once()
|
||||
self.assertIsNotNone(self.ksm.backend_instance)
|
||||
|
||||
def test_init_with_custom_shortcut(self):
|
||||
"""Test initialization with a custom shortcut."""
|
||||
# Create a new KSM with alt+alt shortcut
|
||||
ksm = KeyboardShortcutManager(shortcut="alt+alt")
|
||||
|
||||
# Verify the shortcut was passed to create_backend
|
||||
self.mock_create_backend.assert_called_with(preferred_backend=None, shortcut="alt+alt")
|
||||
|
||||
def test_default_shortcut(self):
|
||||
"""Test that default shortcut is ctrl+ctrl."""
|
||||
self.assertEqual(DEFAULT_SHORTCUT, "ctrl+ctrl")
|
||||
|
||||
def test_supported_shortcuts(self):
|
||||
"""Test that all expected shortcuts are supported."""
|
||||
expected_shortcuts = ["ctrl+ctrl", "alt+alt", "shift+shift", "super+super"]
|
||||
for shortcut in expected_shortcuts:
|
||||
self.assertIn(shortcut, SUPPORTED_SHORTCUTS)
|
||||
|
||||
def test_shortcut_display_names(self):
|
||||
"""Test that all shortcuts have display names."""
|
||||
for shortcut in SUPPORTED_SHORTCUTS:
|
||||
self.assertIn(shortcut, SHORTCUT_DISPLAY_NAMES)
|
||||
self.assertIsInstance(SHORTCUT_DISPLAY_NAMES[shortcut], str)
|
||||
self.assertTrue(len(SHORTCUT_DISPLAY_NAMES[shortcut]) > 0)
|
||||
|
||||
def test_shortcut_property(self):
|
||||
"""Test the shortcut property."""
|
||||
self.assertEqual(self.ksm.shortcut, "ctrl+ctrl")
|
||||
|
||||
def test_shortcut_display_name_property(self):
|
||||
"""Test the shortcut_display_name property."""
|
||||
display_name = self.ksm.shortcut_display_name
|
||||
self.assertEqual(display_name, SHORTCUT_DISPLAY_NAMES["ctrl+ctrl"])
|
||||
|
||||
def test_set_shortcut_valid(self):
|
||||
"""Test setting a valid shortcut."""
|
||||
result = self.ksm.set_shortcut("alt+alt")
|
||||
|
||||
self.assertTrue(result)
|
||||
self.assertEqual(self.ksm.shortcut, "alt+alt")
|
||||
self.mock_backend.set_shortcut.assert_called_once_with("alt+alt")
|
||||
|
||||
def test_set_shortcut_invalid(self):
|
||||
"""Test setting an invalid shortcut."""
|
||||
result = self.ksm.set_shortcut("invalid+shortcut")
|
||||
|
||||
self.assertFalse(result)
|
||||
self.assertEqual(self.ksm.shortcut, "ctrl+ctrl") # Should remain unchanged
|
||||
|
||||
def test_restart_with_shortcut_valid(self):
|
||||
"""Test restarting with a valid shortcut."""
|
||||
# Start the listener first
|
||||
self.ksm.start()
|
||||
callback = MagicMock()
|
||||
self.mock_backend.double_tap_callback = callback
|
||||
|
||||
# Restart with new shortcut
|
||||
result = self.ksm.restart_with_shortcut("alt+alt")
|
||||
|
||||
self.assertTrue(result)
|
||||
self.assertEqual(self.ksm.shortcut, "alt+alt")
|
||||
# Should have stopped and started
|
||||
self.mock_backend.stop.assert_called()
|
||||
self.mock_backend.start.assert_called()
|
||||
self.mock_backend.set_shortcut.assert_called_with("alt+alt")
|
||||
|
||||
def test_restart_with_shortcut_invalid(self):
|
||||
"""Test restarting with an invalid shortcut."""
|
||||
result = self.ksm.restart_with_shortcut("invalid+shortcut")
|
||||
|
||||
self.assertFalse(result)
|
||||
self.assertEqual(self.ksm.shortcut, "ctrl+ctrl") # Should remain unchanged
|
||||
|
||||
def test_restart_with_shortcut_same_shortcut(self):
|
||||
"""Test restarting with the same shortcut (no-op)."""
|
||||
self.ksm.start()
|
||||
|
||||
# Clear the mock to see if stop/start are called
|
||||
self.mock_backend.stop.reset_mock()
|
||||
self.mock_backend.start.reset_mock()
|
||||
|
||||
result = self.ksm.restart_with_shortcut("ctrl+ctrl")
|
||||
|
||||
self.assertTrue(result)
|
||||
# Should not have stopped or started since shortcut is the same
|
||||
self.mock_backend.stop.assert_not_called()
|
||||
|
||||
def test_restart_with_shortcut_when_not_active(self):
|
||||
"""Test restarting when listener was not active."""
|
||||
# Don't start the listener
|
||||
self.ksm.active = False
|
||||
|
||||
result = self.ksm.restart_with_shortcut("alt+alt")
|
||||
|
||||
self.assertTrue(result)
|
||||
self.assertEqual(self.ksm.shortcut, "alt+alt")
|
||||
# Should not have started since it wasn't active before
|
||||
self.mock_backend.start.assert_not_called()
|
||||
|
||||
def test_restart_with_shortcut_preserves_callback(self):
|
||||
"""Test that restart preserves the registered callback."""
|
||||
callback = MagicMock()
|
||||
self.mock_backend.double_tap_callback = callback
|
||||
|
||||
# Start the listener
|
||||
self.ksm.start()
|
||||
|
||||
# Restart with new shortcut
|
||||
self.ksm.restart_with_shortcut("shift+shift")
|
||||
|
||||
# Callback should have been re-registered
|
||||
self.mock_backend.register_toggle_callback.assert_called_with(callback)
|
||||
|
||||
def test_restart_with_shortcut_handles_start_failure(self):
|
||||
"""Test handling when restart fails to start."""
|
||||
# Start first
|
||||
self.ksm.start()
|
||||
self.ksm.active = True
|
||||
|
||||
# Make start return False on next call
|
||||
self.mock_backend.start.return_value = False
|
||||
|
||||
result = self.ksm.restart_with_shortcut("alt+alt")
|
||||
|
||||
self.assertFalse(result)
|
||||
# Shortcut should still be updated
|
||||
self.assertEqual(self.ksm.shortcut, "alt+alt")
|
||||
|
||||
def test_start_listener(self):
|
||||
"""Test starting the keyboard listener."""
|
||||
# Start the listener
|
||||
@@ -166,6 +300,12 @@ class TestPynputBackend(unittest.TestCase):
|
||||
self.mock_keyboard.Key.alt = Mock()
|
||||
self.mock_keyboard.Key.alt_l = Mock()
|
||||
self.mock_keyboard.Key.alt_r = Mock()
|
||||
self.mock_keyboard.Key.shift = Mock()
|
||||
self.mock_keyboard.Key.shift_l = Mock()
|
||||
self.mock_keyboard.Key.shift_r = Mock()
|
||||
self.mock_keyboard.Key.cmd = Mock()
|
||||
self.mock_keyboard.Key.cmd_l = Mock()
|
||||
self.mock_keyboard.Key.cmd_r = Mock()
|
||||
|
||||
# Create mock Listener
|
||||
self.mock_listener = MagicMock()
|
||||
@@ -218,6 +358,24 @@ class TestPynputBackend(unittest.TestCase):
|
||||
backend = PynputKeyboardBackend()
|
||||
self.assertIsNone(backend.get_permission_hint())
|
||||
|
||||
def test_pynput_backend_custom_shortcut(self):
|
||||
"""Test pynput backend with custom shortcut."""
|
||||
from vocalinux.ui.keyboard_backends.pynput_backend import PynputKeyboardBackend
|
||||
|
||||
backend = PynputKeyboardBackend(shortcut="alt+alt")
|
||||
self.assertEqual(backend.shortcut, "alt+alt")
|
||||
self.assertEqual(backend.modifier_key, "alt")
|
||||
|
||||
def test_pynput_backend_set_shortcut(self):
|
||||
"""Test changing shortcut on pynput backend."""
|
||||
from vocalinux.ui.keyboard_backends.pynput_backend import PynputKeyboardBackend
|
||||
|
||||
backend = PynputKeyboardBackend()
|
||||
backend.set_shortcut("shift+shift")
|
||||
|
||||
self.assertEqual(backend.shortcut, "shift+shift")
|
||||
self.assertEqual(backend.modifier_key, "shift")
|
||||
|
||||
|
||||
class TestEvdevBackend(unittest.TestCase):
|
||||
"""Test cases for the evdev backend."""
|
||||
@@ -358,6 +516,76 @@ class TestBackendFactory(unittest.TestCase):
|
||||
self.assertIsNotNone(result)
|
||||
MockEvdev.assert_called_once()
|
||||
|
||||
def test_create_backend_with_custom_shortcut(self):
|
||||
"""Test creating backend with custom shortcut."""
|
||||
from vocalinux.ui.keyboard_backends import create_backend
|
||||
|
||||
with patch("vocalinux.ui.keyboard_backends.PYNPUT_AVAILABLE", True):
|
||||
with patch(
|
||||
"vocalinux.ui.keyboard_backends.DesktopEnvironment.detect", return_value="x11"
|
||||
):
|
||||
with patch("vocalinux.ui.keyboard_backends.PynputKeyboardBackend") as MockPynput:
|
||||
mock_backend = MagicMock()
|
||||
MockPynput.return_value = mock_backend
|
||||
|
||||
result = create_backend(shortcut="alt+alt")
|
||||
|
||||
self.assertIsNotNone(result)
|
||||
MockPynput.assert_called_once_with(shortcut="alt+alt")
|
||||
|
||||
|
||||
class TestShortcutParseFunction(unittest.TestCase):
|
||||
"""Test cases for the parse_shortcut function."""
|
||||
|
||||
def test_parse_shortcut_ctrl(self):
|
||||
"""Test parsing ctrl+ctrl shortcut."""
|
||||
from vocalinux.ui.keyboard_backends.base import parse_shortcut
|
||||
|
||||
result = parse_shortcut("ctrl+ctrl")
|
||||
self.assertEqual(result, "ctrl")
|
||||
|
||||
def test_parse_shortcut_alt(self):
|
||||
"""Test parsing alt+alt shortcut."""
|
||||
from vocalinux.ui.keyboard_backends.base import parse_shortcut
|
||||
|
||||
result = parse_shortcut("alt+alt")
|
||||
self.assertEqual(result, "alt")
|
||||
|
||||
def test_parse_shortcut_shift(self):
|
||||
"""Test parsing shift+shift shortcut."""
|
||||
from vocalinux.ui.keyboard_backends.base import parse_shortcut
|
||||
|
||||
result = parse_shortcut("shift+shift")
|
||||
self.assertEqual(result, "shift")
|
||||
|
||||
def test_parse_shortcut_super(self):
|
||||
"""Test parsing super+super shortcut."""
|
||||
from vocalinux.ui.keyboard_backends.base import parse_shortcut
|
||||
|
||||
result = parse_shortcut("super+super")
|
||||
self.assertEqual(result, "super")
|
||||
|
||||
def test_parse_shortcut_case_insensitive(self):
|
||||
"""Test that shortcut parsing is case insensitive."""
|
||||
from vocalinux.ui.keyboard_backends.base import parse_shortcut
|
||||
|
||||
self.assertEqual(parse_shortcut("CTRL+CTRL"), "ctrl")
|
||||
self.assertEqual(parse_shortcut("Alt+Alt"), "alt")
|
||||
self.assertEqual(parse_shortcut("SHIFT+shift"), "shift")
|
||||
|
||||
def test_parse_shortcut_invalid(self):
|
||||
"""Test parsing invalid shortcut raises ValueError."""
|
||||
from vocalinux.ui.keyboard_backends.base import parse_shortcut
|
||||
|
||||
with self.assertRaises(ValueError):
|
||||
parse_shortcut("invalid+shortcut")
|
||||
|
||||
with self.assertRaises(ValueError):
|
||||
parse_shortcut("ctrl")
|
||||
|
||||
with self.assertRaises(ValueError):
|
||||
parse_shortcut("")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
"""
|
||||
Tests for keyboard shortcuts section in settings dialog.
|
||||
|
||||
Uses source code inspection to avoid GTK mocking issues.
|
||||
"""
|
||||
|
||||
import os
|
||||
import unittest
|
||||
|
||||
|
||||
def _get_source_code():
|
||||
"""Read the settings dialog source file."""
|
||||
source_path = os.path.join(
|
||||
os.path.dirname(__file__),
|
||||
"..",
|
||||
"src",
|
||||
"vocalinux",
|
||||
"ui",
|
||||
"settings_dialog.py",
|
||||
)
|
||||
with open(source_path, "r") as f:
|
||||
return f.read()
|
||||
|
||||
|
||||
class TestSettingsDialogShortcutsSection(unittest.TestCase):
|
||||
"""Test cases for keyboard shortcuts section in settings dialog."""
|
||||
|
||||
def setUp(self):
|
||||
"""Set up test fixtures."""
|
||||
self.source_code = _get_source_code()
|
||||
|
||||
def test_shortcuts_section_exists(self):
|
||||
"""Test that shortcuts section build method exists."""
|
||||
self.assertIn("def _build_shortcuts_section(self)", self.source_code)
|
||||
|
||||
def test_shortcuts_section_called_in_init(self):
|
||||
"""Test that shortcuts section is built in __init__."""
|
||||
self.assertIn("self._build_shortcuts_section()", self.source_code)
|
||||
|
||||
def test_shortcut_combo_widget_created(self):
|
||||
"""Test that shortcut combo box widget is created."""
|
||||
self.assertIn("self.shortcut_combo = Gtk.ComboBoxText()", self.source_code)
|
||||
|
||||
def test_shortcut_combo_size_request(self):
|
||||
"""Test that shortcut combo has size request set."""
|
||||
self.assertIn("self.shortcut_combo.set_size_request(200, -1)", self.source_code)
|
||||
|
||||
def test_shortcut_combo_tooltip(self):
|
||||
"""Test that shortcut combo has a tooltip."""
|
||||
self.assertIn("self.shortcut_combo.set_tooltip_text(", self.source_code)
|
||||
|
||||
def test_shortcut_options_populated(self):
|
||||
"""Test that shortcut options are populated from SHORTCUT_DISPLAY_NAMES."""
|
||||
self.assertIn(
|
||||
"for shortcut_id, display_name in SHORTCUT_DISPLAY_NAMES.items()", self.source_code
|
||||
)
|
||||
self.assertIn("self.shortcut_combo.append(shortcut_id, display_name)", self.source_code)
|
||||
|
||||
def test_shortcut_config_read(self):
|
||||
"""Test that shortcut is read from config."""
|
||||
self.assertIn('self.config_manager.get("shortcuts", "toggle_recognition"', self.source_code)
|
||||
|
||||
def test_shortcut_changed_handler_exists(self):
|
||||
"""Test that shortcut changed handler exists."""
|
||||
self.assertIn("def _on_shortcut_changed(self, widget)", self.source_code)
|
||||
|
||||
def test_shortcut_change_saved_to_config(self):
|
||||
"""Test that shortcut change is saved to config."""
|
||||
self.assertIn(
|
||||
'self.config_manager.set("shortcuts", "toggle_recognition", shortcut_id)',
|
||||
self.source_code,
|
||||
)
|
||||
|
||||
def test_shortcut_change_triggers_save(self):
|
||||
"""Test that shortcut change triggers config save."""
|
||||
# After setting the shortcut, save_settings should be called
|
||||
self.assertIn("self.config_manager.save_settings()", self.source_code)
|
||||
|
||||
def test_shortcut_preference_row_title(self):
|
||||
"""Test that preference row has correct title."""
|
||||
self.assertIn('title="Toggle Recognition"', self.source_code)
|
||||
|
||||
def test_shortcut_preference_row_subtitle(self):
|
||||
"""Test that preference row has descriptive subtitle."""
|
||||
self.assertIn("Press this shortcut twice quickly", self.source_code)
|
||||
|
||||
def test_shortcut_info_label_exists(self):
|
||||
"""Test that info label exists for user guidance."""
|
||||
self.assertIn("self.shortcut_info_label", self.source_code)
|
||||
|
||||
def test_shortcut_info_box_styling(self):
|
||||
"""Test that info box has proper styling."""
|
||||
self.assertIn('get_style_context().add_class("info-box")', self.source_code)
|
||||
|
||||
def test_shortcut_group_title(self):
|
||||
"""Test that shortcuts group has proper title."""
|
||||
self.assertIn('title="Keyboard Shortcuts"', self.source_code)
|
||||
|
||||
def test_imports_shortcut_constants(self):
|
||||
"""Test that shortcut constants are imported."""
|
||||
self.assertIn("from .keyboard_backends import SHORTCUT_DISPLAY_NAMES", self.source_code)
|
||||
self.assertIn("SUPPORTED_SHORTCUTS", self.source_code)
|
||||
|
||||
|
||||
class TestKeyboardBackendsBase(unittest.TestCase):
|
||||
"""Test cases for keyboard backends base module."""
|
||||
|
||||
def test_supported_shortcuts_defined(self):
|
||||
"""Test that SUPPORTED_SHORTCUTS is defined."""
|
||||
from vocalinux.ui.keyboard_backends.base import SUPPORTED_SHORTCUTS
|
||||
|
||||
self.assertIsInstance(SUPPORTED_SHORTCUTS, dict)
|
||||
self.assertIn("ctrl+ctrl", SUPPORTED_SHORTCUTS)
|
||||
self.assertIn("alt+alt", SUPPORTED_SHORTCUTS)
|
||||
self.assertIn("shift+shift", SUPPORTED_SHORTCUTS)
|
||||
self.assertIn("super+super", SUPPORTED_SHORTCUTS)
|
||||
|
||||
def test_shortcut_display_names_defined(self):
|
||||
"""Test that SHORTCUT_DISPLAY_NAMES is defined."""
|
||||
from vocalinux.ui.keyboard_backends.base import SHORTCUT_DISPLAY_NAMES
|
||||
|
||||
self.assertIsInstance(SHORTCUT_DISPLAY_NAMES, dict)
|
||||
self.assertEqual(
|
||||
set(SHORTCUT_DISPLAY_NAMES.keys()),
|
||||
{"ctrl+ctrl", "alt+alt", "shift+shift", "super+super"},
|
||||
)
|
||||
|
||||
def test_default_shortcut_defined(self):
|
||||
"""Test that DEFAULT_SHORTCUT is defined."""
|
||||
from vocalinux.ui.keyboard_backends.base import DEFAULT_SHORTCUT
|
||||
|
||||
self.assertEqual(DEFAULT_SHORTCUT, "ctrl+ctrl")
|
||||
|
||||
def test_keyboard_backend_shortcut_property(self):
|
||||
"""Test that KeyboardBackend has shortcut property."""
|
||||
from vocalinux.ui.keyboard_backends.base import KeyboardBackend
|
||||
|
||||
# Can't instantiate abstract class, but can check the property exists
|
||||
self.assertTrue(hasattr(KeyboardBackend, "shortcut"))
|
||||
self.assertTrue(hasattr(KeyboardBackend, "modifier_key"))
|
||||
self.assertTrue(hasattr(KeyboardBackend, "set_shortcut"))
|
||||
|
||||
|
||||
class TestConfigManagerShortcuts(unittest.TestCase):
|
||||
"""Test cases for shortcut configuration in config manager."""
|
||||
|
||||
def test_shortcuts_section_in_default_config(self):
|
||||
"""Test that shortcuts section exists in DEFAULT_CONFIG."""
|
||||
from vocalinux.ui.config_manager import DEFAULT_CONFIG
|
||||
|
||||
self.assertIn("shortcuts", DEFAULT_CONFIG)
|
||||
self.assertIn("toggle_recognition", DEFAULT_CONFIG["shortcuts"])
|
||||
self.assertEqual(DEFAULT_CONFIG["shortcuts"]["toggle_recognition"], "ctrl+ctrl")
|
||||
|
||||
def test_config_manager_get_shortcut(self):
|
||||
"""Test getting shortcut from config manager."""
|
||||
from unittest.mock import patch
|
||||
|
||||
with patch("vocalinux.ui.config_manager.os.path.exists", return_value=False):
|
||||
from vocalinux.ui.config_manager import ConfigManager
|
||||
|
||||
config = ConfigManager()
|
||||
shortcut = config.get("shortcuts", "toggle_recognition", "ctrl+ctrl")
|
||||
self.assertEqual(shortcut, "ctrl+ctrl")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user