diff --git a/bec_widgets/tests/utils.py b/bec_widgets/tests/utils.py index 53f3dbd1..6f4eb7ab 100644 --- a/bec_widgets/tests/utils.py +++ b/bec_widgets/tests/utils.py @@ -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 = { diff --git a/bec_widgets/utils/filter_io.py b/bec_widgets/utils/filter_io.py index b4d16be2..b0f6700d 100644 --- a/bec_widgets/utils/filter_io.py +++ b/bec_widgets/utils/filter_io.py @@ -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): """ diff --git a/bec_widgets/widgets/control/device_input/base_classes/device_signal_input_base.py b/bec_widgets/widgets/control/device_input/base_classes/device_signal_input_base.py index a7c5647d..89424e52 100644 --- a/bec_widgets/widgets/control/device_input/base_classes/device_signal_input_base.py +++ b/bec_widgets/widgets/control/device_input/base_classes/device_signal_input_base.py @@ -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,8 +278,11 @@ class DeviceSignalInputBase(BECWidget): Args: signal(str): Signal to validate. """ - if signal in self.signals: - return True + for entry in self.signals: + if isinstance(entry, tuple): + entry = entry[0] + if entry == signal: + return True return False def _process_config_input(self, config: DeviceSignalInputBaseConfig | dict | None): diff --git a/bec_widgets/widgets/control/device_input/signal_combobox/signal_combobox.py b/bec_widgets/widgets/control/device_input/signal_combobox/signal_combobox.py index 16f1c70f..bf47cf18 100644 --- a/bec_widgets/widgets/control/device_input/signal_combobox/signal_combobox.py +++ b/bec_widgets/widgets/control/device_input/signal_combobox/signal_combobox.py @@ -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 diff --git a/bec_widgets/widgets/plots/waveform/settings/curve_settings/curve_setting.py b/bec_widgets/widgets/plots/waveform/settings/curve_settings/curve_setting.py index 351fc0c1..a1d1e35d 100644 --- a/bec_widgets/widgets/plots/waveform/settings/curve_settings/curve_setting.py +++ b/bec_widgets/widgets/plots/waveform/settings/curve_settings/curve_setting.py @@ -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. diff --git a/bec_widgets/widgets/plots/waveform/settings/curve_settings/curve_tree.py b/bec_widgets/widgets/plots/waveform/settings/curve_settings/curve_tree.py index 107be6db..177f0bcb 100644 --- a/bec_widgets/widgets/plots/waveform/settings/curve_settings/curve_tree.py +++ b/bec_widgets/widgets/plots/waveform/settings/curve_settings/curve_tree.py @@ -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_validator.validate_signal( - name=device_name, entry=self.entry_edit.text() - ) - self.entry_edit.setText(device_entry) + 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=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): diff --git a/tests/end-2-end/user_interaction/test_user_interaction_e2e.py b/tests/end-2-end/user_interaction/test_user_interaction_e2e.py index c2a603ef..7af0244b 100644 --- a/tests/end-2-end/user_interaction/test_user_interaction_e2e.py +++ b/tests/end-2-end/user_interaction/test_user_interaction_e2e.py @@ -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) diff --git a/tests/unit_tests/test_device_signal_input.py b/tests/unit_tests/test_device_signal_input.py index e632c312..e76def1a 100644 --- a/tests/unit_tests/test_device_signal_input.py +++ b/tests/unit_tests/test_device_signal_input.py @@ -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):