Sync from development - prepare for v0.2.0

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

View File

@@ -25,7 +25,6 @@ from .commands.install_modlist import InstallModlistCommand
# Import our menu handlers
from .menus.main_menu import MainMenuHandler
from .menus.wabbajack_menu import WabbajackMenuHandler
from .menus.hoolamike_menu import HoolamikeMenuHandler
from .menus.additional_menu import AdditionalMenuHandler
# Import backend handlers for legacy compatibility
@@ -59,10 +58,18 @@ class JackifyCLI:
# Configure logging to be quiet by default - will be adjusted after arg parsing
self._configure_logging_early()
# Determine system info
self.system_info = SystemInfo(is_steamdeck=self._is_steamdeck())
# Detect Steam installation types once at startup
from ...shared.steam_utils import detect_steam_installation_types
is_flatpak, is_native = detect_steam_installation_types()
# Determine system info with Steam detection
self.system_info = SystemInfo(
is_steamdeck=self._is_steamdeck(),
is_flatpak_steam=is_flatpak,
is_native_steam=is_native
)
# Apply resource limits for optimal operation
self._apply_resource_limits()
@@ -290,7 +297,6 @@ class JackifyCLI:
menus = {
'main': MainMenuHandler(dev_mode=getattr(self, 'dev_mode', False)),
'wabbajack': WabbajackMenuHandler(),
'hoolamike': HoolamikeMenuHandler(),
'additional': AdditionalMenuHandler()
}
@@ -419,9 +425,6 @@ class JackifyCLI:
elif command == "install-wabbajack":
# Legacy functionality - TODO: extract to command handler
return self._handle_legacy_install_wabbajack()
elif command == "hoolamike":
# Legacy functionality - TODO: extract to command handler
return self._handle_legacy_hoolamike()
elif command == "install-mo2":
print("MO2 installation not yet implemented")
print("This functionality is coming soon!")
@@ -495,9 +498,6 @@ class JackifyCLI:
print("Install Wabbajack functionality not yet migrated to new structure")
return 1
def _handle_legacy_hoolamike(self):
"""Handle hoolamike command (legacy functionality)"""
print("Hoolamike functionality not yet migrated to new structure")
return 1
def _handle_legacy_recovery(self, args):

View File

@@ -5,14 +5,12 @@ Extracted from the legacy monolithic CLI system
from .main_menu import MainMenuHandler
from .wabbajack_menu import WabbajackMenuHandler
from .hoolamike_menu import HoolamikeMenuHandler
from .additional_menu import AdditionalMenuHandler
from .recovery_menu import RecoveryMenuHandler
__all__ = [
'MainMenuHandler',
'WabbajackMenuHandler',
'HoolamikeMenuHandler',
'AdditionalMenuHandler',
'RecoveryMenuHandler'
]

View File

@@ -29,19 +29,23 @@ class AdditionalMenuHandler:
self._clear_screen()
print_jackify_banner()
print_section_header("Additional Tasks & Tools")
print(f"{COLOR_INFO}Additional Tasks & Tools, such as TTW Installation{COLOR_RESET}\n")
print(f"{COLOR_INFO}Nexus Authentication, TTW Install & more{COLOR_RESET}\n")
print(f"{COLOR_SELECTION}1.{COLOR_RESET} Tale of Two Wastelands (TTW) Installation")
print(f" {COLOR_ACTION}Install TTW using Hoolamike native automation{COLOR_RESET}")
print(f"{COLOR_SELECTION}2.{COLOR_RESET} Coming Soon...")
print(f"{COLOR_SELECTION}1.{COLOR_RESET} Nexus Mods Authorization")
print(f" {COLOR_ACTION}Authorize with Nexus using OAuth or manage API key{COLOR_RESET}")
print(f"{COLOR_SELECTION}2.{COLOR_RESET} Tale of Two Wastelands (TTW) Installation")
print(f" {COLOR_ACTION}→ Install TTW using TTW_Linux_Installer{COLOR_RESET}")
print(f"{COLOR_SELECTION}3.{COLOR_RESET} Coming Soon...")
print(f"{COLOR_SELECTION}0.{COLOR_RESET} Return to Main Menu")
selection = input(f"\n{COLOR_PROMPT}Enter your selection (0-2): {COLOR_RESET}").strip()
selection = input(f"\n{COLOR_PROMPT}Enter your selection (0-3): {COLOR_RESET}").strip()
if selection.lower() == 'q': # Allow 'q' to re-display menu
continue
if selection == "1":
self._execute_hoolamike_ttw_install(cli_instance)
self._execute_nexus_authorization(cli_instance)
elif selection == "2":
self._execute_ttw_install(cli_instance)
elif selection == "3":
print(f"\n{COLOR_INFO}More features coming soon!{COLOR_RESET}")
input("\nPress Enter to return to menu...")
elif selection == "0":
@@ -68,57 +72,211 @@ class AdditionalMenuHandler:
recovery_handler.logger = self.logger
recovery_handler.show_recovery_menu(cli_instance)
def _execute_hoolamike_ttw_install(self, cli_instance):
"""Execute TTW installation using Hoolamike handler"""
from ....backend.handlers.hoolamike_handler import HoolamikeHandler
def _execute_ttw_install(self, cli_instance):
"""Execute TTW installation using TTW_Linux_Installer handler"""
from ....backend.handlers.ttw_installer_handler import TTWInstallerHandler
from ....backend.models.configuration import SystemInfo
from ....shared.colors import COLOR_ERROR
from ....shared.colors import COLOR_ERROR, COLOR_WARNING, COLOR_SUCCESS, COLOR_INFO, COLOR_PROMPT
from pathlib import Path
system_info = SystemInfo(is_steamdeck=cli_instance.system_info.is_steamdeck)
hoolamike_handler = HoolamikeHandler(
ttw_installer_handler = TTWInstallerHandler(
steamdeck=system_info.is_steamdeck,
verbose=cli_instance.verbose,
filesystem_handler=cli_instance.filesystem_handler,
config_handler=cli_instance.config_handler,
menu_handler=cli_instance.menu_handler
config_handler=cli_instance.config_handler
)
# First check if Hoolamike is installed
if not hoolamike_handler.hoolamike_installed:
print(f"\n{COLOR_WARNING}Hoolamike is not installed. Installing Hoolamike first...{COLOR_RESET}")
if not hoolamike_handler.install_update_hoolamike():
print(f"{COLOR_ERROR}Failed to install Hoolamike. Cannot proceed with TTW installation.{COLOR_RESET}")
# First check if TTW_Linux_Installer is installed
if not ttw_installer_handler.ttw_installer_installed:
print(f"\n{COLOR_WARNING}TTW_Linux_Installer is not installed. Installing TTW_Linux_Installer first...{COLOR_RESET}")
success, message = ttw_installer_handler.install_ttw_installer()
if not success:
print(f"{COLOR_ERROR}Failed to install TTW_Linux_Installer. Cannot proceed with TTW installation.{COLOR_RESET}")
print(f"{COLOR_ERROR}Error: {message}{COLOR_RESET}")
input("Press Enter to return to menu...")
return
# Run TTW installation workflow
# Check for required games
detected_games = ttw_installer_handler.path_handler.find_vanilla_game_paths()
required_games = ['Fallout 3', 'Fallout New Vegas']
missing_games = [game for game in required_games if game not in detected_games]
if missing_games:
print(f"\n{COLOR_ERROR}Missing required games: {', '.join(missing_games)}")
print(f"TTW requires both Fallout 3 and Fallout New Vegas to be installed.{COLOR_RESET}")
input("Press Enter to return to menu...")
return
# Prompt for TTW .mpi file
print(f"\n{COLOR_PROMPT}TTW Installer File (.mpi){COLOR_RESET}")
mpi_path = input(f"{COLOR_PROMPT}Path to TTW .mpi file: {COLOR_RESET}").strip()
if not mpi_path:
print(f"{COLOR_WARNING}No .mpi file specified. Cancelling.{COLOR_RESET}")
input("Press Enter to return to menu...")
return
mpi_path = Path(mpi_path).expanduser()
if not mpi_path.exists() or not mpi_path.is_file():
print(f"{COLOR_ERROR}TTW .mpi file not found: {mpi_path}{COLOR_RESET}")
input("Press Enter to return to menu...")
return
# Prompt for output directory
print(f"\n{COLOR_PROMPT}TTW Installation Directory{COLOR_RESET}")
default_output = Path.home() / "ModdedGames" / "TTW"
output_path = input(f"{COLOR_PROMPT}TTW install directory (Enter for default: {default_output}): {COLOR_RESET}").strip()
if not output_path:
output_path = default_output
else:
output_path = Path(output_path).expanduser()
# Run TTW installation
print(f"\n{COLOR_INFO}Starting TTW installation workflow...{COLOR_RESET}")
result = hoolamike_handler.install_ttw()
if result is None:
print(f"\n{COLOR_WARNING}TTW installation returned without result.{COLOR_RESET}")
success, message = ttw_installer_handler.install_ttw_backend(mpi_path, output_path)
if success:
print(f"\n{COLOR_SUCCESS}TTW installation completed successfully!{COLOR_RESET}")
print(f"{COLOR_INFO}TTW installed to: {output_path}{COLOR_RESET}")
else:
print(f"\n{COLOR_ERROR}TTW installation failed.{COLOR_RESET}")
print(f"{COLOR_ERROR}Error: {message}{COLOR_RESET}")
input("Press Enter to return to menu...")
def _execute_hoolamike_modlist_install(self, cli_instance):
"""Execute modlist installation using Hoolamike handler"""
from ....backend.handlers.hoolamike_handler import HoolamikeHandler
from ....backend.models.configuration import SystemInfo
def _execute_nexus_authorization(self, cli_instance):
"""Execute Nexus authorization menu (OAuth or API key)"""
from ....backend.services.nexus_auth_service import NexusAuthService
from ....backend.services.api_key_service import APIKeyService
from ....shared.colors import COLOR_ERROR, COLOR_SUCCESS
system_info = SystemInfo(is_steamdeck=cli_instance.system_info.is_steamdeck)
hoolamike_handler = HoolamikeHandler(
steamdeck=system_info.is_steamdeck,
verbose=cli_instance.verbose,
filesystem_handler=cli_instance.filesystem_handler,
config_handler=cli_instance.config_handler,
menu_handler=cli_instance.menu_handler
)
auth_service = NexusAuthService()
# First check if Hoolamike is installed
if not hoolamike_handler.hoolamike_installed:
print(f"\n{COLOR_WARNING}Hoolamike is not installed. Installing Hoolamike first...{COLOR_RESET}")
if not hoolamike_handler.install_update_hoolamike():
print(f"{COLOR_ERROR}Failed to install Hoolamike. Cannot proceed with modlist installation.{COLOR_RESET}")
input("Press Enter to return to menu...")
return
while True:
self._clear_screen()
print_jackify_banner()
print_section_header("Nexus Mods Authorization")
# Run modlist installation
hoolamike_handler.install_modlist()
# Get current auth status
authenticated, method, username = auth_service.get_auth_status()
if authenticated:
if method == 'oauth':
print(f"\n{COLOR_SUCCESS}Status: Authorized via OAuth{COLOR_RESET}")
if username:
print(f"{COLOR_INFO}Logged in as: {username}{COLOR_RESET}")
elif method == 'api_key':
print(f"\n{COLOR_WARNING}Status: Using API Key (Legacy){COLOR_RESET}")
print(f"{COLOR_INFO}Consider switching to OAuth for better security{COLOR_RESET}")
else:
print(f"\n{COLOR_WARNING}Status: Not Authorized{COLOR_RESET}")
print(f"{COLOR_INFO}You need to authorize to download mods from Nexus{COLOR_RESET}")
print(f"\n{COLOR_SELECTION}1.{COLOR_RESET} Authorize with Nexus (OAuth)")
print(f" {COLOR_ACTION}→ Opens browser for secure authorization{COLOR_RESET}")
if method == 'oauth':
print(f"{COLOR_SELECTION}2.{COLOR_RESET} Revoke OAuth Authorization")
print(f" {COLOR_ACTION}→ Remove OAuth token{COLOR_RESET}")
print(f"{COLOR_SELECTION}3.{COLOR_RESET} Set API Key (Legacy Fallback)")
print(f" {COLOR_ACTION}→ Manually enter Nexus API key{COLOR_RESET}")
if authenticated:
print(f"{COLOR_SELECTION}4.{COLOR_RESET} Clear All Authentication")
print(f" {COLOR_ACTION}→ Remove both OAuth and API key{COLOR_RESET}")
print(f"{COLOR_SELECTION}0.{COLOR_RESET} Return to Additional Tasks Menu")
selection = input(f"\n{COLOR_PROMPT}Enter your selection: {COLOR_RESET}").strip()
if selection == "1":
# OAuth authorization
print(f"\n{COLOR_INFO}Starting OAuth authorization...{COLOR_RESET}")
print(f"{COLOR_WARNING}Your browser will open shortly.{COLOR_RESET}")
print(f"{COLOR_WARNING}Please check your browser and authorize Jackify.{COLOR_RESET}")
print(f"\n{COLOR_INFO}Note: Your browser may ask permission to open 'xdg-open' or{COLOR_RESET}")
print(f"{COLOR_INFO}Jackify's protocol handler - please click 'Open' or 'Allow'.{COLOR_RESET}")
input(f"\n{COLOR_PROMPT}Press Enter to open browser...{COLOR_RESET}")
# Perform OAuth authorization
def show_message(msg):
print(f"\n{COLOR_INFO}{msg}{COLOR_RESET}")
success = auth_service.authorize_oauth(show_browser_message_callback=show_message)
if success:
print(f"\n{COLOR_SUCCESS}OAuth authorization successful!{COLOR_RESET}")
# Get username
_, _, username = auth_service.get_auth_status()
if username:
print(f"{COLOR_INFO}Authorized as: {username}{COLOR_RESET}")
else:
print(f"\n{COLOR_ERROR}OAuth authorization failed.{COLOR_RESET}")
print(f"{COLOR_INFO}You can try again or use API key as fallback.{COLOR_RESET}")
input(f"\n{COLOR_PROMPT}Press Enter to continue...{COLOR_RESET}")
elif selection == "2" and method == 'oauth':
# Revoke OAuth
print(f"\n{COLOR_WARNING}Are you sure you want to revoke OAuth authorization?{COLOR_RESET}")
confirm = input(f"{COLOR_PROMPT}Type 'yes' to confirm: {COLOR_RESET}").strip().lower()
if confirm == 'yes':
if auth_service.revoke_oauth():
print(f"\n{COLOR_SUCCESS}OAuth authorization revoked.{COLOR_RESET}")
else:
print(f"\n{COLOR_ERROR}Failed to revoke OAuth authorization.{COLOR_RESET}")
else:
print(f"\n{COLOR_INFO}Cancelled.{COLOR_RESET}")
input(f"\n{COLOR_PROMPT}Press Enter to continue...{COLOR_RESET}")
elif selection == "3":
# Set API key
print(f"\n{COLOR_INFO}Enter your Nexus API Key{COLOR_RESET}")
print(f"{COLOR_INFO}(Get it from: https://www.nexusmods.com/users/myaccount?tab=api){COLOR_RESET}")
api_key = input(f"\n{COLOR_PROMPT}API Key: {COLOR_RESET}").strip()
if api_key:
if auth_service.save_api_key(api_key):
print(f"\n{COLOR_SUCCESS}API key saved successfully.{COLOR_RESET}")
# Optionally validate
print(f"\n{COLOR_INFO}Validating API key...{COLOR_RESET}")
valid, result = auth_service.validate_api_key(api_key)
if valid:
print(f"{COLOR_SUCCESS}API key validated successfully!{COLOR_RESET}")
print(f"{COLOR_INFO}Username: {result}{COLOR_RESET}")
else:
print(f"{COLOR_WARNING}Warning: API key validation failed: {result}{COLOR_RESET}")
print(f"{COLOR_INFO}Key saved, but may not work correctly.{COLOR_RESET}")
else:
print(f"\n{COLOR_ERROR}Failed to save API key.{COLOR_RESET}")
else:
print(f"\n{COLOR_INFO}Cancelled.{COLOR_RESET}")
input(f"\n{COLOR_PROMPT}Press Enter to continue...{COLOR_RESET}")
elif selection == "4" and authenticated:
# Clear all authentication
print(f"\n{COLOR_WARNING}Are you sure you want to clear ALL authentication?{COLOR_RESET}")
print(f"{COLOR_WARNING}This will remove both OAuth token and API key.{COLOR_RESET}")
confirm = input(f"{COLOR_PROMPT}Type 'yes' to confirm: {COLOR_RESET}").strip().lower()
if confirm == 'yes':
if auth_service.clear_all_auth():
print(f"\n{COLOR_SUCCESS}All authentication cleared.{COLOR_RESET}")
else:
print(f"\n{COLOR_INFO}No authentication to clear.{COLOR_RESET}")
else:
print(f"\n{COLOR_INFO}Cancelled.{COLOR_RESET}")
input(f"\n{COLOR_PROMPT}Press Enter to continue...{COLOR_RESET}")
elif selection == "0":
break
else:
print(f"\n{COLOR_ERROR}Invalid selection.{COLOR_RESET}")
time.sleep(1)

View File

@@ -1,32 +0,0 @@
"""
Hoolamike Menu Handler for Jackify CLI Frontend
Extracted from src.modules.menu_handler.MenuHandler.show_hoolamike_menu()
"""
from jackify.shared.colors import COLOR_INFO, COLOR_PROMPT, COLOR_RESET
class HoolamikeMenuHandler:
"""
Handles the Hoolamike Tasks menu
Extracted from legacy MenuHandler class
"""
def __init__(self):
self.logger = None # Will be set by CLI when needed
def show_hoolamike_menu(self, cli_instance):
"""
LEGACY BRIDGE: Delegate to legacy menu handler until full backend migration
Args:
cli_instance: Reference to main CLI instance for access to handlers
"""
print(f"{COLOR_INFO}Hoolamike menu functionality has been extracted but needs migration to backend services.{COLOR_RESET}")
print(f"{COLOR_INFO}This will be implemented in Phase 2.3 (Menu Backend Integration).{COLOR_RESET}")
# LEGACY BRIDGE: Use the original menu handler's method
if hasattr(cli_instance, 'menu') and hasattr(cli_instance.menu, 'show_hoolamike_menu'):
cli_instance.menu.show_hoolamike_menu(cli_instance)
else:
print(f"{COLOR_INFO}Legacy menu handler not available - returning to main menu.{COLOR_RESET}")
input(f"{COLOR_PROMPT}Press Enter to continue...{COLOR_RESET}")

View File

@@ -5,7 +5,123 @@ Entry point for Jackify GUI Frontend
Usage: python -m jackify.frontends.gui
"""
from jackify.frontends.gui.main import main
import sys
from pathlib import Path
def main():
# Check if launched with jackify:// protocol URL (OAuth callback)
if len(sys.argv) > 1 and sys.argv[1].startswith('jackify://'):
handle_protocol_url(sys.argv[1])
return
# Normal GUI launch
from jackify.frontends.gui.main import main as gui_main
gui_main()
def handle_protocol_url(url: str):
"""Handle jackify:// protocol URL (OAuth callback)"""
import os
import sys
# Enhanced logging with system information
try:
from jackify.shared.paths import get_jackify_logs_dir
log_dir = get_jackify_logs_dir()
except Exception as e:
# Fallback if config system fails
log_dir = Path.home() / ".config" / "jackify" / "logs"
log_dir.mkdir(parents=True, exist_ok=True)
log_file = log_dir / "protocol_handler.log"
def log(msg):
with open(log_file, 'a') as f:
import datetime
timestamp = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
f.write(f"[{timestamp}] {msg}\n")
f.flush() # Ensure immediate write
try:
# Log system information for debugging
log(f"=== Protocol Handler Invoked ===")
log(f"URL: {url}")
log(f"Python executable: {sys.executable}")
log(f"Script path: {sys.argv[0]}")
log(f"Working directory: {os.getcwd()}")
log(f"APPIMAGE env: {os.environ.get('APPIMAGE', 'Not set')}")
log(f"APPDIR env: {os.environ.get('APPDIR', 'Not set')}")
from urllib.parse import urlparse, parse_qs
parsed = urlparse(url)
log(f"Parsed URL - scheme: {parsed.scheme}, netloc: {parsed.netloc}, path: {parsed.path}, query: {parsed.query}")
# URL format: jackify://oauth/callback?code=XXX&state=YYY
# urlparse treats "oauth" as netloc, so reconstruct full path
full_path = f"/{parsed.netloc}{parsed.path}" if parsed.netloc else parsed.path
log(f"Reconstructed path: {full_path}")
if full_path == '/oauth/callback':
params = parse_qs(parsed.query)
code = params.get('code', [None])[0]
state = params.get('state', [None])[0]
error = params.get('error', [None])[0]
log(f"OAuth parameters - Code: {'Present' if code else 'Missing'}, State: {'Present' if state else 'Missing'}, Error: {error}")
if error:
log(f"ERROR: OAuth error received: {error}")
error_description = params.get('error_description', ['No description'])[0]
log(f"ERROR: OAuth error description: {error_description}")
print(f"OAuth authorization failed: {error} - {error_description}")
elif code and state:
# Write to callback file for OAuth service to pick up
callback_file = Path.home() / ".config" / "jackify" / "oauth_callback.tmp"
log(f"Creating callback file: {callback_file}")
try:
callback_file.parent.mkdir(parents=True, exist_ok=True)
callback_content = f"{code}\n{state}"
callback_file.write_text(callback_content)
# Verify file was written
if callback_file.exists():
written_content = callback_file.read_text()
log(f"Callback file created successfully, size: {len(written_content)} bytes")
print("OAuth callback received and saved successfully")
else:
log("ERROR: Callback file was not created")
print("Error: Failed to create callback file")
except Exception as callback_error:
log(f"ERROR: Failed to write callback file: {callback_error}")
print(f"Error writing callback file: {callback_error}")
else:
log("ERROR: Missing required OAuth parameters (code or state)")
print("Invalid OAuth callback - missing required parameters")
else:
log(f"ERROR: Unknown protocol path: {full_path}")
print(f"Unknown protocol path: {full_path}")
log("=== Protocol Handler Completed ===")
except Exception as e:
log(f"CRITICAL EXCEPTION: {e}")
import traceback
log(f"TRACEBACK:\n{traceback.format_exc()}")
print(f"Critical error handling protocol URL: {e}")
# Try to log to a fallback location if main logging fails
try:
fallback_log = Path.home() / "jackify_protocol_error.log"
with open(fallback_log, 'a') as f:
import datetime
timestamp = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
f.write(f"[{timestamp}] CRITICAL ERROR: {e}\n")
f.write(f"URL: {url}\n")
f.write(f"Traceback:\n{traceback.format_exc()}\n\n")
except:
pass # If even fallback logging fails, just continue
if __name__ == "__main__":
main()

View File

@@ -304,11 +304,12 @@ class AboutDialog(QDialog):
def _get_engine_version(self) -> str:
"""Get jackify-engine version."""
try:
# Try to execute jackify-engine --version
# Try to execute jackify-engine --version
engine_path = Path(__file__).parent.parent.parent.parent / "engine" / "jackify-engine"
if engine_path.exists():
result = subprocess.run([str(engine_path), "--version"],
capture_output=True, text=True, timeout=5)
from jackify.backend.handlers.subprocess_utils import get_clean_subprocess_env
result = subprocess.run([str(engine_path), "--version"],
capture_output=True, text=True, timeout=5, env=get_clean_subprocess_env())
if result.returncode == 0:
version = result.stdout.strip()
# Extract just the version number (before the +commit hash)

View File

@@ -23,8 +23,10 @@ class WarningDialog(QDialog):
self.setWindowTitle("Warning!")
self.setModal(True)
# Increased height for better text display, scalable for 800p screens
self.setFixedSize(500, 440)
self.setFixedSize(500, 460)
self.confirmed = False
self._failed_attempts = 0
self._max_attempts = 3
self._setup_ui(warning_message)
def _setup_ui(self, warning_message):
@@ -99,21 +101,21 @@ class WarningDialog(QDialog):
card_layout.addWidget(message_text)
# Confirmation entry
confirm_label = QLabel("Type 'DELETE' to confirm:")
confirm_label.setAlignment(Qt.AlignCenter)
confirm_label.setStyleSheet(
self.confirm_label = QLabel("Type 'DELETE' to confirm (all caps):")
self.confirm_label.setAlignment(Qt.AlignCenter)
self.confirm_label.setStyleSheet(
"QLabel { "
" font-size: 13px; "
" color: #e67e22; "
" margin-bottom: 2px; "
"}"
)
card_layout.addWidget(confirm_label)
card_layout.addWidget(self.confirm_label)
self.confirm_edit = QLineEdit()
self.confirm_edit.setAlignment(Qt.AlignCenter)
self.confirm_edit.setPlaceholderText("DELETE")
self.confirm_edit.setStyleSheet(
self._default_lineedit_style = (
"QLineEdit { "
" font-size: 15px; "
" border: 1px solid #e67e22; "
@@ -123,6 +125,9 @@ class WarningDialog(QDialog):
" color: #e67e22; "
"}"
)
self.confirm_edit.setStyleSheet(self._default_lineedit_style)
self.confirm_edit.textChanged.connect(self._on_text_changed)
self.confirm_edit.returnPressed.connect(self._on_confirm) # Handle Enter key
card_layout.addWidget(self.confirm_edit)
# Action buttons
@@ -178,11 +183,69 @@ class WarningDialog(QDialog):
layout.addWidget(card, alignment=Qt.AlignCenter)
layout.addStretch()
def _on_text_changed(self):
"""Reset error styling when user starts typing again."""
# Only reset if currently showing error state (darker background)
if "#3b2323" in self.confirm_edit.styleSheet():
self.confirm_edit.setStyleSheet(self._default_lineedit_style)
self.confirm_edit.setPlaceholderText("DELETE")
# Reset label but keep attempt counter if attempts were made
if self._failed_attempts > 0:
remaining = self._max_attempts - self._failed_attempts
self.confirm_label.setText(f"Type 'DELETE' to confirm (all caps) - {remaining} attempt(s) remaining:")
else:
self.confirm_label.setText("Type 'DELETE' to confirm (all caps):")
self.confirm_label.setStyleSheet(
"QLabel { "
" font-size: 13px; "
" color: #e67e22; "
" margin-bottom: 2px; "
"}"
)
def _on_confirm(self):
if self.confirm_edit.text().strip().upper() == "DELETE":
entered_text = self.confirm_edit.text().strip()
if entered_text == "DELETE":
# Correct - proceed
self.confirmed = True
self.accept()
else:
self.confirm_edit.setText("")
self.confirm_edit.setPlaceholderText("Type DELETE to confirm")
self.confirm_edit.setStyleSheet(self.confirm_edit.styleSheet() + "QLineEdit { background: #3b2323; }")
# Wrong text entered
self._failed_attempts += 1
if self._failed_attempts >= self._max_attempts:
# Too many failed attempts - cancel automatically
self.confirmed = False
self.reject()
return
# Still have attempts remaining - clear field and show error feedback
self.confirm_edit.clear()
# Update label to show remaining attempts
remaining = self._max_attempts - self._failed_attempts
self.confirm_label.setText(f"Wrong! Type 'DELETE' exactly (all caps) - {remaining} attempt(s) remaining:")
self.confirm_label.setStyleSheet(
"QLabel { "
" font-size: 13px; "
" color: #c0392b; " # Red for error
" margin-bottom: 2px; "
" font-weight: bold; "
"}"
)
# Show error state in text field
self.confirm_edit.setPlaceholderText(f"Type DELETE ({remaining} attempts left)")
self.confirm_edit.setStyleSheet(
"QLineEdit { "
" font-size: 15px; "
" border: 2px solid #c0392b; " # Red border for error
" border-radius: 6px; "
" padding: 6px; "
" background: #3b2323; " # Darker red background
" color: #e67e22; "
"}"
)

File diff suppressed because it is too large Load Diff

View File

@@ -17,6 +17,7 @@ from PySide6.QtGui import QFont
from jackify.backend.models.configuration import SystemInfo
from ..shared_theme import JACKIFY_COLOR_BLUE
from ..utils import set_responsive_minimum
logger = logging.getLogger(__name__)
@@ -35,8 +36,8 @@ class AdditionalTasksScreen(QWidget):
def _setup_ui(self):
"""Set up the user interface following ModlistTasksScreen pattern"""
layout = QVBoxLayout()
layout.setContentsMargins(40, 40, 40, 40)
layout.setSpacing(0)
layout.setContentsMargins(30, 30, 30, 30) # Reduced from 40
layout.setSpacing(12) # Match main menu spacing
# Header section
self._setup_header(layout)
@@ -50,55 +51,58 @@ class AdditionalTasksScreen(QWidget):
def _setup_header(self, layout):
"""Set up the header section"""
header_widget = QWidget()
header_layout = QVBoxLayout()
header_layout.setSpacing(0)
header_layout.setContentsMargins(0, 0, 0, 0)
header_layout.setSpacing(2)
# Title
title = QLabel("<b>Additional Tasks & Tools</b>")
title.setStyleSheet(f"font-size: 24px; color: {JACKIFY_COLOR_BLUE};")
title.setStyleSheet(f"font-size: 20px; color: {JACKIFY_COLOR_BLUE};")
title.setAlignment(Qt.AlignHCenter)
header_layout.addWidget(title)
# Add a spacer to match main menu vertical spacing
header_layout.addSpacing(16)
# Description
desc = QLabel(
"TTW automation and additional tools.<br>&nbsp;"
)
header_layout.addSpacing(10)
# Description area with fixed height
desc = QLabel("TTW automation and additional tools.")
desc.setWordWrap(True)
desc.setStyleSheet("color: #ccc;")
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(24)
# Separator (shorter like main menu)
header_layout.addSpacing(12)
# Separator
sep = QLabel()
sep.setFixedHeight(2)
sep.setFixedWidth(400) # Match button width
sep.setStyleSheet("background: #fff;")
header_layout.addWidget(sep, alignment=Qt.AlignHCenter)
header_layout.addSpacing(16)
layout.addLayout(header_layout)
header_layout.addSpacing(10)
header_widget.setLayout(header_layout)
header_widget.setFixedHeight(120) # Fixed total header height
layout.addWidget(header_widget)
def _setup_menu_buttons(self, layout):
"""Set up the menu buttons section"""
# Menu options - ONLY TTW and placeholder
MENU_ITEMS = [
("Install TTW", "ttw_install", "Install Tale of Two Wastelands using Hoolamike automation"),
("Install TTW", "ttw_install", "Install Tale of Two Wastelands using TTW_Linux_Installer"),
("Coming Soon...", "coming_soon", "Additional tools will be added in future updates"),
("Return to Main Menu", "return_main_menu", "Go back to the main menu"),
]
# Create grid layout for buttons (mirror ModlistTasksScreen pattern)
button_grid = QGridLayout()
button_grid.setSpacing(16)
button_grid.setSpacing(12) # Reduced from 16
button_grid.setAlignment(Qt.AlignHCenter)
button_width = 400
button_height = 50
button_height = 40 # Reduced from 50
for i, (label, action_id, description) in enumerate(MENU_ITEMS):
# Create button
@@ -109,8 +113,8 @@ class AdditionalTasksScreen(QWidget):
background-color: #4a5568;
color: white;
border: none;
border-radius: 8px;
font-size: 14px;
border-radius: 6px;
font-size: 13px;
font-weight: bold;
text-align: center;
}}
@@ -126,7 +130,7 @@ class AdditionalTasksScreen(QWidget):
# Description label
desc_label = QLabel(description)
desc_label.setAlignment(Qt.AlignHCenter)
desc_label.setStyleSheet("color: #999; font-size: 12px;")
desc_label.setStyleSheet("color: #999; font-size: 11px;") # Reduced from 12px
desc_label.setWordWrap(True)
desc_label.setFixedWidth(button_width)
@@ -166,4 +170,18 @@ class AdditionalTasksScreen(QWidget):
def _return_to_main_menu(self):
"""Return to main menu"""
if self.stacked_widget:
self.stacked_widget.setCurrentIndex(self.main_menu_index)
self.stacked_widget.setCurrentIndex(self.main_menu_index)
def showEvent(self, event):
"""Called when the widget becomes visible - resize to compact size"""
super().showEvent(event)
try:
main_window = self.window()
if main_window:
from PySide6.QtCore import QSize
# Only set minimum size - DO NOT RESIZE
main_window.setMaximumSize(QSize(16777215, 16777215))
set_responsive_minimum(main_window, min_width=960, min_height=420)
# DO NOT resize - let window stay at current size
except Exception:
pass

View File

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

View File

@@ -1,11 +1,15 @@
"""
ConfigureNewModlistScreen for Jackify GUI
"""
from PySide6.QtWidgets import QWidget, QVBoxLayout, QLabel, QComboBox, QHBoxLayout, QLineEdit, QPushButton, QGridLayout, QFileDialog, QTextEdit, QSizePolicy, QTabWidget, QDialog, QListWidget, QListWidgetItem, QMessageBox, QProgressDialog, QCheckBox
from PySide6.QtWidgets import QWidget, QVBoxLayout, QLabel, QComboBox, QHBoxLayout, QLineEdit, QPushButton, QGridLayout, QFileDialog, QTextEdit, QSizePolicy, QTabWidget, QDialog, QListWidget, QListWidgetItem, QMessageBox, QProgressDialog, QCheckBox, QMainWindow
from PySide6.QtCore import Qt, QSize, QThread, Signal, QTimer, QProcess, QMetaObject
from PySide6.QtGui import QPixmap, QTextCursor
from ..shared_theme import JACKIFY_COLOR_BLUE, DEBUG_BORDERS
from ..utils import ansi_to_html
from ..utils import ansi_to_html, set_responsive_minimum
# Progress reporting components
from jackify.frontends.gui.widgets.progress_indicator import OverallProgressIndicator
from jackify.frontends.gui.widgets.file_progress_list import FileProgressList
from jackify.shared.progress_models import InstallationPhase, InstallationProgress
import os
import subprocess
import sys
@@ -44,17 +48,24 @@ class ModlistFetchThread(QThread):
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 = [sys.executable, self.cli_path, '--install-modlist', '--list-modlists', '--game-type', self.game_type]
cmd = [python_exe, self.cli_path, '--install-modlist', '--list-modlists', '--game-type', self.game_type]
elif self.mode == 'install':
cmd = [sys.executable, 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]
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")
proc = subprocess.Popen(cmd, cwd=self.project_root, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
# 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:
@@ -112,10 +123,21 @@ class ConfigureNewModlistScreen(QWidget):
# 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 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
@@ -271,79 +293,104 @@ class ConfigureNewModlistScreen(QWidget):
self.start_btn = QPushButton("Start Configuration")
btn_row.addWidget(self.start_btn)
cancel_btn = QPushButton("Cancel")
cancel_btn.clicked.connect(self.go_back)
cancel_btn.clicked.connect(self.cancel_and_cleanup)
btn_row.addWidget(cancel_btn)
user_config_widget = QWidget()
user_config_widget.setLayout(user_config_vbox)
if self.debug:
user_config_widget.setStyleSheet("border: 2px solid orange;")
user_config_widget.setToolTip("USER_CONFIG_WIDGET")
# Right: process monitor (as before)
# Right: Activity window (FileProgressList widget)
# Fixed size policy to prevent shrinking when window expands
self.file_progress_list.setMinimumSize(QSize(300, 20))
self.file_progress_list.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
activity_widget = QWidget()
activity_layout = QVBoxLayout()
activity_layout.setContentsMargins(0, 0, 0, 0)
activity_layout.setSpacing(0)
activity_layout.addWidget(self.file_progress_list)
activity_widget.setLayout(activity_layout)
if self.debug:
activity_widget.setStyleSheet("border: 2px solid purple;")
activity_widget.setToolTip("ACTIVITY_WINDOW")
upper_hbox.addWidget(user_config_widget, stretch=11)
upper_hbox.addWidget(activity_widget, stretch=9)
# Keep legacy process monitor hidden (for compatibility with existing code)
self.process_monitor = QTextEdit()
self.process_monitor.setReadOnly(True)
self.process_monitor.setTextInteractionFlags(Qt.TextSelectableByMouse | Qt.TextSelectableByKeyboard)
self.process_monitor.setMinimumSize(QSize(300, 20))
self.process_monitor.setStyleSheet(f"background: #222; color: {JACKIFY_COLOR_BLUE}; font-family: monospace; font-size: 11px; border: 1px solid #444;")
self.process_monitor_heading = QLabel("<b>[Process Monitor]</b>")
self.process_monitor_heading.setStyleSheet(f"color: {JACKIFY_COLOR_BLUE}; font-size: 13px; margin-bottom: 2px;")
self.process_monitor_heading.setAlignment(Qt.AlignLeft | Qt.AlignVCenter)
process_vbox = QVBoxLayout()
process_vbox.setContentsMargins(0, 0, 0, 0)
process_vbox.setSpacing(2)
process_vbox.addWidget(self.process_monitor_heading)
process_vbox.addWidget(self.process_monitor)
process_monitor_widget = QWidget()
process_monitor_widget.setLayout(process_vbox)
if self.debug:
process_monitor_widget.setStyleSheet("border: 2px solid purple;")
process_monitor_widget.setToolTip("PROCESS_MONITOR")
upper_hbox.addWidget(user_config_widget, stretch=11)
upper_hbox.addWidget(process_monitor_widget, stretch=9)
self.process_monitor.setVisible(False) # Hidden in compact mode
upper_hbox.setAlignment(Qt.AlignTop)
upper_section_widget = QWidget()
upper_section_widget.setLayout(upper_hbox)
# Use Fixed size policy for consistent height
upper_section_widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
upper_section_widget.setMaximumHeight(280) # Increased to show resolution dropdown
if self.debug:
upper_section_widget.setStyleSheet("border: 2px solid green;")
upper_section_widget.setToolTip("UPPER_SECTION")
main_overall_vbox.addWidget(upper_section_widget)
# Remove spacing - console should expand to fill available space
# --- Console output area (full width, placeholder for now) ---
# Status banner with progress indicator and "Show details" toggle
banner_row = QHBoxLayout()
banner_row.setContentsMargins(0, 0, 0, 0)
banner_row.setSpacing(8)
banner_row.addWidget(self.progress_indicator, 1)
banner_row.addStretch()
banner_row.addWidget(self.show_details_checkbox)
banner_row_widget = QWidget()
banner_row_widget.setLayout(banner_row)
banner_row_widget.setMaximumHeight(45) # Compact height
banner_row_widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
main_overall_vbox.addWidget(banner_row_widget)
# Console output area (shown when "Show details" is checked)
self.console = QTextEdit()
self.console.setReadOnly(True)
self.console.setTextInteractionFlags(Qt.TextSelectableByMouse | Qt.TextSelectableByKeyboard)
self.console.setMinimumHeight(50) # Very small minimum - can shrink to almost nothing
self.console.setMaximumHeight(1000) # Allow growth when space available
self.console.setMinimumHeight(50)
self.console.setMaximumHeight(1000)
self.console.setFontFamily('monospace')
self.console.setVisible(False) # Hidden by default (compact mode)
if self.debug:
self.console.setStyleSheet("border: 2px solid yellow;")
self.console.setToolTip("CONSOLE")
# Set up scroll tracking for professional auto-scroll behavior
self._setup_scroll_tracking()
# Wrap button row in widget for debug borders
btn_row_widget = QWidget()
btn_row_widget.setLayout(btn_row)
btn_row_widget.setMaximumHeight(50) # Limit height to make it more compact
btn_row_widget.setMaximumHeight(50)
if self.debug:
btn_row_widget.setStyleSheet("border: 2px solid red;")
btn_row_widget.setToolTip("BUTTON_ROW")
# Create a container that holds console + button row with proper spacing
console_and_buttons_widget = QWidget()
console_and_buttons_layout = QVBoxLayout()
console_and_buttons_layout.setContentsMargins(0, 0, 0, 0)
console_and_buttons_layout.setSpacing(8) # Small gap between console and buttons
console_and_buttons_layout.addWidget(self.console, stretch=1) # Console fills most space
console_and_buttons_layout.addWidget(btn_row_widget) # Buttons at bottom of this container
console_and_buttons_layout.setSpacing(8)
console_and_buttons_layout.addWidget(self.console, stretch=1)
console_and_buttons_layout.addWidget(btn_row_widget)
console_and_buttons_widget.setLayout(console_and_buttons_layout)
console_and_buttons_widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
console_and_buttons_widget.setFixedHeight(50) # Lock to button row height when console is hidden
if self.debug:
console_and_buttons_widget.setStyleSheet("border: 2px solid lightblue;")
console_and_buttons_widget.setToolTip("CONSOLE_AND_BUTTONS_CONTAINER")
main_overall_vbox.addWidget(console_and_buttons_widget, stretch=1) # This container fills remaining space
# Add without stretch to prevent squashing upper section
main_overall_vbox.addWidget(console_and_buttons_widget)
# Store references for toggle functionality
self.console_and_buttons_widget = console_and_buttons_widget
self.console_and_buttons_layout = console_and_buttons_layout
self.main_overall_vbox = main_overall_vbox
self.setLayout(main_overall_vbox)
# --- Process Monitor (right) ---
@@ -442,6 +489,87 @@ class ConfigureNewModlistScreen(QWidget):
if scrollbar.value() >= scrollbar.maximum() - 1:
self._user_manually_scrolled = False
def _on_show_details_toggled(self, checked):
"""Handle Show Details checkbox toggle"""
self._toggle_console_visibility(checked)
def _toggle_console_visibility(self, is_checked):
"""Toggle console visibility and window size"""
main_window = None
try:
parent = self.parent()
while parent and not isinstance(parent, QMainWindow):
parent = parent.parent()
if parent and isinstance(parent, QMainWindow):
main_window = parent
except Exception:
pass
if is_checked:
# Show console
self.console.setVisible(True)
self.console.setMinimumHeight(200)
self.console.setMaximumHeight(16777215)
self.console.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
# Allow expansion when console is visible
if hasattr(self, 'console_and_buttons_widget'):
self.console_and_buttons_widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
self.console_and_buttons_widget.setMinimumHeight(0)
self.console_and_buttons_widget.setMaximumHeight(16777215)
self.console_and_buttons_widget.updateGeometry()
# Stop CPU tracking when showing console
self.file_progress_list.stop_cpu_tracking()
# Expand window
if main_window:
try:
from PySide6.QtCore import QSize
# On Steam Deck, keep fullscreen; on other systems, set normal window state
if not (hasattr(main_window, 'system_info') and main_window.system_info.is_steamdeck):
main_window.showNormal()
main_window.setMaximumHeight(16777215)
main_window.setMinimumHeight(0)
expanded_min = 900
current_size = main_window.size()
target_height = max(expanded_min, 900)
main_window.setMinimumHeight(expanded_min)
main_window.resize(current_size.width(), target_height)
self.main_overall_vbox.invalidate()
self.updateGeometry()
except Exception:
pass
else:
# Hide console
self.console.setVisible(False)
self.console.setMinimumHeight(0)
self.console.setMaximumHeight(0)
self.console.setSizePolicy(QSizePolicy.Ignored, QSizePolicy.Ignored)
# Lock height when console is hidden
if hasattr(self, 'console_and_buttons_widget'):
self.console_and_buttons_widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
self.console_and_buttons_widget.setFixedHeight(50)
self.console_and_buttons_widget.updateGeometry()
# CPU tracking will start when user clicks "Start Configuration", not here
# (Removed to avoid blocking showEvent)
# Collapse window
if main_window:
try:
from PySide6.QtCore import QSize
# Only set minimum size - DO NOT RESIZE
# 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.setMaximumSize(QSize(16777215, 16777215))
set_responsive_minimum(main_window, min_width=960, min_height=420)
# DO NOT resize - let window stay at current size
except Exception:
pass
def _safe_append_text(self, text):
"""Append text with professional auto-scroll behavior"""
# Write all messages to log file
@@ -480,9 +608,62 @@ class ConfigureNewModlistScreen(QWidget):
self.install_dir_edit.setText(file)
def go_back(self):
"""Navigate back to main menu and restore window size"""
# Emit collapse signal to restore compact mode
self.resize_request.emit('collapse')
# Restore window size before navigating away
try:
main_window = self.window()
if main_window:
from PySide6.QtCore import QSize
from ..utils import apply_window_size_and_position
# Only set minimum size - DO NOT RESIZE
main_window.setMaximumSize(QSize(16777215, 16777215))
set_responsive_minimum(main_window, min_width=960, min_height=420)
# DO NOT resize - let window stay at current size
except Exception:
pass
if self.stacked_widget:
self.stacked_widget.setCurrentIndex(self.main_menu_index)
def cleanup_processes(self):
"""Clean up any running processes when the window closes or is cancelled"""
# Stop CPU tracking if active
if hasattr(self, 'file_progress_list'):
self.file_progress_list.stop_cpu_tracking()
# Clean up configuration thread if running
if hasattr(self, 'config_thread') and self.config_thread.isRunning():
self.config_thread.terminate()
self.config_thread.wait(1000)
def cancel_and_cleanup(self):
"""Handle Cancel button - clean up processes and go back"""
self.cleanup_processes()
self.go_back()
def showEvent(self, event):
"""Called when the widget becomes visible - ensure collapsed state"""
super().showEvent(event)
# Ensure initial collapsed layout each time this screen is opened
try:
from PySide6.QtCore import Qt as _Qt
# Ensure checkbox is unchecked without emitting signals
if self.show_details_checkbox.isChecked():
self.show_details_checkbox.blockSignals(True)
self.show_details_checkbox.setChecked(False)
self.show_details_checkbox.blockSignals(False)
# Force collapsed state
self._toggle_console_visibility(False)
except Exception as e:
# If initial collapse fails, log but don't crash
print(f"Warning: Failed to set initial collapsed state: {e}")
def update_top_panel(self):
try:
result = subprocess.run([
@@ -528,6 +709,9 @@ class ConfigureNewModlistScreen(QWidget):
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:
@@ -582,7 +766,13 @@ class ConfigureNewModlistScreen(QWidget):
# 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()
@@ -1251,7 +1441,10 @@ class ConfigureNewModlistScreen(QWidget):
if success:
# Calculate time taken
time_taken = self._calculate_time_taken()
# Clear Activity window before showing success dialog
self.file_progress_list.clear()
# Show success dialog with celebration
success_dialog = SuccessDialog(
modlist_name=modlist_name,

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,63 @@
"""
Simplified output handler for TTW installation - minimal filtering, maximum stability
This is a reference implementation showing the absolute minimum needed.
"""
def on_installation_output_simple(self, message):
"""
Ultra-simplified output handler:
- Strip emojis (required)
- Show all output (no filtering)
- Extract progress numbers for Activity window only
- No regex except for simple number extraction
"""
# Strip ANSI codes
cleaned = strip_ansi_control_codes(message).strip()
# Strip emojis - character by character (no regex)
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()
if not cleaned:
return
# Log everything
self._write_to_log_file(message)
# Show everything in console (no filtering)
self._safe_append_text(cleaned)
# Extract progress for Activity window ONLY - minimal regex with error handling
# Pattern: [X/Y] or "Loading manifest: X/Y"
try:
# Try to extract [X/Y] pattern
import re
match = re.search(r'\[(\d+)/(\d+)\]', cleaned)
if match:
current = int(match.group(1))
total = int(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"
match = re.search(r'loading manifest:\s*(\d+)/(\d+)', cleaned.lower())
if match:
current = int(match.group(1))
total = int(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 (RecursionError, re.error, Exception):
# If regex fails, just skip progress extraction - show output anyway
pass

View File

@@ -6,6 +6,7 @@ from PySide6.QtGui import QPixmap, QFont
from PySide6.QtCore import Qt
import os
from ..shared_theme import JACKIFY_COLOR_BLUE, LOGO_PATH, DISCLAIMER_TEXT
from ..utils import set_responsive_minimum
class MainMenu(QWidget):
def __init__(self, stacked_widget=None, dev_mode=False):
@@ -14,37 +15,52 @@ class MainMenu(QWidget):
self.dev_mode = dev_mode
layout = QVBoxLayout()
layout.setAlignment(Qt.AlignTop | Qt.AlignHCenter)
layout.setContentsMargins(50, 50, 50, 50)
layout.setSpacing(20)
layout.setContentsMargins(30, 30, 30, 30) # Reduced from 50
layout.setSpacing(12) # Reduced from 20
# Header zone with fixed height for consistent layout across all menu screens
header_widget = QWidget()
header_layout = QVBoxLayout()
header_layout.setContentsMargins(0, 0, 0, 0)
header_layout.setSpacing(2)
# Title
title = QLabel("<b>Jackify</b>")
title.setStyleSheet(f"font-size: 24px; color: {JACKIFY_COLOR_BLUE};")
title.setStyleSheet(f"font-size: 20px; color: {JACKIFY_COLOR_BLUE};")
title.setAlignment(Qt.AlignHCenter)
layout.addWidget(title)
header_layout.addWidget(title)
# Description
header_layout.addSpacing(10)
# Description area with fixed height
desc = QLabel(
"Manage your modlists with native Linux tools. "
"Choose from the options below to install, "
"configure, or manage modlists."
)
desc.setWordWrap(True)
desc.setStyleSheet("color: #ccc;")
desc.setStyleSheet("color: #ccc; font-size: 13px;")
desc.setAlignment(Qt.AlignHCenter)
layout.addWidget(desc)
desc.setMaximumHeight(50) # Fixed height for description zone
header_layout.addWidget(desc)
header_layout.addSpacing(12)
# Separator
layout.addSpacing(16)
sep = QLabel()
sep.setFixedHeight(2)
sep.setStyleSheet("background: #fff;")
layout.addWidget(sep)
layout.addSpacing(16)
header_layout.addWidget(sep)
header_layout.addSpacing(10)
header_widget.setLayout(header_layout)
header_widget.setFixedHeight(120) # Fixed total header height
layout.addWidget(header_widget)
# Menu buttons
button_width = 400
button_height = 60
button_height = 40 # Reduced from 50/60
MENU_ITEMS = [
("Modlist Tasks", "modlist_tasks", "Manage your modlists with native Linux tools"),
("Additional Tasks", "additional_tasks", "Additional Tasks & Tools, such as TTW Installation"),
@@ -54,14 +70,14 @@ class MainMenu(QWidget):
for label, action_id, description in MENU_ITEMS:
# Main button
btn = QPushButton(label)
btn.setFixedSize(button_width, 50)
btn.setFixedSize(button_width, button_height) # Use variable height
btn.setStyleSheet(f"""
QPushButton {{
background-color: #4a5568;
color: white;
border: none;
border-radius: 8px;
font-size: 14px;
border-radius: 6px;
font-size: 13px;
font-weight: bold;
text-align: center;
}}
@@ -73,28 +89,28 @@ class MainMenu(QWidget):
}}
""")
btn.clicked.connect(lambda checked, a=action_id: self.menu_action(a))
# Button container with proper alignment
btn_container = QWidget()
btn_layout = QVBoxLayout()
btn_layout.setContentsMargins(0, 0, 0, 0)
btn_layout.setSpacing(4)
btn_layout.setSpacing(3) # Reduced from 4
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: 12px;")
desc_label.setStyleSheet("color: #999; font-size: 11px;") # Reduced from 12px
desc_label.setWordWrap(True)
desc_label.setFixedWidth(button_width) # Match button width for proper alignment
desc_label.setFixedWidth(button_width)
btn_layout.addWidget(desc_label)
btn_container.setLayout(btn_layout)
layout.addWidget(btn_container)
# Disclaimer
layout.addSpacing(20)
layout.addSpacing(12) # Reduced from 20
disclaimer = QLabel(DISCLAIMER_TEXT)
disclaimer.setWordWrap(True)
disclaimer.setAlignment(Qt.AlignCenter)
@@ -104,6 +120,20 @@ class MainMenu(QWidget):
self.setLayout(layout)
def showEvent(self, event):
"""Called when the widget becomes visible - ensure minimum size only"""
super().showEvent(event)
try:
main_window = self.window()
if main_window:
from PySide6.QtCore import QSize
# Only set minimum size - DO NOT RESIZE
main_window.setMaximumSize(QSize(16777215, 16777215))
set_responsive_minimum(main_window, min_width=960, min_height=420)
# DO NOT resize - let window stay at current size
except Exception:
pass
def menu_action(self, action_id):
if action_id == "exit_jackify":
from PySide6.QtWidgets import QApplication

File diff suppressed because it is too large Load Diff

View File

@@ -27,6 +27,7 @@ from PySide6.QtGui import QFont, QPalette, QColor, QPixmap
# Import our GUI services
from jackify.backend.models.configuration import SystemInfo
from ..shared_theme import JACKIFY_COLOR_BLUE
from ..utils import set_responsive_minimum
# Constants
DEBUG_BORDERS = False
@@ -77,8 +78,9 @@ class ModlistTasksScreen(QWidget):
"""Set up the user interface"""
main_layout = QVBoxLayout(self)
main_layout.setAlignment(Qt.AlignTop | Qt.AlignHCenter)
main_layout.setContentsMargins(50, 50, 50, 50)
main_layout.setContentsMargins(30, 30, 30, 30) # Reduced from 50
main_layout.setSpacing(12) # Match main menu spacing
if self.debug:
self.setStyleSheet("border: 2px solid green;")
@@ -93,38 +95,43 @@ class ModlistTasksScreen(QWidget):
def _setup_header(self, layout):
"""Set up the header section"""
header_widget = QWidget()
header_layout = QVBoxLayout()
header_layout.setContentsMargins(0, 0, 0, 0)
header_layout.setSpacing(2)
# Title
title = QLabel("<b>Modlist Tasks</b>")
title.setStyleSheet(f"font-size: 24px; color: {JACKIFY_COLOR_BLUE};")
title.setStyleSheet(f"font-size: 20px; color: {JACKIFY_COLOR_BLUE};")
title.setAlignment(Qt.AlignHCenter)
header_layout.addWidget(title)
# Add a spacer to match main menu vertical spacing
header_layout.addSpacing(16)
# Description
header_layout.addSpacing(10)
# Description area with fixed height
desc = QLabel(
"Manage your modlists with native Linux tools. Choose "
"from the options below to install or configure modlists.<br>&nbsp;"
"from the options below to install or configure modlists."
)
desc.setWordWrap(True)
desc.setStyleSheet("color: #ccc;")
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(24)
header_layout.addSpacing(12)
# Separator
sep = QLabel()
sep.setFixedHeight(2)
sep.setStyleSheet("background: #fff;")
header_layout.addWidget(sep)
header_layout.addSpacing(16)
layout.addLayout(header_layout)
header_layout.addSpacing(10)
header_widget.setLayout(header_layout)
header_widget.setFixedHeight(120) # Fixed total header height
layout.addWidget(header_widget)
def _setup_menu_buttons(self, layout):
"""Set up the menu buttons section"""
@@ -140,12 +147,12 @@ class ModlistTasksScreen(QWidget):
# Create grid layout for buttons
button_grid = QGridLayout()
button_grid.setSpacing(16)
button_grid.setSpacing(12) # Reduced from 16
button_grid.setAlignment(Qt.AlignHCenter)
button_width = 400
button_height = 50
button_height = 40 # Reduced from 50
for i, (label, action_id, description) in enumerate(MENU_ITEMS):
# Create button
btn = QPushButton(label)
@@ -155,8 +162,8 @@ class ModlistTasksScreen(QWidget):
background-color: #4a5568;
color: white;
border: none;
border-radius: 8px;
font-size: 14px;
border-radius: 6px;
font-size: 13px;
font-weight: bold;
text-align: center;
}}
@@ -168,11 +175,11 @@ class ModlistTasksScreen(QWidget):
}}
""")
btn.clicked.connect(lambda checked, a=action_id: self.menu_action(a))
# Create description label
desc_label = QLabel(description)
desc_label.setAlignment(Qt.AlignHCenter)
desc_label.setStyleSheet("color: #999; font-size: 12px;")
desc_label.setStyleSheet("color: #999; font-size: 11px;") # Reduced from 12px
desc_label.setWordWrap(True)
desc_label.setFixedWidth(button_width)
@@ -208,7 +215,21 @@ class ModlistTasksScreen(QWidget):
"""Return to main menu"""
if self.stacked_widget:
self.stacked_widget.setCurrentIndex(self.main_menu_index)
def showEvent(self, event):
"""Called when the widget becomes visible - resize to compact size"""
super().showEvent(event)
try:
main_window = self.window()
if main_window:
from PySide6.QtCore import QSize
# Only set minimum size - DO NOT RESIZE
main_window.setMaximumSize(QSize(16777215, 16777215))
set_responsive_minimum(main_window, min_width=960, min_height=420)
# DO NOT resize - let window stay at current size
except Exception:
pass
def cleanup(self):
"""Clean up resources when the screen is closed"""
pass

View File

@@ -4,7 +4,7 @@ Jackify GUI theme and shared constants
import os
JACKIFY_COLOR_BLUE = "#3fd0ea" # Official Jackify blue
DEBUG_BORDERS = False
DEBUG_BORDERS = False # Enable debug borders to visualize widget boundaries
ASSETS_DIR = os.path.join(os.path.dirname(__file__), 'assets')
LOGO_PATH = os.path.join(ASSETS_DIR, 'jackify_logo.png')
DISCLAIMER_TEXT = (

View File

@@ -2,6 +2,9 @@
GUI Utilities for Jackify Frontend
"""
import re
from typing import Tuple, Optional
from PySide6.QtWidgets import QApplication, QWidget
from PySide6.QtCore import QSize, QPoint
ANSI_COLOR_MAP = {
'30': 'black', '31': 'red', '32': 'green', '33': 'yellow', '34': 'blue', '35': 'magenta', '36': 'cyan', '37': 'white',
@@ -50,4 +53,272 @@ def ansi_to_html(text):
else:
result += chunk
result = result.replace('\n', '<br>')
return result
return result
def get_screen_geometry(widget: Optional[QWidget] = None) -> Tuple[int, int, int, int]:
"""
Get available screen geometry for a widget.
Args:
widget: Widget to get screen for (uses primary screen if None)
Returns:
Tuple of (x, y, width, height) for available screen geometry
"""
app = QApplication.instance()
if not app:
return (0, 0, 1920, 1080) # Fallback
if widget:
screen = None
window_handle = widget.windowHandle()
if window_handle and window_handle.screen():
screen = window_handle.screen()
else:
try:
global_pos = widget.mapToGlobal(widget.rect().center())
except Exception:
global_pos = QPoint(0, 0)
if app:
screen = app.screenAt(global_pos)
if not screen and app:
screen = app.primaryScreen()
else:
screen = app.primaryScreen()
if screen:
geometry = screen.availableGeometry()
return (geometry.x(), geometry.y(), geometry.width(), geometry.height())
return (0, 0, 1920, 1080) # Fallback
def calculate_window_size(
widget: Optional[QWidget] = None,
width_ratio: float = 0.7,
height_ratio: float = 0.6,
min_width: int = 900,
min_height: int = 500,
max_width: Optional[int] = None,
max_height: Optional[int] = None
) -> Tuple[int, int]:
"""
Calculate appropriate window size based on screen geometry.
Args:
widget: Widget to calculate size for (uses primary screen if None)
width_ratio: Fraction of screen width to use (0.0-1.0)
height_ratio: Fraction of screen height to use (0.0-1.0)
min_width: Minimum window width
min_height: Minimum window height
max_width: Maximum window width (None = no limit)
max_height: Maximum window height (None = no limit)
Returns:
Tuple of (width, height)
"""
_, _, screen_width, screen_height = get_screen_geometry(widget)
# Calculate size based on ratios
width = int(screen_width * width_ratio)
height = int(screen_height * height_ratio)
# Apply minimums
width = max(width, min_width)
height = max(height, min_height)
# Apply maximums
if max_width:
width = min(width, max_width)
if max_height:
height = min(height, max_height)
# Ensure we don't exceed screen bounds
width = min(width, screen_width)
height = min(height, screen_height)
return (width, height)
def calculate_window_position(
widget: QWidget,
window_width: int,
window_height: int,
parent: Optional[QWidget] = None
) -> QPoint:
"""
Calculate appropriate window position (centered on parent or screen).
Args:
widget: Widget to position
window_width: Width of window to position
window_height: Height of window to position
parent: Parent widget to center on (centers on screen if None)
Returns:
QPoint with x, y coordinates
"""
_, _, screen_width, screen_height = get_screen_geometry(widget)
if parent:
parent_geometry = parent.geometry()
x = parent_geometry.x() + (parent_geometry.width() - window_width) // 2
y = parent_geometry.y() + (parent_geometry.height() - window_height) // 2
else:
# Center on screen
x = (screen_width - window_width) // 2
y = (screen_height - window_height) // 2
# Ensure window stays on screen
x = max(0, min(x, screen_width - window_width))
y = max(0, min(y, screen_height - window_height))
return QPoint(x, y)
def set_responsive_minimum(window: Optional[QWidget], min_width: int = 960,
min_height: int = 520, margin: int = 32):
"""
Apply minimum size constraints that respect the current screen bounds.
Args:
window: Target window
min_width: Desired minimum width
min_height: Desired minimum height
margin: Pixels to subtract from available size to avoid full-screen overlap
"""
if window is None:
return
_, _, screen_width, screen_height = get_screen_geometry(window)
width_cap = min_width
height_cap = min_height
if screen_width:
available_width = max(640, screen_width - margin)
available_width = min(available_width, screen_width)
width_cap = min(min_width, available_width)
if screen_height:
available_height = max(520, screen_height - margin)
available_height = min(available_height, screen_height)
height_cap = min(min_height, available_height)
window.setMinimumSize(QSize(width_cap, height_cap))
def load_saved_window_size(window: QWidget) -> Optional[Tuple[int, int]]:
"""
Load saved window size from config if available.
Only returns sizes that are reasonable (compact menu size, not expanded).
Args:
window: Window widget (used to validate size against screen)
Returns:
Tuple of (width, height) if saved size exists and is valid, None otherwise
"""
try:
from ...backend.handlers.config_handler import ConfigHandler
config_handler = ConfigHandler()
saved_width = config_handler.get('window_width')
saved_height = config_handler.get('window_height')
if saved_width and saved_height:
# Validate saved size is reasonable (not too small, fits on screen)
_, _, screen_width, screen_height = get_screen_geometry(window)
min_width = 1200
min_height = 500
max_height = int(screen_height * 0.6) # Reject sizes larger than 60% of screen (expanded state)
# Ensure saved size is within reasonable bounds (compact menu size)
# Reject expanded sizes that are too tall
if (min_width <= saved_width <= screen_width and
min_height <= saved_height <= max_height):
return (saved_width, saved_height)
except Exception:
pass
return None
def save_window_size(window: QWidget):
"""
Save current window size to config.
Args:
window: Window widget to save size for
"""
try:
from ...backend.handlers.config_handler import ConfigHandler
config_handler = ConfigHandler()
size = window.size()
config_handler.set('window_width', size.width())
config_handler.set('window_height', size.height())
config_handler.save_config()
except Exception:
pass
def apply_window_size_and_position(
window: QWidget,
width_ratio: float = 0.7,
height_ratio: float = 0.6,
min_width: int = 900,
min_height: int = 500,
max_width: Optional[int] = None,
max_height: Optional[int] = None,
parent: Optional[QWidget] = None,
preserve_position: bool = False,
use_saved_size: bool = True
):
"""
Apply dynamic window sizing and positioning based on screen geometry.
Optionally uses saved window size if user has manually resized before.
Args:
window: Window widget to size/position
width_ratio: Fraction of screen width to use (if no saved size)
height_ratio: Fraction of screen height to use (if no saved size)
min_width: Minimum window width
min_height: Minimum window height
max_width: Maximum window width (None = no limit)
max_height: Maximum window height (None = no limit)
parent: Parent widget to center on (centers on screen if None)
preserve_position: If True, preserve current size and position (only set minimums)
use_saved_size: If True, check for saved window size first
"""
# Set minimum size first
window.setMinimumSize(QSize(min_width, min_height))
# If preserve_position is True, don't resize - just ensure minimums are set
if preserve_position:
# Only ensure current size meets minimums, don't change size
current_size = window.size()
if current_size.width() < min_width:
window.resize(min_width, current_size.height())
if current_size.height() < min_height:
window.resize(window.size().width(), min_height)
return
# Check for saved window size first
width = None
height = None
if use_saved_size:
saved_size = load_saved_window_size(window)
if saved_size:
width, height = saved_size
# If no saved size, calculate dynamically
if width is None or height is None:
width, height = calculate_window_size(
window, width_ratio, height_ratio, min_width, min_height, max_width, max_height
)
# Calculate and set position
pos = calculate_window_position(window, width, height, parent)
window.resize(width, height)
window.move(pos)

View File

@@ -0,0 +1,848 @@
"""
File Progress List Widget
Displays a list of files currently being processed (downloaded, extracted, etc.)
with individual progress indicators.
R&D NOTE: This is experimental code for investigation purposes.
"""
from typing import Optional
import shiboken6
import time
from PySide6.QtWidgets import (
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
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._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
# Speed display (if available)
speed_label = QLabel()
speed_label.setFixedWidth(60)
speed_label.setAlignment(Qt.AlignRight | Qt.AlignVCenter)
speed_label.setStyleSheet("color: #888; font-size: 10px;")
layout.addWidget(speed_label)
self.speed_label = speed_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.speed_label.setText("") # No speed
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.speed_label.setText("") # No speed for summary
self.progress_bar.setRange(0, 100)
# Progress bar value will be updated by animation timer
else:
# Indeterminate if no max - use Qt's built-in smooth animation
self.percent_label.setText("")
self.speed_label.setText("")
self.progress_bar.setRange(0, 0) # Qt handles animation smoothly
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 Qt's indeterminate mode
if has_meaningful_progress:
# Update target for smooth animation
self._target_percent = max(0, self.file_progress.percent)
# Start animation timer if not already running
if not self._animation_timer.isActive():
self._animation_timer.start()
# Update speed label immediately (doesn't need animation)
self.speed_label.setText(self.file_progress.speed_display)
self.progress_bar.setRange(0, 100)
# Progress bar value will be updated by animation timer
else:
# No progress data (e.g., texture conversions) - Qt's indeterminate mode
self._animation_timer.stop() # Stop animation for indeterminate items
self.percent_label.setText("") # No percentage
self.speed_label.setText("") # No speed
self.progress_bar.setRange(0, 0) # Qt handles smooth indeterminate animation
def _animate_progress(self):
"""Smoothly animate progress bar from current to target value."""
# Calculate difference
diff = self._target_percent - self._current_display_percent
# If very close, snap to target and stop animation
if abs(diff) < 0.1:
self._current_display_percent = self._target_percent
self._animation_timer.stop()
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.
Shows individual progress for each file.
"""
def __init__(self, parent=None):
"""
Initialize file progress list.
Args:
parent: Parent widget
"""
super().__init__(parent)
self._file_items: dict[str, FileProgressItem] = {}
self._summary_widget: Optional[SummaryProgressWidget] = None
self._last_phase: Optional[str] = None # Track phase changes for transition messages
self._transition_label: Optional[QLabel] = None # Label for "Preparing..." message
self._last_summary_time: float = 0.0 # Track when summary widget was last shown
self._summary_hold_duration: float = 0.5 # Hold summary for minimum 0.5s to prevent flicker
self._last_summary_update: float = 0.0 # Track last summary update for throttling
self._summary_update_interval: float = 0.1 # Update summary every 100ms (simple throttling)
self._setup_ui()
# Set size policy to match Process Monitor - expand to fill available space
self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
def _setup_ui(self):
"""Set up the UI - match Process Monitor layout structure exactly."""
layout = QVBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(2) # Match Process Monitor spacing (was 4, now 2)
# Header row with CPU usage only (tab label replaces "[Activity]" header)
header_layout = QHBoxLayout()
header_layout.setContentsMargins(0, 0, 0, 0)
header_layout.setSpacing(8)
# CPU usage indicator (right-aligned)
self.cpu_label = QLabel("")
self.cpu_label.setStyleSheet("color: #888; font-size: 11px; margin-bottom: 2px;")
self.cpu_label.setAlignment(Qt.AlignRight | Qt.AlignVCenter)
header_layout.addStretch() # Push CPU label to the right
header_layout.addWidget(self.cpu_label, 0)
layout.addLayout(header_layout)
# List widget for file items - match Process Monitor size constraints
self.list_widget = QListWidget()
self.list_widget.setStyleSheet("""
QListWidget {
background-color: #222;
border: 1px solid #444;
border-radius: 4px;
}
QListWidget::item {
border-bottom: 1px solid #2a2a2a;
padding: 2px;
}
QListWidget::item:selected {
background-color: #2a2a2a;
}
""")
# Match Process Monitor minimum size: QSize(300, 20)
self.list_widget.setMinimumSize(QSize(300, 20))
# Match Process Monitor - no maximum height constraint, expand to fill available space
# The list will scroll if there are more items than can fit
self.list_widget.setVerticalScrollBarPolicy(Qt.ScrollBarAsNeeded)
# Match Process Monitor size policy - expand to fill available space
self.list_widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
layout.addWidget(self.list_widget, stretch=1) # Match Process Monitor stretch
# Throttle timer for updates when there are many files
import time
self._last_update_time = 0.0
# CPU usage tracking
self._cpu_timer = QTimer(self)
self._cpu_timer.timeout.connect(self._update_cpu_usage)
self._cpu_timer.setInterval(2000) # Update every 2 seconds
self._last_cpu_percent = 0.0
self._cpu_process_cache = None # Cache the process object for better performance
self._child_process_cache = {} # Cache child Process objects by PID for persistent CPU tracking
def update_files(self, file_progresses: list[FileProgress], current_phase: str = None, summary_info: dict = None):
"""
Update the list with current file progresses.
Args:
file_progresses: List of FileProgress objects for active files
current_phase: Optional phase name to display in header (e.g., "Downloading", "Extracting")
summary_info: Optional dict with 'current_step' and 'max_steps' for summary display (e.g., Installing phase)
"""
# Throttle updates to prevent UI freezing with many files
# If we have many files (>50), throttle updates to every 100ms
import time
current_time = time.time()
if len(file_progresses) > 50:
if current_time - self._last_update_time < 0.1: # 100ms throttle
return # Skip this update
self._last_update_time = current_time
# If we have summary info (e.g., Installing phase), show summary widget instead of file list
if summary_info and not file_progresses:
current_time = time.time()
# Get new values
current_step = summary_info.get('current_step', 0)
max_steps = summary_info.get('max_steps', 0)
phase_name = current_phase or "Installing files"
# Check if summary widget already exists and is valid
summary_widget_valid = self._summary_widget and shiboken6.isValid(self._summary_widget)
if not summary_widget_valid:
self._summary_widget = None
# If widget exists, check if we should throttle the update
if self._summary_widget:
# Throttle updates to prevent flickering with rapidly changing counters
if current_time - self._last_summary_update < self._summary_update_interval:
return # Skip update, too soon
# Update existing summary widget (no clearing needed)
self._summary_widget.update_progress(current_step, max_steps)
# Update phase name if it changed
if self._summary_widget.phase_name != phase_name:
self._summary_widget.phase_name = phase_name
self._summary_widget._update_display()
self._last_summary_update = current_time
return
# Widget doesn't exist - create it (only clear when creating new widget)
self.list_widget.clear()
self._file_items.clear()
# Create new summary widget
self._summary_widget = SummaryProgressWidget(phase_name, current_step, max_steps)
summary_item = QListWidgetItem()
summary_item.setSizeHint(self._summary_widget.sizeHint())
summary_item.setData(Qt.UserRole, "__summary__")
self.list_widget.addItem(summary_item)
self.list_widget.setItemWidget(summary_item, self._summary_widget)
self._last_summary_time = current_time
self._last_summary_update = current_time
return
# Clear summary widget and transition label when showing file list
# But only if enough time has passed to prevent flickering
current_time = time.time()
if self._summary_widget:
# Hold summary widget for minimum duration to prevent rapid flickering
if current_time - self._last_summary_time >= self._summary_hold_duration:
# Remove summary widget from list
for i in range(self.list_widget.count()):
item = self.list_widget.item(i)
if item and item.data(Qt.UserRole) == "__summary__":
self.list_widget.takeItem(i)
break
self._summary_widget = None
else:
# Too soon to clear summary, keep it visible
return
# Clear transition label if it exists
if self._transition_label:
for i in range(self.list_widget.count()):
item = self.list_widget.item(i)
if item and item.data(Qt.UserRole) == "__transition__":
self.list_widget.takeItem(i)
break
self._transition_label = None
if not file_progresses:
# No files - check if this is a phase transition
if current_phase and self._last_phase and current_phase != self._last_phase:
# Phase changed - show transition message briefly
self._show_transition_message(current_phase)
else:
# Show empty state but keep header stable
self.list_widget.clear()
self._file_items.clear()
# Update last phase tracker
if current_phase:
self._last_phase = current_phase
return
# Determine phase from file operations if not provided
if not current_phase and file_progresses:
# Get the most common operation type
operations = [fp.operation for fp in file_progresses if fp.operation != OperationType.UNKNOWN]
if operations:
operation_counts = {}
for op in operations:
operation_counts[op] = operation_counts.get(op, 0) + 1
most_common = max(operation_counts.items(), key=lambda x: x[1])[0]
phase_map = {
OperationType.DOWNLOAD: "Downloading",
OperationType.EXTRACT: "Extracting",
OperationType.VALIDATE: "Validating",
OperationType.INSTALL: "Installing",
}
current_phase = phase_map.get(most_common, "")
# Remove completed files
# Build set of current item keys (using stable keys for counters)
current_keys = set()
for fp in file_progresses:
if 'Installing Files:' in fp.filename:
current_keys.add("__installing_files__")
elif 'Converting Texture:' in fp.filename:
base_name = fp.filename.split('(')[0].strip()
current_keys.add(f"__texture_{base_name}__")
elif fp.filename.startswith('BSA:'):
bsa_name = fp.filename.split('(')[0].strip()
current_keys.add(f"__bsa_{bsa_name}__")
else:
current_keys.add(fp.filename)
for item_key in list(self._file_items.keys()):
if item_key not in current_keys:
# Find and remove the item
for i in range(self.list_widget.count()):
item = self.list_widget.item(i)
if item and item.data(Qt.UserRole) == item_key:
self.list_widget.takeItem(i)
break
del self._file_items[item_key]
# Update or add files - maintain specific ordering
# Use stable identifiers for special items (like "Installing Files: X/Y")
for idx, file_progress in enumerate(file_progresses):
# For items with changing counters in filename, use a stable key
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}__"
else:
# Use filename as key for regular files
item_key = file_progress.filename
if item_key in self._file_items:
# Update existing - ensure it's in the right position
item_widget = self._file_items[item_key]
item_widget.update_progress(file_progress)
# Find the item in the list and move it if needed
for i in range(self.list_widget.count()):
item = self.list_widget.item(i)
if item and item.data(Qt.UserRole) == item_key:
# Item is at position i, should be at position idx
if i != idx:
# Take item from current position and insert at correct position
taken_item = self.list_widget.takeItem(i)
self.list_widget.insertItem(idx, taken_item)
self.list_widget.setItemWidget(taken_item, item_widget)
break
else:
# Add new - insert at specific position (idx) to maintain order
item_widget = FileProgressItem(file_progress)
list_item = QListWidgetItem()
list_item.setSizeHint(item_widget.sizeHint())
list_item.setData(Qt.UserRole, item_key) # Use stable key
self.list_widget.insertItem(idx, list_item) # Insert at specific position
self.list_widget.setItemWidget(list_item, item_widget)
self._file_items[item_key] = item_widget
# Update last phase tracker
if current_phase:
self._last_phase = current_phase
def _show_transition_message(self, new_phase: str):
"""Show a brief 'Preparing...' message during phase transitions."""
self.list_widget.clear()
self._file_items.clear()
# Header removed - tab label provides context
# Create or update transition label
if self._transition_label is None or not shiboken6.isValid(self._transition_label):
self._transition_label = QLabel()
self._transition_label.setAlignment(Qt.AlignCenter)
self._transition_label.setStyleSheet("color: #888; font-style: italic; padding: 20px;")
self._transition_label.setText(f"Preparing {new_phase.lower()}...")
# Add to list widget
transition_item = QListWidgetItem()
transition_item.setSizeHint(self._transition_label.sizeHint())
transition_item.setData(Qt.UserRole, "__transition__")
self.list_widget.addItem(transition_item)
self.list_widget.setItemWidget(transition_item, self._transition_label)
# 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(self):
"""Clear all file items."""
self.list_widget.clear()
self._file_items.clear()
self._summary_widget = None
self._transition_label = None
self._last_phase = None
# Header removed - tab label provides context
# Stop CPU timer and clear CPU label
self.stop_cpu_tracking()
self.cpu_label.setText("")
def start_cpu_tracking(self):
"""Start tracking CPU usage."""
if not self._cpu_timer.isActive():
# Initialize process and take first measurement to establish baseline
try:
import psutil
import os
self._cpu_process_cache = psutil.Process(os.getpid())
# First call with interval to establish baseline
self._cpu_process_cache.cpu_percent(interval=0.1)
# Cache child processes
self._child_process_cache = {}
for child in self._cpu_process_cache.children(recursive=True):
try:
child.cpu_percent(interval=0.1)
self._child_process_cache[child.pid] = child
except (psutil.NoSuchProcess, psutil.AccessDenied):
pass
except Exception:
pass
self._cpu_timer.start()
self._update_cpu_usage() # Update immediately after baseline
def stop_cpu_tracking(self):
"""Stop tracking CPU usage."""
if self._cpu_timer.isActive():
self._cpu_timer.stop()
def update_or_add_item(self, item_id: str, label: str, progress: float = 0.0):
"""
Add or update a single status item in the Activity window.
Useful for simple status messages like "Downloading...", "Extracting...", etc.
Args:
item_id: Unique identifier for this item
label: Display label for the item
progress: Progress percentage (0-100), or 0 for indeterminate
"""
from jackify.shared.progress_models import FileProgress, OperationType
# Create a FileProgress object for this status item
file_progress = FileProgress(
filename=label,
operation=OperationType.DOWNLOAD if progress > 0 else OperationType.UNKNOWN,
percent=progress,
current_size=0,
total_size=0
)
# Use update_files with a single-item list
self.update_files([file_progress], current_phase=None)
def _update_cpu_usage(self):
"""
Update CPU usage display with Jackify-related processes.
Shows total CPU usage across all cores as a percentage of system capacity.
E.g., on an 8-core system:
- 100% = using all 8 cores fully
- 50% = using 4 cores fully (or 8 cores at half capacity)
- 12.5% = using 1 core fully
"""
try:
import psutil
import os
import sys
# Get or create process cache
if self._cpu_process_cache is None:
self._cpu_process_cache = psutil.Process(os.getpid())
# Get current process CPU (Jackify GUI)
# cpu_percent() returns percentage relative to one core
# We need to divide by num_cpus to get system-wide percentage
num_cpus = psutil.cpu_count()
main_cpu_raw = self._cpu_process_cache.cpu_percent(interval=None)
main_cpu = main_cpu_raw / num_cpus
total_cpu = main_cpu
# Add CPU usage from ALL child processes recursively
# This includes jackify-engine, texconv.exe, wine processes, etc.
child_count = 0
child_cpu_sum = 0.0
try:
children = self._cpu_process_cache.children(recursive=True)
current_child_pids = set()
for child in children:
try:
current_child_pids.add(child.pid)
# Check if this is a new process we haven't cached
if child.pid not in self._child_process_cache:
# Cache new process and establish baseline
child.cpu_percent(interval=0.1)
self._child_process_cache[child.pid] = child
# Skip this iteration since baseline was just set
continue
# Use cached process object for consistent cpu_percent tracking
cached_child = self._child_process_cache[child.pid]
child_cpu_raw = cached_child.cpu_percent(interval=None)
child_cpu = child_cpu_raw / num_cpus
total_cpu += child_cpu
child_count += 1
child_cpu_sum += child_cpu_raw
except (psutil.NoSuchProcess, psutil.AccessDenied):
pass
# Clean up cache for processes that no longer exist
dead_pids = set(self._child_process_cache.keys()) - current_child_pids
for pid in dead_pids:
del self._child_process_cache[pid]
except (psutil.NoSuchProcess, psutil.AccessDenied):
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.
tracked_pids = {self._cpu_process_cache.pid} # Avoid double-counting
tracked_pids.update(current_child_pids)
extra_count = 0
extra_cpu_sum = 0.0
try:
for proc in psutil.process_iter(['name', 'pid', 'cmdline']):
try:
if proc.pid in tracked_pids:
continue
proc_name = proc.info.get('name', '').lower()
cmdline = proc.info.get('cmdline', [])
cmdline_str = ' '.join(cmdline).lower() if cmdline else ''
# Match Jackify-related process names (include Proton/wine wrappers)
# Include all tools that jackify-engine uses during installation
jackify_names = [
'jackify-engine', # Main engine
'texconv', # Texture conversion
'texdiag', # Texture diagnostics
'directxtex', # DirectXTex helper binaries
'texconv_jackify', # Bundled texconv build
'texdiag_jackify', # Bundled texdiag build
'directxtex_jackify', # Bundled DirectXTex build
'7z', # Archive extraction (7z)
'7zz', # Archive extraction (7zz)
'bsarch', # BSA archive tool
'wine', # Proton/wine launcher
'wine64', # Proton/wine 64-bit launcher
'wine64-preloader', # Proton/wine preloader
'steam-run', # Steam runtime wrapper
'proton', # Proton launcher scripts
]
# Check process name
is_jackify = any(name in proc_name for name in jackify_names)
# 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
is_jackify = any(name in cmdline_str for name in jackify_names)
# Also check for .exe variants (wine runs .exe files)
if not is_jackify:
exe_names = [f'{name}.exe' for name in jackify_names]
is_jackify = any(exe_name in cmdline_str for exe_name in exe_names)
# Also check if command line contains jackify paths
if not is_jackify:
is_jackify = 'jackify' in cmdline_str and any(
tool in cmdline_str for tool in ['engine', 'tools', 'binaries']
)
if is_jackify:
# Check if this is a new process we haven't cached
if proc.pid not in self._child_process_cache:
# Establish baseline for new process and cache it
proc.cpu_percent(interval=0.1)
self._child_process_cache[proc.pid] = proc
# Skip this iteration since baseline was just set
continue
# Use cached process object
cached_proc = self._child_process_cache[proc.pid]
proc_cpu_raw = cached_proc.cpu_percent(interval=None)
proc_cpu = proc_cpu_raw / num_cpus
total_cpu += proc_cpu
tracked_pids.add(proc.pid)
extra_count += 1
extra_cpu_sum += proc_cpu_raw
except (psutil.NoSuchProcess, psutil.AccessDenied, AttributeError, TypeError):
pass
except Exception:
pass
# Smooth the value slightly to reduce jitter (less aggressive than before)
if self._last_cpu_percent > 0:
total_cpu = (self._last_cpu_percent * 0.3) + (total_cpu * 0.7)
self._last_cpu_percent = total_cpu
# Always show CPU percentage when tracking is active
# Cap at 100% for display (shouldn't exceed but just in case)
display_percent = min(100.0, total_cpu)
if display_percent >= 0.1:
self.cpu_label.setText(f"CPU: {display_percent:.0f}%")
else:
# Show 0% instead of hiding to indicate tracking is active
self.cpu_label.setText("CPU: 0%")
except Exception as e:
# Show error indicator if tracking fails
import sys
print(f"CPU tracking error: {e}", file=sys.stderr)
self.cpu_label.setText("")

View File

@@ -0,0 +1,178 @@
"""
Progress Indicator Widget
Enhanced status banner widget that displays overall installation progress.
R&D NOTE: This is experimental code for investigation purposes.
"""
from PySide6.QtWidgets import QWidget, QHBoxLayout, QLabel, QProgressBar, QSizePolicy
from PySide6.QtCore import Qt
from PySide6.QtGui import QFont
from jackify.shared.progress_models import InstallationProgress
from ..shared_theme import JACKIFY_COLOR_BLUE
class OverallProgressIndicator(QWidget):
"""
Enhanced progress indicator widget showing:
- Phase name
- Step progress [12/14]
- Data progress (1.1GB/56.3GB)
- Overall percentage
- Optional progress bar
"""
def __init__(self, parent=None, show_progress_bar=True):
"""
Initialize progress indicator.
Args:
parent: Parent widget
show_progress_bar: If True, show visual progress bar in addition to text
"""
super().__init__(parent)
self.show_progress_bar = show_progress_bar
self._setup_ui()
def _setup_ui(self):
"""Set up the UI components."""
layout = QHBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(8)
# Status text label (similar to TTW status banner)
self.status_label = QLabel("Ready to install")
self.status_label.setAlignment(Qt.AlignCenter)
self.status_label.setStyleSheet(f"""
background-color: #2a2a2a;
color: {JACKIFY_COLOR_BLUE};
padding: 6px 8px;
border-radius: 4px;
font-weight: bold;
font-size: 13px;
""")
self.status_label.setMaximumHeight(34)
self.status_label.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed)
# Progress bar (optional, shown below or integrated)
if self.show_progress_bar:
self.progress_bar = QProgressBar()
self.progress_bar.setRange(0, 100)
self.progress_bar.setValue(0)
self.progress_bar.setFormat("%p%")
# Use white text with shadow/outline effect for readability on both dark and blue backgrounds
self.progress_bar.setStyleSheet(f"""
QProgressBar {{
border: 1px solid #444;
border-radius: 4px;
text-align: center;
background-color: #1a1a1a;
color: #fff;
font-weight: bold;
height: 20px;
}}
QProgressBar::chunk {{
background-color: {JACKIFY_COLOR_BLUE};
border-radius: 3px;
}}
""")
self.progress_bar.setMaximumHeight(20)
self.progress_bar.setVisible(True)
# Layout: text on left, progress bar on right (or stacked)
if self.show_progress_bar:
# Horizontal layout: status text takes available space, progress bar fixed width
layout.addWidget(self.status_label, 1)
layout.addWidget(self.progress_bar, 0) # Fixed width
self.progress_bar.setFixedWidth(100) # Fixed width for progress bar
else:
# Just the status label, full width
layout.addWidget(self.status_label, 1)
# Constrain widget height to prevent unwanted vertical expansion
self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
self.setMaximumHeight(34) # Match status label height
def update_progress(self, progress: InstallationProgress):
"""
Update the progress indicator with new progress state.
Args:
progress: InstallationProgress object with current state
"""
# Update status text
display_text = progress.display_text
if not display_text or display_text == "Processing...":
display_text = progress.phase_name or progress.phase.value.title() or "Processing..."
self.status_label.setText(display_text)
# Update progress bar if enabled
if self.show_progress_bar and hasattr(self, 'progress_bar'):
# Calculate progress - prioritize data progress, then step progress, then overall_percent
display_percent = 0.0
# Check if we're in BSA building phase (detected by phase label)
from jackify.shared.progress_models import InstallationPhase
is_bsa_building = progress.get_phase_label() == "Building BSAs"
# For install/extract/BSA building phases, prefer step-based progress (more accurate)
if progress.phase in (InstallationPhase.INSTALL, InstallationPhase.EXTRACT) or is_bsa_building:
if progress.phase_max_steps > 0:
display_percent = (progress.phase_step / progress.phase_max_steps) * 100.0
elif progress.data_total > 0 and progress.data_processed > 0:
display_percent = (progress.data_processed / progress.data_total) * 100.0
else:
# If no step/data info, use overall_percent but only if it's reasonable
# Don't carry over 100% from previous phase
if progress.overall_percent > 0 and progress.overall_percent < 100.0:
display_percent = progress.overall_percent
else:
display_percent = 0.0 # Reset if we don't have valid progress
else:
# For other phases, prefer data progress, then overall_percent, then step progress
if progress.data_total > 0 and progress.data_processed > 0:
display_percent = (progress.data_processed / progress.data_total) * 100.0
elif progress.overall_percent > 0:
display_percent = progress.overall_percent
elif progress.phase_max_steps > 0:
display_percent = (progress.phase_step / progress.phase_max_steps) * 100.0
self.progress_bar.setValue(int(display_percent))
# Update tooltip with detailed information
tooltip_parts = []
if progress.phase_name:
tooltip_parts.append(f"Phase: {progress.phase_name}")
if progress.phase_progress_text:
tooltip_parts.append(f"Step: {progress.phase_progress_text}")
if progress.data_progress_text:
tooltip_parts.append(f"Data: {progress.data_progress_text}")
if progress.overall_percent > 0:
tooltip_parts.append(f"Overall: {progress.overall_percent:.1f}%")
if tooltip_parts:
self.progress_bar.setToolTip("\n".join(tooltip_parts))
self.status_label.setToolTip("\n".join(tooltip_parts))
def set_status(self, text: str, percent: int = None):
"""
Set status text directly without full progress update.
Args:
text: Status text to display
percent: Optional progress percentage (0-100)
"""
self.status_label.setText(text)
if percent is not None and self.show_progress_bar and hasattr(self, 'progress_bar'):
self.progress_bar.setValue(int(percent))
def reset(self):
"""Reset the progress indicator to initial state."""
self.status_label.setText("Ready to install")
if self.show_progress_bar and hasattr(self, 'progress_bar'):
self.progress_bar.setValue(0)
self.progress_bar.setToolTip("")
self.status_label.setToolTip("")