diff --git a/bec_widgets/utils/bec_widget.py b/bec_widgets/utils/bec_widget.py index 34a80ee2..e4e72e52 100644 --- a/bec_widgets/utils/bec_widget.py +++ b/bec_widgets/utils/bec_widget.py @@ -36,6 +36,8 @@ class BECWidget(BECConnector): config: ConnectionConfig = None, gui_id: str | None = None, theme_update: bool = False, + start_busy: bool = False, + busy_text: str = "Loading…", parent_dock: BECDock | None = None, # TODO should go away -> issue created #473 **kwargs, ): @@ -65,6 +67,20 @@ class BECWidget(BECConnector): logger.debug(f"Subscribing to theme updates for {self.__class__.__name__}") self._connect_to_theme_change() + # Initialize optional busy loader overlay utility (lazy by default) + self._busy_overlay = 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}") + def _connect_to_theme_change(self): """Connect to the theme change signal.""" qapp = QApplication.instance() @@ -81,8 +97,77 @@ class BECWidget(BECConnector): theme = qapp.theme.theme else: theme = "dark" + 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. + Returns the overlay instance or None if not a QWidget. + """ + if not isinstance(self, QWidget): + return None + overlay = getattr(self, "_busy_overlay", None) + 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) + self._busy_overlay = overlay + return overlay + + def _init_busy_loader(self, *, start_busy: bool = False, busy_text: str = "Loading…") -> 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()) + self._busy_overlay.raise_() + self._busy_overlay.show() + + def set_busy(self, enabled: bool, text: str | None = None) -> None: + """ + Enable/disable the loading overlay. Optionally update the 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. + """ + 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 enabled: + self._busy_overlay.setGeometry(self.rect()) + self._busy_overlay.raise_() + self._busy_overlay.show() + else: + self._busy_overlay.hide() + self._loading = bool(enabled) + + def is_busy(self) -> bool: + """ + Check if the loading overlay is enabled. + + Returns: + bool: True if the loading overlay is enabled, False otherwise. + """ + 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): """ @@ -92,6 +177,14 @@ class BECWidget(BECConnector): theme(str, optional): The theme to be applied. """ + 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() + except Exception: + logger.warning(f"Failed to apply theme {theme} to {self}") + @SafeSlot() @SafeSlot(str) @rpc_timeout(None) @@ -150,6 +243,22 @@ class BECWidget(BECConnector): child.close() child.deleteLater() + # Tear down busy overlay explicitly to stop spinner and remove filters + overlay = getattr(self, "_busy_overlay", None) + if overlay is not None and shiboken6.isValid(overlay): + try: + overlay.hide() + filt = getattr(overlay, "_filter", None) + if filt is not None and shiboken6.isValid(filt): + try: + self.removeEventFilter(filt) + except Exception as exc: + logger.warning(f"Failed to remove event filter from busy overlay: {exc}") + 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.""" try: diff --git a/bec_widgets/utils/busy_loader.py b/bec_widgets/utils/busy_loader.py new file mode 100644 index 00000000..2305170e --- /dev/null +++ b/bec_widgets/utils/busy_loader.py @@ -0,0 +1,253 @@ +from __future__ import annotations + +from qtpy.QtCore import QEvent, QObject, Qt, QTimer +from qtpy.QtGui import QColor, QFont +from qtpy.QtWidgets import ( + QApplication, + QFrame, + QHBoxLayout, + QLabel, + QMainWindow, + QPushButton, + QVBoxLayout, + QWidget, +) + +from bec_widgets import BECWidget +from bec_widgets.utils.colors import apply_theme +from bec_widgets.widgets.plots.waveform.waveform import Waveform +from bec_widgets.widgets.utility.spinner.spinner import SpinnerWidget + + +class _OverlayEventFilter(QObject): + """Keeps the overlay sized and stacked over its target widget.""" + + def __init__(self, target: QWidget, overlay: QWidget): + super().__init__(target) + self._target = target + self._overlay = overlay + + def eventFilter(self, obj, event): + if obj is self._target and event.type() in ( + QEvent.Resize, + QEvent.Show, + QEvent.LayoutRequest, + QEvent.Move, + ): + self._overlay.setGeometry(self._target.rect()) + self._overlay.raise_() + return False + + +class BusyLoaderOverlay(QWidget): + """ + A semi-transparent scrim with centered text and an animated spinner. + Call show()/hide() directly, or use via `install_busy_loader(...)`. + + Args: + parent(QWidget): The parent widget to overlay. + text(str): Initial text to display. + opacity(float): Overlay opacity (0..1). + + Returns: + BusyLoaderOverlay: The overlay instance. + """ + + def __init__(self, parent: QWidget, text: str = "Loading…", 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._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) + + 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) + + 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() + + # Start hidden; interactions beneath are blocked while visible + self.hide() + + # --- API --- + def set_text(self, text: str): + """ + Update the overlay text. + + Args: + text(str): The text to display on the overlay. + """ + self._label.setText(text) + + def set_opacity(self, opacity: float): + """ + Set overlay opacity (0..1). + + 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 + self.update() + + 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") + else: + # Fallback neutrals + self._bg = QColor(30, 30, 30) + self._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};") + self._frame.setStyleSheet( + f"#busyFrame {{ border: 2px dashed {fg_hex}; border-radius: 9px; background-color: rgba(128, 128, 128, 110); }}" + ) + self.update() + + # --- QWidget overrides --- + def showEvent(self, e): + self._spinner.start() + super().showEvent(e) + + def hideEvent(self, e): + self._spinner.stop() + super().hideEvent(e) + + def resizeEvent(self, e): + super().resizeEvent(e) + r = self.rect().adjusted(10, 10, -10, -10) + self._frame.setGeometry(r) + + def paintEvent(self, e): + super().paintEvent(e) + + +def install_busy_loader( + target: QWidget, text: str = "Loading…", 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.setGeometry(target.rect()) + filt = _OverlayEventFilter(target, overlay) + overlay._filter = filt # type: ignore[attr-defined] + target.installEventFilter(filt) + if start_loading: + overlay.show() + return overlay + + +# -------------------------- +# 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…" + ) + + self._title = QLabel("Demo Content", self) + self._title.setAlignment(Qt.AlignCenter) + self._title.setFrameStyle(QFrame.Panel | QFrame.Sunken) + lay = QVBoxLayout(self) + lay.addWidget(self._title) + waveform = Waveform(self) + waveform.plot([1, 2, 3, 4, 5]) + lay.addWidget(waveform, 1) + + QTimer.singleShot(5000, self._ready) + + def _ready(self): + self._title.setText("Ready ✓") + self.set_busy(False) + + +class DemoWindow(QMainWindow): + def __init__(self): + super().__init__() + self.setWindowTitle("Busy Loader — BECWidget demo") + + left = DemoWidget() + 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_off.clicked.connect(lambda: right.set_busy(False)) + btn_text.clicked.connect(lambda: right.set_busy_text("Almost there…")) + + panel = QWidget() + prow = QVBoxLayout(panel) + prow.addWidget(btn_on) + prow.addWidget(btn_off) + prow.addWidget(btn_text) + prow.addStretch(1) + + central = QWidget() + row = QHBoxLayout(central) + row.setContentsMargins(12, 12, 12, 12) + row.setSpacing(12) + row.addWidget(left, 1) + row.addWidget(right, 1) + row.addWidget(panel, 0) + + self.setCentralWidget(central) + self.resize(900, 420) + + +if __name__ == "__main__": # pragma: no cover + import sys + + app = QApplication(sys.argv) + apply_theme("light") + w = DemoWindow() + w.show() + sys.exit(app.exec()) diff --git a/tests/unit_tests/test_busy_loader.py b/tests/unit_tests/test_busy_loader.py new file mode 100644 index 00000000..2f9e859c --- /dev/null +++ b/tests/unit_tests/test_busy_loader.py @@ -0,0 +1,145 @@ +import pytest +from qtpy.QtWidgets import QLabel, QVBoxLayout, QWidget + +from bec_widgets import BECWidget + +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, + ): + super().__init__( + parent=parent, + theme_update=theme_update, + start_busy=start_busy, + busy_text=busy_text, + **kwargs, + ) + lay = QVBoxLayout(self) + lay.setContentsMargins(0, 0, 0, 0) + lay.addWidget(QLabel("content", self)) + + +@pytest.fixture +def widget_busy(qtbot, mocked_client): + w = _TestBusyWidget(client=mocked_client, start_busy=True, busy_text="Initializing…") + qtbot.addWidget(w) + w.resize(320, 200) + w.show() + qtbot.waitExposed(w) + return w + + +@pytest.fixture +def widget_idle(qtbot): + w = _TestBusyWidget(client=mocked_client, start_busy=False) + qtbot.addWidget(w) + w.resize(320, 200) + w.show() + qtbot.waitExposed(w) + return w + + +def test_becwidget_start_busy_shows_overlay(qtbot, widget_busy): + overlay = getattr(widget_busy, "_busy_overlay", None) + assert overlay is not None, "BECWidget should create a busy overlay in __init__" + qtbot.waitUntil(lambda: overlay.geometry() == widget_busy.rect()) + qtbot.waitUntil(lambda: overlay.isVisible()) + + +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…") + overlay = getattr(widget_idle, "_busy_overlay") + qtbot.waitUntil(lambda: overlay.isVisible()) + + lbl = getattr(overlay, "_label") + assert lbl.text() == "Fetching data…" + + widget_idle.set_busy(False) + qtbot.waitUntil(lambda: overlay.isHidden()) + + +def test_becwidget_overlay_tracks_resize(qtbot, widget_busy): + overlay = getattr(widget_busy, "_busy_overlay") + qtbot.waitUntil(lambda: overlay.geometry() == widget_busy.rect()) + + widget_busy.resize(480, 260) + qtbot.waitUntil(lambda: overlay.geometry() == widget_busy.rect()) + + +def test_becwidget_overlay_frame_geometry_and_style(qtbot, widget_busy): + overlay = getattr(widget_busy, "_busy_overlay") + qtbot.waitUntil(lambda: overlay.isVisible()) + + frame = getattr(overlay, "_frame", None) + assert frame is not None, "Busy overlay must use an internal QFrame for visuals" + + # Insets are 10 px in the implementation + outer = overlay.rect() + # Ensure resizeEvent has run and frame geometry is updated + qtbot.waitUntil( + lambda: frame.geometry().width() == outer.width() - 20 + and frame.geometry().height() == outer.height() - 20 + ) + + inner = frame.geometry() + assert inner.left() == outer.left() + 10 + assert inner.top() == outer.top() + 10 + assert inner.right() == outer.right() - 10 + assert inner.bottom() == outer.bottom() - 10 + + # Style: dashed border + semi-transparent grey background + ss = frame.styleSheet() + assert "dashed" in ss + assert "border" in ss + 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" + + # Initially visible because start_busy=True + qtbot.waitUntil(lambda: overlay.isVisible()) + + # Switch OFF + widget_busy.set_busy(False) + qtbot.waitUntil(lambda: overlay.isHidden()) + + # Switch ON again (with new text) + widget_busy.set_busy(True, "Back to work…") + 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())