From 9e68268ebb396d6c65e962ded8fd71d1d9b7c993 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 22:09:28 +0000 Subject: [PATCH] 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) Claude-Session: https://claude.ai/code/session_01A4kzsfXBk1iaZU32We5XLP --- bec_widgets/utils/quick/__init__.py | 26 ++ bec_widgets/utils/quick/host.py | 233 ++++++++++ bec_widgets/utils/quick/list_model.py | 83 ++++ bec_widgets/utils/quick/qml/BecUi/Card.qml | 56 +++ .../utils/quick/qml/BecUi/FieldFrame.qml | 12 + bec_widgets/utils/quick/qml/BecUi/Icon.qml | 25 ++ .../utils/quick/qml/BecUi/IconButton.qml | 36 ++ .../utils/quick/qml/BecUi/InputField.qml | 38 ++ .../utils/quick/qml/BecUi/SelectField.qml | 88 ++++ .../utils/quick/qml/BecUi/StatusPill.qml | 40 ++ .../utils/quick/qml/BecUi/SwitchField.qml | 34 ++ .../utils/quick/qml/BecUi/TextButton.qml | 65 +++ bec_widgets/utils/quick/qml/BecUi/qmldir | 10 + bec_widgets/utils/ux_kit.py | 420 ++++++++++++++++++ 14 files changed, 1166 insertions(+) create mode 100644 bec_widgets/utils/quick/__init__.py create mode 100644 bec_widgets/utils/quick/host.py create mode 100644 bec_widgets/utils/quick/list_model.py create mode 100644 bec_widgets/utils/quick/qml/BecUi/Card.qml create mode 100644 bec_widgets/utils/quick/qml/BecUi/FieldFrame.qml create mode 100644 bec_widgets/utils/quick/qml/BecUi/Icon.qml create mode 100644 bec_widgets/utils/quick/qml/BecUi/IconButton.qml create mode 100644 bec_widgets/utils/quick/qml/BecUi/InputField.qml create mode 100644 bec_widgets/utils/quick/qml/BecUi/SelectField.qml create mode 100644 bec_widgets/utils/quick/qml/BecUi/StatusPill.qml create mode 100644 bec_widgets/utils/quick/qml/BecUi/SwitchField.qml create mode 100644 bec_widgets/utils/quick/qml/BecUi/TextButton.qml create mode 100644 bec_widgets/utils/quick/qml/BecUi/qmldir create mode 100644 bec_widgets/utils/ux_kit.py diff --git a/bec_widgets/utils/quick/__init__.py b/bec_widgets/utils/quick/__init__.py new file mode 100644 index 00000000..dcc683f1 --- /dev/null +++ b/bec_widgets/utils/quick/__init__.py @@ -0,0 +1,26 @@ +"""Shared plumbing for widgets that render their content with Qt Quick (QML). + +The QML ports keep the usual ``BECWidget`` shell and public API and only swap the rendering +layer: a :class:`~qtpy.QtQuickWidgets.QQuickWidget` is embedded into the existing widget and +driven by a small Python backend object. +""" + +from bec_widgets.utils.quick.host import ( + QML_IMPORT_PATH, + MaterialIconProvider, + QmlTheme, + create_quick_widget, + quick_engine, + release_quick_widget, +) +from bec_widgets.utils.quick.list_model import DictListModel + +__all__ = [ + "QML_IMPORT_PATH", + "DictListModel", + "MaterialIconProvider", + "QmlTheme", + "create_quick_widget", + "quick_engine", + "release_quick_widget", +] diff --git a/bec_widgets/utils/quick/host.py b/bec_widgets/utils/quick/host.py new file mode 100644 index 00000000..0df3b172 --- /dev/null +++ b/bec_widgets/utils/quick/host.py @@ -0,0 +1,233 @@ +"""Hosting helpers for QML views embedded into BEC widgets. + +All QML views share one :class:`QQmlEngine` per application. The engine carries the +application-wide ``theme`` context property (a :class:`QmlTheme` bridging ``app.theme`` from +``bec_qthemes``), an ``image://material/`` provider for Material icons and the import path +of the shared ``BecUi`` control module. Per-widget state is handed to the view through initial +properties of the root item, typically a single ``backend`` QObject. +""" + +from __future__ import annotations + +from pathlib import Path +from urllib.parse import parse_qs + +from bec_lib.logger import bec_logger +from bec_qthemes import material_icon +from qtpy.QtCore import Property, QObject, QSize, Qt, QUrl, Signal +from qtpy.QtGui import QColor, QPixmap +from qtpy.QtQml import QQmlEngine +from qtpy.QtQuick import QQuickImageProvider +from qtpy.QtQuickWidgets import QQuickWidget +from qtpy.QtWidgets import QApplication, QWidget + +logger = bec_logger.logger + +QML_IMPORT_PATH = Path(__file__).parent / "qml" + +_DARK_FALLBACK = { + "PRIMARY": "#3b82f6", + "ON_PRIMARY": "#ffffff", + "BG": "#0f1115", + "CARD_BG": "#171a21", + "FIELD_BG": "#10131a", + "BORDER": "#2a2f3a", + "FG": "#e8ebf1", + "ACCENT_DEFAULT": "#8ab4f7", + "ACCENT_HIGHLIGHT": "#B53565", + "ACCENT_WARNING": "#EAC435", + "ACCENT_EMERGENCY": "#CC181E", + "ACCENT_SUCCESS": "#2CA58D", +} + + +def _blend(base: QColor, top: QColor, amount: float) -> QColor: + """Return ``top`` mixed into ``base`` by ``amount`` (0 = base, 1 = top).""" + return QColor.fromRgbF( + base.redF() + (top.redF() - base.redF()) * amount, + base.greenF() + (top.greenF() - base.greenF()) * amount, + base.blueF() + (top.blueF() - base.blueF()) * amount, + 1.0, + ) + + +class ThemeTokens: + """Resolved colour tokens of the active BEC theme, shared by the QML and QWidget ports.""" + + def __init__(self): + app = QApplication.instance() + theme = getattr(app, "theme", None) if app is not None else None + name = getattr(theme, "theme", "dark") if theme is not None else "dark" + + def color(key: str) -> QColor: + fallback = _DARK_FALLBACK.get(key, "#808080") + if theme is None: + return QColor(fallback) + return QColor(theme.color(key, fallback)) + + self.name: str = str(name) + self.bg = color("BG") + self.card = color("CARD_BG") + self.field = color("FIELD_BG") + self.border = color("BORDER") + self.fg = color("FG") + self.primary = color("PRIMARY") + self.on_primary = color("ON_PRIMARY") + self.accent = color("ACCENT_DEFAULT") + self.highlight = color("ACCENT_HIGHLIGHT") + self.warning = color("ACCENT_WARNING") + self.danger = color("ACCENT_EMERGENCY") + self.success = color("ACCENT_SUCCESS") + self.dark = self.bg.lightnessF() < 0.5 + self.fg_muted = _blend(self.card, self.fg, 0.62) + self.fg_subtle = _blend(self.card, self.fg, 0.4) + self.hover = _blend(self.card, self.fg, 0.06) + self.pressed = _blend(self.card, self.fg, 0.12) + self.track = _blend(self.card, self.fg, 0.1) + + def soft(self, color: QColor, amount: float = 0.16) -> QColor: + """Tint ``color`` onto the card background, e.g. for chips and banners.""" + return _blend(self.card, color, amount) + + +class QmlTheme(QObject): + """Expose the BEC theme colours to QML as ``theme.`` and follow theme changes.""" + + changed = Signal() + + def __init__(self, parent: QObject | None = None): + super().__init__(parent) + self._tokens = ThemeTokens() + self._connected_theme = None + self._connect_theme() + + def _connect_theme(self) -> None: + app = QApplication.instance() + theme = getattr(app, "theme", None) if app is not None else None + if theme is None or theme is self._connected_theme: + return + theme.theme_changed.connect(self.refresh) + self._connected_theme = theme + + def refresh(self, *_args) -> None: + """Re-read the tokens from the application theme.""" + self._connect_theme() + self._tokens = ThemeTokens() + self.changed.emit() + + # pylint: disable=missing-function-docstring + def _get(self, name: str) -> QColor: + return getattr(self._tokens, name) + + name = Property(str, lambda self: self._tokens.name, notify=changed) + dark = Property(bool, lambda self: self._tokens.dark, notify=changed) + bg = Property(QColor, lambda self: self._get("bg"), notify=changed) + card = Property(QColor, lambda self: self._get("card"), notify=changed) + field = Property(QColor, lambda self: self._get("field"), notify=changed) + border = Property(QColor, lambda self: self._get("border"), notify=changed) + fg = Property(QColor, lambda self: self._get("fg"), notify=changed) + fgMuted = Property(QColor, lambda self: self._get("fg_muted"), notify=changed) + fgSubtle = Property(QColor, lambda self: self._get("fg_subtle"), notify=changed) + hover = Property(QColor, lambda self: self._get("hover"), notify=changed) + pressed = Property(QColor, lambda self: self._get("pressed"), notify=changed) + track = Property(QColor, lambda self: self._get("track"), notify=changed) + primary = Property(QColor, lambda self: self._get("primary"), notify=changed) + onPrimary = Property(QColor, lambda self: self._get("on_primary"), notify=changed) + accent = Property(QColor, lambda self: self._get("accent"), notify=changed) + highlight = Property(QColor, lambda self: self._get("highlight"), notify=changed) + warning = Property(QColor, lambda self: self._get("warning"), notify=changed) + danger = Property(QColor, lambda self: self._get("danger"), notify=changed) + success = Property(QColor, lambda self: self._get("success"), notify=changed) + + +class MaterialIconProvider(QQuickImageProvider): + """Serve Material icons to QML as ``image://material/?color=%23rrggbb&filled=1``.""" + + def __init__(self): + super().__init__(QQuickImageProvider.ImageType.Pixmap) + + def requestPixmap(self, icon_id: str, size: QSize, requested_size: QSize) -> QPixmap: + name, _, query = icon_id.partition("?") + options = parse_qs(query) + color = options.get("color", [None])[0] + filled = options.get("filled", ["0"])[0] in ("1", "true") + target = requested_size if requested_size.isValid() else QSize(24, 24) + if target.width() <= 0 or target.height() <= 0: + target = QSize(24, 24) + pixmap = material_icon(name, size=target, color=color, filled=filled) + if size is not None: + size.setWidth(pixmap.width()) + size.setHeight(pixmap.height()) + return pixmap + + +_ENGINE: QQmlEngine | None = None +_THEME: QmlTheme | None = None + + +def quick_engine() -> QQmlEngine: + """Return the application-wide QML engine used by all BEC QML views.""" + global _ENGINE, _THEME # pylint: disable=global-statement + if _ENGINE is not None: + if _THEME._connected_theme is None: # pylint: disable=protected-access + # the theme was applied after the engine was created + _THEME.refresh() + return _ENGINE + app = QApplication.instance() + _ENGINE = QQmlEngine(app) + _ENGINE.addImportPath(str(QML_IMPORT_PATH)) + _ENGINE.addImageProvider("material", MaterialIconProvider()) + _THEME = QmlTheme(_ENGINE) + _ENGINE.rootContext().setContextProperty("theme", _THEME) + _ENGINE.warnings.connect( + lambda warnings: [logger.warning(f"QML: {w.toString()}") for w in warnings] + ) + if app is not None: + app.aboutToQuit.connect(_drop_engine) + return _ENGINE + + +def _drop_engine() -> None: + global _ENGINE, _THEME # pylint: disable=global-statement + _ENGINE = None + _THEME = None + + +def create_quick_widget( + parent: QWidget, qml_file: str | Path, properties: dict | None = None +) -> QQuickWidget: + """Create a QQuickWidget showing ``qml_file`` on the shared engine. + + Args: + parent(QWidget): Parent widget, usually the BEC widget shell. + qml_file(str | Path): Path of the root QML file. + properties(dict | None): Initial properties of the root item, e.g. ``{"backend": obj}``. + + Returns: + QQuickWidget: The view, already loaded. Load errors are logged. + """ + view = QQuickWidget(quick_engine(), parent) + view.setResizeMode(QQuickWidget.ResizeMode.SizeRootObjectToView) + view.setAttribute(Qt.WidgetAttribute.WA_AlwaysStackOnTop, False) + view.setClearColor(parent.palette().window().color()) + if properties: + view.setInitialProperties(properties) + view.setSource(QUrl.fromLocalFile(str(qml_file))) + for error in view.errors(): + logger.error(f"QML error in {qml_file}: {error.toString()}") + return view + + +def release_quick_widget(view: QQuickWidget | None) -> None: + """Unload the QML scene of ``view`` before its backend objects are destroyed. + + Without this, bindings of a still-loaded scene are re-evaluated against already deleted + backends during teardown and log ``TypeError`` warnings. + """ + if view is None: + return + try: + view.setSource(QUrl()) + except RuntimeError: + # the C++ object is already gone + return diff --git a/bec_widgets/utils/quick/list_model.py b/bec_widgets/utils/quick/list_model.py new file mode 100644 index 00000000..6351b93d --- /dev/null +++ b/bec_widgets/utils/quick/list_model.py @@ -0,0 +1,83 @@ +"""A small list model that feeds lists of dictionaries to QML views.""" + +from __future__ import annotations + +from typing import Any, Sequence + +from qtpy.QtCore import Property, QAbstractListModel, QByteArray, QModelIndex, Qt, Signal + + +class DictListModel(QAbstractListModel): + """List model whose rows are plain dictionaries with a fixed set of keys. + + Each key becomes a QML role. :meth:`set_items` updates rows in place when the row count is + unchanged, so QML delegates are kept alive and ``Behavior`` animations run on value changes + instead of the delegates being rebuilt. + + Args: + roles(Sequence[str]): Keys of the row dictionaries exposed as roles. + parent: Parent QObject. + """ + + count_changed = Signal() + + def __init__(self, roles: Sequence[str], parent=None): + super().__init__(parent) + self._keys = list(roles) + self._roles = {Qt.ItemDataRole.UserRole + 1 + i: key for i, key in enumerate(self._keys)} + self._items: list[dict[str, Any]] = [] + + # pylint: disable=invalid-name + def roleNames(self) -> dict[int, QByteArray]: + return {role: QByteArray(key.encode()) for role, key in self._roles.items()} + + def rowCount(self, parent: QModelIndex = QModelIndex()) -> int: + if parent.isValid(): + return 0 + return len(self._items) + + def data(self, index: QModelIndex, role: int = Qt.ItemDataRole.DisplayRole): + if not index.isValid() or not 0 <= index.row() < len(self._items): + return None + key = self._roles.get(role) + if key is None: + return None + return self._items[index.row()].get(key) + + @property + def items(self) -> list[dict[str, Any]]: + """Copy of the current rows.""" + return [dict(item) for item in self._items] + + def set_items(self, items: Sequence[dict[str, Any]]) -> None: + """Replace the rows, reusing existing rows where possible. + + Args: + items(Sequence[dict]): New rows. + """ + items = [dict(item) for item in items] + old_count = len(self._items) + new_count = len(items) + common = min(old_count, new_count) + for row in range(common): + changed = [ + role + for role, key in self._roles.items() + if self._items[row].get(key) != items[row].get(key) + ] + self._items[row] = items[row] + if changed: + index = self.index(row, 0) + self.dataChanged.emit(index, index, changed) + if new_count > old_count: + self.beginInsertRows(QModelIndex(), old_count, new_count - 1) + self._items.extend(items[old_count:]) + self.endInsertRows() + elif new_count < old_count: + self.beginRemoveRows(QModelIndex(), new_count, old_count - 1) + del self._items[new_count:] + self.endRemoveRows() + if new_count != old_count: + self.count_changed.emit() + + count = Property(int, lambda self: len(self._items), notify=count_changed) diff --git a/bec_widgets/utils/quick/qml/BecUi/Card.qml b/bec_widgets/utils/quick/qml/BecUi/Card.qml new file mode 100644 index 00000000..2451958f --- /dev/null +++ b/bec_widgets/utils/quick/qml/BecUi/Card.qml @@ -0,0 +1,56 @@ +import QtQuick +import QtQuick.Layouts + +// Rounded surface with an optional title row. Children go into the content column. +Rectangle { + id: root + property string title: "" + property string subtitle: "" + default property alias content: column.data + property alias headerExtras: extras.data + property int padding: 12 + property alias spacing: column.spacing + // Let the content column take the remaining height, e.g. for a list that fills the card. + property bool fillContent: false + + color: theme.card + radius: 10 + border.color: theme.border + border.width: 1 + implicitHeight: outer.implicitHeight + 2 * padding + implicitWidth: outer.implicitWidth + 2 * padding + + ColumnLayout { + id: outer + anchors.fill: parent + anchors.margins: root.padding + spacing: 10 + RowLayout { + visible: root.title !== "" || extras.children.length > 0 + Layout.fillWidth: true + spacing: 8 + Text { + text: root.title + color: theme.fg + font.pixelSize: 13 + font.weight: Font.DemiBold + } + Text { + text: root.subtitle + visible: text !== "" + color: theme.fgSubtle + font.pixelSize: 12 + Layout.fillWidth: true + elide: Text.ElideRight + } + Item { Layout.fillWidth: root.subtitle === "" } + Row { id: extras; spacing: 4 } + } + ColumnLayout { + id: column + Layout.fillWidth: true + Layout.fillHeight: root.fillContent + spacing: 8 + } + } +} diff --git a/bec_widgets/utils/quick/qml/BecUi/FieldFrame.qml b/bec_widgets/utils/quick/qml/BecUi/FieldFrame.qml new file mode 100644 index 00000000..aad88d6a --- /dev/null +++ b/bec_widgets/utils/quick/qml/BecUi/FieldFrame.qml @@ -0,0 +1,12 @@ +import QtQuick + +// Background used by all input controls: field colour, border, focus and error states. +Rectangle { + property bool focused: false + property bool invalid: false + property bool hovered: false + radius: 6 + color: theme.field + border.width: focused || invalid ? 2 : 1 + border.color: invalid ? theme.danger : focused ? theme.primary : hovered ? theme.fgSubtle : theme.border +} diff --git a/bec_widgets/utils/quick/qml/BecUi/Icon.qml b/bec_widgets/utils/quick/qml/BecUi/Icon.qml new file mode 100644 index 00000000..d9fbedc4 --- /dev/null +++ b/bec_widgets/utils/quick/qml/BecUi/Icon.qml @@ -0,0 +1,25 @@ +import QtQuick + +// Material icon served by the "material" image provider, tinted with the given colour. +Item { + id: root + property string name: "" + property color color: theme.fg + property bool filled: false + property int size: 18 + + implicitWidth: size + implicitHeight: size + + Image { + anchors.centerIn: parent + width: root.size + height: root.size + sourceSize.width: root.size * 2 + sourceSize.height: root.size * 2 + smooth: true + fillMode: Image.PreserveAspectFit + source: root.name === "" ? "" : "image://material/" + root.name + "?color=" + + encodeURIComponent(root.color.toString()) + (root.filled ? "&filled=1" : "") + } +} diff --git a/bec_widgets/utils/quick/qml/BecUi/IconButton.qml b/bec_widgets/utils/quick/qml/BecUi/IconButton.qml new file mode 100644 index 00000000..9603aa81 --- /dev/null +++ b/bec_widgets/utils/quick/qml/BecUi/IconButton.qml @@ -0,0 +1,36 @@ +import QtQuick +import QtQuick.Controls.Basic + +// Flat, square icon button with hover and pressed feedback and a tooltip. +AbstractButton { + id: root + property string iconName: "" + property color iconColor: theme.fgMuted + property string tip: "" + property int iconSize: 18 + + implicitWidth: 30 + implicitHeight: 30 + hoverEnabled: true + focusPolicy: Qt.TabFocus + opacity: enabled ? 1.0 : 0.4 + Accessible.name: tip + + background: Rectangle { + radius: 6 + color: root.down ? theme.pressed : (root.hovered || root.checked ? theme.hover : "transparent") + border.width: root.visualFocus ? 1 : 0 + border.color: theme.primary + } + contentItem: Item { + Icon { + anchors.centerIn: parent + name: root.iconName + size: root.iconSize + color: root.checked ? theme.primary : root.iconColor + } + } + ToolTip.visible: tip !== "" && hovered + ToolTip.delay: 500 + ToolTip.text: tip +} diff --git a/bec_widgets/utils/quick/qml/BecUi/InputField.qml b/bec_widgets/utils/quick/qml/BecUi/InputField.qml new file mode 100644 index 00000000..0ed06d5f --- /dev/null +++ b/bec_widgets/utils/quick/qml/BecUi/InputField.qml @@ -0,0 +1,38 @@ +import QtQuick +import QtQuick.Controls.Basic + +// Single-line text input with optional unit suffix and error state. +TextField { + id: root + property bool invalid: false + property string suffix: "" + + implicitHeight: 32 + implicitWidth: 120 + color: theme.fg + placeholderTextColor: theme.fgSubtle + selectionColor: theme.primary + selectedTextColor: theme.onPrimary + font.pixelSize: 13 + leftPadding: 9 + rightPadding: suffixLabel.visible ? suffixLabel.implicitWidth + 14 : 9 + selectByMouse: true + hoverEnabled: true + opacity: enabled ? 1.0 : 0.5 + + background: FieldFrame { + focused: root.activeFocus + invalid: root.invalid + hovered: root.hovered + } + Text { + id: suffixLabel + visible: root.suffix !== "" + text: root.suffix + color: theme.fgSubtle + font.pixelSize: 12 + anchors.right: parent.right + anchors.rightMargin: 9 + anchors.verticalCenter: parent.verticalCenter + } +} diff --git a/bec_widgets/utils/quick/qml/BecUi/SelectField.qml b/bec_widgets/utils/quick/qml/BecUi/SelectField.qml new file mode 100644 index 00000000..2d4f814b --- /dev/null +++ b/bec_widgets/utils/quick/qml/BecUi/SelectField.qml @@ -0,0 +1,88 @@ +import QtQuick +import QtQuick.Controls.Basic + +// Combo box styled like the input fields. With editable: true it filters while typing. +ComboBox { + id: root + property bool invalid: false + property string placeholder: "" + + implicitHeight: 32 + implicitWidth: 140 + font.pixelSize: 13 + hoverEnabled: true + opacity: enabled ? 1.0 : 0.5 + + background: FieldFrame { + focused: root.activeFocus || root.popup.visible + invalid: root.invalid + hovered: root.hovered + } + contentItem: TextField { + leftPadding: 9 + rightPadding: 4 + text: root.editable ? root.editText : root.displayText + readOnly: !root.editable + placeholderText: root.placeholder + placeholderTextColor: theme.fgSubtle + enabled: root.enabled + color: theme.fg + selectionColor: theme.primary + selectedTextColor: theme.onPrimary + font: root.font + verticalAlignment: Text.AlignVCenter + background: null + selectByMouse: true + // forward clicks to the combo when it is not editable + TapHandler { enabled: !root.editable; onTapped: root.popup.visible ? root.popup.close() : root.popup.open() } + } + indicator: Icon { + x: root.width - width - 6 + y: (root.height - height) / 2 + name: "expand_more" + size: 18 + color: theme.fgMuted + } + delegate: ItemDelegate { + id: option + required property int index + required property var modelData + width: ListView.view ? ListView.view.width : root.width + height: 30 + highlighted: root.highlightedIndex === index + contentItem: Text { + text: option.modelData === "" ? "None" : option.modelData + color: option.modelData === "" ? theme.fgSubtle : theme.fg + font.pixelSize: 13 + elide: Text.ElideRight + verticalAlignment: Text.AlignVCenter + } + background: Rectangle { + color: option.highlighted ? theme.hover : "transparent" + Rectangle { + width: 3; height: parent.height - 10; radius: 1.5 + anchors.verticalCenter: parent.verticalCenter + color: theme.primary + visible: root.currentIndex === option.index + } + } + } + popup: Popup { + y: root.height + 2 + width: root.width + implicitHeight: Math.min(contentItem.implicitHeight + 8, 280) + padding: 4 + contentItem: ListView { + clip: true + implicitHeight: contentHeight + model: root.popup.visible ? root.delegateModel : null + currentIndex: root.highlightedIndex + ScrollIndicator.vertical: ScrollIndicator {} + } + background: Rectangle { + color: theme.card + border.color: theme.border + radius: 8 + } + } +} diff --git a/bec_widgets/utils/quick/qml/BecUi/StatusPill.qml b/bec_widgets/utils/quick/qml/BecUi/StatusPill.qml new file mode 100644 index 00000000..7901c832 --- /dev/null +++ b/bec_widgets/utils/quick/qml/BecUi/StatusPill.qml @@ -0,0 +1,40 @@ +import QtQuick + +// Small rounded status chip with a coloured dot. Set pulse for live states such as "Moving". +Rectangle { + id: root + property string text: "" + property color tone: theme.fgMuted + property bool pulse: false + + implicitHeight: 22 + implicitWidth: label.implicitWidth + 26 + radius: height / 2 + color: Qt.rgba(tone.r, tone.g, tone.b, 0.16) + + Rectangle { + id: dot + width: 7; height: 7; radius: 3.5 + color: root.tone + anchors.left: parent.left + anchors.leftMargin: 9 + anchors.verticalCenter: parent.verticalCenter + SequentialAnimation on opacity { + running: root.pulse + loops: Animation.Infinite + alwaysRunToEnd: true + NumberAnimation { to: 0.25; duration: 550; easing.type: Easing.InOutQuad } + NumberAnimation { to: 1.0; duration: 550; easing.type: Easing.InOutQuad } + } + } + Text { + id: label + anchors.left: dot.right + anchors.leftMargin: 6 + anchors.verticalCenter: parent.verticalCenter + text: root.text + color: root.tone + font.pixelSize: 12 + font.weight: Font.DemiBold + } +} diff --git a/bec_widgets/utils/quick/qml/BecUi/SwitchField.qml b/bec_widgets/utils/quick/qml/BecUi/SwitchField.qml new file mode 100644 index 00000000..a21f3221 --- /dev/null +++ b/bec_widgets/utils/quick/qml/BecUi/SwitchField.qml @@ -0,0 +1,34 @@ +import QtQuick +import QtQuick.Controls.Basic + +// Toggle switch for boolean parameters. +Switch { + id: root + implicitHeight: 32 + hoverEnabled: true + padding: 0 + indicator: Rectangle { + implicitWidth: 38 + implicitHeight: 22 + x: root.leftPadding + y: (root.height - height) / 2 + radius: 11 + color: root.checked ? theme.primary : theme.track + border.color: root.visualFocus ? theme.primary : (root.checked ? theme.primary : theme.border) + Behavior on color { ColorAnimation { duration: 120 } } + Rectangle { + x: root.checked ? parent.width - width - 3 : 3 + y: 3 + width: 16; height: 16; radius: 8 + color: root.checked ? theme.onPrimary : theme.fgMuted + Behavior on x { NumberAnimation { duration: 120; easing.type: Easing.OutCubic } } + } + } + contentItem: Text { + leftPadding: root.indicator.width + 8 + text: root.text + color: theme.fg + font.pixelSize: 13 + verticalAlignment: Text.AlignVCenter + } +} diff --git a/bec_widgets/utils/quick/qml/BecUi/TextButton.qml b/bec_widgets/utils/quick/qml/BecUi/TextButton.qml new file mode 100644 index 00000000..45b069dc --- /dev/null +++ b/bec_widgets/utils/quick/qml/BecUi/TextButton.qml @@ -0,0 +1,65 @@ +import QtQuick +import QtQuick.Controls.Basic +import QtQuick.Layouts + +// Labelled button. variant: "primary", "danger", "success", "neutral" or "ghost". +AbstractButton { + id: root + property string variant: "neutral" + property string iconName: "" + property string tip: "" + property bool busy: false + + readonly property color baseColor: variant === "primary" ? theme.primary + : variant === "danger" ? theme.danger + : variant === "success" ? theme.success + : "transparent" + readonly property bool solid: variant === "primary" || variant === "danger" || variant === "success" + readonly property color labelColor: solid ? "white" : theme.fg + + implicitHeight: 32 + implicitWidth: Math.max(64, row.implicitWidth + 24) + hoverEnabled: true + opacity: enabled ? 1.0 : 0.45 + Accessible.name: text + + background: Rectangle { + radius: 6 + color: root.solid ? (root.down ? Qt.darker(root.baseColor, 1.25) : root.hovered ? Qt.lighter(root.baseColor, 1.1) : root.baseColor) + : (root.down ? theme.pressed : root.hovered ? theme.hover : "transparent") + border.width: root.variant === "neutral" || root.visualFocus ? 1 : 0 + border.color: root.visualFocus ? theme.primary : theme.border + Behavior on color { ColorAnimation { duration: 90 } } + } + contentItem: Item { + implicitWidth: row.implicitWidth + implicitHeight: row.implicitHeight + RowLayout { + id: row + anchors.centerIn: parent + spacing: 6 + BusyIndicator { + visible: root.busy + running: root.busy + Layout.preferredWidth: 16 + Layout.preferredHeight: 16 + } + Icon { + visible: root.iconName !== "" && !root.busy + name: root.iconName + color: root.labelColor + size: 16 + } + Text { + text: root.text + color: root.labelColor + font.pixelSize: 13 + font.weight: root.solid ? Font.DemiBold : Font.Medium + elide: Text.ElideRight + } + } + } + ToolTip.visible: tip !== "" && hovered + ToolTip.delay: 500 + ToolTip.text: tip +} diff --git a/bec_widgets/utils/quick/qml/BecUi/qmldir b/bec_widgets/utils/quick/qml/BecUi/qmldir new file mode 100644 index 00000000..160907bc --- /dev/null +++ b/bec_widgets/utils/quick/qml/BecUi/qmldir @@ -0,0 +1,10 @@ +module BecUi +Icon 1.0 Icon.qml +IconButton 1.0 IconButton.qml +TextButton 1.0 TextButton.qml +StatusPill 1.0 StatusPill.qml +Card 1.0 Card.qml +FieldFrame 1.0 FieldFrame.qml +InputField 1.0 InputField.qml +SelectField 1.0 SelectField.qml +SwitchField 1.0 SwitchField.qml diff --git a/bec_widgets/utils/ux_kit.py b/bec_widgets/utils/ux_kit.py new file mode 100644 index 00000000..c8497afc --- /dev/null +++ b/bec_widgets/utils/ux_kit.py @@ -0,0 +1,420 @@ +"""Small themed QWidget controls used by the QWidget twins of the QML widget ports. + +They mirror the ``BecUi`` QML controls in ``bec_widgets/utils/quick/qml`` so that the QML and +QWidget versions of a widget look and behave the same and can be compared side by side. Colours +come from the same :class:`~bec_widgets.utils.quick.host.ThemeTokens`. +""" + +from __future__ import annotations + +from bec_qthemes import material_icon +from qtpy.QtCore import Property, QEasingCurve, QPropertyAnimation, QRectF, QSize, Qt +from qtpy.QtGui import QColor, QFont, QPainter +from qtpy.QtWidgets import ( + QFrame, + QHBoxLayout, + QLabel, + QLineEdit, + QPushButton, + QSizePolicy, + QToolButton, + QVBoxLayout, + QWidget, +) + +from bec_widgets.utils.quick.host import ThemeTokens + + +def rgba(color: QColor, alpha: float) -> str: + """Return a QSS ``rgba()`` string for ``color`` with alpha in 0..1.""" + return f"rgba({color.red()}, {color.green()}, {color.blue()}, {int(alpha * 255)})" + + +def refresh_kit_theme(root: QWidget) -> ThemeTokens: + """Re-theme every kit control below ``root`` and return the current tokens.""" + tokens = ThemeTokens() + for widget in [root, *root.findChildren(QWidget)]: + refresh = getattr(widget, "refresh_theme", None) + if callable(refresh) and widget is not root: + refresh(tokens) + return tokens + + +def field_qss(tokens: ThemeTokens) -> str: + """Stylesheet for inputs inside kit cards; ``invalid=true`` marks a field with an error.""" + return f""" + QLineEdit, QComboBox, QSpinBox, QDoubleSpinBox {{ + background: {tokens.field.name()}; + color: {tokens.fg.name()}; + border: 1px solid {tokens.border.name()}; + border-radius: 6px; + padding: 0px 8px; + min-height: 30px; + font-size: 13px; + selection-background-color: {tokens.primary.name()}; + }} + QLineEdit:hover, QComboBox:hover, QSpinBox:hover, QDoubleSpinBox:hover {{ + border-color: {tokens.fg_subtle.name()}; + }} + QLineEdit:focus, QComboBox:focus, QSpinBox:focus, QDoubleSpinBox:focus {{ + border: 2px solid {tokens.primary.name()}; + padding: 0px 7px; + }} + QLineEdit[invalid="true"], QComboBox[invalid="true"] {{ + border: 2px solid {tokens.danger.name()}; + padding: 0px 7px; + }} + QLineEdit:disabled, QComboBox:disabled {{ color: {tokens.fg_subtle.name()}; }} + QComboBox::drop-down {{ border: none; width: 22px; }} + """ + + +def set_invalid(widget: QWidget, invalid: bool) -> None: + """Toggle the ``invalid`` style of a field styled with :func:`field_qss`.""" + if bool(widget.property("invalid")) == invalid: + return + widget.setProperty("invalid", invalid) + widget.style().unpolish(widget) + widget.style().polish(widget) + + +class IconButton(QToolButton): + """Flat square icon button with hover feedback, matching ``BecUi.IconButton``.""" + + def __init__(self, icon_name: str, tip: str = "", parent: QWidget | None = None, size=18): + super().__init__(parent) + self._icon_name = icon_name + self._icon_size = size + self.setToolTip(tip) + self.setAccessibleName(tip) + self.setAutoRaise(True) + self.setFixedSize(30, 30) + self.setIconSize(QSize(size, size)) + self.setCursor(Qt.CursorShape.PointingHandCursor) + self.refresh_theme(ThemeTokens()) + + def set_icon_name(self, icon_name: str) -> None: + """Change the Material icon.""" + self._icon_name = icon_name + self.refresh_theme(ThemeTokens()) + + def refresh_theme(self, tokens: ThemeTokens) -> None: + """Apply the theme tokens.""" + color = tokens.primary if self.isChecked() else tokens.fg_muted + self.setIcon( + material_icon(self._icon_name, size=(48, 48), color=color, convert_to_pixmap=False) + ) + self.setStyleSheet( + f"QToolButton {{ border: none; border-radius: 6px; background: transparent; }}" + f"QToolButton:hover, QToolButton:checked {{ background: {tokens.hover.name()}; }}" + f"QToolButton:pressed {{ background: {tokens.pressed.name()}; }}" + ) + + +class TextButton(QPushButton): + """Labelled button; variant is ``primary``, ``danger``, ``success``, ``neutral`` or ``ghost``.""" + + def __init__( + self, + text: str, + variant: str = "neutral", + icon_name: str = "", + parent: QWidget | None = None, + ): + super().__init__(text, parent) + self._variant = variant + self._icon_name = icon_name + self.setMinimumHeight(32) + self.setCursor(Qt.CursorShape.PointingHandCursor) + self.refresh_theme(ThemeTokens()) + + def set_variant(self, variant: str) -> None: + """Switch the colour variant.""" + self._variant = variant + self.refresh_theme(ThemeTokens()) + + def refresh_theme(self, tokens: ThemeTokens) -> None: + """Apply the theme tokens.""" + base = {"primary": tokens.primary, "danger": tokens.danger, "success": tokens.success}.get( + self._variant + ) + if base is not None: + label = QColor("white") + qss = ( + f"QPushButton {{ background: {base.name()}; color: white; border: none;" + f" border-radius: 6px; padding: 0 12px; font-size: 13px; font-weight: 600; }}" + f"QPushButton:hover {{ background: {base.lighter(110).name()}; }}" + f"QPushButton:pressed {{ background: {base.darker(125).name()}; }}" + f"QPushButton:disabled {{ background: {rgba(base, 0.45)};" + f" color: {rgba(label, 0.7)}; }}" + ) + else: + label = tokens.fg + border = tokens.border.name() if self._variant == "neutral" else "transparent" + qss = ( + f"QPushButton {{ background: transparent; color: {tokens.fg.name()};" + f" border: 1px solid {border}; border-radius: 6px; padding: 0 12px;" + f" font-size: 13px; font-weight: 500; }}" + f"QPushButton:hover {{ background: {tokens.hover.name()}; }}" + f"QPushButton:pressed {{ background: {tokens.pressed.name()}; }}" + f"QPushButton:disabled {{ color: {tokens.fg_subtle.name()}; }}" + ) + self.setStyleSheet(qss) + if self._icon_name: + self.setIcon( + material_icon(self._icon_name, size=(32, 32), color=label, convert_to_pixmap=False) + ) + self.setIconSize(QSize(16, 16)) + + +class StatusPill(QWidget): + """Rounded status chip with a coloured dot, matching ``BecUi.StatusPill``.""" + + def __init__(self, parent: QWidget | None = None): + super().__init__(parent) + self._text = "" + self._tone = QColor("#808080") + self._dot_opacity = 1.0 + self._pulse = QPropertyAnimation(self, b"dot_opacity", self) + self._pulse.setDuration(1100) + self._pulse.setKeyValueAt(0.0, 1.0) + self._pulse.setKeyValueAt(0.5, 0.25) + self._pulse.setKeyValueAt(1.0, 1.0) + self._pulse.setEasingCurve(QEasingCurve.Type.InOutQuad) + self._pulse.setLoopCount(-1) + self.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed) + font = self.font() + font.setPixelSize(12) + font.setWeight(QFont.Weight.DemiBold) + self.setFont(font) + + def set_status(self, text: str, tone: QColor, pulse: bool = False) -> None: + """Set label, colour and whether the dot pulses.""" + self._text = text + self._tone = QColor(tone) + if pulse and self._pulse.state() != QPropertyAnimation.State.Running: + self._pulse.start() + elif not pulse: + self._pulse.stop() + self._dot_opacity = 1.0 + self.setToolTip(text) + self.updateGeometry() + self.update() + + @property + def text(self) -> str: + """Current label.""" + return self._text + + @property + def pulsing(self) -> bool: + """Whether the dot animation runs.""" + return self._pulse.state() == QPropertyAnimation.State.Running + + def _get_dot_opacity(self) -> float: + return self._dot_opacity + + def _set_dot_opacity(self, value: float) -> None: + self._dot_opacity = value + self.update() + + dot_opacity = Property(float, _get_dot_opacity, _set_dot_opacity) + + def sizeHint(self) -> QSize: # pylint: disable=invalid-name + return QSize(self.fontMetrics().horizontalAdvance(self._text) + 26, 22) + + def paintEvent(self, _event): # pylint: disable=invalid-name + painter = QPainter(self) + painter.setRenderHint(QPainter.RenderHint.Antialiasing) + rect = QRectF(self.rect()) + background = QColor(self._tone) + background.setAlphaF(0.16) + painter.setPen(Qt.PenStyle.NoPen) + painter.setBrush(background) + painter.drawRoundedRect(rect, rect.height() / 2, rect.height() / 2) + dot = QColor(self._tone) + dot.setAlphaF(self._dot_opacity) + painter.setBrush(dot) + painter.drawEllipse(QRectF(9, rect.height() / 2 - 3.5, 7, 7)) + painter.setPen(self._tone) + painter.drawText(rect.adjusted(22, 0, 0, 0), Qt.AlignmentFlag.AlignVCenter, self._text) + painter.end() + + def cleanup(self) -> None: + """Stop the pulse animation.""" + self._pulse.stop() + + +class Card(QFrame): + """Rounded surface with an optional title row, matching ``BecUi.Card``.""" + + def __init__(self, title: str = "", parent: QWidget | None = None, padding: int = 12): + super().__init__(parent) + self.setObjectName("uxCard") + outer = QVBoxLayout(self) + outer.setContentsMargins(padding, padding, padding, padding) + outer.setSpacing(10) + self.header = QHBoxLayout() + self.header.setSpacing(8) + self.title_label = QLabel(title, self) + self.subtitle_label = QLabel("", self) + self.subtitle_label.setVisible(False) + self.header.addWidget(self.title_label) + self.header.addWidget(self.subtitle_label, 1) + self.header_extras = QHBoxLayout() + self.header_extras.setSpacing(4) + self.header.addLayout(self.header_extras) + outer.addLayout(self.header) + self.body = QVBoxLayout() + self.body.setSpacing(8) + outer.addLayout(self.body) + self._has_title = bool(title) + self._update_header_visibility() + self.refresh_theme(ThemeTokens()) + + def set_title(self, title: str, subtitle: str = "") -> None: + """Set the title and an optional muted subtitle.""" + self._has_title = bool(title) + self.title_label.setText(title) + self.subtitle_label.setText(subtitle) + self.subtitle_label.setVisible(bool(subtitle)) + self._update_header_visibility() + + def add_header_widget(self, widget: QWidget) -> None: + """Add a widget to the right end of the title row.""" + self.header_extras.addWidget(widget) + self._has_title = True + self._update_header_visibility() + + def _update_header_visibility(self) -> None: + self.title_label.setVisible(bool(self.title_label.text())) + + def refresh_theme(self, tokens: ThemeTokens) -> None: + """Apply the theme tokens.""" + self.setStyleSheet( + f"QFrame#uxCard {{ background: {tokens.card.name()};" + f" border: 1px solid {tokens.border.name()}; border-radius: 10px; }}" + ) + self.title_label.setStyleSheet( + f"color: {tokens.fg.name()}; font-size: 13px; font-weight: 600; background: none;" + ) + self.subtitle_label.setStyleSheet( + f"color: {tokens.fg_subtle.name()}; font-size: 12px; background: none;" + ) + + +class PortedPropertiesMixin: + """Make a port export and load the ``SafeProperty`` settings of the class it ports. + + ``BECConnector`` only collects the properties defined directly on ``type(self)``, so a + subclass would silently drop the inherited ones (e.g. ``ring_json``) from saved profiles. + Set ``PORTED_FROM`` to the original class; its properties and those of every class in + between are collected. + """ + + PORTED_FROM: type | None = None + + def _get_bec_meta_objects(self) -> dict: + objects = {} + for cls in type(self).__mro__: + for name, attr in vars(cls).items(): + if name in objects or not isinstance(attr, Property): + continue + if getattr(attr.fget, "__is_safe_getter__", False): + objects[name] = attr + if cls is self.PORTED_FROM: + break + return objects + + +class ToggleSwitch(QPushButton): + """Checkable switch painted like ``BecUi.SwitchField``; the label shows On or Off.""" + + def __init__(self, parent: QWidget | None = None): + super().__init__(parent) + self.setCheckable(True) + self.setCursor(Qt.CursorShape.PointingHandCursor) + self.setMinimumHeight(32) + self.setFixedWidth(84) + self._knob = 0.0 + self._animation = QPropertyAnimation(self, b"knob", self) + self._animation.setDuration(120) + self._animation.setEasingCurve(QEasingCurve.Type.OutCubic) + self.toggled.connect(self._animate) + + def _animate(self, checked: bool) -> None: + self._animation.stop() + self._animation.setStartValue(self._knob) + self._animation.setEndValue(1.0 if checked else 0.0) + self._animation.start() + + def setChecked(self, checked: bool) -> None: # pylint: disable=invalid-name + """Set the state without animating.""" + self.blockSignals(True) + super().setChecked(checked) + self.blockSignals(False) + self._animation.stop() + self._knob = 1.0 if checked else 0.0 + self.update() + + def _get_knob(self) -> float: + return self._knob + + def _set_knob(self, value: float) -> None: + self._knob = value + self.update() + + knob = Property(float, _get_knob, _set_knob) + + def paintEvent(self, _event): # pylint: disable=invalid-name + tokens = ThemeTokens() + painter = QPainter(self) + painter.setRenderHint(QPainter.RenderHint.Antialiasing) + track = QRectF(0, (self.height() - 22) / 2, 38, 22) + painter.setPen(tokens.primary if self.isChecked() else tokens.border) + painter.setBrush(tokens.primary if self.isChecked() else tokens.track) + painter.drawRoundedRect(track, 11, 11) + painter.setPen(Qt.PenStyle.NoPen) + painter.setBrush(tokens.on_primary if self.isChecked() else tokens.fg_muted) + painter.drawEllipse(QRectF(track.left() + 3 + self._knob * 16, track.top() + 3, 16, 16)) + painter.setPen(tokens.fg) + font = self.font() + font.setPixelSize(13) + painter.setFont(font) + painter.drawText( + QRectF(track.right() + 8, 0, self.width() - track.right() - 8, self.height()), + Qt.AlignmentFlag.AlignVCenter, + "On" if self.isChecked() else "Off", + ) + painter.end() + + +class SuffixLineEdit(QLineEdit): + """Line edit that paints a unit suffix inside its right edge, like ``BecUi.InputField``.""" + + def __init__(self, parent: QWidget | None = None): + super().__init__(parent) + self._suffix = "" + + def set_suffix(self, suffix: str) -> None: + """Set the unit shown at the right edge.""" + self._suffix = suffix or "" + width = self.fontMetrics().horizontalAdvance(self._suffix) + 10 if self._suffix else 0 + self.setTextMargins(0, 0, width, 0) + self.update() + + def paintEvent(self, event): # pylint: disable=invalid-name + super().paintEvent(event) + if not self._suffix: + return + painter = QPainter(self) + painter.setPen(ThemeTokens().fg_subtle) + font = self.font() + font.setPixelSize(12) + painter.setFont(font) + painter.drawText( + self.rect().adjusted(0, 0, -9, 0), + Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter, + self._suffix, + ) + painter.end()