469 lines
24 KiB
Python
469 lines
24 KiB
Python
import logging # Import logging
|
|
import time # For logging timestamps
|
|
from PySide6.QtCore import QAbstractTableModel, Qt, QModelIndex, QSortFilterProxyModel, QThread # Import QThread
|
|
from PySide6.QtGui import QColor
|
|
|
|
log = logging.getLogger(__name__) # Get logger
|
|
|
|
# Define colors for alternating asset groups
|
|
COLOR_ASSET_GROUP_1 = QColor("#292929") # Dark grey 1
|
|
COLOR_ASSET_GROUP_2 = QColor("#343434") # Dark grey 2
|
|
|
|
# Define text colors for statuses
|
|
class PreviewTableModel(QAbstractTableModel):
|
|
"""
|
|
Custom table model for the GUI preview table.
|
|
Holds detailed file prediction results or a simple list of source assets.
|
|
"""
|
|
# Define text colors for statuses
|
|
STATUS_COLORS = {
|
|
"Mapped": QColor("#9dd9db"),
|
|
"Ignored": QColor("#c1753d"),
|
|
"Extra": QColor("#cfdca4"),
|
|
"Unrecognised": QColor("#92371f"),
|
|
"Model": QColor("#a4b8dc"),
|
|
"Unmatched Extra": QColor("#777777"),
|
|
"Error": QColor(Qt.GlobalColor.red),
|
|
"[No Status]": None # Use default color for no status
|
|
}
|
|
|
|
# Define column roles for clarity (Detailed Mode)
|
|
COL_STATUS = 0
|
|
COL_PREDICTED_ASSET = 1
|
|
COL_ORIGINAL_PATH = 2
|
|
COL_PREDICTED_OUTPUT = 3 # Kept for internal data access, but hidden in view
|
|
COL_DETAILS = 4
|
|
COL_ADDITIONAL_FILES = 5 # New column for ignored/extra files
|
|
|
|
# Define internal data roles for sorting/filtering
|
|
ROLE_RAW_STATUS = Qt.ItemDataRole.UserRole + 1
|
|
ROLE_SOURCE_ASSET = Qt.ItemDataRole.UserRole + 2
|
|
|
|
# Column for Simple Mode
|
|
COL_SIMPLE_PATH = 0
|
|
|
|
def __init__(self, data=None, parent=None):
|
|
super().__init__(parent)
|
|
log.debug("PreviewTableModel initialized.")
|
|
# Data format: List of dictionaries, each representing a file's details
|
|
# Example: {'original_path': '...', 'predicted_asset_name': '...', 'predicted_output_name': '...', 'status': '...', 'details': '...', 'source_asset': '...'}
|
|
self._data = [] # Keep the original flat data for reference if needed, but not for display
|
|
self._table_rows = [] # New structure for displaying rows
|
|
self._simple_data = [] # List of unique source asset paths for simple mode
|
|
self._simple_mode = False # Flag to toggle between detailed and simple view
|
|
self._headers_detailed = ["Status", "Predicted Asset", "Original Path", "Predicted Output", "Details", "Additional Files"] # Added new column header
|
|
self._sorted_unique_assets = [] # Store sorted unique asset names for coloring
|
|
self._headers_simple = ["Input Path"]
|
|
self.set_data(data or []) # Initialize data and simple_data
|
|
|
|
def set_simple_mode(self, enabled: bool):
|
|
"""Toggles the model between detailed and simple view modes."""
|
|
thread_id = QThread.currentThread() # Get current thread object
|
|
log.info(f"[{time.time():.4f}][T:{thread_id}] --> Entered PreviewTableModel.set_simple_mode(enabled={enabled}). Current mode: {self._simple_mode}")
|
|
if self._simple_mode != enabled:
|
|
log.info(f"[{time.time():.4f}][T:{thread_id}] Calling beginResetModel()...")
|
|
self.beginResetModel()
|
|
log.info(f"[{time.time():.4f}][T:{thread_id}] Returned from beginResetModel(). Setting mode.")
|
|
self._simple_mode = enabled
|
|
log.info(f"[{time.time():.4f}][T:{thread_id}] Mode changed to: {self._simple_mode}. Calling endResetModel()...")
|
|
self.endResetModel()
|
|
log.info(f"[{time.time():.4f}][T:{thread_id}] Returned from endResetModel().")
|
|
else:
|
|
log.info(f"[{time.time():.4f}][T:{thread_id}] PreviewTableModel mode is already as requested. No change.")
|
|
log.info(f"[{time.time():.4f}][T:{thread_id}] <-- Exiting PreviewTableModel.set_simple_mode.")
|
|
|
|
|
|
def rowCount(self, parent=QModelIndex()):
|
|
"""Returns the number of rows in the model."""
|
|
if parent.isValid():
|
|
return 0
|
|
row_count = len(self._simple_data) if self._simple_mode else len(self._table_rows) # Use _table_rows for detailed mode
|
|
# log.debug(f"PreviewTableModel.rowCount called. Mode: {self._simple_mode}, Row Count: {row_count}")
|
|
return row_count
|
|
|
|
def columnCount(self, parent=QModelIndex()):
|
|
"""Returns the number of columns in the model."""
|
|
if parent.isValid():
|
|
return 0
|
|
col_count = len(self._headers_simple) if self._simple_mode else len(self._headers_detailed) # Use updated headers_detailed
|
|
# log.debug(f"PreviewTableModel.columnCount called. Mode: {self._simple_mode}, Column Count: {col_count}")
|
|
return col_count
|
|
|
|
def data(self, index: QModelIndex, role: int = Qt.ItemDataRole.DisplayRole):
|
|
"""Returns the data for a given index and role."""
|
|
if not index.isValid():
|
|
return None
|
|
|
|
row = index.row()
|
|
col = index.column()
|
|
|
|
# --- Simple Mode ---
|
|
if self._simple_mode:
|
|
if row >= len(self._simple_data):
|
|
# log.warning(f"data called with out of bounds row in simple mode: {row}/{len(self._simple_data)}")
|
|
return None # Bounds check
|
|
source_asset_path = self._simple_data[row]
|
|
if role == Qt.ItemDataRole.DisplayRole:
|
|
if col == self.COL_SIMPLE_PATH:
|
|
return source_asset_path
|
|
elif role == Qt.ItemDataRole.ToolTipRole:
|
|
if col == self.COL_SIMPLE_PATH:
|
|
return f"Input Asset: {source_asset_path}"
|
|
return None
|
|
|
|
# --- Detailed Mode ---
|
|
if row >= len(self._table_rows): # Use _table_rows
|
|
# log.warning(f"data called with out of bounds row in detailed mode: {row}/{len(self._table_rows)}")
|
|
return None # Bounds check
|
|
row_data = self._table_rows[row] # Get data from the structured row
|
|
|
|
# --- Handle Custom Internal Roles ---
|
|
# These roles are now handled by the proxy model based on the structured data
|
|
if role == self.ROLE_RAW_STATUS:
|
|
# Return status of the main file if it exists, otherwise a placeholder for additional rows
|
|
main_file = row_data.get('main_file')
|
|
return main_file.get('status', '[No Status]') if main_file else '[Additional]'
|
|
if role == self.ROLE_SOURCE_ASSET:
|
|
return row_data.get('source_asset', 'N/A')
|
|
|
|
# --- Handle Display Role ---
|
|
if role == Qt.ItemDataRole.DisplayRole:
|
|
if col == self.COL_STATUS:
|
|
main_file = row_data.get('main_file')
|
|
if main_file:
|
|
raw_status = main_file.get('status', '[No Status]')
|
|
details = main_file.get('details', '') # Get details for parsing
|
|
|
|
# Implement status text simplification
|
|
if raw_status == "Unmatched Extra":
|
|
if details and details.startswith("[Unmatched Extra (Regex match:"):
|
|
try:
|
|
pattern = details.split("match: '")[1].split("'")[0]
|
|
return f"[Extra={pattern}]"
|
|
except IndexError:
|
|
return "Extra" # Fallback if parsing fails
|
|
else:
|
|
return "Extra"
|
|
elif raw_status == "Ignored" and details and "Superseed by 16bit variant for" in details:
|
|
try:
|
|
filename = details.split("Superseed by 16bit variant for ")[1]
|
|
return f"Superseeded by 16bit {filename}"
|
|
except IndexError:
|
|
return raw_status # Fallback if parsing fails
|
|
else:
|
|
return raw_status # Return original status if no simplification applies
|
|
else:
|
|
return "" # Empty for additional-only rows
|
|
|
|
elif col == self.COL_PREDICTED_ASSET:
|
|
main_file = row_data.get('main_file')
|
|
return main_file.get('predicted_asset_name', 'N/A') if main_file else ""
|
|
elif col == self.COL_ORIGINAL_PATH:
|
|
main_file = row_data.get('main_file')
|
|
return main_file.get('original_path', '[Missing Path]') if main_file else ""
|
|
elif col == self.COL_PREDICTED_OUTPUT:
|
|
main_file = row_data.get('main_file')
|
|
return main_file.get('predicted_output_name', '') if main_file else ""
|
|
elif col == self.COL_DETAILS:
|
|
main_file = row_data.get('main_file')
|
|
return main_file.get('details', '') if main_file else ""
|
|
elif col == self.COL_ADDITIONAL_FILES:
|
|
return row_data.get('additional_file_path', '')
|
|
return None # Should not happen with defined columns
|
|
|
|
# --- Handle Tooltip Role ---
|
|
if role == Qt.ItemDataRole.ToolTipRole:
|
|
if col == self.COL_ORIGINAL_PATH:
|
|
main_file = row_data.get('main_file')
|
|
if main_file:
|
|
source_asset = row_data.get('source_asset', 'N/A')
|
|
original_path = main_file.get('original_path', '[Missing Path]')
|
|
return f"Source Asset: {source_asset}\nFull Path: {original_path}"
|
|
else:
|
|
return "" # No tooltip for empty cells
|
|
elif col == self.COL_STATUS:
|
|
main_file = row_data.get('main_file')
|
|
if main_file:
|
|
return main_file.get('details', main_file.get('status', '[No Status]'))
|
|
else:
|
|
return "" # No tooltip for empty cells
|
|
elif col == self.COL_PREDICTED_ASSET:
|
|
main_file = row_data.get('main_file')
|
|
if main_file:
|
|
predicted_asset_name = main_file.get('predicted_asset_name', 'None')
|
|
return f"Predicted Asset Name: {predicted_asset_name}"
|
|
else:
|
|
return "" # No tooltip for empty cells
|
|
elif col == self.COL_PREDICTED_OUTPUT:
|
|
main_file = row_data.get('main_file')
|
|
if main_file:
|
|
predicted_output_name = main_file.get('predicted_output_name', 'None')
|
|
return f"Predicted Output Name: {predicted_output_name}"
|
|
else:
|
|
return "" # No tooltip for empty cells
|
|
elif col == self.COL_DETAILS:
|
|
main_file = row_data.get('main_file')
|
|
if main_file:
|
|
return main_file.get('details', '')
|
|
else:
|
|
return "" # No tooltip for empty cells
|
|
elif col == self.COL_ADDITIONAL_FILES:
|
|
additional_file = row_data.get('additional_file_details')
|
|
if additional_file:
|
|
status = additional_file.get('status', '[No Status]')
|
|
details = additional_file.get('details', '')
|
|
return f"Status: {status}\nDetails: {details}"
|
|
else:
|
|
return "" # No tooltip if no additional file in this cell
|
|
return None
|
|
|
|
# --- Handle Foreground (Text Color) Role ---
|
|
if role == Qt.ItemDataRole.ForegroundRole:
|
|
row_data = self._table_rows[row] # Get data from the structured row
|
|
status = None
|
|
|
|
# Determine the relevant status based on column and row data
|
|
if col in [self.COL_STATUS, self.COL_PREDICTED_ASSET, self.COL_ORIGINAL_PATH, self.COL_PREDICTED_OUTPUT, self.COL_DETAILS]:
|
|
# These columns relate to the main file
|
|
main_file = row_data.get('main_file')
|
|
if main_file:
|
|
status = main_file.get('status', '[No Status]')
|
|
elif col == self.COL_ADDITIONAL_FILES:
|
|
# This column relates to the additional file
|
|
additional_file = row_data.get('additional_file_details')
|
|
if additional_file:
|
|
status = additional_file.get('status', '[No Status]')
|
|
|
|
# Look up color based on determined status
|
|
if status in self.STATUS_COLORS:
|
|
return self.STATUS_COLORS[status]
|
|
else:
|
|
return None # Use default text color if no specific status color or no relevant file data
|
|
|
|
# --- Handle Background Role ---
|
|
if role == Qt.ItemDataRole.BackgroundRole:
|
|
# Apply alternating background color based on asset group
|
|
source_asset = row_data.get('source_asset')
|
|
if source_asset and source_asset in self._sorted_unique_assets:
|
|
try:
|
|
asset_index = self._sorted_unique_assets.index(source_asset)
|
|
if asset_index % 2 == 0:
|
|
return COLOR_ASSET_GROUP_1
|
|
else:
|
|
return COLOR_ASSET_GROUP_2
|
|
except ValueError:
|
|
# Should not happen if logic is correct, but handle defensively
|
|
log.warning(f"Asset '{source_asset}' not found in _sorted_unique_assets.")
|
|
return None # Use default background
|
|
return None # Use default background for rows without a source asset
|
|
|
|
|
|
# --- Handle Text Alignment Role ---
|
|
if role == Qt.ItemDataRole.TextAlignmentRole:
|
|
if col == self.COL_ORIGINAL_PATH:
|
|
return int(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter)
|
|
elif col == self.COL_ADDITIONAL_FILES:
|
|
return int(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter)
|
|
# For other columns, return default alignment (or None)
|
|
return None
|
|
|
|
|
|
# --- Handle Text Alignment Role ---
|
|
if role == Qt.ItemDataRole.TextAlignmentRole:
|
|
if col == self.COL_ORIGINAL_PATH:
|
|
return int(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter)
|
|
elif col == self.COL_ADDITIONAL_FILES:
|
|
return int(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter)
|
|
# For other columns, return default alignment (or None)
|
|
return None
|
|
|
|
|
|
return None
|
|
|
|
def headerData(self, section: int, orientation: Qt.Orientation, role: int = Qt.ItemDataRole.DisplayRole):
|
|
"""Returns the header data for a given section, orientation, and role."""
|
|
if role == Qt.ItemDataRole.DisplayRole and orientation == Qt.Orientation.Horizontal:
|
|
headers = self._headers_simple if self._simple_mode else self._headers_detailed
|
|
if 0 <= section < len(headers):
|
|
return headers[section]
|
|
return None
|
|
|
|
def set_data(self, data: list):
|
|
"""Sets the model's data, extracts simple data, and emits signals."""
|
|
# Removed diagnostic import here
|
|
thread_id = QThread.currentThread() # Get current thread object
|
|
log.info(f"[{time.time():.4f}][T:{thread_id}] --> Entered PreviewTableModel.set_data. Received {len(data)} items.")
|
|
log.info(f"[{time.time():.4f}][T:{thread_id}] Calling beginResetModel()...")
|
|
self.beginResetModel()
|
|
log.info(f"[{time.time():.4f}][T:{thread_id}] Returned from beginResetModel(). Processing data...")
|
|
self._data = data or [] # Keep original data for reference if needed
|
|
self._table_rows = [] # Clear previous structured data
|
|
|
|
# Group files by source asset
|
|
grouped_data = {}
|
|
unique_sources = set()
|
|
if data and isinstance(data[0], dict): # Ensure data is in detailed format
|
|
for file_details in data:
|
|
source_asset = file_details.get('source_asset')
|
|
if source_asset:
|
|
if source_asset not in grouped_data:
|
|
grouped_data[source_asset] = {'main_files': [], 'additional_files': []}
|
|
unique_sources.add(source_asset)
|
|
|
|
status = file_details.get('status')
|
|
# Separate into main and additional files based on status
|
|
if status in ["Mapped", "Model", "Error"]:
|
|
grouped_data[source_asset]['main_files'].append(file_details)
|
|
else: # Ignored, Extra, Unrecognised, Unmatched Extra
|
|
grouped_data[source_asset]['additional_files'].append(file_details)
|
|
|
|
# Sort main and additional files within each group (e.g., by original_path)
|
|
for asset_data in grouped_data.values():
|
|
asset_data['main_files'].sort(key=lambda x: x.get('original_path', ''))
|
|
asset_data['additional_files'].sort(key=lambda x: x.get('original_path', '')) # Sort additional by their path
|
|
|
|
# Build the _table_rows structure
|
|
sorted_assets = sorted(list(unique_sources)) # Sort assets alphabetically
|
|
for asset_name in sorted_assets:
|
|
asset_data = grouped_data[asset_name]
|
|
main_files = asset_data['main_files']
|
|
additional_files = asset_data['additional_files']
|
|
max_rows = max(len(main_files), len(additional_files))
|
|
|
|
for i in range(max_rows):
|
|
main_file = main_files[i] if i < len(main_files) else None
|
|
additional_file = additional_files[i] if i < len(additional_files) else None
|
|
|
|
row_data = {
|
|
'source_asset': asset_name,
|
|
'main_file': main_file, # Store the full dict for easy access
|
|
'additional_file_path': additional_file.get('original_path', '') if additional_file else '',
|
|
'additional_file_details': additional_file, # Store full dict for tooltip
|
|
'is_main_row': main_file is not None # True if this row has a main file
|
|
}
|
|
self._table_rows.append(row_data)
|
|
|
|
# Store sorted unique asset paths for simple mode and coloring
|
|
self._sorted_unique_assets = sorted(list(unique_sources))
|
|
self._simple_data = self._sorted_unique_assets # Simple data is just the sorted unique assets
|
|
|
|
|
|
log.info(f"[{time.time():.4f}][T:{thread_id}] Structured data built: {len(self._table_rows)} rows.")
|
|
log.info(f"[{time.time():.4f}][T:{thread_id}] Simple data extracted: {len(self._simple_data)} unique sources.")
|
|
log.info(f"[{time.time():.4f}][T:{thread_id}] Calling endResetModel()...")
|
|
self.endResetModel()
|
|
log.info(f"[{time.time():.4f}][T:{thread_id}] Returned from endResetModel().")
|
|
log.info(f"[{time.time():.4f}][T:{thread_id}] <-- Exiting PreviewTableModel.set_data.")
|
|
|
|
def clear_data(self):
|
|
"""Clears the model's data."""
|
|
thread_id = QThread.currentThread() # Get current thread object
|
|
log.info(f"[{time.time():.4f}][T:{thread_id}] PreviewTableModel.clear_data called.")
|
|
self.set_data([])
|
|
|
|
|
|
class PreviewSortFilterProxyModel(QSortFilterProxyModel):
|
|
"""
|
|
Custom proxy model for sorting the preview table.
|
|
Implements multi-level sorting and custom status order.
|
|
"""
|
|
# Define the desired status priority for sorting
|
|
# Lower numbers sort first. Mapped/Model have same priority.
|
|
STATUS_PRIORITY = {
|
|
"Error": 0,
|
|
"Mapped": 1,
|
|
"Model": 1,
|
|
"Ignored": 2,
|
|
"Extra": 3,
|
|
"Unrecognised": 3, # Treat as Extra
|
|
"Unmatched Extra": 3, # Treat as Extra
|
|
"[No Status]": 99 # Lowest priority
|
|
}
|
|
|
|
def __init__(self, parent=None):
|
|
super().__init__(parent)
|
|
log.debug("PreviewSortFilterProxyModel initialized.")
|
|
# Set default sort column and order (Status column, Ascending)
|
|
# This will be overridden by the custom lessThan logic
|
|
self.setSortRole(PreviewTableModel.ROLE_RAW_STATUS) # Sort using the raw status role
|
|
self.sort(PreviewTableModel.COL_STATUS, Qt.SortOrder.AscendingOrder) # Apply initial sort
|
|
|
|
def lessThan(self, left: QModelIndex, right: QModelIndex):
|
|
"""
|
|
Custom comparison logic for multi-level sorting.
|
|
Sorts by:
|
|
1. Source Asset (Ascending)
|
|
2. Status (Custom Order: Error > Mapped/Model > Ignored > Extra)
|
|
3. Original Path (Ascending)
|
|
"""
|
|
model = self.sourceModel()
|
|
if not model:
|
|
# log.debug("ProxyModel.lessThan: No source model.")
|
|
return super().lessThan(left, right) # Fallback if no source model
|
|
|
|
# If in simple mode, sort by the simple path column
|
|
if isinstance(model, PreviewTableModel) and model._simple_mode:
|
|
left_path = model.data(left.siblingAtColumn(model.COL_SIMPLE_PATH), Qt.ItemDataRole.DisplayRole)
|
|
right_path = model.data(right.siblingAtColumn(model.COL_SIMPLE_PATH), Qt.ItemDataRole.DisplayRole)
|
|
# log.debug(f"ProxyModel.lessThan (Simple Mode): Comparing '{left_path}' < '{right_path}'")
|
|
if not left_path: return True
|
|
if not right_path: return False
|
|
return left_path < right_path
|
|
|
|
|
|
# --- Detailed Mode Sorting ---
|
|
# log.debug("ProxyModel.lessThan (Detailed Mode).")
|
|
# Get the full row data from the source model's _table_rows
|
|
left_row_data = model._table_rows[left.row()]
|
|
right_row_data = model._table_rows[right.row()]
|
|
|
|
# --- Level 1: Sort by Source Asset ---
|
|
left_asset = left_row_data.get('source_asset', 'N/A')
|
|
right_asset = right_row_data.get('source_asset', 'N/A')
|
|
|
|
if left_asset != right_asset:
|
|
# Handle None/empty strings for consistent sorting
|
|
if not left_asset or left_asset == 'N/A': return True # Empty asset comes first
|
|
if not right_asset or right_asset == 'N/A': return False # Non-empty asset comes first
|
|
return left_asset < right_asset # Alphabetical sort for assets
|
|
|
|
# --- Level 2: Sort by Row Type (Main vs Additional-only) ---
|
|
# Main rows (is_main_row == True) should come before additional-only rows
|
|
left_is_main = left_row_data.get('is_main_row', False)
|
|
right_is_main = right_row_data.get('is_main_row', False)
|
|
|
|
if left_is_main != right_is_main:
|
|
return left_is_main > right_is_main # True > False
|
|
|
|
# --- Level 3: Sort within the row type ---
|
|
if left_is_main: # Both are main rows
|
|
# Sort by Original Path (Alphabetical)
|
|
left_path = left_row_data.get('main_file', {}).get('original_path', '')
|
|
right_path = right_row_data.get('main_file', {}).get('original_path', '')
|
|
|
|
if not left_path: return True
|
|
if not right_path: return False
|
|
return left_path < right_path
|
|
|
|
else: # Both are additional-only rows
|
|
# Sort by Additional File Path (Alphabetical)
|
|
left_additional_path = left_row_data.get('additional_file_path', '')
|
|
right_additional_path = right_row_data.get('additional_file_path', '')
|
|
|
|
if not left_additional_path: return True
|
|
if not right_additional_path: return False
|
|
return left_additional_path < right_additional_path
|
|
|
|
# Should not reach here if logic is correct, but include a fallback
|
|
return super().lessThan(left, right)
|
|
|
|
# Override sort method to ensure custom sorting is used
|
|
def sort(self, column: int, order: Qt.SortOrder = Qt.SortOrder.AscendingOrder):
|
|
# We ignore the column and order here and rely on lessThan for multi-level sort
|
|
# However, calling this method is necessary to trigger the proxy model's sorting mechanism.
|
|
# We can potentially use the column/order to toggle ascending/descending within each level in lessThan,
|
|
# but for now, we'll stick to the defined order.
|
|
log.debug(f"ProxyModel.sort called with column {column}, order {order}. Triggering lessThan.")
|
|
# Call base class sort to trigger update. Pass a valid column, e.g., COL_STATUS,
|
|
# as the actual sorting logic is in lessThan.
|
|
super().sort(PreviewTableModel.COL_STATUS, Qt.SortOrder.AscendingOrder) |