From 1e4aaafef43f9f18b016c4a4bcd12debafea5d60 Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Fri, 16 Oct 2026 15:00:21 +0000 Subject: [PATCH] refactor(busy_loader): share BusyStateWidget and build CustomBusyWidget on it --- .../device_manager_display_widget.py | 71 +++++-------------- bec_widgets/utils/bec_widget.py | 67 +++-------------- bec_widgets/utils/busy_loader.py | 53 +++++++++++++- tests/unit_tests/test_busy_loader.py | 27 +++++++ tests/unit_tests/test_device_manager_view.py | 20 +++--- 5 files changed, 119 insertions(+), 119 deletions(-) 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 c6c15ac5..22acb2d6 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 @@ -15,7 +15,6 @@ from bec_lib.messages import ConfigAction, ScanStatusMessage from bec_lib.plugin_helper import plugin_package_name, plugin_repo_path from bec_qthemes import apply_theme, material_icon from qtpy.QtCore import QMetaObject, Qt, QThreadPool, Signal -from qtpy.QtGui import QColor from qtpy.QtWidgets import ( QApplication, QFileDialog, @@ -33,6 +32,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.busy_loader import BusyStateWidget 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 @@ -55,7 +55,6 @@ from bec_widgets.widgets.control.device_manager.components.ophyd_validation.ophy from bec_widgets.widgets.progress.device_initialization_progress_bar.device_initialization_progress_bar import ( DeviceInitializationProgressBar, ) -from bec_widgets.widgets.utility.spinner.spinner import SpinnerWidget if TYPE_CHECKING: # pragma: no cover from bec_lib.client import BECClient @@ -69,38 +68,32 @@ _yes_no_question = partial( ) -class CustomBusyWidget(QWidget): - """Custom busy widget to show during device config upload.""" +class CustomBusyWidget(BusyStateWidget): + """Busy state shown during device config upload: spinner, progress bar and cancel button.""" cancel_requested = Signal() def __init__(self, parent=None, client: BECClient | None = None): - super().__init__(parent=parent) + super().__init__(parent=parent, text=None) - # Widgets + # Progress of the device initialization, on a card so it stays readable over the scrim self.progress = QWidget(parent=self) + self.progress.setObjectName("busyProgressCard") + self.progress.setAttribute(Qt.WidgetAttribute.WA_StyledBackground, True) + self.progress.setStyleSheet( + "#busyProgressCard { background-color: palette(window); border-radius: 12px; }" + ) self.progress_layout = QVBoxLayout(self.progress) self.progress_layout.setContentsMargins(6, 6, 6, 6) self.progress_inner = DeviceInitializationProgressBar(parent=self.progress, client=client) self.progress_layout.addWidget(self.progress_inner) self.progress.setMinimumWidth(320) - # Spinner - self.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, 96)) - self.spinner.setFixedSize(spinner_size, spinner_size) - # Cancel button self.cancel_button = QPushButton("Cancel Upload", parent=self) self.cancel_button.setIcon(material_icon("cancel")) self.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) - self.cancel_button.setFixedSize(button_width, button_height) + self.cancel_button.setFixedSize(136, 36) color = get_accent_colors() self.cancel_button.setStyleSheet(f""" QPushButton {{ @@ -111,40 +104,14 @@ class CustomBusyWidget(QWidget): }} """) - # Layout - content_layout = QVBoxLayout(self) - content_layout.setContentsMargins(24, 24, 24, 24) - content_layout.setSpacing(16) - content_layout.addStretch() - content_layout.addWidget(self.spinner, 0, Qt.AlignmentFlag.AlignHCenter) - content_layout.addWidget(self.progress, 0, Qt.AlignmentFlag.AlignHCenter) - content_layout.addStretch() - content_layout.addWidget(self.cancel_button, 0, Qt.AlignmentFlag.AlignHCenter) - - if hasattr(color, "_colors"): - bg_color = color._colors.get("BG", None) - if bg_color is None: # Fallback if missing - bg_color = QColor(50, 50, 50, 255) - self.setStyleSheet(f""" - background-color: {bg_color.name()}; - border-radius: 12px; - """) - - 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) - self.spinner.start() - - def hideEvent(self, event): - """Hide event to stop the spinner.""" - super().hideEvent(event) - self.spinner.stop() + # Progress below the spinner, cancel button at the bottom + self.content_layout.insertWidget( + self.content_layout.indexOf(self.label) + 1, + self.progress, + 0, + Qt.AlignmentFlag.AlignHCenter, + ) + self.content_layout.addWidget(self.cancel_button, 0, Qt.AlignmentFlag.AlignHCenter) class DeviceManagerDisplayWidget(DockAreaWidget): diff --git a/bec_widgets/utils/bec_widget.py b/bec_widgets/utils/bec_widget.py index 7a6f0f06..940a5fcb 100644 --- a/bec_widgets/utils/bec_widget.py +++ b/bec_widgets/utils/bec_widget.py @@ -10,24 +10,16 @@ from typing import TYPE_CHECKING import shiboken6 from bec_lib.logger import bec_logger from qtpy.QtCore import SIGNAL, SLOT, QBuffer, QByteArray, QIODevice, QObject, Qt, QTimer -from qtpy.QtGui import QFont, QPixmap -from qtpy.QtWidgets import ( - QAbstractItemView, - QApplication, - QFileDialog, - QLabel, - QVBoxLayout, - QWidget, -) +from qtpy.QtGui import QPixmap +from qtpy.QtWidgets import QAbstractItemView, QApplication, QFileDialog, QWidget import bec_widgets.widgets.containers.qt_ads as QtAds from bec_widgets.utils.bec_connector import BECConnector, ConnectionConfig -from bec_widgets.utils.busy_loader import install_busy_loader +from bec_widgets.utils.busy_loader import BusyStateWidget, install_busy_loader from bec_widgets.utils.error_popups import SafeConnect, SafeSlot from bec_widgets.utils.rpc_decorator import rpc_timeout from bec_widgets.utils.rpc_register import RPCRegister 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 @@ -195,56 +187,17 @@ class BECWidget(BECConnector): 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 implementation here is a BusyStateWidget, a spinner 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, for example by subclassing BusyStateWidget. 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 + return BusyStateWidget(self) def _install_busy_loader(self) -> "BusyLoaderOverlay" | None: """ diff --git a/bec_widgets/utils/busy_loader.py b/bec_widgets/utils/busy_loader.py index 07567074..b66cbef1 100644 --- a/bec_widgets/utils/busy_loader.py +++ b/bec_widgets/utils/busy_loader.py @@ -2,7 +2,7 @@ from __future__ import annotations from bec_lib.logger import bec_logger from qtpy.QtCore import QEvent, QObject, Qt, QTimer, Signal -from qtpy.QtGui import QColor +from qtpy.QtGui import QColor, QFont from qtpy.QtWidgets import ( QApplication, QFrame, @@ -16,6 +16,7 @@ from qtpy.QtWidgets import ( from bec_widgets.utils.colors import apply_theme from bec_widgets.utils.error_popups import SafeProperty +from bec_widgets.widgets.utility.spinner.spinner import SpinnerWidget logger = bec_logger.logger @@ -46,7 +47,7 @@ class _OverlayEventFilter(QObject): class BusyLoaderOverlay(QWidget): """ - A semi-transparent scrim with centered text and an animated spinner. + A semi-transparent scrim that hosts a busy state widget, by default a BusyStateWidget. Call show()/hide() directly, or use via `install_busy_loader(...)`. Args: @@ -236,6 +237,54 @@ class BusyLoaderOverlay(QWidget): self._custom_widget.cleanup() +class BusyStateWidget(QWidget): + """ + Default content of the busy overlay: a spinner with an optional message below it. + The spinner only runs while the widget is visible. Subclasses can add widgets to + `content_layout`, for example a progress bar or a cancel button. + + Args: + parent(QWidget): The parent widget. + text(str | None): Message shown below the spinner. None hides the message. + spinner_size(int): Width and height of the spinner in pixels. + """ + + def __init__( + self, parent: QWidget | None = None, text: str | None = "Loading...", spinner_size: int = 42 + ): + super().__init__(parent) + + self.spinner = SpinnerWidget(self) + self.spinner.setFixedSize(spinner_size, spinner_size) + + self.label = QLabel(text or "", self) + self.label.setAlignment(Qt.AlignHCenter | Qt.AlignVCenter) + font = QFont(self.label.font()) + font.setBold(True) + font.setPointSize(font.pointSize() + 1) + self.label.setFont(font) + if text is None: + self.label.hide() + + self.content_layout = QVBoxLayout(self) + self.content_layout.setContentsMargins(24, 24, 24, 24) + self.content_layout.setSpacing(10) + self.content_layout.addStretch(1) + self.content_layout.addWidget(self.spinner, 0, Qt.AlignHCenter) + self.content_layout.addWidget(self.label, 0, Qt.AlignHCenter) + self.content_layout.addStretch(1) + + def showEvent(self, event): + """Start the spinner when the widget is shown.""" + super().showEvent(event) + self.spinner.start() + + def hideEvent(self, event): + """Stop the spinner when the widget is hidden.""" + super().hideEvent(event) + self.spinner.stop() + + def install_busy_loader( target: QWidget, start_loading: bool = False, opacity: float = 0.35 ) -> BusyLoaderOverlay: diff --git a/tests/unit_tests/test_busy_loader.py b/tests/unit_tests/test_busy_loader.py index 5e8fcba9..306dfdb2 100644 --- a/tests/unit_tests/test_busy_loader.py +++ b/tests/unit_tests/test_busy_loader.py @@ -4,6 +4,7 @@ from qtpy.QtWidgets import QLabel, QVBoxLayout, QWidget from bec_widgets import BECWidget from bec_widgets.tests.client_mocks import mocked_client +from bec_widgets.utils.busy_loader import BusyStateWidget from bec_widgets.widgets.utility.spinner.spinner import SpinnerWidget @@ -139,3 +140,29 @@ def test_becwidget_busy_cycle_start_on_off_on(qtbot, widget_busy): # Geometry follows parent after re-show qtbot.waitUntil(lambda: overlay.geometry() == widget_busy.rect()) + + +def test_becwidget_default_busy_state_widget(widget_idle): + assert isinstance(widget_idle._busy_state_widget, BusyStateWidget) + assert widget_idle._busy_state_widget.label.text() == "Loading..." + + +def test_busy_state_widget_without_text_hides_label(qtbot): + widget = BusyStateWidget(text=None, spinner_size=32) + qtbot.addWidget(widget) + assert widget.label.isHidden() + assert widget.spinner.width() == 32 + assert widget.spinner.height() == 32 + + +def test_busy_state_widget_spinner_runs_only_while_visible(qtbot): + widget = BusyStateWidget() + qtbot.addWidget(widget) + assert widget.spinner._started is False + + widget.show() + qtbot.waitExposed(widget) + assert widget.spinner._started is True + + widget.hide() + assert widget.spinner._started is False diff --git a/tests/unit_tests/test_device_manager_view.py b/tests/unit_tests/test_device_manager_view.py index 429eb024..fc6813f8 100644 --- a/tests/unit_tests/test_device_manager_view.py +++ b/tests/unit_tests/test_device_manager_view.py @@ -31,7 +31,7 @@ from bec_widgets.applications.views.device_manager_view.device_manager_view impo DeviceManagerWidget, ) from bec_widgets.tests.client_mocks import mocked_client -from bec_widgets.utils.colors import get_accent_colors +from bec_widgets.utils.busy_loader import BusyStateWidget from bec_widgets.widgets.control.device_manager.components import ( DeviceTable, DMConfigView, @@ -644,17 +644,21 @@ class TestDeviceManagerView: def test_custom_busy_widget(self, custom_busy: CustomBusyWidget, qtbot): """Test the CustomBusyWidget functionality.""" - # Check layout + # Check layout: shared busy state spinner, no message, progress card and cancel button + assert isinstance(custom_busy, BusyStateWidget) assert custom_busy.progress is not None assert custom_busy.spinner is not None assert custom_busy.spinner._started is False + assert custom_busy.label.isHidden() + layout = custom_busy.content_layout + assert layout.indexOf(custom_busy.spinner) < layout.indexOf(custom_busy.progress) + assert layout.indexOf(custom_busy.progress) < layout.indexOf(custom_busy.cancel_button) - # Check background - color = get_accent_colors() - bg = color._colors["BG"] - sheet = custom_busy.styleSheet() - assert bg.name() in sheet - assert "border-radius: 12px" in sheet + # The card background is scoped to the progress card and follows the theme palette + sheet = custom_busy.progress.styleSheet() + assert "#busyProgressCard" in sheet + assert "palette(window)" in sheet + assert custom_busy.styleSheet() == "" # Show event should start spinner custom_busy.showEvent(None)