""" Lemontropia Suite - Gallery Dialog Browse and manage screenshots captured during hunting sessions. """ import os import json from datetime import datetime from pathlib import Path from typing import Optional, List, Dict, Any # PIL is optional - screenshots won't work without it but gallery can still view try: from PIL import Image, ImageGrab PIL_AVAILABLE = True except ImportError: PIL_AVAILABLE = False Image = None ImageGrab = None from PyQt6.QtWidgets import ( QDialog, QVBoxLayout, QHBoxLayout, QFormLayout, QLabel, QPushButton, QListWidget, QListWidgetItem, QSplitter, QWidget, QGroupBox, QComboBox, QMessageBox, QFileDialog, QScrollArea, QFrame, QSizePolicy, QGridLayout ) from PyQt6.QtCore import Qt, pyqtSignal, QSize from PyQt6.QtGui import QPixmap, QImage, QColor from core.database import DatabaseManager class ImageLabel(QLabel): """Custom label for displaying images with click handling.""" clicked = pyqtSignal() def __init__(self, parent=None): super().__init__(parent) self.setAlignment(Qt.AlignmentFlag.AlignCenter) self.setStyleSheet("background-color: #252525; border: 1px solid #444;") self.setMinimumSize(400, 300) self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) def mousePressEvent(self, event): self.clicked.emit() class GalleryDialog(QDialog): """ Dialog for viewing and managing captured screenshots. Features: - Browse all screenshots with thumbnails - Filter by type (global, hof, all) - View full-size image with metadata - Delete screenshots - Open in external viewer """ screenshot_deleted = pyqtSignal(int) # Emits screenshot_id when deleted def __init__(self, parent=None): super().__init__(parent) self.setWindowTitle("Screenshot Gallery") self.setMinimumSize(1400, 900) self.resize(1600, 1000) # Initialize database self.db = DatabaseManager() # Ensure screenshots directory exists self.screenshots_dir = Path(__file__).parent.parent / "data" / "screenshots" self.screenshots_dir.mkdir(parents=True, exist_ok=True) # State self.current_screenshot_id: Optional[int] = None self.screenshots_data: List[Dict[str, Any]] = [] self.current_pixmap: Optional[QPixmap] = None self._setup_ui() self._load_screenshots() self._apply_dark_theme() def _setup_ui(self): """Setup the dialog UI.""" layout = QVBoxLayout(self) layout.setContentsMargins(10, 10, 10, 10) layout.setSpacing(10) # Top controls controls_layout = QHBoxLayout() # Filter by type controls_layout.addWidget(QLabel("Filter:")) self.type_filter = QComboBox() self.type_filter.addItem("📷 All Screenshots", "all") self.type_filter.addItem("🌟 Globals", "global") self.type_filter.addItem("🏆 HoFs", "hof") self.type_filter.currentIndexChanged.connect(self._on_filter_changed) controls_layout.addWidget(self.type_filter) controls_layout.addSpacing(20) # Filter by session controls_layout.addWidget(QLabel("Session:")) self.session_filter = QComboBox() self.session_filter.addItem("All Sessions", None) self.session_filter.currentIndexChanged.connect(self._on_filter_changed) controls_layout.addWidget(self.session_filter) controls_layout.addStretch() # Stats label self.stats_label = QLabel("0 screenshots") controls_layout.addWidget(self.stats_label) controls_layout.addSpacing(20) # Refresh button self.refresh_btn = QPushButton("🔄 Refresh") self.refresh_btn.clicked.connect(self._load_screenshots) controls_layout.addWidget(self.refresh_btn) # Open folder button self.open_folder_btn = QPushButton("📁 Open Folder") self.open_folder_btn.clicked.connect(self._open_screenshots_folder) controls_layout.addWidget(self.open_folder_btn) layout.addLayout(controls_layout) # Main splitter splitter = QSplitter(Qt.Orientation.Horizontal) layout.addWidget(splitter) # Left side - Thumbnail list left_panel = QWidget() left_layout = QVBoxLayout(left_panel) left_layout.setContentsMargins(0, 0, 0, 0) # Screenshots list self.screenshots_list = QListWidget() self.screenshots_list.setIconSize(QSize(120, 90)) self.screenshots_list.setViewMode(QListWidget.ViewMode.IconMode) self.screenshots_list.setResizeMode(QListWidget.ResizeMode.Adjust) self.screenshots_list.setSpacing(10) self.screenshots_list.setWrapping(True) self.screenshots_list.setMinimumWidth(400) self.screenshots_list.itemClicked.connect(self._on_screenshot_selected) self.screenshots_list.itemDoubleClicked.connect(self._on_screenshot_double_clicked) left_layout.addWidget(self.screenshots_list) splitter.addWidget(left_panel) # Right side - Preview and details right_panel = QWidget() right_layout = QVBoxLayout(right_panel) right_layout.setContentsMargins(0, 0, 0, 0) # Image preview area self.preview_group = QGroupBox("Preview") preview_layout = QVBoxLayout(self.preview_group) # Scroll area for image self.scroll_area = QScrollArea() self.scroll_area.setWidgetResizable(True) self.scroll_area.setStyleSheet("background-color: #151515; border: none;") self.image_label = ImageLabel() self.image_label.setText("Select a screenshot to preview") self.image_label.clicked.connect(self._open_external_viewer) self.scroll_area.setWidget(self.image_label) preview_layout.addWidget(self.scroll_area) right_layout.addWidget(self.preview_group, stretch=1) # Details panel self.details_group = QGroupBox("Details") details_layout = QFormLayout(self.details_group) self.detail_id = QLabel("-") details_layout.addRow("ID:", self.detail_id) self.detail_timestamp = QLabel("-") details_layout.addRow("Captured:", self.detail_timestamp) self.detail_event_type = QLabel("-") details_layout.addRow("Event Type:", self.detail_event_type) self.detail_value = QLabel("-") details_layout.addRow("Value:", self.detail_value) self.detail_mob = QLabel("-") details_layout.addRow("Mob:", self.detail_mob) self.detail_session = QLabel("-") details_layout.addRow("Session:", self.detail_session) self.detail_file_path = QLabel("-") self.detail_file_path.setWordWrap(True) self.detail_file_path.setStyleSheet("font-size: 9px; color: #888;") details_layout.addRow("File:", self.detail_file_path) self.detail_file_size = QLabel("-") details_layout.addRow("Size:", self.detail_file_size) self.detail_dimensions = QLabel("-") details_layout.addRow("Dimensions:", self.detail_dimensions) right_layout.addWidget(self.details_group) # Action buttons actions_layout = QHBoxLayout() self.open_external_btn = QPushButton("🔍 Open External") self.open_external_btn.clicked.connect(self._open_external_viewer) self.open_external_btn.setEnabled(False) actions_layout.addWidget(self.open_external_btn) self.save_as_btn = QPushButton("💾 Save As...") self.save_as_btn.clicked.connect(self._save_as) self.save_as_btn.setEnabled(False) actions_layout.addWidget(self.save_as_btn) actions_layout.addStretch() self.delete_btn = QPushButton("🗑️ Delete") self.delete_btn.clicked.connect(self._delete_screenshot) self.delete_btn.setEnabled(False) actions_layout.addWidget(self.delete_btn) right_layout.addLayout(actions_layout) splitter.addWidget(right_panel) # Set splitter sizes splitter.setSizes([500, 700]) # Close button close_layout = QHBoxLayout() close_layout.addStretch() self.close_btn = QPushButton("Close") self.close_btn.clicked.connect(self.accept) close_layout.addWidget(self.close_btn) layout.addLayout(close_layout) def _load_screenshots(self): """Load screenshots from database.""" self.screenshots_list.clear() self.screenshots_data = [] try: # Load sessions for filter self._load_sessions() # Get filter values event_filter = self.type_filter.currentData() session_filter = self.session_filter.currentData() # Build query - check both screenshots table and loot_events with screenshots query = """ SELECT s.id, s.session_id, s.timestamp, s.file_path, s.trigger_event, s.trigger_value_ped as value, le.creature_name as mob_name, le.event_type as loot_event_type, p.name as project_name, hs.started_at as session_date FROM screenshots s JOIN sessions ses ON s.session_id = ses.id JOIN projects p ON ses.project_id = p.id LEFT JOIN hunting_sessions hs ON hs.session_id = s.session_id LEFT JOIN loot_events le ON le.session_id = s.session_id AND le.screenshot_path = s.file_path WHERE 1=1 """ params = [] # Apply event type filter if event_filter == "global": query += " AND (s.trigger_event LIKE '%global%' OR le.event_type = 'global')" elif event_filter == "hof": query += " AND (s.trigger_event LIKE '%hof%' OR s.trigger_event LIKE '%hall%' OR le.event_type = 'hof')" # Apply session filter if session_filter: query += " AND s.session_id = ?" params.append(session_filter) query += " ORDER BY s.timestamp DESC" cursor = self.db.execute(query, tuple(params)) rows = cursor.fetchall() for row in rows: screenshot_data = dict(row) self.screenshots_data.append(screenshot_data) # Create list item with thumbnail item = QListWidgetItem() item.setData(Qt.ItemDataRole.UserRole, screenshot_data['id']) # Set text with timestamp and value timestamp = datetime.fromisoformat(screenshot_data['timestamp']) time_str = timestamp.strftime("%m/%d %H:%M") value = screenshot_data['value'] or 0 event_type = screenshot_data['trigger_event'] or screenshot_data['loot_event_type'] or "Manual" if value > 0: item.setText(f"{time_str}\n{value:.0f} PED") else: item.setText(f"{time_str}\n{event_type}") # Load thumbnail file_path = screenshot_data['file_path'] if file_path and os.path.exists(file_path): pixmap = QPixmap(file_path) if not pixmap.isNull(): # Scale to thumbnail size scaled = pixmap.scaled(120, 90, Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.SmoothTransformation) item.setIcon(scaled) self.screenshots_list.addItem(item) # Update stats self.stats_label.setText(f"{len(self.screenshots_data)} screenshot(s)") except Exception as e: QMessageBox.warning(self, "Error", f"Failed to load screenshots: {e}") def _load_sessions(self): """Load sessions for filter dropdown.""" current = self.session_filter.currentData() self.session_filter.clear() self.session_filter.addItem("All Sessions", None) try: cursor = self.db.execute(""" SELECT DISTINCT s.id, p.name, s.started_at FROM sessions s JOIN projects p ON s.project_id = p.id JOIN screenshots sc ON sc.session_id = s.id ORDER BY s.started_at DESC """) for row in cursor.fetchall(): started = datetime.fromisoformat(row['started_at']) label = f"{row['name']} - {started.strftime('%Y-%m-%d %H:%M')}" self.session_filter.addItem(label, row['id']) # Restore selection if current: idx = self.session_filter.findData(current) if idx >= 0: self.session_filter.setCurrentIndex(idx) except Exception: pass def _on_filter_changed(self): """Handle filter changes.""" self._load_screenshots() def _on_screenshot_selected(self, item: QListWidgetItem): """Handle screenshot selection.""" screenshot_id = item.data(Qt.ItemDataRole.UserRole) self.current_screenshot_id = screenshot_id self._load_screenshot_details(screenshot_id) def _on_screenshot_double_clicked(self, item: QListWidgetItem): """Handle double-click on screenshot.""" self._open_external_viewer() def _load_screenshot_details(self, screenshot_id: int): """Load and display screenshot details.""" try: screenshot = next((s for s in self.screenshots_data if s['id'] == screenshot_id), None) if not screenshot: return file_path = screenshot['file_path'] # Update details self.detail_id.setText(str(screenshot['id'])) timestamp = datetime.fromisoformat(screenshot['timestamp']) self.detail_timestamp.setText(timestamp.strftime("%Y-%m-%d %H:%M:%S")) event_type = screenshot['trigger_event'] or screenshot['loot_event_type'] or "Manual" self.detail_event_type.setText(event_type.capitalize()) value = screenshot['value'] or 0 if value > 0: self.detail_value.setText(f"{value:.2f} PED") self.detail_value.setStyleSheet("color: #4caf50; font-weight: bold;") else: self.detail_value.setText("-") self.detail_value.setStyleSheet("") mob = screenshot['mob_name'] or "Unknown" self.detail_mob.setText(mob) session_info = f"{screenshot['project_name'] or 'Unknown'}" if screenshot['session_date']: session_date = datetime.fromisoformat(screenshot['session_date']) session_info += f" ({session_date.strftime('%Y-%m-%d')})" self.detail_session.setText(session_info) self.detail_file_path.setText(file_path or "-") # Load and display image if file_path and os.path.exists(file_path): self._display_image(file_path) # Get file info file_size = os.path.getsize(file_path) self.detail_file_size.setText(self._format_file_size(file_size)) # Enable buttons self.open_external_btn.setEnabled(True) self.save_as_btn.setEnabled(True) self.delete_btn.setEnabled(True) else: self.image_label.setText(f"File not found:\n{file_path}") self.image_label.setPixmap(QPixmap()) self.current_pixmap = None self.detail_file_size.setText("-") self.detail_dimensions.setText("-") # Disable buttons self.open_external_btn.setEnabled(False) self.save_as_btn.setEnabled(False) self.delete_btn.setEnabled(True) # Still allow delete if DB record exists except Exception as e: QMessageBox.warning(self, "Error", f"Failed to load screenshot details: {e}") def _display_image(self, file_path: str): """Load and display the image.""" pixmap = QPixmap(file_path) if pixmap.isNull(): self.image_label.setText("Failed to load image") self.current_pixmap = None return self.current_pixmap = pixmap # Update dimensions self.detail_dimensions.setText(f"{pixmap.width()} x {pixmap.height()}") # Scale to fit while maintaining aspect ratio scroll_size = self.scroll_area.size() scaled = pixmap.scaled( scroll_size.width() - 20, scroll_size.height() - 20, Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.SmoothTransformation ) self.image_label.setPixmap(scaled) self.image_label.setText("") def _format_file_size(self, size_bytes: int) -> str: """Format file size to human readable.""" if size_bytes < 1024: return f"{size_bytes} B" elif size_bytes < 1024 * 1024: return f"{size_bytes / 1024:.1f} KB" else: return f"{size_bytes / (1024 * 1024):.1f} MB" def _open_external_viewer(self): """Open the screenshot in external viewer.""" if not self.current_screenshot_id: return screenshot = next( (s for s in self.screenshots_data if s['id'] == self.current_screenshot_id), None ) if screenshot and screenshot['file_path'] and os.path.exists(screenshot['file_path']): import subprocess import platform file_path = screenshot['file_path'] try: if platform.system() == 'Windows': os.startfile(file_path) elif platform.system() == 'Darwin': # macOS subprocess.run(['open', file_path]) else: # Linux subprocess.run(['xdg-open', file_path]) except Exception as e: QMessageBox.warning(self, "Error", f"Failed to open image: {e}") def _save_as(self): """Save the screenshot to a new location.""" if not self.current_screenshot_id: return screenshot = next( (s for s in self.screenshots_data if s['id'] == self.current_screenshot_id), None ) if not screenshot or not screenshot['file_path']: return source_path = Path(screenshot['file_path']) file_path, _ = QFileDialog.getSaveFileName( self, "Save Screenshot", f"screenshot_{screenshot['id']}.png", "PNG Images (*.png);;JPEG Images (*.jpg *.jpeg);;All Files (*)" ) if not file_path: return try: import shutil shutil.copy2(source_path, file_path) QMessageBox.information(self, "Success", f"Screenshot saved to {file_path}") except Exception as e: QMessageBox.critical(self, "Error", f"Failed to save: {e}") def _delete_screenshot(self): """Delete the selected screenshot.""" if not self.current_screenshot_id: return reply = QMessageBox.question( self, "Confirm Delete", "Are you sure you want to delete this screenshot?\n\n" "This will permanently delete the file and its metadata.", QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, QMessageBox.StandardButton.No ) if reply != QMessageBox.StandardButton.Yes: return try: screenshot = next( (s for s in self.screenshots_data if s['id'] == self.current_screenshot_id), None ) # Delete file if exists if screenshot and screenshot['file_path'] and os.path.exists(screenshot['file_path']): os.remove(screenshot['file_path']) # Delete from database self.db.execute( "DELETE FROM screenshots WHERE id = ?", (self.current_screenshot_id,) ) self.db.commit() # Emit signal self.screenshot_deleted.emit(self.current_screenshot_id) # Reload self._load_screenshots() self.current_screenshot_id = None # Clear details self.image_label.setText("Select a screenshot to preview") self.image_label.setPixmap(QPixmap()) self.current_pixmap = None self.detail_id.setText("-") self.detail_timestamp.setText("-") self.detail_event_type.setText("-") self.detail_value.setText("-") self.detail_value.setStyleSheet("") self.detail_mob.setText("-") self.detail_session.setText("-") self.detail_file_path.setText("-") self.detail_file_size.setText("-") self.detail_dimensions.setText("-") self.open_external_btn.setEnabled(False) self.save_as_btn.setEnabled(False) self.delete_btn.setEnabled(False) except Exception as e: QMessageBox.critical(self, "Error", f"Failed to delete screenshot: {e}") def _open_screenshots_folder(self): """Open the screenshots folder in file manager.""" import subprocess import platform folder_path = str(self.screenshots_dir) try: if platform.system() == 'Windows': os.startfile(folder_path) elif platform.system() == 'Darwin': # macOS subprocess.run(['open', folder_path]) else: # Linux subprocess.run(['xdg-open', folder_path]) except Exception as e: QMessageBox.warning(self, "Error", f"Failed to open folder: {e}") def resizeEvent(self, event): """Handle resize to rescale image.""" super().resizeEvent(event) if self.current_pixmap: scroll_size = self.scroll_area.size() scaled = self.current_pixmap.scaled( scroll_size.width() - 20, scroll_size.height() - 20, Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.SmoothTransformation ) self.image_label.setPixmap(scaled) def _apply_dark_theme(self): """Apply dark theme styling.""" dark_stylesheet = """ QDialog { background-color: #1e1e1e; } QWidget { background-color: #1e1e1e; color: #e0e0e0; font-family: 'Segoe UI', Arial, sans-serif; font-size: 10pt; } QGroupBox { font-weight: bold; border: 1px solid #444; border-radius: 6px; margin-top: 10px; padding-top: 10px; padding: 10px; } QGroupBox::title { subcontrol-origin: margin; left: 10px; padding: 0 5px; color: #888; } QPushButton { background-color: #2d2d2d; border: 1px solid #444; border-radius: 4px; padding: 8px 16px; color: #e0e0e0; } QPushButton:hover { background-color: #3d3d3d; border-color: #555; } QPushButton:pressed { background-color: #4d4d4d; } QPushButton:disabled { background-color: #252525; color: #666; border-color: #333; } QListWidget { background-color: #252525; border: 1px solid #444; border-radius: 4px; outline: none; padding: 10px; } QListWidget::item { padding: 5px; border-radius: 4px; margin: 2px; background-color: #2d2d2d; } QListWidget::item:selected { background-color: #0d47a1; color: white; } QListWidget::item:hover:!selected { background-color: #3d3d3d; } QComboBox { background-color: #252525; border: 1px solid #444; border-radius: 4px; padding: 6px; color: #e0e0e0; min-width: 150px; } QComboBox:focus { border-color: #0d47a1; } QComboBox::drop-down { border: none; padding-right: 10px; } QScrollArea { border: none; } QLabel { color: #e0e0e0; } QFormLayout QLabel { color: #888; } QSplitter::handle { background-color: #444; } """ self.setStyleSheet(dark_stylesheet) class ScreenshotCapture: """ Utility class for capturing screenshots. Handles: - Screen capture - Saving to file with metadata - Database recording """ def __init__(self, db: Optional[DatabaseManager] = None): self.db = db or DatabaseManager() self.screenshots_dir = Path(__file__).parent.parent / "data" / "screenshots" self.screenshots_dir.mkdir(parents=True, exist_ok=True) def capture(self, session_id: int, trigger_event: str = "manual", value_ped: float = 0.0, mob_name: str = "") -> Optional[str]: """ Capture a screenshot and save it. Args: session_id: The current session ID trigger_event: What triggered the screenshot (global, hof, manual) value_ped: Value of the event in PED mob_name: Name of the mob/creature Returns: Path to the saved screenshot, or None if failed """ if not PIL_AVAILABLE: print("Screenshot capture requires PIL (Pillow). Install with: pip install Pillow") return None try: # Generate filename with timestamp timestamp = datetime.now() filename = f"{timestamp.strftime('%Y%m%d_%H%M%S')}_{trigger_event}" if value_ped > 0: filename += f"_{value_ped:.0f}PED" filename += ".png" file_path = self.screenshots_dir / filename # Capture screenshot using PIL screenshot = ImageGrab.grab() screenshot.save(file_path, "PNG") # Save metadata to database cursor = self.db.execute(""" INSERT INTO screenshots (session_id, timestamp, file_path, trigger_event, trigger_value_ped) VALUES (?, ?, ?, ?, ?) """, ( session_id, timestamp.isoformat(), str(file_path), trigger_event, value_ped )) # Also update loot_events if there's a matching recent event self.db.execute(""" UPDATE loot_events SET screenshot_path = ? WHERE session_id = ? AND screenshot_path IS NULL AND event_type IN ('global', 'hof') ORDER BY timestamp DESC LIMIT 1 """, (str(file_path), session_id)) self.db.commit() return str(file_path) except Exception as e: print(f"Screenshot capture failed: {e}") return None def capture_global(self, session_id: int, value_ped: float, mob_name: str = "") -> Optional[str]: """Capture screenshot for a global event.""" return self.capture(session_id, "global", value_ped, mob_name) def capture_hof(self, session_id: int, value_ped: float, mob_name: str = "") -> Optional[str]: """Capture screenshot for a HoF event.""" return self.capture(session_id, "hof", value_ped, mob_name) def capture_manual(self, session_id: int) -> Optional[str]: """Capture manual screenshot.""" return self.capture(session_id, "manual", 0.0, "")