mirror of
https://github.com/Omni-guides/Jackify.git
synced 2026-01-17 19:47:00 +01:00
Sync from development - prepare for v0.2.0.1
This commit is contained in:
@@ -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."""
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"""
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user