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()