diff --git a/src/vocalinux/ui/config_manager.py b/src/vocalinux/ui/config_manager.py index a37fe96..144c220 100644 --- a/src/vocalinux/ui/config_manager.py +++ b/src/vocalinux/ui/config_manager.py @@ -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, diff --git a/src/vocalinux/ui/keyboard_backends/__init__.py b/src/vocalinux/ui/keyboard_backends/__init__.py index 667dc11..af9c67a 100644 --- a/src/vocalinux/ui/keyboard_backends/__init__.py +++ b/src/vocalinux/ui/keyboard_backends/__init__.py @@ -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", ] diff --git a/src/vocalinux/ui/keyboard_backends/base.py b/src/vocalinux/ui/keyboard_backends/base.py index 39ec63b..17d775c 100644 --- a/src/vocalinux/ui/keyboard_backends/base.py +++ b/src/vocalinux/ui/keyboard_backends/base.py @@ -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 diff --git a/src/vocalinux/ui/keyboard_backends/evdev_backend.py b/src/vocalinux/ui/keyboard_backends/evdev_backend.py index 4164baa..46aa5fa 100644 --- a/src/vocalinux/ui/keyboard_backends/evdev_backend.py +++ b/src/vocalinux/ui/keyboard_backends/evdev_backend.py @@ -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", ] diff --git a/src/vocalinux/ui/keyboard_backends/pynput_backend.py b/src/vocalinux/ui/keyboard_backends/pynput_backend.py index b5fcd00..4bb929a 100644 --- a/src/vocalinux/ui/keyboard_backends/pynput_backend.py +++ b/src/vocalinux/ui/keyboard_backends/pynput_backend.py @@ -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 diff --git a/src/vocalinux/ui/keyboard_shortcuts.py b/src/vocalinux/ui/keyboard_shortcuts.py index 03cb33d..1a677a9 100644 --- a/src/vocalinux/ui/keyboard_shortcuts.py +++ b/src/vocalinux/ui/keyboard_shortcuts.py @@ -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", ] diff --git a/src/vocalinux/ui/settings_dialog.py b/src/vocalinux/ui/settings_dialog.py index ec4b68b..fa33c96 100644 --- a/src/vocalinux/ui/settings_dialog.py +++ b/src/vocalinux/ui/settings_dialog.py @@ -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"Shortcut updated to {display_name}. " + f"Active now!" + ) + else: + self.shortcut_info_label.set_markup( + f"Shortcut updated to {display_name}. " + f"Restart the app for the change to take full effect." + ) + else: + # No callback provided, fall back to restart message + self.shortcut_info_label.set_markup( + f"Shortcut updated to {display_name}. " + f"Restart the app for the change to take full effect." + ) + def _build_test_section(self): """Build the Test Recognition section.""" group = PreferencesGroup(title="Test Recognition") diff --git a/src/vocalinux/ui/tray_indicator.py b/src/vocalinux/ui/tray_indicator.py index 3e37dc1..354de1e 100644 --- a/src/vocalinux/ui/tray_indicator.py +++ b/src/vocalinux/ui/tray_indicator.py @@ -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 diff --git a/tests/test_keyboard_shortcuts.py b/tests/test_keyboard_shortcuts.py index b2fc86c..f53d3f4 100644 --- a/tests/test_keyboard_shortcuts.py +++ b/tests/test_keyboard_shortcuts.py @@ -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() diff --git a/tests/test_settings_shortcuts.py b/tests/test_settings_shortcuts.py new file mode 100644 index 0000000..c5e868e --- /dev/null +++ b/tests/test_settings_shortcuts.py @@ -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()