0
0
mirror of https://github.com/bec-project/bec_widgets.git synced 2025-07-13 11:11:49 +02:00

feat(curve settings): add combobox selection for device and signal

This commit is contained in:
2025-06-20 14:17:52 +02:00
committed by Klaus Wakonig
parent a9708f6d8f
commit eea5f7ebbd
8 changed files with 230 additions and 71 deletions

View File

@ -96,9 +96,21 @@ class FakePositioner(BECPositioner):
} }
self._info = { self._info = {
"signals": { "signals": {
"readback": {"kind_str": "hinted"}, # hinted "readback": {
"setpoint": {"kind_str": "normal"}, # normal "kind_str": "hinted",
"velocity": {"kind_str": "config"}, # config "component_name": "readback",
"obj_name": self.name,
}, # hinted
"setpoint": {
"kind_str": "normal",
"component_name": "setpoint",
"obj_name": f"{self.name}_setpoint",
}, # normal
"velocity": {
"kind_str": "config",
"component_name": "velocity",
"obj_name": f"{self.name}_velocity",
}, # config
} }
} }
self.signals = { self.signals = {

View File

@ -8,6 +8,8 @@ from bec_lib.logger import bec_logger
from qtpy.QtCore import QStringListModel from qtpy.QtCore import QStringListModel
from qtpy.QtWidgets import QComboBox, QCompleter, QLineEdit from qtpy.QtWidgets import QComboBox, QCompleter, QLineEdit
from bec_widgets.utils.ophyd_kind_util import Kind
logger = bec_logger.logger logger = bec_logger.logger
@ -36,6 +38,23 @@ class WidgetFilterHandler(ABC):
bool: True if the input text is in the filtered selection bool: True if the input text is in the filtered selection
""" """
@abstractmethod
def update_with_kind(
self, kind: Kind, signal_filter: set, device_info: dict, device_name: str
) -> list[str | tuple]:
"""Update the selection based on the kind of signal.
Args:
kind (Kind): The kind of signal to filter.
signal_filter (set): Set of signal kinds to filter.
device_info (dict): Dictionary containing device information.
device_name (str): Name of the device.
Returns:
list[str | tuple]: A list of filtered signals based on the kind.
"""
# This method should be implemented in subclasses or extended as needed
class LineEditFilterHandler(WidgetFilterHandler): class LineEditFilterHandler(WidgetFilterHandler):
"""Handler for QLineEdit widget""" """Handler for QLineEdit widget"""
@ -69,6 +88,27 @@ class LineEditFilterHandler(WidgetFilterHandler):
model_data = [model.data(model.index(i)) for i in range(model.rowCount())] model_data = [model.data(model.index(i)) for i in range(model.rowCount())]
return text in model_data return text in model_data
def update_with_kind(
self, kind: Kind, signal_filter: set, device_info: dict, device_name: str
) -> list[str | tuple]:
"""Update the selection based on the kind of signal.
Args:
kind (Kind): The kind of signal to filter.
signal_filter (set): Set of signal kinds to filter.
device_info (dict): Dictionary containing device information.
device_name (str): Name of the device.
Returns:
list[str | tuple]: A list of filtered signals based on the kind.
"""
return [
signal
for signal, signal_info in device_info.items()
if kind in signal_filter and (signal_info.get("kind_str", None) == str(kind.name))
]
class ComboBoxFilterHandler(WidgetFilterHandler): class ComboBoxFilterHandler(WidgetFilterHandler):
"""Handler for QComboBox widget""" """Handler for QComboBox widget"""
@ -102,6 +142,40 @@ class ComboBoxFilterHandler(WidgetFilterHandler):
""" """
return text in [widget.itemText(i) for i in range(widget.count())] return text in [widget.itemText(i) for i in range(widget.count())]
def update_with_kind(
self, kind: Kind, signal_filter: set, device_info: dict, device_name: str
) -> list[str | tuple]:
"""Update the selection based on the kind of signal.
Args:
kind (Kind): The kind of signal to filter.
signal_filter (set): Set of signal kinds to filter.
device_info (dict): Dictionary containing device information.
device_name (str): Name of the device.
Returns:
list[str | tuple]: A list of filtered signals based on the kind.
"""
out = []
for signal, signal_info in device_info.items():
if kind not in signal_filter or (signal_info.get("kind_str", None) != str(kind.name)):
continue
obj_name = signal_info.get("obj_name", "")
component_name = signal_info.get("component_name", "")
signal_wo_device = obj_name.removeprefix(f"{device_name}_")
if not signal_wo_device:
signal_wo_device = obj_name
if signal_wo_device != signal and component_name.replace(".", "_") != signal_wo_device:
# If the object name is not the same as the signal name, we use the object name
# to display in the combobox.
out.append((f"{signal_wo_device} ({signal})", signal_info))
else:
# If the object name is the same as the signal name, we do not change it.
out.append((signal, signal_info))
return out
class FilterIO: class FilterIO:
"""Public interface to set filters for input widgets. """Public interface to set filters for input widgets.
@ -152,6 +226,35 @@ class FilterIO:
) )
return None return None
@staticmethod
def update_with_kind(
widget, kind: Kind, signal_filter: set, device_info: dict, device_name: str
) -> list[str | tuple]:
"""
Update the selection based on the kind of signal.
Args:
widget: Widget instance.
kind (Kind): The kind of signal to filter.
signal_filter (set): Set of signal kinds to filter.
device_info (dict): Dictionary containing device information.
device_name (str): Name of the device.
Returns:
list[str | tuple]: A list of filtered signals based on the kind.
"""
handler_class = FilterIO._find_handler(widget)
if handler_class:
return handler_class().update_with_kind(
kind=kind,
signal_filter=signal_filter,
device_info=device_info,
device_name=device_name,
)
raise ValueError(
f"No matching handler for widget type: {type(widget)} in handler list {FilterIO._handlers}"
)
@staticmethod @staticmethod
def _find_handler(widget): def _find_handler(widget):
""" """

View File

@ -6,7 +6,7 @@ from qtpy.QtCore import Property
from bec_widgets.utils import ConnectionConfig from bec_widgets.utils import ConnectionConfig
from bec_widgets.utils.bec_widget import BECWidget from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.error_popups import SafeSlot from bec_widgets.utils.error_popups import SafeSlot
from bec_widgets.utils.filter_io import FilterIO from bec_widgets.utils.filter_io import FilterIO, LineEditFilterHandler
from bec_widgets.utils.ophyd_kind_util import Kind from bec_widgets.utils.ophyd_kind_util import Kind
from bec_widgets.utils.widget_io import WidgetIO from bec_widgets.utils.widget_io import WidgetIO
@ -108,25 +108,32 @@ class DeviceSignalInputBase(BECWidget):
if not self.validate_device(self._device): if not self.validate_device(self._device):
self._device = None self._device = None
self.config.device = self._device self.config.device = self._device
return self._signals = []
device = self.get_device_object(self._device) self._hinted_signals = []
# See above convention for Signals and ComputedSignals
if isinstance(device, Signal):
self._signals = [self._device]
self._hinted_signals = [self._device]
self._normal_signals = [] self._normal_signals = []
self._config_signals = [] self._config_signals = []
FilterIO.set_selection(widget=self, selection=self._signals) FilterIO.set_selection(widget=self, selection=self._signals)
return return
device = self.get_device_object(self._device)
device_info = device._info.get("signals", {}) device_info = device._info.get("signals", {})
# See above convention for Signals and ComputedSignals
if isinstance(device, Signal):
self._signals = [(self._device, {})]
self._hinted_signals = [(self._device, {})]
self._normal_signals = []
self._config_signals = []
FilterIO.set_selection(widget=self, selection=self._signals)
return
def _update(kind: Kind): def _update(kind: Kind):
return [ return FilterIO.update_with_kind(
signal widget=self,
for signal, signal_info in device_info.items() kind=kind,
if kind in self.signal_filter signal_filter=self.signal_filter,
and (signal_info.get("kind_str", None) == str(kind.name)) device_info=device_info,
] device_name=self._device,
)
self._hinted_signals = _update(Kind.hinted) self._hinted_signals = _update(Kind.hinted)
self._normal_signals = _update(Kind.normal) self._normal_signals = _update(Kind.normal)
@ -271,8 +278,11 @@ class DeviceSignalInputBase(BECWidget):
Args: Args:
signal(str): Signal to validate. signal(str): Signal to validate.
""" """
if signal in self.signals: for entry in self.signals:
return True if isinstance(entry, tuple):
entry = entry[0]
if entry == signal:
return True
return False return False
def _process_config_input(self, config: DeviceSignalInputBaseConfig | dict | None): def _process_config_input(self, config: DeviceSignalInputBaseConfig | dict | None):

View File

@ -110,11 +110,7 @@ class SignalComboBox(DeviceSignalInputBase, QComboBox):
return return
if self.validate_signal(text) is False: if self.validate_signal(text) is False:
return return
if text == "readback" and isinstance(self.get_device_object(self.device), Positioner): self.device_signal_changed.emit(text)
device_signal = self.device
else:
device_signal = f"{self.device}_{text}"
self.device_signal_changed.emit(device_signal)
if __name__ == "__main__": # pragma: no cover if __name__ == "__main__": # pragma: no cover

View File

@ -102,7 +102,7 @@ class CurveSetting(SettingWidget):
self.layout.addWidget(self.y_axis_box) self.layout.addWidget(self.y_axis_box)
@SafeSlot() @SafeSlot(popup_error=True)
def accept_changes(self): def accept_changes(self):
""" """
Accepts the changes made in the settings widget and applies them to the target widget. Accepts the changes made in the settings widget and applies them to the target widget.

View File

@ -5,13 +5,12 @@ from typing import TYPE_CHECKING
from bec_lib.logger import bec_logger from bec_lib.logger import bec_logger
from bec_qthemes._icon.material_icons import material_icon from bec_qthemes._icon.material_icons import material_icon
from qtpy.QtGui import QColor from qtpy.QtCore import Qt
from qtpy.QtWidgets import ( from qtpy.QtWidgets import (
QColorDialog,
QComboBox, QComboBox,
QHBoxLayout, QHBoxLayout,
QHeaderView,
QLabel, QLabel,
QLineEdit,
QPushButton, QPushButton,
QSizePolicy, QSizePolicy,
QSpinBox, QSpinBox,
@ -27,9 +26,8 @@ from bec_widgets.utils import ConnectionConfig, EntryValidator
from bec_widgets.utils.bec_widget import BECWidget from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.colors import Colors from bec_widgets.utils.colors import Colors
from bec_widgets.utils.toolbar import MaterialIconAction, ModularToolBar from bec_widgets.utils.toolbar import MaterialIconAction, ModularToolBar
from bec_widgets.widgets.control.device_input.device_line_edit.device_line_edit import ( from bec_widgets.widgets.control.device_input.device_combobox.device_combobox import DeviceComboBox
DeviceLineEdit, from bec_widgets.widgets.control.device_input.signal_combobox.signal_combobox import SignalComboBox
)
from bec_widgets.widgets.dap.dap_combo_box.dap_combo_box import DapComboBox from bec_widgets.widgets.dap.dap_combo_box.dap_combo_box import DapComboBox
from bec_widgets.widgets.plots.waveform.curve import CurveConfig, DeviceSignal from bec_widgets.widgets.plots.waveform.curve import CurveConfig, DeviceSignal
from bec_widgets.widgets.utility.visual.color_button_native.color_button_native import ( from bec_widgets.widgets.utility.visual.color_button_native.color_button_native import (
@ -125,11 +123,40 @@ class CurveRow(QTreeWidgetItem):
"""Create columns 1 and 2. For device rows, we have device/entry edits; for dap rows, label/model combo.""" """Create columns 1 and 2. For device rows, we have device/entry edits; for dap rows, label/model combo."""
if self.source == "device": if self.source == "device":
# Device row: columns 1..2 are device line edits # Device row: columns 1..2 are device line edits
self.device_edit = DeviceLineEdit(parent=self.tree) self.device_edit = DeviceComboBox(parent=self.tree)
self.entry_edit = QLineEdit(parent=self.tree) # TODO in future will be signal line edit self.device_edit.insertItem(0, "")
self.device_edit.setEditable(True)
self.entry_edit = SignalComboBox(parent=self.tree)
self.entry_edit.include_config_signals = False
self.entry_edit.insertItem(0, "")
self.entry_edit.setEditable(True)
self.device_edit.currentTextChanged.connect(self.entry_edit.set_device)
self.device_edit.device_reset.connect(self.entry_edit.reset_selection)
if self.config.signal: if self.config.signal:
self.device_edit.setText(self.config.signal.name or "") device_index = self.device_edit.findText(self.config.signal.name or "")
self.entry_edit.setText(self.config.signal.entry or "") if device_index >= 0:
self.device_edit.setCurrentIndex(device_index)
# Force the entry_edit to update based on the device name
self.device_edit.currentTextChanged.emit(self.device_edit.currentText())
else:
# If the device name is not found, set the first enabled item
self.device_edit.setCurrentIndex(0)
for i in range(self.entry_edit.count()):
entry_data = self.entry_edit.itemData(i)
if entry_data and entry_data.get("obj_name") == self.config.signal.entry:
# If the device name matches an object name, set it
self.entry_edit.setCurrentIndex(i)
break
else:
# If no match found, set the first enabled item
for i in range(self.entry_edit.count()):
model = self.entry_edit.model()
if model.flags(model.index(i, 0)) & Qt.ItemIsEnabled:
self.entry_edit.setCurrentIndex(i)
break
else:
self.entry_edit.setCurrentIndex(0)
self.tree.setItemWidget(self, 1, self.device_edit) self.tree.setItemWidget(self, 1, self.device_edit)
self.tree.setItemWidget(self, 2, self.entry_edit) self.tree.setItemWidget(self, 2, self.entry_edit)
@ -268,13 +295,22 @@ class CurveRow(QTreeWidgetItem):
# Gather device name/entry # Gather device name/entry
device_name = "" device_name = ""
device_entry = "" device_entry = ""
## TODO: Move this to itemData
if hasattr(self, "device_edit"): if hasattr(self, "device_edit"):
device_name = self.device_edit.text() device_name = self.device_edit.currentText()
if hasattr(self, "entry_edit"): if hasattr(self, "entry_edit"):
device_entry = self.entry_validator.validate_signal( device_entry = self.entry_edit.currentText()
name=device_name, entry=self.entry_edit.text() index = self.entry_edit.findText(device_entry)
) if index > -1:
self.entry_edit.setText(device_entry) device_entry_info = self.entry_edit.itemData(index)
if device_entry_info:
device_entry = device_entry_info.get("obj_name", device_entry)
else:
device_entry = self.entry_validator.validate_signal(
name=device_name, entry=device_entry
)
self.config.signal = DeviceSignal(name=device_name, entry=device_entry) self.config.signal = DeviceSignal(name=device_name, entry=device_entry)
self.config.source = "device" self.config.source = "device"
self.config.label = f"{device_name}-{device_entry}" self.config.label = f"{device_name}-{device_entry}"
@ -390,13 +426,20 @@ class CurveTree(BECWidget, QWidget):
self.tree = QTreeWidget() self.tree = QTreeWidget()
self.tree.setColumnCount(7) self.tree.setColumnCount(7)
self.tree.setHeaderLabels(["Actions", "Name", "Entry", "Color", "Style", "Width", "Symbol"]) self.tree.setHeaderLabels(["Actions", "Name", "Entry", "Color", "Style", "Width", "Symbol"])
header = self.tree.header()
for idx in range(self.tree.columnCount()):
if idx in (1, 2): # Device name and entry should stretch
header.setSectionResizeMode(idx, QHeaderView.Stretch)
else:
header.setSectionResizeMode(idx, QHeaderView.Fixed)
header.setStretchLastSection(False)
self.tree.setColumnWidth(0, 90) self.tree.setColumnWidth(0, 90)
self.tree.setColumnWidth(1, 100)
self.tree.setColumnWidth(2, 100)
self.tree.setColumnWidth(3, 70) self.tree.setColumnWidth(3, 70)
self.tree.setColumnWidth(4, 80) self.tree.setColumnWidth(4, 80)
self.tree.setColumnWidth(5, 40) self.tree.setColumnWidth(5, 50)
self.tree.setColumnWidth(6, 40) self.tree.setColumnWidth(6, 50)
self.layout.addWidget(self.tree) self.layout.addWidget(self.tree)
def _init_color_buffer(self, size: int): def _init_color_buffer(self, size: int):

View File

@ -12,12 +12,11 @@ may not be created immediately after the rpc call is made.
from __future__ import annotations from __future__ import annotations
import random import random
from typing import TYPE_CHECKING, Any from typing import TYPE_CHECKING
import numpy as np import numpy as np
import pytest import pytest
from bec_widgets.cli.client import BECDockArea
from bec_widgets.cli.rpc.rpc_base import RPCBase, RPCReference from bec_widgets.cli.rpc.rpc_base import RPCBase, RPCReference
PYTEST_TIMEOUT = 50 PYTEST_TIMEOUT = 50
@ -321,20 +320,20 @@ def test_widgets_e2e_signal_combobox(qtbot, connected_client_gui_obj, random_gen
gui = connected_client_gui_obj gui = connected_client_gui_obj
bec = gui._client bec = gui._client
# Create dock_area, dock, widget # Create dock_area, dock, widget
dock, widget = create_widget(qtbot, gui, gui.available_widgets.SignalComboBox) _, widget = create_widget(qtbot, gui, gui.available_widgets.SignalComboBox)
dock: client.BECDock
widget: client.SignalComboBox widget: client.SignalComboBox
widget.set_device("samx") widget.set_device("samx")
info = bec.device_manager.devices.samx._info["signals"]
assert widget.signals == [ assert widget.signals == [
"readback", ["samx (readback)", info.get("readback")],
"setpoint", ["setpoint", info.get("setpoint")],
"motor_is_moving", ["motor_is_moving", info.get("motor_is_moving")],
"velocity", ["velocity", info.get("velocity")],
"acceleration", ["acceleration", info.get("acceleration")],
"tolerance", ["tolerance", info.get("tolerance")],
] ]
widget.set_signal("readback") widget.set_signal("samx (readback)")
# Test removing the widget, or leaving it open for the next test # Test removing the widget, or leaving it open for the next test
maybe_remove_dock_area(qtbot, gui=gui, random_int_gen=random_generator_from_seed) maybe_remove_dock_area(qtbot, gui=gui, random_int_gen=random_generator_from_seed)

View File

@ -94,18 +94,6 @@ def test_device_signal_qproperties(device_signal_base):
assert device_signal_base._signal_filter == {Kind.config, Kind.normal} assert device_signal_base._signal_filter == {Kind.config, Kind.normal}
def test_device_signal_set_device(device_signal_base):
"""Test if the set_device method works correctly"""
device_signal_base.include_hinted_signals = True
device_signal_base.set_device("samx")
assert device_signal_base.device == "samx"
assert device_signal_base.signals == ["readback"]
device_signal_base.include_normal_signals = True
assert device_signal_base.signals == ["readback", "setpoint"]
device_signal_base.include_config_signals = True
assert device_signal_base.signals == ["readback", "setpoint", "velocity"]
def test_signal_combobox(qtbot, device_signal_combobox): def test_signal_combobox(qtbot, device_signal_combobox):
"""Test the signal_combobox""" """Test the signal_combobox"""
container = [] container = []
@ -120,17 +108,25 @@ def test_signal_combobox(qtbot, device_signal_combobox):
device_signal_combobox.include_config_signals = True device_signal_combobox.include_config_signals = True
assert device_signal_combobox.signals == [] assert device_signal_combobox.signals == []
device_signal_combobox.set_device("samx") device_signal_combobox.set_device("samx")
assert device_signal_combobox.signals == ["readback", "setpoint", "velocity"] samx = device_signal_combobox.dev.samx
assert device_signal_combobox.signals == [
("samx (readback)", samx._info["signals"].get("readback")),
("setpoint", samx._info["signals"].get("setpoint")),
("velocity", samx._info["signals"].get("velocity")),
]
qtbot.wait(100) qtbot.wait(100)
assert container == ["samx"] assert container == ["samx (readback)"]
# Set the type of class from the FakeDevice to Signal # Set the type of class from the FakeDevice to Signal
fake_signal = FakeSignal(name="fake_signal") fake_signal = FakeSignal(name="fake_signal", info={"device_info": {"signals": {}}})
device_signal_combobox.client.device_manager.add_devices([fake_signal]) device_signal_combobox.client.device_manager.add_devices([fake_signal])
device_signal_combobox.set_device("fake_signal") device_signal_combobox.set_device("fake_signal")
assert device_signal_combobox.signals == ["fake_signal"] fake_signal = device_signal_combobox.dev.fake_signal
assert device_signal_combobox.signals == [
("fake_signal", fake_signal._info["signals"].get("fake_signal", {}))
]
assert device_signal_combobox._config_signals == [] assert device_signal_combobox._config_signals == []
assert device_signal_combobox._normal_signals == [] assert device_signal_combobox._normal_signals == []
assert device_signal_combobox._hinted_signals == ["fake_signal"] assert device_signal_combobox._hinted_signals == [("fake_signal", {})]
def test_signal_lineedit(device_signal_line_edit): def test_signal_lineedit(device_signal_line_edit):