-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathpandas_table_model.py
More file actions
1249 lines (1069 loc) · 44.6 KB
/
pandas_table_model.py
File metadata and controls
1249 lines (1069 loc) · 44.6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
from typing import Any
import petab.v1 as petab
from PySide6.QtCore import (
QAbstractTableModel,
QMimeData,
QModelIndex,
QSortFilterProxyModel,
Qt,
Signal,
)
from PySide6.QtGui import QBrush, QColor, QPalette
from ..C import COLUMNS
from ..commands import (
ModifyColumnCommand,
ModifyDataFrameCommand,
ModifyRowCommand,
RenameIndexCommand,
)
from ..resources.whats_this import column_whats_this
from ..settings_manager import settings_manager
from ..utils import (
create_empty_dataframe,
get_selected,
)
from .default_handler import DefaultHandlerModel
from .tooltips import cell_tip, header_tip
from .validators import is_invalid, validate_value
def _get_system_palette_color(role):
"""Get system palette color, with fallback if Qt is not available.
Args:
role: QPalette color role (e.g., QPalette.Highlight)
Returns:
QColor: The system color or a fallback color
"""
try:
# Try to get system palette from QApplication
from PySide6.QtWidgets import QApplication
app = QApplication.instance()
if app:
return app.palette().color(role)
except (ImportError, RuntimeError):
pass
# Fallback colors when Qt is not available or no QApplication
fallback_colors = {
QPalette.Highlight: QColor(51, 153, 255, 100), # Light blue
QPalette.HighlightedText: QColor(255, 255, 255), # White
}
return fallback_colors.get(role, QColor(0, 0, 0))
class PandasTableModel(QAbstractTableModel):
"""Basic table model for a pandas DataFrame.
This class provides a Qt model interface for pandas DataFrames,
allowing them to be displayed and edited in Qt table views. It handles
data access, modification, and various table operations like
adding/removing rows and columns.
"""
# Signals
relevant_id_changed = Signal(str, str, str) # new_id, old_id, type
new_log_message = Signal(str, str) # message, color
cell_needs_validation = Signal(int, int) # row, column
something_changed = Signal(bool)
inserted_row = Signal(QModelIndex)
plotting_needs_break = Signal(bool)
def __init__(
self,
data_frame,
allowed_columns,
table_type,
undo_stack=None,
parent=None,
):
"""Initialize the pandas table model.
Args:
data_frame:
The pandas DataFrame to be displayed in the table
allowed_columns:
Dictionary of allowed columns with their properties
table_type:
The type of table (e.g., 'observable', 'parameter', 'condition')
undo_stack:
Optional QUndoStack for undo/redo functionality
parent:
The parent QObject
"""
super().__init__(parent)
self._allowed_columns = allowed_columns
self.table_type = table_type
self._invalid_cells = set()
self.highlighted_cells = set()
self._has_named_index = False
if data_frame is None:
data_frame = create_empty_dataframe(allowed_columns, table_type)
self._data_frame = data_frame
# add a view here, access is needed for selectionModels
self.view = None
# offset for row and column to get from the data_frame to the view
self.row_index_offset = 0
self.column_offset = 0
# default values setup
self.config = settings_manager.get_table_defaults(table_type)
self.default_handler = DefaultHandlerModel(self, self.config)
self.undo_stack = undo_stack
# Cache colors to avoid runtime dependency on QApplication
self._highlight_bg_color = _get_system_palette_color(
QPalette.Highlight
)
self._highlight_fg_color = _get_system_palette_color(
QPalette.HighlightedText
)
def rowCount(self, parent=None):
"""Return the number of rows in the model.
Includes an extra row at the end for adding new entries.
Args:
parent: The parent model index (unused in table models)
Returns:
int: The number of rows in the model
"""
if parent is None:
parent = QModelIndex()
return self._data_frame.shape[0] + 1 # empty row at the end
def columnCount(self, parent=None):
"""Return the number of columns in the model.
Includes any column offset (e.g., for index column).
Args:
parent: The parent model index (unused in table models)
Returns:
int: The number of columns in the model
"""
if parent is None:
parent = QModelIndex()
return self._data_frame.shape[1] + self.column_offset
def data(self, index, role=Qt.DisplayRole):
"""Return the data at the given index and role for the View.
Handles different roles:
- DisplayRole/EditRole: Returns the cell value as a string
- BackgroundRole: Returns the background color for the cell
- ForegroundRole: Returns the text color for the cell
Args:
index: The model index to get data for
role: The data role (DisplayRole, EditRole, BackgroundRole, etc.)
Returns:
The requested data for the given index and role, or None
"""
if not index.isValid():
return None
row, column = index.row(), index.column()
if role == Qt.WhatsThisRole:
if row == self._data_frame.shape[0]:
return "Add a new row."
if column == 0 and self._has_named_index:
return None
col_label = self._data_frame.columns[column - self.column_offset]
return column_whats_this(self.table_type, col_label)
if role == Qt.DisplayRole or role == Qt.EditRole:
if row == self._data_frame.shape[0]:
if column == 0:
return f"New {self.table_type}"
return ""
if column == 0 and self._has_named_index:
value = self._data_frame.index[row]
return str(value)
value = self._data_frame.iloc[row, column - self.column_offset]
if is_invalid(value):
return ""
return str(value)
if role == Qt.BackgroundRole:
return self.determine_background_color(row, column)
if role == Qt.ForegroundRole:
# Return highlighted text color if this cell is a match
if (row, column) in self.highlighted_cells:
return self._highlight_fg_color
return QBrush(QColor(0, 0, 0)) # Default black text
if role == Qt.ToolTipRole:
if row == self._data_frame.shape[0]:
return "Add a new row"
col_label = self._data_frame.columns[column - self.column_offset]
if column == 0 and self._has_named_index:
col_label = self._data_frame.index.name
return cell_tip(self.table_type, col_label)
return None
def flags(self, index):
"""Return the item flags for the given index.
Determines whether cells are editable, selectable, and enabled.
Args:
index: The model index to get flags for
Returns:
Qt.ItemFlags: The flags for the given index
"""
if not index.isValid():
return Qt.ItemIsEnabled
return Qt.ItemIsEditable | Qt.ItemIsEnabled | Qt.ItemIsSelectable
def headerData(self, section, orientation, role=Qt.DisplayRole):
"""Return the header data for the given section and orientation.
Provides column and row headers for the table view.
Args:
section:
The row or column number
orientation:
Qt.Horizontal for column headers, Qt.Vertical for row headers
role:
The data role (usually DisplayRole)
Returns:
The header text for the given section and orientation, or None.
"""
if role not in (Qt.DisplayRole, Qt.ToolTipRole, Qt.WhatsThisRole):
return None
if orientation == Qt.Horizontal:
if section == 0 and self._has_named_index:
col_label = self._data_frame.index.name
else:
col_label = self._data_frame.columns[
section - self.column_offset
]
if role == Qt.ToolTipRole:
tooltip_header = header_tip(self.table_type, col_label)
return tooltip_header
if role == Qt.WhatsThisRole:
return column_whats_this(self.table_type, col_label)
return col_label
if orientation == Qt.Vertical:
return str(section)
return None
def insertRows(self, position, rows, parent=None) -> bool:
"""
Insert new rows at the end of the DataFrame in-place.
This function always adds rows at the end.
Parameters:
-----------
position: Ignored, as rows are always inserted at the end.
rows: The number of rows to add.
parent: Unused in this implementation.
Returns:
--------
bool: True if rows were added successfully.
"""
if self.undo_stack:
self.undo_stack.push(ModifyRowCommand(self, rows))
else:
# Fallback if undo stack isn't used
command = ModifyRowCommand(self, rows)
command.redo()
return True
def insertColumn(self, column_name: str):
"""Add a new column to the table.
Always adds the column at the right (end) of the table. Checks if the
column already exists or if it's not in the allowed columns list.
Args:
column_name: The name of the column to add
Returns:
bool: True if the column was added successfully, False otherwise
Notes:
If the column is not in the allowed columns list, a warning message
is emitted but the column is still added.
"""
if column_name in self._data_frame.columns:
self.new_log_message.emit(
f"Column '{column_name}' already exists", "red"
)
return False
if not (
column_name in self._allowed_columns
or self.table_type == "condition"
): # empty dict means all columns allowed
self.new_log_message.emit(
f"Column '{column_name}' will be ignored for the petab "
f"problem but may still be used to store relevant information",
"orange",
)
if self.undo_stack:
self.undo_stack.push(ModifyColumnCommand(self, column_name))
else:
# Fallback if undo stack isn't used
command = ModifyColumnCommand(self, column_name)
command.redo()
return True
def setData(
self, index, value, role=Qt.EditRole, check_multi: bool = True
):
"""Set the data for a specific model index.
Updates the value at the given index in the model. If multiple rows are
selected and check_multi is True, applies the change to all selected
cells in the same column.
Args:
index: The model index to set data for
value: The new value to set
role: The data role (usually EditRole)
check_multi: Whether to check for multi-row selection
Returns:
bool: True if the data was set successfully, False otherwise
"""
if not (index.isValid() and role == Qt.EditRole):
return False
if role != Qt.EditRole:
return False
if is_invalid(value) or value == "":
value = None
self.plotting_needs_break.emit(True) # Temp disable plotting
multi_row_change = False
if check_multi:
# check whether multiple rows but only one column is selected
multi_row_change, selected = self.check_selection()
if not multi_row_change:
self.undo_stack.beginMacro("Set data")
success = self._set_data_single(index, value)
self.undo_stack.endMacro()
self.plotting_needs_break.emit(False)
return success
# multiple rows but only one column is selected
all_set = []
self.undo_stack.beginMacro("Set data")
for index in selected:
all_set.append(self._set_data_single(index, value))
self.undo_stack.endMacro()
self.plotting_needs_break.emit(False)
return all(all_set)
def _set_data_single(self, index, value):
"""Set the data of a single cell.
Internal method used by setData to update a single cell's value.
Handles special cases like new row creation, named index columns,
and type validation.
Args:
index: The model index to set data for
value: The new value to set
Returns:
bool: True if the data was set successfully, False otherwise
"""
row, column = index.row(), index.column()
fill_with_defaults = False
# Handle new row creation
if row == self._data_frame.shape[0]:
self.insertRows(row, 1)
fill_with_defaults = True
next_index = self.index(row, 0)
self.inserted_row.emit(next_index)
# Handle named index column
if column == 0 and self._has_named_index:
return_this = self.handle_named_index(index, value)
if fill_with_defaults:
self.get_default_values(index)
self.cell_needs_validation.emit(row, column)
return return_this
column_name = self._data_frame.columns[column - self.column_offset]
old_value = self._data_frame.iloc[row, column - self.column_offset]
# Handle invalid value
if is_invalid(value):
self._push_change_and_notify(
row, column, column_name, old_value, None
)
return True
# Type validation
expected_info = self._allowed_columns.get(column_name)
if expected_info:
expected_type = expected_info["type"]
validated, error = validate_value(value, expected_type)
if error:
self.new_log_message.emit(
f"Column '{column_name}' expects a value of type "
f"{expected_type.__name__}, but got '{value}'",
"red",
)
return False
value = validated
if value == old_value:
return False
# Special ID emitters
if column_name == petab.C.OBSERVABLE_ID:
if fill_with_defaults:
self.get_default_values(index, {column_name: value})
self.relevant_id_changed.emit(value, old_value, "observable")
self._push_change_and_notify(
row, column, column_name, old_value, value
)
return True
if column_name in [
petab.C.CONDITION_ID,
petab.C.SIMULATION_CONDITION_ID,
petab.C.PREEQUILIBRATION_CONDITION_ID,
]:
if fill_with_defaults:
self.get_default_values(index, {column_name: value})
self.relevant_id_changed.emit(value, old_value, "condition")
self._push_change_and_notify(
row, column, column_name, old_value, value
)
return True
# Default value setting
if fill_with_defaults:
self.get_default_values(index, {column_name: value})
self._push_change_and_notify(
row, column, column_name, old_value, value
)
return True
def _push_change_and_notify(
self, row, column, column_name, old_value, new_value
):
"""Push a dataframe change to the undo stack and emit signals.
Creates a ModifyDataFrameCommand for the change and adds it to the
undo stack. Also emits signals to notify views and other components
about the change.
Args:
row: The row index in the dataframe
column: The column index in the view
column_name: The name of the column being changed
old_value: The previous value in the cell
new_value: The new value to set in the cell
"""
change = {
(self._data_frame.index[row], column_name): (old_value, new_value)
}
self.undo_stack.push(ModifyDataFrameCommand(self, change))
self.dataChanged.emit(
self.index(row, column), self.index(row, column), [Qt.DisplayRole]
)
self.cell_needs_validation.emit(row, column)
self.something_changed.emit(True)
def clear_cells(self, selected):
"""Clear the values in the selected cells.
Sets all selected cells to None (empty) and groups the changes into a
single undo command for better undo/redo functionality.
Args:
selected:
A list of QModelIndex objects representing the selected cells
"""
self.undo_stack.beginMacro("Clear cells")
for index in selected:
if index.isValid():
self.setData(index, None, Qt.EditRole, False)
self.undo_stack.endMacro()
def handle_named_index(self, index, value):
"""Handle changes to the named index column.
This is a placeholder method in the base class. Subclasses that use
named indices (like IndexedPandasTableModel) override this method to
implement the actual behavior.
Args:
index: The model index of the cell being edited
value: The new value for the index
Returns:
bool: True if the index was successfully changed, False otherwise
"""
pass
def get_default_values(self, index, changed: dict | None = None):
"""Fill a row with default values based on the table's configuration.
This is a placeholder method in the base class. Subclasses override
this method to implement the actual behavior for filling default
values.
Args:
index:
The model index where the first change occurs
changed:
Dictionary of changes made to the DataFrame not yet registered
"""
pass
def replace_text(self, old_text: str, new_text: str):
"""Replace all occurrences of a text string in the table.
Searches for and replaces all instances of old_text with new_text in
both the data cells and index values (if using named indices).
Efficiently updates the view by emitting dataChanged signals only
for the affected cells.
Args:
old_text: The text to search for
new_text: The text to replace it with
"""
# find all occurrences of old_text and save indices
mask = self._data_frame.eq(old_text)
if mask.any().any():
self._data_frame.replace(old_text, new_text, inplace=True)
# Get first and last modified cell for efficient `dataChanged` emit
changed_cells = mask.stack()[
mask.stack()
].index.tolist() # Extract (row, col) pairs
if changed_cells:
first_row, first_col = changed_cells[0]
last_row, last_col = changed_cells[-1]
if self._has_named_index:
first_col += 1
last_col += 1
top_left = self.index(first_row, first_col)
bottom_right = self.index(last_row, last_col)
self.dataChanged.emit(top_left, bottom_right, [Qt.DisplayRole])
# also replace in the index
if self._has_named_index and old_text in self._data_frame.index:
self._data_frame.rename(index={old_text: new_text}, inplace=True)
index_row = self._data_frame.index.get_loc(new_text)
index_top_left = self.index(index_row, 0)
index_bottom_right = self.index(index_row, 0)
self.dataChanged.emit(
index_top_left, index_bottom_right, [Qt.DisplayRole]
)
def get_df(self):
"""Return the underlying pandas DataFrame.
Provides direct access to the DataFrame that this model wraps.
Returns:
pd.DataFrame: The DataFrame containing the table data
"""
return self._data_frame
def add_invalid_cell(self, row, column):
"""Mark a cell as invalid, giving it a special background color.
Adds the cell coordinates to the _invalid_cells set and triggers a UI
update to show the cell with an error background color. Performs
several validity checks before adding the cell.
Args:
row: The row index of the cell
column: The column index of the cell
"""
# check that the index is valid
if not self.index(row, column).isValid():
return
# return if it is the last row
if row == self._data_frame.shape[0]:
return
# return if it is already invalid
if (row, column) in self._invalid_cells:
return
self._invalid_cells.add((row, column))
self.dataChanged.emit(
self.index(row, column),
self.index(row, column),
[Qt.BackgroundRole],
)
def discard_invalid_cell(self, row, column):
"""Remove a cell from the invalid cells set, restoring its state.
Removes the cell coordinates from the _invalid_cells set and triggers
a UI update to restore the cell's normal background color.
Args:
row: The row index of the cell
column: The column index of the cell
"""
self._invalid_cells.discard((row, column))
self.dataChanged.emit(
self.index(row, column),
self.index(row, column),
[Qt.BackgroundRole],
)
def update_invalid_cells(self, selected, mode: str = "rows"):
"""Update invalid cell coordinates when rows or columns are deleted.
When rows or columns are deleted, the coordinates of invalid cells need
to be adjusted to account for the shifted indices. This method
recalculates the coordinates of all invalid cells based on the
deleted indices.
Args:
selected:
A set or list of indices (row or column) that are being deleted
mode:
Either "rows" or "columns" to indicate what is being deleted
"""
if not selected:
return
old_invalid_cells = self._invalid_cells.copy()
new_invalid_cells = set()
sorted_to_del = sorted(selected)
for a, b in old_invalid_cells:
if mode == "rows":
to_be_change = a
not_changed = b
elif mode == "columns":
to_be_change = b
not_changed = a
if to_be_change in selected:
continue
smaller_count = sum(1 for x in sorted_to_del if x < to_be_change)
new_val = to_be_change - smaller_count
if mode == "rows":
new_invalid_cells.add((new_val, not_changed))
if mode == "columns":
new_invalid_cells.add((not_changed, new_val))
self._invalid_cells = new_invalid_cells
def notify_data_color_change(self, row, column):
"""Notify the view that a cell's background color needs to be updated.
Emits a dataChanged signal with the BackgroundRole to trigger the view
to redraw the cell with its current background color.
Args:
row: The row index of the cell
column: The column index of the cell
"""
self.dataChanged.emit(
self.index(row, column),
self.index(row, column),
[Qt.BackgroundRole],
)
def get_value_from_column(self, column_name, row):
"""Retrieve the value from a specific column and row in the DataFrame.
Handles special cases like the "new row" at the end of the table and
accessing values from the index column.
Args:
column_name: The name of the column to get the value from
row: The row index to get the value from
Returns:
The value at the specified column and row, or an empty string
"""
# if row is a new row return ""
if row == self._data_frame.shape[0]:
return ""
if column_name in self._data_frame.columns:
return self._data_frame.loc[row, column_name]
if column_name == self._data_frame.index.name:
return self._data_frame.index[row]
return ""
def return_column_index(self, column_name):
"""Return the view column index for a given column name.
This is a placeholder method in the base class. Subclasses override
this method to implement the actual behavior for mapping column
names to view indices.
Args:
column_name: The name of the column to find the index for
Returns:
int: The view column index for the given column name, or -1
"""
if column_name in self._data_frame.columns:
return self._data_frame.columns.get_loc(column_name)
return -1
def unique_values(self, column_name):
"""Return a list of unique values in a specified column.
Used for providing suggestions in autocomplete fields/dropdown lists.
Handles both regular columns and the index column.
Args:
column_name: The name of the column to get unique values from
Returns:
list: A list of unique values from the column, or an empty list
"""
if column_name in self._data_frame.columns:
return list(self._data_frame[column_name].dropna().unique())
if column_name == self._data_frame.index.name:
return list(self._data_frame.index.dropna().unique())
return []
def delete_row(self, row):
"""Delete a row from the table.
Creates a ModifyRowCommand for the deletion and adds it to the stack
to support undo/redo functionality.
Args:
row: The index of the row to delete
"""
if self.undo_stack:
self.undo_stack.push(ModifyRowCommand(self, row, False))
else:
# Fallback if undo stack isn't used
command = ModifyRowCommand(self, row, False)
command.redo()
def delete_column(self, column_index):
"""Delete a column from the table.
Maps the view column index to the actual DataFrame column name and
creates a ModifyColumnCommand for the deletion. Adds the command to
the stack to support undo/redo functionality.
Args:
column_index: The view index of the column to delete
"""
column_name = self._data_frame.columns[
column_index - self.column_offset
]
if self.undo_stack:
self.undo_stack.push(ModifyColumnCommand(self, column_name, False))
else:
# Fallback if undo stack isn't used
command = ModifyColumnCommand(self, column_name, False)
command.redo()
def clear_table(self):
"""Clear all data from the table."""
self.beginResetModel()
self._data_frame.drop(self._data_frame.index, inplace=True)
self._data_frame.drop(
self._data_frame.columns.difference(
COLUMNS[self.table_type].keys()
),
axis=1,
inplace=True,
)
self.endResetModel()
def check_selection(self):
"""Check if multiple rows but only one column is selected in the view.
Used to determine if a multi-row edit operation should be performed,
when setting data. This allows for efficiently applying the same
change to multiple cells in the same column.
Returns:
tuple: A tuple containing:
- bool: True if multiple rows but only one column is selected
- list: The list of selected QModelIndex objects, or None
"""
if self.view is None:
return False, None
selected = get_selected(self.view, mode="index")
cols = {index.column() for index in selected}
rows = {index.row() for index in selected}
return len(rows) > 1 and len(cols) == 1, selected
def reset_invalid_cells(self):
"""Clear all invalid cell markings and update their appearance.
Removes all cells from the _invalid_cells set and triggers UI updates
to restore their normal background colors.
This is useful when reloading data or when validation state needs to be
reset.
"""
if not self._invalid_cells:
return
invalid_cells = list(self._invalid_cells)
self._invalid_cells.clear() # Clear invalid cells set
for row, col in invalid_cells:
index = self.index(row, col)
self.dataChanged.emit(index, index, [Qt.BackgroundRole])
def mimeData(self, rectangle, start_index):
"""Return the data to be copied to the clipboard.
Formats the selected cells' data as tab-separated text for clipboard
operations.
Args:
rectangle:
A numpy array representing the selected cells, where True values
indicate selected cells within the minimum bounding rectangle
start_index:
A tuple (row, col) indicating the top-left corner of the selection
Returns:
QMimeData: A mime data object containing the formatted text data
"""
copied_data = ""
for row in range(rectangle.shape[0]):
for col in range(rectangle.shape[1]):
if rectangle[row, col]:
copied_data += self.data(
self.index(start_index[0] + row, start_index[1] + col),
Qt.DisplayRole,
)
else:
copied_data += "SKIP"
if col < rectangle.shape[1] - 1:
copied_data += "\t"
copied_data += "\n"
mime_data = QMimeData()
mime_data.setText(copied_data.strip())
return mime_data
def setDataFromText(self, text, start_row, start_column):
"""Set table data from tab-separated text.
Used for pasting clipboard content into the table. Parses the text as
tab-separated values and sets the data in the table starting from the
specified position. Groups all changes into a single undo command.
Args:
text: The tab-separated text to parse and set in the table
start_row: The row index where to start setting data
start_column: The column index where to start setting data
"""
lines = text.split("\n")
self.undo_stack.beginMacro("Paste from Clipboard")
self.maybe_add_rows(start_row, len(lines))
for row_offset, line in enumerate(lines):
values = line.split("\t")
for col_offset, value in enumerate(values):
if value == "SKIP":
continue
self.setData(
self.index(
start_row + row_offset, start_column + col_offset
),
value,
Qt.EditRole,
)
self.undo_stack.endMacro()
def maybe_add_rows(self, start_row, n_rows):
"""Add rows to the table if there aren't enough.
Used during paste operations to ensure there are enough rows for the
pasted data. Adds rows if the current number of rows is insufficient.
Args:
start_row: The row index where data insertion begins
n_rows: The number of rows needed for the data
"""
if start_row + n_rows > self._data_frame.shape[0]:
self.insertRows(
self._data_frame.shape[0],
start_row + n_rows - self._data_frame.shape[0],
)
def determine_background_color(self, row, column):
"""Determine the background color for a specific cell.
Applies different background colors based on cell properties:
- Light green for the "New row" cell (first column of last row)
- System highlight color for cells that match search criteria
- Red for cells marked as invalid
- Alternating light blue and light green for even/odd rows
Args:
row: The row index of the cell
column: The column index of the cell
Returns:
QColor: The background color to use for the cell
"""
if (row, column) == (self._data_frame.shape[0], 0):
return QColor(144, 238, 144, 150)
if (row, column) in self.highlighted_cells:
return self._highlight_bg_color
if (row, column) in self._invalid_cells:
return QColor(255, 100, 100, 150)
if row % 2 == 0:
return QColor(144, 190, 109, 102)
return QColor(177, 217, 231, 102)
def allow_column_deletion(
self, column: int
) -> tuple[bool, Any] | tuple[Any, Any]:
"""Check whether a column can safely be deleted from the table.
Prevents deletion of required columns and the index column.
Used to validate column deletion requests before they are processed.
Args:
column: The view index of the column to check
Returns:
tuple: A tuple containing:
- bool: True if the column can be deleted, False otherwise
- str: The name of the column
"""
if column == 0 and self._has_named_index:
return False, self._data_frame.index.name
column_name = self._data_frame.columns[column - self.column_offset]
if column_name not in self._allowed_columns:
return True, column_name
return self._allowed_columns[column_name]["optional"], column_name
def endResetModel(self):
"""Override endResetModel to reset the default handler."""
super().endResetModel()
self.config = settings_manager.get_table_defaults(self.table_type)
sbml_model = self.default_handler._sbml_model
self.default_handler = DefaultHandlerModel(
self, self.config, sbml_model=sbml_model
)
def fill_row(self, row_position: int, data: dict):
"""Fill a row with data.
Parameters
----------
row_position:
The position of the row to fill.
data:
The data to fill the row with. Gets updated with default values.
"""
data_to_add = dict.fromkeys(self._data_frame.columns, "")
unknown_keys = set(data) - set(self._data_frame.columns)
index_key = None
for key in unknown_keys:
if key == self._data_frame.index.name:
index_key = data.pop(key)
continue
data.pop(key, None)
data_to_add.update(data)
if index_key and self._has_named_index:
self.undo_stack.push(
RenameIndexCommand(
self,
self._data_frame.index.tolist()[row_position],
index_key,
self.index(row_position, 0),
)
)
if index_key is None:
index_key = self._data_frame.index.tolist()[row_position]
changes = {
(index_key, col): (self._data_frame.at[index_key, col], val)
for col, val in data_to_add.items()
if val not in [self._data_frame.at[index_key, col], "", None]
}
self.undo_stack.push(
ModifyDataFrameCommand(self, changes, "Fill values")
)
# rename changes keys to only the col names
changes = {col: val for (_, col), val in changes.items()}
self.get_default_values(
self.index(row_position, 0),
changed=changes,
)