diff --git a/src/petab_gui/C.py b/src/petab_gui/C.py index 814c95c..e7acf26 100644 --- a/src/petab_gui/C.py +++ b/src/petab_gui/C.py @@ -173,3 +173,8 @@ "condition": DEFAULT_COND_CONFIG, "measurement": DEFAULT_MEAS_CONFIG } + +COMMON_ERRORS = { + r"Error parsing '': Syntax error at \d+:\d+: mismatched input '' " + r"expecting \{[^}]+\}" : "Invalid empty cell!" +} diff --git a/src/petab_gui/commands.py b/src/petab_gui/commands.py new file mode 100644 index 0000000..34336d2 --- /dev/null +++ b/src/petab_gui/commands.py @@ -0,0 +1,200 @@ +"""Store commands for the do/undo functionality.""" +from PySide6.QtGui import QUndoCommand +from PySide6.QtCore import QModelIndex, Qt +import pandas as pd +import numpy as np + + +pd.set_option('future.no_silent_downcasting', True) + + +class ModifyColumnCommand(QUndoCommand): + """Command to add a column to the table.""" + + def __init__(self, model, column_name, add_mode: bool = True): + action = "Add" if add_mode else "Remove" + super().__init__( + f"{action} column {column_name} in table {model.table_type}" + ) + self.model = model + self.column_name = column_name + self.add_mode = add_mode + self.old_values = None + self.position = None + + if not add_mode and column_name in model._data_frame.columns: + self.position = model._data_frame.columns.get_loc(column_name) + self.old_values = model._data_frame[column_name].copy() + + def redo(self): + if self.add_mode: + position = self.model._data_frame.shape[1] + self.model.beginInsertColumns(QModelIndex(), position, position) + self.model._data_frame[self.column_name] = "" + self.model.endInsertColumns() + else: + self.position = self.model._data_frame.columns.get_loc(self.column_name) + self.model.beginRemoveColumns(QModelIndex(), self.position, self.position) + self.model._data_frame.drop(columns=self.column_name, inplace=True) + self.model.endRemoveColumns() + + def undo(self): + if self.add_mode: + position = self.model._data_frame.columns.get_loc(self.column_name) + self.model.beginRemoveColumns(QModelIndex(), position, position) + self.model._data_frame.drop(columns=self.column_name, inplace=True) + self.model.endRemoveColumns() + else: + self.model.beginInsertColumns(QModelIndex(), self.position, self.position) + self.model._data_frame.insert(self.position, self.column_name, self.old_values) + self.model.endInsertColumns() + + +class ModifyRowCommand(QUndoCommand): + """Command to add a row to the table.""" + + def __init__( + self, + model, + row_indices: list[int] | int, + add_mode: bool = True + ): + action = "Add" if add_mode else "Remove" + super().__init__(f"{action} row(s) in table {model.table_type}") + self.model = model + self.add_mode = add_mode + self.old_rows = None + self.old_ind_names = None + + df = self.model._data_frame + + if add_mode: + # Adding: interpret input as count of new rows + self.row_indices = self._generate_new_indices(row_indices) + else: + # Deleting: interpret input as specific index labels + self.row_indices = row_indices if isinstance(row_indices, list) else [row_indices] + self.old_rows = df.iloc[self.row_indices].copy() + self.old_ind_names = [df.index[idx] for idx in self.row_indices] + + def _generate_new_indices(self, count): + """Generate default row indices based on table type and index type.""" + df = self.model._data_frame + base = 0 + existing = set(df.index.astype(str)) + + indices = [] + while len(indices) < count: + idx = f"new_{self.model.table_type}_{base}" + if idx not in existing: + indices.append(idx) + base += 1 + self.old_ind_names = indices + return indices + + def redo(self): + df = self.model._data_frame + + if self.add_mode: + position = df.shape[0] - 1 # insert *before* the auto-row + self.model.beginInsertRows(QModelIndex(), position, position + len(self.row_indices) - 1) + for i, idx in enumerate(self.row_indices): + df.loc[idx] = [""] * df.shape[1] + self.model.endInsertRows() + else: + self.model.beginRemoveRows(QModelIndex(), min(self.row_indices), max(self.row_indices)) + df.drop(index=self.old_ind_names, inplace=True) + self.model.endRemoveRows() + + def undo(self): + df = self.model._data_frame + + if self.add_mode: + positions = [df.index.get_loc(idx) for idx in self.row_indices] + self.model.beginRemoveRows(QModelIndex(), min(positions), max(positions)) + df.drop(index=self.old_ind_names, inplace=True) + self.model.endRemoveRows() + else: + self.model.beginInsertRows(QModelIndex(), min(self.row_indices), max(self.row_indices)) + restore_index_order = df.index + for pos, index_name, row in zip( + self.row_indices, self.old_ind_names, self.old_rows.values + ): + restore_index_order = restore_index_order.insert( + pos, index_name + ) + df.loc[index_name] = row + df.sort_index( + inplace=True, + key=lambda x: x.map(restore_index_order.get_loc) + ) + self.model.endInsertRows() + + +class ModifyDataFrameCommand(QUndoCommand): + def __init__(self, model, changes: dict[tuple, tuple], description="Modify values"): + super().__init__(description) + self.model = model + self.changes = changes # {(row_key, column_name): (old_val, new_val)} + + def redo(self): + self._apply_changes(use_new=True) + + def undo(self): + self._apply_changes(use_new=False) + + def _apply_changes(self, use_new: bool): + df = self.model._data_frame + col_offset = 1 if self.model._has_named_index else 0 + original_dtypes = df.dtypes.copy() + + # Apply changes + update_vals = { + (row, col): val[1 if use_new else 0] + for (row, col), val in self.changes.items() + } + update_df = pd.Series(update_vals).unstack() + for col in update_df.columns: + if col in df.columns: + df[col] = df[col].astype('object') + update_df.replace({None: "Placeholder_temp"}, inplace=True) + df.update(update_df) + df.replace({"Placeholder_temp": ""}, inplace=True) + for col, dtype in original_dtypes.items(): + if col not in update_df.columns: + continue + if np.issubdtype(dtype, np.number): + df[col] = pd.to_numeric(df[col], errors="coerce") + else: + df[col] = df[col].astype(dtype) + + rows = [df.index.get_loc(row_key) for (row_key, _) in + self.changes.keys()] + cols = [df.columns.get_loc(col) + col_offset for (_, col) in + self.changes.keys()] + + top_left = self.model.index(min(rows), min(cols)) + bottom_right = self.model.index(max(rows), max(cols)) + self.model.dataChanged.emit(top_left, bottom_right, [Qt.DisplayRole]) + + +class RenameIndexCommand(QUndoCommand): + def __init__(self, model, old_index, new_index, model_index): + super().__init__(f"Rename index {old_index} → {new_index}") + self.model = model + self.model_index = model_index + self.old_index = old_index + self.new_index = new_index + + def redo(self): + self._apply(self.old_index, self.new_index) + + def undo(self): + self._apply(self.new_index, self.old_index) + + def _apply(self, src, dst): + df = self.model._data_frame + df.rename(index={src: dst}, inplace=True) + self.model.dataChanged.emit( + self.model_index, self.model_index, [Qt.DisplayRole] + ) diff --git a/src/petab_gui/controllers/mother_controller.py b/src/petab_gui/controllers/mother_controller.py index 5f8397d..3f28e43 100644 --- a/src/petab_gui/controllers/mother_controller.py +++ b/src/petab_gui/controllers/mother_controller.py @@ -2,7 +2,7 @@ from PySide6.QtWidgets import QMessageBox, QFileDialog, QLineEdit, QWidget, \ QHBoxLayout, QToolButton, QTableView -from PySide6.QtGui import QAction, QDesktopServices +from PySide6.QtGui import QAction, QDesktopServices, QUndoStack, QKeySequence import zipfile import tempfile import os @@ -19,7 +19,7 @@ ConditionController, ParameterController from .logger_controller import LoggerController from ..views import TaskBar -from .utils import prompt_overwrite_or_append, RecentFilesManager +from .utils import prompt_overwrite_or_append, RecentFilesManager, filtered_error from functools import partial from ..settings_manager import SettingsDialog, settings_manager @@ -42,6 +42,7 @@ def __init__(self, view, model: PEtabModel): model: PEtabModel The PEtab model. """ + self.undo_stack = QUndoStack() self.task_bar = None self.view = view self.model = model @@ -51,24 +52,28 @@ def __init__(self, view, model: PEtabModel): self.view.measurement_dock, self.model.measurement, self.logger, + self.undo_stack, self ) self.observable_controller = ObservableController( self.view.observable_dock, self.model.observable, self.logger, + self.undo_stack, self ) self.parameter_controller = ParameterController( self.view.parameter_dock, self.model.parameter, self.logger, + self.undo_stack, self ) self.condition_controller = ConditionController( self.view.condition_dock, self.model.condition, self.logger, + self.undo_stack, self ) self.sbml_controller = SbmlController( @@ -189,21 +194,21 @@ def setup_actions(self): "&Close", self.view )} # Close - actions["close"].setShortcut("Ctrl+Q") + actions["close"].setShortcut(QKeySequence.Close) actions["close"].triggered.connect(self.view.close) # New File actions["new"] = QAction( qta.icon("mdi6.file-document"), "&New", self.view ) - actions["new"].setShortcut("Ctrl+N") + actions["new"].setShortcut(QKeySequence.New) actions["new"].triggered.connect(self.new_file) # Open File actions["open"] = QAction( qta.icon("mdi6.folder-open"), "&Open", self.view ) - actions["open"].setShortcut("Ctrl+O") + actions["open"].setShortcut(QKeySequence.Open) actions["open"].triggered.connect( partial(self.open_file, mode="overwrite") ) @@ -221,33 +226,33 @@ def setup_actions(self): qta.icon("mdi6.content-save-all"), "&Save", self.view ) - actions["save"].setShortcut("Ctrl+S") + actions["save"].setShortcut(QKeySequence.Save) actions["save"].triggered.connect(self.save_model) # Find + Replace actions["find"] = QAction( qta.icon("mdi6.magnify"), "Find", self.view ) - actions["find"].setShortcut("Ctrl+F") + actions["find"].setShortcut(QKeySequence.Find) actions["find"].triggered.connect(self.find) actions["find+replace"] = QAction( qta.icon("mdi6.find-replace"), "Find/Replace", self.view ) - actions["find+replace"].setShortcut("Ctrl+R") + actions["find+replace"].setShortcut(QKeySequence.Replace) actions["find+replace"].triggered.connect(self.replace) # Copy / Paste actions["copy"] = QAction( qta.icon("mdi6.content-copy"), "Copy", self.view ) - actions["copy"].setShortcut("Ctrl+C") + actions["copy"].setShortcut(QKeySequence.Copy) 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"].setShortcut(QKeySequence.Paste) actions["paste"].triggered.connect(self.paste_from_clipboard) # add/delete row actions["add_row"] = QAction( @@ -369,6 +374,23 @@ def setup_actions(self): )) ) + # Undo / Redo + actions["undo"] = QAction( + qta.icon("mdi6.undo"), + "&Undo", self.view + ) + actions["undo"].setShortcut(QKeySequence.Undo) + actions["undo"].triggered.connect(self.undo_stack.undo) + actions["undo"].setEnabled(self.undo_stack.canUndo()) + self.undo_stack.canUndoChanged.connect(actions["undo"].setEnabled) + actions["redo"] = QAction( + qta.icon("mdi6.redo"), + "&Redo", self.view + ) + actions["redo"].setShortcut(QKeySequence.Redo) + actions["redo"].triggered.connect(self.undo_stack.redo) + actions["redo"].setEnabled(self.undo_stack.canRedo()) + self.undo_stack.canRedoChanged.connect(actions["redo"].setEnabled) return actions def sync_visibility_with_actions(self): @@ -673,6 +695,9 @@ def check_model(self): model.reset_invalid_cells() else: self.logger.log_message("Model is inconsistent.", color="red") + except Exception as e: + msg = f"PEtab linter failed at some point: {filtered_error(e)}" + self.logger.log_message(msg, color="red") finally: # Always remove the capture handler logger.removeHandler(capture_handler) diff --git a/src/petab_gui/controllers/table_controllers.py b/src/petab_gui/controllers/table_controllers.py index 4ba9a62..7e6e964 100644 --- a/src/petab_gui/controllers/table_controllers.py +++ b/src/petab_gui/controllers/table_controllers.py @@ -11,8 +11,8 @@ from ..settings_manager import settings_manager from ..views.table_view import TableViewer, SingleSuggestionDelegate, \ ColumnSuggestionDelegate, ComboBoxDelegate, ParameterIdSuggestionDelegate -from ..utils import get_selected, process_file -from .utils import prompt_overwrite_or_append +from ..utils import get_selected, process_file, ConditionInputDialog +from .utils import prompt_overwrite_or_append, linter_wrapper from ..C import COLUMN import re @@ -26,6 +26,7 @@ def __init__( view: TableViewer, model: PandasTableModel, logger, + undo_stack, mother_controller ): """Initialize the table controller. @@ -48,6 +49,8 @@ def __init__( self.model.view = self.view.table_view self.proxy_model = PandasTableFilterProxy(model) self.logger = logger + self.undo_stack = undo_stack + self.model.undo_stack = undo_stack self.check_petab_lint_mode = True self.mother_controller = mother_controller self.view.table_view.setModel(self.proxy_model) @@ -86,9 +89,6 @@ def setup_connections(self): self.model.inserted_row.connect( self.set_index_on_new_row ) - self.model.fill_defaults.connect( - self.model.get_default_values, Qt.QueuedConnection - ) settings_manager.settings_changed.connect( self.update_defaults ) @@ -102,27 +102,22 @@ 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 + df = self.model.get_df() + row_data = df.iloc[row] + index_name = df.index.name row_data = row_data.to_frame().T row_data.index.name = index_name - try: - self.check_petab_lint(row_data) + row_name = row_data.index[0] + if column == 0 and self.model._has_named_index: + col_name = index_name + else: + col_name = df.columns[column - self.model.column_offset] + is_valid = self.check_petab_lint(row_data, row_name, col_name) + if is_valid: for col in range(self.model.columnCount()): self.model.discard_invalid_cell(row, col) - error_message = None - except Exception as e: - error_message = str(e) - self.logger.log_message( - f"PEtab linter failed at row {row}, column {column}: " - f"{error_message}", - color="red" - ) - # Update invalid cells based on the error state - if error_message: - self.model.add_invalid_cell(row, column) else: - self.model.discard_invalid_cell(row, column) + self.model.add_invalid_cell(row, column) self.model.notify_data_color_change(row, column) def open_table(self, file_path=None, separator=None, mode="overwrite"): @@ -264,7 +259,7 @@ def delete_column(self): self.logger.log_message( f"Cannot delete column {column_name}, as it is a " f"required column!", - color = "red" + color="red" ) continue if column_name in self.completers: @@ -293,7 +288,8 @@ def add_column(self, column_name: str = None): 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) + proxy_index = self.proxy_model.mapFromSource(index) + self.view.table_view.setCurrentIndex(proxy_index) def filter_table(self, text): """Filter the table.""" @@ -322,7 +318,12 @@ def paste_from_clipboard(self): color="red" ) - def check_petab_lint(self, row_data): + def check_petab_lint( + self, + row_data: pd.DataFrame = None, + row_name: str = None, + col_name: str = None + ): """Check a single row of the model with petablint.""" raise NotImplementedError( "This method must be implemented in child classes." @@ -483,11 +484,11 @@ def update_defaults(self, settings_changed): class MeasurementController(TableController): """Controller of the Measurement table.""" - def check_petab_lint(self, row_data: pd.DataFrame = None): + @linter_wrapper + def check_petab_lint(self, row_data: pd.DataFrame = None, row_name: str = None, col_name: str = 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( row_data, @@ -595,9 +596,9 @@ def process_data_matrix_file(self, file_name, mode, separator=None): cond_dialog = ConditionInputDialog() if cond_dialog.exec(): - conditions = cond_dialog.get_condition_id() - condition_id = conditions.get("conditionId", "") - preeq_id = conditions.get("preeq_id", "") + conditions = cond_dialog.get_inputs() + condition_id = conditions.get("simulationConditionId", "") + preeq_id = conditions.get("preequilibrationConditionId", "") if mode == "overwrite": self.model.clear_table() self.populate_tables_from_data_matrix( @@ -756,7 +757,8 @@ def setup_connections_specific(self): self.update_handler_model ) - def check_petab_lint(self, row_data: pd.DataFrame = None): + @linter_wrapper + def check_petab_lint(self, row_data: pd.DataFrame = None, row_name: str = None, col_name: str = None): """Check a number of rows of the model with petablint.""" if row_data is None: row_data = self.model.get_df() @@ -904,7 +906,8 @@ def setup_connections_specific(self): self.update_handler_model ) - def check_petab_lint(self, row_data: pd.DataFrame = None): + @linter_wrapper + def check_petab_lint(self, row_data: pd.DataFrame = None, row_name: str = None, col_name: str = None): """Check a number of rows of the model with petablint.""" if row_data is None: row_data = self.model.get_df() @@ -1035,7 +1038,8 @@ def setup_completers(self): self.completers["parameterId"] ) - def check_petab_lint(self, row_data: pd.DataFrame = None): + @linter_wrapper(additional_error_check=True) + def check_petab_lint(self, row_data: pd.DataFrame = None, row_name: str = None, col_name: str = None): """Check a number of rows of the model with petablint.""" if row_data is None: row_data = self.model.get_df() diff --git a/src/petab_gui/controllers/utils.py b/src/petab_gui/controllers/utils.py index 5802b36..7669022 100644 --- a/src/petab_gui/controllers/utils.py +++ b/src/petab_gui/controllers/utils.py @@ -3,8 +3,69 @@ from PySide6.QtGui import QAction from collections import Counter from pathlib import Path +import functools +import pandas as pd +import re from ..settings_manager import settings_manager +from ..C import COMMON_ERRORS + + +def linter_wrapper(_func=None, additional_error_check: bool = False): + def decorator(func): + @functools.wraps(func) + def wrapper(self, row_data: pd.DataFrame = None, row_name: + str = None, col_name: str = None, *args, **kwargs): + try: + result = func( + self, row_data, row_name, col_name, *args, **kwargs + ) + return True + except Exception as e: + err_msg = filtered_error(e) + if additional_error_check: + if "Missing parameter(s)" in err_msg: + match = re.search(r"\{(.+?)\}", err_msg) + missing_params = { + s.strip(" '") for s in match.group(1).split(",") + } + remain = { + p for p in missing_params + if p not in self.model._data_frame.index + } + if not remain: + return True + err_msg = re.sub( + r"\{.*?\}", "{" + ", ".join(sorted(remain)) + "}", + err_msg + ) + if row_name is not None and col_name is not None: + msg = f"PEtab linter failed at ({row_name}, {col_name}): {err_msg}" + else: + msg = f"PEtab linter failed: {err_msg}" + + self.logger.log_message(msg, color="red") + return False + return wrapper + if callable(_func): # used without parentheses + return decorator(_func) + return decorator + + +def filtered_error(error_message: BaseException) -> str: + """Filters know error message and reformulates them.""" + all_errors = "|".join( + f"(?P{pattern})" for i, pattern in enumerate(COMMON_ERRORS) + ) + regex = re.compile(all_errors) + replacement_values = list(COMMON_ERRORS.values()) + # Replace function + def replacer(match): + for i, _ in enumerate(COMMON_ERRORS): + if match.group(f"key{i}"): + return replacement_values[i] + return match.group(0) + return regex.sub(replacer, str(error_message)) def prompt_overwrite_or_append(controller): diff --git a/src/petab_gui/models/pandas_table_model.py b/src/petab_gui/models/pandas_table_model.py index 9973885..76acfea 100644 --- a/src/petab_gui/models/pandas_table_model.py +++ b/src/petab_gui/models/pandas_table_model.py @@ -8,6 +8,8 @@ get_selected from ..controllers.default_handler import DefaultHandlerModel from ..settings_manager import settings_manager +from ..commands import (ModifyColumnCommand, ModifyRowCommand, + ModifyDataFrameCommand, RenameIndexCommand) class PandasTableModel(QAbstractTableModel): @@ -20,7 +22,8 @@ class PandasTableModel(QAbstractTableModel): inserted_row = Signal(QModelIndex) fill_defaults = Signal(QModelIndex) - def __init__(self, data_frame, allowed_columns, table_type, parent=None): + def __init__(self, data_frame, allowed_columns, table_type, + undo_stack = None, parent=None): super().__init__(parent) self._allowed_columns = allowed_columns self.table_type = table_type @@ -38,6 +41,7 @@ def __init__(self, data_frame, allowed_columns, table_type, parent=None): # default values setup self.config = settings_manager.get_table_defaults(table_type) self.default_handler = DefaultHandlerModel(self, self.config) + self.undo_stack = undo_stack def rowCount(self, parent=QModelIndex()): return self._data_frame.shape[0] + 1 # empty row at the end @@ -106,17 +110,12 @@ def insertRows(self, position, rows, parent=QModelIndex()) -> bool: -------- bool: True if rows were added successfully. """ - end_position = len(self._data_frame) - self.beginInsertRows( - QModelIndex(), end_position, end_position + rows - 1 - ) - - # In-place row addition using loc - for i in range(rows): - # Append an empty row or row with default values using loc - self._data_frame.loc[end_position + i] = \ - [""] * self._data_frame.shape[1] - self.endInsertRows() + 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): @@ -124,21 +123,28 @@ def insertColumn(self, column_name: str): Override insertColumn to always add the column at the right (end) of the table, and do so in-place on the DataFrame. """ + 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}' not allowed in {self.table_type} table", + f"Column '{column_name}' will be ignored for the petab " + f"problem but may still be used to store relevant information", "orange" ) - position = self._data_frame.shape[1] - self.beginInsertColumns(QModelIndex(), position, position) - column_type = \ - self._allowed_columns.get(column_name, {"type": "STRING"})["type"] - default_value = "" if column_type == "STRING" else 0 - self._data_frame[column_name] = default_value - self.endInsertColumns() + + 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 @@ -154,90 +160,95 @@ def setData(self, index, value, role=Qt.EditRole): # check whether multiple rows but only one column is selected multi_row_change, selected = self.check_selection() if not multi_row_change: - return self._set_data_single(index, value) + self.undo_stack.beginMacro("Set data") + success = self._set_data_single(index, value) + self.undo_stack.endMacro() + return success # multiple rows but only one column is selected all_set = list() + self.undo_stack.beginMacro("Set data") for index in selected: all_set.append(self._set_data_single(index, value)) + self.undo_stack.endMacro() return all(all_set) def _set_data_single(self, index, value): """Set the data of a single cell.""" - col_setoff = 0 - if self._has_named_index: - col_setoff = 1 - if index.row() == self._data_frame.shape[0]: - # empty row at the end - self.insertRows(index.row(), 1) - self.fill_defaults.emit(index) - # self.get_default_values(index) - next_index = self.index(index.row(), 0) - self.inserted_row.emit(next_index) - if index.column() == 0 and self._has_named_index: - return self.handle_named_index(index, value) row, column = index.row(), index.column() - # 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 - expected_type = self._allowed_columns.get(column_name, None) + 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): - if not expected_type["optional"]: - return False - self._data_frame.iloc[row, column - col_setoff] = None - self.dataChanged.emit(index, index, [Qt.DisplayRole]) + self._push_change_and_notify(row, column, column_name, old_value, None) return True - if expected_type: - expected_type = expected_type["type"] - value, error_message = validate_value(value, expected_type) - if error_message: + + # 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 numeric value", - "red" + f"Column '{column_name}' expects a value of type " + f"{expected_type}, but got '{value}'", "red" ) return False + value = validated + if value == old_value: return False + # Special ID emitters if column_name == "observableId": - self._data_frame.iloc[row, column - col_setoff] = value - self.dataChanged.emit(index, index, [Qt.DisplayRole]) + self._push_change_and_notify(row, column, column_name, old_value, value) self.relevant_id_changed.emit(value, old_value, "observable") - self.cell_needs_validation.emit(row, column) - self.something_changed.emit(True) + if fill_with_defaults: + self.get_default_values(index) return True - if column_name in ["conditionId", "simulationConditionId", - "preequilibrationConditionId"]: - self._data_frame.iloc[row, column - col_setoff] = value - self.dataChanged.emit(index, index, [Qt.DisplayRole]) + + if column_name in ["conditionId", "simulationConditionId", "preequilibrationConditionId"]: + if fill_with_defaults: + self.get_default_values(index) + self._push_change_and_notify(row, column, column_name, old_value, value) self.relevant_id_changed.emit(value, old_value, "condition") - self.cell_needs_validation.emit(row, column) - self.something_changed.emit(True) return True - # Validate data based on expected type - expected_type = self._allowed_columns.get(column_name, None) - if expected_type: - expected_type = expected_type["type"] - tried_value = value - value, error_message = validate_value( - value, expected_type - ) - if error_message: - self.new_log_message.emit( - f"Column '{column_name}' expects a value of " - f"type {expected_type}, but got '{tried_value}'", - "red" - ) - return False - # Set the new value - self._data_frame.iloc[row, column - col_setoff] = value - # Validate the row after setting data + # Default value setting + if fill_with_defaults: + self.get_default_values(index) + 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 undo stack and notify view + validation.""" + change = { + (self._data_frame.index[row], column_name): (old_value, new_value) + } + self.undo_stack.push(ModifyDataFrameCommand(self, change)) self.cell_needs_validation.emit(row, column) + self.dataChanged.emit(self.index(row, column), self.index(row, column), + [Qt.DisplayRole]) self.something_changed.emit(True) - self.dataChanged.emit(index, index, [Qt.DisplayRole]) - - return True def handle_named_index(self, index, value): """Handle the named index column.""" @@ -363,16 +374,22 @@ def unique_values(self, column_name): def delete_row(self, row): """Delete a row from the table.""" - self.beginRemoveRows(QModelIndex(), row, row) - self._data_frame.drop(self._data_frame.index[row], inplace=True) - self.endRemoveRows() + 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 DataFrame.""" column_name = self._data_frame.columns[column_index - self.column_offset] - self.beginRemoveColumns(QModelIndex(), column_index, column_index) - self._data_frame.drop(columns=[column_name], inplace=True) - self.endRemoveColumns() + 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 the table.""" @@ -432,8 +449,8 @@ def mimeData(self, rectangle, start_index): 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.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") @@ -447,6 +464,7 @@ def setDataFromText(self, text, start_row, start_column): value, Qt.EditRole ) + self.undo_stack.endMacro() def maybe_add_rows(self, start_row, n_rows): """Add rows if needed.""" @@ -489,6 +507,45 @@ def endResetModel(self): self.config = settings_manager.get_table_defaults(self.table_type) self.default_handler = DefaultHandlerModel(self, self.config) + 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 = { + column_name: "" for column_name in self._data_frame.columns + } + unknown_keys = set(data) - set(self._data_frame.columns) + for key in unknown_keys: + if key == self._data_frame.index.name: + continue + data.pop(key, None) + data_to_add.update(data) + index_key = None + if self.table_type == "condition": + index_key = data_to_add.pop("conditionId") + elif self.table_type == "observable": + index_key = data_to_add.pop("observableId") + 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) + )) + + changes = { + (index_key, col): (self._data_frame.at[index_key, col], val) + for col, val in data_to_add.items() + } + self.undo_stack.push(ModifyDataFrameCommand( + self, changes, "Fill values" + )) + self.fill_defaults.emit(self.index(row_position, 0)) + class IndexedPandasTableModel(PandasTableModel): """Table model for tables with named index.""" @@ -506,30 +563,54 @@ def __init__(self, data_frame, allowed_columns, table_type, parent=None): def get_default_values(self, index): """Return the default values for a the row in a new index.""" - row = index.row() - if isinstance(row, int): - row = self._data_frame.index[row] - columns_with_index = ( - [self._data_frame.index.name or "index"] + - list(self._data_frame.columns) - ) + row_idx = index.row() + df = self._data_frame + if isinstance(row_idx, int): + row_key = df.index[row_idx] + else: + row_key = row_idx + changes = {} + rename_needed = False + old_index = row_key + new_index = row_key + + columns_with_index = [df.index.name] if df.index.name else [] + columns_with_index += list(df.columns) + for colname in columns_with_index: - if colname == self._data_frame.index.name and not isinstance(row, int): - continue - if colname == self._data_frame.index.name and isinstance(row, int): - default_value = self.default_handler.get_default(colname, row) - if default_value == "": - default_value = f"{self.table_type}_{row}" - self._data_frame.rename( - index={self._data_frame.index[row]: default_value}, - inplace=True - ) - row = default_value # Update row to new index - continue - # if column is empty, fill with default value - if self._data_frame.loc[row, colname] == "": - default_value = self.default_handler.get_default(colname, row) - self._data_frame.loc[row, colname] = default_value + if colname == df.index.name: + # Generate default index name if empty + default_value = self.default_handler.get_default(colname, row_key) + if ( + not row_key + or f"new_{self.table_type}" in row_key + ): + rename_needed = True + new_index = default_value + else: + if df.at[row_key, colname] == "": + default_value = self.default_handler.get_default(colname, row_key) + changes[(row_key, colname)] = ("", default_value) + + commands = [] + if changes: + commands.append(ModifyDataFrameCommand( + self, changes, "Fill default values" + )) + if rename_needed: + commands.append(RenameIndexCommand( + self, old_index, new_index, index + )) + if not commands: + return + if not self.undo_stack: + for command in commands: + command.redo() + return + self.undo_stack.beginMacro("Fill default values") + for command in commands: + self.undo_stack.push(command) + self.undo_stack.endMacro() def handle_named_index(self, index, value): """Handle the named index column.""" @@ -538,16 +619,23 @@ def handle_named_index(self, index, value): if value == old_value: return False if value in self._data_frame.index: + base = 0 + value = None + while value is None: + idx = f"new_{self.table_type}_{base}" + if idx not in set(self._data_frame.index.astype(str)): + value = idx + base += 1 self.new_log_message.emit( - f"Duplicate index value '{value}'", - "red" + f"Duplicate index value '{value}'. Renaming to default " + f"value '{value}'", + "orange" ) - return False try: - self._data_frame.rename(index={old_value: value}, inplace=True) - self.dataChanged.emit(index, index, [Qt.DisplayRole]) + self.undo_stack.push(RenameIndexCommand( + self, old_value, value, index + )) self.relevant_id_changed.emit(value, old_value, self.table_type) - self.cell_needs_validation.emit(row, 0) self.something_changed.emit(True) return True except Exception as e: @@ -582,16 +670,24 @@ def __init__(self, data_frame, parent=None): def get_default_values(self, index): """Fill missing values in a row without modifying the index.""" row = index.row() + df = self._data_frame if isinstance(row, int): row_key = self._data_frame.index[row] else: row_key = row - for colname in self._data_frame.columns: - if self._data_frame.at[row_key, colname] == "": - default_value = self.default_handler.get_default(colname, - row_key) - self._data_frame.at[row_key, colname] = default_value + changes = {} + for colname in df.columns: + if df.at[row_key, colname] == "": + default = self.default_handler.get_default(colname, row_key) + changes[(row_key, colname)] = ("", default) + command = ModifyDataFrameCommand( + self, changes, "Fill default values" + ) + if self.undo_stack: + self.undo_stack.push(command) + else: + command.redo() def data(self, index, role=Qt.DisplayRole): """Return the data at the given index and role for the View.""" @@ -627,26 +723,6 @@ def headerData(self, section, orientation, role=Qt.DisplayRole): return str(section) return None - 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 = { - column_name: "" for column_name in self._data_frame.columns - } - # remove preequilibrationConditionId if not in columns - if "preequilibrationConditionId" not in self._data_frame.columns: - data.pop("preequilibrationConditionId", None) - data_to_add.update(data) - # Maybe add default values for missing columns - self._data_frame.iloc[row_position] = data_to_add - def return_column_index(self, column_name): """Return the index of a column.""" if column_name in self._data_frame.columns: @@ -665,32 +741,6 @@ def __init__(self, data_frame, parent=None): parent=parent ) - 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 = { - column_name: "" for column_name in self._data_frame.columns - } - data_to_add.update(data) - # Maybe add default values for missing columns? - new_index = self._data_frame.index.tolist() - index_name = self._data_frame.index.name - new_index[row_position] = data_to_add.pop( - "observableId" - ) - self._data_frame.index = pd.Index(new_index, name=index_name) - self._data_frame.iloc[row_position] = data_to_add - # make a QModelIndex for the new row - new_index = self.index(row_position, 0) - self.fill_defaults.emit(new_index) - class ParameterModel(IndexedPandasTableModel): """Table model for the parameter data.""" @@ -716,31 +766,6 @@ def __init__(self, data_frame, parent=None): ) self._allowed_columns.pop("conditionId") - 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 = { - column_name: "" for column_name in self._data_frame.columns - } - data_to_add.update(data) - new_index = self._data_frame.index.tolist() - index_name = self._data_frame.index.name - new_index[row_position] = data_to_add.pop( - "conditionId" - ) - self._data_frame.index = pd.Index(new_index, name=index_name) - self._data_frame.iloc[row_position] = data_to_add - # make a QModelIndex for the new row - new_index = self.index(row_position, 0) - self.fill_defaults.emit(new_index) - class PandasTableFilterProxy(QSortFilterProxyModel): def __init__(self, model, parent=None): diff --git a/src/petab_gui/models/petab_model.py b/src/petab_gui/models/petab_model.py index d292fcb..be0509d 100644 --- a/src/petab_gui/models/petab_model.py +++ b/src/petab_gui/models/petab_model.py @@ -108,7 +108,6 @@ def test_consistency(self) -> bool: bool Whether the data is consistent. """ - # TODO: Create logging messages return petab.lint.lint_problem(self.current_petab_problem) def save(self, directory: str | Path): diff --git a/src/petab_gui/utils.py b/src/petab_gui/utils.py index 279bb16..0c79122 100644 --- a/src/petab_gui/utils.py +++ b/src/petab_gui/utils.py @@ -74,37 +74,38 @@ def antimonyToSBML(ant): class ConditionInputDialog(QDialog): - def __init__(self, condition_id, condition_columns, initial_values=None, error_key=None, parent=None): + def __init__(self, condition_id=None, parent=None): super().__init__(parent) self.setWindowTitle("Add Condition") self.layout = QVBoxLayout(self) - - # Condition ID - self.condition_id_layout = QHBoxLayout() - self.condition_id_label = QLabel("Condition ID:", self) - self.condition_id_input = QLineEdit(self) - self.condition_id_input.setText(condition_id) - self.condition_id_input.setReadOnly(True) - self.condition_id_layout.addWidget(self.condition_id_label) - self.condition_id_layout.addWidget(self.condition_id_input) - self.layout.addLayout(self.condition_id_layout) - - # Dynamic fields for existing columns - self.fields = {} - for column in condition_columns: - if column != "conditionId": # Skip conditionId - field_layout = QHBoxLayout() - field_label = QLabel(f"{column}:", self) - field_input = QLineEdit(self) - if initial_values and column in initial_values: - field_input.setText(str(initial_values[column])) - if column == error_key: - field_input.setStyleSheet("background-color: red;") - field_layout.addWidget(field_label) - field_layout.addWidget(field_input) - self.layout.addLayout(field_layout) - self.fields[column] = field_input + self.notification_label = QLabel("", self) + self.notification_label.setStyleSheet("color: red;") + self.notification_label.setVisible(False) + self.layout.addWidget(self.notification_label) + + # Simulation Condition + sim_layout = QHBoxLayout() + sim_label = QLabel("Simulation Condition:", self) + self.sim_input = QLineEdit(self) + if condition_id: + self.sim_input.setText(condition_id) + sim_layout.addWidget(sim_label) + sim_layout.addWidget(self.sim_input) + self.layout.addLayout(sim_layout) + + # Preequilibration Condition + preeq_layout = QHBoxLayout() + preeq_label = QLabel("Preequilibration Condition:", self) + self.preeq_input = QLineEdit(self) + self.preeq_input.setToolTip( + "This field is only needed when your experiment started in steady " + "state. In this case add here the experimental condition id for " + "the steady state." + ) + preeq_layout.addWidget(preeq_label) + preeq_layout.addWidget(self.preeq_input) + self.layout.addLayout(preeq_layout) # Buttons self.buttons_layout = QHBoxLayout() @@ -117,10 +118,22 @@ def __init__(self, condition_id, condition_columns, initial_values=None, error_k self.ok_button.clicked.connect(self.accept) self.cancel_button.clicked.connect(self.reject) + def accept(self): + if not self.sim_input.text().strip(): + self.sim_input.setStyleSheet("background-color: red;") + self.notification_label.setText("Simulation Condition is required.") + self.notification_label.setVisible(True) + return + self.notification_label.setVisible(False) + self.sim_input.setStyleSheet("") + super().accept() + def get_inputs(self): - inputs = {column: field.text() for column, field in self.fields.items()} - inputs["conditionId"] = self.condition_id_input.text() - inputs["conditionName"] = inputs["conditionId"] + inputs = {} + inputs["simulationConditionId"] = self.sim_input.text() + preeq = self.preeq_input.text() + if preeq: + inputs["preequilibrationConditionId"] = preeq return inputs @@ -464,8 +477,6 @@ def replace(self): self.parent().sbml_viewer.antimony_text_edit.setPlainText(antimony_text) - - class SyntaxHighlighter(QSyntaxHighlighter): def __init__(self, parent=None): super().__init__(parent) diff --git a/src/petab_gui/views/task_bar.py b/src/petab_gui/views/task_bar.py index 97986d8..f7d5733 100644 --- a/src/petab_gui/views/task_bar.py +++ b/src/petab_gui/views/task_bar.py @@ -65,14 +65,18 @@ def menu_name(self): def __init__(self, parent, actions): super().__init__(parent, actions) - # Find and Replace - self.menu.addAction(actions["find"]) - self.menu.addAction(actions["find+replace"]) + # Undo, Redo + self.menu.addAction(actions["undo"]) + self.menu.addAction(actions["redo"]) self.menu.addSeparator() # Copy, Paste self.menu.addAction(actions["copy"]) self.menu.addAction(actions["paste"]) self.menu.addSeparator() + # Find and Replace + self.menu.addAction(actions["find"]) + self.menu.addAction(actions["find+replace"]) + self.menu.addSeparator() # Add Columns self.menu.addAction(actions["add_column"]) self.menu.addAction(actions["delete_column"])