diff --git a/bec_widgets/cli/designer_plugins.py b/bec_widgets/cli/designer_plugins.py index 03fa63ad..9c51d995 100644 --- a/bec_widgets/cli/designer_plugins.py +++ b/bec_widgets/cli/designer_plugins.py @@ -91,6 +91,10 @@ designer_plugins = { ), "SBBMonitor": ("bec_widgets.widgets.editors.sbb_monitor.sbb_monitor", "SBBMonitor"), "ScanControl": ("bec_widgets.widgets.control.scan_control.scan_control", "ScanControl"), + "ScanIndexComboBox": ( + "bec_widgets.widgets.utility.scan_index_combobox.scan_index_combobox", + "ScanIndexComboBox", + ), "ScanMetadata": ("bec_widgets.widgets.editors.scan_metadata.scan_metadata", "ScanMetadata"), "ScanProgressBar": ( "bec_widgets.widgets.progress.scan_progressbar.scan_progressbar", @@ -156,6 +160,7 @@ widget_icons = { "RingProgressBar": "track_changes", "SBBMonitor": "train", "ScanControl": "tune", + "ScanIndexComboBox": "history", "ScanMetadata": "list_alt", "ScanProgressBar": "timelapse", "ScatterWaveform": "scatter_plot", diff --git a/bec_widgets/widgets/utility/scan_index_combobox/__init__.py b/bec_widgets/widgets/utility/scan_index_combobox/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/bec_widgets/widgets/utility/scan_index_combobox/register_scan_index_combo_box.py b/bec_widgets/widgets/utility/scan_index_combobox/register_scan_index_combo_box.py new file mode 100644 index 00000000..5262218b --- /dev/null +++ b/bec_widgets/widgets/utility/scan_index_combobox/register_scan_index_combo_box.py @@ -0,0 +1,17 @@ +def main(): # pragma: no cover + from qtpy import PYSIDE6 + + if not PYSIDE6: + print("PYSIDE6 is not available in the environment. Cannot patch designer.") + return + from PySide6.QtDesigner import QPyDesignerCustomWidgetCollection + + from bec_widgets.widgets.utility.scan_index_combobox.scan_index_combo_box_plugin import ( + ScanIndexComboBoxPlugin, + ) + + QPyDesignerCustomWidgetCollection.addCustomWidget(ScanIndexComboBoxPlugin()) + + +if __name__ == "__main__": # pragma: no cover + main() diff --git a/bec_widgets/widgets/utility/scan_index_combobox/scan_index_combo_box.pyproject b/bec_widgets/widgets/utility/scan_index_combobox/scan_index_combo_box.pyproject new file mode 100644 index 00000000..8a974acb --- /dev/null +++ b/bec_widgets/widgets/utility/scan_index_combobox/scan_index_combo_box.pyproject @@ -0,0 +1 @@ +{'files': ['scan_index_combobox.py']} \ No newline at end of file diff --git a/bec_widgets/widgets/utility/scan_index_combobox/scan_index_combo_box_plugin.py b/bec_widgets/widgets/utility/scan_index_combobox/scan_index_combo_box_plugin.py new file mode 100644 index 00000000..752f4cf8 --- /dev/null +++ b/bec_widgets/widgets/utility/scan_index_combobox/scan_index_combo_box_plugin.py @@ -0,0 +1,57 @@ +# Copyright (C) 2022 The Qt Company Ltd. +# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause + +from qtpy.QtDesigner import QDesignerCustomWidgetInterface +from qtpy.QtWidgets import QWidget + +from bec_widgets.utils.bec_designer import designer_material_icon +from bec_widgets.widgets.utility.scan_index_combobox.scan_index_combobox import ScanIndexComboBox + +DOM_XML = """ + + + + +""" + + +class ScanIndexComboBoxPlugin(QDesignerCustomWidgetInterface): # pragma: no cover + def __init__(self): + super().__init__() + self._form_editor = None + + def createWidget(self, parent): + if parent is None: + return QWidget() + t = ScanIndexComboBox(parent) + return t + + def domXml(self): + return DOM_XML + + def group(self): + return "BEC Utils" + + def icon(self): + return designer_material_icon(ScanIndexComboBox.ICON_NAME) + + def includeFile(self): + return "scan_index_combo_box" + + def initialize(self, form_editor): + self._form_editor = form_editor + + def isContainer(self): + return False + + def isInitialized(self): + return self._form_editor is not None + + def name(self): + return "ScanIndexComboBox" + + def toolTip(self): + return "" + + def whatsThis(self): + return self.toolTip() diff --git a/bec_widgets/widgets/utility/scan_index_combobox/scan_index_combobox.py b/bec_widgets/widgets/utility/scan_index_combobox/scan_index_combobox.py new file mode 100644 index 00000000..8a3585ce --- /dev/null +++ b/bec_widgets/widgets/utility/scan_index_combobox/scan_index_combobox.py @@ -0,0 +1,152 @@ +"""Editable combobox for selecting a scan from history or live acquisition.""" + +from __future__ import annotations + +from qtpy.QtCore import Slot +from qtpy.QtWidgets import QComboBox + +from bec_widgets.utils.bec_connector import ConnectionConfig +from bec_widgets.utils.bec_widget import BECWidget +from bec_widgets.utils.error_popups import SafeSlot + +LIVE_ENTRY = "live" + + +class ScanIndexComboBox(BECWidget, QComboBox): + """ + Editable combobox listing 'live' and the scan numbers available in the BEC scan + history. Scan numbers are displayed as text while the corresponding scan IDs are + stored as item data, so a selection maps to either live mode (scan_id is None) + or a historical scan. + + Typing is not restricted: input that matches neither 'live' nor a listed scan + number is flagged with a red border while editing, and reading scan_id or + scan_number for such input raises ValueError. The default editable-combobox + completer autocompletes against the listed entries while typing. + """ + + ICON_NAME = "history" + RPC = False + PLUGIN = True + + def __init__( + self, + parent=None, + client=None, + config: ConnectionConfig | None = None, + gui_id: str | None = None, + **kwargs, + ): + super().__init__(parent=parent, client=client, gui_id=gui_id, config=config, **kwargs) + self._is_valid_input = False + self.setEditable(True) + self.setInsertPolicy(QComboBox.NoInsert) + self.currentTextChanged.connect(self.check_validity) + self.refresh_scan_indices() + + @SafeSlot() + def refresh_scan_indices(self): + """Repopulate the combobox with 'live' and all scan numbers from the scan history.""" + # Unlike the scan_id property, tolerate invalid typed text here: a refresh + # simply falls back to 'live' instead of raising. + index = self.findText(self.currentText()) + current_scan_id = self.itemData(index) if index >= 0 else None + self.clear() + self.addItem(LIVE_ENTRY, None) + + # client.history is None until the client services are started (e.g. in Qt Designer) + history = self.client.history + scan_number_list = history._scan_numbers if history is not None else [] + scan_id_list = history._scan_ids if history is not None else [] + + # Add items: show scan numbers, store scan IDs as item data, + # ordered live -> latest scan -> oldest scan + pairs = sorted(zip(scan_number_list, scan_id_list), key=lambda pair: pair[0], reverse=True) + for num, sid in pairs: + self.addItem(str(num), sid) + + self.set_scan_id(current_scan_id) + # The current text may be unchanged while the item list changed, so no + # currentTextChanged signal is emitted; re-check validity explicitly. + self.check_validity(self.currentText()) + + @property + def is_valid_input(self) -> bool: + """Whether the current text is 'live' or one of the listed scan numbers.""" + return self._is_valid_input + + @property + def scan_id(self) -> str | None: + """The scan ID of the selected scan, or None for 'live'. + + Raises: + ValueError: If the current text is neither 'live' nor a listed scan number. + """ + text = self.currentText() + # Look up by text: typing into the editable combobox does not move the + # current index, so currentData() could return a stale scan ID. + index = self.findText(text) + if index < 0: + raise ValueError( + f"'{text}' is not a valid scan selection; choose '{LIVE_ENTRY}' or one of the " + "listed scan numbers." + ) + return self.itemData(index) + + @property + def scan_number(self) -> int | None: + """The scan number of the selected scan, or None for 'live'. + + Raises: + ValueError: If the current text is neither 'live' nor a listed scan number. + """ + if self.scan_id is None: + return None + return int(self.currentText()) + + @SafeSlot(str) + @SafeSlot() + def set_scan_id(self, scan_id: str | None = None): + """ + Select the scan with the given scan ID, or 'live' if the scan ID is None or not found. + + Args: + scan_id (str | None): The scan ID to select. + """ + if scan_id is not None: + for i in range(self.count()): + if self.itemData(i) == scan_id: + self.setCurrentIndex(i) + return + self.setCurrentText(LIVE_ENTRY) + + @Slot(str) + def check_validity(self, input_text: str) -> None: + """Validate the current text and update the visual state. + + Args: + input_text: Current combobox text. + """ + self._is_valid_input = bool(input_text) and self.findText(input_text) >= 0 + self._update_validity_style(self._is_valid_input) + + def setEnabled(self, enabled: bool) -> None: # noqa: N802 + super().setEnabled(enabled) + self._update_validity_style(self._is_valid_input) + + def _update_validity_style(self, is_valid: bool) -> None: + if is_valid or not self.isEnabled(): + self.setStyleSheet("") + return + self.setStyleSheet("QComboBox { border: 1px solid red; }") + + +if __name__ == "__main__": # pragma: no cover + import sys + + from qtpy.QtWidgets import QApplication + + app = QApplication(sys.argv) + combo = ScanIndexComboBox() + combo.show() + sys.exit(app.exec_()) diff --git a/tests/unit_tests/test_scan_index_combobox.py b/tests/unit_tests/test_scan_index_combobox.py new file mode 100644 index 00000000..49d68fcf --- /dev/null +++ b/tests/unit_tests/test_scan_index_combobox.py @@ -0,0 +1,164 @@ +import pytest +from bec_lib.scan_history import ScanHistory +from qtpy.QtCore import Qt + +from bec_widgets.widgets.utility.scan_index_combobox.scan_index_combobox import ScanIndexComboBox + +# pylint: disable=unused-import +from tests.unit_tests.client_mocks import mocked_client +from tests.unit_tests.conftest import create_widget + + +@pytest.fixture +def scan_index_combo(qtbot, mocked_client, scan_history_factory): + """ScanIndexComboBox with two history scans injected into the client.""" + msgs = [ + scan_history_factory(scan_id="hid1", scan_number=1), + scan_history_factory(scan_id="hid2", scan_number=2), + ] + mocked_client.history = ScanHistory(mocked_client, False) + for msg in msgs: + mocked_client.history._scan_data[msg.scan_id] = msg + mocked_client.history._scan_ids.append(msg.scan_id) + mocked_client.history._scan_numbers.append(msg.scan_number) + combo = create_widget(qtbot, ScanIndexComboBox, client=mocked_client) + return combo + + +def test_scan_index_combobox_populates_history(scan_index_combo): + """The combobox lists 'live' plus all scan numbers (latest first) with scan IDs as item data.""" + assert scan_index_combo.count() == 3 + assert scan_index_combo.itemText(0) == "live" + assert scan_index_combo.itemData(0) is None + assert scan_index_combo.itemText(1) == "2" + assert scan_index_combo.itemData(1) == "hid2" + assert scan_index_combo.itemText(2) == "1" + assert scan_index_combo.itemData(2) == "hid1" + + +def test_scan_index_combobox_scan_id_and_number(scan_index_combo): + """scan_id/scan_number map the selection to live (None) or a history scan.""" + assert scan_index_combo.currentText() == "live" + assert scan_index_combo.scan_id is None + assert scan_index_combo.scan_number is None + + scan_index_combo.setCurrentIndex(1) + assert scan_index_combo.scan_id == "hid2" + assert scan_index_combo.scan_number == 2 + + # Typing a scan number does not move the current index; scan_id must follow the text + scan_index_combo.setCurrentText("live") + scan_index_combo.setCurrentText("1") + assert scan_index_combo.scan_id == "hid1" + assert scan_index_combo.scan_number == 1 + + +def test_scan_index_combobox_set_scan_id(scan_index_combo): + """set_scan_id selects the matching scan or falls back to 'live'.""" + scan_index_combo.set_scan_id("hid1") + assert scan_index_combo.currentText() == "1" + + scan_index_combo.set_scan_id("unknown") + assert scan_index_combo.currentText() == "live" + + scan_index_combo.set_scan_id("hid2") + assert scan_index_combo.currentText() == "2" + scan_index_combo.set_scan_id(None) + assert scan_index_combo.currentText() == "live" + + +def test_scan_index_combobox_refresh_keeps_selection(scan_index_combo): + """refresh_scan_indices repopulates the items without losing the current selection.""" + scan_index_combo.set_scan_id("hid2") + scan_index_combo.refresh_scan_indices() + assert scan_index_combo.count() == 3 + assert scan_index_combo.currentText() == "2" + assert scan_index_combo.scan_id == "hid2" + + +@pytest.mark.parametrize("history", [None, "empty"]) +def test_scan_index_combobox_empty_history(qtbot, mocked_client, history): + """ + With no scan history (client not started) or an empty one, only 'live' is offered + and any typed scan number is flagged as invalid. + """ + mocked_client.history = ScanHistory(mocked_client, False) if history == "empty" else None + combo = create_widget(qtbot, ScanIndexComboBox, client=mocked_client) + + assert combo.count() == 1 + assert combo.currentText() == "live" + assert combo.scan_id is None + assert combo.scan_number is None + assert combo.is_valid_input + + # Typing is not blocked by a validator, but scan numbers are flagged since none exist + assert combo.lineEdit().validator() is None + combo.lineEdit().clear() + qtbot.keyClicks(combo.lineEdit(), "1") + assert combo.currentText() == "1" + assert not combo.is_valid_input + with pytest.raises(ValueError): + _ = combo.scan_id + + +def test_scan_index_combobox_typing_multi_digit_scan(qtbot, mocked_client, scan_history_factory): + """ + Multi-digit scan numbers can be typed even when their prefixes are not scan numbers + themselves, and the completer autocompletes against the listed entries. + """ + msgs = [ + scan_history_factory(scan_id="hid101", scan_number=101), + scan_history_factory(scan_id="hid105", scan_number=105), + ] + mocked_client.history = ScanHistory(mocked_client, False) + for msg in msgs: + mocked_client.history._scan_data[msg.scan_id] = msg + mocked_client.history._scan_ids.append(msg.scan_id) + mocked_client.history._scan_numbers.append(msg.scan_number) + combo = create_widget(qtbot, ScanIndexComboBox, client=mocked_client) + + combo.lineEdit().clear() + qtbot.keyClicks(combo.lineEdit(), "101") + assert combo.currentText() == "101" + assert combo.is_valid_input + assert combo.styleSheet() == "" + assert combo.scan_id == "hid101" + assert combo.scan_number == 101 + + # The default editable-combobox completer offers the listed entries while typing + completer = combo.completer() + assert completer is not None + completer.setCompletionPrefix("l") + assert completer.currentCompletion() == "live" + completer.setCompletionPrefix("10") + assert completer.currentCompletion() == "105" + + +def test_scan_index_combobox_invalid_input_flagged(scan_index_combo, qtbot): + """ + Unknown scan numbers can be typed; they are flagged with a red border, raise on + read-out, and are not inserted as new items when committed. + """ + combo = scan_index_combo + combo.lineEdit().clear() + qtbot.keyClicks(combo.lineEdit(), "99") + assert combo.currentText() == "99" + assert not combo.is_valid_input + assert "red" in combo.styleSheet() + with pytest.raises(ValueError): + _ = combo.scan_id + with pytest.raises(ValueError): + _ = combo.scan_number + + qtbot.keyClick(combo.lineEdit(), Qt.Key_Return) + assert combo.count() == 3 + + # A refresh with invalid text falls back to 'live' instead of raising + combo.refresh_scan_indices() + assert combo.currentText() == "live" + assert combo.is_valid_input + + combo.setCurrentText("2") + assert combo.is_valid_input + assert combo.styleSheet() == "" + assert combo.scan_id == "hid2"