feat(shortcuts): add customizable keyboard shortcuts via GUI (#137)

* feat(shortcuts): add customizable keyboard shortcuts via GUI

Implement configurable keyboard shortcuts for toggling voice recognition,
allowing users to choose between Ctrl+Ctrl, Alt+Alt, Shift+Shift, or
Super+Super (Windows key) double-tap shortcuts.

Changes:
- Add shortcuts configuration section to settings dialog with dropdown
- Update keyboard backends (pynput and evdev) to support configurable
  modifier keys for double-tap detection
- Add SUPPORTED_SHORTCUTS, SHORTCUT_DISPLAY_NAMES constants in base module
- Update KeyboardShortcutManager to accept and pass shortcut configuration
- TrayIndicator now reads shortcut from config on startup
- Add comprehensive tests for shortcut configuration and parsing

The default shortcut remains Ctrl+Ctrl for backward compatibility.
Changes take effect after app restart (noted in UI).

Closes #16

* feat(shortcuts): enable live shortcut switching without app restart

Add restart_with_shortcut() method to KeyboardShortcutManager that stops
the listener, updates the shortcut configuration, and restarts with the
new shortcut. The settings dialog now applies shortcut changes immediately
with visual feedback indicating success.

- Add restart_with_shortcut() to KeyboardShortcutManager
- Add update_shortcut() to TrayIndicator for exposing live updates
- Update SettingsDialog to use callback for immediate shortcut changes
- Show green success message when live switching succeeds
- Add 7 new tests for restart_with_shortcut functionality

* fix: use Python 3.8 compatible context manager syntax

Convert parenthesized context manager (Python 3.10+) to nested with
statements for Python 3.8 compatibility in test_keyboard_shortcuts.py
This commit is contained in:
Jatin K Malik
2026-02-05 16:47:29 -08:00
committed by GitHub
parent c75893b2a4
commit 7a1c42aae6
10 changed files with 843 additions and 78 deletions
+3 -1
View File
@@ -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,
+21 -7
View File
@@ -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",
]
+71 -4
View File
@@ -8,6 +8,46 @@ the required methods.
from abc import ABC, abstractmethod
from typing import Callable, Optional
# Supported double-tap shortcut keys
SUPPORTED_SHORTCUTS = {
"ctrl+ctrl": "ctrl",
"alt+alt": "alt",
"shift+shift": "shift",
"super+super": "super",
}
# Human-readable names for shortcuts
SHORTCUT_DISPLAY_NAMES = {
"ctrl+ctrl": "Double-tap Ctrl",
"alt+alt": "Double-tap Alt",
"shift+shift": "Double-tap Shift",
"super+super": "Double-tap Super (Windows key)",
}
DEFAULT_SHORTCUT = "ctrl+ctrl"
def parse_shortcut(shortcut_string: str) -> str:
"""
Parse a shortcut string and return the modifier key name.
Args:
shortcut_string: The shortcut string (e.g., "ctrl+ctrl", "alt+alt")
Returns:
The modifier key name (e.g., "ctrl", "alt")
Raises:
ValueError: If the shortcut string is not recognized
"""
shortcut_lower = shortcut_string.lower().strip()
if shortcut_lower not in SUPPORTED_SHORTCUTS:
raise ValueError(
f"Unsupported shortcut: {shortcut_string}. "
f"Supported shortcuts: {', '.join(SUPPORTED_SHORTCUTS.keys())}"
)
return SUPPORTED_SHORTCUTS[shortcut_lower]
class KeyboardBackend(ABC):
"""
@@ -17,10 +57,37 @@ class KeyboardBackend(ABC):
event listening and registering callbacks for specific shortcuts.
"""
def __init__(self):
"""Initialize the keyboard backend."""
def __init__(self, shortcut: str = DEFAULT_SHORTCUT):
"""
Initialize the keyboard backend.
Args:
shortcut: The shortcut string to listen for (e.g., "ctrl+ctrl")
"""
self.active = False
self.double_tap_callback: Optional[Callable] = None
self._shortcut = shortcut
self._modifier_key = parse_shortcut(shortcut)
@property
def shortcut(self) -> str:
"""Get the current shortcut string."""
return self._shortcut
@property
def modifier_key(self) -> str:
"""Get the modifier key being watched for double-tap."""
return self._modifier_key
def set_shortcut(self, shortcut: str) -> None:
"""
Update the shortcut to listen for.
Args:
shortcut: The new shortcut string (e.g., "ctrl+ctrl", "alt+alt")
"""
self._modifier_key = parse_shortcut(shortcut)
self._shortcut = shortcut
@abstractmethod
def start(self) -> bool:
@@ -59,9 +126,9 @@ class KeyboardBackend(ABC):
def register_toggle_callback(self, callback: Callable) -> None:
"""
Register a callback for the double-tap Ctrl shortcut.
Register a callback for the double-tap shortcut.
Args:
callback: Function to call when double-tap Ctrl is detected
callback: Function to call when double-tap is detected
"""
self.double_tap_callback = callback
@@ -11,7 +11,7 @@ import os
import select
import threading
import time
from typing import List, Optional, Set
from typing import Dict, List, Optional, Set
# Try to import evdev
try:
@@ -25,15 +25,28 @@ except ImportError:
ecodes = None # type: ignore
EVDEV_AVAILABLE = False
from .base import KeyboardBackend
from .base import DEFAULT_SHORTCUT, KeyboardBackend
logger = logging.getLogger(__name__)
# Key code for Ctrl (left and right)
# Key codes for modifier keys (left and right variants)
KEY_LEFTCTRL = 29
KEY_RIGHTCTRL = 97
KEY_CTRL = {KEY_LEFTCTRL, KEY_RIGHTCTRL}
KEY_LEFTALT = 56
KEY_RIGHTALT = 100
KEY_LEFTSHIFT = 42
KEY_RIGHTSHIFT = 54
KEY_LEFTMETA = 125 # Super/Windows key
KEY_RIGHTMETA = 126
# Map modifier key names to evdev key codes
MODIFIER_KEY_CODES: Dict[str, Set[int]] = {
"ctrl": {KEY_LEFTCTRL, KEY_RIGHTCTRL},
"alt": {KEY_LEFTALT, KEY_RIGHTALT},
"shift": {KEY_LEFTSHIFT, KEY_RIGHTSHIFT},
"super": {KEY_LEFTMETA, KEY_RIGHTMETA},
}
def find_keyboard_devices() -> List[str]:
@@ -76,30 +89,36 @@ def find_keyboard_devices() -> List[str]:
return keyboard_devices
def device_has_ctrl_key(device_path: str) -> bool:
def device_has_modifier_key(device_path: str, modifier: str = "ctrl") -> bool:
"""
Check if a device has Ctrl key capability.
Check if a device has a specific modifier key capability.
Args:
device_path: Path to the input device
modifier: The modifier key name ("ctrl", "alt", "shift", "super")
Returns:
True if the device can send Ctrl key events
True if the device can send the specified modifier key events
"""
if not EVDEV_AVAILABLE:
return False
key_codes = MODIFIER_KEY_CODES.get(modifier, set())
if not key_codes:
return False
try:
device = InputDevice(device_path)
capabilities = device.capabilities()
device.close()
# Check if device has EV_KEY capability and supports Ctrl keys
# Check if device has EV_KEY capability and supports the modifier keys
if ecodes.EV_KEY in capabilities:
key_caps = capabilities[ecodes.EV_KEY]
# Check for left or right Ctrl key
if KEY_LEFTCTRL in key_caps or KEY_RIGHTCTRL in key_caps:
return True
# Check for left or right variant of the modifier
for key_code in key_codes:
if key_code in key_caps:
return True
except (OSError, IOError):
pass
@@ -115,39 +134,48 @@ class EvdevKeyboardBackend(KeyboardBackend):
to read from /dev/input/event* devices (member of 'input' group).
"""
def __init__(self):
"""Initialize the evdev keyboard backend."""
super().__init__()
def __init__(self, shortcut: str = DEFAULT_SHORTCUT):
"""
Initialize the evdev keyboard backend.
Args:
shortcut: The shortcut string to listen for (e.g., "ctrl+ctrl")
"""
super().__init__(shortcut)
self.devices: List[InputDevice] = []
self.device_fds: List[int] = []
self.running = False
self.monitor_thread: Optional[threading.Thread] = None
self.last_trigger_time = 0
self.last_ctrl_press_time = 0
self.last_key_press_time = 0
self.double_tap_threshold = 0.3 # seconds
self.ctrl_pressed_devices: Set[int] = set()
self.key_pressed_devices: Set[int] = set()
if not EVDEV_AVAILABLE:
logger.error("python-evdev not available")
def _get_target_key_codes(self) -> Set[int]:
"""Get the evdev key codes for the configured modifier."""
return MODIFIER_KEY_CODES.get(self._modifier_key, set())
def is_available(self) -> bool:
"""Check if evdev is available and we can access a keyboard device with Ctrl key."""
"""Check if evdev is available and we can access a keyboard device with the modifier key."""
if not EVDEV_AVAILABLE:
return False
# Check if we can access at least one keyboard device with Ctrl key capability
# Check if we can access at least one keyboard device with the modifier key capability
try:
devices = find_keyboard_devices()
if not devices:
return False
# Try to find at least one device with Ctrl key that we can open
# Try to find at least one device with the modifier key that we can open
for device_path in devices:
if device_has_ctrl_key(device_path):
if device_has_modifier_key(device_path, self._modifier_key):
return True
# Could not find any accessible device with Ctrl key
# Could not find any accessible device with the modifier key
return False
except Exception:
return False
@@ -204,11 +232,12 @@ class EvdevKeyboardBackend(KeyboardBackend):
return False
logger.info(f"Found {len(device_paths)} keyboard device(s)")
logger.info(f"Listening for shortcut: {self._shortcut}")
# Open devices
self.devices = []
self.device_fds = []
self.ctrl_pressed_devices = set()
self.key_pressed_devices = set()
for device_path in device_paths:
try:
@@ -303,28 +332,30 @@ class EvdevKeyboardBackend(KeyboardBackend):
code = event.code
value = event.value # 0 = release, 1 = press, 2 = repeat
# Check if this is a Ctrl key
if code in KEY_CTRL:
target_codes = self._get_target_key_codes()
# Check if this is our target modifier key
if code in target_codes:
device_id = id(device)
if value == 1: # Key press
self.ctrl_pressed_devices.add(device_id)
self.key_pressed_devices.add(device_id)
current_time = time.time()
# Check for double-tap
if (
current_time - self.last_ctrl_press_time < self.double_tap_threshold
current_time - self.last_key_press_time < self.double_tap_threshold
and self.double_tap_callback is not None
and current_time - self.last_trigger_time > 0.5
):
logger.debug("Double-tap Ctrl detected (evdev)")
logger.debug(f"Double-tap {self._modifier_key} detected (evdev)")
self.last_trigger_time = current_time
threading.Thread(target=self.double_tap_callback, daemon=True).start()
self.last_ctrl_press_time = current_time
self.last_key_press_time = current_time
elif value == 0: # Key release
self.ctrl_pressed_devices.discard(device_id)
self.key_pressed_devices.discard(device_id)
except Exception as e:
logger.error(f"Error handling key event: {e}")
@@ -335,5 +366,5 @@ __all__ = [
"EvdevKeyboardBackend",
"EVDEV_AVAILABLE",
"find_keyboard_devices",
"device_has_ctrl_key",
"device_has_modifier_key",
]
@@ -19,11 +19,34 @@ except ImportError:
keyboard = None # type: ignore
PYNPUT_AVAILABLE = False
from .base import KeyboardBackend
from .base import DEFAULT_SHORTCUT, KeyboardBackend
logger = logging.getLogger(__name__)
# Map modifier key names to pynput Key objects
MODIFIER_KEY_MAP = {
"ctrl": keyboard.Key.ctrl if PYNPUT_AVAILABLE else None,
"alt": keyboard.Key.alt if PYNPUT_AVAILABLE else None,
"shift": keyboard.Key.shift if PYNPUT_AVAILABLE else None,
"super": keyboard.Key.cmd if PYNPUT_AVAILABLE else None, # cmd is Super/Windows key
}
# Map for normalizing left/right variants
MODIFIER_NORMALIZE_MAP = {}
if PYNPUT_AVAILABLE:
MODIFIER_NORMALIZE_MAP = {
keyboard.Key.ctrl_l: keyboard.Key.ctrl,
keyboard.Key.ctrl_r: keyboard.Key.ctrl,
keyboard.Key.alt_l: keyboard.Key.alt,
keyboard.Key.alt_r: keyboard.Key.alt,
keyboard.Key.shift_l: keyboard.Key.shift,
keyboard.Key.shift_r: keyboard.Key.shift,
keyboard.Key.cmd_l: keyboard.Key.cmd,
keyboard.Key.cmd_r: keyboard.Key.cmd,
}
class PynputKeyboardBackend(KeyboardBackend):
"""
Keyboard backend using pynput library.
@@ -32,18 +55,27 @@ class PynputKeyboardBackend(KeyboardBackend):
due to Wayland's security restrictions.
"""
def __init__(self):
"""Initialize the pynput keyboard backend."""
super().__init__()
def __init__(self, shortcut: str = DEFAULT_SHORTCUT):
"""
Initialize the pynput keyboard backend.
Args:
shortcut: The shortcut string to listen for (e.g., "ctrl+ctrl")
"""
super().__init__(shortcut)
self.listener = None
self.last_trigger_time = 0
self.last_ctrl_press_time = 0
self.last_key_press_time = 0
self.double_tap_threshold = 0.3 # seconds
self.current_keys = set()
if not PYNPUT_AVAILABLE:
logger.error("pynput library not available")
def _get_target_key(self):
"""Get the pynput Key object for the configured modifier."""
return MODIFIER_KEY_MAP.get(self._modifier_key)
def is_available(self) -> bool:
"""Check if pynput is available."""
return PYNPUT_AVAILABLE
@@ -71,7 +103,7 @@ class PynputKeyboardBackend(KeyboardBackend):
if self.active:
return True
logger.info("Starting pynput keyboard listener")
logger.info(f"Starting pynput keyboard listener for shortcut: {self._shortcut}")
self.current_keys = set()
try:
@@ -112,20 +144,22 @@ class PynputKeyboardBackend(KeyboardBackend):
"""Handle key press events."""
try:
normalized_key = self._normalize_modifier_key(key)
if normalized_key == keyboard.Key.ctrl:
target_key = self._get_target_key()
if normalized_key == target_key:
current_time = time.time()
if (
current_time - self.last_ctrl_press_time < self.double_tap_threshold
current_time - self.last_key_press_time < self.double_tap_threshold
and self.double_tap_callback is not None
and current_time - self.last_trigger_time > 0.5
):
logger.debug("Double-tap Ctrl detected (pynput)")
logger.debug(f"Double-tap {self._modifier_key} detected (pynput)")
self.last_trigger_time = current_time
threading.Thread(target=self.double_tap_callback, daemon=True).start()
self.last_ctrl_press_time = current_time
self.last_key_press_time = current_time
if key in {keyboard.Key.ctrl, keyboard.Key.ctrl_l, keyboard.Key.ctrl_r}:
normalized_key = self._normalize_modifier_key(key)
# Track current keys (using target key for reference)
if target_key and key in self._get_key_variants(self._modifier_key):
self.current_keys.add(normalized_key)
except Exception as e:
@@ -141,17 +175,20 @@ class PynputKeyboardBackend(KeyboardBackend):
def _normalize_modifier_key(self, key):
"""Normalize left/right variants of modifier keys."""
key_mapping = {
keyboard.Key.alt_l: keyboard.Key.alt,
keyboard.Key.alt_r: keyboard.Key.alt,
keyboard.Key.shift_l: keyboard.Key.shift,
keyboard.Key.shift_r: keyboard.Key.shift,
keyboard.Key.ctrl_l: keyboard.Key.ctrl,
keyboard.Key.ctrl_r: keyboard.Key.ctrl,
keyboard.Key.cmd_l: keyboard.Key.cmd,
keyboard.Key.cmd_r: keyboard.Key.cmd,
return MODIFIER_NORMALIZE_MAP.get(key, key)
def _get_key_variants(self, modifier_name: str):
"""Get all key variants for a modifier name."""
if not PYNPUT_AVAILABLE:
return set()
variants = {
"ctrl": {keyboard.Key.ctrl, keyboard.Key.ctrl_l, keyboard.Key.ctrl_r},
"alt": {keyboard.Key.alt, keyboard.Key.alt_l, keyboard.Key.alt_r},
"shift": {keyboard.Key.shift, keyboard.Key.shift_l, keyboard.Key.shift_r},
"super": {keyboard.Key.cmd, keyboard.Key.cmd_l, keyboard.Key.cmd_r},
}
return key_mapping.get(key, key)
return variants.get(modifier_name, set())
# Export availability
+118 -9
View File
@@ -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",
]
+93
View File
@@ -29,6 +29,7 @@ from gi.repository import Gdk, GLib, Gtk, Pango # noqa: E402
from ..common_types import RecognitionState # noqa: E402
from ..utils.vosk_model_info import SUPPORTED_LANGUAGES, VOSK_MODEL_INFO # noqa: E402
from .keyboard_backends import SHORTCUT_DISPLAY_NAMES, SUPPORTED_SHORTCUTS # noqa: E402
# Avoid circular imports for type checking
if TYPE_CHECKING:
@@ -588,10 +589,12 @@ class SettingsDialog(Gtk.Dialog):
parent: Gtk.Window,
config_manager: "ConfigManager",
speech_engine: "SpeechRecognitionManager",
shortcut_update_callback: callable = None,
):
super().__init__(title="Vocalinux Settings", transient_for=parent, flags=0)
self.config_manager = config_manager
self.speech_engine = speech_engine
self.shortcut_update_callback = shortcut_update_callback
self._test_active = False
self._test_result = ""
self._initializing = True # Flag to prevent auto-apply during initialization
@@ -628,6 +631,7 @@ class SettingsDialog(Gtk.Dialog):
self._build_audio_section()
self._build_engine_section()
self._build_recognition_section()
self._build_shortcuts_section()
self._build_test_section()
# Load settings and populate UI
@@ -820,6 +824,95 @@ class SettingsDialog(Gtk.Dialog):
self.vad_spin.connect("value-changed", self._on_vad_changed)
self.silence_spin.connect("value-changed", self._on_silence_changed)
def _build_shortcuts_section(self):
"""Build the Keyboard Shortcuts section."""
group = PreferencesGroup(
title="Keyboard Shortcuts",
description="Configure the shortcut to toggle voice recognition",
)
# Shortcut selection combo
self.shortcut_combo = Gtk.ComboBoxText()
self.shortcut_combo.set_size_request(200, -1)
self.shortcut_combo.set_tooltip_text("Select the keyboard shortcut to toggle voice typing")
# Populate shortcut options
for shortcut_id, display_name in SHORTCUT_DISPLAY_NAMES.items():
self.shortcut_combo.append(shortcut_id, display_name)
# Load current shortcut from config
current_shortcut = self.config_manager.get("shortcuts", "toggle_recognition", "ctrl+ctrl")
if not self.shortcut_combo.set_active_id(current_shortcut):
self.shortcut_combo.set_active_id("ctrl+ctrl")
shortcut_row = PreferenceRow(
title="Toggle Recognition",
subtitle="Press this shortcut twice quickly to start/stop",
widget=self.shortcut_combo,
)
group.add_row(shortcut_row)
self.content_box.pack_start(group, False, False, 0)
# Info box about the shortcut
info_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
info_box.get_style_context().add_class("info-box")
info_box.set_margin_start(4)
info_box.set_margin_end(4)
info_box.set_margin_top(4)
info_icon = Gtk.Image.new_from_icon_name("dialog-information-symbolic", Gtk.IconSize.MENU)
info_box.pack_start(info_icon, False, False, 0)
self.shortcut_info_label = Gtk.Label(
label="Changes take effect immediately. Double-tap the key to toggle voice typing.",
xalign=0,
wrap=True,
)
self.shortcut_info_label.get_style_context().add_class("tip-label")
info_box.pack_start(self.shortcut_info_label, True, True, 0)
self.content_box.pack_start(info_box, False, False, 0)
# Connect signal
self.shortcut_combo.connect("changed", self._on_shortcut_changed)
def _on_shortcut_changed(self, widget):
"""Handle shortcut selection change."""
if self._initializing:
return
shortcut_id = self.shortcut_combo.get_active_id()
if not shortcut_id:
return
# Save to config
self.config_manager.set("shortcuts", "toggle_recognition", shortcut_id)
self.config_manager.save_settings()
display_name = SHORTCUT_DISPLAY_NAMES.get(shortcut_id, shortcut_id)
logger.info(f"Keyboard shortcut changed to: {display_name}")
# Try to apply the shortcut change live
if self.shortcut_update_callback:
success = self.shortcut_update_callback(shortcut_id)
if success:
self.shortcut_info_label.set_markup(
f"<span foreground='#26a269'>Shortcut updated to <b>{display_name}</b>. "
f"Active now!</span>"
)
else:
self.shortcut_info_label.set_markup(
f"<i>Shortcut updated to <b>{display_name}</b>. "
f"Restart the app for the change to take full effect.</i>"
)
else:
# No callback provided, fall back to restart message
self.shortcut_info_label.set_markup(
f"<i>Shortcut updated to <b>{display_name}</b>. "
f"Restart the app for the change to take full effect.</i>"
)
def _build_test_section(self):
"""Build the Test Recognition section."""
group = PreferencesGroup(title="Test Recognition")
+20 -4
View File
@@ -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