mirror of
https://github.com/Omni-guides/Jackify.git
synced 2026-06-08 00:07:45 +02:00
Sync from development - prepare for v0.3.0
This commit is contained in:
386
jackify/frontends/gui/dialogs/settings_dialog.py
Normal file
386
jackify/frontends/gui/dialogs/settings_dialog.py
Normal 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
|
||||
|
||||
|
||||
114
jackify/frontends/gui/dialogs/settings_dialog_proton.py
Normal file
114
jackify/frontends/gui/dialogs/settings_dialog_proton.py
Normal 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)
|
||||
280
jackify/frontends/gui/dialogs/settings_dialog_tabs.py
Normal file
280
jackify/frontends/gui/dialogs/settings_dialog_tabs.py
Normal 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")
|
||||
@@ -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
73
jackify/frontends/gui/mixins/main_window_backend.py
Normal file
73
jackify/frontends/gui/mixins/main_window_backend.py
Normal 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}")
|
||||
117
jackify/frontends/gui/mixins/main_window_dialogs.py
Normal file
117
jackify/frontends/gui/mixins/main_window_dialogs.py
Normal 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()
|
||||
207
jackify/frontends/gui/mixins/main_window_geometry.py
Normal file
207
jackify/frontends/gui/mixins/main_window_geometry.py
Normal 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()
|
||||
102
jackify/frontends/gui/mixins/main_window_startup.py
Normal file
102
jackify/frontends/gui/mixins/main_window_startup.py
Normal 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}")
|
||||
187
jackify/frontends/gui/mixins/main_window_ui.py
Normal file
187
jackify/frontends/gui/mixins/main_window_ui.py
Normal 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)
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
564
jackify/frontends/gui/screens/configure_existing_modlist_ui.py
Normal file
564
jackify/frontends/gui/screens/configure_existing_modlist_ui.py
Normal 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}]")
|
||||
|
||||
|
||||
@@ -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
172
jackify/frontends/gui/screens/configure_new_modlist_console.py
Normal file
172
jackify/frontends/gui/screens/configure_new_modlist_console.py
Normal 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)
|
||||
|
||||
|
||||
354
jackify/frontends/gui/screens/configure_new_modlist_dialogs.py
Normal file
354
jackify/frontends/gui/screens/configure_new_modlist_dialogs.py
Normal 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()
|
||||
|
||||
|
||||
631
jackify/frontends/gui/screens/configure_new_modlist_ui_setup.py
Normal file
631
jackify/frontends/gui/screens/configure_new_modlist_ui_setup.py
Normal 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
|
||||
|
||||
|
||||
524
jackify/frontends/gui/screens/configure_new_modlist_workflow.py
Normal file
524
jackify/frontends/gui/screens/configure_new_modlist_workflow.py
Normal 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
@@ -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)
|
||||
|
||||
625
jackify/frontends/gui/screens/install_modlist_configuration.py
Normal file
625
jackify/frontends/gui/screens/install_modlist_configuration.py
Normal 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)
|
||||
|
||||
368
jackify/frontends/gui/screens/install_modlist_console.py
Normal file
368
jackify/frontends/gui/screens/install_modlist_console.py
Normal 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
|
||||
|
||||
327
jackify/frontends/gui/screens/install_modlist_dialogs.py
Normal file
327
jackify/frontends/gui/screens/install_modlist_dialogs.py
Normal 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()
|
||||
|
||||
@@ -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()
|
||||
260
jackify/frontends/gui/screens/install_modlist_nexus.py
Normal file
260
jackify/frontends/gui/screens/install_modlist_nexus.py
Normal 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"
|
||||
)
|
||||
|
||||
228
jackify/frontends/gui/screens/install_modlist_output_mixin.py
Normal file
228
jackify/frontends/gui/screens/install_modlist_output_mixin.py
Normal 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)
|
||||
470
jackify/frontends/gui/screens/install_modlist_postinstall.py
Normal file
470
jackify/frontends/gui/screens/install_modlist_postinstall.py
Normal 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
|
||||
|
||||
413
jackify/frontends/gui/screens/install_modlist_progress.py
Normal file
413
jackify/frontends/gui/screens/install_modlist_progress.py
Normal 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)
|
||||
|
||||
195
jackify/frontends/gui/screens/install_modlist_selection.py
Normal file
195
jackify/frontends/gui/screens/install_modlist_selection.py
Normal 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)
|
||||
|
||||
114
jackify/frontends/gui/screens/install_modlist_shortcut_dialog.py
Normal file
114
jackify/frontends/gui/screens/install_modlist_shortcut_dialog.py
Normal 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()
|
||||
222
jackify/frontends/gui/screens/install_modlist_ttw.py
Normal file
222
jackify/frontends/gui/screens/install_modlist_ttw.py
Normal 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)}"
|
||||
)
|
||||
|
||||
519
jackify/frontends/gui/screens/install_modlist_ui_setup.py
Normal file
519
jackify/frontends/gui/screens/install_modlist_ui_setup.py
Normal 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()
|
||||
|
||||
208
jackify/frontends/gui/screens/install_modlist_vnv.py
Normal file
208
jackify/frontends/gui/screens/install_modlist_vnv.py
Normal 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}")
|
||||
|
||||
371
jackify/frontends/gui/screens/install_modlist_workflow.py
Normal file
371
jackify/frontends/gui/screens/install_modlist_workflow.py
Normal 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
657
jackify/frontends/gui/screens/install_ttw_config.py
Normal file
657
jackify/frontends/gui/screens/install_ttw_config.py
Normal 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)
|
||||
|
||||
290
jackify/frontends/gui/screens/install_ttw_installer.py
Normal file
290
jackify/frontends/gui/screens/install_ttw_installer.py
Normal 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")
|
||||
|
||||
322
jackify/frontends/gui/screens/install_ttw_integration.py
Normal file
322
jackify/frontends/gui/screens/install_ttw_integration.py
Normal 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
|
||||
|
||||
155
jackify/frontends/gui/screens/install_ttw_lifecycle.py
Normal file
155
jackify/frontends/gui/screens/install_ttw_lifecycle.py
Normal 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
|
||||
|
||||
298
jackify/frontends/gui/screens/install_ttw_requirements.py
Normal file
298
jackify/frontends/gui/screens/install_ttw_requirements.py
Normal 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")
|
||||
|
||||
275
jackify/frontends/gui/screens/install_ttw_ui.py
Normal file
275
jackify/frontends/gui/screens/install_ttw_ui.py
Normal 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('&', '&').replace('<', '<').replace('>', '>')
|
||||
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")
|
||||
|
||||
368
jackify/frontends/gui/screens/install_ttw_ui_setup.py
Normal file
368
jackify/frontends/gui/screens/install_ttw_ui_setup.py
Normal 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 = []
|
||||
|
||||
681
jackify/frontends/gui/screens/install_ttw_workflow.py
Normal file
681
jackify/frontends/gui/screens/install_ttw_workflow.py
Normal 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('&', '&').replace('<', '<').replace('>', '>')
|
||||
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('&', '&').replace('<', '<').replace('>', '>')
|
||||
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)
|
||||
|
||||
@@ -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
208
jackify/frontends/gui/screens/modlist_gallery_card.py
Normal file
208
jackify/frontends/gui/screens/modlist_gallery_card.py
Normal 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)
|
||||
451
jackify/frontends/gui/screens/modlist_gallery_detail.py
Normal file
451
jackify/frontends/gui/screens/modlist_gallery_detail.py
Normal 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
|
||||
)
|
||||
302
jackify/frontends/gui/screens/modlist_gallery_filters.py
Normal file
302
jackify/frontends/gui/screens/modlist_gallery_filters.py
Normal 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()
|
||||
)
|
||||
|
||||
|
||||
141
jackify/frontends/gui/screens/modlist_gallery_image_manager.py
Normal file
141
jackify/frontends/gui/screens/modlist_gallery_image_manager.py
Normal 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
|
||||
401
jackify/frontends/gui/screens/modlist_gallery_loading.py
Normal file
401
jackify/frontends/gui/screens/modlist_gallery_loading.py
Normal 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()
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
50
jackify/frontends/gui/screens/screen_back_mixin.py
Normal file
50
jackify/frontends/gui/screens/screen_back_mixin.py
Normal 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)
|
||||
@@ -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"""
|
||||
|
||||
@@ -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
|
||||
22
jackify/frontends/gui/widgets/feature_placeholder.py
Normal file
22
jackify/frontends/gui/widgets/feature_placeholder.py
Normal 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)
|
||||
195
jackify/frontends/gui/widgets/file_progress_item.py
Normal file
195
jackify/frontends/gui/widgets/file_progress_item.py
Normal 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()
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
67
jackify/frontends/gui/widgets/summary_progress_widget.py
Normal file
67
jackify/frontends/gui/widgets/summary_progress_widget.py
Normal 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
|
||||
Reference in New Issue
Block a user