GUI - File Type Keybinds And F2 Renaming

This commit is contained in:
Rusfort 2025-05-06 20:31:53 +02:00
parent ff548e902e
commit 9a27d23a4c
7 changed files with 530 additions and 73 deletions

View File

@ -28,6 +28,15 @@ python -m gui.main_window
* **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.
* **Keybinds for Item Management:** When items are selected in the Preview Table, the following keybinds can be used:
* `Ctrl + C`: Sets the file type of selected items to Color/Albedo (`MAP_COL`).
* `Ctrl + R`: Toggles the file type of selected items between Roughness (`MAP_ROUGH`) and Glossiness (`MAP_GLOSS`).
* `Ctrl + N`: Sets the file type of selected items to Normal (`MAP_NRM`).
* `Ctrl + M`: Toggles the file type of selected items between Metalness (`MAP_METAL`) and Reflection/Specular (`MAP_REFL`).
* `Ctrl + D`: Sets the file type of selected items to Displacement/Height (`MAP_DISP`).
* `Ctrl + E`: Sets the file type of selected items to Extra (`EXTRA`).
* `Ctrl + X`: Sets the file type of selected items to Ignore (`FILE_IGNORE`).
* `F2`: Prompts to set the asset name for all selected items. This name propagates to the `AssetRule` name or the `FileRule` `target_asset_name_override` for the files under the selected assets. If individual files are selected, it will affect their `target_asset_name_override`.
* **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.

View File

@ -7,6 +7,34 @@ This document provides technical details about the configuration system and the
The tool utilizes a two-tiered configuration system managed by the `configuration.py` module:
1. **Application Settings (`config/app_settings.json`):** This JSON file defines the core global default settings, constants, and rules that apply generally across different asset sources (e.g., the global `OUTPUT_DIRECTORY_PATTERN` and `OUTPUT_FILENAME_PATTERN`, standard image resolutions, map merge rules, output format rules, Blender paths, `FILE_TYPE_DEFINITIONS`, `ASSET_TYPE_DEFINITIONS`). See the [User Guide: Output Structure](../01_User_Guide/09_Output_Structure.md#available-tokens) for a list of available tokens for these patterns.
* **`FILE_TYPE_DEFINITIONS` Enhancements:**
* **`keybind` Property:** Each file type object within `FILE_TYPE_DEFINITIONS` can now optionally include a `keybind` property. This property accepts a single character string (e.g., `"C"`, `"R"`) representing the keyboard key. In the GUI, this key (typically combined with `Ctrl`, or standalone like `F2` for asset naming) is used as a shortcut to set or toggle the corresponding file type for selected items in the Preview Table.
*Example:*
```json
"MAP_COL": {
"description": "Color/Albedo Map",
"color": [200, 200, 200],
"examples": ["albedo", "col", "basecolor"],
"standard_type": "COL",
"bit_depth_rule": "respect",
"is_grayscale": false,
"keybind": "C"
},
```
* **New File Type `MAP_GLOSS`:** A new standard file type, `MAP_GLOSS`, has been added. It is typically configured as follows:
*Example:*
```json
"MAP_GLOSS": {
"description": "Glossiness Map",
"color": [180, 180, 220],
"examples": ["gloss", "gls"],
"standard_type": "GLOSS",
"bit_depth_rule": "respect",
"is_grayscale": true,
"keybind": "R"
}
```
Note: The `keybind` "R" for `MAP_GLOSS` is often shared with `MAP_ROUGH` to allow toggling between them.
2. **LLM Settings (`config/llm_settings.json`):** This JSON file contains settings specifically related to the LLM predictor, such as the API endpoint, model name, prompt template, and examples. These settings can be edited through the GUI using the `LLMEditorWidget`.
3. **Preset Files (`Presets/*.json`):** These JSON files define supplier-specific rules and overrides. They contain patterns to interpret filenames, classify map types, handle variants, define naming conventions, and specify other source-specific behaviors.

View File

@ -57,7 +57,8 @@
],
"standard_type": "COL",
"bit_depth_rule": "force_8bit",
"is_grayscale": false
"is_grayscale": false,
"keybind": "C"
},
"MAP_NRM": {
"description": "Normal Map",
@ -68,7 +69,8 @@
],
"standard_type": "NRM",
"bit_depth_rule": "respect",
"is_grayscale": false
"is_grayscale": false,
"keybind": "N"
},
"MAP_METAL": {
"description": "Metalness Map",
@ -79,7 +81,8 @@
],
"standard_type": "METAL",
"bit_depth_rule": "force_8bit",
"is_grayscale": true
"is_grayscale": true,
"keybind": "M"
},
"MAP_ROUGH": {
"description": "Roughness Map",
@ -91,7 +94,20 @@
],
"standard_type": "ROUGH",
"bit_depth_rule": "force_8bit",
"is_grayscale": true
"is_grayscale": true,
"keybind": "R"
},
"MAP_GLOSS": {
"description": "Glossiness Map",
"color": "#d6bfd6",
"examples": [
"_gloss.",
"_gls."
],
"standard_type": "GLOSS",
"bit_depth_rule": "force_8bit",
"is_grayscale": true,
"keybind": "R"
},
"MAP_AO": {
"description": "Ambient Occlusion Map",
@ -113,7 +129,8 @@
],
"standard_type": "DISP",
"bit_depth_rule": "respect",
"is_grayscale": true
"is_grayscale": true,
"keybind": "D"
},
"MAP_REFL": {
"description": "Reflection/Specular Map",
@ -124,7 +141,8 @@
],
"standard_type": "REFL",
"bit_depth_rule": "force_8bit",
"is_grayscale": true
"is_grayscale": true,
"keybind": "M"
},
"MAP_SSS": {
"description": "Subsurface Scattering Map",
@ -210,7 +228,8 @@
],
"standard_type": "",
"bit_depth_rule": "",
"is_grayscale": false
"is_grayscale": false,
"keybind": "E"
},
"FILE_IGNORE": {
"description": "File to be ignored",
@ -221,7 +240,8 @@
],
"standard_type": "",
"bit_depth_rule": "",
"is_grayscale": false
"is_grayscale": false,
"keybind": "X"
}
},
"TARGET_FILENAME_PATTERN": "{base_name}_{map_type}_{resolution}.{ext}",

View File

@ -559,6 +559,29 @@ class Configuration:
"""Returns the LLM request timeout in seconds from LLM settings."""
return self._llm_settings.get('llm_request_timeout', 120) # Default timeout
@property
def keybind_config(self) -> dict[str, list[str]]:
"""
Processes FILE_TYPE_DEFINITIONS to create a mapping of keybinds
to their associated file type keys.
Example: {'C': ['MAP_COL'], 'R': ['MAP_ROUGH', 'MAP_GLOSS']}
"""
keybinds = {}
file_type_defs = self._core_settings.get('FILE_TYPE_DEFINITIONS', {})
for ftd_key, ftd_value in file_type_defs.items():
if isinstance(ftd_value, dict) and 'keybind' in ftd_value:
key = ftd_value['keybind']
if key not in keybinds:
keybinds[key] = []
keybinds[key].append(ftd_key)
# Ensure toggleable keybinds have their file types in a consistent order if necessary
# For example, for 'R': ['MAP_ROUGH', 'MAP_GLOSS']
# The order from app_settings.json is generally preserved by dict iteration in Python 3.7+
# but explicit sorting could be added if a specific cycle order is critical beyond config file order.
# For now, we rely on the order they appear in the config.
return keybinds
# --- Standalone Base Config Functions ---
def load_base_config() -> dict:

View File

@ -18,40 +18,43 @@ class AssetRestructureHandler(QObject):
if not isinstance(model, UnifiedViewModel):
raise TypeError("AssetRestructureHandler requires a UnifiedViewModel instance.")
self.model = model
# Connect to the modified signal (passes FileRule object)
self.model.targetAssetOverrideChanged.connect(self.handle_target_asset_override)
# Connect to the new signal for AssetRule name changes
self.model.assetNameChanged.connect(self.handle_asset_name_changed)
log.debug("AssetRestructureHandler initialized.")
@Slot(QModelIndex, object)
def handle_target_asset_override(self, index: QModelIndex, new_target_path: object):
@Slot(FileRule, str, QModelIndex)
def handle_target_asset_override(self, file_rule_item: FileRule, new_target_name: str, index: QModelIndex): # Ensure FileRule is imported
"""
Slot connected to UnifiedViewModel.targetAssetOverrideChanged.
Orchestrates model changes based on the new target asset path.
Args:
index: The QModelIndex of the FileRule whose override changed.
new_target_path: The new target asset path (string or None).
file_rule_item: The FileRule object whose override changed.
new_target_name: The new target asset path (string).
index: The QModelIndex of the changed item (passed by the signal).
"""
log.debug(f"Handler received targetAssetOverrideChanged: Index=({index.row()},{index.column()}), New Path='{new_target_path}'")
if not index.isValid():
log.warning("Handler received invalid index. Aborting.")
if not isinstance(file_rule_item, FileRule): # Check the correct parameter
log.warning(f"Handler received targetAssetOverrideChanged for non-FileRule item: {type(file_rule_item)}. Aborting.")
return
file_item = self.model.getItem(index)
if not isinstance(file_item, FileRule):
log.warning(f"Handler received index for non-FileRule item: {type(file_item)}. Aborting.")
return
# Crucially, use file_rule_item for all logic. 'index' is for context or if model interaction is *unavoidable* (which it shouldn't be here).
log.debug(f"Handler received targetAssetOverrideChanged: OBJECT='{file_rule_item!r}', FILE_PATH='{file_rule_item.file_path}', NEW_NAME='{new_target_name}'")
# Ensure new_target_path is a string or None
new_target_name = str(new_target_path).strip() if new_target_path is not None else None
if new_target_name == "": new_target_name = None # Treat empty string as None
# Ensure new_target_name is a string or None (already string from signal, but good practice if it could be object)
effective_new_target_name = str(new_target_name).strip() if new_target_name is not None else None
if effective_new_target_name == "": effective_new_target_name = None # Treat empty string as None
# --- Get necessary context ---
old_parent_asset = getattr(file_item, 'parent_asset', None)
# Use file_rule_item directly
old_parent_asset = getattr(file_rule_item, 'parent_asset', None)
if not old_parent_asset:
log.error(f"Handler: File item '{Path(file_item.file_path).name}' has no parent asset. Cannot restructure.")
log.error(f"Handler: File item '{Path(file_rule_item.file_path).name}' has no parent asset. Cannot restructure.")
# Note: Data change already happened in setData, cannot easily revert here.
return
# Use file_rule_item directly
source_rule = getattr(old_parent_asset, 'parent_source', None)
if not source_rule:
log.error(f"Handler: Could not find SourceRule for parent asset '{old_parent_asset.asset_name}'. Cannot restructure.")
@ -59,80 +62,165 @@ class AssetRestructureHandler(QObject):
# --- Logic based on the new target name ---
target_parent_asset = None
target_parent_index = QModelIndex()
target_parent_index = QModelIndex() # This will be the QModelIndex of the target AssetRule
move_occurred = False
# 1. Find existing target parent AssetRule within the same SourceRule
if new_target_name:
if effective_new_target_name:
for i, asset in enumerate(source_rule.assets):
if asset.asset_name == new_target_name:
if asset.asset_name == effective_new_target_name:
target_parent_asset = asset
# Get index for the target parent
# Get QModelIndex for the target parent AssetRule
try:
source_rule_row = self.model._source_rules.index(source_rule)
source_rule_index = self.model.createIndex(source_rule_row, 0, source_rule)
target_parent_index = self.model.index(i, 0, source_rule_index)
target_parent_index = self.model.index(i, 0, source_rule_index) # QModelIndex for the target AssetRule
if not target_parent_index.isValid():
log.error(f"Handler: Failed to create valid index for existing target parent '{new_target_name}'.")
log.error(f"Handler: Failed to create valid QModelIndex for existing target parent '{effective_new_target_name}'.")
target_parent_asset = None # Reset if index is invalid
except ValueError:
log.error(f"Handler: Could not find SourceRule index while looking for target parent '{new_target_name}'.")
log.error(f"Handler: Could not find SourceRule index while looking for target parent '{effective_new_target_name}'.")
target_parent_asset = None # Reset if index is invalid
break # Found the asset
# 2. Handle Move or Creation
if target_parent_asset:
if target_parent_asset: # An existing AssetRule to move to was found
# --- Move to Existing Parent ---
if target_parent_asset != old_parent_asset:
log.info(f"Handler: Moving file '{Path(file_item.file_path).name}' to existing asset '{target_parent_asset.asset_name}'.")
if self.model.moveFileRule(index, target_parent_index):
log.info(f"Handler: Moving file '{Path(file_rule_item.file_path).name}' to existing asset '{target_parent_asset.asset_name}'.")
# The 'index' parameter IS the QModelIndex of the FileRule being changed.
# No need to re-fetch or re-validate it if the signal emits it correctly.
# The core issue was using a stale index to get the *object*, now we *have* the object.
source_file_qmodelindex = index # Use the index passed by the signal
if not source_file_qmodelindex or not source_file_qmodelindex.isValid(): # Should always be valid if signal emits it
log.error(f"Handler: Received invalid QModelIndex for source file '{Path(file_rule_item.file_path).name}'. Cannot move.")
return
if self.model.moveFileRule(source_file_qmodelindex, target_parent_index): # target_parent_index is for the AssetRule
move_occurred = True
else:
log.error(f"Handler: Model failed to move file rule to existing asset '{target_parent_asset.asset_name}'.")
# Consider how to handle failure - maybe log and continue to cleanup?
else:
# Target is the same as the old parent. No move needed.
log.debug(f"Handler: Target asset '{new_target_name}' is the same as the current parent. No move required.")
pass # No move needed, but might still need cleanup if old parent becomes empty later (unlikely in this specific case)
log.debug(f"Handler: Target asset '{effective_new_target_name}' is the same as the current parent. No move required.")
elif new_target_name: # Only create if a *new* specific target name was given
elif effective_new_target_name: # No existing AssetRule found, but a new name is provided. Create it.
# --- Create New Parent AssetRule and Move ---
log.info(f"Handler: Creating new asset '{new_target_name}' and moving file '{Path(file_item.file_path).name}'.")
# Create the new asset rule using the model's method
new_asset_index = self.model.createAssetRule(source_rule, new_target_name, copy_from_asset=old_parent_asset)
log.info(f"Handler: Creating new asset '{effective_new_target_name}' and moving file '{Path(file_rule_item.file_path).name}'.")
new_asset_qmodelindex = self.model.createAssetRule(source_rule, effective_new_target_name, copy_from_asset=old_parent_asset)
if new_asset_index.isValid():
# Now move the file to the newly created asset
if self.model.moveFileRule(index, new_asset_index):
if new_asset_qmodelindex.isValid():
target_parent_asset = new_asset_qmodelindex.internalPointer() # Get the newly created AssetRule object
target_parent_index = new_asset_qmodelindex # The QModelIndex of the new AssetRule
source_file_qmodelindex = index # Use the index passed by the signal
if not source_file_qmodelindex or not source_file_qmodelindex.isValid(): # Should always be valid
log.error(f"Handler: Received invalid QModelIndex for source file '{Path(file_rule_item.file_path).name}'. Cannot move to new asset.")
self.model.removeAssetRule(target_parent_asset) # Attempt to clean up newly created asset
return
if self.model.moveFileRule(source_file_qmodelindex, target_parent_index): # Move to the new AssetRule
move_occurred = True
target_parent_asset = new_asset_index.internalPointer() # Update for cleanup check
else:
log.error(f"Handler: Model failed to move file rule to newly created asset '{new_target_name}'.")
# If move fails after creation, should we remove the created asset? Maybe.
# For now, just log the error.
log.error(f"Handler: Model failed to move file rule to newly created asset '{effective_new_target_name}'.")
# Consider removing the newly created asset if the move fails
self.model.removeAssetRule(target_parent_asset) # Attempt to clean up
else:
log.error(f"Handler: Model failed to create new asset rule '{new_target_name}'. Cannot move file.")
log.error(f"Handler: Model failed to create new asset rule '{effective_new_target_name}'. Cannot move file.")
else: # new_target_name is None or empty
# --- Moving back to original/default parent (Clearing Override) ---
# The file *should* already be under its original parent if the override was just cleared.
# However, if it was previously moved *away* from its original parent due to an override,
# clearing the override *should* ideally move it back.
# This logic is complex: we need to know the *original* parent before any overrides.
# The current structure doesn't explicitly store this.
# For now, assume clearing the override means it stays in its *current* parent,
# and we only handle cleanup if that parent becomes empty.
# A more robust solution might involve finding the asset matching the file's *directory* name.
log.debug(f"Handler: Target asset override cleared for '{Path(file_item.file_path).name}'. File remains in parent '{old_parent_asset.asset_name}'.")
# No move occurs in this simplified interpretation.
else: # effective_new_target_name is None or empty (override cleared)
log.debug(f"Handler: Target asset override cleared for '{Path(file_rule_item.file_path).name}'. File remains in parent '{old_parent_asset.asset_name}'.")
# No move occurs in this interpretation if the override is simply cleared.
# The file_rule_item.target_asset_name_override is now None (set by model.setData).
# 3. Cleanup Empty Old Parent (only if a move occurred)
# Check the old_parent_asset *after* the potential move
if move_occurred and old_parent_asset and not old_parent_asset.files:
# 3. Cleanup Empty Old Parent (only if a move occurred and old parent is now empty)
if move_occurred and old_parent_asset and not old_parent_asset.files and old_parent_asset != target_parent_asset:
log.info(f"Handler: Attempting to remove empty old parent asset '{old_parent_asset.asset_name}'.")
if not self.model.removeAssetRule(old_parent_asset):
log.warning(f"Handler: Model failed to remove empty old parent asset '{old_parent_asset.asset_name}'.")
elif move_occurred:
log.debug(f"Handler: Old parent asset '{old_parent_asset.asset_name}' still contains files. No removal needed.")
log.debug(f"Handler: Old parent asset '{old_parent_asset.asset_name}' still contains files or is the target. No removal needed.")
log.debug(f"Handler finished processing targetAssetOverrideChanged for '{Path(file_item.file_path).name}'.")
log.debug(f"Handler finished processing targetAssetOverrideChanged for '{Path(file_rule_item.file_path).name}'.")
def _get_qmodelindex_for_item(self, item_to_find):
"""
Helper to find the QModelIndex for a given FileRule or AssetRule item.
Returns a valid QModelIndex or QModelIndex() if not found/invalid.
"""
if isinstance(item_to_find, FileRule):
parent_asset = getattr(item_to_find, 'parent_asset', None)
if not parent_asset: return QModelIndex()
source_rule = getattr(parent_asset, 'parent_source', None)
if not source_rule: return QModelIndex()
try:
source_rule_row = self.model._source_rules.index(source_rule)
source_rule_index = self.model.createIndex(source_rule_row, 0, source_rule)
if not source_rule_index.isValid(): return QModelIndex()
parent_asset_row = source_rule.assets.index(parent_asset)
parent_asset_index = self.model.index(parent_asset_row, 0, source_rule_index)
if not parent_asset_index.isValid(): return QModelIndex()
item_row = parent_asset.files.index(item_to_find)
return self.model.index(item_row, 0, parent_asset_index)
except ValueError:
log.error(f"Error finding item {item_to_find} in model hierarchy during QModelIndex reconstruction.")
return QModelIndex()
elif isinstance(item_to_find, AssetRule):
source_rule = getattr(item_to_find, 'parent_source', None)
if not source_rule: return QModelIndex()
try:
source_rule_row = self.model._source_rules.index(source_rule)
source_rule_index = self.model.createIndex(source_rule_row, 0, source_rule)
if not source_rule_index.isValid(): return QModelIndex()
item_row = source_rule.assets.index(item_to_find)
return self.model.index(item_row, 0, source_rule_index)
except ValueError:
log.error(f"Error finding asset {item_to_find.asset_name} in model hierarchy during QModelIndex reconstruction.")
return QModelIndex()
return QModelIndex()
@Slot(AssetRule, str, QModelIndex) # Updated signature
def handle_asset_name_changed(self, asset_rule_item: AssetRule, new_name: str, index: QModelIndex): # Ensure AssetRule is imported
"""
Slot connected to UnifiedViewModel.assetNameChanged.
Handles logic when an AssetRule's name is changed.
Args:
asset_rule_item: The AssetRule object whose name changed.
new_name: The new name of the asset.
index: The QModelIndex of the changed AssetRule item.
"""
if not isinstance(asset_rule_item, AssetRule):
log.warning(f"Handler received assetNameChanged for non-AssetRule item: {type(asset_rule_item)}. Aborting.")
return
# The 'old_name' is not directly passed by the new signal signature.
# If needed, it would have to be inferred or stored prior to the change.
# However, the model's setData already handles updating child FileRule targets.
# This handler's main job is to react to the AssetRule object itself.
log.debug(f"Handler received assetNameChanged: OBJECT='{asset_rule_item!r}', ASSET_NAME='{asset_rule_item.asset_name}', NEW_NAME='{new_name}'")
# The UnifiedViewModel.setData has already updated FileRule.target_asset_name_override
# for any FileRules that were pointing to the *old* asset name across the entire model.
# The primary purpose of this handler slot, given the problem description,
# is to ensure that if any restructuring or disk operations were tied to an AssetRule's
# name, they would now correctly use 'asset_rule_item' (the actual object)
# and 'new_name'.
# For this specific task, confirming correct identification is key.
# If this handler were also responsible for renaming directories on disk,
# this is where that logic would go, using asset_rule_item and new_name.
# The old name would need to be retrieved differently if essential for such an operation,
# e.g. by storing it temporarily before the model's setData commits the change,
# or by having the signal pass it (which it currently doesn't in the revised design).
# For now, the model handles the critical part of updating linked FileRules.
log.info(f"Handler correctly identified AssetRule '{new_name}' for processing using the direct object. Model's setData handles related FileRule target updates.")

View File

@ -19,6 +19,7 @@ from PySide6.QtWidgets import (
)
from PySide6.QtCore import Qt, QThread, Slot, Signal, QObject, QModelIndex, QItemSelectionModel, QPoint, QTimer # Added Signal, QObject, QModelIndex, QItemSelectionModel, QPoint, QTimer
from PySide6.QtGui import QColor, QAction, QPalette, QClipboard # Add QColor import, QAction, QPalette, QClipboard
from PySide6.QtGui import QKeySequence
# --- Local GUI Imports ---
from .preset_editor_widget import PresetEditorWidget
@ -34,7 +35,7 @@ from rule_structure import SourceRule, AssetRule, FileRule # Import Rule Structu
# --- 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.unified_view_model import UnifiedViewModel, CustomRoles # Import the new unified model and CustomRoles
# Removed delegate imports, now handled by MainPanelWidget
from .prediction_handler import RuleBasedPredictionHandler # Corrected import path
from .llm_interaction_handler import LLMInteractionHandler # Import the new handler
@ -230,12 +231,39 @@ class MainWindow(QMainWindow):
# --- Connect Model Signals ---
self.unified_model.targetAssetOverrideChanged.connect(self.restructure_handler.handle_target_asset_override)
self.unified_model.assetNameChanged.connect(self.restructure_handler.handle_asset_name_changed) # Added connection
# --- Connect LLM Editor Signals ---
self.llm_editor_widget.settings_saved.connect(self._on_llm_settings_saved) # Connect save signal
# --- Adjust Splitter ---
self.splitter.setSizes([400, 800]) # Initial size ratio
# --- Initialize Keybind Map ---
self.key_char_to_qt_key = {
'C': Qt.Key_C, 'R': Qt.Key_R, 'N': Qt.Key_N, 'M': Qt.Key_M,
'D': Qt.Key_D, 'E': Qt.Key_E, 'X': Qt.Key_X
}
self.qt_key_to_ftd_map = {}
try:
base_settings = load_base_config()
file_type_defs = base_settings.get('FILE_TYPE_DEFINITIONS', {})
for ftd_key, ftd_value in file_type_defs.items():
if isinstance(ftd_value, dict) and 'keybind' in ftd_value:
char_key = ftd_value['keybind']
qt_key_val = self.key_char_to_qt_key.get(char_key)
if qt_key_val:
if qt_key_val not in self.qt_key_to_ftd_map:
self.qt_key_to_ftd_map[qt_key_val] = []
# Ensure consistent order for toggleable types if they are defined together under one key
# For example, if 'R' maps to ROUGH then GLOSS, they should appear in that order.
# This relies on the order in app_settings.json and dict iteration (Python 3.7+).
self.qt_key_to_ftd_map[qt_key_val].append(ftd_key)
log.info(f"Loaded keybind map: {self.qt_key_to_ftd_map}")
except Exception as e:
log.error(f"Failed to load keybind configurations: {e}")
# self.qt_key_to_ftd_map will be empty, keybinds won't work.
# --- UI Setup Methods ---
# setup_editor_panel_ui, _create_editor_general_tab, _create_editor_mapping_tab moved to PresetEditorWidget
@ -1312,11 +1340,209 @@ class MainWindow(QMainWindow):
log.warning("get_llm_source_preset_name called before preset_editor_widget was initialized.")
return None
def keyPressEvent(self, event):
"""Handles key press events for implementing keybinds."""
log.debug(f"KeyPressEvent: key={event.key()}, modifiers={event.modifiers()}, text='{event.text()}'")
if not self.main_panel_widget or not self.unified_model:
log.warning("Key press ignored: Main panel or unified model not available.")
super().keyPressEvent(event)
return
selected_view_indexes = self.main_panel_widget.unified_view.selectionModel().selectedIndexes()
if not selected_view_indexes:
log.debug("Key press ignored: No items selected.")
super().keyPressEvent(event)
return
# Get unique model indexes (typically one per selected row)
# Assuming unified_view uses unified_model directly or proxy maps correctly
model_indexes_to_process = []
unique_rows = set()
for view_idx in selected_view_indexes:
# If using a proxy:
# model_idx = self.main_panel_widget.unified_view.model().mapToSource(view_idx)
model_idx = view_idx # Assuming direct model usage for now
if model_idx.row() not in unique_rows: # Process each underlying model row only once
# Ensure we are getting the index for column 0 if multiple columns are selected for the same row
model_indexes_to_process.append(self.unified_model.index(model_idx.row(), 0, model_idx.parent()))
unique_rows.add(model_idx.row())
if not model_indexes_to_process:
super().keyPressEvent(event)
return
pressed_key = event.key()
modifiers = event.modifiers()
keybind_processed = False
# --- Asset Name Keybind (F2) ---
if pressed_key == Qt.Key_F2 and not modifiers: # No modifiers for F2
log.debug("F2 pressed for asset name change.")
# Get current asset name from the first selected item as a suggestion
first_selected_item_index = model_indexes_to_process[0] # This is a col 0 index
first_item_object = self.unified_model.getItem(first_selected_item_index)
current_name_suggestion = ""
if isinstance(first_item_object, AssetRule):
# For AssetRule, its name is in COL_NAME (which is first_selected_item_index's column, typically 0)
# The index itself (first_selected_item_index) can be used as it's for COL_NAME.
current_name_suggestion = self.unified_model.data(first_selected_item_index, Qt.DisplayRole) or ""
elif isinstance(first_item_object, FileRule):
# For FileRule, its target asset name override is in COL_TARGET_ASSET
target_asset_col_idx = self.unified_model.COL_TARGET_ASSET
target_asset_index_for_suggestion = first_selected_item_index.siblingAtColumn(target_asset_col_idx)
current_name_suggestion = self.unified_model.data(target_asset_index_for_suggestion, Qt.DisplayRole) or ""
new_name_input, ok = QInputDialog.getText(self, "Set Name", "Enter new name for selected items:", QLineEdit.EchoMode.Normal, current_name_suggestion)
if ok and new_name_input is not None:
stripped_name = new_name_input.strip()
if stripped_name:
log.info(f"User entered new name: '{stripped_name}' for selected items.")
# Step 1: Collect Objects
initial_selected_indices = self.main_panel_widget.unified_view.selectedIndexes()
objects_to_rename = []
processed_rows_for_object_collection = set() # To avoid processing same underlying item multiple times if multiple columns selected
for view_idx in initial_selected_indices:
# Assuming direct model usage or correct proxy mapping by the view
model_idx_for_item = self.unified_model.index(view_idx.row(), 0, view_idx.parent()) # Get column 0 index
if model_idx_for_item.row() not in processed_rows_for_object_collection:
item = self.unified_model.getItem(model_idx_for_item)
if isinstance(item, (AssetRule, FileRule)):
objects_to_rename.append(item)
processed_rows_for_object_collection.add(model_idx_for_item.row())
else:
log.debug(f"F2 RENAME: Skipping item {item!r} (type: {type(item)}) during object collection as it's not AssetRule or FileRule.")
log.debug(f"F2 RENAME: Collected {len(objects_to_rename)} AssetRule/FileRule objects to rename.")
# Step 2: Iterate Over Objects and Update
successful_renames = 0
for item_object in objects_to_rename:
current_model_index = self.unified_model.findIndexForItem(item_object)
if current_model_index is None or not current_model_index.isValid():
item_repr = getattr(item_object, 'asset_name', getattr(item_object, 'file_path', repr(item_object)))
log.warning(f"F2 RENAME: Could not find current index for item {item_repr!r}. It might have been moved/deleted unexpectedly. Skipping.")
continue
target_column = -1
item_description_for_log = ""
if isinstance(item_object, AssetRule):
target_column = self.unified_model.COL_NAME
item_description_for_log = f"AssetRule '{item_object.asset_name}'"
elif isinstance(item_object, FileRule):
target_column = self.unified_model.COL_TARGET_ASSET
item_description_for_log = f"FileRule '{Path(item_object.file_path).name}'"
if target_column == -1:
log.warning(f"F2 RENAME: Unknown item type for {item_object!r}. Cannot determine target column. Skipping.")
continue
index_to_update_in_column = current_model_index.siblingAtColumn(target_column)
log.debug(f"F2 RENAME: Attempting to set new name '{stripped_name}' for {item_description_for_log} at index r={index_to_update_in_column.row()}, c={index_to_update_in_column.column()}")
success = self.unified_model.setData(index_to_update_in_column, stripped_name, Qt.EditRole)
if success:
successful_renames += 1
log.info(f"F2 RENAME: Successfully renamed {item_description_for_log} to '{stripped_name}'.")
else:
log.warning(f"F2 RENAME: Failed to rename {item_description_for_log} to '{stripped_name}'. setData returned False.")
self.statusBar().showMessage(f"{successful_renames} item(s) renamed to '{stripped_name}'.", 3000)
keybind_processed = True
else:
log.debug("Asset name change aborted: name was empty after stripping.")
else:
log.debug("Asset name change cancelled or empty name entered.")
event.accept()
return
# --- File Type Keybinds (Ctrl + Key) ---
if modifiers == Qt.ControlModifier:
log.debug(f"Ctrl modifier detected with key: {pressed_key}")
qt_key_sequence_str = QKeySequence(pressed_key).toString() # For logging
if pressed_key in self.qt_key_to_ftd_map:
target_ftd_keys = self.qt_key_to_ftd_map[pressed_key]
log.debug(f"Keybind match: Ctrl+{qt_key_sequence_str} maps to FTDs: {target_ftd_keys}")
if not target_ftd_keys:
log.warning(f"No FTDs configured for key Ctrl+{qt_key_sequence_str}")
super().keyPressEvent(event)
return
# self.unified_model.beginResetModel() # Potentially too broad
for index in model_indexes_to_process:
item = self.unified_model.getItem(index) # index is for col 0
# Check if the item is a FileRule instance
# --- BEGIN ADDED LOGGING ---
log.debug(f"Processing item for keybind: row={index.row()}, column={index.column()}")
log.debug(f" Item object: {item!r}") # !r calls __repr__
log.debug(f" Item type: {type(item)}")
log.debug(f" Is instance of FileRule: {isinstance(item, FileRule)}")
if hasattr(item, '__dict__'): # Log attributes if it's a custom object
log.debug(f" Item attributes: {item.__dict__}")
# --- END ADDED LOGGING ---
if not isinstance(item, FileRule): # This is the existing check
log.debug(f"Skipping item at row {index.row()} because it's not a FileRule instance (actual type: {type(item)}).")
continue
# Get current map type using COL_ITEM_TYPE and DisplayRole
item_type_display_index = self.unified_model.index(index.row(), self.unified_model.COL_ITEM_TYPE, index.parent())
current_map_type = self.unified_model.data(item_type_display_index, Qt.DisplayRole)
log.debug(f"Item at row {index.row()} ({Path(item.file_path).name}), current map_type (DisplayRole): '{current_map_type}'")
new_map_type = ""
if len(target_ftd_keys) == 1: # Single target type
new_map_type = target_ftd_keys[0]
log.debug(f" Single target FTD: '{new_map_type}'")
else: # Toggle logic for multiple target types
log.debug(f" Toggle FTDs: {target_ftd_keys}. Current: '{current_map_type}'")
try:
current_ftd_index = target_ftd_keys.index(current_map_type)
next_ftd_index = (current_ftd_index + 1) % len(target_ftd_keys)
new_map_type = target_ftd_keys[next_ftd_index]
log.debug(f" Calculated next FTD: '{new_map_type}'")
except ValueError: # current_map_type is not in the toggle list
new_map_type = target_ftd_keys[0] # Default to the first one
log.debug(f" Current not in toggle list, defaulting to first: '{new_map_type}'")
if new_map_type and new_map_type != current_map_type:
log.debug(f" Updating item at row {index.row()} ({Path(item.file_path).name}) from '{current_map_type}' to '{new_map_type}'")
# Set new map type using COL_ITEM_TYPE and EditRole
item_type_edit_index = self.unified_model.index(index.row(), self.unified_model.COL_ITEM_TYPE, index.parent())
success = self.unified_model.setData(item_type_edit_index, new_map_type, Qt.EditRole)
log.debug(f" setData call successful: {success}")
elif not new_map_type:
log.debug(f" Skipping update for item at row {index.row()}, new_map_type is empty.")
else: # new_map_type == current_map_type
log.debug(f" Skipping update for item at row {index.row()}, new_map_type ('{new_map_type}') is same as current ('{current_map_type}').")
# self.unified_model.endResetModel() # Potentially too broad
# The model should emit dataChanged for each setData call.
self.statusBar().showMessage(f"File types updated for selected items.", 3000)
keybind_processed = True
event.accept()
return
if not keybind_processed:
log.debug("Key press not handled by custom keybinds, passing to super.")
super().keyPressEvent(event)
# --- Main Execution ---
# --- Main Execution ---
def run_gui():
"""Initializes and runs the Qt application."""
print("--- Reached run_gui() ---")
# Ensure QInputDialog is imported if not already at the top
# from PySide6.QtWidgets import QInputDialog (already handled by being part of PySide6.QtWidgets import *)
from PySide6.QtGui import QKeySequence # Ensure QKeySequence is imported if used standalone
app = QApplication(sys.argv)
#app.setStyle('Fusion')

View File

@ -8,6 +8,10 @@ from rule_structure import SourceRule, AssetRule, FileRule # Removed AssetType,
from configuration import load_base_config # Import load_base_config
from typing import List # Added for type hinting
class CustomRoles:
MapTypeRole = Qt.UserRole + 1
TargetAssetRole = Qt.UserRole + 2
# Add other custom roles here as needed
class UnifiedViewModel(QAbstractItemModel):
# --- Color Constants for Row Backgrounds ---
# Old colors removed, using config now + fixed source color
@ -19,8 +23,12 @@ class UnifiedViewModel(QAbstractItemModel):
of SourceRule -> AssetRule -> FileRule.
"""
# Signal emitted when a FileRule's target asset override changes.
# Carries the index of the FileRule and the new target asset path (or None).
targetAssetOverrideChanged = Signal(QModelIndex, object)
# Carries the FileRule object and the new target asset path (or None).
targetAssetOverrideChanged = Signal(FileRule, str, QModelIndex) # Emit FileRule object, new value, and index
# Signal emitted when an AssetRule's name changes.
# Carries the AssetRule object, the new name, and the index.
assetNameChanged = Signal(AssetRule, str, QModelIndex)
Columns = [
"Name", "Target Asset", "Supplier",
@ -362,6 +370,8 @@ class UnifiedViewModel(QAbstractItemModel):
old_asset_name = item.asset_name
item.asset_name = new_asset_name
changed = True
# Emit signal for asset name change, including the index
self.assetNameChanged.emit(item, new_asset_name, index)
# --- Update Child FileRule Target Asset Overrides ---
log.debug(f"setData: Updating FileRule target overrides from '{old_asset_name}' to '{new_asset_name}'")
@ -408,9 +418,10 @@ class UnifiedViewModel(QAbstractItemModel):
item.target_asset_name_override = new_value
changed = True
# Emit signal that the override changed, let handler deal with restructuring
self.targetAssetOverrideChanged.emit(index, new_value)
# Pass the FileRule item itself, the new value, and the index
self.targetAssetOverrideChanged.emit(item, new_value, index)
elif column == self.COL_ITEM_TYPE: # Item-Type Override
# Delegate provides string value (e.g., "MAP_COL") or None
# Delegate provides string value (e.g., "MAP_COL") or None
new_value = str(value) if value is not None else None
if new_value == "": new_value = None # Treat empty string as None
# Update item_type_override
@ -784,11 +795,63 @@ class UnifiedViewModel(QAbstractItemModel):
"""Returns the cached list of file type keys."""
return self._file_type_keys
def findIndexForItem(self, target_item_object) -> QModelIndex | None:
"""
Finds the QModelIndex for a given item object (SourceRule, AssetRule, or FileRule)
by traversing the model's internal tree structure.
Args:
target_item_object: The specific SourceRule, AssetRule, or FileRule object to find.
Returns:
QModelIndex for the item if found, otherwise None.
"""
if target_item_object is None:
return None
for sr_row, source_rule in enumerate(self._source_rules):
if source_rule is target_item_object:
return self.createIndex(sr_row, 0, source_rule) # Top-level item
parent_source_rule_index = self.createIndex(sr_row, 0, source_rule) # Potential parent for children
if not parent_source_rule_index.isValid(): # Should always be valid here
log.error(f"findIndexForItem: Could not create valid index for SourceRule: {source_rule.input_path}")
continue
for ar_row, asset_rule in enumerate(source_rule.assets):
if asset_rule is target_item_object:
return self.index(ar_row, 0, parent_source_rule_index)
parent_asset_rule_index = self.index(ar_row, 0, parent_source_rule_index)
if not parent_asset_rule_index.isValid():
log.error(f"findIndexForItem: Could not create valid index for AssetRule: {asset_rule.asset_name}")
continue # Skip children if parent index is invalid
for fr_row, file_rule in enumerate(asset_rule.files):
if file_rule is target_item_object:
return self.index(fr_row, 0, parent_asset_rule_index)
log.debug(f"findIndexForItem: Item {target_item_object!r} not found in the model.")
return None
# --- removeAssetRule continued (log.debug was separated by the insert) ---
# This log line belongs to the removeAssetRule method defined earlier.
# It's being re-indented here to its correct place if it was part of that method's flow.
# However, looking at the original structure, the `return True` for removeAssetRule
# was at line 802, and the log.debug was at 798. This indicates the log.debug
# was likely the *start* of the problematic section in the previous attempt,
# and the `return True` was the end of `removeAssetRule`.
# The `log.debug` at original line 798 should be part of `removeAssetRule`'s positive path.
# The `return True` at original line 802 should be the final return of `removeAssetRule`.
# Correcting the end of removeAssetRule:
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)
source_rule.assets.pop(asset_row_for_removal)
self.endRemoveRows()
return True
return True # This was the original end of removeAssetRule
def update_status(self, source_path: str, status_text: str):
"""
Finds the SourceRule node for the given source_path and updates its status.