import sys
import os
import json
import logging
import time
import zipfile # Added for archive extraction
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
# Removed incorrect import of AssetType, ItemType from config
# Removed: from gui.rule_editor_widget import RuleEditorWidget
# --- GUI Model Imports ---
# Removed: from gui.preview_table_model import PreviewTableModel, PreviewSortFilterProxyModel
# Removed: from gui.rule_hierarchy_model import RuleHierarchyModel
from gui.unified_view_model import UnifiedViewModel # Import the new unified model
from gui.delegates import LineEditDelegate, ComboBoxDelegate, SupplierSearchDelegate # Import delegates
from gui.delegates import LineEditDelegate, ComboBoxDelegate # Import delegates
# --- 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 # REMOVED Obsolete Handler
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 # REMOVED Obsolete Handler
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(str, list, str) # input_source_identifier, file_list, preset_name
# Signal to request processing with the final list of rules
processing_requested = Signal(list) # Emits List[SourceRule]
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._pending_predictions = set() # Track input paths awaiting prediction results
self._accumulated_rules = {} # Store {input_path: SourceRule} as results arrive
self._source_file_lists = {} # Store {input_path: [file_list]} for context
# Removed: self.rule_hierarchy_model = RuleHierarchyModel()
# Removed: self._current_source_rule = None # The new model will hold the data
# --- 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 # REMOVED Obsolete Handler Thread
# self.processing_handler = None # REMOVED Obsolete Handler
self.prediction_thread = None
self.prediction_handler = None
self.setup_threads()
# --- Preview Area (Table) Setup --- REMOVED ---
# Models, TableView, and Placeholder are no longer needed here.
# They are replaced by the Unified View.
# --- 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
# --- Unified View Setup ---
from PySide6.QtWidgets import QTreeView # Import QTreeView here if not already imported globally
self.unified_view = QTreeView()
self.unified_model = UnifiedViewModel() # Instantiate the new model
self.unified_view.setModel(self.unified_model) # Set the model
# Instantiate Delegates
lineEditDelegate = LineEditDelegate(self.unified_view)
comboBoxDelegate = ComboBoxDelegate(self.unified_view)
supplierSearchDelegate = SupplierSearchDelegate(self.unified_view) # Instantiate the new delegate
# Set Delegates for Columns (adjust column indices as per UnifiedViewModel)
# Assuming columns are: Name (0), Supplier (1), AssetType (2), TargetAsset (3), ItemType (4)
self.unified_view.setItemDelegateForColumn(UnifiedViewModel.COL_SUPPLIER, supplierSearchDelegate) # Use the new delegate for Supplier
self.unified_view.setItemDelegateForColumn(UnifiedViewModel.COL_ASSET_TYPE, comboBoxDelegate)
self.unified_view.setItemDelegateForColumn(UnifiedViewModel.COL_TARGET_ASSET, lineEditDelegate)
self.unified_view.setItemDelegateForColumn(UnifiedViewModel.COL_ITEM_TYPE, comboBoxDelegate)
# Configure View Appearance (optional, customize as needed)
self.unified_view.setAlternatingRowColors(True)
self.unified_view.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
self.unified_view.setEditTriggers(QAbstractItemView.EditTrigger.DoubleClicked | QAbstractItemView.EditTrigger.SelectedClicked | QAbstractItemView.EditTrigger.EditKeyPressed)
self.unified_view.header().setStretchLastSection(False) # Adjust as needed
# self.unified_view.header().setSectionResizeMode(0, QHeaderView.ResizeMode.Stretch) # Example: Stretch first column
# self.unified_view.header().setSectionResizeMode(1, QHeaderView.ResizeMode.ResizeToContents) # Example: Resize others to contents
# Add the Unified View to the main layout
main_layout.addWidget(self.unified_view, 1) # Give it stretch factor 1
# --- REMOVED Old Hierarchy/Rule/Preview Splitter and Contents ---
# --- 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 _extract_file_list(self, input_path_str: str) -> list | None:
"""Extracts a list of relative file paths from a directory or zip archive."""
input_path = Path(input_path_str)
file_list = []
try:
if input_path.is_dir():
log.debug(f"Extracting files from directory: {input_path_str}")
for root, _, files in os.walk(input_path):
for file in files:
full_path = Path(root) / file
relative_path = full_path.relative_to(input_path).as_posix() # Use POSIX paths for consistency
file_list.append(relative_path)
log.debug(f"Found {len(file_list)} files in directory.")
elif input_path.is_file() and input_path.suffix.lower() == '.zip':
log.debug(f"Extracting files from zip archive: {input_path_str}")
if not zipfile.is_zipfile(input_path):
log.warning(f"File is not a valid zip archive: {input_path_str}")
return None
with zipfile.ZipFile(input_path, 'r') as zip_ref:
# Filter out directory entries if any exist in the zip explicitly
file_list = [name for name in zip_ref.namelist() if not name.endswith('/')]
log.debug(f"Found {len(file_list)} files in zip archive.")
else:
log.warning(f"Input path is neither a directory nor a supported .zip file: {input_path_str}")
return None # Not a directory or supported archive
return file_list
except FileNotFoundError:
log.error(f"File or directory not found during extraction: {input_path_str}")
self.statusBar().showMessage(f"Error: Input not found: {input_path.name}", 5000)
return None
except zipfile.BadZipFile:
log.error(f"Bad zip file encountered: {input_path_str}")
self.statusBar().showMessage(f"Error: Invalid zip file: {input_path.name}", 5000)
return None
except PermissionError:
log.error(f"Permission denied accessing: {input_path_str}")
self.statusBar().showMessage(f"Error: Permission denied for: {input_path.name}", 5000)
return None
except Exception as e:
log.exception(f"Unexpected error extracting files from {input_path_str}: {e}")
self.statusBar().showMessage(f"Error extracting files from: {input_path.name}", 5000)
return None
def add_input_paths(self, paths):
log.debug(f"--> Entered add_input_paths with paths: {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():
# Only support directories and .zip files for now
if p.is_dir() or (p.is_file() and p.suffix.lower() == '.zip'):
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 .zip): {p.name}", 5000); log.warning(f"Invalid input (not dir or .zip): {p_str}")
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)
# --- Trigger prediction for newly added paths ---
current_editor_item = self.editor_preset_list.currentItem()
is_placeholder = current_editor_item and current_editor_item.data(Qt.ItemDataRole.UserRole) == "__PLACEHOLDER__"
selected_preset = current_editor_item.text() if current_editor_item and not is_placeholder else None
if selected_preset:
log.info(f"Preset '{selected_preset}' selected. Triggering prediction for {len(newly_added_paths)} new paths.")
# Ensure the prediction thread is running before emitting signals
if self.prediction_thread and not self.prediction_thread.isRunning():
log.debug("Starting prediction thread from add_input_paths.")
self.prediction_thread.start()
for input_path_str in newly_added_paths:
file_list = self._extract_file_list(input_path_str)
if file_list is not None: # Check if extraction was successful (not None)
log.debug(f"Extracted {len(file_list)} files for {input_path_str}. Emitting signal.")
log.info(f"VERIFY: Extracted file list for '{input_path_str}'. Count: {len(file_list)}. Emitting prediction signal.") # DEBUG Verify
# Store file list and mark as pending before emitting
self._source_file_lists[input_path_str] = file_list
self._pending_predictions.add(input_path_str)
log.debug(f"Added '{input_path_str}' to pending predictions. Current pending: {self._pending_predictions}")
self.start_prediction_signal.emit(input_path_str, file_list, selected_preset)
else:
log.warning(f"Skipping prediction for {input_path_str} due to extraction error.")
else:
log.warning(f"Added {added_count} asset(s), but no valid preset selected. Prediction not triggered.")
self.statusBar().showMessage(f"Added {added_count} asset(s). Select a preset to generate preview.", 3000)
# --- REMOVED call to self.update_preview() ---
# The preview update is now triggered per-item via the signal emission above,
# and also when the preset selection changes (handled in update_preview).
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):
# Commented-out code moved to Deprecated/Old-Code/gui_main_window_py_old_processing_state_checks_line_710.py
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}'.")
# --- Get the final list of SourceRule objects from the model ---
# Assuming UnifiedViewModel has a method like get_all_source_rules()
try:
final_source_rules = self.unified_model.get_all_source_rules()
if not final_source_rules:
log.warning("No source rules found in the model. Nothing to process.")
self.statusBar().showMessage("No rules generated or assets added. Nothing to process.", 3000)
return
except AttributeError:
log.error("UnifiedViewModel does not have 'get_all_source_rules()' method.")
self.statusBar().showMessage("Error: Cannot retrieve rules from model.", 5000)
return
except Exception as e:
log.exception(f"Error getting rules from model: {e}")
self.statusBar().showMessage(f"Error retrieving rules: {e}", 5000)
return
log.info(f"Retrieved {len(final_source_rules)} SourceRule objects from the model.")
# --- Emit signal with the list of rules ---
log.info(f"DEBUG: Emitting processing_requested with rules: {final_source_rules}") # DEBUG LOG
# DEBUG Verify: Log the rules being emitted
rule_paths = [rule.input_path for rule in final_source_rules]
log.info(f"VERIFY: Emitting processing_requested with {len(final_source_rules)} SourceRule(s). Input paths: {rule_paths}")
# test_message = f"Processing requested for {len(final_source_rules)} rules." # Reverted
self.processing_requested.emit(final_source_rules) # Emit original list
log.info("Emitted processing_requested signal with the list of SourceRules.") # Reverted log
# --- Update UI (Progress bar, status, buttons) ---
# Note: The actual processing start/progress/finish will now be handled
# by the main application logic connected to the processing_requested signal.
# We might want to show an intermediate status here.
self.progress_bar.setValue(0)
self.progress_bar.setFormat("Waiting for processing start...")
self.statusBar().showMessage(f"Requested processing for {len(final_source_rules)} rule sets...", 0)
# Disable start button, enable cancel (assuming main will handle re-enabling)
self.set_controls_enabled(False) # Disable most controls
self.start_button.setEnabled(False) # Keep start disabled
self.start_button.setText("Processing...")
self.cancel_button.setEnabled(True) # Enable cancel
# Commented-out code moved to Deprecated/Old-Code/gui_main_window_py_old_direct_processing_call_line_814.py
def cancel_processing(self):
# TODO: Implement cancellation by signaling the App/main thread to stop the QThreadPool tasks
log.warning("Cancel button clicked, but cancellation logic needs reimplementation.")
self.statusBar().showMessage("Cancellation not yet implemented.", 3000)
# Commented-out code moved to Deprecated/Old-Code/gui_main_window_py_old_cancel_processing_logic_line_834.py
def clear_queue(self):
"""Clears the current asset queue and the preview table."""
# TODO: Check processing state via App/main thread if needed before clearing
# if self.processing_handler and self.processing_handler.is_running: # OLD HANDLER CHECK
# 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() # Old model removed
self.unified_model.clear_data() # Clear the new model data
# Clear accumulation state
self._pending_predictions.clear()
self._accumulated_rules.clear()
self._source_file_lists.clear()
log.info("Cleared accumulation state (_pending_predictions, _accumulated_rules, _source_file_lists).")
self.statusBar().showMessage("Asset queue and prediction state 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')}")
# The duplicate update_preview definition below this was removed.
# This is now the primary update_preview method.
log.debug(f"[{time.time():.4f}] ### LOG: Entering update_preview")
log.debug("--> Entered update_preview")
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 (REMOVED - No longer relevant)
# --- Cancel Prediction if Running (Existing logic, might need refinement) ---
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.
# Commented-out code moved to Deprecated/Old-Code/gui_main_window_py_old_preview_model_mode_setting_line_864.py
log.debug(f"[{time.time():.4f}] ### LOG: Checking if prediction handler is running")
# --- 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.")
log.debug(f"[{time.time():.4f}] ### LOG: Inside 'is_running' check")
# Removed the 'return' statement here to allow the signal to be emitted
# return
# The rest of the logic should execute regardless of is_running state,
# though the handler itself should handle being called multiple times.
# A better fix might involve properly resetting is_running in the handler.
if PredictionHandler is None:
log.error("PredictionHandler not loaded. Cannot update preview.")
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 unified view.")
self.unified_model.clear_data() # Clear the new model if placeholder selected
self.statusBar().showMessage("Select a preset from the list on the left to update preview.", 3000)
# No placeholder label to manage for unified view
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.unified_model.clear_data() # Clear the new 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.unified_model.clear_data() # Clear the new model if no assets
return
input_paths = list(self.current_asset_paths)
log.info(f"[{time.time():.4f}] Requesting background preview update for {len(input_paths)} items using Preset='{selected_preset}'")
self.statusBar().showMessage(f"Updating preview for '{selected_preset}'...", 0)
# --- Reset Accumulation State for this batch ---
log.debug("Clearing accumulated rules for new preview batch.")
self._accumulated_rules.clear()
# Reset pending predictions to only include paths in this update request
self._pending_predictions = set(input_paths)
log.debug(f"Reset pending predictions for batch: {self._pending_predictions}")
# Keep _source_file_lists, it might contain lists for paths already processed
# Clearing is handled by model's set_data now, no need to clear table view directly
if self.prediction_thread and self.prediction_handler:
# Commented-out code moved to Deprecated/Old-Code/gui_main_window_py_old_placeholder_sourcerule_creation_line_922.py
# Start the prediction thread
# The thread should already be running or started once. Don't restart it here.
# log.debug(f"[{time.time():.4f}] Starting prediction thread...")
self.prediction_thread.start() # Ensure thread is running
# log.debug(f"[{time.time():.4f}] Prediction thread start requested.")
# Iterate through all current paths, extract files, and emit signal for each
log.debug(f"[{time.time():.4f}] Iterating through {len(input_paths)} paths to extract files and emit signals.")
for input_path_str in input_paths:
file_list = self._extract_file_list(input_path_str)
if file_list is not None: # Check if extraction was successful
log.debug(f"[{time.time():.4f}] Emitting start_prediction_signal for: {input_path_str} with {len(file_list)} files.")
self.start_prediction_signal.emit(input_path_str, file_list, selected_preset)
else:
log.warning(f"[{time.time():.4f}] Skipping prediction signal for {input_path_str} due to extraction error during preview update.")
else:
log.error(f"[{time.time():.4f}][T:{thread_id}] Failed to trigger 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.")
# --- Threading and Processing Control ---
def setup_threads(self):
# Commented-out code moved to Deprecated/Old-Code/gui_main_window_py_old_processing_thread_setup_line_978.py
# Setup Prediction Thread (Keep this)
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)
# Removed: self.prediction_handler.prediction_results_ready.connect(self.on_prediction_results_ready) # Old signal
# Assume PredictionHandler.rule_hierarchy_ready signal is changed to Signal(str, list) -> input_path, rules_list
self.prediction_handler.rule_hierarchy_ready.connect(self._on_rule_hierarchy_ready) # Connect the LIST signal (now with input_path)
# Assume PredictionHandler.prediction_finished signal is changed to Signal(str) -> input_path
self.prediction_handler.prediction_finished.connect(self.on_prediction_finished) # Connect finish signal (now with input_path)
self.prediction_handler.status_message.connect(self.show_status_message)
# Commented-out code moved to Deprecated/Old-Code/gui_main_window_py_old_prediction_thread_cleanup_connections_line_1012.py
log.debug("Prediction thread and handler set up to be persistent.")
elif not PredictionHandler:
log.error("PredictionHandler not available. Cannot set up prediction thread.")
# Commented-out code moved to Deprecated/Old-Code/gui_main_window_py_old_reset_processing_thread_references_slot_line_1022.py
@Slot()
def _reset_prediction_thread_references(self):
# This slot is no longer connected, but keep it for now in case needed later
log.debug("Resetting prediction thread and handler references (Slot disconnected).")
# self.prediction_thread = None # Keep references alive
# self.prediction_handler = None # Keep references alive
@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")
# Commented-out code moved to Deprecated/Old-Code/gui_main_window_py_old_on_prediction_results_ready_slot_line_987.py
# Slot signature assumes prediction_finished signal is updated to emit input_path: Signal(str)
# Slot signature assumes prediction_finished signal is updated to emit input_path: Signal(str)
@Slot(str)
def on_prediction_finished(self, input_path: str):
"""Handles the completion (potentially failure) of a single prediction task."""
log.info(f"[{time.time():.4f}] --> Prediction finished signal received for: {input_path}")
# Ensure path is removed from pending even if rule_hierarchy_ready wasn't emitted (e.g., critical error)
if input_path in self._pending_predictions:
log.warning(f"Prediction finished for '{input_path}', but it was still marked as pending. Removing.")
self._pending_predictions.discard(input_path)
# Check if this was the last pending item after an error
if not self._pending_predictions:
log.info("Prediction finished, and no more predictions are pending (potentially due to error). Finalizing model update.")
self._finalize_model_update()
else:
# Update status about remaining items
remaining_count = len(self._pending_predictions)
self.statusBar().showMessage(f"Prediction failed/finished for {Path(input_path).name}. Waiting for {remaining_count} more...", 5000)
else:
log.debug(f"Prediction finished for '{input_path}', which was already processed.")
# Original status message might be misleading now, handled by accumulation logic.
# self.statusBar().showMessage("Preview updated.", 3000) # Removed
@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}")
# TODO: This slot needs to be connected to a signal from the App/main thread
# indicating that all tasks in the QThreadPool are complete.
@Slot(int, int, int)
def on_processing_finished(self, processed_count, skipped_count, failed_count):
# This log message might be inaccurate until signal source is updated
log.info(f"GUI received processing_finished signal (Source TBC): 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)
# Removed: self.preview_table_view.setEnabled(enabled)
self.unified_view.setEnabled(enabled) # Enable/disable the new Unified View
# 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 unified view is cleared (handled by model clear)
# No placeholder label to manage for unified view
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.")
# No placeholder/table view visibility to manage here
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 unified view.")
self._clear_editor()
self.unified_model.clear_data() # Ensure the new model is empty
# No placeholder/table view visibility to manage
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 ---
# No placeholder/table view visibility to manage
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
# No placeholder/table view visibility to manage
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
# --- REMOVED Slots for Old Hierarchy and Rule Editor ---
# Commented-out code moved to Deprecated/Old-Code/gui_main_window_py_old_hierarchy_and_rule_editor_slots_line_1553.py
# Slot signature assumes rule_hierarchy_ready signal is updated to emit input_path: Signal(str, list)
# Slot signature matches rule_hierarchy_ready = Signal(list)
@Slot(list)
def _on_rule_hierarchy_ready(self, source_rules_list: list):
"""Receives prediction results (a list containing one SourceRule) for a single input path,
accumulates them, and updates the model when all are ready."""
# --- Extract input_path from the received rule ---
input_path = None
source_rule = None
if source_rules_list and isinstance(source_rules_list[0], SourceRule):
source_rule = source_rules_list[0]
input_path = source_rule.input_path
log.debug(f"--> Entered _on_rule_hierarchy_ready for '{input_path}' with {len(source_rules_list)} SourceRule(s)")
elif source_rules_list:
log.error(f"Received non-SourceRule object in list: {type(source_rules_list[0])}. Cannot process.")
# Attempt to find which pending prediction this might correspond to? Difficult.
# For now, we can't reliably remove from pending without the path.
return
else:
# This case might happen if prediction failed critically before creating a rule.
# The prediction_finished signal (which now includes input_path) should handle removing from pending.
log.warning("Received empty source_rules_list in _on_rule_hierarchy_ready. Prediction likely failed.")
return # Nothing to accumulate
if input_path is None:
log.error("Could not determine input_path from received source_rules_list. Aborting accumulation.")
return
if input_path not in self._pending_predictions:
log.warning(f"Received rule hierarchy for '{input_path}', but it was not in the pending set. Ignoring stale result? Pending: {self._pending_predictions}")
return # Ignore if not expected
# --- Accumulate Result ---
if source_rule: # Check if we successfully got the rule object
self._accumulated_rules[input_path] = source_rule
log.debug(f"Accumulated rule for '{input_path}'. Total accumulated: {len(self._accumulated_rules)}")
else:
# This path is already handled by the initial checks, but log just in case.
log.warning(f"No valid SourceRule found for '{input_path}' to accumulate.")
# --- Mark as Completed ---
self._pending_predictions.discard(input_path)
log.debug(f"Removed '{input_path}' from pending predictions. Remaining: {self._pending_predictions}")
# --- Check for Completion ---
if not self._pending_predictions:
log.info("All pending predictions received. Finalizing model update.")
self._finalize_model_update()
else:
# Update status bar with progress
completed_count = len(self._accumulated_rules)
pending_count = len(self._pending_predictions)
total_count = completed_count + pending_count # This might be slightly off if some failed without rules
status_msg = f"Preview updated for {Path(input_path).name}. Waiting for {pending_count} more ({completed_count}/{total_count} requested)..."
self.statusBar().showMessage(status_msg, 5000)
log.debug(status_msg)
def _finalize_model_update(self):
"""Combines accumulated rules and updates the UI model and view."""
log.debug("Entering _finalize_model_update")
final_rules = list(self._accumulated_rules.values())
log.info(f"Finalizing model with {len(final_rules)} accumulated SourceRule(s).")
# Load the FINAL LIST of data into the UnifiedViewModel
self.unified_model.load_data(final_rules)
log.debug("Unified view model updated with final list of SourceRules.")
# Resize columns to fit content after loading data
for col in range(self.unified_model.columnCount()):
self.unified_view.resizeColumnToContents(col)
log.debug("Unified view columns resized to contents.")
self.unified_view.expandToDepth(1) # Expand Source -> Asset level
self.statusBar().showMessage(f"Preview complete for {len(final_rules)} asset(s).", 5000)
# --- Main Execution ---
def run_gui():
"""Initializes and runs the Qt application."""
print("--- Reached run_gui() ---")
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()