|
1 | 1 | import petab.v1 as petab |
2 | | -from PySide6.QtCore import QPropertyAnimation, QRect, Qt |
| 2 | +from PySide6.QtCore import QItemSelectionModel, QPropertyAnimation, QRect, Qt |
3 | 3 | from PySide6.QtGui import QColor, QGuiApplication |
4 | 4 | from PySide6.QtWidgets import ( |
5 | 5 | QComboBox, |
6 | 6 | QCompleter, |
7 | 7 | QDockWidget, |
8 | 8 | QHeaderView, |
9 | 9 | QLineEdit, |
| 10 | + QMenu, |
10 | 11 | QStyledItemDelegate, |
11 | 12 | QTableView, |
12 | 13 | ) |
13 | 14 |
|
14 | | -from ..utils import get_selected_rectangles |
| 15 | +from ..C import COLUMN |
| 16 | +from ..utils import get_selected, get_selected_rectangles |
15 | 17 | from .context_menu_mananger import ContextMenuManager |
16 | 18 |
|
17 | 19 |
|
@@ -321,6 +323,196 @@ def setup_context_menu(self, actions): |
321 | 323 | self.context_menu_manager.create_context_menu |
322 | 324 | ) |
323 | 325 |
|
| 326 | + def _get_source_model(self): |
| 327 | + """Get the source model, handling proxy models if present. |
| 328 | +
|
| 329 | + Returns: |
| 330 | + The source model (unwraps proxy if present) |
| 331 | + """ |
| 332 | + model = self.model() |
| 333 | + return model.sourceModel() if hasattr(model, "sourceModel") else model |
| 334 | + |
| 335 | + def _ensure_header_selected(self, index, mode=None): |
| 336 | + """Ensure header at index is selected. |
| 337 | +
|
| 338 | + If not already selected, selects the entire row/column. Otherwise |
| 339 | + preserves existing multi-selection (Excel-like behavior). |
| 340 | +
|
| 341 | + Args: |
| 342 | + index: Row or column index to ensure is selected |
| 343 | + mode: COLUMN constant for column selection, None for row selection |
| 344 | +
|
| 345 | + Returns: |
| 346 | + Set of selected indices |
| 347 | + """ |
| 348 | + selection_model = self.selectionModel() |
| 349 | + selected = get_selected(self, mode=mode if mode == COLUMN else None) |
| 350 | + |
| 351 | + if index not in selected: |
| 352 | + flag = ( |
| 353 | + QItemSelectionModel.Columns |
| 354 | + if mode == COLUMN |
| 355 | + else QItemSelectionModel.Rows |
| 356 | + ) |
| 357 | + row_idx = 0 if mode == COLUMN else index |
| 358 | + col_idx = index if mode == COLUMN else 0 |
| 359 | + selection_model.select( |
| 360 | + self.model().index(row_idx, col_idx), |
| 361 | + QItemSelectionModel.Select | flag, |
| 362 | + ) |
| 363 | + selected = {index} |
| 364 | + |
| 365 | + return selected |
| 366 | + |
| 367 | + def setup_header_context_menus(self, actions): |
| 368 | + """Set up context menus for row and column deletion. |
| 369 | +
|
| 370 | + Enables right-click context menus on both the vertical (row) and |
| 371 | + horizontal (column) headers. The menus provide options to delete the |
| 372 | + clicked row or column using the same actions as the table body context |
| 373 | + menu. |
| 374 | +
|
| 375 | + Args: |
| 376 | + actions: Dictionary of QAction objects (same as setup_context_menu) |
| 377 | + """ |
| 378 | + # Store references to the delete actions for header menus |
| 379 | + self.delete_row_action = actions["delete_row"] |
| 380 | + self.delete_column_action = actions["delete_column"] |
| 381 | + |
| 382 | + # Enable custom context menus on headers |
| 383 | + self.verticalHeader().setContextMenuPolicy(Qt.CustomContextMenu) |
| 384 | + self.horizontalHeader().setContextMenuPolicy(Qt.CustomContextMenu) |
| 385 | + |
| 386 | + # Connect signals |
| 387 | + self.verticalHeader().customContextMenuRequested.connect( |
| 388 | + self._show_row_context_menu |
| 389 | + ) |
| 390 | + self.horizontalHeader().customContextMenuRequested.connect( |
| 391 | + self._show_column_context_menu |
| 392 | + ) |
| 393 | + |
| 394 | + def _show_row_context_menu(self, position): |
| 395 | + """Show context menu for row deletion. |
| 396 | +
|
| 397 | + Supports multi-row deletion. If the clicked row is not selected, |
| 398 | + it selects it. If multiple rows are already selected, it keeps |
| 399 | + the selection and deletes all selected rows (Excel-like behavior). |
| 400 | +
|
| 401 | + Uses the same delete_row action as the table body context menu. |
| 402 | +
|
| 403 | + Args: |
| 404 | + position: The position where the context menu was requested |
| 405 | + """ |
| 406 | + vertical_header = self.verticalHeader() |
| 407 | + row = vertical_header.logicalIndexAt(position) |
| 408 | + |
| 409 | + source_model = self._get_source_model() |
| 410 | + |
| 411 | + # Don't show menu for the "new row" at the end |
| 412 | + if row >= source_model.get_df().shape[0]: |
| 413 | + return |
| 414 | + |
| 415 | + # Ensure clicked row is selected (or preserve multi-selection) |
| 416 | + selected_rows = self._ensure_header_selected(row) |
| 417 | + |
| 418 | + # Create menu with count indicator using the existing action |
| 419 | + menu = QMenu() |
| 420 | + row_count = len(selected_rows) |
| 421 | + original_text = self.delete_row_action.text() |
| 422 | + |
| 423 | + # Update action text based on selection count |
| 424 | + new_text = ( |
| 425 | + "Delete Row" if row_count == 1 else f"Delete {row_count} Rows" |
| 426 | + ) |
| 427 | + self.delete_row_action.setText(new_text) |
| 428 | + menu.addAction(self.delete_row_action) |
| 429 | + |
| 430 | + try: |
| 431 | + menu.exec_(vertical_header.mapToGlobal(position)) |
| 432 | + finally: |
| 433 | + # Always restore original action text |
| 434 | + self.delete_row_action.setText(original_text) |
| 435 | + |
| 436 | + def _show_column_context_menu(self, position): |
| 437 | + """Show context menu for column deletion. |
| 438 | +
|
| 439 | + Supports multi-column deletion. If the clicked column is not selected, |
| 440 | + it selects it. If multiple columns are already selected, it keeps |
| 441 | + the selection and deletes all selected columns (Excel-like behavior). |
| 442 | +
|
| 443 | + Shows informative text for required columns. Uses the same |
| 444 | + delete_column action as the table body context menu. |
| 445 | +
|
| 446 | + Args: |
| 447 | + position: The position where the context menu was requested |
| 448 | + """ |
| 449 | + horizontal_header = self.horizontalHeader() |
| 450 | + column = horizontal_header.logicalIndexAt(position) |
| 451 | + |
| 452 | + source_model = self._get_source_model() |
| 453 | + |
| 454 | + # Ensure clicked column is selected (or preserve multi-selection) |
| 455 | + selected_columns = self._ensure_header_selected(column, mode=COLUMN) |
| 456 | + |
| 457 | + # Check which columns can be deleted and build required column list |
| 458 | + required_columns = [] |
| 459 | + deletable_columns = [] |
| 460 | + for col in selected_columns: |
| 461 | + can_delete, col_name = source_model.allow_column_deletion(col) |
| 462 | + if can_delete: |
| 463 | + deletable_columns.append(col) |
| 464 | + else: |
| 465 | + required_columns.append(col_name) |
| 466 | + |
| 467 | + # Create menu with appropriate text using the existing action |
| 468 | + menu = QMenu() |
| 469 | + column_count = len(selected_columns) |
| 470 | + deletable_count = len(deletable_columns) |
| 471 | + |
| 472 | + # Store original action text and enabled state |
| 473 | + original_text = self.delete_column_action.text() |
| 474 | + original_enabled = self.delete_column_action.isEnabled() |
| 475 | + |
| 476 | + # Determine menu text and enabled state based on selection |
| 477 | + if column_count == 1: |
| 478 | + if deletable_count == 1: |
| 479 | + menu_text = "Delete Column" |
| 480 | + enabled = True |
| 481 | + else: |
| 482 | + # Single required column |
| 483 | + menu_text = ( |
| 484 | + f"Delete Column (Required: '{required_columns[0]}')" |
| 485 | + ) |
| 486 | + enabled = False |
| 487 | + else: |
| 488 | + # Multiple columns selected |
| 489 | + if deletable_count == column_count: |
| 490 | + menu_text = f"Delete {column_count} Columns" |
| 491 | + enabled = True |
| 492 | + elif deletable_count == 0: |
| 493 | + # All required |
| 494 | + menu_text = f"Delete {column_count} Columns (All required)" |
| 495 | + enabled = False |
| 496 | + else: |
| 497 | + # Some required, some deletable |
| 498 | + required_count = len(required_columns) |
| 499 | + menu_text = ( |
| 500 | + f"Delete {deletable_count} Columns " |
| 501 | + f"({required_count} required will be skipped)" |
| 502 | + ) |
| 503 | + enabled = True |
| 504 | + |
| 505 | + self.delete_column_action.setText(menu_text) |
| 506 | + self.delete_column_action.setEnabled(enabled) |
| 507 | + menu.addAction(self.delete_column_action) |
| 508 | + |
| 509 | + try: |
| 510 | + menu.exec_(horizontal_header.mapToGlobal(position)) |
| 511 | + finally: |
| 512 | + # Always restore original action text and enabled state |
| 513 | + self.delete_column_action.setText(original_text) |
| 514 | + self.delete_column_action.setEnabled(original_enabled) |
| 515 | + |
324 | 516 | def setModel(self, model): |
325 | 517 | """Set the model for the table view. |
326 | 518 |
|
|
0 commit comments