mirror of
https://github.com/bec-project/bec_widgets.git
synced 2026-10-28 16:11:27 +01:00
feat(ring_progress_bar): add QML and QWidget ports with legend, animated values and empty state
This commit is contained in:
20 files changed
+2276
-1
No files matched your search
@@ -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",
|
||||
]
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
@@ -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())
|
||||
Reference in new issue
Block a user