Skip to content

Commit 0bd5333

Browse files
Filtering enabled! (#63)
* Filtering enabled! * adding numpy import * Fixed crash due to layoutChanged.emit() * Removed further layoutChanged.emit() instances
1 parent 3044fb3 commit 0bd5333

3 files changed

Lines changed: 116 additions & 55 deletions

File tree

src/petab_gui/controllers/mother_controller.py

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -292,12 +292,12 @@ def setup_actions(self):
292292
filter_layout = QHBoxLayout()
293293
filter_layout.setContentsMargins(0, 0, 0, 0)
294294
filter_widget.setLayout(filter_layout)
295-
filter_input = QLineEdit()
296-
filter_input.setPlaceholderText("Filter not functional yet ...")
297-
filter_layout.addWidget(filter_input)
295+
self.filter_input = QLineEdit()
296+
self.filter_input.setPlaceholderText("Filter not functional yet ...")
297+
filter_layout.addWidget(self.filter_input)
298298
for table_n, table_name in zip(
299-
["m", "p", "o", "c", "x"],
300-
["Measurement", "Parameter", "Observable", "Condition", "SBML"]
299+
["m", "p", "o", "c"],
300+
["measurement", "parameter", "observable", "condition"]
301301
):
302302
tool_button = QToolButton()
303303
icon = qta.icon(
@@ -312,7 +312,11 @@ def setup_actions(self):
312312
tool_button.setToolTip(f"Filter for {table_name}")
313313
filter_layout.addWidget(tool_button)
314314
self.filter_active[table_name] = tool_button
315+
self.filter_active[table_name].toggled.connect(
316+
self.filter_table
317+
)
315318
actions["filter_widget"] = filter_widget
319+
self.filter_input.textChanged.connect(self.filter_table)
316320

317321
# show/hide elements
318322
for element in ["measurement", "observable", "parameter", "condition"]:
@@ -707,6 +711,17 @@ def delete_column(self):
707711
if controller:
708712
controller.delete_column()
709713

714+
def filter_table(self):
715+
"""Filter the currently activated tables"""
716+
filter_text = self.filter_input.text()
717+
for table_name, tool_button in self.filter_active.items():
718+
if tool_button.isChecked():
719+
controller = getattr(self, f"{table_name}_controller")
720+
controller.filter_table(filter_text)
721+
else:
722+
controller = getattr(self, f"{table_name}_controller")
723+
controller.remove_filter()
724+
710725
def copy_to_clipboard(self):
711726
controller = self.active_controller()
712727
if controller:

src/petab_gui/controllers/table_controllers.py

Lines changed: 44 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
import numpy as np
55
import pandas as pd
66
import petab.v1 as petab
7-
from PySide6.QtCore import Signal, QObject, QModelIndex, QPoint
7+
from PySide6.QtCore import Signal, QObject, QModelIndex, Qt
88
from pathlib import Path
99
from ..models.pandas_table_model import PandasTableModel, \
1010
PandasTableFilterProxy
@@ -48,7 +48,7 @@ def __init__(
4848
self.logger = logger
4949
self.check_petab_lint_mode = True
5050
self.mother_controller = mother_controller
51-
self.view.table_view.setModel(self.model)
51+
self.view.table_view.setModel(self.proxy_model)
5252
self.setup_connections()
5353
self.setup_connections_specific()
5454

@@ -160,12 +160,16 @@ def overwrite_df(self, new_df: pd.DataFrame):
160160
# TODO: Mother controller connects to overwritten_df signal. Set df
161161
# in petabProblem and unsaved changes to True
162162
"""Overwrite the DataFrame of the model with the data from the view."""
163+
self.proxy_model.setSourceModel(None)
164+
self.model.beginResetModel()
163165
self.model._data_frame = new_df
164-
self.model.layoutChanged.emit()
166+
self.model.beginResetModel()
165167
self.logger.log_message(
166168
f"Overwrote the {self.model.table_type} table with new data.",
167169
color="green"
168170
)
171+
# test: overwrite the new model as source model
172+
self.proxy_model.setSourceModel(self.model)
169173
self.overwritten_df.emit()
170174

171175
def append_df(self, new_df: pd.DataFrame):
@@ -175,17 +179,20 @@ def append_df(self, new_df: pd.DataFrame):
175179
1. Columns are the union of both DataFrame columns.
176180
2. Rows are the union of both DataFrame rows (duplicates removed)
177181
"""
178-
self.model._data_frame = pd.concat(
182+
self.model.beginResetModel()
183+
combined_df = pd.concat(
179184
[self.model.get_df(), new_df], axis=0
180185
)
181-
self.model._data_frame = self.model._data_frame[
182-
~self.model._data_frame.index.duplicated(keep="first")
183-
]
184-
self.model.layoutChanged.emit()
186+
combined_df = combined_df[~combined_df.index.duplicated(keep="first")]
187+
self.model._data_frame = combined_df
188+
self.proxy_model.setSourceModel(None)
189+
self.proxy_model.setSourceModel(self.model)
190+
self.model.endResetModel()
185191
self.logger.log_message(
186192
f"Appended the {self.model.table_type} table with new data.",
187193
color="green"
188194
)
195+
# test: overwrite the new model as source model
189196
self.overwritten_df.emit()
190197

191198
def clear_table(self):
@@ -271,6 +278,16 @@ def set_index_on_new_row(self, index: QModelIndex):
271278
"""Set the index of the model when a new row is added."""
272279
self.view.table_view.setCurrentIndex(index)
273280

281+
def filter_table(self, text):
282+
"""Filter the table."""
283+
self.proxy_model.setFilterRegularExpression(text)
284+
self.proxy_model.setFilterKeyColumn(-1)
285+
286+
def remove_filter(self):
287+
"""Remove the filter from the table."""
288+
self.proxy_model.setFilterRegularExpression("")
289+
self.proxy_model.setFilterKeyColumn(-1)
290+
274291
def copy_to_clipboard(self):
275292
"""Copy the currently selected cells to the clipboard."""
276293
self.view.copy_to_clipboard()
@@ -322,13 +339,25 @@ def rename_value(self, old_id: str, new_id: str, column_names: str | list[str]):
322339
"""
323340
if not isinstance(column_names, list):
324341
column_names = [column_names]
325-
rows = self.model.get_df().shape[0]
326-
for row in range(rows):
327-
for column_name in column_names:
328-
if self.model._data_frame.at[row, column_name] == old_id:
329-
self.model._data_frame.at[row, column_name] = new_id
330-
self.model.something_changed.emit(True)
331-
self.model.layoutChanged.emit()
342+
343+
# Find occurences
344+
mask = self.model._data_frame[column_names].eq(old_id)
345+
if mask.any().any():
346+
self.model._data_frame.loc[mask] = new_id
347+
changed_rows = mask.any(axis=1)
348+
first_row, last_row = (
349+
changed_rows.idxmax(), changed_rows[::-1].idxmax()
350+
)
351+
top_left = self.model.index(first_row, 1)
352+
bottom_right = self.model.index(
353+
last_row, self.model.columnCount() - 1
354+
)
355+
self.model.dataChanged.emit(
356+
top_left, bottom_right, [Qt.DisplayRole, Qt.EditRole]
357+
)
358+
359+
# Emit change signal
360+
self.model.something_changed.emit(True)
332361

333362
def copy_noise_parameters(
334363
self,

src/petab_gui/models/pandas_table_model.py

Lines changed: 52 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,8 @@
44
from PySide6.QtGui import QColor
55

66
from ..C import COLUMNS
7-
from ..utils import validate_value, create_empty_dataframe, is_invalid, get_selected
7+
from ..utils import validate_value, create_empty_dataframe, is_invalid, \
8+
get_selected
89

910

1011
class PandasTableModel(QAbstractTableModel):
@@ -47,7 +48,7 @@ def data(self, index, role=Qt.DisplayRole):
4748
if column == 0:
4849
value = self._data_frame.index[row]
4950
return str(value)
50-
value = self._data_frame.iloc[row, column-1]
51+
value = self._data_frame.iloc[row, column - 1]
5152
if is_invalid(value):
5253
return ""
5354
return str(value)
@@ -118,7 +119,8 @@ def insertColumn(self, column_name: str):
118119
)
119120
position = self._data_frame.shape[1]
120121
self.beginInsertColumns(QModelIndex(), position, position)
121-
column_type = self._allowed_columns.get(column_name, {"type": "STRING"})["type"]
122+
column_type = \
123+
self._allowed_columns.get(column_name, {"type": "STRING"})["type"]
122124
default_value = "" if column_type == "STRING" else 0
123125
self._data_frame[column_name] = default_value
124126
self.endInsertColumns()
@@ -148,7 +150,6 @@ def _set_data_single(self, index, value):
148150
if index.row() == self._data_frame.shape[0]:
149151
# empty row at the end
150152
self.insertRows(index.row(), 1)
151-
self.layoutChanged.emit()
152153
next_index = self.index(index.row(), 0)
153154
self.inserted_row.emit(next_index)
154155
if index.column() == 0 and self._has_named_index:
@@ -177,7 +178,8 @@ def _set_data_single(self, index, value):
177178
self.cell_needs_validation.emit(row, column)
178179
self.something_changed.emit(True)
179180
return True
180-
if column_name in ["conditionId", "simulationConditionId", "preequilibrationConditionId"]:
181+
if column_name in ["conditionId", "simulationConditionId",
182+
"preequilibrationConditionId"]:
181183
self._data_frame.iloc[row, column - col_setoff] = value
182184
self.dataChanged.emit(index, index, [Qt.DisplayRole])
183185
self.relevant_id_changed.emit(value, old_value, "condition")
@@ -214,11 +216,31 @@ def handle_named_index(self, index, value):
214216

215217
def replace_text(self, old_text: str, new_text: str):
216218
"""Replace text in the table."""
217-
self._data_frame.replace(old_text, new_text, inplace=True)
219+
# find all occurences of old_text and sae indices
220+
mask = self._data_frame.eq(old_text)
221+
if mask.any().any():
222+
self._data_frame.replace(old_text, new_text, inplace=True)
223+
# Get first and last modified cell for efficient `dataChanged` emit
224+
changed_cells = mask.stack()[
225+
mask.stack()].index.tolist() # Extract (row, col) pairs
226+
if changed_cells:
227+
first_row, first_col = changed_cells[0]
228+
last_row, last_col = changed_cells[-1]
229+
if self._has_named_index:
230+
first_col += 1
231+
last_col += 1
232+
top_left = self.index(first_row, first_col)
233+
bottom_right = self.index(last_row, last_col)
234+
self.dataChanged.emit(top_left, bottom_right, [Qt.DisplayRole])
218235
# also replace in the index
219-
if self._has_named_index:
236+
if self._has_named_index and old_text in self._data_frame.index:
220237
self._data_frame.rename(index={old_text: new_text}, inplace=True)
221-
self.layoutChanged.emit()
238+
index_row = self._data_frame.index.get_loc(new_text)
239+
index_top_left = self.index(index_row, 0)
240+
index_bottom_right = self.index(index_row, 0)
241+
self.dataChanged.emit(
242+
index_top_left, index_bottom_right, [Qt.DisplayRole]
243+
)
222244

223245
def get_df(self):
224246
"""Return the DataFrame."""
@@ -332,10 +354,16 @@ def check_selection(self):
332354
return len(rows) > 1 and len(cols) == 1, selected
333355

334356
def reset_invalid_cells(self):
335-
"""Reset the invalid cells."""
336-
self._invalid_cells = set()
337-
self.layoutChanged.emit()
357+
"""Reset the invalid cells and update their background color."""
358+
if not self._invalid_cells:
359+
return
360+
361+
invalid_cells = list(self._invalid_cells)
362+
self._invalid_cells.clear() # Clear invalid cells set
338363

364+
for row, col in invalid_cells:
365+
index = self.index(row, col)
366+
self.dataChanged.emit(index, index, [Qt.BackgroundRole])
339367
def mimeData(self, rectangle, start_index):
340368
"""Return the data to be copied to the clipboard.
341369
@@ -528,6 +556,7 @@ def return_column_index(self, column_name):
528556

529557
class ObservableModel(IndexedPandasTableModel):
530558
"""Table model for the observable data."""
559+
531560
def __init__(self, data_frame, parent=None):
532561
super().__init__(
533562
data_frame=data_frame,
@@ -562,6 +591,7 @@ def fill_row(self, row_position: int, data: dict):
562591

563592
class ParameterModel(IndexedPandasTableModel):
564593
"""Table model for the parameter data."""
594+
565595
def __init__(self, data_frame, parent=None):
566596
super().__init__(
567597
data_frame=data_frame,
@@ -573,6 +603,7 @@ def __init__(self, data_frame, parent=None):
573603

574604
class ConditionModel(IndexedPandasTableModel):
575605
"""Table model for the condition data."""
606+
576607
def __init__(self, data_frame, parent=None):
577608
super().__init__(
578609
data_frame=data_frame,
@@ -610,36 +641,22 @@ def __init__(self, model, parent=None):
610641
super().__init__(parent)
611642
self.source_model = model
612643
self.setSourceModel(model)
613-
self.column_filters = {} # Store filters for multiple columns
614-
615-
def setFilterForColumn(self, column, pattern):
616-
"""Set filter pattern for a specific column."""
617-
if pattern:
618-
self.column_filters[column] = pattern # Add or update filter for the column
619-
else:
620-
self.column_filters.pop(column, None) # Remove filter if pattern is empty
621-
self.invalidateFilter() # Trigger the proxy to re-evaluate the filters
622644

623645
def filterAcceptsRow(self, source_row, source_parent):
624-
"""Custom filtering logic to apply filters on multiple columns."""
646+
"""Custom filtering logic to apply global filtering across all columns."""
625647
source_model = self.sourceModel()
626648

627649
# Always accept the last row (for "add new row")
628650
if source_row == source_model.rowCount() - 1:
629651
return True
630652

631-
# Apply all column filters
632-
for column, pattern in self.column_filters.items():
633-
index = source_model.index(source_row, column, source_parent)
634-
value = source_model.data(index, Qt.DisplayRole)
635-
if not self.valueMatchesFilter(value, pattern):
636-
return False # Reject the row if any column doesn't match its filter
637-
638-
return True # Accept row if it matches all filters
639-
640-
def valueMatchesFilter(self, value, pattern):
641-
"""Check if the value matches the filter pattern."""
642-
if pattern and pattern not in str(value):
643-
return False
644-
return True
653+
regex = self.filterRegularExpression()
654+
if regex.pattern() == "":
655+
return True
645656

657+
for column in range(source_model.columnCount()):
658+
index = source_model.index(source_row, column, QModelIndex())
659+
data_str = str(source_model.data(index) or "")
660+
if regex.match(data_str).hasMatch():
661+
return True
662+
return False # No match found

0 commit comments

Comments
 (0)