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

209 lines
9.5 KiB
Python

"""Visual card representing a single modlist."""
from PySide6.QtWidgets import QFrame, QVBoxLayout, QHBoxLayout, QLabel, QSizePolicy
from PySide6.QtCore import Qt, Signal, QSize
from PySide6.QtGui import QPixmap, QPainter, QColor, QFont
from jackify.backend.models.modlist_metadata import ModlistMetadata
from ..shared_theme import JACKIFY_COLOR_BLUE
from .modlist_gallery_image_manager import ImageManager
class ModlistCard(QFrame):
"""Visual card representing a single modlist"""
clicked = Signal(ModlistMetadata)
def __init__(self, metadata: ModlistMetadata, image_manager: ImageManager, is_steamdeck: bool = False):
super().__init__()
self.metadata = metadata
self.image_manager = image_manager
self.is_steamdeck = is_steamdeck
self._setup_ui()
def _setup_ui(self):
"""Set up the card UI"""
self.setFrameShape(QFrame.StyledPanel)
self.setFrameShadow(QFrame.Raised)
self.setCursor(Qt.PointingHandCursor)
# Steam Deck-specific sizing (1280x800 screen)
if self.is_steamdeck:
self.setFixedSize(250, 270) # Smaller cards for Steam Deck
image_width, image_height = 230, 130 # Smaller images, maintaining 16:9 ratio
else:
self.setFixedSize(300, 320) # Standard size
image_width, image_height = 280, 158 # Standard image size
self.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed)
layout = QVBoxLayout()
layout.setContentsMargins(10, 8, 10, 8) # Reduced vertical margins
layout.setSpacing(6) # Reduced spacing between elements
# Image (widescreen aspect ratio like Wabbajack)
self.image_label = QLabel()
self.image_label.setFixedSize(image_width, image_height) # 16:9 aspect ratio
self.image_label.setStyleSheet("background: #333; border-radius: 4px;")
self.image_label.setAlignment(Qt.AlignCenter)
self.image_label.setScaledContents(True) # Use Qt's automatic scaling - this works best
self.image_label.setText("")
layout.addWidget(self.image_label)
# Title row with badges (Official, NSFW, UNAVAILABLE)
title_row = QHBoxLayout()
title_row.setSpacing(4)
title = QLabel(self.metadata.title)
title.setWordWrap(True)
title.setFont(QFont("Sans", 12, QFont.Bold))
title.setStyleSheet(f"color: {JACKIFY_COLOR_BLUE};")
title.setMaximumHeight(40)
title_row.addWidget(title, stretch=1)
# Store reference to unavailable badge for dynamic updates
self.unavailable_badge = None
if not self.metadata.is_available():
self.unavailable_badge = QLabel("UNAVAILABLE")
self.unavailable_badge.setStyleSheet("background: #666; color: white; padding: 2px 6px; font-size: 9px; border-radius: 3px;")
self.unavailable_badge.setFixedHeight(20)
title_row.addWidget(self.unavailable_badge, alignment=Qt.AlignTop | Qt.AlignRight)
if self.metadata.official:
official_badge = QLabel("OFFICIAL")
official_badge.setStyleSheet("background: #2a5; color: white; padding: 2px 6px; font-size: 9px; border-radius: 3px;")
official_badge.setFixedHeight(20)
title_row.addWidget(official_badge, alignment=Qt.AlignTop | Qt.AlignRight)
if self.metadata.nsfw:
nsfw_badge = QLabel("NSFW")
nsfw_badge.setStyleSheet("background: #d44; color: white; padding: 2px 6px; font-size: 9px; border-radius: 3px;")
nsfw_badge.setFixedHeight(20)
title_row.addWidget(nsfw_badge, alignment=Qt.AlignTop | Qt.AlignRight)
layout.addLayout(title_row)
# Author
author = QLabel(f"by {self.metadata.author}")
author.setStyleSheet("color: #aaa; font-size: 11px;")
layout.addWidget(author)
# Game
game = QLabel(self.metadata.gameHumanFriendly)
game.setStyleSheet("color: #ccc; font-size: 10px;")
layout.addWidget(game)
# Sizes (Download, Install, Total)
if self.metadata.sizes:
size_info = QLabel(
f"Download: {self.metadata.sizes.downloadSizeFormatted} | "
f"Install: {self.metadata.sizes.installSizeFormatted} | "
f"Total: {self.metadata.sizes.totalSizeFormatted}"
)
size_info.setStyleSheet("color: #999; font-size: 10px;")
size_info.setWordWrap(True) # Allow wrapping if text is too long
layout.addWidget(size_info)
# Removed addStretch() to eliminate wasted space
self.setLayout(layout)
# Load image
self._load_image()
def _create_placeholder(self):
"""Create a placeholder pixmap for cards without images"""
# Create placeholder matching the image label size (Steam Deck or standard)
image_size = self.image_label.size()
placeholder = QPixmap(image_size)
placeholder.fill(QColor("#333"))
# Draw a simple icon/text on the placeholder
painter = QPainter(placeholder)
painter.setPen(QColor("#666"))
painter.setFont(QFont("Sans", 10))
painter.drawText(placeholder.rect(), Qt.AlignCenter, "No Image")
painter.end()
# Show placeholder immediately
self.image_label.setPixmap(placeholder)
def _load_image(self):
"""Load image using centralized image manager - use large images and scale down for quality"""
# Get large image for card - scale down for better quality than small images
pixmap = self.image_manager.get_image(self.metadata, self._on_image_loaded, size="large")
if pixmap and not pixmap.isNull():
# Image was in cache - display immediately (should be instant)
self._display_image(pixmap)
else:
# Image needs to be downloaded - show placeholder
self._create_placeholder()
def _on_image_loaded(self, pixmap: QPixmap):
"""Callback when image is loaded from network"""
if pixmap and not pixmap.isNull():
self._display_image(pixmap)
def _display_image(self, pixmap: QPixmap):
"""Display image - use best method based on aspect ratio"""
if pixmap.isNull():
return
label_size = self.image_label.size()
label_aspect = label_size.width() / label_size.height() # 16:9 = ~1.778
# Calculate image aspect ratio
image_aspect = pixmap.width() / pixmap.height() if pixmap.height() > 0 else label_aspect
# If aspect ratios are close (within 5%), use Qt's automatic scaling for best quality
# Otherwise, manually scale with cropping to avoid stretching
aspect_diff = abs(image_aspect - label_aspect) / label_aspect
if aspect_diff < 0.05: # Within 5% of 16:9
# Close to correct aspect - use Qt's automatic scaling (best quality)
self.image_label.setScaledContents(True)
self.image_label.setPixmap(pixmap)
else:
# Different aspect - manually scale with cropping (no stretching)
self.image_label.setScaledContents(False)
scaled_pixmap = pixmap.scaled(
label_size.width(),
label_size.height(),
Qt.KeepAspectRatioByExpanding, # Crop instead of stretch
Qt.SmoothTransformation # High quality
)
self.image_label.setPixmap(scaled_pixmap)
def _update_availability_badge(self):
"""Update unavailable badge visibility based on current availability status"""
is_unavailable = not self.metadata.is_available()
# Find title row layout (it's the 2nd layout item: image at 0, title_row at 1)
main_layout = self.layout()
if main_layout and main_layout.count() >= 2:
title_row = main_layout.itemAt(1).layout()
if title_row:
if is_unavailable and self.unavailable_badge is None:
# Need to add badge to title row (before Official/NSFW badges)
self.unavailable_badge = QLabel("UNAVAILABLE")
self.unavailable_badge.setStyleSheet("background: #666; color: white; padding: 2px 6px; font-size: 9px; border-radius: 3px;")
self.unavailable_badge.setFixedHeight(20)
# Insert after title (index 1) but before other badges
# Find first badge position (if any exist)
insert_index = 1 # After title widget
for i in range(title_row.count()):
item = title_row.itemAt(i)
if item and item.widget() and isinstance(item.widget(), QLabel):
widget_text = item.widget().text()
if widget_text in ("OFFICIAL", "NSFW"):
insert_index = i
break
title_row.insertWidget(insert_index, self.unavailable_badge, alignment=Qt.AlignTop | Qt.AlignRight)
elif not is_unavailable and self.unavailable_badge is not None:
# Need to remove badge from title row
title_row.removeWidget(self.unavailable_badge)
self.unavailable_badge.setParent(None)
self.unavailable_badge = None
def mousePressEvent(self, event):
"""Handle click on card"""
if event.button() == Qt.LeftButton:
self.clicked.emit(self.metadata)
super().mousePressEvent(event)