diff --git a/pyproject.toml b/pyproject.toml index a7221e1..973f029 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -56,3 +56,21 @@ include-package-data = true [tool.setuptools.package-data] "petab_gui.assets" = ["PEtab.png"] + +[tool.ruff] +line-length = 79 +lint.select = [ + "F", # Pyflakes + "I", # isort + "D", # pydocstyle (PEP 257) + "S", # flake8-bandit + "B", # flake8-bugbear + "C4", # flake8-comprehensions + "T20", # flake8-print + "W", # pycodestyle Warnings + "E", # pycodestyle Errors + "UP", # pyupgrade + # "ANN", # flakes-annotations TODO: currently produces ~1500 errors to manual fix +] +[tool.ruff.lint.pydocstyle] +convention = "pep257" diff --git a/setup.py b/setup.py index 7bc1b1e..c69a7f9 100644 --- a/setup.py +++ b/setup.py @@ -2,6 +2,5 @@ """Setup script for petab-gui.""" from setuptools import setup # type: ignore - if __name__ == "__main__": - setup(version="0.0.1") \ No newline at end of file + setup(version="0.0.1") diff --git a/src/petab_gui/C.py b/src/petab_gui/C.py index eb412af..d566728 100644 --- a/src/petab_gui/C.py +++ b/src/petab_gui/C.py @@ -1,4 +1,5 @@ """Constants for the PEtab edit GUI.""" + import numpy as np COLUMNS = { @@ -30,36 +31,39 @@ "nominalValue": {"type": np.float64, "optional": False}, "estimate": {"type": np.object_, "optional": False}, "initializationPriorType": {"type": np.object_, "optional": True}, - "initializationPriorParameters": {"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": np.object_, "optional": False}, "conditionName": {"type": np.object_, "optional": False}, - } + }, } CONFIG = { - 'window_title': 'My Application', - 'window_size': (800, 600), - 'table_titles': { - 'data': 'Data', - 'parameters': 'Parameters', - 'observables': 'Observables', - 'conditions': 'Conditions' + "window_title": "My Application", + "window_size": (800, 600), + "table_titles": { + "data": "Data", + "parameters": "Parameters", + "observables": "Observables", + "conditions": "Conditions", + }, + "summary_title": "Summary", + "buttons": { + "test_consistency": "Test Consistency", + "proceed_optimization": "Proceed to Optimization", }, - 'summary_title': 'Summary', - 'buttons': { - 'test_consistency': 'Test Consistency', - 'proceed_optimization': 'Proceed to Optimization' - } } # String constants -ROW = 'row' -COLUMN = 'column' -INDEX = 'index' +ROW = "row" +COLUMN = "column" +INDEX = "index" COPY_FROM = "copy from" USE_DEFAULT = "use default" @@ -120,66 +124,47 @@ "observable": ALLOWED_STRATEGIES_OBS, "parameter": ALLOWED_STRATEGIES_PAR, "condition": ALLOWED_STRATEGIES_COND, - "measurement": ALLOWED_STRATEGIES_MEAS + "measurement": ALLOWED_STRATEGIES_MEAS, } DEFAULT_OBS_CONFIG = { "observableId": { - "strategy": COPY_FROM, SOURCE_COLUMN: "observableFormula", - DEFAULT_VALUE: "new_observable" - }, - "observableName": { - "strategy": COPY_FROM, SOURCE_COLUMN: "observableId" - }, - "noiseFormula": { - "strategy": USE_DEFAULT, DEFAULT_VALUE: 1 + "strategy": COPY_FROM, + SOURCE_COLUMN: "observableFormula", + DEFAULT_VALUE: "new_observable", }, + "observableName": {"strategy": COPY_FROM, SOURCE_COLUMN: "observableId"}, + "noiseFormula": {"strategy": USE_DEFAULT, DEFAULT_VALUE: 1}, "observableTransformation": { "strategy": USE_DEFAULT, - DEFAULT_VALUE: "lin" + DEFAULT_VALUE: "lin", }, - "noiseDistribution": { - "strategy": USE_DEFAULT, - DEFAULT_VALUE: "normal" - } + "noiseDistribution": {"strategy": USE_DEFAULT, DEFAULT_VALUE: "normal"}, } DEFAULT_PAR_CONFIG = { "parameterName": { - "strategy": COPY_FROM, SOURCE_COLUMN: "parameterId", - DEFAULT_VALUE: "new_parameter" - }, - "parameterScale": { - "strategy": USE_DEFAULT, DEFAULT_VALUE: "log10" - }, - "lowerBound": { - "strategy": MIN_COLUMN - }, - "upperBound": { - "strategy": MAX_COLUMN - }, - "estimate": { - "strategy": USE_DEFAULT, DEFAULT_VALUE: 1 - }, - "nominalValue": { - "strategy": SBML_LOOK + "strategy": COPY_FROM, + SOURCE_COLUMN: "parameterId", + DEFAULT_VALUE: "new_parameter", }, + "parameterScale": {"strategy": USE_DEFAULT, DEFAULT_VALUE: "log10"}, + "lowerBound": {"strategy": MIN_COLUMN}, + "upperBound": {"strategy": MAX_COLUMN}, + "estimate": {"strategy": USE_DEFAULT, DEFAULT_VALUE: 1}, + "nominalValue": {"strategy": SBML_LOOK}, } DEFAULT_COND_CONFIG = { - "conditionId": { - "strategy": USE_DEFAULT, DEFAULT_VALUE: "new_condition" - }, - "conditionName": { - "strategy": COPY_FROM, SOURCE_COLUMN: "conditionId" - } + "conditionId": {"strategy": USE_DEFAULT, DEFAULT_VALUE: "new_condition"}, + "conditionName": {"strategy": COPY_FROM, SOURCE_COLUMN: "conditionId"}, } DEFAULT_MEAS_CONFIG = {} DEFAULT_CONFIGS = { "observable": DEFAULT_OBS_CONFIG, "parameter": DEFAULT_PAR_CONFIG, "condition": DEFAULT_COND_CONFIG, - "measurement": DEFAULT_MEAS_CONFIG + "measurement": DEFAULT_MEAS_CONFIG, } COMMON_ERRORS = { r"Error parsing '': Syntax error at \d+:\d+: mismatched input '' " - r"expecting \{[^}]+\}" : "Invalid empty cell!" + r"expecting \{[^}]+\}": "Invalid empty cell!" } diff --git a/src/petab_gui/__init__.py b/src/petab_gui/__init__.py index b39b50f..00aac77 100644 --- a/src/petab_gui/__init__.py +++ b/src/petab_gui/__init__.py @@ -1 +1,3 @@ -from .app import main \ No newline at end of file +"""Package for the PETAB GUI.""" + +from .app import main diff --git a/src/petab_gui/__main__.py b/src/petab_gui/__main__.py index 226178c..9be38c9 100644 --- a/src/petab_gui/__main__.py +++ b/src/petab_gui/__main__.py @@ -1,8 +1,9 @@ -import sys from petab_gui import app + def main(): app.main() + if __name__ == "__main__": - main() \ No newline at end of file + main() diff --git a/src/petab_gui/app.py b/src/petab_gui/app.py index 6c8694e..83fc763 100644 --- a/src/petab_gui/app.py +++ b/src/petab_gui/app.py @@ -1,49 +1,66 @@ -from PySide6.QtWidgets import QApplication -from PySide6.QtGui import QFileOpenEvent, QIcon -from PySide6.QtCore import QEvent -from importlib.resources import files -import sys import os -import petab.v1 as petab +import sys +from importlib.resources import files +from pathlib import Path + +from PySide6.QtCore import QEvent +from PySide6.QtGui import QFileOpenEvent, QIcon +from PySide6.QtWidgets import QApplication -from .views import MainWindow from .controllers import MainController from .models import PEtabModel - -from pathlib import Path +from .views import MainWindow def find_example(path: Path) -> Path: + """Find the example directory by traversing up from the given path. + + Args: + path: The starting path to search from + + Returns: + Path: The path to the example directory + + Raises: + FileNotFoundError: If the example directory cannot be found + """ while path.parent != path: if (path / "example").is_dir(): return path / "example" path = path.parent - + raise FileNotFoundError("Could not find examples directory") def get_icon() -> QIcon: - """Get the Icon for the Window""" + """Get the Icon for the Window.""" icon_path = files("petab_gui.assets").joinpath("PEtab.png") if not icon_path.is_file(): raise FileNotFoundError(f"Icon file not found: {icon_path}") - icon = QIcon(str(icon_path)) - return icon + return QIcon(str(icon_path)) class PEtabGuiApp(QApplication): + """Main application class for PEtab GUI. + + Inherits from QApplication and sets up the MVC components. + """ + def __init__(self): + """Initialize the PEtab GUI application. + + Sets up the model, view, and controller components. + Handles command line arguments for opening files. + """ super().__init__(sys.argv) - # Load the stylesheet - # self.apply_stylesheet() self.setWindowIcon(get_icon()) self.model = PEtabModel() self.view = MainWindow() self.view.setWindowIcon(get_icon()) self.controller = MainController(self.view, self.model) - # hack to be discussed + # Connect the view to the controller self.view.controller = self.controller if len(sys.argv) > 1 and os.path.isfile(sys.argv[1]): @@ -52,27 +69,50 @@ def __init__(self): self.view.show() def event(self, event): + """Handle application events. + + Args: + event: The Qt event to handle + + Returns: + bool: Result of the event handling from the parent class + + Notes: + Currently handles FileOpen events to open files dropped on + the application. + """ if event.type() == QEvent.FileOpen: - openEvent = QFileOpenEvent(event) + openEvent = QFileOpenEvent(event) self.controller.open_file(openEvent.file(), mode="overwrite") return super().event(event) def apply_stylesheet(self): - """Load and apply the QSS stylesheet.""" + """Load and apply the QSS stylesheet to the application. + + Reads the stylesheet.css file from the same directory as this module + and applies it to the application. If the file doesn't exist, + no stylesheet is applied. + """ stylesheet_path = os.path.join( os.path.dirname(__file__), "stylesheet.css" ) if os.path.exists(stylesheet_path): - with open(stylesheet_path, "r") as f: + with open(stylesheet_path) as f: self.setStyleSheet(f.read()) else: - print(f"Warning: Stylesheet '{stylesheet_path}' not found!") + pass def main(): + """Entry point for the PEtab GUI application. + + Creates the application instance and starts the event loop. + The function exits with the return code from the application. + """ app = PEtabGuiApp() sys.exit(app.exec()) + if __name__ == "__main__": main() diff --git a/src/petab_gui/commands.py b/src/petab_gui/commands.py index 2563c1a..518cf67 100644 --- a/src/petab_gui/commands.py +++ b/src/petab_gui/commands.py @@ -1,17 +1,28 @@ """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 +import numpy as np +import pandas as pd +from PySide6.QtCore import QModelIndex, Qt +from PySide6.QtGui import QUndoCommand -pd.set_option('future.no_silent_downcasting', True) +pd.set_option("future.no_silent_downcasting", True) class ModifyColumnCommand(QUndoCommand): - """Command to add a column to the table.""" + """Command to add or remove a column in the table. + + This command is used for undo/redo functionality when adding or removing + columns in a table model. + """ def __init__(self, model, column_name, add_mode: bool = True): + """Initialize the command for adding or removing a column. + + Args: + model: The table model to modify + column_name: The name of the column to add or remove + add_mode: If True, add a column; if False, remove a column + """ action = "Add" if add_mode else "Remove" super().__init__( f"{action} column {column_name} in table {model.table_type}" @@ -27,38 +38,65 @@ def __init__(self, model, column_name, add_mode: bool = True): self.old_values = model._data_frame[column_name].copy() def redo(self): + """Execute the command to add or remove a column. + + If in add mode, adds a new column to the table. + If in remove mode, removes the specified column from the table. + """ 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.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): + """Undo the command, reversing the add or remove operation. + + If the original command was to add a column, this removes it. + If the original command was to remove a column, this restores it. + """ 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.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.""" + """Command to add or remove rows in the table. + + This command is used for undo/redo functionality when adding or removing + rows in a table model. + """ def __init__( - self, - model, - row_indices: list[int] | int, - add_mode: bool = True + self, model, row_indices: list[int] | int, add_mode: bool = True ): + """Initialize the command for adding or removing rows. + + Args: + model: The table model to modify + row_indices: If add_mode is True, the number of rows to add. + If add_mode is False, the indices of rows to remove. + add_mode: If True, add rows; if False, remove rows + """ action = "Add" if add_mode else "Remove" super().__init__(f"{action} row(s) in table {model.table_type}") self.model = model @@ -73,7 +111,9 @@ def __init__( 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.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] @@ -93,32 +133,53 @@ def _generate_new_indices(self, count): return indices def redo(self): + """Execute the command to add or remove rows. + + If in add mode, adds new rows to the table. + If in remove mode, removes the specified rows from the table. + """ 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): + self.model.beginInsertRows( + QModelIndex(), position, position + len(self.row_indices) - 1 + ) + for _i, idx in enumerate(self.row_indices): df.loc[idx] = [np.nan] * df.shape[1] self.model.endInsertRows() else: - self.model.beginRemoveRows(QModelIndex(), min(self.row_indices), max(self.row_indices)) + 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): + """Undo the command, reversing the add or remove operation. + + If the original command was to add rows, this removes them. + If the original command was to remove rows, this restores them. + """ 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)) + 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)) + 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 + self.row_indices, + self.old_ind_names, + self.old_rows.values, + strict=False, ): restore_index_order = restore_index_order.insert( pos, index_name @@ -126,24 +187,50 @@ def undo(self): df.loc[index_name] = row df.sort_index( inplace=True, - key=lambda x: x.map(restore_index_order.get_loc) + 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"): + """Command to modify values in a DataFrame. + + This command is used for undo/redo functionality when modifying cell values + in a table model. + """ + + def __init__( + self, model, changes: dict[tuple, tuple], description="Modify values" + ): + """Initialize the command for modifying DataFrame values. + + Args: + model: + The table model to modify + changes: + A dictionary mapping (row_key, column_name) to (old_val, new_val) + description: + A description of the command for the undo stack + """ super().__init__(description) self.model = model self.changes = changes # {(row_key, column_name): (old_val, new_val)} def redo(self): + """Execute the command to apply the new values.""" self._apply_changes(use_new=True) def undo(self): + """Undo the command to restore the old values.""" self._apply_changes(use_new=False) def _apply_changes(self, use_new: bool): + """Apply changes to the DataFrame. + + Args: + use_new: + If True, apply the new values; if False, restore the old values + """ df = self.model._data_frame col_offset = 1 if self.model._has_named_index else 0 original_dtypes = df.dtypes.copy() @@ -156,7 +243,7 @@ def _apply_changes(self, use_new: bool): update_df = pd.Series(update_vals).unstack() for col in update_df.columns: if col in df.columns: - df[col] = df[col].astype('object') + df[col] = df[col].astype("object") update_df.replace({None: "Placeholder_temp"}, inplace=True) df.update(update_df) df.replace({"Placeholder_temp": ""}, inplace=True) @@ -168,10 +255,13 @@ def _apply_changes(self, use_new: bool): 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()] + rows = [ + df.index.get_loc(row_key) for (row_key, _) in self.changes + ] + cols = [ + df.columns.get_loc(col) + col_offset + for (_, col) in self.changes + ] top_left = self.model.index(min(rows), min(cols)) bottom_right = self.model.index(max(rows), max(cols)) @@ -179,7 +269,21 @@ def _apply_changes(self, use_new: bool): class RenameIndexCommand(QUndoCommand): + """Command to rename an index in a DataFrame. + + This command is used for undo/redo functionality when renaming row indices + in a table model. + """ + def __init__(self, model, old_index, new_index, model_index): + """Initialize the command for renaming an index. + + Args: + model: The table model to modify + old_index: The original index name + new_index: The new index name + model_index: The QModelIndex of the cell being edited + """ super().__init__(f"Rename index {old_index} → {new_index}") self.model = model self.model_index = model_index @@ -187,12 +291,20 @@ def __init__(self, model, old_index, new_index, model_index): self.new_index = new_index def redo(self): + """Execute the command to rename the index.""" self._apply(self.old_index, self.new_index) def undo(self): + """Undo the command to restore the original index name.""" self._apply(self.new_index, self.old_index) def _apply(self, src, dst): + """Apply the rename operation. + + Args: + src: The source index name to rename + dst: The destination index name + """ df = self.model._data_frame df.rename(index={src: dst}, inplace=True) self.model.dataChanged.emit( diff --git a/src/petab_gui/controllers/__init__.py b/src/petab_gui/controllers/__init__.py index 94c83eb..8b95e95 100644 --- a/src/petab_gui/controllers/__init__.py +++ b/src/petab_gui/controllers/__init__.py @@ -10,8 +10,14 @@ - The log controller, which handles all logging tasks - The SBML controller, which handles all SBML tasks """ -from .mother_controller import MainController -from .table_controllers import MeasurementController, ObservableController, \ - ParameterController, ConditionController, TableController + from .logger_controller import LoggerController -from .sbml_controller import SbmlController \ No newline at end of file +from .mother_controller import MainController +from .sbml_controller import SbmlController +from .table_controllers import ( + ConditionController, + MeasurementController, + ObservableController, + ParameterController, + TableController, +) diff --git a/src/petab_gui/controllers/default_handler.py b/src/petab_gui/controllers/default_handler.py index 1454f93..6ccacd2 100644 --- a/src/petab_gui/controllers/default_handler.py +++ b/src/petab_gui/controllers/default_handler.py @@ -1,19 +1,32 @@ """The Default Handlers for the GUI.""" -import pandas as pd -import numpy as np -import copy +import copy from collections import Counter -from ..C import (COPY_FROM, USE_DEFAULT, NO_DEFAULT, MIN_COLUMN, MAX_COLUMN, - MODE, DEFAULT_VALUE, SOURCE_COLUMN, SBML_LOOK) + +import numpy as np +import pandas as pd + +from ..C import ( + COPY_FROM, + DEFAULT_VALUE, + MAX_COLUMN, + MIN_COLUMN, + MODE, + NO_DEFAULT, + SBML_LOOK, + SOURCE_COLUMN, + USE_DEFAULT, +) class DefaultHandlerModel: - def __init__(self, model, config, sbml_model = None): + def __init__(self, model, config, sbml_model=None): """ Initialize the handler for the model. + :param model: The PandasTable Model containing the Data. - :param config: Dictionary containing strategies and settings for each column. + :param config: Dictionary containing strategies and settings for each + column. """ self._model = model # TODO: Check what happens with non inplace operations @@ -31,10 +44,14 @@ def get_default( ): """ Get the default value for a column based on its strategy. + :param column_name: The name of the column to compute the default for. - :param row_index: Optional index of the row (needed for some strategies). - :param par_scale: Optional parameter scale (needed for some strategies). - :param changed: Optional tuple containing the column name and index of the changed cell. + :param row_index: Optional index of the row (needed for some + strategies). + :param par_scale: Optional parameter scale (needed for some + strategies). + :param changed: Optional tuple containing the column name and index of + the changed cell. :return: The computed default value. """ source_column = column_name @@ -49,26 +66,27 @@ def get_default( default_value = column_config.get(DEFAULT_VALUE, "") if strategy == USE_DEFAULT: - if self.model.dtypes[column_name] == float: + if np.issubdtype(self.model.dtypes[column_name], np.floating): return float(default_value) return default_value - elif strategy == NO_DEFAULT: + if strategy == NO_DEFAULT: return "" - elif strategy == MIN_COLUMN: + if strategy == MIN_COLUMN: return self._min_column(column_name, par_scale) - elif strategy == MAX_COLUMN: + if strategy == MAX_COLUMN: return self._max_column(column_name, par_scale) - elif strategy == COPY_FROM: + if strategy == COPY_FROM: return self._copy_column( column_name, column_config, row_index, changed ) - elif strategy == MODE: + if strategy == MODE: column_config[SOURCE_COLUMN] = source_column return self._majority_vote(column_name, column_config) - elif strategy == SBML_LOOK: + if strategy == SBML_LOOK: return self._sbml_lookup(row_index) - else: - raise ValueError(f"Unknown strategy '{strategy}' for column '{column_name}'.") + raise ValueError( + f"Unknown strategy '{strategy}' for column '{column_name}'." + ) def _min_column(self, column_name, par_scale=None): if column_name not in self.model: @@ -80,6 +98,7 @@ def _min_column(self, column_name, par_scale=None): ] if not column_data.empty: return column_data.min() + return None def _max_column(self, column_name, par_scale=None): if column_name not in self.model: @@ -91,22 +110,18 @@ def _max_column(self, column_name, par_scale=None): ] if not column_data.empty: return column_data.max() + return None def _copy_column( - self, - column_name, - config, - row_index, - changed: dict | None = None + self, column_name, config, row_index, changed: dict | None = None ): """Copy the value from another column in the same row.""" source_column = config.get(SOURCE_COLUMN, column_name) source_column_valid = ( source_column in self.model or source_column == self.model_index ) - if changed: - if source_column in changed.keys(): - return changed[source_column] + if changed and source_column in changed: + return changed[source_column] if source_column and source_column_valid and row_index is not None: prefix = config.get("prefix", "") if row_index in self.model.index: diff --git a/src/petab_gui/controllers/logger_controller.py b/src/petab_gui/controllers/logger_controller.py index e791f51..d9b75dc 100644 --- a/src/petab_gui/controllers/logger_controller.py +++ b/src/petab_gui/controllers/logger_controller.py @@ -1,7 +1,6 @@ """File containing the controller of the logger widget.""" -from datetime import datetime -from pyarrow import timestamp +from datetime import datetime class LoggerController: @@ -31,9 +30,9 @@ def log_message(self, message, color="black", loglevel=1): if loglevel > self.logger_level: return timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S") - full_message = \ - (f"[{timestamp}]\t " - f"{message}") + full_message = ( + f"[{timestamp}]\t {message}" + ) for view in self.views: view.logger.append(full_message) diff --git a/src/petab_gui/controllers/mother_controller.py b/src/petab_gui/controllers/mother_controller.py index 605803b..e18290f 100644 --- a/src/petab_gui/controllers/mother_controller.py +++ b/src/petab_gui/controllers/mother_controller.py @@ -1,27 +1,42 @@ -from functools import partial - -from PySide6.QtWidgets import QMessageBox, QFileDialog, QLineEdit, QWidget, \ - QHBoxLayout, QToolButton, QTableView -from PySide6.QtGui import QAction, QDesktopServices, QUndoStack, QKeySequence -import zipfile -import tempfile +import logging import os +import tempfile +import zipfile +from functools import partial from io import BytesIO -import logging -import yaml +from pathlib import Path + import qtawesome as qta -from ..utils import FindReplaceDialog, CaptureLogHandler, process_file +import yaml from PySide6.QtCore import Qt, QUrl -from pathlib import Path +from PySide6.QtGui import QAction, QDesktopServices, QKeySequence, QUndoStack +from PySide6.QtWidgets import ( + QFileDialog, + QHBoxLayout, + QLineEdit, + QMessageBox, + QTableView, + QToolButton, + QWidget, +) + from ..models import PEtabModel -from .sbml_controller import SbmlController -from .table_controllers import MeasurementController, ObservableController, \ - ConditionController, ParameterController -from .logger_controller import LoggerController -from ..views import TaskBar -from .utils import prompt_overwrite_or_append, RecentFilesManager, filtered_error -from functools import partial from ..settings_manager import SettingsDialog, settings_manager +from ..utils import CaptureLogHandler, process_file +from ..views import TaskBar +from .logger_controller import LoggerController +from .sbml_controller import SbmlController +from .table_controllers import ( + ConditionController, + MeasurementController, + ObservableController, + ParameterController, +) +from .utils import ( + RecentFilesManager, + filtered_error, + prompt_overwrite_or_append, +) class MainController: @@ -53,41 +68,38 @@ def __init__(self, view, model: PEtabModel): self.model.measurement, self.logger, self.undo_stack, - self + self, ) self.observable_controller = ObservableController( self.view.observable_dock, self.model.observable, self.logger, self.undo_stack, - self + self, ) self.parameter_controller = ParameterController( self.view.parameter_dock, self.model.parameter, self.logger, self.undo_stack, - self + self, ) self.condition_controller = ConditionController( self.view.condition_dock, self.model.condition, self.logger, self.undo_stack, - self + self, ) self.sbml_controller = SbmlController( - self.view.sbml_viewer, - self.model.sbml, - self.logger, - self + self.view.sbml_viewer, self.model.sbml, self.logger, self ) self.controllers = [ self.measurement_controller, self.observable_controller, self.parameter_controller, self.condition_controller, - self.sbml_controller + self.sbml_controller, ] # Recent Files self.recent_files_manager = RecentFilesManager(max_files=10) @@ -96,12 +108,9 @@ def __init__(self, view, model: PEtabModel): "measurement": False, "observable": False, "parameter": False, - "condition": False - } - self.sbml_checkbox_states = { - "sbml": False, - "antimony": False + "condition": False, } + self.sbml_checkbox_states = {"sbml": False, "antimony": False} self.unsaved_changes = False self.filter = QLineEdit() self.filter_active = {} # Saves which tables the filter applies to @@ -135,31 +144,34 @@ 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 self.condition_controller.condition_2be_renamed.connect( partial( self.measurement_controller.rename_value, - column_names = ["simulationConditionId", - "preequilibrationConditionId"] + column_names=[ + "simulationConditionId", + "preequilibrationConditionId", + ], ) ) # Add new condition or observable self.model.measurement.relevant_id_changed.connect( lambda x, y, z: self.observable_controller.maybe_add_observable( - x, y) if z == "observable" else - self.condition_controller.maybe_add_condition( - x, y) if z == "condition" else None + x, y + ) + if z == "observable" + else self.condition_controller.maybe_add_condition(x, y) + if z == "condition" + else None ) # Maybe Move to a Plot Model self.view.measurement_dock.table_view.selectionModel().selectionChanged.connect( self.handle_selection_changed ) - self.model.measurement.dataChanged.connect( - self.handle_data_changed - ) + self.model.measurement.dataChanged.connect(self.handle_data_changed) # Unsaved Changes self.model.measurement.something_changed.connect( self.unsaved_changes_change @@ -173,9 +185,7 @@ def setup_connections(self): self.model.condition.something_changed.connect( self.unsaved_changes_change ) - self.model.sbml.something_changed.connect( - self.unsaved_changes_change - ) + self.model.sbml.something_changed.connect(self.unsaved_changes_change) # Visibility self.sync_visibility_with_actions() # Recent Files @@ -183,9 +193,7 @@ def setup_connections(self): partial(self.open_file, mode="overwrite") ) # Settings logging - settings_manager.new_log_message.connect( - self.logger.log_message - ) + settings_manager.new_log_message.connect(self.logger.log_message) # Update Parameter SBML Model self.sbml_controller.overwritten_model.connect( self.parameter_controller.update_handler_sbml @@ -193,108 +201,90 @@ def setup_connections(self): def setup_actions(self): """Setup actions for the main controller.""" - actions = {"close": QAction( - qta.icon("mdi6.close"), - "&Close", self.view - )} + actions = { + "close": QAction(qta.icon("mdi6.close"), "&Close", self.view) + } # Close 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 + qta.icon("mdi6.file-document"), "&New", self.view ) 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 + qta.icon("mdi6.folder-open"), "&Open", self.view ) actions["open"].setShortcut(QKeySequence.Open) actions["open"].triggered.connect( partial(self.open_file, mode="overwrite") ) # Add File - actions["add"] = QAction( - qta.icon("mdi6.table-plus"), - "Add", self.view - ) + actions["add"] = QAction(qta.icon("mdi6.table-plus"), "Add", self.view) actions["add"].setShortcut("Ctrl+Shift+O") actions["add"].triggered.connect( partial(self.open_file, mode="append") ) # Save actions["save"] = QAction( - qta.icon("mdi6.content-save-all"), - "&Save", self.view + qta.icon("mdi6.content-save-all"), "&Save", self.view ) 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"] = QAction(qta.icon("mdi6.magnify"), "Find", self.view) actions["find"].setShortcut(QKeySequence.Find) actions["find"].triggered.connect(self.find) actions["find+replace"] = QAction( - qta.icon("mdi6.find-replace"), - "Find/Replace", self.view + qta.icon("mdi6.find-replace"), "Find/Replace", self.view ) 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 + qta.icon("mdi6.content-copy"), "Copy", self.view ) actions["copy"].setShortcut(QKeySequence.Copy) actions["copy"].triggered.connect(self.copy_to_clipboard) actions["paste"] = QAction( - qta.icon("mdi6.content-paste"), - "Paste", self.view + qta.icon("mdi6.content-paste"), "Paste", self.view ) actions["paste"].setShortcut(QKeySequence.Paste) actions["paste"].triggered.connect(self.paste_from_clipboard) actions["cut"] = QAction( - qta.icon("mdi6.content-cut"), - "&Cut", self.view + qta.icon("mdi6.content-cut"), "&Cut", self.view ) actions["cut"].setShortcut(QKeySequence.Cut) actions["cut"].triggered.connect(self.cut) # add/delete row actions["add_row"] = QAction( - qta.icon("mdi6.table-row-plus-after"), - "Add Row", self.view + qta.icon("mdi6.table-row-plus-after"), "Add Row", self.view ) actions["add_row"].triggered.connect(self.add_row) actions["delete_row"] = QAction( - qta.icon("mdi6.table-row-remove"), - "Delete Row(s)", self.view + qta.icon("mdi6.table-row-remove"), "Delete Row(s)", self.view ) actions["delete_row"].triggered.connect(self.delete_rows) # add/delete column actions["add_column"] = QAction( - qta.icon("mdi6.table-column-plus-after"), - "Add Column", self.view + qta.icon("mdi6.table-column-plus-after"), "Add Column", self.view ) actions["add_column"].triggered.connect(self.add_column) actions["delete_column"] = QAction( - qta.icon("mdi6.table-column-remove"), - "Delete Column(s)", self.view + qta.icon("mdi6.table-column-remove"), "Delete Column(s)", self.view ) actions["delete_column"].triggered.connect(self.delete_column) # check petab model actions["check_petab"] = QAction( qta.icon("mdi6.checkbox-multiple-marked-circle-outline"), - "Check PEtab", self.view + "Check PEtab", + self.view, ) actions["check_petab"].triggered.connect(self.check_model) actions["reset_model"] = QAction( - qta.icon("mdi6.restore"), - "Reset SBML Model", self.view + qta.icon("mdi6.restore"), "Reset SBML Model", self.view ) actions["reset_model"].triggered.connect( self.sbml_controller.reset_to_original_model @@ -312,14 +302,16 @@ def setup_actions(self): filter_layout.addWidget(self.filter_input) for table_n, table_name in zip( ["m", "p", "o", "c"], - ["measurement", "parameter", "observable", "condition"] + ["measurement", "parameter", "observable", "condition"], + strict=False, ): tool_button = QToolButton() icon = qta.icon( - "mdi6.alpha-{}".format(table_n), "mdi6.filter", + f"mdi6.alpha-{table_n}", + "mdi6.filter", options=[ - {'scale_factor': 1.5, 'offset': (-0.2, -0.2)}, - {'off': 'mdi6.filter-off', 'offset': (0.3, 0.3)}, + {"scale_factor": 1.5, "offset": (-0.2, -0.2)}, + {"off": "mdi6.filter-off", "offset": (0.3, 0.3)}, ], ) tool_button.setIcon(icon) @@ -328,9 +320,7 @@ def setup_actions(self): tool_button.setToolTip(f"Filter for {table_name} table") filter_layout.addWidget(tool_button) self.filter_active[table_name] = tool_button - self.filter_active[table_name].toggled.connect( - self.filter_table - ) + self.filter_active[table_name].toggled.connect(self.filter_table) actions["filter_widget"] = filter_widget self.filter_input.textChanged.connect(self.filter_table) @@ -341,70 +331,55 @@ def setup_actions(self): ) actions[f"show_{element}"].setCheckable(True) actions[f"show_{element}"].setChecked(True) - actions["show_logger"] = QAction( - "Info", self.view - ) + actions["show_logger"] = QAction("Info", self.view) actions["show_logger"].setCheckable(True) actions["show_logger"].setChecked(True) - actions["show_plot"] = QAction( - "Data Plot", self.view - ) + actions["show_plot"] = QAction("Data Plot", self.view) actions["show_plot"].setCheckable(True) actions["show_plot"].setChecked(True) # connect actions actions["reset_view"] = QAction( - qta.icon("mdi6.view-grid-plus"), - "Reset View", self.view - ) - actions["reset_view"].triggered.connect( - self.view.default_view + qta.icon("mdi6.view-grid-plus"), "Reset View", self.view ) + actions["reset_view"].triggered.connect(self.view.default_view) # Clear Log actions["clear_log"] = QAction( - qta.icon("mdi6.delete"), - "Clear Log", self.view + qta.icon("mdi6.delete"), "Clear Log", self.view ) actions["clear_log"].triggered.connect(self.logger.clear_log) # Settings actions["settings"] = QAction( - qta.icon("mdi6.cog"), - "Settings", self.view + qta.icon("mdi6.cog"), "Settings", self.view ) actions["settings"].triggered.connect(self.open_settings) # Opening the PEtab documentation actions["open_documentation"] = QAction( - qta.icon("mdi6.web"), - "View PEtab Documentation", self.view + qta.icon("mdi6.web"), "View PEtab Documentation", self.view ) actions["open_documentation"].triggered.connect( - lambda: QDesktopServices.openUrl(QUrl( - "https://petab.readthedocs.io/en/latest/v1/" - "documentation_data_format.html" - )) + lambda: QDesktopServices.openUrl( + QUrl( + "https://petab.readthedocs.io/en/latest/v1/" + "documentation_data_format.html" + ) + ) ) # Undo / Redo - actions["undo"] = QAction( - qta.icon("mdi6.undo"), - "&Undo", self.view - ) + 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"] = 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) # Clear cells actions["clear_cells"] = QAction( - qta.icon("mdi6.delete"), - "&Clear Cells", self.view + qta.icon("mdi6.delete"), "&Clear Cells", self.view ) actions["clear_cells"].setShortcuts( [QKeySequence.Delete, QKeySequence.Backspace] @@ -414,7 +389,6 @@ def setup_actions(self): def sync_visibility_with_actions(self): """Sync dock visibility and QAction states in both directions.""" - dock_map = { "measurement": self.view.measurement_dock, "observable": self.view.observable_dock, @@ -427,7 +401,8 @@ def sync_visibility_with_actions(self): for key, dock in dock_map.items(): action = self.actions[f"show_{key}"] - # Initial sync: block signal to avoid triggering unwanted visibility changes + # Initial sync: block signal to avoid triggering unwanted + # visibility changes was_blocked = action.blockSignals(True) action.setChecked(dock.isVisible()) action.blockSignals(was_blocked) @@ -439,11 +414,7 @@ def sync_visibility_with_actions(self): def save_model(self): options = QFileDialog.Options() file_name, _ = QFileDialog.getSaveFileName( - self.view, - "Save Project", - "", - "Zip Files (*.zip)", - options=options + self.view, "Save Project", "", "Zip Files (*.zip)", options=options ) if not file_name: return False @@ -456,55 +427,44 @@ def save_model(self): # Create a bytes buffer to hold the zip file in memory buffer = BytesIO() - with zipfile.ZipFile(buffer, 'w') as zip_file: + with zipfile.ZipFile(buffer, "w") as zip_file: # Add files to zip archive for root, _, files in os.walk(temp_dir): for file in files: file_path = os.path.join(root, file) - with open(file_path, 'rb') as f: + with open(file_path, "rb") as f: zip_file.writestr(file, f.read()) - with open(file_name, 'wb') as f: + with open(file_name, "wb") as f: f.write(buffer.getvalue()) QMessageBox.information( - self.view, "Save Project", - f"Project saved successfully to {file_name}" + self.view, + "Save Project", + f"Project saved successfully to {file_name}", ) return True - def open_find_replace_dialog(self): - current_tab = self.view.tab_widget.currentIndex() - if current_tab == 0: - # TODO: rewrite functionality in FindReplaceDialoge - dialog = FindReplaceDialog( - self.view, mode="petab", - checkbox_states=self.petab_checkbox_states, - controller=self - ) - elif current_tab == 1: - dialog = FindReplaceDialog( - self.view, mode="sbml", - checkbox_states=self.sbml_checkbox_states, - controller=self - ) - dialog.exec() - def handle_selection_changed(self): - # ?? + """Update the plot when selection in the measurement table changes.""" self.update_plot() def handle_data_changed(self, top_left, bottom_right, roles): - # ?? + """Update the plot when the data in the measurement table changes.""" if not roles or Qt.DisplayRole in roles: self.update_plot() def update_plot(self): - # ?? - selection_model = \ + """Update the plot with the selected measurement data. + + Extracts the selected data points from the measurement table and + updates the plot visualization with this data. + """ + selection_model = ( self.view.measurement_dock.table_view.selectionModel() + ) indexes = selection_model.selectedIndexes() if not indexes: - return None + return selected_points = {} for index in indexes: @@ -512,30 +472,34 @@ def update_plot(self): continue row = index.row() observable_id = self.model.measurement._data_frame.iloc[row][ - "observableId"] + "observableId" + ] if observable_id not in selected_points: selected_points[observable_id] = [] - selected_points[observable_id].append({ - "x": self.model.measurement._data_frame.iloc[row]["time"], - "y": self.model.measurement._data_frame.iloc[row][ - "measurement"] - }) + selected_points[observable_id].append( + { + "x": self.model.measurement._data_frame.iloc[row]["time"], + "y": self.model.measurement._data_frame.iloc[row][ + "measurement" + ], + } + ) if selected_points == {}: - return None + return measurement_data = self.model.measurement._data_frame - plot_data = { - "all_data": [], - "selected_points": selected_points - } - for observable_id in selected_points.keys(): + plot_data = {"all_data": [], "selected_points": selected_points} + for observable_id in selected_points: observable_data = measurement_data[ - measurement_data["observableId"] == observable_id] - plot_data["all_data"].append({ - "observable_id": observable_id, - "x": observable_data["time"].tolist(), - "y": observable_data["measurement"].tolist() - }) + measurement_data["observableId"] == observable_id + ] + plot_data["all_data"].append( + { + "observable_id": observable_id, + "x": observable_data["time"].tolist(), + "y": observable_data["measurement"].tolist(), + } + ) self.view.plot_dock.update_visualization(plot_data) @@ -553,7 +517,7 @@ def open_file(self, file_path=None, mode=None): "All supported (*.yaml *.yml *.xml *.sbml *.tsv *.csv *.txt);;" "PEtab Problems (*.yaml *.yml);;SBML Files (*.xml *.sbml);;" "PEtab Tables or Data Matrix (*.tsv *.csv *.txt);;" - "All files (*)" + "All files (*)", ) if not file_path: return @@ -562,7 +526,7 @@ def open_file(self, file_path=None, mode=None): if actionable in ["yaml", "sbml"] and mode == "append": self.logger.log_message( f"Append mode is not supported for *.{actionable} files.", - color="red" + color="red", ) return if not actionable: @@ -579,6 +543,7 @@ def open_file(self, file_path=None, mode=None): def _open_file(self, actionable, file_path, sep, mode): """Overwrites the File in the appropriate controller. + Actionable dictates which controller to use. """ if actionable == "yaml": @@ -586,21 +551,13 @@ def _open_file(self, actionable, file_path, sep, mode): elif actionable == "sbml": self.sbml_controller.overwrite_sbml(file_path) elif actionable == "measurement": - self.measurement_controller.open_table( - file_path, sep, mode - ) + self.measurement_controller.open_table(file_path, sep, mode) elif actionable == "observable": - self.observable_controller.open_table( - file_path, sep, mode - ) + self.observable_controller.open_table(file_path, sep, mode) elif actionable == "parameter": - self.parameter_controller.open_table( - file_path, sep, mode - ) + self.parameter_controller.open_table(file_path, sep, mode) elif actionable == "condition": - self.condition_controller.open_table( - file_path, sep, mode - ) + self.condition_controller.open_table(file_path, sep, mode) elif actionable == "data_matrix": self.measurement_controller.process_data_matrix_file( file_path, mode, sep @@ -614,10 +571,7 @@ def open_yaml_and_load_files(self, yaml_path=None, mode="overwrite"): """ if not yaml_path: yaml_path, _ = QFileDialog.getOpenFileName( - self.view, - "Open YAML File", - "", - "YAML Files (*.yaml *.yml)" + self.view, "Open YAML File", "", "YAML Files (*.yaml *.yml)" ) if not yaml_path: return @@ -627,31 +581,32 @@ def open_yaml_and_load_files(self, yaml_path=None, mode="overwrite"): continue controller.release_completers() # Load the YAML content - with open(yaml_path, 'r') as file: + with open(yaml_path) as file: yaml_content = yaml.safe_load(file) # Resolve the directory of the YAML file to handle relative paths yaml_dir = Path(yaml_path).parent # Upload SBML model - sbml_file_path = \ - yaml_dir / yaml_content['problems'][0]['sbml_files'][0] + sbml_file_path = ( + yaml_dir / yaml_content["problems"][0]["sbml_files"][0] + ) self.sbml_controller.overwrite_sbml(sbml_file_path) self.measurement_controller.open_table( - yaml_dir / yaml_content['problems'][0]['measurement_files'][0] + yaml_dir / yaml_content["problems"][0]["measurement_files"][0] ) self.observable_controller.open_table( - yaml_dir / yaml_content['problems'][0]['observable_files'][0] + yaml_dir / yaml_content["problems"][0]["observable_files"][0] ) self.parameter_controller.open_table( - yaml_dir / yaml_content['parameter_file'] + yaml_dir / yaml_content["parameter_file"] ) self.condition_controller.open_table( - yaml_dir / yaml_content['problems'][0]['condition_files'][0] + yaml_dir / yaml_content["problems"][0]["condition_files"][0] ) self.logger.log_message( "All files opened successfully from the YAML configuration.", - color="green" + color="green", ) self.check_model() # rerun the completers @@ -670,10 +625,11 @@ def new_file(self): """Empty all tables. In case of unsaved changes, ask to save.""" if self.unsaved_changes: reply = QMessageBox.question( - self.view, "Unsaved Changes", + self.view, + "Unsaved Changes", "You have unsaved changes. Do you want to save them?", QMessageBox.Save | QMessageBox.Discard | QMessageBox.Cancel, - QMessageBox.Save + QMessageBox.Save, ) if reply == QMessageBox.Save: self.save_model() @@ -681,7 +637,7 @@ def new_file(self): self.measurement_controller, self.observable_controller, self.parameter_controller, - self.condition_controller + self.condition_controller, ]: controller.clear_table() @@ -704,7 +660,7 @@ def check_model(self): self.logger.log_message( f"Captured petab lint logs:
" f"    {captured_output}", - color="purple" + color="purple", ) # Log the consistency check result @@ -733,10 +689,11 @@ def maybe_close(self): self.view.allow_close = True return reply = QMessageBox.question( - self.view, "Unsaved Changes", + self.view, + "Unsaved Changes", "You have unsaved changes. Do you want to save them?", QMessageBox.Save | QMessageBox.Discard | QMessageBox.Cancel, - QMessageBox.Save + QMessageBox.Save, ) if reply == QMessageBox.Save: saved = self.save_model() @@ -764,7 +721,6 @@ def active_controller(self): return self.parameter_controller if active_widget == self.view.condition_dock.table_view: return self.condition_controller - print("No active controller found") return None def delete_rows(self): @@ -793,7 +749,7 @@ def clear_cells(self): controller.clear_cells() def filter_table(self): - """Filter the currently activated tables""" + """Filter the currently activated tables.""" filter_text = self.filter_input.text() for table_name, tool_button in self.filter_active.items(): if tool_button.isChecked(): @@ -831,9 +787,6 @@ def open_settings(self): settings_dialog = SettingsDialog(table_columns, self.view) settings_dialog.exec() - def set_docks_visible(self): - """Handles Visibility of docks.""" - pass def find(self): """Create a find replace bar if it is non existent.""" diff --git a/src/petab_gui/controllers/sbml_controller.py b/src/petab_gui/controllers/sbml_controller.py index 1cfdfeb..26ea325 100644 --- a/src/petab_gui/controllers/sbml_controller.py +++ b/src/petab_gui/controllers/sbml_controller.py @@ -1,16 +1,20 @@ """Class for handling SBML files in the GUI.""" -from PySide6.QtWidgets import QFileDialog + +from pathlib import Path + import libsbml from petab.models.sbml_model import SbmlModel -from PySide6.QtCore import Signal, QObject -from pathlib import Path +from PySide6.QtCore import QObject, Signal +from PySide6.QtWidgets import QFileDialog + from ..models.sbml_model import SbmlViewerModel -from ..views.sbml_view import SbmlViewer from ..utils import sbmlToAntimony +from ..views.sbml_view import SbmlViewer class SbmlController(QObject): """Class for handling SBML files in the GUI.""" + overwritten_model = Signal() def __init__( @@ -18,7 +22,7 @@ def __init__( view: SbmlViewer, model: SbmlViewerModel, logger, - mother_controller + mother_controller, ): """Initialize the SBML controller. @@ -57,7 +61,7 @@ def reset_to_original_model(self): """Reset the model to the original SBML and Antimony text.""" self.logger.log_message( "Resetting the model to the original SBML and Antimony text", - color="orange" + color="orange", ) self.model.sbml_text = libsbml.writeSBMLToString( self.model._sbml_model_original.sbml_model.getSBMLDocument() @@ -67,20 +71,17 @@ def reset_to_original_model(self): self.view.antimony_text_edit.setPlainText(self.model.antimony_text) def update_antimony_from_sbml(self): - "Convert current SBML to Antimony and update the Antimony text." + """Convert current SBML to Antimony and update the Antimony text.""" self.model.sbml_text = self.view.sbml_text_edit.toPlainText() try: self.model.convert_sbml_to_antimony() except Exception as e: self.logger.log_message( - f"Failed to convert SBML to Antimony: {str(e)}", - color="red" + f"Failed to convert SBML to Antimony: {str(e)}", color="red" ) return self.logger.log_message("Converting SBML to Antimony", color="green") - self.view.antimony_text_edit.setPlainText( - self.model.antimony_text - ) + self.view.antimony_text_edit.setPlainText(self.model.antimony_text) self.model.something_changed.emit(True) def update_sbml_from_antimony(self): @@ -90,8 +91,7 @@ def update_sbml_from_antimony(self): self.model.convert_antimony_to_sbml() except Exception as e: self.logger.log_message( - f"Failed to convert Antimony to SBML: {str(e)}", - color="red" + f"Failed to convert Antimony to SBML: {str(e)}", color="red" ) return self.logger.log_message("Converting Antimony to SBML", color="green") @@ -114,20 +114,15 @@ def overwrite_sbml(self, file_path=None): self.model._sbml_model_original.sbml_model.getSBMLDocument() ) self.model.convert_sbml_to_antimony() - self.view.sbml_text_edit.setPlainText( - self.model.sbml_text - ) - self.view.antimony_text_edit.setPlainText( - self.model.antimony_text - ) + self.view.sbml_text_edit.setPlainText(self.model.sbml_text) + self.view.antimony_text_edit.setPlainText(self.model.antimony_text) self.overwritten_model.emit() self.logger.log_message( "SBML model successfully opened and overwritten.", - color="green" + color="green", ) except Exception as e: self.logger.log_message( - f"Failed to open SBML file: {str(e)}", - color="red" + f"Failed to open SBML file: {str(e)}", color="red" ) diff --git a/src/petab_gui/controllers/table_controllers.py b/src/petab_gui/controllers/table_controllers.py index ed771d6..4156a86 100644 --- a/src/petab_gui/controllers/table_controllers.py +++ b/src/petab_gui/controllers/table_controllers.py @@ -1,24 +1,40 @@ """Classes for the controllers of the tables in the GUI.""" -from PySide6.QtWidgets import QInputDialog, QMessageBox, QFileDialog, \ - QCompleter, QAbstractItemView + +import re +from pathlib import Path + import numpy as np import pandas as pd import petab.v1 as petab -from PySide6.QtCore import Signal, QObject, QModelIndex, Qt, QTimer -from pathlib import Path -from ..models.pandas_table_model import PandasTableModel, \ - PandasTableFilterProxy -from ..settings_manager import settings_manager -from ..views.table_view import TableViewer, SingleSuggestionDelegate, \ - ColumnSuggestionDelegate, ComboBoxDelegate, ParameterIdSuggestionDelegate -from ..utils import get_selected, process_file, ConditionInputDialog -from .utils import prompt_overwrite_or_append, linter_wrapper +from PySide6.QtCore import QModelIndex, QObject, Qt, Signal +from PySide6.QtWidgets import ( + QAbstractItemView, + QCompleter, + QFileDialog, + QInputDialog, + QMessageBox, +) + from ..C import COLUMN, INDEX -import re +from ..models.pandas_table_model import ( + PandasTableFilterProxy, + PandasTableModel, +) +from ..settings_manager import settings_manager +from ..utils import ConditionInputDialog, get_selected, process_file +from ..views.table_view import ( + ColumnSuggestionDelegate, + ComboBoxDelegate, + ParameterIdSuggestionDelegate, + SingleSuggestionDelegate, + TableViewer, +) +from .utils import linter_wrapper, prompt_overwrite_or_append class TableController(QObject): """Base class for table controllers.""" + overwritten_df = Signal() # Signal to mother controller def __init__( @@ -27,7 +43,7 @@ def __init__( model: PandasTableModel, logger, undo_stack, - mother_controller + mother_controller, ): """Initialize the table controller. @@ -80,18 +96,10 @@ def setup_connections(self): Only handles connections from within the table controllers. """ - self.model.new_log_message.connect( - self.logger.log_message - ) - self.model.cell_needs_validation.connect( - self.validate_changed_cell - ) - self.model.inserted_row.connect( - self.set_index_on_new_row - ) - settings_manager.settings_changed.connect( - self.update_defaults - ) + self.model.new_log_message.connect(self.logger.log_message) + self.model.cell_needs_validation.connect(self.validate_changed_cell) + self.model.inserted_row.connect(self.set_index_on_new_row) + settings_manager.settings_changed.connect(self.update_defaults) def setup_context_menu(self, actions): """Setup context menu for this table.""" @@ -124,8 +132,10 @@ def open_table(self, file_path=None, separator=None, mode="overwrite"): if not file_path: # Open a file dialog to select the CSV or TSV file file_path, _ = QFileDialog.getOpenFileName( - self.view, "Open CSV or TSV", "", - "CSV/TSV/TXT Files (*.csv *.tsv *.txt)" + self.view, + "Open CSV or TSV", + "", + "CSV/TSV/TXT Files (*.csv *.tsv *.txt)", ) # just in case anything goes wrong here if not file_path: @@ -145,14 +155,14 @@ def open_table(self, file_path=None, separator=None, mode="overwrite"): new_df = pd.read_csv(file_path, sep=separator, index_col=0) except Exception as e: self.view.log_message( - f"Failed to read file: {str(e)}", - color="red" + f"Failed to read file: {str(e)}", color="red" ) return dtypes = { - col: self.model._allowed_columns.get( - col, {"type": np.object_} - )["type"] for col in new_df.columns + 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: @@ -174,7 +184,7 @@ def overwrite_df(self, new_df: pd.DataFrame): self.model.endResetModel() self.logger.log_message( f"Overwrote the {self.model.table_type} table with new data.", - color="green" + color="green", ) # test: overwrite the new model as source model self.proxy_model.setSourceModel(self.model) @@ -190,9 +200,7 @@ def append_df(self, new_df: pd.DataFrame): 2. Rows are the union of both DataFrame rows (duplicates removed) """ self.model.beginResetModel() - combined_df = pd.concat( - [self.model.get_df(), new_df], axis=0 - ) + combined_df = pd.concat([self.model.get_df(), new_df], axis=0) combined_df = combined_df[~combined_df.index.duplicated(keep="first")] self.model._data_frame = combined_df self.proxy_model.setSourceModel(None) @@ -200,7 +208,7 @@ def append_df(self, new_df: pd.DataFrame): self.model.endResetModel() self.logger.log_message( f"Appended the {self.model.table_type} table with new data.", - color="green" + color="green", ) # test: overwrite the new model as source model self.overwritten_df.emit() @@ -225,12 +233,12 @@ def delete_row(self): self.logger.log_message( f"Deleted row {row} from {self.model.table_type} table." f" Data: {row_info}", - color="orange" + color="orange", ) self.model.something_changed.emit(True) def add_row(self): - """Add a row to the datatable""" + """Add a row to the datatable.""" row_count = self.model.rowCount() - 1 if self.model.insertRows(row_count, 1): new_row_index = self.model.index(row_count, 0) @@ -238,8 +246,7 @@ def add_row(self): selection_model = self.view.table_view.selectionModel() if selection_model: selection_model.select( - new_row_index, - selection_model.SelectionFlag.ClearAndSelect + new_row_index, selection_model.SelectionFlag.ClearAndSelect ) self.view.table_view.scrollTo(new_row_index) self.view.table_view.setCurrentIndex(new_row_index) @@ -259,7 +266,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: @@ -267,27 +274,26 @@ def delete_column(self): del self.completers[column_name] self.model.delete_column(column) self.logger.log_message( - f"Deleted column '{column_name}' from {self.model.table_type} table.", - color="orange" + f"Deleted column '{column_name}' from " + f"{self.model.table_type} table.", + color="orange", ) deleted_columns.add(column) self.model.update_invalid_cells(deleted_columns, mode="columns") self.model.something_changed.emit(True) def add_column(self, column_name: str = None): - """Add a column to the datatable""" + """Add a column to the datatable.""" if not column_name: column_name, ok = QInputDialog.getText( - self.view, - "Add Column", - "Enter the name of the new column:" + self.view, "Add Column", "Enter the name of the new column:" ) if not ok: return self.model.insertColumn(column_name) def clear_cells(self): - """Clear all selected cells""" + """Clear all selected cells.""" selected = get_selected(self.view.table_view, mode=INDEX) self.model.clear_cells(selected) @@ -319,15 +325,14 @@ def paste_from_clipboard(self): self.check_petab_lint() except Exception as e: self.logger.log_message( - f"PEtab linter failed after copying: {str(e)}", - color="red" + f"PEtab linter failed after copying: {str(e)}", color="red" ) def check_petab_lint( self, row_data: pd.DataFrame = None, row_name: str = None, - col_name: str = None + col_name: str = None, ): """Check a single row of the model with petablint.""" raise NotImplementedError( @@ -343,26 +348,61 @@ def find_text( # Search in the main DataFrame if regex: pattern = re.compile(text, 0 if case_sensitive else re.IGNORECASE) - mask = df.map(lambda cell: bool(pattern.fullmatch(str(cell))) if whole_cell else bool(pattern.search(str(cell)))) + mask = df.map( + lambda cell: bool(pattern.fullmatch(str(cell))) + if whole_cell + else bool(pattern.search(str(cell))) + ) else: text = text.lower() if not case_sensitive else text - mask = df.map(lambda cell: text == str(cell).lower() if whole_cell else text in str(cell).lower()) if not case_sensitive else \ - df.map(lambda cell: text == str(cell) if whole_cell else text in str(cell)) + mask = ( + df.map( + lambda cell: text == str(cell).lower() + if whole_cell + else text in str(cell).lower() + ) + if not case_sensitive + else df.map( + lambda cell: text == str(cell) + if whole_cell + else text in str(cell) + ) + ) # Find matches - match_indices = list(zip(*mask.to_numpy().nonzero())) - table_matches = [(row, col + self.model.column_offset) for row, col in match_indices] + match_indices = list(zip(*mask.to_numpy().nonzero(), strict=False)) + table_matches = [ + (row, col + self.model.column_offset) for row, col in match_indices + ] # Search in the index if it's named index_matches = [] if isinstance(df.index, pd.Index) and df.index.name: if regex: - index_mask = df.index.to_series().map(lambda idx: bool(pattern.fullmatch(str(idx))) if whole_cell else bool(pattern.search(str(idx)))) + index_mask = df.index.to_series().map( + lambda idx: bool(pattern.fullmatch(str(idx))) + if whole_cell + else bool(pattern.search(str(idx))) + ) else: - 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 \ - df.index.to_series().map(lambda idx: text == str(idx) if whole_cell else text in str(idx)) + 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 df.index.to_series().map( + lambda idx: text == str(idx) + if whole_cell + else text in str(idx) + ) + ) - index_matches = [(df.index.get_loc(idx), 0) for idx in index_mask[index_mask].index] + index_matches = [ + (df.index.get_loc(idx), 0) + for idx in index_mask[index_mask].index + ] all_matches = index_matches + table_matches @@ -374,19 +414,23 @@ def highlight_text(self, matches): """Color the text of all matched cells in yellow.""" self.model.highlighted_cells = set(matches) top_left = self.model.index(0, 0) - bottom_right = self.model.index(self.model.rowCount() - 1, - self.model.columnCount() - 1) - self.model.dataChanged.emit(top_left, bottom_right, - [Qt.ForegroundRole]) + bottom_right = self.model.index( + self.model.rowCount() - 1, self.model.columnCount() - 1 + ) + self.model.dataChanged.emit( + top_left, bottom_right, [Qt.ForegroundRole] + ) def cleanse_highlighted_cells(self): """Cleanses the highlighted cells.""" self.model.highlighted_cells = set() top_left = self.model.index(0, 0) - bottom_right = self.model.index(self.model.rowCount() - 1, - self.model.columnCount() - 1) - self.model.dataChanged.emit(top_left, bottom_right, - [Qt.ForegroundRole]) + bottom_right = self.model.index( + self.model.rowCount() - 1, self.model.columnCount() - 1 + ) + self.model.dataChanged.emit( + top_left, bottom_right, [Qt.ForegroundRole] + ) def focus_match(self, match, with_focus: bool = False): """Focus and select the given match in the table.""" @@ -403,12 +447,14 @@ def focus_match(self, match, with_focus: bool = False): self.view.table_view.setCurrentIndex(proxy_index) self.view.table_view.scrollTo( - proxy_index, QAbstractItemView.EnsureVisible + proxy_index, QAbstractItemView.EnsureVisible ) if with_focus: self.view.table_view.setFocus() - def replace_text(self, row, col, replace_text, search_text, case_sensitive, regex): + def replace_text( + self, row, col, replace_text, search_text, case_sensitive, regex + ): """Replace the text in the given cell and update highlights.""" index = self.model.index(row, col) original_text = self.model.data(index, Qt.DisplayRole) @@ -417,12 +463,19 @@ def replace_text(self, row, col, replace_text, search_text, case_sensitive, rege return if regex: - pattern = re.compile(search_text, 0 if case_sensitive else re.IGNORECASE) + pattern = re.compile( + search_text, 0 if case_sensitive else re.IGNORECASE + ) new_text = pattern.sub(replace_text, original_text) else: if not case_sensitive: search_text = re.escape(search_text.lower()) - new_text = re.sub(search_text, replace_text, original_text, flags=re.IGNORECASE) + new_text = re.sub( + search_text, + replace_text, + original_text, + flags=re.IGNORECASE, + ) else: new_text = original_text.replace(search_text, replace_text) @@ -440,28 +493,37 @@ def replace_all( df = self.model._data_frame if regex: - pattern = re.compile(search_text, - 0 if case_sensitive else re.IGNORECASE) - df.replace(to_replace=pattern, value=replace_text, regex=True, - inplace=True) + pattern = re.compile( + search_text, 0 if case_sensitive else re.IGNORECASE + ) + df.replace( + to_replace=pattern, + value=replace_text, + regex=True, + inplace=True, + ) else: if not case_sensitive: df.replace( to_replace=re.escape(search_text), value=replace_text, regex=True, - inplace=True + inplace=True, ) else: - df.replace(to_replace=search_text, value=replace_text, - inplace=True) + df.replace( + to_replace=search_text, value=replace_text, inplace=True + ) # Replace in the index as well if isinstance(df.index, pd.Index) and df.index.name: index_map = { - idx: pattern.sub(replace_text, str(idx)) if regex else str( - idx).replace(search_text, replace_text) - for idx in df.index if search_text in str(idx)} + idx: pattern.sub(replace_text, str(idx)) + if regex + else str(idx).replace(search_text, replace_text) + for idx in df.index + if search_text in str(idx) + } if index_map: df.rename(index=index_map, inplace=True) @@ -490,7 +552,12 @@ class MeasurementController(TableController): """Controller of the Measurement table.""" @linter_wrapper - def check_petab_lint(self, row_data: pd.DataFrame = None, row_name: str = None, col_name: str = None): + 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() @@ -500,7 +567,9 @@ def check_petab_lint(self, row_data: pd.DataFrame = None, row_name: str = None, observable_df=observable_df, ) - def rename_value(self, old_id: str, new_id: str, column_names: str | list[str]): + def rename_value( + self, old_id: str, new_id: str, column_names: str | list[str] + ): """Rename the observables in the measurement_df. Triggered by changes in the original observable_df id. @@ -521,7 +590,8 @@ def rename_value(self, old_id: str, new_id: str, column_names: str | list[str]): self.model._data_frame.loc[mask] = new_id changed_rows = mask.any(axis=1) first_row, last_row = ( - changed_rows.idxmax(), changed_rows[::-1].idxmax() + changed_rows.idxmax(), + changed_rows[::-1].idxmax(), ) top_left = self.model.index(first_row, 1) bottom_right = self.model.index( @@ -535,9 +605,7 @@ def rename_value(self, old_id: str, new_id: str, column_names: str | list[str]): self.model.something_changed.emit(True) def copy_noise_parameters( - self, - observable_id: str, - condition_id: str | None = None + self, observable_id: str, condition_id: str | None = None ) -> str: """Copies noise parameter from measurements already in the table. @@ -559,17 +627,18 @@ def copy_noise_parameters( """ measurement_df = self.model.measurement._data_frame matching_rows = measurement_df[ - measurement_df["observableId"] == observable_id] + measurement_df["observableId"] == observable_id + ] if matching_rows.empty: return "" if not condition_id: return matching_rows["noiseParameters"].iloc[0] preferred_row = matching_rows[ - matching_rows["simulationConditionId"] == condition_id] + matching_rows["simulationConditionId"] == condition_id + ] if not preferred_row.empty: return preferred_row["noiseParameters"].iloc[0] - else: - return matching_rows["noiseParameters"].iloc[0] + return matching_rows["noiseParameters"].iloc[0] def upload_data_matrix(self): """Upload a data matrix to the measurement table. @@ -583,7 +652,7 @@ def upload_data_matrix(self): self.view, "Open Data Matrix", "", - "CSV Files (*.csv);;TSV Files (*.tsv)" + "CSV Files (*.csv);;TSV Files (*.tsv)", ) if file_name: self.process_data_matrix_file(file_name, "append") @@ -614,20 +683,19 @@ def process_data_matrix_file(self, file_name, mode, separator=None): except Exception as e: self.logger.log_message( f"An error occurred while uploading the data matrix: {str(e)}", - color="red" + color="red", ) def load_data_matrix(self, file_name, separator=None): """Loads in the data matrix. Checks for the 'time' column.""" - data_matrix = pd.read_csv( - file_name, delimiter=separator - ) + data_matrix = pd.read_csv(file_name, delimiter=separator) if not any( - col in data_matrix.columns for col in ["Time", "time", "t"]): + col in data_matrix.columns for col in ["Time", "time", "t"] + ): self.logger.log_message( "Invalid File, the file must contain a 'Time' column. " "Please ensure that the file contains a 'Time'", - color="red" + color="red", ) return None @@ -654,22 +722,24 @@ def populate_tables_from_data_matrix( data_matrix[["time", observable_id]], observable_id, condition_id, - preeq_id + preeq_id, ) def add_measurement_rows( - self, data_matrix, + self, + data_matrix, observable_id, condition_id: str = "", - preeq_id: str = "" + preeq_id: str = "", ): """Adds multiple rows to the measurement table.""" # check number of rows and signal row insertion rows = data_matrix.shape[0] # get current number of rows current_rows = self.model.get_df().shape[0] - self.model.insertRows(position=None, - rows=rows) # Fills the table with empty rows + self.model.insertRows( + position=None, rows=rows + ) # Fills the table with empty rows top_left = self.model.createIndex(current_rows, 0) for i_row, (_, row) in enumerate(data_matrix.iterrows()): self.model.fill_row( @@ -679,15 +749,15 @@ def add_measurement_rows( "time": row["time"], "measurement": row[observable_id], "simulationConditionId": condition_id, - "preequilibrationConditionId": preeq_id - } + "preequilibrationConditionId": preeq_id, + }, ) bottom, right = (x - 1 for x in self.model.get_df().shape) bottom_right = self.model.createIndex(bottom, right) self.model.dataChanged.emit(top_left, bottom_right) self.logger.log_message( f"Added {rows} measurements to the measurement table.", - color="green" + color="green", ) def setup_completers(self): @@ -700,50 +770,52 @@ def setup_completers(self): self.mother_controller.model.observable, "observableId" ) table_view.setItemDelegateForColumn( - observableId_index, - self.completers["observableId"] + observableId_index, self.completers["observableId"] ) # preequilibrationConditionId preequilibrationConditionId_index = self.model.return_column_index( "preequilibrationConditionId" ) if preequilibrationConditionId_index > -1: - self.completers[ - "preequilibrationConditionId"] = ColumnSuggestionDelegate( - self.mother_controller.model.condition, "conditionId" + self.completers["preequilibrationConditionId"] = ( + ColumnSuggestionDelegate( + self.mother_controller.model.condition, "conditionId" + ) ) table_view.setItemDelegateForColumn( preequilibrationConditionId_index, - self.completers["preequilibrationConditionId"] + self.completers["preequilibrationConditionId"], ) # simulationConditionId simulationConditionId_index = self.model.return_column_index( "simulationConditionId" ) if simulationConditionId_index > -1: - self.completers[ - "simulationConditionId"] = ColumnSuggestionDelegate( - self.mother_controller.model.condition, "conditionId" + self.completers["simulationConditionId"] = ( + ColumnSuggestionDelegate( + self.mother_controller.model.condition, "conditionId" + ) ) table_view.setItemDelegateForColumn( simulationConditionId_index, - self.completers["simulationConditionId"] + self.completers["simulationConditionId"], ) # noiseParameters noiseParameters_index = self.model.return_column_index( - "noiseParameters") + "noiseParameters" + ) if noiseParameters_index > -1: self.completers["noiseParameters"] = SingleSuggestionDelegate( self.model, "observableId", afix="sd_" ) table_view.setItemDelegateForColumn( - noiseParameters_index, - self.completers["noiseParameters"] + noiseParameters_index, self.completers["noiseParameters"] ) class ConditionController(TableController): """Controller of the Condition table.""" + condition_2be_renamed = Signal(str, str) # Signal to mother controller def update_handler_model(self): @@ -755,15 +827,16 @@ def setup_connections_specific(self): Only handles connections from within the table controllers. """ - self.model.relevant_id_changed.connect( - self.maybe_rename_condition - ) - self.overwritten_df.connect( - self.update_handler_model - ) + self.model.relevant_id_changed.connect(self.maybe_rename_condition) + self.overwritten_df.connect(self.update_handler_model) @linter_wrapper - def check_petab_lint(self, row_data: pd.DataFrame = None, row_name: str = None, col_name: str = None): + 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() @@ -781,24 +854,25 @@ def maybe_rename_condition(self, new_id, old_id): Opens a dialog to ask the user if they want to rename the conditions. If so, emits a signal to rename the conditions in the measurement_df. """ - if old_id not in self.mother_controller.measurement_controller.model.get_df()["simulationConditionId"].values: + df = self.mother_controller.measurement_controller.model.get_df() + if old_id not in df["simulationConditionId"].values: return reply = QMessageBox.question( - self.view, 'Rename Condition', + self.view, + "Rename Condition", f'Do you want to rename condition "{old_id}" to "{new_id}" ' - f'in all measurements?', + f"in all measurements?", QMessageBox.Yes | QMessageBox.No, - QMessageBox.No + QMessageBox.No, ) if reply == QMessageBox.Yes: self.logger.log_message( f"Renaming condition '{old_id}' to '{new_id}' in all " f"measurements", - color="green" + color="green", ) self.condition_2be_renamed.emit(old_id, new_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 or not condition_id: @@ -807,7 +881,7 @@ def maybe_add_condition(self, condition_id, old_id=None): self.model.insertRows(position=None, rows=1) self.model.fill_row( self.model.get_df().shape[0] - 1, - data={"conditionId": condition_id} + data={"conditionId": condition_id}, ) self.model.cell_needs_validation.emit( self.model.get_df().shape[0] - 1, 0 @@ -815,7 +889,7 @@ def maybe_add_condition(self, condition_id, old_id=None): self.logger.log_message( f"Automatically added condition '{condition_id}' to the condition " f"table.", - color="green" + color="green", ) def setup_completers(self): @@ -825,10 +899,10 @@ def setup_completers(self): conditionName_index = self.model.return_column_index("conditionName") if conditionName_index > -1: self.completers["conditionName"] = SingleSuggestionDelegate( - self.model, "conditionId") + self.model, "conditionId" + ) table_view.setItemDelegateForColumn( - conditionName_index, - self.completers["conditionName"] + conditionName_index, self.completers["conditionName"] ) for column in self.model.get_df().columns: if column in ["conditionId", "conditionName"]: @@ -839,13 +913,13 @@ def setup_completers(self): self.model, column, QCompleter.PopupCompletion ) table_view.setItemDelegateForColumn( - column_index, - self.completers[column] + column_index, self.completers[column] ) class ObservableController(TableController): """Controller of the Observable table.""" + observable_2be_renamed = Signal(str, str) # Signal to mother controller def update_handler_model(self): @@ -859,10 +933,10 @@ def setup_completers(self): observableName_index = self.model.return_column_index("observableName") if observableName_index > -1: self.completers["observableName"] = SingleSuggestionDelegate( - self.model, "observableId") + self.model, "observableId" + ) table_view.setItemDelegateForColumn( - observableName_index, - self.completers["observableName"] + observableName_index, self.completers["observableName"] ) # observableTransformation observableTransformation_index = self.model.return_column_index( @@ -874,7 +948,7 @@ def setup_completers(self): ) table_view.setItemDelegateForColumn( observableTransformation_index, - self.completers["observableTransformation"] + self.completers["observableTransformation"], ) # noiseFormula noiseFormula_index = self.model.return_column_index("noiseFormula") @@ -883,8 +957,7 @@ def setup_completers(self): self.model, "observableId", afix="noiseParameter1_" ) table_view.setItemDelegateForColumn( - noiseFormula_index, - self.completers["noiseFormula"] + noiseFormula_index, self.completers["noiseFormula"] ) # noiseDistribution noiseDistribution_index = self.model.return_column_index( @@ -895,8 +968,7 @@ def setup_completers(self): ["normal", "laplace"] ) table_view.setItemDelegateForColumn( - noiseDistribution_index, - self.completers["noiseDistribution"] + noiseDistribution_index, self.completers["noiseDistribution"] ) def setup_connections_specific(self): @@ -904,15 +976,16 @@ def setup_connections_specific(self): Only handles connections from within the table controllers. """ - self.model.relevant_id_changed.connect( - self.maybe_rename_observable - ) - self.overwritten_df.connect( - self.update_handler_model - ) + self.model.relevant_id_changed.connect(self.maybe_rename_observable) + self.overwritten_df.connect(self.update_handler_model) @linter_wrapper - def check_petab_lint(self, row_data: pd.DataFrame = None, row_name: str = None, col_name: str = None): + 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() @@ -924,20 +997,22 @@ def maybe_rename_observable(self, new_id, old_id): Opens a dialog to ask the user if they want to rename the observables. If so, emits a signal to rename the observables in the measurement_df. """ - if old_id not in self.mother_controller.measurement_controller.model.get_df()["observableId"].values: + df = self.mother_controller.measurement_controller.model.get_df() + if old_id not in df["observableId"].values: return reply = QMessageBox.question( - self.view, 'Rename Observable', + self.view, + "Rename Observable", f'Do you want to rename observable "{old_id}" to "{new_id}" ' - f'in all measurements?', + f"in all measurements?", QMessageBox.Yes | QMessageBox.No, - QMessageBox.No + QMessageBox.No, ) if reply == QMessageBox.Yes: self.logger.log_message( f"Renaming observable '{old_id}' to '{new_id}' in all " f"measurements", - color="green" + color="green", ) # TODO: connect this signal with the measurement function self.observable_2be_renamed.emit(old_id, new_id) @@ -953,7 +1028,7 @@ def maybe_add_observable(self, observable_id, old_id=None): self.model.insertRows(position=None, rows=1) self.model.fill_row( self.model.get_df().shape[0] - 1, - data={"observableId": observable_id} + data={"observableId": observable_id}, ) self.model.cell_needs_validation.emit( self.model.get_df().shape[0] - 1, 0 @@ -961,7 +1036,7 @@ def maybe_add_observable(self, observable_id, old_id=None): self.logger.log_message( f"Automatically added observable '{observable_id}' to the " f"observable table.", - color="green" + color="green", ) @@ -970,16 +1045,16 @@ class ParameterController(TableController): def setup_connections_specific(self): """Connect signals specific to the parameter controller.""" - self.overwritten_df.connect( - self.update_handler_model - ) + self.overwritten_df.connect(self.update_handler_model) def update_handler_model(self): """Update the handler model.""" self.model.default_handler.model = self.model._data_frame def update_handler_sbml(self): - self.model.default_handler._sbml_model = self.mother_controller.model.sbml + self.model.default_handler._sbml_model = ( + self.mother_controller.model.sbml + ) def setup_completers(self): """Set completers for the parameter table.""" @@ -988,10 +1063,10 @@ def setup_completers(self): parameterName_index = self.model.return_column_index("parameterName") if parameterName_index > -1: self.completers["parameterName"] = SingleSuggestionDelegate( - self.model, "parameterId") + self.model, "parameterId" + ) table_view.setItemDelegateForColumn( - parameterName_index, - self.completers["parameterName"] + parameterName_index, self.completers["parameterName"] ) # parameterScale parameterScale_index = self.model.return_column_index("parameterScale") @@ -1000,8 +1075,7 @@ def setup_completers(self): ["lin", "log", "log10"] ) table_view.setItemDelegateForColumn( - parameterScale_index, - self.completers["parameterScale"] + parameterScale_index, self.completers["parameterScale"] ) # lowerBound lowerBound_index = self.model.return_column_index("lowerBound") @@ -1010,8 +1084,7 @@ def setup_completers(self): self.model, "lowerBound", QCompleter.PopupCompletion ) table_view.setItemDelegateForColumn( - lowerBound_index, - self.completers["lowerBound"] + lowerBound_index, self.completers["lowerBound"] ) # upperBound upperBound_index = self.model.return_column_index("upperBound") @@ -1020,34 +1093,33 @@ def setup_completers(self): self.model, "upperBound", QCompleter.PopupCompletion ) table_view.setItemDelegateForColumn( - upperBound_index, - self.completers["upperBound"] + upperBound_index, self.completers["upperBound"] ) # estimate estimate_index = self.model.return_column_index("estimate") if estimate_index > -1: - self.completers["estimate"] = ComboBoxDelegate( - ["1", "0"] - ) + self.completers["estimate"] = ComboBoxDelegate(["1", "0"]) table_view.setItemDelegateForColumn( - estimate_index, - self.completers["estimate"] + estimate_index, self.completers["estimate"] ) # parameterId: retrieved from the sbml model parameterId_index = self.model.return_column_index("parameterId") sbml_model = self.mother_controller.model.sbml if parameterId_index > -1: self.completers["parameterId"] = ParameterIdSuggestionDelegate( - par_model=self.model, - sbml_model=sbml_model + par_model=self.model, sbml_model=sbml_model ) table_view.setItemDelegateForColumn( - parameterId_index, - self.completers["parameterId"] + parameterId_index, self.completers["parameterId"] ) @linter_wrapper(additional_error_check=True) - def check_petab_lint(self, row_data: pd.DataFrame = None, row_name: str = None, col_name: str = None): + 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 a608604..3b7ef3c 100644 --- a/src/petab_gui/controllers/utils.py +++ b/src/petab_gui/controllers/utils.py @@ -1,54 +1,64 @@ -from PySide6.QtWidgets import QMessageBox, QMenu -from PySide6.QtCore import QObject, Signal -from PySide6.QtGui import QAction +import functools +import html +import re from collections import Counter from pathlib import Path -import functools + import pandas as pd -import re -import html +from PySide6.QtCore import QObject, Signal +from PySide6.QtGui import QAction +from PySide6.QtWidgets import QMenu, QMessageBox -from ..settings_manager import settings_manager from ..C import COMMON_ERRORS +from ..settings_manager import settings_manager 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): + 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 - ) + func(self, row_data, row_name, col_name, *args, **kwargs) return True except Exception as e: err_msg = filtered_error(e) err_msg = html.escape(err_msg) - if additional_error_check: - if "Missing parameter(s)" in err_msg: - match = re.search(r"\{(.+?)\}", err_msg) + if (additional_error_check and "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 + 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 + r"\{.*?}", + "{" + ", ".join(sorted(remain)) + "}", + err_msg, ) + msg = "PEtab linter failed" if row_name is not None and col_name is not None: - msg = f"PEtab linter failed at ({row_name}, {col_name}): {err_msg}" + msg = f"{msg} at ({row_name}, {col_name}): {err_msg}" else: - msg = f"PEtab linter failed: {err_msg}" + msg = f"{msg}: {err_msg}" self.logger.log_message(msg, color="red") return False + return wrapper + if callable(_func): # used without parentheses return decorator(_func) return decorator @@ -61,12 +71,14 @@ def filtered_error(error_message: BaseException) -> str: ) 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)) @@ -74,7 +86,9 @@ def prompt_overwrite_or_append(controller): """Prompt user to choose between overwriting or appending the file.""" msg_box = QMessageBox(controller.view) msg_box.setWindowTitle("Open File Options") - msg_box.setText("Do you want to overwrite the current data or append to it?") + msg_box.setText( + "Do you want to overwrite the current data or append to it?" + ) overwrite_button = msg_box.addButton("Overwrite", QMessageBox.AcceptRole) append_button = msg_box.addButton("Append", QMessageBox.AcceptRole) cancel_button = msg_box.addButton("Cancel", QMessageBox.RejectRole) @@ -83,14 +97,16 @@ def prompt_overwrite_or_append(controller): if msg_box.clickedButton() == cancel_button: return None - elif msg_box.clickedButton() == overwrite_button: + if msg_box.clickedButton() == overwrite_button: return "overwrite" - elif msg_box.clickedButton() == append_button: + if msg_box.clickedButton() == append_button: return "append" + return None class RecentFilesManager(QObject): """Manage a list of recent files.""" + open_file = Signal(str) # Signal to open a file def __init__(self, max_files=10): @@ -105,7 +121,7 @@ def add_file(self, file_path): if file_path in self.recent_files: self.recent_files.remove(file_path) self.recent_files.insert(0, file_path) - self.recent_files = self.recent_files[:self.max_files] + self.recent_files = self.recent_files[: self.max_files] self.save_recent_files() self.update_tool_bar_menu() @@ -132,10 +148,14 @@ def short_name(path): short_paths = [short_name(f) for f in self.recent_files] counts = Counter(short_paths) - for full_path, short in zip(self.recent_files, short_paths): + for full_path, short in zip( + self.recent_files, short_paths, strict=False + ): display = full_path if counts[short] > 1 else short action = QAction(display, self.tool_bar_menu) - action.triggered.connect(lambda _, p=full_path: self.open_file.emit(p)) + action.triggered.connect( + lambda _, p=full_path: self.open_file.emit(p) + ) self.tool_bar_menu.addAction(action) self.tool_bar_menu.addSeparator() clear_action = QAction("Clear Recent Files", self.tool_bar_menu) diff --git a/src/petab_gui/models/__init__.py b/src/petab_gui/models/__init__.py index 57ca176..be23554 100644 --- a/src/petab_gui/models/__init__.py +++ b/src/petab_gui/models/__init__.py @@ -5,7 +5,12 @@ This package contains the models for the PEtab edit GUI. """ -from .pandas_table_model import MeasurementModel, ObservableModel, \ - ParameterModel, ConditionModel, PandasTableModel +from .pandas_table_model import ( + ConditionModel, + MeasurementModel, + ObservableModel, + PandasTableModel, + ParameterModel, +) +from .petab_model import PEtabModel from .sbml_model import SbmlViewerModel -from .petab_model import PEtabModel \ No newline at end of file diff --git a/src/petab_gui/models/pandas_table_model.py b/src/petab_gui/models/pandas_table_model.py index dbcd7fe..4ff68e7 100644 --- a/src/petab_gui/models/pandas_table_model.py +++ b/src/petab_gui/models/pandas_table_model.py @@ -1,29 +1,71 @@ -import pandas as pd -from PySide6.QtCore import (Qt, QAbstractTableModel, QModelIndex, Signal, - QSortFilterProxyModel, QMimeData) -from PySide6.QtGui import QColor, QBrush, QPalette +from typing import Any + +from PySide6.QtCore import ( + QAbstractTableModel, + QMimeData, + QModelIndex, + QSortFilterProxyModel, + Qt, + Signal, +) +from PySide6.QtGui import QBrush, QColor, QPalette from PySide6.QtWidgets import QApplication + from ..C import COLUMNS -from ..utils import validate_value, create_empty_dataframe, is_invalid, \ - get_selected +from ..commands import ( + ModifyColumnCommand, + ModifyDataFrameCommand, + ModifyRowCommand, + RenameIndexCommand, +) from ..controllers.default_handler import DefaultHandlerModel from ..settings_manager import settings_manager -from ..commands import (ModifyColumnCommand, ModifyRowCommand, - ModifyDataFrameCommand, RenameIndexCommand) +from ..utils import ( + create_empty_dataframe, + get_selected, + is_invalid, + validate_value, +) class PandasTableModel(QAbstractTableModel): - """Basic table model for a pandas DataFrame.""" + """Basic table model for a pandas DataFrame. + + This class provides a Qt model interface for pandas DataFrames, + allowing them to be displayed and edited in Qt table views. It handles + data access, modification, and various table operations like + adding/removing rows and columns. + """ + # Signals relevant_id_changed = Signal(str, str, str) # new_id, old_id, type new_log_message = Signal(str, str) # message, color cell_needs_validation = Signal(int, int) # row, column something_changed = Signal(bool) inserted_row = Signal(QModelIndex) - fill_defaults = Signal(QModelIndex) - def __init__(self, data_frame, allowed_columns, table_type, - undo_stack = None, parent=None): + def __init__( + self, + data_frame, + allowed_columns, + table_type, + undo_stack=None, + parent=None, + ): + """Initialize the pandas table model. + + Args: + data_frame: + The pandas DataFrame to be displayed in the table + allowed_columns: + Dictionary of allowed columns with their properties + table_type: + The type of table (e.g., 'observable', 'parameter', 'condition') + undo_stack: + Optional QUndoStack for undo/redo functionality + parent: + The parent QObject + """ super().__init__(parent) self._allowed_columns = allowed_columns self.table_type = table_type @@ -43,14 +85,51 @@ def __init__(self, data_frame, allowed_columns, table_type, self.default_handler = DefaultHandlerModel(self, self.config) self.undo_stack = undo_stack - def rowCount(self, parent=QModelIndex()): + def rowCount(self, parent=None): + """Return the number of rows in the model. + + Includes an extra row at the end for adding new entries. + + Args: + parent: The parent model index (unused in table models) + + Returns: + int: The number of rows in the model + """ + if parent is None: + parent = QModelIndex() return self._data_frame.shape[0] + 1 # empty row at the end - def columnCount(self, parent=QModelIndex()): + def columnCount(self, parent=None): + """Return the number of columns in the model. + + Includes any column offset (e.g., for index column). + + Args: + parent: The parent model index (unused in table models) + + Returns: + int: The number of columns in the model + """ + if parent is None: + parent = QModelIndex() return self._data_frame.shape[1] + self.column_offset def data(self, index, role=Qt.DisplayRole): - """Return the data at the given index and role for the View.""" + """Return the data at the given index and role for the View. + + Handles different roles: + - DisplayRole/EditRole: Returns the cell value as a string + - BackgroundRole: Returns the background color for the cell + - ForegroundRole: Returns the text color for the cell + + Args: + index: The model index to get data for + role: The data role (DisplayRole, EditRole, BackgroundRole, etc.) + + Returns: + The requested data for the given index and role, or None + """ if not index.isValid(): return None row, column = index.row(), index.column() @@ -66,9 +145,9 @@ def data(self, index, role=Qt.DisplayRole): if is_invalid(value): return "" return str(value) - elif role == Qt.BackgroundRole: + if role == Qt.BackgroundRole: return self.determine_background_color(row, column) - elif role == Qt.ForegroundRole: + if role == Qt.ForegroundRole: # Return yellow text if this cell is a match if (row, column) in self.highlighted_cells: return QApplication.palette().color(QPalette.HighlightedText) @@ -76,28 +155,50 @@ def data(self, index, role=Qt.DisplayRole): return None def flags(self, index): - """Return whether cells are editable and selectable""" + """Return the item flags for the given index. + + Determines whether cells are editable, selectable, and enabled. + + Args: + index: The model index to get flags for + + Returns: + Qt.ItemFlags: The flags for the given index + """ if not index.isValid(): return Qt.ItemIsEnabled return Qt.ItemIsEditable | Qt.ItemIsEnabled | Qt.ItemIsSelectable def headerData(self, section, orientation, role=Qt.DisplayRole): - """Return the header data for the given section, orientation""" + """Return the header data for the given section and orientation. + + Provides column and row headers for the table view. + + Args: + section: + The row or column number + orientation: + Qt.Horizontal for column headers, Qt.Vertical for row headers + role: + The data role (usually DisplayRole) + + Returns: + The header text for the given section and orientation, or None. + """ if role != Qt.DisplayRole: return None - # role == Qt.DisplayRole if orientation == Qt.Horizontal: if section == 0: return self._data_frame.index.name - else: - return self._data_frame.columns[section - 1] + return self._data_frame.columns[section - 1] if orientation == Qt.Vertical: return str(section) return None - def insertRows(self, position, rows, parent=QModelIndex()) -> bool: + def insertRows(self, position, rows, parent=None) -> bool: """ Insert new rows at the end of the DataFrame in-place. + This function always adds rows at the end. Parameters: @@ -119,24 +220,34 @@ def insertRows(self, position, rows, parent=QModelIndex()) -> bool: return True 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. + """Add a new column to the table. + + Always adds the column at the right (end) of the table. Checks if the + column already exists or if it's not in the allowed columns list. + + Args: + column_name: The name of the column to add + + Returns: + bool: True if the column was added successfully, False otherwise + + Notes: + If the column is not in the allowed columns list, a warning message + is emitted but the column is still added. """ if column_name in self._data_frame.columns: self.new_log_message.emit( - f"Column '{column_name}' already exists", - "red" + f"Column '{column_name}' already exists", "red" ) return False if not ( - column_name in self._allowed_columns or - self.table_type == "condition" + 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}' will be ignored for the petab " f"problem but may still be used to store relevant information", - "orange" + "orange", ) if self.undo_stack: @@ -151,6 +262,21 @@ def insertColumn(self, column_name: str): def setData( self, index, value, role=Qt.EditRole, check_multi: bool = True ): + """Set the data for a specific model index. + + Updates the value at the given index in the model. If multiple rows are + selected and check_multi is True, applies the change to all selected + cells in the same column. + + Args: + index: The model index to set data for + value: The new value to set + role: The data role (usually EditRole) + check_multi: Whether to check for multi-row selection + + Returns: + bool: True if the data was set successfully, False otherwise + """ if not (index.isValid() and role == Qt.EditRole): return False @@ -169,7 +295,7 @@ def setData( self.undo_stack.endMacro() return success # multiple rows but only one column is selected - all_set = list() + all_set = [] self.undo_stack.beginMacro("Set data") for index in selected: all_set.append(self._set_data_single(index, value)) @@ -177,7 +303,19 @@ def setData( return all(all_set) def _set_data_single(self, index, value): - """Set the data of a single cell.""" + """Set the data of a single cell. + + Internal method used by setData to update a single cell's value. + Handles special cases like new row creation, named index columns, + and type validation. + + Args: + index: The model index to set data for + value: The new value to set + + Returns: + bool: True if the data was set successfully, False otherwise + """ row, column = index.row(), index.column() fill_with_defaults = False @@ -201,7 +339,9 @@ def _set_data_single(self, index, value): # Handle invalid value if is_invalid(value): - self._push_change_and_notify(row, column, column_name, old_value, None) + self._push_change_and_notify( + row, column, column_name, old_value, None + ) return True # Type validation @@ -212,7 +352,8 @@ def _set_data_single(self, index, value): if error: self.new_log_message.emit( f"Column '{column_name}' expects a value of type " - f"{expected_type}, but got '{value}'", "red" + f"{expected_type}, but got '{value}'", + "red", ) return False value = validated @@ -225,37 +366,68 @@ def _set_data_single(self, index, value): if fill_with_defaults: self.get_default_values(index, {column_name: value}) self.relevant_id_changed.emit(value, old_value, "observable") - self._push_change_and_notify(row, column, column_name, old_value, value) + self._push_change_and_notify( + row, column, column_name, old_value, value + ) return True - if column_name in ["conditionId", "simulationConditionId", "preequilibrationConditionId"]: + if column_name in [ + "conditionId", + "simulationConditionId", + "preequilibrationConditionId", + ]: if fill_with_defaults: self.get_default_values(index, {column_name: value}) self.relevant_id_changed.emit(value, old_value, "condition") - self._push_change_and_notify(row, column, column_name, old_value, value) + self._push_change_and_notify( + row, column, column_name, old_value, value + ) return True # Default value setting if fill_with_defaults: self.get_default_values(index, {column_name: value}) - self._push_change_and_notify(row, column, column_name, old_value, value) + 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.""" + """Push a dataframe change to the undo stack and emit signals. + + Creates a ModifyDataFrameCommand for the change and adds it to the + undo stack. Also emits signals to notify views and other components + about the change. + + Args: + row: The row index in the dataframe + column: The column index in the view + column_name: The name of the column being changed + old_value: The previous value in the cell + new_value: The new value to set in the cell + """ change = { (self._data_frame.index[row], column_name): (old_value, new_value) } self.undo_stack.push(ModifyDataFrameCommand(self, change)) - self.dataChanged.emit(self.index(row, column), self.index(row, column), - [Qt.DisplayRole]) + self.dataChanged.emit( + self.index(row, column), self.index(row, column), [Qt.DisplayRole] + ) self.cell_needs_validation.emit(row, column) self.something_changed.emit(True) def clear_cells(self, selected): - """Clear the selected cells.""" + """Clear the values in the selected cells. + + Sets all selected cells to None (empty) and groups the changes into a + single undo command for better undo/redo functionality. + + Args: + selected: + A list of QModelIndex objects representing the selected cells + """ self.undo_stack.beginMacro("Clear cells") for index in selected: if index.isValid(): @@ -263,29 +435,56 @@ def clear_cells(self, selected): self.undo_stack.endMacro() def handle_named_index(self, index, value): - """Handle the named index column.""" + """Handle changes to the named index column. + + This is a placeholder method in the base class. Subclasses that use + named indices (like IndexedPandasTableModel) override this method to + implement the actual behavior. + + Args: + index: The model index of the cell being edited + value: The new value for the index + + Returns: + bool: True if the index was successfully changed, False otherwise + """ pass def get_default_values(self, index, changed: dict | None = None): - """Return the default values for the row in a new index. + """Fill a row with default values based on the table's configuration. - Parameters - ---------- - index: QModelIndex, index where the first change occurs + This is a placeholder method in the base class. Subclasses override + this method to implement the actual behavior for filling default + values. + + Args: + index: + The model index where the first change occurs changed: - the changes made to the DataFrame, that have not yet been registered + Dictionary of changes made to the DataFrame not yet registered """ pass def replace_text(self, old_text: str, new_text: str): - """Replace text in the table.""" - # find all occurences of old_text and sae indices + """Replace all occurrences of a text string in the table. + + Searches for and replaces all instances of old_text with new_text in + both the data cells and index values (if using named indices). + Efficiently updates the view by emitting dataChanged signals only + for the affected cells. + + Args: + old_text: The text to search for + new_text: The text to replace it with + """ + # find all occurrences of old_text and save indices mask = self._data_frame.eq(old_text) if mask.any().any(): self._data_frame.replace(old_text, new_text, inplace=True) # Get first and last modified cell for efficient `dataChanged` emit changed_cells = mask.stack()[ - mask.stack()].index.tolist() # Extract (row, col) pairs + mask.stack() + ].index.tolist() # Extract (row, col) pairs if changed_cells: first_row, first_col = changed_cells[0] last_row, last_col = changed_cells[-1] @@ -306,11 +505,26 @@ def replace_text(self, old_text: str, new_text: str): ) def get_df(self): - """Return the DataFrame.""" + """Return the underlying pandas DataFrame. + + Provides direct access to the DataFrame that this model wraps. + + Returns: + pd.DataFrame: The DataFrame containing the table data + """ return self._data_frame def add_invalid_cell(self, row, column): - """Add an invalid cell to the set.""" + """Mark a cell as invalid, giving it a special background color. + + Adds the cell coordinates to the _invalid_cells set and triggers a UI + update to show the cell with an error background color. Performs + several validity checks before adding the cell. + + Args: + row: The row index of the cell + column: The column index of the cell + """ # check that the index is valid if not self.index(row, column).isValid(): return @@ -324,20 +538,40 @@ def add_invalid_cell(self, row, column): self.dataChanged.emit( self.index(row, column), self.index(row, column), - [Qt.BackgroundRole] + [Qt.BackgroundRole], ) def discard_invalid_cell(self, row, column): - """Discard an invalid cell from the set.""" + """Remove a cell from the invalid cells set, restoring its state. + + Removes the cell coordinates from the _invalid_cells set and triggers + a UI update to restore the cell's normal background color. + + Args: + row: The row index of the cell + column: The column index of the cell + """ self._invalid_cells.discard((row, column)) self.dataChanged.emit( self.index(row, column), self.index(row, column), - [Qt.BackgroundRole] + [Qt.BackgroundRole], ) def update_invalid_cells(self, selected, mode: str = "rows"): - """Edits the invalid cells when values are deleted.""" + """Update invalid cell coordinates when rows or columns are deleted. + + When rows or columns are deleted, the coordinates of invalid cells need + to be adjusted to account for the shifted indices. This method + recalculates the coordinates of all invalid cells based on the + deleted indices. + + Args: + selected: + A set or list of indices (row or column) that are being deleted + mode: + Either "rows" or "columns" to indicate what is being deleted + """ if not selected: return old_invalid_cells = self._invalid_cells.copy() @@ -361,15 +595,34 @@ def update_invalid_cells(self, selected, mode: str = "rows"): self._invalid_cells = new_invalid_cells def notify_data_color_change(self, row, column): - """Notify the view to change the color of some cells""" + """Notify the view that a cell's background color needs to be updated. + + Emits a dataChanged signal with the BackgroundRole to trigger the view + to redraw the cell with its current background color. + + Args: + row: The row index of the cell + column: The column index of the cell + """ self.dataChanged.emit( self.index(row, column), self.index(row, column), - [Qt.BackgroundRole] + [Qt.BackgroundRole], ) def get_value_from_column(self, column_name, row): - """Retrieve the value from a specific column and row.""" + """Retrieve the value from a specific column and row in the DataFrame. + + Handles special cases like the "new row" at the end of the table and + accessing values from the index column. + + Args: + column_name: The name of the column to get the value from + row: The row index to get the value from + + Returns: + The value at the specified column and row, or an empty string + """ # if row is a new row return "" if row == self._data_frame.shape[0]: return "" @@ -380,11 +633,32 @@ def get_value_from_column(self, column_name, row): return "" def return_column_index(self, column_name): - """Return the index of a column. Defined in Subclasses""" + """Return the view column index for a given column name. + + This is a placeholder method in the base class. Subclasses override + this method to implement the actual behavior for mapping column + names to view indices. + + Args: + column_name: The name of the column to find the index for + + Returns: + int: The view column index for the given column name, or -1 + """ pass def unique_values(self, column_name): - """Return the unique values in a column.""" + """Return a list of unique values in a specified column. + + Used for providing suggestions in autocomplete fields/dropdown lists. + Handles both regular columns and the index column. + + Args: + column_name: The name of the column to get unique values from + + Returns: + list: A list of unique values from the column, or an empty list + """ if column_name in self._data_frame.columns: return list(self._data_frame[column_name].dropna().unique()) if column_name == self._data_frame.index.name: @@ -392,7 +666,14 @@ def unique_values(self, column_name): return [] def delete_row(self, row): - """Delete a row from the table.""" + """Delete a row from the table. + + Creates a ModifyRowCommand for the deletion and adds it to the stack + to support undo/redo functionality. + + Args: + row: The index of the row to delete + """ if self.undo_stack: self.undo_stack.push(ModifyRowCommand(self, row, False)) else: @@ -401,8 +682,18 @@ def delete_row(self, row): 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] + """Delete a column from the table. + + Maps the view column index to the actual DataFrame column name and + creates a ModifyColumnCommand for the deletion. Adds the command to + the stack to support undo/redo functionality. + + Args: + column_index: The view index of the column to delete + """ + column_name = self._data_frame.columns[ + column_index - self.column_offset + ] if self.undo_stack: self.undo_stack.push(ModifyColumnCommand(self, column_name, False)) else: @@ -411,22 +702,38 @@ def delete_column(self, column_index): command.redo() def clear_table(self): - """Clear the table.""" + """Clear all data from the table.""" self.beginResetModel() self._data_frame.drop(self._data_frame.index, inplace=True) self.endResetModel() def check_selection(self): - """Check if multiple rows but only one column is selected.""" + """Check if multiple rows but only one column is selected in the view. + + Used to determine if a multi-row edit operation should be performed, + when setting data. This allows for efficiently applying the same + change to multiple cells in the same column. + + Returns: + tuple: A tuple containing: + - bool: True if multiple rows but only one column is selected + - list: The list of selected QModelIndex objects, or None + """ if self.view is None: - return False + return False, None selected = get_selected(self.view, mode="index") - cols = set([index.column() for index in selected]) - rows = set([index.row() for index in selected]) + cols = {index.column() for index in selected} + rows = {index.row() for index in selected} return len(rows) > 1 and len(cols) == 1, selected def reset_invalid_cells(self): - """Reset the invalid cells and update their background color.""" + """Clear all invalid cell markings and update their appearance. + + Removes all cells from the _invalid_cells set and triggers UI updates + to restore their normal background colors. + This is useful when reloading data or when validation state needs to be + reset. + """ if not self._invalid_cells: return @@ -440,14 +747,18 @@ def reset_invalid_cells(self): 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. + Formats the selected cells' data as tab-separated text for clipboard + operations. + + Args: + rectangle: + A numpy array representing the selected cells, where True values + indicate selected cells within the minimum bounding rectangle + start_index: + A tuple (row, col) indicating the top-left corner of the selection + + Returns: + QMimeData: A mime data object containing the formatted text data """ copied_data = "" for row in range(rectangle.shape[0]): @@ -455,7 +766,7 @@ def mimeData(self, rectangle, start_index): if rectangle[row, col]: copied_data += self.data( self.index(start_index[0] + row, start_index[1] + col), - Qt.DisplayRole + Qt.DisplayRole, ) else: copied_data += "SKIP" @@ -467,7 +778,17 @@ def mimeData(self, rectangle, start_index): return mime_data def setDataFromText(self, text, start_row, start_column): - """Set the data from text.""" + """Set table data from tab-separated text. + + Used for pasting clipboard content into the table. Parses the text as + tab-separated values and sets the data in the table starting from the + specified position. Groups all changes into a single undo command. + + Args: + text: The tab-separated text to parse and set in the table + start_row: The row index where to start setting data + start_column: The column index where to start setting data + """ lines = text.split("\n") self.undo_stack.beginMacro("Paste from Clipboard") self.maybe_add_rows(start_row, len(lines)) @@ -481,25 +802,41 @@ def setDataFromText(self, text, start_row, start_column): start_row + row_offset, start_column + col_offset ), value, - Qt.EditRole + Qt.EditRole, ) self.undo_stack.endMacro() def maybe_add_rows(self, start_row, n_rows): - """Add rows if needed.""" + """Add rows to the table if there aren't enough. + + Used during paste operations to ensure there are enough rows for the + pasted data. Adds rows if the current number of rows is insufficient. + + Args: + start_row: The row index where data insertion begins + n_rows: The number of rows needed for the data + """ 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] + start_row + n_rows - self._data_frame.shape[0], ) def determine_background_color(self, row, column): - """Determine the background color of a cell. + """Determine the background color for a specific cell. + + Applies different background colors based on cell properties: + - Light green for the "New row" cell (first column of last row) + - System highlight color for cells that match search criteria + - Red for cells marked as invalid + - Alternating light blue and light green for even/odd rows + + Args: + row: The row index of the cell + column: The column index of the cell - 1. If it is the first column and last row, return light green. - 2. If it is an invalid cell, return red - 3. If it is an even row return light blue - 4. Otherwise return light green + Returns: + QColor: The background color to use for the cell """ if (row, column) == (self._data_frame.shape[0], 0): return QColor(144, 238, 144, 150) @@ -511,12 +848,26 @@ def determine_background_color(self, row, column): return QColor(144, 190, 109, 102) return QColor(177, 217, 231, 102) - def allow_column_deletion(self, column: int) -> bool: - """Checks whether the column can safely be deleted""" + def allow_column_deletion( + self, column: int + ) -> tuple[bool, Any] | tuple[Any, Any]: + """Check whether a column can safely be deleted from the table. + + Prevents deletion of required columns and the index column. + Used to validate column deletion requests before they are processed. + + Args: + column: The view index of the column to check + + Returns: + tuple: A tuple containing: + - bool: True if the column can be deleted, False otherwise + - str: The name of the column + """ if column == 0 and self._has_named_index: return False, self._data_frame.index.name - column_name = self._data_frame.columns[column-self.column_offset] - if column_name not in self._allowed_columns.keys(): + column_name = self._data_frame.columns[column - self.column_offset] + if column_name not in self._allowed_columns: return True, column_name return self._allowed_columns[column_name]["optional"], column_name @@ -539,9 +890,7 @@ def fill_row(self, row_position: int, data: dict): 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 = dict.fromkeys(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: @@ -554,18 +903,22 @@ def fill_row(self, row_position: int, data: dict): 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) - )) + 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.undo_stack.push( + ModifyDataFrameCommand(self, changes, "Fill values") + ) self.get_default_values( self.index(row_position, 0), changed=changes, @@ -574,6 +927,7 @@ def fill_row(self, row_position: int, data: dict): 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): @@ -581,7 +935,7 @@ def __init__(self, data_frame, allowed_columns, table_type, parent=None): data_frame=data_frame, allowed_columns=allowed_columns, table_type=table_type, - parent=parent + parent=parent, ) self._has_named_index = True self.column_offset = 1 @@ -590,10 +944,7 @@ def get_default_values(self, index, changed: dict | None = None): """Return the default values for a the row in a new index.""" row_idx = index.row() df = self._data_frame - if isinstance(row_idx, int): - row_key = df.index[row_idx] - else: - row_key = row_idx + row_key = df.index[row_idx] if isinstance(row_idx, int) else row_idx changes = {} rename_needed = False old_index = row_key @@ -607,42 +958,43 @@ def get_default_values(self, index, changed: dict | None = None): columns_with_index.insert(1, "parameterScale") for colname in columns_with_index: - if changed and colname in changed.keys(): + if changed and colname in changed: continue if colname == df.index.name: # Generate default index name if empty default_value = self.default_handler.get_default( - colname, - row_key, - changed = changed + colname, row_key, changed=changed ) if ( - not row_key - or f"new_{self.table_type}" in row_key + not row_key or f"new_{self.table_type}" in row_key ) and bool(default_value): rename_needed = True new_index = default_value elif colname in ["upperBound", "lowerBound"]: - par_scale = changes[(row_key, "parameterScale")][1] if\ - (row_key, "parameterScale") in changes \ + par_scale = ( + changes[(row_key, "parameterScale")][1] + if (row_key, "parameterScale") in changes else changed["parameterScale"] + ) default_value = self.default_handler.get_default( colname, row_key, par_scale ) changes[(row_key, colname)] = ("", default_value) else: - default_value = self.default_handler.get_default(colname, row_key) + 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" - )) + commands.append( + ModifyDataFrameCommand(self, changes, "Fill default values") + ) if rename_needed: - commands.append(RenameIndexCommand( - self, old_index, new_index, index - )) + commands.append( + RenameIndexCommand(self, old_index, new_index, index) + ) if not commands: return if not self.undo_stack: @@ -656,7 +1008,7 @@ def get_default_values(self, index, changed: dict | None = None): def handle_named_index(self, index, value): """Handle the named index column.""" - row, column = index.row(), index.column() + row = index.row() old_value = self._data_frame.index[row] if value == old_value: return False @@ -671,19 +1023,19 @@ def handle_named_index(self, index, value): self.new_log_message.emit( f"Duplicate index value '{value}'. Renaming to default " f"value '{value}'", - "orange" + "orange", ) try: - self.undo_stack.push(RenameIndexCommand( - self, old_value, value, index - )) + self.undo_stack.push( + RenameIndexCommand(self, old_value, value, index) + ) self.relevant_id_changed.emit(value, old_value, self.table_type) self.something_changed.emit(True) return True except Exception as e: self.new_log_message.emit( f"Error renaming index value '{old_value}' to '{value}': {e}", - "red" + "red", ) return False @@ -698,6 +1050,7 @@ def return_column_index(self, column_name): 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 @@ -706,27 +1059,22 @@ def __init__(self, data_frame, parent=None): data_frame=data_frame, allowed_columns=COLUMNS["measurement"], table_type="measurement", - parent=parent + parent=parent, ) def get_default_values(self, index, changed: dict | None = None): """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 + row_key = self._data_frame.index[row] if isinstance(row, int) else row changes = {} for colname in df.columns: - if colname in changed.keys(): + if colname in changed: continue default = self.default_handler.get_default(colname, row_key) changes[(row_key, colname)] = ("", default) - command = ModifyDataFrameCommand( - self, changes, "Fill default values" - ) + command = ModifyDataFrameCommand(self, changes, "Fill default values") if self.undo_stack: self.undo_stack.push(command) else: @@ -746,9 +1094,9 @@ def data(self, index, role=Qt.DisplayRole): if is_invalid(value): return "" return str(value) - elif role == Qt.BackgroundRole: + if role == Qt.BackgroundRole: return self.determine_background_color(row, column) - elif role == Qt.ForegroundRole: + if role == Qt.ForegroundRole: # Return yellow text if this cell is a match if (row, column) in self.highlighted_cells: return QApplication.palette().color(QPalette.HighlightedText) @@ -756,10 +1104,9 @@ def data(self, index, role=Qt.DisplayRole): return None def headerData(self, section, orientation, role=Qt.DisplayRole): - """Return the header data for the given section, orientation""" + """Return the header data for the given section, orientation.""" if role != Qt.DisplayRole: return None - # role == Qt.DisplayRole if orientation == Qt.Horizontal: return self._data_frame.columns[section] if orientation == Qt.Vertical: @@ -781,7 +1128,7 @@ def __init__(self, data_frame, parent=None): data_frame=data_frame, allowed_columns=COLUMNS["observable"], table_type="observable", - parent=parent + parent=parent, ) @@ -793,11 +1140,10 @@ def __init__(self, data_frame, parent=None, sbml_model=None): data_frame=data_frame, allowed_columns=COLUMNS["parameter"], table_type="parameter", - parent=parent + parent=parent, ) self.default_handler = DefaultHandlerModel( - self, self.config, - sbml_model=sbml_model + self, self.config, sbml_model=sbml_model ) @@ -809,7 +1155,7 @@ def __init__(self, data_frame, parent=None): data_frame=data_frame, allowed_columns=COLUMNS["condition"], table_type="condition", - parent=parent + parent=parent, ) self._allowed_columns.pop("conditionId") @@ -821,7 +1167,7 @@ def __init__(self, model, parent=None): self.setSourceModel(model) def filterAcceptsRow(self, source_row, source_parent): - """Custom filtering logic to apply global filtering across all columns.""" + """Apply global filtering across all columns.""" source_model = self.sourceModel() # Always accept the last row (for "add new row") diff --git a/src/petab_gui/models/petab_model.py b/src/petab_gui/models/petab_model.py index 565bea2..0f5f857 100644 --- a/src/petab_gui/models/petab_model.py +++ b/src/petab_gui/models/petab_model.py @@ -1,10 +1,18 @@ """Contains the overarching PEtab model class.""" + from __future__ import annotations from pathlib import Path + import petab.v1 as petab + +from .pandas_table_model import ( + ConditionModel, + MeasurementModel, + ObservableModel, + ParameterModel, +) from .sbml_model import SbmlViewerModel -from .pandas_table_model import MeasurementModel, ObservableModel, ParameterModel, ConditionModel class PEtabModel: @@ -56,8 +64,7 @@ def __init__( data_frame=self.problem.observable_df, ) self.parameter = ParameterModel( - data_frame=self.problem.parameter_df, - sbml_model=self.sbml + data_frame=self.problem.parameter_df, sbml_model=self.sbml ) self.condition = ConditionModel( data_frame=self.problem.condition_df, @@ -137,4 +144,3 @@ def current_petab_problem(self) -> petab.Problem: parameter_df=self.parameter.get_df(), model=self.sbml.get_current_sbml_model(), ) - diff --git a/src/petab_gui/models/sbml_model.py b/src/petab_gui/models/sbml_model.py index 2da02ab..30ba684 100644 --- a/src/petab_gui/models/sbml_model.py +++ b/src/petab_gui/models/sbml_model.py @@ -1,10 +1,10 @@ -from PySide6.QtCore import QObject, Signal import libsbml -import tempfile +import petab.v1 as petab from petab.v1.models.sbml_model import SbmlModel from petab.v1.sbml import load_sbml_from_string -import petab.v1 as petab -from ..utils import sbmlToAntimony, antimonyToSBML +from PySide6.QtCore import QObject, Signal + +from ..utils import antimonyToSBML, sbmlToAntimony class SbmlViewerModel(QObject): @@ -17,6 +17,7 @@ class SbmlViewerModel(QObject): antimony_text: str The SBML model converted to Antimony. """ + something_changed = Signal(bool) def __init__(self, sbml_model: petab.models.Model, parent=None): @@ -43,7 +44,7 @@ def get_current_sbml_model(self): """Temporary write SBML to file and turn into petab.models.Model.""" if self.sbml_text == "": return None - + sbml_reader, sbml_document, sbml_model = load_sbml_from_string( self.sbml_text ) diff --git a/src/petab_gui/settings_manager.py b/src/petab_gui/settings_manager.py index 675d0e0..d359d2c 100644 --- a/src/petab_gui/settings_manager.py +++ b/src/petab_gui/settings_manager.py @@ -1,17 +1,40 @@ -"""Create a SettingsManager class to handle application settings with -persistent storage. +"""SettingsManager class to handle application setting's persistent storage. -Creates a single instance that will be imported and used.""" -from PySide6.QtCore import QSettings, QObject, Signal, Qt +Creates a single instance that will be imported and used. +""" + +from PySide6.QtCore import QObject, QSettings, Qt, Signal from PySide6.QtWidgets import ( - QDialog, QVBoxLayout, QHBoxLayout, QListWidget, QStackedWidget, QWidget, - QLabel, QGridLayout, QFormLayout, QComboBox, QDoubleSpinBox, QLineEdit, - QScrollArea, QGroupBox, QSizePolicy, QSpacerItem, QPushButton + QComboBox, + QDialog, + QFormLayout, + QGridLayout, + QGroupBox, + QHBoxLayout, + QLabel, + QLineEdit, + QListWidget, + QPushButton, + QScrollArea, + QSizePolicy, + QSpacerItem, + QStackedWidget, + QVBoxLayout, + QWidget, +) + +from .C import ( + ALLOWED_STRATEGIES, + COPY_FROM, + DEFAULT_CONFIGS, + DEFAULT_VALUE, + MODE, + NO_DEFAULT, + SOURCE_COLUMN, + STRATEGIES_DEFAULT_ALL, + STRATEGY_TOOLTIP, + USE_DEFAULT, ) -from .C import (DEFAULT_CONFIGS, COPY_FROM, USE_DEFAULT, NO_DEFAULT, - SOURCE_COLUMN, DEFAULT_VALUE, ALLOWED_STRATEGIES, - MODE, STRATEGY_TOOLTIP, - STRATEGIES_DEFAULT_ALL) class SettingsManager(QObject): @@ -39,8 +62,12 @@ def set_value(self, key, value): def load_ui_settings(self, main_window): """Load UI-related settings such as main window and dock states.""" # Restore main window geometry and state - main_window.restoreGeometry(self.get_value("main_window/geometry", main_window.saveGeometry())) - main_window.restoreState(self.get_value("main_window/state", main_window.saveState())) + main_window.restoreGeometry(self.get_value( + "main_window/geometry", main_window.saveGeometry() + )) + main_window.restoreState( + self.get_value("main_window/state", main_window.saveState()) + ) # Restore dock widget visibility for dock, _ in main_window.dock_visibility.items(): @@ -48,9 +75,11 @@ def load_ui_settings(self, main_window): f"docks/{dock.objectName()}", True, value_type=bool )) - main_window.data_tab.restoreGeometry(self.get_value( - "data_tab/geometry", main_window.data_tab.saveGeometry() - )) + main_window.data_tab.restoreGeometry( + self.get_value( + "data_tab/geometry", main_window.data_tab.saveGeometry() + ) + ) main_window.data_tab.restoreState(self.get_value( "data_tab/state", main_window.data_tab.saveState() )) @@ -69,15 +98,12 @@ def save_ui_settings(self, main_window): self.set_value( "data_tab/geometry", main_window.data_tab.saveGeometry() ) - self.set_value( - "data_tab/state", main_window.data_tab.saveState() - ) + self.set_value("data_tab/state", main_window.data_tab.saveState()) def get_table_defaults(self, table_name): """Retrieve default configuration for a specific table.""" return self.settings.value( - f"table_defaults/{table_name}", - DEFAULT_CONFIGS.get(table_name, {}) + f"table_defaults/{table_name}", DEFAULT_CONFIGS.get(table_name, {}) ) def set_table_defaults(self, table_name, config): @@ -93,12 +119,18 @@ def set_table_defaults(self, table_name, config): class ColumnConfigWidget(QWidget): """Widget for editing a single column's configuration.""" - def __init__(self, column_name, config, table_columns, - strategies=None, parent=None): + def __init__( + self, column_name, config, table_columns, strategies=None, parent=None + ): """ - :param column_name: Name of the column - :param config: Dictionary containing settings for the column - :param table_columns: List of columns in the same table (used for dropdown) + Initialize the column configuration widget. + + :param column_name: + Name of the column + :param config: + Dictionary containing settings for the column + :param table_columns: + List of columns in the same table (used for dropdown) """ super().__init__(parent) self.setWindowTitle(column_name) @@ -150,8 +182,11 @@ def __init__(self, column_name, config, table_columns, "Source Column:", self.source_column_dropdown ) - for widget in [self.strategy_choice, self.default_value, - self.source_column_dropdown]: + for widget in [ + self.strategy_choice, + self.default_value, + self.source_column_dropdown, + ]: widget.setFixedWidth(150) widget.setMinimumHeight(24) @@ -199,6 +234,8 @@ class TableDefaultsWidget(QWidget): def __init__(self, table_name, table_columns, settings, parent=None): """ + Initialize the table defaults widget. + :param table_name: The name of the table :param table_columns: List of column names in this table :param settings: Dictionary of settings for this table @@ -215,16 +252,22 @@ def __init__(self, table_name, table_columns, settings, parent=None): allowed_strats = ALLOWED_STRATEGIES.get(table_name, {}) # Iterate over columns and create widgets for column_name in table_columns: - column_settings = settings.get(column_name, self.default_col_config()) + column_settings = settings.get( + column_name, self.default_col_config() + ) strategies = allowed_strats.get(column_name, None) column_widget = ColumnConfigWidget( column_name, column_settings, table_columns, strategies ) - column_widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Minimum) + column_widget.setSizePolicy( + QSizePolicy.Expanding, QSizePolicy.Minimum + ) group_layout.addWidget(column_widget) self.column_widgets[column_name] = column_widget - group_layout.addSpacerItem(QSpacerItem(20, 40, QSizePolicy.Minimum, QSizePolicy.Expanding)) + group_layout.addSpacerItem( + QSpacerItem(20, 40, QSizePolicy.Minimum, QSizePolicy.Expanding) + ) # Apply layout main_layout = QVBoxLayout(self) @@ -241,9 +284,7 @@ def save_current_settings(self): def default_col_config(self): """Return default config for new columns.""" - return { - "strategy": NO_DEFAULT - } + return {"strategy": NO_DEFAULT} class SettingsDialog(QDialog): @@ -257,7 +298,8 @@ def __init__(self, table_columns, parent=None): self.settings = { table_type: settings_manager.get_value( f"table_defaults/{table_type}", {} - ) for table_type in table_columns.keys() + ) + for table_type in table_columns } self.main_layout = QHBoxLayout(self) @@ -301,8 +343,12 @@ def init_table_defaults_page(self): self.table_widgets = {} # Add tables in a 2x2 grid - for i_table, (table_name, column_list) in enumerate(self.table_columns.items()): - table_widget = TableDefaultsWidget(table_name, column_list, self.settings.get(table_name, {})) + for i_table, (table_name, column_list) in enumerate( + self.table_columns.items() + ): + table_widget = TableDefaultsWidget( + table_name, column_list, self.settings.get(table_name, {}) + ) grid_layout.addWidget(table_widget, i_table // 2, i_table % 2) self.table_widgets[table_name] = table_widget @@ -325,8 +371,7 @@ def init_table_defaults_page(self): def apply_settings(self): """Retrieve UI settings and save them in SettingsManager.""" - for table_name, table_widget in self.table_widgets.items(): + for _table_name, table_widget in self.table_widgets.items(): table_widget.save_current_settings() settings_manager.new_log_message.emit("New settings applied.", "green") self.accept() - diff --git a/src/petab_gui/utils.py b/src/petab_gui/utils.py index 0c79122..bcd9188 100644 --- a/src/petab_gui/utils.py +++ b/src/petab_gui/utils.py @@ -1,34 +1,52 @@ -from PySide6.QtWidgets import (QDialog, QVBoxLayout, QHBoxLayout, QLabel, \ - QLineEdit, QPushButton, QCompleter, QCheckBox, QGridLayout, QTableView, - QWidget, QToolButton, QMenu) -from PySide6.QtGui import QSyntaxHighlighter, QTextCharFormat, QColor, QAction -from PySide6.QtCore import QObject, Signal, Qt -import re -from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg as FigureCanvas -from matplotlib.figure import Figure -import pandas as pd import logging -from .C import ROW, COLUMN, INDEX -import antimony -import os import math +import os +import re +from typing import Any + +import antimony import numpy as np +import pandas as pd import qtawesome as qta +from matplotlib.backends.backend_qt5agg import ( + FigureCanvasQTAgg as FigureCanvas, +) +from matplotlib.figure import Figure +from PySide6.QtCore import QModelIndex, QObject, Qt, Signal +from PySide6.QtGui import QAction, QColor, QSyntaxHighlighter, QTextCharFormat +from PySide6.QtWidgets import ( + QCheckBox, + QCompleter, + QDialog, + QGridLayout, + QHBoxLayout, + QLabel, + QLineEdit, + QMenu, + QPushButton, + QTableView, + QToolButton, + QVBoxLayout, + QWidget, +) + +from .C import COLUMN, INDEX, ROW def _checkAntimonyReturnCode(code): - """ Helper for checking the antimony response code. + """Helper for checking the antimony response code. + Raises Exception if error in antimony. :param code: antimony response :type code: int """ if code < 0: - raise Exception('Antimony: {}'.format(antimony.getLastError())) + raise Exception(f"Antimony: {antimony.getLastError()}") def sbmlToAntimony(sbml): - """ Convert SBML to antimony string. + """Convert SBML to antimony string. :param sbml: SBML string or file :type sbml: str | file @@ -40,8 +58,9 @@ def sbmlToAntimony(sbml): isfile = False try: isfile = os.path.isfile(sbml) - except: - pass + except Exception as e: + logging.warning(f"Error checking if {sbml} is a file: {str(e)}") + isfile = False if isfile: code = antimony.loadSBMLFile(sbml) else: @@ -51,7 +70,7 @@ def sbmlToAntimony(sbml): def antimonyToSBML(ant): - """ Convert Antimony to SBML string. + """Convert Antimony to SBML string. :param ant: Antimony string or file :type ant: str | file @@ -74,7 +93,21 @@ def antimonyToSBML(ant): class ConditionInputDialog(QDialog): + """Dialog for adding or editing experimental conditions. + + Provides input fields for simulation condition ID and optional + preequilibration condition ID. + """ + def __init__(self, condition_id=None, parent=None): + """Initialize the condition input dialog. + + Args: + condition_id: + Optional initial value for the simulation condition ID + parent: + The parent widget + """ super().__init__(parent) self.setWindowTitle("Add Condition") @@ -119,9 +152,16 @@ def __init__(self, condition_id=None, parent=None): self.cancel_button.clicked.connect(self.reject) def accept(self): + """Override the accept method to validate inputs before accepting. + + Checks if the simulation condition ID is provided. + If not, shows an error message and prevents the dialog from closing. + """ 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.setText( + "Simulation Condition is required." + ) self.notification_label.setVisible(True) return self.notification_label.setVisible(False) @@ -129,6 +169,14 @@ def accept(self): super().accept() def get_inputs(self): + """Get the user inputs as a dictionary. + + Returns: + A dictionary containing: + - 'simulationConditionId': The simulation condition ID + - 'preequilibrationConditionId': The preequilibration condition ID + (only included if provided) + """ inputs = {} inputs["simulationConditionId"] = self.sim_input.text() preeq = self.preeq_input.text() @@ -137,367 +185,18 @@ def get_inputs(self): return inputs -class MeasurementInputDialog(QDialog): - def __init__( - self, - condition_ids = None, - observable_ids = None, - initial_values=None, - error_key=None, - parent=None - ): - super().__init__(parent) - self.setWindowTitle("Add Measurement") - - self.layout = QVBoxLayout(self) - - # Observable ID - self.observable_id_layout = QHBoxLayout() - self.observable_id_label = QLabel("Observable ID:", self) - self.observable_id_input = QLineEdit(self) - if initial_values and "observableId" in initial_values: - self.observable_id_input.setText(str(initial_values["observableId"])) - if "observableId" == error_key: - self.observable_id_input.setStyleSheet("background-color: red;") - self.observable_id_layout.addWidget(self.observable_id_label) - self.observable_id_layout.addWidget(self.observable_id_input) - self.layout.addLayout(self.observable_id_layout) - - if observable_ids: - # Auto-suggestion for Observable ID - observable_completer = QCompleter(observable_ids, self) - self.observable_id_input.setCompleter(observable_completer) - - # Measurement - self.measurement_layout = QHBoxLayout() - self.measurement_label = QLabel("Measurement:", self) - self.measurement_input = QLineEdit(self) - if initial_values and "measurement" in initial_values: - self.measurement_input.setText(str(initial_values["measurement"])) - if "measurement" == error_key: - self.measurement_input.setStyleSheet("background-color: red;") - self.measurement_layout.addWidget(self.measurement_label) - self.measurement_layout.addWidget(self.measurement_input) - self.layout.addLayout(self.measurement_layout) - - # Timepoints - self.timepoints_layout = QHBoxLayout() - self.timepoints_label = QLabel("Timepoints:", self) - self.timepoints_input = QLineEdit(self) - if initial_values and "time" in initial_values: - self.timepoints_input.setText(str(initial_values["time"])) - if "time" == error_key: - self.timepoints_input.setStyleSheet("background-color: red;") - self.timepoints_layout.addWidget(self.timepoints_label) - self.timepoints_layout.addWidget(self.timepoints_input) - self.layout.addLayout(self.timepoints_layout) - - # Condition ID - self.condition_id_layout = QHBoxLayout() - self.condition_id_label = QLabel("Condition ID:", self) - self.condition_id_input = QLineEdit(self) - if initial_values and "conditionId" in initial_values: - self.condition_id_input.setText(str(initial_values["conditionId"])) - if "conditionId" == error_key: - self.condition_id_input.setStyleSheet("background-color: red;") - elif condition_ids and len(condition_ids) == 1: - self.condition_id_input.setText(condition_ids[0]) - 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) - - if condition_ids: - # Auto-suggestion for Condition ID - condition_completer = QCompleter(condition_ids, self) - self.condition_id_input.setCompleter(condition_completer) - - # Buttons - self.buttons_layout = QHBoxLayout() - self.ok_button = QPushButton("OK", self) - self.cancel_button = QPushButton("Cancel", self) - self.buttons_layout.addWidget(self.ok_button) - self.buttons_layout.addWidget(self.cancel_button) - self.layout.addLayout(self.buttons_layout) - - self.ok_button.clicked.connect(self.accept) - self.cancel_button.clicked.connect(self.reject) - - def get_inputs(self): - return (self.observable_id_input.text(), - self.measurement_input.text(), - self.timepoints_input.text(), - self.condition_id_input.text()) - - -class ObservableInputDialog(QDialog): - def __init__(self, initial_values=None, error_key=None, parent=None): - super().__init__(parent) - self.setWindowTitle("Add Observable") - - self.layout = QVBoxLayout(self) - - # Observable ID - self.observable_id_layout = QHBoxLayout() - self.observable_id_label = QLabel("Observable ID:", self) - self.observable_id_input = QLineEdit(self) - if initial_values and "observableId" in initial_values: - self.observable_id_input.setText(str(initial_values["observableId"])) - if "observableId" == error_key: - self.observable_id_input.setStyleSheet("background-color: red;") - self.observable_id_layout.addWidget(self.observable_id_label) - self.observable_id_layout.addWidget(self.observable_id_input) - self.layout.addLayout(self.observable_id_layout) - - # Observable Formula - self.observable_formula_layout = QHBoxLayout() - self.observable_formula_label = QLabel("Observable Formula:", self) - self.observable_formula_input = QLineEdit(self) - if initial_values and "observableFormula" in initial_values: - self.observable_formula_input.setText(str(initial_values["observableFormula"])) - if "observableFormula" == error_key: - self.observable_formula_input.setStyleSheet("background-color: red;") - self.observable_formula_layout.addWidget(self.observable_formula_label) - self.observable_formula_layout.addWidget(self.observable_formula_input) - self.layout.addLayout(self.observable_formula_layout) - - # Buttons - self.buttons_layout = QHBoxLayout() - self.ok_button = QPushButton("OK", self) - self.cancel_button = QPushButton("Cancel", self) - self.buttons_layout.addWidget(self.ok_button) - self.buttons_layout.addWidget(self.cancel_button) - self.layout.addLayout(self.buttons_layout) - - self.ok_button.clicked.connect(self.accept) - self.cancel_button.clicked.connect(self.reject) - - def get_inputs(self): - return self.observable_id_input.text(), self.observable_formula_input.text() - - -class ObservableFormulaInputDialog(QDialog): - def __init__(self, observable_id, parent=None): - super().__init__(parent) - self.setWindowTitle( - "You added a new observable! Please provide the formula." - ) - - self.layout = QVBoxLayout(self) - - # Observable ID - self.observable_id_layout = QHBoxLayout() - self.observable_id_label = QLabel("Observable ID:", self) - self.observable_id_input = QLineEdit(self) - self.observable_id_input.setText(observable_id) - self.observable_id_input.setReadOnly(True) - self.observable_id_layout.addWidget(self.observable_id_label) - self.observable_id_layout.addWidget(self.observable_id_input) - self.layout.addLayout(self.observable_id_layout) - - # Observable Formula - self.observable_formula_layout = QHBoxLayout() - self.observable_formula_label = QLabel("Observable Formula:", self) - self.observable_formula_input = QLineEdit(self) - self.observable_formula_layout.addWidget(self.observable_formula_label) - self.observable_formula_layout.addWidget(self.observable_formula_input) - self.layout.addLayout(self.observable_formula_layout) - - # Buttons - self.buttons_layout = QHBoxLayout() - self.ok_button = QPushButton("OK", self) - self.cancel_button = QPushButton("Cancel", self) - self.buttons_layout.addWidget(self.ok_button) - self.buttons_layout.addWidget(self.cancel_button) - self.layout.addLayout(self.buttons_layout) - - self.ok_button.clicked.connect(self.accept) - self.cancel_button.clicked.connect(self.reject) - - def get_inputs(self): - return (self.observable_id_input.text(), - self.observable_formula_input.text()) - - -class ParameterInputDialog(QDialog): - def __init__(self, initial_values=None, error_key=None, parent=None): - super().__init__(parent) - self.setWindowTitle("Add Parameter") - - self.layout = QVBoxLayout(self) - - # Parameter ID - self.parameter_id_layout = QHBoxLayout() - self.parameter_id_label = QLabel("Parameter ID:", self) - self.parameter_id_input = QLineEdit(self) - if initial_values and "parameterId" in initial_values: - self.parameter_id_input.setText(str(initial_values["parameterId"])) - if "parameterId" == error_key: - self.parameter_id_input.setStyleSheet("background-color: red;") - self.parameter_id_layout.addWidget(self.parameter_id_label) - self.parameter_id_layout.addWidget(self.parameter_id_input) - self.layout.addLayout(self.parameter_id_layout) - - # Nominal Value - self.nominal_value_layout = QHBoxLayout() - self.nominal_value_label = QLabel("Nominal Value (optional):", self) - self.nominal_value_input = QLineEdit(self) - if initial_values and "nominalValue" in initial_values: - self.nominal_value_input.setText(str(initial_values["nominalValue"])) - if "nominalValue" == error_key: - self.nominal_value_input.setStyleSheet("background-color: red;") - self.nominal_value_layout.addWidget(self.nominal_value_label) - self.nominal_value_layout.addWidget(self.nominal_value_input) - self.layout.addLayout(self.nominal_value_layout) - - # Buttons - self.buttons_layout = QHBoxLayout() - self.ok_button = QPushButton("OK", self) - self.cancel_button = QPushButton("Cancel", self) - self.buttons_layout.addWidget(self.ok_button) - self.buttons_layout.addWidget(self.cancel_button) - self.layout.addLayout(self.buttons_layout) - - self.ok_button.clicked.connect(self.accept) - self.cancel_button.clicked.connect(self.reject) - - def get_inputs(self): - return self.parameter_id_input.text(), self.nominal_value_input.text() - - -def set_dtypes(data_frame, columns, index_columns=None): - dtype_mapping = { - "STRING": str, - "NUMERIC": float, - "BOOLEAN": bool - } - for column, dtype in columns.items(): - if column in data_frame.columns: - data_frame[column] = data_frame[column].astype(dtype_mapping[dtype]) - if index_columns: - data_frame.set_index(index_columns, inplace=True) - return data_frame - - -class FindReplaceDialog(QDialog): - def __init__(self, parent=None, mode="petab", checkbox_states=None, controller=None): - super().__init__(parent) - self.controller = controller - self.setWindowTitle("Find and Replace") - self.mode = mode - self.checkbox_states = checkbox_states or {} - - self.find_label = QLabel("Find:") - self.find_input = QLineEdit() - - self.replace_label = QLabel("Replace:") - self.replace_input = QLineEdit() - - self.find_button = QPushButton("Find") - self.replace_button = QPushButton("Replace") - self.close_button = QPushButton("Close") - - self.replace_button.clicked.connect(self.replace) - self.close_button.clicked.connect(self.close) - - layout = QVBoxLayout() - form_layout = QHBoxLayout() - form_layout.addWidget(self.find_label) - form_layout.addWidget(self.find_input) - form_layout.addWidget(self.replace_label) - form_layout.addWidget(self.replace_input) - - layout.addLayout(form_layout) - - checkbox_layout = QGridLayout() - - if self.mode == "petab": - self.measurement_checkbox = QCheckBox("Measurement Table") - self.observable_checkbox = QCheckBox("Observable Table") - self.parameter_checkbox = QCheckBox("Parameter Table") - self.condition_checkbox = QCheckBox("Condition Table") - - checkbox_layout.addWidget(self.measurement_checkbox, 0, 0) - checkbox_layout.addWidget(self.observable_checkbox, 0, 1) - checkbox_layout.addWidget(self.parameter_checkbox, 1, 0) - checkbox_layout.addWidget(self.condition_checkbox, 1, 1) - - self.measurement_checkbox.setChecked(self.checkbox_states.get("measurement", False)) - self.observable_checkbox.setChecked(self.checkbox_states.get("observable", False)) - self.parameter_checkbox.setChecked(self.checkbox_states.get("parameter", False)) - self.condition_checkbox.setChecked(self.checkbox_states.get("condition", False)) - else: # SBML mode - self.sbml_checkbox = QCheckBox("SBML Text") - self.antimony_checkbox = QCheckBox("Antimony Text") - - checkbox_layout.addWidget(self.sbml_checkbox, 0, 0) - checkbox_layout.addWidget(self.antimony_checkbox, 0, 1) - - self.sbml_checkbox.setChecked(self.checkbox_states.get("sbml", False)) - self.antimony_checkbox.setChecked(self.checkbox_states.get("antimony", False)) - - layout.addLayout(checkbox_layout) - - layout.addWidget(self.replace_button) - layout.addWidget(self.close_button) - self.setLayout(layout) - - def closeEvent(self, event): - if self.mode == "petab": - self.checkbox_states["measurement"] = self.measurement_checkbox.isChecked() - self.checkbox_states["observable"] = self.observable_checkbox.isChecked() - self.checkbox_states["parameter"] = self.parameter_checkbox.isChecked() - self.checkbox_states["condition"] = self.condition_checkbox.isChecked() - else: # SBML mode - self.checkbox_states["sbml"] = self.sbml_checkbox.isChecked() - self.checkbox_states["antimony"] = self.antimony_checkbox.isChecked() - super().closeEvent(event) - - def replace(self): - find_text = self.find_input.text() - replace_text = self.replace_input.text() - - if self.mode == "petab": - if self.measurement_checkbox.isChecked(): - self.controller.measurement_controller.replace_text(find_text, replace_text) - if self.observable_checkbox.isChecked(): - self.controller.observable_controller.replace_text(find_text, replace_text) - if self.parameter_checkbox.isChecked(): - self.controller.parameter_controller.replace_text(find_text, replace_text) - if self.condition_checkbox.isChecked(): - self.controller.condition_controller.replace_text(find_text, replace_text) - else: # SBML mode - if self.sbml_checkbox.isChecked(): - sbml_text = self.parent().sbml_viewer.sbml_text_edit.toPlainText() - sbml_text = sbml_text.replace(find_text, replace_text) - self.parent().sbml_viewer.sbml_text_edit.setPlainText(sbml_text) - - if self.antimony_checkbox.isChecked(): - antimony_text = self.parent().sbml_viewer.antimony_text_edit.toPlainText() - antimony_text = antimony_text.replace(find_text, replace_text) - self.parent().sbml_viewer.antimony_text_edit.setPlainText(antimony_text) - - -class SyntaxHighlighter(QSyntaxHighlighter): - def __init__(self, parent=None): - super().__init__(parent) - self._rules = [] - - # Define formats - keyword_format = QTextCharFormat() - keyword_format.setForeground(QColor("blue")) - - # Define regex patterns - keywords = ["keyword1", "keyword2"] # Replace with actual keywords - keyword_pattern = r"\b(" + "|".join(keywords) + r")\b" - self._rules.append((re.compile(keyword_pattern), keyword_format)) - - def highlightBlock(self, text): - for pattern, format in self._rules: - for match in pattern.finditer(text): - self.setFormat(match.start(), match.end() - match.start(), format) +def validate_value(value, expected_type): + """Validate and convert a value to the expected type. + Args: + value: The value to validate and convert + expected_type: The numpy type to convert the value to -def validate_value(value, expected_type): + Returns: + tuple: A tuple containing: + - The converted value, or None if conversion failed + - An error message if conversion failed, or None if successful + """ try: if expected_type == np.object_: value = str(value) @@ -509,37 +208,47 @@ def validate_value(value, expected_type): class PlotWidget(FigureCanvas): + """A widget for displaying matplotlib plots in Qt applications. + + Inherits from FigureCanvas to provide a Qt widget that can display + matplotlib figures. + """ def __init__(self, parent=None, width=5, height=4, dpi=100): + """Initialize the plot widget. + + Args: + parent: The parent widget + width: The width of the figure in inches + height: The height of the figure in inches + dpi: The resolution of the figure in dots per inch + """ fig = Figure(figsize=(width, height), dpi=dpi) self.axes = fig.add_subplot(111) - super(PlotWidget, self).__init__(fig) - + super().__init__(fig) -class SignalForwarder(QObject): - """Forward signals from one object to another.""" - forwarded_signal = Signal() - def __init__(self, original_signal): - super().__init__() - self.original_signal = original_signal - self.original_signal.connect(self.forward_signal) - def forward_signal(self, *args, **kwargs): - """ - Capture any arguments from the original signal and forward them. - """ - self.forwarded_signal.emit(*args, **kwargs) - - def connect_forwarded(self, slot): - """Connect a slot to the forwarded signal.""" - self.forwarded_signal.connect(slot) +def create_empty_dataframe(column_dict: dict, table_type: str): + """Create an empty pandas DataFrame with the specified columns and types. + Args: + column_dict: + A dictionary mapping column names to their properties, where each + property dict contains 'optional' and 'type' keys + table_type: + The type of table to create ('observable', 'parameter', + or 'condition') which determines the index column -def create_empty_dataframe(column_dict: dict, table_type: str): - columns = [col for col, props in column_dict.items() if not props["optional"]] + Returns: + pd.DataFrame: An empty DataFrame with the specified columns and index + """ + columns = [ + col for col, props in column_dict.items() if not props["optional"] + ] dtypes = { - col: props["type"] 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 @@ -554,29 +263,49 @@ def create_empty_dataframe(column_dict: dict, table_type: str): class CaptureLogHandler(logging.Handler): """A logging handler to capture log messages with levels.""" + def __init__(self): + """Initialize the log handler. + + Creates an empty list to store log records. + """ super().__init__() self.records = [] # Store full log records def emit(self, record): + """Process a log record by storing it in the records list. + + Args: + record: The LogRecord to process + """ self.records.append(record) # Save the entire LogRecord def get_formatted_messages(self): """Return formatted messages with levels.""" return [ - f"{record.levelname}: {self.format(record)}" for record in self.records + f"{record.levelname}: {self.format(record)}" + for record in self.records ] -def get_selected(table_view: QTableView, mode: str = ROW) -> list[int]: +def get_selected( + table_view: QTableView, mode: str = ROW +) -> list[Any] | list[QModelIndex] | set[int] | None: """ - Determines which rows are selected in a QTableView. + Determines which items are selected in a QTableView. Args: table_view (QTableView): The table view to check. + mode (str): The selection mode to use. Can be one of: + - ROW: Return selected row indices + - COLUMN: Return selected column indices + - INDEX: Return selected model indices Returns: - list[int]: A list of selected row indices. + list[int] or set[int] or list[QModelIndex]: + - If mode is ROW: A set of selected row indices + - If mode is COLUMN: A set of selected column indices + - If mode is INDEX: A list of selected QModelIndex objects """ if not table_view or not isinstance(table_view, QTableView): return [] @@ -590,11 +319,9 @@ def get_selected(table_view: QTableView, mode: str = ROW) -> list[int]: if mode == INDEX: return selected_indexes if mode == COLUMN: - selected_columns = set([index.column() for index in selected_indexes]) - return selected_columns + return {index.column() for index in selected_indexes} if mode == ROW: - selected_rows = set([index.row() for index in selected_indexes]) - return selected_rows + return {index.row() for index in selected_indexes} return None @@ -634,6 +361,12 @@ def process_file(filepath, logger): Args: filepath (str): Path to the file to process. + logger: A logger object with a log_message method for reporting errors. + + Returns: + A tuple containing: + - The detected file type (or None if not recognized) + - The detected separator for tabular files (or None if not applicable) """ _, ext = os.path.splitext(filepath) ext = ext.lower() @@ -648,7 +381,6 @@ def process_file(filepath, logger): # Case 3: CSV/TSV/TXT files if ext in {".csv", ".tsv", ".txt"}: - # Determine separator by attempting to read the file with different delimiters separators = [",", "\t", ";"] separator = None header = None @@ -656,41 +388,42 @@ def process_file(filepath, logger): for sep in separators: # read the first line of the file try: - with open(filepath, "r", encoding="utf-8") as file: + with open(filepath, encoding="utf-8") as file: header = file.readline().strip().split(sep) if len(header) > 1: separator = sep break - except Exception: + except Exception as e: + logging.debug( + f"Failed to read file with separator '{sep}': {str(e)}" + ) continue if header is None: logger.log_message( f"Failed to read file: {filepath}. Perhaps unsupported " f"delimiter. Supported delimiters: {', '.join(separators)}", - color="red" + color="red", ) return None, None # Case 3.2: Identify the table type based on header content if {"observableId", "measurement", "time"}.issubset(header): return "measurement", separator - elif {"observableId", "observableFormula"}.issubset(header): + if {"observableId", "observableFormula"}.issubset(header): return "observable", separator - elif "parameterId" in header: + if "parameterId" in header: return "parameter", separator - elif "conditionId" in header or "\ufeffconditionId" in header: + if "conditionId" in header or "\ufeffconditionId" in header: return "condition", separator - else: - logger.log_message( - f"Unrecognized table type for file: {filepath}. Uploading as " - f"data matrix.", - color="orange" - ) - return "data_matrix", separator + logger.log_message( + f"Unrecognized table type for file: {filepath}. Uploading as " + f"data matrix.", + color="orange", + ) + return "data_matrix", separator logger.log_message( - f"Unrecognized file type for file: {filepath}.", - color="red" + f"Unrecognized file type for file: {filepath}.", color="red" ) return None, None @@ -705,301 +438,3 @@ def is_invalid(value): return not math.isfinite(value) except TypeError: return True - - -class FindReplaceBar(QWidget): - def __init__(self, controller, parent=None): - super().__init__(parent) - self.controller = controller - self.controller_map = { - "Observable Table": self.controller.observable_controller, - "Condition Table": self.controller.condition_controller, - "Parameter Table": self.controller.parameter_controller, - "Measurement Table": self.controller.measurement_controller, - } - self.selected_controllers = set() - self.only_search = False - self.matches = None - - # 🔍 Find Input with options - self.find_input = QLineEdit() - self.find_input.setPlaceholderText("Find...") - self.find_input.textChanged.connect(self.run_find) - - self.case_sensitive_button = QToolButton() - self.case_sensitive_button.setIcon(qta.icon("mdi6.format-letter-case")) - self.case_sensitive_button.setCheckable(True) - self.case_sensitive_button.toggled.connect(self.run_find) - - self.word_match_button = QToolButton() - self.word_match_button.setIcon(qta.icon("mdi6.alpha-w")) - self.word_match_button.setCheckable(True) - self.word_match_button.toggled.connect(self.run_find) - - self.regex_button = QToolButton() - self.regex_button.setIcon(qta.icon("mdi6.regex")) - self.regex_button.setCheckable(True) - self.regex_button.toggled.connect(self.run_find) - - find_layout = QHBoxLayout() - find_layout.addWidget(self.find_input) - find_layout.addWidget(self.case_sensitive_button) - find_layout.addWidget(self.word_match_button) - find_layout.addWidget(self.regex_button) - - # 🔄 Replace Input - self.replace_input = QLineEdit() - self.replace_input.setPlaceholderText("Replace...") - - replace_layout = QHBoxLayout() - replace_layout.addWidget(self.replace_input) - - # 🔘 Action Buttons (Navigation, Results, Replace, Close) - self.prev_button = QPushButton() - self.prev_button.setIcon(qta.icon("mdi6.arrow-up")) - self.next_button = QPushButton() - self.next_button.setIcon(qta.icon("mdi6.arrow-down")) - self.prev_button.clicked.connect(self.find_previous) - self.next_button.clicked.connect(self.find_next) - - self.results_label = QLabel("0 results") - self.filter_button = QPushButton() - self.filter_button.setIcon(qta.icon("mdi6.filter")) - self.close_button = QPushButton() - self.filter_button.clicked.connect(self.show_filter_menu) - self.filter_menu = QMenu(self) # Dropdown menu - self.filter_actions = {} - action = QAction("All", self.filter_menu) - action.setCheckable(True) - action.setChecked(True) - action.triggered.connect(self.update_selected_controllers) - self.filter_menu.addAction(action) - self.filter_actions["All"] = action - for table_name in self.controller_map.keys(): - action = QAction(table_name, self.filter_menu) - action.setCheckable(True) - action.triggered.connect(self.update_selected_controllers) - self.filter_menu.addAction(action) - self.filter_actions[table_name] = action - self.close_button.setIcon(qta.icon("mdi6.close")) - self.close_button.clicked.connect(self.hide) - - self.replace_button = QPushButton("Replace") - self.replace_button.clicked.connect(self.replace_current_match) - self.replace_all_button = QPushButton("Replace All") - self.replace_all_button.clicked.connect(self.replace_all) - - find_controls_layout = QHBoxLayout() - find_controls_layout.addWidget(self.results_label) - find_controls_layout.addWidget(self.prev_button) - find_controls_layout.addWidget(self.next_button) - find_controls_layout.addWidget(self.filter_button) - find_controls_layout.addWidget(self.close_button) - - replace_controls_layout = QHBoxLayout() - replace_controls_layout.addWidget(self.replace_button) - replace_controls_layout.addWidget(self.replace_all_button) - - # 🔹 Main Layout - self.layout_main = QHBoxLayout() - self.layout_edits = QVBoxLayout() - self.layout_options = QVBoxLayout() - - self.layout_edits.addLayout(find_layout) - self.layout_edits.addLayout(replace_layout) - - self.layout_options.addLayout(find_controls_layout) - self.layout_options.addLayout(replace_controls_layout) - - self.layout_main.addLayout(self.layout_edits) - self.layout_main.addLayout(self.layout_options) - self.setLayout(self.layout_main) - - def run_find(self): - """Triggered when the search text changes.""""" - search_text = self.find_input.text() - case_sensitive = self.case_sensitive_button.isChecked() - regex = self.regex_button.isChecked() - whole_cell = self.word_match_button.isChecked() - - self.matches = [] - self.current_match_ind = -1 - - for controller in [ - self.controller.observable_controller, - self.controller.condition_controller, - self.controller.parameter_controller, - self.controller.measurement_controller, - ]: - matches = controller.find_text(search_text, case_sensitive, regex, whole_cell) - self.matches.extend([(match[0], match[1], controller) for match in matches]) # Extend match with controller - - if self.matches: - self.current_match_ind = 0 - self.focus_match(self.matches[self.current_match_ind]) - - self.update_result_label() - - def find_next(self): - """Move to the next match.""" - if not self.matches: - return - __, _, controller = self.matches[self.current_match_ind] - controller.focus_match(None) - self.current_match_ind = (self.current_match_ind + 1) % len(self.matches) - row, col, controller = self.matches[self.current_match_ind] - controller.focus_match((row, col), with_focus=True) - self.update_result_label() - - def find_previous(self): - """Move to the previous match.""" - if not self.matches: - return - __, _, controller = self.matches[self.current_match_ind] - controller.focus_match(None) - self.current_match_ind = (self.current_match_ind - 1) % len( - self.matches) - row, col, controller = self.matches[self.current_match_ind] - controller.focus_match((row, col), with_focus=True) - self.update_result_label() - - def update_result_label(self): - """Update the result label dynamically.""" - match_count = len(self.matches) - self.results_label.setText( - f"{self.current_match_ind + 1}/{match_count}" if match_count > 0 - else "0 results" - ) - - def replace_current_match(self): - """Replace the currently selected match and move to the next one.""" - if not self.matches or self.current_match_ind == -1: - return - - replace_text = self.replace_input.text() - if not replace_text: - return - - row, col, controller = self.matches[self.current_match_ind] # Extract controller from match - - controller.replace_text( - row=row, - col=col, - replace_text=replace_text, - search_text=self.find_input.text(), - case_sensitive=self.case_sensitive_button.isChecked(), - regex=self.regex_button.isChecked(), - ) - # drop the current match and update the result label - self.matches.pop(self.current_match_ind) - self.update_result_label() - match = self.matches[self.current_match_ind] if self.matches else None - self.focus_match(match, with_focus=True) - - def replace_all(self): - """Replace all matches with the given text.""" - if not self.matches: - return - - replace_text = self.replace_input.text() - search_text = self.find_input.text() - case_sensitive = self.case_sensitive_button.isChecked() - regex = self.regex_button.isChecked() - - controllers = set(match[2] for match in self.matches) # Get unique controllers - - for controller in controllers: - controller.replace_all( - search_text, replace_text, case_sensitive, regex - ) - # emit dataChanged emit for each match - for row, col, controller in self.matches: - controller.model.dataChanged.emit( - controller.model.index(row, col), - controller.model.index(row, col) - ) - controller.cleanse_highlighted_cells() - self.run_find() - - def focus_match(self, match, with_focus: bool = False): - """Focus the match in the correct table.""" - if not match: - return - row, col, controller = match - controller.focus_match((row, col), with_focus) - - def show_filter_menu(self): - """Show the filter selection dropdown below the filter button.""" - self.filter_menu.exec_(self.filter_button.mapToGlobal( - self.filter_button.rect().bottomLeft())) - - def update_selected_controllers(self): - """Update which tables are included in the search based on selection.""" - if self.filter_actions["All"].isChecked(): - self.selected_controllers = set(self.controller_map.values()) - else: - self.selected_controllers = { - self.controller_map[table_name] - for table_name, action in self.filter_actions.items() if - action.isChecked() & (table_name != "All") - } - self.run_find() - - def keyPressEvent(self, event): - if event.key() == Qt.Key_Escape: - self.hide() - event.accept() - else: - super().keyPressEvent(event) - - def hideEvent(self, event): - """Reset highlights when the Find/Replace bar is hidden.""" - for controller in self.selected_controllers: - controller.cleanse_highlighted_cells() - super().hideEvent(event) - - def showEvent(self, event): - """Reset highlights when the Find/Replace bar is shown.""" - # group matches by controller - if not self.matches: - super().showEvent(event) - return - for controller in [match[2] for match in self.matches]: - matches = [ - (match[0], match[1]) for match in self.matches - if match[2] == controller - ] - controller.highlight_text(matches) - super().showEvent(event) - - def show_replace_parts(self, show: bool = False): - """Toggle the visibility of the replace parts.""" - self.replace_input.setVisible(show) - self.replace_button.setVisible(show) - self.replace_all_button.setVisible(show) - - def toggle_find(self): - """Toggle behaviour of the search bar.""" - if not self.isVisible(): - self.show() - self.show_replace_parts(False) - self.only_search = True - return - if not self.only_search: - self.show_replace_parts(False) - self.only_search = True - return - self.hide() - - def toggle_replace(self): - """Toggle behaviour of the replace bar.""" - if not self.isVisible(): - self.show() - self.show_replace_parts(True) - self.only_search = False - return - if self.only_search: - self.show_replace_parts(True) - self.only_search = False - return - self.hide() diff --git a/src/petab_gui/views/__init__.py b/src/petab_gui/views/__init__.py index a8addb3..c4164c0 100644 --- a/src/petab_gui/views/__init__.py +++ b/src/petab_gui/views/__init__.py @@ -9,6 +9,7 @@ - the task bar - the log window """ + from .main_view import MainWindow from .sbml_view import SbmlViewer from .table_view import TableViewer diff --git a/src/petab_gui/views/context_menu_mananger.py b/src/petab_gui/views/context_menu_mananger.py index 8100ccc..066b8bb 100644 --- a/src/petab_gui/views/context_menu_mananger.py +++ b/src/petab_gui/views/context_menu_mananger.py @@ -3,6 +3,7 @@ class ContextMenuManager: """Manage context menu actions for the tables.""" + def __init__(self, actions, table_view, parent=None): self.parent = parent() self.actions = actions @@ -22,4 +23,4 @@ def create_context_menu(self, position): menu.addSeparator() # execute the menu - menu.exec_(self.table_view.viewport().mapToGlobal(position)) \ No newline at end of file + menu.exec_(self.table_view.viewport().mapToGlobal(position)) diff --git a/src/petab_gui/views/find_replace_bar.py b/src/petab_gui/views/find_replace_bar.py new file mode 100644 index 0000000..589587a --- /dev/null +++ b/src/petab_gui/views/find_replace_bar.py @@ -0,0 +1,330 @@ +import logging +import re + +import qtawesome as qta +from PySide6.QtCore import Qt +from PySide6.QtGui import QAction +from PySide6.QtWidgets import ( + QCheckBox, + QHBoxLayout, + QLabel, + QLineEdit, + QMenu, + QPushButton, + QToolButton, + QVBoxLayout, + QWidget, +) + + +class FindReplaceBar(QWidget): + def __init__(self, controller, parent=None): + super().__init__(parent) + self.controller = controller + self.controller_map = { + "Observable Table": self.controller.observable_controller, + "Condition Table": self.controller.condition_controller, + "Parameter Table": self.controller.parameter_controller, + "Measurement Table": self.controller.measurement_controller, + } + self.selected_controllers = set() + self.only_search = False + self.matches = None + + # 🔍 Find Input with options + self.find_input = QLineEdit() + self.find_input.setPlaceholderText("Find...") + self.find_input.textChanged.connect(self.run_find) + + self.case_sensitive_button = QToolButton() + self.case_sensitive_button.setIcon(qta.icon("mdi6.format-letter-case")) + self.case_sensitive_button.setCheckable(True) + self.case_sensitive_button.toggled.connect(self.run_find) + + self.word_match_button = QToolButton() + self.word_match_button.setIcon(qta.icon("mdi6.alpha-w")) + self.word_match_button.setCheckable(True) + self.word_match_button.toggled.connect(self.run_find) + + self.regex_button = QToolButton() + self.regex_button.setIcon(qta.icon("mdi6.regex")) + self.regex_button.setCheckable(True) + self.regex_button.toggled.connect(self.run_find) + + find_layout = QHBoxLayout() + find_layout.addWidget(self.find_input) + find_layout.addWidget(self.case_sensitive_button) + find_layout.addWidget(self.word_match_button) + find_layout.addWidget(self.regex_button) + + # 🔄 Replace Input + self.replace_input = QLineEdit() + self.replace_input.setPlaceholderText("Replace...") + + replace_layout = QHBoxLayout() + replace_layout.addWidget(self.replace_input) + + # 🔘 Action Buttons (Navigation, Results, Replace, Close) + self.prev_button = QPushButton() + self.prev_button.setIcon(qta.icon("mdi6.arrow-up")) + self.next_button = QPushButton() + self.next_button.setIcon(qta.icon("mdi6.arrow-down")) + self.prev_button.clicked.connect(self.find_previous) + self.next_button.clicked.connect(self.find_next) + + self.results_label = QLabel("0 results") + self.filter_button = QPushButton() + self.filter_button.setIcon(qta.icon("mdi6.filter")) + self.close_button = QPushButton() + self.filter_button.clicked.connect(self.show_filter_menu) + self.filter_menu = QMenu(self) # Dropdown menu + self.filter_actions = {} + action = QAction("All", self.filter_menu) + action.setCheckable(True) + action.setChecked(True) + action.triggered.connect(self.update_selected_controllers) + self.filter_menu.addAction(action) + self.filter_actions["All"] = action + for table_name in self.controller_map: + action = QAction(table_name, self.filter_menu) + action.setCheckable(True) + action.triggered.connect(self.update_selected_controllers) + self.filter_menu.addAction(action) + self.filter_actions[table_name] = action + self.close_button.setIcon(qta.icon("mdi6.close")) + self.close_button.clicked.connect(self.hide) + + self.replace_button = QPushButton("Replace") + self.replace_button.clicked.connect(self.replace_current_match) + self.replace_all_button = QPushButton("Replace All") + self.replace_all_button.clicked.connect(self.replace_all) + + find_controls_layout = QHBoxLayout() + find_controls_layout.addWidget(self.results_label) + find_controls_layout.addWidget(self.prev_button) + find_controls_layout.addWidget(self.next_button) + find_controls_layout.addWidget(self.filter_button) + find_controls_layout.addWidget(self.close_button) + + replace_controls_layout = QHBoxLayout() + replace_controls_layout.addWidget(self.replace_button) + replace_controls_layout.addWidget(self.replace_all_button) + + # 🔹 Main Layout + self.layout_main = QHBoxLayout() + self.layout_edits = QVBoxLayout() + self.layout_options = QVBoxLayout() + + self.layout_edits.addLayout(find_layout) + self.layout_edits.addLayout(replace_layout) + + self.layout_options.addLayout(find_controls_layout) + self.layout_options.addLayout(replace_controls_layout) + + self.layout_main.addLayout(self.layout_edits) + self.layout_main.addLayout(self.layout_options) + self.setLayout(self.layout_main) + + def run_find(self): + """Triggered when the search text changes.""" + search_text = self.find_input.text() + case_sensitive = self.case_sensitive_button.isChecked() + regex = self.regex_button.isChecked() + whole_cell = self.word_match_button.isChecked() + + self.matches = [] + self.current_match_ind = -1 + + for controller in [ + self.controller.observable_controller, + self.controller.condition_controller, + self.controller.parameter_controller, + self.controller.measurement_controller, + ]: + matches = controller.find_text( + search_text, case_sensitive, regex, whole_cell + ) + self.matches.extend( + [(match[0], match[1], controller) for match in matches] + ) # Extend match with controller + + if self.matches: + self.current_match_ind = 0 + self.focus_match(self.matches[self.current_match_ind]) + + self.update_result_label() + + def find_next(self): + """Move to the next match.""" + if not self.matches: + return + __, _, controller = self.matches[self.current_match_ind] + controller.focus_match(None) + self.current_match_ind = (self.current_match_ind + 1) % len( + self.matches + ) + row, col, controller = self.matches[self.current_match_ind] + controller.focus_match((row, col), with_focus=True) + self.update_result_label() + + def find_previous(self): + """Move to the previous match.""" + if not self.matches: + return + __, _, controller = self.matches[self.current_match_ind] + controller.focus_match(None) + self.current_match_ind = (self.current_match_ind - 1) % len( + self.matches + ) + row, col, controller = self.matches[self.current_match_ind] + controller.focus_match((row, col), with_focus=True) + self.update_result_label() + + def update_result_label(self): + """Update the result label dynamically.""" + match_count = len(self.matches) + self.results_label.setText( + f"{self.current_match_ind + 1}/{match_count}" + if match_count > 0 + else "0 results" + ) + + def replace_current_match(self): + """Replace the currently selected match and move to the next one.""" + if not self.matches or self.current_match_ind == -1: + return + + replace_text = self.replace_input.text() + if not replace_text: + return + + row, col, controller = self.matches[ + self.current_match_ind + ] # Extract controller from match + + controller.replace_text( + row=row, + col=col, + replace_text=replace_text, + search_text=self.find_input.text(), + case_sensitive=self.case_sensitive_button.isChecked(), + regex=self.regex_button.isChecked(), + ) + # drop the current match and update the result label + self.matches.pop(self.current_match_ind) + self.update_result_label() + match = self.matches[self.current_match_ind] if self.matches else None + self.focus_match(match, with_focus=True) + + def replace_all(self): + """Replace all matches with the given text.""" + if not self.matches: + return + + replace_text = self.replace_input.text() + search_text = self.find_input.text() + case_sensitive = self.case_sensitive_button.isChecked() + regex = self.regex_button.isChecked() + + controllers = { + match[2] for match in self.matches + } # Get unique controllers + + for controller in controllers: + controller.replace_all( + search_text, replace_text, case_sensitive, regex + ) + # emit dataChanged emit for each match + for row, col, controller in self.matches: + controller.model.dataChanged.emit( + controller.model.index(row, col), + controller.model.index(row, col), + ) + controller.cleanse_highlighted_cells() + self.run_find() + + def focus_match(self, match, with_focus: bool = False): + """Focus the match in the correct table.""" + if not match: + return + row, col, controller = match + controller.focus_match((row, col), with_focus) + + def show_filter_menu(self): + """Show the filter selection dropdown below the filter button.""" + self.filter_menu.exec_( + self.filter_button.mapToGlobal( + self.filter_button.rect().bottomLeft() + ) + ) + + def update_selected_controllers(self): + """Update which tables are included in the search.""" + if self.filter_actions["All"].isChecked(): + self.selected_controllers = set(self.controller_map.values()) + else: + self.selected_controllers = { + self.controller_map[table_name] + for table_name, action in self.filter_actions.items() + if action.isChecked() and (table_name != "All") + self.run_find() + + def keyPressEvent(self, event): + if event.key() == Qt.Key_Escape: + self.hide() + event.accept() + else: + super().keyPressEvent(event) + + def hideEvent(self, event): + """Reset highlights when the Find/Replace bar is hidden.""" + for controller in self.selected_controllers: + controller.cleanse_highlighted_cells() + super().hideEvent(event) + + def showEvent(self, event): + """Reset highlights when the Find/Replace bar is shown.""" + # group matches by controller + if not self.matches: + super().showEvent(event) + return + for controller in [match[2] for match in self.matches]: + matches = [ + (match[0], match[1]) + for match in self.matches + if match[2] == controller + ] + controller.highlight_text(matches) + super().showEvent(event) + + def show_replace_parts(self, show: bool = False): + """Toggle the visibility of the replace parts.""" + self.replace_input.setVisible(show) + self.replace_button.setVisible(show) + self.replace_all_button.setVisible(show) + + def toggle_find(self): + """Toggle behaviour of the search bar.""" + if not self.isVisible(): + self.show() + self.show_replace_parts(False) + self.only_search = True + return + if not self.only_search: + self.show_replace_parts(False) + self.only_search = True + return + self.hide() + + def toggle_replace(self): + """Toggle behaviour of the replace bar.""" + if not self.isVisible(): + self.show() + self.show_replace_parts(True) + self.only_search = False + return + if self.only_search: + self.show_replace_parts(True) + self.only_search = False + return + self.hide() diff --git a/src/petab_gui/views/logger.py b/src/petab_gui/views/logger.py index bad717c..14c7d40 100644 --- a/src/petab_gui/views/logger.py +++ b/src/petab_gui/views/logger.py @@ -2,7 +2,8 @@ Contains logger widget as well as two helper buttons. """ -from PySide6.QtWidgets import QTextBrowser, QHBoxLayout, QWidget + +from PySide6.QtWidgets import QHBoxLayout, QTextBrowser, QWidget class Logger(QWidget): diff --git a/src/petab_gui/views/main_view.py b/src/petab_gui/views/main_view.py index 0926a40..ecfdf7a 100644 --- a/src/petab_gui/views/main_view.py +++ b/src/petab_gui/views/main_view.py @@ -1,14 +1,23 @@ """Main Window View.""" -from PySide6.QtWidgets import (QMainWindow, QDockWidget, - QWidget, QVBoxLayout, QTabWidget, QSizePolicy) -from PySide6.QtCore import Qt, QSettings -from .sbml_view import SbmlViewer -from .table_view import TableViewer -from ..utils import FindReplaceBar + +import copy + +from PySide6.QtCore import QSettings, Qt +from PySide6.QtWidgets import ( + QDockWidget, + QMainWindow, + QSizePolicy, + QTabWidget, + QVBoxLayout, + QWidget, +) + +from ..settings_manager import settings_manager +from .find_replace_bar import FindReplaceBar from .logger import Logger from .measurement_plot import MeasuremenPlotter -from ..settings_manager import settings_manager -import copy +from .sbml_view import SbmlViewer +from .table_view import TableViewer class MainWindow(QMainWindow): @@ -47,7 +56,6 @@ def __init__(self): self.logger_dock.setWidget(self.logger_views[1]) self.plot_dock = MeasuremenPlotter(self) - # Connect the visibility changes of the QDockWidget instances to a slot that saves their visibility status self.dock_visibility = { self.condition_dock: self.condition_dock.isVisible(), self.measurement_dock: self.measurement_dock.isVisible(), @@ -69,17 +77,11 @@ def __init__(self): self.parameter_dock.visibilityChanged.connect( self.save_dock_visibility ) - self.logger_dock.visibilityChanged.connect( - self.save_dock_visibility - ) - self.plot_dock.visibilityChanged.connect( - self.save_dock_visibility - ) + self.logger_dock.visibilityChanged.connect(self.save_dock_visibility) + self.plot_dock.visibilityChanged.connect(self.save_dock_visibility) # Allow docking in multiple areas - self.data_tab.setDockOptions( - QMainWindow.AllowNestedDocks - ) + self.data_tab.setDockOptions(QMainWindow.AllowNestedDocks) self.tab_widget.currentChanged.connect(self.set_docks_visible) @@ -126,9 +128,7 @@ def default_view(self): dock.setGeometry(x, y, width, height) dock.setFloating(False) if x == x_right: - self.data_tab.splitDockWidget( - y, dock, Qt.Horizontal - ) + self.data_tab.splitDockWidget(y, dock, Qt.Horizontal) if hasattr(self, "dock_visibility"): for dock in self.dock_visibility: @@ -149,7 +149,7 @@ def dropEvent(self, event): return event.ignore() - + def setup_toolbar(self, actions): # add a toolbar with actions from self.task_bar tb = self.addToolBar("MainToolbar") @@ -172,7 +172,7 @@ def setup_toolbar(self, actions): tb.addWidget(actions["filter_widget"]) def add_menu_action(self, dock_widget, name): - """Helper function to add actions to the menu for showing dock widgets""" + """Add actions to the menu to show dock widgets.""" action = self.view_menu.addAction(name) action.setCheckable(True) action.setChecked(True) @@ -184,7 +184,7 @@ def add_menu_action(self, dock_widget, name): dock_widget.visibilityChanged.connect(action.setChecked) def save_dock_visibility(self, visible): - """Slot to save the visibility status of a QDockWidget when it changes""" + """Save the visibility status of a QDockWidget when it changes.""" # if current tab is not the data tab return if self.tab_widget.currentIndex() != 0: return @@ -192,15 +192,13 @@ def save_dock_visibility(self, visible): self.dock_visibility[dock] = dock.isVisible() def set_docks_visible(self, index): - """Slot to set all QDockWidget instances to their previous visibility - when the "Data Tables" tab is not selected.""" + """Set all QDockWidgets to their previous visibility on tab-change.""" if index != 0: # Another tab is selected for dock, visible in self.dock_visibility.items(): dock.setVisible(visible) def closeEvent(self, event): - """Override the closeEvent to emit a signal and let the controller handle it.""" - # Emit the signal to let the controller decide what to do + """Override the closeEvent to emit additional signal.""" self.controller.maybe_close() if self.allow_close: @@ -215,7 +213,9 @@ def load_settings(self): # Load the visibility of the dock widgets for dock, _ in self.dock_visibility.items(): - dock.setVisible(settings.value(f"docks/{dock.objectName()}", True, type=bool)) + dock.setVisible( + settings.value(f"docks/{dock.objectName()}", True, type=bool) + ) # Load the geometry of the main window self.restoreGeometry(settings.value("main_window/geometry")) @@ -246,7 +246,10 @@ def save_settings(self): settings.setValue("data_tab/state", self.data_tab.saveState()) def create_find_replace_bar(self): - """Create the find/replace bar and add it without replacing the tab widget.""" + """Create the find/replace bar. + + Add it without replacing the tab widget. + """ self.find_replace_bar = FindReplaceBar(self.controller, self) # manually create a copy of the dock visibility dock_visibility_values = copy.deepcopy( @@ -262,16 +265,16 @@ def create_find_replace_bar(self): self.setCentralWidget(container) # Restore the visibility of the docks - for dock, visible in zip(self.dock_visibility.keys(), dock_visibility_values): + for dock, visible in zip( + self.dock_visibility.keys(), dock_visibility_values, strict=False + ): self.dock_visibility[dock] = visible dock.setVisible(visible) def toggle_find(self): """Toggles the find-part of the Find.Replace Bar.""" - # self.find_replace_bar.toggle_find() self.find_replace_bar.toggle_find() def toggle_replace(self): """Toggles the replace-part of the Find.Replace Bar.""" - # self.find_replace_bar.toggle_replace() self.find_replace_bar.toggle_replace() diff --git a/src/petab_gui/views/measurement_plot.py b/src/petab_gui/views/measurement_plot.py index e09b2f1..13f7af8 100644 --- a/src/petab_gui/views/measurement_plot.py +++ b/src/petab_gui/views/measurement_plot.py @@ -1,9 +1,12 @@ """File containing the measurement plot widget.""" -from PySide6.QtWidgets import QDockWidget, QVBoxLayout, QWidget -from matplotlib.backends.backend_qt5agg import NavigationToolbar2QT + import matplotlib.pyplot as plt +from matplotlib.backends.backend_qt5agg import NavigationToolbar2QT +from PySide6.QtWidgets import QDockWidget, QVBoxLayout, QWidget + from ..utils import PlotWidget + class MeasuremenPlotter(QDockWidget): def __init__(self, parent=None): super().__init__("Data Plot", parent) @@ -38,28 +41,35 @@ def update_visualization(self, plot_data=None): # Plot all data points with lower alpha for unselected points for idx, data in enumerate(plot_data["all_data"]): color = color_map(idx) - handle, = self.plot_widget.axes.plot( - data["x"], data["y"], 'o--', - color=color, alpha=0.5, - label=data["observable_id"] + (handle,) = self.plot_widget.axes.plot( + data["x"], + data["y"], + "o--", + color=color, + alpha=0.5, + label=data["observable_id"], ) handles.append(handle) labels.append(data["observable_id"]) # Plot selected points with full alpha - for idx, (observable_id, points) in enumerate(plot_data["selected_points"].items()): + for idx, (observable_id, points) in enumerate( + plot_data["selected_points"].items() + ): color = color_map(idx) selected_x = [point["x"] for point in points] selected_y = [point["y"] for point in points] - selected_handle, = self.plot_widget.axes.plot( + (selected_handle,) = self.plot_widget.axes.plot( selected_x, - selected_y, 'o', - color=color, alpha=1, - label=f"{observable_id} (selected)" + selected_y, + "o", + color=color, + alpha=1, + label=f"{observable_id} (selected)", ) handles.append(selected_handle) labels.append(f"{observable_id} (selected)") # Add legend self.plot_widget.axes.legend(handles=handles, labels=labels) - self.plot_widget.draw() \ No newline at end of file + self.plot_widget.draw() diff --git a/src/petab_gui/views/sbml_view.py b/src/petab_gui/views/sbml_view.py index 07ab9f3..f7efe8b 100644 --- a/src/petab_gui/views/sbml_view.py +++ b/src/petab_gui/views/sbml_view.py @@ -1,11 +1,18 @@ -"""Widget for viewing the SBML model""" -from PySide6.QtWidgets import QVBoxLayout, QPlainTextEdit, QSplitter, \ - QWidget, QPushButton, QLabel +"""Widget for viewing the SBML model.""" + from PySide6.QtCore import Qt +from PySide6.QtWidgets import ( + QLabel, + QPlainTextEdit, + QPushButton, + QSplitter, + QVBoxLayout, + QWidget, +) class SbmlViewer(QWidget): - """Widget for viewing the SBML model""" + """Widget for viewing the SBML model.""" def __init__(self, parent=None, logger_view=None): super().__init__(parent) diff --git a/src/petab_gui/views/table_view.py b/src/petab_gui/views/table_view.py index 87c50a0..d89452f 100644 --- a/src/petab_gui/views/table_view.py +++ b/src/petab_gui/views/table_view.py @@ -1,23 +1,37 @@ -from PySide6.QtWidgets import (QDockWidget, QHeaderView, QTableView, - QCompleter, QLineEdit, QStyledItemDelegate, - QComboBox) -from PySide6.QtCore import Qt, QPropertyAnimation, QRect -from PySide6.QtGui import QGuiApplication, QColor +from PySide6.QtCore import QPropertyAnimation, QRect, Qt +from PySide6.QtGui import QColor, QGuiApplication +from PySide6.QtWidgets import ( + QComboBox, + QCompleter, + QDockWidget, + QHeaderView, + QLineEdit, + QStyledItemDelegate, + QTableView, +) from ..utils import get_selected_rectangles from .context_menu_mananger import ContextMenuManager -import re -import pandas as pd class TableViewer(QDockWidget): + """A dock widget that contains a table view for displaying tabular data. + + This class provides a container for a CustomTableView and handles clipboard + operations for copying and pasting data. + """ + def __init__(self, title, parent=None): + """Initialize the table viewer. + + Args: + title: The title of the dock widget + parent: The parent widget + """ super().__init__(title, parent) self.title = title self.setObjectName(title) - self.setAllowedAreas( - Qt.AllDockWidgetAreas - ) + self.setAllowedAreas(Qt.AllDockWidgetAreas) # Create the QTableView for the table content self.table_view = CustomTableView() self.setWidget(self.table_view) @@ -26,9 +40,13 @@ def __init__(self, title, parent=None): self.table_view.setAlternatingRowColors(True) def copy_to_clipboard(self): - selected_rect, rect_start = get_selected_rectangles( - self.table_view - ) + """Copy selected cells to the clipboard. + + Gets the selected cells from the table view and copies their content + to the system clipboard in a format suitable for pasting into other + applications. + """ + 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 @@ -37,6 +55,18 @@ def copy_to_clipboard(self): clipboard.setMimeData(mime_data) def paste_from_clipboard(self): + """Paste clipboard content into the table. + + Retrieves text from the system clipboard and pastes it into the table + starting at the current selection. The text is parsed as tab-separated + values, with each line representing a row. + + The method handles: + - Mapping between proxy and source models + - Parsing clipboard data into rows and columns + - Identifying and handling cells that will be overridden + - Handling invalid cells that might be affected by the paste operation + """ clipboard = QGuiApplication.clipboard() text = clipboard.text() if not text: @@ -56,8 +86,9 @@ def paste_from_clipboard(self): row_start, col_start = source_index.row(), source_index.column() # Parse clipboard data - pasted_data = [line.split("\t") for line in text.split("\n") if - line.strip()] + pasted_data = [ + line.split("\t") for line in text.split("\n") if line.strip() + ] num_rows = len(pasted_data) num_cols = max(len(line) for line in pasted_data) @@ -72,7 +103,8 @@ def paste_from_clipboard(self): # Handle invalid cells if hasattr(source_model, "_invalid_cells"): invalid_overridden_cells = overridden_cells.intersection( - source_model._invalid_cells) + source_model._invalid_cells + ) for row_invalid, col_invalid in invalid_overridden_cells: source_model.discard_invalid_cell(row_invalid, col_invalid) @@ -81,29 +113,66 @@ def paste_from_clipboard(self): class ComboBoxDelegate(QStyledItemDelegate): + """A delegate that provides a combo box for editing table cells. + + This delegate is used to provide a dropdown list of predefined options + when editing cells in a table view. + """ + def __init__(self, options, parent=None): + """Initialize the combo box delegate. + + Args: + options: A list of strings to display in the combo box + parent: The parent widget + """ super().__init__(parent) self.options = options def createEditor(self, parent, option, index): - # Create a QComboBox for inline editing + """Create a QComboBox for inline editing. + + Args: + parent: The parent widget for the editor + option: The style options for the editor + index: The model index of the cell being edited + + Returns: + QComboBox: A combo box containing the predefined options + """ editor = QComboBox(parent) editor.addItems(self.options) return editor class SingleSuggestionDelegate(QStyledItemDelegate): - """Suggest a single option based the current row and the value in - `column_name`.""" + """ + Single Option Suggestion Delegate. + + Suggest a single option based the current row and the value in + `column_name`. + """ def __init__(self, model, suggestions_column, afix=None, parent=None): + """Initialize the single suggestion delegate. + + Args: + model: + The data model to retrieve suggestions from + suggestions_column: + The column name or index to get suggestion values from + afix: + Optional prefix to add to the suggestion value + parent: + The parent widget + """ super().__init__(parent) self.model = model # The main model to retrieve data from self.suggestions_column = suggestions_column self.afix = afix def createEditor(self, parent, option, index): - # Create a QLineEdit for inline editing + """Create a QLineEdit for inline editing with a single suggestion.""" editor = QLineEdit(parent) # Get the conditionId of the current row @@ -124,20 +193,33 @@ def createEditor(self, parent, option, index): class ColumnSuggestionDelegate(QStyledItemDelegate): """Suggest options based on all unique values in the specified column.""" + def __init__( self, model, suggestions_column, suggestion_mode=QCompleter.PopupCompletion, - parent=None + parent=None, ): + """Initialize the column suggestion delegate. + + Args: + model: + The data model to retrieve suggestions from + suggestions_column: + The column name or index to get unique values from + suggestion_mode: + The completion mode for the QCompleter (default: PopupCompletion) + parent: + The parent widget + """ super().__init__(parent) self.model = model # The main model to retrieve data from self.suggestions_column = suggestions_column self.suggestion_mode = suggestion_mode def createEditor(self, parent, option, index): - # Create a QLineEdit for inline editing + """Create a QLineEdit for inline editing with suggestions.""" editor = QLineEdit(parent) # Get unique suggestions from the specified column @@ -153,13 +235,24 @@ def createEditor(self, parent, option, index): class ParameterIdSuggestionDelegate(QStyledItemDelegate): """Suggest options based on all unique values in the specified column.""" + def __init__(self, par_model, sbml_model, parent=None): + """Initialize the parameter ID suggestion delegate. + + Args: + par_model: + The parameter table model to retrieve current parameter IDs from + sbml_model: + The SBML model to retrieve valid parameter suggestions from + parent: + The parent widget + """ super().__init__(parent) self.par_model = par_model self.sbml_model = sbml_model # The main model to retrieve data from def createEditor(self, parent, option, index): - # Create a QLineEdit for inline editing + """Create an editor for the parameterId column.""" editor = QLineEdit(parent) # Get unique suggestions from the specified column @@ -170,7 +263,7 @@ def createEditor(self, parent, option, index): # substract the current parameter ids except for the current row row = index.row() selected_parameter_id = self.par_model.get_value_from_column( - 'parameterId', row + "parameterId", row ) current_parameter_ids = self.par_model.get_df().index.tolist() if selected_parameter_id in current_parameter_ids: @@ -189,6 +282,14 @@ class CustomTableView(QTableView): """Custom Table View to Handle Copy Paste events, resizing policies etc.""" def __init__(self, parent=None): + """Initialize the custom table view. + + Sets up the table view with appropriate size adjustment policies, + resize modes, and connects signals for column resizing. + + Args: + parent: The parent widget + """ super().__init__(parent) self.setSizeAdjustPolicy(QTableView.AdjustToContents) self.horizontalHeader().setSectionResizeMode( @@ -203,7 +304,14 @@ def __init__(self, parent=None): ) def setup_context_menu(self, actions): - """Setup the context menu for the table view.""" + """Setup the context menu for the table view. + + Creates a context menu manager and connects it to the table view's + context menu request signal. + + Args: + actions: A list of QAction objects to include in the context menu + """ self.context_menu_manager = ContextMenuManager( actions, self, self.parent ) @@ -213,13 +321,32 @@ def setup_context_menu(self, actions): ) def setModel(self, model): - """Ensures selection model exists before connecting signals""" + """Set the model for the table view. + + Overrides the base class method to ensure that signals are connected + only after the selection model exists. + + Args: + model: The model to set for this view + """ super().setModel(model) if self.selectionModel(): - self.selectionModel().currentColumnChanged.connect(self.highlight_active_column) + self.selectionModel().currentColumnChanged.connect( + self.highlight_active_column + ) def reset_column_sizes(self): - """Resets column sizes with refinements""" + """Reset column sizes with intelligent width adjustments. + + This method: + 1. Initially resizes all columns to fit their content + 2. Enforces a maximum width (1/4 of the viewport width) for any column + 3. Collapses empty columns to save space + 4. Updates the table geometry to reflect the new column sizes + + The result is a table with columns, appropriately sized for their + content while maintaining a reasonable overall width. + """ header = self.horizontalHeader() total_width = self.viewport().width() max_width = total_width // 4 # 1/4th of total table width @@ -236,52 +363,95 @@ def reset_column_sizes(self): else: self.setColumnWidth(col, optimal_width) - # self.adjust_for_empty_neighbors() self.collapse_empty_columns() self.updateGeometry() def adjust_for_empty_neighbors(self): - """Expands column if adjacent columns are empty""" + """Expands column if adjacent columns are empty.""" model = self.model() for col in range(model.columnCount()): - if self.columnWidth(col) == self.viewport().width() // 4: # If maxed out + if ( + self.columnWidth(col) == self.viewport().width() // 4 + ): # If maxed out next_col = col + 1 - if next_col < model.columnCount(): - if all(model.index(row, next_col).data() in [None, ""] for row in range(model.rowCount())): - new_width = self.columnWidth( - col) + self.columnWidth(next_col) - self.setColumnWidth(col, new_width) - self.setColumnWidth(next_col, 0) # Hide empty column + if next_col < model.columnCount() and all( + model.index(row, next_col).data() in [None, ""] + for row in range(model.rowCount()) + ): + new_width = self.columnWidth(col) + self.columnWidth( + next_col + ) + self.setColumnWidth(col, new_width) + self.setColumnWidth(next_col, 0) # Hide empty column def collapse_empty_columns(self): - """Collapses columns that only contain empty values""" + """Collapses columns that only contain empty values.""" model = self.model() for col in range(model.columnCount()): - if all(model.index(row, col).data() in [None, "", " "] for row in - range(model.rowCount())): + if all( + model.index(row, col).data() in [None, "", " "] + for row in range(model.rowCount()) + ): self.setColumnWidth(col, 10) # Minimal width def autofit_column(self, col): - """Expands column width on double-click""" - self.horizontalHeader().setSectionResizeMode(col, - QHeaderView.ResizeToContents) + """Expands column width to fit its content when double-clicked. + + Temporarily sets the column's resize mode to ResizeToContents, + resizes it, then sets it back to Interactive mode to allow manual + resizing. + + Args: + col: The index of the column to resize + """ + self.horizontalHeader().setSectionResizeMode( + col, QHeaderView.ResizeToContents + ) self.resizeColumnToContents(col) - self.horizontalHeader().setSectionResizeMode(col, - QHeaderView.Interactive) + self.horizontalHeader().setSectionResizeMode( + col, QHeaderView.Interactive + ) def highlight_active_column(self, index): - """Highlights the active column""" + """Highlights the active column with a light blue background. + + This method is connected to the selectionModel's currentColumnChanged + signal and applies a background color to all cells in the column of + the current index. + + Args: + index: The model index of the currently selected cell + """ for row in range(self.model().rowCount()): - self.model().setData(self.model().index(row, index.column()), - QColor("#cce6ff"), Qt.BackgroundRole) + self.model().setData( + self.model().index(row, index.column()), + QColor("#cce6ff"), + Qt.BackgroundRole, + ) def animate_column_resize(self, col, new_width): - """Smoothly animates column resizing""" + """Smoothly animates column resizing with a visual transition effect. + + Creates a QPropertyAnimation that gradually changes the column width + from its current value to the new width over a short duration. + + Args: + col: The index of the column to resize + new_width: The target width for the column in pixels + """ anim = QPropertyAnimation(self, b"geometry") anim.setDuration(200) - anim.setStartValue(QRect(self.columnViewportPosition(col), 0, - self.columnWidth(col), self.height())) + anim.setStartValue( + QRect( + self.columnViewportPosition(col), + 0, + self.columnWidth(col), + self.height(), + ) + ) anim.setEndValue( - QRect(self.columnViewportPosition(col), 0, new_width, - self.height())) + QRect( + self.columnViewportPosition(col), 0, new_width, self.height() + ) + ) anim.start() diff --git a/src/petab_gui/views/task_bar.py b/src/petab_gui/views/task_bar.py index f65228e..0137028 100644 --- a/src/petab_gui/views/task_bar.py +++ b/src/petab_gui/views/task_bar.py @@ -1,10 +1,11 @@ -from PySide6.QtWidgets import QMenu -from PySide6.QtGui import QAction import qtawesome as qta +from PySide6.QtGui import QAction +from PySide6.QtWidgets import QMenu class BasicMenu: """Base class for a TaskBar Menu.""" + def __init__(self, parent, actions): self.menu = QMenu(self.menu_name(), parent) self.parent = parent @@ -14,7 +15,8 @@ def add_action_or_menu( ): """Add an action or a menu to the menu. - If no menu is provided, the action is added to the main menu.""" + If no menu is provided, the action is added to the main menu. + """ if menu is None: menu = self.menu if is_action: @@ -39,6 +41,7 @@ def menu_name(self): class FileMenu(BasicMenu): """Class for the file menu.""" + def menu_name(self): return "&File" @@ -59,6 +62,7 @@ class EditMenu(BasicMenu): # TODO: Add actions to the setup actions (Requires fix of those, will be # done in the next PR) """Edit Menu of the TaskBar.""" + def menu_name(self): return "&Edit" @@ -95,6 +99,7 @@ def __init__(self, parent, actions): class ViewMenu(BasicMenu): """View Menu of the TaskBar.""" + def menu_name(self): return "&View" @@ -102,9 +107,7 @@ def __init__(self, parent, actions): super().__init__(parent, actions) # Add actions to the menu for re-adding tables - visibility_header = QAction( - qta.icon("fa5s.eye"), "Visibility", parent - ) + visibility_header = QAction(qta.icon("fa5s.eye"), "Visibility", parent) visibility_header.setEnabled(False) self.menu.addAction(visibility_header) self.menu.addSeparator() @@ -121,6 +124,7 @@ def __init__(self, parent, actions): class HelpMenu(BasicMenu): """Help Menu of the TaskBar.""" + def menu_name(self): return "&Help" @@ -131,9 +135,9 @@ def __init__(self, parent, actions): self.menu.addAction(actions["open_documentation"]) - class TaskBar: """TaskBar of the PEtab Editor.""" + def add_menu(self, menu_class, actions): """Add a menu to the task bar.""" menu = menu_class(self.parent, actions)