mirror of
https://github.com/Omni-guides/Jackify.git
synced 2026-06-08 03:27:45 +02:00
Sync from development - prepare for v0.3.0
This commit is contained in:
368
jackify/frontends/gui/screens/install_modlist_console.py
Normal file
368
jackify/frontends/gui/screens/install_modlist_console.py
Normal file
@@ -0,0 +1,368 @@
|
||||
"""Console output management for InstallModlistScreen (Mixin)."""
|
||||
from PySide6.QtCore import Qt, QTimer
|
||||
from PySide6.QtWidgets import QSizePolicy, QApplication
|
||||
from PySide6.QtGui import QTextCursor
|
||||
from jackify.frontends.gui.services.message_service import MessageService
|
||||
import re
|
||||
|
||||
|
||||
class ConsoleOutputMixin:
|
||||
"""Mixin providing console output and scroll tracking for InstallModlistScreen."""
|
||||
|
||||
def _toggle_console_visibility(self, state):
|
||||
"""R&D: Toggle console visibility only
|
||||
|
||||
When "Show Details" is checked:
|
||||
- Show Console (below tabs)
|
||||
- Expand window height
|
||||
When "Show Details" is unchecked:
|
||||
- Hide Console
|
||||
- Collapse window height
|
||||
|
||||
Note: Activity and Process Monitor tabs are always available via tabs.
|
||||
"""
|
||||
is_checked = (state == Qt.Checked)
|
||||
|
||||
# Get main window reference (like TTW screen)
|
||||
main_window = None
|
||||
try:
|
||||
app = QApplication.instance()
|
||||
if app:
|
||||
main_window = app.activeWindow()
|
||||
# Try to find the actual main window (parent of stacked widget)
|
||||
if self.stacked_widget and self.stacked_widget.parent():
|
||||
main_window = self.stacked_widget.parent()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Save geometry on first expand (like TTW screen)
|
||||
if is_checked and main_window and self._saved_geometry is None:
|
||||
try:
|
||||
self._saved_geometry = main_window.geometry()
|
||||
self._saved_min_size = main_window.minimumSize()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if is_checked:
|
||||
# Keep upper section height consistent - don't change it
|
||||
# Prevent buttons from being cut off
|
||||
try:
|
||||
if hasattr(self, 'upper_section_widget') and self.upper_section_widget is not None:
|
||||
# Maintain consistent height - ALWAYS use the stored fixed height
|
||||
# Never recalculate - use the exact same height calculated in showEvent
|
||||
if hasattr(self, '_upper_section_fixed_height') and self._upper_section_fixed_height is not None:
|
||||
self.upper_section_widget.setMaximumHeight(self._upper_section_fixed_height)
|
||||
self.upper_section_widget.setMinimumHeight(self._upper_section_fixed_height) # Lock it
|
||||
# If somehow not stored, it should have been set in showEvent - don't recalculate here
|
||||
self.upper_section_widget.updateGeometry()
|
||||
except Exception:
|
||||
pass
|
||||
# Show console
|
||||
self.console.setVisible(True)
|
||||
self.console.show()
|
||||
self.console.setMinimumHeight(200)
|
||||
self.console.setMaximumHeight(16777215) # Remove height limit
|
||||
try:
|
||||
self.console.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
|
||||
# Set stretch on console in its layout to fill space
|
||||
console_layout = self.console.parent().layout()
|
||||
if console_layout:
|
||||
console_layout.setStretchFactor(console_layout.indexOf(self.console), 1)
|
||||
# Restore spacing when console is visible
|
||||
console_layout.setSpacing(4)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
# Set spacing in console_and_buttons_layout when console is visible
|
||||
if hasattr(self, 'console_and_buttons_layout'):
|
||||
self.console_and_buttons_layout.setSpacing(4) # Small gap between console and buttons
|
||||
# Set stretch on console_and_buttons_widget to fill space when expanded
|
||||
if hasattr(self, 'console_and_buttons_widget'):
|
||||
self.main_overall_vbox.setStretchFactor(self.console_and_buttons_widget, 1)
|
||||
# Allow expansion when console is visible - remove fixed height constraint
|
||||
self.console_and_buttons_widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
|
||||
# Clear fixed height by setting min/max (setFixedHeight sets both, so we override it)
|
||||
self.console_and_buttons_widget.setMinimumHeight(0)
|
||||
self.console_and_buttons_widget.setMaximumHeight(16777215)
|
||||
self.console_and_buttons_widget.updateGeometry()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Notify parent to expand - let main window handle resizing
|
||||
try:
|
||||
self.resize_request.emit('expand')
|
||||
except Exception:
|
||||
pass
|
||||
else:
|
||||
# Keep upper section height consistent - use same constraint
|
||||
# Prevent buttons from being cut off
|
||||
try:
|
||||
if hasattr(self, 'upper_section_widget') and self.upper_section_widget is not None:
|
||||
# Use the same stored fixed height for consistency
|
||||
# ALWAYS use the stored height - never recalculate to avoid drift
|
||||
if hasattr(self, '_upper_section_fixed_height') and self._upper_section_fixed_height is not None:
|
||||
self.upper_section_widget.setMaximumHeight(self._upper_section_fixed_height)
|
||||
self.upper_section_widget.setMinimumHeight(self._upper_section_fixed_height) # Lock it
|
||||
# If somehow not stored, it should have been set in showEvent - don't recalculate here
|
||||
self.upper_section_widget.updateGeometry()
|
||||
except Exception:
|
||||
pass
|
||||
# Hide console and ensure it takes zero space
|
||||
self.console.setVisible(False)
|
||||
self.console.setMinimumHeight(0)
|
||||
self.console.setMaximumHeight(0)
|
||||
# Use Ignored size policy so it doesn't participate in layout calculations
|
||||
self.console.setSizePolicy(QSizePolicy.Ignored, QSizePolicy.Ignored)
|
||||
try:
|
||||
# Remove stretch from console_and_buttons_widget when collapsed
|
||||
if hasattr(self, 'console_and_buttons_widget'):
|
||||
self.main_overall_vbox.setStretchFactor(self.console_and_buttons_widget, 0)
|
||||
# Set fixed height when console is hidden
|
||||
self.console_and_buttons_widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
|
||||
# Calculate height based on buttons only (console takes 0 space)
|
||||
button_height = 0
|
||||
if hasattr(self, 'console_and_buttons_layout'):
|
||||
for i in range(self.console_and_buttons_layout.count()):
|
||||
item = self.console_and_buttons_layout.itemAt(i)
|
||||
if item and item.widget() and item.widget() != self.console:
|
||||
button_height = max(button_height, item.widget().sizeHint().height())
|
||||
self.console_and_buttons_widget.setFixedHeight(button_height + 8) # Add small padding
|
||||
# Clear spacing when console is hidden
|
||||
if hasattr(self, 'console_and_buttons_layout'):
|
||||
self.console_and_buttons_layout.setSpacing(0)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Notify parent to collapse - let main window handle resizing
|
||||
try:
|
||||
self.resize_request.emit('collapse')
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def on_installation_output(self, message):
|
||||
"""Handle regular output from installation thread"""
|
||||
# Filter out internal status messages from user console
|
||||
if message.strip().startswith('[Jackify]'):
|
||||
# Log internal messages to file but don't show in console
|
||||
self._write_to_log_file(message)
|
||||
return
|
||||
|
||||
# CRITICAL: Detect token/auth errors and ALWAYS show them (even when not in debug mode)
|
||||
msg_lower = message.lower()
|
||||
token_error_keywords = [
|
||||
'token has expired',
|
||||
'token expired',
|
||||
'oauth token',
|
||||
'authentication failed',
|
||||
'unauthorized',
|
||||
'401',
|
||||
'403',
|
||||
'refresh token',
|
||||
'authorization failed',
|
||||
'nexus.*premium.*required',
|
||||
'premium.*required',
|
||||
]
|
||||
|
||||
is_token_error = any(keyword in msg_lower for keyword in token_error_keywords)
|
||||
if is_token_error:
|
||||
# CRITICAL ERROR - always show, even if console is hidden
|
||||
if not hasattr(self, '_token_error_notified'):
|
||||
self._token_error_notified = True
|
||||
# Show error dialog immediately
|
||||
MessageService.error(
|
||||
self,
|
||||
"Authentication Error",
|
||||
(
|
||||
"Nexus Mods authentication has failed. This may be due to:\n\n"
|
||||
"• OAuth token expired and refresh failed\n"
|
||||
"• Nexus Premium required for this modlist\n"
|
||||
"• Network connectivity issues\n\n"
|
||||
"Please check the console output (Show Details) for more information.\n"
|
||||
"You may need to re-authorize in Settings."
|
||||
),
|
||||
safety_level="high"
|
||||
)
|
||||
# Also show in console
|
||||
guidance = (
|
||||
"\n[Jackify] CRITICAL: Authentication/Token Error Detected!\n"
|
||||
"[Jackify] This may cause downloads to stop. Check the error message above.\n"
|
||||
"[Jackify] If OAuth token expired, go to Settings and re-authorize.\n"
|
||||
)
|
||||
self._safe_append_text(guidance)
|
||||
# Force console to be visible so user can see the error
|
||||
if not self.show_details_checkbox.isChecked():
|
||||
self.show_details_checkbox.setChecked(True)
|
||||
|
||||
# Detect known engine bugs and provide helpful guidance
|
||||
if 'destination array was not long enough' in msg_lower or \
|
||||
('argumentexception' in msg_lower and 'downloadmachineurl' in msg_lower):
|
||||
# Known bug in jackify-engine 0.4.0 during .wabbajack download
|
||||
if not hasattr(self, '_array_error_notified'):
|
||||
self._array_error_notified = True
|
||||
guidance = (
|
||||
"\n[Jackify] Engine Error Detected: Buffer size issue during .wabbajack download.\n"
|
||||
"[Jackify] This is a known bug in jackify-engine 0.4.0.\n"
|
||||
"[Jackify] Workaround: Delete any partial .wabbajack files in your downloads directory and try again.\n"
|
||||
)
|
||||
self._safe_append_text(guidance)
|
||||
|
||||
# R&D: Always write output to console buffer so it's available when user toggles Show Details
|
||||
# The console visibility is controlled by the checkbox, not whether we write to it
|
||||
self._safe_append_text(message)
|
||||
|
||||
def _setup_scroll_tracking(self):
|
||||
"""Set up scroll tracking for professional auto-scroll behavior"""
|
||||
scrollbar = self.console.verticalScrollBar()
|
||||
scrollbar.sliderPressed.connect(self._on_scrollbar_pressed)
|
||||
scrollbar.sliderReleased.connect(self._on_scrollbar_released)
|
||||
scrollbar.valueChanged.connect(self._on_scrollbar_value_changed)
|
||||
|
||||
def _on_scrollbar_pressed(self):
|
||||
"""User started manually scrolling"""
|
||||
self._user_manually_scrolled = True
|
||||
|
||||
def _on_scrollbar_released(self):
|
||||
"""User finished manually scrolling"""
|
||||
self._user_manually_scrolled = False
|
||||
|
||||
def _on_scrollbar_value_changed(self):
|
||||
"""Track if user is at bottom of scroll area"""
|
||||
scrollbar = self.console.verticalScrollBar()
|
||||
# Use tolerance to account for rounding and rapid updates
|
||||
self._was_at_bottom = scrollbar.value() >= scrollbar.maximum() - 1
|
||||
|
||||
# If user manually scrolls to bottom, reset manual scroll flag
|
||||
if self._was_at_bottom and self._user_manually_scrolled:
|
||||
# Small delay to allow user to scroll away if they want
|
||||
QTimer.singleShot(100, self._reset_manual_scroll_if_at_bottom)
|
||||
|
||||
def _reset_manual_scroll_if_at_bottom(self):
|
||||
"""Reset manual scroll flag if user is still at bottom after delay"""
|
||||
scrollbar = self.console.verticalScrollBar()
|
||||
if scrollbar.value() >= scrollbar.maximum() - 1:
|
||||
self._user_manually_scrolled = False
|
||||
|
||||
def _safe_append_text(self, text):
|
||||
"""
|
||||
Append text with professional auto-scroll behavior.
|
||||
|
||||
Handles carriage return (\\r) for in-place updates and newline (\\n) for new lines.
|
||||
"""
|
||||
# Write all messages to log file (including internal messages)
|
||||
self._write_to_log_file(text)
|
||||
|
||||
# Filter out internal status messages from user console display
|
||||
if text.strip().startswith('[Jackify]'):
|
||||
# Internal messages are logged but not shown in user console
|
||||
return
|
||||
|
||||
# Check if this is a carriage return update (should replace last line)
|
||||
if '\r' in text and '\n' not in text:
|
||||
# Carriage return - replace last line
|
||||
self._replace_last_console_line(text.replace('\r', ''))
|
||||
return
|
||||
|
||||
# Handle mixed \r\n or just \n - normal append
|
||||
# Clean up any remaining \r characters
|
||||
clean_text = text.replace('\r', '')
|
||||
|
||||
scrollbar = self.console.verticalScrollBar()
|
||||
# Check if user was at bottom BEFORE adding text
|
||||
was_at_bottom = (scrollbar.value() >= scrollbar.maximum() - 1) # Allow 1px tolerance
|
||||
|
||||
# Add the text
|
||||
self.console.append(clean_text)
|
||||
|
||||
# Auto-scroll if user was at bottom and hasn't manually scrolled
|
||||
# Re-check bottom state after text addition for better reliability
|
||||
if (was_at_bottom and not self._user_manually_scrolled) or \
|
||||
(not self._user_manually_scrolled and scrollbar.value() >= scrollbar.maximum() - 2):
|
||||
scrollbar.setValue(scrollbar.maximum())
|
||||
# Ensure user can still manually scroll up during rapid updates
|
||||
if scrollbar.value() == scrollbar.maximum():
|
||||
self._was_at_bottom = True
|
||||
|
||||
def _is_similar_progress_line(self, text):
|
||||
"""Check if this line is a similar progress update to the last line"""
|
||||
if not hasattr(self, '_last_console_line') or not self._last_console_line:
|
||||
return False
|
||||
|
||||
# Don't deduplicate if either line contains important markers
|
||||
important_markers = [
|
||||
'complete',
|
||||
'failed',
|
||||
'error',
|
||||
'warning',
|
||||
'starting',
|
||||
'===',
|
||||
'---',
|
||||
'SUCCESS',
|
||||
'FAILED',
|
||||
]
|
||||
|
||||
text_lower = text.lower()
|
||||
last_lower = self._last_console_line.lower()
|
||||
|
||||
for marker in important_markers:
|
||||
if marker.lower() in text_lower or marker.lower() in last_lower:
|
||||
return False
|
||||
|
||||
# Patterns that indicate this is a progress line that should replace the previous
|
||||
# These are the status lines that update rapidly with changing numbers
|
||||
progress_patterns = [
|
||||
'Installing files',
|
||||
'Extracting files',
|
||||
'Downloading:',
|
||||
'Building BSAs',
|
||||
'Validating',
|
||||
]
|
||||
|
||||
# Check if both current and last line contain the same progress pattern
|
||||
# AND the lines are actually different (not exact duplicates)
|
||||
for pattern in progress_patterns:
|
||||
if pattern in text and pattern in self._last_console_line:
|
||||
# Only deduplicate if the numbers/progress changed (not exact duplicate)
|
||||
if text.strip() != self._last_console_line.strip():
|
||||
return True
|
||||
|
||||
# Special case: texture conversion status is embedded in Installing files lines
|
||||
# Match lines like "Installing files X/Y (A/B) - Converting textures: N/M"
|
||||
if '- Converting textures:' in text and '- Converting textures:' in self._last_console_line:
|
||||
if text.strip() != self._last_console_line.strip():
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def _replace_last_console_line(self, text):
|
||||
"""Replace the last line in the console with new text"""
|
||||
scrollbar = self.console.verticalScrollBar()
|
||||
was_at_bottom = (scrollbar.value() >= scrollbar.maximum() - 1)
|
||||
|
||||
# Move cursor to end and select the last line
|
||||
cursor = self.console.textCursor()
|
||||
cursor.movePosition(QTextCursor.End)
|
||||
cursor.select(QTextCursor.LineUnderCursor)
|
||||
cursor.removeSelectedText()
|
||||
cursor.deletePreviousChar() # Remove the newline
|
||||
|
||||
# Insert the new text
|
||||
self.console.append(text)
|
||||
|
||||
# Track this line
|
||||
self._last_console_line = text
|
||||
|
||||
# Restore scroll position
|
||||
if was_at_bottom or not self._user_manually_scrolled:
|
||||
scrollbar.setValue(scrollbar.maximum())
|
||||
|
||||
def _write_to_log_file(self, message):
|
||||
"""Write message to workflow log file with timestamp"""
|
||||
try:
|
||||
from datetime import datetime
|
||||
timestamp = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
|
||||
with open(self.modlist_log_path, 'a', encoding='utf-8') as f:
|
||||
f.write(f"[{timestamp}] {message}\n")
|
||||
except Exception:
|
||||
# Logging should never break the workflow
|
||||
pass
|
||||
|
||||
Reference in New Issue
Block a user