"""Detailed view of a modlist with install option.""" from PySide6.QtWidgets import ( QDialog, QWidget, QVBoxLayout, QHBoxLayout, QLabel, QPushButton, QFrame, QTextEdit, QSizePolicy ) from PySide6.QtCore import Qt, Signal, QSize from PySide6.QtGui import QPixmap, QFont, QPainter, QColor from jackify.backend.models.modlist_metadata import ModlistMetadata from ..shared_theme import JACKIFY_COLOR_BLUE from ..utils import get_screen_geometry, set_responsive_minimum from .modlist_gallery_image_manager import ImageManager class ModlistDetailDialog(QDialog): """Detailed view of a modlist with install option""" install_requested = Signal(ModlistMetadata) def __init__(self, metadata: ModlistMetadata, image_manager: ImageManager, parent=None): super().__init__(parent) self.metadata = metadata self.image_manager = image_manager self.setWindowTitle(metadata.title) set_responsive_minimum(self, min_width=900, min_height=640) self._apply_initial_size() self._setup_ui() def _apply_initial_size(self): """Ensure dialog size fits current screen.""" _, _, screen_width, screen_height = get_screen_geometry(self) width = 1000 height = 760 if screen_width: width = min(width, max(880, screen_width - 40)) if screen_height: height = min(height, max(640, screen_height - 40)) self.resize(width, height) def _setup_ui(self): """Set up detail dialog UI with modern layout matching Wabbajack style""" main_layout = QVBoxLayout() main_layout.setContentsMargins(0, 0, 0, 0) main_layout.setSpacing(0) # --- Banner area with full-width text overlay --- # Container so we can place a semi-opaque text panel over the banner image banner_container = QFrame() banner_container.setFrameShape(QFrame.NoFrame) banner_container.setStyleSheet("background: #000; border: none;") banner_layout = QVBoxLayout() banner_layout.setContentsMargins(0, 0, 0, 0) banner_layout.setSpacing(0) banner_container.setLayout(banner_layout) # Banner image at top with 16:9 aspect ratio (like Wabbajack) self.banner_label = QLabel() # Height will be calculated based on width to maintain 16:9 ratio self.banner_label.setMinimumHeight(200) self.banner_label.setStyleSheet("background: #1a1a1a; border: none;") self.banner_label.setAlignment(Qt.AlignCenter) self.banner_label.setText("Loading image...") banner_layout.addWidget(self.banner_label) # Full-width transparent container with opaque card inside (only as wide as text) overlay_container = QWidget() overlay_container.setStyleSheet("background: transparent;") overlay_layout = QHBoxLayout() overlay_layout.setContentsMargins(24, 0, 24, 24) overlay_layout.setSpacing(0) overlay_container.setLayout(overlay_layout) # Opaque text card - only as wide as content needs (where red lines are) self.banner_text_panel = QFrame() self.banner_text_panel.setFrameShape(QFrame.StyledPanel) # Opaque background, rounded corners, sized to content only self.banner_text_panel.setStyleSheet(""" QFrame { background-color: rgba(0, 0, 0, 180); border: 1px solid rgba(255, 255, 255, 30); border-radius: 8px; } """) self.banner_text_panel.setSizePolicy(QSizePolicy.Maximum, QSizePolicy.Maximum) banner_text_layout = QVBoxLayout() banner_text_layout.setContentsMargins(20, 12, 20, 14) banner_text_layout.setSpacing(6) self.banner_text_panel.setLayout(banner_text_layout) # Add card to container (left-aligned, rest stays transparent) overlay_layout.addWidget(self.banner_text_panel, alignment=Qt.AlignBottom | Qt.AlignLeft) overlay_layout.addStretch() # Push card left, rest transparent # Title only (badges moved to tags section below) title = QLabel(self.metadata.title) title.setFont(QFont("Sans", 24, QFont.Bold)) title.setStyleSheet(f"color: {JACKIFY_COLOR_BLUE};") title.setWordWrap(True) banner_text_layout.addWidget(title) # Only sizes in overlay (minimal info on image) if self.metadata.sizes: sizes_text = ( f"Download: {self.metadata.sizes.downloadSizeFormatted} • " f"Install: {self.metadata.sizes.installSizeFormatted} • " f"Total: {self.metadata.sizes.totalSizeFormatted}" ) sizes_label = QLabel(sizes_text) sizes_label.setStyleSheet("color: #fff; font-size: 13px;") banner_text_layout.addWidget(sizes_label) # Add full-width transparent container at bottom of banner banner_layout.addWidget(overlay_container, alignment=Qt.AlignBottom) main_layout.addWidget(banner_container) # Content area with padding (tags + description + bottom bar) content_widget = QWidget() content_layout = QVBoxLayout() content_layout.setContentsMargins(24, 20, 24, 20) content_layout.setSpacing(16) content_widget.setLayout(content_layout) # Metadata line (version, author, game) - moved below image metadata_line_parts = [] if self.metadata.version: metadata_line_parts.append(f"version {self.metadata.version}") metadata_line_parts.append(f"by {self.metadata.author}") metadata_line_parts.append(f" {self.metadata.gameHumanFriendly}") if self.metadata.maintainers and len(self.metadata.maintainers) > 0: maintainers_text = ", ".join(self.metadata.maintainers) if maintainers_text != self.metadata.author: # Only show if different from author metadata_line_parts.append(f" Maintained by {maintainers_text}") metadata_line = QLabel(" ".join(metadata_line_parts)) metadata_line.setStyleSheet("color: #fff; font-size: 14px;") metadata_line.setWordWrap(True) content_layout.addWidget(metadata_line) # Tags row (includes status badges moved from overlay) tags_layout = QHBoxLayout() tags_layout.setSpacing(6) tags_layout.setContentsMargins(0, 0, 0, 0) # Add status badges first (UNAVAILABLE, Unofficial) if not self.metadata.is_available(): unavailable_badge = QLabel("UNAVAILABLE") unavailable_badge.setStyleSheet("background: #666; color: white; padding: 6px 12px; font-size: 11px; border-radius: 4px;") tags_layout.addWidget(unavailable_badge) if not self.metadata.official: unofficial_badge = QLabel("Unofficial") unofficial_badge.setStyleSheet("background: #666; color: white; padding: 6px 12px; font-size: 11px; border-radius: 4px;") tags_layout.addWidget(unofficial_badge) # Add regular tags tags_to_render = getattr(self.metadata, 'normalized_tags_display', self.metadata.tags or []) if tags_to_render: for tag in tags_to_render: tag_badge = QLabel(tag) # Match Wabbajack tag styling if tag.lower() == "nsfw": tag_badge.setStyleSheet("background: #d44; color: white; padding: 6px 12px; font-size: 11px; border-radius: 4px;") elif tag.lower() == "official" or tag.lower() == "featured": tag_badge.setStyleSheet("background: #2a5; color: white; padding: 6px 12px; font-size: 11px; border-radius: 4px;") else: tag_badge.setStyleSheet("background: #3a3a3a; color: #ccc; padding: 6px 12px; font-size: 11px; border-radius: 4px;") tags_layout.addWidget(tag_badge) tags_layout.addStretch() content_layout.addLayout(tags_layout) # Description section desc_label = QLabel("Description:") content_layout.addWidget(desc_label) # Use QTextEdit with explicit line counting to force scrollbar self.desc_text = QTextEdit() self.desc_text.setReadOnly(True) self.desc_text.setPlainText(self.metadata.description or "No description provided.") # Compact description area; scroll when content is long self.desc_text.setFixedHeight(120) self.desc_text.setVerticalScrollBarPolicy(Qt.ScrollBarAsNeeded) self.desc_text.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) self.desc_text.setLineWrapMode(QTextEdit.WidgetWidth) self.desc_text.setStyleSheet(""" QTextEdit { background: #2a2a2a; color: #fff; border: none; border-radius: 6px; padding: 12px; } """) content_layout.addWidget(self.desc_text) main_layout.addWidget(content_widget) # Bottom bar with Links (left) and Action buttons (right) bottom_bar = QHBoxLayout() bottom_bar.setContentsMargins(24, 16, 24, 24) bottom_bar.setSpacing(12) # Links section on the left links_layout = QHBoxLayout() links_layout.setSpacing(10) if self.metadata.links and (self.metadata.links.discordURL or self.metadata.links.websiteURL or self.metadata.links.readme): links_label = QLabel("Links:") links_layout.addWidget(links_label) if self.metadata.links.discordURL: discord_btn = QPushButton("Discord") discord_btn.setStyleSheet(""" QPushButton { background: #5865F2; color: white; padding: 8px 16px; border: none; border-radius: 6px; font-weight: bold; font-size: 12px; } QPushButton:hover { background: #4752C4; } QPushButton:pressed { background: #3C45A5; } """) discord_btn.clicked.connect(lambda: self._open_url(self.metadata.links.discordURL)) links_layout.addWidget(discord_btn) if self.metadata.links.websiteURL: website_btn = QPushButton("Website") website_btn.setStyleSheet(""" QPushButton { background: #3a3a3a; color: white; padding: 8px 16px; border: none; border-radius: 6px; font-weight: bold; font-size: 12px; } QPushButton:hover { background: #4a4a4a; } QPushButton:pressed { background: #2a2a2a; } """) website_btn.clicked.connect(lambda: self._open_url(self.metadata.links.websiteURL)) links_layout.addWidget(website_btn) if self.metadata.links.readme: readme_btn = QPushButton("Readme") readme_btn.setStyleSheet(""" QPushButton { background: #3a3a3a; color: white; padding: 8px 16px; border: none; border-radius: 6px; font-weight: bold; font-size: 12px; } QPushButton:hover { background: #4a4a4a; } QPushButton:pressed { background: #2a2a2a; } """) readme_url = self._convert_raw_github_url(self.metadata.links.readme) readme_btn.clicked.connect(lambda: self._open_url(readme_url)) links_layout.addWidget(readme_btn) bottom_bar.addLayout(links_layout) bottom_bar.addStretch() # Action buttons on the right cancel_btn = QPushButton("Close") cancel_btn.setStyleSheet(""" QPushButton { background: #3a3a3a; color: white; padding: 8px 16px; border: none; border-radius: 6px; font-weight: bold; font-size: 12px; } QPushButton:hover { background: #4a4a4a; } QPushButton:pressed { background: #2a2a2a; } """) cancel_btn.clicked.connect(self.reject) bottom_bar.addWidget(cancel_btn) install_btn = QPushButton("Install Modlist") install_btn.setDefault(True) if not self.metadata.is_available(): install_btn.setEnabled(False) install_btn.setToolTip("This modlist is currently unavailable") install_btn.setStyleSheet(""" QPushButton { background: #555; color: #999; padding: 8px 16px; border: none; border-radius: 6px; font-weight: bold; font-size: 12px; } """) else: install_btn.setStyleSheet(f""" QPushButton {{ background: {JACKIFY_COLOR_BLUE}; color: white; padding: 8px 16px; border: none; border-radius: 6px; font-weight: bold; font-size: 12px; }} QPushButton:hover {{ background: #4a9eff; }} QPushButton:pressed {{ background: #3a8eef; }} """) install_btn.clicked.connect(self._on_install_clicked) bottom_bar.addWidget(install_btn) main_layout.addLayout(bottom_bar) self.setLayout(main_layout) # Load banner image self._load_banner_image() def _load_banner_image(self): """Load large banner image for detail view""" if not self.metadata.images or not self.metadata.images.large: self.banner_label.setText("No image available") self.banner_label.setStyleSheet("background: #1a1a1a; color: #666; border: none;") return # Try to get large image from cache or download (for detail view banner) pixmap = self.image_manager.get_image(self.metadata, self._on_banner_loaded, size="large") if pixmap and not pixmap.isNull(): # Image was in cache - display immediately self._display_banner(pixmap) else: # Show placeholder while downloading placeholder = QPixmap(self.banner_label.size()) placeholder.fill(QColor("#1a1a1a")) painter = QPainter(placeholder) painter.setPen(QColor("#666")) painter.setFont(QFont("Sans", 12)) painter.drawText(placeholder.rect(), Qt.AlignCenter, "Loading image...") painter.end() self.banner_label.setPixmap(placeholder) def _on_banner_loaded(self, pixmap: QPixmap): """Callback when banner image is loaded""" if pixmap and not pixmap.isNull(): self._display_banner(pixmap) def resizeEvent(self, event): """Handle dialog resize to maintain 16:9 aspect ratio for banner""" super().resizeEvent(event) # Update banner height to maintain 16:9 aspect ratio if hasattr(self, 'banner_label'): width = self.width() height = int(width / 16 * 9) # 16:9 aspect ratio self.banner_label.setFixedHeight(height) # Redisplay image if we have one if hasattr(self, '_current_banner_pixmap'): self._display_banner(self._current_banner_pixmap) def _display_banner(self, pixmap: QPixmap): """Display banner image with proper 16:9 aspect ratio (like Wabbajack)""" # Store pixmap for resize events self._current_banner_pixmap = pixmap # Calculate 16:9 aspect ratio height width = self.width() if self.width() > 0 else 1000 target_height = int(width / 16 * 9) self.banner_label.setFixedHeight(target_height) # Scale image to fill width while maintaining aspect ratio (UniformToFill behavior) # Crops if needed, no stretch scaled_pixmap = pixmap.scaled( width, target_height, Qt.KeepAspectRatioByExpanding, # Fill the area, cropping if needed Qt.SmoothTransformation ) self.banner_label.setPixmap(scaled_pixmap) self.banner_label.setText("") def _convert_raw_github_url(self, url: str) -> str: """Convert raw GitHub URLs to rendered blob URLs for better user experience""" if not url: return url if "raw.githubusercontent.com" in url: url = url.replace("raw.githubusercontent.com", "github.com") url = url.replace("/master/", "/blob/master/") url = url.replace("/main/", "/blob/main/") return url def _on_install_clicked(self): """Handle install button click""" self.install_requested.emit(self.metadata) self.accept() def _open_url(self, url: str): """Open URL with clean environment to avoid AppImage library conflicts.""" import subprocess import os env = os.environ.copy() # Remove AppImage-specific environment variables appimage_vars = [ 'LD_LIBRARY_PATH', 'PYTHONPATH', 'PYTHONHOME', 'QT_PLUGIN_PATH', 'QML2_IMPORT_PATH', ] if 'APPIMAGE' in env or 'APPDIR' in env: for var in appimage_vars: if var in env: del env[var] subprocess.Popen( ['xdg-open', url], env=env, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, start_new_session=True )