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())