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 @@
+
+
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()
+