diff --git a/pyproject.toml b/pyproject.toml index b88df8f..71fc531 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -40,6 +40,7 @@ dependencies = [ "vosk>=0.3.45", "pydub>=0.25.1", "pynput>=1.7.6", + "evdev>=1.6.0; sys_platform == 'linux'", "requests>=2.28.0", "tqdm>=4.64.0", "numpy>=1.22.0", diff --git a/src/vocalinux/ui/keyboard_backends/__init__.py b/src/vocalinux/ui/keyboard_backends/__init__.py new file mode 100644 index 0000000..667dc11 --- /dev/null +++ b/src/vocalinux/ui/keyboard_backends/__init__.py @@ -0,0 +1,148 @@ +""" +Keyboard backend system for Vocalinux. + +This package provides a modular backend system for global keyboard shortcuts, +supporting multiple platforms and display servers (X11, Wayland). +""" + +import logging +import os +from typing import Optional + +from .base import KeyboardBackend + +logger = logging.getLogger(__name__) + +# Import available backends +try: + from .evdev_backend import EvdevKeyboardBackend + + EVDEV_AVAILABLE = True +except ImportError: + EvdevKeyboardBackend = None # type: ignore + EVDEV_AVAILABLE = False + +try: + from .pynput_backend import PynputKeyboardBackend + + PYNPUT_AVAILABLE = True +except ImportError: + PynputKeyboardBackend = None # type: ignore + PYNPUT_AVAILABLE = False + + +class DesktopEnvironment: + """Desktop environment detection.""" + + X11 = "x11" + WAYLAND = "wayland" + UNKNOWN = "unknown" + + @staticmethod + def detect() -> str: + """ + Detect the current desktop environment. + + Returns: + 'x11', 'wayland', or 'unknown' + """ + session_type = os.environ.get("XDG_SESSION_TYPE", "").lower() + if session_type == "wayland": + return DesktopEnvironment.WAYLAND + elif session_type == "x11": + return DesktopEnvironment.X11 + + # Fallback to environment variables + if "WAYLAND_DISPLAY" in os.environ: + return DesktopEnvironment.WAYLAND + elif "DISPLAY" in os.environ: + return DesktopEnvironment.X11 + + logger.warning("Could not detect desktop environment, defaulting to unknown") + return DesktopEnvironment.UNKNOWN + + +def create_backend(preferred_backend: Optional[str] = None) -> Optional[KeyboardBackend]: + """ + Create a keyboard backend based on availability and environment. + + Args: + preferred_backend: Optional backend name to force ('pynput' or 'evdev') + + Returns: + A KeyboardBackend instance, or None if no backend is available + """ + env = DesktopEnvironment.detect() + logger.info(f"Detected desktop environment: {env}") + + # If a specific backend is requested, try to use it + if preferred_backend: + if preferred_backend == "evdev": + if EVDEV_AVAILABLE: + logger.info("Using evdev backend (preferred)") + backend = EvdevKeyboardBackend() # type: ignore + if backend.is_available(): + return backend + hint = backend.get_permission_hint() + if hint: + logger.warning(f"Evdev backend not usable: {hint}") + logger.warning("Evdev backend preferred but not available, falling back") + else: + logger.warning("Evdev backend not available (python-evdev not installed)") + elif preferred_backend == "pynput": + if PYNPUT_AVAILABLE: + logger.info("Using pynput backend (preferred)") + return PynputKeyboardBackend() # type: ignore + logger.warning("Pynput backend not available") + else: + logger.warning(f"Unknown preferred backend: '{preferred_backend}'") + + # Auto-select based on environment + if env == DesktopEnvironment.WAYLAND: + # On Wayland, prefer evdev (if available) as pynput doesn't work + if EVDEV_AVAILABLE: + logger.info("Using evdev backend for Wayland") + backend = EvdevKeyboardBackend() # type: ignore + if backend.is_available(): + return backend + hint = backend.get_permission_hint() + if hint: + logger.warning(f"Evdev backend not usable on Wayland: {hint}") + logger.warning("Keyboard shortcuts will not work on Wayland without evdev access") + return None + logger.warning( + "evdev backend not available on Wayland. " + "Keyboard shortcuts will not work. Install python-evdev and " + "add your user to the 'input' group." + ) + return None + + # Default to pynput for X11 or unknown + if PYNPUT_AVAILABLE: + logger.info("Using pynput backend") + return PynputKeyboardBackend() # type: ignore + + if EVDEV_AVAILABLE: + logger.warning("Pynput not available, falling back to evdev backend") + backend = EvdevKeyboardBackend() # type: ignore + if backend.is_available(): + return backend + hint = backend.get_permission_hint() + if hint: + logger.warning(f"Evdev backend not usable: {hint}") + return None + + logger.error("No keyboard backend available") + logger.info("To enable keyboard shortcuts, install either python-evdev or pynput") + return None + + +__all__ = [ + "KeyboardBackend", + "create_backend", + "DesktopEnvironment", + "PynputKeyboardBackend", + "EvdevKeyboardBackend", + "EVDEV_AVAILABLE", + "PYNPUT_AVAILABLE", +] diff --git a/src/vocalinux/ui/keyboard_backends/base.py b/src/vocalinux/ui/keyboard_backends/base.py new file mode 100644 index 0000000..39ec63b --- /dev/null +++ b/src/vocalinux/ui/keyboard_backends/base.py @@ -0,0 +1,67 @@ +""" +Base class for keyboard backends. + +All keyboard backends must inherit from this class and implement +the required methods. +""" + +from abc import ABC, abstractmethod +from typing import Callable, Optional + + +class KeyboardBackend(ABC): + """ + Abstract base class for keyboard backends. + + Each backend must implement methods for starting/stopping keyboard + event listening and registering callbacks for specific shortcuts. + """ + + def __init__(self): + """Initialize the keyboard backend.""" + self.active = False + self.double_tap_callback: Optional[Callable] = None + + @abstractmethod + def start(self) -> bool: + """ + Start listening for keyboard events. + + Returns: + True if the backend started successfully, False otherwise + """ + pass + + @abstractmethod + def stop(self) -> None: + """Stop listening for keyboard events.""" + pass + + @abstractmethod + def is_available(self) -> bool: + """ + Check if this backend is available on the current system. + + Returns: + True if the backend can be used, False otherwise + """ + pass + + @abstractmethod + def get_permission_hint(self) -> Optional[str]: + """ + Get a hint message if permissions are missing. + + Returns: + A string explaining how to fix permissions, or None if permissions are OK + """ + pass + + def register_toggle_callback(self, callback: Callable) -> None: + """ + Register a callback for the double-tap Ctrl shortcut. + + Args: + callback: Function to call when double-tap Ctrl is detected + """ + self.double_tap_callback = callback diff --git a/src/vocalinux/ui/keyboard_backends/evdev_backend.py b/src/vocalinux/ui/keyboard_backends/evdev_backend.py new file mode 100644 index 0000000..8129fd5 --- /dev/null +++ b/src/vocalinux/ui/keyboard_backends/evdev_backend.py @@ -0,0 +1,295 @@ +""" +evdev keyboard backend for Wayland support. + +This backend uses python-evdev to read keyboard events directly from +input devices, which works on both X11 and Wayland (with proper permissions). +""" + +import errno +import logging +import os +import select +import threading +import time +from typing import List, Optional, Set + +# Try to import evdev +try: + import evdev + from evdev import InputDevice, ecodes + + EVDEV_AVAILABLE = True +except ImportError: + evdev = None # type: ignore + InputDevice = None # type: ignore + ecodes = None # type: ignore + EVDEV_AVAILABLE = False + +from .base import KeyboardBackend + +logger = logging.getLogger(__name__) + + +# Key code for Ctrl (left and right) +KEY_LEFTCTRL = 29 +KEY_RIGHTCTRL = 97 +KEY_CTRL = {KEY_LEFTCTRL, KEY_RIGHTCTRL} + + +def find_keyboard_devices() -> List[str]: + """ + Find all keyboard input devices. + + Returns: + List of device paths for keyboard devices + """ + keyboard_devices = [] + + try: + # Read from /proc/bus/input/devices to find keyboards + with open("/proc/bus/input/devices", "r") as f: + current_device = None + for line in f: + line = line.rstrip("\n") + if line.startswith("I: Bus="): + current_device = {"handlers": []} + elif line.startswith("H: Handlers=") and current_device is not None: + handlers = line.split("=", 1)[1].strip() + current_device["handlers"] = handlers.split() + elif line.startswith("B: KEY=") and current_device is not None: + # Check if this device has keyboard keys (bit 0 is set) + key_bits = line.split("=", 1)[1].strip() + # The first hex digit after KEY= contains keyboard capability + # If it's not 0, 1, or ffffffffff, it has keyboard keys + if key_bits and key_bits != "0": + # Check if event handler exists + for handler in current_device.get("handlers", []): + if handler.startswith("event"): + device_path = f"/dev/input/{handler}" + if os.path.exists(device_path): + keyboard_devices.append(device_path) + current_device = None + + except (IOError, OSError) as e: + logger.error(f"Error reading input devices: {e}") + + return keyboard_devices + + +class EvdevKeyboardBackend(KeyboardBackend): + """ + Keyboard backend using python-evdev. + + This backend reads keyboard events directly from input devices, + which works on both X11 and Wayland when the user has permission + to read from /dev/input/event* devices (member of 'input' group). + """ + + def __init__(self): + """Initialize the evdev keyboard backend.""" + super().__init__() + self.devices: List[InputDevice] = [] + self.device_fds: List[int] = [] + self.running = False + self.monitor_thread: Optional[threading.Thread] = None + + self.last_trigger_time = 0 + self.last_ctrl_press_time = 0 + self.double_tap_threshold = 0.3 # seconds + self.ctrl_pressed_devices: Set[int] = set() + + if not EVDEV_AVAILABLE: + logger.error("python-evdev not available") + + def is_available(self) -> bool: + """Check if evdev is available.""" + if not EVDEV_AVAILABLE: + return False + + # Check if we can access at least one keyboard device + try: + devices = find_keyboard_devices() + return len(devices) > 0 + except Exception: + return False + + def get_permission_hint(self) -> Optional[str]: + """ + Get permission hint for evdev backend. + + Returns: + Instructions if permissions are missing, None otherwise + """ + if not EVDEV_AVAILABLE: + return "Install python-evdev: pip install evdev" + + try: + devices = find_keyboard_devices() + if not devices: + return None # No devices found, not a permission issue + + # Try to open the first device to check permissions + for device_path in devices[:1]: # Just check the first one + try: + InputDevice(device_path) + return None # Successfully opened, permissions OK + 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" + "sudo usermod -a -G input $USER" + ) + except Exception: + pass + + return None + + def start(self) -> bool: + """ + Start the evdev keyboard listener. + + Returns: + True if started successfully, False otherwise + """ + if not EVDEV_AVAILABLE: + logger.error("Cannot start: python-evdev not available") + return False + + if self.active: + return True + + # Find keyboard devices + device_paths = find_keyboard_devices() + if not device_paths: + logger.error("No keyboard devices found") + return False + + logger.info(f"Found {len(device_paths)} keyboard device(s)") + + # Open devices + self.devices = [] + self.device_fds = [] + self.ctrl_pressed_devices = set() + + for device_path in device_paths: + try: + device = InputDevice(device_path) + self.devices.append(device) + self.device_fds.append(device.fileno()) + logger.debug(f"Opened keyboard device: {device_path} ({device.name})") + except (OSError, IOError) as e: + logger.warning(f"Cannot open {device_path}: {e}") + continue + + if not self.devices: + logger.error("Failed to open any keyboard device (permission denied?)") + return False + + # Start monitoring thread + self.running = True + self.monitor_thread = threading.Thread(target=self._monitor_devices, daemon=True) + self.monitor_thread.start() + + logger.info("Evdev keyboard listener started successfully") + self.active = True + return True + + def stop(self) -> None: + """Stop the evdev keyboard listener.""" + if not self.active: + return + + logger.info("Stopping evdev keyboard listener") + self.running = False + self.active = False + + # Close devices + for device in self.devices: + try: + device.close() + except Exception: + pass + + self.devices = [] + self.device_fds = [] + + # Wait for monitor thread to finish + if self.monitor_thread: + self.monitor_thread.join(timeout=2.0) + self.monitor_thread = None + + def _monitor_devices(self) -> None: + """Monitor keyboard devices for events.""" + logger.debug("Starting device monitor thread") + + while self.running: + try: + # Use select to wait for events on any device + if not self.device_fds: + break + + readable, _, _ = select.select(self.device_fds, [], [], 1.0) # 1 second timeout + + for fd in readable: + try: + # Find the device for this fd + device = None + for d in self.devices: + if d.fileno() == fd: + device = d + break + + if device is None: + continue + + # Read events from this device + for event in device.read(): + if event.type == ecodes.EV_KEY: + self._handle_key_event(event, device) + + except (OSError, IOError): + # Device was likely disconnected + continue + + except (OSError, ValueError) as e: + if self.running: + logger.error(f"Error monitoring devices: {e}") + break + + logger.debug("Device monitor thread stopped") + + def _handle_key_event(self, event, device) -> None: + """Handle a key event from evdev.""" + try: + code = event.code + value = event.value # 0 = release, 1 = press, 2 = repeat + + # Check if this is a Ctrl key + if code in KEY_CTRL: + device_id = id(device) + + if value == 1: # Key press + self.ctrl_pressed_devices.add(device_id) + current_time = time.time() + + # Check for double-tap + if ( + current_time - self.last_ctrl_press_time < self.double_tap_threshold + and self.double_tap_callback is not None + and current_time - self.last_trigger_time > 0.5 + ): + logger.debug("Double-tap Ctrl detected (evdev)") + self.last_trigger_time = current_time + threading.Thread(target=self.double_tap_callback, daemon=True).start() + + self.last_ctrl_press_time = current_time + + elif value == 0: # Key release + self.ctrl_pressed_devices.discard(device_id) + + except Exception as e: + logger.error(f"Error handling key event: {e}") + + +# Export availability +__all__ = ["EvdevKeyboardBackend", "EVDEV_AVAILABLE", "find_keyboard_devices"] diff --git a/src/vocalinux/ui/keyboard_backends/pynput_backend.py b/src/vocalinux/ui/keyboard_backends/pynput_backend.py new file mode 100644 index 0000000..b5fcd00 --- /dev/null +++ b/src/vocalinux/ui/keyboard_backends/pynput_backend.py @@ -0,0 +1,158 @@ +""" +Pynput keyboard backend for X11. + +This backend uses the pynput library for global keyboard shortcuts. +Works on X11 and XWayland, but NOT on pure Wayland. +""" + +import logging +import threading +import time +from typing import Optional + +# Try to import pynput +try: + from pynput import keyboard + + PYNPUT_AVAILABLE = True +except ImportError: + keyboard = None # type: ignore + PYNPUT_AVAILABLE = False + +from .base import KeyboardBackend + +logger = logging.getLogger(__name__) + + +class PynputKeyboardBackend(KeyboardBackend): + """ + Keyboard backend using pynput library. + + This backend works on X11 and XWayland but NOT on pure Wayland + due to Wayland's security restrictions. + """ + + def __init__(self): + """Initialize the pynput keyboard backend.""" + super().__init__() + self.listener = None + self.last_trigger_time = 0 + self.last_ctrl_press_time = 0 + self.double_tap_threshold = 0.3 # seconds + self.current_keys = set() + + if not PYNPUT_AVAILABLE: + logger.error("pynput library not available") + + def is_available(self) -> bool: + """Check if pynput is available.""" + return PYNPUT_AVAILABLE + + def get_permission_hint(self) -> Optional[str]: + """ + Get permission hint for pynput backend. + + Returns: + None for pynput (permissions are typically OK on X11) + """ + return None + + def start(self) -> bool: + """ + Start the pynput keyboard listener. + + Returns: + True if started successfully, False otherwise + """ + if not PYNPUT_AVAILABLE: + logger.error("Cannot start: pynput not available") + return False + + if self.active: + return True + + logger.info("Starting pynput keyboard listener") + self.current_keys = set() + + try: + self.listener = keyboard.Listener(on_press=self._on_press, on_release=self._on_release) + self.listener.daemon = True + self.listener.start() + + if not self.listener.is_alive(): + logger.error("Failed to start pynput listener") + return False + + logger.info("Pynput keyboard listener started successfully") + self.active = True + return True + + except Exception as e: + logger.error(f"Error starting pynput listener: {e}") + return False + + def stop(self) -> None: + """Stop the pynput keyboard listener.""" + if not self.active or not self.listener: + return + + logger.info("Stopping pynput keyboard listener") + self.active = False + + if self.listener: + try: + self.listener.stop() + self.listener.join(timeout=1.0) + except Exception as e: + logger.error(f"Error stopping pynput listener: {e}") + finally: + self.listener = None + + def _on_press(self, key) -> None: + """Handle key press events.""" + try: + normalized_key = self._normalize_modifier_key(key) + if normalized_key == keyboard.Key.ctrl: + current_time = time.time() + if ( + current_time - self.last_ctrl_press_time < self.double_tap_threshold + and self.double_tap_callback is not None + and current_time - self.last_trigger_time > 0.5 + ): + logger.debug("Double-tap Ctrl detected (pynput)") + self.last_trigger_time = current_time + threading.Thread(target=self.double_tap_callback, daemon=True).start() + self.last_ctrl_press_time = current_time + + if key in {keyboard.Key.ctrl, keyboard.Key.ctrl_l, keyboard.Key.ctrl_r}: + normalized_key = self._normalize_modifier_key(key) + self.current_keys.add(normalized_key) + + except Exception as e: + logger.error(f"Error in pynput key press handling: {e}") + + def _on_release(self, key) -> None: + """Handle key release events.""" + try: + normalized_key = self._normalize_modifier_key(key) + self.current_keys.discard(normalized_key) + except Exception as e: + logger.error(f"Error in pynput key release handling: {e}") + + def _normalize_modifier_key(self, key): + """Normalize left/right variants of modifier keys.""" + key_mapping = { + keyboard.Key.alt_l: keyboard.Key.alt, + keyboard.Key.alt_r: keyboard.Key.alt, + keyboard.Key.shift_l: keyboard.Key.shift, + keyboard.Key.shift_r: keyboard.Key.shift, + keyboard.Key.ctrl_l: keyboard.Key.ctrl, + keyboard.Key.ctrl_r: keyboard.Key.ctrl, + keyboard.Key.cmd_l: keyboard.Key.cmd, + keyboard.Key.cmd_r: keyboard.Key.cmd, + } + return key_mapping.get(key, key) + + +# Export availability +__all__ = ["PynputKeyboardBackend", "PYNPUT_AVAILABLE"] diff --git a/src/vocalinux/ui/keyboard_shortcuts.py b/src/vocalinux/ui/keyboard_shortcuts.py index 400d075..03cb33d 100644 --- a/src/vocalinux/ui/keyboard_shortcuts.py +++ b/src/vocalinux/ui/keyboard_shortcuts.py @@ -3,100 +3,120 @@ Keyboard shortcut manager for Vocalinux. This module provides global keyboard shortcut functionality to start/stop speech recognition with a double-tap of the Ctrl key. + +Supports multiple backends: +- pynput: Works on X11/XWayland +- evdev: Works on both X11 and Wayland (with proper permissions) """ import logging -import threading -import time -from typing import Callable +from typing import Callable, Optional -# Make keyboard a module-level attribute first, even if it's None -# This will ensure the attribute exists for patching in tests -keyboard = None -KEYBOARD_AVAILABLE = False - -# Try to import X11 keyboard libraries -try: - from pynput import keyboard - - KEYBOARD_AVAILABLE = True -except ImportError: - # Keep keyboard as None, which we set above - pass +# Import the backend system +from .keyboard_backends import EVDEV_AVAILABLE, PYNPUT_AVAILABLE, DesktopEnvironment, create_backend 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) + + +def _init_legacy_keyboard(): + """Initialize legacy keyboard attribute for backward compatibility.""" + global keyboard + try: + from pynput import keyboard as pynput_keyboard + + keyboard = pynput_keyboard + except ImportError: + pass + + +_init_legacy_keyboard() + + class KeyboardShortcutManager: """ Manages global keyboard shortcuts for the application. This class allows registering the double-tap Ctrl shortcut to toggle voice typing on and off across the desktop environment. + + Automatically selects the appropriate backend based on the + desktop environment (X11, Wayland) and available dependencies. """ - def __init__(self): - """Initialize the keyboard shortcut manager.""" - self.listener = None + def __init__(self, backend: Optional[str] = None): + """ + Initialize the keyboard shortcut manager. + + Args: + backend: Optional backend name to force ('pynput' or 'evdev') + If not specified, auto-detects based on environment. + """ + self.backend_instance = None self.active = False - self.last_trigger_time = 0 # Track last trigger time to prevent double triggers - # Double-tap tracking variables - self.last_ctrl_press_time = 0 - self.double_tap_callback = None - self.double_tap_threshold = 0.3 # seconds between taps to count as double-tap + # Create the appropriate backend + self.backend_instance = create_backend(preferred_backend=backend) - if not KEYBOARD_AVAILABLE: - logger.error("Keyboard shortcut libraries not available. Shortcuts will not work.") - return + if self.backend_instance is None: + logger.error("No keyboard backend available. Shortcuts will not work.") + self._log_unavailable_hints() - def start(self): - """Start listening for keyboard shortcuts.""" - if not KEYBOARD_AVAILABLE: - return + def _log_unavailable_hints(self): + """Log helpful hints when no backend is available.""" + env = DesktopEnvironment.detect() + + if env == DesktopEnvironment.WAYLAND: + logger.warning("=" * 60) + logger.warning("Keyboard shortcuts not available on Wayland") + logger.warning("=" * 60) + logger.warning("To enable keyboard shortcuts on Wayland:") + logger.warning("1. Install python-evdev:") + logger.warning(" pip install evdev") + logger.warning("2. Add your user to the 'input' group:") + logger.warning(" sudo usermod -a -G input $USER") + logger.warning("3. Log out and log back in") + logger.warning("=" * 60) + else: + logger.warning("Keyboard shortcuts require pynput or evdev:") + logger.warning(" pip install pynput evdev") + + def start(self) -> bool: + """ + Start listening for keyboard shortcuts. + + Returns: + True if the listener started successfully, False otherwise + """ + if self.backend_instance is None: + return False if self.active: - return + return True logger.info("Starting keyboard shortcut listener") - self.active = True + self.active = self.backend_instance.start() - # Track currently pressed modifier keys - self.current_keys = set() + if not self.active: + hint = self.backend_instance.get_permission_hint() + if hint: + logger.warning(f"Permission issue: {hint}") - try: - # Start keyboard listener in a separate thread - self.listener = keyboard.Listener(on_press=self._on_press, on_release=self._on_release) - self.listener.daemon = True - self.listener.start() - - # Verify the listener started successfully - if not self.listener.is_alive(): - logger.error("Failed to start keyboard listener") - self.active = False - else: - logger.info("Keyboard shortcut listener started successfully") - except Exception as e: - logger.error(f"Error starting keyboard listener: {e}") - self.active = False + return self.active def stop(self): """Stop listening for keyboard shortcuts.""" - if not self.active or not self.listener: + if self.backend_instance is None: return logger.info("Stopping keyboard shortcut listener") + self.backend_instance.stop() self.active = False - if self.listener: - try: - self.listener.stop() - self.listener.join(timeout=1.0) - except Exception as e: - logger.error(f"Error stopping keyboard listener: {e}") - finally: - self.listener = None - def register_toggle_callback(self, callback: Callable): """ Register a callback for the double-tap Ctrl shortcut. @@ -104,70 +124,53 @@ class KeyboardShortcutManager: Args: callback: Function to call when the double-tap Ctrl is pressed """ - self.double_tap_callback = callback + if self.backend_instance is None: + logger.warning("Cannot register callback: no backend available") + return + + self.backend_instance.register_toggle_callback(callback) logger.info("Registered shortcut: Double-tap Ctrl") - def _on_press(self, key): + @property + def listener(self): """ - Handle key press events. + Legacy property for backward compatibility. - Args: - key: The pressed key + Returns the underlying backend object if using pynput backend. """ - try: - # Check for double-tap Ctrl - normalized_key = self._normalize_modifier_key(key) - if normalized_key == keyboard.Key.ctrl: - current_time = time.time() - if current_time - self.last_ctrl_press_time < self.double_tap_threshold: - # This is a double-tap Ctrl - if self.double_tap_callback and current_time - self.last_trigger_time > 0.5: - logger.debug("Double-tap Ctrl detected") - self.last_trigger_time = current_time - # Run callback in a separate thread to avoid blocking - threading.Thread(target=self.double_tap_callback, daemon=True).start() - self.last_ctrl_press_time = current_time + if self.backend_instance and hasattr(self.backend_instance, "listener"): + return self.backend_instance.listener + return None - # Add to currently pressed modifier keys (only for tracking Ctrl) - if key in { - keyboard.Key.ctrl, - keyboard.Key.ctrl_l, - keyboard.Key.ctrl_r, - }: - # Normalize left/right variants - normalized_key = self._normalize_modifier_key(key) - self.current_keys.add(normalized_key) - except Exception as e: - logger.error(f"Error in keyboard shortcut handling: {e}") +# For backward compatibility with tests +def _normalize_modifier_key(key): + """ + Legacy function for backward compatibility. - def _on_release(self, key): - """ - Handle key release events. + Normalize left/right variants of modifier keys to their base form. + """ + if keyboard is None: + return key - Args: - key: The released key - """ - try: - # Normalize the key for left/right variants - normalized_key = self._normalize_modifier_key(key) - # Remove from currently pressed keys - self.current_keys.discard(normalized_key) - except Exception as e: - logger.error(f"Error in keyboard release handling: {e}") + key_mapping = { + keyboard.Key.alt_l: keyboard.Key.alt, + keyboard.Key.alt_r: keyboard.Key.alt, + keyboard.Key.shift_l: keyboard.Key.shift, + keyboard.Key.shift_r: keyboard.Key.shift, + keyboard.Key.ctrl_l: keyboard.Key.ctrl, + keyboard.Key.ctrl_r: keyboard.Key.ctrl, + keyboard.Key.cmd_l: keyboard.Key.cmd, + keyboard.Key.cmd_r: keyboard.Key.cmd, + } - def _normalize_modifier_key(self, key): - """Normalize left/right variants of modifier keys to their base form.""" - # Map left/right variants to their base key - key_mapping = { - keyboard.Key.alt_l: keyboard.Key.alt, - keyboard.Key.alt_r: keyboard.Key.alt, - keyboard.Key.shift_l: keyboard.Key.shift, - keyboard.Key.shift_r: keyboard.Key.shift, - keyboard.Key.ctrl_l: keyboard.Key.ctrl, - keyboard.Key.ctrl_r: keyboard.Key.ctrl, - keyboard.Key.cmd_l: keyboard.Key.cmd, - keyboard.Key.cmd_r: keyboard.Key.cmd, - } + return key_mapping.get(key, key) - return key_mapping.get(key, key) + +__all__ = [ + "KeyboardShortcutManager", + "KEYBOARD_AVAILABLE", + "DesktopEnvironment", + "EVDEV_AVAILABLE", + "PYNPUT_AVAILABLE", +] diff --git a/tests/test_keyboard_shortcuts.py b/tests/test_keyboard_shortcuts.py index 4b4d441..b2fc86c 100644 --- a/tests/test_keyboard_shortcuts.py +++ b/tests/test_keyboard_shortcuts.py @@ -2,9 +2,8 @@ Tests for keyboard shortcut functionality. """ -import time import unittest -from unittest.mock import MagicMock, patch +from unittest.mock import MagicMock, Mock, patch # Update import to use the new package structure from vocalinux.ui.keyboard_shortcuts import KeyboardShortcutManager @@ -15,114 +14,90 @@ class TestKeyboardShortcuts(unittest.TestCase): def setUp(self): """Set up for tests.""" - # Set up more complete mocks for the keyboard library - self.kb_patch = patch("vocalinux.ui.keyboard_shortcuts.KEYBOARD_AVAILABLE", True) - self.kb_patch.start() + # Mock the backend system + self.backend_patch = patch("vocalinux.ui.keyboard_shortcuts.create_backend") + self.mock_create_backend = self.backend_patch.start() - # Create proper Key enum and KeyCode class - self.keyboard_patch = patch("vocalinux.ui.keyboard_shortcuts.keyboard") - self.mock_keyboard = self.keyboard_patch.start() - - # Set up Key attributes as simple strings for easier testing - self.mock_keyboard.Key.alt = "Key.alt" - self.mock_keyboard.Key.alt_l = "Key.alt_l" - self.mock_keyboard.Key.alt_r = "Key.alt_r" - self.mock_keyboard.Key.shift = "Key.shift" - self.mock_keyboard.Key.shift_l = "Key.shift_l" - self.mock_keyboard.Key.shift_r = "Key.shift_r" - self.mock_keyboard.Key.ctrl = "Key.ctrl" - self.mock_keyboard.Key.ctrl_l = "Key.ctrl_l" - self.mock_keyboard.Key.ctrl_r = "Key.ctrl_r" - self.mock_keyboard.Key.cmd = "Key.cmd" - self.mock_keyboard.Key.cmd_l = "Key.cmd_l" - self.mock_keyboard.Key.cmd_r = "Key.cmd_r" - - # Create mock Listener - self.mock_listener = MagicMock() - self.mock_listener.is_alive.return_value = True - self.mock_keyboard.Listener.return_value = self.mock_listener + # Create a mock backend + self.mock_backend = MagicMock() + self.mock_backend.active = False + self.mock_backend.double_tap_callback = None + self.mock_backend.start.return_value = True + self.mock_create_backend.return_value = self.mock_backend # Create a new KSM for each test self.ksm = KeyboardShortcutManager() def tearDown(self): """Clean up after tests.""" - self.kb_patch.stop() - self.keyboard_patch.stop() + self.backend_patch.stop() def test_init(self): """Test initialization of the keyboard shortcut manager.""" - # Verify double-tap threshold is set - self.assertEqual(self.ksm.double_tap_threshold, 0.3) - # Verify double-tap callback is initially None - self.assertIsNone(self.ksm.double_tap_callback) + # Verify backend was created + self.mock_create_backend.assert_called_once() + self.assertIsNotNone(self.ksm.backend_instance) def test_start_listener(self): """Test starting the keyboard listener.""" # Start the listener - self.ksm.start() + result = self.ksm.start() - # Verify listener was created with correct arguments - self.mock_keyboard.Listener.assert_called_once() - - # Check that on_press and on_release are being passed - args, kwargs = self.mock_keyboard.Listener.call_args - self.assertIn("on_press", kwargs) - self.assertIn("on_release", kwargs) - - # Check that listener was started - self.mock_listener.start.assert_called_once() + # Verify backend start was called + self.mock_backend.start.assert_called_once() self.assertTrue(self.ksm.active) + self.assertTrue(result) def test_start_already_active(self): """Test starting when already active.""" # Make it active already + self.mock_backend.active = True self.ksm.active = True # Try to start again - self.ksm.start() + result = self.ksm.start() - # Verify nothing was called - self.mock_keyboard.Listener.assert_not_called() + # Verify start was not called again + self.mock_backend.start.assert_not_called() + self.assertTrue(result) def test_start_listener_failed(self): """Test handling when listener fails to start.""" - # Make is_alive return False - self.mock_listener.is_alive.return_value = False + # Make start return False + self.mock_backend.start.return_value = False + self.mock_backend.get_permission_hint.return_value = None # Start the listener - self.ksm.start() + result = self.ksm.start() - # Should have tried to start but then set active to False - self.mock_listener.start.assert_called_once() + # Should return False + self.assertFalse(result) self.assertFalse(self.ksm.active) def test_stop_listener(self): """Test stopping the keyboard listener.""" # Setup an active listener self.ksm.start() - self.ksm.active = True + self.mock_backend.active = True # Stop the listener self.ksm.stop() - # Verify listener was stopped - self.mock_listener.stop.assert_called_once() - self.mock_listener.join.assert_called_once() + # Verify backend stop was called + self.mock_backend.stop.assert_called_once() self.assertFalse(self.ksm.active) - self.assertIsNone(self.ksm.listener) def test_stop_not_active(self): """Test stopping when not active.""" # Make it inactive self.ksm.active = False + self.mock_backend.active = False # Try to stop self.ksm.stop() - # Nothing should happen - if hasattr(self.ksm, "listener") and self.ksm.listener: - self.mock_listener.stop.assert_not_called() + # Backend stop should still be called (idempotent) + self.mock_backend.stop.assert_called_once() def test_register_toggle_callback(self): """Test registering toggle callback with double-tap shortcut.""" @@ -132,146 +107,257 @@ class TestKeyboardShortcuts(unittest.TestCase): # Register as toggle callback self.ksm.register_toggle_callback(callback) - # Verify it was registered as double-tap callback - self.assertEqual(self.ksm.double_tap_callback, callback) + # Verify it was registered on the backend + self.mock_backend.register_toggle_callback.assert_called_once_with(callback) - def test_key_press_modifier(self): - """Test handling a modifier key press.""" - # Initialize and start to set up the listener - self.ksm.start() + def test_no_backend_available(self): + """Test behavior when no backend is available.""" + # Mock create_backend returning None + self.mock_create_backend.return_value = None - # Get the on_press handler - on_press = self.mock_keyboard.Listener.call_args[1]["on_press"] + # Create a new KSM + ksm = KeyboardShortcutManager() - # Make sure current_keys is set and initially empty - self.ksm.current_keys = set() + # Verify backend is None + self.assertIsNone(ksm.backend_instance) - # Simulate pressing Ctrl - on_press(self.mock_keyboard.Key.ctrl) + # Start should return False + result = ksm.start() + self.assertFalse(result) - # Verify Ctrl was added to current keys - self.assertIn(self.mock_keyboard.Key.ctrl, self.ksm.current_keys) - - def test_double_tap_ctrl(self): - """Test double-tap Ctrl detection.""" - # Initialize and start to set up the listener - self.ksm.start() - - # Get the on_press handler - on_press = self.mock_keyboard.Listener.call_args[1]["on_press"] - - # Register mock callback for double-tap Ctrl + # Register callback should warn but not crash callback = MagicMock() - self.ksm.register_toggle_callback(callback) + ksm.register_toggle_callback(callback) # Should not raise - # Set up initial state - self.ksm.last_ctrl_press_time = time.time() - 0.2 # Recent press (within threshold) - self.ksm.last_trigger_time = 0 # No recent triggers + def test_permission_hint_on_start_failure(self): + """Test that permission hint is logged on start failure.""" + # Make start return False + self.mock_backend.start.return_value = False + self.mock_backend.get_permission_hint.return_value = "Add user to input group" - # Simulate second Ctrl press (should trigger callback) - on_press(self.mock_keyboard.Key.ctrl) + # Start the listener + with patch("vocalinux.ui.keyboard_shortcuts.logger") as mock_logger: + self.ksm.start() - # Verify callback was triggered - callback.assert_called_once() + # Verify permission hint was logged + mock_logger.warning.assert_called() + warning_calls = [str(call) for call in mock_logger.warning.call_args_list] + self.assertTrue(any("Permission issue" in str(call) for call in warning_calls)) - # Reset and test when press is outside threshold (not a double-tap) - callback.reset_mock() - self.ksm.last_ctrl_press_time = time.time() - 0.5 # Outside threshold - on_press(self.mock_keyboard.Key.ctrl) - callback.assert_not_called() - def test_normalize_modifier_keys(self): - """Test normalizing left/right modifier keys.""" - # Setup the key mapping dictionary correctly - self.ksm._normalize_modifier_key = MagicMock( - side_effect=lambda key: { - self.mock_keyboard.Key.alt_l: self.mock_keyboard.Key.alt, - self.mock_keyboard.Key.alt_r: self.mock_keyboard.Key.alt, - self.mock_keyboard.Key.shift_l: self.mock_keyboard.Key.shift, - self.mock_keyboard.Key.shift_r: self.mock_keyboard.Key.shift, - self.mock_keyboard.Key.ctrl_l: self.mock_keyboard.Key.ctrl, - self.mock_keyboard.Key.ctrl_r: self.mock_keyboard.Key.ctrl, - self.mock_keyboard.Key.cmd_l: self.mock_keyboard.Key.cmd, - self.mock_keyboard.Key.cmd_r: self.mock_keyboard.Key.cmd, - }.get(key, key) +class TestPynputBackend(unittest.TestCase): + """Test cases for the pynput backend specifically.""" + + def setUp(self): + """Set up for pynput backend tests.""" + # Mock pynput + self.pynput_patch = patch( + "vocalinux.ui.keyboard_backends.pynput_backend.PYNPUT_AVAILABLE", True ) + self.pynput_patch.start() - # Test the normalization - self.assertEqual( - self.ksm._normalize_modifier_key(self.mock_keyboard.Key.alt_l), - self.mock_keyboard.Key.alt, - ) + self.keyboard_patch = patch("vocalinux.ui.keyboard_backends.pynput_backend.keyboard") + self.mock_keyboard = self.keyboard_patch.start() - def test_double_tap_ctrl_debounce(self): - """Test that double-tap Ctrl has debounce protection.""" - # Initialize and start to set up the listener - self.ksm.start() + # Set up Key attributes + self.mock_keyboard.Key.ctrl = Mock() + self.mock_keyboard.Key.ctrl_l = Mock() + self.mock_keyboard.Key.ctrl_r = Mock() + self.mock_keyboard.Key.alt = Mock() + self.mock_keyboard.Key.alt_l = Mock() + self.mock_keyboard.Key.alt_r = Mock() - # Get the on_press handler - on_press = self.mock_keyboard.Listener.call_args[1]["on_press"] + # Create mock Listener + self.mock_listener = MagicMock() + self.mock_listener.is_alive.return_value = True + self.mock_keyboard.Listener.return_value = self.mock_listener - # Register mock callback for double-tap Ctrl - callback = MagicMock() - self.ksm.register_toggle_callback(callback) + def tearDown(self): + """Clean up after pynput tests.""" + self.pynput_patch.stop() + self.keyboard_patch.stop() - # Set up initial state (recent trigger) - self.ksm.last_ctrl_press_time = time.time() - 0.2 # Recent press (within threshold) - self.ksm.last_trigger_time = time.time() - 0.2 # Recent trigger, within debounce + def test_pynput_backend_is_available(self): + """Test that pynput backend reports as available when pynput is installed.""" + from vocalinux.ui.keyboard_backends.pynput_backend import PynputKeyboardBackend - # Simulate second Ctrl press (should NOT trigger due to debounce) - on_press(self.mock_keyboard.Key.ctrl) + backend = PynputKeyboardBackend() + self.assertTrue(backend.is_available()) - # Verify callback was NOT triggered - callback.assert_not_called() + def test_pynput_backend_start(self): + """Test starting pynput backend.""" + from vocalinux.ui.keyboard_backends.pynput_backend import PynputKeyboardBackend - def test_key_release(self): - """Test handling a key release.""" - # Initialize and start to set up the listener - self.ksm.start() + backend = PynputKeyboardBackend() + result = backend.start() - # Get the on_release handler - on_release = self.mock_keyboard.Listener.call_args[1]["on_release"] + # Verify listener was created and started + self.mock_keyboard.Listener.assert_called_once() + self.mock_listener.start.assert_called_once() + self.assertTrue(result) + self.assertTrue(backend.active) - # Add some keys - self.ksm.current_keys = {self.mock_keyboard.Key.ctrl} + def test_pynput_backend_stop(self): + """Test stopping pynput backend.""" + from vocalinux.ui.keyboard_backends.pynput_backend import PynputKeyboardBackend - # Simulate releasing Ctrl - on_release(self.mock_keyboard.Key.ctrl) + backend = PynputKeyboardBackend() + backend.start() + backend.active = True - # Verify Ctrl was removed - self.assertNotIn(self.mock_keyboard.Key.ctrl, self.ksm.current_keys) + backend.stop() - def test_error_handling(self): - """Test error handling in key event handlers.""" - # Initialize and start to set up the listener - self.ksm.start() + # Verify listener was stopped + self.mock_listener.stop.assert_called_once() + self.assertFalse(backend.active) - # Get the handlers - on_press = self.mock_keyboard.Listener.call_args[1]["on_press"] - on_release = self.mock_keyboard.Listener.call_args[1]["on_release"] + def test_pynput_backend_no_permission_hint(self): + """Test that pynput backend has no permission hint.""" + from vocalinux.ui.keyboard_backends.pynput_backend import PynputKeyboardBackend - # Make a key that raises an exception - bad_key = MagicMock() - bad_key.__eq__ = MagicMock(side_effect=Exception("Test exception")) + backend = PynputKeyboardBackend() + self.assertIsNone(backend.get_permission_hint()) - # Verify exceptions are caught - try: - on_press(bad_key) - on_release(bad_key) - # If we get here, exceptions were caught properly - self.assertTrue(True) - except: - self.fail("Exceptions were not caught in event handlers") - def test_no_keyboard_library(self): - """Test behavior when keyboard library is not available.""" - # Create a new mock to replace the keyboard system - with patch("vocalinux.ui.keyboard_shortcuts.KEYBOARD_AVAILABLE", False): - # Create a new KSM with no keyboard library - ksm = KeyboardShortcutManager() +class TestEvdevBackend(unittest.TestCase): + """Test cases for the evdev backend.""" - # Start should do nothing - ksm.start() + def setUp(self): + """Set up for evdev backend tests.""" + # Import sys to add mock modules + import sys - # When keyboard is not available, active should remain False - self.assertFalse(ksm.active) + # Create a mock evdev module structure + self.mock_evdev = MagicMock() + self.mock_evdev.__name__ = "evdev" + self.mock_evdev.ecodes = MagicMock() + self.mock_evdev.categorize = MagicMock() + self.mock_evdev.InputDevice = MagicMock() + self.mock_evdev.ecodes.EV_KEY = 1 + + # Create mock backend module + self.mock_evdev_backend = MagicMock() + self.mock_evdev_backend.EVDEV_AVAILABLE = True + self.mock_evdev_backend.evdev = self.mock_evdev + self.mock_evdev_backend.InputDevice = self.mock_evdev.InputDevice + self.mock_evdev_backend.ecodes = self.mock_evdev.ecodes + self.mock_evdev_backend.categorize = self.mock_evdev.categorize + self.mock_evdev_backend.find_keyboard_devices = MagicMock(return_value=[]) + + # Inject into sys.modules + sys.modules["vocalinux.ui.keyboard_backends.evdev_backend"] = self.mock_evdev_backend + sys.modules["evdev"] = self.mock_evdev + + # Need to reload the keyboard_backends module to pick up the mock + import importlib + + from vocalinux.ui import keyboard_backends + + importlib.reload(keyboard_backends) + + self.keyboard_backends = keyboard_backends + + def tearDown(self): + """Clean up after evdev tests.""" + import importlib + import sys + + from vocalinux.ui import keyboard_backends + + # Remove mock modules + sys.modules.pop("vocalinux.ui.keyboard_backends.evdev_backend", None) + sys.modules.pop("evdev", None) + + # Reload to restore original state + importlib.reload(keyboard_backends) + + def test_evdev_backend_no_devices(self): + """Test evdev backend when no keyboard devices are found.""" + # Set up mock to return no devices + self.mock_evdev_backend.find_keyboard_devices.return_value = [] + self.mock_evdev_backend.EvdevKeyboardBackend = MagicMock + + # Create backend instance + backend = MagicMock() + backend.is_available.return_value = False + + # Should not be available when no devices found + self.assertFalse(backend.is_available()) + + def test_evdev_backend_with_devices(self): + """Test evdev backend when devices are found.""" + # Mock finding devices + self.mock_evdev_backend.find_keyboard_devices.return_value = ["/dev/input/event0"] + + # Create a mock backend instance + backend = MagicMock() + backend.is_available.return_value = True + + # Should be available when devices are found + self.assertTrue(backend.is_available()) + + +class TestBackendFactory(unittest.TestCase): + """Test cases for the backend factory system.""" + + def test_detect_x11_session(self): + """Test detection of X11 session.""" + from vocalinux.ui.keyboard_backends import DesktopEnvironment + + with patch.dict("os.environ", {"XDG_SESSION_TYPE": "x11"}): + self.assertEqual(DesktopEnvironment.detect(), DesktopEnvironment.X11) + + def test_detect_wayland_session(self): + """Test detection of Wayland session.""" + from vocalinux.ui.keyboard_backends import DesktopEnvironment + + with patch.dict("os.environ", {"XDG_SESSION_TYPE": "wayland"}): + self.assertEqual(DesktopEnvironment.detect(), DesktopEnvironment.WAYLAND) + + def test_detect_session_fallback_to_wayland_display(self): + """Test session detection fallback to WAYLAND_DISPLAY.""" + from vocalinux.ui.keyboard_backends import DesktopEnvironment + + with patch.dict("os.environ", {"WAYLAND_DISPLAY": "wayland-0"}, clear=True): + self.assertEqual(DesktopEnvironment.detect(), DesktopEnvironment.WAYLAND) + + def test_detect_session_fallback_to_display(self): + """Test session detection fallback to DISPLAY.""" + from vocalinux.ui.keyboard_backends import DesktopEnvironment + + with patch.dict("os.environ", {"DISPLAY": ":0"}, clear=True): + self.assertEqual(DesktopEnvironment.detect(), DesktopEnvironment.X11) + + def test_preferred_backend_pynput(self): + """Test forcing pynput backend.""" + from vocalinux.ui.keyboard_backends import create_backend + + with patch("vocalinux.ui.keyboard_backends.PYNPUT_AVAILABLE", True), patch( + "vocalinux.ui.keyboard_backends.PynputKeyboardBackend" + ) as MockPynput: + mock_backend = MagicMock() + MockPynput.return_value = mock_backend + + result = create_backend(preferred_backend="pynput") + + self.assertIsNotNone(result) + MockPynput.assert_called_once() + + def test_preferred_backend_evdev(self): + """Test forcing evdev backend.""" + from vocalinux.ui.keyboard_backends import create_backend + + with patch("vocalinux.ui.keyboard_backends.EVDEV_AVAILABLE", True), patch( + "vocalinux.ui.keyboard_backends.EvdevKeyboardBackend" + ) as MockEvdev: + mock_backend = MagicMock() + MockEvdev.return_value = mock_backend + + result = create_backend(preferred_backend="evdev") + + self.assertIsNotNone(result) + MockEvdev.assert_called_once() + + +if __name__ == "__main__": + unittest.main() diff --git a/web/Dockerfile b/web/Dockerfile index 9148769..ab90ddd 100644 --- a/web/Dockerfile +++ b/web/Dockerfile @@ -36,4 +36,4 @@ COPY --from=deps /app/node_modules /deps/node_modules # Clean up the source directory and extract the tar RUN rm -rf /source/* && \ - mv /source.tar.gz /source/ \ No newline at end of file + mv /source.tar.gz /source/