mirror of
https://github.com/bec-project/bec_widgets.git
synced 2026-10-28 08:01:29 +01:00
chore(quick): bring in the shared QML host and ux kit from claude/qml-widget-ports-s7h02q
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Kns5hgJ1MEXNfFeCuTESFP
This commit is contained in:
14 files changed
+1166
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,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
|
||||
}
|
||||
@@ -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,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()
|
||||
Reference in new issue
Block a user