diff --git a/rascal2/ui/view.py b/rascal2/ui/view.py index 110397a0..62d31384 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,11 @@ 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")) @@ -38,14 +43,21 @@ def __init__(self): self.plot_widget = PlotWidget(self) self.terminal_widget = TerminalWidget() self.controls_widget = ControlsWidget(self) + self.sliders_view_widget = SlidersViewWidget(self) self.project_widget = ProjectWidget(self) - self.disabled_elements = [] + ## protected interface and public properties construction + + # define menu controlling switch between table and slider views + self._sliders_menu_control_text = { + "ShowSliders": "&Show Sliders", # if state is show sliders, click will show them + "HideSliders": "&Hide Sliders", + } # if state is show table, click will show sliders self.create_actions() - self.main_menu = self.menuBar() - self.add_submenus(self.main_menu) + main_menu = self.menuBar() + self.add_submenus(main_menu) self.create_toolbar() self.create_status_bar() @@ -166,6 +178,20 @@ def create_actions(self): open_help_action.triggered.connect(self.open_docs) self.open_help_action = open_help_action + # done this way expecting the value "show_sliders" being stored + # in configuration in a future + "show_sliders" is public for this reason + if self.show_sliders: + # if show_sliders state is True, action will be hide + show_or_hide_slider_action = QtGui.QAction(self._sliders_menu_control_text["HideSliders"], self) + else: + # if display_sliders state is False, action will be show + show_or_hide_slider_action = QtGui.QAction(self._sliders_menu_control_text["ShowSliders"], self) + show_or_hide_slider_action.setStatusTip("Show or Hide Sliders") + show_or_hide_slider_action.triggered.connect(lambda: self.show_or_hide_sliders(None)) + self._show_or_hide_slider_action = show_or_hide_slider_action + self._show_or_hide_slider_action.setEnabled(False) + self.disabled_elements.append(self._show_or_hide_slider_action) + open_about_action = QtGui.QAction("&About", self) open_about_action.setStatusTip("Report RAT version&info") open_about_action.triggered.connect(self.open_about_info) @@ -242,6 +268,8 @@ def add_submenus(self, main_menu: QtWidgets.QMenuBar): tools_menu = main_menu.addMenu("&Tools") tools_menu.setObjectName("&Tools") + tools_menu.addAction(self._show_or_hide_slider_action) + tools_menu.addSeparator() tools_menu.addAction(self.clear_terminal_action) tools_menu.addSeparator() tools_menu.addAction(self.setup_matlab_action) @@ -251,6 +279,32 @@ def add_submenus(self, main_menu: QtWidgets.QMenuBar): help_menu.addAction(self.open_about_action) help_menu.addAction(self.open_help_action) + def show_or_hide_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.show_sliders = do_show_sliders + + if self.show_sliders: + self._show_or_hide_slider_action.setText(self._sliders_menu_control_text["HideSliders"]) + self.sliders_view_widget.show() + self.project_widget.setWindowTitle("Sliders View") + self.project_widget.stacked_widget.setCurrentIndex(2) + else: + self._show_or_hide_slider_action.setText(self._sliders_menu_control_text["ShowSliders"]) + self.sliders_view_widget.hide() + self.project_widget.show_project_view() + def open_about_info(self): """Opens about menu containing information about RASCAL gui""" self.about_dialog.update_rascal_info(self) @@ -311,7 +365,9 @@ def setup_mdi(self): self.setCentralWidget(self.mdi) def setup_mdi_widgets(self): - """Performs setup of MDI widgets that relies on the Project existing.""" + """ + Performs initialization of MDI widgets that rely on the Project being defined. + """ self.controls_widget.setup_controls() self.project_widget.show_project_view() self.plot_widget.clear() @@ -333,7 +389,6 @@ def reset_mdi_layout(self): window.showMinimized() else: window.showNormal() - window.setGeometry(x, y, width, height) def save_mdi_layout(self): diff --git a/rascal2/widgets/__init__.py b/rascal2/widgets/__init__.py index d1b68dbd..bd884688 100644 --- a/rascal2/widgets/__init__.py +++ b/rascal2/widgets/__init__.py @@ -1,6 +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.sliders_view import SlidersViewWidget from rascal2.widgets.terminal import TerminalWidget __all__ = [ @@ -11,4 +12,5 @@ "MultiSelectList", "PlotWidget", "TerminalWidget", + "SlidersViewWidget", ] diff --git a/rascal2/widgets/project/project.py b/rascal2/widgets/project/project.py index 155f8972..a9048cbd 100644 --- a/rascal2/widgets/project/project.py +++ b/rascal2/widgets/project/project.py @@ -72,22 +72,27 @@ def __init__(self, parent): self.stacked_widget = QtWidgets.QStackedWidget() self.stacked_widget.addWidget(project_view) self.stacked_widget.addWidget(project_edit) + self.stacked_widget.addWidget(self.parent.sliders_view_widget) layout = QtWidgets.QVBoxLayout() layout.setContentsMargins(0, 0, 0, 0) layout.addWidget(self.stacked_widget) self.setLayout(layout) - def create_project_view(self) -> None: + def create_project_view(self) -> QtWidgets.QWidget: """Creates the project (non-edit) view""" project_widget = QtWidgets.QWidget() main_layout = QtWidgets.QVBoxLayout() main_layout.setSpacing(20) + show_sliders_button = QtWidgets.QPushButton("Show sliders", self, objectName="ShowSliders") + show_sliders_button.clicked.connect(lambda: self.parent.show_or_hide_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) button_layout = QtWidgets.QHBoxLayout() button_layout.setAlignment(QtCore.Qt.AlignmentFlag.AlignRight) + button_layout.addWidget(show_sliders_button) button_layout.addWidget(self.edit_project_button) main_layout.addLayout(button_layout) @@ -142,7 +147,7 @@ def create_project_view(self) -> None: return project_widget - def create_edit_view(self) -> None: + def create_edit_view(self) -> QtWidgets.QWidget: """Creates the project edit view""" edit_project_widget = QtWidgets.QWidget() @@ -360,6 +365,8 @@ def show_project_view(self) -> None: def show_edit_view(self) -> None: """Show edit view""" + + # will be updated according to edit changes self.update_project_view(0) self.setWindowTitle("Edit Project") self.parent.controls_widget.run_button.setEnabled(False) @@ -540,6 +547,7 @@ def __init__(self, fields: list[str], parent, edit_mode: bool = False): self.tables[field] = DataWidget(field, self) else: self.tables[field] = ProjectFieldWidget(field, self) + self.tables[field].setObjectName(field) layout.addWidget(self.tables[field]) scroll_area = QtWidgets.QScrollArea() diff --git a/rascal2/widgets/project/tables.py b/rascal2/widgets/project/tables.py index 68befb25..2017d624 100644 --- a/rascal2/widgets/project/tables.py +++ b/rascal2/widgets/project/tables.py @@ -75,7 +75,27 @@ 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: + def setData( + self, index: QtCore.QModelIndex, value, role=QtCore.Qt.ItemDataRole.EditRole, recalculate_proj=True + ) -> bool: + """Implement abstract setData method of QAbstractTableModel. + + Parameters + ---------- + index: QtCore.QModelIndex + QModelIndex representing the row and column indices of edited cell wrt. the edited table + value: + new value of appropriate cell of the table. + role: QtCore.Qt.ItemDataRole + not sure what it is but apparently controls table behaviour amd 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() param = self.index_header(index) @@ -93,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 @@ -175,6 +195,7 @@ def __init__(self, field: str, parent): self.parent = parent self.project_widget = parent.parent self.table = QtWidgets.QTableView(parent) + self.table.horizontalHeader().setCascadingSectionResizes(True) self.table.setMinimumHeight(100) @@ -231,7 +252,7 @@ def resize_columns(self): header.setStretchLastSection(True) - def update_model(self, classlist): + def update_model(self, classlist: ratapi.classlist.ClassList): """Update the table model to synchronise with the project field.""" self.model = self.classlist_model(classlist, self) @@ -248,10 +269,18 @@ def update_model(self, 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.""" @@ -354,13 +383,14 @@ class ParameterFieldWidget(ProjectFieldWidget): def set_item_delegates(self): for i, header in enumerate(self.model.headers): if header in ["min", "value", "max"]: - self.table.setItemDelegateForColumn(i + 1, delegates.ValueSpinBoxDelegate(header, self.table)) + delegate = delegates.ValueSpinBoxDelegate(header, self.table) + self.table.setItemDelegateForColumn(i + 1, delegate) else: self.table.setItemDelegateForColumn( i + 1, delegates.ValidatedInputDelegate(self.model.item_type.model_fields[header], self.table) ) - def update_model(self, classlist): + def update_model(self, classlist: ratapi.classlist.ClassList): super().update_model(classlist) header = self.table.horizontalHeader() header.setSectionResizeMode( diff --git a/rascal2/widgets/sliders_view.py b/rascal2/widgets/sliders_view.py new file mode 100644 index 00000000..edf0ed09 --- /dev/null +++ b/rascal2/widgets/sliders_view.py @@ -0,0 +1,600 @@ +"""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__() + # 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.__accept_button = None # Placeholder for accept button indicating particular. Presence indicates + # initial stage of widget construction was completed + self.__sliders_widgets_layout = None # Placeholder for the area, containing sliders widgets. + # presence indicates advanced stage of slider widget construction was completed and sliders widgets + # cam be propagated. + + # create initial slider view layout and everything else which depends on it + self.init() + + def show(self): + """Overload parent show method sets up or updates sliders + 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() + super().show() + + 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.__accept_button 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. + trial_properties[model_param.name] = SliderChangeHolder( + row_number=row, model=data_model, param=model_param + ) + + 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 _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) + self.__accept_button = accept_button + + 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 + 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.show_or_hide_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.show_or_hide_sliders(do_show_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.__block_slider_value_changed_signal = False + + 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.__block_slider_value_changed_signal = True + self._slider.setValue(idx) + self.__block_slider_value_changed_signal = 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 + + """ + if self.__block_slider_value_changed_signal: + return + 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/ui/test_presenter.py b/tests/ui/test_presenter.py index b3582396..66f8cb81 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 74951b78..41502aea 100644 --- a/tests/ui/test_view.py +++ b/tests/ui/test_view.py @@ -39,8 +39,20 @@ def test_view(): @pytest.mark.parametrize( "geometry", [ - ((1, 2, 196, 24, True), (1, 2, 196, 24, True), (1, 2, 196, 24, True), (1, 2, 196, 24, True)), - ((1, 2, 196, 24, True), (3, 78, 196, 24, True), (1, 2, 204, 66, False), (12, 342, 196, 24, True)), + ( + (1, 2, 196, 24, True), + (1, 2, 196, 24, True), + (1, 2, 196, 24, True), + (1, 2, 196, 24, True), + (1, 2, 196, 24, True), + ), + ( + (1, 2, 196, 24, True), + (3, 78, 196, 24, True), + (1, 2, 204, 66, False), + (12, 342, 196, 24, True), + (5, 6, 200, 28, True), + ), ], ) @patch("rascal2.ui.view.ProjectWidget.show_project_view") @@ -134,18 +146,11 @@ def change_dir(*args, **kwargs): mock_overwrite.assert_called_once() -def test_menu_bar_present(test_view): - """Test menu bar is present""" - - assert hasattr(test_view, "main_menu") - assert isinstance(test_view.main_menu, QtWidgets.QMenuBar) - - @pytest.mark.parametrize("submenu_name", ["&File", "&Edit", "&Windows", "&Tools", "&Help"]) def test_menu_element_present(test_view, submenu_name): """Test requested menu items are present""" - main_menu = test_view.main_menu + main_menu = test_view.menuBar() elements = main_menu.children() assert any(hasattr(submenu, "title") and submenu.title() == submenu_name for submenu in elements) @@ -174,16 +179,74 @@ def test_menu_element_present(test_view, submenu_name): ), ("&Edit", ["&Undo", "&Redo", "Undo &History"]), ("&Windows", ["Tile Windows", "Reset to Default", "Save Current Window Positions"]), - ("&Tools", ["Clear Terminal", "", "Setup MATLAB"]), + ("&Tools", ["&Show Sliders", "", "Clear Terminal", "", "Setup MATLAB"]), ("&Help", ["&About", "&Help"]), ], ) def test_help_menu_actions_present(test_view, submenu_name, action_names_and_layout): """Test if menu actions are available and their layouts are as specified in parameterize""" - main_menu = test_view.main_menu + main_menu = test_view.menuBar() submenu = main_menu.findChild(QtWidgets.QMenu, submenu_name) actions = submenu.actions() assert len(actions) == len(action_names_and_layout) for action, name in zip(actions, action_names_and_layout, strict=True): assert action.text() == name + + +@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 = 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_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 + """ + + 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") +@patch("rascal2.ui.view.ProjectWidget.update_project_view") +def test_click_on_select_tabs_works_as_expected(mock_update_proj, 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 + """ + + 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 + assert mock_update_proj.call_count == 0 + 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 + assert mock_update_proj.call_count == 1 diff --git a/tests/widgets/project/test_models.py b/tests/widgets/project/test_models.py index 22ddc14d..48a3d31a 100644 --- a/tests/widgets/project/test_models.py +++ b/tests/widgets/project/test_models.py @@ -254,39 +254,58 @@ 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_hidden_bayesian_columns(param_classlist): +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) + + +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 +402,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 4e37919d..afc5f24f 100644 --- a/tests/widgets/project/test_project.py +++ b/tests/widgets/project/test_project.py @@ -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, @@ -36,6 +37,22 @@ def __init__(self): super().__init__() self.presenter = MockPresenter() self.controls_widget = MagicMock() + self.project_widget = None + self.sliders_view_widget = SlidersViewWidget(self) + + def show_or_hide_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.show_or_hide_sliders(do_show_sliders=prev_call_vis_sliders_state) + else: + self.show_or_hide_sliders(do_show_sliders=False) class DataModel(pydantic.BaseModel, validate_assignment=True): 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..b5474da7 --- /dev/null +++ b/tests/widgets/test_sliders_widget.py @@ -0,0 +1,234 @@ +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 create_draft_project +from rascal2.widgets.project.tables import ParameterFieldWidget +from rascal2.widgets.sliders_view import EmptySlider, LabeledSlider + + +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) +@patch("rascal2.ui.view.ProjectWidget.show_project_view") +def test_cancel_button_called(mock_show_project, 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 + + assert mock_show_project.call_count == 1 + + +@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_show_or_hide_sliders(self, do_show_sliders): + fake_show_or_hide_sliders.num_calls = +1 + fake_show_or_hide_sliders.call_param = do_show_sliders + + +fake_show_or_hide_sliders.num_calls = 0 +fake_show_or_hide_sliders.call_param = [] + + +@patch.object(MainWindowView, "show_or_hide_sliders", fake_show_or_hide_sliders) +def test_apply_cancel_changes_called_hide_sliders(view_with_proj): + view_with_proj.sliders_view_widget._cancel_changes_from_sliders() + assert fake_show_or_hide_sliders.num_calls == 1 + assert not fake_show_or_hide_sliders.call_param + + fake_show_or_hide_sliders.num_calls = 0 + fake_show_or_hide_sliders.call_param = [] + + view_with_proj.sliders_view_widget._apply_changes_from_sliders() + assert fake_show_or_hide_sliders.num_calls == 1 + assert not fake_show_or_hide_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)