feat(shortcuts): add push-to-talk mode and improve shortcut reliability (#278)
* feat(shortcuts): add push-to-talk mode as alternative to toggle This commit implements push-to-talk functionality (Issue #252) as an alternative to the existing double-tap toggle mode: - Add keyboard shortcut mode setting (toggle vs push_to_talk) - In push-to-talk mode: hold key to speak, release to stop - Update both pynput and evdev backends to support press/release callbacks - Add UI controls in settings dialog for mode selection - Maintain backward compatibility with existing toggle mode as default Closes #252 Related: Closes #185 (superseded by push-to-talk approach) * fix(shortcuts): remove duplicate code blocks that broke settings dialog The previous push-to-talk implementation had duplicate code blocks in tray_indicator.py and settings_dialog.py that caused the settings dialog to fail silently. This commit cleans up the duplicate code while preserving the push-to-talk functionality. Fixes issue where settings dialog would not open. * fix(ui): add set_subtitle method to PreferenceRow class The PreferenceRow class was missing a set_subtitle method, which is called by _update_shortcut_ui_for_mode when switching between toggle and push-to-talk modes. This caused an AttributeError that silently crashed the settings dialog during initialization. Also stores subtitle_label as instance variable for later updates. * fix(keyboard): remove duplicate code and fix mode handling Critical fixes for keyboard backends: - evdev_backend.py: Removed duplicate __init__ and _handle_key_event methods - pynput_backend.py: Removed duplicate __init__, _on_press, _on_release methods - Both backends now properly handle toggle and push-to-talk modes Fixed first_run_dialog.py: - Added do_response() override to properly close dialog - Added explicit destroy() call in show_first_run_dialog() Fixed tray_indicator.py: - Added _unregister_keyboard_callbacks() method - Updated _setup_keyboard_shortcuts() to properly clean up old callbacks - Updated update_shortcut() to properly handle mode switching Fixed settings_dialog.py: - Added set_subtitle() method to PreferenceRow class - Updated UI text for push-to-talk mode - Fixed _on_shortcut_changed to properly pass mode to callback All 547 tests pass locally. * feat(shortcuts): improve mode switching and add mode-aware display names Key improvements: - Add SHORTCUT_MODE_DISPLAY_NAMES for mode-specific shortcut names - Add get_shortcut_display_name() function for mode-aware display - Fix restart_with_shortcut() to properly save and restore all callbacks - Fix update_shortcut() to properly handle mode changes - Fix _setup_keyboard_shortcuts() to clear old callbacks before registering new ones - Ensure only one type of callback is active at a time This fixes issues where: - Old callbacks remained registered after mode switch - Single-tap triggered action after switching from push-to-talk to toggle - Display names weren't appropriate for the selected mode * fix(shortcuts): stabilize live mode switching callback lifecycle * test(shortcuts): cover callback reset on mode transitions * fix(shortcuts): remove super preset and migrate legacy config * test(shortcuts): update coverage for super preset removal
This commit is contained in:
@@ -34,7 +34,8 @@ DEFAULT_CONFIG = {
|
||||
},
|
||||
"shortcuts": {
|
||||
"toggle_recognition": "ctrl+ctrl", # Double-tap modifier key
|
||||
# Supported values: "ctrl+ctrl", "alt+alt", "shift+shift", "super+super"
|
||||
"mode": "toggle", # "toggle" or "push_to_talk"
|
||||
# Supported values: "ctrl+ctrl", "alt+alt", "shift+shift"
|
||||
# These represent double-tap shortcuts for the respective modifier keys
|
||||
},
|
||||
"ui": {
|
||||
@@ -97,6 +98,8 @@ class ConfigManager:
|
||||
if needs_migration:
|
||||
self._migrate_config(user_config)
|
||||
|
||||
self._migrate_shortcuts_config()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to load config: {e}")
|
||||
|
||||
@@ -133,6 +136,15 @@ class ConfigManager:
|
||||
self.save_config()
|
||||
logger.info("Config migrated to new per-engine model format")
|
||||
|
||||
def _migrate_shortcuts_config(self):
|
||||
shortcuts_config = self.config.get("shortcuts", {})
|
||||
shortcut = shortcuts_config.get("toggle_recognition")
|
||||
|
||||
if shortcut == "super+super":
|
||||
shortcuts_config["toggle_recognition"] = "ctrl+ctrl"
|
||||
self.save_config()
|
||||
logger.info("Migrated deprecated super+super shortcut to ctrl+ctrl")
|
||||
|
||||
def save_config(self):
|
||||
"""Save the current configuration to the config file."""
|
||||
try:
|
||||
|
||||
@@ -40,10 +40,6 @@ class FirstRunDialog(Gtk.Dialog):
|
||||
}
|
||||
self.result = None
|
||||
|
||||
# Connect to response signal to handle button clicks properly
|
||||
# This avoids race conditions when destroying the dialog from button handlers
|
||||
self.connect("response", self._on_response)
|
||||
|
||||
box = self.get_content_area()
|
||||
box.set_spacing(16)
|
||||
box.set_margin_start(24)
|
||||
@@ -87,9 +83,7 @@ class FirstRunDialog(Gtk.Dialog):
|
||||
button_box.set_halign(Gtk.Align.CENTER)
|
||||
button_box.set_margin_top(16)
|
||||
|
||||
# Use add_button with response IDs instead of custom click handlers
|
||||
# This ensures proper dialog lifecycle management and avoids GTK widget
|
||||
# access issues on Wayland (gtk_widget_get_scale_factor assertion failures)
|
||||
# Use add_button with response IDs - these will automatically emit response signals
|
||||
yes_button = self.add_button("Yes, start on login", RESPONSE_YES)
|
||||
yes_button.get_style_context().add_class("suggested-action")
|
||||
|
||||
@@ -111,15 +105,11 @@ class FirstRunDialog(Gtk.Dialog):
|
||||
|
||||
self.show_all()
|
||||
|
||||
def _on_response(self, dialog, response_id):
|
||||
"""Handle dialog response signals properly.
|
||||
|
||||
Using the response signal instead of direct destroy() calls from button
|
||||
handlers prevents race conditions on Wayland where GTK may try to access
|
||||
widget properties (like scale_factor) after the widget is marked for
|
||||
destruction but before the event processing completes.
|
||||
"""
|
||||
def do_response(self, response_id):
|
||||
"""Handle dialog response - override default handler to set result."""
|
||||
self.result = self._response_map.get(response_id, None)
|
||||
# Call parent to actually close the dialog
|
||||
Gtk.Dialog.do_response(self, response_id)
|
||||
|
||||
|
||||
def show_first_run_dialog(parent: Optional[Gtk.Window] = None) -> Optional[str]:
|
||||
@@ -137,4 +127,6 @@ def show_first_run_dialog(parent: Optional[Gtk.Window] = None) -> Optional[str]:
|
||||
"""
|
||||
dialog = FirstRunDialog(parent)
|
||||
dialog.run()
|
||||
return dialog.result
|
||||
result = dialog.result
|
||||
dialog.destroy()
|
||||
return result
|
||||
|
||||
@@ -11,9 +11,13 @@ from typing import Optional
|
||||
|
||||
from .base import (
|
||||
DEFAULT_SHORTCUT,
|
||||
DEFAULT_SHORTCUT_MODE,
|
||||
SHORTCUT_DISPLAY_NAMES,
|
||||
SHORTCUT_MODE_DISPLAY_NAMES,
|
||||
SHORTCUT_MODES,
|
||||
SUPPORTED_SHORTCUTS,
|
||||
KeyboardBackend,
|
||||
get_shortcut_display_name,
|
||||
parse_shortcut,
|
||||
)
|
||||
|
||||
@@ -69,7 +73,9 @@ class DesktopEnvironment:
|
||||
|
||||
|
||||
def create_backend(
|
||||
preferred_backend: Optional[str] = None, shortcut: str = DEFAULT_SHORTCUT
|
||||
preferred_backend: Optional[str] = None,
|
||||
shortcut: str = DEFAULT_SHORTCUT,
|
||||
mode: str = DEFAULT_SHORTCUT_MODE,
|
||||
) -> Optional[KeyboardBackend]:
|
||||
"""
|
||||
Create a keyboard backend based on availability and environment.
|
||||
@@ -77,6 +83,7 @@ def create_backend(
|
||||
Args:
|
||||
preferred_backend: Optional backend name to force ('pynput' or 'evdev')
|
||||
shortcut: The shortcut string to listen for (e.g., "ctrl+ctrl", "alt+alt")
|
||||
mode: The shortcut mode ("toggle" or "push_to_talk")
|
||||
|
||||
Returns:
|
||||
A KeyboardBackend instance, or None if no backend is available
|
||||
@@ -84,13 +91,14 @@ def create_backend(
|
||||
env = DesktopEnvironment.detect()
|
||||
logger.info(f"Detected desktop environment: {env}")
|
||||
logger.info(f"Configured shortcut: {shortcut}")
|
||||
logger.info(f"Configured mode: {mode}")
|
||||
|
||||
# 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(shortcut=shortcut) # type: ignore
|
||||
backend = EvdevKeyboardBackend(shortcut=shortcut, mode=mode) # type: ignore
|
||||
if backend.is_available():
|
||||
return backend
|
||||
hint = backend.get_permission_hint()
|
||||
@@ -102,7 +110,7 @@ def create_backend(
|
||||
elif preferred_backend == "pynput":
|
||||
if PYNPUT_AVAILABLE:
|
||||
logger.info("Using pynput backend (preferred)")
|
||||
return PynputKeyboardBackend(shortcut=shortcut) # type: ignore
|
||||
return PynputKeyboardBackend(shortcut=shortcut, mode=mode) # type: ignore
|
||||
logger.warning("Pynput backend not available")
|
||||
else:
|
||||
logger.warning(f"Unknown preferred backend: '{preferred_backend}'")
|
||||
@@ -112,7 +120,7 @@ def create_backend(
|
||||
# On Wayland, prefer evdev (if available) as pynput doesn't work
|
||||
if EVDEV_AVAILABLE:
|
||||
logger.info("Using evdev backend for Wayland")
|
||||
backend = EvdevKeyboardBackend(shortcut=shortcut) # type: ignore
|
||||
backend = EvdevKeyboardBackend(shortcut=shortcut, mode=mode) # type: ignore
|
||||
if backend.is_available():
|
||||
return backend
|
||||
hint = backend.get_permission_hint()
|
||||
@@ -130,11 +138,11 @@ def create_backend(
|
||||
# Default to pynput for X11 or unknown
|
||||
if PYNPUT_AVAILABLE:
|
||||
logger.info("Using pynput backend")
|
||||
return PynputKeyboardBackend(shortcut=shortcut) # type: ignore
|
||||
return PynputKeyboardBackend(shortcut=shortcut, mode=mode) # type: ignore
|
||||
|
||||
if EVDEV_AVAILABLE:
|
||||
logger.warning("Pynput not available, falling back to evdev backend")
|
||||
backend = EvdevKeyboardBackend(shortcut=shortcut) # type: ignore
|
||||
backend = EvdevKeyboardBackend(shortcut=shortcut, mode=mode) # type: ignore
|
||||
if backend.is_available():
|
||||
return backend
|
||||
hint = backend.get_permission_hint()
|
||||
@@ -157,6 +165,8 @@ __all__ = [
|
||||
"PYNPUT_AVAILABLE",
|
||||
"SUPPORTED_SHORTCUTS",
|
||||
"SHORTCUT_DISPLAY_NAMES",
|
||||
"SHORTCUT_MODES",
|
||||
"DEFAULT_SHORTCUT",
|
||||
"DEFAULT_SHORTCUT_MODE",
|
||||
"parse_shortcut",
|
||||
]
|
||||
|
||||
@@ -8,24 +8,66 @@ the required methods.
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Callable, Optional
|
||||
|
||||
Callback = Callable[[], None]
|
||||
|
||||
# Supported double-tap shortcut keys
|
||||
SUPPORTED_SHORTCUTS = {
|
||||
"ctrl+ctrl": "ctrl",
|
||||
"alt+alt": "alt",
|
||||
"shift+shift": "shift",
|
||||
"super+super": "super",
|
||||
}
|
||||
|
||||
# Human-readable names for shortcuts
|
||||
# Human-readable names for shortcuts (mode-agnostic base names)
|
||||
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)",
|
||||
"ctrl+ctrl": "Ctrl",
|
||||
"alt+alt": "Alt",
|
||||
"shift+shift": "Shift",
|
||||
}
|
||||
|
||||
# Mode-specific display names (format: {shortcut: {mode: display_name}})
|
||||
SHORTCUT_MODE_DISPLAY_NAMES = {
|
||||
"ctrl+ctrl": {
|
||||
"toggle": "Double-tap Ctrl",
|
||||
"push_to_talk": "Hold Ctrl",
|
||||
},
|
||||
"alt+alt": {
|
||||
"toggle": "Double-tap Alt",
|
||||
"push_to_talk": "Hold Alt",
|
||||
},
|
||||
"shift+shift": {
|
||||
"toggle": "Double-tap Shift",
|
||||
"push_to_talk": "Hold Shift",
|
||||
},
|
||||
}
|
||||
|
||||
DEFAULT_SHORTCUT = "ctrl+ctrl"
|
||||
|
||||
# Supported shortcut modes
|
||||
SHORTCUT_MODES = {
|
||||
"toggle": "Toggle (double-tap to start/stop)",
|
||||
"push_to_talk": "Push-to-Talk (hold to speak)",
|
||||
}
|
||||
|
||||
DEFAULT_SHORTCUT_MODE = "toggle"
|
||||
|
||||
|
||||
def get_shortcut_display_name(shortcut: str, mode: Optional[str] = None) -> str:
|
||||
"""
|
||||
Get a human-readable display name for a shortcut.
|
||||
|
||||
Args:
|
||||
shortcut: The shortcut string (e.g., "ctrl+ctrl")
|
||||
mode: Optional mode string. If provided, returns mode-specific name.
|
||||
|
||||
Returns:
|
||||
A human-readable display name for the shortcut
|
||||
"""
|
||||
if mode and shortcut in SHORTCUT_MODE_DISPLAY_NAMES:
|
||||
return SHORTCUT_MODE_DISPLAY_NAMES[shortcut].get(
|
||||
mode, SHORTCUT_DISPLAY_NAMES.get(shortcut, shortcut)
|
||||
)
|
||||
return SHORTCUT_DISPLAY_NAMES.get(shortcut, shortcut)
|
||||
|
||||
|
||||
def parse_shortcut(shortcut_string: str) -> str:
|
||||
"""
|
||||
@@ -57,16 +99,20 @@ class KeyboardBackend(ABC):
|
||||
event listening and registering callbacks for specific shortcuts.
|
||||
"""
|
||||
|
||||
def __init__(self, shortcut: str = DEFAULT_SHORTCUT):
|
||||
def __init__(self, shortcut: str = DEFAULT_SHORTCUT, mode: str = DEFAULT_SHORTCUT_MODE):
|
||||
"""
|
||||
Initialize the keyboard backend.
|
||||
|
||||
Args:
|
||||
shortcut: The shortcut string to listen for (e.g., "ctrl+ctrl")
|
||||
mode: The shortcut mode ("toggle" or "push_to_talk")
|
||||
"""
|
||||
self.active = False
|
||||
self.double_tap_callback: Optional[Callable] = None
|
||||
self.double_tap_callback: Optional[Callback] = None
|
||||
self.key_press_callback: Optional[Callback] = None
|
||||
self.key_release_callback: Optional[Callback] = None
|
||||
self._shortcut = shortcut
|
||||
self._mode = mode
|
||||
self._modifier_key = parse_shortcut(shortcut)
|
||||
|
||||
@property
|
||||
@@ -74,6 +120,22 @@ class KeyboardBackend(ABC):
|
||||
"""Get the current shortcut string."""
|
||||
return self._shortcut
|
||||
|
||||
@property
|
||||
def mode(self) -> str:
|
||||
"""Get the current shortcut mode."""
|
||||
return self._mode
|
||||
|
||||
def set_mode(self, mode: str) -> None:
|
||||
"""
|
||||
Update the shortcut mode.
|
||||
|
||||
Args:
|
||||
mode: The new mode ("toggle" or "push_to_talk")
|
||||
"""
|
||||
if mode not in SHORTCUT_MODES:
|
||||
raise ValueError(f"Invalid mode: {mode}. Must be one of {list(SHORTCUT_MODES.keys())}")
|
||||
self._mode = mode
|
||||
|
||||
@property
|
||||
def modifier_key(self) -> str:
|
||||
"""Get the modifier key being watched for double-tap."""
|
||||
@@ -124,7 +186,7 @@ class KeyboardBackend(ABC):
|
||||
"""
|
||||
pass
|
||||
|
||||
def register_toggle_callback(self, callback: Callable) -> None:
|
||||
def register_toggle_callback(self, callback: Optional[Callback]) -> None:
|
||||
"""
|
||||
Register a callback for the double-tap shortcut.
|
||||
|
||||
@@ -132,3 +194,21 @@ class KeyboardBackend(ABC):
|
||||
callback: Function to call when double-tap is detected
|
||||
"""
|
||||
self.double_tap_callback = callback
|
||||
|
||||
def register_press_callback(self, callback: Optional[Callback]) -> None:
|
||||
"""
|
||||
Register a callback for key press events (push-to-talk mode).
|
||||
|
||||
Args:
|
||||
callback: Function to call when the shortcut key is pressed
|
||||
"""
|
||||
self.key_press_callback = callback
|
||||
|
||||
def register_release_callback(self, callback: Optional[Callback]) -> None:
|
||||
"""
|
||||
Register a callback for key release events (push-to-talk mode).
|
||||
|
||||
Args:
|
||||
callback: Function to call when the shortcut key is released
|
||||
"""
|
||||
self.key_release_callback = callback
|
||||
|
||||
@@ -25,7 +25,7 @@ except ImportError:
|
||||
ecodes = None # type: ignore
|
||||
EVDEV_AVAILABLE = False
|
||||
|
||||
from .base import DEFAULT_SHORTCUT, KeyboardBackend
|
||||
from .base import DEFAULT_SHORTCUT, DEFAULT_SHORTCUT_MODE, KeyboardBackend, parse_shortcut
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -134,14 +134,15 @@ class EvdevKeyboardBackend(KeyboardBackend):
|
||||
to read from /dev/input/event* devices (member of 'input' group).
|
||||
"""
|
||||
|
||||
def __init__(self, shortcut: str = DEFAULT_SHORTCUT):
|
||||
def __init__(self, shortcut: str = DEFAULT_SHORTCUT, mode: str = DEFAULT_SHORTCUT_MODE):
|
||||
"""
|
||||
Initialize the evdev keyboard backend.
|
||||
|
||||
Args:
|
||||
shortcut: The shortcut string to listen for (e.g., "ctrl+ctrl")
|
||||
mode: The shortcut mode ("toggle" or "push_to_talk")
|
||||
"""
|
||||
super().__init__(shortcut)
|
||||
super().__init__(shortcut, mode)
|
||||
self.devices: List[InputDevice] = []
|
||||
self.device_fds: List[int] = []
|
||||
self.running = False
|
||||
@@ -205,7 +206,7 @@ class EvdevKeyboardBackend(KeyboardBackend):
|
||||
except (OSError, IOError) as e:
|
||||
if "Permission denied" in str(e) or e.errno == errno.EACCES:
|
||||
return (
|
||||
"Add your user to the 'input' group and log out/in:\\n"
|
||||
"Add your user to the 'input' group and log out/in:\n"
|
||||
"sudo usermod -a -G input $USER"
|
||||
)
|
||||
except Exception:
|
||||
@@ -234,7 +235,7 @@ class EvdevKeyboardBackend(KeyboardBackend):
|
||||
return False
|
||||
|
||||
logger.info(f"Found {len(device_paths)} keyboard device(s)")
|
||||
logger.info(f"Listening for shortcut: {self._shortcut}")
|
||||
logger.info(f"Listening for shortcut: {self._shortcut} (mode: {self._mode})")
|
||||
|
||||
# Open devices
|
||||
self.devices = []
|
||||
@@ -361,21 +362,33 @@ class EvdevKeyboardBackend(KeyboardBackend):
|
||||
self.key_pressed_devices.add(device_id)
|
||||
current_time = time.time()
|
||||
|
||||
# Check for double-tap
|
||||
if (
|
||||
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(f"Double-tap {self._modifier_key} detected (evdev)")
|
||||
self.last_trigger_time = current_time
|
||||
threading.Thread(target=self.double_tap_callback, daemon=True).start()
|
||||
if self._mode == "toggle":
|
||||
# Check for double-tap
|
||||
if (
|
||||
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(f"Double-tap {self._modifier_key} detected (evdev)")
|
||||
self.last_trigger_time = current_time
|
||||
threading.Thread(target=self.double_tap_callback, daemon=True).start()
|
||||
elif self._mode == "push_to_talk":
|
||||
# Trigger on press
|
||||
if self.key_press_callback is not None:
|
||||
logger.debug(f"Key press {self._modifier_key} detected (evdev)")
|
||||
threading.Thread(target=self.key_press_callback, daemon=True).start()
|
||||
|
||||
self.last_key_press_time = current_time
|
||||
|
||||
elif value == 0: # Key release
|
||||
self.key_pressed_devices.discard(device_id)
|
||||
|
||||
if self._mode == "push_to_talk":
|
||||
# Trigger on release
|
||||
if self.key_release_callback is not None:
|
||||
logger.debug(f"Key release {self._modifier_key} detected (evdev)")
|
||||
threading.Thread(target=self.key_release_callback, daemon=True).start()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error handling key event: {e}")
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ except ImportError:
|
||||
keyboard = None # type: ignore
|
||||
PYNPUT_AVAILABLE = False
|
||||
|
||||
from .base import DEFAULT_SHORTCUT, KeyboardBackend
|
||||
from .base import DEFAULT_SHORTCUT, DEFAULT_SHORTCUT_MODE, KeyboardBackend
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -55,14 +55,15 @@ class PynputKeyboardBackend(KeyboardBackend):
|
||||
due to Wayland's security restrictions.
|
||||
"""
|
||||
|
||||
def __init__(self, shortcut: str = DEFAULT_SHORTCUT):
|
||||
def __init__(self, shortcut: str = DEFAULT_SHORTCUT, mode: str = DEFAULT_SHORTCUT_MODE):
|
||||
"""
|
||||
Initialize the pynput keyboard backend.
|
||||
|
||||
Args:
|
||||
shortcut: The shortcut string to listen for (e.g., "ctrl+ctrl")
|
||||
mode: The shortcut mode ("toggle" or "push_to_talk")
|
||||
"""
|
||||
super().__init__(shortcut)
|
||||
super().__init__(shortcut, mode)
|
||||
self.listener = None
|
||||
self.last_trigger_time = 0
|
||||
self.last_key_press_time = 0
|
||||
@@ -103,7 +104,9 @@ class PynputKeyboardBackend(KeyboardBackend):
|
||||
if self.active:
|
||||
return True
|
||||
|
||||
logger.info(f"Starting pynput keyboard listener for shortcut: {self._shortcut}")
|
||||
logger.info(
|
||||
f"Starting pynput keyboard listener for shortcut: {self._shortcut} (mode: {self._mode})"
|
||||
)
|
||||
self.current_keys = set()
|
||||
|
||||
try:
|
||||
@@ -148,14 +151,23 @@ class PynputKeyboardBackend(KeyboardBackend):
|
||||
|
||||
if normalized_key == target_key:
|
||||
current_time = time.time()
|
||||
if (
|
||||
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(f"Double-tap {self._modifier_key} detected (pynput)")
|
||||
self.last_trigger_time = current_time
|
||||
threading.Thread(target=self.double_tap_callback, daemon=True).start()
|
||||
|
||||
if self._mode == "toggle":
|
||||
# Check for double-tap
|
||||
if (
|
||||
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(f"Double-tap {self._modifier_key} detected (pynput)")
|
||||
self.last_trigger_time = current_time
|
||||
threading.Thread(target=self.double_tap_callback, daemon=True).start()
|
||||
elif self._mode == "push_to_talk":
|
||||
# Trigger on press
|
||||
if self.key_press_callback is not None:
|
||||
logger.debug(f"Key press {self._modifier_key} detected (pynput)")
|
||||
threading.Thread(target=self.key_press_callback, daemon=True).start()
|
||||
|
||||
self.last_key_press_time = current_time
|
||||
|
||||
# Track current keys (using target key for reference)
|
||||
@@ -169,7 +181,17 @@ class PynputKeyboardBackend(KeyboardBackend):
|
||||
"""Handle key release events."""
|
||||
try:
|
||||
normalized_key = self._normalize_modifier_key(key)
|
||||
target_key = self._get_target_key()
|
||||
|
||||
# Track current keys
|
||||
self.current_keys.discard(normalized_key)
|
||||
|
||||
# Handle push-to-talk release
|
||||
if self._mode == "push_to_talk" and normalized_key == target_key:
|
||||
if self.key_release_callback is not None:
|
||||
logger.debug(f"Key release {self._modifier_key} detected (pynput)")
|
||||
threading.Thread(target=self.key_release_callback, daemon=True).start()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in pynput key release handling: {e}")
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
Keyboard shortcut manager for Vocalinux.
|
||||
|
||||
This module provides global keyboard shortcut functionality to
|
||||
start/stop speech recognition with configurable double-tap shortcuts.
|
||||
start/stop speech recognition with configurable shortcuts.
|
||||
|
||||
Supports multiple backends:
|
||||
- pynput: Works on X11/XWayland
|
||||
@@ -10,14 +10,16 @@ Supports multiple backends:
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Callable, Optional
|
||||
from typing import Any, Callable, Optional, cast
|
||||
|
||||
# Import the backend system
|
||||
from .keyboard_backends import (
|
||||
DEFAULT_SHORTCUT,
|
||||
DEFAULT_SHORTCUT_MODE,
|
||||
EVDEV_AVAILABLE,
|
||||
PYNPUT_AVAILABLE,
|
||||
SHORTCUT_DISPLAY_NAMES,
|
||||
SHORTCUT_MODES,
|
||||
SUPPORTED_SHORTCUTS,
|
||||
DesktopEnvironment,
|
||||
create_backend,
|
||||
@@ -25,7 +27,6 @@ from .keyboard_backends import (
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# Keep legacy module-level attributes for backward compatibility
|
||||
KEYBOARD_AVAILABLE = PYNPUT_AVAILABLE or EVDEV_AVAILABLE
|
||||
keyboard = None # Will be set if pynput is available (for tests)
|
||||
@@ -49,14 +50,20 @@ class KeyboardShortcutManager:
|
||||
"""
|
||||
Manages global keyboard shortcuts for the application.
|
||||
|
||||
This class allows registering configurable double-tap shortcuts to
|
||||
This class allows registering configurable shortcuts to
|
||||
toggle voice typing on and off across the desktop environment.
|
||||
Supports both toggle (double-tap) and push-to-talk modes.
|
||||
|
||||
Automatically selects the appropriate backend based on the
|
||||
desktop environment (X11, Wayland) and available dependencies.
|
||||
"""
|
||||
|
||||
def __init__(self, backend: Optional[str] = None, shortcut: str = DEFAULT_SHORTCUT):
|
||||
def __init__(
|
||||
self,
|
||||
backend: Optional[str] = None,
|
||||
shortcut: str = DEFAULT_SHORTCUT,
|
||||
mode: str = DEFAULT_SHORTCUT_MODE,
|
||||
):
|
||||
"""
|
||||
Initialize the keyboard shortcut manager.
|
||||
|
||||
@@ -64,13 +71,17 @@ class KeyboardShortcutManager:
|
||||
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")
|
||||
mode: The shortcut mode ("toggle" or "push_to_talk")
|
||||
"""
|
||||
self.backend_instance = None
|
||||
self.active = False
|
||||
self._shortcut = shortcut
|
||||
self._mode = mode
|
||||
|
||||
# Create the appropriate backend
|
||||
self.backend_instance = create_backend(preferred_backend=backend, shortcut=shortcut)
|
||||
self.backend_instance = create_backend(
|
||||
preferred_backend=backend, shortcut=shortcut, mode=mode
|
||||
)
|
||||
|
||||
if self.backend_instance is None:
|
||||
logger.error("No keyboard backend available. Shortcuts will not work.")
|
||||
@@ -100,6 +111,40 @@ class KeyboardShortcutManager:
|
||||
"""Get the current shortcut string."""
|
||||
return self._shortcut
|
||||
|
||||
@property
|
||||
def mode(self) -> str:
|
||||
"""Get the current shortcut mode."""
|
||||
return self._mode
|
||||
|
||||
@property
|
||||
def mode_display_name(self) -> str:
|
||||
"""Get the human-readable name for the current mode."""
|
||||
return SHORTCUT_MODES.get(self._mode, self._mode)
|
||||
|
||||
def set_mode(self, mode: str) -> bool:
|
||||
"""
|
||||
Update the shortcut mode.
|
||||
|
||||
Note: This requires restarting the listener to take effect.
|
||||
|
||||
Args:
|
||||
mode: The new mode ("toggle" or "push_to_talk")
|
||||
|
||||
Returns:
|
||||
True if successful, False if the mode is invalid
|
||||
"""
|
||||
if mode not in SHORTCUT_MODES:
|
||||
logger.error(f"Invalid mode: {mode}")
|
||||
return False
|
||||
|
||||
self._mode = mode
|
||||
|
||||
if self.backend_instance:
|
||||
self.backend_instance.set_mode(mode)
|
||||
logger.info(f"Mode updated to: {SHORTCUT_MODES.get(mode, mode)}")
|
||||
|
||||
return True
|
||||
|
||||
@property
|
||||
def shortcut_display_name(self) -> str:
|
||||
"""Get the human-readable name for the current shortcut."""
|
||||
@@ -130,7 +175,7 @@ class KeyboardShortcutManager:
|
||||
|
||||
return True
|
||||
|
||||
def restart_with_shortcut(self, shortcut: str) -> bool:
|
||||
def restart_with_shortcut(self, shortcut: str, mode: Optional[str] = None) -> bool:
|
||||
"""
|
||||
Restart the keyboard listener with a new shortcut.
|
||||
|
||||
@@ -140,6 +185,7 @@ class KeyboardShortcutManager:
|
||||
|
||||
Args:
|
||||
shortcut: The new shortcut string (e.g., "ctrl+ctrl", "alt+alt")
|
||||
mode: Optional new mode ("toggle" or "push_to_talk"). If None, keeps current mode.
|
||||
|
||||
Returns:
|
||||
True if the listener was successfully restarted with the new shortcut,
|
||||
@@ -149,16 +195,21 @@ class KeyboardShortcutManager:
|
||||
logger.error(f"Invalid shortcut: {shortcut}")
|
||||
return False
|
||||
|
||||
if shortcut == self._shortcut:
|
||||
if shortcut == self._shortcut and (mode is None or mode == self._mode):
|
||||
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
|
||||
# Save all current callbacks before stopping
|
||||
toggle_callback = None
|
||||
press_callback = None
|
||||
release_callback = None
|
||||
|
||||
if self.backend_instance:
|
||||
callback = self.backend_instance.double_tap_callback
|
||||
toggle_callback = self.backend_instance.double_tap_callback
|
||||
press_callback = self.backend_instance.key_press_callback
|
||||
release_callback = self.backend_instance.key_release_callback
|
||||
|
||||
# Stop the current listener
|
||||
if was_active:
|
||||
@@ -168,21 +219,37 @@ class KeyboardShortcutManager:
|
||||
# Update the shortcut
|
||||
self._shortcut = shortcut
|
||||
|
||||
# Update mode if provided
|
||||
if mode is not None:
|
||||
self._mode = mode
|
||||
if self.backend_instance:
|
||||
self.backend_instance.set_mode(mode)
|
||||
|
||||
# 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)
|
||||
# Clear all callbacks first to prevent stale registrations
|
||||
self.register_toggle_callback(None)
|
||||
self.register_press_callback(None)
|
||||
self.register_release_callback(None)
|
||||
|
||||
# Re-register callbacks based on current mode
|
||||
if self._mode == "toggle" and toggle_callback:
|
||||
self.register_toggle_callback(toggle_callback)
|
||||
elif self._mode == "push_to_talk":
|
||||
if press_callback:
|
||||
self.register_press_callback(press_callback)
|
||||
if release_callback:
|
||||
self.register_release_callback(release_callback)
|
||||
|
||||
success = self.start()
|
||||
if success:
|
||||
logger.info(
|
||||
f"Listener restarted with new shortcut: "
|
||||
f"{SHORTCUT_DISPLAY_NAMES.get(shortcut, shortcut)}"
|
||||
f"{SHORTCUT_DISPLAY_NAMES.get(shortcut, shortcut)} (mode: {self._mode})"
|
||||
)
|
||||
else:
|
||||
logger.error(f"Failed to restart listener with shortcut: {shortcut}")
|
||||
@@ -223,9 +290,9 @@ class KeyboardShortcutManager:
|
||||
self.backend_instance.stop()
|
||||
self.active = False
|
||||
|
||||
def register_toggle_callback(self, callback: Callable):
|
||||
def register_toggle_callback(self, callback: Optional[Callable[[], None]]):
|
||||
"""
|
||||
Register a callback for the double-tap shortcut.
|
||||
Register a callback for the toggle shortcut (double-tap).
|
||||
|
||||
Args:
|
||||
callback: Function to call when the double-tap shortcut is pressed
|
||||
@@ -235,7 +302,44 @@ class KeyboardShortcutManager:
|
||||
return
|
||||
|
||||
self.backend_instance.register_toggle_callback(callback)
|
||||
logger.info(f"Registered shortcut: {self.shortcut_display_name}")
|
||||
if callback is None:
|
||||
logger.debug("Cleared toggle callback")
|
||||
else:
|
||||
logger.info(f"Registered toggle callback for: {self.shortcut_display_name}")
|
||||
|
||||
def register_press_callback(self, callback: Optional[Callable[[], None]]):
|
||||
"""
|
||||
Register a callback for key press events (push-to-talk mode).
|
||||
|
||||
Args:
|
||||
callback: Function to call when the shortcut key is pressed
|
||||
"""
|
||||
if self.backend_instance is None:
|
||||
logger.warning("Cannot register press callback: no backend available")
|
||||
return
|
||||
|
||||
self.backend_instance.register_press_callback(callback)
|
||||
if callback is None:
|
||||
logger.debug("Cleared press callback")
|
||||
else:
|
||||
logger.info(f"Registered press callback for: {self.shortcut_display_name}")
|
||||
|
||||
def register_release_callback(self, callback: Optional[Callable[[], None]]):
|
||||
"""
|
||||
Register a callback for key release events (push-to-talk mode).
|
||||
|
||||
Args:
|
||||
callback: Function to call when the shortcut key is released
|
||||
"""
|
||||
if self.backend_instance is None:
|
||||
logger.warning("Cannot register release callback: no backend available")
|
||||
return
|
||||
|
||||
self.backend_instance.register_release_callback(callback)
|
||||
if callback is None:
|
||||
logger.debug("Cleared release callback")
|
||||
else:
|
||||
logger.info(f"Registered release callback for: {self.shortcut_display_name}")
|
||||
|
||||
@property
|
||||
def listener(self):
|
||||
@@ -244,8 +348,9 @@ class KeyboardShortcutManager:
|
||||
|
||||
Returns the underlying backend object if using pynput backend.
|
||||
"""
|
||||
if self.backend_instance and hasattr(self.backend_instance, "listener"):
|
||||
return self.backend_instance.listener
|
||||
if self.backend_instance:
|
||||
backend = cast(Any, self.backend_instance)
|
||||
return getattr(backend, "listener", None)
|
||||
return None
|
||||
|
||||
|
||||
@@ -281,5 +386,7 @@ __all__ = [
|
||||
"PYNPUT_AVAILABLE",
|
||||
"SUPPORTED_SHORTCUTS",
|
||||
"SHORTCUT_DISPLAY_NAMES",
|
||||
"SHORTCUT_MODES",
|
||||
"DEFAULT_SHORTCUT",
|
||||
"DEFAULT_SHORTCUT_MODE",
|
||||
]
|
||||
|
||||
@@ -36,7 +36,11 @@ from ..utils.whispercpp_model_info import (
|
||||
)
|
||||
from ..utils.whispercpp_model_info import get_recommended_model as get_recommended_whispercpp_model
|
||||
from ..utils.whispercpp_model_info import is_model_downloaded as is_whispercpp_model_downloaded
|
||||
from .keyboard_backends import SHORTCUT_DISPLAY_NAMES, SUPPORTED_SHORTCUTS # noqa: E402
|
||||
from .keyboard_backends import ( # noqa: E402
|
||||
SHORTCUT_DISPLAY_NAMES,
|
||||
SHORTCUT_MODES,
|
||||
SUPPORTED_SHORTCUTS,
|
||||
)
|
||||
|
||||
# Avoid circular imports for type checking
|
||||
if TYPE_CHECKING:
|
||||
@@ -560,13 +564,15 @@ class PreferenceRow(Gtk.ListBoxRow):
|
||||
title_label.get_style_context().add_class("preference-row-title")
|
||||
text_box.pack_start(title_label, False, False, 0)
|
||||
|
||||
# Store subtitle label reference for later updates
|
||||
self.subtitle_label = None
|
||||
if subtitle:
|
||||
subtitle_label = Gtk.Label(label=subtitle, xalign=0, wrap=True)
|
||||
subtitle_label.get_style_context().add_class("preference-row-subtitle")
|
||||
subtitle_label.set_max_width_chars(40)
|
||||
subtitle_label.set_line_wrap(True)
|
||||
subtitle_label.set_line_wrap_mode(Pango.WrapMode.WORD_CHAR)
|
||||
text_box.pack_start(subtitle_label, False, False, 0)
|
||||
self.subtitle_label = Gtk.Label(label=subtitle, xalign=0, wrap=True)
|
||||
self.subtitle_label.get_style_context().add_class("preference-row-subtitle")
|
||||
self.subtitle_label.set_max_width_chars(40)
|
||||
self.subtitle_label.set_line_wrap(True)
|
||||
self.subtitle_label.set_line_wrap_mode(Pango.WrapMode.WORD_CHAR)
|
||||
text_box.pack_start(self.subtitle_label, False, False, 0)
|
||||
|
||||
hbox.pack_start(text_box, True, True, 0)
|
||||
|
||||
@@ -577,6 +583,11 @@ class PreferenceRow(Gtk.ListBoxRow):
|
||||
|
||||
self.add(hbox)
|
||||
|
||||
def set_subtitle(self, subtitle: str):
|
||||
"""Update the subtitle text."""
|
||||
if self.subtitle_label:
|
||||
self.subtitle_label.set_text(subtitle)
|
||||
|
||||
|
||||
class ModelDownloadDialog(Gtk.Dialog):
|
||||
"""Dialog showing model download progress with cancel support."""
|
||||
@@ -1105,13 +1116,37 @@ class SettingsDialog(Gtk.Dialog):
|
||||
"""Build the Keyboard Shortcuts section."""
|
||||
group = PreferencesGroup(
|
||||
title="Keyboard Shortcuts",
|
||||
description="Configure the shortcut to toggle voice recognition",
|
||||
description="Configure the shortcut to control voice recognition",
|
||||
)
|
||||
|
||||
# Mode selection (Toggle vs Push-to-Talk)
|
||||
self.shortcut_mode_combo = Gtk.ComboBoxText()
|
||||
self.shortcut_mode_combo.set_size_request(200, -1)
|
||||
self.shortcut_mode_combo.set_tooltip_text(
|
||||
"Choose between toggle (double-tap) or push-to-talk mode"
|
||||
)
|
||||
_prevent_scroll_on_hover(self.shortcut_mode_combo)
|
||||
|
||||
# Populate mode options
|
||||
for mode_id, display_name in SHORTCUT_MODES.items():
|
||||
self.shortcut_mode_combo.append(mode_id, display_name)
|
||||
|
||||
# Load current mode from config
|
||||
current_mode = self.config_manager.get("shortcuts", "mode", "toggle")
|
||||
if not self.shortcut_mode_combo.set_active_id(current_mode):
|
||||
self.shortcut_mode_combo.set_active_id("toggle")
|
||||
|
||||
mode_row = PreferenceRow(
|
||||
title="Shortcut Mode",
|
||||
subtitle="How the shortcut behaves",
|
||||
widget=self.shortcut_mode_combo,
|
||||
)
|
||||
group.add_row(mode_row)
|
||||
|
||||
# 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")
|
||||
self.shortcut_combo.set_tooltip_text("Select the keyboard shortcut for voice typing")
|
||||
_prevent_scroll_on_hover(self.shortcut_combo)
|
||||
|
||||
# Populate shortcut options
|
||||
@@ -1123,12 +1158,12 @@ class SettingsDialog(Gtk.Dialog):
|
||||
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",
|
||||
self.shortcut_row = PreferenceRow(
|
||||
title="Shortcut Key",
|
||||
subtitle="Press this key to control voice typing",
|
||||
widget=self.shortcut_combo,
|
||||
)
|
||||
group.add_row(shortcut_row)
|
||||
group.add_row(self.shortcut_row)
|
||||
|
||||
self.shortcuts_tab.pack_start(group, False, False, 0)
|
||||
|
||||
@@ -1143,7 +1178,7 @@ class SettingsDialog(Gtk.Dialog):
|
||||
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.",
|
||||
label="Changes take effect immediately.",
|
||||
xalign=0,
|
||||
wrap=True,
|
||||
)
|
||||
@@ -1152,8 +1187,64 @@ class SettingsDialog(Gtk.Dialog):
|
||||
|
||||
self.shortcuts_tab.pack_start(info_box, False, False, 0)
|
||||
|
||||
# Connect signal
|
||||
# Connect signals
|
||||
self.shortcut_combo.connect("changed", self._on_shortcut_changed)
|
||||
self.shortcut_mode_combo.connect("changed", self._on_shortcut_mode_changed)
|
||||
|
||||
# Update UI based on initial mode
|
||||
self._update_shortcut_ui_for_mode(current_mode)
|
||||
|
||||
def _update_shortcut_ui_for_mode(self, mode: str):
|
||||
"""Update the shortcut UI based on the selected mode."""
|
||||
if mode == "toggle":
|
||||
self.shortcut_row.set_subtitle("Double-tap this key to start/stop voice typing")
|
||||
self.shortcut_info_label.set_text(
|
||||
"In Toggle mode: Double-tap the key to start voice typing, double-tap again to stop."
|
||||
)
|
||||
elif mode == "push_to_talk":
|
||||
self.shortcut_row.set_subtitle("Hold this key to speak, release to stop")
|
||||
self.shortcut_info_label.set_text(
|
||||
"In Push-to-Talk mode: Hold the key down to speak, release to stop recording."
|
||||
)
|
||||
|
||||
def _on_shortcut_mode_changed(self, widget):
|
||||
"""Handle shortcut mode selection change."""
|
||||
if self._initializing:
|
||||
return
|
||||
|
||||
mode_id = self.shortcut_mode_combo.get_active_id()
|
||||
if not mode_id:
|
||||
return
|
||||
|
||||
# Save to config
|
||||
self.config_manager.set("shortcuts", "mode", mode_id)
|
||||
self.config_manager.save_settings()
|
||||
|
||||
mode_name = SHORTCUT_MODES.get(mode_id, mode_id)
|
||||
logger.info(f"Keyboard shortcut mode changed to: {mode_name}")
|
||||
|
||||
# Update UI to reflect new mode
|
||||
self._update_shortcut_ui_for_mode(mode_id)
|
||||
|
||||
# Try to apply the mode change live
|
||||
if self.shortcut_update_callback:
|
||||
shortcut_id = self.shortcut_combo.get_active_id()
|
||||
success = self.shortcut_update_callback(shortcut_id, mode_id)
|
||||
if success:
|
||||
self.shortcut_info_label.set_markup(
|
||||
f"<span foreground='#26a269'>Mode updated to <b>{mode_name}</b>. "
|
||||
f"Active now!</span>"
|
||||
)
|
||||
else:
|
||||
self.shortcut_info_label.set_markup(
|
||||
f"<i>Mode updated to <b>{mode_name}</b>. "
|
||||
f"Restart the app for the change to take full effect.</i>"
|
||||
)
|
||||
else:
|
||||
self.shortcut_info_label.set_markup(
|
||||
f"<i>Mode updated to <b>{mode_name}</b>. "
|
||||
f"Restart the app for the change to take full effect.</i>"
|
||||
)
|
||||
|
||||
def _on_shortcut_changed(self, widget):
|
||||
"""Handle shortcut selection change."""
|
||||
@@ -1173,7 +1264,8 @@ class SettingsDialog(Gtk.Dialog):
|
||||
|
||||
# Try to apply the shortcut change live
|
||||
if self.shortcut_update_callback:
|
||||
success = self.shortcut_update_callback(shortcut_id)
|
||||
mode_id = self.shortcut_mode_combo.get_active_id()
|
||||
success = self.shortcut_update_callback(shortcut_id, mode_id)
|
||||
if success:
|
||||
self.shortcut_info_label.set_markup(
|
||||
f"<span foreground='#26a269'>Shortcut updated to <b>{display_name}</b>. "
|
||||
@@ -1185,7 +1277,6 @@ class SettingsDialog(Gtk.Dialog):
|
||||
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>"
|
||||
|
||||
@@ -8,7 +8,7 @@ recognition process and displaying its status.
|
||||
import logging
|
||||
import os
|
||||
import signal
|
||||
from typing import Callable
|
||||
from typing import Callable, Optional
|
||||
|
||||
import gi
|
||||
|
||||
@@ -28,11 +28,7 @@ except (ImportError, ValueError):
|
||||
from gi.repository import GdkPixbuf, GLib, GObject, Gtk
|
||||
|
||||
# Import local modules - Use protocols to avoid circular imports
|
||||
from ..common_types import (
|
||||
RecognitionState,
|
||||
SpeechRecognitionManagerProtocol,
|
||||
TextInjectorProtocol,
|
||||
)
|
||||
from ..common_types import RecognitionState, SpeechRecognitionManagerProtocol, TextInjectorProtocol
|
||||
|
||||
# Import necessary components
|
||||
from .config_manager import ConfigManager # noqa: E402
|
||||
@@ -109,8 +105,34 @@ class TrayIndicator:
|
||||
# Initialize the indicator (in the GTK main thread)
|
||||
GLib.idle_add(self._init_indicator)
|
||||
|
||||
# Set up keyboard shortcuts
|
||||
self.shortcut_manager.register_toggle_callback(self._toggle_recognition)
|
||||
# Set up keyboard shortcuts with mode support
|
||||
self._setup_keyboard_shortcuts()
|
||||
|
||||
def _setup_keyboard_shortcuts(self):
|
||||
"""Set up keyboard shortcuts based on configured mode."""
|
||||
# Stop existing shortcut manager if running
|
||||
if self.shortcut_manager.active:
|
||||
logger.info("Stopping existing shortcut manager before reconfiguration")
|
||||
self.shortcut_manager.stop()
|
||||
|
||||
# Clear any existing callbacks to prevent duplicate triggers
|
||||
self.shortcut_manager.register_toggle_callback(None)
|
||||
self.shortcut_manager.register_press_callback(None)
|
||||
self.shortcut_manager.register_release_callback(None)
|
||||
|
||||
# Get configured mode from config
|
||||
mode = self.config_manager.get("shortcuts", "mode", "toggle")
|
||||
logger.info(f"Setting up keyboard shortcuts with mode: {mode}")
|
||||
|
||||
if mode == "toggle":
|
||||
# Register toggle callback for double-tap mode
|
||||
self.shortcut_manager.register_toggle_callback(self._toggle_recognition)
|
||||
elif mode == "push_to_talk":
|
||||
# Register press/release callbacks for push-to-talk mode
|
||||
self.shortcut_manager.register_press_callback(self._start_recognition)
|
||||
self.shortcut_manager.register_release_callback(self._stop_recognition)
|
||||
|
||||
# Start the keyboard shortcut manager
|
||||
self.shortcut_manager.start()
|
||||
|
||||
def _init_icons(self):
|
||||
@@ -205,6 +227,16 @@ class TrayIndicator:
|
||||
else:
|
||||
self.speech_engine.stop_recognition()
|
||||
|
||||
def _start_recognition(self):
|
||||
"""Start voice recognition (for push-to-talk mode)."""
|
||||
if self.speech_engine.state == RecognitionState.IDLE:
|
||||
self.speech_engine.start_recognition()
|
||||
|
||||
def _stop_recognition(self):
|
||||
"""Stop voice recognition (for push-to-talk mode)."""
|
||||
if self.speech_engine.state != RecognitionState.IDLE:
|
||||
self.speech_engine.stop_recognition()
|
||||
|
||||
def _add_menu_item(self, label: str, callback: Callable):
|
||||
"""
|
||||
Add a menu item to the indicator menu.
|
||||
@@ -368,7 +400,7 @@ class TrayIndicator:
|
||||
logger.info("Settings dialog closed.")
|
||||
dialog.destroy()
|
||||
|
||||
def update_shortcut(self, shortcut: str) -> bool:
|
||||
def update_shortcut(self, shortcut: str, mode: Optional[str] = None) -> bool:
|
||||
"""
|
||||
Update the keyboard shortcut for toggling voice recognition.
|
||||
|
||||
@@ -376,11 +408,33 @@ class TrayIndicator:
|
||||
|
||||
Args:
|
||||
shortcut: The new shortcut string (e.g., "ctrl+ctrl", "alt+alt")
|
||||
mode: Optional new mode ("toggle" or "push_to_talk"). If None, keeps current mode.
|
||||
|
||||
Returns:
|
||||
True if the shortcut was updated successfully, False otherwise
|
||||
"""
|
||||
return self.shortcut_manager.restart_with_shortcut(shortcut)
|
||||
current_mode = self.shortcut_manager.mode
|
||||
mode_changed = mode is not None and mode != current_mode
|
||||
shortcut_changed = shortcut != self.shortcut_manager.shortcut
|
||||
|
||||
if mode_changed:
|
||||
logger.info(f"Mode changing from {current_mode} to {mode}")
|
||||
assert mode is not None
|
||||
if not self.shortcut_manager.set_mode(mode):
|
||||
logger.error(f"Failed to set shortcut mode: {mode}")
|
||||
return False
|
||||
|
||||
if shortcut_changed:
|
||||
if not self.shortcut_manager.set_shortcut(shortcut):
|
||||
logger.error(f"Failed to set shortcut: {shortcut}")
|
||||
return False
|
||||
|
||||
if mode_changed or shortcut_changed:
|
||||
self._setup_keyboard_shortcuts()
|
||||
return self.shortcut_manager.active
|
||||
|
||||
logger.debug("No changes needed - shortcut and mode unchanged")
|
||||
return True
|
||||
|
||||
def _on_about_clicked(self, widget):
|
||||
"""Handle click on the About menu item."""
|
||||
|
||||
@@ -378,3 +378,32 @@ class TestConfigManager(unittest.TestCase):
|
||||
self.assertIn("speech_recognition", config_manager.config)
|
||||
self.assertEqual(config_manager.config["speech_recognition"]["engine"], "whisper")
|
||||
self.assertEqual(config_manager.config["speech_recognition"]["vad_sensitivity"], 5)
|
||||
|
||||
def test_migrate_super_shortcut_to_ctrl(self):
|
||||
test_config = {
|
||||
"shortcuts": {
|
||||
"toggle_recognition": "super+super",
|
||||
"mode": "toggle",
|
||||
}
|
||||
}
|
||||
|
||||
with open(self.temp_config_file, "w") as f:
|
||||
json.dump(test_config, f)
|
||||
|
||||
config_manager = ConfigManager()
|
||||
self.assertEqual(config_manager.config["shortcuts"]["toggle_recognition"], "ctrl+ctrl")
|
||||
|
||||
def test_non_super_shortcut_unchanged(self):
|
||||
test_config = {
|
||||
"shortcuts": {
|
||||
"toggle_recognition": "alt+alt",
|
||||
"mode": "push_to_talk",
|
||||
}
|
||||
}
|
||||
|
||||
with open(self.temp_config_file, "w") as f:
|
||||
json.dump(test_config, f)
|
||||
|
||||
config_manager = ConfigManager()
|
||||
self.assertEqual(config_manager.config["shortcuts"]["toggle_recognition"], "alt+alt")
|
||||
self.assertEqual(config_manager.config["shortcuts"]["mode"], "push_to_talk")
|
||||
|
||||
@@ -3,7 +3,7 @@ Tests for keyboard shortcut functionality.
|
||||
"""
|
||||
|
||||
import unittest
|
||||
from unittest.mock import MagicMock, Mock, patch
|
||||
from unittest.mock import MagicMock, Mock, call, patch
|
||||
|
||||
# Update import to use the new package structure
|
||||
from vocalinux.ui.keyboard_shortcuts import (
|
||||
@@ -27,6 +27,8 @@ class TestKeyboardShortcuts(unittest.TestCase):
|
||||
self.mock_backend = MagicMock()
|
||||
self.mock_backend.active = False
|
||||
self.mock_backend.double_tap_callback = None
|
||||
self.mock_backend.key_press_callback = None
|
||||
self.mock_backend.key_release_callback = None
|
||||
self.mock_backend.start.return_value = True
|
||||
self.mock_backend.shortcut = "ctrl+ctrl"
|
||||
self.mock_create_backend.return_value = self.mock_backend
|
||||
@@ -50,7 +52,10 @@ class TestKeyboardShortcuts(unittest.TestCase):
|
||||
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")
|
||||
# Verify the shortcut was passed to create_backend
|
||||
self.mock_create_backend.assert_called_with(
|
||||
preferred_backend=None, shortcut="alt+alt", mode="toggle"
|
||||
)
|
||||
|
||||
def test_default_shortcut(self):
|
||||
"""Test that default shortcut is ctrl+ctrl."""
|
||||
@@ -58,7 +63,7 @@ class TestKeyboardShortcuts(unittest.TestCase):
|
||||
|
||||
def test_supported_shortcuts(self):
|
||||
"""Test that all expected shortcuts are supported."""
|
||||
expected_shortcuts = ["ctrl+ctrl", "alt+alt", "shift+shift", "super+super"]
|
||||
expected_shortcuts = ["ctrl+ctrl", "alt+alt", "shift+shift"]
|
||||
for shortcut in expected_shortcuts:
|
||||
self.assertIn(shortcut, SUPPORTED_SHORTCUTS)
|
||||
|
||||
@@ -157,6 +162,48 @@ class TestKeyboardShortcuts(unittest.TestCase):
|
||||
# Callback should have been re-registered
|
||||
self.mock_backend.register_toggle_callback.assert_called_with(callback)
|
||||
|
||||
def test_restart_with_shortcut_preserves_push_to_talk_callbacks(self):
|
||||
"""Push-to-talk callbacks are re-registered after restart."""
|
||||
press_callback = MagicMock()
|
||||
release_callback = MagicMock()
|
||||
|
||||
self.ksm.set_mode("push_to_talk")
|
||||
self.mock_backend.key_press_callback = press_callback
|
||||
self.mock_backend.key_release_callback = release_callback
|
||||
self.ksm.start()
|
||||
|
||||
result = self.ksm.restart_with_shortcut("alt+alt", "push_to_talk")
|
||||
|
||||
self.assertTrue(result)
|
||||
self.assertEqual(self.ksm.shortcut, "alt+alt")
|
||||
self.mock_backend.register_press_callback.assert_any_call(press_callback)
|
||||
self.mock_backend.register_release_callback.assert_any_call(release_callback)
|
||||
|
||||
def test_restart_with_shortcut_switch_mode_clears_old_callbacks(self):
|
||||
"""Switching modes does not keep old mode callbacks active."""
|
||||
toggle_callback = MagicMock()
|
||||
press_callback = MagicMock()
|
||||
release_callback = MagicMock()
|
||||
|
||||
self.ksm.set_mode("push_to_talk")
|
||||
self.mock_backend.double_tap_callback = toggle_callback
|
||||
self.mock_backend.key_press_callback = press_callback
|
||||
self.mock_backend.key_release_callback = release_callback
|
||||
self.ksm.start()
|
||||
|
||||
result = self.ksm.restart_with_shortcut("shift+shift", "toggle")
|
||||
|
||||
self.assertTrue(result)
|
||||
self.assertEqual(self.ksm.mode, "toggle")
|
||||
self.mock_backend.register_toggle_callback.assert_any_call(toggle_callback)
|
||||
self.assertNotIn(
|
||||
call(press_callback), self.mock_backend.register_press_callback.call_args_list
|
||||
)
|
||||
self.assertNotIn(
|
||||
call(release_callback),
|
||||
self.mock_backend.register_release_callback.call_args_list,
|
||||
)
|
||||
|
||||
def test_restart_with_shortcut_handles_start_failure(self):
|
||||
"""Test handling when restart fails to start."""
|
||||
# Start first
|
||||
@@ -531,7 +578,7 @@ class TestBackendFactory(unittest.TestCase):
|
||||
result = create_backend(shortcut="alt+alt")
|
||||
|
||||
self.assertIsNotNone(result)
|
||||
MockPynput.assert_called_once_with(shortcut="alt+alt")
|
||||
MockPynput.assert_called_once_with(shortcut="alt+alt", mode="toggle")
|
||||
|
||||
|
||||
class TestShortcutParseFunction(unittest.TestCase):
|
||||
@@ -558,13 +605,6 @@ class TestShortcutParseFunction(unittest.TestCase):
|
||||
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
|
||||
|
||||
@@ -77,12 +77,22 @@ class TestSettingsDialogShortcutsSection(unittest.TestCase):
|
||||
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="Shortcut Key"', self.source_code)
|
||||
|
||||
def test_shortcut_preference_row_subtitle(self):
|
||||
"""Test that preference row has descriptive subtitle."""
|
||||
# The subtitle is now dynamic based on mode (toggle or push-to-talk)
|
||||
self.assertIn("set_subtitle", self.source_code)
|
||||
"""Test that preference row has correct title."""
|
||||
self.assertIn('title="Shortcut Key"', self.source_code)
|
||||
"""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)
|
||||
# The subtitle is now dynamic based on mode (toggle or push-to-talk)
|
||||
self.assertIn("set_subtitle", self.source_code)
|
||||
|
||||
def test_shortcut_info_label_exists(self):
|
||||
"""Test that info label exists for user guidance."""
|
||||
@@ -98,7 +108,9 @@ class TestSettingsDialogShortcutsSection(unittest.TestCase):
|
||||
|
||||
def test_imports_shortcut_constants(self):
|
||||
"""Test that shortcut constants are imported."""
|
||||
self.assertIn("from .keyboard_backends import SHORTCUT_DISPLAY_NAMES", self.source_code)
|
||||
# Check for imports (may be multi-line)
|
||||
self.assertIn("from .keyboard_backends import", self.source_code)
|
||||
self.assertIn("SHORTCUT_DISPLAY_NAMES", self.source_code)
|
||||
self.assertIn("SUPPORTED_SHORTCUTS", self.source_code)
|
||||
|
||||
|
||||
@@ -113,7 +125,6 @@ class TestKeyboardBackendsBase(unittest.TestCase):
|
||||
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."""
|
||||
@@ -122,7 +133,7 @@ class TestKeyboardBackendsBase(unittest.TestCase):
|
||||
self.assertIsInstance(SHORTCUT_DISPLAY_NAMES, dict)
|
||||
self.assertEqual(
|
||||
set(SHORTCUT_DISPLAY_NAMES.keys()),
|
||||
{"ctrl+ctrl", "alt+alt", "shift+shift", "super+super"},
|
||||
{"ctrl+ctrl", "alt+alt", "shift+shift"},
|
||||
)
|
||||
|
||||
def test_default_shortcut_defined(self):
|
||||
|
||||
Reference in New Issue
Block a user