Files
Jackify/jackify/frontends/gui/screens/modlist_gallery.py
2026-02-07 18:26:54 +00:00

254 lines
11 KiB
Python

"""
Enhanced Modlist Gallery Screen for Jackify GUI.
Provides visual browsing, filtering, and selection of modlists using
rich metadata from jackify-engine.
"""
from PySide6.QtWidgets import (
QWidget, QVBoxLayout, QHBoxLayout, QLabel, QPushButton,
QLineEdit, QComboBox, QCheckBox, QScrollArea, QGridLayout,
QFrame, QSizePolicy, QDialog, QTextEdit, QTextBrowser, QMessageBox, QListWidget
)
from PySide6.QtCore import Qt, Signal, QSize, QThread, QUrl, QTimer, QObject
from PySide6.QtGui import QPixmap, QFont, QPainter, QColor, QTextOption, QPalette
from PySide6.QtNetwork import QNetworkAccessManager, QNetworkRequest, QNetworkReply
from pathlib import Path
from typing import List, Optional, Dict
from collections import deque
import random
from jackify.backend.services.modlist_gallery_service import ModlistGalleryService
from jackify.backend.models.modlist_metadata import ModlistMetadata, ModlistMetadataResponse
from ..shared_theme import JACKIFY_COLOR_BLUE
from ..utils import get_screen_geometry, set_responsive_minimum
from .modlist_gallery_image_manager import ImageManager
from .modlist_gallery_card import ModlistCard
from .modlist_gallery_detail import ModlistDetailDialog
from .modlist_gallery_filters import ModlistGalleryFiltersMixin
from .modlist_gallery_loading import ModlistGalleryLoadingMixin
class ModlistGalleryDialog(ModlistGalleryFiltersMixin, ModlistGalleryLoadingMixin, QDialog):
"""Enhanced modlist gallery dialog with visual browsing"""
modlist_selected = Signal(ModlistMetadata)
def __init__(self, game_filter: Optional[str] = None, parent=None):
super().__init__(parent)
self.setWindowTitle("Select Modlist")
self.setModal(True)
self.setAutoFillBackground(True)
palette = self.palette()
palette.setColor(QPalette.Window, QColor("#111111"))
self.setPalette(palette)
# Detect Steam Deck
from jackify.backend.services.platform_detection_service import PlatformDetectionService
platform_service = PlatformDetectionService.get_instance()
self.is_steamdeck = platform_service.is_steamdeck
# Responsive sizing for different screen sizes (especially Steam Deck 1280x800)
min_height = 650 if self.is_steamdeck else 700
set_responsive_minimum(self, min_width=1100 if self.is_steamdeck else 1200, min_height=min_height)
self._apply_initial_size()
self.gallery_service = ModlistGalleryService()
self.image_manager = ImageManager(self.gallery_service)
self.all_modlists: List[ModlistMetadata] = []
self.filtered_modlists: List[ModlistMetadata] = []
self.game_filter = game_filter
self.selected_metadata: Optional[ModlistMetadata] = None
self.all_cards: Dict[str, ModlistCard] = {} # Dict keyed by machineURL for quick lookup
self._validation_update_timer = None # Timer for background validation updates
self._setup_ui()
# Disable filter controls during initial load to prevent race conditions
self._set_filter_controls_enabled(False)
# Lazy load - fetch modlists when dialog is shown
def _apply_initial_size(self):
"""Ensure dialog fits on screen while maximizing usable space."""
_, _, screen_width, screen_height = get_screen_geometry(self)
width = 1400
height = 800
if self.is_steamdeck or (screen_width and screen_width <= 1280):
width = min(width, 1200)
height = min(height, 750)
if screen_width:
width = min(width, max(1000, screen_width - 40))
if screen_height:
height = min(height, max(640, screen_height - 40))
self.resize(width, height)
def showEvent(self, event):
"""Fetch modlists when dialog is first shown"""
super().showEvent(event)
if not self.all_modlists:
# Start loading in background thread for instant dialog appearance
self._load_modlists_async()
def _setup_ui(self):
"""Set up the gallery UI"""
main_layout = QHBoxLayout()
main_layout.setContentsMargins(16, 16, 16, 16)
main_layout.setSpacing(12)
# Left sidebar (filters)
filter_panel = self._create_filter_panel()
main_layout.addWidget(filter_panel)
# Right content area (modlist grid)
self.content_area = self._create_content_area()
main_layout.addWidget(self.content_area, stretch=1)
self.setLayout(main_layout)
def _create_content_area(self) -> QWidget:
"""Create modlist grid content area"""
container = QWidget()
layout = QVBoxLayout()
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(12)
# Status label (subtle, top-right) - hidden during initial loading (popup shows instead)
self.status_label = QLabel("")
self.status_label.setStyleSheet("color: #888; font-size: 10px;")
self.status_label.setAlignment(Qt.AlignRight | Qt.AlignTop)
layout.addWidget(self.status_label)
# Scroll area for modlist cards
self.scroll_area = QScrollArea()
self.scroll_area.setWidgetResizable(True)
self.scroll_area.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
self.scroll_area.setVerticalScrollBarPolicy(Qt.ScrollBarAsNeeded)
# Grid container for cards
self.grid_widget = QWidget()
# Don't use WA_StaticContents - we need resize events to recalculate columns
self.grid_layout = QGridLayout()
self.grid_layout.setSpacing(8)
self.grid_layout.setAlignment(Qt.AlignTop | Qt.AlignLeft)
self.grid_widget.setLayout(self.grid_layout)
self.scroll_area.setWidget(self.grid_widget)
layout.addWidget(self.scroll_area)
container.setLayout(layout)
return container
def _update_grid(self):
"""Update grid by removing all cards and re-adding only visible ones"""
# CRITICAL: Guard against race condition - don't update if cards aren't ready yet
if not self.all_cards:
return
# Disable updates during grid update
self.grid_widget.setUpdatesEnabled(False)
try:
# Remove all cards from layout
# CRITICAL FIX: Properly remove all widgets to prevent overlapping
# Iterate backwards to avoid index shifting issues
for i in range(self.grid_layout.count() - 1, -1, -1):
item = self.grid_layout.takeAt(i)
widget = item.widget() if item else None
if widget:
# Hide widget during removal to prevent visual artifacts
widget.hide()
del item
# Force layout update to ensure all widgets are removed
self.grid_layout.update()
# Calculate number of columns based on available width
# Get the scroll area width (accounting for filter panel ~280px + margins)
scroll_area = self.grid_widget.parent()
if scroll_area and hasattr(scroll_area, 'viewport'):
available_width = scroll_area.viewport().width()
else:
# Fallback: estimate based on dialog width minus filter panel
available_width = self.width() - 280 - 32 # Filter panel + margins
if available_width <= 0:
# Fallback if width not yet calculated
available_width = 900 if not self.is_steamdeck else 700
# Card width + spacing between cards
if self.is_steamdeck:
card_width = 250
else:
card_width = 300
card_spacing = 8
# Calculate how many columns fit
columns = max(1, int((available_width + card_spacing) / (card_width + card_spacing)))
# Limit to reasonable max (4 columns on large screens, 3 on Steam Deck)
if not self.is_steamdeck:
columns = min(columns, 4)
else:
columns = min(columns, 3)
# Preserve randomized order (already shuffled in _load_modlists)
# Add visible cards to grid in order
for idx, modlist in enumerate(self.filtered_modlists):
row = idx // columns
col = idx % columns
card = self.all_cards.get(modlist.machineURL)
if card:
# Safety check: ensure widget is not already in the layout
# (shouldn't happen after proper removal above, but defensive programming)
already_in_layout = False
for i in range(self.grid_layout.count()):
item = self.grid_layout.itemAt(i)
if item and item.widget() == card:
# Widget is already in layout - this shouldn't happen, but handle it
already_in_layout = True
self.grid_layout.removeWidget(card)
break
# Ensure widget is visible and add to grid
if not already_in_layout or card.isHidden():
card.show()
self.grid_layout.addWidget(card, row, col)
# Set column stretch - don't stretch card columns, but add a spacer column
for col in range(columns):
self.grid_layout.setColumnStretch(col, 0) # Cards are fixed width
# Add a stretch column after cards to fill remaining space (centers the grid)
if columns < 4:
self.grid_layout.setColumnStretch(columns, 1)
finally:
# Re-enable updates
self.grid_widget.setUpdatesEnabled(True)
self.grid_widget.update()
# Update status
self.status_label.setText(f"Showing {len(self.filtered_modlists)} modlists")
def resizeEvent(self, event):
"""Handle dialog resize to recalculate grid columns"""
super().resizeEvent(event)
# Recalculate columns when dialog is resized
if hasattr(self, 'filtered_modlists') and self.filtered_modlists:
self._update_grid()
def _on_modlist_clicked(self, metadata: ModlistMetadata):
"""Handle modlist card click - show detail dialog"""
dialog = ModlistDetailDialog(metadata, self.image_manager, self)
dialog.install_requested.connect(self._on_install_requested)
dialog.exec()
def _on_install_requested(self, metadata: ModlistMetadata):
"""Handle install request from detail dialog"""
self.selected_metadata = metadata
self.modlist_selected.emit(metadata)
self.accept()
# Re-export for backward compatibility
__all__ = ['ImageManager', 'ModlistCard', 'ModlistDetailDialog', 'ModlistGalleryDialog']