From 78fec93418dcc2033e803d811beb4f07954cbe30 Mon Sep 17 00:00:00 2001 From: alexhroom Date: Mon, 25 Nov 2024 15:32:57 +0000 Subject: [PATCH 01/10] working widget except layer add remove --- pyproject.toml | 3 +- rascal2/widgets/delegates.py | 9 +- rascal2/widgets/project/lists.py | 397 +++++++++++++++++++++++++++ rascal2/widgets/project/models.py | 10 +- rascal2/widgets/project/project.py | 9 +- tests/widgets/project/test_models.py | 12 +- 6 files changed, 427 insertions(+), 13 deletions(-) create mode 100644 rascal2/widgets/project/lists.py diff --git a/pyproject.toml b/pyproject.toml index b470c74..1572074 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -52,5 +52,6 @@ extend-ignore-names = ['allKeys', 'showEvent', 'sizeHint', 'stepBy', + 'supportedDropActions', 'textFromValue', - 'valueFromText',] \ No newline at end of file + 'valueFromText',] diff --git a/rascal2/widgets/delegates.py b/rascal2/widgets/delegates.py index a044081..07775f9 100644 --- a/rascal2/widgets/delegates.py +++ b/rascal2/widgets/delegates.py @@ -113,17 +113,18 @@ def setModelData(self, editor, model, index): model.setData(index, data, QtCore.Qt.ItemDataRole.EditRole) -class ParametersDelegate(QtWidgets.QStyledItemDelegate): +class ProjectFieldDelegate(QtWidgets.QStyledItemDelegate): """Item delegate to choose from existing draft project parameters.""" - def __init__(self, project_widget, parent): + def __init__(self, project_widget, field, parent): super().__init__(parent) + self.field = field self.project_widget = project_widget def createEditor(self, parent, option, index): widget = QtWidgets.QComboBox(parent) - parameters = self.project_widget.draft_project["parameters"] - names = [p.name for p in parameters] + items = self.project_widget.draft_project[self.field] + names = [item.name for item in items] widget.addItems(names) widget.setCurrentText(index.data(QtCore.Qt.ItemDataRole.DisplayRole)) diff --git a/rascal2/widgets/project/lists.py b/rascal2/widgets/project/lists.py new file mode 100644 index 0000000..9263902 --- /dev/null +++ b/rascal2/widgets/project/lists.py @@ -0,0 +1,397 @@ +"""Tab model/views which are based on a list at the side of the widget.""" + +from typing import Any, Callable, Generic, TypeVar + +import RATapi +from PyQt6 import QtCore, QtGui, QtWidgets +from RATapi.utils.enums import BackgroundActions + +from rascal2.config import path_for +from rascal2.widgets.delegates import ProjectFieldDelegate + +T = TypeVar("T") + + +class ClassListItemModel(QtCore.QAbstractListModel, Generic[T]): + """Item model for a project ClassList field. + + Parameters + ---------- + classlist : ClassList + The initial classlist to represent in this model. + field : str + The name of the field represented by this model. + parent : QtWidgets.QWidget + The parent widget for the model. + + """ + + def __init__(self, classlist: RATapi.ClassList[T], parent: QtWidgets.QWidget): + super().__init__(parent) + self.parent = parent + + self.classlist = classlist + self.item_type = classlist._class_handle + self.edit_mode = False + + def rowCount(self, parent=None) -> int: + return len(self.classlist) + + def data(self, index, role=QtCore.Qt.ItemDataRole.DisplayRole) -> str: + if role == QtCore.Qt.ItemDataRole.DisplayRole: + row = index.row() + return self.classlist[row].name + + def get_item(self, row: int) -> T: + """Get an item from the ClassList. + + Parameters + ---------- + row : int + The index of the ClassList to get. + + Returns + ------- + T + The relevant item from the classlist. + + """ + return self.classlist[row] + + def set_data(self, row: int, param: str, value: Any): + """Set data for an item in the ClassList. + + Parameters + ---------- + row : int + The index of the ClassList to get. + param : str + The parameter of the item to change. + value : Any + The value to set the parameter to. + + """ + setattr(self.classlist[row], param, value) + self.endResetModel() + + def append_item(self): + """Append an item to the ClassList.""" + self.classlist.append(self.item_type()) + self.endResetModel() + + def delete_item(self, row: int): + """Delete an item in the ClassList. + + Parameters + ---------- + row : int + The row containing the item to delete. + + """ + self.classlist.pop(row) + self.endResetModel() + + +class AbstractProjectListWidget(QtWidgets.QWidget): + """An abstract base widget for editing items kept in a list.""" + + def __init__(self, field: str, parent): + super().__init__(parent) + self.field = field + self.parent = parent + self.project_widget = self.parent.parent + self.edit_mode = False + self.model = None + + layout = QtWidgets.QHBoxLayout() + + item_list = QtWidgets.QVBoxLayout() + + self.list = QtWidgets.QListView(parent) + self.list.setSizePolicy(QtWidgets.QSizePolicy.Policy.Minimum, QtWidgets.QSizePolicy.Policy.Expanding) + + buttons = QtWidgets.QHBoxLayout() + + self.add_button = QtWidgets.QPushButton("+") + self.add_button.setHidden(True) + self.add_button.pressed.connect(self.append_item) + buttons.addWidget(self.add_button) + + self.delete_button = QtWidgets.QPushButton(icon=QtGui.QIcon(path_for("delete.png"))) + self.delete_button.setHidden(True) + self.delete_button.pressed.connect(self.delete_item) + buttons.addWidget(self.delete_button) + + item_list.addWidget(self.list) + item_list.addLayout(buttons) + + layout.addLayout(item_list) + + self.item_view = QtWidgets.QScrollArea(parent) + self.item_view.setWidgetResizable(True) + layout.addWidget(self.item_view) + + self.setLayout(layout) + + def update_model(self, classlist): + """Update the list model to synchronise with the project field. + + Parameters + ---------- + classlist: RATapi.ClassList + The classlist to set in the model. + + """ + self.model = ClassListItemModel(classlist, self) + self.list.setModel(self.model) + # this signal changes the current contrast shown in the editor to be the currently highlighted list item + self.list.selectionModel().currentChanged.connect(lambda index, _: self.view_stack.setCurrentIndex(index.row())) + + self.update_item_view() + + def update_item_view(self): + """Update the item views to correspond with the list model.""" + + self.view_stack = QtWidgets.QStackedWidget(self) + + if self.model is not None: + # if there are no items, replace the widget with information + if self.model.rowCount() == 0: + self.view_stack = QtWidgets.QLabel( + "No contrasts are currently defined! Edit the project to add a contrast." + ) + self.view_stack.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter) + + for i in range(0, self.model.rowCount()): + if self.edit_mode: + widget = self.create_editor(i) + else: + widget = self.create_view(i) + self.view_stack.addWidget(widget) + + self.item_view.setWidget(self.view_stack) + + def edit(self): + """Update the view to be in edit mode.""" + self.add_button.setVisible(True) + self.delete_button.setVisible(True) + self.edit_mode = True + self.update_item_view() + + def append_item(self): + """Append an item to the model if the model exists.""" + if self.model is not None: + self.model.append_item() + + new_widget_index = self.model.rowCount() - 1 + # handle if no contrasts currently exist + if isinstance(self.view_stack, QtWidgets.QLabel): + self.view_stack = QtWidgets.QStackedWidget(self) + self.item_view.setWidget(self.view_stack) + + # add contrast viewer/editor to stack without resetting entire stack + if self.edit_mode: + self.view_stack.addWidget(self.create_editor(new_widget_index)) + else: + self.view_stack.addWidget(self.create_view(new_widget_index)) + + def delete_item(self): + """Delete the currently selected item.""" + if self.model is not None: + selection_model = self.list.selectionModel() + self.model.delete_item(selection_model.currentIndex().row()) + + self.update_item_view() + + def create_view(self, i: int) -> QtWidgets.QWidget: + """Create the view widget for a specific item. + + Parameters + ---------- + i : int + The index of the classlist item displayed by this widget. + + Returns + ------- + QtWidgets.QWidget + The widget that displays the classlist item. + + """ + raise NotImplementedError + + def create_editor(self, i: int) -> QtWidgets.QWidget: + """Create the edit widget for a specific item. + + Parameters + ---------- + i : int + The index of the classlist item displayed by this widget. + + Returns + ------- + QtWidgets.QWidget + The widget that allows the classlist item to be edited. + + """ + raise NotImplementedError + + +class LayerStringListModel(QtCore.QStringListModel): + """A string list that supports drag and drop.""" + + def flags(self, index): + # we disable ItemIsDropEnabled to disable overwriting of items via drop + flags = super().flags(index) + if index.isValid(): + flags &= ~QtCore.Qt.ItemFlag.ItemIsDropEnabled + + return flags + + def supportedDropActions(self): + return QtCore.Qt.DropAction.MoveAction + + +class ContrastModelWidget(QtWidgets.QWidget): + """Widget for contrast models.""" + + def __init__(self, init_list: list[str] = None, parent=None): + super().__init__(parent) + + self.model = LayerStringListModel(init_list, self) + self.list = QtWidgets.QListView(parent) + self.list.setModel(self.model) + self.list.setItemDelegateForColumn(0, ProjectFieldDelegate(parent.project_widget, "layers", self)) + self.list.setDragEnabled(True) + self.list.setAcceptDrops(True) + self.list.setDropIndicatorShown(True) + self.list.setDragDropOverwriteMode(False) + + layout = QtWidgets.QVBoxLayout() + layout.addWidget(self.list) + + self.setLayout(layout) + + +class ContrastWidget(AbstractProjectListWidget): + """Widget for viewing and editing Contrasts.""" + + def compose_widget(self, i: int, data_widget: Callable[[str], QtWidgets.QWidget]) -> QtWidgets.QWidget: + """Create the base grid layouts for the widget. + + Parameters + ---------- + i : int + The row of the contrasts list to display in this widget. + data_widget_creator : Callable[[str], QtWidgets.QWidget] + A function which takes a field name and returns the data widget for that field. + + Returns + ------- + QtWidgets.QWidget + The resulting widget for the item. + + """ + top_grid = QtWidgets.QGridLayout() + top_grid.addWidget(QtWidgets.QLabel("Contrast Name:"), 0, 0) + top_grid.addWidget(data_widget("name"), 0, 1, 1, -1) + + top_grid.addWidget(QtWidgets.QLabel("Background:"), 1, 0) + top_grid.addWidget(data_widget("background"), 1, 1, 1, 2) + top_grid.addWidget(QtWidgets.QLabel("Background Action:"), 1, 3) + top_grid.addWidget(data_widget("background_action"), 1, 4, 1, 2) + + top_grid.addWidget(QtWidgets.QLabel("Resolution:"), 2, 0) + top_grid.addWidget(data_widget("resolution"), 2, 1) + top_grid.addWidget(QtWidgets.QLabel("Scalefactor:"), 2, 2) + top_grid.addWidget(data_widget("scalefactor"), 2, 3) + top_grid.addWidget(QtWidgets.QLabel("Data:"), 2, 4) + top_grid.addWidget(data_widget("data"), 2, 5) + + top_grid.setVerticalSpacing(10) + + settings_row = QtWidgets.QHBoxLayout() + settings_row.addWidget(QtWidgets.QLabel("Use resampling:")) + resampling_checkbox = QtWidgets.QCheckBox() + resampling_checkbox.setChecked(self.model.get_item(i).resample) + resampling_checkbox.checkStateChanged.connect( + lambda s: self.model.set_data(i, "resample", (s == QtCore.Qt.CheckState.Checked)) + ) + settings_row.addWidget(resampling_checkbox) + settings_row.addStretch() + + model_grid = QtWidgets.QGridLayout() + model_grid.addWidget(QtWidgets.QLabel("Bulk in:"), 0, 0) + model_grid.addWidget(data_widget("bulk_in"), 0, 1) + model_grid.addWidget(QtWidgets.QLabel("Layers:"), 1, 0) + model_grid.addWidget(data_widget("model"), 1, 1) + model_grid.addWidget(QtWidgets.QLabel("Bulk out:"), 2, 0) + model_grid.addWidget(data_widget("bulk_out"), 2, 1) + + layout = QtWidgets.QVBoxLayout() + layout.addLayout(top_grid) + layout.addLayout(settings_row) + layout.addStretch() + layout.addLayout(model_grid) + layout.setContentsMargins(0, 0, 0, 0) + + widget = QtWidgets.QWidget(self) + widget.setLayout(layout) + + return widget + + def create_view(self, i: int) -> QtWidgets.QWidget: + def data_box(field: str) -> QtWidgets.QWidget: + """Create a read only line edit box for display.""" + current_data = getattr(self.model.get_item(i), field) + if field == "model": + widget = QtWidgets.QListWidget(parent=self) + widget.addItems(current_data) + else: + widget = QtWidgets.QLineEdit(current_data) + widget.setReadOnly(True) + + return widget + + return self.compose_widget(i, data_box) + + def create_editor(self, i: int) -> QtWidgets.QWidget: + self.comboboxes = {} + + def data_combobox(field: str) -> QtWidgets.QWidget: + current_data = getattr(self.model.get_item(i), field) + match field: + case "name": + widget = QtWidgets.QLineEdit(current_data) + widget.textChanged.connect(lambda text: self.model.set_data(i, "name", text)) + return widget + case "background_action": + widget = QtWidgets.QComboBox() + for action in BackgroundActions: + widget.addItem(str(action), action) + widget.setCurrentText(current_data) + widget.currentTextChanged.connect( + lambda: self.model.set_data(i, "background_action", widget.currentData()) + ) + return widget + case "model": + widget = ContrastModelWidget(current_data, self) + return widget + # all other cases are comboboxes with data from other widget tables + case "data" | "bulk_in" | "bulk_out": + project_field_name = field + pass + case _: + project_field_name = field + "s" + pass + + project_field = self.project_widget.draft_project[project_field_name] + combobox = QtWidgets.QComboBox(self) + items = [""] + [item.name for item in project_field] + combobox.addItems(items) + combobox.setCurrentText(current_data) + combobox.currentTextChanged.connect(lambda: self.model.set_data(i, field, combobox.currentText())) + combobox.setSizePolicy(QtWidgets.QSizePolicy.Policy.MinimumExpanding, QtWidgets.QSizePolicy.Policy.Fixed) + + return combobox + + return self.compose_widget(i, data_combobox) diff --git a/rascal2/widgets/project/models.py b/rascal2/widgets/project/models.py index 6a8d83d..61175aa 100644 --- a/rascal2/widgets/project/models.py +++ b/rascal2/widgets/project/models.py @@ -155,6 +155,10 @@ class ProjectFieldWidget(QtWidgets.QWidget): classlist_model = ClassListModel + # the model can change and disconnect, so we re-connect it + # to a signal here on each change + edited = QtCore.pyqtSignal() + def __init__(self, field: str, parent): super().__init__(parent) self.field = field @@ -183,6 +187,8 @@ def update_model(self, classlist): self.model = self.classlist_model(classlist, self) self.table.setModel(self.model) + self.model.dataChanged.connect(lambda: self.edited.emit()) + self.model.modelReset.connect(lambda: self.edited.emit()) self.table.hideColumn(0) self.set_item_delegates() header = self.table.horizontalHeader() @@ -415,7 +421,9 @@ def set_item_delegates(self): i, delegates.ValidatedInputDelegate(self.model.item_type.model_fields[header], self.table) ) else: - self.table.setItemDelegateForColumn(i, delegates.ParametersDelegate(self.project_widget, self.table)) + self.table.setItemDelegateForColumn( + i, delegates.ProjectFieldDelegate(self.project_widget, "parameters", self.table) + ) def set_absorption(self, absorption: bool): """Set whether the classlist uses AbsorptionLayers. diff --git a/rascal2/widgets/project/project.py b/rascal2/widgets/project/project.py index 8b85cf7..df4b8df 100644 --- a/rascal2/widgets/project/project.py +++ b/rascal2/widgets/project/project.py @@ -7,6 +7,7 @@ from RATapi.utils.enums import Calculations, Geometries, LayerModels from rascal2.config import path_for +from rascal2.widgets.project.lists import ContrastWidget from rascal2.widgets.project.models import ( CustomFileWidget, DomainContrastWidget, @@ -45,7 +46,7 @@ def __init__(self, parent): "Backgrounds": [], "Domains": ["domain_ratios", "domain_contrasts"], "Custom Files": ["custom_files"], - "Contrasts": [], + "Contrasts": ["contrasts"], } self.view_tabs = {} @@ -215,6 +216,10 @@ def create_edit_view(self) -> None: lambda s: self.edit_tabs["Layers"].tables["layers"].set_absorption(s == QtCore.Qt.CheckState.Checked) ) + for tab in ["Experimental Parameters", "Layers", "Backgrounds"]: + for table in self.edit_tabs[tab].tables.values(): + table.edited.connect(lambda: self.edit_tabs["Contrasts"].tables["contrasts"].update_item_view()) + main_layout.addWidget(self.edit_project_tab) edit_project_widget.setLayout(main_layout) @@ -386,6 +391,8 @@ def __init__(self, fields: list[str], parent, edit_mode: bool = False): self.tables[field] = DomainContrastWidget(field, self) elif field == "custom_files": self.tables[field] = CustomFileWidget(field, self) + elif field == "contrasts": + self.tables[field] = ContrastWidget(field, self) else: self.tables[field] = ProjectFieldWidget(field, self) layout.addWidget(self.tables[field]) diff --git a/tests/widgets/project/test_models.py b/tests/widgets/project/test_models.py index 66250a1..d9aac85 100644 --- a/tests/widgets/project/test_models.py +++ b/tests/widgets/project/test_models.py @@ -363,12 +363,12 @@ def test_layer_widget_delegates(init_class): expected_delegates = { "name": delegates.ValidatedInputDelegate, - "thickness": delegates.ParametersDelegate, - "SLD": delegates.ParametersDelegate, - "SLD_real": delegates.ParametersDelegate, - "SLD_imaginary": delegates.ParametersDelegate, - "roughness": delegates.ParametersDelegate, - "hydration": delegates.ParametersDelegate, + "thickness": delegates.ProjectFieldDelegate, + "SLD": delegates.ProjectFieldDelegate, + "SLD_real": delegates.ProjectFieldDelegate, + "SLD_imaginary": delegates.ProjectFieldDelegate, + "roughness": delegates.ProjectFieldDelegate, + "hydration": delegates.ProjectFieldDelegate, "hydrate_with": delegates.ValidatedInputDelegate, } From a05a5d09b28f0ea4b36f46c2c2d5120558725aa1 Mon Sep 17 00:00:00 2001 From: alexhroom Date: Mon, 20 Jan 2025 17:03:36 +0000 Subject: [PATCH 02/10] custom models now work --- rascal2/widgets/project/lists.py | 59 ++++++++++++++++++++------------ 1 file changed, 38 insertions(+), 21 deletions(-) diff --git a/rascal2/widgets/project/lists.py b/rascal2/widgets/project/lists.py index 9263902..fc7c733 100644 --- a/rascal2/widgets/project/lists.py +++ b/rascal2/widgets/project/lists.py @@ -4,7 +4,7 @@ import RATapi from PyQt6 import QtCore, QtGui, QtWidgets -from RATapi.utils.enums import BackgroundActions +from RATapi.utils.enums import BackgroundActions, LayerModels from rascal2.config import path_for from rascal2.widgets.delegates import ProjectFieldDelegate @@ -95,6 +95,8 @@ def delete_item(self, row: int): class AbstractProjectListWidget(QtWidgets.QWidget): """An abstract base widget for editing items kept in a list.""" + item_type = "item" + def __init__(self, field: str, parent): super().__init__(parent) self.field = field @@ -158,7 +160,7 @@ def update_item_view(self): # if there are no items, replace the widget with information if self.model.rowCount() == 0: self.view_stack = QtWidgets.QLabel( - "No contrasts are currently defined! Edit the project to add a contrast." + f"No {self.item_type}s are currently defined! Edit the project to add a {self.item_type}." ) self.view_stack.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter) @@ -251,23 +253,23 @@ def supportedDropActions(self): return QtCore.Qt.DropAction.MoveAction -class ContrastModelWidget(QtWidgets.QWidget): - """Widget for contrast models.""" +class StandardLayerModelWidget(QtWidgets.QWidget): + """Widget for standard layer contrast models.""" - def __init__(self, init_list: list[str] = None, parent=None): + def __init__(self, init_list: list[str], parent=None): super().__init__(parent) - self.model = LayerStringListModel(init_list, self) - self.list = QtWidgets.QListView(parent) - self.list.setModel(self.model) - self.list.setItemDelegateForColumn(0, ProjectFieldDelegate(parent.project_widget, "layers", self)) - self.list.setDragEnabled(True) - self.list.setAcceptDrops(True) - self.list.setDropIndicatorShown(True) - self.list.setDragDropOverwriteMode(False) + model = LayerStringListModel(init_list, self) + standard_layer_list = QtWidgets.QListView(parent) + standard_layer_list.setModel(model) + standard_layer_list.setItemDelegateForColumn(0, ProjectFieldDelegate(parent.project_widget, "layers", self)) + standard_layer_list.setDragEnabled(True) + standard_layer_list.setAcceptDrops(True) + standard_layer_list.setDropIndicatorShown(True) + standard_layer_list.setDragDropOverwriteMode(False) layout = QtWidgets.QVBoxLayout() - layout.addWidget(self.list) + layout.addWidget(standard_layer_list) self.setLayout(layout) @@ -275,6 +277,8 @@ def __init__(self, init_list: list[str] = None, parent=None): class ContrastWidget(AbstractProjectListWidget): """Widget for viewing and editing Contrasts.""" + item_type = "contrast" + def compose_widget(self, i: int, data_widget: Callable[[str], QtWidgets.QWidget]) -> QtWidgets.QWidget: """Create the base grid layouts for the widget. @@ -282,7 +286,7 @@ def compose_widget(self, i: int, data_widget: Callable[[str], QtWidgets.QWidget] ---------- i : int The row of the contrasts list to display in this widget. - data_widget_creator : Callable[[str], QtWidgets.QWidget] + data_widget : Callable[[str], QtWidgets.QWidget] A function which takes a field name and returns the data widget for that field. Returns @@ -322,7 +326,7 @@ def compose_widget(self, i: int, data_widget: Callable[[str], QtWidgets.QWidget] model_grid = QtWidgets.QGridLayout() model_grid.addWidget(QtWidgets.QLabel("Bulk in:"), 0, 0) model_grid.addWidget(data_widget("bulk_in"), 0, 1) - model_grid.addWidget(QtWidgets.QLabel("Layers:"), 1, 0) + model_grid.addWidget(QtWidgets.QLabel("Model:"), 1, 0) model_grid.addWidget(data_widget("model"), 1, 1) model_grid.addWidget(QtWidgets.QLabel("Bulk out:"), 2, 0) model_grid.addWidget(data_widget("bulk_out"), 2, 1) @@ -332,7 +336,6 @@ def compose_widget(self, i: int, data_widget: Callable[[str], QtWidgets.QWidget] layout.addLayout(settings_row) layout.addStretch() layout.addLayout(model_grid) - layout.setContentsMargins(0, 0, 0, 0) widget = QtWidgets.QWidget(self) widget.setLayout(layout) @@ -344,8 +347,12 @@ def data_box(field: str) -> QtWidgets.QWidget: """Create a read only line edit box for display.""" current_data = getattr(self.model.get_item(i), field) if field == "model": - widget = QtWidgets.QListWidget(parent=self) - widget.addItems(current_data) + if self.project_widget.parent_model.project.model == LayerModels.StandardLayers: + widget = QtWidgets.QListWidget(parent=self) + widget.addItems(current_data) + else: + widget = QtWidgets.QLineEdit(current_data[0]) + widget.setReadOnly(True) else: widget = QtWidgets.QLineEdit(current_data) widget.setReadOnly(True) @@ -374,8 +381,18 @@ def data_combobox(field: str) -> QtWidgets.QWidget: ) return widget case "model": - widget = ContrastModelWidget(current_data, self) - return widget + if self.project_widget.draft_project["model"] == LayerModels.StandardLayers: + widget = StandardLayerModelWidget(current_data, self) + widget.dataChanged.connect(lambda: self.model.set_data(i, field, widget.stringList())) + return widget + else: + widget = QtWidgets.QComboBox(self) + widget.addItem("", []) + for file in self.project_widget.draft_project["custom_files"]: + widget.addItem(file.name, [file.name]) + widget.setCurrentText(current_data[0]) + widget.currentTextChanged.connect(lambda: self.model.set_data(i, field, widget.currentData())) + return widget # all other cases are comboboxes with data from other widget tables case "data" | "bulk_in" | "bulk_out": project_field_name = field From d439561d46b8cf5526a585e739275ab024a581bb Mon Sep 17 00:00:00 2001 From: alexhroom Date: Wed, 22 Jan 2025 07:32:59 +0000 Subject: [PATCH 03/10] added add/delete buttons --- rascal2/widgets/project/lists.py | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/rascal2/widgets/project/lists.py b/rascal2/widgets/project/lists.py index fc7c733..4163930 100644 --- a/rascal2/widgets/project/lists.py +++ b/rascal2/widgets/project/lists.py @@ -268,11 +268,36 @@ def __init__(self, init_list: list[str], parent=None): standard_layer_list.setDropIndicatorShown(True) standard_layer_list.setDragDropOverwriteMode(False) + add_button = QtWidgets.QPushButton("+") + add_button.pressed.connect(self.append_item) + delete_button = QtWidgets.QPushButton(icon=QtGui.QIcon(path_for("delete.png"))) + delete_button.pressed.connect(self.delete_item) + + buttons = QtWidgets.QHBoxLayout() + buttons.addWidget(add_button) + buttons.addWidget(delete_button) + layout = QtWidgets.QVBoxLayout() layout.addWidget(standard_layer_list) + layout.addLayout(buttons) self.setLayout(layout) + def get_data(self): + """Get the data from the model.""" + return self.model.stringList() + + def append_item(self): + """Append an item to the model if the model exists.""" + if self.model is not None: + self.model.insertRows(self.model.rowCount(), 1) + self.model.setData(self.model.index(self.model.rowCount() - 1, 0), "Choose Layer...") + + def delete_item(self): + """Delete the currently selected item.""" + if self.model is not None: + selection_model = self.list.selectionModel() + self.model.removeRows(selection_model.currentIndex().row(), 1) class ContrastWidget(AbstractProjectListWidget): """Widget for viewing and editing Contrasts.""" From 9396bf937db1190d3a606ed221f81d43fc84dbd6 Mon Sep 17 00:00:00 2001 From: alexhroom Date: Wed, 22 Jan 2025 14:25:09 +0000 Subject: [PATCH 04/10] adding, moving, deleting now correctly change data --- rascal2/widgets/project/lists.py | 39 ++++++++++++++++---------------- 1 file changed, 19 insertions(+), 20 deletions(-) diff --git a/rascal2/widgets/project/lists.py b/rascal2/widgets/project/lists.py index 4163930..4eb26d6 100644 --- a/rascal2/widgets/project/lists.py +++ b/rascal2/widgets/project/lists.py @@ -252,21 +252,19 @@ def flags(self, index): def supportedDropActions(self): return QtCore.Qt.DropAction.MoveAction - class StandardLayerModelWidget(QtWidgets.QWidget): """Widget for standard layer contrast models.""" def __init__(self, init_list: list[str], parent=None): super().__init__(parent) - model = LayerStringListModel(init_list, self) - standard_layer_list = QtWidgets.QListView(parent) - standard_layer_list.setModel(model) - standard_layer_list.setItemDelegateForColumn(0, ProjectFieldDelegate(parent.project_widget, "layers", self)) - standard_layer_list.setDragEnabled(True) - standard_layer_list.setAcceptDrops(True) - standard_layer_list.setDropIndicatorShown(True) - standard_layer_list.setDragDropOverwriteMode(False) + self.model = LayerStringListModel(init_list, self) + self.layer_list = QtWidgets.QListView(parent) + self.layer_list.setModel(self.model) + self.layer_list.setItemDelegateForColumn(0, ProjectFieldDelegate(parent.project_widget, "layers", self)) + self.layer_list.setDragEnabled(True) + self.layer_list.setAcceptDrops(True) + self.layer_list.setDropIndicatorShown(True) add_button = QtWidgets.QPushButton("+") add_button.pressed.connect(self.append_item) @@ -278,26 +276,26 @@ def __init__(self, init_list: list[str], parent=None): buttons.addWidget(delete_button) layout = QtWidgets.QVBoxLayout() - layout.addWidget(standard_layer_list) + layout.addWidget(self.layer_list) layout.addLayout(buttons) self.setLayout(layout) - def get_data(self): - """Get the data from the model.""" - return self.model.stringList() - def append_item(self): - """Append an item to the model if the model exists.""" + """Append an item below the currently selected item.""" if self.model is not None: - self.model.insertRows(self.model.rowCount(), 1) - self.model.setData(self.model.index(self.model.rowCount() - 1, 0), "Choose Layer...") + selection_model = self.layer_list.selectionModel() + self.model.insertRow(selection_model.currentIndex().row() + 1) + self.model.setData(self.model.index(selection_model.currentIndex().row() + 1, 0), "Choose Layer...") def delete_item(self): """Delete the currently selected item.""" if self.model is not None: - selection_model = self.list.selectionModel() - self.model.removeRows(selection_model.currentIndex().row(), 1) + selection_model = self.layer_list.selectionModel() + index = selection_model.currentIndex() + self.model.removeRow(index.row()) + self.model.dataChanged.emit(index, index) + class ContrastWidget(AbstractProjectListWidget): """Widget for viewing and editing Contrasts.""" @@ -408,7 +406,8 @@ def data_combobox(field: str) -> QtWidgets.QWidget: case "model": if self.project_widget.draft_project["model"] == LayerModels.StandardLayers: widget = StandardLayerModelWidget(current_data, self) - widget.dataChanged.connect(lambda: self.model.set_data(i, field, widget.stringList())) + widget.model.dataChanged.connect(lambda: self.model.set_data(i, field, widget.model.stringList())) + widget.model.rowsMoved.connect(lambda: self.model.set_data(i, field, widget.model.stringList())) return widget else: widget = QtWidgets.QComboBox(self) From 91fcfc8cf6763f4d72e7554c050adfb98dda5510 Mon Sep 17 00:00:00 2001 From: alexhroom Date: Mon, 27 Jan 2025 13:25:17 +0000 Subject: [PATCH 05/10] added keyboard shortcuts and layer search --- rascal2/widgets/delegates.py | 6 +++ rascal2/widgets/project/lists.py | 69 ++++++++++++++++++++++++++++-- rascal2/widgets/project/project.py | 34 ++++++++++++++- 3 files changed, 104 insertions(+), 5 deletions(-) diff --git a/rascal2/widgets/delegates.py b/rascal2/widgets/delegates.py index 07775f9..3b5c903 100644 --- a/rascal2/widgets/delegates.py +++ b/rascal2/widgets/delegates.py @@ -128,6 +128,12 @@ def createEditor(self, parent, option, index): widget.addItems(names) widget.setCurrentText(index.data(QtCore.Qt.ItemDataRole.DisplayRole)) + # make combobox searchable + widget.setEditable(True) + widget.setInsertPolicy(widget.InsertPolicy.NoInsert) + widget.setFrame(False) + widget.completer().setCompletionMode(QtWidgets.QCompleter.CompletionMode.PopupCompletion) + return widget def setEditorData(self, editor: QtWidgets.QWidget, index): diff --git a/rascal2/widgets/project/lists.py b/rascal2/widgets/project/lists.py index 4eb26d6..986c6e6 100644 --- a/rascal2/widgets/project/lists.py +++ b/rascal2/widgets/project/lists.py @@ -252,6 +252,7 @@ def flags(self, index): def supportedDropActions(self): return QtCore.Qt.DropAction.MoveAction + class StandardLayerModelWidget(QtWidgets.QWidget): """Widget for standard layer contrast models.""" @@ -266,10 +267,29 @@ def __init__(self, init_list: list[str], parent=None): self.layer_list.setAcceptDrops(True) self.layer_list.setDropIndicatorShown(True) + self.layer_list.selectionModel().setCurrentIndex( + self.model.index(0, 0), QtCore.QItemSelectionModel.SelectionFlag.ClearAndSelect + ) + add_button = QtWidgets.QPushButton("+") + add_button.setToolTip("Add a layer after the currently selected layer (Shift+Enter)") + add_shortcut = QtGui.QShortcut(QtGui.QKeySequence("Shift+Return"), self) add_button.pressed.connect(self.append_item) + add_shortcut.activated.connect(self.append_item) + delete_button = QtWidgets.QPushButton(icon=QtGui.QIcon(path_for("delete.png"))) + delete_button.setToolTip("Delete the currently selected layer (Del)") + delete_shortcut = QtGui.QShortcut(QtGui.QKeySequence.StandardKey.Delete, self) delete_button.pressed.connect(self.delete_item) + delete_shortcut.activated.connect(self.delete_item) + + edit_shortcut = QtGui.QShortcut(QtGui.QKeySequence("Tab"), self) + edit_shortcut.activated.connect(lambda: self.edit_item()) + + move_up_shortcut = QtGui.QShortcut(QtGui.QKeySequence("Shift+Up"), self) + move_down_shortcut = QtGui.QShortcut(QtGui.QKeySequence("Shift+Down"), self) + move_up_shortcut.activated.connect(lambda: self.move_item(-1)) + move_down_shortcut.activated.connect(lambda: self.move_item(1)) buttons = QtWidgets.QHBoxLayout() buttons.addWidget(add_button) @@ -285,8 +305,11 @@ def append_item(self): """Append an item below the currently selected item.""" if self.model is not None: selection_model = self.layer_list.selectionModel() - self.model.insertRow(selection_model.currentIndex().row() + 1) - self.model.setData(self.model.index(selection_model.currentIndex().row() + 1, 0), "Choose Layer...") + index = selection_model.currentIndex() + self.model.insertRow(index.row() + 1) + new_index = self.model.index(index.row() + 1, 0) + selection_model.setCurrentIndex(new_index, selection_model.SelectionFlag.ClearAndSelect) + self.layer_list.edit(new_index) def delete_item(self): """Delete the currently selected item.""" @@ -296,6 +319,39 @@ def delete_item(self): self.model.removeRow(index.row()) self.model.dataChanged.emit(index, index) + def move_item(self, delta: int): + """Change the currently selected item's index by a number of rows. + + Parameters + ---------- + delta : int + The change in index of the selected item. + + """ + if self.model is not None: + selection_model = self.layer_list.selectionModel() + index = selection_model.currentIndex() + + if index.row() + delta < 0: + new_row = 0 + elif index.row() + delta >= self.model.rowCount(): + new_row = self.model.rowCount() - 1 + else: + new_row = index.row() + delta + + new_index = self.model.index(new_row, 0) + item_data = self.model.data(index) + self.model.removeRow(index.row()) + self.model.insertRow(new_index.row()) + self.model.setData(new_index, item_data) + selection_model.setCurrentIndex(new_index, selection_model.SelectionFlag.ClearAndSelect) + + def edit_item(self): + """Open the currently selected item's editor if it isn't already open.""" + # if this check isn't here, Qt produces warnings into the terminal + if self.layer_list.state() != self.layer_list.State.EditingState: + self.layer_list.edit(self.layer_list.selectionModel().currentIndex()) + class ContrastWidget(AbstractProjectListWidget): """Widget for viewing and editing Contrasts.""" @@ -406,7 +462,9 @@ def data_combobox(field: str) -> QtWidgets.QWidget: case "model": if self.project_widget.draft_project["model"] == LayerModels.StandardLayers: widget = StandardLayerModelWidget(current_data, self) - widget.model.dataChanged.connect(lambda: self.model.set_data(i, field, widget.model.stringList())) + widget.model.dataChanged.connect( + lambda: self.model.set_data(i, field, widget.model.stringList()) + ) widget.model.rowsMoved.connect(lambda: self.model.set_data(i, field, widget.model.stringList())) return widget else: @@ -414,7 +472,10 @@ def data_combobox(field: str) -> QtWidgets.QWidget: widget.addItem("", []) for file in self.project_widget.draft_project["custom_files"]: widget.addItem(file.name, [file.name]) - widget.setCurrentText(current_data[0]) + if current_data: + widget.setCurrentText(current_data[0]) + else: + widget.setCurrentText("") widget.currentTextChanged.connect(lambda: self.model.set_data(i, field, widget.currentData())) return widget # all other cases are comboboxes with data from other widget tables diff --git a/rascal2/widgets/project/project.py b/rascal2/widgets/project/project.py index df4b8df..78f71fb 100644 --- a/rascal2/widgets/project/project.py +++ b/rascal2/widgets/project/project.py @@ -52,6 +52,8 @@ def __init__(self, parent): self.view_tabs = {} self.edit_tabs = {} self.draft_project = None + # for making model type changes non-destructive + self.old_contrast_models = {} project_view = self.create_project_view() project_edit = self.create_edit_view() @@ -201,10 +203,14 @@ def create_edit_view(self) -> None: self.edit_absorption_checkbox.checkStateChanged.connect( lambda s: self.update_draft_project({"absorption": s == QtCore.Qt.CheckState.Checked}) ) + # when calculation type changed, update the draft project and show/hide the domains tab self.calculation_combobox.currentTextChanged.connect(lambda s: self.update_draft_project({"calculation": s})) self.calculation_combobox.currentTextChanged.connect(lambda: self.handle_tabs()) - self.model_combobox.currentTextChanged.connect(lambda s: self.update_draft_project({"model": s})) + + # when model type changed, hide/show layers tab and self.model_combobox.currentTextChanged.connect(lambda: self.handle_tabs()) + self.model_combobox.currentTextChanged.connect(lambda s: self.handle_model_update(s)) + self.geometry_combobox.currentTextChanged.connect(lambda s: self.update_draft_project({"geometry": s})) self.edit_project_tab = QtWidgets.QTabWidget() @@ -288,6 +294,32 @@ def handle_controls_update(self): self.view_tabs[tab].handle_controls_update(controls) self.edit_tabs[tab].handle_controls_update(controls) + def handle_model_update(self, new_type): + """Handle updates to the model type. + + Parameters + ---------- + new_type : LayerModels + The new layer model. + + """ + if self.draft_project is None: + return + + old_type = self.draft_project["model"] + self.update_draft_project({"model": new_type}) + + # we use 'xor' (^) as "if the old type was standard layers and the new type isn't, or vice versa" + if (old_type == LayerModels.StandardLayers) ^ (new_type == LayerModels.StandardLayers): + old_contrast_models = {} + # clear contrasts as what the 'model' means has changed! + for contrast in self.draft_project["contrasts"]: + old_contrast_models[contrast.name] = contrast.model + contrast.model = self.old_contrast_models.get(contrast.name, []) + + self.old_contrast_models = old_contrast_models + self.edit_tabs["Contrasts"].tables["contrasts"].update_item_view() + def show_project_view(self) -> None: """Show project view""" self.setWindowTitle("Project") From efb8c441b7a8548586b2e1a0f8913ce7a04109d3 Mon Sep 17 00:00:00 2001 From: alexhroom Date: Mon, 27 Jan 2025 15:08:09 +0000 Subject: [PATCH 06/10] add tests --- rascal2/widgets/project/lists.py | 3 +- tests/widgets/project/test_lists.py | 251 ++++++++++++++++++++++++++++ 2 files changed, 252 insertions(+), 2 deletions(-) create mode 100644 tests/widgets/project/test_lists.py diff --git a/rascal2/widgets/project/lists.py b/rascal2/widgets/project/lists.py index 986c6e6..633acb8 100644 --- a/rascal2/widgets/project/lists.py +++ b/rascal2/widgets/project/lists.py @@ -256,7 +256,7 @@ def supportedDropActions(self): class StandardLayerModelWidget(QtWidgets.QWidget): """Widget for standard layer contrast models.""" - def __init__(self, init_list: list[str], parent=None): + def __init__(self, init_list: list[str], parent): super().__init__(parent) self.model = LayerStringListModel(init_list, self) @@ -413,7 +413,6 @@ def compose_widget(self, i: int, data_widget: Callable[[str], QtWidgets.QWidget] layout = QtWidgets.QVBoxLayout() layout.addLayout(top_grid) layout.addLayout(settings_row) - layout.addStretch() layout.addLayout(model_grid) widget = QtWidgets.QWidget(self) diff --git a/tests/widgets/project/test_lists.py b/tests/widgets/project/test_lists.py new file mode 100644 index 0000000..85f22cd --- /dev/null +++ b/tests/widgets/project/test_lists.py @@ -0,0 +1,251 @@ +"""Tests for list tab/model views.""" + +from dataclasses import dataclass +from unittest.mock import MagicMock + +import pytest +from PyQt6 import QtWidgets +from RATapi import ClassList + +from rascal2.widgets.project.lists import AbstractProjectListWidget, ClassListItemModel, StandardLayerModelWidget + + +class MockMainWindow(QtWidgets.QMainWindow): + def __init__(self): + super().__init__() + self.parent = MagicMock() + self.project_widget = MagicMock() + + +parent = MockMainWindow() + + +@dataclass +class MockDataClass: + name: str = "New Item" + val: int = 0 + + +class MockProjectListWidget(AbstractProjectListWidget): + """A mock implementation of the project list widget.""" + + def create_view(self, i): + return QtWidgets.QLabel(self.model.classlist[i].name) + + def create_editor(self, i): + return QtWidgets.QLabel(str(self.model.classlist[i].val)) + + +@pytest.fixture +def mock_item_model(): + init_classlist = ClassList( + [ + MockDataClass("a", 1), + MockDataClass("b", 2), + MockDataClass("c", 3), + ] + ) + return ClassListItemModel(init_classlist, parent) + + +@pytest.fixture +def mock_project_widget(): + def create_project_widget(edit_mode: bool = False): + widget = MockProjectListWidget("mocks", parent) + widget.update_model( + ClassList( + [ + MockDataClass("a", 1), + MockDataClass("b", 2), + MockDataClass("c", 3), + ] + ) + ) + if edit_mode: + widget.edit() + + return widget + + return create_project_widget + + +def test_classlist_item_model_init(mock_item_model): + """Test that the ClassListItemModel has the expected data on creation.""" + init_classlist = ClassList( + [ + MockDataClass("a", 1), + MockDataClass("b", 2), + MockDataClass("c", 3), + ] + ) + + assert mock_item_model.classlist == init_classlist + assert mock_item_model.item_type == MockDataClass + assert not mock_item_model.edit_mode + + assert mock_item_model.rowCount() == 3 + for i, item in enumerate(init_classlist): + assert mock_item_model.data(mock_item_model.index(i, 0)) == item.name + assert mock_item_model.get_item(i) == item + + +def test_classlist_item_model_edit(mock_item_model): + """Test that setting data in the ClassListItemModel works as expected.""" + mock_item_model.set_data(1, "val", 5) + assert mock_item_model.get_item(1) == MockDataClass("b", 5) + + mock_item_model.set_data(2, "name", "d") + assert mock_item_model.get_item(2) == MockDataClass("d", 3) + + +def test_classlist_item_model_append(mock_item_model): + """Test that appending an item to the ClassListItemModel works as expected.""" + mock_item_model.append_item() + + assert mock_item_model.rowCount() == 4 + assert mock_item_model.get_item(-1) == MockDataClass() + + +def test_classlist_item_model_delete(mock_item_model): + """Test that deleting items from the ClassListItemModel works as expected.""" + mock_item_model.delete_item(1) + assert mock_item_model.rowCount() == 2 + assert mock_item_model.get_item(0) == MockDataClass("a", 1) + assert mock_item_model.get_item(1) == MockDataClass("c", 3) + + +def test_project_list_widget_init(): + """Test the initial state of the abstract project list widget.""" + widget = AbstractProjectListWidget("nothing", parent) + + assert not widget.edit_mode + assert not widget.add_button.isVisibleTo(widget) + assert not widget.delete_button.isVisibleTo(widget) + + +def test_project_list_widget_edit(): + """Test the correct changes are made when edit mode is activated on the project list widget.""" + widget = AbstractProjectListWidget("nothing", parent) + widget.edit() + + assert widget.edit_mode + assert widget.add_button.isVisibleTo(widget) + assert widget.delete_button.isVisibleTo(widget) + + +def test_project_list_widget_empty_model(): + """Test that a message is shown if an empty model is given to the widget.""" + widget = AbstractProjectListWidget("nothing", parent) + empty_classlist = ClassList() + empty_classlist._class_handle = None + widget.update_model(empty_classlist) + + assert isinstance(widget.view_stack, QtWidgets.QLabel) + assert widget.view_stack.text() == "No items are currently defined! Edit the project to add a item." + + +@pytest.mark.parametrize("edit_mode, expected_labels", ([False, ["a", "b", "c"]], [True, ["1", "2", "3"]])) +def test_project_list_widget_build(mock_project_widget, edit_mode, expected_labels): + """Test that the project list widget builds correctly when given a model.""" + + widget = mock_project_widget(edit_mode) + + assert widget.view_stack.count() == 3 + + for i, val in enumerate(expected_labels): + widget.view_stack.setCurrentIndex(i) + + assert widget.view_stack.currentWidget().text() == val + + +@pytest.mark.parametrize("edit_mode, expected_labels", ([False, ["a", "b", "c"]], [True, ["1", "2", "3"]])) +def test_project_list_widget_choose(mock_project_widget, edit_mode, expected_labels): + """Test that the current widget changes when the item is selected.""" + + widget = mock_project_widget(edit_mode) + + for i, label in enumerate(expected_labels): + sel_mod = widget.list.selectionModel() + sel_mod.setCurrentIndex(widget.model.index(i, 0), sel_mod.SelectionFlag.ClearAndSelect) + assert widget.view_stack.currentWidget().text() == label + + +@pytest.mark.parametrize("edit_mode, expected_label", ([False, "New Item"], [True, "0"])) +def test_project_list_widget_append(mock_project_widget, edit_mode, expected_label): + """Test that items are correctly appended to a project list widget.""" + + widget = mock_project_widget(edit_mode) + + widget.append_item() + + assert widget.view_stack.count() == 4 + + widget.view_stack.setCurrentIndex(3) + assert widget.view_stack.currentWidget().text() == expected_label + + +def test_project_list_widget_delete(mock_project_widget): + """Test that items are correctly deleted from a project list widget.""" + + widget = mock_project_widget() + + sel_mod = widget.list.selectionModel() + sel_mod.setCurrentIndex(widget.model.index(1, 0), sel_mod.SelectionFlag.ClearAndSelect) + + widget.delete_item() + + assert widget.view_stack.count() == 2 + + for i, expected_label in enumerate(["a", "c"]): + widget.view_stack.setCurrentIndex(i) + assert widget.view_stack.currentWidget().text() == expected_label + + +def test_standard_layer_widget_init(): + """Test that the StandardLayerModelWidget initialises as expected.""" + + widget = StandardLayerModelWidget(["a", "b", "c"], parent) + + assert widget.layer_list.model().stringList() == ["a", "b", "c"] + + +@pytest.mark.parametrize("selected_index", [0, 1, 2]) +def test_standard_layer_widget_append(selected_index): + """Test that the StandardLayerModelWidget appends items correctly.""" + init_list = ["a", "b", "c"] + widget = StandardLayerModelWidget(init_list, parent) + + sel_mod = widget.layer_list.selectionModel() + sel_mod.setCurrentIndex(widget.model.index(selected_index, 0), sel_mod.SelectionFlag.ClearAndSelect) + widget.append_item() + + assert widget.model.stringList() == init_list[: selected_index + 1] + [""] + init_list[selected_index + 1 :] + assert widget.layer_list.state() == widget.layer_list.State.EditingState + + +@pytest.mark.parametrize("selected_index", [0, 1, 2]) +def test_standard_layer_widget_delete(selected_index): + """Test that the StandardLayerModelWidget deletes items correctly.""" + init_list = ["a", "b", "c"] + widget = StandardLayerModelWidget(init_list, parent) + + sel_mod = widget.layer_list.selectionModel() + sel_mod.setCurrentIndex(widget.model.index(selected_index, 0), sel_mod.SelectionFlag.ClearAndSelect) + widget.delete_item() + + assert widget.model.stringList() == init_list[:selected_index] + init_list[selected_index + 1 :] + + +@pytest.mark.parametrize("selected_index", [0, 1, 2]) +@pytest.mark.parametrize("delta", [1, -1, 2, -2, 5]) +def test_standard_layer_widget_move(selected_index, delta): + """Test that the StandardLayerModelWidget moves items correctly.""" + init_list = ["a", "b", "c"] + widget = StandardLayerModelWidget(init_list, parent) + + sel_mod = widget.layer_list.selectionModel() + sel_mod.setCurrentIndex(widget.model.index(selected_index, 0), sel_mod.SelectionFlag.ClearAndSelect) + widget.move_item(delta) + + init_list.insert(max(selected_index + delta, 0), init_list.pop(selected_index)) + assert widget.model.stringList() == init_list From 5a2b902c3d8bdafb63bc35c07afb7332dd83f8bd Mon Sep 17 00:00:00 2001 From: alexhroom Date: Fri, 31 Jan 2025 17:19:52 +0000 Subject: [PATCH 07/10] domains now work --- rascal2/widgets/project/lists.py | 87 +++++++++++++++++++++++++-- rascal2/widgets/project/models.py | 24 +++----- rascal2/widgets/project/project.py | 13 +++- tests/widgets/project/test_lists.py | 1 + tests/widgets/project/test_models.py | 8 +-- tests/widgets/project/test_project.py | 6 +- 6 files changed, 107 insertions(+), 32 deletions(-) diff --git a/rascal2/widgets/project/lists.py b/rascal2/widgets/project/lists.py index 633acb8..723d63d 100644 --- a/rascal2/widgets/project/lists.py +++ b/rascal2/widgets/project/lists.py @@ -19,8 +19,6 @@ class ClassListItemModel(QtCore.QAbstractListModel, Generic[T]): ---------- classlist : ClassList The initial classlist to represent in this model. - field : str - The name of the field represented by this model. parent : QtWidgets.QWidget The parent widget for the model. @@ -96,6 +94,7 @@ class AbstractProjectListWidget(QtWidgets.QWidget): """An abstract base widget for editing items kept in a list.""" item_type = "item" + classlist_model = ClassListItemModel def __init__(self, field: str, parent): super().__init__(parent) @@ -144,11 +143,10 @@ def update_model(self, classlist): The classlist to set in the model. """ - self.model = ClassListItemModel(classlist, self) + self.model = self.classlist_model(classlist, self) self.list.setModel(self.model) # this signal changes the current contrast shown in the editor to be the currently highlighted list item self.list.selectionModel().currentChanged.connect(lambda index, _: self.view_stack.setCurrentIndex(index.row())) - self.update_item_view() def update_item_view(self): @@ -262,7 +260,10 @@ def __init__(self, init_list: list[str], parent): self.model = LayerStringListModel(init_list, self) self.layer_list = QtWidgets.QListView(parent) self.layer_list.setModel(self.model) - self.layer_list.setItemDelegateForColumn(0, ProjectFieldDelegate(parent.project_widget, "layers", self)) + if parent.model.domains: + self.layer_list.setItemDelegateForColumn(0, ProjectFieldDelegate(parent.project_widget, "domain_contrasts", self)) + else: + self.layer_list.setItemDelegateForColumn(0, ProjectFieldDelegate(parent.project_widget, "layers", self)) self.layer_list.setDragEnabled(True) self.layer_list.setAcceptDrops(True) self.layer_list.setDropIndicatorShown(True) @@ -353,10 +354,71 @@ def edit_item(self): self.layer_list.edit(self.layer_list.selectionModel().currentIndex()) +class ContrastModel(ClassListItemModel): + """ClassList item model for contrast data with or without a ratio.""" + + def __init__(self, classlist, parent): + super().__init__(classlist, parent) + self.domains = classlist._class_handle == RATapi.models.ContrastWithRatio + self.domain_ratios = {} + + def set_domains(self, domains: bool): + """Set whether the classlist uses ContrastWithRatio. + + Parameters + ---------- + domains : bool + Whether the classlist should use ContrastWithRatio. + + """ + if domains != self.domains: + self.beginResetModel() + self.domains = domains + if domains: + classlist = RATapi.ClassList( + [ + RATapi.models.ContrastWithRatio( + **dict(contrast), domain_ratio=self.domain_ratios.get(contrast.name, "") + ) + for contrast in self.classlist + ] + ) + # set handle manually if classlist is empty + classlist._class_handle = RATapi.models.ContrastWithRatio + else: + # save domain ratios so they aren't lost if the user toggles + # back and forth + self.domain_ratios = {contrast.name: contrast.domain_ratio for contrast in self.classlist} + classlist = RATapi.ClassList( + [ + RATapi.models.Contrast( + name=contrast.name, + data=contrast.data, + background=contrast.background, + background_action=contrast.background_action, + bulk_in=contrast.bulk_in, + bulk_out=contrast.bulk_out, + scalefactor=contrast.scalefactor, + resolution=contrast.resolution, + resample=contrast.resample, + model=contrast.model, + ) + for contrast in self.classlist + ] + ) + # set handle manually if classlist is empty + classlist._class_handle = RATapi.models.Contrast + + self.classlist = classlist + self.item_type = classlist._class_handle + self.parent.project_widget.update_draft_project({"contrasts": classlist}) + + class ContrastWidget(AbstractProjectListWidget): """Widget for viewing and editing Contrasts.""" item_type = "contrast" + classlist_model = ContrastModel def compose_widget(self, i: int, data_widget: Callable[[str], QtWidgets.QWidget]) -> QtWidgets.QWidget: """Create the base grid layouts for the widget. @@ -389,6 +451,9 @@ def compose_widget(self, i: int, data_widget: Callable[[str], QtWidgets.QWidget] top_grid.addWidget(data_widget("scalefactor"), 2, 3) top_grid.addWidget(QtWidgets.QLabel("Data:"), 2, 4) top_grid.addWidget(data_widget("data"), 2, 5) + if self.model.domains: + top_grid.addWidget(QtWidgets.QLabel("Domain Ratio:"), 3, 0) + top_grid.addWidget(data_widget("domain_ratio"), 3, 1, 1, -1) top_grid.setVerticalSpacing(10) @@ -496,3 +561,15 @@ def data_combobox(field: str) -> QtWidgets.QWidget: return combobox return self.compose_widget(i, data_combobox) + + def set_domains(self, domains: bool): + """Set whether the model uses ContrastWithRatio. + + Parameters + ---------- + domains : bool + Whether the model should use ContrastWithRatio. + + """ + self.model.set_domains(domains) + self.update_model(self.model.classlist) diff --git a/rascal2/widgets/project/models.py b/rascal2/widgets/project/models.py index 61175aa..f8887ae 100644 --- a/rascal2/widgets/project/models.py +++ b/rascal2/widgets/project/models.py @@ -15,7 +15,7 @@ from rascal2.dialogs.custom_file_editor import edit_file, edit_file_matlab -class ClassListModel(QtCore.QAbstractTableModel): +class ClassListTableModel(QtCore.QAbstractTableModel): """Table model for a project ClassList field. Parameters @@ -45,7 +45,7 @@ def setup_classlist(self, classlist: RATapi.ClassList): self.classlist = classlist self.item_type = classlist._class_handle if not issubclass(self.item_type, pydantic.BaseModel): - raise NotImplementedError("ClassListModel only works for classlists of Pydantic models!") + raise NotImplementedError("ClassListTableModel only works for classlists of Pydantic models!") self.headers = list(self.item_type.model_fields) def rowCount(self, parent=None) -> int: @@ -153,7 +153,7 @@ class ProjectFieldWidget(QtWidgets.QWidget): """ - classlist_model = ClassListModel + classlist_model = ClassListTableModel # the model can change and disconnect, so we re-connect it # to a signal here on each change @@ -249,7 +249,7 @@ def update_project(self): presenter.edit_project({self.field: self.model.classlist}) -class ParametersModel(ClassListModel): +class ParametersModel(ClassListTableModel): """Classlist model for Parameters.""" def __init__(self, classlist: RATapi.ClassList, parent: QtWidgets.QWidget): @@ -326,7 +326,7 @@ def edit(self): self.table.setIndexWidget(self.model.index(i, 0), None) -class LayersModel(ClassListModel): +class LayersModel(ClassListTableModel): """Classlist model for Layers.""" def __init__(self, classlist: RATapi.ClassList, parent: QtWidgets.QWidget): @@ -394,16 +394,6 @@ def set_absorption(self, absorption: bool): self.endResetModel() -class ContrastsModel(ClassListModel): - """Classlist model for Contrasts.""" - - def flags(self, index): - flags = super().flags(index) - if self.edit_mode: - flags |= QtCore.Qt.ItemFlag.ItemIsEditable - return flags - - class LayerFieldWidget(ProjectFieldWidget): """Project field widget for Layer objects.""" @@ -439,7 +429,7 @@ def set_absorption(self, absorption: bool): self.edit() -class DomainsModel(ClassListModel): +class DomainsModel(ClassListTableModel): """Classlist model for domain contrasts.""" def flags(self, index): @@ -472,7 +462,7 @@ def set_item_delegates(self): self.table.setItemDelegateForColumn(2, delegates.MultiSelectLayerDelegate(self.project_widget, self.table)) -class CustomFileModel(ClassListModel): +class CustomFileModel(ClassListTableModel): """Classlist model for custom files.""" def __init__(self, classlist: RATapi.ClassList, parent: QtWidgets.QWidget): diff --git a/rascal2/widgets/project/project.py b/rascal2/widgets/project/project.py index 78f71fb..18b8199 100644 --- a/rascal2/widgets/project/project.py +++ b/rascal2/widgets/project/project.py @@ -203,11 +203,15 @@ def create_edit_view(self) -> None: self.edit_absorption_checkbox.checkStateChanged.connect( lambda s: self.update_draft_project({"absorption": s == QtCore.Qt.CheckState.Checked}) ) - # when calculation type changed, update the draft project and show/hide the domains tab + # when calculation type changed, update the draft project, show/hide the domains tab, + # and change contrasts to have ratio self.calculation_combobox.currentTextChanged.connect(lambda s: self.update_draft_project({"calculation": s})) self.calculation_combobox.currentTextChanged.connect(lambda: self.handle_tabs()) + self.calculation_combobox.currentTextChanged.connect( + lambda s: self.edit_tabs["Contrasts"].tables["contrasts"].set_domains(s == Calculations.Domains) + ) - # when model type changed, hide/show layers tab and + # when model type changed, hide/show layers tab and change model field in contrasts self.model_combobox.currentTextChanged.connect(lambda: self.handle_tabs()) self.model_combobox.currentTextChanged.connect(lambda s: self.handle_model_update(s)) @@ -222,7 +226,7 @@ def create_edit_view(self) -> None: lambda s: self.edit_tabs["Layers"].tables["layers"].set_absorption(s == QtCore.Qt.CheckState.Checked) ) - for tab in ["Experimental Parameters", "Layers", "Backgrounds"]: + for tab in ["Experimental Parameters", "Layers", "Backgrounds", "Domains"]: for table in self.edit_tabs[tab].tables.values(): table.edited.connect(lambda: self.edit_tabs["Contrasts"].tables["contrasts"].update_item_view()) @@ -458,6 +462,9 @@ def update_model(self, new_model): table.edit() if "layers" in self.tables: self.tables["layers"].set_absorption(new_model["absorption"]) + if "contrasts" in self.tables: + self.tables["contrasts"].set_domains(new_model["calculation"] == Calculations.Domains) + def handle_controls_update(self, controls): """Reflect changes to the Controls object.""" diff --git a/tests/widgets/project/test_lists.py b/tests/widgets/project/test_lists.py index 85f22cd..6c4c063 100644 --- a/tests/widgets/project/test_lists.py +++ b/tests/widgets/project/test_lists.py @@ -14,6 +14,7 @@ class MockMainWindow(QtWidgets.QMainWindow): def __init__(self): super().__init__() self.parent = MagicMock() + self.model = MagicMock() self.project_widget = MagicMock() diff --git a/tests/widgets/project/test_models.py b/tests/widgets/project/test_models.py index d9aac85..aaf8188 100644 --- a/tests/widgets/project/test_models.py +++ b/tests/widgets/project/test_models.py @@ -11,7 +11,7 @@ import rascal2.widgets.delegates as delegates import rascal2.widgets.inputs as inputs from rascal2.widgets.project.models import ( - ClassListModel, + ClassListTableModel, CustomFileModel, CustomFileWidget, DomainContrastWidget, @@ -52,8 +52,8 @@ def classlist(): @pytest.fixture def table_model(classlist): - """A test ClassListModel.""" - return ClassListModel(classlist, parent) + """A test ClassListTableModel.""" + return ClassListTableModel(classlist, parent) @pytest.fixture @@ -93,7 +93,7 @@ def _param_model(protected_indices): def test_model_init(table_model, classlist): - """Test that initialisation works correctly for ClassListModels.""" + """Test that initialisation works correctly for ClassListTableModels.""" model = table_model assert model.classlist == classlist diff --git a/tests/widgets/project/test_project.py b/tests/widgets/project/test_project.py index 8fed595..7551e7b 100644 --- a/tests/widgets/project/test_project.py +++ b/tests/widgets/project/test_project.py @@ -8,7 +8,7 @@ from RATapi.utils.enums import Calculations, Geometries, LayerModels from rascal2.widgets.project.models import ( - ClassListModel, + ClassListTableModel, ParameterFieldWidget, ParametersModel, ProjectFieldWidget, @@ -57,8 +57,8 @@ def classlist(): @pytest.fixture def table_model(classlist): - """A test ClassListModel.""" - return ClassListModel(classlist, parent) + """A test ClassListTableModel.""" + return ClassListTableModel(classlist, parent) @pytest.fixture From 162f62fdcca9cf09b04b81ce2cd5efcc15809e43 Mon Sep 17 00:00:00 2001 From: alexhroom Date: Mon, 3 Feb 2025 09:39:31 +0000 Subject: [PATCH 08/10] Project tabs now update properly when changing between domains/non-domains and absorption/non-abs projects --- rascal2/widgets/project/lists.py | 4 +++- rascal2/widgets/project/project.py | 13 ++++--------- 2 files changed, 7 insertions(+), 10 deletions(-) diff --git a/rascal2/widgets/project/lists.py b/rascal2/widgets/project/lists.py index 723d63d..1f2d5a9 100644 --- a/rascal2/widgets/project/lists.py +++ b/rascal2/widgets/project/lists.py @@ -261,7 +261,9 @@ def __init__(self, init_list: list[str], parent): self.layer_list = QtWidgets.QListView(parent) self.layer_list.setModel(self.model) if parent.model.domains: - self.layer_list.setItemDelegateForColumn(0, ProjectFieldDelegate(parent.project_widget, "domain_contrasts", self)) + self.layer_list.setItemDelegateForColumn( + 0, ProjectFieldDelegate(parent.project_widget, "domain_contrasts", self) + ) else: self.layer_list.setItemDelegateForColumn(0, ProjectFieldDelegate(parent.project_widget, "layers", self)) self.layer_list.setDragEnabled(True) diff --git a/rascal2/widgets/project/project.py b/rascal2/widgets/project/project.py index 18b8199..f090752 100644 --- a/rascal2/widgets/project/project.py +++ b/rascal2/widgets/project/project.py @@ -242,6 +242,10 @@ def update_project_view(self) -> None: # because we don't want validation errors going off while editing the model is in-progress self.draft_project: dict = create_draft_project(self.parent_model.project) + for tab in self.tabs: + self.view_tabs[tab].update_model(self.draft_project) + self.edit_tabs[tab].update_model(self.draft_project) + self.absorption_checkbox.setChecked(self.parent_model.project.absorption) self.calculation_type.setText(self.parent_model.project.calculation) self.model_type.setText(self.parent_model.project.model) @@ -252,10 +256,6 @@ def update_project_view(self) -> None: self.model_combobox.setCurrentText(self.parent_model.project.model) self.geometry_combobox.setCurrentText(self.parent_model.project.geometry) - for tab in self.tabs: - self.view_tabs[tab].update_model(self.draft_project) - self.edit_tabs[tab].update_model(self.draft_project) - self.handle_tabs() self.handle_controls_update() @@ -460,11 +460,6 @@ def update_model(self, new_model): table.update_model(classlist) if self.edit_mode: table.edit() - if "layers" in self.tables: - self.tables["layers"].set_absorption(new_model["absorption"]) - if "contrasts" in self.tables: - self.tables["contrasts"].set_domains(new_model["calculation"] == Calculations.Domains) - def handle_controls_update(self, controls): """Reflect changes to the Controls object.""" From 584aaafdcb15ffc3867b8041bf145f92ca0596f8 Mon Sep 17 00:00:00 2001 From: alexhroom Date: Mon, 3 Feb 2025 11:59:11 +0000 Subject: [PATCH 09/10] added contrast validation --- rascal2/widgets/project/lists.py | 18 +++- rascal2/widgets/project/project.py | 114 ++++++++++++++++++++----- tests/widgets/project/test_project.py | 115 ++++++++++++++++++++++++-- 3 files changed, 220 insertions(+), 27 deletions(-) diff --git a/rascal2/widgets/project/lists.py b/rascal2/widgets/project/lists.py index 1f2d5a9..f7ce0e6 100644 --- a/rascal2/widgets/project/lists.py +++ b/rascal2/widgets/project/lists.py @@ -514,7 +514,7 @@ def data_combobox(field: str) -> QtWidgets.QWidget: match field: case "name": widget = QtWidgets.QLineEdit(current_data) - widget.textChanged.connect(lambda text: self.model.set_data(i, "name", text)) + widget.textChanged.connect(lambda text: self.set_name_data(i, text)) return widget case "background_action": widget = QtWidgets.QComboBox() @@ -564,6 +564,22 @@ def data_combobox(field: str) -> QtWidgets.QWidget: return self.compose_widget(i, data_combobox) + def set_name_data(self, index: int, name: str): + """Set name data, ensuring name isn't empty. + + Parameters + ---------- + index : int + The index of the contrast. + name : str + The desired name for the contrast. + + """ + if name != "": + self.model.set_data(index, "name", name) + else: + self.model.set_data(index, "name", "Unnamed Contrast") + def set_domains(self, domains: bool): """Set whether the model uses ContrastWithRatio. diff --git a/rascal2/widgets/project/project.py b/rascal2/widgets/project/project.py index f090752..bcf94b3 100644 --- a/rascal2/widgets/project/project.py +++ b/rascal2/widgets/project/project.py @@ -1,8 +1,10 @@ """Widget for the Project window.""" +from collections.abc import Generator from copy import deepcopy import RATapi +from pydantic import ValidationError from PyQt6 import QtCore, QtGui, QtWidgets from RATapi.utils.enums import Calculations, Geometries, LayerModels @@ -339,26 +341,42 @@ def show_edit_view(self) -> None: def save_changes(self) -> None: """Save changes to the project.""" - try: - self.validate_draft_project() - except ValueError as err: - self.parent.terminal_widget.write_error(f"Could not save draft project:\n {err}") + errors = "\n ".join(self.validate_draft_project()) + if errors: + self.parent.terminal_widget.write_error(f"Could not save draft project:\n {errors}") else: - self.parent.presenter.edit_project(self.draft_project) - self.update_project_view() - self.parent.controls_widget.run_button.setEnabled(True) - self.show_project_view() - - def validate_draft_project(self): - """Check that the draft project is valid.""" - errors = [] - if self.draft_project["model"] == LayerModels.StandardLayers and self.draft_project["layers"]: - layer_attrs = list(self.draft_project["layers"][0].model_fields) + # catch errors from Pydantic as fallback rather than crashing + try: + self.parent.presenter.edit_project(self.draft_project) + except ValidationError as err: + self.parent.terminal_widget.write_error(f"Could not save draft project:\n {err}") + else: + self.update_project_view() + self.parent.controls_widget.run_button.setEnabled(True) + self.show_project_view() + + def validate_draft_project(self) -> Generator[str, None, None]: + """Get all errors with the draft project.""" + yield from self.validate_layers() + yield from self.validate_contrasts() + + def validate_layers(self) -> Generator[str, None, None]: + """Ensure that all layers in the draft project are valid, and yield errors if not. + + Yields + ------ + str + The message for each error in layers. + + """ + project = self.draft_project + if project["model"] == LayerModels.StandardLayers and project["layers"]: + layer_attrs = list(project["layers"][0].model_fields) layer_attrs.remove("name") layer_attrs.remove("hydrate_with") # ensure all layer parameters have been filled in, and all names are layers that exist - valid_params = [p.name for p in self.draft_project["parameters"]] - for i, layer in enumerate(self.draft_project["layers"]): + valid_params = [p.name for p in project["parameters"]] + for i, layer in enumerate(project["layers"]): missing_params = [] invalid_params = [] for attr in layer_attrs: @@ -371,16 +389,72 @@ def validate_draft_project(self): if missing_params: noun = "a parameter" if len(missing_params) == 1 else "parameters" msg = f"Layer '{layer.name}' (row {i + 1}) is missing {noun}: {', '.join(missing_params)}" - errors.append(msg) + yield msg if invalid_params: noun = "an invalid value" if len(invalid_params) == 1 else "invalid values" msg = f"Layer '{layer.name}' (row {i + 1}) has {noun}: {{0}}".format( ",\n ".join(f'"{v}" for parameter {p}' for p, v in invalid_params) ) - errors.append(msg) + yield msg - if errors: - raise ValueError("\n ".join(errors)) + def validate_contrasts(self) -> Generator[str, None, None]: + """Ensure that all contrast parameters in the draft project are valid, and yield errors if not. + + Yields + ------ + str + The messages for each error in contrasts. + + """ + project = self.draft_project + if project["contrasts"]: + contrast_attrs = list(project["contrasts"][0].model_fields) + contrast_attrs.remove("name") + contrast_attrs.remove("background_action") + contrast_attrs.remove("model") + contrast_attrs.remove("resample") + for i, contrast in enumerate(project["contrasts"]): + missing_params = [] + invalid_params = [] + for attr in contrast_attrs: + project_field_name = attr if attr in ["data", "bulk_in", "bulk_out"] else attr + "s" + valid_params = [p.name for p in project[project_field_name]] + param = getattr(contrast, attr) + if param == "": + missing_params.append(attr) + elif param not in valid_params: + invalid_params.append((attr, param)) + + if missing_params: + msg = f"Contrast '{contrast.name}' (row {i + 1}) is missing: {', '.join(missing_params)}" + yield msg + if invalid_params: + noun = "an invalid value" if len(invalid_params) == 1 else "invalid values" + msg = f"Contrast '{contrast.name}' (row {i + 1}) has {noun}: {{0}}".format( + ",\n ".join(f'"{v}" for field {p}' for p, v in invalid_params) + ) + yield msg + + model = contrast.model + if project["model"] == LayerModels.StandardLayers: + if project["calculation"] == Calculations.Domains: + model_field_name = "domain_contrasts" + else: + model_field_name = "layers" + valid_params = [p.name for p in project[model_field_name]] + invalid_model_vals = [item for item in model if item not in valid_params] + # this is the fastest way to get all unique items from a list without changing the order... + invalid_model_vals = list(dict.fromkeys(invalid_model_vals)) + if invalid_model_vals: + noun = "an invalid model value" if len(invalid_model_vals) == 1 else "invalid model values" + msg = f"Contrast '{contrast.name}' (row {i + 1}) has {noun}: {{0}}".format( + ", ".join(invalid_model_vals) + ) + yield msg + else: + if model[0] not in [f.name for f in project["custom_files"]]: + msg = f"Contrast '{contrast.name}' (row {i + 1}) has invalid model: {model[0]}" + yield msg def cancel_changes(self) -> None: """Cancel changes to the project.""" diff --git a/tests/widgets/project/test_project.py b/tests/widgets/project/test_project.py index 7551e7b..690fb1f 100644 --- a/tests/widgets/project/test_project.py +++ b/tests/widgets/project/test_project.py @@ -1,4 +1,3 @@ -import re from unittest.mock import MagicMock import pydantic @@ -69,6 +68,14 @@ def setup_project_widget(): return project_widget +@pytest.fixture +def project_with_draft(): + draft = create_draft_project(RATapi.Project()) + project = ProjectWidget(parent) + project.draft_project = draft + return project + + @pytest.fixture def param_classlist(): def _classlist(protected_indices): @@ -314,8 +321,104 @@ def test_project_tab_validate_layers(input_params, absorption): project = ProjectWidget(parent) project.draft_project = draft - if not expected_err: - project.validate_draft_project() - else: - with pytest.raises(ValueError, match=re.escape("\n ".join(expected_err))): - project.validate_draft_project() + assert list(project.validate_layers()) == expected_err + + +@pytest.mark.parametrize( + "calculation, model_values", + [ + (Calculations.Normal, ([0, 1, 1, 2, 1], [0, 0, 1, 0, 1])), + (Calculations.Normal, ([0, 0, 0, 1, 0], [0, 0, 1, 1, 0])), + (Calculations.Normal, ([0, 0, 0, 1, 0], [0, 0, 1, 3, 0])), + (Calculations.Normal, ([2, 2, 3, 2, 0], [0, 0, 2, 0, 1])), + (Calculations.Domains, ([0, 1], [1, 1])), + (Calculations.Domains, ([0, 2], [0, 1])), + (Calculations.Domains, ([0, 1], [1, 2])), + (Calculations.Domains, ([2, 3], [1, 3])), + ], +) +def test_project_tab_validate_contrast_models_standard(calculation, model_values, project_with_draft): + """Test that contrast values are correctly validated for a standard layers calculation.""" + model_names = ["1", "2", "Invalid 1", "Invalid 2"] + models = [[model_names[i] for i in model_values[j]] for j in [0, 1]] + contrasts = RATapi.ClassList( + [ + RATapi.models.Contrast( + name=f"contrast {i}", + data="Simulation", + background="Background 1", + bulk_in="SLD Air", + bulk_out="SLD D2O", + scalefactor="Scalefactor 1", + resolution="Resolution 1", + model=models[i], + ) + for i in [0, 1] + ] + ) + + expected_err = [] + for i in [0, 1]: + invalid = [] + if 2 in model_values[i]: + invalid.append("Invalid 1") + if 3 in model_values[i]: + invalid.append("Invalid 2") + + if invalid: + noun = "an invalid model value" if len(invalid) == 1 else "invalid model values" + msg = f"Contrast 'contrast {i}' (row {i + 1}) has {noun}: {{0}}".format(", ".join(invalid)) + expected_err.append(msg) + + draft = project_with_draft.draft_project + draft["calculation"] = calculation + draft["contrasts"] = contrasts + draft["parameters"] = RATapi.ClassList(RATapi.models.Parameter(name="p")) + draft["layers"] = RATapi.ClassList( + [ + RATapi.models.Layer(name="1", thickness="p", SLD="p", roughness="p"), + RATapi.models.Layer(name="2", thickness="p", SLD="p", roughness="p"), + ] + ) + draft["domain_contrasts"] = RATapi.ClassList( + [ + RATapi.models.DomainContrast(name="1", model=["1", "2"]), + RATapi.models.DomainContrast(name="2", model=["1", "2"]), + ] + ) + + assert list(project_with_draft.validate_contrasts()) == expected_err + + +@pytest.mark.parametrize("contrast_models", [[0, 0], [0, 1], [1, 0], [1, 1]]) +@pytest.mark.parametrize("calc_type", [LayerModels.CustomLayers, LayerModels.CustomXY]) +def test_project_tab_validate_contrast_models_custom(contrast_models, calc_type, project_with_draft): + """Test that contrast values are correctly validated for a custom layers/XY calculation.""" + custom_files = ["Custom File 1", "Invalid Custom File"] + contrasts = RATapi.ClassList( + [ + RATapi.models.Contrast( + name=f"contrast {i}", + data="Simulation", + background="Background 1", + bulk_in="SLD Air", + bulk_out="SLD D2O", + scalefactor="Scalefactor 1", + resolution="Resolution 1", + model=[custom_files[model_index]], + ) + for i, model_index in enumerate(contrast_models) + ] + ) + + expected_err = [] + for i, model_index in enumerate(contrast_models): + if model_index == 1: + expected_err.append(f"Contrast 'contrast {i}' (row {i + 1}) has invalid model: Invalid Custom File") + + draft = project_with_draft.draft_project + draft["model"] = calc_type + draft["custom_files"] = RATapi.ClassList([RATapi.models.CustomFile(name="Custom File 1", filename="test.py")]) + draft["contrasts"] = contrasts + + assert list(project_with_draft.validate_contrasts()) == expected_err From d7df4e3ee1dbecb1fab0b959040cefe8a2f5cfd5 Mon Sep 17 00:00:00 2001 From: alexhroom Date: Wed, 5 Feb 2025 10:11:50 +0000 Subject: [PATCH 10/10] limit domains model list to 2 values --- rascal2/widgets/project/lists.py | 24 +++++++++++++++++++----- 1 file changed, 19 insertions(+), 5 deletions(-) diff --git a/rascal2/widgets/project/lists.py b/rascal2/widgets/project/lists.py index f7ce0e6..a5c2f12 100644 --- a/rascal2/widgets/project/lists.py +++ b/rascal2/widgets/project/lists.py @@ -258,9 +258,10 @@ def __init__(self, init_list: list[str], parent): super().__init__(parent) self.model = LayerStringListModel(init_list, self) + self.domains = parent.model.domains self.layer_list = QtWidgets.QListView(parent) self.layer_list.setModel(self.model) - if parent.model.domains: + if self.domains: self.layer_list.setItemDelegateForColumn( 0, ProjectFieldDelegate(parent.project_widget, "domain_contrasts", self) ) @@ -274,10 +275,12 @@ def __init__(self, init_list: list[str], parent): self.model.index(0, 0), QtCore.QItemSelectionModel.SelectionFlag.ClearAndSelect ) - add_button = QtWidgets.QPushButton("+") - add_button.setToolTip("Add a layer after the currently selected layer (Shift+Enter)") + self.add_button = QtWidgets.QPushButton("+") + self.add_button.setToolTip("Add a layer after the currently selected layer (Shift+Enter)") + if self.model.rowCount() == 2: + self.add_button.setEnabled(False) add_shortcut = QtGui.QShortcut(QtGui.QKeySequence("Shift+Return"), self) - add_button.pressed.connect(self.append_item) + self.add_button.pressed.connect(self.append_item) add_shortcut.activated.connect(self.append_item) delete_button = QtWidgets.QPushButton(icon=QtGui.QIcon(path_for("delete.png"))) @@ -295,7 +298,7 @@ def __init__(self, init_list: list[str], parent): move_down_shortcut.activated.connect(lambda: self.move_item(1)) buttons = QtWidgets.QHBoxLayout() - buttons.addWidget(add_button) + buttons.addWidget(self.add_button) buttons.addWidget(delete_button) layout = QtWidgets.QVBoxLayout() @@ -307,6 +310,10 @@ def __init__(self, init_list: list[str], parent): def append_item(self): """Append an item below the currently selected item.""" if self.model is not None: + # do not allow items to be added in domains for over 2 items + if self.domains and self.model.rowCount() == 2: + return + selection_model = self.layer_list.selectionModel() index = selection_model.currentIndex() self.model.insertRow(index.row() + 1) @@ -314,6 +321,10 @@ def append_item(self): selection_model.setCurrentIndex(new_index, selection_model.SelectionFlag.ClearAndSelect) self.layer_list.edit(new_index) + # if 2 items have been reached by this adding, disable add button + if self.domains and self.model.rowCount() == 2: + self.add_button.setEnabled(False) + def delete_item(self): """Delete the currently selected item.""" if self.model is not None: @@ -322,6 +333,9 @@ def delete_item(self): self.model.removeRow(index.row()) self.model.dataChanged.emit(index, index) + # re-enable add button if disabled + self.add_button.setEnabled(True) + def move_item(self, delta: int): """Change the currently selected item's index by a number of rows.