from __future__ import annotations from datetime import datetime from typing import TYPE_CHECKING import shiboken6 from bec_lib.logger import bec_logger from qtpy.QtCore import QBuffer, QByteArray, QIODevice, QObject, Qt from qtpy.QtGui import QFont, QPixmap from qtpy.QtWidgets import QApplication, QFileDialog, QLabel, QVBoxLayout, 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.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 from bec_widgets.widgets.containers.dock import BECDock logger = bec_logger.logger class BECWidget(BECConnector): """Mixin class for all BEC widgets, to handle cleanup""" # The icon name is the name of the icon in the icon theme, typically a name taken # from fonts.google.com/icons. Override this in subclasses to set the icon name. ICON_NAME = "widgets" USER_ACCESS = ["remove", "attach", "detach"] # pylint: disable=too-many-arguments def __init__( self, client=None, config: ConnectionConfig = None, gui_id: str | None = None, theme_update: bool = False, start_busy: bool = False, **kwargs, ): """ Base class for all BEC widgets. This class should be used as a mixin class for all BEC widgets, e.g.: >>> class MyWidget(BECWidget, QWidget): >>> def __init__(self, parent=None, client=None, config=None, gui_id=None): >>> super().__init__(parent=parent, client=client, config=config, gui_id=gui_id) Args: client(BECClient, optional): The BEC client. config(ConnectionConfig, optional): The connection configuration. gui_id(str, optional): The GUI ID. theme_update(bool, optional): Whether to subscribe to theme updates. Defaults to False. When set to True, the widget's apply_theme method will be called when the theme changes. """ super().__init__(client=client, config=config, gui_id=gui_id, **kwargs) if not isinstance(self, QObject): raise RuntimeError(f"{repr(self)} is not a subclass of QWidget") if theme_update: 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: "BusyLoaderOverlay" | None = None self._busy_state_widget: QWidget | None = None self._loading = False self._busy_overlay = self._install_busy_loader() if start_busy and isinstance(self, QWidget): self._show_busy_overlay() self._loading = True def _connect_to_theme_change(self): """Connect to the theme change signal.""" qapp = QApplication.instance() if hasattr(qapp, "theme"): SafeConnect(self, qapp.theme.theme_changed, self._update_theme) @SafeSlot(str) @SafeSlot() def _update_theme(self, theme: str | None = None): """Update the theme.""" if theme is None: qapp = QApplication.instance() if hasattr(qapp, "theme"): theme = qapp.theme.theme else: theme = "dark" self._update_overlay_theme(theme) self.apply_theme(theme) def create_busy_state_widget(self) -> QWidget: """ Method to create a custom busy state widget to be shown in the busy overlay. Child classes should overrid this method to provide a custom widget if desired. Returns: 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 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 def _install_busy_loader(self) -> "BusyLoaderOverlay" | None: """ 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: overlay = install_busy_loader(target=self, start_loading=False) self._busy_overlay = overlay # Create and set the busy state widget self._busy_state_widget = self.create_busy_state_widget() self._busy_overlay.set_widget(self._busy_state_widget) return overlay def _show_busy_overlay(self) -> None: """Create and attach the loading overlay to this widget if QWidget is present.""" if not isinstance(self, QWidget): return if self._busy_overlay is not None: self._busy_overlay.setGeometry(self.rect()) # pylint: disable=no-member self._busy_overlay.raise_() self._busy_overlay.show() def set_busy(self, enabled: bool) -> None: """ Set the busy state of the widget. This will show or hide the loading overlay, which will block user interaction with the widget and show the busy_state_widget if provided. Per default, the busy state widget is a spinner with "Loading..." text. Args: enabled(bool): Whether to enable the busy state. """ if not isinstance(self, QWidget): return # If not yet installed, install the busy overlay now together with the busy state widget if self._busy_overlay is None: self._busy_overlay = self._install_busy_loader() if enabled: self._show_busy_overlay() 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)) @SafeSlot(str) def apply_theme(self, theme: str): """ Apply the theme to the widget. Args: 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: overlay._update_palette() except Exception: logger.warning(f"Failed to apply theme {theme} to {self}") def get_help_md(self) -> str: """ Method to override in subclasses to provide help text in markdown format. Returns: str: The help text in markdown format. """ return "" @SafeSlot() @SafeSlot(str) @rpc_timeout(None) def screenshot(self, file_name: str | None = None): """ Take a screenshot of the dock area and save it to a file. """ if not isinstance(self, QWidget): logger.error("Cannot take screenshot of non-QWidget instance") return screenshot = self.grab() if file_name is None: file_name, _ = QFileDialog.getSaveFileName( self, "Save Screenshot", f"bec_{datetime.now().strftime('%Y%m%d_%H%M%S')}.png", "PNG Files (*.png);;JPEG Files (*.jpg *.jpeg);;All Files (*)", ) if not file_name: return screenshot.save(file_name) logger.info(f"Screenshot saved to {file_name}") def screenshot_bytes( self, *, max_width: int | None = None, max_height: int | None = None, fmt: str = "PNG", quality: int = -1, ) -> QByteArray: """ Grab this widget, optionally scale to a max size, and return encoded image bytes. If max_width/max_height are omitted (the default), capture at full resolution. Args: max_width(int, optional): Maximum width of the screenshot. max_height(int, optional): Maximum height of the screenshot. fmt(str, optional): Image format (e.g., "PNG", "JPEG"). quality(int, optional): Image quality (0-100), -1 for default. Returns: QByteArray: The screenshot image bytes. """ if not isinstance(self, QWidget): return QByteArray() if not hasattr(self, "grab"): raise RuntimeError(f"Cannot take screenshot of non-QWidget instance: {repr(self)}") pixmap: QPixmap = self.grab() if pixmap.isNull(): return QByteArray() if max_width is not None or max_height is not None: w = max_width if max_width is not None else pixmap.width() h = max_height if max_height is not None else pixmap.height() pixmap = pixmap.scaled( w, h, Qt.AspectRatioMode.KeepAspectRatio, Qt.QSmoothTransformation ) ba = QByteArray() buf = QBuffer(ba) buf.open(QIODevice.OpenModeFlag.WriteOnly) pixmap.save(buf, fmt, quality) buf.close() return ba def attach(self): dock = WidgetHierarchy.find_ancestor(self, QtAds.CDockWidget) if dock is None: return if not dock.isFloating(): return dock.dockManager().addDockWidget(QtAds.DockWidgetArea.RightDockWidgetArea, dock) def detach(self): """ Detach the widget from its parent dock widget (if widget is in the dock), making it a floating widget. """ dock = WidgetHierarchy.find_ancestor(self, QtAds.CDockWidget) if dock is None: return if dock.isFloating(): return dock.setFloating() def cleanup(self): """Cleanup the widget.""" with RPCRegister.delayed_broadcast(): # All widgets need to call super().cleanup() in their cleanup method logger.info(f"Registry cleanup for widget {self.__class__.__name__}") self.rpc_register.remove_rpc(self) children = self.findChildren(BECWidget) for child in children: if not shiboken6.isValid(child): # If the child is not valid, it means it has already been deleted continue 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}") # Cleanup the overlay widget. This will call cleanup on the custom widget if present. overlay.cleanup() overlay.deleteLater() except Exception as exc: logger.warning(f"Failed to delete busy overlay: {exc}") def closeEvent(self, event): """Wrap the close even to ensure the rpc_register is cleaned up.""" try: if not self._destroyed: self.cleanup() self._destroyed = True finally: super().closeEvent(event) # pylint: disable=no-member