Sync from development - prepare for v0.3.0

This commit is contained in:
Omni
2026-02-07 18:26:54 +00:00
parent b55e1cf768
commit 12294d3186
169 changed files with 31749 additions and 33649 deletions

View File

@@ -0,0 +1,386 @@
"""Settings dialog for Jackify GUI."""
from PySide6.QtWidgets import (
QDialog, QVBoxLayout, QHBoxLayout, QLabel, QLineEdit, QPushButton, QCheckBox,
QTabWidget, QFileDialog, QMessageBox, QProgressDialog, QApplication, QToolButton
)
from PySide6.QtCore import Qt
from PySide6.QtGui import QIcon
from pathlib import Path
import json
import os
import logging
from jackify.frontends.gui.services.message_service import MessageService
from .settings_dialog_tabs import SettingsDialogTabsMixin
from .settings_dialog_proton import SettingsDialogProtonMixin
logger = logging.getLogger(__name__)
class SettingsDialog(SettingsDialogTabsMixin, SettingsDialogProtonMixin, QDialog):
def __init__(self, parent=None):
try:
super().__init__(parent)
from jackify.backend.handlers.config_handler import ConfigHandler
import logging
self.logger = logging.getLogger(__name__)
self.config_handler = ConfigHandler()
self._original_debug_mode = self.config_handler.get('debug_mode', False)
self.setWindowTitle("Settings")
self.setModal(True)
self.setMinimumWidth(650)
self.setMaximumWidth(800)
self.setStyleSheet("QDialog { background-color: #232323; color: #eee; } QPushButton:hover { background-color: #333; }")
main_layout = QVBoxLayout()
self.setLayout(main_layout)
# Create tab widget
self.tab_widget = QTabWidget()
self.tab_widget.setStyleSheet("""
QTabWidget::pane { border: 1px solid #555; background: #232323; }
QTabBar::tab { background: #333; color: #eee; padding: 8px 16px; margin: 2px; }
QTabBar::tab:selected { background: #555; }
QTabBar::tab:hover { background: #444; }
""")
main_layout.addWidget(self.tab_widget)
# Create tabs
self._create_general_tab()
self._create_advanced_tab()
# --- Save/Close/Help Buttons ---
btn_layout = QHBoxLayout()
self.help_btn = QPushButton("Help")
self.help_btn.setToolTip("Help/documentation coming soon!")
self.help_btn.clicked.connect(self._show_help)
btn_layout.addWidget(self.help_btn)
btn_layout.addStretch(1)
save_btn = QPushButton("Save")
close_btn = QPushButton("Close")
save_btn.clicked.connect(self._save)
close_btn.clicked.connect(self.reject)
btn_layout.addWidget(save_btn)
btn_layout.addWidget(close_btn)
# Add error label for validation messages
self.error_label = QLabel("")
self.error_label.setStyleSheet("QLabel { color: #ff6b6b; }")
main_layout.addWidget(self.error_label)
main_layout.addSpacing(10)
main_layout.addLayout(btn_layout)
except Exception as e:
print(f"[ERROR] Exception in SettingsDialog.__init__: {e}")
import traceback
traceback.print_exc()
def _toggle_api_key_visibility(self, checked):
eye_icon = QIcon.fromTheme("view-visible")
if not eye_icon.isNull():
self.api_show_btn.setIcon(eye_icon)
self.api_show_btn.setText("")
else:
self.api_show_btn.setIcon(QIcon())
self.api_show_btn.setText("\U0001F441")
if checked:
self.api_key_edit.setEchoMode(QLineEdit.Normal)
self.api_show_btn.setStyleSheet("QToolButton { color: #4fc3f7; }")
else:
self.api_key_edit.setEchoMode(QLineEdit.Password)
self.api_show_btn.setStyleSheet("")
def _pick_directory(self, line_edit):
dir_path = QFileDialog.getExistingDirectory(self, "Select Directory", line_edit.text() or os.path.expanduser("~"))
if dir_path:
line_edit.setText(dir_path)
def _show_help(self):
MessageService.information(self, "Help", "Help/documentation coming soon!", safety_level="low")
def _load_json(self, path):
if os.path.exists(path):
try:
with open(path, 'r') as f:
return json.load(f)
except Exception:
return {}
return {}
def _save_json(self, path, data):
try:
os.makedirs(os.path.dirname(path), exist_ok=True)
with open(path, 'w') as f:
json.dump(data, f, indent=2)
except Exception as e:
MessageService.warning(self, "Error", f"Failed to save {path}: {e}", safety_level="medium")
def _clear_api_key(self):
self.api_key_edit.setText("")
self.config_handler.clear_api_key()
MessageService.information(self, "API Key Cleared", "Nexus API Key has been cleared.", safety_level="low")
def _on_api_key_changed(self, text):
api_key = text.strip()
self.config_handler.save_api_key(api_key)
def _update_oauth_status(self):
from jackify.backend.services.nexus_auth_service import NexusAuthService
auth_service = NexusAuthService()
authenticated, method, username = auth_service.get_auth_status()
if authenticated and method == 'oauth':
self.oauth_status_label.setText(f"Authorised as {username}" if username else "Authorised")
self.oauth_status_label.setStyleSheet("color: #3fd0ea;")
self.oauth_btn.setText("Revoke")
elif method == 'oauth_expired':
self.oauth_status_label.setText("OAuth token expired")
self.oauth_status_label.setStyleSheet("color: #FFA726;")
self.oauth_btn.setText("Re-authorise")
else:
self.oauth_status_label.setText("Not authorised")
self.oauth_status_label.setStyleSheet("color: #f44336;")
self.oauth_btn.setText("Authorise")
def _handle_oauth_click(self):
from jackify.backend.services.nexus_auth_service import NexusAuthService
auth_service = NexusAuthService()
authenticated, method, _ = auth_service.get_auth_status()
if authenticated and method == 'oauth':
reply = MessageService.question(self, "Revoke", "Revoke OAuth authorisation?", safety_level="low")
if reply == QMessageBox.Yes:
auth_service.revoke_oauth()
self._update_oauth_status()
MessageService.information(self, "Revoked", "OAuth authorisation has been revoked.", safety_level="low")
else:
reply = MessageService.question(self, "Authorise with Nexus",
"Your browser will open for Nexus authorisation.\n\n"
"Note: Your browser may ask permission to open 'xdg-open'\n"
"or Jackify's protocol handler - please click 'Open' or 'Allow'.\n\n"
"Please log in and authorise Jackify when prompted.\n\n"
"Continue?", safety_level="low")
if reply != QMessageBox.Yes:
return
progress = QProgressDialog(
"Waiting for authorisation...\n\nPlease check your browser.",
"Cancel", 0, 0, self
)
progress.setWindowTitle("Nexus OAuth")
progress.setWindowModality(Qt.WindowModal)
progress.setMinimumDuration(0)
progress.setMinimumWidth(400)
progress.show()
QApplication.processEvents()
def show_message(msg):
progress.setLabelText(f"Waiting for authorisation...\n\n{msg}")
QApplication.processEvents()
success = auth_service.authorize_oauth(show_browser_message_callback=show_message)
progress.close()
QApplication.processEvents()
self._update_oauth_status()
if success:
_, _, username = auth_service.get_auth_status()
msg = "OAuth authorisation successful!"
if username:
msg += f"\n\nAuthorised as: {username}"
MessageService.information(self, "Success", msg, safety_level="low")
else:
MessageService.warning(self, "Failed", "OAuth authorisation failed or was cancelled.", safety_level="low")
def _save(self):
try:
# Validate values (only if resource_edits exist)
for k, (multithreading_checkbox, max_tasks_spin) in self.resource_edits.items():
if max_tasks_spin.value() > 128:
self.error_label.setText(f"Invalid value for {k}: Max Tasks must be <= 128.")
return
if self.bandwidth_spin and self.bandwidth_spin.value() > 1000000:
self.error_label.setText("Bandwidth limit must be <= 1,000,000 KB/s.")
return
self.error_label.setText("")
# Save resource settings
for k, (multithreading_checkbox, max_tasks_spin) in self.resource_edits.items():
resource_data = self.resource_settings.get(k, {})
resource_data['MaxTasks'] = max_tasks_spin.value()
self.resource_settings[k] = resource_data
# Save bandwidth limit to Downloads resource MaxThroughput (only if bandwidth UI exists)
if self.bandwidth_spin:
if "Downloads" not in self.resource_settings:
self.resource_settings["Downloads"] = {"MaxTasks": 16} # Provide default MaxTasks
# Convert KB/s to bytes/s for storage (resource_settings.json expects bytes)
bandwidth_kb = self.bandwidth_spin.value()
bandwidth_bytes = bandwidth_kb * 1024
self.resource_settings["Downloads"]["MaxThroughput"] = bandwidth_bytes
# Save all resource settings (including bandwidth) in one operation
self._save_json(self.resource_settings_path, self.resource_settings)
# Save debug mode to config
self.config_handler.set('debug_mode', self.debug_checkbox.isChecked())
# OAuth disabled for v0.1.8 - no fallback setting needed
# Save API key
api_key = self.api_key_edit.text().strip()
self.config_handler.save_api_key(api_key)
# Save modlist base dirs
self.config_handler.set("modlist_install_base_dir", self.install_dir_edit.text().strip())
self.config_handler.set("modlist_downloads_base_dir", self.download_dir_edit.text().strip())
# Save jackify data directory (always store actual path, never None)
jackify_data_dir = self.jackify_data_dir_edit.text().strip()
self.config_handler.set("jackify_data_dir", jackify_data_dir)
# Initialize with existing config values as fallback (prevents UnboundLocalError if auto-detection fails)
resolved_install_path = self.config_handler.get("proton_path", "")
resolved_install_version = self.config_handler.get("proton_version", "")
# Save Install Proton selection - resolve "auto" to actual path
selected_install_proton_path = self.install_proton_dropdown.currentData()
if selected_install_proton_path == "none":
# No Proton detected - warn user but allow saving other settings
MessageService.warning(
self,
"No Compatible Proton Installed",
"Jackify requires Proton 9.0+, Proton Experimental, or GE-Proton 10+ to install modlists.\n\n"
"To install Proton:\n"
"1. Install any Windows game in Steam (Proton downloads automatically), OR\n"
"2. Install GE-Proton using ProtonPlus or ProtonUp-Qt, OR\n"
"3. Download GE-Proton manually from:\n"
" https://github.com/GloriousEggroll/proton-ge-custom/releases\n\n"
"Your other settings will be saved, but modlist installation may not work without Proton.",
safety_level="medium"
)
logger.warning("No Proton detected - user warned, allowing save to proceed for other settings")
# Don't modify Proton config, but continue to save other settings
elif selected_install_proton_path == "auto":
# Resolve "auto" to actual best Proton path using unified detection
try:
from jackify.backend.handlers.wine_utils import WineUtils
best_proton = WineUtils.select_best_proton()
if best_proton:
resolved_install_path = str(best_proton['path'])
resolved_install_version = best_proton['name']
self.config_handler.set("proton_path", resolved_install_path)
self.config_handler.set("proton_version", resolved_install_version)
else:
# No Proton found - don't write anything, let engine auto-detect
logger.warning("Auto Proton selection failed: No Proton versions found")
# Don't modify existing config values
except Exception as e:
# Exception during detection - log it and don't write anything
logger.error(f"Auto Proton selection failed with exception: {e}", exc_info=True)
# Don't modify existing config values
else:
# User selected specific Proton version
resolved_install_path = selected_install_proton_path
resolved_install_version = self.install_proton_dropdown.currentText()
self.config_handler.set("proton_path", resolved_install_path)
self.config_handler.set("proton_version", resolved_install_version)
# Save Game Proton selection
selected_game_proton_path = self.game_proton_dropdown.currentData()
if selected_game_proton_path == "same_as_install":
# Use same as install proton
resolved_game_path = resolved_install_path
resolved_game_version = resolved_install_version
else:
# User selected specific game Proton version
resolved_game_path = selected_game_proton_path
resolved_game_version = self.game_proton_dropdown.currentText()
self.config_handler.set("game_proton_path", resolved_game_path)
self.config_handler.set("game_proton_version", resolved_game_version)
# Save component installation method preference
if self.winetricks_radio.isChecked():
method = 'winetricks'
else: # protontricks_radio (alternative)
method = 'system_protontricks'
old_method = self.config_handler.get('component_installation_method', 'winetricks')
method_changed = (old_method != method)
self.config_handler.set("component_installation_method", method)
self.config_handler.set("use_winetricks_for_components", method == 'winetricks')
# Force immediate save and verify
save_result = self.config_handler.save_config()
if not save_result:
self.logger.error("Failed to save Proton configuration")
else:
self.logger.info(f"Saved Proton config: install_path={resolved_install_path}, game_path={resolved_game_path}")
# Verify the save worked by reading it back
saved_path = self.config_handler.get("proton_path")
if saved_path != resolved_install_path:
self.logger.error(f"Config save verification failed: expected {resolved_install_path}, got {saved_path}")
else:
self.logger.debug("Config save verified successfully")
# Refresh cached paths in GUI screens if Jackify directory changed
self._refresh_gui_paths()
# Check if debug mode changed and prompt for restart
new_debug_mode = self.debug_checkbox.isChecked()
if new_debug_mode != self._original_debug_mode:
reply = MessageService.question(self, "Restart Required", "Debug mode change requires a restart. Restart Jackify now?", safety_level="low")
if reply == QMessageBox.Yes:
import os, sys
# User requested restart - do it regardless of execution environment
self.accept()
# Check if running from AppImage
if os.environ.get('APPIMAGE'):
# AppImage: restart the AppImage
os.execv(os.environ['APPIMAGE'], [os.environ['APPIMAGE']] + sys.argv[1:])
else:
# Dev mode: restart the Python module
os.execv(sys.executable, [sys.executable, '-m', 'jackify.frontends.gui'] + sys.argv[1:])
return
# If we get here, no restart was needed
# Check protontricks if user just switched to it
if method_changed and method == 'system_protontricks':
main_window = self.parent()
if main_window and hasattr(main_window, 'protontricks_service'):
is_installed, installation_type, details = main_window.protontricks_service.detect_protontricks(use_cache=False)
if not is_installed:
from jackify.frontends.gui.dialogs.protontricks_error_dialog import ProtontricksErrorDialog
dialog = ProtontricksErrorDialog(main_window.protontricks_service, main_window)
dialog.exec()
MessageService.information(self, "Settings Saved", "Settings have been saved successfully.", safety_level="low")
self.accept()
except Exception as e:
self.logger.error(f"Error saving settings: {e}")
MessageService.warning(self, "Save Error", f"Failed to save settings: {e}", safety_level="medium")
def _refresh_gui_paths(self):
"""Refresh cached paths in all GUI screens."""
try:
# Get the main window through parent relationship
main_window = self.parent()
if not main_window or not hasattr(main_window, 'stacked_widget'):
return
# Refresh paths in all screens that have the method
screens_to_refresh = [
getattr(main_window, 'install_modlist_screen', None),
getattr(main_window, 'configure_new_modlist_screen', None),
getattr(main_window, 'configure_existing_modlist_screen', None),
]
for screen in screens_to_refresh:
if screen and hasattr(screen, 'refresh_paths'):
screen.refresh_paths()
except Exception as e:
print(f"Warning: Could not refresh GUI paths: {e}")
def _bold_label(self, text):
label = QLabel(text)
label.setStyleSheet("font-weight: bold; color: #fff;")
return label

View File

@@ -0,0 +1,114 @@
"""
Settings dialog Proton dropdown population and refresh.
"""
import logging
logger = logging.getLogger(__name__)
class SettingsDialogProtonMixin:
"""Mixin providing Proton dropdown population and refresh for SettingsDialog."""
def _get_proton_10_path(self):
try:
from jackify.backend.handlers.wine_utils import WineUtils
available_protons = WineUtils.scan_valve_proton_versions()
for proton in available_protons:
if proton['version'].startswith('10.'):
return proton['path']
return 'auto'
except Exception:
return 'auto'
def _populate_install_proton_dropdown(self):
try:
from jackify.backend.handlers.wine_utils import WineUtils
available_protons = WineUtils.scan_all_proton_versions()
has_proton = len(available_protons) > 0
if has_proton:
self.install_proton_dropdown.addItem("Auto (Recommended)", "auto")
else:
self.install_proton_dropdown.addItem("No Proton Versions Detected", "none")
fast_protons = []
slow_protons = []
for proton in available_protons:
proton_name = proton.get('name', 'Unknown Proton')
proton_type = proton.get('type', 'Unknown')
if proton_type not in ('GE-Proton', 'Valve-Proton'):
logger.debug(
"Skipping %s (%s) from Install Proton dropdown - third-party builds may have compatibility issues",
proton_name, proton_type
)
continue
slow_warning = False
is_fast_proton = False
display_name = proton_name
if proton_name == "Proton - Experimental":
is_fast_proton = True
elif proton_type == 'GE-Proton':
major_version = proton.get('major_version')
if major_version is not None and isinstance(major_version, int) and major_version >= 10:
is_fast_proton = True
elif 'GE-Proton9' in proton_name or 'GE-Proton8' in proton_name:
slow_warning = True
display_name = f"{proton_name} (GE)"
elif proton_type == 'Valve-Proton':
if proton_name.startswith("Proton 9") or "9.0" in proton_name:
slow_warning = True
if slow_warning:
display_name = f"{display_name} (Slow texture processing)"
slow_protons.append((display_name, str(proton['path'])))
else:
fast_protons.append((display_name, str(proton['path'])))
for display_name, path in fast_protons:
self.install_proton_dropdown.addItem(display_name, path)
if slow_protons:
self.install_proton_dropdown.insertSeparator(self.install_proton_dropdown.count())
for display_name, path in slow_protons:
self.install_proton_dropdown.addItem(display_name, path)
saved_proton = self.config_handler.get('proton_path', self._get_proton_10_path())
self._set_dropdown_selection(self.install_proton_dropdown, saved_proton)
except Exception as e:
logger.error("Failed to populate install Proton dropdown: %s", e)
self.install_proton_dropdown.addItem("Auto (Recommended)", "auto")
def _populate_game_proton_dropdown(self):
try:
from jackify.backend.handlers.wine_utils import WineUtils
available_protons = WineUtils.scan_all_proton_versions()
self.game_proton_dropdown.addItem("Same as Install Proton", "same_as_install")
for proton in available_protons:
proton_name = proton.get('name', 'Unknown Proton')
proton_type = proton.get('type', 'Unknown')
display_name = f"{proton_name} (GE)" if proton_type == 'GE-Proton' else proton_name
self.game_proton_dropdown.addItem(display_name, str(proton['path']))
saved_game_proton = self.config_handler.get('game_proton_path', 'same_as_install')
self._set_dropdown_selection(self.game_proton_dropdown, saved_game_proton)
except Exception as e:
logger.error("Failed to populate game Proton dropdown: %s", e)
self.game_proton_dropdown.addItem("Same as Install Proton", "same_as_install")
def _set_dropdown_selection(self, dropdown, saved_value):
found_match = False
for i in range(dropdown.count()):
if dropdown.itemData(i) == saved_value:
dropdown.setCurrentIndex(i)
found_match = True
break
if not found_match and saved_value not in ["auto", "same_as_install"]:
dropdown.setCurrentIndex(0)
def _refresh_install_proton_dropdown(self):
current_selection = self.install_proton_dropdown.currentData()
self.install_proton_dropdown.clear()
self._populate_install_proton_dropdown()
self._set_dropdown_selection(self.install_proton_dropdown, current_selection)
def _refresh_game_proton_dropdown(self):
current_selection = self.game_proton_dropdown.currentData()
self.game_proton_dropdown.clear()
self._populate_game_proton_dropdown()
self._set_dropdown_selection(self.game_proton_dropdown, current_selection)

View File

@@ -0,0 +1,280 @@
"""
Settings dialog tab creation: General and Advanced tabs.
"""
import os
import logging
from pathlib import Path
from PySide6.QtWidgets import (
QWidget, QVBoxLayout, QHBoxLayout, QLabel, QLineEdit, QPushButton, QCheckBox,
QComboBox, QGroupBox, QFormLayout, QGridLayout, QSpinBox, QRadioButton, QButtonGroup,
QToolButton
)
from PySide6.QtCore import Qt
from PySide6.QtGui import QIcon
logger = logging.getLogger(__name__)
class SettingsDialogTabsMixin:
"""Mixin providing _create_general_tab and _create_advanced_tab for SettingsDialog."""
def _create_general_tab(self):
general_tab = QWidget()
general_layout = QVBoxLayout(general_tab)
dir_group = QGroupBox("Directory Paths")
dir_group.setStyleSheet("QGroupBox { border: 1px solid #555; border-radius: 6px; margin-top: 8px; padding: 8px; background: #23282d; } QGroupBox:title { subcontrol-origin: margin; left: 10px; padding: 0 3px 0 3px; font-weight: bold; color: #fff; }")
dir_layout = QFormLayout()
dir_group.setLayout(dir_layout)
self.install_dir_edit = QLineEdit(self.config_handler.get("modlist_install_base_dir", ""))
self.install_dir_edit.setToolTip("Default directory for modlist installations.")
self.install_dir_btn = QPushButton()
self.install_dir_btn.setIcon(QIcon.fromTheme("folder-open"))
self.install_dir_btn.setToolTip("Browse for directory")
self.install_dir_btn.setFixedWidth(32)
self.install_dir_btn.clicked.connect(lambda: self._pick_directory(self.install_dir_edit))
install_dir_row = QHBoxLayout()
install_dir_row.addWidget(self.install_dir_edit)
install_dir_row.addWidget(self.install_dir_btn)
dir_layout.addRow(QLabel("Install Base Dir:"), install_dir_row)
self.download_dir_edit = QLineEdit(self.config_handler.get("modlist_downloads_base_dir", ""))
self.download_dir_edit.setToolTip("Default directory for modlist downloads.")
self.download_dir_btn = QPushButton()
self.download_dir_btn.setIcon(QIcon.fromTheme("folder-open"))
self.download_dir_btn.setToolTip("Browse for directory")
self.download_dir_btn.setFixedWidth(32)
self.download_dir_btn.clicked.connect(lambda: self._pick_directory(self.download_dir_edit))
download_dir_row = QHBoxLayout()
download_dir_row.addWidget(self.download_dir_edit)
download_dir_row.addWidget(self.download_dir_btn)
dir_layout.addRow(QLabel("Downloads Base Dir:"), download_dir_row)
from jackify.shared.paths import get_jackify_data_dir
current_jackify_dir = str(get_jackify_data_dir())
self.jackify_data_dir_edit = QLineEdit(current_jackify_dir)
self.jackify_data_dir_edit.setToolTip("Directory for Jackify data (logs, downloads, temp files). Default: ~/Jackify")
self.jackify_data_dir_btn = QPushButton()
self.jackify_data_dir_btn.setIcon(QIcon.fromTheme("folder-open"))
self.jackify_data_dir_btn.setToolTip("Browse for directory")
self.jackify_data_dir_btn.setFixedWidth(32)
self.jackify_data_dir_btn.clicked.connect(lambda: self._pick_directory(self.jackify_data_dir_edit))
jackify_data_dir_row = QHBoxLayout()
jackify_data_dir_row.addWidget(self.jackify_data_dir_edit)
jackify_data_dir_row.addWidget(self.jackify_data_dir_btn)
reset_jackify_dir_btn = QPushButton("Reset")
reset_jackify_dir_btn.setToolTip("Reset to default (~/ Jackify)")
reset_jackify_dir_btn.setFixedWidth(50)
reset_jackify_dir_btn.clicked.connect(lambda: self.jackify_data_dir_edit.setText(str(Path.home() / "Jackify")))
jackify_data_dir_row.addWidget(reset_jackify_dir_btn)
dir_layout.addRow(QLabel("Jackify Data Dir:"), jackify_data_dir_row)
general_layout.addWidget(dir_group)
general_layout.addSpacing(12)
proton_group = QGroupBox("Proton Version Settings")
proton_group.setStyleSheet("QGroupBox { border: 1px solid #555; border-radius: 6px; margin-top: 8px; padding: 8px; background: #23282d; } QGroupBox:title { subcontrol-origin: margin; left: 10px; padding: 0 3px 0 3px; font-weight: bold; color: #fff; }")
proton_layout = QVBoxLayout()
proton_group.setLayout(proton_layout)
install_proton_layout = QHBoxLayout()
self.install_proton_dropdown = QComboBox()
self.install_proton_dropdown.setToolTip("Proton version for modlist installation and texture processing (requires fast Proton)")
self.install_proton_dropdown.setMinimumWidth(200)
install_refresh_btn = QPushButton("\u21BB")
install_refresh_btn.setFixedSize(30, 30)
install_refresh_btn.setToolTip("Refresh install Proton version list")
install_refresh_btn.clicked.connect(self._refresh_install_proton_dropdown)
install_proton_layout.addWidget(QLabel("Install Proton:"))
install_proton_layout.addWidget(self.install_proton_dropdown)
install_proton_layout.addWidget(install_refresh_btn)
install_proton_layout.addStretch()
game_proton_layout = QHBoxLayout()
self.game_proton_dropdown = QComboBox()
self.game_proton_dropdown.setToolTip("Proton version for game shortcuts (can be any Proton 9+)")
self.game_proton_dropdown.setMinimumWidth(200)
game_refresh_btn = QPushButton("\u21BB")
game_refresh_btn.setFixedSize(30, 30)
game_refresh_btn.setToolTip("Refresh game Proton version list")
game_refresh_btn.clicked.connect(self._refresh_game_proton_dropdown)
game_proton_layout.addWidget(QLabel("Game Proton:"))
game_proton_layout.addWidget(self.game_proton_dropdown)
game_proton_layout.addWidget(game_refresh_btn)
game_proton_layout.addStretch()
proton_layout.addLayout(install_proton_layout)
proton_layout.addLayout(game_proton_layout)
self._populate_install_proton_dropdown()
self._populate_game_proton_dropdown()
general_layout.addWidget(proton_group)
general_layout.addSpacing(12)
from jackify.frontends.gui.services.message_service import MessageService
oauth_group = QGroupBox("Nexus Authentication")
oauth_group.setStyleSheet("QGroupBox { border: 1px solid #555; border-radius: 6px; margin-top: 8px; padding: 8px; background: #23282d; } QGroupBox:title { subcontrol-origin: margin; left: 10px; padding: 0 3px 0 3px; font-weight: bold; color: #fff; }")
oauth_layout = QVBoxLayout()
oauth_group.setLayout(oauth_layout)
oauth_status_layout = QHBoxLayout()
self.oauth_status_label = QLabel("Checking...")
self.oauth_status_label.setStyleSheet("color: #ccc;")
self.oauth_btn = QPushButton("Authorise")
self.oauth_btn.setMaximumWidth(100)
self.oauth_btn.clicked.connect(self._handle_oauth_click)
oauth_status_layout.addWidget(QLabel("Status:"))
oauth_status_layout.addWidget(self.oauth_status_label)
oauth_status_layout.addWidget(self.oauth_btn)
oauth_status_layout.addStretch()
oauth_layout.addLayout(oauth_status_layout)
self._update_oauth_status()
general_layout.addWidget(oauth_group)
general_layout.addSpacing(12)
debug_group = QGroupBox("Enable Debug")
debug_group.setStyleSheet("QGroupBox { border: 1px solid #555; border-radius: 6px; margin-top: 8px; padding: 8px; background: #23282d; } QGroupBox:title { subcontrol-origin: margin; left: 10px; padding: 0 3px 0 3px; font-weight: bold; color: #fff; }")
debug_layout = QVBoxLayout()
debug_group.setLayout(debug_layout)
self.debug_checkbox = QCheckBox("Enable debug mode (requires restart)")
self.debug_checkbox.setChecked(self.config_handler.get('debug_mode', False))
self.debug_checkbox.setToolTip("Enable verbose debug logging. Requires Jackify restart to take effect.")
self.debug_checkbox.setStyleSheet("color: #fff;")
debug_layout.addWidget(self.debug_checkbox)
general_layout.addWidget(debug_group)
general_layout.addStretch()
self.tab_widget.addTab(general_tab, "General")
def _create_advanced_tab(self):
advanced_tab = QWidget()
advanced_layout = QVBoxLayout(advanced_tab)
auth_group = QGroupBox("Nexus Authentication")
auth_group.setStyleSheet("QGroupBox { border: 1px solid #555; border-radius: 6px; margin-top: 8px; padding: 8px; background: #23282d; } QGroupBox:title { subcontrol-origin: margin; left: 10px; padding: 0 3px 0 3px; font-weight: bold; color: #fff; }")
auth_layout = QVBoxLayout()
auth_group.setLayout(auth_layout)
api_layout = QHBoxLayout()
self.api_key_edit = QLineEdit()
self.api_key_edit.setEchoMode(QLineEdit.Password)
api_key = self.config_handler.get_api_key()
self.api_key_edit.setText(api_key if api_key else "")
self.api_key_edit.setToolTip("Your Nexus API Key (legacy authentication method)")
self.api_key_edit.textChanged.connect(self._on_api_key_changed)
self.api_show_btn = QToolButton()
self.api_show_btn.setCheckable(True)
self.api_show_btn.setIcon(QIcon.fromTheme("view-visible"))
self.api_show_btn.setToolTip("Show or hide your API key")
self.api_show_btn.toggled.connect(self._toggle_api_key_visibility)
clear_api_btn = QPushButton("Clear")
clear_api_btn.clicked.connect(self._clear_api_key)
clear_api_btn.setMaximumWidth(60)
api_layout.addWidget(QLabel("API Key:"))
api_layout.addWidget(self.api_key_edit)
api_layout.addWidget(self.api_show_btn)
api_layout.addWidget(clear_api_btn)
auth_layout.addLayout(api_layout)
advanced_layout.addWidget(auth_group)
advanced_layout.addSpacing(12)
self.resource_settings_path = os.path.expanduser("~/.config/jackify/resource_settings.json")
self.resource_settings = self._load_json(self.resource_settings_path)
self.resource_edits = {}
resource_group = QGroupBox("Resource Limits")
resource_group.setStyleSheet("QGroupBox { border: 1px solid #555; border-radius: 6px; margin-top: 8px; padding: 8px; background: #23282d; } QGroupBox:title { subcontrol-origin: margin; left: 10px; padding: 0 3px 0 3px; font-weight: bold; color: #fff; }")
resource_outer_layout = QVBoxLayout()
resource_group.setLayout(resource_outer_layout)
if not self.resource_settings:
info_label = QLabel("Resource Limit settings will be generated once a modlist install action is performed")
info_label.setStyleSheet("color: #aaa; font-style: italic; padding: 20px; font-size: 11pt;")
info_label.setWordWrap(True)
info_label.setAlignment(Qt.AlignCenter)
info_label.setMinimumHeight(60)
resource_outer_layout.addWidget(info_label)
else:
resource_grid = QGridLayout()
resource_grid.setVerticalSpacing(4)
resource_grid.setHorizontalSpacing(8)
resource_grid.setColumnMinimumWidth(2, 40)
resource_grid.addWidget(self._bold_label("Resource"), 0, 0, 1, 1, Qt.AlignLeft)
resource_grid.addWidget(self._bold_label("Max Tasks"), 0, 1, 1, 1, Qt.AlignLeft)
resource_grid.addWidget(self._bold_label("Resource"), 0, 3, 1, 1, Qt.AlignLeft)
resource_grid.addWidget(self._bold_label("Max Tasks"), 0, 4, 1, 1, Qt.AlignLeft)
resource_items = list(self.resource_settings.items())
bandwidth_kb = 0
if "Downloads" in self.resource_settings:
bandwidth_kb = self.resource_settings["Downloads"].get("MaxThroughput", 0) // 1024 or 0
left_row = 1
for k, v in resource_items[:4]:
try:
resource_grid.addWidget(QLabel(f"{k}:", parent=self), left_row, 0, 1, 1, Qt.AlignLeft)
max_tasks_spin = QSpinBox()
max_tasks_spin.setMinimum(1)
max_tasks_spin.setMaximum(128)
max_tasks_spin.setValue(v.get('MaxTasks', 16))
max_tasks_spin.setToolTip("Maximum number of concurrent tasks for this resource.")
max_tasks_spin.setFixedWidth(100)
resource_grid.addWidget(max_tasks_spin, left_row, 1)
self.resource_edits[k] = (None, max_tasks_spin)
left_row += 1
except Exception as e:
self.logger.error("Failed to create widgets for resource '%s': %s", k, e)
continue
right_row = 1
for k, v in resource_items[4:]:
try:
resource_grid.addWidget(QLabel(f"{k}:", parent=self), right_row, 3, 1, 1, Qt.AlignLeft)
max_tasks_spin = QSpinBox()
max_tasks_spin.setMinimum(1)
max_tasks_spin.setMaximum(128)
max_tasks_spin.setValue(v.get('MaxTasks', 16))
max_tasks_spin.setToolTip("Maximum number of concurrent tasks for this resource.")
max_tasks_spin.setFixedWidth(100)
resource_grid.addWidget(max_tasks_spin, right_row, 4)
self.resource_edits[k] = (None, max_tasks_spin)
right_row += 1
except Exception as e:
self.logger.error("Failed to create widgets for resource '%s': %s", k, e)
continue
if "Downloads" in self.resource_settings:
resource_grid.addWidget(QLabel("Bandwidth Limit:", parent=self), right_row, 3, 1, 1, Qt.AlignLeft)
self.bandwidth_spin = QSpinBox()
self.bandwidth_spin.setMinimum(0)
self.bandwidth_spin.setMaximum(1000000)
self.bandwidth_spin.setValue(bandwidth_kb)
self.bandwidth_spin.setSuffix(" KB/s")
self.bandwidth_spin.setFixedWidth(100)
self.bandwidth_spin.setToolTip("Set the maximum download speed for modlist downloads. 0 = unlimited.")
bandwidth_widget_layout = QHBoxLayout()
bandwidth_widget_layout.setContentsMargins(0, 0, 0, 0)
bandwidth_widget_layout.addWidget(self.bandwidth_spin)
bandwidth_note = QLabel("(0 = unlimited)")
bandwidth_note.setStyleSheet("color: #aaa; font-size: 9pt;")
bandwidth_widget_layout.addWidget(bandwidth_note)
bandwidth_widget_layout.addStretch()
bandwidth_container = QWidget()
bandwidth_container.setLayout(bandwidth_widget_layout)
resource_grid.addWidget(bandwidth_container, right_row, 4, 1, 1, Qt.AlignLeft)
else:
self.bandwidth_spin = None
resource_grid.setColumnStretch(5, 1)
resource_outer_layout.addLayout(resource_grid)
advanced_layout.addWidget(resource_group)
component_group = QGroupBox("Advanced Tool Options")
component_group.setStyleSheet("QGroupBox { border: 1px solid #555; border-radius: 6px; margin-top: 8px; padding: 8px; background: #23282d; } QGroupBox:title { subcontrol-origin: margin; left: 10px; padding: 0 3px 0 3px; font-weight: bold; color: #fff; }")
component_layout = QVBoxLayout()
component_group.setLayout(component_layout)
component_layout.addWidget(QLabel("Wine Components Installation:"))
self.component_method_group = QButtonGroup()
component_method_layout = QVBoxLayout()
current_method = self.config_handler.get('component_installation_method', 'winetricks')
if current_method == 'bundled_protontricks':
current_method = 'system_protontricks'
self.winetricks_radio = QRadioButton("Winetricks (Default)")
self.winetricks_radio.setChecked(current_method == 'winetricks')
self.winetricks_radio.setToolTip("Use bundled winetricks for component installation. Faster and more reliable.")
self.component_method_group.addButton(self.winetricks_radio, 0)
component_method_layout.addWidget(self.winetricks_radio)
self.protontricks_radio = QRadioButton("Protontricks (Alternative)")
self.protontricks_radio.setChecked(current_method == 'system_protontricks')
self.protontricks_radio.setToolTip("Use system-installed protontricks (flatpak or native). Fallback option if winetricks fails.")
self.component_method_group.addButton(self.protontricks_radio, 1)
component_method_layout.addWidget(self.protontricks_radio)
component_layout.addLayout(component_method_layout)
advanced_layout.addWidget(component_group)
advanced_layout.addStretch()
self.tab_widget.addTab(advanced_tab, "Advanced")

View File

@@ -244,7 +244,7 @@ class SuccessDialog(QDialog):
else:
base_message = f"You can now launch {self.modlist_name} from Steam and enjoy your modded {game_display} experience!"
# Note: ENB-specific Proton warning is now shown in a separate dialog when ENB is detected
# ENB Proton warning shown in separate dialog
return base_message
def _update_countdown(self):

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,73 @@
"""
Main window backend initialization mixin.
System info, config, modlist service, protontricks service, resource limits.
"""
import os
from jackify.backend.models.configuration import SystemInfo
from jackify.backend.services.modlist_service import ModlistService
def _debug_print(message):
from jackify.backend.handlers.config_handler import ConfigHandler
ch = ConfigHandler()
if ch.get('debug_mode', False):
print(message)
class MainWindowBackendMixin:
"""Mixin for backend service initialization."""
def _initialize_backend(self):
from jackify.shared.steam_utils import detect_steam_installation_types
is_flatpak, is_native = detect_steam_installation_types()
self.system_info = SystemInfo(
is_steamdeck=self._is_steamdeck(),
is_flatpak_steam=is_flatpak,
is_native_steam=is_native
)
self._apply_resource_limits()
from jackify.backend.handlers.config_handler import ConfigHandler
self.config_handler = ConfigHandler()
self.backend_services = {'modlist_service': ModlistService(self.system_info)}
self.gui_services = {}
from jackify.backend.services.protontricks_detection_service import ProtontricksDetectionService
self.protontricks_service = ProtontricksDetectionService(steamdeck=self.system_info.is_steamdeck)
from jackify.backend.services.update_service import UpdateService
from jackify import __version__
self.update_service = UpdateService(__version__)
_debug_print(f"GUI Backend initialized - Steam Deck: {self.system_info.is_steamdeck}")
def _is_steamdeck(self):
try:
if os.path.exists("/etc/os-release"):
with open("/etc/os-release", "r") as f:
content = f.read()
if "steamdeck" in content:
return True
return False
except Exception:
return False
def _apply_resource_limits(self):
try:
from jackify.backend.services.resource_manager import ResourceManager
resource_manager = ResourceManager()
success = resource_manager.apply_recommended_limits()
if success:
status = resource_manager.get_limit_status()
if status['target_achieved']:
_debug_print(f"Resource limits optimized: file descriptors set to {status['current_soft']}")
else:
print(f"Resource limits improved: file descriptors increased to {status['current_soft']} (target: {status['target_limit']})")
else:
status = resource_manager.get_limit_status()
print(f"Warning: Could not optimize resource limits: current file descriptors={status['current_soft']}, target={status['target_limit']}")
from jackify.backend.handlers.config_handler import ConfigHandler
config_handler = ConfigHandler()
if config_handler.get('debug_mode', False):
instructions = resource_manager.get_manual_increase_instructions()
print(f"Manual increase instructions available for {instructions['distribution']}")
except Exception as e:
print(f"Warning: Error applying resource limits: {e}")

View File

@@ -0,0 +1,117 @@
"""
Main window dialogs and cleanup mixin.
Settings, About, open URL, cleanup_processes, closeEvent.
"""
import os
import subprocess
from jackify.frontends.gui.dialogs.settings_dialog import SettingsDialog
class MainWindowDialogsMixin:
"""Mixin for settings/about dialogs, open URL, and cleanup."""
def open_settings_dialog(self):
try:
if self._settings_dialog is not None:
try:
if self._settings_dialog.isVisible():
self._settings_dialog.raise_()
self._settings_dialog.activateWindow()
return
else:
self._settings_dialog = None
except RuntimeError:
self._settings_dialog = None
dlg = SettingsDialog(self)
self._settings_dialog = dlg
def on_dialog_finished():
self._settings_dialog = None
dlg.finished.connect(on_dialog_finished)
dlg.exec()
except Exception as e:
print(f"[ERROR] Exception in open_settings_dialog: {e}")
import traceback
traceback.print_exc()
self._settings_dialog = None
def open_about_dialog(self):
try:
from jackify.frontends.gui.dialogs.about_dialog import AboutDialog
if self._about_dialog is not None:
try:
if self._about_dialog.isVisible():
self._about_dialog.raise_()
self._about_dialog.activateWindow()
return
else:
self._about_dialog = None
except RuntimeError:
self._about_dialog = None
dlg = AboutDialog(self.system_info, self)
self._about_dialog = dlg
def on_dialog_finished():
self._about_dialog = None
dlg.finished.connect(on_dialog_finished)
dlg.exec()
except Exception as e:
print(f"[ERROR] Exception in open_about_dialog: {e}")
import traceback
traceback.print_exc()
self._about_dialog = None
def _open_url(self, url: str):
env = os.environ.copy()
appimage_vars = [
'LD_LIBRARY_PATH', 'PYTHONPATH', 'PYTHONHOME',
'QT_PLUGIN_PATH', 'QML2_IMPORT_PATH',
]
if 'APPIMAGE' in env or 'APPDIR' in env:
for var in appimage_vars:
env.pop(var, None)
subprocess.Popen(
['xdg-open', url],
env=env,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
start_new_session=True
)
def cleanup_processes(self):
try:
if hasattr(self, '_update_thread') and self._update_thread is not None:
if self._update_thread.isRunning():
self._update_thread.quit()
self._update_thread.wait(2000)
self._update_thread = None
if hasattr(self, '_gallery_cache_preload_thread') and self._gallery_cache_preload_thread is not None:
if self._gallery_cache_preload_thread.isRunning():
self._gallery_cache_preload_thread.quit()
self._gallery_cache_preload_thread.wait(2000)
self._gallery_cache_preload_thread = None
for service in self.gui_services.values():
if hasattr(service, 'cleanup'):
service.cleanup()
screens = [
self.modlist_tasks_screen, self.install_modlist_screen,
self.configure_new_modlist_screen, self.configure_existing_modlist_screen,
]
for screen in screens:
if hasattr(screen, 'cleanup_processes'):
screen.cleanup_processes()
elif hasattr(screen, 'cleanup'):
screen.cleanup()
try:
subprocess.run(['pkill', '-f', 'jackify-engine'], timeout=5, capture_output=True)
except Exception:
pass
except Exception as e:
print(f"Error during cleanup: {e}")
def closeEvent(self, event):
self._save_geometry_on_quit()
self.cleanup_processes()
event.accept()

View File

@@ -0,0 +1,207 @@
"""
Main window geometry and resize mixin.
Window flags, save/restore geometry, compact mode, responsive minimum, resize handling.
"""
from PySide6.QtWidgets import QMainWindow, QApplication
from PySide6.QtCore import Qt, QTimer, QRect
from jackify.frontends.gui.utils import get_screen_geometry, set_responsive_minimum
ENABLE_WINDOW_HEIGHT_ANIMATION = False
def _debug_print(message):
from jackify.backend.handlers.config_handler import ConfigHandler
ch = ConfigHandler()
if ch.get('debug_mode', False):
print(message)
class MainWindowGeometryMixin:
"""Mixin for window geometry, save/restore, compact mode, and resize behavior."""
def _apply_standard_window_flags(self):
window_flags = self.windowFlags()
window_flags |= (
Qt.Window
| Qt.WindowTitleHint
| Qt.WindowSystemMenuHint
| Qt.WindowMinimizeButtonHint
| Qt.WindowMaximizeButtonHint
| Qt.WindowCloseButtonHint
)
window_flags &= ~Qt.CustomizeWindowHint
self.setWindowFlags(window_flags)
def _restore_geometry(self):
width, height = self._calculate_initial_window_size()
height = min(height, self._compact_height)
self.resize(width, height)
self._center_on_screen(width, height)
def _save_geometry_on_quit(self):
if self._is_compact_mode():
self._save_geometry()
else:
from PySide6.QtCore import QSettings
settings = QSettings("Jackify", "Jackify")
settings.remove("windowGeometry")
def _is_compact_mode(self) -> bool:
try:
if hasattr(self, 'install_modlist_screen') and hasattr(self.install_modlist_screen, 'show_details_checkbox'):
if self.install_modlist_screen.show_details_checkbox.isChecked():
return False
if hasattr(self, 'install_ttw_screen') and hasattr(self.install_ttw_screen, 'show_details_checkbox'):
if self.install_ttw_screen.show_details_checkbox.isChecked():
return False
if hasattr(self, 'configure_new_modlist_screen') and hasattr(self.configure_new_modlist_screen, 'show_details_checkbox'):
if self.configure_new_modlist_screen.show_details_checkbox.isChecked():
return False
if hasattr(self, 'configure_existing_modlist_screen') and hasattr(self.configure_existing_modlist_screen, 'show_details_checkbox'):
if self.configure_existing_modlist_screen.show_details_checkbox.isChecked():
return False
except Exception:
pass
return True
def _save_geometry(self):
from PySide6.QtCore import QSettings
settings = QSettings("Jackify", "Jackify")
settings.setValue("windowGeometry", self.saveGeometry())
def apply_responsive_minimum(self, min_width: int = 1100, min_height: int = 600):
set_responsive_minimum(self, min_width=min_width, min_height=min_height, margin=self._window_margin)
def _calculate_initial_window_size(self):
_, _, screen_width, screen_height = get_screen_geometry(self)
if not screen_width or not screen_height:
return (self._base_min_width, self._base_min_height)
width = min(
max(self._base_min_width, int(screen_width * 0.85)),
screen_width - self._window_margin
)
height = min(
max(self._base_min_height, int(screen_height * 0.75)),
screen_height - self._window_margin
)
return (width, height)
def _center_on_screen(self, width: int, height: int):
_, _, screen_width, screen_height = get_screen_geometry(self)
if not screen_width or not screen_height:
return
x = max(0, (screen_width - width) // 2)
y = max(0, (screen_height - height) // 2)
self.move(x, y)
def _ensure_within_available_geometry(self):
from PySide6.QtCore import QRect
_, _, screen_width, screen_height = get_screen_geometry(self)
if not screen_width or not screen_height:
return
current_geometry = self.geometry()
new_width = min(current_geometry.width(), screen_width - self._window_margin)
new_height = min(current_geometry.height(), screen_height - self._window_margin)
new_width = max(new_width, self.minimumWidth())
new_height = max(new_height, self.minimumHeight())
new_x = min(max(current_geometry.x(), 0), screen_width - new_width)
new_y = min(max(current_geometry.y(), 0), screen_height - new_height)
self.setGeometry(new_x, new_y, new_width, new_height)
def _on_resize_event_geometry(self, event):
super().resizeEvent(event)
if self._is_compact_mode():
if not hasattr(self, '_geometry_save_timer'):
self._geometry_save_timer = QTimer()
self._geometry_save_timer.setSingleShot(True)
self._geometry_save_timer.timeout.connect(self._save_geometry)
self._geometry_save_timer.stop()
self._geometry_save_timer.start(500)
def _geometry_show_event(self, event):
super().showEvent(event)
if not self._initial_show_adjusted:
self._initial_show_adjusted = True
if not (hasattr(self, 'system_info') and self.system_info.is_steamdeck):
self.setWindowState(Qt.WindowNoState)
self.apply_responsive_minimum(self._base_min_width, self._base_min_height)
self._ensure_within_available_geometry()
def _maintain_fullscreen_on_deck(self, index):
if hasattr(self, 'system_info') and self.system_info.is_steamdeck:
if not self.isMaximized():
self.showMaximized()
def _on_child_resize_request(self, mode: str):
_debug_print(f"DEBUG: _on_child_resize_request called with mode='{mode}', current_size={self.size()}")
try:
if self.system_info and self.system_info.is_steamdeck:
_debug_print("DEBUG: Steam Deck detected, ignoring resize request")
try:
if hasattr(self, 'install_ttw_screen') and self.install_ttw_screen.show_details_checkbox:
self.install_ttw_screen.show_details_checkbox.setVisible(False)
except Exception:
pass
return
except Exception:
pass
if mode == "expand":
target_height = self._compact_height + self._details_extra_height
self._resize_height(target_height)
elif mode == "collapse" or mode == "compact":
self._resize_height(self._compact_height)
else:
self.apply_responsive_minimum(self._base_min_width, self._base_min_height)
def _resize_height(self, requested_height: int):
target_height = self._clamp_height_to_screen(requested_height)
self.apply_responsive_minimum(self._base_min_width, self._base_min_height)
if ENABLE_WINDOW_HEIGHT_ANIMATION:
self._animate_height(target_height)
return
geom = self.geometry()
new_y = geom.y()
_, _, _, screen_height = get_screen_geometry(self)
max_bottom = max(self._base_min_height, screen_height - self._window_margin)
if new_y + target_height > max_bottom:
new_y = max(0, max_bottom - target_height)
self._programmatic_resize = True
self.setGeometry(geom.x(), new_y, geom.width(), target_height)
QTimer.singleShot(100, lambda: setattr(self, '_programmatic_resize', False))
def _clamp_height_to_screen(self, requested_height: int) -> int:
_, _, _, screen_height = get_screen_geometry(self)
available = max(self._base_min_height, screen_height - self._window_margin)
return max(self._base_min_height, min(requested_height, available))
def _animate_height(self, target_height: int, duration_ms: int = 180):
try:
from PySide6.QtCore import QEasingCurve, QPropertyAnimation, QRect
except Exception:
before = self.size()
self._programmatic_resize = True
self.resize(self.size().width(), target_height)
_debug_print(f"DEBUG: Animated fallback resize from {before} to {self.size()}")
QTimer.singleShot(100, lambda: setattr(self, '_programmatic_resize', False))
return
start_rect = self.geometry()
end_rect = QRect(start_rect.x(), start_rect.y(), start_rect.width(), self._clamp_height_to_screen(target_height))
screen = QApplication.primaryScreen()
if screen:
screen_geometry = screen.availableGeometry()
would_be_bottom = start_rect.y() + target_height
if would_be_bottom > screen_geometry.bottom():
new_y = screen_geometry.bottom() - target_height
if new_y < screen_geometry.top():
new_y = screen_geometry.top()
end_rect.moveTop(new_y)
self._resize_anim = QPropertyAnimation(self, b"geometry")
self._resize_anim.setDuration(duration_ms)
self._resize_anim.setEasingCurve(QEasingCurve.OutCubic)
self._resize_anim.setStartValue(start_rect)
self._resize_anim.setEndValue(end_rect)
self._programmatic_resize = True
self._resize_anim.finished.connect(lambda: setattr(self, '_programmatic_resize', False))
self._resize_anim.start()

View File

@@ -0,0 +1,102 @@
"""
Main window startup and background tasks mixin.
Gallery cache preload, protontricks check, update check.
"""
import sys
from PySide6.QtCore import QThread, Signal, QTimer
from PySide6.QtWidgets import QDialog
def _debug_print(message):
from jackify.backend.handlers.config_handler import ConfigHandler
ch = ConfigHandler()
if ch.get('debug_mode', False):
print(message)
class MainWindowStartupMixin:
"""Mixin for startup and background tasks."""
def _start_gallery_cache_preload(self):
from PySide6.QtCore import QThread, Signal
class GalleryCachePreloadThread(QThread):
finished_signal = Signal(bool, str)
def run(self):
try:
from jackify.backend.services.modlist_gallery_service import ModlistGalleryService
service = ModlistGalleryService()
metadata = service.fetch_modlist_metadata(
include_validation=False,
include_search_index=True,
sort_by="title",
force_refresh=False
)
if metadata:
modlists_with_mods = sum(1 for m in metadata.modlists if hasattr(m, 'mods') and m.mods)
if modlists_with_mods > 0:
_debug_print(f"Gallery cache ready ({modlists_with_mods} modlists with mods)")
else:
_debug_print("Gallery cache updated")
else:
_debug_print("Failed to load gallery cache")
except Exception as e:
_debug_print(f"Gallery cache preload error: {str(e)}")
self._gallery_cache_preload_thread = GalleryCachePreloadThread()
self._gallery_cache_preload_thread.start()
_debug_print("Started background gallery cache preload")
def _check_protontricks_on_startup(self):
try:
method = self.config_handler.get('component_installation_method', 'winetricks')
if method != 'system_protontricks':
_debug_print(f"Skipping protontricks check (current method: {method}).")
return
is_installed, installation_type, details = self.protontricks_service.detect_protontricks()
if not is_installed:
print(f"Protontricks not found: {details}")
from jackify.frontends.gui.dialogs.protontricks_error_dialog import ProtontricksErrorDialog
dialog = ProtontricksErrorDialog(self.protontricks_service, self)
result = dialog.exec()
if result == QDialog.Rejected:
print("User chose to exit due to missing protontricks")
sys.exit(1)
else:
_debug_print(f"Protontricks detected: {details}")
except Exception as e:
print(f"Error checking protontricks: {e}")
def _check_for_updates_on_startup(self):
try:
_debug_print("Checking for updates on startup...")
class UpdateCheckThread(QThread):
update_available = Signal(object)
def __init__(self, update_service):
super().__init__()
self.update_service = update_service
def run(self):
update_info = self.update_service.check_for_updates()
if update_info:
self.update_available.emit(update_info)
def on_update_available(update_info):
_debug_print(f"Update available: v{update_info.version}")
def show_update_dialog():
from jackify.frontends.gui.dialogs.update_dialog import UpdateDialog
dialog = UpdateDialog(update_info, self.update_service, self)
dialog.exec()
QTimer.singleShot(1000, show_update_dialog)
self._update_thread = UpdateCheckThread(self.update_service)
self._update_thread.update_available.connect(on_update_available)
self._update_thread.start()
except Exception as e:
_debug_print(f"Error setting up update check: {e}")

View File

@@ -0,0 +1,187 @@
"""
Main window UI setup mixin.
Stacked widget, screens, bottom bar, screen change handling.
"""
import sys
from PySide6.QtWidgets import (
QWidget, QLabel, QVBoxLayout, QHBoxLayout,
QStackedWidget, QSizePolicy,
)
from PySide6.QtCore import Qt
from jackify import __version__
from jackify.frontends.gui.shared_theme import DEBUG_BORDERS
from jackify.frontends.gui.widgets.feature_placeholder import FeaturePlaceholder
def _debug_print(message):
from jackify.backend.handlers.config_handler import ConfigHandler
ch = ConfigHandler()
if ch.get('debug_mode', False):
print(message)
class MainWindowUIMixin:
"""Mixin for main window UI: stacked widget, screens, bottom bar."""
def _setup_ui(self, dev_mode=False):
self.stacked_widget = QStackedWidget()
from jackify.frontends.gui.screens import (
MainMenu, ModlistTasksScreen, AdditionalTasksScreen,
InstallModlistScreen, ConfigureNewModlistScreen, ConfigureExistingModlistScreen,
)
from jackify.frontends.gui.screens.install_ttw import InstallTTWScreen
from jackify.frontends.gui.screens.wabbajack_installer import WabbajackInstallerScreen
self.main_menu = MainMenu(stacked_widget=self.stacked_widget, dev_mode=dev_mode)
self.feature_placeholder = FeaturePlaceholder(stacked_widget=self.stacked_widget)
self.modlist_tasks_screen = ModlistTasksScreen(
stacked_widget=self.stacked_widget, main_menu_index=0, dev_mode=dev_mode
)
self.additional_tasks_screen = AdditionalTasksScreen(
stacked_widget=self.stacked_widget, main_menu_index=0, system_info=self.system_info
)
self.install_modlist_screen = InstallModlistScreen(
stacked_widget=self.stacked_widget, main_menu_index=0, system_info=self.system_info
)
self.configure_new_modlist_screen = ConfigureNewModlistScreen(
stacked_widget=self.stacked_widget, main_menu_index=0, system_info=self.system_info
)
self.configure_existing_modlist_screen = ConfigureExistingModlistScreen(
stacked_widget=self.stacked_widget, main_menu_index=0, system_info=self.system_info
)
self.install_ttw_screen = InstallTTWScreen(
stacked_widget=self.stacked_widget, main_menu_index=0, system_info=self.system_info
)
self.wabbajack_installer_screen = WabbajackInstallerScreen(
stacked_widget=self.stacked_widget, additional_tasks_index=3, system_info=self.system_info
)
try:
self.install_ttw_screen.resize_request.connect(self._on_child_resize_request)
except Exception:
pass
try:
self.install_modlist_screen.resize_request.connect(self._on_child_resize_request)
except Exception:
pass
try:
self.configure_new_modlist_screen.resize_request.connect(self._on_child_resize_request)
except Exception:
pass
try:
self.configure_existing_modlist_screen.resize_request.connect(self._on_child_resize_request)
except Exception:
pass
try:
self.wabbajack_installer_screen.resize_request.connect(self._on_child_resize_request)
except Exception:
pass
self.stacked_widget.addWidget(self.main_menu)
self.stacked_widget.addWidget(self.feature_placeholder)
self.stacked_widget.addWidget(self.modlist_tasks_screen)
self.stacked_widget.addWidget(self.additional_tasks_screen)
self.stacked_widget.addWidget(self.install_modlist_screen)
self.stacked_widget.addWidget(self.install_ttw_screen)
self.stacked_widget.addWidget(self.configure_new_modlist_screen)
self.stacked_widget.addWidget(self.wabbajack_installer_screen)
self.stacked_widget.addWidget(self.configure_existing_modlist_screen)
self.stacked_widget.currentChanged.connect(self._debug_screen_change)
self.stacked_widget.currentChanged.connect(self._maintain_fullscreen_on_deck)
bottom_bar = QWidget()
bottom_bar_layout = QHBoxLayout()
bottom_bar_layout.setContentsMargins(10, 2, 10, 2)
bottom_bar_layout.setSpacing(0)
bottom_bar.setLayout(bottom_bar_layout)
bottom_bar.setFixedHeight(32)
bottom_bar_style = "background-color: #181818; border-top: 1px solid #222;"
if DEBUG_BORDERS:
bottom_bar_style += " border: 2px solid lime;"
bottom_bar.setStyleSheet(bottom_bar_style)
version_label = QLabel(f"Jackify v{__version__}")
version_label.setStyleSheet("color: #bbb; font-size: 13px;")
bottom_bar_layout.addWidget(version_label, alignment=Qt.AlignLeft)
bottom_bar_layout.addStretch(1)
kofi_link = QLabel('<a href="#" style="color:#72A5F2; text-decoration:none;">Support on Ko-fi</a>')
kofi_link.setStyleSheet("color: #72A5F2; font-size: 13px;")
kofi_link.setTextInteractionFlags(Qt.TextBrowserInteraction)
kofi_link.setOpenExternalLinks(False)
kofi_link.linkActivated.connect(lambda: self._open_url("https://ko-fi.com/omni1"))
kofi_link.setToolTip("Support Jackify development")
bottom_bar_layout.addWidget(kofi_link, alignment=Qt.AlignCenter)
bottom_bar_layout.addStretch(1)
settings_btn = QLabel('<a href="#" style="color:#6cf; text-decoration:none;">Settings</a>')
settings_btn.setStyleSheet("color: #6cf; font-size: 13px; padding-right: 8px;")
settings_btn.setTextInteractionFlags(Qt.TextBrowserInteraction)
settings_btn.setOpenExternalLinks(False)
settings_btn.linkActivated.connect(self.open_settings_dialog)
bottom_bar_layout.addWidget(settings_btn, alignment=Qt.AlignRight)
about_btn = QLabel('<a href="#" style="color:#6cf; text-decoration:none;">About</a>')
about_btn.setStyleSheet("color: #6cf; font-size: 13px; padding-right: 8px;")
about_btn.setTextInteractionFlags(Qt.TextBrowserInteraction)
about_btn.setOpenExternalLinks(False)
about_btn.linkActivated.connect(self.open_about_dialog)
bottom_bar_layout.addWidget(about_btn, alignment=Qt.AlignRight)
central_widget = QWidget()
main_layout = QVBoxLayout()
main_layout.setContentsMargins(0, 0, 0, 0)
main_layout.setSpacing(0)
main_layout.addWidget(self.stacked_widget)
main_layout.addWidget(bottom_bar)
self.stacked_widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Minimum)
central_widget.setLayout(main_layout)
self.setCentralWidget(central_widget)
self.stacked_widget.setCurrentIndex(0)
self._check_protontricks_on_startup()
def _debug_screen_change(self, index):
try:
idx = int(index) if index is not None else 0
widget = self.stacked_widget.widget(idx)
except (OverflowError, TypeError, ValueError):
widget = self.stacked_widget.currentWidget()
idx = None
if widget and hasattr(widget, 'reset_screen_to_defaults'):
widget.reset_screen_to_defaults()
from jackify.backend.handlers.config_handler import ConfigHandler
config_handler = ConfigHandler()
if not config_handler.get('debug_mode', False):
return
if idx is None:
return
try:
screen_names = {
0: "Main Menu",
1: "Feature Placeholder",
2: "Modlist Tasks Menu",
3: "Additional Tasks Menu",
4: "Install Modlist Screen",
5: "Install TTW Screen",
6: "Configure New Modlist",
7: "Wabbajack Installer",
8: "Configure Existing Modlist",
}
screen_name = screen_names.get(idx, f"Unknown Screen (Index {idx})")
widget = self.stacked_widget.widget(idx)
except (OverflowError, TypeError, ValueError):
return
widget_class = widget.__class__.__name__ if widget else "None"
print(f"[DEBUG] Screen changed to Index {idx}: {screen_name} (Widget: {widget_class})", file=sys.stderr)
if idx == 4:
print(" Install Modlist Screen details:", file=sys.stderr)
print(f" - Widget type: {type(widget)}", file=sys.stderr)
print(f" - Widget file: {widget.__class__.__module__}", file=sys.stderr)
if hasattr(widget, 'windowTitle'):
print(f" - Window title: {widget.windowTitle()}", file=sys.stderr)
if hasattr(widget, 'layout'):
layout = widget.layout()
if layout:
print(f" - Layout type: {type(layout)}", file=sys.stderr)
print(f" - Layout children count: {layout.count()}", file=sys.stderr)

View File

@@ -36,7 +36,7 @@ class AdditionalTasksScreen(QWidget):
def _setup_ui(self):
"""Set up the user interface following ModlistTasksScreen pattern"""
layout = QVBoxLayout()
layout.setContentsMargins(30, 30, 30, 30) # Reduced from 40
layout.setContentsMargins(30, 30, 30, 30)
layout.setSpacing(12) # Match main menu spacing
# Header section
@@ -98,11 +98,11 @@ class AdditionalTasksScreen(QWidget):
# Create grid layout for buttons (mirror ModlistTasksScreen pattern)
button_grid = QGridLayout()
button_grid.setSpacing(12) # Reduced from 16
button_grid.setSpacing(12)
button_grid.setAlignment(Qt.AlignHCenter)
button_width = 400
button_height = 40 # Reduced from 50
button_height = 40
for i, (label, action_id, description) in enumerate(MENU_ITEMS):
# Create button
@@ -130,7 +130,7 @@ class AdditionalTasksScreen(QWidget):
# Description label
desc_label = QLabel(description)
desc_label.setAlignment(Qt.AlignHCenter)
desc_label.setStyleSheet("color: #999; font-size: 11px;") # Reduced from 12px
desc_label.setStyleSheet("color: #999; font-size: 11px;")
desc_label.setWordWrap(True)
desc_label.setFixedWidth(button_width)

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,150 @@
"""Console output management for ConfigureExistingModlistScreen (Mixin)."""
import re
import time
from PySide6.QtCore import QTimer
from jackify.shared.progress_models import FileProgress, OperationType
class ConfigureExistingModlistConsoleMixin:
"""Mixin providing console output management for ConfigureExistingModlistScreen."""
def _handle_progress_update(self, text):
"""Handle progress updates - update console, activity window, and progress indicator"""
# Always append to console
self._safe_append_text(text)
# Parse the message to update UI widgets
message_lower = text.lower()
# Update progress indicator based on key status messages
if "setting protontricks permissions" in message_lower:
self._stop_component_install_pulse()
self.progress_indicator.set_status("Setting permissions...", 20)
self.file_progress_list.update_or_add_item("__phase__", "Setting permissions...", 0.0)
elif "applying curated registry" in message_lower or "registry" in message_lower:
self._stop_component_install_pulse()
self.progress_indicator.set_status("Applying registry files...", 40)
self.file_progress_list.update_or_add_item("__phase__", "Applying registry...", 0.0)
elif "installing wine components" in message_lower or "wine component" in message_lower:
self.progress_indicator.set_status("Installing wine components...", 60)
if not hasattr(self, '_component_install_timer') or not self._component_install_timer:
self._start_component_install_pulse()
comp_list = self._parse_wine_components_message(text)
if comp_list:
self._start_component_install_pulse_with_components(comp_list)
elif "wine components verified" in message_lower or "wine components installed" in message_lower:
self._stop_component_install_pulse()
self.progress_indicator.set_status("Wine components installed", 65)
self.file_progress_list.update_or_add_item("__phase__", "Wine components installed", 0.0)
elif "dotnet" in message_lower and "fix" in message_lower:
self._stop_component_install_pulse()
self.progress_indicator.set_status("Applying dotnet fixes...", 75)
self.file_progress_list.update_or_add_item("__phase__", "Applying dotnet fixes...", 0.0)
elif "setting ownership" in message_lower or "ownership and permissions" in message_lower:
self._stop_component_install_pulse()
self.progress_indicator.set_status("Setting permissions...", 85)
self.file_progress_list.update_or_add_item("__phase__", "Setting permissions...", 0.0)
elif "verifying" in message_lower:
self._stop_component_install_pulse()
self.progress_indicator.set_status("Verifying setup...", 90)
self.file_progress_list.update_or_add_item("__phase__", "Verifying setup...", 0.0)
elif "steam integration complete" in message_lower or "configuration complete" in message_lower:
self._stop_component_install_pulse()
self.progress_indicator.set_status("Configuration complete", 100)
self.file_progress_list.update_or_add_item("__phase__", "Configuration complete", 0.0)
def _safe_append_text(self, text):
"""Append text with professional auto-scroll behavior"""
# Write all messages to log file
self._write_to_log_file(text)
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:
pass
def _parse_wine_components_message(self, text: str):
"""Extract list of wine component names from backend status message, or None."""
if "installing wine components:" not in text.lower() and "installing wine components via protontricks:" not in text.lower():
return None
match = re.search(r"installing wine components(?:\s+via protontricks)?:\s*(.+)", text, re.IGNORECASE)
if not match:
return None
raw = match.group(1).strip()
if not raw:
return None
return [c.strip() for c in raw.split(",") if c.strip()]
def _start_component_install_pulse(self):
"""Start pulsing Activity item for Wine component installation (single generic item)."""
self.file_progress_list.update_or_add_item("__wine_components__", "Installing Wine components...", 0.0)
if not getattr(self, '_component_install_timer', None):
self._component_install_timer = QTimer(self)
self._component_install_timer.timeout.connect(self._component_install_heartbeat)
self._component_install_timer.start(100)
self._component_install_start_time = time.time()
def _start_component_install_pulse_with_components(self, components: list):
"""Replace single item with one Activity entry per component, each with pulsing progress."""
self._component_install_list = components
progresses = [
FileProgress(
filename=f"Wine component: {comp}",
operation=OperationType.UNKNOWN,
percent=0.0,
)
for comp in components
]
self.file_progress_list.update_files(progresses, current_phase=None)
def _component_install_heartbeat(self):
"""Heartbeat to keep component install item(s) pulsing."""
if not hasattr(self, '_component_install_start_time') or not self._component_install_start_time:
return
if hasattr(self, '_component_install_list') and self._component_install_list:
progresses = [
FileProgress(
filename=f"Wine component: {comp}",
operation=OperationType.UNKNOWN,
percent=0.0,
)
for comp in self._component_install_list
]
self.file_progress_list.update_files(progresses, current_phase=None)
else:
self.file_progress_list.update_or_add_item("__wine_components__", "Installing Wine components...", 0.0)
def _stop_component_install_pulse(self):
"""Stop the component install pulsing timer."""
if hasattr(self, '_component_install_timer') and self._component_install_timer:
self._component_install_timer.stop()
self._component_install_timer = None
if hasattr(self, '_component_install_list'):
del self._component_install_list

View File

@@ -0,0 +1,117 @@
"""Shortcut loading for ConfigureExistingModlistScreen (Mixin)."""
from PySide6.QtCore import QThread, Signal, QObject
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 ConfigureExistingModlistShortcutsMixin:
"""Mixin providing shortcut loading for ConfigureExistingModlistScreen."""
def _load_shortcuts_async(self):
"""Load ModOrganizer.exe shortcuts asynchronously to avoid blocking UI"""
from PySide6.QtCore import QThread, Signal, QObject
class ShortcutLoaderThread(QThread):
finished_signal = Signal(list) # Emits list of shortcuts when done
error_signal = Signal(str) # Emits error message if something goes wrong
def run(self):
try:
# Suppress all logging/output in background thread to avoid reentrant stderr issues
import logging
import sys
# Temporarily redirect stderr to avoid reentrant calls
old_stderr = sys.stderr
try:
# Use a null device or StringIO to capture errors without writing to stderr
from io import StringIO
sys.stderr = StringIO()
# Fetch shortcuts for ModOrganizer.exe using existing backend functionality
from jackify.backend.handlers.modlist_handler import ModlistHandler
# Initialize modlist handler with empty config dict to use default initialization
modlist_handler = ModlistHandler({})
discovered_modlists = modlist_handler.discover_executable_shortcuts("ModOrganizer.exe")
# Convert to shortcut_handler format for UI compatibility
shortcuts = []
for modlist in discovered_modlists:
# Convert discovered modlist format to shortcut format
shortcut = {
'AppName': modlist.get('name', 'Unknown'),
'AppID': modlist.get('appid', ''),
'StartDir': modlist.get('path', ''),
'Exe': f"{modlist.get('path', '')}/ModOrganizer.exe"
}
shortcuts.append(shortcut)
# Restore stderr before emitting signal
sys.stderr = old_stderr
self.finished_signal.emit(shortcuts)
except Exception as inner_e:
# Restore stderr before emitting error
sys.stderr = old_stderr
error_msg = str(inner_e)
self.error_signal.emit(error_msg)
self.finished_signal.emit([])
except Exception as e:
# Fallback error handling
error_msg = str(e)
self.error_signal.emit(error_msg)
self.finished_signal.emit([])
# Show loading state in dropdown
if hasattr(self, 'shortcut_combo'):
self.shortcut_combo.clear()
self.shortcut_combo.addItem("Loading modlists...")
self.shortcut_combo.setEnabled(False)
# Clean up any existing thread first (defer so we don't block main thread)
if self._shortcut_loader is not None:
if self._shortcut_loader.isRunning():
self._shortcut_loader.finished_signal.disconnect()
self._shortcut_loader.terminate()
self._shortcut_loader = None
# Start background thread
self._shortcut_loader = ShortcutLoaderThread()
self._shortcut_loader.finished_signal.connect(self._on_shortcuts_loaded)
self._shortcut_loader.error_signal.connect(self._on_shortcuts_error)
self._shortcut_loader.start()
def _on_shortcuts_loaded(self, shortcuts):
"""Update UI when shortcuts are loaded"""
self.mo2_shortcuts = shortcuts
# Update the dropdown
if hasattr(self, 'shortcut_combo'):
self.shortcut_combo.clear()
self.shortcut_combo.setEnabled(True)
self.shortcut_combo.addItem("Please Select...")
self.shortcut_map.clear()
for shortcut in self.mo2_shortcuts:
display = f"{shortcut.get('AppName', shortcut.get('appname', 'Unknown'))} ({shortcut.get('StartDir', shortcut.get('startdir', ''))})"
self.shortcut_combo.addItem(display)
self.shortcut_map.append(shortcut)
def _on_shortcuts_error(self, error_msg):
"""Handle errors from shortcut loading thread"""
# Log error from main thread (safe to write to stderr here)
debug_print(f"Warning: Failed to load shortcuts: {error_msg}")
# Update UI to show error state
if hasattr(self, 'shortcut_combo'):
self.shortcut_combo.clear()
self.shortcut_combo.setEnabled(True)
self.shortcut_combo.addItem("Error loading modlists - please try again")

View File

@@ -0,0 +1,564 @@
"""UI setup and control management for ConfigureExistingModlistScreen (Mixin)."""
from PySide6.QtWidgets import (
QWidget, QVBoxLayout, QHBoxLayout, QLabel, QComboBox, QPushButton,
QGridLayout, QTextEdit, QSizePolicy, QTabWidget, QCheckBox, QMainWindow
)
from PySide6.QtCore import Qt, QSize, QTimer
import os
import subprocess
from ..shared_theme import JACKIFY_COLOR_BLUE, DEBUG_BORDERS
from ..utils import set_responsive_minimum
from jackify.frontends.gui.widgets.progress_indicator import OverallProgressIndicator
from jackify.frontends.gui.widgets.file_progress_list import FileProgressList
from jackify.backend.handlers.shortcut_handler import ShortcutHandler
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 ConfigureExistingModlistUIMixin:
"""Mixin providing UI setup and control management for ConfigureExistingModlistScreen."""
def __init__(self, stacked_widget=None, main_menu_index=0, system_info=None):
super().__init__()
debug_print("DEBUG: ConfigureExistingModlistScreen __init__ called")
self.stacked_widget = stacked_widget
self.main_menu_index = main_menu_index
from jackify.backend.models.configuration import SystemInfo
self.system_info = system_info or SystemInfo(is_steamdeck=False)
self.debug = DEBUG_BORDERS
self.refresh_paths()
# --- Detect Steam Deck ---
from jackify.backend.services.platform_detection_service import PlatformDetectionService
platform_service = PlatformDetectionService.get_instance()
steamdeck = platform_service.is_steamdeck
self.shortcut_handler = ShortcutHandler(steamdeck=steamdeck)
# Initialize services early
from jackify.backend.services.api_key_service import APIKeyService
from jackify.backend.services.resolution_service import ResolutionService
from jackify.backend.handlers.config_handler import ConfigHandler
self.api_key_service = APIKeyService()
self.resolution_service = ResolutionService()
self.config_handler = ConfigHandler()
# --- Fetch shortcuts for ModOrganizer.exe - deferred to showEvent to avoid blocking init ---
# Initialize empty list, will be populated when screen is shown
self.mo2_shortcuts = []
self._shortcuts_loaded = False
self._shortcut_loader = None # Thread for async shortcut loading
# Initialize progress reporting components
self.progress_indicator = OverallProgressIndicator(show_progress_bar=True)
self.progress_indicator.set_status("Ready to configure", 0)
self.file_progress_list = FileProgressList()
# Create "Show Details" checkbox
self.show_details_checkbox = QCheckBox("Show details")
self.show_details_checkbox.setChecked(False) # Start collapsed
self.show_details_checkbox.setToolTip("Toggle between activity summary and detailed console output")
self.show_details_checkbox.toggled.connect(self._on_show_details_toggled)
# --- UI Layout ---
main_overall_vbox = QVBoxLayout(self)
main_overall_vbox.setAlignment(Qt.AlignTop | Qt.AlignHCenter)
main_overall_vbox.setContentsMargins(50, 50, 50, 0) # No bottom margin
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 = QLabel("<b>Configure Existing Modlist</b>")
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)
desc = QLabel(
"This screen allows you to configure an existing modlist in Jackify. "
"Select your Steam shortcut for ModOrganizer.exe, set your resolution, and complete post-install configuration."
)
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)
header_widget.setMaximumHeight(75) # Match other screens
if self.debug:
header_widget.setStyleSheet("border: 2px solid pink;")
header_widget.setToolTip("HEADER_SECTION")
main_overall_vbox.addWidget(header_widget)
# --- Upper section: shortcut selector (left) + process monitor (right) ---
upper_hbox = QHBoxLayout()
upper_hbox.setContentsMargins(0, 0, 0, 0)
upper_hbox.setSpacing(16)
user_config_vbox = QVBoxLayout()
user_config_vbox.setAlignment(Qt.AlignTop)
# --- [Options] header (moved here for alignment) ---
options_header = QLabel("<b>[Options]</b>")
options_header.setStyleSheet(f"color: {JACKIFY_COLOR_BLUE}; font-size: 13px; font-weight: bold;")
options_header.setAlignment(Qt.AlignLeft | Qt.AlignVCenter)
user_config_vbox.addWidget(options_header)
form_grid = QGridLayout()
form_grid.setHorizontalSpacing(12)
form_grid.setVerticalSpacing(6)
form_grid.setContentsMargins(0, 0, 0, 0)
# --- Shortcut selector ---
shortcut_label = QLabel("Select Modlist:")
self.shortcut_combo = QComboBox()
self.shortcut_combo.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
self.shortcut_combo.addItem("Please Select...")
self.shortcut_map = []
for shortcut in self.mo2_shortcuts:
display = f"{shortcut.get('AppName', shortcut.get('appname', 'Unknown'))} ({shortcut.get('StartDir', shortcut.get('startdir', ''))})"
self.shortcut_combo.addItem(display)
self.shortcut_map.append(shortcut)
# Add refresh button next to dropdown
refresh_btn = QPushButton("")
refresh_btn.setToolTip("Refresh modlist list")
refresh_btn.setFixedSize(30, 30)
refresh_btn.clicked.connect(self.refresh_modlist_list)
# Create horizontal layout for dropdown and refresh button
shortcut_hbox = QHBoxLayout()
shortcut_hbox.addWidget(self.shortcut_combo)
shortcut_hbox.addWidget(refresh_btn)
shortcut_hbox.setSpacing(4)
shortcut_hbox.setStretch(0, 1) # Make dropdown expand
form_grid.addWidget(shortcut_label, 0, 0, alignment=Qt.AlignLeft | Qt.AlignVCenter)
form_grid.addLayout(shortcut_hbox, 0, 1)
# --- Info message under shortcut selector ---
info_label = QLabel("<span style='color:#aaa'>If you don't see your modlist entry in this list, please ensure you have added it to Steam as a non-steam game, set a proton version in properties, and have started the modlist Steam entry at least once. You can also click the refresh button (↻) to update the list.</span>")
info_label.setWordWrap(True)
form_grid.addWidget(info_label, 1, 0, 1, 2)
# --- Resolution selector ---
resolution_label = QLabel("Resolution:")
self.resolution_combo = QComboBox()
self.resolution_combo.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
self.resolution_combo.addItem("Leave unchanged")
self.resolution_combo.addItems([
"1280x720",
"1280x800 (Steam Deck)",
"1366x768",
"1440x900",
"1600x900",
"1600x1200",
"1680x1050",
"1920x1080",
"1920x1200",
"2048x1152",
"2560x1080",
"2560x1440",
"2560x1600",
"3440x1440",
"3840x1600",
"3840x2160",
"3840x2400",
"5120x1440",
"5120x2160",
"7680x4320"
])
form_grid.addWidget(resolution_label, 2, 0, alignment=Qt.AlignLeft | Qt.AlignVCenter)
form_grid.addWidget(self.resolution_combo, 2, 1)
# Load saved resolution if available
saved_resolution = self.resolution_service.get_saved_resolution()
is_steam_deck = False
try:
if os.path.exists('/etc/os-release'):
with open('/etc/os-release') as f:
if 'steamdeck' in f.read().lower():
is_steam_deck = True
except Exception:
pass
if saved_resolution:
combo_items = [self.resolution_combo.itemText(i) for i in range(self.resolution_combo.count())]
resolution_index = self.resolution_service.get_resolution_index(saved_resolution, combo_items)
self.resolution_combo.setCurrentIndex(resolution_index)
debug_print(f"DEBUG: Loaded saved resolution: {saved_resolution} (index: {resolution_index})")
elif is_steam_deck:
# Set default to 1280x800 (Steam Deck)
combo_items = [self.resolution_combo.itemText(i) for i in range(self.resolution_combo.count())]
if "1280x800 (Steam Deck)" in combo_items:
self.resolution_combo.setCurrentIndex(combo_items.index("1280x800 (Steam Deck)"))
else:
self.resolution_combo.setCurrentIndex(0)
# Otherwise, default is 'Leave unchanged' (index 0)
form_section_widget = QWidget()
form_section_widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
form_section_widget.setLayout(form_grid)
form_section_widget.setMinimumHeight(160) # Reduced to match compact form
form_section_widget.setMaximumHeight(240) # Increased to show resolution dropdown
if self.debug:
form_section_widget.setStyleSheet("border: 2px solid blue;")
form_section_widget.setToolTip("FORM_SECTION")
user_config_vbox.addWidget(form_section_widget)
btn_row = QHBoxLayout()
btn_row.setAlignment(Qt.AlignHCenter)
self.start_btn = QPushButton("Start Configuration")
btn_row.addWidget(self.start_btn)
cancel_btn = QPushButton("Cancel")
cancel_btn.clicked.connect(self.cancel_and_cleanup)
btn_row.addWidget(cancel_btn)
user_config_widget = QWidget()
user_config_widget.setLayout(user_config_vbox)
if self.debug:
user_config_widget.setStyleSheet("border: 2px solid orange;")
user_config_widget.setToolTip("USER_CONFIG_WIDGET")
# Right: Tabbed interface with Activity and Process Monitor
# Both tabs are always available, user can switch between them
self.process_monitor = QTextEdit()
self.process_monitor.setReadOnly(True)
self.process_monitor.setTextInteractionFlags(Qt.TextSelectableByMouse | Qt.TextSelectableByKeyboard)
self.process_monitor.setMinimumSize(QSize(300, 20))
self.process_monitor.setStyleSheet(f"background: #222; color: {JACKIFY_COLOR_BLUE}; font-family: monospace; font-size: 11px; border: 1px solid #444;")
self.process_monitor_heading = QLabel("<b>[Process Monitor]</b>")
self.process_monitor_heading.setStyleSheet(f"color: {JACKIFY_COLOR_BLUE}; font-size: 13px; margin-bottom: 2px;")
self.process_monitor_heading.setAlignment(Qt.AlignLeft | Qt.AlignVCenter)
process_vbox = QVBoxLayout()
process_vbox.setContentsMargins(0, 0, 0, 0)
process_vbox.setSpacing(2)
process_vbox.addWidget(self.process_monitor_heading)
process_vbox.addWidget(self.process_monitor)
process_monitor_widget = QWidget()
process_monitor_widget.setLayout(process_vbox)
process_monitor_widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
if self.debug:
process_monitor_widget.setStyleSheet("border: 2px solid purple;")
process_monitor_widget.setToolTip("PROCESS_MONITOR")
self.process_monitor_widget = process_monitor_widget
# Set up File Progress List (Activity tab)
self.file_progress_list.setMinimumSize(QSize(300, 20))
self.file_progress_list.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
# Create tab widget to hold both Activity and Process Monitor
self.activity_tabs = QTabWidget()
self.activity_tabs.setStyleSheet("QTabWidget::pane { background: #222; border: 1px solid #444; } QTabBar::tab { background: #222; color: #ccc; padding: 6px 16px; } QTabBar::tab:selected { background: #333; color: #3fd0ea; } QTabWidget { margin: 0px; padding: 0px; } QTabBar { margin: 0px; padding: 0px; }")
self.activity_tabs.setContentsMargins(0, 0, 0, 0)
self.activity_tabs.setDocumentMode(False)
self.activity_tabs.setTabPosition(QTabWidget.North)
if self.debug:
self.activity_tabs.setStyleSheet("border: 2px solid cyan;")
self.activity_tabs.setToolTip("ACTIVITY_TABS")
# Add both widgets as tabs
self.activity_tabs.addTab(self.file_progress_list, "Activity")
self.activity_tabs.addTab(process_monitor_widget, "Process Monitor")
upper_hbox.addWidget(user_config_widget, stretch=11)
upper_hbox.addWidget(self.activity_tabs, stretch=9)
upper_hbox.setAlignment(Qt.AlignTop)
upper_section_widget = QWidget()
upper_section_widget.setLayout(upper_hbox)
# Use Fixed size policy for consistent height
upper_section_widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
upper_section_widget.setMaximumHeight(280) # Increased to show resolution dropdown
if self.debug:
upper_section_widget.setStyleSheet("border: 2px solid green;")
upper_section_widget.setToolTip("UPPER_SECTION")
main_overall_vbox.addWidget(upper_section_widget)
# Status banner with progress indicator and "Show details" toggle
banner_row = QHBoxLayout()
banner_row.setContentsMargins(0, 0, 0, 0)
banner_row.setSpacing(8)
banner_row.addWidget(self.progress_indicator, 1)
banner_row.addStretch()
banner_row.addWidget(self.show_details_checkbox)
banner_row_widget = QWidget()
banner_row_widget.setLayout(banner_row)
banner_row_widget.setMaximumHeight(45) # Compact height
banner_row_widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
main_overall_vbox.addWidget(banner_row_widget)
# Console output area (shown when "Show details" is checked)
self.console = QTextEdit()
self.console.setReadOnly(True)
self.console.setTextInteractionFlags(Qt.TextSelectableByMouse | Qt.TextSelectableByKeyboard)
self.console.setMinimumHeight(50)
self.console.setMaximumHeight(1000)
self.console.setFontFamily('monospace')
self.console.setVisible(False) # Hidden by default (compact mode)
if self.debug:
self.console.setStyleSheet("border: 2px solid yellow;")
self.console.setToolTip("CONSOLE")
# Set up scroll tracking for professional auto-scroll behavior
self._setup_scroll_tracking()
# Wrap button row in widget for debug borders
btn_row_widget = QWidget()
btn_row_widget.setLayout(btn_row)
btn_row_widget.setMaximumHeight(50)
if self.debug:
btn_row_widget.setStyleSheet("border: 2px solid red;")
btn_row_widget.setToolTip("BUTTON_ROW")
# Create a container that holds console + button row with proper spacing
console_and_buttons_widget = QWidget()
console_and_buttons_layout = QVBoxLayout()
console_and_buttons_layout.setContentsMargins(0, 0, 0, 0)
console_and_buttons_layout.setSpacing(8)
console_and_buttons_layout.addWidget(self.console, stretch=1)
console_and_buttons_layout.addWidget(btn_row_widget)
console_and_buttons_widget.setLayout(console_and_buttons_layout)
console_and_buttons_widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
console_and_buttons_widget.setFixedHeight(50) # Lock to button row height when console is hidden
if self.debug:
console_and_buttons_widget.setStyleSheet("border: 2px solid lightblue;")
console_and_buttons_widget.setToolTip("CONSOLE_AND_BUTTONS_CONTAINER")
# Add without stretch to prevent squashing upper section
main_overall_vbox.addWidget(console_and_buttons_widget)
# Store references for toggle functionality
self.console_and_buttons_widget = console_and_buttons_widget
self.console_and_buttons_layout = console_and_buttons_layout
self.main_overall_vbox = main_overall_vbox
self.setLayout(main_overall_vbox)
self.process = None
self.log_timer = None
self.last_log_pos = 0
self.top_timer = QTimer(self)
self.top_timer.timeout.connect(self.update_top_panel)
self.top_timer.start(2000)
self.start_btn.clicked.connect(self.validate_and_start_configure)
self.steam_restart_finished.connect(self._on_steam_restart_finished)
# Scroll tracking for professional auto-scroll behavior
self._user_manually_scrolled = False
self._was_at_bottom = True
# Time tracking for workflow completion
self._workflow_start_time = None
# Initialize empty controls list - will be populated after UI is built
self._actionable_controls = []
# Now collect all actionable controls after UI is fully built
self._collect_actionable_controls()
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,
# Form fields
self.shortcut_combo,
# Resolution controls
self.resolution_combo,
]
def _disable_controls_during_operation(self):
"""Disable all actionable controls during 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 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() / 'Configure_Existing_Modlist_workflow.log'
os.makedirs(os.path.dirname(self.modlist_log_path), exist_ok=True)
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
self.console.setMinimumHeight(50) # Keep minimum height for usability
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):
"""Handle Show Details checkbox toggle"""
self._toggle_console_visibility(checked)
def _toggle_console_visibility(self, is_checked):
"""Toggle console visibility and window size"""
main_window = None
try:
parent = self.parent()
while parent and not isinstance(parent, QMainWindow):
parent = parent.parent()
if parent and isinstance(parent, QMainWindow):
main_window = parent
except Exception:
pass
if is_checked:
# Show console
self.console.setVisible(True)
self.console.setMinimumHeight(200)
self.console.setMaximumHeight(16777215)
self.console.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
# Allow expansion when console is visible
if hasattr(self, 'console_and_buttons_widget'):
self.console_and_buttons_widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
self.console_and_buttons_widget.setMinimumHeight(0)
self.console_and_buttons_widget.setMaximumHeight(16777215)
self.console_and_buttons_widget.updateGeometry()
# Stop CPU tracking when showing console
self.file_progress_list.stop_cpu_tracking()
# Expand window
if main_window:
try:
from PySide6.QtCore import QSize
# On Steam Deck, keep fullscreen; on other systems, set normal window state
if not (hasattr(main_window, 'system_info') and main_window.system_info.is_steamdeck):
main_window.showNormal()
main_window.setMaximumHeight(16777215)
main_window.setMinimumHeight(0)
expanded_min = 900
current_size = main_window.size()
target_height = max(expanded_min, 900)
main_window.setMinimumHeight(expanded_min)
main_window.resize(current_size.width(), target_height)
self.main_overall_vbox.invalidate()
self.updateGeometry()
except Exception:
pass
else:
# Hide console
self.console.setVisible(False)
self.console.setMinimumHeight(0)
self.console.setMaximumHeight(0)
self.console.setSizePolicy(QSizePolicy.Ignored, QSizePolicy.Ignored)
# Lock height when console is hidden
if hasattr(self, 'console_and_buttons_widget'):
self.console_and_buttons_widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
self.console_and_buttons_widget.setFixedHeight(50)
self.console_and_buttons_widget.updateGeometry()
# CPU tracking will start when user clicks "Start Configuration", not here
# (Removed to avoid blocking showEvent)
# Collapse window
if main_window:
try:
from PySide6.QtCore import QSize
# Use fixed compact height for consistency across all workflow screens
compact_height = 620
# On Steam Deck, keep fullscreen; on other systems, set normal window state
if not (hasattr(main_window, 'system_info') and main_window.system_info.is_steamdeck):
main_window.showNormal()
set_responsive_minimum(main_window, min_width=960, min_height=compact_height)
current_size = main_window.size()
main_window.resize(current_size.width(), compact_height)
except Exception:
pass
def 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()
# Include jackify-engine and related heavy processes
heavy_processes = (
"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
)
# Include Python processes running configure-modlist command
configure_processes = (
"python" in line_lower and "configure-modlist" in line_lower
)
# Include configuration threads that might be running
config_threads = (
hasattr(self, 'config_thread') and
self.config_thread and
self.config_thread.isRunning() and
("python" in line_lower or "jackify" in line_lower)
)
if (heavy_processes or configure_processes or config_threads) 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}]")

View File

@@ -0,0 +1,392 @@
"""Workflow management for ConfigureExistingModlistScreen (Mixin)."""
from PySide6.QtCore import QThread, Signal
import os
import time
import logging
from pathlib import Path
from typing import Optional
from jackify.shared.resolution_utils import get_resolution_fallback
logger = logging.getLogger(__name__)
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 ConfigureExistingModlistWorkflowMixin:
"""Mixin providing workflow management for ConfigureExistingModlistScreen."""
def _detect_game_type_from_mo2_ini(self, install_dir: str) -> str:
"""Detect game type by checking ModOrganizer.ini for loader executables."""
from pathlib import Path
mo2_ini = Path(install_dir) / "ModOrganizer.ini"
if not mo2_ini.exists():
return 'skyrim' # Fallback to most common
try:
content = mo2_ini.read_text(encoding='utf-8', errors='ignore').lower()
if 'skse64_loader.exe' in content or 'skyrim special edition' in content:
return 'skyrim'
elif 'f4se_loader.exe' in content or 'fallout 4' in content:
return 'fallout4'
elif 'nvse_loader.exe' in content or 'fallout new vegas' in content:
return 'falloutnv'
elif 'obse_loader.exe' in content or 'oblivion' in content:
return 'oblivion'
elif 'starfield' in content:
return 'starfield'
elif 'enderal' in content:
return 'enderal'
else:
return 'skyrim'
except Exception as e:
logger.warning(f"Error detecting game type from ModOrganizer.ini: {e}")
return 'skyrim'
def validate_and_start_configure(self):
# Reload config to pick up any settings changes made in Settings dialog
self.config_handler.reload_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)
# Initialize progress indicator
self.progress_indicator.set_status("Preparing to configure...", 0)
# Start CPU tracking
self.file_progress_list.start_cpu_tracking()
# Disable controls during configuration
self._disable_controls_during_operation()
# Get selected shortcut
idx = self.shortcut_combo.currentIndex() - 1 # Account for 'Please Select...'
from jackify.frontends.gui.services.message_service import MessageService
if idx < 0 or idx >= len(self.shortcut_map):
MessageService.critical(self, "No Shortcut Selected", "Please select a ModOrganizer.exe Steam shortcut to configure.", safety_level="medium")
self._enable_controls_after_operation()
return
shortcut = self.shortcut_map[idx]
modlist_name = shortcut.get('AppName', shortcut.get('appname', ''))
install_dir = shortcut.get('StartDir', shortcut.get('startdir', ''))
if not modlist_name or not install_dir:
MessageService.critical(self, "Invalid Shortcut", "The selected shortcut is missing required information.", safety_level="medium")
self._enable_controls_after_operation()
return
resolution = self.resolution_combo.currentText()
# Handle resolution saving
if resolution and resolution != "Leave unchanged":
success = self.resolution_service.save_resolution(resolution)
if success:
debug_print(f"DEBUG: Resolution saved successfully: {resolution}")
else:
debug_print("DEBUG: Failed to save resolution")
else:
# Clear saved resolution if "Leave unchanged" is selected
if self.resolution_service.has_saved_resolution():
self.resolution_service.clear_saved_resolution()
debug_print("DEBUG: Saved resolution cleared")
# Start the workflow (no shortcut creation needed)
self.start_workflow(modlist_name, install_dir, resolution)
def start_workflow(self, modlist_name, install_dir, resolution):
"""Start the configuration workflow using backend service directly"""
# CRITICAL: Reload config from disk to pick up any settings changes from Settings dialog
# Refresh Proton version and winetricks settings
self.config_handler._load_config()
# Store install_dir for later use in on_configuration_complete
self._current_install_dir = install_dir
try:
# Start time tracking
self._workflow_start_time = time.time()
from jackify import __version__ as jackify_version
self._safe_append_text(f"Jackify v{jackify_version}")
self._safe_append_text("[Jackify] Starting post-install configuration...")
# Create configuration thread using backend service
from PySide6.QtCore import QThread, Signal
from jackify.backend.models.configuration import SystemInfo
from jackify.backend.services.platform_detection_service import PlatformDetectionService
# Capture parent's method and create system_info
detect_game_type_func = self._detect_game_type_from_mo2_ini
platform_service = PlatformDetectionService.get_instance()
parent_system_info = SystemInfo(is_steamdeck=platform_service.is_steamdeck)
class ConfigurationThread(QThread):
progress_update = Signal(str)
configuration_complete = Signal(bool, str, str, bool)
error_occurred = Signal(str)
def __init__(self, modlist_name, install_dir, resolution, system_info, detect_func):
super().__init__()
self.modlist_name = modlist_name
self.install_dir = install_dir
self.resolution = resolution
self.system_info = system_info
self.detect_game_type = detect_func
def run(self):
try:
from jackify.backend.services.modlist_service import ModlistService
from jackify.backend.models.modlist import ModlistContext
from pathlib import Path
import os
# Initialize backend service
modlist_service = ModlistService(self.system_info)
# Detect game type from ModOrganizer.ini using captured function
detected_game_type = self.detect_game_type(self.install_dir)
# Create modlist context for existing modlist configuration
mo2_exe_path = os.path.join(self.install_dir, "ModOrganizer.exe")
modlist_context = ModlistContext(
name=self.modlist_name,
install_dir=Path(self.install_dir),
download_dir=Path(self.install_dir).parent / 'Downloads', # Default
game_type=detected_game_type,
nexus_api_key='', # Not needed for configuration-only
modlist_value='', # Not needed for existing modlist
modlist_source='existing',
skip_confirmation=True
)
# For existing modlists, add resolution if specified
if self.resolution != "Leave unchanged":
modlist_context.resolution = self.resolution.split()[0]
# If "Leave unchanged" selected, resolution stays None
# Define callbacks
def progress_callback(message):
self.progress_update.emit(message)
def completion_callback(success, message, modlist_name, enb_detected=False):
self.configuration_complete.emit(success, message, modlist_name, enb_detected)
def manual_steps_callback(modlist_name, retry_count):
# Existing modlists shouldn't need manual steps, but handle gracefully
self.progress_update.emit(f"Note: Manual steps callback triggered for {modlist_name} (retry {retry_count})")
# Call the working configuration service method
self.progress_update.emit("Starting existing modlist configuration...")
# For existing modlists, call configure_modlist_post_steam directly
# since Steam setup and manual steps should already be done
success = 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 success:
self.error_occurred.emit("Configuration failed - check logs for details")
except Exception as e:
import traceback
error_msg = f"Configuration error: {e}\n{traceback.format_exc()}"
self.error_occurred.emit(error_msg)
# Create and start the configuration thread
self.config_thread = ConfigurationThread(modlist_name, install_dir, resolution, parent_system_info, detect_game_type_func)
self.config_thread.progress_update.connect(self._handle_progress_update)
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] Failed to start configuration: {e}")
MessageService.critical(self, "Configuration Error", f"Failed to start configuration: {e}", safety_level="medium")
def _check_and_run_vnv_automation(self, modlist_name: str, install_dir: str):
"""Check if VNV automation should run and execute if applicable
Args:
modlist_name: Name of the installed modlist
install_dir: Installation directory path
"""
try:
from pathlib import Path
from jackify.backend.services.vnv_integration_helper import run_vnv_automation_if_applicable, should_offer_vnv_automation
from jackify.backend.services.automated_prefix_service import AutomatedPrefixService
from jackify.backend.handlers.path_handler import PathHandler
# Get paths first (needed for VNV detection)
install_path = Path(install_dir)
# Quick check before importing more (pass install location for ModOrganizer.ini check)
if not should_offer_vnv_automation(modlist_name, install_path):
return
game_paths = PathHandler().find_vanilla_game_paths()
game_root = game_paths.get('Fallout New Vegas')
if not game_root:
debug_print("DEBUG: VNV automation skipped - FNV game root not found")
return
# Confirmation callback - show dialog to user
def confirmation_callback(description: str) -> bool:
from ..services.message_service import MessageService
reply = MessageService.question(
self,
"VNV Post-Install Automation",
description,
critical=False,
safety_level="medium"
)
return reply == QMessageBox.Yes
# Manual file callback for non-Premium users
def manual_file_callback(title: str, instructions: str) -> Optional[Path]:
from PySide6.QtWidgets import QFileDialog
from ..services.message_service import MessageService
# Show instructions
MessageService.information(self, title, instructions)
# Open file picker
file_path, _ = QFileDialog.getOpenFileName(
self,
title,
str(Path.home() / "Downloads"),
"All Files (*.*)"
)
if file_path:
return Path(file_path)
return None
# Run automation
automation_ran, error = run_vnv_automation_if_applicable(
modlist_name=modlist_name,
modlist_install_location=install_path,
game_root=game_root,
ttw_installer_path=AutomatedPrefixService.get_ttw_installer_path(),
progress_callback=None, # GUI doesn't need progress updates for post-install
manual_file_callback=manual_file_callback,
confirmation_callback=confirmation_callback
)
if error:
from ..services.message_service import MessageService
MessageService.warning(
self,
"VNV Automation Failed",
f"VNV post-install automation encountered an error:\n\n{error}\n\n"
"You can complete these steps manually by following the guide at:\n"
"https://vivanewvegas.moddinglinked.com/wabbajack.html"
)
except Exception as e:
debug_print(f"ERROR: Failed to run VNV automation: {e}")
import traceback
debug_print(f"Traceback: {traceback.format_exc()}")
def show_manual_steps_dialog(self, extra_warning=""):
modlist_name = self.shortcut_combo.currentText().split('(')[0].strip() or "your modlist"
msg = (
f"<b>Manual Proton Setup Required for <span style='color:#3fd0ea'>{modlist_name}</span></b><br>"
"After Steam restarts, complete the following steps in Steam:<br>"
f"1. Locate the '<b>{modlist_name}</b>' entry in your Steam Library<br>"
"2. Right-click and select 'Properties'<br>"
"3. Switch to the 'Compatibility' tab<br>"
"4. Check the box labeled 'Force the use of a specific Steam Play compatibility tool'<br>"
"5. Select 'Proton - Experimental' from the dropdown menu<br>"
"6. Close the Properties window<br>"
f"7. Launch '<b>{modlist_name}</b>' from your Steam Library<br>"
"8. Wait for Wabbajack to download its files and fully load<br>"
"9. Once Wabbajack has fully loaded, CLOSE IT completely and return here<br>"
"<br>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:
if self.config_process and self.config_process.state() == QProcess.Running:
self.config_process.write(b'\n')
self.config_process.waitForBytesWritten(1000)
self._config_prompt_state = None
self._manual_steps_buffer = []
else:
# User clicked Cancel or closed the dialog - cancel the workflow
self._safe_append_text("\nManual steps cancelled by user. Workflow stopped.")
# Terminate the configuration process
if self.config_process and self.config_process.state() == QProcess.Running:
self.config_process.terminate()
self.config_process.waitForFinished(2000)
# Re-enable all controls
self._enable_controls_after_operation()
self.cancel_btn.setVisible(True)
def show_next_steps_dialog(self, message):
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)
def on_exit():
QApplication.quit()
btn_return.clicked.connect(on_return)
btn_exit.clicked.connect(on_exit)
dlg.exec()
def _on_steam_restart_finished(self, success, message):
pass
def refresh_modlist_list(self):
"""Refresh the modlist dropdown by re-detecting ModOrganizer.exe shortcuts (async)"""
# Use async loading to avoid blocking UI
self._shortcuts_loaded = False # Allow reload
self._load_shortcuts_async()
def _calculate_time_taken(self) -> str:
"""Calculate and format the time taken for the workflow"""
if self._workflow_start_time is None:
return "unknown time"
elapsed_seconds = time.time() - self._workflow_start_time
elapsed_minutes = int(elapsed_seconds // 60)
elapsed_seconds_remainder = int(elapsed_seconds % 60)
if elapsed_minutes > 0:
if elapsed_minutes == 1:
return f"{elapsed_minutes} minute {elapsed_seconds_remainder} seconds"
else:
return f"{elapsed_minutes} minutes {elapsed_seconds_remainder} seconds"
else:
return f"{elapsed_seconds_remainder} seconds"

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,172 @@
"""Console output management for ConfigureNewModlistScreen (Mixin)."""
import os
import re
import time
from PySide6.QtCore import QTimer
from PySide6.QtWidgets import QFileDialog
from jackify.shared.progress_models import FileProgress, OperationType
class ConfigureNewModlistConsoleMixin:
"""Mixin providing console output management for ConfigureNewModlistScreen."""
def _handle_progress_update(self, text):
"""Handle progress updates - update console, activity window, and progress indicator."""
self._safe_append_text(text)
message_lower = text.lower()
if "creating steam shortcut" in message_lower:
self._stop_component_install_pulse()
self.progress_indicator.set_status("Creating Steam shortcut...", 10)
self.file_progress_list.update_or_add_item("__phase__", "Creating Steam shortcut...", 0.0)
elif "restarting steam" in message_lower or "restart steam" in message_lower:
self._stop_component_install_pulse()
self.progress_indicator.set_status("Restarting Steam...", 20)
self.file_progress_list.update_or_add_item("__phase__", "Restarting Steam...", 0.0)
elif "steam restart" in message_lower and "success" in message_lower:
self._stop_component_install_pulse()
self.progress_indicator.set_status("Steam restarted successfully", 30)
self.file_progress_list.update_or_add_item("__phase__", "Steam restarted", 0.0)
elif "creating proton prefix" in message_lower or "prefix creation" in message_lower:
self._stop_component_install_pulse()
self.progress_indicator.set_status("Creating Proton prefix...", 50)
self.file_progress_list.update_or_add_item("__phase__", "Creating Proton prefix...", 0.0)
elif "prefix created" in message_lower or ("prefix creation" in message_lower and "success" in message_lower):
self._stop_component_install_pulse()
self.progress_indicator.set_status("Proton prefix created", 70)
self.file_progress_list.update_or_add_item("__phase__", "Proton prefix created", 0.0)
elif "applying curated registry" in message_lower or "registry" in message_lower:
self._stop_component_install_pulse()
self.progress_indicator.set_status("Applying registry files...", 75)
self.file_progress_list.update_or_add_item("__phase__", "Applying registry...", 0.0)
elif "installing wine components" in message_lower or "wine component" in message_lower:
self.progress_indicator.set_status("Installing wine components...", 80)
if not hasattr(self, '_component_install_timer') or not self._component_install_timer:
self._start_component_install_pulse()
comp_list = self._parse_wine_components_message(text)
if comp_list:
self._start_component_install_pulse_with_components(comp_list)
elif "wine components verified" in message_lower or "wine components installed" in message_lower:
self._stop_component_install_pulse()
self.progress_indicator.set_status("Wine components installed", 85)
self.file_progress_list.update_or_add_item("__phase__", "Wine components installed", 0.0)
elif "dotnet" in message_lower and "fix" in message_lower:
self._stop_component_install_pulse()
self.progress_indicator.set_status("Applying dotnet fixes...", 85)
self.file_progress_list.update_or_add_item("__phase__", "Applying dotnet fixes...", 0.0)
elif "setting ownership" in message_lower or "ownership and permissions" in message_lower:
self._stop_component_install_pulse()
self.progress_indicator.set_status("Setting permissions...", 90)
self.file_progress_list.update_or_add_item("__phase__", "Setting permissions...", 0.0)
elif "verifying" in message_lower:
self._stop_component_install_pulse()
self.progress_indicator.set_status("Verifying setup...", 95)
self.file_progress_list.update_or_add_item("__phase__", "Verifying setup...", 0.0)
elif "steam integration complete" in message_lower or "configuration complete" in message_lower:
self._stop_component_install_pulse()
self.progress_indicator.set_status("Configuration complete", 100)
self.file_progress_list.update_or_add_item("__phase__", "Configuration complete", 0.0)
def _safe_append_text(self, text):
"""Append text with professional auto-scroll behavior"""
# Write all messages to log file
self._write_to_log_file(text)
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 _parse_wine_components_message(self, text: str):
"""Extract list of wine component names from backend status message, or None."""
if "installing wine components:" not in text.lower() and "installing wine components via protontricks:" not in text.lower():
return None
match = re.search(r"installing wine components(?:\s+via protontricks)?:\s*(.+)", text, re.IGNORECASE)
if not match:
return None
raw = match.group(1).strip()
if not raw:
return None
return [c.strip() for c in raw.split(",") if c.strip()]
def _start_component_install_pulse(self):
"""Start pulsing Activity item for Wine component installation (single generic item)."""
self.file_progress_list.update_or_add_item("__wine_components__", "Installing Wine components...", 0.0)
if not getattr(self, '_component_install_timer', None):
self._component_install_timer = QTimer(self)
self._component_install_timer.timeout.connect(self._component_install_heartbeat)
self._component_install_timer.start(100)
self._component_install_start_time = time.time()
def _start_component_install_pulse_with_components(self, components: list):
"""Replace single item with one Activity entry per component, each with pulsing progress."""
self._component_install_list = components
progresses = [
FileProgress(
filename=f"Wine component: {comp}",
operation=OperationType.UNKNOWN,
percent=0.0,
)
for comp in components
]
self.file_progress_list.update_files(progresses, current_phase=None)
def _component_install_heartbeat(self):
"""Heartbeat to keep component install item(s) pulsing. Duration is shown in the progress banner only."""
if not hasattr(self, '_component_install_start_time') or not self._component_install_start_time:
return
if hasattr(self, '_component_install_list') and self._component_install_list:
progresses = [
FileProgress(
filename=f"Wine component: {comp}",
operation=OperationType.UNKNOWN,
percent=0.0,
)
for comp in self._component_install_list
]
self.file_progress_list.update_files(progresses, current_phase=None)
else:
self.file_progress_list.update_or_add_item("__wine_components__", "Installing Wine components...", 0.0)
def _stop_component_install_pulse(self):
"""Stop the component install pulsing timer."""
if hasattr(self, '_component_install_timer') and self._component_install_timer:
self._component_install_timer.stop()
self._component_install_timer = None
if hasattr(self, '_component_install_list'):
del self._component_install_list
def browse_install_dir(self):
file, _ = QFileDialog.getOpenFileName(self, "Select ModOrganizer.exe", os.path.expanduser("~"), "ModOrganizer.exe (ModOrganizer.exe)")
if file:
self.install_dir_edit.setText(file)

View File

@@ -0,0 +1,354 @@
"""Dialog management for ConfigureNewModlistScreen (Mixin)."""
from PySide6.QtWidgets import QDialog, QVBoxLayout, QLabel, QLineEdit, QPushButton, QHBoxLayout, QFileDialog, QMessageBox, QApplication, QListWidget, QListWidgetItem
from PySide6.QtCore import Qt, QThread, Signal
from PySide6.QtGui import QTextCursor
from pathlib import Path
from typing import Optional
import subprocess
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, cli_path, game_type, project_root, log_path, mode='list-modlists', modlist_name=None, install_dir=None, download_dir=None):
super().__init__()
self.cli_path = cli_path
self.game_type = game_type
self.project_root = project_root
self.log_path = log_path
self.mode = mode
self.modlist_name = modlist_name
self.install_dir = install_dir
self.download_dir = download_dir
def run(self):
# CRITICAL: Use safe Python executable to prevent AppImage recursive spawning
from jackify.backend.handlers.subprocess_utils import get_safe_python_executable
python_exe = get_safe_python_executable()
if self.mode == 'list-modlists':
cmd = [python_exe, self.cli_path, '--install-modlist', '--list-modlists', '--game-type', self.game_type]
elif self.mode == 'install':
cmd = [python_exe, self.cli_path, '--install-modlist', '--install', '--modlist-name', self.modlist_name, '--install-dir', self.install_dir, '--download-dir', self.download_dir, '--game-type', self.game_type]
else:
self.result.emit([], '[ModlistFetchThread] Unknown mode')
return
try:
with open(self.log_path, 'a') as logf:
logf.write(f"\n[Modlist Fetch CMD] {cmd}\n")
# Use clean subprocess environment to prevent AppImage variable inheritance
from jackify.backend.handlers.subprocess_utils import get_clean_subprocess_env
env = get_clean_subprocess_env()
proc = subprocess.Popen(cmd, cwd=self.project_root, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, env=env)
stdout, stderr = proc.communicate()
logf.write(f"[stdout]\n{stdout}\n[stderr]\n{stderr}\n")
if proc.returncode == 0:
modlist_ids = [line.strip() for line in stdout.splitlines() if line.strip()]
self.result.emit(modlist_ids, '')
else:
self.result.emit([], stderr)
except Exception as e:
self.result.emit([], str(e))
class SelectionDialog(QDialog):
def __init__(self, title, items, parent=None):
super().__init__(parent)
self.setWindowTitle(title)
self.setModal(True)
self.setMinimumWidth(350)
self.setMinimumHeight(300)
layout = QVBoxLayout(self)
self.list_widget = QListWidget()
self.list_widget.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
from PySide6.QtWidgets import QSizePolicy
self.list_widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
for item in items:
QListWidgetItem(item, self.list_widget)
layout.addWidget(self.list_widget)
self.selected_item = None
self.list_widget.itemClicked.connect(self.on_item_clicked)
def on_item_clicked(self, item):
self.selected_item = item.text()
self.accept()
class ConfigureNewModlistDialogsMixin:
"""Mixin providing dialog management for ConfigureNewModlistScreen."""
def cleanup_processes(self):
"""Clean up any running processes when the window closes or is cancelled"""
# Stop CPU tracking if active
if hasattr(self, 'file_progress_list'):
self.file_progress_list.stop_cpu_tracking()
# Clean up automated prefix thread if running
if hasattr(self, 'automated_prefix_thread') and self.automated_prefix_thread.isRunning():
self.automated_prefix_thread.terminate()
self.automated_prefix_thread.wait(1000)
# Clean up configuration thread if running
if hasattr(self, 'config_thread') and self.config_thread.isRunning():
self.config_thread.terminate()
self.config_thread.wait(1000)
def 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 = self.modlist_name_edit.text().strip()
# 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
original_name = self.modlist_name_edit.text()
self.modlist_name_edit.setText(new_name)
# Restart the automated workflow
self._safe_append_text(f"Retrying with new shortcut name: '{new_name}'")
self._start_automated_prefix_workflow(new_name, os.path.dirname(self.install_dir_edit.text().strip()) if self.install_dir_edit.text().strip().endswith('ModOrganizer.exe') else self.install_dir_edit.text().strip(), self.install_dir_edit.text().strip(), self.resolution_combo.currentText())
def handle_validation_failure(self, missing_text):
"""Handle manual steps validation failure with retry logic"""
self._manual_steps_retry_count += 1
if self._manual_steps_retry_count < 3:
# Show retry dialog
MessageService.critical(self, "Manual Steps Incomplete",
f"Manual steps validation failed:\n\n{missing_text}\n\n"
"Please complete the manual steps and try again.", safety_level="medium")
# Show manual steps dialog again
extra_warning = ""
if self._manual_steps_retry_count >= 2:
extra_warning = "<br><b style='color:#f33'>It looks like you have not completed the manual steps yet. Please try again.</b>"
self.show_manual_steps_dialog(extra_warning)
else:
# Max retries reached
MessageService.critical(self, "Manual Steps Failed",
"Manual steps validation failed after multiple attempts.", safety_level="medium")
self.on_configuration_complete(False, "Manual steps validation failed after multiple attempts", self.modlist_name_edit.text().strip())
def _check_and_run_vnv_automation(self, modlist_name: str, install_dir: str):
"""Check if VNV automation should run and execute if applicable
Args:
modlist_name: Name of the installed modlist
install_dir: Installation directory path
"""
try:
from pathlib import Path
from jackify.backend.services.vnv_integration_helper import run_vnv_automation_if_applicable, should_offer_vnv_automation
from jackify.backend.services.automated_prefix_service import AutomatedPrefixService
from jackify.backend.handlers.path_handler import PathHandler
# Get paths first (needed for VNV detection)
install_path = Path(install_dir)
# Quick check before importing more (pass install location for ModOrganizer.ini check)
if not should_offer_vnv_automation(modlist_name, install_path):
return
game_paths = PathHandler().find_vanilla_game_paths()
game_root = game_paths.get('Fallout New Vegas')
if not game_root:
debug_print("DEBUG: VNV automation skipped - FNV game root not found")
return
# Confirmation callback - show dialog to user
def confirmation_callback(description: str) -> bool:
from ..services.message_service import MessageService
reply = MessageService.question(
self,
"VNV Post-Install Automation",
description,
critical=False,
safety_level="medium"
)
return reply == QMessageBox.Yes
# Manual file callback for non-Premium users
def manual_file_callback(title: str, instructions: str) -> Optional[Path]:
from PySide6.QtWidgets import QFileDialog
from ..services.message_service import MessageService
# Show instructions
MessageService.information(self, title, instructions)
# Open file picker
file_path, _ = QFileDialog.getOpenFileName(
self,
title,
str(Path.home() / "Downloads"),
"All Files (*.*)"
)
if file_path:
return Path(file_path)
return None
# Run automation
automation_ran, error = run_vnv_automation_if_applicable(
modlist_name=modlist_name,
modlist_install_location=install_path,
game_root=game_root,
ttw_installer_path=AutomatedPrefixService.get_ttw_installer_path(),
progress_callback=None, # GUI doesn't need progress updates for post-install
manual_file_callback=manual_file_callback,
confirmation_callback=confirmation_callback
)
if error:
from ..services.message_service import MessageService
MessageService.warning(
self,
"VNV Automation Failed",
f"VNV post-install automation encountered an error:\n\n{error}\n\n"
"You can complete these steps manually by following the guide at:\n"
"https://vivanewvegas.moddinglinked.com/wabbajack.html"
)
except Exception as e:
debug_print(f"ERROR: Failed to run VNV automation: {e}")
import traceback
debug_print(f"Traceback: {traceback.format_exc()}")
def show_next_steps_dialog(self, message):
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)
def on_exit():
QApplication.quit()
btn_return.clicked.connect(on_return)
btn_exit.clicked.connect(on_exit)
dlg.exec()

View File

@@ -0,0 +1,631 @@
"""UI setup and control management for ConfigureNewModlistScreen (Mixin)."""
from PySide6.QtWidgets import (
QWidget, QVBoxLayout, QHBoxLayout, QLabel, QComboBox, QLineEdit, QPushButton,
QGridLayout, QTextEdit, QSizePolicy, QTabWidget, QCheckBox, QMainWindow, QDialog
)
from PySide6.QtCore import Qt, QSize, QTimer, QProcess
import os
import subprocess
from ..shared_theme import JACKIFY_COLOR_BLUE, DEBUG_BORDERS
from ..utils import set_responsive_minimum
from jackify.frontends.gui.widgets.progress_indicator import OverallProgressIndicator
from jackify.frontends.gui.widgets.file_progress_list import FileProgressList
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 ConfigureNewModlistUISetupMixin:
"""Mixin providing UI setup and control management for ConfigureNewModlistScreen."""
def __init__(self, stacked_widget=None, main_menu_index=0, dev_mode=False, system_info=None):
super().__init__()
debug_print("DEBUG: ConfigureNewModlistScreen __init__ called")
self.stacked_widget = stacked_widget
self.main_menu_index = main_menu_index
self.dev_mode = dev_mode
from jackify.backend.models.configuration import SystemInfo
self.system_info = system_info or SystemInfo(is_steamdeck=False)
self.debug = DEBUG_BORDERS
self.online_modlists = {} # {game_type: [modlist_dict, ...]}
self.modlist_details = {} # {modlist_name: modlist_dict}
# 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()
# Path for workflow log
self.refresh_paths()
# Scroll tracking for professional auto-scroll behavior
self._user_manually_scrolled = False
self._was_at_bottom = True
# Time tracking for workflow completion
self._workflow_start_time = None
# Retry count for manual steps validation (used by dialogs mixin)
self._manual_steps_retry_count = 0
# Initialize progress reporting components
self.progress_indicator = OverallProgressIndicator(show_progress_bar=True)
self.progress_indicator.set_status("Ready to configure", 0)
self.file_progress_list = FileProgressList()
# Create "Show Details" checkbox
self.show_details_checkbox = QCheckBox("Show details")
self.show_details_checkbox.setChecked(False) # Start collapsed
self.show_details_checkbox.setToolTip("Toggle between activity summary and detailed console output")
self.show_details_checkbox.toggled.connect(self._on_show_details_toggled)
main_overall_vbox = QVBoxLayout(self)
main_overall_vbox.setAlignment(Qt.AlignTop | Qt.AlignHCenter)
main_overall_vbox.setContentsMargins(50, 50, 50, 0) # No bottom margin
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("<b>Configure New Modlist</b>")
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 configure a newly installed modlist in Jackify. "
"Set up your Steam shortcut, restart Steam, and complete post-install configuration."
)
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)
header_widget.setMaximumHeight(75) # Match other screens
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)
# --- [Options] header (moved here for alignment) ---
options_header = QLabel("<b>[Options]</b>")
options_header.setStyleSheet(f"color: {JACKIFY_COLOR_BLUE}; font-size: 13px; font-weight: bold;")
options_header.setAlignment(Qt.AlignLeft | Qt.AlignVCenter)
user_config_vbox.addWidget(options_header)
# --- Install/Downloads Dir/API Key (reuse Tuxborn style) ---
form_grid = QGridLayout()
form_grid.setHorizontalSpacing(12)
form_grid.setVerticalSpacing(6)
form_grid.setContentsMargins(0, 0, 0, 0)
# Modlist Name (NEW FIELD)
modlist_name_label = QLabel("Modlist Name:")
self.modlist_name_edit = QLineEdit()
self.modlist_name_edit.setMaximumHeight(25) # Force compact height
form_grid.addWidget(modlist_name_label, 0, 0, alignment=Qt.AlignLeft | Qt.AlignVCenter)
form_grid.addWidget(self.modlist_name_edit, 0, 1)
# Install Dir
install_dir_label = QLabel("ModOrganizer.exe Path:")
self.install_dir_edit = QLineEdit("/path/to/Modlist/ModOrganizer.exe")
self.install_dir_edit.setMaximumHeight(25) # Force compact height
browse_install_btn = QPushButton("Browse")
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(browse_install_btn)
form_grid.addWidget(install_dir_label, 1, 0, alignment=Qt.AlignLeft | Qt.AlignVCenter)
form_grid.addLayout(install_dir_hbox, 1, 1)
# --- Resolution Dropdown ---
resolution_label = QLabel("Resolution:")
self.resolution_combo = QComboBox()
self.resolution_combo.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
self.resolution_combo.addItem("Leave unchanged")
self.resolution_combo.addItems([
"1280x720",
"1280x800 (Steam Deck)",
"1366x768",
"1440x900",
"1600x900",
"1600x1200",
"1680x1050",
"1920x1080",
"1920x1200",
"2048x1152",
"2560x1080",
"2560x1440",
"2560x1600",
"3440x1440",
"3840x1600",
"3840x2160",
"3840x2400",
"5120x1440",
"5120x2160",
"7680x4320"
])
form_grid.addWidget(resolution_label, 2, 0, alignment=Qt.AlignLeft | Qt.AlignVCenter)
# Load saved resolution if available
saved_resolution = self.resolution_service.get_saved_resolution()
is_steam_deck = False
try:
if os.path.exists('/etc/os-release'):
with open('/etc/os-release') as f:
if 'steamdeck' in f.read().lower():
is_steam_deck = True
except Exception:
pass
if saved_resolution:
combo_items = [self.resolution_combo.itemText(i) for i in range(self.resolution_combo.count())]
resolution_index = self.resolution_service.get_resolution_index(saved_resolution, combo_items)
self.resolution_combo.setCurrentIndex(resolution_index)
debug_print(f"DEBUG: Loaded saved resolution: {saved_resolution} (index: {resolution_index})")
elif is_steam_deck:
# Set default to 1280x800 (Steam Deck)
combo_items = [self.resolution_combo.itemText(i) for i in range(self.resolution_combo.count())]
if "1280x800 (Steam Deck)" in combo_items:
self.resolution_combo.setCurrentIndex(combo_items.index("1280x800 (Steam Deck)"))
else:
self.resolution_combo.setCurrentIndex(0)
# Otherwise, default is 'Leave unchanged' (index 0)
# Horizontal layout for resolution dropdown and auto-restart checkbox
resolution_and_restart_layout = QHBoxLayout()
resolution_and_restart_layout.setSpacing(12)
# Resolution dropdown (made smaller)
self.resolution_combo.setMaximumWidth(280) # Constrain width but keep aesthetically pleasing
resolution_and_restart_layout.addWidget(self.resolution_combo)
# Add stretch to push checkbox to the right
resolution_and_restart_layout.addStretch()
# Auto-accept Steam restart checkbox (right-aligned)
self.auto_restart_checkbox = QCheckBox("Auto-accept Steam restart")
self.auto_restart_checkbox.setChecked(False) # Always default to unchecked per session
self.auto_restart_checkbox.setToolTip("When checked, Steam restart dialog will be automatically accepted, allowing unattended configuration")
resolution_and_restart_layout.addWidget(self.auto_restart_checkbox)
# Update the form grid to use the combined layout
form_grid.addLayout(resolution_and_restart_layout, 2, 1)
form_section_widget = QWidget()
form_section_widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
form_section_widget.setLayout(form_grid)
form_section_widget.setMinimumHeight(120) # Reduced to match compact form
form_section_widget.setMaximumHeight(240) # Increased to show resolution dropdown
if self.debug:
form_section_widget.setStyleSheet("border: 2px solid blue;")
form_section_widget.setToolTip("FORM_SECTION")
user_config_vbox.addWidget(form_section_widget)
# --- Buttons ---
btn_row = QHBoxLayout()
btn_row.setAlignment(Qt.AlignHCenter)
self.start_btn = QPushButton("Start Configuration")
btn_row.addWidget(self.start_btn)
cancel_btn = QPushButton("Cancel")
cancel_btn.clicked.connect(self.cancel_and_cleanup)
btn_row.addWidget(cancel_btn)
user_config_widget = QWidget()
user_config_widget.setLayout(user_config_vbox)
if self.debug:
user_config_widget.setStyleSheet("border: 2px solid orange;")
user_config_widget.setToolTip("USER_CONFIG_WIDGET")
# Right: Tabbed interface with Activity and Process Monitor
# Both tabs are always available, user can switch between them
self.process_monitor = QTextEdit()
self.process_monitor.setReadOnly(True)
self.process_monitor.setTextInteractionFlags(Qt.TextSelectableByMouse | Qt.TextSelectableByKeyboard)
self.process_monitor.setMinimumSize(QSize(300, 20))
self.process_monitor.setStyleSheet(f"background: #222; color: {JACKIFY_COLOR_BLUE}; font-family: monospace; font-size: 11px; border: 1px solid #444;")
self.process_monitor_heading = QLabel("<b>[Process Monitor]</b>")
self.process_monitor_heading.setStyleSheet(f"color: {JACKIFY_COLOR_BLUE}; font-size: 13px; margin-bottom: 2px;")
self.process_monitor_heading.setAlignment(Qt.AlignLeft | Qt.AlignVCenter)
process_vbox = QVBoxLayout()
process_vbox.setContentsMargins(0, 0, 0, 0)
process_vbox.setSpacing(2)
process_vbox.addWidget(self.process_monitor_heading)
process_vbox.addWidget(self.process_monitor)
process_monitor_widget = QWidget()
process_monitor_widget.setLayout(process_vbox)
process_monitor_widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
if self.debug:
process_monitor_widget.setStyleSheet("border: 2px solid purple;")
process_monitor_widget.setToolTip("PROCESS_MONITOR")
self.process_monitor_widget = process_monitor_widget
# Set up File Progress List (Activity tab)
self.file_progress_list.setMinimumSize(QSize(300, 20))
self.file_progress_list.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
# Create tab widget to hold both Activity and Process Monitor
self.activity_tabs = QTabWidget()
self.activity_tabs.setStyleSheet("QTabWidget::pane { background: #222; border: 1px solid #444; } QTabBar::tab { background: #222; color: #ccc; padding: 6px 16px; } QTabBar::tab:selected { background: #333; color: #3fd0ea; } QTabWidget { margin: 0px; padding: 0px; } QTabBar { margin: 0px; padding: 0px; }")
self.activity_tabs.setContentsMargins(0, 0, 0, 0)
self.activity_tabs.setDocumentMode(False)
self.activity_tabs.setTabPosition(QTabWidget.North)
if self.debug:
self.activity_tabs.setStyleSheet("border: 2px solid cyan;")
self.activity_tabs.setToolTip("ACTIVITY_TABS")
# Add both widgets as tabs
self.activity_tabs.addTab(self.file_progress_list, "Activity")
self.activity_tabs.addTab(process_monitor_widget, "Process Monitor")
upper_hbox.addWidget(user_config_widget, stretch=11)
upper_hbox.addWidget(self.activity_tabs, stretch=9)
upper_hbox.setAlignment(Qt.AlignTop)
upper_section_widget = QWidget()
upper_section_widget.setLayout(upper_hbox)
# Use Fixed size policy for consistent height
upper_section_widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
upper_section_widget.setMaximumHeight(280) # Increased to show resolution dropdown
if self.debug:
upper_section_widget.setStyleSheet("border: 2px solid green;")
upper_section_widget.setToolTip("UPPER_SECTION")
main_overall_vbox.addWidget(upper_section_widget)
# Status banner with progress indicator and "Show details" toggle
banner_row = QHBoxLayout()
banner_row.setContentsMargins(0, 0, 0, 0)
banner_row.setSpacing(8)
banner_row.addWidget(self.progress_indicator, 1)
banner_row.addStretch()
banner_row.addWidget(self.show_details_checkbox)
banner_row_widget = QWidget()
banner_row_widget.setLayout(banner_row)
banner_row_widget.setMaximumHeight(45) # Compact height
banner_row_widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
main_overall_vbox.addWidget(banner_row_widget)
# Console output area (shown when "Show details" is checked)
self.console = QTextEdit()
self.console.setReadOnly(True)
self.console.setTextInteractionFlags(Qt.TextSelectableByMouse | Qt.TextSelectableByKeyboard)
self.console.setMinimumHeight(50)
self.console.setMaximumHeight(1000)
self.console.setFontFamily('monospace')
self.console.setVisible(False) # Hidden by default (compact mode)
if self.debug:
self.console.setStyleSheet("border: 2px solid yellow;")
self.console.setToolTip("CONSOLE")
# Set up scroll tracking for professional auto-scroll behavior
self._setup_scroll_tracking()
# Wrap button row in widget for debug borders
btn_row_widget = QWidget()
btn_row_widget.setLayout(btn_row)
btn_row_widget.setMaximumHeight(50)
if self.debug:
btn_row_widget.setStyleSheet("border: 2px solid red;")
btn_row_widget.setToolTip("BUTTON_ROW")
# Create a container that holds console + button row with proper spacing
console_and_buttons_widget = QWidget()
console_and_buttons_layout = QVBoxLayout()
console_and_buttons_layout.setContentsMargins(0, 0, 0, 0)
console_and_buttons_layout.setSpacing(8)
console_and_buttons_layout.addWidget(self.console, stretch=1)
console_and_buttons_layout.addWidget(btn_row_widget)
console_and_buttons_widget.setLayout(console_and_buttons_layout)
console_and_buttons_widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
console_and_buttons_widget.setFixedHeight(50) # Lock to button row height when console is hidden
if self.debug:
console_and_buttons_widget.setStyleSheet("border: 2px solid lightblue;")
console_and_buttons_widget.setToolTip("CONSOLE_AND_BUTTONS_CONTAINER")
# Add without stretch to prevent squashing upper section
main_overall_vbox.addWidget(console_and_buttons_widget)
# Store references for toggle functionality
self.console_and_buttons_widget = console_and_buttons_widget
self.console_and_buttons_layout = console_and_buttons_layout
self.main_overall_vbox = main_overall_vbox
self.setLayout(main_overall_vbox)
# --- 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 Configuration button ---
self.start_btn.clicked.connect(self.validate_and_start_configure)
# Initialize empty controls list - will be populated after UI is built
self._actionable_controls = []
# Now collect all actionable controls after UI is fully built
self._collect_actionable_controls()
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,
# Form fields
self.modlist_name_edit,
self.install_dir_edit,
# Resolution controls
self.resolution_combo,
# Checkboxes
self.auto_restart_checkbox,
]
def _disable_controls_during_operation(self):
"""Disable all actionable controls during 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 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() / 'Configure_New_Modlist_workflow.log'
os.makedirs(os.path.dirname(self.modlist_log_path), exist_ok=True)
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
self.console.setMinimumHeight(50) # Keep minimum height for usability
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):
"""Handle Show Details checkbox toggle"""
self._toggle_console_visibility(checked)
def _toggle_console_visibility(self, is_checked):
"""Toggle console visibility and window size - matches pattern from other screens"""
main_window = None
try:
parent = self.parent()
while parent and not isinstance(parent, QMainWindow):
parent = parent.parent()
if parent and isinstance(parent, QMainWindow):
main_window = parent
except Exception:
pass
if is_checked:
# Show console
self.console.setVisible(True)
self.console.setMinimumHeight(200)
self.console.setMaximumHeight(16777215)
self.console.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
# Allow expansion when console is visible
if hasattr(self, 'console_and_buttons_widget'):
self.console_and_buttons_widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
self.console_and_buttons_widget.setMinimumHeight(0)
self.console_and_buttons_widget.setMaximumHeight(16777215)
self.console_and_buttons_widget.updateGeometry()
# Set stretch factor for console in layout
if hasattr(self, 'main_overall_vbox'):
try:
self.main_overall_vbox.setStretchFactor(self.console, 1)
except Exception:
pass
# Expand window
if main_window:
try:
from PySide6.QtCore import QSize
# On Steam Deck, keep fullscreen; on other systems, set normal window state
if not (hasattr(main_window, 'system_info') and main_window.system_info.is_steamdeck):
main_window.showNormal()
main_window.setMaximumHeight(16777215)
main_window.setMinimumHeight(0)
expanded_min = 900
current_size = main_window.size()
target_height = max(expanded_min, 900)
main_window.setMinimumHeight(expanded_min)
main_window.resize(current_size.width(), target_height)
if hasattr(self, 'main_overall_vbox'):
self.main_overall_vbox.invalidate()
self.updateGeometry()
except Exception:
pass
# Notify parent to expand
self.resize_request.emit("expand")
else:
# Hide console
self.console.setVisible(False)
self.console.setMinimumHeight(0)
self.console.setMaximumHeight(0)
self.console.setSizePolicy(QSizePolicy.Ignored, QSizePolicy.Fixed)
# Lock height when console is hidden
if hasattr(self, 'console_and_buttons_widget'):
self.console_and_buttons_widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
self.console_and_buttons_widget.setFixedHeight(50)
self.console_and_buttons_widget.updateGeometry()
# Remove stretch factor for console
if hasattr(self, 'main_overall_vbox'):
try:
self.main_overall_vbox.setStretchFactor(self.console, 0)
except Exception:
pass
# Collapse window
if main_window:
try:
from PySide6.QtCore import QSize
compact_height = 620
# On Steam Deck, keep fullscreen; on other systems, set normal window state
if not (hasattr(main_window, 'system_info') and main_window.system_info.is_steamdeck):
main_window.showNormal()
set_responsive_minimum(main_window, min_width=960, min_height=compact_height)
current_size = main_window.size()
main_window.resize(current_size.width(), compact_height)
except Exception:
pass
# Notify parent to collapse
self.resize_request.emit("compact")
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()
# Include jackify-engine and related heavy processes
heavy_processes = (
"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
)
# Include Python processes running configure-modlist command
configure_processes = (
"python" in line_lower and "configure-modlist" in line_lower
)
# Include QProcess processes that might be configuration-related
qprocess_config = (
hasattr(self, 'config_process') and
self.config_process and
self.config_process.state() == QProcess.Running and
("python" in line_lower or "jackify" in line_lower)
)
if (heavy_processes or configure_processes or qprocess_config) 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:
if self.protontricks_service.is_bundled_mode():
return True
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}")
from jackify.frontends.gui.services.message_service import MessageService
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

View File

@@ -0,0 +1,524 @@
"""Workflow management for ConfigureNewModlistScreen (Mixin)."""
from PySide6.QtCore import QThread, Signal
import os
import time
import logging
from jackify.shared.resolution_utils import get_resolution_fallback
logger = logging.getLogger(__name__)
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 ConfigureNewModlistWorkflowMixin:
"""Mixin providing workflow management for ConfigureNewModlistScreen."""
def _detect_game_type_from_mo2_ini(self, install_dir: str) -> str:
"""Detect game type by checking ModOrganizer.ini for loader executables."""
from pathlib import Path
mo2_ini = Path(install_dir) / "ModOrganizer.ini"
if not mo2_ini.exists():
return 'skyrim' # Fallback to most common
try:
content = mo2_ini.read_text(encoding='utf-8', errors='ignore').lower()
if 'skse64_loader.exe' in content or 'skyrim special edition' in content:
return 'skyrim'
elif 'f4se_loader.exe' in content or 'fallout 4' in content:
return 'fallout4'
elif 'nvse_loader.exe' in content or 'fallout new vegas' in content:
return 'falloutnv'
elif 'obse_loader.exe' in content or 'oblivion' in content:
return 'oblivion'
elif 'starfield' in content:
return 'starfield'
elif 'enderal' in content:
return 'enderal'
else:
return 'skyrim'
except Exception as e:
logger.warning(f"Error detecting game type from ModOrganizer.ini: {e}")
return 'skyrim'
def validate_and_start_configure(self):
# Reload config to pick up any settings changes made in Settings dialog
self.config_handler.reload_config()
# Check protontricks before proceeding
if not self._check_protontricks():
return
# 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)
# Validate ModOrganizer.exe path
mo2_path = self.install_dir_edit.text().strip()
from jackify.frontends.gui.services.message_service import MessageService
if not mo2_path:
MessageService.warning(self, "Missing Path", "Please specify the path to ModOrganizer.exe", safety_level="low")
return
if not os.path.isfile(mo2_path):
MessageService.warning(self, "Invalid Path", "The specified path does not point to a valid file", safety_level="low")
return
if not mo2_path.endswith('ModOrganizer.exe'):
MessageService.warning(self, "Invalid File", "The specified file is not ModOrganizer.exe", safety_level="low")
return
# Start time tracking
self._workflow_start_time = time.time()
# Initialize progress indicator
self.progress_indicator.set_status("Preparing to configure...", 0)
# Start CPU tracking
self.file_progress_list.start_cpu_tracking()
# Disable controls during configuration (after validation passes)
self._disable_controls_during_operation()
# Validate modlist name
modlist_name = self.modlist_name_edit.text().strip()
if not modlist_name:
MessageService.warning(self, "Missing Name", "Please specify a name for your modlist", safety_level="low")
self._enable_controls_after_operation()
return
# Handle resolution saving
resolution = self.resolution_combo.currentText()
if resolution and resolution != "Leave unchanged":
success = self.resolution_service.save_resolution(resolution)
if success:
debug_print(f"DEBUG: Resolution saved successfully: {resolution}")
else:
debug_print("DEBUG: Failed to save resolution")
else:
# Clear saved resolution if "Leave unchanged" is selected
if self.resolution_service.has_saved_resolution():
self.resolution_service.clear_saved_resolution()
debug_print("DEBUG: Saved resolution cleared")
# Start configuration - automated workflow handles Steam restart internally
self.configure_modlist()
def configure_modlist(self):
# CRITICAL: Reload config from disk to pick up any settings changes from Settings dialog
# Refresh Proton version and winetricks settings
self.config_handler._load_config()
install_dir = os.path.dirname(self.install_dir_edit.text().strip()) if self.install_dir_edit.text().strip().endswith('ModOrganizer.exe') else self.install_dir_edit.text().strip()
modlist_name = self.modlist_name_edit.text().strip()
mo2_exe_path = self.install_dir_edit.text().strip()
resolution = self.resolution_combo.currentText()
if not install_dir or not modlist_name:
MessageService.warning(self, "Missing Info", "Install directory or modlist name is missing.", safety_level="low")
return
# Use automated prefix service instead of manual steps
self._safe_append_text("")
self._safe_append_text("=== Steam Integration Phase ===")
self._safe_append_text("Starting automated Steam setup workflow...")
# Start automated prefix workflow
self._start_automated_prefix_workflow(modlist_name, install_dir, mo2_exe_path, resolution)
def _start_automated_prefix_workflow(self, modlist_name, install_dir, mo2_exe_path, resolution):
"""Start the automated prefix workflow using AutomatedPrefixService in a background thread"""
from jackify import __version__ as jackify_version
self._safe_append_text(f"Jackify v{jackify_version}")
self._safe_append_text(f"Initializing automated Steam setup for '{modlist_name}'...")
self._safe_append_text("Starting automated Steam shortcut creation and configuration...")
# Disable the start button to prevent multiple workflows
self.start_btn.setEnabled(False)
# Create and start the automated prefix thread
class AutomatedPrefixThread(QThread):
progress_update = Signal(str)
workflow_complete = Signal(object) # Will emit the result tuple
error_occurred = Signal(str)
def __init__(self, modlist_name, install_dir, mo2_exe_path, steamdeck, auto_restart):
super().__init__()
self.modlist_name = modlist_name
self.install_dir = install_dir
self.mo2_exe_path = mo2_exe_path
self.steamdeck = steamdeck
self.auto_restart = auto_restart
def run(self):
try:
from jackify.backend.services.automated_prefix_service import AutomatedPrefixService
# Initialize the automated prefix service
prefix_service = AutomatedPrefixService()
# Define progress callback for GUI updates
def progress_callback(message):
self.progress_update.emit(message)
# Run the automated workflow (this contains the blocking operations)
result = prefix_service.run_working_workflow(
self.modlist_name, self.install_dir, self.mo2_exe_path,
progress_callback, steamdeck=self.steamdeck, auto_restart=self.auto_restart
)
# Emit the result
self.workflow_complete.emit(result)
except Exception as e:
self.error_occurred.emit(str(e))
# Detect Steam Deck once using centralized service
from jackify.backend.services.platform_detection_service import PlatformDetectionService
platform_service = PlatformDetectionService.get_instance()
_is_steamdeck = platform_service.is_steamdeck
# Decide whether to restart Steam: checkbox checked = yes; unchecked = ask (only skip if user clicks No)
from PySide6.QtWidgets import QMessageBox
from jackify.frontends.gui.services.message_service import MessageService
auto_restart = self.auto_restart_checkbox.isChecked()
if not auto_restart:
reply = MessageService.question(
self,
"Restart Steam?",
"Steam will need to restart to detect the new shortcut. Do you want Jackify to restart Steam when the time comes?",
safety_level="medium"
)
# Only skip restart when user explicitly clicks No; treat Yes or dialog close as restart
auto_restart = reply != QMessageBox.No
logger.info("Configure New Modlist: starting automated prefix workflow with auto_restart=%s", auto_restart)
# Create and start the thread
self.automated_prefix_thread = AutomatedPrefixThread(modlist_name, install_dir, mo2_exe_path, _is_steamdeck, auto_restart)
self.automated_prefix_thread.progress_update.connect(self._handle_progress_update)
self.automated_prefix_thread.workflow_complete.connect(self._on_automated_prefix_complete)
self.automated_prefix_thread.error_occurred.connect(self._on_automated_prefix_error)
self.automated_prefix_thread.start()
def _on_automated_prefix_complete(self, result):
"""Handle completion of the automated prefix workflow"""
try:
# Handle the result - check for conflicts
if isinstance(result, tuple) and len(result) == 4:
if result[0] == "CONFLICT":
# Conflict detected - show conflict resolution dialog
conflicts = result[1]
self.show_shortcut_conflict_dialog(conflicts)
return
else:
# Normal result
success, prefix_path, new_appid, last_timestamp = result
if success:
self._safe_append_text(f"Automated Steam setup completed successfully!")
self._safe_append_text(f"New AppID assigned: {new_appid}")
# Continue with post-Steam configuration, passing the last timestamp
self.continue_configuration_after_automated_prefix(new_appid, self.modlist_name_edit.text().strip(),
os.path.dirname(self.install_dir_edit.text().strip()) if self.install_dir_edit.text().strip().endswith('ModOrganizer.exe') else self.install_dir_edit.text().strip(),
last_timestamp)
else:
self._safe_append_text(f"Automated Steam setup failed")
self._safe_append_text("Please check the logs for details.")
self.start_btn.setEnabled(True)
elif isinstance(result, tuple) and len(result) == 3:
# Fallback for old format (backward compatibility)
success, prefix_path, new_appid = result
if success:
self._safe_append_text(f"Automated Steam setup completed successfully!")
self._safe_append_text(f"New AppID assigned: {new_appid}")
# Continue with post-Steam configuration
self.continue_configuration_after_automated_prefix(new_appid, self.modlist_name_edit.text().strip(),
os.path.dirname(self.install_dir_edit.text().strip()) if self.install_dir_edit.text().strip().endswith('ModOrganizer.exe') else self.install_dir_edit.text().strip())
else:
self._safe_append_text(f"Automated Steam setup failed")
self._safe_append_text("Please check the logs for details.")
self.start_btn.setEnabled(True)
else:
# Handle unexpected result format
self._safe_append_text(f"Automated Steam setup failed - unexpected result format")
self._safe_append_text("Please check the logs for details.")
self.start_btn.setEnabled(True)
except Exception as e:
self._safe_append_text(f"Error handling automated prefix result: {str(e)}")
self.start_btn.setEnabled(True)
def _on_automated_prefix_error(self, error_message):
"""Handle error from the automated prefix workflow"""
self._safe_append_text(f"Error during automated Steam setup: {error_message}")
self._safe_append_text("Please check the logs for details.")
# Show critical error dialog to user (don't silently fail)
from jackify.backend.services.message_service import MessageService
MessageService.critical(
self,
"Steam Setup Error",
f"Error during automated Steam setup:\n\n{error_message}\n\nPlease check the console output for details.",
safety_level="medium"
)
self._enable_controls_after_operation()
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:
# Get resolution from UI
resolution = self.resolution_combo.currentText()
resolution_value = resolution.split()[0] if resolution != "Leave unchanged" else None
# Update the context with the new AppID (same format as manual steps)
mo2_exe_path = self.install_dir_edit.text().strip()
updated_context = {
'name': modlist_name,
'path': install_dir,
'mo2_exe_path': mo2_exe_path,
'modlist_value': None,
'modlist_source': None,
'resolution': resolution_value,
'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': 'Skyrim Special Edition' # Default for new modlist
}
self.context = updated_context # Ensure context is always set
debug_print(f"Updated context with new AppID: {new_appid}")
# Create new config thread with updated context
from PySide6.QtCore import QThread, Signal
# Capture parent's method and system_info
detect_game_type_func = self._detect_game_type_from_mo2_ini
parent_system_info = self.system_info
class ConfigThread(QThread):
progress_update = Signal(str)
configuration_complete = Signal(bool, str, str, bool)
error_occurred = Signal(str)
def __init__(self, context, system_info, detect_func):
super().__init__()
self.context = context
self.system_info = system_info
self.detect_game_type = detect_func
def run(self):
try:
from jackify.backend.services.modlist_service import ModlistService
from jackify.backend.models.modlist import ModlistContext
from pathlib import Path
# Initialize backend service
modlist_service = ModlistService(self.system_info)
# Detect game type from ModOrganizer.ini using captured function
detected_game_type = self.detect_game_type(self.context['path'])
# 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=detected_game_type,
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') or get_resolution_fallback(None),
skip_confirmation=True
)
# 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, enb_detected=False):
self.configuration_complete.emit(success, message, modlist_name, enb_detected)
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
self.progress_update.emit("")
self.progress_update.emit("=== Configuration Phase ===")
self.progress_update.emit("")
self.progress_update.emit("Starting modlist 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, parent_system_info, detect_game_type_func)
self.config_thread.progress_update.connect(self._handle_progress_update)
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
mo2_exe_path = self.install_dir_edit.text().strip()
resolution = self.resolution_combo.currentText()
updated_context = {
'name': modlist_name,
'path': install_dir,
'mo2_exe_path': mo2_exe_path,
'resolution': resolution.split()[0] if resolution != "Leave unchanged" else None,
'skip_confirmation': True,
'manual_steps_completed': True, # Mark as completed
'appid': new_appid, # Use the NEW AppID from Steam
'game_name': 'Skyrim Special Edition' # Default for new modlist
}
debug_print(f"Updated context with new AppID: {new_appid}")
# Create new config thread with updated context (same as Tuxborn)
from PySide6.QtCore import QThread, Signal
# Capture parent's method and system_info
detect_game_type_func = self._detect_game_type_from_mo2_ini
parent_system_info = self.system_info
class ConfigThread(QThread):
progress_update = Signal(str)
configuration_complete = Signal(bool, str, str, bool)
error_occurred = Signal(str)
def __init__(self, context, system_info, detect_func):
super().__init__()
self.context = context
self.system_info = system_info
self.detect_game_type = detect_func
def run(self):
try:
from jackify.backend.services.modlist_service import ModlistService
from jackify.backend.models.modlist import ModlistContext
from pathlib import Path
# Initialize backend service
modlist_service = ModlistService(self.system_info)
# Detect game type from ModOrganizer.ini using captured function
detected_game_type = self.detect_game_type(self.context['path'])
# 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=detected_game_type,
nexus_api_key='', # Not needed for configuration
modlist_value='', # Not needed for existing modlist
modlist_source='existing',
resolution=self.context.get('resolution') or get_resolution_fallback(None),
skip_confirmation=True
)
# 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, enb_detected=False):
self.configuration_complete.emit(success, message, modlist_name, enb_detected)
def manual_steps_callback(modlist_name, retry_count):
# Should not reach here -- manual steps already complete
self.progress_update.emit(f"Unexpected manual steps callback for {modlist_name}")
# Call the working configuration service method
self.progress_update.emit("Starting configuration with backend service...")
success = 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 success:
self.error_occurred.emit("Configuration failed - check logs for details")
except Exception as e:
import traceback
error_msg = f"Configuration error: {e}\n{traceback.format_exc()}"
self.error_occurred.emit(error_msg)
# Create and start the configuration thread
self.config_thread = ConfigThread(updated_context, parent_system_info, detect_game_type_func)
self.config_thread.progress_update.connect(self._handle_progress_update)
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}")
MessageService.critical(self, "Configuration Error", f"Failed to continue configuration: {e}", safety_level="medium")
def _calculate_time_taken(self) -> str:
"""Calculate and format the time taken for the workflow"""
if self._workflow_start_time is None:
return "unknown time"
elapsed_seconds = time.time() - self._workflow_start_time
elapsed_minutes = int(elapsed_seconds // 60)
elapsed_seconds_remainder = int(elapsed_seconds % 60)
if elapsed_minutes > 0:
if elapsed_minutes == 1:
return f"{elapsed_minutes} minute {elapsed_seconds_remainder} seconds"
else:
return f"{elapsed_minutes} minutes {elapsed_seconds_remainder} seconds"
else:
return f"{elapsed_seconds_remainder} seconds"

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,377 @@
"""Automated prefix workflow handlers for InstallModlistScreen (Mixin)."""
from PySide6.QtCore import QThread, Signal, Qt, QTimer
from PySide6.QtWidgets import QProgressDialog, QMainWindow
from jackify.frontends.gui.services.message_service import MessageService
from jackify.backend.services.automated_prefix_service import AutomatedPrefixService
from pathlib import Path
import traceback
import threading
import subprocess
import time
import os
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 AutomatedPrefixHandlersMixin:
"""Mixin providing automated prefix workflow event handlers for InstallModlistScreen."""
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
# Get system_info from parent screen
system_info = getattr(self, 'system_info', None)
is_steamdeck = system_info.is_steamdeck if system_info else False
shortcut_handler = ShortcutHandler(steamdeck=is_steamdeck)
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.")
# Force Steam GUI to start after restart
# Ensure Steam GUI is visible after restart
# start_steam() now uses -foreground, but we'll also try to bring GUI to front
debug_print("DEBUG: Ensuring Steam GUI is visible after restart")
try:
# Wait a moment for Steam processes to stabilize
time.sleep(3)
# Try multiple methods to ensure GUI opens
# Method 1: steam:// protocol (works if Steam is running)
try:
subprocess.Popen(['xdg-open', 'steam://open/main'], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
debug_print("DEBUG: Issued steam://open/main command")
time.sleep(1)
except Exception as e:
debug_print(f"DEBUG: steam://open/main failed: {e}")
# Method 2: Direct steam -foreground command (redundant but ensures GUI)
try:
subprocess.Popen(['steam', '-foreground'], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
debug_print("DEBUG: Issued steam -foreground command")
except Exception as e2:
debug_print(f"DEBUG: steam -foreground failed: {e2}")
except Exception as e:
debug_print(f"DEBUG: Error ensuring Steam GUI visibility: {e}")
# CRITICAL: Bring Jackify window back to focus after Steam restart
# Let user continue with installation
debug_print("DEBUG: Bringing Jackify window back to focus")
try:
from PySide6.QtWidgets import QApplication
# Get the main window - use window() to get top-level widget, then find QMainWindow
top_level = self.window()
main_window = None
# Try to find QMainWindow in the widget hierarchy
if isinstance(top_level, QMainWindow):
main_window = top_level
else:
# Walk up the parent chain
current = self
while current:
if isinstance(current, QMainWindow):
main_window = current
break
current = current.parent()
# Last resort: use top-level widget
if not main_window and top_level:
main_window = top_level
if main_window:
# Restore window if minimized
if hasattr(main_window, 'isMinimized') and main_window.isMinimized():
main_window.showNormal()
# Bring to front and activate - use multiple methods for reliability
main_window.raise_()
main_window.activateWindow()
main_window.show()
# Aggressive focus restoration with multiple attempts
# Steam may steal focus, so we retry multiple times over several seconds
def restore_focus():
if main_window:
try:
main_window.raise_()
main_window.activateWindow()
app = QApplication.instance()
if app and app.activeWindow() != main_window:
debug_print("DEBUG: Window not active, retrying focus restoration")
except Exception:
pass
# Immediate attempts
QTimer.singleShot(50, restore_focus)
QTimer.singleShot(200, restore_focus)
QTimer.singleShot(500, restore_focus)
# Delayed attempts in case Steam steals focus after initial restoration
QTimer.singleShot(1000, restore_focus)
QTimer.singleShot(2000, restore_focus)
QTimer.singleShot(3000, restore_focus)
debug_print(f"DEBUG: Jackify window focus restoration scheduled (type: {type(main_window).__name__})")
else:
debug_print("DEBUG: Could not find main window to bring to focus")
except Exception as e:
debug_print(f"DEBUG: Error bringing Jackify to focus: {e}")
# Save context for later use in configuration
self._manual_steps_retry_count = 0
self._current_modlist_name = self.modlist_name_edit.text().strip()
# Save resolution for later use in configuration
resolution = self.resolution_combo.currentText()
# Extract resolution properly (e.g., "1280x800" from "1280x800 (Steam Deck)")
if resolution != "Leave unchanged":
if " (" in resolution:
self._current_resolution = resolution.split(" (")[0]
else:
self._current_resolution = resolution
else:
self._current_resolution = None
# 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):
"""Start the automated prefix creation workflow"""
# CRITICAL: Reload config from disk to pick up any settings changes from Settings dialog
# Refresh Proton version and winetricks settings
self.config_handler._load_config()
# Ensure _current_resolution is always set before starting workflow
if not hasattr(self, '_current_resolution') or self._current_resolution is None:
resolution = self.resolution_combo.currentText() if hasattr(self, 'resolution_combo') else None
# 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
try:
# Disable controls during installation
self._disable_controls_during_operation()
modlist_name = self.modlist_name_edit.text().strip()
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
self._begin_post_install_feedback()
# Run automated prefix creation in separate thread
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, downloads_dir=None):
super().__init__()
self.modlist_name = modlist_name
self.install_dir = install_dir
self.final_exe_path = final_exe_path
self.downloads_dir = downloads_dir
def run(self):
try:
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:
_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, download_dir=self.downloads_dir
)
# 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 (pass downloads_dir for STEAM_COMPAT_MOUNTS)
downloads_dir = self.downloads_dir_edit.text().strip() if getattr(self, 'downloads_dir_edit', None) else None
self.prefix_thread = AutomatedPrefixThread(modlist_name, install_dir, final_exe_path, downloads_dir)
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}")
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 = self.modlist_name_edit.text().strip()
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()
self._end_post_install_feedback(success=False)
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()
self._end_post_install_feedback(success=False)
def on_automated_prefix_progress(self, progress_msg):
"""Handle progress updates from automated prefix creation"""
self._safe_append_text(progress_msg)
self._handle_post_install_progress(progress_msg)

View File

@@ -0,0 +1,625 @@
"""Configuration phase workflow for InstallModlistScreen (Mixin)."""
from PySide6.QtWidgets import QMessageBox, QProgressDialog
from PySide6.QtCore import Qt, QThread, Signal
from PySide6.QtGui import QFont
from jackify.frontends.gui.services.message_service import MessageService
from jackify.frontends.gui.dialogs import SuccessDialog
from jackify.backend.handlers.validation_handler import ValidationHandler
from jackify.backend.models.modlist import ModlistContext
from pathlib import Path
import traceback
import os
import time
from .install_modlist_shortcut_dialog import InstallModlistShortcutDialogMixin
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 ConfigurationPhaseMixin(InstallModlistShortcutDialogMixin):
"""Mixin providing configuration phase workflow and dialog management for InstallModlistScreen."""
def on_configuration_progress(self, progress_msg):
"""Handle progress updates from modlist configuration"""
self._safe_append_text(progress_msg)
self._handle_post_install_progress(progress_msg)
def show_steam_restart_progress(self, message):
"""Show Steam restart progress dialog"""
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 _detect_game_type_from_mo2_ini(self, install_dir: str) -> str:
"""Detect game type by checking ModOrganizer.ini for loader executables."""
from pathlib import Path
import logging
logger = logging.getLogger(__name__)
mo2_ini = Path(install_dir) / "ModOrganizer.ini"
if not mo2_ini.exists():
return 'skyrim' # Fallback to most common
try:
content = mo2_ini.read_text(encoding='utf-8', errors='ignore').lower()
if 'skse64_loader.exe' in content or 'skyrim special edition' in content:
return 'skyrim'
elif 'f4se_loader.exe' in content or 'fallout 4' in content:
return 'fallout4'
elif 'nvse_loader.exe' in content or 'fallout new vegas' in content:
return 'falloutnv'
elif 'obse_loader.exe' in content or 'oblivion' in content:
return 'oblivion'
elif 'starfield' in content:
return 'starfield'
elif 'enderal' in content:
return 'enderal'
else:
return 'skyrim'
except Exception as e:
logger.warning(f"Error detecting game type from ModOrganizer.ini: {e}")
return 'skyrim'
def on_configuration_complete(self, success, message, modlist_name, enb_detected=False):
"""Handle configuration completion on main thread"""
try:
# Stop CPU tracking now that everything is complete
self.file_progress_list.stop_cpu_tracking()
# Re-enable controls now that installation/configuration is complete
self._enable_controls_after_operation()
# Don't end post-install feedback yet - may continue with VNV automation
# Will be called in _on_vnv_complete or after VNV check
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
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)
# Check for TTW eligibility before showing final success dialog
install_dir = self.install_dir_edit.text().strip()
if self._check_ttw_eligibility(modlist_name, self._current_game_type, install_dir):
# Offer TTW installation
reply = MessageService.question(
self,
"Install TTW?",
f"{modlist_name} requires Tale of Two Wastelands!\n\n"
"Would you like to install TTW now?\n\n"
"This will:\n"
"• Guide you through TTW installation\n"
"• Attempt to integrate TTW into your modlist automatically\n"
"• Configure load order if integration is supported\n\n"
"Note: Automatic integration works for some modlists (like Begin Again). "
"Other modlists may require manual TTW setup. "
"TTW installation can take a while.\n\n"
"You can also install TTW later from Additional Tasks & Tools.",
critical=False,
safety_level="medium"
)
if reply == QMessageBox.Yes:
# Navigate to TTW screen
self._initiate_ttw_workflow(modlist_name, install_dir)
return # Don't show success dialog yet, will show after TTW completes
# Check for VNV post-install automation after TTW check
vnv_automation_running = self._check_and_run_vnv_automation(modlist_name, install_dir)
if vnv_automation_running:
# Store success dialog params for later (after VNV automation completes)
self._pending_success_dialog_params = {
'modlist_name': modlist_name,
'time_taken': time_str,
'game_name': game_name,
'enb_detected': enb_detected
}
# Keep post-install feedback active during VNV automation
# Don't show success dialog yet - will be shown in _on_vnv_complete
return
# No VNV automation - end post-install feedback now
self._end_post_install_feedback(True)
# Clear Activity window before showing success dialog
self.file_progress_list.clear()
# Show normal success dialog
success_dialog = SuccessDialog(
modlist_name=modlist_name,
workflow_type="install",
time_taken=time_str,
game_name=game_name,
parent=self
)
success_dialog.show()
# Show ENB Proton dialog if ENB was detected (use stored detection result, no re-detection)
if enb_detected:
try:
from ..dialogs.enb_proton_dialog import ENBProtonDialog
enb_dialog = ENBProtonDialog(modlist_name=modlist_name, parent=self)
enb_dialog.exec() # Modal dialog - blocks until user clicks OK
except Exception as e:
# Non-blocking: if dialog fails, just log and continue
import logging
logger = logging.getLogger(__name__)
logger.warning(f"Failed to show ENB dialog: {e}")
elif hasattr(self, '_manual_steps_retry_count') and self._manual_steps_retry_count >= 3:
# Max retries reached - show failure message
self._end_post_install_feedback(False)
MessageService.critical(self, "Manual Steps Failed",
"Manual steps validation failed after multiple attempts.")
else:
# Configuration failed for other reasons
self._end_post_install_feedback(False)
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 = self.modlist_name_edit.text().strip() or "your modlist"
msg = (
f"<b>Manual Proton Setup Required for <span style='color:#3fd0ea'>{modlist_name}</span></b><br>"
"After Steam restarts, complete the following steps in Steam:<br>"
f"1. Locate the '<b>{modlist_name}</b>' entry in your Steam Library<br>"
"2. Right-click and select 'Properties'<br>"
"3. Switch to the 'Compatibility' tab<br>"
"4. Check the box labeled 'Force the use of a specific Steam Play compatibility tool'<br>"
"5. Select 'Proton - Experimental' from the dropdown menu<br>"
"6. Close the Properties window<br>"
f"7. Launch '<b>{modlist_name}</b>' from your Steam Library<br>"
"8. Wait for Mod Organizer 2 to fully open<br>"
"9. Once Mod Organizer has fully loaded, CLOSE IT completely and return here<br>"
"<br>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("\nManual 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 = self.modlist_name_edit.text().strip()
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...")
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
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:
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 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
# Capture parent's method for game type detection
detect_game_type_func = self._detect_game_type_from_mo2_ini
class ConfigThread(QThread):
progress_update = Signal(str)
configuration_complete = Signal(bool, str, str, bool)
error_occurred = Signal(str)
def __init__(self, context, is_steamdeck, detect_func):
super().__init__()
self.context = context
self.is_steamdeck = is_steamdeck
self.detect_game_type = detect_func
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
# Initialize backend service with passed Steam Deck detection
system_info = SystemInfo(is_steamdeck=self.is_steamdeck)
modlist_service = ModlistService(system_info)
# Detect game type from ModOrganizer.ini using captured function
detected_game_type = self.detect_game_type(self.context['path'])
# 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=detected_game_type,
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, enb_detected=False):
self.configuration_complete.emit(success, message, modlist_name, enb_detected)
def manual_steps_callback(modlist_name, retry_count):
# Should not reach here -- prefix creation already 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, detect_game_type_func)
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._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"""
# 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
# Capture parent's method for game type detection
detect_game_type_func = self._detect_game_type_from_mo2_ini
class ConfigThread(QThread):
progress_update = Signal(str)
configuration_complete = Signal(bool, str, str)
error_occurred = Signal(str)
def __init__(self, context, is_steamdeck, detect_func, parent=None):
super().__init__(parent)
self.context = context
self.is_steamdeck = is_steamdeck
self.detect_game_type = detect_func
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
# Initialize backend service with passed Steam Deck detection
system_info = SystemInfo(is_steamdeck=self.is_steamdeck)
modlist_service = ModlistService(system_info)
# Detect game type from ModOrganizer.ini using captured function
detected_game_type = self.detect_game_type(self.context['path'])
# 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=detected_game_type,
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):
# Should not reach here -- manual steps already complete
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:
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, detect_game_type_func, parent=self)

View File

@@ -0,0 +1,368 @@
"""Console output management for InstallModlistScreen (Mixin)."""
from PySide6.QtCore import Qt, QTimer
from PySide6.QtWidgets import QSizePolicy, QApplication
from PySide6.QtGui import QTextCursor
from jackify.frontends.gui.services.message_service import MessageService
import re
class ConsoleOutputMixin:
"""Mixin providing console output and scroll tracking for InstallModlistScreen."""
def _toggle_console_visibility(self, state):
"""R&D: Toggle console visibility only
When "Show Details" is checked:
- Show Console (below tabs)
- Expand window height
When "Show Details" is unchecked:
- Hide Console
- Collapse window height
Note: Activity and Process Monitor tabs are always available via tabs.
"""
is_checked = (state == Qt.Checked)
# Get main window reference (like TTW screen)
main_window = None
try:
app = QApplication.instance()
if app:
main_window = app.activeWindow()
# Try to find the actual main window (parent of stacked widget)
if self.stacked_widget and self.stacked_widget.parent():
main_window = self.stacked_widget.parent()
except Exception:
pass
# Save geometry on first expand (like TTW screen)
if is_checked and main_window and self._saved_geometry is None:
try:
self._saved_geometry = main_window.geometry()
self._saved_min_size = main_window.minimumSize()
except Exception:
pass
if is_checked:
# Keep upper section height consistent - don't change it
# Prevent buttons from being cut off
try:
if hasattr(self, 'upper_section_widget') and self.upper_section_widget is not None:
# Maintain consistent height - ALWAYS use the stored fixed height
# Never recalculate - use the exact same height calculated in showEvent
if hasattr(self, '_upper_section_fixed_height') and self._upper_section_fixed_height is not None:
self.upper_section_widget.setMaximumHeight(self._upper_section_fixed_height)
self.upper_section_widget.setMinimumHeight(self._upper_section_fixed_height) # Lock it
# If somehow not stored, it should have been set in showEvent - don't recalculate here
self.upper_section_widget.updateGeometry()
except Exception:
pass
# Show console
self.console.setVisible(True)
self.console.show()
self.console.setMinimumHeight(200)
self.console.setMaximumHeight(16777215) # Remove height limit
try:
self.console.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
# Set stretch on console in its layout to fill space
console_layout = self.console.parent().layout()
if console_layout:
console_layout.setStretchFactor(console_layout.indexOf(self.console), 1)
# Restore spacing when console is visible
console_layout.setSpacing(4)
except Exception:
pass
try:
# Set spacing in console_and_buttons_layout when console is visible
if hasattr(self, 'console_and_buttons_layout'):
self.console_and_buttons_layout.setSpacing(4) # Small gap between console and buttons
# Set stretch on console_and_buttons_widget to fill space when expanded
if hasattr(self, 'console_and_buttons_widget'):
self.main_overall_vbox.setStretchFactor(self.console_and_buttons_widget, 1)
# Allow expansion when console is visible - remove fixed height constraint
self.console_and_buttons_widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
# Clear fixed height by setting min/max (setFixedHeight sets both, so we override it)
self.console_and_buttons_widget.setMinimumHeight(0)
self.console_and_buttons_widget.setMaximumHeight(16777215)
self.console_and_buttons_widget.updateGeometry()
except Exception:
pass
# Notify parent to expand - let main window handle resizing
try:
self.resize_request.emit('expand')
except Exception:
pass
else:
# Keep upper section height consistent - use same constraint
# Prevent buttons from being cut off
try:
if hasattr(self, 'upper_section_widget') and self.upper_section_widget is not None:
# Use the same stored fixed height for consistency
# ALWAYS use the stored height - never recalculate to avoid drift
if hasattr(self, '_upper_section_fixed_height') and self._upper_section_fixed_height is not None:
self.upper_section_widget.setMaximumHeight(self._upper_section_fixed_height)
self.upper_section_widget.setMinimumHeight(self._upper_section_fixed_height) # Lock it
# If somehow not stored, it should have been set in showEvent - don't recalculate here
self.upper_section_widget.updateGeometry()
except Exception:
pass
# Hide console and ensure it takes zero space
self.console.setVisible(False)
self.console.setMinimumHeight(0)
self.console.setMaximumHeight(0)
# Use Ignored size policy so it doesn't participate in layout calculations
self.console.setSizePolicy(QSizePolicy.Ignored, QSizePolicy.Ignored)
try:
# Remove stretch from console_and_buttons_widget when collapsed
if hasattr(self, 'console_and_buttons_widget'):
self.main_overall_vbox.setStretchFactor(self.console_and_buttons_widget, 0)
# Set fixed height when console is hidden
self.console_and_buttons_widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
# Calculate height based on buttons only (console takes 0 space)
button_height = 0
if hasattr(self, 'console_and_buttons_layout'):
for i in range(self.console_and_buttons_layout.count()):
item = self.console_and_buttons_layout.itemAt(i)
if item and item.widget() and item.widget() != self.console:
button_height = max(button_height, item.widget().sizeHint().height())
self.console_and_buttons_widget.setFixedHeight(button_height + 8) # Add small padding
# Clear spacing when console is hidden
if hasattr(self, 'console_and_buttons_layout'):
self.console_and_buttons_layout.setSpacing(0)
except Exception:
pass
# Notify parent to collapse - let main window handle resizing
try:
self.resize_request.emit('collapse')
except Exception:
pass
def on_installation_output(self, message):
"""Handle regular output from installation thread"""
# 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
# CRITICAL: Detect token/auth errors and ALWAYS show them (even when not in debug mode)
msg_lower = message.lower()
token_error_keywords = [
'token has expired',
'token expired',
'oauth token',
'authentication failed',
'unauthorized',
'401',
'403',
'refresh token',
'authorization failed',
'nexus.*premium.*required',
'premium.*required',
]
is_token_error = any(keyword in msg_lower for keyword in token_error_keywords)
if is_token_error:
# CRITICAL ERROR - always show, even if console is hidden
if not hasattr(self, '_token_error_notified'):
self._token_error_notified = True
# Show error dialog immediately
MessageService.error(
self,
"Authentication Error",
(
"Nexus Mods authentication has failed. This may be due to:\n\n"
"• OAuth token expired and refresh failed\n"
"• Nexus Premium required for this modlist\n"
"• Network connectivity issues\n\n"
"Please check the console output (Show Details) for more information.\n"
"You may need to re-authorize in Settings."
),
safety_level="high"
)
# Also show in console
guidance = (
"\n[Jackify] CRITICAL: Authentication/Token Error Detected!\n"
"[Jackify] This may cause downloads to stop. Check the error message above.\n"
"[Jackify] If OAuth token expired, go to Settings and re-authorize.\n"
)
self._safe_append_text(guidance)
# Force console to be visible so user can see the error
if not self.show_details_checkbox.isChecked():
self.show_details_checkbox.setChecked(True)
# Detect known engine bugs and provide helpful guidance
if 'destination array was not long enough' in msg_lower or \
('argumentexception' in msg_lower and 'downloadmachineurl' in msg_lower):
# Known bug in jackify-engine 0.4.0 during .wabbajack download
if not hasattr(self, '_array_error_notified'):
self._array_error_notified = True
guidance = (
"\n[Jackify] Engine Error Detected: Buffer size issue during .wabbajack download.\n"
"[Jackify] This is a known bug in jackify-engine 0.4.0.\n"
"[Jackify] Workaround: Delete any partial .wabbajack files in your downloads directory and try again.\n"
)
self._safe_append_text(guidance)
# R&D: Always write output to console buffer so it's available when user toggles Show Details
# The console visibility is controlled by the checkbox, not whether we write to it
self._safe_append_text(message)
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
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 _safe_append_text(self, text):
"""
Append text with professional auto-scroll behavior.
Handles carriage return (\\r) for in-place updates and newline (\\n) for new lines.
"""
# 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
# Check if this is a carriage return update (should replace last line)
if '\r' in text and '\n' not in text:
# Carriage return - replace last line
self._replace_last_console_line(text.replace('\r', ''))
return
# Handle mixed \r\n or just \n - normal append
# Clean up any remaining \r characters
clean_text = text.replace('\r', '')
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(clean_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 _is_similar_progress_line(self, text):
"""Check if this line is a similar progress update to the last line"""
if not hasattr(self, '_last_console_line') or not self._last_console_line:
return False
# Don't deduplicate if either line contains important markers
important_markers = [
'complete',
'failed',
'error',
'warning',
'starting',
'===',
'---',
'SUCCESS',
'FAILED',
]
text_lower = text.lower()
last_lower = self._last_console_line.lower()
for marker in important_markers:
if marker.lower() in text_lower or marker.lower() in last_lower:
return False
# Patterns that indicate this is a progress line that should replace the previous
# These are the status lines that update rapidly with changing numbers
progress_patterns = [
'Installing files',
'Extracting files',
'Downloading:',
'Building BSAs',
'Validating',
]
# Check if both current and last line contain the same progress pattern
# AND the lines are actually different (not exact duplicates)
for pattern in progress_patterns:
if pattern in text and pattern in self._last_console_line:
# Only deduplicate if the numbers/progress changed (not exact duplicate)
if text.strip() != self._last_console_line.strip():
return True
# Special case: texture conversion status is embedded in Installing files lines
# Match lines like "Installing files X/Y (A/B) - Converting textures: N/M"
if '- Converting textures:' in text and '- Converting textures:' in self._last_console_line:
if text.strip() != self._last_console_line.strip():
return True
return False
def _replace_last_console_line(self, text):
"""Replace the last line in the console with new text"""
scrollbar = self.console.verticalScrollBar()
was_at_bottom = (scrollbar.value() >= scrollbar.maximum() - 1)
# Move cursor to end and select the last line
cursor = self.console.textCursor()
cursor.movePosition(QTextCursor.End)
cursor.select(QTextCursor.LineUnderCursor)
cursor.removeSelectedText()
cursor.deletePreviousChar() # Remove the newline
# Insert the new text
self.console.append(text)
# Track this line
self._last_console_line = text
# Restore scroll position
if was_at_bottom or not self._user_manually_scrolled:
scrollbar.setValue(scrollbar.maximum())
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

View File

@@ -0,0 +1,327 @@
"""
Helper dialog classes for InstallModlistScreen
"""
from PySide6.QtWidgets import (QDialog, QVBoxLayout, QHBoxLayout, QListWidget,
QListWidgetItem, QPushButton, QLineEdit, QTableWidget, QTableWidgetItem,
QHeaderView, QCheckBox, QAbstractItemView, QLabel, QWidget, QSizePolicy)
from PySide6.QtCore import Qt, QThread, Signal
from PySide6.QtGui import QColor, QBrush
import logging
import os
logger = logging.getLogger(__name__)
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 SelectionDialog(QDialog):
def __init__(self, title, items, parent=None, show_search=True, placeholder_text="Search modlists...", show_legend=False):
super().__init__(parent)
self.setWindowTitle(title)
self.setModal(True)
self.setMinimumWidth(600)
self.setMinimumHeight(300)
layout = QVBoxLayout(self)
self.show_search = show_search
if self.show_search:
# Search box with clear button
search_layout = QHBoxLayout()
self.search_box = QLineEdit()
self.search_box.setPlaceholderText(placeholder_text)
# Make placeholder text lighter
self.search_box.setStyleSheet("QLineEdit { color: #ccc; } QLineEdit:placeholder { color: #aaa; }")
self.clear_btn = QPushButton("Clear")
self.clear_btn.setFixedWidth(50)
search_layout.addWidget(self.search_box)
search_layout.addWidget(self.clear_btn)
layout.addLayout(search_layout)
if show_legend:
# Use table for modlist selection with proper columns
self.table_widget = QTableWidget()
self.table_widget.setColumnCount(4)
self.table_widget.setHorizontalHeaderLabels(["Modlist Name", "Download", "Install", "Total"])
# Configure table appearance
self.table_widget.setSelectionBehavior(QTableWidget.SelectRows)
self.table_widget.setSelectionMode(QTableWidget.SingleSelection)
self.table_widget.verticalHeader().setVisible(False)
self.table_widget.setAlternatingRowColors(True)
# Set column widths
header = self.table_widget.horizontalHeader()
header.setSectionResizeMode(0, QHeaderView.Stretch) # Modlist name takes remaining space
header.setSectionResizeMode(1, QHeaderView.ResizeToContents) # Download size
header.setSectionResizeMode(2, QHeaderView.ResizeToContents) # Install size
header.setSectionResizeMode(3, QHeaderView.ResizeToContents) # Total size
self._all_items = list(items)
self._populate_table(self._all_items)
layout.addWidget(self.table_widget)
# Apply initial NSFW filter since checkbox starts unchecked
self._filter_nsfw(False)
else:
# Use list for non-modlist dialogs (backward compatibility)
self.list_widget = QListWidget()
self.list_widget.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
self.list_widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
self._all_items = list(items)
self._populate_list(self._all_items)
layout.addWidget(self.list_widget)
# Add interactive legend bar only for modlist selection dialogs
if show_legend:
legend_layout = QHBoxLayout()
legend_layout.setContentsMargins(10, 5, 10, 5)
# Status indicator explanation (far left)
status_label = QLabel('<small><b>[DOWN]</b> Unavailable</small>')
status_label.setStyleSheet("color: #bbb;")
legend_layout.addWidget(status_label)
# Spacer after DOWN legend
legend_layout.addSpacing(15)
# No need for size format explanation since we have table headers now
# Just add some spacing
# Main spacer to push NSFW checkbox to far right
legend_layout.addStretch()
# NSFW filter checkbox (far right)
self.nsfw_checkbox = QCheckBox("Show NSFW")
self.nsfw_checkbox.setStyleSheet("color: #bbb; font-size: 11px;")
self.nsfw_checkbox.setChecked(False) # Default to hiding NSFW content
self.nsfw_checkbox.toggled.connect(self._filter_nsfw)
legend_layout.addWidget(self.nsfw_checkbox)
# Legend container
legend_widget = QWidget()
legend_widget.setLayout(legend_layout)
legend_widget.setStyleSheet("background-color: #333; border-radius: 3px; margin: 2px;")
layout.addWidget(legend_widget)
self.selected_item = None
# Connect appropriate signals based on widget type
if show_legend:
self.table_widget.itemClicked.connect(self.on_table_item_clicked)
if self.show_search:
self.search_box.textChanged.connect(self._filter_table)
self.clear_btn.clicked.connect(self._clear_search)
self.search_box.returnPressed.connect(self._focus_table)
self.search_box.installEventFilter(self)
else:
self.list_widget.itemClicked.connect(self.on_item_clicked)
if self.show_search:
self.search_box.textChanged.connect(self._filter_list)
self.clear_btn.clicked.connect(self._clear_search)
self.search_box.returnPressed.connect(self._focus_list)
self.search_box.installEventFilter(self)
def _populate_list(self, items):
self.list_widget.clear()
for item in items:
# Create list item - custom delegate handles all styling
QListWidgetItem(item, self.list_widget)
def _populate_table(self, items):
self.table_widget.setRowCount(len(items))
for row, item in enumerate(items):
# Parse the item string to extract components
# Format: "[STATUS] Modlist Name Download|Install|Total"
# Extract status indicators
status_down = '[DOWN]' in item
status_nsfw = '[NSFW]' in item
# Clean the item string
clean_item = item.replace('[DOWN]', '').replace('[NSFW]', '').strip()
# Split into name and sizes
# The format should be "Name Download|Install|Total"
parts = clean_item.rsplit(' ', 1) # Split from right to separate name from sizes
if len(parts) == 2:
name = parts[0].strip()
sizes = parts[1].strip()
size_parts = sizes.split('|')
if len(size_parts) == 3:
download_size, install_size, total_size = [s.strip() for s in size_parts]
else:
# Fallback if format is unexpected
download_size = install_size = total_size = sizes
else:
# Fallback if format is unexpected
name = clean_item
download_size = install_size = total_size = ""
# Create table items
name_item = QTableWidgetItem(name)
download_item = QTableWidgetItem(download_size)
install_item = QTableWidgetItem(install_size)
total_item = QTableWidgetItem(total_size)
# Apply styling
if status_down:
# Gray out and strikethrough for DOWN items
for item_widget in [name_item, download_item, install_item, total_item]:
item_widget.setForeground(QColor('#999999'))
font = item_widget.font()
font.setStrikeOut(True)
item_widget.setFont(font)
elif status_nsfw:
# Red text for NSFW items - but only the name, sizes stay white
name_item.setForeground(QColor('#ff4444'))
for item_widget in [download_item, install_item, total_item]:
item_widget.setForeground(QColor('#ffffff'))
else:
# White text for normal items
for item_widget in [name_item, download_item, install_item, total_item]:
item_widget.setForeground(QColor('#ffffff'))
# Add status indicators to name if present
if status_nsfw:
name_item.setText(f"[NSFW] {name}")
if status_down:
# For DOWN items, we want [DOWN] normal and the name strikethrough
# Since we can't easily mix fonts in a single QTableWidgetItem,
# we'll style the whole item but the visual effect will be clear
name_item.setText(f"[DOWN] {name_item.text()}")
# Right-align size columns
download_item.setTextAlignment(Qt.AlignRight | Qt.AlignVCenter)
install_item.setTextAlignment(Qt.AlignRight | Qt.AlignVCenter)
total_item.setTextAlignment(Qt.AlignRight | Qt.AlignVCenter)
# Add items to table
self.table_widget.setItem(row, 0, name_item)
self.table_widget.setItem(row, 1, download_item)
self.table_widget.setItem(row, 2, install_item)
self.table_widget.setItem(row, 3, total_item)
# Store original item text as data for filtering
name_item.setData(Qt.UserRole, item)
def _filter_list(self, text):
text = text.strip().lower()
if not text:
filtered = self._all_items
else:
filtered = [item for item in self._all_items if text in item.lower()]
self._populate_list(filtered)
if filtered:
self.list_widget.setCurrentRow(0)
def _clear_search(self):
self.search_box.clear()
self.search_box.setFocus()
def _focus_list(self):
self.list_widget.setFocus()
self.list_widget.setCurrentRow(0)
def _focus_table(self):
self.table_widget.setFocus()
self.table_widget.setCurrentCell(0, 0)
def _filter_table(self, text):
text = text.strip().lower()
if not text:
# Show all rows
for row in range(self.table_widget.rowCount()):
self.table_widget.setRowHidden(row, False)
else:
# Filter rows based on modlist name
for row in range(self.table_widget.rowCount()):
name_item = self.table_widget.item(row, 0)
if name_item:
# Search in the modlist name
match = text in name_item.text().lower()
self.table_widget.setRowHidden(row, not match)
def on_table_item_clicked(self, item):
# Get the original item text from the name column
row = item.row()
name_item = self.table_widget.item(row, 0)
if name_item:
original_item = name_item.data(Qt.UserRole)
self.selected_item = original_item
self.accept()
def _filter_nsfw(self, show_nsfw):
"""Filter NSFW modlists based on checkbox state"""
if show_nsfw:
# Show all items
filtered_items = self._all_items
else:
# Hide NSFW items
filtered_items = [item for item in self._all_items if '[NSFW]' not in item]
# Use appropriate populate method based on widget type
if hasattr(self, 'table_widget'):
self._populate_table(filtered_items)
# Apply search filter if there's search text
if hasattr(self, 'search_box') and self.search_box.text().strip():
self._filter_table(self.search_box.text())
else:
self._populate_list(filtered_items)
# Apply search filter if there's search text
if hasattr(self, 'search_box') and self.search_box.text().strip():
self._filter_list(self.search_box.text())
def eventFilter(self, obj, event):
if self.show_search and obj == self.search_box and event.type() == event.Type.KeyPress:
if event.key() in (Qt.Key.Key_Down, Qt.Key.Key_Tab):
# Focus appropriate widget
if hasattr(self, 'table_widget'):
self._focus_table()
else:
self._focus_list()
return True
return super().eventFilter(obj, event)
def on_item_clicked(self, item):
self.selected_item = item.text()
self.accept()

View File

@@ -0,0 +1,257 @@
"""
InstallerThread: QThread subclass for running jackify-engine install.
Signals are defined at class level (required for Qt signal/slot).
"""
import os
import re
from PySide6.QtCore import QThread, Signal
class InstallerThread(QThread):
"""Runs jackify-engine install in a background thread. Signals at class level."""
output_received = Signal(str)
progress_received = Signal(str)
progress_updated = Signal(object)
installation_finished = Signal(bool, str)
premium_required_detected = Signal(str)
def __init__(self, modlist, install_dir, downloads_dir, api_key, modlist_name,
install_mode='online', progress_state_manager=None, auth_service=None, oauth_info=None):
super().__init__()
self.modlist = modlist
self.install_dir = install_dir
self.downloads_dir = downloads_dir
self.api_key = api_key
self.modlist_name = modlist_name
self.install_mode = install_mode
self.cancelled = False
self.process_manager = None
self.progress_state_manager = progress_state_manager
self.auth_service = auth_service
self.oauth_info = oauth_info
self._premium_signal_sent = False
self._engine_output_buffer = []
self._buffer_size = 10
def cancel(self):
self.cancelled = True
if self.process_manager:
self.process_manager.cancel()
def run(self):
from .install_modlist import debug_print
try:
from jackify.backend.core.modlist_operations import get_jackify_engine_path
engine_path = get_jackify_engine_path()
if not os.path.exists(engine_path):
error_msg = f"Engine not found at: {engine_path}"
debug_print(f"DEBUG: {error_msg}")
self.installation_finished.emit(False, error_msg)
return
if not os.access(engine_path, os.X_OK):
error_msg = f"Engine is not executable: {engine_path}"
debug_print(f"DEBUG: {error_msg}")
self.installation_finished.emit(False, error_msg)
return
debug_print(f"DEBUG: Using engine at: {engine_path}")
if self.install_mode == 'file':
cmd = [engine_path, "install", "--show-file-progress", "-w", self.modlist, "-o", self.install_dir, "-d", self.downloads_dir]
else:
cmd = [engine_path, "install", "--show-file-progress", "-m", self.modlist, "-o", self.install_dir, "-d", self.downloads_dir]
from jackify.backend.handlers.config_handler import ConfigHandler
config_handler = ConfigHandler()
debug_mode = config_handler.get('debug_mode', False)
if debug_mode:
cmd.append('--debug')
debug_print("DEBUG: Added --debug flag to jackify-engine command")
debug_print(f"DEBUG: FULL Engine command: {' '.join(cmd)}")
debug_print(f"DEBUG: modlist value being passed: '{self.modlist}'")
from jackify.backend.handlers.subprocess_utils import get_clean_subprocess_env
env_vars = {'NEXUS_API_KEY': self.api_key}
if self.oauth_info:
env_vars['NEXUS_OAUTH_INFO'] = self.oauth_info
from jackify.backend.services.nexus_oauth_service import NexusOAuthService
env_vars['NEXUS_OAUTH_CLIENT_ID'] = NexusOAuthService.CLIENT_ID
env = get_clean_subprocess_env(env_vars)
from jackify.backend.handlers.subprocess_utils import ProcessManager
self.process_manager = ProcessManager(cmd, env=env, text=False)
ansi_escape = re.compile(rb'\x1b\[[0-9;?]*[ -/]*[@-~]')
buffer = b''
last_was_blank = False
while True:
if self.cancelled:
self.cancel()
break
char = self.process_manager.read_stdout_char()
if not char:
break
buffer += char
while b'\n' in buffer or b'\r' in buffer:
if b'\r' in buffer and (buffer.index(b'\r') < buffer.index(b'\n') if b'\n' in buffer else True):
line, buffer = buffer.split(b'\r', 1)
line = ansi_escape.sub(b'', line)
decoded = line.decode('utf-8', errors='replace')
config_handler = ConfigHandler()
debug_mode = config_handler.get('debug_mode', False)
from jackify.backend.utils.nexus_premium_detector import is_non_premium_indicator
is_premium_error, matched_pattern = is_non_premium_indicator(decoded)
if not self._premium_signal_sent and is_premium_error:
self._premium_signal_sent = True
import logging
logger = logging.getLogger(__name__)
logger.warning("=" * 80)
logger.warning("PREMIUM DETECTION TRIGGERED - DIAGNOSTIC DUMP (Issue #111)")
logger.warning("=" * 80)
logger.warning(f"Matched pattern: '{matched_pattern}'")
logger.warning(f"Triggering line: '{decoded.strip()}'")
logger.warning("AUTHENTICATION DIAGNOSTICS:")
logger.warning(f" Auth value present: {'YES' if self.api_key else 'NO'}")
if self.api_key:
logger.warning(f" Auth value length: {len(self.api_key)} chars")
if len(self.api_key) >= 8:
logger.warning(f" Auth value (partial): {self.api_key[:4]}...{self.api_key[-4:]}")
auth_method = self.auth_service.get_auth_method() if self.auth_service else None
logger.warning(f" Auth method: {auth_method or 'UNKNOWN'}")
if auth_method == 'oauth' and self.auth_service:
token_handler = self.auth_service.token_handler
token_info = token_handler.get_token_info()
logger.warning(" OAuth Token Status:")
logger.warning(f" Has token file: {token_info.get('has_token', False)}")
logger.warning(f" Has refresh token: {token_info.get('has_refresh_token', False)}")
if 'expires_in_minutes' in token_info:
logger.warning(f" Expires in: {token_info['expires_in_minutes']:.1f} minutes")
if 'refresh_token_age_days' in token_info:
logger.warning(f" Refresh token age: {token_info['refresh_token_age_days']:.1f} days")
if token_info.get('error'):
logger.warning(f" Error: {token_info['error']}")
logger.warning("Previous engine output (last 10 lines):")
for i, buffered_line in enumerate(self._engine_output_buffer, 1):
logger.warning(f" -{len(self._engine_output_buffer) - i + 1}: {buffered_line}")
logger.warning("If user HAS Premium, this is a FALSE POSITIVE")
logger.warning("=" * 80)
self.premium_required_detected.emit(decoded.strip() or "Nexus Premium required")
self._engine_output_buffer.append(decoded.strip())
if len(self._engine_output_buffer) > self._buffer_size:
self._engine_output_buffer.pop(0)
if self.progress_state_manager:
updated = self.progress_state_manager.process_line(decoded)
if updated:
progress_state = self.progress_state_manager.get_state()
if progress_state.active_files and debug_mode:
debug_print(f"DEBUG: Parser detected {len(progress_state.active_files)} active files from line: {decoded[:80]}")
self.progress_updated.emit(progress_state)
if '[FILE_PROGRESS]' in decoded:
parts = decoded.split('[FILE_PROGRESS]', 1)
if parts[0].strip():
self.progress_received.emit(parts[0].rstrip())
else:
self.progress_received.emit(decoded + '\r')
elif b'\n' in buffer:
line, buffer = buffer.split(b'\n', 1)
line = ansi_escape.sub(b'', line)
decoded = line.decode('utf-8', errors='replace')
from jackify.backend.utils.nexus_premium_detector import is_non_premium_indicator
is_premium_error, matched_pattern = is_non_premium_indicator(decoded)
if not self._premium_signal_sent and is_premium_error:
self._premium_signal_sent = True
import logging
logger = logging.getLogger(__name__)
logger.warning("=" * 80)
logger.warning("PREMIUM DETECTION TRIGGERED - DIAGNOSTIC DUMP (Issue #111)")
logger.warning("=" * 80)
logger.warning(f"Matched pattern: '{matched_pattern}'")
logger.warning(f"Triggering line: '{decoded.strip()}'")
logger.warning("AUTHENTICATION DIAGNOSTICS:")
logger.warning(f" Auth value present: {'YES' if self.api_key else 'NO'}")
if self.api_key:
logger.warning(f" Auth value length: {len(self.api_key)} chars")
if len(self.api_key) >= 8:
logger.warning(f" Auth value (partial): {self.api_key[:4]}...{self.api_key[-4:]}")
auth_method = self.auth_service.get_auth_method() if self.auth_service else None
logger.warning(f" Auth method: {auth_method or 'UNKNOWN'}")
if auth_method == 'oauth' and self.auth_service:
token_handler = self.auth_service.token_handler
token_info = token_handler.get_token_info()
logger.warning(" OAuth Token Status:")
logger.warning(f" Has token file: {token_info.get('has_token', False)}")
logger.warning(f" Has refresh token: {token_info.get('has_refresh_token', False)}")
if 'expires_in_minutes' in token_info:
logger.warning(f" Expires in: {token_info['expires_in_minutes']:.1f} minutes")
if 'refresh_token_age_days' in token_info:
logger.warning(f" Refresh token age: {token_info['refresh_token_age_days']:.1f} days")
if token_info.get('error'):
logger.warning(f" Error: {token_info['error']}")
logger.warning("Previous engine output (last 10 lines):")
for i, buffered_line in enumerate(self._engine_output_buffer, 1):
logger.warning(f" -{len(self._engine_output_buffer) - i + 1}: {buffered_line}")
logger.warning("If user HAS Premium, this is a FALSE POSITIVE")
logger.warning("=" * 80)
self.premium_required_detected.emit(decoded.strip() or "Nexus Premium required")
self._engine_output_buffer.append(decoded.strip())
if len(self._engine_output_buffer) > self._buffer_size:
self._engine_output_buffer.pop(0)
config_handler = ConfigHandler()
debug_mode = config_handler.get('debug_mode', False)
if self.progress_state_manager:
updated = self.progress_state_manager.process_line(decoded)
if updated:
progress_state = self.progress_state_manager.get_state()
if progress_state.active_files and debug_mode:
debug_print(f"DEBUG: Parser detected {len(progress_state.active_files)} active files from line: {decoded[:80]}")
self.progress_updated.emit(progress_state)
if '[FILE_PROGRESS]' in decoded:
parts = decoded.split('[FILE_PROGRESS]', 1)
if parts[0].strip():
self.output_received.emit(parts[0].rstrip())
last_was_blank = False
continue
if decoded.strip() == '':
if not last_was_blank:
self.output_received.emit('\n')
last_was_blank = True
else:
self.output_received.emit(decoded + '\n')
last_was_blank = False
if buffer:
line = ansi_escape.sub(b'', buffer)
decoded = line.decode('utf-8', errors='replace')
if '[FILE_PROGRESS]' in decoded:
parts = decoded.split('[FILE_PROGRESS]', 1)
if parts[0].strip():
self.output_received.emit(parts[0].rstrip())
else:
self.output_received.emit(decoded)
returncode = self.process_manager.wait()
if self.process_manager.proc and self.process_manager.proc.stdout:
try:
remaining = self.process_manager.proc.stdout.read()
if remaining:
decoded_remaining = remaining.decode('utf-8', errors='replace')
if decoded_remaining.strip():
debug_print(f"DEBUG: Remaining output after process exit: {decoded_remaining[:500]}")
if '[FILE_PROGRESS]' in decoded_remaining:
parts = decoded_remaining.split('[FILE_PROGRESS]', 1)
if parts[0].strip():
self.output_received.emit(parts[0].rstrip())
else:
self.output_received.emit(decoded_remaining)
except Exception as e:
debug_print(f"DEBUG: Error reading remaining output: {e}")
if self.cancelled:
self.installation_finished.emit(False, "Installation cancelled by user")
elif returncode == 0:
self.installation_finished.emit(True, "Installation completed successfully")
else:
error_msg = f"Installation failed (exit code {returncode})"
debug_print(f"DEBUG: Engine exited with code {returncode}")
if self.process_manager.proc:
debug_print("DEBUG: Process stderr/stdout may contain error details")
self.installation_finished.emit(False, error_msg)
except Exception as e:
self.installation_finished.emit(False, f"Installation error: {str(e)}")
finally:
if self.cancelled and self.process_manager:
self.process_manager.cancel()

View File

@@ -0,0 +1,260 @@
"""Nexus authentication methods for InstallModlistScreen (Mixin)."""
from PySide6.QtWidgets import QDialog, QVBoxLayout, QHBoxLayout, QLabel, QLineEdit, QPushButton, QMessageBox, QProgressDialog, QApplication
from PySide6.QtCore import Qt, QTimer, QThread, Signal
from PySide6.QtGui import QDesktopServices, QGuiApplication
import logging
import webbrowser
logger = logging.getLogger(__name__)
class NexusAuthMixin:
"""Mixin providing Nexus authentication methods for InstallModlistScreen."""
def _update_nexus_status(self):
"""Update the Nexus login status display"""
authenticated, method, username = self.auth_service.get_auth_status()
if authenticated and method == 'oauth':
# OAuth authorised
status_text = "Authorised"
if username:
status_text += f" ({username})"
self.nexus_status.setText(status_text)
self.nexus_status.setStyleSheet("color: #3fd0ea;")
self.nexus_login_btn.setText("Revoke")
self.nexus_login_btn.setVisible(True)
elif authenticated and method == 'api_key':
# API Key in use (fallback - configured in Settings)
self.nexus_status.setText("API Key")
self.nexus_status.setStyleSheet("color: #FFA726;")
self.nexus_login_btn.setText("Authorise")
self.nexus_login_btn.setVisible(True)
else:
# Not authorised
self.nexus_status.setText("Not Authorised")
self.nexus_status.setStyleSheet("color: #f44336;")
self.nexus_login_btn.setText("Authorise")
self.nexus_login_btn.setVisible(True)
def _show_copyable_url_dialog(self, url: str):
"""Show a dialog with a copyable URL"""
dialog = QDialog(self)
dialog.setWindowTitle("Manual Browser Open Required")
dialog.setModal(True)
dialog.setMinimumWidth(600)
layout = QVBoxLayout()
layout.setSpacing(15)
# Explanation label
info_label = QLabel(
"Could not open browser automatically.\n\n"
"Please copy the URL below and paste it into your browser:"
)
info_label.setWordWrap(True)
info_label.setStyleSheet("color: #ccc; font-size: 12px;")
layout.addWidget(info_label)
# URL input (read-only but selectable)
url_input = QLineEdit()
url_input.setText(url)
url_input.setReadOnly(True)
url_input.selectAll() # Pre-select text for easy copying
url_input.setStyleSheet("""
QLineEdit {
background-color: #1a1a1a;
color: #3fd0ea;
border: 1px solid #444;
border-radius: 4px;
padding: 8px;
font-family: monospace;
font-size: 11px;
}
""")
layout.addWidget(url_input)
# Button row
button_layout = QHBoxLayout()
button_layout.addStretch()
# Copy button
copy_btn = QPushButton("Copy URL")
copy_btn.setStyleSheet("""
QPushButton {
background-color: #3fd0ea;
color: #000;
border: none;
border-radius: 4px;
padding: 8px 20px;
font-weight: bold;
}
QPushButton:hover {
background-color: #5fdfff;
}
""")
def copy_to_clipboard():
clipboard = QApplication.clipboard()
clipboard.setText(url)
copy_btn.setText("Copied!")
copy_btn.setEnabled(False)
copy_btn.clicked.connect(copy_to_clipboard)
button_layout.addWidget(copy_btn)
# Close button
close_btn = QPushButton("Close")
close_btn.setStyleSheet("""
QPushButton {
background-color: #444;
color: #ccc;
border: none;
border-radius: 4px;
padding: 8px 20px;
}
QPushButton:hover {
background-color: #555;
}
""")
close_btn.clicked.connect(dialog.accept)
button_layout.addWidget(close_btn)
layout.addLayout(button_layout)
dialog.setLayout(layout)
dialog.exec()
def _handle_nexus_login_click(self):
"""Handle Nexus login button click"""
from jackify.frontends.gui.services.message_service import MessageService
authenticated, method, _ = self.auth_service.get_auth_status()
if authenticated and method == 'oauth':
# OAuth is active - offer to revoke
reply = MessageService.question(self, "Revoke", "Revoke OAuth authorisation?", safety_level="low")
if reply == QMessageBox.Yes:
self.auth_service.revoke_oauth()
self._update_nexus_status()
else:
# Not authorised or using API key - offer to authorise with OAuth
reply = MessageService.question(self, "Authorise with Nexus",
"Your browser will open for Nexus authorisation.\n\n"
"Note: Your browser may ask permission to open 'xdg-open'\n"
"or Jackify's protocol handler - please click 'Open' or 'Allow'.\n\n"
"Please log in and authorise Jackify when prompted.\n\n"
"Continue?", safety_level="low")
if reply != QMessageBox.Yes:
return
# Create progress dialog
progress = QProgressDialog(
"Waiting for authorisation...\n\nPlease check your browser.",
"Cancel",
0, 0,
self
)
progress.setWindowTitle("Nexus OAuth")
progress.setWindowModality(Qt.WindowModal)
progress.setMinimumDuration(0)
progress.setMinimumWidth(400)
# Track cancellation
oauth_cancelled = [False]
def on_cancel():
oauth_cancelled[0] = True
progress.canceled.connect(on_cancel)
progress.show()
QApplication.processEvents()
# Create OAuth thread to prevent GUI freeze
class OAuthThread(QThread):
finished_signal = Signal(bool)
message_signal = Signal(str)
manual_url_signal = Signal(str) # Signal when browser fails to open
def __init__(self, auth_service, parent=None):
super().__init__(parent)
self.auth_service = auth_service
def run(self):
def show_message(msg):
# Check if this is a "browser failed" message with URL
if "Could not open browser" in msg and "Please open this URL manually:" in msg:
# Extract URL from message
url_start = msg.find("Please open this URL manually:") + len("Please open this URL manually:")
url = msg[url_start:].strip()
self.manual_url_signal.emit(url)
else:
self.message_signal.emit(msg)
success = self.auth_service.authorize_oauth(show_browser_message_callback=show_message)
self.finished_signal.emit(success)
oauth_thread = OAuthThread(self.auth_service, self)
# Connect message signal to update progress dialog
def update_progress_message(msg):
if not oauth_cancelled[0]:
progress.setLabelText(f"Waiting for authorisation...\n\n{msg}")
QApplication.processEvents()
# Connect manual URL signal to show copyable dialog
def show_manual_url_dialog(url):
if not oauth_cancelled[0]:
progress.hide() # Hide progress dialog temporarily
self._show_copyable_url_dialog(url)
progress.show()
oauth_thread.message_signal.connect(update_progress_message)
oauth_thread.manual_url_signal.connect(show_manual_url_dialog)
# Wait for thread completion
oauth_success = [False]
def on_oauth_finished(success):
oauth_success[0] = success
oauth_thread.finished_signal.connect(on_oauth_finished)
oauth_thread.start()
# Wait for thread to finish (non-blocking event loop)
while oauth_thread.isRunning():
QApplication.processEvents()
oauth_thread.wait(100) # Check every 100ms
if oauth_cancelled[0]:
# User cancelled - thread will still complete but we ignore result
oauth_thread.wait(2000)
if oauth_thread.isRunning():
oauth_thread.terminate()
break
progress.close()
QApplication.processEvents()
self._update_nexus_status()
self._enable_controls_after_operation()
# Check success first - if OAuth succeeded, ignore cancellation flag
# (progress dialog close can trigger cancel handler even on success)
if oauth_success[0]:
_, _, username = self.auth_service.get_auth_status()
if username:
msg = f"OAuth authorisation successful!<br><br>Authorised as: {username}"
else:
msg = "OAuth authorisation successful!"
MessageService.information(self, "Success", msg, safety_level="low")
elif oauth_cancelled[0]:
MessageService.information(self, "Cancelled", "OAuth authorisation cancelled.", safety_level="low")
else:
MessageService.warning(
self,
"Authorisation Failed",
"OAuth authorisation failed.\n\n"
"If your browser showed a blank page (e.g. Firefox on Steam Deck),\n"
"try again and use 'Paste callback URL' to paste the URL from the address bar.\n\n"
"If you see 'redirect URI mismatch', the OAuth redirect URI must be configured by Nexus.\n\n"
"You can configure an API key in Settings as a fallback.",
safety_level="medium"
)

View File

@@ -0,0 +1,228 @@
"""
InstallModlistOutputMixin: handlers for InstallerThread signals.
on_installation_output, on_installation_progress, on_premium_required_detected, on_progress_updated.
"""
import time
from jackify.shared.progress_models import InstallationPhase, OperationType, FileProgress
class InstallModlistOutputMixin:
"""Mixin providing signal handlers for InstallerThread output/progress/premium/progress_updated."""
def on_installation_output(self, message):
"""Handle regular output from installation thread."""
if message.strip().startswith('[Jackify]'):
self._write_to_log_file(message)
return
msg_lower = message.lower()
token_error_keywords = [
'token has expired', 'token expired', 'oauth token', 'authentication failed',
'unauthorized', '401', '403', 'refresh token', 'authorization failed',
'nexus.*premium.*required', 'premium.*required',
]
is_token_error = any(keyword in msg_lower for keyword in token_error_keywords)
if is_token_error:
if not hasattr(self, '_token_error_notified'):
self._token_error_notified = True
from jackify.frontends.gui.services.message_service import MessageService
MessageService.error(
self,
"Authentication Error",
(
"Nexus Mods authentication has failed. This may be due to:\n\n"
"• OAuth token expired and refresh failed\n"
"• Nexus Premium required for this modlist\n"
"• Network connectivity issues\n\n"
"Please check the console output (Show Details) for more information.\n"
"You may need to re-authorize in Settings."
),
safety_level="high"
)
guidance = (
"\n[Jackify] CRITICAL: Authentication/Token Error Detected!\n"
"[Jackify] This may cause downloads to stop. Check the error message above.\n"
"[Jackify] If OAuth token expired, go to Settings and re-authorize.\n"
)
self._safe_append_text(guidance)
if not self.show_details_checkbox.isChecked():
self.show_details_checkbox.setChecked(True)
if 'destination array was not long enough' in msg_lower or \
('argumentexception' in msg_lower and 'downloadmachineurl' in msg_lower):
if not hasattr(self, '_array_error_notified'):
self._array_error_notified = True
guidance = (
"\n[Jackify] Engine Error Detected: Buffer size issue during .wabbajack download.\n"
"[Jackify] This is a known bug in jackify-engine 0.4.0.\n"
"[Jackify] Workaround: Delete any partial .wabbajack files in your downloads directory and try again.\n"
)
self._safe_append_text(guidance)
self._safe_append_text(message)
def on_installation_progress(self, progress_message):
"""Handle progress messages from installation thread (main output path)."""
self._safe_append_text(progress_message)
def on_premium_required_detected(self, engine_line: str):
"""Handle detection of Nexus Premium requirement."""
if self._premium_notice_shown:
return
self._premium_notice_shown = True
self._premium_failure_active = True
user_message = (
"Nexus Mods rejected the automated download because this account is not Premium. "
"Jackify currently requires a Nexus Premium membership for automated installs, "
"and non-premium support is still planned."
)
if engine_line:
self._safe_append_text(f"[Jackify] Engine message: {engine_line}")
self._safe_append_text("[Jackify] Jackify detected that Nexus Premium is required for this modlist install.")
from jackify.frontends.gui.services.message_service import MessageService
MessageService.critical(
self,
"Nexus Premium Required",
f"{user_message}\n\nDetected engine output:\n{engine_line or 'Buy Nexus Premium to automate this process.'}",
safety_level="medium"
)
if hasattr(self, 'install_thread') and self.install_thread:
self.install_thread.cancel()
def on_progress_updated(self, progress_state):
"""Handle structured progress updates from parser."""
if progress_state.bsa_building_total > 0 and progress_state.bsa_building_current > 0:
bsa_percent = (progress_state.bsa_building_current / progress_state.bsa_building_total) * 100.0
progress_state.overall_percent = min(99.0, bsa_percent)
if progress_state.phase == InstallationPhase.DOWNLOAD:
speed_display = progress_state.get_overall_speed_display()
is_stalled = not speed_display or speed_display == "0.0B/s" or \
(speed_display and any(x in speed_display.lower() for x in ['0.0mb/s', '0.0kb/s', '0b/s']))
has_active_downloads = any(
f.operation == OperationType.DOWNLOAD and not f.is_complete
for f in progress_state.active_files
)
if is_stalled and has_active_downloads:
if self._stalled_download_start_time is None:
self._stalled_download_start_time = time.time()
else:
stalled_duration = time.time() - self._stalled_download_start_time
if stalled_duration > 120 and not self._stalled_download_notified:
self._stalled_download_notified = True
from jackify.frontends.gui.services.message_service import MessageService
MessageService.warning(
self,
"Download Stalled",
(
"Downloads have been stalled (0.0MB/s) for over 2 minutes.\n\n"
"Possible causes:\n"
"• OAuth token expired and refresh failed\n"
"• Network connectivity issues\n"
"• Nexus Mods server issues\n\n"
"Please check the console output (Show Details) for error messages.\n"
"If authentication failed, you may need to re-authorize in Settings."
),
safety_level="low"
)
if not self.show_details_checkbox.isChecked():
self.show_details_checkbox.setChecked(True)
self._safe_append_text(
"\n[Jackify] WARNING: Downloads have stalled (0.0MB/s for 2+ minutes)\n"
"[Jackify] This may indicate an authentication or network issue.\n"
"[Jackify] Check the console above for error messages.\n"
)
else:
self._stalled_download_start_time = None
self._stalled_download_notified = False
self.progress_indicator.update_progress(progress_state)
phase_label = progress_state.get_phase_label()
is_installation_phase = (
progress_state.phase == InstallationPhase.INSTALL or
(progress_state.phase_name and 'install' in progress_state.phase_name.lower())
)
is_extraction_phase = (
progress_state.phase == InstallationPhase.EXTRACT or
(progress_state.phase_name and 'extract' in progress_state.phase_name.lower())
)
is_bsa_building = False
if progress_state.phase_name:
phase_lower = progress_state.phase_name.lower()
if 'bsa' in phase_lower or ('building' in phase_lower and progress_state.phase == InstallationPhase.INSTALL):
is_bsa_building = True
if not is_bsa_building and progress_state.message:
msg_lower = progress_state.message.lower()
if ('building' in msg_lower or 'writing' in msg_lower or 'verifying' in msg_lower) and '.bsa' in msg_lower:
is_bsa_building = True
if not is_bsa_building and progress_state.active_files:
bsa_files = [f for f in progress_state.active_files if f.filename.lower().endswith('.bsa')]
if bsa_files and progress_state.phase == InstallationPhase.INSTALL:
is_bsa_building = True
if not is_bsa_building:
display_text = getattr(progress_state, 'display_text', None) or ''
if 'bsa' in display_text.lower() and progress_state.phase == InstallationPhase.INSTALL:
is_bsa_building = True
now_mono = time.monotonic()
if is_bsa_building:
self._bsa_hold_deadline = now_mono + 1.5
elif now_mono < self._bsa_hold_deadline:
is_bsa_building = True
else:
self._bsa_hold_deadline = now_mono
if is_installation_phase:
current_step = progress_state.phase_step
display_items = []
if current_step > 0 or progress_state.phase_max_steps > 0:
install_line = FileProgress(
filename=f"Installing Files: {current_step}/{progress_state.phase_max_steps}",
operation=OperationType.INSTALL, percent=0.0, speed=-1.0
)
install_line._no_progress_bar = True
display_items.append(install_line)
for f in progress_state.active_files:
if f.operation == OperationType.INSTALL:
if f.filename.lower().endswith('.bsa') or f.filename.lower().endswith('.ba2'):
display_filename = f"BSA: {f.filename} ({progress_state.bsa_building_current}/{progress_state.bsa_building_total})" if progress_state.bsa_building_total > 0 else f"BSA: {f.filename}"
display_file = FileProgress(filename=display_filename, operation=f.operation, percent=f.percent, current_size=0, total_size=0, speed=-1.0)
display_items.append(display_file)
if len(display_items) >= 4:
break
elif f.filename.lower().endswith(('.dds', '.png', '.tga', '.bmp')):
display_filename = f"Converting Texture: {f.filename} ({progress_state.texture_conversion_current}/{progress_state.texture_conversion_total})" if progress_state.texture_conversion_total > 0 else f"Converting Texture: {f.filename}"
display_file = FileProgress(filename=display_filename, operation=f.operation, percent=f.percent, current_size=0, total_size=0, speed=-1.0)
display_items.append(display_file)
if len(display_items) >= 4:
break
if display_items:
self.file_progress_list.update_files(display_items, current_phase="Installing", summary_info=None)
return
if is_extraction_phase:
current_step = progress_state.phase_step
summary_info = {'current_step': current_step, 'max_steps': progress_state.phase_max_steps}
phase_display_name = phase_label or "Extracting"
self.file_progress_list.update_files([], current_phase=phase_display_name, summary_info=summary_info)
return
if progress_state.active_files:
try:
self.file_progress_list.update_files(progress_state.active_files, current_phase=phase_label, summary_info=None)
except RuntimeError as e:
if "already deleted" in str(e):
if getattr(self, 'debug', False):
from .install_modlist import debug_print
debug_print(f"DEBUG: Ignoring widget deletion error: {e}")
return
raise
except Exception as e:
if getattr(self, 'debug', False):
from .install_modlist import debug_print
debug_print(f"DEBUG: Error updating file progress list: {e}")
import logging
logging.getLogger(__name__).error(f"Error updating file progress list: {e}", exc_info=True)
else:
try:
self.file_progress_list.update_files([], current_phase=phase_label)
except RuntimeError as e:
if "already deleted" in str(e):
return
raise
except Exception as e:
import logging
logging.getLogger(__name__).error(f"Error updating file progress list: {e}", exc_info=True)

View File

@@ -0,0 +1,470 @@
"""Post-install UI feedback management for InstallModlistScreen (Mixin)."""
import re
import time
from typing import Optional
from PySide6.QtCore import QTimer
from jackify.shared.progress_models import InstallationProgress, InstallationPhase, FileProgress, OperationType
class PostInstallFeedbackMixin:
"""Mixin providing post-install progress tracking and UI feedback for InstallModlistScreen."""
def _build_post_install_sequence(self):
"""
Define the ordered steps for post-install (Jackify-managed) operations.
These steps represent Jackify's automated Steam integration and configuration workflow
that runs AFTER the jackify-engine completes modlist installation. Progress is shown as
"X/Y" in the progress banner and Activity window.
The post-install steps are:
1. Preparing Steam integration - Initial setup before creating Steam shortcut
2. Creating Steam shortcut - Add modlist to Steam library with proper Proton settings
3. Restarting Steam - Restart Steam to make shortcut visible and create AppID
4. Creating Proton prefix - Launch temporary batch file to initialize Proton prefix
5. Verifying Steam setup - Confirm prefix exists and Proton version is correct
6. Steam integration complete - Steam setup finished successfully
7. Installing Wine components - Install vcrun, dotnet, and other Wine dependencies
8. Applying registry files - Import .reg files for game configuration
9. Installing .NET fixes - Apply .NET framework workarounds if needed
10. Enabling dotfiles - Make hidden config files visible in file manager
11. Setting permissions - Ensure modlist files have correct permissions
12. Backing up configuration - Create backup of ModOrganizer.ini
13. Finalising Jackify configuration - All post-install steps complete
"""
return [
{
'id': 'prepare',
'label': "Preparing Steam integration",
'keywords': [
"starting automated steam setup",
"starting configuration phase",
"starting configuration"
],
},
{
'id': 'steam_shortcut',
'label': "Creating Steam shortcut",
'keywords': [
"creating steam shortcut",
"steam shortcut created successfully"
],
},
{
'id': 'steam_restart',
'label': "Restarting Steam",
'keywords': [
"restarting steam",
"steam restarted successfully"
],
},
{
'id': 'proton_prefix',
'label': "Creating Proton prefix",
'keywords': [
"creating proton prefix",
"proton prefix created successfully",
"temporary batch file launched",
"verifying prefix creation"
],
},
{
'id': 'steam_verify',
'label': "Verifying Steam setup",
'keywords': [
"verifying setup",
"verifying prefix",
"setup verification completed",
"detecting actual appid",
"steam configuration complete"
],
},
{
'id': 'steam_complete',
'label': "Steam integration complete",
'keywords': [
"steam integration complete",
"steam integration",
"steam configuration complete!"
],
},
{
'id': 'wine_components',
'label': "Installing Wine components",
'keywords': [
"installing wine components",
"wine components",
"vcrun",
"dotnet",
"running winetricks",
],
},
{
'id': 'registry_files',
'label': "Applying registry files",
'keywords': [
"applying registry",
"importing registry",
".reg file",
"registry files",
],
},
{
'id': 'dotnet_fixes',
'label': "Installing .NET fixes",
'keywords': [
"dotnet fix",
".net fix",
"installing .net",
],
},
{
'id': 'enable_dotfiles',
'label': "Enabling dotfiles",
'keywords': [
"enabling dotfiles",
"dotfiles",
"hidden files",
],
},
{
'id': 'set_permissions',
'label': "Setting permissions",
'keywords': [
"setting permissions",
"chmod",
"permissions",
],
},
{
'id': 'backup_config',
'label': "Backing up configuration",
'keywords': [
"backing up",
"modorganizer.ini",
"backup",
],
},
{
'id': 'vnv_root_mods',
'label': "VNV: Copying root mods",
'keywords': [
"step 1/3: copying root mods",
"copying root mods to game directory",
"root mods:",
],
},
{
'id': 'vnv_4gb_patch',
'label': "VNV: Applying 4GB patch",
'keywords': [
"step 2/3: downloading and running 4gb patcher",
"downloading fnv4gb",
"downloading:",
"fetching file list",
"running 4gb patcher",
"4gb patcher:",
],
},
{
'id': 'vnv_bsa_decompress',
'label': "VNV: Decompressing BSA files",
'keywords': [
"step 3/3: downloading and running bsa decompressor",
"downloading:",
"fetching file list",
"running bsa decompressor",
"decompressing bsa files:",
"bsa decompression:",
],
},
{
'id': 'config_finalize',
'label': "Finalising Jackify configuration",
'keywords': [
"configuration completed successfully",
"configuration complete",
"manual steps validation failed",
"configuration failed",
"vnv post-install completed successfully"
],
},
]
def _begin_post_install_feedback(self):
"""Reset trackers and surface post-install progress in collapsed mode."""
self._post_install_active = True
self._post_install_current_step = 0
self._post_install_last_label = "Preparing Steam integration"
total = max(1, self._post_install_total_steps)
self._update_post_install_ui(self._post_install_last_label, 0, total)
def _handle_post_install_progress(self, message: str):
"""Translate backend progress messages into collapsed-mode feedback."""
if not self._post_install_active or not message:
return
text = message.strip()
if not text:
return
normalized = text.lower()
total = max(1, self._post_install_total_steps)
matched = False
matched_step = None
# Check for wine components completion first
if "wine components verified" in normalized or "wine components installed" in normalized:
self._stop_component_install_pulse()
for idx, step in enumerate(self._post_install_sequence, start=1):
if any(keyword in normalized for keyword in step['keywords']):
matched = True
matched_step = idx
# Always update to the highest step we've seen (don't go backwards)
if idx >= self._post_install_current_step:
# Stop pulser when moving away from wine_components step
if self._post_install_current_step > 0:
prev_step = self._post_install_sequence[self._post_install_current_step - 1]
if prev_step['id'] == 'wine_components' and step['id'] != 'wine_components':
self._stop_component_install_pulse()
self._post_install_current_step = idx
self._post_install_last_label = step['label']
# Wine components: pulser manages Activity window directly.
# Must remove summary widget so pulser items display immediately
# (otherwise the 0.5s hold blocks update_files from adding items).
if step['id'] == 'wine_components':
self.file_progress_list.clear_summary()
self.progress_indicator.set_status(
"Installing Wine components...",
int((self._post_install_current_step / total) * 100)
)
if not hasattr(self, '_component_install_timer') or not self._component_install_timer:
self._start_component_install_pulse()
# Always check for component list updates (may come in later messages)
comp_list = self._parse_wine_components_message(text)
if comp_list:
self._start_component_install_pulse_with_components(comp_list)
break
# Keep Activity window in sync with progress banner
# If we're already in wine_components step, check for component list updates
# Skip _update_post_install_ui() for wine_components - pulser manages Activity window directly
if step['id'] == 'wine_components':
comp_list = self._parse_wine_components_message(text)
if comp_list:
self._start_component_install_pulse_with_components(comp_list)
# Don't call _update_post_install_ui() - it would clear the component items
break
# CRITICAL: If pulser is active (wine components still installing), don't update progress banner
# Keep it on "Installing Wine components..." until pulser stops
if getattr(self, '_component_install_timer', None) and self._component_install_timer.isActive():
# Find wine_components step and keep banner on that
wine_step = None
wine_step_idx = None
for wine_idx, wine_s in enumerate(self._post_install_sequence, start=1):
if wine_s['id'] == 'wine_components':
wine_step = wine_s
wine_step_idx = wine_idx
break
if wine_step:
# Update step counter internally but keep banner on wine components
# Filter out winetricks/protontricks internal messages from detail
filtered_detail = text
if text and any(keyword in text.lower() for keyword in ['perl:', 'wine:', 'winetricks:', 'protontricks:']):
filtered_detail = None
self._update_post_install_ui(
wine_step['label'],
wine_step_idx,
total,
detail=filtered_detail
)
break
self._update_post_install_ui(step['label'], self._post_install_current_step, total, detail=text)
break
# If no match but we have a current step, update with that step (not a new one)
# Skip when pulser is active -- it manages Activity window directly
if not matched and self._post_install_current_step > 0:
# CRITICAL: If pulser is active, we're still installing wine components
# Keep progress banner on "Installing Wine components..." regardless of step counter
if getattr(self, '_component_install_timer', None) and self._component_install_timer.isActive():
# Find wine_components step in sequence
wine_step = None
wine_step_idx = None
for idx, step in enumerate(self._post_install_sequence, start=1):
if step['id'] == 'wine_components':
wine_step = step
wine_step_idx = idx
break
if wine_step:
# Always check for component list updates, even if message doesn't match keywords
comp_list = self._parse_wine_components_message(text)
if comp_list:
self._start_component_install_pulse_with_components(comp_list)
# Update progress banner to show wine components installation (pulser manages Activity window directly)
# Filter out winetricks/protontricks internal messages from detail
filtered_detail = text
if text and any(keyword in text.lower() for keyword in ['perl:', 'wine:', 'winetricks:', 'protontricks:']):
filtered_detail = None
total = len(self._post_install_sequence)
self._update_post_install_ui(
wine_step['label'],
wine_step_idx,
total,
detail=filtered_detail
)
return
# Check if we're in wine_components step (by step counter)
current_step = self._post_install_sequence[self._post_install_current_step - 1] if self._post_install_current_step > 0 else None
if current_step and current_step['id'] == 'wine_components':
# Always check for component list updates, even if message doesn't match keywords
comp_list = self._parse_wine_components_message(text)
if comp_list:
self._start_component_install_pulse_with_components(comp_list)
# Update progress banner to keep it current (pulser manages Activity window directly)
# Filter out winetricks/protontricks internal messages from detail
filtered_detail = text
if text and any(keyword in text.lower() for keyword in ['perl:', 'wine:', 'winetricks:', 'protontricks:']):
filtered_detail = None
total = len(self._post_install_sequence)
self._update_post_install_ui(
current_step['label'],
self._post_install_current_step,
total,
detail=filtered_detail
)
return
if not getattr(self, '_component_install_timer', None):
label = self._post_install_last_label or "Post-installation"
# Filter out winetricks/protontricks internal messages from detail
filtered_detail = text
if text and any(keyword in text.lower() for keyword in ['perl:', 'wine:', 'winetricks:', 'protontricks:']):
filtered_detail = None
self._update_post_install_ui(label, self._post_install_current_step, total, detail=filtered_detail)
def _strip_timestamp_prefix(self, text: str) -> str:
"""Remove timestamp prefix like '[00:03:15]' from text."""
# Match timestamps like [00:03:15], [01:23:45], etc.
timestamp_pattern = r'^\[\d{2}:\d{2}:\d{2}\]\s*'
return re.sub(timestamp_pattern, '', text)
def _update_post_install_ui(self, label: str, step: int, total: int, detail: Optional[str] = None):
"""Update progress indicator + activity summary for post-install steps."""
# Use the label as the primary display, but include step info in Activity window
display_label = label
if detail:
# Remove timestamp prefix from detail messages
clean_detail = self._strip_timestamp_prefix(detail.strip())
if clean_detail:
# Filter out winetricks/protontricks internal messages (perl, wine paths, etc.)
# These are implementation details, not user-facing status
if any(keyword in clean_detail.lower() for keyword in ['perl:', 'wine:', '/usr/bin/', 'winetricks:', 'protontricks:']):
# Use original label, ignore internal tool messages
pass
elif clean_detail.lower().startswith(label.lower()):
display_label = clean_detail
else:
display_label = clean_detail
total = max(1, total)
step_clamped = max(0, min(step, total))
overall_percent = (step_clamped / total) * 100.0
# CRITICAL: Ensure both displays use the SAME step counter
# Progress banner uses phase_step/phase_max_steps from progress_state
progress_state = InstallationProgress(
phase=InstallationPhase.FINALIZE,
phase_name=display_label, # This will show in progress banner
phase_step=step_clamped, # This creates [step/total] in display_text
phase_max_steps=total,
overall_percent=overall_percent
)
self.progress_indicator.update_progress(progress_state)
# Activity window uses summary_info with the SAME step counter
summary_info = {
'current_step': step_clamped, # Must match phase_step above
'max_steps': total, # Must match phase_max_steps above
}
# Use the same label for consistency
self.file_progress_list.update_files([], current_phase=display_label, summary_info=summary_info)
def _end_post_install_feedback(self, success: bool):
"""Mark the end of post-install feedback."""
if not self._post_install_active:
return
self._stop_component_install_pulse()
total = max(1, self._post_install_total_steps)
final_step = total if success else max(0, self._post_install_current_step)
label = "Post-installation complete" if success else "Post-installation stopped"
self._update_post_install_ui(label, final_step, total)
self._post_install_active = False
self._post_install_last_label = label
def _parse_wine_components_message(self, text: str):
"""Extract list of wine component names from backend status message, or None."""
if "installing wine components:" not in text.lower() and "installing wine components via protontricks:" not in text.lower():
return None
match = re.search(r"installing wine components(?:\s+via protontricks)?:\s*(.+)", text, re.IGNORECASE)
if not match:
return None
raw = match.group(1).strip()
if not raw:
return None
return [c.strip() for c in raw.split(",") if c.strip()]
def _start_component_install_pulse(self):
"""Start pulsing Activity item for Wine component installation."""
self.file_progress_list.update_or_add_item("__wine_components__", "Installing Wine components...", 0.0)
if not getattr(self, '_component_install_timer', None):
self._component_install_timer = QTimer(self)
self._component_install_timer.timeout.connect(self._component_install_heartbeat)
self._component_install_timer.start(100)
self._component_install_start_time = time.time()
def _start_component_install_pulse_with_components(self, components: list):
"""Replace single item with one Activity entry per component, each with pulsing progress."""
self._component_install_list = components
progresses = [
FileProgress(
filename=f"Wine component: {comp}",
operation=OperationType.UNKNOWN,
percent=0.0,
)
for comp in components
]
self.file_progress_list.update_files(progresses, current_phase=None)
def _component_install_heartbeat(self):
"""Heartbeat to keep component install item(s) pulsing."""
if not hasattr(self, '_component_install_start_time') or not self._component_install_start_time:
return
if hasattr(self, '_component_install_list') and self._component_install_list:
progresses = [
FileProgress(
filename=f"Wine component: {comp}",
operation=OperationType.UNKNOWN,
percent=0.0,
)
for comp in self._component_install_list
]
self.file_progress_list.update_files(progresses, current_phase=None)
else:
self.file_progress_list.update_or_add_item("__wine_components__", "Installing Wine components...", 0.0)
def _stop_component_install_pulse(self):
"""Stop the component install pulsing timer."""
if hasattr(self, '_component_install_timer') and self._component_install_timer:
self._component_install_timer.stop()
self._component_install_timer = None
if hasattr(self, '_component_install_list'):
del self._component_install_list

View File

@@ -0,0 +1,413 @@
"""Progress and installation event handlers for InstallModlistScreen (Mixin)."""
from PySide6.QtCore import QProcess
from PySide6.QtWidgets import QMessageBox
from PySide6.QtGui import QTextCursor
from jackify.frontends.gui.services.message_service import MessageService
from jackify.shared.progress_models import InstallationPhase, OperationType, InstallationProgress, FileProgress
from jackify.backend.utils.nexus_premium_detector import is_non_premium_indicator
import time
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 ProgressHandlersMixin:
"""Mixin providing progress tracking and installation event handlers for InstallModlistScreen."""
def on_installation_progress(self, progress_message):
"""
Handle progress messages from installation thread.
NOTE: This is called for MOST engine output, not just progress lines!
The name is misleading - it's actually the main output path.
"""
# Always write output to console buffer (same as on_installation_output)
self._safe_append_text(progress_message)
def on_premium_required_detected(self, engine_line: str):
"""Handle detection of Nexus Premium requirement."""
if self._premium_notice_shown:
return
self._premium_notice_shown = True
self._premium_failure_active = True
user_message = (
"Nexus Mods rejected the automated download because this account is not Premium. "
"Jackify currently requires a Nexus Premium membership for automated installs, "
"and non-premium support is still planned."
)
if engine_line:
self._safe_append_text(f"[Jackify] Engine message: {engine_line}")
self._safe_append_text("[Jackify] Jackify detected that Nexus Premium is required for this modlist install.")
MessageService.critical(
self,
"Nexus Premium Required",
f"{user_message}\n\nDetected engine output:\n{engine_line or 'Buy Nexus Premium to automate this process.'}",
safety_level="medium"
)
if hasattr(self, 'install_thread') and self.install_thread:
self.install_thread.cancel()
def on_progress_updated(self, progress_state):
"""R&D: Handle structured progress updates from parser"""
# Calculate proper overall progress during BSA building
# During BSA building, file installation is at 100% but BSAs are still being built
# Override overall_percent to show BSA building progress instead
if progress_state.bsa_building_total > 0 and progress_state.bsa_building_current > 0:
bsa_percent = (progress_state.bsa_building_current / progress_state.bsa_building_total) * 100.0
progress_state.overall_percent = min(99.0, bsa_percent) # Cap at 99% until fully complete
# CRITICAL: Detect stalled downloads (0.0MB/s for extended period)
# Catch silent token refresh failures or network issues
# IMPORTANT: Only check during DOWNLOAD phase, not during VALIDATE phase
# Validation checks existing files and shows 0.0MB/s, which is expected behavior
if progress_state.phase == InstallationPhase.DOWNLOAD:
speed_display = progress_state.get_overall_speed_display()
# Check if speed is 0 or very low (< 0.1MB/s) for more than 2 minutes
# Only trigger if we're actually in download phase (not validation)
is_stalled = not speed_display or speed_display == "0.0B/s" or \
(speed_display and any(x in speed_display.lower() for x in ['0.0mb/s', '0.0kb/s', '0b/s']))
# Additional check: Only consider it stalled if we have active download files
# If no files are being downloaded, it might just be between downloads
has_active_downloads = any(
f.operation == OperationType.DOWNLOAD and not f.is_complete
for f in progress_state.active_files
)
if is_stalled and has_active_downloads:
if self._stalled_download_start_time is None:
self._stalled_download_start_time = time.time()
else:
stalled_duration = time.time() - self._stalled_download_start_time
# Warn after 2 minutes of stalled downloads
if stalled_duration > 120 and not self._stalled_download_notified:
self._stalled_download_notified = True
MessageService.warning(
self,
"Download Stalled",
(
"Downloads have been stalled (0.0MB/s) for over 2 minutes.\n\n"
"Possible causes:\n"
"• OAuth token expired and refresh failed\n"
"• Network connectivity issues\n"
"• Nexus Mods server issues\n\n"
"Please check the console output (Show Details) for error messages.\n"
"If authentication failed, you may need to re-authorize in Settings."
),
safety_level="low"
)
# Force console to be visible
if not self.show_details_checkbox.isChecked():
self.show_details_checkbox.setChecked(True)
# Add warning to console
self._safe_append_text(
"\n[Jackify] WARNING: Downloads have stalled (0.0MB/s for 2+ minutes)\n"
"[Jackify] This may indicate an authentication or network issue.\n"
"[Jackify] Check the console above for error messages.\n"
)
else:
# Downloads are active - reset stall timer
self._stalled_download_start_time = None
self._stalled_download_notified = False
# Update progress indicator widget
self.progress_indicator.update_progress(progress_state)
# Only show file progress list if console is not visible (mutually exclusive)
console_visible = self.show_details_checkbox.isChecked()
# Determine phase display name up front (short/stable label)
phase_label = progress_state.get_phase_label()
# During installation or extraction phase, show summary counter instead of individual files
# Avoid cluttering UI with completed files
is_installation_phase = (
progress_state.phase == InstallationPhase.INSTALL or
(progress_state.phase_name and 'install' in progress_state.phase_name.lower())
)
is_extraction_phase = (
progress_state.phase == InstallationPhase.EXTRACT or
(progress_state.phase_name and 'extract' in progress_state.phase_name.lower())
)
# Detect BSA building phase - check multiple indicators
is_bsa_building = False
# Check phase name for BSA indicators
if progress_state.phase_name:
phase_lower = progress_state.phase_name.lower()
if 'bsa' in phase_lower or ('building' in phase_lower and progress_state.phase == InstallationPhase.INSTALL):
is_bsa_building = True
# Check message/status text for BSA building indicators
if not is_bsa_building and progress_state.message:
msg_lower = progress_state.message.lower()
if ('building' in msg_lower or 'writing' in msg_lower or 'verifying' in msg_lower) and '.bsa' in msg_lower:
is_bsa_building = True
# Check if we have BSA files being processed (even if they're at 100%, they indicate BSA phase)
if not is_bsa_building and progress_state.active_files:
bsa_files = [f for f in progress_state.active_files if f.filename.lower().endswith('.bsa')]
if len(bsa_files) > 0:
# If we have any BSA files and we're in INSTALL phase, likely BSA building
if progress_state.phase == InstallationPhase.INSTALL:
is_bsa_building = True
# Also check display text for BSA mentions (fallback)
if not is_bsa_building:
display_lower = progress_state.display_text.lower()
if 'bsa' in display_lower and progress_state.phase == InstallationPhase.INSTALL:
is_bsa_building = True
now_mono = time.monotonic()
if is_bsa_building:
self._bsa_hold_deadline = now_mono + 1.5
elif now_mono < self._bsa_hold_deadline:
is_bsa_building = True
else:
self._bsa_hold_deadline = now_mono
if is_installation_phase:
# During installation, we may have BSA building AND file installation happening
# Show both: install summary + any active BSA files
# Render loop handles smooth updates - just set target state
current_step = progress_state.phase_step
display_items = []
# Line 1: Always show "Installing Files: X/Y" at the top (no progress bar, no size)
if current_step > 0 or progress_state.phase_max_steps > 0:
install_line = FileProgress(
filename=f"Installing Files: {current_step}/{progress_state.phase_max_steps}",
operation=OperationType.INSTALL,
percent=0.0,
speed=-1.0
)
install_line._no_progress_bar = True # Flag to hide progress bar
display_items.append(install_line)
# Lines 2+: Show converting textures and BSA files
# Extract and categorize active files
for f in progress_state.active_files:
if f.operation == OperationType.INSTALL:
if f.filename.lower().endswith('.bsa') or f.filename.lower().endswith('.ba2'):
# BSA: filename.bsa (42/89) - Use state-level BSA counter
if progress_state.bsa_building_total > 0:
display_filename = f"BSA: {f.filename} ({progress_state.bsa_building_current}/{progress_state.bsa_building_total})"
else:
display_filename = f"BSA: {f.filename}"
display_file = FileProgress(
filename=display_filename,
operation=f.operation,
percent=f.percent,
current_size=0, # Don't show size
total_size=0,
speed=-1.0 # No speed
)
display_items.append(display_file)
if len(display_items) >= 4: # Max 1 install line + 3 operations
break
elif f.filename.lower().endswith(('.dds', '.png', '.tga', '.bmp')):
# Converting Texture: filename.dds (234/1078)
# Use state-level texture counter (more reliable than file-level)
if progress_state.texture_conversion_total > 0:
display_filename = f"Converting Texture: {f.filename} ({progress_state.texture_conversion_current}/{progress_state.texture_conversion_total})"
else:
# No texture counter available, just show filename
display_filename = f"Converting Texture: {f.filename}"
display_file = FileProgress(
filename=display_filename,
operation=f.operation,
percent=f.percent,
current_size=0, # Don't show size
total_size=0,
speed=-1.0 # No speed
)
display_items.append(display_file)
if len(display_items) >= 4: # Max 1 install line + 3 operations
break
# Update target state (render loop handles smooth display)
# Explicitly pass None for summary_info to clear any stale summary data
if display_items:
self.file_progress_list.update_files(display_items, current_phase="Installing", summary_info=None)
return
elif is_extraction_phase:
# Show summary info for Extracting phase (step count)
# Render loop handles smooth updates - just set target state
# Explicitly pass empty list for file_progresses to clear any stale file list
current_step = progress_state.phase_step
summary_info = {
'current_step': current_step,
'max_steps': progress_state.phase_max_steps,
}
phase_display_name = phase_label or "Extracting"
self.file_progress_list.update_files([], current_phase=phase_display_name, summary_info=summary_info)
return
elif progress_state.active_files:
if self.debug:
debug_print(f"DEBUG: Updating file progress list with {len(progress_state.active_files)} files")
for fp in progress_state.active_files:
debug_print(f"DEBUG: - {fp.filename}: {fp.percent:.1f}% ({fp.operation.value})")
# Pass phase label to update header (e.g., "[Activity - Downloading]")
# Explicitly clear summary_info when showing file list
try:
self.file_progress_list.update_files(progress_state.active_files, current_phase=phase_label, summary_info=None)
except RuntimeError as e:
# Widget was deleted - ignore to prevent coredump
if "already deleted" in str(e):
if self.debug:
debug_print(f"DEBUG: Ignoring widget deletion error: {e}")
return
raise
except Exception as e:
# Catch any other exceptions to prevent coredump
if self.debug:
debug_print(f"DEBUG: Error updating file progress list: {e}")
import logging
logging.getLogger(__name__).error(f"Error updating file progress list: {e}", exc_info=True)
else:
# Show empty state so widget stays visible even when no files are active
try:
self.file_progress_list.update_files([], current_phase=phase_label)
except RuntimeError as e:
# Widget was deleted - ignore to prevent coredump
if "already deleted" in str(e):
return
raise
except Exception as e:
# Catch any other exceptions to prevent coredump
import logging
logging.getLogger(__name__).error(f"Error updating file progress list: {e}", exc_info=True)
def on_installation_finished(self, success, message):
"""Handle installation completion"""
debug_print(f"DEBUG: on_installation_finished called with success={success}, message={message}")
# R&D: Clear all progress displays when installation completes
self.progress_state_manager.reset()
# Clear file list but keep CPU tracking running for configuration phase
self.file_progress_list.list_widget.clear()
self.file_progress_list._file_items.clear()
self.file_progress_list._summary_widget = None
self.file_progress_list._transition_label = None
self.file_progress_list._last_phase = None
if success:
# Update progress indicator with completion
final_state = InstallationProgress(
phase=InstallationPhase.FINALIZE,
phase_name="Installation Complete",
overall_percent=100.0
)
self.progress_indicator.update_progress(final_state)
if self.show_details_checkbox.isChecked():
self._safe_append_text(f"\nSuccess: {message}")
self.process_finished(0, QProcess.NormalExit) # Simulate successful completion
else:
# Reset to initial state on failure
self.progress_indicator.reset()
if self._premium_failure_active:
message = "Installation stopped because Nexus Premium is required for automated downloads."
if self.show_details_checkbox.isChecked():
self._safe_append_text(f"\nError: {message}")
self.process_finished(1, QProcess.CrashExit) # Simulate error
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:
# Check if this was an unsupported game
game_type = getattr(self, '_current_game_type', None)
game_name = getattr(self, '_current_game_name', None)
if game_type and not self.wabbajack_parser.is_supported_game(game_type):
# Show success message for unsupported games without post-install configuration
MessageService.information(
self, "Modlist Install Complete!",
f"Modlist installation completed successfully!\n\n"
f"Note: Post-install configuration was skipped for unsupported game type: {game_name or game_type}\n\n"
f"You will need to manually configure Steam shortcuts and other post-install steps."
)
self._safe_append_text(f"\nModlist installation completed successfully.")
self._safe_append_text(f"\nWarning: Post-install configuration skipped for unsupported game: {game_name or game_type}")
else:
# Check if auto-restart is enabled
auto_restart_enabled = hasattr(self, 'auto_restart_checkbox') and self.auto_restart_checkbox.isChecked()
if auto_restart_enabled:
# Auto-accept Steam restart - proceed without dialog
self._safe_append_text("\nAuto-accepting Steam restart (unattended mode enabled)")
reply = QMessageBox.Yes # Simulate user clicking Yes
else:
# Show the normal install complete dialog for supported games
reply = MessageService.question(
self, "Modlist Install Complete!",
"Modlist install complete!\n\nWould you like to add this modlist to Steam and configure it now? Steam will restart, closing any game you have open!",
critical=False # Non-critical, won't steal focus
)
if reply == QMessageBox.Yes:
# --- Create Steam shortcut BEFORE restarting Steam ---
# Proceed directly to automated prefix creation
self.start_automated_prefix_workflow()
else:
# User selected "No" - show completion message and keep GUI open
self._safe_append_text("\nModlist installation completed successfully!")
self._safe_append_text("Note: You can manually configure Steam integration later if needed.")
MessageService.information(
self, "Installation Complete",
"Modlist installation completed successfully!\n\n"
"The modlist has been installed but Steam integration was skipped.\n"
"You can manually add the modlist to Steam later if desired.",
safety_level="medium"
)
# Re-enable controls since operation is complete
self._enable_controls_after_operation()
else:
# Check for user cancellation first - check message parameter first, then console
if self._premium_failure_active:
MessageService.warning(
self,
"Nexus Premium Required",
"Jackify stopped the installation because Nexus Mods reported that this account is not Premium.\n\n"
"Automatic installs currently require Nexus Premium. Non-premium support is planned.",
safety_level="medium"
)
self._safe_append_text("\nInstall stopped: Nexus Premium required.")
self._premium_failure_active = False
elif hasattr(self, '_cancellation_requested') and self._cancellation_requested:
# User explicitly cancelled via cancel button
MessageService.information(self, "Installation Cancelled", "The installation was cancelled by the user.", safety_level="low")
self._cancellation_requested = False
else:
# Check console as fallback
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)

View File

@@ -0,0 +1,195 @@
"""Modlist selection methods for InstallModlistScreen (Mixin)."""
from pathlib import Path
from PySide6.QtWidgets import QFileDialog, QMessageBox, QApplication, QDialog
from PySide6.QtCore import QTimer, Qt
import logging
import os
import re
# Runtime imports to avoid circular dependencies
from .install_modlist_dialogs import SelectionDialog, ModlistFetchThread # Runtime import
from jackify.frontends.gui.screens.modlist_gallery import ModlistGalleryDialog # Runtime import
logger = logging.getLogger(__name__)
class ModlistSelectionMixin:
"""Mixin providing modlist selection methods for InstallModlistScreen."""
def open_game_type_dialog(self):
dlg = SelectionDialog("Select Game Type", self.game_types, self, show_search=False)
if dlg.exec() == QDialog.Accepted and dlg.selected_item:
self.game_type_btn.setText(dlg.selected_item)
# Store game type for gallery filter
self.current_game_type = dlg.selected_item
# Enable modlist button immediately - gallery will fetch its own data
self.modlist_btn.setEnabled(True)
self.modlist_btn.setText("Select Modlist")
# No need to fetch modlists here - gallery does it when opened
def fetch_modlists_for_game_type(self, game_type):
self.current_game_type = game_type # Store for display formatting
self.modlist_btn.setText("Fetching modlists...")
self.modlist_btn.setEnabled(False)
game_type_map = {
"Skyrim": "skyrim",
"Fallout 4": "fallout4",
"Fallout New Vegas": "falloutnv",
"Oblivion": "oblivion",
"Starfield": "starfield",
"Oblivion Remastered": "oblivion_remastered",
"Enderal": "enderal",
"Other": "other"
}
cli_game_type = game_type_map.get(game_type, "other")
log_path = self.modlist_log_path
# Use backend service directly - NO CLI CALLS
self.fetch_thread = ModlistFetchThread(
cli_game_type, log_path, mode='list-modlists')
self.fetch_thread.result.connect(self.on_modlists_fetched)
self.fetch_thread.start()
def on_modlists_fetched(self, modlist_infos, error):
# Handle the case where modlist_infos might be strings (backward compatibility)
if modlist_infos and isinstance(modlist_infos[0], str):
filtered = [m for m in modlist_infos if m and not m.startswith('DEBUG:')]
self.current_modlists = filtered
self.current_modlist_display = filtered
else:
# New format - full modlist objects with enhanced metadata
filtered_modlists = [m for m in modlist_infos if m and hasattr(m, 'id')]
filtered = filtered_modlists # Set filtered for the condition check below
self.current_modlists = [m.id for m in filtered_modlists] # Keep IDs for selection
# Create enhanced display strings with size info and status indicators
display_strings = []
for modlist in filtered_modlists:
# Get enhanced metadata
download_size = getattr(modlist, 'download_size', '')
install_size = getattr(modlist, 'install_size', '')
total_size = getattr(modlist, 'total_size', '')
status_down = getattr(modlist, 'status_down', False)
status_nsfw = getattr(modlist, 'status_nsfw', False)
# Format display string without redundant game type: "Modlist Name - Download|Install|Total"
# For "Other" category, include game type in brackets for clarity
# Use padding to create alignment: left-aligned name, right-aligned sizes
if hasattr(self, 'current_game_type') and self.current_game_type == "Other":
name_part = f"{modlist.name} [{modlist.game}]"
else:
name_part = modlist.name
size_part = f"{download_size}|{install_size}|{total_size}"
# Create aligned display using string formatting (approximate alignment)
display_str = f"{name_part:<50} {size_part:>15}"
# Add status indicators at the beginning if present
if status_down or status_nsfw:
status_parts = []
if status_down:
status_parts.append("[DOWN]")
if status_nsfw:
status_parts.append("[NSFW]")
display_str = " ".join(status_parts) + " " + display_str
display_strings.append(display_str)
self.current_modlist_display = display_strings
# Create mapping from display string back to modlist ID for selection
self._modlist_id_map = {}
if len(self.current_modlist_display) == len(self.current_modlists):
self._modlist_id_map = {display: modlist_id for display, modlist_id in
zip(self.current_modlist_display, self.current_modlists)}
else:
# Fallback for backward compatibility
self._modlist_id_map = {mid: mid for mid in self.current_modlists}
if error:
self.modlist_btn.setText("Error fetching modlists.")
self.modlist_btn.setEnabled(False)
# Don't write to log file before workflow starts - just show error in UI
elif filtered:
self.modlist_btn.setText("Select Modlist")
self.modlist_btn.setEnabled(True)
else:
self.modlist_btn.setText("No modlists found.")
self.modlist_btn.setEnabled(False)
def open_modlist_dialog(self):
# CRITICAL: Prevent opening gallery without game type selected
# Prevent engine path resolution / subprocess issues
if not hasattr(self, 'current_game_type') or not self.current_game_type:
QMessageBox.warning(
self,
"Game Type Required",
"Please select a game type before opening the modlist gallery."
)
return
self.modlist_btn.setEnabled(False)
cursor_overridden = False
try:
QApplication.setOverrideCursor(Qt.WaitCursor)
cursor_overridden = True
game_type_to_human_friendly = {
"Skyrim": "Skyrim Special Edition",
"Fallout 4": "Fallout 4",
"Fallout New Vegas": "Fallout New Vegas",
"Oblivion": "Oblivion",
"Starfield": "Starfield",
"Oblivion Remastered": "Oblivion",
"Enderal": "Enderal Special Edition",
"Other": None
}
game_filter = None
if hasattr(self, 'current_game_type'):
game_filter = game_type_to_human_friendly.get(self.current_game_type)
dlg = ModlistGalleryDialog(game_filter=game_filter, parent=self)
if cursor_overridden:
QApplication.restoreOverrideCursor()
cursor_overridden = False
if dlg.exec() == QDialog.Accepted and dlg.selected_metadata:
metadata = dlg.selected_metadata
self.modlist_btn.setText(metadata.title)
self.selected_modlist_info = {
'machine_url': metadata.namespacedName,
'title': metadata.title,
'author': metadata.author,
'game': metadata.gameHumanFriendly,
'description': metadata.description,
'nsfw': metadata.nsfw,
'force_down': metadata.forceDown
}
self.modlist_name_edit.setText(metadata.title)
# Auto-append modlist name to install directory
base_install_dir = self.config_handler.get_modlist_install_base_dir()
if base_install_dir:
# Sanitize modlist title for filesystem use
safe_title = re.sub(r'[<>:"/\\|?*]', '', metadata.title)
safe_title = safe_title.strip()
modlist_install_path = os.path.join(base_install_dir, safe_title)
self.install_dir_edit.setText(modlist_install_path)
finally:
if cursor_overridden:
QApplication.restoreOverrideCursor()
self.modlist_btn.setEnabled(True)
def browse_wabbajack_file(self):
file, _ = QFileDialog.getOpenFileName(self, "Select .wabbajack File", os.path.expanduser("~"), "Wabbajack Files (*.wabbajack)")
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 browse_downloads_dir(self):
dir = QFileDialog.getExistingDirectory(self, "Select Downloads Directory", self.downloads_dir_edit.text())
if dir:
self.downloads_dir_edit.setText(dir)

View File

@@ -0,0 +1,114 @@
"""Steam shortcut conflict dialog and retry workflow for InstallModlistScreen (Mixin)."""
from PySide6.QtWidgets import (
QDialog,
QVBoxLayout,
QLabel,
QLineEdit,
QPushButton,
QHBoxLayout,
)
from jackify.frontends.gui.services.message_service import MessageService
class InstallModlistShortcutDialogMixin:
"""Mixin providing shortcut conflict dialog and retry-with-new-name for InstallModlistScreen."""
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 = self.modlist_name_edit.text().strip()
dialog = QDialog(self)
dialog.setWindowTitle("Steam Shortcut Conflict")
dialog.setModal(True)
dialog.resize(450, 180)
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_label = QLabel(f"{conflict_info}\n\nPlease choose a different name for your shortcut:")
layout.addWidget(conflict_label)
name_input = QLineEdit(modlist_name)
name_input.selectAll()
layout.addWidget(name_input)
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)
def on_create():
new_name = name_input.text().strip()
if new_name and new_name != modlist_name:
dialog.accept()
self.retry_automated_workflow_with_new_name(new_name)
elif new_name == modlist_name:
MessageService.warning(self, "Same Name", "Please enter a different name to resolve the conflict.")
else:
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)
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."""
self.modlist_name_edit.setText(new_name)
self._safe_append_text(f"Retrying with new shortcut name: '{new_name}'")
self.start_automated_prefix_workflow()

View File

@@ -0,0 +1,222 @@
"""TTW integration methods for InstallModlistScreen (Mixin)."""
from pathlib import Path
from PySide6.QtCore import QTimer
import logging
import os
logger = logging.getLogger(__name__)
class TTWIntegrationMixin:
"""Mixin providing TTW integration methods for InstallModlistScreen."""
def _check_ttw_eligibility(self, modlist_name: str, game_type: str, install_dir: str) -> bool:
"""Check if modlist is FNV, TTW-compatible, and doesn't already have TTW
Args:
modlist_name: Name of the installed modlist
game_type: Game type (e.g., 'falloutnv')
install_dir: Modlist installation directory
Returns:
bool: True if should offer TTW integration
"""
try:
# Check 1: Must be Fallout New Vegas
if game_type.lower() not in ['falloutnv', 'fallout new vegas', 'fallout_new_vegas']:
return False
# Check 2: Must be on whitelist
from jackify.backend.data.ttw_compatible_modlists import is_ttw_compatible
if not is_ttw_compatible(modlist_name):
return False
# Check 3: TTW must not already be installed
if self._detect_existing_ttw(install_dir):
from .install_modlist import debug_print
debug_print("DEBUG: TTW already installed, skipping prompt")
return False
return True
except Exception as e:
from .install_modlist import debug_print
debug_print(f"DEBUG: Error checking TTW eligibility: {e}")
return False
def _detect_existing_ttw(self, install_dir: str) -> bool:
"""Check if TTW is already installed in the modlist
Args:
install_dir: Modlist installation directory
Returns:
bool: True if TTW is already present
"""
try:
mods_dir = Path(install_dir) / "mods"
if not mods_dir.exists():
return False
# Check for folders containing "Tale of Two Wastelands" that have actual TTW content
# Exclude separators and placeholder folders
for folder in mods_dir.iterdir():
if not folder.is_dir():
continue
folder_name_lower = folder.name.lower()
# Skip separator folders and placeholders
if "_separator" in folder_name_lower or "put" in folder_name_lower or "here" in folder_name_lower:
continue
# Check if folder name contains TTW indicator
if "tale of two wastelands" in folder_name_lower:
# Verify it has actual TTW content by checking for the main ESM
ttw_esm = folder / "TaleOfTwoWastelands.esm"
if ttw_esm.exists():
from .install_modlist import debug_print
debug_print(f"DEBUG: Found existing TTW installation: {folder.name}")
return True
else:
from .install_modlist import debug_print
debug_print(f"DEBUG: Found TTW folder but no ESM, skipping: {folder.name}")
return False
except Exception as e:
from .install_modlist import debug_print
debug_print(f"DEBUG: Error detecting existing TTW: {e}")
return False # Assume not installed on error
def _initiate_ttw_workflow(self, modlist_name: str, install_dir: str):
"""Navigate to TTW screen and set it up for modlist integration
Args:
modlist_name: Name of the modlist that needs TTW integration
install_dir: Path to the modlist installation directory
"""
try:
# Store modlist context for later use when TTW completes
self._ttw_modlist_name = modlist_name
self._ttw_install_dir = install_dir
# Get reference to TTW screen BEFORE navigation
if self.stacked_widget:
ttw_screen = self.stacked_widget.widget(5)
# Set integration mode BEFORE navigating to avoid showEvent race condition
if hasattr(ttw_screen, 'set_modlist_integration_mode'):
ttw_screen.set_modlist_integration_mode(modlist_name, install_dir)
# Connect to completion signal to show success dialog after TTW
if hasattr(ttw_screen, 'integration_complete'):
ttw_screen.integration_complete.connect(self._on_ttw_integration_complete)
else:
from .install_modlist import debug_print
debug_print("WARNING: TTW screen does not support modlist integration mode yet")
# Navigate to TTW screen AFTER setting integration mode
self.stacked_widget.setCurrentIndex(5)
# Force collapsed state shortly after navigation to avoid any
# showEvent/layout timing races that may leave it expanded
try:
QTimer.singleShot(50, lambda: getattr(ttw_screen, 'force_collapsed_state', lambda: None)())
except Exception:
pass
except Exception as e:
from .install_modlist import debug_print
debug_print(f"ERROR: Failed to initiate TTW workflow: {e}")
from jackify.frontends.gui.services.message_service import MessageService
MessageService.critical(
self,
"TTW Navigation Failed",
f"Failed to navigate to TTW installation screen: {str(e)}"
)
def _on_ttw_integration_complete(self, success: bool, ttw_version: str = ""):
"""Handle completion of TTW integration and show final success dialog
Args:
success: Whether TTW integration completed successfully
ttw_version: Version of TTW that was installed
"""
try:
if not success:
from jackify.frontends.gui.services.message_service import MessageService
MessageService.critical(
self,
"TTW Integration Failed",
"Tale of Two Wastelands integration did not complete successfully."
)
return
# Navigate back to this screen to show success dialog
if self.stacked_widget:
self.stacked_widget.setCurrentIndex(4)
# Calculate elapsed time from workflow start
import time
if hasattr(self, '_install_workflow_start_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"
else:
time_str = "unknown"
# Build success message including TTW installation
modlist_name = getattr(self, '_ttw_modlist_name', 'Unknown')
game_name = "Fallout New Vegas"
# Check for VNV post-install automation after TTW installation
vnv_automation_running = False
if hasattr(self, '_ttw_install_dir') and hasattr(self, '_ttw_modlist_name'):
vnv_automation_running = self._check_and_run_vnv_automation(self._ttw_modlist_name, self._ttw_install_dir)
if vnv_automation_running:
# Store success dialog params for later (after VNV automation completes)
self._pending_success_dialog_params = {
'modlist_name': modlist_name,
'time_taken': time_str,
'game_name': game_name,
'enb_detected': False, # TTW installs don't have ENB
'ttw_version': ttw_version if 'ttw_version' in locals() else None
}
# Keep post-install feedback active during VNV automation
# Don't show success dialog yet - will be shown in _on_vnv_complete
return
# No VNV automation - end post-install feedback now
self._end_post_install_feedback(True)
# Clear Activity window before showing success dialog
self.file_progress_list.clear()
# Show enhanced success dialog
from ..dialogs import SuccessDialog
success_dialog = SuccessDialog(
modlist_name=modlist_name,
workflow_type="install",
time_taken=time_str,
game_name=game_name,
parent=self
)
# Add TTW installation info to dialog if possible
if 'ttw_version' in locals() and hasattr(success_dialog, 'add_info_line'):
success_dialog.add_info_line(f"TTW {ttw_version} integrated successfully")
success_dialog.show()
except Exception as e:
from .install_modlist import debug_print
debug_print(f"ERROR: Failed to show final success dialog: {e}")
from jackify.frontends.gui.services.message_service import MessageService
MessageService.critical(
self,
"Display Error",
f"TTW integration completed but failed to show success dialog: {str(e)}"
)

View File

@@ -0,0 +1,519 @@
"""UI setup methods for InstallModlistScreen (Mixin)."""
from PySide6.QtWidgets import QWidget, QVBoxLayout, QLabel, QComboBox, QHBoxLayout, QLineEdit, QPushButton, QGridLayout, QFileDialog, QTextEdit, QSizePolicy, QTabWidget, QDialog, QListWidget, QListWidgetItem, QMessageBox, QProgressDialog, QApplication, QCheckBox, QStyledItemDelegate, QStyle, QTableWidget, QTableWidgetItem, QHeaderView, QMainWindow
from PySide6.QtCore import Qt, QSize, QThread, Signal, QTimer, QProcess, QMetaObject, QUrl
from PySide6.QtGui import QPixmap, QTextCursor, QColor, QPainter, QFont
from ..shared_theme import JACKIFY_COLOR_BLUE, DEBUG_BORDERS
from ..utils import ansi_to_html, set_responsive_minimum
from jackify.backend.handlers.wabbajack_parser import WabbajackParser
from jackify.backend.handlers.progress_parser import ProgressStateManager
from jackify.frontends.gui.widgets.progress_indicator import OverallProgressIndicator
from jackify.frontends.gui.widgets.file_progress_list import FileProgressList
import os
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 InstallModlistUISetupMixin:
"""Mixin providing UI initialization for InstallModlistScreen."""
def __init__(self, stacked_widget=None, main_menu_index=0, system_info=None):
super().__init__()
# Set size policy to prevent unnecessary expansion - let content determine size
self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Minimum)
self.stacked_widget = stacked_widget
self.main_menu_index = main_menu_index
from jackify.backend.models.configuration import SystemInfo
self.system_info = system_info or SystemInfo(is_steamdeck=False)
self.debug = DEBUG_BORDERS
# Remember original main window geometry/min-size to restore on expand (like TTW screen)
self._saved_geometry = None
self._saved_min_size = None
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.nexus_auth_service import NexusAuthService
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.auth_service = NexusAuthService()
self.resolution_service = ResolutionService()
self.config_handler = ConfigHandler()
self.protontricks_service = ProtontricksDetectionService()
# Somnium guidance tracking
self._show_somnium_guidance = False
self._somnium_install_dir = None
# Console deduplication tracking
self._last_console_line = None
# Gallery cache preloading tracking
self._gallery_cache_preload_started = False
self._gallery_cache_preload_thread = 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()
# R&D: Initialize progress reporting components
self.progress_state_manager = ProgressStateManager()
self.progress_indicator = OverallProgressIndicator(show_progress_bar=True)
self.file_progress_list = FileProgressList() # Shows all active files (scrolls if needed)
self._premium_notice_shown = False
self._premium_failure_active = False
self._stalled_download_start_time = None # Track when downloads stall
self._stalled_download_notified = False
self._post_install_sequence = self._build_post_install_sequence()
self._post_install_total_steps = len(self._post_install_sequence)
self._post_install_current_step = 0
self._post_install_active = False
self._post_install_last_label = ""
self._bsa_hold_deadline = 0.0
# No throttling needed - render loop handles smooth updates at 60fps
# R&D: Create "Show Details" checkbox (reuse TTW pattern)
self.show_details_checkbox = QCheckBox("Show details")
self.show_details_checkbox.setChecked(False) # Start collapsed
self.show_details_checkbox.setToolTip("Toggle between activity summary and detailed console output")
self.show_details_checkbox.toggled.connect(self._on_show_details_toggled)
main_overall_vbox = QVBoxLayout(self)
self.main_overall_vbox = main_overall_vbox # Store reference for expand/collapse
main_overall_vbox.setAlignment(Qt.AlignTop | Qt.AlignHCenter)
main_overall_vbox.setContentsMargins(50, 50, 50, 0) # No bottom margin
main_overall_vbox.setSpacing(0) # No spacing between widgets to eliminate gaps
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("<b>Install a Modlist (Automated)</b>")
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 a Wabbajack modlist using Jackify. "
"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)
header_widget.setMaximumHeight(75) # Increase header height by 25% (60 + 15)
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)
upper_hbox.setAlignment(Qt.AlignTop) # Align both sides at the top
# 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
user_config_vbox.setContentsMargins(0, 0, 0, 0) # No margins to ensure tab alignment
# --- Tabs for source selection ---
self.source_tabs = QTabWidget()
self.source_tabs.setStyleSheet("QTabWidget::pane { background: #222; border: 1px solid #444; } QTabBar::tab { background: #222; color: #ccc; padding: 6px 16px; } QTabBar::tab:selected { background: #333; color: #3fd0ea; } QTabWidget { margin: 0px; padding: 0px; } QTabBar { margin: 0px; padding: 0px; }")
self.source_tabs.setContentsMargins(0, 0, 0, 0) # Ensure no margins for alignment
self.source_tabs.setDocumentMode(False) # Keep frame for consistency
self.source_tabs.setTabPosition(QTabWidget.North) # Ensure tabs are at top
if self.debug:
self.source_tabs.setStyleSheet("border: 2px solid cyan;")
self.source_tabs.setToolTip("SOURCE_TABS")
# --- Online List Tab ---
online_tab = QWidget()
online_tab_vbox = QVBoxLayout()
online_tab_vbox.setAlignment(Qt.AlignTop)
# Online List Controls
self.online_group = QWidget()
online_layout = QHBoxLayout()
online_layout.setContentsMargins(0, 0, 0, 0)
# --- Game Type Selection ---
self.game_types = ["Skyrim", "Fallout 4", "Fallout New Vegas", "Oblivion", "Starfield", "Oblivion Remastered", "Enderal", "Other"]
self.game_type_btn = QPushButton("Please Select...")
self.game_type_btn.setMinimumWidth(200)
self.game_type_btn.clicked.connect(self.open_game_type_dialog)
# --- Modlist Selection ---
self.modlist_btn = QPushButton("Select Modlist")
self.modlist_btn.setMinimumWidth(300)
self.modlist_btn.clicked.connect(self.open_modlist_dialog)
self.modlist_btn.setEnabled(False)
online_layout.addWidget(QLabel("Game Type:"))
online_layout.addWidget(self.game_type_btn)
online_layout.addSpacing(4)
online_layout.addWidget(QLabel("Modlist:"))
online_layout.addWidget(self.modlist_btn)
self.online_group.setLayout(online_layout)
online_tab_vbox.addWidget(self.online_group)
online_tab.setLayout(online_tab_vbox)
self.source_tabs.addTab(online_tab, "Select Modlist")
# --- File Picker Tab ---
file_tab = QWidget()
file_tab_vbox = QVBoxLayout()
file_tab_vbox.setAlignment(Qt.AlignTop)
self.file_group = QWidget()
file_layout = QHBoxLayout()
file_layout.setContentsMargins(0, 0, 0, 0)
self.file_edit = QLineEdit()
self.file_edit.setMinimumWidth(400)
self.file_btn = QPushButton("Browse")
self.file_btn.clicked.connect(self.browse_wabbajack_file)
file_layout.addWidget(QLabel(".wabbajack File:"))
file_layout.addWidget(self.file_edit)
file_layout.addWidget(self.file_btn)
self.file_group.setLayout(file_layout)
file_tab_vbox.addWidget(self.file_group)
file_tab.setLayout(file_tab_vbox)
self.source_tabs.addTab(file_tab, "Use .wabbajack File")
user_config_vbox.addWidget(self.source_tabs)
# --- Install/Downloads Dir/API Key (reuse Tuxborn style) ---
form_grid = QGridLayout()
form_grid.setHorizontalSpacing(12)
form_grid.setVerticalSpacing(6) # Increased from 1 to 6 for better readability
form_grid.setContentsMargins(0, 0, 0, 0)
# Modlist Name (NEW FIELD)
modlist_name_label = QLabel("Modlist Name:")
self.modlist_name_edit = QLineEdit()
self.modlist_name_edit.setMaximumHeight(25) # Force compact height
form_grid.addWidget(modlist_name_label, 0, 0, alignment=Qt.AlignLeft | Qt.AlignVCenter)
form_grid.addWidget(self.modlist_name_edit, 0, 1)
# Install Dir
install_dir_label = QLabel("Install Directory:")
self.install_dir_edit = QLineEdit(self.config_handler.get_modlist_install_base_dir())
self.install_dir_edit.setMaximumHeight(25) # Force compact height
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)
# Downloads Dir
downloads_dir_label = QLabel("Downloads Directory:")
self.downloads_dir_edit = QLineEdit(self.config_handler.get_modlist_downloads_base_dir())
self.downloads_dir_edit.setMaximumHeight(25) # Force compact height
self.browse_downloads_btn = QPushButton("Browse")
self.browse_downloads_btn.clicked.connect(self.browse_downloads_dir)
downloads_dir_hbox = QHBoxLayout()
downloads_dir_hbox.addWidget(self.downloads_dir_edit)
downloads_dir_hbox.addWidget(self.browse_downloads_btn)
form_grid.addWidget(downloads_dir_label, 2, 0, alignment=Qt.AlignLeft | Qt.AlignVCenter)
form_grid.addLayout(downloads_dir_hbox, 2, 1)
# Nexus Login (OAuth)
nexus_login_label = QLabel("Nexus Login:")
self.nexus_status = QLabel("Checking...")
self.nexus_status.setStyleSheet("color: #ccc;")
self.nexus_login_btn = QPushButton("Authorise")
self.nexus_login_btn.setStyleSheet("""
QPushButton:hover { opacity: 0.95; }
QPushButton:disabled { opacity: 0.6; }
""")
self.nexus_login_btn.setMaximumWidth(90)
self.nexus_login_btn.setVisible(False)
self.nexus_login_btn.clicked.connect(self._handle_nexus_login_click)
nexus_hbox = QHBoxLayout()
nexus_hbox.setContentsMargins(0, 0, 0, 0)
nexus_hbox.setSpacing(8)
nexus_hbox.addWidget(self.nexus_login_btn)
nexus_hbox.addWidget(self.nexus_status)
nexus_hbox.addStretch()
form_grid.addWidget(nexus_login_label, 3, 0, alignment=Qt.AlignLeft | Qt.AlignVCenter)
form_grid.addLayout(nexus_hbox, 3, 1)
# Update nexus status on init
self._update_nexus_status()
# --- Resolution Dropdown ---
resolution_label = QLabel("Resolution:")
self.resolution_combo = QComboBox()
self.resolution_combo.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
self.resolution_combo.addItem("Leave unchanged")
self.resolution_combo.addItems([
"1280x720",
"1280x800 (Steam Deck)",
"1366x768",
"1440x900",
"1600x900",
"1600x1200",
"1680x1050",
"1920x1080",
"1920x1200",
"2048x1152",
"2560x1080",
"2560x1440",
"2560x1600",
"3440x1440",
"3840x1600",
"3840x2160",
"3840x2400",
"5120x1440",
"5120x2160",
"7680x4320"
])
# Load saved resolution if available
saved_resolution = self.resolution_service.get_saved_resolution()
is_steam_deck = False
try:
if os.path.exists('/etc/os-release'):
with open('/etc/os-release') as f:
if 'steamdeck' in f.read().lower():
is_steam_deck = True
except Exception:
pass
if saved_resolution:
combo_items = [self.resolution_combo.itemText(i) for i in range(self.resolution_combo.count())]
resolution_index = self.resolution_service.get_resolution_index(saved_resolution, combo_items)
self.resolution_combo.setCurrentIndex(resolution_index)
debug_print(f"DEBUG: Loaded saved resolution: {saved_resolution} (index: {resolution_index})")
elif is_steam_deck:
# Set default to 1280x800 (Steam Deck)
combo_items = [self.resolution_combo.itemText(i) for i in range(self.resolution_combo.count())]
if "1280x800 (Steam Deck)" in combo_items:
self.resolution_combo.setCurrentIndex(combo_items.index("1280x800 (Steam Deck)"))
else:
self.resolution_combo.setCurrentIndex(0)
# Otherwise, default is 'Leave unchanged' (index 0)
form_grid.addWidget(resolution_label, 5, 0, alignment=Qt.AlignLeft | Qt.AlignVCenter)
# Horizontal layout for resolution dropdown and auto-restart checkbox
resolution_and_restart_layout = QHBoxLayout()
resolution_and_restart_layout.setSpacing(12)
# Resolution dropdown (made smaller)
self.resolution_combo.setMaximumWidth(280) # Constrain width but keep aesthetically pleasing
resolution_and_restart_layout.addWidget(self.resolution_combo)
# Add stretch to push checkbox to the right
resolution_and_restart_layout.addStretch()
# Auto-accept Steam restart checkbox (right-aligned)
self.auto_restart_checkbox = QCheckBox("Auto-accept Steam restart")
self.auto_restart_checkbox.setChecked(False) # Always default to unchecked per session
self.auto_restart_checkbox.setToolTip("When checked, Steam restart dialog will be automatically accepted, allowing unattended installation")
resolution_and_restart_layout.addWidget(self.auto_restart_checkbox)
form_grid.addLayout(resolution_and_restart_layout, 5, 1)
form_section_widget = QWidget()
form_section_widget.setLayout(form_grid)
# Let form section size naturally to its content
# Don't force a fixed height - let it calculate based on grid content
form_section_widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
if self.debug:
form_section_widget.setStyleSheet("border: 2px solid blue;")
form_section_widget.setToolTip("FORM_SECTION")
user_config_vbox.addWidget(form_section_widget)
# --- Buttons ---
btn_row = QHBoxLayout()
btn_row.setAlignment(Qt.AlignHCenter)
self.start_btn = QPushButton("Start Installation")
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)
# 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")
user_config_widget = QWidget()
self.user_config_widget = user_config_widget # Store reference for height calculation
user_config_widget.setLayout(user_config_vbox)
user_config_widget.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed) # Fixed height - don't expand unnecessarily
if self.debug:
user_config_widget.setStyleSheet("border: 2px solid orange;")
user_config_widget.setToolTip("USER_CONFIG_WIDGET")
# Right: Tabbed interface with Activity and Process Monitor
# Both tabs are always available, user can switch between them
self.process_monitor = QTextEdit()
self.process_monitor.setReadOnly(True)
self.process_monitor.setTextInteractionFlags(Qt.TextSelectableByMouse | Qt.TextSelectableByKeyboard)
self.process_monitor.setMinimumSize(QSize(300, 20))
self.process_monitor.setStyleSheet(f"background: #222; color: {JACKIFY_COLOR_BLUE}; font-family: monospace; font-size: 11px; border: 1px solid #444;")
self.process_monitor_heading = QLabel("<b>[Process Monitor]</b>")
self.process_monitor_heading.setStyleSheet(f"color: {JACKIFY_COLOR_BLUE}; font-size: 13px; margin-bottom: 2px;")
self.process_monitor_heading.setAlignment(Qt.AlignLeft | Qt.AlignVCenter)
process_vbox = QVBoxLayout()
process_vbox.setContentsMargins(0, 0, 0, 0)
process_vbox.setSpacing(2)
process_vbox.addWidget(self.process_monitor_heading)
process_vbox.addWidget(self.process_monitor)
process_monitor_widget = QWidget()
process_monitor_widget.setLayout(process_vbox)
# Match size policy - Process Monitor should expand to fill available space
process_monitor_widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
if self.debug:
process_monitor_widget.setStyleSheet("border: 2px solid purple;")
process_monitor_widget.setToolTip("PROCESS_MONITOR")
# Store reference
self.process_monitor_widget = process_monitor_widget
# Set up File Progress List (Activity tab)
# Match Process Monitor size policy exactly - expand to fill available space
self.file_progress_list.setMinimumSize(QSize(300, 20))
self.file_progress_list.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
# Create tab widget to hold both Activity and Process Monitor
# Match styling of source_tabs on the left for consistency
self.activity_tabs = QTabWidget()
self.activity_tabs.setStyleSheet("QTabWidget::pane { background: #222; border: 1px solid #444; } QTabBar::tab { background: #222; color: #ccc; padding: 6px 16px; } QTabBar::tab:selected { background: #333; color: #3fd0ea; } QTabWidget { margin: 0px; padding: 0px; } QTabBar { margin: 0px; padding: 0px; }")
self.activity_tabs.setContentsMargins(0, 0, 0, 0) # Ensure no margins for alignment
self.activity_tabs.setDocumentMode(False) # Match left tabs
self.activity_tabs.setTabPosition(QTabWidget.North) # Ensure tabs are at top
if self.debug:
self.activity_tabs.setStyleSheet("border: 2px solid cyan;")
self.activity_tabs.setToolTip("ACTIVITY_TABS")
# Add both widgets as tabs
self.activity_tabs.addTab(self.file_progress_list, "Activity")
self.activity_tabs.addTab(process_monitor_widget, "Process Monitor")
upper_hbox.addWidget(user_config_widget, stretch=1, alignment=Qt.AlignTop)
# Add tab widget with stretch=3 to match original Process Monitor stretch
upper_hbox.addWidget(self.activity_tabs, stretch=3, alignment=Qt.AlignTop)
upper_section_widget = QWidget()
self.upper_section_widget = upper_section_widget # Store reference for showEvent
upper_section_widget.setLayout(upper_hbox)
# Use Fixed size policy - the height should be based on LEFT side only
# Consistent height for both Active Files and Process Monitor
upper_section_widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
# Calculate height based on LEFT side (user_config_widget) only
self._upper_section_fixed_height = None # Will be set in showEvent based on left side
if self.debug:
upper_section_widget.setStyleSheet("border: 2px solid green;")
upper_section_widget.setToolTip("UPPER_SECTION")
main_overall_vbox.addWidget(upper_section_widget)
# Add spacing between upper section and progress banner
main_overall_vbox.addSpacing(8)
# R&D: Progress indicator banner row (similar to TTW screen)
banner_row = QHBoxLayout()
banner_row.setContentsMargins(0, 0, 0, 0)
banner_row.setSpacing(8)
banner_row.addWidget(self.progress_indicator, 1)
banner_row.addStretch()
banner_row.addWidget(self.show_details_checkbox)
banner_row_widget = QWidget()
banner_row_widget.setLayout(banner_row)
# Constrain height to prevent unwanted vertical expansion
banner_row_widget.setMaximumHeight(45) # Compact height: 34px label + small margin
banner_row_widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
main_overall_vbox.addWidget(banner_row_widget)
# Add spacing between progress banner and console/details area
main_overall_vbox.addSpacing(8)
# R&D: File progress list is now in the upper section (replacing Process Monitor)
# Console shows below when "Show details" is checked
# File progress list is already added to upper_hbox above
# 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)
# R&D: Console starts hidden (only shows when "Show details" is checked)
self.console.setMinimumHeight(0)
self.console.setMaximumHeight(0)
self.console.setVisible(False)
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()
# Create a container that holds console + button row with proper spacing
console_and_buttons_widget = QWidget()
console_and_buttons_layout = QVBoxLayout()
console_and_buttons_layout.setContentsMargins(0, 0, 0, 0)
console_and_buttons_layout.setSpacing(0) # No spacing - console is hidden initially
# Console with stretch only when visible, buttons always at natural size
console_and_buttons_layout.addWidget(self.console) # No stretch initially - will be set dynamically
console_and_buttons_layout.addWidget(btn_row_widget) # Buttons at bottom of this container
console_and_buttons_widget.setLayout(console_and_buttons_layout)
self.console_and_buttons_widget = console_and_buttons_widget # Store reference for stretch control
self.console_and_buttons_layout = console_and_buttons_layout # Store reference for spacing control
# Use Minimum size policy - takes only the minimum space needed
console_and_buttons_widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Minimum)
# Constrain height to button row only when console is hidden - match button row height exactly
# Button row is 50px max, so container should be exactly that when collapsed
console_and_buttons_widget.setFixedHeight(50) # Lock to exact button row height when console is hidden
if self.debug:
console_and_buttons_widget.setStyleSheet("border: 2px solid lightblue;")
console_and_buttons_widget.setToolTip("CONSOLE_AND_BUTTONS_CONTAINER")
# Add without stretch - let it size naturally to content
main_overall_vbox.addWidget(console_and_buttons_widget)
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 = []
# Now collect all actionable controls after UI is fully built
self._collect_actionable_controls()

View File

@@ -0,0 +1,208 @@
"""VNV automation methods for InstallModlistScreen (Mixin)."""
from pathlib import Path
from PySide6.QtCore import QTimer
import logging
import os
from typing import Optional
logger = logging.getLogger(__name__)
class VNVAutomationMixin:
"""Mixin providing VNV automation methods for InstallModlistScreen."""
def _check_and_run_vnv_automation(self, modlist_name: str, install_dir: str) -> bool:
"""Check if VNV automation should run and execute if applicable in background thread
Args:
modlist_name: Name of the installed modlist
install_dir: Installation directory path
Returns:
True if VNV automation is starting (success dialog should be deferred)
False if no VNV automation needed (show success dialog immediately)
"""
try:
from jackify.backend.services.vnv_integration_helper import should_offer_vnv_automation
from jackify.backend.handlers.path_handler import PathHandler
from jackify.backend.services.vnv_post_install_service import VNVPostInstallService
from jackify.backend.services.automated_prefix_service import AutomatedPrefixService
# Get paths first (needed for VNV detection)
install_path = Path(install_dir)
# Quick check before importing more (pass install location for ModOrganizer.ini check)
if not should_offer_vnv_automation(modlist_name, install_path):
return False
game_paths = PathHandler().find_vanilla_game_paths()
game_root = game_paths.get('Fallout New Vegas')
if not game_root:
from .install_modlist import debug_print
debug_print("DEBUG: VNV automation skipped - FNV game root not found")
return False
# Initialize service to check completion status
vnv_service = VNVPostInstallService(
modlist_install_location=install_path,
game_root=game_root,
ttw_installer_path=AutomatedPrefixService.get_ttw_installer_path()
)
# Check what's already done
completed = vnv_service.check_already_completed()
# Only skip if ALL three steps are completed
if completed['root_mods'] and completed['4gb_patch'] and completed['bsa_decompressed']:
logger.info("VNV automation steps already completed")
return False
# Get automation description for confirmation
description = vnv_service.get_automation_description()
# Show confirmation dialog ON MAIN THREAD (not in worker thread!)
from ..services.message_service import MessageService
from PySide6.QtWidgets import QMessageBox
reply = MessageService.question(
self,
"VNV Post-Install Automation",
description,
critical=False,
safety_level="medium"
)
if reply != QMessageBox.Yes:
logger.info("User declined VNV automation")
return False
# Enable post-install progress tracking for VNV automation
self._begin_post_install_feedback()
# User confirmed - start automation in background thread
# Note: manual_file_callback is not passed because Qt GUI operations
# cannot be called from a background thread. If downloads fail,
# the service will return instructions for manual download instead.
self._run_vnv_automation_threaded(
modlist_name,
install_path,
game_root
)
return True # VNV automation is running, defer success dialog
except Exception as e:
from .install_modlist import debug_print
debug_print(f"ERROR: Failed to start VNV automation: {e}")
import traceback
debug_print(f"Traceback: {traceback.format_exc()}")
return False # Error - show success dialog anyway
def _run_vnv_automation_threaded(self, modlist_name, install_path, game_root):
"""Run VNV automation in a background thread with progress updates
Note: User confirmation should already be obtained before calling this method.
Manual file selection is not supported from background threads - if downloads
fail, the service will return instructions for manual download.
"""
from PySide6.QtCore import QThread, Signal
from jackify.backend.services.vnv_integration_helper import run_vnv_automation_if_applicable
from jackify.backend.services.automated_prefix_service import AutomatedPrefixService
class VNVAutomationWorker(QThread):
progress_update = Signal(str)
completed = Signal(bool, str) # (success, error_message)
def __init__(self, modlist_name, install_path, game_root, ttw_installer_path):
super().__init__()
self.modlist_name = modlist_name
self.install_path = install_path
self.game_root = game_root
self.ttw_installer_path = ttw_installer_path
def run(self):
try:
# User already confirmed, pass lambda that always returns True
# manual_file_callback is None - downloads that fail will return
# instructions for manual download instead of showing Qt dialogs
automation_ran, error = run_vnv_automation_if_applicable(
modlist_name=self.modlist_name,
modlist_install_location=self.install_path,
game_root=self.game_root,
ttw_installer_path=self.ttw_installer_path,
progress_callback=self.progress_update.emit,
manual_file_callback=None,
confirmation_callback=lambda desc: True # Already confirmed on main thread
)
self.completed.emit(error is None, error or "")
except Exception as e:
import traceback
self.completed.emit(False, f"Exception: {str(e)}\n{traceback.format_exc()}")
# Create and start worker
self.vnv_worker = VNVAutomationWorker(
modlist_name,
install_path,
game_root,
AutomatedPrefixService.get_ttw_installer_path()
)
# Connect signals
self.vnv_worker.progress_update.connect(self._on_vnv_progress)
self.vnv_worker.completed.connect(self._on_vnv_complete)
self.vnv_worker.finished.connect(self.vnv_worker.deleteLater)
# Start worker
self.vnv_worker.start()
def _on_vnv_progress(self, message: str):
"""Handle VNV automation progress updates"""
self._safe_append_text(message)
# Also update progress indicator, Activity window, and Details window
self._handle_post_install_progress(message)
def _on_vnv_complete(self, success: bool, error: str):
"""Handle VNV automation completion and show deferred success dialog"""
# End post-install feedback now that VNV automation is complete
self._end_post_install_feedback(True)
if not success and error:
from ..services.message_service import MessageService
MessageService.warning(
self,
"VNV Automation Failed",
f"VNV post-install automation encountered an error:\n\n{error}\n\n"
"You can complete these steps manually by following the guide at:\n"
"https://vivanewvegas.moddinglinked.com/wabbajack.html"
)
elif success:
self._safe_append_text("VNV post-install automation completed successfully")
# Show the deferred success dialog now that VNV automation is complete
if hasattr(self, '_pending_success_dialog_params'):
params = self._pending_success_dialog_params
del self._pending_success_dialog_params # Clean up
# Clear Activity window before showing success dialog
self.file_progress_list.clear()
# Show success dialog
from ..dialogs import SuccessDialog
success_dialog = SuccessDialog(
modlist_name=params['modlist_name'],
workflow_type="install",
time_taken=params['time_taken'],
game_name=params['game_name'],
parent=self
)
success_dialog.show()
# Show ENB Proton dialog if ENB was detected
if params.get('enb_detected'):
try:
from ..dialogs.enb_proton_dialog import ENBProtonDialog
enb_dialog = ENBProtonDialog(modlist_name=params['modlist_name'], parent=self)
enb_dialog.exec() # Modal dialog - blocks until user clicks OK
except Exception as e:
# Non-blocking: if dialog fails, just log and continue
logger.warning(f"Failed to show ENB dialog: {e}")

View File

@@ -0,0 +1,371 @@
"""Installation workflow methods for InstallModlistScreen (Mixin)."""
from pathlib import Path
from PySide6.QtCore import QTimer
from PySide6.QtWidgets import QMessageBox
import logging
import os
import re
import time
from .install_modlist_installer_thread import InstallerThread
from .install_modlist_output_mixin import InstallModlistOutputMixin
logger = logging.getLogger(__name__)
class InstallWorkflowMixin(InstallModlistOutputMixin):
"""Mixin providing installation workflow methods for InstallModlistScreen."""
def validate_and_start_install(self):
import time
self._install_workflow_start_time = time.time()
from .install_modlist import debug_print
debug_print('DEBUG: validate_and_start_install called')
# Immediately show "Initialising" status to provide feedback
self.progress_indicator.set_status("Initialising...", 0)
from PySide6.QtWidgets import QApplication
QApplication.processEvents() # Force UI update
# Reload config to pick up any settings changes made in Settings dialog
self.config_handler.reload_config()
# Check protontricks before proceeding
if not self._check_protontricks():
self.progress_indicator.reset()
return
# Disable all controls during installation (except Cancel)
self._disable_controls_during_operation()
try:
tab_index = self.source_tabs.currentIndex()
install_mode = 'online'
if tab_index == 1: # .wabbajack File tab
modlist = self.file_edit.text().strip()
if not modlist or not os.path.isfile(modlist) or not modlist.endswith('.wabbajack'):
self._abort_with_message(
"warning",
"Invalid Modlist",
"Please select a valid .wabbajack file."
)
return
install_mode = 'file'
else:
# For online modlists, ALWAYS use machine_url from selected_modlist_info
# Button text is now the display name (title), NOT the machine URL
if not hasattr(self, 'selected_modlist_info') or not self.selected_modlist_info:
self._abort_with_message(
"warning",
"Invalid Modlist",
"Modlist information is missing. Please select the modlist again from the gallery."
)
return
machine_url = self.selected_modlist_info.get('machine_url')
if not machine_url:
self._abort_with_message(
"warning",
"Invalid Modlist",
"Modlist information is incomplete. Please select the modlist again from the gallery."
)
return
# CRITICAL: Use machine_url, NOT button text
modlist = machine_url
install_dir = self.install_dir_edit.text().strip()
downloads_dir = self.downloads_dir_edit.text().strip()
# Get authentication token (OAuth or API key) with automatic refresh
api_key, oauth_info = self.auth_service.get_auth_for_engine()
if not api_key:
self._abort_with_message(
"warning",
"Authorisation Required",
"Please authorise with Nexus Mods before installing modlists.\n\n"
"Click the 'Authorise' button above to log in with OAuth,\n"
"or configure an API key in Settings.",
safety_level="medium"
)
return
# Log authentication status at install start (Issue #111 diagnostics)
import logging
logger = logging.getLogger(__name__)
auth_method = self.auth_service.get_auth_method()
logger.info("=" * 60)
logger.info("Authentication Status at Install Start")
logger.info(f"Method: {auth_method or 'UNKNOWN'}")
logger.info(f"Token length: {len(api_key)} chars")
if len(api_key) >= 8:
logger.info(f"Token (partial): {api_key[:4]}...{api_key[-4:]}")
if auth_method == 'oauth':
token_handler = self.auth_service.token_handler
token_info = token_handler.get_token_info()
if 'expires_in_minutes' in token_info:
logger.info(f"OAuth expires in: {token_info['expires_in_minutes']:.1f} minutes")
if token_info.get('refresh_token_likely_expired'):
logger.warning(f"OAuth refresh token age: {token_info['refresh_token_age_days']:.1f} days (may need re-auth)")
logger.info("=" * 60)
modlist_name = self.modlist_name_edit.text().strip()
missing_fields = []
if not modlist_name:
missing_fields.append("Modlist Name")
if not install_dir:
missing_fields.append("Install Directory")
if not downloads_dir:
missing_fields.append("Downloads Directory")
if missing_fields:
self._abort_with_message(
"warning",
"Missing Required Fields",
"Please fill in all required fields before starting the install:\n- " + "\n- ".join(missing_fields)
)
return
from jackify.backend.handlers.validation_handler import ValidationHandler
validation_handler = ValidationHandler()
is_safe, reason = validation_handler.is_safe_install_directory(Path(install_dir))
if not is_safe:
from jackify.frontends.gui.dialogs.warning_dialog import WarningDialog
dlg = WarningDialog(reason, parent=self)
result = dlg.exec()
if not result or not dlg.confirmed:
self._abort_install_validation()
return
if not os.path.isdir(install_dir):
from ..services.message_service import MessageService
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}")
self._abort_install_validation()
return
else:
self._abort_install_validation()
return
if not os.path.isdir(downloads_dir):
from ..services.message_service import MessageService
create = MessageService.question(self, "Create Directory?",
f"The downloads directory does not exist:\n{downloads_dir}\n\nWould you like to create it?",
critical=False # Non-critical, won't steal focus
)
if create == QMessageBox.Yes:
try:
os.makedirs(downloads_dir, exist_ok=True)
except Exception as e:
MessageService.critical(self, "Error", f"Failed to create downloads directory:\n{e}")
self._abort_install_validation()
return
else:
self._abort_install_validation()
return
# Handle resolution saving
resolution = self.resolution_combo.currentText()
if resolution and resolution != "Leave unchanged":
success = self.resolution_service.save_resolution(resolution)
if success:
from .install_modlist import debug_print
debug_print(f"DEBUG: Resolution saved successfully: {resolution}")
else:
from .install_modlist import debug_print
debug_print("DEBUG: Failed to save resolution")
else:
# Clear saved resolution if "Leave unchanged" is selected
if self.resolution_service.has_saved_resolution():
self.resolution_service.clear_saved_resolution()
from .install_modlist import debug_print
debug_print("DEBUG: Saved resolution cleared")
# Handle parent directory saving
self._save_parent_directories(install_dir, downloads_dir)
# Detect game type and check support
game_type = None
game_name = None
if install_mode == 'file':
# Parse .wabbajack file to get game type
wabbajack_path = Path(modlist)
result = self.wabbajack_parser.parse_wabbajack_game_type(wabbajack_path)
if result:
if isinstance(result, tuple):
game_type, raw_game_type = result
# Get display name for the game
display_names = {
'skyrim': 'Skyrim',
'fallout4': 'Fallout 4',
'falloutnv': 'Fallout New Vegas',
'oblivion': 'Oblivion',
'starfield': 'Starfield',
'oblivion_remastered': 'Oblivion Remastered',
'enderal': 'Enderal'
}
if game_type == 'unknown' and raw_game_type:
game_name = raw_game_type
else:
game_name = display_names.get(game_type, game_type)
else:
game_type = result
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(game_type, game_type)
else:
# For online modlists, try to get game type from selected modlist
if hasattr(self, 'selected_modlist_info') and self.selected_modlist_info:
game_name = self.selected_modlist_info.get('game', '')
from .install_modlist import debug_print
debug_print(f"DEBUG: Detected game_name from selected_modlist_info: '{game_name}'")
# Map game name to game type
game_mapping = {
'skyrim special edition': 'skyrim',
'skyrim': 'skyrim',
'fallout 4': 'fallout4',
'fallout new vegas': 'falloutnv',
'oblivion': 'oblivion',
'starfield': 'starfield',
'oblivion_remastered': 'oblivion_remastered',
'enderal': 'enderal',
'enderal special edition': 'enderal'
}
game_type = game_mapping.get(game_name.lower())
from .install_modlist import debug_print
debug_print(f"DEBUG: Mapped game_name '{game_name}' to game_type: '{game_type}'")
if not game_type:
game_type = 'unknown'
from .install_modlist import debug_print
debug_print(f"DEBUG: Game type not found in mapping, setting to 'unknown'")
else:
from .install_modlist import debug_print
debug_print(f"DEBUG: No selected_modlist_info found")
game_type = 'unknown'
# Store game type and name for later use
self._current_game_type = game_type
self._current_game_name = game_name
# Check if game is supported
from .install_modlist import debug_print
debug_print(f"DEBUG: Checking if game_type '{game_type}' is supported")
debug_print(f"DEBUG: game_type='{game_type}', game_name='{game_name}'")
is_supported = self.wabbajack_parser.is_supported_game(game_type) if game_type else False
debug_print(f"DEBUG: is_supported_game('{game_type}') returned: {is_supported}")
if game_type and not is_supported:
from .install_modlist import debug_print
debug_print(f"DEBUG: Game '{game_type}' is not supported, showing dialog")
# Show unsupported game dialog
from ..widgets.unsupported_game_dialog import UnsupportedGameDialog
dialog = UnsupportedGameDialog(self, game_name)
if not dialog.show_dialog(self, game_name):
self._abort_install_validation()
return
self.console.clear()
self.process_monitor.clear()
# R&D: Reset progress indicator for new installation
self.progress_indicator.reset()
self.progress_state_manager.reset()
self.file_progress_list.clear()
self.file_progress_list.start_cpu_tracking() # Start tracking CPU during installation
self._premium_notice_shown = False
self._stalled_download_start_time = None # Reset stall detection
self._stalled_download_notified = False
self._token_error_notified = False # Reset token error notification
self._premium_failure_active = False
self._post_install_active = False
self._post_install_current_step = 0
# Activity tab is always visible (tabs handle visibility automatically)
# Update button states for installation
self.start_btn.setEnabled(False)
self.cancel_btn.setVisible(False)
self.cancel_install_btn.setVisible(True)
# CRITICAL: Final safety check - ensure online modlists use machine_url
if install_mode == 'online':
if hasattr(self, 'selected_modlist_info') and self.selected_modlist_info:
expected_machine_url = self.selected_modlist_info.get('machine_url')
if expected_machine_url:
modlist = expected_machine_url # Force use machine_url
else:
self._abort_with_message(
"critical",
"Installation Error",
"Cannot determine modlist machine URL. Please select the modlist again."
)
return
else:
self._abort_with_message(
"critical",
"Installation Error",
"Modlist information is missing. Please select the modlist again from the gallery."
)
return
from .install_modlist import debug_print
debug_print(f'DEBUG: Calling run_modlist_installer with modlist={modlist}, install_dir={install_dir}, downloads_dir={downloads_dir}, install_mode={install_mode}')
self.run_modlist_installer(modlist, install_dir, downloads_dir, api_key, install_mode, oauth_info)
except Exception as e:
from .install_modlist import debug_print
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)
from .install_modlist import debug_print
debug_print(f"DEBUG: Controls re-enabled in exception handler")
def run_modlist_installer(self, modlist, install_dir, downloads_dir, api_key, install_mode='online', oauth_info=None):
from .install_modlist import debug_print
debug_print('DEBUG: run_modlist_installer called - USING THREADED BACKEND WRAPPER')
# Rotate log file at start of each workflow run (keep 5 backups)
from jackify.backend.handlers.logging_handler import LoggingHandler
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()
from jackify import __version__ as jackify_version
self._safe_append_text(f"Jackify v{jackify_version}")
self._safe_append_text("Starting modlist installation with custom progress handling...")
# Update UI state for installation
self.start_btn.setEnabled(False)
self.cancel_btn.setVisible(False)
self.cancel_install_btn.setVisible(True)
self.install_thread = InstallerThread(
modlist, install_dir, downloads_dir, api_key, self.modlist_name_edit.text().strip(), install_mode,
progress_state_manager=self.progress_state_manager, # R&D: Pass progress state manager
auth_service=self.auth_service, # Fix Issue #127: Pass auth_service for Premium detection diagnostics
oauth_info=oauth_info # Pass OAuth state for auto-refresh
)
self.install_thread.output_received.connect(self.on_installation_output)
self.install_thread.progress_received.connect(self.on_installation_progress)
self.install_thread.progress_updated.connect(self.on_progress_updated) # R&D: Connect progress update
self.install_thread.installation_finished.connect(self.on_installation_finished)
self.install_thread.premium_required_detected.connect(self.on_premium_required_detected)
# R&D: Pass progress state manager to thread
self.install_thread.progress_state_manager = self.progress_state_manager
self.install_thread.start()

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,657 @@
"""Configuration workflow methods for InstallTTWScreen (Mixin)."""
from pathlib import Path
from PySide6.QtCore import QTimer, Qt, QThread, Signal
from PySide6.QtWidgets import QMessageBox, QProgressDialog
import logging
import os
import threading
import traceback
# Runtime imports to avoid circular dependencies
from jackify.frontends.gui.services.message_service import MessageService # Runtime import
logger = logging.getLogger(__name__)
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 TTWConfigMixin:
"""Mixin providing configuration workflow methods for InstallTTWScreen."""
def _detect_game_type_from_mo2_ini(self, install_dir: str) -> str:
"""Detect game type by checking ModOrganizer.ini for loader executables."""
from pathlib import Path
import logging
logger = logging.getLogger(__name__)
mo2_ini = Path(install_dir) / "ModOrganizer.ini"
if not mo2_ini.exists():
return 'skyrim' # Fallback to most common
try:
content = mo2_ini.read_text(encoding='utf-8', errors='ignore').lower()
if 'skse64_loader.exe' in content or 'skyrim special edition' in content:
return 'skyrim'
elif 'f4se_loader.exe' in content or 'fallout 4' in content:
return 'fallout4'
elif 'nvse_loader.exe' in content or 'fallout new vegas' in content:
return 'falloutnv'
elif 'obse_loader.exe' in content or 'oblivion' in content:
return 'oblivion'
elif 'starfield' in content:
return 'starfield'
elif 'enderal' in content:
return 'enderal'
else:
return 'skyrim'
except Exception as e:
logger.warning(f"Error detecting game type from ModOrganizer.ini: {e}")
return 'skyrim'
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
# Get system_info from parent screen
system_info = getattr(self, 'system_info', None)
is_steamdeck = system_info.is_steamdeck if system_info else False
shortcut_handler = ShortcutHandler(steamdeck=is_steamdeck)
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, enb_detected=False):
"""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()
# TTW workflow does NOT need ENB detection/dialog
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 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)
# Detect game type from ModOrganizer.ini
detected_game_type = self._detect_game_type_from_mo2_ini(self.context['path'])
# 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=detected_game_type,
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, enb_detected=False):
self.configuration_complete.emit(success, message, modlist_name, enb_detected)
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)
# Detect game type from ModOrganizer.ini
detected_game_type = self._detect_game_type_from_mo2_ini(self.context['path'])
# 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=detected_game_type,
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):
# Should not reach here -- manual steps already complete
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)

View File

@@ -0,0 +1,290 @@
"""TTW installer management methods for InstallTTWScreen (Mixin)."""
from pathlib import Path
from PySide6.QtCore import QTimer
import logging
import os
# Runtime imports to avoid circular dependencies
from jackify.frontends.gui.services.message_service import MessageService # Runtime import
logger = logging.getLogger(__name__)
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 TTWInstallerMixin:
"""Mixin providing TTW installer management methods for InstallTTWScreen."""
def check_requirements(self):
"""Check and display requirements status"""
from jackify.backend.handlers.path_handler import PathHandler
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_ttw_installer_status(self):
"""Check TTW_Linux_Installer installation status and update UI"""
try:
from jackify.backend.handlers.ttw_installer_handler import TTWInstallerHandler
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)
ttw_installer_handler = TTWInstallerHandler(
steamdeck=False,
verbose=False,
filesystem_handler=filesystem_handler,
config_handler=config_handler
)
# Check if TTW_Linux_Installer is installed
ttw_installer_handler._check_installation()
if ttw_installer_handler.ttw_installer_installed:
# Check version against latest
update_available, installed_v, latest_v = ttw_installer_handler.is_ttw_installer_update_available()
if update_available:
version_text = f"Out of date (v{installed_v} → v{latest_v})" if installed_v and latest_v else "Out of date"
self.ttw_installer_status.setText(version_text)
self.ttw_installer_status.setStyleSheet("color: #f44336;")
self.ttw_installer_btn.setText("Update now")
self.ttw_installer_btn.setEnabled(True)
self.ttw_installer_btn.setVisible(True)
else:
version_text = f"Ready (v{installed_v})" if installed_v else "Ready"
self.ttw_installer_status.setText(version_text)
self.ttw_installer_status.setStyleSheet("color: #3fd0ea;")
self.ttw_installer_btn.setText("Update now")
self.ttw_installer_btn.setEnabled(False) # Greyed out when ready
self.ttw_installer_btn.setVisible(True)
else:
self.ttw_installer_status.setText("Not Found")
self.ttw_installer_status.setStyleSheet("color: #f44336;")
self.ttw_installer_btn.setText("Install now")
self.ttw_installer_btn.setEnabled(True)
self.ttw_installer_btn.setVisible(True)
except Exception as e:
self.ttw_installer_status.setText("Check Failed")
self.ttw_installer_status.setStyleSheet("color: #f44336;")
self.ttw_installer_btn.setText("Install now")
self.ttw_installer_btn.setEnabled(True)
self.ttw_installer_btn.setVisible(True)
debug_print(f"DEBUG: TTW_Linux_Installer status check failed: {e}")
def install_ttw_installer(self):
"""Install or update TTW_Linux_Installer"""
# If not detected, show info dialog
try:
current_status = self.ttw_installer_status.text().strip()
except Exception:
current_status = ""
if current_status == "Not Found":
MessageService.information(
self,
"TTW_Linux_Installer Installation",
(
"TTW_Linux_Installer is a native Linux installer for TTW and other MPI packages.<br><br>"
"Project: <a href=\"https://github.com/SulfurNitride/TTW_Linux_Installer\">github.com/SulfurNitride/TTW_Linux_Installer</a><br>"
"Please star the repository and thank the developer.<br><br>"
"Jackify will now download and install the pinned TTW_Linux_Installer version (0.0.7)."
),
safety_level="low",
)
# Update button to show installation in progress
self.ttw_installer_btn.setText("Installing...")
self.ttw_installer_btn.setEnabled(False)
self.console.append("Installing/updating TTW_Linux_Installer...")
# Create background thread for installation
from PySide6.QtCore import QThread, Signal
class InstallerDownloadThread(QThread):
finished = Signal(bool, str) # success, message
progress = Signal(str) # progress message
def run(self):
try:
from jackify.backend.handlers.ttw_installer_handler import TTWInstallerHandler
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)
ttw_installer_handler = TTWInstallerHandler(
steamdeck=False,
verbose=False,
filesystem_handler=filesystem_handler,
config_handler=config_handler
)
# Install TTW_Linux_Installer (this will download and extract)
self.progress.emit("Downloading TTW_Linux_Installer...")
success, message = ttw_installer_handler.install_ttw_installer()
if success:
install_path = ttw_installer_handler.ttw_installer_dir
self.progress.emit(f"Installation complete: {install_path}")
else:
self.progress.emit(f"Installation failed: {message}")
self.finished.emit(success, message)
except Exception as e:
error_msg = f"Error installing TTW_Linux_Installer: {str(e)}"
self.progress.emit(error_msg)
debug_print(f"DEBUG: TTW_Linux_Installer installation error: {e}")
self.finished.emit(False, error_msg)
# Create and start thread
self.installer_download_thread = InstallerDownloadThread()
self.installer_download_thread.progress.connect(self._on_installer_download_progress)
self.installer_download_thread.finished.connect(self._on_installer_download_finished)
self.installer_download_thread.start()
# Update Activity window to show download in progress
self.file_progress_list.clear()
self.file_progress_list.update_or_add_item(
item_id="ttw_installer_download",
label="Downloading TTW_Linux_Installer...",
progress=0
)
def _on_installer_download_progress(self, message):
"""Handle installer download progress updates"""
self.console.append(message)
# Update Activity window based on progress message
if "Downloading" in message:
self.file_progress_list.update_or_add_item(
item_id="ttw_installer_download",
label="Downloading TTW_Linux_Installer...",
progress=0 # Indeterminate progress
)
elif "Extracting" in message or "extracting" in message.lower():
self.file_progress_list.update_or_add_item(
item_id="ttw_installer_download",
label="Extracting TTW_Linux_Installer...",
progress=50
)
elif "complete" in message.lower() or "successfully" in message.lower():
self.file_progress_list.update_or_add_item(
item_id="ttw_installer_download",
label="TTW_Linux_Installer ready",
progress=100
)
def _on_installer_download_finished(self, success, message):
"""Handle installer download completion"""
if success:
self.console.append("TTW_Linux_Installer installed successfully")
# Clear Activity window after successful installation
self.file_progress_list.clear()
# Re-check status after installation (this will update button state correctly)
self._check_ttw_installer_status()
self._update_start_button_state()
else:
self.console.append(f"Installation failed: {message}")
# Clear Activity window on failure
self.file_progress_list.clear()
# Re-enable button on failure so user can retry
self.ttw_installer_btn.setText("Install now")
self.ttw_installer_btn.setEnabled(True)
def _check_ttw_requirements(self):
"""Check TTW requirements before installation"""
from jackify.backend.handlers.path_handler import PathHandler
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 TTW_Linux_Installer using the status we already checked
status_text = self.ttw_installer_status.text()
if status_text in ("Not Found", "Check Failed"):
MessageService.warning(
self,
"TTW_Linux_Installer Required",
"TTW_Linux_Installer is required for TTW installation but is not installed.\n\nPlease install TTW_Linux_Installer 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")

View File

@@ -0,0 +1,322 @@
"""Modlist integration workflow for InstallTTWScreen (Mixin)."""
from PySide6.QtCore import QThread, Signal, Qt
from PySide6.QtWidgets import QProgressDialog, QApplication
from jackify.frontends.gui.services.message_service import MessageService
from pathlib import Path
import traceback
import os
import json
import shutil
import re
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 TTWIntegrationMixin:
"""Mixin providing modlist integration workflow for InstallTTWScreen."""
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 _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.ttw_installer_handler import TTWInstallerHandler
self.progress.emit("Integrating TTW into modlist...")
success = TTWInstallerHandler.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 progress dialog for integration
progress_dialog = QProgressDialog(
f"Integrating TTW {ttw_version} into modlist...\n\n"
"This involves copying several GB of files and may take a few minutes.\n"
"Please wait...",
None, # No cancel button
0, 0, # Indeterminate progress
self
)
progress_dialog.setWindowTitle("Integrating TTW")
progress_dialog.setMinimumDuration(0) # Show immediately
progress_dialog.setWindowModality(Qt.ApplicationModal)
progress_dialog.setCancelButton(None)
progress_dialog.show()
QApplication.processEvents()
# Store reference to close later
self._integration_progress_dialog = progress_dialog
# 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:
# Close progress dialog if it exists
if hasattr(self, '_integration_progress_dialog'):
self._integration_progress_dialog.close()
delattr(self, '_integration_progress_dialog')
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:
# Close progress dialog
if hasattr(self, '_integration_progress_dialog'):
self._integration_progress_dialog.close()
delattr(self, '_integration_progress_dialog')
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 re
from PySide6.QtCore import QThread, Signal
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
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
archive_name = f"[NoDelete] Tale of Two Wastelands{version_suffix}"
archive_path = output_dir.parent / archive_name
# Create background thread for zip creation
class ZipCreationThread(QThread):
finished = Signal(bool, str) # success, result_message
def __init__(self, output_dir, archive_path):
super().__init__()
self.output_dir = output_dir
self.archive_path = archive_path
def run(self):
try:
import shutil
final_archive = shutil.make_archive(
str(self.archive_path),
'zip',
str(self.output_dir)
)
self.finished.emit(True, str(final_archive))
except Exception as e:
self.finished.emit(False, str(e))
# Create progress dialog (non-modal so UI stays responsive)
progress_dialog = QProgressDialog(
f"Creating mod archive: {archive_name}.zip\n\n"
"This may take several minutes depending on installation size...",
"Cancel",
0, 0, # 0,0 = indeterminate progress bar
self
)
progress_dialog.setWindowTitle("Creating Archive")
progress_dialog.setMinimumDuration(0) # Show immediately
progress_dialog.setWindowModality(Qt.ApplicationModal)
progress_dialog.setCancelButton(None) # Cannot cancel zip operation safely
progress_dialog.show()
QApplication.processEvents()
# Create and start thread
zip_thread = ZipCreationThread(output_dir, archive_path)
def on_zip_finished(success, result):
progress_dialog.close()
if success:
final_archive = result
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"
)
else:
error_msg = f"Failed to create mod archive: {result}"
if not automated:
self._safe_append_text(f"\nError: {error_msg}")
MessageService.critical(self, "Archive Creation Failed", error_msg)
zip_thread.finished.connect(on_zip_finished)
zip_thread.start()
# Keep reference to prevent garbage collection
self._zip_thread = zip_thread
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

View File

@@ -0,0 +1,155 @@
"""Window lifecycle and resize handlers for InstallTTWScreen (Mixin)."""
from PySide6.QtCore import QTimer, QSize, Qt
from PySide6.QtGui import QResizeEvent
from ..utils import set_responsive_minimum
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 TTWLifecycleMixin:
"""Mixin providing window lifecycle and resize management for InstallTTWScreen."""
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 TTW_Linux_Installer status asynchronously (non-blocking) after screen opens
from PySide6.QtCore import QTimer
QTimer.singleShot(0, self._check_ttw_installer_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}")
# Fixed compact size - same as menu screens
from PySide6.QtCore import QSize
# On Steam Deck, keep fullscreen; on other systems, set normal window state
if not (hasattr(main_window, 'system_info') and main_window.system_info.is_steamdeck):
main_window.showNormal()
# First, completely unlock the window
main_window.setMinimumSize(QSize(0, 0))
main_window.setMaximumSize(QSize(16777215, 16777215))
# Only set minimum size - DO NOT RESIZE
set_responsive_minimum(main_window, min_width=960, min_height=420)
# DO NOT resize - let window stay at current 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 - restore window size constraints"""
super().hideEvent(event)
try:
main_window = self.window()
if main_window:
from PySide6.QtCore import QSize
# Clear any size constraints that might have been set to prevent affecting other screens
# Important when console is expanded
main_window.setMaximumSize(QSize(16777215, 16777215))
main_window.setMinimumSize(QSize(0, 0))
debug_print("DEBUG: Install TTW hideEvent - cleared window size constraints")
except Exception as e:
debug_print(f"DEBUG: hideEvent exception: {e}")
pass

View File

@@ -0,0 +1,298 @@
"""TTW installer requirements and validation for InstallTTWScreen (Mixin)."""
from PySide6.QtCore import QThread, Signal
from PySide6.QtWidgets import QMessageBox
from jackify.frontends.gui.services.message_service import MessageService
from pathlib import Path
import os
import requests
import traceback
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 TTWRequirementsMixin:
"""Mixin providing TTW installer requirement checking and validation for InstallTTWScreen."""
def check_requirements(self):
"""Check and display requirements status"""
from jackify.backend.handlers.path_handler import PathHandler
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_ttw_installer_status(self):
"""Check TTW_Linux_Installer installation status and update UI"""
try:
from jackify.backend.handlers.ttw_installer_handler import TTWInstallerHandler
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)
ttw_installer_handler = TTWInstallerHandler(
steamdeck=False,
verbose=False,
filesystem_handler=filesystem_handler,
config_handler=config_handler
)
# Check if TTW_Linux_Installer is installed
ttw_installer_handler._check_installation()
if ttw_installer_handler.ttw_installer_installed:
# Check version against pinned/latest
update_available, installed_v, target_v = ttw_installer_handler.is_ttw_installer_update_available()
if update_available:
# Determine if this is a downgrade or upgrade
from jackify.backend.handlers.ttw_installer_handler import TTW_INSTALLER_PINNED_VERSION
if TTW_INSTALLER_PINNED_VERSION and installed_v and target_v:
# If we have a pinned version and installed is newer, it's a downgrade
try:
# Simple version comparison - if installed version string is longer/more complex, likely newer
# For now, just check if they're different and show appropriate message
if installed_v != target_v:
version_text = f"Update to v{target_v} (currently v{installed_v})"
else:
version_text = f"Update available (v{installed_v} → v{target_v})"
except Exception:
version_text = f"Update to v{target_v}" if target_v else "Update available"
else:
# Normal update (newer version available)
version_text = f"Update available (v{installed_v} → v{target_v})" if installed_v and target_v else "Update available"
self.ttw_installer_status.setText(version_text)
self.ttw_installer_status.setStyleSheet("color: #f44336;")
self.ttw_installer_btn.setText("Update now")
self.ttw_installer_btn.setEnabled(True)
self.ttw_installer_btn.setVisible(True)
else:
version_text = f"Ready (v{installed_v})" if installed_v else "Ready"
self.ttw_installer_status.setText(version_text)
self.ttw_installer_status.setStyleSheet("color: #3fd0ea;")
self.ttw_installer_btn.setText("Update now")
self.ttw_installer_btn.setEnabled(False) # Greyed out when ready
self.ttw_installer_btn.setVisible(True)
else:
self.ttw_installer_status.setText("Not Found")
self.ttw_installer_status.setStyleSheet("color: #f44336;")
self.ttw_installer_btn.setText("Install now")
self.ttw_installer_btn.setEnabled(True)
self.ttw_installer_btn.setVisible(True)
except Exception as e:
self.ttw_installer_status.setText("Check Failed")
self.ttw_installer_status.setStyleSheet("color: #f44336;")
self.ttw_installer_btn.setText("Install now")
self.ttw_installer_btn.setEnabled(True)
self.ttw_installer_btn.setVisible(True)
debug_print(f"DEBUG: TTW_Linux_Installer status check failed: {e}")
def install_ttw_installer(self):
"""Install or update TTW_Linux_Installer"""
# If not detected, show info dialog
try:
current_status = self.ttw_installer_status.text().strip()
except Exception:
current_status = ""
if current_status == "Not Found":
MessageService.information(
self,
"TTW_Linux_Installer Installation",
(
"TTW_Linux_Installer is a native Linux installer for TTW and other MPI packages.<br><br>"
"Project: <a href=\"https://github.com/SulfurNitride/TTW_Linux_Installer\">github.com/SulfurNitride/TTW_Linux_Installer</a><br>"
"Please star the repository and thank the developer.<br><br>"
"Jackify will now download and install the latest Linux build of TTW_Linux_Installer."
),
safety_level="low",
)
# Update button to show installation in progress
self.ttw_installer_btn.setText("Installing...")
self.ttw_installer_btn.setEnabled(False)
self.console.append("Installing/updating TTW_Linux_Installer...")
# Create background thread for installation
from PySide6.QtCore import QThread, Signal
class InstallerDownloadThread(QThread):
finished = Signal(bool, str) # success, message
progress = Signal(str) # progress message
def run(self):
try:
from jackify.backend.handlers.ttw_installer_handler import TTWInstallerHandler
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)
ttw_installer_handler = TTWInstallerHandler(
steamdeck=False,
verbose=False,
filesystem_handler=filesystem_handler,
config_handler=config_handler
)
# Install TTW_Linux_Installer (this will download and extract)
self.progress.emit("Downloading TTW_Linux_Installer...")
success, message = ttw_installer_handler.install_ttw_installer()
if success:
install_path = ttw_installer_handler.ttw_installer_dir
self.progress.emit(f"Installation complete: {install_path}")
else:
self.progress.emit(f"Installation failed: {message}")
self.finished.emit(success, message)
except Exception as e:
error_msg = f"Error installing TTW_Linux_Installer: {str(e)}"
self.progress.emit(error_msg)
debug_print(f"DEBUG: TTW_Linux_Installer installation error: {e}")
self.finished.emit(False, error_msg)
# Create and start thread
self.installer_download_thread = InstallerDownloadThread()
self.installer_download_thread.progress.connect(self._on_installer_download_progress)
self.installer_download_thread.finished.connect(self._on_installer_download_finished)
self.installer_download_thread.start()
# Update Activity window to show download in progress
self.file_progress_list.clear()
self.file_progress_list.update_or_add_item(
item_id="ttw_installer_download",
label="Downloading TTW_Linux_Installer...",
progress=0
)
def _on_installer_download_progress(self, message):
"""Handle installer download progress updates"""
self.console.append(message)
# Update Activity window based on progress message
if "Downloading" in message:
self.file_progress_list.update_or_add_item(
item_id="ttw_installer_download",
label="Downloading TTW_Linux_Installer...",
progress=0 # Indeterminate progress
)
elif "Extracting" in message or "extracting" in message.lower():
self.file_progress_list.update_or_add_item(
item_id="ttw_installer_download",
label="Extracting TTW_Linux_Installer...",
progress=50
)
elif "complete" in message.lower() or "successfully" in message.lower():
self.file_progress_list.update_or_add_item(
item_id="ttw_installer_download",
label="TTW_Linux_Installer ready",
progress=100
)
def _on_installer_download_finished(self, success, message):
"""Handle installer download completion"""
if success:
self.console.append("TTW_Linux_Installer installed successfully")
# Clear Activity window after successful installation
self.file_progress_list.clear()
# Re-check status after installation (this will update button state correctly)
self._check_ttw_installer_status()
self._update_start_button_state()
else:
self.console.append(f"Installation failed: {message}")
# Clear Activity window on failure
self.file_progress_list.clear()
# Re-enable button on failure so user can retry
self.ttw_installer_btn.setText("Install now")
self.ttw_installer_btn.setEnabled(True)
def _check_ttw_requirements(self):
"""Check TTW requirements before installation"""
from jackify.backend.handlers.path_handler import PathHandler
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 TTW_Linux_Installer using the status we already checked
status_text = self.ttw_installer_status.text()
if status_text in ("Not Found", "Check Failed"):
MessageService.warning(
self,
"TTW_Linux_Installer Required",
"TTW_Linux_Installer is required for TTW installation but is not installed.\n\nPlease install TTW_Linux_Installer using the 'Install now' button."
)
return False
return True
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")

View File

@@ -0,0 +1,275 @@
"""UI helper methods for InstallTTWScreen (Mixin)."""
from PySide6.QtCore import Qt, QTimer
from PySide6.QtGui import QTextCursor, QColor
from PySide6.QtWidgets import QSizePolicy
import logging
import time
# Runtime imports to avoid circular dependencies
from ..utils import set_responsive_minimum # Runtime import
logger = logging.getLogger(__name__)
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 TTWUIMixin:
"""Mixin providing UI helper methods for InstallTTWScreen."""
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)
# On Steam Deck, keep fullscreen; on other systems, set normal window state
if not (hasattr(main_window, 'system_info') and main_window.system_info.is_steamdeck):
main_window.showNormal()
main_window.setMaximumHeight(16777215)
main_window.setMinimumHeight(0)
# 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
# Use fixed compact height for consistency across all workflow screens
compact_height = 620
# On Steam Deck, keep fullscreen; on other systems, set normal window state
if not (hasattr(main_window, 'system_info') and main_window.system_info.is_steamdeck):
main_window.showNormal()
# Set minimum height but no maximum to allow user resizing
try:
from PySide6.QtCore import QSize
set_responsive_minimum(main_window, min_width=960, min_height=compact_height)
main_window.setMaximumSize(QSize(16777215, 16777215)) # No maximum
except Exception:
pass
# Resize to compact height to avoid leftover space
current_size = main_window.size()
main_window.resize(current_size.width(), compact_height)
# Notify parent to collapse
try:
self.resize_request.emit('collapse')
except Exception:
pass
def _update_ttw_activity(self, current, total, percent):
"""Update Activity window with TTW installation progress"""
try:
# Determine current phase based on progress
if not hasattr(self, '_ttw_current_phase'):
self._ttw_current_phase = None
# Use current phase name or default
phase_name = self._ttw_current_phase or "Processing"
# Update or add activity item showing current progress with phase name and counters
# Don't include percentage in label - progress bar shows it
label = f"{phase_name}: {current:,}/{total:,}"
self.file_progress_list.update_or_add_item(
item_id="ttw_progress",
label=label,
progress=percent
)
except Exception:
pass
def _update_ttw_phase(self, phase_name, current=None, total=None, percent=0):
"""Update Activity window with current TTW installation phase and optional progress"""
try:
self._ttw_current_phase = phase_name
# Build label with phase name and counters if provided
# Don't include percentage in label - progress bar shows it
if current is not None and total is not None:
label = f"{phase_name}: {current:,}/{total:,}"
else:
label = phase_name
# Update or add activity item
self.file_progress_list.update_or_add_item(
item_id="ttw_phase",
label=label,
progress=percent
)
except Exception:
pass
def _safe_append_text(self, text, color=None):
"""Append text with professional auto-scroll behavior
Args:
text: Text to append
color: Optional HTML color code (e.g., '#f44336' for red) to format the text
"""
# 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
# Format text with color if provided
if color:
# Escape HTML special characters
escaped_text = text.replace('&', '&amp;').replace('<', '&lt;').replace('>', '&gt;')
formatted_text = f'<span style="color: {color};">{escaped_text}</span>'
# Use insertHtml for colored text (QTextEdit supports HTML in append when using RichText)
cursor = self.console.textCursor()
cursor.movePosition(QTextCursor.End)
self.console.setTextCursor(cursor)
self.console.insertHtml(formatted_text + '<br>')
else:
# Add plain 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 _update_progress_line(self, text):
"""Update progress - just append, don't try to replace (simpler and safer)"""
# Simplified: Just append progress lines instead of trying to replace
# Avoids Qt cursor SystemError
# Only show in details mode to avoid spam
if self.show_details_checkbox.isChecked():
self._safe_append_text(text)
# Always track for Activity window updates (handled separately)
self._ttw_progress_line_text = text
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")

View File

@@ -0,0 +1,368 @@
"""UI setup methods for InstallTTWScreen (Mixin)."""
from PySide6.QtWidgets import QWidget, QVBoxLayout, QLabel, QHBoxLayout, QLineEdit, QPushButton, QGridLayout, QFileDialog, QTextEdit, QSizePolicy, QCheckBox, QFrame, QTabWidget
from PySide6.QtCore import Qt, QTimer, QSize
from PySide6.QtGui import QFont
from ..shared_theme import JACKIFY_COLOR_BLUE, DEBUG_BORDERS
from jackify.backend.handlers.wabbajack_parser import WabbajackParser
from jackify.frontends.gui.widgets.file_progress_list import FileProgressList
class TTWUISetupMixin:
"""Mixin providing UI initialization for InstallTTWScreen."""
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)
# Match other workflow screens
main_overall_vbox.setContentsMargins(50, 50, 50, 0)
main_overall_vbox.setSpacing(12)
if self.debug:
self.setStyleSheet("border: 2px solid magenta;")
# --- Header (title, description) ---
header_widget = QWidget()
header_layout = QVBoxLayout()
header_layout.setContentsMargins(0, 0, 0, 0)
header_layout.setSpacing(2)
# Title
title = QLabel("<b>Install Tale of Two Wastelands (TTW)</b>")
title.setStyleSheet(f"font-size: 20px; color: {JACKIFY_COLOR_BLUE};")
title.setAlignment(Qt.AlignHCenter)
header_layout.addWidget(title)
header_layout.addSpacing(10)
# Description area with fixed height
desc = QLabel(
"This screen allows you to install Tale of Two Wastelands (TTW) using TTW_Linux_Installer. "
"Configure your options and start the installation."
)
desc.setWordWrap(True)
desc.setStyleSheet("color: #ccc; font-size: 13px;")
desc.setAlignment(Qt.AlignHCenter)
desc.setMaximumHeight(50) # Fixed height for description zone
header_layout.addWidget(desc)
header_layout.addSpacing(12)
header_widget.setLayout(header_layout)
header_widget.setFixedHeight(120) # Fixed total header height to match other screens
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: "
'<a href="https://mod.pub/ttw/133/files">https://mod.pub/ttw/133/files</a> '
"(requires a user account for ModPub)"
)
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)
# --- TTW_Linux_Installer Status aligned in form grid (row 2) ---
ttw_installer_label = QLabel("TTW_Linux_Installer Status:")
self.ttw_installer_status = QLabel("Checking...")
self.ttw_installer_btn = QPushButton("Install now")
self.ttw_installer_btn.setStyleSheet("""
QPushButton:hover { opacity: 0.95; }
QPushButton:disabled { opacity: 0.6; }
""")
self.ttw_installer_btn.setVisible(False)
self.ttw_installer_btn.clicked.connect(self.install_ttw_installer)
ttw_installer_hbox = QHBoxLayout()
ttw_installer_hbox.setContentsMargins(0, 0, 0, 0)
ttw_installer_hbox.setSpacing(8)
ttw_installer_hbox.addWidget(self.ttw_installer_status)
ttw_installer_hbox.addWidget(self.ttw_installer_btn)
ttw_installer_hbox.addStretch()
form_grid.addWidget(ttw_installer_label, 2, 0, alignment=Qt.AlignLeft | Qt.AlignVCenter)
form_grid.addLayout(ttw_installer_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)
# (TTW_Linux_Installer 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)
self.show_details_checkbox.setToolTip("Toggle between activity summary and detailed console output")
# 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)
# Checkbox placed in 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)
if self.debug:
user_config_widget.setStyleSheet("border: 2px solid orange;")
user_config_widget.setToolTip("USER_CONFIG_WIDGET")
# Right: Tabbed interface with Activity and Process Monitor
# Both tabs are always available, user can switch between them
self.file_progress_list = FileProgressList()
self.file_progress_list.setMinimumSize(QSize(300, 20))
self.file_progress_list.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
self.process_monitor = QTextEdit()
self.process_monitor.setReadOnly(True)
self.process_monitor.setTextInteractionFlags(Qt.TextSelectableByMouse | Qt.TextSelectableByKeyboard)
self.process_monitor.setMinimumSize(QSize(300, 20))
self.process_monitor.setStyleSheet(f"background: #222; color: {JACKIFY_COLOR_BLUE}; font-family: monospace; font-size: 11px; border: 1px solid #444;")
self.process_monitor_heading = QLabel("<b>[Process Monitor]</b>")
self.process_monitor_heading.setStyleSheet(f"color: {JACKIFY_COLOR_BLUE}; font-size: 13px; margin-bottom: 2px;")
self.process_monitor_heading.setAlignment(Qt.AlignLeft | Qt.AlignVCenter)
process_vbox = QVBoxLayout()
process_vbox.setContentsMargins(0, 0, 0, 0)
process_vbox.setSpacing(2)
process_vbox.addWidget(self.process_monitor_heading)
process_vbox.addWidget(self.process_monitor)
process_monitor_widget = QWidget()
process_monitor_widget.setLayout(process_vbox)
process_monitor_widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
if self.debug:
process_monitor_widget.setStyleSheet("border: 2px solid purple;")
process_monitor_widget.setToolTip("PROCESS_MONITOR")
self.process_monitor_widget = process_monitor_widget
# Create tab widget to hold both Activity and Process Monitor
self.activity_tabs = QTabWidget()
self.activity_tabs.setStyleSheet("QTabWidget::pane { background: #222; border: 1px solid #444; } QTabBar::tab { background: #222; color: #ccc; padding: 6px 16px; } QTabBar::tab:selected { background: #333; color: #3fd0ea; } QTabWidget { margin: 0px; padding: 0px; } QTabBar { margin: 0px; padding: 0px; }")
self.activity_tabs.setContentsMargins(0, 0, 0, 0)
self.activity_tabs.setDocumentMode(False)
self.activity_tabs.setTabPosition(QTabWidget.North)
if self.debug:
self.activity_tabs.setStyleSheet("border: 2px solid cyan;")
self.activity_tabs.setToolTip("ACTIVITY_TABS")
# Add both widgets as tabs
self.activity_tabs.addTab(self.file_progress_list, "Activity")
self.activity_tabs.addTab(process_monitor_widget, "Process Monitor")
upper_hbox.addWidget(user_config_widget, stretch=11)
upper_hbox.addWidget(self.activity_tabs, stretch=9)
upper_hbox.setAlignment(Qt.AlignTop)
self.upper_section_widget = QWidget()
self.upper_section_widget.setLayout(upper_hbox)
# Use Fixed size policy for consistent height
self.upper_section_widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
self.upper_section_widget.setMaximumHeight(280) # Fixed height to match other workflow screens
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)
banner_row_widget.setMaximumHeight(45) # Compact height
banner_row_widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
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 = []

View File

@@ -0,0 +1,681 @@
"""TTW installation workflow methods for InstallTTWScreen (Mixin)."""
from pathlib import Path
from PySide6.QtCore import QTimer, Qt, QThread, Signal, QProcess
from PySide6.QtWidgets import QMessageBox, QApplication
from PySide6.QtGui import QTextCursor
import logging
import os
import re
import time
import traceback
import shutil
import tempfile
# Runtime imports to avoid circular dependencies
from jackify.frontends.gui.services.message_service import MessageService # Runtime import
from jackify.backend.handlers.validation_handler import ValidationHandler # Runtime import
from jackify.frontends.gui.dialogs.warning_dialog import WarningDialog # Runtime import
from ..shared_theme import JACKIFY_COLOR_BLUE # Runtime import
from ..utils import strip_ansi_control_codes # Runtime import
logger = logging.getLogger(__name__)
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 TTWWorkflowMixin:
"""Mixin providing installation workflow methods for InstallTTWScreen."""
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
install_dir_path = Path(install_dir)
# Check for dangerous directories first (system roots, etc.)
if validation_handler.is_dangerous_directory(install_dir_path):
dlg = WarningDialog(
f"The directory '{install_dir}' is a system or user root and cannot be used for TTW installation.",
parent=self
)
if not dlg.exec() or not dlg.confirmed:
self._enable_controls_after_operation()
return
# Check if directory exists and is not empty - TTW_Linux_Installer will overwrite existing files
if install_dir_path.exists() and install_dir_path.is_dir():
# Check if directory contains any files
try:
has_files = any(install_dir_path.iterdir())
if has_files:
# Directory exists and is not empty - warn user about deletion
dlg = WarningDialog(
f"The TTW output directory already exists and contains files:\n{install_dir}\n\n"
f"All files in this directory will be deleted before installation.\n\n"
f"This action cannot be undone.",
parent=self
)
if not dlg.exec() or not dlg.confirmed:
self._enable_controls_after_operation()
return
# User confirmed - delete all contents of the directory
import shutil
try:
for item in install_dir_path.iterdir():
if item.is_dir():
shutil.rmtree(item)
else:
item.unlink()
debug_print(f"DEBUG: Deleted all contents of {install_dir}")
except Exception as e:
MessageService.critical(self, "Error", f"Failed to delete directory contents:\n{e}")
self._enable_controls_after_operation()
return
except Exception as e:
debug_print(f"DEBUG: Error checking directory contents: {e}")
# If we can't check, proceed
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}")
self._enable_controls_after_operation()
return
else:
self._enable_controls_after_operation()
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
# Refresh Proton version and winetricks settings
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...")
# Initialize Activity window with immediate feedback
self.file_progress_list.clear()
self._update_ttw_phase("Initializing TTW installation", 0, 0, 0)
# Force UI update immediately
QApplication.processEvents()
# 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_batch_received = Signal(list) # Batched output lines
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
self.output_buffer = [] # Buffer for batching output
self.last_emit_time = 0 # Track when we last emitted
def cancel(self):
self.cancelled = True
try:
if self.proc and self.proc.poll() is None:
self.proc.terminate()
except Exception:
pass
def process_and_buffer_line(self, raw_line):
"""Process line in worker thread and add to buffer"""
# Strip ANSI codes
cleaned = strip_ansi_control_codes(raw_line).strip()
# Strip emojis (do this in worker thread, not UI thread)
filtered_chars = []
for char in cleaned:
code = ord(char)
is_emoji = (
(0x1F300 <= code <= 0x1F9FF) or
(0x1F600 <= code <= 0x1F64F) or
(0x2600 <= code <= 0x26FF) or
(0x2700 <= code <= 0x27BF)
)
if not is_emoji:
filtered_chars.append(char)
cleaned = ''.join(filtered_chars).strip()
# Only buffer non-empty lines
if cleaned:
self.output_buffer.append(cleaned)
def flush_output_buffer(self):
"""Emit buffered lines as a batch"""
if self.output_buffer:
self.output_batch_received.emit(self.output_buffer[:])
self.output_buffer.clear()
self.last_emit_time = time.time()
def run(self):
try:
from jackify.backend.handlers.ttw_installer_handler import TTWInstallerHandler
from jackify.backend.handlers.filesystem_handler import FileSystemHandler
from jackify.backend.handlers.config_handler import ConfigHandler
from pathlib import Path
import tempfile
# Emit startup message
self.process_and_buffer_line("Initializing TTW installation...")
self.flush_output_buffer()
# Create backend handler
filesystem_handler = FileSystemHandler()
config_handler = ConfigHandler()
ttw_handler = TTWInstallerHandler(
steamdeck=False,
verbose=False,
filesystem_handler=filesystem_handler,
config_handler=config_handler
)
# Create temporary output file
output_file = tempfile.NamedTemporaryFile(mode='w+', delete=False, suffix='.ttw_output', encoding='utf-8')
output_file_path = Path(output_file.name)
output_file.close()
# Start installation via backend (non-blocking)
self.process_and_buffer_line("Starting TTW installation...")
self.flush_output_buffer()
self.proc, error_msg = ttw_handler.start_ttw_installation(
Path(self.mpi_path),
Path(self.install_dir),
output_file_path
)
if not self.proc:
self.installation_finished.emit(False, error_msg or "Failed to start TTW installation")
return
self.process_and_buffer_line("TTW_Linux_Installer process started, monitoring output...")
self.flush_output_buffer()
# Poll output file with batching for UI responsiveness
last_position = 0
BATCH_INTERVAL = 0.3 # Emit batches every 300ms
while self.proc.poll() is None:
if self.cancelled:
break
try:
# Read new content from file
with open(output_file_path, 'r', encoding='utf-8', errors='replace') as f:
f.seek(last_position)
new_lines = f.readlines()
last_position = f.tell()
# Process lines in worker thread (heavy work done here, not UI thread)
for line in new_lines:
if self.cancelled:
break
self.process_and_buffer_line(line.rstrip())
# Emit batch if enough time has passed
current_time = time.time()
if current_time - self.last_emit_time >= BATCH_INTERVAL:
self.flush_output_buffer()
except Exception:
pass
# Sleep longer since we're batching
time.sleep(0.1)
# Read any remaining output
try:
with open(output_file_path, 'r', encoding='utf-8', errors='replace') as f:
f.seek(last_position)
remaining_lines = f.readlines()
for line in remaining_lines:
self.process_and_buffer_line(line.rstrip())
self.flush_output_buffer()
except Exception:
pass
# Clean up
try:
output_file_path.unlink(missing_ok=True)
except Exception:
pass
ttw_handler.cleanup_ttw_process(self.proc)
# Check result
returncode = self.proc.returncode if self.proc else -1
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:
import traceback
traceback.print_exc()
self.installation_finished.emit(False, f"Installation error: {str(e)}")
# Start the installation thread
self.install_thread = TTWInstallationThread(mpi_path, install_dir)
# Use QueuedConnection to ensure signals are processed asynchronously and don't block UI
self.install_thread.output_batch_received.connect(self.on_installation_output_batch, Qt.QueuedConnection)
self.install_thread.progress_received.connect(self.on_installation_progress, Qt.QueuedConnection)
self.install_thread.installation_finished.connect(self.on_installation_finished, Qt.QueuedConnection)
# Start thread and immediately process events to show initial UI state
self.install_thread.start()
QApplication.processEvents() # Process any pending events to update UI immediately
def on_installation_output_batch(self, messages):
"""Handle batched output from TTW_Linux_Installer (already processed in worker thread)"""
# Lines are already cleaned (ANSI codes stripped, emojis removed) in worker thread
# CRITICAL: Accumulate all console updates and do ONE widget update per batch
if not hasattr(self, '_ttw_seen_lines'):
self._ttw_seen_lines = set()
self._ttw_current_phase = None
self._ttw_last_progress = 0
self._ttw_last_activity_update = 0
self.ttw_start_time = time.time()
# Accumulate lines to display (do ONE console update at end)
lines_to_display = []
html_fragments = []
show_details_due_to_error = False
latest_progress = None # Track latest progress to update activity ONCE per batch
for cleaned in messages:
if not cleaned:
continue
lower_cleaned = cleaned.lower()
# Extract progress (but don't update UI yet - wait until end of batch)
try:
progress_match = re.search(r'\[(\d+)/(\d+)\]', cleaned)
if progress_match:
current = int(progress_match.group(1))
total = int(progress_match.group(2))
percent = int((current / total) * 100) if total > 0 else 0
latest_progress = (current, total, percent)
if 'loading manifest:' in lower_cleaned:
manifest_match = re.search(r'loading manifest:\s*(\d+)/(\d+)', lower_cleaned)
if manifest_match:
current = int(manifest_match.group(1))
total = int(manifest_match.group(2))
self._ttw_current_phase = "Loading manifest"
except Exception:
pass
# Determine if we should show this line
is_error = 'error:' in lower_cleaned and 'succeeded' not in lower_cleaned and '0 failed' not in lower_cleaned
is_warning = 'warning:' in lower_cleaned
is_milestone = any(kw in lower_cleaned for kw in ['===', 'complete', 'finished', 'validation', 'configuration valid'])
is_file_op = any(ext in lower_cleaned for ext in ['.ogg', '.mp3', '.bsa', '.dds', '.nif', '.kf', '.hkx'])
# Filter out meaningless standalone messages (just "OK", etc.)
is_noise = cleaned.strip().upper() in ['OK', 'OK.', 'OK!', 'DONE', 'DONE.', 'SUCCESS', 'SUCCESS.']
should_show = (is_error or is_warning or is_milestone) or (self.show_details_checkbox.isChecked() and not is_file_op and not is_noise)
if should_show:
if is_error or is_warning:
color = '#f44336' if is_error else '#ff9800'
prefix = "WARNING: " if is_warning else "ERROR: "
escaped = (prefix + cleaned).replace('&', '&amp;').replace('<', '&lt;').replace('>', '&gt;')
html_fragments.append(f'<span style="color: {color};">{escaped}</span>')
show_details_due_to_error = True
else:
lines_to_display.append(cleaned)
# Update activity widget ONCE per batch (if progress changed significantly)
if latest_progress:
current, total, percent = latest_progress
current_time = time.time()
percent_changed = abs(percent - self._ttw_last_progress) >= 1
time_passed = (current_time - self._ttw_last_activity_update) >= 0.5 # 500ms throttle
if percent_changed or time_passed:
self._update_ttw_activity(current, total, percent)
self._ttw_last_progress = percent
self._ttw_last_activity_update = current_time
# Now do ONE console update for entire batch
if html_fragments or lines_to_display:
try:
# Update console with all accumulated output in one operation
if html_fragments:
combined_html = '<br>'.join(html_fragments)
self.console.insertHtml(combined_html + '<br>')
if lines_to_display:
combined_text = '\n'.join(lines_to_display)
self.console.append(combined_text)
if show_details_due_to_error and not self.show_details_checkbox.isChecked():
self.show_details_checkbox.setChecked(True)
except Exception:
pass
def on_installation_output(self, message):
"""Handle regular output from TTW_Linux_Installer with comprehensive filtering and smart parsing"""
# Initialize tracking structures
if not hasattr(self, '_ttw_seen_lines'):
self._ttw_seen_lines = set()
self._ttw_last_extraction_progress = 0
self._ttw_last_file_operation_time = 0
self._ttw_file_operation_count = 0
self._ttw_current_phase = None
self._ttw_last_progress_line = None
self._ttw_progress_line_text = None
# 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
cleaned = strip_ansi_control_codes(message).strip()
# Strip emojis from output (TTW_Linux_Installer includes emojis)
# Use character-by-character filtering to avoid regex recursion issues
# Safer than regex for emoji removal
filtered_chars = []
for char in cleaned:
code = ord(char)
# Check if character is in emoji ranges - skip emojis
is_emoji = (
(0x1F300 <= code <= 0x1F9FF) or # Miscellaneous Symbols and Pictographs
(0x1F600 <= code <= 0x1F64F) or # Emoticons
(0x2600 <= code <= 0x26FF) or # Miscellaneous Symbols
(0x2700 <= code <= 0x27BF) # Dingbats
)
if not is_emoji:
filtered_chars.append(char)
cleaned = ''.join(filtered_chars).strip()
# Filter out empty lines
if not cleaned:
return
# Initialize start time if not set
if not hasattr(self, 'ttw_start_time'):
self.ttw_start_time = time.time()
lower_cleaned = cleaned.lower()
# === MINIMAL PROCESSING: Match standalone behavior as closely as possible ===
# When running standalone: output goes directly to terminal, no processing
# Here: We must process each line, but do it as efficiently as possible
# Always log to file (simple, no recursion risk)
try:
self._write_to_log_file(cleaned)
except Exception:
pass
# Extract progress for Activity window (minimal regex, wrapped in try/except)
try:
# Try [X/Y] pattern
progress_match = re.search(r'\[(\d+)/(\d+)\]', cleaned)
if progress_match:
current = int(progress_match.group(1))
total = int(progress_match.group(2))
percent = int((current / total) * 100) if total > 0 else 0
phase = self._ttw_current_phase or "Processing"
self._update_ttw_activity(current, total, percent)
# Try "Loading manifest: X/Y"
if 'loading manifest:' in lower_cleaned:
manifest_match = re.search(r'loading manifest:\s*(\d+)/(\d+)', lower_cleaned)
if manifest_match:
current = int(manifest_match.group(1))
total = int(manifest_match.group(2))
percent = int((current / total) * 100) if total > 0 else 0
self._ttw_current_phase = "Loading manifest"
self._update_ttw_activity(current, total, percent)
except Exception:
pass # Skip if regex fails
# Determine if we should show this line
# By default: only show errors, warnings, milestones
# Everything else: only in details mode
is_error = 'error:' in lower_cleaned and 'succeeded' not in lower_cleaned and '0 failed' not in lower_cleaned
is_warning = 'warning:' in lower_cleaned
is_milestone = any(kw in lower_cleaned for kw in ['===', 'complete', 'finished', 'validation', 'configuration valid'])
is_file_op = any(ext in lower_cleaned for ext in ['.ogg', '.mp3', '.bsa', '.dds', '.nif', '.kf', '.hkx'])
# Filter out meaningless standalone messages (just "OK", etc.)
is_noise = cleaned.strip().upper() in ['OK', 'OK.', 'OK!', 'DONE', 'DONE.', 'SUCCESS', 'SUCCESS.']
should_show = (is_error or is_warning or is_milestone) or (self.show_details_checkbox.isChecked() and not is_file_op and not is_noise)
if should_show:
# Direct console append - no recursion, no complex processing
try:
if is_error or is_warning:
# Color code errors/warnings
color = '#f44336' if is_error else '#ff9800'
prefix = "WARNING: " if is_warning else "ERROR: "
escaped = (prefix + cleaned).replace('&', '&amp;').replace('<', '&lt;').replace('>', '&gt;')
html = f'<span style="color: {color};">{escaped}</span><br>'
self.console.insertHtml(html)
if not self.show_details_checkbox.isChecked():
self.show_details_checkbox.setChecked(True)
else:
self.console.append(cleaned)
except Exception:
pass # Don't break on console errors
return
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 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)

View File

@@ -15,8 +15,8 @@ class MainMenu(QWidget):
self.dev_mode = dev_mode
layout = QVBoxLayout()
layout.setAlignment(Qt.AlignTop | Qt.AlignHCenter)
layout.setContentsMargins(30, 30, 30, 30) # Reduced from 50
layout.setSpacing(12) # Reduced from 20
layout.setContentsMargins(30, 30, 30, 30)
layout.setSpacing(12)
# Header zone with fixed height for consistent layout across all menu screens
header_widget = QWidget()
@@ -60,7 +60,7 @@ class MainMenu(QWidget):
# Menu buttons
button_width = 400
button_height = 40 # Reduced from 50/60
button_height = 40
MENU_ITEMS = [
("Modlist Tasks", "modlist_tasks", "Manage your modlists with native Linux tools"),
("Additional Tasks", "additional_tasks", "Additional Tasks & Tools, such as TTW Installation"),
@@ -94,14 +94,14 @@ class MainMenu(QWidget):
btn_container = QWidget()
btn_layout = QVBoxLayout()
btn_layout.setContentsMargins(0, 0, 0, 0)
btn_layout.setSpacing(3) # Reduced from 4
btn_layout.setSpacing(3)
btn_layout.setAlignment(Qt.AlignHCenter)
btn_layout.addWidget(btn)
# Description label with proper alignment
desc_label = QLabel(description)
desc_label.setAlignment(Qt.AlignHCenter)
desc_label.setStyleSheet("color: #999; font-size: 11px;") # Reduced from 12px
desc_label.setStyleSheet("color: #999; font-size: 11px;")
desc_label.setWordWrap(True)
desc_label.setFixedWidth(button_width)
btn_layout.addWidget(desc_label)
@@ -110,7 +110,7 @@ class MainMenu(QWidget):
layout.addWidget(btn_container)
# Disclaimer
layout.addSpacing(12) # Reduced from 20
layout.addSpacing(12)
disclaimer = QLabel(DISCLAIMER_TEXT)
disclaimer.setWordWrap(True)
disclaimer.setAlignment(Qt.AlignCenter)
@@ -151,7 +151,6 @@ class MainMenu(QWidget):
elif action_id == "additional_tasks" and self.stacked_widget:
self.stacked_widget.setCurrentIndex(3)
elif action_id == "return_main_menu":
# This is the main menu, so do nothing
pass
elif self.stacked_widget:
self.stacked_widget.setCurrentIndex(1) # Default to placeholder

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,208 @@
"""Visual card representing a single modlist."""
from PySide6.QtWidgets import QFrame, QVBoxLayout, QHBoxLayout, QLabel, QSizePolicy
from PySide6.QtCore import Qt, Signal, QSize
from PySide6.QtGui import QPixmap, QPainter, QColor, QFont
from jackify.backend.models.modlist_metadata import ModlistMetadata
from ..shared_theme import JACKIFY_COLOR_BLUE
from .modlist_gallery_image_manager import ImageManager
class ModlistCard(QFrame):
"""Visual card representing a single modlist"""
clicked = Signal(ModlistMetadata)
def __init__(self, metadata: ModlistMetadata, image_manager: ImageManager, is_steamdeck: bool = False):
super().__init__()
self.metadata = metadata
self.image_manager = image_manager
self.is_steamdeck = is_steamdeck
self._setup_ui()
def _setup_ui(self):
"""Set up the card UI"""
self.setFrameShape(QFrame.StyledPanel)
self.setFrameShadow(QFrame.Raised)
self.setCursor(Qt.PointingHandCursor)
# Steam Deck-specific sizing (1280x800 screen)
if self.is_steamdeck:
self.setFixedSize(250, 270) # Smaller cards for Steam Deck
image_width, image_height = 230, 130 # Smaller images, maintaining 16:9 ratio
else:
self.setFixedSize(300, 320) # Standard size
image_width, image_height = 280, 158 # Standard image size
self.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed)
layout = QVBoxLayout()
layout.setContentsMargins(10, 8, 10, 8) # Reduced vertical margins
layout.setSpacing(6) # Reduced spacing between elements
# Image (widescreen aspect ratio like Wabbajack)
self.image_label = QLabel()
self.image_label.setFixedSize(image_width, image_height) # 16:9 aspect ratio
self.image_label.setStyleSheet("background: #333; border-radius: 4px;")
self.image_label.setAlignment(Qt.AlignCenter)
self.image_label.setScaledContents(True) # Use Qt's automatic scaling - this works best
self.image_label.setText("")
layout.addWidget(self.image_label)
# Title row with badges (Official, NSFW, UNAVAILABLE)
title_row = QHBoxLayout()
title_row.setSpacing(4)
title = QLabel(self.metadata.title)
title.setWordWrap(True)
title.setFont(QFont("Sans", 12, QFont.Bold))
title.setStyleSheet(f"color: {JACKIFY_COLOR_BLUE};")
title.setMaximumHeight(40)
title_row.addWidget(title, stretch=1)
# Store reference to unavailable badge for dynamic updates
self.unavailable_badge = None
if not self.metadata.is_available():
self.unavailable_badge = QLabel("UNAVAILABLE")
self.unavailable_badge.setStyleSheet("background: #666; color: white; padding: 2px 6px; font-size: 9px; border-radius: 3px;")
self.unavailable_badge.setFixedHeight(20)
title_row.addWidget(self.unavailable_badge, alignment=Qt.AlignTop | Qt.AlignRight)
if self.metadata.official:
official_badge = QLabel("OFFICIAL")
official_badge.setStyleSheet("background: #2a5; color: white; padding: 2px 6px; font-size: 9px; border-radius: 3px;")
official_badge.setFixedHeight(20)
title_row.addWidget(official_badge, alignment=Qt.AlignTop | Qt.AlignRight)
if self.metadata.nsfw:
nsfw_badge = QLabel("NSFW")
nsfw_badge.setStyleSheet("background: #d44; color: white; padding: 2px 6px; font-size: 9px; border-radius: 3px;")
nsfw_badge.setFixedHeight(20)
title_row.addWidget(nsfw_badge, alignment=Qt.AlignTop | Qt.AlignRight)
layout.addLayout(title_row)
# Author
author = QLabel(f"by {self.metadata.author}")
author.setStyleSheet("color: #aaa; font-size: 11px;")
layout.addWidget(author)
# Game
game = QLabel(self.metadata.gameHumanFriendly)
game.setStyleSheet("color: #ccc; font-size: 10px;")
layout.addWidget(game)
# Sizes (Download, Install, Total)
if self.metadata.sizes:
size_info = QLabel(
f"Download: {self.metadata.sizes.downloadSizeFormatted} | "
f"Install: {self.metadata.sizes.installSizeFormatted} | "
f"Total: {self.metadata.sizes.totalSizeFormatted}"
)
size_info.setStyleSheet("color: #999; font-size: 10px;")
size_info.setWordWrap(True) # Allow wrapping if text is too long
layout.addWidget(size_info)
# Removed addStretch() to eliminate wasted space
self.setLayout(layout)
# Load image
self._load_image()
def _create_placeholder(self):
"""Create a placeholder pixmap for cards without images"""
# Create placeholder matching the image label size (Steam Deck or standard)
image_size = self.image_label.size()
placeholder = QPixmap(image_size)
placeholder.fill(QColor("#333"))
# Draw a simple icon/text on the placeholder
painter = QPainter(placeholder)
painter.setPen(QColor("#666"))
painter.setFont(QFont("Sans", 10))
painter.drawText(placeholder.rect(), Qt.AlignCenter, "No Image")
painter.end()
# Show placeholder immediately
self.image_label.setPixmap(placeholder)
def _load_image(self):
"""Load image using centralized image manager - use large images and scale down for quality"""
# Get large image for card - scale down for better quality than small images
pixmap = self.image_manager.get_image(self.metadata, self._on_image_loaded, size="large")
if pixmap and not pixmap.isNull():
# Image was in cache - display immediately (should be instant)
self._display_image(pixmap)
else:
# Image needs to be downloaded - show placeholder
self._create_placeholder()
def _on_image_loaded(self, pixmap: QPixmap):
"""Callback when image is loaded from network"""
if pixmap and not pixmap.isNull():
self._display_image(pixmap)
def _display_image(self, pixmap: QPixmap):
"""Display image - use best method based on aspect ratio"""
if pixmap.isNull():
return
label_size = self.image_label.size()
label_aspect = label_size.width() / label_size.height() # 16:9 = ~1.778
# Calculate image aspect ratio
image_aspect = pixmap.width() / pixmap.height() if pixmap.height() > 0 else label_aspect
# If aspect ratios are close (within 5%), use Qt's automatic scaling for best quality
# Otherwise, manually scale with cropping to avoid stretching
aspect_diff = abs(image_aspect - label_aspect) / label_aspect
if aspect_diff < 0.05: # Within 5% of 16:9
# Close to correct aspect - use Qt's automatic scaling (best quality)
self.image_label.setScaledContents(True)
self.image_label.setPixmap(pixmap)
else:
# Different aspect - manually scale with cropping (no stretching)
self.image_label.setScaledContents(False)
scaled_pixmap = pixmap.scaled(
label_size.width(),
label_size.height(),
Qt.KeepAspectRatioByExpanding, # Crop instead of stretch
Qt.SmoothTransformation # High quality
)
self.image_label.setPixmap(scaled_pixmap)
def _update_availability_badge(self):
"""Update unavailable badge visibility based on current availability status"""
is_unavailable = not self.metadata.is_available()
# Find title row layout (it's the 2nd layout item: image at 0, title_row at 1)
main_layout = self.layout()
if main_layout and main_layout.count() >= 2:
title_row = main_layout.itemAt(1).layout()
if title_row:
if is_unavailable and self.unavailable_badge is None:
# Need to add badge to title row (before Official/NSFW badges)
self.unavailable_badge = QLabel("UNAVAILABLE")
self.unavailable_badge.setStyleSheet("background: #666; color: white; padding: 2px 6px; font-size: 9px; border-radius: 3px;")
self.unavailable_badge.setFixedHeight(20)
# Insert after title (index 1) but before other badges
# Find first badge position (if any exist)
insert_index = 1 # After title widget
for i in range(title_row.count()):
item = title_row.itemAt(i)
if item and item.widget() and isinstance(item.widget(), QLabel):
widget_text = item.widget().text()
if widget_text in ("OFFICIAL", "NSFW"):
insert_index = i
break
title_row.insertWidget(insert_index, self.unavailable_badge, alignment=Qt.AlignTop | Qt.AlignRight)
elif not is_unavailable and self.unavailable_badge is not None:
# Need to remove badge from title row
title_row.removeWidget(self.unavailable_badge)
self.unavailable_badge.setParent(None)
self.unavailable_badge = None
def mousePressEvent(self, event):
"""Handle click on card"""
if event.button() == Qt.LeftButton:
self.clicked.emit(self.metadata)
super().mousePressEvent(event)

View File

@@ -0,0 +1,451 @@
"""Detailed view of a modlist with install option."""
from PySide6.QtWidgets import (
QDialog, QWidget, QVBoxLayout, QHBoxLayout, QLabel, QPushButton,
QFrame, QTextEdit, QSizePolicy
)
from PySide6.QtCore import Qt, Signal, QSize
from PySide6.QtGui import QPixmap, QFont, QPainter, QColor
from jackify.backend.models.modlist_metadata import ModlistMetadata
from ..shared_theme import JACKIFY_COLOR_BLUE
from ..utils import get_screen_geometry, set_responsive_minimum
from .modlist_gallery_image_manager import ImageManager
class ModlistDetailDialog(QDialog):
"""Detailed view of a modlist with install option"""
install_requested = Signal(ModlistMetadata)
def __init__(self, metadata: ModlistMetadata, image_manager: ImageManager, parent=None):
super().__init__(parent)
self.metadata = metadata
self.image_manager = image_manager
self.setWindowTitle(metadata.title)
set_responsive_minimum(self, min_width=900, min_height=640)
self._apply_initial_size()
self._setup_ui()
def _apply_initial_size(self):
"""Ensure dialog size fits current screen."""
_, _, screen_width, screen_height = get_screen_geometry(self)
width = 1000
height = 760
if screen_width:
width = min(width, max(880, screen_width - 40))
if screen_height:
height = min(height, max(640, screen_height - 40))
self.resize(width, height)
def _setup_ui(self):
"""Set up detail dialog UI with modern layout matching Wabbajack style"""
main_layout = QVBoxLayout()
main_layout.setContentsMargins(0, 0, 0, 0)
main_layout.setSpacing(0)
# --- Banner area with full-width text overlay ---
# Container so we can place a semi-opaque text panel over the banner image
banner_container = QFrame()
banner_container.setFrameShape(QFrame.NoFrame)
banner_container.setStyleSheet("background: #000; border: none;")
banner_layout = QVBoxLayout()
banner_layout.setContentsMargins(0, 0, 0, 0)
banner_layout.setSpacing(0)
banner_container.setLayout(banner_layout)
# Banner image at top with 16:9 aspect ratio (like Wabbajack)
self.banner_label = QLabel()
# Height will be calculated based on width to maintain 16:9 ratio
self.banner_label.setMinimumHeight(200)
self.banner_label.setStyleSheet("background: #1a1a1a; border: none;")
self.banner_label.setAlignment(Qt.AlignCenter)
self.banner_label.setText("Loading image...")
banner_layout.addWidget(self.banner_label)
# Full-width transparent container with opaque card inside (only as wide as text)
overlay_container = QWidget()
overlay_container.setStyleSheet("background: transparent;")
overlay_layout = QHBoxLayout()
overlay_layout.setContentsMargins(24, 0, 24, 24)
overlay_layout.setSpacing(0)
overlay_container.setLayout(overlay_layout)
# Opaque text card - only as wide as content needs (where red lines are)
self.banner_text_panel = QFrame()
self.banner_text_panel.setFrameShape(QFrame.StyledPanel)
# Opaque background, rounded corners, sized to content only
self.banner_text_panel.setStyleSheet("""
QFrame {
background-color: rgba(0, 0, 0, 180);
border: 1px solid rgba(255, 255, 255, 30);
border-radius: 8px;
}
""")
self.banner_text_panel.setSizePolicy(QSizePolicy.Maximum, QSizePolicy.Maximum)
banner_text_layout = QVBoxLayout()
banner_text_layout.setContentsMargins(20, 12, 20, 14)
banner_text_layout.setSpacing(6)
self.banner_text_panel.setLayout(banner_text_layout)
# Add card to container (left-aligned, rest stays transparent)
overlay_layout.addWidget(self.banner_text_panel, alignment=Qt.AlignBottom | Qt.AlignLeft)
overlay_layout.addStretch() # Push card left, rest transparent
# Title only (badges moved to tags section below)
title = QLabel(self.metadata.title)
title.setFont(QFont("Sans", 24, QFont.Bold))
title.setStyleSheet(f"color: {JACKIFY_COLOR_BLUE};")
title.setWordWrap(True)
banner_text_layout.addWidget(title)
# Only sizes in overlay (minimal info on image)
if self.metadata.sizes:
sizes_text = (
f"<span style='color: #aaa;'>Download:</span> {self.metadata.sizes.downloadSizeFormatted}"
f"<span style='color: #aaa;'>Install:</span> {self.metadata.sizes.installSizeFormatted}"
f"<span style='color: #aaa;'>Total:</span> {self.metadata.sizes.totalSizeFormatted}"
)
sizes_label = QLabel(sizes_text)
sizes_label.setStyleSheet("color: #fff; font-size: 13px;")
banner_text_layout.addWidget(sizes_label)
# Add full-width transparent container at bottom of banner
banner_layout.addWidget(overlay_container, alignment=Qt.AlignBottom)
main_layout.addWidget(banner_container)
# Content area with padding (tags + description + bottom bar)
content_widget = QWidget()
content_layout = QVBoxLayout()
content_layout.setContentsMargins(24, 20, 24, 20)
content_layout.setSpacing(16)
content_widget.setLayout(content_layout)
# Metadata line (version, author, game) - moved below image
metadata_line_parts = []
if self.metadata.version:
metadata_line_parts.append(f"<span style='color: #aaa;'>version</span> {self.metadata.version}")
metadata_line_parts.append(f"<span style='color: #aaa;'>by</span> {self.metadata.author}")
metadata_line_parts.append(f"<span style='color: #aaa;'>•</span> {self.metadata.gameHumanFriendly}")
if self.metadata.maintainers and len(self.metadata.maintainers) > 0:
maintainers_text = ", ".join(self.metadata.maintainers)
if maintainers_text != self.metadata.author: # Only show if different from author
metadata_line_parts.append(f"<span style='color: #aaa;'>•</span> Maintained by {maintainers_text}")
metadata_line = QLabel(" ".join(metadata_line_parts))
metadata_line.setStyleSheet("color: #fff; font-size: 14px;")
metadata_line.setWordWrap(True)
content_layout.addWidget(metadata_line)
# Tags row (includes status badges moved from overlay)
tags_layout = QHBoxLayout()
tags_layout.setSpacing(6)
tags_layout.setContentsMargins(0, 0, 0, 0)
# Add status badges first (UNAVAILABLE, Unofficial)
if not self.metadata.is_available():
unavailable_badge = QLabel("UNAVAILABLE")
unavailable_badge.setStyleSheet("background: #666; color: white; padding: 6px 12px; font-size: 11px; border-radius: 4px;")
tags_layout.addWidget(unavailable_badge)
if not self.metadata.official:
unofficial_badge = QLabel("Unofficial")
unofficial_badge.setStyleSheet("background: #666; color: white; padding: 6px 12px; font-size: 11px; border-radius: 4px;")
tags_layout.addWidget(unofficial_badge)
# Add regular tags
tags_to_render = getattr(self.metadata, 'normalized_tags_display', self.metadata.tags or [])
if tags_to_render:
for tag in tags_to_render:
tag_badge = QLabel(tag)
# Match Wabbajack tag styling
if tag.lower() == "nsfw":
tag_badge.setStyleSheet("background: #d44; color: white; padding: 6px 12px; font-size: 11px; border-radius: 4px;")
elif tag.lower() == "official" or tag.lower() == "featured":
tag_badge.setStyleSheet("background: #2a5; color: white; padding: 6px 12px; font-size: 11px; border-radius: 4px;")
else:
tag_badge.setStyleSheet("background: #3a3a3a; color: #ccc; padding: 6px 12px; font-size: 11px; border-radius: 4px;")
tags_layout.addWidget(tag_badge)
tags_layout.addStretch()
content_layout.addLayout(tags_layout)
# Description section
desc_label = QLabel("<b style='color: #aaa; font-size: 14px;'>Description:</b>")
content_layout.addWidget(desc_label)
# Use QTextEdit with explicit line counting to force scrollbar
self.desc_text = QTextEdit()
self.desc_text.setReadOnly(True)
self.desc_text.setPlainText(self.metadata.description or "No description provided.")
# Compact description area; scroll when content is long
self.desc_text.setFixedHeight(120)
self.desc_text.setVerticalScrollBarPolicy(Qt.ScrollBarAsNeeded)
self.desc_text.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
self.desc_text.setLineWrapMode(QTextEdit.WidgetWidth)
self.desc_text.setStyleSheet("""
QTextEdit {
background: #2a2a2a;
color: #fff;
border: none;
border-radius: 6px;
padding: 12px;
}
""")
content_layout.addWidget(self.desc_text)
main_layout.addWidget(content_widget)
# Bottom bar with Links (left) and Action buttons (right)
bottom_bar = QHBoxLayout()
bottom_bar.setContentsMargins(24, 16, 24, 24)
bottom_bar.setSpacing(12)
# Links section on the left
links_layout = QHBoxLayout()
links_layout.setSpacing(10)
if self.metadata.links and (self.metadata.links.discordURL or self.metadata.links.websiteURL or self.metadata.links.readme):
links_label = QLabel("<b style='color: #aaa; font-size: 14px;'>Links:</b>")
links_layout.addWidget(links_label)
if self.metadata.links.discordURL:
discord_btn = QPushButton("Discord")
discord_btn.setStyleSheet("""
QPushButton {
background: #5865F2;
color: white;
padding: 8px 16px;
border: none;
border-radius: 6px;
font-weight: bold;
font-size: 12px;
}
QPushButton:hover {
background: #4752C4;
}
QPushButton:pressed {
background: #3C45A5;
}
""")
discord_btn.clicked.connect(lambda: self._open_url(self.metadata.links.discordURL))
links_layout.addWidget(discord_btn)
if self.metadata.links.websiteURL:
website_btn = QPushButton("Website")
website_btn.setStyleSheet("""
QPushButton {
background: #3a3a3a;
color: white;
padding: 8px 16px;
border: none;
border-radius: 6px;
font-weight: bold;
font-size: 12px;
}
QPushButton:hover {
background: #4a4a4a;
}
QPushButton:pressed {
background: #2a2a2a;
}
""")
website_btn.clicked.connect(lambda: self._open_url(self.metadata.links.websiteURL))
links_layout.addWidget(website_btn)
if self.metadata.links.readme:
readme_btn = QPushButton("Readme")
readme_btn.setStyleSheet("""
QPushButton {
background: #3a3a3a;
color: white;
padding: 8px 16px;
border: none;
border-radius: 6px;
font-weight: bold;
font-size: 12px;
}
QPushButton:hover {
background: #4a4a4a;
}
QPushButton:pressed {
background: #2a2a2a;
}
""")
readme_url = self._convert_raw_github_url(self.metadata.links.readme)
readme_btn.clicked.connect(lambda: self._open_url(readme_url))
links_layout.addWidget(readme_btn)
bottom_bar.addLayout(links_layout)
bottom_bar.addStretch()
# Action buttons on the right
cancel_btn = QPushButton("Close")
cancel_btn.setStyleSheet("""
QPushButton {
background: #3a3a3a;
color: white;
padding: 8px 16px;
border: none;
border-radius: 6px;
font-weight: bold;
font-size: 12px;
}
QPushButton:hover {
background: #4a4a4a;
}
QPushButton:pressed {
background: #2a2a2a;
}
""")
cancel_btn.clicked.connect(self.reject)
bottom_bar.addWidget(cancel_btn)
install_btn = QPushButton("Install Modlist")
install_btn.setDefault(True)
if not self.metadata.is_available():
install_btn.setEnabled(False)
install_btn.setToolTip("This modlist is currently unavailable")
install_btn.setStyleSheet("""
QPushButton {
background: #555;
color: #999;
padding: 8px 16px;
border: none;
border-radius: 6px;
font-weight: bold;
font-size: 12px;
}
""")
else:
install_btn.setStyleSheet(f"""
QPushButton {{
background: {JACKIFY_COLOR_BLUE};
color: white;
padding: 8px 16px;
border: none;
border-radius: 6px;
font-weight: bold;
font-size: 12px;
}}
QPushButton:hover {{
background: #4a9eff;
}}
QPushButton:pressed {{
background: #3a8eef;
}}
""")
install_btn.clicked.connect(self._on_install_clicked)
bottom_bar.addWidget(install_btn)
main_layout.addLayout(bottom_bar)
self.setLayout(main_layout)
# Load banner image
self._load_banner_image()
def _load_banner_image(self):
"""Load large banner image for detail view"""
if not self.metadata.images or not self.metadata.images.large:
self.banner_label.setText("No image available")
self.banner_label.setStyleSheet("background: #1a1a1a; color: #666; border: none;")
return
# Try to get large image from cache or download (for detail view banner)
pixmap = self.image_manager.get_image(self.metadata, self._on_banner_loaded, size="large")
if pixmap and not pixmap.isNull():
# Image was in cache - display immediately
self._display_banner(pixmap)
else:
# Show placeholder while downloading
placeholder = QPixmap(self.banner_label.size())
placeholder.fill(QColor("#1a1a1a"))
painter = QPainter(placeholder)
painter.setPen(QColor("#666"))
painter.setFont(QFont("Sans", 12))
painter.drawText(placeholder.rect(), Qt.AlignCenter, "Loading image...")
painter.end()
self.banner_label.setPixmap(placeholder)
def _on_banner_loaded(self, pixmap: QPixmap):
"""Callback when banner image is loaded"""
if pixmap and not pixmap.isNull():
self._display_banner(pixmap)
def resizeEvent(self, event):
"""Handle dialog resize to maintain 16:9 aspect ratio for banner"""
super().resizeEvent(event)
# Update banner height to maintain 16:9 aspect ratio
if hasattr(self, 'banner_label'):
width = self.width()
height = int(width / 16 * 9) # 16:9 aspect ratio
self.banner_label.setFixedHeight(height)
# Redisplay image if we have one
if hasattr(self, '_current_banner_pixmap'):
self._display_banner(self._current_banner_pixmap)
def _display_banner(self, pixmap: QPixmap):
"""Display banner image with proper 16:9 aspect ratio (like Wabbajack)"""
# Store pixmap for resize events
self._current_banner_pixmap = pixmap
# Calculate 16:9 aspect ratio height
width = self.width() if self.width() > 0 else 1000
target_height = int(width / 16 * 9)
self.banner_label.setFixedHeight(target_height)
# Scale image to fill width while maintaining aspect ratio (UniformToFill behavior)
# Crops if needed, no stretch
scaled_pixmap = pixmap.scaled(
width,
target_height,
Qt.KeepAspectRatioByExpanding, # Fill the area, cropping if needed
Qt.SmoothTransformation
)
self.banner_label.setPixmap(scaled_pixmap)
self.banner_label.setText("")
def _convert_raw_github_url(self, url: str) -> str:
"""Convert raw GitHub URLs to rendered blob URLs for better user experience"""
if not url:
return url
if "raw.githubusercontent.com" in url:
url = url.replace("raw.githubusercontent.com", "github.com")
url = url.replace("/master/", "/blob/master/")
url = url.replace("/main/", "/blob/main/")
return url
def _on_install_clicked(self):
"""Handle install button click"""
self.install_requested.emit(self.metadata)
self.accept()
def _open_url(self, url: str):
"""Open URL with clean environment to avoid AppImage library conflicts."""
import subprocess
import os
env = os.environ.copy()
# Remove AppImage-specific environment variables
appimage_vars = [
'LD_LIBRARY_PATH',
'PYTHONPATH',
'PYTHONHOME',
'QT_PLUGIN_PATH',
'QML2_IMPORT_PATH',
]
if 'APPIMAGE' in env or 'APPDIR' in env:
for var in appimage_vars:
if var in env:
del env[var]
subprocess.Popen(
['xdg-open', url],
env=env,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
start_new_session=True
)

View File

@@ -0,0 +1,302 @@
"""Filter management for ModlistGalleryDialog (Mixin)."""
from PySide6.QtWidgets import QWidget, QFrame, QVBoxLayout, QLabel, QLineEdit, QComboBox, QCheckBox, QListWidget, QPushButton
from PySide6.QtCore import Qt
from typing import List
from jackify.backend.models.modlist_metadata import ModlistMetadata
from ..shared_theme import JACKIFY_COLOR_BLUE
class ModlistGalleryFiltersMixin:
"""Mixin providing filter management for ModlistGalleryDialog."""
def _create_filter_panel(self) -> QWidget:
"""Create filter sidebar"""
panel = QFrame()
panel.setFrameShape(QFrame.StyledPanel)
panel.setFixedWidth(280) # Slightly wider for better readability
layout = QVBoxLayout()
layout.setSpacing(6)
# Title
title = QLabel("<b>Filters</b>")
title.setStyleSheet(f"font-size: 14px; color: {JACKIFY_COLOR_BLUE};")
layout.addWidget(title)
# Search box (label removed - placeholder text is clear enough)
self.search_box = QLineEdit()
self.search_box.setPlaceholderText("Search modlists...")
self.search_box.setStyleSheet("QLineEdit { background: #2a2a2a; color: #fff; border: 1px solid #555; padding: 4px; }")
self.search_box.textChanged.connect(self._apply_filters)
layout.addWidget(self.search_box)
# Game filter (label removed - combo box is self-explanatory)
self.game_combo = QComboBox()
self.game_combo.addItem("All Games", None)
self.game_combo.currentIndexChanged.connect(self._apply_filters)
layout.addWidget(self.game_combo)
# Status filters
self.show_official_only = QCheckBox("Show Official Only")
self.show_official_only.stateChanged.connect(self._apply_filters)
layout.addWidget(self.show_official_only)
self.show_nsfw = QCheckBox("Show NSFW")
self.show_nsfw.stateChanged.connect(self._on_nsfw_toggled)
layout.addWidget(self.show_nsfw)
self.hide_unavailable = QCheckBox("Hide Unavailable")
self.hide_unavailable.setChecked(True)
self.hide_unavailable.stateChanged.connect(self._apply_filters)
layout.addWidget(self.hide_unavailable)
# Tag filter
tags_label = QLabel("Tags:")
layout.addWidget(tags_label)
self.tags_list = QListWidget()
self.tags_list.setSelectionMode(QListWidget.MultiSelection)
self.tags_list.setMaximumHeight(150)
self.tags_list.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) # Remove horizontal scrollbar
self.tags_list.setStyleSheet("QListWidget { background: #2a2a2a; color: #fff; border: 1px solid #555; }")
self.tags_list.itemSelectionChanged.connect(self._apply_filters)
layout.addWidget(self.tags_list)
# Add spacing between Tags and Mods sections
layout.addSpacing(8)
# DISABLED: Mod search feature temporarily disabled due to search index issue
# Re-enable after indexing bug is resolved
# The mod search UI allowed filtering modlists by individual mod names
# Disabled in v0.2.0.8 - planned for re-enabling in future release
# mods_label = QLabel("Mods:")
# layout.addWidget(mods_label)
#
# self.mod_search = QLineEdit()
# self.mod_search.setPlaceholderText("Search mods...")
# self.mod_search.setStyleSheet("QLineEdit { background: #2a2a2a; color: #fff; border: 1px solid #555; padding: 4px; }")
# self.mod_search.textChanged.connect(self._filter_mods_list)
# # Prevent Enter from triggering default button (which would close dialog)
# self.mod_search.returnPressed.connect(lambda: self.mod_search.clearFocus())
# layout.addWidget(self.mod_search)
#
# self.mods_list = QListWidget()
# self.mods_list.setSelectionMode(QListWidget.MultiSelection)
# self.mods_list.setMaximumHeight(150)
# self.mods_list.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) # Remove horizontal scrollbar
# self.mods_list.setStyleSheet("QListWidget { background: #2a2a2a; color: #fff; border: 1px solid #555; }")
# self.mods_list.itemSelectionChanged.connect(self._apply_filters)
# layout.addWidget(self.mods_list)
#
# self.all_mods_list = [] # Store all mods for filtering
layout.addStretch()
# Cancel button (not default to prevent Enter from closing)
cancel_btn = QPushButton("Cancel")
cancel_btn.setDefault(False)
cancel_btn.setAutoDefault(False)
cancel_btn.clicked.connect(self.reject)
layout.addWidget(cancel_btn)
panel.setLayout(layout)
return panel
def _populate_tag_filter(self):
"""Populate tag filter with normalized tags (like Wabbajack)"""
normalized_tags = set()
for modlist in self.all_modlists:
display_tags = getattr(modlist, 'normalized_tags_display', None)
if display_tags is None:
display_tags = self.gallery_service.normalize_tags_for_display(getattr(modlist, 'tags', []))
modlist.normalized_tags_display = display_tags
modlist.normalized_tags_keys = [tag.lower() for tag in display_tags]
normalized_tags.update(display_tags)
# Add special tags (like Wabbajack)
normalized_tags.add("NSFW")
normalized_tags.add("Featured") # Official
normalized_tags.add("Unavailable")
self.tags_list.clear()
for tag in sorted(normalized_tags):
self.tags_list.addItem(tag)
def _get_normalized_tag_display(self, modlist: ModlistMetadata) -> List[str]:
"""Return (and cache) normalized tags for display for a modlist."""
display_tags = getattr(modlist, 'normalized_tags_display', None)
if display_tags is None:
display_tags = self.gallery_service.normalize_tags_for_display(getattr(modlist, 'tags', []))
modlist.normalized_tags_display = display_tags
modlist.normalized_tags_keys = [tag.lower() for tag in display_tags]
return display_tags
def _get_normalized_tag_keys(self, modlist: ModlistMetadata) -> List[str]:
"""Return (and cache) lowercase normalized tags for filtering."""
keys = getattr(modlist, 'normalized_tags_keys', None)
if keys is None:
display_tags = self._get_normalized_tag_display(modlist)
keys = [tag.lower() for tag in display_tags]
modlist.normalized_tags_keys = keys
return keys
def _tag_in_modlist(self, modlist: ModlistMetadata, normalized_tag_key: str) -> bool:
"""Check if a normalized (lowercase) tag is present on a modlist."""
keys = self._get_normalized_tag_keys(modlist)
return any(key == normalized_tag_key for key in keys)
def _populate_mod_filter(self):
"""Populate mod filter with all available mods from search index"""
# TEMPORARILY DISABLED - mod filter feature removed in v0.2.0.8
return
# all_mods = set()
# # Track which mods come from NSFW modlists only
# mods_from_nsfw_only = set()
# mods_from_sfw = set()
# modlists_with_mods = 0
#
# for modlist in self.all_modlists:
# if hasattr(modlist, 'mods') and modlist.mods:
# modlists_with_mods += 1
# for mod in modlist.mods:
# all_mods.add(mod)
# if modlist.nsfw:
# mods_from_nsfw_only.add(mod)
# else:
# mods_from_sfw.add(mod)
#
# # Mods that are ONLY in NSFW modlists (not in any SFW modlists)
# self.nsfw_only_mods = mods_from_nsfw_only - mods_from_sfw
#
# self.all_mods_list = sorted(all_mods)
#
# self._filter_mods_list("") # Populate with all mods initially
def _filter_mods_list(self, search_text: str = ""):
"""Filter the mods list based on search text and NSFW checkbox"""
# TEMPORARILY DISABLED - mod filter feature removed in v0.2.0.8
return
# Get search text from the widget if not provided
# if not search_text and hasattr(self, 'mod_search'):
# search_text = self.mod_search.text()
#
# self.mods_list.clear()
# search_lower = search_text.lower().strip()
#
# # Start with all mods or filtered by search
# if search_lower:
# filtered_mods = [m for m in self.all_mods_list if search_lower in m.lower()]
# else:
# filtered_mods = self.all_mods_list
#
# # Filter out NSFW-only mods if NSFW checkbox is not checked
# if not self.show_nsfw.isChecked():
# filtered_mods = [m for m in filtered_mods if m not in getattr(self, 'nsfw_only_mods', set())]
#
# # Limit to first 500 results for performance
# for mod in filtered_mods[:500]:
# self.mods_list.addItem(mod)
#
# if len(filtered_mods) > 500:
# self.mods_list.addItem(f"... and {len(filtered_mods) - 500} more (refine search)")
def _on_nsfw_toggled(self, checked: bool):
"""Handle NSFW checkbox toggle - refresh mod list and apply filters"""
# self._filter_mods_list() # TEMPORARILY DISABLED - Refresh mod list based on NSFW state
self._apply_filters() # Apply all filters
def _set_filter_controls_enabled(self, enabled: bool):
"""Enable or disable all filter controls"""
self.search_box.setEnabled(enabled)
self.game_combo.setEnabled(enabled)
self.show_official_only.setEnabled(enabled)
self.show_nsfw.setEnabled(enabled)
self.hide_unavailable.setEnabled(enabled)
self.tags_list.setEnabled(enabled)
# self.mod_search.setEnabled(enabled) # TEMPORARILY DISABLED
# self.mods_list.setEnabled(enabled) # TEMPORARILY DISABLED
def _apply_filters(self):
"""Apply current filters to modlist display"""
# CRITICAL: Guard against race condition - don't filter if modlists aren't loaded yet
if not self.all_modlists:
return
filtered = self.all_modlists
# Search filter
search_text = self.search_box.text().strip()
if search_text:
filtered = [m for m in filtered if self._matches_search(m, search_text)]
# Game filter
game = self.game_combo.currentData()
if game:
filtered = [m for m in filtered if m.gameHumanFriendly == game]
# Status filters
if self.show_official_only.isChecked():
filtered = [m for m in filtered if m.official]
if not self.show_nsfw.isChecked():
filtered = [m for m in filtered if not m.nsfw]
if self.hide_unavailable.isChecked():
filtered = [m for m in filtered if m.is_available()]
# Tag filter - modlist must have ALL selected tags (normalized like Wabbajack)
selected_tags = [item.text() for item in self.tags_list.selectedItems()]
if selected_tags:
special_selected = {tag for tag in selected_tags if tag in ("NSFW", "Featured", "Unavailable")}
normalized_selected = [
self.gallery_service.normalize_tag_value(tag).lower()
for tag in selected_tags
if tag not in special_selected
]
if "NSFW" in special_selected:
filtered = [m for m in filtered if m.nsfw]
if "Featured" in special_selected:
filtered = [m for m in filtered if m.official]
if "Unavailable" in special_selected:
filtered = [m for m in filtered if not m.is_available()]
if normalized_selected:
filtered = [
m for m in filtered
if all(
self._tag_in_modlist(m, normalized_tag)
for normalized_tag in normalized_selected
)
]
# Mod filter - TEMPORARILY DISABLED (not working correctly in v0.2.0.8)
# selected_mods = [item.text() for item in self.mods_list.selectedItems()]
# if selected_mods:
# filtered = [m for m in filtered if m.mods and all(mod in m.mods for mod in selected_mods)]
self.filtered_modlists = filtered
self._update_grid()
def _matches_search(self, modlist: ModlistMetadata, query: str) -> bool:
"""Check if modlist matches search query"""
query_lower = query.lower()
return (
query_lower in modlist.title.lower() or
query_lower in modlist.description.lower() or
query_lower in modlist.author.lower()
)

View File

@@ -0,0 +1,141 @@
"""Image loading and caching manager for ModlistGalleryDialog."""
from PySide6.QtCore import QObject, QTimer, QUrl
from PySide6.QtNetwork import QNetworkAccessManager, QNetworkRequest, QNetworkReply
from PySide6.QtGui import QPixmap
from typing import Optional, Dict
from collections import deque
from jackify.backend.models.modlist_metadata import ModlistMetadata
from jackify.backend.services.modlist_gallery_service import ModlistGalleryService
class ImageManager(QObject):
"""Centralized image loading and caching manager"""
def __init__(self, gallery_service: ModlistGalleryService):
super().__init__()
self.gallery_service = gallery_service
self.pixmap_cache: Dict[str, QPixmap] = {}
self.network_manager = QNetworkAccessManager()
self.download_queue = deque()
self.downloading: set = set()
self.max_concurrent = 2 # Start with 2 concurrent downloads to reduce UI lag
self.save_queue = deque() # Queue for deferred disk saves
self._save_timer = None
def get_image(self, metadata: ModlistMetadata, callback, size: str = "small") -> Optional[QPixmap]:
"""
Get image for modlist - returns cached pixmap or None if needs download
Args:
metadata: Modlist metadata
callback: Callback function when image is loaded
size: Image size to use ("small" for cards, "large" for detail view)
"""
cache_key = f"{metadata.machineURL}_{size}"
# Check memory cache first (should be preloaded)
if cache_key in self.pixmap_cache:
return self.pixmap_cache[cache_key]
# Only check disk cache if not in memory (fallback for images that weren't preloaded)
# Rarely happens if preload worked
cached_path = self.gallery_service.get_cached_image_path(metadata, size)
if cached_path and cached_path.exists():
try:
pixmap = QPixmap(str(cached_path))
if not pixmap.isNull():
self.pixmap_cache[cache_key] = pixmap
return pixmap
except Exception:
pass
# Queue for download if not cached
if cache_key not in self.downloading:
self.download_queue.append((metadata, callback, size))
self._process_queue()
return None
def _process_queue(self):
"""Process download queue up to max_concurrent"""
# Process one at a time with small delays to keep UI responsive
if len(self.downloading) < self.max_concurrent and self.download_queue:
metadata, callback, size = self.download_queue.popleft()
cache_key = f"{metadata.machineURL}_{size}"
if cache_key not in self.downloading:
self.downloading.add(cache_key)
self._download_image(metadata, callback, size)
# Schedule next download with small delay to yield to UI
if self.download_queue:
QTimer.singleShot(100, self._process_queue)
def _download_image(self, metadata: ModlistMetadata, callback, size: str = "small"):
"""Download image from network"""
image_url = self.gallery_service.get_image_url(metadata, size)
if not image_url:
cache_key = f"{metadata.machineURL}_{size}"
self.downloading.discard(cache_key)
self._process_queue()
return
url = QUrl(image_url)
request = QNetworkRequest(url)
request.setRawHeader(b"User-Agent", b"Jackify/0.1.8")
reply = self.network_manager.get(request)
reply.finished.connect(lambda: self._on_download_finished(reply, metadata, callback, size))
def _on_download_finished(self, reply: QNetworkReply, metadata: ModlistMetadata, callback, size: str = "small"):
"""Handle download completion"""
from PySide6.QtWidgets import QApplication
cache_key = f"{metadata.machineURL}_{size}"
self.downloading.discard(cache_key)
if reply.error() == QNetworkReply.NoError:
image_data = reply.readAll()
pixmap = QPixmap()
if pixmap.loadFromData(image_data) and not pixmap.isNull():
# Store in memory cache immediately
self.pixmap_cache[cache_key] = pixmap
# Defer disk save to avoid blocking UI - queue it for later
cached_path = self.gallery_service.get_image_cache_path(metadata, size)
self.save_queue.append((pixmap, cached_path))
self._start_save_timer()
# Call callback with pixmap (update UI immediately)
if callback:
callback(pixmap)
# Process events to keep UI responsive
QApplication.processEvents()
reply.deleteLater()
# Process next in queue (with small delay to yield to UI)
QTimer.singleShot(50, self._process_queue)
def _start_save_timer(self):
"""Start timer for deferred disk saves if not already running"""
if self._save_timer is None:
self._save_timer = QTimer()
self._save_timer.timeout.connect(self._save_next_image)
self._save_timer.setSingleShot(False)
self._save_timer.start(200) # Save one image every 200ms
def _save_next_image(self):
"""Save next image from queue to disk (non-blocking)"""
if self.save_queue:
pixmap, cached_path = self.save_queue.popleft()
try:
cached_path.parent.mkdir(parents=True, exist_ok=True)
pixmap.save(str(cached_path), "WEBP")
except Exception:
pass # Save failed - not critical, image is in memory cache
# Stop timer if queue is empty
if not self.save_queue and self._save_timer:
self._save_timer.stop()
self._save_timer = None

View File

@@ -0,0 +1,401 @@
"""Loading and data management for ModlistGalleryDialog (Mixin)."""
from PySide6.QtCore import Qt, QThread, Signal, QTimer
from PySide6.QtWidgets import QWidget, QVBoxLayout, QLabel, QApplication
from PySide6.QtGui import QFont
from typing import List, Dict
import random
import logging
from jackify.backend.models.modlist_metadata import ModlistMetadata
from ..shared_theme import JACKIFY_COLOR_BLUE
from .modlist_gallery_card import ModlistCard
logger = logging.getLogger(__name__)
class ModlistGalleryLoadingMixin:
"""Mixin providing loading and data management for ModlistGalleryDialog."""
def _load_modlists_async(self):
"""Load modlists in background thread for instant dialog appearance"""
from PySide6.QtCore import QThread, Signal
from PySide6.QtGui import QFont
# Hide status label during loading (popup dialog will show instead)
self.status_label.setVisible(False)
# Show loading overlay directly in content area (simpler than separate dialog)
self._loading_overlay = QWidget(self.content_area)
self._loading_overlay.setStyleSheet("""
QWidget {
background-color: rgba(35, 35, 35, 240);
border-radius: 8px;
}
""")
overlay_layout = QVBoxLayout()
overlay_layout.setContentsMargins(30, 20, 30, 20)
overlay_layout.setSpacing(12)
self._loading_label = QLabel("Loading modlists")
self._loading_label.setAlignment(Qt.AlignCenter)
# Set fixed width to prevent text shifting when dots animate
# Width accommodates "Loading modlists..." (longest version)
self._loading_label.setFixedWidth(220)
font = QFont()
font.setPointSize(14)
font.setBold(True)
self._loading_label.setFont(font)
self._loading_label.setStyleSheet(f"color: {JACKIFY_COLOR_BLUE}; font-size: 14px; font-weight: bold;")
overlay_layout.addWidget(self._loading_label)
self._loading_overlay.setLayout(overlay_layout)
self._loading_overlay.setFixedSize(300, 120)
# Animate dots in loading message
self._loading_dot_count = 0
self._loading_dot_timer = QTimer()
self._loading_dot_timer.timeout.connect(self._animate_loading_dots)
self._loading_dot_timer.start(500) # Update every 500ms
# Position overlay in center of content area
def position_overlay():
if hasattr(self, 'content_area') and self.content_area.isVisible():
content_width = self.content_area.width()
content_height = self.content_area.height()
x = (content_width - 300) // 2
y = (content_height - 120) // 2
self._loading_overlay.move(x, y)
self._loading_overlay.show()
self._loading_overlay.raise_()
# Delay slightly to ensure content_area is laid out
QTimer.singleShot(50, position_overlay)
class ModlistLoaderThread(QThread):
"""Background thread to load modlist metadata"""
finished = Signal(object, object) # metadata_response, error_message
def __init__(self, gallery_service):
super().__init__()
self.gallery_service = gallery_service
def run(self):
try:
import time
start_time = time.time()
# Fetch metadata (CPU-intensive work happens here in background)
# Skip search index initially for faster loading - can be loaded later if user searches
metadata_response = self.gallery_service.fetch_modlist_metadata(
include_validation=False,
include_search_index=False, # Skip for faster initial load
sort_by="title"
)
elapsed = time.time() - start_time
import logging
logger = logging.getLogger(__name__)
if elapsed < 0.5:
logger.debug(f"Gallery metadata loaded from cache in {elapsed:.2f}s")
else:
logger.info(f"Gallery metadata fetched from engine in {elapsed:.2f}s")
self.finished.emit(metadata_response, None)
except Exception as e:
self.finished.emit(None, str(e))
# Create and start background thread
self._loader_thread = ModlistLoaderThread(self.gallery_service)
self._loader_thread.finished.connect(self._on_modlists_loaded)
self._loader_thread.start()
def _animate_loading_dots(self):
"""Animate dots in loading message"""
if hasattr(self, '_loading_label') and self._loading_label:
self._loading_dot_count = (self._loading_dot_count + 1) % 4
dots = "." * self._loading_dot_count
# Pad with spaces to keep text width constant (prevents shifting)
padding = " " * (3 - self._loading_dot_count)
self._loading_label.setText(f"Loading modlists{dots}{padding}")
def _on_modlists_loaded(self, metadata_response, error_message):
"""Handle modlist metadata loaded in background thread (runs in GUI thread)"""
import random
from PySide6.QtGui import QFont
# Stop animation timer and close loading overlay
if hasattr(self, '_loading_dot_timer') and self._loading_dot_timer:
self._loading_dot_timer.stop()
self._loading_dot_timer = None
if hasattr(self, '_loading_overlay') and self._loading_overlay:
self._loading_overlay.hide()
self._loading_overlay.deleteLater()
self._loading_overlay = None
self.status_label.setVisible(True)
if error_message:
self.status_label.setText(f"Error loading modlists: {error_message}")
return
if not metadata_response:
self.status_label.setText("Failed to load modlists")
return
try:
# Get all modlists
all_modlists = metadata_response.modlists
# RANDOMIZE the order each time gallery opens (like Wabbajack)
random.shuffle(all_modlists)
self.all_modlists = all_modlists
# Precompute normalized tags for display/filtering
for modlist in self.all_modlists:
normalized_display = self.gallery_service.normalize_tags_for_display(getattr(modlist, 'tags', []))
modlist.normalized_tags_display = normalized_display
modlist.normalized_tags_keys = [tag.lower() for tag in normalized_display]
# Temporarily disconnect to prevent triggering during setup
self.game_combo.currentIndexChanged.disconnect(self._apply_filters)
# Populate game filter
games = sorted(set(m.gameHumanFriendly for m in self.all_modlists))
for game in games:
self.game_combo.addItem(game, game)
# If dialog was opened with a game filter, pre-select it
if self.game_filter:
index = self.game_combo.findData(self.game_filter)
if index >= 0:
self.game_combo.setCurrentIndex(index)
# Populate tag filter (mod filter temporarily disabled)
self._populate_tag_filter()
# self._populate_mod_filter() # TEMPORARILY DISABLED
# Create cards immediately (will show placeholders for images not in cache)
self._create_all_cards()
# Preload cached images in background (non-blocking)
self.status_label.setText("Loading images...")
QTimer.singleShot(0, self._preload_cached_images_async)
# Reconnect filter handler
self.game_combo.currentIndexChanged.connect(self._apply_filters)
# Enable filter controls now that data is loaded
self._set_filter_controls_enabled(True)
# Apply filters (will show all modlists for selected game initially)
self._apply_filters()
# Start background validation update (non-blocking)
self._start_validation_update()
except Exception as e:
self.status_label.setText(f"Error processing modlists: {str(e)}")
def _load_modlists(self):
"""DEPRECATED: Synchronous loading - replaced by _load_modlists_async()"""
from PySide6.QtWidgets import QApplication
self.status_label.setText("Loading modlists...")
QApplication.processEvents() # Update UI immediately
# Fetch metadata (will use cache if valid)
# Skip validation initially for faster loading - can be added later if needed
try:
metadata_response = self.gallery_service.fetch_modlist_metadata(
include_validation=False, # Skip validation for faster initial load
include_search_index=True, # Include mod search index for mod filtering
sort_by="title"
)
if metadata_response:
# Get all modlists
all_modlists = metadata_response.modlists
# RANDOMIZE the order each time gallery opens (like Wabbajack)
# Prevent gaming via alphabetical ordering
random.shuffle(all_modlists)
self.all_modlists = all_modlists
# Precompute normalized tags for display/filtering (matches upstream Wabbajack)
for modlist in self.all_modlists:
normalized_display = self.gallery_service.normalize_tags_for_display(getattr(modlist, 'tags', []))
modlist.normalized_tags_display = normalized_display
modlist.normalized_tags_keys = [tag.lower() for tag in normalized_display]
# Temporarily disconnect to prevent triggering during setup
self.game_combo.currentIndexChanged.disconnect(self._apply_filters)
# Populate game filter
games = sorted(set(m.gameHumanFriendly for m in self.all_modlists))
for game in games:
self.game_combo.addItem(game, game)
# If dialog was opened with a game filter, pre-select it
if self.game_filter:
index = self.game_combo.findData(self.game_filter)
if index >= 0:
self.game_combo.setCurrentIndex(index)
# Populate tag filter (mod filter temporarily disabled)
self._populate_tag_filter()
# self._populate_mod_filter() # TEMPORARILY DISABLED
# Create cards immediately (will show placeholders for images not in cache)
self._create_all_cards()
# Preload cached images in background (non-blocking)
# Images will appear as they're loaded
self.status_label.setText("Loading images...")
QTimer.singleShot(0, self._preload_cached_images_async)
# Reconnect filter handler
self.game_combo.currentIndexChanged.connect(self._apply_filters)
# Apply filters (will show all modlists for selected game initially)
self._apply_filters()
# Start background validation update (non-blocking)
self._start_validation_update()
else:
self.status_label.setText("Failed to load modlists")
except Exception as e:
self.status_label.setText(f"Error loading modlists: {str(e)}")
def _preload_cached_images_async(self):
"""Preload cached images asynchronously - images appear as they load"""
from PySide6.QtWidgets import QApplication
preloaded = 0
total = len(self.all_modlists)
for idx, modlist in enumerate(self.all_modlists):
cache_key = modlist.machineURL
# Skip if already in cache
if cache_key in self.image_manager.pixmap_cache:
continue
# Preload large images for cards (scale down for better quality)
cached_path = self.gallery_service.get_cached_image_path(modlist, "large")
if cached_path and cached_path.exists():
try:
pixmap = QPixmap(str(cached_path))
if not pixmap.isNull():
cache_key_large = f"{cache_key}_large"
self.image_manager.pixmap_cache[cache_key_large] = pixmap
preloaded += 1
# Update card immediately if it exists
card = self.all_cards.get(cache_key)
if card:
card._display_image(pixmap)
except Exception:
pass
# Process events every 10 images to keep UI responsive
if idx % 10 == 0 and idx > 0:
QApplication.processEvents()
# Update status (subtle, user-friendly)
modlist_count = len(self.filtered_modlists)
if modlist_count == 1:
self.status_label.setText("1 modlist")
else:
self.status_label.setText(f"{modlist_count} modlists")
def _create_all_cards(self):
"""Create cards for all modlists and store in dict"""
# Clear existing cards
self.all_cards.clear()
# Disable updates during card creation to prevent individual renders
self.grid_widget.setUpdatesEnabled(False)
self.setUpdatesEnabled(False)
try:
# Create all cards - images should be in memory cache from preload
# so _load_image() will find them instantly
for modlist in self.all_modlists:
card = ModlistCard(modlist, self.image_manager, is_steamdeck=self.is_steamdeck)
card.clicked.connect(self._on_modlist_clicked)
self.all_cards[modlist.machineURL] = card
finally:
# Re-enable updates - single render for all cards
self.setUpdatesEnabled(True)
self.grid_widget.setUpdatesEnabled(True)
def _refresh_metadata(self):
"""Force refresh metadata from jackify-engine"""
self.status_label.setText("Refreshing metadata...")
self.gallery_service.clear_cache()
self._load_modlists()
def _start_validation_update(self):
"""Start background validation update to get availability status"""
# Update validation in background thread to avoid blocking UI
class ValidationUpdateThread(QThread):
finished_signal = Signal(object) # Emits updated metadata response
def __init__(self, gallery_service):
super().__init__()
self.gallery_service = gallery_service
def run(self):
try:
# Fetch with validation (slower, but in background)
metadata_response = self.gallery_service.fetch_modlist_metadata(
include_validation=True,
include_search_index=False,
sort_by="title"
)
self.finished_signal.emit(metadata_response)
except Exception:
self.finished_signal.emit(None)
self._validation_thread = ValidationUpdateThread(self.gallery_service)
self._validation_thread.finished_signal.connect(self._on_validation_updated)
self._validation_thread.start()
def _on_validation_updated(self, metadata_response):
"""Update modlists with validation data when background fetch completes"""
if not metadata_response:
return
# Create lookup dict for validation data
validation_map = {}
for modlist in metadata_response.modlists:
if modlist.validation:
validation_map[modlist.machineURL] = modlist.validation
# Update existing modlists with validation data
updated_count = 0
for modlist in self.all_modlists:
if modlist.machineURL in validation_map:
modlist.validation = validation_map[modlist.machineURL]
updated_count += 1
# Update card if it exists
card = self.all_cards.get(modlist.machineURL)
if card:
# Update unavailable badge visibility
card._update_availability_badge()
# Re-apply filters to update availability filtering
if updated_count > 0:
self._apply_filters()

View File

@@ -78,7 +78,7 @@ class ModlistTasksScreen(QWidget):
"""Set up the user interface"""
main_layout = QVBoxLayout(self)
main_layout.setAlignment(Qt.AlignTop | Qt.AlignHCenter)
main_layout.setContentsMargins(30, 30, 30, 30) # Reduced from 50
main_layout.setContentsMargins(30, 30, 30, 30)
main_layout.setSpacing(12) # Match main menu spacing
if self.debug:
@@ -147,11 +147,11 @@ class ModlistTasksScreen(QWidget):
# Create grid layout for buttons
button_grid = QGridLayout()
button_grid.setSpacing(12) # Reduced from 16
button_grid.setSpacing(12)
button_grid.setAlignment(Qt.AlignHCenter)
button_width = 400
button_height = 40 # Reduced from 50
button_height = 40
for i, (label, action_id, description) in enumerate(MENU_ITEMS):
# Create button
@@ -179,7 +179,7 @@ class ModlistTasksScreen(QWidget):
# Create description label
desc_label = QLabel(description)
desc_label.setAlignment(Qt.AlignHCenter)
desc_label.setStyleSheet("color: #999; font-size: 11px;") # Reduced from 12px
desc_label.setStyleSheet("color: #999; font-size: 11px;")
desc_label.setWordWrap(True)
desc_label.setFixedWidth(button_width)

View File

@@ -0,0 +1,50 @@
"""
Shared back/cancel behavior for screens with Show Details.
All screens that have a Cancel/Back button and optional Show Details checkbox
should use this mixin so the main window consistently collapses when leaving.
"""
from PySide6.QtCore import QSize, Qt
from ..utils import set_responsive_minimum
class ScreenBackMixin:
"""
Mixin providing shared go_back() and collapse_show_details_before_leave().
Requires on self: resize_request (Signal(str)), stacked_widget, main_menu_index.
Optional: show_details_checkbox, _toggle_console_visibility (for collapse).
"""
def go_back(self):
"""Navigate back to main menu and request main window collapse."""
self.resize_request.emit("collapse")
try:
main_window = self.window()
if main_window:
main_window.setMaximumSize(QSize(16777215, 16777215))
set_responsive_minimum(main_window, min_width=960, min_height=420)
except Exception:
pass
if getattr(self, "stacked_widget", None) is not None:
self.stacked_widget.setCurrentIndex(self.main_menu_index)
def collapse_show_details_before_leave(self):
"""
If Show Details is expanded, collapse it so the main window shrinks
before we leave. Call this from cancel_and_cleanup (or any exit path)
before go_back().
"""
main_window = self.window()
is_steamdeck = bool(
getattr(main_window, "system_info", None)
and getattr(main_window.system_info, "is_steamdeck", False)
)
if not hasattr(self, "show_details_checkbox") or not self.show_details_checkbox.isChecked():
return
self.show_details_checkbox.blockSignals(True)
self.show_details_checkbox.setChecked(False)
self.show_details_checkbox.blockSignals(False)
if not is_steamdeck and hasattr(self, "_toggle_console_visibility"):
self._toggle_console_visibility(Qt.Unchecked)

View File

@@ -6,23 +6,26 @@ Follows standard Jackify screen layout.
"""
import logging
import os
from datetime import datetime
from pathlib import Path
from typing import Optional
from PySide6.QtWidgets import (
QWidget, QVBoxLayout, QHBoxLayout, QLabel, QPushButton,
QFileDialog, QLineEdit, QGridLayout, QTextEdit, QTabWidget, QSizePolicy, QCheckBox
QFileDialog, QLineEdit, QGridLayout, QTextEdit, QTabWidget, QSizePolicy, QCheckBox,
QMessageBox
)
from PySide6.QtCore import Qt, QThread, Signal, QSize
from PySide6.QtGui import QTextCursor
from jackify.backend.models.configuration import SystemInfo
from jackify.backend.handlers.wabbajack_installer_handler import WabbajackInstallerHandler
from ..services.message_service import MessageService
from ..shared_theme import JACKIFY_COLOR_BLUE, DEBUG_BORDERS
from ..utils import set_responsive_minimum
from ..widgets.file_progress_list import FileProgressList
from ..widgets.progress_indicator import OverallProgressIndicator
from .screen_back_mixin import ScreenBackMixin
logger = logging.getLogger(__name__)
@@ -40,7 +43,6 @@ class WabbajackInstallerWorker(QThread):
self.install_folder = install_folder
self.shortcut_name = shortcut_name
self.enable_gog = enable_gog
self.handler = WabbajackInstallerHandler()
self.launch_options = "" # Store launch options for success message
self.start_time = None # Track installation start time
@@ -50,206 +52,38 @@ class WabbajackInstallerWorker(QThread):
logger.info(message)
def run(self):
"""Run the installation workflow"""
"""Run the installation workflow using backend service"""
import time
self.start_time = time.time()
try:
total_steps = 12
# Step 1: Check requirements
self.progress_update.emit("Checking requirements...", 5)
self.activity_update.emit("Checking requirements", 1, total_steps)
self._log("Checking system requirements...")
proton_path = self.handler.find_proton_experimental()
if not proton_path:
self.installation_complete.emit(
False,
"Proton Experimental not found.\nPlease install it from Steam."
)
return
self._log(f"Found Proton Experimental: {proton_path}")
userdata = self.handler.find_steam_userdata_path()
if not userdata:
self.installation_complete.emit(
False,
"Steam userdata not found.\nPlease ensure Steam is installed and you're logged in."
)
return
self._log(f"Found Steam userdata: {userdata}")
# Step 2: Download Wabbajack
self.progress_update.emit("Downloading Wabbajack.exe...", 15)
self.activity_update.emit("Downloading Wabbajack.exe", 2, total_steps)
self._log("Downloading Wabbajack.exe from GitHub...")
wabbajack_exe = self.handler.download_wabbajack(self.install_folder)
self._log(f"Downloaded to: {wabbajack_exe}")
# Step 3: Create dotnet cache
self.progress_update.emit("Creating .NET cache directory...", 20)
self.activity_update.emit("Creating .NET cache", 3, total_steps)
self._log("Creating .NET bundle extract cache...")
self.handler.create_dotnet_cache(self.install_folder)
# Step 4: Stop Steam before modifying shortcuts.vdf
self.progress_update.emit("Stopping Steam...", 25)
self.activity_update.emit("Stopping Steam", 4, total_steps)
self._log("Stopping Steam (required to safely modify shortcuts.vdf)...")
import subprocess
import time
# Kill Steam using pkill (simple approach like AuCu)
try:
subprocess.run(['steam', '-shutdown'], timeout=5, capture_output=True)
time.sleep(2)
subprocess.run(['pkill', '-9', 'steam'], timeout=5, capture_output=True)
time.sleep(2)
self._log("Steam stopped successfully")
except Exception as e:
self._log(f"Warning: Steam shutdown had issues: {e}. Proceeding anyway...")
# Step 5: Add to Steam shortcuts (NO Proton - like AuCu, but with STEAM_COMPAT_MOUNTS for libraries)
self.progress_update.emit("Adding to Steam shortcuts...", 30)
self.activity_update.emit("Adding to Steam", 5, total_steps)
self._log("Adding Wabbajack to Steam shortcuts...")
from jackify.backend.services.native_steam_service import NativeSteamService
steam_service = NativeSteamService()
# Generate launch options with STEAM_COMPAT_MOUNTS for additional Steam libraries (like modlist installs)
# Default to empty string (like AuCu) - only add options if we have additional libraries
# Note: Users may need to manually add other paths (e.g., download directories on different drives) to launch options
launch_options = ""
try:
from jackify.backend.handlers.path_handler import PathHandler
path_handler = PathHandler()
all_libs = path_handler.get_all_steam_library_paths()
main_steam_lib_path_obj = path_handler.find_steam_library()
if main_steam_lib_path_obj and main_steam_lib_path_obj.name == "common":
main_steam_lib_path = main_steam_lib_path_obj.parent.parent
filtered_libs = [lib for lib in all_libs if str(lib) != str(main_steam_lib_path)]
if filtered_libs:
mount_paths = ":".join(str(lib) for lib in filtered_libs)
launch_options = f'STEAM_COMPAT_MOUNTS="{mount_paths}" %command%'
self._log(f"Added STEAM_COMPAT_MOUNTS for additional Steam libraries: {mount_paths}")
else:
self._log("No additional Steam libraries found - using empty launch options (like AuCu)")
except Exception as e:
self._log(f"Could not generate STEAM_COMPAT_MOUNTS (non-critical): {e}")
# Keep empty string like AuCu
# Store launch options for success message
self.launch_options = launch_options
# Create shortcut WITHOUT Proton (AuCu does this separately later)
success, app_id = steam_service.create_shortcut(
app_name=self.shortcut_name,
exe_path=str(wabbajack_exe),
start_dir=str(wabbajack_exe.parent),
launch_options=launch_options, # Empty or with STEAM_COMPAT_MOUNTS
tags=["Jackify"]
)
if not success or app_id is None:
raise RuntimeError("Failed to create Steam shortcut")
self._log(f"Created Steam shortcut with AppID: {app_id}")
# Step 6: Initialize Wine prefix
self.progress_update.emit("Initializing Wine prefix...", 45)
self.activity_update.emit("Initializing Wine prefix", 6, total_steps)
self._log("Initializing Wine prefix with Proton...")
prefix_path = self.handler.init_wine_prefix(app_id)
self._log(f"Wine prefix created: {prefix_path}")
# Step 7: Install WebView2
self.progress_update.emit("Installing WebView2 runtime...", 60)
self.activity_update.emit("Installing WebView2", 7, total_steps)
self._log("Downloading and installing WebView2...")
try:
self.handler.install_webview2(app_id, self.install_folder)
self._log("WebView2 installed successfully")
except Exception as e:
self._log(f"WARNING: WebView2 installation may have failed: {e}")
self._log("This may prevent Nexus login in Wabbajack. You can manually install WebView2 later.")
# Continue installation - WebView2 is not critical for basic functionality
# Step 8: Apply Win7 registry
self.progress_update.emit("Applying Windows 7 registry settings...", 75)
self.activity_update.emit("Applying registry settings", 8, total_steps)
self._log("Applying Windows 7 compatibility settings...")
self.handler.apply_win7_registry(app_id)
self._log("Registry settings applied")
# Step 9: GOG game detection (optional)
gog_count = 0
if self.enable_gog:
self.progress_update.emit("Detecting GOG games from Heroic...", 80)
self.activity_update.emit("Detecting GOG games", 9, total_steps)
self._log("Searching for GOG games in Heroic...")
try:
gog_count = self.handler.inject_gog_registry(app_id)
if gog_count > 0:
self._log(f"Detected and injected {gog_count} GOG games")
else:
self._log("No GOG games found in Heroic")
except Exception as e:
self._log(f"GOG injection failed (non-critical): {e}")
# Step 10: Create Steam library symlinks
self.progress_update.emit("Creating Steam library symlinks...", 85)
self.activity_update.emit("Creating library symlinks", 10, total_steps)
self._log("Creating Steam library symlinks for game detection...")
steam_service.create_steam_library_symlinks(app_id)
self._log("Steam library symlinks created")
# Step 11: Set Proton Experimental (separate step like AuCu)
self.progress_update.emit("Setting Proton compatibility...", 90)
self.activity_update.emit("Setting Proton compatibility", 11, total_steps)
self._log("Setting Proton Experimental as compatibility tool...")
try:
steam_service.set_proton_version(app_id, "proton_experimental")
self._log("Proton Experimental set successfully")
except Exception as e:
self._log(f"Warning: Failed to set Proton version (non-critical): {e}")
self._log("You can set it manually in Steam: Properties → Compatibility → Proton Experimental")
# Step 12: Start Steam at the end
self.progress_update.emit("Starting Steam...", 95)
self.activity_update.emit("Starting Steam", 12, total_steps)
self._log("Starting Steam...")
from jackify.backend.services.steam_restart_service import start_steam
start_steam()
time.sleep(3) # Give Steam time to start
self._log("Steam started successfully")
# Done!
self.progress_update.emit("Installation complete!", 100)
self.activity_update.emit("Installation complete", 12, total_steps)
self._log("\n=== Installation Complete ===")
self._log(f"Wabbajack installed to: {self.install_folder}")
self._log(f"Steam AppID: {app_id}")
if gog_count > 0:
self._log(f"GOG games detected: {gog_count}")
self._log("You can now launch Wabbajack from Steam")
# Calculate time taken
import time
time_taken = int(time.time() - self.start_time)
mins, secs = divmod(time_taken, 60)
time_str = f"{mins} minutes, {secs} seconds" if mins else f"{secs} seconds"
# Store data for success dialog (app_id as string to avoid overflow)
self.installation_complete.emit(True, "", self.launch_options, str(app_id), time_str)
except Exception as e:
error_msg = f"Installation failed: {str(e)}"
self._log(f"\nERROR: {error_msg}")
logger.error(f"Wabbajack installation failed: {e}", exc_info=True)
self.installation_complete.emit(False, error_msg, "", "", "")
from jackify.backend.services.wabbajack_installer_service import WabbajackInstallerService
service = WabbajackInstallerService()
def progress_callback(message: str, percentage: int):
self.progress_update.emit(message, percentage)
step_num = int((percentage / 100) * 12) if percentage < 100 else 12
self.activity_update.emit(message, step_num, 12)
def log_callback(message: str):
self._log(message)
success, app_id, launch_options, gog_count, time_str, error_msg = service.install_wabbajack(
install_folder=self.install_folder,
shortcut_name=self.shortcut_name,
enable_gog=self.enable_gog,
progress_callback=progress_callback,
log_callback=log_callback
)
if success:
self.launch_options = launch_options or ""
self.installation_complete.emit(True, "", self.launch_options, str(app_id), time_str or "")
else:
self.installation_complete.emit(False, error_msg or "Installation failed", "", "", "")
class WabbajackInstallerScreen(QWidget):
class WabbajackInstallerScreen(ScreenBackMixin, QWidget):
"""Wabbajack installer GUI screen following standard Jackify layout"""
resize_request = Signal(str)
@@ -257,6 +91,7 @@ class WabbajackInstallerScreen(QWidget):
def __init__(self, stacked_widget=None, additional_tasks_index=3, system_info: Optional[SystemInfo] = None):
super().__init__()
self.stacked_widget = stacked_widget
self.main_menu_index = additional_tasks_index
self.additional_tasks_index = additional_tasks_index
self.system_info = system_info or SystemInfo(is_steamdeck=False)
self.debug = DEBUG_BORDERS
@@ -273,6 +108,11 @@ class WabbajackInstallerScreen(QWidget):
self._user_manually_scrolled = False
self._was_at_bottom = True
# Set up log file path
from jackify.shared.paths import get_jackify_logs_dir
self.log_path = get_jackify_logs_dir() / 'Wabbajack_Installer_workflow.log'
os.makedirs(os.path.dirname(self.log_path), exist_ok=True)
# Initialize progress reporting
self.progress_indicator = OverallProgressIndicator(show_progress_bar=True)
self.progress_indicator.set_status("Ready", 0)
@@ -542,7 +382,7 @@ class WabbajackInstallerScreen(QWidget):
# Get shortcut name
self.shortcut_name = self.shortcut_name_edit.text().strip() or "Wabbajack"
# Confirm with user
# Confirm with user (standard dialog - no safety countdown needed for this operation)
confirm = MessageService.question(
self,
"Confirm Installation",
@@ -553,13 +393,25 @@ class WabbajackInstallerScreen(QWidget):
"Continue?"
)
if not confirm:
if confirm != QMessageBox.Yes:
return
# Clear displays
self.console.clear()
self.file_progress_list.clear()
# Rotate log file at start of each workflow run (keep 5 backups)
from jackify.backend.handlers.logging_handler import LoggingHandler
log_handler = LoggingHandler()
log_handler.rotate_log_file_per_run(self.log_path, backup_count=5)
# Log session start
self._write_to_log_file("=" * 60)
self._write_to_log_file(f"Wabbajack Installation Started")
self._write_to_log_file(f"Install folder: {self.install_folder}")
self._write_to_log_file(f"Shortcut name: {self.shortcut_name}")
self._write_to_log_file("=" * 60)
# Update UI state
self.start_btn.setEnabled(False)
self.cancel_btn.setEnabled(False)
@@ -585,8 +437,19 @@ class WabbajackInstallerScreen(QWidget):
summary_info={"current_step": current, "max_steps": total}
)
def _write_to_log_file(self, message: str):
"""Write message to workflow log file with timestamp"""
try:
timestamp = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
with open(self.log_path, 'a', encoding='utf-8') as f:
f.write(f"[{timestamp}] {message}\n")
except Exception:
pass
def _on_log_output(self, message: str):
"""Handle log output with professional auto-scroll"""
self._write_to_log_file(message)
scrollbar = self.console.verticalScrollBar()
was_at_bottom = (scrollbar.value() >= scrollbar.maximum() - 1)
@@ -678,7 +541,7 @@ class WabbajackInstallerScreen(QWidget):
# Insert before the Ko-Fi link (which should be near the end)
# Find the index of the Ko-Fi label or add at the end
insert_index = card_layout.count() - 2 # Before buttons, after next steps
insert_index = card_layout.count() - 2
card_layout.insertWidget(insert_index, note_frame)
success_dialog.show()
@@ -698,8 +561,8 @@ class WabbajackInstallerScreen(QWidget):
def _go_back(self):
"""Return to Additional Tasks menu"""
if self.stacked_widget:
self.stacked_widget.setCurrentIndex(self.additional_tasks_index)
self.collapse_show_details_before_leave()
self.go_back()
def showEvent(self, event):
"""Called when widget becomes visible"""

View File

@@ -93,18 +93,12 @@ class SafeMessageBox(NonFocusMessageBox):
"""High safety: requires typing confirmation code"""
# Generate random confirmation code
self.confirmation_code = ''.join(random.choices(string.ascii_uppercase, k=6))
# Create custom buttons
self.proceed_btn = self.addButton(danger_action, QMessageBox.AcceptRole)
self.cancel_btn = self.addButton(safe_action, QMessageBox.RejectRole)
# Make cancel the default (Enter key)
self.proceed_btn = self.addButton(danger_action, QMessageBox.ActionRole)
self.cancel_btn = self.addButton(safe_action, QMessageBox.ActionRole)
self.setDefaultButton(self.cancel_btn)
# Initially disable proceed button
self.proceed_btn.setEnabled(False)
# Add confirmation code input
widget = QWidget()
layout = QVBoxLayout(widget)
@@ -124,26 +118,16 @@ class SafeMessageBox(NonFocusMessageBox):
def _setup_medium_safety(self, danger_action: str, safe_action: str):
"""Medium safety: requires wait period"""
# Create custom buttons
self.proceed_btn = self.addButton(danger_action, QMessageBox.AcceptRole)
self.cancel_btn = self.addButton(safe_action, QMessageBox.RejectRole)
# Make cancel the default (Enter key)
self.proceed_btn = self.addButton(danger_action, QMessageBox.ActionRole)
self.cancel_btn = self.addButton(safe_action, QMessageBox.ActionRole)
self.setDefaultButton(self.cancel_btn)
# Initially disable proceed button
self.proceed_btn.setEnabled(False)
# Start countdown
self._start_countdown(3)
def _setup_low_safety(self, danger_action: str, safe_action: str):
"""Low safety: no additional features needed"""
# Create standard buttons
self.proceed_btn = self.addButton(danger_action, QMessageBox.AcceptRole)
self.cancel_btn = self.addButton(safe_action, QMessageBox.RejectRole)
# Make proceed the default for low safety
self.proceed_btn = self.addButton(danger_action, QMessageBox.ActionRole)
self.cancel_btn = self.addButton(safe_action, QMessageBox.ActionRole)
self.setDefaultButton(self.proceed_btn)
def _start_countdown(self, seconds: int):
@@ -274,7 +258,7 @@ class MessageService:
default_button: QMessageBox.StandardButton = QMessageBox.No,
critical: bool = False,
safety_level: str = "low") -> int:
"""Show question dialog without stealing focus"""
"""Show question dialog without stealing focus. Uses explicit button order for consistency."""
if safety_level in ["medium", "high"]:
msg_box = SafeMessageBox(parent, safety_level)
msg_box.setup_safety_features(title, message, "Yes", "No", is_question=True)
@@ -283,7 +267,21 @@ class MessageService:
msg_box.setIcon(QMessageBox.Question)
msg_box.setWindowTitle(title)
msg_box.setText(message)
msg_box.setStandardButtons(buttons)
msg_box.setDefaultButton(default_button)
return msg_box.exec()
yes_btn = msg_box.addButton("Yes", QMessageBox.ActionRole)
no_btn = msg_box.addButton("No", QMessageBox.ActionRole)
if default_button == QMessageBox.No:
msg_box.setDefaultButton(no_btn)
else:
msg_box.setDefaultButton(yes_btn)
result = msg_box.exec()
# For SafeMessageBox with is_question=True, return value is already set by done()
if safety_level in ["medium", "high"]:
return result
# For non-SafeMessageBox, map clicked button to QMessageBox.Yes/No for compatibility
clicked = msg_box.clickedButton()
if clicked and clicked.text() == "Yes":
return QMessageBox.Yes
return QMessageBox.No

View File

@@ -0,0 +1,22 @@
"""
Placeholder widget for unimplemented feature screens.
"""
from PySide6.QtWidgets import QWidget, QLabel, QVBoxLayout, QPushButton
from PySide6.QtCore import Qt
class FeaturePlaceholder(QWidget):
"""Placeholder widget for features not yet implemented."""
def __init__(self, stacked_widget=None):
super().__init__()
layout = QVBoxLayout()
label = QLabel("[Feature screen placeholder]")
label.setAlignment(Qt.AlignCenter)
layout.addWidget(label)
back_btn = QPushButton("Back to Main Menu")
if stacked_widget:
back_btn.clicked.connect(lambda: stacked_widget.setCurrentIndex(0))
layout.addWidget(back_btn)
self.setLayout(layout)

View File

@@ -0,0 +1,195 @@
"""
File progress item widget for a single file's progress display.
"""
from PySide6.QtWidgets import (
QWidget, QHBoxLayout, QLabel, QProgressBar, QSizePolicy
)
from PySide6.QtCore import Qt, QTimer
from jackify.shared.progress_models import FileProgress, OperationType
from ..shared_theme import JACKIFY_COLOR_BLUE
class FileProgressItem(QWidget):
"""Widget representing a single file's progress."""
def __init__(self, file_progress: FileProgress, parent=None):
super().__init__(parent)
self.file_progress = file_progress
self._target_percent = file_progress.percent
self._current_display_percent = file_progress.percent
self._spinner_position = 0
self._is_indeterminate = False
self._animation_timer = QTimer(self)
self._animation_timer.timeout.connect(self._animate_progress)
self._animation_timer.setInterval(16)
self._setup_ui()
self._update_display()
def _setup_ui(self):
layout = QHBoxLayout(self)
layout.setContentsMargins(4, 2, 4, 2)
layout.setSpacing(8)
operation_label = QLabel(self._get_operation_symbol())
operation_label.setFixedWidth(20)
operation_label.setAlignment(Qt.AlignCenter)
operation_label.setStyleSheet(f"color: {JACKIFY_COLOR_BLUE}; font-weight: bold;")
layout.addWidget(operation_label)
filename_label = QLabel(self._truncate_filename(self.file_progress.filename))
filename_label.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
filename_label.setToolTip(self.file_progress.filename)
filename_label.setStyleSheet("color: #ccc; font-size: 11px;")
layout.addWidget(filename_label, 1)
self.filename_label = filename_label
percent_label = QLabel()
percent_label.setFixedWidth(40)
percent_label.setAlignment(Qt.AlignRight | Qt.AlignVCenter)
percent_label.setStyleSheet("color: #aaa; font-size: 11px;")
layout.addWidget(percent_label)
self.percent_label = percent_label
progress_bar = QProgressBar()
progress_bar.setFixedHeight(12)
progress_bar.setFixedWidth(80)
progress_bar.setTextVisible(False)
progress_bar.setStyleSheet(f"""
QProgressBar {{
border: 1px solid #444;
border-radius: 2px;
background-color: #1a1a1a;
}}
QProgressBar::chunk {{
background-color: {JACKIFY_COLOR_BLUE};
border-radius: 1px;
}}
""")
layout.addWidget(progress_bar)
self.progress_bar = progress_bar
def _get_operation_symbol(self) -> str:
symbols = {
OperationType.DOWNLOAD: "",
OperationType.EXTRACT: "",
OperationType.VALIDATE: "",
OperationType.INSTALL: "",
}
return symbols.get(self.file_progress.operation, "")
def _truncate_filename(self, filename: str, max_length: int = 40) -> str:
if len(filename) <= max_length:
return filename
return filename[:max_length-3] + "..."
def _update_display(self):
is_summary = hasattr(self.file_progress, '_is_summary') and self.file_progress._is_summary
no_progress_bar = hasattr(self.file_progress, '_no_progress_bar') and self.file_progress._no_progress_bar
if 'Installing Files' in self.file_progress.filename or 'Converting Texture' in self.file_progress.filename or 'BSA:' in self.file_progress.filename:
name_display = self.file_progress.filename
elif self.file_progress.filename.startswith('Wine component:'):
rest = self.file_progress.filename.split(':', 1)[1].strip()
comp_id = rest.split('|')[0].strip() if '|' in rest else rest
name_display = f"Installing {comp_id}..."
else:
name_display = self._truncate_filename(self.file_progress.filename)
if not is_summary and not no_progress_bar:
size_display = self.file_progress.size_display
if size_display:
name_display = f"{name_display} ({size_display})"
self.filename_label.setText(name_display)
self.filename_label.setToolTip(self.file_progress.filename)
if no_progress_bar:
self._animation_timer.stop()
self.percent_label.setText("")
self.progress_bar.setVisible(False)
return
self.progress_bar.setVisible(True)
if is_summary:
summary_step = getattr(self.file_progress, '_summary_step', 0)
summary_max = getattr(self.file_progress, '_summary_max', 0)
if summary_max > 0:
percent = (summary_step / summary_max) * 100.0
self._target_percent = max(0, min(100, percent))
if not self._animation_timer.isActive():
self._animation_timer.start()
self.progress_bar.setRange(0, 100)
else:
self._is_indeterminate = True
self.percent_label.setText("")
self.progress_bar.setRange(0, 100)
if not self._animation_timer.isActive():
self._animation_timer.start()
return
is_queued = (
self.file_progress.total_size > 0 and
self.file_progress.percent == 0 and
self.file_progress.current_size == 0 and
self.file_progress.speed <= 0
)
if is_queued:
self._is_indeterminate = False
self._animation_timer.stop()
self.percent_label.setText("Queued")
self.progress_bar.setRange(0, 100)
self.progress_bar.setValue(0)
return
has_meaningful_progress = (
self.file_progress.percent > 0 or
(self.file_progress.total_size > 0 and self.file_progress.current_size > 0) or
(self.file_progress.speed > 0 and self.file_progress.percent >= 0)
)
if has_meaningful_progress:
self._is_indeterminate = False
self._target_percent = max(0, self.file_progress.percent)
if not self._animation_timer.isActive():
self._animation_timer.start()
self.progress_bar.setRange(0, 100)
else:
self._is_indeterminate = True
self.percent_label.setText("")
self.progress_bar.setRange(0, 100)
if not self._animation_timer.isActive():
self._animation_timer.start()
def _animate_progress(self):
if self._is_indeterminate:
self._spinner_position = (self._spinner_position + 4) % 200
if self._spinner_position < 100:
display_value = self._spinner_position
else:
display_value = 200 - self._spinner_position
self.progress_bar.setValue(display_value)
else:
diff = self._target_percent - self._current_display_percent
if abs(diff) >= 0.1:
self._current_display_percent += diff * 0.2
self._current_display_percent = max(0, min(100, self._current_display_percent))
display_percent = self._current_display_percent
self.progress_bar.setValue(int(display_percent))
if self.file_progress.percent > 0:
self.percent_label.setText(f"{display_percent:.0f}%")
else:
self.percent_label.setText("")
def update_progress(self, file_progress: FileProgress):
self.file_progress = file_progress
self._update_display()
def cleanup(self):
if self._animation_timer.isActive():
self._animation_timer.stop()

View File

@@ -11,14 +11,18 @@ import shiboken6
import time
from PySide6.QtWidgets import (
QWidget, QVBoxLayout, QLabel, QListWidget, QListWidgetItem,
QWidget, QVBoxLayout, QLabel, QListWidget, QListWidgetItem,
QProgressBar, QHBoxLayout, QSizePolicy
)
from PySide6.QtCore import Qt, QSize, QTimer
from PySide6.QtGui import QFont
from jackify.shared.progress_models import FileProgress, OperationType
from ..shared_theme import JACKIFY_COLOR_BLUE
from .summary_progress_widget import SummaryProgressWidget
from .file_progress_item import FileProgressItem
__all__ = ['SummaryProgressWidget', 'FileProgressItem', 'FileProgressList']
def _debug_log(message):
"""Log message only if debug mode is enabled"""
@@ -28,322 +32,6 @@ def _debug_log(message):
print(message)
class SummaryProgressWidget(QWidget):
"""Widget showing summary progress for phases like Installing."""
def __init__(self, phase_name: str, current_step: int, max_steps: int, parent=None):
super().__init__(parent)
self.phase_name = phase_name
self.current_step = current_step
self.max_steps = max_steps
# Smooth interpolation for counter updates
self._target_step = current_step
self._target_max = max_steps
self._display_step = current_step
self._display_max = max_steps
self._interpolation_timer = QTimer(self)
self._interpolation_timer.timeout.connect(self._interpolate_counter)
self._interpolation_timer.setInterval(16) # ~60fps
self._interpolation_timer.start()
self._setup_ui()
self._update_display()
def _setup_ui(self):
"""Set up the UI for summary display."""
layout = QVBoxLayout(self)
layout.setContentsMargins(8, 8, 8, 8)
layout.setSpacing(6)
# Text label showing phase and count (no progress bar for cleaner display)
self.text_label = QLabel()
self.text_label.setStyleSheet("color: #ccc; font-size: 12px; font-weight: bold;")
layout.addWidget(self.text_label)
def _interpolate_counter(self):
"""Smoothly interpolate counter display toward target values."""
# Interpolate step
step_diff = self._target_step - self._display_step
if abs(step_diff) < 0.5:
self._display_step = self._target_step
else:
# Smooth interpolation (20% per frame)
self._display_step += step_diff * 0.2
# Interpolate max (usually doesn't change, but handle it)
max_diff = self._target_max - self._display_max
if abs(max_diff) < 0.5:
self._display_max = self._target_max
else:
self._display_max += max_diff * 0.2
# Update display with interpolated values
self._update_display()
def _update_display(self):
"""Update the display with current progress."""
# Use interpolated display values for smooth counter updates
display_step = int(round(self._display_step))
display_max = int(round(self._display_max))
if display_max > 0:
new_text = f"{self.phase_name} ({display_step}/{display_max})"
else:
new_text = f"{self.phase_name}"
# Only update text if it changed (reduces repaints)
if self.text_label.text() != new_text:
self.text_label.setText(new_text)
def update_progress(self, current_step: int, max_steps: int):
"""Update target values (display will smoothly interpolate)."""
# Update targets (render loop will smoothly interpolate)
self._target_step = current_step
self._target_max = max_steps
# Also update actual values for reference
self.current_step = current_step
self.max_steps = max_steps
class FileProgressItem(QWidget):
"""Widget representing a single file's progress."""
def __init__(self, file_progress: FileProgress, parent=None):
super().__init__(parent)
self.file_progress = file_progress
self._target_percent = file_progress.percent # Target value for smooth animation
self._current_display_percent = file_progress.percent # Currently displayed value
self._spinner_position = 0 # For custom indeterminate spinner animation (0-200 range for smooth wraparound)
self._is_indeterminate = False # Track if we're in indeterminate mode
self._animation_timer = QTimer(self)
self._animation_timer.timeout.connect(self._animate_progress)
self._animation_timer.setInterval(16) # ~60fps for smooth animation
self._setup_ui()
self._update_display()
def _setup_ui(self):
"""Set up the UI for this file item."""
layout = QHBoxLayout(self)
layout.setContentsMargins(4, 2, 4, 2)
layout.setSpacing(8)
# Operation icon/indicator (simple text for now)
operation_label = QLabel(self._get_operation_symbol())
operation_label.setFixedWidth(20)
operation_label.setAlignment(Qt.AlignCenter)
operation_label.setStyleSheet(f"color: {JACKIFY_COLOR_BLUE}; font-weight: bold;")
layout.addWidget(operation_label)
# Filename (truncated if too long)
filename_label = QLabel(self._truncate_filename(self.file_progress.filename))
filename_label.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
filename_label.setToolTip(self.file_progress.filename) # Full name in tooltip
filename_label.setStyleSheet("color: #ccc; font-size: 11px;")
layout.addWidget(filename_label, 1)
self.filename_label = filename_label
# Progress percentage (only show if we have valid progress data)
percent_label = QLabel()
percent_label.setFixedWidth(40)
percent_label.setAlignment(Qt.AlignRight | Qt.AlignVCenter)
percent_label.setStyleSheet("color: #aaa; font-size: 11px;")
layout.addWidget(percent_label)
self.percent_label = percent_label
# Progress indicator: either progress bar (with %) or animated spinner (no %)
progress_bar = QProgressBar()
progress_bar.setFixedHeight(12)
progress_bar.setFixedWidth(80)
progress_bar.setTextVisible(False) # Hide text, we have percent label
# Apply stylesheet ONCE here instead of on every update
progress_bar.setStyleSheet(f"""
QProgressBar {{
border: 1px solid #444;
border-radius: 2px;
background-color: #1a1a1a;
}}
QProgressBar::chunk {{
background-color: {JACKIFY_COLOR_BLUE};
border-radius: 1px;
}}
""")
layout.addWidget(progress_bar)
self.progress_bar = progress_bar
def _get_operation_symbol(self) -> str:
"""Get symbol for operation type."""
symbols = {
OperationType.DOWNLOAD: "",
OperationType.EXTRACT: "",
OperationType.VALIDATE: "",
OperationType.INSTALL: "",
}
return symbols.get(self.file_progress.operation, "")
def _truncate_filename(self, filename: str, max_length: int = 40) -> str:
"""Truncate filename if too long."""
if len(filename) <= max_length:
return filename
return filename[:max_length-3] + "..."
def _update_display(self):
"""Update the display with current progress."""
# Check if this is a summary item (e.g., "Installing files (1234/5678)")
is_summary = hasattr(self.file_progress, '_is_summary') and self.file_progress._is_summary
# Check if progress bar should be hidden (e.g., "Installing Files: 234/35346")
no_progress_bar = hasattr(self.file_progress, '_no_progress_bar') and self.file_progress._no_progress_bar
# Update filename - DON'T truncate for install phase items
# Only truncate for download phase to keep consistency there
if 'Installing Files' in self.file_progress.filename or 'Converting Texture' in self.file_progress.filename or 'BSA:' in self.file_progress.filename:
name_display = self.file_progress.filename # Don't truncate
else:
name_display = self._truncate_filename(self.file_progress.filename)
if not is_summary and not no_progress_bar:
size_display = self.file_progress.size_display
if size_display:
name_display = f"{name_display} ({size_display})"
self.filename_label.setText(name_display)
self.filename_label.setToolTip(self.file_progress.filename)
# For items with _no_progress_bar flag (e.g., "Installing Files: 234/35346")
# Hide the progress bar and percentage - just show the text
if no_progress_bar:
self._animation_timer.stop() # Stop animation for items without progress bars
self.percent_label.setText("") # No percentage
self.progress_bar.setVisible(False) # Hide progress bar
return
# Ensure progress bar is visible for other items
self.progress_bar.setVisible(True)
# For summary items, calculate progress from step/max
if is_summary:
summary_step = getattr(self.file_progress, '_summary_step', 0)
summary_max = getattr(self.file_progress, '_summary_max', 0)
if summary_max > 0:
percent = (summary_step / summary_max) * 100.0
# Update target for smooth animation
self._target_percent = max(0, min(100, percent))
# Start animation timer if not already running
if not self._animation_timer.isActive():
self._animation_timer.start()
self.progress_bar.setRange(0, 100)
# Progress bar value will be updated by animation timer
else:
# No max for summary - use custom animated spinner
self._is_indeterminate = True
self.percent_label.setText("")
self.progress_bar.setRange(0, 100) # Use determinate range for custom animation
if not self._animation_timer.isActive():
self._animation_timer.start()
return
# Check if this is a queued item (not yet started)
# Queued items have total_size > 0 but percent == 0, current_size == 0, speed <= 0
is_queued = (
self.file_progress.total_size > 0 and
self.file_progress.percent == 0 and
self.file_progress.current_size == 0 and
self.file_progress.speed <= 0
)
if is_queued:
# Queued download - show "Queued" text with empty progress bar
self._is_indeterminate = False
self._animation_timer.stop()
self.percent_label.setText("Queued")
self.progress_bar.setRange(0, 100)
self.progress_bar.setValue(0)
return
# Check if we have meaningful progress data
# For operations like BSA building, we may not have percent or size data
has_meaningful_progress = (
self.file_progress.percent > 0 or
(self.file_progress.total_size > 0 and self.file_progress.current_size > 0) or
(self.file_progress.speed > 0 and self.file_progress.percent >= 0)
)
# Use determinate mode if we have actual progress data, otherwise use custom animated spinner
if has_meaningful_progress:
# Normal progress mode
self._is_indeterminate = False
# Update target for smooth animation
self._target_percent = max(0, self.file_progress.percent)
# Start animation timer if not already running
if not self._animation_timer.isActive():
self._animation_timer.start()
self.progress_bar.setRange(0, 100)
# Progress bar value will be updated by animation timer
else:
# No progress data (e.g., texture conversions, BSA building) - use custom animated spinner
self._is_indeterminate = True
self.percent_label.setText("") # Clear percent label
self.progress_bar.setRange(0, 100) # Use determinate range for custom animation
# Start animation timer for custom spinner
if not self._animation_timer.isActive():
self._animation_timer.start()
def _animate_progress(self):
"""Smoothly animate progress bar from current to target value, or animate spinner."""
if self._is_indeterminate:
# Custom indeterminate spinner animation
# Use a bouncing/pulsing effect: position moves 0-100-0 smoothly
# Increment by 4 units per frame for fast animation (full cycle in ~0.8s at 60fps)
self._spinner_position = (self._spinner_position + 4) % 200
# Create bouncing effect: 0->100->0
if self._spinner_position < 100:
display_value = self._spinner_position
else:
display_value = 200 - self._spinner_position
self.progress_bar.setValue(display_value)
else:
# Normal progress animation
# Calculate difference
diff = self._target_percent - self._current_display_percent
# If very close, snap to target and stop animation
if abs(diff) < 0.1:
self._current_display_percent = self._target_percent
self._animation_timer.stop()
else:
# Smooth interpolation (ease-out for natural feel)
# Move 20% of remaining distance per frame (~60fps = smooth)
self._current_display_percent += diff * 0.2
# Update display
display_percent = max(0, min(100, self._current_display_percent))
self.progress_bar.setValue(int(display_percent))
# Update percentage label
if self.file_progress.percent > 0:
self.percent_label.setText(f"{display_percent:.0f}%")
else:
self.percent_label.setText("")
def update_progress(self, file_progress: FileProgress):
"""Update with new progress data."""
self.file_progress = file_progress
self._update_display()
def cleanup(self):
"""Clean up resources when widget is no longer needed."""
if self._animation_timer.isActive():
self._animation_timer.stop()
class FileProgressList(QWidget):
"""
Widget displaying a list of files currently being processed.
@@ -584,6 +272,10 @@ class FileProgressList(QWidget):
elif fp.filename.startswith('BSA:'):
bsa_name = fp.filename.split('(')[0].strip()
current_keys.add(f"__bsa_{bsa_name}__")
elif fp.filename.startswith('Wine component:'):
rest = fp.filename.split(':', 1)[1].strip()
comp_id = rest.split('|')[0].strip() if '|' in rest else rest
current_keys.add(f"__wine_comp_{comp_id}__")
else:
current_keys.add(fp.filename)
@@ -608,15 +300,16 @@ class FileProgressList(QWidget):
if 'Installing Files:' in file_progress.filename:
item_key = "__installing_files__"
elif 'Converting Texture:' in file_progress.filename:
# Extract base filename for stable key
base_name = file_progress.filename.split('(')[0].strip()
item_key = f"__texture_{base_name}__"
elif file_progress.filename.startswith('BSA:'):
# Extract BSA filename for stable key
bsa_name = file_progress.filename.split('(')[0].strip()
item_key = f"__bsa_{bsa_name}__"
elif file_progress.filename.startswith('Wine component:'):
rest = file_progress.filename.split(':', 1)[1].strip()
comp_id = rest.split('|')[0].strip() if '|' in rest else rest
item_key = f"__wine_comp_{comp_id}__"
else:
# Use filename as key for regular files
item_key = file_progress.filename
if item_key in self._file_items:
@@ -686,6 +379,19 @@ class FileProgressList(QWidget):
# Remove transition message after brief delay (will be replaced by actual content)
# The next update_files call with actual content will clear this automatically
def clear_summary(self):
"""Remove the summary widget so file-list items can take over immediately."""
if self._summary_widget:
for i in range(self.list_widget.count()):
item = self.list_widget.item(i)
if item and item.data(Qt.UserRole) == "__summary__":
widget = self.list_widget.itemWidget(item)
if widget:
self.list_widget.removeItemWidget(item)
self.list_widget.takeItem(i)
break
self._summary_widget = None
def clear(self):
"""Clear all file items."""
# CRITICAL FIX: Remove all item widgets before clear() to prevent orphaned widgets
@@ -787,7 +493,7 @@ class FileProgressList(QWidget):
total_cpu = main_cpu
# Add CPU usage from ALL child processes recursively
# This includes jackify-engine, texconv.exe, wine processes, etc.
# Includes jackify-engine, texconv.exe, wine processes, etc.
child_count = 0
child_cpu_sum = 0.0
try:
@@ -825,8 +531,8 @@ class FileProgressList(QWidget):
pass
# Also search for ALL Jackify-related processes by name/cmdline
# This catches processes that may not be direct children (shell launches, Proton/wine wrappers, etc.)
# NOTE: Since children() is recursive, this typically only finds Proton spawn cases.
# Catches non-direct children: shell launches, Proton/wine wrappers, etc.
# children() is recursive, so typically only finds Proton spawn cases
tracked_pids = {self._cpu_process_cache.pid} # Avoid double-counting
tracked_pids.update(current_child_pids)
@@ -868,7 +574,7 @@ class FileProgressList(QWidget):
# Check command line (e.g., wine running jackify tools, or paths containing jackify)
if not is_jackify and cmdline_str:
# Check for jackify tool names in command line (catches wine running texconv.exe, etc.)
# This includes: texconv, texconv.exe, texdiag, 7z, 7zz, bsarch, jackify-engine
# Includes texconv, texconv.exe, texdiag, 7z, 7zz, bsarch, jackify-engine
is_jackify = any(name in cmdline_str for name in jackify_names)
# Also check for .exe variants (wine runs .exe files)

View File

@@ -136,7 +136,7 @@ class OverallProgressIndicator(QWidget):
eta_seconds = -1.0
if using_aggregated:
# For concurrent downloads: sum all active download speeds (not average)
# This gives us the combined throughput
# Combined throughput
active_speeds = [f.speed for f in progress.active_files if f.speed > 0]
if active_speeds:
combined_speed = sum(active_speeds) # Sum speeds for concurrent downloads
@@ -189,7 +189,7 @@ class OverallProgressIndicator(QWidget):
is_bsa_building = progress.get_phase_label() == "Building BSAs"
# For install/extract/download/BSA building phases, prefer step-based progress (more accurate)
# This prevents carrying over 100% from previous phases (e.g., .wabbajack download)
# Prevent carrying over 100% from previous phases
if progress.phase in (InstallationPhase.INSTALL, InstallationPhase.EXTRACT, InstallationPhase.DOWNLOAD) or is_bsa_building:
if progress.phase_max_steps > 0:
display_percent = (progress.phase_step / progress.phase_max_steps) * 100.0

View File

@@ -0,0 +1,67 @@
"""
Summary progress widget for phase display (e.g. Installing 123/456).
"""
from PySide6.QtWidgets import QWidget, QVBoxLayout, QLabel
from PySide6.QtCore import QTimer
class SummaryProgressWidget(QWidget):
"""Widget showing summary progress for phases like Installing."""
def __init__(self, phase_name: str, current_step: int, max_steps: int, parent=None):
super().__init__(parent)
self.phase_name = phase_name
self.current_step = current_step
self.max_steps = max_steps
self._target_step = current_step
self._target_max = max_steps
self._display_step = current_step
self._display_max = max_steps
self._interpolation_timer = QTimer(self)
self._interpolation_timer.timeout.connect(self._interpolate_counter)
self._interpolation_timer.setInterval(16)
self._interpolation_timer.start()
self._setup_ui()
self._update_display()
def _setup_ui(self):
layout = QVBoxLayout(self)
layout.setContentsMargins(8, 8, 8, 8)
layout.setSpacing(6)
self.text_label = QLabel()
self.text_label.setStyleSheet("color: #ccc; font-size: 12px; font-weight: bold;")
layout.addWidget(self.text_label)
def _interpolate_counter(self):
step_diff = self._target_step - self._display_step
if abs(step_diff) < 0.5:
self._display_step = self._target_step
else:
self._display_step += step_diff * 0.2
max_diff = self._target_max - self._display_max
if abs(max_diff) < 0.5:
self._display_max = self._target_max
else:
self._display_max += max_diff * 0.2
self._update_display()
def _update_display(self):
display_step = int(round(self._display_step))
display_max = int(round(self._display_max))
if display_max > 0:
new_text = f"{self.phase_name} ({display_step}/{display_max})"
else:
new_text = f"{self.phase_name}"
if self.text_label.text() != new_text:
self.text_label.setText(new_text)
def update_progress(self, current_step: int, max_steps: int):
self._target_step = current_step
self._target_max = max_steps
self.current_step = current_step
self.max_steps = max_steps