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

402 lines
17 KiB
Python

"""Loading and data management for ModlistGalleryDialog (Mixin)."""
from PySide6.QtCore import Qt, QThread, Signal, QTimer
from PySide6.QtWidgets import QWidget, QVBoxLayout, QLabel, QApplication
from PySide6.QtGui import QFont
from typing import List, Dict
import random
import logging
from jackify.backend.models.modlist_metadata import ModlistMetadata
from ..shared_theme import JACKIFY_COLOR_BLUE
from .modlist_gallery_card import ModlistCard
logger = logging.getLogger(__name__)
class ModlistGalleryLoadingMixin:
"""Mixin providing loading and data management for ModlistGalleryDialog."""
def _load_modlists_async(self):
"""Load modlists in background thread for instant dialog appearance"""
from PySide6.QtCore import QThread, Signal
from PySide6.QtGui import QFont
# Hide status label during loading (popup dialog will show instead)
self.status_label.setVisible(False)
# Show loading overlay directly in content area (simpler than separate dialog)
self._loading_overlay = QWidget(self.content_area)
self._loading_overlay.setStyleSheet("""
QWidget {
background-color: rgba(35, 35, 35, 240);
border-radius: 8px;
}
""")
overlay_layout = QVBoxLayout()
overlay_layout.setContentsMargins(30, 20, 30, 20)
overlay_layout.setSpacing(12)
self._loading_label = QLabel("Loading modlists")
self._loading_label.setAlignment(Qt.AlignCenter)
# Set fixed width to prevent text shifting when dots animate
# Width accommodates "Loading modlists..." (longest version)
self._loading_label.setFixedWidth(220)
font = QFont()
font.setPointSize(14)
font.setBold(True)
self._loading_label.setFont(font)
self._loading_label.setStyleSheet(f"color: {JACKIFY_COLOR_BLUE}; font-size: 14px; font-weight: bold;")
overlay_layout.addWidget(self._loading_label)
self._loading_overlay.setLayout(overlay_layout)
self._loading_overlay.setFixedSize(300, 120)
# Animate dots in loading message
self._loading_dot_count = 0
self._loading_dot_timer = QTimer()
self._loading_dot_timer.timeout.connect(self._animate_loading_dots)
self._loading_dot_timer.start(500) # Update every 500ms
# Position overlay in center of content area
def position_overlay():
if hasattr(self, 'content_area') and self.content_area.isVisible():
content_width = self.content_area.width()
content_height = self.content_area.height()
x = (content_width - 300) // 2
y = (content_height - 120) // 2
self._loading_overlay.move(x, y)
self._loading_overlay.show()
self._loading_overlay.raise_()
# Delay slightly to ensure content_area is laid out
QTimer.singleShot(50, position_overlay)
class ModlistLoaderThread(QThread):
"""Background thread to load modlist metadata"""
finished = Signal(object, object) # metadata_response, error_message
def __init__(self, gallery_service):
super().__init__()
self.gallery_service = gallery_service
def run(self):
try:
import time
start_time = time.time()
# Fetch metadata (CPU-intensive work happens here in background)
# Skip search index initially for faster loading - can be loaded later if user searches
metadata_response = self.gallery_service.fetch_modlist_metadata(
include_validation=False,
include_search_index=False, # Skip for faster initial load
sort_by="title"
)
elapsed = time.time() - start_time
import logging
logger = logging.getLogger(__name__)
if elapsed < 0.5:
logger.debug(f"Gallery metadata loaded from cache in {elapsed:.2f}s")
else:
logger.info(f"Gallery metadata fetched from engine in {elapsed:.2f}s")
self.finished.emit(metadata_response, None)
except Exception as e:
self.finished.emit(None, str(e))
# Create and start background thread
self._loader_thread = ModlistLoaderThread(self.gallery_service)
self._loader_thread.finished.connect(self._on_modlists_loaded)
self._loader_thread.start()
def _animate_loading_dots(self):
"""Animate dots in loading message"""
if hasattr(self, '_loading_label') and self._loading_label:
self._loading_dot_count = (self._loading_dot_count + 1) % 4
dots = "." * self._loading_dot_count
# Pad with spaces to keep text width constant (prevents shifting)
padding = " " * (3 - self._loading_dot_count)
self._loading_label.setText(f"Loading modlists{dots}{padding}")
def _on_modlists_loaded(self, metadata_response, error_message):
"""Handle modlist metadata loaded in background thread (runs in GUI thread)"""
import random
from PySide6.QtGui import QFont
# Stop animation timer and close loading overlay
if hasattr(self, '_loading_dot_timer') and self._loading_dot_timer:
self._loading_dot_timer.stop()
self._loading_dot_timer = None
if hasattr(self, '_loading_overlay') and self._loading_overlay:
self._loading_overlay.hide()
self._loading_overlay.deleteLater()
self._loading_overlay = None
self.status_label.setVisible(True)
if error_message:
self.status_label.setText(f"Error loading modlists: {error_message}")
return
if not metadata_response:
self.status_label.setText("Failed to load modlists")
return
try:
# Get all modlists
all_modlists = metadata_response.modlists
# RANDOMIZE the order each time gallery opens (like Wabbajack)
random.shuffle(all_modlists)
self.all_modlists = all_modlists
# Precompute normalized tags for display/filtering
for modlist in self.all_modlists:
normalized_display = self.gallery_service.normalize_tags_for_display(getattr(modlist, 'tags', []))
modlist.normalized_tags_display = normalized_display
modlist.normalized_tags_keys = [tag.lower() for tag in normalized_display]
# Temporarily disconnect to prevent triggering during setup
self.game_combo.currentIndexChanged.disconnect(self._apply_filters)
# Populate game filter
games = sorted(set(m.gameHumanFriendly for m in self.all_modlists))
for game in games:
self.game_combo.addItem(game, game)
# If dialog was opened with a game filter, pre-select it
if self.game_filter:
index = self.game_combo.findData(self.game_filter)
if index >= 0:
self.game_combo.setCurrentIndex(index)
# Populate tag filter (mod filter temporarily disabled)
self._populate_tag_filter()
# self._populate_mod_filter() # TEMPORARILY DISABLED
# Create cards immediately (will show placeholders for images not in cache)
self._create_all_cards()
# Preload cached images in background (non-blocking)
self.status_label.setText("Loading images...")
QTimer.singleShot(0, self._preload_cached_images_async)
# Reconnect filter handler
self.game_combo.currentIndexChanged.connect(self._apply_filters)
# Enable filter controls now that data is loaded
self._set_filter_controls_enabled(True)
# Apply filters (will show all modlists for selected game initially)
self._apply_filters()
# Start background validation update (non-blocking)
self._start_validation_update()
except Exception as e:
self.status_label.setText(f"Error processing modlists: {str(e)}")
def _load_modlists(self):
"""DEPRECATED: Synchronous loading - replaced by _load_modlists_async()"""
from PySide6.QtWidgets import QApplication
self.status_label.setText("Loading modlists...")
QApplication.processEvents() # Update UI immediately
# Fetch metadata (will use cache if valid)
# Skip validation initially for faster loading - can be added later if needed
try:
metadata_response = self.gallery_service.fetch_modlist_metadata(
include_validation=False, # Skip validation for faster initial load
include_search_index=True, # Include mod search index for mod filtering
sort_by="title"
)
if metadata_response:
# Get all modlists
all_modlists = metadata_response.modlists
# RANDOMIZE the order each time gallery opens (like Wabbajack)
# Prevent gaming via alphabetical ordering
random.shuffle(all_modlists)
self.all_modlists = all_modlists
# Precompute normalized tags for display/filtering (matches upstream Wabbajack)
for modlist in self.all_modlists:
normalized_display = self.gallery_service.normalize_tags_for_display(getattr(modlist, 'tags', []))
modlist.normalized_tags_display = normalized_display
modlist.normalized_tags_keys = [tag.lower() for tag in normalized_display]
# Temporarily disconnect to prevent triggering during setup
self.game_combo.currentIndexChanged.disconnect(self._apply_filters)
# Populate game filter
games = sorted(set(m.gameHumanFriendly for m in self.all_modlists))
for game in games:
self.game_combo.addItem(game, game)
# If dialog was opened with a game filter, pre-select it
if self.game_filter:
index = self.game_combo.findData(self.game_filter)
if index >= 0:
self.game_combo.setCurrentIndex(index)
# Populate tag filter (mod filter temporarily disabled)
self._populate_tag_filter()
# self._populate_mod_filter() # TEMPORARILY DISABLED
# Create cards immediately (will show placeholders for images not in cache)
self._create_all_cards()
# Preload cached images in background (non-blocking)
# Images will appear as they're loaded
self.status_label.setText("Loading images...")
QTimer.singleShot(0, self._preload_cached_images_async)
# Reconnect filter handler
self.game_combo.currentIndexChanged.connect(self._apply_filters)
# Apply filters (will show all modlists for selected game initially)
self._apply_filters()
# Start background validation update (non-blocking)
self._start_validation_update()
else:
self.status_label.setText("Failed to load modlists")
except Exception as e:
self.status_label.setText(f"Error loading modlists: {str(e)}")
def _preload_cached_images_async(self):
"""Preload cached images asynchronously - images appear as they load"""
from PySide6.QtWidgets import QApplication
preloaded = 0
total = len(self.all_modlists)
for idx, modlist in enumerate(self.all_modlists):
cache_key = modlist.machineURL
# Skip if already in cache
if cache_key in self.image_manager.pixmap_cache:
continue
# Preload large images for cards (scale down for better quality)
cached_path = self.gallery_service.get_cached_image_path(modlist, "large")
if cached_path and cached_path.exists():
try:
pixmap = QPixmap(str(cached_path))
if not pixmap.isNull():
cache_key_large = f"{cache_key}_large"
self.image_manager.pixmap_cache[cache_key_large] = pixmap
preloaded += 1
# Update card immediately if it exists
card = self.all_cards.get(cache_key)
if card:
card._display_image(pixmap)
except Exception:
pass
# Process events every 10 images to keep UI responsive
if idx % 10 == 0 and idx > 0:
QApplication.processEvents()
# Update status (subtle, user-friendly)
modlist_count = len(self.filtered_modlists)
if modlist_count == 1:
self.status_label.setText("1 modlist")
else:
self.status_label.setText(f"{modlist_count} modlists")
def _create_all_cards(self):
"""Create cards for all modlists and store in dict"""
# Clear existing cards
self.all_cards.clear()
# Disable updates during card creation to prevent individual renders
self.grid_widget.setUpdatesEnabled(False)
self.setUpdatesEnabled(False)
try:
# Create all cards - images should be in memory cache from preload
# so _load_image() will find them instantly
for modlist in self.all_modlists:
card = ModlistCard(modlist, self.image_manager, is_steamdeck=self.is_steamdeck)
card.clicked.connect(self._on_modlist_clicked)
self.all_cards[modlist.machineURL] = card
finally:
# Re-enable updates - single render for all cards
self.setUpdatesEnabled(True)
self.grid_widget.setUpdatesEnabled(True)
def _refresh_metadata(self):
"""Force refresh metadata from jackify-engine"""
self.status_label.setText("Refreshing metadata...")
self.gallery_service.clear_cache()
self._load_modlists()
def _start_validation_update(self):
"""Start background validation update to get availability status"""
# Update validation in background thread to avoid blocking UI
class ValidationUpdateThread(QThread):
finished_signal = Signal(object) # Emits updated metadata response
def __init__(self, gallery_service):
super().__init__()
self.gallery_service = gallery_service
def run(self):
try:
# Fetch with validation (slower, but in background)
metadata_response = self.gallery_service.fetch_modlist_metadata(
include_validation=True,
include_search_index=False,
sort_by="title"
)
self.finished_signal.emit(metadata_response)
except Exception:
self.finished_signal.emit(None)
self._validation_thread = ValidationUpdateThread(self.gallery_service)
self._validation_thread.finished_signal.connect(self._on_validation_updated)
self._validation_thread.start()
def _on_validation_updated(self, metadata_response):
"""Update modlists with validation data when background fetch completes"""
if not metadata_response:
return
# Create lookup dict for validation data
validation_map = {}
for modlist in metadata_response.modlists:
if modlist.validation:
validation_map[modlist.machineURL] = modlist.validation
# Update existing modlists with validation data
updated_count = 0
for modlist in self.all_modlists:
if modlist.machineURL in validation_map:
modlist.validation = validation_map[modlist.machineURL]
updated_count += 1
# Update card if it exists
card = self.all_cards.get(modlist.machineURL)
if card:
# Update unavailable badge visibility
card._update_availability_badge()
# Re-apply filters to update availability filtering
if updated_count > 0:
self._apply_filters()