From 69cb73baa39fac09c1b924aa7243558a0e8bfea2 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 20:25:57 +0000 Subject: [PATCH] feat(ring_progress_bar): add QML and QWidget ports with legend, animated values and empty state --- 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 | 53 +++ .../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 | 85 ++++ .../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 | 326 +++++++++++++ .../qml/RingProgressView.qml | 239 ++++++++++ .../ring_progress_bar/ring_progress_bar.py | 6 +- .../ring_progress_bar_qml.py | 147 ++++++ .../ring_progress_bar_qwidget.py | 439 ++++++++++++++++++ .../ring_progress_bar/ring_ux_common.py | 226 +++++++++ .../test_ring_progress_bar_ports.py | 154 ++++++ 20 files changed, 2276 insertions(+), 1 deletion(-) 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 create mode 100644 bec_widgets/widgets/progress/ring_progress_bar/qml/RingProgressView.qml create mode 100644 bec_widgets/widgets/progress/ring_progress_bar/ring_progress_bar_qml.py create mode 100644 bec_widgets/widgets/progress/ring_progress_bar/ring_progress_bar_qwidget.py create mode 100644 bec_widgets/widgets/progress/ring_progress_bar/ring_ux_common.py create mode 100644 tests/unit_tests/test_ring_progress_bar_ports.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..eadf5d56 --- /dev/null +++ b/bec_widgets/utils/quick/qml/BecUi/Card.qml @@ -0,0 +1,53 @@ +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 + + 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 + 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..d2149dce --- /dev/null +++ b/bec_widgets/utils/quick/qml/BecUi/SelectField.qml @@ -0,0 +1,85 @@ +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 + + 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 + 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..11719f28 --- /dev/null +++ b/bec_widgets/utils/ux_kit.py @@ -0,0 +1,326 @@ +"""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, + 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 diff --git a/bec_widgets/widgets/progress/ring_progress_bar/qml/RingProgressView.qml b/bec_widgets/widgets/progress/ring_progress_bar/qml/RingProgressView.qml new file mode 100644 index 00000000..9f1fa99b --- /dev/null +++ b/bec_widgets/widgets/progress/ring_progress_bar/qml/RingProgressView.qml @@ -0,0 +1,239 @@ +import QtQuick +import QtQuick.Layouts +import QtQuick.Shapes +import BecUi + +// Concentric progress rings with a center readout and a legend. +// backend: RingViewBackend (rings model, centerText, centerCaption, highlighted, showLegend). +Rectangle { + id: root + required property QtObject backend + + readonly property int ringCount: backend.rings.count + readonly property bool wide: width >= height * 1.35 + readonly property bool legendVisible: backend.showLegend && ringCount > 0 + && (wide ? width > 260 : height > 230) + + color: theme.bg + implicitWidth: 240 + implicitHeight: 240 + + GridLayout { + anchors.fill: parent + anchors.margins: 8 + flow: root.wide ? GridLayout.LeftToRight : GridLayout.TopToBottom + columnSpacing: 16 + rowSpacing: 10 + + // ---- rings -------------------------------------------------------------------- + Item { + id: dial + Layout.fillWidth: true + Layout.fillHeight: true + Layout.minimumWidth: 60 + Layout.minimumHeight: 60 + readonly property real side: Math.min(width, height) + readonly property real baseRadius: (side - 2 * root.backend.maxLineWidth) / 2 + + Repeater { + id: ringRepeater + model: root.backend.rings + delegate: Shape { + id: ring + required property int index + required property color color + required property color trackColor + required property real fraction + required property int lineWidth + required property int gap + required property int startAngle + required property int direction + required property bool highlighted + + readonly property real radius: Math.max(1, dial.baseRadius - gap) + readonly property real width_: lineWidth + (highlighted ? 3 : 0) + property real shownFraction: fraction + Behavior on shownFraction { NumberAnimation { duration: 260; easing.type: Easing.OutCubic } } + + anchors.fill: parent + preferredRendererType: Shape.CurveRenderer + opacity: root.backend.highlighted >= 0 && !highlighted ? 0.4 : 1.0 + Behavior on opacity { NumberAnimation { duration: 140 } } + + ShapePath { + strokeColor: ring.trackColor + strokeWidth: ring.width_ + fillColor: "transparent" + capStyle: ShapePath.FlatCap + PathAngleArc { + centerX: dial.width / 2; centerY: dial.height / 2 + radiusX: ring.radius; radiusY: ring.radius + startAngle: 0; sweepAngle: 360 + } + } + ShapePath { + strokeColor: ring.color + strokeWidth: ring.width_ + fillColor: "transparent" + capStyle: ring.shownFraction > 0.001 ? ShapePath.RoundCap : ShapePath.FlatCap + PathAngleArc { + centerX: dial.width / 2; centerY: dial.height / 2 + radiusX: ring.radius; radiusY: ring.radius + startAngle: -ring.startAngle + sweepAngle: -ring.direction * 360 * Math.min(ring.shownFraction, 0.9999) + } + } + } + } + + // center readout + Column { + anchors.centerIn: parent + width: Math.max(10, 2 * (dial.baseRadius - root.backend.innerOffset) * 0.8) + spacing: 2 + visible: root.ringCount > 0 + Text { + width: parent.width + horizontalAlignment: Text.AlignHCenter + text: root.backend.centerText + color: theme.fg + font.pixelSize: Math.max(12, Math.min(34, dial.side * 0.13)) + font.weight: Font.DemiBold + fontSizeMode: Text.HorizontalFit + minimumPixelSize: 10 + elide: Text.ElideRight + } + Text { + width: parent.width + horizontalAlignment: Text.AlignHCenter + visible: text !== "" && dial.side > 120 + text: root.backend.centerCaption + color: theme.fgMuted + font.pixelSize: 11 + elide: Text.ElideRight + } + } + + // hover picking: find the ring under the pointer by its radius + HoverHandler { + id: hover + onPointChanged: pick(point.position) + onHoveredChanged: if (!hovered) root.backend.setHighlighted(-1) + function pick(pos) { + const dx = pos.x - dial.width / 2 + const dy = pos.y - dial.height / 2 + const distance = Math.sqrt(dx * dx + dy * dy) + let best = -1 + let bestDelta = 1e9 + for (let i = 0; i < root.ringCount; ++i) { + const item = ringRepeater.itemAt(i) + if (!item) continue + const delta = Math.abs(distance - item.radius) + if (delta <= item.lineWidth / 2 + 2 && delta < bestDelta) { + best = i + bestDelta = delta + } + } + root.backend.setHighlighted(best) + } + } + + // empty state + Column { + anchors.centerIn: parent + visible: root.ringCount === 0 + spacing: 10 + Rectangle { + anchors.horizontalCenter: parent.horizontalCenter + width: Math.min(96, dial.side * 0.5); height: width; radius: width / 2 + color: "transparent" + border.color: theme.border + border.width: 6 + } + Text { + anchors.horizontalCenter: parent.horizontalCenter + text: "No rings yet" + color: theme.fgMuted + font.pixelSize: 13 + } + TextButton { + anchors.horizontalCenter: parent.horizontalCenter + text: "Add ring" + iconName: "add" + variant: "neutral" + onClicked: root.backend.openSettings() + } + } + } + + // ---- legend ------------------------------------------------------------------- + ListView { + id: legend + visible: root.legendVisible + model: root.backend.rings + interactive: contentHeight > height + clip: true + spacing: 2 + Layout.fillWidth: !root.wide + Layout.preferredWidth: root.wide ? Math.min(260, root.width * 0.45) : -1 + Layout.preferredHeight: Math.min(contentHeight, root.wide ? root.height - 16 : root.height * 0.38) + Layout.alignment: Qt.AlignVCenter + + delegate: Rectangle { + id: row + required property int index + required property string label + required property var model + required property string valueText + required property string percentText + required property bool done + required property bool highlighted + + width: ListView.view.width + height: 30 + radius: 6 + color: highlighted ? theme.hover : "transparent" + + RowLayout { + anchors.fill: parent + anchors.leftMargin: 8 + anchors.rightMargin: 8 + spacing: 8 + Rectangle { width: 10; height: 10; radius: 5; color: row.model.color } + Text { + text: row.label + color: theme.fg + font.pixelSize: 12 + elide: Text.ElideRight + Layout.fillWidth: true + } + Text { + text: row.valueText + color: theme.fgMuted + font.pixelSize: 12 + font.family: "monospace" + } + Icon { + visible: row.done + name: "check_circle" + filled: true + size: 14 + color: theme.success + } + Text { + visible: !row.done + text: row.percentText + color: theme.fg + font.pixelSize: 12 + font.weight: Font.DemiBold + horizontalAlignment: Text.AlignRight + Layout.preferredWidth: 36 + } + } + HoverHandler { + onHoveredChanged: root.backend.setHighlighted(hovered ? row.index : -1) + } + } + } + } +} diff --git a/bec_widgets/widgets/progress/ring_progress_bar/ring_progress_bar.py b/bec_widgets/widgets/progress/ring_progress_bar/ring_progress_bar.py index 7d1cf018..bb9c5c4a 100644 --- a/bec_widgets/widgets/progress/ring_progress_bar/ring_progress_bar.py +++ b/bec_widgets/widgets/progress/ring_progress_bar/ring_progress_bar.py @@ -411,13 +411,17 @@ class RingProgressBar(BECWidget, QWidget): self.layout.addWidget(self.toolbar) # Placeholder for the actual ring progress bar widget - self.ring_progress_bar = RingProgressContainerWidget(self) + self.ring_progress_bar = self._create_ring_container() self.layout.addWidget(self.ring_progress_bar) self.settings_dialog = None self.toolbar.show_bundles(["rpb_settings"]) + def _create_ring_container(self) -> RingProgressContainerWidget: + """Create the widget that holds and renders the rings.""" + return RingProgressContainerWidget(self) + def apply_theme(self, theme: str): super().apply_theme(theme) if self.ring_progress_bar.color_map: diff --git a/bec_widgets/widgets/progress/ring_progress_bar/ring_progress_bar_qml.py b/bec_widgets/widgets/progress/ring_progress_bar/ring_progress_bar_qml.py new file mode 100644 index 00000000..0a6424be --- /dev/null +++ b/bec_widgets/widgets/progress/ring_progress_bar/ring_progress_bar_qml.py @@ -0,0 +1,147 @@ +"""Ring progress bar rendered with Qt Quick (QML). + +:class:`RingProgressBarQML` has the same public API as :class:`RingProgressBar`; only the ring +container is replaced by a QQuickWidget that draws the rings with ``QtQuick.Shapes`` and adds a +legend, animated values and an empty state. +""" + +from __future__ import annotations + +from pathlib import Path + +from qtpy.QtCore import Property, QObject, Signal, Slot + +from bec_widgets.utils.quick import DictListModel, create_quick_widget, release_quick_widget +from bec_widgets.utils.ux_kit import PortedPropertiesMixin +from bec_widgets.widgets.progress.ring_progress_bar.ring_progress_bar import RingProgressBar +from bec_widgets.widgets.progress.ring_progress_bar.ring_ux_common import RingStateContainer + +QML_FILE = Path(__file__).parent / "qml" / "RingProgressView.qml" + +RING_ROLES = [ + "index", + "label", + "mode", + "color", + "trackColor", + "fraction", + "lineWidth", + "gap", + "startAngle", + "direction", + "valueText", + "percentText", + "done", + "highlighted", +] + + +class RingViewBackend(QObject): + """Bridge between :class:`QuickRingContainer` and ``RingProgressView.qml``.""" + + changed = Signal() + + def __init__(self, container: "QuickRingContainer"): + super().__init__(container) + self._container = container + self._model = DictListModel(RING_ROLES, self) + self._center_text = "" + self._center_caption = "" + self._max_line_width = 10 + self._inner_offset = 0 + self._highlighted = -1 + self._show_legend = True + + def update_from(self, container: RingStateContainer) -> None: + """Copy the container state into the model and properties.""" + self._model.set_items(container.snapshots()) + self._center_text, self._center_caption = container.center_texts() + self._max_line_width = container.get_max_ring_size() + self._inner_offset = max( + (ring["gap"] + ring["lineWidth"] for ring in self._model.items), default=0 + ) + self._highlighted = container.highlighted + self.changed.emit() + + @Slot(int) + def setHighlighted(self, index: int) -> None: # pylint: disable=invalid-name + """Highlight a ring from QML hover.""" + self._container.set_highlighted(index) + + @Slot() + def openSettings(self) -> None: # pylint: disable=invalid-name + """Open the ring settings dialog.""" + self._container.request_settings() + + def _set_show_legend(self, show: bool) -> None: + self._show_legend = bool(show) + self.changed.emit() + + rings = Property(QObject, lambda self: self._model, constant=True) + centerText = Property(str, lambda self: self._center_text, notify=changed) + centerCaption = Property(str, lambda self: self._center_caption, notify=changed) + maxLineWidth = Property(int, lambda self: self._max_line_width, notify=changed) + innerOffset = Property(int, lambda self: self._inner_offset, notify=changed) + highlighted = Property(int, lambda self: self._highlighted, notify=changed) + showLegend = Property(bool, lambda self: self._show_legend, notify=changed) + + +class QuickRingContainer(RingStateContainer): + """Ring container that renders through a QML view.""" + + def _init_view(self) -> None: + self.backend = RingViewBackend(self) + self.view = create_quick_widget(self, QML_FILE, {"backend": self.backend}) + self.layout().addWidget(self.view) + + def _sync_view(self) -> None: + self.backend.update_from(self) + + def apply_theme(self, _theme: str | None = None) -> None: + """Match the view clear colour to the window background.""" + self.view.setClearColor(self.palette().window().color()) + + def closeEvent(self, event): # pylint: disable=invalid-name + release_quick_widget(self.view) + super().closeEvent(event) + + +class RingProgressBarQML(PortedPropertiesMixin, RingProgressBar): + """Ring progress bar rendered with QML; same API as :class:`RingProgressBar`.""" + + PLUGIN = False + RPC = False + rpc_widget_class = "RingProgressBar" + PORTED_FROM = RingProgressBar + + def _create_ring_container(self) -> QuickRingContainer: + return QuickRingContainer(self) + + def apply_theme(self, theme: str): + super().apply_theme(theme) + self.ring_progress_bar.apply_theme(theme) + + @property + def show_legend(self) -> bool: + """Whether the legend next to the rings is shown.""" + return self.ring_progress_bar.backend.showLegend + + @show_legend.setter + def show_legend(self, show: bool): + self.ring_progress_bar.backend._set_show_legend(show) # pylint: disable=protected-access + + +if __name__ == "__main__": # pragma: no cover + import sys + + from qtpy.QtWidgets import QApplication + + from bec_widgets.utils.colors import apply_theme + + app = QApplication(sys.argv) + apply_theme("dark") + widget = RingProgressBarQML() + for value in (72, 35, 90): + widget.add_ring().set_value(value) + widget.show() + sys.exit(app.exec()) diff --git a/bec_widgets/widgets/progress/ring_progress_bar/ring_progress_bar_qwidget.py b/bec_widgets/widgets/progress/ring_progress_bar/ring_progress_bar_qwidget.py new file mode 100644 index 00000000..d1e5b47c --- /dev/null +++ b/bec_widgets/widgets/progress/ring_progress_bar/ring_progress_bar_qwidget.py @@ -0,0 +1,439 @@ +"""Ring progress bar with the modernised UX, rendered with plain QWidgets and QPainter. + +This is the QWidget twin of :class:`RingProgressBarQML`: same API as :class:`RingProgressBar`, +same look (legend, animated values, center readout, empty state), so both rendering stacks can +be compared side by side. +""" + +from __future__ import annotations + +import math + +from bec_qthemes import material_icon +from qtpy.QtCore import QEasingCurve, QPointF, QRectF, Qt, QVariantAnimation +from qtpy.QtGui import QColor, QFont, QFontMetricsF, QPainter, QPen +from qtpy.QtWidgets import QBoxLayout, QHBoxLayout, QLabel, QSizePolicy, QVBoxLayout, QWidget + +from bec_widgets.utils.quick.host import ThemeTokens +from bec_widgets.utils.ux_kit import PortedPropertiesMixin, TextButton, refresh_kit_theme +from bec_widgets.widgets.progress.ring_progress_bar.ring_progress_bar import RingProgressBar +from bec_widgets.widgets.progress.ring_progress_bar.ring_ux_common import RingStateContainer + + +class RingCanvas(QWidget): + """Paints the rings, the center readout and the empty state.""" + + def __init__(self, container: "PaintedRingContainer"): + super().__init__(container) + self._container = container + self._snapshots: list[dict] = [] + self._center = ("", "") + self._max_line_width = 10 + self._start: list[float] = [] + self._target: list[float] = [] + self._progress = 1.0 + self._animation = QVariantAnimation(self) + self._animation.setDuration(260) + self._animation.setStartValue(0.0) + self._animation.setEndValue(1.0) + self._animation.setEasingCurve(QEasingCurve.Type.OutCubic) + self._animation.valueChanged.connect(self._on_animation_step) + self.setMouseTracking(True) + self.setMinimumSize(60, 60) + self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) + + self.add_button = TextButton("Add ring", "neutral", "add", self) + self.add_button.clicked.connect(container.request_settings) + self.add_button.hide() + + # ---- state --------------------------------------------------------------------------- + + def set_state(self, snapshots: list[dict], center: tuple[str, str], max_line_width: int): + """Take new ring snapshots; value changes are animated.""" + shown = self.shown_fractions() + targets = [snapshot["fraction"] for snapshot in snapshots] + self._snapshots = snapshots + self._center = center + self._max_line_width = max_line_width + if len(shown) != len(targets): + shown = targets[:] + if targets != self._target: + self._start = shown[: len(targets)] + self._target = targets + self._progress = 0.0 + self._animation.stop() + self._animation.start() + self.add_button.setVisible(not snapshots) + self._place_add_button() + self.update() + + def shown_fractions(self) -> list[float]: + """Fractions currently drawn, i.e. mid-animation values.""" + if not self._target: + return [] + return [s + (t - s) * self._progress for s, t in zip(self._start, self._target)] + + def _on_animation_step(self, value: float) -> None: + self._progress = float(value) + self.update() + + def stop_animation(self) -> None: + """Stop the value animation (used on cleanup).""" + self._animation.stop() + + # ---- geometry ------------------------------------------------------------------------ + + def _side(self) -> float: + return float(min(self.width(), self.height())) + + def _base_radius(self) -> float: + return (self._side() - 2 * self._max_line_width) / 2 + + def ring_at(self, pos: QPointF) -> int: + """Index of the ring under ``pos`` or -1.""" + dx = pos.x() - self.width() / 2 + dy = pos.y() - self.height() / 2 + distance = math.hypot(dx, dy) + best, best_delta = -1, float("inf") + for snapshot in self._snapshots: + radius = max(1.0, self._base_radius() - snapshot["gap"]) + delta = abs(distance - radius) + if delta <= snapshot["lineWidth"] / 2 + 2 and delta < best_delta: + best, best_delta = snapshot["index"], delta + return best + + def mouseMoveEvent(self, event): # pylint: disable=invalid-name + self._container.set_highlighted(self.ring_at(event.position())) + super().mouseMoveEvent(event) + + def leaveEvent(self, event): # pylint: disable=invalid-name + self._container.set_highlighted(-1) + super().leaveEvent(event) + + def resizeEvent(self, event): # pylint: disable=invalid-name + super().resizeEvent(event) + self._place_add_button() + + def _place_add_button(self) -> None: + hint = self.add_button.sizeHint() + _circle_y, _radius, caption_top = self._empty_layout() + self.add_button.setGeometry( + int((self.width() - hint.width()) / 2), int(caption_top + 28), hint.width(), 32 + ) + + # ---- painting ------------------------------------------------------------------------ + + def paintEvent(self, _event): # pylint: disable=invalid-name + tokens = ThemeTokens() + painter = QPainter(self) + painter.setRenderHint(QPainter.RenderHint.Antialiasing) + center = QPointF(self.width() / 2, self.height() / 2) + if not self._snapshots: + self._paint_empty(painter, tokens, center) + painter.end() + return + + highlighted = self._container.highlighted + fractions = self.shown_fractions() + for snapshot, fraction in zip(self._snapshots, fractions): + radius = max(1.0, self._base_radius() - snapshot["gap"]) + width = snapshot["lineWidth"] + (3 if snapshot["highlighted"] else 0) + rect = QRectF(center.x() - radius, center.y() - radius, 2 * radius, 2 * radius) + painter.setOpacity(0.4 if highlighted >= 0 and not snapshot["highlighted"] else 1.0) + + pen = QPen(QColor(snapshot["trackColor"]), width) + pen.setCapStyle(Qt.PenCapStyle.FlatCap) + painter.setPen(pen) + painter.drawArc(rect, 0, 360 * 16) + + if fraction > 0.001: + pen = QPen(QColor(snapshot["color"]), width) + pen.setCapStyle(Qt.PenCapStyle.RoundCap) + painter.setPen(pen) + span = int(min(fraction, 0.9999) * 360 * 16 * snapshot["direction"]) + painter.drawArc(rect, int(snapshot["startAngle"] * 16), span) + painter.setOpacity(1.0) + + innermost = self._snapshots[-1] + inner = max( + 10.0, 2 * (self._base_radius() - innermost["gap"] - innermost["lineWidth"] / 2) * 0.8 + ) + text, caption = self._center + show_caption = bool(caption) and self._side() > 120 + font = QFont(self.font()) + pixel_size = int(max(12, min(34, self._side() * 0.13))) + font.setWeight(QFont.Weight.DemiBold) + # shrink the text to fit the inner circle, like fontSizeMode: HorizontalFit in QML + while True: + font.setPixelSize(pixel_size) + if pixel_size <= 10 or QFontMetricsF(font).horizontalAdvance(text) <= inner - 4: + break + pixel_size -= 1 + painter.setFont(font) + painter.setPen(tokens.fg) + metrics = painter.fontMetrics() + text_rect = QRectF( + center.x() - inner / 2, center.y() - metrics.height() / 2, inner, metrics.height() + ) + if show_caption: + text_rect.translate(0, -8) + painter.drawText( + text_rect, + Qt.AlignmentFlag.AlignCenter, + metrics.elidedText(text, Qt.TextElideMode.ElideRight, int(text_rect.width())), + ) + if show_caption: + font.setPixelSize(11) + font.setWeight(QFont.Weight.Normal) + painter.setFont(font) + painter.setPen(tokens.fg_muted) + caption_rect = QRectF(text_rect.left(), text_rect.bottom() + 2, text_rect.width(), 16) + painter.drawText( + caption_rect, + Qt.AlignmentFlag.AlignCenter, + painter.fontMetrics().elidedText( + caption, Qt.TextElideMode.ElideRight, int(caption_rect.width()) + ), + ) + painter.end() + + def _empty_layout(self) -> tuple[float, float, float]: + """Circle center y, circle radius and caption top of the empty state.""" + radius = min(96, self._side() * 0.5) / 2 + total = 2 * radius + 10 + 18 + 10 + 32 + top = (self.height() - total) / 2 + return top + radius, radius, top + 2 * radius + 10 + + def _paint_empty(self, painter: QPainter, tokens: ThemeTokens, center: QPointF) -> None: + circle_y, radius, caption_top = self._empty_layout() + painter.setPen(QPen(tokens.border, 6)) + painter.drawEllipse(QPointF(center.x(), circle_y), radius - 3, radius - 3) + painter.setPen(tokens.fg_muted) + font = QFont(self.font()) + font.setPixelSize(13) + painter.setFont(font) + painter.drawText( + QRectF(0, caption_top, self.width(), 18), Qt.AlignmentFlag.AlignCenter, "No rings yet" + ) + + +class LegendRow(QWidget): + """One legend entry: colour dot, ring name, value and percentage or a done check.""" + + def __init__(self, container: "PaintedRingContainer", parent: QWidget): + super().__init__(parent) + self._container = container + self._index = -1 + self._color = QColor() + self._highlighted = False + self._hover_color = QColor() + self._full_label = "" + self.setFixedHeight(30) + self.setAttribute(Qt.WidgetAttribute.WA_Hover, True) + layout = QHBoxLayout(self) + layout.setContentsMargins(26, 0, 8, 0) + layout.setSpacing(8) + self.label = QLabel(self) + self.value = QLabel(self) + self.percent = QLabel(self) + self.percent.setFixedWidth(36) + self.percent.setAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter) + self.label.setSizePolicy(QSizePolicy.Policy.Ignored, QSizePolicy.Policy.Preferred) + self.value.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Preferred) + layout.addWidget(self.label, 1) + layout.addWidget(self.value) + layout.addWidget(self.percent) + + def set_snapshot(self, snapshot: dict, tokens: ThemeTokens) -> None: + """Show one ring snapshot.""" + self._index = snapshot["index"] + self._color = QColor(snapshot["color"]) + self._highlighted = snapshot["highlighted"] + self._full_label = snapshot["label"] + self._elide_label() + self.label.setToolTip(snapshot["label"]) + self.value.setText(snapshot["valueText"]) + if snapshot["done"]: + self.percent.setPixmap( + material_icon( + "check_circle", size=(28, 28), color=tokens.success, filled=True + ).scaled( + 14, + 14, + Qt.AspectRatioMode.KeepAspectRatio, + Qt.TransformationMode.SmoothTransformation, + ) + ) + else: + self.percent.setText(snapshot["percentText"]) + self.label.setStyleSheet(f"color: {tokens.fg.name()}; font-size: 12px;") + self.value.setStyleSheet( + f"color: {tokens.fg_muted.name()}; font-size: 12px; font-family: monospace;" + ) + done_color = tokens.success if snapshot["done"] else tokens.fg + self.percent.setStyleSheet( + f"color: {done_color.name()}; font-size: 12px; font-weight: 600;" + ) + self._hover_color = tokens.hover + self.update() + + def _elide_label(self) -> None: + self.label.setText( + self.label.fontMetrics().elidedText( + self._full_label, Qt.TextElideMode.ElideRight, max(20, self.label.width()) + ) + ) + + def resizeEvent(self, event): # pylint: disable=invalid-name + super().resizeEvent(event) + self._elide_label() + + def enterEvent(self, event): # pylint: disable=invalid-name + self._container.set_highlighted(self._index) + super().enterEvent(event) + + def leaveEvent(self, event): # pylint: disable=invalid-name + self._container.set_highlighted(-1) + super().leaveEvent(event) + + def paintEvent(self, _event): # pylint: disable=invalid-name + painter = QPainter(self) + painter.setRenderHint(QPainter.RenderHint.Antialiasing) + painter.setPen(Qt.PenStyle.NoPen) + if self._highlighted: + painter.setBrush(self._hover_color) + painter.drawRoundedRect(QRectF(self.rect()), 6, 6) + painter.setBrush(self._color) + painter.drawEllipse(QRectF(8, self.height() / 2 - 5, 10, 10)) + painter.end() + + +class RingLegend(QWidget): + """Vertical list of :class:`LegendRow` entries.""" + + def __init__(self, container: "PaintedRingContainer"): + super().__init__(container) + self._container = container + self._layout = QVBoxLayout(self) + self._layout.setContentsMargins(0, 0, 0, 0) + self._layout.setSpacing(2) + self._layout.addStretch(1) + self._layout.addStretch(1) + self.rows: list[LegendRow] = [] + + def set_snapshots(self, snapshots: list[dict]) -> None: + """Create or reuse rows for the snapshots.""" + tokens = ThemeTokens() + while len(self.rows) < len(snapshots): + row = LegendRow(self._container, self) + self._layout.insertWidget(len(self.rows) + 1, row) + self.rows.append(row) + while len(self.rows) > len(snapshots): + row = self.rows.pop() + self._layout.removeWidget(row) + row.deleteLater() + for row, snapshot in zip(self.rows, snapshots): + row.set_snapshot(snapshot, tokens) + + +class PaintedRingContainer(RingStateContainer): + """Ring container rendered with QPainter and QWidgets.""" + + def _init_view(self) -> None: + self._show_legend = True + self._box = QBoxLayout(QBoxLayout.Direction.LeftToRight) + self._box.setContentsMargins(8, 8, 8, 8) + self._box.setSpacing(16) + self.canvas = RingCanvas(self) + self.legend = RingLegend(self) + self._box.addWidget(self.canvas, 1) + self._box.addWidget(self.legend) + self.layout().addLayout(self._box) + + def _sync_view(self) -> None: + snapshots = self.snapshots() + self.canvas.set_state(snapshots, self.center_texts(), self.get_max_ring_size()) + self.legend.set_snapshots(snapshots) + self._update_arrangement() + + def set_show_legend(self, show: bool) -> None: + """Show or hide the legend.""" + self._show_legend = bool(show) + self._update_arrangement() + + def _update_arrangement(self) -> None: + wide = self.width() >= self.height() * 1.35 + self._box.setDirection( + QBoxLayout.Direction.LeftToRight if wide else QBoxLayout.Direction.TopToBottom + ) + if wide: + self.legend.setFixedWidth(int(min(260, self.width() * 0.45))) + self.legend.setMaximumHeight(16777215) + else: + self.legend.setMinimumWidth(0) + self.legend.setMaximumWidth(16777215) + self.legend.setFixedHeight(int(min(len(self.rings) * 32, self.height() * 0.38))) + visible = ( + self._show_legend + and bool(self.rings) + and (self.width() > 260 if wide else self.height() > 230) + ) + self.legend.setVisible(visible) + + def resizeEvent(self, event): # pylint: disable=invalid-name + QWidget.resizeEvent(self, event) + self._update_arrangement() + + def apply_theme(self, _theme: str | None = None) -> None: + """Re-read theme colours.""" + refresh_kit_theme(self) + self._sync_view() + + def closeEvent(self, event): # pylint: disable=invalid-name + self.canvas.stop_animation() + super().closeEvent(event) + + +class RingProgressBarQWidget(PortedPropertiesMixin, RingProgressBar): + """Ring progress bar with the modernised UX in plain QWidgets; same API as RingProgressBar.""" + + PLUGIN = False + RPC = False + rpc_widget_class = "RingProgressBar" + PORTED_FROM = RingProgressBar + + def _create_ring_container(self) -> PaintedRingContainer: + return PaintedRingContainer(self) + + def apply_theme(self, theme: str): + super().apply_theme(theme) + self.ring_progress_bar.apply_theme(theme) + + @property + def show_legend(self) -> bool: + """Whether the legend next to the rings is shown.""" + return self.ring_progress_bar._show_legend # pylint: disable=protected-access + + @show_legend.setter + def show_legend(self, show: bool): + self.ring_progress_bar.set_show_legend(show) + + def cleanup(self): + self.ring_progress_bar.canvas.stop_animation() + super().cleanup() + + +if __name__ == "__main__": # pragma: no cover + import sys + + from qtpy.QtWidgets import QApplication + + from bec_widgets.utils.colors import apply_theme + + app = QApplication(sys.argv) + apply_theme("dark") + widget = RingProgressBarQWidget() + for value in (72, 35, 90): + widget.add_ring().set_value(value) + widget.show() + sys.exit(app.exec()) diff --git a/bec_widgets/widgets/progress/ring_progress_bar/ring_ux_common.py b/bec_widgets/widgets/progress/ring_progress_bar/ring_ux_common.py new file mode 100644 index 00000000..c67bc986 --- /dev/null +++ b/bec_widgets/widgets/progress/ring_progress_bar/ring_ux_common.py @@ -0,0 +1,226 @@ +"""State shared by the QML and QWidget ports of the ring progress bar. + +Both ports keep :class:`Ring` objects as the source of truth, so every ring keeps its full API +(``set_value``, ``set_update``, ...), its settings dialog support and its BEC subscriptions. The +rings are not painted themselves; instead every change is forwarded to the container, which +turns the rings into plain snapshots that the QML view or the painted QWidget render. +""" + +from __future__ import annotations + +from qtpy.QtCore import Signal +from qtpy.QtWidgets import QLabel, QWidget + +from bec_widgets.utils.ux_kit import PortedPropertiesMixin +from bec_widgets.widgets.progress.ring_progress_bar.ring import Ring +from bec_widgets.widgets.progress.ring_progress_bar.ring_progress_bar import ( + RingProgressContainerWidget, +) + + +class TrackedRing(PortedPropertiesMixin, Ring): + """A :class:`Ring` that reports its changes to the container instead of painting itself.""" + + PORTED_FROM = Ring + # Same client-side API as Ring; the ports are not published separately over RPC. + RPC = False + rpc_widget_class = "Ring" + + def _request_update(self, *, refresh_tooltip: bool = True): + container = self.progress_container + if container is not None and hasattr(container, "ring_changed"): + container.ring_changed(self) + + def paintEvent(self, event): # pylint: disable=invalid-name + """Rings are rendered by the container.""" + + +class CenterText(QLabel): + """Hidden label holding the center text; reports changes so the view can follow.""" + + text_changed = Signal(str) + + def setText(self, text: str): # pylint: disable=invalid-name + """Set the text and notify the view.""" + changed = text != self.text() + super().setText(text) + if changed: + self.text_changed.emit(text) + + +def format_number(value: float, precision: int) -> str: + """Format with the ring precision, without trailing zeros (``12.500`` -> ``12.5``).""" + text = f"{value:.{max(0, int(precision))}f}" + if "." in text: + text = text.rstrip("0").rstrip(".") + return text + + +def ring_label(ring: Ring, index: int) -> str: + """Human readable name of what a ring shows.""" + config = ring.config + if config.mode == "scan": + return "Scan progress" + if config.mode == "device" and config.device: + return f"{config.device}:{config.signal}" if config.signal else config.device + return f"Ring {index + 1}" + + +class RingStateContainer(RingProgressContainerWidget): + """Container keeping the ring list and exposing it as snapshots for a renderer. + + Subclasses build their renderer in :meth:`_init_view` and refresh it in :meth:`_sync_view`. + """ + + def __init__(self, parent: QWidget | None = None, **kwargs): + self._owner = parent + self._highlighted = -1 + self._syncing_enabled = False + super().__init__(parent=parent, **kwargs) + self.setMouseTracking(False) + self._syncing_enabled = True + self._sync_view() + + # ---- RingProgressContainerWidget overrides ------------------------------------------ + + def initialize_center_label(self): + """Keep the center text in a hidden label and build the renderer instead.""" + layout = self.layout() + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(0) + self.center_label = CenterText("", parent=self) + self.center_label.hide() + self.center_label.text_changed.connect(lambda _text: self._refresh()) + self._init_view() + + def add_ring(self, config: dict | None = None) -> Ring: + """Add a ring; see :meth:`RingProgressContainerWidget.add_ring`.""" + ring = TrackedRing(parent=self) + ring.hide() + ring.gap = self.gap * len(self.rings) + ring.set_value(0) + self.rings.append(ring) + if config: + ring.link_colors = config.pop("link_colors", True) + ring.load_settings(config) + if self.color_map: + self.set_colors_from_map(self.color_map) + self.update() + return ring + + def remove_ring(self, index: int | None = None): + """Remove a ring; see :meth:`RingProgressContainerWidget.remove_ring`.""" + self._highlighted = -1 + super().remove_ring(index) + + def clear_all(self): + """Remove all rings.""" + self._highlighted = -1 + super().clear_all() + + def update(self, *args): + """Refresh the renderer, then schedule a repaint.""" + self._refresh() + super().update(*args) + + # Hover tooltips are replaced by the legend, which shows the same details permanently. + def mouseMoveEvent(self, event): # pylint: disable=invalid-name + QWidget.mouseMoveEvent(self, event) + + def leaveEvent(self, event): # pylint: disable=invalid-name + QWidget.leaveEvent(self, event) + + def refresh_hover_tooltip(self, ring: Ring, event=None): + """Not used by the ports: the legend shows the ring details.""" + + def is_ring_hovered(self, ring: Ring) -> bool: + return 0 <= self._highlighted < len(self.rings) and self.rings[self._highlighted] is ring + + # ---- shared state -------------------------------------------------------------------- + + def ring_changed(self, _ring: Ring) -> None: + """Called by every :class:`TrackedRing` after a change.""" + self._refresh() + + def _refresh(self) -> None: + if self._syncing_enabled: + self._sync_view() + + @property + def highlighted(self) -> int: + """Index of the ring highlighted by hover, -1 for none.""" + return self._highlighted + + def set_highlighted(self, index: int) -> None: + """Highlight one ring (and its legend row); -1 clears the highlight.""" + index = index if 0 <= index < len(self.rings) else -1 + if index == self._highlighted: + return + self._highlighted = index + self._refresh() + + def ring_gap(self, index: int) -> int: + """Distance of ring ``index`` from the outermost ring, in pixels.""" + return self.gap * index + + def snapshots(self) -> list[dict]: + """Plain description of every ring, in drawing order (outermost first).""" + snapshots = [] + for index, ring in enumerate(self.rings): + config = ring.config + span = config.max_value - config.min_value + fraction = (config.value - config.min_value) / span if span else 0.0 + fraction = max(0.0, min(1.0, fraction)) + precision = int(config.precision) + snapshots.append( + { + "index": index, + "label": ring_label(ring, index), + "mode": config.mode, + "color": ring.color.name(), + "trackColor": ring.background_color.name(), + "fraction": fraction, + "lineWidth": int(config.line_width), + "gap": self.ring_gap(index), + "startAngle": int(config.start_position), + "direction": int(config.direction), + "valueText": ( + f"{format_number(config.value, precision)}" + f" / {format_number(config.max_value, precision)}" + ), + "percentText": f"{fraction * 100:.0f}%", + "done": fraction >= 1.0, + "highlighted": index == self._highlighted, + } + ) + return snapshots + + def center_texts(self) -> tuple[str, str]: + """Big and small center text. + + The center label set through the API wins; without one, the first ring's percentage is + shown so the widget is informative without configuration. + """ + text = self.center_label.text() + if text: + return text, "" + if not self.rings: + return "", "" + first = self.snapshots()[0] + return first["percentText"], first["label"] + + def request_settings(self) -> None: + """Open the settings dialog of the owning RingProgressBar (used by the empty state).""" + opener = getattr(self._owner, "_open_settings_dialog", None) + if callable(opener): + opener() + + # ---- renderer hooks ------------------------------------------------------------------ + + def _init_view(self) -> None: + """Create the renderer and add it to ``self.layout()``.""" + raise NotImplementedError + + def _sync_view(self) -> None: + """Push the current snapshots to the renderer.""" + raise NotImplementedError diff --git a/tests/unit_tests/test_ring_progress_bar_ports.py b/tests/unit_tests/test_ring_progress_bar_ports.py new file mode 100644 index 00000000..3a58b805 --- /dev/null +++ b/tests/unit_tests/test_ring_progress_bar_ports.py @@ -0,0 +1,154 @@ +"""Tests for the QML and QWidget ports of the ring progress bar.""" + +# pylint: disable=redefined-outer-name, protected-access, missing-function-docstring +import json + +import pytest +from qtpy.QtCore import QPointF +from qtpy.QtQuickWidgets import QQuickWidget + +from bec_widgets.widgets.progress.ring_progress_bar.ring_progress_bar_qml import RingProgressBarQML +from bec_widgets.widgets.progress.ring_progress_bar.ring_progress_bar_qwidget import ( + RingProgressBarQWidget, +) + +from .conftest import create_widget + + +@pytest.fixture(params=[RingProgressBarQML, RingProgressBarQWidget]) +def ring_bar(request, qtbot, mocked_client): + widget = create_widget(qtbot, request.param, client=mocked_client) + widget.resize(420, 300) + yield widget + + +def test_qml_view_loads(qtbot, mocked_client): + widget = create_widget(qtbot, RingProgressBarQML, client=mocked_client) + view = widget.ring_progress_bar.view + assert view.status() == QQuickWidget.Status.Ready + assert view.errors() == [] + + +def test_add_ring_and_value_snapshot(ring_bar): + ring = ring_bar.add_ring() + ring.set_value(25) + snapshots = ring_bar.ring_progress_bar.snapshots() + assert len(snapshots) == 1 + assert snapshots[0]["fraction"] == pytest.approx(0.25) + assert snapshots[0]["percentText"] == "25%" + assert snapshots[0]["valueText"] == "25 / 100" + assert snapshots[0]["label"] == "Ring 1" + + +def test_center_text_defaults_to_first_ring_percentage(ring_bar): + container = ring_bar.ring_progress_bar + assert container.center_texts() == ("", "") + ring_bar.add_ring().set_value(40) + assert container.center_texts() == ("40%", "Ring 1") + ring_bar.set_center_label("Scan 12") + assert ring_bar.center_label == "Scan 12" + assert container.center_texts() == ("Scan 12", "") + + +def test_set_progress_state_updates_rings_and_label(ring_bar): + for _ in range(3): + ring_bar.add_ring() + ring_bar.set_progress_state(values=[10, None, 80], center_label="busy") + assert [ring.config.value for ring in ring_bar.rings] == [10, 0, 80] + ring_bar.set_progress_state(values={1: 55}) + assert ring_bar.rings[1].config.value == 55 + assert ring_bar.center_label == "busy" + + +def test_remove_ring_and_highlight(ring_bar): + for _ in range(3): + ring_bar.add_ring() + container = ring_bar.ring_progress_bar + container.set_highlighted(2) + assert container.snapshots()[2]["highlighted"] + assert container.is_ring_hovered(ring_bar.rings[2]) + ring_bar.remove_ring(2) + assert len(container.snapshots()) == 2 + assert container.highlighted == -1 + container.set_highlighted(7) + assert container.highlighted == -1 + + +def test_gap_is_applied_live(ring_bar): + ring_bar.add_ring() + ring_bar.add_ring() + ring_bar.set_gap(30) + assert [snapshot["gap"] for snapshot in ring_bar.ring_progress_bar.snapshots()] == [0, 30] + + +def test_ring_json_roundtrip(ring_bar): + ring_bar.add_ring().set_value(12) + ring_bar.add_ring().set_value(34) + data = ring_bar.ring_json + ring_bar.ring_json = json.dumps([]) + assert ring_bar.rings == [] + ring_bar.ring_json = data + assert [ring.config.value for ring in ring_bar.rings] == [12, 34] + + +def test_done_ring_and_scan_label(ring_bar): + ring = ring_bar.add_ring() + ring.set_update("scan") + ring.set_value(100) + snapshot = ring_bar.ring_progress_bar.snapshots()[0] + assert snapshot["done"] + assert snapshot["label"] == "Scan progress" + + +def test_device_label(ring_bar): + ring = ring_bar.add_ring() + ring.config.mode = "device" + ring.config.device = "samx" + ring.config.signal = "samx_readback" + ring.set_value(1) + assert ring_bar.ring_progress_bar.snapshots()[0]["label"] == "samx:samx_readback" + + +def test_show_legend_toggle(ring_bar): + ring_bar.add_ring() + assert ring_bar.show_legend + ring_bar.show_legend = False + assert not ring_bar.show_legend + + +def test_qml_backend_model_follows_rings(qtbot, mocked_client): + widget = create_widget(qtbot, RingProgressBarQML, client=mocked_client) + backend = widget.ring_progress_bar.backend + widget.add_ring().set_value(50) + widget.add_ring() + assert backend.rings.count == 2 + assert backend.rings.items[0]["fraction"] == pytest.approx(0.5) + assert backend.centerText == "50%" + backend.setHighlighted(1) + assert backend.highlighted == 1 + + +def test_qwidget_legend_and_hover_picking(qtbot, mocked_client): + widget = create_widget(qtbot, RingProgressBarQWidget, client=mocked_client) + widget.resize(420, 300) + widget.show() + qtbot.waitExposed(widget) + widget.add_ring().set_value(50) + widget.add_ring() + container = widget.ring_progress_bar + assert len(container.legend.rows) == 2 + canvas = container.canvas + snapshot = container.snapshots()[0] + radius = canvas._base_radius() - snapshot["gap"] + pos = QPointF(canvas.width() / 2 + radius, canvas.height() / 2) + assert canvas.ring_at(pos) == 0 + assert canvas.ring_at(QPointF(canvas.width() / 2, canvas.height() / 2)) == -1 + widget.remove_ring() + assert len(container.legend.rows) == 1 + + +def test_ports_export_inherited_properties(ring_bar): + names = set(ring_bar._get_bec_meta_objects()) + assert {"gap", "color_map", "center_label", "ring_json"} <= names + ring = ring_bar.add_ring() + assert {"value", "line_width", "link_colors"} <= set(ring._get_bec_meta_objects())