mirror of
https://github.com/bec-project/bec_widgets.git
synced 2026-08-10 04:00:36 +02:00
feat(scan_control): make the scan selection combobox editable
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
committed by
Jan Wyzula
co-authored by
Claude Opus 5
parent
de78853dea
commit
33ea484da6
@@ -13,6 +13,7 @@ from qtpy.QtCore import QSignalBlocker, Qt, QTimer, Signal
|
||||
from qtpy.QtWidgets import (
|
||||
QApplication,
|
||||
QComboBox,
|
||||
QCompleter,
|
||||
QDialog,
|
||||
QGroupBox,
|
||||
QHBoxLayout,
|
||||
@@ -131,6 +132,7 @@ class ScanControl(BECWidget, QWidget):
|
||||
self._last_scan_fetch_watchdog.setSingleShot(True)
|
||||
self._last_scan_fetch_watchdog.setInterval(self.LAST_SCAN_FETCH_TIMEOUT_MS)
|
||||
self._last_scan_fetch_watchdog.timeout.connect(self._on_last_scan_parameters_timeout)
|
||||
self._selected_scan: str | None = None
|
||||
|
||||
# Create and set main layout
|
||||
self._init_UI()
|
||||
@@ -145,6 +147,13 @@ class ScanControl(BECWidget, QWidget):
|
||||
scan_selection_layout = QHBoxLayout()
|
||||
self.comboBox_scan_selection_label = QLabel("Scan:", self.scan_selection_group)
|
||||
self.comboBox_scan_selection = QComboBox(self.scan_selection_group)
|
||||
self.comboBox_scan_selection.setEditable(True)
|
||||
self.comboBox_scan_selection.setInsertPolicy(QComboBox.NoInsert)
|
||||
completer = QCompleter(self.comboBox_scan_selection.model(), self.comboBox_scan_selection)
|
||||
completer.setCaseSensitivity(Qt.CaseInsensitive)
|
||||
completer.setCompletionMode(QCompleter.PopupCompletion)
|
||||
completer.setFilterMode(Qt.MatchContains)
|
||||
self.comboBox_scan_selection.setCompleter(completer)
|
||||
self.scan_info_button = QToolButton(self.scan_selection_group)
|
||||
self.scan_info_button.setIcon(material_icon("info", size=(20, 20), convert_to_pixmap=False))
|
||||
self.scan_info_button.setAutoRaise(True)
|
||||
@@ -188,8 +197,10 @@ class ScanControl(BECWidget, QWidget):
|
||||
self.layout.addWidget(self.scan_control_group)
|
||||
|
||||
# Connect signals
|
||||
self.comboBox_scan_selection.view().pressed.connect(self.save_current_scan_parameters)
|
||||
self.comboBox_scan_selection.currentIndexChanged.connect(self.on_scan_selection_changed)
|
||||
self.comboBox_scan_selection.currentTextChanged.connect(self.on_scan_selection_changed)
|
||||
self.comboBox_scan_selection.lineEdit().editingFinished.connect(
|
||||
self.validate_scan_selection
|
||||
)
|
||||
self.scan_info_button.clicked.connect(self.show_selected_scan_info)
|
||||
self.scan_selector_settings_button.clicked.connect(self.show_scan_selector_settings)
|
||||
self.button_run_scan.clicked.connect(self.run_scan)
|
||||
@@ -201,7 +212,7 @@ class ScanControl(BECWidget, QWidget):
|
||||
|
||||
# Default scan from config; applied after population so the entry exists
|
||||
if self.config.default_scan is not None:
|
||||
self.comboBox_scan_selection.setCurrentText(self.config.default_scan)
|
||||
self.current_scan = self.config.default_scan
|
||||
|
||||
# Append metadata form
|
||||
self._add_metadata_form()
|
||||
@@ -285,7 +296,7 @@ class ScanControl(BECWidget, QWidget):
|
||||
self._update_run_button_state()
|
||||
self._update_selected_scan_tooltip()
|
||||
if self.comboBox_scan_selection.currentText() != current_scan:
|
||||
self.on_scan_selection_changed(self.comboBox_scan_selection.currentIndex())
|
||||
self.on_scan_selection_changed(self.comboBox_scan_selection.currentText())
|
||||
|
||||
def _update_run_button_state(self) -> None:
|
||||
"""Start requires a selected scan and valid metadata."""
|
||||
@@ -369,12 +380,66 @@ class ScanControl(BECWidget, QWidget):
|
||||
self._hide_scan_selector_settings_button = bool(hide)
|
||||
self.scan_selector_settings_button.setVisible(not self._hide_scan_selector_settings_button)
|
||||
|
||||
def on_scan_selection_changed(self, index: int):
|
||||
"""Callback for scan selection combo box"""
|
||||
selected_scan_name = self.comboBox_scan_selection.currentText()
|
||||
def on_scan_selection_changed(self, scan_name: str):
|
||||
"""Callback for the scan selection combo box.
|
||||
|
||||
The combo box is editable, so the text changes with every keystroke. Only a name
|
||||
that matches one of the listed scans switches the widget to that scan; any other
|
||||
text is flagged as invalid input and reverted once editing is finished.
|
||||
|
||||
Args:
|
||||
scan_name(str): Current text of the scan selection combo box.
|
||||
"""
|
||||
if not self.is_valid_scan(scan_name):
|
||||
self._update_validity_style(False)
|
||||
return
|
||||
self._update_validity_style(True)
|
||||
if scan_name == self._selected_scan:
|
||||
return
|
||||
|
||||
if self._selected_scan is not None:
|
||||
# Store the parameters of the scan we are leaving before its boxes are removed.
|
||||
self._save_scan_parameters(self._selected_scan)
|
||||
self._selected_scan = scan_name
|
||||
# Selecting by typing only changes the text; keep the current index in sync.
|
||||
self.comboBox_scan_selection.setCurrentIndex(
|
||||
self.comboBox_scan_selection.findText(scan_name)
|
||||
)
|
||||
self._update_selected_scan_tooltip()
|
||||
self.scan_selected.emit(selected_scan_name)
|
||||
self.restore_scan_parameters(selected_scan_name)
|
||||
self.scan_selected.emit(scan_name)
|
||||
self.restore_scan_parameters(scan_name)
|
||||
|
||||
@SafeSlot()
|
||||
def validate_scan_selection(self):
|
||||
"""
|
||||
Resolve the typed text to a valid scan once the user finished editing.
|
||||
|
||||
A name that differs only in case is completed to the listed scan, anything else
|
||||
falls back to the last valid selection.
|
||||
"""
|
||||
scan_name = self.comboBox_scan_selection.currentText()
|
||||
if self.is_valid_scan(scan_name):
|
||||
return
|
||||
index = self.comboBox_scan_selection.findText(scan_name, Qt.MatchFixedString)
|
||||
if index >= 0:
|
||||
self.comboBox_scan_selection.setCurrentIndex(index)
|
||||
return
|
||||
self.comboBox_scan_selection.setCurrentText(self._selected_scan or "")
|
||||
|
||||
def is_valid_scan(self, scan_name: str) -> bool:
|
||||
"""Returns True if the given name is one of the scans listed in the combo box.
|
||||
|
||||
Args:
|
||||
scan_name(str): Name of the scan to check.
|
||||
"""
|
||||
return bool(scan_name) and self.comboBox_scan_selection.findText(scan_name) >= 0
|
||||
|
||||
def _update_validity_style(self, is_valid: bool):
|
||||
"""Highlights the scan selection combo box while it holds an unknown scan name."""
|
||||
if is_valid:
|
||||
self.comboBox_scan_selection.setStyleSheet("")
|
||||
return
|
||||
self.comboBox_scan_selection.setStyleSheet("QComboBox { border: 1px solid red; }")
|
||||
|
||||
@SafeSlot()
|
||||
@SafeSlot(bool)
|
||||
@@ -581,7 +646,7 @@ class ScanControl(BECWidget, QWidget):
|
||||
Args:
|
||||
scan_name(str): Name of the scan to set as current.
|
||||
"""
|
||||
if scan_name not in self.available_scans:
|
||||
if not self.is_valid_scan(scan_name):
|
||||
return
|
||||
self.comboBox_scan_selection.setCurrentText(scan_name)
|
||||
|
||||
@@ -857,7 +922,14 @@ class ScanControl(BECWidget, QWidget):
|
||||
|
||||
def save_current_scan_parameters(self):
|
||||
"""Saves the current scan parameters to the scan control config for further use."""
|
||||
scan_name = self.comboBox_scan_selection.currentText()
|
||||
self._save_scan_parameters(self.comboBox_scan_selection.currentText())
|
||||
|
||||
def _save_scan_parameters(self, scan_name: str):
|
||||
"""Saves the parameters currently shown in the group boxes under the given scan name.
|
||||
|
||||
Args:
|
||||
scan_name(str): Name of the scan the shown parameters belong to.
|
||||
"""
|
||||
self.previous_scan = scan_name
|
||||
args, kwargs = self.get_scan_parameters(False)
|
||||
scan_params = ScanParameterConfig(name=scan_name, args=args, kwargs=kwargs)
|
||||
@@ -872,6 +944,8 @@ class ScanControl(BECWidget, QWidget):
|
||||
@SafeSlot(popup_error=True)
|
||||
def run_scan(self):
|
||||
"""Starts the selected scan with the given parameters."""
|
||||
# The scan name may still be edited when the run button is clicked.
|
||||
self.validate_scan_selection()
|
||||
scan_name = self.comboBox_scan_selection.currentText()
|
||||
if not scan_name:
|
||||
return
|
||||
|
||||
@@ -8,7 +8,7 @@ from bec_lib.endpoints import MessageEndpoints
|
||||
from bec_lib.messages import AvailableResourceMessage, ScanHistoryMessage
|
||||
from bec_lib.scan_history import ScanHistory
|
||||
from qtpy.QtCore import QModelIndex, QPoint, Qt
|
||||
from qtpy.QtWidgets import QCheckBox, QDialog, QStyle
|
||||
from qtpy.QtWidgets import QCheckBox, QComboBox, QDialog, QStyle
|
||||
|
||||
from bec_widgets.utils.forms_from_types.items import StrFormItem
|
||||
from bec_widgets.utils.widget_io import WidgetIO
|
||||
@@ -944,6 +944,90 @@ def test_current_scan(scan_control, mocked_client):
|
||||
assert scan_control.current_scan == new_scan
|
||||
|
||||
|
||||
def test_scan_selection_is_editable_with_completer(scan_control):
|
||||
combo = scan_control.comboBox_scan_selection
|
||||
completer = combo.completer()
|
||||
|
||||
assert combo.isEditable()
|
||||
assert combo.insertPolicy() == QComboBox.NoInsert
|
||||
assert completer.caseSensitivity() == Qt.CaseInsensitive
|
||||
assert completer.filterMode() == Qt.MatchContains
|
||||
|
||||
# the completer proposes the listed scans, matching anywhere in the name
|
||||
completer.setCompletionPrefix("scan")
|
||||
completion_model = completer.completionModel()
|
||||
completions = {
|
||||
completion_model.index(row, 0).data() for row in range(completion_model.rowCount())
|
||||
}
|
||||
assert completions == {"line_scan", "grid_scan"}
|
||||
|
||||
|
||||
def test_typing_scan_name_selects_scan(scan_control, qtbot):
|
||||
combo = scan_control.comboBox_scan_selection
|
||||
assert scan_control.current_scan == "line_scan"
|
||||
|
||||
with qtbot.waitSignal(scan_control.scan_selected) as blocker:
|
||||
combo.setEditText("grid_scan")
|
||||
|
||||
assert blocker.args == ["grid_scan"]
|
||||
assert scan_control.current_scan == "grid_scan"
|
||||
assert combo.currentIndex() == combo.findText("grid_scan")
|
||||
assert combo.styleSheet() == ""
|
||||
|
||||
|
||||
def test_typing_unknown_scan_name_is_rejected(scan_control, qtbot):
|
||||
combo = scan_control.comboBox_scan_selection
|
||||
|
||||
combo.setEditText("grid")
|
||||
|
||||
# an incomplete name does not switch the scan, it is only flagged while editing
|
||||
assert scan_control._metadata_form._scan_name == "line_scan"
|
||||
assert "red" in combo.styleSheet()
|
||||
|
||||
qtbot.keyClick(combo.lineEdit(), Qt.Key_Return)
|
||||
|
||||
assert combo.currentText() == "line_scan"
|
||||
assert combo.styleSheet() == ""
|
||||
|
||||
|
||||
def test_typing_scan_name_with_different_case_is_completed(scan_control, qtbot):
|
||||
combo = scan_control.comboBox_scan_selection
|
||||
|
||||
combo.setEditText("GRID_SCAN")
|
||||
qtbot.keyClick(combo.lineEdit(), Qt.Key_Return)
|
||||
|
||||
assert combo.currentText() == "grid_scan"
|
||||
assert scan_control._metadata_form._scan_name == "grid_scan"
|
||||
|
||||
|
||||
def test_typing_scan_name_stores_previous_scan_parameters(scan_control):
|
||||
for kwarg_box in scan_control.kwarg_boxes:
|
||||
for widget in kwarg_box.widgets:
|
||||
if widget.arg_name == "exp_time":
|
||||
WidgetIO.set_value(widget, 3.0)
|
||||
|
||||
scan_control.comboBox_scan_selection.setEditText("grid_scan")
|
||||
|
||||
assert scan_control.previous_scan == "line_scan"
|
||||
assert scan_control.config.scans["line_scan"].kwargs["exp_time"] == 3.0
|
||||
|
||||
|
||||
def test_run_scan_discards_unfinished_scan_name(scan_control):
|
||||
combo = scan_control.comboBox_scan_selection
|
||||
combo.setEditText("grid_scan")
|
||||
combo.setEditText("grid_sca")
|
||||
|
||||
scans = SimpleNamespace(grid_scan=MagicMock())
|
||||
with (
|
||||
patch.object(scan_control, "scans", scans),
|
||||
patch.object(scan_control, "get_scan_parameters", lambda: ((), {})),
|
||||
):
|
||||
scan_control.run_scan()
|
||||
|
||||
assert combo.currentText() == "grid_scan"
|
||||
scans.grid_scan.assert_called_once_with()
|
||||
|
||||
|
||||
def test_scan_switch_runs_cleanup_on_previous_inputs(scan_control):
|
||||
"""Switching scans tears down the old group boxes; the BECWidget inputs inside
|
||||
(device comboboxes) must go through close() so their cleanup runs, instead of
|
||||
|
||||
Reference in New Issue
Block a user