feat(ring_progress_bar): add QML and QWidget ports with legend, animated values and empty state

This commit is contained in:
Claude committed 2026-10-09 20:25:57 +00:00
1 parent 703513b175
commit 69cb73baa3
20 files changed
+2276 -1

No files matched your search

+26
View File
@@ -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",
]
+233
View File
@@ -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/<name>`` 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.<token>`` 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/<name>?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
+83
View File
@@ -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)
@@ -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
}
}
}
@@ -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
}
@@ -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" : "")
}
}
@@ -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
}
@@ -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
}
}
@@ -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
}
}
}
@@ -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
}
}
@@ -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
}
}
@@ -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
}
+10
View File
@@ -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
+326
View File
@@ -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
@@ -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)
}
}
}
}
}
@@ -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:
@@ -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())
@@ -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())
@@ -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
@@ -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())