Asset-Frameworker/gui/main_window.py
2025-04-30 17:30:51 +02:00

1777 lines
98 KiB
Python

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"<font color='red'>{message}</font>"
# elif message.startswith("WARNING"):
# message = f"<font color='orange'>{message}</font>"
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()