diff --git a/modules/screenshot_hotkey.py b/modules/screenshot_hotkey.py new file mode 100644 index 0000000..ffbc3aa --- /dev/null +++ b/modules/screenshot_hotkey.py @@ -0,0 +1,471 @@ +""" +Lemontropia Suite - Screenshot Hotkey Manager +Global hotkey listener for manual screenshot capture. +Uses pynput for global keyboard shortcuts. +""" + +import logging +import threading +from pathlib import Path +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, QThread + +from .auto_screenshot import AutoScreenshot + +logger = logging.getLogger(__name__) + + +@dataclass +class ScreenshotEvent: + """Screenshot capture event data.""" + filepath: Path + timestamp: datetime + triggered_by: str # 'hotkey' or 'auto' + description: str = "" + + +class ScreenshotHotkeyManager(QObject): + """ + Global hotkey manager for manual screenshots. + Runs in background thread to capture hotkeys even when app not focused. + """ + + # Signals for Qt integration + screenshot_captured = pyqtSignal(str) # filepath + hotkey_pressed = pyqtSignal(str) # hotkey name + + # Default hotkeys + 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 + } + + def __init__(self, screenshot_manager: Optional[AutoScreenshot] = None, + parent=None): + super().__init__(parent) + + self.screenshot_manager = screenshot_manager or AutoScreenshot() + self.hotkeys: Dict[str, str] = self.DEFAULT_HOTKEYS.copy() + self.enabled = True + self.listening = False + + # Callbacks for custom actions + self.on_screenshot: Optional[Callable[[ScreenshotEvent], None]] = None + + # Screenshot history + 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() + + 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: + 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 + + except Exception as e: + logger.error(f"Failed to start hotkey listener: {e}") + 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 = {} + + 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}") + + 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() + + except Exception as e: + logger.error(f"Hotkey listener error: {e}") + + def _on_hotkey(self, action: str): + """Handle hotkey press.""" + if not self.enabled: + return + + logger.info(f"Hotkey triggered: {action}") + self.hotkey_pressed.emit(action) + + if action == 'screenshot_full': + self._capture_full_screen() + elif action == 'screenshot_region': + self._capture_region() + elif action == 'screenshot_loot': + self._capture_loot_window() + elif action == 'screenshot_hud': + self._capture_hud() + + def _capture_full_screen(self): + """Capture full screen.""" + filename = f"manual_full_{datetime.now():%Y%m%d_%H%M%S_%f}"[:-3] + ".png" + filepath = self.screenshot_manager.capture_full_screen(filename) + + if filepath: + event = ScreenshotEvent( + filepath=filepath, + timestamp=datetime.now(), + triggered_by='hotkey', + description='Full screen capture (F12)' + ) + self._add_to_history(event) + 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") + 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: + monitor = sct.monitors[1] + screen_w = monitor['width'] + screen_h = monitor['height'] + + # Capture center 800x600 + x = (screen_w - 800) // 2 + y = (screen_h - 600) // 2 + + filepath = self.screenshot_manager.capture_region( + x, y, 800, 600, filename + ) + + if filepath: + event = ScreenshotEvent( + filepath=filepath, + timestamp=datetime.now(), + triggered_by='hotkey', + description='Region capture (Shift+F12)' + ) + self._add_to_history(event) + self.screenshot_captured.emit(str(filepath)) + except Exception as e: + logger.error(f"Region capture failed: {e}") + + def _capture_loot_window(self): + """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: + monitor = sct.monitors[1] + screen_w = monitor['width'] + screen_h = monitor['height'] + + # Loot window: right side, ~300x400 + x = screen_w - 350 + y = screen_h // 2 - 200 + w = 300 + h = 400 + + filepath = self.screenshot_manager.capture_region( + max(0, x), max(0, y), w, h, filename + ) + + if filepath: + event = ScreenshotEvent( + filepath=filepath, + timestamp=datetime.now(), + triggered_by='hotkey', + description='Loot window capture (Ctrl+F12)' + ) + self._add_to_history(event) + self.screenshot_captured.emit(str(filepath)) + except Exception as e: + logger.error(f"Loot capture failed: {e}") + + def _capture_hud(self): + """Capture HUD area.""" + filename = f"manual_hud_{datetime.now():%Y%m%d_%H%M%S_%f}"[:-3] + ".png" + + try: + import mss + with mss.mss() as sct: + monitor = sct.monitors[1] + screen_w = monitor['width'] + screen_h = monitor['height'] + + # HUD: bottom center + w = 600 + h = 150 + x = (screen_w - w) // 2 + y = screen_h - h - 50 + + filepath = self.screenshot_manager.capture_region( + max(0, x), max(0, y), w, h, filename + ) + + if filepath: + event = ScreenshotEvent( + filepath=filepath, + timestamp=datetime.now(), + triggered_by='hotkey', + description='HUD capture (Alt+F12)' + ) + self._add_to_history(event) + self.screenshot_captured.emit(str(filepath)) + except Exception as e: + logger.error(f"HUD capture failed: {e}") + + def _add_to_history(self, event: ScreenshotEvent): + """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) + except Exception as e: + logger.error(f"Screenshot callback error: {e}") + + def get_recent_screenshots(self, n: int = 10) -> List[ScreenshotEvent]: + """Get recent screenshots.""" + return self.recent_screenshots[:n] + + def set_hotkey(self, action: str, combination: str) -> bool: + """ + Change hotkey for an action. + + Args: + action: Action name ('screenshot_full', etc.) + combination: New hotkey combination (e.g., 'f10', 'ctrl+shift+s') + + Returns: + True if successful + """ + if action not in self.hotkeys: + logger.error(f"Unknown action: {action}") + return False + + try: + # Validate hotkey format + keyboard.HotKey.parse(combination) + + # Restart listener with new hotkey + was_listening = self.listening + self.stop_listening() + + 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}") + return False + + def get_hotkey_help(self) -> str: + """Get formatted hotkey help text.""" + lines = ["📸 Screenshot Hotkeys:", ""] + + descriptions = { + 'screenshot_full': 'Full screen', + 'screenshot_region': 'Center region (800x600)', + 'screenshot_loot': 'Loot window area', + 'screenshot_hud': 'HUD area', + } + + for action, combo in self.hotkeys.items(): + desc = descriptions.get(action, action) + lines.append(f" {combo.upper():<15} - {desc}") + + return "\n".join(lines) + + +class ScreenshotHotkeyWidget: + """ + PyQt widget for configuring screenshot hotkeys. + Can be embedded in settings dialog. + """ + + def __init__(self, hotkey_manager: ScreenshotHotkeyManager): + self.manager = hotkey_manager + + def create_settings_widget(self): + """Create settings widget for hotkey configuration.""" + from PyQt6.QtWidgets import ( + QWidget, QVBoxLayout, QHBoxLayout, QLabel, + QLineEdit, QPushButton, QGroupBox, QFormLayout, + QMessageBox + ) + + widget = QWidget() + layout = QVBoxLayout() + + # Hotkey group + group = QGroupBox("Screenshot Hotkeys") + form = QFormLayout() + + self.hotkey_inputs = {} + + descriptions = { + 'screenshot_full': 'Full Screen:', + 'screenshot_region': 'Region (Center):', + 'screenshot_loot': 'Loot Window:', + 'screenshot_hud': 'HUD Area:', + } + + for action, combo in self.manager.hotkeys.items(): + row = QHBoxLayout() + + line_edit = QLineEdit(combo) + line_edit.setMaximumWidth(150) + self.hotkey_inputs[action] = line_edit + + row.addWidget(line_edit) + + test_btn = QPushButton("Test") + test_btn.setMaximumWidth(60) + test_btn.clicked.connect(lambda checked, a=action: self._test_screenshot(a)) + row.addWidget(test_btn) + + row.addStretch() + + form.addRow(descriptions.get(action, action), row) + + group.setLayout(form) + layout.addWidget(group) + + # Save button + save_btn = QPushButton("Save Hotkeys") + save_btn.clicked.connect(self._save_hotkeys) + layout.addWidget(save_btn) + + # Help text + help_label = QLabel(self.manager.get_hotkey_help()) + help_label.setWordWrap(True) + layout.addWidget(help_label) + + layout.addStretch() + widget.setLayout(layout) + + return widget + + def _test_screenshot(self, action: str): + """Test screenshot action.""" + self.manager._on_hotkey(action) + + def _save_hotkeys(self): + """Save hotkey changes.""" + for action, line_edit in self.hotkey_inputs.items(): + combo = line_edit.text().strip().lower() + 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]: + """ + Create and start screenshot hotkey manager. + + Args: + 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() + + return manager \ No newline at end of file diff --git a/ui/main_window.py b/ui/main_window.py index c54ee76..724f84d 100644 --- a/ui/main_window.py +++ b/ui/main_window.py @@ -120,6 +120,18 @@ from ui.hud_overlay_clean import HUDOverlay from ui.session_history import SessionHistoryDialog from ui.gallery_dialog import GalleryDialog, ScreenshotCapture +# ============================================================================ +# Screenshot Hotkey Integration +# ============================================================================ + +try: + from modules.screenshot_hotkey import ScreenshotHotkeyManager, ScreenshotHotkeyWidget + SCREENSHOT_HOTKEYS_AVAILABLE = True +except ImportError: + SCREENSHOT_HOTKEYS_AVAILABLE = False + ScreenshotHotkeyManager = None + ScreenshotHotkeyWidget = None + # ============================================================================ # Core Integration @@ -398,6 +410,10 @@ class MainWindow(QMainWindow): # Screenshot capture self._screenshot_capture = ScreenshotCapture(self.db) + + # Screenshot hotkey manager (global hotkeys for manual capture) + self._screenshot_hotkeys = None + self._init_screenshot_hotkeys() # Setup UI self.setup_ui() @@ -812,6 +828,14 @@ class MainWindow(QMainWindow): vision_test_action.triggered.connect(self.on_vision_test) vision_menu.addAction(vision_test_action) + tools_menu.addSeparator() + + # Screenshot hotkey settings + screenshot_hotkeys_action = QAction("📸 Screenshot &Hotkeys", self) + screenshot_hotkeys_action.setShortcut("Ctrl+Shift+S") + screenshot_hotkeys_action.triggered.connect(self._show_screenshot_hotkey_settings) + tools_menu.addAction(screenshot_hotkeys_action) + # View menu view_menu = menubar.addMenu("&View") @@ -1867,6 +1891,83 @@ class MainWindow(QMainWindow): # Settings Management # ======================================================================== + def _init_screenshot_hotkeys(self): + """Initialize global screenshot hotkey manager.""" + if not SCREENSHOT_HOTKEYS_AVAILABLE: + logger.warning("Screenshot hotkeys not available (pynput not installed)") + self.log_info("Hotkeys", "Screenshot hotkeys disabled - install pynput") + return + + try: + from modules.auto_screenshot import AutoScreenshot + + # Create screenshot manager + auto_screenshot = AutoScreenshot(self._screenshot_capture.screenshot_dir) + + # Create hotkey manager + self._screenshot_hotkeys = ScreenshotHotkeyManager(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") + 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") + + except Exception as e: + logger.error(f"Failed to initialize screenshot hotkeys: {e}") + self._screenshot_hotkeys = None + + def _on_screenshot_captured(self, filepath: str): + """Handle screenshot captured signal.""" + self.log_info("Screenshot", f"Saved: {filepath}") + + # Show notification + self.statusBar().showMessage(f"📸 Screenshot saved: {filepath}", 3000) + + # Refresh gallery if open + # (Gallery dialog would need to be updated to refresh) + + def _on_hotkey_pressed(self, action: str): + """Handle hotkey pressed signal.""" + logger.debug(f"Screenshot hotkey pressed: {action}") + + def _show_screenshot_hotkey_settings(self): + """Show screenshot hotkey settings dialog.""" + if not SCREENSHOT_HOTKEYS_AVAILABLE or not self._screenshot_hotkeys: + QMessageBox.information( + self, + "Screenshot Hotkeys", + "Screenshot hotkeys are not available.\n\n" + "Install pynput to enable:\n" + " pip install pynput\n\n" + "Default hotkeys:\n" + " F12 - Full screen\n" + " Shift+F12 - Center region\n" + " Ctrl+F12 - Loot window\n" + " Alt+F12 - HUD area" + ) + return + + from PyQt6.QtWidgets import QDialog, QVBoxLayout + + dialog = QDialog(self) + dialog.setWindowTitle("Screenshot Hotkey Settings") + dialog.setMinimumWidth(400) + + layout = QVBoxLayout(dialog) + + # Create settings widget + widget = ScreenshotHotkeyWidget(self._screenshot_hotkeys) + layout.addWidget(widget.create_settings_widget()) + + dialog.exec() + def _load_settings(self): """Load persistent settings from QSettings.""" settings = QSettings("Lemontropia", "Suite") @@ -1900,6 +2001,14 @@ class MainWindow(QMainWindow): def closeEvent(self, event): """Handle window close event.""" + # Stop screenshot hotkey listener + if self._screenshot_hotkeys: + try: + self._screenshot_hotkeys.stop_listening() + logger.info("Screenshot hotkeys stopped") + except Exception as e: + logger.error(f"Error stopping screenshot hotkeys: {e}") + if self.session_state == SessionState.RUNNING: reply = QMessageBox.question( self,