import sys import os import json import logging import time from pathlib import Path from functools import partial # For connecting signals with arguments log = logging.getLogger(__name__) log.info(f"sys.path: {sys.path}") from PySide6.QtWidgets import ( QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, QSplitter, QTableView, # Added QSplitter, QTableView QPushButton, QComboBox, QTableWidget, QTableWidgetItem, QHeaderView, QProgressBar, QLabel, QFrame, QCheckBox, QSpinBox, QListWidget, QTextEdit, # Added QListWidget, QTextEdit QLineEdit, QMessageBox, QFileDialog, QInputDialog, QListWidgetItem, QTabWidget, # Added more widgets QFormLayout, QGroupBox, QAbstractItemView, QSizePolicy, # Added more layout/widget items QMenuBar, QMenu # Added for menu ) from PySide6.QtCore import Qt, QThread, Slot, Signal, QObject, QModelIndex # Added Signal, QObject, QModelIndex from PySide6.QtGui import QColor, QAction, QPalette # Add QColor import, QAction, QPalette # --- Backend Imports for Data Structures --- from rule_structure import SourceRule, AssetRule, FileRule # Import Rule Structures from gui.rule_editor_widget import RuleEditorWidget # Import the new rule editor widget # --- GUI Model Imports --- from gui.preview_table_model import PreviewTableModel, PreviewSortFilterProxyModel from gui.rule_hierarchy_model import RuleHierarchyModel # Import the new hierarchy model # --- Backend Imports --- script_dir = Path(__file__).parent project_root = script_dir.parent if str(project_root) not in sys.path: sys.path.insert(0, str(project_root)) try: from configuration import Configuration, ConfigurationError from asset_processor import AssetProcessor, AssetProcessingError from gui.processing_handler import ProcessingHandler from gui.prediction_handler import PredictionHandler import config as core_config # Import the config module # PresetEditorDialog is no longer needed except ImportError as e: print(f"ERROR: Failed to import backend modules: {e}") print(f"Ensure GUI is run from project root or backend modules are in PYTHONPATH.") Configuration = None AssetProcessor = None ProcessingHandler = None PredictionHandler = None ConfigurationError = Exception AssetProcessingError = Exception # --- Constants --- PRESETS_DIR = project_root / "presets" TEMPLATE_PATH = PRESETS_DIR / "_template.json" # Setup basic logging log = logging.getLogger(__name__) if not log.hasHandlers(): # Set level back to INFO for normal operation logging.basicConfig(level=logging.INFO, format='%(levelname)s: %(message)s') # Reverted level and format # --- Custom Log Handler --- class QtLogHandler(logging.Handler, QObject): """ Custom logging handler that emits a Qt signal for each log record. Inherits from QObject to support signals. """ log_record_received = Signal(str) # Signal emitting the formatted log string def __init__(self, parent=None): logging.Handler.__init__(self) QObject.__init__(self, parent) # Initialize QObject part def emit(self, record): """ Overrides the default emit method to format the record and emit a signal. """ try: msg = self.format(record) self.log_record_received.emit(msg) except Exception: self.handleError(record) # --- Helper Functions (from PresetEditorDialog) --- # NOTE: Consider moving these to a utils file if reused elsewhere def setup_list_widget_with_controls(parent_layout, label_text, attribute_name, instance): """Adds a QListWidget with Add/Remove buttons to a layout.""" list_widget = QListWidget() list_widget.setAlternatingRowColors(True) # Make items editable by default in the editor context list_widget.setEditTriggers(QAbstractItemView.EditTrigger.DoubleClicked | QAbstractItemView.EditTrigger.SelectedClicked | QAbstractItemView.EditTrigger.EditKeyPressed) instance.__setattr__(attribute_name, list_widget) # Store list widget on the instance add_button = QPushButton("+") remove_button = QPushButton("-") add_button.setFixedWidth(30) remove_button.setFixedWidth(30) button_layout = QVBoxLayout() button_layout.addWidget(add_button) button_layout.addWidget(remove_button) button_layout.addStretch() list_layout = QHBoxLayout() list_layout.addWidget(list_widget) list_layout.addLayout(button_layout) group_box = QGroupBox(label_text) group_box_layout = QVBoxLayout(group_box) group_box_layout.addLayout(list_layout) parent_layout.addWidget(group_box) # Connections (use the instance's methods) add_button.clicked.connect(partial(instance._editor_add_list_item, list_widget)) remove_button.clicked.connect(partial(instance._editor_remove_list_item, list_widget)) list_widget.itemChanged.connect(instance._mark_editor_unsaved) # Mark unsaved on item edit def setup_table_widget_with_controls(parent_layout, label_text, attribute_name, columns, instance): """Adds a QTableWidget with Add/Remove buttons to a layout.""" table_widget = QTableWidget() table_widget.setColumnCount(len(columns)) table_widget.setHorizontalHeaderLabels(columns) table_widget.setAlternatingRowColors(True) instance.__setattr__(attribute_name, table_widget) # Store table widget add_button = QPushButton("+ Row") remove_button = QPushButton("- Row") button_layout = QHBoxLayout() button_layout.addStretch() button_layout.addWidget(add_button) button_layout.addWidget(remove_button) group_box = QGroupBox(label_text) group_box_layout = QVBoxLayout(group_box) group_box_layout.addWidget(table_widget) group_box_layout.addLayout(button_layout) parent_layout.addWidget(group_box) # Connections (use the instance's methods) add_button.clicked.connect(partial(instance._editor_add_table_row, table_widget)) remove_button.clicked.connect(partial(instance._editor_remove_table_row, table_widget)) table_widget.itemChanged.connect(instance._mark_editor_unsaved) # Mark unsaved on item edit # --- Main Window Class --- class MainWindow(QMainWindow): # Signal emitted when presets change in the editor panel presets_changed_signal = Signal() # Signal to trigger prediction handler in its thread start_prediction_signal = Signal(list, str, object) # input_paths, preset_name, rules def __init__(self): super().__init__() self.setWindowTitle("Asset Processor Tool") self.resize(1200, 700) # Increased default size # --- Internal State --- self.current_asset_paths = set() # Store unique paths of assets added self.rule_hierarchy_model = RuleHierarchyModel() # Instantiate the hierarchy model self._current_source_rule = None # Store the current SourceRule object # --- Editor State --- self.current_editing_preset_path = None self.editor_unsaved_changes = False self._is_loading_editor = False # Flag to prevent signals during load # --- Threading Setup --- self.processing_thread = None self.processing_handler = None self.prediction_thread = None self.prediction_handler = None self.setup_threads() # --- Preview Area (Table) Setup --- # Initialize models self.preview_model = PreviewTableModel() self.preview_proxy_model = PreviewSortFilterProxyModel() self.preview_proxy_model.setSourceModel(self.preview_model) # Initialize table view and placeholder self.preview_table_view = QTableView() self.preview_table_view.setModel(self.preview_proxy_model) self.preview_placeholder_label = QLabel("Please select a preset to view file predictions") self.preview_placeholder_label.setAlignment(Qt.AlignmentFlag.AlignCenter) self.preview_placeholder_label.setStyleSheet("QLabel { font-size: 16px; color: grey; }") # Initially hide the table view and show the placeholder self.preview_table_view.setVisible(False) self.preview_placeholder_label.setVisible(True) # Apply style sheet to remove borders and rounded corners self.preview_table_view.setStyleSheet("QTableView { border: none; }") # --- Main Layout with Splitter --- self.splitter = QSplitter(Qt.Orientation.Horizontal) self.setCentralWidget(self.splitter) # --- Create Panels --- self.editor_panel = QWidget() self.main_panel = QWidget() self.splitter.addWidget(self.editor_panel) self.splitter.addWidget(self.main_panel) # --- Setup UI Elements for each panel --- self.setup_editor_panel_ui() self.setup_main_panel_ui() self.setup_menu_bar() # Setup menu bar # --- Status Bar --- self.statusBar().showMessage("Ready") # --- Initial State --- self._clear_editor() # Clear/disable editor fields initially self._set_editor_enabled(False) # Disable editor initially self.populate_presets() # Populate preset list self.setup_logging_handler() # Setup the custom log handler # --- Connect Editor Signals --- self._connect_editor_change_signals() # --- Adjust Splitter --- self.splitter.setSizes([400, 800]) # Initial size ratio # --- UI Setup Methods --- def setup_editor_panel_ui(self): """Sets up the UI elements for the left preset editor panel.""" editor_layout = QVBoxLayout(self.editor_panel) editor_layout.setContentsMargins(5, 5, 5, 5) # Reduce margins # --- Log Console Output (Initially Hidden) --- self.log_console_widget = QWidget() log_console_layout = QVBoxLayout(self.log_console_widget) log_console_layout.setContentsMargins(0, 0, 0, 5) # Add some bottom margin log_console_label = QLabel("Log Console:") self.log_console_output = QTextEdit() self.log_console_output.setReadOnly(True) self.log_console_output.setMaximumHeight(150) # Limit initial height self.log_console_output.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Maximum) log_console_layout.addWidget(log_console_label) log_console_layout.addWidget(self.log_console_output) self.log_console_widget.setVisible(False) # Start hidden editor_layout.addWidget(self.log_console_widget) # Add it at the top # Preset List and Controls list_layout = QVBoxLayout() list_layout.addWidget(QLabel("Presets:")) self.editor_preset_list = QListWidget() self.editor_preset_list.currentItemChanged.connect(self._load_selected_preset_for_editing) list_layout.addWidget(self.editor_preset_list) list_button_layout = QHBoxLayout() self.editor_new_button = QPushButton("New") self.editor_delete_button = QPushButton("Delete") self.editor_new_button.clicked.connect(self._new_preset) self.editor_delete_button.clicked.connect(self._delete_selected_preset) list_button_layout.addWidget(self.editor_new_button) list_button_layout.addWidget(self.editor_delete_button) list_layout.addLayout(list_button_layout) editor_layout.addLayout(list_layout, 1) # Allow list to stretch # Editor Tabs self.editor_tab_widget = QTabWidget() self.editor_tab_general_naming = QWidget() self.editor_tab_mapping_rules = QWidget() self.editor_tab_widget.addTab(self.editor_tab_general_naming, "General & Naming") self.editor_tab_widget.addTab(self.editor_tab_mapping_rules, "Mapping & Rules") self._create_editor_general_tab() self._create_editor_mapping_tab() editor_layout.addWidget(self.editor_tab_widget, 3) # Allow tabs to stretch more # Save Buttons save_button_layout = QHBoxLayout() self.editor_save_button = QPushButton("Save") self.editor_save_as_button = QPushButton("Save As...") self.editor_save_button.setEnabled(False) # Disabled initially self.editor_save_button.clicked.connect(self._save_current_preset) self.editor_save_as_button.clicked.connect(self._save_preset_as) save_button_layout.addStretch() save_button_layout.addWidget(self.editor_save_button) save_button_layout.addWidget(self.editor_save_as_button) editor_layout.addLayout(save_button_layout) def _create_editor_general_tab(self): """Creates the widgets and layout for the 'General & Naming' editor tab.""" layout = QVBoxLayout(self.editor_tab_general_naming) form_layout = QFormLayout() form_layout.setFieldGrowthPolicy(QFormLayout.FieldGrowthPolicy.ExpandingFieldsGrow) # Basic Info self.editor_preset_name = QLineEdit() self.editor_supplier_name = QLineEdit() self.editor_notes = QTextEdit() self.editor_notes.setAcceptRichText(False) self.editor_notes.setFixedHeight(60) form_layout.addRow("Preset Name:", self.editor_preset_name) form_layout.addRow("Supplier Name:", self.editor_supplier_name) form_layout.addRow("Notes:", self.editor_notes) layout.addLayout(form_layout) # Source Naming Group naming_group = QGroupBox("Source File Naming Rules") naming_layout_outer = QVBoxLayout(naming_group) naming_layout_form = QFormLayout() self.editor_separator = QLineEdit() self.editor_separator.setMaxLength(1) self.editor_spin_base_name_idx = QSpinBox() self.editor_spin_base_name_idx.setMinimum(-1) self.editor_spin_map_type_idx = QSpinBox() self.editor_spin_map_type_idx.setMinimum(-1) naming_layout_form.addRow("Separator:", self.editor_separator) naming_layout_form.addRow("Base Name Index:", self.editor_spin_base_name_idx) naming_layout_form.addRow("Map Type Index:", self.editor_spin_map_type_idx) naming_layout_outer.addLayout(naming_layout_form) # Gloss Keywords List setup_list_widget_with_controls(naming_layout_outer, "Glossiness Keywords", "editor_list_gloss_keywords", self) # Bit Depth Variants Table setup_table_widget_with_controls(naming_layout_outer, "16-bit Variant Patterns", "editor_table_bit_depth_variants", ["Map Type", "Pattern"], self) self.editor_table_bit_depth_variants.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeMode.ResizeToContents) self.editor_table_bit_depth_variants.horizontalHeader().setSectionResizeMode(1, QHeaderView.ResizeMode.Stretch) layout.addWidget(naming_group) # Extra Files Group setup_list_widget_with_controls(layout, "Move to 'Extra' Folder Patterns", "editor_list_extra_patterns", self) layout.addStretch(1) def _create_editor_mapping_tab(self): """Creates the widgets and layout for the 'Mapping & Rules' editor tab.""" layout = QVBoxLayout(self.editor_tab_mapping_rules) # Map Type Mapping Group setup_table_widget_with_controls(layout, "Map Type Mapping (Standard Type <- Input Keywords)", "editor_table_map_type_mapping", ["Standard Type", "Input Keywords (comma-sep)"], self) self.editor_table_map_type_mapping.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeMode.ResizeToContents) self.editor_table_map_type_mapping.horizontalHeader().setSectionResizeMode(1, QHeaderView.ResizeMode.Stretch) # Category Rules Group category_group = QGroupBox("Asset Category Rules") category_layout = QVBoxLayout(category_group) setup_list_widget_with_controls(category_layout, "Model File Patterns", "editor_list_model_patterns", self) setup_list_widget_with_controls(category_layout, "Decal Keywords", "editor_list_decal_keywords", self) layout.addWidget(category_group) # Archetype Rules Group setup_table_widget_with_controls(layout, "Archetype Rules", "editor_table_archetype_rules", ["Archetype Name", "Match Any (comma-sep)", "Match All (comma-sep)"], self) self.editor_table_archetype_rules.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeMode.ResizeToContents) self.editor_table_archetype_rules.horizontalHeader().setSectionResizeMode(1, QHeaderView.ResizeMode.Stretch) self.editor_table_archetype_rules.horizontalHeader().setSectionResizeMode(2, QHeaderView.ResizeMode.Stretch) layout.addStretch(1) def setup_main_panel_ui(self): """Sets up the UI elements for the right main processing panel.""" main_layout = QVBoxLayout(self.main_panel) 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() # Make read-only for now, user must use Browse # self.output_path_edit.setReadOnly(True) self.browse_output_button = QPushButton("Browse...") self.browse_output_button.clicked.connect(self._browse_for_output_directory) 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 --- try: output_base_dir_config = getattr(core_config, 'OUTPUT_BASE_DIR', '../Asset_Processor_Output') # Default if not found # Resolve the path relative to the project root default_output_dir = (project_root / output_base_dir_config).resolve() self.output_path_edit.setText(str(default_output_dir)) log.info(f"Default output directory set to: {default_output_dir}") except Exception as e: log.error(f"Error setting default output directory: {e}") self.output_path_edit.setText("") # Clear on error self.statusBar().showMessage(f"Error setting default output path: {e}", 5000) # --- Drag and Drop Area --- self.drag_drop_area = QFrame() self.drag_drop_area.setFrameShape(QFrame.Shape.StyledPanel) self.drag_drop_area.setFrameShadow(QFrame.Shadow.Sunken) drag_drop_layout = QVBoxLayout(self.drag_drop_area) drag_drop_label = QLabel("Drag and Drop Asset Files/Folders Here") drag_drop_label.setAlignment(Qt.AlignmentFlag.AlignCenter) drag_drop_layout.addWidget(drag_drop_label) self.drag_drop_area.setMinimumHeight(100) self.setAcceptDrops(True) # Main window handles drops initially main_layout.addWidget(self.drag_drop_area) self.drag_drop_area.setVisible(False) # Hide the specific visual drag/drop area # --- Hierarchy and Rule Editor Splitter --- self.hierarchy_rule_splitter = QSplitter(Qt.Orientation.Vertical) main_layout.addWidget(self.hierarchy_rule_splitter, 1) # Give it stretch factor # --- Hierarchy Tree View --- from PySide6.QtWidgets import QTreeView # Import QTreeView self.hierarchy_tree_view = QTreeView() self.hierarchy_tree_view.setHeaderHidden(True) # Hide header for simple hierarchy display self.hierarchy_tree_view.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers) # Make items non-editable self.hierarchy_tree_view.setModel(self.rule_hierarchy_model) # Set the hierarchy model self.hierarchy_tree_view.clicked.connect(self._on_hierarchy_item_clicked) # Connect click signal self.hierarchy_rule_splitter.addWidget(self.hierarchy_tree_view) # --- Rule Editor Widget --- self.rule_editor_widget = RuleEditorWidget() self.rule_editor_widget.rule_updated.connect(self._on_rule_updated) # Connect rule updated signal self.hierarchy_rule_splitter.addWidget(self.rule_editor_widget) # Set initial sizes for the splitter self.hierarchy_rule_splitter.setSizes([200, 400]) # Adjust sizes as needed # --- Preview Area (Table) - Moved into the splitter --- # The preview table view will now be used to display files for the selected asset/source # Set headers and resize modes using the model's headerData header = self.preview_table_view.horizontalHeader() # Set resize modes for detailed columns header.setSectionResizeMode(self.preview_model.COL_STATUS, QHeaderView.ResizeMode.ResizeToContents) header.setSectionResizeMode(self.preview_model.COL_PREDICTED_ASSET, QHeaderView.ResizeMode.ResizeToContents) # Fit header.setSectionResizeMode(self.preview_model.COL_DETAILS, QHeaderView.ResizeMode.ResizeToContents) # Fit header.setSectionResizeMode(self.preview_model.COL_ORIGINAL_PATH, QHeaderView.ResizeMode.ResizeToContents) # Fixed width (using ResizeToContents as closest) header.setSectionResizeMode(self.preview_model.COL_ADDITIONAL_FILES, QHeaderView.ResizeMode.Stretch) # Stretch (Fit-If-Possible) # Hide the Predicted Output column self.preview_table_view.setColumnHidden(self.preview_model.COL_PREDICTED_OUTPUT, True) # Set selection behavior and alternating colors self.preview_table_view.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers) self.preview_table_view.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows) self.preview_table_view.setAlternatingRowColors(False) # Enable sorting via header clicks self.preview_table_view.setSortingEnabled(True) # Set default sort column (Status) - the proxy model's lessThan handles the custom order self.preview_table_view.sortByColumn(self.preview_model.COL_STATUS, Qt.SortOrder.AscendingOrder) # Move columns to the desired order: Status, Predicted Asset, Details, Original Path, Additional Files # Initial logical order: [0, 1, 2, 3(hidden), 4, 5] # Initial visual order: [0, 1, 2, 3, 4, 5] (assuming no initial moves) # Desired visual order: [0, 1, 4, 2, 5, 3(hidden)] # Move Predicted Asset (logical 1) to visual index 1 (already there) # Move Details (logical 4) to visual index 2 header.moveSection(header.visualIndex(self.preview_model.COL_DETAILS), 2) # Current visual: [0, 1, 4, 2, 3, 5] # Move Original Path (logical 2) to visual index 3 header.moveSection(header.visualIndex(self.preview_model.COL_ORIGINAL_PATH), 3) # Current visual: [0, 1, 4, 2, 3, 5] - Original Path is already at visual index 3 after moving Details # Move Additional Files (logical 5) to visual index 4 header.moveSection(header.visualIndex(self.preview_model.COL_ADDITIONAL_FILES), 4) # Current visual: [0, 1, 4, 2, 5, 3] - This looks correct. # Add placeholder label for the preview area (already done, just referencing) # self.preview_placeholder_label = QLabel("Please select a preset to view file predictions") # Already initialized in __init__ # self.preview_placeholder_label.setAlignment(Qt.AlignmentFlag.AlignCenter) # Already done # self.preview_placeholder_label.setStyleSheet("QLabel { font-size: 16px; color: grey; }") # Optional styling # Already done # Add both the table view and the placeholder label to the layout (already done, just referencing) # We will manage their visibility later (already done, just referencing) # main_layout.addWidget(self.preview_placeholder_label, 1) # Give it stretch factor # REMOVED - Now managed by splitter # main_layout.addWidget(self.preview_table_view, 1) # Give it stretch factor # REMOVED - Now managed by splitter # Initially hide the table view and show the placeholder (already done, just referencing) # self.preview_table_view.setVisible(False) # Already done # self.preview_placeholder_label.setVisible(True) # Already done # Apply style sheet to remove borders and rounded corners (already done, just referencing) # self.preview_table_view.setStyleSheet("QTableView { border: none; }") # Already done # --- Add Preview Table View to Splitter --- # The preview table view will now be placed below the hierarchy tree view in the splitter # It will display the files associated with the selected item in the hierarchy self.hierarchy_rule_splitter.addWidget(self.preview_table_view) # Set initial sizes for the splitter (adjusting to include the table view) self.hierarchy_rule_splitter.setSizes([200, 200, 400]) # Hierarchy, Rule Editor, File Preview # --- Progress Bar --- self.progress_bar = QProgressBar() self.progress_bar.setValue(0) self.progress_bar.setTextVisible(True) 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) self.browse_nodegroup_blend_button.clicked.connect(self._browse_for_nodegroup_blend) 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) self.browse_materials_blend_button.clicked.connect(self._browse_for_materials_blend) 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 try: default_ng_path = getattr(core_config, 'DEFAULT_NODEGROUP_BLEND_PATH', '') default_mat_path = getattr(core_config, '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 Exception as e: log.error(f"Error reading default Blender paths from config: {e}") # 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) self.blender_integration_checkbox.toggled.connect(self._toggle_blender_controls) 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.disable_preview_checkbox = QCheckBox("Disable Detailed Preview") # REMOVED - Moved to View Menu # self.disable_preview_checkbox.setToolTip("If checked, shows only the list of input assets instead of detailed file predictions.") # self.disable_preview_checkbox.setChecked(False) # Default is detailed preview enabled # self.disable_preview_checkbox.toggled.connect(self.update_preview) # Update preview when toggled # bottom_controls_layout.addWidget(self.disable_preview_checkbox) # bottom_controls_layout.addSpacing(20) # Add some space # REMOVED - No longer needed after checkbox removal 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") # Added Clear button self.start_button = QPushButton("Start Processing") self.cancel_button = QPushButton("Cancel") self.cancel_button.setEnabled(False) self.clear_queue_button.clicked.connect(self.clear_queue) # Connect signal self.start_button.clicked.connect(self.start_processing) self.cancel_button.clicked.connect(self.cancel_processing) bottom_controls_layout.addWidget(self.clear_queue_button) # Add button to layout bottom_controls_layout.addWidget(self.start_button) bottom_controls_layout.addWidget(self.cancel_button) main_layout.addLayout(bottom_controls_layout) # --- Preset Population and Handling --- def populate_presets(self): """Scans presets dir and populates BOTH the editor list and processing combo.""" log.debug("Populating preset list...") # Store current list selection current_list_item = self.editor_preset_list.currentItem() current_list_selection_text = current_list_item.text() if current_list_item else None # Clear list self.editor_preset_list.clear() log.debug("Preset list cleared.") # Add the "Select a Preset" placeholder item placeholder_item = QListWidgetItem("--- Select a Preset ---") # Make it non-selectable and non-editable placeholder_item.setFlags(placeholder_item.flags() & ~Qt.ItemFlag.ItemIsSelectable & ~Qt.ItemFlag.ItemIsEditable) # Set unique data to identify the placeholder placeholder_item.setData(Qt.ItemDataRole.UserRole, "__PLACEHOLDER__") self.editor_preset_list.addItem(placeholder_item) log.debug("Added '--- Select a Preset ---' placeholder item.") if not PRESETS_DIR.is_dir(): msg = f"Error: Presets directory not found at {PRESETS_DIR}" self.statusBar().showMessage(msg) log.error(msg) return # Exclude template file starting with _ presets = sorted([f for f in PRESETS_DIR.glob("*.json") if f.is_file() and not f.name.startswith('_')]) preset_names = [p.stem for p in presets] if not presets: msg = "Warning: No presets found in presets directory." self.statusBar().showMessage(msg) log.warning(msg) else: # Populate List Widget (for editing) for preset_path in presets: item = QListWidgetItem(preset_path.stem) item.setData(Qt.ItemDataRole.UserRole, preset_path) # Store full path self.editor_preset_list.addItem(item) self.statusBar().showMessage(f"Loaded {len(presets)} presets.") # Try to restore list selection # combo_index = self.preset_combo.findText(current_combo_selection) # REMOVED # if combo_index != -1: # REMOVED # self.preset_combo.setCurrentIndex(combo_index) # REMOVED # Do NOT attempt to restore list selection by default self.statusBar().showMessage(f"Loaded {len(presets)} presets.") # Select the "Select a Preset" item by default log.debug("Preset list populated. Selecting '--- Select a Preset ---' item.") self.editor_preset_list.setCurrentItem(placeholder_item) # Select the placeholder item # --- Drag and Drop Event Handlers (Unchanged) --- def dragEnterEvent(self, event): if event.mimeData().hasUrls(): event.acceptProposedAction() else: event.ignore() def dropEvent(self, event): if event.mimeData().hasUrls(): event.acceptProposedAction() urls = event.mimeData().urls() paths = [url.toLocalFile() for url in urls] self.add_input_paths(paths) else: event.ignore() def add_input_paths(self, paths): if not hasattr(self, 'current_asset_paths'): self.current_asset_paths = set() added_count = 0 newly_added_paths = [] for p_str in paths: p = Path(p_str) if p.exists(): supported_suffixes = ['.zip', '.rar', '.7z'] if p.is_dir() or (p.is_file() and p.suffix.lower() in supported_suffixes): if p_str not in self.current_asset_paths: self.current_asset_paths.add(p_str) newly_added_paths.append(p_str) added_count += 1 else: log.debug(f"Skipping duplicate asset path: {p_str}") # Changed print to log.debug else: self.statusBar().showMessage(f"Invalid input (not dir or supported archive): {p.name}", 5000); log.warning(f"Invalid input: {p_str}") # Changed print to log.warning and updated message else: self.statusBar().showMessage(f"Input path not found: {p.name}", 5000); print(f"Input path not found: {p_str}") if added_count > 0: log.info(f"Added {added_count} new asset paths: {newly_added_paths}") self.statusBar().showMessage(f"Added {added_count} asset(s). Updating preview...", 3000) # --- Auto-disable detailed preview if > 10 assets --- preview_toggled = False if hasattr(self, 'toggle_preview_action') and len(self.current_asset_paths) > 10: if not self.toggle_preview_action.isChecked(): # Only check it if it's not already checked log.info(f"Asset count ({len(self.current_asset_paths)}) > 10. Forcing simple preview.") self.toggle_preview_action.setChecked(True) # This will trigger update_preview via its signal preview_toggled = True # Only call update_preview directly if the toggle wasn't triggered # If in simple mode, we need to explicitly update the model with the simple list of paths if hasattr(self, 'toggle_preview_action') and self.toggle_preview_action.isChecked(): log.debug("Currently in simple preview mode. Updating model with simple paths.") self.preview_model.set_data(list(self.current_asset_paths)) # Update model with simple list self.statusBar().showMessage(f"Added {added_count} asset(s). Preview updated.", 3000) # Only call update_preview if a preset is currently selected in the editor list current_editor_item = self.editor_preset_list.currentItem() if not preview_toggled and current_editor_item: log.debug("Preset selected and not in simple mode. Triggering detailed preview update.") self.update_preview() elif not current_editor_item: log.debug("No preset selected. Not triggering detailed preview update.") self.statusBar().showMessage(f"Added {added_count} asset(s). Select a preset to update preview.", 3000) 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(): # Fallback to project root or home directory if current path is invalid current_path = str(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) log.info(f"User selected output directory: {directory}") # --- Processing Action Methods --- def start_processing(self): if self.processing_handler and self.processing_handler.is_running: log.warning("Start clicked, but processing is already running.") self.statusBar().showMessage("Processing is already in progress.", 3000) return if ProcessingHandler is None: self.statusBar().showMessage("Error: Processing components not loaded.", 5000) return if not hasattr(self, 'current_asset_paths') or not self.current_asset_paths: self.statusBar().showMessage("No assets added to process.", 3000) return input_paths = list(self.current_asset_paths) if not input_paths: self.statusBar().showMessage("No assets added to process.", 3000) return # --- Get preset from editor list --- current_editor_item = self.editor_preset_list.currentItem() # Check if the selected item is the placeholder is_placeholder = current_editor_item and current_editor_item.data(Qt.ItemDataRole.UserRole) == "__PLACEHOLDER__" if is_placeholder: self.statusBar().showMessage("Please select a valid preset from the list on the left.", 3000) log.warning("Start processing failed: Placeholder preset selected.") return # Existing logic to get selected_preset text and proceed selected_preset = current_editor_item.text() if current_editor_item else None overwrite = self.overwrite_checkbox.isChecked() num_workers = self.workers_spinbox.value() # --- Get Output Directory from UI and Validate --- output_dir_str = self.output_path_edit.text().strip() if not output_dir_str: self.statusBar().showMessage("Error: Output directory cannot be empty.", 5000) log.error("Start processing failed: Output directory field is empty.") return try: output_dir = Path(output_dir_str) # Attempt to create the directory if it doesn't exist output_dir.mkdir(parents=True, exist_ok=True) # Basic writability check (create and delete a temp file) # Note: This isn't foolproof due to potential race conditions/permissions issues # but provides a basic level of validation. temp_file = output_dir / f".writable_check_{time.time()}" temp_file.touch() temp_file.unlink() log.info(f"Using validated output directory: {output_dir_str}") except OSError as e: error_msg = f"Error creating/accessing output directory '{output_dir_str}': {e}" self.statusBar().showMessage(error_msg, 5000) log.error(error_msg) return except Exception as e: # Catch other potential Path errors or unexpected issues error_msg = f"Invalid output directory path '{output_dir_str}': {e}" self.statusBar().showMessage(error_msg, 5000) log.error(error_msg) return # --- End Output Directory Validation --- log.info(f"Preparing to start processing {len(input_paths)} items to '{output_dir_str}'.") self.set_controls_enabled(False) self.cancel_button.setEnabled(True) self.start_button.setText("Processing...") self.progress_bar.setValue(0) self.progress_bar.setFormat("%p%") self.setup_threads() if self.processing_thread and self.processing_handler: try: self.processing_thread.started.disconnect() except RuntimeError: pass # Use the current SourceRule from the hierarchy model if self._current_source_rule is None: log.error("Cannot start processing: No rule hierarchy available.") self.statusBar().showMessage("Error: No rule hierarchy available. Run preview first.", 5000) self.set_controls_enabled(True) self.cancel_button.setEnabled(False) self.start_button.setText("Start Processing") return log.debug(f"Using SourceRule '{self._current_source_rule.input_path}' for processing.") self.processing_thread.started.connect( lambda: self.processing_handler.run_processing( input_paths, selected_preset, output_dir_str, overwrite, num_workers, # Pass Blender integration settings rules=self._current_source_rule, # Pass the current SourceRule run_blender=self.blender_integration_checkbox.isChecked(), nodegroup_blend_path=self.nodegroup_blend_path_input.text(), materials_blend_path=self.materials_blend_path_input.text(), # Pass verbose setting verbose=self.toggle_verbose_action.isChecked() ) ) self.processing_thread.start() log.info("Processing thread started.") self.statusBar().showMessage(f"Processing {len(input_paths)} items...", 0) else: log.error("Failed to start processing: Thread or handler not initialized.") self.statusBar().showMessage("Error: Failed to initialize processing thread.", 5000) self.set_controls_enabled(True) self.cancel_button.setEnabled(False) self.start_button.setText("Start Processing") def cancel_processing(self): if self.processing_handler and self.processing_handler.is_running: log.info("Cancel button clicked. Requesting cancellation.") self.statusBar().showMessage("Requesting cancellation...", 3000) self.processing_handler.request_cancel() self.cancel_button.setEnabled(False) self.start_button.setText("Cancelling...") else: log.warning("Cancel clicked, but no processing is running.") self.statusBar().showMessage("Nothing to cancel.", 3000) def clear_queue(self): """Clears the current asset queue and the preview table.""" if self.processing_handler and self.processing_handler.is_running: self.statusBar().showMessage("Cannot clear queue while processing.", 3000) return if hasattr(self, 'current_asset_paths') and self.current_asset_paths: log.info(f"Clearing asset queue ({len(self.current_asset_paths)} items).") self.current_asset_paths.clear() self.preview_model.clear_data() # Clear the model data self.statusBar().showMessage("Asset queue cleared.", 3000) else: self.statusBar().showMessage("Asset queue is already empty.", 3000) # --- Preview Update Method --- def update_preview(self): log.info(f"--> Entered update_preview. View Action exists: {hasattr(self, 'toggle_preview_action')}") if hasattr(self, 'toggle_preview_action'): log.info(f" Disable Preview Action checked: {self.toggle_preview_action.isChecked()}") # --- Preview Update Method --- def update_preview(self): thread_id = QThread.currentThread() # Get current thread object log.info(f"[{time.time():.4f}][T:{thread_id}] --> Entered update_preview. View Action exists: {hasattr(self, 'toggle_preview_action')}") if hasattr(self, 'toggle_preview_action'): log.info(f"[{time.time():.4f}][T:{thread_id}] Disable Preview Action checked: {self.toggle_preview_action.isChecked()}") # Determine mode based on menu action simple_mode_enabled = hasattr(self, 'toggle_preview_action') and self.toggle_preview_action.isChecked() log.info(f"[{time.time():.4f}][T:{thread_id}] Determined simple_mode_enabled: {simple_mode_enabled}") # --- Cancel Prediction if Running --- if self.prediction_handler and self.prediction_handler.is_running: log.warning(f"[{time.time():.4f}][T:{thread_id}] Prediction is running. Attempting to call prediction_handler.request_cancel()...") try: # --- THIS METHOD DOES NOT EXIST IN PredictionHandler --- self.prediction_handler.request_cancel() log.info(f"[{time.time():.4f}][T:{thread_id}] Called prediction_handler.request_cancel() (Method might be missing!).") except AttributeError as e: log.error(f"[{time.time():.4f}][T:{thread_id}] AttributeError calling prediction_handler.request_cancel(): {e}. Prediction cannot be cancelled.") except Exception as e: log.exception(f"[{time.time():.4f}][T:{thread_id}] Unexpected error calling prediction_handler.request_cancel(): {e}") # Note: Cancellation is not immediate even if it existed. The thread would stop when it next checks the flag. # We proceed with updating the UI immediately. # Set the model's mode log.info(f"[{time.time():.4f}][T:{thread_id}] Calling preview_model.set_simple_mode({simple_mode_enabled})...") self.preview_model.set_simple_mode(simple_mode_enabled) log.info(f"[{time.time():.4f}][T:{thread_id}] Returned from preview_model.set_simple_mode({simple_mode_enabled}).") # Configure the QTableView based on the mode header = self.preview_table_view.horizontalHeader() if simple_mode_enabled: log.info(" Configuring QTableView for SIMPLE mode.") # Hide detailed columns, show simple column self.preview_table_view.setColumnHidden(self.preview_model.COL_STATUS, True) self.preview_table_view.setColumnHidden(self.preview_model.COL_PREDICTED_ASSET, True) self.preview_table_view.setColumnHidden(self.preview_model.COL_ORIGINAL_PATH, True) self.preview_table_view.setColumnHidden(self.preview_model.COL_PREDICTED_OUTPUT, True) # Already hidden, but good practice self.preview_table_view.setColumnHidden(self.preview_model.COL_DETAILS, True) # Ensure the simple path column exists and is visible if self.preview_model.columnCount() > self.preview_model.COL_SIMPLE_PATH: self.preview_table_view.setColumnHidden(self.preview_model.COL_SIMPLE_PATH, False) # Show the simple path column # Set resize mode for the single visible column header.setSectionResizeMode(self.preview_model.COL_SIMPLE_PATH, QHeaderView.ResizeMode.Stretch) else: log.error(" Simple path column index out of bounds for model.") # Disable sorting in simple mode (optional, but makes sense) self.preview_table_view.setSortingEnabled(False) # Update status bar if hasattr(self, 'current_asset_paths') and self.current_asset_paths: self.statusBar().showMessage(f"Preview disabled. Showing {len(self.current_asset_paths)} input assets.", 3000) else: self.statusBar().showMessage("Preview disabled. No assets added.", 3000) # In simple mode, the model's data is derived from current_asset_paths. # We need to ensure the model's simple data is up-to-date. # The simplest way is to re-set the data, which will re-extract simple data. # This might be slightly inefficient if only the mode changed, but safe. # A more optimized approach would be to have a separate method in the model # to just update the simple data from a list of paths. # For now, let's re-set the data. # --- REMOVED REDUNDANT set_data CALL --- # The set_simple_mode(True) call above should be sufficient as the model # already holds the simple data internally. This extra reset seems to cause instability. # log.debug(" Simple mode enabled. Re-setting model data to trigger simple data update.") # self.preview_model.set_data(list(self.current_asset_paths)) # Pass the list of paths # --- END REMOVAL --- # Stop here, do not run PredictionHandler in simple mode log.info(f"[{time.time():.4f}][T:{thread_id}] <-- Exiting update_preview (Simple Mode).") return else: # --- Proceed with Detailed Preview --- log.info(f"[{time.time():.4f}][T:{thread_id}] Configuring QTableView for DETAILED mode.") # Show detailed columns, hide simple column self.preview_table_view.setColumnHidden(self.preview_model.COL_STATUS, False) self.preview_table_view.setColumnHidden(self.preview_model.COL_PREDICTED_ASSET, False) self.preview_table_view.setColumnHidden(self.preview_model.COL_ORIGINAL_PATH, False) self.preview_table_view.setColumnHidden(self.preview_model.COL_PREDICTED_OUTPUT, True) # Keep this hidden self.preview_table_view.setColumnHidden(self.preview_model.COL_DETAILS, False) # Ensure the simple path column exists and is hidden if self.preview_model.columnCount() > self.preview_model.COL_SIMPLE_PATH: self.preview_table_view.setColumnHidden(self.preview_model.COL_SIMPLE_PATH, True) # Hide the simple path column else: log.warning(" Simple path column index out of bounds for model when hiding.") # Set resize modes for detailed columns header.setSectionResizeMode(self.preview_model.COL_STATUS, QHeaderView.ResizeMode.ResizeToContents) header.setSectionResizeMode(self.preview_model.COL_PREDICTED_ASSET, QHeaderView.ResizeMode.ResizeToContents) # Fit header.setSectionResizeMode(self.preview_model.COL_DETAILS, QHeaderView.ResizeMode.ResizeToContents) # Fit header.setSectionResizeMode(self.preview_model.COL_ORIGINAL_PATH, QHeaderView.ResizeMode.ResizeToContents) # Fixed width (using ResizeToContents as closest) header.setSectionResizeMode(self.preview_model.COL_ADDITIONAL_FILES, QHeaderView.ResizeMode.Stretch) # Stretch (Fit-If-Possible) # Move columns to the desired order: Status, Predicted Asset, Details, Original Path, Additional Files # Initial logical order: [0, 1, 2, 3(hidden), 4, 5] # Initial visual order: [0, 1, 2, 3, 4, 5] (assuming no initial moves) # Desired visual order: [0, 1, 4, 2, 5, 3(hidden)] # Move Predicted Asset (logical 1) to visual index 1 (already there) # Move Details (logical 4) to visual index 2 header.moveSection(header.visualIndex(self.preview_model.COL_DETAILS), 2) # Current visual: [0, 1, 4, 2, 3, 5] # Move Original Path (logical 2) to visual index 3 header.moveSection(header.visualIndex(self.preview_model.COL_ORIGINAL_PATH), 3) # Current visual: [0, 1, 4, 2, 3, 5] - Original Path is already at visual index 3 after moving Details # Move Additional Files (logical 5) to visual index 4 header.moveSection(header.visualIndex(self.preview_model.COL_ADDITIONAL_FILES), 4) # Current visual: [0, 1, 4, 2, 5, 3] - This looks correct. # Re-enable sorting for detailed mode self.preview_table_view.setSortingEnabled(True) # Reset sort order if needed (optional, proxy model handles default) # self.preview_table_view.sortByColumn(self.preview_model.COL_STATUS, Qt.SortOrder.AscendingOrder) # --- Trigger Prediction Handler --- if self.prediction_handler and self.prediction_handler.is_running: log.warning(f"[{time.time():.4f}] Preview update requested, but already running.") return if PredictionHandler is None: self.statusBar().showMessage("Error: Prediction components not loaded.", 5000) return # Get preset from editor list current_editor_item = self.editor_preset_list.currentItem() # Check if the selected item is the placeholder is_placeholder = current_editor_item and current_editor_item.data(Qt.ItemDataRole.UserRole) == "__PLACEHOLDER__" if is_placeholder: log.debug("Update preview called with placeholder preset selected. Clearing preview.") self.preview_model.clear_data() # Clear model if placeholder selected self.statusBar().showMessage("Select a preset from the list on the left to update preview.", 3000) # Ensure placeholder is visible and table is hidden if hasattr(self, 'preview_placeholder_label') and hasattr(self, 'preview_table_view'): self.preview_placeholder_label.setVisible(True) self.preview_table_view.setVisible(False) return # Stop prediction as no valid preset is selected # Existing logic to get selected_preset text and proceed selected_preset = current_editor_item.text() if current_editor_item else None if not selected_preset: log.debug("Update preview called with no preset selected in the editor list.") self.preview_model.clear_data() # Clear model if no preset selected self.statusBar().showMessage("Select a preset from the list on the left to update preview.", 3000) return if not hasattr(self, 'current_asset_paths') or not self.current_asset_paths: log.debug("Update preview called with no assets tracked.") self.preview_model.clear_data() # Clear model if no assets return input_paths = list(self.current_asset_paths) if not input_paths: log.debug("Update preview called but no input paths derived.") self.preview_model.clear_data() # Clear model if no paths return log.info(f"[{time.time():.4f}] Requesting background preview update for {len(input_paths)} items, Preset='{selected_preset}'") self.statusBar().showMessage(f"Updating preview for '{selected_preset}'...", 0) # Clearing is handled by model's set_data now, no need to clear table view directly if self.prediction_thread and self.prediction_handler: # Create a placeholder SourceRule instance (replace with actual rule loading later) placeholder_rules = SourceRule() # Temporary rule for passing data log.debug(f"Created placeholder SourceRule for prediction.") # Create a placeholder SourceRule instance (replace with actual rule loading later) placeholder_rules = SourceRule() # Temporary rule for passing data log.debug(f"Created placeholder SourceRule for prediction.") # Start the prediction thread log.debug(f"[{time.time():.4f}] Starting prediction thread...") self.prediction_thread.start() log.debug(f"[{time.time():.4f}] Prediction thread start requested.") # Emit the signal to trigger run_prediction in the prediction thread log.debug(f"[{time.time():.4f}] Emitting start_prediction_signal...") self.start_prediction_signal.emit(input_paths, selected_preset, placeholder_rules) log.debug(f"[{time.time():.4f}] start_prediction_signal emitted.") else: log.error(f"[{time.time():.4f}][T:{thread_id}] Failed to start prediction: Thread or handler not initialized.") self.statusBar().showMessage("Error: Failed to initialize prediction thread.", 5000) log.info(f"[{time.time():.4f}][T:{thread_id}] <-- Exiting update_preview (Detailed Mode).") # --- Threading and Processing Control --- def setup_threads(self): # Setup Processing Thread if ProcessingHandler and self.processing_thread is None: self.processing_thread = QThread(self) self.processing_handler = ProcessingHandler() self.processing_handler.moveToThread(self.processing_thread) self.processing_handler.progress_updated.connect(self.update_progress_bar) self.processing_handler.file_status_updated.connect(self.update_file_status) self.processing_handler.processing_finished.connect(self.on_processing_finished) self.processing_handler.status_message.connect(self.show_status_message) self.processing_handler.processing_finished.connect(self.processing_thread.quit) self.processing_handler.processing_finished.connect(self.processing_handler.deleteLater) self.processing_thread.finished.connect(self.processing_thread.deleteLater) self.processing_thread.finished.connect(self._reset_processing_thread_references) log.debug("Processing thread and handler set up.") elif not ProcessingHandler: log.error("ProcessingHandler not available. Cannot set up processing thread.") if hasattr(self, 'start_button'): self.start_button.setEnabled(False) self.start_button.setToolTip("Error: Backend processing components failed to load.") # Setup Prediction Thread if PredictionHandler and self.prediction_thread is None: self.prediction_thread = QThread(self) self.prediction_handler = PredictionHandler() self.prediction_handler.moveToThread(self.prediction_thread) # Connect the new signal to the handler's run_prediction slot using QueuedConnection self.start_prediction_signal.connect(self.prediction_handler.run_prediction, Qt.ConnectionType.QueuedConnection) self.prediction_handler.prediction_results_ready.connect(self.on_prediction_results_ready) # Connect the file list signal self.prediction_handler.rule_hierarchy_ready.connect(self._on_rule_hierarchy_ready) # Connect the new hierarchy signal self.prediction_handler.prediction_finished.connect(self.on_prediction_finished) self.prediction_handler.status_message.connect(self.show_status_message) self.prediction_handler.prediction_finished.connect(self.prediction_thread.quit) self.prediction_handler.prediction_finished.connect(self.prediction_handler.deleteLater) self.prediction_thread.finished.connect(self.prediction_thread.deleteLater) self.prediction_thread.finished.connect(self._reset_prediction_thread_references) log.debug("Prediction thread and handler set up.") elif not PredictionHandler: log.error("PredictionHandler not available. Cannot set up prediction thread.") @Slot() def _reset_processing_thread_references(self): log.debug("Resetting processing thread and handler references.") self.processing_thread = None self.processing_handler = None @Slot() def _reset_prediction_thread_references(self): log.debug("Resetting prediction thread and handler references.") self.prediction_thread = None self.prediction_handler = None @Slot(int, int) def update_progress_bar(self, current_count, total_count): if total_count > 0: percentage = int((current_count / total_count) * 100) self.progress_bar.setValue(percentage) self.progress_bar.setFormat(f"%p% ({current_count}/{total_count})") else: self.progress_bar.setValue(0) self.progress_bar.setFormat("0/0") # Slot for prediction results (Updated for new format and coloring) @Slot(list) def on_prediction_results_ready(self, results: list): """Populates the preview table model with detailed prediction results.""" thread_id = QThread.currentThread() # Get current thread object log.info(f"[{time.time():.4f}][T:{thread_id}] --> Entered on_prediction_results_ready. Received {len(results)} file details.") # Update the model with the new data log.info(f"[{time.time():.4f}][T:{thread_id}] Calling preview_model.set_data()...") self.preview_model.set_data(results) log.info(f"[{time.time():.4f}][T:{thread_id}] Returned from preview_model.set_data().") log.info(f"[{time.time():.4f}][T:{thread_id}] <-- Exiting on_prediction_results_ready.") @Slot() def on_prediction_finished(self): log.info(f"[{time.time():.4f}] --> Prediction finished signal received.") # Optionally update status bar or re-enable controls if needed after prediction finishes # (Controls are primarily managed by processing_finished, but prediction is a separate background task) self.statusBar().showMessage("Preview updated.", 3000) @Slot(str, str, str) def update_file_status(self, input_path_str, status, message): # TODO: Update status bar or potentially find rows in table later status_text = f"Asset '{Path(input_path_str).name}': {status.upper()}" if status == "failed" and message: status_text += f" - Error: {message}" self.statusBar().showMessage(status_text, 5000) log.debug(f"Received file status update: {input_path_str} - {status}") @Slot(int, int, int) def on_processing_finished(self, processed_count, skipped_count, failed_count): log.info(f"GUI received processing_finished signal: P={processed_count}, S={skipped_count}, F={failed_count}") self.set_controls_enabled(True) self.cancel_button.setEnabled(False) self.start_button.setText("Start Processing") @Slot(str, int) def show_status_message(self, message, timeout_ms): if timeout_ms > 0: self.statusBar().showMessage(message, timeout_ms) else: self.statusBar().showMessage(message) def set_controls_enabled(self, enabled: bool): """Enables/disables input controls during processing.""" # Main panel controls self.start_button.setEnabled(enabled) self.setAcceptDrops(enabled) self.drag_drop_area.setEnabled(enabled) # self.preview_table.setEnabled(enabled) # This was the old QTableWidget self.preview_table_view.setEnabled(enabled) # Enable/disable the QTableView instead # Editor panel controls (should generally be enabled unless processing) self.editor_panel.setEnabled(enabled) # Enable/disable the whole panel # Blender controls self.blender_integration_checkbox.setEnabled(enabled) # Only enable path inputs if checkbox is checked AND main controls are enabled 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 _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(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) log.info(f"User selected blend file: {file_path}") def _browse_for_nodegroup_blend(self): self._browse_for_blend_file(self.nodegroup_blend_path_input) def _browse_for_materials_blend(self): self._browse_for_blend_file(self.materials_blend_path_input) # --- Preset Editor Methods (Adapted from PresetEditorDialog) --- def _editor_add_list_item(self, list_widget: QListWidget): """Adds an editable item to the specified list widget in the editor.""" text, ok = QInputDialog.getText(self, f"Add Item", "Enter value:") if ok and text: item = QListWidgetItem(text) # item.setFlags(item.flags() | Qt.ItemFlag.ItemIsEditable) # Already editable by default list_widget.addItem(item) self._mark_editor_unsaved() def _editor_remove_list_item(self, list_widget: QListWidget): """Removes the selected item from the specified list widget in the editor.""" selected_items = list_widget.selectedItems() if not selected_items: return for item in selected_items: list_widget.takeItem(list_widget.row(item)) self._mark_editor_unsaved() def _editor_add_table_row(self, table_widget: QTableWidget): """Adds an empty row to the specified table widget in the editor.""" row_count = table_widget.rowCount() table_widget.insertRow(row_count) for col in range(table_widget.columnCount()): table_widget.setItem(row_count, col, QTableWidgetItem("")) self._mark_editor_unsaved() def _editor_remove_table_row(self, table_widget: QTableWidget): """Removes the selected row(s) from the specified table widget in the editor.""" selected_rows = sorted(list(set(index.row() for index in table_widget.selectedIndexes())), reverse=True) if not selected_rows: if table_widget.rowCount() > 0: selected_rows = [table_widget.rowCount() - 1] else: return for row in selected_rows: table_widget.removeRow(row) self._mark_editor_unsaved() def _mark_editor_unsaved(self): """Marks changes in the editor panel as unsaved.""" if self._is_loading_editor: return self.editor_unsaved_changes = True self.editor_save_button.setEnabled(True) preset_name = Path(self.current_editing_preset_path).name if self.current_editing_preset_path else 'New Preset' self.setWindowTitle(f"Asset Processor Tool - {preset_name}*") def _connect_editor_change_signals(self): """Connect signals from all editor widgets to mark_editor_unsaved.""" self.editor_preset_name.textChanged.connect(self._mark_editor_unsaved) self.editor_supplier_name.textChanged.connect(self._mark_editor_unsaved) self.editor_notes.textChanged.connect(self._mark_editor_unsaved) self.editor_separator.textChanged.connect(self._mark_editor_unsaved) self.editor_spin_base_name_idx.valueChanged.connect(self._mark_editor_unsaved) self.editor_spin_map_type_idx.valueChanged.connect(self._mark_editor_unsaved) # List/Table widgets are connected via helper functions def _check_editor_unsaved_changes(self) -> bool: """Checks for unsaved changes in the editor and prompts the user. Returns True if should cancel action.""" if not self.editor_unsaved_changes: return False reply = QMessageBox.question(self, "Unsaved Preset Changes", "You have unsaved changes in the preset editor. Discard them?", QMessageBox.StandardButton.Save | QMessageBox.StandardButton.Discard | QMessageBox.StandardButton.Cancel, QMessageBox.StandardButton.Cancel) if reply == QMessageBox.StandardButton.Save: return not self._save_current_preset() # Return True (cancel) if save fails elif reply == QMessageBox.StandardButton.Discard: return False # Discarded, proceed else: return True # Cancel action def _set_editor_enabled(self, enabled: bool): """Enables or disables all editor widgets.""" self.editor_tab_widget.setEnabled(enabled) # Also enable/disable save buttons based on editor state, not just processing state self.editor_save_button.setEnabled(enabled and self.editor_unsaved_changes) self.editor_save_as_button.setEnabled(enabled) # Save As is always possible if editor is enabled def _clear_editor(self): """Clears the editor fields and resets state.""" self._is_loading_editor = True self.editor_preset_name.clear() self.editor_supplier_name.clear() self.editor_notes.clear() self.editor_separator.clear() self.editor_spin_base_name_idx.setValue(0) self.editor_spin_map_type_idx.setValue(1) self.editor_list_gloss_keywords.clear() self.editor_table_bit_depth_variants.setRowCount(0) self.editor_list_extra_patterns.clear() self.editor_table_map_type_mapping.setRowCount(0) self.editor_list_model_patterns.clear() self.editor_list_decal_keywords.clear() self.editor_table_archetype_rules.setRowCount(0) self.current_editing_preset_path = None self.editor_unsaved_changes = False self.editor_save_button.setEnabled(False) self.setWindowTitle("Asset Processor Tool") # Reset window title self._set_editor_enabled(False) # Ensure placeholder is visible and table is hidden when editor is cleared if hasattr(self, 'preview_placeholder_label') and hasattr(self, 'preview_table_view'): log.debug("Clearing editor. Showing placeholder, hiding table view.") self.preview_placeholder_label.setVisible(True) self.preview_table_view.setVisible(False) self._is_loading_editor = False def _populate_editor_from_data(self, preset_data: dict): """Helper method to populate editor UI widgets from a preset data dictionary.""" self._is_loading_editor = True try: self.editor_preset_name.setText(preset_data.get("preset_name", "")) self.editor_supplier_name.setText(preset_data.get("supplier_name", "")) self.editor_notes.setText(preset_data.get("notes", "")) naming_data = preset_data.get("source_naming", {}) self.editor_separator.setText(naming_data.get("separator", "_")) indices = naming_data.get("part_indices", {}) self.editor_spin_base_name_idx.setValue(indices.get("base_name", 0)) self.editor_spin_map_type_idx.setValue(indices.get("map_type", 1)) self.editor_list_gloss_keywords.clear() self.editor_list_gloss_keywords.addItems(naming_data.get("glossiness_keywords", [])) self.editor_table_bit_depth_variants.setRowCount(0) bit_depth_vars = naming_data.get("bit_depth_variants", {}) for i, (map_type, pattern) in enumerate(bit_depth_vars.items()): self.editor_table_bit_depth_variants.insertRow(i) self.editor_table_bit_depth_variants.setItem(i, 0, QTableWidgetItem(map_type)) self.editor_table_bit_depth_variants.setItem(i, 1, QTableWidgetItem(pattern)) self.editor_list_extra_patterns.clear() self.editor_list_extra_patterns.addItems(preset_data.get("move_to_extra_patterns", [])) self.editor_table_map_type_mapping.setRowCount(0) map_mappings = preset_data.get("map_type_mapping", []) # --- UPDATED for new dictionary format --- for i, mapping_dict in enumerate(map_mappings): if isinstance(mapping_dict, dict) and "target_type" in mapping_dict and "keywords" in mapping_dict: std_type = mapping_dict["target_type"] keywords = mapping_dict["keywords"] self.editor_table_map_type_mapping.insertRow(i) self.editor_table_map_type_mapping.setItem(i, 0, QTableWidgetItem(std_type)) # Ensure keywords are strings before joining keywords_str = [str(k) for k in keywords if isinstance(k, str)] self.editor_table_map_type_mapping.setItem(i, 1, QTableWidgetItem(", ".join(keywords_str))) else: log.warning(f"Skipping invalid map_type_mapping item during editor population: {mapping_dict}") # --- END UPDATE --- category_rules = preset_data.get("asset_category_rules", {}) self.editor_list_model_patterns.clear() self.editor_list_model_patterns.addItems(category_rules.get("model_patterns", [])) self.editor_list_decal_keywords.clear() self.editor_list_decal_keywords.addItems(category_rules.get("decal_keywords", [])) self.editor_table_archetype_rules.setRowCount(0) arch_rules = preset_data.get("archetype_rules", []) for i, rule in enumerate(arch_rules): if isinstance(rule, (list, tuple)) and len(rule) == 2: arch_name, conditions = rule match_any = ", ".join(conditions.get("match_any", [])) match_all = ", ".join(conditions.get("match_all", [])) self.editor_table_archetype_rules.insertRow(i) self.editor_table_archetype_rules.setItem(i, 0, QTableWidgetItem(arch_name)) self.editor_table_archetype_rules.setItem(i, 1, QTableWidgetItem(match_any)) self.editor_table_archetype_rules.setItem(i, 2, QTableWidgetItem(match_all)) finally: self._is_loading_editor = False def _load_preset_for_editing(self, file_path: Path): """Loads the content of the selected preset file into the editor widgets.""" if not file_path or not file_path.is_file(): self._clear_editor() return log.info(f"Loading preset into editor: {file_path.name}") log.info(f"Loading preset into editor: {file_path.name}") try: with open(file_path, 'r', encoding='utf-8') as f: preset_data = json.load(f) self._populate_editor_from_data(preset_data) self._set_editor_enabled(True) self.current_editing_preset_path = file_path self.editor_unsaved_changes = False self.editor_save_button.setEnabled(False) self.setWindowTitle(f"Asset Processor Tool - {file_path.name}") log.info(f"Preset '{file_path.name}' loaded into editor.") log.debug("Preset loaded. Checking visibility states.") log.debug(f"preview_placeholder_label visible: {self.preview_placeholder_label.isVisible()}") log.debug(f"preview_table_view visible: {self.preview_table_view.isVisible()}") except json.JSONDecodeError as json_err: log.error(f"Invalid JSON in {file_path.name}: {json_err}") QMessageBox.warning(self, "Load Error", f"Failed to load preset '{file_path.name}'.\nInvalid JSON structure:\n{json_err}") self._clear_editor() except Exception as e: log.exception(f"Error loading preset file {file_path}: {e}") QMessageBox.critical(self, "Error", f"Could not load preset file:\n{file_path}\n\nError: {e}") self._clear_editor() def _load_selected_preset_for_editing(self, current_item: QListWidgetItem, previous_item: QListWidgetItem): """Loads the preset currently selected in the editor list.""" log.debug(f"currentItemChanged signal triggered. current_item: {current_item.text() if current_item else 'None'}, previous_item: {previous_item.text() if previous_item else 'None'}") # Check if the selected item is the placeholder is_placeholder = current_item and current_item.data(Qt.ItemDataRole.UserRole) == "__PLACEHOLDER__" if self._check_editor_unsaved_changes(): # If user cancels, revert selection if previous_item: log.debug("Unsaved changes check cancelled. Reverting selection.") self.editor_preset_list.blockSignals(True) self.editor_preset_list.setCurrentItem(previous_item) self.editor_preset_list.blockSignals(False) return if is_placeholder: log.debug("Placeholder item selected. Clearing editor and preview.") self._clear_editor() # This also hides the table and shows the placeholder label self.preview_model.clear_data() # Ensure the model is empty # Visibility is handled by _clear_editor, but explicitly set here for clarity self.preview_placeholder_label.setVisible(True) self.preview_table_view.setVisible(False) self.start_button.setEnabled(False) # Disable start button return # Stop processing as no real preset is selected # Existing logic for handling real preset items starts here if current_item: log.debug(f"Loading preset for editing: {current_item.text()}") preset_path = current_item.data(Qt.ItemDataRole.UserRole) self._load_preset_for_editing(preset_path) self.start_button.setEnabled(True) # Enable start button # --- Trigger preview update after loading editor --- self.update_preview() # --- End Trigger --- # Hide placeholder and show table view log.debug("Real preset selected. Hiding placeholder, showing table view.") self.preview_placeholder_label.setVisible(False) self.preview_table_view.setVisible(True) else: # This case should ideally not be reached if the placeholder is always present log.debug("No preset selected (unexpected state if placeholder is present). Clearing editor.") self._clear_editor() # Clear editor if selection is cleared # Ensure placeholder is visible if no preset is selected log.debug("No preset selected. Showing placeholder, hiding table view.") self.preview_placeholder_label.setVisible(True) self.preview_table_view.setVisible(False) def _gather_editor_data(self) -> dict: """Gathers data from all editor UI widgets and returns a dictionary.""" preset_data = {} preset_data["preset_name"] = self.editor_preset_name.text().strip() preset_data["supplier_name"] = self.editor_supplier_name.text().strip() preset_data["notes"] = self.editor_notes.toPlainText().strip() naming_data = {} naming_data["separator"] = self.editor_separator.text() naming_data["part_indices"] = { "base_name": self.editor_spin_base_name_idx.value(), "map_type": self.editor_spin_map_type_idx.value() } naming_data["glossiness_keywords"] = [self.editor_list_gloss_keywords.item(i).text() for i in range(self.editor_list_gloss_keywords.count())] naming_data["bit_depth_variants"] = {self.editor_table_bit_depth_variants.item(r, 0).text(): self.editor_table_bit_depth_variants.item(r, 1).text() for r in range(self.editor_table_bit_depth_variants.rowCount()) if self.editor_table_bit_depth_variants.item(r, 0) and self.editor_table_bit_depth_variants.item(r, 1)} preset_data["source_naming"] = naming_data preset_data["move_to_extra_patterns"] = [self.editor_list_extra_patterns.item(i).text() for i in range(self.editor_list_extra_patterns.count())] # --- UPDATED for new dictionary format --- map_mappings = [] for r in range(self.editor_table_map_type_mapping.rowCount()): type_item = self.editor_table_map_type_mapping.item(r, 0) keywords_item = self.editor_table_map_type_mapping.item(r, 1) # Ensure both items exist and have text before processing if type_item and type_item.text() and keywords_item and keywords_item.text(): target_type = type_item.text().strip() keywords = [k.strip() for k in keywords_item.text().split(',') if k.strip()] if target_type and keywords: # Only add if both parts are valid map_mappings.append({"target_type": target_type, "keywords": keywords}) else: log.warning(f"Skipping row {r} in map type mapping table due to empty target type or keywords.") else: log.warning(f"Skipping row {r} in map type mapping table due to missing items.") preset_data["map_type_mapping"] = map_mappings # --- END UPDATE --- category_rules = {} category_rules["model_patterns"] = [self.editor_list_model_patterns.item(i).text() for i in range(self.editor_list_model_patterns.count())] category_rules["decal_keywords"] = [self.editor_list_decal_keywords.item(i).text() for i in range(self.editor_list_decal_keywords.count())] preset_data["asset_category_rules"] = category_rules arch_rules = [] for r in range(self.editor_table_archetype_rules.rowCount()): name_item = self.editor_table_archetype_rules.item(r, 0) any_item = self.editor_table_archetype_rules.item(r, 1) all_item = self.editor_table_archetype_rules.item(r, 2) if name_item and any_item and all_item: match_any = [k.strip() for k in any_item.text().split(',') if k.strip()] match_all = [k.strip() for k in all_item.text().split(',') if k.strip()] arch_rules.append([name_item.text().strip(), {"match_any": match_any, "match_all": match_all}]) preset_data["archetype_rules"] = arch_rules return preset_data def _save_current_preset(self) -> bool: """Saves the current editor content to the currently loaded file path.""" if not self.current_editing_preset_path: return self._save_preset_as() log.info(f"Saving preset: {self.current_editing_preset_path.name}") try: preset_data = self._gather_editor_data() if not preset_data.get("preset_name"): QMessageBox.warning(self, "Save Error", "Preset Name cannot be empty."); return False if not preset_data.get("supplier_name"): QMessageBox.warning(self, "Save Error", "Supplier Name cannot be empty."); return False content_to_save = json.dumps(preset_data, indent=4, ensure_ascii=False) with open(self.current_editing_preset_path, 'w', encoding='utf-8') as f: f.write(content_to_save) self.editor_unsaved_changes = False self.editor_save_button.setEnabled(False) self.setWindowTitle(f"Asset Processor Tool - {self.current_editing_preset_path.name}") self.presets_changed_signal.emit() # Signal that presets changed log.info("Preset saved successfully.") # Refresh lists after save self.populate_presets() return True except Exception as e: log.exception(f"Error saving preset file {self.current_editing_preset_path}: {e}") QMessageBox.critical(self, "Save Error", f"Could not save preset file:\n{self.current_editing_preset_path}\n\nError: {e}") return False def _save_preset_as(self) -> bool: """Saves the current editor content to a new file chosen by the user.""" log.debug("Save As action triggered.") try: preset_data = self._gather_editor_data() new_preset_name = preset_data.get("preset_name") if not new_preset_name: QMessageBox.warning(self, "Save As Error", "Preset Name cannot be empty."); return False if not preset_data.get("supplier_name"): QMessageBox.warning(self, "Save As Error", "Supplier Name cannot be empty."); return False content_to_save = json.dumps(preset_data, indent=4, ensure_ascii=False) suggested_name = f"{new_preset_name}.json" default_path = PRESETS_DIR / suggested_name file_path_str, _ = QFileDialog.getSaveFileName(self, "Save Preset As", str(default_path), "JSON Files (*.json);;All Files (*)") if not file_path_str: log.debug("Save As cancelled by user."); return False save_path = Path(file_path_str) if save_path.suffix.lower() != ".json": save_path = save_path.with_suffix(".json") if save_path.exists() and save_path != self.current_editing_preset_path: reply = QMessageBox.warning(self, "Confirm Overwrite", f"Preset '{save_path.name}' already exists. Overwrite?", QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, QMessageBox.StandardButton.No) if reply == QMessageBox.StandardButton.No: log.debug("Save As overwrite cancelled."); return False log.info(f"Saving preset as: {save_path.name}") with open(save_path, 'w', encoding='utf-8') as f: f.write(content_to_save) self.current_editing_preset_path = save_path # Update current path self.editor_unsaved_changes = False self.editor_save_button.setEnabled(False) self.setWindowTitle(f"Asset Processor Tool - {save_path.name}") self.presets_changed_signal.emit() # Signal change log.info("Preset saved successfully (Save As).") # Refresh lists and select the new item self.populate_presets() return True except Exception as e: log.exception(f"Error saving preset file (Save As): {e}") QMessageBox.critical(self, "Save Error", f"Could not save preset file.\n\nError: {e}") return False def _new_preset(self): """Clears the editor and loads data from _template.json.""" log.debug("New Preset action triggered.") if self._check_editor_unsaved_changes(): return self._clear_editor() if TEMPLATE_PATH.is_file(): log.info("Loading new preset from _template.json") try: with open(TEMPLATE_PATH, 'r', encoding='utf-8') as f: template_data = json.load(f) self._populate_editor_from_data(template_data) # Override specific fields for a new preset self.editor_preset_name.setText("NewPreset") self.setWindowTitle("Asset Processor Tool - New Preset*") except Exception as e: log.exception(f"Error loading template preset file {TEMPLATE_PATH}: {e}") QMessageBox.critical(self, "Error", f"Could not load template preset file:\n{TEMPLATE_PATH}\n\nError: {e}") self._clear_editor() self.setWindowTitle("Asset Processor Tool - New Preset*") else: log.warning("Presets/_template.json not found. Creating empty preset.") self.setWindowTitle("Asset Processor Tool - New Preset*") self.editor_preset_name.setText("NewPreset") self.editor_supplier_name.setText("MySupplier") self._set_editor_enabled(True) self.editor_unsaved_changes = True self.editor_save_button.setEnabled(True) def _delete_selected_preset(self): """Deletes the currently selected preset file from the editor list after confirmation.""" current_item = self.editor_preset_list.currentItem() if not current_item: QMessageBox.information(self, "Delete Preset", "Please select a preset from the list to delete."); return preset_path = current_item.data(Qt.ItemDataRole.UserRole) preset_name = preset_path.stem reply = QMessageBox.warning(self, "Confirm Delete", f"Are you sure you want to permanently delete the preset '{preset_name}'?", QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, QMessageBox.StandardButton.No) if reply == QMessageBox.StandardButton.Yes: log.info(f"Deleting preset: {preset_path.name}") try: preset_path.unlink() log.info("Preset deleted successfully.") if self.current_editing_preset_path == preset_path: self._clear_editor() self.presets_changed_signal.emit() # Signal change # Refresh lists self.populate_presets() except Exception as e: log.exception(f"Error deleting preset file {preset_path}: {e}") QMessageBox.critical(self, "Delete Error", f"Could not delete preset file:\n{preset_path}\n\nError: {e}") # --- Menu Bar Setup --- def setup_menu_bar(self): """Creates the main menu bar and View menu.""" self.menu_bar = self.menuBar() view_menu = self.menu_bar.addMenu("&View") # Log Console Action self.toggle_log_action = QAction("Show Log Console", self, checkable=True) self.toggle_log_action.setChecked(False) # Start hidden self.toggle_log_action.toggled.connect(self._toggle_log_console_visibility) view_menu.addAction(self.toggle_log_action) # Detailed Preview Action self.toggle_preview_action = QAction("Disable Detailed Preview", self, checkable=True) self.toggle_preview_action.setChecked(False) # Start enabled (detailed view) # Connect to update_preview, which now checks this action's state self.toggle_preview_action.toggled.connect(self.update_preview) view_menu.addAction(self.toggle_preview_action) # Verbose Logging Action self.toggle_verbose_action = QAction("Verbose Logging (DEBUG)", self, checkable=True) self.toggle_verbose_action.setChecked(False) # Start disabled (INFO level) self.toggle_verbose_action.toggled.connect(self._toggle_verbose_logging) view_menu.addAction(self.toggle_verbose_action) # --- Logging Handler Setup --- def setup_logging_handler(self): """Creates and configures the custom QtLogHandler.""" self.log_handler = QtLogHandler(self) # Set the formatter to match the basicConfig format log_format = '%(levelname)s: %(message)s' # Simpler format for UI console formatter = logging.Formatter(log_format) self.log_handler.setFormatter(formatter) # Set level (e.g., INFO to capture standard messages) self.log_handler.setLevel(logging.INFO) # Add handler to the root logger to capture logs from all modules logging.getLogger().addHandler(self.log_handler) # Connect the signal to the slot self.log_handler.log_record_received.connect(self._append_log_message) log.info("UI Log Handler Initialized.") # Log that the handler is ready # --- Slots for Menu Actions and Logging --- @Slot(bool) def _toggle_log_console_visibility(self, checked): """Shows or hides the log console widget based on menu action.""" if hasattr(self, 'log_console_widget'): self.log_console_widget.setVisible(checked) log.debug(f"Log console visibility set to: {checked}") @Slot(bool) def _toggle_verbose_logging(self, checked): """Sets the logging level for the root logger and the GUI handler.""" if not hasattr(self, 'log_handler'): log.error("Log handler not initialized, cannot change level.") return new_level = logging.DEBUG if checked else logging.INFO root_logger = logging.getLogger() # Get the root logger root_logger.setLevel(new_level) self.log_handler.setLevel(new_level) log.info(f"Root and GUI logging level set to: {logging.getLevelName(new_level)}") # Update status bar or log console to indicate change self.statusBar().showMessage(f"Logging level set to {logging.getLevelName(new_level)}", 3000) @Slot(str) def _append_log_message(self, message): """Appends a log message to the QTextEdit console.""" if hasattr(self, 'log_console_output'): # Optional: Add basic coloring (can be expanded) # if message.startswith("ERROR"): # message = f"{message}" # elif message.startswith("WARNING"): # message = f"{message}" self.log_console_output.append(message) # Use append for plain text # Optional: Limit history size # MAX_LINES = 500 # if self.log_console_output.document().blockCount() > MAX_LINES: # cursor = self.log_console_output.textCursor() # cursor.movePosition(QTextCursor.MoveOperation.Start) # cursor.select(QTextCursor.SelectionType.BlockUnderCursor) # cursor.removeSelectedText() # cursor.deletePreviousChar() # Remove the newline potentially left behind # Ensure the view scrolls to the bottom self.log_console_output.verticalScrollBar().setValue(self.log_console_output.verticalScrollBar().maximum()) # --- Overridden Close Event --- def closeEvent(self, event): """Overrides close event to check for unsaved changes in the editor.""" if self._check_editor_unsaved_changes(): event.ignore() # Ignore close event if user cancels else: event.accept() # Accept close event # --- Slots for Hierarchy and Rule Editor --- @Slot(QModelIndex) def _on_hierarchy_item_clicked(self, index: QModelIndex): """Loads the selected rule item into the rule editor and filters the preview table.""" if index.isValid(): rule_item = self.rule_hierarchy_model.get_item_from_index(index) if rule_item: rule_type_name = type(rule_item).__name__ log.debug(f"Hierarchy item clicked: {rule_type_name} - {getattr(rule_item, 'name', 'N/A')}") self.rule_editor_widget.load_rule(rule_item, rule_type_name) # Filter the preview table based on the selected item if isinstance(rule_item, SourceRule): # Show all files for the source self.preview_proxy_model.setFilterRegularExpression("") # Clear filter self.preview_proxy_model.setFilterKeyColumn(-1) # Apply to all columns (effectively no column filter) log.debug("Filtering preview table: Showing all files for Source.") elif isinstance(rule_item, AssetRule): # Show files belonging to this asset # Filter by the 'source_asset' column (which stores the asset name/path) # Need to escape potential regex special characters in the asset name/path filter_string = "^" + rule_item.asset_name.replace('\\', '\\\\').replace('.', '\\.') + "$" self.preview_proxy_model.setFilterRegularExpression(filter_string) self.preview_proxy_model.setFilterKeyColumn(self.preview_model.ROLE_SOURCE_ASSET) # Filter by source_asset column log.debug(f"Filtering preview table: Showing files for Asset '{rule_item.asset_name}'. Filter: '{filter_string}' on column {self.preview_model.ROLE_SOURCE_ASSET}") elif isinstance(rule_item, FileRule): # Show only this specific file # Filter by the 'original_path' column filter_string = "^" + rule_item.file_path.replace('\\', '\\\\').replace('.', '\\.') + "$" self.preview_proxy_model.setFilterRegularExpression(filter_string) self.preview_proxy_model.setFilterKeyColumn(self.preview_model.COL_ORIGINAL_PATH) # Filter by original_path column log.debug(f"Filtering preview table: Showing file '{rule_item.file_path}'. Filter: '{filter_string}' on column {self.preview_model.COL_ORIGINAL_PATH}") else: # Clear filter for unknown types self.preview_proxy_model.setFilterRegularExpression("") self.preview_proxy_model.setFilterKeyColumn(-1) log.warning(f"Clicked item has unknown type {type(rule_item)}. Clearing preview filter.") else: log.warning("Clicked item has no associated rule object. Clearing editor and preview filter.") self.rule_editor_widget.clear_editor() self.preview_proxy_model.setFilterRegularExpression("") # Clear filter self.preview_proxy_model.setFilterKeyColumn(-1) else: log.debug("Clicked item index is invalid. Clearing editor and preview filter.") self.rule_editor_widget.clear_editor() self.preview_proxy_model.setFilterRegularExpression("") # Clear filter self.preview_proxy_model.setFilterKeyColumn(-1) @Slot(object) def _on_rule_updated(self, rule_object): """Handles the signal when a rule is updated in the editor.""" # This slot is called when an attribute is changed in the RuleEditorWidget. # The rule_object passed is the actual object instance from the hierarchy model. # Since the RuleEditorWidget modifies the object in place, we don't need to # explicitly update the model's data structure here. # However, if the change affects the display in the hierarchy tree or preview table, # we might need to emit dataChanged signals or trigger updates. # For now, just log the update. log.debug(f"Rule object updated in editor: {type(rule_object).__name__} - {getattr(rule_object, 'name', 'N/A')}") # TODO: Consider if any UI updates are needed based on the rule change. # E.g., if a rule name changes, the hierarchy tree might need a dataChanged signal. # If a rule affects file output names, the preview table might need updating. # This is complex and depends on which rule attributes are editable and their impact. @Slot(object) def _on_rule_hierarchy_ready(self, source_rule: SourceRule): """Receives the generated SourceRule hierarchy and updates the tree view model.""" log.info(f"Received rule hierarchy ready signal for input: {source_rule.input_path}") self._current_source_rule = source_rule # Store the generated rule hierarchy self.rule_hierarchy_model.set_root_rule(source_rule) # Update the tree view model self.hierarchy_tree_view.expandToDepth(0) # Expand the first level (Source and Assets) log.debug("Rule hierarchy model updated and tree view expanded.") # --- Main Execution --- def run_gui(): """Initializes and runs the Qt application.""" app = QApplication(sys.argv) app.setStyle('Fusion') # Set a custom palette to override default Fusion colors palette = app.palette() grey_color = QColor("#3a3a3a") palette.setColor(QPalette.ColorRole.Base, grey_color) palette.setColor(QPalette.ColorRole.AlternateBase, grey_color.lighter(110)) # Use a slightly lighter shade for alternate rows if needed # You might need to experiment with other roles depending on which widgets are affected # palette.setColor(QPalette.ColorRole.Window, grey_color) # palette.setColor(QPalette.ColorRole.WindowText, Qt.GlobalColor.white) # Example: set text color to white app.setPalette(palette) window = MainWindow() window.show() sys.exit(app.exec()) if __name__ == "__main__": run_gui()