From 31e15675f7169533df40cba1528cd1829a678cf1 Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Tue, 12 May 2026 16:58:54 +0200 Subject: [PATCH] fix: remove device/signal line edit and abstraction layer for combobox/lineEdit --- bec_widgets/cli/client.py | 24 - bec_widgets/cli/designer_plugins.py | 10 - bec_widgets/utils/filter_io.py | 378 ++--------- bec_widgets/utils/toolbars/actions.py | 6 +- bec_widgets/utils/widget_io.py | 6 +- .../positioner_box/positioner_box_base.py | 10 +- .../device_input/base_classes/__init__.py | 0 .../base_classes/device_input_base.py | 458 ------------- .../base_classes/device_signal_input_base.py | 301 --------- .../device_combobox/device_combobox.py | 525 ++++++++++++--- .../device_input/device_line_edit/__init__.py | 0 .../device_line_edit/device_line_edit.py | 197 ------ .../device_line_edit.pyproject | 1 - .../device_line_edit_plugin.py | 59 -- .../register_device_line_edit.py | 17 - .../signal_combobox/signal_combobox.py | 610 +++++++++++------- .../device_input/signal_line_edit/__init__.py | 0 .../register_signal_line_edit.py | 17 - .../signal_line_edit/signal_line_edit.py | 169 ----- .../signal_line_edit.pyproject | 1 - .../signal_line_edit_plugin.py | 59 -- .../control/scan_control/scan_group_box.py | 26 +- .../toolbar_components/motor_selection.py | 6 +- .../toolbar_components/monitor_selection.py | 6 +- .../scatter_curve_settings_vertical.ui | 12 +- .../settings/curve_settings/curve_tree.py | 2 +- .../utility/signal_label/signal_label.py | 21 +- tests/unit_tests/test_device_input_base.py | 168 ++--- tests/unit_tests/test_device_input_widgets.py | 89 +-- tests/unit_tests/test_device_signal_input.py | 99 +-- tests/unit_tests/test_filter_io.py | 82 +-- tests/unit_tests/test_positioner_box.py | 8 +- tests/unit_tests/test_signal_label.py | 17 +- 33 files changed, 1039 insertions(+), 2345 deletions(-) delete mode 100644 bec_widgets/widgets/control/device_input/base_classes/__init__.py delete mode 100644 bec_widgets/widgets/control/device_input/base_classes/device_input_base.py delete mode 100644 bec_widgets/widgets/control/device_input/base_classes/device_signal_input_base.py delete mode 100644 bec_widgets/widgets/control/device_input/device_line_edit/__init__.py delete mode 100644 bec_widgets/widgets/control/device_input/device_line_edit/device_line_edit.py delete mode 100644 bec_widgets/widgets/control/device_input/device_line_edit/device_line_edit.pyproject delete mode 100644 bec_widgets/widgets/control/device_input/device_line_edit/device_line_edit_plugin.py delete mode 100644 bec_widgets/widgets/control/device_input/device_line_edit/register_device_line_edit.py delete mode 100644 bec_widgets/widgets/control/device_input/signal_line_edit/__init__.py delete mode 100644 bec_widgets/widgets/control/device_input/signal_line_edit/register_signal_line_edit.py delete mode 100644 bec_widgets/widgets/control/device_input/signal_line_edit/signal_line_edit.py delete mode 100644 bec_widgets/widgets/control/device_input/signal_line_edit/signal_line_edit.pyproject delete mode 100644 bec_widgets/widgets/control/device_input/signal_line_edit/signal_line_edit_plugin.py diff --git a/bec_widgets/cli/client.py b/bec_widgets/cli/client.py index f8228669..1f84fa5a 100644 --- a/bec_widgets/cli/client.py +++ b/bec_widgets/cli/client.py @@ -1131,30 +1131,6 @@ class DeviceInitializationProgressBar(RPCBase): """ -class DeviceInputBase(RPCBase): - """Mixin base class for device input widgets.""" - - _IMPORT_MODULE = "bec_widgets.widgets.control.device_input.base_classes.device_input_base" - - @rpc_call - def remove(self): - """ - Cleanup the BECConnector - """ - - @rpc_call - def attach(self): - """ - None - """ - - @rpc_call - def detach(self): - """ - Detach the widget from its parent dock widget (if widget is in the dock), making it a floating widget. - """ - - class DeviceManagerView(RPCBase): """A view for users to manage devices within the application.""" diff --git a/bec_widgets/cli/designer_plugins.py b/bec_widgets/cli/designer_plugins.py index 8ebc0d84..bdbdcb6f 100644 --- a/bec_widgets/cli/designer_plugins.py +++ b/bec_widgets/cli/designer_plugins.py @@ -42,10 +42,6 @@ designer_plugins = { "bec_widgets.widgets.control.device_input.device_combobox.device_combobox", "DeviceComboBox", ), - "DeviceLineEdit": ( - "bec_widgets.widgets.control.device_input.device_line_edit.device_line_edit", - "DeviceLineEdit", - ), "Heatmap": ("bec_widgets.widgets.plots.heatmap.heatmap", "Heatmap"), "IDEExplorer": ("bec_widgets.widgets.utility.ide_explorer.ide_explorer", "IDEExplorer"), "Image": ("bec_widgets.widgets.plots.image.image", "Image"), @@ -101,10 +97,6 @@ designer_plugins = { "SignalComboBox", ), "SignalLabel": ("bec_widgets.widgets.utility.signal_label.signal_label", "SignalLabel"), - "SignalLineEdit": ( - "bec_widgets.widgets.control.device_input.signal_line_edit.signal_line_edit", - "SignalLineEdit", - ), "SpinnerWidget": ("bec_widgets.widgets.utility.spinner.spinner", "SpinnerWidget"), "StopButton": ("bec_widgets.widgets.control.buttons.stop_button.stop_button", "StopButton"), "TextBox": ("bec_widgets.widgets.editors.text_box.text_box", "TextBox"), @@ -134,7 +126,6 @@ widget_icons = { "DarkModeButton": "dark_mode", "DeviceBrowser": "lists", "DeviceComboBox": "list_alt", - "DeviceLineEdit": "edit_note", "Heatmap": "dataset", "IDEExplorer": "widgets", "Image": "image", @@ -160,7 +151,6 @@ widget_icons = { "ScatterWaveform": "scatter_plot", "SignalComboBox": "list_alt", "SignalLabel": "scoreboard", - "SignalLineEdit": "vital_signs", "SpinnerWidget": "progress_activity", "StopButton": "dangerous", "TextBox": "chat", diff --git a/bec_widgets/utils/filter_io.py b/bec_widgets/utils/filter_io.py index 1e3a316e..607e8595 100644 --- a/bec_widgets/utils/filter_io.py +++ b/bec_widgets/utils/filter_io.py @@ -1,12 +1,9 @@ -"""Module for handling filter I/O operations in BEC Widgets for input fields. -These operations include filtering device/signal names and/or device types. -""" +"""Small helpers for populating editable combo boxes used by device inputs.""" -from abc import ABC, abstractmethod +from __future__ import annotations from bec_lib.logger import bec_logger -from qtpy.QtCore import QStringListModel -from qtpy.QtWidgets import QComboBox, QCompleter, QLineEdit +from qtpy.QtWidgets import QComboBox from typeguard import TypeCheckError from bec_widgets.utils.ophyd_kind_util import Kind @@ -14,329 +11,68 @@ from bec_widgets.utils.ophyd_kind_util import Kind logger = bec_logger.logger -class WidgetFilterHandler(ABC): - """Abstract base class for widget filter handlers""" - - @abstractmethod - def set_selection(self, widget, selection: list[str | tuple]) -> None: - """Set the filtered_selection for the widget - - Args: - widget: Widget instance - selection (list[str | tuple]): Filtered selection of items. - If tuple, it contains (text, data) pairs. - """ - - @abstractmethod - def check_input(self, widget, text: str) -> bool: - """Check if the input text is in the filtered selection - - Args: - widget: Widget instance - text (str): Input text - - Returns: - 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 - - def update_with_bec_signal_class( - self, - signal_class_filter: str | list[str], - client, - ndim_filter: int | list[int] | None = None, - ) -> list[tuple[str, str, dict]]: - """Update the selection based on signal classes using device_manager.get_bec_signals. - - Args: - signal_class_filter (str|list[str]): List of signal class names to filter. - client: BEC client instance. - ndim_filter (int | list[int] | None): Filter signals by dimensionality. - If provided, only signals with matching ndim will be included. - - Returns: - list[tuple[str, str, dict]]: A list of (device_name, signal_name, signal_config) tuples. - """ - if not client or not hasattr(client, "device_manager"): - return [] - - try: - signals = client.device_manager.get_bec_signals(signal_class_filter) - except TypeCheckError as e: - logger.warning(f"Error retrieving signals: {e}") - return [] - - if ndim_filter is None: - return signals - - if isinstance(ndim_filter, int): - ndim_filter = [ndim_filter] - - filtered_signals = [] - for device_name, signal_name, signal_config in signals: - ndim = None - if isinstance(signal_config, dict): - ndim = signal_config.get("describe", {}).get("signal_info", {}).get("ndim") - - if ndim in ndim_filter: - filtered_signals.append((device_name, signal_name, signal_config)) - - return filtered_signals +def replace_combobox_items(combo_box: QComboBox, items: list[str | tuple]) -> None: + """Replace all combobox entries with strings or ``(text, data)`` tuples.""" + combo_box.clear() + for item in items: + if isinstance(item, str): + combo_box.addItem(item) + else: + combo_box.addItem(*item) -class LineEditFilterHandler(WidgetFilterHandler): - """Handler for QLineEdit widget""" - - def set_selection(self, widget: QLineEdit, selection: list[str | tuple]) -> None: - """Set the selection for the widget to the completer model - - Args: - widget (QLineEdit): The QLineEdit widget - selection (list[str | tuple]): Filtered selection of items. If tuple, it contains (text, data) pairs. - """ - if isinstance(selection, tuple): - # If selection is a tuple, it contains (text, data) pairs - selection = [text for text, _ in selection] - if not isinstance(widget.completer, QCompleter): - completer = QCompleter(widget) - widget.setCompleter(completer) - widget.completer.setModel(QStringListModel(selection, widget)) - - def check_input(self, widget: QLineEdit, text: str) -> bool: - """Check if the input text is in the filtered selection - - Args: - widget (QLineEdit): The QLineEdit widget - text (str): Input text - - Returns: - bool: True if the input text is in the filtered selection - """ - model = widget.completer.model() - 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)) - ] +def combobox_contains_text(combo_box: QComboBox, text: str) -> bool: + """Return whether *text* is present as visible combobox text.""" + return any(combo_box.itemText(i) == text for i in range(combo_box.count())) -class ComboBoxFilterHandler(WidgetFilterHandler): - """Handler for QComboBox widget""" +def signal_items_for_kind( + *, kind: Kind, signal_filter: set[Kind], device_info: dict, device_name: str +) -> list[tuple[str, dict]]: + """Build display entries for signals matching a BEC signal kind.""" + items: list[tuple[str, dict]] = [] + for signal_name, signal_info in device_info.items(): + if kind not in signal_filter or signal_info.get("kind_str") != kind.name: + continue - def set_selection(self, widget: QComboBox, selection: list[str | tuple]) -> None: - """Set the selection for the widget to the completer model + obj_name = signal_info.get("obj_name", "") + component_name = signal_info.get("component_name", "") + signal_without_device = obj_name.removeprefix(f"{device_name}_") + if not signal_without_device: + signal_without_device = obj_name - Args: - widget (QComboBox): The QComboBox widget - selection (list[str | tuple]): Filtered selection of items. If tuple, it contains (text, data) pairs. - """ - widget.clear() - if len(selection) == 0: - return - for element in selection: - if isinstance(element, str): - widget.addItem(element) - elif isinstance(element, tuple): - # If element is a tuple, it contains (text, data) pairs - widget.addItem(*element) - - def check_input(self, widget: QComboBox, text: str) -> bool: - """Check if the input text is in the filtered selection - - Args: - widget (QComboBox): The QComboBox widget - text (str): Input text - - Returns: - bool: True if the input text is in the filtered selection - """ - 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 + if ( + signal_without_device != signal_name + and component_name.replace(".", "_") != signal_without_device + ): + items.append((f"{signal_without_device} ({signal_name})", signal_info)) + else: + items.append((signal_name, signal_info)) + return items -class FilterIO: - """Public interface to set filters for input widgets. - It supports the list of widgets stored in class attribute _handlers. - """ +def get_bec_signals_for_classes( + *, client, signal_class_filter: str | list[str], ndim_filter: int | list[int] | None = None +) -> list[tuple[str, str, dict]]: + """Return BEC signals filtered by signal class and optional dimensionality.""" + if not client or not hasattr(client, "device_manager"): + return [] - _handlers = {QLineEdit: LineEditFilterHandler, QComboBox: ComboBoxFilterHandler} + try: + signals = client.device_manager.get_bec_signals(signal_class_filter) + except TypeCheckError as exc: + logger.warning(f"Error retrieving signals: {exc}") + return [] - @staticmethod - def set_selection(widget, selection: list[str | tuple], ignore_errors=True): - """ - Retrieve value from the widget instance. + if ndim_filter is None: + return signals - Args: - widget: Widget instance. - selection (list[str | tuple]): Filtered selection of items. - If tuple, it contains (text, data) pairs. - ignore_errors(bool, optional): Whether to ignore if no handler is found. - """ - handler_class = FilterIO._find_handler(widget) - if handler_class: - return handler_class().set_selection(widget=widget, selection=selection) - if not ignore_errors: - raise ValueError( - f"No matching handler for widget type: {type(widget)} in handler list {FilterIO._handlers}" - ) - return None - - @staticmethod - def check_input(widget, text: str, ignore_errors=True): - """ - Check if the input text is in the filtered selection. - - Args: - widget: Widget instance. - text(str): Input text. - ignore_errors(bool, optional): Whether to ignore if no handler is found. - - Returns: - bool: True if the input text is in the filtered selection. - """ - handler_class = FilterIO._find_handler(widget) - if handler_class: - return handler_class().check_input(widget=widget, text=text) - if not ignore_errors: - raise ValueError( - f"No matching handler for widget type: {type(widget)} in handler list {FilterIO._handlers}" - ) - 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 update_with_signal_class( - widget, signal_class_filter: list[str], client, ndim_filter: int | list[int] | None = None - ) -> list[tuple[str, str, dict]]: - """ - Update the selection based on signal classes using device_manager.get_bec_signals. - - Args: - widget: Widget instance. - signal_class_filter (list[str]): List of signal class names to filter. - client: BEC client instance. - ndim_filter (int | list[int] | None): Filter signals by dimensionality. - If provided, only signals with matching ndim will be included. - - Returns: - list[tuple[str, str, dict]]: A list of (device_name, signal_name, signal_config) tuples. - """ - handler_class = FilterIO._find_handler(widget) - if handler_class: - return handler_class().update_with_bec_signal_class( - signal_class_filter=signal_class_filter, client=client, ndim_filter=ndim_filter - ) - raise ValueError( - f"No matching handler for widget type: {type(widget)} in handler list {FilterIO._handlers}" - ) - - @staticmethod - def _find_handler(widget): - """ - Find the appropriate handler for the widget by checking its base classes. - - Args: - widget: Widget instance. - - Returns: - handler_class: The handler class if found, otherwise None. - """ - for base in type(widget).__mro__: - if base in FilterIO._handlers: - return FilterIO._handlers[base] - return None + accepted_ndim = [ndim_filter] if isinstance(ndim_filter, int) else ndim_filter + filtered_signals: list[tuple[str, str, dict]] = [] + for device_name, signal_name, signal_config in signals: + ndim = None + if isinstance(signal_config, dict): + ndim = signal_config.get("describe", {}).get("signal_info", {}).get("ndim") + if ndim in accepted_ndim: + filtered_signals.append((device_name, signal_name, signal_config)) + return filtered_signals diff --git a/bec_widgets/utils/toolbars/actions.py b/bec_widgets/utils/toolbars/actions.py index ce2cdc72..95d5b272 100644 --- a/bec_widgets/utils/toolbars/actions.py +++ b/bec_widgets/utils/toolbars/actions.py @@ -27,8 +27,10 @@ from qtpy.QtWidgets import ( import bec_widgets from bec_widgets.utils.toolbars.splitter import ResizableSpacer -from bec_widgets.widgets.control.device_input.base_classes.device_input_base import BECDeviceFilter -from bec_widgets.widgets.control.device_input.device_combobox.device_combobox import DeviceComboBox +from bec_widgets.widgets.control.device_input.device_combobox.device_combobox import ( + BECDeviceFilter, + DeviceComboBox, +) logger = bec_logger.logger diff --git a/bec_widgets/utils/widget_io.py b/bec_widgets/utils/widget_io.py index aab7f29e..9c27b7af 100644 --- a/bec_widgets/utils/widget_io.py +++ b/bec_widgets/utils/widget_io.py @@ -85,7 +85,11 @@ class ComboBoxHandler(WidgetHandler): def set_value(self, widget: QComboBox, value: int | str) -> None: if isinstance(value, str): - value = widget.findText(value) + index = widget.findText(value) + if index < 0 and widget.isEditable(): + widget.setCurrentText(value) + return + value = index if isinstance(value, int): widget.setCurrentIndex(value) diff --git a/bec_widgets/widgets/control/device_control/positioner_box/positioner_box_base.py b/bec_widgets/widgets/control/device_control/positioner_box/positioner_box_base.py index 03a06f4e..85773652 100644 --- a/bec_widgets/widgets/control/device_control/positioner_box/positioner_box_base.py +++ b/bec_widgets/widgets/control/device_control/positioner_box/positioner_box_base.py @@ -21,9 +21,9 @@ from bec_widgets.utils.bec_widget import BECWidget from bec_widgets.widgets.control.device_control.position_indicator.position_indicator import ( PositionIndicator, ) -from bec_widgets.widgets.control.device_input.base_classes.device_input_base import BECDeviceFilter -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 ( + BECDeviceFilter, + DeviceComboBox, ) from bec_widgets.widgets.utility.spinner.spinner import SpinnerWidget @@ -257,10 +257,10 @@ class PositionerBoxBase(BECWidget, QWidget): self._dialog = QDialog(self) self._dialog.setWindowTitle("Positioner Selection") layout = QVBoxLayout() - line_edit = DeviceLineEdit( + line_edit = DeviceComboBox( self, client=self.client, device_filter=[BECDeviceFilter.POSITIONER] ) - line_edit.textChanged.connect(set_positioner) + line_edit.currentTextChanged.connect(set_positioner) layout.addWidget(line_edit) close_button = QPushButton("Close") close_button.clicked.connect(self._dialog.accept) diff --git a/bec_widgets/widgets/control/device_input/base_classes/__init__.py b/bec_widgets/widgets/control/device_input/base_classes/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/bec_widgets/widgets/control/device_input/base_classes/device_input_base.py b/bec_widgets/widgets/control/device_input/base_classes/device_input_base.py deleted file mode 100644 index a9a488bb..00000000 --- a/bec_widgets/widgets/control/device_input/base_classes/device_input_base.py +++ /dev/null @@ -1,458 +0,0 @@ -from __future__ import annotations - -import enum - -from bec_lib.device import ComputedSignal, Device, Positioner, ReadoutPriority -from bec_lib.device import Signal as BECSignal -from bec_lib.logger import bec_logger -from pydantic import field_validator - -from bec_widgets.utils.bec_connector import ConnectionConfig -from bec_widgets.utils.bec_widget import BECWidget -from bec_widgets.utils.error_popups import SafeProperty, SafeSlot -from bec_widgets.utils.filter_io import FilterIO -from bec_widgets.utils.widget_io import WidgetIO - -logger = bec_logger.logger - - -class BECDeviceFilter(enum.Enum): - """Filter for the device classes.""" - - DEVICE = "Device" - POSITIONER = "Positioner" - SIGNAL = "Signal" - COMPUTED_SIGNAL = "ComputedSignal" - - -class DeviceInputConfig(ConnectionConfig): - device_filter: list[str] = [] - readout_filter: list[str] = [] - devices: list[str] = [] - default: str | None = None - arg_name: str | None = None - apply_filter: bool = True - signal_class_filter: list[str] = [] - - @field_validator("device_filter") - @classmethod - def check_device_filter(cls, v, values): - valid_device_filters = [entry.value for entry in BECDeviceFilter] - for filt in v: - if filt not in valid_device_filters: - raise ValueError( - f"Device filter {filt} is not a valid device filter {valid_device_filters}." - ) - return v - - @field_validator("readout_filter") - @classmethod - def check_readout_filter(cls, v, values): - valid_device_filters = [entry.value for entry in ReadoutPriority] - for filt in v: - if filt not in valid_device_filters: - raise ValueError( - f"Device filter {filt} is not a valid device filter {valid_device_filters}." - ) - return v - - -class DeviceInputBase(BECWidget): - """ - Mixin base class for device input widgets. - It allows to filter devices from BEC based on - device class and readout priority. - """ - - _device_handler = { - BECDeviceFilter.DEVICE: Device, - BECDeviceFilter.POSITIONER: Positioner, - BECDeviceFilter.SIGNAL: BECSignal, - BECDeviceFilter.COMPUTED_SIGNAL: ComputedSignal, - } - - _filter_handler = { - BECDeviceFilter.DEVICE: "filter_to_device", - BECDeviceFilter.POSITIONER: "filter_to_positioner", - BECDeviceFilter.SIGNAL: "filter_to_signal", - BECDeviceFilter.COMPUTED_SIGNAL: "filter_to_computed_signal", - ReadoutPriority.MONITORED: "readout_monitored", - ReadoutPriority.BASELINE: "readout_baseline", - ReadoutPriority.ASYNC: "readout_async", - ReadoutPriority.CONTINUOUS: "readout_continuous", - ReadoutPriority.ON_REQUEST: "readout_on_request", - } - - def __init__(self, parent=None, client=None, config=None, gui_id: str | None = None, **kwargs): - - if config is None: - config = DeviceInputConfig(widget_class=self.__class__.__name__) - else: - if isinstance(config, dict): - config = DeviceInputConfig(**config) - self.config = config - super().__init__( - parent=parent, client=client, config=config, gui_id=gui_id, theme_update=True, **kwargs - ) - self.get_bec_shortcuts() - self._device_filter = [] - self._readout_filter = [] - self._devices = [] - - ### QtSlots ### - - @SafeSlot(str) - def set_device(self, device: str): - """ - Set the device. - - Args: - device (str): Default name. - """ - if self.validate_device(device) is True: - WidgetIO.set_value(widget=self, value=device) - self.config.default = device - else: - logger.warning( - f"Device {device} is not in the filtered selection of {self}: {self.devices}." - ) - - @SafeSlot() - def update_devices_from_filters(self): - """Update the devices based on the current filter selection - in self.device_filter and self.readout_filter. If apply_filter is False, - it will not apply the filters, store the filter settings and return. - """ - current_device = WidgetIO.get_value(widget=self, as_string=True) - self.config.device_filter = self.device_filter - self.config.readout_filter = self.readout_filter - self.config.signal_class_filter = self.signal_class_filter - if self.apply_filter is False: - return - all_dev = self.dev.enabled_devices - devs = self._filter_devices_by_signal_class(all_dev) - # Filter based on device class - devs = [dev for dev in devs if self._check_device_filter(dev)] - # Filter based on readout priority - devs = [dev for dev in devs if self._check_readout_filter(dev)] - self.devices = [device.name for device in devs] - if current_device != "": - self.set_device(current_device) - - @SafeSlot(list) - def set_available_devices(self, devices: list[str]): - """ - Set the devices. If a device in the list is not valid, it will not be considered. - - Args: - devices (list[str]): List of devices. - """ - self.apply_filter = False - self.devices = devices - - ### QtProperties ### - - @SafeProperty( - "QStringList", - doc="List of devices. If updated, it will disable the apply filters property.", - ) - def devices(self) -> list[str]: - """ - Get the list of devices for the applied filters. - - Returns: - list[str]: List of devices. - """ - return self._devices - - @devices.setter - def devices(self, value: list): - self._devices = value - self.config.devices = value - FilterIO.set_selection(widget=self, selection=value) - - @SafeProperty(str) - def default(self): - """Get the default device name. If set through this property, it will update only if the device is within the filtered selection.""" - return self.config.default - - @default.setter - def default(self, value: str): - if self.validate_device(value) is False: - return - self.config.default = value - WidgetIO.set_value(widget=self, value=value) - - @SafeProperty(bool) - def apply_filter(self): - """Apply the filters on the devices.""" - return self.config.apply_filter - - @apply_filter.setter - def apply_filter(self, value: bool): - self.config.apply_filter = value - self.update_devices_from_filters() - - @SafeProperty("QStringList") - def signal_class_filter(self) -> list[str]: - """ - Get the signal class filter for devices. - - Returns: - list[str]: List of signal class names used for filtering devices. - """ - return self.config.signal_class_filter - - @signal_class_filter.setter - def signal_class_filter(self, value: list[str] | None): - """ - Set the signal class filter and update the device list. - - Args: - value (list[str] | None): List of signal class names to filter by. - """ - self.config.signal_class_filter = value or [] - self.update_devices_from_filters() - - @SafeProperty(bool) - def filter_to_device(self): - """Include devices in filters.""" - return BECDeviceFilter.DEVICE in self.device_filter - - @filter_to_device.setter - def filter_to_device(self, value: bool): - if value is True and BECDeviceFilter.DEVICE not in self.device_filter: - self._device_filter.append(BECDeviceFilter.DEVICE) - if value is False and BECDeviceFilter.DEVICE in self.device_filter: - self._device_filter.remove(BECDeviceFilter.DEVICE) - self.update_devices_from_filters() - - @SafeProperty(bool) - def filter_to_positioner(self): - """Include devices of type Positioner in filters.""" - return BECDeviceFilter.POSITIONER in self.device_filter - - @filter_to_positioner.setter - def filter_to_positioner(self, value: bool): - if value is True and BECDeviceFilter.POSITIONER not in self.device_filter: - self._device_filter.append(BECDeviceFilter.POSITIONER) - if value is False and BECDeviceFilter.POSITIONER in self.device_filter: - self._device_filter.remove(BECDeviceFilter.POSITIONER) - self.update_devices_from_filters() - - @SafeProperty(bool) - def filter_to_signal(self): - """Include devices of type Signal in filters.""" - return BECDeviceFilter.SIGNAL in self.device_filter - - @filter_to_signal.setter - def filter_to_signal(self, value: bool): - if value is True and BECDeviceFilter.SIGNAL not in self.device_filter: - self._device_filter.append(BECDeviceFilter.SIGNAL) - if value is False and BECDeviceFilter.SIGNAL in self.device_filter: - self._device_filter.remove(BECDeviceFilter.SIGNAL) - self.update_devices_from_filters() - - @SafeProperty(bool) - def filter_to_computed_signal(self): - """Include devices of type ComputedSignal in filters.""" - return BECDeviceFilter.COMPUTED_SIGNAL in self.device_filter - - @filter_to_computed_signal.setter - def filter_to_computed_signal(self, value: bool): - if value is True and BECDeviceFilter.COMPUTED_SIGNAL not in self.device_filter: - self._device_filter.append(BECDeviceFilter.COMPUTED_SIGNAL) - if value is False and BECDeviceFilter.COMPUTED_SIGNAL in self.device_filter: - self._device_filter.remove(BECDeviceFilter.COMPUTED_SIGNAL) - self.update_devices_from_filters() - - @SafeProperty(bool) - def readout_monitored(self): - """Include devices with readout priority Monitored in filters.""" - return ReadoutPriority.MONITORED in self.readout_filter - - @readout_monitored.setter - def readout_monitored(self, value: bool): - if value is True and ReadoutPriority.MONITORED not in self.readout_filter: - self._readout_filter.append(ReadoutPriority.MONITORED) - if value is False and ReadoutPriority.MONITORED in self.readout_filter: - self._readout_filter.remove(ReadoutPriority.MONITORED) - self.update_devices_from_filters() - - @SafeProperty(bool) - def readout_baseline(self): - """Include devices with readout priority Baseline in filters.""" - return ReadoutPriority.BASELINE in self.readout_filter - - @readout_baseline.setter - def readout_baseline(self, value: bool): - if value is True and ReadoutPriority.BASELINE not in self.readout_filter: - self._readout_filter.append(ReadoutPriority.BASELINE) - if value is False and ReadoutPriority.BASELINE in self.readout_filter: - self._readout_filter.remove(ReadoutPriority.BASELINE) - self.update_devices_from_filters() - - @SafeProperty(bool) - def readout_async(self): - """Include devices with readout priority Async in filters.""" - return ReadoutPriority.ASYNC in self.readout_filter - - @readout_async.setter - def readout_async(self, value: bool): - if value is True and ReadoutPriority.ASYNC not in self.readout_filter: - self._readout_filter.append(ReadoutPriority.ASYNC) - if value is False and ReadoutPriority.ASYNC in self.readout_filter: - self._readout_filter.remove(ReadoutPriority.ASYNC) - self.update_devices_from_filters() - - @SafeProperty(bool) - def readout_continuous(self): - """Include devices with readout priority continuous in filters.""" - return ReadoutPriority.CONTINUOUS in self.readout_filter - - @readout_continuous.setter - def readout_continuous(self, value: bool): - if value is True and ReadoutPriority.CONTINUOUS not in self.readout_filter: - self._readout_filter.append(ReadoutPriority.CONTINUOUS) - if value is False and ReadoutPriority.CONTINUOUS in self.readout_filter: - self._readout_filter.remove(ReadoutPriority.CONTINUOUS) - self.update_devices_from_filters() - - @SafeProperty(bool) - def readout_on_request(self): - """Include devices with readout priority OnRequest in filters.""" - return ReadoutPriority.ON_REQUEST in self.readout_filter - - @readout_on_request.setter - def readout_on_request(self, value: bool): - if value is True and ReadoutPriority.ON_REQUEST not in self.readout_filter: - self._readout_filter.append(ReadoutPriority.ON_REQUEST) - if value is False and ReadoutPriority.ON_REQUEST in self.readout_filter: - self._readout_filter.remove(ReadoutPriority.ON_REQUEST) - self.update_devices_from_filters() - - ### Python Methods and Properties ### - - @property - def device_filter(self) -> list[object]: - """Get the list of filters to apply on the devices.""" - return self._device_filter - - @property - def readout_filter(self) -> list[str]: - """Get the list of filters to apply on the devices""" - return self._readout_filter - - def get_available_filters(self) -> list: - """Get the available filters.""" - return [entry for entry in BECDeviceFilter] - - def get_readout_priority_filters(self) -> list: - """Get the available readout priority filters.""" - return [entry for entry in ReadoutPriority] - - def set_device_filter( - self, filter_selection: str | BECDeviceFilter | list[str] | list[BECDeviceFilter] - ): - """ - Set the device filter. If None is passed, no filters are applied and all devices included. - - Args: - filter_selection (str | list[str]): Device filters. It is recommended to make an enum for the filters. - """ - filters = None - if isinstance(filter_selection, list): - filters = [self._filter_handler.get(entry) for entry in filter_selection] - if isinstance(filter_selection, str) or isinstance(filter_selection, BECDeviceFilter): - filters = [self._filter_handler.get(filter_selection)] - if filters is None or any([entry is None for entry in filters]): - logger.warning(f"Device filter {filter_selection} is not in the device filter list.") - return - for entry in filters: - setattr(self, entry, True) - - def set_readout_priority_filter( - self, filter_selection: str | ReadoutPriority | list[str] | list[ReadoutPriority] - ): - """ - Set the readout priority filter. If None is passed, all filters are included. - - Args: - filter_selection (str | list[str]): Readout priority filters. - """ - filters = None - if isinstance(filter_selection, list): - filters = [self._filter_handler.get(entry) for entry in filter_selection] - if isinstance(filter_selection, str) or isinstance(filter_selection, ReadoutPriority): - filters = [self._filter_handler.get(filter_selection)] - if filters is None or any([entry is None for entry in filters]): - logger.warning( - f"Readout priority filter {filter_selection} is not in the readout priority list." - ) - return - for entry in filters: - setattr(self, entry, True) - - def _check_device_filter( - self, device: Device | BECSignal | ComputedSignal | Positioner - ) -> bool: - """Check if filter for device type is applied or not. - - Args: - device(Device | Signal | ComputedSignal | Positioner): Device object. - """ - return all(isinstance(device, self._device_handler[entry]) for entry in self.device_filter) - - def _filter_devices_by_signal_class( - self, devices: list[Device | BECSignal | ComputedSignal | Positioner] - ) -> list[Device | BECSignal | ComputedSignal | Positioner]: - """Filter devices by signal class, if a signal class filter is set.""" - if not self.config.signal_class_filter: - return devices - if not self.client or not hasattr(self.client, "device_manager"): - return [] - signals = FilterIO.update_with_signal_class( - widget=self, signal_class_filter=self.config.signal_class_filter, client=self.client - ) - allowed_devices = {device_name for device_name, _, _ in signals} - return [dev for dev in devices if dev.name in allowed_devices] - - def _check_readout_filter( - self, device: Device | BECSignal | ComputedSignal | Positioner - ) -> bool: - """Check if filter for readout priority is applied or not. - - Args: - device(Device | Signal | ComputedSignal | Positioner): Device object. - """ - return device.readout_priority in self.readout_filter - - def get_device_object(self, device: str) -> object: - """ - Get the device object based on the device name. - - Args: - device(str): Device name. - - Returns: - object: Device object, can be device of type Device, Positioner, Signal or ComputedSignal. - """ - self.validate_device(device) - dev = getattr(self.dev, device, None) - if dev is None: - raise ValueError( - f"Device {device} is not found in the device manager {self.dev} as enabled device." - ) - return dev - - def validate_device(self, device: str) -> bool: - """ - Validate the device if it is present in the filtered device selection. - - Args: - device(str): Device to validate. - """ - all_devs = [dev.name for dev in self.dev.enabled_devices] - if device in self.devices and device in all_devs: - return True - return False 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 deleted file mode 100644 index 788dea83..00000000 --- a/bec_widgets/widgets/control/device_input/base_classes/device_signal_input_base.py +++ /dev/null @@ -1,301 +0,0 @@ -from bec_lib.callback_handler import EventType -from bec_lib.device import Signal -from bec_lib.logger import bec_logger -from qtpy.QtCore import Property - -from bec_widgets.utils.bec_connector 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.ophyd_kind_util import Kind -from bec_widgets.utils.widget_io import WidgetIO - -logger = bec_logger.logger - - -class DeviceSignalInputBaseConfig(ConnectionConfig): - """Configuration class for DeviceSignalInputBase.""" - - signal_filter: str | list[str] | None = None - signal_class_filter: list[str] | None = None - ndim_filter: int | list[int] | None = None - default: str | None = None - arg_name: str | None = None - device: str | None = None - signals: list[str] | None = None - - -class DeviceSignalInputBase(BECWidget): - """ - Mixin base class for device signal input widgets. - Mixin class for device signal input widgets. This class provides methods to get the device signal list and device - signal object based on the current text of the widget. - """ - - RPC = False - _filter_handler = { - Kind.hinted: "include_hinted_signals", - Kind.normal: "include_normal_signals", - Kind.config: "include_config_signals", - } - - def __init__( - self, - client=None, - config: DeviceSignalInputBaseConfig | dict | None = None, - gui_id: str = None, - **kwargs, - ): - - self.config = self._process_config_input(config) - super().__init__(client=client, config=self.config, gui_id=gui_id, **kwargs) - - self._device = None - self.get_bec_shortcuts() - self._signal_filter = set() - self._signals = [] - self._hinted_signals = [] - self._normal_signals = [] - self._config_signals = [] - self._device_update_register = self.bec_dispatcher.client.callbacks.register( - EventType.DEVICE_UPDATE, self.update_signals_from_filters - ) - - ### Qt Slots ### - - @SafeSlot(str) - def set_signal(self, signal: str): - """ - Set the signal. - - Args: - signal (str): signal name. - """ - if self.validate_signal(signal): - WidgetIO.set_value(widget=self, value=signal) - self.config.default = signal - else: - logger.warning( - f"Signal {signal} not found for device {self.device} and filtered selection {self.signal_filter}." - ) - - @SafeSlot(str) - def set_device(self, device: str | None): - """ - Set the device. If device is not valid, device will be set to None which happens - - Args: - device(str): device name. - """ - if self.validate_device(device) is False: - self._device = None - else: - self._device = device - self.update_signals_from_filters() - - @SafeSlot(dict, dict) - @SafeSlot() - def update_signals_from_filters( - self, content: dict | None = None, metadata: dict | None = None - ): - """Update the filters for the device signals based on list in self.signal_filter. - In addition, store the hinted, normal and config signals in separate lists to allow - customisation within QLineEdit. - - Note: - Signal and ComputedSignals have no signals. The naming convention follows the device name. - """ - self.config.signal_filter = self.signal_filter - # pylint: disable=protected-access - if not self.validate_device(self._device): - self._device = None - self.config.device = 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 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) - self._config_signals = _update(Kind.config) - - self._signals = self._hinted_signals + self._normal_signals + self._config_signals - FilterIO.set_selection(widget=self, selection=self.signals) - - ### Qt Properties ### - - @Property(str) - def device(self) -> str: - """Get the selected device.""" - if self._device is None: - return "" - return self._device - - @device.setter - def device(self, value: str): - """Set the device and update the filters, only allow devices present in the devicemanager.""" - self._device = value - self.config.device = value - self.update_signals_from_filters() - - @Property(bool) - def include_hinted_signals(self): - """Include hinted signals in filters.""" - return Kind.hinted in self.signal_filter - - @include_hinted_signals.setter - def include_hinted_signals(self, value: bool): - if value: - self._signal_filter.add(Kind.hinted) - else: - self._signal_filter.discard(Kind.hinted) - self.update_signals_from_filters() - - @Property(bool) - def include_normal_signals(self): - """Include normal signals in filters.""" - return Kind.normal in self.signal_filter - - @include_normal_signals.setter - def include_normal_signals(self, value: bool): - if value: - self._signal_filter.add(Kind.normal) - else: - self._signal_filter.discard(Kind.normal) - self.update_signals_from_filters() - - @Property(bool) - def include_config_signals(self): - """Include config signals in filters.""" - return Kind.config in self.signal_filter - - @include_config_signals.setter - def include_config_signals(self, value: bool): - if value: - self._signal_filter.add(Kind.config) - else: - self._signal_filter.discard(Kind.config) - self.update_signals_from_filters() - - ### Properties and Methods ### - - @property - def signals(self) -> list[str]: - """ - Get the list of device signals for the applied filters. - - Returns: - list[str]: List of device signals. - """ - return self._signals - - @signals.setter - def signals(self, value: list[str]): - self._signals = value - self.config.signals = value - FilterIO.set_selection(widget=self, selection=value) - - @property - def signal_filter(self) -> list[str]: - """Get the list of filters to apply on the device signals.""" - return self._signal_filter - - def get_available_filters(self) -> list[str]: - """Get the available filters.""" - return [entry for entry in self._filter_handler] - - def set_filter(self, filter_selection: str | list[str]): - """ - Set the device filter. If None, all devices are included. - - Args: - filter_selection (str | list[str]): Device filters from BECDeviceFilter and BECReadoutPriority. - """ - filters = None - if isinstance(filter_selection, list): - filters = [self._filter_handler.get(entry) for entry in filter_selection] - if isinstance(filter_selection, str): - filters = [self._filter_handler.get(filter_selection)] - if filters is None: - return - for entry in filters: - setattr(self, entry, True) - - def get_device_object(self, device: str) -> object | None: - """ - Get the device object based on the device name. - - Args: - device(str): Device name. - - Returns: - object: Device object, can be device of type Device, Positioner, Signal or ComputedSignal. - """ - self.validate_device(device) - dev = getattr(self.dev, device, None) - if dev is None: - logger.warning(f"Device {device} not found in devicemanager.") - return None - return dev - - def validate_device(self, device: str | None, raise_on_false: bool = False) -> bool: - """ - Validate the device if it is present in current BEC instance. - - Args: - device(str): Device to validate. - raise_on_false(bool): Raise ValueError if device is not found. - """ - if device in self.dev: - return True - if raise_on_false is True: - raise ValueError(f"Device {device} not found in devicemanager.") - return False - - def validate_signal(self, signal: str) -> bool: - """ - Validate the signal if it is present in the device signals. - - Args: - signal(str): Signal to validate. - """ - 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): - if config is None: - return DeviceSignalInputBaseConfig(widget_class=self.__class__.__name__) - return DeviceSignalInputBaseConfig.model_validate(config) - - def cleanup(self): - """ - Cleanup the widget. - """ - self.bec_dispatcher.client.callbacks.remove(self._device_update_register) - super().cleanup() diff --git a/bec_widgets/widgets/control/device_input/device_combobox/device_combobox.py b/bec_widgets/widgets/control/device_input/device_combobox/device_combobox.py index 0f923d0c..95af8c42 100644 --- a/bec_widgets/widgets/control/device_input/device_combobox/device_combobox.py +++ b/bec_widgets/widgets/control/device_input/device_combobox/device_combobox.py @@ -1,32 +1,80 @@ +from __future__ import annotations + +import enum + from bec_lib.callback_handler import EventType -from bec_lib.device import ReadoutPriority +from bec_lib.device import ComputedSignal, Device, Positioner, ReadoutPriority +from bec_lib.device import Signal as BECSignal +from bec_lib.logger import bec_logger +from pydantic import Field, field_validator from qtpy.QtCore import QSize, Signal, Slot from qtpy.QtWidgets import QComboBox, QSizePolicy +from bec_widgets.utils.bec_connector import ConnectionConfig +from bec_widgets.utils.bec_widget import BECWidget from bec_widgets.utils.colors import get_accent_colors -from bec_widgets.utils.error_popups import SafeProperty -from bec_widgets.widgets.control.device_input.base_classes.device_input_base import ( - BECDeviceFilter, - DeviceInputBase, - DeviceInputConfig, -) +from bec_widgets.utils.error_popups import SafeProperty, SafeSlot +from bec_widgets.utils.filter_io import get_bec_signals_for_classes, replace_combobox_items + +logger = bec_logger.logger -class DeviceComboBox(DeviceInputBase, QComboBox): +class BECDeviceFilter(enum.Enum): + """Filter for BEC device classes.""" + + DEVICE = "Device" + POSITIONER = "Positioner" + SIGNAL = "Signal" + COMPUTED_SIGNAL = "ComputedSignal" + + +class DeviceInputConfig(ConnectionConfig): + device_filter: list[str] = Field(default_factory=list) + readout_filter: list[str] = Field(default_factory=list) + devices: list[str] = Field(default_factory=list) + default: str | None = None + arg_name: str | None = None + apply_filter: bool = True + signal_class_filter: list[str] = Field(default_factory=list) + + @field_validator("device_filter") + @classmethod + def check_device_filter(cls, value): + valid_filters = [entry.value for entry in BECDeviceFilter] + for device_filter in value: + if device_filter not in valid_filters: + raise ValueError( + f"Device filter {device_filter} is not a valid device filter {valid_filters}." + ) + return value + + @field_validator("readout_filter") + @classmethod + def check_readout_filter(cls, value): + valid_filters = [entry.value for entry in ReadoutPriority] + for readout_filter in value: + if readout_filter not in valid_filters: + raise ValueError( + f"Readout filter {readout_filter} is not a valid readout filter {valid_filters}." + ) + return value + + +class DeviceComboBox(BECWidget, QComboBox): """ - Combobox widget for device input with autocomplete for device names. + Editable combobox for BEC device input. Args: parent: Parent widget. client: BEC client object. config: Device input configuration. gui_id: GUI ID. - device_filter: Device filter, name of the device class from BECDeviceFilter and BECReadoutPriority. Check DeviceInputBase for more details. - readout_priority_filter: Readout priority filter, name of the readout priority class from BECDeviceFilter and BECReadoutPriority. Check DeviceInputBase for more details. - available_devices: List of available devices, if passed, it sets apply filters to false and device/readout priority filters will not be applied. + device_filter: Device class filter from BECDeviceFilter. + readout_priority_filter: Readout priority filter from ReadoutPriority. + available_devices: Explicit list of devices. Passing this disables automatic filtering. default: Default device name. - arg_name: Argument name, can be used for the other widgets which has to call some other function in bec using correct argument names. - signal_class_filter: List of signal classes to filter the devices by. Only devices with signals of these classes will be shown. + arg_name: Argument name used by scan/input widgets. + signal_class_filter: Only show devices with signals of these classes. """ ICON_NAME = "list_alt" @@ -37,62 +85,89 @@ class DeviceComboBox(DeviceInputBase, QComboBox): device_reset = Signal() device_config_update = Signal() + _device_handler = { + BECDeviceFilter.DEVICE: Device, + BECDeviceFilter.POSITIONER: Positioner, + BECDeviceFilter.SIGNAL: BECSignal, + BECDeviceFilter.COMPUTED_SIGNAL: ComputedSignal, + } + def __init__( self, parent=None, client=None, - config: DeviceInputConfig = None, + config: DeviceInputConfig | dict | None = None, gui_id: str | None = None, - device_filter: BECDeviceFilter | list[BECDeviceFilter] | None = None, - readout_priority_filter: ( - str | ReadoutPriority | list[str] | list[ReadoutPriority] | None - ) = None, + device_filter: BECDeviceFilter | str | list[BECDeviceFilter | str] | None = None, + readout_priority_filter: str | ReadoutPriority | list[str | ReadoutPriority] | None = None, available_devices: list[str] | None = None, default: str | None = None, arg_name: str | None = None, signal_class_filter: list[str] | None = None, **kwargs, ): - super().__init__(parent=parent, client=client, gui_id=gui_id, config=config, **kwargs) - if arg_name is not None: - self.config.arg_name = arg_name - self.arg_name = arg_name - self.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed) - self.setMinimumSize(QSize(100, 0)) + self.config = self._process_config(config) + super().__init__( + parent=parent, + client=client, + config=self.config, + gui_id=gui_id, + theme_update=True, + **kwargs, + ) + self.get_bec_shortcuts() + + self._device_filter: list[BECDeviceFilter] = [] + self._readout_filter: list[ReadoutPriority] = [] + self._devices: list[str] = [] self._callback_id = None self._is_valid_input = False self._accent_colors = get_accent_colors() self._set_first_element_as_empty = False - # We do not consider the config that is passed here, this produced problems - # with QtDesigner, since config and input arguments may differ and resolve properly - # Implementing this logic and config recoverage is postponed. - # Set available devices if passed + self.setEditable(True) + self.setInsertPolicy(QComboBox.NoInsert) + self.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed) + self.setMinimumSize(QSize(100, 0)) + + if arg_name is not None: + self.config.arg_name = arg_name + self.arg_name = arg_name + + if available_devices is None and self.config.devices: + available_devices = self.config.devices + if device_filter is None and self.config.device_filter: + device_filter = self.config.device_filter + if readout_priority_filter is None and self.config.readout_filter: + readout_priority_filter = self.config.readout_filter + if signal_class_filter is None and self.config.signal_class_filter: + signal_class_filter = self.config.signal_class_filter + if default is None and self.config.default: + default = self.config.default + if available_devices is not None: self.set_available_devices(available_devices) - # Set readout priority filter default is all - if readout_priority_filter is not None: - self.set_readout_priority_filter(readout_priority_filter) - else: - self.set_readout_priority_filter( - [ - ReadoutPriority.MONITORED, - ReadoutPriority.BASELINE, - ReadoutPriority.ASYNC, - ReadoutPriority.CONTINUOUS, - ReadoutPriority.ON_REQUEST, - ] - ) - # Device filter default is None + + self.set_readout_priority_filter( + readout_priority_filter + or [ + ReadoutPriority.MONITORED, + ReadoutPriority.BASELINE, + ReadoutPriority.ASYNC, + ReadoutPriority.CONTINUOUS, + ReadoutPriority.ON_REQUEST, + ] + ) + if device_filter is not None: self.set_device_filter(device_filter) if signal_class_filter is not None: self.signal_class_filter = signal_class_filter - # Set default device if passed if default is not None: self.set_device(default) + self._callback_id = self.bec_dispatcher.client.callbacks.register( EventType.DEVICE_UPDATE, self.on_device_update ) @@ -100,39 +175,233 @@ class DeviceComboBox(DeviceInputBase, QComboBox): self.currentTextChanged.connect(self.check_validity) self.check_validity(self.currentText()) + @staticmethod + def _process_config(config: DeviceInputConfig | dict | None) -> DeviceInputConfig: + if config is None: + return DeviceInputConfig(widget_class="DeviceComboBox") + return DeviceInputConfig.model_validate(config) + + @SafeSlot(str) + def set_device(self, device: str): + """Set the current device if it is valid for the current filters.""" + if self.validate_device(device): + self.setCurrentText(device) + self.config.default = device + else: + logger.warning( + f"Device {device} is not in the filtered selection of {self}: {self.devices}." + ) + + @SafeSlot() + def update_devices_from_filters(self): + """Refresh the available device list from current device/readout/signal filters.""" + current_device = self.currentText() + self.config.device_filter = [entry.value for entry in self.device_filter] + self.config.readout_filter = [entry.value for entry in self.readout_filter] + self.config.signal_class_filter = self.signal_class_filter + if not self.apply_filter: + return + + devices = self._filter_devices_by_signal_class(self.dev.enabled_devices) + devices = [device for device in devices if self._check_device_filter(device)] + devices = [device for device in devices if self._check_readout_filter(device)] + self.devices = [device.name for device in devices] + if current_device: + self.setCurrentText(current_device) + self.check_validity(current_device) + + @SafeSlot(list) + def set_available_devices(self, devices: list[str]): + """Use an explicit device list and disable automatic BEC filtering.""" + self.apply_filter = False + self.devices = devices + + @SafeProperty("QStringList") + def devices(self) -> list[str]: + """Devices available after filtering.""" + return self._devices + + @devices.setter + def devices(self, value: list[str]): + self._devices = value + self.config.devices = value + self._replace_items(value) + + @SafeProperty(str) + def default(self): + """Default selected device.""" + return self.config.default + + @default.setter + def default(self, value: str): + self.set_device(value) + + @SafeProperty(bool) + def apply_filter(self): + """Whether BEC filters are applied to the device list.""" + return self.config.apply_filter + + @apply_filter.setter + def apply_filter(self, value: bool): + self.config.apply_filter = value + if value: + self.update_devices_from_filters() + + @SafeProperty("QStringList") + def signal_class_filter(self) -> list[str]: + """Signal class names used to restrict devices.""" + return self.config.signal_class_filter + + @signal_class_filter.setter + def signal_class_filter(self, value: list[str] | None): + self.config.signal_class_filter = value or [] + self.update_devices_from_filters() + + @SafeProperty(bool) + def filter_to_device(self): + """Include generic Device objects.""" + return BECDeviceFilter.DEVICE in self.device_filter + + @filter_to_device.setter + def filter_to_device(self, value: bool): + self._set_device_filter_enabled(BECDeviceFilter.DEVICE, value) + + @SafeProperty(bool) + def filter_to_positioner(self): + """Include Positioner devices.""" + return BECDeviceFilter.POSITIONER in self.device_filter + + @filter_to_positioner.setter + def filter_to_positioner(self, value: bool): + self._set_device_filter_enabled(BECDeviceFilter.POSITIONER, value) + + @SafeProperty(bool) + def filter_to_signal(self): + """Include Signal devices.""" + return BECDeviceFilter.SIGNAL in self.device_filter + + @filter_to_signal.setter + def filter_to_signal(self, value: bool): + self._set_device_filter_enabled(BECDeviceFilter.SIGNAL, value) + + @SafeProperty(bool) + def filter_to_computed_signal(self): + """Include ComputedSignal devices.""" + return BECDeviceFilter.COMPUTED_SIGNAL in self.device_filter + + @filter_to_computed_signal.setter + def filter_to_computed_signal(self, value: bool): + self._set_device_filter_enabled(BECDeviceFilter.COMPUTED_SIGNAL, value) + + @SafeProperty(bool) + def readout_monitored(self): + """Include monitored devices.""" + return ReadoutPriority.MONITORED in self.readout_filter + + @readout_monitored.setter + def readout_monitored(self, value: bool): + self._set_readout_filter_enabled(ReadoutPriority.MONITORED, value) + + @SafeProperty(bool) + def readout_baseline(self): + """Include baseline devices.""" + return ReadoutPriority.BASELINE in self.readout_filter + + @readout_baseline.setter + def readout_baseline(self, value: bool): + self._set_readout_filter_enabled(ReadoutPriority.BASELINE, value) + + @SafeProperty(bool) + def readout_async(self): + """Include async devices.""" + return ReadoutPriority.ASYNC in self.readout_filter + + @readout_async.setter + def readout_async(self, value: bool): + self._set_readout_filter_enabled(ReadoutPriority.ASYNC, value) + + @SafeProperty(bool) + def readout_continuous(self): + """Include continuous devices.""" + return ReadoutPriority.CONTINUOUS in self.readout_filter + + @readout_continuous.setter + def readout_continuous(self, value: bool): + self._set_readout_filter_enabled(ReadoutPriority.CONTINUOUS, value) + + @SafeProperty(bool) + def readout_on_request(self): + """Include on-request devices.""" + return ReadoutPriority.ON_REQUEST in self.readout_filter + + @readout_on_request.setter + def readout_on_request(self, value: bool): + self._set_readout_filter_enabled(ReadoutPriority.ON_REQUEST, value) + @SafeProperty(bool) def set_first_element_as_empty(self) -> bool: - """ - Whether the first element in the combobox should be empty. - This is useful to allow the user to select a device from the list. - """ + """Whether an empty choice is inserted as the first item.""" return self._set_first_element_as_empty @set_first_element_as_empty.setter def set_first_element_as_empty(self, value: bool) -> None: - """ - Set whether the first element in the combobox should be empty. - This is useful to allow the user to select a device from the list. - - Args: - value (bool): True if the first element should be empty, False otherwise. - """ self._set_first_element_as_empty = value - if self._set_first_element_as_empty: - self.insertItem(0, "") + if value: + if self.count() == 0 or self.itemText(0) != "": + self.insertItem(0, "") self.setCurrentIndex(0) - else: - if self.count() > 0 and self.itemText(0) == "": - self.removeItem(0) + elif self.count() > 0 and self.itemText(0) == "": + self.removeItem(0) + + @property + def device_filter(self) -> list[BECDeviceFilter]: + """Device class filters.""" + return self._device_filter + + @property + def readout_filter(self) -> list[ReadoutPriority]: + """Readout priority filters.""" + return self._readout_filter + + @property + def is_valid_input(self) -> bool: + """Whether the current text represents a valid device selection.""" + return self._is_valid_input + + def get_available_filters(self) -> list[BECDeviceFilter]: + """Return available device class filters.""" + return list(BECDeviceFilter) + + def get_readout_priority_filters(self) -> list[ReadoutPriority]: + """Return available readout priority filters.""" + return list(ReadoutPriority) + + def set_device_filter( + self, filter_selection: BECDeviceFilter | str | list[BECDeviceFilter | str] + ): + """Enable one or more device class filters.""" + for device_filter in self._as_list(filter_selection): + normalized = self._normalize_device_filter(device_filter) + if normalized is None: + logger.warning(f"Device filter {device_filter} is not in the device filter list.") + continue + self._set_device_filter_enabled(normalized, True) + + def set_readout_priority_filter( + self, filter_selection: ReadoutPriority | str | list[ReadoutPriority | str] + ): + """Enable one or more readout priority filters.""" + for readout_filter in self._as_list(filter_selection): + normalized = self._normalize_readout_filter(readout_filter) + if normalized is None: + logger.warning( + f"Readout priority filter {readout_filter} is not in the readout priority list." + ) + continue + self._set_readout_filter_enabled(normalized, True) def on_device_update(self, action: str, content: dict) -> None: - """ - Callback for device update events. Triggers the device_update signal. - - Args: - action (str): The action that triggered the event. - content (dict): The content of the config update. - """ + """Refresh filters when the BEC device configuration changes.""" if action in ["add", "remove", "reload"]: self.device_config_update.emit() @@ -143,21 +412,13 @@ class DeviceComboBox(DeviceInputBase, QComboBox): super().cleanup() def get_current_device(self) -> object: - """ - Get the current device object based on the current value. - - Returns: - object: Device object, can be device of type Device, Positioner, Signal or ComputedSignal. - """ - dev_name = self.currentText() - return self.get_device_object(dev_name) + """Return the current BEC device object.""" + return self.get_device_object(self._device_name_from_text(self.currentText())) @Slot(str) def check_validity(self, input_text: str) -> None: - """ - Check if the current value is a valid device name. - """ - if self.validate_device(input_text) is True: + """Validate current text and update visual state.""" + if self.validate_device(input_text): self._is_valid_input = True self.device_selected.emit(input_text) self.setStyleSheet("border: 1px solid transparent;") @@ -167,33 +428,94 @@ class DeviceComboBox(DeviceInputBase, QComboBox): if self.isEnabled(): self.setStyleSheet("border: 1px solid red;") - def validate_device(self, device: str) -> bool: # type: ignore[override] - """ - Extend validation so that preview‑signal pseudo‑devices (labels like - ``"eiger_preview"``) are accepted as valid choices. + def validate_device(self, device: str | None) -> bool: + """Validate a device against the current filtered device selection.""" + if not device: + return False + device_name = self._device_name_from_text(device) + all_devices = [dev.name for dev in self.dev.enabled_devices] + return device_name in self.devices and device_name in all_devices - The validation run only on device not on the preview‑signal. + def get_device_object(self, device: str) -> object: + """Return a device object by name.""" + dev = getattr(self.dev, device, None) + if dev is None: + raise ValueError( + f"Device {device} is not found in the device manager {self.dev} as enabled device." + ) + return dev - Args: - device: The text currently entered/selected. + @staticmethod + def _as_list(value): + return value if isinstance(value, list) else [value] - Returns: - True if the device is a genuine BEC device *or* one of the - whitelisted preview‑signal entries. - """ - idx = self.findText(device) - if idx >= 0 and isinstance(self.itemData(idx), tuple): - device = self.itemData(idx)[0] # type: ignore[assignment] - return super().validate_device(device) + @staticmethod + def _normalize_device_filter(value: BECDeviceFilter | str) -> BECDeviceFilter | None: + if isinstance(value, BECDeviceFilter): + return value + return BECDeviceFilter._value2member_map_.get(value) - @property - def is_valid_input(self) -> bool: - """Whether the current text represents a valid device selection.""" - return self._is_valid_input + @staticmethod + def _normalize_readout_filter(value: ReadoutPriority | str) -> ReadoutPriority | None: + if isinstance(value, ReadoutPriority): + return value + return ReadoutPriority._value2member_map_.get(value) + + def _set_device_filter_enabled(self, device_filter: BECDeviceFilter, enabled: bool): + if enabled and device_filter not in self._device_filter: + self._device_filter.append(device_filter) + elif not enabled and device_filter in self._device_filter: + self._device_filter.remove(device_filter) + self.update_devices_from_filters() + + def _set_readout_filter_enabled(self, readout_filter: ReadoutPriority, enabled: bool): + if enabled and readout_filter not in self._readout_filter: + self._readout_filter.append(readout_filter) + elif not enabled and readout_filter in self._readout_filter: + self._readout_filter.remove(readout_filter) + self.update_devices_from_filters() + + def _check_device_filter( + self, device: Device | BECSignal | ComputedSignal | Positioner + ) -> bool: + if not self.device_filter: + return True + return any(isinstance(device, self._device_handler[entry]) for entry in self.device_filter) + + def _check_readout_filter( + self, device: Device | BECSignal | ComputedSignal | Positioner + ) -> bool: + if not self.readout_filter: + return True + return device.readout_priority in self.readout_filter + + def _filter_devices_by_signal_class( + self, devices: list[Device | BECSignal | ComputedSignal | Positioner] + ) -> list[Device | BECSignal | ComputedSignal | Positioner]: + if not self.config.signal_class_filter: + return devices + signals = get_bec_signals_for_classes( + client=self.client, signal_class_filter=self.config.signal_class_filter + ) + allowed_devices = {device_name for device_name, _, _ in signals} + return [device for device in devices if device.name in allowed_devices] + + def _replace_items(self, devices: list[str]): + current_text = self.currentText() + replace_combobox_items(self, devices) + if self._set_first_element_as_empty: + self.insertItem(0, "") + if current_text: + self.setCurrentText(current_text) + + def _device_name_from_text(self, text: str) -> str: + index = self.findText(text) + if index >= 0 and isinstance(self.itemData(index), tuple): + return self.itemData(index)[0] + return text if __name__ == "__main__": # pragma: no cover - # pylint: disable=import-outside-toplevel from qtpy.QtWidgets import ( QApplication, QCheckBox, @@ -235,10 +557,7 @@ if __name__ == "__main__": # pragma: no cover def _apply_filters(): raw = class_input.text().strip() - if raw: - combo.signal_class_filter = [entry.strip() for entry in raw.split(",") if entry.strip()] - else: - combo.signal_class_filter = [] + combo.signal_class_filter = [entry.strip() for entry in raw.split(",") if entry.strip()] combo.filter_to_device = filter_device.isChecked() combo.filter_to_positioner = filter_positioner.isChecked() combo.filter_to_signal = filter_signal.isChecked() diff --git a/bec_widgets/widgets/control/device_input/device_line_edit/__init__.py b/bec_widgets/widgets/control/device_input/device_line_edit/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/bec_widgets/widgets/control/device_input/device_line_edit/device_line_edit.py b/bec_widgets/widgets/control/device_input/device_line_edit/device_line_edit.py deleted file mode 100644 index 5917b806..00000000 --- a/bec_widgets/widgets/control/device_input/device_line_edit/device_line_edit.py +++ /dev/null @@ -1,197 +0,0 @@ -from bec_lib.callback_handler import EventType -from bec_lib.device import ReadoutPriority -from bec_lib.logger import bec_logger -from qtpy.QtCore import QSize, Signal, Slot -from qtpy.QtGui import QPainter, QPaintEvent, QPen -from qtpy.QtWidgets import QApplication, QCompleter, QLineEdit, QSizePolicy - -from bec_widgets.utils.colors import get_accent_colors -from bec_widgets.widgets.control.device_input.base_classes.device_input_base import ( - BECDeviceFilter, - DeviceInputBase, - DeviceInputConfig, -) - -logger = bec_logger.logger - - -class DeviceLineEdit(DeviceInputBase, QLineEdit): - """ - Line edit widget for device input with autocomplete for device names. - - Args: - parent: Parent widget. - client: BEC client object. - config: Device input configuration. - gui_id: GUI ID. - device_filter: Device filter, name of the device class from BECDeviceFilter and BECReadoutPriority. Check DeviceInputBase for more details. - readout_priority_filter: Readout priority filter, name of the readout priority class from BECDeviceFilter and BECReadoutPriority. Check DeviceInputBase for more details. - available_devices: List of available devices, if passed, it sets apply filters to false and device/readout priority filters will not be applied. - default: Default device name. - arg_name: Argument name, can be used for the other widgets which has to call some other function in bec using correct argument names. - """ - - device_selected = Signal(str) - device_config_update = Signal() - - PLUGIN = True - RPC = False - ICON_NAME = "edit_note" - - def __init__( - self, - parent=None, - client=None, - config: DeviceInputConfig = None, - gui_id: str | None = None, - device_filter: BECDeviceFilter | list[BECDeviceFilter] | None = None, - readout_priority_filter: ( - str | ReadoutPriority | list[str] | list[ReadoutPriority] | None - ) = None, - available_devices: list[str] | None = None, - default: str | None = None, - arg_name: str | None = None, - **kwargs, - ): - self._callback_id = None - self.__is_valid_input = False - self._accent_colors = get_accent_colors() - super().__init__(parent=parent, client=client, gui_id=gui_id, config=config, **kwargs) - self.completer = QCompleter(self) - self.setCompleter(self.completer) - - if arg_name is not None: - self.config.arg_name = arg_name - self.arg_name = arg_name - self.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed) - self.setMinimumSize(QSize(100, 0)) - - # We do not consider the config that is passed here, this produced problems - # with QtDesigner, since config and input arguments may differ and resolve properly - # Implementing this logic and config recoverage is postponed. - # Set available devices if passed - if available_devices is not None: - self.set_available_devices(available_devices) - # Set readout priority filter default is all - if readout_priority_filter is not None: - self.set_readout_priority_filter(readout_priority_filter) - else: - self.set_readout_priority_filter( - [ - ReadoutPriority.MONITORED, - ReadoutPriority.BASELINE, - ReadoutPriority.ASYNC, - ReadoutPriority.CONTINUOUS, - ReadoutPriority.ON_REQUEST, - ] - ) - # Device filter default is None - if device_filter is not None: - self.set_device_filter(device_filter) - # Set default device if passed - if default is not None: - self.set_device(default) - self._callback_id = self.bec_dispatcher.client.callbacks.register( - EventType.DEVICE_UPDATE, self.on_device_update - ) - self.device_config_update.connect(self.update_devices_from_filters) - self.textChanged.connect(self.check_validity) - self.check_validity(self.text()) - - @property - def _is_valid_input(self) -> bool: - """ - Check if the current value is a valid device name. - - Returns: - bool: True if the current value is a valid device name, False otherwise. - """ - return self.__is_valid_input - - @_is_valid_input.setter - def _is_valid_input(self, value: bool) -> None: - self.__is_valid_input = value - - def on_device_update(self, action: str, content: dict) -> None: - """ - Callback for device update events. Triggers the device_update signal. - - Args: - action (str): The action that triggered the event. - content (dict): The content of the config update. - """ - if action in ["add", "remove", "reload"]: - self.device_config_update.emit() - - def cleanup(self): - """Cleanup the widget.""" - if self._callback_id is not None: - self.bec_dispatcher.client.callbacks.remove(self._callback_id) - super().cleanup() - - def get_current_device(self) -> object: - """ - Get the current device object based on the current value. - - Returns: - object: Device object, can be device of type Device, Positioner, Signal or ComputedSignal. - """ - dev_name = self.text() - return self.get_device_object(dev_name) - - def paintEvent(self, event: QPaintEvent) -> None: - """Extend the paint event to set the border color based on the validity of the input. - - Args: - event (PySide6.QtGui.QPaintEvent) : Paint event. - """ - # logger.info(f"Received paint event: {event} in {self.__class__}") - super().paintEvent(event) - - if self._is_valid_input is False and self.isEnabled() is True: - painter = QPainter(self) - pen = QPen() - pen.setWidth(2) - pen.setColor(self._accent_colors.emergency) - painter.setPen(pen) - painter.drawRect(self.rect().adjusted(1, 1, -1, -1)) - painter.end() - - @Slot(str) - def check_validity(self, input_text: str) -> None: - """ - Check if the current value is a valid device name. - """ - if self.validate_device(input_text) is True: - self._is_valid_input = True - self.device_selected.emit(input_text) - else: - self._is_valid_input = False - self.update() - - -if __name__ == "__main__": # pragma: no cover - # pylint: disable=import-outside-toplevel - from qtpy.QtWidgets import QVBoxLayout, QWidget - - from bec_widgets.utils.colors import apply_theme - from bec_widgets.widgets.control.device_input.signal_combobox.signal_combobox import ( - SignalComboBox, - ) - - app = QApplication([]) - apply_theme("dark") - widget = QWidget() - widget.setFixedSize(200, 200) - layout = QVBoxLayout() - widget.setLayout(layout) - line_edit = DeviceLineEdit() - line_edit.filter_to_positioner = True - signal_line_edit = SignalComboBox() - line_edit.textChanged.connect(signal_line_edit.set_device) - line_edit.set_available_devices(["samx", "samy", "samz"]) - line_edit.set_device("samx") - layout.addWidget(line_edit) - layout.addWidget(signal_line_edit) - widget.show() - app.exec_() diff --git a/bec_widgets/widgets/control/device_input/device_line_edit/device_line_edit.pyproject b/bec_widgets/widgets/control/device_input/device_line_edit/device_line_edit.pyproject deleted file mode 100644 index d9496b9f..00000000 --- a/bec_widgets/widgets/control/device_input/device_line_edit/device_line_edit.pyproject +++ /dev/null @@ -1 +0,0 @@ -{'files': ['device_line_edit.py']} \ No newline at end of file diff --git a/bec_widgets/widgets/control/device_input/device_line_edit/device_line_edit_plugin.py b/bec_widgets/widgets/control/device_input/device_line_edit/device_line_edit_plugin.py deleted file mode 100644 index e39b7be3..00000000 --- a/bec_widgets/widgets/control/device_input/device_line_edit/device_line_edit_plugin.py +++ /dev/null @@ -1,59 +0,0 @@ -# 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.control.device_input.device_line_edit.device_line_edit import ( - DeviceLineEdit, -) - -DOM_XML = """ - - - - -""" - - -class DeviceLineEditPlugin(QDesignerCustomWidgetInterface): # pragma: no cover - def __init__(self): - super().__init__() - self._form_editor = None - - def createWidget(self, parent): - if parent is None: - return QWidget() - t = DeviceLineEdit(parent) - return t - - def domXml(self): - return DOM_XML - - def group(self): - return "BEC Input Widgets" - - def icon(self): - return designer_material_icon(DeviceLineEdit.ICON_NAME) - - def includeFile(self): - return "device_line_edit" - - 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 "DeviceLineEdit" - - def toolTip(self): - return "" - - def whatsThis(self): - return self.toolTip() diff --git a/bec_widgets/widgets/control/device_input/device_line_edit/register_device_line_edit.py b/bec_widgets/widgets/control/device_input/device_line_edit/register_device_line_edit.py deleted file mode 100644 index 9cc6e474..00000000 --- a/bec_widgets/widgets/control/device_input/device_line_edit/register_device_line_edit.py +++ /dev/null @@ -1,17 +0,0 @@ -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.control.device_input.device_line_edit.device_line_edit_plugin import ( - DeviceLineEditPlugin, - ) - - QPyDesignerCustomWidgetCollection.addCustomWidget(DeviceLineEditPlugin()) - - -if __name__ == "__main__": # pragma: no cover - main() 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 892d30ab..39041a7a 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 @@ -1,37 +1,53 @@ from __future__ import annotations -from qtpy.QtCore import QSize, Qt, Signal +from bec_lib.callback_handler import EventType +from bec_lib.device import Signal as BECSignal +from bec_lib.logger import bec_logger +from qtpy.QtCore import Property, QSize, Qt, Signal, Slot from qtpy.QtWidgets import QComboBox, QSizePolicy +from bec_widgets.utils.bec_connector import ConnectionConfig +from bec_widgets.utils.bec_widget import BECWidget from bec_widgets.utils.error_popups import SafeProperty, SafeSlot -from bec_widgets.utils.filter_io import ComboBoxFilterHandler, FilterIO -from bec_widgets.utils.ophyd_kind_util import Kind -from bec_widgets.widgets.control.device_input.base_classes.device_signal_input_base import ( - DeviceSignalInputBase, - DeviceSignalInputBaseConfig, +from bec_widgets.utils.filter_io import ( + get_bec_signals_for_classes, + replace_combobox_items, + signal_items_for_kind, ) +from bec_widgets.utils.ophyd_kind_util import Kind + +logger = bec_logger.logger -class SignalComboBox(DeviceSignalInputBase, QComboBox): +class SignalComboBoxConfig(ConnectionConfig): + """Configuration for SignalComboBox.""" + + signal_filter: list[str] | None = None + signal_class_filter: list[str] | None = None + ndim_filter: int | list[int] | None = None + default: str | None = None + arg_name: str | None = None + device: str | None = None + signals: list[str] | None = None + + +class SignalComboBox(BECWidget, QComboBox): """ - Line edit widget for device input with autocomplete for device names. + Editable combobox for selecting BEC device signals. Args: parent: Parent widget. client: BEC client object. - config: Device input configuration. + config: Signal combobox configuration. gui_id: GUI ID. device: Device name to filter signals from. - signal_filter: Signal filter, list of signal kinds from ophyd Kind enum. Check DeviceSignalInputBase for more details. - signal_class_filter: List of signal classes to filter the signals by. Only signals of these classes will be shown. - ndim_filter: Dimensionality filter, int or list of ints to filter signals by their number of dimensions. If signal do not support ndim, it will be included in the selection anyway. - default: Default device name. - arg_name: Argument name, can be used for the other widgets which has to call some other function in bec using correct argument names. - store_signal_config: Whether to store the full signal config in the combobox item data. + signal_filter: Signal kind filters from Kind. + signal_class_filter: Signal classes to show. + ndim_filter: Dimensionality filter for signal-class based lists. + default: Default signal name. + arg_name: Argument name used by scan/input widgets. + store_signal_config: Whether to store signal config in item data. require_device: If True, signals are only shown/validated when a device is set. - Signals: - device_signal_changed: Emitted when the current text represents a valid signal selection. - signal_reset: Emitted when validation fails and the selection should be treated as cleared. """ USER_ACCESS = ["set_signal", "set_device", "signals", "get_signal_name"] @@ -47,10 +63,10 @@ class SignalComboBox(DeviceSignalInputBase, QComboBox): self, parent=None, client=None, - config: DeviceSignalInputBaseConfig | None = None, + config: SignalComboBoxConfig | dict | None = None, gui_id: str | None = None, device: str | None = None, - signal_filter: list[Kind] | None = None, + signal_filter: list[Kind | str] | Kind | str | None = None, signal_class_filter: list[str] | None = None, ndim_filter: int | list[int] | None = None, default: str | None = None, @@ -59,277 +75,336 @@ class SignalComboBox(DeviceSignalInputBase, QComboBox): require_device: bool = False, **kwargs, ): - super().__init__(parent=parent, client=client, gui_id=gui_id, config=config, **kwargs) - if arg_name is not None: - self.config.arg_name = arg_name - self.arg_name = arg_name - if default is not None: - self.set_device(default) + self.config = self._process_config(config) + super().__init__(parent=parent, client=client, config=self.config, gui_id=gui_id, **kwargs) + self.get_bec_shortcuts() - self.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed) - self.setMinimumSize(QSize(100, 0)) - self._set_first_element_as_empty = True + self._device: str | None = None + self._signal_filter: set[Kind] = set() + self._signals: list[str | tuple[str, dict]] = [] + self._hinted_signals: list[tuple[str, dict]] = [] + self._normal_signals: list[tuple[str, dict]] = [] + self._config_signals: list[tuple[str, dict]] = [] + self._set_first_element_as_empty = False self._signal_class_filter = signal_class_filter or [] self._store_signal_config = store_signal_config - self.config.ndim_filter = ndim_filter or None self._require_device = require_device self._is_valid_input = False - # Note: Runtime arguments (e.g. device, default, arg_name) intentionally take - # precedence over values from the passed-in config. Full reconciliation and - # restoration of state between designer-provided config and runtime arguments - # is not yet implemented, as earlier attempts caused issues with QtDesigner. + if arg_name is not None: + self.config.arg_name = arg_name + self.arg_name = arg_name + + if signal_filter is None and self.config.signal_filter: + signal_filter = self.config.signal_filter + if signal_class_filter is None and self.config.signal_class_filter: + self._signal_class_filter = self.config.signal_class_filter + if ndim_filter is None and self.config.ndim_filter is not None: + ndim_filter = self.config.ndim_filter + if device is None and self.config.device: + device = self.config.device + if default is None and self.config.default: + default = self.config.default + self.config.ndim_filter = ndim_filter + + self.setEditable(True) + self.setInsertPolicy(QComboBox.NoInsert) + self.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed) + self.setMinimumSize(QSize(100, 0)) + + self._device_update_register = self.bec_dispatcher.client.callbacks.register( + EventType.DEVICE_UPDATE, self.update_signals_from_filters + ) self.currentTextChanged.connect(self.on_text_changed) - # Kind filtering is always applied; class filtering is additive. If signal_filter is None, - # we default to hinted+normal, even when signal_class_filter is empty or None. To disable - # kinds, pass an explicit signal_filter or toggle include_* after init. - if signal_filter is not None: - self.set_filter(signal_filter) - else: - self.set_filter([Kind.hinted, Kind.normal, Kind.config]) + self.set_filter(signal_filter or [Kind.hinted, Kind.normal, Kind.config]) if device is not None: self.set_device(device) if default is not None: self.set_signal(default) + self.check_validity(self.currentText()) + + @staticmethod + def _process_config(config: SignalComboBoxConfig | dict | None) -> SignalComboBoxConfig: + if config is None: + return SignalComboBoxConfig(widget_class="SignalComboBox") + return SignalComboBoxConfig.model_validate(config) + + @SafeSlot(str) + def set_signal(self, signal: str): + """Set the current signal if it is available in the combobox.""" + display_text = self._display_text_for_signal(signal) + if display_text is None: + logger.warning( + f"Signal {signal} not found for device {self.device} and filtered selection {self.signal_filter}." + ) + return + self.setCurrentText(display_text) + self.config.default = signal @SafeSlot(str) def set_device(self, device: str | None): - """ - Set the device. When signal_class_filter is active, ensures base-class - logic runs and then refreshes the signal list to show only signals from - that device matching the signal class filter. - - Args: - device(str): device name. - """ - super().set_device(device) - - if self._signal_class_filter: - # Refresh the signal list to show only this device's signals - self.update_signals_from_signal_classes() + """Set the device that scopes kind-based signal filtering.""" + if not self.validate_device(device): + self._device = None + else: + self._device = device + self.config.device = self._device + self.update_signals_from_filters() @SafeSlot() @SafeSlot(dict, dict) def update_signals_from_filters( self, content: dict | None = None, metadata: dict | None = None ): - """Update the filters for the combobox. - When signal_class_filter is active, skip the normal Kind-based filtering. - - Args: - content (dict | None): Content dictionary from BEC event. - metadata (dict | None): Metadata dictionary from BEC event. - """ - super().update_signals_from_filters(content, metadata) + """Refresh available signals from the current device and filters.""" + self.config.signal_filter = [kind.name for kind in self.signal_filter] if self._signal_class_filter: self.update_signals_from_signal_classes() return - # pylint: disable=protected-access - if FilterIO._find_handler(self) is ComboBoxFilterHandler: - if len(self._config_signals) > 0: - self.insertItem( - len(self._hinted_signals) + len(self._normal_signals), "Config Signals" - ) - self.model().item(len(self._hinted_signals) + len(self._normal_signals)).setEnabled( - False - ) - if len(self._normal_signals) > 0: - self.insertItem(len(self._hinted_signals), "Normal Signals") - self.model().item(len(self._hinted_signals)).setEnabled(False) - if len(self._hinted_signals) > 0: - self.insertItem(0, "Hinted Signals") - self.model().item(0).setEnabled(False) + + if not self.validate_device(self._device): + self._device = None + self.config.device = None + self._set_signal_groups([], [], []) + return + + device = self.get_device_object(self._device) + device_info = device._info.get("signals", {}) + + if isinstance(device, BECSignal): + self._set_signal_groups([(self._device, {})], [], []) + return + + self._set_signal_groups( + signal_items_for_kind( + kind=Kind.hinted, + signal_filter=self.signal_filter, + device_info=device_info, + device_name=self._device, + ), + signal_items_for_kind( + kind=Kind.normal, + signal_filter=self.signal_filter, + device_info=device_info, + device_name=self._device, + ), + signal_items_for_kind( + kind=Kind.config, + signal_filter=self.signal_filter, + device_info=device_info, + device_name=self._device, + ), + ) + + @Property(str) + def device(self) -> str: + """Selected device.""" + return self._device or "" + + @device.setter + def device(self, value: str): + self.set_device(value) + + @Property(bool) + def include_hinted_signals(self): + """Include hinted signals.""" + return Kind.hinted in self.signal_filter + + @include_hinted_signals.setter + def include_hinted_signals(self, value: bool): + self._set_kind_filter_enabled(Kind.hinted, value) + + @Property(bool) + def include_normal_signals(self): + """Include normal signals.""" + return Kind.normal in self.signal_filter + + @include_normal_signals.setter + def include_normal_signals(self, value: bool): + self._set_kind_filter_enabled(Kind.normal, value) + + @Property(bool) + def include_config_signals(self): + """Include config signals.""" + return Kind.config in self.signal_filter + + @include_config_signals.setter + def include_config_signals(self, value: bool): + self._set_kind_filter_enabled(Kind.config, value) @SafeProperty(bool) def set_first_element_as_empty(self) -> bool: - """ - Whether the first element in the combobox should be empty. - This is useful to allow the user to select a device from the list. - """ + """Whether an empty choice is inserted as the first item.""" return self._set_first_element_as_empty @set_first_element_as_empty.setter def set_first_element_as_empty(self, value: bool) -> None: - """ - Set whether the first element in the combobox should be empty. - This is useful to allow the user to select a device from the list. - - Args: - value (bool): True if the first element should be empty, False otherwise. - """ self._set_first_element_as_empty = value - if self._set_first_element_as_empty: - self.insertItem(0, "") + if value: + if self.count() == 0 or self.itemText(0) != "": + self.insertItem(0, "") self.setCurrentIndex(0) - else: - if self.count() > 0 and self.itemText(0) == "": - self.removeItem(0) + elif self.count() > 0 and self.itemText(0) == "": + self.removeItem(0) @SafeProperty("QStringList") def signal_class_filter(self) -> list[str]: - """ - Get the list of signal classes to filter. - - Returns: - list[str]: List of signal class names to filter. - """ + """Signal class names used to build the signal list.""" return self._signal_class_filter @signal_class_filter.setter def signal_class_filter(self, value: list[str] | None): - """ - Set the signal class filter. - - Args: - value (list[str] | None): List of signal class names to filter, or None/empty - to disable class-based filtering and revert to the default behavior. - """ - normalized_value = value or [] - self._signal_class_filter = normalized_value - self.config.signal_class_filter = normalized_value - if self._signal_class_filter: - self.update_signals_from_signal_classes() - else: - self.update_signals_from_filters() + self._signal_class_filter = value or [] + self.config.signal_class_filter = self._signal_class_filter + self.update_signals_from_filters() @SafeProperty(int) def ndim_filter(self) -> int: - """Dimensionality filter for signals.""" + """Dimensionality filter for signal-class based lists.""" return self.config.ndim_filter if isinstance(self.config.ndim_filter, int) else -1 @ndim_filter.setter def ndim_filter(self, value: int): self.config.ndim_filter = None if value < 0 else value - if self._signal_class_filter: - self.update_signals_from_signal_classes(ndim_filter=self.config.ndim_filter) + self.update_signals_from_filters() @SafeProperty(bool) def require_device(self) -> bool: - """ - If True, signals are only shown/validated when a device is set. - - Note: - This property affects list rebuilding only when a signal_class_filter - is active. Without a signal class filter, the available signals are - managed by the standard Kind-based filtering. - """ + """Whether validation/listing requires a selected device.""" return self._require_device @require_device.setter def require_device(self, value: bool): self._require_device = value - # Rebuild list when toggled, but only when using signal_class_filter - if self._signal_class_filter: - self.update_signals_from_signal_classes() + self.update_signals_from_filters() - def set_to_obj_name(self, obj_name: str) -> bool: - """ - Set the combobox to the object name of the signal. + @property + def signals(self) -> list[str | tuple[str, dict]]: + """Available signals after filtering.""" + return self._signals - Args: - obj_name (str): Object name of the signal. + @signals.setter + def signals(self, value: list[str | tuple[str, dict]]): + self._signals = value + self.config.signals = [entry[0] if isinstance(entry, tuple) else entry for entry in value] + self._replace_signal_items() - Returns: - bool: True if the object name was found and set, False otherwise. - """ - for i in range(self.count()): - signal_data = self.itemData(i) - if signal_data and signal_data.get("obj_name") == obj_name: - self.setCurrentIndex(i) - return True + @property + def signal_filter(self) -> set[Kind]: + """Signal kind filters.""" + return self._signal_filter + + @property + def is_valid_input(self) -> bool: + """Whether the current text represents a valid signal selection.""" + return self._is_valid_input + + @property + def selected_signal_comp_name(self) -> str: + """Component name for the current signal, falling back to object name.""" + index = self._find_signal_index(self.currentText()) + if index < 0: + return self.get_signal_name() + signal_info = self.itemData(index) + if isinstance(signal_info, dict): + return signal_info.get("component_name") or self.get_signal_name() + return self.get_signal_name() + + def set_filter(self, filter_selection: Kind | str | list[Kind | str] | None): + """Enable one or more signal kind filters.""" + if filter_selection is None: + return + filters = filter_selection if isinstance(filter_selection, list) else [filter_selection] + for signal_filter in filters: + kind = self._normalize_kind(signal_filter) + if kind is not None: + self._signal_filter.add(kind) + self.update_signals_from_filters() + + def get_available_filters(self) -> list[Kind]: + """Return available signal kind filters.""" + return [Kind.hinted, Kind.normal, Kind.config] + + def get_device_object(self, device: str) -> object | None: + """Return a BEC device object by name.""" + dev = getattr(self.dev, device, None) + if dev is None: + logger.warning(f"Device {device} not found in devicemanager.") + return None + return dev + + def validate_device(self, device: str | None, raise_on_false: bool = False) -> bool: + """Validate that a device exists in the current device manager.""" + if device in self.dev: + return True + if raise_on_false: + raise ValueError(f"Device {device} not found in devicemanager.") return False - def set_to_first_enabled(self) -> bool: - """ - Set the combobox to the first enabled item. + def validate_signal(self, signal: str) -> bool: + """Validate a signal by display text, object name, or component name.""" + return self._display_text_for_signal(signal) is not None - Returns: - bool: True if an enabled item was found and set, False otherwise. - """ - for i in range(self.count()): - if self.model().item(i).isEnabled(): - self.setCurrentIndex(i) + def set_to_obj_name(self, obj_name: str) -> bool: + """Select the item whose signal config has the given object name.""" + index = self._find_signal_index(obj_name) + if index < 0: + return False + self.setCurrentIndex(index) + return True + + def set_to_first_enabled(self) -> bool: + """Select the first enabled item.""" + for index in range(self.count()): + item = self.model().item(index) + if item is not None and item.isEnabled(): + self.setCurrentIndex(index) return True return False def get_signal_name(self) -> str: - """ - Get the signal name from the combobox. - - Returns: - str: The signal name. - """ - signal_name = self.currentText() - index = self.findText(signal_name) - if index == -1: - return signal_name + """Return the selected signal object name when available.""" + current_text = self.currentText() + index = self._find_signal_index(current_text) + if index < 0: + return current_text signal_info = self.itemData(index) - if signal_info: - signal_name = signal_info.get("obj_name", signal_name) - - return signal_name if signal_name else "" + if isinstance(signal_info, dict): + return signal_info.get("obj_name") or current_text + return current_text def get_signal_config(self) -> dict | None: - """ - Get the signal config from the combobox for the currently selected signal. - - Returns: - dict | None: The signal configuration dictionary or None if not available. - """ + """Return the selected signal config if item-data storage is enabled.""" if not self._store_signal_config: return None - - index = self.currentIndex() - if index == -1: - return None - - signal_info = self.itemData(index) - return signal_info if signal_info else None + signal_info = self.itemData(self.currentIndex()) + return signal_info if isinstance(signal_info, dict) else None def update_signals_from_signal_classes(self, ndim_filter: int | list[int] | None = None): - """ - Update the combobox with signals filtered by signal classes and optionally by ndim. - Uses device_manager.get_bec_signals() to retrieve signals. - If a device is set, only shows signals from that device. - - Args: - ndim_filter (int | list[int] | None): Filter signals by dimensionality. - If provided, only signals with matching ndim will be included. - Can be a single int or a list of ints. Use None to include all dimensions. - If not provided, uses the previously set ndim_filter. - """ + """Refresh signals from device_manager.get_bec_signals for class-based filtering.""" if not self._signal_class_filter: return if self._require_device and not self._device: - self.clear() - self._signals = [] - FilterIO.set_selection(widget=self, selection=self._signals) + self.signals = [] return - # Update stored ndim_filter if a new one is provided if ndim_filter is not None: self.config.ndim_filter = ndim_filter - self.clear() - - # Get signals with ndim filtering applied at the FilterIO level - signals = FilterIO.update_with_signal_class( - widget=self, - signal_class_filter=self._signal_class_filter, + signals = get_bec_signals_for_classes( client=self.client, - ndim_filter=self.config.ndim_filter, # Pass ndim_filter to FilterIO + signal_class_filter=self._signal_class_filter, + ndim_filter=self.config.ndim_filter, ) - # Track signals for validation and FilterIO selection + self.clear() self._signals = [] - for device_name, signal_name, signal_config in signals: - # Filter by device if one is set if self._device and device_name != self._device: continue if self._signal_filter: @@ -339,53 +414,43 @@ class SignalComboBox(DeviceSignalInputBase, QComboBox): }: continue - # Get storage_name for tooltip - storage_name = signal_config.get("storage_name", "") - - # Store the full signal config as item data if requested if self._store_signal_config: self.addItem(signal_name, signal_config) else: self.addItem(signal_name) - # Track for validation self._signals.append(signal_name) - - # Set tooltip to storage_name (Qt.ToolTipRole = 3) + storage_name = signal_config.get("storage_name", "") if storage_name: self.setItemData(self.count() - 1, storage_name, Qt.ItemDataRole.ToolTipRole) - # Keep FilterIO selection in sync for validate_signal - FilterIO.set_selection(widget=self, selection=self._signals) + self.config.signals = [ + entry if isinstance(entry, str) else entry[0] for entry in self._signals + ] + if self._set_first_element_as_empty and self.count() > 0 and self.itemText(0) != "": + self.insertItem(0, "") @SafeSlot() def reset_selection(self): - """Reset the selection of the combobox.""" - self.clear() - self.setItemText(0, "Select a device") + """Reset the current selection and refresh available signals.""" + self.setCurrentText("") self.update_signals_from_filters() self.device_signal_changed.emit("") @SafeSlot(str) def on_text_changed(self, text: str): - """Validate and emit only when the signal is valid. - For a positioner, the readback value has to be renamed to the device name. - When using signal_class_filter, device validation is skipped. - """ + """Validate the current text when edited or selected.""" self.check_validity(text) + @Slot(str) def check_validity(self, input_text: str) -> None: - """Check if the current value is a valid signal and emit only when valid.""" + """Validate current text and update visual state.""" if self._signal_class_filter: - if self._require_device and (not self._device or not input_text): - is_valid = False - else: - is_valid = self.validate_signal(input_text) + is_valid = not (self._require_device and not self._device) and self.validate_signal( + input_text + ) else: - if self._require_device and not self.validate_device(self._device): - is_valid = False - else: - is_valid = self.validate_device(self._device) and self.validate_signal(input_text) + is_valid = self.validate_device(self._device) and self.validate_signal(input_text) if is_valid: self._is_valid_input = True @@ -397,18 +462,89 @@ class SignalComboBox(DeviceSignalInputBase, QComboBox): if self.isEnabled(): self.setStyleSheet("border: 1px solid red;") - @property - def selected_signal_comp_name(self) -> str: - return dict(self.signals).get(self.currentText(), {}).get("component_name", "") + def cleanup(self): + """Cleanup the widget.""" + self.bec_dispatcher.client.callbacks.remove(self._device_update_register) + super().cleanup() - @property - def is_valid_input(self) -> bool: - """Whether the current text represents a valid signal selection.""" - return self._is_valid_input + @staticmethod + def _normalize_kind(value: Kind | str) -> Kind | None: + if isinstance(value, Kind): + return value + return Kind.__members__.get(value) or Kind.__members__.get(value.lower()) + + def _set_kind_filter_enabled(self, kind: Kind, enabled: bool): + if enabled: + self._signal_filter.add(kind) + else: + self._signal_filter.discard(kind) + self.update_signals_from_filters() + + def _set_signal_groups( + self, + hinted: list[tuple[str, dict]], + normal: list[tuple[str, dict]], + config: list[tuple[str, dict]], + ) -> None: + self._hinted_signals = hinted + self._normal_signals = normal + self._config_signals = config + self.signals = self._hinted_signals + self._normal_signals + self._config_signals + self._insert_group_headers() + + def _replace_signal_items(self): + replace_combobox_items(self, self._signals) + if self._set_first_element_as_empty and self.count() > 0 and self.itemText(0) != "": + self.insertItem(0, "") + + def _insert_group_headers(self): + offset = ( + 1 + if self._set_first_element_as_empty and self.count() > 0 and self.itemText(0) == "" + else 0 + ) + if self._config_signals: + index = offset + len(self._hinted_signals) + len(self._normal_signals) + self.insertItem(index, "Config Signals") + self.model().item(index).setEnabled(False) + if self._normal_signals: + index = offset + len(self._hinted_signals) + self.insertItem(index, "Normal Signals") + self.model().item(index).setEnabled(False) + if self._hinted_signals: + index = offset + self.insertItem(index, "Hinted Signals") + self.model().item(index).setEnabled(False) + + def _display_text_for_signal(self, signal: str) -> str | None: + for entry in self._signals: + display_text = entry[0] if isinstance(entry, tuple) else entry + if display_text == signal: + return display_text + if isinstance(entry, tuple) and self._signal_info_matches(entry[1], signal): + return display_text + return None + + @staticmethod + def _signal_info_matches(signal_info: dict, signal: str) -> bool: + return signal in { + signal_info.get("obj_name"), + signal_info.get("component_name"), + signal_info.get("component_name", "").replace(".", "_"), + } + + def _find_signal_index(self, signal: str) -> int: + index = self.findText(signal) + if index >= 0: + return index + for item_index in range(self.count()): + signal_info = self.itemData(item_index) + if isinstance(signal_info, dict) and self._signal_info_matches(signal_info, signal): + return item_index + return -1 if __name__ == "__main__": # pragma: no cover - # pylint: disable=import-outside-toplevel from qtpy.QtWidgets import QApplication, QVBoxLayout, QWidget from bec_widgets.utils.colors import apply_theme @@ -417,16 +553,14 @@ if __name__ == "__main__": # pragma: no cover apply_theme("dark") widget = QWidget() widget.setFixedSize(200, 200) - layout = QVBoxLayout() - widget.setLayout(layout) + layout = QVBoxLayout(widget) box = SignalComboBox( device="waveform", signal_class_filter=["AsyncSignal", "AsyncMultiSignal"], ndim_filter=[1, 2], store_signal_config=True, signal_filter=[Kind.hinted, Kind.normal, Kind.config], - ) # change signal filter class to test - box.setEditable(True) + ) layout.addWidget(box) widget.show() app.exec_() diff --git a/bec_widgets/widgets/control/device_input/signal_line_edit/__init__.py b/bec_widgets/widgets/control/device_input/signal_line_edit/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/bec_widgets/widgets/control/device_input/signal_line_edit/register_signal_line_edit.py b/bec_widgets/widgets/control/device_input/signal_line_edit/register_signal_line_edit.py deleted file mode 100644 index 0a11780e..00000000 --- a/bec_widgets/widgets/control/device_input/signal_line_edit/register_signal_line_edit.py +++ /dev/null @@ -1,17 +0,0 @@ -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.control.device_input.signal_line_edit.signal_line_edit_plugin import ( - SignalLineEditPlugin, - ) - - QPyDesignerCustomWidgetCollection.addCustomWidget(SignalLineEditPlugin()) - - -if __name__ == "__main__": # pragma: no cover - main() diff --git a/bec_widgets/widgets/control/device_input/signal_line_edit/signal_line_edit.py b/bec_widgets/widgets/control/device_input/signal_line_edit/signal_line_edit.py deleted file mode 100644 index a7e9fe1f..00000000 --- a/bec_widgets/widgets/control/device_input/signal_line_edit/signal_line_edit.py +++ /dev/null @@ -1,169 +0,0 @@ -from bec_lib.device import Positioner -from qtpy.QtCore import QSize, Signal, Slot -from qtpy.QtGui import QPainter, QPaintEvent, QPen -from qtpy.QtWidgets import QCompleter, QLineEdit, QSizePolicy - -from bec_widgets.utils.colors import get_accent_colors -from bec_widgets.utils.ophyd_kind_util import Kind -from bec_widgets.widgets.control.device_input.base_classes.device_signal_input_base import ( - DeviceSignalInputBase, -) - - -class SignalLineEdit(DeviceSignalInputBase, QLineEdit): - """ - Line edit widget for device input with autocomplete for device names. - - Args: - parent: Parent widget. - client: BEC client object. - config: Device input configuration. - gui_id: GUI ID. - device_filter: Device filter, name of the device class from BECDeviceFilter and BECReadoutPriority. Check DeviceInputBase for more details. - default: Default device name. - arg_name: Argument name, can be used for the other widgets which has to call some other function in bec using correct argument names. - """ - - USER_ACCESS = ["_is_valid_input", "set_signal", "set_device", "signals"] - - device_signal_changed = Signal(str) - - PLUGIN = True - RPC = False - ICON_NAME = "vital_signs" - - def __init__( - self, - parent=None, - client=None, - config: DeviceSignalInputBase = None, - gui_id: str | None = None, - device: str | None = None, - signal_filter: str | list[str] | None = None, - default: str | None = None, - arg_name: str | None = None, - **kwargs, - ): - self.__is_valid_input = False - super().__init__(parent=parent, client=client, gui_id=gui_id, config=config, **kwargs) - self._accent_colors = get_accent_colors() - self.completer = QCompleter(self) - self.setCompleter(self.completer) - if arg_name is not None: - self.config.arg_name = arg_name - self.arg_name = arg_name - if default is not None: - self.set_device(default) - - self.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed) - self.setMinimumSize(QSize(100, 0)) - # We do not consider the config that is passed here, this produced problems - # with QtDesigner, since config and input arguments may differ and resolve properly - # Implementing this logic and config recoverage is postponed. - if signal_filter is not None: - self.set_filter(signal_filter) - else: - self.set_filter([Kind.hinted, Kind.normal, Kind.config]) - if device is not None: - self.set_device(device) - if default is not None: - self.set_signal(default) - self.textChanged.connect(self.check_validity) - self.check_validity(self.text()) - - @property - def _is_valid_input(self) -> bool: - """ - Check if the current value is a valid device name. - - Returns: - bool: True if the current value is a valid device name, False otherwise. - """ - return self.__is_valid_input - - @_is_valid_input.setter - def _is_valid_input(self, value: bool) -> None: - self.__is_valid_input = value - - def get_current_device(self) -> object: - """ - Get the current device object based on the current value. - - Returns: - object: Device object, can be device of type Device, Positioner, Signal or ComputedSignal. - """ - dev_name = self.text() - return self.get_device_object(dev_name) - - def paintEvent(self, event: QPaintEvent) -> None: - """Extend the paint event to set the border color based on the validity of the input. - - Args: - event (PySide6.QtGui.QPaintEvent) : Paint event. - """ - super().paintEvent(event) - painter = QPainter(self) - pen = QPen() - pen.setWidth(2) - - if self._is_valid_input is False and self.isEnabled() is True: - pen.setColor(self._accent_colors.emergency) - painter.setPen(pen) - painter.drawRect(self.rect().adjusted(1, 1, -1, -1)) - - @Slot(str) - def check_validity(self, input_text: str) -> None: - """ - Check if the current value is a valid device name. - """ - if self.validate_signal(input_text) is True: - self._is_valid_input = True - self.on_text_changed(input_text) - else: - self._is_valid_input = False - self.update() - - @Slot(str) - def on_text_changed(self, text: str): - """Slot for text changed. If a device is selected and the signal is changed and valid it emits a signal. - For a positioner, the readback value has to be renamed to the device name. - - Args: - text (str): Text in the combobox. - """ - print("test") - if self.validate_device(self.device) is False: - 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) - - -if __name__ == "__main__": # pragma: no cover - # pylint: disable=import-outside-toplevel - from qtpy.QtWidgets import QApplication, QVBoxLayout, QWidget - - from bec_widgets.utils.colors import apply_theme - from bec_widgets.widgets.control.device_input.device_combobox.device_combobox import ( - DeviceComboBox, - ) - - app = QApplication([]) - apply_theme("dark") - widget = QWidget() - widget.setFixedSize(200, 200) - layout = QVBoxLayout() - widget.setLayout(layout) - device_line_edit = DeviceComboBox() - device_line_edit.filter_to_positioner = True - signal_line_edit = SignalLineEdit() - device_line_edit.device_selected.connect(signal_line_edit.set_device) - - layout.addWidget(device_line_edit) - layout.addWidget(signal_line_edit) - widget.show() - app.exec_() diff --git a/bec_widgets/widgets/control/device_input/signal_line_edit/signal_line_edit.pyproject b/bec_widgets/widgets/control/device_input/signal_line_edit/signal_line_edit.pyproject deleted file mode 100644 index 3cab6643..00000000 --- a/bec_widgets/widgets/control/device_input/signal_line_edit/signal_line_edit.pyproject +++ /dev/null @@ -1 +0,0 @@ -{'files': ['signal_line_edit.py']} \ No newline at end of file diff --git a/bec_widgets/widgets/control/device_input/signal_line_edit/signal_line_edit_plugin.py b/bec_widgets/widgets/control/device_input/signal_line_edit/signal_line_edit_plugin.py deleted file mode 100644 index 492f4cfa..00000000 --- a/bec_widgets/widgets/control/device_input/signal_line_edit/signal_line_edit_plugin.py +++ /dev/null @@ -1,59 +0,0 @@ -# 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.control.device_input.signal_line_edit.signal_line_edit import ( - SignalLineEdit, -) - -DOM_XML = """ - - - - -""" - - -class SignalLineEditPlugin(QDesignerCustomWidgetInterface): # pragma: no cover - def __init__(self): - super().__init__() - self._form_editor = None - - def createWidget(self, parent): - if parent is None: - return QWidget() - t = SignalLineEdit(parent) - return t - - def domXml(self): - return DOM_XML - - def group(self): - return "BEC Input Widgets" - - def icon(self): - return designer_material_icon(SignalLineEdit.ICON_NAME) - - def includeFile(self): - return "signal_line_edit" - - 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 "SignalLineEdit" - - def toolTip(self): - return "" - - def whatsThis(self): - return self.toolTip() diff --git a/bec_widgets/widgets/control/scan_control/scan_group_box.py b/bec_widgets/widgets/control/scan_control/scan_group_box.py index 46f07f8d..f570fe95 100644 --- a/bec_widgets/widgets/control/scan_control/scan_group_box.py +++ b/bec_widgets/widgets/control/scan_control/scan_group_box.py @@ -21,9 +21,9 @@ from qtpy.QtWidgets import ( ) from bec_widgets.utils.widget_io import WidgetIO -from bec_widgets.widgets.control.device_input.base_classes.device_input_base import BECDeviceFilter -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 ( + BECDeviceFilter, + DeviceComboBox, ) logger = bec_logger.logger @@ -164,8 +164,8 @@ class ScanCheckBox(QCheckBox): class ScanGroupBox(QGroupBox): WIDGET_HANDLER = { - ScanArgType.DEVICE: DeviceLineEdit, - ScanArgType.DEVICEBASE: DeviceLineEdit, + ScanArgType.DEVICE: DeviceComboBox, + ScanArgType.DEVICEBASE: DeviceComboBox, ScanArgType.FLOAT: ScanDoubleSpinBox, ScanArgType.INT: ScanSpinBox, ScanArgType.BOOL: ScanCheckBox, @@ -272,7 +272,7 @@ class ScanGroupBox(QGroupBox): if default == "_empty": default = None widget = widget_class(parent=self.parent(), arg_name=arg_name, default=default) - if isinstance(widget, DeviceLineEdit): + if isinstance(widget, DeviceComboBox): widget.set_device_filter(BECDeviceFilter.DEVICE) self.selected_devices[widget] = "" widget.device_selected.connect(self.emit_device_selected) @@ -311,7 +311,7 @@ class ScanGroupBox(QGroupBox): return for widget in self.widgets[-len(self.inputs) :]: - if isinstance(widget, DeviceLineEdit): + if isinstance(widget, DeviceComboBox): self.selected_devices[widget] = "" widget.close() widget.deleteLater() @@ -323,7 +323,7 @@ class ScanGroupBox(QGroupBox): def remove_all_widget_bundles(self): """Remove every widget bundle from the scan control layout.""" for widget in list(self.widgets): - if isinstance(widget, DeviceLineEdit): + if isinstance(widget, DeviceComboBox): self.selected_devices.pop(widget, None) widget.close() widget.deleteLater() @@ -360,8 +360,10 @@ class ScanGroupBox(QGroupBox): for j in range(self.layout.columnCount()): try: # In case that the bundle size changes widget = self.layout.itemAtPosition(i, j).widget() - if isinstance(widget, DeviceLineEdit) and device_object: + if isinstance(widget, DeviceComboBox) and device_object: value = widget.get_current_device() + elif isinstance(widget, DeviceComboBox): + value = widget.currentText() else: value = WidgetIO.get_value(widget) args.append(value) @@ -373,8 +375,10 @@ class ScanGroupBox(QGroupBox): kwargs = {} for i in range(self.layout.columnCount()): widget = self.layout.itemAtPosition(1, i).widget() - if isinstance(widget, DeviceLineEdit) and device_object: + if isinstance(widget, DeviceComboBox) and device_object: value = widget.get_current_device().name + elif isinstance(widget, DeviceComboBox): + value = widget.currentText() elif isinstance(widget, ScanLiteralsComboBox): value = widget.get_value() else: @@ -390,7 +394,7 @@ class ScanGroupBox(QGroupBox): if item is not None: widget = item.widget() if widget is not None: - if isinstance(widget, DeviceLineEdit): + if isinstance(widget, DeviceComboBox): widget_rows += 1 return widget_rows diff --git a/bec_widgets/widgets/plots/motor_map/toolbar_components/motor_selection.py b/bec_widgets/widgets/plots/motor_map/toolbar_components/motor_selection.py index ecd41627..aa9a1912 100644 --- a/bec_widgets/widgets/plots/motor_map/toolbar_components/motor_selection.py +++ b/bec_widgets/widgets/plots/motor_map/toolbar_components/motor_selection.py @@ -3,8 +3,10 @@ from qtpy.QtWidgets import QHBoxLayout, QSizePolicy, QWidget from bec_widgets.utils.toolbars.actions import NoCheckDelegate, WidgetAction from bec_widgets.utils.toolbars.bundles import ToolbarBundle, ToolbarComponents from bec_widgets.utils.toolbars.connections import BundleConnection -from bec_widgets.widgets.control.device_input.base_classes.device_input_base import BECDeviceFilter -from bec_widgets.widgets.control.device_input.device_combobox.device_combobox import DeviceComboBox +from bec_widgets.widgets.control.device_input.device_combobox.device_combobox import ( + BECDeviceFilter, + DeviceComboBox, +) class MotorSelection(QWidget): diff --git a/bec_widgets/widgets/plots/multi_waveform/toolbar_components/monitor_selection.py b/bec_widgets/widgets/plots/multi_waveform/toolbar_components/monitor_selection.py index 024dd770..d8ed8b5e 100644 --- a/bec_widgets/widgets/plots/multi_waveform/toolbar_components/monitor_selection.py +++ b/bec_widgets/widgets/plots/multi_waveform/toolbar_components/monitor_selection.py @@ -6,8 +6,10 @@ from bec_widgets.utils.error_popups import SafeSlot from bec_widgets.utils.toolbars.actions import DeviceComboBoxAction, WidgetAction from bec_widgets.utils.toolbars.bundles import ToolbarComponents from bec_widgets.utils.toolbars.toolbar import ToolbarBundle -from bec_widgets.widgets.control.device_input.base_classes.device_input_base import BECDeviceFilter -from bec_widgets.widgets.control.device_input.device_combobox.device_combobox import DeviceComboBox +from bec_widgets.widgets.control.device_input.device_combobox.device_combobox import ( + BECDeviceFilter, + DeviceComboBox, +) from bec_widgets.widgets.utility.visual.colormap_widget.colormap_widget import BECColorMapWidget diff --git a/bec_widgets/widgets/plots/scatter_waveform/settings/scatter_curve_settings_vertical.ui b/bec_widgets/widgets/plots/scatter_waveform/settings/scatter_curve_settings_vertical.ui index 27d47885..4e27bd55 100644 --- a/bec_widgets/widgets/plots/scatter_waveform/settings/scatter_curve_settings_vertical.ui +++ b/bec_widgets/widgets/plots/scatter_waveform/settings/scatter_curve_settings_vertical.ui @@ -58,7 +58,7 @@ - + @@ -87,7 +87,7 @@ - + @@ -116,7 +116,7 @@ - + @@ -135,9 +135,9 @@ - DeviceLineEdit - QLineEdit -
device_line_edit
+ DeviceComboBox + QComboBox +
device_combo_box
ToggleSwitch 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 3f73677e..a94b4a76 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 @@ -79,7 +79,7 @@ class CurveRow(QTreeWidgetItem): Columns: 0: Actions (delete or "Add DAP" if source=device) - 1..2: DeviceLineEdit and QLineEdit if source=device, or "Model" label and DapComboBox if source=dap + 1..2: DeviceComboBox and QLineEdit if source=device, or "Model" label and DapComboBox if source=dap 3: ColorButton 4: Style QComboBox 5: Pen width QSpinBox diff --git a/bec_widgets/widgets/utility/signal_label/signal_label.py b/bec_widgets/widgets/utility/signal_label/signal_label.py index 529d175b..05ab64b2 100644 --- a/bec_widgets/widgets/utility/signal_label/signal_label.py +++ b/bec_widgets/widgets/utility/signal_label/signal_label.py @@ -25,9 +25,7 @@ from bec_widgets.utils.bec_widget import BECWidget from bec_widgets.utils.colors import get_accent_colors from bec_widgets.utils.error_popups import SafeProperty, SafeSlot from bec_widgets.utils.ophyd_kind_util import Kind -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 if TYPE_CHECKING: @@ -58,7 +56,7 @@ class ChoiceDialog(QDialog): layout = QHBoxLayout() - self._device_field = DeviceLineEdit(parent=parent, client=client) + self._device_field = DeviceComboBox(parent=parent, client=client) self._signal_field = SignalComboBox(parent=parent, client=client) layout.addWidget(self._device_field) layout.addWidget(self._signal_field) @@ -73,10 +71,13 @@ class ChoiceDialog(QDialog): self._signal_field.include_config_signals = show_config self.setLayout(layout) - self._device_field.textChanged.connect(self._update_device) + self._device_field.currentTextChanged.connect(self._update_device) if device: self._device_field.set_device(device) - if signal and signal in set(s[0] for s in self._signal_field.signals): + available_signals = { + entry[0] if isinstance(entry, tuple) else entry for entry in self._signal_field.signals + } + if signal and signal in available_signals: self._signal_field.set_signal(signal) def _display_error(self): @@ -97,19 +98,19 @@ class ChoiceDialog(QDialog): self._device_field.set_device(device) self._signal_field.set_device(device) self._device_field.setStyleSheet( - f"QLineEdit {{ border-style: solid; border-width: 2px; border-color: {self._accent_colors.success.name() if self._accent_colors else 'green'}}}" + f"QComboBox {{ border-style: solid; border-width: 2px; border-color: {self._accent_colors.success.name() if self._accent_colors else 'green'}}}" ) self.button_box.button(QDialogButtonBox.Ok).setEnabled(True) else: self._device_field.setStyleSheet( - f"QLineEdit {{ border-style: solid; border-width: 2px; border-color: {self._accent_colors.emergency.name() if self._accent_colors else 'red'}}}" + f"QComboBox {{ border-style: solid; border-width: 2px; border-color: {self._accent_colors.emergency.name() if self._accent_colors else 'red'}}}" ) self.button_box.button(QDialogButtonBox.Ok).setEnabled(False) self._signal_field.clear() def accept(self): self.accepted_output.emit( - self._device_field.text(), self._signal_field.selected_signal_comp_name + self._device_field.currentText(), self._signal_field.selected_signal_comp_name ) self.cleanup() return super().accept() @@ -170,7 +171,7 @@ class SignalLabel(BECWidget, QWidget): client (BECClient, optional): The BEC client. Defaults to None. device (str, optional): The device name. Defaults to None. signal (str, optional): The signal name. Defaults to None. - selection_dialog_config (DeviceSignalInputBaseConfig | dict, optional): Configuration for the signal selection dialog. + selection_dialog_config: Configuration for the signal selection dialog. show_select_button (bool, optional): Whether to show the select button. Defaults to True. show_default_units (bool, optional): Whether to show default units. Defaults to False. custom_label (str, optional): Custom label for the widget. Defaults to "". diff --git a/tests/unit_tests/test_device_input_base.py b/tests/unit_tests/test_device_input_base.py index 52fac530..0b73be3e 100644 --- a/tests/unit_tests/test_device_input_base.py +++ b/tests/unit_tests/test_device_input_base.py @@ -1,152 +1,92 @@ from unittest import mock -import pytest from bec_lib.device import ReadoutPriority -from qtpy.QtWidgets import QWidget -from bec_widgets.widgets.control.device_input.base_classes.device_input_base import ( +from bec_widgets.widgets.control.device_input.device_combobox.device_combobox import ( BECDeviceFilter, - DeviceInputBase, + DeviceComboBox, DeviceInputConfig, ) -from bec_widgets.widgets.control.device_input.device_combobox.device_combobox import DeviceComboBox from .client_mocks import mocked_client from .conftest import create_widget -# DeviceInputBase is meant to be mixed in a QWidget -class DeviceInputWidget(DeviceInputBase, QWidget): - """Thin wrapper around DeviceInputBase to make it a QWidget""" - - def __init__(self, parent=None, client=None, config=None, gui_id=None, **kwargs): - super().__init__(parent=parent, client=client, gui_id=gui_id, config=config, **kwargs) - - -@pytest.fixture -def device_input_base(qtbot, mocked_client): - """Fixture with mocked FilterIO and WidgetIO""" - with mock.patch("bec_widgets.utils.filter_io.FilterIO.set_selection"): - with mock.patch("bec_widgets.utils.widget_io.WidgetIO.set_value"): - with mock.patch("bec_widgets.utils.widget_io.WidgetIO.get_value"): - widget = create_widget(qtbot=qtbot, widget=DeviceInputWidget, client=mocked_client) - yield widget - - -def test_device_input_base_init(device_input_base): - """Test init""" - assert device_input_base is not None - assert device_input_base.client is not None - assert isinstance(device_input_base, DeviceInputBase) - assert device_input_base.config.widget_class == "DeviceInputWidget" - assert device_input_base.config.device_filter == [] - assert device_input_base.config.default is None - assert device_input_base.devices == [] - - -def test_device_input_base_init_with_config(qtbot, mocked_client): - """Test init with Config""" +def test_device_combobox_init_with_config(qtbot, mocked_client): config = { - "widget_class": "DeviceInputWidget", + "widget_class": "DeviceComboBox", "gui_id": "test_gui_id", - "device_filter": [BECDeviceFilter.POSITIONER], + "device_filter": [BECDeviceFilter.POSITIONER.value], "default": "samx", } - widget = DeviceInputWidget(client=mocked_client, config=config) - widget2 = DeviceInputWidget( - client=mocked_client, config=DeviceInputConfig.model_validate(config) + widget = create_widget( + qtbot=qtbot, + widget=DeviceComboBox, + client=mocked_client, + config=DeviceInputConfig.model_validate(config), ) - qtbot.addWidget(widget) - qtbot.addWidget(widget2) - qtbot.waitExposed(widget) - qtbot.waitExposed(widget2) - for w in [widget, widget2]: - assert w.config.gui_id == "test_gui_id" - assert w.config.device_filter == ["Positioner"] - assert w.config.default == "samx" + + assert widget.config.gui_id == "test_gui_id" + assert widget.config.device_filter == ["Positioner"] + assert widget.config.default == "samx" -def test_device_input_base_set_device_filter(device_input_base): - """Test device filter setter.""" - device_input_base.set_device_filter(BECDeviceFilter.POSITIONER) - assert device_input_base.config.device_filter == ["Positioner"] +def test_device_combobox_set_device_filter(qtbot, mocked_client): + widget = create_widget(qtbot=qtbot, widget=DeviceComboBox, client=mocked_client) + + widget.set_device_filter(BECDeviceFilter.POSITIONER) + + assert widget.config.device_filter == ["Positioner"] -def test_device_input_base_set_device_filter_error(device_input_base): - """Test set_device_filter with Noneexisting class. This should not raise. It writes a log message entry.""" - device_input_base.set_device_filter("NonExistingClass") - assert device_input_base.device_filter == [] +def test_device_combobox_set_device_filter_error(qtbot, mocked_client): + widget = create_widget(qtbot=qtbot, widget=DeviceComboBox, client=mocked_client) + + widget.set_device_filter("NonExistingClass") + + assert widget.device_filter == [] -def test_device_input_base_set_default_device(device_input_base): - """Test setting the default device. Also tests the update_devices method.""" - device_input_base.set_device("samx") - assert device_input_base.config.default == None - device_input_base.set_device_filter(BECDeviceFilter.POSITIONER) - device_input_base.set_readout_priority_filter(ReadoutPriority.MONITORED) - device_input_base.set_device("samx") - assert device_input_base.config.default == "samx" +def test_device_combobox_set_default_device(qtbot, mocked_client): + widget = create_widget(qtbot=qtbot, widget=DeviceComboBox, client=mocked_client) + + widget.set_device("samx") + + assert widget.config.default == "samx" -def test_device_input_base_get_filters(device_input_base): - """Test getting the available filters.""" - filters = device_input_base.get_available_filters() - selection = [ - BECDeviceFilter.POSITIONER, - BECDeviceFilter.DEVICE, - BECDeviceFilter.COMPUTED_SIGNAL, - BECDeviceFilter.SIGNAL, - ] + [ - ReadoutPriority.MONITORED, - ReadoutPriority.BASELINE, - ReadoutPriority.ASYNC, - ReadoutPriority.ON_REQUEST, - ] - assert [entry for entry in filters if entry in selection] +def test_device_combobox_get_filters(qtbot, mocked_client): + widget = create_widget(qtbot=qtbot, widget=DeviceComboBox, client=mocked_client) + + assert BECDeviceFilter.POSITIONER in widget.get_available_filters() + assert ReadoutPriority.MONITORED in widget.get_readout_priority_filters() -def test_device_input_base_properties(device_input_base): - """Test setting the properties of the device input base.""" - assert device_input_base.device_filter == [] - device_input_base.filter_to_device = True - assert device_input_base.device_filter == [BECDeviceFilter.DEVICE] - device_input_base.filter_to_positioner = True - assert device_input_base.device_filter == [BECDeviceFilter.DEVICE, BECDeviceFilter.POSITIONER] - device_input_base.filter_to_computed_signal = True - assert device_input_base.device_filter == [ - BECDeviceFilter.DEVICE, - BECDeviceFilter.POSITIONER, - BECDeviceFilter.COMPUTED_SIGNAL, - ] - device_input_base.filter_to_signal = True - assert device_input_base.device_filter == [ +def test_device_combobox_properties(qtbot, mocked_client): + widget = create_widget(qtbot=qtbot, widget=DeviceComboBox, client=mocked_client) + + widget.filter_to_device = True + widget.filter_to_positioner = True + widget.filter_to_computed_signal = True + widget.filter_to_signal = True + assert widget.device_filter == [ BECDeviceFilter.DEVICE, BECDeviceFilter.POSITIONER, BECDeviceFilter.COMPUTED_SIGNAL, BECDeviceFilter.SIGNAL, ] - assert device_input_base.readout_filter == [] - device_input_base.readout_async = True - assert device_input_base.readout_filter == [ReadoutPriority.ASYNC] - device_input_base.readout_baseline = True - assert device_input_base.readout_filter == [ReadoutPriority.ASYNC, ReadoutPriority.BASELINE] - device_input_base.readout_monitored = True - assert device_input_base.readout_filter == [ - ReadoutPriority.ASYNC, - ReadoutPriority.BASELINE, - ReadoutPriority.MONITORED, - ] - device_input_base.readout_on_request = True - assert device_input_base.readout_filter == [ - ReadoutPriority.ASYNC, - ReadoutPriority.BASELINE, - ReadoutPriority.MONITORED, - ReadoutPriority.ON_REQUEST, - ] + + widget.readout_async = True + widget.readout_baseline = True + widget.readout_monitored = True + widget.readout_on_request = True + assert ReadoutPriority.ASYNC in widget.readout_filter + assert ReadoutPriority.BASELINE in widget.readout_filter + assert ReadoutPriority.MONITORED in widget.readout_filter + assert ReadoutPriority.ON_REQUEST in widget.readout_filter def test_device_combobox_signal_class_filter(qtbot, mocked_client): - """Test device filtering via signal_class_filter on combobox.""" mocked_client.device_manager.get_bec_signals = mock.MagicMock( return_value=[ ("samx", "async_signal", {"signal_class": "AsyncSignal"}), diff --git a/tests/unit_tests/test_device_input_widgets.py b/tests/unit_tests/test_device_input_widgets.py index f394a297..5a94fb4e 100644 --- a/tests/unit_tests/test_device_input_widgets.py +++ b/tests/unit_tests/test_device_input_widgets.py @@ -1,10 +1,9 @@ import pytest from bec_lib.device import ReadoutPriority -from bec_widgets.widgets.control.device_input.base_classes.device_input_base import BECDeviceFilter -from bec_widgets.widgets.control.device_input.device_combobox.device_combobox import DeviceComboBox -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 ( + BECDeviceFilter, + DeviceComboBox, ) from .client_mocks import mocked_client @@ -37,6 +36,16 @@ def test_device_input_combobox_init(device_input_combobox): assert device_input_combobox.client is not None assert isinstance(device_input_combobox, DeviceComboBox) assert device_input_combobox.config.widget_class == "DeviceComboBox" + assert device_input_combobox.isEditable() is True + assert device_input_combobox.config.device_filter == [] + assert device_input_combobox.config.readout_filter == [ + ReadoutPriority.MONITORED.value, + ReadoutPriority.BASELINE.value, + ReadoutPriority.ASYNC.value, + ReadoutPriority.CONTINUOUS.value, + ReadoutPriority.ON_REQUEST.value, + ] + assert device_input_combobox.config.default is None assert device_input_combobox.devices == [ "samx", "samy", @@ -71,75 +80,3 @@ def test_get_device_from_input_combobox_init(device_input_combobox): current_device = device_input_combobox.get_current_device() assert current_device.name == device_text - - -@pytest.fixture -def device_input_line_edit(qtbot, mocked_client): - widget = DeviceLineEdit(client=mocked_client) - qtbot.addWidget(widget) - qtbot.waitExposed(widget) - yield widget - - -@pytest.fixture -def device_input_line_edit_with_kwargs(qtbot, mocked_client): - widget = DeviceLineEdit( - client=mocked_client, - gui_id="test_gui_id", - device_filter=[BECDeviceFilter.POSITIONER], - default="samx", - arg_name="test_arg_name", - ) - qtbot.addWidget(widget) - qtbot.waitExposed(widget) - yield widget - - -def test_device_input_line_edit_init(device_input_line_edit): - assert device_input_line_edit is not None - assert device_input_line_edit.client is not None - assert isinstance(device_input_line_edit, DeviceLineEdit) - assert device_input_line_edit.config.widget_class == "DeviceLineEdit" - assert device_input_line_edit.config.device_filter == [] - assert device_input_line_edit.config.readout_filter == [ - ReadoutPriority.MONITORED, - ReadoutPriority.BASELINE, - ReadoutPriority.ASYNC, - ReadoutPriority.CONTINUOUS, - ReadoutPriority.ON_REQUEST, - ] - assert device_input_line_edit.config.default is None - assert device_input_line_edit.devices == [ - "samx", - "samy", - "samz", - "aptrx", - "aptry", - "gauss_bpm", - "gauss_adc1", - "gauss_adc2", - "gauss_adc3", - "bpm4i", - "bpm3a", - "bpm3i", - "eiger", - "waveform1d", - "async_device", - "test", - "test_device", - ] - - -def test_device_input_line_edit_init_with_kwargs(device_input_line_edit_with_kwargs): - assert device_input_line_edit_with_kwargs.config.gui_id == "test_gui_id" - assert device_input_line_edit_with_kwargs.config.device_filter == ["Positioner"] - assert device_input_line_edit_with_kwargs.config.default == "samx" - assert device_input_line_edit_with_kwargs.config.arg_name == "test_arg_name" - - -def test_get_device_from_input_line_edit_init(device_input_line_edit): - device_input_line_edit.setText("samx") - device_text = device_input_line_edit.text() - current_device = device_input_line_edit.get_current_device() - - assert current_device.name == device_text diff --git a/tests/unit_tests/test_device_signal_input.py b/tests/unit_tests/test_device_signal_input.py index fbeb4551..e7a50b43 100644 --- a/tests/unit_tests/test_device_signal_input.py +++ b/tests/unit_tests/test_device_signal_input.py @@ -2,54 +2,28 @@ from unittest import mock import pytest from bec_lib.device import Signal -from qtpy.QtWidgets import QWidget from bec_widgets.utils.ophyd_kind_util import Kind -from bec_widgets.widgets.control.device_input.base_classes.device_input_base import BECDeviceFilter -from bec_widgets.widgets.control.device_input.base_classes.device_signal_input_base import ( - DeviceSignalInputBase, +from bec_widgets.widgets.control.device_input.device_combobox.device_combobox import ( + BECDeviceFilter, + DeviceComboBox, ) -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.control.device_input.signal_line_edit.signal_line_edit import ( - SignalLineEdit, -) from .client_mocks import mocked_client from .conftest import create_widget class FakeSignal(Signal): - """Fake signal to test the DeviceSignalInputBase.""" - - -class DeviceInputWidget(DeviceSignalInputBase, QWidget): - """Thin wrapper around DeviceInputBase to make it a QWidget""" - - -@pytest.fixture -def device_signal_base(qtbot, mocked_client): - """Fixture with mocked FilterIO and WidgetIO""" - with mock.patch("bec_widgets.utils.filter_io.FilterIO.set_selection"): - with mock.patch("bec_widgets.utils.widget_io.WidgetIO.set_value"): - widget = create_widget(qtbot=qtbot, widget=DeviceInputWidget, client=mocked_client) - yield widget + """Fake signal used by SignalComboBox tests.""" @pytest.fixture def device_signal_combobox(qtbot, mocked_client): - """Fixture with mocked FilterIO and WidgetIO""" widget = create_widget(qtbot=qtbot, widget=SignalComboBox, client=mocked_client) yield widget -@pytest.fixture -def device_signal_line_edit(qtbot, mocked_client): - """Fixture with mocked FilterIO and WidgetIO""" - widget = create_widget(qtbot=qtbot, widget=SignalLineEdit, client=mocked_client) - yield widget - - @pytest.fixture def test_device_signal_combo(qtbot, mocked_client): """Fixture to create a SignalComboBox widget and a DeviceInputWidget widget""" @@ -63,34 +37,28 @@ def test_device_signal_combo(qtbot, mocked_client): yield input, signal -def test_device_signal_base_init(device_signal_base): - """Test if the DeviceSignalInputBase is initialized correctly""" - assert device_signal_base._device is None - assert device_signal_base._signal_filter == set() - assert device_signal_base._signals == [] - assert device_signal_base._hinted_signals == [] - assert device_signal_base._normal_signals == [] - assert device_signal_base._config_signals == [] +def test_signal_combobox_init(device_signal_combobox): + assert device_signal_combobox._device is None + assert device_signal_combobox._signal_filter == {Kind.config, Kind.normal, Kind.hinted} + assert device_signal_combobox._signals == [] + assert device_signal_combobox._hinted_signals == [] + assert device_signal_combobox._normal_signals == [] + assert device_signal_combobox._config_signals == [] -def test_device_signal_qproperties(device_signal_base): - """Test if the DeviceSignalInputBase has the correct QProperties""" - assert device_signal_base._signal_filter == set() - device_signal_base.include_config_signals = False - device_signal_base.include_normal_signals = False - assert device_signal_base._signal_filter == set() - device_signal_base.include_config_signals = True - assert device_signal_base._signal_filter == {Kind.config} - device_signal_base.include_normal_signals = True - assert device_signal_base._signal_filter == {Kind.config, Kind.normal} - device_signal_base.include_hinted_signals = True - assert device_signal_base._signal_filter == {Kind.config, Kind.normal, Kind.hinted} - device_signal_base.include_hinted_signals = True - assert device_signal_base._signal_filter == {Kind.config, Kind.normal, Kind.hinted} - device_signal_base.include_hinted_signals = True - assert device_signal_base._signal_filter == {Kind.config, Kind.normal, Kind.hinted} - device_signal_base.include_hinted_signals = False - assert device_signal_base._signal_filter == {Kind.config, Kind.normal} +def test_signal_combobox_qproperties(device_signal_combobox): + device_signal_combobox.include_config_signals = False + device_signal_combobox.include_normal_signals = False + device_signal_combobox.include_hinted_signals = False + assert device_signal_combobox._signal_filter == set() + device_signal_combobox.include_config_signals = True + assert device_signal_combobox._signal_filter == {Kind.config} + device_signal_combobox.include_normal_signals = True + assert device_signal_combobox._signal_filter == {Kind.config, Kind.normal} + device_signal_combobox.include_hinted_signals = True + assert device_signal_combobox._signal_filter == {Kind.config, Kind.normal, Kind.hinted} + device_signal_combobox.include_hinted_signals = False + assert device_signal_combobox._signal_filter == {Kind.config, Kind.normal} def test_signal_combobox(qtbot, device_signal_combobox): @@ -128,26 +96,9 @@ def test_signal_combobox(qtbot, device_signal_combobox): assert device_signal_combobox._hinted_signals == [("fake_signal", {})] -def test_signal_lineedit(device_signal_line_edit): - """Test the signal_combobox""" - - assert device_signal_line_edit._signals == [] - device_signal_line_edit.include_normal_signals = True - device_signal_line_edit.include_hinted_signals = True - device_signal_line_edit.include_config_signals = True - assert device_signal_line_edit.signals == [] - device_signal_line_edit.set_device("samx") - assert device_signal_line_edit.signals == ["readback", "setpoint", "velocity"] - device_signal_line_edit.set_signal("readback") - assert device_signal_line_edit.text() == "readback" - assert device_signal_line_edit._is_valid_input is True - device_signal_line_edit.setText("invalid") - assert device_signal_line_edit._is_valid_input is False - - def test_device_signal_input_base_cleanup(qtbot, mocked_client): with mock.patch.object(mocked_client.callbacks, "remove"): - widget = DeviceInputWidget(client=mocked_client) + widget = SignalComboBox(client=mocked_client) widget.close() widget.deleteLater() diff --git a/tests/unit_tests/test_filter_io.py b/tests/unit_tests/test_filter_io.py index e5087124..9bd0b7c4 100644 --- a/tests/unit_tests/test_filter_io.py +++ b/tests/unit_tests/test_filter_io.py @@ -1,8 +1,9 @@ -import pytest +from qtpy.QtWidgets import QComboBox -from bec_widgets.utils.filter_io import FilterIO -from bec_widgets.widgets.control.device_input.device_line_edit.device_line_edit import ( - DeviceLineEdit, +from bec_widgets.utils.filter_io import ( + combobox_contains_text, + get_bec_signals_for_classes, + replace_combobox_items, ) from bec_widgets.widgets.dap.dap_combo_box.dap_combo_box import DapComboBox @@ -10,65 +11,38 @@ from .client_mocks import mocked_client from .conftest import create_widget -@pytest.fixture(scope="function") -def dap_mock(qtbot, mocked_client): - """Fixture for QLineEdit widget""" - models = ["GaussianModel", "LorentzModel", "SineModel"] - mocked_client.dap._available_dap_plugins.keys.return_value = models +def test_replace_combobox_items(qtbot, mocked_client): widget = create_widget(qtbot, DapComboBox, client=mocked_client) - return widget + + replace_combobox_items(widget, ["testA", ("testB", {"payload": True})]) + + assert widget.count() == 2 + assert widget.itemText(0) == "testA" + assert widget.itemText(1) == "testB" + assert widget.itemData(1) == {"payload": True} + assert combobox_contains_text(widget, "testA") is True + assert combobox_contains_text(widget, "missing") is False -@pytest.fixture(scope="function") -def line_edit_mock(qtbot, mocked_client): - """Fixture for QLineEdit widget""" - widget = create_widget(qtbot, DeviceLineEdit, client=mocked_client) - return widget - - -def test_set_selection_combo_box(dap_mock): - """Test set selection for QComboBox using DapComboBox""" - assert dap_mock.fit_model_combobox.count() == 3 - FilterIO.set_selection(dap_mock.fit_model_combobox, selection=["testA", "testB"]) - assert dap_mock.fit_model_combobox.count() == 2 - assert FilterIO.check_input(widget=dap_mock.fit_model_combobox, text="testA") is True - - -def test_set_selection_line_edit(line_edit_mock): - """Test set selection for QComboBox using DapComboBox""" - FilterIO.set_selection(line_edit_mock, selection=["testA", "testB"]) - assert line_edit_mock.completer.model().rowCount() == 2 - model = line_edit_mock.completer.model() - model_data = [model.data(model.index(i)) for i in range(model.rowCount())] - assert model_data == ["testA", "testB"] - assert FilterIO.check_input(widget=line_edit_mock, text="testA") is True - FilterIO.set_selection(line_edit_mock, selection=["testC"]) - assert FilterIO.check_input(widget=line_edit_mock, text="testA") is False - assert FilterIO.check_input(widget=line_edit_mock, text="testC") is True - - -def test_update_with_signal_class_combo_box_ndim_filter(dap_mock, mocked_client): +def test_get_bec_signals_for_classes_ndim_filter(mocked_client): signals = [ ("dev1", "sig1", {"describe": {"signal_info": {"ndim": 1}}}), ("dev1", "sig2", {"describe": {"signal_info": {"ndim": 2}}}), ] mocked_client.device_manager.get_bec_signals = lambda _filters: signals - out = FilterIO.update_with_signal_class( - widget=dap_mock.fit_model_combobox, - signal_class_filter=["AsyncSignal"], - client=mocked_client, - ndim_filter=1, + + out = get_bec_signals_for_classes( + client=mocked_client, signal_class_filter=["AsyncSignal"], ndim_filter=1 ) + assert out == [("dev1", "sig1", {"describe": {"signal_info": {"ndim": 1}}})] -def test_update_with_signal_class_line_edit_passthrough(line_edit_mock, mocked_client): - signals = [("dev1", "sig1", {"describe": {"signal_info": {"ndim": 1}}})] - mocked_client.device_manager.get_bec_signals = lambda _filters: signals - out = FilterIO.update_with_signal_class( - widget=line_edit_mock, - signal_class_filter=["AsyncSignal"], - client=mocked_client, - ndim_filter=1, - ) - assert out == signals +def test_replace_combobox_items_empty(qtbot): + widget = QComboBox() + qtbot.addWidget(widget) + widget.addItem("old") + + replace_combobox_items(widget, []) + + assert widget.count() == 0 diff --git a/tests/unit_tests/test_positioner_box.py b/tests/unit_tests/test_positioner_box.py index f74b1bb1..39741e0e 100644 --- a/tests/unit_tests/test_positioner_box.py +++ b/tests/unit_tests/test_positioner_box.py @@ -12,9 +12,7 @@ from bec_widgets.widgets.control.device_control.positioner_box import ( PositionerBox, PositionerControlLine, ) -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 .client_mocks import mocked_client from .conftest import create_widget @@ -164,8 +162,8 @@ def test_positioner_box_open_dialog_selection(qtbot, positioner_box): # pylint: disable=protected-access assert positioner_box._dialog is not None qtbot.waitUntil(lambda: positioner_box._dialog.isVisible() is True, timeout=1000) - line_edit = positioner_box._dialog.findChild(DeviceLineEdit) - line_edit.setText("samy") + line_edit = positioner_box._dialog.findChild(DeviceComboBox) + line_edit.setCurrentText("samy") close_button = positioner_box._dialog.findChild(QPushButton) assert close_button.text() == "Close" qtbot.mouseClick(close_button, Qt.LeftButton) diff --git a/tests/unit_tests/test_signal_label.py b/tests/unit_tests/test_signal_label.py index 38df89b3..de0c98fb 100644 --- a/tests/unit_tests/test_signal_label.py +++ b/tests/unit_tests/test_signal_label.py @@ -6,8 +6,8 @@ import pytest from qtpy import QtCore from qtpy.QtWidgets import QDialogButtonBox, QLabel -from bec_widgets.widgets.control.device_input.base_classes.device_signal_input_base import ( - DeviceSignalInputBaseConfig, +from bec_widgets.widgets.control.device_input.signal_combobox.signal_combobox import ( + SignalComboBoxConfig, ) from bec_widgets.widgets.utility.signal_label.signal_label import ChoiceDialog, SignalLabel @@ -61,7 +61,7 @@ SAMX_INFO_DICT = { @pytest.fixture def signal_label(qtbot, mocked_client: MagicMock): with patch.object(mocked_client.device_manager.devices.samx, "_info", SAMX_INFO_DICT): - config = DeviceSignalInputBaseConfig(device="samx", default="samx") + config = SignalComboBoxConfig(device="samx", default="samx") widget = SignalLabel( config=config, custom_label="Test Label", custom_units="m/s", client=mocked_client ) @@ -149,7 +149,8 @@ def test_choose_signal_dialog_sends_choices(signal_label: SignalLabel, qtbot): dialog = signal_label.show_choice_dialog() qtbot.waitUntil(dialog.button_box.button(QDialogButtonBox.Ok).isVisible, timeout=500) dialog._device_field.dev["test device"] = MagicMock() - dialog._device_field.setText("test device") + dialog._device_field.devices = ["test device"] + dialog._device_field.setCurrentText("test device") dialog._signal_field._signals = [("test signal", {"component_name": "test signal"})] dialog._signal_field.addItem("test signal") dialog._signal_field.setCurrentIndex(0) @@ -162,7 +163,8 @@ def test_dialog_handler_updates_devices(signal_label: SignalLabel, qtbot): dialog = signal_label.show_choice_dialog() qtbot.waitUntil(dialog.button_box.button(QDialogButtonBox.Ok).isVisible, timeout=500) dialog._device_field.dev["flux_capacitor"] = MagicMock() - dialog._device_field.setText("flux_capacitor") + dialog._device_field.devices = ["flux_capacitor"] + dialog._device_field.setCurrentText("flux_capacitor") dialog._signal_field._signals = [("spin_speed", {"component_name": "spin_speed"})] dialog._signal_field.addItem("spin_speed") dialog._signal_field.setCurrentIndex(0) @@ -176,7 +178,7 @@ def test_choose_signal_dialog_invalid_device(signal_label: SignalLabel, qtbot): signal_label._process_dialog = MagicMock() dialog = signal_label.show_choice_dialog() qtbot.waitUntil(dialog.button_box.button(QDialogButtonBox.Ok).isVisible, timeout=500) - dialog._device_field.setText("invalid device") + dialog._device_field.setCurrentText("invalid device") dialog._signal_field.addItem("test signal") dialog._signal_field.setCurrentIndex(0) qtbot.mouseClick(dialog.button_box.button(QDialogButtonBox.Ok), QtCore.Qt.LeftButton) @@ -206,7 +208,8 @@ def test_dialog_has_signals(signal_label: SignalLabel, qtbot): "signals": {"signal 1": {"kind_str": "hinted"}, "signal 2": {"kind_str": "normal"}} } - dialog._device_field.setText("test device") + dialog._device_field.devices = ["test device"] + dialog._device_field.setCurrentText("test device") assert dialog._signal_field.count() == 2 # the actual signal and the category label assert dialog._signal_field.currentText() == "signal 1"