From 3ef867cf5f1d0d58fecf4a9e4ef3a6b1a0511ff8 Mon Sep 17 00:00:00 2001 From: PaulJonasJost Date: Mon, 19 Jan 2026 11:31:56 +0100 Subject: [PATCH 1/2] Improved that the same actions are used --- .../controllers/table_controllers.py | 10 +- src/petab_gui/views/table_view.py | 185 ++++++++++++++++++ 2 files changed, 194 insertions(+), 1 deletion(-) diff --git a/src/petab_gui/controllers/table_controllers.py b/src/petab_gui/controllers/table_controllers.py index fa419d4..1edad5a 100644 --- a/src/petab_gui/controllers/table_controllers.py +++ b/src/petab_gui/controllers/table_controllers.py @@ -117,9 +117,17 @@ def setup_connections(self): settings_manager.settings_changed.connect(self.update_defaults) def setup_context_menu(self, actions): - """Setup context menu for this table.""" + """Setup context menus for this table. + + Sets up both the table body context menu and the header context menus + using the same actions dictionary for consistency. + + Args: + actions: Dictionary of QAction objects + """ view = self.view.table_view view.setup_context_menu(actions) + view.setup_header_context_menus(actions) def validate_changed_cell(self, row, column): """Validate the changed cell and whether its linting is correct.""" diff --git a/src/petab_gui/views/table_view.py b/src/petab_gui/views/table_view.py index af83372..4feda7e 100644 --- a/src/petab_gui/views/table_view.py +++ b/src/petab_gui/views/table_view.py @@ -321,6 +321,191 @@ def setup_context_menu(self, actions): self.context_menu_manager.create_context_menu ) + def setup_header_context_menus(self, actions): + """Set up context menus for row and column deletion. + + Enables right-click context menus on both the vertical (row) and + horizontal (column) headers. The menus provide options to delete the + clicked row or column using the same actions as the table body context + menu. + + Args: + actions: Dictionary of QAction objects (same as setup_context_menu) + """ + # Store references to the delete actions for header menus + self.delete_row_action = actions["delete_row"] + self.delete_column_action = actions["delete_column"] + + # Enable custom context menus on headers + self.verticalHeader().setContextMenuPolicy(Qt.CustomContextMenu) + self.horizontalHeader().setContextMenuPolicy(Qt.CustomContextMenu) + + # Connect signals + self.verticalHeader().customContextMenuRequested.connect( + self._show_row_context_menu + ) + self.horizontalHeader().customContextMenuRequested.connect( + self._show_column_context_menu + ) + + def _show_row_context_menu(self, position): + """Show context menu for row deletion. + + Supports multi-row deletion. If the clicked row is not selected, + it selects it. If multiple rows are already selected, it keeps + the selection and deletes all selected rows (Excel-like behavior). + + Uses the same delete_row action as the table body context menu. + + Args: + position: The position where the context menu was requested + """ + from PySide6.QtCore import QItemSelectionModel + from PySide6.QtWidgets import QMenu + + from ..utils import get_selected + + vertical_header = self.verticalHeader() + row = vertical_header.logicalIndexAt(position) + + model = self.model() + # Handle proxy model if present + if hasattr(model, "sourceModel"): + source_model = model.sourceModel() + else: + source_model = model + + # Don't show menu for the "new row" at the end + if row >= source_model.get_df().shape[0]: + return + + # Selection logic: select clicked row if not already selected + selection_model = self.selectionModel() + selected_rows = get_selected(self) + if row not in selected_rows: + # Select the entire row + selection_model.select( + model.index(row, 0), + QItemSelectionModel.Select | QItemSelectionModel.Rows, + ) + selected_rows = {row} + + # Create menu with count indicator using the existing action + menu = QMenu() + row_count = len(selected_rows) + if row_count == 1: + # Update action text temporarily for single row + original_text = self.delete_row_action.text() + self.delete_row_action.setText("Delete Row") + menu.addAction(self.delete_row_action) + else: + # Update action text temporarily for multiple rows + original_text = self.delete_row_action.text() + self.delete_row_action.setText(f"Delete {row_count} Rows") + menu.addAction(self.delete_row_action) + + menu.exec_(vertical_header.mapToGlobal(position)) + + # Restore original action text + self.delete_row_action.setText(original_text) + + def _show_column_context_menu(self, position): + """Show context menu for column deletion. + + Supports multi-column deletion. If the clicked column is not selected, + it selects it. If multiple columns are already selected, it keeps + the selection and deletes all selected columns (Excel-like behavior). + + Shows informative text for required columns. Uses the same + delete_column action as the table body context menu. + + Args: + position: The position where the context menu was requested + """ + from PySide6.QtCore import QItemSelectionModel + from PySide6.QtWidgets import QMenu + + from ..C import COLUMN + from ..utils import get_selected + + horizontal_header = self.horizontalHeader() + column = horizontal_header.logicalIndexAt(position) + + model = self.model() + # Handle proxy model if present + if hasattr(model, "sourceModel"): + source_model = model.sourceModel() + else: + source_model = model + + # Selection logic: select clicked column if not already selected + selection_model = self.selectionModel() + selected_columns = get_selected(self, mode=COLUMN) + if column not in selected_columns: + # Select the entire column + selection_model.select( + model.index(0, column), + QItemSelectionModel.Select | QItemSelectionModel.Columns, + ) + selected_columns = {column} + + # Check which columns can be deleted and build required column list + required_columns = [] + deletable_columns = [] + for col in selected_columns: + can_delete, col_name = source_model.allow_column_deletion(col) + if can_delete: + deletable_columns.append(col) + else: + required_columns.append(col_name) + + # Create menu with appropriate text using the existing action + menu = QMenu() + column_count = len(selected_columns) + deletable_count = len(deletable_columns) + + # Store original action text and enabled state + original_text = self.delete_column_action.text() + original_enabled = self.delete_column_action.isEnabled() + + if column_count == 1: + if deletable_count == 1: + self.delete_column_action.setText("Delete Column") + self.delete_column_action.setEnabled(True) + else: + # Single required column + self.delete_column_action.setText( + f"Delete Column (Required: '{required_columns[0]}')" + ) + self.delete_column_action.setEnabled(False) + else: + # Multiple columns selected + if deletable_count == column_count: + menu_text = f"Delete {column_count} Columns" + self.delete_column_action.setText(menu_text) + self.delete_column_action.setEnabled(True) + elif deletable_count == 0: + # All required + menu_text = f"Delete {column_count} Columns (All required)" + self.delete_column_action.setText(menu_text) + self.delete_column_action.setEnabled(False) + else: + # Some required, some deletable + required_count = len(required_columns) + menu_text = ( + f"Delete {deletable_count} Columns " + f"({required_count} required will be skipped)" + ) + self.delete_column_action.setText(menu_text) + self.delete_column_action.setEnabled(True) + + menu.addAction(self.delete_column_action) + menu.exec_(horizontal_header.mapToGlobal(position)) + + # Restore original action text and enabled state + self.delete_column_action.setText(original_text) + self.delete_column_action.setEnabled(original_enabled) + def setModel(self, model): """Set the model for the table view. From b527a77350f4d828b82bcca3093c2a488623a629 Mon Sep 17 00:00:00 2001 From: PaulJonasJost Date: Mon, 19 Jan 2026 11:39:19 +0100 Subject: [PATCH 2/2] Small review --- src/petab_gui/views/table_view.py | 151 ++++++++++++++++-------------- 1 file changed, 79 insertions(+), 72 deletions(-) diff --git a/src/petab_gui/views/table_view.py b/src/petab_gui/views/table_view.py index 4feda7e..75d34d9 100644 --- a/src/petab_gui/views/table_view.py +++ b/src/petab_gui/views/table_view.py @@ -1,5 +1,5 @@ import petab.v1 as petab -from PySide6.QtCore import QPropertyAnimation, QRect, Qt +from PySide6.QtCore import QItemSelectionModel, QPropertyAnimation, QRect, Qt from PySide6.QtGui import QColor, QGuiApplication from PySide6.QtWidgets import ( QComboBox, @@ -7,11 +7,13 @@ QDockWidget, QHeaderView, QLineEdit, + QMenu, QStyledItemDelegate, QTableView, ) -from ..utils import get_selected_rectangles +from ..C import COLUMN +from ..utils import get_selected, get_selected_rectangles from .context_menu_mananger import ContextMenuManager @@ -321,6 +323,47 @@ def setup_context_menu(self, actions): self.context_menu_manager.create_context_menu ) + def _get_source_model(self): + """Get the source model, handling proxy models if present. + + Returns: + The source model (unwraps proxy if present) + """ + model = self.model() + return model.sourceModel() if hasattr(model, "sourceModel") else model + + def _ensure_header_selected(self, index, mode=None): + """Ensure header at index is selected. + + If not already selected, selects the entire row/column. Otherwise + preserves existing multi-selection (Excel-like behavior). + + Args: + index: Row or column index to ensure is selected + mode: COLUMN constant for column selection, None for row selection + + Returns: + Set of selected indices + """ + selection_model = self.selectionModel() + selected = get_selected(self, mode=mode if mode == COLUMN else None) + + if index not in selected: + flag = ( + QItemSelectionModel.Columns + if mode == COLUMN + else QItemSelectionModel.Rows + ) + row_idx = 0 if mode == COLUMN else index + col_idx = index if mode == COLUMN else 0 + selection_model.select( + self.model().index(row_idx, col_idx), + QItemSelectionModel.Select | flag, + ) + selected = {index} + + return selected + def setup_header_context_menus(self, actions): """Set up context menus for row and column deletion. @@ -360,54 +403,35 @@ def _show_row_context_menu(self, position): Args: position: The position where the context menu was requested """ - from PySide6.QtCore import QItemSelectionModel - from PySide6.QtWidgets import QMenu - - from ..utils import get_selected - vertical_header = self.verticalHeader() row = vertical_header.logicalIndexAt(position) - model = self.model() - # Handle proxy model if present - if hasattr(model, "sourceModel"): - source_model = model.sourceModel() - else: - source_model = model + source_model = self._get_source_model() # Don't show menu for the "new row" at the end if row >= source_model.get_df().shape[0]: return - # Selection logic: select clicked row if not already selected - selection_model = self.selectionModel() - selected_rows = get_selected(self) - if row not in selected_rows: - # Select the entire row - selection_model.select( - model.index(row, 0), - QItemSelectionModel.Select | QItemSelectionModel.Rows, - ) - selected_rows = {row} + # Ensure clicked row is selected (or preserve multi-selection) + selected_rows = self._ensure_header_selected(row) # Create menu with count indicator using the existing action menu = QMenu() row_count = len(selected_rows) - if row_count == 1: - # Update action text temporarily for single row - original_text = self.delete_row_action.text() - self.delete_row_action.setText("Delete Row") - menu.addAction(self.delete_row_action) - else: - # Update action text temporarily for multiple rows - original_text = self.delete_row_action.text() - self.delete_row_action.setText(f"Delete {row_count} Rows") - menu.addAction(self.delete_row_action) + original_text = self.delete_row_action.text() - menu.exec_(vertical_header.mapToGlobal(position)) + # Update action text based on selection count + new_text = ( + "Delete Row" if row_count == 1 else f"Delete {row_count} Rows" + ) + self.delete_row_action.setText(new_text) + menu.addAction(self.delete_row_action) - # Restore original action text - self.delete_row_action.setText(original_text) + try: + menu.exec_(vertical_header.mapToGlobal(position)) + finally: + # Always restore original action text + self.delete_row_action.setText(original_text) def _show_column_context_menu(self, position): """Show context menu for column deletion. @@ -422,32 +446,13 @@ def _show_column_context_menu(self, position): Args: position: The position where the context menu was requested """ - from PySide6.QtCore import QItemSelectionModel - from PySide6.QtWidgets import QMenu - - from ..C import COLUMN - from ..utils import get_selected - horizontal_header = self.horizontalHeader() column = horizontal_header.logicalIndexAt(position) - model = self.model() - # Handle proxy model if present - if hasattr(model, "sourceModel"): - source_model = model.sourceModel() - else: - source_model = model + source_model = self._get_source_model() - # Selection logic: select clicked column if not already selected - selection_model = self.selectionModel() - selected_columns = get_selected(self, mode=COLUMN) - if column not in selected_columns: - # Select the entire column - selection_model.select( - model.index(0, column), - QItemSelectionModel.Select | QItemSelectionModel.Columns, - ) - selected_columns = {column} + # Ensure clicked column is selected (or preserve multi-selection) + selected_columns = self._ensure_header_selected(column, mode=COLUMN) # Check which columns can be deleted and build required column list required_columns = [] @@ -468,27 +473,26 @@ def _show_column_context_menu(self, position): original_text = self.delete_column_action.text() original_enabled = self.delete_column_action.isEnabled() + # Determine menu text and enabled state based on selection if column_count == 1: if deletable_count == 1: - self.delete_column_action.setText("Delete Column") - self.delete_column_action.setEnabled(True) + menu_text = "Delete Column" + enabled = True else: # Single required column - self.delete_column_action.setText( + menu_text = ( f"Delete Column (Required: '{required_columns[0]}')" ) - self.delete_column_action.setEnabled(False) + enabled = False else: # Multiple columns selected if deletable_count == column_count: menu_text = f"Delete {column_count} Columns" - self.delete_column_action.setText(menu_text) - self.delete_column_action.setEnabled(True) + enabled = True elif deletable_count == 0: # All required menu_text = f"Delete {column_count} Columns (All required)" - self.delete_column_action.setText(menu_text) - self.delete_column_action.setEnabled(False) + enabled = False else: # Some required, some deletable required_count = len(required_columns) @@ -496,15 +500,18 @@ def _show_column_context_menu(self, position): f"Delete {deletable_count} Columns " f"({required_count} required will be skipped)" ) - self.delete_column_action.setText(menu_text) - self.delete_column_action.setEnabled(True) + enabled = True + self.delete_column_action.setText(menu_text) + self.delete_column_action.setEnabled(enabled) menu.addAction(self.delete_column_action) - menu.exec_(horizontal_header.mapToGlobal(position)) - # Restore original action text and enabled state - self.delete_column_action.setText(original_text) - self.delete_column_action.setEnabled(original_enabled) + try: + menu.exec_(horizontal_header.mapToGlobal(position)) + finally: + # Always restore original action text and enabled state + self.delete_column_action.setText(original_text) + self.delete_column_action.setEnabled(original_enabled) def setModel(self, model): """Set the model for the table view.