chore(quick): bring in the shared QML host and ux kit from claude/notifications-errors-01xgs8

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A4kzsfXBk1iaZU32We5XLP
This commit is contained in:
Claude committed 2026-10-09 22:09:28 +00:00
1 parent 703513b175
commit 9e68268ebb
14 files changed
+1166

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,56 @@
import QtQuick
import QtQuick.Layouts
// Rounded surface with an optional title row. Children go into the content column.
Rectangle {
id: root
property string title: ""
property string subtitle: ""
default property alias content: column.data
property alias headerExtras: extras.data
property int padding: 12
property alias spacing: column.spacing
// Let the content column take the remaining height, e.g. for a list that fills the card.
property bool fillContent: false
color: theme.card
radius: 10
border.color: theme.border
border.width: 1
implicitHeight: outer.implicitHeight + 2 * padding
implicitWidth: outer.implicitWidth + 2 * padding
ColumnLayout {
id: outer
anchors.fill: parent
anchors.margins: root.padding
spacing: 10
RowLayout {
visible: root.title !== "" || extras.children.length > 0
Layout.fillWidth: true
spacing: 8
Text {
text: root.title
color: theme.fg
font.pixelSize: 13
font.weight: Font.DemiBold
}
Text {
text: root.subtitle
visible: text !== ""
color: theme.fgSubtle
font.pixelSize: 12
Layout.fillWidth: true
elide: Text.ElideRight
}
Item { Layout.fillWidth: root.subtitle === "" }
Row { id: extras; spacing: 4 }
}
ColumnLayout {
id: column
Layout.fillWidth: true
Layout.fillHeight: root.fillContent
spacing: 8
}
}
}
@@ -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,88 @@
import QtQuick
import QtQuick.Controls.Basic
// Combo box styled like the input fields. With editable: true it filters while typing.
ComboBox {
id: root
property bool invalid: false
property string placeholder: ""
implicitHeight: 32
implicitWidth: 140
font.pixelSize: 13
hoverEnabled: true
opacity: enabled ? 1.0 : 0.5
background: FieldFrame {
focused: root.activeFocus || root.popup.visible
invalid: root.invalid
hovered: root.hovered
}
contentItem: TextField {
leftPadding: 9
rightPadding: 4
text: root.editable ? root.editText : root.displayText
readOnly: !root.editable
placeholderText: root.placeholder
placeholderTextColor: theme.fgSubtle
enabled: root.enabled
color: theme.fg
selectionColor: theme.primary
selectedTextColor: theme.onPrimary
font: root.font
verticalAlignment: Text.AlignVCenter
background: null
selectByMouse: true
// forward clicks to the combo when it is not editable
TapHandler { enabled: !root.editable; onTapped: root.popup.visible ? root.popup.close() : root.popup.open() }
}
indicator: Icon {
x: root.width - width - 6
y: (root.height - height) / 2
name: "expand_more"
size: 18
color: theme.fgMuted
}
delegate: ItemDelegate {
id: option
required property int index
required property var modelData
width: ListView.view ? ListView.view.width : root.width
height: 30
highlighted: root.highlightedIndex === index
contentItem: Text {
text: option.modelData === "" ? "None" : option.modelData
color: option.modelData === "" ? theme.fgSubtle : theme.fg
font.pixelSize: 13
elide: Text.ElideRight
verticalAlignment: Text.AlignVCenter
}
background: Rectangle {
color: option.highlighted ? theme.hover : "transparent"
Rectangle {
width: 3; height: parent.height - 10; radius: 1.5
anchors.verticalCenter: parent.verticalCenter
color: theme.primary
visible: root.currentIndex === option.index
}
}
}
popup: Popup {
y: root.height + 2
width: root.width
implicitHeight: Math.min(contentItem.implicitHeight + 8, 280)
padding: 4
contentItem: ListView {
clip: true
implicitHeight: contentHeight
model: root.popup.visible ? root.delegateModel : null
currentIndex: root.highlightedIndex
ScrollIndicator.vertical: ScrollIndicator {}
}
background: Rectangle {
color: theme.card
border.color: theme.border
radius: 8
}
}
}
@@ -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
+420
View File
@@ -0,0 +1,420 @@
"""Small themed QWidget controls used by the QWidget twins of the QML widget ports.
They mirror the ``BecUi`` QML controls in ``bec_widgets/utils/quick/qml`` so that the QML and
QWidget versions of a widget look and behave the same and can be compared side by side. Colours
come from the same :class:`~bec_widgets.utils.quick.host.ThemeTokens`.
"""
from __future__ import annotations
from bec_qthemes import material_icon
from qtpy.QtCore import Property, QEasingCurve, QPropertyAnimation, QRectF, QSize, Qt
from qtpy.QtGui import QColor, QFont, QPainter
from qtpy.QtWidgets import (
QFrame,
QHBoxLayout,
QLabel,
QLineEdit,
QPushButton,
QSizePolicy,
QToolButton,
QVBoxLayout,
QWidget,
)
from bec_widgets.utils.quick.host import ThemeTokens
def rgba(color: QColor, alpha: float) -> str:
"""Return a QSS ``rgba()`` string for ``color`` with alpha in 0..1."""
return f"rgba({color.red()}, {color.green()}, {color.blue()}, {int(alpha * 255)})"
def refresh_kit_theme(root: QWidget) -> ThemeTokens:
"""Re-theme every kit control below ``root`` and return the current tokens."""
tokens = ThemeTokens()
for widget in [root, *root.findChildren(QWidget)]:
refresh = getattr(widget, "refresh_theme", None)
if callable(refresh) and widget is not root:
refresh(tokens)
return tokens
def field_qss(tokens: ThemeTokens) -> str:
"""Stylesheet for inputs inside kit cards; ``invalid=true`` marks a field with an error."""
return f"""
QLineEdit, QComboBox, QSpinBox, QDoubleSpinBox {{
background: {tokens.field.name()};
color: {tokens.fg.name()};
border: 1px solid {tokens.border.name()};
border-radius: 6px;
padding: 0px 8px;
min-height: 30px;
font-size: 13px;
selection-background-color: {tokens.primary.name()};
}}
QLineEdit:hover, QComboBox:hover, QSpinBox:hover, QDoubleSpinBox:hover {{
border-color: {tokens.fg_subtle.name()};
}}
QLineEdit:focus, QComboBox:focus, QSpinBox:focus, QDoubleSpinBox:focus {{
border: 2px solid {tokens.primary.name()};
padding: 0px 7px;
}}
QLineEdit[invalid="true"], QComboBox[invalid="true"] {{
border: 2px solid {tokens.danger.name()};
padding: 0px 7px;
}}
QLineEdit:disabled, QComboBox:disabled {{ color: {tokens.fg_subtle.name()}; }}
QComboBox::drop-down {{ border: none; width: 22px; }}
"""
def set_invalid(widget: QWidget, invalid: bool) -> None:
"""Toggle the ``invalid`` style of a field styled with :func:`field_qss`."""
if bool(widget.property("invalid")) == invalid:
return
widget.setProperty("invalid", invalid)
widget.style().unpolish(widget)
widget.style().polish(widget)
class IconButton(QToolButton):
"""Flat square icon button with hover feedback, matching ``BecUi.IconButton``."""
def __init__(self, icon_name: str, tip: str = "", parent: QWidget | None = None, size=18):
super().__init__(parent)
self._icon_name = icon_name
self._icon_size = size
self.setToolTip(tip)
self.setAccessibleName(tip)
self.setAutoRaise(True)
self.setFixedSize(30, 30)
self.setIconSize(QSize(size, size))
self.setCursor(Qt.CursorShape.PointingHandCursor)
self.refresh_theme(ThemeTokens())
def set_icon_name(self, icon_name: str) -> None:
"""Change the Material icon."""
self._icon_name = icon_name
self.refresh_theme(ThemeTokens())
def refresh_theme(self, tokens: ThemeTokens) -> None:
"""Apply the theme tokens."""
color = tokens.primary if self.isChecked() else tokens.fg_muted
self.setIcon(
material_icon(self._icon_name, size=(48, 48), color=color, convert_to_pixmap=False)
)
self.setStyleSheet(
f"QToolButton {{ border: none; border-radius: 6px; background: transparent; }}"
f"QToolButton:hover, QToolButton:checked {{ background: {tokens.hover.name()}; }}"
f"QToolButton:pressed {{ background: {tokens.pressed.name()}; }}"
)
class TextButton(QPushButton):
"""Labelled button; variant is ``primary``, ``danger``, ``success``, ``neutral`` or ``ghost``."""
def __init__(
self,
text: str,
variant: str = "neutral",
icon_name: str = "",
parent: QWidget | None = None,
):
super().__init__(text, parent)
self._variant = variant
self._icon_name = icon_name
self.setMinimumHeight(32)
self.setCursor(Qt.CursorShape.PointingHandCursor)
self.refresh_theme(ThemeTokens())
def set_variant(self, variant: str) -> None:
"""Switch the colour variant."""
self._variant = variant
self.refresh_theme(ThemeTokens())
def refresh_theme(self, tokens: ThemeTokens) -> None:
"""Apply the theme tokens."""
base = {"primary": tokens.primary, "danger": tokens.danger, "success": tokens.success}.get(
self._variant
)
if base is not None:
label = QColor("white")
qss = (
f"QPushButton {{ background: {base.name()}; color: white; border: none;"
f" border-radius: 6px; padding: 0 12px; font-size: 13px; font-weight: 600; }}"
f"QPushButton:hover {{ background: {base.lighter(110).name()}; }}"
f"QPushButton:pressed {{ background: {base.darker(125).name()}; }}"
f"QPushButton:disabled {{ background: {rgba(base, 0.45)};"
f" color: {rgba(label, 0.7)}; }}"
)
else:
label = tokens.fg
border = tokens.border.name() if self._variant == "neutral" else "transparent"
qss = (
f"QPushButton {{ background: transparent; color: {tokens.fg.name()};"
f" border: 1px solid {border}; border-radius: 6px; padding: 0 12px;"
f" font-size: 13px; font-weight: 500; }}"
f"QPushButton:hover {{ background: {tokens.hover.name()}; }}"
f"QPushButton:pressed {{ background: {tokens.pressed.name()}; }}"
f"QPushButton:disabled {{ color: {tokens.fg_subtle.name()}; }}"
)
self.setStyleSheet(qss)
if self._icon_name:
self.setIcon(
material_icon(self._icon_name, size=(32, 32), color=label, convert_to_pixmap=False)
)
self.setIconSize(QSize(16, 16))
class StatusPill(QWidget):
"""Rounded status chip with a coloured dot, matching ``BecUi.StatusPill``."""
def __init__(self, parent: QWidget | None = None):
super().__init__(parent)
self._text = ""
self._tone = QColor("#808080")
self._dot_opacity = 1.0
self._pulse = QPropertyAnimation(self, b"dot_opacity", self)
self._pulse.setDuration(1100)
self._pulse.setKeyValueAt(0.0, 1.0)
self._pulse.setKeyValueAt(0.5, 0.25)
self._pulse.setKeyValueAt(1.0, 1.0)
self._pulse.setEasingCurve(QEasingCurve.Type.InOutQuad)
self._pulse.setLoopCount(-1)
self.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed)
font = self.font()
font.setPixelSize(12)
font.setWeight(QFont.Weight.DemiBold)
self.setFont(font)
def set_status(self, text: str, tone: QColor, pulse: bool = False) -> None:
"""Set label, colour and whether the dot pulses."""
self._text = text
self._tone = QColor(tone)
if pulse and self._pulse.state() != QPropertyAnimation.State.Running:
self._pulse.start()
elif not pulse:
self._pulse.stop()
self._dot_opacity = 1.0
self.setToolTip(text)
self.updateGeometry()
self.update()
@property
def text(self) -> str:
"""Current label."""
return self._text
@property
def pulsing(self) -> bool:
"""Whether the dot animation runs."""
return self._pulse.state() == QPropertyAnimation.State.Running
def _get_dot_opacity(self) -> float:
return self._dot_opacity
def _set_dot_opacity(self, value: float) -> None:
self._dot_opacity = value
self.update()
dot_opacity = Property(float, _get_dot_opacity, _set_dot_opacity)
def sizeHint(self) -> QSize: # pylint: disable=invalid-name
return QSize(self.fontMetrics().horizontalAdvance(self._text) + 26, 22)
def paintEvent(self, _event): # pylint: disable=invalid-name
painter = QPainter(self)
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
rect = QRectF(self.rect())
background = QColor(self._tone)
background.setAlphaF(0.16)
painter.setPen(Qt.PenStyle.NoPen)
painter.setBrush(background)
painter.drawRoundedRect(rect, rect.height() / 2, rect.height() / 2)
dot = QColor(self._tone)
dot.setAlphaF(self._dot_opacity)
painter.setBrush(dot)
painter.drawEllipse(QRectF(9, rect.height() / 2 - 3.5, 7, 7))
painter.setPen(self._tone)
painter.drawText(rect.adjusted(22, 0, 0, 0), Qt.AlignmentFlag.AlignVCenter, self._text)
painter.end()
def cleanup(self) -> None:
"""Stop the pulse animation."""
self._pulse.stop()
class Card(QFrame):
"""Rounded surface with an optional title row, matching ``BecUi.Card``."""
def __init__(self, title: str = "", parent: QWidget | None = None, padding: int = 12):
super().__init__(parent)
self.setObjectName("uxCard")
outer = QVBoxLayout(self)
outer.setContentsMargins(padding, padding, padding, padding)
outer.setSpacing(10)
self.header = QHBoxLayout()
self.header.setSpacing(8)
self.title_label = QLabel(title, self)
self.subtitle_label = QLabel("", self)
self.subtitle_label.setVisible(False)
self.header.addWidget(self.title_label)
self.header.addWidget(self.subtitle_label, 1)
self.header_extras = QHBoxLayout()
self.header_extras.setSpacing(4)
self.header.addLayout(self.header_extras)
outer.addLayout(self.header)
self.body = QVBoxLayout()
self.body.setSpacing(8)
outer.addLayout(self.body)
self._has_title = bool(title)
self._update_header_visibility()
self.refresh_theme(ThemeTokens())
def set_title(self, title: str, subtitle: str = "") -> None:
"""Set the title and an optional muted subtitle."""
self._has_title = bool(title)
self.title_label.setText(title)
self.subtitle_label.setText(subtitle)
self.subtitle_label.setVisible(bool(subtitle))
self._update_header_visibility()
def add_header_widget(self, widget: QWidget) -> None:
"""Add a widget to the right end of the title row."""
self.header_extras.addWidget(widget)
self._has_title = True
self._update_header_visibility()
def _update_header_visibility(self) -> None:
self.title_label.setVisible(bool(self.title_label.text()))
def refresh_theme(self, tokens: ThemeTokens) -> None:
"""Apply the theme tokens."""
self.setStyleSheet(
f"QFrame#uxCard {{ background: {tokens.card.name()};"
f" border: 1px solid {tokens.border.name()}; border-radius: 10px; }}"
)
self.title_label.setStyleSheet(
f"color: {tokens.fg.name()}; font-size: 13px; font-weight: 600; background: none;"
)
self.subtitle_label.setStyleSheet(
f"color: {tokens.fg_subtle.name()}; font-size: 12px; background: none;"
)
class PortedPropertiesMixin:
"""Make a port export and load the ``SafeProperty`` settings of the class it ports.
``BECConnector`` only collects the properties defined directly on ``type(self)``, so a
subclass would silently drop the inherited ones (e.g. ``ring_json``) from saved profiles.
Set ``PORTED_FROM`` to the original class; its properties and those of every class in
between are collected.
"""
PORTED_FROM: type | None = None
def _get_bec_meta_objects(self) -> dict:
objects = {}
for cls in type(self).__mro__:
for name, attr in vars(cls).items():
if name in objects or not isinstance(attr, Property):
continue
if getattr(attr.fget, "__is_safe_getter__", False):
objects[name] = attr
if cls is self.PORTED_FROM:
break
return objects
class ToggleSwitch(QPushButton):
"""Checkable switch painted like ``BecUi.SwitchField``; the label shows On or Off."""
def __init__(self, parent: QWidget | None = None):
super().__init__(parent)
self.setCheckable(True)
self.setCursor(Qt.CursorShape.PointingHandCursor)
self.setMinimumHeight(32)
self.setFixedWidth(84)
self._knob = 0.0
self._animation = QPropertyAnimation(self, b"knob", self)
self._animation.setDuration(120)
self._animation.setEasingCurve(QEasingCurve.Type.OutCubic)
self.toggled.connect(self._animate)
def _animate(self, checked: bool) -> None:
self._animation.stop()
self._animation.setStartValue(self._knob)
self._animation.setEndValue(1.0 if checked else 0.0)
self._animation.start()
def setChecked(self, checked: bool) -> None: # pylint: disable=invalid-name
"""Set the state without animating."""
self.blockSignals(True)
super().setChecked(checked)
self.blockSignals(False)
self._animation.stop()
self._knob = 1.0 if checked else 0.0
self.update()
def _get_knob(self) -> float:
return self._knob
def _set_knob(self, value: float) -> None:
self._knob = value
self.update()
knob = Property(float, _get_knob, _set_knob)
def paintEvent(self, _event): # pylint: disable=invalid-name
tokens = ThemeTokens()
painter = QPainter(self)
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
track = QRectF(0, (self.height() - 22) / 2, 38, 22)
painter.setPen(tokens.primary if self.isChecked() else tokens.border)
painter.setBrush(tokens.primary if self.isChecked() else tokens.track)
painter.drawRoundedRect(track, 11, 11)
painter.setPen(Qt.PenStyle.NoPen)
painter.setBrush(tokens.on_primary if self.isChecked() else tokens.fg_muted)
painter.drawEllipse(QRectF(track.left() + 3 + self._knob * 16, track.top() + 3, 16, 16))
painter.setPen(tokens.fg)
font = self.font()
font.setPixelSize(13)
painter.setFont(font)
painter.drawText(
QRectF(track.right() + 8, 0, self.width() - track.right() - 8, self.height()),
Qt.AlignmentFlag.AlignVCenter,
"On" if self.isChecked() else "Off",
)
painter.end()
class SuffixLineEdit(QLineEdit):
"""Line edit that paints a unit suffix inside its right edge, like ``BecUi.InputField``."""
def __init__(self, parent: QWidget | None = None):
super().__init__(parent)
self._suffix = ""
def set_suffix(self, suffix: str) -> None:
"""Set the unit shown at the right edge."""
self._suffix = suffix or ""
width = self.fontMetrics().horizontalAdvance(self._suffix) + 10 if self._suffix else 0
self.setTextMargins(0, 0, width, 0)
self.update()
def paintEvent(self, event): # pylint: disable=invalid-name
super().paintEvent(event)
if not self._suffix:
return
painter = QPainter(self)
painter.setPen(ThemeTokens().fg_subtle)
font = self.font()
font.setPixelSize(12)
painter.setFont(font)
painter.drawText(
self.rect().adjusted(0, 0, -9, 0),
Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter,
self._suffix,
)
painter.end()