mirror of
https://github.com/bec-project/bec_widgets.git
synced 2026-11-01 00:41:27 +01:00
refactor(busy_loader): share BusyStateWidget and build CustomBusyWidget on it
This commit is contained in:
5 files changed
+119
-119
No files matched your search
+19
-52
@@ -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,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:
|
||||
"""
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user