feat(utility): add scan index combobox widget

This commit is contained in:
2026-07-10 15:56:56 +02:00
committed by Jan Wyzula
parent bc11a3b1a3
commit 567b10c137
7 changed files with 396 additions and 0 deletions
+5
View File
@@ -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",
@@ -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()
@@ -0,0 +1 @@
{'files': ['scan_index_combobox.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 = """
<ui language='c++'>
<widget class='ScanIndexComboBox' name='scan_index_combo_box'>
</widget>
</ui>
"""
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()
@@ -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_())
@@ -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"