feat(shortcuts): add push-to-talk mode and improve shortcut reliability (#278)

* feat(shortcuts): add push-to-talk mode as alternative to toggle

This commit implements push-to-talk functionality (Issue #252) as an
alternative to the existing double-tap toggle mode:

- Add keyboard shortcut mode setting (toggle vs push_to_talk)
- In push-to-talk mode: hold key to speak, release to stop
- Update both pynput and evdev backends to support press/release callbacks
- Add UI controls in settings dialog for mode selection
- Maintain backward compatibility with existing toggle mode as default

Closes #252
Related: Closes #185 (superseded by push-to-talk approach)

* fix(shortcuts): remove duplicate code blocks that broke settings dialog

The previous push-to-talk implementation had duplicate code blocks
in tray_indicator.py and settings_dialog.py that caused the settings
dialog to fail silently. This commit cleans up the duplicate code
while preserving the push-to-talk functionality.

Fixes issue where settings dialog would not open.

* fix(ui): add set_subtitle method to PreferenceRow class

The PreferenceRow class was missing a set_subtitle method, which is called by
_update_shortcut_ui_for_mode when switching between toggle and push-to-talk
modes. This caused an AttributeError that silently crashed the settings dialog
during initialization.

Also stores subtitle_label as instance variable for later updates.

* fix(keyboard): remove duplicate code and fix mode handling

Critical fixes for keyboard backends:
- evdev_backend.py: Removed duplicate __init__ and _handle_key_event methods
- pynput_backend.py: Removed duplicate __init__, _on_press, _on_release methods
- Both backends now properly handle toggle and push-to-talk modes

Fixed first_run_dialog.py:
- Added do_response() override to properly close dialog
- Added explicit destroy() call in show_first_run_dialog()

Fixed tray_indicator.py:
- Added _unregister_keyboard_callbacks() method
- Updated _setup_keyboard_shortcuts() to properly clean up old callbacks
- Updated update_shortcut() to properly handle mode switching

Fixed settings_dialog.py:
- Added set_subtitle() method to PreferenceRow class
- Updated UI text for push-to-talk mode
- Fixed _on_shortcut_changed to properly pass mode to callback

All 547 tests pass locally.

* feat(shortcuts): improve mode switching and add mode-aware display names

Key improvements:
- Add SHORTCUT_MODE_DISPLAY_NAMES for mode-specific shortcut names
- Add get_shortcut_display_name() function for mode-aware display
- Fix restart_with_shortcut() to properly save and restore all callbacks
- Fix update_shortcut() to properly handle mode changes
- Fix _setup_keyboard_shortcuts() to clear old callbacks before registering new ones
- Ensure only one type of callback is active at a time

This fixes issues where:
- Old callbacks remained registered after mode switch
- Single-tap triggered action after switching from push-to-talk to toggle
- Display names weren't appropriate for the selected mode

* fix(shortcuts): stabilize live mode switching callback lifecycle

* test(shortcuts): cover callback reset on mode transitions

* fix(shortcuts): remove super preset and migrate legacy config

* test(shortcuts): update coverage for super preset removal
This commit is contained in:
Jatin K Malik
2026-02-28 20:40:32 -08:00
committed by GitHub
parent bcd6d94eea
commit 86ca22ff5c
13 changed files with 581 additions and 120 deletions
View File
+13 -1
View File
@@ -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:
+8 -16
View File
@@ -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
+16 -6
View File
@@ -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",
]
+89 -9
View File
@@ -8,24 +8,66 @@ the required methods.
from abc import ABC, abstractmethod
from typing import Callable, Optional
Callback = Callable[[], None]
# Supported double-tap shortcut keys
SUPPORTED_SHORTCUTS = {
"ctrl+ctrl": "ctrl",
"alt+alt": "alt",
"shift+shift": "shift",
"super+super": "super",
}
# Human-readable names for shortcuts
# Human-readable names for shortcuts (mode-agnostic base names)
SHORTCUT_DISPLAY_NAMES = {
"ctrl+ctrl": "Double-tap Ctrl",
"alt+alt": "Double-tap Alt",
"shift+shift": "Double-tap Shift",
"super+super": "Double-tap Super (Windows key)",
"ctrl+ctrl": "Ctrl",
"alt+alt": "Alt",
"shift+shift": "Shift",
}
# Mode-specific display names (format: {shortcut: {mode: display_name}})
SHORTCUT_MODE_DISPLAY_NAMES = {
"ctrl+ctrl": {
"toggle": "Double-tap Ctrl",
"push_to_talk": "Hold Ctrl",
},
"alt+alt": {
"toggle": "Double-tap Alt",
"push_to_talk": "Hold Alt",
},
"shift+shift": {
"toggle": "Double-tap Shift",
"push_to_talk": "Hold Shift",
},
}
DEFAULT_SHORTCUT = "ctrl+ctrl"
# Supported shortcut modes
SHORTCUT_MODES = {
"toggle": "Toggle (double-tap to start/stop)",
"push_to_talk": "Push-to-Talk (hold to speak)",
}
DEFAULT_SHORTCUT_MODE = "toggle"
def get_shortcut_display_name(shortcut: str, mode: Optional[str] = None) -> str:
"""
Get a human-readable display name for a shortcut.
Args:
shortcut: The shortcut string (e.g., "ctrl+ctrl")
mode: Optional mode string. If provided, returns mode-specific name.
Returns:
A human-readable display name for the shortcut
"""
if mode and shortcut in SHORTCUT_MODE_DISPLAY_NAMES:
return SHORTCUT_MODE_DISPLAY_NAMES[shortcut].get(
mode, SHORTCUT_DISPLAY_NAMES.get(shortcut, shortcut)
)
return SHORTCUT_DISPLAY_NAMES.get(shortcut, shortcut)
def parse_shortcut(shortcut_string: str) -> str:
"""
@@ -57,16 +99,20 @@ class KeyboardBackend(ABC):
event listening and registering callbacks for specific shortcuts.
"""
def __init__(self, shortcut: str = DEFAULT_SHORTCUT):
def __init__(self, shortcut: str = DEFAULT_SHORTCUT, mode: str = DEFAULT_SHORTCUT_MODE):
"""
Initialize the keyboard backend.
Args:
shortcut: The shortcut string to listen for (e.g., "ctrl+ctrl")
mode: The shortcut mode ("toggle" or "push_to_talk")
"""
self.active = False
self.double_tap_callback: Optional[Callable] = None
self.double_tap_callback: Optional[Callback] = None
self.key_press_callback: Optional[Callback] = None
self.key_release_callback: Optional[Callback] = None
self._shortcut = shortcut
self._mode = mode
self._modifier_key = parse_shortcut(shortcut)
@property
@@ -74,6 +120,22 @@ class KeyboardBackend(ABC):
"""Get the current shortcut string."""
return self._shortcut
@property
def mode(self) -> str:
"""Get the current shortcut mode."""
return self._mode
def set_mode(self, mode: str) -> None:
"""
Update the shortcut mode.
Args:
mode: The new mode ("toggle" or "push_to_talk")
"""
if mode not in SHORTCUT_MODES:
raise ValueError(f"Invalid mode: {mode}. Must be one of {list(SHORTCUT_MODES.keys())}")
self._mode = mode
@property
def modifier_key(self) -> str:
"""Get the modifier key being watched for double-tap."""
@@ -124,7 +186,7 @@ class KeyboardBackend(ABC):
"""
pass
def register_toggle_callback(self, callback: Callable) -> None:
def register_toggle_callback(self, callback: Optional[Callback]) -> None:
"""
Register a callback for the double-tap shortcut.
@@ -132,3 +194,21 @@ class KeyboardBackend(ABC):
callback: Function to call when double-tap is detected
"""
self.double_tap_callback = callback
def register_press_callback(self, callback: Optional[Callback]) -> None:
"""
Register a callback for key press events (push-to-talk mode).
Args:
callback: Function to call when the shortcut key is pressed
"""
self.key_press_callback = callback
def register_release_callback(self, callback: Optional[Callback]) -> None:
"""
Register a callback for key release events (push-to-talk mode).
Args:
callback: Function to call when the shortcut key is released
"""
self.key_release_callback = callback
@@ -25,7 +25,7 @@ except ImportError:
ecodes = None # type: ignore
EVDEV_AVAILABLE = False
from .base import DEFAULT_SHORTCUT, KeyboardBackend
from .base import DEFAULT_SHORTCUT, DEFAULT_SHORTCUT_MODE, KeyboardBackend, parse_shortcut
logger = logging.getLogger(__name__)
@@ -134,14 +134,15 @@ class EvdevKeyboardBackend(KeyboardBackend):
to read from /dev/input/event* devices (member of 'input' group).
"""
def __init__(self, shortcut: str = DEFAULT_SHORTCUT):
def __init__(self, shortcut: str = DEFAULT_SHORTCUT, mode: str = DEFAULT_SHORTCUT_MODE):
"""
Initialize the evdev keyboard backend.
Args:
shortcut: The shortcut string to listen for (e.g., "ctrl+ctrl")
mode: The shortcut mode ("toggle" or "push_to_talk")
"""
super().__init__(shortcut)
super().__init__(shortcut, mode)
self.devices: List[InputDevice] = []
self.device_fds: List[int] = []
self.running = False
@@ -205,7 +206,7 @@ class EvdevKeyboardBackend(KeyboardBackend):
except (OSError, IOError) as e:
if "Permission denied" in str(e) or e.errno == errno.EACCES:
return (
"Add your user to the 'input' group and log out/in:\\n"
"Add your user to the 'input' group and log out/in:\n"
"sudo usermod -a -G input $USER"
)
except Exception:
@@ -234,7 +235,7 @@ class EvdevKeyboardBackend(KeyboardBackend):
return False
logger.info(f"Found {len(device_paths)} keyboard device(s)")
logger.info(f"Listening for shortcut: {self._shortcut}")
logger.info(f"Listening for shortcut: {self._shortcut} (mode: {self._mode})")
# Open devices
self.devices = []
@@ -361,21 +362,33 @@ class EvdevKeyboardBackend(KeyboardBackend):
self.key_pressed_devices.add(device_id)
current_time = time.time()
# Check for double-tap
if (
current_time - self.last_key_press_time < self.double_tap_threshold
and self.double_tap_callback is not None
and current_time - self.last_trigger_time > 0.5
):
logger.debug(f"Double-tap {self._modifier_key} detected (evdev)")
self.last_trigger_time = current_time
threading.Thread(target=self.double_tap_callback, daemon=True).start()
if self._mode == "toggle":
# Check for double-tap
if (
current_time - self.last_key_press_time < self.double_tap_threshold
and self.double_tap_callback is not None
and current_time - self.last_trigger_time > 0.5
):
logger.debug(f"Double-tap {self._modifier_key} detected (evdev)")
self.last_trigger_time = current_time
threading.Thread(target=self.double_tap_callback, daemon=True).start()
elif self._mode == "push_to_talk":
# Trigger on press
if self.key_press_callback is not None:
logger.debug(f"Key press {self._modifier_key} detected (evdev)")
threading.Thread(target=self.key_press_callback, daemon=True).start()
self.last_key_press_time = current_time
elif value == 0: # Key release
self.key_pressed_devices.discard(device_id)
if self._mode == "push_to_talk":
# Trigger on release
if self.key_release_callback is not None:
logger.debug(f"Key release {self._modifier_key} detected (evdev)")
threading.Thread(target=self.key_release_callback, daemon=True).start()
except Exception as e:
logger.error(f"Error handling key event: {e}")
@@ -19,7 +19,7 @@ except ImportError:
keyboard = None # type: ignore
PYNPUT_AVAILABLE = False
from .base import DEFAULT_SHORTCUT, KeyboardBackend
from .base import DEFAULT_SHORTCUT, DEFAULT_SHORTCUT_MODE, KeyboardBackend
logger = logging.getLogger(__name__)
@@ -55,14 +55,15 @@ class PynputKeyboardBackend(KeyboardBackend):
due to Wayland's security restrictions.
"""
def __init__(self, shortcut: str = DEFAULT_SHORTCUT):
def __init__(self, shortcut: str = DEFAULT_SHORTCUT, mode: str = DEFAULT_SHORTCUT_MODE):
"""
Initialize the pynput keyboard backend.
Args:
shortcut: The shortcut string to listen for (e.g., "ctrl+ctrl")
mode: The shortcut mode ("toggle" or "push_to_talk")
"""
super().__init__(shortcut)
super().__init__(shortcut, mode)
self.listener = None
self.last_trigger_time = 0
self.last_key_press_time = 0
@@ -103,7 +104,9 @@ class PynputKeyboardBackend(KeyboardBackend):
if self.active:
return True
logger.info(f"Starting pynput keyboard listener for shortcut: {self._shortcut}")
logger.info(
f"Starting pynput keyboard listener for shortcut: {self._shortcut} (mode: {self._mode})"
)
self.current_keys = set()
try:
@@ -148,14 +151,23 @@ class PynputKeyboardBackend(KeyboardBackend):
if normalized_key == target_key:
current_time = time.time()
if (
current_time - self.last_key_press_time < self.double_tap_threshold
and self.double_tap_callback is not None
and current_time - self.last_trigger_time > 0.5
):
logger.debug(f"Double-tap {self._modifier_key} detected (pynput)")
self.last_trigger_time = current_time
threading.Thread(target=self.double_tap_callback, daemon=True).start()
if self._mode == "toggle":
# Check for double-tap
if (
current_time - self.last_key_press_time < self.double_tap_threshold
and self.double_tap_callback is not None
and current_time - self.last_trigger_time > 0.5
):
logger.debug(f"Double-tap {self._modifier_key} detected (pynput)")
self.last_trigger_time = current_time
threading.Thread(target=self.double_tap_callback, daemon=True).start()
elif self._mode == "push_to_talk":
# Trigger on press
if self.key_press_callback is not None:
logger.debug(f"Key press {self._modifier_key} detected (pynput)")
threading.Thread(target=self.key_press_callback, daemon=True).start()
self.last_key_press_time = current_time
# Track current keys (using target key for reference)
@@ -169,7 +181,17 @@ class PynputKeyboardBackend(KeyboardBackend):
"""Handle key release events."""
try:
normalized_key = self._normalize_modifier_key(key)
target_key = self._get_target_key()
# Track current keys
self.current_keys.discard(normalized_key)
# Handle push-to-talk release
if self._mode == "push_to_talk" and normalized_key == target_key:
if self.key_release_callback is not None:
logger.debug(f"Key release {self._modifier_key} detected (pynput)")
threading.Thread(target=self.key_release_callback, daemon=True).start()
except Exception as e:
logger.error(f"Error in pynput key release handling: {e}")
+127 -20
View File
@@ -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",
]
+108 -17
View File
@@ -36,7 +36,11 @@ from ..utils.whispercpp_model_info import (
)
from ..utils.whispercpp_model_info import get_recommended_model as get_recommended_whispercpp_model
from ..utils.whispercpp_model_info import is_model_downloaded as is_whispercpp_model_downloaded
from .keyboard_backends import SHORTCUT_DISPLAY_NAMES, SUPPORTED_SHORTCUTS # noqa: E402
from .keyboard_backends import ( # noqa: E402
SHORTCUT_DISPLAY_NAMES,
SHORTCUT_MODES,
SUPPORTED_SHORTCUTS,
)
# Avoid circular imports for type checking
if TYPE_CHECKING:
@@ -560,13 +564,15 @@ class PreferenceRow(Gtk.ListBoxRow):
title_label.get_style_context().add_class("preference-row-title")
text_box.pack_start(title_label, False, False, 0)
# Store subtitle label reference for later updates
self.subtitle_label = None
if subtitle:
subtitle_label = Gtk.Label(label=subtitle, xalign=0, wrap=True)
subtitle_label.get_style_context().add_class("preference-row-subtitle")
subtitle_label.set_max_width_chars(40)
subtitle_label.set_line_wrap(True)
subtitle_label.set_line_wrap_mode(Pango.WrapMode.WORD_CHAR)
text_box.pack_start(subtitle_label, False, False, 0)
self.subtitle_label = Gtk.Label(label=subtitle, xalign=0, wrap=True)
self.subtitle_label.get_style_context().add_class("preference-row-subtitle")
self.subtitle_label.set_max_width_chars(40)
self.subtitle_label.set_line_wrap(True)
self.subtitle_label.set_line_wrap_mode(Pango.WrapMode.WORD_CHAR)
text_box.pack_start(self.subtitle_label, False, False, 0)
hbox.pack_start(text_box, True, True, 0)
@@ -577,6 +583,11 @@ class PreferenceRow(Gtk.ListBoxRow):
self.add(hbox)
def set_subtitle(self, subtitle: str):
"""Update the subtitle text."""
if self.subtitle_label:
self.subtitle_label.set_text(subtitle)
class ModelDownloadDialog(Gtk.Dialog):
"""Dialog showing model download progress with cancel support."""
@@ -1105,13 +1116,37 @@ class SettingsDialog(Gtk.Dialog):
"""Build the Keyboard Shortcuts section."""
group = PreferencesGroup(
title="Keyboard Shortcuts",
description="Configure the shortcut to toggle voice recognition",
description="Configure the shortcut to control voice recognition",
)
# Mode selection (Toggle vs Push-to-Talk)
self.shortcut_mode_combo = Gtk.ComboBoxText()
self.shortcut_mode_combo.set_size_request(200, -1)
self.shortcut_mode_combo.set_tooltip_text(
"Choose between toggle (double-tap) or push-to-talk mode"
)
_prevent_scroll_on_hover(self.shortcut_mode_combo)
# Populate mode options
for mode_id, display_name in SHORTCUT_MODES.items():
self.shortcut_mode_combo.append(mode_id, display_name)
# Load current mode from config
current_mode = self.config_manager.get("shortcuts", "mode", "toggle")
if not self.shortcut_mode_combo.set_active_id(current_mode):
self.shortcut_mode_combo.set_active_id("toggle")
mode_row = PreferenceRow(
title="Shortcut Mode",
subtitle="How the shortcut behaves",
widget=self.shortcut_mode_combo,
)
group.add_row(mode_row)
# Shortcut selection combo
self.shortcut_combo = Gtk.ComboBoxText()
self.shortcut_combo.set_size_request(200, -1)
self.shortcut_combo.set_tooltip_text("Select the keyboard shortcut to toggle voice typing")
self.shortcut_combo.set_tooltip_text("Select the keyboard shortcut for voice typing")
_prevent_scroll_on_hover(self.shortcut_combo)
# Populate shortcut options
@@ -1123,12 +1158,12 @@ class SettingsDialog(Gtk.Dialog):
if not self.shortcut_combo.set_active_id(current_shortcut):
self.shortcut_combo.set_active_id("ctrl+ctrl")
shortcut_row = PreferenceRow(
title="Toggle Recognition",
subtitle="Press this shortcut twice quickly to start/stop",
self.shortcut_row = PreferenceRow(
title="Shortcut Key",
subtitle="Press this key to control voice typing",
widget=self.shortcut_combo,
)
group.add_row(shortcut_row)
group.add_row(self.shortcut_row)
self.shortcuts_tab.pack_start(group, False, False, 0)
@@ -1143,7 +1178,7 @@ class SettingsDialog(Gtk.Dialog):
info_box.pack_start(info_icon, False, False, 0)
self.shortcut_info_label = Gtk.Label(
label="Changes take effect immediately. Double-tap the key to toggle voice typing.",
label="Changes take effect immediately.",
xalign=0,
wrap=True,
)
@@ -1152,8 +1187,64 @@ class SettingsDialog(Gtk.Dialog):
self.shortcuts_tab.pack_start(info_box, False, False, 0)
# Connect signal
# Connect signals
self.shortcut_combo.connect("changed", self._on_shortcut_changed)
self.shortcut_mode_combo.connect("changed", self._on_shortcut_mode_changed)
# Update UI based on initial mode
self._update_shortcut_ui_for_mode(current_mode)
def _update_shortcut_ui_for_mode(self, mode: str):
"""Update the shortcut UI based on the selected mode."""
if mode == "toggle":
self.shortcut_row.set_subtitle("Double-tap this key to start/stop voice typing")
self.shortcut_info_label.set_text(
"In Toggle mode: Double-tap the key to start voice typing, double-tap again to stop."
)
elif mode == "push_to_talk":
self.shortcut_row.set_subtitle("Hold this key to speak, release to stop")
self.shortcut_info_label.set_text(
"In Push-to-Talk mode: Hold the key down to speak, release to stop recording."
)
def _on_shortcut_mode_changed(self, widget):
"""Handle shortcut mode selection change."""
if self._initializing:
return
mode_id = self.shortcut_mode_combo.get_active_id()
if not mode_id:
return
# Save to config
self.config_manager.set("shortcuts", "mode", mode_id)
self.config_manager.save_settings()
mode_name = SHORTCUT_MODES.get(mode_id, mode_id)
logger.info(f"Keyboard shortcut mode changed to: {mode_name}")
# Update UI to reflect new mode
self._update_shortcut_ui_for_mode(mode_id)
# Try to apply the mode change live
if self.shortcut_update_callback:
shortcut_id = self.shortcut_combo.get_active_id()
success = self.shortcut_update_callback(shortcut_id, mode_id)
if success:
self.shortcut_info_label.set_markup(
f"<span foreground='#26a269'>Mode updated to <b>{mode_name}</b>. "
f"Active now!</span>"
)
else:
self.shortcut_info_label.set_markup(
f"<i>Mode updated to <b>{mode_name}</b>. "
f"Restart the app for the change to take full effect.</i>"
)
else:
self.shortcut_info_label.set_markup(
f"<i>Mode updated to <b>{mode_name}</b>. "
f"Restart the app for the change to take full effect.</i>"
)
def _on_shortcut_changed(self, widget):
"""Handle shortcut selection change."""
@@ -1173,7 +1264,8 @@ class SettingsDialog(Gtk.Dialog):
# Try to apply the shortcut change live
if self.shortcut_update_callback:
success = self.shortcut_update_callback(shortcut_id)
mode_id = self.shortcut_mode_combo.get_active_id()
success = self.shortcut_update_callback(shortcut_id, mode_id)
if success:
self.shortcut_info_label.set_markup(
f"<span foreground='#26a269'>Shortcut updated to <b>{display_name}</b>. "
@@ -1185,7 +1277,6 @@ class SettingsDialog(Gtk.Dialog):
f"Restart the app for the change to take full effect.</i>"
)
else:
# No callback provided, fall back to restart message
self.shortcut_info_label.set_markup(
f"<i>Shortcut updated to <b>{display_name}</b>. "
f"Restart the app for the change to take full effect.</i>"
+64 -10
View File
@@ -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."""
+29
View File
@@ -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")
+51 -11
View File
@@ -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
+15 -4
View File
@@ -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):