""" InstallModlistScreen for Jackify GUI """ from PySide6.QtWidgets import QWidget, QVBoxLayout, QLabel, QComboBox, QHBoxLayout, QLineEdit, QPushButton, QGridLayout, QFileDialog, QTextEdit, QSizePolicy, QTabWidget, QDialog, QMessageBox, QProgressDialog, QApplication, QCheckBox, QStyledItemDelegate, QStyle, QFrame from PySide6.QtCore import Qt, QSize, QThread, Signal, QTimer, QProcess, QMetaObject, QUrl from PySide6.QtGui import QPixmap, QTextCursor, QPainter, QFont from ..shared_theme import JACKIFY_COLOR_BLUE, DEBUG_BORDERS from ..utils import ansi_to_html, strip_ansi_control_codes from ..widgets.unsupported_game_dialog import UnsupportedGameDialog import os import subprocess import sys import threading from jackify.backend.handlers.shortcut_handler import ShortcutHandler from jackify.backend.handlers.wabbajack_parser import WabbajackParser import traceback from jackify.backend.core.modlist_operations import get_jackify_engine_path import signal import re import time from jackify.backend.handlers.subprocess_utils import ProcessManager from jackify.backend.handlers.config_handler import ConfigHandler from ..dialogs import SuccessDialog from jackify.backend.handlers.validation_handler import ValidationHandler from jackify.frontends.gui.dialogs.warning_dialog import WarningDialog from jackify.frontends.gui.services.message_service import MessageService def debug_print(message): """Print debug message only if debug mode is enabled""" from jackify.backend.handlers.config_handler import ConfigHandler config_handler = ConfigHandler() if config_handler.get('debug_mode', False): print(message) class ModlistFetchThread(QThread): result = Signal(list, str) def __init__(self, game_type, log_path, mode='list-modlists'): super().__init__() self.game_type = game_type self.log_path = log_path self.mode = mode def run(self): try: # Use proper backend service - NOT the misnamed CLI class from jackify.backend.services.modlist_service import ModlistService from jackify.backend.models.configuration import SystemInfo # Initialize backend service # Detect if we're on Steam Deck is_steamdeck = False try: if os.path.exists('/etc/os-release'): with open('/etc/os-release') as f: if 'steamdeck' in f.read().lower(): is_steamdeck = True except Exception: pass system_info = SystemInfo(is_steamdeck=is_steamdeck) modlist_service = ModlistService(system_info) # Get modlists using proper backend service modlist_infos = modlist_service.list_modlists(game_type=self.game_type) # Return full modlist objects instead of just IDs to preserve enhanced metadata self.result.emit(modlist_infos, '') except Exception as e: error_msg = f"Backend service error: {str(e)}" # Don't write to log file before workflow starts - just return error self.result.emit([], error_msg) class InstallTTWScreen(QWidget): steam_restart_finished = Signal(bool, str) resize_request = Signal(str) integration_complete = Signal(bool, str) # Signal for modlist integration completion (success, ttw_version) def __init__(self, stacked_widget=None, main_menu_index=0, system_info=None): super().__init__() self.stacked_widget = stacked_widget self.main_menu_index = main_menu_index self.system_info = system_info self.debug = DEBUG_BORDERS self.online_modlists = {} # {game_type: [modlist_dict, ...]} self.modlist_details = {} # {modlist_name: modlist_dict} # Initialize log path (can be refreshed via refresh_paths method) self.refresh_paths() # Initialize services early from jackify.backend.services.api_key_service import APIKeyService from jackify.backend.services.resolution_service import ResolutionService from jackify.backend.services.protontricks_detection_service import ProtontricksDetectionService from jackify.backend.handlers.config_handler import ConfigHandler self.api_key_service = APIKeyService() self.resolution_service = ResolutionService() self.config_handler = ConfigHandler() self.protontricks_service = ProtontricksDetectionService() # Modlist integration mode tracking self._integration_mode = False self._integration_modlist_name = None self._integration_install_dir = None # Somnium guidance tracking self._show_somnium_guidance = False self._somnium_install_dir = None # Scroll tracking for professional auto-scroll behavior self._user_manually_scrolled = False self._was_at_bottom = True # Initialize Wabbajack parser for game detection self.wabbajack_parser = WabbajackParser() # Remember original main window geometry/min-size to restore on expand self._saved_geometry = None self._saved_min_size = None main_overall_vbox = QVBoxLayout(self) main_overall_vbox.setAlignment(Qt.AlignTop | Qt.AlignHCenter) # Tighter outer margins and reduced inter-section spacing main_overall_vbox.setContentsMargins(20, 12, 20, 0) main_overall_vbox.setSpacing(6) if self.debug: self.setStyleSheet("border: 2px solid magenta;") # --- Header (title, description) --- header_layout = QVBoxLayout() header_layout.setSpacing(1) # Reduce spacing between title and description # Title (no logo) title = QLabel("Install Tale of Two Wastelands (TTW)") title.setStyleSheet(f"font-size: 20px; color: {JACKIFY_COLOR_BLUE}; margin: 0px; padding: 0px;") title.setAlignment(Qt.AlignHCenter) title.setMaximumHeight(30) # Force compact height header_layout.addWidget(title) # Description desc = QLabel( "This screen allows you to install Tale of Two Wastelands (TTW) using the Hoolamike tool. " "Configure your options and start the installation." ) desc.setWordWrap(True) desc.setStyleSheet("color: #ccc; margin: 0px; padding: 0px; line-height: 1.2;") desc.setAlignment(Qt.AlignHCenter) desc.setMaximumHeight(40) # Force compact height for description header_layout.addWidget(desc) header_widget = QWidget() header_widget.setLayout(header_layout) # Keep header compact header_widget.setMaximumHeight(90) # Remove height constraint to allow status banner to show if self.debug: header_widget.setStyleSheet("border: 2px solid pink;") header_widget.setToolTip("HEADER_SECTION") main_overall_vbox.addWidget(header_widget) # --- Upper section: user-configurables (left) + process monitor (right) --- upper_hbox = QHBoxLayout() upper_hbox.setContentsMargins(0, 0, 0, 0) upper_hbox.setSpacing(16) # Left: user-configurables (form and controls) user_config_vbox = QVBoxLayout() user_config_vbox.setAlignment(Qt.AlignTop) user_config_vbox.setSpacing(4) # Reduce spacing between major form sections # --- Instructions --- instruction_text = QLabel( "Tale of Two Wastelands installation requires a .mpi file you can get from: " 'https://mod.pub/ttw/133/files ' "(requires a user account for mod.db)" ) instruction_text.setWordWrap(True) instruction_text.setStyleSheet("color: #ccc; font-size: 12px; margin: 0px; padding: 0px; line-height: 1.2;") instruction_text.setOpenExternalLinks(True) user_config_vbox.addWidget(instruction_text) # --- Compact Form Grid for inputs (align with other screens) --- form_grid = QGridLayout() form_grid.setHorizontalSpacing(12) form_grid.setVerticalSpacing(6) form_grid.setContentsMargins(0, 0, 0, 0) # Row 0: TTW .mpi File location file_label = QLabel("TTW .mpi File location:") self.file_edit = QLineEdit() self.file_edit.setMaximumHeight(25) self.file_edit.textChanged.connect(self._update_start_button_state) self.file_btn = QPushButton("Browse") self.file_btn.clicked.connect(self.browse_wabbajack_file) file_hbox = QHBoxLayout() file_hbox.addWidget(self.file_edit) file_hbox.addWidget(self.file_btn) form_grid.addWidget(file_label, 0, 0, alignment=Qt.AlignLeft | Qt.AlignVCenter) form_grid.addLayout(file_hbox, 0, 1) # Row 1: Output Directory install_dir_label = QLabel("Output Directory:") self.install_dir_edit = QLineEdit(self.config_handler.get_modlist_install_base_dir()) self.install_dir_edit.setMaximumHeight(25) self.browse_install_btn = QPushButton("Browse") self.browse_install_btn.clicked.connect(self.browse_install_dir) install_dir_hbox = QHBoxLayout() install_dir_hbox.addWidget(self.install_dir_edit) install_dir_hbox.addWidget(self.browse_install_btn) form_grid.addWidget(install_dir_label, 1, 0, alignment=Qt.AlignLeft | Qt.AlignVCenter) form_grid.addLayout(install_dir_hbox, 1, 1) # --- Hoolamike Status aligned in form grid (row 2) --- hoolamike_label = QLabel("Hoolamike Status:") self.hoolamike_status = QLabel("Checking...") self.hoolamike_btn = QPushButton("Install now") self.hoolamike_btn.setStyleSheet(""" QPushButton:hover { opacity: 0.95; } QPushButton:disabled { opacity: 0.6; } """) self.hoolamike_btn.setVisible(False) self.hoolamike_btn.clicked.connect(self.install_hoolamike) hoolamike_hbox = QHBoxLayout() hoolamike_hbox.setContentsMargins(0, 0, 0, 0) hoolamike_hbox.setSpacing(8) hoolamike_hbox.addWidget(self.hoolamike_status) hoolamike_hbox.addWidget(self.hoolamike_btn) hoolamike_hbox.addStretch() form_grid.addWidget(hoolamike_label, 2, 0, alignment=Qt.AlignLeft | Qt.AlignVCenter) form_grid.addLayout(hoolamike_hbox, 2, 1) # --- Game Requirements aligned in form grid (row 3) --- game_req_label = QLabel("Game Requirements:") self.fallout3_status = QLabel("Fallout 3: Checking...") self.fallout3_status.setStyleSheet("color: #ccc;") self.fnv_status = QLabel("Fallout New Vegas: Checking...") self.fnv_status.setStyleSheet("color: #ccc;") game_req_hbox = QHBoxLayout() game_req_hbox.setContentsMargins(0, 0, 0, 0) game_req_hbox.setSpacing(16) game_req_hbox.addWidget(self.fallout3_status) game_req_hbox.addWidget(self.fnv_status) game_req_hbox.addStretch() form_grid.addWidget(game_req_label, 3, 0, alignment=Qt.AlignLeft | Qt.AlignVCenter) form_grid.addLayout(game_req_hbox, 3, 1) form_group = QWidget() form_group.setLayout(form_grid) user_config_vbox.addWidget(form_group) # (Hoolamike and Game Requirements now aligned in form_grid above) # --- Buttons --- btn_row = QHBoxLayout() btn_row.setAlignment(Qt.AlignHCenter) self.start_btn = QPushButton("Start Installation") self.start_btn.setEnabled(False) # Disabled until requirements are met btn_row.addWidget(self.start_btn) # Cancel button (goes back to menu) self.cancel_btn = QPushButton("Cancel") self.cancel_btn.clicked.connect(self.cancel_and_cleanup) btn_row.addWidget(self.cancel_btn) # Cancel Installation button (appears during installation) self.cancel_install_btn = QPushButton("Cancel Installation") self.cancel_install_btn.clicked.connect(self.cancel_installation) self.cancel_install_btn.setVisible(False) # Hidden by default btn_row.addWidget(self.cancel_install_btn) # Add stretches to center buttons row btn_row.insertStretch(0, 1) btn_row.addStretch(1) # Show Details Checkbox (collapsible console) self.show_details_checkbox = QCheckBox("Show details") # Start collapsed by default (console hidden until user opts in) self.show_details_checkbox.setChecked(False) # Use toggled(bool) for reliable signal and map to our handler try: self.show_details_checkbox.toggled.connect(self._on_show_details_toggled) except Exception: # Fallback to stateChanged if toggled is unavailable self.show_details_checkbox.stateChanged.connect(self._toggle_console_visibility) # Note: Checkbox will be placed in the status banner row (right-aligned) # 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 if self.debug: btn_row_widget.setStyleSheet("border: 2px solid red;") btn_row_widget.setToolTip("BUTTON_ROW") # Keep a reference for dynamic sizing when collapsing/expanding self.btn_row_widget = btn_row_widget user_config_widget = QWidget() user_config_widget.setLayout(user_config_vbox) user_config_widget.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Expanding) # Allow vertical expansion to fill space if self.debug: user_config_widget.setStyleSheet("border: 2px solid orange;") user_config_widget.setToolTip("USER_CONFIG_WIDGET") # Right: process monitor (as before) 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("[Process Monitor]") 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=1) upper_hbox.addWidget(process_monitor_widget, stretch=3) upper_hbox.setAlignment(Qt.AlignTop) self.upper_section_widget = QWidget() self.upper_section_widget.setLayout(upper_hbox) # Keep the top section tightly wrapped to its content height try: self.upper_section_widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) self.upper_section_widget.setMaximumHeight(self.upper_section_widget.sizeHint().height()) except Exception: pass if self.debug: self.upper_section_widget.setStyleSheet("border: 2px solid green;") self.upper_section_widget.setToolTip("UPPER_SECTION") main_overall_vbox.addWidget(self.upper_section_widget) # --- Status Banner (shows high-level progress) --- self.status_banner = QLabel("Ready to install") self.status_banner.setAlignment(Qt.AlignCenter) self.status_banner.setStyleSheet(f""" background-color: #2a2a2a; color: {JACKIFY_COLOR_BLUE}; padding: 6px 8px; border-radius: 4px; font-weight: bold; font-size: 13px; """) # Prevent banner from expanding vertically self.status_banner.setMaximumHeight(34) self.status_banner.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed) # Show the banner by default so users see status even when collapsed self.status_banner.setVisible(True) # Create a compact banner row with the checkbox right-aligned banner_row = QHBoxLayout() # Minimal padding to avoid visible gaps banner_row.setContentsMargins(0, 0, 0, 0) banner_row.setSpacing(8) banner_row.addWidget(self.status_banner, 1) banner_row.addStretch() banner_row.addWidget(self.show_details_checkbox) banner_row_widget = QWidget() banner_row_widget.setLayout(banner_row) main_overall_vbox.addWidget(banner_row_widget) # Remove spacing - console should expand to fill available space # --- Console output area (full width, placeholder for now) --- self.console = QTextEdit() self.console.setReadOnly(True) self.console.setTextInteractionFlags(Qt.TextSelectableByMouse | Qt.TextSelectableByKeyboard) # Console starts hidden; toggled via Show details self.console.setMinimumHeight(0) self.console.setMaximumHeight(0) self.console.setFontFamily('monospace') 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() # Add console directly so we can hide/show without affecting buttons main_overall_vbox.addWidget(self.console, stretch=1) # Place the button row after the console so it's always visible and centered main_overall_vbox.addWidget(btn_row_widget, alignment=Qt.AlignHCenter) # Store reference to main layout self.main_overall_vbox = main_overall_vbox self.setLayout(main_overall_vbox) self.current_modlists = [] # --- Process Monitor (right) --- self.process = None self.log_timer = None self.last_log_pos = 0 # --- Process Monitor Timer --- self.top_timer = QTimer(self) self.top_timer.timeout.connect(self.update_top_panel) self.top_timer.start(2000) # --- Start Installation button --- self.start_btn.clicked.connect(self.validate_and_start_install) self.steam_restart_finished.connect(self._on_steam_restart_finished) # Initialize process tracking self.process = None # Initialize empty controls list - will be populated after UI is built self._actionable_controls = [] def check_requirements(self): """Check and display requirements status""" from jackify.backend.handlers.path_handler import PathHandler from jackify.backend.handlers.hoolamike_handler import HoolamikeHandler from jackify.backend.handlers.filesystem_handler import FileSystemHandler from jackify.backend.handlers.config_handler import ConfigHandler from jackify.backend.models.configuration import SystemInfo path_handler = PathHandler() # Check game detection detected_games = path_handler.find_vanilla_game_paths() # Fallout 3 if 'Fallout 3' in detected_games: self.fallout3_status.setText("Fallout 3: Detected") self.fallout3_status.setStyleSheet("color: #3fd0ea;") else: self.fallout3_status.setText("Fallout 3: Not Found - Install from Steam") self.fallout3_status.setStyleSheet("color: #f44336;") # Fallout New Vegas if 'Fallout New Vegas' in detected_games: self.fnv_status.setText("Fallout New Vegas: Detected") self.fnv_status.setStyleSheet("color: #3fd0ea;") else: self.fnv_status.setText("Fallout New Vegas: Not Found - Install from Steam") self.fnv_status.setStyleSheet("color: #f44336;") # Update Start button state after checking requirements self._update_start_button_state() def _check_hoolamike_status(self): """Check Hoolamike installation status and update UI""" try: from jackify.backend.handlers.hoolamike_handler import HoolamikeHandler from jackify.backend.handlers.filesystem_handler import FileSystemHandler from jackify.backend.handlers.config_handler import ConfigHandler from jackify.backend.models.configuration import SystemInfo # Create handler instances filesystem_handler = FileSystemHandler() config_handler = ConfigHandler() system_info = SystemInfo(is_steamdeck=False) hoolamike_handler = HoolamikeHandler( steamdeck=False, verbose=False, filesystem_handler=filesystem_handler, config_handler=config_handler ) # Check if Hoolamike is installed hoolamike_handler._check_hoolamike_installation() if hoolamike_handler.hoolamike_installed: # Check version against latest update_available, installed_v, latest_v = hoolamike_handler.is_hoolamike_update_available() if update_available: self.hoolamike_status.setText("Out of date") self.hoolamike_status.setStyleSheet("color: #f44336;") self.hoolamike_btn.setText("Update now") self.hoolamike_btn.setEnabled(True) self.hoolamike_btn.setVisible(True) else: self.hoolamike_status.setText("Ready") self.hoolamike_status.setStyleSheet("color: #3fd0ea;") self.hoolamike_btn.setText("Update now") self.hoolamike_btn.setEnabled(False) # Greyed out when ready self.hoolamike_btn.setVisible(True) else: self.hoolamike_status.setText("Not Found") self.hoolamike_status.setStyleSheet("color: #f44336;") self.hoolamike_btn.setText("Install now") self.hoolamike_btn.setEnabled(True) self.hoolamike_btn.setVisible(True) except Exception as e: self.hoolamike_status.setText("Check Failed") self.hoolamike_status.setStyleSheet("color: #f44336;") self.hoolamike_btn.setText("Install now") self.hoolamike_btn.setEnabled(True) self.hoolamike_btn.setVisible(True) debug_print(f"DEBUG: Hoolamike status check failed: {e}") def install_hoolamike(self): """Install or update Hoolamike""" # If not detected, show an appreciation/info dialog about Hoolamike first try: current_status = self.hoolamike_status.text().strip() except Exception: current_status = "" if current_status == "Not Found": MessageService.information( self, "Hoolamike Installation", ( "Hoolamike is a community-made installer that enables the installation of modlists and TTW on Linux.

" "Project: github.com/Niedzwiedzw/hoolamike
" "Please star the repository and thank the developer.

" "Jackify will now download and install the latest Linux build of Hoolamike." ), safety_level="low", ) # Update button to show installation in progress self.hoolamike_btn.setText("Installing...") self.hoolamike_btn.setEnabled(False) self.console.append("Installing/updating Hoolamike...") try: from jackify.backend.handlers.hoolamike_handler import HoolamikeHandler from jackify.backend.handlers.filesystem_handler import FileSystemHandler from jackify.backend.handlers.config_handler import ConfigHandler from jackify.backend.models.configuration import SystemInfo # Create handler instances filesystem_handler = FileSystemHandler() config_handler = ConfigHandler() system_info = SystemInfo(is_steamdeck=False) hoolamike_handler = HoolamikeHandler( steamdeck=False, verbose=False, filesystem_handler=filesystem_handler, config_handler=config_handler ) # Install Hoolamike success, message = hoolamike_handler.install_hoolamike() if success: # Extract path from message if available, or show config path install_path = hoolamike_handler.hoolamike_app_install_path self.console.append("Hoolamike installed successfully") self.console.append(f"Installation location: {install_path}") self.console.append("Re-checking Hoolamike status...") # Re-check Hoolamike status after installation self._check_hoolamike_status() self._update_start_button_state() # Update button to show successful installation self.hoolamike_btn.setText("Installed") # Keep button disabled - no need to reinstall else: self.console.append(f"Installation failed: {message}") # Re-enable button on failure so user can retry self.hoolamike_btn.setText("Install now") self.hoolamike_btn.setEnabled(True) except Exception as e: self.console.append(f"Error installing Hoolamike: {str(e)}") debug_print(f"DEBUG: Hoolamike installation error: {e}") # Re-enable button on exception so user can retry self.hoolamike_btn.setText("Install now") self.hoolamike_btn.setEnabled(True) def _check_ttw_requirements(self): """Check TTW requirements before installation""" from jackify.backend.handlers.path_handler import PathHandler from jackify.backend.handlers.hoolamike_handler import HoolamikeHandler from jackify.backend.handlers.filesystem_handler import FileSystemHandler from jackify.backend.handlers.config_handler import ConfigHandler path_handler = PathHandler() # Check game detection detected_games = path_handler.find_vanilla_game_paths() missing_games = [] if 'Fallout 3' not in detected_games: missing_games.append("Fallout 3") if 'Fallout New Vegas' not in detected_games: missing_games.append("Fallout New Vegas") if missing_games: MessageService.warning( self, "Missing Required Games", f"TTW requires both Fallout 3 and Fallout New Vegas to be installed.\n\nMissing: {', '.join(missing_games)}" ) return False # Check Hoolamike using the status we already checked status_text = self.hoolamike_status.text() if status_text in ("Not Found", "Check Failed"): MessageService.warning( self, "Hoolamike Required", "Hoolamike is required for TTW installation but is not installed.\n\nPlease install Hoolamike using the 'Install now' button." ) return False return True # Now collect all actionable controls after UI is fully built self._collect_actionable_controls() # Check if all requirements are met and enable/disable Start button self._update_start_button_state() def _update_start_button_state(self): """Enable/disable Start button based on requirements and file selection""" # Check if all requirements are met requirements_met = self._check_ttw_requirements() # Check if .mpi file is selected mpi_file_selected = bool(self.file_edit.text().strip()) # Enable Start button only if both requirements are met and file is selected self.start_btn.setEnabled(requirements_met and mpi_file_selected) # Update button text to indicate what's missing if not requirements_met: self.start_btn.setText("Requirements Not Met") elif not mpi_file_selected: self.start_btn.setText("Select TTW .mpi File") else: self.start_btn.setText("Start Installation") def _collect_actionable_controls(self): """Collect all actionable controls that should be disabled during operations (except Cancel)""" self._actionable_controls = [ # Main action button self.start_btn, # File selection self.file_edit, self.file_btn, # Install directory self.install_dir_edit, self.browse_install_btn, ] def _disable_controls_during_operation(self): """Disable all actionable controls during install/configure operations (except Cancel)""" for control in self._actionable_controls: if control: control.setEnabled(False) def _enable_controls_after_operation(self): """Re-enable all actionable controls after install/configure operations complete""" for control in self._actionable_controls: if control: control.setEnabled(True) def refresh_paths(self): """Refresh cached paths when config changes.""" from jackify.shared.paths import get_jackify_logs_dir self.modlist_log_path = get_jackify_logs_dir() / 'TTW_Install_workflow.log' os.makedirs(os.path.dirname(self.modlist_log_path), exist_ok=True) def set_modlist_integration_mode(self, modlist_name: str, install_dir: str): """Set the screen to modlist integration mode This mode is activated when TTW needs to be installed and integrated into an existing modlist. In this mode, after TTW installation completes, the TTW output will be automatically integrated into the modlist. Args: modlist_name: Name of the modlist to integrate TTW into install_dir: Installation directory of the modlist """ self._integration_mode = True self._integration_modlist_name = modlist_name self._integration_install_dir = install_dir # Reset saved geometry so showEvent can properly collapse from current window size self._saved_geometry = None self._saved_min_size = None # Update UI to show integration mode debug_print(f"TTW screen set to integration mode for modlist: {modlist_name}") debug_print(f"Installation directory: {install_dir}") def _open_url_safe(self, url): """Safely open URL using subprocess to avoid Qt library conflicts in PyInstaller""" import subprocess try: subprocess.Popen(['xdg-open', url], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) except Exception as e: print(f"Warning: Could not open URL {url}: {e}") def force_collapsed_state(self): """Force the screen into its collapsed state regardless of prior layout. This is used to resolve timing/race conditions when navigating here from the end of the Install Modlist workflow, ensuring the UI opens collapsed just like when launched from Additional Tasks. """ try: from PySide6.QtCore import Qt as _Qt # Ensure checkbox is unchecked without emitting user-facing 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) # Apply collapsed layout explicitly self._toggle_console_visibility(_Qt.Unchecked) # Inform parent window to collapse height try: self.resize_request.emit('collapse') except Exception: pass except Exception: pass def resizeEvent(self, event): """Handle window resize to prioritize form over console""" super().resizeEvent(event) self._adjust_console_for_form_priority() def _adjust_console_for_form_priority(self): """Console now dynamically fills available space with stretch=1, no manual calculation needed""" # The console automatically fills remaining space due to stretch=1 in the layout # Remove any fixed height constraints to allow natural stretching self.console.setMaximumHeight(16777215) # Reset to default maximum # Only enforce a small minimum when details are shown; keep 0 when collapsed if self.console.isVisible(): self.console.setMinimumHeight(50) else: self.console.setMinimumHeight(0) def showEvent(self, event): """Called when the widget becomes visible""" super().showEvent(event) debug_print(f"DEBUG: TTW showEvent - integration_mode={self._integration_mode}") # Check Hoolamike status only when TTW screen is opened self._check_hoolamike_status() # Ensure initial collapsed layout each time this screen is opened try: from PySide6.QtCore import Qt as _Qt # On Steam Deck: keep expanded layout and hide the details toggle try: is_steamdeck = False # Check our own system_info first if self.system_info and getattr(self.system_info, 'is_steamdeck', False): is_steamdeck = True # Fallback to checking parent window's system_info elif not self.system_info: parent = self.window() if parent and hasattr(parent, 'system_info') and getattr(parent.system_info, 'is_steamdeck', False): is_steamdeck = True if is_steamdeck: debug_print("DEBUG: Steam Deck detected, keeping expanded") # Force expanded state and hide checkbox if self.show_details_checkbox.isVisible(): self.show_details_checkbox.setVisible(False) # Show console with proper sizing for Steam Deck self.console.setVisible(True) self.console.show() self.console.setMinimumHeight(200) self.console.setMaximumHeight(16777215) # Remove height limit return except Exception as e: debug_print(f"DEBUG: Steam Deck check exception: {e}") pass debug_print(f"DEBUG: Checkbox checked={self.show_details_checkbox.isChecked()}") if self.show_details_checkbox.isChecked(): self.show_details_checkbox.blockSignals(True) self.show_details_checkbox.setChecked(False) self.show_details_checkbox.blockSignals(False) debug_print("DEBUG: Calling _toggle_console_visibility(Unchecked)") self._toggle_console_visibility(_Qt.Unchecked) # Force the window to compact height to eliminate bottom whitespace main_window = self.window() debug_print(f"DEBUG: main_window={main_window}, size={main_window.size() if main_window else None}") if main_window: # Save original geometry once if self._saved_geometry is None: self._saved_geometry = main_window.geometry() debug_print(f"DEBUG: Saved geometry: {self._saved_geometry}") if self._saved_min_size is None: self._saved_min_size = main_window.minimumSize() debug_print(f"DEBUG: Saved min size: {self._saved_min_size}") # Recompute and pin upper section to its content size to avoid slack try: if hasattr(self, 'upper_section_widget') and self.upper_section_widget is not None: self.upper_section_widget.setMaximumHeight(self.upper_section_widget.sizeHint().height()) except Exception: pass # Derive compact height from current content (tighter) compact_height = max(440, min(540, self.sizeHint().height() + 20)) debug_print(f"DEBUG: Calculated compact_height={compact_height}, sizeHint={self.sizeHint().height()}") # COMPLETE RESET: Clear ALL size constraints from previous screen from PySide6.QtCore import QSize main_window.showNormal() # First, completely unlock the window main_window.setMinimumSize(QSize(0, 0)) main_window.setMaximumSize(QSize(16777215, 16777215)) debug_print("DEBUG: Cleared all size constraints") # Now set our compact constraints main_window.setMinimumSize(QSize(1200, compact_height)) main_window.setMaximumHeight(compact_height) debug_print(f"DEBUG: Set compact constraints: min=1200x{compact_height}, max_height={compact_height}") # Force resize before_size = main_window.size() main_window.resize(1400, compact_height) debug_print(f"DEBUG: Resized from {before_size} to {main_window.size()}") # Notify parent to ensure compact try: self.resize_request.emit('collapse') debug_print("DEBUG: Emitted resize_request collapse signal") except Exception as e: debug_print(f"DEBUG: Exception emitting signal: {e}") pass except Exception as e: debug_print(f"DEBUG: showEvent exception: {e}") import traceback debug_print(f"DEBUG: {traceback.format_exc()}") pass def hideEvent(self, event): """Called when the widget becomes hidden - ensure window constraints are cleared on Steam Deck""" super().hideEvent(event) try: # Check if we're on Steam Deck is_steamdeck = False if self.system_info and getattr(self.system_info, 'is_steamdeck', False): is_steamdeck = True else: main_window = self.window() if main_window and hasattr(main_window, 'system_info'): is_steamdeck = getattr(main_window.system_info, 'is_steamdeck', False) # On Steam Deck, clear any size constraints that might have been set # This prevents window size issues affecting other screens after exiting TTW screen if is_steamdeck: debug_print("DEBUG: Steam Deck detected in hideEvent, clearing window constraints") main_window = self.window() if main_window: from PySide6.QtCore import QSize # Clear any size constraints that might have been set main_window.setMaximumSize(QSize(16777215, 16777215)) main_window.setMinimumSize(QSize(0, 0)) except Exception as e: debug_print(f"DEBUG: hideEvent exception: {e}") pass def _load_saved_parent_directories(self): """No-op: do not pre-populate install/download directories from saved values.""" pass def _update_directory_suggestions(self, modlist_name): """Update directory suggestions based on modlist name""" try: if not modlist_name: return # Update install directory suggestion with modlist name saved_install_parent = self.config_handler.get_default_install_parent_dir() if saved_install_parent: suggested_install_dir = os.path.join(saved_install_parent, modlist_name) self.install_dir_edit.setText(suggested_install_dir) debug_print(f"DEBUG: Updated install directory suggestion: {suggested_install_dir}") # Update download directory suggestion saved_download_parent = self.config_handler.get_default_download_parent_dir() if saved_download_parent: suggested_download_dir = os.path.join(saved_download_parent, "Downloads") debug_print(f"DEBUG: Updated download directory suggestion: {suggested_download_dir}") except Exception as e: debug_print(f"DEBUG: Error updating directory suggestions: {e}") def _save_parent_directories(self, install_dir, downloads_dir): """Removed automatic saving - user should set defaults in settings""" pass def browse_wabbajack_file(self): file, _ = QFileDialog.getOpenFileName(self, "Select TTW .mpi File", os.path.expanduser("~"), "MPI Files (*.mpi);;All Files (*)") if file: self.file_edit.setText(file) def browse_install_dir(self): dir = QFileDialog.getExistingDirectory(self, "Select Install Directory", self.install_dir_edit.text()) if dir: self.install_dir_edit.setText(dir) def go_back(self): if self.stacked_widget: self.stacked_widget.setCurrentIndex(self.main_menu_index) def update_top_panel(self): try: result = subprocess.run([ "ps", "-eo", "pcpu,pmem,comm,args" ], stdout=subprocess.PIPE, text=True, timeout=2) lines = result.stdout.splitlines() header = "CPU%\tMEM%\tCOMMAND" filtered = [header] process_rows = [] for line in lines[1:]: line_lower = line.lower() if ( ("jackify-engine" in line_lower or "7zz" in line_lower or "texconv" in line_lower or "wine" in line_lower or "wine64" in line_lower or "protontricks" in line_lower or "hoolamike" in line_lower) and "jackify-gui.py" not in line_lower ): cols = line.strip().split(None, 3) if len(cols) >= 3: process_rows.append(cols) process_rows.sort(key=lambda x: float(x[0]), reverse=True) for cols in process_rows: filtered.append('\t'.join(cols)) if len(filtered) == 1: filtered.append("[No Jackify-related processes found]") self.process_monitor.setPlainText('\n'.join(filtered)) except Exception as e: self.process_monitor.setPlainText(f"[process info unavailable: {e}]") def _check_protontricks(self): """Check if protontricks is available before critical operations""" try: is_installed, installation_type, details = self.protontricks_service.detect_protontricks() if not is_installed: # Show protontricks error dialog from jackify.frontends.gui.dialogs.protontricks_error_dialog import ProtontricksErrorDialog dialog = ProtontricksErrorDialog(self.protontricks_service, self) result = dialog.exec() if result == QDialog.Rejected: return False # Re-check after dialog is_installed, _, _ = self.protontricks_service.detect_protontricks(use_cache=False) return is_installed return True except Exception as e: print(f"Error checking protontricks: {e}") MessageService.warning(self, "Protontricks Check Failed", f"Unable to verify protontricks installation: {e}\n\n" "Continuing anyway, but some features may not work correctly.") return True # Continue anyway def validate_and_start_install(self): import time self._install_workflow_start_time = time.time() debug_print('DEBUG: validate_and_start_install called') # Reload config to pick up any settings changes made in Settings dialog self.config_handler.reload_config() debug_print('DEBUG: Reloaded config from disk') # Check TTW requirements first if not self._check_ttw_requirements(): return # Check protontricks before proceeding if not self._check_protontricks(): return # Disable all controls during installation (except Cancel) self._disable_controls_during_operation() try: # TTW only needs .mpi file mpi_path = self.file_edit.text().strip() if not mpi_path or not os.path.isfile(mpi_path) or not mpi_path.endswith('.mpi'): MessageService.warning(self, "Invalid TTW File", "Please select a valid TTW .mpi file.") self._enable_controls_after_operation() return install_dir = self.install_dir_edit.text().strip() # Validate required fields missing_fields = [] if not install_dir: missing_fields.append("Install Directory") if missing_fields: MessageService.warning(self, "Missing Required Fields", f"Please fill in all required fields before starting the install:\n- " + "\n- ".join(missing_fields)) self._enable_controls_after_operation() return # Validate install directory validation_handler = ValidationHandler() from pathlib import Path is_safe, reason = validation_handler.is_safe_install_directory(Path(install_dir)) if not is_safe: dlg = WarningDialog(reason, parent=self) if not dlg.exec() or not dlg.confirmed: return if not os.path.isdir(install_dir): create = MessageService.question(self, "Create Directory?", f"The install directory does not exist:\n{install_dir}\n\nWould you like to create it?", critical=False # Non-critical, won't steal focus ) if create == QMessageBox.Yes: try: os.makedirs(install_dir, exist_ok=True) except Exception as e: MessageService.critical(self, "Error", f"Failed to create install directory:\n{e}") return else: return # Start TTW installation self.console.clear() self.process_monitor.clear() # Update button states for installation self.start_btn.setEnabled(False) self.cancel_btn.setVisible(False) self.cancel_install_btn.setVisible(True) debug_print(f'DEBUG: Calling run_ttw_installer with mpi_path={mpi_path}, install_dir={install_dir}') self.run_ttw_installer(mpi_path, install_dir) except Exception as e: debug_print(f"DEBUG: Exception in validate_and_start_install: {e}") import traceback debug_print(f"DEBUG: Traceback: {traceback.format_exc()}") # Re-enable all controls after exception self._enable_controls_after_operation() self.cancel_btn.setVisible(True) self.cancel_install_btn.setVisible(False) debug_print(f"DEBUG: Controls re-enabled in exception handler") def run_ttw_installer(self, mpi_path, install_dir): debug_print('DEBUG: run_ttw_installer called - USING THREADED BACKEND WRAPPER') # CRITICAL: Reload config from disk to pick up any settings changes from Settings dialog # This ensures Proton version and winetricks settings are current self.config_handler._load_config() # Rotate log file at start of each workflow run (keep 5 backups) from jackify.backend.handlers.logging_handler import LoggingHandler from pathlib import Path log_handler = LoggingHandler() log_handler.rotate_log_file_per_run(Path(self.modlist_log_path), backup_count=5) # Clear console for fresh installation output self.console.clear() self._safe_append_text("Starting TTW installation...") # Show status banner and show details checkbox self.status_banner.setVisible(True) self.status_banner.setText("Initializing TTW installation...") self.show_details_checkbox.setVisible(True) # Reset banner to default blue color for new installation self.status_banner.setStyleSheet(f""" background-color: #2a2a2a; color: {JACKIFY_COLOR_BLUE}; padding: 8px; border-radius: 4px; font-weight: bold; font-size: 13px; """) self.ttw_start_time = time.time() # Start a timer to update elapsed time self.ttw_elapsed_timer = QTimer() self.ttw_elapsed_timer.timeout.connect(self._update_ttw_elapsed_time) self.ttw_elapsed_timer.start(1000) # Update every second # Update UI state for installation self.start_btn.setEnabled(False) self.cancel_btn.setVisible(False) self.cancel_install_btn.setVisible(True) # Create installation thread from PySide6.QtCore import QThread, Signal class TTWInstallationThread(QThread): output_received = Signal(str) progress_received = Signal(str) installation_finished = Signal(bool, str) def __init__(self, mpi_path, install_dir): super().__init__() self.mpi_path = mpi_path self.install_dir = install_dir self.cancelled = False self.proc = None def cancel(self): self.cancelled = True try: if self.proc and self.proc.poll() is None: self.proc.terminate() except Exception: pass def run(self): try: from jackify.backend.handlers.hoolamike_handler import HoolamikeHandler from jackify.backend.handlers.filesystem_handler import FileSystemHandler from jackify.backend.handlers.config_handler import ConfigHandler from jackify.backend.models.configuration import SystemInfo from jackify.backend.handlers.subprocess_utils import get_clean_subprocess_env import subprocess, sys # Prepare backend config (do not run process here) filesystem_handler = FileSystemHandler() config_handler = ConfigHandler() system_info = SystemInfo(is_steamdeck=False) hoolamike_handler = HoolamikeHandler( steamdeck=False, verbose=False, filesystem_handler=filesystem_handler, config_handler=config_handler ) # Update config for TTW and save hoolamike_handler._update_hoolamike_config_for_ttw( Path(self.mpi_path), Path(self.install_dir) ) if not hoolamike_handler.save_hoolamike_config(): self.installation_finished.emit(False, "Failed to save hoolamike.yaml") return hoolamike_handler._check_hoolamike_installation() if not hoolamike_handler.hoolamike_executable_path: self.installation_finished.emit(False, "Hoolamike executable not found. Please install Hoolamike.") return cmd = [str(hoolamike_handler.hoolamike_executable_path), "tale-of-two-wastelands"] env = get_clean_subprocess_env() # Use info level to get progress bar updates from indicatif # Our output filtering will parse the progress indicators env['RUST_LOG'] = 'info' cwd = str(hoolamike_handler.hoolamike_app_install_path) # Stream output live to GUI self.proc = subprocess.Popen( cmd, cwd=cwd, env=env, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, bufsize=1, universal_newlines=True, ) assert self.proc.stdout is not None for line in self.proc.stdout: if self.cancelled: break self.output_received.emit(line.rstrip()) returncode = self.proc.wait() if self.cancelled: self.installation_finished.emit(False, "Installation cancelled by user") elif returncode == 0: self.installation_finished.emit(True, "TTW installation completed successfully!") else: self.installation_finished.emit(False, f"TTW installation failed with exit code {returncode}") except Exception as e: self.installation_finished.emit(False, f"Installation error: {str(e)}") # Start the installation thread self.install_thread = TTWInstallationThread(mpi_path, install_dir) self.install_thread.output_received.connect(self.on_installation_output) self.install_thread.progress_received.connect(self.on_installation_progress) self.install_thread.installation_finished.connect(self.on_installation_finished) self.install_thread.start() def on_installation_output(self, message): """Handle regular output from installation thread with smart progress parsing""" # Filter out internal status messages from user console if message.strip().startswith('[Jackify]'): # Log internal messages to file but don't show in console self._write_to_log_file(message) return # Strip ANSI terminal control codes (cursor movement, line clearing, etc.) cleaned = strip_ansi_control_codes(message).strip() # Filter out empty lines after stripping control codes if not cleaned: return # If user asked to see details, show the raw cleaned line first (INFO-level verbosity) try: if self.show_details_checkbox.isChecked(): self._safe_append_text(cleaned) except Exception: pass import re # Try to extract total asset count from the completion message success_match = re.search(r'succesfully installed \[(\d+)\] assets', cleaned) if success_match: total = int(success_match.group(1)) if not hasattr(self, 'ttw_asset_count'): self.ttw_asset_count = 0 # Cache this total for future installs in config from jackify.backend.handlers.config_handler import ConfigHandler config_handler = ConfigHandler() config_handler.set('ttw_asset_count_cache', total) self._safe_append_text(f"\nInstallation complete: {total} assets processed successfully!") return # Parse progress bar lines: "▕bar▏(123/456 ETA 10m ELAPSED 5m) handling_assets" progress_match = re.search(r'\((\d+)/(\d+)\s+ETA\s+([^\)]+)\)\s*(.*)', cleaned) if progress_match: current = int(progress_match.group(1)) total = int(progress_match.group(2)) # Store total for later use if not hasattr(self, 'ttw_total_assets'): self.ttw_total_assets = total task = progress_match.group(4).strip() or "Processing" percent = int((current / total) * 100) if total > 0 else 0 elapsed = int(time.time() - self.ttw_start_time) if hasattr(self, 'ttw_start_time') else 0 elapsed_min = elapsed // 60 elapsed_sec = elapsed % 60 self.status_banner.setText( f"{task}: {current}/{total} ({percent}%) | Elapsed: {elapsed_min}m {elapsed_sec}s" ) # Show progress updates every 100 assets in console (keep it minimal) if current % 100 == 0: self._safe_append_text(f"Progress: {current}/{total} assets ({percent}%)") return lower_cleaned = cleaned.lower() # Detect phases and extract useful information if 'extracting_manifest' in cleaned: self._safe_append_text("Extracting TTW manifest from .mpi file...") return if 'handling_assets_for_location' in cleaned: # Parse location being processed location_match = re.search(r'location=([^}]+)', cleaned) if location_match: location = location_match.group(1).strip() self._safe_append_text(f"Processing location: {location}") return if 'building_archive' in cleaned: self._safe_append_text("Building BSA archives...") return # Filter out variable resolution spam (MAGICALLY messages) if 'magically' in lower_cleaned or 'variable_name=' in cleaned or 'resolve_variable' in cleaned: # Extract total from manifest if present if 'got manifest file' in lower_cleaned: self._safe_append_text("Loading TTW manifest...") return # Use known asset count for TTW 3.4 # Actual count: 215,396 assets (measured from complete installation of TTW 3.4) # This will need updating if TTW releases a new version if 'got manifest file' in lower_cleaned and not hasattr(self, 'ttw_total_assets'): self.ttw_total_assets = 215396 self._safe_append_text(f"Loading TTW manifest ({self.ttw_total_assets:,} assets)...") return # Filter out ALL per-asset processing messages if 'handling_asset{kind=' in cleaned: # Track progress by counting these messages if not hasattr(self, 'ttw_asset_count'): self.ttw_asset_count = 0 self.ttw_asset_count += 1 # Update banner every 10 assets processed if self.ttw_asset_count % 10 == 0: elapsed = int(time.time() - self.ttw_start_time) if hasattr(self, 'ttw_start_time') else 0 elapsed_min = elapsed // 60 elapsed_sec = elapsed % 60 # Show with total if we have it if hasattr(self, 'ttw_total_assets'): percent = int((self.ttw_asset_count / self.ttw_total_assets) * 100) self.status_banner.setText( f"Processing assets... {self.ttw_asset_count}/{self.ttw_total_assets} ({percent}%) | Elapsed: {elapsed_min}m {elapsed_sec}s" ) else: self.status_banner.setText( f"Processing assets... ({self.ttw_asset_count} completed) | Elapsed: {elapsed_min}m {elapsed_sec}s" ) return # Don't show per-asset messages in console # Filter out per-file verbose messages if 'wrote [' in cleaned and 'bytes]' in cleaned: return if '[ok]' in lower_cleaned: return # Skip all [OK] messages # Filter out version/metadata spam at start if any(x in lower_cleaned for x in ['install:installing_ttw', 'title=', 'version=', 'author=', 'description=']): if 'installing_ttw{' in cleaned: # Extract just the version/title cleanly version_match = re.search(r'version=([\d.]+)\s+title=([^}]+)', cleaned) if version_match: self._safe_append_text(f"Installing {version_match.group(2)} v{version_match.group(1)}") return # Keep important messages: errors, warnings, completions important_keywords = [ 'error', 'warning', 'failed', 'patch applied' ] # Show only important messages if any(kw in lower_cleaned for kw in important_keywords): # Strip out emojis if present cleaned_no_emoji = re.sub(r'[⭐☢️🩹]', '', cleaned) self._safe_append_text(cleaned_no_emoji.strip()) # Auto-expand console on errors/warnings if any(kw in lower_cleaned for kw in ['error', 'warning', 'failed']): if not self.show_details_checkbox.isChecked(): self.show_details_checkbox.setChecked(True) def on_installation_progress(self, progress_message): """Replace the last line in the console for progress updates""" cursor = self.console.textCursor() cursor.movePosition(QTextCursor.End) cursor.movePosition(QTextCursor.StartOfLine, QTextCursor.KeepAnchor) cursor.removeSelectedText() cursor.insertText(progress_message) # Don't force scroll for progress updates - let user control def _update_ttw_elapsed_time(self): """Update status banner with elapsed time""" if hasattr(self, 'ttw_start_time'): elapsed = int(time.time() - self.ttw_start_time) minutes = elapsed // 60 seconds = elapsed % 60 self.status_banner.setText(f"Processing Tale of Two Wastelands installation... Elapsed: {minutes}m {seconds}s") def on_installation_finished(self, success, message): """Handle installation completion""" debug_print(f"DEBUG: on_installation_finished called with success={success}, message={message}") # Stop elapsed timer if hasattr(self, 'ttw_elapsed_timer'): self.ttw_elapsed_timer.stop() # Update status banner if success: elapsed = int(time.time() - self.ttw_start_time) if hasattr(self, 'ttw_start_time') else 0 minutes = elapsed // 60 seconds = elapsed % 60 self.status_banner.setText(f"Installation completed successfully! Total time: {minutes}m {seconds}s") self.status_banner.setStyleSheet(f""" background-color: #1a4d1a; color: #4CAF50; padding: 8px; border-radius: 4px; font-weight: bold; font-size: 13px; """) self._safe_append_text(f"\nSuccess: {message}") self.process_finished(0, QProcess.NormalExit) else: self.status_banner.setText(f"Installation failed: {message}") self.status_banner.setStyleSheet(f""" background-color: #4d1a1a; color: #f44336; padding: 8px; border-radius: 4px; font-weight: bold; font-size: 13px; """) self._safe_append_text(f"\nError: {message}") self.process_finished(1, QProcess.CrashExit) def process_finished(self, exit_code, exit_status): debug_print(f"DEBUG: process_finished called with exit_code={exit_code}, exit_status={exit_status}") # Reset button states self.start_btn.setEnabled(True) self.cancel_btn.setVisible(True) self.cancel_install_btn.setVisible(False) debug_print("DEBUG: Button states reset in process_finished") if exit_code == 0: # TTW installation complete self._safe_append_text("\nTTW installation completed successfully!") self._safe_append_text("The merged TTW files have been created in the output directory.") # Check if we're in modlist integration mode if self._integration_mode: self._safe_append_text("\nIntegrating TTW into modlist...") self._perform_modlist_integration() else: # Standard mode - ask user if they want to create a mod archive for MO2 reply = MessageService.question( self, "TTW Installation Complete!", "Tale of Two Wastelands installation completed successfully!\n\n" f"Output location: {self.install_dir_edit.text()}\n\n" "Would you like to create a zipped mod archive for MO2?\n" "This will package the TTW files for easy installation into Mod Organizer 2.", critical=False ) if reply == QMessageBox.Yes: self._create_ttw_mod_archive() else: MessageService.information( self, "Installation Complete", "TTW installation complete!\n\n" "You can manually use the TTW files from the output directory.", safety_level="medium" ) else: # Check for user cancellation first last_output = self.console.toPlainText() if "cancelled by user" in last_output.lower(): MessageService.information(self, "Installation Cancelled", "The installation was cancelled by the user.", safety_level="low") else: MessageService.critical(self, "Install Failed", "The modlist install failed. Please check the console output for details.") self._safe_append_text(f"\nInstall failed (exit code {exit_code}).") self.console.moveCursor(QTextCursor.End) def _setup_scroll_tracking(self): """Set up scroll tracking for professional auto-scroll behavior""" scrollbar = self.console.verticalScrollBar() scrollbar.sliderPressed.connect(self._on_scrollbar_pressed) scrollbar.sliderReleased.connect(self._on_scrollbar_released) scrollbar.valueChanged.connect(self._on_scrollbar_value_changed) def _on_scrollbar_pressed(self): """User started manually scrolling""" self._user_manually_scrolled = True def _on_scrollbar_released(self): """User finished manually scrolling""" self._user_manually_scrolled = False def _on_scrollbar_value_changed(self): """Track if user is at bottom of scroll area""" scrollbar = self.console.verticalScrollBar() # Use tolerance to account for rounding and rapid updates self._was_at_bottom = scrollbar.value() >= scrollbar.maximum() - 1 # If user manually scrolls to bottom, reset manual scroll flag if self._was_at_bottom and self._user_manually_scrolled: # Small delay to allow user to scroll away if they want from PySide6.QtCore import QTimer QTimer.singleShot(100, self._reset_manual_scroll_if_at_bottom) def _reset_manual_scroll_if_at_bottom(self): """Reset manual scroll flag if user is still at bottom after delay""" scrollbar = self.console.verticalScrollBar() if scrollbar.value() >= scrollbar.maximum() - 1: self._user_manually_scrolled = False def _on_show_details_toggled(self, checked: bool): from PySide6.QtCore import Qt as _Qt self._toggle_console_visibility(_Qt.Checked if checked else _Qt.Unchecked) def _toggle_console_visibility(self, state): """Toggle console visibility and resize main window""" is_checked = (state == Qt.Checked) main_window = self.window() if not main_window: return # Check if we're on Steam Deck is_steamdeck = False if self.system_info and getattr(self.system_info, 'is_steamdeck', False): is_steamdeck = True elif not self.system_info and main_window and hasattr(main_window, 'system_info'): is_steamdeck = getattr(main_window.system_info, 'is_steamdeck', False) # Console height when expanded console_height = 300 if is_checked: # Show console self.console.setVisible(True) self.console.show() self.console.setMinimumHeight(200) self.console.setMaximumHeight(16777215) try: self.console.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) except Exception: pass try: self.main_overall_vbox.setStretchFactor(self.console, 1) except Exception: pass # On Steam Deck, skip window resizing - keep default Steam Deck window size if is_steamdeck: debug_print("DEBUG: Steam Deck detected, skipping window resize in _toggle_console_visibility") return # Restore main window to normal size (clear any compact constraints) main_window.showNormal() main_window.setMaximumHeight(16777215) main_window.setMinimumHeight(0) # Restore original minimum size so the window can expand normally try: if self._saved_min_size is not None: main_window.setMinimumSize(self._saved_min_size) except Exception: pass # Prefer exact original geometry if known if self._saved_geometry is not None: main_window.setGeometry(self._saved_geometry) else: 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) try: # Encourage layouts to recompute sizes self.main_overall_vbox.invalidate() self.updateGeometry() except Exception: pass # Notify parent to expand try: self.resize_request.emit('expand') except Exception: pass else: # Hide console fully (removes it from layout sizing) self.console.setVisible(False) self.console.hide() self.console.setMinimumHeight(0) self.console.setMaximumHeight(0) try: # Make the hidden console contribute no expand pressure self.console.setSizePolicy(QSizePolicy.Ignored, QSizePolicy.Fixed) except Exception: pass try: self.main_overall_vbox.setStretchFactor(self.console, 0) except Exception: pass # On Steam Deck, skip window resizing to keep maximized state if is_steamdeck: debug_print("DEBUG: Steam Deck detected, skipping window resize in collapse branch") return # Shrink main window to a compact height so no extra space remains # Use the screen's sizeHint to choose a minimal-but-safe height (tighter) size_hint = self.sizeHint().height() new_min_height = max(440, min(540, size_hint + 20)) main_window.showNormal() # Temporarily clamp max to enforce the smaller collapsed size; parent clears on expand main_window.setMaximumHeight(new_min_height) main_window.setMinimumHeight(new_min_height) # Lower the main window minimum size vertically so it can collapse try: from PySide6.QtCore import QSize current_min = self._saved_min_size or main_window.minimumSize() main_window.setMinimumSize(QSize(current_min.width(), new_min_height)) except Exception: pass # Resize to compact height to avoid leftover space current_size = main_window.size() main_window.resize(current_size.width(), new_min_height) try: self.main_overall_vbox.invalidate() self.updateGeometry() except Exception: pass # Notify parent to collapse try: self.resize_request.emit('collapse') except Exception: pass def _safe_append_text(self, text): """Append text with professional auto-scroll behavior""" # Write all messages to log file (including internal messages) self._write_to_log_file(text) # Filter out internal status messages from user console display if text.strip().startswith('[Jackify]'): # Internal messages are logged but not shown in user console return scrollbar = self.console.verticalScrollBar() # Check if user was at bottom BEFORE adding text was_at_bottom = (scrollbar.value() >= scrollbar.maximum() - 1) # Allow 1px tolerance # Add the text self.console.append(text) # Auto-scroll if user was at bottom and hasn't manually scrolled # Re-check bottom state after text addition for better reliability if (was_at_bottom and not self._user_manually_scrolled) or \ (not self._user_manually_scrolled and scrollbar.value() >= scrollbar.maximum() - 2): scrollbar.setValue(scrollbar.maximum()) # Ensure user can still manually scroll up during rapid updates if scrollbar.value() == scrollbar.maximum(): self._was_at_bottom = True def _write_to_log_file(self, message): """Write message to workflow log file with timestamp""" try: from datetime import datetime timestamp = datetime.now().strftime('%Y-%m-%d %H:%M:%S') with open(self.modlist_log_path, 'a', encoding='utf-8') as f: f.write(f"[{timestamp}] {message}\n") except Exception: # Logging should never break the workflow pass def restart_steam_and_configure(self): """Restart Steam using backend service directly - DECOUPLED FROM CLI""" debug_print("DEBUG: restart_steam_and_configure called - using direct backend service") progress = QProgressDialog("Restarting Steam...", None, 0, 0, self) progress.setWindowTitle("Restarting Steam") progress.setWindowModality(Qt.WindowModal) progress.setMinimumDuration(0) progress.setValue(0) progress.show() def do_restart(): debug_print("DEBUG: do_restart thread started - using direct backend service") try: from jackify.backend.handlers.shortcut_handler import ShortcutHandler # Use backend service directly instead of CLI subprocess shortcut_handler = ShortcutHandler(steamdeck=False) # TODO: Use proper system info debug_print("DEBUG: About to call secure_steam_restart()") success = shortcut_handler.secure_steam_restart() debug_print(f"DEBUG: secure_steam_restart() returned: {success}") out = "Steam restart completed successfully." if success else "Steam restart failed." except Exception as e: debug_print(f"DEBUG: Exception in do_restart: {e}") success = False out = str(e) self.steam_restart_finished.emit(success, out) threading.Thread(target=do_restart, daemon=True).start() self._steam_restart_progress = progress # Store to close later def _on_steam_restart_finished(self, success, out): debug_print("DEBUG: _on_steam_restart_finished called") # Safely cleanup progress dialog on main thread if hasattr(self, '_steam_restart_progress') and self._steam_restart_progress: try: self._steam_restart_progress.close() self._steam_restart_progress.deleteLater() # Use deleteLater() for safer cleanup except Exception as e: debug_print(f"DEBUG: Error closing progress dialog: {e}") finally: self._steam_restart_progress = None # Controls are managed by the proper control management system if success: self._safe_append_text("Steam restarted successfully.") # Save context for later use in configuration self._manual_steps_retry_count = 0 self._current_modlist_name = "TTW Installation" # Fixed name for TTW self._current_resolution = None # TTW doesn't need resolution changes # Use automated prefix creation instead of manual steps debug_print("DEBUG: Starting automated prefix creation workflow") self._safe_append_text("Starting automated prefix creation workflow...") self.start_automated_prefix_workflow() else: self._safe_append_text("Failed to restart Steam.\n" + out) MessageService.critical(self, "Steam Restart Failed", "Failed to restart Steam automatically. Please restart Steam manually, then try again.") def start_automated_prefix_workflow(self): # Ensure _current_resolution is always set before starting workflow if not hasattr(self, '_current_resolution') or self._current_resolution is None: resolution = None # TTW doesn't need resolution changes # Extract resolution properly (e.g., "1280x800" from "1280x800 (Steam Deck)") if resolution and resolution != "Leave unchanged": if " (" in resolution: self._current_resolution = resolution.split(" (")[0] else: self._current_resolution = resolution else: self._current_resolution = None """Start the automated prefix creation workflow""" try: # Disable controls during installation self._disable_controls_during_operation() modlist_name = "TTW Installation" install_dir = self.install_dir_edit.text().strip() final_exe_path = os.path.join(install_dir, "ModOrganizer.exe") if not os.path.exists(final_exe_path): # Check if this is Somnium specifically (uses files/ subdirectory) modlist_name_lower = modlist_name.lower() if "somnium" in modlist_name_lower: somnium_exe_path = os.path.join(install_dir, "files", "ModOrganizer.exe") if os.path.exists(somnium_exe_path): final_exe_path = somnium_exe_path self._safe_append_text(f"Detected Somnium modlist - will proceed with automated setup") # Show Somnium guidance popup after automated workflow completes self._show_somnium_guidance = True self._somnium_install_dir = install_dir else: self._safe_append_text(f"ERROR: Somnium ModOrganizer.exe not found at {somnium_exe_path}") MessageService.critical(self, "Somnium ModOrganizer.exe Not Found", f"Expected Somnium ModOrganizer.exe not found at:\n{somnium_exe_path}\n\nCannot proceed with automated setup.") return else: self._safe_append_text(f"ERROR: ModOrganizer.exe not found at {final_exe_path}") MessageService.critical(self, "ModOrganizer.exe Not Found", f"ModOrganizer.exe not found at:\n{final_exe_path}\n\nCannot proceed with automated setup.") return # Run automated prefix creation in separate thread from PySide6.QtCore import QThread, Signal class AutomatedPrefixThread(QThread): finished = Signal(bool, str, str, str) # success, prefix_path, appid (as string), last_timestamp progress = Signal(str) # progress messages error = Signal(str) # error messages show_progress_dialog = Signal(str) # show progress dialog with message hide_progress_dialog = Signal() # hide progress dialog conflict_detected = Signal(list) # conflicts list def __init__(self, modlist_name, install_dir, final_exe_path): super().__init__() self.modlist_name = modlist_name self.install_dir = install_dir self.final_exe_path = final_exe_path def run(self): try: from jackify.backend.services.automated_prefix_service import AutomatedPrefixService def progress_callback(message): self.progress.emit(message) # Show progress dialog during Steam restart if "Steam restarted successfully" in message: self.hide_progress_dialog.emit() elif "Restarting Steam..." in message: self.show_progress_dialog.emit("Restarting Steam...") prefix_service = AutomatedPrefixService() # Determine Steam Deck once and pass through the workflow try: import os _is_steamdeck = False if os.path.exists('/etc/os-release'): with open('/etc/os-release') as f: if 'steamdeck' in f.read().lower(): _is_steamdeck = True except Exception: _is_steamdeck = False result = prefix_service.run_working_workflow( self.modlist_name, self.install_dir, self.final_exe_path, progress_callback, steamdeck=_is_steamdeck ) # Handle the result - check for conflicts if isinstance(result, tuple) and len(result) == 4: if result[0] == "CONFLICT": # Conflict detected - emit signal to main GUI conflicts = result[1] self.hide_progress_dialog.emit() self.conflict_detected.emit(conflicts) return else: # Normal result with timestamp success, prefix_path, new_appid, last_timestamp = result elif isinstance(result, tuple) and len(result) == 3: # Fallback for old format (backward compatibility) if result[0] == "CONFLICT": # Conflict detected - emit signal to main GUI conflicts = result[1] self.hide_progress_dialog.emit() self.conflict_detected.emit(conflicts) return else: # Normal result (old format) success, prefix_path, new_appid = result last_timestamp = None else: # Handle non-tuple result success = result prefix_path = "" new_appid = "0" last_timestamp = None # Ensure progress dialog is hidden when workflow completes self.hide_progress_dialog.emit() self.finished.emit(success, prefix_path or "", str(new_appid) if new_appid else "0", last_timestamp) except Exception as e: # Ensure progress dialog is hidden on error self.hide_progress_dialog.emit() self.error.emit(str(e)) # Create and start thread self.prefix_thread = AutomatedPrefixThread(modlist_name, install_dir, final_exe_path) self.prefix_thread.finished.connect(self.on_automated_prefix_finished) self.prefix_thread.error.connect(self.on_automated_prefix_error) self.prefix_thread.progress.connect(self.on_automated_prefix_progress) self.prefix_thread.show_progress_dialog.connect(self.show_steam_restart_progress) self.prefix_thread.hide_progress_dialog.connect(self.hide_steam_restart_progress) self.prefix_thread.conflict_detected.connect(self.show_shortcut_conflict_dialog) self.prefix_thread.start() except Exception as e: debug_print(f"DEBUG: Exception in start_automated_prefix_workflow: {e}") import traceback debug_print(f"DEBUG: Traceback: {traceback.format_exc()}") self._safe_append_text(f"ERROR: Failed to start automated workflow: {e}") # Re-enable controls on exception self._enable_controls_after_operation() def on_automated_prefix_finished(self, success, prefix_path, new_appid_str, last_timestamp=None): """Handle completion of automated prefix creation""" try: if success: debug_print(f"SUCCESS: Automated prefix creation completed!") debug_print(f"Prefix created at: {prefix_path}") if new_appid_str and new_appid_str != "0": debug_print(f"AppID: {new_appid_str}") # Convert string AppID back to integer for configuration new_appid = int(new_appid_str) if new_appid_str and new_appid_str != "0" else None # Continue with configuration using the new AppID and timestamp modlist_name = "TTW Installation" install_dir = self.install_dir_edit.text().strip() self.continue_configuration_after_automated_prefix(new_appid, modlist_name, install_dir, last_timestamp) else: self._safe_append_text(f"ERROR: Automated prefix creation failed") self._safe_append_text("Please check the logs for details") MessageService.critical(self, "Automated Setup Failed", "Automated prefix creation failed. Please check the console output for details.") # Re-enable controls on failure self._enable_controls_after_operation() finally: # Always ensure controls are re-enabled when workflow truly completes pass def on_automated_prefix_error(self, error_msg): """Handle error in automated prefix creation""" self._safe_append_text(f"ERROR: Error during automated prefix creation: {error_msg}") MessageService.critical(self, "Automated Setup Error", f"Error during automated prefix creation: {error_msg}") # Re-enable controls on error self._enable_controls_after_operation() def on_automated_prefix_progress(self, progress_msg): """Handle progress updates from automated prefix creation""" self._safe_append_text(progress_msg) def on_configuration_progress(self, progress_msg): """Handle progress updates from modlist configuration""" self._safe_append_text(progress_msg) def show_steam_restart_progress(self, message): """Show Steam restart progress dialog""" from PySide6.QtWidgets import QProgressDialog from PySide6.QtCore import Qt self.steam_restart_progress = QProgressDialog(message, None, 0, 0, self) self.steam_restart_progress.setWindowTitle("Restarting Steam") self.steam_restart_progress.setWindowModality(Qt.WindowModal) self.steam_restart_progress.setMinimumDuration(0) self.steam_restart_progress.setValue(0) self.steam_restart_progress.show() def hide_steam_restart_progress(self): """Hide Steam restart progress dialog""" if hasattr(self, 'steam_restart_progress') and self.steam_restart_progress: try: self.steam_restart_progress.close() self.steam_restart_progress.deleteLater() except Exception: pass finally: self.steam_restart_progress = None # Controls are managed by the proper control management system def on_configuration_complete(self, success, message, modlist_name): """Handle configuration completion on main thread""" try: # Re-enable controls now that installation/configuration is complete self._enable_controls_after_operation() if success: # Check if we need to show Somnium guidance if self._show_somnium_guidance: self._show_somnium_post_install_guidance() # Show celebration SuccessDialog after the entire workflow from ..dialogs import SuccessDialog import time if not hasattr(self, '_install_workflow_start_time'): self._install_workflow_start_time = time.time() time_taken = int(time.time() - self._install_workflow_start_time) mins, secs = divmod(time_taken, 60) time_str = f"{mins} minutes, {secs} seconds" if mins else f"{secs} seconds" display_names = { 'skyrim': 'Skyrim', 'fallout4': 'Fallout 4', 'falloutnv': 'Fallout New Vegas', 'oblivion': 'Oblivion', 'starfield': 'Starfield', 'oblivion_remastered': 'Oblivion Remastered', 'enderal': 'Enderal' } game_name = display_names.get(self._current_game_type, self._current_game_name) success_dialog = SuccessDialog( modlist_name=modlist_name, workflow_type="install", time_taken=time_str, game_name=game_name, parent=self ) success_dialog.show() elif hasattr(self, '_manual_steps_retry_count') and self._manual_steps_retry_count >= 3: # Max retries reached - show failure message MessageService.critical(self, "Manual Steps Failed", "Manual steps validation failed after multiple attempts.") else: # Configuration failed for other reasons MessageService.critical(self, "Configuration Failed", "Post-install configuration failed. Please check the console output.") except Exception as e: # Ensure controls are re-enabled even on unexpected errors self._enable_controls_after_operation() raise # Clean up thread if hasattr(self, 'config_thread') and self.config_thread is not None: # Disconnect all signals to prevent "Internal C++ object already deleted" errors try: self.config_thread.progress_update.disconnect() self.config_thread.configuration_complete.disconnect() self.config_thread.error_occurred.disconnect() except: pass # Ignore errors if already disconnected if self.config_thread.isRunning(): self.config_thread.quit() self.config_thread.wait(5000) # Wait up to 5 seconds self.config_thread.deleteLater() self.config_thread = None def on_configuration_error(self, error_message): """Handle configuration error on main thread""" self._safe_append_text(f"Configuration failed with error: {error_message}") MessageService.critical(self, "Configuration Error", f"Configuration failed: {error_message}") # Re-enable all controls on error self._enable_controls_after_operation() # Clean up thread if hasattr(self, 'config_thread') and self.config_thread is not None: # Disconnect all signals to prevent "Internal C++ object already deleted" errors try: self.config_thread.progress_update.disconnect() self.config_thread.configuration_complete.disconnect() self.config_thread.error_occurred.disconnect() except: pass # Ignore errors if already disconnected if self.config_thread.isRunning(): self.config_thread.quit() self.config_thread.wait(5000) # Wait up to 5 seconds self.config_thread.deleteLater() self.config_thread = None def show_manual_steps_dialog(self, extra_warning=""): modlist_name = "TTW Installation" msg = ( f"Manual Proton Setup Required for {modlist_name}
" "After Steam restarts, complete the following steps in Steam:
" f"1. Locate the '{modlist_name}' entry in your Steam Library
" "2. Right-click and select 'Properties'
" "3. Switch to the 'Compatibility' tab
" "4. Check the box labeled 'Force the use of a specific Steam Play compatibility tool'
" "5. Select 'Proton - Experimental' from the dropdown menu
" "6. Close the Properties window
" f"7. Launch '{modlist_name}' from your Steam Library
" "8. Wait for Mod Organizer 2 to fully open
" "9. Once Mod Organizer has fully loaded, CLOSE IT completely and return here
" "
Once you have completed ALL the steps above, click OK to continue." f"{extra_warning}" ) reply = MessageService.question(self, "Manual Steps Required", msg, safety_level="medium") if reply == QMessageBox.Yes: self.validate_manual_steps_completion() else: # User clicked Cancel or closed the dialog - cancel the workflow self._safe_append_text("\n🛑 Manual steps cancelled by user. Workflow stopped.") # Re-enable all controls when workflow is cancelled self._enable_controls_after_operation() self.cancel_btn.setVisible(True) self.cancel_install_btn.setVisible(False) def _get_mo2_path(self, install_dir, modlist_name): """Get ModOrganizer.exe path, handling Somnium's non-standard structure""" mo2_exe_path = os.path.join(install_dir, "ModOrganizer.exe") if not os.path.exists(mo2_exe_path) and "somnium" in modlist_name.lower(): somnium_path = os.path.join(install_dir, "files", "ModOrganizer.exe") if os.path.exists(somnium_path): mo2_exe_path = somnium_path return mo2_exe_path def validate_manual_steps_completion(self): """Validate that manual steps were actually completed and handle retry logic""" modlist_name = "TTW Installation" install_dir = self.install_dir_edit.text().strip() mo2_exe_path = self._get_mo2_path(install_dir, modlist_name) # Add delay to allow Steam filesystem updates to complete self._safe_append_text("Waiting for Steam filesystem updates to complete...") import time time.sleep(2) # CRITICAL: Re-detect the AppID after Steam restart and manual steps # Steam assigns a NEW AppID during restart, different from the one we initially created self._safe_append_text(f"Re-detecting AppID for shortcut '{modlist_name}' after Steam restart...") from jackify.backend.handlers.shortcut_handler import ShortcutHandler from jackify.backend.services.platform_detection_service import PlatformDetectionService platform_service = PlatformDetectionService.get_instance() shortcut_handler = ShortcutHandler(steamdeck=platform_service.is_steamdeck) current_appid = shortcut_handler.get_appid_for_shortcut(modlist_name, mo2_exe_path) if not current_appid or not current_appid.isdigit(): self._safe_append_text(f"Error: Could not find Steam-assigned AppID for shortcut '{modlist_name}'") self._safe_append_text("Error: This usually means the shortcut was not launched from Steam") self._safe_append_text("Suggestion: Check that Steam is running and shortcuts are visible in library") self.handle_validation_failure("Could not find Steam shortcut") return self._safe_append_text(f"Found Steam-assigned AppID: {current_appid}") self._safe_append_text(f"Validating manual steps completion for AppID: {current_appid}") # Check 1: Proton version proton_ok = False try: from jackify.backend.handlers.modlist_handler import ModlistHandler from jackify.backend.handlers.path_handler import PathHandler # Initialize ModlistHandler with correct parameters path_handler = PathHandler() # Use centralized Steam Deck detection from jackify.backend.services.platform_detection_service import PlatformDetectionService platform_service = PlatformDetectionService.get_instance() modlist_handler = ModlistHandler(steamdeck=platform_service.is_steamdeck, verbose=False) # Set required properties manually after initialization modlist_handler.modlist_dir = install_dir modlist_handler.appid = current_appid modlist_handler.game_var = "skyrimspecialedition" # Default for now # Set compat_data_path for Proton detection compat_data_path_str = path_handler.find_compat_data(current_appid) if compat_data_path_str: from pathlib import Path modlist_handler.compat_data_path = Path(compat_data_path_str) # Check Proton version self._safe_append_text(f"Attempting to detect Proton version for AppID {current_appid}...") if modlist_handler._detect_proton_version(): self._safe_append_text(f"Raw detected Proton version: '{modlist_handler.proton_ver}'") if modlist_handler.proton_ver and 'experimental' in modlist_handler.proton_ver.lower(): proton_ok = True self._safe_append_text(f"Proton version validated: {modlist_handler.proton_ver}") else: self._safe_append_text(f"Error: Wrong Proton version detected: '{modlist_handler.proton_ver}' (expected 'experimental' in name)") else: self._safe_append_text("Error: Could not detect Proton version from any source") except Exception as e: self._safe_append_text(f"Error checking Proton version: {e}") proton_ok = False # Check 2: Compatdata directory exists compatdata_ok = False try: from jackify.backend.handlers.path_handler import PathHandler path_handler = PathHandler() self._safe_append_text(f"Searching for compatdata directory for AppID {current_appid}...") self._safe_append_text("Checking standard Steam locations and Flatpak Steam...") prefix_path_str = path_handler.find_compat_data(current_appid) self._safe_append_text(f"Compatdata search result: '{prefix_path_str}'") if prefix_path_str and os.path.isdir(prefix_path_str): compatdata_ok = True self._safe_append_text(f"Compatdata directory found: {prefix_path_str}") else: if prefix_path_str: self._safe_append_text(f"Error: Path exists but is not a directory: {prefix_path_str}") else: self._safe_append_text(f"Error: No compatdata directory found for AppID {current_appid}") self._safe_append_text("Suggestion: Ensure you launched the shortcut from Steam at least once") self._safe_append_text("Suggestion: Check if Steam is using Flatpak (different file paths)") except Exception as e: self._safe_append_text(f"Error checking compatdata: {e}") compatdata_ok = False # Handle validation results if proton_ok and compatdata_ok: self._safe_append_text("Manual steps validation passed!") self._safe_append_text("Continuing configuration with updated AppID...") # Continue configuration with the corrected AppID and context self.continue_configuration_after_manual_steps(current_appid, modlist_name, install_dir) else: # Validation failed - handle retry logic missing_items = [] if not proton_ok: missing_items.append("• Proton - Experimental not set") if not compatdata_ok: missing_items.append("• Shortcut not launched from Steam (no compatdata)") missing_text = "\n".join(missing_items) self._safe_append_text(f"Manual steps validation failed:\n{missing_text}") self.handle_validation_failure(missing_text) def show_shortcut_conflict_dialog(self, conflicts): """Show dialog to resolve shortcut name conflicts""" conflict_names = [c['name'] for c in conflicts] conflict_info = f"Found existing Steam shortcut: '{conflict_names[0]}'" modlist_name = "TTW Installation" # Create dialog with Jackify styling from PySide6.QtWidgets import QDialog, QVBoxLayout, QLabel, QLineEdit, QPushButton, QHBoxLayout from PySide6.QtCore import Qt dialog = QDialog(self) dialog.setWindowTitle("Steam Shortcut Conflict") dialog.setModal(True) dialog.resize(450, 180) # Apply Jackify dark theme styling dialog.setStyleSheet(""" QDialog { background-color: #2b2b2b; color: #ffffff; } QLabel { color: #ffffff; font-size: 14px; padding: 10px 0px; } QLineEdit { background-color: #404040; color: #ffffff; border: 2px solid #555555; border-radius: 4px; padding: 8px; font-size: 14px; selection-background-color: #3fd0ea; } QLineEdit:focus { border-color: #3fd0ea; } QPushButton { background-color: #404040; color: #ffffff; border: 2px solid #555555; border-radius: 4px; padding: 8px 16px; font-size: 14px; min-width: 120px; } QPushButton:hover { background-color: #505050; border-color: #3fd0ea; } QPushButton:pressed { background-color: #303030; } """) layout = QVBoxLayout(dialog) layout.setContentsMargins(20, 20, 20, 20) layout.setSpacing(15) # Conflict message conflict_label = QLabel(f"{conflict_info}\n\nPlease choose a different name for your shortcut:") layout.addWidget(conflict_label) # Text input for new name name_input = QLineEdit(modlist_name) name_input.selectAll() layout.addWidget(name_input) # Buttons button_layout = QHBoxLayout() button_layout.setSpacing(10) create_button = QPushButton("Create with New Name") cancel_button = QPushButton("Cancel") button_layout.addStretch() button_layout.addWidget(cancel_button) button_layout.addWidget(create_button) layout.addLayout(button_layout) # Connect signals def on_create(): new_name = name_input.text().strip() if new_name and new_name != modlist_name: dialog.accept() # Retry workflow with new name self.retry_automated_workflow_with_new_name(new_name) elif new_name == modlist_name: # Same name - show warning from jackify.backend.services.message_service import MessageService MessageService.warning(self, "Same Name", "Please enter a different name to resolve the conflict.") else: # Empty name from jackify.backend.services.message_service import MessageService MessageService.warning(self, "Invalid Name", "Please enter a valid shortcut name.") def on_cancel(): dialog.reject() self._safe_append_text("Shortcut creation cancelled by user") create_button.clicked.connect(on_create) cancel_button.clicked.connect(on_cancel) # Make Enter key work name_input.returnPressed.connect(on_create) dialog.exec() def retry_automated_workflow_with_new_name(self, new_name): """Retry the automated workflow with a new shortcut name""" # Update the modlist name field temporarily # TTW doesn't need name editing # Restart the automated workflow self._safe_append_text(f"Retrying with new shortcut name: '{new_name}'") self.start_automated_prefix_workflow() def continue_configuration_after_automated_prefix(self, new_appid, modlist_name, install_dir, last_timestamp=None): """Continue the configuration process with the new AppID after automated prefix creation""" # Headers are now shown at start of Steam Integration # No need to show them again here debug_print("Configuration phase continues after Steam Integration") debug_print(f"continue_configuration_after_automated_prefix called with appid: {new_appid}") try: # Update the context with the new AppID (same format as manual steps) updated_context = { 'name': modlist_name, 'path': install_dir, 'mo2_exe_path': self._get_mo2_path(install_dir, modlist_name), 'modlist_value': None, 'modlist_source': None, 'resolution': getattr(self, '_current_resolution', None), 'skip_confirmation': True, 'manual_steps_completed': True, # Mark as completed since automated prefix is done 'appid': new_appid, # Use the NEW AppID from automated prefix creation 'game_name': self.context.get('game_name', 'Skyrim Special Edition') if hasattr(self, 'context') else 'Skyrim Special Edition' } self.context = updated_context # Ensure context is always set debug_print(f"Updated context with new AppID: {new_appid}") # Get Steam Deck detection once and pass to ConfigThread from jackify.backend.services.platform_detection_service import PlatformDetectionService platform_service = PlatformDetectionService.get_instance() is_steamdeck = platform_service.is_steamdeck # Create new config thread with updated context class ConfigThread(QThread): progress_update = Signal(str) configuration_complete = Signal(bool, str, str) error_occurred = Signal(str) def __init__(self, context, is_steamdeck): super().__init__() self.context = context self.is_steamdeck = is_steamdeck def run(self): try: from jackify.backend.services.modlist_service import ModlistService from jackify.backend.models.configuration import SystemInfo from jackify.backend.models.modlist import ModlistContext from pathlib import Path # Initialize backend service with passed Steam Deck detection system_info = SystemInfo(is_steamdeck=self.is_steamdeck) modlist_service = ModlistService(system_info) # Convert context to ModlistContext for service modlist_context = ModlistContext( name=self.context['name'], install_dir=Path(self.context['path']), download_dir=Path(self.context['path']).parent / 'Downloads', # Default game_type='skyrim', # Default for now nexus_api_key='', # Not needed for configuration modlist_value=self.context.get('modlist_value'), modlist_source=self.context.get('modlist_source', 'identifier'), resolution=self.context.get('resolution'), skip_confirmation=True, engine_installed=True # Skip path manipulation for engine workflows ) # Add app_id to context modlist_context.app_id = self.context['appid'] # Define callbacks def progress_callback(message): self.progress_update.emit(message) def completion_callback(success, message, modlist_name): self.configuration_complete.emit(success, message, modlist_name) def manual_steps_callback(modlist_name, retry_count): # This shouldn't happen since automated prefix creation is complete self.progress_update.emit(f"Unexpected manual steps callback for {modlist_name}") # Call the service method for post-Steam configuration result = modlist_service.configure_modlist_post_steam( context=modlist_context, progress_callback=progress_callback, manual_steps_callback=manual_steps_callback, completion_callback=completion_callback ) if not result: self.progress_update.emit("Configuration failed to start") self.error_occurred.emit("Configuration failed to start") except Exception as e: self.error_occurred.emit(str(e)) # Start configuration thread self.config_thread = ConfigThread(updated_context, is_steamdeck) self.config_thread.progress_update.connect(self.on_configuration_progress) self.config_thread.configuration_complete.connect(self.on_configuration_complete) self.config_thread.error_occurred.connect(self.on_configuration_error) self.config_thread.start() except Exception as e: self._safe_append_text(f"Error continuing configuration: {e}") import traceback self._safe_append_text(f"Full traceback: {traceback.format_exc()}") self.on_configuration_error(str(e)) def continue_configuration_after_manual_steps(self, new_appid, modlist_name, install_dir): """Continue the configuration process with the corrected AppID after manual steps validation""" try: # Update the context with the new AppID updated_context = { 'name': modlist_name, 'path': install_dir, 'mo2_exe_path': self._get_mo2_path(install_dir, modlist_name), 'modlist_value': None, 'modlist_source': None, 'resolution': getattr(self, '_current_resolution', None), 'skip_confirmation': True, 'manual_steps_completed': True, # Mark as completed 'appid': new_appid # Use the NEW AppID from Steam } debug_print(f"Updated context with new AppID: {new_appid}") # Clean up old thread if exists and wait for it to finish if hasattr(self, 'config_thread') and self.config_thread is not None: # Disconnect all signals to prevent "Internal C++ object already deleted" errors try: self.config_thread.progress_update.disconnect() self.config_thread.configuration_complete.disconnect() self.config_thread.error_occurred.disconnect() except: pass # Ignore errors if already disconnected if self.config_thread.isRunning(): self.config_thread.quit() self.config_thread.wait(5000) # Wait up to 5 seconds self.config_thread.deleteLater() self.config_thread = None # Start new config thread self.config_thread = self._create_config_thread(updated_context) self.config_thread.progress_update.connect(self.on_configuration_progress) self.config_thread.configuration_complete.connect(self.on_configuration_complete) self.config_thread.error_occurred.connect(self.on_configuration_error) self.config_thread.start() except Exception as e: self._safe_append_text(f"Error continuing configuration: {e}") self.on_configuration_error(str(e)) def _create_config_thread(self, context): """Create a new ConfigThread with proper lifecycle management""" from PySide6.QtCore import QThread, Signal # Get Steam Deck detection once from jackify.backend.services.platform_detection_service import PlatformDetectionService platform_service = PlatformDetectionService.get_instance() is_steamdeck = platform_service.is_steamdeck class ConfigThread(QThread): progress_update = Signal(str) configuration_complete = Signal(bool, str, str) error_occurred = Signal(str) def __init__(self, context, is_steamdeck, parent=None): super().__init__(parent) self.context = context self.is_steamdeck = is_steamdeck def run(self): try: from jackify.backend.models.configuration import SystemInfo from jackify.backend.services.modlist_service import ModlistService from jackify.backend.models.modlist import ModlistContext from pathlib import Path # Initialize backend service with passed Steam Deck detection system_info = SystemInfo(is_steamdeck=self.is_steamdeck) modlist_service = ModlistService(system_info) # Convert context to ModlistContext for service modlist_context = ModlistContext( name=self.context['name'], install_dir=Path(self.context['path']), download_dir=Path(self.context['path']).parent / 'Downloads', # Default game_type='skyrim', # Default for now nexus_api_key='', # Not needed for configuration modlist_value=self.context.get('modlist_value', ''), modlist_source=self.context.get('modlist_source', 'identifier'), resolution=self.context.get('resolution'), # Pass resolution from GUI skip_confirmation=True, engine_installed=True # Skip path manipulation for engine workflows ) # Add app_id to context if 'appid' in self.context: modlist_context.app_id = self.context['appid'] # Define callbacks def progress_callback(message): self.progress_update.emit(message) def completion_callback(success, message, modlist_name): self.configuration_complete.emit(success, message, modlist_name) def manual_steps_callback(modlist_name, retry_count): # This shouldn't happen since manual steps should be done self.progress_update.emit(f"Unexpected manual steps callback for {modlist_name}") # Call the new service method for post-Steam configuration result = modlist_service.configure_modlist_post_steam( context=modlist_context, progress_callback=progress_callback, manual_steps_callback=manual_steps_callback, completion_callback=completion_callback ) if not result: self.progress_update.emit("WARNING: configure_modlist_post_steam returned False") except Exception as e: import traceback error_details = f"Error in configuration: {e}\nTraceback: {traceback.format_exc()}" self.progress_update.emit(f"DEBUG: {error_details}") self.error_occurred.emit(str(e)) return ConfigThread(context, is_steamdeck, parent=self) def handle_validation_failure(self, missing_text): """Handle failed validation with retry logic""" self._manual_steps_retry_count += 1 if self._manual_steps_retry_count < 3: # Show retry dialog with increasingly detailed guidance retry_guidance = "" if self._manual_steps_retry_count == 1: retry_guidance = "\n\nTip: Make sure Steam is fully restarted before trying again." elif self._manual_steps_retry_count == 2: retry_guidance = "\n\nTip: If using Flatpak Steam, ensure compatdata is being created in the correct location." MessageService.critical(self, "Manual Steps Incomplete", f"Manual steps validation failed:\n\n{missing_text}\n\n" f"Please complete the missing steps and try again.{retry_guidance}") # Show manual steps dialog again extra_warning = "" if self._manual_steps_retry_count >= 2: extra_warning = "
It looks like you have not completed the manual steps yet. Please try again." self.show_manual_steps_dialog(extra_warning) else: # Max retries reached MessageService.critical(self, "Manual Steps Failed", "Manual steps validation failed after multiple attempts.\n\n" "Common issues:\n" "• Steam not fully restarted\n" "• Shortcut not launched from Steam\n" "• Flatpak Steam using different file paths\n" "• Proton - Experimental not selected") self.on_configuration_complete(False, "Manual steps validation failed after multiple attempts", self._current_modlist_name) def show_next_steps_dialog(self, message): # EXACT LEGACY show_next_steps_dialog from PySide6.QtWidgets import QDialog, QVBoxLayout, QLabel, QPushButton, QHBoxLayout, QApplication dlg = QDialog(self) dlg.setWindowTitle("Next Steps") dlg.setModal(True) layout = QVBoxLayout(dlg) label = QLabel(message) label.setWordWrap(True) layout.addWidget(label) btn_row = QHBoxLayout() btn_return = QPushButton("Return") btn_exit = QPushButton("Exit") btn_row.addWidget(btn_return) btn_row.addWidget(btn_exit) layout.addLayout(btn_row) def on_return(): dlg.accept() if self.stacked_widget: self.stacked_widget.setCurrentIndex(0) # Main menu def on_exit(): QApplication.quit() btn_return.clicked.connect(on_return) btn_exit.clicked.connect(on_exit) dlg.exec() def cleanup_processes(self): """Clean up any running processes when the window closes or is cancelled""" debug_print("DEBUG: cleanup_processes called - cleaning up InstallationThread and other processes") # Clean up InstallationThread if running if hasattr(self, 'install_thread') and self.install_thread.isRunning(): debug_print("DEBUG: Cancelling running InstallationThread") self.install_thread.cancel() self.install_thread.wait(3000) # Wait up to 3 seconds if self.install_thread.isRunning(): self.install_thread.terminate() # Clean up other threads threads = [ 'prefix_thread', 'config_thread', 'fetch_thread' ] for thread_name in threads: if hasattr(self, thread_name): thread = getattr(self, thread_name) if thread and thread.isRunning(): debug_print(f"DEBUG: Terminating {thread_name}") thread.terminate() thread.wait(1000) # Wait up to 1 second def _perform_modlist_integration(self): """Integrate TTW into the modlist automatically This is called when in integration mode. It will: 1. Copy TTW output to modlist's mods folder 2. Update modlist.txt for all profiles 3. Update plugins.txt with TTW ESMs in correct order 4. Emit integration_complete signal """ try: from pathlib import Path import re from PySide6.QtCore import QThread, Signal # Get TTW output directory ttw_output_dir = Path(self.install_dir_edit.text()) if not ttw_output_dir.exists(): error_msg = f"TTW output directory not found: {ttw_output_dir}" self._safe_append_text(f"\nError: {error_msg}") self.integration_complete.emit(False, "") return # Extract version from .mpi filename mpi_path = self.file_edit.text().strip() ttw_version = "" if mpi_path: mpi_filename = Path(mpi_path).stem version_match = re.search(r'v?(\d+\.\d+(?:\.\d+)?)', mpi_filename, re.IGNORECASE) if version_match: ttw_version = version_match.group(1) # Create background thread for integration class IntegrationThread(QThread): finished = Signal(bool, str) # success, ttw_version progress = Signal(str) # progress message def __init__(self, ttw_output_path, modlist_install_dir, ttw_version): super().__init__() self.ttw_output_path = ttw_output_path self.modlist_install_dir = modlist_install_dir self.ttw_version = ttw_version def run(self): try: from jackify.backend.handlers.hoolamike_handler import HoolamikeHandler self.progress.emit("Integrating TTW into modlist...") success = HoolamikeHandler.integrate_ttw_into_modlist( ttw_output_path=self.ttw_output_path, modlist_install_dir=self.modlist_install_dir, ttw_version=self.ttw_version ) self.finished.emit(success, self.ttw_version) except Exception as e: debug_print(f"ERROR: Integration thread failed: {e}") import traceback traceback.print_exc() self.finished.emit(False, self.ttw_version) # Show progress message self._safe_append_text("\nIntegrating TTW into modlist (this may take a few minutes)...") # Update status banner (only in integration mode - visible when collapsed) if self._integration_mode: self.status_banner.setText("Integrating TTW into modlist (this may take a few minutes)...") self.status_banner.setStyleSheet(f""" QLabel {{ background-color: #FFA500; color: white; font-weight: bold; padding: 8px; border-radius: 5px; }} """) # Create and start integration thread self.integration_thread = IntegrationThread( ttw_output_dir, Path(self._integration_install_dir), ttw_version ) self.integration_thread.progress.connect(self._safe_append_text) self.integration_thread.finished.connect(self._on_integration_thread_finished) self.integration_thread.start() except Exception as e: error_msg = f"Integration error: {str(e)}" self._safe_append_text(f"\nError: {error_msg}") debug_print(f"ERROR: {error_msg}") import traceback traceback.print_exc() self.integration_complete.emit(False, "") def _on_integration_thread_finished(self, success: bool, ttw_version: str): """Handle completion of integration thread""" try: if success: self._safe_append_text("\nTTW integration completed successfully!") # Update status banner (only in integration mode) if self._integration_mode: self.status_banner.setText("TTW integration completed successfully!") self.status_banner.setStyleSheet(f""" QLabel {{ background-color: #28a745; color: white; font-weight: bold; padding: 8px; border-radius: 5px; }} """) MessageService.information( self, "Integration Complete", f"TTW {ttw_version} has been successfully integrated into {self._integration_modlist_name}!", safety_level="medium" ) self.integration_complete.emit(True, ttw_version) else: self._safe_append_text("\nTTW integration failed!") # Update status banner (only in integration mode) if self._integration_mode: self.status_banner.setText("TTW integration failed!") self.status_banner.setStyleSheet(f""" QLabel {{ background-color: #dc3545; color: white; font-weight: bold; padding: 8px; border-radius: 5px; }} """) MessageService.critical( self, "Integration Failed", "Failed to integrate TTW into the modlist. Check the log for details." ) self.integration_complete.emit(False, ttw_version) except Exception as e: debug_print(f"ERROR: Failed to handle integration completion: {e}") self.integration_complete.emit(False, ttw_version) def _create_ttw_mod_archive(self, automated=False): """Create a zipped mod archive of TTW output for MO2 installation. Args: automated: If True, runs silently without user prompts (for automation) """ try: from pathlib import Path import shutil import re output_dir = Path(self.install_dir_edit.text()) if not output_dir.exists(): if not automated: MessageService.warning(self, "Output Directory Not Found", f"Output directory does not exist:\n{output_dir}") return False # Extract version from .mpi filename (e.g., "TTW v3.4.mpi" -> "3.4") mpi_path = self.file_edit.text().strip() version_suffix = "" if mpi_path: mpi_filename = Path(mpi_path).stem # Get filename without extension # Look for version pattern like "3.4", "v3.4", etc. version_match = re.search(r'v?(\d+\.\d+(?:\.\d+)?)', mpi_filename, re.IGNORECASE) if version_match: version_suffix = f" {version_match.group(1)}" # Create archive filename - [NoDelete] prefix is used by MO2 workflows archive_name = f"[NoDelete] Tale of Two Wastelands{version_suffix}" # Place archive in parent directory of output archive_path = output_dir.parent / archive_name if not automated: self._safe_append_text(f"\nCreating mod archive: {archive_name}.zip") self._safe_append_text("This may take several minutes...") # Create the zip archive # shutil.make_archive returns the path without .zip extension final_archive = shutil.make_archive( str(archive_path), # base name (without extension) 'zip', # format str(output_dir) # directory to archive ) if not automated: self._safe_append_text(f"\nArchive created successfully: {Path(final_archive).name}") MessageService.information( self, "Archive Created", f"TTW mod archive created successfully!\n\n" f"Location: {final_archive}\n\n" f"You can now install this archive as a mod in MO2.", safety_level="medium" ) return True except Exception as e: error_msg = f"Failed to create mod archive: {str(e)}" if not automated: self._safe_append_text(f"\nError: {error_msg}") MessageService.critical(self, "Archive Creation Failed", error_msg) return False def cancel_installation(self): """Cancel the currently running installation""" reply = MessageService.question( self, "Cancel Installation", "Are you sure you want to cancel the installation?", critical=False # Non-critical, won't steal focus ) if reply == QMessageBox.Yes: self._safe_append_text("\nCancelling installation...") # Stop the elapsed timer if running if hasattr(self, 'ttw_elapsed_timer') and self.ttw_elapsed_timer.isActive(): self.ttw_elapsed_timer.stop() # Update status banner if hasattr(self, 'status_banner'): self.status_banner.setText("Installation cancelled by user") self.status_banner.setStyleSheet(f""" background-color: #4d3d1a; color: #FFA500; padding: 8px; border-radius: 4px; font-weight: bold; font-size: 13px; """) # Cancel the installation thread if it exists if hasattr(self, 'install_thread') and self.install_thread.isRunning(): self.install_thread.cancel() self.install_thread.wait(3000) # Wait up to 3 seconds for graceful shutdown if self.install_thread.isRunning(): self.install_thread.terminate() # Force terminate if needed self.install_thread.wait(1000) # Cancel the automated prefix thread if it exists if hasattr(self, 'prefix_thread') and self.prefix_thread.isRunning(): self.prefix_thread.terminate() self.prefix_thread.wait(3000) # Wait up to 3 seconds for graceful shutdown if self.prefix_thread.isRunning(): self.prefix_thread.terminate() # Force terminate if needed self.prefix_thread.wait(1000) # Cancel the configuration thread if it exists if hasattr(self, 'config_thread') and self.config_thread.isRunning(): self.config_thread.terminate() self.config_thread.wait(3000) # Wait up to 3 seconds for graceful shutdown if self.config_thread.isRunning(): self.config_thread.terminate() # Force terminate if needed self.config_thread.wait(1000) # Cleanup any remaining processes self.cleanup_processes() # Reset button states and re-enable all controls self._enable_controls_after_operation() self.cancel_btn.setVisible(True) self.cancel_install_btn.setVisible(False) self._safe_append_text("Installation cancelled by user.") def _show_somnium_post_install_guidance(self): """Show guidance popup for Somnium post-installation steps""" from ..widgets.message_service import MessageService guidance_text = f"""Somnium Post-Installation Required

Due to Somnium's non-standard folder structure, you need to manually update the binary paths in ModOrganizer:

1. Launch the Steam shortcut created for Somnium
2. In ModOrganizer, go to Settings → Executables
3. For each executable entry (SKSE64, etc.), update the binary path to point to:
{self._somnium_install_dir}/files/root/Enderal Special Edition/skse64_loader.exe

Note: Full Somnium support will be added in a future Jackify update.

You can also refer to the Somnium installation guide at:
https://wiki.scenicroute.games/Somnium/1_Installation.html
""" MessageService.information(self, "Somnium Setup Required", guidance_text) # Reset the guidance flag self._show_somnium_guidance = False self._somnium_install_dir = None def cancel_and_cleanup(self): """Handle Cancel button - clean up processes and go back""" self.cleanup_processes() # Restore main window to standard Jackify size before leaving try: from PySide6.QtCore import Qt as _Qt main_window = self.window() # Check if we're on Steam Deck - if so, skip all window size modifications is_steamdeck = False if self.system_info and getattr(self.system_info, 'is_steamdeck', False): is_steamdeck = True elif not self.system_info and main_window and hasattr(main_window, 'system_info'): is_steamdeck = getattr(main_window.system_info, 'is_steamdeck', False) if main_window and not is_steamdeck: # Desktop: Restore main window to standard Jackify size main_window.setMaximumHeight(16777215) main_window.setMinimumHeight(900) # Prefer a sane default height; keep current width current_width = max(1200, main_window.size().width()) main_window.resize(current_width, 900) elif is_steamdeck: # Steam Deck: Only clear any constraints that might exist, don't set new ones # This prevents window size issues when navigating away debug_print("DEBUG: Steam Deck detected in cancel_and_cleanup, skipping window resize") if main_window: # Clear any size constraints that might have been set from PySide6.QtCore import QSize main_window.setMaximumSize(QSize(16777215, 16777215)) main_window.setMinimumSize(QSize(0, 0)) # Ensure we exit in collapsed state so next entry starts compact (both Desktop and Deck) if self.show_details_checkbox.isChecked(): self.show_details_checkbox.blockSignals(True) self.show_details_checkbox.setChecked(False) self.show_details_checkbox.blockSignals(False) # Only toggle console visibility on Desktop (on Deck it's always visible) if not is_steamdeck: self._toggle_console_visibility(_Qt.Unchecked) except Exception: pass self.go_back() def reset_screen_to_defaults(self): """Reset the screen to default state when navigating back from main menu""" # Reset form fields self.file_edit.setText("") self.install_dir_edit.setText(self.config_handler.get_modlist_install_base_dir()) # Clear console and process monitor self.console.clear() self.process_monitor.clear() # Re-enable controls (in case they were disabled from previous errors) self._enable_controls_after_operation() # Check requirements when screen is actually shown (not on app startup) self.check_requirements()