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