|
1 | 1 | """Classes for the controllers of the tables in the GUI.""" |
2 | 2 | from PySide6.QtWidgets import QInputDialog, QMessageBox, QFileDialog, \ |
3 | | - QCompleter |
| 3 | + QCompleter, QAbstractItemView |
4 | 4 | import numpy as np |
5 | 5 | import pandas as pd |
6 | 6 | import petab.v1 as petab |
7 | | -from PySide6.QtCore import Signal, QObject, QModelIndex, Qt |
| 7 | +from PySide6.QtCore import Signal, QObject, QModelIndex, Qt, QTimer |
8 | 8 | from pathlib import Path |
9 | 9 | from ..models.pandas_table_model import PandasTableModel, \ |
10 | 10 | PandasTableFilterProxy |
|
13 | 13 | from ..utils import get_selected, process_file |
14 | 14 | from .utils import prompt_overwrite_or_append |
15 | 15 | from ..C import COLUMN |
| 16 | +import re |
16 | 17 |
|
17 | 18 |
|
18 | 19 | class TableController(QObject): |
@@ -277,13 +278,6 @@ def add_column(self, column_name: str = None): |
277 | 278 | return |
278 | 279 | self.model.insertColumn(column_name) |
279 | 280 |
|
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 | | - |
287 | 281 | def set_index_on_new_row(self, index: QModelIndex): |
288 | 282 | """Set the index of the model when a new row is added.""" |
289 | 283 | self.view.table_view.setCurrentIndex(index) |
@@ -314,12 +308,143 @@ def paste_from_clipboard(self): |
314 | 308 | f"PEtab linter failed after copying: {str(e)}", |
315 | 309 | color="red" |
316 | 310 | ) |
| 311 | + |
317 | 312 | def check_petab_lint(self, row_data): |
318 | 313 | """Check a single row of the model with petablint.""" |
319 | 314 | raise NotImplementedError( |
320 | 315 | "This method must be implemented in child classes." |
321 | 316 | ) |
322 | 317 |
|
| 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 | + |
323 | 448 |
|
324 | 449 | class MeasurementController(TableController): |
325 | 450 | """Controller of the Measurement table.""" |
|
0 commit comments