diff --git a/src/sas/qtgui/GUITests.py b/src/sas/qtgui/GUITests.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/sas/qtgui/MainWindow/GuiManager.py b/src/sas/qtgui/MainWindow/GuiManager.py index abe2bde3de..e373b69bcc 100644 --- a/src/sas/qtgui/MainWindow/GuiManager.py +++ b/src/sas/qtgui/MainWindow/GuiManager.py @@ -33,6 +33,7 @@ from sas.qtgui.Utilities.ResultPanel import ResultPanel from sas.qtgui.Utilities.Reports.ReportDialog import ReportDialog +from sas.qtgui.Utilities.PreferencesPanel import PreferencesPanel from sas.qtgui.MainWindow.Acknowledgements import Acknowledgements from sas.qtgui.MainWindow.AboutBox import AboutBox from sas.qtgui.MainWindow.WelcomePanel import WelcomePanel @@ -164,6 +165,7 @@ def addWidgets(self): self.ackWidget = Acknowledgements() self.aboutWidget = AboutBox() self.categoryManagerWidget = CategoryManager(self._parent, manager=self) + self.preferences = PreferencesPanel(self._parent) self.grid_window = None self.grid_window = BatchOutputPanel(parent=self) @@ -652,6 +654,7 @@ def addTriggers(self): self._workspace.actionOpen_Analysis.triggered.connect(self.actionOpen_Analysis) self._workspace.actionSave.triggered.connect(self.actionSave_Project) self._workspace.actionSave_Analysis.triggered.connect(self.actionSave_Analysis) + self._workspace.actionPreferences.triggered.connect(self.actionOpen_Preferences) self._workspace.actionQuit.triggered.connect(self.actionQuit) # Edit self._workspace.actionUndo.triggered.connect(self.actionUndo) @@ -802,6 +805,9 @@ def actionSave_Analysis(self): else: logger.warning('No analysis was available to be saved.') + def actionOpen_Preferences(self): + self.preferences.show() + def actionQuit(self): """ Close the reactor, exit the application. diff --git a/src/sas/qtgui/MainWindow/UI/MainWindowUI.ui b/src/sas/qtgui/MainWindow/UI/MainWindowUI.ui index ef68a7b0fa..8c3ede5e69 100755 --- a/src/sas/qtgui/MainWindow/UI/MainWindowUI.ui +++ b/src/sas/qtgui/MainWindow/UI/MainWindowUI.ui @@ -40,6 +40,8 @@ + + @@ -607,6 +609,15 @@ Model Marketplace + + + + :/res/settings.png:/res/settings.png + + + Preferences... + + diff --git a/src/sas/qtgui/MainWindow/media/preferences_help.rst b/src/sas/qtgui/MainWindow/media/preferences_help.rst new file mode 100644 index 0000000000..73221280d1 --- /dev/null +++ b/src/sas/qtgui/MainWindow/media/preferences_help.rst @@ -0,0 +1,11 @@ +.. preferences_help.rst + +.. J Krzywon wrote initial draft August 2022 + +.. _Preferences: + +Preferences +============ + +SasView has a number of user-settable options available. Each heading will give more information about a +particular group of settings. Any preferences not held within this window are planned to move here in a future release. \ No newline at end of file diff --git a/src/sas/qtgui/UI/main_resources.qrc b/src/sas/qtgui/UI/main_resources.qrc index e37d4eb44a..36b8468da0 100755 --- a/src/sas/qtgui/UI/main_resources.qrc +++ b/src/sas/qtgui/UI/main_resources.qrc @@ -15,6 +15,7 @@ res/report.png res/reset.png res/save.png + res/settings.png res/left-round.png res/right-round.png diff --git a/src/sas/qtgui/UI/res/settings.png b/src/sas/qtgui/UI/res/settings.png new file mode 100644 index 0000000000..59746f1d3d Binary files /dev/null and b/src/sas/qtgui/UI/res/settings.png differ diff --git a/src/sas/qtgui/Utilities/PreferencesPanel.py b/src/sas/qtgui/Utilities/PreferencesPanel.py new file mode 100644 index 0000000000..0971cbbae1 --- /dev/null +++ b/src/sas/qtgui/Utilities/PreferencesPanel.py @@ -0,0 +1,169 @@ +import logging + +from PyQt5.QtWidgets import QComboBox, QDialog, QPushButton, QWidget, QLabel, QHBoxLayout, QVBoxLayout, QLineEdit, QCheckBox +from typing import Optional, Any, List, Union, Callable + +from sas.system.config.config import config +from sas.qtgui.Utilities.UI.PreferencesUI import Ui_preferencesUI + +logger = logging.getLogger(__name__) + + +def set_config_value(attr: str, value: Any): + """Helper method to set any config value, regardless if it exists or not + :param attr: The configuration attribute that will be set + :param value: The value the attribute will be set to. This could be a str, int, bool, a class instance, or any other + """ + setattr(config, attr, value) + + +def get_config_value(attr: str, default: Optional[Any] = None) -> Any: + """Helper method to get any config value, regardless if it exists or not + :param attr: The configuration attribute that will be returned + :param default: The assumed value, if the attribute cannot be found + """ + return getattr(config, attr, default) if hasattr(config, attr) else default + + +def cb_replace_all_items_with_new(cb: QComboBox, new_items: List[str], default_item: Optional[str] = None): + """Helper method that removes existing ComboBox values, replaces them and sets a default item, if defined + :param cb: A QComboBox object + :param new_items: A list of strings that will be used to populate the QComboBox + :param default_item: The value to set the QComboBox to, if set + """ + cb.clear() + cb.addItems(new_items) + index = cb.findText(default_item) if default_item and default_item in new_items else 0 + cb.setCurrentIndex(index) + + +class PreferencesPanel(QDialog, Ui_preferencesUI): + """A preferences panel to house all SasView related settings. The left side of the window is a listWidget with a + options menus available. The right side of the window is a stackedWidget object that houses the options + associated with each listWidget item. + **Important Note** When adding new preference widgets, the index for the listWidget and stackedWidget *must* match + Release notes: + SasView v5.0.5: Added defaults for loaded data units and plotted units + """ + + def __init__(self, parent=None): + super(PreferencesPanel, self).__init__(parent) + self.setupUi(self) + self.parent = parent + self.setWindowTitle("Preferences") + self.warning = None + # A list of callables used to restore the default values for each item in StackedWidget + self.restoreDefaultMethods = [] + # Set defaults values for the list and stacked widgets + self.stackedWidget.setCurrentIndex(0) + self.listWidget.setCurrentRow(0) + # Add window actions + self.listWidget.currentItemChanged.connect(self.prefMenuChanged) + self.buttonBox.clicked.connect(self.onClick) + + def prefMenuChanged(self): + """When the preferences menu selection changes, change to the appropriate preferences widget """ + row = self.listWidget.currentRow() + self.stackedWidget.setCurrentIndex(row) + + def onClick(self, btn: QPushButton): + """Handle button click events in one area""" + # Reset to the default preferences + if btn.text() == 'Restore Defaults': + self.restoreDefaultPreferences() + elif btn.text() == 'OK': + self.close() + elif btn.text() == 'Help': + self.help() + + def restoreDefaultPreferences(self): + """Reset all preferences to their default preferences""" + for method in self.restoreDefaultMethods: + if callable(method): + method() + else: + logger.warning(f'While restoring defaults, {str(method)} of type {type(method)}' + + ' was given. A callable object was expected.') + + def close(self): + """Save the configuration values when the preferences window is closed""" + config.save() + super(PreferencesPanel, self).close() + + def addWidget(self, widget: QWidget): + self.stackedWidget.addWidget(widget) + self.listWidget.addItem(widget.name) + if widget.resetDefaults is not None and callable(widget.resetDefaults): + self.restoreDefaultMethods.append(widget.resetDefaults) + + def help(self): + """Open the help window associated with the preferences window""" + tree_location = "/user/qtgui/MainWindow/preferences_help.html" + self.parent.showHelp(tree_location) + + +class PreferencesWidget(QWidget): + """A helper class that bundles all values needed to add a new widget to the preferences panel + """ + # Name that will be added to the PreferencesPanel listWidget + name = None # type: str + + def __init__(self, name: str, default_method: Optional[Callable] = None): + super(PreferencesWidget, self).__init__() + self.name = name + self.resetDefaults = default_method + self.horizontalLayout = QHBoxLayout() + self.setLayout(self.horizontalLayout) + self.adjustSize() + + def _createLayoutAndTitle(self, title: str): + """A private class method that creates a vertical layout to hold the title and interactive item. + :param title: The title of the interactive item to be added to the preferences panel. + :return: A QVBoxLayout instance with a title box already added + """ + layout = QVBoxLayout(self) + label = QLabel(title + ": ", self) + layout.addWidget(label) + return layout + + def addComboBox(self, title: str, params: List[Union[str, int, float]], callback: Callable, + default: Optional[str] = None): + """Add a title and combo box within the widget. + :param title: The title of the combo box to be added to the preferences panel. + :param params: A list of options to be added to the combo box. + :param callback: A callback method called when the combobox value is changed. + :param default: The default option to be selected in the combo box. The first item is selected if None. + """ + layout = self._createLayoutAndTitle(title) + box = QComboBox(self) + cb_replace_all_items_with_new(box, params, default) + box.currentIndexChanged.connect(callback) + layout.addWidget(box) + self.horizontalLayout.addLayout(layout) + + def addTextInput(self, title: str, callback: Callable, default_text: Optional[str] = ""): + """Add a title and text box within the widget. + :param title: The title of the text box to be added to the preferences panel. + :param callback: A callback method called when the combobox value is changed. + :param default_text: An optional value to be put within the text box as a default. Defaults to an empty string. + """ + layout = self._createLayoutAndTitle(title) + text_box = QLineEdit(self) + if default_text: + text_box.setText(default_text) + text_box.textChanged.connect(callback) + layout.addWidget(text_box) + self.horizontalLayout.addLayout(layout) + + def addCheckBox(self, title: str, callback: Callable, checked: Optional[bool] = False): + """Add a title and check box within the widget. + :param title: The title of the check box to be added to the preferences panel. + :param callback: A callback method called when the combobox value is changed. + :param checked: An optional boolean value to specify if the check box is checked. Defaults to unchecked. + """ + layout = self._createLayoutAndTitle(title) + check_box = QCheckBox(self) + check_box.setChecked(checked) + check_box.toggled.connect(callback) + layout.addWidget(check_box) + self.horizontalLayout.addLayout(layout) diff --git a/src/sas/qtgui/Utilities/UI/PreferencesUI.ui b/src/sas/qtgui/Utilities/UI/PreferencesUI.ui new file mode 100644 index 0000000000..40f0531079 --- /dev/null +++ b/src/sas/qtgui/Utilities/UI/PreferencesUI.ui @@ -0,0 +1,132 @@ + + + preferencesUI + + + + 0 + 0 + 731 + 463 + + + + + 0 + 0 + + + + Preferences + + + + :/res/ball.ico:/res/ball.ico + + + + + + QLayout::SetNoConstraint + + + + + true + + + + 0 + 0 + + + + + 256 + 16777215 + + + + 0 + + + + + + + + + true + + + Qt::LeftToRight + + + QFrame::StyledPanel + + + QFrame::Sunken + + + 2 + + + 1 + + + 0 + + + + + + + + + + + Qt::Horizontal + + + QDialogButtonBox::Help|QDialogButtonBox::Ok|QDialogButtonBox::RestoreDefaults + + + + + + + + + buttonBox + accepted() + preferencesUI + accept() + + + 248 + 254 + + + 157 + 274 + + + + + buttonBox + rejected() + preferencesUI + reject() + + + 316 + 260 + + + 286 + 274 + + + + + \ No newline at end of file diff --git a/src/sas/qtgui/Utilities/UnitTesting/PreferencesPanelTest.py b/src/sas/qtgui/Utilities/UnitTesting/PreferencesPanelTest.py new file mode 100644 index 0000000000..fc8d371da7 --- /dev/null +++ b/src/sas/qtgui/Utilities/UnitTesting/PreferencesPanelTest.py @@ -0,0 +1,90 @@ +import os +import pytest +from PyQt5.QtWidgets import QWidget, QLineEdit, QComboBox, QCheckBox + +from sas.qtgui.Plotting.PlotterData import Data1D +from sas.qtgui.Utilities.PreferencesPanel import * + + +class PreferencesPanelTest: + @pytest.fixture(autouse=True) + def widget(self, qapp): + class DummyClass: + def __init__(self): + self.path = None + + @staticmethod + def showHelp(location): + """Simulate a help window""" + return location == "/user/qtgui/MainWindow/preferences_help.html" + + w = QWidget() + w.guiManager = DummyClass() + panel = PreferencesPanel(w) + yield panel + panel.close() + panel.destroy() + + @pytest.fixture(autouse=True) + def data(self): + data = [Data1D(x=[1.0, 2.0, 3.0], y=[10.0, 11.0, 12.0])] + yield data + + def testDefaults(self, widget): + """Test the freshly-opened panel with no changes made""" + assert widget.stackedWidget.count() == widget.listWidget.count() + assert -1 == widget.stackedWidget.currentIndex() + + def testPreferencesInteractions(self, widget): + """Test the base interactions in window behavior""" + # Check the list widget and stacked widget are tied together + last_row = widget.listWidget.count() - 1 + widget.listWidget.setCurrentRow(last_row) + assert widget.stackedWidget.currentIndex() == widget.listWidget.currentRow() + + def testPreferencesExtensibility(self, widget): + """Test ability to add and remove items from the listWidget and stackedWidget""" + # Create fake PreferencesWidget, add to stacked widget, and add item to list widget + new_widget = PreferencesWidget("Fake Widget") + starting_size = widget.stackedWidget.count() + widget.addWidget(new_widget) + # Ensure stacked widget and list widget have the same number of elements + assert widget.stackedWidget.count() == widget.listWidget.count() + assert starting_size + 1 == widget.stackedWidget.count() + # Select last item in list widget and check the stacked widget moves too + widget.listWidget.setCurrentRow(widget.listWidget.count() - 1) + assert widget.stackedWidget.currentIndex() == widget.listWidget.currentRow() + + def testHelp(self, widget, mocker): + mocker.patch.object(widget, 'onClick') + widget.buttonBox.buttons()[0].click() + assert widget.onClick.called_once() + + def testPreferencesWidget(self, widget, mocker): + mocker.patch.object(widget, 'checked', create=True) + mocker.patch.object(widget, 'combo', create=True) + mocker.patch.object(widget, 'textified', create=True) + mocker.patch.object(widget, 'resetPref', create=True) + + pref = PreferencesWidget("Dummy Widget", widget.resetPref) + pref.addTextInput("blah", widget.textified) + pref.addCheckBox("ho hum", widget.checked) + pref.addComboBox("combo", ["a", "b", "c"], widget.combo, "a") + + widget.addWidget(pref) + + widget.restoreDefaultPreferences() + assert widget.resetPref.called_once() + + for child in pref.layout().children(): + if isinstance(child, QLineEdit): + child.setText("new text") + elif isinstance(child, QComboBox): + child.setCurrentIndex(1) + elif isinstance(child, QCheckBox): + child.setChecked(not child.checkState()) + + assert widget.textified.called_once() + assert widget.combo.called_once() + assert widget.checked.called_once() +