"""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 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 msg_lower = message.lower() # Engine informational line; keep in debug log only to reduce user-facing noise. if ( 'contains files with foreign characters' in msg_lower and 'using proton 7z.exe for extraction' in msg_lower ): self._write_to_log_file(message) return # 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) # 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