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 = {
"signals": {
"readback": {"kind_str": "hinted"}, # hinted
"setpoint": {"kind_str": "normal"}, # normal
"velocity": {"kind_str": "config"}, # config
"readback": {
"kind_str": "hinted",
"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 = {

View File

@ -8,6 +8,8 @@ from bec_lib.logger import bec_logger
from qtpy.QtCore import QStringListModel
from qtpy.QtWidgets import QComboBox, QCompleter, QLineEdit
from bec_widgets.utils.ophyd_kind_util import Kind
logger = bec_logger.logger
@ -36,6 +38,23 @@ class WidgetFilterHandler(ABC):
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):
"""Handler for QLineEdit widget"""
@ -69,6 +88,27 @@ class LineEditFilterHandler(WidgetFilterHandler):
model_data = [model.data(model.index(i)) for i in range(model.rowCount())]
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):
"""Handler for QComboBox widget"""
@ -102,6 +142,40 @@ class ComboBoxFilterHandler(WidgetFilterHandler):
"""
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:
"""Public interface to set filters for input widgets.
@ -152,6 +226,35 @@ class FilterIO:
)
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
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.bec_widget import BECWidget
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.widget_io import WidgetIO
@ -108,25 +108,32 @@ class DeviceSignalInputBase(BECWidget):
if not self.validate_device(self._device):
self._device = None
self.config.device = self._device
return
device = self.get_device_object(self._device)
# See above convention for Signals and ComputedSignals
if isinstance(device, Signal):
self._signals = [self._device]
self._hinted_signals = [self._device]
self._signals = []
self._hinted_signals = []
self._normal_signals = []
self._config_signals = []
FilterIO.set_selection(widget=self, selection=self._signals)
return
device = self.get_device_object(self._device)
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):
return [
signal
for signal, signal_info in device_info.items()
if kind in self.signal_filter
and (signal_info.get("kind_str", None) == str(kind.name))
]
return FilterIO.update_with_kind(
widget=self,
kind=kind,
signal_filter=self.signal_filter,
device_info=device_info,
device_name=self._device,
)
self._hinted_signals = _update(Kind.hinted)
self._normal_signals = _update(Kind.normal)
@ -271,7 +278,10 @@ class DeviceSignalInputBase(BECWidget):
Args:
signal(str): Signal to validate.
"""
if signal in self.signals:
for entry in self.signals:
if isinstance(entry, tuple):
entry = entry[0]
if entry == signal:
return True
return False

View File

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

View File

@ -102,7 +102,7 @@ class CurveSetting(SettingWidget):
self.layout.addWidget(self.y_axis_box)
@SafeSlot()
@SafeSlot(popup_error=True)
def accept_changes(self):
"""
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_qthemes._icon.material_icons import material_icon
from qtpy.QtGui import QColor
from qtpy.QtCore import Qt
from qtpy.QtWidgets import (
QColorDialog,
QComboBox,
QHBoxLayout,
QHeaderView,
QLabel,
QLineEdit,
QPushButton,
QSizePolicy,
QSpinBox,
@ -27,9 +26,8 @@ from bec_widgets.utils import ConnectionConfig, EntryValidator
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.colors import Colors
from bec_widgets.utils.toolbar import MaterialIconAction, ModularToolBar
from bec_widgets.widgets.control.device_input.device_line_edit.device_line_edit import (
DeviceLineEdit,
)
from bec_widgets.widgets.control.device_input.device_combobox.device_combobox import DeviceComboBox
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.plots.waveform.curve import CurveConfig, DeviceSignal
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."""
if self.source == "device":
# Device row: columns 1..2 are device line edits
self.device_edit = DeviceLineEdit(parent=self.tree)
self.entry_edit = QLineEdit(parent=self.tree) # TODO in future will be signal line edit
self.device_edit = DeviceComboBox(parent=self.tree)
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:
self.device_edit.setText(self.config.signal.name or "")
self.entry_edit.setText(self.config.signal.entry or "")
device_index = self.device_edit.findText(self.config.signal.name 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, 2, self.entry_edit)
@ -268,13 +295,22 @@ class CurveRow(QTreeWidgetItem):
# Gather device name/entry
device_name = ""
device_entry = ""
## TODO: Move this to itemData
if hasattr(self, "device_edit"):
device_name = self.device_edit.text()
device_name = self.device_edit.currentText()
if hasattr(self, "entry_edit"):
device_entry = self.entry_edit.currentText()
index = self.entry_edit.findText(device_entry)
if index > -1:
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=self.entry_edit.text()
name=device_name, entry=device_entry
)
self.entry_edit.setText(device_entry)
self.config.signal = DeviceSignal(name=device_name, entry=device_entry)
self.config.source = "device"
self.config.label = f"{device_name}-{device_entry}"
@ -390,13 +426,20 @@ class CurveTree(BECWidget, QWidget):
self.tree = QTreeWidget()
self.tree.setColumnCount(7)
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(1, 100)
self.tree.setColumnWidth(2, 100)
self.tree.setColumnWidth(3, 70)
self.tree.setColumnWidth(4, 80)
self.tree.setColumnWidth(5, 40)
self.tree.setColumnWidth(6, 40)
self.tree.setColumnWidth(5, 50)
self.tree.setColumnWidth(6, 50)
self.layout.addWidget(self.tree)
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
import random
from typing import TYPE_CHECKING, Any
from typing import TYPE_CHECKING
import numpy as np
import pytest
from bec_widgets.cli.client import BECDockArea
from bec_widgets.cli.rpc.rpc_base import RPCBase, RPCReference
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
bec = gui._client
# Create dock_area, dock, widget
dock, widget = create_widget(qtbot, gui, gui.available_widgets.SignalComboBox)
dock: client.BECDock
_, widget = create_widget(qtbot, gui, gui.available_widgets.SignalComboBox)
widget: client.SignalComboBox
widget.set_device("samx")
info = bec.device_manager.devices.samx._info["signals"]
assert widget.signals == [
"readback",
"setpoint",
"motor_is_moving",
"velocity",
"acceleration",
"tolerance",
["samx (readback)", info.get("readback")],
["setpoint", info.get("setpoint")],
["motor_is_moving", info.get("motor_is_moving")],
["velocity", info.get("velocity")],
["acceleration", info.get("acceleration")],
["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
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}
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):
"""Test the signal_combobox"""
container = []
@ -120,17 +108,25 @@ def test_signal_combobox(qtbot, device_signal_combobox):
device_signal_combobox.include_config_signals = True
assert device_signal_combobox.signals == []
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)
assert container == ["samx"]
assert container == ["samx (readback)"]
# 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.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._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):