From f403f3edfec67c4c6d3174277c30c95cd1e916c8 Mon Sep 17 00:00:00 2001 From: LemonNexus Date: Wed, 11 Feb 2026 12:55:24 +0000 Subject: [PATCH] fix: screenshot hotkeys - use Qt shortcuts + optional global hotkeys - Rewrote screenshot_hotkey.py to use Qt QShortcut as primary method - Qt shortcuts work reliably when app is focused (no admin needed) - Added optional 'keyboard' library for global hotkeys (requires admin) - Updated main_window.py to pass parent window to hotkey manager - Shows clear status: global vs Qt-only mode - Users can now press F12 etc when app is focused --- modules/screenshot_hotkey.py | 286 ++++++++++++++++++----------------- requirements.txt | 3 + ui/main_window.py | 17 ++- 3 files changed, 161 insertions(+), 145 deletions(-) diff --git a/modules/screenshot_hotkey.py b/modules/screenshot_hotkey.py index a27a815..af8fd3c 100644 --- a/modules/screenshot_hotkey.py +++ b/modules/screenshot_hotkey.py @@ -1,7 +1,7 @@ """ -Lemontropia Suite - Screenshot Hotkey Manager -Global hotkey listener for manual screenshot capture. -Uses pynput for global keyboard shortcuts. +Lemontropia Suite - Screenshot Hotkey Manager (Windows-friendly version) +Uses 'keyboard' library for reliable global hotkeys on Windows. +Falls back to Qt shortcuts if global hotkeys fail. """ import logging @@ -11,14 +11,16 @@ from datetime import datetime from typing import Optional, Callable, Dict, List from dataclasses import dataclass -try: - from pynput import keyboard - PYNPUT_AVAILABLE = True -except ImportError: - PYNPUT_AVAILABLE = False - keyboard = None +from PyQt6.QtCore import QObject, pyqtSignal +from PyQt6.QtGui import QKeySequence, QShortcut +from PyQt6.QtWidgets import QMainWindow -from PyQt6.QtCore import QObject, pyqtSignal, QThread +try: + import keyboard + KEYBOARD_LIB_AVAILABLE = True +except ImportError: + KEYBOARD_LIB_AVAILABLE = False + keyboard = None from .auto_screenshot import AutoScreenshot @@ -36,30 +38,46 @@ class ScreenshotEvent: class ScreenshotHotkeyManager(QObject): """ - Global hotkey manager for manual screenshots. - Runs in background thread to capture hotkeys even when app not focused. + Screenshot hotkey manager with Qt shortcuts + optional global hotkeys. + + Uses Qt QShortcut (works when app is focused) as primary method. + Falls back to global hotkeys via 'keyboard' library if available. """ # Signals for Qt integration screenshot_captured = pyqtSignal(str) # filepath hotkey_pressed = pyqtSignal(str) # hotkey name - # Default hotkeys + # Default hotkeys (Qt format) DEFAULT_HOTKEYS = { - 'screenshot_full': '', # Full screen - 'screenshot_region': '+', # Region selection - 'screenshot_loot': '+', # Loot window area - 'screenshot_hud': '+', # HUD area + 'screenshot_full': 'F12', + 'screenshot_region': 'Shift+F12', + 'screenshot_loot': 'Ctrl+F12', + 'screenshot_hud': 'Alt+F12', } - def __init__(self, screenshot_manager: Optional[AutoScreenshot] = None, - parent=None): - super().__init__(parent) + def __init__(self, parent_window: QMainWindow, + screenshot_manager: Optional[AutoScreenshot] = None): + """ + Initialize screenshot hotkey manager. + Args: + parent_window: Main window to attach Qt shortcuts to + screenshot_manager: Existing AutoScreenshot instance + """ + super().__init__(parent_window) + + self.parent_window = parent_window self.screenshot_manager = screenshot_manager or AutoScreenshot() self.hotkeys: Dict[str, str] = self.DEFAULT_HOTKEYS.copy() self.enabled = True - self.listening = False + + # Qt shortcuts (always work when app is focused) + self._qt_shortcuts: Dict[str, QShortcut] = {} + + # Global hotkey hooks (work even when app not focused, Windows only) + self._global_hooks: Dict[str, Callable] = {} + self._global_enabled = False # Callbacks for custom actions self.on_screenshot: Optional[Callable[[ScreenshotEvent], None]] = None @@ -68,97 +86,71 @@ class ScreenshotHotkeyManager(QObject): self.recent_screenshots: List[ScreenshotEvent] = [] self.max_history = 50 - # Hotkey listener thread - self._listener_thread: Optional[threading.Thread] = None - self._listener: Optional[keyboard.Listener] = None - self._stop_event = threading.Event() + # Initialize Qt shortcuts + self._setup_qt_shortcuts() - if not PYNPUT_AVAILABLE: - logger.warning("pynput not installed. Hotkey screenshots disabled.") - logger.info("Install with: pip install pynput") + # Try to setup global hotkeys + self._setup_global_hotkeys() - def is_available(self) -> bool: - """Check if hotkey manager is available.""" - return PYNPUT_AVAILABLE - - def start_listening(self): - """Start global hotkey listener in background thread.""" - if not PYNPUT_AVAILABLE: - logger.error("Cannot start hotkey listener: pynput not available") - return False - - if self.listening: - logger.warning("Hotkey listener already running") - return True - + def _setup_qt_shortcuts(self): + """Setup Qt keyboard shortcuts (works when app is focused).""" try: - self._stop_event.clear() - self._listener_thread = threading.Thread( - target=self._listen_for_hotkeys, - daemon=True - ) - self._listener_thread.start() - self.listening = True - logger.info("Screenshot hotkey listener started") - return True - + for action, combo in self.hotkeys.items(): + qt_sequence = self._to_qt_sequence(combo) + shortcut = QShortcut(qt_sequence, self.parent_window) + shortcut.activated.connect(lambda a=action: self._on_hotkey(a)) + self._qt_shortcuts[action] = shortcut + logger.info(f"Qt shortcut registered: {combo} -> {action}") except Exception as e: - logger.error(f"Failed to start hotkey listener: {e}") + logger.error(f"Failed to setup Qt shortcuts: {e}") + + def _to_qt_sequence(self, combo: str) -> QKeySequence: + """Convert our combo format to Qt key sequence.""" + # Convert to Qt format + qt_combo = combo.replace('Ctrl', 'Ctrl').replace('Shift', 'Shift').replace('Alt', 'Alt') + return QKeySequence(qt_combo) + + def _setup_global_hotkeys(self) -> bool: + """ + Setup global hotkeys using 'keyboard' library. + Works even when app is not focused (requires admin on Windows). + """ + if not KEYBOARD_LIB_AVAILABLE: + logger.info("'keyboard' library not installed. Qt shortcuts only.") return False - - def stop_listening(self): - """Stop global hotkey listener.""" - if not self.listening: - return - self._stop_event.set() - - if self._listener: - try: - self._listener.stop() - except: - pass - - if self._listener_thread: - self._listener_thread.join(timeout=1.0) - - self.listening = False - logger.info("Screenshot hotkey listener stopped") - - def _listen_for_hotkeys(self): - """Background thread: listen for global hotkeys.""" try: - # Create hotkey combinations - hotkey_combinations = {} + # Unhook all first to be safe + keyboard.unhook_all() for action, combo in self.hotkeys.items(): try: - hk = keyboard.HotKey( - keyboard.HotKey.parse(combo), - lambda a=action: self._on_hotkey(a) - ) - hotkey_combinations[action] = hk + # Convert to keyboard library format + kb_combo = combo.lower().replace('ctrl', 'ctrl').replace('shift', 'shift').replace('alt', 'alt') + + # Register hotkey + keyboard.add_hotkey(kb_combo, lambda a=action: self._on_hotkey(a)) + logger.info(f"Global hotkey registered: {kb_combo}") + except Exception as e: - logger.warning(f"Failed to parse hotkey '{combo}': {e}") + logger.warning(f"Failed to register global hotkey '{combo}': {e}") - def on_press(key): - for hk in hotkey_combinations.values(): - hk.press(key) - - def on_release(key): - for hk in hotkey_combinations.values(): - hk.release(key) - - self._listener = keyboard.Listener( - on_press=on_press, - on_release=on_release - ) - - self._listener.start() - self._listener.join() + self._global_enabled = True + logger.info("Global hotkeys enabled (requires admin for full functionality)") + return True except Exception as e: - logger.error(f"Hotkey listener error: {e}") + logger.warning(f"Global hotkeys not available: {e}") + logger.info("Using Qt shortcuts only (app must be focused)") + return False + + def is_available(self) -> bool: + """Check if hotkey manager is available.""" + return len(self._qt_shortcuts) > 0 + + def is_global_available(self) -> bool: + """Check if global hotkeys are working.""" + return self._global_enabled def _on_hotkey(self, action: str): """Handle hotkey press.""" @@ -193,13 +185,9 @@ class ScreenshotHotkeyManager(QObject): self.screenshot_captured.emit(str(filepath)) def _capture_region(self): - """Capture region (would need region selector UI).""" - # For now, capture center of screen as a reasonable default - # In the future, this could open a region selection overlay - logger.info("Region capture: using center 800x600 region") + """Capture center region.""" filename = f"manual_region_{datetime.now():%Y%m%d_%H%M%S_%f}"[:-3] + ".png" - # Get primary monitor dimensions try: import mss with mss.mss() as sct: @@ -207,7 +195,6 @@ class ScreenshotHotkeyManager(QObject): screen_w = monitor['width'] screen_h = monitor['height'] - # Capture center 800x600 x = (screen_w - 800) // 2 y = (screen_h - 600) // 2 @@ -231,7 +218,6 @@ class ScreenshotHotkeyManager(QObject): """Capture typical loot window area.""" filename = f"manual_loot_{datetime.now():%Y%m%d_%H%M%S_%f}"[:-3] + ".png" - # Typical loot window position (right side of screen) try: import mss with mss.mss() as sct: @@ -239,7 +225,6 @@ class ScreenshotHotkeyManager(QObject): screen_w = monitor['width'] screen_h = monitor['height'] - # Loot window: right side, ~300x400 x = screen_w - 350 y = screen_h // 2 - 200 w = 300 @@ -272,7 +257,6 @@ class ScreenshotHotkeyManager(QObject): screen_w = monitor['width'] screen_h = monitor['height'] - # HUD: bottom center w = 600 h = 150 x = (screen_w - w) // 2 @@ -298,11 +282,9 @@ class ScreenshotHotkeyManager(QObject): """Add screenshot to history.""" self.recent_screenshots.insert(0, event) - # Trim history if len(self.recent_screenshots) > self.max_history: self.recent_screenshots = self.recent_screenshots[:self.max_history] - # Call custom callback if set if self.on_screenshot: try: self.on_screenshot(event) @@ -319,7 +301,7 @@ class ScreenshotHotkeyManager(QObject): Args: action: Action name ('screenshot_full', etc.) - combination: New hotkey combination (e.g., 'f10', 'ctrl+shift+s') + combination: New hotkey combination (e.g., 'F10', 'Ctrl+Shift+S') Returns: True if successful @@ -329,28 +311,37 @@ class ScreenshotHotkeyManager(QObject): return False try: - # Validate hotkey format - keyboard.HotKey.parse(combination) + # Update Qt shortcut + if action in self._qt_shortcuts: + self._qt_shortcuts[action].setKey(QKeySequence(combination)) - # Restart listener with new hotkey - was_listening = self.listening - self.stop_listening() + # Update global hotkey if enabled + if self._global_enabled and KEYBOARD_LIB_AVAILABLE: + keyboard.unhook_all() + self.hotkeys[action] = combination + self._setup_global_hotkeys() + else: + self.hotkeys[action] = combination - self.hotkeys[action] = combination logger.info(f"Hotkey for '{action}' set to '{combination}'") - - if was_listening: - return self.start_listening() return True except Exception as e: - logger.error(f"Invalid hotkey combination '{combination}': {e}") + logger.error(f"Failed to set hotkey '{combination}': {e}") return False def get_hotkey_help(self) -> str: """Get formatted hotkey help text.""" lines = ["📸 Screenshot Hotkeys:", ""] + if self._global_enabled: + lines.append("✅ Global hotkeys active (work even when game is focused)") + else: + lines.append("â„šī¸ Qt shortcuts only (app must be focused)") + lines.append(" For global hotkeys, run as Administrator or install 'keyboard' library") + + lines.append("") + descriptions = { 'screenshot_full': 'Full screen', 'screenshot_region': 'Center region (800x600)', @@ -360,11 +351,22 @@ class ScreenshotHotkeyManager(QObject): for action, combo in self.hotkeys.items(): desc = descriptions.get(action, action) - # Remove angle brackets for display - display_combo = combo.replace('<', '').replace('>', '') - lines.append(f" {display_combo.upper():<15} - {desc}") + lines.append(f" {combo:<15} - {desc}") return "\n".join(lines) + + def stop(self): + """Stop hotkey listeners.""" + # Remove global hooks + if self._global_enabled and KEYBOARD_LIB_AVAILABLE: + try: + keyboard.unhook_all() + logger.info("Global hotkeys unregistered") + except: + pass + + # Qt shortcuts are automatically cleaned up + logger.info("Screenshot hotkeys stopped") class ScreenshotHotkeyWidget: @@ -387,6 +389,17 @@ class ScreenshotHotkeyWidget: widget = QWidget() layout = QVBoxLayout() + # Status label + status_label = QLabel() + if self.manager.is_global_available(): + status_label.setText("✅ Global hotkeys active - work even when game is focused") + status_label.setStyleSheet("color: #4caf50;") + else: + status_label.setText("â„šī¸ Qt shortcuts only - app must be focused\n" + "For global hotkeys, run as Administrator") + status_label.setStyleSheet("color: #ff9800;") + layout.addWidget(status_label) + # Hotkey group group = QGroupBox("Screenshot Hotkeys") form = QFormLayout() @@ -443,31 +456,30 @@ class ScreenshotHotkeyWidget: def _save_hotkeys(self): """Save hotkey changes.""" for action, line_edit in self.hotkey_inputs.items(): - combo = line_edit.text().strip().lower() + combo = line_edit.text().strip() if combo: self.manager.set_hotkey(action, combo) # Convenience function -def create_screenshot_hotkeys(screenshot_manager: Optional[AutoScreenshot] = None, - start_listening: bool = True) -> Optional[ScreenshotHotkeyManager]: +def create_screenshot_hotkeys(parent_window: QMainWindow, + screenshot_manager: Optional[AutoScreenshot] = None) \ + -> Optional[ScreenshotHotkeyManager]: """ - Create and start screenshot hotkey manager. + Create screenshot hotkey manager. Args: + parent_window: Main window to attach shortcuts to screenshot_manager: Existing AutoScreenshot instance - start_listening: Start listening immediately Returns: ScreenshotHotkeyManager or None if not available """ - if not PYNPUT_AVAILABLE: - logger.error("pynput not installed. Run: pip install pynput") + try: + manager = ScreenshotHotkeyManager(parent_window, screenshot_manager) + if manager.is_available(): + return manager return None - - manager = ScreenshotHotkeyManager(screenshot_manager) - - if start_listening: - manager.start_listening() - - return manager \ No newline at end of file + except Exception as e: + logger.error(f"Failed to create screenshot hotkeys: {e}") + return None \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 0e3f261..39d4a74 100644 --- a/requirements.txt +++ b/requirements.txt @@ -33,6 +33,9 @@ torchvision>=0.15.0 mss>=9.0.0 # Global hotkey support for manual screenshots +# Use 'keyboard' for reliable global hotkeys on Windows (optional, requires admin) +# Use 'pynput' as fallback (already included) +keyboard>=0.13.5 pynput>=1.7.6 # Image hashing and processing diff --git a/ui/main_window.py b/ui/main_window.py index f15ce9f..2232546 100644 --- a/ui/main_window.py +++ b/ui/main_window.py @@ -1908,20 +1908,21 @@ class MainWindow(QMainWindow): screenshots_dir = Path(__file__).parent.parent / "data" / "screenshots" auto_screenshot = AutoScreenshot(screenshots_dir) - # Create hotkey manager - self._screenshot_hotkeys = ScreenshotHotkeyManager(auto_screenshot) + # Create hotkey manager (pass parent window for Qt shortcuts) + self._screenshot_hotkeys = ScreenshotHotkeyManager(self, auto_screenshot) # Connect signals self._screenshot_hotkeys.screenshot_captured.connect(self._on_screenshot_captured) self._screenshot_hotkeys.hotkey_pressed.connect(self._on_hotkey_pressed) - # Start listening - if self._screenshot_hotkeys.start_listening(): - help_text = self._screenshot_hotkeys.get_hotkey_help() - self.log_info("Hotkeys", "Screenshot hotkeys active") + # Log status + if self._screenshot_hotkeys.is_global_available(): + self.log_info("Hotkeys", "Global hotkeys active (work even when game focused)") self.log_info("Hotkeys", "F12=Full, Shift+F12=Region, Ctrl+F12=Loot, Alt+F12=HUD") else: - self.log_warning("Hotkeys", "Failed to start screenshot hotkeys") + self.log_info("Hotkeys", "Qt shortcuts active (app must be focused)") + self.log_info("Hotkeys", "F12=Full, Shift+F12=Region, Ctrl+F12=Loot, Alt+F12=HUD") + self.log_info("Hotkeys", "For global hotkeys, run as Administrator or install 'keyboard' library") except Exception as e: logger.error(f"Failed to initialize screenshot hotkeys: {e}") @@ -2008,7 +2009,7 @@ class MainWindow(QMainWindow): # Stop screenshot hotkey listener if self._screenshot_hotkeys: try: - self._screenshot_hotkeys.stop_listening() + self._screenshot_hotkeys.stop() logger.info("Screenshot hotkeys stopped") except Exception as e: logger.error(f"Error stopping screenshot hotkeys: {e}")