1
0
mirror of https://github.com/bec-project/bec_widgets.git synced 2026-03-04 16:02:51 +01:00

feat(busy_loader): busy loader added to bec widget base class

This commit is contained in:
2025-09-24 12:31:07 -05:00
parent 092ac915a8
commit 3dd688540e
3 changed files with 507 additions and 0 deletions

View File

@@ -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:

View File

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

View File

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