GUI improvements

#23
#22
#19
This commit is contained in:
Rusfort 2025-05-03 18:09:00 +02:00
parent ce26d54a5d
commit bf79c05bdd
8 changed files with 822 additions and 107 deletions

View File

@ -18,13 +18,18 @@ python -m gui.main_window
* **Preset List:** Create, delete, load, edit, and save presets. On startup, the "-- Select a Preset --" item is explicitly selected. You must select a specific preset from this list to load it into the editor below, enable the detailed file preview, and enable the "Start Processing" button. * **Preset List:** Create, delete, load, edit, and save presets. On startup, the "-- Select a Preset --" item is explicitly selected. You must select a specific preset from this list to load it into the editor below, enable the detailed file preview, and enable the "Start Processing" button.
* **Preset Editor Tabs:** Edit the details of the selected preset. * **Preset Editor Tabs:** Edit the details of the selected preset.
* **Processing Panel (Right):** * **Processing Panel (Right):**
* **Preset Selector:** Choose the preset to use for *processing* the current queue. This dropdown now includes a new option: "- LLM Interpretation -". Selecting this option will use the experimental LLM Predictor instead of the traditional rule-based prediction system defined in presets. * **Preset Selector:** Choose the preset to use for *processing* the current queue. (Note: LLM interpretation is now initiated via the right-click context menu in the Preview Table).
* **Output Directory:** Set the output path (defaults to `config/app_settings.json`, use "Browse...") * **Output Directory:** Set the output path (defaults to `config/app_settings.json`, use "Browse...")
* **Drag and Drop Area:** Add asset `.zip`, `.rar`, `.7z` files, or folders by dragging and dropping them here. * **Drag and Drop Area:** Add asset `.zip`, `.rar`, `.7z` files, or folders by dragging and dropping them here.
* **Preview Table:** Shows queued assets in a hierarchical view (Source -> Asset -> File). Assets (files, directories, archives) added via drag-and-drop appear immediately in the table. * **Preview Table:** Shows queued assets in a hierarchical view (Source -> Asset -> File). Assets (files, directories, archives) added via drag-and-drop appear immediately in the table. This table is interactive:
* If no preset is selected ("-- Select a Preset --"), added items (including files within directories/archives) are displayed with empty prediction fields (Target Asset, Asset Type, Item Type), which can be manually edited. * **Editable Fields:** The 'Name' field for Assets and the 'Target Asset', 'Supplier', 'Asset Type', and 'Item Type' fields for all items can be edited directly in the table.
* If a valid preset or LLM mode is selected, the table populates with prediction results as they become available. * Editing an **Asset Name** automatically updates the 'Target Asset' field for all its child files.
* The table always displays the detailed view structure with columns: Name, Target Asset, Supplier, Asset Type, Item Type. The "Target Asset" column stretches to fill available space. * The **Item Type** field is a text input with auto-suggestions based on available types.
* **Drag-and-Drop Re-parenting:** File rows can be dragged and dropped onto different Asset rows to change their parent asset association.
* **Right-Click Context Menu:** Right-clicking on Source, Asset, or File rows brings up a context menu:
* **Re-interpret selected source:** This sub-menu allows re-running the prediction process for the selected source item(s) using either a specific preset or the LLM predictor. The available presets and the "LLM" option are listed dynamically. This replaces the previous standalone "Re-interpret Selected with LLM" button.
* **Prediction Population:** If a valid preset is selected in the Preset Selector (or if re-interpretation is triggered), the table populates with prediction results as they become available. If no preset is selected, added items show empty prediction fields.
* **Columns:** The table displays columns: Name, Target Asset, Supplier, Asset Type, Item Type. The "Target Asset" column stretches to fill available space.
* **Coloring:** The *text color* of file items is determined by their Item Type (colors defined in `config/app_settings.json`). The *background color* of file items is a 30% darker shade of their parent asset's background, helping to visually group files within an asset. Asset rows themselves may use alternating background colors based on the application theme. * **Coloring:** The *text color* of file items is determined by their Item Type (colors defined in `config/app_settings.json`). The *background color* of file items is a 30% darker shade of their parent asset's background, helping to visually group files within an asset. Asset rows themselves may use alternating background colors based on the application theme.
* **Progress Bar:** Shows overall processing progress. * **Progress Bar:** Shows overall processing progress.
* **Blender Post-Processing:** Checkbox to enable Blender scripts. If enabled, shows fields and browse buttons for target `.blend` files (defaults from `config/app_settings.json`). * **Blender Post-Processing:** Checkbox to enable Blender scripts. If enabled, shows fields and browse buttons for target `.blend` files (defaults from `config/app_settings.json`).
@ -34,7 +39,6 @@ python -m gui.main_window
* `Clear Queue`: Button to clear the queue and preview. * `Clear Queue`: Button to clear the queue and preview.
* `Start Processing`: Button to start processing the queue. This button is enabled as long as there are items listed in the Preview Table. When clicked, any items that do not have a value assigned in the "Target Asset" column will be automatically ignored for that processing run. * `Start Processing`: Button to start processing the queue. This button is enabled as long as there are items listed in the Preview Table. When clicked, any items that do not have a value assigned in the "Target Asset" column will be automatically ignored for that processing run.
* `Cancel`: Button to attempt stopping processing. * `Cancel`: Button to attempt stopping processing.
* **Re-interpret Selected with LLM:** This button appears when the "- LLM Interpretation -" preset is selected. It allows you to re-process only the currently selected items in the Preview Table using the LLM, without affecting other items in the queue. This is useful for refining predictions on specific assets.
* **Status Bar:** Displays current status, errors, and completion messages. During LLM processing, the status bar will show messages indicating the progress of the LLM requests. * **Status Bar:** Displays current status, errors, and completion messages. During LLM processing, the status bar will show messages indicating the progress of the LLM requests.
## GUI Configuration Editor ## GUI Configuration Editor

View File

@ -0,0 +1,194 @@
# Implementation Plan: GUI User-Friendliness Enhancements
This document outlines the plan for implementing three key GUI improvements for the Asset Processor Tool, focusing on user-friendliness and workflow efficiency.
**Target Audience:** Developers implementing these features.
**Status:** Planning Phase
## Feature 1: Editable Asset Name
**Goal:** Allow users to edit the name of an asset directly in the main view, and automatically update the 'Target Asset' field of all associated child files to reflect the new name.
**Affected Components:**
* `gui/unified_view_model.py` (`UnifiedViewModel`)
* `gui/delegates.py` (`LineEditDelegate`)
* `gui/main_window.py` (or view setup location)
* `rule_structure.py` (`AssetRule`, `FileRule`)
* Potentially a new handler or modifications to `gui/asset_restructure_handler.py`
**Implementation Steps:**
1. **Enable Editing in Model (`UnifiedViewModel`):**
* Modify `flags()`: For an index pointing to an `AssetRule`, return `Qt.ItemIsEditable` in addition to default flags when `index.column()` is `COL_NAME`.
* Modify `setData()`:
* Add logic to handle `isinstance(item, AssetRule)` and `column == self.COL_NAME`.
* Get the `new_asset_name` from the `value`.
* **Validation:** Before proceeding, check if an `AssetRule` with `new_asset_name` already exists within the same parent `SourceRule`. If so, log a warning and return `False` to prevent duplicate names.
* Store the `old_asset_name = item.asset_name`.
* If `new_asset_name` is valid and different from `old_asset_name`:
* Update `item.asset_name = new_asset_name`.
* Set `changed = True`.
* **Crucial - Child Update:** Iterate through *all* `SourceRule`s, `AssetRule`s, and `FileRule`s in the model (`self._source_rules`). For each `FileRule` found where `file_rule.target_asset_name_override == old_asset_name`, update `file_rule.target_asset_name_override = new_asset_name`. Emit `dataChanged` for the `COL_TARGET_ASSET` index of each modified `FileRule`. (See Potential Challenges regarding performance).
* Emit `dataChanged` for the edited `AssetRule`'s `COL_NAME` index.
* Return `changed`.
* **(Alternative Signal Approach):** Instead of performing the child update directly in `setData`, emit a new signal like `assetNameChanged = Signal(QModelIndex, str, str)` carrying the `AssetRule` index, old name, and new name. A dedicated handler would connect to this signal to perform the child updates. This improves separation of concerns.
2. **Assign Delegate (`main_window.py` / View Setup):**
* Ensure the `LineEditDelegate` is assigned to the view for the `COL_NAME` using `view.setItemDelegateForColumn(UnifiedViewModel.COL_NAME, line_edit_delegate_instance)`.
3. **Handling Child Updates (if using Signal Approach):**
* Create a new handler class (e.g., `AssetNameChangeHandler`) or add a slot to `AssetRestructureHandler`.
* Connect the `UnifiedViewModel.assetNameChanged` signal to this slot.
* The slot receives the `AssetRule` index, old name, and new name. It iterates through the model's `FileRule`s, updates their `target_asset_name_override` where it matches the old name, and emits `dataChanged` for those files.
**Data Model Impact:**
* `AssetRule.asset_name` becomes directly mutable via the GUI.
* The relationship between files and their intended parent asset (represented by `FileRule.target_asset_name_override`) is maintained automatically when the parent asset's name changes.
**Potential Challenges/Considerations:**
* **Performance:** The child update logic requires iterating through potentially all files in the model. For very large datasets, this could be slow. Consider optimizing by maintaining an index/lookup map (`Dict[str, List[FileRule]]`) mapping target asset override names to the list of `FileRule`s using them. This map would need careful updating whenever overrides change or files are moved.
* **Duplicate Asset Names:** The plan includes basic validation in `setData`. Robust handling (e.g., user feedback, preventing the edit) is needed.
* **Undo/Redo:** Reversing an asset name change requires reverting the name *and* reverting all the child `target_asset_name_override` changes, adding complexity.
* **Scope of Child Update:** The current plan updates *any* `FileRule` whose override matches the old name. Confirm if this update should be restricted only to files originally under the renamed asset or within the same `SourceRule`. The current approach seems most logical based on how `target_asset_name_override` works.
```mermaid
sequenceDiagram
participant User
participant View
participant LineEditDelegate
participant UnifiedViewModel
participant AssetNameChangeHandler
User->>View: Edits AssetRule Name in COL_NAME
View->>LineEditDelegate: setModelData(editor, model, index)
LineEditDelegate->>UnifiedViewModel: setData(index, new_name, EditRole)
UnifiedViewModel->>UnifiedViewModel: Validate new_name (no duplicates)
UnifiedViewModel->>UnifiedViewModel: Update AssetRule.asset_name
alt Signal Approach
UnifiedViewModel->>AssetNameChangeHandler: emit assetNameChanged(index, old_name, new_name)
AssetNameChangeHandler->>UnifiedViewModel: Iterate through FileRules
loop For each FileRule where target_override == old_name
AssetNameChangeHandler->>UnifiedViewModel: Update FileRule.target_asset_name_override = new_name
UnifiedViewModel->>View: emit dataChanged(file_rule_target_index)
end
else Direct Approach in setData
UnifiedViewModel->>UnifiedViewModel: Iterate through FileRules
loop For each FileRule where target_override == old_name
UnifiedViewModel->>UnifiedViewModel: Update FileRule.target_asset_name_override = new_name
UnifiedViewModel->>View: emit dataChanged(file_rule_target_index)
end
end
UnifiedViewModel->>View: emit dataChanged(asset_rule_name_index)
```
## Feature 2: Item Type Field Conversion
**Goal:** Replace the `QComboBox` delegate for the "Item Type" column (for `FileRule`s) with a `QLineEdit` that provides auto-suggestions based on defined file types, similar to the existing "Supplier" field.
**Affected Components:**
* `gui/main_window.py` (or view setup location)
* `gui/delegates.py` (Requires a new delegate)
* `gui/unified_view_model.py` (`UnifiedViewModel`)
* `config/app_settings.json` (Source of file type definitions)
**Implementation Steps:**
1. **Create New Delegate (`delegates.py`):**
* Create a new class `ItemTypeSearchDelegate(QStyledItemDelegate)`.
* **`createEditor(self, parent, option, index)`:**
* Create a `QLineEdit` instance.
* Get the list of valid item type keys: `item_keys = index.model()._file_type_keys` (add error handling).
* Create a `QCompleter` using `item_keys` and set it on the `QLineEdit` (configure case sensitivity, filter mode, completion mode as in `SupplierSearchDelegate`).
* Return the editor.
* **`setEditorData(self, editor, index)`:**
* Get the current value using `index.model().data(index, Qt.EditRole)`.
* Set the editor's text (`editor.setText(str(value) if value is not None else "")`).
* **`setModelData(self, editor, model, index)`:**
* Get the `final_text = editor.text().strip()`.
* Determine the `value_to_set = final_text if final_text else None`.
* Call `model.setData(index, value_to_set, Qt.EditRole)`.
* **Important:** Unlike `SupplierSearchDelegate`, do *not* add `final_text` to the list of known types or save anything back to config. Suggestions are strictly based on `config/app_settings.json`.
* **`updateEditorGeometry(self, editor, option, index)`:**
* Standard implementation: `editor.setGeometry(option.rect)`.
2. **Assign Delegate (`main_window.py` / View Setup):**
* Instantiate the new `ItemTypeSearchDelegate`.
* Find where delegates are set for the view.
* Replace the `ComboBoxDelegate` assignment for `UnifiedViewModel.COL_ITEM_TYPE` with the new `ItemTypeSearchDelegate` instance: `view.setItemDelegateForColumn(UnifiedViewModel.COL_ITEM_TYPE, item_type_search_delegate_instance)`.
**Data Model Impact:**
* None. The underlying data (`FileRule.item_type_override`) and its handling remain the same. Only the GUI editor changes.
**Potential Challenges/Considerations:**
* None significant. This is a relatively straightforward replacement of one delegate type with another, leveraging existing patterns from `SupplierSearchDelegate` and data loading from `UnifiedViewModel`.
## Feature 3: Drag-and-Drop File Re-parenting
**Goal:** Enable users to drag one or more `FileRule` rows and drop them onto an `AssetRule` row to change the parent asset of the dragged files.
**Affected Components:**
* `gui/main_panel_widget.py` or `gui/main_window.py` (View management)
* `gui/unified_view_model.py` (`UnifiedViewModel`)
**Implementation Steps:**
1. **Enable Drag/Drop in View (`main_panel_widget.py` / `main_window.py`):**
* Get the `QTreeView` instance (`view`).
* `view.setSelectionMode(QAbstractItemView.ExtendedSelection)` (Allow selecting multiple files)
* `view.setDragEnabled(True)`
* `view.setAcceptDrops(True)`
* `view.setDropIndicatorShown(True)`
* `view.setDefaultDropAction(Qt.MoveAction)`
* `view.setDragDropMode(QAbstractItemView.InternalMove)`
2. **Implement Drag/Drop Support in Model (`UnifiedViewModel`):**
* **`flags(self, index)`:**
* Modify to include `Qt.ItemIsDragEnabled` if `index.internalPointer()` is a `FileRule`.
* Modify to include `Qt.ItemIsDropEnabled` if `index.internalPointer()` is an `AssetRule`.
* Return the combined flags.
* **`supportedDropActions(self)`:**
* Return `Qt.MoveAction`.
* **`mimeData(self, indexes)`:**
* Create `QMimeData`.
* Encode information about the dragged rows (which must be `FileRule`s). Store a list of tuples, each containing `(source_parent_row, source_parent_col, source_row)` for each valid `FileRule` index in `indexes`. Use a custom MIME type (e.g., `"application/x-filerule-index-list"`).
* Return the `QMimeData`.
* **`canDropMimeData(self, data, action, row, column, parent)`:**
* Check if `action == Qt.MoveAction`.
* Check if `data.hasFormat("application/x-filerule-index-list")`.
* Check if `parent.isValid()` and `parent.internalPointer()` is an `AssetRule`.
* Return `True` if all conditions met, `False` otherwise.
* **`dropMimeData(self, data, action, row, column, parent)`:**
* Check `action` and MIME type again for safety.
* Get the target `AssetRule` item: `target_asset = parent.internalPointer()`. If not an `AssetRule`, return `False`.
* Decode the `QMimeData` to get the list of source index information.
* Create a list `files_to_move = []` containing the actual `QModelIndex` objects for the source `FileRule`s (reconstruct them using the decoded info and `self.index()`).
* Iterate through `files_to_move`:
* Get the `source_file_index`.
* Get the `file_item = source_file_index.internalPointer()`.
* Get the `old_parent_asset = getattr(file_item, 'parent_asset', None)`.
* If `target_asset != old_parent_asset`:
* Call `self.moveFileRule(source_file_index, parent)`. This handles the actual move within the model structure and emits `beginMoveRows`/`endMoveRows`.
* **After successful move:** Update the file's override: `file_item.target_asset_name_override = target_asset.asset_name`.
* Emit `self.dataChanged.emit(moved_file_index, moved_file_index, [Qt.DisplayRole, Qt.EditRole])` for the `COL_TARGET_ASSET` column of the *now moved* file (get its new index).
* **Cleanup:** After the loop, identify any original parent `AssetRule`s that became empty as a result of the moves. Call `self.removeAssetRule(empty_asset_rule)` for each.
* Return `True`.
**Data Model Impact:**
* Changes the parentage of `FileRule` items within the model's internal structure.
* Updates `FileRule.target_asset_name_override` to match the `asset_name` of the new parent `AssetRule`, ensuring consistency between the visual structure and the override field.
**Potential Challenges/Considerations:**
* **MIME Data Encoding/Decoding:** Ensure the index information is reliably encoded and decoded, especially handling potential model changes between drag start and drop. Using persistent IDs instead of row/column numbers might be more robust if available.
* **Cleanup Logic:** Reliably identifying and removing empty parent assets after potentially moving multiple files from different original parents requires careful tracking.
* **Transactionality:** If moving multiple files and one part fails, should the whole operation roll back? The current plan doesn't explicitly handle this; errors are logged, and subsequent steps might proceed.
* **Interaction with `AssetRestructureHandler`:** The plan suggests handling the move and override update directly within `dropMimeData`. This means the existing `AssetRestructureHandler` won't be triggered by the override change *during* the drop. Ensure the cleanup logic (removing empty parents) is correctly handled either in `dropMimeData` or by ensuring `moveFileRule` emits signals that the handler *can* use for cleanup.

View File

@ -1,5 +1,5 @@
[ [
"Dimensiva",
"Dinesen", "Dinesen",
"Poliigon", "Poliigon"
"poliigon"
] ]

View File

@ -211,3 +211,54 @@ class SupplierSearchDelegate(QStyledItemDelegate):
def updateEditorGeometry(self, editor, option, index): def updateEditorGeometry(self, editor, option, index):
"""Ensures the editor widget is placed correctly.""" """Ensures the editor widget is placed correctly."""
editor.setGeometry(option.rect) editor.setGeometry(option.rect)
class ItemTypeSearchDelegate(QStyledItemDelegate):
"""
Delegate for editing item types using a QLineEdit with auto-completion.
Loads known item types from the UnifiedViewModel's cached keys.
"""
def __init__(self, parent=None):
super().__init__(parent)
# No persistent list needed here, suggestions come from the model
def createEditor(self, parent, option, index: QModelIndex):
"""Creates the QLineEdit editor with a QCompleter."""
editor = QLineEdit(parent)
model = index.model()
item_keys = []
# Get keys directly from the UnifiedViewModel
if hasattr(model, '_file_type_keys'):
try:
item_keys = model._file_type_keys # Use cached keys
except Exception as e:
log.error(f"Error getting _file_type_keys from model in ItemTypeSearchDelegate: {e}")
item_keys = []
else:
log.warning("ItemTypeSearchDelegate: Model is missing _file_type_keys attribute. Suggestions will be empty.")
completer = QCompleter(item_keys, editor)
completer.setCaseSensitivity(Qt.CaseInsensitive)
completer.setFilterMode(Qt.MatchContains)
completer.setCompletionMode(QCompleter.PopupCompletion)
editor.setCompleter(completer)
return editor
def setEditorData(self, editor: QLineEdit, index: QModelIndex):
"""Sets the editor's initial data from the model."""
# Use EditRole as defined in the model's data() method for item type override
value = index.model().data(index, Qt.EditRole)
editor.setText(str(value) if value is not None else "")
def setModelData(self, editor: QLineEdit, model, index: QModelIndex):
"""Commits the editor's data back to the model."""
final_text = editor.text().strip()
value_to_set = final_text if final_text else None # Set None if empty after stripping
# Set data in the model
# The model's setData handles updating the override and standard_map_type
model.setData(index, value_to_set, Qt.EditRole)
# DO NOT add to a persistent list or save back to config
def updateEditorGeometry(self, editor, option, index):
"""Ensures the editor widget is placed correctly."""
editor.setGeometry(option.rect)

View File

@ -4,6 +4,7 @@ import json
import logging import logging
import time import time
from pathlib import Path from pathlib import Path
import functools # Ensure functools is imported directly for partial
from functools import partial from functools import partial
from PySide6.QtWidgets import QApplication # Added for processEvents from PySide6.QtWidgets import QApplication # Added for processEvents
@ -19,18 +20,23 @@ from PySide6.QtGui import QColor, QAction, QPalette, QClipboard, QGuiApplication
# --- Local GUI Imports --- # --- Local GUI Imports ---
# Import delegates and models needed by the panel # Import delegates and models needed by the panel
from .delegates import LineEditDelegate, ComboBoxDelegate, SupplierSearchDelegate from .delegates import LineEditDelegate, ComboBoxDelegate, SupplierSearchDelegate, ItemTypeSearchDelegate # Added ItemTypeSearchDelegate
from .unified_view_model import UnifiedViewModel # Assuming UnifiedViewModel is passed in from .unified_view_model import UnifiedViewModel # Assuming UnifiedViewModel is passed in
# --- Backend Imports --- # --- Backend Imports ---
# Import Rule Structures if needed for context menus etc. # Import Rule Structures if needed for context menus etc.
from rule_structure import SourceRule, AssetRule, FileRule from rule_structure import SourceRule, AssetRule, FileRule
# Import config loading if defaults are needed directly here (though better passed from MainWindow) # Import config loading if defaults are needed directly here (though better passed from MainWindow)
# Import configuration directly for PRESETS_DIR access
import configuration
try: try:
from configuration import ConfigurationError, load_base_config from configuration import ConfigurationError, load_base_config
except ImportError: except ImportError:
ConfigurationError = Exception ConfigurationError = Exception
load_base_config = None load_base_config = None
# Define PRESETS_DIR fallback if configuration module fails to load entirely
class configuration:
PRESETS_DIR = "Presets" # Fallback path
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
@ -57,6 +63,7 @@ class MainPanelWidget(QWidget):
# Request to re-interpret selected items using LLM # Request to re-interpret selected items using LLM
llm_reinterpret_requested = Signal(list) # Emits list of source paths llm_reinterpret_requested = Signal(list) # Emits list of source paths
preset_reinterpret_requested = Signal(list, str) # Emits list[source_paths], preset_name
# Notify when the output directory changes # Notify when the output directory changes
output_dir_changed = Signal(str) output_dir_changed = Signal(str)
@ -130,12 +137,14 @@ class MainPanelWidget(QWidget):
# TODO: Revisit ComboBoxDelegate dependency # TODO: Revisit ComboBoxDelegate dependency
comboBoxDelegate = ComboBoxDelegate(self) # Pass only parent (self) comboBoxDelegate = ComboBoxDelegate(self) # Pass only parent (self)
supplierSearchDelegate = SupplierSearchDelegate(self) # Pass parent supplierSearchDelegate = SupplierSearchDelegate(self) # Pass parent
itemTypeSearchDelegate = ItemTypeSearchDelegate(self) # Instantiate new delegate
# Set Delegates for Columns # Set Delegates for Columns
self.unified_view.setItemDelegateForColumn(UnifiedViewModel.COL_SUPPLIER, supplierSearchDelegate) self.unified_view.setItemDelegateForColumn(UnifiedViewModel.COL_SUPPLIER, supplierSearchDelegate)
self.unified_view.setItemDelegateForColumn(UnifiedViewModel.COL_ASSET_TYPE, comboBoxDelegate) self.unified_view.setItemDelegateForColumn(UnifiedViewModel.COL_ASSET_TYPE, comboBoxDelegate)
self.unified_view.setItemDelegateForColumn(UnifiedViewModel.COL_TARGET_ASSET, lineEditDelegate) self.unified_view.setItemDelegateForColumn(UnifiedViewModel.COL_TARGET_ASSET, lineEditDelegate)
self.unified_view.setItemDelegateForColumn(UnifiedViewModel.COL_ITEM_TYPE, comboBoxDelegate) self.unified_view.setItemDelegateForColumn(UnifiedViewModel.COL_ITEM_TYPE, itemTypeSearchDelegate) # Use ItemTypeSearchDelegate
self.unified_view.setItemDelegateForColumn(UnifiedViewModel.COL_NAME, lineEditDelegate) # Assign LineEditDelegate for AssetRule names
# Configure View Appearance # Configure View Appearance
self.unified_view.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) self.unified_view.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
@ -156,6 +165,17 @@ class MainPanelWidget(QWidget):
# Enable custom context menu # Enable custom context menu
self.unified_view.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu) self.unified_view.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
# --- Enable Drag and Drop ---
self.unified_view.setDragEnabled(True)
self.unified_view.setAcceptDrops(True)
self.unified_view.setDropIndicatorShown(True)
self.unified_view.setDefaultDropAction(Qt.MoveAction)
# Use InternalMove for handling drops within the model itself
self.unified_view.setDragDropMode(QAbstractItemView.InternalMove)
# Ensure ExtendedSelection is set (already done above, but good practice)
self.unified_view.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection)
# --- End Drag and Drop ---
# Add the Unified View to the main layout # Add the Unified View to the main layout
main_layout.addWidget(self.unified_view, 1) # Give it stretch factor 1 main_layout.addWidget(self.unified_view, 1) # Give it stretch factor 1
@ -240,11 +260,11 @@ class MainPanelWidget(QWidget):
bottom_controls_layout.addWidget(self.workers_spinbox) bottom_controls_layout.addWidget(self.workers_spinbox)
bottom_controls_layout.addStretch(1) bottom_controls_layout.addStretch(1)
# --- LLM Re-interpret Button --- # --- LLM Re-interpret Button (Removed, functionality moved to context menu) ---
self.llm_reinterpret_button = QPushButton("Re-interpret Selected with LLM") # self.llm_reinterpret_button = QPushButton("Re-interpret Selected with LLM")
self.llm_reinterpret_button.setToolTip("Re-run LLM interpretation on the selected source items.") # self.llm_reinterpret_button.setToolTip("Re-run LLM interpretation on the selected source items.")
self.llm_reinterpret_button.setEnabled(False) # Initially disabled # self.llm_reinterpret_button.setEnabled(False) # Initially disabled
bottom_controls_layout.addWidget(self.llm_reinterpret_button) # bottom_controls_layout.addWidget(self.llm_reinterpret_button)
self.clear_queue_button = QPushButton("Clear Queue") self.clear_queue_button = QPushButton("Clear Queue")
self.start_button = QPushButton("Start Processing") self.start_button = QPushButton("Start Processing")
@ -263,7 +283,6 @@ class MainPanelWidget(QWidget):
self.output_path_edit.editingFinished.connect(self._on_output_path_changed) # Emit signal when user finishes editing self.output_path_edit.editingFinished.connect(self._on_output_path_changed) # Emit signal when user finishes editing
# Unified View # Unified View
self.unified_view.selectionModel().selectionChanged.connect(self._update_llm_reinterpret_button_state)
self.unified_view.customContextMenuRequested.connect(self._show_unified_view_context_menu) self.unified_view.customContextMenuRequested.connect(self._show_unified_view_context_menu)
# Blender Controls # Blender Controls
@ -280,7 +299,7 @@ class MainPanelWidget(QWidget):
self.clear_queue_button.clicked.connect(self.clear_queue_requested) # Emit signal directly self.clear_queue_button.clicked.connect(self.clear_queue_requested) # Emit signal directly
self.start_button.clicked.connect(self._on_start_processing_clicked) # Use slot to gather data self.start_button.clicked.connect(self._on_start_processing_clicked) # Use slot to gather data
self.cancel_button.clicked.connect(self.cancel_requested) # Emit signal directly self.cancel_button.clicked.connect(self.cancel_requested) # Emit signal directly
self.llm_reinterpret_button.clicked.connect(self._on_llm_reinterpret_clicked) # Use slot to gather data # self.llm_reinterpret_button.clicked.connect(self._on_llm_reinterpret_clicked) # Removed button connection
# --- Slots for Internal UI Logic --- # --- Slots for Internal UI Logic ---
@ -371,79 +390,98 @@ class MainPanelWidget(QWidget):
} }
self.process_requested.emit(settings) self.process_requested.emit(settings)
@Slot() # Removed _update_llm_reinterpret_button_state as the button is removed.
def _update_llm_reinterpret_button_state(self): # Context menu actions will handle their own enabled state or rely on _on_llm_reinterpret_clicked checks.
"""Enables/disables the LLM re-interpret button based on selection and LLM status."""
selection_model = self.unified_view.selectionModel() def _get_unique_source_dirs_from_selection(self, selected_indexes: list[QModelIndex]) -> set[str]:
has_selection = selection_model is not None and selection_model.hasSelection() """
# Enable only if there's a selection AND LLM is not currently active Extracts unique, valid source directory/zip paths from the selected QModelIndex list.
self.llm_reinterpret_button.setEnabled(has_selection and not self.llm_processing_active) Traverses up the model hierarchy to find the parent SourceRule for each index.
"""
unique_source_dirs = set()
model = self.unified_view.model()
if not model:
log.error("Unified view model not found.")
return unique_source_dirs
processed_source_paths = set() # To avoid processing duplicates if multiple cells of the same source are selected
for index in selected_indexes:
if not index.isValid():
continue
# Use the model's getItem method for robust node retrieval
item_node = model.getItem(index)
source_rule_node = None
# Find the parent SourceRule node by traversing upwards using the index
source_rule_node = None
current_index = index # Start with the index of the selected item
while current_index.isValid():
current_item = model.getItem(current_index)
if isinstance(current_item, SourceRule):
source_rule_node = current_item
break
current_index = model.parent(current_index) # Move to the parent index
# If loop finishes without break, source_rule_node remains None
if source_rule_node:
# Use input_path attribute as defined in SourceRule
source_path = getattr(source_rule_node, 'input_path', None)
if source_path and source_path not in processed_source_paths:
source_path_obj = Path(source_path)
# Check if it's a directory or a zip file (common input types)
if source_path_obj.is_dir() or (source_path_obj.is_file() and source_path_obj.suffix.lower() == '.zip'):
log.debug(f"Identified source path for re-interpretation: {source_path}")
unique_source_dirs.add(source_path)
processed_source_paths.add(source_path)
else:
log.warning(f"Selected item's source path is not a directory or zip file: {source_path}")
elif not source_path:
log.warning(f"Parent SourceRule found for index {index.row()},{index.column()} but has no 'input_path' attribute.")
else:
log.warning(f"Could not find parent SourceRule for selected index: {index.row()},{index.column()} (Node type: {type(item_node).__name__})")
return unique_source_dirs
@Slot() @Slot()
def _on_llm_reinterpret_clicked(self): def _on_llm_reinterpret_clicked(self):
"""Gathers selected source paths and emits the llm_reinterpret_requested signal.""" """Gathers selected source paths and emits the llm_reinterpret_requested signal. (Triggered by context menu)"""
selected_indexes = self.unified_view.selectionModel().selectedIndexes()
if not selected_indexes:
return
if self.llm_processing_active: if self.llm_processing_active:
QMessageBox.warning(self, "Busy", "LLM processing is already in progress. Please wait.") QMessageBox.warning(self, "Busy", "LLM processing is already in progress. Please wait.")
return return
unique_source_dirs = set() selected_indexes = self.unified_view.selectionModel().selectedIndexes()
processed_source_paths = set() # Track processed source paths to avoid duplicates unique_source_dirs = self._get_unique_source_dirs_from_selection(selected_indexes)
for index in selected_indexes:
if not index.isValid(): continue
item_node = index.internalPointer()
if not item_node: continue
# Traverse up to find the SourceRule node (Simplified traversal)
source_node = None
current_node = item_node
while current_node is not None:
if isinstance(current_node, SourceRule):
source_node = current_node
break
# Simplified parent traversal - adjust if model structure is different
parent_attr = getattr(current_node, 'parent', None) # Check for generic 'parent'
if callable(parent_attr): # Check if parent is a method (like in QStandardItemModel)
current_node = parent_attr()
elif parent_attr: # Check if parent is an attribute
current_node = parent_attr
else: # Try specific parent attributes if generic fails
parent_source = getattr(current_node, 'parent_source', None)
if parent_source:
current_node = parent_source
else:
parent_asset = getattr(current_node, 'parent_asset', None)
if parent_asset:
current_node = parent_asset
else: # Reached top or unexpected node type
current_node = None
if source_node and hasattr(source_node, 'input_path') and source_node.input_path:
source_path_str = source_node.input_path
if source_path_str in processed_source_paths:
continue
source_path_obj = Path(source_path_str)
if source_path_obj.is_dir() or (source_path_obj.is_file() and source_path_obj.suffix.lower() == '.zip'):
unique_source_dirs.add(source_path_str)
processed_source_paths.add(source_path_str)
else:
log.warning(f"Skipping non-directory/zip source for re-interpretation: {source_path_str}")
# else: # Reduce log noise
# log.warning(f"Could not determine valid SourceRule or input_path for selected index: {index.row()},{index.column()} (Item type: {type(item_node).__name__})")
if not unique_source_dirs: if not unique_source_dirs:
# self.statusBar().showMessage("No valid source directories found for selected items.", 5000) # Status bar is in MainWindow log.warning("No valid source directories found for selected items to re-interpret with LLM.")
log.warning("No valid source directories found for selected items to re-interpret.") # Optionally show status bar message via MainWindow reference if available
return return
log.info(f"Emitting llm_reinterpret_requested for {len(unique_source_dirs)} paths.")
self.llm_reinterpret_requested.emit(list(unique_source_dirs)) self.llm_reinterpret_requested.emit(list(unique_source_dirs))
@Slot(str, QModelIndex)
def _on_reinterpret_preset_selected(self, preset_name: str, index: QModelIndex):
"""Handles the selection of a preset from the re-interpret context sub-menu."""
log.info(f"Preset re-interpretation requested: Preset='{preset_name}', Index='{index.row()},{index.column()}'")
# Reuse logic from _on_llm_reinterpret_clicked to get selected source paths
selected_indexes = self.unified_view.selectionModel().selectedIndexes()
# Use the helper method to get all selected source paths, not just the one clicked
unique_source_dirs = self._get_unique_source_dirs_from_selection(selected_indexes)
if not unique_source_dirs:
log.warning("No valid source directories found for selected items to re-interpret with preset.")
# Optionally show status bar message via MainWindow reference if available
return
log.info(f"Emitting preset_reinterpret_requested for {len(unique_source_dirs)} paths with preset '{preset_name}'.")
self.preset_reinterpret_requested.emit(list(unique_source_dirs), preset_name)
@Slot(QPoint) @Slot(QPoint)
def _show_unified_view_context_menu(self, point: QPoint): def _show_unified_view_context_menu(self, point: QPoint):
"""Shows the context menu for the unified view.""" """Shows the context menu for the unified view."""
@ -451,31 +489,90 @@ class MainPanelWidget(QWidget):
if not index.isValid(): if not index.isValid():
return return
item_node = index.internalPointer() model = self.unified_view.model()
is_source_item = isinstance(item_node, SourceRule) if not model: return
item_node = model.getItem(index) # Use model's method
# Find the SourceRule node associated with the clicked index
# Find the SourceRule node associated with the clicked index
source_rule_node = None
current_index = index # Start with the clicked index
while current_index.isValid():
current_item = model.getItem(current_index)
if isinstance(current_item, SourceRule):
source_rule_node = current_item
break
current_index = model.parent(current_index) # Move to the parent index
# If loop finishes without break, source_rule_node remains None
menu = QMenu(self) menu = QMenu(self)
if is_source_item: # --- Re-interpret Menu ---
if source_rule_node: # Only show if we clicked on or within a SourceRule item
reinterpet_menu = menu.addMenu("Re-interpret selected source")
# Get Preset Names (Option B: Direct File Listing)
preset_names = []
try:
presets_dir = configuration.PRESETS_DIR
if os.path.isdir(presets_dir):
for filename in os.listdir(presets_dir):
if filename.endswith(".json") and filename != "_template.json":
preset_name = os.path.splitext(filename)[0]
preset_names.append(preset_name)
preset_names.sort() # Sort alphabetically
else:
log.warning(f"Presets directory not found or not a directory: {presets_dir}")
except Exception as e:
log.exception(f"Error listing presets in {configuration.PRESETS_DIR}: {e}")
# Populate Sub-Menu with Presets
if preset_names:
for preset_name in preset_names:
preset_action = QAction(preset_name, self)
# Pass the preset name and the *clicked* index (though the slot will get all selected)
preset_action.triggered.connect(functools.partial(self._on_reinterpret_preset_selected, preset_name, index))
reinterpet_menu.addAction(preset_action)
else:
no_presets_action = QAction("No presets found", self)
no_presets_action.setEnabled(False)
reinterpet_menu.addAction(no_presets_action)
# Add LLM Option (Static)
reinterpet_menu.addSeparator()
llm_action = QAction("LLM", self)
# Connect to the existing slot that handles LLM re-interpretation requests
llm_action.triggered.connect(self._on_llm_reinterpret_clicked)
# Disable if LLM is currently processing
llm_action.setEnabled(not self.llm_processing_active)
reinterpet_menu.addAction(llm_action)
menu.addSeparator() # Separator before other actions
# --- Other Actions (like Copy LLM Example) ---
if source_rule_node: # Check again if it's a source item for this action
copy_llm_example_action = QAction("Copy LLM Example to Clipboard", self) copy_llm_example_action = QAction("Copy LLM Example to Clipboard", self)
copy_llm_example_action.setToolTip("Copies a JSON structure representing the input files and predicted output, suitable for LLM examples.") copy_llm_example_action.setToolTip("Copies a JSON structure representing the input files and predicted output, suitable for LLM examples.")
copy_llm_example_action.triggered.connect(lambda: self._copy_llm_example_to_clipboard(index)) # Pass the found source_rule_node
copy_llm_example_action.triggered.connect(lambda: self._copy_llm_example_to_clipboard(source_rule_node))
menu.addAction(copy_llm_example_action) menu.addAction(copy_llm_example_action)
menu.addSeparator() # menu.addSeparator() # Removed redundant separator
# Add other actions... # Add other general actions here if needed...
if not menu.isEmpty(): if not menu.isEmpty():
menu.exec(self.unified_view.viewport().mapToGlobal(point)) menu.exec(self.unified_view.viewport().mapToGlobal(point))
@Slot(QModelIndex) @Slot(SourceRule) # Accept SourceRule directly
def _copy_llm_example_to_clipboard(self, index: QModelIndex): def _copy_llm_example_to_clipboard(self, source_rule_node: SourceRule | None):
"""Copies a JSON structure for the selected source item to the clipboard.""" """Copies a JSON structure for the given SourceRule node to the clipboard."""
if not index.isValid(): return if not source_rule_node:
item_node = index.internalPointer() log.warning(f"No SourceRule node provided to copy LLM example.")
if not isinstance(item_node, SourceRule): return return
source_rule: SourceRule = item_node # We already have the source_rule_node passed in
source_rule: SourceRule = source_rule_node
log.info(f"Attempting to generate LLM example JSON for source: {source_rule.input_path}") log.info(f"Attempting to generate LLM example JSON for source: {source_rule.input_path}")
all_file_paths = [] all_file_paths = []
@ -570,11 +667,8 @@ class MainPanelWidget(QWidget):
self.materials_blend_path_input.setEnabled(blender_paths_enabled) self.materials_blend_path_input.setEnabled(blender_paths_enabled)
self.browse_materials_blend_button.setEnabled(blender_paths_enabled) self.browse_materials_blend_button.setEnabled(blender_paths_enabled)
# Update LLM button state explicitly when controls are enabled/disabled # LLM button removed, no need to update its state here.
if enabled: # Context menu actions enable/disable themselves based on context (e.g., llm_processing_active).
self._update_llm_reinterpret_button_state()
else:
self.llm_reinterpret_button.setEnabled(False)
@Slot(bool) @Slot(bool)
@ -594,9 +688,9 @@ class MainPanelWidget(QWidget):
@Slot(bool) @Slot(bool)
def set_llm_processing_status(self, active: bool): def set_llm_processing_status(self, active: bool):
"""Informs the panel whether LLM processing is active.""" """Informs the panel whether LLM processing is active (used for context menu state)."""
self.llm_processing_active = active self.llm_processing_active = active
self._update_llm_reinterpret_button_state() # Update button state based on new status # No button state to update directly, but context menu will check this flag when built.
# TODO: Add method to get current output path if needed by MainWindow before processing # TODO: Add method to get current output path if needed by MainWindow before processing
def get_output_directory(self) -> str: def get_output_directory(self) -> str:

View File

@ -189,6 +189,7 @@ class MainWindow(QMainWindow):
self.main_panel_widget.output_dir_changed.connect(self._on_output_dir_changed) self.main_panel_widget.output_dir_changed.connect(self._on_output_dir_changed)
self.main_panel_widget.blender_settings_changed.connect(self._on_blender_settings_changed) self.main_panel_widget.blender_settings_changed.connect(self._on_blender_settings_changed)
self.main_panel_widget.preset_reinterpret_requested.connect(self._on_preset_reinterpret_requested)
# --- Connect Signals from LLMInteractionHandler --- # --- Connect Signals from LLMInteractionHandler ---
self.llm_interaction_handler.llm_prediction_ready.connect(self._on_llm_prediction_ready_from_handler) self.llm_interaction_handler.llm_prediction_ready.connect(self._on_llm_prediction_ready_from_handler)
self.llm_interaction_handler.llm_prediction_error.connect(self._on_prediction_error) # Use common error slot self.llm_interaction_handler.llm_prediction_error.connect(self._on_prediction_error) # Use common error slot
@ -621,6 +622,67 @@ class MainWindow(QMainWindow):
"materials_blend_path": mat_path "materials_blend_path": mat_path
} }
log.debug(f"MainWindow stored Blender settings: {self._current_blender_settings}") log.debug(f"MainWindow stored Blender settings: {self._current_blender_settings}")
@Slot(list, str)
def _on_preset_reinterpret_requested(self, source_paths: list, preset_name: str):
"""Handles the preset_reinterpret_requested signal from MainPanelWidget."""
log.info(f"Received preset re-interpret request for {len(source_paths)} paths using preset '{preset_name}'.")
if not source_paths:
self.statusBar().showMessage("No valid source directories selected for preset re-interpretation.", 5000)
return
# Check if rule-based prediction is already running (optional, handler might manage internally)
# Note: QueuedConnection on the signal helps, but check anyway for immediate feedback/logging
# TODO: Add is_running() method to RuleBasedPredictionHandler if needed for this check
if self.prediction_handler and hasattr(self.prediction_handler, 'is_running') and self.prediction_handler.is_running():
log.warning("Rule-based prediction is already running. Queuing re-interpretation request.")
# Proceed, relying on QueuedConnection
# Ensure the prediction thread is running
if self.prediction_thread and not self.prediction_thread.isRunning():
log.debug("Starting prediction thread for preset re-interpretation.")
self.prediction_thread.start()
elif not self.prediction_thread:
log.error("Prediction thread not initialized. Cannot perform preset re-interpretation.")
self.statusBar().showMessage("Error: Prediction system not ready.", 5000)
return
# Trigger prediction for each path
self.statusBar().showMessage(f"Starting re-interpretation for {len(source_paths)} item(s) using preset '{preset_name}'...", 0)
for input_path_str in source_paths:
# Mark as pending (use existing mechanism)
self._pending_predictions.add(input_path_str)
self._completed_predictions.discard(input_path_str) # Ensure it's not marked completed
# Update status in model (Requires update_status method in UnifiedViewModel)
try:
if hasattr(self.unified_model, 'update_status'):
self.unified_model.update_status(input_path_str, "Re-interpreting...")
else:
log.warning("UnifiedViewModel does not have 'update_status' method. Cannot update status visually.")
except Exception as e:
log.exception(f"Error calling unified_model.update_status for {input_path_str}: {e}")
# Extract file list (needed by RuleBasedPredictionHandler)
file_list = self._extract_file_list(input_path_str)
if file_list is not None:
log.debug(f"Emitting start_prediction_signal for re-interpretation: Path='{input_path_str}', Preset='{preset_name}'")
# Use the existing signal connected to RuleBasedPredictionHandler
self.start_prediction_signal.emit(input_path_str, file_list, preset_name)
else:
log.warning(f"Skipping re-interpretation for {input_path_str} due to extraction error.")
# Update status in model to reflect error (Requires update_status method)
try:
if hasattr(self.unified_model, 'update_status'):
self.unified_model.update_status(input_path_str, "Error extracting files")
else:
log.warning("UnifiedViewModel does not have 'update_status' method. Cannot update error status visually.")
except Exception as e:
log.exception(f"Error calling unified_model.update_status (error case) for {input_path_str}: {e}")
self._handle_prediction_completion(input_path_str) # Mark as completed (with error)
# --- Preview Update Method --- # --- Preview Update Method ---
def update_preview(self): def update_preview(self):
@ -1180,6 +1242,7 @@ class MainWindow(QMainWindow):
log.warning("get_llm_source_preset_name called before preset_editor_widget was initialized.") log.warning("get_llm_source_preset_name called before preset_editor_widget was initialized.")
return None return None
# --- Main Execution ---
# --- Main Execution --- # --- Main Execution ---
def run_gui(): def run_gui():
"""Initializes and runs the Qt application.""" """Initializes and runs the Qt application."""

View File

@ -489,3 +489,7 @@ class RuleBasedPredictionHandler(BasePredictionHandler):
self._current_file_list = None self._current_file_list = None
self._current_preset_name = None self._current_preset_name = None
log.info(f"Finished rule-based prediction run for: {input_source_identifier}") log.info(f"Finished rule-based prediction run for: {input_source_identifier}")
def is_running(self) -> bool:
"""Returns True if the handler is currently processing a prediction request."""
# The _is_running flag is managed by the base class or the run_prediction method
return self._is_running

View File

@ -1,7 +1,7 @@
# gui/unified_view_model.py # gui/unified_view_model.py
import logging # Added for debugging import logging # Added for debugging
log = logging.getLogger(__name__) # Added for debugging log = logging.getLogger(__name__) # Added for debugging
from PySide6.QtCore import QAbstractItemModel, QModelIndex, Qt, Signal, Slot # Added Signal and Slot from PySide6.QtCore import QAbstractItemModel, QModelIndex, Qt, Signal, Slot, QMimeData, QByteArray, QDataStream, QIODevice # Added Signal and Slot, QMimeData, QByteArray, QDataStream, QIODevice
from PySide6.QtGui import QColor # Added for background role from PySide6.QtGui import QColor # Added for background role
from pathlib import Path # Added for file_name extraction from pathlib import Path # Added for file_name extraction
from rule_structure import SourceRule, AssetRule, FileRule # Removed AssetType, ItemType import from rule_structure import SourceRule, AssetRule, FileRule # Removed AssetType, ItemType import
@ -35,6 +35,9 @@ class UnifiedViewModel(QAbstractItemModel):
# COL_STATUS = 5 # Removed # COL_STATUS = 5 # Removed
# COL_OUTPUT_PATH = 6 # Removed # COL_OUTPUT_PATH = 6 # Removed
# --- Drag and Drop MIME Type ---
MIME_TYPE = "application/x-filerule-index-list" # Custom MIME type
def __init__(self, parent=None): def __init__(self, parent=None):
super().__init__(parent) super().__init__(parent)
self._source_rules = [] # Now stores a list of SourceRule objects self._source_rules = [] # Now stores a list of SourceRule objects
@ -278,7 +281,9 @@ class UnifiedViewModel(QAbstractItemModel):
display_value = item.asset_type_override if item.asset_type_override is not None else item.asset_type display_value = item.asset_type_override if item.asset_type_override is not None else item.asset_type
return display_value if display_value else "" return display_value if display_value else ""
elif role == Qt.EditRole: elif role == Qt.EditRole:
if column == self.COL_ASSET_TYPE: if column == self.COL_NAME:
return item.asset_name # Return name for editing
elif column == self.COL_ASSET_TYPE:
return item.asset_type_override return item.asset_type_override
return None return None
@ -330,7 +335,60 @@ class UnifiedViewModel(QAbstractItemModel):
changed = True changed = True
elif isinstance(item, AssetRule): elif isinstance(item, AssetRule):
if column == self.COL_ASSET_TYPE: if column == self.COL_NAME: # Handle Asset Name change
new_asset_name = str(value).strip() if value else None
if not new_asset_name:
log.warning("setData: Asset name cannot be empty.")
return False # Don't allow empty names
if item.asset_name == new_asset_name:
return False # No change
# --- Validation: Check for duplicates within the same SourceRule ---
parent_source = getattr(item, 'parent_source', None)
if parent_source:
for existing_asset in parent_source.assets:
if existing_asset.asset_name == new_asset_name and existing_asset is not item:
log.warning(f"setData: Duplicate asset name '{new_asset_name}' detected within the same source. Aborting rename.")
# Optionally, provide user feedback here via a signal or message box
return False
else:
log.error("setData: Cannot validate asset name, parent SourceRule not found.")
# Decide how to handle this - proceed cautiously or abort? Aborting is safer.
return False
# --- End Validation ---
log.info(f"setData: Renaming AssetRule from '{item.asset_name}' to '{new_asset_name}'")
old_asset_name = item.asset_name
item.asset_name = new_asset_name
changed = True
# --- Update Child FileRule Target Asset Overrides ---
log.debug(f"setData: Updating FileRule target overrides from '{old_asset_name}' to '{new_asset_name}'")
updated_file_indices = []
for src_idx, source_rule in enumerate(self._source_rules):
source_rule_index = self.createIndex(src_idx, 0, source_rule)
for asset_idx, asset_rule in enumerate(source_rule.assets):
asset_rule_index = self.createIndex(asset_idx, 0, asset_rule) # This index is relative to source_rule_index
for file_idx, file_rule in enumerate(asset_rule.files):
if file_rule.target_asset_name_override == old_asset_name:
log.debug(f" Updating target for file: {Path(file_rule.file_path).name}")
file_rule.target_asset_name_override = new_asset_name
# Get the correct index for the file rule to emit dataChanged
file_rule_parent_index = self.parent(self.createIndex(file_idx, 0, file_rule)) # Get parent AssetRule index
file_rule_index = self.index(file_idx, self.COL_TARGET_ASSET, file_rule_parent_index)
if file_rule_index.isValid():
updated_file_indices.append(file_rule_index)
else:
log.warning(f" Could not get valid index for updated file rule target: {Path(file_rule.file_path).name}")
# Emit dataChanged for all updated file rules *after* the loop
for file_index in updated_file_indices:
self.dataChanged.emit(file_index, file_index, [Qt.DisplayRole, Qt.EditRole])
# --- End Child Update ---
elif column == self.COL_ASSET_TYPE:
# Delegate provides string value (e.g., "Surface", "Model") or None # Delegate provides string value (e.g., "Surface", "Model") or None
new_value = str(value) if value is not None else None new_value = str(value) if value is not None else None
if new_value == "": new_value = None # Treat empty string as None if new_value == "": new_value = None # Treat empty string as None
@ -416,6 +474,8 @@ class UnifiedViewModel(QAbstractItemModel):
default_flags = Qt.ItemIsEnabled | Qt.ItemIsSelectable default_flags = Qt.ItemIsEnabled | Qt.ItemIsSelectable
item = index.internalPointer() item = index.internalPointer()
if not item: # Should not happen for valid index, but safety check
return Qt.NoItemFlags
column = index.column() column = index.column()
# Always use detailed mode editability logic # Always use detailed mode editability logic
@ -423,15 +483,21 @@ class UnifiedViewModel(QAbstractItemModel):
if isinstance(item, SourceRule): if isinstance(item, SourceRule):
if column == self.COL_SUPPLIER: can_edit = True if column == self.COL_SUPPLIER: can_edit = True
elif isinstance(item, AssetRule): elif isinstance(item, AssetRule):
if column == self.COL_NAME: can_edit = True # Allow editing name
if column == self.COL_ASSET_TYPE: can_edit = True if column == self.COL_ASSET_TYPE: can_edit = True
# AssetRule items can accept drops
default_flags |= Qt.ItemIsDropEnabled
elif isinstance(item, FileRule): elif isinstance(item, FileRule):
if column == self.COL_TARGET_ASSET: can_edit = True if column == self.COL_TARGET_ASSET: can_edit = True
if column == self.COL_ITEM_TYPE: can_edit = True if column == self.COL_ITEM_TYPE: can_edit = True
# FileRule items can be dragged
default_flags |= Qt.ItemIsDragEnabled
if can_edit: if can_edit:
return default_flags | Qt.ItemIsEditable default_flags |= Qt.ItemIsEditable
else:
return default_flags return default_flags
# Removed erroneous else block
def headerData(self, section: int, orientation: Qt.Orientation, role: int = Qt.DisplayRole): def headerData(self, section: int, orientation: Qt.Orientation, role: int = Qt.DisplayRole):
"""Returns the data for the given role and section in the header.""" """Returns the data for the given role and section in the header."""
@ -756,11 +822,250 @@ def get_asset_type_keys(self) -> List[str]:
def get_file_type_keys(self) -> List[str]: def get_file_type_keys(self) -> List[str]:
"""Returns the cached list of file type keys.""" """Returns the cached list of file type keys."""
return self._file_type_keys return self._file_type_keys
log.debug(f"Removing empty AssetRule '{asset_rule_to_remove.asset_name}' at row {asset_row_for_removal} under '{Path(source_rule.input_path).name}'") log.debug(f"Removing empty AssetRule '{asset_rule_to_remove.asset_name}' at row {asset_row_for_removal} under '{Path(source_rule.input_path).name}'")
self.beginRemoveRows(grandparent_index, asset_row_for_removal, asset_row_for_removal) self.beginRemoveRows(grandparent_index, asset_row_for_removal, asset_row_for_removal)
source_rule.assets.pop(asset_row_for_removal) source_rule.assets.pop(asset_row_for_removal)
self.endRemoveRows() self.endRemoveRows()
return True return True
def update_status(self, source_path: str, status_text: str):
"""
Finds the SourceRule node for the given source_path and updates its status.
Emits dataChanged for the corresponding row.
"""
log.debug(f"Attempting to update status for source '{source_path}' to '{status_text}'")
found_row = -1
found_rule = None
for i, rule in enumerate(self._source_rules):
if rule.input_path == source_path:
found_row = i
found_rule = rule
break
if found_rule is not None and found_row != -1:
try:
# Attempt to set a status attribute (e.g., _status_message)
# Note: This attribute isn't formally defined in SourceRule structure yet.
setattr(found_rule, '_status_message', status_text)
log.info(f"Updated status for SourceRule '{source_path}' (row {found_row}) to '{status_text}'")
# Emit dataChanged for the entire row to potentially trigger updates
# (e.g., delegates, background color based on status if implemented later)
start_index = self.createIndex(found_row, 0, found_rule)
end_index = self.createIndex(found_row, self.columnCount() - 1, found_rule)
self.dataChanged.emit(start_index, end_index, [Qt.DisplayRole]) # Emit for DisplayRole, maybe others needed later
except Exception as e:
log.exception(f"Error setting status attribute or emitting dataChanged for {source_path}: {e}")
else:
log.warning(f"Could not find SourceRule with path '{source_path}' to update status.")
# --- Placeholder for node finding method (Original Request - Replaced by direct list search above) --- # --- Placeholder for node finding method (Original Request - Replaced by direct list search above) ---
# Kept for reference, but the logic above directly searches self._source_rules # Kept for reference, but the logic above directly searches self._source_rules
# --- Drag and Drop Methods ---
def supportedDropActions(self) -> Qt.DropActions:
"""Specifies that only Move actions are supported."""
return Qt.MoveAction
def mimeTypes(self) -> list[str]:
"""Returns the list of supported MIME types for dragging."""
return [self.MIME_TYPE]
def mimeData(self, indexes: list[QModelIndex]) -> QMimeData:
"""Encodes information about the dragged FileRule items."""
mime_data = QMimeData()
encoded_data = QByteArray()
stream = QDataStream(encoded_data, QIODevice.OpenModeFlag.WriteOnly)
dragged_file_info = []
for index in indexes:
if not index.isValid() or index.column() != 0: # Only consider valid indices from the first column
continue
item = index.internalPointer()
if isinstance(item, FileRule):
parent_index = self.parent(index)
if parent_index.isValid():
# Store: source_row, source_parent_row, source_grandparent_row
# This allows reconstructing the index later
grandparent_index = self.parent(parent_index)
# Ensure grandparent_index is valid before accessing its row
if grandparent_index.isValid():
dragged_file_info.append((index.row(), parent_index.row(), grandparent_index.row()))
else:
# Handle case where grandparent is the root (shouldn't happen for FileRule, but safety)
# Or if parent() failed unexpectedly
log.warning(f"mimeData: Could not get valid grandparent index for FileRule at row {index.row()}, parent row {parent_index.row()}")
else:
log.warning(f"mimeData: Could not get parent index for FileRule at row {index.row()}")
# Write the number of items first, then each tuple
stream.writeInt8(len(dragged_file_info))
for info in dragged_file_info:
stream.writeInt8(info[0]) # source_row
stream.writeInt8(info[1]) # source_parent_row
stream.writeInt8(info[2]) # source_grandparent_row
mime_data.setData(self.MIME_TYPE, encoded_data)
log.debug(f"mimeData: Encoded {len(dragged_file_info)} FileRule indices.")
return mime_data
def canDropMimeData(self, data: QMimeData, action: Qt.DropAction, row: int, column: int, parent: QModelIndex) -> bool:
"""Checks if the data can be dropped at the specified location."""
if action != Qt.MoveAction or not data.hasFormat(self.MIME_TYPE):
return False
# Check if the drop target is a valid AssetRule
if not parent.isValid():
return False # Cannot drop onto root or SourceRule directly in this implementation
target_item = parent.internalPointer()
if not isinstance(target_item, AssetRule):
return False # Can only drop onto AssetRule items
# Optional: Prevent dropping onto the original parent? (Might be confusing)
# For now, allow dropping onto the same parent (moveFileRule handles this)
return True
def dropMimeData(self, data: QMimeData, action: Qt.DropAction, row: int, column: int, parent: QModelIndex) -> bool:
"""Handles the dropping of FileRule items onto an AssetRule."""
if not self.canDropMimeData(data, action, row, column, parent):
log.warning("dropMimeData: canDropMimeData check failed.")
return False
target_asset_item = parent.internalPointer()
if not isinstance(target_asset_item, AssetRule): # Should be caught by canDrop, but double-check
log.error("dropMimeData: Target item is not an AssetRule.")
return False
encoded_data = data.data(self.MIME_TYPE)
stream = QDataStream(encoded_data, QIODevice.OpenModeFlag.ReadOnly)
num_items = stream.readInt8()
source_indices_info = []
for _ in range(num_items):
source_row = stream.readInt8()
source_parent_row = stream.readInt8()
source_grandparent_row = stream.readInt8()
source_indices_info.append((source_row, source_parent_row, source_grandparent_row))
log.debug(f"dropMimeData: Decoded {len(source_indices_info)} source indices. Target Asset: '{target_asset_item.asset_name}'")
if not source_indices_info:
log.warning("dropMimeData: No valid source index information decoded.")
return False
# Keep track of original parents that might become empty
original_parents = set()
moved_files_new_indices = {} # Store new index after move for dataChanged emission
# --- BEGIN FIX: Reconstruct all source indices BEFORE the move loop ---
source_indices_to_process = []
log.debug("Reconstructing initial source indices...")
for src_row, src_parent_row, src_grandparent_row in source_indices_info:
grandparent_index = self.index(src_grandparent_row, 0, QModelIndex())
if not grandparent_index.isValid():
log.error(f"dropMimeData: Failed initial reconstruction of grandparent index (row {src_grandparent_row}). Skipping item.")
continue
old_parent_index = self.index(src_parent_row, 0, grandparent_index)
if not old_parent_index.isValid():
log.error(f"dropMimeData: Failed initial reconstruction of old parent index (row {src_parent_row}). Skipping item.")
continue
source_file_index = self.index(src_row, 0, old_parent_index)
if not source_file_index.isValid():
# Log the specific parent it failed under for better debugging
parent_name = getattr(old_parent_index.internalPointer(), 'asset_name', 'Unknown Parent')
log.error(f"dropMimeData: Failed initial reconstruction of source file index (original row {src_row}) under parent '{parent_name}'. Skipping item.")
continue
# Check if the reconstructed index actually points to a FileRule
item_check = source_file_index.internalPointer()
if isinstance(item_check, FileRule):
source_indices_to_process.append(source_file_index)
log.debug(f" Successfully reconstructed index for file: {Path(item_check.file_path).name}")
else:
log.warning(f"dropMimeData: Initial reconstructed index (row {src_row}) does not point to a FileRule. Skipping.")
log.debug(f"Successfully reconstructed {len(source_indices_to_process)} valid source indices.")
# --- END FIX ---
# Process moves using the pre-calculated valid indices
for source_file_index in source_indices_to_process: # Iterate through the valid indices
# Get the file item (already validated during reconstruction)
file_item = source_file_index.internalPointer()
# Track original parent for cleanup (using the valid index)
old_parent_index = self.parent(source_file_index) # Get parent from the valid index
if old_parent_index.isValid():
old_parent_asset = old_parent_index.internalPointer()
if isinstance(old_parent_asset, AssetRule):
# Need grandparent row for the tuple key
grandparent_index = self.parent(old_parent_index)
if grandparent_index.isValid():
original_parents.add((grandparent_index.row(), old_parent_asset.asset_name))
else: # Handle root case or error
log.warning(f"Could not get grandparent index for original parent '{old_parent_asset.asset_name}' during cleanup tracking.")
else:
log.warning(f"Parent of file '{Path(file_item.file_path).name}' is not an AssetRule.")
else:
log.warning(f"Could not get valid parent index for file '{Path(file_item.file_path).name}' during cleanup tracking.")
# Perform the move using the model's method and the valid source_file_index
if self.moveFileRule(source_file_index, parent): # 'parent' is the target_parent_asset_index
# --- Update Target Asset Override After Successful Move ---
# The file_item's parent_asset reference should now be updated by moveFileRule
new_parent_asset = getattr(file_item, 'parent_asset', None)
if new_parent_asset == target_asset_item: # Check if move was successful internally
if file_item.target_asset_name_override != target_asset_item.asset_name:
log.debug(f" Updating target override for '{Path(file_item.file_path).name}' to '{target_asset_item.asset_name}'")
file_item.target_asset_name_override = target_asset_item.asset_name
# Need the *new* index of the moved file to emit dataChanged
try:
new_row = target_asset_item.files.index(file_item)
new_file_index_col0 = self.index(new_row, 0, parent) # Index for col 0 under new parent
new_file_index_target_col = self.index(new_row, self.COL_TARGET_ASSET, parent) # Index for target col
if new_file_index_target_col.isValid():
moved_files_new_indices[file_item.file_path] = new_file_index_target_col # Use hashable file_path as key
else:
log.warning(f" Could not get valid *new* index for target column of moved file: {Path(file_item.file_path).name}")
except ValueError:
log.error(f" Could not find moved file '{Path(file_item.file_path).name}' in target parent's list after move.")
else:
log.error(f" Move reported success, but file's parent reference ('{getattr(new_parent_asset, 'asset_name', 'None')}') doesn't match target ('{target_asset_item.asset_name}'). Override not updated.")
else:
log.error(f"dropMimeData: moveFileRule failed for file '{Path(file_item.file_path).name}'.")
# If one move fails, should we stop? For now, continue processing others.
continue # Skip override update and cleanup check for this file
# --- Emit dataChanged for Target Asset column AFTER all moves ---
for source_path, new_index in moved_files_new_indices.items(): # Key is now source_path (string)
self.dataChanged.emit(new_index, new_index, [Qt.DisplayRole, Qt.EditRole])
# --- Cleanup: Remove any original parent AssetRules that are now empty ---
log.debug(f"dropMimeData: Checking original parents for cleanup: {list(original_parents)}") # Log tuples
for gp_row, asset_name in list(original_parents): # Iterate over a copy of tuples
try:
if 0 <= gp_row < len(self._source_rules):
source_rule = self._source_rules[gp_row]
# Find the asset rule within the correct source rule
asset_rule_to_check = next((asset for asset in source_rule.assets if asset.asset_name == asset_name), None)
if asset_rule_to_check and not asset_rule_to_check.files and asset_rule_to_check != target_asset_item: # Don't remove the target if it was also an original parent
log.info(f"dropMimeData: Attempting cleanup of now empty original parent: '{asset_rule_to_check.asset_name}'")
if not self.removeAssetRule(asset_rule_to_check):
log.warning(f"dropMimeData: Failed to remove empty original parent '{asset_rule_to_check.asset_name}'.")
elif not asset_rule_to_check:
log.warning(f"dropMimeData: Cleanup check failed. Could not find original parent asset '{asset_name}' in source rule at row {gp_row}.")
else:
log.warning(f"dropMimeData: Cleanup check failed. Invalid grandparent row index {gp_row} found in original_parents set.")
except Exception as e:
log.exception(f"dropMimeData: Error during cleanup check for parent '{asset_name}' (gp_row {gp_row}): {e}")
return True