import functools import sys import os import json import logging import time from pathlib import Path from functools import partial from PySide6.QtWidgets import QApplication 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 from .delegates import LineEditDelegate, ComboBoxDelegate, SupplierSearchDelegate, ItemTypeSearchDelegate from .unified_view_model import UnifiedViewModel from rule_structure import SourceRule, AssetRule, FileRule import configuration log = logging.getLogger(__name__) from configuration import Configuration, ConfigurationError # Import Configuration class and Error 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 start the main processing job process_requested = Signal(dict) cancel_requested = Signal() clear_queue_requested = Signal() llm_reinterpret_requested = Signal(list) preset_reinterpret_requested = Signal(list, str) output_dir_changed = Signal(str) blender_settings_changed = Signal(bool, str, str) def __init__(self, config: Configuration, unified_model: UnifiedViewModel, parent=None, file_type_keys: list[str] | None = None): """ Initializes the MainPanelWidget. Args: unified_model: The shared UnifiedViewModel instance. parent: The parent widget. file_type_keys: A list of available file type names (keys from FILE_TYPE_DEFINITIONS). """ super().__init__(parent) self._config = config # Store the Configuration object self.unified_model = unified_model self.file_type_keys = file_type_keys if file_type_keys else [] self.llm_processing_active = False 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) 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) try: # Access configuration directly from the stored object # Use the output_directory_pattern from the Configuration object output_pattern = self._config.output_directory_pattern # Assuming the pattern is relative to the project root for the default default_output_dir = (self.project_root / output_pattern).resolve() self.output_path_edit.setText(str(default_output_dir)) log.info(f"MainPanelWidget: Default output directory set to: {default_output_dir} based on pattern '{output_pattern}'") except ConfigurationError as e: log.error(f"MainPanelWidget: Configuration Error setting default output directory: {e}") self.output_path_edit.setText("") except Exception as e: log.exception(f"MainPanelWidget: Unexpected Error setting default output directory: {e}") self.output_path_edit.setText("") self.unified_view = QTreeView() self.unified_view.setModel(self.unified_model) lineEditDelegate = LineEditDelegate(self.unified_view) # TODO: Revisit ComboBoxDelegate dependency comboBoxDelegate = ComboBoxDelegate(self) supplierSearchDelegate = SupplierSearchDelegate(self) itemTypeSearchDelegate = ItemTypeSearchDelegate(self.file_type_keys, self) 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, itemTypeSearchDelegate) self.unified_view.setItemDelegateForColumn(UnifiedViewModel.COL_NAME, lineEditDelegate) 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) 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) self.unified_view.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu) self.unified_view.setDragEnabled(True) self.unified_view.setAcceptDrops(True) self.unified_view.setDropIndicatorShown(True) self.unified_view.setDefaultDropAction(Qt.MoveAction) self.unified_view.setDragDropMode(QAbstractItemView.InternalMove) self.unified_view.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection) main_layout.addWidget(self.unified_view, 1) self.progress_bar = QProgressBar() self.progress_bar.setValue(0) self.progress_bar.setTextVisible(True) self.progress_bar.setFormat("Idle") main_layout.addWidget(self.progress_bar) 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_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_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) try: # Use hardcoded defaults as Configuration object does not expose these via public interface default_ng_path = '' default_mat_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 Exception as e: log.error(f"MainPanelWidget: Error setting default Blender paths: {e}") 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) 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) 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.""" self.browse_output_button.clicked.connect(self._browse_for_output_directory) self.output_path_edit.editingFinished.connect(self._on_output_path_changed) self.unified_view.customContextMenuRequested.connect(self._show_unified_view_context_menu) 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) 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) self.clear_queue_button.clicked.connect(self.clear_queue_requested) self.start_button.clicked.connect(self._on_start_processing_clicked) self.cancel_button.clicked.connect(self.cancel_requested) @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) 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() @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) 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() @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 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) def _get_unique_source_dirs_from_selection(self, selected_indexes: list[QModelIndex]) -> set[str]: """ Extracts unique, valid source directory/zip paths from the selected QModelIndex list. Traverses up the model hierarchy to find the parent SourceRule for each index. """ unique_source_dirs = set() model = self.unified_view.model() if not model: log.error("Unified view model not found.") return unique_source_dirs processed_source_paths = set() for index in selected_indexes: if not index.isValid(): continue item_node = model.getItem(index) source_rule_node = None source_rule_node = None current_index = index while current_index.isValid(): current_item = model.getItem(current_index) if isinstance(current_item, SourceRule): source_rule_node = current_item break current_index = model.parent(current_index) # Move to the parent index # If loop finishes without break, source_rule_node remains None if source_rule_node: source_path = getattr(source_rule_node, 'input_path', None) if source_path and source_path not in processed_source_paths: source_path_obj = Path(source_path) if source_path_obj.is_dir() or (source_path_obj.is_file() and source_path_obj.suffix.lower() == '.zip'): log.debug(f"Identified source path for re-interpretation: {source_path}") unique_source_dirs.add(source_path) processed_source_paths.add(source_path) else: log.warning(f"Selected item's source path is not a directory or zip file: {source_path}") elif not source_path: log.warning(f"Parent SourceRule found for index {index.row()},{index.column()} but has no 'input_path' attribute.") else: log.warning(f"Could not find parent SourceRule for selected index: {index.row()},{index.column()} (Node type: {type(item_node).__name__})") return unique_source_dirs @Slot() def _on_llm_reinterpret_clicked(self): """Gathers selected source paths and emits the llm_reinterpret_requested signal. (Triggered by context menu)""" if self.llm_processing_active: QMessageBox.warning(self, "Busy", "LLM processing is already in progress. Please wait.") return selected_indexes = self.unified_view.selectionModel().selectedIndexes() unique_source_dirs = self._get_unique_source_dirs_from_selection(selected_indexes) if not unique_source_dirs: log.warning("No valid source directories found for selected items to re-interpret with LLM.") # Optionally show status bar message via MainWindow reference if available return log.info(f"Emitting llm_reinterpret_requested for {len(unique_source_dirs)} paths.") self.llm_reinterpret_requested.emit(list(unique_source_dirs)) @Slot(str, QModelIndex) def _on_reinterpret_preset_selected(self, preset_name: str, index: QModelIndex): """Handles the selection of a preset from the re-interpret context sub-menu.""" log.info(f"Preset re-interpretation requested: Preset='{preset_name}', Index='{index.row()},{index.column()}'") selected_indexes = self.unified_view.selectionModel().selectedIndexes() unique_source_dirs = self._get_unique_source_dirs_from_selection(selected_indexes) if not unique_source_dirs: log.warning("No valid source directories found for selected items to re-interpret with preset.") # Optionally show status bar message via MainWindow reference if available return log.info(f"Emitting preset_reinterpret_requested for {len(unique_source_dirs)} paths with preset '{preset_name}'.") self.preset_reinterpret_requested.emit(list(unique_source_dirs), preset_name) @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 model = self.unified_view.model() if not model: return item_node = model.getItem(index) # Find the SourceRule node associated with the clicked index source_rule_node = None current_index = index while current_index.isValid(): current_item = model.getItem(current_index) if isinstance(current_item, SourceRule): source_rule_node = current_item break current_index = model.parent(current_index) # Move to the parent index # If loop finishes without break, source_rule_node remains None menu = QMenu(self) if source_rule_node: # Only show if we clicked on or within a SourceRule item reinterpet_menu = menu.addMenu("Re-interpret selected source") preset_names = [] try: presets_dir = configuration.PRESETS_DIR if os.path.isdir(presets_dir): for filename in os.listdir(presets_dir): if filename.endswith(".json") and filename != "_template.json": preset_name = os.path.splitext(filename)[0] preset_names.append(preset_name) preset_names.sort() else: log.warning(f"Presets directory not found or not a directory: {presets_dir}") except Exception as e: log.exception(f"Error listing presets in {configuration.PRESETS_DIR}: {e}") if preset_names: for preset_name in preset_names: preset_action = QAction(preset_name, self) preset_action.triggered.connect(functools.partial(self._on_reinterpret_preset_selected, preset_name, index)) reinterpet_menu.addAction(preset_action) else: no_presets_action = QAction("No presets found", self) no_presets_action.setEnabled(False) reinterpet_menu.addAction(no_presets_action) reinterpet_menu.addSeparator() llm_action = QAction("LLM", self) llm_action.triggered.connect(self._on_llm_reinterpret_clicked) llm_action.setEnabled(not self.llm_processing_active) reinterpet_menu.addAction(llm_action) menu.addSeparator() if source_rule_node: # Check again if it's a source item for this action 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(source_rule_node)) menu.addAction(copy_llm_example_action) if not menu.isEmpty(): menu.exec(self.unified_view.viewport().mapToGlobal(point)) @Slot(SourceRule) def _copy_llm_example_to_clipboard(self, source_rule_node: SourceRule | None): """Copies a JSON structure for the given SourceRule node to the clipboard.""" if not source_rule_node: log.warning(f"No SourceRule node provided to copy LLM example.") return source_rule: SourceRule = source_rule_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() if clipboard: clipboard.setText(json_string) log.info(f"Copied LLM example JSON to clipboard for source: {source_rule.input_path}") 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}") self.progress_bar.setValue(percentage) self.progress_bar.setFormat(f"%p% ({current_count}/{total_count})") QApplication.processEvents() 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) 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.""" 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) @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 (used for context menu state).""" self.llm_processing_active = active # No button state to update directly, but context menu will check this flag when built. # TODO: Add method to get current output path if needed by MainWindow before processing def get_output_directory() -> 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() -> 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() -> int: return self.workers_spinbox.value() # TODO: Add method to get current overwrite setting if needed by MainWindow before processing def get_overwrite_setting() -> bool: return self.overwrite_checkbox.isChecked() def get_llm_source_preset_name() -> 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