From 33ea484da615b7aed33dd3b63cb67cbf3c841e0f Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Fri, 31 Jul 2026 15:33:41 +0200 Subject: [PATCH] feat(scan_control): make the scan selection combobox editable Co-Authored-By: Claude Opus 5 --- .../control/scan_control/scan_control.py | 96 ++++++++++++++++--- tests/unit_tests/test_scan_control.py | 86 ++++++++++++++++- 2 files changed, 170 insertions(+), 12 deletions(-) diff --git a/bec_widgets/widgets/control/scan_control/scan_control.py b/bec_widgets/widgets/control/scan_control/scan_control.py index 9cb37984..bb6d69d2 100644 --- a/bec_widgets/widgets/control/scan_control/scan_control.py +++ b/bec_widgets/widgets/control/scan_control/scan_control.py @@ -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 diff --git a/tests/unit_tests/test_scan_control.py b/tests/unit_tests/test_scan_control.py index 707b4968..037a623e 100644 --- a/tests/unit_tests/test_scan_control.py +++ b/tests/unit_tests/test_scan_control.py @@ -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