From d2d74869307cfb1fc45c7f454484b59a51bc10bb Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 21:29:59 +0000 Subject: [PATCH 1/2] chore(quick): bring in the shared QML host and ux kit from claude/qml-widget-ports-s7h02q Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01Kns5hgJ1MEXNfFeCuTESFP --- bec_widgets/utils/quick/__init__.py | 26 ++ bec_widgets/utils/quick/host.py | 233 ++++++++++ bec_widgets/utils/quick/list_model.py | 83 ++++ bec_widgets/utils/quick/qml/BecUi/Card.qml | 56 +++ .../utils/quick/qml/BecUi/FieldFrame.qml | 12 + bec_widgets/utils/quick/qml/BecUi/Icon.qml | 25 ++ .../utils/quick/qml/BecUi/IconButton.qml | 36 ++ .../utils/quick/qml/BecUi/InputField.qml | 38 ++ .../utils/quick/qml/BecUi/SelectField.qml | 88 ++++ .../utils/quick/qml/BecUi/StatusPill.qml | 40 ++ .../utils/quick/qml/BecUi/SwitchField.qml | 34 ++ .../utils/quick/qml/BecUi/TextButton.qml | 65 +++ bec_widgets/utils/quick/qml/BecUi/qmldir | 10 + bec_widgets/utils/ux_kit.py | 420 ++++++++++++++++++ 14 files changed, 1166 insertions(+) create mode 100644 bec_widgets/utils/quick/__init__.py create mode 100644 bec_widgets/utils/quick/host.py create mode 100644 bec_widgets/utils/quick/list_model.py create mode 100644 bec_widgets/utils/quick/qml/BecUi/Card.qml create mode 100644 bec_widgets/utils/quick/qml/BecUi/FieldFrame.qml create mode 100644 bec_widgets/utils/quick/qml/BecUi/Icon.qml create mode 100644 bec_widgets/utils/quick/qml/BecUi/IconButton.qml create mode 100644 bec_widgets/utils/quick/qml/BecUi/InputField.qml create mode 100644 bec_widgets/utils/quick/qml/BecUi/SelectField.qml create mode 100644 bec_widgets/utils/quick/qml/BecUi/StatusPill.qml create mode 100644 bec_widgets/utils/quick/qml/BecUi/SwitchField.qml create mode 100644 bec_widgets/utils/quick/qml/BecUi/TextButton.qml create mode 100644 bec_widgets/utils/quick/qml/BecUi/qmldir create mode 100644 bec_widgets/utils/ux_kit.py diff --git a/bec_widgets/utils/quick/__init__.py b/bec_widgets/utils/quick/__init__.py new file mode 100644 index 00000000..dcc683f1 --- /dev/null +++ b/bec_widgets/utils/quick/__init__.py @@ -0,0 +1,26 @@ +"""Shared plumbing for widgets that render their content with Qt Quick (QML). + +The QML ports keep the usual ``BECWidget`` shell and public API and only swap the rendering +layer: a :class:`~qtpy.QtQuickWidgets.QQuickWidget` is embedded into the existing widget and +driven by a small Python backend object. +""" + +from bec_widgets.utils.quick.host import ( + QML_IMPORT_PATH, + MaterialIconProvider, + QmlTheme, + create_quick_widget, + quick_engine, + release_quick_widget, +) +from bec_widgets.utils.quick.list_model import DictListModel + +__all__ = [ + "QML_IMPORT_PATH", + "DictListModel", + "MaterialIconProvider", + "QmlTheme", + "create_quick_widget", + "quick_engine", + "release_quick_widget", +] diff --git a/bec_widgets/utils/quick/host.py b/bec_widgets/utils/quick/host.py new file mode 100644 index 00000000..0df3b172 --- /dev/null +++ b/bec_widgets/utils/quick/host.py @@ -0,0 +1,233 @@ +"""Hosting helpers for QML views embedded into BEC widgets. + +All QML views share one :class:`QQmlEngine` per application. The engine carries the +application-wide ``theme`` context property (a :class:`QmlTheme` bridging ``app.theme`` from +``bec_qthemes``), an ``image://material/`` provider for Material icons and the import path +of the shared ``BecUi`` control module. Per-widget state is handed to the view through initial +properties of the root item, typically a single ``backend`` QObject. +""" + +from __future__ import annotations + +from pathlib import Path +from urllib.parse import parse_qs + +from bec_lib.logger import bec_logger +from bec_qthemes import material_icon +from qtpy.QtCore import Property, QObject, QSize, Qt, QUrl, Signal +from qtpy.QtGui import QColor, QPixmap +from qtpy.QtQml import QQmlEngine +from qtpy.QtQuick import QQuickImageProvider +from qtpy.QtQuickWidgets import QQuickWidget +from qtpy.QtWidgets import QApplication, QWidget + +logger = bec_logger.logger + +QML_IMPORT_PATH = Path(__file__).parent / "qml" + +_DARK_FALLBACK = { + "PRIMARY": "#3b82f6", + "ON_PRIMARY": "#ffffff", + "BG": "#0f1115", + "CARD_BG": "#171a21", + "FIELD_BG": "#10131a", + "BORDER": "#2a2f3a", + "FG": "#e8ebf1", + "ACCENT_DEFAULT": "#8ab4f7", + "ACCENT_HIGHLIGHT": "#B53565", + "ACCENT_WARNING": "#EAC435", + "ACCENT_EMERGENCY": "#CC181E", + "ACCENT_SUCCESS": "#2CA58D", +} + + +def _blend(base: QColor, top: QColor, amount: float) -> QColor: + """Return ``top`` mixed into ``base`` by ``amount`` (0 = base, 1 = top).""" + return QColor.fromRgbF( + base.redF() + (top.redF() - base.redF()) * amount, + base.greenF() + (top.greenF() - base.greenF()) * amount, + base.blueF() + (top.blueF() - base.blueF()) * amount, + 1.0, + ) + + +class ThemeTokens: + """Resolved colour tokens of the active BEC theme, shared by the QML and QWidget ports.""" + + def __init__(self): + app = QApplication.instance() + theme = getattr(app, "theme", None) if app is not None else None + name = getattr(theme, "theme", "dark") if theme is not None else "dark" + + def color(key: str) -> QColor: + fallback = _DARK_FALLBACK.get(key, "#808080") + if theme is None: + return QColor(fallback) + return QColor(theme.color(key, fallback)) + + self.name: str = str(name) + self.bg = color("BG") + self.card = color("CARD_BG") + self.field = color("FIELD_BG") + self.border = color("BORDER") + self.fg = color("FG") + self.primary = color("PRIMARY") + self.on_primary = color("ON_PRIMARY") + self.accent = color("ACCENT_DEFAULT") + self.highlight = color("ACCENT_HIGHLIGHT") + self.warning = color("ACCENT_WARNING") + self.danger = color("ACCENT_EMERGENCY") + self.success = color("ACCENT_SUCCESS") + self.dark = self.bg.lightnessF() < 0.5 + self.fg_muted = _blend(self.card, self.fg, 0.62) + self.fg_subtle = _blend(self.card, self.fg, 0.4) + self.hover = _blend(self.card, self.fg, 0.06) + self.pressed = _blend(self.card, self.fg, 0.12) + self.track = _blend(self.card, self.fg, 0.1) + + def soft(self, color: QColor, amount: float = 0.16) -> QColor: + """Tint ``color`` onto the card background, e.g. for chips and banners.""" + return _blend(self.card, color, amount) + + +class QmlTheme(QObject): + """Expose the BEC theme colours to QML as ``theme.`` and follow theme changes.""" + + changed = Signal() + + def __init__(self, parent: QObject | None = None): + super().__init__(parent) + self._tokens = ThemeTokens() + self._connected_theme = None + self._connect_theme() + + def _connect_theme(self) -> None: + app = QApplication.instance() + theme = getattr(app, "theme", None) if app is not None else None + if theme is None or theme is self._connected_theme: + return + theme.theme_changed.connect(self.refresh) + self._connected_theme = theme + + def refresh(self, *_args) -> None: + """Re-read the tokens from the application theme.""" + self._connect_theme() + self._tokens = ThemeTokens() + self.changed.emit() + + # pylint: disable=missing-function-docstring + def _get(self, name: str) -> QColor: + return getattr(self._tokens, name) + + name = Property(str, lambda self: self._tokens.name, notify=changed) + dark = Property(bool, lambda self: self._tokens.dark, notify=changed) + bg = Property(QColor, lambda self: self._get("bg"), notify=changed) + card = Property(QColor, lambda self: self._get("card"), notify=changed) + field = Property(QColor, lambda self: self._get("field"), notify=changed) + border = Property(QColor, lambda self: self._get("border"), notify=changed) + fg = Property(QColor, lambda self: self._get("fg"), notify=changed) + fgMuted = Property(QColor, lambda self: self._get("fg_muted"), notify=changed) + fgSubtle = Property(QColor, lambda self: self._get("fg_subtle"), notify=changed) + hover = Property(QColor, lambda self: self._get("hover"), notify=changed) + pressed = Property(QColor, lambda self: self._get("pressed"), notify=changed) + track = Property(QColor, lambda self: self._get("track"), notify=changed) + primary = Property(QColor, lambda self: self._get("primary"), notify=changed) + onPrimary = Property(QColor, lambda self: self._get("on_primary"), notify=changed) + accent = Property(QColor, lambda self: self._get("accent"), notify=changed) + highlight = Property(QColor, lambda self: self._get("highlight"), notify=changed) + warning = Property(QColor, lambda self: self._get("warning"), notify=changed) + danger = Property(QColor, lambda self: self._get("danger"), notify=changed) + success = Property(QColor, lambda self: self._get("success"), notify=changed) + + +class MaterialIconProvider(QQuickImageProvider): + """Serve Material icons to QML as ``image://material/?color=%23rrggbb&filled=1``.""" + + def __init__(self): + super().__init__(QQuickImageProvider.ImageType.Pixmap) + + def requestPixmap(self, icon_id: str, size: QSize, requested_size: QSize) -> QPixmap: + name, _, query = icon_id.partition("?") + options = parse_qs(query) + color = options.get("color", [None])[0] + filled = options.get("filled", ["0"])[0] in ("1", "true") + target = requested_size if requested_size.isValid() else QSize(24, 24) + if target.width() <= 0 or target.height() <= 0: + target = QSize(24, 24) + pixmap = material_icon(name, size=target, color=color, filled=filled) + if size is not None: + size.setWidth(pixmap.width()) + size.setHeight(pixmap.height()) + return pixmap + + +_ENGINE: QQmlEngine | None = None +_THEME: QmlTheme | None = None + + +def quick_engine() -> QQmlEngine: + """Return the application-wide QML engine used by all BEC QML views.""" + global _ENGINE, _THEME # pylint: disable=global-statement + if _ENGINE is not None: + if _THEME._connected_theme is None: # pylint: disable=protected-access + # the theme was applied after the engine was created + _THEME.refresh() + return _ENGINE + app = QApplication.instance() + _ENGINE = QQmlEngine(app) + _ENGINE.addImportPath(str(QML_IMPORT_PATH)) + _ENGINE.addImageProvider("material", MaterialIconProvider()) + _THEME = QmlTheme(_ENGINE) + _ENGINE.rootContext().setContextProperty("theme", _THEME) + _ENGINE.warnings.connect( + lambda warnings: [logger.warning(f"QML: {w.toString()}") for w in warnings] + ) + if app is not None: + app.aboutToQuit.connect(_drop_engine) + return _ENGINE + + +def _drop_engine() -> None: + global _ENGINE, _THEME # pylint: disable=global-statement + _ENGINE = None + _THEME = None + + +def create_quick_widget( + parent: QWidget, qml_file: str | Path, properties: dict | None = None +) -> QQuickWidget: + """Create a QQuickWidget showing ``qml_file`` on the shared engine. + + Args: + parent(QWidget): Parent widget, usually the BEC widget shell. + qml_file(str | Path): Path of the root QML file. + properties(dict | None): Initial properties of the root item, e.g. ``{"backend": obj}``. + + Returns: + QQuickWidget: The view, already loaded. Load errors are logged. + """ + view = QQuickWidget(quick_engine(), parent) + view.setResizeMode(QQuickWidget.ResizeMode.SizeRootObjectToView) + view.setAttribute(Qt.WidgetAttribute.WA_AlwaysStackOnTop, False) + view.setClearColor(parent.palette().window().color()) + if properties: + view.setInitialProperties(properties) + view.setSource(QUrl.fromLocalFile(str(qml_file))) + for error in view.errors(): + logger.error(f"QML error in {qml_file}: {error.toString()}") + return view + + +def release_quick_widget(view: QQuickWidget | None) -> None: + """Unload the QML scene of ``view`` before its backend objects are destroyed. + + Without this, bindings of a still-loaded scene are re-evaluated against already deleted + backends during teardown and log ``TypeError`` warnings. + """ + if view is None: + return + try: + view.setSource(QUrl()) + except RuntimeError: + # the C++ object is already gone + return diff --git a/bec_widgets/utils/quick/list_model.py b/bec_widgets/utils/quick/list_model.py new file mode 100644 index 00000000..6351b93d --- /dev/null +++ b/bec_widgets/utils/quick/list_model.py @@ -0,0 +1,83 @@ +"""A small list model that feeds lists of dictionaries to QML views.""" + +from __future__ import annotations + +from typing import Any, Sequence + +from qtpy.QtCore import Property, QAbstractListModel, QByteArray, QModelIndex, Qt, Signal + + +class DictListModel(QAbstractListModel): + """List model whose rows are plain dictionaries with a fixed set of keys. + + Each key becomes a QML role. :meth:`set_items` updates rows in place when the row count is + unchanged, so QML delegates are kept alive and ``Behavior`` animations run on value changes + instead of the delegates being rebuilt. + + Args: + roles(Sequence[str]): Keys of the row dictionaries exposed as roles. + parent: Parent QObject. + """ + + count_changed = Signal() + + def __init__(self, roles: Sequence[str], parent=None): + super().__init__(parent) + self._keys = list(roles) + self._roles = {Qt.ItemDataRole.UserRole + 1 + i: key for i, key in enumerate(self._keys)} + self._items: list[dict[str, Any]] = [] + + # pylint: disable=invalid-name + def roleNames(self) -> dict[int, QByteArray]: + return {role: QByteArray(key.encode()) for role, key in self._roles.items()} + + def rowCount(self, parent: QModelIndex = QModelIndex()) -> int: + if parent.isValid(): + return 0 + return len(self._items) + + def data(self, index: QModelIndex, role: int = Qt.ItemDataRole.DisplayRole): + if not index.isValid() or not 0 <= index.row() < len(self._items): + return None + key = self._roles.get(role) + if key is None: + return None + return self._items[index.row()].get(key) + + @property + def items(self) -> list[dict[str, Any]]: + """Copy of the current rows.""" + return [dict(item) for item in self._items] + + def set_items(self, items: Sequence[dict[str, Any]]) -> None: + """Replace the rows, reusing existing rows where possible. + + Args: + items(Sequence[dict]): New rows. + """ + items = [dict(item) for item in items] + old_count = len(self._items) + new_count = len(items) + common = min(old_count, new_count) + for row in range(common): + changed = [ + role + for role, key in self._roles.items() + if self._items[row].get(key) != items[row].get(key) + ] + self._items[row] = items[row] + if changed: + index = self.index(row, 0) + self.dataChanged.emit(index, index, changed) + if new_count > old_count: + self.beginInsertRows(QModelIndex(), old_count, new_count - 1) + self._items.extend(items[old_count:]) + self.endInsertRows() + elif new_count < old_count: + self.beginRemoveRows(QModelIndex(), new_count, old_count - 1) + del self._items[new_count:] + self.endRemoveRows() + if new_count != old_count: + self.count_changed.emit() + + count = Property(int, lambda self: len(self._items), notify=count_changed) diff --git a/bec_widgets/utils/quick/qml/BecUi/Card.qml b/bec_widgets/utils/quick/qml/BecUi/Card.qml new file mode 100644 index 00000000..2451958f --- /dev/null +++ b/bec_widgets/utils/quick/qml/BecUi/Card.qml @@ -0,0 +1,56 @@ +import QtQuick +import QtQuick.Layouts + +// Rounded surface with an optional title row. Children go into the content column. +Rectangle { + id: root + property string title: "" + property string subtitle: "" + default property alias content: column.data + property alias headerExtras: extras.data + property int padding: 12 + property alias spacing: column.spacing + // Let the content column take the remaining height, e.g. for a list that fills the card. + property bool fillContent: false + + color: theme.card + radius: 10 + border.color: theme.border + border.width: 1 + implicitHeight: outer.implicitHeight + 2 * padding + implicitWidth: outer.implicitWidth + 2 * padding + + ColumnLayout { + id: outer + anchors.fill: parent + anchors.margins: root.padding + spacing: 10 + RowLayout { + visible: root.title !== "" || extras.children.length > 0 + Layout.fillWidth: true + spacing: 8 + Text { + text: root.title + color: theme.fg + font.pixelSize: 13 + font.weight: Font.DemiBold + } + Text { + text: root.subtitle + visible: text !== "" + color: theme.fgSubtle + font.pixelSize: 12 + Layout.fillWidth: true + elide: Text.ElideRight + } + Item { Layout.fillWidth: root.subtitle === "" } + Row { id: extras; spacing: 4 } + } + ColumnLayout { + id: column + Layout.fillWidth: true + Layout.fillHeight: root.fillContent + spacing: 8 + } + } +} diff --git a/bec_widgets/utils/quick/qml/BecUi/FieldFrame.qml b/bec_widgets/utils/quick/qml/BecUi/FieldFrame.qml new file mode 100644 index 00000000..aad88d6a --- /dev/null +++ b/bec_widgets/utils/quick/qml/BecUi/FieldFrame.qml @@ -0,0 +1,12 @@ +import QtQuick + +// Background used by all input controls: field colour, border, focus and error states. +Rectangle { + property bool focused: false + property bool invalid: false + property bool hovered: false + radius: 6 + color: theme.field + border.width: focused || invalid ? 2 : 1 + border.color: invalid ? theme.danger : focused ? theme.primary : hovered ? theme.fgSubtle : theme.border +} diff --git a/bec_widgets/utils/quick/qml/BecUi/Icon.qml b/bec_widgets/utils/quick/qml/BecUi/Icon.qml new file mode 100644 index 00000000..d9fbedc4 --- /dev/null +++ b/bec_widgets/utils/quick/qml/BecUi/Icon.qml @@ -0,0 +1,25 @@ +import QtQuick + +// Material icon served by the "material" image provider, tinted with the given colour. +Item { + id: root + property string name: "" + property color color: theme.fg + property bool filled: false + property int size: 18 + + implicitWidth: size + implicitHeight: size + + Image { + anchors.centerIn: parent + width: root.size + height: root.size + sourceSize.width: root.size * 2 + sourceSize.height: root.size * 2 + smooth: true + fillMode: Image.PreserveAspectFit + source: root.name === "" ? "" : "image://material/" + root.name + "?color=" + + encodeURIComponent(root.color.toString()) + (root.filled ? "&filled=1" : "") + } +} diff --git a/bec_widgets/utils/quick/qml/BecUi/IconButton.qml b/bec_widgets/utils/quick/qml/BecUi/IconButton.qml new file mode 100644 index 00000000..9603aa81 --- /dev/null +++ b/bec_widgets/utils/quick/qml/BecUi/IconButton.qml @@ -0,0 +1,36 @@ +import QtQuick +import QtQuick.Controls.Basic + +// Flat, square icon button with hover and pressed feedback and a tooltip. +AbstractButton { + id: root + property string iconName: "" + property color iconColor: theme.fgMuted + property string tip: "" + property int iconSize: 18 + + implicitWidth: 30 + implicitHeight: 30 + hoverEnabled: true + focusPolicy: Qt.TabFocus + opacity: enabled ? 1.0 : 0.4 + Accessible.name: tip + + background: Rectangle { + radius: 6 + color: root.down ? theme.pressed : (root.hovered || root.checked ? theme.hover : "transparent") + border.width: root.visualFocus ? 1 : 0 + border.color: theme.primary + } + contentItem: Item { + Icon { + anchors.centerIn: parent + name: root.iconName + size: root.iconSize + color: root.checked ? theme.primary : root.iconColor + } + } + ToolTip.visible: tip !== "" && hovered + ToolTip.delay: 500 + ToolTip.text: tip +} diff --git a/bec_widgets/utils/quick/qml/BecUi/InputField.qml b/bec_widgets/utils/quick/qml/BecUi/InputField.qml new file mode 100644 index 00000000..0ed06d5f --- /dev/null +++ b/bec_widgets/utils/quick/qml/BecUi/InputField.qml @@ -0,0 +1,38 @@ +import QtQuick +import QtQuick.Controls.Basic + +// Single-line text input with optional unit suffix and error state. +TextField { + id: root + property bool invalid: false + property string suffix: "" + + implicitHeight: 32 + implicitWidth: 120 + color: theme.fg + placeholderTextColor: theme.fgSubtle + selectionColor: theme.primary + selectedTextColor: theme.onPrimary + font.pixelSize: 13 + leftPadding: 9 + rightPadding: suffixLabel.visible ? suffixLabel.implicitWidth + 14 : 9 + selectByMouse: true + hoverEnabled: true + opacity: enabled ? 1.0 : 0.5 + + background: FieldFrame { + focused: root.activeFocus + invalid: root.invalid + hovered: root.hovered + } + Text { + id: suffixLabel + visible: root.suffix !== "" + text: root.suffix + color: theme.fgSubtle + font.pixelSize: 12 + anchors.right: parent.right + anchors.rightMargin: 9 + anchors.verticalCenter: parent.verticalCenter + } +} diff --git a/bec_widgets/utils/quick/qml/BecUi/SelectField.qml b/bec_widgets/utils/quick/qml/BecUi/SelectField.qml new file mode 100644 index 00000000..2d4f814b --- /dev/null +++ b/bec_widgets/utils/quick/qml/BecUi/SelectField.qml @@ -0,0 +1,88 @@ +import QtQuick +import QtQuick.Controls.Basic + +// Combo box styled like the input fields. With editable: true it filters while typing. +ComboBox { + id: root + property bool invalid: false + property string placeholder: "" + + implicitHeight: 32 + implicitWidth: 140 + font.pixelSize: 13 + hoverEnabled: true + opacity: enabled ? 1.0 : 0.5 + + background: FieldFrame { + focused: root.activeFocus || root.popup.visible + invalid: root.invalid + hovered: root.hovered + } + contentItem: TextField { + leftPadding: 9 + rightPadding: 4 + text: root.editable ? root.editText : root.displayText + readOnly: !root.editable + placeholderText: root.placeholder + placeholderTextColor: theme.fgSubtle + enabled: root.enabled + color: theme.fg + selectionColor: theme.primary + selectedTextColor: theme.onPrimary + font: root.font + verticalAlignment: Text.AlignVCenter + background: null + selectByMouse: true + // forward clicks to the combo when it is not editable + TapHandler { enabled: !root.editable; onTapped: root.popup.visible ? root.popup.close() : root.popup.open() } + } + indicator: Icon { + x: root.width - width - 6 + y: (root.height - height) / 2 + name: "expand_more" + size: 18 + color: theme.fgMuted + } + delegate: ItemDelegate { + id: option + required property int index + required property var modelData + width: ListView.view ? ListView.view.width : root.width + height: 30 + highlighted: root.highlightedIndex === index + contentItem: Text { + text: option.modelData === "" ? "None" : option.modelData + color: option.modelData === "" ? theme.fgSubtle : theme.fg + font.pixelSize: 13 + elide: Text.ElideRight + verticalAlignment: Text.AlignVCenter + } + background: Rectangle { + color: option.highlighted ? theme.hover : "transparent" + Rectangle { + width: 3; height: parent.height - 10; radius: 1.5 + anchors.verticalCenter: parent.verticalCenter + color: theme.primary + visible: root.currentIndex === option.index + } + } + } + popup: Popup { + y: root.height + 2 + width: root.width + implicitHeight: Math.min(contentItem.implicitHeight + 8, 280) + padding: 4 + contentItem: ListView { + clip: true + implicitHeight: contentHeight + model: root.popup.visible ? root.delegateModel : null + currentIndex: root.highlightedIndex + ScrollIndicator.vertical: ScrollIndicator {} + } + background: Rectangle { + color: theme.card + border.color: theme.border + radius: 8 + } + } +} diff --git a/bec_widgets/utils/quick/qml/BecUi/StatusPill.qml b/bec_widgets/utils/quick/qml/BecUi/StatusPill.qml new file mode 100644 index 00000000..7901c832 --- /dev/null +++ b/bec_widgets/utils/quick/qml/BecUi/StatusPill.qml @@ -0,0 +1,40 @@ +import QtQuick + +// Small rounded status chip with a coloured dot. Set pulse for live states such as "Moving". +Rectangle { + id: root + property string text: "" + property color tone: theme.fgMuted + property bool pulse: false + + implicitHeight: 22 + implicitWidth: label.implicitWidth + 26 + radius: height / 2 + color: Qt.rgba(tone.r, tone.g, tone.b, 0.16) + + Rectangle { + id: dot + width: 7; height: 7; radius: 3.5 + color: root.tone + anchors.left: parent.left + anchors.leftMargin: 9 + anchors.verticalCenter: parent.verticalCenter + SequentialAnimation on opacity { + running: root.pulse + loops: Animation.Infinite + alwaysRunToEnd: true + NumberAnimation { to: 0.25; duration: 550; easing.type: Easing.InOutQuad } + NumberAnimation { to: 1.0; duration: 550; easing.type: Easing.InOutQuad } + } + } + Text { + id: label + anchors.left: dot.right + anchors.leftMargin: 6 + anchors.verticalCenter: parent.verticalCenter + text: root.text + color: root.tone + font.pixelSize: 12 + font.weight: Font.DemiBold + } +} diff --git a/bec_widgets/utils/quick/qml/BecUi/SwitchField.qml b/bec_widgets/utils/quick/qml/BecUi/SwitchField.qml new file mode 100644 index 00000000..a21f3221 --- /dev/null +++ b/bec_widgets/utils/quick/qml/BecUi/SwitchField.qml @@ -0,0 +1,34 @@ +import QtQuick +import QtQuick.Controls.Basic + +// Toggle switch for boolean parameters. +Switch { + id: root + implicitHeight: 32 + hoverEnabled: true + padding: 0 + indicator: Rectangle { + implicitWidth: 38 + implicitHeight: 22 + x: root.leftPadding + y: (root.height - height) / 2 + radius: 11 + color: root.checked ? theme.primary : theme.track + border.color: root.visualFocus ? theme.primary : (root.checked ? theme.primary : theme.border) + Behavior on color { ColorAnimation { duration: 120 } } + Rectangle { + x: root.checked ? parent.width - width - 3 : 3 + y: 3 + width: 16; height: 16; radius: 8 + color: root.checked ? theme.onPrimary : theme.fgMuted + Behavior on x { NumberAnimation { duration: 120; easing.type: Easing.OutCubic } } + } + } + contentItem: Text { + leftPadding: root.indicator.width + 8 + text: root.text + color: theme.fg + font.pixelSize: 13 + verticalAlignment: Text.AlignVCenter + } +} diff --git a/bec_widgets/utils/quick/qml/BecUi/TextButton.qml b/bec_widgets/utils/quick/qml/BecUi/TextButton.qml new file mode 100644 index 00000000..45b069dc --- /dev/null +++ b/bec_widgets/utils/quick/qml/BecUi/TextButton.qml @@ -0,0 +1,65 @@ +import QtQuick +import QtQuick.Controls.Basic +import QtQuick.Layouts + +// Labelled button. variant: "primary", "danger", "success", "neutral" or "ghost". +AbstractButton { + id: root + property string variant: "neutral" + property string iconName: "" + property string tip: "" + property bool busy: false + + readonly property color baseColor: variant === "primary" ? theme.primary + : variant === "danger" ? theme.danger + : variant === "success" ? theme.success + : "transparent" + readonly property bool solid: variant === "primary" || variant === "danger" || variant === "success" + readonly property color labelColor: solid ? "white" : theme.fg + + implicitHeight: 32 + implicitWidth: Math.max(64, row.implicitWidth + 24) + hoverEnabled: true + opacity: enabled ? 1.0 : 0.45 + Accessible.name: text + + background: Rectangle { + radius: 6 + color: root.solid ? (root.down ? Qt.darker(root.baseColor, 1.25) : root.hovered ? Qt.lighter(root.baseColor, 1.1) : root.baseColor) + : (root.down ? theme.pressed : root.hovered ? theme.hover : "transparent") + border.width: root.variant === "neutral" || root.visualFocus ? 1 : 0 + border.color: root.visualFocus ? theme.primary : theme.border + Behavior on color { ColorAnimation { duration: 90 } } + } + contentItem: Item { + implicitWidth: row.implicitWidth + implicitHeight: row.implicitHeight + RowLayout { + id: row + anchors.centerIn: parent + spacing: 6 + BusyIndicator { + visible: root.busy + running: root.busy + Layout.preferredWidth: 16 + Layout.preferredHeight: 16 + } + Icon { + visible: root.iconName !== "" && !root.busy + name: root.iconName + color: root.labelColor + size: 16 + } + Text { + text: root.text + color: root.labelColor + font.pixelSize: 13 + font.weight: root.solid ? Font.DemiBold : Font.Medium + elide: Text.ElideRight + } + } + } + ToolTip.visible: tip !== "" && hovered + ToolTip.delay: 500 + ToolTip.text: tip +} diff --git a/bec_widgets/utils/quick/qml/BecUi/qmldir b/bec_widgets/utils/quick/qml/BecUi/qmldir new file mode 100644 index 00000000..160907bc --- /dev/null +++ b/bec_widgets/utils/quick/qml/BecUi/qmldir @@ -0,0 +1,10 @@ +module BecUi +Icon 1.0 Icon.qml +IconButton 1.0 IconButton.qml +TextButton 1.0 TextButton.qml +StatusPill 1.0 StatusPill.qml +Card 1.0 Card.qml +FieldFrame 1.0 FieldFrame.qml +InputField 1.0 InputField.qml +SelectField 1.0 SelectField.qml +SwitchField 1.0 SwitchField.qml diff --git a/bec_widgets/utils/ux_kit.py b/bec_widgets/utils/ux_kit.py new file mode 100644 index 00000000..c8497afc --- /dev/null +++ b/bec_widgets/utils/ux_kit.py @@ -0,0 +1,420 @@ +"""Small themed QWidget controls used by the QWidget twins of the QML widget ports. + +They mirror the ``BecUi`` QML controls in ``bec_widgets/utils/quick/qml`` so that the QML and +QWidget versions of a widget look and behave the same and can be compared side by side. Colours +come from the same :class:`~bec_widgets.utils.quick.host.ThemeTokens`. +""" + +from __future__ import annotations + +from bec_qthemes import material_icon +from qtpy.QtCore import Property, QEasingCurve, QPropertyAnimation, QRectF, QSize, Qt +from qtpy.QtGui import QColor, QFont, QPainter +from qtpy.QtWidgets import ( + QFrame, + QHBoxLayout, + QLabel, + QLineEdit, + QPushButton, + QSizePolicy, + QToolButton, + QVBoxLayout, + QWidget, +) + +from bec_widgets.utils.quick.host import ThemeTokens + + +def rgba(color: QColor, alpha: float) -> str: + """Return a QSS ``rgba()`` string for ``color`` with alpha in 0..1.""" + return f"rgba({color.red()}, {color.green()}, {color.blue()}, {int(alpha * 255)})" + + +def refresh_kit_theme(root: QWidget) -> ThemeTokens: + """Re-theme every kit control below ``root`` and return the current tokens.""" + tokens = ThemeTokens() + for widget in [root, *root.findChildren(QWidget)]: + refresh = getattr(widget, "refresh_theme", None) + if callable(refresh) and widget is not root: + refresh(tokens) + return tokens + + +def field_qss(tokens: ThemeTokens) -> str: + """Stylesheet for inputs inside kit cards; ``invalid=true`` marks a field with an error.""" + return f""" + QLineEdit, QComboBox, QSpinBox, QDoubleSpinBox {{ + background: {tokens.field.name()}; + color: {tokens.fg.name()}; + border: 1px solid {tokens.border.name()}; + border-radius: 6px; + padding: 0px 8px; + min-height: 30px; + font-size: 13px; + selection-background-color: {tokens.primary.name()}; + }} + QLineEdit:hover, QComboBox:hover, QSpinBox:hover, QDoubleSpinBox:hover {{ + border-color: {tokens.fg_subtle.name()}; + }} + QLineEdit:focus, QComboBox:focus, QSpinBox:focus, QDoubleSpinBox:focus {{ + border: 2px solid {tokens.primary.name()}; + padding: 0px 7px; + }} + QLineEdit[invalid="true"], QComboBox[invalid="true"] {{ + border: 2px solid {tokens.danger.name()}; + padding: 0px 7px; + }} + QLineEdit:disabled, QComboBox:disabled {{ color: {tokens.fg_subtle.name()}; }} + QComboBox::drop-down {{ border: none; width: 22px; }} + """ + + +def set_invalid(widget: QWidget, invalid: bool) -> None: + """Toggle the ``invalid`` style of a field styled with :func:`field_qss`.""" + if bool(widget.property("invalid")) == invalid: + return + widget.setProperty("invalid", invalid) + widget.style().unpolish(widget) + widget.style().polish(widget) + + +class IconButton(QToolButton): + """Flat square icon button with hover feedback, matching ``BecUi.IconButton``.""" + + def __init__(self, icon_name: str, tip: str = "", parent: QWidget | None = None, size=18): + super().__init__(parent) + self._icon_name = icon_name + self._icon_size = size + self.setToolTip(tip) + self.setAccessibleName(tip) + self.setAutoRaise(True) + self.setFixedSize(30, 30) + self.setIconSize(QSize(size, size)) + self.setCursor(Qt.CursorShape.PointingHandCursor) + self.refresh_theme(ThemeTokens()) + + def set_icon_name(self, icon_name: str) -> None: + """Change the Material icon.""" + self._icon_name = icon_name + self.refresh_theme(ThemeTokens()) + + def refresh_theme(self, tokens: ThemeTokens) -> None: + """Apply the theme tokens.""" + color = tokens.primary if self.isChecked() else tokens.fg_muted + self.setIcon( + material_icon(self._icon_name, size=(48, 48), color=color, convert_to_pixmap=False) + ) + self.setStyleSheet( + f"QToolButton {{ border: none; border-radius: 6px; background: transparent; }}" + f"QToolButton:hover, QToolButton:checked {{ background: {tokens.hover.name()}; }}" + f"QToolButton:pressed {{ background: {tokens.pressed.name()}; }}" + ) + + +class TextButton(QPushButton): + """Labelled button; variant is ``primary``, ``danger``, ``success``, ``neutral`` or ``ghost``.""" + + def __init__( + self, + text: str, + variant: str = "neutral", + icon_name: str = "", + parent: QWidget | None = None, + ): + super().__init__(text, parent) + self._variant = variant + self._icon_name = icon_name + self.setMinimumHeight(32) + self.setCursor(Qt.CursorShape.PointingHandCursor) + self.refresh_theme(ThemeTokens()) + + def set_variant(self, variant: str) -> None: + """Switch the colour variant.""" + self._variant = variant + self.refresh_theme(ThemeTokens()) + + def refresh_theme(self, tokens: ThemeTokens) -> None: + """Apply the theme tokens.""" + base = {"primary": tokens.primary, "danger": tokens.danger, "success": tokens.success}.get( + self._variant + ) + if base is not None: + label = QColor("white") + qss = ( + f"QPushButton {{ background: {base.name()}; color: white; border: none;" + f" border-radius: 6px; padding: 0 12px; font-size: 13px; font-weight: 600; }}" + f"QPushButton:hover {{ background: {base.lighter(110).name()}; }}" + f"QPushButton:pressed {{ background: {base.darker(125).name()}; }}" + f"QPushButton:disabled {{ background: {rgba(base, 0.45)};" + f" color: {rgba(label, 0.7)}; }}" + ) + else: + label = tokens.fg + border = tokens.border.name() if self._variant == "neutral" else "transparent" + qss = ( + f"QPushButton {{ background: transparent; color: {tokens.fg.name()};" + f" border: 1px solid {border}; border-radius: 6px; padding: 0 12px;" + f" font-size: 13px; font-weight: 500; }}" + f"QPushButton:hover {{ background: {tokens.hover.name()}; }}" + f"QPushButton:pressed {{ background: {tokens.pressed.name()}; }}" + f"QPushButton:disabled {{ color: {tokens.fg_subtle.name()}; }}" + ) + self.setStyleSheet(qss) + if self._icon_name: + self.setIcon( + material_icon(self._icon_name, size=(32, 32), color=label, convert_to_pixmap=False) + ) + self.setIconSize(QSize(16, 16)) + + +class StatusPill(QWidget): + """Rounded status chip with a coloured dot, matching ``BecUi.StatusPill``.""" + + def __init__(self, parent: QWidget | None = None): + super().__init__(parent) + self._text = "" + self._tone = QColor("#808080") + self._dot_opacity = 1.0 + self._pulse = QPropertyAnimation(self, b"dot_opacity", self) + self._pulse.setDuration(1100) + self._pulse.setKeyValueAt(0.0, 1.0) + self._pulse.setKeyValueAt(0.5, 0.25) + self._pulse.setKeyValueAt(1.0, 1.0) + self._pulse.setEasingCurve(QEasingCurve.Type.InOutQuad) + self._pulse.setLoopCount(-1) + self.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed) + font = self.font() + font.setPixelSize(12) + font.setWeight(QFont.Weight.DemiBold) + self.setFont(font) + + def set_status(self, text: str, tone: QColor, pulse: bool = False) -> None: + """Set label, colour and whether the dot pulses.""" + self._text = text + self._tone = QColor(tone) + if pulse and self._pulse.state() != QPropertyAnimation.State.Running: + self._pulse.start() + elif not pulse: + self._pulse.stop() + self._dot_opacity = 1.0 + self.setToolTip(text) + self.updateGeometry() + self.update() + + @property + def text(self) -> str: + """Current label.""" + return self._text + + @property + def pulsing(self) -> bool: + """Whether the dot animation runs.""" + return self._pulse.state() == QPropertyAnimation.State.Running + + def _get_dot_opacity(self) -> float: + return self._dot_opacity + + def _set_dot_opacity(self, value: float) -> None: + self._dot_opacity = value + self.update() + + dot_opacity = Property(float, _get_dot_opacity, _set_dot_opacity) + + def sizeHint(self) -> QSize: # pylint: disable=invalid-name + return QSize(self.fontMetrics().horizontalAdvance(self._text) + 26, 22) + + def paintEvent(self, _event): # pylint: disable=invalid-name + painter = QPainter(self) + painter.setRenderHint(QPainter.RenderHint.Antialiasing) + rect = QRectF(self.rect()) + background = QColor(self._tone) + background.setAlphaF(0.16) + painter.setPen(Qt.PenStyle.NoPen) + painter.setBrush(background) + painter.drawRoundedRect(rect, rect.height() / 2, rect.height() / 2) + dot = QColor(self._tone) + dot.setAlphaF(self._dot_opacity) + painter.setBrush(dot) + painter.drawEllipse(QRectF(9, rect.height() / 2 - 3.5, 7, 7)) + painter.setPen(self._tone) + painter.drawText(rect.adjusted(22, 0, 0, 0), Qt.AlignmentFlag.AlignVCenter, self._text) + painter.end() + + def cleanup(self) -> None: + """Stop the pulse animation.""" + self._pulse.stop() + + +class Card(QFrame): + """Rounded surface with an optional title row, matching ``BecUi.Card``.""" + + def __init__(self, title: str = "", parent: QWidget | None = None, padding: int = 12): + super().__init__(parent) + self.setObjectName("uxCard") + outer = QVBoxLayout(self) + outer.setContentsMargins(padding, padding, padding, padding) + outer.setSpacing(10) + self.header = QHBoxLayout() + self.header.setSpacing(8) + self.title_label = QLabel(title, self) + self.subtitle_label = QLabel("", self) + self.subtitle_label.setVisible(False) + self.header.addWidget(self.title_label) + self.header.addWidget(self.subtitle_label, 1) + self.header_extras = QHBoxLayout() + self.header_extras.setSpacing(4) + self.header.addLayout(self.header_extras) + outer.addLayout(self.header) + self.body = QVBoxLayout() + self.body.setSpacing(8) + outer.addLayout(self.body) + self._has_title = bool(title) + self._update_header_visibility() + self.refresh_theme(ThemeTokens()) + + def set_title(self, title: str, subtitle: str = "") -> None: + """Set the title and an optional muted subtitle.""" + self._has_title = bool(title) + self.title_label.setText(title) + self.subtitle_label.setText(subtitle) + self.subtitle_label.setVisible(bool(subtitle)) + self._update_header_visibility() + + def add_header_widget(self, widget: QWidget) -> None: + """Add a widget to the right end of the title row.""" + self.header_extras.addWidget(widget) + self._has_title = True + self._update_header_visibility() + + def _update_header_visibility(self) -> None: + self.title_label.setVisible(bool(self.title_label.text())) + + def refresh_theme(self, tokens: ThemeTokens) -> None: + """Apply the theme tokens.""" + self.setStyleSheet( + f"QFrame#uxCard {{ background: {tokens.card.name()};" + f" border: 1px solid {tokens.border.name()}; border-radius: 10px; }}" + ) + self.title_label.setStyleSheet( + f"color: {tokens.fg.name()}; font-size: 13px; font-weight: 600; background: none;" + ) + self.subtitle_label.setStyleSheet( + f"color: {tokens.fg_subtle.name()}; font-size: 12px; background: none;" + ) + + +class PortedPropertiesMixin: + """Make a port export and load the ``SafeProperty`` settings of the class it ports. + + ``BECConnector`` only collects the properties defined directly on ``type(self)``, so a + subclass would silently drop the inherited ones (e.g. ``ring_json``) from saved profiles. + Set ``PORTED_FROM`` to the original class; its properties and those of every class in + between are collected. + """ + + PORTED_FROM: type | None = None + + def _get_bec_meta_objects(self) -> dict: + objects = {} + for cls in type(self).__mro__: + for name, attr in vars(cls).items(): + if name in objects or not isinstance(attr, Property): + continue + if getattr(attr.fget, "__is_safe_getter__", False): + objects[name] = attr + if cls is self.PORTED_FROM: + break + return objects + + +class ToggleSwitch(QPushButton): + """Checkable switch painted like ``BecUi.SwitchField``; the label shows On or Off.""" + + def __init__(self, parent: QWidget | None = None): + super().__init__(parent) + self.setCheckable(True) + self.setCursor(Qt.CursorShape.PointingHandCursor) + self.setMinimumHeight(32) + self.setFixedWidth(84) + self._knob = 0.0 + self._animation = QPropertyAnimation(self, b"knob", self) + self._animation.setDuration(120) + self._animation.setEasingCurve(QEasingCurve.Type.OutCubic) + self.toggled.connect(self._animate) + + def _animate(self, checked: bool) -> None: + self._animation.stop() + self._animation.setStartValue(self._knob) + self._animation.setEndValue(1.0 if checked else 0.0) + self._animation.start() + + def setChecked(self, checked: bool) -> None: # pylint: disable=invalid-name + """Set the state without animating.""" + self.blockSignals(True) + super().setChecked(checked) + self.blockSignals(False) + self._animation.stop() + self._knob = 1.0 if checked else 0.0 + self.update() + + def _get_knob(self) -> float: + return self._knob + + def _set_knob(self, value: float) -> None: + self._knob = value + self.update() + + knob = Property(float, _get_knob, _set_knob) + + def paintEvent(self, _event): # pylint: disable=invalid-name + tokens = ThemeTokens() + painter = QPainter(self) + painter.setRenderHint(QPainter.RenderHint.Antialiasing) + track = QRectF(0, (self.height() - 22) / 2, 38, 22) + painter.setPen(tokens.primary if self.isChecked() else tokens.border) + painter.setBrush(tokens.primary if self.isChecked() else tokens.track) + painter.drawRoundedRect(track, 11, 11) + painter.setPen(Qt.PenStyle.NoPen) + painter.setBrush(tokens.on_primary if self.isChecked() else tokens.fg_muted) + painter.drawEllipse(QRectF(track.left() + 3 + self._knob * 16, track.top() + 3, 16, 16)) + painter.setPen(tokens.fg) + font = self.font() + font.setPixelSize(13) + painter.setFont(font) + painter.drawText( + QRectF(track.right() + 8, 0, self.width() - track.right() - 8, self.height()), + Qt.AlignmentFlag.AlignVCenter, + "On" if self.isChecked() else "Off", + ) + painter.end() + + +class SuffixLineEdit(QLineEdit): + """Line edit that paints a unit suffix inside its right edge, like ``BecUi.InputField``.""" + + def __init__(self, parent: QWidget | None = None): + super().__init__(parent) + self._suffix = "" + + def set_suffix(self, suffix: str) -> None: + """Set the unit shown at the right edge.""" + self._suffix = suffix or "" + width = self.fontMetrics().horizontalAdvance(self._suffix) + 10 if self._suffix else 0 + self.setTextMargins(0, 0, width, 0) + self.update() + + def paintEvent(self, event): # pylint: disable=invalid-name + super().paintEvent(event) + if not self._suffix: + return + painter = QPainter(self) + painter.setPen(ThemeTokens().fg_subtle) + font = self.font() + font.setPixelSize(12) + painter.setFont(font) + painter.drawText( + self.rect().adjusted(0, 0, -9, 0), + Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter, + self._suffix, + ) + painter.end() From 4ac2e629aab2cb73070f341b300e2e25bb8e26ac Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 21:29:59 +0000 Subject: [PATCH 2/2] feat(notifications): QML and QWidget toasts with history drawer, folded repeats and sticky critical errors instead of modal error dialogs Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01Kns5hgJ1MEXNfFeCuTESFP --- bec_widgets/tests/fixtures.py | 8 + bec_widgets/utils/error_popups.py | 48 +- .../notification_center/notification_demo.py | 205 ++++ .../notification_center/notification_qml.py | 177 ++++ .../notification_qwidget.py | 740 ++++++++++++++ .../notification_ux_common.py | 962 ++++++++++++++++++ .../qml/NotificationBell.qml | 54 + .../qml/NotificationDrawer.qml | 414 ++++++++ .../qml/NotificationToasts.qml | 203 ++++ .../containers/main_window/main_window.py | 40 +- tests/unit_tests/test_notification_ports.py | 551 ++++++++++ 11 files changed, 3390 insertions(+), 12 deletions(-) create mode 100644 bec_widgets/widgets/containers/main_window/addons/notification_center/notification_demo.py create mode 100644 bec_widgets/widgets/containers/main_window/addons/notification_center/notification_qml.py create mode 100644 bec_widgets/widgets/containers/main_window/addons/notification_center/notification_qwidget.py create mode 100644 bec_widgets/widgets/containers/main_window/addons/notification_center/notification_ux_common.py create mode 100644 bec_widgets/widgets/containers/main_window/addons/notification_center/qml/NotificationBell.qml create mode 100644 bec_widgets/widgets/containers/main_window/addons/notification_center/qml/NotificationDrawer.qml create mode 100644 bec_widgets/widgets/containers/main_window/addons/notification_center/qml/NotificationToasts.qml create mode 100644 tests/unit_tests/test_notification_ports.py diff --git a/bec_widgets/tests/fixtures.py b/bec_widgets/tests/fixtures.py index 7078d1db..78c46351 100644 --- a/bec_widgets/tests/fixtures.py +++ b/bec_widgets/tests/fixtures.py @@ -21,6 +21,7 @@ Requires ``bec_widgets[dev]`` (pytest-qt, fakeredis) and bec_lib's ``threads_che # pylint: disable=redefined-outer-name,unused-argument,wrong-import-position import os +import sys # Keep ophyd on its dummy control layer, so importing it does not create a real EPICS CA context os.environ.setdefault("OPHYD_CONTROL_LAYER", "dummy") @@ -159,6 +160,13 @@ def notification_broker_reset(bec_dispatcher): """Tear down the app-wide notification broker before the dispatcher it is bound to.""" yield BECNotificationBroker.reset_singleton() + # the reworked notification hub is only reset if some test imported it + hub_module = sys.modules.get( + "bec_widgets.widgets.containers.main_window.addons.notification_center." + "notification_ux_common" + ) + if hub_module is not None: + hub_module.NotificationHub.reset_instance() @pytest.fixture(autouse=True) diff --git a/bec_widgets/utils/error_popups.py b/bec_widgets/utils/error_popups.py index 993e2f36..6157a19d 100644 --- a/bec_widgets/utils/error_popups.py +++ b/bec_widgets/utils/error_popups.py @@ -111,7 +111,9 @@ def SafeProperty( error_msg = traceback.format_exc() if popup_error: - ErrorPopupUtility().custom_exception_hook(*sys.exc_info(), popup_error=True) + ErrorPopupUtility().custom_exception_hook( + *sys.exc_info(), popup_error=True, source=py_getter.__qualname__ + ) logger.error(f"SafeProperty error in GETTER of '{prop_name}':\n{error_msg}") return default @@ -173,7 +175,7 @@ def SafeProperty( if popup_error: ErrorPopupUtility().custom_exception_hook( - *sys.exc_info(), popup_error=True + *sys.exc_info(), popup_error=True, source=setter_func.__qualname__ ) logger.error(f"SafeProperty error in SETTER of '{prop_name}':\n{error_msg}") return @@ -298,7 +300,9 @@ def SafeSlot(*slot_args, **slot_kwargs): # pylint: disable=invalid-name slot_name = f"{method.__module__}.{method.__qualname__}" error_msg = traceback.format_exc() if call_params["popup_error"]: - ErrorPopupUtility().custom_exception_hook(*sys.exc_info(), popup_error=True) + ErrorPopupUtility().custom_exception_hook( + *sys.exc_info(), popup_error=True, source=method.__qualname__ + ) logger.error(f"SafeSlot error in slot '{slot_name}':\n{error_msg}") if call_params["raise_error"]: raise @@ -308,6 +312,26 @@ def SafeSlot(*slot_args, **slot_kwargs): # pylint: disable=invalid-name return error_managed +def _notification_hub(): + """Return the notification hub if a window shows notifications, else None. + + When the reworked notification UI is attached to a window, errors and warnings become + toasts with a history entry instead of blocking message boxes. + """ + # only look the module up: if nobody imported it, no window can show notifications, and + # importing it here would pull Qt Quick into every widget that reports an error + module = sys.modules.get( + "bec_widgets.widgets.containers.main_window.addons.notification_center." + "notification_ux_common" + ) + if module is None: + return None + hub = module.NotificationHub._instance # pylint: disable=protected-access + if hub is None or not shiboken6.isValid(hub) or not hub.has_hosts: + return None + return hub + + class WarningPopupUtility(QObject): """ Utility class to show warning popups in the application. @@ -315,6 +339,11 @@ class WarningPopupUtility(QObject): @SafeSlot(str, str, str, QWidget) def show_warning_message(self, title, message, detailed_text, widget): + hub = _notification_hub() + if hub is not None: + source = type(widget).__name__ if widget is not None else "" + hub.notify(title, message, "warning", details=detailed_text, source=source) + return msg = QMessageBox(widget) msg.setIcon(QMessageBox.Warning) msg.setWindowTitle(title) @@ -415,12 +444,17 @@ class _ErrorPopupUtility(QObject): def get_error_message(self, exctype, value, tb): return "".join(traceback.format_exception(exctype, value, tb)) - def custom_exception_hook(self, exctype, value, tb, popup_error=False): + def custom_exception_hook(self, exctype, value, tb, popup_error=False, source=None): if popup_error or self.enable_error_popup: + title = "Method error" if popup_error else "Application Error" + hub = _notification_hub() + if hub is not None: + hub.report_exception( + title, self.get_error_message(exctype, value, tb), source=source or title + ) + return self.error_occurred.emit( - "Method error" if popup_error else "Application Error", - self.get_error_message(exctype, value, tb), - self.parent(), + title, self.get_error_message(exctype, value, tb), self.parent() ) else: sys.__excepthook__(exctype, value, tb) # Call the original excepthook diff --git a/bec_widgets/widgets/containers/main_window/addons/notification_center/notification_demo.py b/bec_widgets/widgets/containers/main_window/addons/notification_center/notification_demo.py new file mode 100644 index 00000000..c9e174dc --- /dev/null +++ b/bec_widgets/widgets/containers/main_window/addons/notification_center/notification_demo.py @@ -0,0 +1,205 @@ +"""Demo window for the reworked notifications, used for screenshots and side-by-side checks. + +Run this module with ``--variant qml`` or ``--variant qwidget`` (``python -m +bec_widgets.widgets.containers.main_window.addons.notification_center.notification_demo``). +No BEC server is needed: the buttons post sample +notifications and raise errors inside ``SafeSlot(popup_error=True)`` slots. +""" + +from __future__ import annotations + +import argparse +import sys +import traceback + +import numpy as np +import pyqtgraph as pg +from qtpy.QtWidgets import ( + QApplication, + QHBoxLayout, + QLabel, + QMainWindow, + QPushButton, + QToolBar, + QVBoxLayout, + QWidget, +) + +from bec_widgets.utils.colors import apply_theme +from bec_widgets.utils.error_popups import SafeSlot +from bec_widgets.widgets.containers.main_window.addons.notification_center.notification_ux_common import ( + NotificationHub, + Severity, +) + +SAMPLE_TRACE = """Traceback (most recent call last): + File "/opt/bec/bec_server/device_server/devices/devicemanager.py", line 412, in stage + status = obj.stage() + File "/opt/bec/ophyd_devices/devices/eiger9m.py", line 233, in on_stage + self.cam.acquire.set(1).wait(timeout=10) + File "/opt/bec/ophyd/status.py", line 336, in wait + raise WaitTimeoutError(f"Status {self!r} has not completed yet.") +ophyd.utils.errors.WaitTimeoutError: Status DeviceStatus(device=eiger9m_cam_acquire) has not completed yet. + +During handling of the above exception, another exception occurred: + +Traceback (most recent call last): + File "/opt/bec/bec_server/scan_server/scan_worker.py", line 289, in _stage_devices + self.device_manager.stage(devices, timeout=10) +bec_lib.alarm_handler.DeviceTimeoutError: Device eiger9m did not respond within 10 s while staging.""" + + +def post_samples(hub: NotificationHub) -> None: + """Fill the hub with a realistic mix of notifications for screenshots.""" + hub.notify( + "Scan 1042 finished", + "line_scan over samx, 101 points in 2 min 13 s.", + Severity.SUCCESS, + source="Scan queue", + scan_number=1042, + ) + hub.notify( + "Profile saved", + "Workspace 'alignment' was saved to the beamline profiles.", + Severity.INFO, + source="Workspace", + ) + hub.notify( + "Ring current low", + "Ring current dropped to 180 mA (threshold 200 mA). Data may be noisy.", + Severity.WARNING, + source="Beamline states", + ) + trace = "" + try: + raise ValueError("Target 12.5 is outside the soft limits [-10, 10] of samx.") + except ValueError: + trace = traceback.format_exc() + for _ in range(3): + hub.report_exception("Method error", trace, source="PositionerBox.move") + hub.post_alarm( + { + "severity": 2, + "alarm_type": "DeviceTimeoutError", + "msg": SAMPLE_TRACE, + "info": { + "id": "demo-critical", + "error_message": SAMPLE_TRACE, + "compact_error_message": "Device eiger9m did not respond within 10 s while" + " staging. The scan was aborted.", + "exception_type": "DeviceTimeoutError", + "device": "eiger9m", + }, + }, + {"scan_number": 1043, "scan_id": "4f0d6c1e-7a51-4b0c-9a59-2f9a3d1c2b77"}, + ) + + +class DemoWindow(QMainWindow): + """Window that looks like the BEC app, with buttons that produce notifications.""" + + def __init__(self, variant: str = "qwidget"): + super().__init__() + self.setWindowTitle(f"BEC notifications ({variant})") + self.resize(1180, 720) + self.menuBar().addMenu("File") + self.menuBar().addMenu("View") + self.menuBar().addMenu("Help") + toolbar = QToolBar("Main", self) + for label in ("Dock area", "Device manager", "Scan control"): + toolbar.addAction(label) + self.addToolBar(toolbar) + + central = QWidget(self) + layout = QHBoxLayout(central) + controls = QVBoxLayout() + self.hub = NotificationHub.instance() + for label, slot in ( + ("Info", lambda: self.hub.notify("Profile saved", "Workspace saved.", "info")), + ("Success", lambda: self.hub.notify("Scan finished", "101 points.", "success")), + ("Warning", lambda: self.hub.notify("Ring current low", "180 mA.", "warning")), + ("Error in a slot", self.failing_slot), + ("Critical alarm", self.critical), + ("Burst of 20 errors", self.burst), + ("Toggle theme", self.toggle_theme), + ): + button = QPushButton(label, central) + button.clicked.connect(slot) + controls.addWidget(button) + controls.addStretch(1) + layout.addLayout(controls) + plot = pg.PlotWidget(central) + x = np.linspace(-5, 5, 400) + plot.plot(x, np.exp(-(x**2) / 2) + 0.05 * np.sin(9 * x), pen=pg.mkPen("#3b82f6", width=2)) + plot.showGrid(x=True, y=True, alpha=0.2) + layout.addWidget(plot, 1) + self.setCentralWidget(central) + self.statusBar().addWidget(QLabel("Ready · account p12345")) + + if variant == "qml": + from bec_widgets.widgets.containers.main_window.addons.notification_center.notification_qml import ( + NotificationHostQML as Host, + ) + else: + from bec_widgets.widgets.containers.main_window.addons.notification_center.notification_qwidget import ( + NotificationHostQWidget as Host, + ) + self.notifications = Host(self) + + @SafeSlot(popup_error=True) + def failing_slot(self): + """Raise inside a SafeSlot: shows a toast instead of a modal dialog.""" + raise ValueError("Target 12.5 is outside the soft limits [-10, 10] of samx.") + + def critical(self): + """Post a critical alarm with a long traceback.""" + self.hub.post_alarm( + { + "severity": 2, + "alarm_type": "DeviceTimeoutError", + "msg": SAMPLE_TRACE, + "info": { + "id": None, + "error_message": SAMPLE_TRACE, + "compact_error_message": "Device eiger9m did not respond within 10 s.", + "exception_type": "DeviceTimeoutError", + "device": "eiger9m", + }, + }, + {"scan_number": 1043}, + ) + + def burst(self): + """Post many different errors at once; the stack shows three and counts the rest.""" + for i in range(20): + self.hub.notify(f"Readback failed for motor{i}", "Timeout after 2 s.", "error") + + def toggle_theme(self): + """Switch between the light and dark theme.""" + theme = getattr(QApplication.instance(), "theme", None) + apply_theme("light" if theme is not None and theme.theme == "dark" else "dark") + + def closeEvent(self, event): # pylint: disable=invalid-name + """Detach the notification UI.""" + self.notifications.cleanup() + super().closeEvent(event) + + +def main(variant: str | None = None) -> None: # pragma: no cover + """Start the demo.""" + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--variant", choices=("qml", "qwidget"), default=variant or "qwidget") + parser.add_argument("--theme", choices=("dark", "light"), default="dark") + parser.add_argument("--samples", action="store_true", help="start with sample notifications") + args = parser.parse_args() + app = QApplication(sys.argv) + apply_theme(args.theme) + window = DemoWindow(args.variant) + window.show() + if args.samples: + post_samples(window.hub) + sys.exit(app.exec()) + + +if __name__ == "__main__": # pragma: no cover + main() diff --git a/bec_widgets/widgets/containers/main_window/addons/notification_center/notification_qml.py b/bec_widgets/widgets/containers/main_window/addons/notification_center/notification_qml.py new file mode 100644 index 00000000..7160fe14 --- /dev/null +++ b/bec_widgets/widgets/containers/main_window/addons/notification_center/notification_qml.py @@ -0,0 +1,177 @@ +"""Notification toasts, history drawer and bell rendered with Qt Quick (QML). + +Same UX as :mod:`notification_qwidget`; the state comes from +:class:`~.notification_ux_common.NotificationHostBase`. +""" + +from __future__ import annotations + +from pathlib import Path + +from qtpy.QtCore import Property, QObject, Qt, Signal, Slot +from qtpy.QtQuickWidgets import QQuickWidget + +from bec_widgets.utils.quick import DictListModel, create_quick_widget, release_quick_widget +from bec_widgets.widgets.containers.main_window.addons.notification_center.notification_ux_common import ( + NotificationHostBase, +) + +QML_DIR = Path(__file__).parent / "qml" + +ROW_ROLES = [ + "entryId", + "severity", + "severityLabel", + "icon", + "color", + "tint", + "title", + "body", + "details", + "hasDetails", + "source", + "meta", + "time", + "timeAbs", + "firstAbs", + "scan", + "count", + "unread", + "needsAck", + "critical", +] + + +class NotificationBackend(QObject): + """Bridge between :class:`NotificationHostQML` and its three QML views.""" + + changed = Signal() + + def __init__(self, host: NotificationHostQML): + super().__init__(host) + self._host = host + self._state: dict = {} + self._toasts = DictListModel(ROW_ROLES, self) + self._rows = DictListModel(ROW_ROLES, self) + + def update_from(self, state: dict) -> None: + """Take a new view state from the host.""" + state = dict(state) + self._toasts.set_items(state.pop("toasts")) + self._rows.set_items(state.pop("rows")) + state["selected"] = state["selected"] or {} + self._state = state + self.changed.emit() + + # pylint: disable=invalid-name,missing-function-docstring + @Slot() + def toggleDrawer(self) -> None: + self._host.toggle_drawer() + + @Slot(bool) + def setDrawerOpen(self, open_: bool) -> None: + self._host.set_drawer_open(open_) + + @Slot(str) + def openDetails(self, entry_id: str) -> None: + self._host.open_details(entry_id) + + @Slot(str) + def select(self, entry_id: str) -> None: + self._host.select(entry_id or None) + + @Slot(str) + def setFilter(self, key: str) -> None: + self._host.set_filter(key) + + @Slot(str) + def setSearch(self, text: str) -> None: + self._host.set_search(text) + + @Slot(str) + def acknowledge(self, entry_id: str) -> None: + self._host.acknowledge(entry_id) + + @Slot(str) + def remove(self, entry_id: str) -> None: + self._host.remove(entry_id) + + @Slot(str) + def dismissToast(self, entry_id: str) -> None: + self._host.dismiss_toast(entry_id) + + @Slot(str, result=bool) + def copyDetails(self, entry_id: str) -> bool: + return self._host.copy_details(entry_id) + + @Slot() + def markAllRead(self) -> None: + self._host.mark_all_read() + + @Slot() + def clearHistory(self) -> None: + self._host.clear_history() + + @Slot(bool) + def setHovered(self, hovered: bool) -> None: + self._host.set_toasts_hovered(hovered) + + state = Property("QVariantMap", lambda self: self._state, notify=changed) + toasts = Property(QObject, lambda self: self._toasts, constant=True) + rows = Property(QObject, lambda self: self._rows, constant=True) + + +class NotificationHostQML(NotificationHostBase): + """Notification UI rendered in QML. See :class:`NotificationHostBase`.""" + + def _create_views(self) -> None: + self.backend = NotificationBackend(self) + props = {"backend": self.backend} + # toasts float over the window content: a transparent view stacked on top + self.toasts = create_quick_widget(self.window, QML_DIR / "NotificationToasts.qml", props) + self.toasts.setAttribute(Qt.WidgetAttribute.WA_AlwaysStackOnTop, True) + self.toasts.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground, True) + self.toasts.setClearColor(Qt.GlobalColor.transparent) + self.toasts.hide() + self.drawer = create_quick_widget(self.window, QML_DIR / "NotificationDrawer.qml", props) + self.drawer.hide() + self.bell = None + if self.status_bar is not None: + self.bell = create_quick_widget( + self.status_bar, QML_DIR / "NotificationBell.qml", props + ) + self.bell.setFixedSize(36, 24) + self.bell.setResizeMode(QQuickWidget.ResizeMode.SizeRootObjectToView) + self.status_bar.addPermanentWidget(self.bell) + root = self.toasts.rootObject() + if root is not None: + root.implicitHeightChanged.connect(self._place_views) + + def _sync_views(self, state: dict) -> None: + self.backend.update_from(state) + self.toasts.setVisible(bool(state["toasts"])) + self.drawer.setVisible(state["drawerOpen"]) + + def _place_views(self, *_args) -> None: + if self.drawer.isVisible(): + self.drawer.setGeometry(self.drawer_geometry()) + self.drawer.raise_() + if self.toasts.isVisible(): + root = self.toasts.rootObject() + height = int(root.property("implicitHeight")) if root is not None else 0 + rect = self.toast_geometry(max(0, height - 16)) + self.toasts.setGeometry(rect.adjusted(-8, -8, 8, 8) & self.window.rect()) + self.toasts.raise_() + + def _apply_theme(self) -> None: + if self.bell is not None: + self.bell.setClearColor(self.status_bar.palette().window().color()) + self.drawer.setClearColor(self.window.palette().window().color()) + + def _destroy_views(self) -> None: + for view in (self.toasts, self.drawer, self.bell): + if view is None: + continue + release_quick_widget(view) + view.hide() + view.deleteLater() diff --git a/bec_widgets/widgets/containers/main_window/addons/notification_center/notification_qwidget.py b/bec_widgets/widgets/containers/main_window/addons/notification_center/notification_qwidget.py new file mode 100644 index 00000000..8fba4ded --- /dev/null +++ b/bec_widgets/widgets/containers/main_window/addons/notification_center/notification_qwidget.py @@ -0,0 +1,740 @@ +"""Notification toasts, history drawer and bell built with QWidgets. + +Same UX as :mod:`notification_qml`; the state comes from +:class:`~.notification_ux_common.NotificationHostBase`. +""" + +from __future__ import annotations + +from bec_qthemes import material_icon +from qtpy.QtCore import QAbstractListModel, QModelIndex, QRectF, QSize, Qt +from qtpy.QtGui import QColor, QFont, QFontDatabase, QPainter, QPainterPath, QPen +from qtpy.QtWidgets import ( + QFrame, + QGraphicsDropShadowEffect, + QHBoxLayout, + QLabel, + QLineEdit, + QListView, + QPlainTextEdit, + QPushButton, + QScrollArea, + QStackedWidget, + QStyle, + QStyledItemDelegate, + QToolButton, + QVBoxLayout, + QWidget, +) + +from bec_widgets.utils.quick.host import ThemeTokens +from bec_widgets.utils.ux_kit import IconButton, TextButton, field_qss, rgba +from bec_widgets.widgets.containers.main_window.addons.notification_center.notification_ux_common import ( + NotificationHostBase, +) + + +def _icon_badge(painter: QPainter, rect: QRectF, icon: str, color: QColor, tint: QColor): + """Paint a round tinted badge with a Material icon, as used by toasts and rows.""" + painter.setPen(Qt.PenStyle.NoPen) + painter.setBrush(tint) + painter.drawEllipse(rect) + size = int(rect.width() * 0.6) + pixmap = material_icon(icon, size=(size * 2, size * 2), color=color, filled=True) + target = QRectF(rect.center().x() - size / 2, rect.center().y() - size / 2, size, size).toRect() + painter.drawPixmap(target, pixmap) + + +class _Badge(QWidget): + """Round severity icon painted on a tinted circle.""" + + def __init__(self, parent: QWidget | None = None, size: int = 32): + super().__init__(parent) + self.setFixedSize(size, size) + self._icon = "info" + self._color = QColor("#888") + self._tint = QColor("#333") + + def set_icon(self, icon: str, color: str, tint: str) -> None: + """Set icon name and colours.""" + self._icon, self._color, self._tint = icon, QColor(color), QColor(tint) + self.update() + + def paintEvent(self, _event): # pylint: disable=invalid-name + painter = QPainter(self) + painter.setRenderHint(QPainter.RenderHint.Antialiasing) + _icon_badge(painter, QRectF(self.rect()), self._icon, self._color, self._tint) + painter.end() + + +class ToastCard(QFrame): + """One toast: badge, title, message, meta line and actions.""" + + def __init__(self, host: NotificationHostBase, parent: QWidget | None = None): + super().__init__(parent) + self.host = host + self.entry_id = "" + self._row: dict = {} + self.setObjectName("toastCard") + self.setAttribute(Qt.WidgetAttribute.WA_StyledBackground, False) + + self.badge = _Badge(self, 32) + self.title = QLabel(self) + self.title.setTextFormat(Qt.TextFormat.PlainText) + self.count = QLabel(self) + self.close_btn = IconButton("close", "Dismiss", self, size=16) + self.close_btn.setFixedSize(24, 24) + self.close_btn.clicked.connect(lambda: self.host.dismiss_toast(self.entry_id)) + self.body = QLabel(self) + self.body.setWordWrap(True) + self.body.setTextFormat(Qt.TextFormat.PlainText) + self.meta = QLabel(self) + self.details_btn = TextButton("Details", "ghost", "open_in_new", self) + self.details_btn.setMinimumHeight(28) + self.details_btn.clicked.connect(lambda: self.host.open_details(self.entry_id)) + self.copy_btn = IconButton("content_copy", "Copy report", self, size=16) + self.copy_btn.setFixedSize(28, 28) + self.copy_btn.clicked.connect(lambda: self.host.copy_details(self.entry_id)) + self.ack_btn = TextButton("Acknowledge", "danger", "done", self) + self.ack_btn.setMinimumHeight(28) + self.ack_btn.clicked.connect(lambda: self.host.acknowledge(self.entry_id)) + + head = QHBoxLayout() + head.setSpacing(6) + head.addWidget(self.title, 1) + head.addWidget(self.count) + head.addWidget(self.close_btn) + text = QVBoxLayout() + text.setSpacing(3) + text.addLayout(head) + text.addWidget(self.body) + text.addWidget(self.meta) + self.actions = QHBoxLayout() + self.actions.setSpacing(6) + self.actions.addStretch(1) + self.actions.addWidget(self.copy_btn) + self.actions.addWidget(self.details_btn) + self.actions.addWidget(self.ack_btn) + text.addLayout(self.actions) + row = QHBoxLayout(self) + row.setContentsMargins(14, 12, 10, 10) + row.setSpacing(12) + row.addWidget(self.badge, 0, Qt.AlignmentFlag.AlignTop) + row.addLayout(text, 1) + + shadow = QGraphicsDropShadowEffect(self) + shadow.setBlurRadius(24) + shadow.setOffset(0, 4) + shadow.setColor(QColor(0, 0, 0, 90)) + self.setGraphicsEffect(shadow) + + def set_row(self, row: dict, tokens: ThemeTokens) -> None: + """Show the entry described by ``row``.""" + self._row = row + self.entry_id = row["id"] + self.badge.set_icon(row["icon"], row["color"], row["tint"]) + self.title.setText(row["title"]) + self.title.setToolTip(row["title"]) + self.count.setText(f"×{row['count']}") + self.count.setVisible(row["count"] > 1) + body = row["body"] or "" + self.body.setText(body if len(body) <= 220 else body[:217] + "…") + self.body.setToolTip(body if len(body) > 220 else "") + self.body.setVisible(bool(body)) + self.meta.setText(row["meta"]) + self.close_btn.setVisible(not row["needsAck"]) + self.ack_btn.setVisible(row["needsAck"]) + self.details_btn.setVisible(row["hasDetails"] or row["needsAck"]) + self.copy_btn.setVisible(row["severity"] in ("error", "critical")) + self._style(tokens) + self.update() + + def _style(self, tokens: ThemeTokens) -> None: + self.title.setStyleSheet( + f"color: {tokens.fg.name()}; font-size: 13px; font-weight: 600; background: none;" + ) + self.body.setStyleSheet( + f"color: {tokens.fg_muted.name()}; font-size: 12px; background: none;" + ) + self.meta.setStyleSheet( + f"color: {tokens.fg_subtle.name()}; font-size: 11px; background: none;" + ) + self.count.setStyleSheet( + f"color: {self._row.get('color', tokens.fg.name())}; font-size: 11px;" + f" font-weight: 700; background: {self._row.get('tint', 'transparent')};" + " border-radius: 8px; padding: 0px 6px;" + ) + self.count.setFixedHeight(16) + for button in (self.close_btn, self.details_btn, self.copy_btn, self.ack_btn): + button.refresh_theme(tokens) + + def paintEvent(self, _event): # pylint: disable=invalid-name + tokens = ThemeTokens() + painter = QPainter(self) + painter.setRenderHint(QPainter.RenderHint.Antialiasing) + rect = QRectF(self.rect()).adjusted(0.5, 0.5, -0.5, -0.5) + path = QPainterPath() + path.addRoundedRect(rect, 10, 10) + painter.fillPath(path, tokens.card) + accent = QColor(self._row.get("color", tokens.border.name())) + critical = self._row.get("needsAck", False) + painter.setPen(QPen(accent if critical else tokens.border, 1.5 if critical else 1)) + painter.drawPath(path) + # severity stripe along the straight part of the left edge + painter.setPen(Qt.PenStyle.NoPen) + painter.setBrush(accent) + painter.drawRoundedRect(QRectF(rect.left(), rect.top() + 10, 4, rect.height() - 20), 2, 2) + painter.end() + + +class ToastStack(QWidget): + """Bottom-right column of toasts with an overflow link; hovering pauses the countdowns.""" + + def __init__(self, host: NotificationHostBase, parent: QWidget): + super().__init__(parent) + self.host = host + self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground, True) + self._cards: dict[str, ToastCard] = {} + self._layout = QVBoxLayout(self) + self._layout.setContentsMargins(8, 8, 8, 8) # room for the shadow + self._layout.setSpacing(10) + self.more_btn = TextButton("", "neutral", "expand_less", self) + self.more_btn.clicked.connect(lambda: self.host.set_drawer_open(True)) + self._layout.addWidget(self.more_btn, 0, Qt.AlignmentFlag.AlignRight) + + def set_state(self, state: dict, tokens: ThemeTokens) -> None: + """Show the toasts of ``state``; cards are reused by entry id.""" + rows = state["toasts"] + wanted = [row["id"] for row in rows] + for entry_id in list(self._cards): + if entry_id not in wanted: + card = self._cards.pop(entry_id) + self._layout.removeWidget(card) + card.deleteLater() + # newest at the bottom, nearest to the status bar + for position, row in enumerate(rows, start=1): + card = self._cards.get(row["id"]) + if card is None: + card = ToastCard(self.host, self) + self._cards[row["id"]] = card + card.set_row(row, tokens) + if self._layout.indexOf(card) != position: + self._layout.removeWidget(card) + self._layout.insertWidget(position, card) + overflow = state["overflow"] + self.more_btn.setText(f"{overflow} more in notifications") + self.more_btn.setVisible(overflow > 0) + self.more_btn.refresh_theme(tokens) + # solid like a card: the link floats over plots + self.more_btn.setStyleSheet( + self.more_btn.styleSheet() + + f"QPushButton {{ background: {tokens.card.name()}; }}" + + f"QPushButton:hover {{ background: {tokens.hover.name()}; }}" + ) + self.setVisible(bool(rows)) + + @property + def cards(self) -> list[ToastCard]: + """Cards on screen, oldest first.""" + return [ + self._layout.itemAt(i).widget() + for i in range(self._layout.count()) + if isinstance(self._layout.itemAt(i).widget(), ToastCard) + ] + + def enterEvent(self, event): # pylint: disable=invalid-name + self.host.set_toasts_hovered(True) + super().enterEvent(event) + + def leaveEvent(self, event): # pylint: disable=invalid-name + self.host.set_toasts_hovered(False) + super().leaveEvent(event) + + +class _RowsModel(QAbstractListModel): + """Rows of the history list; each row is the dictionary from ``entry_row``.""" + + def __init__(self, parent=None): + super().__init__(parent) + self.rows: list[dict] = [] + + def set_rows(self, rows: list[dict]) -> None: + """Replace the rows.""" + self.beginResetModel() + self.rows = rows + self.endResetModel() + + def rowCount(self, parent: QModelIndex = QModelIndex()) -> int: # pylint: disable=invalid-name + return 0 if parent.isValid() else len(self.rows) + + def data(self, index: QModelIndex, role: int = Qt.ItemDataRole.DisplayRole): + if not index.isValid(): + return None + row = self.rows[index.row()] + if role == Qt.ItemDataRole.UserRole: + return row + if role == Qt.ItemDataRole.DisplayRole: + return row["title"] + if role == Qt.ItemDataRole.ToolTipRole: + return row["timeAbs"] + return None + + +class _RowDelegate(QStyledItemDelegate): + """Paints a history row: stripe, badge, title, time, message and meta line.""" + + HEIGHT = 78 + + def sizeHint(self, _option, _index): # pylint: disable=invalid-name + return QSize(200, self.HEIGHT) + + def paint(self, painter: QPainter, option, index): + row = index.data(Qt.ItemDataRole.UserRole) + if row is None: + return + tokens = ThemeTokens() + painter.save() + painter.setRenderHint(QPainter.RenderHint.Antialiasing) + rect = QRectF(option.rect).adjusted(8, 3, -8, -3) + hovered = bool(option.state & QStyle.StateFlag.State_MouseOver) + background = QColor(row["tint"]) if row["needsAck"] else tokens.card + if hovered: + background = tokens.hover if not row["needsAck"] else QColor(row["tint"]).lighter(110) + path = QPainterPath() + path.addRoundedRect(rect, 8, 8) + painter.fillPath(path, background) + painter.setPen(QPen(QColor(row["color"]) if row["needsAck"] else tokens.border, 1)) + painter.drawPath(path) + _icon_badge( + painter, + QRectF(rect.left() + 10, rect.top() + 10, 28, 28), + row["icon"], + QColor(row["color"]), + QColor(row["tint"]) if not row["needsAck"] else tokens.card, + ) + left = rect.left() + 48 + right = rect.right() - 10 + font = QFont(option.font) + font.setPixelSize(11) + painter.setFont(font) + time_w = painter.fontMetrics().horizontalAdvance(row["time"]) + painter.setPen(tokens.fg_subtle) + painter.drawText( + QRectF(right - time_w, rect.top() + 8, time_w, 18), + Qt.AlignmentFlag.AlignVCenter, + row["time"], + ) + title_right = right - time_w - 8 + if row["unread"]: + painter.setPen(Qt.PenStyle.NoPen) + painter.setBrush(tokens.primary) + painter.drawEllipse(QRectF(title_right - 7, rect.top() + 14, 7, 7)) + title_right -= 14 + if row["count"] > 1: + badge = f"×{row['count']}" + badge_w = painter.fontMetrics().horizontalAdvance(badge) + 12 + badge_rect = QRectF(title_right - badge_w, rect.top() + 9, badge_w, 16) + painter.setPen(Qt.PenStyle.NoPen) + painter.setBrush(QColor(row["tint"])) + painter.drawRoundedRect(badge_rect, 8, 8) + painter.setPen(QColor(row["color"])) + painter.drawText(badge_rect, Qt.AlignmentFlag.AlignCenter, badge) + title_right -= badge_w + 6 + font.setPixelSize(13) + font.setWeight(QFont.Weight.DemiBold) + painter.setFont(font) + painter.setPen(tokens.fg) + title = painter.fontMetrics().elidedText( + row["title"], Qt.TextElideMode.ElideRight, int(title_right - left) + ) + painter.drawText( + QRectF(left, rect.top() + 8, title_right - left, 18), + Qt.AlignmentFlag.AlignVCenter, + title, + ) + font.setPixelSize(12) + font.setWeight(QFont.Weight.Normal) + painter.setFont(font) + painter.setPen(tokens.fg_muted) + body = painter.fontMetrics().elidedText( + row["body"].replace("\n", " "), Qt.TextElideMode.ElideRight, int(right - left) + ) + painter.drawText( + QRectF(left, rect.top() + 28, right - left, 18), Qt.AlignmentFlag.AlignVCenter, body + ) + font.setPixelSize(11) + painter.setFont(font) + meta_color = QColor(row["color"]) if row["needsAck"] else tokens.fg_subtle + painter.setPen(meta_color) + meta = ("Needs acknowledgement · " if row["needsAck"] else "") + " · ".join( + part for part in (row["severityLabel"], row["source"], row["scan"]) if part + ) + meta = painter.fontMetrics().elidedText( + meta, Qt.TextElideMode.ElideRight, int(right - left) + ) + painter.drawText( + QRectF(left, rect.top() + 48, right - left, 16), Qt.AlignmentFlag.AlignVCenter, meta + ) + painter.restore() + + +class _DetailsPage(QWidget): + """Full view of one entry: message, metadata, details text and actions.""" + + def __init__(self, host: NotificationHostBase, parent: QWidget | None = None): + super().__init__(parent) + self.host = host + self.entry_id = "" + self.back_btn = TextButton("All notifications", "ghost", "arrow_back", self) + self.back_btn.clicked.connect(lambda: self.host.select(None)) + self.badge = _Badge(self, 36) + self.title = QLabel(self) + self.title.setWordWrap(True) + self.title.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse) + self.pill = QLabel(self) + self.pill.setFixedHeight(18) + self.body = QLabel(self) + self.body.setWordWrap(True) + self.body.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse) + self.facts = QLabel(self) + self.facts.setTextFormat(Qt.TextFormat.RichText) + self.details_label = QLabel("Details", self) + self.details = QPlainTextEdit(self) + self.details.setReadOnly(True) + self.details.setLineWrapMode(QPlainTextEdit.LineWrapMode.NoWrap) + mono = QFontDatabase.systemFont(QFontDatabase.SystemFont.FixedFont) + mono.setPixelSize(11) + self.details.setFont(mono) + self.copy_btn = TextButton("Copy report", "neutral", "content_copy", self) + self.copy_btn.clicked.connect(self._copy) + self.ack_btn = TextButton("Acknowledge", "danger", "done", self) + self.ack_btn.clicked.connect(lambda: self.host.acknowledge(self.entry_id)) + self.remove_btn = TextButton("Remove", "ghost", "delete", self) + self.remove_btn.clicked.connect(lambda: self.host.remove(self.entry_id)) + + head = QHBoxLayout() + head.setSpacing(12) + head.addWidget(self.badge, 0, Qt.AlignmentFlag.AlignTop) + title_col = QVBoxLayout() + title_col.setSpacing(4) + title_col.addWidget(self.pill, 0, Qt.AlignmentFlag.AlignLeft) + title_col.addWidget(self.title) + head.addLayout(title_col, 1) + actions = QHBoxLayout() + actions.setSpacing(6) + actions.addWidget(self.copy_btn) + actions.addWidget(self.ack_btn) + actions.addStretch(1) + actions.addWidget(self.remove_btn) + layout = QVBoxLayout(self) + layout.setContentsMargins(12, 4, 12, 12) + layout.setSpacing(10) + layout.addWidget(self.back_btn, 0, Qt.AlignmentFlag.AlignLeft) + layout.addLayout(head) + layout.addWidget(self.body) + layout.addWidget(self.facts) + layout.addWidget(self.details_label) + layout.addWidget(self.details, 1) + layout.addStretch(0) + layout.addLayout(actions) + + def _copy(self) -> None: + if self.host.copy_details(self.entry_id): + self.copy_btn.setText("Copied") + + def set_row(self, row: dict, tokens: ThemeTokens) -> None: + """Show ``row``.""" + if row["id"] != self.entry_id: + self.copy_btn.setText("Copy report") + self.entry_id = row["id"] + self.badge.set_icon(row["icon"], row["color"], row["tint"]) + self.title.setText(row["title"]) + label = row["severityLabel"] + (" · needs acknowledgement" if row["needsAck"] else "") + self.pill.setText(label) + self.body.setText(row["body"] or "No message.") + facts = [("When", row["timeAbs"])] + if row["count"] > 1: + facts.append(("Repeated", f"{row['count']} times since {row['firstAbs']}")) + if row["source"]: + facts.append(("Source", row["source"])) + if row["scan"]: + facts.append(("Scan", row["scan"])) + self.facts.setText( + "" + + "".join( + f"" + f"" + for k, v in facts + ) + + "
{k}{v}
" + ) + if self.details.toPlainText() != row["details"]: + self.details.setPlainText(row["details"]) + self.details.setVisible(row["hasDetails"]) + self.details_label.setVisible(row["hasDetails"]) + self.ack_btn.setVisible(row["needsAck"]) + self.title.setStyleSheet( + f"color: {tokens.fg.name()}; font-size: 15px; font-weight: 600; background: none;" + ) + self.pill.setStyleSheet( + f"color: {row['color']}; background: {row['tint']}; border-radius: 9px;" + " padding: 1px 8px; font-size: 11px; font-weight: 600;" + ) + self.body.setStyleSheet(f"color: {tokens.fg.name()}; font-size: 13px; background: none;") + self.facts.setStyleSheet("font-size: 12px; background: none;") + self.details_label.setStyleSheet( + f"color: {tokens.fg_subtle.name()}; font-size: 11px; font-weight: 600;" + " text-transform: uppercase; background: none;" + ) + self.details.setStyleSheet( + f"QPlainTextEdit {{ background: {tokens.field.name()}; color: {tokens.fg.name()};" + f" border: 1px solid {tokens.border.name()}; border-radius: 8px; padding: 6px; }}" + ) + for button in (self.back_btn, self.copy_btn, self.ack_btn, self.remove_btn): + button.refresh_theme(tokens) + + +class HistoryDrawer(QFrame): + """Right-edge panel with filters, search, the notification history and details.""" + + def __init__(self, host: NotificationHostBase, parent: QWidget): + super().__init__(parent) + self.host = host + self.setObjectName("notificationDrawer") + self.title = QLabel("Notifications", self) + self.subtitle = QLabel(self) + self.read_btn = IconButton("done_all", "Mark all as read", self) + self.read_btn.clicked.connect(self.host.mark_all_read) + self.clear_btn = IconButton("clear_all", "Clear history", self) + self.clear_btn.clicked.connect(self.host.clear_history) + self.close_btn = IconButton("close", "Close", self) + self.close_btn.clicked.connect(lambda: self.host.set_drawer_open(False)) + self.chips: dict[str, QPushButton] = {} + chips_row = QHBoxLayout() + chips_row.setSpacing(6) + for key in ("all", "errors", "warnings", "info"): + chip = QPushButton(self) + chip.setCheckable(True) + chip.setFixedHeight(26) + chip.setCursor(Qt.CursorShape.PointingHandCursor) + chip.clicked.connect(lambda _=False, k=key: self.host.set_filter(k)) + self.chips[key] = chip + chips_row.addWidget(chip) + chips_row.addStretch(1) + self.search = QLineEdit(self) + self.search.setPlaceholderText("Search notifications") + self.search.setClearButtonEnabled(True) + self.search.textChanged.connect(self.host.set_search) + + self.model = _RowsModel(self) + self.list = QListView(self) + self.list.setModel(self.model) + self.list.setItemDelegate(_RowDelegate(self.list)) + self.list.setMouseTracking(True) + self.list.setUniformItemSizes(True) + self.list.setVerticalScrollMode(QListView.ScrollMode.ScrollPerPixel) + self.list.setFrameShape(QFrame.Shape.NoFrame) + self.list.clicked.connect( + lambda index: self.host.select(index.data(Qt.ItemDataRole.UserRole)["id"]) + ) + self.empty = QLabel(self) + self.empty.setAlignment(Qt.AlignmentFlag.AlignCenter) + self.empty.setWordWrap(True) + self.details = _DetailsPage(host, self) + details_scroll = QScrollArea(self) + details_scroll.setWidgetResizable(True) + details_scroll.setFrameShape(QFrame.Shape.NoFrame) + details_scroll.setWidget(self.details) + self.pages = QStackedWidget(self) + self.pages.addWidget(self.list) + self.pages.addWidget(self.empty) + self.pages.addWidget(details_scroll) + + header = QHBoxLayout() + header.setSpacing(4) + title_col = QVBoxLayout() + title_col.setSpacing(0) + title_col.addWidget(self.title) + title_col.addWidget(self.subtitle) + header.addLayout(title_col, 1) + header.addWidget(self.read_btn) + header.addWidget(self.clear_btn) + header.addWidget(self.close_btn) + self.list_tools = QWidget(self) + tools = QVBoxLayout(self.list_tools) + tools.setContentsMargins(0, 0, 0, 0) + tools.setSpacing(8) + tools.addLayout(chips_row) + tools.addWidget(self.search) + top = QVBoxLayout() + top.setContentsMargins(16, 14, 12, 8) + top.setSpacing(10) + top.addLayout(header) + top.addWidget(self.list_tools) + layout = QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(0) + layout.addLayout(top) + layout.addWidget(self.pages, 1) + + shadow = QGraphicsDropShadowEffect(self) + shadow.setBlurRadius(28) + shadow.setOffset(-4, 0) + shadow.setColor(QColor(0, 0, 0, 80)) + self.setGraphicsEffect(shadow) + + def set_state(self, state: dict, tokens: ThemeTokens) -> None: + """Render the drawer part of ``state``.""" + self.setVisible(state["drawerOpen"]) + if not state["drawerOpen"]: + return + self._style(tokens) + unread = state["openCriticals"] + self.subtitle.setText( + f"{state['total']} in history" + (f" · {unread} critical open" if unread else "") + ) + for item in state["filters"]: + chip = self.chips[item["key"]] + chip.setText(f"{item['label']} {item['count']}") + chip.setChecked(item["active"]) + if self.search.text() != state["search"]: + self.search.blockSignals(True) + self.search.setText(state["search"]) + self.search.blockSignals(False) + self.model.set_rows(state["rows"]) + selected = state["selected"] + self.list_tools.setVisible(selected is None) + if selected is not None: + self.details.set_row(selected, tokens) + self.pages.setCurrentIndex(2) + elif state["rows"]: + self.pages.setCurrentIndex(0) + else: + if state["total"]: + self.empty.setText("Nothing matches this filter.") + else: + self.empty.setText("You're all caught up.\nNew messages and errors appear here.") + self.pages.setCurrentIndex(1) + + def _style(self, tokens: ThemeTokens) -> None: + chip_qss = ( + f"QPushButton {{ background: transparent; color: {tokens.fg_muted.name()};" + f" border: 1px solid {tokens.border.name()}; border-radius: 13px;" + f" padding: 3px 10px; font-size: 12px; }}" + f"QPushButton:hover {{ background: {tokens.hover.name()}; }}" + f"QPushButton:checked {{ background: {rgba(tokens.primary, 0.18)};" + f" color: {tokens.fg.name()}; border-color: {tokens.primary.name()};" + f" font-weight: 600; }}" + ) + self.setStyleSheet( + f"QFrame#notificationDrawer {{ background: {tokens.bg.name()};" + f" border-left: 1px solid {tokens.border.name()}; }}" + f"QListView, QScrollArea, QStackedWidget, QScrollArea > QWidget > QWidget" + f" {{ background: {tokens.bg.name()}; border: none; }}" + field_qss(tokens) + ) + for chip in self.chips.values(): + chip.setStyleSheet(chip_qss) + self.title.setStyleSheet( + f"color: {tokens.fg.name()}; font-size: 15px; font-weight: 600; background: none;" + ) + self.subtitle.setStyleSheet( + f"color: {tokens.fg_subtle.name()}; font-size: 11px; background: none;" + ) + self.empty.setStyleSheet( + f"color: {tokens.fg_subtle.name()}; font-size: 13px; background: none;" + ) + for button in (self.read_btn, self.clear_btn, self.close_btn): + button.refresh_theme(tokens) + + +class BellButton(QToolButton): + """Status-bar bell with a badge counting unread notifications.""" + + def __init__(self, host: NotificationHostBase, parent: QWidget | None = None): + super().__init__(parent) + self.host = host + self._state: dict = {} + self.setAutoRaise(True) + self.setCheckable(True) + self.setFixedSize(36, 24) + self.setIconSize(QSize(18, 18)) + self.setCursor(Qt.CursorShape.PointingHandCursor) + self.setAccessibleName("Notifications") + self.clicked.connect(self.host.toggle_drawer) + + def set_state(self, state: dict, tokens: ThemeTokens) -> None: + """Update icon, badge and tooltip.""" + self._state = state + self.setChecked(state["drawerOpen"]) + color = state["bellColor"] if state["unread"] or state["openCriticals"] else tokens.fg_muted + self.setIcon( + material_icon(state["bellIcon"], size=(36, 36), color=color, convert_to_pixmap=False) + ) + self.setToolTip(state["bellTip"]) + self.setStyleSheet( + f"QToolButton {{ border: none; border-radius: 6px; background: transparent; }}" + f"QToolButton:hover, QToolButton:checked {{ background: {tokens.hover.name()}; }}" + ) + self.update() + + def paintEvent(self, event): # pylint: disable=invalid-name + super().paintEvent(event) + unread = self._state.get("unread", 0) + if not unread: + return + text = "99+" if unread > 99 else str(unread) + painter = QPainter(self) + painter.setRenderHint(QPainter.RenderHint.Antialiasing) + font = self.font() + font.setPixelSize(9) + font.setWeight(QFont.Weight.Bold) + painter.setFont(font) + width = max(14, painter.fontMetrics().horizontalAdvance(text) + 8) + rect = QRectF(self.width() - width - 1, 1, width, 14) + painter.setPen(Qt.PenStyle.NoPen) + painter.setBrush(QColor(self._state["bellColor"])) + painter.drawRoundedRect(rect, 7, 7) + painter.setPen(QColor("white")) + painter.drawText(rect, Qt.AlignmentFlag.AlignCenter, text) + painter.end() + + +class NotificationHostQWidget(NotificationHostBase): + """Notification UI drawn with QWidgets. See :class:`NotificationHostBase`.""" + + def _create_views(self) -> None: + self.toasts = ToastStack(self, self.window) + self.drawer = HistoryDrawer(self, self.window) + self.drawer.hide() + self.toasts.hide() + self.bell = BellButton(self) + if self.status_bar is not None: + self.status_bar.addPermanentWidget(self.bell) + + def _sync_views(self, state: dict) -> None: + tokens = ThemeTokens() + self.toasts.set_state(state, tokens) + self.drawer.set_state(state, tokens) + self.bell.set_state(state, tokens) + + def _place_views(self) -> None: + if self.drawer.isVisible(): + self.drawer.setGeometry(self.drawer_geometry()) + self.drawer.raise_() + if self.toasts.isVisible(): + # the stack has an 8 px margin for the shadow, so the cards line up with MARGIN + width = self.toast_geometry(0).width() + 16 + layout = self.toasts.layout() + height = ( + layout.totalHeightForWidth(width) + if layout.hasHeightForWidth() + else layout.totalSizeHint().height() + ) + rect = self.toast_geometry(height - 16) + self.toasts.setGeometry(rect.adjusted(-8, -8, 8, 8) & self.window.rect()) + self.toasts.raise_() + + def _destroy_views(self) -> None: + for widget in (self.toasts, self.drawer, self.bell): + widget.hide() + widget.deleteLater() diff --git a/bec_widgets/widgets/containers/main_window/addons/notification_center/notification_ux_common.py b/bec_widgets/widgets/containers/main_window/addons/notification_center/notification_ux_common.py new file mode 100644 index 00000000..ccc010f7 --- /dev/null +++ b/bec_widgets/widgets/containers/main_window/addons/notification_center/notification_ux_common.py @@ -0,0 +1,962 @@ +"""Toolkit-independent core of the reworked notification and error UX. + +The QML and QWidget versions of the notification UI share everything that is not drawing: + +- :class:`NotificationHub` is the application-wide history of notifications. BEC alarms, + errors caught by ``SafeSlot(popup_error=True)``, uncaught exceptions and calls to + :meth:`NotificationHub.notify` all end up here. Repeated messages are folded into one entry + with a counter instead of flooding the screen. +- :class:`ToastQueue` decides which entries are shown as short toasts: at most a few at a time, + auto-dismissed after a severity dependent time (paused while hovered) except for critical + errors, which stay until they are acknowledged. +- :class:`NotificationHostBase` attaches the UI to a window: a toast stack in the bottom right + corner, a history drawer on the right edge and a bell button with an unread badge for the + status bar. Subclasses only create and place their views and render :meth:`view_state`. +""" + +from __future__ import annotations + +import time +from dataclasses import dataclass, field +from datetime import datetime +from enum import Enum +from typing import Any +from uuid import uuid4 + +import shiboken6 +from bec_lib.endpoints import MessageEndpoints +from bec_lib.logger import bec_logger +from bec_lib.messages import ErrorInfo +from qtpy.QtCore import QEvent, QObject, QRect, Qt, QThread, QTimer, Signal +from qtpy.QtGui import QColor +from qtpy.QtWidgets import QApplication, QMainWindow, QStatusBar, QWidget + +from bec_widgets.utils.quick.host import ThemeTokens, _blend + +logger = bec_logger.logger + + +class Severity(str, Enum): + """Severity of a notification, from least to most serious.""" + + INFO = "info" + SUCCESS = "success" + WARNING = "warning" + ERROR = "error" + CRITICAL = "critical" + + +# label, Material icon, toast lifetime in ms (0 = until acknowledged) and rank for sorting +SEVERITY_META: dict[Severity, dict[str, Any]] = { + Severity.INFO: {"label": "Info", "icon": "info", "lifetime": 4000, "rank": 0}, + Severity.SUCCESS: {"label": "Done", "icon": "check_circle", "lifetime": 4000, "rank": 1}, + Severity.WARNING: {"label": "Warning", "icon": "warning", "lifetime": 8000, "rank": 2}, + Severity.ERROR: {"label": "Error", "icon": "error", "lifetime": 12000, "rank": 3}, + Severity.CRITICAL: {"label": "Critical", "icon": "dangerous", "lifetime": 0, "rank": 4}, +} + +# legacy banner kinds and BEC alarm levels mapped onto the new severities +_LEGACY_KINDS = { + "info": Severity.INFO, + "warning": Severity.WARNING, + "minor": Severity.ERROR, + "major": Severity.CRITICAL, +} +_ALARM_LEVELS = {0: Severity.WARNING, 1: Severity.ERROR, 2: Severity.CRITICAL} + +# history filters: key -> (label, severities) +FILTERS: dict[str, tuple[str, tuple[Severity, ...]]] = { + "all": ("All", tuple(Severity)), + "errors": ("Errors", (Severity.ERROR, Severity.CRITICAL)), + "warnings": ("Warnings", (Severity.WARNING,)), + "info": ("Info", (Severity.INFO, Severity.SUCCESS)), +} + + +def to_severity(value: Any) -> Severity: + """Normalise a severity given as :class:`Severity`, legacy banner kind or alarm level. + + Args: + value: ``Severity``, its value, a legacy kind (``info``/``warning``/``minor``/``major``) + or an integer BEC alarm level (0, 1, 2). + + Returns: + Severity: The matching severity; unknown values become ``WARNING``. + """ + if isinstance(value, Severity): + return value + raw = getattr(value, "value", value) + if isinstance(raw, str): + raw = raw.lower() + if raw in _LEGACY_KINDS: + return _LEGACY_KINDS[raw] + try: + return Severity(raw) + except ValueError: + return Severity.WARNING + if isinstance(raw, int) and not isinstance(raw, bool): + return _ALARM_LEVELS.get(raw, Severity.WARNING) + return Severity.WARNING + + +def severity_color(tokens: ThemeTokens, severity: Severity) -> QColor: + """Theme colour used for a severity: icon, accent stripe and badge.""" + if severity == Severity.INFO: + return QColor(tokens.accent) + if severity == Severity.SUCCESS: + return QColor(tokens.success) + if severity == Severity.WARNING: + return QColor(tokens.warning) + if severity == Severity.ERROR: + # orange: between warning and critical so the two error levels read differently + return _blend(tokens.warning, tokens.danger, 0.55) + return QColor(tokens.danger) + + +def split_traceback(text: str) -> tuple[str, str]: + """Return ``(exception type, message)`` from the last line of a formatted traceback.""" + lines = [line.strip() for line in (text or "").strip().splitlines() if line.strip()] + if not lines: + return "Error", "" + last = lines[-1] + head, sep, tail = last.partition(":") + if sep and head and " " not in head.strip(): + return head.strip().rsplit(".", 1)[-1], tail.strip() + return "Error", last + + +def relative_time(timestamp: float, now: float | None = None) -> str: + """Short human readable age of ``timestamp`` such as ``2 min ago`` or ``13:04``.""" + now = time.time() if now is None else now + seconds = max(0, int(now - timestamp)) + if seconds < 30: + return "just now" + if seconds < 3600: + return f"{max(1, seconds // 60)} min ago" + stamp = datetime.fromtimestamp(timestamp) + if stamp.date() == datetime.fromtimestamp(now).date(): + return stamp.strftime("%H:%M") + return stamp.strftime("%b %d, %H:%M") + + +def absolute_time(timestamp: float) -> str: + """Full local timestamp.""" + return datetime.fromtimestamp(timestamp).strftime("%Y-%m-%d %H:%M:%S") + + +@dataclass +class NotificationEntry: + """One notification in the history.""" + + id: str + severity: Severity + title: str + body: str = "" + details: str = "" + source: str = "" + scan_number: int | None = None + created: float = field(default_factory=time.time) + updated: float = field(default_factory=time.time) + count: int = 1 + read: bool = False + acknowledged: bool = False + + @property + def needs_ack(self) -> bool: + """A critical entry stays pinned and on screen until somebody acknowledges it.""" + return self.severity == Severity.CRITICAL and not self.acknowledged + + def copy_text(self) -> str: + """Plain-text report used by the Copy action, ready to paste into a logbook or issue.""" + lines = [f"[{SEVERITY_META[self.severity]['label']}] {self.title}"] + lines.append(f"Time: {absolute_time(self.updated)}") + if self.count > 1: + lines.append(f"Occurrences: {self.count} (first at {absolute_time(self.created)})") + if self.source: + lines.append(f"Source: {self.source}") + if self.scan_number is not None: + lines.append(f"Scan: #{self.scan_number}") + if self.body: + lines.extend(["", self.body]) + if self.details: + lines.extend(["", self.details]) + return "\n".join(lines) + + +class NotificationHub(QObject): + """Application-wide notification history shared by every window and both UI versions. + + Signals: + posted(str): A notification was added, or a repeated one was folded into an entry. + changed(): Anything in the history changed. + scan_started(): A new scan opened; hosts hide the non-critical toasts. + """ + + MAX_ENTRIES = 500 + DEDUP_WINDOW_S = 120.0 + + posted = Signal(str) + changed = Signal() + scan_started = Signal() + _post_requested = Signal(dict) + + _instance: NotificationHub | None = None + + def __init__(self, parent: QObject | None = None): + super().__init__(parent) + self._entries: list[NotificationEntry] = [] + self._hosts = 0 + self._dispatcher = None + self._post_requested.connect(self._post, Qt.ConnectionType.QueuedConnection) + + @classmethod + def instance(cls) -> NotificationHub: + """Return the hub of this application, creating it on first use.""" + inst = cls._instance + if inst is None or not shiboken6.isValid(inst): + inst = cls(QApplication.instance()) + cls._instance = inst + return inst + + @classmethod + def reset_instance(cls) -> None: + """Drop the hub, e.g. between tests. Its BEC subscriptions are disconnected.""" + inst = cls._instance + cls._instance = None + if inst is not None and shiboken6.isValid(inst): + inst.disconnect_bec() + shiboken6.delete(inst) + + # ------------------------------------------------------------------ public API + def notify( + self, + title: str, + body: str = "", + severity: Severity | str | int = Severity.INFO, + *, + details: str = "", + source: str = "", + scan_number: int | None = None, + entry_id: str | None = None, + ) -> str: + """Post a notification. Safe to call from any thread. + + Args: + title(str): Short headline, e.g. ``Motor samx not reachable``. + body(str): One or two sentences for the toast and the history row. + severity(Severity | str | int): Severity, legacy kind or alarm level. + details(str): Optional long text such as a traceback, shown in the details view. + source(str): Where it came from, e.g. ``Scan server`` or a widget name. + scan_number(int | None): Scan the notification refers to. + entry_id(str | None): Stable id; a second post with the same id is ignored. + + Returns: + str: The id of the (possibly folded) entry. + """ + payload = { + "title": str(title or "Notification"), + "body": str(body or ""), + "severity": to_severity(severity), + "details": str(details or ""), + "source": str(source or ""), + "scan_number": scan_number, + "entry_id": entry_id or uuid4().hex, + } + if QThread.currentThread() is not self.thread(): + self._post_requested.emit(payload) + return payload["entry_id"] + return self._post(payload) + + def report_exception(self, title: str, traceback_text: str, source: str = "") -> str: + """Post an error from a formatted traceback; used instead of a modal error dialog.""" + exc_type, message = split_traceback(traceback_text) + return self.notify( + exc_type, + message or "An unexpected error occurred.", + Severity.ERROR, + details=traceback_text.strip(), + source=source or title, + ) + + def post_alarm(self, msg: dict, meta: dict | None = None) -> str | None: + """Translate a BEC alarm message into a notification. + + Args: + msg(dict): Alarm content with ``severity``, ``alarm_type``, ``msg`` and ``info``. + meta(dict | None): Message metadata, may hold ``scan_id`` and ``scan_number``. + + Returns: + str | None: Id of the entry, or None if the alarm was already posted. + """ + msg = msg or {} + meta = meta or {} + info = msg.get("info") + alarm_id = info.get("id") if isinstance(info, dict) else getattr(info, "id", None) + if isinstance(info, dict): + try: + info = ErrorInfo(**info) + except Exception: # pylint: disable=broad-except + # incomplete payload: keep what is there + info = type("PartialInfo", (), dict(info))() + if alarm_id and self.get(alarm_id) is not None: + return None + trace = getattr(info, "error_message", None) or msg.get("msg") or "" + compact = getattr(info, "compact_error_message", None) + exc_type, last_line = split_traceback(trace) + title = msg.get("alarm_type") or getattr(info, "exception_type", None) or exc_type + body = compact or last_line or str(msg.get("msg") or "") + details = trace if trace.strip() and trace.strip() != body.strip() else "" + if meta.get("scan_id") and details: + details = f"Scan id: {meta['scan_id']}\n\n{details}" + device = getattr(info, "device", None) + source = f"Device {device}" if device else "BEC server" + return self.notify( + title, + body, + msg.get("severity", 0), + details=details, + source=source, + scan_number=meta.get("scan_number"), + entry_id=alarm_id, + ) + + @property + def entries(self) -> list[NotificationEntry]: + """History, newest first.""" + return list(self._entries) + + def get(self, entry_id: str) -> NotificationEntry | None: + """Return the entry with ``entry_id`` or None.""" + for entry in self._entries: + if entry.id == entry_id: + return entry + return None + + def acknowledge(self, entry_id: str) -> None: + """Acknowledge a (critical) entry; it is unpinned and its toast closes.""" + entry = self.get(entry_id) + if entry is None or entry.acknowledged: + return + entry.acknowledged = True + entry.read = True + self.changed.emit() + + def remove(self, entry_id: str) -> None: + """Remove one entry from the history.""" + entry = self.get(entry_id) + if entry is not None: + self._entries.remove(entry) + self.changed.emit() + + def clear(self) -> None: + """Clear the history but keep critical errors nobody has acknowledged yet.""" + kept = [entry for entry in self._entries if entry.needs_ack] + if len(kept) != len(self._entries): + self._entries = kept + self.changed.emit() + + def mark_read(self, entry_ids: list[str] | None = None) -> None: + """Mark the given entries, or all of them, as read.""" + touched = False + for entry in self._entries: + if not entry.read and (entry_ids is None or entry.id in entry_ids): + entry.read = True + touched = True + if touched: + self.changed.emit() + + def unread_count(self) -> int: + """Number of unread entries.""" + return sum(1 for entry in self._entries if not entry.read) + + def top_unread_severity(self) -> Severity | None: + """Most serious severity among unread or unacknowledged entries.""" + open_entries = [e for e in self._entries if not e.read or e.needs_ack] + if not open_entries: + return None + return max(open_entries, key=lambda e: SEVERITY_META[e.severity]["rank"]).severity + + # ------------------------------------------------------------------ hosts and BEC feed + def register_host(self) -> None: + """Called by a host when it attaches; errors are then routed here, not to dialogs.""" + self._hosts += 1 + + def unregister_host(self) -> None: + """Called by a host when it detaches.""" + self._hosts = max(0, self._hosts - 1) + + @property + def has_hosts(self) -> bool: + """Whether any window currently shows notifications.""" + return self._hosts > 0 + + def connect_bec(self, dispatcher) -> None: + """Subscribe to BEC alarms and scan status once per application.""" + if self._dispatcher is not None or dispatcher is None: + return + self._dispatcher = dispatcher + dispatcher.connect_slot(self._on_alarm, MessageEndpoints.alarm()) + dispatcher.connect_slot(self._on_scan_status, MessageEndpoints.scan_status()) + + def disconnect_bec(self) -> None: + """Undo :meth:`connect_bec`.""" + dispatcher, self._dispatcher = self._dispatcher, None + if dispatcher is None: + return + try: + dispatcher.disconnect_slot(self._on_alarm, MessageEndpoints.alarm()) + dispatcher.disconnect_slot(self._on_scan_status, MessageEndpoints.scan_status()) + except Exception as exc: # pylint: disable=broad-except + logger.warning(f"NotificationHub could not disconnect from BEC: {exc}") + + def _on_alarm(self, msg: dict, meta: dict) -> None: + try: + self.post_alarm(msg, meta) + except Exception as exc: # pylint: disable=broad-except + logger.error(f"Could not turn alarm into a notification: {exc}") + + def _on_scan_status(self, msg: dict, _meta: dict) -> None: + if (msg or {}).get("status") == "open": + self.scan_started.emit() + + # ------------------------------------------------------------------ internals + def _post(self, payload: dict) -> str: + entry_id = payload["entry_id"] + if self.get(entry_id) is not None: + return entry_id + now = time.time() + for entry in self._entries: + if now - entry.updated > self.DEDUP_WINDOW_S: + break + if ( + entry.severity == payload["severity"] + and entry.title == payload["title"] + and entry.body == payload["body"] + and entry.source == payload["source"] + ): + entry.count += 1 + entry.updated = now + entry.read = False + entry.acknowledged = False + entry.details = payload["details"] or entry.details + self._entries.remove(entry) + self._entries.insert(0, entry) + self.changed.emit() + self.posted.emit(entry.id) + return entry.id + entry = NotificationEntry( + id=entry_id, + severity=payload["severity"], + title=payload["title"], + body=payload["body"], + details=payload["details"], + source=payload["source"], + scan_number=payload["scan_number"], + created=now, + updated=now, + ) + self._entries.insert(0, entry) + self._trim() + self.changed.emit() + self.posted.emit(entry.id) + return entry.id + + def _trim(self) -> None: + excess = len(self._entries) - self.MAX_ENTRIES + if excess <= 0: + return + for entry in reversed(list(self._entries)): + if excess <= 0: + break + if not entry.needs_ack: + self._entries.remove(entry) + excess -= 1 + + +class ToastQueue(QObject): + """Decides which notifications are on screen as toasts and for how long. + + At most :attr:`MAX_VISIBLE` toasts are shown; newer ones replace the oldest non-critical + toast and the rest are counted as overflow ("+3 more"), which stays reachable in the + history. Hovering the stack pauses every countdown. + + Signals: + changed(): The visible toasts or the overflow count changed. + """ + + MAX_VISIBLE = 3 + + changed = Signal() + + def __init__(self, hub: NotificationHub, parent: QObject | None = None): + super().__init__(parent) + self._hub = hub + self._visible: list[str] = [] # oldest first + self._timers: dict[str, QTimer] = {} + self._remaining: dict[str, int] = {} + self._started: dict[str, float] = {} + self._paused = False + self._suspended = False + self.overflow = 0 + hub.posted.connect(self._on_posted) + hub.changed.connect(self._on_hub_changed) + hub.scan_started.connect(self.dismiss_transient) + + @property + def visible_ids(self) -> list[str]: + """Ids of the toasts on screen, oldest first.""" + return list(self._visible) + + def set_suspended(self, suspended: bool) -> None: + """Hide all toasts while the history drawer is open; they stay in the history.""" + self._suspended = suspended + if suspended: + self.dismiss_all(keep_critical=False) + + def set_paused(self, paused: bool) -> None: + """Pause or resume all countdowns, e.g. while the pointer is over the stack.""" + if paused == self._paused: + return + self._paused = paused + now = time.monotonic() + for entry_id, timer in self._timers.items(): + if paused: + elapsed = int((now - self._started[entry_id]) * 1000) + self._remaining[entry_id] = max(500, self._remaining[entry_id] - elapsed) + timer.stop() + else: + self._started[entry_id] = now + timer.start(self._remaining[entry_id]) + + def dismiss(self, entry_id: str) -> None: + """Close one toast; the entry stays in the history.""" + if entry_id not in self._visible: + return + self._visible.remove(entry_id) + self._stop_timer(entry_id) + self.changed.emit() + + def dismiss_transient(self) -> None: + """Close all toasts except unacknowledged critical errors (used when a scan starts).""" + self.dismiss_all(keep_critical=True) + + def dismiss_all(self, keep_critical: bool = True) -> None: + """Close all toasts, optionally keeping the critical ones.""" + keep = [] + for entry_id in self._visible: + entry = self._hub.get(entry_id) + if keep_critical and entry is not None and entry.needs_ack: + keep.append(entry_id) + else: + self._stop_timer(entry_id) + changed = keep != self._visible or self.overflow + self._visible = keep + self.overflow = 0 + if changed: + self.changed.emit() + + def cleanup(self) -> None: + """Stop all timers and disconnect from the hub.""" + for entry_id in list(self._timers): + self._stop_timer(entry_id) + for signal, slot in ( + (self._hub.posted, self._on_posted), + (self._hub.changed, self._on_hub_changed), + (self._hub.scan_started, self.dismiss_transient), + ): + try: + signal.disconnect(slot) + except (RuntimeError, TypeError): + pass + + # ------------------------------------------------------------------ internals + def _on_posted(self, entry_id: str) -> None: + if self._suspended: + return + entry = self._hub.get(entry_id) + if entry is None: + return + if entry_id in self._visible: + # a repeat: move it to the newest position and restart its countdown + self._visible.remove(entry_id) + self._visible.append(entry_id) + self._start_timer(entry) + self.changed.emit() + return + if len(self._visible) >= self.MAX_VISIBLE: + victim = next( + (i for i in self._visible if not getattr(self._hub.get(i), "needs_ack", False)), + None, + ) + if victim is None and not entry.needs_ack: + self.overflow += 1 + self.changed.emit() + return + victim = victim or self._visible[0] + self._visible.remove(victim) + self._stop_timer(victim) + self.overflow += 1 + self._visible.append(entry_id) + self._start_timer(entry) + self.changed.emit() + + def _on_hub_changed(self) -> None: + gone = [ + i + for i in self._visible + if (entry := self._hub.get(i)) is None + or (entry.severity == Severity.CRITICAL and entry.acknowledged) + ] + for entry_id in gone: + self._visible.remove(entry_id) + self._stop_timer(entry_id) + if gone: + self.changed.emit() + + def _start_timer(self, entry: NotificationEntry) -> None: + self._stop_timer(entry.id) + lifetime = SEVERITY_META[entry.severity]["lifetime"] + if lifetime <= 0: + return + timer = QTimer(self) + timer.setSingleShot(True) + timer.timeout.connect(lambda entry_id=entry.id: self.dismiss(entry_id)) + self._timers[entry.id] = timer + self._remaining[entry.id] = lifetime + self._started[entry.id] = time.monotonic() + if not self._paused: + timer.start(lifetime) + + def _stop_timer(self, entry_id: str) -> None: + timer = self._timers.pop(entry_id, None) + self._remaining.pop(entry_id, None) + self._started.pop(entry_id, None) + if timer is not None: + timer.stop() + timer.deleteLater() + + +def entry_row(entry: NotificationEntry, tokens: ThemeTokens, now: float | None = None) -> dict: + """Flat dictionary describing one entry, consumed by both views.""" + meta = SEVERITY_META[entry.severity] + color = severity_color(tokens, entry.severity) + subtitle = [part for part in (entry.source, relative_time(entry.updated, now)) if part] + if entry.scan_number is not None: + subtitle.insert(1 if entry.source else 0, f"Scan #{entry.scan_number}") + return { + "id": entry.id, + "entryId": entry.id, + "severity": entry.severity.value, + "severityLabel": meta["label"], + "icon": meta["icon"], + "color": color.name(), + "tint": tokens.soft(color, 0.14).name(), + "title": entry.title, + "body": entry.body, + "details": entry.details, + "hasDetails": bool(entry.details), + "source": entry.source, + "meta": " · ".join(subtitle), + "time": relative_time(entry.updated, now), + "timeAbs": absolute_time(entry.updated), + "firstAbs": absolute_time(entry.created), + "scan": "" if entry.scan_number is None else f"#{entry.scan_number}", + "count": entry.count, + "unread": not entry.read, + "needsAck": entry.needs_ack, + "critical": entry.severity == Severity.CRITICAL, + } + + +class NotificationHostBase(QObject): + """Attach the notification UI (toasts, history drawer, bell) to a window. + + Subclasses implement :meth:`_create_views`, :meth:`_sync_views` and :meth:`_place_views`. + + Args: + window(QWidget): Window to overlay, usually a ``QMainWindow``. + status_bar(QStatusBar | None): Where to put the bell; defaults to the window's status + bar if it is a ``QMainWindow``. Pass ``None`` on a plain widget to skip the bell. + dispatcher: ``BECDispatcher`` used to receive BEC alarms; optional. + """ + + TOAST_WIDTH = 380 + DRAWER_WIDTH = 400 + MARGIN = 16 + + def __init__(self, window: QWidget, status_bar: QStatusBar | None = None, dispatcher=None): + super().__init__(window) + self.window = window + self.hub = NotificationHub.instance() + self.hub.register_host() + self.hub.connect_bec(dispatcher) + self.queue = ToastQueue(self.hub, self) + self.drawer_open = False + self.filter_key = "all" + self.search = "" + self.selected_id: str | None = None + self._sync_pending = False + if status_bar is None and isinstance(window, QMainWindow): + status_bar = window.statusBar() + self.status_bar = status_bar + self._create_views() + self.hub.changed.connect(self.sync) + self.queue.changed.connect(self.sync) + self._tick = QTimer(self) + self._tick.setInterval(30_000) + self._tick.timeout.connect(self.sync) + self._tick.start() + app = QApplication.instance() + theme = getattr(app, "theme", None) + if theme is not None: + theme.theme_changed.connect(self._on_theme_changed) + window.installEventFilter(self) + self._cleaned = False + self.sync_now() + + # ------------------------------------------------------------------ actions + def toggle_drawer(self) -> None: + """Open or close the history drawer.""" + self.set_drawer_open(not self.drawer_open) + + def set_drawer_open(self, open_: bool) -> None: + """Open or close the history drawer. Opening marks everything as read.""" + self.drawer_open = bool(open_) + self.queue.set_suspended(self.drawer_open) + if self.drawer_open: + self.hub.mark_read() + else: + self.selected_id = None + self.sync() + + def open_details(self, entry_id: str) -> None: + """Open the drawer on the details of one entry (the toast's Details action).""" + self.selected_id = entry_id + self.set_drawer_open(True) + + def select(self, entry_id: str | None) -> None: + """Show the details of ``entry_id``; None goes back to the list.""" + self.selected_id = entry_id or None + self.sync() + + def set_filter(self, key: str) -> None: + """Filter the history by one of :data:`FILTERS`.""" + self.filter_key = key if key in FILTERS else "all" + self.sync() + + def set_search(self, text: str) -> None: + """Filter the history by text in title, message, source or details.""" + self.search = text or "" + self.sync() + + def acknowledge(self, entry_id: str) -> None: + """Acknowledge a critical error.""" + self.hub.acknowledge(entry_id) + + def remove(self, entry_id: str) -> None: + """Remove an entry from the history.""" + if self.selected_id == entry_id: + self.selected_id = None + self.queue.dismiss(entry_id) + self.hub.remove(entry_id) + + def dismiss_toast(self, entry_id: str) -> None: + """Close a toast without touching the history.""" + entry = self.hub.get(entry_id) + if entry is not None and entry.needs_ack: + self.hub.acknowledge(entry_id) + self.queue.dismiss(entry_id) + if entry is not None: + self.hub.mark_read([entry_id]) + + def copy_details(self, entry_id: str) -> bool: + """Copy a plain-text report of the entry to the clipboard.""" + entry = self.hub.get(entry_id) + if entry is None: + return False + QApplication.clipboard().setText(entry.copy_text()) + return True + + def mark_all_read(self) -> None: + """Mark the whole history as read.""" + self.hub.mark_read() + + def clear_history(self) -> None: + """Clear the history; unacknowledged critical errors stay.""" + self.selected_id = None + self.hub.clear() + + def set_toasts_hovered(self, hovered: bool) -> None: + """Pause the toast countdowns while the pointer is over them.""" + self.queue.set_paused(hovered) + + # ------------------------------------------------------------------ state + def filtered_entries(self) -> list[NotificationEntry]: + """History entries matching the filter and search, critical ones pinned on top.""" + severities = FILTERS[self.filter_key][1] + needle = self.search.strip().lower() + rows = [] + for entry in self.hub.entries: + if entry.severity not in severities: + continue + if needle and not any( + needle in text.lower() + for text in (entry.title, entry.body, entry.source, entry.details) + ): + continue + rows.append(entry) + rows.sort(key=lambda e: not e.needs_ack) # stable: keeps newest first otherwise + return rows + + def view_state(self) -> dict: + """Everything the views need to render, as plain Python data.""" + tokens = ThemeTokens() + now = time.time() + entries = self.hub.entries + counts = { + key: sum(1 for e in entries if e.severity in severities) + for key, (_label, severities) in FILTERS.items() + } + toasts = [] + if not self.drawer_open: + for entry_id in self.queue.visible_ids: + entry = self.hub.get(entry_id) + if entry is not None: + toasts.append(entry_row(entry, tokens, now)) + selected = self.hub.get(self.selected_id) if self.selected_id else None + top = self.hub.top_unread_severity() + unread = self.hub.unread_count() + open_criticals = sum(1 for e in entries if e.needs_ack) + if open_criticals: + bell_tip = f"{open_criticals} critical error(s) need attention" + elif unread: + bell_tip = f"{unread} unread notification(s)" + else: + bell_tip = "Notifications" + return { + "drawerOpen": self.drawer_open, + "toasts": toasts, + "overflow": self.queue.overflow if toasts else 0, + # the list is only built while the drawer is visible + "rows": ( + [entry_row(e, tokens, now) for e in self.filtered_entries()] + if self.drawer_open + else [] + ), + "filters": [ + {"key": key, "label": label, "count": counts[key], "active": key == self.filter_key} + for key, (label, _sev) in FILTERS.items() + ], + "filterKey": self.filter_key, + "search": self.search, + "total": len(entries), + "selected": entry_row(selected, tokens, now) if selected is not None else None, + "unread": unread, + "openCriticals": open_criticals, + "bellColor": ( + severity_color(tokens, top).name() if top is not None else tokens.fg_muted.name() + ), + "bellIcon": "notifications_active" if unread or open_criticals else "notifications", + "bellTip": bell_tip, + } + + def sync(self, *_args) -> None: + """Schedule a view update; bursts of changes are folded into one update.""" + if self._cleaned or self._sync_pending: + return + self._sync_pending = True + QTimer.singleShot(0, self.sync_now) + + def sync_now(self) -> None: + """Push the current state into the views and re-place them right away.""" + self._sync_pending = False + if self._cleaned: + return + state = self.view_state() + self._sync_views(state) + self._place_views() + + def content_rect(self) -> QRect: + """Area of the window the overlays may cover: below menu and tool bars, above status.""" + rect = self.window.rect() + top = 0 + bottom = rect.bottom() + if isinstance(self.window, QMainWindow): + menu = self.window.menuWidget() + if menu is not None and menu.isVisible(): + top = menu.geometry().bottom() + 1 + central = self.window.centralWidget() + if central is not None: + top = max(top, central.geometry().top()) + status = self.window.statusBar() if self.window.statusBar() else None + if status is not None and status.isVisible(): + bottom = status.geometry().top() - 1 + return QRect(rect.left(), top, rect.width(), max(0, bottom - top + 1)) + + def toast_geometry(self, content_height: int) -> QRect: + """Bottom-right rectangle for a toast stack of ``content_height`` pixels.""" + area = self.content_rect() + width = min(self.TOAST_WIDTH, max(200, area.width() - 2 * self.MARGIN)) + height = min(content_height, max(0, area.height() - 2 * self.MARGIN)) + return QRect( + area.right() - self.MARGIN - width + 1, + area.bottom() - self.MARGIN - height + 1, + width, + height, + ) + + def drawer_geometry(self) -> QRect: + """Right-edge rectangle of the history drawer.""" + area = self.content_rect() + width = min(self.DRAWER_WIDTH, area.width()) + return QRect(area.right() - width + 1, area.top(), width, area.height()) + + # ------------------------------------------------------------------ plumbing + def eventFilter(self, watched, event): # pylint: disable=invalid-name + if watched is self.window and event.type() in (QEvent.Type.Resize, QEvent.Type.Show): + self._place_views() + return False + + def _on_theme_changed(self, *_args) -> None: + self._apply_theme() + self.sync() + + def cleanup(self) -> None: + """Detach from the window and the hub.""" + if self._cleaned: + return + self._cleaned = True + self._tick.stop() + self.queue.cleanup() + for signal, slot in ((self.hub.changed, self.sync), (self.queue.changed, self.sync)): + try: + signal.disconnect(slot) + except (RuntimeError, TypeError): + pass + app = QApplication.instance() + theme = getattr(app, "theme", None) + if theme is not None: + try: + theme.theme_changed.disconnect(self._on_theme_changed) + except (RuntimeError, TypeError): + pass + if shiboken6.isValid(self.window): + self.window.removeEventFilter(self) + self.hub.unregister_host() + self._destroy_views() + + # ------------------------------------------------------------------ for subclasses + def _create_views(self) -> None: + raise NotImplementedError + + def _sync_views(self, state: dict) -> None: + raise NotImplementedError + + def _place_views(self) -> None: + raise NotImplementedError + + def _apply_theme(self) -> None: + """Re-style the views after a theme change; QML follows the theme by itself.""" + + def _destroy_views(self) -> None: + """Release the views.""" diff --git a/bec_widgets/widgets/containers/main_window/addons/notification_center/qml/NotificationBell.qml b/bec_widgets/widgets/containers/main_window/addons/notification_center/qml/NotificationBell.qml new file mode 100644 index 00000000..d254d813 --- /dev/null +++ b/bec_widgets/widgets/containers/main_window/addons/notification_center/qml/NotificationBell.qml @@ -0,0 +1,54 @@ +import QtQuick +import QtQuick.Controls.Basic +import BecUi + +// Status-bar bell. The badge counts unread notifications and takes the colour of the most +// serious one; a critical error that is not acknowledged keeps the bell coloured. +AbstractButton { + id: root + required property var backend + readonly property var st: backend.state + readonly property int unread: st.unread || 0 + readonly property bool alert: unread > 0 || (st.openCriticals || 0) > 0 + + implicitWidth: 36 + implicitHeight: 24 + hoverEnabled: true + checked: st.drawerOpen || false + Accessible.name: "Notifications" + onClicked: backend.toggleDrawer() + + background: Rectangle { + radius: 6 + color: root.down ? theme.pressed : (root.hovered || root.checked ? theme.hover : "transparent") + } + contentItem: Item { + Icon { + anchors.centerIn: parent + name: root.st.bellIcon || "notifications" + color: root.alert ? (root.st.bellColor || theme.fgMuted) : theme.fgMuted + size: 18 + } + } + Rectangle { + visible: root.unread > 0 + anchors.right: parent.right + anchors.top: parent.top + anchors.margins: 1 + height: 14 + width: Math.max(14, badgeText.implicitWidth + 8) + radius: 7 + color: root.st.bellColor || theme.primary + Text { + id: badgeText + anchors.centerIn: parent + text: root.unread > 99 ? "99+" : root.unread + color: "white" + font.pixelSize: 9 + font.weight: Font.Bold + } + } + ToolTip.visible: hovered + ToolTip.delay: 400 + ToolTip.text: st.bellTip || "Notifications" +} diff --git a/bec_widgets/widgets/containers/main_window/addons/notification_center/qml/NotificationDrawer.qml b/bec_widgets/widgets/containers/main_window/addons/notification_center/qml/NotificationDrawer.qml new file mode 100644 index 00000000..bf234e44 --- /dev/null +++ b/bec_widgets/widgets/containers/main_window/addons/notification_center/qml/NotificationDrawer.qml @@ -0,0 +1,414 @@ +import QtQuick +import QtQuick.Controls.Basic +import QtQuick.Layouts +import BecUi + +// Right-edge history drawer: filter chips, search, the list of notifications and the +// details of one entry with its full text, metadata and actions. +Rectangle { + id: root + required property var backend + readonly property var st: backend.state + readonly property var sel: st.selected || ({}) + readonly property bool hasSelection: sel.entryId !== undefined + + color: theme.bg + + // shadow towards the content and a hairline border + Rectangle { + anchors.right: parent.left + width: 8; height: parent.height + gradient: Gradient { + orientation: Gradient.Horizontal + GradientStop { position: 0.0; color: "transparent" } + GradientStop { position: 1.0; color: Qt.rgba(0, 0, 0, theme.dark ? 0.3 : 0.08) } + } + } + Rectangle { anchors.left: parent.left; width: 1; height: parent.height; color: theme.border } + + ColumnLayout { + anchors.fill: parent + anchors.leftMargin: 1 + spacing: 0 + + // ---------------------------------------------------------------- header + ColumnLayout { + Layout.fillWidth: true + Layout.leftMargin: 16 + Layout.rightMargin: 12 + Layout.topMargin: 14 + Layout.bottomMargin: 8 + spacing: 10 + + RowLayout { + Layout.fillWidth: true + spacing: 4 + ColumnLayout { + Layout.fillWidth: true + spacing: 0 + Text { + Layout.fillWidth: true + text: "Notifications" + color: theme.fg + font.pixelSize: 15 + font.weight: Font.DemiBold + } + Text { + Layout.fillWidth: true + text: (root.st.total || 0) + " in history" + + ((root.st.openCriticals || 0) > 0 ? " · " + root.st.openCriticals + " critical open" : "") + color: theme.fgSubtle + font.pixelSize: 11 + } + } + IconButton { iconName: "done_all"; tip: "Mark all as read"; onClicked: root.backend.markAllRead() } + IconButton { iconName: "clear_all"; tip: "Clear history"; onClicked: root.backend.clearHistory() } + IconButton { iconName: "close"; tip: "Close"; onClicked: root.backend.setDrawerOpen(false) } + } + + Flow { + Layout.fillWidth: true + visible: !root.hasSelection + spacing: 6 + Repeater { + model: root.st.filters || [] + delegate: AbstractButton { + id: chip + required property var modelData + implicitHeight: 26 + implicitWidth: chipLabel.implicitWidth + 22 + hoverEnabled: true + onClicked: root.backend.setFilter(modelData.key) + background: Rectangle { + radius: 13 + color: chip.modelData.active ? Qt.rgba(theme.primary.r, theme.primary.g, theme.primary.b, 0.18) + : chip.hovered ? theme.hover : "transparent" + border.color: chip.modelData.active ? theme.primary : theme.border + } + contentItem: Text { + id: chipLabel + text: chip.modelData.label + " " + chip.modelData.count + color: chip.modelData.active ? theme.fg : theme.fgMuted + font.pixelSize: 12 + font.weight: chip.modelData.active ? Font.DemiBold : Font.Normal + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + } + } + } + } + + InputField { + id: search + Layout.fillWidth: true + visible: !root.hasSelection + placeholderText: "Search notifications" + text: root.st.search || "" + onTextEdited: root.backend.setSearch(text) + } + } + + // ---------------------------------------------------------------- list + ListView { + id: list + Layout.fillWidth: true + Layout.fillHeight: true + visible: !root.hasSelection && count > 0 + clip: true + model: root.backend.rows + spacing: 0 + boundsBehavior: Flickable.StopAtBounds + ScrollBar.vertical: ScrollBar { policy: ScrollBar.AsNeeded } + + delegate: Item { + id: row + required property string entryId + required property string icon + required property color color + required property color tint + required property string title + required property string body + required property string time + required property string timeAbs + required property string severityLabel + required property string source + required property string scan + required property int count + required property bool unread + required property bool needsAck + + width: ListView.view.width + height: 78 + + Rectangle { + anchors.fill: parent + anchors.leftMargin: 8 + anchors.rightMargin: 8 + anchors.topMargin: 3 + anchors.bottomMargin: 3 + radius: 8 + color: row.needsAck ? (rowMouse.containsMouse ? Qt.lighter(row.tint, 1.1) : row.tint) + : (rowMouse.containsMouse ? theme.hover : theme.card) + border.color: row.needsAck ? row.color : theme.border + + Rectangle { + x: 10; y: 10 + width: 28; height: 28; radius: 14 + color: row.needsAck ? theme.card : row.tint + Icon { anchors.centerIn: parent; name: row.icon; color: row.color; filled: true; size: 17 } + } + + ColumnLayout { + anchors.fill: parent + anchors.leftMargin: 48 + anchors.rightMargin: 10 + anchors.topMargin: 6 + anchors.bottomMargin: 6 + spacing: 2 + + RowLayout { + Layout.fillWidth: true + spacing: 6 + Text { + Layout.fillWidth: true + text: row.title + textFormat: Text.PlainText + color: theme.fg + font.pixelSize: 13 + font.weight: Font.DemiBold + elide: Text.ElideRight + } + Rectangle { + visible: row.count > 1 + implicitWidth: rowCount.implicitWidth + 12 + implicitHeight: 16 + radius: 8 + color: row.tint + Text { + id: rowCount + anchors.centerIn: parent + text: "×" + row.count + color: row.color + font.pixelSize: 11 + } + } + Rectangle { + visible: row.unread + width: 7; height: 7; radius: 3.5 + color: theme.primary + } + Text { + text: row.time + color: theme.fgSubtle + font.pixelSize: 11 + } + } + Text { + Layout.fillWidth: true + text: row.body.replace(/\n/g, " ") + textFormat: Text.PlainText + color: theme.fgMuted + font.pixelSize: 12 + elide: Text.ElideRight + } + Text { + Layout.fillWidth: true + text: (row.needsAck ? "Needs acknowledgement · " : "") + + [row.severityLabel, row.source, row.scan].filter(function (p) { return p !== "" }).join(" · ") + color: row.needsAck ? row.color : theme.fgSubtle + font.pixelSize: 11 + elide: Text.ElideRight + } + } + MouseArea { + id: rowMouse + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: root.backend.select(row.entryId) + ToolTip.visible: containsMouse + ToolTip.delay: 800 + ToolTip.text: row.timeAbs + } + } + } + } + + // ---------------------------------------------------------------- empty state + Item { + Layout.fillWidth: true + Layout.fillHeight: true + visible: !root.hasSelection && list.count === 0 + Column { + anchors.centerIn: parent + spacing: 8 + Icon { + anchors.horizontalCenter: parent.horizontalCenter + name: (root.st.total || 0) > 0 ? "filter_alt_off" : "inbox" + color: theme.fgSubtle + size: 32 + } + Text { + anchors.horizontalCenter: parent.horizontalCenter + horizontalAlignment: Text.AlignHCenter + text: (root.st.total || 0) > 0 ? "Nothing matches this filter." + : "You're all caught up.\nNew messages and errors appear here." + color: theme.fgSubtle + font.pixelSize: 13 + } + } + } + + // ---------------------------------------------------------------- details + Flickable { + id: detailsView + Layout.fillWidth: true + Layout.fillHeight: true + visible: root.hasSelection + clip: true + contentHeight: details.implicitHeight + 16 + boundsBehavior: Flickable.StopAtBounds + ScrollBar.vertical: ScrollBar { policy: ScrollBar.AsNeeded } + + ColumnLayout { + id: details + width: detailsView.width - 24 + x: 12 + spacing: 10 + + TextButton { + text: "All notifications" + iconName: "arrow_back" + variant: "ghost" + onClicked: root.backend.select("") + } + RowLayout { + Layout.fillWidth: true + spacing: 12 + Rectangle { + Layout.alignment: Qt.AlignTop + width: 36; height: 36; radius: 18 + color: root.sel.tint || theme.hover + Icon { anchors.centerIn: parent; name: root.sel.icon || ""; color: root.sel.color || theme.fg; filled: true; size: 21 } + } + ColumnLayout { + Layout.fillWidth: true + spacing: 4 + Rectangle { + implicitHeight: 18 + implicitWidth: pillText.implicitWidth + 16 + radius: 9 + color: root.sel.tint || "transparent" + Text { + id: pillText + anchors.centerIn: parent + text: (root.sel.severityLabel || "") + (root.sel.needsAck ? " · needs acknowledgement" : "") + color: root.sel.color || theme.fg + font.pixelSize: 11 + font.weight: Font.DemiBold + } + } + TextEdit { + Layout.fillWidth: true + text: root.sel.title || "" + textFormat: TextEdit.PlainText + readOnly: true + selectByMouse: true + wrapMode: TextEdit.Wrap + color: theme.fg + selectionColor: theme.primary + font.pixelSize: 15 + font.weight: Font.DemiBold + } + } + } + TextEdit { + Layout.fillWidth: true + text: root.sel.body || "No message." + textFormat: TextEdit.PlainText + readOnly: true + selectByMouse: true + wrapMode: TextEdit.Wrap + color: theme.fg + selectionColor: theme.primary + font.pixelSize: 13 + } + GridLayout { + columns: 2 + columnSpacing: 12 + rowSpacing: 4 + Text { text: "When"; color: theme.fgSubtle; font.pixelSize: 12 } + Text { text: root.sel.timeAbs || ""; color: theme.fg; font.pixelSize: 12 } + Text { visible: (root.sel.count || 0) > 1; text: "Repeated"; color: theme.fgSubtle; font.pixelSize: 12 } + Text { visible: (root.sel.count || 0) > 1; text: (root.sel.count || 0) + " times since " + (root.sel.firstAbs || ""); color: theme.fg; font.pixelSize: 12 } + Text { visible: (root.sel.source || "") !== ""; text: "Source"; color: theme.fgSubtle; font.pixelSize: 12 } + Text { visible: (root.sel.source || "") !== ""; text: root.sel.source || ""; color: theme.fg; font.pixelSize: 12 } + Text { visible: (root.sel.scan || "") !== ""; text: "Scan"; color: theme.fgSubtle; font.pixelSize: 12 } + Text { visible: (root.sel.scan || "") !== ""; text: root.sel.scan || ""; color: theme.fg; font.pixelSize: 12 } + } + Text { + visible: root.sel.hasDetails || false + text: "DETAILS" + color: theme.fgSubtle + font.pixelSize: 11 + font.weight: Font.DemiBold + } + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: Math.min(320, traceText.contentHeight + 16) + visible: root.sel.hasDetails || false + radius: 8 + color: theme.field + border.color: theme.border + Flickable { + id: traceFlick + anchors.fill: parent + anchors.margins: 8 + clip: true + contentWidth: traceText.contentWidth + contentHeight: traceText.contentHeight + boundsBehavior: Flickable.StopAtBounds + ScrollBar.vertical: ScrollBar { policy: ScrollBar.AsNeeded } + ScrollBar.horizontal: ScrollBar { policy: ScrollBar.AsNeeded } + TextEdit { + id: traceText + text: root.sel.details || "" + textFormat: TextEdit.PlainText + readOnly: true + selectByMouse: true + color: theme.fg + selectionColor: theme.primary + font.family: "monospace" + font.pixelSize: 11 + } + } + } + RowLayout { + Layout.fillWidth: true + spacing: 6 + TextButton { + id: copyReport + property string copiedId: "" + text: copiedId !== "" && copiedId === root.sel.entryId ? "Copied" : "Copy report" + iconName: "content_copy" + onClicked: if (root.backend.copyDetails(root.sel.entryId)) copiedId = root.sel.entryId + } + TextButton { + visible: root.sel.needsAck || false + text: "Acknowledge" + iconName: "done" + variant: "danger" + onClicked: root.backend.acknowledge(root.sel.entryId) + } + Item { Layout.fillWidth: true } + TextButton { + text: "Remove" + iconName: "delete" + variant: "ghost" + onClicked: root.backend.remove(root.sel.entryId) + } + } + } + } + } +} diff --git a/bec_widgets/widgets/containers/main_window/addons/notification_center/qml/NotificationToasts.qml b/bec_widgets/widgets/containers/main_window/addons/notification_center/qml/NotificationToasts.qml new file mode 100644 index 00000000..711d2354 --- /dev/null +++ b/bec_widgets/widgets/containers/main_window/addons/notification_center/qml/NotificationToasts.qml @@ -0,0 +1,203 @@ +import QtQuick +import QtQuick.Layouts +import BecUi + +// Bottom-right toast stack. Newest toast at the bottom, nearest to the status bar. +// Hovering the stack pauses every countdown. Critical errors stay until acknowledged. +Item { + id: root + required property var backend + readonly property var st: backend.state + + implicitWidth: 396 + implicitHeight: stack.implicitHeight + 16 + + HoverHandler { + onHoveredChanged: root.backend.setHovered(hovered) + } + + Column { + id: stack + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: parent.bottom + anchors.margins: 8 + spacing: 10 + + move: Transition { NumberAnimation { properties: "y"; duration: 140; easing.type: Easing.OutCubic } } + add: Transition { + NumberAnimation { property: "opacity"; from: 0; to: 1; duration: 160 } + NumberAnimation { property: "x"; from: 40; to: 0; duration: 180; easing.type: Easing.OutCubic } + } + + Item { + width: stack.width + height: moreButton.visible ? moreButton.implicitHeight : 0 + visible: (root.st.overflow || 0) > 0 + TextButton { + id: moreButton + anchors.right: parent.right + visible: parent.visible + text: (root.st.overflow || 0) + " more in notifications" + iconName: "expand_less" + background: Rectangle { + radius: 6 + color: moreButton.hovered ? theme.hover : theme.card + border.color: theme.border + } + onClicked: root.backend.setDrawerOpen(true) + } + } + + Repeater { + model: root.backend.toasts + delegate: Item { + id: toast + required property string entryId + required property string icon + required property color color + required property color tint + required property string title + required property string body + required property string meta + required property string severity + required property int count + required property bool needsAck + required property bool hasDetails + + width: stack.width + height: card.height + + // soft shadow + Rectangle { + x: 0; y: 3 + width: card.width; height: card.height + radius: 11 + color: Qt.rgba(0, 0, 0, theme.dark ? 0.35 : 0.07) + } + + Rectangle { + id: card + width: parent.width + height: content.implicitHeight + 22 + radius: 10 + color: theme.card + border.width: toast.needsAck ? 1.5 : 1 + border.color: toast.needsAck ? toast.color : theme.border + + Rectangle { + x: 0; y: card.radius + width: 4; height: parent.height - 2 * card.radius + radius: 2 + color: toast.color + } + + RowLayout { + id: content + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + anchors.leftMargin: 14 + anchors.rightMargin: 10 + anchors.topMargin: 12 + spacing: 12 + + Rectangle { + Layout.alignment: Qt.AlignTop + width: 32; height: 32; radius: 16 + color: toast.tint + Icon { anchors.centerIn: parent; name: toast.icon; color: toast.color; filled: true; size: 19 } + } + + ColumnLayout { + Layout.fillWidth: true + spacing: 3 + + RowLayout { + Layout.fillWidth: true + spacing: 6 + Text { + Layout.fillWidth: true + text: toast.title + textFormat: Text.PlainText + color: theme.fg + font.pixelSize: 13 + font.weight: Font.DemiBold + elide: Text.ElideRight + } + Rectangle { + visible: toast.count > 1 + implicitWidth: countLabel.implicitWidth + 12 + implicitHeight: 16 + radius: 8 + color: toast.tint + Text { + id: countLabel + anchors.centerIn: parent + text: "×" + toast.count + color: toast.color + font.pixelSize: 11 + font.weight: Font.Bold + } + } + IconButton { + visible: !toast.needsAck + implicitWidth: 24; implicitHeight: 24 + iconName: "close"; iconSize: 16 + tip: "Dismiss" + onClicked: root.backend.dismissToast(toast.entryId) + } + } + Text { + Layout.fillWidth: true + visible: toast.body !== "" + text: toast.body + textFormat: Text.PlainText + color: theme.fgMuted + font.pixelSize: 12 + wrapMode: Text.Wrap + maximumLineCount: 4 + elide: Text.ElideRight + } + Text { + Layout.fillWidth: true + text: toast.meta + color: theme.fgSubtle + font.pixelSize: 11 + elide: Text.ElideRight + } + RowLayout { + Layout.fillWidth: true + spacing: 6 + visible: copyButton.visible || detailsButton.visible || ackButton.visible + Item { Layout.fillWidth: true } + IconButton { + id: copyButton + visible: toast.severity === "error" || toast.severity === "critical" + implicitWidth: 28; implicitHeight: 28 + iconName: "content_copy"; iconSize: 16 + tip: "Copy report" + onClicked: root.backend.copyDetails(toast.entryId) + } + TextButton { + id: detailsButton + visible: toast.hasDetails || toast.needsAck + implicitHeight: 28 + text: "Details"; iconName: "open_in_new"; variant: "ghost" + onClicked: root.backend.openDetails(toast.entryId) + } + TextButton { + id: ackButton + visible: toast.needsAck + implicitHeight: 28 + text: "Acknowledge"; iconName: "done"; variant: "danger" + onClicked: root.backend.acknowledge(toast.entryId) + } + } + } + } + } + } + } + } +} diff --git a/bec_widgets/widgets/containers/main_window/main_window.py b/bec_widgets/widgets/containers/main_window/main_window.py index 6753c845..1682596c 100644 --- a/bec_widgets/widgets/containers/main_window/main_window.py +++ b/bec_widgets/widgets/containers/main_window/main_window.py @@ -64,9 +64,16 @@ class BECMainWindow(BECWidget, QMainWindow): self.setWindowTitle(window_title) self.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose, True) - # Notification Centre overlay - self.notification_centre = NotificationCentre(parent=self) # Notification layer - self.notification_broker = BECNotificationBroker(parent=self) + # Notification Centre overlay. BEC_NOTIFICATION_UI=qml|qwidget opts into the reworked + # toasts and history drawer, which also replace the modal error dialogs. + self._notification_ui = os.environ.get("BEC_NOTIFICATION_UI", "legacy").lower() + self.notifications = None + if self._notification_ui in ("qml", "qwidget"): + self.notification_centre = None + self.notification_broker = None + else: + self.notification_centre = NotificationCentre(parent=self) # Notification layer + self.notification_broker = BECNotificationBroker(parent=self) self._nc_margin = 16 self._position_notification_centre() self._widget_hierarchy_dialog: WidgetHierarchyDialog | None = None @@ -95,7 +102,10 @@ class BECMainWindow(BECWidget, QMainWindow): during __init__ when we first install ``self._full_content``). """ super().setCentralWidget(widget) - self.notification_centre.raise_() + if self.notification_centre is not None: + self.notification_centre.raise_() + if self.notifications is not None: + self.notifications.sync() self.statusBar().raise_() def resizeEvent(self, event): @@ -104,7 +114,7 @@ class BECMainWindow(BECWidget, QMainWindow): def _position_notification_centre(self): """Keep the notification panel at a fixed margin top-right.""" - if not hasattr(self, "notification_centre"): + if getattr(self, "notification_centre", None) is None: return margin = getattr(self, "_nc_margin", 16) # px nc = self.notification_centre @@ -158,6 +168,9 @@ class BECMainWindow(BECWidget, QMainWindow): """ Add the notification indicator to the status bar and hook the signals. """ + if self.notification_centre is None: + self._add_reworked_notifications() + return # Add the notification indicator to the status bar self.notification_indicator = NotificationIndicator(self) self.status_bar.addPermanentWidget(self.notification_indicator) @@ -168,6 +181,20 @@ class BECMainWindow(BECWidget, QMainWindow): self.notification_indicator.show_all_requested.connect(self.notification_centre.show_all) self.notification_indicator.hide_all_requested.connect(self.notification_centre.hide_all) + def _add_reworked_notifications(self): + """Attach the reworked toasts, history drawer and bell (QML or QWidget version).""" + if self._notification_ui == "qml": + from bec_widgets.widgets.containers.main_window.addons.notification_center.notification_qml import ( + NotificationHostQML as Host, + ) + else: + from bec_widgets.widgets.containers.main_window.addons.notification_center.notification_qwidget import ( + NotificationHostQWidget as Host, + ) + self.notifications = Host(self, status_bar=self.status_bar, dispatcher=self.bec_dispatcher) + # the guided tour points at the bell + self.notification_indicator = self.notifications.bell + ################################################################################ # Client message status bar widget helpers @@ -539,6 +566,9 @@ class BECMainWindow(BECWidget, QMainWindow): self._scan_progress_bar_full.deleteLater() self._scan_progress_hover.close() self._scan_progress_hover.deleteLater() + if self.notifications is not None: + self.notifications.cleanup() + self.notifications = None super().cleanup() diff --git a/tests/unit_tests/test_notification_ports.py b/tests/unit_tests/test_notification_ports.py new file mode 100644 index 00000000..2f8541b6 --- /dev/null +++ b/tests/unit_tests/test_notification_ports.py @@ -0,0 +1,551 @@ +# pylint: disable=missing-function-docstring,redefined-outer-name,protected-access +import threading +import time +import traceback +from unittest import mock + +import pytest +from qtpy.QtWidgets import QApplication, QLabel, QMainWindow, QMessageBox, QWidget + +from bec_widgets.utils.error_popups import ErrorPopupUtility, SafeSlot, WarningPopupUtility +from bec_widgets.widgets.containers.main_window.addons.notification_center import ( + notification_ux_common as common, +) +from bec_widgets.widgets.containers.main_window.addons.notification_center.notification_qml import ( + NotificationHostQML, +) +from bec_widgets.widgets.containers.main_window.addons.notification_center.notification_qwidget import ( + NotificationHostQWidget, +) +from bec_widgets.widgets.containers.main_window.addons.notification_center.notification_ux_common import ( + NotificationHub, + Severity, + ToastQueue, +) + +TRACE = """Traceback (most recent call last): + File "x.py", line 1, in move + raise ValueError("Target 12.5 is outside the limits.") +ValueError: Target 12.5 is outside the limits.""" + + +@pytest.fixture +def hub(): + yield NotificationHub.instance() + NotificationHub.reset_instance() + + +@pytest.fixture +def window(qtbot): + win = QMainWindow() + win.setCentralWidget(QLabel("content")) + win.statusBar().addWidget(QLabel("status")) + win.resize(1000, 700) + qtbot.addWidget(win) + win.show() + qtbot.waitExposed(win) + yield win + + +@pytest.fixture(params=["qwidget", "qml"]) +def host(request, window, hub, qtbot): + cls = NotificationHostQWidget if request.param == "qwidget" else NotificationHostQML + attached = cls(window) + yield attached + attached.cleanup() + + +def flush(qtbot, host): + """Run the coalesced view update.""" + host.sync_now() + qtbot.wait(10) + + +def toast_count(host) -> int: + if isinstance(host, NotificationHostQML): + return host.backend.toasts.rowCount() + return len(host.toasts.cards) + + +def row_count(host) -> int: + if isinstance(host, NotificationHostQML): + return host.backend.rows.rowCount() + return host.drawer.model.rowCount() + + +# ---------------------------------------------------------------------------- pure helpers + + +@pytest.mark.parametrize( + "value, expected", + [ + ("info", Severity.INFO), + ("minor", Severity.ERROR), + ("major", Severity.CRITICAL), + (0, Severity.WARNING), + (1, Severity.ERROR), + (2, Severity.CRITICAL), + (Severity.SUCCESS, Severity.SUCCESS), + ("nonsense", Severity.WARNING), + (None, Severity.WARNING), + ], +) +def test_to_severity(value, expected): + assert common.to_severity(value) == expected + + +def test_split_traceback(): + assert common.split_traceback(TRACE) == ("ValueError", "Target 12.5 is outside the limits.") + assert common.split_traceback("") == ("Error", "") + assert common.split_traceback("something odd happened") == ("Error", "something odd happened") + assert common.split_traceback("ophyd.utils.errors.WaitTimeoutError: late")[0] == ( + "WaitTimeoutError" + ) + + +def test_relative_time(): + now = time.time() + assert common.relative_time(now - 5, now) == "just now" + assert common.relative_time(now - 300, now) == "5 min ago" + + +# ---------------------------------------------------------------------------- hub + + +def test_hub_is_singleton(hub): + assert NotificationHub.instance() is hub + + +def test_notify_adds_entry_newest_first(hub): + first = hub.notify("A", "one") + second = hub.notify("B", "two", "warning", source="x") + assert [e.id for e in hub.entries] == [second, first] + assert hub.get(second).severity == Severity.WARNING + assert hub.unread_count() == 2 + + +def test_repeated_notifications_fold_into_one_entry(hub): + first = hub.notify("Readback failed", "timeout", "error", source="samx") + hub.notify("Other", "x") + again = hub.notify("Readback failed", "timeout", "error", source="samx") + assert again == first + assert len(hub.entries) == 2 + assert hub.entries[0].id == first # moved to the top + assert hub.get(first).count == 2 + # a different source is a different problem + hub.notify("Readback failed", "timeout", "error", source="samy") + assert len(hub.entries) == 3 + + +def test_same_entry_id_is_posted_once(hub): + hub.notify("A", entry_id="fixed") + hub.notify("A changed", entry_id="fixed") + assert len(hub.entries) == 1 + + +def test_history_is_trimmed_but_keeps_open_critical(hub, monkeypatch): + monkeypatch.setattr(NotificationHub, "MAX_ENTRIES", 5) + critical = hub.notify("Beam lost", severity="critical") + for i in range(10): + hub.notify(f"info {i}") + assert len(hub.entries) == 5 + assert hub.get(critical) is not None + + +def test_clear_keeps_unacknowledged_critical(hub): + critical = hub.notify("Beam lost", severity="critical") + hub.notify("hello") + hub.clear() + assert [e.id for e in hub.entries] == [critical] + hub.acknowledge(critical) + hub.clear() + assert hub.entries == [] + + +def test_acknowledge_and_top_severity(hub): + hub.notify("w", severity="warning") + critical = hub.notify("c", severity="critical") + assert hub.top_unread_severity() == Severity.CRITICAL + hub.mark_read() + # still open: a critical error needs an acknowledgement, reading it is not enough + assert hub.top_unread_severity() == Severity.CRITICAL + hub.acknowledge(critical) + assert hub.top_unread_severity() is None + + +def test_post_alarm_parses_error_info(hub): + entry_id = hub.post_alarm( + { + "severity": 2, + "alarm_type": "DeviceTimeoutError", + "info": { + "id": "abc", + "error_message": TRACE, + "compact_error_message": "Device eiger did not respond.", + "exception_type": "DeviceTimeoutError", + "device": "eiger", + }, + }, + {"scan_number": 12, "scan_id": "sid"}, + ) + entry = hub.get(entry_id) + assert entry_id == "abc" + assert entry.severity == Severity.CRITICAL + assert entry.title == "DeviceTimeoutError" + assert entry.body == "Device eiger did not respond." + assert entry.source == "Device eiger" + assert entry.scan_number == 12 + assert "sid" in entry.details and "Traceback" in entry.details + # the same alarm arriving again is ignored + assert hub.post_alarm({"severity": 2, "info": {"id": "abc", "error_message": "x"}}) is None + + +def test_post_alarm_without_info(hub): + entry_id = hub.post_alarm({"severity": 0, "alarm_type": "Warning", "msg": "low beam"}) + entry = hub.get(entry_id) + assert entry.severity == Severity.WARNING + assert entry.body == "low beam" + assert entry.details == "" + + +def test_report_exception(hub): + entry = hub.get(hub.report_exception("Method error", TRACE, source="Box.move")) + assert entry.title == "ValueError" + assert entry.body == "Target 12.5 is outside the limits." + assert entry.source == "Box.move" + assert entry.severity == Severity.ERROR + assert "Traceback" in entry.details + report = entry.copy_text() + assert "[Error] ValueError" in report and "Source: Box.move" in report and TRACE in report + + +def test_notify_from_worker_thread_is_queued(hub, qtbot): + thread = threading.Thread(target=lambda: hub.notify("from thread")) + thread.start() + thread.join() + qtbot.waitUntil(lambda: len(hub.entries) == 1, timeout=1000) + assert hub.entries[0].title == "from thread" + + +def test_bec_alarm_subscription(hub, bec_dispatcher): + with mock.patch.object(bec_dispatcher, "connect_slot") as connect: + hub.connect_bec(bec_dispatcher) + hub.connect_bec(bec_dispatcher) # only once + assert connect.call_count == 2 + with mock.patch.object(bec_dispatcher, "disconnect_slot") as disconnect: + hub.disconnect_bec() + assert disconnect.call_count == 2 + + +def test_scan_start_signal(hub, qtbot): + with qtbot.waitSignal(hub.scan_started, timeout=500): + hub._on_scan_status({"status": "open"}, {}) + with qtbot.assertNotEmitted(hub.scan_started): + hub._on_scan_status({"status": "closed"}, {}) + + +# ---------------------------------------------------------------------------- toast queue + + +@pytest.fixture +def queue(hub): + toast_queue = ToastQueue(hub) + yield toast_queue + toast_queue.cleanup() + toast_queue.deleteLater() + + +def test_queue_limits_visible_toasts(hub, queue): + ids = [hub.notify(f"n{i}", severity="error") for i in range(5)] + assert queue.visible_ids == ids[-3:] + assert queue.overflow == 2 + + +def test_queue_critical_evicts_oldest_transient(hub, queue): + critical = [hub.notify(f"c{i}", severity="critical") for i in range(2)] + info = hub.notify("info") + new_critical = hub.notify("c-new", severity="critical") + assert info not in queue.visible_ids + assert queue.visible_ids == critical + [new_critical] + # all slots held by critical errors: a transient toast only counts as overflow + hub.notify("more info") + assert queue.visible_ids == critical + [new_critical] + + +def test_queue_auto_dismiss_and_pause(hub, queue, qtbot, monkeypatch): + monkeypatch.setitem(common.SEVERITY_META[Severity.INFO], "lifetime", 80) + entry_id = hub.notify("short") + queue.set_paused(True) + qtbot.wait(150) + assert entry_id in queue.visible_ids + queue.set_paused(False) + qtbot.waitUntil(lambda: entry_id not in queue.visible_ids, timeout=2000) + # the entry stays in the history + assert hub.get(entry_id) is not None + + +def test_queue_critical_stays_until_acknowledged(hub, queue): + critical = hub.notify("c", severity="critical") + assert critical not in queue._timers + hub.acknowledge(critical) + assert critical not in queue.visible_ids + + +def test_queue_scan_start_keeps_critical(hub, queue): + hub.notify("info") + critical = hub.notify("c", severity="critical") + hub.scan_started.emit() + assert queue.visible_ids == [critical] + + +def test_queue_repeat_moves_toast_to_newest(hub, queue): + first = hub.notify("a", "x", "error") + hub.notify("b", "y", "error") + hub.notify("a", "x", "error") + assert queue.visible_ids[-1] == first + assert len(queue.visible_ids) == 2 + + +def test_queue_suspended_shows_nothing(hub, queue): + hub.notify("a") + queue.set_suspended(True) + assert queue.visible_ids == [] + hub.notify("b") + assert queue.visible_ids == [] + + +# ---------------------------------------------------------------------------- hosts (both views) + + +def test_host_shows_toasts_and_bell(host, hub, qtbot): + hub.notify("Hello", "world") + hub.notify("Broken", "it failed", "critical") + flush(qtbot, host) + assert toast_count(host) == 2 + assert host.toasts.isVisible() + state = host.view_state() + assert state["unread"] == 2 + assert state["openCriticals"] == 1 + assert "critical" in state["bellTip"] + assert host.toasts.geometry().right() <= host.window.width() + assert host.toasts.geometry().bottom() < host.window.statusBar().geometry().top() + + +def test_host_drawer_lists_filters_and_details(host, hub, qtbot): + hub.notify("Info one", "a") + error = hub.report_exception("Method error", TRACE, source="Box.move") + hub.notify("Warn", "low beam", "warning") + host.set_drawer_open(True) + flush(qtbot, host) + assert host.drawer.isVisible() + assert toast_count(host) == 0 # toasts give way to the drawer + assert hub.unread_count() == 0 # opening the drawer reads everything + assert row_count(host) == 3 + host.set_filter("errors") + flush(qtbot, host) + assert row_count(host) == 1 + host.set_filter("all") + host.set_search("low beam") + flush(qtbot, host) + assert row_count(host) == 1 + host.set_search("") + host.select(error) + flush(qtbot, host) + assert host.view_state()["selected"]["entryId"] == error + assert host.copy_details(error) + assert "ValueError" in QApplication.clipboard().text() + host.remove(error) + flush(qtbot, host) + assert hub.get(error) is None + assert host.view_state()["selected"] is None + host.set_drawer_open(False) + flush(qtbot, host) + assert not host.drawer.isVisible() + + +def test_host_critical_pinned_on_top(host, hub, qtbot): + critical = hub.notify("Beam lost", severity="critical") + hub.notify("newer info") + host.set_drawer_open(True) + flush(qtbot, host) + assert host.view_state()["rows"][0]["entryId"] == critical + host.acknowledge(critical) + flush(qtbot, host) + assert host.view_state()["rows"][0]["entryId"] != critical + + +def test_host_open_details_from_toast(host, hub, qtbot): + entry_id = hub.notify("Broken", "x", "error", details="trace") + flush(qtbot, host) + host.open_details(entry_id) + flush(qtbot, host) + assert host.drawer_open + assert host.view_state()["selected"]["details"] == "trace" + + +def test_host_dismiss_critical_toast_acknowledges(host, hub, qtbot): + critical = hub.notify("Beam lost", severity="critical") + flush(qtbot, host) + host.dismiss_toast(critical) + flush(qtbot, host) + assert hub.get(critical).acknowledged + assert toast_count(host) == 0 + + +def test_host_follows_theme(host, hub, qtbot): + from bec_widgets.utils.colors import apply_theme + + hub.notify("x", severity="warning") + flush(qtbot, host) + apply_theme("dark") + flush(qtbot, host) + apply_theme("light") + flush(qtbot, host) + assert toast_count(host) == 1 + + +def test_host_cleanup_unregisters(window, hub): + attached = NotificationHostQWidget(window) + assert hub.has_hosts + attached.cleanup() + attached.cleanup() # idempotent + assert not hub.has_hosts + + +def test_qml_views_load_without_errors(window, hub): + attached = NotificationHostQML(window) + try: + for view in (attached.toasts, attached.drawer, attached.bell): + assert view.errors() == [] + assert view.rootObject() is not None + finally: + attached.cleanup() + + +def test_qwidget_toast_buttons(window, hub, qtbot): + attached = NotificationHostQWidget(window) + try: + hub.notify("Broken", "x", "critical", details="trace") + flush(qtbot, attached) + card = attached.toasts.cards[0] + assert card.ack_btn.isVisibleTo(card) + assert not card.close_btn.isVisibleTo(card) + card.details_btn.click() + flush(qtbot, attached) + assert attached.drawer_open + attached.drawer.details.ack_btn.click() + flush(qtbot, attached) + assert hub.entries[0].acknowledged + finally: + attached.cleanup() + + +# ---------------------------------------------------------------------------- error routing + + +class _Failing(QWidget): + @SafeSlot(popup_error=True) + def fail(self): + raise ValueError("Target 12.5 is outside the limits.") + + +def test_safeslot_error_becomes_notification_when_host_attached(window, hub, qtbot): + attached = NotificationHostQWidget(window) + widget = _Failing() + qtbot.addWidget(widget) + try: + with ( + mock.patch.object(QMessageBox, "exec_") as exec_, + qtbot.assertNotEmitted(ErrorPopupUtility().error_occurred), + ): + widget.fail() + exec_.assert_not_called() + entry = hub.entries[0] + assert entry.title == "ValueError" + assert entry.source == "_Failing.fail" + assert "Traceback" in entry.details + finally: + attached.cleanup() + + +def test_safeslot_error_uses_dialog_without_host(hub, qtbot): + widget = _Failing() + qtbot.addWidget(widget) + with qtbot.waitSignal(ErrorPopupUtility().error_occurred, timeout=1000): + widget.fail() + assert hub.entries == [] + + +def test_warning_popup_becomes_notification(window, hub): + attached = NotificationHostQWidget(window) + try: + with mock.patch.object(QMessageBox, "exec_") as exec_: + WarningPopupUtility().show_warning("Export", "No data to export.", "details", window) + exec_.assert_not_called() + entry = hub.entries[0] + assert (entry.title, entry.body, entry.severity) == ( + "Export", + "No data to export.", + Severity.WARNING, + ) + assert entry.details == "details" + finally: + attached.cleanup() + + +def test_application_error_routed(window, hub): + attached = NotificationHostQWidget(window) + try: + util = ErrorPopupUtility() + util.enable_global_error_popups(True) + try: + raise RuntimeError("boom") + except RuntimeError as exc: + util.custom_exception_hook(type(exc), exc, exc.__traceback__) + assert hub.entries[0].title == "RuntimeError" + assert hub.entries[0].source == "Application Error" + finally: + util.enable_global_error_popups(False) + attached.cleanup() + + +# ---------------------------------------------------------------------------- main window opt-in + + +@pytest.mark.parametrize("variant", ["qwidget", "qml"]) +def test_main_window_opt_in(qtbot, monkeypatch, variant, mocked_client): + from bec_widgets.widgets.containers.main_window.main_window import BECMainWindow + + monkeypatch.setenv("BEC_NOTIFICATION_UI", variant) + win = BECMainWindow(client=mocked_client) + qtbot.addWidget(win) + try: + assert win.notification_centre is None + assert win.notifications is not None + assert win.notification_indicator is win.notifications.bell + win.notifications.hub.notify("hello") + win.notifications.sync_now() + assert win.notifications.view_state()["unread"] == 1 + finally: + win.close() + + +def test_main_window_default_is_legacy(qtbot, mocked_client, monkeypatch): + from bec_widgets.widgets.containers.main_window.main_window import BECMainWindow + + monkeypatch.delenv("BEC_NOTIFICATION_UI", raising=False) + win = BECMainWindow(client=mocked_client) + qtbot.addWidget(win) + try: + assert win.notification_centre is not None + assert win.notifications is None + finally: + win.close() + + +def test_traceback_helper_matches_python(): + try: + raise KeyError("missing") + except KeyError: + text = traceback.format_exc() + assert common.split_traceback(text) == ("KeyError", "'missing'")