mirror of
https://github.com/bec-project/bec_widgets.git
synced 2026-07-28 06:03:02 +02:00
feat(utility): add scan index combobox widget
This commit is contained in:
@@ -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"
|
||||
Reference in New Issue
Block a user