diff --git a/src/sas/qtgui/Perspectives/Fitting/FittingWidget.py b/src/sas/qtgui/Perspectives/Fitting/FittingWidget.py
index b9fcacb39e..f1fbd015d6 100644
--- a/src/sas/qtgui/Perspectives/Fitting/FittingWidget.py
+++ b/src/sas/qtgui/Perspectives/Fitting/FittingWidget.py
@@ -23,6 +23,7 @@
from sas.qtgui.Perspectives.Fitting.FitPage import FitPage
from sas.qtgui.Perspectives.Fitting.FitThread import FitThread
from sas.qtgui.Perspectives.Fitting.FittingLogic import FittingLogic
+from sas.qtgui.Perspectives.Fitting.InViewWidget import InViewWidget
from sas.qtgui.Perspectives.Fitting.MagnetismWidget import MagnetismWidget
from sas.qtgui.Perspectives.Fitting.ModelThread import Calc1D, Calc2D
from sas.qtgui.Perspectives.Fitting.MultiConstraint import MultiConstraint
@@ -108,6 +109,8 @@ def __init__(self, parent: QtWidgets.QWidget | None = None, data: Any | None = N
# Logics.data contains a single Data1D/Data2D object
self._logic = [FittingLogic()]
+ self._in_view_widget = None
+
# Main GUI setup up
self.setupUi(self)
self.setWindowTitle("Fitting")
@@ -457,6 +460,7 @@ def setEnablementOnDataLoad(self) -> None:
self.chkChainFit.setVisible(True)
# This panel is not designed to view individual fits, so disable plotting
self.cmdPlot.setVisible(False)
+ self.cmdInView.setEnabled(False)
# Similarly on other tabs
self.options_widget.setEnablementOnDataLoad()
self.onSelectModel()
@@ -536,6 +540,16 @@ def toggle2D(self, isChecked: bool) -> None:
""" Enable/disable the controls dependent on 1D/2D data instance """
self.chkMagnetism.setEnabled(isChecked)
self.is2D = isChecked
+
+ if isChecked:
+ # 2D view is toggled on, therefore disable InView button,
+ # and close InView widget if opened
+ self.cmdInView.setEnabled(False)
+ if getattr(self, '_in_view_widget', None):
+ self._in_view_widget.close()
+ else:
+ # 2D view is toggled off, therefore enable InView button
+ self.cmdInView.setEnabled(True)
# Reload the current model
if self.logic.kernel_module:
self.onSelectModel()
@@ -591,6 +605,7 @@ def initializeSignals(self) -> None:
self.cmdFit.clicked.connect(self.onFit)
self.cmdPlot.clicked.connect(self.onPlot)
self.cmdHelp.clicked.connect(self.onHelp)
+ self.cmdInView.clicked.connect(self.onInView)
# Respond to change in parameters from the UI
self._model_model.dataChanged.connect(self.onMainParamsChange)
@@ -1658,6 +1673,72 @@ def onSelectCategory(self) -> None:
self.cbModel.addItems(sorted(models_to_show))
self.cbModel.blockSignals(False)
+ def onInView(self):
+ """
+ Trigers the display of 'InView'
+ """
+ if self._in_view_widget is None:
+ self._in_view_widget = InViewWidget(parent=self)
+ self._in_view_widget.destroyed.connect(lambda: setattr(self, '_in_view_widget', None))
+
+ # When InView is closed, re-enable editing and clear reference
+ def _on_inview_closed():
+ self._in_view_widget = None
+ # Enabling paramters edits
+ self.lstParams.setEnabled(True)
+ self.cbCategory.setEnabled(True)
+ self.cmdFit.setEnabled(True)
+ self.cmdPlot.setEnabled(True)
+
+
+ self._in_view_widget.destroyed.connect(_on_inview_closed)
+
+ self.lstParams.setEnabled(False)
+ self.cbCategory.setEnabled(False)
+ self.cmdFit.setEnabled(False)
+ self.cmdPlot.setEnabled(False)
+
+ ###
+ # Validate parameter bounds for InView (no +/-inf allowed)
+ params = list(self.main_params_to_fit)
+ if self.chkPolydispersity.isChecked():
+ params += list(self.polydispersity_widget.poly_params_to_fit)
+ has_infinite_bounds = False
+ for p in params:
+ try:
+ details = self.logic.kernel_module.details.get(p)
+ if details is None or len(details) < 3:
+ continue
+ pmin, pmax = details[1], details[2]
+ # Disallow non-finite bounds
+ if not np.isfinite(pmin) or not np.isfinite(pmax):
+ has_infinite_bounds = True
+ break
+ except Exception:
+ # If anything odd, be conservative and flag
+ has_infinite_bounds = True
+ break
+ if has_infinite_bounds:
+ QtWidgets.QMessageBox.warning(self, "InView",
+ "Some parameters have infinite bounds. Please set appropriate min/max ranges.")
+ # Re-enable controls since we are not opening InView
+ self.lstParams.setEnabled(True)
+ self.cbCategory.setEnabled(True)
+ self.cmdFit.setEnabled(True)
+ self.cmdPlot.setEnabled(True)
+ return
+ ###
+
+ # Pushing the 1D data set to InView
+ self._in_view_widget.setData(self.logic.data)
+
+ # Construct sliders from the Fit Page
+ self._in_view_widget.initFromFitPage(self)
+
+ self._in_view_widget.show()
+ self._in_view_widget.raise_()
+ self._in_view_widget.activateWindow()
+
def onHelp(self):
"""
Show the "Fitting" section of help
@@ -3362,6 +3443,10 @@ def enableInteractiveElements(self) -> None:
self.fit_started = False
self.setInteractiveElements(True)
+ # Re-enable InView button for 1D data
+ if not getattr(self, 'is2D', False):
+ self.cmdInView.setEnabled(True)
+
def disableInteractiveElements(self) -> None:
"""
Set button caption on fitting/calculate start
@@ -3373,6 +3458,15 @@ def disableInteractiveElements(self) -> None:
self.cmdFit.setText('Stop fit')
self.setInteractiveElements(False)
+ # Disable InView button and close the widget if opened
+ self.cmdInView.setEnabled(False)
+ isInView = getattr(self, '_in_inview_widget', None)
+ if isInView is not None:
+ try:
+ isInView.close()
+ except Exception:
+ pass
+
def disableInteractiveElementsOnCalculate(self) -> None:
"""
Set button caption on fitting/calculate start
@@ -3384,6 +3478,15 @@ def disableInteractiveElementsOnCalculate(self) -> None:
self.cmdFit.setText('Running...')
self.setInteractiveElements(False)
+ # Disable InView button and close the widget if opened
+ self.cmdInView.setEnabled(False)
+ isInView = getattr(self, '_in_inview_widget', None)
+ if isInView is not None:
+ try:
+ isInView.close()
+ except Exception:
+ pass
+
def readFitPage(self, fp: FitPage) -> None:
"""
Read in state from a fitpage object and update GUI
diff --git a/src/sas/qtgui/Perspectives/Fitting/InViewWidget.py b/src/sas/qtgui/Perspectives/Fitting/InViewWidget.py
new file mode 100644
index 0000000000..638e2e3179
--- /dev/null
+++ b/src/sas/qtgui/Perspectives/Fitting/InViewWidget.py
@@ -0,0 +1,318 @@
+import copy
+import logging
+
+from PySide6 import QtCore, QtWidgets
+
+from sas.qtgui.Perspectives.Fitting import FittingUtilities
+from sas.qtgui.Perspectives.Fitting.ModelThread import Calc1D
+from sas.qtgui.Perspectives.Fitting.UI.InViewWidgetUI import Ui_InViewWidgetUI
+from sas.qtgui.Plotting.Plotter import PlotterWidget
+from sas.qtgui.Plotting.PlotterData import Data1D
+from sas.qtgui.Utilities import GuiUtils
+from sas.system import HELP_SYSTEM
+
+logger = logging.getLogger(__name__)
+
+class InViewWidget(QtWidgets.QWidget, Ui_InViewWidgetUI):
+ def __init__(self, parent: QtWidgets.QWidget):
+ super().__init__(parent)
+ self.setAttribute(QtCore.Qt.WA_DeleteOnClose, True)
+ self.setWindowFlag(QtCore.Qt.Window, True)
+ self.setupUi(self)
+ self.communicator = GuiUtils.Communicate()
+ self.plotter = PlotterWidget(self, self)
+ self._has_data = False
+ if self.plotBox.layout() is not None:
+ self.plotBox.layout().addWidget(self.plotter)
+ self._fw = None
+ self._plot_name = "InView Model"
+ self._param_values = {}
+ self._param_info = {}
+ self._slider_meta = {}
+ self._update_timer = QtCore.QTimer(self)
+ self._update_timer.setSingleShot(True)
+ self._update_timer.setInterval(75)
+ self._update_timer.timeout.connect(self._recompute_model)
+ if hasattr(self, 'cmdUpdateParam'):
+ self.cmdUpdateParam.clicked.connect(self._apply_to_fitpage)
+ if hasattr(self, 'cmdHelp'):
+ self.cmdHelp.clicked.connect(self.onHelp)
+ if hasattr(self, 'cmdClose'):
+ self.cmdClose.clicked.connect(self.close)
+
+ def setData(self, data):
+ if not isinstance(data, Data1D):
+ return
+ self._has_data = True
+ #self.plotter.plot(data=data)
+
+ try:
+ x = data.x
+ y = data.y
+ if y is not None and hasattr(y, "__len__") and len(x) == len(y):
+ self.plotter.plot(data=data)
+ except Exception:
+ pass
+
+ def initFromFitPage(self, fitting_widget):
+ self._fw = fitting_widget
+ params = list(self._fw.main_params_to_fit)
+ try:
+ if self._fw.chkPolydispersity.isChecked():
+ params += list(self._fw.polydispersity_widget.poly_params_to_fit)
+ except Exception:
+ pass
+ seen = set()
+ params = [p for p in params if not (p in seen or seen.add(p))]
+ self._param_info = {}
+ for name in params:
+ try:
+ model_key = self._fw.getModelKeyFromName(name)
+ row = self._fw.getRowFromName(name)
+ if row is None:
+ continue
+ val = GuiUtils.toDouble(self._fw.model_dict[model_key].item(row, 1).text())
+ except Exception:
+ continue
+ try:
+ _, pmin, pmax = self._fw.logic.kernel_module.details.get(name, ('', None, None))
+ except Exception:
+ pmin = None
+ pmax = None
+ if pmin is None or pmax is None or not (pmax > pmin):
+ delt = abs(val) if val is not None else 1.0
+ if delt <= 0:
+ delt = 1.0
+ pmin = (val - delt) if val is not None else -1.0
+ pmax = (val + delt) if val is not None else 1.0
+ ###
+ unit = ""
+ try:
+ d = self._fw.logic.kernel_module.details.get(name)
+ if d and len(d) >= 1 and d[0]:
+ unit = str(d[0])
+ elif name.endswith('.width'):
+ base = name[:-6]
+ bd = self._fw.logic.kernel_module.details.get(base)
+ if bd and len(bd) >= 1 and bd[0]:
+ unit = str(bd[0])
+ except Exception:
+ unit = ""
+
+ self._param_info[name] = {
+ 'min': float(pmin),
+ 'max': float(pmax),
+ 'value': float(val),
+ 'unit': unit
+ }
+ #self._param_info[name] = {'min': float(pmin), 'max': float(pmax), 'value': float(val)}
+ ###
+ self._build_sliders(list(self._param_info.keys()))
+ self._recompute_model()
+
+ def _build_sliders(self, params):
+ layout = self.sliderBox.layout()
+ while layout.count():
+ item = layout.takeAt(0)
+ w = item.widget()
+ if w is not None:
+ w.deleteLater()
+ else:
+ sub = item.layout()
+ if sub is not None:
+ while sub.count():
+ child = sub.takeAt(0)
+ cw = child.widget()
+ if cw is not None:
+ cw.deleteLater()
+ sub.deleteLater()
+ self._slider_meta = {}
+ steps = 1000
+ for name in params:
+ info = self._param_info[name]
+ container = QtWidgets.QWidget(self)
+ h = QtWidgets.QHBoxLayout(container)
+ h.setContentsMargins(0, 0, 0, 0)
+ lbl = QtWidgets.QLabel(self._display_label_for_param(name), container)
+ slider = QtWidgets.QSlider(QtCore.Qt.Horizontal, container)
+ slider.setMinimum(0)
+ slider.setMaximum(steps)
+ slider.setTracking(True)
+ spin = QtWidgets.QDoubleSpinBox(container)
+ spin.setDecimals(8)
+ spin.setMinimum(info['min'])
+ spin.setMaximum(info['max'])
+ ###
+ if info['max'] > info['min']:
+ spin.setSingleStep((info['max'] - info['min'])/100.0)
+
+ unit = info.get('unit', '')
+ unit_lbl = QtWidgets.QLabel("", container)
+ unit_lbl.setObjectName(f"lblUnit_{name}")
+ unit_lbl.setContentsMargins(6, 0, 0, 0)
+ if unit:
+ unit_lbl.setTextFormat(QtCore.Qt.RichText)
+ unit_lbl.setText(GuiUtils.convertUnitToHTML(unit))
+ unit_lbl.setVisible(bool(unit))
+
+ val = info['value']
+ spin.setValue(val)
+ ###
+ pos = self._value_to_slider(val, info['min'], info['max'], steps)
+ slider.setValue(pos)
+ h.addWidget(lbl)
+ h.addWidget(slider, 2)
+ h.addWidget(spin)
+ h.addWidget(unit_lbl)
+ layout.addWidget(container)
+ self._slider_meta[name] = {'slider': slider, 'spin': spin, 'min': info['min'], 'max': info['max'], 'steps': steps}
+ self._param_values[name] = val
+ slider.valueChanged.connect(lambda v, p=name: self._on_slider_changed(p, v))
+ spin.valueChanged.connect(lambda v, p=name: self._on_spin_changed(p, v))
+
+ def _display_label_for_param(self, name: str) -> str:
+ if name.endswith('.width'):
+ try:
+ poly_model = self._fw.polydispersity_widget.poly_model
+ for row in range(poly_model.rowCount()):
+ item = poly_model.item(row, 0)
+ if item is None:
+ continue
+ if item.data(QtCore.Qt.UserRole) == name:
+ txt = item.text()
+ if txt:
+ return str(txt)
+ except Exception:
+ pass
+ base = name[:-6]
+ return f"Distribution of {base}"
+ return name
+
+ def _slider_to_value(self, pos, min_v, max_v, steps):
+ return float(min_v + (max_v - min_v) * (float(pos) / float(steps)))
+
+ def _value_to_slider(self, val, min_v, max_v, steps):
+ if max_v == min_v:
+ return 0
+ t = (val - min_v) / (max_v - min_v)
+ t = min(max(t, 0.0), 1.0)
+ return int(round(t * steps))
+
+ def _on_slider_changed(self, name, pos):
+ meta = self._slider_meta.get(name)
+ if not meta:
+ return
+ val = self._slider_to_value(pos, meta['min'], meta['max'], meta['steps'])
+ self._param_values[name] = val
+ spin = meta['spin']
+ try:
+ spin.blockSignals(True)
+ spin.setValue(val)
+ finally:
+ spin.blockSignals(False)
+ self._update_timer.start()
+
+ def _on_spin_changed(self, name, val):
+ meta = self._slider_meta.get(name)
+ if not meta:
+ return
+ self._param_values[name] = float(val)
+ slider = meta['slider']
+ pos = self._value_to_slider(val, meta['min'], meta['max'], meta['steps'])
+ try:
+ slider.blockSignals(True)
+ slider.setValue(pos)
+ finally:
+ slider.blockSignals(False)
+ self._update_timer.start()
+
+ def _recompute_model(self):
+ if self._fw is None or not self._has_data:
+ return
+ model = copy.deepcopy(self._fw.logic.kernel_module)
+ #####
+ # 1) If polydispersity is enabled, push slider widths into the polydispersity widget
+ try:
+ if self._fw.chkPolydispersity.isChecked():
+ for pname, pinfo in self._param_info.items():
+ if '.width' in pname:
+ val = float(self._param_values.get(pname, pinfo['value']))
+ self._fw.polydispersity_widget.poly_params[pname] = val
+ except Exception:
+ pass
+
+ # 2) Apply extras (polydispersity + magnetism) using the Fit page widgets
+ try:
+ # This calls polydispersity_widget.updateModel(model) and magnetism_widget.updateModel(model)
+ self._fw.updateKernelModelWithExtraParams(model)
+ except Exception:
+ pass
+
+ # 3) Apply all slider params to the model (non-PDI and as a final override for PDI widths)
+ for pname, pinfo in self._param_info.items():
+ try:
+ model.setParam(pname, float(self._param_values.get(pname, pinfo['value'])))
+ except Exception:
+ pass
+ #####
+ data = self._fw.logic.data
+ qmin = self._fw.q_range_min
+ qmax = self._fw.q_range_max
+ smearer = self._fw.smearing_widget.smearer()
+ weight = FittingUtilities.getWeight(data=data, is2d=self._fw.is2D, flag=self._fw.weighting)
+ calc = Calc1D(model=model, page_id=0, data=data, qmin=qmin, qmax=qmax, smearer=smearer, weight=weight, update_chisqr=False, completefn=self._complete_inview)
+ try:
+ ret = calc.compute()
+ if ret is not None:
+ self._complete_inview(ret)
+ except Exception:
+ return
+
+ def _complete_inview(self, return_data):
+ try:
+ fitted = self._fw.logic.new1DPlot(return_data, self._fw.tab_id)
+ except Exception:
+ return
+ fitted.name = self._plot_name
+ fitted.symbol = "Line"
+
+ if self._plot_name in self.plotter.plot_dict:
+ self.plotter.replacePlot(self._plot_name, fitted)
+ else:
+ self.plotter.plot(data=fitted)
+
+ def _apply_to_fitpage(self):
+ if self._fw is None:
+ return
+ # Update the Fit page table only;
+ for name, val in self._param_values.items():
+ row = self._fw.getRowFromName(name)
+ if row is None:
+ continue
+ model_key = self._fw.getModelKeyFromName(name)
+ model = self._fw.model_dict.get(model_key)
+ if model is None:
+ continue
+ try:
+ model.item(row, 1).setText(GuiUtils.formatNumber(val, high=True))
+ except Exception:
+ model.item(row, 1).setText(str(val))
+ # Let the view refresh and dataChanged handlers run
+ QtWidgets.QApplication.processEvents()
+
+ # Stoping any pending slider-triggered recompute , after that, closing the window
+ if hasattr(self, '_update_timer'):
+ self._update_timer.stop()
+
+ def onHelp(self):
+ """
+ Show the InView help in the embedded documentation window
+ """
+ if not HELP_SYSTEM.path:
+ logger.error("Help documentation was not found.")
+ return
+ base = HELP_SYSTEM.path
+ help_location = base / "user" / "qtgui" / "Perspectives" / "Fitting" / "inview.html"
+ if not help_location.exists():
+ help_location = base / "user" / "qtgui" / "Perspectives" / "Fitting" / "fitting_help.html"
+ self._help_window = GuiUtils.showHelp(help_location)
diff --git a/src/sas/qtgui/Perspectives/Fitting/UI/FittingWidgetUI.ui b/src/sas/qtgui/Perspectives/Fitting/UI/FittingWidgetUI.ui
index f1fea3c5aa..df810fd518 100755
--- a/src/sas/qtgui/Perspectives/Fitting/UI/FittingWidgetUI.ui
+++ b/src/sas/qtgui/Perspectives/Fitting/UI/FittingWidgetUI.ui
@@ -41,6 +41,31 @@
+ -
+
+
+ true
+
+
+
+ 0
+ 0
+
+
+
+
+ 93
+ 28
+
+
+
+ InView
+
+
+ false
+
+
+
-
@@ -150,6 +175,9 @@
-
+
+ true
+
0
@@ -440,7 +468,7 @@
false
-
+
Magnetism
@@ -465,8 +493,6 @@
chk2DView
chkMagnetism
chkChainFit
- lstMagnetic
- cmdMagneticDisplay
cmdPlot
cmdFit
cmdHelp
diff --git a/src/sas/qtgui/Perspectives/Fitting/UI/InViewWidgetUI.ui b/src/sas/qtgui/Perspectives/Fitting/UI/InViewWidgetUI.ui
new file mode 100644
index 0000000000..8a0d7e4acc
--- /dev/null
+++ b/src/sas/qtgui/Perspectives/Fitting/UI/InViewWidgetUI.ui
@@ -0,0 +1,108 @@
+
+
+ InViewWidgetUI
+
+
+
+ 0
+ 0
+ 800
+ 515
+
+
+
+
+ 800
+ 500
+
+
+
+ InView
+
+
+
-
+
+
-
+
+
+
+ 0
+ 0
+
+
+
+
+ 0
+ 450
+
+
+
+
+ 600
+ 16777215
+
+
+
+ Sliders
+
+
+
+
+ -
+
+
-
+
+
+ Update fitted parameters
+
+
+
+ -
+
+
+
+ 65
+ 16777215
+
+
+
+ Close
+
+
+
+ -
+
+
+
+ 65
+ 16777215
+
+
+
+ Help
+
+
+
+
+
+
+
+ -
+
+
+
+ 0
+ 0
+
+
+
+ InView
+
+
+
+
+
+
+
+
+
diff --git a/src/sas/qtgui/Perspectives/Fitting/UnitTesting/InViewWidgetTest.py b/src/sas/qtgui/Perspectives/Fitting/UnitTesting/InViewWidgetTest.py
new file mode 100644
index 0000000000..b98755d380
--- /dev/null
+++ b/src/sas/qtgui/Perspectives/Fitting/UnitTesting/InViewWidgetTest.py
@@ -0,0 +1,183 @@
+import logging
+from types import SimpleNamespace
+from unittest.mock import MagicMock
+
+import pytest
+from PySide6 import QtCore, QtGui, QtWidgets
+
+from sas.qtgui.Perspectives.Fitting import FittingUtilities
+from sas.qtgui.Perspectives.Fitting.InViewWidget import InViewWidget
+from sas.qtgui.Plotting.PlotterData import Data1D
+
+logger = logging.getLogger(__name__)
+
+class InViewWidgetTest:
+
+ @pytest.fixture(autouse=True)
+ def widget(self, qapp, qtbot):
+ w = InViewWidget(parent=None)
+ # Prevent auto-deletion on close during tests
+ w.setAttribute(QtCore.Qt.WA_DeleteOnClose, False)
+ qtbot.addWidget(w) # keeps it alive and cleans up reliably
+ w.show()
+ qapp.processEvents()
+ return w
+
+ def _make_fw(self, *, params, values, details, data=None, pdi=False):
+ """
+ Building dummy widget of Fit Page with elements that are connected with InViewWidget
+ """
+
+ class _FW:
+ pass
+
+ fw = _FW()
+ fw.main_params_to_fit = list(params)
+
+ class _Chk:
+ def __init__(self, checked):
+ self._c = checked
+
+ def isChecked(self):
+ return self._c
+
+ class _Poly:
+ def __init__(self):
+ self.poly_params_to_fit = [n for n in params if n.endswith('.width')]
+ self.poly_model = QtGui.QStandardItemModel()
+ for n in self.poly_params_to_fit:
+ it = QtGui.QStandardItem(f"Distribution of {n[:-6]}")
+ it.setData(n, QtCore.Qt.UserRole)
+ self.poly_model.appendRow(it)
+ self.poly_params = {}
+
+ fw.polydispersity_widget = _Poly()
+
+
+ # Adding controls that are getting turned on and off when InViewWidget is active
+ fw.lstParams = QtWidgets.QTableView()
+ fw.cbCategory = QtWidgets.QComboBox()
+ fw.cmdFit = QtWidgets.QPushButton()
+ fw.cmdPlot = QtWidgets.QPushButton()
+
+ fw.getModelKeyFromName = lambda name: 'model'
+ fw.getRowFromName = lambda name: 0
+
+ # Param Table
+ m = QtGui.QStandardItemModel(1, 2)
+
+ first_param = params[0]
+ m.setItem(0, 1, QtGui.QStandardItem(str(values[first_param])))
+ fw.model_dict = {'model': m}
+
+ class _Kernel:
+ def __init__(self, _details):
+ self.details = dict(_details)
+
+ def setParam(self, name, value): #### <-----------------
+ pass
+
+ class _Logic:
+ def __init__(self):
+ self.kernel_module = _Kernel(details)
+ self.data = data or Data1D(x=[0.01, 0.02], y=[1.0, 1.0])
+
+ def new1DPlot(self, return_data, tab_id):
+ return SimpleNamespace()
+
+
+ fw.logic = _Logic()
+ fw.q_range_min = None
+ fw.q_range_max = None
+
+ class _Smearing:
+ def smearer(self):
+ return None
+
+ fw.smearing_widget = _Smearing()
+ fw.is2D = False
+ fw.weighting = None
+ fw.tab_id = 1
+ return fw
+
+ def test_defaults(self, widget):
+ assert isinstance(widget, QtWidgets.QWidget)
+ assert widget._update_timer.isSingleShot()
+ assert widget._update_timer.interval() == 75 #### <-----------------
+ assert widget.plotter is not None
+
+ def test_setData_plots(self, widget, mocker):
+ widget.plotter.plot = MagicMock()
+ d = Data1D(x=[1.0, 2.0], y=[1.0, 2.0])
+ widget.setData(d)
+ assert widget._has_data is True
+ widget.plotter.plot.assert_called_once()
+
+ def test_initFromFitPage_builds_sliders(self, widget):
+ fw = self._make_fw(
+ params=['scale'],
+ values={'scale': 1.0},
+ details={'scale': ('', 0.5, 1.5)}
+ )
+ widget.initFromFitPage(fw)
+ # slider metadata exists
+ assert 'scale' in widget._slider_meta
+ meta = widget._slider_meta['scale']
+ spin = meta['spin']
+ assert spin.minimum() == 0.5
+ assert spin.maximum() == 1.5
+ assert spin.value() == 1.0
+
+ def test_recompute_model_triggers_calc_and_plot(self, widget, mocker):
+ d = Data1D(x=[0.01, 0.02], y=[1.0, 1.0])
+ widget.setData(d)
+ fw = self._make_fw(
+ params=['scale'],
+ values={'scale': 1.0},
+ details={'scale': ('', 0.5, 1.5)},
+ data=d
+ )
+
+ # Keep weight trivial
+ mocker.patch.object(FittingUtilities, 'getWeight', return_value=None)
+
+ # Dummy Calc1D which returns something to feed _complete_inview
+ class DummyCalc:
+ def __init__(self, **kwargs): self.kwargs = kwargs
+ def compute(self): return {'theory': [1.0, 1.0]}
+
+ mocker.patch('sas.qtgui.Perspectives.Fitting.InViewWidget.Calc1D',
+ side_effect=lambda **kw: DummyCalc(**kw))
+
+ # Plotter interactions: ensure plot_dict and methods are safe
+ widget.plotter.plot = MagicMock()
+ widget.plotter.replacePlot = MagicMock()
+ widget.plotter.plot_dict = {}
+
+ # Observe new1DPlot call and plotting
+ fw.logic.new1DPlot = MagicMock(return_value=SimpleNamespace())
+ widget.initFromFitPage(fw) # calls _recompute_model inside
+ assert fw.logic.new1DPlot.called
+ assert widget.plotter.plot.called or widget.plotter.replacePlot.called
+
+ def test_apply_to_fitpage_updates_model(self, widget, mocker):
+ fw = self._make_fw(
+ params=['scale'],
+ values={'scale': 1.0},
+ details={'scale': ('', 0.5, 1.5)}
+ )
+ widget.initFromFitPage(fw)
+ widget._param_values['scale'] = 1.2345
+
+ # Stable formatting for assertion
+ mocker.patch('sas.qtgui.Perspectives.Fitting.InViewWidget.GuiUtils.formatNumber',
+ side_effect=lambda v, high=True: str(v))
+
+ widget._apply_to_fitpage()
+ model = fw.model_dict['model']
+ assert model.item(0, 1).text() == '1.2345'
+
+
+ @pytest.mark.xfail(reason="2022-09 already broken")
+ def test_onHelp(self, widget):
+ pass
diff --git a/src/sas/qtgui/Perspectives/Fitting/media/inview.rst b/src/sas/qtgui/Perspectives/Fitting/media/inview.rst
new file mode 100644
index 0000000000..4a6c71bd1c
--- /dev/null
+++ b/src/sas/qtgui/Perspectives/Fitting/media/inview.rst
@@ -0,0 +1,27 @@
+.. _InView_Documentation:
+
+InView Documentation
+=====================
+
+.. toctree::
+ :maxdepth: 1
+
+qtgui/Perspectives/Fitting/inview
+
+The 'InView' widget (Interactive View) is a tool that allows user to visualize in real-time how the model is changing when selected parameters are adjusted,
+and to compare this changes with 1D experimental data.
+
+Accessing InView
+----------------
+By clicking button 'InView' in Fitting, InView widget window is opened. This button is disabled in case of Batch fitting (REF) and when 2D option in Fitting is selected.
+The InView window is divided into two sections, InvView and Sliders.
+
+.. image:: FittingWidget_marked.png
+
+How to use
+----------
+After sending data into Fitting and choosing the model, one has to tick desired parameters and open the InView window.
+Next to the interactive plot, one would see sliders for each parameter that has been previously selected. Once the model is satisfactory, it can be send back to Fitting
+via 'Update fitted parameters'.
+
+.. image:: InViewWindow.png
\ No newline at end of file