From 86ca22ff5cd1f2fe411b0547632180d7b7fe8926 Mon Sep 17 00:00:00 2001 From: Jatin K Malik Date: Sat, 28 Feb 2026 20:40:32 -0800 Subject: [PATCH] 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 --- CI=1 | 0 src/vocalinux/ui/config_manager.py | 14 +- src/vocalinux/ui/first_run_dialog.py | 24 +-- .../ui/keyboard_backends/__init__.py | 22 ++- src/vocalinux/ui/keyboard_backends/base.py | 98 ++++++++++-- .../ui/keyboard_backends/evdev_backend.py | 41 +++-- .../ui/keyboard_backends/pynput_backend.py | 46 ++++-- src/vocalinux/ui/keyboard_shortcuts.py | 147 +++++++++++++++--- src/vocalinux/ui/settings_dialog.py | 125 +++++++++++++-- src/vocalinux/ui/tray_indicator.py | 74 +++++++-- tests/test_config_manager.py | 29 ++++ tests/test_keyboard_shortcuts.py | 62 ++++++-- tests/test_settings_shortcuts.py | 19 ++- 13 files changed, 581 insertions(+), 120 deletions(-) create mode 100644 CI=1 diff --git a/CI=1 b/CI=1 new file mode 100644 index 0000000..e69de29 diff --git a/src/vocalinux/ui/config_manager.py b/src/vocalinux/ui/config_manager.py index 47ef559..a6b809d 100644 --- a/src/vocalinux/ui/config_manager.py +++ b/src/vocalinux/ui/config_manager.py @@ -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: diff --git a/src/vocalinux/ui/first_run_dialog.py b/src/vocalinux/ui/first_run_dialog.py index 86ac3fa..9f7b642 100644 --- a/src/vocalinux/ui/first_run_dialog.py +++ b/src/vocalinux/ui/first_run_dialog.py @@ -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 diff --git a/src/vocalinux/ui/keyboard_backends/__init__.py b/src/vocalinux/ui/keyboard_backends/__init__.py index af9c67a..6224c18 100644 --- a/src/vocalinux/ui/keyboard_backends/__init__.py +++ b/src/vocalinux/ui/keyboard_backends/__init__.py @@ -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", ] diff --git a/src/vocalinux/ui/keyboard_backends/base.py b/src/vocalinux/ui/keyboard_backends/base.py index 17d775c..2026d06 100644 --- a/src/vocalinux/ui/keyboard_backends/base.py +++ b/src/vocalinux/ui/keyboard_backends/base.py @@ -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 diff --git a/src/vocalinux/ui/keyboard_backends/evdev_backend.py b/src/vocalinux/ui/keyboard_backends/evdev_backend.py index 7707ad8..de87d47 100644 --- a/src/vocalinux/ui/keyboard_backends/evdev_backend.py +++ b/src/vocalinux/ui/keyboard_backends/evdev_backend.py @@ -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}") diff --git a/src/vocalinux/ui/keyboard_backends/pynput_backend.py b/src/vocalinux/ui/keyboard_backends/pynput_backend.py index 4bb929a..c76dabf 100644 --- a/src/vocalinux/ui/keyboard_backends/pynput_backend.py +++ b/src/vocalinux/ui/keyboard_backends/pynput_backend.py @@ -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}") diff --git a/src/vocalinux/ui/keyboard_shortcuts.py b/src/vocalinux/ui/keyboard_shortcuts.py index 1a677a9..a3ba61a 100644 --- a/src/vocalinux/ui/keyboard_shortcuts.py +++ b/src/vocalinux/ui/keyboard_shortcuts.py @@ -2,7 +2,7 @@ Keyboard shortcut manager for Vocalinux. This module provides global keyboard shortcut functionality to -start/stop speech recognition with 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", ] diff --git a/src/vocalinux/ui/settings_dialog.py b/src/vocalinux/ui/settings_dialog.py index cc0c348..26dfa2a 100644 --- a/src/vocalinux/ui/settings_dialog.py +++ b/src/vocalinux/ui/settings_dialog.py @@ -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"Mode updated to {mode_name}. " + f"Active now!" + ) + else: + self.shortcut_info_label.set_markup( + f"Mode updated to {mode_name}. " + f"Restart the app for the change to take full effect." + ) + else: + self.shortcut_info_label.set_markup( + f"Mode updated to {mode_name}. " + f"Restart the app for the change to take full effect." + ) 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"Shortcut updated to {display_name}. " @@ -1185,7 +1277,6 @@ class SettingsDialog(Gtk.Dialog): f"Restart the app for the change to take full effect." ) else: - # No callback provided, fall back to restart message self.shortcut_info_label.set_markup( f"Shortcut updated to {display_name}. " f"Restart the app for the change to take full effect." diff --git a/src/vocalinux/ui/tray_indicator.py b/src/vocalinux/ui/tray_indicator.py index ab38793..03b9b29 100644 --- a/src/vocalinux/ui/tray_indicator.py +++ b/src/vocalinux/ui/tray_indicator.py @@ -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.""" diff --git a/tests/test_config_manager.py b/tests/test_config_manager.py index 09675e3..f6ea039 100644 --- a/tests/test_config_manager.py +++ b/tests/test_config_manager.py @@ -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") diff --git a/tests/test_keyboard_shortcuts.py b/tests/test_keyboard_shortcuts.py index f53d3f4..58ec044 100644 --- a/tests/test_keyboard_shortcuts.py +++ b/tests/test_keyboard_shortcuts.py @@ -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 diff --git a/tests/test_settings_shortcuts.py b/tests/test_settings_shortcuts.py index c5e868e..0a5f125 100644 --- a/tests/test_settings_shortcuts.py +++ b/tests/test_settings_shortcuts.py @@ -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):