Sync from development - prepare for v0.2.0.1

This commit is contained in:
Omni
2025-12-19 19:42:31 +00:00
parent e3dc62fdac
commit 9c52c0434b
57 changed files with 786 additions and 395 deletions

View File

@@ -379,18 +379,44 @@ Python: {platform.python_version()}"""
def open_github(self):
"""Open GitHub repository."""
try:
import webbrowser
webbrowser.open("https://github.com/Omni-guides/Jackify")
self._open_url("https://github.com/Omni-guides/Jackify")
except Exception as e:
logger.error(f"Error opening GitHub: {e}")
def open_nexus(self):
"""Open Nexus Mods page."""
try:
import webbrowser
webbrowser.open("https://www.nexusmods.com/site/mods/1427")
self._open_url("https://www.nexusmods.com/site/mods/1427")
except Exception as e:
logger.error(f"Error opening Nexus: {e}")
def _open_url(self, url: str):
"""Open URL with clean environment to avoid AppImage library conflicts."""
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
)
def closeEvent(self, event):
"""Handle dialog close event."""

View File

@@ -101,7 +101,7 @@ src_dir = Path(__file__).parent.parent.parent.parent
sys.path.insert(0, str(src_dir))
from PySide6.QtWidgets import (
QSizePolicy,
QSizePolicy, QScrollArea,
QApplication, QMainWindow, QWidget, QLabel, QVBoxLayout, QPushButton,
QStackedWidget, QHBoxLayout, QDialog, QFormLayout, QLineEdit, QCheckBox, QSpinBox, QMessageBox, QGroupBox, QGridLayout, QFileDialog, QToolButton, QStyle, QComboBox, QTabWidget, QRadioButton, QButtonGroup
)
@@ -171,8 +171,8 @@ class SettingsDialog(QDialog):
self._original_debug_mode = self.config_handler.get('debug_mode', False)
self.setWindowTitle("Settings")
self.setModal(True)
self.setMinimumWidth(650) # Reduced width for Steam Deck compatibility
self.setMaximumWidth(800) # Maximum width to prevent excessive stretching
self.setMinimumWidth(650)
self.setMaximumWidth(800)
self.setStyleSheet("QDialog { background-color: #232323; color: #eee; } QPushButton:hover { background-color: #333; }")
main_layout = QVBoxLayout()
@@ -420,69 +420,119 @@ class SettingsDialog(QDialog):
resource_group = QGroupBox("Resource Limits")
resource_group.setStyleSheet("QGroupBox { border: 1px solid #555; border-radius: 6px; margin-top: 8px; padding: 8px; background: #23282d; } QGroupBox:title { subcontrol-origin: margin; left: 10px; padding: 0 3px 0 3px; font-weight: bold; color: #fff; }")
resource_layout = QGridLayout()
resource_group.setLayout(resource_layout)
resource_layout.setVerticalSpacing(4)
resource_layout.setHorizontalSpacing(8)
resource_layout.addWidget(self._bold_label("Resource"), 0, 0, 1, 1, Qt.AlignLeft)
resource_layout.addWidget(self._bold_label("Max Tasks"), 0, 1, 1, 1, Qt.AlignLeft)
resource_outer_layout = QVBoxLayout()
resource_group.setLayout(resource_outer_layout)
self.resource_settings_path = os.path.expanduser("~/.config/jackify/resource_settings.json")
self.resource_settings = self._load_json(self.resource_settings_path)
self.resource_edits = {}
resource_row_index = 0
for resource_row_index, (k, v) in enumerate(self.resource_settings.items(), start=1):
try:
# Create resource label
resource_layout.addWidget(QLabel(f"{k}:", parent=self), resource_row_index, 0, 1, 1, Qt.AlignLeft)
max_tasks_spin = QSpinBox()
max_tasks_spin.setMinimum(1)
max_tasks_spin.setMaximum(128)
max_tasks_spin.setValue(v.get('MaxTasks', 16))
max_tasks_spin.setToolTip("Maximum number of concurrent tasks for this resource.")
max_tasks_spin.setFixedWidth(160)
resource_layout.addWidget(max_tasks_spin, resource_row_index, 1)
# Store the widgets
self.resource_edits[k] = (None, max_tasks_spin)
except Exception as e:
print(f"[ERROR] Failed to create widgets for resource '{k}': {e}")
continue
# If no resources exist, show helpful message
if not self.resource_edits:
if not self.resource_settings:
info_label = QLabel("Resource Limit settings will be generated once a modlist install action is performed")
info_label.setStyleSheet("color: #aaa; font-style: italic; padding: 20px; font-size: 11pt;")
info_label.setWordWrap(True)
info_label.setAlignment(Qt.AlignCenter)
info_label.setMinimumHeight(60) # Ensure enough height to prevent cutoff
resource_layout.addWidget(info_label, 1, 0, 3, 2) # Span more rows for better space
# Bandwidth limiter row (only show if Downloads resource exists)
if "Downloads" in self.resource_settings:
downloads_throughput_bytes = self.resource_settings["Downloads"].get("MaxThroughput", 0)
# Convert bytes/s to KB/s for display
downloads_throughput_kb = downloads_throughput_bytes // 1024 if downloads_throughput_bytes > 0 else 0
self.bandwidth_spin = QSpinBox()
self.bandwidth_spin.setMinimum(0)
self.bandwidth_spin.setMaximum(1000000)
self.bandwidth_spin.setValue(downloads_throughput_kb)
self.bandwidth_spin.setSuffix(" KB/s")
self.bandwidth_spin.setFixedWidth(160)
self.bandwidth_spin.setToolTip("Set the maximum download speed for modlist downloads. 0 = unlimited.")
bandwidth_note = QLabel("(0 = unlimited)")
bandwidth_note.setStyleSheet("color: #aaa; font-size: 10pt;")
# Create horizontal layout for bandwidth row
bandwidth_row = QHBoxLayout()
bandwidth_row.addWidget(self.bandwidth_spin)
bandwidth_row.addWidget(bandwidth_note)
bandwidth_row.addStretch() # Push to the left
resource_layout.addWidget(QLabel("Bandwidth Limit:", parent=self), resource_row_index+1, 0, 1, 1, Qt.AlignLeft)
resource_layout.addLayout(bandwidth_row, resource_row_index+1, 1)
info_label.setMinimumHeight(60)
resource_outer_layout.addWidget(info_label)
else:
self.bandwidth_spin = None # No bandwidth UI if Downloads resource doesn't exist
# Two-column layout for better space usage
# Use a single grid with proper column spacing
resource_grid = QGridLayout()
resource_grid.setVerticalSpacing(4)
resource_grid.setHorizontalSpacing(8)
resource_grid.setColumnMinimumWidth(2, 40) # Spacing between columns
# Headers for left column (columns 0-1)
resource_grid.addWidget(self._bold_label("Resource"), 0, 0, 1, 1, Qt.AlignLeft)
resource_grid.addWidget(self._bold_label("Max Tasks"), 0, 1, 1, 1, Qt.AlignLeft)
# Headers for right column (columns 3-4, skip column 2 for spacing)
resource_grid.addWidget(self._bold_label("Resource"), 0, 3, 1, 1, Qt.AlignLeft)
resource_grid.addWidget(self._bold_label("Max Tasks"), 0, 4, 1, 1, Qt.AlignLeft)
# Split resources between left and right columns (4 + 4)
resource_items = list(self.resource_settings.items())
# Find Bandwidth info from Downloads resource if it exists
bandwidth_kb = 0
if "Downloads" in self.resource_settings:
downloads_throughput_bytes = self.resource_settings["Downloads"].get("MaxThroughput", 0)
bandwidth_kb = downloads_throughput_bytes // 1024 if downloads_throughput_bytes > 0 else 0
# Left column gets first 4 resources (columns 0-1)
left_row = 1
for k, v in resource_items[:4]:
try:
resource_grid.addWidget(QLabel(f"{k}:", parent=self), left_row, 0, 1, 1, Qt.AlignLeft)
max_tasks_spin = QSpinBox()
max_tasks_spin.setMinimum(1)
max_tasks_spin.setMaximum(128)
max_tasks_spin.setValue(v.get('MaxTasks', 16))
max_tasks_spin.setToolTip("Maximum number of concurrent tasks for this resource.")
max_tasks_spin.setFixedWidth(100)
resource_grid.addWidget(max_tasks_spin, left_row, 1)
self.resource_edits[k] = (None, max_tasks_spin)
left_row += 1
except Exception as e:
print(f"[ERROR] Failed to create widgets for resource '{k}': {e}")
continue
# Right column gets next 4 resources (columns 3-4, skip column 2 for spacing)
right_row = 1
for k, v in resource_items[4:]:
try:
resource_grid.addWidget(QLabel(f"{k}:", parent=self), right_row, 3, 1, 1, Qt.AlignLeft)
max_tasks_spin = QSpinBox()
max_tasks_spin.setMinimum(1)
max_tasks_spin.setMaximum(128)
max_tasks_spin.setValue(v.get('MaxTasks', 16))
max_tasks_spin.setToolTip("Maximum number of concurrent tasks for this resource.")
max_tasks_spin.setFixedWidth(100)
resource_grid.addWidget(max_tasks_spin, right_row, 4)
self.resource_edits[k] = (None, max_tasks_spin)
right_row += 1
except Exception as e:
print(f"[ERROR] Failed to create widgets for resource '{k}': {e}")
continue
# Add Bandwidth Limit at the bottom of right column
if "Downloads" in self.resource_settings:
resource_grid.addWidget(QLabel("Bandwidth Limit:", parent=self), right_row, 3, 1, 1, Qt.AlignLeft)
self.bandwidth_spin = QSpinBox()
self.bandwidth_spin.setMinimum(0)
self.bandwidth_spin.setMaximum(1000000)
self.bandwidth_spin.setValue(bandwidth_kb)
self.bandwidth_spin.setSuffix(" KB/s")
self.bandwidth_spin.setFixedWidth(100)
self.bandwidth_spin.setToolTip("Set the maximum download speed for modlist downloads. 0 = unlimited.")
# Create a layout for the spinbox and note
bandwidth_widget_layout = QHBoxLayout()
bandwidth_widget_layout.setContentsMargins(0, 0, 0, 0)
bandwidth_widget_layout.addWidget(self.bandwidth_spin)
bandwidth_note = QLabel("(0 = unlimited)")
bandwidth_note.setStyleSheet("color: #aaa; font-size: 9pt;")
bandwidth_widget_layout.addWidget(bandwidth_note)
bandwidth_widget_layout.addStretch()
# Create container widget for the layout
bandwidth_container = QWidget()
bandwidth_container.setLayout(bandwidth_widget_layout)
resource_grid.addWidget(bandwidth_container, right_row, 4, 1, 1, Qt.AlignLeft)
else:
self.bandwidth_spin = None
# Add stretch column at the end to push content left
resource_grid.setColumnStretch(5, 1)
resource_outer_layout.addLayout(resource_grid)
advanced_layout.addWidget(resource_group)
@@ -1358,10 +1408,11 @@ class JackifyMainWindow(QMainWindow):
bottom_bar_layout.addStretch(1)
# Ko-Fi support link (center)
kofi_link = QLabel('<a href="https://ko-fi.com/omni1" style="color:#72A5F2; text-decoration:none;">♥ Support on Ko-fi</a>')
kofi_link = QLabel('<a href="#" style="color:#72A5F2; text-decoration:none;">♥ Support on Ko-fi</a>')
kofi_link.setStyleSheet("color: #72A5F2; font-size: 13px;")
kofi_link.setTextInteractionFlags(Qt.TextBrowserInteraction)
kofi_link.setOpenExternalLinks(True)
kofi_link.setOpenExternalLinks(False)
kofi_link.linkActivated.connect(lambda: self._open_url("https://ko-fi.com/omni1"))
kofi_link.setToolTip("Support Jackify development")
bottom_bar_layout.addWidget(kofi_link, alignment=Qt.AlignCenter)
@@ -1629,6 +1680,35 @@ class JackifyMainWindow(QMainWindow):
import traceback
traceback.print_exc()
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
)
def _on_child_resize_request(self, mode: str):
"""
Handle child screen resize requests (expand/collapse console).
@@ -1761,6 +1841,20 @@ def resource_path(relative_path):
def main():
"""Main entry point for the GUI application"""
# CRITICAL: Enable faulthandler for segfault debugging
# This will print Python stack traces on segfault
import faulthandler
import signal
# Enable faulthandler to both stderr and file
try:
log_dir = Path.home() / '.local' / 'share' / 'jackify' / 'logs'
log_dir.mkdir(parents=True, exist_ok=True)
trace_file = open(log_dir / 'segfault_trace.txt', 'w')
faulthandler.enable(file=trace_file, all_threads=True)
except Exception:
# Fallback to stderr only if file can't be opened
faulthandler.enable(all_threads=True)
# Check for CLI mode argument
if len(sys.argv) > 1 and '--cli' in sys.argv:
# Launch CLI frontend instead of GUI

View File

@@ -1,7 +1,7 @@
"""
InstallModlistScreen for Jackify GUI
"""
from PySide6.QtWidgets import QWidget, QVBoxLayout, QLabel, QComboBox, QHBoxLayout, QLineEdit, QPushButton, QGridLayout, QFileDialog, QTextEdit, QSizePolicy, QTabWidget, QDialog, QListWidget, QListWidgetItem, QMessageBox, QProgressDialog, QApplication, QCheckBox, QStyledItemDelegate, QStyle, QTableWidget, QTableWidgetItem, QHeaderView
from PySide6.QtWidgets import QWidget, QVBoxLayout, QLabel, QComboBox, QHBoxLayout, QLineEdit, QPushButton, QGridLayout, QFileDialog, QTextEdit, QSizePolicy, QTabWidget, QDialog, QListWidget, QListWidgetItem, QMessageBox, QProgressDialog, QApplication, QCheckBox, QStyledItemDelegate, QStyle, QTableWidget, QTableWidgetItem, QHeaderView, QMainWindow
from PySide6.QtCore import Qt, QSize, QThread, Signal, QTimer, QProcess, QMetaObject, QUrl
from PySide6.QtGui import QPixmap, QTextCursor, QColor, QPainter, QFont
from ..shared_theme import JACKIFY_COLOR_BLUE, DEBUG_BORDERS
@@ -2533,10 +2533,34 @@ class InstallModlistScreen(QWidget):
debug_print(f"DEBUG: - {fp.filename}: {fp.percent:.1f}% ({fp.operation.value})")
# Pass phase label to update header (e.g., "[Activity - Downloading]")
# Explicitly clear summary_info when showing file list
self.file_progress_list.update_files(progress_state.active_files, current_phase=phase_label, summary_info=None)
try:
self.file_progress_list.update_files(progress_state.active_files, current_phase=phase_label, summary_info=None)
except RuntimeError as e:
# Widget was deleted - ignore to prevent coredump
if "already deleted" in str(e):
if self.debug:
debug_print(f"DEBUG: Ignoring widget deletion error: {e}")
return
raise
except Exception as e:
# Catch any other exceptions to prevent coredump
if self.debug:
debug_print(f"DEBUG: Error updating file progress list: {e}")
import logging
logging.getLogger(__name__).error(f"Error updating file progress list: {e}", exc_info=True)
else:
# Show empty state so widget stays visible even when no files are active
self.file_progress_list.update_files([], current_phase=phase_label)
try:
self.file_progress_list.update_files([], current_phase=phase_label)
except RuntimeError as e:
# Widget was deleted - ignore to prevent coredump
if "already deleted" in str(e):
return
raise
except Exception as e:
# Catch any other exceptions to prevent coredump
import logging
logging.getLogger(__name__).error(f"Error updating file progress list: {e}", exc_info=True)
def _on_show_details_toggled(self, checked: bool):
"""R&D: Toggle console visibility (reuse TTW pattern)"""
@@ -2996,19 +3020,24 @@ class InstallModlistScreen(QWidget):
normalized = text.lower()
total = max(1, self._post_install_total_steps)
matched = False
matched_step = None
for idx, step in enumerate(self._post_install_sequence, start=1):
if any(keyword in normalized for keyword in step['keywords']):
matched = True
matched_step = idx
# Always update to the highest step we've seen (don't go backwards)
if idx >= self._post_install_current_step:
self._post_install_current_step = idx
self._post_install_last_label = step['label']
self._update_post_install_ui(step['label'], idx, total, detail=text)
else:
self._update_post_install_ui(step['label'], idx, total, detail=text)
# CRITICAL: Always use the current step (not the matched step) to ensure consistency
# This prevents Activity window showing different step than progress banner
self._update_post_install_ui(step['label'], self._post_install_current_step, total, detail=text)
break
# If no match but we have a current step, update with that step (not a new one)
if not matched and self._post_install_current_step > 0:
label = self._post_install_last_label or "Post-installation"
# CRITICAL: Use _post_install_current_step (not a new step) to keep displays in sync
self._update_post_install_ui(label, self._post_install_current_step, total, detail=text)
def _strip_timestamp_prefix(self, text: str) -> str:
@@ -3020,11 +3049,14 @@ class InstallModlistScreen(QWidget):
def _update_post_install_ui(self, label: str, step: int, total: int, detail: Optional[str] = None):
"""Update progress indicator + activity summary for post-install steps."""
# Use the label as the primary display, but include step info in Activity window
display_label = label
if detail:
# Remove timestamp prefix from detail messages
clean_detail = self._strip_timestamp_prefix(detail.strip())
if clean_detail:
# For Activity window, show the detail with step counter
# But keep label simple for progress banner
if clean_detail.lower().startswith(label.lower()):
display_label = clean_detail
else:
@@ -3032,18 +3064,24 @@ class InstallModlistScreen(QWidget):
total = max(1, total)
step_clamped = max(0, min(step, total))
overall_percent = (step_clamped / total) * 100.0
# CRITICAL: Ensure both displays use the SAME step counter
# Progress banner uses phase_step/phase_max_steps from progress_state
progress_state = InstallationProgress(
phase=InstallationPhase.FINALIZE,
phase_name=display_label,
phase_step=step_clamped,
phase_name=display_label, # This will show in progress banner
phase_step=step_clamped, # This creates [step/total] in display_text
phase_max_steps=total,
overall_percent=overall_percent
)
self.progress_indicator.update_progress(progress_state)
# Activity window uses summary_info with the SAME step counter
summary_info = {
'current_step': step_clamped,
'max_steps': total,
'current_step': step_clamped, # Must match phase_step above
'max_steps': total, # Must match phase_max_steps above
}
# Use the same label for consistency
self.file_progress_list.update_files([], current_phase=display_label, summary_info=summary_info)
def _end_post_install_feedback(self, success: bool):
@@ -3263,6 +3301,52 @@ class InstallModlistScreen(QWidget):
debug_print(f"DEBUG: steam -foreground failed: {e2}")
except Exception as e:
debug_print(f"DEBUG: Error ensuring Steam GUI visibility: {e}")
# CRITICAL: Bring Jackify window back to focus after Steam restart
# This ensures the user can continue with the installation workflow
debug_print("DEBUG: Bringing Jackify window back to focus")
try:
# Get the main window - use window() to get top-level widget, then find QMainWindow
top_level = self.window()
main_window = None
# Try to find QMainWindow in the widget hierarchy
if isinstance(top_level, QMainWindow):
main_window = top_level
else:
# Walk up the parent chain
current = self
while current:
if isinstance(current, QMainWindow):
main_window = current
break
current = current.parent()
# Last resort: use top-level widget
if not main_window and top_level:
main_window = top_level
if main_window:
# Restore window if minimized
if hasattr(main_window, 'isMinimized') and main_window.isMinimized():
main_window.showNormal()
# Bring to front and activate - use multiple methods for reliability
main_window.raise_()
main_window.activateWindow()
main_window.show()
# Force focus with multiple attempts (some window managers need this)
from PySide6.QtCore import QTimer
QTimer.singleShot(50, lambda: main_window.activateWindow() if main_window else None)
QTimer.singleShot(200, lambda: (main_window.raise_(), main_window.activateWindow()) if main_window else None)
QTimer.singleShot(500, lambda: main_window.activateWindow() if main_window else None)
debug_print(f"DEBUG: Jackify window brought back to focus (type: {type(main_window).__name__})")
else:
debug_print("DEBUG: Could not find main window to bring to focus")
except Exception as e:
debug_print(f"DEBUG: Error bringing Jackify to focus: {e}")
# Save context for later use in configuration
self._manual_steps_retry_count = 0

View File

@@ -472,13 +472,15 @@ class InstallTTWScreen(QWidget):
# Check version against latest
update_available, installed_v, latest_v = ttw_installer_handler.is_ttw_installer_update_available()
if update_available:
self.ttw_installer_status.setText("Out of date")
version_text = f"Out of date (v{installed_v} → v{latest_v})" if installed_v and latest_v else "Out of date"
self.ttw_installer_status.setText(version_text)
self.ttw_installer_status.setStyleSheet("color: #f44336;")
self.ttw_installer_btn.setText("Update now")
self.ttw_installer_btn.setEnabled(True)
self.ttw_installer_btn.setVisible(True)
else:
self.ttw_installer_status.setText("Ready")
version_text = f"Ready (v{installed_v})" if installed_v else "Ready"
self.ttw_installer_status.setText(version_text)
self.ttw_installer_status.setStyleSheet("color: #3fd0ea;")
self.ttw_installer_btn.setText("Update now")
self.ttw_installer_btn.setEnabled(False) # Greyed out when ready
@@ -1418,8 +1420,11 @@ class InstallTTWScreen(QWidget):
is_warning = 'warning:' in lower_cleaned
is_milestone = any(kw in lower_cleaned for kw in ['===', 'complete', 'finished', 'validation', 'configuration valid'])
is_file_op = any(ext in lower_cleaned for ext in ['.ogg', '.mp3', '.bsa', '.dds', '.nif', '.kf', '.hkx'])
should_show = (is_error or is_warning or is_milestone) or (self.show_details_checkbox.isChecked() and not is_file_op)
# Filter out meaningless standalone messages (just "OK", etc.)
is_noise = cleaned.strip().upper() in ['OK', 'OK.', 'OK!', 'DONE', 'DONE.', 'SUCCESS', 'SUCCESS.']
should_show = (is_error or is_warning or is_milestone) or (self.show_details_checkbox.isChecked() and not is_file_op and not is_noise)
if should_show:
if is_error or is_warning:
@@ -1550,7 +1555,10 @@ class InstallTTWScreen(QWidget):
is_milestone = any(kw in lower_cleaned for kw in ['===', 'complete', 'finished', 'validation', 'configuration valid'])
is_file_op = any(ext in lower_cleaned for ext in ['.ogg', '.mp3', '.bsa', '.dds', '.nif', '.kf', '.hkx'])
should_show = (is_error or is_warning or is_milestone) or (self.show_details_checkbox.isChecked() and not is_file_op)
# Filter out meaningless standalone messages (just "OK", etc.)
is_noise = cleaned.strip().upper() in ['OK', 'OK.', 'OK!', 'DONE', 'DONE.', 'SUCCESS', 'SUCCESS.']
should_show = (is_error or is_warning or is_milestone) or (self.show_details_checkbox.isChecked() and not is_file_op and not is_noise)
if should_show:
# Direct console append - no recursion, no complex processing

View File

@@ -10,7 +10,7 @@ from PySide6.QtWidgets import (
QFrame, QSizePolicy, QDialog, QTextEdit, QTextBrowser, QMessageBox, QListWidget
)
from PySide6.QtCore import Qt, Signal, QSize, QThread, QUrl, QTimer, QObject
from PySide6.QtGui import QPixmap, QFont, QDesktopServices, QPainter, QColor, QTextOption, QPalette
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
@@ -536,7 +536,7 @@ class ModlistDetailDialog(QDialog):
background: #3C45A5;
}
""")
discord_btn.clicked.connect(lambda: QDesktopServices.openUrl(QUrl(self.metadata.links.discordURL)))
discord_btn.clicked.connect(lambda: self._open_url(self.metadata.links.discordURL))
links_layout.addWidget(discord_btn)
if self.metadata.links.websiteURL:
@@ -558,7 +558,7 @@ class ModlistDetailDialog(QDialog):
background: #2a2a2a;
}
""")
website_btn.clicked.connect(lambda: QDesktopServices.openUrl(QUrl(self.metadata.links.websiteURL)))
website_btn.clicked.connect(lambda: self._open_url(self.metadata.links.websiteURL))
links_layout.addWidget(website_btn)
if self.metadata.links.readme:
@@ -581,7 +581,7 @@ class ModlistDetailDialog(QDialog):
}
""")
readme_url = self._convert_raw_github_url(self.metadata.links.readme)
readme_btn.clicked.connect(lambda: QDesktopServices.openUrl(QUrl(readme_url)))
readme_btn.clicked.connect(lambda: self._open_url(readme_url))
links_layout.addWidget(readme_btn)
bottom_bar.addLayout(links_layout)
@@ -732,6 +732,35 @@ class ModlistDetailDialog(QDialog):
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
)
class ModlistGalleryDialog(QDialog):
"""Enhanced modlist gallery dialog with visual browsing"""

View File

@@ -105,6 +105,8 @@ class FileProgressItem(QWidget):
self.file_progress = file_progress
self._target_percent = file_progress.percent # Target value for smooth animation
self._current_display_percent = file_progress.percent # Currently displayed value
self._spinner_position = 0 # For custom indeterminate spinner animation (0-200 range for smooth wraparound)
self._is_indeterminate = False # Track if we're in indeterminate mode
self._animation_timer = QTimer(self)
self._animation_timer.timeout.connect(self._animate_progress)
self._animation_timer.setInterval(16) # ~60fps for smooth animation
@@ -239,10 +241,32 @@ class FileProgressItem(QWidget):
self.progress_bar.setRange(0, 100)
# Progress bar value will be updated by animation timer
else:
# Indeterminate if no max - use Qt's built-in smooth animation
# No max for summary - use custom animated spinner
self._is_indeterminate = True
self.percent_label.setText("")
self.speed_label.setText("")
self.progress_bar.setRange(0, 0) # Qt handles animation smoothly
self.progress_bar.setRange(0, 100) # Use determinate range for custom animation
if not self._animation_timer.isActive():
self._animation_timer.start()
return
# Check if this is a queued item (not yet started)
# Queued items have total_size > 0 but percent == 0, current_size == 0, speed <= 0
is_queued = (
self.file_progress.total_size > 0 and
self.file_progress.percent == 0 and
self.file_progress.current_size == 0 and
self.file_progress.speed <= 0
)
if is_queued:
# Queued download - show "Queued" text with empty progress bar
self._is_indeterminate = False
self._animation_timer.stop()
self.percent_label.setText("Queued")
self.speed_label.setText("")
self.progress_bar.setRange(0, 100)
self.progress_bar.setValue(0)
return
# Check if we have meaningful progress data
@@ -253,49 +277,69 @@ class FileProgressItem(QWidget):
(self.file_progress.speed > 0 and self.file_progress.percent >= 0)
)
# Use determinate mode if we have actual progress data, otherwise use Qt's indeterminate mode
# Use determinate mode if we have actual progress data, otherwise use custom animated spinner
if has_meaningful_progress:
# Normal progress mode
self._is_indeterminate = False
# Update target for smooth animation
self._target_percent = max(0, self.file_progress.percent)
# Start animation timer if not already running
if not self._animation_timer.isActive():
self._animation_timer.start()
# Update speed label immediately (doesn't need animation)
self.speed_label.setText(self.file_progress.speed_display)
self.progress_bar.setRange(0, 100)
# Progress bar value will be updated by animation timer
else:
# No progress data (e.g., texture conversions) - Qt's indeterminate mode
self._animation_timer.stop() # Stop animation for indeterminate items
self.percent_label.setText("") # No percentage
# No progress data (e.g., texture conversions, BSA building) - use custom animated spinner
self._is_indeterminate = True
self.percent_label.setText("") # Clear percent label
self.speed_label.setText("") # No speed
self.progress_bar.setRange(0, 0) # Qt handles smooth indeterminate animation
self.progress_bar.setRange(0, 100) # Use determinate range for custom animation
# Start animation timer for custom spinner
if not self._animation_timer.isActive():
self._animation_timer.start()
def _animate_progress(self):
"""Smoothly animate progress bar from current to target value."""
# Calculate difference
diff = self._target_percent - self._current_display_percent
# If very close, snap to target and stop animation
if abs(diff) < 0.1:
self._current_display_percent = self._target_percent
self._animation_timer.stop()
"""Smoothly animate progress bar from current to target value, or animate spinner."""
if self._is_indeterminate:
# Custom indeterminate spinner animation
# Use a bouncing/pulsing effect: position moves 0-100-0 smoothly
# Increment by 4 units per frame for fast animation (full cycle in ~0.8s at 60fps)
self._spinner_position = (self._spinner_position + 4) % 200
# Create bouncing effect: 0->100->0
if self._spinner_position < 100:
display_value = self._spinner_position
else:
display_value = 200 - self._spinner_position
self.progress_bar.setValue(display_value)
else:
# Smooth interpolation (ease-out for natural feel)
# Move 20% of remaining distance per frame (~60fps = smooth)
self._current_display_percent += diff * 0.2
# Update display
display_percent = max(0, min(100, self._current_display_percent))
self.progress_bar.setValue(int(display_percent))
# Update percentage label
if self.file_progress.percent > 0:
self.percent_label.setText(f"{display_percent:.0f}%")
else:
self.percent_label.setText("")
# Normal progress animation
# Calculate difference
diff = self._target_percent - self._current_display_percent
# If very close, snap to target and stop animation
if abs(diff) < 0.1:
self._current_display_percent = self._target_percent
self._animation_timer.stop()
else:
# Smooth interpolation (ease-out for natural feel)
# Move 20% of remaining distance per frame (~60fps = smooth)
self._current_display_percent += diff * 0.2
# Update display
display_percent = max(0, min(100, self._current_display_percent))
self.progress_bar.setValue(int(display_percent))
# Update percentage label
if self.file_progress.percent > 0:
self.percent_label.setText(f"{display_percent:.0f}%")
else:
self.percent_label.setText("")
def update_progress(self, file_progress: FileProgress):
"""Update with new progress data."""
@@ -558,30 +602,35 @@ class FileProgressList(QWidget):
item_key = file_progress.filename
if item_key in self._file_items:
# Update existing - ensure it's in the right position
# Update existing widget - DO NOT reorder items (causes segfaults)
# Reordering with takeItem/insertItem can delete widgets and cause crashes
# Order is less important than stability - just update the widget in place
item_widget = self._file_items[item_key]
item_widget.update_progress(file_progress)
# Find the item in the list and move it if needed
for i in range(self.list_widget.count()):
item = self.list_widget.item(i)
if item and item.data(Qt.UserRole) == item_key:
# Item is at position i, should be at position idx
if i != idx:
# Take item from current position and insert at correct position
taken_item = self.list_widget.takeItem(i)
self.list_widget.insertItem(idx, taken_item)
self.list_widget.setItemWidget(taken_item, item_widget)
break
else:
# Add new - insert at specific position (idx) to maintain order
item_widget = FileProgressItem(file_progress)
list_item = QListWidgetItem()
list_item.setSizeHint(item_widget.sizeHint())
list_item.setData(Qt.UserRole, item_key) # Use stable key
self.list_widget.insertItem(idx, list_item) # Insert at specific position
self.list_widget.setItemWidget(list_item, item_widget)
self._file_items[item_key] = item_widget
# CRITICAL: Check widget is still valid before updating
if shiboken6.isValid(item_widget):
try:
item_widget.update_progress(file_progress)
except RuntimeError:
# Widget was deleted - remove from dict and create new one below
del self._file_items[item_key]
# Fall through to create new widget
else:
# Update successful - skip creating new widget
continue
else:
# Widget invalid - remove from dict and create new one
del self._file_items[item_key]
# Fall through to create new widget
# Create new widget (either because it didn't exist or was invalid)
# CRITICAL: Use addItem instead of insertItem to avoid position conflicts
# Order is less important than stability - addItem is safer than insertItem
item_widget = FileProgressItem(file_progress)
list_item = QListWidgetItem()
list_item.setSizeHint(item_widget.sizeHint())
list_item.setData(Qt.UserRole, item_key) # Use stable key
self.list_widget.addItem(list_item) # Use addItem for safety (avoids segfaults)
self.list_widget.setItemWidget(list_item, item_widget)
self._file_items[item_key] = item_widget
# Update last phase tracker
if current_phase:

View File

@@ -117,8 +117,9 @@ class OverallProgressIndicator(QWidget):
from jackify.shared.progress_models import InstallationPhase
is_bsa_building = progress.get_phase_label() == "Building BSAs"
# For install/extract/BSA building phases, prefer step-based progress (more accurate)
if progress.phase in (InstallationPhase.INSTALL, InstallationPhase.EXTRACT) or is_bsa_building:
# For install/extract/download/BSA building phases, prefer step-based progress (more accurate)
# This prevents carrying over 100% from previous phases (e.g., .wabbajack download)
if progress.phase in (InstallationPhase.INSTALL, InstallationPhase.EXTRACT, InstallationPhase.DOWNLOAD) or is_bsa_building:
if progress.phase_max_steps > 0:
display_percent = (progress.phase_step / progress.phase_max_steps) * 100.0
elif progress.data_total > 0 and progress.data_processed > 0: