mirror of
https://github.com/Omni-guides/Jackify.git
synced 2026-01-17 19:47:00 +01:00
Sync from development - prepare for v0.2.0
This commit is contained in:
@@ -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):
|
||||
|
||||
@@ -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'
|
||||
]
|
||||
@@ -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)
|
||||
|
||||
@@ -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}")
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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> "
|
||||
)
|
||||
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
|
||||
@@ -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"""
|
||||
|
||||
@@ -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
63
jackify/frontends/gui/screens/install_ttw_simple_output.py
Normal file
63
jackify/frontends/gui/screens/install_ttw_simple_output.py
Normal 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
|
||||
@@ -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
|
||||
|
||||
1500
jackify/frontends/gui/screens/modlist_gallery.py
Normal file
1500
jackify/frontends/gui/screens/modlist_gallery.py
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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> "
|
||||
"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
|
||||
@@ -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 = (
|
||||
|
||||
@@ -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)
|
||||
|
||||
848
jackify/frontends/gui/widgets/file_progress_list.py
Normal file
848
jackify/frontends/gui/widgets/file_progress_list.py
Normal 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("")
|
||||
|
||||
|
||||
178
jackify/frontends/gui/widgets/progress_indicator.py
Normal file
178
jackify/frontends/gui/widgets/progress_indicator.py
Normal 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("")
|
||||
|
||||
Reference in New Issue
Block a user