mirror of
https://github.com/bec-project/bec_widgets.git
synced 2026-05-01 04:22:34 +02:00
369 lines
14 KiB
Python
369 lines
14 KiB
Python
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.cli.rpc.rpc_register import RPCRegister
|
|
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.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
|