Skip to content

Commit 243cdef

Browse files
Proper Find Replace Search (#72)
* Search Replace works for for observable table * All observable Tables find/replace * Toggling visibility
1 parent b347dd1 commit 243cdef

8 files changed

Lines changed: 514 additions & 25 deletions

File tree

src/petab_gui/app.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ def __init__(self):
2626
super().__init__(sys.argv)
2727

2828
# Load the styleshee
29-
self.apply_stylesheet()
29+
# self.apply_stylesheet()
3030
self.model = PEtabModel()
3131
self.view = MainWindow()
3232
self.controller = MainController(self.view, self.model)

src/petab_gui/controllers/mother_controller.py

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -229,13 +229,18 @@ def setup_actions(self):
229229
actions["save"].setShortcut("Ctrl+S")
230230
actions["save"].triggered.connect(self.save_model)
231231
# Find + Replace
232+
actions["find"] = QAction(
233+
qta.icon("mdi6.magnify"),
234+
"Find", self.view
235+
)
236+
actions["find"].setShortcut("Ctrl+F")
237+
actions["find"].triggered.connect(self.find)
232238
actions["find+replace"] = QAction(
233239
qta.icon("mdi6.find-replace"),
234240
"Find/Replace", self.view
235241
)
236242
actions["find+replace"].setShortcut("Ctrl+R")
237-
actions["find+replace"].triggered.connect(
238-
self.open_find_replace_dialog)
243+
actions["find+replace"].triggered.connect(self.replace)
239244
# Copy / Paste
240245
actions["copy"] = QAction(
241246
qta.icon("mdi6.content-copy"),
@@ -731,3 +736,19 @@ def paste_from_clipboard(self):
731736
controller = self.active_controller()
732737
if controller:
733738
controller.paste_from_clipboard()
739+
740+
def set_docks_visible(self):
741+
"""Handles Visibility of docks."""
742+
pass
743+
744+
def find(self):
745+
"""Create a find replace bar if it is non existent."""
746+
if self.view.find_replace_bar is None:
747+
self.view.create_find_replace_bar()
748+
self.view.toggle_find()
749+
750+
def replace(self):
751+
"""Create a find replace bar if it is non existent."""
752+
if self.view.find_replace_bar is None:
753+
self.view.create_find_replace_bar()
754+
self.view.toggle_replace()

src/petab_gui/controllers/table_controllers.py

Lines changed: 134 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
"""Classes for the controllers of the tables in the GUI."""
22
from PySide6.QtWidgets import QInputDialog, QMessageBox, QFileDialog, \
3-
QCompleter
3+
QCompleter, QAbstractItemView
44
import numpy as np
55
import pandas as pd
66
import petab.v1 as petab
7-
from PySide6.QtCore import Signal, QObject, QModelIndex, Qt
7+
from PySide6.QtCore import Signal, QObject, QModelIndex, Qt, QTimer
88
from pathlib import Path
99
from ..models.pandas_table_model import PandasTableModel, \
1010
PandasTableFilterProxy
@@ -13,6 +13,7 @@
1313
from ..utils import get_selected, process_file
1414
from .utils import prompt_overwrite_or_append
1515
from ..C import COLUMN
16+
import re
1617

1718

1819
class TableController(QObject):
@@ -277,13 +278,6 @@ def add_column(self, column_name: str = None):
277278
return
278279
self.model.insertColumn(column_name)
279280

280-
def replace_text(self, find_text, replace_text):
281-
self.logger.log_message(
282-
f"Replacing '{find_text}' with '{replace_text}' in selected tables",
283-
color="green"
284-
)
285-
self.model.replace_text(find_text, replace_text)
286-
287281
def set_index_on_new_row(self, index: QModelIndex):
288282
"""Set the index of the model when a new row is added."""
289283
self.view.table_view.setCurrentIndex(index)
@@ -314,12 +308,143 @@ def paste_from_clipboard(self):
314308
f"PEtab linter failed after copying: {str(e)}",
315309
color="red"
316310
)
311+
317312
def check_petab_lint(self, row_data):
318313
"""Check a single row of the model with petablint."""
319314
raise NotImplementedError(
320315
"This method must be implemented in child classes."
321316
)
322317

318+
def find_text(
319+
self, text, case_sensitive=False, regex=False, whole_cell=False
320+
):
321+
"""Efficiently find all matching cells."""
322+
df = self.model.get_df()
323+
324+
# Search in the main DataFrame
325+
if regex:
326+
pattern = re.compile(text, 0 if case_sensitive else re.IGNORECASE)
327+
mask = df.map(lambda cell: bool(pattern.fullmatch(str(cell))) if whole_cell else bool(pattern.search(str(cell))))
328+
else:
329+
text = text.lower() if not case_sensitive else text
330+
mask = df.map(lambda cell: text == str(cell).lower() if whole_cell else text in str(cell).lower()) if not case_sensitive else \
331+
df.map(lambda cell: text == str(cell) if whole_cell else text in str(cell))
332+
333+
# Find matches
334+
match_indices = list(zip(*mask.to_numpy().nonzero()))
335+
table_matches = [(row, col + self.model.column_offset) for row, col in match_indices]
336+
337+
# Search in the index if it's named
338+
index_matches = []
339+
if isinstance(df.index, pd.Index) and df.index.name:
340+
if regex:
341+
index_mask = df.index.to_series().map(lambda idx: bool(pattern.fullmatch(str(idx))) if whole_cell else bool(pattern.search(str(idx))))
342+
else:
343+
index_mask = df.index.to_series().map(lambda idx: text == str(idx).lower() if whole_cell else text in str(idx).lower()) if not case_sensitive else \
344+
df.index.to_series().map(lambda idx: text == str(idx) if whole_cell else text in str(idx))
345+
346+
index_matches = [(df.index.get_loc(idx), 0) for idx in index_mask[index_mask].index]
347+
348+
all_matches = index_matches + table_matches
349+
350+
# 🔹 Highlight matched text
351+
self.highlight_text(all_matches)
352+
return all_matches
353+
354+
def highlight_text(self, matches):
355+
"""Color the text of all matched cells in yellow."""
356+
self.model.highlighted_cells = set(matches)
357+
top_left = self.model.index(0, 0)
358+
bottom_right = self.model.index(self.model.rowCount() - 1,
359+
self.model.columnCount() - 1)
360+
self.model.dataChanged.emit(top_left, bottom_right,
361+
[Qt.ForegroundRole])
362+
363+
def cleanse_highlighted_cells(self):
364+
"""Cleanses the highlighted cells."""
365+
self.model.highlighted_cells = set()
366+
top_left = self.model.index(0, 0)
367+
bottom_right = self.model.index(self.model.rowCount() - 1,
368+
self.model.columnCount() - 1)
369+
self.model.dataChanged.emit(top_left, bottom_right,
370+
[Qt.ForegroundRole])
371+
372+
def focus_match(self, match):
373+
"""Focus and select the given match in the table."""
374+
if match is None:
375+
self.view.table_view.clearSelection()
376+
return
377+
row, col = match
378+
index = self.model.index(row, col)
379+
if not index.isValid():
380+
return
381+
proxy_index = self.view.table_view.model().mapFromSource(index)
382+
if not proxy_index.isValid():
383+
return
384+
385+
self.view.table_view.setCurrentIndex(proxy_index)
386+
self.view.table_view.setCurrentIndex(proxy_index)
387+
self.view.table_view.scrollTo(
388+
proxy_index, QAbstractItemView.EnsureVisible
389+
)
390+
391+
def replace_text(self, row, col, replace_text, search_text, case_sensitive, regex):
392+
"""Replace the text in the given cell and update highlights."""
393+
index = self.model.index(row, col)
394+
original_text = self.model.data(index, Qt.DisplayRole)
395+
396+
if not original_text:
397+
return
398+
399+
if regex:
400+
pattern = re.compile(search_text, 0 if case_sensitive else re.IGNORECASE)
401+
new_text = pattern.sub(replace_text, original_text)
402+
else:
403+
if not case_sensitive:
404+
search_text = re.escape(search_text.lower())
405+
new_text = re.sub(search_text, replace_text, original_text, flags=re.IGNORECASE)
406+
else:
407+
new_text = original_text.replace(search_text, replace_text)
408+
409+
if new_text != original_text:
410+
self.model.setData(index, new_text, Qt.EditRole)
411+
self.model.highlighted_cells.discard((row, col))
412+
self.model.dataChanged.emit(index, index, [Qt.DisplayRole])
413+
414+
def replace_all(
415+
self, search_text, replace_text, case_sensitive=False, regex=False
416+
):
417+
"""Replace all occurrences of the search term in the Model."""
418+
if not search_text or not replace_text:
419+
return
420+
421+
df = self.model._data_frame
422+
if regex:
423+
pattern = re.compile(search_text,
424+
0 if case_sensitive else re.IGNORECASE)
425+
df.replace(to_replace=pattern, value=replace_text, regex=True,
426+
inplace=True)
427+
else:
428+
if not case_sensitive:
429+
df.replace(
430+
to_replace=re.escape(search_text),
431+
value=replace_text,
432+
regex=True,
433+
inplace=True
434+
)
435+
else:
436+
df.replace(to_replace=search_text, value=replace_text,
437+
inplace=True)
438+
439+
# Replace in the index as well
440+
if isinstance(df.index, pd.Index) and df.index.name:
441+
index_map = {
442+
idx: pattern.sub(replace_text, str(idx)) if regex else str(
443+
idx).replace(search_text, replace_text)
444+
for idx in df.index if search_text in str(idx)}
445+
if index_map:
446+
df.rename(index=index_map, inplace=True)
447+
323448

324449
class MeasurementController(TableController):
325450
"""Controller of the Measurement table."""

src/petab_gui/models/pandas_table_model.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import pandas as pd
22
from PySide6.QtCore import (Qt, QAbstractTableModel, QModelIndex, Signal,
33
QSortFilterProxyModel, QMimeData)
4-
from PySide6.QtGui import QColor
4+
from PySide6.QtGui import QColor, QBrush
55

66
from ..C import COLUMNS
77
from ..utils import validate_value, create_empty_dataframe, is_invalid, \
@@ -22,6 +22,7 @@ def __init__(self, data_frame, allowed_columns, table_type, parent=None):
2222
self._allowed_columns = allowed_columns
2323
self.table_type = table_type
2424
self._invalid_cells = set()
25+
self.highlighted_cells = set()
2526
self._has_named_index = False
2627
if data_frame is None:
2728
data_frame = create_empty_dataframe(allowed_columns, table_type)
@@ -57,6 +58,11 @@ def data(self, index, role=Qt.DisplayRole):
5758
return str(value)
5859
elif role == Qt.BackgroundRole:
5960
return self.determine_background_color(row, column)
61+
elif role == Qt.ForegroundRole:
62+
# Return yellow text if this cell is a match
63+
if (row, column) in self.highlighted_cells:
64+
return QBrush(QColor(255, 255, 0)) # Yellow color
65+
return QBrush(QColor(0, 0, 0)) # Default black text
6066
return None
6167

6268
def flags(self, index):
@@ -133,6 +139,10 @@ def insertColumn(self, column_name: str):
133139
def setData(self, index, value, role=Qt.EditRole):
134140
if not (index.isValid() and role == Qt.EditRole):
135141
return False
142+
143+
if role != Qt.EditRole:
144+
return False
145+
136146
if is_invalid(value) or value == "":
137147
value = None
138148
# check whether multiple rows but only one column is selected

0 commit comments

Comments
 (0)