refactor(busy_loader): share BusyStateWidget and build CustomBusyWidget on it

This commit is contained in:
wyzula_j committed 2026-10-30 15:47:07 +00:00
1 parent 4afaf20e34
commit 1e4aaafef4
5 files changed
+119 -119

No files matched your search

@@ -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):
+10 -57
View File
@@ -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:
"""
+51 -2
View File
@@ -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:
+27
View File
@@ -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
+12 -8
View File
@@ -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)