From 6cbbe832e86dcf049bbc51281231029c469bfee8 Mon Sep 17 00:00:00 2001 From: alexhroom Date: Mon, 25 Nov 2024 15:32:57 +0000 Subject: [PATCH] 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 2af7105..43c3502 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() @@ -414,7 +420,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 b184820..32bd3ed 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) @@ -375,6 +380,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, }