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)