import sys import os import json import logging import time from pathlib import Path from functools import partial from PySide6.QtWidgets import QApplication # Added for processEvents from PySide6.QtWidgets import ( QWidget, QVBoxLayout, QHBoxLayout, QSplitter, QTableView, QPushButton, QComboBox, QTableWidget, QTableWidgetItem, QHeaderView, QProgressBar, QLabel, QFrame, QCheckBox, QSpinBox, QListWidget, QTextEdit, QLineEdit, QMessageBox, QFileDialog, QInputDialog, QListWidgetItem, QTabWidget, QFormLayout, QGroupBox, QAbstractItemView, QSizePolicy, QTreeView, QMenu ) from PySide6.QtCore import Qt, Signal, Slot, QPoint, QModelIndex, QTimer from PySide6.QtGui import QColor, QAction, QPalette, QClipboard, QGuiApplication # Added QGuiApplication for clipboard # --- Local GUI Imports --- # Import delegates and models needed by the panel from .delegates import LineEditDelegate, ComboBoxDelegate, SupplierSearchDelegate from .unified_view_model import UnifiedViewModel # Assuming UnifiedViewModel is passed in # --- Backend Imports --- # Import Rule Structures if needed for context menus etc. from rule_structure import SourceRule, AssetRule, FileRule # Import config loading if defaults are needed directly here (though better passed from MainWindow) try: from configuration import ConfigurationError, load_base_config except ImportError: ConfigurationError = Exception load_base_config = None log = logging.getLogger(__name__) class MainPanelWidget(QWidget): """ Widget handling the main interaction panel: - Output directory selection - Asset preview/editing view (Unified View) - Blender post-processing options - Processing controls (Start, Cancel, Clear, LLM Re-interpret) """ # --- Signals Emitted by the Panel --- # Request to add new input paths (e.g., from drag/drop handled by MainWindow) # add_paths_requested = Signal(list) # Maybe not needed if MainWindow handles drop directly # Request to start the main processing job process_requested = Signal(dict) # Emits dict with settings: output_dir, overwrite, workers, blender_enabled, ng_path, mat_path # Request to cancel the ongoing processing job cancel_requested = Signal() # Request to clear the current queue/view clear_queue_requested = Signal() # Request to re-interpret selected items using LLM llm_reinterpret_requested = Signal(list) # Emits list of source paths # Notify when the output directory changes output_dir_changed = Signal(str) # Notify when Blender settings change blender_settings_changed = Signal(bool, str, str) # enabled, ng_path, mat_path def __init__(self, unified_model: UnifiedViewModel, parent=None): """ Initializes the MainPanelWidget. Args: unified_model: The shared UnifiedViewModel instance. parent: The parent widget. """ super().__init__(parent) self.unified_model = unified_model self.llm_processing_active = False # Track if LLM is running (set by MainWindow) # Get project root for resolving default paths if needed here script_dir = Path(__file__).parent self.project_root = script_dir.parent self._setup_ui() self._connect_signals() def _setup_ui(self): """Sets up the UI elements for the panel.""" main_layout = QVBoxLayout(self) main_layout.setContentsMargins(5, 5, 5, 5) # Reduce margins # --- Output Directory Selection --- output_layout = QHBoxLayout() self.output_dir_label = QLabel("Output Directory:") self.output_path_edit = QLineEdit() self.browse_output_button = QPushButton("Browse...") output_layout.addWidget(self.output_dir_label) output_layout.addWidget(self.output_path_edit, 1) output_layout.addWidget(self.browse_output_button) main_layout.addLayout(output_layout) # --- Set Initial Output Path (Copied from MainWindow) --- # Consider passing this default path from MainWindow instead of reloading config here if load_base_config: try: base_config = load_base_config() output_base_dir_config = base_config.get('OUTPUT_BASE_DIR', '../Asset_Processor_Output') default_output_dir = (self.project_root / output_base_dir_config).resolve() self.output_path_edit.setText(str(default_output_dir)) log.info(f"MainPanelWidget: Default output directory set to: {default_output_dir}") except ConfigurationError as e: log.error(f"MainPanelWidget: Error reading base configuration for default output directory: {e}") self.output_path_edit.setText("") except Exception as e: log.exception(f"MainPanelWidget: Error setting default output directory: {e}") self.output_path_edit.setText("") else: log.warning("MainPanelWidget: load_base_config not available to set default output path.") self.output_path_edit.setText("") # --- Unified View Setup --- self.unified_view = QTreeView() self.unified_view.setModel(self.unified_model) # Set the passed-in model # Instantiate Delegates lineEditDelegate = LineEditDelegate(self.unified_view) # ComboBoxDelegate needs access to MainWindow's get_llm_source_preset_name, # which might require passing MainWindow or a callback here. # For now, let's assume it can work without it or we adapt it later. # TODO: Revisit ComboBoxDelegate dependency comboBoxDelegate = ComboBoxDelegate(self) # Pass only parent (self) supplierSearchDelegate = SupplierSearchDelegate(self) # Pass parent # Set Delegates for Columns self.unified_view.setItemDelegateForColumn(UnifiedViewModel.COL_SUPPLIER, supplierSearchDelegate) self.unified_view.setItemDelegateForColumn(UnifiedViewModel.COL_ASSET_TYPE, comboBoxDelegate) self.unified_view.setItemDelegateForColumn(UnifiedViewModel.COL_TARGET_ASSET, lineEditDelegate) self.unified_view.setItemDelegateForColumn(UnifiedViewModel.COL_ITEM_TYPE, comboBoxDelegate) # Configure View Appearance self.unified_view.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) self.unified_view.setAlternatingRowColors(True) self.unified_view.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows) self.unified_view.setEditTriggers(QAbstractItemView.EditTrigger.DoubleClicked | QAbstractItemView.EditTrigger.SelectedClicked | QAbstractItemView.EditTrigger.EditKeyPressed) self.unified_view.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection) # Allow multi-select for re-interpret # Configure Header Resize Modes header = self.unified_view.header() header.setStretchLastSection(False) header.setSectionResizeMode(UnifiedViewModel.COL_NAME, QHeaderView.ResizeMode.ResizeToContents) header.setSectionResizeMode(UnifiedViewModel.COL_TARGET_ASSET, QHeaderView.ResizeMode.Stretch) header.setSectionResizeMode(UnifiedViewModel.COL_SUPPLIER, QHeaderView.ResizeMode.ResizeToContents) header.setSectionResizeMode(UnifiedViewModel.COL_ASSET_TYPE, QHeaderView.ResizeMode.ResizeToContents) header.setSectionResizeMode(UnifiedViewModel.COL_ITEM_TYPE, QHeaderView.ResizeMode.ResizeToContents) # Enable custom context menu self.unified_view.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu) # Add the Unified View to the main layout main_layout.addWidget(self.unified_view, 1) # Give it stretch factor 1 # --- Progress Bar --- self.progress_bar = QProgressBar() self.progress_bar.setValue(0) self.progress_bar.setTextVisible(True) self.progress_bar.setFormat("Idle") # Initial format main_layout.addWidget(self.progress_bar) # --- Blender Integration Controls --- blender_group = QGroupBox("Blender Post-Processing") blender_layout = QVBoxLayout(blender_group) self.blender_integration_checkbox = QCheckBox("Run Blender Scripts After Processing") self.blender_integration_checkbox.setToolTip("If checked, attempts to run create_nodegroups.py and create_materials.py in Blender.") blender_layout.addWidget(self.blender_integration_checkbox) # Nodegroup Blend Path nodegroup_layout = QHBoxLayout() nodegroup_layout.addWidget(QLabel("Nodegroup .blend:")) self.nodegroup_blend_path_input = QLineEdit() self.browse_nodegroup_blend_button = QPushButton("...") self.browse_nodegroup_blend_button.setFixedWidth(30) nodegroup_layout.addWidget(self.nodegroup_blend_path_input) nodegroup_layout.addWidget(self.browse_nodegroup_blend_button) blender_layout.addLayout(nodegroup_layout) # Materials Blend Path materials_layout = QHBoxLayout() materials_layout.addWidget(QLabel("Materials .blend:")) self.materials_blend_path_input = QLineEdit() self.browse_materials_blend_button = QPushButton("...") self.browse_materials_blend_button.setFixedWidth(30) materials_layout.addWidget(self.materials_blend_path_input) materials_layout.addWidget(self.browse_materials_blend_button) blender_layout.addLayout(materials_layout) # Initialize paths from config (Copied from MainWindow) # Consider passing these defaults from MainWindow if load_base_config: try: base_config = load_base_config() default_ng_path = base_config.get('DEFAULT_NODEGROUP_BLEND_PATH', '') default_mat_path = base_config.get('DEFAULT_MATERIALS_BLEND_PATH', '') self.nodegroup_blend_path_input.setText(default_ng_path if default_ng_path else "") self.materials_blend_path_input.setText(default_mat_path if default_mat_path else "") except ConfigurationError as e: log.error(f"MainPanelWidget: Error reading base configuration for default Blender paths: {e}") except Exception as e: log.error(f"MainPanelWidget: Error reading default Blender paths from config: {e}") else: log.warning("MainPanelWidget: load_base_config not available to set default Blender paths.") # Disable Blender controls initially if checkbox is unchecked self.nodegroup_blend_path_input.setEnabled(False) self.browse_nodegroup_blend_button.setEnabled(False) self.materials_blend_path_input.setEnabled(False) self.browse_materials_blend_button.setEnabled(False) main_layout.addWidget(blender_group) # Add the group box to the main layout # --- Bottom Controls --- bottom_controls_layout = QHBoxLayout() self.overwrite_checkbox = QCheckBox("Overwrite Existing") self.overwrite_checkbox.setToolTip("If checked, existing output folders for processed assets will be deleted and replaced.") bottom_controls_layout.addWidget(self.overwrite_checkbox) self.workers_label = QLabel("Workers:") self.workers_spinbox = QSpinBox() default_workers = 1 try: cores = os.cpu_count() if cores: default_workers = max(1, cores // 2) except NotImplementedError: pass self.workers_spinbox.setMinimum(1) self.workers_spinbox.setMaximum(os.cpu_count() or 32) self.workers_spinbox.setValue(default_workers) self.workers_spinbox.setToolTip("Number of assets to process concurrently.") bottom_controls_layout.addWidget(self.workers_label) bottom_controls_layout.addWidget(self.workers_spinbox) bottom_controls_layout.addStretch(1) # --- LLM Re-interpret Button --- self.llm_reinterpret_button = QPushButton("Re-interpret Selected with LLM") self.llm_reinterpret_button.setToolTip("Re-run LLM interpretation on the selected source items.") self.llm_reinterpret_button.setEnabled(False) # Initially disabled bottom_controls_layout.addWidget(self.llm_reinterpret_button) self.clear_queue_button = QPushButton("Clear Queue") self.start_button = QPushButton("Start Processing") self.cancel_button = QPushButton("Cancel") self.cancel_button.setEnabled(False) bottom_controls_layout.addWidget(self.clear_queue_button) bottom_controls_layout.addWidget(self.start_button) bottom_controls_layout.addWidget(self.cancel_button) main_layout.addLayout(bottom_controls_layout) def _connect_signals(self): """Connect internal UI signals to slots or emit panel signals.""" # Output Directory self.browse_output_button.clicked.connect(self._browse_for_output_directory) self.output_path_edit.editingFinished.connect(self._on_output_path_changed) # Emit signal when user finishes editing # Unified View self.unified_view.selectionModel().selectionChanged.connect(self._update_llm_reinterpret_button_state) self.unified_view.customContextMenuRequested.connect(self._show_unified_view_context_menu) # Blender Controls self.blender_integration_checkbox.toggled.connect(self._toggle_blender_controls) self.browse_nodegroup_blend_button.clicked.connect(self._browse_for_nodegroup_blend) self.browse_materials_blend_button.clicked.connect(self._browse_for_materials_blend) # Emit signal when paths change self.nodegroup_blend_path_input.editingFinished.connect(self._emit_blender_settings_changed) self.materials_blend_path_input.editingFinished.connect(self._emit_blender_settings_changed) self.blender_integration_checkbox.toggled.connect(self._emit_blender_settings_changed) # Bottom Buttons self.clear_queue_button.clicked.connect(self.clear_queue_requested) # Emit signal directly self.start_button.clicked.connect(self._on_start_processing_clicked) # Use slot to gather data self.cancel_button.clicked.connect(self.cancel_requested) # Emit signal directly self.llm_reinterpret_button.clicked.connect(self._on_llm_reinterpret_clicked) # Use slot to gather data # --- Slots for Internal UI Logic --- @Slot() def _browse_for_output_directory(self): """Opens a dialog to select the output directory.""" current_path = self.output_path_edit.text() if not current_path or not Path(current_path).is_dir(): current_path = str(self.project_root) # Use project root as fallback directory = QFileDialog.getExistingDirectory( self, "Select Output Directory", current_path, QFileDialog.Option.ShowDirsOnly | QFileDialog.Option.DontResolveSymlinks ) if directory: self.output_path_edit.setText(directory) self._on_output_path_changed() # Explicitly call the change handler @Slot() def _on_output_path_changed(self): """Emits the output_dir_changed signal.""" self.output_dir_changed.emit(self.output_path_edit.text()) @Slot(bool) def _toggle_blender_controls(self, checked): """Enable/disable Blender path inputs based on the checkbox state.""" self.nodegroup_blend_path_input.setEnabled(checked) self.browse_nodegroup_blend_button.setEnabled(checked) self.materials_blend_path_input.setEnabled(checked) self.browse_materials_blend_button.setEnabled(checked) # No need to emit here, the checkbox toggle signal is connected separately def _browse_for_blend_file(self, line_edit_widget: QLineEdit): """Opens a dialog to select a .blend file and updates the line edit.""" current_path = line_edit_widget.text() start_dir = str(Path(current_path).parent) if current_path and Path(current_path).exists() else str(self.project_root) file_path, _ = QFileDialog.getOpenFileName( self, "Select Blender File", start_dir, "Blender Files (*.blend);;All Files (*)" ) if file_path: line_edit_widget.setText(file_path) line_edit_widget.editingFinished.emit() # Trigger editingFinished to emit change signal @Slot() def _browse_for_nodegroup_blend(self): self._browse_for_blend_file(self.nodegroup_blend_path_input) @Slot() def _browse_for_materials_blend(self): self._browse_for_blend_file(self.materials_blend_path_input) @Slot() def _emit_blender_settings_changed(self): """Gathers current Blender settings and emits the blender_settings_changed signal.""" enabled = self.blender_integration_checkbox.isChecked() ng_path = self.nodegroup_blend_path_input.text() mat_path = self.materials_blend_path_input.text() self.blender_settings_changed.emit(enabled, ng_path, mat_path) @Slot() def _on_start_processing_clicked(self): """Gathers settings and emits the process_requested signal.""" output_dir = self.output_path_edit.text().strip() if not output_dir: QMessageBox.warning(self, "Missing Output Directory", "Please select an output directory.") return # Basic validation (MainWindow should do more thorough validation) try: Path(output_dir).mkdir(parents=True, exist_ok=True) except Exception as e: QMessageBox.warning(self, "Invalid Output Directory", f"Cannot use output directory:\n{output_dir}\n\nError: {e}") return settings = { "output_dir": output_dir, "overwrite": self.overwrite_checkbox.isChecked(), "workers": self.workers_spinbox.value(), "blender_enabled": self.blender_integration_checkbox.isChecked(), "nodegroup_blend_path": self.nodegroup_blend_path_input.text(), "materials_blend_path": self.materials_blend_path_input.text() } self.process_requested.emit(settings) @Slot() def _update_llm_reinterpret_button_state(self): """Enables/disables the LLM re-interpret button based on selection and LLM status.""" selection_model = self.unified_view.selectionModel() has_selection = selection_model is not None and selection_model.hasSelection() # Enable only if there's a selection AND LLM is not currently active self.llm_reinterpret_button.setEnabled(has_selection and not self.llm_processing_active) @Slot() def _on_llm_reinterpret_clicked(self): """Gathers selected source paths and emits the llm_reinterpret_requested signal.""" selected_indexes = self.unified_view.selectionModel().selectedIndexes() if not selected_indexes: return if self.llm_processing_active: QMessageBox.warning(self, "Busy", "LLM processing is already in progress. Please wait.") return unique_source_dirs = set() processed_source_paths = set() # Track processed source paths to avoid duplicates for index in selected_indexes: if not index.isValid(): continue item_node = index.internalPointer() if not item_node: continue # Traverse up to find the SourceRule node (Simplified traversal) source_node = None current_node = item_node while current_node is not None: if isinstance(current_node, SourceRule): source_node = current_node break # Simplified parent traversal - adjust if model structure is different parent_attr = getattr(current_node, 'parent', None) # Check for generic 'parent' if callable(parent_attr): # Check if parent is a method (like in QStandardItemModel) current_node = parent_attr() elif parent_attr: # Check if parent is an attribute current_node = parent_attr else: # Try specific parent attributes if generic fails parent_source = getattr(current_node, 'parent_source', None) if parent_source: current_node = parent_source else: parent_asset = getattr(current_node, 'parent_asset', None) if parent_asset: current_node = parent_asset else: # Reached top or unexpected node type current_node = None if source_node and hasattr(source_node, 'input_path') and source_node.input_path: source_path_str = source_node.input_path if source_path_str in processed_source_paths: continue source_path_obj = Path(source_path_str) if source_path_obj.is_dir() or (source_path_obj.is_file() and source_path_obj.suffix.lower() == '.zip'): unique_source_dirs.add(source_path_str) processed_source_paths.add(source_path_str) else: log.warning(f"Skipping non-directory/zip source for re-interpretation: {source_path_str}") # else: # Reduce log noise # log.warning(f"Could not determine valid SourceRule or input_path for selected index: {index.row()},{index.column()} (Item type: {type(item_node).__name__})") if not unique_source_dirs: # self.statusBar().showMessage("No valid source directories found for selected items.", 5000) # Status bar is in MainWindow log.warning("No valid source directories found for selected items to re-interpret.") return self.llm_reinterpret_requested.emit(list(unique_source_dirs)) @Slot(QPoint) def _show_unified_view_context_menu(self, point: QPoint): """Shows the context menu for the unified view.""" index = self.unified_view.indexAt(point) if not index.isValid(): return item_node = index.internalPointer() is_source_item = isinstance(item_node, SourceRule) menu = QMenu(self) if is_source_item: copy_llm_example_action = QAction("Copy LLM Example to Clipboard", self) copy_llm_example_action.setToolTip("Copies a JSON structure representing the input files and predicted output, suitable for LLM examples.") copy_llm_example_action.triggered.connect(lambda: self._copy_llm_example_to_clipboard(index)) menu.addAction(copy_llm_example_action) menu.addSeparator() # Add other actions... if not menu.isEmpty(): menu.exec(self.unified_view.viewport().mapToGlobal(point)) @Slot(QModelIndex) def _copy_llm_example_to_clipboard(self, index: QModelIndex): """Copies a JSON structure for the selected source item to the clipboard.""" if not index.isValid(): return item_node = index.internalPointer() if not isinstance(item_node, SourceRule): return source_rule: SourceRule = item_node log.info(f"Attempting to generate LLM example JSON for source: {source_rule.input_path}") all_file_paths = [] predicted_assets_data = [] for asset_rule in source_rule.assets: asset_files_data = [] for file_rule in asset_rule.files: if file_rule.file_path: all_file_paths.append(file_rule.file_path) asset_files_data.append({ "file_path": file_rule.file_path, "predicted_file_type": file_rule.item_type or "UNKNOWN" }) asset_files_data.sort(key=lambda x: x['file_path']) predicted_assets_data.append({ "suggested_asset_name": asset_rule.asset_name or "UnnamedAsset", "predicted_asset_type": asset_rule.asset_type or "UNKNOWN", "files": asset_files_data }) predicted_assets_data.sort(key=lambda x: x['suggested_asset_name']) all_file_paths.sort() if not all_file_paths: log.warning(f"No file paths found for source: {source_rule.input_path}. Cannot generate example.") # Cannot show status bar message here return llm_example = { "input": "\n".join(all_file_paths), "output": {"predicted_assets": predicted_assets_data} } try: json_string = json.dumps(llm_example, indent=2) clipboard = QGuiApplication.clipboard() # Use QGuiApplication if clipboard: clipboard.setText(json_string) log.info(f"Copied LLM example JSON to clipboard for source: {source_rule.input_path}") # Cannot show status bar message here else: log.error("Failed to get system clipboard.") except Exception as e: log.exception(f"Error copying LLM example JSON to clipboard: {e}") # --- Public Slots for MainWindow to Call --- @Slot(int, int) def update_progress_bar(self, current_count, total_count): """Updates the progress bar display.""" if total_count > 0: percentage = int((current_count / total_count) * 100) log.debug(f"Updating progress bar: current={current_count}, total={total_count}, calculated_percentage={percentage}") # DEBUG LOG self.progress_bar.setValue(percentage) self.progress_bar.setFormat(f"%p% ({current_count}/{total_count})") QApplication.processEvents() # Force GUI update else: self.progress_bar.setValue(0) self.progress_bar.setFormat("0/0") @Slot(str) def set_progress_bar_text(self, text: str): """Sets the text format of the progress bar.""" self.progress_bar.setFormat(text) # Reset value if setting text like "Idle" or "Waiting..." if not "%" in text: self.progress_bar.setValue(0) @Slot(bool) def set_controls_enabled(self, enabled: bool): """Enables or disables controls within the panel.""" # Enable/disable most controls based on the 'enabled' flag self.output_path_edit.setEnabled(enabled) self.browse_output_button.setEnabled(enabled) self.unified_view.setEnabled(enabled) self.overwrite_checkbox.setEnabled(enabled) self.workers_spinbox.setEnabled(enabled) self.clear_queue_button.setEnabled(enabled) self.blender_integration_checkbox.setEnabled(enabled) # Start button is enabled only if controls are generally enabled AND preset mode is active (handled by MainWindow) # Cancel button is enabled only when processing is active (handled by MainWindow) # LLM button state depends on selection and LLM status (handled by _update_llm_reinterpret_button_state) # Blender path inputs depend on both 'enabled' and the checkbox state blender_paths_enabled = enabled and self.blender_integration_checkbox.isChecked() self.nodegroup_blend_path_input.setEnabled(blender_paths_enabled) self.browse_nodegroup_blend_button.setEnabled(blender_paths_enabled) self.materials_blend_path_input.setEnabled(blender_paths_enabled) self.browse_materials_blend_button.setEnabled(blender_paths_enabled) # Update LLM button state explicitly when controls are enabled/disabled if enabled: self._update_llm_reinterpret_button_state() else: self.llm_reinterpret_button.setEnabled(False) @Slot(bool) def set_start_button_enabled(self, enabled: bool): """Sets the enabled state of the Start Processing button.""" self.start_button.setEnabled(enabled) @Slot(str) def set_start_button_text(self, text: str): """Sets the text of the Start Processing button.""" self.start_button.setText(text) @Slot(bool) def set_cancel_button_enabled(self, enabled: bool): """Sets the enabled state of the Cancel button.""" self.cancel_button.setEnabled(enabled) @Slot(bool) def set_llm_processing_status(self, active: bool): """Informs the panel whether LLM processing is active.""" self.llm_processing_active = active self._update_llm_reinterpret_button_state() # Update button state based on new status # TODO: Add method to get current output path if needed by MainWindow before processing def get_output_directory(self) -> str: return self.output_path_edit.text().strip() # TODO: Add method to get current Blender settings if needed by MainWindow before processing def get_blender_settings(self) -> dict: return { "enabled": self.blender_integration_checkbox.isChecked(), "nodegroup_blend_path": self.nodegroup_blend_path_input.text(), "materials_blend_path": self.materials_blend_path_input.text() } # TODO: Add method to get current worker count if needed by MainWindow before processing def get_worker_count(self) -> int: return self.workers_spinbox.value() # TODO: Add method to get current overwrite setting if needed by MainWindow before processing def get_overwrite_setting(self) -> bool: return self.overwrite_checkbox.isChecked() # --- Delegate Dependency --- # This method might be needed by ComboBoxDelegate if it relies on MainWindow's logic def get_llm_source_preset_name(self) -> str | None: """ Placeholder for providing context to delegates. Ideally, the required info (like last preset name) should be passed from MainWindow when the delegate needs it, or the delegate's dependency should be refactored. """ log.warning("MainPanelWidget.get_llm_source_preset_name called - needs proper implementation or refactoring.") # This needs to get the info from MainWindow, perhaps via a signal/slot or passed reference. # Returning None for now. return None