mirror of
https://github.com/Omni-guides/Jackify.git
synced 2026-06-08 00:17:58 +02:00
452 lines
19 KiB
Python
452 lines
19 KiB
Python
"""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"<span style='color: #aaa;'>Download:</span> {self.metadata.sizes.downloadSizeFormatted} • "
|
|
f"<span style='color: #aaa;'>Install:</span> {self.metadata.sizes.installSizeFormatted} • "
|
|
f"<span style='color: #aaa;'>Total:</span> {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"<span style='color: #aaa;'>version</span> {self.metadata.version}")
|
|
metadata_line_parts.append(f"<span style='color: #aaa;'>by</span> {self.metadata.author}")
|
|
metadata_line_parts.append(f"<span style='color: #aaa;'>•</span> {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"<span style='color: #aaa;'>•</span> 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("<b style='color: #aaa; font-size: 14px;'>Description:</b>")
|
|
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("<b style='color: #aaa; font-size: 14px;'>Links:</b>")
|
|
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
|
|
)
|