diff --git a/src/sas/qtgui/GUITests.py b/src/sas/qtgui/GUITests.py index 22948c2bfc..a62d43502b 100644 --- a/src/sas/qtgui/GUITests.py +++ b/src/sas/qtgui/GUITests.py @@ -57,6 +57,7 @@ from Plotting.UnitTesting import PlotterBaseTest from Plotting.UnitTesting import PlotterTest from Plotting.UnitTesting import Plotter2DTest +from Plotting.UnitTesting import QRangeSliderTests # Calculators from Calculators.UnitTesting import KiessigCalculatorTest @@ -121,6 +122,7 @@ def plottingSuite(): unittest.makeSuite(SlicerParametersTest.SlicerParametersTest, 'test'), unittest.makeSuite(PlotterBaseTest.PlotterBaseTest, 'test'), unittest.makeSuite(PlotterTest.PlotterTest, 'test'), + unittest.makeSuite(QRangeSliderTests.QRangeSlidersTest, 'test'), ) return unittest.TestSuite(suites) diff --git a/src/sas/qtgui/MainWindow/DataExplorer.py b/src/sas/qtgui/MainWindow/DataExplorer.py index 819965d890..632c55d941 100644 --- a/src/sas/qtgui/MainWindow/DataExplorer.py +++ b/src/sas/qtgui/MainWindow/DataExplorer.py @@ -398,36 +398,12 @@ def allDataForModel(self, model): if data is None: continue # Now, all plots under this item name = data.name - ###################################################### - # Reset all slider values in data so save/load does not choke on them - # Remove once slider definition moved out of PlotterData - data.slider_low_q_setter = None - data.slider_high_q_setter = None - data.slider_low_q_input = None - data.slider_high_q_input = None - data.slider_update_on_move = False - data.slider_low_q_getter = None - data.slider_high_q_getter = None - ###################################################### is_checked = item.checkState() properties['checked'] = is_checked - other_datas = [] # save underlying theories other_datas = GuiUtils.plotsFromDisplayName(name, model) # skip the main plot other_datas = list(other_datas.values())[1:] - for datas in other_datas: - ###################################################### - # Reset all slider values in data so save/load does not choke on them - # Remove once slider definition moved out of PlotterData - datas.slider_low_q_setter = None - datas.slider_high_q_setter = None - datas.slider_low_q_input = None - datas.slider_high_q_input = None - datas.slider_update_on_move = False - datas.slider_low_q_getter = None - datas.slider_high_q_getter = None - ###################################################### all_data[data.id] = [data, properties, other_datas] return all_data diff --git a/src/sas/qtgui/MainWindow/GuiManager.py b/src/sas/qtgui/MainWindow/GuiManager.py index 8054239424..5b9eda163f 100644 --- a/src/sas/qtgui/MainWindow/GuiManager.py +++ b/src/sas/qtgui/MainWindow/GuiManager.py @@ -60,6 +60,9 @@ logger = logging.getLogger(__name__) +# Expose the loaded perspectives for easy linking between perspective methods/inputs and plots +LOADED_PERSPECTIVES = {} + class GuiManager(object): """ @@ -192,6 +195,8 @@ def loadAllPerspectives(self): except Exception as e: logger.warning(f"Unable to load {name} perspective.\n{e}") self.loadedPerspectives = loaded_dict + global LOADED_PERSPECTIVES + LOADED_PERSPECTIVES = self.loadedPerspectives def closeAllPerspectives(self): # Close all perspectives if they are open @@ -205,6 +210,8 @@ def closeAllPerspectives(self): except Exception as e: logger.warning(f"Unable to close {name} perspective\n{e}") self.loadedPerspectives = {} + global LOADED_PERSPECTIVES + LOADED_PERSPECTIVES = self.loadedPerspectives self._current_perspective = None def addCategories(self): diff --git a/src/sas/qtgui/Perspectives/Fitting/FittingWidget.py b/src/sas/qtgui/Perspectives/Fitting/FittingWidget.py index 227efc97d2..89e2a6f1b4 100644 --- a/src/sas/qtgui/Perspectives/Fitting/FittingWidget.py +++ b/src/sas/qtgui/Perspectives/Fitting/FittingWidget.py @@ -2964,10 +2964,12 @@ def complete1D(self, return_data): fitted_data.show_q_range_sliders = True # Suppress the GUI update until the move is finished to limit model calculations fitted_data.slider_update_on_move = False - fitted_data.slider_high_q_input = self.options_widget.txtMaxRange - fitted_data.slider_high_q_setter = self.options_widget.updateMaxQ - fitted_data.slider_low_q_input = self.options_widget.txtMinRange - fitted_data.slider_low_q_setter = self.options_widget.updateMinQ + fitted_data.slider_tab_name = self.modelName() + fitted_data.slider_perspective_name = 'Fitting' + fitted_data.slider_high_q_input = ['options_widget', 'txtMaxRange'] + fitted_data.slider_high_q_setter = ['options_widget', 'updateMaxQ'] + fitted_data.slider_low_q_input = ['options_widget', 'txtMinRange'] + fitted_data.slider_low_q_setter = ['options_widget', 'updateMinQ'] self.model_data = fitted_data new_plots = [fitted_data] diff --git a/src/sas/qtgui/Perspectives/Invariant/InvariantPerspective.py b/src/sas/qtgui/Perspectives/Invariant/InvariantPerspective.py index 26be4f7786..cfc879dd90 100644 --- a/src/sas/qtgui/Perspectives/Invariant/InvariantPerspective.py +++ b/src/sas/qtgui/Perspectives/Invariant/InvariantPerspective.py @@ -148,15 +148,17 @@ def set_low_q_extrapolation_upper_limit(self, value): n_pts = (np.abs(self._data.x - value)).argmin() + 1 item = QtGui.QStandardItem(str(n_pts)) self.model.setItem(WIDGETS.W_NPTS_LOWQ, item) + self.txtNptsLowQ.setText(str(n_pts)) def get_high_q_extrapolation_lower_limit(self): q_value = self._data.x[len(self._data.x) - int(self.txtNptsHighQ.text()) - 1] return q_value def set_high_q_extrapolation_lower_limit(self, value): - n_pts = (np.abs(self._data.x - value)).argmin() + 1 + n_pts = len(self._data.x) - (np.abs(self._data.x - value)).argmin() + 1 item = QtGui.QStandardItem(str(int(n_pts))) self.model.setItem(WIDGETS.W_NPTS_HIGHQ, item) + self.txtNptsHighQ.setText(str(n_pts)) def enabling(self): """ """ @@ -260,13 +262,13 @@ def plotResult(self, model): self.high_extrapolation_plot.plot_role = Data1D.ROLE_DEFAULT self.high_extrapolation_plot.symbol = "Line" self.high_extrapolation_plot.show_errors = False - # TODO: Fix the link between npts and q and then enable the q-range sliders - #self.high_extrapolation_plot.show_q_range_sliders = True - #self.high_extrapolation_plot.slider_update_on_move = False - #self.high_extrapolation_plot.slider_low_q_input = self.txtNptsHighQ - #self.high_extrapolation_plot.slider_low_q_setter = self.set_high_q_extrapolation_lower_limit - #self.high_extrapolation_plot.slider_low_q_getter = self.get_high_q_extrapolation_lower_limit - #self.high_extrapolation_plot.slider_high_q_input = self.txtExtrapolQMax + self.high_extrapolation_plot.show_q_range_sliders = True + self.high_extrapolation_plot.slider_update_on_move = False + self.high_extrapolation_plot.slider_perspective_name = self.name + self.high_extrapolation_plot.slider_low_q_input = ['txtNptsHighQ'] + self.high_extrapolation_plot.slider_low_q_setter = ['set_high_q_extrapolation_lower_limit'] + self.high_extrapolation_plot.slider_low_q_getter = ['get_high_q_extrapolation_lower_limit'] + self.high_extrapolation_plot.slider_high_q_input = ['txtExtrapolQMax'] GuiUtils.updateModelItemWithPlot(self._model_item, self.high_extrapolation_plot, self.high_extrapolation_plot.title) plots.append(self.high_extrapolation_plot) @@ -274,13 +276,13 @@ def plotResult(self, model): self.low_extrapolation_plot.plot_role = Data1D.ROLE_DEFAULT self.low_extrapolation_plot.symbol = "Line" self.low_extrapolation_plot.show_errors = False - # TODO: Fix the link between npts and q and then enable the q-range sliders - #self.low_extrapolation_plot.show_q_range_sliders = True - #self.low_extrapolation_plot.slider_update_on_move = False - #self.low_extrapolation_plot.slider_high_q_input = self.txtNptsLowQ - #self.low_extrapolation_plot.slider_high_q_setter = self.set_low_q_extrapolation_upper_limit - #self.low_extrapolation_plot.slider_high_q_getter = self.get_low_q_extrapolation_upper_limit - #self.low_extrapolation_plot.slider_low_q_input = self.txtExtrapolQMin + self.low_extrapolation_plot.show_q_range_sliders = True + self.low_extrapolation_plot.slider_update_on_move = False + self.low_extrapolation_plot.slider_perspective_name = self.name + self.low_extrapolation_plot.slider_high_q_input = ['txtNptsLowQ'] + self.low_extrapolation_plot.slider_high_q_setter = ['set_low_q_extrapolation_upper_limit'] + self.low_extrapolation_plot.slider_high_q_getter = ['get_low_q_extrapolation_upper_limit'] + self.low_extrapolation_plot.slider_low_q_input = ['txtExtrapolQMin'] GuiUtils.updateModelItemWithPlot(self._model_item, self.low_extrapolation_plot, self.low_extrapolation_plot.title) plots.append(self.low_extrapolation_plot) @@ -571,12 +573,16 @@ def setupSlots(self): self.txtNptsHighQ.textChanged.connect(self.checkLength) + self.txtExtrapolQMin.editingFinished.connect(self.checkQMinRange) self.txtExtrapolQMin.textChanged.connect(self.checkQMinRange) + self.txtExtrapolQMax.editingFinished.connect(self.checkQMaxRange) self.txtExtrapolQMax.textChanged.connect(self.checkQMaxRange) + self.txtNptsLowQ.editingFinished.connect(self.checkQRange) self.txtNptsLowQ.textChanged.connect(self.checkQRange) + self.txtNptsHighQ.editingFinished.connect(self.checkQRange) self.txtNptsHighQ.textChanged.connect(self.checkQRange) def stateChanged(self): @@ -639,12 +645,22 @@ def checkQExtrapolatedData(self): self._path, name, self.sender().checkState()) - def checkQMaxRange(self): + def checkQMaxRange(self, value=None): + if not value: + value = float(self.txtExtrapolQMax.text()) if self.txtExtrapolQMax.text() else '' + if value == '': + self.model.setItem(WIDGETS.W_EX_QMAX, QtGui.QStandardItem(value)) + return item = QtGui.QStandardItem(self.txtExtrapolQMax.text()) self.model.setItem(WIDGETS.W_EX_QMAX, item) self.checkQRange() - def checkQMinRange(self): + def checkQMinRange(self, value=None): + if not value: + value = float(self.txtExtrapolQMin.text()) if self.txtExtrapolQMin.text() else '' + if value == '': + self.model.setItem(WIDGETS.W_EX_QMIN, QtGui.QStandardItem(value)) + return item = QtGui.QStandardItem(self.txtExtrapolQMin.text()) self.model.setItem(WIDGETS.W_EX_QMIN, item) self.checkQRange() diff --git a/src/sas/qtgui/Perspectives/Inversion/InversionPerspective.py b/src/sas/qtgui/Perspectives/Inversion/InversionPerspective.py index b5ae026d23..229f49f290 100644 --- a/src/sas/qtgui/Perspectives/Inversion/InversionPerspective.py +++ b/src/sas/qtgui/Perspectives/Inversion/InversionPerspective.py @@ -670,10 +670,10 @@ def removeData(self, data_list=None): self._dataList.pop(data, None) if self.dataPlot: # Reset dataplot sliders - self.dataPlot.slider_low_q_input = None - self.dataPlot.slider_high_q_input = None - self.dataPlot.slider_low_q_setter = None - self.dataPlot.slider_high_q_setter = None + self.dataPlot.slider_low_q_input = [] + self.dataPlot.slider_high_q_input = [] + self.dataPlot.slider_low_q_setter = [] + self.dataPlot.slider_high_q_setter = [] self._data = None length = len(self.dataList) for index in reversed(range(length)): @@ -1047,10 +1047,12 @@ def _calculateUpdate(self, output_tuple): self.dataPlot.filename = self.logic.data.filename self.dataPlot.show_q_range_sliders = True - self.dataPlot.slider_low_q_input = self.minQInput - self.dataPlot.slider_low_q_setter = self.check_q_low - self.dataPlot.slider_high_q_input = self.maxQInput - self.dataPlot.slider_high_q_setter = self.check_q_high + self.dataPlot.slider_update_on_move = False + self.dataPlot.slider_perspective_name = "Inversion" + self.dataPlot.slider_low_q_input = ['minQInput'] + self.dataPlot.slider_low_q_setter = ['check_q_low'] + self.dataPlot.slider_high_q_input = ['maxQInput'] + self.dataPlot.slider_high_q_setter = ['check_q_high'] # Udpate internals and GUI self.updateDataList(self._data) diff --git a/src/sas/qtgui/Plotting/LinearFit.py b/src/sas/qtgui/Plotting/LinearFit.py index 771739073d..316f0fe2c8 100644 --- a/src/sas/qtgui/Plotting/LinearFit.py +++ b/src/sas/qtgui/Plotting/LinearFit.py @@ -3,6 +3,8 @@ """ import re import numpy +from numbers import Number +from typing import Optional from PyQt5 import QtCore from PyQt5 import QtGui from PyQt5 import QtWidgets @@ -12,12 +14,14 @@ from sas.qtgui.Plotting import Fittings from sas.qtgui.Plotting import DataTransform from sas.qtgui.Plotting.LineModel import LineModel +from sas.qtgui.Plotting.QRangeSlider import QRangeSlider import sas.qtgui.Utilities.GuiUtils as GuiUtils # Local UI from sas.qtgui.UI import main_resources_rc from sas.qtgui.Plotting.UI.LinearFitUI import Ui_LinearFitUI + class LinearFit(QtWidgets.QDialog, Ui_LinearFitUI): updatePlot = QtCore.pyqtSignal(tuple) def __init__(self, parent=None, @@ -26,7 +30,7 @@ def __init__(self, parent=None, fit_range=(0.0, 0.0), xlabel="", ylabel=""): - super(LinearFit, self).__init__() + super(LinearFit, self).__init__(parent) self.setupUi(self) # disable the context help icon @@ -100,6 +104,9 @@ def __init__(self, parent=None, self.cstB = Fittings.Parameter(self.model, 'B', self.default_B) self.transform = DataTransform + self.q_sliders = None + self.drawSliders() + self.setFixedSize(self.minimumSizeHint()) # connect Fit button @@ -239,7 +246,9 @@ def fit(self, event): tempx = numpy.array(tempx) tempy = numpy.array(tempy) + self.clearSliders() self.updatePlot.emit((tempx, tempy)) + self.drawSliders() def origData(self): # Store the transformed values of view x, y and dy before the fit @@ -314,4 +323,29 @@ def floatInvTransform(self, x): return numpy.sqrt(numpy.sqrt(numpy.power(10.0, x))) return x - + def drawSliders(self): + """Show new Q-range sliders""" + self.data.show_q_range_sliders = True + self.q_sliders = QRangeSlider(self.parent, self.parent.ax, data=self.data) + self.q_sliders.line_min.input = self.txtFitRangeMin + self.q_sliders.line_max.input = self.txtFitRangeMax + # Ensure values are updated on redraw of plots + self.q_sliders.line_min.inputChanged() + self.q_sliders.line_max.inputChanged() + + def clearSliders(self): + """Clear existing sliders""" + if self.q_sliders: + self.q_sliders.clear() + self.data.show_q_range_sliders = False + self.q_sliders = None + + def closeEvent(self, ev): + self.clearSliders() + self.parent.update() + + def accept(self, ev): + self.close() + + def reject(self, ev): + self.close() diff --git a/src/sas/qtgui/Plotting/Plotter.py b/src/sas/qtgui/Plotting/Plotter.py index 6e5a43776e..efed9f2830 100644 --- a/src/sas/qtgui/Plotting/Plotter.py +++ b/src/sas/qtgui/Plotting/Plotter.py @@ -252,7 +252,12 @@ def plot(self, data=None, color=None, marker=None, hide_error=False, transform=T # Add q-range sliders if data.show_q_range_sliders: + # Grab existing slider if it exists + existing_slider = self.sliders.pop(data.name, None) sliders = QRangeSlider(self, self.ax, data=data) + # New sliders should be visible but existing sliders that were turned off should remain off + if existing_slider is not None and not existing_slider.is_visible: + sliders.toggle() self.sliders[data.name] = sliders # refresh canvas @@ -338,6 +343,11 @@ def addPlotsToContextMenu(self): self.actionRemovePlot.triggered.connect( functools.partial(self.onRemovePlot, id)) + if plot.show_q_range_sliders: + self.actionToggleSlider = plot_menu.addAction("Toggle Q-Range Slider Visibility") + self.actionToggleSlider.triggered.connect( + functools.partial(self.toggleSlider, id)) + if not plot.is_data: self.actionFreeze = plot_menu.addAction('&Freeze') self.actionFreeze.triggered.connect( @@ -540,7 +550,6 @@ def removePlot(self, id): # Remove the plot from the list of plots self.plot_dict.pop(id) - self.sliders.pop(id, None) # Labels might have been changed xl = self.ax.xaxis.label.get_text() @@ -561,6 +570,11 @@ def removePlot(self, id): self.ax.set_ylabel(yl) self.canvas.draw_idle() + def toggleSlider(self, id): + if id in self.sliders.keys(): + slider = self.sliders.get(id) + slider.toggle() + def onFreeze(self, id): """ Freezes the selected plot to a separate chart diff --git a/src/sas/qtgui/Plotting/PlotterData.py b/src/sas/qtgui/Plotting/PlotterData.py index badb37df62..fd349b53ef 100644 --- a/src/sas/qtgui/Plotting/PlotterData.py +++ b/src/sas/qtgui/Plotting/PlotterData.py @@ -47,16 +47,18 @@ def __init__(self, x=None, y=None, dx=None, dy=None): # Q-range slider definitions self.show_q_range_sliders = False # Should sliders be shown? self.slider_update_on_move = True # Should the gui update during the move? + self.slider_perspective_name = "" # Name of the perspective that this slider is associated with + self.slider_tab_name = "" # Name of the tab where the data set is # The following q-range slider variables are optional but help tie # the slider to a GUI element for 2-way updates - self.slider_low_q_input = None # Qt input that is tied to low-Q - self.slider_high_q_input = None # Qt input that is tied to high-Q + self.slider_low_q_input = [] # List of attributes that lead to a Qt input to tie a low Q input to the slider + self.slider_high_q_input = [] # List of attributes that lead to a Qt input to tie a high Q input to the slider # Setters and getters are only needed for inputs that aren't Q values # e.g. Invariant perspective nPts - self.slider_low_q_setter = None # Callback method to set the low-Q value - self.slider_low_q_getter = None # Callback method to get the low-Q value - self.slider_high_q_setter = None # Callback method to set the high-Q value - self.slider_high_q_getter = None # Callback method to get the high-Q value + self.slider_low_q_setter = [] # List of attributes that lead to a setter to tie a low Q method to the slider + self.slider_low_q_getter = [] # List of attributes that lead to a getter to tie a low Q method to the slider + self.slider_high_q_setter = [] # List of attributes that lead to a setter to tie a high Q method to the slider + self.slider_high_q_getter = [] # List of attributes that lead to a getter to tie a high Q method to the slider def copy_from_datainfo(self, data1d): """ diff --git a/src/sas/qtgui/Plotting/QRangeSlider.py b/src/sas/qtgui/Plotting/QRangeSlider.py index 6eaaecab93..16dc51ba53 100644 --- a/src/sas/qtgui/Plotting/QRangeSlider.py +++ b/src/sas/qtgui/Plotting/QRangeSlider.py @@ -1,102 +1,112 @@ -""" -Double slider interactor for setting the Q range for a fit or function -""" +"""Double slider interactor for setting the Q range for a fit or function""" import numpy as np +from typing import Union +from PyQt5.QtCore import QEvent +from PyQt5.QtWidgets import QLineEdit, QTextEdit from sas.qtgui.Plotting.PlotterData import Data1D from sas.qtgui.Plotting.Slicers.BaseInteractor import BaseInteractor class QRangeSlider(BaseInteractor): - """ - Draw a pair of draggable vertical lines. Each line can be linked to a GUI input. + """ Draw a pair of draggable vertical lines. Each line can be linked to a GUI input. The GUI input should update the lines and vice-versa. """ def __init__(self, base, axes, color='black', zorder=5, data=None): - """ - """ - BaseInteractor.__init__(self, base, axes, color=color) + # type: (Plotter, Plotter.ax, str, int, Data1D) -> None + """ Initialize the slideable lines and associated markers """ + # Assert the data object is a plottable assert isinstance(data, Data1D) + # Plotter object + BaseInteractor.__init__(self, base, axes, color=color) self.base = base self.markers = [] self.axes = axes self.data = data + # Track the visibility for toggling the slider on/off + self.is_visible = False self.connect = self.base.connect + # Min and max x values self.x_min = np.fabs(min(self.data.x)) self.y_marker_min = self.data.y[np.where(self.data.x == self.x_min)[0][0]] self.x_max = np.fabs(max(self.data.x)) self.y_marker_max = self.data.y[np.where(self.data.x == self.x_max)[0][-1]] + # Should the inputs update while the bar is actively being dragged? self.updateOnMove = data.slider_update_on_move + # Draw the lines self.line_min = LineInteractor(self, axes, zorder=zorder, x=self.x_min, y=self.y_marker_min, - input=self.data.slider_low_q_input, setter=self.data.slider_low_q_setter, - getter=self.data.slider_low_q_getter) + input=data.slider_low_q_input, setter=data.slider_low_q_setter, + getter=data.slider_low_q_getter, perspective=data.slider_perspective_name, + tab=data.slider_tab_name) self.line_max = LineInteractor(self, axes, zorder=zorder, x=self.x_max, y=self.y_marker_max, - input=self.data.slider_high_q_input, setter=self.data.slider_high_q_setter, - getter=self.data.slider_high_q_getter) + input=data.slider_high_q_input, setter=data.slider_high_q_setter, + getter=data.slider_high_q_getter, perspective=data.slider_perspective_name, + tab=data.slider_tab_name) self.has_move = True self.update() - def validate(self, param_name, param_value): - """ - Validate input from user - """ - return True - - def set_layer(self, n): - """ - Allow adding plot to the same panel - - :param n: the number of layer + def clear(self): + # type: () -> None + """ Clear this slicer and its markers """ + self.clear_markers() - """ - self.layernum = n + def show(self): + # type: () -> None + """ Show this slicer and its markers """ + self.line_max.draw() + self.line_min.draw() self.update() - def clear(self): - """ - Clear this slicer and its markers - """ - self.clear_markers() + def remove(self): + # type: () -> None + """ Remove this slicer and its markers """ + self.line_max.remove() + self.line_min.remove() + self.draw() + self.is_visible = False def update(self, x=None, y=None): - """ - Draw the new roughness on the graph. - """ - self.line_min.update(x, y) - self.line_max.update(x, y) + # type: (float, float) -> None + """Draw the new lines on the graph.""" + self.line_min.update(x, y, draw=self.updateOnMove) + self.line_max.update(x, y, draw=self.updateOnMove) self.base.update() + self.is_visible = True def save(self, ev): - """ - Remember the roughness for this layer and the next so that we - can restore on Esc. - """ + # type: (QEvent) -> None + """ Remember the position of the lines so that we can restore on Esc. """ self.line_min.save(ev) self.line_max.save(ev) def restore(self, ev): - """ - Restore the roughness for this layer. - """ + # type: (QEvent) -> None + """ Restore the lines. """ self.line_max.restore(ev) self.line_min.restore(ev) + def toggle(self): + # type: () -> None + """ Toggle the slider visibility. """ + if self.is_visible: + self.remove() + else: + self.show() + def move(self, x, y, ev): - """ - Process move to a new position, making sure that the move is allowed. - """ + # type: (float, float, QEvent) -> None + """ Process move to a new position, making sure that the move is allowed. """ pass def clear_markers(self): - """ - Clear each of the lines individually - """ + # type: () -> None + """ Clear each of the lines individually """ self.line_min.clear() self.line_max.clear() def draw(self): - """ - """ + # type: () -> None + """ Update the plot """ self.base.draw() @@ -104,60 +114,135 @@ class LineInteractor(BaseInteractor): """ Draw a single vertical line that can be dragged on a plot """ - def __init__(self, base, axes, color='black', zorder=5, x=0.5, y=0.5, input=None, setter=None, getter=None): - """ - """ + def __init__(self, base, axes, color='black', zorder=5, x=0.5, y=0.5, + input=None, setter=None, getter=None, perspective=None, tab=None): + # type: (Plotter, Plotter.ax, str, int, float, float, [str], [str], [str], str, str) -> None + """ Initialize the line interactor object""" BaseInteractor.__init__(self, base, axes, color=color) + # Plotter object self.base = base + # Inputs and methods linking this slider to a GUI element so, as one changes, the other also updates + self._input = None + self._setter = None + self._getter = None + # The marker(s) for this line - typically only one self.markers = [] + # The Plotter.ax object self.axes = axes + # X and Y values used for the line and markers self.x = x self.save_x = self.x self.y_marker = y self.save_y = self.y_marker - # Inner circle marker - self.inner_marker = self.axes.plot([self.x], [self.y_marker], linestyle='', marker='o', markersize=4, - color=self.color, alpha=0.6, pickradius=5, label=None, zorder=zorder, - visible=True)[0] - self.line = self.axes.axvline(self.x, linestyle='-', color=self.color, marker='', pickradius=5, - label=None, zorder=zorder, visible=True) + self.draw(zorder) + # Is the slider able to move self.has_move = True + if not perspective: + # The perspective this slider is actively tied to + self.perspective = None + return + # Import on demand to prevent circular import + from sas.qtgui.MainWindow.GuiManager import LOADED_PERSPECTIVES # Map GUI input to x value so slider and input update each other - self.input = input - if self.input: - self.input.textChanged.connect(self.inputChanged) - self.setter = setter if callable(setter) else None - self.getter = getter if callable(getter) else None + self.perspective = LOADED_PERSPECTIVES.get(perspective, None) + if tab and hasattr(self.perspective, 'getTabByName'): + # If the perspective is tabbed, set the perspective to the tab this slider in associated with + self.perspective = self.perspective.getTabByName(tab) + if self.perspective: + # Connect the inputs and methods + self.input = self._get_input_or_callback(input) + self.setter = self._get_input_or_callback(setter) + self.getter = self._get_input_or_callback(getter) self.connect_markers([self.line, self.inner_marker]) - self.update() + self.update(draw=True) - def validate(self, param_name, param_value): - """ - Validate input from user - Should never fail - """ - return True + @property + def input(self): + # type: () -> Union[QLineEdit, QTextEdit, None] + """ Get the text input that should be linked to the position of this slider """ + return self._input + + @input.setter + def input(self, input): + # type: (Union[QLineEdit, QTextEdit, None]) -> None + """ Set the text input that should be linked to the position of this slider """ + self._input = input + if self._input: + self._input.editingFinished.connect(self.inputChanged) + + @property + def setter(self): + # type: () -> Union[callable, None] + """ Get the x-value setter method associated with this slider """ + return self._setter + + @setter.setter + def setter(self, setter): + # type: (Union[callable, None]) -> None + """ Set the x-value setter method associated with this slider """ + self._setter = setter if callable(setter) else None + + @property + def getter(self): + # type: () -> Union[callable, None] + """ Get the x-value getter method associated with this slider """ + return self._getter + + @getter.setter + def getter(self, getter): + # type: (Union[callable, None]) -> None + """ Set the x-value getter associated with this slider """ + self._getter = getter if callable(getter) else None def clear(self): + # type: () -> None + """ Disconnect any inputs and callbacks and the clear the line and marker """ self.clear_markers() self.remove() def remove(self): - """ - Clear this slicer and its markers - """ - self.inner_marker.remove() - self.line.remove() + # type: () -> None + """ Clear this slicer and its markers """ + if self.inner_marker: + self.inner_marker.remove() + if self.line: + self.line.remove() + + def draw(self, zorder=5): + # type: (int) -> None + """ Draw the line and marker on the linked plot using the latest x and y values """ + # Inner circle marker + self.inner_marker = self.axes.plot([self.x], [self.y_marker], linestyle='', marker='o', markersize=4, + color=self.color, alpha=0.6, pickradius=5, label=None, zorder=zorder, + visible=True)[0] + self.line = self.axes.axvline(self.x, linestyle='-', color=self.color, marker='', pickradius=5, + label=None, zorder=zorder, visible=True) + + def _get_input_or_callback(self, connection_list=None): + # type: ([str]) -> Union[QLineEdit, QTextEdit, None] + """ Returns an input or callback method based on a list of inputs/commands """ + connection = None + if isinstance(connection_list, list): + connection = self.perspective + for item in connection_list: + try: + connection = getattr(connection, item) + except Exception: + return None + return connection def _set_q(self, value): - """ - Call the q setter callback method if it exists - """ - self.setter(value) + # type: (float) -> None + """ Call the q setter callback method if it exists """ + self.x = value + if self.setter and callable(self.setter): + self.setter(value) + elif hasattr(self.input, 'setText'): + self.input.setText(f"{value:.3}") def _get_q(self): - """ - Get the q value, inferring the method to get the value - """ + # type: () -> None + """ Get the q value, inferring the method to get the value """ if self.getter: # Separate callback method to get Q value self.x = float(self.getter()) @@ -169,15 +254,15 @@ def _get_q(self): self.x = float(self.input.getText()) def inputChanged(self): + # type: () -> None """ Track the input linked to the x value for this slider and update as needed """ self._get_q() self.y_marker = self.base.data.y[(np.abs(self.base.data.x - self.x)).argmin()] self.update(draw=True) def update(self, x=None, y=None, draw=False): - """ - Update the line position on the graph. - """ + # type: (float, float, bool) -> None + """ Update the line position on the graph. """ # Reset x, y -coordinates if given as parameters if x is not None: self.x = np.sign(self.x) * np.fabs(x) @@ -191,49 +276,39 @@ def update(self, x=None, y=None, draw=False): self.base.draw() def save(self, ev): - """ - Remember the position for this line so that we can restore on Esc. - """ + # type: (QEvent) -> None + """ Remember the position for this line so that we can restore on Esc. """ self.save_x = self.x self.save_y = self.y_marker def restore(self, ev): - """ - Restore the position for this line - """ + # type: (QEvent) -> None + """ Restore the position for this line """ self.x = self.save_x self.y_marker = self.save_y def move(self, x, y, ev): - """ - Process move to a new position, making sure that the move is allowed. - """ + # type: (float, float, QEvent) -> None + """ Process move to a new position, making sure that the move is allowed. """ self.has_move = True self.x = x if self.base.updateOnMove: - if self.setter: - self._set_q(self.x) - else: - self.input.setText(f"{self.x:.3}") + self._set_q(x) self.y_marker = self.base.data.y[(np.abs(self.base.data.x - self.x)).argmin()] self.update(draw=self.base.updateOnMove) def onRelease(self, ev): - """ - Update the line position when the mouse button is released - """ - if self.setter: - self._set_q(self.x) - else: - self.input.setText(f"{self.x:.3}") + # type: (QEvent) -> bool + """ Update the line position when the mouse button is released """ + # Set the Q value if a callable setter exists otherwise update the attached input + self._set_q(self.x) self.update(draw=True) self.moveend(ev) return True def clear_markers(self): - """ - Disconnect the input and clear the callbacks - """ + # type: () -> None + """ Disconnect the input and clear the callbacks """ if self.input: self.input.textChanged.disconnect(self.inputChanged) self.setter = None diff --git a/src/sas/qtgui/Plotting/UnitTesting/LinearFitTest.py b/src/sas/qtgui/Plotting/UnitTesting/LinearFitTest.py index 4fcf91cec4..dfbb4ad968 100644 --- a/src/sas/qtgui/Plotting/UnitTesting/LinearFitTest.py +++ b/src/sas/qtgui/Plotting/UnitTesting/LinearFitTest.py @@ -28,6 +28,7 @@ def setUp(self): dx=[0.1, 0.2, 0.3], dy=[0.1, 0.2, 0.3]) plotter = Plotter.Plotter(None, quickplot=True) + plotter.plot(self.data) self.widget = LinearFit(parent=plotter, data=self.data, xlabel="log10(x^2)", ylabel="log10(y)") def tearDown(self): diff --git a/src/sas/qtgui/Plotting/UnitTesting/QRangeSliderTests.py b/src/sas/qtgui/Plotting/UnitTesting/QRangeSliderTests.py new file mode 100644 index 0000000000..da8dd57de9 --- /dev/null +++ b/src/sas/qtgui/Plotting/UnitTesting/QRangeSliderTests.py @@ -0,0 +1,170 @@ +import sys +import unittest + +from PyQt5 import QtCore, QtWidgets + +# set up import paths +import sas.qtgui.path_prepare + +# Local +from sas.qtgui.MainWindow.GuiManager import GuiManager +from sas.qtgui.MainWindow.MainWindow import MainSasViewWindow +from sas.qtgui.Plotting.PlotterData import Data1D +import sas.qtgui.Plotting.Plotter as Plotter +from sas.qtgui.Plotting.QRangeSlider import QRangeSlider +from sas.qtgui.Plotting.LinearFit import LinearFit + +if not QtWidgets.QApplication.instance(): + app = QtWidgets.QApplication(sys.argv) + + +class QRangeSlidersTest(unittest.TestCase): + '''Test the QRangeSliders''' + + def setUp(self): + '''Create the ScaleProperties''' + class MainWindow(MainSasViewWindow): + # Main window of the application + def __init__(self, reactor, parent=None): + screen_resolution = QtCore.QRect(0, 0, 640, 480) + super(MainWindow, self).__init__(screen_resolution, parent) + + # define workspace for dialogs. + self.workspace = QtWidgets.QMdiArea(self) + self.setCentralWidget(self.workspace) + + self.manager = GuiManager(MainWindow(None)) + self.plotter = Plotter.Plotter(None, quickplot=True) + self.data = Data1D(x=[0.001,0.1,0.2,0.3,0.4], y=[1000,100,10,1,0.1]) + self.data.name = "Test QRangeSliders class" + self.data.show_q_range_sliders = True + self.data.slider_update_on_move = True + self.manager.filesWidget.updateModel(self.data, self.data.name) + self.current_perspective = None + self.slider = None + + def testUnplottedDefaults(self): + '''Test the QRangeSlider class in its default state when it is not plotted''' + self.plotter.plot(self.data) + self.assertRaises(AssertionError, lambda: QRangeSlider(self.plotter, self.plotter.ax)) + self.slider = QRangeSlider(self.plotter, self.plotter.ax, data=self.data) + self.assertIsNotNone(self.slider.base) + self.assertIsNotNone(self.slider.data) + self.assertTrue(self.slider.updateOnMove) + self.assertTrue(self.slider.has_move) + self.assertTrue(self.slider.is_visible) + self.assertIsNone(self.slider.line_min.input) + self.assertIsNone(self.slider.line_min.setter) + self.assertIsNone(self.slider.line_min.getter) + self.assertIsNone(self.slider.line_min.perspective) + self.assertIsNone(self.slider.line_max.input) + self.assertIsNone(self.slider.line_max.setter) + self.assertIsNone(self.slider.line_max.getter) + self.assertIsNone(self.slider.line_max.perspective) + + def testFittingSliders(self): + '''Test the QRangeSlider class within the context of the Fitting perspective''' + # Ensure fitting prespective is active and send data to it + self.current_perspective = 'Fitting' + self.manager.perspectiveChanged(self.current_perspective) + fitting = self.manager.perspective() + self.manager.filesWidget.sendData() + widget = fitting.currentTab + # Create slider on base data set + self.data.slider_tab_name = widget.modelName() + self.data.slider_perspective_name = self.current_perspective + self.data.slider_high_q_input = ['options_widget', 'txtMaxRange'] + self.data.slider_high_q_setter = ['options_widget', 'updateMaxQ'] + self.data.slider_low_q_input = ['options_widget', 'txtMinRange'] + self.data.slider_low_q_setter = ['options_widget', 'updateMinQ'] + self.plotter.plot(self.data) + self.slider = QRangeSlider(self.plotter, self.plotter.ax, data=self.data) + # Check inputs are linked properly. + self.assertEqual(len(self.plotter.sliders), 1) + self.assertEqual(self.slider.line_min.setter, widget.options_widget.updateMinQ) + self.assertEqual(self.slider.line_max.setter, widget.options_widget.updateMaxQ) + self.moveSliderAndInputs(widget.options_widget.txtMinRange, widget.options_widget.txtMaxRange) + + def testInvariantSliders(self): + '''Test the QRangeSlider class within the context of the Invariant perspective''' + # Ensure invariant prespective is active and send data to it + self.current_perspective = 'Invariant' + self.manager.perspectiveChanged(self.current_perspective) + widget = self.manager.perspective() + widget._data = self.data + # Create slider on base data set + self.data.slider_perspective_name = self.current_perspective + self.data.slider_low_q_input = ['txtNptsHighQ'] + self.data.slider_low_q_setter = ['set_high_q_extrapolation_lower_limit'] + self.data.slider_low_q_getter = ['get_high_q_extrapolation_lower_limit'] + self.data.slider_high_q_input = ['txtExtrapolQMax'] + self.plotter.plot(self.data) + self.slider = QRangeSlider(self.plotter, self.plotter.ax, data=self.data) + # Check inputs are linked properly. + self.assertEqual(len(self.plotter.sliders), 1) + # Move slider and ensure text input matches - Npts needs to be checked differently + self.moveSliderAndInputs(None, widget.txtExtrapolQMax) + # Check npts after moving line + self.slider.line_min.move(self.data.x[1], self.data.y[1], None) + self.assertAlmostEqual(5, float(widget.txtNptsHighQ.text())) + # Move npts and check slider + widget.txtNptsHighQ.setText('2') + self.assertAlmostEqual(self.data.x[2], self.slider.line_min.x) + + def testInversionSliders(self): + '''Test the QRangeSlider class within the context of the Inversion perspective''' + # Ensure inversion prespective is active and send data to it + self.current_perspective = 'Inversion' + self.manager.perspectiveChanged(self.current_perspective) + widget = self.manager.perspective() + # Create slider on base data set + self.data.slider_perspective_name = self.current_perspective + self.data.slider_low_q_input = ['minQInput'] + self.data.slider_low_q_setter = ['check_q_low'] + self.data.slider_high_q_input = ['maxQInput'] + self.data.slider_high_q_setter = ['check_q_high'] + self.plotter.plot(self.data) + self.slider = QRangeSlider(self.plotter, self.plotter.ax, data=self.data) + # Check inputs are linked properly. + self.assertEqual(len(self.plotter.sliders), 1) + # Move slider and ensure text input matches + self.moveSliderAndInputs(widget.minQInput, widget.maxQInput) + + def testLinearFitSliders(self): + '''Test the QRangeSlider class within the context of the Linear Fit tool''' + self.plotter.plot(self.data) + linearFit = LinearFit(self.plotter, self.data, (min(self.data.x), max(self.data.x)), + (min(self.data.x), max(self.data.x))) + linearFit.fit(None) + self.slider = linearFit.q_sliders + # Ensure base values match + self.assertAlmostEqual(min(self.data.x), float(linearFit.txtFitRangeMin.text())) + # Move inputs and sliders and ensure values match + self.moveSliderAndInputs(linearFit.txtFitRangeMin, linearFit.txtFitRangeMax) + + def moveSliderAndInputs(self, minInput, maxInput): + '''Helper method to minimize repeated code''' + # Check QRangeSlider defaults and connections + self.assertIsNotNone(self.slider) + + # Move slider and ensure text input matches + if minInput: + self.assertEqual(self.slider.line_min.input, minInput) + self.slider.line_min.move(self.data.x[1], self.data.y[1], None) + self.assertAlmostEqual(self.slider.line_min.x, float(minInput.text())) + if maxInput: + self.assertEqual(self.slider.line_max.input, maxInput) + self.slider.line_max.move(self.data.x[-2], self.data.y[-2], None) + self.assertAlmostEqual(self.slider.line_max.x, float(maxInput.text())) + + # Edit text input and ensure QSlider position matches + if minInput: + minInput.setText(f'{self.data.x[1]}') + self.assertAlmostEqual(self.slider.line_min.x, float(minInput.text())) + if maxInput: + maxInput.setText(f'{self.data.x[-2]}') + self.assertAlmostEqual(self.slider.line_max.x, float(maxInput.text())) + + +if __name__ == "__main__": + unittest.main()