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:
Jatin K Malik
2026-02-04 20:14:58 -08:00
committed by GitHub
parent c9810dc7f5
commit 22ab96cea7
8 changed files with 1047 additions and 289 deletions
+1
View File
@@ -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"]
+119 -116
View File
@@ -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
View File
@@ -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
View File
@@ -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/