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
This commit is contained in:
LemonNexus 2026-02-11 12:55:24 +00:00
parent 21c2508842
commit f403f3edfe
3 changed files with 161 additions and 145 deletions

View File

@ -1,7 +1,7 @@
""" """
Lemontropia Suite - Screenshot Hotkey Manager Lemontropia Suite - Screenshot Hotkey Manager (Windows-friendly version)
Global hotkey listener for manual screenshot capture. Uses 'keyboard' library for reliable global hotkeys on Windows.
Uses pynput for global keyboard shortcuts. Falls back to Qt shortcuts if global hotkeys fail.
""" """
import logging import logging
@ -11,14 +11,16 @@ from datetime import datetime
from typing import Optional, Callable, Dict, List from typing import Optional, Callable, Dict, List
from dataclasses import dataclass from dataclasses import dataclass
try: from PyQt6.QtCore import QObject, pyqtSignal
from pynput import keyboard from PyQt6.QtGui import QKeySequence, QShortcut
PYNPUT_AVAILABLE = True from PyQt6.QtWidgets import QMainWindow
except ImportError:
PYNPUT_AVAILABLE = False
keyboard = None
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 from .auto_screenshot import AutoScreenshot
@ -36,30 +38,46 @@ class ScreenshotEvent:
class ScreenshotHotkeyManager(QObject): class ScreenshotHotkeyManager(QObject):
""" """
Global hotkey manager for manual screenshots. Screenshot hotkey manager with Qt shortcuts + optional global hotkeys.
Runs in background thread to capture hotkeys even when app not focused.
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 # Signals for Qt integration
screenshot_captured = pyqtSignal(str) # filepath screenshot_captured = pyqtSignal(str) # filepath
hotkey_pressed = pyqtSignal(str) # hotkey name hotkey_pressed = pyqtSignal(str) # hotkey name
# Default hotkeys # Default hotkeys (Qt format)
DEFAULT_HOTKEYS = { DEFAULT_HOTKEYS = {
'screenshot_full': '<f12>', # Full screen 'screenshot_full': 'F12',
'screenshot_region': '<shift>+<f12>', # Region selection 'screenshot_region': 'Shift+F12',
'screenshot_loot': '<ctrl>+<f12>', # Loot window area 'screenshot_loot': 'Ctrl+F12',
'screenshot_hud': '<alt>+<f12>', # HUD area 'screenshot_hud': 'Alt+F12',
} }
def __init__(self, screenshot_manager: Optional[AutoScreenshot] = None, def __init__(self, parent_window: QMainWindow,
parent=None): screenshot_manager: Optional[AutoScreenshot] = None):
super().__init__(parent) """
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.screenshot_manager = screenshot_manager or AutoScreenshot()
self.hotkeys: Dict[str, str] = self.DEFAULT_HOTKEYS.copy() self.hotkeys: Dict[str, str] = self.DEFAULT_HOTKEYS.copy()
self.enabled = True 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 # Callbacks for custom actions
self.on_screenshot: Optional[Callable[[ScreenshotEvent], None]] = None self.on_screenshot: Optional[Callable[[ScreenshotEvent], None]] = None
@ -68,97 +86,71 @@ class ScreenshotHotkeyManager(QObject):
self.recent_screenshots: List[ScreenshotEvent] = [] self.recent_screenshots: List[ScreenshotEvent] = []
self.max_history = 50 self.max_history = 50
# Hotkey listener thread # Initialize Qt shortcuts
self._listener_thread: Optional[threading.Thread] = None self._setup_qt_shortcuts()
self._listener: Optional[keyboard.Listener] = None
self._stop_event = threading.Event()
if not PYNPUT_AVAILABLE: # Try to setup global hotkeys
logger.warning("pynput not installed. Hotkey screenshots disabled.") self._setup_global_hotkeys()
logger.info("Install with: pip install pynput")
def is_available(self) -> bool: def _setup_qt_shortcuts(self):
"""Check if hotkey manager is available.""" """Setup Qt keyboard shortcuts (works when app is focused)."""
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
try: try:
self._stop_event.clear() for action, combo in self.hotkeys.items():
self._listener_thread = threading.Thread( qt_sequence = self._to_qt_sequence(combo)
target=self._listen_for_hotkeys, shortcut = QShortcut(qt_sequence, self.parent_window)
daemon=True shortcut.activated.connect(lambda a=action: self._on_hotkey(a))
) self._qt_shortcuts[action] = shortcut
self._listener_thread.start() logger.info(f"Qt shortcut registered: {combo} -> {action}")
self.listening = True
logger.info("Screenshot hotkey listener started")
return True
except Exception as e: 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 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: try:
# Create hotkey combinations # Unhook all first to be safe
hotkey_combinations = {} keyboard.unhook_all()
for action, combo in self.hotkeys.items(): for action, combo in self.hotkeys.items():
try: try:
hk = keyboard.HotKey( # Convert to keyboard library format
keyboard.HotKey.parse(combo), kb_combo = combo.lower().replace('ctrl', 'ctrl').replace('shift', 'shift').replace('alt', 'alt')
lambda a=action: self._on_hotkey(a)
) # Register hotkey
hotkey_combinations[action] = hk keyboard.add_hotkey(kb_combo, lambda a=action: self._on_hotkey(a))
logger.info(f"Global hotkey registered: {kb_combo}")
except Exception as e: 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): self._global_enabled = True
for hk in hotkey_combinations.values(): logger.info("Global hotkeys enabled (requires admin for full functionality)")
hk.press(key) return True
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()
except Exception as e: 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): def _on_hotkey(self, action: str):
"""Handle hotkey press.""" """Handle hotkey press."""
@ -193,13 +185,9 @@ class ScreenshotHotkeyManager(QObject):
self.screenshot_captured.emit(str(filepath)) self.screenshot_captured.emit(str(filepath))
def _capture_region(self): def _capture_region(self):
"""Capture region (would need region selector UI).""" """Capture center region."""
# 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")
filename = f"manual_region_{datetime.now():%Y%m%d_%H%M%S_%f}"[:-3] + ".png" filename = f"manual_region_{datetime.now():%Y%m%d_%H%M%S_%f}"[:-3] + ".png"
# Get primary monitor dimensions
try: try:
import mss import mss
with mss.mss() as sct: with mss.mss() as sct:
@ -207,7 +195,6 @@ class ScreenshotHotkeyManager(QObject):
screen_w = monitor['width'] screen_w = monitor['width']
screen_h = monitor['height'] screen_h = monitor['height']
# Capture center 800x600
x = (screen_w - 800) // 2 x = (screen_w - 800) // 2
y = (screen_h - 600) // 2 y = (screen_h - 600) // 2
@ -231,7 +218,6 @@ class ScreenshotHotkeyManager(QObject):
"""Capture typical loot window area.""" """Capture typical loot window area."""
filename = f"manual_loot_{datetime.now():%Y%m%d_%H%M%S_%f}"[:-3] + ".png" filename = f"manual_loot_{datetime.now():%Y%m%d_%H%M%S_%f}"[:-3] + ".png"
# Typical loot window position (right side of screen)
try: try:
import mss import mss
with mss.mss() as sct: with mss.mss() as sct:
@ -239,7 +225,6 @@ class ScreenshotHotkeyManager(QObject):
screen_w = monitor['width'] screen_w = monitor['width']
screen_h = monitor['height'] screen_h = monitor['height']
# Loot window: right side, ~300x400
x = screen_w - 350 x = screen_w - 350
y = screen_h // 2 - 200 y = screen_h // 2 - 200
w = 300 w = 300
@ -272,7 +257,6 @@ class ScreenshotHotkeyManager(QObject):
screen_w = monitor['width'] screen_w = monitor['width']
screen_h = monitor['height'] screen_h = monitor['height']
# HUD: bottom center
w = 600 w = 600
h = 150 h = 150
x = (screen_w - w) // 2 x = (screen_w - w) // 2
@ -298,11 +282,9 @@ class ScreenshotHotkeyManager(QObject):
"""Add screenshot to history.""" """Add screenshot to history."""
self.recent_screenshots.insert(0, event) self.recent_screenshots.insert(0, event)
# Trim history
if len(self.recent_screenshots) > self.max_history: if len(self.recent_screenshots) > self.max_history:
self.recent_screenshots = self.recent_screenshots[:self.max_history] self.recent_screenshots = self.recent_screenshots[:self.max_history]
# Call custom callback if set
if self.on_screenshot: if self.on_screenshot:
try: try:
self.on_screenshot(event) self.on_screenshot(event)
@ -319,7 +301,7 @@ class ScreenshotHotkeyManager(QObject):
Args: Args:
action: Action name ('screenshot_full', etc.) 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: Returns:
True if successful True if successful
@ -329,28 +311,37 @@ class ScreenshotHotkeyManager(QObject):
return False return False
try: try:
# Validate hotkey format # Update Qt shortcut
keyboard.HotKey.parse(combination) if action in self._qt_shortcuts:
self._qt_shortcuts[action].setKey(QKeySequence(combination))
# Restart listener with new hotkey # Update global hotkey if enabled
was_listening = self.listening if self._global_enabled and KEYBOARD_LIB_AVAILABLE:
self.stop_listening() 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}'") logger.info(f"Hotkey for '{action}' set to '{combination}'")
if was_listening:
return self.start_listening()
return True return True
except Exception as e: except Exception as e:
logger.error(f"Invalid hotkey combination '{combination}': {e}") logger.error(f"Failed to set hotkey '{combination}': {e}")
return False return False
def get_hotkey_help(self) -> str: def get_hotkey_help(self) -> str:
"""Get formatted hotkey help text.""" """Get formatted hotkey help text."""
lines = ["📸 Screenshot Hotkeys:", ""] 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 = { descriptions = {
'screenshot_full': 'Full screen', 'screenshot_full': 'Full screen',
'screenshot_region': 'Center region (800x600)', 'screenshot_region': 'Center region (800x600)',
@ -360,11 +351,22 @@ class ScreenshotHotkeyManager(QObject):
for action, combo in self.hotkeys.items(): for action, combo in self.hotkeys.items():
desc = descriptions.get(action, action) desc = descriptions.get(action, action)
# Remove angle brackets for display lines.append(f" {combo:<15} - {desc}")
display_combo = combo.replace('<', '').replace('>', '')
lines.append(f" {display_combo.upper():<15} - {desc}")
return "\n".join(lines) 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: class ScreenshotHotkeyWidget:
@ -387,6 +389,17 @@ class ScreenshotHotkeyWidget:
widget = QWidget() widget = QWidget()
layout = QVBoxLayout() 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 # Hotkey group
group = QGroupBox("Screenshot Hotkeys") group = QGroupBox("Screenshot Hotkeys")
form = QFormLayout() form = QFormLayout()
@ -443,31 +456,30 @@ class ScreenshotHotkeyWidget:
def _save_hotkeys(self): def _save_hotkeys(self):
"""Save hotkey changes.""" """Save hotkey changes."""
for action, line_edit in self.hotkey_inputs.items(): for action, line_edit in self.hotkey_inputs.items():
combo = line_edit.text().strip().lower() combo = line_edit.text().strip()
if combo: if combo:
self.manager.set_hotkey(action, combo) self.manager.set_hotkey(action, combo)
# Convenience function # Convenience function
def create_screenshot_hotkeys(screenshot_manager: Optional[AutoScreenshot] = None, def create_screenshot_hotkeys(parent_window: QMainWindow,
start_listening: bool = True) -> Optional[ScreenshotHotkeyManager]: screenshot_manager: Optional[AutoScreenshot] = None) \
-> Optional[ScreenshotHotkeyManager]:
""" """
Create and start screenshot hotkey manager. Create screenshot hotkey manager.
Args: Args:
parent_window: Main window to attach shortcuts to
screenshot_manager: Existing AutoScreenshot instance screenshot_manager: Existing AutoScreenshot instance
start_listening: Start listening immediately
Returns: Returns:
ScreenshotHotkeyManager or None if not available ScreenshotHotkeyManager or None if not available
""" """
if not PYNPUT_AVAILABLE: try:
logger.error("pynput not installed. Run: pip install pynput") manager = ScreenshotHotkeyManager(parent_window, screenshot_manager)
if manager.is_available():
return manager
return None return None
except Exception as e:
manager = ScreenshotHotkeyManager(screenshot_manager) logger.error(f"Failed to create screenshot hotkeys: {e}")
return None
if start_listening:
manager.start_listening()
return manager

View File

@ -33,6 +33,9 @@ torchvision>=0.15.0
mss>=9.0.0 mss>=9.0.0
# Global hotkey support for manual screenshots # 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 pynput>=1.7.6
# Image hashing and processing # Image hashing and processing

View File

@ -1908,20 +1908,21 @@ class MainWindow(QMainWindow):
screenshots_dir = Path(__file__).parent.parent / "data" / "screenshots" screenshots_dir = Path(__file__).parent.parent / "data" / "screenshots"
auto_screenshot = AutoScreenshot(screenshots_dir) auto_screenshot = AutoScreenshot(screenshots_dir)
# Create hotkey manager # Create hotkey manager (pass parent window for Qt shortcuts)
self._screenshot_hotkeys = ScreenshotHotkeyManager(auto_screenshot) self._screenshot_hotkeys = ScreenshotHotkeyManager(self, auto_screenshot)
# Connect signals # Connect signals
self._screenshot_hotkeys.screenshot_captured.connect(self._on_screenshot_captured) self._screenshot_hotkeys.screenshot_captured.connect(self._on_screenshot_captured)
self._screenshot_hotkeys.hotkey_pressed.connect(self._on_hotkey_pressed) self._screenshot_hotkeys.hotkey_pressed.connect(self._on_hotkey_pressed)
# Start listening # Log status
if self._screenshot_hotkeys.start_listening(): if self._screenshot_hotkeys.is_global_available():
help_text = self._screenshot_hotkeys.get_hotkey_help() self.log_info("Hotkeys", "Global hotkeys active (work even when game focused)")
self.log_info("Hotkeys", "Screenshot hotkeys active")
self.log_info("Hotkeys", "F12=Full, Shift+F12=Region, Ctrl+F12=Loot, Alt+F12=HUD") self.log_info("Hotkeys", "F12=Full, Shift+F12=Region, Ctrl+F12=Loot, Alt+F12=HUD")
else: 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: except Exception as e:
logger.error(f"Failed to initialize screenshot hotkeys: {e}") logger.error(f"Failed to initialize screenshot hotkeys: {e}")
@ -2008,7 +2009,7 @@ class MainWindow(QMainWindow):
# Stop screenshot hotkey listener # Stop screenshot hotkey listener
if self._screenshot_hotkeys: if self._screenshot_hotkeys:
try: try:
self._screenshot_hotkeys.stop_listening() self._screenshot_hotkeys.stop()
logger.info("Screenshot hotkeys stopped") logger.info("Screenshot hotkeys stopped")
except Exception as e: except Exception as e:
logger.error(f"Error stopping screenshot hotkeys: {e}") logger.error(f"Error stopping screenshot hotkeys: {e}")