feat(shortcuts): add customizable keyboard shortcuts via GUI (#137)
* feat(shortcuts): add customizable keyboard shortcuts via GUI Implement configurable keyboard shortcuts for toggling voice recognition, allowing users to choose between Ctrl+Ctrl, Alt+Alt, Shift+Shift, or Super+Super (Windows key) double-tap shortcuts. Changes: - Add shortcuts configuration section to settings dialog with dropdown - Update keyboard backends (pynput and evdev) to support configurable modifier keys for double-tap detection - Add SUPPORTED_SHORTCUTS, SHORTCUT_DISPLAY_NAMES constants in base module - Update KeyboardShortcutManager to accept and pass shortcut configuration - TrayIndicator now reads shortcut from config on startup - Add comprehensive tests for shortcut configuration and parsing The default shortcut remains Ctrl+Ctrl for backward compatibility. Changes take effect after app restart (noted in UI). Closes #16 * feat(shortcuts): enable live shortcut switching without app restart Add restart_with_shortcut() method to KeyboardShortcutManager that stops the listener, updates the shortcut configuration, and restarts with the new shortcut. The settings dialog now applies shortcut changes immediately with visual feedback indicating success. - Add restart_with_shortcut() to KeyboardShortcutManager - Add update_shortcut() to TrayIndicator for exposing live updates - Update SettingsDialog to use callback for immediate shortcut changes - Show green success message when live switching succeeds - Add 7 new tests for restart_with_shortcut functionality * fix: use Python 3.8 compatible context manager syntax Convert parenthesized context manager (Python 3.10+) to nested with statements for Python 3.8 compatibility in test_keyboard_shortcuts.py
This commit is contained in:
@@ -31,7 +31,9 @@ DEFAULT_CONFIG = {
|
||||
"device_name": None, # Saved device name for display/reference
|
||||
},
|
||||
"shortcuts": {
|
||||
"toggle_recognition": "ctrl+ctrl", # Double-tap Ctrl
|
||||
"toggle_recognition": "ctrl+ctrl", # Double-tap modifier key
|
||||
# Supported values: "ctrl+ctrl", "alt+alt", "shift+shift", "super+super"
|
||||
# These represent double-tap shortcuts for the respective modifier keys
|
||||
},
|
||||
"ui": {
|
||||
"start_minimized": False,
|
||||
|
||||
@@ -9,7 +9,13 @@ import logging
|
||||
import os
|
||||
from typing import Optional
|
||||
|
||||
from .base import KeyboardBackend
|
||||
from .base import (
|
||||
DEFAULT_SHORTCUT,
|
||||
SHORTCUT_DISPLAY_NAMES,
|
||||
SUPPORTED_SHORTCUTS,
|
||||
KeyboardBackend,
|
||||
parse_shortcut,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -62,25 +68,29 @@ class DesktopEnvironment:
|
||||
return DesktopEnvironment.UNKNOWN
|
||||
|
||||
|
||||
def create_backend(preferred_backend: Optional[str] = None) -> Optional[KeyboardBackend]:
|
||||
def create_backend(
|
||||
preferred_backend: Optional[str] = None, shortcut: str = DEFAULT_SHORTCUT
|
||||
) -> Optional[KeyboardBackend]:
|
||||
"""
|
||||
Create a keyboard backend based on availability and environment.
|
||||
|
||||
Args:
|
||||
preferred_backend: Optional backend name to force ('pynput' or 'evdev')
|
||||
shortcut: The shortcut string to listen for (e.g., "ctrl+ctrl", "alt+alt")
|
||||
|
||||
Returns:
|
||||
A KeyboardBackend instance, or None if no backend is available
|
||||
"""
|
||||
env = DesktopEnvironment.detect()
|
||||
logger.info(f"Detected desktop environment: {env}")
|
||||
logger.info(f"Configured shortcut: {shortcut}")
|
||||
|
||||
# If a specific backend is requested, try to use it
|
||||
if preferred_backend:
|
||||
if preferred_backend == "evdev":
|
||||
if EVDEV_AVAILABLE:
|
||||
logger.info("Using evdev backend (preferred)")
|
||||
backend = EvdevKeyboardBackend() # type: ignore
|
||||
backend = EvdevKeyboardBackend(shortcut=shortcut) # type: ignore
|
||||
if backend.is_available():
|
||||
return backend
|
||||
hint = backend.get_permission_hint()
|
||||
@@ -92,7 +102,7 @@ def create_backend(preferred_backend: Optional[str] = None) -> Optional[Keyboard
|
||||
elif preferred_backend == "pynput":
|
||||
if PYNPUT_AVAILABLE:
|
||||
logger.info("Using pynput backend (preferred)")
|
||||
return PynputKeyboardBackend() # type: ignore
|
||||
return PynputKeyboardBackend(shortcut=shortcut) # type: ignore
|
||||
logger.warning("Pynput backend not available")
|
||||
else:
|
||||
logger.warning(f"Unknown preferred backend: '{preferred_backend}'")
|
||||
@@ -102,7 +112,7 @@ def create_backend(preferred_backend: Optional[str] = None) -> Optional[Keyboard
|
||||
# On Wayland, prefer evdev (if available) as pynput doesn't work
|
||||
if EVDEV_AVAILABLE:
|
||||
logger.info("Using evdev backend for Wayland")
|
||||
backend = EvdevKeyboardBackend() # type: ignore
|
||||
backend = EvdevKeyboardBackend(shortcut=shortcut) # type: ignore
|
||||
if backend.is_available():
|
||||
return backend
|
||||
hint = backend.get_permission_hint()
|
||||
@@ -120,11 +130,11 @@ def create_backend(preferred_backend: Optional[str] = None) -> Optional[Keyboard
|
||||
# Default to pynput for X11 or unknown
|
||||
if PYNPUT_AVAILABLE:
|
||||
logger.info("Using pynput backend")
|
||||
return PynputKeyboardBackend() # type: ignore
|
||||
return PynputKeyboardBackend(shortcut=shortcut) # type: ignore
|
||||
|
||||
if EVDEV_AVAILABLE:
|
||||
logger.warning("Pynput not available, falling back to evdev backend")
|
||||
backend = EvdevKeyboardBackend() # type: ignore
|
||||
backend = EvdevKeyboardBackend(shortcut=shortcut) # type: ignore
|
||||
if backend.is_available():
|
||||
return backend
|
||||
hint = backend.get_permission_hint()
|
||||
@@ -145,4 +155,8 @@ __all__ = [
|
||||
"EvdevKeyboardBackend",
|
||||
"EVDEV_AVAILABLE",
|
||||
"PYNPUT_AVAILABLE",
|
||||
"SUPPORTED_SHORTCUTS",
|
||||
"SHORTCUT_DISPLAY_NAMES",
|
||||
"DEFAULT_SHORTCUT",
|
||||
"parse_shortcut",
|
||||
]
|
||||
|
||||
@@ -8,6 +8,46 @@ the required methods.
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Callable, Optional
|
||||
|
||||
# Supported double-tap shortcut keys
|
||||
SUPPORTED_SHORTCUTS = {
|
||||
"ctrl+ctrl": "ctrl",
|
||||
"alt+alt": "alt",
|
||||
"shift+shift": "shift",
|
||||
"super+super": "super",
|
||||
}
|
||||
|
||||
# Human-readable names for shortcuts
|
||||
SHORTCUT_DISPLAY_NAMES = {
|
||||
"ctrl+ctrl": "Double-tap Ctrl",
|
||||
"alt+alt": "Double-tap Alt",
|
||||
"shift+shift": "Double-tap Shift",
|
||||
"super+super": "Double-tap Super (Windows key)",
|
||||
}
|
||||
|
||||
DEFAULT_SHORTCUT = "ctrl+ctrl"
|
||||
|
||||
|
||||
def parse_shortcut(shortcut_string: str) -> str:
|
||||
"""
|
||||
Parse a shortcut string and return the modifier key name.
|
||||
|
||||
Args:
|
||||
shortcut_string: The shortcut string (e.g., "ctrl+ctrl", "alt+alt")
|
||||
|
||||
Returns:
|
||||
The modifier key name (e.g., "ctrl", "alt")
|
||||
|
||||
Raises:
|
||||
ValueError: If the shortcut string is not recognized
|
||||
"""
|
||||
shortcut_lower = shortcut_string.lower().strip()
|
||||
if shortcut_lower not in SUPPORTED_SHORTCUTS:
|
||||
raise ValueError(
|
||||
f"Unsupported shortcut: {shortcut_string}. "
|
||||
f"Supported shortcuts: {', '.join(SUPPORTED_SHORTCUTS.keys())}"
|
||||
)
|
||||
return SUPPORTED_SHORTCUTS[shortcut_lower]
|
||||
|
||||
|
||||
class KeyboardBackend(ABC):
|
||||
"""
|
||||
@@ -17,10 +57,37 @@ class KeyboardBackend(ABC):
|
||||
event listening and registering callbacks for specific shortcuts.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize the keyboard backend."""
|
||||
def __init__(self, shortcut: str = DEFAULT_SHORTCUT):
|
||||
"""
|
||||
Initialize the keyboard backend.
|
||||
|
||||
Args:
|
||||
shortcut: The shortcut string to listen for (e.g., "ctrl+ctrl")
|
||||
"""
|
||||
self.active = False
|
||||
self.double_tap_callback: Optional[Callable] = None
|
||||
self._shortcut = shortcut
|
||||
self._modifier_key = parse_shortcut(shortcut)
|
||||
|
||||
@property
|
||||
def shortcut(self) -> str:
|
||||
"""Get the current shortcut string."""
|
||||
return self._shortcut
|
||||
|
||||
@property
|
||||
def modifier_key(self) -> str:
|
||||
"""Get the modifier key being watched for double-tap."""
|
||||
return self._modifier_key
|
||||
|
||||
def set_shortcut(self, shortcut: str) -> None:
|
||||
"""
|
||||
Update the shortcut to listen for.
|
||||
|
||||
Args:
|
||||
shortcut: The new shortcut string (e.g., "ctrl+ctrl", "alt+alt")
|
||||
"""
|
||||
self._modifier_key = parse_shortcut(shortcut)
|
||||
self._shortcut = shortcut
|
||||
|
||||
@abstractmethod
|
||||
def start(self) -> bool:
|
||||
@@ -59,9 +126,9 @@ class KeyboardBackend(ABC):
|
||||
|
||||
def register_toggle_callback(self, callback: Callable) -> None:
|
||||
"""
|
||||
Register a callback for the double-tap Ctrl shortcut.
|
||||
Register a callback for the double-tap shortcut.
|
||||
|
||||
Args:
|
||||
callback: Function to call when double-tap Ctrl is detected
|
||||
callback: Function to call when double-tap is detected
|
||||
"""
|
||||
self.double_tap_callback = callback
|
||||
|
||||
@@ -11,7 +11,7 @@ import os
|
||||
import select
|
||||
import threading
|
||||
import time
|
||||
from typing import List, Optional, Set
|
||||
from typing import Dict, List, Optional, Set
|
||||
|
||||
# Try to import evdev
|
||||
try:
|
||||
@@ -25,15 +25,28 @@ except ImportError:
|
||||
ecodes = None # type: ignore
|
||||
EVDEV_AVAILABLE = False
|
||||
|
||||
from .base import KeyboardBackend
|
||||
from .base import DEFAULT_SHORTCUT, KeyboardBackend
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# Key code for Ctrl (left and right)
|
||||
# Key codes for modifier keys (left and right variants)
|
||||
KEY_LEFTCTRL = 29
|
||||
KEY_RIGHTCTRL = 97
|
||||
KEY_CTRL = {KEY_LEFTCTRL, KEY_RIGHTCTRL}
|
||||
KEY_LEFTALT = 56
|
||||
KEY_RIGHTALT = 100
|
||||
KEY_LEFTSHIFT = 42
|
||||
KEY_RIGHTSHIFT = 54
|
||||
KEY_LEFTMETA = 125 # Super/Windows key
|
||||
KEY_RIGHTMETA = 126
|
||||
|
||||
# Map modifier key names to evdev key codes
|
||||
MODIFIER_KEY_CODES: Dict[str, Set[int]] = {
|
||||
"ctrl": {KEY_LEFTCTRL, KEY_RIGHTCTRL},
|
||||
"alt": {KEY_LEFTALT, KEY_RIGHTALT},
|
||||
"shift": {KEY_LEFTSHIFT, KEY_RIGHTSHIFT},
|
||||
"super": {KEY_LEFTMETA, KEY_RIGHTMETA},
|
||||
}
|
||||
|
||||
|
||||
def find_keyboard_devices() -> List[str]:
|
||||
@@ -76,30 +89,36 @@ def find_keyboard_devices() -> List[str]:
|
||||
return keyboard_devices
|
||||
|
||||
|
||||
def device_has_ctrl_key(device_path: str) -> bool:
|
||||
def device_has_modifier_key(device_path: str, modifier: str = "ctrl") -> bool:
|
||||
"""
|
||||
Check if a device has Ctrl key capability.
|
||||
Check if a device has a specific modifier key capability.
|
||||
|
||||
Args:
|
||||
device_path: Path to the input device
|
||||
modifier: The modifier key name ("ctrl", "alt", "shift", "super")
|
||||
|
||||
Returns:
|
||||
True if the device can send Ctrl key events
|
||||
True if the device can send the specified modifier key events
|
||||
"""
|
||||
if not EVDEV_AVAILABLE:
|
||||
return False
|
||||
|
||||
key_codes = MODIFIER_KEY_CODES.get(modifier, set())
|
||||
if not key_codes:
|
||||
return False
|
||||
|
||||
try:
|
||||
device = InputDevice(device_path)
|
||||
capabilities = device.capabilities()
|
||||
device.close()
|
||||
|
||||
# Check if device has EV_KEY capability and supports Ctrl keys
|
||||
# Check if device has EV_KEY capability and supports the modifier keys
|
||||
if ecodes.EV_KEY in capabilities:
|
||||
key_caps = capabilities[ecodes.EV_KEY]
|
||||
# Check for left or right Ctrl key
|
||||
if KEY_LEFTCTRL in key_caps or KEY_RIGHTCTRL in key_caps:
|
||||
return True
|
||||
# Check for left or right variant of the modifier
|
||||
for key_code in key_codes:
|
||||
if key_code in key_caps:
|
||||
return True
|
||||
except (OSError, IOError):
|
||||
pass
|
||||
|
||||
@@ -115,39 +134,48 @@ class EvdevKeyboardBackend(KeyboardBackend):
|
||||
to read from /dev/input/event* devices (member of 'input' group).
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize the evdev keyboard backend."""
|
||||
super().__init__()
|
||||
def __init__(self, shortcut: str = DEFAULT_SHORTCUT):
|
||||
"""
|
||||
Initialize the evdev keyboard backend.
|
||||
|
||||
Args:
|
||||
shortcut: The shortcut string to listen for (e.g., "ctrl+ctrl")
|
||||
"""
|
||||
super().__init__(shortcut)
|
||||
self.devices: List[InputDevice] = []
|
||||
self.device_fds: List[int] = []
|
||||
self.running = False
|
||||
self.monitor_thread: Optional[threading.Thread] = None
|
||||
|
||||
self.last_trigger_time = 0
|
||||
self.last_ctrl_press_time = 0
|
||||
self.last_key_press_time = 0
|
||||
self.double_tap_threshold = 0.3 # seconds
|
||||
self.ctrl_pressed_devices: Set[int] = set()
|
||||
self.key_pressed_devices: Set[int] = set()
|
||||
|
||||
if not EVDEV_AVAILABLE:
|
||||
logger.error("python-evdev not available")
|
||||
|
||||
def _get_target_key_codes(self) -> Set[int]:
|
||||
"""Get the evdev key codes for the configured modifier."""
|
||||
return MODIFIER_KEY_CODES.get(self._modifier_key, set())
|
||||
|
||||
def is_available(self) -> bool:
|
||||
"""Check if evdev is available and we can access a keyboard device with Ctrl key."""
|
||||
"""Check if evdev is available and we can access a keyboard device with the modifier key."""
|
||||
if not EVDEV_AVAILABLE:
|
||||
return False
|
||||
|
||||
# Check if we can access at least one keyboard device with Ctrl key capability
|
||||
# Check if we can access at least one keyboard device with the modifier key capability
|
||||
try:
|
||||
devices = find_keyboard_devices()
|
||||
if not devices:
|
||||
return False
|
||||
|
||||
# Try to find at least one device with Ctrl key that we can open
|
||||
# Try to find at least one device with the modifier key that we can open
|
||||
for device_path in devices:
|
||||
if device_has_ctrl_key(device_path):
|
||||
if device_has_modifier_key(device_path, self._modifier_key):
|
||||
return True
|
||||
|
||||
# Could not find any accessible device with Ctrl key
|
||||
# Could not find any accessible device with the modifier key
|
||||
return False
|
||||
except Exception:
|
||||
return False
|
||||
@@ -204,11 +232,12 @@ class EvdevKeyboardBackend(KeyboardBackend):
|
||||
return False
|
||||
|
||||
logger.info(f"Found {len(device_paths)} keyboard device(s)")
|
||||
logger.info(f"Listening for shortcut: {self._shortcut}")
|
||||
|
||||
# Open devices
|
||||
self.devices = []
|
||||
self.device_fds = []
|
||||
self.ctrl_pressed_devices = set()
|
||||
self.key_pressed_devices = set()
|
||||
|
||||
for device_path in device_paths:
|
||||
try:
|
||||
@@ -303,28 +332,30 @@ class EvdevKeyboardBackend(KeyboardBackend):
|
||||
code = event.code
|
||||
value = event.value # 0 = release, 1 = press, 2 = repeat
|
||||
|
||||
# Check if this is a Ctrl key
|
||||
if code in KEY_CTRL:
|
||||
target_codes = self._get_target_key_codes()
|
||||
|
||||
# Check if this is our target modifier key
|
||||
if code in target_codes:
|
||||
device_id = id(device)
|
||||
|
||||
if value == 1: # Key press
|
||||
self.ctrl_pressed_devices.add(device_id)
|
||||
self.key_pressed_devices.add(device_id)
|
||||
current_time = time.time()
|
||||
|
||||
# Check for double-tap
|
||||
if (
|
||||
current_time - self.last_ctrl_press_time < self.double_tap_threshold
|
||||
current_time - self.last_key_press_time < self.double_tap_threshold
|
||||
and self.double_tap_callback is not None
|
||||
and current_time - self.last_trigger_time > 0.5
|
||||
):
|
||||
logger.debug("Double-tap Ctrl detected (evdev)")
|
||||
logger.debug(f"Double-tap {self._modifier_key} detected (evdev)")
|
||||
self.last_trigger_time = current_time
|
||||
threading.Thread(target=self.double_tap_callback, daemon=True).start()
|
||||
|
||||
self.last_ctrl_press_time = current_time
|
||||
self.last_key_press_time = current_time
|
||||
|
||||
elif value == 0: # Key release
|
||||
self.ctrl_pressed_devices.discard(device_id)
|
||||
self.key_pressed_devices.discard(device_id)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error handling key event: {e}")
|
||||
@@ -335,5 +366,5 @@ __all__ = [
|
||||
"EvdevKeyboardBackend",
|
||||
"EVDEV_AVAILABLE",
|
||||
"find_keyboard_devices",
|
||||
"device_has_ctrl_key",
|
||||
"device_has_modifier_key",
|
||||
]
|
||||
|
||||
@@ -19,11 +19,34 @@ except ImportError:
|
||||
keyboard = None # type: ignore
|
||||
PYNPUT_AVAILABLE = False
|
||||
|
||||
from .base import KeyboardBackend
|
||||
from .base import DEFAULT_SHORTCUT, KeyboardBackend
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# Map modifier key names to pynput Key objects
|
||||
MODIFIER_KEY_MAP = {
|
||||
"ctrl": keyboard.Key.ctrl if PYNPUT_AVAILABLE else None,
|
||||
"alt": keyboard.Key.alt if PYNPUT_AVAILABLE else None,
|
||||
"shift": keyboard.Key.shift if PYNPUT_AVAILABLE else None,
|
||||
"super": keyboard.Key.cmd if PYNPUT_AVAILABLE else None, # cmd is Super/Windows key
|
||||
}
|
||||
|
||||
# Map for normalizing left/right variants
|
||||
MODIFIER_NORMALIZE_MAP = {}
|
||||
if PYNPUT_AVAILABLE:
|
||||
MODIFIER_NORMALIZE_MAP = {
|
||||
keyboard.Key.ctrl_l: keyboard.Key.ctrl,
|
||||
keyboard.Key.ctrl_r: keyboard.Key.ctrl,
|
||||
keyboard.Key.alt_l: keyboard.Key.alt,
|
||||
keyboard.Key.alt_r: keyboard.Key.alt,
|
||||
keyboard.Key.shift_l: keyboard.Key.shift,
|
||||
keyboard.Key.shift_r: keyboard.Key.shift,
|
||||
keyboard.Key.cmd_l: keyboard.Key.cmd,
|
||||
keyboard.Key.cmd_r: keyboard.Key.cmd,
|
||||
}
|
||||
|
||||
|
||||
class PynputKeyboardBackend(KeyboardBackend):
|
||||
"""
|
||||
Keyboard backend using pynput library.
|
||||
@@ -32,18 +55,27 @@ class PynputKeyboardBackend(KeyboardBackend):
|
||||
due to Wayland's security restrictions.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize the pynput keyboard backend."""
|
||||
super().__init__()
|
||||
def __init__(self, shortcut: str = DEFAULT_SHORTCUT):
|
||||
"""
|
||||
Initialize the pynput keyboard backend.
|
||||
|
||||
Args:
|
||||
shortcut: The shortcut string to listen for (e.g., "ctrl+ctrl")
|
||||
"""
|
||||
super().__init__(shortcut)
|
||||
self.listener = None
|
||||
self.last_trigger_time = 0
|
||||
self.last_ctrl_press_time = 0
|
||||
self.last_key_press_time = 0
|
||||
self.double_tap_threshold = 0.3 # seconds
|
||||
self.current_keys = set()
|
||||
|
||||
if not PYNPUT_AVAILABLE:
|
||||
logger.error("pynput library not available")
|
||||
|
||||
def _get_target_key(self):
|
||||
"""Get the pynput Key object for the configured modifier."""
|
||||
return MODIFIER_KEY_MAP.get(self._modifier_key)
|
||||
|
||||
def is_available(self) -> bool:
|
||||
"""Check if pynput is available."""
|
||||
return PYNPUT_AVAILABLE
|
||||
@@ -71,7 +103,7 @@ class PynputKeyboardBackend(KeyboardBackend):
|
||||
if self.active:
|
||||
return True
|
||||
|
||||
logger.info("Starting pynput keyboard listener")
|
||||
logger.info(f"Starting pynput keyboard listener for shortcut: {self._shortcut}")
|
||||
self.current_keys = set()
|
||||
|
||||
try:
|
||||
@@ -112,20 +144,22 @@ class PynputKeyboardBackend(KeyboardBackend):
|
||||
"""Handle key press events."""
|
||||
try:
|
||||
normalized_key = self._normalize_modifier_key(key)
|
||||
if normalized_key == keyboard.Key.ctrl:
|
||||
target_key = self._get_target_key()
|
||||
|
||||
if normalized_key == target_key:
|
||||
current_time = time.time()
|
||||
if (
|
||||
current_time - self.last_ctrl_press_time < self.double_tap_threshold
|
||||
current_time - self.last_key_press_time < self.double_tap_threshold
|
||||
and self.double_tap_callback is not None
|
||||
and current_time - self.last_trigger_time > 0.5
|
||||
):
|
||||
logger.debug("Double-tap Ctrl detected (pynput)")
|
||||
logger.debug(f"Double-tap {self._modifier_key} detected (pynput)")
|
||||
self.last_trigger_time = current_time
|
||||
threading.Thread(target=self.double_tap_callback, daemon=True).start()
|
||||
self.last_ctrl_press_time = current_time
|
||||
self.last_key_press_time = current_time
|
||||
|
||||
if key in {keyboard.Key.ctrl, keyboard.Key.ctrl_l, keyboard.Key.ctrl_r}:
|
||||
normalized_key = self._normalize_modifier_key(key)
|
||||
# Track current keys (using target key for reference)
|
||||
if target_key and key in self._get_key_variants(self._modifier_key):
|
||||
self.current_keys.add(normalized_key)
|
||||
|
||||
except Exception as e:
|
||||
@@ -141,17 +175,20 @@ class PynputKeyboardBackend(KeyboardBackend):
|
||||
|
||||
def _normalize_modifier_key(self, key):
|
||||
"""Normalize left/right variants of modifier keys."""
|
||||
key_mapping = {
|
||||
keyboard.Key.alt_l: keyboard.Key.alt,
|
||||
keyboard.Key.alt_r: keyboard.Key.alt,
|
||||
keyboard.Key.shift_l: keyboard.Key.shift,
|
||||
keyboard.Key.shift_r: keyboard.Key.shift,
|
||||
keyboard.Key.ctrl_l: keyboard.Key.ctrl,
|
||||
keyboard.Key.ctrl_r: keyboard.Key.ctrl,
|
||||
keyboard.Key.cmd_l: keyboard.Key.cmd,
|
||||
keyboard.Key.cmd_r: keyboard.Key.cmd,
|
||||
return MODIFIER_NORMALIZE_MAP.get(key, key)
|
||||
|
||||
def _get_key_variants(self, modifier_name: str):
|
||||
"""Get all key variants for a modifier name."""
|
||||
if not PYNPUT_AVAILABLE:
|
||||
return set()
|
||||
|
||||
variants = {
|
||||
"ctrl": {keyboard.Key.ctrl, keyboard.Key.ctrl_l, keyboard.Key.ctrl_r},
|
||||
"alt": {keyboard.Key.alt, keyboard.Key.alt_l, keyboard.Key.alt_r},
|
||||
"shift": {keyboard.Key.shift, keyboard.Key.shift_l, keyboard.Key.shift_r},
|
||||
"super": {keyboard.Key.cmd, keyboard.Key.cmd_l, keyboard.Key.cmd_r},
|
||||
}
|
||||
return key_mapping.get(key, key)
|
||||
return variants.get(modifier_name, set())
|
||||
|
||||
|
||||
# Export availability
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
Keyboard shortcut manager for Vocalinux.
|
||||
|
||||
This module provides global keyboard shortcut functionality to
|
||||
start/stop speech recognition with a double-tap of the Ctrl key.
|
||||
start/stop speech recognition with configurable double-tap shortcuts.
|
||||
|
||||
Supports multiple backends:
|
||||
- pynput: Works on X11/XWayland
|
||||
@@ -13,7 +13,15 @@ import logging
|
||||
from typing import Callable, Optional
|
||||
|
||||
# Import the backend system
|
||||
from .keyboard_backends import EVDEV_AVAILABLE, PYNPUT_AVAILABLE, DesktopEnvironment, create_backend
|
||||
from .keyboard_backends import (
|
||||
DEFAULT_SHORTCUT,
|
||||
EVDEV_AVAILABLE,
|
||||
PYNPUT_AVAILABLE,
|
||||
SHORTCUT_DISPLAY_NAMES,
|
||||
SUPPORTED_SHORTCUTS,
|
||||
DesktopEnvironment,
|
||||
create_backend,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -41,26 +49,28 @@ class KeyboardShortcutManager:
|
||||
"""
|
||||
Manages global keyboard shortcuts for the application.
|
||||
|
||||
This class allows registering the double-tap Ctrl shortcut to
|
||||
This class allows registering configurable double-tap shortcuts to
|
||||
toggle voice typing on and off across the desktop environment.
|
||||
|
||||
Automatically selects the appropriate backend based on the
|
||||
desktop environment (X11, Wayland) and available dependencies.
|
||||
"""
|
||||
|
||||
def __init__(self, backend: Optional[str] = None):
|
||||
def __init__(self, backend: Optional[str] = None, shortcut: str = DEFAULT_SHORTCUT):
|
||||
"""
|
||||
Initialize the keyboard shortcut manager.
|
||||
|
||||
Args:
|
||||
backend: Optional backend name to force ('pynput' or 'evdev')
|
||||
If not specified, auto-detects based on environment.
|
||||
shortcut: The shortcut to listen for (e.g., "ctrl+ctrl", "alt+alt")
|
||||
"""
|
||||
self.backend_instance = None
|
||||
self.active = False
|
||||
self._shortcut = shortcut
|
||||
|
||||
# Create the appropriate backend
|
||||
self.backend_instance = create_backend(preferred_backend=backend)
|
||||
self.backend_instance = create_backend(preferred_backend=backend, shortcut=shortcut)
|
||||
|
||||
if self.backend_instance is None:
|
||||
logger.error("No keyboard backend available. Shortcuts will not work.")
|
||||
@@ -85,6 +95,102 @@ class KeyboardShortcutManager:
|
||||
logger.warning("Keyboard shortcuts require pynput or evdev:")
|
||||
logger.warning(" pip install pynput evdev")
|
||||
|
||||
@property
|
||||
def shortcut(self) -> str:
|
||||
"""Get the current shortcut string."""
|
||||
return self._shortcut
|
||||
|
||||
@property
|
||||
def shortcut_display_name(self) -> str:
|
||||
"""Get the human-readable name for the current shortcut."""
|
||||
return SHORTCUT_DISPLAY_NAMES.get(self._shortcut, self._shortcut)
|
||||
|
||||
def set_shortcut(self, shortcut: str) -> bool:
|
||||
"""
|
||||
Update the shortcut to listen for.
|
||||
|
||||
Note: This only updates the internal state. Use restart_with_shortcut()
|
||||
to apply changes to an active listener.
|
||||
|
||||
Args:
|
||||
shortcut: The new shortcut string (e.g., "ctrl+ctrl", "alt+alt")
|
||||
|
||||
Returns:
|
||||
True if successful, False if the shortcut is invalid
|
||||
"""
|
||||
if shortcut not in SUPPORTED_SHORTCUTS:
|
||||
logger.error(f"Invalid shortcut: {shortcut}")
|
||||
return False
|
||||
|
||||
self._shortcut = shortcut
|
||||
|
||||
if self.backend_instance:
|
||||
self.backend_instance.set_shortcut(shortcut)
|
||||
logger.info(f"Shortcut updated to: {SHORTCUT_DISPLAY_NAMES.get(shortcut, shortcut)}")
|
||||
|
||||
return True
|
||||
|
||||
def restart_with_shortcut(self, shortcut: str) -> bool:
|
||||
"""
|
||||
Restart the keyboard listener with a new shortcut.
|
||||
|
||||
This stops the current listener, updates the shortcut configuration,
|
||||
and starts a new listener with the new shortcut. This enables live
|
||||
shortcut switching without requiring an application restart.
|
||||
|
||||
Args:
|
||||
shortcut: The new shortcut string (e.g., "ctrl+ctrl", "alt+alt")
|
||||
|
||||
Returns:
|
||||
True if the listener was successfully restarted with the new shortcut,
|
||||
False if the shortcut is invalid or restart failed
|
||||
"""
|
||||
if shortcut not in SUPPORTED_SHORTCUTS:
|
||||
logger.error(f"Invalid shortcut: {shortcut}")
|
||||
return False
|
||||
|
||||
if shortcut == self._shortcut:
|
||||
logger.debug(f"Shortcut already set to {shortcut}, no restart needed")
|
||||
return True
|
||||
|
||||
was_active = self.active
|
||||
callback = None
|
||||
|
||||
# Save the current callback before stopping
|
||||
if self.backend_instance:
|
||||
callback = self.backend_instance.double_tap_callback
|
||||
|
||||
# Stop the current listener
|
||||
if was_active:
|
||||
logger.info(f"Stopping listener for shortcut change: {self._shortcut} -> {shortcut}")
|
||||
self.stop()
|
||||
|
||||
# Update the shortcut
|
||||
self._shortcut = shortcut
|
||||
|
||||
# Update backend shortcut
|
||||
if self.backend_instance:
|
||||
self.backend_instance.set_shortcut(shortcut)
|
||||
|
||||
# Restart if it was active
|
||||
if was_active:
|
||||
# Re-register the callback
|
||||
if callback:
|
||||
self.register_toggle_callback(callback)
|
||||
|
||||
success = self.start()
|
||||
if success:
|
||||
logger.info(
|
||||
f"Listener restarted with new shortcut: "
|
||||
f"{SHORTCUT_DISPLAY_NAMES.get(shortcut, shortcut)}"
|
||||
)
|
||||
else:
|
||||
logger.error(f"Failed to restart listener with shortcut: {shortcut}")
|
||||
return success
|
||||
|
||||
logger.info(f"Shortcut updated to: {SHORTCUT_DISPLAY_NAMES.get(shortcut, shortcut)}")
|
||||
return True
|
||||
|
||||
def start(self) -> bool:
|
||||
"""
|
||||
Start listening for keyboard shortcuts.
|
||||
@@ -98,7 +204,7 @@ class KeyboardShortcutManager:
|
||||
if self.active:
|
||||
return True
|
||||
|
||||
logger.info("Starting keyboard shortcut listener")
|
||||
logger.info(f"Starting keyboard shortcut listener for: {self.shortcut_display_name}")
|
||||
self.active = self.backend_instance.start()
|
||||
|
||||
if not self.active:
|
||||
@@ -119,17 +225,17 @@ class KeyboardShortcutManager:
|
||||
|
||||
def register_toggle_callback(self, callback: Callable):
|
||||
"""
|
||||
Register a callback for the double-tap Ctrl shortcut.
|
||||
Register a callback for the double-tap shortcut.
|
||||
|
||||
Args:
|
||||
callback: Function to call when the double-tap Ctrl is pressed
|
||||
callback: Function to call when the double-tap shortcut is pressed
|
||||
"""
|
||||
if self.backend_instance is None:
|
||||
logger.warning("Cannot register callback: no backend available")
|
||||
return
|
||||
|
||||
self.backend_instance.register_toggle_callback(callback)
|
||||
logger.info("Registered shortcut: Double-tap Ctrl")
|
||||
logger.info(f"Registered shortcut: {self.shortcut_display_name}")
|
||||
|
||||
@property
|
||||
def listener(self):
|
||||
@@ -173,4 +279,7 @@ __all__ = [
|
||||
"DesktopEnvironment",
|
||||
"EVDEV_AVAILABLE",
|
||||
"PYNPUT_AVAILABLE",
|
||||
"SUPPORTED_SHORTCUTS",
|
||||
"SHORTCUT_DISPLAY_NAMES",
|
||||
"DEFAULT_SHORTCUT",
|
||||
]
|
||||
|
||||
@@ -29,6 +29,7 @@ from gi.repository import Gdk, GLib, Gtk, Pango # noqa: E402
|
||||
|
||||
from ..common_types import RecognitionState # noqa: E402
|
||||
from ..utils.vosk_model_info import SUPPORTED_LANGUAGES, VOSK_MODEL_INFO # noqa: E402
|
||||
from .keyboard_backends import SHORTCUT_DISPLAY_NAMES, SUPPORTED_SHORTCUTS # noqa: E402
|
||||
|
||||
# Avoid circular imports for type checking
|
||||
if TYPE_CHECKING:
|
||||
@@ -588,10 +589,12 @@ class SettingsDialog(Gtk.Dialog):
|
||||
parent: Gtk.Window,
|
||||
config_manager: "ConfigManager",
|
||||
speech_engine: "SpeechRecognitionManager",
|
||||
shortcut_update_callback: callable = None,
|
||||
):
|
||||
super().__init__(title="Vocalinux Settings", transient_for=parent, flags=0)
|
||||
self.config_manager = config_manager
|
||||
self.speech_engine = speech_engine
|
||||
self.shortcut_update_callback = shortcut_update_callback
|
||||
self._test_active = False
|
||||
self._test_result = ""
|
||||
self._initializing = True # Flag to prevent auto-apply during initialization
|
||||
@@ -628,6 +631,7 @@ class SettingsDialog(Gtk.Dialog):
|
||||
self._build_audio_section()
|
||||
self._build_engine_section()
|
||||
self._build_recognition_section()
|
||||
self._build_shortcuts_section()
|
||||
self._build_test_section()
|
||||
|
||||
# Load settings and populate UI
|
||||
@@ -820,6 +824,95 @@ class SettingsDialog(Gtk.Dialog):
|
||||
self.vad_spin.connect("value-changed", self._on_vad_changed)
|
||||
self.silence_spin.connect("value-changed", self._on_silence_changed)
|
||||
|
||||
def _build_shortcuts_section(self):
|
||||
"""Build the Keyboard Shortcuts section."""
|
||||
group = PreferencesGroup(
|
||||
title="Keyboard Shortcuts",
|
||||
description="Configure the shortcut to toggle voice recognition",
|
||||
)
|
||||
|
||||
# Shortcut selection combo
|
||||
self.shortcut_combo = Gtk.ComboBoxText()
|
||||
self.shortcut_combo.set_size_request(200, -1)
|
||||
self.shortcut_combo.set_tooltip_text("Select the keyboard shortcut to toggle voice typing")
|
||||
|
||||
# Populate shortcut options
|
||||
for shortcut_id, display_name in SHORTCUT_DISPLAY_NAMES.items():
|
||||
self.shortcut_combo.append(shortcut_id, display_name)
|
||||
|
||||
# Load current shortcut from config
|
||||
current_shortcut = self.config_manager.get("shortcuts", "toggle_recognition", "ctrl+ctrl")
|
||||
if not self.shortcut_combo.set_active_id(current_shortcut):
|
||||
self.shortcut_combo.set_active_id("ctrl+ctrl")
|
||||
|
||||
shortcut_row = PreferenceRow(
|
||||
title="Toggle Recognition",
|
||||
subtitle="Press this shortcut twice quickly to start/stop",
|
||||
widget=self.shortcut_combo,
|
||||
)
|
||||
group.add_row(shortcut_row)
|
||||
|
||||
self.content_box.pack_start(group, False, False, 0)
|
||||
|
||||
# Info box about the shortcut
|
||||
info_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
|
||||
info_box.get_style_context().add_class("info-box")
|
||||
info_box.set_margin_start(4)
|
||||
info_box.set_margin_end(4)
|
||||
info_box.set_margin_top(4)
|
||||
|
||||
info_icon = Gtk.Image.new_from_icon_name("dialog-information-symbolic", Gtk.IconSize.MENU)
|
||||
info_box.pack_start(info_icon, False, False, 0)
|
||||
|
||||
self.shortcut_info_label = Gtk.Label(
|
||||
label="Changes take effect immediately. Double-tap the key to toggle voice typing.",
|
||||
xalign=0,
|
||||
wrap=True,
|
||||
)
|
||||
self.shortcut_info_label.get_style_context().add_class("tip-label")
|
||||
info_box.pack_start(self.shortcut_info_label, True, True, 0)
|
||||
|
||||
self.content_box.pack_start(info_box, False, False, 0)
|
||||
|
||||
# Connect signal
|
||||
self.shortcut_combo.connect("changed", self._on_shortcut_changed)
|
||||
|
||||
def _on_shortcut_changed(self, widget):
|
||||
"""Handle shortcut selection change."""
|
||||
if self._initializing:
|
||||
return
|
||||
|
||||
shortcut_id = self.shortcut_combo.get_active_id()
|
||||
if not shortcut_id:
|
||||
return
|
||||
|
||||
# Save to config
|
||||
self.config_manager.set("shortcuts", "toggle_recognition", shortcut_id)
|
||||
self.config_manager.save_settings()
|
||||
|
||||
display_name = SHORTCUT_DISPLAY_NAMES.get(shortcut_id, shortcut_id)
|
||||
logger.info(f"Keyboard shortcut changed to: {display_name}")
|
||||
|
||||
# Try to apply the shortcut change live
|
||||
if self.shortcut_update_callback:
|
||||
success = self.shortcut_update_callback(shortcut_id)
|
||||
if success:
|
||||
self.shortcut_info_label.set_markup(
|
||||
f"<span foreground='#26a269'>Shortcut updated to <b>{display_name}</b>. "
|
||||
f"Active now!</span>"
|
||||
)
|
||||
else:
|
||||
self.shortcut_info_label.set_markup(
|
||||
f"<i>Shortcut updated to <b>{display_name}</b>. "
|
||||
f"Restart the app for the change to take full effect.</i>"
|
||||
)
|
||||
else:
|
||||
# No callback provided, fall back to restart message
|
||||
self.shortcut_info_label.set_markup(
|
||||
f"<i>Shortcut updated to <b>{display_name}</b>. "
|
||||
f"Restart the app for the change to take full effect.</i>"
|
||||
)
|
||||
|
||||
def _build_test_section(self):
|
||||
"""Build the Test Recognition section."""
|
||||
group = PreferencesGroup(title="Test Recognition")
|
||||
|
||||
@@ -78,10 +78,11 @@ class TrayIndicator:
|
||||
self.text_injector = text_injector
|
||||
self.config_manager = ConfigManager() # Added: Initialize ConfigManager
|
||||
|
||||
# Initialize keyboard shortcut manager
|
||||
self.shortcut_manager = (
|
||||
KeyboardShortcutManager()
|
||||
) # Pass config_manager - Removed config_manager argument
|
||||
# Get configured shortcut from config
|
||||
shortcut = self.config_manager.get("shortcuts", "toggle_recognition", "ctrl+ctrl")
|
||||
|
||||
# Initialize keyboard shortcut manager with configured shortcut
|
||||
self.shortcut_manager = KeyboardShortcutManager(shortcut=shortcut)
|
||||
|
||||
# Ensure icon directory exists
|
||||
os.makedirs(ICON_DIR, exist_ok=True)
|
||||
@@ -278,6 +279,7 @@ class TrayIndicator:
|
||||
parent=None, # Or get the main window if available
|
||||
config_manager=self.config_manager,
|
||||
speech_engine=self.speech_engine,
|
||||
shortcut_update_callback=self.update_shortcut,
|
||||
)
|
||||
|
||||
# Connect to the response signal
|
||||
@@ -304,6 +306,20 @@ class TrayIndicator:
|
||||
logger.info("Settings dialog closed.")
|
||||
dialog.destroy()
|
||||
|
||||
def update_shortcut(self, shortcut: str) -> bool:
|
||||
"""
|
||||
Update the keyboard shortcut for toggling voice recognition.
|
||||
|
||||
This performs a live shortcut switch without requiring an app restart.
|
||||
|
||||
Args:
|
||||
shortcut: The new shortcut string (e.g., "ctrl+ctrl", "alt+alt")
|
||||
|
||||
Returns:
|
||||
True if the shortcut was updated successfully, False otherwise
|
||||
"""
|
||||
return self.shortcut_manager.restart_with_shortcut(shortcut)
|
||||
|
||||
def _on_about_clicked(self, widget):
|
||||
"""Handle click on the About menu item."""
|
||||
from .about_dialog import show_about_dialog
|
||||
|
||||
Reference in New Issue
Block a user