From 276cf151df01f857f7a08962633dbcd6236880a0 Mon Sep 17 00:00:00 2001 From: PaulJonasJost Date: Thu, 23 Jan 2025 11:22:16 +0100 Subject: [PATCH 1/3] Enable copy pasting as it is behaviour in Numbers/Excel --- .../controllers/mother_controller.py | 25 +++++++++- .../controllers/table_controllers.py | 9 ++++ src/petab_gui/models/pandas_table_model.py | 50 ++++++++++++++++++- src/petab_gui/utils.py | 23 +++++++++ src/petab_gui/views/table_view.py | 26 ++++++++++ src/petab_gui/views/task_bar.py | 3 ++ 6 files changed, 134 insertions(+), 2 deletions(-) diff --git a/src/petab_gui/controllers/mother_controller.py b/src/petab_gui/controllers/mother_controller.py index 9124e77..b85cb93 100644 --- a/src/petab_gui/controllers/mother_controller.py +++ b/src/petab_gui/controllers/mother_controller.py @@ -119,7 +119,7 @@ def setup_connections(self): self.observable_controller.observable_2be_renamed.connect( partial( self.measurement_controller.rename_value, - column_names = "observableId" + column_names="observableId" ) ) # Rename Condition @@ -230,6 +230,19 @@ def setup_actions(self): actions["find+replace"].setShortcut("Ctrl+R") actions["find+replace"].triggered.connect( self.open_find_replace_dialog) + # Copy / Paste + actions["copy"] = QAction( + qta.icon("mdi6.content-copy"), + "Copy", self.view + ) + actions["copy"].setShortcut("Ctrl+C") + actions["copy"].triggered.connect(self.copy_to_clipboard) + actions["paste"] = QAction( + qta.icon("mdi6.content-paste"), + "Paste", self.view + ) + actions["paste"].setShortcut("Ctrl+V") + actions["paste"].triggered.connect(self.paste_from_clipboard) # add/delete row actions["add_row"] = QAction( qta.icon("mdi6.table-row-plus-after"), @@ -678,3 +691,13 @@ def delete_column(self): controller = self.active_controller() if controller: controller.delete_column() + + def copy_to_clipboard(self): + controller = self.active_controller() + if controller: + controller.copy_to_clipboard() + + def paste_from_clipboard(self): + controller = self.active_controller() + if controller: + controller.paste_from_clipboard() diff --git a/src/petab_gui/controllers/table_controllers.py b/src/petab_gui/controllers/table_controllers.py index e2d019a..04fc7a9 100644 --- a/src/petab_gui/controllers/table_controllers.py +++ b/src/petab_gui/controllers/table_controllers.py @@ -259,6 +259,15 @@ def set_index_on_new_row(self, index: QModelIndex): """Set the index of the model when a new row is added.""" self.view.table_view.setCurrentIndex(index) + def copy_to_clipboard(self): + """Copy the currently selected cells to the clipboard.""" + self.view.copy_to_clipboard() + + def paste_from_clipboard(self): + """Paste the clipboard content to the currently selected cells.""" + self.view.paste_from_clipboard() + + class MeasurementController(TableController): """Controller of the Measurement table.""" diff --git a/src/petab_gui/models/pandas_table_model.py b/src/petab_gui/models/pandas_table_model.py index 9bbc30b..7ddf19a 100644 --- a/src/petab_gui/models/pandas_table_model.py +++ b/src/petab_gui/models/pandas_table_model.py @@ -1,5 +1,6 @@ import pandas as pd -from PySide6.QtCore import Qt, QAbstractTableModel, QModelIndex, Signal, QSortFilterProxyModel +from PySide6.QtCore import (Qt, QAbstractTableModel, QModelIndex, Signal, + QSortFilterProxyModel, QMimeData) from PySide6.QtGui import QColor from ..C import COLUMNS @@ -288,11 +289,57 @@ def reset_invalid_cells(self): self._invalid_cells = set() self.layoutChanged.emit() + def mimeData(self, rectangle, start_index): + """Return the data to be copied to the clipboard. + + Parameters + ---------- + rectangle: np.ndarray + The rectangle of selected cells. Creates a minimum rectangle + around all selected cells and is True if the cell is selected. + start_index: (int, int) + The start index of the selection. Used to determine the location + of the copied 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 the data from text.""" + # TODO: Does this need to be more flexible in the separator? + lines = text.split("\n") + 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 + ) class IndexedPandasTableModel(PandasTableModel): """Table model for tables with named index.""" condition_2be_renamed = Signal(str, str) # Signal to mother controller + def __init__(self, data_frame, allowed_columns, table_type, parent=None): super().__init__( data_frame=data_frame, @@ -341,6 +388,7 @@ class MeasurementModel(PandasTableModel): """Table model for the measurement data.""" possibly_new_condition = Signal(str) # Signal for new condition possibly_new_observable = Signal(str) # Signal for new observable + def __init__(self, data_frame, parent=None): super().__init__( data_frame=data_frame, diff --git a/src/petab_gui/utils.py b/src/petab_gui/utils.py index 50e4fd4..40f4d2b 100644 --- a/src/petab_gui/utils.py +++ b/src/petab_gui/utils.py @@ -585,6 +585,29 @@ def get_selected(table_view: QTableView, mode: str = ROW) -> list[int]: return None +def get_selected_rectangles(table_view: QTableView) -> np.array: + """Returns the selected cells in a rectangular view. + + The size of the rectangle is determined by Max_row - Min_row and + Max_column - Min_column. The returned array is a boolean array with + True values for selected cells. + """ + selected = get_selected(table_view, mode=INDEX) + if not selected: + return None + rows = [index.row() for index in selected] + cols = [index.column() for index in selected] + min_row, max_row = min(rows), max(rows) + min_col, max_col = min(cols), max(cols) + rect_start = (min_row, min_col) + selected_rect = np.zeros( + (max_row - min_row + 1, max_col - min_col + 1), dtype=bool + ) + for index in selected: + selected_rect[index.row() - min_row, index.column() - min_col] = True + return selected_rect, rect_start + + def process_file(filepath, logger): """ Utility function to process a file based on its type and content. diff --git a/src/petab_gui/views/table_view.py b/src/petab_gui/views/table_view.py index a388d19..0bfde3e 100644 --- a/src/petab_gui/views/table_view.py +++ b/src/petab_gui/views/table_view.py @@ -1,6 +1,10 @@ from PySide6.QtWidgets import QDockWidget, QVBoxLayout, QTableView, QWidget,\ QCompleter, QLineEdit, QStyledItemDelegate, QComboBox from PySide6.QtCore import Qt +from PySide6.QtGui import QGuiApplication + +from ..utils import get_selected, get_selected_rectangles +from ..C import INDEX class TableViewer(QDockWidget): @@ -21,6 +25,28 @@ def __init__(self, title, parent=None): # Dictionary to store column-specific completers self.completers = {} + def copy_to_clipboard(self): + selected_rect, rect_start = get_selected_rectangles( + self.table_view + ) + if selected_rect.any(): + mime_data = self.table_view.model().mimeData( + selected_rect, rect_start + ) + clipboard = QGuiApplication.clipboard() + clipboard.setMimeData(mime_data) + + def paste_from_clipboard(self): + clipboard = QGuiApplication.clipboard() + text = clipboard.text() + if text: + start_index = self.table_view.selectionModel().currentIndex() + if start_index.isValid(): + self.table_view.model().setDataFromText( + text, start_index.row(), + start_index.column() + ) + class ComboBoxDelegate(QStyledItemDelegate): def __init__(self, options, parent=None): diff --git a/src/petab_gui/views/task_bar.py b/src/petab_gui/views/task_bar.py index adb0a87..3ae9312 100644 --- a/src/petab_gui/views/task_bar.py +++ b/src/petab_gui/views/task_bar.py @@ -65,6 +65,9 @@ def __init__(self, parent, actions): # Find and Replace self.find_replace_action = self.add_action_or_menu("Find/Replace") + # Copy, Paste + self.menu.addAction(actions["copy"]) + self.menu.addAction(actions["paste"]) # Add Columns self.menu.addAction(actions["add_column"]) self.menu.addAction(actions["delete_column"]) From eed54e25436ca502cbd399bb468a41eedaf670fa Mon Sep 17 00:00:00 2001 From: PaulJonasJost Date: Thu, 23 Jan 2025 14:49:07 +0100 Subject: [PATCH 2/3] Check that rows are enough --- src/petab_gui/models/pandas_table_model.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/petab_gui/models/pandas_table_model.py b/src/petab_gui/models/pandas_table_model.py index 7ddf19a..9e7c54c 100644 --- a/src/petab_gui/models/pandas_table_model.py +++ b/src/petab_gui/models/pandas_table_model.py @@ -322,6 +322,7 @@ def setDataFromText(self, text, start_row, start_column): """Set the data from text.""" # TODO: Does this need to be more flexible in the separator? lines = text.split("\n") + 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): @@ -335,6 +336,15 @@ def setDataFromText(self, text, start_row, start_column): Qt.EditRole ) + def maybe_add_rows(self, start_row, n_rows): + """Add rows if needed.""" + 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] + ) + self.layoutChanged.emit() + class IndexedPandasTableModel(PandasTableModel): """Table model for tables with named index.""" From 10e140b050ca9555b5dc7bc363711290cfcb33b0 Mon Sep 17 00:00:00 2001 From: PaulJonasJost Date: Thu, 23 Jan 2025 16:39:16 +0100 Subject: [PATCH 3/3] Copy Pasting now also correctly cast into dtpye, and does not create empty condition id --- src/petab_gui/C.py | 58 ++++++++++--------- .../controllers/table_controllers.py | 53 +++++++++++++---- src/petab_gui/models/pandas_table_model.py | 12 ++++ src/petab_gui/utils.py | 5 +- 4 files changed, 86 insertions(+), 42 deletions(-) diff --git a/src/petab_gui/C.py b/src/petab_gui/C.py index af2d7f8..f552b83 100644 --- a/src/petab_gui/C.py +++ b/src/petab_gui/C.py @@ -1,40 +1,42 @@ """Constants for the PEtab edit GUI.""" +import numpy as np + COLUMNS = { "measurement": { - "observableId": {"type": "STRING", "optional": False}, - "preequilibrationConditionId": {"type": "STRING", "optional": True}, - "simulationConditionId": {"type": "STRING", "optional": False}, - "time": {"type": "NUMERIC", "optional": False}, - "measurement": {"type": "NUMERIC", "optional": False}, - "observableParameters": {"type": "STRING", "optional": True}, - "noiseParameters": {"type": "STRING", "optional": True}, - "datasetId": {"type": "STRING", "optional": True}, - "replicateId": {"type": "STRING", "optional": True}, + "observableId": {"type": np.object_, "optional": False}, + "preequilibrationConditionId": {"type": np.object_, "optional": True}, + "simulationConditionId": {"type": np.object_, "optional": False}, + "time": {"type": np.float64, "optional": False}, + "measurement": {"type": np.float64, "optional": False}, + "observableParameters": {"type": np.object_, "optional": True}, + "noiseParameters": {"type": np.object_, "optional": True}, + "datasetId": {"type": np.object_, "optional": True}, + "replicateId": {"type": np.object_, "optional": True}, }, "observable": { - "observableId": {"type": "STRING", "optional": False}, - "observableName": {"type": "STRING", "optional": True}, - "observableFormula": {"type": "STRING", "optional": False}, - "observableTransformation": {"type": "STRING", "optional": True}, - "noiseFormula": {"type": "STRING", "optional": False}, - "noiseDistribution": {"type": "STRING", "optional": True}, + "observableId": {"type": np.object_, "optional": False}, + "observableName": {"type": np.object_, "optional": True}, + "observableFormula": {"type": np.object_, "optional": False}, + "observableTransformation": {"type": np.object_, "optional": True}, + "noiseFormula": {"type": np.object_, "optional": False}, + "noiseDistribution": {"type": np.object_, "optional": True}, }, "parameter": { - "parameterId": {"type": "STRING", "optional": False}, - "parameterName": {"type": "STRING", "optional": True}, - "parameterScale": {"type": "STRING", "optional": False}, - "lowerBound": {"type": "NUMERIC", "optional": False}, - "upperBound": {"type": "NUMERIC", "optional": False}, - "nominalValue": {"type": "NUMERIC", "optional": False}, - "estimate": {"type": "STRING", "optional": False}, - "initializationPriorType": {"type": "STRING", "optional": True}, - "initializationPriorParameters": {"type": "STRING", "optional": True}, - "objectivePriorType": {"type": "STRING", "optional": True}, - "objectivePriorParameters": {"type": "STRING", "optional": True}, + "parameterId": {"type": np.object_, "optional": False}, + "parameterName": {"type": np.object_, "optional": True}, + "parameterScale": {"type": np.object_, "optional": False}, + "lowerBound": {"type": np.float64, "optional": False}, + "upperBound": {"type": np.float64, "optional": False}, + "nominalValue": {"type": np.float64, "optional": False}, + "estimate": {"type": np.object_, "optional": False}, + "initializationPriorType": {"type": np.object_, "optional": True}, + "initializationPriorParameters": {"type": np.object_, "optional": True}, + "objectivePriorType": {"type": np.object_, "optional": True}, + "objectivePriorParameters": {"type": np.object_, "optional": True}, }, "condition": { - "conditionId": {"type": "STRING", "optional": False}, - "conditionName": {"type": "STRING", "optional": False}, + "conditionId": {"type": np.object_, "optional": False}, + "conditionName": {"type": np.object_, "optional": False}, } } diff --git a/src/petab_gui/controllers/table_controllers.py b/src/petab_gui/controllers/table_controllers.py index 04fc7a9..9f1f111 100644 --- a/src/petab_gui/controllers/table_controllers.py +++ b/src/petab_gui/controllers/table_controllers.py @@ -1,6 +1,7 @@ """Classes for the controllers of the tables in the GUI.""" from PySide6.QtWidgets import QInputDialog, QMessageBox, QFileDialog, \ QCompleter +import numpy as np import pandas as pd import petab.v1 as petab from PySide6.QtCore import Signal, QObject, QModelIndex, QPoint @@ -45,6 +46,7 @@ def __init__( self.model.view = self.view.table_view self.proxy_model = PandasTableFilterProxy(model) self.logger = logger + self.check_petab_lint_mode = True self.mother_controller = mother_controller self.view.table_view.setModel(self.model) self.setup_connections() @@ -85,6 +87,8 @@ def setup_connections(self): def validate_changed_cell(self, row, column): """Validate the changed cell and whether its linting is correct.""" + if not self.check_petab_lint_mode: + return row_data = self.model.get_df().iloc[row] index_name = self.model.get_df().index.name row_data = row_data.to_frame().T @@ -137,6 +141,12 @@ def open_table(self, file_path=None, separator=None, mode="overwrite"): color="red" ) return + dtypes = { + col: self.model._allowed_columns.get( + col, {"type": np.object_} + )["type"] for col in new_df.columns + } + new_df = new_df.astype(dtypes) if mode is None: mode = prompt_overwrite_or_append(self) # Overwrite or append the table with the new DataFrame @@ -265,15 +275,30 @@ def copy_to_clipboard(self): def paste_from_clipboard(self): """Paste the clipboard content to the currently selected cells.""" + self.check_petab_lint_mode = False self.view.paste_from_clipboard() - + self.check_petab_lint_mode = True + try: + self.check_petab_lint() + except Exception as e: + self.logger.log_message( + f"PEtab linter failed after copying: {str(e)}", + color="red" + ) + def check_petab_lint(self, row_data): + """Check a single row of the model with petablint.""" + raise NotImplementedError( + "This method must be implemented in child classes." + ) class MeasurementController(TableController): """Controller of the Measurement table.""" - def check_petab_lint(self, row_data): - """Check a single row of the model with petablint.""" + def check_petab_lint(self, row_data: pd.DataFrame = None): + """Check a number of rows of the model with petablint.""" + if row_data is None: + row_data = self.model.get_df() # Can this be done more elegantly? observable_df = self.mother_controller.model.observable.get_df() return petab.check_measurement_df( @@ -505,8 +530,10 @@ def setup_connections_specific(self): self.maybe_rename_condition ) - def check_petab_lint(self, row_data): - """Check a single row of the model with petablint.""" + def check_petab_lint(self, row_data: pd.DataFrame = None): + """Check a number of rows of the model with petablint.""" + if row_data is None: + row_data = self.model.get_df() observable_df = self.mother_controller.model.observable.get_df() sbml_model = self.mother_controller.model.sbml.get_current_sbml_model() return petab.check_condition_df( @@ -541,7 +568,7 @@ def maybe_rename_condition(self, new_id, old_id): def maybe_add_condition(self, condition_id, old_id=None): """Add a condition to the condition table if it does not exist yet.""" - if condition_id in self.model.get_df().index: + if condition_id in self.model.get_df().index or not condition_id: return # add a row self.model.insertRows(position=None, rows=1) @@ -644,8 +671,10 @@ def setup_connections_specific(self): self.maybe_rename_observable ) - def check_petab_lint(self, row_data): - """Check a single row of the model with petablint.""" + def check_petab_lint(self, row_data: pd.DataFrame = None): + """Check a number of rows of the model with petablint.""" + if row_data is None: + row_data = self.model.get_df() return petab.check_observable_df(row_data) def maybe_rename_observable(self, new_id, old_id): @@ -677,7 +706,7 @@ def maybe_add_observable(self, observable_id, old_id=None): Currently, `old_id` is not used. """ - if observable_id in self.model.get_df().index: + if observable_id in self.model.get_df().index or not observable_id: return # add a row self.model.insertRows(position=None, rows=1) @@ -763,8 +792,10 @@ def setup_completers(self): self.completers["parameterId"] ) - def check_petab_lint(self, row_data): - """Check a single row of the model with petablint.""" + def check_petab_lint(self, row_data: pd.DataFrame = None): + """Check a number of rows of the model with petablint.""" + if row_data is None: + row_data = self.model.get_df() observable_df = self.mother_controller.model.observable.get_df() measurement_df = self.mother_controller.model.measurement.get_df() condition_df = self.mother_controller.model.condition.get_df() diff --git a/src/petab_gui/models/pandas_table_model.py b/src/petab_gui/models/pandas_table_model.py index 9e7c54c..540fcea 100644 --- a/src/petab_gui/models/pandas_table_model.py +++ b/src/petab_gui/models/pandas_table_model.py @@ -131,6 +131,8 @@ def insertColumn(self, column_name: str): def setData(self, index, value, role=Qt.EditRole): if not (index.isValid() and role == Qt.EditRole): return False + if is_invalid(value) or value == "": + value = None # check whether multiple rows but only one column is selected multi_row_change, selected = self.check_selection() if not multi_row_change: @@ -158,6 +160,16 @@ def _set_data_single(self, index, value): # Handling non-index (regular data) columns column_name = self._data_frame.columns[column - col_setoff] old_value = self._data_frame.iloc[row, column - col_setoff] + # cast to numeric if necessary + if not self._data_frame[column_name].dtype == "object": + try: + value = float(value) + except ValueError: + self.new_log_message.emit( + f"Column '{column_name}' expects a numeric value", + "red" + ) + return False if value == old_value: return False diff --git a/src/petab_gui/utils.py b/src/petab_gui/utils.py index 40f4d2b..79efaa9 100644 --- a/src/petab_gui/utils.py +++ b/src/petab_gui/utils.py @@ -524,9 +524,8 @@ def connect_forwarded(self, slot): def create_empty_dataframe(column_dict: dict, table_type: str): columns = [col for col, props in column_dict.items() if not props["optional"]] dtypes = { - col: 'float64' if props["type"] == "NUMERIC" - else 'object' - for col, props in column_dict.items() if not props["optional"] + col: props["type"] for col, props in column_dict.items() if not + props["optional"] } df = pd.DataFrame(columns=columns).astype(dtypes) # set potential index columns