From b38d6dc54946f7f93c3b71af216cfe0c1bd8fe27 Mon Sep 17 00:00:00 2001 From: appel_c Date: Fri, 9 Jan 2026 13:16:24 +0100 Subject: [PATCH] refactor(busy-loader): refactor busy loader to use custom widget --- .../device_form_dialog.py | 10 +- .../device_manager_display_widget.py | 229 +++++++++++++++--- bec_widgets/cli/client.py | 74 ++++++ bec_widgets/utils/bec_widget.py | 144 +++++++---- bec_widgets/utils/busy_loader.py | 197 ++++++++++----- .../components/device_table/device_table.py | 154 ++++++++++-- .../ophyd_validation/ophyd_validation.py | 55 +++-- .../device_initialization_progress_bar.py | 5 +- ...vice_initialization_progress_bar.pyproject | 1 + ...vice_initialization_progress_bar_plugin.py | 59 +++++ ...ster_device_initialization_progress_bar.py | 17 ++ .../device_item/config_communicator.py | 9 + tests/unit_tests/test_busy_loader.py | 53 ++-- ...test_device_initialization_progress_bar.py | 8 +- .../test_device_manager_components.py | 80 +++++- tests/unit_tests/test_device_manager_view.py | 34 +-- 16 files changed, 850 insertions(+), 279 deletions(-) create mode 100644 bec_widgets/widgets/progress/device_initialization_progress_bar/device_initialization_progress_bar.pyproject create mode 100644 bec_widgets/widgets/progress/device_initialization_progress_bar/device_initialization_progress_bar_plugin.py create mode 100644 bec_widgets/widgets/progress/device_initialization_progress_bar/register_device_initialization_progress_bar.py diff --git a/bec_widgets/applications/views/device_manager_view/device_manager_dialogs/device_form_dialog.py b/bec_widgets/applications/views/device_manager_view/device_manager_dialogs/device_form_dialog.py index 9d57daf0..167cfbff 100644 --- a/bec_widgets/applications/views/device_manager_view/device_manager_dialogs/device_form_dialog.py +++ b/bec_widgets/applications/views/device_manager_view/device_manager_dialogs/device_form_dialog.py @@ -6,6 +6,7 @@ from bec_lib.atlas_models import Device as DeviceModel from bec_lib.logger import bec_logger from ophyd_devices.interfaces.device_config_templates.ophyd_templates import OPHYD_DEVICE_TEMPLATES from qtpy import QtCore, QtWidgets +from zmq.devices import Device from bec_widgets.utils.error_popups import SafeSlot from bec_widgets.widgets.control.device_manager.components import OphydValidation @@ -56,8 +57,6 @@ class DeviceManagerOphydValidationDialog(QtWidgets.QDialog): if device_name: self.device_manager_ophyd_test.add_device_to_keep_visible_after_validation(device_name) - self.device_manager_ophyd_test.change_device_configs([config], True, True) - # Dialog Buttons: equal size, stacked horizontally button_box = QtWidgets.QDialogButtonBox(QtWidgets.QDialogButtonBox.StandardButton.Close) for button in button_box.buttons(): @@ -71,6 +70,9 @@ class DeviceManagerOphydValidationDialog(QtWidgets.QDialog): self._resize_dialog() self.finished.connect(self._finished) + # Add and test device config + self.device_manager_ophyd_test.change_device_configs([config], added=True, connect=True) + def _resize_dialog(self): """Resize the dialog based on the screen size.""" app: QtCore.QCoreApplication = QtWidgets.QApplication.instance() @@ -285,7 +287,7 @@ class DeviceFormDialog(QtWidgets.QDialog): The dialog will be modal and prevent user interaction until validation is complete. """ wait_dialog = QtWidgets.QProgressDialog( - "Validating config… please wait", None, 0, 0, parent=self + "Validating config... please wait", None, 0, 0, parent=self ) wait_dialog.setWindowModality(QtCore.Qt.WindowModality.ApplicationModal) wait_dialog.setCancelButton(None) @@ -368,7 +370,7 @@ class DeviceFormDialog(QtWidgets.QDialog): if not validate_name(config.get("name", "")): msg_box = self._create_warning_message_box( "Invalid Device Name", - f"Device is invalid, can not be empty with spaces. Please provide a valid name. {config.get('name', '')!r} ", + f"Device is invalid, cannot be empty or contain spaces. Please provide a valid name. {config.get('name', '')!r}", ) msg_box.exec() return diff --git a/bec_widgets/applications/views/device_manager_view/device_manager_display_widget.py b/bec_widgets/applications/views/device_manager_view/device_manager_display_widget.py index 8c388664..d90e72d0 100644 --- a/bec_widgets/applications/views/device_manager_view/device_manager_display_widget.py +++ b/bec_widgets/applications/views/device_manager_view/device_manager_display_widget.py @@ -2,7 +2,7 @@ from __future__ import annotations import os from functools import partial -from typing import List, Literal, get_args +from typing import TYPE_CHECKING, List, Literal, get_args import yaml from bec_lib import config_helper @@ -11,9 +11,17 @@ from bec_lib.file_utils import DeviceConfigWriter from bec_lib.logger import bec_logger from bec_lib.messages import ConfigAction from bec_lib.plugin_helper import plugin_package_name, plugin_repo_path -from bec_qthemes import apply_theme -from qtpy.QtCore import QMetaObject, QThreadPool, Signal -from qtpy.QtWidgets import QFileDialog, QMessageBox, QTextEdit, QVBoxLayout, QWidget +from bec_qthemes import apply_theme, material_icon +from qtpy.QtCore import QMetaObject, Qt, QThreadPool, Signal +from qtpy.QtWidgets import ( + QApplication, + QFileDialog, + QMessageBox, + QPushButton, + QTextEdit, + QVBoxLayout, + QWidget, +) from bec_widgets.applications.views.device_manager_view.device_manager_dialogs import ( ConfigChoiceDialog, @@ -22,6 +30,7 @@ from bec_widgets.applications.views.device_manager_view.device_manager_dialogs i from bec_widgets.applications.views.device_manager_view.device_manager_dialogs.upload_redis_dialog import ( UploadRedisDialog, ) +from bec_widgets.utils.colors import get_accent_colors from bec_widgets.utils.error_popups import SafeSlot from bec_widgets.utils.toolbars.actions import MaterialIconAction from bec_widgets.utils.toolbars.bundles import ToolbarBundle @@ -38,9 +47,16 @@ from bec_widgets.widgets.control.device_manager.components.ophyd_validation.ophy ConfigStatus, ConnectionStatus, ) +from bec_widgets.widgets.progress.device_initialization_progress_bar.device_initialization_progress_bar import ( + DeviceInitializationProgressBar, +) from bec_widgets.widgets.services.device_browser.device_item.config_communicator import ( CommunicateConfigAction, ) +from bec_widgets.widgets.utility.spinner.spinner import SpinnerWidget + +if TYPE_CHECKING: # pragma: no cover + from bec_lib.client import BECClient logger = bec_logger.logger @@ -51,6 +67,74 @@ _yes_no_question = partial( ) +class CustomBusyWidget(QWidget): + """Custom busy widget to show during device config upload.""" + + cancel_requested = Signal() + + def __init__(self, parent=None, client: BECClient | None = None): + super().__init__(parent=parent) + + # Widgets + progress = DeviceInitializationProgressBar(parent=self, client=client) + + # Spinner + spinner = SpinnerWidget(parent=self) + scale = self._ui_scale() + spinner_size = int(scale * 0.12) if scale else 1 + spinner_size = max(32, min(spinner_size, 64)) + spinner.setFixedSize(spinner_size, spinner_size) + + # Cancel button + cancel_button = QPushButton("Cancel Upload", parent=self) + cancel_button.setIcon(material_icon("cancel")) + cancel_button.clicked.connect(self.cancel_requested.emit) + button_height = int(spinner_size * 0.9) + button_height = max(36, min(button_height, 72)) + aspect_ratio = 3.8 # width / height, visually stable for text buttons + button_width = int(button_height * aspect_ratio) + cancel_button.setFixedSize(button_width, button_height) + color = get_accent_colors() + cancel_button.setStyleSheet( + f""" + QPushButton {{ + background-color: {color.emergency.name()}; + color: white; + font-weight: 600; + border-radius: 6px; + }} + """ + ) + + # Layout + content_layout = QVBoxLayout(self) + content_layout.setContentsMargins(24, 24, 24, 24) + content_layout.setSpacing(16) + content_layout.addStretch() + content_layout.addWidget(spinner, 0, Qt.AlignmentFlag.AlignHCenter) + content_layout.addWidget(progress, 0, Qt.AlignmentFlag.AlignHCenter) + content_layout.addStretch() + content_layout.addWidget(cancel_button, 0, Qt.AlignmentFlag.AlignHCenter) + + def _ui_scale(self) -> int: + parent = self.parent() + if not parent: + return 0 + return min(parent.width(), parent.height()) + + def showEvent(self, event): + """Show event to start the spinner.""" + super().showEvent(event) + for child in self.findChildren(SpinnerWidget): + child.start() + + def hideEvent(self, event): + """Hide event to stop the spinner.""" + super().hideEvent(event) + for child in self.findChildren(SpinnerWidget): + child.stop() + + class DeviceManagerDisplayWidget(DockAreaWidget): """Device Manager main display widget. This contains all sub-widgets and the toolbar.""" @@ -61,13 +145,22 @@ class DeviceManagerDisplayWidget(DockAreaWidget): def __init__(self, parent=None, *args, **kwargs): super().__init__(parent=parent, variant="compact", *args, **kwargs) + # State variable for config upload + self._config_upload_active: bool = False + # Push to Redis dialog self._upload_redis_dialog: UploadRedisDialog | None = None self._dialog_validation_connection: QMetaObject.Connection | None = None + # NOTE: We need here a seperate config helper instance to avoid conflicts with + # other communications to REDIS as uploading a config through a CommunicationConfigAction + # will block if we use the config_helper from self.client.config._config_helper self._config_helper = config_helper.ConfigHelper(self.client.connector) self._shared_selection = SharedSelectionSignal() + # Custom upload widget for busy overlay + self._custom_overlay_widget: QWidget | None = None + # Device Table View widget self.device_table_view = DeviceTable(self) @@ -125,6 +218,43 @@ class DeviceManagerDisplayWidget(DockAreaWidget): # Build dock layout using shared helpers self._build_docks() + logger.info("Connecting application about to quit signal to device manager view...") + QApplication.instance().aboutToQuit.connect(self._about_to_quit_handler) + + ############################## + ### Custom set busy widget ### + ############################## + + def create_busy_state_widget(self) -> QWidget: + """Create a custom busy state widget for uploading device configurations.""" + widget = CustomBusyWidget(parent=self, client=self.client) + widget.cancel_requested.connect(self._cancel_device_config_upload) + return widget + + ################################ + ### Application quit handler ### + ################################ + + @SafeSlot() + def _about_to_quit_handler(self): + """Handle application about to quit event. If config upload is active, cancel it.""" + logger.info("Application is quitting, checking for active config upload...") + if self._config_upload_active: + logger.info("Application is quitting, cancelling active config upload...") + self._config_helper.send_config_request( + action="cancel", config=None, wait_for_response=True, timeout_s=10 + ) + logger.info("Config upload cancelled.") + + def _set_busy_wrapper(self, enabled: bool): + """Thin wrapper around set_busy to flip the state variable.""" + self._config_upload_active = enabled + self.set_busy(enabled=enabled) + + ############################## + ### Toolbar and Dock setup ### + ############################## + def _add_toolbar(self): self.toolbar = ModularToolBar(self) @@ -306,6 +436,10 @@ class DeviceManagerDisplayWidget(DockAreaWidget): # Add load config from plugin dir self.toolbar.add_bundle(table_bundle) + ####################### + ### Action Handlers ### + ####################### + @SafeSlot() @SafeSlot(bool) def _run_validate_connection(self, connect: bool = True): @@ -432,10 +566,8 @@ class DeviceManagerDisplayWidget(DockAreaWidget): "Do you really want to flush the current config in BEC Server?", ) if reply == QMessageBox.StandardButton.Yes: - self.set_busy(enabled=True, text="Flushing configuration in BEC Server...") self.client.config.reset_config() logger.info("Successfully flushed configuration in BEC Server.") - self.set_busy(enabled=False) # Check if config is in sync, enable load redis button self.device_table_view.device_config_in_sync_with_redis.emit( self.device_table_view._is_config_in_sync_with_redis() @@ -511,12 +643,49 @@ class DeviceManagerDisplayWidget(DockAreaWidget): comm.signals.done.connect(self._handle_push_complete_to_communicator) comm.signals.error.connect(self._handle_exception_from_communicator) threadpool.start(comm) - self.set_busy(enabled=True, text="Uploading configuration to BEC Server...") + self._set_busy_wrapper(enabled=True) + + def _cancel_device_config_upload(self): + """Cancel the device configuration upload process.""" + threadpool = QThreadPool.globalInstance() + comm = CommunicateConfigAction(self._config_helper, None, {}, "cancel") + # Cancelling will raise an exception in the communicator, so we connect to the failure handler + comm.signals.error.connect(self._handle_cancel_config_upload_failed) + threadpool.start(comm) + + def _handle_cancel_config_upload_failed(self, exception: Exception): + """Handle failure to cancel the config upload.""" + QMessageBox.critical(self, "Error Cancelling Upload", f"{str(exception)}") + self._set_busy_wrapper(enabled=False) + + validation_results = self.device_table_view.get_validation_results() + devices_to_update = [] + for config, config_status, connection_status in validation_results.values(): + devices_to_update.append( + (config, config_status, ConnectionStatus.UNKNOWN.value, "Upload Cancelled") + ) + # Rerun validation of all devices after cancellation + self.device_table_view.update_multiple_device_validations(devices_to_update) + self.ophyd_test_view.change_device_configs( + [cfg for cfg, _, _, _ in devices_to_update], added=True, skip_validation=False + ) + # Config is in sync with BEC, so we update the state + self.device_table_view.device_config_in_sync_with_redis.emit(False) + + # Cleanup custom overlay widget + if self._custom_overlay_widget is not None: + self._custom_overlay_widget.close() + self._custom_overlay_widget.deleteLater() + self._custom_overlay_widget = None def _handle_push_complete_to_communicator(self): """Handle completion of the config push to Redis.""" - self.set_busy(enabled=False) - self._update_validation_icons_after_upload() + self._set_busy_wrapper(enabled=False) + # Cleanup custom overlay widget + if self._custom_overlay_widget is not None: + self._custom_overlay_widget.close() + self._custom_overlay_widget.deleteLater() + self._custom_overlay_widget = None def _handle_exception_from_communicator(self, exception: Exception): """Handle exceptions from the config communicator.""" @@ -525,29 +694,11 @@ class DeviceManagerDisplayWidget(DockAreaWidget): "Error Uploading Config", f"An error occurred while uploading the configuration to BEC Server:\n{str(exception)}", ) - self.set_busy(enabled=False) - self._update_validation_icons_after_upload() - - def _update_validation_icons_after_upload(self): - """Update validation icons after uploading config to Redis.""" - if self.client.device_manager is None: - return - device_names_in_session = list(self.client.device_manager.devices.keys()) - validation_results = self.device_table_view.get_validation_results() - devices_to_update = [] - for config, config_status, connection_status in validation_results.values(): - if config["name"] in device_names_in_session: - devices_to_update.append( - (config, config_status, ConnectionStatus.CONNECTED.value, "") - ) - # Update validation status in device table view - self.device_table_view.update_multiple_device_validations(devices_to_update) - # Remove devices from ophyd validation view - self.ophyd_test_view.change_device_configs( - [cfg for cfg, _, _, _ in devices_to_update], added=False, skip_validation=True - ) - # Config is in sync with BEC, so we update the state - self.device_table_view.device_config_in_sync_with_redis.emit(True) + self._set_busy_wrapper(enabled=False) + if self._custom_overlay_widget is not None: + self._custom_overlay_widget.close() + self._custom_overlay_widget.deleteLater() + self._custom_overlay_widget = None @SafeSlot() def _save_to_disk_action(self): @@ -613,8 +764,7 @@ class DeviceManagerDisplayWidget(DockAreaWidget): ): if old_device_name and old_device_name != data.get("name", ""): self.device_table_view.remove_device(old_device_name) - self.device_table_view.update_device_configs([data], skip_validation=True) - self.device_table_view.update_device_validation(data, config_status, connection_status, msg) + self._add_to_table_from_dialog(data, config_status, connection_status, msg, old_device_name) @SafeSlot(dict, int, int, str, str) def _add_to_table_from_dialog( @@ -625,8 +775,15 @@ class DeviceManagerDisplayWidget(DockAreaWidget): msg: str, old_device_name: str = "", ): - self.device_table_view.add_device_configs([data], skip_validation=True) - self.device_table_view.update_device_validation(data, config_status, connection_status, msg) + if connection_status == ConnectionStatus.UNKNOWN.value: + self.device_table_view.update_device_configs([data], skip_validation=False) + else: # Connection status was tested in dialog + # If device is connected, we remove it from the ophyd validation view + self.device_table_view.update_device_configs([data], skip_validation=True) + # Update validation status in device table view and ophyd validation view + self.ophyd_test_view._on_device_test_completed( + data, config_status, connection_status, msg + ) @SafeSlot() def _remove_device_action(self): diff --git a/bec_widgets/cli/client.py b/bec_widgets/cli/client.py index d5d36267..eef2ac49 100644 --- a/bec_widgets/cli/client.py +++ b/bec_widgets/cli/client.py @@ -36,6 +36,7 @@ _Widgets = { "DarkModeButton": "DarkModeButton", "DeviceBrowser": "DeviceBrowser", "DeviceComboBox": "DeviceComboBox", + "DeviceInitializationProgressBar": "DeviceInitializationProgressBar", "DeviceLineEdit": "DeviceLineEdit", "Heatmap": "Heatmap", "Image": "Image", @@ -1071,6 +1072,79 @@ class DeviceComboBox(RPCBase): """ +class DeviceInitializationProgressBar(RPCBase): + """A progress bar that displays the progress of device initialization.""" + + @rpc_call + def set_value(self, value): + """ + Set the value of the progress bar. + + Args: + value (float): The value to set. + """ + + @rpc_call + def set_maximum(self, maximum: float): + """ + Set the maximum value of the progress bar. + + Args: + maximum (float): The maximum value. + """ + + @rpc_call + def set_minimum(self, minimum: float): + """ + Set the minimum value of the progress bar. + + Args: + minimum (float): The minimum value. + """ + + @property + @rpc_call + def label_template(self): + """ + The template for the center label. Use $value, $maximum, and $percentage to insert the values. + + Examples: + >>> progressbar.label_template = "$value / $maximum - $percentage %" + >>> progressbar.label_template = "$value / $percentage %" + """ + + @label_template.setter + @rpc_call + def label_template(self): + """ + The template for the center label. Use $value, $maximum, and $percentage to insert the values. + + Examples: + >>> progressbar.label_template = "$value / $maximum - $percentage %" + >>> progressbar.label_template = "$value / $percentage %" + """ + + @property + @rpc_call + def state(self): + """ + None + """ + + @state.setter + @rpc_call + def state(self): + """ + None + """ + + @rpc_call + def _get_label(self) -> str: + """ + Return the label text. mostly used for testing rpc. + """ + + class DeviceInputBase(RPCBase): """Mixin base class for device input widgets.""" diff --git a/bec_widgets/utils/bec_widget.py b/bec_widgets/utils/bec_widget.py index 5b6bb392..79553a29 100644 --- a/bec_widgets/utils/bec_widget.py +++ b/bec_widgets/utils/bec_widget.py @@ -6,8 +6,8 @@ from typing import TYPE_CHECKING import shiboken6 from bec_lib.logger import bec_logger from qtpy.QtCore import QBuffer, QByteArray, QIODevice, QObject, Qt -from qtpy.QtGui import QPixmap -from qtpy.QtWidgets import QApplication, QFileDialog, QWidget +from qtpy.QtGui import QFont, QPixmap +from qtpy.QtWidgets import QApplication, QFileDialog, QLabel, QVBoxLayout, QWidget import bec_widgets.widgets.containers.qt_ads as QtAds from bec_widgets.cli.rpc.rpc_register import RPCRegister @@ -15,8 +15,10 @@ from bec_widgets.utils.bec_connector import BECConnector, ConnectionConfig from bec_widgets.utils.error_popups import SafeConnect, SafeSlot from bec_widgets.utils.rpc_decorator import rpc_timeout from bec_widgets.utils.widget_io import WidgetHierarchy +from bec_widgets.widgets.utility.spinner.spinner import SpinnerWidget if TYPE_CHECKING: # pragma: no cover + from bec_widgets.utils.busy_loader import BusyLoaderOverlay from bec_widgets.widgets.containers.dock import BECDock logger = bec_logger.logger @@ -38,7 +40,6 @@ class BECWidget(BECConnector): gui_id: str | None = None, theme_update: bool = False, start_busy: bool = False, - busy_text: str = "Loading…", **kwargs, ): """ @@ -65,18 +66,14 @@ class BECWidget(BECConnector): self._connect_to_theme_change() # Initialize optional busy loader overlay utility (lazy by default) - self._busy_overlay = None + self._busy_overlay: "BusyLoaderOverlay" | None = None + self._busy_state_widget: QWidget | None = None + self._loading = False if start_busy and isinstance(self, QWidget): - try: - overlay = self._ensure_busy_overlay(busy_text=busy_text) - if overlay is not None: - overlay.setGeometry(self.rect()) - overlay.raise_() - overlay.show() - self._loading = True - except Exception as exc: - logger.debug(f"Busy loader init skipped: {exc}") + self._busy_overlay = self._install_busy_loader() + self._adjust_busy_overlay() + self._loading = True def _connect_to_theme_change(self): """Connect to the theme change signal.""" @@ -97,8 +94,70 @@ class BECWidget(BECConnector): self._update_overlay_theme(theme) self.apply_theme(theme) - def _ensure_busy_overlay(self, *, busy_text: str = "Loading…"): - """Create the busy overlay on demand and cache it in _busy_overlay. + def create_busy_state_widget(self) -> QWidget: + """ + Method to create a custom busy state widget to be shown in the busy overlay. + Child classes should overrid this method to provide a custom widget if desired. + + Returns: + QWidget: The custom busy state widget. + + NOTE: + The implementation here is a SpinnerWidget with a "Loading..." label. This is the default + busy state widget for all BECWidgets. However, child classes with specific needs for the + busy state can easily overrite this method to provide a custom widget. The signature of + the method must be preserved to ensure compatibility with the busy overlay system. If + the widget provides a 'cleanup' method, it will be called when the overlay is cleaned up. + + The widget may connect to the _busy_overlay signals foreground_color_changed and + scrim_color_changed to update its colors when the theme changes. + """ + + # Widget + class BusyStateWidget(QWidget): + def __init__(self, parent=None): + super().__init__(parent) + # label + label = QLabel("Loading...", self) + label.setAlignment(Qt.AlignHCenter | Qt.AlignVCenter) + f = QFont(label.font()) + f.setBold(True) + f.setPointSize(f.pointSize() + 1) + label.setFont(f) + + # spinner + spinner = SpinnerWidget(self) + spinner.setFixedSize(42, 42) + + # Layout + lay = QVBoxLayout(self) + lay.setContentsMargins(24, 24, 24, 24) + lay.setSpacing(10) + lay.addStretch(1) + lay.addWidget(spinner, 0, Qt.AlignHCenter) + lay.addWidget(label, 0, Qt.AlignHCenter) + lay.addStretch(1) + self.setLayout(lay) + + def showEvent(self, event): + """Show event to start the spinner.""" + super().showEvent(event) + for child in self.findChildren(SpinnerWidget): + child.start() + + def hideEvent(self, event): + """Hide event to stop the spinner.""" + super().hideEvent(event) + for child in self.findChildren(SpinnerWidget): + child.stop() + + widget = BusyStateWidget(self) + + return widget + + def _install_busy_loader(self) -> "BusyLoaderOverlay" | None: + """ + Create the busy overlay on demand and cache it in _busy_overlay. Returns the overlay instance or None if not a QWidget. """ if not isinstance(self, QWidget): @@ -107,38 +166,39 @@ class BECWidget(BECConnector): if overlay is None: from bec_widgets.utils.busy_loader import install_busy_loader - overlay = install_busy_loader(self, text=busy_text, start_loading=False) + overlay = install_busy_loader(self, start_loading=False) self._busy_overlay = overlay + + # Create and set the busy state widget + self._busy_state_widget = self.create_busy_state_widget() + self._busy_overlay.set_widget(self._busy_state_widget) return overlay - def _init_busy_loader(self, *, start_busy: bool = False, busy_text: str = "Loading…") -> None: + def _adjust_busy_overlay(self) -> None: """Create and attach the loading overlay to this widget if QWidget is present.""" if not isinstance(self, QWidget): return - self._ensure_busy_overlay(busy_text=busy_text) - if start_busy and self._busy_overlay is not None: - self._busy_overlay.setGeometry(self.rect()) + if self._busy_overlay is not None: + self._busy_overlay.setGeometry(self.rect()) # pylint: disable=no-member self._busy_overlay.raise_() self._busy_overlay.show() - def set_busy(self, enabled: bool, text: str | None = None) -> None: + def set_busy(self, enabled: bool) -> None: """ - Enable/disable the loading overlay. Optionally update the text. + Set the busy state of the widget. This will show or hide the loading overlay, which will + block user interaction with the widget and show the busy_state_widget if provided. Per + default, the busy state widget is a spinner with "Loading..." text. Args: - enabled(bool): Whether to enable the loading overlay. - text(str, optional): The text to display on the overlay. If None, the text is not changed. + enabled(bool): Whether to enable the busy state. """ if not isinstance(self, QWidget): return - if getattr(self, "_busy_overlay", None) is None: - self._ensure_busy_overlay(busy_text=text or "Loading…") - if text is not None: - self.set_busy_text(text) + # If not yet installed, install the busy overlay now together with the busy state widget + if self._busy_overlay is None: + self._busy_overlay = self._install_busy_loader() if enabled: - self._busy_overlay.setGeometry(self.rect()) - self._busy_overlay.raise_() - self._busy_overlay.show() + self._adjust_busy_overlay() else: self._busy_overlay.hide() self._loading = bool(enabled) @@ -152,19 +212,6 @@ class BECWidget(BECConnector): """ return bool(getattr(self, "_loading", False)) - def set_busy_text(self, text: str) -> None: - """ - Update the text on the loading overlay. - - Args: - text(str): The text to display on the overlay. - """ - overlay = getattr(self, "_busy_overlay", None) - if overlay is None: - overlay = self._ensure_busy_overlay(busy_text=text) - if overlay is not None: - overlay.set_text(text) - @SafeSlot(str) def apply_theme(self, theme: str): """ @@ -177,8 +224,8 @@ class BECWidget(BECConnector): def _update_overlay_theme(self, theme: str): try: overlay = getattr(self, "_busy_overlay", None) - if overlay is not None and hasattr(overlay, "update_palette"): - overlay.update_palette() + if overlay is not None: + overlay._update_palette() except Exception: logger.warning(f"Failed to apply theme {theme} to {self}") @@ -304,10 +351,13 @@ class BECWidget(BECConnector): self.removeEventFilter(filt) except Exception as exc: logger.warning(f"Failed to remove event filter from busy overlay: {exc}") + + # Cleanup the overlay widget. This will call cleanup on the custom widget if present. + + overlay.cleanup() overlay.deleteLater() except Exception as exc: logger.warning(f"Failed to delete busy overlay: {exc}") - self._busy_overlay = None def closeEvent(self, event): """Wrap the close even to ensure the rpc_register is cleaned up.""" diff --git a/bec_widgets/utils/busy_loader.py b/bec_widgets/utils/busy_loader.py index 2305170e..76af94d3 100644 --- a/bec_widgets/utils/busy_loader.py +++ b/bec_widgets/utils/busy_loader.py @@ -1,7 +1,7 @@ from __future__ import annotations -from qtpy.QtCore import QEvent, QObject, Qt, QTimer -from qtpy.QtGui import QColor, QFont +from qtpy.QtCore import QEvent, QObject, Qt, QTimer, Signal +from qtpy.QtGui import QColor from qtpy.QtWidgets import ( QApplication, QFrame, @@ -15,8 +15,8 @@ from qtpy.QtWidgets import ( from bec_widgets import BECWidget from bec_widgets.utils.colors import apply_theme +from bec_widgets.utils.error_popups import SafeProperty from bec_widgets.widgets.plots.waveform.waveform import Waveform -from bec_widgets.widgets.utility.spinner.spinner import SpinnerWidget class _OverlayEventFilter(QObject): @@ -53,132 +53,200 @@ class BusyLoaderOverlay(QWidget): BusyLoaderOverlay: The overlay instance. """ - def __init__(self, parent: QWidget, text: str = "Loading…", opacity: float = 0.85, **kwargs): + foreground_color_changed = Signal(QColor) + scrim_color_changed = Signal(QColor) + + def __init__(self, parent: QWidget, opacity: float = 0.85, **kwargs): super().__init__(parent=parent, **kwargs) + self.setAttribute(Qt.WA_StyledBackground, True) self.setAutoFillBackground(False) self.setAttribute(Qt.WA_TranslucentBackground, True) self._opacity = opacity + self._scrim_color = QColor(0, 0, 0, 110) + self._label_color = QColor(240, 240, 240) + self._filter: QObject | None = None - self._label = QLabel(text, self) - self._label.setAlignment(Qt.AlignHCenter | Qt.AlignVCenter) - f = QFont(self._label.font()) - f.setBold(True) - f.setPointSize(f.pointSize() + 1) - self._label.setFont(f) + # Set Main Layout + layout = QVBoxLayout(self) + layout.setContentsMargins(24, 24, 24, 24) + layout.setSpacing(10) + self.setLayout(layout) - self._spinner = SpinnerWidget(self) - self._spinner.setFixedSize(42, 42) - - lay = QVBoxLayout(self) - lay.setContentsMargins(24, 24, 24, 24) - lay.setSpacing(10) - lay.addStretch(1) - lay.addWidget(self._spinner, 0, Qt.AlignHCenter) - lay.addWidget(self._label, 0, Qt.AlignHCenter) - lay.addStretch(1) + # Custom widget placeholder + self._custom_widget: QWidget | None = None + # Add a frame around the content self._frame = QFrame(self) self._frame.setObjectName("busyFrame") self._frame.setAttribute(Qt.WA_TransparentForMouseEvents, True) self._frame.lower() # Defaults - self._scrim_color = QColor(0, 0, 0, 110) - self._label_color = QColor(240, 240, 240) - self.update_palette() + self._update_palette() # Start hidden; interactions beneath are blocked while visible self.hide() - # --- API --- - def set_text(self, text: str): + @SafeProperty(QColor, notify=scrim_color_changed) + def scrim_color(self) -> QColor: """ - Update the overlay text. + The overlay scrim color. + """ + return self._scrim_color + + @scrim_color.setter + def scrim_color(self, value: QColor): + if not isinstance(value, QColor): + raise TypeError("scrim_color must be a QColor") + self._scrim_color = value + self.update() + + @SafeProperty(QColor, notify=foreground_color_changed) + def foreground_color(self) -> QColor: + """ + The overlay foreground color (text, spinner). + """ + return self._label_color + + @foreground_color.setter + def foreground_color(self, value: QColor): + if not isinstance(value, QColor): + try: + color = QColor(value) + if not color.isValid(): + raise ValueError(f"Invalid color: {value}") + except Exception: + # pylint: disable=raise-missing-from + raise ValueError(f"Color {value} is invalid, cannot be converted to QColor") + self._label_color = value + self.update() + + def set_filter(self, filt: _OverlayEventFilter): + """ + Set an event filter to keep the overlay sized and stacked over its target. Args: - text(str): The text to display on the overlay. + filt(QObject): The event filter instance. """ - self._label.setText(text) + self._filter = filt + target = filt._target + if self.parent() != target: + logger.warning(f"Overlay parent {self.parent()} does not match filter target {target}") + target.installEventFilter(self._filter) + + ###################### + ### Public methods ### + ###################### + + def set_widget(self, widget: QWidget): + """ + Set a custom widget as an overlay for the busy overlay. + + Args: + widget(QWidget): The custom widget to display. + """ + lay = self.layout() + if lay is None: + return + self._custom_widget = widget + lay.addWidget(widget, 0, Qt.AlignHCenter) def set_opacity(self, opacity: float): """ - Set overlay opacity (0..1). + Set the overlay opacity. Only values between 0.0 and 1.0 are accepted. If a + value outside this range is provided, it will be clamped. Args: opacity(float): The opacity value between 0.0 (fully transparent) and 1.0 (fully opaque). """ self._opacity = max(0.0, min(1.0, float(opacity))) # Re-apply alpha using the current theme color - if isinstance(self._scrim_color, QColor): - base = QColor(self._scrim_color) - base.setAlpha(int(255 * self._opacity)) - self._scrim_color = base + base = self.scrim_color + base.setAlpha(int(255 * self._opacity)) + self.scrim_color = base self.update() - def update_palette(self): + ########################## + ### Internal methods ### + ########################## + + def _update_palette(self): """ Update colors from the current application theme. """ - app = QApplication.instance() - if hasattr(app, "theme"): - theme = app.theme # type: ignore[attr-defined] - self._bg = theme.color("BORDER") - self._fg = theme.color("FG") - self._primary = theme.color("PRIMARY") + _app = QApplication.instance() + if hasattr(_app, "theme"): + theme = _app.theme # type: ignore[attr-defined] + _bg = theme.color("BORDER") + _fg = theme.color("FG") else: # Fallback neutrals - self._bg = QColor(30, 30, 30) - self._fg = QColor(230, 230, 230) + _bg = QColor(30, 30, 30) + _fg = QColor(230, 230, 230) + # Semi-transparent scrim derived from bg - self._scrim_color = QColor(self._bg) - self._scrim_color.setAlpha(int(255 * max(0.0, min(1.0, getattr(self, "_opacity", 0.35))))) - self._spinner.update() - fg_hex = self._fg.name() if isinstance(self._fg, QColor) else str(self._fg) - self._label.setStyleSheet(f"color: {fg_hex};") + base = _bg if isinstance(_bg, QColor) else QColor(str(_bg)) + base.setAlpha(int(255 * max(0.0, min(1.0, getattr(self, "_opacity", 0.35))))) + self.scrim_color = base + fg = _fg if isinstance(_fg, QColor) else QColor(str(_fg)) + self.foreground_color = fg + + # Set the frame style with updated foreground colors self._frame.setStyleSheet( - f"#busyFrame {{ border: 2px dashed {fg_hex}; border-radius: 9px; background-color: rgba(128, 128, 128, 110); }}" + f"#busyFrame {{ border: 2px dashed {self.foreground_color.name()}; border-radius: 9px; background-color: rgba(128, 128, 128, 110); }}" ) self.update() - # --- QWidget overrides --- + ############################# + ### Custom Event Handlers ### + ############################# + def showEvent(self, e): - self._spinner.start() + # Call showEvent on custom widget if present + if self._custom_widget is not None: + self._custom_widget.showEvent(e) super().showEvent(e) def hideEvent(self, e): - self._spinner.stop() + # Call hideEvent on custom widget if present + if self._custom_widget is not None: + self._custom_widget.hideEvent(e) super().hideEvent(e) def resizeEvent(self, e): + # Call resizeEvent on custom widget if present + if self._custom_widget is not None: + self._custom_widget.resizeEvent(e) super().resizeEvent(e) r = self.rect().adjusted(10, 10, -10, -10) self._frame.setGeometry(r) - def paintEvent(self, e): - super().paintEvent(e) + # TODO should we have this cleanup here? + def cleanup(self): + """Cleanup resources used by the overlay.""" + if self._custom_widget is not None: + if hasattr(self._custom_widget, "cleanup"): + self._custom_widget.cleanup() def install_busy_loader( - target: QWidget, text: str = "Loading…", start_loading: bool = False, opacity: float = 0.35 + target: QWidget, start_loading: bool = False, opacity: float = 0.35 ) -> BusyLoaderOverlay: """ Attach a BusyLoaderOverlay to `target` and keep it sized and stacked. Args: target(QWidget): The widget to overlay. - text(str): Initial text to display. start_loading(bool): If True, show the overlay immediately. opacity(float): Overlay opacity (0..1). Returns: BusyLoaderOverlay: The overlay instance. """ - overlay = BusyLoaderOverlay(target, text=text, opacity=opacity) + overlay = BusyLoaderOverlay(parent=target, opacity=opacity) overlay.setGeometry(target.rect()) - filt = _OverlayEventFilter(target, overlay) - overlay._filter = filt # type: ignore[attr-defined] - target.installEventFilter(filt) + overlay.set_filter(_OverlayEventFilter(target, overlay)) if start_loading: overlay.show() return overlay @@ -188,10 +256,8 @@ def install_busy_loader( # Launchable demo # -------------------------- class DemoWidget(BECWidget, QWidget): # pragma: no cover - def __init__(self, parent=None): - super().__init__( - parent=parent, theme_update=True, start_busy=True, busy_text="Demo: Initializing…" - ) + def __init__(self, parent=None, start_busy: bool = False): + super().__init__(parent=parent, theme_update=True, start_busy=start_busy) self._title = QLabel("Demo Content", self) self._title.setAlignment(Qt.AlignCenter) @@ -214,15 +280,14 @@ class DemoWindow(QMainWindow): super().__init__() self.setWindowTitle("Busy Loader — BECWidget demo") - left = DemoWidget() + left = DemoWidget(start_busy=True) right = DemoWidget() btn_on = QPushButton("Right → Loading") btn_off = QPushButton("Right → Ready") btn_text = QPushButton("Set custom text") - btn_on.clicked.connect(lambda: right.set_busy(True, "Fetching data…")) + btn_on.clicked.connect(lambda: right.set_busy(True)) btn_off.clicked.connect(lambda: right.set_busy(False)) - btn_text.clicked.connect(lambda: right.set_busy_text("Almost there…")) panel = QWidget() prow = QVBoxLayout(panel) diff --git a/bec_widgets/widgets/control/device_manager/components/device_table/device_table.py b/bec_widgets/widgets/control/device_manager/components/device_table/device_table.py index 7f2edb32..a7b716cf 100644 --- a/bec_widgets/widgets/control/device_manager/components/device_table/device_table.py +++ b/bec_widgets/widgets/control/device_manager/components/device_table/device_table.py @@ -5,10 +5,12 @@ in DeviceTableRow entries. from __future__ import annotations +import traceback from copy import deepcopy -from typing import Any, Callable, Iterable, Tuple +from typing import TYPE_CHECKING, Any, Callable, Iterable, Literal, Tuple from bec_lib.atlas_models import Device as DeviceModel +from bec_lib.callback_handler import EventType from bec_lib.logger import bec_logger from bec_qthemes import material_icon from qtpy import QtCore, QtGui, QtWidgets @@ -26,6 +28,9 @@ from bec_widgets.widgets.control.device_manager.components.ophyd_validation impo get_validation_icons, ) +if TYPE_CHECKING: # pragma: no cover + from bec_lib.messages import ConfigAction + logger = bec_logger.logger _DeviceCfgIter = Iterable[dict[str, Any]] @@ -208,6 +213,11 @@ class DeviceTable(BECWidget, QtWidgets.QWidget): # Signal emitted when the device config is in sync with Redis device_config_in_sync_with_redis = QtCore.Signal(bool) + # Request multiple validation updates for devices + request_update_multiple_device_validations = QtCore.Signal(list) + # Request update after client DEVICE_UPDATE event + request_update_after_client_device_update = QtCore.Signal() + _auto_size_request = QtCore.Signal() def __init__(self, parent: QtWidgets.QWidget | None = None, client=None): @@ -267,15 +277,66 @@ class DeviceTable(BECWidget, QtWidgets.QWidget): # Connect slots self.table.selectionModel().selectionChanged.connect(self._on_selection_changed) self.table.cellDoubleClicked.connect(self._on_cell_double_clicked) + self.request_update_multiple_device_validations.connect( + self.update_multiple_device_validations + ) + self.request_update_after_client_device_update.connect(self._on_device_config_update) # Install event filter self.table.installEventFilter(self) + # Add hook to BECClient for DeviceUpdates + self.client_callback_id = self.client.callbacks.register( + event_type=EventType.DEVICE_UPDATE, callback=self.__on_client_device_update_event + ) + def cleanup(self): """Cleanup resources.""" self.row_data.clear() # Drop references to row data.. - # self._autosize_timer.stop() + self.client.callbacks.remove(self.client_callback_id) # Unregister callback super().cleanup() + def __on_client_device_update_event( + self, action: "ConfigAction", config: dict[str, dict[str, Any]] + ) -> None: + """Handle DEVICE_UPDATE events from the BECClient.""" + self.request_update_after_client_device_update.emit() + + @SafeSlot() + def _on_device_config_update(self) -> None: + """Handle device configuration updates from the BECClient.""" + # Determine the overlapping device configs between Redis and the table + device_config_overlap_with_bec = self._get_overlapping_configs() + if len(device_config_overlap_with_bec) > 0: + # Notify any listeners about the update, the device manager devices will now be up to date + self.device_configs_changed.emit(device_config_overlap_with_bec, True, True) + + # Correct all connection statuses in the table which are ConnectionStatus.CONNECTED + # to ConnectionStatus.CAN_CONNECT + device_status_updates = [] + validation_results = self.get_validation_results() + for device_name, (cfg, config_status, connection_status) in validation_results.items(): + if device_name is None: + continue + # Check if config is not in the overlap, but connection status is CONNECTED + # Update to CAN_CONNECT + if cfg not in device_config_overlap_with_bec: + if connection_status == ConnectionStatus.CONNECTED.value: + device_status_updates.append( + (cfg, config_status, ConnectionStatus.CAN_CONNECT.value, "") + ) + # Update only if there are any updates + if len(device_status_updates) > 0: + # NOTE We need to emit here a signal to call update_multiple_device_validations + # as this otherwise can cause problems with being executed from a python callback + # thread which are not properly scheduled in the Qt event loop. We see that this + # has caused issues in form of segfaults under certain usage of the UI. Please + # do not remove this signal & slot mechanism! + self.request_update_multiple_device_validations.emit(device_status_updates) + + # Check if in sync with BEC server session + in_sync_with_redis = self._is_config_in_sync_with_redis() + self.device_config_in_sync_with_redis.emit(in_sync_with_redis) + # ------------------------------------------------------------------------- # Custom hooks for table events # ------------------------------------------------------------------------- @@ -769,6 +830,51 @@ class DeviceTable(BECWidget, QtWidgets.QWidget): logger.error(f"Error comparing device configs: {e}") return False + def _get_overlapping_configs(self) -> list[dict[str, Any]]: + """ + Get the device configs that overlap between the table and the config in the current running BEC session. + A device will be ignored if it is disabled in the BEC session. + + Args: + device_configs (Iterable[dict[str, Any]]): The device configs to check. + + Returns: + list[dict[str, Any]]: The list of overlapping device configs. + """ + overlapping_configs = [] + for cfg in self.get_device_config(): + device_name = cfg.get("name", None) + if device_name is None: + continue + if self._is_device_in_redis_session(device_name, cfg): + overlapping_configs.append(cfg) + + return overlapping_configs + + def _is_device_in_redis_session(self, device_name: str, device_config: dict) -> bool: + """Check if a device is in the running section.""" + dev_obj = self.client.device_manager.devices.get(device_name, None) + if dev_obj is None or dev_obj.enabled is False: + return False + return self._compare_device_configs(dev_obj._config, device_config) + + def _compare_device_configs(self, config1: dict, config2: dict) -> bool: + """Compare two device configurations through the Device model in bec_lib.atlas_models. + + Args: + config1 (dict): The first device configuration. + config2 (dict): The second device configuration. + + Returns: + bool: True if the configurations are equivalent, False otherwise. + """ + try: + model1 = DeviceModel.model_validate(config1) + model2 = DeviceModel.model_validate(config2) + return model1 == model2 + except Exception: + return False + # ------------------------------------------------------------------------- # Public API to manage device configs in the table # ------------------------------------------------------------------------- @@ -832,7 +938,7 @@ class DeviceTable(BECWidget, QtWidgets.QWidget): device_configs (Iterable[dict[str, Any]]): The device configs to set. skip_validation (bool): Whether to skip validation for the set devices. """ - self.set_busy(True, text="Loading device configurations...") + self.set_busy(True) with self.table_sort_on_hold: self.clear_device_configs() cfgs_added = [] @@ -842,12 +948,12 @@ class DeviceTable(BECWidget, QtWidgets.QWidget): self.device_configs_changed.emit(cfgs_added, True, skip_validation) in_sync_with_redis = self._is_config_in_sync_with_redis() self.device_config_in_sync_with_redis.emit(in_sync_with_redis) - self.set_busy(False, text="") + self.set_busy(False) @SafeSlot() def clear_device_configs(self): - """Clear the device configs. Skips validation per default.""" - self.set_busy(True, text="Clearing device configurations...") + """Clear the device configs. Skips validation by default.""" + self.set_busy(True) device_configs = self.get_device_config() with self.table_sort_on_hold: self._clear_table() @@ -856,7 +962,7 @@ class DeviceTable(BECWidget, QtWidgets.QWidget): ) # Skip validation for removals in_sync_with_redis = self._is_config_in_sync_with_redis() self.device_config_in_sync_with_redis.emit(in_sync_with_redis) - self.set_busy(False, text="") + self.set_busy(False) @SafeSlot(list, bool) def add_device_configs(self, device_configs: _DeviceCfgIter, skip_validation: bool = False): @@ -869,7 +975,7 @@ class DeviceTable(BECWidget, QtWidgets.QWidget): device_configs (Iterable[dict[str, Any]]): The device configs to add. skip_validation (bool): Whether to skip validation for the added devices. """ - self.set_busy(True, text="Adding device configurations...") + self.set_busy(True) already_in_table = [] not_in_table = [] with self.table_sort_on_hold: @@ -894,7 +1000,7 @@ class DeviceTable(BECWidget, QtWidgets.QWidget): self.device_configs_changed.emit(already_in_table + not_in_table, True, skip_validation) in_sync_with_redis = self._is_config_in_sync_with_redis() self.device_config_in_sync_with_redis.emit(in_sync_with_redis) - self.set_busy(False, text="") + self.set_busy(False) @SafeSlot(list, bool) def update_device_configs(self, device_configs: _DeviceCfgIter, skip_validation: bool = False): @@ -905,7 +1011,7 @@ class DeviceTable(BECWidget, QtWidgets.QWidget): device_configs (Iterable[dict[str, Any]]): The device configs to update. skip_validation (bool): Whether to skip validation for the updated devices. """ - self.set_busy(True, text="Loading device configurations...") + self.set_busy(True) cfgs_updated = [] with self.table_sort_on_hold: for cfg in device_configs: @@ -920,7 +1026,7 @@ class DeviceTable(BECWidget, QtWidgets.QWidget): self.device_configs_changed.emit(cfgs_updated, True, skip_validation) in_sync_with_redis = self._is_config_in_sync_with_redis() self.device_config_in_sync_with_redis.emit(in_sync_with_redis) - self.set_busy(False, text="") + self.set_busy(False) @SafeSlot(list) def remove_device_configs(self, device_configs: _DeviceCfgIter): @@ -930,7 +1036,7 @@ class DeviceTable(BECWidget, QtWidgets.QWidget): Args: device_configs (dict[str, dict]): The device configs to remove. """ - self.set_busy(True, text="Removing device configurations...") + self.set_busy(True) cfgs_to_be_removed = list(device_configs) with self.table_sort_on_hold: self._remove_rows_by_name([cfg["name"] for cfg in cfgs_to_be_removed]) @@ -939,7 +1045,7 @@ class DeviceTable(BECWidget, QtWidgets.QWidget): ) # Skip validation for removals in_sync_with_redis = self._is_config_in_sync_with_redis() self.device_config_in_sync_with_redis.emit(in_sync_with_redis) - self.set_busy(False, text="") + self.set_busy(False) @SafeSlot(str) def remove_device(self, device_name: str): @@ -949,11 +1055,11 @@ class DeviceTable(BECWidget, QtWidgets.QWidget): Args: device_name (str): The name of the device to remove. """ - self.set_busy(True, text=f"Removing device configuration for {device_name}...") + self.set_busy(True) row_data = self.row_data.get(device_name) if not row_data: logger.warning(f"Device {device_name} not found in table for removal.") - self.set_busy(False, text="") + self.set_busy(False) return with self.table_sort_on_hold: self._remove_rows_by_name([row_data.data["name"]]) @@ -961,7 +1067,7 @@ class DeviceTable(BECWidget, QtWidgets.QWidget): self.device_configs_changed.emit(cfgs, False, True) # Skip validation for removals in_sync_with_redis = self._is_config_in_sync_with_redis() self.device_config_in_sync_with_redis.emit(in_sync_with_redis) - self.set_busy(False, text="") + self.set_busy(False) @SafeSlot(list) def update_multiple_device_validations(self, validation_results: _ValidationResultIter): @@ -973,9 +1079,15 @@ class DeviceTable(BECWidget, QtWidgets.QWidget): Args: device_configs (Iterable[dict[str, Any]]): The device configs to update. """ - self.set_busy(True, text="Updating device validations in session...") + self.set_busy(True) self.table.setSortingEnabled(False) + logger.info( + f"Updating multiple device validation statuses with names {[cfg.get('name', '') for cfg, _, _, _ in validation_results]}..." + ) for cfg, config_status, connection_status, _ in validation_results: + logger.info( + f"Updating device {cfg.get('name', '')} with config status {config_status} and connection status {connection_status}..." + ) row = self._find_row_by_name(cfg.get("name", "")) if row is None: logger.warning(f"Device {cfg.get('name')} not found in table for session update.") @@ -984,7 +1096,7 @@ class DeviceTable(BECWidget, QtWidgets.QWidget): in_sync_with_redis = self._is_config_in_sync_with_redis() self.device_config_in_sync_with_redis.emit(in_sync_with_redis) self.table.setSortingEnabled(True) - self.set_busy(False, text="") + self.set_busy(False) @SafeSlot(dict, int, int, str) def update_device_validation( @@ -997,13 +1109,13 @@ class DeviceTable(BECWidget, QtWidgets.QWidget): Args: """ - self.set_busy(True, text="Updating device validation status...") + self.set_busy(True) row = self._find_row_by_name(device_config.get("name", "")) if row is None: logger.warning( f"Device {device_config.get('name')} not found in table for validation update." ) - self.set_busy(False, text="") + self.set_busy(False) return # Disable here sorting without context manager to avoid triggering of registered # resizing methods. Those can be quite heavy, thus, should not run on every @@ -1013,4 +1125,4 @@ class DeviceTable(BECWidget, QtWidgets.QWidget): self.table.setSortingEnabled(True) in_sync_with_redis = self._is_config_in_sync_with_redis() self.device_config_in_sync_with_redis.emit(in_sync_with_redis) - self.set_busy(False, text="") + self.set_busy(False) diff --git a/bec_widgets/widgets/control/device_manager/components/ophyd_validation/ophyd_validation.py b/bec_widgets/widgets/control/device_manager/components/ophyd_validation/ophyd_validation.py index 5ff9d978..10395879 100644 --- a/bec_widgets/widgets/control/device_manager/components/ophyd_validation/ophyd_validation.py +++ b/bec_widgets/widgets/control/device_manager/components/ophyd_validation/ophyd_validation.py @@ -548,9 +548,10 @@ class OphydValidation(BECWidget, QtWidgets.QWidget): if device_name is None: # Config missing name, will be skipped.. logger.error(f"Device config missing 'name': {cfg}. Config will be skipped.") continue - if not added or skip_validation is True: # Remove requested + if not added: # Remove requested, holds priority over skip_validation self._remove_device_config(cfg) continue + # Check if device is already in running session with the same config if self._is_device_in_redis_session(cfg.get("name"), cfg): logger.debug( f"Device {device_name} already in running session with same config. Skipping." @@ -563,29 +564,39 @@ class OphydValidation(BECWidget, QtWidgets.QWidget): "Device already in session.", ) ) + # If in addition, the device is to be kept visible after validation, we ensure it is added + # and potentially update it's config & validation icons if device_name in self._keep_visible_after_validation: - self._add_device_config( - cfg, - connect=connect, - force_connect=force_connect, - timeout=timeout, - skip_validation=True, - ) - self._on_device_test_completed( - cfg, - ConfigStatus.VALID.value, - ConnectionStatus.CONNECTED.value, - "Device already in session.", - ) - self._remove_device_config(cfg) + if not self._device_already_exists(device_name): + self._add_device_config( + cfg, + connect=connect, + force_connect=force_connect, + timeout=timeout, + skip_validation=True, + ) + # Now make sure that the existing widget is updated to reflect the CONNECTED & VALID status + widget: ValidationListItem = self.list_widget.get_widget(device_name) + if widget: + self._on_device_test_completed( + cfg, + ConfigStatus.VALID.value, + ConnectionStatus.CONNECTED.value, + "Device already in session.", + ) + else: # If not to be kept visible, we ensure it is removed from the list + self._remove_device_config(cfg) + continue # Now we continue to the next device config + if skip_validation is True: # Skip validation requested, so we skip this continue - if not self._device_already_exists(cfg.get("name")): # New device case + # New device case, that is not in BEC session + if not self._device_already_exists(cfg.get("name")): self._add_device_config( cfg, connect=connect, force_connect=force_connect, timeout=timeout ) else: # Update existing, but removing first logger.info(f"Device {cfg.get('name')} already exists, re-adding it.") - self._remove_device_config(cfg) + self._remove_device_config(cfg, force_remove=True) self._add_device_config( cfg, connect=connect, force_connect=force_connect, timeout=timeout ) @@ -661,13 +672,13 @@ class OphydValidation(BECWidget, QtWidgets.QWidget): if not skip_validation: self.__delayed_submit_test(widget, connect, force_connect, timeout) - def _remove_device(self, device_name: str) -> None: + def _remove_device(self, device_name: str, force_remove: bool = False) -> None: if not self._device_already_exists(device_name): logger.debug( f"Device with name {device_name} not found in OphydValidation, can't remove it." ) return - if device_name in self._keep_visible_after_validation: + if device_name in self._keep_visible_after_validation and not force_remove: logger.debug( f"Device with name {device_name} is set to be kept visible after validation, not removing it." ) @@ -676,9 +687,11 @@ class OphydValidation(BECWidget, QtWidgets.QWidget): self.thread_pool_manager.clear_device_in_queue(device_name) self.list_widget.remove_widget_item(device_name) - def _remove_device_config(self, device_config: dict[str, Any]) -> None: + def _remove_device_config( + self, device_config: dict[str, Any], force_remove: bool = False + ) -> None: device_name = device_config.get("name") - self._remove_device(device_name) + self._remove_device(device_name, force_remove=force_remove) @SafeSlot(str, dict, bool, bool, float) def _on_request_rerun_validation( diff --git a/bec_widgets/widgets/progress/device_initialization_progress_bar/device_initialization_progress_bar.py b/bec_widgets/widgets/progress/device_initialization_progress_bar/device_initialization_progress_bar.py index 093aad71..de18bbeb 100644 --- a/bec_widgets/widgets/progress/device_initialization_progress_bar/device_initialization_progress_bar.py +++ b/bec_widgets/widgets/progress/device_initialization_progress_bar/device_initialization_progress_bar.py @@ -12,9 +12,8 @@ class DeviceInitializationProgressBar(BECProgressBar): # Signal emitted for failed device initializations failed_devices_changed = Signal(list) - def __init__(self, parent=None, client=None): - super().__init__(parent=parent, client=client) - self._latest_device_config_msg: dict | None = None + def __init__(self, parent=None, client=None, **kwargs): + super().__init__(parent=parent, client=client, **kwargs) self._failed_devices: list[str] = [] self.bec_dispatcher.connect_slot( slot=self._update_device_initialization_progress, diff --git a/bec_widgets/widgets/progress/device_initialization_progress_bar/device_initialization_progress_bar.pyproject b/bec_widgets/widgets/progress/device_initialization_progress_bar/device_initialization_progress_bar.pyproject new file mode 100644 index 00000000..2f908ecb --- /dev/null +++ b/bec_widgets/widgets/progress/device_initialization_progress_bar/device_initialization_progress_bar.pyproject @@ -0,0 +1 @@ +{'files': ['device_initialization_progress_bar.py']} \ No newline at end of file diff --git a/bec_widgets/widgets/progress/device_initialization_progress_bar/device_initialization_progress_bar_plugin.py b/bec_widgets/widgets/progress/device_initialization_progress_bar/device_initialization_progress_bar_plugin.py new file mode 100644 index 00000000..52ceea46 --- /dev/null +++ b/bec_widgets/widgets/progress/device_initialization_progress_bar/device_initialization_progress_bar_plugin.py @@ -0,0 +1,59 @@ +# 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.progress.device_initialization_progress_bar.device_initialization_progress_bar import ( + DeviceInitializationProgressBar, +) + +DOM_XML = """ + + + + +""" + + +class DeviceInitializationProgressBarPlugin(QDesignerCustomWidgetInterface): # pragma: no cover + def __init__(self): + super().__init__() + self._form_editor = None + + def createWidget(self, parent): + if parent is None: + return QWidget() + t = DeviceInitializationProgressBar(parent) + return t + + def domXml(self): + return DOM_XML + + def group(self): + return "" + + def icon(self): + return designer_material_icon(DeviceInitializationProgressBar.ICON_NAME) + + def includeFile(self): + return "device_initialization_progress_bar" + + 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 "DeviceInitializationProgressBar" + + def toolTip(self): + return "A progress bar that displays the progress of device initialization." + + def whatsThis(self): + return self.toolTip() diff --git a/bec_widgets/widgets/progress/device_initialization_progress_bar/register_device_initialization_progress_bar.py b/bec_widgets/widgets/progress/device_initialization_progress_bar/register_device_initialization_progress_bar.py new file mode 100644 index 00000000..1f60596c --- /dev/null +++ b/bec_widgets/widgets/progress/device_initialization_progress_bar/register_device_initialization_progress_bar.py @@ -0,0 +1,17 @@ +def main(): # pragma: no cover + from qtpy import PYSIDE6 + + if not PYSIDE6: + print("PYSIDE6 is not available in the environment. Cannot patch designer.") + return + from PySide6.QtDesigner import QPyDesignerCustomWidgetCollection + + from bec_widgets.widgets.progress.device_initialization_progress_bar.device_initialization_progress_bar_plugin import ( + DeviceInitializationProgressBarPlugin, + ) + + QPyDesignerCustomWidgetCollection.addCustomWidget(DeviceInitializationProgressBarPlugin()) + + +if __name__ == "__main__": # pragma: no cover + main() diff --git a/bec_widgets/widgets/services/device_browser/device_item/config_communicator.py b/bec_widgets/widgets/services/device_browser/device_item/config_communicator.py index ca1d66f7..990f030a 100644 --- a/bec_widgets/widgets/services/device_browser/device_item/config_communicator.py +++ b/bec_widgets/widgets/services/device_browser/device_item/config_communicator.py @@ -38,6 +38,8 @@ class CommunicateConfigAction(QRunnable): self._process( {"action": self.action, "config": self.config, "wait_for_response": False} ) + elif self.action == "cancel": + self._process_cancel() elif self.action in ["add", "update", "remove"]: if (dev_name := self.device or self.config.get("name")) is None: raise ValueError( @@ -73,6 +75,13 @@ class CommunicateConfigAction(QRunnable): self.config_helper.handle_update_reply(reply, RID, timeout) logger.info("Done updating config!") + def _process_cancel(self): + logger.info("Cancelling ongoing configuration operation") + self.config_helper.send_config_request( + action="cancel", config=None, wait_for_response=True, timeout_s=10 + ) + logger.info("Done cancelling configuration operation") + def process_remove_readd(self, dev_name: str): logger.info(f"Removing and readding device: {dev_name}") self.process_simple_action(dev_name, "remove") diff --git a/tests/unit_tests/test_busy_loader.py b/tests/unit_tests/test_busy_loader.py index 2f9e859c..466ea03a 100644 --- a/tests/unit_tests/test_busy_loader.py +++ b/tests/unit_tests/test_busy_loader.py @@ -2,27 +2,16 @@ import pytest from qtpy.QtWidgets import QLabel, QVBoxLayout, QWidget from bec_widgets import BECWidget +from bec_widgets.widgets.utility.spinner.spinner import SpinnerWidget from .client_mocks import mocked_client class _TestBusyWidget(BECWidget, QWidget): def __init__( - self, - parent=None, - *, - start_busy: bool = False, - busy_text: str = "Loading…", - theme_update: bool = False, - **kwargs, + self, parent=None, *, start_busy: bool = False, theme_update: bool = False, **kwargs ): - super().__init__( - parent=parent, - theme_update=theme_update, - start_busy=start_busy, - busy_text=busy_text, - **kwargs, - ) + super().__init__(parent=parent, theme_update=theme_update, start_busy=start_busy, **kwargs) lay = QVBoxLayout(self) lay.setContentsMargins(0, 0, 0, 0) lay.addWidget(QLabel("content", self)) @@ -30,7 +19,7 @@ class _TestBusyWidget(BECWidget, QWidget): @pytest.fixture def widget_busy(qtbot, mocked_client): - w = _TestBusyWidget(client=mocked_client, start_busy=True, busy_text="Initializing…") + w = _TestBusyWidget(client=mocked_client, start_busy=True) qtbot.addWidget(w) w.resize(320, 200) w.show() @@ -59,12 +48,21 @@ def test_becwidget_set_busy_toggle_and_text(qtbot, widget_idle): overlay = getattr(widget_idle, "_busy_overlay", None) assert overlay is None, "Overlay should be lazily created when idle" - widget_idle.set_busy(True, "Fetching data…") + widget_idle.set_busy(True) overlay = getattr(widget_idle, "_busy_overlay") qtbot.waitUntil(lambda: overlay.isVisible()) - lbl = getattr(overlay, "_label") - assert lbl.text() == "Fetching data…" + assert hasattr(widget_idle, "_busy_state_widget") + assert overlay._custom_widget is not None + + label = overlay._custom_widget.findChild(QLabel) + assert label is not None + assert label.text() == "Loading..." + + spinner = overlay._custom_widget.findChild(SpinnerWidget) + assert spinner is not None + assert spinner.isVisible() + assert spinner._started is True widget_idle.set_busy(False) qtbot.waitUntil(lambda: overlay.isHidden()) @@ -106,19 +104,6 @@ def test_becwidget_overlay_frame_geometry_and_style(qtbot, widget_busy): assert "rgba(128, 128, 128, 110)" in ss -def test_becwidget_apply_busy_text_without_toggle(qtbot, widget_idle): - overlay = getattr(widget_idle, "_busy_overlay", None) - assert overlay is None, "Overlay should be created on first text update" - - widget_idle.set_busy_text("Preparing…") - overlay = getattr(widget_idle, "_busy_overlay") - assert overlay is not None - assert overlay.isHidden() - - lbl = getattr(overlay, "_label") - assert lbl.text() == "Preparing…" - - def test_becwidget_busy_cycle_start_on_off_on(qtbot, widget_busy): overlay = getattr(widget_busy, "_busy_overlay", None) assert overlay is not None, "Busy overlay should exist on a start_busy widget" @@ -131,15 +116,11 @@ def test_becwidget_busy_cycle_start_on_off_on(qtbot, widget_busy): qtbot.waitUntil(lambda: overlay.isHidden()) # Switch ON again (with new text) - widget_busy.set_busy(True, "Back to work…") + widget_busy.set_busy(True) qtbot.waitUntil(lambda: overlay.isVisible()) # Same overlay instance reused (no duplication) assert getattr(widget_busy, "_busy_overlay") is overlay - # Label updated - lbl = getattr(overlay, "_label") - assert lbl.text() == "Back to work…" - # Geometry follows parent after re-show qtbot.waitUntil(lambda: overlay.geometry() == widget_busy.rect()) diff --git a/tests/unit_tests/test_device_initialization_progress_bar.py b/tests/unit_tests/test_device_initialization_progress_bar.py index 9904d919..22aac824 100644 --- a/tests/unit_tests/test_device_initialization_progress_bar.py +++ b/tests/unit_tests/test_device_initialization_progress_bar.py @@ -1,4 +1,4 @@ -# pylint skip +# pylint skip-file import pytest from bec_lib.messages import DeviceInitializationProgressMessage @@ -6,10 +6,12 @@ from bec_widgets.widgets.progress.device_initialization_progress_bar.device_init DeviceInitializationProgressBar, ) +from .client_mocks import mocked_client + @pytest.fixture -def progress_bar(qtbot): - widget = DeviceInitializationProgressBar() +def progress_bar(qtbot, mocked_client): + widget = DeviceInitializationProgressBar(client=mocked_client) qtbot.addWidget(widget) qtbot.waitExposed(widget) yield widget diff --git a/tests/unit_tests/test_device_manager_components.py b/tests/unit_tests/test_device_manager_components.py index 80c31b6b..1131929e 100644 --- a/tests/unit_tests/test_device_manager_components.py +++ b/tests/unit_tests/test_device_manager_components.py @@ -361,6 +361,75 @@ class TestDeviceTable: assert device_table.search_input is not None assert device_table.fuzzy_is_disabled.isChecked() is False assert device_table.table.selectionBehavior() == QtWidgets.QAbstractItemView.SelectRows + assert hasattr(device_table, "client_callback_id") + + def test_device_table_client_device_update_callback( + self, device_table: DeviceTable, mocked_client, qtbot + ): + """ + Test that runs the client device update callback. This should update the status of devices in the table + that are in sync with the client. + + I. First test will run a callback when no devices are in the table, should do nothing. + II. Second test will add devices all devices from the mocked_client, then remove one + device from the client and run the callback. The table should update the status of the + removed device to CAN_CONNECT and all others to CONNECTED. + """ + device_configs_changed_calls = [] + requested_update_for_multiple_device_validations = [] + + def _device_configs_changed_cb(cfgs: list[dict], added: bool, skip_validation: bool): + """Callback to capture device config changes.""" + device_configs_changed_calls.append((cfgs, added, skip_validation)) + + def _requested_update_for_multiple_device_validations_cb(device_names: list): + """Callback to capture requests for multiple device validations.""" + requested_update_for_multiple_device_validations.append(device_names) + + device_table.device_configs_changed.connect(_device_configs_changed_cb) + device_table.request_update_multiple_device_validations.connect( + _requested_update_for_multiple_device_validations_cb + ) + + # I. First test case with no devices in the table + with qtbot.waitSignal(device_table.request_update_after_client_device_update) as blocker: + device_table.request_update_after_client_device_update.emit() + assert blocker.signal_triggered is True + # Table should remain empty, and no updates should have occurred + assert not device_configs_changed_calls + assert not requested_update_for_multiple_device_validations + + # II. Second test case, add all devices from mocked client to table + # Add all devices from mocked client to table. + device_configs = mocked_client.device_manager._get_redis_device_config() + device_table.add_device_configs(device_configs, skip_validation=True) + mocked_client.device_manager.devices.pop("samx") # Remove samx from client + with qtbot.waitSignal(device_table.request_update_after_client_device_update) as blocker: + validation_results = { + cfg.get("name"): ( + DeviceModel.model_validate(cfg).model_dump(), + ConfigStatus.VALID, + ConnectionStatus.CONNECTED, + ) + for cfg in device_configs + } + with mock.patch.object( + device_table, "get_validation_results", return_value=validation_results + ): + device_table.request_update_after_client_device_update.emit() + assert blocker.signal_triggered is True + # Table should remain empty, and no updates should have occurred + # One for add_device_configs, one for the update + assert len(device_configs_changed_calls) == 2 + # The first call should have one more device than the second + assert ( + len(device_configs_changed_calls[0][0]) + - len(device_configs_changed_calls[1][0]) + == 1 + ) + # Only one device should have been marked for validation update + assert len(requested_update_for_multiple_device_validations) == 1 + assert len(requested_update_for_multiple_device_validations[0]) == 1 def test_add_row(self, device_table: DeviceTable, sample_devices: dict): """Test adding a single device row.""" @@ -1060,9 +1129,7 @@ class TestOphydValidation: ophyd_test, "_is_device_in_redis_session", return_value=True ) as mock_is_device_in_redis_session, mock.patch.object(ophyd_test, "_add_device_config") as mock_add_device_config, - mock.patch.object( - ophyd_test, "_on_device_test_completed" - ) as mock_on_device_test_completed, + mock.patch.object(ophyd_test.list_widget, "get_widget") as mock_get_widget, ): ophyd_test.change_device_configs( [{"name": "device_2", "deviceClass": "TestClass"}], @@ -1070,12 +1137,7 @@ class TestOphydValidation: skip_validation=False, ) mock_add_device_config.assert_called_once() - mock_on_device_test_completed.assert_called_once_with( - {"name": "device_2", "deviceClass": "TestClass"}, - ConfigStatus.VALID.value, - ConnectionStatus.CONNECTED.value, - "Device already in session.", - ) + mock_get_widget.assert_called_once_with("device_2") def test_ophyd_test_adding_devices(self, ophyd_test: OphydValidation, qtbot): """Test adding devices to OphydValidation widget.""" diff --git a/tests/unit_tests/test_device_manager_view.py b/tests/unit_tests/test_device_manager_view.py index 214855e2..12aa2219 100644 --- a/tests/unit_tests/test_device_manager_view.py +++ b/tests/unit_tests/test_device_manager_view.py @@ -305,7 +305,7 @@ class TestDeviceManagerViewDialogs: qtbot.mouseClick(dialog.add_btn, QtCore.Qt.LeftButton) mock_warning_box.assert_called_once_with( "Invalid Device Name", - f"Device is invalid, can not be empty with spaces. Please provide a valid name. {dialog._device_config_template.get_config_fields().get('name', '')!r} ", + f"Device is invalid, cannot be empty or contain spaces. Please provide a valid name. {dialog._device_config_template.get_config_fields().get('name', '')!r}", ) mock_create_dialog.assert_not_called() mock_create_validation.assert_not_called() @@ -741,35 +741,3 @@ class TestDeviceManagerView: "rerun_validation" ].action.action.triggered.emit() assert len(mock_change_configs.call_args[0][0]) == 1 - - def test_update_validation_icons_after_upload( - self, - device_manager_display_widget: DeviceManagerDisplayWidget, - device_configs: list[dict[str, Any]], - ): - """Test that validation icons are updated after uploading to Redis.""" - dm_view = device_manager_display_widget - - # Add device configs to the table - dm_view.device_table_view.add_device_configs(device_configs) - # Update the device manager devices to match what's in the table - dm_view.client.device_manager.devices = {cfg["name"]: cfg for cfg in device_configs} - - # Simulate callback - dm_view._update_validation_icons_after_upload() - - # Get validation results from the table - validation_results = dm_view.device_table_view.get_validation_results() - # Check that all devices are connected and status is updated - for dev_name, (cfg, _, connection_status) in validation_results.items(): - assert cfg in device_configs - assert connection_status == ConnectionStatus.CONNECTED.value - - # Check that no devices are in ophyd_validation widget - # Those should be all cleared after upload - cfgs = dm_view.ophyd_test_view.get_device_configs() - assert len(cfgs) == 0 - - # Check that upload config button is disabled - action = dm_view.toolbar.components.get_action("update_config_redis") - assert action.action.isEnabled() is False