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
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': '<f12>', # Full screen
'screenshot_region': '<shift>+<f12>', # Region selection
'screenshot_loot': '<ctrl>+<f12>', # Loot window area
'screenshot_hud': '<alt>+<f12>', # 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")
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
# Try to setup global hotkeys
self._setup_global_hotkeys()
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
except Exception as e:
logger.warning(f"Failed to parse hotkey '{combo}': {e}")
# Convert to keyboard library format
kb_combo = combo.lower().replace('ctrl', 'ctrl').replace('shift', 'shift').replace('alt', 'alt')
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()
# 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.error(f"Hotkey listener error: {e}")
logger.warning(f"Failed to register global hotkey '{combo}': {e}")
self._global_enabled = True
logger.info("Global hotkeys enabled (requires admin for full functionality)")
return True
except Exception as 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)
# Restart listener with new hotkey
was_listening = self.listening
self.stop_listening()
# Update Qt shortcut
if action in self._qt_shortcuts:
self._qt_shortcuts[action].setKey(QKeySequence(combination))
# 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
logger.info(f"Hotkey for '{action}' set to '{combination}'")
if was_listening:
return self.start_listening()
logger.info(f"Hotkey for '{action}' set to '{combination}'")
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,12 +351,23 @@ 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")
return None
manager = ScreenshotHotkeyManager(screenshot_manager)
if start_listening:
manager.start_listening()
try:
manager = ScreenshotHotkeyManager(parent_window, screenshot_manager)
if manager.is_available():
return manager
return None
except Exception as e:
logger.error(f"Failed to create screenshot hotkeys: {e}")
return None

View File

@ -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

View File

@ -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}")