diff --git a/rascal2/settings.py b/rascal2/settings.py index 4e079cbf..3720c79a 100644 --- a/rascal2/settings.py +++ b/rascal2/settings.py @@ -97,10 +97,11 @@ def _missing_(cls, value): class MDIGeometries(BaseModel): """Model for storing window positions and sizes.""" - plots: WindowGeometry = Field(max_length=5, min_length=5) - project: WindowGeometry = Field(max_length=5, min_length=5) - terminal: WindowGeometry = Field(max_length=5, min_length=5) - controls: WindowGeometry = Field(max_length=5, min_length=5) + Plots: WindowGeometry = Field(max_length=5, min_length=5) + Project: WindowGeometry = Field(max_length=5, min_length=5) + Terminal: WindowGeometry = Field(max_length=5, min_length=5) + FittingControls: WindowGeometry = Field(max_length=5, min_length=5) + SlidersView: WindowGeometry = Field(max_length=5, min_length=5) class Settings(BaseModel, validate_assignment=True, arbitrary_types_allowed=True): diff --git a/rascal2/ui/view.py b/rascal2/ui/view.py index a129c84d..be701640 100644 --- a/rascal2/ui/view.py +++ b/rascal2/ui/view.py @@ -8,7 +8,7 @@ from rascal2.dialogs.settings_dialog import SettingsDialog from rascal2.dialogs.startup_dialog import PROJECT_FILES, LoadDialog, LoadR1Dialog, NewProjectDialog, StartupDialog from rascal2.settings import MDIGeometries, Settings, get_global_settings -from rascal2.widgets import ControlsWidget, PlotWidget, TerminalWidget +from rascal2.widgets import ControlsWidget, PlotWidget, SlidersViewWidget, TerminalWidget from rascal2.widgets.project import ProjectWidget from rascal2.widgets.startup import StartUpWidget @@ -22,6 +22,10 @@ class MainWindowView(QtWidgets.QMainWindow): def __init__(self): super().__init__() + # Public interface + self.disabled_elements = [] + self.show_sliders = False # no one displays sliders initially except got from configuration + # (not implemented yet) self.setWindowTitle(MAIN_WINDOW_TITLE) window_icon = QtGui.QIcon(path_for("logo.png")) @@ -39,11 +43,14 @@ def __init__(self): self.terminal_widget = TerminalWidget() self.controls_widget = ControlsWidget(self) self.project_widget = ProjectWidget(self) + self.sliders_view_widget = SlidersViewWidget(self) - self.disabled_elements = [] + ## protected interface and public properties construction self.create_actions() - self.create_menus() + + self.add_submenus() + self.create_toolbar() self.create_status_bar() @@ -56,6 +63,15 @@ def __init__(self): self.setCentralWidget(self.startup_dlg) self.about_dialog = AboutDialog(self) + # dictionary of main widgets present in the main operation area controlled + # by mdi interface. + # There are other widgets (sliders_view) which are initially hidden. + self._main_window_widgets = { + "Plots": self.plot_widget, + "Project": self.project_widget, + "Terminal": self.terminal_widget, + "Fitting Controls": self.controls_widget, + } def closeEvent(self, event): if self.presenter.ask_to_save_project(): @@ -162,13 +178,17 @@ def create_actions(self): self.open_help_action.setIcon(QtGui.QIcon(path_for("help.png"))) self.open_help_action.triggered.connect(self.open_docs) - self.toggle_slider_action = QtGui.QAction("Show &Sliders", self) - self.toggle_slider_action.setProperty("show_text", "Show &Sliders") - self.toggle_slider_action.setProperty("hide_text", "Hide &Sliders") - self.toggle_slider_action.setStatusTip("Show or Hide Sliders") - self.toggle_slider_action.triggered.connect(self.toggle_sliders) - self.toggle_slider_action.setEnabled(False) - self.disabled_elements.append(self.toggle_slider_action) + self._toggle_slider_action = QtGui.QAction("Show &Sliders", self) + self._toggle_slider_action.setProperty("show_text", "Show &Sliders") + self._toggle_slider_action.setProperty("hide_text", "Hide &Sliders") + self._toggle_slider_action.setStatusTip("Show or Hide Sliders") + self._toggle_slider_action.triggered.connect(lambda: self.toggle_sliders(None)) + # done this way expecting the value "show_sliders" being stored + # in configuration in a future + "show_sliders" is public for this reason + self.toggle_sliders(self.show_sliders) + if not self.show_sliders: + self._toggle_slider_action.setEnabled(False) + self.disabled_elements.append(self._toggle_slider_action) self.open_about_action = QtGui.QAction("&About", self) self.open_about_action.setStatusTip("Report RAT version&info") @@ -208,7 +228,7 @@ def create_actions(self): self.setup_matlab_action.setStatusTip("Set the path of the MATLAB executable") self.setup_matlab_action.triggered.connect(lambda: self.show_settings_dialog(tab_name="Matlab")) - def create_menus(self): + def add_submenus(self): """Add sub menus to the main menu bar""" main_menu = self.menuBar() main_menu.setContextMenuPolicy(QtCore.Qt.ContextMenuPolicy.PreventContextMenu) @@ -241,7 +261,8 @@ def create_menus(self): self.disabled_elements.append(windows_menu) tools_menu = main_menu.addMenu("&Tools") - tools_menu.addAction(self.toggle_slider_action) + tools_menu.setObjectName("&Tools") + tools_menu.addAction(self._toggle_slider_action) tools_menu.addSeparator() tools_menu.addAction(self.clear_terminal_action) tools_menu.addSeparator() @@ -251,16 +272,54 @@ def create_menus(self): help_menu.addAction(self.open_about_action) help_menu.addAction(self.open_help_action) - def toggle_sliders(self): - """Toggles sliders for the fitted parameters in project class view.""" - show_text = self.toggle_slider_action.property("show_text") - if self.toggle_slider_action.text() == show_text: - hide_text = self.toggle_slider_action.property("hide_text") - self.toggle_slider_action.setText(hide_text) - self.project_widget.show_slider_view() + def toggle_sliders(self, do_show_sliders=None): + """Depending on current state, show or hide sliders for + table properties within Project class view. + + Parameters: + ----------- + do_show_sliders: bool,default None + if provided, sets self.show_sliders logical variable into the requested state + (True/False), forcing sliders widget to appear/disappear. if None, applies not to current state. + """ + if do_show_sliders is None: + self.show_sliders = not self.show_sliders else: - self.toggle_slider_action.setText(show_text) - self.project_widget.show_project_view() + self.show_sliders = do_show_sliders + + # ignore show/hide operations if project and its mdi container are not defined + if self.sliders_view_widget.mdi_holder is None: + return + + if self.show_sliders: + sliders_text = self._toggle_slider_action.property("hide_text") + self.sliders_view_widget.show() + else: + sliders_text = self._toggle_slider_action.property("show_text") + self.sliders_view_widget.hide() + self._toggle_slider_action.setText(sliders_text) + + def sliders_view_enabled(self, is_enabled: bool, prev_call_vis_sliders_state: bool = False): + """Makes sliders view button in menu enabled or disabled depending + on the state of the input parameters. + + Used by: project widget to control menu when project editing is enabled. + + Parameters: + ----------- + is_enabled: bool, + if True, slider state should be enabled, if False - disabled. + prev_call_vis_sliders_state : bool,default False + logical stating what sliders view widget view state was when this method was called + when slider state was disabled + """ + self._toggle_slider_action.setEnabled(is_enabled) + + # hide sliders when disabled or else + if is_enabled: + self.toggle_sliders(do_show_sliders=prev_call_vis_sliders_state) + else: + self.toggle_sliders(do_show_sliders=False) def open_about_info(self): """Opens about menu containing information about RASCAL gui""" @@ -299,24 +358,26 @@ def setup_mdi(self): """Creates the multi-document interface""" # if windows are already created, don't set them up again, # just refresh the widget data - if len(self.mdi.subWindowList()) == 4: + if len(self.mdi.subWindowList()) == 5: self.setup_mdi_widgets() return - widgets = { - "Plots": self.plot_widget, - "Project": self.project_widget, - "Terminal": self.terminal_widget, - "Fitting Controls": self.controls_widget, - } self.setup_mdi_widgets() - for title, widget in reversed(widgets.items()): + for title, widget in reversed(self._main_window_widgets.items()): widget.setWindowTitle(title) window = self.mdi.addSubWindow( widget, QtCore.Qt.WindowType.WindowMinMaxButtonsHint | QtCore.Qt.WindowType.WindowTitleHint ) window.setWindowTitle(title) + # Add sliders view widget separately, as it will behave and is controlled differently from other + # mdi windows + self.mdi.addSubWindow( + self.sliders_view_widget, + QtCore.Qt.WindowType.WindowMinMaxButtonsHint | QtCore.Qt.WindowType.WindowTitleHint, + ) + self.sliders_view_widget.setWindowTitle("Sliders View") + self.reset_mdi_layout() self.startup_dlg = self.takeCentralWidget() self.setCentralWidget(self.mdi) @@ -330,17 +391,34 @@ def setup_mdi_widgets(self): self.plot_widget.clear() self.terminal_widget.clear() self.terminal_widget.write_startup() + self.sliders_view_widget.init() def reset_mdi_layout(self): """Reset MDI layout to the default.""" + + main_widget_names = self._main_window_widgets.keys() if self.settings.mdi_defaults is None: + # logic expects "Sliders View" the only widget in the mdi list + slider_view_wrapper = None for window in self.mdi.subWindowList(): - window.showNormal() + if window.windowTitle() in main_widget_names: + window.showNormal() + else: + window.hide() + slider_view_wrapper = window self.mdi.tileSubWindows() + self.sliders_view_widget.mdi_holder = slider_view_wrapper else: + # reliability check. Can we have project saved previously with mdi defaults + # and initialized here newer entering the "if mdi_defaults" loop above? + if self.sliders_view_widget.mdi_holder is None: + for window in self.mdi.subWindowList(): + if window.windowTitle() == "Sliders View": + self.sliders_view_widget.mdi_holder = window + for window in self.mdi.subWindowList(): # get corresponding MDIGeometries entry for the widget - widget_name = window.windowTitle().lower().split(" ")[-1] + widget_name = window.windowTitle().replace(" ", "") x, y, width, height, minimized = getattr(self.settings.mdi_defaults, widget_name) if minimized: window.showMinimized() @@ -353,7 +431,7 @@ def save_mdi_layout(self): geoms = {} for window in self.mdi.subWindowList(): # get corresponding MDIGeometries entry for the widget - widget_name = window.windowTitle().lower().split(" ")[-1] + widget_name = window.windowTitle().replace(" ", "") geom = window.geometry() geoms[widget_name] = (geom.x(), geom.y(), geom.width(), geom.height(), window.isMinimized()) diff --git a/rascal2/widgets/__init__.py b/rascal2/widgets/__init__.py index 92ec968e..bd884688 100644 --- a/rascal2/widgets/__init__.py +++ b/rascal2/widgets/__init__.py @@ -1,7 +1,7 @@ from rascal2.widgets.controls import ControlsWidget from rascal2.widgets.inputs import AdaptiveDoubleSpinBox, MultiSelectComboBox, MultiSelectList, get_validated_input from rascal2.widgets.plot import PlotWidget -from rascal2.widgets.project.slider_view import SliderViewWidget +from rascal2.widgets.sliders_view import SlidersViewWidget from rascal2.widgets.terminal import TerminalWidget __all__ = [ @@ -12,5 +12,5 @@ "MultiSelectList", "PlotWidget", "TerminalWidget", - "SliderViewWidget", + "SlidersViewWidget", ] diff --git a/rascal2/widgets/delegates.py b/rascal2/widgets/delegates.py index 2dfc5f05..3dc09fbb 100644 --- a/rascal2/widgets/delegates.py +++ b/rascal2/widgets/delegates.py @@ -11,6 +11,10 @@ class ValidatedInputDelegate(QtWidgets.QStyledItemDelegate): """Item delegate for validated inputs.""" + # create custom signal to send to labelled sliders when contents of a cell in + # a table class have been changed + edit_finished_inform_sliders = QtCore.pyqtSignal(QtCore.QModelIndex, object) + def __init__(self, field_info, parent, remove_items: list[int] = None, open_on_show: bool = False): super().__init__(parent) self.table = parent @@ -50,6 +54,7 @@ def setEditorData(self, _editor: QtWidgets.QWidget, index): def setModelData(self, _editor, model, index): data = self.widget.get_data() model.setData(index, data, QtCore.Qt.ItemDataRole.EditRole) + self.edit_finished_inform_sliders.emit(index, self.field_info) class CustomFileFunctionDelegate(QtWidgets.QStyledItemDelegate): @@ -97,6 +102,10 @@ class ValueSpinBoxDelegate(QtWidgets.QStyledItemDelegate): """ + # create custom signal to send to labelled sliders when contents of a cell in + # a table cell attached to sliders have been changed + edit_finished_inform_sliders = QtCore.pyqtSignal(QtCore.QModelIndex, object) + def __init__(self, field: Literal["min", "value", "max"], parent): super().__init__(parent) self.table = parent @@ -130,6 +139,7 @@ def setEditorData(self, editor: AdaptiveDoubleSpinBox, index): def setModelData(self, editor, model, index): data = editor.value() model.setData(index, data, QtCore.Qt.ItemDataRole.EditRole) + self.edit_finished_inform_sliders.emit(index, self.field) class ProjectFieldDelegate(QtWidgets.QStyledItemDelegate): diff --git a/rascal2/widgets/project/project.py b/rascal2/widgets/project/project.py index 449bd405..4300d75d 100644 --- a/rascal2/widgets/project/project.py +++ b/rascal2/widgets/project/project.py @@ -11,7 +11,6 @@ from rascal2.config import path_for from rascal2.widgets.project.lists import ContrastWidget, DataWidget -from rascal2.widgets.project.slider_view import SliderViewWidget from rascal2.widgets.project.tables import ( BackgroundsFieldWidget, CustomFileWidget, @@ -42,7 +41,6 @@ def __init__(self, parent): self.parent_model = self.parent.presenter.model self.parent_model.project_updated.connect(self.update_project_view) - self.parent_model.project_updated.connect(self.update_slider_view) self.parent_model.controls_updated.connect(self.handle_controls_update) self.tabs = { @@ -79,6 +77,9 @@ def __init__(self, parent): layout.setContentsMargins(0, 0, 0, 0) layout.addWidget(self.stacked_widget) self.setLayout(layout) + # function holder to store/restore state of slider_view when edit project + # button is pressed + self.__slider_view_state_holder_function = None def create_project_view(self) -> QtWidgets.QWidget: """Creates the project (non-edit) view""" @@ -86,8 +87,8 @@ def create_project_view(self) -> QtWidgets.QWidget: main_layout = QtWidgets.QVBoxLayout() main_layout.setSpacing(20) - show_sliders_button = QtWidgets.QPushButton("Show sliders", self) - show_sliders_button.clicked.connect(self.parent.toggle_sliders) + show_sliders_button = QtWidgets.QPushButton("Show sliders", self, objectName="ShowSliders") + show_sliders_button.clicked.connect(lambda: self.parent.toggle_sliders(True)) self.edit_project_button = QtWidgets.QPushButton("Edit Project", self, icon=QtGui.QIcon(path_for("edit.png"))) self.edit_project_button.clicked.connect(self.show_edit_view) @@ -247,26 +248,6 @@ def create_edit_view(self) -> QtWidgets.QWidget: return edit_project_widget - def show_slider_view(self): - """Create slider view and make it visible.""" - if self.stacked_widget.count() == 3: - # 3 widgets means slider view already exist - # (with project view and edit view) so delete before replacing with new one - old_slider_widget = self.stacked_widget.widget(2) - self.stacked_widget.removeWidget(old_slider_widget) - old_slider_widget.deleteLater() - slider_view = SliderViewWidget(create_draft_project(self.parent_model.project), self.parent) - self.stacked_widget.addWidget(slider_view) - self.stacked_widget.setCurrentIndex(2) - - def update_slider_view(self): - """Update the slider view if the project changes when it is opened.""" - if self.stacked_widget.currentIndex() == 2: - # slider view is the 3rd widget in the layout - widget = self.stacked_widget.widget(2) - widget.draft_project = create_draft_project(self.parent_model.project) - widget.initialize() - def update_project_view(self, update_tab_index=None) -> None: """Updates the project view.""" @@ -383,10 +364,22 @@ def show_project_view(self) -> None: self.setWindowTitle("Project") self.parent.controls_widget.run_button.setEnabled(True) self.stacked_widget.setCurrentIndex(0) + if self.__slider_view_state_holder_function is not None: + # restore previous sliders view + self.__slider_view_state_holder_function() + self.__slider_view_state_holder_function = None def show_edit_view(self) -> None: """Show edit view""" + # disable possibility to enable sliders view until project is edited + # and store current state of sliders view to restore it when editing + # finishes. + sliders_visible = self.parent.sliders_view_widget.isVisible() + self.parent.sliders_view_enabled(False, sliders_visible) + self.__slider_view_state_holder_function = ( + lambda enbl=True, vis=sliders_visible: self.parent.sliders_view_enabled(enbl, vis) + ) # will be updated according to edit changes self.update_project_view(0) self.setWindowTitle("Edit Project") @@ -410,6 +403,10 @@ def save_changes(self) -> None: self.parent.terminal_widget.write_error(f"Could not save draft project:\n {custom_errors}") else: self.show_project_view() + if self.__slider_view_state_holder_function is not None: + # restore previous sliders view state + self.__slider_view_state_holder_function() + self.__slider_view_state_holder_function = None def validate_draft_project(self) -> Generator[str, None, None]: """Get all errors with the draft project.""" diff --git a/rascal2/widgets/project/slider_view.py b/rascal2/widgets/project/slider_view.py deleted file mode 100644 index 80fc0cbe..00000000 --- a/rascal2/widgets/project/slider_view.py +++ /dev/null @@ -1,264 +0,0 @@ -"""Widget for the Sliders View window.""" - -import ratapi -from PyQt6 import QtCore, QtGui, QtWidgets - - -class SliderViewWidget(QtWidgets.QWidget): - """The slider view widget which allows user change fitted parameters with sliders.""" - - def __init__(self, draft_project, parent): - """Initialize widget. - - Parameters - ---------- - draft_project: ratapi.Project - A copy of the project that will be modified by slider - parent: MainWindowView - An instance of the MainWindowView - """ - super().__init__() - self._parent = parent - self.draft_project = draft_project - - self._sliders = {} - self.parameters = {} - - main_layout = QtWidgets.QVBoxLayout() - self.setLayout(main_layout) - - self.accept_button = QtWidgets.QPushButton("Accept", self) - self.accept_button.clicked.connect(self._apply_changes_from_sliders) - - cancel_button = QtWidgets.QPushButton("Cancel", self) - cancel_button.clicked.connect(self._cancel_changes_from_sliders) - - button_layout = QtWidgets.QHBoxLayout() - button_layout.addStretch(1) - button_layout.addWidget(self.accept_button) - button_layout.addWidget(cancel_button) - main_layout.addLayout(button_layout) - - scroll = QtWidgets.QScrollArea() - scroll.setWidgetResizable(True) - main_layout.addWidget(scroll) - content = QtWidgets.QWidget() - scroll.setWidget(content) - self.slider_content_layout = QtWidgets.QVBoxLayout() - content.setLayout(self.slider_content_layout) - - self.initialize() - - def initialize(self): - """Populate parameters and slider from draft project.""" - self._init_parameters_for_sliders() - self._add_sliders_widgets() - - def _init_parameters_for_sliders(self): - """Extract fitted parameters from the draft project.""" - self.parameters.clear() - - for class_list in self.draft_project.values(): - if hasattr(class_list, "_class_handle") and class_list._class_handle is ratapi.models.Parameter: - for parameter in class_list: - if parameter.fit: - self.parameters[parameter.name] = parameter - - def _add_sliders_widgets(self): - """Add sliders to the layout.""" - # We are adding new sliders, so delete all previous ones. - for slider in self._sliders.values(): - self.slider_content_layout.removeWidget(slider) - slider.deleteLater() - for _ in range(self.slider_content_layout.count()): - w = self.slider_content_layout.takeAt(0).widget() - if w is not None: - w.deleteLater() - self._sliders.clear() - self.accept_button.setDisabled(not self.parameters) - - if not self.parameters: - no_label = QtWidgets.QLabel( - "There are no fitted parameters.\n " - "Select parameters to fit in the project view to populate the slider view.", - alignment=QtCore.Qt.AlignmentFlag.AlignCenter, - ) - self.slider_content_layout.addWidget(no_label) - else: - self.slider_content_layout.setSpacing(0) - for name, params in self.parameters.items(): - slider = LabeledSlider(params, self) - - self._sliders[name] = slider - self.slider_content_layout.addWidget(slider) - self.slider_content_layout.addStretch(1) - - def update_result_and_plots(self): - project = ratapi.Project() - vars(project).update(self.draft_project) - results = self._parent.presenter.quick_run(project) - self._parent.plot_widget.reflectivity_plot.plot(project, results) - - def _cancel_changes_from_sliders(self): - """Revert changes to parameter values and close slider view.""" - self._parent.plot_widget.update_plots() - self._parent.toggle_sliders() - - def _apply_changes_from_sliders(self): - """ - Apply changes obtained from sliders to the project and close slider view. - """ - self._parent.presenter.edit_project(self.draft_project) - self._parent.toggle_sliders() - - -class LabeledSlider(QtWidgets.QFrame): - def __init__(self, param, parent): - """Create a LabeledSlider for a given RAT parameter - - Parameters - ---------- - param : ratapi.models.Parameter - The parameter which the slider updates. - parent : SliderViewWidget - The container for the slider widget. - """ - - super().__init__() - self.parent = parent - self._value_label_format: str = "{:.3g}" - - self.param = param - - self._slider = QtWidgets.QSlider(QtCore.Qt.Orientation.Horizontal) - self._slider.setMinimum(0) - self._slider.setMaximum(100) - self._slider.setTickInterval(10) - self._slider.setTickPosition(QtWidgets.QSlider.TickPosition.TicksBothSides) - self._slider.setValue(self._param_value_to_slider_value(self.param.value)) - - # name of given slider can not change. It will be different slider with different name - name_label = QtWidgets.QLabel(param.name, alignment=QtCore.Qt.AlignmentFlag.AlignLeft) - self._value_label = QtWidgets.QLabel( - self._value_label_format.format(self.param.value), alignment=QtCore.Qt.AlignmentFlag.AlignRight - ) - lab_layout = QtWidgets.QHBoxLayout() - lab_layout.addWidget(name_label) - lab_layout.addWidget(self._value_label) - - scale_layout = QtWidgets.QHBoxLayout() - num_of_ticks = self._slider.maximum() // self._slider.tickInterval() - tick_step = (self.param.max - self.param.min) / num_of_ticks - self.labels = [self.param.min + i * tick_step for i in range(num_of_ticks + 1)] - - self.margins = [10, 10, 10, 15] # left, top, right, bottom - layout = QtWidgets.QVBoxLayout(self) - layout.addLayout(lab_layout) - layout.addWidget(self._slider) - layout.addLayout(scale_layout) - layout.setContentsMargins(*self.margins) - - self._slider.valueChanged.connect(self._update_value) - self.setFrameShape(QtWidgets.QFrame.Shape.Box) - self.setSizePolicy(QtWidgets.QSizePolicy.Policy.MinimumExpanding, QtWidgets.QSizePolicy.Policy.Fixed) - - def paintEvent(self, event): - # Draws tick labels - # Adapted from https://gist.github.com/wiccy46/b7d8a1d57626a4ea40b19c5dbc5029ff""" - super().paintEvent(event) - style = self._slider.style() - painter = QtGui.QPainter(self) - st_slider = QtWidgets.QStyleOptionSlider() - st_slider.initFrom(self._slider) - st_slider.orientation = self._slider.orientation() - - length = style.pixelMetric(QtWidgets.QStyle.PixelMetric.PM_SliderLength, st_slider, self._slider) - available = style.pixelMetric(QtWidgets.QStyle.PixelMetric.PM_SliderSpaceAvailable, st_slider, self._slider) - for i, label_value in enumerate(self.labels): - value = i * (len(self.labels) - 1) - value_label = self._value_label_format.format(label_value) - - # get the size of the label - rect = painter.drawText(QtCore.QRect(), QtCore.Qt.TextFlag.TextDontPrint, value_label) - - if self._slider.orientation() == QtCore.Qt.Orientation.Horizontal: - # I assume the offset is half the length of slider, therefore - # + length//2 - x_loc = ( - QtWidgets.QStyle.sliderPositionFromValue( - self._slider.minimum(), self._slider.maximum(), value, available - ) - + length // 2 - ) - - # left bound of the text = center - half of text width + L_margin - left = x_loc - rect.width() // 2 + self.margins[0] - bottom = self.rect().bottom() - 5 - - # enlarge margins if clipping - if value == self._slider.minimum(): - if left <= 0: - self.margins[0] = rect.width() // 2 - x_loc - if self.margins[3] <= rect.height(): - self.margins[3] = rect.height() - - self.layout().setContentsMargins(*self.margins) - - if value == self._slider.maximum() and rect.width() // 2 >= self.margins[2]: - self.margins[2] = rect.width() // 2 - self.layout().setContentsMargins(*self.margins) - - pos = QtCore.QPoint(left, bottom) - painter.drawText(pos, value_label) - - def _param_value_to_slider_value(self, param_value: float) -> int: - """Convert parameter value into slider value. - - Parameters: - ----------- - param_value : float - parameter value - - Returns: - -------- - value : int - slider value that corresponds to the parameter value - """ - param_value_range = self.param.max - self.param.min - if abs(param_value_range) < 10e-7: - return self._slider.maximum() - return int(round(self._slider.maximum() * (param_value - self.param.min) / param_value_range, 0)) - - def _slider_value_to_param_value(self, value: int) -> float: - """Convert slider value into parameter value. - - Parameters - ---------- - value : int - slider value - - Returns - ------- - param_value : float - parameter value that corresponds to slider value - """ - - value_step = (self.param.max - self.param.min) / self._slider.maximum() - param_value = self.param.min + value * value_step - if param_value > self.param.max: # This should not happen but do occur due to round-off errors - param_value = self.param.max - return param_value - - def _update_value(self, value: int): - """Update parameter value and plot when slider value is changed. - - Parameters - ---------- - value : int - slider value - - """ - param_value = self._slider_value_to_param_value(value) - self._value_label.setText(self._value_label_format.format(param_value)) - self.param.value = param_value - self.parent.update_result_and_plots() diff --git a/rascal2/widgets/project/tables.py b/rascal2/widgets/project/tables.py index e2d72834..74eedea8 100644 --- a/rascal2/widgets/project/tables.py +++ b/rascal2/widgets/project/tables.py @@ -75,17 +75,26 @@ def data(self, index, role=QtCore.Qt.ItemDataRole.DisplayRole): elif role == QtCore.Qt.ItemDataRole.CheckStateRole and self.index_header(index) == "fit": return QtCore.Qt.CheckState.Checked if data else QtCore.Qt.CheckState.Unchecked - def setData(self, index, value, role=QtCore.Qt.ItemDataRole.EditRole) -> bool: - """Set the data of a given index in the table model. + def setData( + self, index: QtCore.QModelIndex, value, role=QtCore.Qt.ItemDataRole.EditRole, recalculate_proj=True + ) -> bool: + """Implement abstract setData method of QAbstractTableModel + and sets the data of a given index in the table model Parameters ---------- index: QtCore.QModelIndex - The model index indicates which cells to change + QModelIndex index indicates which cells to change value: Any - The new data value + new value of appropriate cell of the table. role: QtCore.Qt.ItemDataRole - Indicates the role of the Data. + controls table behaviour and needs to be Edit. It nof Edit, method does nothing. + recalculate_proj: bool,default True + Additional control for RAT project recalculation. Set it to False when modifying + a bunch of properties in a loop changing it to True for the last value to recalculate + project and update all table's dependent widgets. + IMPORTANT: ensure last value differs from the existing one for this property as project + will be not recalculated otherwise. """ if role == QtCore.Qt.ItemDataRole.EditRole or role == QtCore.Qt.ItemDataRole.CheckStateRole: row = index.row() @@ -104,7 +113,7 @@ def setData(self, index, value, role=QtCore.Qt.ItemDataRole.EditRole) -> bool: return False if not self.edit_mode: # recalculate plots if value was changed - recalculate = self.index_header(index) == "value" + recalculate = self.index_header(index) == "value" and recalculate_proj self.parent.update_project(recalculate) self.dataChanged.emit(index, index) return True @@ -260,10 +269,18 @@ def update_model(self, classlist: ratapi.classlist.ClassList): def set_item_delegates(self): """Set item delegates and open persistent editors for the table.""" for i, header in enumerate(self.model.headers): - self.table.setItemDelegateForColumn( - i + self.model.col_offset, - delegates.ValidatedInputDelegate(self.model.item_type.model_fields[header], self.table), - ) + delegate = delegates.ValidatedInputDelegate(self.model.item_type.model_fields[header], self.table) + self.table.setItemDelegateForColumn(i + self.model.col_offset, delegate) + + def get_item_delegates(self, fields_list: list): + """Return list of delegates attached to the fields + with the names provided as input + """ + dlgts = [] + for i, header in enumerate(self.model.headers): + if header in fields_list: + dlgts.append(self.table.itemDelegateForColumn(i + self.model.col_offset)) + return dlgts def append_item(self): """Append an item to the model if the model exists.""" diff --git a/rascal2/widgets/sliders_view.py b/rascal2/widgets/sliders_view.py new file mode 100644 index 00000000..af3193e8 --- /dev/null +++ b/rascal2/widgets/sliders_view.py @@ -0,0 +1,658 @@ +"""Widget for the Sliders View window.""" + +import ratapi.models +from PyQt6 import QtCore, QtWidgets + +from rascal2.widgets.project.tables import ParametersModel + + +class SlidersViewWidget(QtWidgets.QWidget): + """ + The sliders view Widget represents properties user intends to fit. + The sliders allow user to change the properties and immediately see how the change affects contrast. + """ + + def __init__(self, parent): + """ + Initialize widget. + + Parameters + ---------- + parent: MainWindowView + An instance of the MainWindowView + """ + super().__init__() + self.mdi_holder = None # the variable contains reference to mdi container holding this widget + # Will be set up in the presenter, which arranges mdi windows for all MainWindow widgets + self._view_geometry = None # holder for slider view geometry, created to store slider view location + # within the main window for subsequent calls to show sliders. Not yet restored from hdd properly + # inherits project geometry on the first view. + self._parent = parent # reference to main view widget which holds sliders view + + self._values_to_revert = {} # dictionary of values of original properties with fit parameter "true" + # to be restored back into original project if cancel button is pressed. + self._prop_to_change = {} # dictionary of references to SliderChangeHolder classes containing properties + # with fit parameter "true" to build sliders for and allow changes when slider is moved. + # Their values are reflected in project and affect plots. + + self._sliders = {} # dictionary of the sliders used to display fittable values. + + self.__sliders_widgets_layout = None # Placeholder for the area, containing sliders widgets + + # create initial slider view layout and everything else which depends on it + self.init() + + def show(self): + """Overload parent show method to deal with mdi container + showing sliders widget window. Also sets up or updates sliders + widget list depending on previous state of the widget. + """ + + # avoid running init view more than once if sliders are visible. + if self.isVisible(): + return + + self.init() + if self.mdi_holder is None: + self._view_geometry = None + super().show() + else: + if self._view_geometry is None: + # inherit geometry from project view + for window in self._parent.mdi.subWindowList(): + if window.windowTitle() == "Project": + self._view_geometry = window.geometry() + break + + self.mdi_holder.setGeometry(self._view_geometry) + self.mdi_holder.show() + + def hide(self): + """Overload parent hide method to deal with mdi container + hiding slider widgets window + """ + + if self.mdi_holder is None: + super().hide() + else: + # store sliders geometry which may be user changed for the following view + self._view_geometry = self.mdi_holder.geometry() + self.mdi_holder.hide() + + def init(self) -> None: + """Initializes general contents (buttons) of the sliders widget if they have not been initialized. + + If project is defined extracts properties, used to build sliders and generate list of sliders + widgets to control the properties. + """ + if self.findChild(QtWidgets.QWidget, "AcceptButton") is None: + self._create_slider_view_layout() + + if self._parent.presenter.model.project is None: + return # Project may be not initialized at all so project gui is not initialized + + update_sliders = self._init_properties_for_sliders() + if update_sliders: + self._update_sliders_widgets() + else: + self._add_sliders_widgets() + + def _init_properties_for_sliders(self) -> bool: + """Loop through project's widget view tabs and models associated with them and extract + properties used by sliders widgets. + + Select all ParametersModel-s and copy all their properties which have attribute + "Fit" == True into dictionary used to build sliders for them. Also set back-up + dictionary to reset properties values back to their initial values if "Cancel" + button is pressed. + + Requests: SlidersViewWidget with initialized Project. + + Returns + -------- + bool + true if all properties in the project have already had sliders, generated for them + earlier so we may update existing widgets instead of generating new ones. + + Sets up dictionary of slider parameters used to define sliders and sets up connections + necessary to interact with table view, namely: + + 1) slider to table and update graphics -> in the dictionary of slider parameters + 2) change from Table view delegates -> routine which modifies sliders view. + """ + + proj = self._parent.project_widget + if proj is None: + return False + + n_updated_properties = 0 + trial_properties = {} + + for widget in proj.view_tabs.values(): + for table_view in widget.tables.values(): + if not hasattr(table_view, "model"): + continue # usually in tests when table view model is not properly established for all tabs + data_model = table_view.model + if not isinstance(data_model, ParametersModel): + continue # data may be empty + + for row, model_param in enumerate(data_model.classlist): + if model_param.fit: + # Store information about necessary property and the model, which contains the property. + # The model is the source of methods which modify dependent table and force project + # recalculation. + slider_info = SliderChangeHolder(row_number=row, model=data_model, param=model_param) + trial_properties[model_param.name] = slider_info + # Connect delegates which propagate parameters changed in tables to correspondent sliders. + # Can be improved by using item index as these delegates emit "edited" signal for the whole + # column, but row index is presented in signal itself. + this_prop_change_delegates = table_view.get_item_delegates(["min", "max", "value"]) + for delegate in this_prop_change_delegates: + delegate.edit_finished_inform_sliders.connect( + lambda index, + field, + slider_name=model_param.name: self._table_edit_finished_change_slider( + index, field, slider_name + ) + ) + + if model_param.name in self._prop_to_change: + n_updated_properties += 1 + + # if all properties of trial dictionary are in existing dictionary and the number of properties are the same + # no new/deleted sliders have appeared. + # We will update widgets parameters instead of deleting old and creating the new one. + update_properties = ( + n_updated_properties == len(trial_properties) + and len(self._prop_to_change) == n_updated_properties + and n_updated_properties != 0 + ) + + # store information about sliders properties + self._prop_to_change = trial_properties + # remember current values of properties controlled by sliders in case you want to revert them back later + self._values_to_revert = {name: prop.value for name, prop in trial_properties.items()} + + return update_properties + + def _table_edit_finished_change_slider(self, index, field_name: str, slider_name: str) -> None: + """Method to bind with tables delegates and change slider appearance accordingly to changes in tables. + + Signal about slider parameters changed is sent to sliders widget last after all other signals on + table parameters have been processed. + At this stage, rascal properties have already been modified, so we just modify appropriate slider appearance + + Parameters + ---------- + index: QtCore.QtTableIndex + Index of appropriate rascal property in correspondent GUI table. + Duplicates slider name is already in dictionary here so is not currently used. + field_name: str + string indicating changed min/max/value fields of property. May be used later to optimize changes + but benefit of that is minuscules. + slider_name: str + name of the property, slider describes used as a key, which defines slider position in the dictionary + of sliders. + """ + + if self.isVisible(): # Do not bother otherwise, as slider appearance will be modified when made visible and + # slider itself may have even been deleted + self._sliders[slider_name].update_slider_display_from_property(in_constructor=False) + + def _create_slider_view_layout(self) -> None: + """Create sliders layout with all necessary controls and connections + but without sliders themselves. + """ + + main_layout = QtWidgets.QVBoxLayout() + + accept_button = QtWidgets.QPushButton("Accept", self, objectName="AcceptButton") + accept_button.clicked.connect(self._apply_changes_from_sliders) + + cancel_button = QtWidgets.QPushButton("Cancel", self, objectName="CancelButton") + cancel_button.clicked.connect(self._cancel_changes_from_sliders) + + button_layout = QtWidgets.QHBoxLayout() + button_layout.setAlignment(QtCore.Qt.AlignmentFlag.AlignRight) + button_layout.addWidget(accept_button) + button_layout.addWidget(cancel_button) + + main_layout.addLayout(button_layout) + + self.setLayout(main_layout) + + def _add_sliders_widgets(self) -> None: + """Given sliders view layout and list of properties which can be controlled by sliders + add appropriate sliders to sliders view Widget + """ + + if self.__sliders_widgets_layout is None: + main_layout = self.layout() + scroll = QtWidgets.QScrollArea() + scroll.setWidgetResizable(True) # important: resize content to fit area + scroll.setObjectName("Scroll") + main_layout.addWidget(scroll) + content = QtWidgets.QWidget() + scroll.setWidget(content) + # --- Add content layout + content_layout = QtWidgets.QVBoxLayout(content) + self.__sliders_widgets_layout = content_layout + else: + content_layout = self.__sliders_widgets_layout + + # We are adding new sliders, so delete all previous ones. Update is done in another routine. + for slider in self._sliders.values(): + slider.deleteLater() + self._sliders = {} + + if len(self._prop_to_change) == 0: + no_label = EmptySlider() + content_layout.addWidget(no_label) + self._sliders[no_label.slider_name] = no_label + else: + content_layout.setSpacing(0) + for name, prop in self._prop_to_change.items(): + slider = LabeledSlider(prop) + slider.setMaximumHeight(100) + + self._sliders[name] = slider + content_layout.addWidget(slider, alignment=QtCore.Qt.AlignmentFlag.AlignTop) + + def _update_sliders_widgets(self) -> None: + """ + Updates the sliders given the project properties to fit are the same but their values may be modified + """ + for name, prop in self._prop_to_change.items(): + self._sliders[name].update_slider_parameters(prop) + + def _cancel_changes_from_sliders(self): + """Revert changes to values of properties, controlled and modified by sliders + to their initial values and hide sliders view. + """ + + changed_properties = self._identify_changed_properties() + if len(changed_properties) > 0: + last_changed_prop_num = len(changed_properties) - 1 + for prop_num, (name, val) in enumerate(self._values_to_revert.items()): + self._prop_to_change[name].update_value_representation( + val, + recalculate_project=(prop_num == last_changed_prop_num), # it is important to update project for + # last changed property only not to recalculate project multiple times. + ) + # else: all properties value remain the same so no point in reverting to them + + self._parent.toggle_sliders(do_show_sliders=False) + + def _identify_changed_properties(self) -> dict: + """Identify properties changed by sliders from initial sliders state. + + Returns + ------- + :dict + dictionary of the original values for properties changed by sliders. + """ + + changed_properties = {} + for prop_name, value in self._values_to_revert.items(): + if value != self._prop_to_change[prop_name].value: + changed_properties[prop_name] = value + return changed_properties + + def _apply_changes_from_sliders(self) -> None: + """ + Apply changes obtained from sliders to the project and make them permanent + """ + # Changes have already been applied so just hide sliders widget + self._parent.toggle_sliders(False) + return + + +class SliderChangeHolder: + """Helper class containing information necessary for update ratapi parameter and its representation + in project table view when slider position is changed. + """ + + def __init__(self, row_number: int, model: ParametersModel, param: ratapi.models.Parameter) -> None: + """Class Initialization function: + + Parameters + ---------- + row_number: int + the number of the row in the project table, which should be changed + model: rascal2.widgets.project.tables.ParametersModel + parameters model (in QT sense) participating in ParametersTableView + and containing the parameter (below) to modify here. + param: ratapi.models.Parameter + the parameter which value field may be changed by slider widget + """ + self.param = param + self._vis_model = model + self._row_number = row_number + + @property + def name(self): + return self.param.name + + @property + def value(self) -> float: + return self.param.value + + @value.setter + def value(self, value: float) -> None: + self.param.value = value + + def update_value_representation(self, val: float, recalculate_project=True) -> None: + """given new value, updates project table and property representations in the tables + + No checks are necessary as value comes from slider or undo cache + + Parameters + ---------- + val: float + new value to set up slider position according to the slider's numerical scale + (recalculated into actual integer position) + recalculate_project: bool + if True, run ratapi calculations and update representation of results in all dependent widgets. + if False, just update tables and properties + """ + # value for ratapi parameter is defined in column 4 and this number is hardwired here + # should be a better way of doing this. + index = self._vis_model.index(self._row_number, 4) + self._vis_model.setData(index, val, QtCore.Qt.ItemDataRole.EditRole, recalculate_project) + + +class LabeledSlider(QtWidgets.QFrame): + """Class describes slider widget which allows modifying rascal property value and its representation + in project table view. + + It also connects with table view and accepts changes in min/max/value + obtained from property. + """ + + # Class attributes of slider widget which usually remain the same for all classes. + # Affect all sliders behaviour so are global. + _num_slider_ticks: int = 10 + _slider_max_idx: int = 100 # defines accuracy of slider motion + _ticks_step: int = 10 # Number of sliders ticks + _value_label_format: str = ( + "{:.4g}" # format to display slider value. Should be not too accurate as slider accuracy is 1/100 + ) + _tick_label_format: str = "{:.2g}" # format to display numbers under the sliders ticks + + def __init__(self, param: SliderChangeHolder): + """Construct LabeledSlider for a particular property + + Parameters + ---------- + param: SliceChangeHolder + instance of the SliderChangeHolder class, containing reference to the property to be modified by + slider and the reference to visual model, which controls the position and the place of this + property in the correspondent project table. + """ + + super().__init__() + # Defaults for property min/max. Will be overwritten from actual input property + self._value_min = 0 # minimal value property may have + self._value_max = 100 # maximal value property may have + self._value = 50 # cache for property value + self._value_range = 100 # difference between maximal and minimal values of the property + self._value_step = 1 # the change in property value per single step slider move + + self._prop = param # hold the property controlled by slider + if param is None: + return + + self._labels = [] # list of slider labels describing sliders axis + + self.slider_name = param.name # name the slider as the property it refers to. Sets up once here. + self.update_slider_parameters(param, in_constructor=True) # Retrieve slider's parameters from input property + + # Build all sliders widget and arrange them as expected + self._slider = self._build_slider(param.value) + + # name of given slider can not change. It will be different slider with different name + name_label = QtWidgets.QLabel(self.slider_name, alignment=QtCore.Qt.AlignmentFlag.AlignLeft) + self._value_label = QtWidgets.QLabel( + self._value_label_format.format(self._value), alignment=QtCore.Qt.AlignmentFlag.AlignRight + ) + lab_layout = QtWidgets.QHBoxLayout() + lab_layout.addWidget(name_label) + lab_layout.addWidget(self._value_label) + + # layout for numeric scale below + scale_layout = QtWidgets.QHBoxLayout() + + tick_step = self._value_range / self._num_slider_ticks + middle_val = self._value_min + 0.5 * self._value_range + middle_min = middle_val - 0.5 * tick_step + middle_max = middle_val + 0.5 * tick_step + for idx in range(0, self._num_slider_ticks + 1): + tick_value = ( + self._value_min + idx * tick_step + ) # it is not _slider_idx_to_value as tick step there is different + label = QtWidgets.QLabel(self._tick_label_format.format(tick_value)) + if tick_value < middle_min: + label.setAlignment(QtCore.Qt.AlignmentFlag.AlignLeft) + elif tick_value > middle_max: + label.setAlignment(QtCore.Qt.AlignmentFlag.AlignRight) + else: + label.setAlignment(QtCore.Qt.AlignmentFlag.AlignHCenter) + + scale_layout.addWidget(label) + self._labels.append(label) + + layout = QtWidgets.QVBoxLayout(self) + layout.addLayout(lab_layout) + layout.addWidget(self._slider) + layout.addLayout(scale_layout) + + # signal to update label dynamically and change all dependent properties + self._slider.valueChanged.connect(self._update_value) + + self.setObjectName(self.slider_name) + self.setFrameShape(QtWidgets.QFrame.Shape.Box) + self.setFrameShadow(QtWidgets.QFrame.Shadow.Plain) + self.setMaximumHeight(self._slider.height()) + + def set_slider_gui_position(self, value: float) -> None: + """Set specified slider GUI position programmatically. + + As value assumed to be already correct, block signal + for change, associated with slider position change in GUI + + Parameters + ---------- + value: float + new float value of the slider + """ + self._value = value + self._value_label.setText(self._value_label_format.format(value)) + + idx = self._value_to_slider_pos(value) + + self._slider.blockSignals(True) + self._slider.setValue(idx) + self._slider.blockSignals(False) + + def update_slider_parameters(self, param: SliderChangeHolder, in_constructor=False): + """Modifies slider values which may change for this slider from his parent property + + Parameters + ---------- + param: SliderChangeHolder + instance of the SliderChangeHolder class, containing updated values for the slider + in_constructor: bool,default False + logical value, indicating that the method is invoked in constructor. If true, + some additional initialization will be performed. + """ + self._prop = param + # Changing RASCAL property this slider modifies is currently prohibited, + # as property connected through table model and project parameters: + if self._prop.name != self.slider_name: + # This should not happen but if it is, ensure failure. Something wrong with logic. + raise RuntimeError("Existing slider may be responsible for only one property") + self.update_slider_display_from_property(in_constructor) + + def update_slider_display_from_property(self, in_constructor: bool) -> None: + """Change internal sliders parameters and their representation in GUI + if property, underlying sliders parameters have changed. + + Bound to event received from delegate when table values are changed. + + Parameters + ---------- + in_constructor: bool,default False + logical value, indicating that the method is invoked in constructor. If True, + avoid change in graphics as these changes + graphics initialization + will be performed separately. + """ + # note the order of methods in comparison. Should be as here, as may break + # property updates in constructor otherwise. + if not (self._updated_from_rascal_property() or in_constructor): + return + + self._value_range = self._value_max - self._value_min + # the change in property value per single step slider move + self._value_step = self._value_range / self._slider_max_idx + + if in_constructor: + return + # otherwise, update slider's labels + self.set_slider_gui_position(self._value) + tick_step = self._value_range / self._num_slider_ticks + for idx in range(0, self._num_slider_ticks + 1): + tick_value = self._value_min + idx * tick_step + self._labels[idx].setText(self._tick_label_format.format(tick_value)) + + def _updated_from_rascal_property(self) -> bool: + """Check if rascal property values related to slider widget have changed + and update them accordingly + + Returns: + ------- + True if change detected and False otherwise + """ + updated = False + if self._value_min != self._prop.param.min: + self._value_min = self._prop.param.min + updated = True + if self._value_max != self._prop.param.max: + self._value_max = self._prop.param.max + updated = True + if self._value != self._prop.param.value: + self._value = self._prop.param.value + updated = True + return updated + + def _value_to_slider_pos(self, value: float) -> int: + """Convert double (property) value into slider position + + Parameters: + ----------- + value : float + double value within slider's min-max range to identify integer + position corresponding to this value + + Returns: + -------- + index : int + integer position within 0-self._slider_max_idx range corresponding to input value + """ + return int(round(self._slider_max_idx * (value - self._value_min) / self._value_range, 0)) + + def _slider_pos_to_value(self, index: int) -> float: + """Convert slider GUI position (index) into double property value + + Parameters + ---------- + index : int + integer position within 0-self._slider_max_idx range to process + + Returns + ------- + value : float + double value within slider's min-max range corresponding to input index + """ + + value = self._value_min + index * self._value_step + if value > self._value_max: # This should not happen but do occur due to round-off errors + value = self._value_max + return value + + def _build_slider(self, initial_value: float) -> QtWidgets.QSlider: + """Construct slider widget with integer scales and ticks in integer positions + + Part of slider constructor + + Parameters + ---------- + value : float + double value within slider's min-max range to identify integer + position corresponding to this value. + + Returns + ------- + QtWidgets.QSlider instance + with settings, corresponding to input parameters. + """ + + slider = QtWidgets.QSlider(QtCore.Qt.Orientation.Horizontal) + slider.setMinimum(0) + slider.setMaximum(self._slider_max_idx) + slider.setTickInterval(self._ticks_step) + slider.setSingleStep(self._slider_max_idx) + slider.setTickPosition(QtWidgets.QSlider.TickPosition.TicksBothSides) + slider.setValue(self._value_to_slider_pos(initial_value)) + + return slider + + def _update_value(self, idx: int) -> None: + """Method which converts slider position into double property value + and informs all dependent clients about this. + + Bound in constructor to GUI slider position changed event + + Parameters + ---------- + idx : int + integer position of slider deal in GUI + + """ + val = self._slider_pos_to_value(idx) + self._value = val + self._value_label.setText(self._value_label_format.format(val)) + + self._prop.update_value_representation(val) + # This should not be necessary as already done through setter above + self._prop.param.value = val # but fast and nice for tests + + +class EmptySlider(LabeledSlider): + def __init__(self): + """Construct empty slider which have interface of LabeledSlider but no properties + associated with it + + Parameters + ---------- + All input parameters are ignored + """ + super().__init__(None) + + name_label = QtWidgets.QLabel( + "There are no fitted parameters.\n" + " Select parameters to fit in the project view to populate the sliders view.", + alignment=QtCore.Qt.AlignmentFlag.AlignCenter, + ) + layout = QtWidgets.QVBoxLayout(self) + layout.addWidget(name_label) + self.slider_name = "Empty Slider" + self.setObjectName(self.slider_name) + + def set_slider_gui_position(self, value: float) -> None: + return + + def update_slider_parameters(self, param: SliderChangeHolder, in_constructor=False): + return + + def update_slider_display_from_property(self, in_constructor: bool) -> None: + return diff --git a/tests/test_ui.py b/tests/test_ui.py index 5c27f046..849d22cb 100644 --- a/tests/test_ui.py +++ b/tests/test_ui.py @@ -39,7 +39,7 @@ def test_integration(qt_application, make_main_window): window.presenter.create_project("project", ".") names = [win.windowTitle() for win in window.mdi.subWindowList()] # QMDIArea is first in last out hence the reversed list - assert names == ["Fitting Controls", "Terminal", "Project", "Plots"] + assert names == ["Fitting Controls", "Terminal", "Project", "Plots", "Sliders View"] # Work through the different sections of the UI diff --git a/tests/ui/test_presenter.py b/tests/ui/test_presenter.py index 80aa9e37..9af92fcc 100644 --- a/tests/ui/test_presenter.py +++ b/tests/ui/test_presenter.py @@ -44,6 +44,7 @@ def __init__(self): self.logging = MagicMock() self.settings = MagicMock() self.get_project_folder = lambda: "new path/" + self.sliders_view_widget = MagicMock() @pytest.fixture diff --git a/tests/ui/test_view.py b/tests/ui/test_view.py index 70a5d128..4ab7d776 100644 --- a/tests/ui/test_view.py +++ b/tests/ui/test_view.py @@ -64,12 +64,16 @@ def test_reset_mdi(self, mock1, mock2, mock3, test_view, geometry): test_view.settings = Settings() test_view.setup_mdi() test_view.settings.mdi_defaults = MDIGeometries( - plots=geometry[0], project=geometry[1], terminal=geometry[2], controls=geometry[3] + Plots=geometry[0], + Project=geometry[1], + Terminal=geometry[2], + FittingControls=geometry[3], + SlidersView=geometry[4], ) test_view.reset_mdi_layout() for window in test_view.mdi.subWindowList(): # get corresponding MDIGeometries entry for the widget - widget_name = window.windowTitle().lower().split(" ")[-1] + widget_name = window.windowTitle().replace(" ", "") w_geom = window.geometry() assert getattr(test_view.settings.mdi_defaults, widget_name) == ( w_geom.x(), @@ -86,7 +90,7 @@ def test_set_mdi(self, mock1, mock2, mock3, test_view, geometry): widgets_in_order = [] for i, window in enumerate(test_view.mdi.subWindowList()): - widgets_in_order.append(window.windowTitle().lower().split(" ")[-1]) + widgets_in_order.append(window.windowTitle().replace(" ", "")) window.setGeometry(*geometry[i][0:4]) if geometry[i][4] is True: window.showMinimized() @@ -198,20 +202,127 @@ def test_help_menu_actions_present(test_view, submenu_name, action_names_and_lay assert action.text() == name -def test_toggle_slider(): - mw = MainWindowView() - with patch.object(mw, "project_widget") as project_mock: - show_text = mw.toggle_slider_action.property("show_text") - hide_text = mw.toggle_slider_action.property("hide_text") - assert mw.toggle_slider_action.text() == show_text - project_mock.show_slider_view.assert_not_called() - project_mock.show_project_view.assert_not_called() - - mw.toggle_sliders() - - assert mw.toggle_slider_action.text() == hide_text - project_mock.show_slider_view.assert_called_once() +@pytest.fixture +def test_view_with_mdi(): + """An instance of MainWindowView with mdi property defined to some rubbish + for mimicking operations performed in MainWindowView.reset_mdi_layout + """ - mw.toggle_sliders() - assert mw.toggle_slider_action.text() == show_text - project_mock.show_project_view.assert_called_once() + mw = MainWindowView() + mw.mdi.addSubWindow(mw.sliders_view_widget) + mdi_windows = mw.mdi.subWindowList() + mw.sliders_view_widget.mdi_holder = mdi_windows[0] + mw.enable_elements() + return mw + + +@patch("rascal2.ui.view.SlidersViewWidget.show") +@patch("rascal2.ui.view.SlidersViewWidget.hide") +def test_toggle_slider(mock_hide, mock_show, test_view_with_mdi): + mw = test_view_with_mdi + + show_text = mw._toggle_slider_action.property("show_text") + hide_text = mw._toggle_slider_action.property("hide_text") + assert mw._toggle_slider_action.text() == show_text + mw.toggle_sliders() + + assert mw._toggle_slider_action.text() == hide_text + mock_show.assert_called_once() + + mw.toggle_sliders() + assert mw._toggle_slider_action.text() == show_text + mock_hide.assert_called_once() + + +@patch("rascal2.ui.view.SlidersViewWidget.show") +@patch("rascal2.ui.view.SlidersViewWidget.hide") +def test_click_on_select_sliders_works_as_expected(mock_hide, mock_show, test_view_with_mdi): + """Test if click on menu in the state "Show Slider" changes text appropriately + and initiates correct callback + """ + + # check initial state -- defined now but needs to be refactored when + # this may be included in configuration + assert not test_view_with_mdi.show_sliders + + main_menu = test_view_with_mdi.menuBar() + submenu = main_menu.findChild(QtWidgets.QMenu, "&Tools") + all_actions = submenu.actions() + + # Trigger the action + all_actions[0].trigger() + assert all_actions[0].text() == "Hide &Sliders" + assert test_view_with_mdi.show_sliders + assert mock_show.call_count == 1 + + +@patch("rascal2.ui.view.SlidersViewWidget.show") +@patch("rascal2.ui.view.SlidersViewWidget.hide") +def test_click_on_select_tabs_works_as_expected(mock_hide, mock_show, test_view_with_mdi): + """Test if click on menu in the state "Show Sliders" changes text appropriately + and initiates correct callback + """ + + # check initial state -- defined now but needs to be refactored when + # this may be included in configuration + assert not test_view_with_mdi.show_sliders + + main_menu = test_view_with_mdi.menuBar() + submenu = main_menu.findChild(QtWidgets.QMenu, "&Tools") + all_actions = submenu.actions() + + # Trigger the action + all_actions[0].trigger() + assert test_view_with_mdi.show_sliders + assert mock_show.call_count == 1 # this would show sliders widget + # check if next click returns to initial state + all_actions[0].trigger() + + assert all_actions[0].text() == "Show &Sliders" + assert not test_view_with_mdi.show_sliders + assert mock_hide.call_count == 1 # this would hide sliders widget + + +@patch("rascal2.ui.view.SlidersViewWidget.hide") +def test_enable_disable_sliders_menu_without_params(mock_hide, test_view_with_mdi): + """Test if click on menu in the state "Show Sliders" changes text appropriately + and initiates correct callback + """ + assert not test_view_with_mdi.sliders_view_widget.isVisible() + + main_menu = test_view_with_mdi.menuBar() + submenu = main_menu.findChild(QtWidgets.QMenu, "&Tools") + all_actions = submenu.actions() + assert all_actions[0].isEnabled() # enabled in patch + + test_view_with_mdi.sliders_view_enabled(False) + assert not all_actions[0].isEnabled() # disabled now + assert mock_hide.call_count == 1 # this would hide sliders widget if it was visible + + test_view_with_mdi.sliders_view_enabled(True) + assert all_actions[0].isEnabled() # enabled now + assert mock_hide.call_count == 2 # still call hide as this was the previous state + + # here as it remembers the call state + + +@patch("rascal2.ui.view.SlidersViewWidget.show") +@patch("rascal2.ui.view.SlidersViewWidget.hide") +def test_enable_disable_sliders_menu_with_params(mock_hide, mock_show, test_view_with_mdi): + """Test if click on menu in the state "Show Sliders" changes text appropriately + and initiates correct callback + """ + assert not test_view_with_mdi.sliders_view_widget.isVisible() + + main_menu = test_view_with_mdi.menuBar() + submenu = main_menu.findChild(QtWidgets.QMenu, "&Tools") + all_actions = submenu.actions() + assert all_actions[0].isEnabled() # enabled in patch + + test_view_with_mdi.sliders_view_enabled(False, True) + assert not all_actions[0].isEnabled() + assert mock_hide.call_count == 1 # this would hide sliders widget if it was visible + + test_view_with_mdi.sliders_view_enabled(True, True) + assert all_actions[0].isEnabled() # enabled now + assert mock_show.call_count == 1 # call show as this was the previous prev_call_vis_sliders_state state diff --git a/tests/widgets/project/test_models.py b/tests/widgets/project/test_models.py index 22ddc14d..5ef09a81 100644 --- a/tests/widgets/project/test_models.py +++ b/tests/widgets/project/test_models.py @@ -254,39 +254,90 @@ def test_parameter_flags(param_model, prior_type, protected): assert item_flags & QtCore.Qt.ItemFlag.ItemIsEditable -def test_param_item_delegates(param_classlist): - """Test that parameter models have the expected item delegates.""" +@pytest.fixture +def widget_with_delegates(): widget = ParameterFieldWidget("Test", parent) widget.parent = MagicMock() - widget.update_model(param_classlist([])) - for column, header in enumerate(widget.model.headers, start=1): + param = [ratapi.models.Parameter() for i in [0, 1, 2]] + class_list = ratapi.ClassList(param) + widget.update_model(class_list) + + return widget + + +def test_param_item_delegates(widget_with_delegates): + """Test that parameter models have the expected item delegates.""" + + for column, header in enumerate(widget_with_delegates.model.headers, start=1): if header in ["min", "value", "max"]: - assert isinstance(widget.table.itemDelegateForColumn(column), delegates.ValueSpinBoxDelegate) + assert isinstance(widget_with_delegates.table.itemDelegateForColumn(column), delegates.ValueSpinBoxDelegate) else: - assert isinstance(widget.table.itemDelegateForColumn(column), delegates.ValidatedInputDelegate) + assert isinstance( + widget_with_delegates.table.itemDelegateForColumn(column), delegates.ValidatedInputDelegate + ) + + +def test_param_item_delegates_exposed_to_sliders(widget_with_delegates): + """Test that parameter models provides the item delegates related to slides""" + + delegates_list = widget_with_delegates.get_item_delegates(["min", "max", "value"]) + assert len(delegates_list) == 3 + + for delegate in delegates_list: + assert isinstance(delegate, delegates.ValueSpinBoxDelegate) + +class MockReceiver: + """Test object which receives signals sent to slider.""" -def test_hidden_bayesian_columns(param_classlist): + def __init__(self): + self.cache_state = [] + self.call_count = 0 + + def receive_signal(self, index, value): + """To bind to delegate signal.""" + self.call_count += 1 + self.cache_state = (index, value) + + +def test_param_item_delegates_emit_to_slider_subscribers(widget_with_delegates): + """Test if edit_finished signals emitted to subscribed clients.""" + sr = MockReceiver() + selected_fields = ["min", "value", "max"] + + # Expected order of delegates in the property should be is as in the list. + delegates_list = widget_with_delegates.get_item_delegates(selected_fields) + for delegate in delegates_list: + delegate.edit_finished_inform_sliders.connect(lambda idx, tab_name: sr.receive_signal(idx, tab_name)) + + index = widget_with_delegates.model.index(1, 1) + mc_editor = MagicMock() + + for n_calls, (delegate, field_name) in enumerate(zip(delegates_list, selected_fields, strict=True), start=1): + delegate.setModelData(mc_editor, widget_with_delegates.model, index) + assert sr.call_count == n_calls + assert sr.cache_state == (index, field_name) + + +def test_hidden_bayesian_columns(widget_with_delegates): """Test that Bayes columns are hidden when procedure is not Bayesian.""" - widget = ParameterFieldWidget("Test", parent) - widget.parent = MagicMock() - widget.update_model(param_classlist([])) - mock_controls = widget.parent.parent.parent_model.controls = MagicMock() + + mock_controls = widget_with_delegates.parent.parent.parent_model.controls = MagicMock() mock_controls.procedure = "calculate" bayesian_columns = ["prior_type", "mu", "sigma"] - widget.handle_bayesian_columns("calculate") + widget_with_delegates.handle_bayesian_columns("calculate") for item in bayesian_columns: - index = widget.model.headers.index(item) - assert widget.table.isColumnHidden(index + 1) + index = widget_with_delegates.model.headers.index(item) + assert widget_with_delegates.table.isColumnHidden(index + 1) - widget.handle_bayesian_columns("dream") + widget_with_delegates.handle_bayesian_columns("dream") for item in bayesian_columns: - index = widget.model.headers.index(item) - assert not widget.table.isColumnHidden(index + 1) + index = widget_with_delegates.model.headers.index(item) + assert not widget_with_delegates.table.isColumnHidden(index + 1) def test_layer_model_init(): @@ -383,7 +434,7 @@ def test_layer_widget_delegates(init_list): "hydrate_with": delegates.ValidatedInputDelegate, } - widget = LayerFieldWidget("test", parent) + widget = LayerFieldWidget("Test", parent) widget.update_model(init_list) for i, header in enumerate(widget.model.headers): diff --git a/tests/widgets/project/test_project.py b/tests/widgets/project/test_project.py index d8cd7053..7b14ef71 100644 --- a/tests/widgets/project/test_project.py +++ b/tests/widgets/project/test_project.py @@ -1,4 +1,4 @@ -from unittest.mock import MagicMock +from unittest.mock import MagicMock, patch import pydantic import pytest @@ -6,6 +6,7 @@ from PyQt6 import QtCore, QtWidgets from ratapi.utils.enums import Calculations, Geometries, LayerModels +from rascal2.widgets import SlidersViewWidget from rascal2.widgets.project.project import ProjectTabWidget, ProjectWidget, create_draft_project from rascal2.widgets.project.tables import ( ClassListTableModel, @@ -37,7 +38,21 @@ def __init__(self): self.presenter = MockPresenter() self.controls_widget = MagicMock() self.project_widget = None - self.toggle_sliders = MagicMock() + self.sliders_view_widget = SlidersViewWidget(self) + + def toggle_sliders(self, do_show_sliders=True): + if do_show_sliders: + self.sliders_view_widget.show() + else: + self.sliders_view_widget.hide() + + def sliders_view_enabled(self, is_enabled: bool, prev_call_vis_sliders_state: bool = False): + self.sliders_view_widget.setEnabled(is_enabled) + # hide sliders when disabled or else + if is_enabled: + self.toggle_sliders(do_show_sliders=prev_call_vis_sliders_state) + else: + self.toggle_sliders(do_show_sliders=False) class DataModel(pydantic.BaseModel, validate_assignment=True): @@ -154,7 +169,8 @@ def test_project_widget_initial_state(setup_project_widget): assert project_widget.edit_project_tab.currentIndex() == 0 -def test_edit_cancel_button_toggle(setup_project_widget): +@patch("rascal2.ui.view.SlidersViewWidget.hide") +def test_edit_cancel_button_toggle(mock_hide, setup_project_widget): """ Tests clicking the edit button causes the stacked widget to change state. """ @@ -163,6 +179,7 @@ def test_edit_cancel_button_toggle(setup_project_widget): assert project_widget.stacked_widget.currentIndex() == 0 project_widget.edit_project_button.click() assert project_widget.stacked_widget.currentIndex() == 1 + assert mock_hide.call_count == 1 assert project_widget.geometry_combobox.currentText() == Geometries.AirSubstrate assert project_widget.model_combobox.currentText() == LayerModels.StandardLayers @@ -176,24 +193,25 @@ def test_edit_cancel_button_toggle(setup_project_widget): assert project_widget.calculation_type.text() == Calculations.Normal -def test_save_changes_to_model_project(setup_project_widget): +@patch("rascal2.ui.view.SlidersViewWidget.hide") +def test_save_changes_to_model_project(mock_hide, setup_project_widget): """ Tests that making changes to the project settings """ - project_widget = setup_project_widget - project_widget.edit_project_button.click() + setup_project_widget.edit_project_button.click() + assert mock_hide.call_count == 1 - project_widget.calculation_combobox.setCurrentText(Calculations.Domains) - project_widget.geometry_combobox.setCurrentText(Geometries.SubstrateLiquid) - project_widget.model_combobox.setCurrentText(LayerModels.CustomXY) + setup_project_widget.calculation_combobox.setCurrentText(Calculations.Domains) + setup_project_widget.geometry_combobox.setCurrentText(Geometries.SubstrateLiquid) + setup_project_widget.model_combobox.setCurrentText(LayerModels.CustomXY) - assert project_widget.draft_project["geometry"] == Geometries.SubstrateLiquid - assert project_widget.draft_project["model"] == LayerModels.CustomXY - assert project_widget.draft_project["calculation"] == Calculations.Domains + assert setup_project_widget.draft_project["geometry"] == Geometries.SubstrateLiquid + assert setup_project_widget.draft_project["model"] == LayerModels.CustomXY + assert setup_project_widget.draft_project["calculation"] == Calculations.Domains - project_widget.save_changes() - assert project_widget.parent.presenter.edit_project.call_count == 1 + setup_project_widget.save_changes() + assert setup_project_widget.parent.presenter.edit_project.call_count == 1 def test_cancel_changes_to_model_project(setup_project_widget): diff --git a/tests/widgets/project/test_slider_view.py b/tests/widgets/project/test_slider_view.py deleted file mode 100644 index c57aa0d5..00000000 --- a/tests/widgets/project/test_slider_view.py +++ /dev/null @@ -1,125 +0,0 @@ -from unittest.mock import MagicMock, patch - -import pytest -import ratapi -from PyQt6 import QtWidgets - -from rascal2.ui.view import MainWindowView -from rascal2.widgets.project.project import create_draft_project -from rascal2.widgets.project.slider_view import LabeledSlider, SliderViewWidget - - -@pytest.fixture -def draft_project(): - draft = create_draft_project(ratapi.Project()) - draft["parameters"] = ratapi.ClassList( - [ - ratapi.models.Parameter(name="Param 1", min=1, max=10, value=2.1, fit=True), - ratapi.models.Parameter(name="Param 2", min=10, max=100, value=20, fit=True), - ] - ) - draft["bulk_in"] = ratapi.ClassList( - [ - ratapi.models.Parameter(name="H2O", min=0, max=1, value=0.2, fit=True), - ] - ) - draft["bulk_out"] = ratapi.ClassList( - [ - ratapi.models.Parameter(name="Silicon", min=0, max=1, value=0.2, fit=True), - ] - ) - draft["scalefactors"] = ratapi.ClassList( - [ - ratapi.models.Parameter(name="Scale Factor 1", min=0, max=1, value=0.2, fit=True), - ] - ) - draft["background_parameters"] = ratapi.ClassList( - [ - ratapi.models.Parameter(name="Background Param 1", min=0, max=1, value=0.2, fit=True), - ] - ) - draft["resolution_parameters"] = ratapi.ClassList( - [ - ratapi.models.Parameter(name="Resolution Param 1", min=0, max=1, value=0.2, fit=True), - ] - ) - draft["domain_ratios"] = ratapi.ClassList( - [ - ratapi.models.Parameter(name="Domain ratio 1", min=0, max=1, value=0.2, fit=True), - ] - ) - - return draft - - -def test_no_sliders_creation(): - """Sliders should be created for fitted parameter only""" - mw = MainWindowView() - draft = create_draft_project(ratapi.Project()) - draft["parameters"][0].fit = False - slider_view = SliderViewWidget(draft, mw) - assert len(slider_view.parameters) == 0 - assert len(slider_view._sliders) == 0 - label = slider_view.slider_content_layout.takeAt(0).widget() - assert label.text().startswith("There are no fitted parameters") - - -def test_sliders_creation(draft_project): - """Sliders should be created for fitted parameter only""" - mw = MainWindowView() - slider_view = SliderViewWidget(draft_project, mw) - - assert len(slider_view.parameters) == 8 - assert len(slider_view._sliders) == 8 - - for param_name, slider_name in zip(slider_view.parameters, slider_view._sliders, strict=True): - assert param_name == slider_name - - draft_project["parameters"][0].fit = False - slider_view = SliderViewWidget(draft_project, mw) - assert len(slider_view.parameters) == 7 - assert draft_project["parameters"][0].name not in slider_view._sliders - - -def test_slider_buttons(): - mw = MainWindowView() - draft = create_draft_project(ratapi.Project()) - mw.toggle_sliders = MagicMock() - mw.plot_widget.update_plots = MagicMock() - mw.presenter.edit_project = MagicMock() - - slider_view = SliderViewWidget(draft, mw) - buttons = slider_view.findChildren(QtWidgets.QPushButton) - accept_button = buttons[0] - accept_button.click() - mw.toggle_sliders.assert_called_once() - mw.presenter.edit_project.assert_called_once_with(draft) - - mw.toggle_sliders.reset_mock() - reject_button = buttons[1] - reject_button.click() - mw.toggle_sliders.assert_called_once() - mw.plot_widget.update_plots.assert_called_once() - - -@pytest.mark.parametrize( - "param", - [ - ratapi.models.Parameter(name="Param 1", min=1, max=75, value=21, fit=True), - ratapi.models.Parameter(name="Param 2", min=-0.1, max=0.5, value=0.3, fit=True), - ratapi.models.Parameter(name="Param 3", min=3, max=3, value=3, fit=True), - ], -) -@patch("rascal2.widgets.project.slider_view.SliderViewWidget", autospec=True) -def test_labelled_slider_value(slider_view, param): - slider_view.update_result_and_plots = MagicMock() - slider = LabeledSlider(param, slider_view) - # actual range of the slider should never change but - # value would be scaled to parameter range. - assert slider._slider.maximum() == 100 - assert slider._slider.minimum() == 0 - assert slider._slider.value() == slider._param_value_to_slider_value(param.value) - - slider._slider.setValue(79) - assert param.value == slider._slider_value_to_param_value(slider._slider.value()) - slider_view.update_result_and_plots.assert_called_once() diff --git a/tests/widgets/test_labeled_slider_class.py b/tests/widgets/test_labeled_slider_class.py new file mode 100644 index 00000000..11fd1135 --- /dev/null +++ b/tests/widgets/test_labeled_slider_class.py @@ -0,0 +1,133 @@ +import pydantic +import pytest +import ratapi +from PyQt6 import QtCore, QtWidgets + +from rascal2.widgets.project.tables import ParametersModel +from rascal2.widgets.sliders_view import LabeledSlider, SliderChangeHolder + + +class ParametersModelMock(ParametersModel): + _value: float + _index: QtCore.QModelIndex + _role: QtCore.Qt.ItemDataRole + _recalculate_proj: bool + call_count: int + + def __init__(self, class_list: ratapi.ClassList, parent: QtWidgets.QWidget): + super().__init__(class_list, parent) + self.call_count = 0 + + def setData( + self, index: QtCore.QModelIndex, val: float, qt_role=QtCore.Qt.ItemDataRole.EditRole, recalculate_project=True + ) -> bool: + self._index = index + self._value = val + self._role = qt_role + self._recalculate_proj = recalculate_project + self.call_count += 1 + return True + + +class DataModel(pydantic.BaseModel, validate_assignment=True): + """A test Pydantic model.""" + + name: str + min: float + max: float + value: float + fit: bool + show_priors: bool + + +@pytest.fixture +def slider(): + param = ratapi.models.Parameter(name="Test Slider", min=1, max=10, value=2.1, fit=True) + parent = QtWidgets.QWidget() + class_view = ratapi.ClassList( + [ + DataModel(name="Slider_A", min=0, value=1, max=100, fit=True, show_priors=False), + DataModel(name="Slider_B", min=0, value=1, max=100, fit=True, show_priors=False), + DataModel(name="Slider_C", min=0, value=1, max=100, fit=True, show_priors=False), + ] + ) + model = ParametersModelMock(class_view, parent) + # note 3 elements in ratapi.ClassList needed for row_number == 2 to work + inputs = SliderChangeHolder(row_number=2, model=model, param=param) + return LabeledSlider(inputs) + + +def test_a_slider_construction(slider): + """constructing a slider widget works and have all necessary properties""" + assert slider.slider_name == "Test Slider" + assert slider._value_min == 1 + assert slider._value_range == 10 - 1 + assert slider._value == 2.1 + assert slider._value_step == 9 / 100 + assert len(slider._labels) == 11 + + +def test_a_slider_label_range(slider): + """check if labels cover whole property range""" + assert len(slider._labels) == 11 + assert slider._labels[0].text() == slider._tick_label_format.format(1) + assert slider._labels[-1].text() == slider._tick_label_format.format(10) + + +def test_a_slider_value_text(slider): + """check if slider have correct value label""" + assert slider._value_label.text() == slider._value_label_format.format(2.1) + + +def test_set_slider_value_changes_label(slider): + """check if slider accepts correct value and uses correct index""" + slider.set_slider_gui_position(4) + assert slider._value_label.text() == slider._value_label_format.format(4) + idx = slider._value_to_slider_pos(4) + assert slider._slider.value() == idx + + +def test_set_slider_max_value_in_range(slider): + """round-off error keep sliders within the ranges""" + slider.set_slider_gui_position(slider._value_max) + assert slider._value_label.text() == slider._value_label_format.format(slider._value_max) + assert slider._slider.value() == slider._slider_max_idx + + +def test_set_slider_min_value_in_range(slider): + """round-off error keep sliders within the ranges""" + slider.set_slider_gui_position(slider._value_min) + assert slider._value_label.text() == slider._value_label_format.format(slider._value_min) + assert slider._slider.value() == 0 + + +def test_set_value_do_correct_calls(slider): + """update value bound correctly and does correct calls""" + + assert slider._prop._vis_model.call_count == 0 + slider._slider.setValue(50) + float_val = slider._slider_pos_to_value(50) + assert float_val == slider._value + assert slider._slider.value() == 50 + assert slider._prop._vis_model.call_count == 1 + assert slider._prop._vis_model._value == float_val + assert slider._prop._vis_model._index.row() == 2 # row number in slider fixture + assert slider._prop._vis_model._role == QtCore.Qt.ItemDataRole.EditRole # row number in slider fixture + + +@pytest.mark.parametrize( + "minmax_slider_idx, min_max_prop_value", + [ + (0, 1), # min_max indices are the indices hardwired in class and + (100, 10), # min_max values are the values supplied for property in the slider fixture + ], +) +def test_set_values_in_limits_work(slider, minmax_slider_idx, min_max_prop_value): + """update_value bound correctly and does correct calls at limiting values""" + + slider._slider.setValue(minmax_slider_idx) + assert min_max_prop_value == slider._value + assert slider._slider.value() == minmax_slider_idx + assert slider._value == min_max_prop_value + assert slider._prop._vis_model._value == min_max_prop_value + assert slider._prop.param.value == min_max_prop_value diff --git a/tests/widgets/test_sliders_widget.py b/tests/widgets/test_sliders_widget.py new file mode 100644 index 00000000..4ea4bf83 --- /dev/null +++ b/tests/widgets/test_sliders_widget.py @@ -0,0 +1,279 @@ +from unittest.mock import patch + +import pytest +import ratapi +from PyQt6 import QtWidgets + +from rascal2.ui.view import MainWindowView +from rascal2.widgets.project.project import ProjectWidget, create_draft_project +from rascal2.widgets.project.tables import ParameterFieldWidget +from rascal2.widgets.sliders_view import EmptySlider, LabeledSlider, SlidersViewWidget + + +class MockFigureCanvas(QtWidgets.QWidget): + """A mock figure canvas.""" + + def draw(*args, **kwargs): + pass + + +@pytest.fixture +def view_with_proj(): + """An instance of MainWindowView with project partially defined + for mimicking sliders generation from project tabs + """ + mw = MainWindowView() + + draft = create_draft_project(ratapi.Project()) + draft["parameters"] = ratapi.ClassList( + [ + ratapi.models.Parameter(name="Param 1", min=1, max=10, value=2.1, fit=True), + ratapi.models.Parameter(name="Param 2", min=10, max=100, value=20, fit=False), + ratapi.models.Parameter(name="Param 3", min=100, max=1000, value=209, fit=True), + ratapi.models.Parameter(name="Param 4", min=200, max=2000, value=409, fit=True), + ] + ) + draft["background_parameters"] = ratapi.ClassList( + [ + ratapi.models.Parameter(name="Background Param 1", min=0, max=1, value=0.2, fit=False), + ] + ) + project = ratapi.Project(name="Sliders Test Project") + for param in draft["parameters"]: + project.parameters.append(param) + for param in draft["background_parameters"]: + project.parameters.append(param) + + mw.project_widget.view_tabs["Parameters"].update_model(draft) + mw.presenter.model.project = project + + yield mw + + +def test_extract_properties_for_sliders(view_with_proj): + update_sliders = view_with_proj.sliders_view_widget._init_properties_for_sliders() + assert not update_sliders # its false as at first call sliders should be regenerated + assert len(view_with_proj.sliders_view_widget._prop_to_change) == 3 + assert list(view_with_proj.sliders_view_widget._prop_to_change.keys()) == ["Param 1", "Param 3", "Param 4"] + assert list(view_with_proj.sliders_view_widget._values_to_revert.values()) == [2.1, 209.0, 409] + assert view_with_proj.sliders_view_widget._init_properties_for_sliders() # now its true as sliders should be + # available for update on second call + + +@patch("rascal2.ui.view.SlidersViewWidget._update_sliders_widgets") +@patch("rascal2.ui.view.SlidersViewWidget._add_sliders_widgets") +def test_create_update_called(add_sliders, update_sliders, view_with_proj): + view_with_proj.sliders_view_widget.init() + assert add_sliders.called == 1 + assert update_sliders.called == 0 + view_with_proj.sliders_view_widget.init() + assert add_sliders.called == 1 + assert update_sliders.called == 1 + + +def test_init_slider_widget_builds_sliders(view_with_proj): + view_with_proj.sliders_view_widget.init() + assert len(view_with_proj.sliders_view_widget._sliders) == 3 + assert "Param 1" in view_with_proj.sliders_view_widget._sliders + assert "Param 3" in view_with_proj.sliders_view_widget._sliders + assert "Param 4" in view_with_proj.sliders_view_widget._sliders + slider1 = view_with_proj.sliders_view_widget._sliders["Param 1"] + slider2 = view_with_proj.sliders_view_widget._sliders["Param 3"] + slider3 = view_with_proj.sliders_view_widget._sliders["Param 4"] + assert slider1._prop._vis_model == view_with_proj.project_widget.view_tabs["Parameters"].tables["parameters"].model + assert slider2._prop._vis_model == view_with_proj.project_widget.view_tabs["Parameters"].tables["parameters"].model + assert slider3._prop._vis_model == view_with_proj.project_widget.view_tabs["Parameters"].tables["parameters"].model + + +def fake_update(self, recalculate_project): + fake_update.num_calls += 1 + fake_update.project_updated.append(recalculate_project) + + +fake_update.num_calls = 0 +fake_update.project_updated = [] + + +def test_identify_changed_properties_empty_for_unchanged(view_with_proj): + view_with_proj.sliders_view_widget.init() + + assert len(view_with_proj.sliders_view_widget._identify_changed_properties()) == 0 + + +def test_identify_changed_properties_picks_up_changed(view_with_proj): + view_with_proj.sliders_view_widget.init() + view_with_proj.sliders_view_widget._values_to_revert["Param 1"] = 4 + view_with_proj.sliders_view_widget._values_to_revert["Param 3"] = 400 + + assert len(view_with_proj.sliders_view_widget._identify_changed_properties()) == 2 + assert list(view_with_proj.sliders_view_widget._identify_changed_properties().keys()) == ["Param 1", "Param 3"] + + +@patch.object(ParameterFieldWidget, "update_project", fake_update) +def test_cancel_button_called(view_with_proj): + """Cancel button sets value of controlled properties to value, stored in + _value_to_revert dictionary + """ + + view_with_proj.sliders_view_widget.init() + + view_with_proj.sliders_view_widget._values_to_revert["Param 1"] = 4 + view_with_proj.sliders_view_widget._values_to_revert["Param 3"] = 400 + cancel_button = view_with_proj.sliders_view_widget.findChild(QtWidgets.QPushButton, "CancelButton") + + cancel_button.click() + + assert fake_update.num_calls == 2 + # project update should be true for last property change + assert fake_update.project_updated == [False, True] + assert not view_with_proj.show_sliders + assert view_with_proj.presenter.model.project.parameters["Param 1"].value == 4 + assert view_with_proj.presenter.model.project.parameters["Param 2"].value == 20 + assert view_with_proj.presenter.model.project.parameters["Param 3"].value == 400 + assert view_with_proj.presenter.model.project.parameters["Param 4"].value == 409 + + +@patch("rascal2.ui.view.SlidersViewWidget._apply_changes_from_sliders") +def test_cancel_accept_button_connections(mock_accept, view_with_proj): + view_with_proj.sliders_view_widget.init() + + accept_button = view_with_proj.sliders_view_widget.findChild(QtWidgets.QPushButton, "AcceptButton") + accept_button.clicked.disconnect() # previous actual function was connected regardless + accept_button.clicked.connect(view_with_proj.sliders_view_widget._apply_changes_from_sliders) + accept_button.click() + assert mock_accept.called == 1 + + +@patch("rascal2.ui.view.SlidersViewWidget._cancel_changes_from_sliders") +def test_cancel_cancel_button_connections(mock_cancel, view_with_proj): + view_with_proj.sliders_view_widget.init() + cancel_button = view_with_proj.sliders_view_widget.findChild(QtWidgets.QPushButton, "CancelButton") + cancel_button.clicked.disconnect() # previous actual function was connected regardless + cancel_button.clicked.connect(view_with_proj.sliders_view_widget._cancel_changes_from_sliders) + + cancel_button.click() + assert mock_cancel.called == 1 + + +def fake_toggle_sliders(self, do_show_sliders): + fake_toggle_sliders.num_calls = +1 + fake_toggle_sliders.call_param = do_show_sliders + + +fake_toggle_sliders.num_calls = 0 +fake_toggle_sliders.call_param = [] + + +@patch.object(MainWindowView, "toggle_sliders", fake_toggle_sliders) +def test_apply_cancel_changes_called_hide_sliders(view_with_proj): + view_with_proj.sliders_view_widget._cancel_changes_from_sliders() + assert fake_toggle_sliders.num_calls == 1 + assert not fake_toggle_sliders.call_param + + fake_toggle_sliders.num_calls = 0 + fake_toggle_sliders.call_param = [] + + view_with_proj.sliders_view_widget._apply_changes_from_sliders() + assert fake_toggle_sliders.num_calls == 1 + assert not fake_toggle_sliders.call_param + + +# ====================================================================================================================== +def set_proj_properties_fit_to_requested(proj, true_list: list): + """set up all projects properties "fit" parameter to False except provided + within the true_list, which to be set to True""" + + project = proj.presenter.model.project + for field in ratapi.Project.model_fields: + attr = getattr(project, field) + if isinstance(attr, ratapi.ClassList): + for item in attr: + if hasattr(item, "fit"): + if item.name in true_list: + item.fit = True + else: + item.fit = False + + +def test_empty_slider_generated(view_with_proj): + set_proj_properties_fit_to_requested(view_with_proj, []) + + view_with_proj.sliders_view_widget.init() + assert len(view_with_proj.sliders_view_widget._sliders) == 1 + slider1 = view_with_proj.sliders_view_widget._sliders["Empty Slider"] + assert isinstance(slider1, EmptySlider) + + +def test_empty_slider_updated(view_with_proj): + set_proj_properties_fit_to_requested(view_with_proj, []) + + view_with_proj.sliders_view_widget.init() + assert len(view_with_proj.sliders_view_widget._sliders) == 1 + slider1 = view_with_proj.sliders_view_widget._sliders["Empty Slider"] + assert isinstance(slider1, EmptySlider) + view_with_proj.sliders_view_widget.init() + assert isinstance(slider1, EmptySlider) + + +def test_empty_slider_removed(view_with_proj): + set_proj_properties_fit_to_requested(view_with_proj, []) + + view_with_proj.sliders_view_widget.init() + assert len(view_with_proj.sliders_view_widget._sliders) == 1 + slider1 = view_with_proj.sliders_view_widget._sliders["Empty Slider"] + assert isinstance(slider1, EmptySlider) + + set_proj_properties_fit_to_requested(view_with_proj, ["Param 2"]) + + view_with_proj.sliders_view_widget.init() + assert len(view_with_proj.sliders_view_widget._sliders) == 1 + slider1 = view_with_proj.sliders_view_widget._sliders["Param 2"] + assert isinstance(slider1, LabeledSlider) + + +# ====================================================================================================================== +@patch.object(SlidersViewWidget, "isVisible", lambda self: True) +@patch.object(ProjectWidget, "validate_draft_project", lambda self: "Errors present") +@patch("rascal2.ui.view.ProjectWidget.update_project_view") +@patch("rascal2.ui.view.SlidersViewWidget.show") +@patch("rascal2.ui.view.SlidersViewWidget.hide") +def test_hide_sliders_when_edited_restore_when_accepted(mock_hide, mock_show, mock_update, view_with_proj): + view_with_proj.sliders_view_widget.mdi_holder = QtWidgets.QWidget() # needs for project to be defined + + edit_button = view_with_proj.project_widget.edit_project_button + edit_button.click() + assert mock_hide.call_count == 1 + assert mock_update.call_count == 1 + # show state stored + assert callable(view_with_proj.project_widget._ProjectWidget__slider_view_state_holder_function) + + save_button = view_with_proj.project_widget.save_project_button + save_button.click() + + assert mock_show.call_count == 1 + assert mock_update.call_count == 1 # we patched save with error so should not update + # call state persistent and return does not recover anything + assert view_with_proj.project_widget._ProjectWidget__slider_view_state_holder_function is None + + +@patch.object(SlidersViewWidget, "isVisible", lambda self: True) +@patch("rascal2.ui.view.ProjectWidget.update_project_view") +@patch("rascal2.ui.view.SlidersViewWidget.show") +@patch("rascal2.ui.view.SlidersViewWidget.hide") +def test_hide_sliders_when_edited_restore_when_canceled(mock_hide, mock_show, mock_update, view_with_proj): + view_with_proj.sliders_view_widget.mdi_holder = QtWidgets.QWidget() # needs for project to be defined + + edit_button = view_with_proj.project_widget.edit_project_button + edit_button.click() + assert mock_hide.call_count == 1 + assert mock_update.call_count == 1 + # show state stored + assert callable(view_with_proj.project_widget._ProjectWidget__slider_view_state_holder_function) + + cancel_button = view_with_proj.project_widget.cancel_button + cancel_button.click() + + assert mock_show.call_count == 1 + assert mock_update.call_count == 2 + # call state persistent and return does not recover anything. + assert view_with_proj.project_widget._ProjectWidget__slider_view_state_holder_function is None