Sync from development - prepare for v0.2.0

This commit is contained in:
Omni
2025-12-06 20:09:55 +00:00
parent fe14e4ecfb
commit ce969eba1b
277 changed files with 14059 additions and 3899 deletions

View File

@@ -3,7 +3,11 @@ from PySide6.QtWidgets import *
from PySide6.QtCore import *
from PySide6.QtGui import *
from ..shared_theme import JACKIFY_COLOR_BLUE, DEBUG_BORDERS
from ..utils import ansi_to_html
from ..utils import ansi_to_html, set_responsive_minimum
# Progress reporting components
from jackify.frontends.gui.widgets.progress_indicator import OverallProgressIndicator
from jackify.frontends.gui.widgets.file_progress_list import FileProgressList
from jackify.shared.progress_models import InstallationPhase, InstallationProgress
import os
import subprocess
import sys
@@ -50,25 +54,23 @@ class ConfigureExistingModlistScreen(QWidget):
self.resolution_service = ResolutionService()
self.config_handler = ConfigHandler()
# --- Fetch shortcuts for ModOrganizer.exe using existing backend functionality ---
# Use existing discover_executable_shortcuts which already filters by protontricks availability
from jackify.backend.handlers.modlist_handler import ModlistHandler
# Initialize modlist handler with empty config dict to use default initialization
modlist_handler = ModlistHandler({})
discovered_modlists = modlist_handler.discover_executable_shortcuts("ModOrganizer.exe")
# Convert to shortcut_handler format for UI compatibility
# --- Fetch shortcuts for ModOrganizer.exe - deferred to showEvent to avoid blocking init ---
# Initialize empty list, will be populated when screen is shown
self.mo2_shortcuts = []
for modlist in discovered_modlists:
# Convert discovered modlist format to shortcut format
shortcut = {
'AppName': modlist.get('name', 'Unknown'),
'AppID': modlist.get('appid', ''),
'StartDir': modlist.get('path', ''),
'Exe': f"{modlist.get('path', '')}/ModOrganizer.exe"
}
self.mo2_shortcuts.append(shortcut)
self._shortcuts_loaded = False
self._shortcut_loader = None # Thread for async shortcut loading
# Initialize progress reporting components
self.progress_indicator = OverallProgressIndicator(show_progress_bar=True)
self.progress_indicator.set_status("Ready to configure", 0)
self.file_progress_list = FileProgressList()
# Create "Show Details" checkbox
self.show_details_checkbox = QCheckBox("Show details")
self.show_details_checkbox.setChecked(False) # Start collapsed
self.show_details_checkbox.setToolTip("Toggle between activity summary and detailed console output")
self.show_details_checkbox.toggled.connect(self._on_show_details_toggled)
# --- UI Layout ---
main_overall_vbox = QVBoxLayout(self)
main_overall_vbox.setAlignment(Qt.AlignTop | Qt.AlignHCenter)
@@ -211,77 +213,104 @@ class ConfigureExistingModlistScreen(QWidget):
self.start_btn = QPushButton("Start Configuration")
btn_row.addWidget(self.start_btn)
cancel_btn = QPushButton("Cancel")
cancel_btn.clicked.connect(self.go_back)
cancel_btn.clicked.connect(self.cancel_and_cleanup)
btn_row.addWidget(cancel_btn)
user_config_widget = QWidget()
user_config_widget.setLayout(user_config_vbox)
if self.debug:
user_config_widget.setStyleSheet("border: 2px solid orange;")
user_config_widget.setToolTip("USER_CONFIG_WIDGET")
# Right: Activity window (FileProgressList widget)
# Fixed size policy to prevent shrinking when window expands
self.file_progress_list.setMinimumSize(QSize(300, 20))
self.file_progress_list.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
activity_widget = QWidget()
activity_layout = QVBoxLayout()
activity_layout.setContentsMargins(0, 0, 0, 0)
activity_layout.setSpacing(0)
activity_layout.addWidget(self.file_progress_list)
activity_widget.setLayout(activity_layout)
if self.debug:
activity_widget.setStyleSheet("border: 2px solid purple;")
activity_widget.setToolTip("ACTIVITY_WINDOW")
upper_hbox.addWidget(user_config_widget, stretch=11)
upper_hbox.addWidget(activity_widget, stretch=9)
# Keep legacy process monitor hidden (for compatibility with existing code)
self.process_monitor = QTextEdit()
self.process_monitor.setReadOnly(True)
self.process_monitor.setTextInteractionFlags(Qt.TextSelectableByMouse | Qt.TextSelectableByKeyboard)
self.process_monitor.setMinimumSize(QSize(300, 20))
self.process_monitor.setStyleSheet(f"background: #222; color: {JACKIFY_COLOR_BLUE}; font-family: monospace; font-size: 11px; border: 1px solid #444;")
self.process_monitor_heading = QLabel("<b>[Process Monitor]</b>")
self.process_monitor_heading.setStyleSheet(f"color: {JACKIFY_COLOR_BLUE}; font-size: 13px; margin-bottom: 2px;")
self.process_monitor_heading.setAlignment(Qt.AlignLeft | Qt.AlignVCenter)
process_vbox = QVBoxLayout()
process_vbox.setContentsMargins(0, 0, 0, 0)
process_vbox.setSpacing(2)
process_vbox.addWidget(self.process_monitor_heading)
process_vbox.addWidget(self.process_monitor)
process_monitor_widget = QWidget()
process_monitor_widget.setLayout(process_vbox)
if self.debug:
process_monitor_widget.setStyleSheet("border: 2px solid purple;")
process_monitor_widget.setToolTip("PROCESS_MONITOR")
upper_hbox.addWidget(user_config_widget, stretch=11)
upper_hbox.addWidget(process_monitor_widget, stretch=9)
self.process_monitor.setVisible(False) # Hidden in compact mode
upper_hbox.setAlignment(Qt.AlignTop)
upper_section_widget = QWidget()
upper_section_widget.setLayout(upper_hbox)
# Use Fixed size policy for consistent height
upper_section_widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
upper_section_widget.setMaximumHeight(280) # Increased to show resolution dropdown
if self.debug:
upper_section_widget.setStyleSheet("border: 2px solid green;")
upper_section_widget.setToolTip("UPPER_SECTION")
main_overall_vbox.addWidget(upper_section_widget)
# Remove spacing - console should expand to fill available space
# Status banner with progress indicator and "Show details" toggle
banner_row = QHBoxLayout()
banner_row.setContentsMargins(0, 0, 0, 0)
banner_row.setSpacing(8)
banner_row.addWidget(self.progress_indicator, 1)
banner_row.addStretch()
banner_row.addWidget(self.show_details_checkbox)
banner_row_widget = QWidget()
banner_row_widget.setLayout(banner_row)
banner_row_widget.setMaximumHeight(45) # Compact height
banner_row_widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
main_overall_vbox.addWidget(banner_row_widget)
# Console output area (shown when "Show details" is checked)
self.console = QTextEdit()
self.console.setReadOnly(True)
self.console.setTextInteractionFlags(Qt.TextSelectableByMouse | Qt.TextSelectableByKeyboard)
self.console.setMinimumHeight(50) # Very small minimum - can shrink to almost nothing
self.console.setMaximumHeight(1000) # Allow growth when space available
self.console.setMinimumHeight(50)
self.console.setMaximumHeight(1000)
self.console.setFontFamily('monospace')
self.console.setVisible(False) # Hidden by default (compact mode)
if self.debug:
self.console.setStyleSheet("border: 2px solid yellow;")
self.console.setToolTip("CONSOLE")
# Set up scroll tracking for professional auto-scroll behavior
self._setup_scroll_tracking()
# Wrap button row in widget for debug borders
btn_row_widget = QWidget()
btn_row_widget.setLayout(btn_row)
btn_row_widget.setMaximumHeight(50) # Limit height to make it more compact
btn_row_widget.setMaximumHeight(50)
if self.debug:
btn_row_widget.setStyleSheet("border: 2px solid red;")
btn_row_widget.setToolTip("BUTTON_ROW")
# Create a container that holds console + button row with proper spacing
console_and_buttons_widget = QWidget()
console_and_buttons_layout = QVBoxLayout()
console_and_buttons_layout.setContentsMargins(0, 0, 0, 0)
console_and_buttons_layout.setSpacing(8) # Small gap between console and buttons
console_and_buttons_layout.addWidget(self.console, stretch=1) # Console fills most space
console_and_buttons_layout.addWidget(btn_row_widget) # Buttons at bottom of this container
console_and_buttons_layout.setSpacing(8)
console_and_buttons_layout.addWidget(self.console, stretch=1)
console_and_buttons_layout.addWidget(btn_row_widget)
console_and_buttons_widget.setLayout(console_and_buttons_layout)
console_and_buttons_widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
console_and_buttons_widget.setFixedHeight(50) # Lock to button row height when console is hidden
if self.debug:
console_and_buttons_widget.setStyleSheet("border: 2px solid lightblue;")
console_and_buttons_widget.setToolTip("CONSOLE_AND_BUTTONS_CONTAINER")
main_overall_vbox.addWidget(console_and_buttons_widget, stretch=1) # This container fills remaining space
# Add without stretch to prevent squashing upper section
main_overall_vbox.addWidget(console_and_buttons_widget)
# Store references for toggle functionality
self.console_and_buttons_widget = console_and_buttons_widget
self.console_and_buttons_layout = console_and_buttons_layout
self.main_overall_vbox = main_overall_vbox
self.setLayout(main_overall_vbox)
self.process = None
self.log_timer = None
@@ -379,6 +408,88 @@ class ConfigureExistingModlistScreen(QWidget):
if scrollbar.value() >= scrollbar.maximum() - 1:
self._user_manually_scrolled = False
def _on_show_details_toggled(self, checked):
"""Handle Show Details checkbox toggle"""
self._toggle_console_visibility(checked)
def _toggle_console_visibility(self, is_checked):
"""Toggle console visibility and window size"""
main_window = None
try:
parent = self.parent()
while parent and not isinstance(parent, QMainWindow):
parent = parent.parent()
if parent and isinstance(parent, QMainWindow):
main_window = parent
except Exception:
pass
if is_checked:
# Show console
self.console.setVisible(True)
self.console.setMinimumHeight(200)
self.console.setMaximumHeight(16777215)
self.console.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
# Allow expansion when console is visible
if hasattr(self, 'console_and_buttons_widget'):
self.console_and_buttons_widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
self.console_and_buttons_widget.setMinimumHeight(0)
self.console_and_buttons_widget.setMaximumHeight(16777215)
self.console_and_buttons_widget.updateGeometry()
# Stop CPU tracking when showing console
self.file_progress_list.stop_cpu_tracking()
# Expand window
if main_window:
try:
from PySide6.QtCore import QSize
# On Steam Deck, keep fullscreen; on other systems, set normal window state
if not (hasattr(main_window, 'system_info') and main_window.system_info.is_steamdeck):
main_window.showNormal()
main_window.setMaximumHeight(16777215)
main_window.setMinimumHeight(0)
expanded_min = 900
current_size = main_window.size()
target_height = max(expanded_min, 900)
main_window.setMinimumHeight(expanded_min)
main_window.resize(current_size.width(), target_height)
self.main_overall_vbox.invalidate()
self.updateGeometry()
except Exception:
pass
else:
# Hide console
self.console.setVisible(False)
self.console.setMinimumHeight(0)
self.console.setMaximumHeight(0)
self.console.setSizePolicy(QSizePolicy.Ignored, QSizePolicy.Ignored)
# Lock height when console is hidden
if hasattr(self, 'console_and_buttons_widget'):
self.console_and_buttons_widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
self.console_and_buttons_widget.setFixedHeight(50)
self.console_and_buttons_widget.updateGeometry()
# CPU tracking will start when user clicks "Start Configuration", not here
# (Removed to avoid blocking showEvent)
# Collapse window
if main_window:
try:
from PySide6.QtCore import QSize
# Use fixed compact height for consistency across all workflow screens
compact_height = 620
# On Steam Deck, keep fullscreen; on other systems, set normal window state
if not (hasattr(main_window, 'system_info') and main_window.system_info.is_steamdeck):
main_window.showNormal()
set_responsive_minimum(main_window, min_width=960, min_height=compact_height)
current_size = main_window.size()
main_window.resize(current_size.width(), compact_height)
except Exception:
pass
def _safe_append_text(self, text):
"""Append text with professional auto-scroll behavior"""
# Write all messages to log file
@@ -420,7 +531,13 @@ class ConfigureExistingModlistScreen(QWidget):
from pathlib import Path
log_handler = LoggingHandler()
log_handler.rotate_log_file_per_run(Path(self.modlist_log_path), backup_count=5)
# Initialize progress indicator
self.progress_indicator.set_status("Preparing to configure...", 0)
# Start CPU tracking
self.file_progress_list.start_cpu_tracking()
# Disable controls during configuration
self._disable_controls_during_operation()
@@ -560,7 +677,10 @@ class ConfigureExistingModlistScreen(QWidget):
if success:
# Calculate time taken
time_taken = self._calculate_time_taken()
# Clear Activity window before showing success dialog
self.file_progress_list.clear()
# Show success dialog with celebration
success_dialog = SuccessDialog(
modlist_name=modlist_name,
@@ -644,9 +764,188 @@ class ConfigureExistingModlistScreen(QWidget):
dlg.exec()
def go_back(self):
"""Navigate back to main menu and restore window size"""
# Emit collapse signal to restore compact mode
self.resize_request.emit('collapse')
# Restore window size before navigating away
try:
main_window = self.window()
if main_window:
from PySide6.QtCore import QSize
from ..utils import apply_window_size_and_position
# Only set minimum size - DO NOT RESIZE
main_window.setMaximumSize(QSize(16777215, 16777215))
set_responsive_minimum(main_window, min_width=960, min_height=420)
# DO NOT resize - let window stay at current size
except Exception:
pass
if self.stacked_widget:
self.stacked_widget.setCurrentIndex(self.main_menu_index)
def cleanup_processes(self):
"""Clean up any running processes when the window closes or is cancelled"""
# Stop CPU tracking if active
if hasattr(self, 'file_progress_list'):
self.file_progress_list.stop_cpu_tracking()
# Clean up configuration thread if running
if hasattr(self, 'config_thread') and self.config_thread.isRunning():
self.config_thread.terminate()
self.config_thread.wait(1000)
def cancel_and_cleanup(self):
"""Handle Cancel button - clean up processes and go back"""
self.cleanup_processes()
self.go_back()
def showEvent(self, event):
"""Called when the widget becomes visible - ensure collapsed state"""
super().showEvent(event)
# Load shortcuts asynchronously (only once, on first show) to avoid blocking UI
if not self._shortcuts_loaded:
# Load in background thread to avoid blocking UI
from PySide6.QtCore import QTimer
QTimer.singleShot(0, self._load_shortcuts_async)
self._shortcuts_loaded = True
# Ensure initial collapsed layout each time this screen is opened
try:
from PySide6.QtCore import Qt as _Qt
# Ensure checkbox is unchecked without emitting signals
if self.show_details_checkbox.isChecked():
self.show_details_checkbox.blockSignals(True)
self.show_details_checkbox.setChecked(False)
self.show_details_checkbox.blockSignals(False)
# Force collapsed state
self._toggle_console_visibility(False)
# Only set minimum size - DO NOT RESIZE
main_window = self.window()
if main_window:
from PySide6.QtCore import QSize
main_window.setMaximumSize(QSize(16777215, 16777215))
set_responsive_minimum(main_window, min_width=960, min_height=420)
# DO NOT resize - let window stay at current size
except Exception as e:
# If initial collapse fails, log but don't crash
print(f"Warning: Failed to set initial collapsed state: {e}")
def hideEvent(self, event):
"""Clean up thread when screen is hidden"""
super().hideEvent(event)
# Clean up shortcut loader thread if it's still running
if self._shortcut_loader is not None:
if self._shortcut_loader.isRunning():
self._shortcut_loader.finished_signal.disconnect()
self._shortcut_loader.terminate()
self._shortcut_loader.wait(1000) # Wait up to 1 second for cleanup
self._shortcut_loader = None
def _load_shortcuts_async(self):
"""Load ModOrganizer.exe shortcuts asynchronously to avoid blocking UI"""
from PySide6.QtCore import QThread, Signal, QObject
class ShortcutLoaderThread(QThread):
finished_signal = Signal(list) # Emits list of shortcuts when done
error_signal = Signal(str) # Emits error message if something goes wrong
def run(self):
try:
# Suppress all logging/output in background thread to avoid reentrant stderr issues
import logging
import sys
# Temporarily redirect stderr to avoid reentrant calls
old_stderr = sys.stderr
try:
# Use a null device or StringIO to capture errors without writing to stderr
from io import StringIO
sys.stderr = StringIO()
# Fetch shortcuts for ModOrganizer.exe using existing backend functionality
from jackify.backend.handlers.modlist_handler import ModlistHandler
# Initialize modlist handler with empty config dict to use default initialization
modlist_handler = ModlistHandler({})
discovered_modlists = modlist_handler.discover_executable_shortcuts("ModOrganizer.exe")
# Convert to shortcut_handler format for UI compatibility
shortcuts = []
for modlist in discovered_modlists:
# Convert discovered modlist format to shortcut format
shortcut = {
'AppName': modlist.get('name', 'Unknown'),
'AppID': modlist.get('appid', ''),
'StartDir': modlist.get('path', ''),
'Exe': f"{modlist.get('path', '')}/ModOrganizer.exe"
}
shortcuts.append(shortcut)
# Restore stderr before emitting signal
sys.stderr = old_stderr
self.finished_signal.emit(shortcuts)
except Exception as inner_e:
# Restore stderr before emitting error
sys.stderr = old_stderr
error_msg = str(inner_e)
self.error_signal.emit(error_msg)
self.finished_signal.emit([])
except Exception as e:
# Fallback error handling
error_msg = str(e)
self.error_signal.emit(error_msg)
self.finished_signal.emit([])
# Show loading state in dropdown
if hasattr(self, 'shortcut_combo'):
self.shortcut_combo.clear()
self.shortcut_combo.addItem("Loading modlists...")
self.shortcut_combo.setEnabled(False)
# Clean up any existing thread first
if self._shortcut_loader is not None:
if self._shortcut_loader.isRunning():
self._shortcut_loader.finished_signal.disconnect()
self._shortcut_loader.terminate()
self._shortcut_loader.wait(1000) # Wait up to 1 second
self._shortcut_loader = None
# Start background thread
self._shortcut_loader = ShortcutLoaderThread()
self._shortcut_loader.finished_signal.connect(self._on_shortcuts_loaded)
self._shortcut_loader.error_signal.connect(self._on_shortcuts_error)
self._shortcut_loader.start()
def _on_shortcuts_loaded(self, shortcuts):
"""Update UI when shortcuts are loaded"""
self.mo2_shortcuts = shortcuts
# Update the dropdown
if hasattr(self, 'shortcut_combo'):
self.shortcut_combo.clear()
self.shortcut_combo.setEnabled(True)
self.shortcut_combo.addItem("Please Select...")
self.shortcut_map.clear()
for shortcut in self.mo2_shortcuts:
display = f"{shortcut.get('AppName', shortcut.get('appname', 'Unknown'))} ({shortcut.get('StartDir', shortcut.get('startdir', ''))})"
self.shortcut_combo.addItem(display)
self.shortcut_map.append(shortcut)
def _on_shortcuts_error(self, error_msg):
"""Handle errors from shortcut loading thread"""
# Log error from main thread (safe to write to stderr here)
debug_print(f"Warning: Failed to load shortcuts: {error_msg}")
# Update UI to show error state
if hasattr(self, 'shortcut_combo'):
self.shortcut_combo.clear()
self.shortcut_combo.setEnabled(True)
self.shortcut_combo.addItem("Error loading modlists - please try again")
def update_top_panel(self):
try:
result = subprocess.run([
@@ -693,43 +992,10 @@ class ConfigureExistingModlistScreen(QWidget):
pass
def refresh_modlist_list(self):
"""Refresh the modlist dropdown by re-detecting ModOrganizer.exe shortcuts"""
try:
# Re-detect shortcuts using existing backend functionality
from jackify.backend.handlers.modlist_handler import ModlistHandler
# Initialize modlist handler with empty config dict to use default initialization
modlist_handler = ModlistHandler({})
discovered_modlists = modlist_handler.discover_executable_shortcuts("ModOrganizer.exe")
# Convert to shortcut_handler format for UI compatibility
self.mo2_shortcuts = []
for modlist in discovered_modlists:
# Convert discovered modlist format to shortcut format
shortcut = {
'AppName': modlist.get('name', 'Unknown'),
'AppID': modlist.get('appid', ''),
'StartDir': modlist.get('path', ''),
'Exe': f"{modlist.get('path', '')}/ModOrganizer.exe"
}
self.mo2_shortcuts.append(shortcut)
# Clear and repopulate the combo box
self.shortcut_combo.clear()
self.shortcut_combo.addItem("Please Select...")
self.shortcut_map.clear()
for shortcut in self.mo2_shortcuts:
display = f"{shortcut.get('AppName', shortcut.get('appname', 'Unknown'))} ({shortcut.get('StartDir', shortcut.get('startdir', ''))})"
self.shortcut_combo.addItem(display)
self.shortcut_map.append(shortcut)
# Show feedback to user in UI only (don't write to log before workflow starts)
# Feedback is shown by the updated dropdown items
except Exception as e:
# Don't write to log file before workflow starts - just show error in UI
MessageService.warning(self, "Refresh Error", f"Failed to refresh modlist list: {e}", safety_level="low")
"""Refresh the modlist dropdown by re-detecting ModOrganizer.exe shortcuts (async)"""
# Use async loading to avoid blocking UI
self._shortcuts_loaded = False # Allow reload
self._load_shortcuts_async()
def _calculate_time_taken(self) -> str:
"""Calculate and format the time taken for the workflow"""