feat: add Wayland support for keyboard shortcuts using evdev backend (#81)
* feat: add Wayland support for keyboard shortcuts using evdev backend This commit adds multi-backend support for keyboard shortcuts, enabling them to work on Wayland sessions where pynput does not work. **Changes:** - New `keyboard_backends/` package with modular backend system - `base.py`: Abstract base class for keyboard backends - `pynput_backend.py`: Existing pynput implementation (X11/XWayland) - `evdev_backend.py`: New evdev implementation (X11 & Wayland) - `__init__.py`: Backend factory with auto-detection - Updated `keyboard_shortcuts.py`: - Refactored to use backend system - Auto-detects session type (X11 vs Wayland) - Selects appropriate backend automatically - Provides helpful error messages when shortcuts unavailable - Added `evdev>=1.6.0` to dependencies (Linux only) - Comprehensive test updates: - 21 tests pass covering all backends - Tests for session detection - Tests for backend factory - Tests for permission hints **User Impact:** - Keyboard shortcuts now work on Wayland when user is in 'input' group - Clear error messages guide users to fix permission issues - Backward compatible with X11 (pynput backend) **Setup for Wayland:** ```bash sudo usermod -a -G input $USER # Log out and log back in ``` Related to #80 * style: fix flake8 issues in evdev_backend.py - Remove unused import 'categorize' - Remove unused variable 'dev' * style: fix remaining flake8 issues in tests - Remove unused 'time' import - Remove unused 'result' variable - Remove unused 'backend_cls' variable - Break long line to stay under 100 characters * style: apply black and isort formatting to pass CI * feat: auto-configure Wayland keyboard shortcuts during installation Automatically detect Wayland sessions during installation and add the user to the 'input' group required for evdev-based keyboard shortcuts. This eliminates the need for users to manually configure permissions after installation on Wayland systems. * fix: escape commit message in Detect Changes step to handle special characters The commit message was being directly inserted into the bash comparison, which broke when the message contained special characters like parentheses. Fix by assigning to a variable first. * chore: drop unrelated installer/web changes * fix: fall back to evdev backend * style: format keyboard shortcut imports
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
@@ -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"]
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
+258
-172
@@ -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()
|
||||
|
||||
+1
-1
@@ -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/
|
||||
mv /source.tar.gz /source/
|
||||
|
||||
Reference in New Issue
Block a user