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:
parent
21c2508842
commit
f403f3edfe
|
|
@ -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")
|
||||
# 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
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to create screenshot hotkeys: {e}")
|
||||
return None
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}")
|
||||
|
|
|
|||
Loading…
Reference in New Issue